diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9e2d5a2..a4c5992 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -9,6 +9,7 @@ plugins { id("org.jetbrains.kotlin.plugin.serialization") version "2.1.20" alias(libs.plugins.kotlin.ksp) id("com.diffplug.spotless") version "8.2.1" + alias(libs.plugins.kover) } val localProperties = Properties() @@ -150,6 +151,22 @@ android { } } } + +kover { + reports { + filters { + excludes { + classes( + "*.BuildConfig", + "*.ComposableSingletons*", + "*_Impl", + "*Database_Impl", + "*Dao_Impl" + ) + } + } + } +} //noinspection UseTomlInstead dependencies { @@ -220,7 +237,6 @@ dependencies { implementation("com.jakewharton.timber:timber:5.0.1") - implementation("com.tom-roush:pdfbox-android:2.0.27.0") implementation("me.zhanghai.android.libarchive:library:1.1.6") implementation("androidx.paging:paging-runtime-ktx:3.3.6") @@ -252,6 +268,8 @@ dependencies { testImplementation("junit:junit:4.13.2") testImplementation("io.mockk:mockk-android:1.14.9") testImplementation(libs.kotlinx.coroutines.test) + testImplementation("org.json:json:20251224") + testImplementation("org.robolectric:robolectric:4.16.1") testImplementation("org.slf4j:slf4j-nop:2.0.17") } diff --git a/app/src/main/cpp/pdfium_bridge.cpp b/app/src/main/cpp/pdfium_bridge.cpp index c389fc7..e56b5fe 100644 --- a/app/src/main/cpp/pdfium_bridge.cpp +++ b/app/src/main/cpp/pdfium_bridge.cpp @@ -1,6 +1,11 @@ #include #include #include +#include +#include +#include +#include +#include #include #include #include @@ -11,6 +16,34 @@ #define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__) #define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) +struct FS_RECTF_BRIDGE { + float left; + float top; + float right; + float bottom; +}; + +struct FS_POINTF_BRIDGE { + float x; + float y; +}; + +struct FS_QUADPOINTSF_BRIDGE { + float x1; + float y1; + float x2; + float y2; + float x3; + float y3; + float x4; + float y4; +}; + +struct FPDF_FILEWRITE_BRIDGE { + int version; + int (*WriteBlock)(FPDF_FILEWRITE_BRIDGE* self, const void* data, unsigned long size); +}; + typedef double (*FPDFText_GetFontSize_t)(void* text_page, int index); typedef int (*FPDFText_GetFontWeight_t)(void* text_page, int index); typedef int (*FPDFText_GetFontInfo_t)(void* text_page, int index, void* buffer, unsigned long buflen, int* flags); @@ -25,6 +58,7 @@ typedef int (*FPDFPage_CountObjects_t)(void* page); typedef void* (*FPDFPage_GetObject_t)(void* page, int index); typedef int (*FPDFPageObj_GetType_t)(void* page_object); typedef void* (*FPDFImageObj_GetBitmap_t)(void* image_object); +typedef void* (*FPDFBitmap_CreateEx_t)(int width, int height, int format, void* first_scan, int stride); typedef int (*FPDFBitmap_GetWidth_t)(void* bitmap); typedef int (*FPDFBitmap_GetHeight_t)(void* bitmap); typedef int (*FPDFBitmap_GetStride_t)(void* bitmap); @@ -45,6 +79,42 @@ typedef void* (*FPDFLink_GetDest_t)(void* document, void* link); typedef void* (*FPDFAction_GetDest_t)(void* document, void* action); typedef int (*FPDFDest_GetDestPageIndex_t)(void* document, void* dest); typedef unsigned long (*FPDFAction_GetFilePath_t)(void* action, void* buffer, unsigned long buflen); +typedef void* (*FPDF_LoadDocument_t)(const char* file_path, const char* password); +typedef void (*FPDF_CloseDocument_t)(void* document); +typedef int (*FPDF_GetPageCount_t)(void* document); +typedef void* (*FPDF_LoadPage_t)(void* document, int page_index); +typedef void (*FPDF_ClosePage_t)(void* page); +typedef float (*FPDF_GetPageWidthF_t)(void* page); +typedef float (*FPDF_GetPageHeightF_t)(void* page); +typedef double (*FPDF_GetPageWidth_t)(void* page); +typedef double (*FPDF_GetPageHeight_t)(void* page); +typedef void* (*FPDFPage_CreateAnnot_t)(void* page, int subtype); +typedef int (*FPDFAnnot_SetRect_t)(void* annot, const FS_RECTF_BRIDGE* rect); +typedef int (*FPDFAnnot_SetColor_t)(void* annot, int type, unsigned int R, unsigned int G, unsigned int B, unsigned int A); +typedef int (*FPDFAnnot_SetBorder_t)(void* annot, float horizontal_radius, float vertical_radius, float border_width); +typedef int (*FPDFAnnot_SetStringValue_t)(void* annot, const char* key, const unsigned short* value); +typedef int (*FPDFAnnot_AddInkStroke_t)(void* annot, const FS_POINTF_BRIDGE* points, size_t point_count); +typedef int (*FPDFAnnot_AppendAttachmentPoints_t)(void* annot, const FS_QUADPOINTSF_BRIDGE* quad_points); +typedef void (*FPDFPage_InsertObject_t)(void* page, void* page_object); +typedef void* (*FPDFPageObj_NewImageObj_t)(void* document); +typedef int (*FPDFImageObj_SetMatrix_t)(void* image_object, double a, double b, double c, double d, double e, double f); +typedef int (*FPDFImageObj_SetBitmap_t)(void** pages, int nCount, void* image_object, void* bitmap); +typedef void* (*FPDFPageObj_NewTextObj_t)(void* document, const char* font, float font_size); +typedef void* (*FPDFPageObj_CreateTextObj_t)(void* document, void* font, float font_size); +typedef void* (*FPDFText_LoadFont_t)(void* document, const unsigned char* data, unsigned int size, int font_type, int cid); +typedef void* (*FPDFText_LoadStandardFont_t)(void* document, const char* font); +typedef int (*FPDFText_SetText_t)(void* text_object, const unsigned short* text); +typedef int (*FPDFPageObj_SetFillColor_t)(void* page_object, unsigned int R, unsigned int G, unsigned int B, unsigned int A); +typedef int (*FPDFPageObj_SetStrokeColor_t)(void* page_object, unsigned int R, unsigned int G, unsigned int B, unsigned int A); +typedef int (*FPDFPageObj_SetStrokeWidth_t)(void* page_object, float width); +typedef void (*FPDFPageObj_Transform_t)(void* page_object, double a, double b, double c, double d, double e, double f); +typedef void* (*FPDFPageObj_CreateNewRect_t)(float x, float y, float w, float h); +typedef void* (*FPDFPageObj_CreateNewPath_t)(float x, float y); +typedef int (*FPDFPath_LineTo_t)(void* path, float x, float y); +typedef int (*FPDFPath_SetDrawMode_t)(void* path, int fillmode, int stroke); +typedef void (*FPDFPageObj_Destroy_t)(void* page_object); +typedef int (*FPDFPage_GenerateContent_t)(void* page); +typedef int (*FPDF_SaveAsCopy_t)(void* document, FPDF_FILEWRITE_BRIDGE* file_write, unsigned long flags); static FPDFLink_GetLinkAtPoint_t get_link_at_point_func = nullptr; static FPDFAction_GetURIPath_t get_uri_path_func = nullptr; @@ -62,6 +132,7 @@ static FPDFPage_CountObjects_t count_objects_func = nullptr; static FPDFPage_GetObject_t get_object_func = nullptr; static FPDFPageObj_GetType_t get_object_type_func = nullptr; static FPDFImageObj_GetBitmap_t get_image_bitmap_func = nullptr; +static FPDFBitmap_CreateEx_t bitmap_create_ex_func = nullptr; static FPDFBitmap_GetWidth_t bitmap_get_width_func = nullptr; static FPDFBitmap_GetHeight_t bitmap_get_height_func = nullptr; static FPDFBitmap_GetStride_t bitmap_get_stride_func = nullptr; @@ -88,6 +159,42 @@ typedef void (*FPDFPage_CloseAnnot_t)(void* annot); static FPDFAnnot_GetLinkedAnnot_t get_linked_annot_func = nullptr; static FPDFPage_CloseAnnot_t close_annot_func = nullptr; +static FPDF_LoadDocument_t load_document_func = nullptr; +static FPDF_CloseDocument_t close_document_func = nullptr; +static FPDF_GetPageCount_t get_page_count_func = nullptr; +static FPDF_LoadPage_t load_page_func = nullptr; +static FPDF_ClosePage_t close_page_func = nullptr; +static FPDF_GetPageWidthF_t get_page_width_func = nullptr; +static FPDF_GetPageHeightF_t get_page_height_func = nullptr; +static FPDF_GetPageWidth_t get_page_width_double_func = nullptr; +static FPDF_GetPageHeight_t get_page_height_double_func = nullptr; +static FPDFPage_CreateAnnot_t create_annot_func = nullptr; +static FPDFAnnot_SetRect_t set_annot_rect_func = nullptr; +static FPDFAnnot_SetColor_t set_annot_color_func = nullptr; +static FPDFAnnot_SetBorder_t set_annot_border_func = nullptr; +static FPDFAnnot_SetStringValue_t set_annot_string_value_func = nullptr; +static FPDFAnnot_AddInkStroke_t add_ink_stroke_func = nullptr; +static FPDFAnnot_AppendAttachmentPoints_t append_attachment_points_func = nullptr; +static FPDFPage_InsertObject_t insert_page_object_func = nullptr; +static FPDFPageObj_NewImageObj_t new_image_object_func = nullptr; +static FPDFImageObj_SetMatrix_t set_image_matrix_func = nullptr; +static FPDFImageObj_SetBitmap_t set_image_bitmap_func = nullptr; +static FPDFPageObj_NewTextObj_t new_text_object_func = nullptr; +static FPDFPageObj_CreateTextObj_t create_text_object_func = nullptr; +static FPDFText_LoadFont_t load_font_func = nullptr; +static FPDFText_LoadStandardFont_t load_standard_font_func = nullptr; +static FPDFText_SetText_t set_text_object_text_func = nullptr; +static FPDFPageObj_SetFillColor_t set_page_object_fill_color_func = nullptr; +static FPDFPageObj_SetStrokeColor_t set_page_object_stroke_color_func = nullptr; +static FPDFPageObj_SetStrokeWidth_t set_page_object_stroke_width_func = nullptr; +static FPDFPageObj_Transform_t transform_page_object_func = nullptr; +static FPDFPageObj_CreateNewRect_t create_rect_object_func = nullptr; +static FPDFPageObj_CreateNewPath_t create_path_object_func = nullptr; +static FPDFPath_LineTo_t path_line_to_func = nullptr; +static FPDFPath_SetDrawMode_t path_set_draw_mode_func = nullptr; +static FPDFPageObj_Destroy_t destroy_page_object_func = nullptr; +static FPDFPage_GenerateContent_t generate_content_func = nullptr; +static FPDF_SaveAsCopy_t save_as_copy_func = nullptr; static bool init_pdfium() { if (pdfium_handle) return true; @@ -115,6 +222,42 @@ static bool init_pdfium() { 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"); + load_document_func = (FPDF_LoadDocument_t) dlsym(pdfium_handle, "FPDF_LoadDocument"); + close_document_func = (FPDF_CloseDocument_t) dlsym(pdfium_handle, "FPDF_CloseDocument"); + get_page_count_func = (FPDF_GetPageCount_t) dlsym(pdfium_handle, "FPDF_GetPageCount"); + load_page_func = (FPDF_LoadPage_t) dlsym(pdfium_handle, "FPDF_LoadPage"); + close_page_func = (FPDF_ClosePage_t) dlsym(pdfium_handle, "FPDF_ClosePage"); + get_page_width_func = (FPDF_GetPageWidthF_t) dlsym(pdfium_handle, "FPDF_GetPageWidthF"); + get_page_height_func = (FPDF_GetPageHeightF_t) dlsym(pdfium_handle, "FPDF_GetPageHeightF"); + get_page_width_double_func = (FPDF_GetPageWidth_t) dlsym(pdfium_handle, "FPDF_GetPageWidth"); + get_page_height_double_func = (FPDF_GetPageHeight_t) dlsym(pdfium_handle, "FPDF_GetPageHeight"); + create_annot_func = (FPDFPage_CreateAnnot_t) dlsym(pdfium_handle, "FPDFPage_CreateAnnot"); + set_annot_rect_func = (FPDFAnnot_SetRect_t) dlsym(pdfium_handle, "FPDFAnnot_SetRect"); + set_annot_color_func = (FPDFAnnot_SetColor_t) dlsym(pdfium_handle, "FPDFAnnot_SetColor"); + set_annot_border_func = (FPDFAnnot_SetBorder_t) dlsym(pdfium_handle, "FPDFAnnot_SetBorder"); + set_annot_string_value_func = (FPDFAnnot_SetStringValue_t) dlsym(pdfium_handle, "FPDFAnnot_SetStringValue"); + add_ink_stroke_func = (FPDFAnnot_AddInkStroke_t) dlsym(pdfium_handle, "FPDFAnnot_AddInkStroke"); + append_attachment_points_func = (FPDFAnnot_AppendAttachmentPoints_t) dlsym(pdfium_handle, "FPDFAnnot_AppendAttachmentPoints"); + insert_page_object_func = (FPDFPage_InsertObject_t) dlsym(pdfium_handle, "FPDFPage_InsertObject"); + new_image_object_func = (FPDFPageObj_NewImageObj_t) dlsym(pdfium_handle, "FPDFPageObj_NewImageObj"); + set_image_matrix_func = (FPDFImageObj_SetMatrix_t) dlsym(pdfium_handle, "FPDFImageObj_SetMatrix"); + set_image_bitmap_func = (FPDFImageObj_SetBitmap_t) dlsym(pdfium_handle, "FPDFImageObj_SetBitmap"); + new_text_object_func = (FPDFPageObj_NewTextObj_t) dlsym(pdfium_handle, "FPDFPageObj_NewTextObj"); + create_text_object_func = (FPDFPageObj_CreateTextObj_t) dlsym(pdfium_handle, "FPDFPageObj_CreateTextObj"); + load_font_func = (FPDFText_LoadFont_t) dlsym(pdfium_handle, "FPDFText_LoadFont"); + load_standard_font_func = (FPDFText_LoadStandardFont_t) dlsym(pdfium_handle, "FPDFText_LoadStandardFont"); + set_text_object_text_func = (FPDFText_SetText_t) dlsym(pdfium_handle, "FPDFText_SetText"); + set_page_object_fill_color_func = (FPDFPageObj_SetFillColor_t) dlsym(pdfium_handle, "FPDFPageObj_SetFillColor"); + set_page_object_stroke_color_func = (FPDFPageObj_SetStrokeColor_t) dlsym(pdfium_handle, "FPDFPageObj_SetStrokeColor"); + set_page_object_stroke_width_func = (FPDFPageObj_SetStrokeWidth_t) dlsym(pdfium_handle, "FPDFPageObj_SetStrokeWidth"); + transform_page_object_func = (FPDFPageObj_Transform_t) dlsym(pdfium_handle, "FPDFPageObj_Transform"); + create_rect_object_func = (FPDFPageObj_CreateNewRect_t) dlsym(pdfium_handle, "FPDFPageObj_CreateNewRect"); + create_path_object_func = (FPDFPageObj_CreateNewPath_t) dlsym(pdfium_handle, "FPDFPageObj_CreateNewPath"); + path_line_to_func = (FPDFPath_LineTo_t) dlsym(pdfium_handle, "FPDFPath_LineTo"); + path_set_draw_mode_func = (FPDFPath_SetDrawMode_t) dlsym(pdfium_handle, "FPDFPath_SetDrawMode"); + destroy_page_object_func = (FPDFPageObj_Destroy_t) dlsym(pdfium_handle, "FPDFPageObj_Destroy"); + generate_content_func = (FPDFPage_GenerateContent_t) dlsym(pdfium_handle, "FPDFPage_GenerateContent"); + save_as_copy_func = (FPDF_SaveAsCopy_t) dlsym(pdfium_handle, "FPDF_SaveAsCopy"); // --- Object & Bitmap Functions --- count_objects_func = (FPDFPage_CountObjects_t) dlsym(pdfium_handle, "FPDFPage_CountObjects"); @@ -122,6 +265,7 @@ static bool init_pdfium() { 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"); + bitmap_create_ex_func = (FPDFBitmap_CreateEx_t) dlsym(pdfium_handle, "FPDFBitmap_CreateEx"); 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_stride_func = (FPDFBitmap_GetStride_t) dlsym(pdfium_handle, "FPDFBitmap_GetStride"); @@ -164,6 +308,28 @@ static bool init_pdfium() { get_link_action_func, do_annot_action_func, get_widget_at_point_func); } + if (!load_document_func || !load_page_func || !create_annot_func || !save_as_copy_func) { + LOGE("PdfiumExport: Missing export functions. LoadDoc=%p LoadPage=%p CreateAnnot=%p Save=%p", + load_document_func, load_page_func, create_annot_func, save_as_copy_func); + } + + if (!insert_page_object_func || !set_text_object_text_func || + !set_page_object_fill_color_func || !transform_page_object_func || !generate_content_func) { + LOGE("PdfiumExport: Missing text object functions. InsertObj=%p NewText=%p CreateText=%p SetText=%p Fill=%p Transform=%p Generate=%p", + insert_page_object_func, new_text_object_func, create_text_object_func, + set_text_object_text_func, set_page_object_fill_color_func, + transform_page_object_func, generate_content_func); + } + + if (!insert_page_object_func || !new_image_object_func || !set_image_bitmap_func || + !bitmap_create_ex_func || !bitmap_destroy_func || (!set_image_matrix_func && !transform_page_object_func) || + !generate_content_func) { + LOGE("PdfiumExport: Missing raster image functions. InsertObj=%p NewImage=%p SetBitmap=%p SetMatrix=%p CreateBitmap=%p DestroyBitmap=%p Transform=%p Generate=%p", + insert_page_object_func, new_image_object_func, set_image_bitmap_func, + set_image_matrix_func, bitmap_create_ex_func, bitmap_destroy_func, + transform_page_object_func, generate_content_func); + } + return get_annot_count_func != nullptr; } @@ -386,6 +552,1035 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_extractImagePixels(JNIEnv *env, jcl return result; } +static constexpr int kPdfAnnotHighlight = 9; +static constexpr int kPdfAnnotInk = 15; +static constexpr int kAnnotColor = 0; +static constexpr int kAnnotFlagPrint = 1 << 2; +static constexpr unsigned long kPdfNoIncremental = 1 << 1; +static constexpr int kTextFlagBold = 1; +static constexpr int kTextFlagItalic = 1 << 1; +static constexpr int kTextFlagUnderline = 1 << 2; +static constexpr int kTextFlagStrikeThrough = 1 << 3; +static constexpr int kTextFlagAbsoluteLine = 1 << 4; +static constexpr int kPdfFontTrueType = 2; +static constexpr int kPdfBitmapBgra = 4; + +struct PdfiumFileWriter { + FPDF_FILEWRITE_BRIDGE base; + FILE* file; +}; + +static int write_pdf_block(FPDF_FILEWRITE_BRIDGE* self, const void* data, unsigned long size) { + auto* writer = reinterpret_cast(self); + if (!writer || !writer->file || !data) return 0; + return fwrite(data, 1, size, writer->file) == size ? 1 : 0; +} + +static std::vector read_int_array(JNIEnv* env, jintArray array) { + std::vector values; + if (!array) return values; + jsize length = env->GetArrayLength(array); + values.resize(length); + if (length > 0) env->GetIntArrayRegion(array, 0, length, values.data()); + return values; +} + +static std::vector read_float_array(JNIEnv* env, jfloatArray array) { + std::vector values; + if (!array) return values; + jsize length = env->GetArrayLength(array); + values.resize(length); + if (length > 0) env->GetFloatArrayRegion(array, 0, length, values.data()); + return values; +} + +static std::string jstring_to_utf8(JNIEnv* env, jstring value) { + if (!value) return ""; + const char* chars = env->GetStringUTFChars(value, nullptr); + if (!chars) return ""; + std::string result(chars); + env->ReleaseStringUTFChars(value, chars); + return result; +} + +static std::vector read_string_array(JNIEnv* env, jobjectArray array) { + std::vector values; + if (!array) return values; + jsize length = env->GetArrayLength(array); + values.reserve(static_cast(length)); + for (jsize i = 0; i < length; i++) { + auto value = static_cast(env->GetObjectArrayElement(array, i)); + values.push_back(jstring_to_utf8(env, value)); + if (value) env->DeleteLocalRef(value); + } + return values; +} + +static bool set_annot_string_from_jstring(JNIEnv* env, void* annot, const char* key, jstring value) { + if (!set_annot_string_value_func || !annot || !key || !value) return false; + jsize length = env->GetStringLength(value); + const jchar* chars = env->GetStringChars(value, nullptr); + if (!chars) return false; + + std::vector wide(static_cast(length) + 1); + for (jsize i = 0; i < length; i++) { + wide[static_cast(i)] = static_cast(chars[i]); + } + wide[static_cast(length)] = 0; + env->ReleaseStringChars(value, chars); + + return set_annot_string_value_func(annot, key, wide.data()) != 0; +} + +static bool set_annot_string_from_ascii(void* annot, const char* key, const std::string& value) { + if (!set_annot_string_value_func || !annot || !key) return false; + std::vector wide(value.size() + 1); + for (size_t i = 0; i < value.size(); i++) { + wide[i] = static_cast(static_cast(value[i])); + } + wide[value.size()] = 0; + return set_annot_string_value_func(annot, key, wide.data()) != 0; +} + +static void argb_to_rgba(jint color, unsigned int* r, unsigned int* g, unsigned int* b, unsigned int* a) { + unsigned int argb = static_cast(color); + *a = (argb >> 24) & 0xFF; + *r = (argb >> 16) & 0xFF; + *g = (argb >> 8) & 0xFF; + *b = argb & 0xFF; +} + +static float clamp_unit(float value) { + if (!std::isfinite(value)) return 0.0f; + if (value < 0.0f) return 0.0f; + if (value > 1.0f) return 1.0f; + return value; +} + +static FS_RECTF_BRIDGE make_pdf_rect(float left, float top, float right, float bottom, float padding) { + float l = std::min(left, right) - padding; + float r = std::max(left, right) + padding; + float t = std::max(top, bottom) + padding; + float b = std::min(top, bottom) - padding; + return FS_RECTF_BRIDGE{l, t, r, b}; +} + +static float get_page_width_bridge(void* page) { + if (get_page_width_func) return get_page_width_func(page); + if (get_page_width_double_func) return static_cast(get_page_width_double_func(page)); + return 0.0f; +} + +static float get_page_height_bridge(void* page) { + if (get_page_height_func) return get_page_height_func(page); + if (get_page_height_double_func) return static_cast(get_page_height_double_func(page)); + return 0.0f; +} + +static bool validate_export_functions() { + return load_document_func && + close_document_func && + get_page_count_func && + load_page_func && + close_page_func && + (get_page_width_func || get_page_width_double_func) && + (get_page_height_func || get_page_height_double_func) && + create_annot_func && + close_annot_func && + set_annot_rect_func && + set_annot_color_func && + set_annot_border_func && + set_annot_string_value_func && + add_ink_stroke_func && + append_attachment_points_func && + save_as_copy_func; +} + +static bool validate_text_object_functions() { + return insert_page_object_func && + (new_text_object_func || create_text_object_func) && + set_text_object_text_func && + set_page_object_fill_color_func && + transform_page_object_func && + generate_content_func; +} + +static bool validate_raster_image_functions() { + return insert_page_object_func && + new_image_object_func && + set_image_bitmap_func && + bitmap_create_ex_func && + bitmap_destroy_func && + (set_image_matrix_func || transform_page_object_func) && + generate_content_func; +} + +static std::vector> split_jstring_lines_wide(JNIEnv* env, jstring value) { + std::vector> lines; + lines.emplace_back(); + if (!value) { + lines.back().push_back(0); + return lines; + } + + jsize length = env->GetStringLength(value); + const jchar* chars = env->GetStringChars(value, nullptr); + if (!chars) { + lines.back().push_back(0); + return lines; + } + + for (jsize i = 0; i < length; i++) { + jchar ch = chars[i]; + if (ch == '\n') { + lines.emplace_back(); + } else if (ch != '\r') { + lines.back().push_back(static_cast(ch)); + } + } + env->ReleaseStringChars(value, chars); + + for (auto& line : lines) { + line.push_back(0); + } + return lines; +} + +static bool is_wide_space(unsigned short value) { + return value == static_cast(' ') || + value == static_cast('\t') || + value == static_cast('\v') || + value == static_cast('\f'); +} + +static void push_wide_slice( + std::vector>& lines, + const std::vector& source, + size_t start, + size_t end) { + std::vector line; + if (start < end && start < source.size()) { + end = std::min(end, source.size()); + line.insert(line.end(), source.begin() + static_cast(start), source.begin() + static_cast(end)); + } + line.push_back(0); + lines.push_back(std::move(line)); +} + +static std::vector> wrap_wide_lines( + const std::vector>& source_lines, + float max_width, + float font_size, + bool preserve_lines) { + if (preserve_lines || max_width <= 1.0f || font_size <= 0.0f) { + return source_lines; + } + + int max_chars = static_cast(std::floor(max_width / std::max(1.0f, font_size * 0.55f))); + max_chars = std::max(1, max_chars); + + std::vector> wrapped; + for (const auto& source_line : source_lines) { + if (source_line.size() <= 1) { + wrapped.push_back(source_line); + continue; + } + + size_t length = source_line.size() - 1; + size_t start = 0; + while (start < length) { + size_t end = std::min(length, start + static_cast(max_chars)); + if (end < length) { + size_t break_at = end; + for (size_t pos = end; pos > start; pos--) { + if (is_wide_space(source_line[pos - 1])) { + break_at = pos; + break; + } + } + end = break_at; + } + + if (end <= start) end = std::min(length, start + static_cast(max_chars)); + push_wide_slice(wrapped, source_line, start, end); + start = end; + } + } + + if (wrapped.empty()) { + wrapped.push_back(std::vector{0}); + } + return wrapped; +} + +static bool insert_page_object_or_destroy(void* page, void* object) { + if (!page || !object || !insert_page_object_func) { + if (object && destroy_page_object_func) destroy_page_object_func(object); + return false; + } + insert_page_object_func(page, object); + return true; +} + +static std::vector read_file_bytes(const std::string& path) { + std::vector bytes; + if (path.empty()) return bytes; + + FILE* file = fopen(path.c_str(), "rb"); + if (!file) return bytes; + if (fseek(file, 0, SEEK_END) != 0) { + fclose(file); + return bytes; + } + long size = ftell(file); + if (size <= 0) { + fclose(file); + return bytes; + } + rewind(file); + + bytes.resize(static_cast(size)); + size_t read = fread(bytes.data(), 1, bytes.size(), file); + fclose(file); + if (read != bytes.size()) bytes.clear(); + return bytes; +} + +static const char* standard_font_name(const std::string& font_name, int flags) { + bool bold = (flags & kTextFlagBold) != 0; + bool italic = (flags & kTextFlagItalic) != 0; + + if (font_name == "Serif") { + if (bold && italic) return "Times-BoldItalic"; + if (bold) return "Times-Bold"; + if (italic) return "Times-Italic"; + return "Times-Roman"; + } + if (font_name == "Monospace") { + if (bold && italic) return "Courier-BoldOblique"; + if (bold) return "Courier-Bold"; + if (italic) return "Courier-Oblique"; + return "Courier"; + } + if (bold && italic) return "Helvetica-BoldOblique"; + if (bold) return "Helvetica-Bold"; + if (italic || font_name == "Cursive") return "Helvetica-Oblique"; + return "Helvetica"; +} + +static void* create_pdfium_text_object( + void* document, + const std::string& font_path, + const std::string& font_name, + float font_size, + int flags) { + if (create_text_object_func && load_font_func && !font_path.empty()) { + std::vector font_bytes = read_file_bytes(font_path); + if (!font_bytes.empty()) { + void* font = load_font_func( + document, + font_bytes.data(), + static_cast(font_bytes.size()), + kPdfFontTrueType, + 1 + ); + if (font) { + void* text_object = create_text_object_func(document, font, font_size); + if (text_object) return text_object; + } + } + } + + const char* standard_name = standard_font_name(font_name, flags); + if (create_text_object_func && load_standard_font_func) { + void* font = load_standard_font_func(document, standard_name); + if (font) { + void* text_object = create_text_object_func(document, font, font_size); + if (text_object) return text_object; + } + } + + if (new_text_object_func) { + return new_text_object_func(document, standard_name, font_size); + } + return nullptr; +} + +static bool insert_background_rect_object( + void* page, + float left, + float bottom, + float width, + float height, + unsigned int r, + unsigned int g, + unsigned int b, + unsigned int a) { + if (!create_rect_object_func || !set_page_object_fill_color_func || !path_set_draw_mode_func) { + return false; + } + if (width <= 0.0f || height <= 0.0f || a == 0) return false; + + void* background = create_rect_object_func(left, bottom, width, height); + if (!background) return false; + set_page_object_fill_color_func(background, r, g, b, a); + path_set_draw_mode_func(background, 1, 0); + return insert_page_object_or_destroy(page, background); +} + +static bool insert_decoration_line_object( + void* page, + float x1, + float y, + float x2, + unsigned int r, + unsigned int g, + unsigned int b, + unsigned int a, + float stroke_width) { + if (!create_path_object_func || !path_line_to_func || !path_set_draw_mode_func || + !set_page_object_stroke_color_func || !set_page_object_stroke_width_func) { + return false; + } + + void* path = create_path_object_func(x1, y); + if (!path) return false; + path_line_to_func(path, x2, y); + set_page_object_stroke_color_func(path, r, g, b, a); + set_page_object_stroke_width_func(path, stroke_width); + path_set_draw_mode_func(path, 0, 1); + return insert_page_object_or_destroy(page, path); +} + +static bool insert_text_line_object( + void* document, + void* page, + const std::vector& wide_line, + float x, + float y, + float font_size, + unsigned int r, + unsigned int g, + unsigned int b, + unsigned int a, + int flags, + const std::string& font_path, + const std::string& font_name) { + if (wide_line.size() <= 1) return true; + + void* text_object = create_pdfium_text_object(document, font_path, font_name, font_size, flags); + if (!text_object) { + LOGE("PdfiumExport: Failed to create text object fontPath=%s fontName=%s size=%.2f", + font_path.c_str(), font_name.c_str(), font_size); + return false; + } + if (!set_text_object_text_func(text_object, wide_line.data())) { + LOGE("PdfiumExport: Failed to set text object text fontPath=%s fontName=%s chars=%zu", + font_path.c_str(), font_name.c_str(), wide_line.size() > 0 ? wide_line.size() - 1 : 0); + if (destroy_page_object_func) destroy_page_object_func(text_object); + return false; + } + + set_page_object_fill_color_func(text_object, r, g, b, a); + float italicSkew = (flags & kTextFlagItalic) ? 0.22f : 0.0f; + transform_page_object_func(text_object, 1.0, 0.0, italicSkew, 1.0, x, y); + + bool inserted = insert_page_object_or_destroy(page, text_object); + if (inserted && (flags & kTextFlagBold)) { + void* bold_object = create_pdfium_text_object(document, font_path, font_name, font_size, flags); + if (bold_object && set_text_object_text_func(bold_object, wide_line.data())) { + set_page_object_fill_color_func(bold_object, r, g, b, a); + transform_page_object_func(bold_object, 1.0, 0.0, italicSkew, 1.0, x + std::max(0.35f, font_size * 0.035f), y); + insert_page_object_or_destroy(page, bold_object); + } else if (bold_object && destroy_page_object_func) { + destroy_page_object_func(bold_object); + } + } + + return inserted; +} + +extern "C" JNIEXPORT jboolean JNICALL +Java_com_aryan_reader_pdf_NativePdfiumBridge_exportAnnotatedPdf( + JNIEnv *env, + jclass clazz, + jstring sourcePath, + jstring destPath, + jintArray inkPageIndicesArray, + jintArray inkTypesArray, + jintArray inkColorsArray, + jfloatArray inkStrokeWidthsArray, + jintArray inkPointOffsetsArray, + jintArray inkPointCountsArray, + jfloatArray inkPointsArray, + jintArray textPageIndicesArray, + jfloatArray textBoundsArray, + jintArray textColorsArray, + jintArray textBackgroundColorsArray, + jfloatArray textFontSizesArray, + jintArray textFlagsArray, + jobjectArray textValuesArray, + jobjectArray textFontPathsArray, + jobjectArray textFontNamesArray, + jintArray rasterPageIndicesArray, + jfloatArray rasterBoundsArray, + jintArray rasterWidthsArray, + jintArray rasterHeightsArray, + jintArray rasterPixelOffsetsArray, + jintArray rasterPixelsArray, + jintArray highlightPageIndicesArray, + jintArray highlightColorsArray, + jintArray highlightRectOffsetsArray, + jintArray highlightRectCountsArray, + jfloatArray highlightRectsArray, + jobjectArray highlightContentsArray) { + std::lock_guard lock(g_pdfium_mutex); + + if (!init_pdfium() || !validate_export_functions()) { + LOGE("PdfiumExport: PDFium export functions are unavailable."); + return JNI_FALSE; + } + + std::string source = jstring_to_utf8(env, sourcePath); + std::string dest = jstring_to_utf8(env, destPath); + if (source.empty() || dest.empty()) { + LOGE("PdfiumExport: Missing source or destination path."); + return JNI_FALSE; + } + + std::vector inkPageIndices = read_int_array(env, inkPageIndicesArray); + std::vector inkTypes = read_int_array(env, inkTypesArray); + std::vector inkColors = read_int_array(env, inkColorsArray); + std::vector inkStrokeWidths = read_float_array(env, inkStrokeWidthsArray); + std::vector inkPointOffsets = read_int_array(env, inkPointOffsetsArray); + std::vector inkPointCounts = read_int_array(env, inkPointCountsArray); + std::vector inkPoints = read_float_array(env, inkPointsArray); + + std::vector textPageIndices = read_int_array(env, textPageIndicesArray); + std::vector textBounds = read_float_array(env, textBoundsArray); + std::vector textColors = read_int_array(env, textColorsArray); + std::vector textBackgroundColors = read_int_array(env, textBackgroundColorsArray); + std::vector textFontSizes = read_float_array(env, textFontSizesArray); + std::vector textFlags = read_int_array(env, textFlagsArray); + std::vector textFontPaths = read_string_array(env, textFontPathsArray); + std::vector textFontNames = read_string_array(env, textFontNamesArray); + if (!textPageIndices.empty() && !validate_text_object_functions()) { + LOGE("PdfiumExport: Text export functions are unavailable."); + return JNI_FALSE; + } + + std::vector rasterPageIndices = read_int_array(env, rasterPageIndicesArray); + std::vector rasterBounds = read_float_array(env, rasterBoundsArray); + std::vector rasterWidths = read_int_array(env, rasterWidthsArray); + std::vector rasterHeights = read_int_array(env, rasterHeightsArray); + std::vector rasterPixelOffsets = read_int_array(env, rasterPixelOffsetsArray); + jsize rasterPixelsLength = rasterPixelsArray ? env->GetArrayLength(rasterPixelsArray) : 0; + if (!rasterPageIndices.empty() && !validate_raster_image_functions()) { + LOGE("PdfiumExport: Raster image export functions are unavailable."); + return JNI_FALSE; + } + if (!rasterPageIndices.empty() && rasterPixelsLength <= 0) { + LOGE("PdfiumExport: Raster image payload is missing pixels."); + return JNI_FALSE; + } + + std::vector highlightPageIndices = read_int_array(env, highlightPageIndicesArray); + std::vector highlightColors = read_int_array(env, highlightColorsArray); + std::vector highlightRectOffsets = read_int_array(env, highlightRectOffsetsArray); + std::vector highlightRectCounts = read_int_array(env, highlightRectCountsArray); + std::vector highlightRects = read_float_array(env, highlightRectsArray); + + void* document = load_document_func(source.c_str(), nullptr); + if (!document) { + LOGE("PdfiumExport: Failed to load source PDF."); + return JNI_FALSE; + } + + int pageCount = get_page_count_func(document); + bool hadFailure = false; + jint* rasterPixels = nullptr; + std::vector rasterBitmapsToDestroy; + auto releaseRasterResources = [&]() { + for (void* bitmap : rasterBitmapsToDestroy) { + if (bitmap && bitmap_destroy_func) { + bitmap_destroy_func(bitmap); + } + } + rasterBitmapsToDestroy.clear(); + if (rasterPixels) { + env->ReleaseIntArrayElements(rasterPixelsArray, rasterPixels, JNI_ABORT); + rasterPixels = nullptr; + } + }; + + if (!rasterPageIndices.empty()) { + rasterPixels = env->GetIntArrayElements(rasterPixelsArray, nullptr); + if (!rasterPixels) { + LOGE("PdfiumExport: Unable to access raster image pixels."); + close_document_func(document); + return JNI_FALSE; + } + } + + for (size_t i = 0; i < inkPageIndices.size(); i++) { + if (i >= inkTypes.size() || i >= inkColors.size() || i >= inkStrokeWidths.size() || + i >= inkPointOffsets.size() || i >= inkPointCounts.size()) { + hadFailure = true; + break; + } + + int pageIndex = inkPageIndices[i]; + int pointOffset = inkPointOffsets[i]; + int pointCount = inkPointCounts[i]; + if (pageIndex < 0 || pageIndex >= pageCount || pointOffset < 0 || pointCount < 2 || + (pointOffset + pointCount) * 2 > static_cast(inkPoints.size())) { + hadFailure = true; + continue; + } + + void* page = load_page_func(document, pageIndex); + if (!page) { + hadFailure = true; + continue; + } + + float pageWidth = get_page_width_bridge(page); + float pageHeight = get_page_height_bridge(page); + if (pageWidth <= 0.0f || pageHeight <= 0.0f) { + close_page_func(page); + hadFailure = true; + continue; + } + + void* annot = create_annot_func(page, kPdfAnnotInk); + if (!annot) { + close_page_func(page); + hadFailure = true; + continue; + } + + std::vector points; + points.reserve(static_cast(pointCount)); + float minX = pageWidth; + float maxX = 0.0f; + float minY = pageHeight; + float maxY = 0.0f; + + for (int j = 0; j < pointCount; j++) { + int sourceIndex = (pointOffset + j) * 2; + float x = clamp_unit(inkPoints[sourceIndex]) * pageWidth; + float y = (1.0f - clamp_unit(inkPoints[sourceIndex + 1])) * pageHeight; + points.push_back(FS_POINTF_BRIDGE{x, y}); + minX = std::min(minX, x); + maxX = std::max(maxX, x); + minY = std::min(minY, y); + maxY = std::max(maxY, y); + } + + float strokeWidth = std::max(0.25f, inkStrokeWidths[i] * pageWidth); + FS_RECTF_BRIDGE rect = make_pdf_rect(minX, maxY, maxX, minY, strokeWidth * 1.5f); + set_annot_rect_func(annot, &rect); + + unsigned int r, g, b, a; + argb_to_rgba(inkColors[i], &r, &g, &b, &a); + if ((inkTypes[i] == 1 || inkTypes[i] == 2) && a == 255) { + a = 102; + } + set_annot_color_func(annot, kAnnotColor, r, g, b, a); + set_annot_border_func(annot, 0.0f, 0.0f, strokeWidth); + if (set_annot_flags_func) set_annot_flags_func(annot, kAnnotFlagPrint); + + if (add_ink_stroke_func(annot, points.data(), points.size()) < 0) { + hadFailure = true; + } + + set_annot_string_from_ascii(annot, "Contents", "Ink"); + if (generate_content_func) generate_content_func(page); + close_annot_func(annot); + close_page_func(page); + } + + for (size_t i = 0; i < highlightPageIndices.size(); i++) { + if (i >= highlightColors.size() || i >= highlightRectOffsets.size() || i >= highlightRectCounts.size()) { + hadFailure = true; + break; + } + + int pageIndex = highlightPageIndices[i]; + int rectOffset = highlightRectOffsets[i]; + int rectCount = highlightRectCounts[i]; + if (pageIndex < 0 || pageIndex >= pageCount || rectOffset < 0 || rectCount <= 0 || + (rectOffset + rectCount) * 4 > static_cast(highlightRects.size())) { + hadFailure = true; + continue; + } + + std::vector quads; + quads.reserve(static_cast(rectCount)); + float unionLeft = 0.0f; + float unionRight = 0.0f; + float unionTop = 0.0f; + float unionBottom = 0.0f; + + for (int j = 0; j < rectCount; j++) { + int sourceIndex = (rectOffset + j) * 4; + float left = std::min(highlightRects[sourceIndex], highlightRects[sourceIndex + 2]); + float right = std::max(highlightRects[sourceIndex], highlightRects[sourceIndex + 2]); + float top = std::max(highlightRects[sourceIndex + 1], highlightRects[sourceIndex + 3]); + float bottom = std::min(highlightRects[sourceIndex + 1], highlightRects[sourceIndex + 3]); + if (right <= left || top <= bottom) continue; + + quads.push_back(FS_QUADPOINTSF_BRIDGE{left, top, right, top, left, bottom, right, bottom}); + if (quads.size() == 1) { + unionLeft = left; + unionRight = right; + unionTop = top; + unionBottom = bottom; + } else { + unionLeft = std::min(unionLeft, left); + unionRight = std::max(unionRight, right); + unionTop = std::max(unionTop, top); + unionBottom = std::min(unionBottom, bottom); + } + } + + if (quads.empty()) { + hadFailure = true; + continue; + } + + void* page = load_page_func(document, pageIndex); + if (!page) { + hadFailure = true; + continue; + } + + void* annot = create_annot_func(page, kPdfAnnotHighlight); + if (!annot) { + close_page_func(page); + hadFailure = true; + continue; + } + + for (const FS_QUADPOINTSF_BRIDGE& quad : quads) { + if (!append_attachment_points_func(annot, &quad)) { + hadFailure = true; + } + } + FS_RECTF_BRIDGE rect = make_pdf_rect(unionLeft, unionTop, unionRight, unionBottom, 1.0f); + set_annot_rect_func(annot, &rect); + + unsigned int r, g, b, a; + argb_to_rgba(highlightColors[i], &r, &g, &b, &a); + if (a == 255) a = 102; + set_annot_color_func(annot, kAnnotColor, r, g, b, a); + if (set_annot_flags_func) set_annot_flags_func(annot, kAnnotFlagPrint); + + if (highlightContentsArray && i < static_cast(env->GetArrayLength(highlightContentsArray))) { + auto content = static_cast(env->GetObjectArrayElement(highlightContentsArray, static_cast(i))); + if (content) { + set_annot_string_from_jstring(env, annot, "Contents", content); + env->DeleteLocalRef(content); + } + } + + if (generate_content_func) generate_content_func(page); + close_annot_func(annot); + close_page_func(page); + } + + for (size_t i = 0; i < rasterPageIndices.size(); i++) { + if (i >= rasterWidths.size() || i >= rasterHeights.size() || i >= rasterPixelOffsets.size() || + (i + 1) * 4 > rasterBounds.size()) { + hadFailure = true; + break; + } + + int pageIndex = rasterPageIndices[i]; + int imageWidth = rasterWidths[i]; + int imageHeight = rasterHeights[i]; + int pixelOffset = rasterPixelOffsets[i]; + long long pixelCount = static_cast(imageWidth) * static_cast(imageHeight); + if (pageIndex < 0 || pageIndex >= pageCount || imageWidth <= 0 || imageHeight <= 0 || + pixelOffset < 0 || pixelCount <= 0 || + static_cast(pixelOffset) + pixelCount > static_cast(rasterPixelsLength)) { + LOGE("PdfiumExport: Invalid raster image payload index=%zu page=%d size=%dx%d offset=%d pixels=%d", + i, pageIndex, imageWidth, imageHeight, pixelOffset, rasterPixelsLength); + hadFailure = true; + continue; + } + + void* page = load_page_func(document, pageIndex); + if (!page) { + hadFailure = true; + continue; + } + + float pageWidth = get_page_width_bridge(page); + float pageHeight = get_page_height_bridge(page); + if (pageWidth <= 0.0f || pageHeight <= 0.0f) { + close_page_func(page); + hadFailure = true; + continue; + } + + float left = clamp_unit(rasterBounds[i * 4]) * pageWidth; + float top = (1.0f - clamp_unit(rasterBounds[i * 4 + 1])) * pageHeight; + float right = clamp_unit(rasterBounds[i * 4 + 2]) * pageWidth; + float bottom = (1.0f - clamp_unit(rasterBounds[i * 4 + 3])) * pageHeight; + FS_RECTF_BRIDGE rect = make_pdf_rect(left, top, right, bottom, 0.0f); + float rectWidth = rect.right - rect.left; + float rectHeight = rect.top - rect.bottom; + if (rectWidth <= 0.5f || rectHeight <= 0.5f) { + close_page_func(page); + hadFailure = true; + continue; + } + + void* imageObject = new_image_object_func(document); + if (!imageObject) { + close_page_func(page); + hadFailure = true; + continue; + } + + void* bitmap = bitmap_create_ex_func( + imageWidth, + imageHeight, + kPdfBitmapBgra, + reinterpret_cast(rasterPixels + pixelOffset), + imageWidth * 4 + ); + if (!bitmap) { + if (destroy_page_object_func) destroy_page_object_func(imageObject); + close_page_func(page); + hadFailure = true; + continue; + } + + void* pages[] = {page}; + if (!set_image_bitmap_func(pages, 1, imageObject, bitmap)) { + bitmap_destroy_func(bitmap); + if (destroy_page_object_func) destroy_page_object_func(imageObject); + close_page_func(page); + hadFailure = true; + continue; + } + + bool positioned = true; + if (set_image_matrix_func) { + positioned = set_image_matrix_func(imageObject, rectWidth, 0.0, 0.0, rectHeight, rect.left, rect.bottom) != 0; + } else { + transform_page_object_func(imageObject, rectWidth, 0.0, 0.0, rectHeight, rect.left, rect.bottom); + } + if (!positioned) { + bitmap_destroy_func(bitmap); + if (destroy_page_object_func) destroy_page_object_func(imageObject); + close_page_func(page); + hadFailure = true; + continue; + } + + if (!insert_page_object_or_destroy(page, imageObject)) { + bitmap_destroy_func(bitmap); + close_page_func(page); + hadFailure = true; + continue; + } + + rasterBitmapsToDestroy.push_back(bitmap); + if (!generate_content_func(page)) { + hadFailure = true; + } + close_page_func(page); + } + + for (size_t i = 0; i < textPageIndices.size(); i++) { + if (i >= textColors.size() || i >= textBackgroundColors.size() || i >= textFontSizes.size() || + i >= textFlags.size() || i >= textFontPaths.size() || i >= textFontNames.size() || + (i + 1) * 4 > textBounds.size()) { + hadFailure = true; + break; + } + + int pageIndex = textPageIndices[i]; + if (pageIndex < 0 || pageIndex >= pageCount) { + hadFailure = true; + continue; + } + + void* page = load_page_func(document, pageIndex); + if (!page) { + hadFailure = true; + continue; + } + + float pageWidth = get_page_width_bridge(page); + float pageHeight = get_page_height_bridge(page); + if (pageWidth <= 0.0f || pageHeight <= 0.0f) { + close_page_func(page); + hadFailure = true; + continue; + } + + float left = clamp_unit(textBounds[i * 4]) * pageWidth; + float top = (1.0f - clamp_unit(textBounds[i * 4 + 1])) * pageHeight; + float right = clamp_unit(textBounds[i * 4 + 2]) * pageWidth; + float bottom = (1.0f - clamp_unit(textBounds[i * 4 + 3])) * pageHeight; + FS_RECTF_BRIDGE rect = make_pdf_rect(left, top, right, bottom, 0.0f); + if (rect.right - rect.left <= 1.0f || rect.top - rect.bottom <= 1.0f) { + close_page_func(page); + hadFailure = true; + continue; + } + float rectWidth = std::max(1.0f, rect.right - rect.left); + bool preserveLines = (textFlags[i] & kTextFlagAbsoluteLine) != 0; + + unsigned int textR, textG, textB, textA; + argb_to_rgba(textColors[i], &textR, &textG, &textB, &textA); + unsigned int bgR, bgG, bgB, bgA; + argb_to_rgba(textBackgroundColors[i], &bgR, &bgG, &bgB, &bgA); + + float fontSize = textFontSizes[i] > 1.0f ? textFontSizes[i] : textFontSizes[i] * pageHeight; + if (fontSize <= 0.0f) fontSize = 12.0f; + + if (textValuesArray && i < static_cast(env->GetArrayLength(textValuesArray))) { + auto content = static_cast(env->GetObjectArrayElement(textValuesArray, static_cast(i))); + if (content) { + auto lines = wrap_wide_lines( + split_jstring_lines_wide(env, content), + preserveLines ? rectWidth : std::max(1.0f, rectWidth - 4.0f), + fontSize, + preserveLines + ); + float lineHeight = std::max(fontSize * 1.18f, fontSize + 2.0f); + float baseline = preserveLines ? top : rect.top - (fontSize * 0.85f); + float textX = preserveLines ? rect.left : rect.left + 2.0f; + bool insertedAnyText = false; + float decorationStroke = std::max(0.35f, fontSize * 0.035f); + + for (const auto& line : lines) { + if (!preserveLines && baseline < rect.bottom + 1.0f) break; + float lineVisualWidth = line.size() > 1 + ? std::min( + std::max(1.0f, rectWidth), + std::max(1.0f, static_cast(line.size() - 1) * fontSize * 0.55f)) + : 0.0f; + if (preserveLines) { + lineVisualWidth = std::max(1.0f, rectWidth); + } + if (line.size() > 1 && bgA > 0) { + insert_background_rect_object( + page, + textX, + baseline - (fontSize * 0.95f), + lineVisualWidth + (fontSize * 0.2f), + fontSize * 1.2f, + bgR, + bgG, + bgB, + bgA + ); + } + if (insert_text_line_object( + document, + page, + line, + textX, + baseline, + fontSize, + textR, + textG, + textB, + textA, + textFlags[i], + textFontPaths[i], + textFontNames[i])) { + if (line.size() > 1) insertedAnyText = true; + } + + if (line.size() > 1 && (textFlags[i] & (kTextFlagUnderline | kTextFlagStrikeThrough))) { + if (textFlags[i] & kTextFlagUnderline) { + insert_decoration_line_object( + page, + textX, + baseline - 2.0f, + textX + lineVisualWidth, + textR, + textG, + textB, + textA, + decorationStroke + ); + } + if (textFlags[i] & kTextFlagStrikeThrough) { + insert_decoration_line_object( + page, + textX, + baseline + fontSize * 0.35f, + textX + lineVisualWidth, + textR, + textG, + textB, + textA, + decorationStroke + ); + } + } + + baseline -= lineHeight; + } + + if (!insertedAnyText) { + LOGE("PdfiumExport: No text inserted for text item index=%zu page=%d fontPath=%s fontName=%s textChars=%d rect=(%.2f,%.2f,%.2f,%.2f)", + i, + pageIndex, + textFontPaths[i].c_str(), + textFontNames[i].c_str(), + content ? env->GetStringLength(content) : 0, + rect.left, + rect.top, + rect.right, + rect.bottom); + hadFailure = true; + } + env->DeleteLocalRef(content); + } + } else { + hadFailure = true; + } + + if (generate_content_func) generate_content_func(page); + close_page_func(page); + } + + FILE* output = fopen(dest.c_str(), "wb"); + if (!output) { + LOGE("PdfiumExport: Failed to open destination PDF."); + releaseRasterResources(); + close_document_func(document); + return JNI_FALSE; + } + + PdfiumFileWriter writer{{1, write_pdf_block}, output}; + int saved = save_as_copy_func(document, &writer.base, kPdfNoIncremental); + fclose(output); + close_document_func(document); + releaseRasterResources(); + + if (!saved) { + LOGE("PdfiumExport: Save result=%d hadFailure=%d", saved, hadFailure ? 1 : 0); + remove(dest.c_str()); + return JNI_FALSE; + } + + if (hadFailure) { + LOGE("PdfiumExport: Saved PDF with partial annotation/text failures."); + } + + return JNI_TRUE; +} + extern "C" JNIEXPORT jboolean JNICALL Java_com_aryan_reader_pdf_NativePdfiumBridge_checkActionSupport(JNIEnv *env, jclass clazz) { std::lock_guard lock(g_pdfium_mutex); diff --git a/app/src/main/java/com/aryan/reader/FileTypeResolver.kt b/app/src/main/java/com/aryan/reader/FileTypeResolver.kt index ceca8b9..272691f 100644 --- a/app/src/main/java/com/aryan/reader/FileTypeResolver.kt +++ b/app/src/main/java/com/aryan/reader/FileTypeResolver.kt @@ -17,6 +17,26 @@ private val codeOrDataExtensions = setOf( "go" ) +private val manualOnlyReaderMimeTypes = setOf( + "text/csv", + "text/comma-separated-values", + "text/tab-separated-values", + "application/json", + "application/xml", + "text/xml", + "text/x-java-source", + "text/x-python", + "text/x-kotlin", + "text/javascript", + "application/javascript", + "text/x-c", + "text/x-c++", + "text/x-csharp", + "text/x-ruby", + "text/x-go", + "text/x-log" +) + internal fun resolveFileTypeFromName(fileName: String?): FileType? { val lowerName = fileName?.lowercase()?.takeIf { it.isNotBlank() } ?: return null val effectiveName = lowerName.withTransparentTextSuffix() @@ -44,6 +64,22 @@ internal fun isCodeOrDataFileName(fileName: String): Boolean { return fileName.lowercase().withTransparentTextSuffix().extensionAfterLastDot() in codeOrDataExtensions } +internal fun isManualOnlyReaderFileName(fileName: String?): Boolean { + val lowerName = fileName?.lowercase()?.takeIf { it.isNotBlank() } ?: return false + return lowerName.withTransparentTextSuffix().extensionAfterLastDot() in codeOrDataExtensions +} + +internal fun isManualOnlyReaderMimeType(mimeType: String?): Boolean { + val normalized = mimeType?.lowercase() ?: return false + return normalized in manualOnlyReaderMimeTypes +} + +internal fun isLocalFolderSyncEligibleFile(name: String, mimeType: String?): Boolean { + if (isManualOnlyReaderFileName(name)) return false + if (resolveFileTypeFromName(name) != null) return true + return !isManualOnlyReaderMimeType(mimeType) +} + internal fun resolveFileExtensionSuffixFromName(fileName: String?): String? { val lowerName = fileName?.lowercase()?.takeIf { it.isNotBlank() } ?: return null val effectiveName = lowerName.withTransparentTextSuffix() diff --git a/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt b/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt index 3cd554b..554c0c6 100644 --- a/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt +++ b/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt @@ -40,7 +40,6 @@ import com.aryan.reader.data.LocalSyncUtils import com.aryan.reader.data.FolderBookMetadata import java.io.File import android.provider.DocumentsContract -import java.security.MessageDigest class FolderSyncWorker( private val appContext: Context, @@ -316,7 +315,13 @@ class FolderSyncWorker( val lastModified = if (!cursor.isNull(modCol)) cursor.getLong(modCol) else 0L val type = getFileType(name, mimeType) - if (type != null && type in allowedFileTypes && !name.endsWith(".json") && !name.startsWith(".")) { + if ( + type != null && + type in allowedFileTypes && + isLocalFolderSyncEligibleFile(name, mimeType) && + !name.endsWith(".json") && + !name.startsWith(".") + ) { supportedBooksSeen++ val stableId = buildStableBookId(name, rootDocId, docId) foundBookIds.add(stableId) @@ -554,11 +559,14 @@ class FolderSyncWorker( val sidecarData = preloadedSidecars[book.bookId] ?: continue val (remoteTs, jsonPayload) = sidecarData + val safeSlashBookId = book.bookId.replace("/", "_") + val safeRichTextBookId = book.bookId.replace("[^a-zA-Z0-9._-]".toRegex(), "_") val localFiles = listOf( - File(appContext.filesDir, "annotations/annotation_${book.bookId}.json"), - File(appContext.filesDir, "pdf_rich_text/text_${book.bookId}.json"), - File(appContext.filesDir, "page_layouts/layout_${book.bookId}.json"), - File(appContext.filesDir, "pdf_text_boxes/boxes_${book.bookId}.json") + File(appContext.filesDir, "annotations/annotation_$safeSlashBookId.json"), + File(appContext.filesDir, "rich_doc_${safeRichTextBookId}.json"), + File(appContext.filesDir, "page_layouts/layout_$safeSlashBookId.json"), + File(appContext.filesDir, "textboxes/textboxes_$safeSlashBookId.json"), + File(appContext.filesDir, "pdf_highlights/highlights_$safeSlashBookId.json") ) val localTs = localFiles.maxOfOrNull { if (it.exists()) it.lastModified() else 0L } ?: 0L @@ -607,10 +615,7 @@ class FolderSyncWorker( private fun buildStableBookId(name: String, rootDocId: String, docId: String): String { val relativePath = buildRelativePath(rootDocId, docId, name) - if (relativePath.equals(name, ignoreCase = true)) { - return "local_$name" - } - return "local_${name}_${shortHash(relativePath.lowercase())}" + return com.aryan.reader.shared.LocalFolderSyncEngine.buildStableBookId(name, relativePath) } private fun buildRelativePath(rootDocId: String, docId: String, fallbackName: String): String { @@ -625,11 +630,6 @@ class FolderSyncWorker( return relative.ifBlank { fallbackName } } - private fun shortHash(value: String): String { - val bytes = MessageDigest.getInstance("SHA-256").digest(value.toByteArray()) - return bytes.joinToString("") { "%02x".format(it) }.take(12) - } - private fun computeStableIdForStoredItem(item: RecentFileItem, rootDocId: String): String? { val uriString = item.uriString ?: return null return try { diff --git a/app/src/main/java/com/aryan/reader/LibraryScreen.kt b/app/src/main/java/com/aryan/reader/LibraryScreen.kt index 619b37e..0b619e2 100644 --- a/app/src/main/java/com/aryan/reader/LibraryScreen.kt +++ b/app/src/main/java/com/aryan/reader/LibraryScreen.kt @@ -3147,11 +3147,12 @@ fun OpdsBookDetailsSheet( } } - if (!entry.summary.isNullOrBlank()) { + val summary = entry.summary + if (!summary.isNullOrBlank()) { Text(stringResource(R.string.synopsis), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - val cleanSummary = remember(entry.summary) { - val preProcessed = entry.summary + val cleanSummary = remember(summary) { + val preProcessed = summary .replace("
", "\n") .replace("

", "\n\n") Jsoup.parse(preProcessed).text().trim() diff --git a/app/src/main/java/com/aryan/reader/LibraryStateProjector.kt b/app/src/main/java/com/aryan/reader/LibraryStateProjector.kt index 3277879..323fb80 100644 --- a/app/src/main/java/com/aryan/reader/LibraryStateProjector.kt +++ b/app/src/main/java/com/aryan/reader/LibraryStateProjector.kt @@ -4,8 +4,8 @@ import com.aryan.reader.data.BookShelfCrossRef import com.aryan.reader.data.BookTagCrossRef import com.aryan.reader.data.RecentFileItem import com.aryan.reader.data.ShelfEntity -import com.aryan.reader.data.SmartCollectionEngine import com.aryan.reader.data.TagEntity +import com.aryan.reader.shared.SmartCollectionEngine fun interface FolderPathResolver { fun relativeFolderSegments(item: RecentFileItem): List @@ -183,7 +183,7 @@ class LibraryStateProjector( if (shelfEntity.isSmart && shelfEntity.smartRulesJson != null) { val rules = SmartCollectionEngine.fromJson(shelfEntity.smartRulesJson) if (rules != null) { - val matchingBooks = allLibraryFiles.filter { SmartCollectionEngine.evaluate(it, rules) } + val matchingBooks = allLibraryFiles.filter { SmartCollectionEngine.evaluate(it.toSharedBookItem(), rules) } allShelves.add(Shelf(shelfEntity.id, shelfEntity.name, ShelfType.SMART, sortFiles(matchingBooks, sortOrder))) shelvedBookIds.addAll(matchingBooks.map { it.bookId }) } diff --git a/app/src/main/java/com/aryan/reader/MainViewModel.kt b/app/src/main/java/com/aryan/reader/MainViewModel.kt index 3703770..d342f53 100644 --- a/app/src/main/java/com/aryan/reader/MainViewModel.kt +++ b/app/src/main/java/com/aryan/reader/MainViewModel.kt @@ -53,6 +53,7 @@ import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkInfo import androidx.work.WorkManager +import com.aryan.reader.data.BookMetadata import com.aryan.reader.data.CloudflareRepository import com.aryan.reader.data.CustomFontEntity import com.aryan.reader.data.FeedbackRepository @@ -83,8 +84,8 @@ import com.aryan.reader.paginatedreader.Locator import com.aryan.reader.paginatedreader.data.BookCacheDatabase import com.aryan.reader.paginatedreader.data.BookProcessingWorker import com.aryan.reader.pdf.PdfCoverGenerator -import com.aryan.reader.pdf.PdfExporter import com.aryan.reader.pdf.PdfUserHighlight +import com.aryan.reader.pdf.PdfiumAnnotationExporter import com.aryan.reader.pdf.ReflowWorker import com.aryan.reader.pdf.data.PageLayoutRepository import com.aryan.reader.pdf.data.PdfAnnotation @@ -94,7 +95,9 @@ import com.aryan.reader.pdf.data.PdfTextBox import com.aryan.reader.pdf.data.PdfTextBoxRepository import com.aryan.reader.pdf.data.PdfTextRepository import com.aryan.reader.pdf.data.VirtualPage -import com.tom_roush.pdfbox.android.PDFBoxResourceLoader +import com.aryan.reader.shared.SharedLibraryEditor +import com.aryan.reader.shared.pdf.SHARED_PDF_RICH_TEXT_LOG_TAG +import com.aryan.reader.shared.pdf.SharedPdfAnnotationSidecarCodec import io.legere.pdfiumandroid.PdfiumCore import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers @@ -709,27 +712,28 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } fun createAndAssignTag(name: String, bookIds: Set) { - val trimmedName = name.trim() - if (trimmedName.isBlank() || bookIds.isEmpty()) return + val sanitizedBookIds = SharedLibraryEditor.cleanBookIds(bookIds) + if (sanitizedBookIds.isEmpty()) return viewModelScope.launch { val tagId = UUID.randomUUID().toString() val colors = listOf(0xFFE57373, 0xFFF06292, 0xFFBA68C8, 0xFF9575CD, 0xFF7986CB, 0xFF64B5F6, 0xFF4FC3F7, 0xFF4DD0E1, 0xFF4DB6AC, 0xFF81C784, 0xFFAED581, 0xFFFF8A65, 0xFFA1887F, 0xFF90A4AE) val color = colors.random().toInt() - - val tag = TagEntity(tagId, trimmedName, color, System.currentTimeMillis()) + val now = System.currentTimeMillis() + val tag = SharedLibraryEditor.createTag(name, tagId, color)?.toTagEntity(now) ?: return@launch recentFilesRepository.createTag(tag) - bookIds.forEach { bookId -> + sanitizedBookIds.forEach { bookId -> recentFilesRepository.assignTagToBook(bookId, tagId) } } } fun toggleTagForBooks(tagId: String, bookIds: Set, assign: Boolean) { - if (tagId.isBlank() || bookIds.isEmpty()) return + val sanitizedBookIds = SharedLibraryEditor.cleanBookIds(bookIds) + if (tagId.isBlank() || sanitizedBookIds.isEmpty()) return viewModelScope.launch { - bookIds.forEach { bookId -> + sanitizedBookIds.forEach { bookId -> if (assign) { recentFilesRepository.assignTagToBook(bookId, tagId) } else { @@ -1094,9 +1098,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio prefs.edit { putBoolean(KEY_DEFAULT_TAGS_SEEDED, true) } } } - viewModelScope.launch(Dispatchers.IO) { - PDFBoxResourceLoader.init(getApplication()) - } val currentOpenCount = prefs.getInt(KEY_APP_OPEN_COUNT, 0) prefs.edit { putInt(KEY_APP_OPEN_COUNT, currentOpenCount + 1) } @@ -1779,7 +1780,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val virtualPages = pageLayoutRepository.getLayoutOrNull(bookId) val outputStream = appContext.contentResolver.openOutputStream(destUri) if (outputStream != null) { - PdfExporter.exportAnnotatedPdf( + PdfiumAnnotationExporter.exportAnnotatedPdf( context = appContext, sourceUri = sourceUri, destStream = outputStream, @@ -1889,24 +1890,24 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } val destFile = File(shareDir, filename) - val outputStream = FileOutputStream(destFile) + FileOutputStream(destFile).use { outputStream -> + if (includeAnnotations) { + val virtualPages = pageLayoutRepository.getLayoutOrNull(resolvedBookId) - if (includeAnnotations) { - val virtualPages = pageLayoutRepository.getLayoutOrNull(resolvedBookId) - - PdfExporter.exportAnnotatedPdf( - context = appContext, - sourceUri = sourceUri, - destStream = outputStream, - virtualPages = virtualPages, - inkAnnotations = annotations, - richTextPageLayouts = richTextPageLayouts, - textBoxes = textBoxes, - highlights = highlights - ) - } else { - appContext.contentResolver.openInputStream(sourceUri)?.use { input -> - input.copyTo(outputStream) + PdfiumAnnotationExporter.exportAnnotatedPdf( + context = appContext, + sourceUri = sourceUri, + destStream = outputStream, + virtualPages = virtualPages, + inkAnnotations = annotations, + richTextPageLayouts = richTextPageLayouts, + textBoxes = textBoxes, + highlights = highlights + ) + } else { + appContext.contentResolver.openInputStream(sourceUri)?.use { input -> + input.copyTo(outputStream) + } } } @@ -1953,6 +1954,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio Timber.d("Skipping metadata sync for local folder book: ${book.displayName}") return } + + if (book.isManualOnlyReaderFile()) { + Timber.d("Skipping metadata sync for manual-only reader file: ${book.displayName}") + return + } val currentUser = uiState.value.currentUser ?: return viewModelScope.launch { @@ -1972,6 +1978,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val hasTextBoxes = textBoxFile.exists() val hasHighlights = highlightFile.exists() val hasAnyData = hasInk || hasRichText || hasLayout || hasTextBoxes || hasHighlights + Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( + "android.cloud.export candidates book=${book.bookId} hasRichText=$hasRichText " + + "richBytes=${if (hasRichText) richTextFile.length() else 0L} hasAnyData=$hasAnyData" + ) if (hasAnyData) { if (googleDriveRepository.hasDrivePermissions(appContext)) { @@ -1985,12 +1995,22 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio if (file == null || !file.exists()) return try { val content = file.readText().trim() + if (key == "text") { + Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( + "android.cloud.export.readRichText book=${book.bookId} rawLen=${content.length} " + + "file=${file.absolutePath}" + ) + } if (content.startsWith("[")) { bundleJson.put(key, JSONArray(content)) } else if (content.startsWith("{")) { bundleJson.put(key, JSONObject(content)) } } catch (e: Exception) { + if (key == "text") { + Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG) + .e(e, "android.cloud.export.richTextParseFailed book=${book.bookId}") + } Timber.e(e, "Failed to parse local $key file") } } @@ -2003,7 +2023,14 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val bundleFile = File(appContext.cacheDir, "sync_bundle_${book.bookId}.json") - bundleFile.writeText(bundleJson.toString()) + val canonicalBundle = SharedPdfAnnotationSidecarCodec.canonicalizeDataJson(bundleJson.toString()) + bundleFile.writeText(canonicalBundle) + if (hasRichText) { + Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( + "android.cloud.export.bundleReady book=${book.bookId} canonicalLen=${canonicalBundle.length} " + + "bundleFile=${bundleFile.absolutePath}" + ) + } val uploaded = googleDriveRepository.uploadAnnotationFile( accessToken, book.bookId, bundleFile @@ -2011,9 +2038,17 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio bundleFile.delete() if (uploaded != null) { + if (hasRichText) { + Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG) + .d("android.cloud.export.uploadSuccess book=${book.bookId} driveId=${uploaded.id}") + } Timber.tag("AnnotationSync") .d("Bundle upload SUCCESS. ID: ${uploaded.id}") } else { + if (hasRichText) { + Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG) + .e("android.cloud.export.uploadFailed book=${book.bookId}") + } Timber.tag("AnnotationSync") .e("Bundle upload FAILED. Skipping Firestore sync to prevent data loss.") return@launch @@ -2204,7 +2239,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio while (nextIdx < totalChapters) { Timber.tag("TTS_BG_ADVANCE").d("Trying chapter $nextIdx natively.") - val nativeChunks = locatorConverter.getTtsChunksForChapter(book, nextIdx) + val nativeChunks = locatorConverter.getTtsChunksForChapter(book, nextIdx, bookId) if (!nativeChunks.isNullOrEmpty()) { val token = getAuthToken() @@ -2231,7 +2266,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio // Save reading position locally val cfi = nativeChunks.firstOrNull()?.sourceCfi if (cfi != null) { - val locator = locatorConverter.getLocatorFromCfi(book, nextIdx, cfi) + val locator = locatorConverter.getLocatorFromCfi(book, nextIdx, cfi, bookId) if (locator != null) { recentFilesRepository.getFileByBookId(bookId)?.uriString?.let { uriString -> recentFilesRepository.updateEpubReadingPosition(uriString, locator, cfi, 0f) @@ -2872,25 +2907,28 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } else { allFiles.filter { it.sourceFolderUri == null } } - filtered.filterNot { it.uriString?.startsWith("opds-pse") == true } + filtered + .filterNot { it.uriString?.startsWith("opds-pse") == true } + .filterNot { it.isManualOnlyReaderFile() } } val localShelfNames = prefs.getStringSet(KEY_SHELVES, emptySet()).orEmpty() + val remoteBooks = remoteBooksDeferred.await() + .filterNot { it.isManualOnlyReaderFile() } + val remoteShelves = remoteShelvesDeferred.await() + val syncableBookIds = (localBooks.map { it.bookId } + remoteBooks.map { it.bookId }).toSet() val allKnownShelfNames = - (localShelfNames + remoteShelvesDeferred.await().map { it.name }).toSet() + (localShelfNames + remoteShelves.map { it.name }).toSet() val localShelves = allKnownShelfNames.mapNotNull { name -> val timestamp = prefs.getLong("$KEY_SHELF_TIMESTAMP_PREFIX$name", 0L) if (timestamp == 0L && name !in localShelfNames) return@mapNotNull null val bookIds = prefs.getStringSet( "$KEY_SHELF_CONTENT_PREFIX$name", emptySet() - ).orEmpty().toList() + ).orEmpty().filter { it in syncableBookIds } val isDeleted = prefs.getBoolean("$KEY_SHELF_DELETED_PREFIX$name", false) ShelfMetadata(name, bookIds, timestamp, isDeleted) } - val remoteBooks = remoteBooksDeferred.await() - val remoteShelves = remoteShelvesDeferred.await() - // 3. Merge Books val localBooksMap = localBooks.associateBy { it.bookId } val remoteBooksMap = remoteBooks.associateBy { it.bookId } @@ -2985,7 +3023,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio currentShelves.add(remote.name) putStringSet( "$KEY_SHELF_CONTENT_PREFIX${remote.name}", - remote.bookIds.toSet() + remote.bookIds.filter { it in syncableBookIds }.toSet() ) } putStringSet(KEY_SHELVES, currentShelves) @@ -3014,7 +3052,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio currentShelves.add(remote.name) putStringSet( "$KEY_SHELF_CONTENT_PREFIX${remote.name}", - remote.bookIds.toSet() + remote.bookIds.filter { it in syncableBookIds }.toSet() ) } putStringSet(KEY_SHELVES, currentShelves) @@ -3033,7 +3071,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val finalMergedBooks = withContext(Dispatchers.IO) { recentFilesRepository.getAllFilesForSync() - } + }.filterNot { it.isManualOnlyReaderFile() } val remoteFiles = withContext(Dispatchers.IO) { googleDriveRepository.getFiles(accessToken)?.files.orEmpty().associateBy { it.name } } @@ -3100,11 +3138,20 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio try { val jsonString = tempDownloadFile.readText() + Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( + "android.cloud.import.downloaded book=$bookId rawLen=${jsonString.length}" + ) // Determine format val isBundle = try { val obj = JSONObject(jsonString) - obj.has("version") || obj.has("ink") || obj.has("text") || obj.has("layout") + obj.has("version") || + obj.has(SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS) || + obj.has("ink") || + obj.has("text") || + obj.has("layout") || + obj.has("textBoxes") || + obj.has("highlights") } catch (_: Exception) { false } @@ -3124,13 +3171,29 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio highlightFile.parentFile?.mkdirs() if (isBundle) { - val bundle = JSONObject(jsonString) + val bundle = JSONObject( + SharedPdfAnnotationSidecarCodec.legacyAndroidDataJsonFromCanonical(jsonString) + ) + Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( + "android.cloud.import.bundle book=$bookId hasRichText=${bundle.has("text")} keys=${bundle.keys().asSequence().toList()}" + ) fun writeSafe(key: String, file: File) { if (bundle.has(key)) { file.parentFile?.mkdirs() - file.writeText(bundle.get(key).toString()) + val content = bundle.get(key).toString() + file.writeText(content) + if (key == "text") { + Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( + "android.cloud.import.writeRichText book=$bookId rawLen=${content.length} file=${file.absolutePath}" + ) + } } else { + if (key == "text" && file.exists()) { + Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( + "android.cloud.import.deleteMissingRichText book=$bookId file=${file.absolutePath}" + ) + } if (file.exists()) file.delete() } } @@ -4592,21 +4655,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } fun createShelf(name: String) { - if (name.isNotBlank()) { - viewModelScope.launch { - val shelfId = UUID.randomUUID().toString() - val shelf = com.aryan.reader.data.ShelfEntity( - id = shelfId, - name = name, - isSmart = false, - smartRulesJson = null, - createdAt = System.currentTimeMillis(), - updatedAt = System.currentTimeMillis() - ) - recentFilesRepository.addShelf(shelf) - dismissCreateShelfDialog() - syncShelfChangeToFirestore(shelfId) - } + val shelfId = UUID.randomUUID().toString() + val now = System.currentTimeMillis() + val shelf = SharedLibraryEditor.createShelfRecord(name, shelfId)?.toShelfEntity(now) ?: return + viewModelScope.launch { + recentFilesRepository.addShelf(shelf) + dismissCreateShelfDialog() + syncShelfChangeToFirestore(shelfId) } } @@ -4651,12 +4706,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } fun renameShelf(shelfId: String, newName: String) { - if (shelfId.isBlank() || newName.isBlank()) { + val cleanName = SharedLibraryEditor.cleanShelfName(newName) + if (!SharedLibraryEditor.canMutateShelf(shelfId) || cleanName == null) { dismissRenameShelfDialog() return } viewModelScope.launch { - recentFilesRepository.renameShelf(shelfId, newName) + recentFilesRepository.renameShelf(shelfId, cleanName) syncShelfChangeToFirestore(shelfId) _internalState.update { it.copy(viewingShelfId = shelfId) } persistLibraryLandingState() @@ -4665,7 +4721,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } fun deleteShelf(shelfId: String) { - if (shelfId.isBlank() || shelfId == "unshelved") { + if (!SharedLibraryEditor.canMutateShelf(shelfId)) { dismissDeleteShelfDialog() return } @@ -4697,21 +4753,22 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio fun removeContextualItemsFromShelf() { val shelfId = _internalState.value.viewingShelfId - if (shelfId.isNullOrBlank() || shelfId == "unshelved") { + if (!SharedLibraryEditor.canMutateShelf(shelfId)) { clearContextualAction() return } + val targetShelfId = shelfId ?: return - val bookIdsToRemove = _internalState.value.contextualActionItems.map { it.bookId } + val bookIdsToRemove = SharedLibraryEditor.cleanBookIds(_internalState.value.contextualActionItems.map { it.bookId }) if (bookIdsToRemove.isEmpty()) { clearContextualAction() return } viewModelScope.launch { - recentFilesRepository.removeBooksFromShelf(shelfId, bookIdsToRemove) + recentFilesRepository.removeBooksFromShelf(targetShelfId, bookIdsToRemove.toList()) clearContextualAction() - syncShelfChangeToFirestore(shelfId) + syncShelfChangeToFirestore(targetShelfId) } } @@ -4755,6 +4812,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio fun deleteSelectedShelves() { val shelvesToDelete = _internalState.value.contextualActionShelfIds + .filterTo(mutableSetOf()) { SharedLibraryEditor.canMutateShelf(it) } if (shelvesToDelete.isEmpty()) { clearShelfContextualAction() return @@ -4788,7 +4846,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val db = com.aryan.reader.data.AppDatabase.getDatabase(appContext) val shelf = db.shelfDao().getShelfById(shelfId) ?: return@launch val crossRefs = db.shelfDao().getCrossRefsForShelf(shelfId) + val manualOnlyBookIds = recentFilesRepository.getAllFilesForSync() + .filter { it.isManualOnlyReaderFile() } + .mapTo(mutableSetOf()) { it.bookId } val bookIds = crossRefs.map { it.bookId } + .filterNot { it in manualOnlyBookIds } val shelfMetadata = ShelfMetadata( name = shelf.name, @@ -4814,8 +4876,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } fun addBooksToShelf(shelfId: String) { - val bookIdsToAdd = _internalState.value.booksSelectedForAdding - if (bookIdsToAdd.isEmpty()) { + val bookIdsToAdd = SharedLibraryEditor.cleanBookIds(_internalState.value.booksSelectedForAdding) + if (!SharedLibraryEditor.canMutateShelf(shelfId) || bookIdsToAdd.isEmpty()) { dismissAddBooksToShelf() return } @@ -4943,6 +5005,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio .associateBy { it.name } for (item in managedBooks) { + if (item.isManualOnlyReaderFile()) { + cleanupBookDataLocally(item.bookId) + recentFilesRepository.deleteFilePermanently(listOf(item.bookId)) + continue + } + recentFilesRepository.markAsDeleted(listOf(item.bookId)) cleanupBookDataLocally(item.bookId) @@ -5462,3 +5530,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio ) } } + +private fun RecentFileItem.isManualOnlyReaderFile(): Boolean { + return isManualOnlyReaderFileName(displayName) +} + +private fun BookMetadata.isManualOnlyReaderFile(): Boolean { + return isManualOnlyReaderFileName(displayName) +} diff --git a/app/src/main/java/com/aryan/reader/SharedModelMappers.kt b/app/src/main/java/com/aryan/reader/SharedModelMappers.kt index c397738..4ed229f 100644 --- a/app/src/main/java/com/aryan/reader/SharedModelMappers.kt +++ b/app/src/main/java/com/aryan/reader/SharedModelMappers.kt @@ -12,6 +12,7 @@ import com.aryan.reader.shared.BannerMessage as SharedBannerMessage import com.aryan.reader.shared.BookItem as SharedBookItem import com.aryan.reader.shared.BookShelfRef as SharedBookShelfRef import com.aryan.reader.shared.CustomAppTheme as SharedCustomAppTheme +import com.aryan.reader.shared.EpubAnnotationSerializer import com.aryan.reader.shared.FileType as SharedFileType import com.aryan.reader.shared.LibraryFilters as SharedLibraryFilters import com.aryan.reader.shared.ReadStatusFilter as SharedReadStatusFilter @@ -30,15 +31,18 @@ fun RecentFileItem.toSharedBookItem(): SharedBookItem { type = type.toSharedFileType(), displayName = customName ?: displayName, timestamp = timestamp, + coverImagePath = coverImagePath, title = title, author = author, progressPercentage = progressPercentage, isRecent = isRecent, fileSize = fileSize, sourceFolder = sourceFolderUri, + folderTextMetadataParsed = folderTextMetadataParsed, seriesName = seriesName, seriesIndex = seriesIndex, - tags = tags.map { it.toSharedTag() } + tags = tags.map { it.toSharedTag() }, + readerHighlights = EpubAnnotationSerializer.parseHighlightsJson(highlightsJson) ) } @@ -50,6 +54,15 @@ fun TagEntity.toSharedTag(): SharedTag { ) } +fun SharedTag.toTagEntity(createdAt: Long): TagEntity { + return TagEntity( + id = id, + name = name, + color = color, + createdAt = createdAt + ) +} + fun ShelfEntity.toSharedShelfRecord(): ShelfRecord { return ShelfRecord( id = id, @@ -59,6 +72,17 @@ fun ShelfEntity.toSharedShelfRecord(): ShelfRecord { ) } +fun ShelfRecord.toShelfEntity(createdAt: Long, updatedAt: Long = createdAt): ShelfEntity { + return ShelfEntity( + id = id, + name = name, + isSmart = isSmart, + smartRulesJson = smartRulesJson, + createdAt = createdAt, + updatedAt = updatedAt + ) +} + fun BookShelfCrossRef.toSharedBookShelfRef(): SharedBookShelfRef { return SharedBookShelfRef( bookId = bookId, diff --git a/app/src/main/java/com/aryan/reader/TtsReplacementStore.kt b/app/src/main/java/com/aryan/reader/TtsReplacementStore.kt new file mode 100644 index 0000000..5167ba5 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/TtsReplacementStore.kt @@ -0,0 +1,43 @@ +package com.aryan.reader + +import android.content.Context +import androidx.core.content.edit +import com.aryan.reader.paginatedreader.TtsChunk +import com.aryan.reader.shared.ReaderTtsReplacementEngine +import com.aryan.reader.shared.ReaderTtsReplacementPreferences +import com.aryan.reader.shared.ReaderTtsReplacementPreferencesJson + +private const val READER_PREFS_NAME = "reader_prefs" +private const val TTS_REPLACEMENTS_KEY = "tts_word_replacements_json" + +fun loadTtsReplacementPreferences(context: Context): ReaderTtsReplacementPreferences { + val prefs = context.getSharedPreferences(READER_PREFS_NAME, Context.MODE_PRIVATE) + return ReaderTtsReplacementPreferencesJson.decodeOrEmpty(prefs.getString(TTS_REPLACEMENTS_KEY, null)) +} + +fun saveTtsReplacementPreferences( + context: Context, + preferences: ReaderTtsReplacementPreferences, +) { + val prefs = context.getSharedPreferences(READER_PREFS_NAME, Context.MODE_PRIVATE) + prefs.edit { + putString(TTS_REPLACEMENTS_KEY, ReaderTtsReplacementPreferencesJson.encode(preferences)) + } +} + +fun TtsChunk.withTtsReplacements( + preferences: ReaderTtsReplacementPreferences, + bookId: String?, +): TtsChunk { + val spoken = ReaderTtsReplacementEngine.apply( + text = text, + preferences = preferences, + bookId = bookId, + ).text + return copy(spokenText = spoken.ifBlank { text }) +} + +fun List.withTtsReplacements( + preferences: ReaderTtsReplacementPreferences, + bookId: String?, +): List = map { it.withTtsReplacements(preferences, bookId) } diff --git a/app/src/main/java/com/aryan/reader/TtsWordReplacementsSheet.kt b/app/src/main/java/com/aryan/reader/TtsWordReplacementsSheet.kt new file mode 100644 index 0000000..e415335 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/TtsWordReplacementsSheet.kt @@ -0,0 +1,667 @@ +package com.aryan.reader + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.aryan.reader.shared.ReaderTtsReplacementBookSettings +import com.aryan.reader.shared.ReaderTtsReplacementEngine +import com.aryan.reader.shared.ReaderTtsReplacementPreferences +import com.aryan.reader.shared.ReaderTtsReplacementRule +import com.aryan.reader.shared.ReaderTtsReplacementSuggestions + +private enum class TtsReplacementScope { + Global, + Book +} + +private data class RuleEditTarget( + val scope: TtsReplacementScope, + val ruleId: String? = null, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TtsWordReplacementsSheet( + isVisible: Boolean, + bookId: String, + bookTitle: String?, + preferences: ReaderTtsReplacementPreferences, + onPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit, + onDismiss: () -> Unit, +) { + if (!isVisible) return + + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var selectedTab by remember { mutableIntStateOf(0) } + var editTarget by remember { mutableStateOf(null) } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 720.dp) + .imePadding() + .padding(horizontal = 20.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "TTS Word Replacements", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = bookTitle?.takeIf { it.isNotBlank() } ?: "Current book", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, contentDescription = "Close") + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + TabRow(selectedTabIndex = selectedTab) { + Tab( + selected = selectedTab == 0, + onClick = { + selectedTab = 0 + editTarget = null + }, + text = { Text("Global") }, + ) + Tab( + selected = selectedTab == 1, + onClick = { + selectedTab = 1 + editTarget = null + }, + text = { Text("This book") }, + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + when (selectedTab) { + 0 -> GlobalReplacementTab( + preferences = preferences, + editTarget = editTarget?.takeIf { it.scope == TtsReplacementScope.Global }, + onEditTargetChange = { editTarget = it }, + onPreferencesChange = onPreferencesChange, + ) + else -> BookReplacementTab( + bookId = bookId, + preferences = preferences, + editTarget = editTarget?.takeIf { it.scope == TtsReplacementScope.Book }, + onEditTargetChange = { editTarget = it }, + onPreferencesChange = onPreferencesChange, + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + } + } +} + +@Composable +private fun GlobalReplacementTab( + preferences: ReaderTtsReplacementPreferences, + editTarget: RuleEditTarget?, + onEditTargetChange: (RuleEditTarget?) -> Unit, + onPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit, +) { + val editingRule = editTarget?.ruleId?.let { id -> preferences.globalRules.firstOrNull { it.id == id } } + LazyColumn( + modifier = Modifier.heightIn(max = 560.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + ListItem( + headlineContent = { Text("Enable replacements") }, + supportingContent = { Text("Rules here apply to every book unless disabled for a specific title.") }, + trailingContent = { + Switch( + checked = preferences.isEnabled, + onCheckedChange = { onPreferencesChange(preferences.copy(isEnabled = it)) }, + ) + }, + ) + } + item { + SuggestionChips( + onSuggestionClick = { suggestion -> + onPreferencesChange( + preferences.copy( + globalRules = preferences.globalRules + suggestion.asEditableRule("global"), + ), + ) + }, + ) + } + item { + TextButton( + onClick = { onEditTargetChange(RuleEditTarget(TtsReplacementScope.Global)) }, + ) { + Icon(Icons.Default.Add, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text("Add rule") + } + } + if (editTarget != null) { + item { + RuleEditorCard( + seedRule = editingRule, + onCancel = { onEditTargetChange(null) }, + onSave = { rule -> + val updatedRules = if (editingRule == null) { + preferences.globalRules + rule + } else { + preferences.globalRules.map { if (it.id == editingRule.id) rule else it } + } + onPreferencesChange(preferences.copy(globalRules = updatedRules)) + onEditTargetChange(null) + }, + ) + } + } + item { + ReplacementRuleList( + rules = preferences.globalRules, + emptyText = "No global replacement rules yet.", + onToggle = { rule, enabled -> + onPreferencesChange( + preferences.copy( + globalRules = preferences.globalRules.map { + if (it.id == rule.id) it.copy(enabled = enabled) else it + }, + ), + ) + }, + onEdit = { onEditTargetChange(RuleEditTarget(TtsReplacementScope.Global, it.id)) }, + onDelete = { rule -> + onPreferencesChange( + preferences.copy(globalRules = preferences.globalRules.filterNot { it.id == rule.id }), + ) + }, + ) + } + } +} + +@Composable +private fun BookReplacementTab( + bookId: String, + preferences: ReaderTtsReplacementPreferences, + editTarget: RuleEditTarget?, + onEditTargetChange: (RuleEditTarget?) -> Unit, + onPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit, +) { + val settings = preferences.settingsForBook(bookId) + val localRules = preferences.rulesForBook(bookId) + val editingRule = editTarget?.ruleId?.let { id -> localRules.firstOrNull { it.id == id } } + + LazyColumn( + modifier = Modifier.heightIn(max = 560.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + BookSettingsSwitches( + settings = settings, + onSettingsChange = { onPreferencesChange(preferences.withBookSettings(bookId, it)) }, + ) + } + item { + InheritedGlobalRules( + globalRules = preferences.globalRules, + settings = settings, + onSettingsChange = { onPreferencesChange(preferences.withBookSettings(bookId, it)) }, + ) + } + item { + SuggestionChips( + onSuggestionClick = { suggestion -> + onPreferencesChange( + preferences.withBookRules( + bookId, + localRules + suggestion.asEditableRule("book"), + ), + ) + }, + ) + } + item { + TextButton( + onClick = { onEditTargetChange(RuleEditTarget(TtsReplacementScope.Book)) }, + ) { + Icon(Icons.Default.Add, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text("Add book rule") + } + } + if (editTarget != null) { + item { + RuleEditorCard( + seedRule = editingRule, + onCancel = { onEditTargetChange(null) }, + onSave = { rule -> + val updatedRules = if (editingRule == null) { + localRules + rule + } else { + localRules.map { if (it.id == editingRule.id) rule else it } + } + onPreferencesChange(preferences.withBookRules(bookId, updatedRules)) + onEditTargetChange(null) + }, + ) + } + } + item { + ReplacementRuleList( + rules = localRules, + emptyText = "No book-specific rules yet.", + onToggle = { rule, enabled -> + onPreferencesChange( + preferences.withBookRules( + bookId, + localRules.map { if (it.id == rule.id) it.copy(enabled = enabled) else it }, + ), + ) + }, + onEdit = { onEditTargetChange(RuleEditTarget(TtsReplacementScope.Book, it.id)) }, + onDelete = { rule -> + onPreferencesChange(preferences.withBookRules(bookId, localRules.filterNot { it.id == rule.id })) + }, + ) + } + } +} + +@Composable +private fun BookSettingsSwitches( + settings: ReaderTtsReplacementBookSettings, + onSettingsChange: (ReaderTtsReplacementBookSettings) -> Unit, +) { + Card( + shape = RoundedCornerShape(8.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f)), + ) { + Column(modifier = Modifier.fillMaxWidth()) { + ListItem( + headlineContent = { Text("Use global rules here") }, + supportingContent = { Text("Turn this off when a book needs its own pronunciation choices.") }, + trailingContent = { + Switch( + checked = settings.globalRulesEnabled, + onCheckedChange = { onSettingsChange(settings.copy(globalRulesEnabled = it)) }, + ) + }, + ) + HorizontalDivider() + ListItem( + headlineContent = { Text("Enable book rules") }, + supportingContent = { Text("Local rules run after global rules.") }, + trailingContent = { + Switch( + checked = settings.localRulesEnabled, + onCheckedChange = { onSettingsChange(settings.copy(localRulesEnabled = it)) }, + ) + }, + ) + } + } +} + +@Composable +private fun InheritedGlobalRules( + globalRules: List, + settings: ReaderTtsReplacementBookSettings, + onSettingsChange: (ReaderTtsReplacementBookSettings) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "Inherited global rules", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + if (globalRules.isEmpty()) { + Text( + text = "No global rules to inherit.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + return + } + globalRules.forEach { rule -> + val enabledHere = rule.id !in settings.disabledGlobalRuleIds + ListItem( + headlineContent = { Text(rule.summaryText()) }, + supportingContent = { Text(if (enabledHere) "Allowed in this book" else "Disabled for this book") }, + trailingContent = { + Switch( + checked = enabledHere, + onCheckedChange = { checked -> + val disabledIds = if (checked) { + settings.disabledGlobalRuleIds - rule.id + } else { + settings.disabledGlobalRuleIds + rule.id + } + onSettingsChange(settings.copy(disabledGlobalRuleIds = disabledIds)) + }, + ) + }, + ) + } + } +} + +@Composable +private fun SuggestionChips( + onSuggestionClick: (ReaderTtsReplacementRule) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "Suggestions", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(ReaderTtsReplacementSuggestions.presets) { suggestion -> + AssistChip( + onClick = { onSuggestionClick(suggestion) }, + label = { Text(suggestion.summaryText(), maxLines = 1, overflow = TextOverflow.Ellipsis) }, + leadingIcon = { Icon(Icons.Default.Add, contentDescription = null) }, + ) + } + } + } +} + +@Composable +private fun RuleEditorCard( + seedRule: ReaderTtsReplacementRule?, + onCancel: () -> Unit, + onSave: (ReaderTtsReplacementRule) -> Unit, +) { + val draftRuleId = remember(seedRule?.id) { seedRule?.id ?: newReplacementRuleId() } + val initial = seedRule ?: ReaderTtsReplacementRule( + id = draftRuleId, + from = "", + to = "", + ) + var from by remember(initial.id) { mutableStateOf(initial.from) } + var to by remember(initial.id) { mutableStateOf(initial.to) } + var enabled by remember(initial.id) { mutableStateOf(initial.enabled) } + var isRegex by remember(initial.id) { mutableStateOf(initial.isRegex) } + var wholeWord by remember(initial.id) { mutableStateOf(initial.wholeWord) } + var matchCase by remember(initial.id) { mutableStateOf(initial.matchCase) } + var previewInput by remember(initial.id) { + mutableStateOf(initial.from.takeIf { it.isNotBlank() } ?: "Dr. Smith met NASA at 5 p.m.") + } + + val draft = ReaderTtsReplacementRule( + id = initial.id, + from = from, + to = to, + enabled = enabled, + isRegex = isRegex, + matchCase = matchCase, + wholeWord = wholeWord, + ) + val validation = ReaderTtsReplacementEngine.validate(draft) + val previewOutput = if (validation.isValid) { + ReaderTtsReplacementEngine.apply( + text = previewInput, + preferences = ReaderTtsReplacementPreferences(globalRules = listOf(draft.copy(enabled = true))), + ).text + } else { + previewInput + } + + Card( + shape = RoundedCornerShape(8.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.35f)), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = if (seedRule == null) "New replacement" else "Edit replacement", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + OutlinedTextField( + value = from, + onValueChange = { from = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Replace") }, + singleLine = !isRegex, + isError = !validation.isValid, + supportingText = if (validation.message != null) { + { Text(validation.message.orEmpty()) } + } else { + null + }, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + keyboardType = KeyboardType.Text, + ), + ) + OutlinedTextField( + value = to, + onValueChange = { to = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Speak as") }, + singleLine = !isRegex, + ) + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + item { + FilterChip( + selected = enabled, + onClick = { enabled = !enabled }, + label = { Text("Enabled") }, + leadingIcon = if (enabled) { + { Icon(Icons.Default.Check, contentDescription = null) } + } else { + null + }, + ) + } + item { + FilterChip( + selected = isRegex, + onClick = { isRegex = !isRegex }, + label = { Text("Regex") }, + ) + } + item { + FilterChip( + selected = wholeWord, + onClick = { wholeWord = !wholeWord }, + label = { Text("Whole word") }, + ) + } + item { + FilterChip( + selected = matchCase, + onClick = { matchCase = !matchCase }, + label = { Text("Match case") }, + ) + } + } + OutlinedTextField( + value = previewInput, + onValueChange = { previewInput = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Preview input") }, + minLines = 2, + ) + Text( + text = previewOutput, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = onCancel) { + Text("Cancel") + } + Spacer(modifier = Modifier.width(8.dp)) + Button( + onClick = { onSave(draft) }, + enabled = validation.isValid, + ) { + Text("Save") + } + } + } + } +} + +@Composable +private fun ReplacementRuleList( + rules: List, + emptyText: String, + onToggle: (ReaderTtsReplacementRule, Boolean) -> Unit, + onEdit: (ReaderTtsReplacementRule) -> Unit, + onDelete: (ReaderTtsReplacementRule) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "Rules", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + if (rules.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = emptyText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + return + } + rules.forEach { rule -> + ListItem( + headlineContent = { + Text( + text = rule.summaryText(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + supportingContent = { + Text(rule.optionSummary()) + }, + trailingContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + Switch( + checked = rule.enabled, + onCheckedChange = { onToggle(rule, it) }, + ) + IconButton(onClick = { onEdit(rule) }) { + Icon(Icons.Default.Edit, contentDescription = "Edit") + } + IconButton(onClick = { onDelete(rule) }) { + Icon(Icons.Default.Delete, contentDescription = "Delete") + } + } + }, + ) + } + } +} + +private fun ReaderTtsReplacementRule.asEditableRule(scope: String): ReaderTtsReplacementRule { + return copy(id = "${scope}_${System.currentTimeMillis()}_${id}", enabled = true) +} + +private fun ReaderTtsReplacementRule.summaryText(): String { + val replacement = to.ifBlank { "silence" } + return "$from -> $replacement" +} + +private fun ReaderTtsReplacementRule.optionSummary(): String { + val parts = buildList { + add(if (isRegex) "Regex" else "Plain text") + if (wholeWord) add("whole word") + if (matchCase) add("case-sensitive") + } + return parts.joinToString(" - ") +} + +private fun newReplacementRuleId(): String { + return "rule_${System.currentTimeMillis()}" +} diff --git a/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt b/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt index 3cca485..b18ef03 100644 --- a/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt +++ b/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt @@ -40,6 +40,8 @@ import java.io.FileOutputStream import com.aryan.reader.pdf.data.PdfAnnotationRepository import com.aryan.reader.pdf.data.PageLayoutRepository import com.aryan.reader.pdf.data.PdfTextBoxRepository +import com.aryan.reader.shared.pdf.SHARED_PDF_RICH_TEXT_LOG_TAG +import com.aryan.reader.shared.pdf.SharedPdfAnnotationSidecarCodec import org.json.JSONObject import org.json.JSONArray import java.util.UUID @@ -273,6 +275,10 @@ class RecentFilesRepository(private val context: Context) { val hasHighlights = highlightFile.exists() Timber.tag("FolderAnnotationSync").d("File checks -> hasInk: $hasInk, hasRichText: $hasRichText, hasLayout: $hasLayout, hasTextBoxes: $hasTextBoxes, hasHighlights: $hasHighlights") + Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( + "android.folder.export candidates book=$bookId hasRichText=$hasRichText " + + "richBytes=${if (hasRichText) richTextFile.length() else 0L} folder=$folderUriString" + ) if (!hasInk && !hasRichText && !hasLayout && !hasTextBoxes && !hasHighlights) { Timber.tag("FolderAnnotationSync").d("No annotations found locally for bookId: $bookId. Aborting sync.") @@ -284,12 +290,21 @@ class RecentFilesRepository(private val context: Context) { fun putJsonSafe(key: String, file: File) { try { val content = file.readText().trim() + if (key == "text") { + Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( + "android.folder.export.readRichText book=$bookId rawLen=${content.length} file=${file.absolutePath}" + ) + } if (content.startsWith("[")) { bundleJson.put(key, JSONArray(content)) } else if (content.startsWith("{")) { bundleJson.put(key, JSONObject(content)) } } catch (e: Exception) { + if (key == "text") { + Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG) + .e(e, "android.folder.export.richTextParseFailed book=$bookId") + } Timber.tag("FolderAnnotationSync").e(e, "Error parsing $key file") } } @@ -311,11 +326,18 @@ class RecentFilesRepository(private val context: Context) { Timber.tag("FolderAnnotationSync").d("Pushing annotation bundle for $bookId to folder. finalTs=$finalTs") + val canonicalBundleJson = SharedPdfAnnotationSidecarCodec.canonicalizeDataJson(bundleJson.toString()) + if (hasRichText) { + Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( + "android.folder.export.saveSidecar book=$bookId timestamp=$finalTs canonicalLen=${canonicalBundleJson.length}" + ) + } + LocalSyncUtils.saveAnnotationSidecar( context = context, sourceFolderUri = folderUriString.toUri(), bookId = bookId, - jsonPayload = bundleJson.toString(), + jsonPayload = canonicalBundleJson, timestamp = finalTs ) } @@ -323,13 +345,24 @@ class RecentFilesRepository(private val context: Context) { suspend fun importAnnotationBundle(bookId: String, jsonString: String) = withContext(Dispatchers.IO) { Timber.tag("FolderAnnotationSync").d("importAnnotationBundle: Processing bundle for $bookId") try { - val bundle = JSONObject(jsonString) + val bundle = JSONObject( + SharedPdfAnnotationSidecarCodec.legacyAndroidDataJsonFromCanonical(jsonString) + ) + Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( + "android.folder.import.bundle book=$bookId rawLen=${jsonString.length} " + + "hasRichText=${bundle.has("text")} keys=${bundle.keys().asSequence().toList()}" + ) fun writeSafe(key: String, file: File?) { if (file != null && bundle.has(key)) { file.parentFile?.mkdirs() val contentStr = bundle.get(key).toString() file.writeText(contentStr) + if (key == "text") { + Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( + "android.folder.import.writeRichText book=$bookId rawLen=${contentStr.length} file=${file.absolutePath}" + ) + } Timber.tag("FolderAnnotationSync").v(" -> Updated $key file (${contentStr.length} chars)") } } diff --git a/app/src/main/java/com/aryan/reader/data/SmartCollectionEngine.kt b/app/src/main/java/com/aryan/reader/data/SmartCollectionEngine.kt index 07a8d49..41d0b80 100644 --- a/app/src/main/java/com/aryan/reader/data/SmartCollectionEngine.kt +++ b/app/src/main/java/com/aryan/reader/data/SmartCollectionEngine.kt @@ -1,81 +1,20 @@ package com.aryan.reader.data -import kotlinx.serialization.Serializable -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json +import com.aryan.reader.toSharedBookItem +import com.aryan.reader.shared.SmartCollectionEngine as SharedSmartCollectionEngine -@Serializable -enum class SmartField { TITLE, AUTHOR, PROGRESS, FILE_TYPE, FOLDER, TAG } -@Serializable -enum class SmartOperator { EQUALS, CONTAINS, GREATER_THAN, LESS_THAN } - -@Serializable -data class SmartRule( - val field: SmartField, - val operator: SmartOperator, - val value: String -) - -@Serializable -data class SmartCollectionDefinition( - val matchAll: Boolean = true, - val rules: List = emptyList() -) +typealias SmartField = com.aryan.reader.shared.SmartField +typealias SmartOperator = com.aryan.reader.shared.SmartOperator +typealias SmartRule = com.aryan.reader.shared.SmartRule +typealias SmartCollectionDefinition = com.aryan.reader.shared.SmartCollectionDefinition object SmartCollectionEngine { - private val json = Json { - encodeDefaults = true - ignoreUnknownKeys = true - } + fun toJson(definition: SmartCollectionDefinition): String = + SharedSmartCollectionEngine.toJson(definition) - fun toJson(definition: SmartCollectionDefinition): String = json.encodeToString(definition) + fun fromJson(json: String?): SmartCollectionDefinition? = + SharedSmartCollectionEngine.fromJson(json) - fun fromJson(json: String?): SmartCollectionDefinition? { - if (json.isNullOrBlank()) return null - return try { - this.json.decodeFromString(json) - } catch (_: Exception) { null } - } - - fun evaluate(book: RecentFileItem, definition: SmartCollectionDefinition): Boolean { - if (definition.rules.isEmpty()) return false - - val results = definition.rules.map { rule -> - when (rule.field) { - SmartField.TITLE -> evaluateString(book.title ?: book.displayName, rule) - SmartField.AUTHOR -> evaluateString(book.author ?: "", rule) - SmartField.FILE_TYPE -> evaluateString(book.type.name, rule) - SmartField.FOLDER -> evaluateString(book.sourceFolderUri ?: "", rule) - SmartField.TAG -> evaluateTags(book.tags.map { it.name }, rule) - SmartField.PROGRESS -> evaluateNumber(book.progressPercentage ?: 0f, rule) - } - } - return if (definition.matchAll) results.all { it } else results.any { it } - } - - private fun evaluateString(target: String, rule: SmartRule): Boolean { - return when (rule.operator) { - SmartOperator.EQUALS -> target.equals(rule.value, ignoreCase = true) - SmartOperator.CONTAINS -> target.contains(rule.value, ignoreCase = true) - else -> false - } - } - - private fun evaluateNumber(target: Float, rule: SmartRule): Boolean { - val ruleValue = rule.value.toFloatOrNull() ?: return false - return when (rule.operator) { - SmartOperator.EQUALS -> target == ruleValue - SmartOperator.GREATER_THAN -> target > ruleValue - SmartOperator.LESS_THAN -> target < ruleValue - else -> false - } - } - - private fun evaluateTags(tags: List, rule: SmartRule): Boolean { - return when (rule.operator) { - SmartOperator.EQUALS -> tags.any { it.equals(rule.value, ignoreCase = true) } - SmartOperator.CONTAINS -> tags.any { it.contains(rule.value, ignoreCase = true) } - else -> false - } - } + fun evaluate(book: RecentFileItem, definition: SmartCollectionDefinition): Boolean = + SharedSmartCollectionEngine.evaluate(book.toSharedBookItem(), definition) } diff --git a/app/src/main/java/com/aryan/reader/epub/EpubParser.kt b/app/src/main/java/com/aryan/reader/epub/EpubParser.kt index b5bb7c1..e8649d2 100644 --- a/app/src/main/java/com/aryan/reader/epub/EpubParser.kt +++ b/app/src/main/java/com/aryan/reader/epub/EpubParser.kt @@ -26,6 +26,9 @@ import timber.log.Timber import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import org.jsoup.Jsoup import org.w3c.dom.Element import org.w3c.dom.Node @@ -42,6 +45,8 @@ import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit class EpubParser(private val context: Context) { + private val jsonSerializer = Json { ignoreUnknownKeys = true; encodeDefaults = true } + data class EpubDocument( val metadata: Node, val manifest: Node, val spine: Node, val opfFilePath: String ) @@ -67,6 +72,15 @@ class EpubParser(private val context: Context) { val depth: Int ) + @Serializable + private data class EpubExtractionCacheManifest( + val bookId: String, + val originalBookNameHint: String, + val parserVersion: Int, + val parseContent: Boolean, + val shouldUseToc: Boolean + ) + // EpubFile can still represent in-memory file data during initial parsing before extraction data class EpubFile(val absPath: String, val data: ByteArray) { override fun equals(other: Any?): Boolean { @@ -92,6 +106,9 @@ class EpubParser(private val context: Context) { companion object { const val TAG = "EpubParser" + private const val BOOK_METADATA_FILE = "book_metadata.json" + private const val CACHE_MANIFEST_FILE = "epub_cache_manifest.json" + private const val EPUB_EXTRACTION_CACHE_VERSION = 1 } internal val String.decodedURL: String @@ -173,8 +190,24 @@ class EpubParser(private val context: Context) { return withContext(Dispatchers.IO) { Timber.d("Parsing EPUB input stream for bookId: $bookId") - val extractionDir = extractionDirOverride?.let(ImportedFileCache::prepareDirectory) - ?: ImportedFileCache.prepareActiveBookDir(context, bookId) + val shouldDeleteExtractionDir = !parseContent && extractionDirOverride == null + val extractionDir = if (extractionDirOverride != null) { + ImportedFileCache.prepareDirectory(extractionDirOverride) + } else if (!parseContent) { + ImportedFileCache.createTemporaryBookDir(context, bookId, "metadata") + } else { + val activeDir = ImportedFileCache.ensureActiveBookDir(context, bookId) + readCachedEpubBook( + extractionDir = activeDir, + bookId = bookId, + originalBookNameHint = originalBookNameHint, + shouldUseToc = shouldUseToc + )?.let { cachedBook -> + Timber.tag("FileOpenPerf").d("[EPUB] Loaded extracted book from cache | bookId=$bookId") + return@withContext cachedBook + } + ImportedFileCache.resetActiveBookDir(context, bookId) + } val tempFile = File.createTempFile("epub_stream", ".epub", context.cacheDir) val filesMap: Map @@ -190,10 +223,80 @@ class EpubParser(private val context: Context) { val document = createEpubDocument(filesMap) val book = parseAndCreateEbook(filesMap, document, shouldUseToc, extractionDir.absolutePath, originalBookNameHint, parseContent) + if (parseContent && extractionDirOverride == null) { + writeCachedEpubBook( + extractionDir = extractionDir, + bookId = bookId, + originalBookNameHint = originalBookNameHint, + shouldUseToc = shouldUseToc, + book = book + ) + } + if (shouldDeleteExtractionDir) { + extractionDir.deleteRecursively() + } return@withContext book } } + private fun readCachedEpubBook( + extractionDir: File, + bookId: String, + originalBookNameHint: String, + shouldUseToc: Boolean + ): EpubBook? { + val metadataFile = File(extractionDir, BOOK_METADATA_FILE) + val manifestFile = File(extractionDir, CACHE_MANIFEST_FILE) + if (!metadataFile.isFile || !manifestFile.isFile) return null + + return try { + val manifest = jsonSerializer.decodeFromString(manifestFile.readText()) + val isCompatible = manifest.bookId == bookId && + manifest.originalBookNameHint == originalBookNameHint && + manifest.parserVersion == EPUB_EXTRACTION_CACHE_VERSION && + manifest.parseContent && + manifest.shouldUseToc == shouldUseToc + + if (!isCompatible) { + Timber.d("EPUB extraction cache manifest is stale for bookId=$bookId") + return null + } + + val cachedBook = jsonSerializer.decodeFromString(metadataFile.readText()) + .copy(extractionBasePath = extractionDir.absolutePath) + + cachedBook.takeIf { it.hasReadableExtractedContent() } + } catch (e: Exception) { + Timber.e(e, "Failed to read EPUB extraction cache for bookId=$bookId") + null + } + } + + private fun writeCachedEpubBook( + extractionDir: File, + bookId: String, + originalBookNameHint: String, + shouldUseToc: Boolean, + book: EpubBook + ) { + try { + File(extractionDir, BOOK_METADATA_FILE).writeText(jsonSerializer.encodeToString(book)) + File(extractionDir, CACHE_MANIFEST_FILE).writeText( + jsonSerializer.encodeToString( + EpubExtractionCacheManifest( + bookId = bookId, + originalBookNameHint = originalBookNameHint, + parserVersion = EPUB_EXTRACTION_CACHE_VERSION, + parseContent = true, + shouldUseToc = shouldUseToc + ) + ) + ) + } catch (e: Exception) { + Timber.e(e, "Failed to write EPUB extraction cache for bookId=$bookId") + } + } + private fun extractEpubContents(zipFile: ZipFile, extractionDir: File, parseContent: Boolean): Map { val filesMap = mutableMapOf() zipFile.use { zf -> diff --git a/app/src/main/java/com/aryan/reader/epub/Fb2Parser.kt b/app/src/main/java/com/aryan/reader/epub/Fb2Parser.kt index 2d6d743..458b37f 100644 --- a/app/src/main/java/com/aryan/reader/epub/Fb2Parser.kt +++ b/app/src/main/java/com/aryan/reader/epub/Fb2Parser.kt @@ -24,7 +24,11 @@ class Fb2Parser(private val context: Context) { extractionDirOverride: File? = null ): EpubBook = withContext(Dispatchers.IO) { val extractionDir = extractionDirOverride?.let(ImportedFileCache::prepareDirectory) - ?: ImportedFileCache.prepareActiveBookDir(context, bookId) + ?: if (parseContent) { + ImportedFileCache.prepareActiveBookDir(context, bookId) + } else { + ImportedFileCache.createTemporaryBookDir(context, bookId, "metadata") + } var streamToParse = inputStream try { diff --git a/app/src/main/java/com/aryan/reader/epub/ImportedFileCache.kt b/app/src/main/java/com/aryan/reader/epub/ImportedFileCache.kt index aaecd62..5199091 100644 --- a/app/src/main/java/com/aryan/reader/epub/ImportedFileCache.kt +++ b/app/src/main/java/com/aryan/reader/epub/ImportedFileCache.kt @@ -17,10 +17,18 @@ object ImportedFileCache { return File(context.cacheDir, activeBookDirName(bookId)) } - fun prepareActiveBookDir(context: Context, bookId: String): File { + fun ensureActiveBookDir(context: Context, bookId: String): File { + return activeBookDir(context, bookId).also { it.mkdirs() } + } + + fun resetActiveBookDir(context: Context, bookId: String): File { return prepareDirectory(activeBookDir(context, bookId)) } + fun prepareActiveBookDir(context: Context, bookId: String): File { + return resetActiveBookDir(context, bookId) + } + fun createTemporaryBookDir(context: Context, bookId: String, purpose: String): File { val dirName = buildString { append(TEMP_PREFIX) diff --git a/app/src/main/java/com/aryan/reader/epub/MobiParser.kt b/app/src/main/java/com/aryan/reader/epub/MobiParser.kt index ef46c8f..35fb60f 100644 --- a/app/src/main/java/com/aryan/reader/epub/MobiParser.kt +++ b/app/src/main/java/com/aryan/reader/epub/MobiParser.kt @@ -169,7 +169,11 @@ class MobiParser(private val context: Context) { val bookAuthor = parsedData.author ?: "Unknown Author" val extractionDir = extractionDirOverride?.let(ImportedFileCache::prepareDirectory) - ?: ImportedFileCache.prepareActiveBookDir(context, bookId) + ?: if (parseContent) { + ImportedFileCache.prepareActiveBookDir(context, bookId) + } else { + ImportedFileCache.createTemporaryBookDir(context, bookId, "metadata") + } val sequentialImageMap = parsedData.resources .filter { it.mediaType.startsWith("image/") } diff --git a/app/src/main/java/com/aryan/reader/epub/OdtParser.kt b/app/src/main/java/com/aryan/reader/epub/OdtParser.kt index e027fef..4451f12 100644 --- a/app/src/main/java/com/aryan/reader/epub/OdtParser.kt +++ b/app/src/main/java/com/aryan/reader/epub/OdtParser.kt @@ -33,7 +33,11 @@ class OdtParser(private val context: Context) { extractionDirOverride: File? = null ): EpubBook = withContext(Dispatchers.IO) { val extractionDir = extractionDirOverride?.let(ImportedFileCache::prepareDirectory) - ?: ImportedFileCache.prepareActiveBookDir(context, bookId) + ?: if (parseContent) { + ImportedFileCache.prepareActiveBookDir(context, bookId) + } else { + ImportedFileCache.createTemporaryBookDir(context, bookId, "metadata") + } val mathJaxFileName = "tex-mml-chtml.js" val mathJaxFile = File(extractionDir, mathJaxFileName) diff --git a/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt b/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt index 001b5e0..155377c 100644 --- a/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt +++ b/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt @@ -101,7 +101,7 @@ class SingleFileImporter(private val context: Context) { try { FileOutputStream(tempFile).bufferedWriter().use { writer -> - writer.write("\n\n\n\n${originalBookNameHint}\n") + writer.write("\n\n\n\n${generatedHtmlTitle(originalBookNameHint)}\n") if (isCsv) { writer.write("\n") @@ -189,18 +189,20 @@ class SingleFileImporter(private val context: Context) { ) } - val extractionDir = ImportedFileCache.prepareActiveBookDir(context, bookId) + val extractionDir = ImportedFileCache.ensureActiveBookDir(context, bookId) val metadataFile = File(extractionDir, "book_metadata.json") if (metadataFile.exists()) { try { val cachedBook = jsonSerializer.decodeFromString(metadataFile.readText()) + .copy(extractionBasePath = extractionDir.absolutePath) Timber.tag("FileOpenPerf").d("[MD] Loaded from cache instantly | bookId=$bookId") return@withContext cachedBook } catch (e: Exception) { Timber.e(e, "Failed to load cached MD, parsing again") } } + ImportedFileCache.resetActiveBookDir(context, bookId) val parseStart = System.currentTimeMillis() Timber.tag("FileOpenPerf").d("[MD] parseMarkdown START | file=$originalBookNameHint") @@ -323,18 +325,20 @@ class SingleFileImporter(private val context: Context) { ) } - val extractionDir = ImportedFileCache.prepareActiveBookDir(context, bookId) + val extractionDir = ImportedFileCache.ensureActiveBookDir(context, bookId) val metadataFile = File(extractionDir, "book_metadata.json") if (metadataFile.exists()) { try { val cachedBook = jsonSerializer.decodeFromString(metadataFile.readText()) + .copy(extractionBasePath = extractionDir.absolutePath) Timber.tag("FileOpenPerf").d("[TXT] Loaded from cache instantly | bookId=$bookId") return@withContext cachedBook } catch (e: Exception) { Timber.e(e, "Failed to load cached TXT, parsing again") } } + ImportedFileCache.resetActiveBookDir(context, bookId) val parseStart = System.currentTimeMillis() Timber.tag("FileOpenPerf").d("[TXT] parsePlainText START | file=$originalBookNameHint") @@ -484,18 +488,20 @@ class SingleFileImporter(private val context: Context) { ) } - val extractionDir = ImportedFileCache.prepareActiveBookDir(context, bookId) + val extractionDir = ImportedFileCache.ensureActiveBookDir(context, bookId) val metadataFile = File(extractionDir, "book_metadata.json") if (metadataFile.exists()) { try { val cachedBook = jsonSerializer.decodeFromString(metadataFile.readText()) + .copy(extractionBasePath = extractionDir.absolutePath) Timber.tag("FileOpenPerf").d("[HTML] Loaded from cache instantly | bookId=$bookId") return@withContext cachedBook } catch (e: Exception) { Timber.e(e, "Failed to load cached HTML, parsing again") } } + ImportedFileCache.resetActiveBookDir(context, bookId) val parseStart = System.currentTimeMillis() Timber.tag("FileOpenPerf").d("[HTML] parseHtml START | file=$originalBookNameHint") @@ -511,6 +517,7 @@ class SingleFileImporter(private val context: Context) { var inStyle = false var inBody = false var pageNum = 1 + val headBuilder = java.lang.StringBuilder() val currentChapterBuilder = java.lang.StringBuilder() var line: String? @@ -531,19 +538,9 @@ class SingleFileImporter(private val context: Context) { } if (!inBody) { - if (trimmed.startsWith("").substringBefore("") - if (t.isNotBlank()) title = t - } - val authorMatch = Regex("]+name=\"author\"[^>]+content=\"([^\"]+)\"").find( - line - ) - ?: Regex("]+property=\"article:author\"[^>]+content=\"([^\"]+)\"").find( - line - ) - if (authorMatch != null) { - author = authorMatch.groupValues[1] - } + headBuilder.append(line).append('\n') + extractHtmlTitle(headBuilder.toString())?.let { title = it } + extractHtmlAuthor(headBuilder.toString())?.let { author = it } if (trimmed.startsWith("") || (trimmed.isNotBlank() && !trimmed.startsWith("<") && !trimmed.startsWith("= 3 && + this[0] == '<' && + this[1].lowercaseChar() == 'h' && + this[2] in '1'..'6' + } + + private fun generatedHtmlTitle(originalBookNameHint: String): String { + if (!originalBookNameHint.endsWith(".txt", ignoreCase = true)) return originalBookNameHint + + val innerName = originalBookNameHint.dropLast(4) + return if (innerName.contains('.') && com.aryan.reader.isCodeOrDataFileName(innerName)) { + innerName + } else { + originalBookNameHint + } + } + + private fun extractHtmlTitle(line: String): String? { + val match = Regex( + pattern = "<\\s*title\\b[^>]*>(.*?)<\\s*/\\s*title\\s*>", + options = setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL) + ).find(line) ?: return null + + return Jsoup.parse(match.groupValues[1]).text().takeIf { it.isNotBlank() } + } + + private fun extractHtmlAuthor(line: String): String? { + val metaTag = Regex( + pattern = "<\\s*meta\\b[^>]*>", + options = setOf(RegexOption.IGNORE_CASE) + ).find(line)?.value ?: return null + + val name = Regex( + pattern = "\\b(?:name|property)\\s*=\\s*['\"]([^'\"]+)['\"]", + options = setOf(RegexOption.IGNORE_CASE) + ).find(metaTag)?.groupValues?.get(1) ?: return null + + if (!name.equals("author", ignoreCase = true) && !name.equals("article:author", ignoreCase = true)) { + return null + } + + return Regex( + pattern = "\\bcontent\\s*=\\s*['\"]([^'\"]+)['\"]", + options = setOf(RegexOption.IGNORE_CASE) + ).find(metaTag)?.groupValues?.get(1)?.takeIf { it.isNotBlank() } + } + private fun sanitizeHtmlFragment(html: String): String { return Jsoup.clean(html, "", htmlSafelist, htmlOutputSettings) } @@ -672,18 +717,20 @@ class SingleFileImporter(private val context: Context) { ) } - val extractionDir = ImportedFileCache.prepareActiveBookDir(context, bookId) + val extractionDir = ImportedFileCache.ensureActiveBookDir(context, bookId) val metadataFile = File(extractionDir, "book_metadata.json") if (metadataFile.exists()) { try { val cachedBook = jsonSerializer.decodeFromString(metadataFile.readText()) + .copy(extractionBasePath = extractionDir.absolutePath) Timber.tag("FileOpenPerf").d("[DOCX] Loaded from cache instantly | bookId=$bookId") return@withContext cachedBook } catch (e: Exception) { Timber.e(e, "Failed to load cached DOCX, parsing again") } } + ImportedFileCache.resetActiveBookDir(context, bookId) val parseStart = System.currentTimeMillis() Timber.tag("FileOpenPerf").d("[DOCX] parseDocx START | file=$originalBookNameHint") diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAnnotations.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAnnotations.kt index fd8cc6d..564ed28 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAnnotations.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAnnotations.kt @@ -70,58 +70,16 @@ import androidx.core.content.edit import androidx.core.text.HtmlCompat import com.aryan.reader.R import com.aryan.reader.epub.EpubChapter -import org.json.JSONArray -import org.json.JSONObject -import java.util.UUID +import com.aryan.reader.shared.EpubAnnotationSerializer private const val BOOKMARK_PREFS_NAME = "epub_reader_bookmarks" -data class Bookmark( - val cfi: String, - val chapterTitle: String, - val label: String? = null, - val snippet: String, - val pageInChapter: Int?, - val totalPagesInChapter: Int?, - val chapterIndex: Int -) - -enum class HighlightColor(val id: String, val color: Color, val cssClass: String) { - YELLOW("yellow", Color(0xFFFBC02D), "user-highlight-yellow"), - GREEN("green", Color(0xFF388E3C), "user-highlight-green"), - BLUE("blue", Color(0xFF1976D2), "user-highlight-blue"), - RED("red", Color(0xFFD32F2F), "user-highlight-red"), - PURPLE("purple", Color(0xFF7B1FA2), "user-highlight-purple"), - ORANGE("orange", Color(0xFFF57C00), "user-highlight-orange"), - CYAN("cyan", Color(0xFF0097A7), "user-highlight-cyan"), - MAGENTA("magenta", Color(0xFFC2185B), "user-highlight-magenta"), - LIME("lime", Color(0xFFAFB42B), "user-highlight-lime"), - PINK("pink", Color(0xFFE91E63), "user-highlight-pink"), - TEAL("teal", Color(0xFF00796B), "user-highlight-teal"), - INDIGO("indigo", Color(0xFF303F9F), "user-highlight-indigo"), - BLACK("black", Color(0xFF424242), "user-highlight-black"), - WHITE("white", Color(0xFFF5F5F5), "user-highlight-white"); -} - -data class UserHighlight( - val id: String = UUID.randomUUID().toString(), - val cfi: String, - val text: String, - val color: HighlightColor, - val chapterIndex: Int, - val note: String? = null -) +typealias Bookmark = com.aryan.reader.shared.EpubBookmark +typealias HighlightColor = com.aryan.reader.shared.HighlightColor +typealias UserHighlight = com.aryan.reader.shared.UserHighlight fun escapeJsString(value: String): String { - return value - .replace("\\", "\\\\") - .replace("'", "\\'") - .replace("\"", "\\\"") - .replace("\n", "\\n") - .replace("\r", "\\r") - .replace("\t", "\\t") - .replace("\u2028", "\\u2028") - .replace("\u2029", "\\u2029") + return com.aryan.reader.shared.escapeJsString(value) } fun saveHighlightPalette(context: Context, palette: List) { @@ -146,60 +104,21 @@ fun loadHighlightPalette(context: Context): List { fun loadBookmarks(context: Context, bookTitle: String, chapters: List, bookmarksJson: String?): Set { val stringSetToParse: Collection = if (bookmarksJson != null) { - try { - val jsonArray = JSONArray(bookmarksJson) - (0 until jsonArray.length()).map { jsonArray.getString(it) } - } catch (e: Exception) { - Timber.e(e, "Failed to parse bookmarks from ViewModel") - emptyList() - } + return EpubAnnotationSerializer.parseBookmarksJson(bookmarksJson, chapters.map { it.title }) } else { val prefs = context.getSharedPreferences(BOOKMARK_PREFS_NAME, Context.MODE_PRIVATE) val key = "bookmarks_cfi_${bookTitle.replace("[^a-zA-Z0-9]".toRegex(), "")}" prefs.getStringSet(key, emptySet()) ?: emptySet() } - return stringSetToParse.mapNotNull { jsonString -> - try { - val json = JSONObject(jsonString) - val chapterIndex = if (json.has("chapterIndex")) { - json.getInt("chapterIndex") - } else { - val chapterTitle = json.getString("chapterTitle") - chapters.indexOfFirst { it.title == chapterTitle }.coerceAtLeast(0) - } - Bookmark( - cfi = json.getString("cfi"), - chapterTitle = json.getString("chapterTitle"), - label = if (json.has("label")) json.getString("label") else null, - snippet = json.getString("snippet"), - pageInChapter = if (json.has("pageInChapter")) json.optInt("pageInChapter") else null, - totalPagesInChapter = if (json.has("totalPagesInChapter")) json.optInt("totalPagesInChapter") else null, - chapterIndex = chapterIndex - ) - } catch (_: Exception) { - null - } - }.toSet() + return EpubAnnotationSerializer.parseBookmarkEntries(stringSetToParse, chapters.map { it.title }) } fun saveHighlightsToPrefs(context: Context, bookTitle: String, highlights: List) { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) val sanitizedTitle = bookTitle.replace("[^a-zA-Z0-9]".toRegex(), "") val key = "highlights_data_$sanitizedTitle" - val jsonArray = JSONArray() - highlights.forEach { h -> - val obj = JSONObject().apply { - put("id", h.id) - put("cfi", h.cfi) - put("text", h.text) - put("colorId", h.color.id) - put("chapterIndex", h.chapterIndex) - put("note", h.note ?: "") - } - jsonArray.put(obj) - } - prefs.edit { putString(key, jsonArray.toString()) } + prefs.edit { putString(key, EpubAnnotationSerializer.highlightsToJson(highlights)) } } fun loadHighlightsFromPrefs(context: Context, bookTitle: String): List { @@ -207,72 +126,19 @@ fun loadHighlightsFromPrefs(context: Context, bookTitle: String): List() - try { - val jsonArray = JSONArray(jsonString) - for (i in 0 until jsonArray.length()) { - val obj = jsonArray.getJSONObject(i) - val colorId = obj.getString("colorId") - val color = HighlightColor.entries.find { it.id == colorId } ?: HighlightColor.YELLOW - val noteStr = obj.optString("note", "") - list.add( - UserHighlight( - id = obj.optString("id", UUID.randomUUID().toString()), - cfi = obj.getString("cfi"), - text = obj.getString("text"), - color = color, - chapterIndex = obj.getInt("chapterIndex"), - note = noteStr.takeIf { it.isNotBlank() } - ) - ) - } - } catch (e: Exception) { - Timber.e(e, "Error loading highlights") - } - return list + return EpubAnnotationSerializer.parseHighlightsJson(jsonString) } fun parseHighlightsJson(jsonString: String?): List { - if (jsonString.isNullOrBlank()) return emptyList() - val list = mutableListOf() - try { - val jsonArray = JSONArray(jsonString) - for (i in 0 until jsonArray.length()) { - val obj = jsonArray.getJSONObject(i) - val colorId = obj.getString("colorId") - val color = HighlightColor.entries.find { it.id == colorId } ?: HighlightColor.YELLOW - val noteStr = obj.optString("note", "") - list.add( - UserHighlight( - id = obj.optString("id", UUID.randomUUID().toString()), - cfi = obj.getString("cfi"), - text = obj.getString("text"), - color = color, - chapterIndex = obj.getInt("chapterIndex"), - note = noteStr.takeIf { it.isNotBlank() } - ) - ) - } - } catch (e: Exception) { - Timber.e(e, "Error parsing highlights JSON") - } - return list + return EpubAnnotationSerializer.parseHighlightsJson(jsonString) } fun highlightsToJson(highlights: List): String { - val jsonArray = JSONArray() - highlights.forEach { h -> - val obj = JSONObject().apply { - put("id", h.id) - put("cfi", h.cfi) - put("text", h.text) - put("colorId", h.color.id) - put("chapterIndex", h.chapterIndex) - put("note", h.note ?: "") - } - jsonArray.put(obj) - } - return jsonArray.toString() + return EpubAnnotationSerializer.highlightsToJson(highlights) +} + +fun bookmarksToJson(bookmarks: Collection): String { + return EpubAnnotationSerializer.bookmarksToJson(bookmarks) } fun clearHighlightsFromPrefs(context: Context, bookTitle: String) { @@ -291,28 +157,13 @@ fun processAndAddHighlight( chapterIndex: Int, currentList: MutableList ): String { - // Scenario: Exact match -> Update color and text instead of stacking identical spans - val exactMatchIndex = currentList.indexOfFirst { - it.chapterIndex == chapterIndex && it.cfi == newCfi - } - - if (exactMatchIndex != -1) { - val existing = currentList[exactMatchIndex] - currentList[exactMatchIndex] = existing.copy(color = newColor, text = newText) - return existing.cfi - } - - // Scenarios: Partial overlap or subsumption -> Add independently - currentList.add( - UserHighlight( - cfi = newCfi, - text = newText, - color = newColor, - chapterIndex = chapterIndex, - note = null - ) + return EpubAnnotationSerializer.processAndAddHighlight( + newCfi = newCfi, + newText = newText, + newColor = newColor, + chapterIndex = chapterIndex, + currentList = currentList ) - return newCfi } // --- UI Components --- diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt index f5014e6..636889c 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt @@ -167,7 +167,8 @@ enum class ReaderTool(val title: String, val category: String) { KEEP_SCREEN_ON("Keep Screen On", "Overflow Menu"), VISUAL_OPTIONS("Visual Options", "Overflow Menu"), AUTO_SCROLL("Auto Scroll", "Overflow Menu"), - TTS_SETTINGS("TTS Voice Settings", "Overflow Menu") + TTS_SETTINGS("TTS Voice Settings", "Overflow Menu"), + TTS_REPLACEMENTS("TTS Word Replacements", "Overflow Menu") } enum class FlatItemType { SECTION_HEADER, TOOL, EMPTY_PLACEHOLDER, MORE_HEADER, MORE_TOOL } @@ -282,6 +283,7 @@ fun EpubReaderTopBar( onTogglePageTurnAnimation: (Boolean) -> Unit, onStartAutoScroll: () -> Unit, onOpenTtsSettings: () -> Unit, + onOpenTtsReplacements: () -> Unit, onOpenDictionarySettings: () -> Unit, onOpenThemeSettings: () -> Unit, onOpenVisualOptions: () -> Unit, @@ -678,6 +680,23 @@ fun EpubReaderTopBar( ) } ) + HorizontalDivider() + } + if (!hiddenTools.contains(ReaderTool.TTS_REPLACEMENTS.name)) { + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_tts_word_replacements)) }, + onClick = { + showMoreMenu = false + onOpenTtsReplacements() + }, + leadingIcon = { + Icon( + Icons.Default.GraphicEq, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + } + ) } } } diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderDrawer.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderDrawer.kt index ed92a6f..de87dba 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderDrawer.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderDrawer.kt @@ -791,7 +791,8 @@ private fun HighlightsList( color = MaterialTheme.colorScheme.onSurfaceVariant ) } - if (!highlight.note.isNullOrBlank()) { + val note = highlight.note + if (!note.isNullOrBlank()) { Spacer(Modifier.height(8.dp)) Surface( shape = RoundedCornerShape(8.dp), @@ -799,7 +800,7 @@ private fun HighlightsList( modifier = Modifier.fillMaxWidth() ) { Text( - text = highlight.note, + text = note, style = MaterialTheme.typography.bodySmall.copy(fontStyle = androidx.compose.ui.text.font.FontStyle.Italic), modifier = Modifier.padding(12.dp), color = MaterialTheme.colorScheme.onSurfaceVariant diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt index df01d07..c24acc9 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt @@ -166,6 +166,7 @@ import com.aryan.reader.SearchResult import com.aryan.reader.SummarizationResult import com.aryan.reader.SummaryCacheManager import com.aryan.reader.TtsSettingsSheet +import com.aryan.reader.TtsWordReplacementsSheet import com.aryan.reader.areReaderAiFeaturesEnabled import com.aryan.reader.countWords import com.aryan.reader.isByokCloudTtsAvailable @@ -177,6 +178,7 @@ import com.aryan.reader.loadCustomThemes import com.aryan.reader.loadGlobalTextureTransparency import com.aryan.reader.loadReaderThemeId import com.aryan.reader.loadReaderTextureBitmap +import com.aryan.reader.loadTtsReplacementPreferences import com.aryan.reader.paginatedreader.BookPaginator import com.aryan.reader.paginatedreader.CfiUtils import com.aryan.reader.paginatedreader.HeaderBlock @@ -195,12 +197,16 @@ import com.aryan.reader.rememberSearchState import com.aryan.reader.saveCustomThemes import com.aryan.reader.saveGlobalTextureTransparency import com.aryan.reader.saveReaderThemeId +import com.aryan.reader.saveTtsReplacementPreferences +import com.aryan.reader.shared.ReaderTtsReplacementPreferences import com.aryan.reader.tts.SpeakerSamplePlayer import com.aryan.reader.tts.TtsPlaybackManager import com.aryan.reader.tts.loadTtsMode import com.aryan.reader.tts.splitTextIntoChunks +import com.aryan.reader.withTtsReplacements import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.isActive @@ -570,6 +576,7 @@ fun EpubReaderScreen( } } } else null, + stableBookId = uiState.selectedBookId, viewModel = viewModel ) } @@ -600,6 +607,7 @@ fun EpubReaderHost( onImportFont: (Uri) -> Unit, onToggleReflow: ((Int) -> Unit)? = null, onDeleteReflow: (() -> Unit)? = null, + stableBookId: String? = null, viewModel: MainViewModel ) { val view = LocalView.current @@ -668,11 +676,16 @@ fun EpubReaderHost( ) } - val locatorConverter = remember(context) { + val readerCacheBookId = remember(stableBookId, epubBook.title, epubBook.fileName) { + stableBookId ?: if (epubBook.fileName.length > 20) epubBook.fileName else getBookIdForPrefs(epubBook.title) + } + + val locatorConverter = remember(context, readerCacheBookId) { LocatorConverter( bookCacheDao = BookCacheDatabase.getDatabase(context).bookCacheDao(), proto = ProtoBuf { serializersModule = semanticBlockModule }, - context = context + context = context, + stableBookId = readerCacheBookId ) } @@ -698,9 +711,7 @@ fun EpubReaderHost( var isAutoScrollCollapsed by remember { mutableStateOf(false) } var isTtsCollapsed by remember { mutableStateOf(false) } - val bookId = remember(epubBook.title, epubBook.fileName) { - if (epubBook.fileName.length > 20) epubBook.fileName else getBookIdForPrefs(epubBook.title) - } + val bookId = readerCacheBookId var isAutoScrollLocal by remember { mutableStateOf(loadAutoScrollLocalMode(context, bookId)) } val initialSettings = remember(isAutoScrollLocal) { @@ -895,6 +906,8 @@ fun EpubReaderHost( var currentRenderMode by remember(renderMode) { mutableStateOf(renderMode) } var chapterToLoadOnSwitch by remember { mutableStateOf(null) } var lastKnownLocator by remember(initialLocator) { mutableStateOf(initialLocator) } + var paginatedReconfigurationAnchor by remember { mutableStateOf(null) } + var isPaginatedReconfigurationRestoring by remember { mutableStateOf(false) } val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() val roundedCornerBottomPadding = rememberBottomRoundedCornerPadding(view) @@ -910,18 +923,7 @@ fun EpubReaderHost( LaunchedEffect(bookmarks) { Timber.d("Bookmarks changed, saving...") - val stringSet = bookmarks.map { bookmark -> - JSONObject().apply { - put("cfi", bookmark.cfi) - put("chapterTitle", bookmark.chapterTitle) - put("label", bookmark.label) - put("snippet", bookmark.snippet) - bookmark.pageInChapter?.let { put("pageInChapter", it) } - bookmark.totalPagesInChapter?.let { put("totalPagesInChapter", it) } - put("chapterIndex", bookmark.chapterIndex) - }.toString() - } - onBookmarksChanged(JSONArray(stringSet).toString()) + onBookmarksChanged(bookmarksToJson(bookmarks)) } var activeBookmarkInVerticalView by remember { mutableStateOf(null) } @@ -1209,9 +1211,15 @@ fun EpubReaderHost( var showPermissionRationaleDialog by remember { mutableStateOf(false) } var showTtsSettingsSheet by remember { mutableStateOf(false) } + var showTtsReplacementsSheet by remember { mutableStateOf(false) } var showTtsControlsSheet by remember { mutableStateOf(false) } var showThemePanel by remember { mutableStateOf(false) } var showPaletteManager by remember { mutableStateOf(false) } + var ttsReplacementPreferences by remember { mutableStateOf(loadTtsReplacementPreferences(context)) } + val updateTtsReplacementPreferences: (ReaderTtsReplacementPreferences) -> Unit = { next -> + ttsReplacementPreferences = next + saveTtsReplacementPreferences(context, next) + } var currentThemeId by remember { mutableStateOf(loadReaderThemeId(context)) } var customThemes by remember { mutableStateOf(loadCustomThemes(context)) } @@ -1350,7 +1358,7 @@ fun EpubReaderHost( } Timber.tag("TTS_LOCATE") - .d("Saving locator from TTS. chapter=${locator.chapterIndex}, block=${locator.blockIndex}, progress=$progress") + .d("Saving resolved locator position. chapter=${locator.chapterIndex}, block=${locator.blockIndex}, progress=$progress") onSavePosition(locator, cfiForWebView, progress) } @@ -1535,7 +1543,7 @@ fun EpubReaderHost( val coverUriString = coverImagePath?.let { Uri.fromFile(File(it)).toString() } ttsChapterIndex = chapterIndex ttsController.start( - chunks = ttsChunks, + chunks = ttsChunks.withTtsReplacements(ttsReplacementPreferences, bookId), bookTitle = epubBook.title, chapterTitle = chapterTitle, coverImageUri = coverUriString, @@ -1588,7 +1596,11 @@ fun EpubReaderHost( val relativeOffset = startOffset - target.startOffsetInSource val safeRelativeOffset = relativeOffset.coerceIn(0, target.text.length) val slicedText = target.text.substring(safeRelativeOffset) - val newChunk = target.copy(text = slicedText, startOffsetInSource = startOffset) + val newChunk = target.copy( + text = slicedText, + startOffsetInSource = startOffset, + spokenText = slicedText, + ) val remainingChunks = mutableListOf(newChunk) remainingChunks.addAll(chunks.subList(foundIdx + 1, chunks.size)) @@ -1599,7 +1611,7 @@ fun EpubReaderHost( val chapterTitle = chapters.getOrNull(chapterIndex)?.title val coverUriString = coverImagePath?.let { Uri.fromFile(File(it)).toString() } ttsController.start( - chunks = remainingChunks, + chunks = remainingChunks.withTtsReplacements(ttsReplacementPreferences, bookId), bookTitle = epubBook.title, chapterTitle = chapterTitle, coverImageUri = coverUriString, @@ -1662,7 +1674,9 @@ fun EpubReaderHost( currentTtsMode = currentTtsMode, getAuthToken = { viewModel.getAuthToken() }, locatorConverter = locatorConverter, - epubBook = epubBook + epubBook = epubBook, + ttsReplacementPreferences = ttsReplacementPreferences, + ttsReplacementBookId = bookId ) TtsHighlightHandler( @@ -2045,8 +2059,26 @@ fun EpubReaderHost( } } - LaunchedEffect(paginatedPagerState.currentPage, paginator) { - if (currentRenderMode == RenderMode.PAGINATED && paginator != null && isPagerInitialized) { + LaunchedEffect(paginatedPagerState, paginator, currentRenderMode, isPagerInitialized, isPaginatedReconfigurationRestoring) { + if (currentRenderMode != RenderMode.PAGINATED || paginator == null || !isPagerInitialized) { + return@LaunchedEffect + } + snapshotFlow { paginatedPagerState.currentPage } + .collectLatest { page -> + if (!isPaginatedReconfigurationRestoring) { + (paginator as? BookPaginator)?.getLocatorForPage(page)?.let { locator -> + lastKnownLocator = locator + } + } + } + } + + LaunchedEffect(paginatedPagerState.currentPage, paginator, isPaginatedReconfigurationRestoring) { + if (currentRenderMode == RenderMode.PAGINATED && + paginator != null && + isPagerInitialized && + !isPaginatedReconfigurationRestoring + ) { delay(1500L) val pageToSave = paginatedPagerState.currentPage @@ -2164,12 +2196,21 @@ fun EpubReaderHost( RenderMode.PAGINATED -> { scope.launch { val pageToSave = paginatedPagerState.currentPage - val locator = (paginator as? BookPaginator)?.getLocatorForPage(pageToSave) + val pageLocator = if (isPaginatedReconfigurationRestoring) { + null + } else { + (paginator as? BookPaginator)?.getLocatorForPage(pageToSave) + } + val locator = pageLocator ?: paginatedReconfigurationAnchor ?: lastKnownLocator val chapterIndex = paginator?.findChapterIndexForPage(pageToSave) - if (locator != null && chapterIndex != null) { + if (locator != null) { val bookPaginator = paginator as? BookPaginator - val progress = if (totalBookLengthChars > 0 && bookPaginator != null) { + val progress = if (pageLocator == null || chapterIndex == null) { + saveResolvedLocatorPosition(locator, null) + onNavigateBack() + return@launch + } else if (totalBookLengthChars > 0 && bookPaginator != null) { val completedCharsInPreviousChapters = chapters.take(chapterIndex).sumOf { it.plainTextContent.length.toLong() } val currentPageInChapter = (bookPaginator.chapterStartPageIndices[chapterIndex] ?: 0).let { pageToSave - it } val charsScrolledInCurrentChapter = bookPaginator.getCharactersScrolledInChapter(chapterIndex, currentPageInChapter) @@ -2186,7 +2227,7 @@ fun EpubReaderHost( ) onSavePosition(locator, null, progress) } else { - Timber.w("Final save for paginated view failed. Locator or chapter index is null." + Timber.w("Final save for paginated view failed. Locator is null." ) } onNavigateBack() @@ -3570,7 +3611,7 @@ fun EpubReaderHost( ttsChapterIndex = targetChapterIndex ttsController.start( - chunks = ttsChunks, + chunks = ttsChunks.withTtsReplacements(ttsReplacementPreferences, bookId), bookTitle = epubBook.title, chapterTitle = chapterTitle, coverImageUri = coverUriString, @@ -3886,6 +3927,7 @@ fun EpubReaderHost( ) { PaginatedReaderScreen( book = epubBook, + bookId = readerCacheBookId, isDarkTheme = isDarkTheme, effectiveBg = effectiveBg, effectiveText = effectiveText, @@ -3910,7 +3952,18 @@ fun EpubReaderHost( activeTextureId = activeTextureId, activeTextureAlpha = activeTextureAlpha, initialChapterIndexInBook = lastKnownLocator?.chapterIndex, - modifier = Modifier.alpha(if (isPagerInitialized) 1f else 0f), + fallbackLocatorForReconfiguration = paginatedReconfigurationAnchor ?: lastKnownLocator, + onReconfigurationAnchorCaptured = { locator -> + paginatedReconfigurationAnchor = locator + lastKnownLocator = locator + }, + onReconfigurationRestoreActiveChanged = { isActive -> + isPaginatedReconfigurationRestoring = isActive + if (!isActive) { + paginatedReconfigurationAnchor = null + } + }, + modifier = Modifier.alpha(if (isPagerInitialized && !isPaginatedReconfigurationRestoring) 1f else 0f), onPaginatorReady = { newPaginator -> paginator = newPaginator }, @@ -4624,6 +4677,7 @@ fun EpubReaderHost( searchFocusRequester = searchFocusRequester, modifier = Modifier.align(Alignment.TopCenter), onOpenTtsSettings = { showTtsSettingsSheet = true }, + onOpenTtsReplacements = { showTtsReplacementsSheet = true }, onOpenDictionarySettings = { showDictionarySettingsSheet = true }, onOpenThemeSettings = { showThemePanel = true }, onOpenVisualOptions = { showVisualOptionsSheet = true }, @@ -5259,6 +5313,15 @@ fun EpubReaderHost( ) } + TtsWordReplacementsSheet( + isVisible = showTtsReplacementsSheet, + bookId = bookId, + bookTitle = epubBook.title, + preferences = ttsReplacementPreferences, + onPreferencesChange = updateTtsReplacementPreferences, + onDismiss = { showTtsReplacementsSheet = false }, + ) + if (showCustomizeToolsSheet) { CustomizeToolsSheet( hiddenTools = hiddenTools, diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderTts.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderTts.kt index 6808cf3..a1ae8cd 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderTts.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderTts.kt @@ -37,9 +37,11 @@ import com.aryan.reader.RenderMode import com.aryan.reader.epub.EpubChapter import com.aryan.reader.paginatedreader.BookPaginator import com.aryan.reader.paginatedreader.IPaginator +import com.aryan.reader.shared.ReaderTtsReplacementPreferences import com.aryan.reader.tts.TtsController import com.aryan.reader.tts.TtsPlaybackManager import com.aryan.reader.tts.TtsPlaybackManager.TtsMode +import com.aryan.reader.withTtsReplacements import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -115,7 +117,9 @@ fun TtsSessionObserver( currentTtsMode: TtsMode, getAuthToken: suspend () -> String?, locatorConverter: com.aryan.reader.paginatedreader.LocatorConverter, // NEW - epubBook: com.aryan.reader.epub.EpubBook // NEW + epubBook: com.aryan.reader.epub.EpubBook, // NEW + ttsReplacementPreferences: ReaderTtsReplacementPreferences, + ttsReplacementBookId: String? ) { val currentRenderModeState = rememberUpdatedState(currentRenderMode) val loadedChunkCountState = rememberUpdatedState(loadedChunkCount) @@ -131,6 +135,8 @@ fun TtsSessionObserver( val onTtsChapterIndexChangeState = rememberUpdatedState(onTtsChapterIndexChange) val locatorConverterState = rememberUpdatedState(locatorConverter) // NEW val epubBookState = rememberUpdatedState(epubBook) // NEW + val ttsReplacementPreferencesState = rememberUpdatedState(ttsReplacementPreferences) + val ttsReplacementBookIdState = rememberUpdatedState(ttsReplacementBookId) DisposableEffect(ttsController) { val job = scope.launch { @@ -168,7 +174,9 @@ fun TtsSessionObserver( ttsController = ttsController, scope = this, locatorConverter = locatorConverterState.value, - epubBook = epubBookState.value + epubBook = epubBookState.value, + ttsReplacementPreferences = ttsReplacementPreferencesState.value, + ttsReplacementBookId = ttsReplacementBookIdState.value ) } else if (currentRenderModeState.value == RenderMode.PAGINATED) { handlePaginatedAutoAdvance( @@ -182,7 +190,9 @@ fun TtsSessionObserver( onUpdateTtsChapter = onTtsChapterIndexChangeState.value, scope = this, ttsMode = currentTtsMode, - getAuthToken = getAuthToken + getAuthToken = getAuthToken, + ttsReplacementPreferences = ttsReplacementPreferencesState.value, + ttsReplacementBookId = ttsReplacementBookIdState.value ) } } else if (wasPlaying && !isPlaying && !sessionFinished) { @@ -303,7 +313,9 @@ private fun handleVerticalAutoAdvance( ttsController: TtsController, scope: CoroutineScope, locatorConverter: com.aryan.reader.paginatedreader.LocatorConverter, - epubBook: com.aryan.reader.epub.EpubBook + epubBook: com.aryan.reader.epub.EpubBook, + ttsReplacementPreferences: ReaderTtsReplacementPreferences, + ttsReplacementBookId: String? ) { if (currentTtsChapterIndex == null) return @@ -323,7 +335,7 @@ private fun handleVerticalAutoAdvance( val remainingChunks = nativeChunks.subList(resumeIdx + 1, nativeChunks.size) val token = getAuthToken() ttsController.start( - chunks = remainingChunks, + chunks = remainingChunks.withTtsReplacements(ttsReplacementPreferences, ttsReplacementBookId), bookTitle = epubBookTitle, chapterTitle = chapters.getOrNull(currentTtsChapterIndex)?.title, coverImageUri = coverImagePath?.let { android.net.Uri.fromFile(File(it)).toString() }, @@ -355,7 +367,7 @@ private fun handleVerticalAutoAdvance( onUpdateTtsChapter(nextIdx) ttsController.start( - chunks = nativeChunks, + chunks = nativeChunks.withTtsReplacements(ttsReplacementPreferences, ttsReplacementBookId), bookTitle = epubBookTitle, chapterTitle = chapters.getOrNull(nextIdx)?.title, coverImageUri = coverImagePath?.let { Uri.fromFile(File(it)).toString() }, @@ -399,7 +411,9 @@ private fun handlePaginatedAutoAdvance( onUpdateTtsChapter: (Int?) -> Unit, scope: CoroutineScope, ttsMode: TtsMode, - getAuthToken: suspend () -> String? + getAuthToken: suspend () -> String?, + ttsReplacementPreferences: ReaderTtsReplacementPreferences, + ttsReplacementBookId: String? ) { if (currentTtsChapterIndex != null && currentTtsChapterIndex < chapters.size - 1) { Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("Paginated: Searching for next TTS content...") @@ -432,7 +446,7 @@ private fun handlePaginatedAutoAdvance( val token = getAuthToken() ttsController.start( - chunks = nextChapterChunks, + chunks = nextChapterChunks.withTtsReplacements(ttsReplacementPreferences, ttsReplacementBookId), bookTitle = epubBookTitle, chapterTitle = chapterTitle, coverImageUri = coverUriString, diff --git a/app/src/main/java/com/aryan/reader/opds/OpdsModels.kt b/app/src/main/java/com/aryan/reader/opds/OpdsModels.kt index 500a86c..b4f9350 100644 --- a/app/src/main/java/com/aryan/reader/opds/OpdsModels.kt +++ b/app/src/main/java/com/aryan/reader/opds/OpdsModels.kt @@ -1,96 +1,9 @@ -// OpdsModels.kt package com.aryan.reader.opds -data class OpdsCatalog( - val id: String, - val title: String, - val url: String, - val isDefault: Boolean = false, - val username: String? = null, - val password: String? = null -) - -data class OpdsFacet( - val title: String, - val group: String, - val url: String, - val isActive: Boolean -) - -data class OpdsFeed( - val title: String, - val entries: List, - val nextUrl: String?, - val searchUrl: String? = null, - val facets: List = emptyList() -) - -data class OpdsAuthor( - val name: String, - val url: String? -) - -data class OpdsAcquisition( - val url: String, - val mimeType: String -) { - val formatName: String - get() = when { - mimeType.contains("epub") -> "EPUB" - mimeType.contains("pdf") -> "PDF" - mimeType.contains("markdown") || mimeType.contains("text/x-markdown") -> "MD" - mimeType.contains("html") || mimeType.contains("xhtml") -> "HTML" - mimeType.contains("mobi") || mimeType.contains("x-mobipocket-ebook") -> "MOBI" - mimeType.contains("fictionbook") || mimeType.contains("fb2") -> "FB2" - mimeType.contains("cbz") || mimeType.contains("comicbook") -> "CBZ" - mimeType.contains("cbr") || mimeType.contains("rar") -> "CBR" - mimeType.contains("txt") || mimeType.contains("text/plain") -> "TXT" - else -> mimeType.substringAfterLast("/").uppercase() - } - - val priority: Int - get() = when (formatName) { - "EPUB" -> 5 - "PDF" -> 4 - "MOBI" -> 3 - "FB2" -> 2 - "MD", "HTML" -> 2 - "CBZ" -> 1 - "TXT" -> 0 - else -> -1 - } -} - -data class OpdsEntry( - val id: String, - val title: String, - val summary: String?, - val authors: List = emptyList(), - val coverUrl: String?, - val acquisitions: List = emptyList(), - val navigationUrl: String?, - val publisher: String? = null, - val published: String? = null, - val language: String? = null, - val series: String? = null, - val seriesIndex: String? = null, - val categories: List = emptyList(), - // ADD THESE: - val pseCount: Int? = null, - val pseUrlTemplate: String? = null -) { - val author: String? - get() = authors.firstOrNull()?.name - - val bestAcquisition: OpdsAcquisition? - get() = acquisitions.maxByOrNull { it.priority } - - val isAcquisition: Boolean - get() = acquisitions.isNotEmpty() - - val isNavigation: Boolean - get() = navigationUrl != null && acquisitions.isEmpty() - - val isStreamable: Boolean - get() = pseUrlTemplate != null && pseCount != null && pseCount > 0 -} +typealias OpdsCatalog = com.aryan.reader.shared.opds.OpdsCatalog +typealias OpdsFacet = com.aryan.reader.shared.opds.OpdsFacet +typealias OpdsFeed = com.aryan.reader.shared.opds.OpdsFeed +typealias OpdsAuthor = com.aryan.reader.shared.opds.OpdsAuthor +typealias OpdsAcquisition = com.aryan.reader.shared.opds.OpdsAcquisition +typealias OpdsEntry = com.aryan.reader.shared.opds.OpdsEntry +typealias OpdsScreenState = com.aryan.reader.shared.opds.SharedOpdsScreenState diff --git a/app/src/main/java/com/aryan/reader/opds/OpdsParser.kt b/app/src/main/java/com/aryan/reader/opds/OpdsParser.kt index fbd3b9f..4f911c5 100644 --- a/app/src/main/java/com/aryan/reader/opds/OpdsParser.kt +++ b/app/src/main/java/com/aryan/reader/opds/OpdsParser.kt @@ -1,486 +1,3 @@ -// OpdsParser.kt package com.aryan.reader.opds -import android.util.Xml -import org.json.JSONArray -import org.json.JSONObject -import org.xmlpull.v1.XmlPullParser -import timber.log.Timber -import java.io.InputStream -import java.util.UUID - -class OpdsParser { - - fun parse(bodyString: String, baseUrl: String): OpdsFeed { - val trimmed = bodyString.trimStart() - return if (trimmed.startsWith("{")) { - Timber.tag("OpdsDebug").d("Detected OPDS 2.0 (JSON) feed") - parseOpds2(trimmed, baseUrl) - } else { - Timber.tag("OpdsDebug").d("Detected OPDS 1.x (XML) feed") - parseOpds1(trimmed.byteInputStream(), baseUrl) - } - } - - // --- OPDS 2.0 (JSON) Parsing --- - - private fun parseOpds2(jsonString: String, baseUrl: String): OpdsFeed { - val root = JSONObject(jsonString) - val metadata = root.optJSONObject("metadata") - val title = metadata?.optString("title") ?: "OPDS 2.0 Feed" - - var nextUrl: String? = null - var searchUrl: String? = null - val facets = mutableListOf() - - // Root Links - val links = root.optJSONArray("links") - if (links != null) { - for (i in 0 until links.length()) { - val link = links.getJSONObject(i) - val relArray = link.optJSONArray("rel") - val rels = mutableListOf() - if (relArray != null) { - for (j in 0 until relArray.length()) rels.add(relArray.getString(j)) - } else if (link.has("rel")) { - val rel = link.optString("rel") - if (rel.isNotBlank()) rels.add(rel) - } - - val href = link.optString("href") - if (href.isNotEmpty()) { - val resolvedHref = resolveUrl(baseUrl, href) - if (rels.contains("next")) { - nextUrl = resolvedHref - } else if (rels.contains("search")) { - searchUrl = resolvedHref - } - } - } - } - - // Facets - val facetsArray = root.optJSONArray("facets") - if (facetsArray != null) { - for (i in 0 until facetsArray.length()) { - val facetObj = facetsArray.getJSONObject(i) - val group = facetObj.optJSONObject("metadata")?.optString("title") ?: "Filter" - val facetLinks = facetObj.optJSONArray("links") - if (facetLinks != null) { - for (j in 0 until facetLinks.length()) { - val link = facetLinks.getJSONObject(j) - val href = link.optString("href") - if (href.isNotEmpty()) { - val titleFacet = link.optString("title", "Facet") - val properties = link.optJSONObject("properties") - val active = properties?.optBoolean("active", false) ?: false - facets.add(OpdsFacet(titleFacet, group, resolveUrl(baseUrl, href), active)) - } - } - } - } - } - - val entries = mutableListOf() - - // Publications - val publications = root.optJSONArray("publications") - if (publications != null) { - for (i in 0 until publications.length()) { - entries.add(parseOpds2Publication(publications.getJSONObject(i), baseUrl)) - } - } - - // Navigation - val navigation = root.optJSONArray("navigation") - if (navigation != null) { - for (i in 0 until navigation.length()) { - entries.add(parseOpds2Navigation(navigation.getJSONObject(i), baseUrl)) - } - } - - // Groups (Collections containing sub-navigation or sub-publications) - val groups = root.optJSONArray("groups") - if (groups != null) { - for (i in 0 until groups.length()) { - val group = groups.getJSONObject(i) - val groupTitle = group.optJSONObject("metadata")?.optString("title") ?: "" - - val groupNav = group.optJSONArray("navigation") - if (groupNav != null) { - for (j in 0 until groupNav.length()) { - entries.add(parseOpds2Navigation(groupNav.getJSONObject(j), baseUrl)) - } - } - - val groupPubs = group.optJSONArray("publications") - if (groupPubs != null) { - for (j in 0 until groupPubs.length()) { - entries.add(parseOpds2Publication(groupPubs.getJSONObject(j), baseUrl)) - } - } - - val groupLinks = group.optJSONArray("links") - if (groupLinks != null) { - for (j in 0 until groupLinks.length()) { - val link = groupLinks.getJSONObject(j) - val href = link.optString("href") - if (href.isNotEmpty()) { - val linkTitle = link.optString("title", groupTitle) - entries.add(OpdsEntry( - id = href, - title = linkTitle, - summary = null, - authors = emptyList(), - coverUrl = null, - acquisitions = emptyList(), - navigationUrl = resolveUrl(baseUrl, href) - )) - } - } - } - } - } - - return OpdsFeed(title, entries, nextUrl, searchUrl, facets) - } - - private fun parseOpds2Publication(pub: JSONObject, baseUrl: String): OpdsEntry { - val metadata = pub.optJSONObject("metadata") - val title = metadata?.optString("title") ?: "Unknown Title" - val id = metadata?.optString("identifier") ?: pub.optString("id", UUID.randomUUID().toString()) - val summary = metadata?.optString("description") ?: metadata?.optString("summary") - val language = metadata?.optString("language") - val publisher = metadata?.optString("publisher") - val published = metadata?.optString("published") - - val authors = mutableListOf() - val authorObj = metadata?.opt("author") - if (authorObj is String) { - authors.add(OpdsAuthor(authorObj, null)) - } else if (authorObj is JSONArray) { - for (i in 0 until authorObj.length()) { - val item = authorObj.get(i) - if (item is String) authors.add(OpdsAuthor(item, null)) - else if (item is JSONObject) { - val name = item.optString("name") - var uri: String? = null - val links = item.optJSONArray("links") - if (links != null && links.length() > 0) { - uri = resolveUrl(baseUrl, links.getJSONObject(0).optString("href")) - } - if (name.isNotBlank()) authors.add(OpdsAuthor(name, uri)) - } - } - } else if (authorObj is JSONObject) { - val name = authorObj.optString("name") - var uri: String? = null - val links = authorObj.optJSONArray("links") - if (links != null && links.length() > 0) { - uri = resolveUrl(baseUrl, links.getJSONObject(0).optString("href")) - } - if (name.isNotBlank()) authors.add(OpdsAuthor(name, uri)) - } - - val categories = mutableListOf() - when (val subjectObj = metadata?.opt("subject")) { - is String -> categories.add(subjectObj) - is JSONArray -> { - for (i in 0 until subjectObj.length()) { - val subj = subjectObj.get(i) - if (subj is String) categories.add(subj) - else if (subj is JSONObject) categories.add(subj.optString("name")) - } - } - is JSONObject -> { - categories.add(subjectObj.optString("name")) - } - } - - var series: String? = null - var seriesIndex: String? = null - val belongsTo = metadata?.optJSONObject("belongsTo") - if (belongsTo != null) { - val seriesObj = belongsTo.opt("series") - if (seriesObj is String) { - series = seriesObj - } else if (seriesObj is JSONObject) { - series = seriesObj.optString("name") - if (seriesObj.has("position")) { - seriesIndex = seriesObj.optDouble("position").toString().removeSuffix(".0") - } - } else if (seriesObj is JSONArray && seriesObj.length() > 0) { - val firstSeries = seriesObj.get(0) - if (firstSeries is String) { - series = firstSeries - } else if (firstSeries is JSONObject) { - series = firstSeries.optString("name") - if (firstSeries.has("position")) { - seriesIndex = firstSeries.optDouble("position").toString().removeSuffix(".0") - } - } - } - } - - var coverUrl: String? = null - val images = pub.optJSONArray("images") - if (images != null && images.length() > 0) { - for (i in 0 until images.length()) { - val image = images.getJSONObject(i) - val href = image.optString("href") - if (href.isNotEmpty()) { - val resolvedHref = resolveUrl(baseUrl, href) - if (coverUrl == null) coverUrl = resolvedHref - val rels = image.opt("rel") - var isCover = false - if (rels is String && rels == "cover") isCover = true - else if (rels is JSONArray) { - for (j in 0 until rels.length()) if (rels.optString(j) == "cover") isCover = true - } - if (isCover) { - coverUrl = resolvedHref - break - } - } - } - } - - val acquisitions = mutableListOf() - var pseCount: Int? = null - var pseUrlTemplate: String? = null - - val links = pub.optJSONArray("links") - if (links != null) { - for (i in 0 until links.length()) { - val link = links.getJSONObject(i) - val href = link.optString("href") - if (href.isNotEmpty()) { - val rels = link.opt("rel") - - var isStream = false - if (rels is String && rels == "http://vaemendis.net/opds-pse/stream") isStream = true - else if (rels is JSONArray) { - for (j in 0 until rels.length()) if (rels.optString(j) == "http://vaemendis.net/opds-pse/stream") isStream = true - } - if (isStream) { - pseUrlTemplate = resolveUrl(baseUrl, href) - val properties = link.optJSONObject("properties") - pseCount = properties?.optInt("numberOfItems")?.takeIf { it > 0 } - } - - var isAcquisition = false - if (rels is String && rels.contains("acquisition")) isAcquisition = true - else if (rels is JSONArray) { - for (j in 0 until rels.length()) if (rels.optString(j).contains("acquisition")) isAcquisition = true - } - - if (isAcquisition) { - val type = link.optString("type") ?: "" - acquisitions.add(OpdsAcquisition(resolveUrl(baseUrl, href), type)) - } - } - } - } - - return OpdsEntry( - id = id, title = title, summary = summary, authors = authors, - coverUrl = coverUrl, acquisitions = acquisitions, - navigationUrl = null, publisher = publisher, published = published, - language = language, series = series, seriesIndex = seriesIndex, categories = categories, - pseCount = pseCount, pseUrlTemplate = pseUrlTemplate - ) - } - - private fun parseOpds2Navigation(nav: JSONObject, baseUrl: String): OpdsEntry { - val title = nav.optString("title", "Unknown") - val href = nav.optString("href") - val summary = nav.optString("description") - val navigationUrl = if (href.isNotEmpty()) resolveUrl(baseUrl, href) else null - - return OpdsEntry( - id = href, title = title, summary = summary, authors = emptyList(), - coverUrl = null, acquisitions = emptyList(), - navigationUrl = navigationUrl - ) - } - - // --- OPDS 1.x (XML) Parsing --- - - private fun parseOpds1(inputStream: InputStream, baseUrl: String): OpdsFeed { - return inputStream.use { - val parser: XmlPullParser = Xml.newPullParser() - parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false) - parser.setInput(it, null) - parser.nextTag() - Timber.tag("OpdsDebug").d($$"Parser started at root tag: <${parser.name}>") - readFeed(parser, baseUrl) - } - } - - private fun readFeed(parser: XmlPullParser, baseUrl: String): OpdsFeed { - var title = "" - var nextUrl: String? = null - var searchUrl: String? = null - val entries = mutableListOf() - val facets = mutableListOf() - - parser.require(XmlPullParser.START_TAG, null, "feed") - while (parser.next() != XmlPullParser.END_TAG) { - if (parser.eventType != XmlPullParser.START_TAG) continue - - when (parser.name.substringAfter(":")) { - "title" -> title = readText(parser) - "entry" -> entries.add(readEntry(parser, baseUrl)) - "link" -> { - val rel = parser.getAttributeValue(null, "rel") - val href = parser.getAttributeValue(null, "href") - val linkTitle = parser.getAttributeValue(null, "title") - val facetGroup = parser.getAttributeValue(null, "opds:facetGroup") ?: "Filter" - val activeFacet = parser.getAttributeValue(null, "opds:activeFacet") == "true" - - if (rel == "next") { - nextUrl = resolveUrl(baseUrl, href ?: "") - } else if (rel == "search") { - searchUrl = resolveUrl(baseUrl, href ?: "") - } else if (rel == "facet" || rel == "http://opds-spec.org/facet") { - if (href != null && linkTitle != null) { - facets.add(OpdsFacet(linkTitle, facetGroup, resolveUrl(baseUrl, href), activeFacet)) - } - } - skip(parser) - } - else -> skip(parser) - } - } - return OpdsFeed(title, entries, nextUrl, searchUrl, facets) - } - - private fun readEntry(parser: XmlPullParser, baseUrl: String): OpdsEntry { - parser.require(XmlPullParser.START_TAG, null, "entry") - var id = ""; var title = ""; var summary: String? = null - var coverUrl: String? = null; var navigationUrl: String? = null - var publisher: String? = null; var published: String? = null; var language: String? = null - var series: String? = null; var seriesIndex: String? = null - var pseCount: Int? = null - var pseUrlTemplate: String? = null - val authors = mutableListOf() - val categories = mutableListOf() - val acquisitions = mutableListOf() - - while (parser.next() != XmlPullParser.END_TAG) { - if (parser.eventType != XmlPullParser.START_TAG) continue - - when (val tagName = parser.name.substringAfter(":")) { - "id" -> id = readText(parser) - "title" -> title = readText(parser) - "summary", "content" -> summary = readText(parser) - "author" -> authors.add(readAuthor(parser, baseUrl)) - "publisher" -> publisher = readText(parser) - "language" -> language = language ?: readText(parser) - "issued", "published", "updated" -> { - val date = readText(parser) - if (published == null || tagName != "updated") published = date - } - "category" -> { - val label = parser.getAttributeValue(null, "label") - val term = parser.getAttributeValue(null, "term") - val cat = label ?: term - if (!cat.isNullOrBlank()) categories.add(cat) - skip(parser) - } - "meta" -> { - val property = parser.getAttributeValue(null, "property") ?: parser.getAttributeValue(null, "name") - val content = parser.getAttributeValue(null, "content") - val textContent = readText(parser) - if (property == "calibre:series") series = content ?: textContent.takeIf { it.isNotBlank() } - else if (property == "calibre:series_index") seriesIndex = content ?: textContent.takeIf { it.isNotBlank() } - } - "link" -> { - val rel = parser.getAttributeValue(null, "rel") ?: "" - val href = parser.getAttributeValue(null, "href") ?: "" - val type = parser.getAttributeValue(null, "type") ?: "" - val linkTitle = parser.getAttributeValue(null, "title") - - if (rel == "http://vaemendis.net/opds-pse/stream") { - pseUrlTemplate = resolveUrl(baseUrl, href) - val countStr = parser.getAttributeValue(null, "pse:count") - pseCount = countStr?.toIntOrNull() - } - - if (rel == "http://calibre-ebook.com/opds/series") { - if (series == null) series = linkTitle - } - - if (href.isNotEmpty()) { - val absoluteUrl = resolveUrl(baseUrl, href) - - if (rel.contains("http://opds-spec.org/image")) { - if (coverUrl == null || rel.contains("thumbnail")) coverUrl = absoluteUrl - } else if (rel.contains("http://opds-spec.org/acquisition")) { - acquisitions.add(OpdsAcquisition(absoluteUrl, type)) - } else if (type.contains("profile=opds-catalog") || type.contains("application/atom+xml")) { - if (navigationUrl == null) navigationUrl = absoluteUrl - } else if (rel == "subsection" || rel == "collection" || rel == "start") { - if (navigationUrl == null) navigationUrl = absoluteUrl - } - } - skip(parser) - } - else -> skip(parser) - } - } - return OpdsEntry(id, title, summary, authors, coverUrl, acquisitions, navigationUrl, publisher, published, language, series, seriesIndex, categories, pseCount, pseUrlTemplate) - } - - private fun readAuthor(parser: XmlPullParser, baseUrl: String): OpdsAuthor { - var name = "" - var uri: String? = null - while (parser.next() != XmlPullParser.END_TAG) { - if (parser.eventType != XmlPullParser.START_TAG) continue - when (parser.name.substringAfter(":")) { - "name" -> name = readText(parser) - "uri" -> uri = resolveUrl(baseUrl, readText(parser)) - else -> skip(parser) - } - } - return OpdsAuthor(name, uri) - } - - private fun readText(parser: XmlPullParser): String { - val result = StringBuilder() - var depth = 1 - - while (depth != 0) { - when (parser.next()) { - XmlPullParser.TEXT, XmlPullParser.CDSECT, XmlPullParser.ENTITY_REF -> { - result.append(parser.text) - } - XmlPullParser.START_TAG -> depth++ - XmlPullParser.END_TAG -> depth-- - } - } - return result.toString().trim() - } - - private fun skip(parser: XmlPullParser) { - if (parser.eventType != XmlPullParser.START_TAG) throw java.lang.IllegalStateException() - var depth = 1 - while (depth != 0) { - when (parser.next()) { - XmlPullParser.END_TAG -> depth-- - XmlPullParser.START_TAG -> depth++ - } - } - } - - private fun resolveUrl(baseUrl: String, href: String): String { - return try { - val resolved = java.net.URL(java.net.URL(baseUrl), href).toString() - - resolved.replace("http://m.gutenberg.org", "https://m.gutenberg.org") - .replace("http://www.gutenberg.org", "https://www.gutenberg.org") - } catch (_: Exception) { - href - } - } -} \ No newline at end of file +typealias OpdsParser = com.aryan.reader.shared.opds.SharedOpdsParser diff --git a/app/src/main/java/com/aryan/reader/opds/OpdsRepository.kt b/app/src/main/java/com/aryan/reader/opds/OpdsRepository.kt index 0475147..097f95c 100644 --- a/app/src/main/java/com/aryan/reader/opds/OpdsRepository.kt +++ b/app/src/main/java/com/aryan/reader/opds/OpdsRepository.kt @@ -3,12 +3,11 @@ package com.aryan.reader.opds import android.content.Context import android.content.SharedPreferences import androidx.core.content.edit +import com.aryan.reader.shared.opds.SharedOpdsCatalogs import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request -import org.json.JSONArray -import org.json.JSONObject import timber.log.Timber import java.security.MessageDigest import java.util.UUID @@ -37,76 +36,26 @@ class OpdsRepository(context: Context) { fun getCatalogs(): List { val jsonString = prefs.getString(KEY_CATALOGS_JSON, null) - val catalogs = mutableListOf() - - if (jsonString != null) { - try { - val jsonArray = JSONArray(jsonString) - for (i in 0 until jsonArray.length()) { - val obj = jsonArray.getJSONObject(i) - catalogs.add( - OpdsCatalog( - id = obj.getString("id"), - title = obj.getString("title"), - url = obj.getString("url"), - isDefault = obj.optBoolean("isDefault", false), - username = obj.optString("username", "").takeIf { it.isNotBlank() }, - password = obj.optString("password", "").takeIf { it.isNotBlank() } - ) - ) - } - } catch (e: Exception) { - e.printStackTrace() - } + val decodedCatalogs = SharedOpdsCatalogs.decode(jsonString) + val catalogs = decodedCatalogs.ifEmpty { + SharedOpdsCatalogs.defaultCatalogs { UUID.randomUUID().toString() } } - - if (catalogs.isEmpty()) { - catalogs.add(OpdsCatalog(UUID.randomUUID().toString(), "Project Gutenberg", "https://m.gutenberg.org/ebooks.opds/", isDefault = true)) - catalogs.add(OpdsCatalog(UUID.randomUUID().toString(), "Standard Ebooks", "https://standardebooks.org/feeds/opds", isDefault = true)) - + if (decodedCatalogs.isEmpty()) { saveCatalogs(catalogs) } - return catalogs } - private fun resolveUrl(baseUrl: String, href: String): String { - return try { - val resolved = java.net.URL(java.net.URL(baseUrl), href).toString() - - resolved.replace("http://m.gutenberg.org", "https://m.gutenberg.org") - .replace("http://www.gutenberg.org", "https://www.gutenberg.org") - } catch (_: Exception) { - href - } - } - - suspend fun getSearchTemplate(openSearchUrl: String): String? = withContext(Dispatchers.IO) { + suspend fun getSearchTemplate( + openSearchUrl: String, + username: String? = null, + password: String? = null + ): String? = withContext(Dispatchers.IO) { try { val request = Request.Builder().url(openSearchUrl).build() - val response = httpClient.newCall(request).execute() + val response = getAuthenticatedClient(username, password).newCall(request).execute() val body = response.body?.string() ?: return@withContext null - - val parser = android.util.Xml.newPullParser() - parser.setFeature(org.xmlpull.v1.XmlPullParser.FEATURE_PROCESS_NAMESPACES, false) - parser.setInput(body.byteInputStream(), null) - var eventType = parser.eventType - - while (eventType != org.xmlpull.v1.XmlPullParser.END_DOCUMENT) { - if (eventType == org.xmlpull.v1.XmlPullParser.START_TAG && parser.name.equals("Url", ignoreCase = true)) { - val type = parser.getAttributeValue(null, "type") - if (type != null && (type.contains("atom+xml") || type.contains("opds+xml"))) { - val template = parser.getAttributeValue(null, "template") - if (template != null) { - val resolvedTemplate = resolveUrl(openSearchUrl, template) - Timber.tag("OpdsDebug").d("Resolved search template: $resolvedTemplate") - return@withContext resolvedTemplate - } - } - } - eventType = parser.next() - } - null + parser.extractOpenSearchTemplate(body, openSearchUrl) } catch (e: Exception) { Timber.e(e, "Failed to fetch OpenSearch template") null @@ -114,48 +63,28 @@ class OpdsRepository(context: Context) { } fun addCatalog(title: String, url: String, username: String? = null, password: String? = null) { - val current = getCatalogs().toMutableList() - current.add(OpdsCatalog(UUID.randomUUID().toString(), title, url, username = username, password = password)) - saveCatalogs(current) + saveCatalogs( + SharedOpdsCatalogs.addCatalog( + catalogs = getCatalogs(), + title = title, + url = url, + username = username, + password = password, + idFactory = { UUID.randomUUID().toString() } + ) + ) } fun updateCatalog(id: String, title: String, url: String, username: String?, password: String?) { - val current = getCatalogs().toMutableList() - val index = current.indexOfFirst { it.id == id } - if (index != -1 && !current[index].isDefault) { - current[index] = current[index].copy( - title = title.trim(), - url = url.trim(), - username = username?.trim().takeIf { !it.isNullOrBlank() }, - password = password?.trim().takeIf { !it.isNullOrBlank() } - ) - saveCatalogs(current) - } + saveCatalogs(SharedOpdsCatalogs.updateCatalog(getCatalogs(), id, title, url, username, password)) } fun removeCatalog(id: String) { - val current = getCatalogs().toMutableList() - val toRemove = current.find { it.id == id } - if (toRemove?.isDefault == true) { - return - } - current.removeAll { it.id == id } - saveCatalogs(current) + saveCatalogs(SharedOpdsCatalogs.removeCatalog(getCatalogs(), id)) } private fun saveCatalogs(catalogs: List) { - val jsonArray = JSONArray() - catalogs.forEach { catalog -> - val obj = JSONObject() - obj.put("id", catalog.id) - obj.put("title", catalog.title) - obj.put("url", catalog.url) - obj.put("isDefault", catalog.isDefault) - if (catalog.username != null) obj.put("username", catalog.username) - if (catalog.password != null) obj.put("password", catalog.password) - jsonArray.put(obj) - } - prefs.edit { putString(KEY_CATALOGS_JSON, jsonArray.toString()) } + prefs.edit { putString(KEY_CATALOGS_JSON, SharedOpdsCatalogs.encode(catalogs)) } } fun getAuthenticatedClient(username: String?, password: String?): OkHttpClient { @@ -272,4 +201,4 @@ class OpdsRepository(context: Context) { Result.failure(e) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aryan/reader/opds/OpdsViewModel.kt b/app/src/main/java/com/aryan/reader/opds/OpdsViewModel.kt index a8fdb05..9024233 100644 --- a/app/src/main/java/com/aryan/reader/opds/OpdsViewModel.kt +++ b/app/src/main/java/com/aryan/reader/opds/OpdsViewModel.kt @@ -5,7 +5,8 @@ import android.content.Context import android.net.Uri import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope -import com.aryan.reader.resolveFileExtensionSuffixFromName +import com.aryan.reader.shared.opds.SharedOpdsDownloadNamer +import com.aryan.reader.shared.opds.SharedOpdsSearch import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -18,16 +19,6 @@ import okhttp3.Request import timber.log.Timber import java.io.File -data class OpdsScreenState( - val catalogs: List = emptyList(), - val currentCatalog: OpdsCatalog? = null, - val currentFeed: OpdsFeed? = null, - val isLoading: Boolean = false, - val errorMessage: String? = null, - val isViewingCatalog: Boolean = false, - val searchUrlTemplate: String? = null -) - class OpdsViewModel(application: Application) : AndroidViewModel(application) { private val repository = OpdsRepository(application) @@ -142,42 +133,11 @@ class OpdsViewModel(application: Application) : AndroidViewModel(application) { } private fun resolveOpdsDownloadExtension(acquisition: OpdsAcquisition, response: Response): String { - val candidates = listOfNotNull( - response.header("Content-Disposition")?.let(::extractContentDispositionFilename), - Uri.parse(acquisition.url).lastPathSegment + return SharedOpdsDownloadNamer.resolveExtension( + acquisition = acquisition, + contentDisposition = response.header("Content-Disposition"), + urlPathSegment = Uri.parse(acquisition.url).lastPathSegment ) - - candidates.forEach { candidate -> - resolveFileExtensionSuffixFromName(Uri.decode(candidate))?.let { return it } - } - - return when (acquisition.formatName) { - "EPUB" -> ".epub" - "PDF" -> ".pdf" - "MOBI" -> ".mobi" - "FB2" -> ".fb2" - "CBZ" -> ".cbz" - "CBR" -> ".cbr" - "MD" -> ".md" - "HTML" -> ".html" - "TXT" -> ".txt" - else -> ".epub" - } - } - - private fun extractContentDispositionFilename(contentDisposition: String): String? { - val encodedFilename = Regex("filename\\*=UTF-8''([^;]+)", RegexOption.IGNORE_CASE) - .find(contentDisposition) - ?.groupValues - ?.getOrNull(1) - if (!encodedFilename.isNullOrBlank()) return encodedFilename.trim('"') - - return Regex("filename=\"?([^\";]+)\"?", RegexOption.IGNORE_CASE) - .find(contentDisposition) - ?.groupValues - ?.getOrNull(1) - ?.trim() - ?.trim('"') } init { @@ -233,17 +193,9 @@ class OpdsViewModel(application: Application) : AndroidViewModel(application) { viewModelScope.launch { _uiState.update { it.copy(isLoading = true, errorMessage = null) } - val template = if (!searchLink.contains("{searchTerms}")) { - repository.getSearchTemplate(searchLink) ?: searchLink - } else { - searchLink - } - - val finalUrl = if (template.contains("{searchTerms}")) { - template.replace("{searchTerms}", Uri.encode(query)) - } else { - val separator = if (template.contains("?")) "&" else "?" - "$template${separator}query=${Uri.encode(query)}" + val finalUrl = SharedOpdsSearch.buildSearchUrl(searchLink, query) { openSearchUrl -> + val catalog = _uiState.value.currentCatalog + repository.getSearchTemplate(openSearchUrl, catalog?.username, catalog?.password) } openFeedUrl(finalUrl) diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/AndroidHtmlParserPlatform.kt b/app/src/main/java/com/aryan/reader/paginatedreader/AndroidHtmlParserPlatform.kt index a069fec..370b5f8 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/AndroidHtmlParserPlatform.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/AndroidHtmlParserPlatform.kt @@ -62,7 +62,8 @@ fun androidHtmlToSemanticBlocks( fontFamilyMap: Map, constraints: androidx.compose.ui.unit.Constraints, imageDimensionsCache: Map> = emptyMap(), - mathSvgCache: Map = emptyMap() + mathSvgCache: Map = emptyMap(), + adaptThemeColors: Boolean = false ): List { return htmlToSemanticBlocks( html = html, @@ -76,6 +77,7 @@ fun androidHtmlToSemanticBlocks( imageDimensionsCache = imageDimensionsCache, mathSvgCache = mathSvgCache, resourceResolver = AndroidHtmlResourceResolver, - fontFamilyLoader = AndroidHtmlFontFamilyLoader + fontFamilyLoader = AndroidHtmlFontFamilyLoader, + adaptThemeColors = adaptThemeColors ) } diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt b/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt index 8133d68..b8752e2 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt @@ -42,7 +42,10 @@ import com.aryan.reader.paginatedreader.data.BookCacheDao import com.aryan.reader.paginatedreader.data.BookProcessingInput import com.aryan.reader.paginatedreader.data.BookProcessingWorker import com.aryan.reader.paginatedreader.data.ConfigurationCache +import com.aryan.reader.paginatedreader.data.LATEST_PAGE_CACHE_VERSION import com.aryan.reader.paginatedreader.data.LATEST_PROCESSING_VERSION +import com.aryan.reader.paginatedreader.data.PageCacheEntry +import com.aryan.reader.paginatedreader.data.PageIndexEntry import com.aryan.reader.paginatedreader.data.ProcessedBook import com.aryan.reader.paginatedreader.data.ProcessedChapter import com.aryan.reader.paginatedreader.data.SerializableEpubChapter @@ -80,7 +83,8 @@ data class TtsChunk( val text: String, val sourceCfi: String, val startOffsetInSource: Int, - val timedWords: List = emptyList() + val timedWords: List = emptyList(), + val spokenText: String = text ) private data class PaginationRequest(val chapterIndex: Int, val priority: Int) : Comparable { @@ -94,6 +98,26 @@ private data class PaginationRequest(val chapterIndex: Int, val priority: Int) : } } +private const val PAGE_INDEX_ANCHOR_SEPARATOR = "\u001F" + +private data class TextRangeIndex( + val pageInChapter: Int, + val blockIndex: Int, + val startOffset: Int, + val endOffset: Int +) + +private data class PageNavigationEntry( + val pageInChapter: Int, + val firstBlockIndex: Int, + val lastBlockIndex: Int, + val firstTextBlockIndex: Int?, + val firstTextCharOffset: Int, + val firstTextEndOffset: Int, + val firstCfi: String?, + val anchors: Set +) + @OptIn(ExperimentalSerializationApi::class) @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) @Stable @@ -138,7 +162,7 @@ class BookPaginator( internal val chapterPageCounts = ConcurrentHashMap() val chapterStartPageIndices = ConcurrentHashMap() - private val pageCache = object : LruCache>(6) { + private val pageCache = object : LruCache>(12) { override fun entryRemoved(evicted: Boolean, key: Int, oldValue: List, newValue: List?) { Timber.d("Chapter $key pages removed from cache. Evicted: $evicted") } @@ -151,10 +175,15 @@ class BookPaginator( } private val chapterCharacterIndex = ConcurrentHashMap>() private val chapterCumulativeChars = ConcurrentHashMap>() + private val chapterTextRangeIndex = ConcurrentHashMap>() + private val chapterPageNavigationIndex = ConcurrentHashMap>() + private val chapterAnchorPageIndex = ConcurrentHashMap>() private var pageCountsAreAccurate by mutableStateOf(false) private val finalizedChapterCounts = ConcurrentHashMap.newKeySet() private var currentConfigHash: Int = 0 + @Volatile + private var chapterStartSnapshot: IntArray = IntArray(0) private val paginationQueue = PriorityBlockingQueue() private val chaptersBeingProcessed = ConcurrentHashMap.newKeySet() @@ -198,6 +227,7 @@ class BookPaginator( val bookRecord = bookCacheDao.getProcessedBook(bookId) if (bookRecord == null || bookRecord.processingVersion < LATEST_PROCESSING_VERSION) { Timber.i("Book cache is new or stale. Creating initial record.") + bookCacheDao.deleteEntireBookCache(bookId) val initialBook = ProcessedBook(bookId, LATEST_PROCESSING_VERSION, 0) // Temp 0 bookCacheDao.insertProcessedBook(initialBook) enqueueBookProcessingWork() @@ -229,8 +259,10 @@ class BookPaginator( triggerPagination(startChapter, PRIORITY_HIGHEST) // Queue neighbors with lower priority - if (startChapter + 1 < chapters.size) triggerPagination(startChapter + 1, PRIORITY_LOW) - if (startChapter - 1 >= 0) triggerPagination(startChapter - 1, PRIORITY_LOW) + for (offset in 1..2) { + if (startChapter + offset < chapters.size) triggerPagination(startChapter + offset, PRIORITY_LOW) + if (startChapter - offset >= 0) triggerPagination(startChapter - offset, PRIORITY_LOW) + } isLoading = false Timber.i("Paginator initialized. UI is ready.") @@ -238,10 +270,8 @@ class BookPaginator( } } - // [ADD this new function] private fun runEstimator() { var runningTotal = 0 - val tempCounts = mutableMapOf() // This loop is extremely fast (math only) chapters.forEachIndexed { index, chapter -> @@ -255,12 +285,12 @@ class BookPaginator( chapterPageCounts[index] = estimatedCount chapterStartPageIndices[index] = runningTotal - tempCounts[index] = estimatedCount runningTotal += estimatedCount } totalPageCount = runningTotal pageCountsAreAccurate = false + rebuildChapterStartSnapshot() Timber.i("Estimator finished. Estimated total pages: $totalPageCount") } @@ -287,6 +317,11 @@ class BookPaginator( append("-pg:$paragraphGapMultiplier") append("-img:$imageSizeMultiplier") append("-vm:$verticalMarginMultiplier") + append("-proc:$LATEST_PROCESSING_VERSION") + append("-pageCache:$LATEST_PAGE_CACHE_VERSION") + append("-ua:${userAgentStylesheet.hashCode()}") + append("-css:${bookCss.hashCode()}") + append("-fonts:${allFontFaces.hashCode()}") } val hash = configString.hashCode() return hash @@ -325,6 +360,7 @@ class BookPaginator( } totalPageCount = runningTotal pageCountsAreAccurate = countsMap.size == chapters.size + rebuildChapterStartSnapshot() } private suspend fun updateAndSaveConfigurationCache() { @@ -340,12 +376,204 @@ class BookPaginator( bookCacheDao.insertConfigurationCache(newCache) bookCacheDao.cleanupOldConfigurations(bookId) + bookCacheDao.cleanupOldPageCaches(bookId) if (finalizedChapterCounts.size >= chapters.size) { pageCountsAreAccurate = true } } + private fun rebuildChapterStartSnapshot() { + chapterStartSnapshot = IntArray(chapters.size) { index -> + chapterStartPageIndices[index] ?: 0 + } + } + + private fun chapterContentVersion(chapter: EpubChapter): Int { + val backingFile = java.io.File(extractionBasePath, chapter.htmlFilePath) + return buildString { + append(chapter.absPath) + append('|') + append(chapter.htmlFilePath) + append('|') + append(chapter.htmlContent.length) + append('|') + append(chapter.htmlContent.hashCode()) + append('|') + append(chapter.plainTextContent.length) + append('|') + append(chapter.plainTextContent.hashCode()) + append('|') + if (backingFile.exists()) { + append(backingFile.length()) + append('|') + append(backingFile.lastModified()) + } + }.hashCode() + } + + private suspend fun loadCachedPagesForChapter(chapter: EpubChapter, chapterIndex: Int): List? { + val cachedPages = bookCacheDao.getPageCache(bookId, currentConfigHash, chapterIndex) ?: return null + val expectedContentVersion = chapterContentVersion(chapter) + val isCompatible = cachedPages.processingVersion == LATEST_PROCESSING_VERSION && + cachedPages.pageCacheVersion == LATEST_PAGE_CACHE_VERSION && + cachedPages.contentVersion == expectedContentVersion + + if (!isCompatible) { + Timber.d("Page cache stale for chapter $chapterIndex. Ignoring cached pages.") + return null + } + + return try { + val pages = proto.decodeFromByteArray>(cachedPages.pagesProto) + if (pages.size != cachedPages.pageCount) { + Timber.w("Page cache count mismatch for chapter $chapterIndex. Ignoring cached pages.") + null + } else { + pageCache.put(chapterIndex, pages) + applyPageRuntimeIndexes(chapterIndex, pages) + updatePageCountsOnMain(chapterIndex, pages.size) + Timber.i("Page cache HIT for chapter $chapterIndex. Loaded ${pages.size} measured pages.") + pages + } + } catch (e: Exception) { + Timber.e(e, "Failed to deserialize page cache for chapter $chapterIndex") + null + } + } + + private fun savePageCacheAsync(chapter: EpubChapter, chapterIndex: Int, pages: List) { + coroutineScope.launch(Dispatchers.IO) { + try { + val pageIndexEntries = buildPersistentPageIndexEntries(chapterIndex, pages) + val cacheEntry = PageCacheEntry( + bookId = bookId, + configHash = currentConfigHash, + chapterIndex = chapterIndex, + processingVersion = LATEST_PROCESSING_VERSION, + pageCacheVersion = LATEST_PAGE_CACHE_VERSION, + contentVersion = chapterContentVersion(chapter), + pageCount = pages.size, + pagesProto = proto.encodeToByteArray(pages) + ) + bookCacheDao.insertPageCache(cacheEntry, pageIndexEntries) + Timber.d("Saved measured page cache for chapter $chapterIndex (${pages.size} pages).") + } catch (e: Exception) { + Timber.e(e, "Failed to persist page cache for chapter $chapterIndex") + } + } + } + + private fun getAllBlocks(blocks: List): List { + return blocks.flatMap { block -> + when (block) { + is WrappingContentBlock -> listOf(block, block.floatedImage) + getAllBlocks(block.paragraphsToWrap) + is FlexContainerBlock -> listOf(block) + getAllBlocks(block.children) + is TableBlock -> listOf(block) + block.rows.flatten().flatMap { getAllBlocks(it.content) } + else -> listOf(block) + } + } + } + + private fun applyPageRuntimeIndexes(chapterIndex: Int, pages: List) { + val characterIndex = mutableListOf() + val textRangeIndex = mutableListOf() + val navigationEntries = mutableListOf() + val anchorPageMap = linkedMapOf() + val cumulativeCharsPerPage = mutableListOf() + var runningTotalChars = 0L + + pages.forEachIndexed { pageInChapterIndex, page -> + val allBlocksOnPage = getAllBlocks(page.content) + val allTextBlocksOnPage = getAllTextBlocks(page.content) + val anchors = allBlocksOnPage.flatMap { findAllIds(it) }.toSet() + anchors.forEach { anchorPageMap.putIfAbsent(it, pageInChapterIndex) } + + allTextBlocksOnPage.forEach { block -> + if (block.cfi != null && block.startCharOffsetInSource >= 0 && block.content.isNotEmpty()) { + val startOffset = block.startCharOffsetInSource + val endOffset = startOffset + block.content.text.length + characterIndex.add( + PageCharacterRange( + pageInChapter = pageInChapterIndex, + cfi = block.cfi!!, + startOffset = startOffset, + endOffset = endOffset + ) + ) + textRangeIndex.add( + TextRangeIndex( + pageInChapter = pageInChapterIndex, + blockIndex = block.blockIndex, + startOffset = startOffset, + endOffset = endOffset + ) + ) + } + } + + val firstTextBlock = allTextBlocksOnPage.firstOrNull { it.content.text.isNotBlank() } + ?: allTextBlocksOnPage.firstOrNull() + val firstBlock = allBlocksOnPage.firstOrNull() + val blockIndices = allBlocksOnPage.map { it.blockIndex } + navigationEntries.add( + PageNavigationEntry( + pageInChapter = pageInChapterIndex, + firstBlockIndex = blockIndices.minOrNull() ?: firstBlock?.blockIndex ?: -1, + lastBlockIndex = blockIndices.maxOrNull() ?: firstBlock?.blockIndex ?: -1, + firstTextBlockIndex = firstTextBlock?.blockIndex, + firstTextCharOffset = firstTextBlock?.startCharOffsetInSource ?: 0, + firstTextEndOffset = firstTextBlock?.let { it.startCharOffsetInSource + it.content.text.length } ?: 0, + firstCfi = firstTextBlock?.cfi ?: firstBlock?.cfi, + anchors = anchors + ) + ) + + runningTotalChars += allTextBlocksOnPage.sumOf { it.content.text.length.toLong() } + cumulativeCharsPerPage.add(runningTotalChars) + } + + chapterCharacterIndex[chapterIndex] = characterIndex + chapterTextRangeIndex[chapterIndex] = textRangeIndex + chapterPageNavigationIndex[chapterIndex] = navigationEntries + chapterAnchorPageIndex[chapterIndex] = anchorPageMap + chapterCumulativeChars[chapterIndex] = cumulativeCharsPerPage + } + + private fun buildPersistentPageIndexEntries(chapterIndex: Int, pages: List): List { + val entries = chapterPageNavigationIndex[chapterIndex] ?: run { + applyPageRuntimeIndexes(chapterIndex, pages) + chapterPageNavigationIndex[chapterIndex].orEmpty() + } + + return entries.map { entry -> + PageIndexEntry( + bookId = bookId, + configHash = currentConfigHash, + chapterIndex = chapterIndex, + pageInChapter = entry.pageInChapter, + firstBlockIndex = entry.firstBlockIndex, + lastBlockIndex = entry.lastBlockIndex, + firstTextBlockIndex = entry.firstTextBlockIndex, + firstTextCharOffset = entry.firstTextCharOffset, + firstTextEndOffset = entry.firstTextEndOffset, + firstCfi = entry.firstCfi, + anchors = entry.anchors.sorted().joinToString(PAGE_INDEX_ANCHOR_SEPARATOR) + ) + } + } + + private suspend fun updatePageCountsOnMain(chapterIndex: Int, actualPageCount: Int) { + withContext(Dispatchers.Main) { + if (chapterPageCounts[chapterIndex] != actualPageCount) { + updatePageCounts(chapterIndex, actualPageCount) + } else if (finalizedChapterCounts.add(chapterIndex)) { + coroutineScope.launch(Dispatchers.IO) { updateAndSaveConfigurationCache() } + } + generation++ + } + } + suspend fun getTtsChunksForChapter(chapterIndex: Int, startingFromPageInChapter: Int = 0): List? { val pages = pageCache[chapterIndex] ?: paginateChapter(chapterIndex) if (pages.isNullOrEmpty()) { @@ -401,7 +629,12 @@ class BookPaginator( private fun enqueueBookProcessingWork() { val serializableChapters = chapters.map { - SerializableEpubChapter(it.htmlContent, it.title, it.absPath) + SerializableEpubChapter( + htmlContent = it.htmlContent, + title = it.title, + absPath = it.absPath, + htmlFilePath = it.htmlFilePath + ) } val input = BookProcessingInput( @@ -437,13 +670,12 @@ class BookPaginator( chapterAbsPath = chapter.absPath, extractionBasePath = extractionBasePath, userTextAlign = userTextAlign, - paragraphGapMultiplier = paragraphGapMultiplier + paragraphGapMultiplier = paragraphGapMultiplier, + adaptThemeColors = false ) bookCacheDao.getProcessedChapter(bookId, chapterIndex)?.let { cachedChapter -> - if (cachedChapter.estimatedPageCount == 0) { - Timber.d("getBlocksForChapter: Found 'lite' cache for chapter $chapterIndex. Reprocessing for full fidelity.") - } else { + if (cachedChapter.contentBlocksProto.isNotEmpty()) { try { val semanticBlocks = proto.decodeFromByteArray>(cachedChapter.contentBlocksProto) @@ -466,10 +698,12 @@ class BookPaginator( } catch (e: Exception) { Timber.e(e, "Failed to deserialize/style chapter $chapterIndex from DB. Reprocessing for this session.") } + } else { + Timber.d("getBlocksForChapter: Cached chapter $chapterIndex had no semantic payload. Reprocessing.") } } - Timber.d("getBlocksForChapter: Cache MISS or 'lite' version found for chapter $chapterIndex. Parsing to Semantic IR.") + Timber.d("getBlocksForChapter: Cache MISS for chapter $chapterIndex. Parsing to Semantic IR.") var htmlToParse = chapter.htmlContent if (htmlToParse.isEmpty()) { @@ -506,10 +740,10 @@ class BookPaginator( val processedHtml = document.outerHtml() var parsingCssRules = OptimizedCssRules() - val uaResult = CssParser.parse(cssContent = userAgentStylesheet, cssPath = null, baseFontSizeSp = textStyle.fontSize.value, density = density.density, constraints = constraints, isDarkTheme = false, themeBackgroundColor = themeBackgroundColor, themeTextColor = themeTextColor) + val uaResult = CssParser.parse(cssContent = userAgentStylesheet, cssPath = null, baseFontSizeSp = textStyle.fontSize.value, density = density.density, constraints = constraints, isDarkTheme = false, adaptThemeColors = false) parsingCssRules = parsingCssRules.merge(uaResult.rules) bookCss.forEach { (path, content) -> - val bookCssResult = CssParser.parse(cssContent = content, cssPath = path, baseFontSizeSp = textStyle.fontSize.value, density = density.density, constraints = constraints, isDarkTheme = false, themeBackgroundColor = themeBackgroundColor, themeTextColor = themeTextColor) + val bookCssResult = CssParser.parse(cssContent = content, cssPath = path, baseFontSizeSp = textStyle.fontSize.value, density = density.density, constraints = constraints, isDarkTheme = false, adaptThemeColors = false) parsingCssRules = parsingCssRules.merge(bookCssResult.rules) } @@ -522,7 +756,8 @@ class BookPaginator( density = density, fontFamilyMap = fontFamilyMap, constraints = constraints, - mathSvgCache = svgResults + mathSvgCache = svgResults, + adaptThemeColors = false ) coroutineScope.launch(Dispatchers.IO) { @@ -617,6 +852,7 @@ class BookPaginator( for (i in (chapterIndex + 1) until chapters.size) { chapterStartPageIndices[i] = (chapterStartPageIndices[i] ?: 0) + difference } + rebuildChapterStartSnapshot() if (chapterIndex < currentUserChapterIndex.value) { pageShiftRequest.tryEmit(difference) @@ -640,6 +876,14 @@ class BookPaginator( val chapterStart = chapterStartPageIndices[chapterIndex] ?: 0 val currentPageInChapter = pageIndex - chapterStart + chapterAnchorPageIndex[chapterIndex]?.let { anchorPages -> + val anchorSet = tocAnchors.toSet() + return anchorPages + .filter { (anchor, anchorPage) -> anchor in anchorSet && anchorPage <= currentPageInChapter } + .maxByOrNull { it.value } + ?.key + } + var lastFoundAnchor: String? = null val anchorSet = tocAnchors.toSet() @@ -685,6 +929,19 @@ class BookPaginator( ) return null } + val starts = chapterStartSnapshot + if (starts.isNotEmpty()) { + val exactOrInsertionPoint = starts.binarySearch(pageIndex) + val index = if (exactOrInsertionPoint >= 0) { + exactOrInsertionPoint + } else { + -exactOrInsertionPoint - 2 + } + if (index in chapters.indices) { + return index + } + } + val entry = chapterStartPageIndices.entries .filter { it.value <= pageIndex } .maxWithOrNull(compareBy({ it.value }, { it.key })) @@ -698,12 +955,24 @@ class BookPaginator( override fun getCfiForPage(pageIndex: Int): String? { val chapterIndex = findChapterIndexForPage(pageIndex) ?: return null + val chapterStart = chapterStartPageIndices[chapterIndex] ?: 0 + val pageInChapterIndex = pageIndex - chapterStart + chapterPageNavigationIndex[chapterIndex] + ?.getOrNull(pageInChapterIndex) + ?.firstCfi + ?.let { cfi -> + val offset = chapterPageNavigationIndex[chapterIndex] + ?.getOrNull(pageInChapterIndex) + ?.firstTextCharOffset + ?: 0 + return if (offset > 0 && !cfi.contains(':')) "$cfi:$offset" else cfi + } + val chapterPages = pageCache[chapterIndex] if (chapterPages == null) { Timber.w("getCfiForPage: Chapter $chapterIndex not in cache for page $pageIndex.") return null } - val pageInChapterIndex = pageIndex - (chapterStartPageIndices[chapterIndex] ?: 0) val pageContent = chapterPages.getOrNull(pageInChapterIndex)?.content ?: return null val firstTextBlock = pageContent.firstOrNull { it is TextContentBlock } as? TextContentBlock @@ -729,6 +998,11 @@ class BookPaginator( return null } + loadCachedPagesForChapter(chapter, chapterIndex)?.let { + Timber.d("paginateChapter: Persistent page cache HIT for chapter $chapterIndex.") + return it + } + val blocks = blockCache[chapterIndex] ?: run { Timber.d("paginateChapter: L2 Cache MISS for chapter $chapterIndex. Loading from DB.") val blocksFromDb = getBlocksForChapter(chapter, chapterIndex) @@ -757,47 +1031,10 @@ class BookPaginator( pageCache.put(chapterIndex, pages) Timber.d("paginateChapter: Chapter $chapterIndex pages stored in L1 pageCache.") - pageCache.put(chapterIndex, pages) - Timber.d("paginateChapter: Chapter $chapterIndex pages stored in L1 pageCache.") + applyPageRuntimeIndexes(chapterIndex, pages) + savePageCacheAsync(chapter, chapterIndex, pages) - val characterIndex = mutableListOf() - pages.forEachIndexed { pageInChapterIndex, page -> - var totalCharsOnPage = 0L - val allTextBlocksOnPage = getAllTextBlocks(page.content) - allTextBlocksOnPage.forEach { block -> - if (block.cfi != null && block.startCharOffsetInSource >= 0 && block.content.isNotEmpty()) { - val startOffset = block.startCharOffsetInSource - val endOffset = startOffset + block.content.text.length - totalCharsOnPage += block.content.text.length - - characterIndex.add( - PageCharacterRange( - pageInChapter = pageInChapterIndex, - cfi = block.cfi!!, - startOffset = startOffset, - endOffset = endOffset - ) - ) - } - } - } - chapterCharacterIndex[chapterIndex] = characterIndex - - val cumulativeCharsPerPage = mutableListOf() - var runningTotalChars = 0L - pages.forEachIndexed { _, page -> - val charsOnPage = getAllTextBlocks(page.content).sumOf { it.content.text.length.toLong() } - runningTotalChars += charsOnPage - cumulativeCharsPerPage.add(runningTotalChars) - } - chapterCumulativeChars[chapterIndex] = cumulativeCharsPerPage - - withContext(Dispatchers.Main) { - if (chapterPageCounts[chapterIndex] != pages.size) { - updatePageCounts(chapterIndex, pages.size) - } - generation++ - } + updatePageCountsOnMain(chapterIndex, pages.size) return pages } @@ -835,14 +1072,16 @@ class BookPaginator( private fun prefetchChapters(currentChapterIndex: Int) { Timber.v("Prefetching chapters around index $currentChapterIndex.") - val nextChapterIndex = currentChapterIndex + 1 - if (nextChapterIndex < chapters.size) { - triggerPagination(nextChapterIndex, PRIORITY_MEDIUM) - } + for (offset in 1..2) { + val nextChapterIndex = currentChapterIndex + offset + if (nextChapterIndex < chapters.size) { + triggerPagination(nextChapterIndex, PRIORITY_MEDIUM) + } - val prevChapterIndex = currentChapterIndex - 1 - if (prevChapterIndex >= 0) { - triggerPagination(prevChapterIndex, PRIORITY_MEDIUM) + val prevChapterIndex = currentChapterIndex - offset + if (prevChapterIndex >= 0) { + triggerPagination(prevChapterIndex, PRIORITY_MEDIUM) + } } } @@ -908,6 +1147,19 @@ class BookPaginator( return@launch } + val indexedPageInChapter = targetBlock?.let { blockIndex -> + chapterPageNavigationIndex[targetChapter] + ?.firstOrNull { blockIndex in it.firstBlockIndex..it.lastBlockIndex } + ?.pageInChapter + } ?: chapterAnchorPageIndex[targetChapter]?.get(anchor) + + if (indexedPageInChapter != null) { + val finalPage = chapterStartPage + indexedPageInChapter + Timber.tag("TOC_NAV_DEBUG").d("Navigation resolved from page index to Absolute Page: $finalPage") + withContext(Dispatchers.Main) { onResult(finalPage) } + return@launch + } + // 3. FIND PAGE var targetPageInChapter = 0 var found = false @@ -1093,6 +1345,26 @@ class BookPaginator( return null } + chapterTextRangeIndex[targetChapterIndex] + ?.firstOrNull { range -> + range.blockIndex == locator.blockIndex && + (locator.charOffset in range.startOffset.. + val finalPageIndex = chapterStartPage + range.pageInChapter + Timber.tag("POS_DIAG").i("findPageForLocator: FOUND via runtime index on absolute page $finalPageIndex") + return finalPageIndex + } + + chapterPageNavigationIndex[targetChapterIndex] + ?.firstOrNull { locator.blockIndex in it.firstBlockIndex..it.lastBlockIndex } + ?.let { entry -> + val finalPageIndex = chapterStartPage + entry.pageInChapter + Timber.tag("POS_DIAG").w("findPageForLocator: Using block-range fallback page $finalPageIndex") + return finalPageIndex + } + var fallbackPageInChapter = -1 for ((pageIndex, page) in chapterPages.withIndex()) { @@ -1150,6 +1422,23 @@ class BookPaginator( val chStart = chapterStartPageIndices[chapterIndex] ?: 0 Timber.tag("POS_DIAG").d("getLocatorForPage: Request pageIndex=$pageIndex. Resolved chapterIndex=$chapterIndex (starts at $chStart). PageInChapter=${pageIndex - chStart}") + chapterPageNavigationIndex[chapterIndex]?.getOrNull(pageIndex - chStart)?.let { entry -> + entry.firstTextBlockIndex?.let { blockIndex -> + return Locator( + chapterIndex = chapterIndex, + blockIndex = blockIndex, + charOffset = entry.firstTextCharOffset + ) + } + if (entry.firstBlockIndex >= 0) { + return Locator( + chapterIndex = chapterIndex, + blockIndex = entry.firstBlockIndex, + charOffset = 0 + ) + } + } + val pageContent = getPageContent(pageIndex) ?: return null Timber.tag("POS_DIAG").d("getLocatorForPage: Inspecting page $pageIndex (chapter=$chapterIndex). Total top-level blocks=${pageContent.content.size}") diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/ContentStyler.kt b/app/src/main/java/com/aryan/reader/paginatedreader/ContentStyler.kt index 69dd687..34f0e8e 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/ContentStyler.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/ContentStyler.kt @@ -46,6 +46,8 @@ import org.jsoup.Jsoup import java.io.File import java.net.URLDecoder +private const val DEBUG_CONTENT_STYLING = false + @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) class ContentStyler( private val baseTextStyle: TextStyle, @@ -57,7 +59,8 @@ class ContentStyler( private val chapterAbsPath: String, private val extractionBasePath: String, private val userTextAlign: TextAlign?, - private val paragraphGapMultiplier: Float + private val paragraphGapMultiplier: Float, + private val adaptThemeColors: Boolean = true ) { fun style(semanticBlocks: List): List { @@ -167,6 +170,7 @@ class ContentStyler( val nonBlankSvgContent = svgContent?.takeIf { it.isNotBlank() } val finalSvgContent = when { block.isFromMathJax || nonBlankSvgContent == null -> svgContent + !adaptThemeColors -> embedImagesInSvg(nonBlankSvgContent) else -> { val themedSvg = applyThemeToSvg(nonBlankSvgContent) embedImagesInSvg(themedSvg) @@ -223,6 +227,10 @@ class ContentStyler( } private fun applyThemeToStyle(style: CssStyle): CssStyle { + if (!adaptThemeColors) { + return style + } + val newSpanStyle = style.spanStyle.let { original -> val newColor = if (original.color.isSpecified) { CssParser.adaptColorForTheme(original.color, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor) @@ -346,7 +354,9 @@ class ContentStyler( block: SemanticTextBlock, blockStyle: CssStyle ): AnnotatedString { - Timber.d("ContentStyler: Building annotated string. UserAlign=$userTextAlign, CSSAlign=${blockStyle.paragraphStyle.textAlign}") + if (DEBUG_CONTENT_STYLING) { + Timber.d("ContentStyler: Building annotated string. UserAlign=$userTextAlign, CSSAlign=${blockStyle.paragraphStyle.textAlign}") + } val builtString = buildAnnotatedString { val rootFontFamily = findFirstAvailableFontFamily(blockStyle.fontFamilies, fontFamilyMap) @@ -394,7 +404,9 @@ class ContentStyler( .merge(blockStyle.spanStyle) .copy(fontFamily = effectiveBlockFontFamily) - Timber.d("ContentStyler: InitialSpanStyle. BaseFontSize=${baseTextStyle.fontSize}, BlockFontSize=${blockStyle.spanStyle.fontSize} -> Merged=${initialSpanStyle.fontSize}") + if (DEBUG_CONTENT_STYLING) { + Timber.d("ContentStyler: InitialSpanStyle. BaseFontSize=${baseTextStyle.fontSize}, BlockFontSize=${blockStyle.spanStyle.fontSize} -> Merged=${initialSpanStyle.fontSize}") + } withStyle(finalParagraphStyle) { withStyle(initialSpanStyle) { diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/Locator.kt b/app/src/main/java/com/aryan/reader/paginatedreader/Locator.kt index 55bd8c6..c2a3be6 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/Locator.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/Locator.kt @@ -48,10 +48,20 @@ data class Locator( class LocatorConverter( private val bookCacheDao: BookCacheDao, private val proto: ProtoBuf, - private val context: Context + private val context: Context, + private val stableBookId: String? = null ) { - private suspend fun processAndCacheChapter(book: EpubBook, chapterIndex: Int): List? = withContext(Dispatchers.IO) { - Timber.tag("POS_DIAG").d("processAndCacheChapter: Processing for bookId='${book.title}' index=$chapterIndex") + private fun cacheBookId(book: EpubBook, overrideBookId: String? = null): String { + return overrideBookId ?: stableBookId ?: book.title + } + + private suspend fun processAndCacheChapter( + book: EpubBook, + chapterIndex: Int, + explicitBookId: String? = null + ): List? = withContext(Dispatchers.IO) { + val cacheBookId = cacheBookId(book, explicitBookId) + Timber.tag("POS_DIAG").d("processAndCacheChapter: Processing for bookId='$cacheBookId' index=$chapterIndex") try { val chapter = book.chapters.getOrNull(chapterIndex) ?: return@withContext null @@ -98,7 +108,8 @@ class LocatorConverter( baseFontSizeSp = 16f, density = density.density, constraints = constraints, - isDarkTheme = false + isDarkTheme = false, + adaptThemeColors = false ) val rules = bookCssResult.rules @@ -123,16 +134,17 @@ class LocatorConverter( extractionBasePath = book.extractionBasePath, density = density, fontFamilyMap = emptyMap(), - constraints = constraints + constraints = constraints, + adaptThemeColors = false ) val protoBytes = proto.encodeToByteArray(semanticBlocks) val newCacheEntry = ProcessedChapter( - bookId = book.title, + bookId = cacheBookId, chapterIndex = chapterIndex, contentBlocksProto = protoBytes, - estimatedPageCount = 0 + estimatedPageCount = estimateSemanticPageCount(semanticBlocks) ) bookCacheDao.insertProcessedChapters(listOf(newCacheEntry)) semanticBlocks @@ -141,9 +153,9 @@ class LocatorConverter( } } - suspend fun getLocatorFromCfi(book: EpubBook, chapterIndex: Int, cfi: String): Locator? = withContext(Dispatchers.IO) { + suspend fun getLocatorFromCfi(book: EpubBook, chapterIndex: Int, cfi: String, bookId: String? = null): Locator? = withContext(Dispatchers.IO) { Timber.tag("POS_DIAG").d("getLocatorFromCfi: Input CFI='$cfi' for chapterIndex=$chapterIndex") - val processedChapter = bookCacheDao.getProcessedChapter(bookId = book.title, chapterIndex = chapterIndex) + val processedChapter = bookCacheDao.getProcessedChapter(bookId = cacheBookId(book, bookId), chapterIndex = chapterIndex) var allBlocks: List? = null @@ -154,7 +166,7 @@ class LocatorConverter( } if (allBlocks.isNullOrEmpty()) { - allBlocks = processAndCacheChapter(book, chapterIndex) + allBlocks = processAndCacheChapter(book, chapterIndex, bookId) } if (allBlocks.isNullOrEmpty()) { @@ -224,8 +236,8 @@ class LocatorConverter( return bestMatch } - suspend fun getTtsChunksForChapter(book: EpubBook, chapterIndex: Int): List? = withContext(Dispatchers.IO) { - val processedChapter = bookCacheDao.getProcessedChapter(bookId = book.title, chapterIndex = chapterIndex) + suspend fun getTtsChunksForChapter(book: EpubBook, chapterIndex: Int, bookId: String? = null): List? = withContext(Dispatchers.IO) { + val processedChapter = bookCacheDao.getProcessedChapter(bookId = cacheBookId(book, bookId), chapterIndex = chapterIndex) var allBlocks: List? = null if (processedChapter != null && processedChapter.contentBlocksProto.isNotEmpty()) { @@ -235,7 +247,7 @@ class LocatorConverter( } if (allBlocks.isNullOrEmpty()) { - allBlocks = processAndCacheChapter(book, chapterIndex) + allBlocks = processAndCacheChapter(book, chapterIndex, bookId) } if (allBlocks.isNullOrEmpty()) return@withContext null @@ -279,9 +291,9 @@ class LocatorConverter( chunks } - suspend fun getCfiFromLocator(book: EpubBook, locator: Locator): String? = withContext(Dispatchers.IO) { + suspend fun getCfiFromLocator(book: EpubBook, locator: Locator, bookId: String? = null): String? = withContext(Dispatchers.IO) { Timber.tag("POS_DIAG").d("getCfiFromLocator: Input $locator") - val processedChapter = bookCacheDao.getProcessedChapter(bookId = book.title, chapterIndex = locator.chapterIndex) + val processedChapter = bookCacheDao.getProcessedChapter(bookId = cacheBookId(book, bookId), chapterIndex = locator.chapterIndex) var blocks: List? = null if (processedChapter != null && processedChapter.contentBlocksProto.isNotEmpty()) { @@ -291,7 +303,7 @@ class LocatorConverter( } if (blocks.isNullOrEmpty()) { - blocks = processAndCacheChapter(book, locator.chapterIndex) + blocks = processAndCacheChapter(book, locator.chapterIndex, bookId) } if (blocks.isNullOrEmpty()) { @@ -331,8 +343,26 @@ class LocatorConverter( return null } - suspend fun getTextOffset(book: EpubBook, locator: Locator): Int? = withContext(Dispatchers.IO) { - val processedChapter = bookCacheDao.getProcessedChapter(bookId = book.title, chapterIndex = locator.chapterIndex) + private fun estimateSemanticPageCount(blocks: List): Int { + var charCount = 0 + + fun walk(block: SemanticBlock) { + when (block) { + is SemanticTextBlock -> charCount += block.text.length + is SemanticFlexContainer -> block.children.forEach(::walk) + is SemanticTable -> block.rows.forEach { row -> row.forEach { cell -> cell.content.forEach(::walk) } } + is SemanticList -> block.items.forEach(::walk) + is SemanticWrappingBlock -> block.paragraphsToWrap.forEach(::walk) + else -> Unit + } + } + + blocks.forEach(::walk) + return ((charCount + 2_499) / 2_500).coerceAtLeast(1) + } + + suspend fun getTextOffset(book: EpubBook, locator: Locator, bookId: String? = null): Int? = withContext(Dispatchers.IO) { + val processedChapter = bookCacheDao.getProcessedChapter(bookId = cacheBookId(book, bookId), chapterIndex = locator.chapterIndex) var allBlocks: List? = null if (processedChapter != null && processedChapter.contentBlocksProto.isNotEmpty()) { @@ -342,7 +372,7 @@ class LocatorConverter( } if (allBlocks.isNullOrEmpty()) { - allBlocks = processAndCacheChapter(book, locator.chapterIndex) + allBlocks = processAndCacheChapter(book, locator.chapterIndex, bookId) } if (allBlocks.isNullOrEmpty()) return@withContext null diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt index ce064a8..e8d8f4b 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt @@ -723,6 +723,7 @@ private fun WrappingContentLayout( fun PaginatedReaderScreen( modifier: Modifier = Modifier, book: EpubBook, + bookId: String? = null, isDarkTheme: Boolean, effectiveBg: Color, effectiveText: Color, @@ -739,6 +740,9 @@ fun PaginatedReaderScreen( textAlign: ReaderTextAlign, ttsHighlightInfo: TtsHighlightInfo?, initialChapterIndexInBook: Int?, + fallbackLocatorForReconfiguration: Locator? = null, + onReconfigurationAnchorCaptured: (Locator) -> Unit = {}, + onReconfigurationRestoreActiveChanged: (Boolean) -> Unit = {}, onPaginatorReady: (IPaginator) -> Unit, onTap: (Offset?) -> Unit, isProUser: Boolean, @@ -796,37 +800,32 @@ fun PaginatedReaderScreen( var anchorLocatorForReconfig by remember { mutableStateOf(null) } val currentPaginatorRef = remember { mutableStateOf(null) } + val latestFallbackLocatorForReconfiguration by rememberUpdatedState(fallbackLocatorForReconfiguration) - val previousState = remember { - arrayOf(this.constraints, isDarkTheme, effectiveBg, effectiveText) + var previousConstraints by remember { + mutableStateOf(this.constraints) } - if (previousState[0] != this.constraints || - previousState[1] != isDarkTheme || - previousState[2] != effectiveBg || - previousState[3] != effectiveText - ) { + if (previousConstraints != this.constraints) { val activePaginator = currentPaginatorRef.value - if (activePaginator is BookPaginator) { - val currentPage = pagerState.currentPage - val locator = activePaginator.getLocatorForPage(currentPage) - anchorLocatorForReconfig = locator + val currentPage = pagerState.currentPage + val locator = resolvePaginatedReconfigurationAnchor( + currentPageLocator = (activePaginator as? BookPaginator)?.getLocatorForPage(currentPage), + fallbackLocator = fallbackLocatorForReconfiguration + ) + anchorLocatorForReconfig = locator - Timber.tag("ThemeReconfig").d(""" + Timber.tag("ThemeReconfig").d(""" RECONFIG DETECTED - - Reason: ${if (previousState[0] != this.constraints) "Constraints" else "Theme/Colors"} + - Reason: Constraints - Current Page: $currentPage - Saved Locator: $locator """.trimIndent()) - } - previousState[0] = this.constraints - previousState[1] = isDarkTheme - previousState[2] = effectiveBg - previousState[3] = effectiveText + previousConstraints = this.constraints } - val textStyle = remember( - baseTextStyle, effectiveText, + val layoutTextStyle = remember( + baseTextStyle, debouncedFontSizeMult, debouncedLineHeightMult, debouncedFontFamily @@ -835,7 +834,7 @@ fun PaginatedReaderScreen( val adjustedLineHeight = adjustedFontSize * paginationLineHeightMultiplierForWebViewSetting(debouncedLineHeightMult) baseTextStyle.copy( - color = effectiveText, + color = Color.Unspecified, fontSize = adjustedFontSize, lineHeight = adjustedLineHeight, fontFamily = debouncedFontFamily, @@ -848,6 +847,9 @@ fun PaginatedReaderScreen( ) ) } + val textStyle = remember(layoutTextStyle, effectiveText) { + layoutTextStyle.copy(color = effectiveText) + } LaunchedEffect(pagerState) { snapshotFlow { pagerState.currentPage }.collect { page -> @@ -875,12 +877,13 @@ fun PaginatedReaderScreen( delay(400L) val activePaginator = currentPaginatorRef.value - if (activePaginator is BookPaginator) { - val currentPage = pagerState.currentPage - val locator = activePaginator.getLocatorForPage(currentPage) - if (locator != null) { - anchorLocatorForReconfig = locator - } + val currentPage = pagerState.currentPage + val locator = resolvePaginatedReconfigurationAnchor( + currentPageLocator = (activePaginator as? BookPaginator)?.getLocatorForPage(currentPage), + fallbackLocator = fallbackLocatorForReconfiguration + ) + if (locator != null) { + anchorLocatorForReconfig = locator } debouncedFontSizeMult = fontSizeMultiplier @@ -955,7 +958,15 @@ fun PaginatedReaderScreen( remember(initialChapterIndexInBook, anchorLocatorForReconfig) { anchorLocatorForReconfig?.chapterIndex ?: initialChapterIndexInBook ?: 0 } - val paginator = remember(book, textConstraints, isDarkTheme, textStyle, userTextAlign, effectiveBg, effectiveText, debouncedParagraphGapMult) { + + LaunchedEffect(anchorLocatorForReconfig) { + anchorLocatorForReconfig?.let { locator -> + onReconfigurationAnchorCaptured(locator) + onReconfigurationRestoreActiveChanged(true) + } + } + + val paginator = remember(book, bookId, textConstraints, layoutTextStyle, userTextAlign, debouncedParagraphGapMult, debouncedImageSizeMult, debouncedVerticalMarginMult) { val userAgentStylesheet = UserAgentStylesheet.default var allRules = OptimizedCssRules() val allFontFaces = mutableListOf() @@ -963,12 +974,11 @@ fun PaginatedReaderScreen( val uaResult = CssParser.parse( cssContent = userAgentStylesheet, cssPath = null, - baseFontSizeSp = textStyle.fontSize.value, + baseFontSizeSp = layoutTextStyle.fontSize.value, density = density.density, constraints = textConstraints, - isDarkTheme = isDarkTheme, - themeBackgroundColor = effectiveBg, - themeTextColor = effectiveText + isDarkTheme = false, + adaptThemeColors = false ) allRules = allRules.merge(uaResult.rules) allFontFaces.addAll(uaResult.fontFaces) @@ -977,12 +987,11 @@ fun PaginatedReaderScreen( val bookCssResult = CssParser.parse( cssContent = content, cssPath = path, - baseFontSizeSp = textStyle.fontSize.value, + baseFontSizeSp = layoutTextStyle.fontSize.value, density = density.density, constraints = textConstraints, - isDarkTheme = isDarkTheme, - themeBackgroundColor = effectiveBg, - themeTextColor = effectiveText + isDarkTheme = false, + adaptThemeColors = false ) allRules = allRules.merge(bookCssResult.rules) allFontFaces.addAll(bookCssResult.fontFaces) @@ -990,12 +999,11 @@ fun PaginatedReaderScreen( val fontFamilyMap = loadFontFamilies( fontFaces = allFontFaces, extractionPath = book.extractionBasePath ) - book.title val bookCacheDao = BookCacheDatabase.getDatabase(context.applicationContext).bookCacheDao() val proto = ProtoBuf { serializersModule = semanticBlockModule } - val uniqueBookId = if (book.fileName.length > 20) book.fileName else book.title + val uniqueBookId = bookId ?: if (book.fileName.length > 20) book.fileName else book.title Timber.d("Recreating BookPaginator for ID: $uniqueBookId. TextAlign: $userTextAlign") Timber.tag("ReflowPaginationDiag").d("PaginatedReaderScreen: Instantiating BookPaginator. book.chaptersForPagination.size=${book.chaptersForPagination.size}, initialChapter=$effectiveInitialChapter") @@ -1005,7 +1013,7 @@ fun PaginatedReaderScreen( chapters = book.chaptersForPagination, textMeasurer = textMeasurer, constraints = textConstraints, - textStyle = textStyle, + textStyle = layoutTextStyle, extractionBasePath = book.extractionBasePath, density = density, fontFamilyMap = fontFamilyMap, @@ -1037,25 +1045,32 @@ fun PaginatedReaderScreen( if (anchorLocatorForReconfig != null) { Timber.tag("POS_DIAG").d("Restoration Triggered. Anchor Locator: $anchorLocatorForReconfig") - snapshotFlow { paginator.isLoading }.filter { !it }.first() + try { + onReconfigurationRestoreActiveChanged(true) + snapshotFlow { paginator.isLoading }.filter { !it }.first() - val targetLocator = anchorLocatorForReconfig - if (targetLocator != null) { - val page = paginator.findPageForLocator(targetLocator) + val targetLocator = anchorLocatorForReconfig + if (targetLocator != null) { + val page = paginator.findPageForLocator(targetLocator) - Timber.tag("POS_DIAG").d("Restoration Result: Paginator resolved locator to page: $page") + Timber.tag("POS_DIAG").d("Restoration Result: Paginator resolved locator to page: $page") - if (page != null) { - pagerState.scrollToPage(page) - Timber.tag("POS_DIAG").i("Restoration: Pager scrolled to $page") - } else { - val startPage = paginator.chapterStartPageIndices[targetLocator.chapterIndex] - if (startPage != null) { - Timber.tag("POS_DIAG").w("Restoration: Precise page not found, falling back to chapter start: $startPage") - pagerState.scrollToPage(startPage) + if (page != null) { + pagerState.scrollToPage(page) + paginator.onUserScrolledTo(page) + Timber.tag("POS_DIAG").i("Restoration: Pager scrolled to $page") + } else { + val startPage = paginator.chapterStartPageIndices[targetLocator.chapterIndex] + if (startPage != null) { + Timber.tag("POS_DIAG").w("Restoration: Precise page not found, falling back to chapter start: $startPage") + pagerState.scrollToPage(startPage) + paginator.onUserScrolledTo(startPage) + } } + anchorLocatorForReconfig = null } - anchorLocatorForReconfig = null + } finally { + onReconfigurationRestoreActiveChanged(false) } } } @@ -1083,13 +1098,31 @@ fun PaginatedReaderScreen( LaunchedEffect(pagerState, paginator) { snapshotFlow { pagerState.currentPage }.debounce(500) - .collectLatest { page -> paginator.onUserScrolledTo(page) } + .collectLatest { page -> + if (anchorLocatorForReconfig == null) { + paginator.onUserScrolledTo(page) + } + } } LaunchedEffect(paginator, pagerState) { paginator.pageShiftRequest.collect { shiftAmount -> - val newPage = pagerState.currentPage + shiftAmount - pagerState.scrollToPage(newPage) + val anchor = resolvePaginatedReconfigurationAnchor( + currentPageLocator = anchorLocatorForReconfig, + fallbackLocator = latestFallbackLocatorForReconfiguration + ) + val resolvedPage = anchor?.let { locator -> + (paginator as? BookPaginator)?.findPageForLocator(locator) + } + + if (resolvedPage != null) { + pagerState.scrollToPage(resolvedPage) + paginator.onUserScrolledTo(resolvedPage) + } else { + val newPage = pagerState.currentPage + shiftAmount + pagerState.scrollToPage(newPage) + paginator.onUserScrolledTo(newPage) + } } } @@ -2218,6 +2251,13 @@ internal fun PaginatedReaderContent( var pageContent by remember { mutableStateOf(null) } var currentChapterPath by remember { mutableStateOf(null) } + val themedPageContent = remember(pageContent, isDarkTheme, effectiveBg, effectiveText) { + pageContent?.applyReaderThemeForDisplay( + isDarkTheme = isDarkTheme, + themeBackgroundColor = effectiveBg, + themeTextColor = effectiveText + ) + } LaunchedEffect(pageIndex, uiState.generation) { val fetchStartTime = System.currentTimeMillis() @@ -2236,7 +2276,7 @@ internal fun PaginatedReaderContent( } val textBlocksOnPage = - pageContent?.content?.extractTextBlocks() + themedPageContent?.content?.extractTextBlocks() ?.filter { it.cfi != null } ?: emptyList() val lastTextBlock = textBlocksOnPage.lastOrNull() val lastBlockAbs = lastTextBlock?.let { @@ -2379,7 +2419,8 @@ internal fun PaginatedReaderContent( horizontal = horizontalPadding, vertical = verticalPadding ), contentAlignment = Alignment.TopStart) { - if (pageContent != null) { + if (themedPageContent != null) { + val displayPage = themedPageContent val onGeneralTapCallback: (Offset) -> Unit = { offset -> activeSelection = null onTap(offset) @@ -2405,7 +2446,7 @@ internal fun PaginatedReaderContent( val ttsHighlightColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.5f) - pageContent!!.content.forEach { block -> + displayPage.content.forEach { block -> val marginModifier = Modifier.padding( top = block.style.margin.top.coerceAtLeast(0.dp), bottom = block.style.margin.bottom.coerceAtLeast( diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReaderViewModel.kt b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReaderViewModel.kt index fcbded3..7e9c340 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReaderViewModel.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReaderViewModel.kt @@ -24,6 +24,7 @@ import android.os.Build import androidx.annotation.RequiresApi import androidx.annotation.VisibleForTesting import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextMeasurer import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.Constraints @@ -77,7 +78,8 @@ class PaginatedReaderViewModel : ViewModel() { context: Context, initialChapterToPaginate: Int?, mathMLRenderer: MathMLRenderer, - paragraphGapMultiplier: Float + paragraphGapMultiplier: Float, + bookId: String? = null ) { if (paginator != null) return @@ -88,16 +90,16 @@ class PaginatedReaderViewModel : ViewModel() { val userAgentStylesheet = UserAgentStylesheet.default var allRules = OptimizedCssRules() val allFontFaces = mutableListOf() + val layoutTextStyle = textStyle.copy(color = Color.Unspecified) val uaResult = CssParser.parse( cssContent = userAgentStylesheet, cssPath = null, - baseFontSizeSp = textStyle.fontSize.value, + baseFontSizeSp = layoutTextStyle.fontSize.value, density = density.density, constraints = textConstraints, - isDarkTheme = isDarkTheme, - themeBackgroundColor = themeBackgroundColor, - themeTextColor = themeTextColor + isDarkTheme = false, + adaptThemeColors = false ) allRules = allRules.merge(uaResult.rules) allFontFaces.addAll(uaResult.fontFaces) @@ -106,12 +108,11 @@ class PaginatedReaderViewModel : ViewModel() { val bookCssResult = CssParser.parse( cssContent = content, cssPath = path, - baseFontSizeSp = textStyle.fontSize.value, + baseFontSizeSp = layoutTextStyle.fontSize.value, density = density.density, constraints = textConstraints, - isDarkTheme = isDarkTheme, - themeBackgroundColor = themeBackgroundColor, - themeTextColor = themeTextColor + isDarkTheme = false, + adaptThemeColors = false ) allRules = allRules.merge(bookCssResult.rules) allFontFaces.addAll(bookCssResult.fontFaces) @@ -120,21 +121,21 @@ class PaginatedReaderViewModel : ViewModel() { fontFaces = allFontFaces, extractionPath = book.extractionBasePath ) - val bookId = book.title + val cacheBookId = bookId ?: if (book.fileName.length > 20) book.fileName else book.title val bookCacheDao = BookCacheDatabase.getDatabase(context.applicationContext).bookCacheDao() val newPaginator = BookPaginator( coroutineScope = viewModelScope, chapters = book.chaptersForPagination, textMeasurer = textMeasurer, constraints = textConstraints, - textStyle = textStyle, + textStyle = layoutTextStyle, extractionBasePath = book.extractionBasePath, density = density, fontFamilyMap = fontFamilyMap, isDarkTheme = isDarkTheme, themeBackgroundColor = themeBackgroundColor, themeTextColor = themeTextColor, - bookId = bookId, + bookId = cacheBookId, bookCacheDao = bookCacheDao, proto = proto, initialChapterToPaginate = initialChapterToPaginate ?: 0, diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReconfiguration.kt b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReconfiguration.kt new file mode 100644 index 0000000..fe7fa7e --- /dev/null +++ b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReconfiguration.kt @@ -0,0 +1,6 @@ +package com.aryan.reader.paginatedreader + +internal fun resolvePaginatedReconfigurationAnchor( + currentPageLocator: Locator?, + fallbackLocator: Locator? +): Locator? = currentPageLocator ?: fallbackLocator diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/Paginator.kt b/app/src/main/java/com/aryan/reader/paginatedreader/Paginator.kt index 978ea41..078c90b 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/Paginator.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/Paginator.kt @@ -35,8 +35,11 @@ import androidx.compose.ui.unit.isSpecified import androidx.compose.ui.unit.sp import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import java.util.concurrent.ConcurrentHashMap import kotlin.math.roundToInt +private const val DEBUG_PAGINATION_LOGS = false + interface BlockMeasurementProvider { suspend fun measure(block: ContentBlock): Int suspend fun split(block: ParagraphBlock, availableHeight: Int): Pair? @@ -53,9 +56,13 @@ class SuspendingAndroidBlockMeasurementProvider( private val density: Density, private val imageSizeMultiplier: Float ) : BlockMeasurementProvider { + private val measurementCache = ConcurrentHashMap() override suspend fun measure(block: ContentBlock): Int { - return measureBlockHeight( + val cacheKey = blockMeasurementCacheKey(block) + measurementCache[cacheKey]?.let { return it } + + val measured = measureBlockHeight( block = block, textMeasurer = textMeasurer, constraints = constraints, @@ -64,6 +71,17 @@ class SuspendingAndroidBlockMeasurementProvider( density = density, imageSizeMultiplier = imageSizeMultiplier ) + measurementCache[cacheKey] = measured + return measured + } + + private fun blockMeasurementCacheKey(block: ContentBlock): Int { + var result = block.hashCode() + result = 31 * result + constraints.maxWidth + result = 31 * result + constraints.maxHeight + result = 31 * result + textStyle.hashCode() + result = 31 * result + imageSizeMultiplier.hashCode() + return result } override suspend fun split(block: ParagraphBlock, availableHeight: Int): Pair? { @@ -280,7 +298,9 @@ class SuspendingAndroidBlockMeasurementProvider( block.style.padding.bottom.toPx() + (block.style.borderBottom?.width?.toPx() ?: 0f) }.roundToInt() - Timber.tag("PAGINATION_DEBUG").d("SplitTable: avail=$availableHeight, topDec=$decorationTop, botDec=$decorationBottom") + if (DEBUG_PAGINATION_LOGS) { + Timber.tag("PAGINATION_DEBUG").d("SplitTable: avail=$availableHeight, topDec=$decorationTop, botDec=$decorationBottom") + } currentHeight += decorationTop for (i in block.rows.indices) { @@ -305,7 +325,9 @@ class SuspendingAndroidBlockMeasurementProvider( } if (currentHeight + maxRowHeight + decorationBottom > availableHeight) { - Timber.tag("PAGINATION_DEBUG").d("SplitTable: Breaking at row $i. currentH=$currentHeight, rowH=$maxRowHeight") + if (DEBUG_PAGINATION_LOGS) { + Timber.tag("PAGINATION_DEBUG").d("SplitTable: Breaking at row $i. currentH=$currentHeight, rowH=$maxRowHeight") + } splitRowIndex = i break } @@ -410,7 +432,9 @@ suspend fun paginate( if (blocks.isEmpty()) { return emptyList() } - Timber.d("Starting pagination for ${blocks.size} blocks with page height $pageHeight.") + if (DEBUG_PAGINATION_LOGS) { + Timber.d("Starting pagination for ${blocks.size} blocks with page height $pageHeight.") + } val pages = mutableListOf() var currentPageContent = mutableListOf() @@ -437,8 +461,10 @@ suspend fun paginate( val spaceRequired = blockHeightWithSafetyMargin + spaceBetweenBlocks - Timber.tag("PAGINATION_DEBUG") - .d("Processing ${block::class.simpleName}: req=$spaceRequired, remaining=$remainingHeight, margin=$spaceBetweenBlocks, heightOnly=$blockHeight") + if (DEBUG_PAGINATION_LOGS) { + Timber.tag("PAGINATION_DEBUG") + .d("Processing ${block::class.simpleName}: req=$spaceRequired, remaining=$remainingHeight, margin=$spaceBetweenBlocks, heightOnly=$blockHeight") + } if (spaceRequired <= remainingHeight) { var blockToAdd = block @@ -608,23 +634,31 @@ suspend fun paginate( } else -> { - Timber.d("Page ${pageIndex + 1}: Block type is not splittable.") + if (DEBUG_PAGINATION_LOGS) { + Timber.d("Page ${pageIndex + 1}: Block type is not splittable.") + } } } } else { - Timber.d("Page ${pageIndex + 1}: Not enough height for splitting ($heightForSplitting <= 50).") + if (DEBUG_PAGINATION_LOGS) { + Timber.d("Page ${pageIndex + 1}: Not enough height for splitting ($heightForSplitting <= 50).") + } } if (!wasSplit) { if (currentPageContent.isEmpty()) { - Timber.tag("PAGINATION_DEBUG") - .w("FORCING block ${block::class.simpleName} onto page because it is the first block, even though req($spaceRequired) > remaining($remainingHeight)") + if (DEBUG_PAGINATION_LOGS) { + Timber.tag("PAGINATION_DEBUG") + .w("FORCING block ${block::class.simpleName} onto page because it is the first block, even though req($spaceRequired) > remaining($remainingHeight)") + } val forcedHeight = blockHeight + spaceBetweenBlocks val blockToAdd = setBlockExpectedHeight(block, forcedHeight) currentPageContent.add(blockToAdd) } else { - Timber.tag("PAGINATION_DEBUG") - .d("Block ${block::class.simpleName} did not fit and was not split. Moving to next page.") + if (DEBUG_PAGINATION_LOGS) { + Timber.tag("PAGINATION_DEBUG") + .d("Block ${block::class.simpleName} did not fit and was not split. Moving to next page.") + } remainingBlocks.add(0, block) } } @@ -643,7 +677,9 @@ suspend fun paginate( pages.add(Page(content = currentPageContent.toList())) } - Timber.i("Pagination complete. Produced ${pages.size} pages from ${blocks.size} initial blocks.") + if (DEBUG_PAGINATION_LOGS) { + Timber.i("Pagination complete. Produced ${pages.size} pages from ${blocks.size} initial blocks.") + } return pages } @@ -906,7 +942,9 @@ private suspend fun measureBlockHeight( (contentHeight + verticalPaddingPx + verticalBorderPx).roundToInt() } - Timber.tag("PAGINATION_DEBUG").v("Measure result for ${block::class.simpleName}: content=$contentHeight, paddingV=$verticalPaddingPx, borderV=$verticalBorderPx, total=$finalHeight") + if (DEBUG_PAGINATION_LOGS) { + Timber.tag("PAGINATION_DEBUG").v("Measure result for ${block::class.simpleName}: content=$contentHeight, paddingV=$verticalPaddingPx, borderV=$verticalBorderPx, total=$finalHeight") + } return finalHeight } @@ -935,10 +973,14 @@ private suspend fun splitParagraphBlock( val availableTextHeight = availableHeight - decorationTop - decorationBottom - centeredSafetyPaddingPx - Timber.tag("PAGINATION_DEBUG").d("SplitPara: totalAvail=$availableHeight, topDec=$decorationTop, botDec=$decorationBottom, textAvail=$availableTextHeight") + if (DEBUG_PAGINATION_LOGS) { + Timber.tag("PAGINATION_DEBUG").d("SplitPara: totalAvail=$availableHeight, topDec=$decorationTop, botDec=$decorationBottom, textAvail=$availableTextHeight") + } if (availableTextHeight <= 0) { - Timber.tag("PAGINATION_DEBUG").w("SplitPara aborted: availableTextHeight <= 0") + if (DEBUG_PAGINATION_LOGS) { + Timber.tag("PAGINATION_DEBUG").w("SplitPara aborted: availableTextHeight <= 0") + } return null } @@ -969,7 +1011,9 @@ private suspend fun splitParagraphBlock( } if (lastVisibleLine == 0) { - Timber.d("Orphan control: Preventing split that would leave one line at the bottom of the page.") + if (DEBUG_PAGINATION_LOGS) { + Timber.d("Orphan control: Preventing split that would leave one line at the bottom of the page.") + } return null } @@ -985,7 +1029,9 @@ private suspend fun splitParagraphBlock( ) } if (part2Layout.lineCount == 1) { - Timber.d("Widow control: Adjusting split to prevent a single line at the top of the next page.") + if (DEBUG_PAGINATION_LOGS) { + Timber.d("Widow control: Adjusting split to prevent a single line at the top of the next page.") + } lastVisibleLine-- splitOffset = layoutResult.getLineEnd(lastVisibleLine, visibleEnd = true) } @@ -1046,7 +1092,9 @@ private suspend fun splitParagraphBlock( endCharOffsetInSource = block.endCharOffsetInSource ) - Timber.d("Split block at offset $splitOffset. Part 1 len: ${part1.content.length}, Part 2 len: ${part2.content.length}") + if (DEBUG_PAGINATION_LOGS) { + Timber.d("Split block at offset $splitOffset. Part 1 len: ${part1.content.length}, Part 2 len: ${part2.content.length}") + } return part1 to part2 } @@ -1090,7 +1138,9 @@ private suspend fun calculateContentHeightWithMargins( } }.roundToInt() totalHeight += (childHeight + margin) - Timber.tag("PAGINATION_DEBUG").v(" Internal Child ${child::class.simpleName}: h=$childHeight, margin=$margin, runningTotal=$totalHeight") + if (DEBUG_PAGINATION_LOGS) { + Timber.tag("PAGINATION_DEBUG").v(" Internal Child ${child::class.simpleName}: h=$childHeight, margin=$margin, runningTotal=$totalHeight") + } } if (children.isNotEmpty()) { totalHeight += with(density) { children.last().style.margin.bottom.toPx().roundToInt() } diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/RenderThemeApplier.kt b/app/src/main/java/com/aryan/reader/paginatedreader/RenderThemeApplier.kt new file mode 100644 index 0000000..9bfccdc --- /dev/null +++ b/app/src/main/java/com/aryan/reader/paginatedreader/RenderThemeApplier.kt @@ -0,0 +1,265 @@ +package com.aryan.reader.paginatedreader + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import org.jsoup.Jsoup + +internal fun Page.applyReaderThemeForDisplay( + isDarkTheme: Boolean, + themeBackgroundColor: Color, + themeTextColor: Color +): Page { + return copy( + content = content.map { + it.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor) + } + ) +} + +private fun ContentBlock.applyReaderThemeForDisplay( + isDarkTheme: Boolean, + themeBackgroundColor: Color, + themeTextColor: Color +): ContentBlock { + val themedStyle = style.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor) + return when (this) { + is ParagraphBlock -> copy( + content = content.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor), + style = themedStyle + ) + is HeaderBlock -> copy( + content = content.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor), + style = themedStyle + ) + is QuoteBlock -> copy( + content = content.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor), + style = themedStyle + ) + is ListItemBlock -> copy( + content = content.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor), + style = themedStyle + ) + is ImageBlock -> copy(style = themedStyle) + is SpacerBlock -> copy(style = themedStyle) + is MathBlock -> copy( + svgContent = if (isFromMathJax) { + svgContent + } else { + svgContent?.applyReaderThemeToSvgText(themeTextColor) + }, + style = themedStyle + ) + is WrappingContentBlock -> copy( + floatedImage = floatedImage.applyReaderThemeForDisplay( + isDarkTheme, + themeBackgroundColor, + themeTextColor + ) as ImageBlock, + paragraphsToWrap = paragraphsToWrap.map { + it.applyReaderThemeForDisplay( + isDarkTheme, + themeBackgroundColor, + themeTextColor + ) as ParagraphBlock + }, + style = themedStyle + ) + is TableBlock -> copy( + rows = rows.map { row -> + row.map { cell -> + cell.copy( + content = cell.content.map { + it.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor) + }, + style = cell.style.applyReaderThemeForDisplay( + isDarkTheme, + themeBackgroundColor, + themeTextColor + ) + ) + } + }, + style = themedStyle + ) + is FlexContainerBlock -> copy( + children = children.map { + it.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor) + }, + style = themedStyle + ) + } +} + +private fun AnnotatedString.applyReaderThemeForDisplay( + isDarkTheme: Boolean, + themeBackgroundColor: Color, + themeTextColor: Color +): AnnotatedString { + return buildAnnotatedString { + append(this@applyReaderThemeForDisplay.text) + this@applyReaderThemeForDisplay.spanStyles.forEach { range -> + addStyle( + range.item.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor), + range.start, + range.end + ) + } + this@applyReaderThemeForDisplay.paragraphStyles.forEach { range -> + addStyle(range.item, range.start, range.end) + } + this@applyReaderThemeForDisplay.getStringAnnotations(0, this@applyReaderThemeForDisplay.length).forEach { range -> + val item = if (range.tag == "CustomUnderline") { + range.item.applyReaderThemeToUnderlineAnnotation(isDarkTheme, themeBackgroundColor, themeTextColor) + } else { + range.item + } + addStringAnnotation(range.tag, item, range.start, range.end) + } + } +} + +private fun CssStyle.applyReaderThemeForDisplay( + isDarkTheme: Boolean, + themeBackgroundColor: Color, + themeTextColor: Color +): CssStyle { + val emphasis = textEmphasis + return copy( + spanStyle = spanStyle.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor), + blockStyle = blockStyle.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor), + textDecorationColor = textDecorationColor.applyReaderThemeColor( + isDarkTheme = isDarkTheme, + isBackground = false, + themeBackgroundColor = themeBackgroundColor, + themeTextColor = themeTextColor + ), + textEmphasis = emphasis?.copy( + color = emphasis.color.applyReaderThemeColor( + isDarkTheme = isDarkTheme, + isBackground = false, + themeBackgroundColor = themeBackgroundColor, + themeTextColor = themeTextColor + ) + ) + ) +} + +private fun SpanStyle.applyReaderThemeForDisplay( + isDarkTheme: Boolean, + themeBackgroundColor: Color, + themeTextColor: Color +): SpanStyle { + return copy( + color = color.applyReaderThemeColor( + isDarkTheme = isDarkTheme, + isBackground = false, + themeBackgroundColor = themeBackgroundColor, + themeTextColor = themeTextColor + ), + background = background.applyReaderThemeColor( + isDarkTheme = isDarkTheme, + isBackground = true, + themeBackgroundColor = themeBackgroundColor, + themeTextColor = themeTextColor + ) + ) +} + +private fun BlockStyle.applyReaderThemeForDisplay( + isDarkTheme: Boolean, + themeBackgroundColor: Color, + themeTextColor: Color +): BlockStyle { + return copy( + backgroundColor = backgroundColor.applyReaderThemeColor( + isDarkTheme = isDarkTheme, + isBackground = true, + themeBackgroundColor = themeBackgroundColor, + themeTextColor = themeTextColor + ), + borderTop = borderTop?.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor), + borderRight = borderRight?.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor), + borderBottom = borderBottom?.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor), + borderLeft = borderLeft?.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor) + ) +} + +private fun BorderStyle.applyReaderThemeForDisplay( + isDarkTheme: Boolean, + themeBackgroundColor: Color, + themeTextColor: Color +): BorderStyle { + return copy( + color = color.applyReaderThemeColor( + isDarkTheme = isDarkTheme, + isBackground = false, + themeBackgroundColor = themeBackgroundColor, + themeTextColor = themeTextColor + ) + ) +} + +private fun Color.applyReaderThemeColor( + isDarkTheme: Boolean, + isBackground: Boolean, + themeBackgroundColor: Color, + themeTextColor: Color +): Color { + if (!isSpecified) return this + return CssParser.adaptColorForTheme( + color = this, + isDarkTheme = isDarkTheme, + isBackground = isBackground, + themeBackground = themeBackgroundColor, + themeText = themeTextColor + ) +} + +private fun String.applyReaderThemeToUnderlineAnnotation( + isDarkTheme: Boolean, + themeBackgroundColor: Color, + themeTextColor: Color +): String { + val parts = split('|').toMutableList() + val colorPart = parts.getOrNull(1) ?: return this + if (colorPart == "Unspecified") return this + + val color = colorPart.toULongOrNull()?.let { Color(it) } ?: return this + parts[1] = color.applyReaderThemeColor( + isDarkTheme = isDarkTheme, + isBackground = false, + themeBackgroundColor = themeBackgroundColor, + themeTextColor = themeTextColor + ).value.toString() + return parts.joinToString("|") +} + +private fun String.applyReaderThemeToSvgText(themeTextColor: Color): String { + if (!themeTextColor.isSpecified || isBlank()) return this + return try { + val textColorHex = themeTextColor.toCssHexString() + val svgDocument = Jsoup.parseBodyFragment(this) + val svgElement = svgDocument.body().children().firstOrNull() ?: return this + + svgElement.select("text").forEach { textElement -> + val existingStyle = textElement.attr("style") + val styleWithoutFill = existingStyle.replace(Regex("""\bfill\s*:\s*[^;]+;?"""), "") + val newStyle = "fill:$textColorHex; $styleWithoutFill".trim() + textElement.attr("style", newStyle) + textElement.removeAttr("fill") + } + svgElement.outerHtml() + } catch (_: Exception) { + this + } +} + +private fun Color.toCssHexString(): String { + val red = (this.red * 255).toInt() + val green = (this.green * 255).toInt() + val blue = (this.blue * 255).toInt() + return "#%02X%02X%02X".format(red, green, blue) +} diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/data/BookCacheDatabase.kt b/app/src/main/java/com/aryan/reader/paginatedreader/data/BookCacheDatabase.kt index 3138b7b..821fd86 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/data/BookCacheDatabase.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/data/BookCacheDatabase.kt @@ -28,6 +28,8 @@ import androidx.room.Query import androidx.room.Room import androidx.room.RoomDatabase import androidx.room.Transaction +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase @Dao abstract class BookCacheDao { @@ -151,12 +153,19 @@ abstract class BookCacheDao { @Query("DELETE FROM configuration_cache WHERE bookId = :bookId") abstract suspend fun deleteConfigurationCacheForBook(bookId: String) + @Query("DELETE FROM page_cache_metadata WHERE book_id = :bookId") + protected abstract suspend fun deletePageCacheMetadataForBook(bookId: String) + + @Query("DELETE FROM page_cache_metadata WHERE book_id = :bookId AND config_hash = :configHash AND chapter_index = :chapterIndex") + protected abstract suspend fun deletePageCacheMetadataForChapter(bookId: String, configHash: Int, chapterIndex: Int) + @Transaction open suspend fun deleteEntireBookCache(bookId: String) { deleteBook(bookId) deleteChaptersForBook(bookId) deleteAnchorsForBook(bookId) deleteConfigurationCacheForBook(bookId) + deletePageCacheMetadataForBook(bookId) } @Query("DELETE FROM anchor_index") @@ -165,12 +174,16 @@ abstract class BookCacheDao { @Query("DELETE FROM configuration_cache") abstract suspend fun clearConfigurationCache() + @Query("DELETE FROM page_cache_metadata") + protected abstract suspend fun clearPageCacheMetadata() + @Transaction open suspend fun clearAllCache() { clearProcessedBooks() clearProcessedChapters() clearAnchors() clearConfigurationCache() + clearPageCacheMetadata() } @Query("SELECT * FROM configuration_cache WHERE bookId = :bookId AND configHash = :configHash") @@ -188,6 +201,101 @@ abstract class BookCacheDao { ) """) abstract suspend fun cleanupOldConfigurations(bookId: String) + + @Query("SELECT * FROM page_cache_metadata WHERE book_id = :bookId AND config_hash = :configHash AND chapter_index = :chapterIndex") + protected abstract suspend fun getPageCacheMetadata(bookId: String, configHash: Int, chapterIndex: Int): PageCacheMetadata? + + @Query("SELECT chunk_data FROM page_cache_chunks WHERE book_id = :bookId AND config_hash = :configHash AND chapter_index = :chapterIndex ORDER BY chunk_index ASC") + protected abstract suspend fun getPageCacheChunks(bookId: String, configHash: Int, chapterIndex: Int): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + protected abstract suspend fun insertPageCacheMetadata(metadata: PageCacheMetadata) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + protected abstract suspend fun insertPageCacheChunks(chunks: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun insertPageIndexEntries(entries: List) + + @Query("SELECT * FROM page_index_entries WHERE book_id = :bookId AND config_hash = :configHash AND chapter_index = :chapterIndex ORDER BY page_in_chapter ASC") + abstract suspend fun getPageIndexEntries(bookId: String, configHash: Int, chapterIndex: Int): List + + @Transaction + open suspend fun getPageCache(bookId: String, configHash: Int, chapterIndex: Int): PageCacheEntry? { + val metadata = getPageCacheMetadata(bookId, configHash, chapterIndex) ?: return null + val chunks = getPageCacheChunks(bookId, configHash, chapterIndex) + if (chunks.isEmpty()) return null + + val totalSize = chunks.sumOf { it.size } + val mergedData = ByteArray(totalSize) + var offset = 0 + for (chunk in chunks) { + System.arraycopy(chunk, 0, mergedData, offset, chunk.size) + offset += chunk.size + } + + return PageCacheEntry( + bookId = metadata.bookId, + configHash = metadata.configHash, + chapterIndex = metadata.chapterIndex, + processingVersion = metadata.processingVersion, + pageCacheVersion = metadata.pageCacheVersion, + contentVersion = metadata.contentVersion, + pageCount = metadata.pageCount, + pagesProto = mergedData + ) + } + + @Transaction + open suspend fun insertPageCache(entry: PageCacheEntry, pageIndexEntries: List) { + @Suppress("LocalVariableName") val CHUNK_SIZE = 900 * 1024 + + deletePageCacheMetadataForChapter(entry.bookId, entry.configHash, entry.chapterIndex) + + insertPageCacheMetadata( + PageCacheMetadata( + bookId = entry.bookId, + configHash = entry.configHash, + chapterIndex = entry.chapterIndex, + processingVersion = entry.processingVersion, + pageCacheVersion = entry.pageCacheVersion, + contentVersion = entry.contentVersion, + pageCount = entry.pageCount + ) + ) + + val chunks = ArrayList() + var offset = 0 + var chunkIndex = 0 + while (offset < entry.pagesProto.size) { + val end = (offset + CHUNK_SIZE).coerceAtMost(entry.pagesProto.size) + chunks.add( + PageCacheChunk( + bookId = entry.bookId, + configHash = entry.configHash, + chapterIndex = entry.chapterIndex, + chunkIndex = chunkIndex, + chunkData = entry.pagesProto.copyOfRange(offset, end) + ) + ) + offset = end + chunkIndex++ + } + insertPageCacheChunks(chunks) + if (pageIndexEntries.isNotEmpty()) { + insertPageIndexEntries(pageIndexEntries) + } + } + + @Query(""" + DELETE FROM page_cache_metadata + WHERE book_id = :bookId AND config_hash NOT IN ( + SELECT configHash FROM configuration_cache + WHERE bookId = :bookId + ORDER BY rowid DESC LIMIT 3 + ) + """) + abstract suspend fun cleanupOldPageCaches(bookId: String) } @Database( @@ -196,9 +304,12 @@ abstract class BookCacheDao { ProcessedChapterMetadata::class, ProcessedChapterChunk::class, ConfigurationCache::class, - AnchorIndexEntry::class + AnchorIndexEntry::class, + PageCacheMetadata::class, + PageCacheChunk::class, + PageIndexEntry::class ], - version = 10, + version = 11, exportSchema = false ) abstract class BookCacheDatabase : RoomDatabase() { @@ -215,11 +326,73 @@ abstract class BookCacheDatabase : RoomDatabase() { BookCacheDatabase::class.java, "book_cache_database" ) + .addMigrations(MIGRATION_10_11) .fallbackToDestructiveMigration(true) .build() INSTANCE = instance instance } } + + private val MIGRATION_10_11 = object : Migration(10, 11) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `page_cache_metadata` ( + `book_id` TEXT NOT NULL, + `config_hash` INTEGER NOT NULL, + `chapter_index` INTEGER NOT NULL, + `processing_version` INTEGER NOT NULL, + `page_cache_version` INTEGER NOT NULL, + `content_version` INTEGER NOT NULL, + `page_count` INTEGER NOT NULL, + PRIMARY KEY(`book_id`, `config_hash`, `chapter_index`) + ) + """.trimIndent() + ) + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `page_cache_chunks` ( + `book_id` TEXT NOT NULL, + `config_hash` INTEGER NOT NULL, + `chapter_index` INTEGER NOT NULL, + `chunk_index` INTEGER NOT NULL, + `chunk_data` BLOB NOT NULL, + PRIMARY KEY(`book_id`, `config_hash`, `chapter_index`, `chunk_index`), + FOREIGN KEY(`book_id`, `config_hash`, `chapter_index`) + REFERENCES `page_cache_metadata`(`book_id`, `config_hash`, `chapter_index`) + ON UPDATE NO ACTION ON DELETE CASCADE + ) + """.trimIndent() + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_page_cache_chunks_book_id_config_hash_chapter_index` ON `page_cache_chunks` (`book_id`, `config_hash`, `chapter_index`)" + ) + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `page_index_entries` ( + `book_id` TEXT NOT NULL, + `config_hash` INTEGER NOT NULL, + `chapter_index` INTEGER NOT NULL, + `page_in_chapter` INTEGER NOT NULL, + `first_block_index` INTEGER NOT NULL, + `last_block_index` INTEGER NOT NULL, + `first_text_block_index` INTEGER, + `first_text_char_offset` INTEGER NOT NULL, + `first_text_end_offset` INTEGER NOT NULL, + `first_cfi` TEXT, + `anchors` TEXT NOT NULL, + PRIMARY KEY(`book_id`, `config_hash`, `chapter_index`, `page_in_chapter`), + FOREIGN KEY(`book_id`, `config_hash`, `chapter_index`) + REFERENCES `page_cache_metadata`(`book_id`, `config_hash`, `chapter_index`) + ON UPDATE NO ACTION ON DELETE CASCADE + ) + """.trimIndent() + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_page_index_entries_book_id_config_hash_chapter_index` ON `page_index_entries` (`book_id`, `config_hash`, `chapter_index`)" + ) + } + } } } diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/data/BookCacheEntities.kt b/app/src/main/java/com/aryan/reader/paginatedreader/data/BookCacheEntities.kt index da13b51..3afc197 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/data/BookCacheEntities.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/data/BookCacheEntities.kt @@ -25,7 +25,8 @@ import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey -const val LATEST_PROCESSING_VERSION = 10 +const val LATEST_PROCESSING_VERSION = 11 +const val LATEST_PAGE_CACHE_VERSION = 3 @Entity(tableName = "processed_books") data class ProcessedBook( @@ -131,3 +132,121 @@ data class ConfigurationCache( val configHash: Int, val chapterPageCounts: String ) + +data class PageCacheEntry( + val bookId: String, + val configHash: Int, + val chapterIndex: Int, + val processingVersion: Int, + val pageCacheVersion: Int, + val contentVersion: Int, + val pageCount: Int, + val pagesProto: ByteArray +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as PageCacheEntry + if (bookId != other.bookId) return false + if (configHash != other.configHash) return false + if (chapterIndex != other.chapterIndex) return false + if (processingVersion != other.processingVersion) return false + if (pageCacheVersion != other.pageCacheVersion) return false + if (contentVersion != other.contentVersion) return false + if (pageCount != other.pageCount) return false + if (!pagesProto.contentEquals(other.pagesProto)) return false + return true + } + + override fun hashCode(): Int { + var result = bookId.hashCode() + result = 31 * result + configHash + result = 31 * result + chapterIndex + result = 31 * result + processingVersion + result = 31 * result + pageCacheVersion + result = 31 * result + contentVersion + result = 31 * result + pageCount + result = 31 * result + pagesProto.contentHashCode() + return result + } +} + +@Entity(tableName = "page_cache_metadata", primaryKeys = ["book_id", "config_hash", "chapter_index"]) +data class PageCacheMetadata( + @ColumnInfo(name = "book_id") val bookId: String, + @ColumnInfo(name = "config_hash") val configHash: Int, + @ColumnInfo(name = "chapter_index") val chapterIndex: Int, + @ColumnInfo(name = "processing_version") val processingVersion: Int, + @ColumnInfo(name = "page_cache_version") val pageCacheVersion: Int, + @ColumnInfo(name = "content_version") val contentVersion: Int, + @ColumnInfo(name = "page_count") val pageCount: Int +) + +@Entity( + tableName = "page_cache_chunks", + primaryKeys = ["book_id", "config_hash", "chapter_index", "chunk_index"], + foreignKeys = [ + ForeignKey( + entity = PageCacheMetadata::class, + parentColumns = ["book_id", "config_hash", "chapter_index"], + childColumns = ["book_id", "config_hash", "chapter_index"], + onDelete = ForeignKey.CASCADE + ) + ], + indices = [Index(value = ["book_id", "config_hash", "chapter_index"])] +) +data class PageCacheChunk( + @ColumnInfo(name = "book_id") val bookId: String, + @ColumnInfo(name = "config_hash") val configHash: Int, + @ColumnInfo(name = "chapter_index") val chapterIndex: Int, + @ColumnInfo(name = "chunk_index") val chunkIndex: Int, + @ColumnInfo(name = "chunk_data", typeAffinity = ColumnInfo.BLOB) val chunkData: ByteArray +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as PageCacheChunk + if (bookId != other.bookId) return false + if (configHash != other.configHash) return false + if (chapterIndex != other.chapterIndex) return false + if (chunkIndex != other.chunkIndex) return false + if (!chunkData.contentEquals(other.chunkData)) return false + return true + } + + override fun hashCode(): Int { + var result = bookId.hashCode() + result = 31 * result + configHash + result = 31 * result + chapterIndex + result = 31 * result + chunkIndex + result = 31 * result + chunkData.contentHashCode() + return result + } +} + +@Entity( + tableName = "page_index_entries", + primaryKeys = ["book_id", "config_hash", "chapter_index", "page_in_chapter"], + foreignKeys = [ + ForeignKey( + entity = PageCacheMetadata::class, + parentColumns = ["book_id", "config_hash", "chapter_index"], + childColumns = ["book_id", "config_hash", "chapter_index"], + onDelete = ForeignKey.CASCADE + ) + ], + indices = [Index(value = ["book_id", "config_hash", "chapter_index"])] +) +data class PageIndexEntry( + @ColumnInfo(name = "book_id") val bookId: String, + @ColumnInfo(name = "config_hash") val configHash: Int, + @ColumnInfo(name = "chapter_index") val chapterIndex: Int, + @ColumnInfo(name = "page_in_chapter") val pageInChapter: Int, + @ColumnInfo(name = "first_block_index") val firstBlockIndex: Int, + @ColumnInfo(name = "last_block_index") val lastBlockIndex: Int, + @ColumnInfo(name = "first_text_block_index") val firstTextBlockIndex: Int?, + @ColumnInfo(name = "first_text_char_offset") val firstTextCharOffset: Int, + @ColumnInfo(name = "first_text_end_offset") val firstTextEndOffset: Int, + @ColumnInfo(name = "first_cfi") val firstCfi: String?, + @ColumnInfo(name = "anchors") val anchors: String +) diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/data/BookProcessingWorker.kt b/app/src/main/java/com/aryan/reader/paginatedreader/data/BookProcessingWorker.kt index 794a7fc..3ce975a 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/data/BookProcessingWorker.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/data/BookProcessingWorker.kt @@ -63,7 +63,8 @@ import kotlin.math.abs data class SerializableEpubChapter( @ProtoNumber(1) val htmlContent: String, @ProtoNumber(2) val title: String, - @ProtoNumber(3) val absPath: String + @ProtoNumber(3) val absPath: String, + @ProtoNumber(4) val htmlFilePath: String = absPath ) @OptIn(ExperimentalSerializationApi::class) @@ -208,7 +209,8 @@ class BookProcessingWorker( baseFontSizeSp = textStyle.fontSize.value, density = density.density, constraints = constraints, - isDarkTheme = false // GUARANTEED LIGHT THEME + isDarkTheme = false, + adaptThemeColors = false ) lightThemeCssRules = lightThemeCssRules.merge(uaResult.rules) @@ -219,7 +221,8 @@ class BookProcessingWorker( baseFontSizeSp = textStyle.fontSize.value, density = density.density, constraints = constraints, - isDarkTheme = false // GUARANTEED LIGHT THEME + isDarkTheme = false, + adaptThemeColors = false ) lightThemeCssRules = lightThemeCssRules.merge(bookCssResult.rules) } @@ -242,7 +245,20 @@ class BookProcessingWorker( Timber.d("Async task started for chapter index $index.") if (db.bookCacheDao().getProcessedChapter(bookId, index) == null) { Timber.d("[BG_PROC] Caching chapter $index: ${chapter.title}") - val document = Jsoup.parse(chapter.htmlContent, chapter.absPath) + val htmlToParse = chapter.htmlContent.ifBlank { + val backingFile = File(extractionBasePath, chapter.htmlFilePath) + if (backingFile.exists()) { + backingFile.readText() + } else { + "" + } + } + if (htmlToParse.isBlank()) { + Timber.w("[BG_PROC] Skipping chapter $index because no HTML content was available.") + return@async null + } + + val document = Jsoup.parse(htmlToParse, chapter.absPath) val mathElements = document.select("math") val svgResults = mutableMapOf() @@ -294,7 +310,7 @@ class BookProcessingWorker( bookId = bookId, chapterIndex = index, contentBlocksProto = protoBytes, - estimatedPageCount = 0 + estimatedPageCount = estimateSemanticPageCount(semanticBlocks) ) } else { Timber.d("Chapter $index was already in the database. Skipping.") @@ -371,4 +387,28 @@ class BookProcessingWorker( blocks.forEach { walk(it) } return anchors } + + private fun estimateSemanticPageCount( + blocks: List + ): Int { + var charCount = 0 + + fun walk(block: com.aryan.reader.paginatedreader.SemanticBlock) { + when (block) { + is com.aryan.reader.paginatedreader.SemanticTextBlock -> { + charCount += block.text.length + } + is com.aryan.reader.paginatedreader.SemanticFlexContainer -> block.children.forEach(::walk) + is com.aryan.reader.paginatedreader.SemanticTable -> { + block.rows.forEach { row -> row.forEach { cell -> cell.content.forEach(::walk) } } + } + is com.aryan.reader.paginatedreader.SemanticList -> block.items.forEach(::walk) + is com.aryan.reader.paginatedreader.SemanticWrappingBlock -> block.paragraphsToWrap.forEach(::walk) + else -> Unit + } + } + + blocks.forEach(::walk) + return ((charCount + 2_499) / 2_500).coerceAtLeast(1) + } } diff --git a/app/src/main/java/com/aryan/reader/pdf/NativePdfiumBridge.kt b/app/src/main/java/com/aryan/reader/pdf/NativePdfiumBridge.kt index f60efa1..ba4f6e7 100644 --- a/app/src/main/java/com/aryan/reader/pdf/NativePdfiumBridge.kt +++ b/app/src/main/java/com/aryan/reader/pdf/NativePdfiumBridge.kt @@ -32,6 +32,38 @@ object NativePdfiumBridge { @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 + @JvmStatic external fun exportAnnotatedPdf( + sourcePath: String, + destPath: String, + inkPageIndices: IntArray, + inkTypes: IntArray, + inkColors: IntArray, + inkStrokeWidths: FloatArray, + inkPointOffsets: IntArray, + inkPointCounts: IntArray, + inkPoints: FloatArray, + textPageIndices: IntArray, + textBounds: FloatArray, + textColors: IntArray, + textBackgroundColors: IntArray, + textFontSizes: FloatArray, + textFlags: IntArray, + textValues: Array, + textFontPaths: Array, + textFontNames: Array, + rasterPageIndices: IntArray, + rasterBounds: FloatArray, + rasterWidths: IntArray, + rasterHeights: IntArray, + rasterPixelOffsets: IntArray, + rasterPixels: IntArray, + highlightPageIndices: IntArray, + highlightColors: IntArray, + highlightRectOffsets: IntArray, + highlightRectCounts: IntArray, + highlightRects: FloatArray, + highlightContents: Array + ): Boolean const val ANNOT_TEXT = PdfiumAnnotationSubtype.TEXT const val ANNOT_LINK = PdfiumAnnotationSubtype.LINK diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfExporter.kt b/app/src/main/java/com/aryan/reader/pdf/PdfExporter.kt index d974489..e69de29 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfExporter.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfExporter.kt @@ -1,1057 +0,0 @@ -/* - * Episteme Reader - A native Android document reader. - * Copyright (C) 2026 Episteme - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * mail: epistemereader@gmail.com - */ -package com.aryan.reader.pdf - -import android.content.Context -import android.graphics.BitmapShader -import android.graphics.Canvas -import android.graphics.Paint -import android.graphics.PorterDuff -import android.graphics.PorterDuffColorFilter -import android.graphics.Shader -import android.net.Uri -import androidx.compose.ui.graphics.Color -import com.tom_roush.pdfbox.pdmodel.font.PDType0Font -import java.io.File -import java.io.FileInputStream -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.unit.isSpecified -import androidx.core.graphics.createBitmap -import com.aryan.reader.pdf.data.PdfAnnotation -import com.aryan.reader.pdf.data.PdfTextBox -import com.aryan.reader.pdf.data.VirtualPage -import com.tom_roush.pdfbox.pdmodel.PDDocument -import com.tom_roush.pdfbox.pdmodel.PDPage -import com.tom_roush.pdfbox.pdmodel.PDPageContentStream -import com.tom_roush.pdfbox.pdmodel.common.PDRectangle -import com.tom_roush.pdfbox.pdmodel.font.PDFont -import com.tom_roush.pdfbox.pdmodel.font.PDType1Font -import com.tom_roush.pdfbox.pdmodel.graphics.blend.BlendMode -import com.tom_roush.pdfbox.pdmodel.graphics.image.LosslessFactory -import com.tom_roush.pdfbox.pdmodel.graphics.state.PDExtendedGraphicsState -import com.tom_roush.pdfbox.pdmodel.graphics.state.RenderingMode -import com.tom_roush.pdfbox.util.Matrix -import java.io.OutputStream -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import timber.log.Timber -import java.util.StringTokenizer - -object PdfExporter { - private class PdfBoxFontCache(val doc: PDDocument, val context: Context) { - private val cache = mutableMapOf() - - fun getFont(fontPath: String?, fontName: String?, isBold: Boolean, isItalic: Boolean): PDFont { - if (!fontPath.isNullOrBlank()) { - Timber.tag("PdfFontDebug").d("Exporter: Requesting font at $fontPath") - val cached = cache[fontPath] - if (cached != null) return cached - - try { - val font = if (fontPath.startsWith("asset:")) { - val assetPath = fontPath.removePrefix("asset:") - Timber.tag("PdfFontDebug").i("Exporter: Loading preset font from assets: $assetPath") - PDType0Font.load(doc, context.assets.open(assetPath)) - } else { - val file = File(fontPath) - if (file.exists()) { - PDType0Font.load(doc, FileInputStream(file)) - } else null - } - - if (font != null) { - cache[fontPath] = font - return font - } - } catch (e: Exception) { - Timber.tag("PdfFontDebug").e(e, "Exporter: Failed to embed $fontPath") - } - } - - // 2. Map Standard Presets via fontName - if (fontName != null) { - when (fontName) { - "Serif" -> return when { - isBold && isItalic -> PDType1Font.TIMES_BOLD_ITALIC - isBold -> PDType1Font.TIMES_BOLD - isItalic -> PDType1Font.TIMES_ITALIC - else -> PDType1Font.TIMES_ROMAN - } - "Monospace" -> return when { - isBold && isItalic -> PDType1Font.COURIER_BOLD_OBLIQUE - isBold -> PDType1Font.COURIER_BOLD - isItalic -> PDType1Font.COURIER_OBLIQUE - else -> PDType1Font.COURIER - } - // "Sans" and others fall through to Helvetica - } - } - - // 3. Fallback to Helvetica (Sans-Serif) - return when { - isBold && isItalic -> PDType1Font.HELVETICA_BOLD_OBLIQUE - isBold -> PDType1Font.HELVETICA_BOLD - isItalic -> PDType1Font.HELVETICA_OBLIQUE - else -> PDType1Font.HELVETICA - } - } - } - - private fun applyStyleSimulations( - cs: PDPageContentStream, - fontSize: Float, - isBold: Boolean, - isItalic: Boolean, - isCustomFont: Boolean, - x: Float, - y: Float - ) { - if (isCustomFont) { - if (isBold) { - cs.setRenderingMode(RenderingMode.FILL_STROKE) - cs.setLineWidth(fontSize * 0.03f) - } else { - cs.setRenderingMode(RenderingMode.FILL) - } - - if (isItalic) { - cs.setTextMatrix(Matrix(1f, 0f, 0.3f, 1f, x, y)) - } else { - cs.setTextMatrix(Matrix(1f, 0f, 0f, 1f, x, y)) - } - } else { - cs.setRenderingMode(RenderingMode.FILL) - cs.setTextMatrix(Matrix(1f, 0f, 0f, 1f, x, y)) - } - } - - suspend fun exportAnnotatedPdf( - context: Context, - sourceUri: Uri, - destStream: OutputStream, - virtualPages: List?, - inkAnnotations: Map>, - richTextPageLayouts: List? = null, - textBoxes: List? = null, - highlights: List? = null - ) { - withContext(Dispatchers.IO) { - var sourceDocument: PDDocument? = null - var destDocument: PDDocument? = null - try { - val inputStream = context.contentResolver.openInputStream(sourceUri) - sourceDocument = PDDocument.load(inputStream) - destDocument = PDDocument() - - // Determine the sequence of pages to export - val pagesToProcess: List = - virtualPages - ?: (0 until sourceDocument.numberOfPages).map { - VirtualPage.PdfPage(it) - } - - val referencePage = - if (sourceDocument.numberOfPages > 0) sourceDocument.getPage(0) else null - val fontCache = PdfBoxFontCache(destDocument, context) - - Timber.tag("PdfExportDebug").i("Starting export. Total highlights received: ${highlights?.size ?: 0}") - - pagesToProcess.forEachIndexed { virtualIndex, vPage -> - val pageToDecorate: PDPage = - when (vPage) { - is VirtualPage.PdfPage -> { - if (vPage.pdfIndex < sourceDocument.numberOfPages) { - destDocument.importPage( - sourceDocument.getPage(vPage.pdfIndex) - ) - } else { - Timber.w( - "Source page ${vPage.pdfIndex} is out of bounds! Creating blank page as fallback." - ) - val blank = - PDPage(referencePage?.mediaBox ?: PDRectangle.A4) - destDocument.addPage(blank) - blank - } - } - is VirtualPage.BlankPage -> { - Timber.tag("PdfExportSize").d("Creating blank page with explicit dimensions: ${vPage.width}x${vPage.height}") - val blank = PDPage(PDRectangle(vPage.width.toFloat(), vPage.height.toFloat())) - destDocument.addPage(blank) - blank - } - } - - val pageInkAnnos = inkAnnotations[virtualIndex] ?: emptyList() - val richTextLayout = richTextPageLayouts?.find { it.pageIndex == virtualIndex } - - val cropBox = pageToDecorate.cropBox - val pageWidth = cropBox.width - val pageHeight = cropBox.height - val lowerLeftY = cropBox.lowerLeftY - - val pageHighlights = highlights?.filter { it.pageIndex == virtualIndex } - Timber.tag("PdfExportDebug").d("Page $virtualIndex: Found ${pageHighlights?.size ?: 0} highlights to draw.") - - if (!pageHighlights.isNullOrEmpty()) { - PDPageContentStream(destDocument, pageToDecorate, PDPageContentStream.AppendMode.APPEND, true, true).use { cs -> - drawHighlights(cs, pageHighlights) - } - } - - if (pageInkAnnos.isNotEmpty()) { - val (pencilAnnos, vectorAnnos) = - pageInkAnnos.partition { it.inkType == InkType.PENCIL } - - if (pencilAnnos.isNotEmpty()) { - drawPencilOverlay( - destDocument, - pageToDecorate, - pencilAnnos, - pageWidth, - pageHeight, - lowerLeftY - ) - } - - if (vectorAnnos.isNotEmpty()) { - PDPageContentStream( - destDocument, - pageToDecorate, - PDPageContentStream.AppendMode.APPEND, - true, - true - ) - .use { cs -> - vectorAnnos.forEach { annotation -> - if (annotation.inkType == InkType.FOUNTAIN_PEN) { - drawFountainPen( - cs, - annotation, - pageWidth, - pageHeight, - lowerLeftY - ) - } else { - drawStandardAnnotation( - cs, - annotation, - pageWidth, - pageHeight, - lowerLeftY - ) - } - } - } - } - } - - if (richTextLayout != null && richTextLayout.visibleText.isNotEmpty()) { - PDPageContentStream(destDocument, pageToDecorate, PDPageContentStream.AppendMode.APPEND, true, true).use { cs -> - drawRichTextLayout(cs, richTextLayout, pageWidth, pageHeight, lowerLeftY, fontCache) - } - } - val pageTextBoxes = textBoxes?.filter { it.pageIndex == virtualIndex } - if (!pageTextBoxes.isNullOrEmpty()) { - PDPageContentStream(destDocument, pageToDecorate, PDPageContentStream.AppendMode.APPEND, true, true).use { cs -> - drawTextBoxes(cs, pageTextBoxes, pageWidth, pageHeight, lowerLeftY, fontCache) - } - } - } - destDocument.save(destStream) - Timber.tag("PdfExportDebug").i("Export document saved successfully.") - } catch (e: Exception) { - Timber.tag("PdfExportDebug").e(e, "Export failed during processing") - throw e - } finally { - sourceDocument?.close() - destDocument?.close() - destStream.close() - } - } - } - - private fun drawTextBoxes( - cs: PDPageContentStream, - boxes: List, - pageWidth: Float, - pageHeight: Float, - lowerLeftY: Float, - fontCache: PdfBoxFontCache - ) { - for (box in boxes) { - if (box.text.isBlank()) continue - - val font = fontCache.getFont(box.fontPath, box.fontName, box.isBold, box.isItalic) - val fontSize = box.fontSize * pageHeight - val lineHeight = fontSize * 1.2f - val boxX = box.relativeBounds.left * pageWidth - val boxWidth = box.relativeBounds.width * pageWidth - val topY = lowerLeftY + pageHeight - (box.relativeBounds.top * pageHeight) - - val wrappedLines = mutableListOf() - val paragraphs = box.text.split('\n') - - for (paragraph in paragraphs) { - if (paragraph.isEmpty()) { - wrappedLines.add("") - continue - } - - val tokenizer = StringTokenizer(paragraph, " ", true) - var currentLine = StringBuilder() - var currentLineWidth = 0f - - while (tokenizer.hasMoreTokens()) { - val token = tokenizer.nextToken() - - fun getStringWidth(s: String): Float = try { - (font.getStringWidth(s) / 1000f) * fontSize - } catch (_: Exception) { 0f } - - val tokenWidth = getStringWidth(token) - - if (tokenWidth > boxWidth) { - if (currentLine.isNotEmpty()) { - wrappedLines.add(currentLine.toString()) - currentLine = StringBuilder() - currentLineWidth = 0f - } - - var tempWord = StringBuilder() - var tempWidth = 0f - - for (char in token) { - val charW = getStringWidth(char.toString()) - if (tempWidth + charW > boxWidth) { - wrappedLines.add(tempWord.toString()) - tempWord = StringBuilder(char.toString()) - tempWidth = charW - } else { - tempWord.append(char) - tempWidth += charW - } - } - currentLine.append(tempWord) - currentLineWidth = tempWidth - } else if (currentLineWidth + tokenWidth <= boxWidth) { - currentLine.append(token) - currentLineWidth += tokenWidth - } else { - wrappedLines.add(currentLine.toString()) - if (token.isBlank()) { - currentLine = StringBuilder() - currentLineWidth = 0f - } else { - currentLine = StringBuilder(token) - currentLineWidth = tokenWidth - } - } - } - if (currentLine.isNotEmpty()) { - wrappedLines.add(currentLine.toString()) - } - } - - if (box.backgroundColor != Color.Transparent && - box.backgroundColor != Color.Unspecified) { - - val r = box.backgroundColor.red - val g = box.backgroundColor.green - val b = box.backgroundColor.blue - val a = box.backgroundColor.alpha - - if (a < 1.0f) { - val gs = PDExtendedGraphicsState() - gs.nonStrokingAlphaConstant = a - cs.setGraphicsStateParameters(gs) - } - - cs.setNonStrokingColor(r, g, b) - - var currentBgY = topY - - for (line in wrappedLines) { - if (line.isNotEmpty()) { - val lineWidth = try { (font.getStringWidth(line) / 1000f) * fontSize } catch(_: Exception) { 0f } - val padding = fontSize * 0.1f - - cs.addRect(boxX - padding, currentBgY - lineHeight, lineWidth + (padding * 2), lineHeight) - cs.fill() - } - currentBgY -= lineHeight - } - - if (a < 1.0f) { - val gs = PDExtendedGraphicsState() - gs.nonStrokingAlphaConstant = 1.0f - cs.setGraphicsStateParameters(gs) - } - } - - val tr = box.color.red - val tg = box.color.green - val tb = box.color.blue - cs.setNonStrokingColor(tr, tg, tb) - cs.setFont(font, fontSize) - - val textY = topY - (fontSize * 0.85f) - - cs.beginText() - for ((index, line) in wrappedLines.withIndex()) { - val currentLineY = textY - (index * lineHeight) - - applyStyleSimulations( - cs = cs, - fontSize = fontSize, - isBold = box.isBold, - isItalic = box.isItalic, - isCustomFont = !box.fontPath.isNullOrBlank(), - x = boxX, - y = currentLineY - ) - - if (line.isNotEmpty()) { - try { - cs.showText(line) - } catch (e: Exception) { - Timber.e(e, "Error drawing text line") - } - } - } - cs.endText() - - if (box.isUnderline || box.isStrikeThrough) { - cs.setStrokingColor(tr, tg, tb) - cs.setLineWidth(fontSize / 15f) - - var decorY = topY - (fontSize * 0.85f) - - for (line in wrappedLines) { - if (line.isNotEmpty()) { - val lineWidth = try { (font.getStringWidth(line) / 1000f) * fontSize } catch(_:Exception){0f} - - if (box.isUnderline) { - val underlineY = decorY - (fontSize * 0.15f) - cs.moveTo(boxX, underlineY) - cs.lineTo(boxX + lineWidth, underlineY) - cs.stroke() - } - - if (box.isStrikeThrough) { - val strikeY = decorY + (fontSize * 0.3f) - cs.moveTo(boxX, strikeY) - cs.lineTo(boxX + lineWidth, strikeY) - cs.stroke() - } - } - decorY -= lineHeight - } - } - } - } - - private fun drawHighlights( - cs: PDPageContentStream, - highlights: List - ) { - val gs = PDExtendedGraphicsState() - gs.blendMode = BlendMode.MULTIPLY - gs.nonStrokingAlphaConstant = 0.4f - cs.setGraphicsStateParameters(gs) - - for (highlight in highlights) { - val r = highlight.color.color.red - val g = highlight.color.color.green - val b = highlight.color.color.blue - cs.setNonStrokingColor(r, g, b) - - for (rect in highlight.bounds) { - val x = minOf(rect.left, rect.right) - val y = minOf(rect.top, rect.bottom) - val w = kotlin.math.abs(rect.right - rect.left) - val h = kotlin.math.abs(rect.top - rect.bottom) - - cs.addRect(x, y, w, h) - cs.fill() - } - } - - // Reset graphics state - val resetState = PDExtendedGraphicsState() - resetState.blendMode = BlendMode.NORMAL - resetState.nonStrokingAlphaConstant = 1.0f - cs.setGraphicsStateParameters(resetState) - } - - private fun drawPencilOverlay( - document: PDDocument, - page: PDPage, - annotations: List, - pageWidth: Float, - pageHeight: Float, - lowerLeftY: Float - ) { - val scale = 2.0f - val bitmapW = (pageWidth * scale).toInt() - val bitmapH = (pageHeight * scale).toInt() - - if (bitmapW <= 0 || bitmapH <= 0) return - - val bitmap = createBitmap(bitmapW, bitmapH) - val canvas = Canvas(bitmap) - - val texture = PdfTextureGenerator.getNoiseTexture() - - val paint = - Paint().apply { - isAntiAlias = true - style = Paint.Style.STROKE - strokeCap = Paint.Cap.ROUND - strokeJoin = Paint.Join.ROUND - shader = BitmapShader(texture, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT) - } - - annotations.forEach { annot -> - if (annot.points.size > 1) { - val strokeWidthPx = annot.strokeWidth * bitmapW - paint.strokeWidth = strokeWidthPx - - val adjustedAlpha = (annot.color.alpha * 0.8f).coerceIn(0f, 1f) - - paint.colorFilter = - PorterDuffColorFilter( - android.graphics.Color.argb( - (adjustedAlpha * 255).toInt(), - (annot.color.red * 255).toInt(), - (annot.color.green * 255).toInt(), - (annot.color.blue * 255).toInt() - ), - PorterDuff.Mode.SRC_IN - ) - - val path = android.graphics.Path() - val startP = annot.points[0] - path.moveTo(startP.x * bitmapW, startP.y * bitmapH) - - for (i in 1 until annot.points.size) { - val p0 = annot.points[i - 1] - val p1 = annot.points[i] - val p0x = p0.x * bitmapW - val p0y = p0.y * bitmapH - val p1x = p1.x * bitmapW - val p1y = p1.y * bitmapH - val midX = (p0x + p1x) / 2f - val midY = (p0y + p1y) / 2f - if (i == 1) path.lineTo(midX, midY) else path.quadTo(p0x, p0y, midX, midY) - } - val last = annot.points.last() - path.lineTo(last.x * bitmapW, last.y * bitmapH) - canvas.drawPath(path, paint) - } - } - - val pdImage = LosslessFactory.createFromImage(document, bitmap) - bitmap.recycle() - PDPageContentStream(document, page, PDPageContentStream.AppendMode.APPEND, true, true) - .use { cs -> cs.drawImage(pdImage, 0f, lowerLeftY, pageWidth, pageHeight) } - } - - private fun drawFountainPen( - cs: PDPageContentStream, - annotation: PdfAnnotation, - pageWidth: Float, - pageHeight: Float, - lowerLeftY: Float - ) { - if (annotation.points.size < 2) return - - val r = annotation.color.red - val g = annotation.color.green - val b = annotation.color.blue - val a = annotation.color.alpha - - cs.setNonStrokingColor(r, g, b) - - if (a < 1.0f) { - val graphicsState = PDExtendedGraphicsState() - graphicsState.nonStrokingAlphaConstant = a - cs.setGraphicsStateParameters(graphicsState) - } - - val baseStrokeWidth = annotation.strokeWidth * pageWidth - val (leftSide, rightSide) = - PdfInkGeometry.calculateFountainPenPoints( - annotation.points, - baseStrokeWidth, - pageWidth, - pageHeight - ) - - if (leftSide.isNotEmpty()) { - fun fixY(y: Float): Float = lowerLeftY + pageHeight - y - - cs.moveTo(leftSide[0].x, fixY(leftSide[0].y)) - - for (i in 1 until leftSide.size) { - cs.lineTo(leftSide[i].x, fixY(leftSide[i].y)) - } - - for (i in rightSide.size - 1 downTo 0) { - cs.lineTo(rightSide[i].x, fixY(rightSide[i].y)) - } - - @Suppress("DEPRECATION") cs.closeSubPath() - cs.fill() - } - - if (a < 1.0f) { - val resetState = PDExtendedGraphicsState() - resetState.nonStrokingAlphaConstant = 1.0f - cs.setGraphicsStateParameters(resetState) - } - } - - private fun drawStandardAnnotation( - cs: PDPageContentStream, - annotation: PdfAnnotation, - pageWidth: Float, - pageHeight: Float, - lowerLeftY: Float - ) { - if (annotation.points.isEmpty()) return - - val r = annotation.color.red - val g = annotation.color.green - val b = annotation.color.blue - val a = annotation.color.alpha - - cs.setStrokingColor(r, g, b) - - if (a < 1.0f || - annotation.inkType == InkType.HIGHLIGHTER || - annotation.inkType == InkType.HIGHLIGHTER_ROUND - ) { - val graphicsState = PDExtendedGraphicsState() - graphicsState.strokingAlphaConstant = a - - if (annotation.inkType == InkType.HIGHLIGHTER || - annotation.inkType == InkType.HIGHLIGHTER_ROUND - ) { - graphicsState.blendMode = BlendMode.MULTIPLY - } - cs.setGraphicsStateParameters(graphicsState) - } - - val lineWidth = annotation.strokeWidth * pageWidth - cs.setLineWidth(lineWidth) - - when (annotation.inkType) { - InkType.HIGHLIGHTER -> cs.setLineCapStyle(0) - else -> cs.setLineCapStyle(1) - } - cs.setLineJoinStyle(1) - - val points = annotation.points - val startX = points[0].x * pageWidth - val startY = lowerLeftY + pageHeight - (points[0].y * pageHeight) - - cs.moveTo(startX, startY) - - for (i in 1 until points.size) { - val p0 = points[i - 1] - val p1 = points[i] - - val p0x = p0.x * pageWidth - val p0y = lowerLeftY + pageHeight - (p0.y * pageHeight) - - val p1x = p1.x * pageWidth - val p1y = lowerLeftY + pageHeight - (p1.y * pageHeight) - - val midX = (p0x + p1x) / 2f - val midY = (p0y + p1y) / 2f - - if (i == 1) { - cs.lineTo(midX, midY) - } else { - cs.curveTo2(p0x, p0y, midX, midY) - } - } - val lastP = points.last() - val lastX = lastP.x * pageWidth - val lastY = lowerLeftY + pageHeight - (lastP.y * pageHeight) - cs.lineTo(lastX, lastY) - - cs.stroke() - - val resetState = PDExtendedGraphicsState() - resetState.strokingAlphaConstant = 1.0f - resetState.blendMode = BlendMode.NORMAL - cs.setGraphicsStateParameters(resetState) - } - - private data class StyledRun( - val text: String, - val fontSize: Float, - val isBold: Boolean, - val isItalic: Boolean, - val isUnderline: Boolean, - val isStrikethrough: Boolean, - val colorArgb: Int, - val backgroundColorArgb: Int, - val fontPath: String?, - val fontName: String? // Add this field - ) - - private fun buildStyledRuns( - text: AnnotatedString, - @Suppress("SameParameterValue") startIndex: Int, - endIndex: Int, - scaleFactor: Float - ): List { - if (startIndex >= endIndex || text.text.isEmpty()) return emptyList() - - val runs = mutableListOf() - var currentRunStart = startIndex - val currentStyle = getStyleAt(text, startIndex) - - // Updated Tuple to 9 elements - data class StyleProps( - val fontSize: Float, - val isBold: Boolean, - val isItalic: Boolean, - val isUnderline: Boolean, - val isStrikethrough: Boolean, - val colorArgb: Int, - val backgroundColorArgb: Int, - val fontPath: String?, - val fontName: String? - ) - - fun extractRunProperties(style: SpanStyle): StyleProps { - val fontSize = if (style.fontSize.isSpecified) style.fontSize.value * scaleFactor else 16f * scaleFactor - val isBold = style.fontWeight == FontWeight.Bold - val isItalic = style.fontStyle == FontStyle.Italic - val decoration = style.textDecoration ?: TextDecoration.None - val isUnderline = decoration.contains(TextDecoration.Underline) - val isStrikethrough = decoration.contains(TextDecoration.LineThrough) - val colorArgb = if (style.color != Color.Unspecified) style.color.toArgb() else android.graphics.Color.BLACK - val bgColorArgb = if (style.background != Color.Unspecified) style.background.toArgb() else android.graphics.Color.TRANSPARENT - - val fontPath = PdfFontCache.getPath(style.fontFamily) - - // Map standard families back to names for the exporter - val fontName = when (style.fontFamily) { - FontFamily.Serif -> "Serif" - FontFamily.Monospace -> "Monospace" - FontFamily.SansSerif -> "Sans" - else -> null - } - - return StyleProps(fontSize, isBold, isItalic, isUnderline, isStrikethrough, colorArgb, bgColorArgb, fontPath, fontName) - } - - var currentProps = extractRunProperties(currentStyle) - - for (i in (startIndex + 1) until endIndex) { - val charStyle = getStyleAt(text, i) - val charProps = extractRunProperties(charStyle) - - if (charProps != currentProps) { - val runText = text.text.substring(currentRunStart, i) - runs.add( - StyledRun( - text = runText, - fontSize = currentProps.fontSize, - isBold = currentProps.isBold, - isItalic = currentProps.isItalic, - isUnderline = currentProps.isUnderline, - isStrikethrough = currentProps.isStrikethrough, - colorArgb = currentProps.colorArgb, - backgroundColorArgb = currentProps.backgroundColorArgb, - fontPath = currentProps.fontPath, - fontName = currentProps.fontName // Pass fontName - ) - ) - currentRunStart = i - currentProps = charProps - } - } - - val lastRunText = text.text.substring(currentRunStart, endIndex) - if (lastRunText.isNotEmpty()) { - runs.add( - StyledRun( - text = lastRunText, - fontSize = currentProps.fontSize, - isBold = currentProps.isBold, - isItalic = currentProps.isItalic, - isUnderline = currentProps.isUnderline, - isStrikethrough = currentProps.isStrikethrough, - colorArgb = currentProps.colorArgb, - backgroundColorArgb = currentProps.backgroundColorArgb, - fontPath = currentProps.fontPath, - fontName = currentProps.fontName - ) - ) - } - - return runs - } - - private fun drawRichTextLayout( - cs: PDPageContentStream, - layout: PageTextLayout, - pageWidth: Float, - pageHeight: Float, - lowerLeftY: Float, - fontCache: PdfBoxFontCache - ) { - val text = layout.visibleText - val layoutPageHeightPx = layout.pageHeightPx - - if (text.text.isEmpty()) return - - Timber.tag("PdfExportWrap").d("Starting export for Page ${layout.pageIndex}") - - val estimatedDensity = 2.3f - val scaleFactor = - if (layoutPageHeightPx > 0) { - estimatedDensity * pageHeight / layoutPageHeightPx - } else { - 1.15f - } - - val marginX = pageWidth * 0.1f - val marginY = pageHeight * 0.08f - val contentWidth = pageWidth - (marginX * 2) - - Timber.tag("PdfExportWrap").d("Layout Constants: pageWidth=$pageWidth, contentWidth=$contentWidth, scaleFactor=$scaleFactor") - - val allRuns = buildStyledRuns(text, 0, text.text.length, scaleFactor) - - val firstFontSize = allRuns.firstOrNull()?.fontSize ?: (16f * scaleFactor) - var currentY = lowerLeftY + pageHeight - marginY - (firstFontSize * 1.25f) - - data class LineRun(val run: StyledRun, val width: Float) - val currentLineRuns = mutableListOf() - var currentLineWidth = 0f - var maxFontSizeInLine = 0f - - fun flushLine() { - if (currentLineRuns.isEmpty()) return - Timber.tag("PdfExportWrap").d("Flushing Line: width=$currentLineWidth, y=$currentY, runsCount=${currentLineRuns.size}") - drawLineOfRuns(cs, currentLineRuns.map { it.run }, marginX, currentY, contentWidth, fontCache) - currentY -= (maxFontSizeInLine * 1.2f) - currentLineRuns.clear() - currentLineWidth = 0f - maxFontSizeInLine = 0f - } - - for (run in allRuns) { - val parts = run.text.split('\n') - parts.forEachIndexed { partIndex, part -> - if (partIndex > 0) { - flushLine() - if (part.isEmpty()) { - currentY -= (run.fontSize * 1.2f) - return@forEachIndexed - } - } - - if (part.isEmpty()) return@forEachIndexed - - val tokenizer = StringTokenizer(part, " \t\u000B\u000C\r", true) - - while (tokenizer.hasMoreTokens()) { - val token = tokenizer.nextToken() - var remainingToken = token - - while (remainingToken.isNotEmpty()) { - val font = fontCache.getFont(run.fontPath, run.fontName, run.isBold, run.isItalic) - - fun measure(s: String): Float = try { - (font.getStringWidth(s) / 1000f) * run.fontSize - } catch (_: Exception) { 0f } - - val tokenWidth = measure(remainingToken) - - if (currentLineWidth + tokenWidth <= contentWidth) { - currentLineRuns.add(LineRun(run.copy(text = remainingToken), tokenWidth)) - currentLineWidth += tokenWidth - if (run.fontSize > maxFontSizeInLine) maxFontSizeInLine = run.fontSize - remainingToken = "" - } - else if (currentLineRuns.isNotEmpty()) { - flushLine() - } - else { - var low = 1 - var high = remainingToken.length - var bestIndex = 1 - - while (low <= high) { - val mid = (low + high) / 2 - if (measure(remainingToken.take(mid)) <= contentWidth) { - bestIndex = mid - low = mid + 1 - } else { - high = mid - 1 - } - } - - val chunk = remainingToken.take(bestIndex) - val chunkWidth = measure(chunk) - - currentLineRuns.add(LineRun(run.copy(text = chunk), chunkWidth)) - currentLineWidth = chunkWidth - maxFontSizeInLine = run.fontSize - - flushLine() - remainingToken = remainingToken.substring(bestIndex) - } - } - } - } - } - flushLine() - } - - private fun drawLineOfRuns( - cs: PDPageContentStream, - runs: List, - startX: Float, - y: Float, - @Suppress("UNUSED_PARAMETER") contentWidth: Float, - fontCache: PdfBoxFontCache - ) { - if (runs.isEmpty()) return - - var bgX = startX - for (run in runs) { - val font = fontCache.getFont(run.fontPath, run.fontName, run.isBold, run.isItalic) - val safeText = run.text.replace("\n", " ") - .replace("\r", "") - .replace("\u000C", "") - .replace("\u200B", "") - - val runWidth = (font.getStringWidth(safeText) / 1000f) * run.fontSize - - if (run.backgroundColorArgb != android.graphics.Color.TRANSPARENT) { - val r = android.graphics.Color.red(run.backgroundColorArgb) / 255f - val g = android.graphics.Color.green(run.backgroundColorArgb) / 255f - val b = android.graphics.Color.blue(run.backgroundColorArgb) / 255f - cs.setNonStrokingColor(r, g, b) - cs.addRect(bgX, y - (run.fontSize * 0.2f), runWidth, run.fontSize * 1.2f) - cs.fill() - } - bgX += runWidth - } - - cs.beginText() - cs.newLineAtOffset(startX, y) - - var currentFont: PDFont? = null - var currentFontSize = -1f - var currentColor = -1 - android.graphics.Color.BLACK - - var currentX = startX - - for (run in runs) { - val font = fontCache.getFont(run.fontPath, run.fontName, run.isBold, run.isItalic) - val isCustom = !run.fontPath.isNullOrBlank() - - if (font != currentFont || run.fontSize != currentFontSize) { - cs.setFont(font, run.fontSize) - currentFont = font - currentFontSize = run.fontSize - } - - if (run.colorArgb != currentColor) { - val r = android.graphics.Color.red(run.colorArgb) / 255f - val g = android.graphics.Color.green(run.colorArgb) / 255f - val b = android.graphics.Color.blue(run.colorArgb) / 255f - cs.setNonStrokingColor(r, g, b) - currentColor = run.colorArgb - } - - applyStyleSimulations(cs, run.fontSize, run.isBold, run.isItalic, isCustom, currentX, y) - - try { - val safeText = run.text.replace("\n", " ").replace("\r", "").replace("\u000C", "").replace("\u200B", "") - cs.showText(safeText) - - val runWidth = (font.getStringWidth(safeText) / 1000f) * run.fontSize - currentX += runWidth - } catch (e: Exception) { - Timber.e(e, "Error drawing run: ${run.text}") - } - } - cs.endText() - - var decorationX = startX - for (run in runs) { - val font = fontCache.getFont(run.fontPath, run.fontName, run.isBold, run.isItalic) - val safeText = run.text.replace("\n", " ") - .replace("\r", "") - .replace("\u000C", "") - .replace("\u200B", "") - val runWidth = (font.getStringWidth(safeText) / 1000f) * run.fontSize - - if (run.isUnderline) { - val r = android.graphics.Color.red(run.colorArgb) / 255f - val g = android.graphics.Color.green(run.colorArgb) / 255f - val b = android.graphics.Color.blue(run.colorArgb) / 255f - cs.setStrokingColor(r, g, b) - cs.setLineWidth(run.fontSize / 15f) - cs.moveTo(decorationX, y - (run.fontSize * 0.15f)) - cs.lineTo(decorationX + runWidth, y - (run.fontSize * 0.15f)) - cs.stroke() - } - - if (run.isStrikethrough) { - val r = android.graphics.Color.red(run.colorArgb) / 255f - val g = android.graphics.Color.green(run.colorArgb) / 255f - val b = android.graphics.Color.blue(run.colorArgb) / 255f - cs.setStrokingColor(r, g, b) - cs.setLineWidth(run.fontSize / 15f) - cs.moveTo(decorationX, y + (run.fontSize * 0.25f)) - cs.lineTo(decorationX + runWidth, y + (run.fontSize * 0.25f)) - cs.stroke() - } - - decorationX += runWidth - } - } - - private fun getStyleAt(text: AnnotatedString, index: Int): SpanStyle { - val styles = text.spanStyles.filter { index >= it.start && index < it.end } - var style = SpanStyle() - styles.forEach { style = style.merge(it.item) } - return style - } -} \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfModels.kt b/app/src/main/java/com/aryan/reader/pdf/PdfModels.kt index cace9ec..5dd45de 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfModels.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfModels.kt @@ -33,4 +33,4 @@ internal enum class DisplayMode { internal fun saveTtsMode(context: Context, mode: TtsPlaybackManager.TtsMode) { val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) prefs.edit { putString(TTS_MODE_KEY, mode.name) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt b/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt index 0d52c0f..7acfc95 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt @@ -549,6 +549,7 @@ internal fun PdfPageComposable( onNoteRequested: (String?) -> Unit = {}, onTts: (Int, Int) -> Unit = { _, _ -> }, activeToolThickness: Float = 0f, + eraserToolThickness: Float = 0f, customHighlightColors: Map = emptyMap(), onPaletteClick: (() -> Unit)? = null, lockedState: Triple? = null, @@ -4270,6 +4271,7 @@ internal fun PdfPageComposable( eraserPosition = eraserPosition, isStylusEraserOverride = isStylusEraserOverride, activeToolThickness = activeToolThickness, + eraserToolThickness = eraserToolThickness, richTextController = richTextController, textBoxes = textBoxes, selectedTextBoxId = selectedTextBoxId, @@ -5106,6 +5108,7 @@ private fun PdfPageRenderer( onHighlightDelete: (String) -> Unit, onTts: (Int, Int) -> Unit, activeToolThickness: Float, + eraserToolThickness: Float, onNote: (String?) -> Unit, isBubbleZoomModeActive: Boolean = false, isActivePage: Boolean = true, @@ -5173,7 +5176,7 @@ private fun PdfPageRenderer( val isEditable = isEditMode && selectedTool == InkType.TEXT val hasContent = richTextController.pageLayouts.any { it.pageIndex == selectionData.pageIndex - } + } || richTextController.hasRenderableText if (isEditable || hasContent) { PdfRichTextLayer( @@ -5323,8 +5326,13 @@ private fun PdfPageRenderer( if (isEditMode && (selectedTool == InkType.ERASER || isStylusEraserOverride) && eraserPosition != null) { Canvas(modifier = Modifier.fillMaxSize()) { - val radiusPx = if (activeToolThickness > 0f && staticData.targetWidth > 0) { - activeToolThickness * staticData.targetWidth * scale // Calculate dynamic size based on tool settings scale + val eraserStrokeWidth = resolveEraserStrokeWidth( + isStylusEraserOverride, + activeToolThickness, + eraserToolThickness + ) + val radiusPx = if (eraserStrokeWidth > 0f && staticData.targetWidth > 0) { + eraserStrokeWidth * staticData.targetWidth * scale } else { 8.dp.toPx() } @@ -5798,7 +5806,7 @@ fun PdfRichTextLayer( val textToRender = if (controller.activePageIndex == pageIndex) { controller.localTextFieldValue.annotatedString } else { - pageLayout?.visibleText + pageLayout?.visibleText?.withoutTrailingPdfPageBreakForRender() } if (textToRender != null) { @@ -5871,6 +5879,14 @@ fun PdfRichTextLayer( } } +private fun AnnotatedString.withoutTrailingPdfPageBreakForRender(): AnnotatedString { + return if (text.lastOrNull() == PAGE_BREAK_CHAR) { + subSequence(0, length - 1) + } else { + this + } +} + private fun getNativePointer(obj: Any): Long { val priorityFields = listOf("pagePtr", "mNativePage", "page") diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfPreferences.kt b/app/src/main/java/com/aryan/reader/pdf/PdfPreferences.kt index 0209c26..1af330f 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfPreferences.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfPreferences.kt @@ -39,10 +39,10 @@ private const val PREF_EXTERNAL_TRANSLATE_PKG = "external_translate_package" private const val PREF_EXTERNAL_SEARCH_PKG = "external_search_package" private const val PDF_THEME_KEY = "pdf_reader_theme" private const val PDF_KEEP_SCREEN_ON_KEY = "pdf_keep_screen_on_enabled" -private const val PDF_HIDDEN_TOOLS_KEY = "pdf_hidden_tools" -private const val PDF_TOOL_ORDER_KEY = "pdf_tool_order" -private const val PDF_BOTTOM_TOOLS_KEY = "pdf_bottom_tools" -private const val PDF_SYSTEM_UI_MODE_KEY = "pdf_system_ui_mode" +internal const val PDF_HIDDEN_TOOLS_KEY = "pdf_hidden_tools" +internal const val PDF_TOOL_ORDER_KEY = "pdf_tool_order" +internal const val PDF_BOTTOM_TOOLS_KEY = "pdf_bottom_tools" +internal const val PDF_SYSTEM_UI_MODE_KEY = "pdf_system_ui_mode" internal const val PDF_LAYOUT_DEBUG_TAG = "PdfLayoutDebug" enum class PdfReaderTool(val title: String, val category: String) { @@ -64,6 +64,7 @@ enum class PdfReaderTool(val title: String, val category: String) { KEEP_SCREEN_ON("Keep Screen On", "Overflow Menu"), AUTO_SCROLL("Auto Scroll", "Overflow Menu"), TTS_SETTINGS("TTS Voice Settings", "Overflow Menu"), + TTS_REPLACEMENTS("TTS Word Replacements", "Overflow Menu"), BOOKMARK("Bookmark", "Overflow Menu"), PAGE_MANAGEMENT("Page Management", "Overflow Menu"), REFLOW("Text View (Reflow)", "Overflow Menu"), diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfToolbars.kt b/app/src/main/java/com/aryan/reader/pdf/PdfToolbars.kt index 4b593ab..7b91896 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfToolbars.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfToolbars.kt @@ -117,6 +117,7 @@ internal fun PdfTopBar( onToggleKeepScreenOn: () -> Unit, onStartAutoScroll: () -> Unit, onShowTtsSettings: () -> Unit, + onShowTtsReplacements: () -> Unit, onToggleBookmark: () -> Unit, onInsertPage: () -> Unit, onDeletePage: () -> Unit, @@ -425,6 +426,15 @@ internal fun PdfTopBar( onClick = { showMoreMenu = false; onShowTtsSettings() }, leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) } ) + HorizontalDivider() + } + + if (!hiddenTools.contains(PdfReaderTool.TTS_REPLACEMENTS.name)) { + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_tts_word_replacements)) }, + onClick = { showMoreMenu = false; onShowTtsReplacements() }, + leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) } + ) } if (!hiddenTools.contains(PdfReaderTool.BOOKMARK.name)) { diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt b/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt index 4cc6a03..65cc6dd 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt @@ -251,6 +251,7 @@ internal fun PdfVerticalReader( onNoteRequested: (String?) -> Unit = {}, onTts: (Int, Int) -> Unit = { _, _ -> }, activeToolThickness: Float = 0f, + eraserToolThickness: Float = 0f, customHighlightColors: Map = emptyMap(), onPaletteClick: () -> Unit = {}, lockedState: Triple? = null, @@ -1744,6 +1745,7 @@ internal fun PdfVerticalReader( onNoteRequested = onNoteRequested, onTts = onTts, activeToolThickness = activeToolThickness, + eraserToolThickness = eraserToolThickness, customHighlightColors = customHighlightColors, onPaletteClick = onPaletteClick, onTextBoxDragStart = { box, localTopLeft, touchOffset -> @@ -2151,8 +2153,13 @@ internal fun PdfVerticalReader( if (isEditMode && (selectedTool == InkType.ERASER || isStylusEraserOverride) && globalEraserPosition != null) { Canvas(modifier = Modifier.fillMaxSize()) { val pos = globalEraserPosition!! - val radiusPx = if (activeToolThickness > 0f) { - activeToolThickness * screenWidth * zoomAnimatable.value + val eraserStrokeWidth = resolveEraserStrokeWidth( + isStylusEraserOverride, + activeToolThickness, + eraserToolThickness + ) + val radiusPx = if (eraserStrokeWidth > 0f) { + eraserStrokeWidth * screenWidth * zoomAnimatable.value } else { 8.dp.toPx() } diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt index 5427254..3d4ef79 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt @@ -210,6 +210,7 @@ import com.aryan.reader.SearchResult import com.aryan.reader.SummarizationResult import com.aryan.reader.SummaryCacheManager import com.aryan.reader.TtsSettingsSheet +import com.aryan.reader.TtsWordReplacementsSheet import com.aryan.reader.ml.SpeechBubble import com.aryan.reader.epubreader.AutoScrollControls import com.aryan.reader.epubreader.DictionarySettingsDialog @@ -224,6 +225,7 @@ import com.aryan.reader.callByokGeminiInlineAi import com.aryan.reader.isByokCloudTtsAvailable import com.aryan.reader.loadCustomThemes import com.aryan.reader.loadGlobalTextureTransparency +import com.aryan.reader.loadTtsReplacementPreferences import com.aryan.reader.paginatedreader.TtsChunk import com.aryan.reader.pdf.data.AnnotationSettingsRepository import com.aryan.reader.pdf.data.PdfAnnotation @@ -238,11 +240,14 @@ import com.aryan.reader.pdf.data.VirtualPage import com.aryan.reader.rememberSearchState import com.aryan.reader.saveCustomThemes import com.aryan.reader.saveGlobalTextureTransparency +import com.aryan.reader.saveTtsReplacementPreferences +import com.aryan.reader.shared.ReaderTtsReplacementPreferences import com.aryan.reader.summarizationUrl import com.aryan.reader.tts.SpeakerSamplePlayer import com.aryan.reader.tts.TtsPlaybackManager import com.aryan.reader.tts.rememberTtsController import com.aryan.reader.tts.splitTextIntoChunks +import com.aryan.reader.withTtsReplacements import io.legere.pdfiumandroid.suspend.PdfDocumentKt import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope @@ -277,6 +282,12 @@ import androidx.compose.ui.input.pointer.isTertiaryPressed import androidx.compose.ui.input.pointer.isBackPressed import androidx.compose.ui.input.pointer.isForwardPressed +internal fun resolveEraserStrokeWidth( + isEraserOverride: Boolean, + activeToolThickness: Float, + eraserToolThickness: Float +): Float = if (isEraserOverride) eraserToolThickness else activeToolThickness + @Suppress("KotlinConstantConditions") @SuppressLint("UnusedBoxWithConstraintsScope", "ObsoleteSdkInt", "LocalContextGetResourceValueCall") @ExperimentalMaterial3Api @@ -441,6 +452,12 @@ fun PdfViewerScreen( ) } var showTtsSettingsSheet by remember { mutableStateOf(false) } + var showTtsReplacementsSheet by remember { mutableStateOf(false) } + var ttsReplacementPreferences by remember { mutableStateOf(loadTtsReplacementPreferences(context)) } + val updateTtsReplacementPreferences: (ReaderTtsReplacementPreferences) -> Unit = { next -> + ttsReplacementPreferences = next + saveTtsReplacementPreferences(context, next) + } DisposableEffect(isKeepScreenOn) { view.keepScreenOn = isKeepScreenOn @@ -805,6 +822,7 @@ fun PdfViewerScreen( val activeToolColor = toolSettings.getToolColor(selectedTool) val activeToolThickness = toolSettings.getToolThickness(selectedTool) + val eraserToolThickness = toolSettings.getToolThickness(InkType.ERASER) val fountainPenColor = toolSettings.getToolColor(InkType.FOUNTAIN_PEN) val markerColor = toolSettings.getToolColor(InkType.PEN) @@ -824,6 +842,7 @@ fun PdfViewerScreen( val currentStrokeColor by remember(activeToolColor) { derivedStateOf { activeToolColor } } val currentStrokeWidth by remember(activeToolThickness) { derivedStateOf { activeToolThickness } } + val currentEraserStrokeWidth by remember(eraserToolThickness) { derivedStateOf { eraserToolThickness } } val pdfTextRepository = remember(context) { PdfTextRepository(context) } val annotationRepository = remember(context) { PdfAnnotationRepository(context) } @@ -1342,6 +1361,30 @@ fun PdfViewerScreen( Timber.d("Derived currentPage recomposed. New value: $currentPage (Mode: $displayMode)") + suspend fun rebuildMissingHighlightBounds( + document: ReaderDocument, + highlights: List + ): List = withContext(Dispatchers.IO) { + highlights.map { highlight -> + if (highlight.bounds.isNotEmpty()) return@map highlight + val start = highlight.range.first + val end = highlight.range.second + if (highlight.pageIndex < 0 || end <= start) return@map highlight + + runCatching { + document.openPage(highlight.pageIndex)?.use { page -> + page.openTextPage().use { textPage -> + val rects = textPage.textPageGetRectsForRanges(intArrayOf(start, end - start)) + ?.map { it.rect } + .orEmpty() + val merged = mergePdfRectsIntoLines(rects) + if (merged.isEmpty()) highlight else highlight.copy(bounds = merged) + } + } ?: highlight + }.getOrDefault(highlight) + } + } + val onHighlightAdd = remember(pdfDocument, currentBookId) { { pageIndex: Int, range: Pair, text: String, color: PdfHighlightColor -> Timber.tag("PdfExportDebug").i("onHighlightAdd: Adding persistent highlight. Page: $pageIndex, Text: ${text.take(20)}...") @@ -2042,6 +2085,25 @@ fun PdfViewerScreen( } } + var isRebuildingSyncedHighlightBounds by remember(currentBookId) { mutableStateOf(false) } + LaunchedEffect(pdfDocument, currentBookId, userHighlights.toList()) { + val document = pdfDocument ?: return@LaunchedEffect + if (currentBookId == null || isRebuildingSyncedHighlightBounds) return@LaunchedEffect + val snapshot = userHighlights.toList() + if (snapshot.none { it.bounds.isEmpty() && it.range.second > it.range.first }) return@LaunchedEffect + + isRebuildingSyncedHighlightBounds = true + try { + val rebuilt = rebuildMissingHighlightBounds(document, snapshot) + if (rebuilt != snapshot) { + userHighlights.clear() + userHighlights.addAll(rebuilt) + } + } finally { + isRebuildingSyncedHighlightBounds = false + } + } + var pendingSaveMode by remember { mutableStateOf(null) } val saveLauncher = rememberLauncherForActivityResult( @@ -2076,7 +2138,7 @@ fun PdfViewerScreen( viewModel.saveOriginalPdf(effectivePdfUri, uri) } - else -> {} + null -> Unit } } pendingSaveMode = null @@ -2618,7 +2680,7 @@ fun PdfViewerScreen( val ttsChunks = chunks.mapIndexed { index, text -> TtsChunk(text, "", index) } ttsController.start( - chunks = ttsChunks, + chunks = ttsChunks.withTtsReplacements(ttsReplacementPreferences, bookId), bookTitle = bookTitle, chapterTitle = pageTitle, coverImageUri = null, @@ -3434,6 +3496,7 @@ fun PdfViewerScreen( } showTtsSettingsSheet -> showTtsSettingsSheet = false + showTtsReplacementsSheet -> showTtsReplacementsSheet = false showThemePanel -> showThemePanel = false else -> { @@ -3712,6 +3775,9 @@ fun PdfViewerScreen( val currentStrokeWidthState by rememberUpdatedState( currentStrokeWidth ) + val currentEraserStrokeWidthState by rememberUpdatedState( + currentEraserStrokeWidth + ) @Suppress("ControlFlowWithEmptyBody") val onDrawPagination = remember(pageIndex) { @@ -3719,10 +3785,15 @@ fun PdfViewerScreen( val effectiveTool = if (isEraserOverride) InkType.ERASER else currentSelectedTool if (effectiveTool == InkType.TEXT) { } else if (effectiveTool == InkType.ERASER) { + val eraserStrokeWidth = resolveEraserStrokeWidth( + isEraserOverride, + currentStrokeWidthState, + currentEraserStrokeWidthState + ) val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f } val existing = allAnnotations[pageIndex] ?: emptyList() val toRemove = existing.filter { - isAnnotationHit(it, point, lastEraserPoint, aspectRatio, currentStrokeWidthState) + isAnnotationHit(it, point, lastEraserPoint, aspectRatio, eraserStrokeWidth) } lastEraserPoint = point if (toRemove.isNotEmpty()) { @@ -3762,10 +3833,15 @@ fun PdfViewerScreen( } else if (effectiveTool == InkType.ERASER) { lastEraserPoint = point erasedAnnotationsFromStroke.clear() + val eraserStrokeWidth = resolveEraserStrokeWidth( + isEraserOverride, + currentStrokeWidthState, + currentEraserStrokeWidthState + ) val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f } val existing = allAnnotations[pageIndex] ?: emptyList() val toRemove = existing.filter { - isAnnotationHit(it, point, lastEraserPoint, aspectRatio, currentStrokeWidthState) + isAnnotationHit(it, point, lastEraserPoint, aspectRatio, eraserStrokeWidth) } if (toRemove.isNotEmpty()) { val batch = @@ -3892,6 +3968,7 @@ fun PdfViewerScreen( onNoteRequested = onNoteRequested, onTts = { pageIdx, charIdx -> startTtsWithPermissionCheck(pageIdx, charIdx) }, activeToolThickness = currentStrokeWidthState, + eraserToolThickness = currentEraserStrokeWidthState, lockedState = lockedState, onZoomAndPanChanged = { newScale, newOffset -> if (pagerState.currentPage == pageIndex) { @@ -4152,6 +4229,9 @@ fun PdfViewerScreen( val currentStrokeWidthState by rememberUpdatedState( currentStrokeWidth ) + val currentEraserStrokeWidthState by rememberUpdatedState( + currentEraserStrokeWidth + ) @Suppress("ControlFlowWithEmptyBody") val onDrawStartStable = remember { @@ -4164,11 +4244,16 @@ fun PdfViewerScreen( } else if (effectiveTool == InkType.ERASER) { lastEraserPoint = point erasedAnnotationsFromStroke.clear() + val eraserStrokeWidth = resolveEraserStrokeWidth( + isEraserOverride, + currentStrokeWidthState, + currentEraserStrokeWidthState + ) val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f } val existing = allAnnotations[pageIndex] ?: emptyList() val toRemove = existing.filter { - isAnnotationHit(it, point, lastEraserPoint, aspectRatio, currentStrokeWidthState) + isAnnotationHit(it, point, lastEraserPoint, aspectRatio, eraserStrokeWidth) } if (toRemove.isNotEmpty()) { val batch = @@ -4204,10 +4289,15 @@ fun PdfViewerScreen( { pageIndex: Int, point: PdfPoint, isEraserOverride: Boolean -> val effectiveTool = if (isEraserOverride) InkType.ERASER else currentSelectedTool if (effectiveTool == InkType.ERASER) { + val eraserStrokeWidth = resolveEraserStrokeWidth( + isEraserOverride, + currentStrokeWidthState, + currentEraserStrokeWidthState + ) val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f } val existing = allAnnotations[pageIndex] ?: emptyList() val toRemove = existing.filter { - isAnnotationHit(it, point, lastEraserPoint, aspectRatio, currentStrokeWidthState) + isAnnotationHit(it, point, lastEraserPoint, aspectRatio, eraserStrokeWidth) } lastEraserPoint = point if (toRemove.isNotEmpty()) { @@ -4281,6 +4371,7 @@ fun PdfViewerScreen( onNoteRequested = onNoteRequested, onTts = { pageIdx, charIdx -> startTtsWithPermissionCheck(pageIdx, charIdx) }, activeToolThickness = currentStrokeWidthState, + eraserToolThickness = currentEraserStrokeWidthState, onLinkClicked = onLinkClickedStable, onInternalLinkClicked = onInternalLinkNavStable, bookmarks = bookmarksHolder, @@ -5019,6 +5110,7 @@ fun PdfViewerScreen( showBars = !isMusicianMode }, onShowTtsSettings = { showTtsSettingsSheet = true }, + onShowTtsReplacements = { showTtsReplacementsSheet = true }, onToggleBookmark = onBookmarkClick, onInsertPage = onInsertPage, onDeletePage = onDeletePage, @@ -6511,6 +6603,15 @@ fun PdfViewerScreen( ) } + TtsWordReplacementsSheet( + isVisible = showTtsReplacementsSheet, + bookId = bookId, + bookTitle = documentMetadataTitle ?: originalFileName, + preferences = ttsReplacementPreferences, + onPreferencesChange = updateTtsReplacementPreferences, + onDismiss = { showTtsReplacementsSheet = false }, + ) + if (showDictionarySettingsSheet) { DictionarySettingsDialog( isVisible = true, @@ -6684,15 +6785,18 @@ fun PdfViewerScreen( title = { Text(stringResource(R.string.title_save_to_device)) }, text = { Text(stringResource(R.string.desc_choose_format_save)) }, confirmButton = { - TextButton( - onClick = { - showSaveDialog = false - pendingSaveMode = SaveMode.ANNOTATED - val suggestedName = getSuggestedFilename( - originalFileName, isAnnotated = true - ) - saveLauncher.launch(suggestedName) - }) { Text(stringResource(R.string.action_with_annotations)) } + Column(horizontalAlignment = Alignment.End) { + TextButton( + onClick = { + showSaveDialog = false + pendingSaveMode = SaveMode.ANNOTATED + val suggestedName = getSuggestedFilename( + originalFileName, isAnnotated = true + ) + saveLauncher.launch(suggestedName) + }) { Text(stringResource(R.string.action_with_annotations)) } + + } }, dismissButton = { Row { @@ -6723,31 +6827,34 @@ fun PdfViewerScreen( title = { Text(stringResource(R.string.share_chooser_title)) }, text = { Text(stringResource(R.string.desc_choose_format_share)) }, confirmButton = { - TextButton( - onClick = { - showShareDialog = false - isShareLoading = true - Timber.tag("PdfExportDebug").i("SHARE TRIGGERED: userHighlights count: ${userHighlights.size}") - val filename = getSuggestedFilename( - originalFileName, isAnnotated = true - ) - coroutineScope.launch { - val currentRichTextLayouts = richTextController?.pageLayouts - - viewModel.sharePdf( - activityContext = context, - sourceUri = effectivePdfUri, - annotations = allAnnotations, - richTextPageLayouts = currentRichTextLayouts, - textBoxes = textBoxes.toList(), - highlights = userHighlights.toList(), - includeAnnotations = true, - filename = filename, - bookId = currentBookId + Column(horizontalAlignment = Alignment.End) { + TextButton( + onClick = { + showShareDialog = false + isShareLoading = true + Timber.tag("PdfExportDebug").i("SHARE TRIGGERED: userHighlights count: ${userHighlights.size}") + val filename = getSuggestedFilename( + originalFileName, isAnnotated = true ) - isShareLoading = false - } - }) { Text(stringResource(R.string.action_with_annotations)) } + coroutineScope.launch { + val currentRichTextLayouts = richTextController?.pageLayouts + + viewModel.sharePdf( + activityContext = context, + sourceUri = effectivePdfUri, + annotations = allAnnotations, + richTextPageLayouts = currentRichTextLayouts, + textBoxes = textBoxes.toList(), + highlights = userHighlights.toList(), + includeAnnotations = true, + filename = filename, + bookId = currentBookId + ) + isShareLoading = false + } + }) { Text(stringResource(R.string.action_with_annotations)) } + + } }, dismissButton = { Row { diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfiumAnnotationExporter.kt b/app/src/main/java/com/aryan/reader/pdf/PdfiumAnnotationExporter.kt new file mode 100644 index 0000000..16fbc79 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/pdf/PdfiumAnnotationExporter.kt @@ -0,0 +1,768 @@ +package com.aryan.reader.pdf + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Typeface +import android.graphics.pdf.PdfRenderer +import android.net.Uri +import android.os.ParcelFileDescriptor +import android.text.Layout +import android.text.SpannableString +import android.text.Spanned +import android.text.StaticLayout +import android.text.TextPaint +import android.text.style.AbsoluteSizeSpan +import android.text.style.BackgroundColorSpan +import android.text.style.ForegroundColorSpan +import android.text.style.MetricAffectingSpan +import android.text.style.StrikethroughSpan +import android.text.style.StyleSpan +import android.text.style.UnderlineSpan +import android.util.TypedValue +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.isSpecified +import com.aryan.reader.pdf.data.PdfAnnotation +import com.aryan.reader.pdf.data.PdfTextBox +import com.aryan.reader.pdf.data.VirtualPage +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.IOException +import java.io.OutputStream +import java.util.Locale +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import timber.log.Timber +import kotlin.math.ceil +import kotlin.math.roundToInt + +internal object PdfiumAnnotationExporter { + internal const val TEXT_FLAG_BOLD = 1 + internal const val TEXT_FLAG_ITALIC = 1 shl 1 + internal const val TEXT_FLAG_UNDERLINE = 1 shl 2 + internal const val TEXT_FLAG_STRIKE_THROUGH = 1 shl 3 + internal const val TEXT_FLAG_ABSOLUTE_LINE = 1 shl 4 + + private const val TEXT_BOX_PADDING_DP = 8f + private const val TEXT_RASTER_PDF_POINT_SCALE = 3f + private const val TEXT_RASTER_MIN_PAGE_HEIGHT_PX = 1200f + private const val TEXT_RASTER_MAX_PAGE_HEIGHT_PX = 3600f + private const val RICH_TEXT_MARGIN_X = 0.1f + private const val RICH_TEXT_MARGIN_Y = 0.08f + + suspend fun exportAnnotatedPdf( + context: Context, + sourceUri: Uri, + destStream: OutputStream, + virtualPages: List?, + inkAnnotations: Map>, + richTextPageLayouts: List? = null, + textBoxes: List? = null, + highlights: List? = null + ) { + withContext(Dispatchers.IO) { + if (!supportsOriginalPageOrder(virtualPages)) { + destStream.close() + throw UnsupportedOperationException( + "PDFium annotation export currently supports only the original PDF page order." + ) + } + + val exportDir = File(context.cacheDir, "pdfium_annotation_export") + if (!exportDir.exists() && !exportDir.mkdirs()) { + destStream.close() + throw IOException("Unable to create PDFium export cache directory.") + } + val sourceFile: File + val destFile: File + try { + sourceFile = File.createTempFile("source_", ".pdf", exportDir) + destFile = File.createTempFile("annotated_", ".pdf", exportDir) + } catch (e: IOException) { + destStream.close() + throw e + } + + try { + context.contentResolver.openInputStream(sourceUri)?.use { input -> + FileOutputStream(sourceFile).use { output -> input.copyTo(output) } + } ?: throw IOException("Unable to open source PDF for PDFium export.") + + val pageSizes = runCatching { readPdfPageSizes(sourceFile) } + .onFailure { Timber.tag("PdfExportDebug").w(it, "Unable to read page sizes for text raster export.") } + .getOrDefault(emptyList()) + val rasterOverlays = buildTextRasterOverlays( + context = context, + textBoxes = textBoxes.orEmpty(), + richTextPageLayouts = richTextPageLayouts.orEmpty(), + pageSizes = pageSizes + ) + val payload = buildPayload( + inkAnnotations = inkAnnotations, + textBoxes = emptyList(), + highlights = highlights.orEmpty(), + richTextPageLayouts = emptyList(), + rasterOverlays = rasterOverlays + ) + + if (!payload.hasAnnotations()) { + FileInputStream(sourceFile).use { input -> input.copyTo(destStream) } + return@withContext + } + + val exported = NativePdfiumBridge.exportAnnotatedPdf( + sourcePath = sourceFile.absolutePath, + destPath = destFile.absolutePath, + inkPageIndices = payload.inkPageIndices, + inkTypes = payload.inkTypes, + inkColors = payload.inkColors, + inkStrokeWidths = payload.inkStrokeWidths, + inkPointOffsets = payload.inkPointOffsets, + inkPointCounts = payload.inkPointCounts, + inkPoints = payload.inkPoints, + textPageIndices = payload.textPageIndices, + textBounds = payload.textBounds, + textColors = payload.textColors, + textBackgroundColors = payload.textBackgroundColors, + textFontSizes = payload.textFontSizes, + textFlags = payload.textFlags, + textValues = payload.textValues, + textFontPaths = payload.textFontPaths, + textFontNames = payload.textFontNames, + rasterPageIndices = payload.rasterPageIndices, + rasterBounds = payload.rasterBounds, + rasterWidths = payload.rasterWidths, + rasterHeights = payload.rasterHeights, + rasterPixelOffsets = payload.rasterPixelOffsets, + rasterPixels = payload.rasterPixels, + highlightPageIndices = payload.highlightPageIndices, + highlightColors = payload.highlightColors, + highlightRectOffsets = payload.highlightRectOffsets, + highlightRectCounts = payload.highlightRectCounts, + highlightRects = payload.highlightRects, + highlightContents = payload.highlightContents + ) + + if (!exported) { + throw IOException("PDFium failed to write annotated PDF.") + } + + FileInputStream(destFile).use { input -> input.copyTo(destStream) } + Timber.tag("PdfExportDebug").i( + "PDFium export saved ${payload.inkPageIndices.size} ink, " + + "${payload.highlightPageIndices.size} highlight, " + + "${payload.rasterPageIndices.size} raster text overlays." + ) + } finally { + destStream.close() + sourceFile.delete() + destFile.delete() + } + } + } + + internal fun supportsOriginalPageOrder(virtualPages: List?): Boolean { + return virtualPages == null || virtualPages.withIndex().all { (index, page) -> + page is VirtualPage.PdfPage && page.pdfIndex == index + } + } + + @Suppress("UNUSED_PARAMETER") + internal fun buildPayload( + inkAnnotations: Map>, + textBoxes: List, + highlights: List, + richTextPageLayouts: List = emptyList(), + fontPathResolver: (String?) -> String? = { it }, + rasterOverlays: List = emptyList() + ): PdfiumAnnotationExportPayload { + val inkItems = inkAnnotations.entries + .flatMap { (pageIndex, annotations) -> annotations.map { pageIndex to it } } + .filter { (_, annotation) -> + annotation.points.size >= 2 && + annotation.inkType != InkType.ERASER && + annotation.inkType != InkType.TEXT + } + + val inkPageIndices = IntArray(inkItems.size) + val inkTypes = IntArray(inkItems.size) + val inkColors = IntArray(inkItems.size) + val inkStrokeWidths = FloatArray(inkItems.size) + val inkPointOffsets = IntArray(inkItems.size) + val inkPointCounts = IntArray(inkItems.size) + val inkPoints = FloatArray(inkItems.sumOf { it.second.points.size } * 2) + + var inkPointCursor = 0 + inkItems.forEachIndexed { index, (pageIndex, annotation) -> + inkPageIndices[index] = pageIndex + inkTypes[index] = annotation.inkType.ordinal + inkColors[index] = annotation.color.toArgb() + inkStrokeWidths[index] = annotation.strokeWidth + inkPointOffsets[index] = inkPointCursor / 2 + inkPointCounts[index] = annotation.points.size + annotation.points.forEach { point -> + inkPoints[inkPointCursor++] = point.x + inkPoints[inkPointCursor++] = point.y + } + } + + val textPageIndices = IntArray(0) + val textBounds = FloatArray(0) + val textColors = IntArray(0) + val textBackgroundColors = IntArray(0) + val textFontSizes = FloatArray(0) + val textFlags = IntArray(0) + val textValues = emptyArray() + val textFontPaths = emptyArray() + val textFontNames = emptyArray() + + val rasterPageIndices = IntArray(rasterOverlays.size) + val rasterBounds = FloatArray(rasterOverlays.size * 4) + val rasterWidths = IntArray(rasterOverlays.size) + val rasterHeights = IntArray(rasterOverlays.size) + val rasterPixelOffsets = IntArray(rasterOverlays.size) + val rasterPixels = IntArray(rasterOverlays.sumOf { it.pixels.size }) + + var rasterPixelCursor = 0 + rasterOverlays.forEachIndexed { index, overlay -> + rasterPageIndices[index] = overlay.pageIndex + rasterBounds[index * 4] = overlay.left + rasterBounds[index * 4 + 1] = overlay.top + rasterBounds[index * 4 + 2] = overlay.right + rasterBounds[index * 4 + 3] = overlay.bottom + rasterWidths[index] = overlay.width + rasterHeights[index] = overlay.height + rasterPixelOffsets[index] = rasterPixelCursor + overlay.pixels.copyInto(rasterPixels, rasterPixelCursor) + rasterPixelCursor += overlay.pixels.size + } + + val boundedHighlights = highlights.filter { it.bounds.isNotEmpty() } + val highlightPageIndices = IntArray(boundedHighlights.size) + val highlightColors = IntArray(boundedHighlights.size) + val highlightRectOffsets = IntArray(boundedHighlights.size) + val highlightRectCounts = IntArray(boundedHighlights.size) + val highlightRects = FloatArray(boundedHighlights.sumOf { it.bounds.size } * 4) + val highlightContents = Array(boundedHighlights.size) { "" } + + var highlightRectCursor = 0 + boundedHighlights.forEachIndexed { index, highlight -> + highlightPageIndices[index] = highlight.pageIndex + highlightColors[index] = highlight.color.color.toArgb() + highlightRectOffsets[index] = highlightRectCursor / 4 + highlightRectCounts[index] = highlight.bounds.size + highlightContents[index] = highlight.note?.takeIf { it.isNotBlank() } ?: highlight.text + highlight.bounds.forEach { rect -> + highlightRects[highlightRectCursor++] = rect.left + highlightRects[highlightRectCursor++] = rect.top + highlightRects[highlightRectCursor++] = rect.right + highlightRects[highlightRectCursor++] = rect.bottom + } + } + + return PdfiumAnnotationExportPayload( + inkPageIndices = inkPageIndices, + inkTypes = inkTypes, + inkColors = inkColors, + inkStrokeWidths = inkStrokeWidths, + inkPointOffsets = inkPointOffsets, + inkPointCounts = inkPointCounts, + inkPoints = inkPoints, + textPageIndices = textPageIndices, + textBounds = textBounds, + textColors = textColors, + textBackgroundColors = textBackgroundColors, + textFontSizes = textFontSizes, + textFlags = textFlags, + textValues = textValues, + textFontPaths = textFontPaths, + textFontNames = textFontNames, + rasterPageIndices = rasterPageIndices, + rasterBounds = rasterBounds, + rasterWidths = rasterWidths, + rasterHeights = rasterHeights, + rasterPixelOffsets = rasterPixelOffsets, + rasterPixels = rasterPixels, + highlightPageIndices = highlightPageIndices, + highlightColors = highlightColors, + highlightRectOffsets = highlightRectOffsets, + highlightRectCounts = highlightRectCounts, + highlightRects = highlightRects, + highlightContents = highlightContents + ) + } + + private fun buildTextRasterOverlays( + context: Context, + textBoxes: List, + richTextPageLayouts: List, + pageSizes: List + ): List { + val overlays = mutableListOf() + textBoxes.mapNotNullTo(overlays) { box -> + renderTextBoxOverlay(context, box, pageSizeFor(pageSizes, box.pageIndex)) + } + richTextPageLayouts.mapNotNullTo(overlays) { layout -> + renderRichTextOverlay(context, layout, pageSizeFor(pageSizes, layout.pageIndex)) + } + return overlays + } + + private fun renderTextBoxOverlay( + context: Context, + box: PdfTextBox, + pageSize: PdfiumPageSize + ): PdfiumRasterOverlay? { + val text = box.text.sanitizeRasterText() + if (box.pageIndex < 0 || text.isBlank()) return null + + val bounds = box.relativeBounds + val left = bounds.left.coerceIn(0f, 1f) + val top = bounds.top.coerceIn(0f, 1f) + val right = bounds.right.coerceIn(left, 1f) + val bottom = bounds.bottom.coerceIn(top, 1f) + if (right - left <= 0f || bottom - top <= 0f) return null + + val pageHeightPx = pageSize.exportHeightPx() + val pageWidthPx = pageHeightPx * pageSize.aspect + val bitmapWidth = ceil((right - left) * pageWidthPx).toInt().coerceAtLeast(1) + val bitmapHeight = ceil((bottom - top) * pageHeightPx).toInt().coerceAtLeast(1) + val paddingPx = dpToPx(context, TEXT_BOX_PADDING_DP) + .coerceAtMost((minOf(bitmapWidth, bitmapHeight) / 2f).coerceAtLeast(0f)) + val contentWidth = (bitmapWidth - paddingPx * 2f).roundToInt().coerceAtLeast(1) + val fontSizePx = (box.fontSize * pageHeightPx).coerceAtLeast(1f) + val typeface = resolveTypeface(context, box.fontPath, box.fontName, box.isBold, box.isItalic) + val bitmap = Bitmap.createBitmap(bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888) + + return try { + val paint = textPaint( + colorArgb = box.color.toArgb(), + textSizePx = fontSizePx, + typeface = typeface + ) + val spannable = SpannableString(text) + applyTextBoxSpans( + text = spannable, + colorArgb = box.color.toArgb(), + backgroundArgb = box.backgroundColor.toArgb(), + fontSizePx = fontSizePx, + isBold = box.isBold, + isItalic = box.isItalic, + isUnderline = box.isUnderline, + isStrikeThrough = box.isStrikeThrough, + typeface = typeface + ) + drawStaticLayout( + bitmap = bitmap, + text = spannable, + paint = paint, + width = contentWidth, + translateX = paddingPx, + translateY = paddingPx + ) + bitmap.toRasterOverlay(box.pageIndex, left, top, right, bottom) + } finally { + bitmap.recycle() + } + } + + private fun renderRichTextOverlay( + context: Context, + layout: PageTextLayout, + pageSize: PdfiumPageSize + ): PdfiumRasterOverlay? { + val visibleText = layout.visibleText.withoutTrailingPdfiumPageBreak() + if (layout.pageIndex < 0 || visibleText.text.isBlank()) return null + + val pageHeightPx = layout.pageHeightPx.takeIf { it > 0f } ?: pageSize.exportHeightPx() + val pageWidthPx = pageHeightPx * pageSize.aspect + val left = RICH_TEXT_MARGIN_X + val top = RICH_TEXT_MARGIN_Y + val right = 1f - RICH_TEXT_MARGIN_X + val bottom = 1f - RICH_TEXT_MARGIN_Y + val bitmapWidth = ceil((right - left) * pageWidthPx).toInt().coerceAtLeast(1) + val bitmapHeight = ceil((bottom - top) * pageHeightPx).toInt().coerceAtLeast(1) + val bitmap = Bitmap.createBitmap(bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888) + + return try { + val paint = textPaint( + colorArgb = Color.Black.toArgb(), + textSizePx = spToPx(context, 16f), + typeface = Typeface.DEFAULT + ) + val spannable = visibleText.toAndroidSpannable(context) + drawStaticLayout( + bitmap = bitmap, + text = spannable, + paint = paint, + width = bitmapWidth, + translateX = 0f, + translateY = 0f + ) + bitmap.toRasterOverlay(layout.pageIndex, left, top, right, bottom) + } finally { + bitmap.recycle() + } + } + + private fun applyTextBoxSpans( + text: SpannableString, + colorArgb: Int, + backgroundArgb: Int, + fontSizePx: Float, + isBold: Boolean, + isItalic: Boolean, + isUnderline: Boolean, + isStrikeThrough: Boolean, + typeface: Typeface + ) { + if (text.isEmpty()) return + val end = text.length + text.setSpan(ForegroundColorSpan(colorArgb), 0, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + if ((backgroundArgb ushr 24) != 0) { + text.setSpan(BackgroundColorSpan(backgroundArgb), 0, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + text.setSpan(AbsoluteSizeSpan(fontSizePx.roundToInt().coerceAtLeast(1), false), 0, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + text.setSpan(TypefaceSpanCompat(typeface), 0, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + if (!hasStyle(typeface, isBold, isItalic)) { + text.setSpan(StyleSpan(typefaceStyle(isBold, isItalic)), 0, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + if (isUnderline) { + text.setSpan(UnderlineSpan(), 0, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + if (isStrikeThrough) { + text.setSpan(StrikethroughSpan(), 0, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + } + + private fun AnnotatedString.toAndroidSpannable(context: Context): SpannableString { + val spannable = SpannableString(text.sanitizeRasterTextPreservingLength()) + spanStyles.forEach { range -> + applySpanStyle(context, spannable, range.item, range.start, range.end) + } + return spannable + } + + private fun applySpanStyle( + context: Context, + spannable: SpannableString, + style: SpanStyle, + rawStart: Int, + rawEnd: Int + ) { + val start = rawStart.coerceIn(0, spannable.length) + val end = rawEnd.coerceIn(start, spannable.length) + if (start >= end) return + + val color = style.color + if (color != Color.Unspecified) { + spannable.setSpan(ForegroundColorSpan(color.toArgb()), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + val background = style.background + if (background != Color.Unspecified && background.alpha > 0f) { + spannable.setSpan(BackgroundColorSpan(background.toArgb()), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + if (style.fontSize.isSpecified) { + val textSizePx = spToPx(context, style.fontSize.value) + spannable.setSpan( + AbsoluteSizeSpan(textSizePx.roundToInt().coerceAtLeast(1), false), + start, + end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + + val isBold = isBold(style.fontWeight) + val isItalic = style.fontStyle == FontStyle.Italic + val fontPath = PdfFontCache.getPath(style.fontFamily) + val fontName = standardFontName(style.fontFamily) + val typeface = resolveTypeface(context, fontPath, fontName, isBold, isItalic) + if (fontPath != null || fontName != null) { + spannable.setSpan(TypefaceSpanCompat(typeface), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } else if (isBold || isItalic) { + spannable.setSpan(StyleSpan(typefaceStyle(isBold, isItalic)), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + + val decoration = style.textDecoration ?: TextDecoration.None + if (decoration.contains(TextDecoration.Underline)) { + spannable.setSpan(UnderlineSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + if (decoration.contains(TextDecoration.LineThrough)) { + spannable.setSpan(StrikethroughSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + } + + private fun drawStaticLayout( + bitmap: Bitmap, + text: CharSequence, + paint: TextPaint, + width: Int, + translateX: Float, + translateY: Float + ) { + val canvas = Canvas(bitmap) + canvas.save() + canvas.clipRect(0, 0, bitmap.width, bitmap.height) + canvas.translate(translateX, translateY) + StaticLayout.Builder.obtain(text, 0, text.length, paint, width) + .setAlignment(Layout.Alignment.ALIGN_NORMAL) + .setIncludePad(false) + .setLineSpacing(0f, 1f) + .build() + .draw(canvas) + canvas.restore() + } + + private fun textPaint( + colorArgb: Int, + textSizePx: Float, + typeface: Typeface + ): TextPaint = + TextPaint(Paint.ANTI_ALIAS_FLAG or Paint.SUBPIXEL_TEXT_FLAG).apply { + color = colorArgb + textSize = textSizePx + this.typeface = typeface + } + + private fun Bitmap.toRasterOverlay( + pageIndex: Int, + boundsLeft: Float, + boundsTop: Float, + boundsRight: Float, + boundsBottom: Float + ): PdfiumRasterOverlay? { + val allPixels = IntArray(width * height) + getPixels(allPixels, 0, width, 0, 0, width, height) + + var minX = width + var minY = height + var maxX = -1 + var maxY = -1 + for (y in 0 until height) { + val rowOffset = y * width + for (x in 0 until width) { + if ((allPixels[rowOffset + x] ushr 24) != 0) { + if (x < minX) minX = x + if (x > maxX) maxX = x + if (y < minY) minY = y + if (y > maxY) maxY = y + } + } + } + + if (maxX < minX || maxY < minY) return null + + val cropWidth = maxX - minX + 1 + val cropHeight = maxY - minY + 1 + val cropped = IntArray(cropWidth * cropHeight) + for (row in 0 until cropHeight) { + System.arraycopy( + allPixels, + (minY + row) * width + minX, + cropped, + row * cropWidth, + cropWidth + ) + } + + val boundsWidth = boundsRight - boundsLeft + val boundsHeight = boundsBottom - boundsTop + return PdfiumRasterOverlay( + pageIndex = pageIndex, + left = boundsLeft + boundsWidth * (minX.toFloat() / width), + top = boundsTop + boundsHeight * (minY.toFloat() / height), + right = boundsLeft + boundsWidth * ((maxX + 1).toFloat() / width), + bottom = boundsTop + boundsHeight * ((maxY + 1).toFloat() / height), + width = cropWidth, + height = cropHeight, + pixels = cropped + ) + } + + private fun readPdfPageSizes(sourceFile: File): List { + return ParcelFileDescriptor.open(sourceFile, ParcelFileDescriptor.MODE_READ_ONLY).use { descriptor -> + PdfRenderer(descriptor).use { renderer -> + List(renderer.pageCount) { index -> + val page = renderer.openPage(index) + try { + PdfiumPageSize(page.width, page.height) + } finally { + page.close() + } + } + } + } + } + + private fun pageSizeFor(pageSizes: List, pageIndex: Int): PdfiumPageSize = + pageSizes.getOrNull(pageIndex) ?: PdfiumPageSize.Default + + private fun PdfiumPageSize.exportHeightPx(): Float = + (height * TEXT_RASTER_PDF_POINT_SCALE) + .coerceIn(TEXT_RASTER_MIN_PAGE_HEIGHT_PX, TEXT_RASTER_MAX_PAGE_HEIGHT_PX) + + private fun resolveTypeface( + context: Context, + fontPath: String?, + fontName: String?, + isBold: Boolean, + isItalic: Boolean + ): Typeface { + val base = try { + when { + !fontPath.isNullOrBlank() && fontPath.startsWith("asset:") -> + Typeface.createFromAsset(context.assets, fontPath.removePrefix("asset:")) + !fontPath.isNullOrBlank() -> + Typeface.createFromFile(fontPath) + else -> when (fontName?.lowercase(Locale.US)) { + "serif" -> Typeface.SERIF + "monospace" -> Typeface.MONOSPACE + "cursive" -> Typeface.create("casual", Typeface.NORMAL) + "sans", "sansserif", "sans-serif" -> Typeface.SANS_SERIF + else -> Typeface.DEFAULT + } + } + } catch (e: Exception) { + Timber.tag("PdfFontDebug").w(e, "Falling back while rasterizing fontPath=$fontPath fontName=$fontName") + Typeface.DEFAULT + } + return Typeface.create(base, typefaceStyle(isBold, isItalic)) + } + + private fun typefaceStyle(isBold: Boolean, isItalic: Boolean): Int = + when { + isBold && isItalic -> Typeface.BOLD_ITALIC + isBold -> Typeface.BOLD + isItalic -> Typeface.ITALIC + else -> Typeface.NORMAL + } + + private fun hasStyle(typeface: Typeface, isBold: Boolean, isItalic: Boolean): Boolean { + val style = typeface.style + return (!isBold || style and Typeface.BOLD != 0) && + (!isItalic || style and Typeface.ITALIC != 0) + } + + private fun isBold(weight: FontWeight?): Boolean = + (weight?.weight ?: FontWeight.Normal.weight) >= FontWeight.SemiBold.weight + + private fun standardFontName(fontFamily: FontFamily?): String? = + when (fontFamily) { + FontFamily.Serif -> "Serif" + FontFamily.Monospace -> "Monospace" + FontFamily.SansSerif -> "Sans" + FontFamily.Cursive -> "Cursive" + else -> null + } + + private fun dpToPx(context: Context, value: Float): Float = + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, value, context.resources.displayMetrics) + + private fun spToPx(context: Context, value: Float): Float = + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, value, context.resources.displayMetrics) + + private fun AnnotatedString.withoutTrailingPdfiumPageBreak(): AnnotatedString = + if (text.lastOrNull() == PAGE_BREAK_CHAR) subSequence(0, length - 1) else this + + private fun String.sanitizeRasterText(): String = + replace(PAGE_BREAK_CHAR, '\n') + .replace("\u200B", "") + .replace('\r', ' ') + + private fun String.sanitizeRasterTextPreservingLength(): String = + replace(PAGE_BREAK_CHAR, '\n') + .replace('\r', ' ') +} + +internal data class PdfiumRasterOverlay( + val pageIndex: Int, + val left: Float, + val top: Float, + val right: Float, + val bottom: Float, + val width: Int, + val height: Int, + val pixels: IntArray +) + +private data class PdfiumPageSize( + val width: Int, + val height: Int +) { + val aspect: Float + get() = if (width > 0 && height > 0) width.toFloat() / height.toFloat() else Default.aspect + + companion object { + val Default = PdfiumPageSize(612, 792) + } +} + +private class TypefaceSpanCompat( + private val typeface: Typeface +) : MetricAffectingSpan() { + override fun updateDrawState(tp: TextPaint) { + apply(tp) + } + + override fun updateMeasureState(tp: TextPaint) { + apply(tp) + } + + private fun apply(paint: Paint) { + val oldStyle = paint.typeface?.style ?: Typeface.NORMAL + val missingStyles = oldStyle and typeface.style.inv() + if (missingStyles and Typeface.BOLD != 0) { + paint.isFakeBoldText = true + } + if (missingStyles and Typeface.ITALIC != 0) { + paint.textSkewX = -0.25f + } + paint.typeface = typeface + } +} + +internal data class PdfiumAnnotationExportPayload( + val inkPageIndices: IntArray, + val inkTypes: IntArray, + val inkColors: IntArray, + val inkStrokeWidths: FloatArray, + val inkPointOffsets: IntArray, + val inkPointCounts: IntArray, + val inkPoints: FloatArray, + val textPageIndices: IntArray, + val textBounds: FloatArray, + val textColors: IntArray, + val textBackgroundColors: IntArray, + val textFontSizes: FloatArray, + val textFlags: IntArray, + val textValues: Array, + val textFontPaths: Array, + val textFontNames: Array, + val rasterPageIndices: IntArray, + val rasterBounds: FloatArray, + val rasterWidths: IntArray, + val rasterHeights: IntArray, + val rasterPixelOffsets: IntArray, + val rasterPixels: IntArray, + val highlightPageIndices: IntArray, + val highlightColors: IntArray, + val highlightRectOffsets: IntArray, + val highlightRectCounts: IntArray, + val highlightRects: FloatArray, + val highlightContents: Array +) { + fun hasAnnotations(): Boolean = + inkPageIndices.isNotEmpty() || + textPageIndices.isNotEmpty() || + rasterPageIndices.isNotEmpty() || + highlightPageIndices.isNotEmpty() +} diff --git a/app/src/main/java/com/aryan/reader/pdf/RichTextSystem.kt b/app/src/main/java/com/aryan/reader/pdf/RichTextSystem.kt index 27ab141..520c777 100644 --- a/app/src/main/java/com/aryan/reader/pdf/RichTextSystem.kt +++ b/app/src/main/java/com/aryan/reader/pdf/RichTextSystem.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.SoftwareKeyboardController import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextMeasurer import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle @@ -56,12 +57,16 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.json.JSONArray import org.json.JSONObject +import com.aryan.reader.shared.pdf.SHARED_PDF_RICH_TEXT_LOG_TAG import timber.log.Timber import java.io.File const val PAGE_BREAK_CHAR = '\u000C' private const val ZWSP = "\u200B" +internal fun String.hasRenderableRichText(): Boolean = + any { it != PAGE_BREAK_CHAR && !it.isWhitespace() } + object PdfFontCache { private val cache = ConcurrentHashMap() private var assetManager: android.content.res.AssetManager? = null @@ -262,126 +267,200 @@ class TextPaginationEngine { dirtyGlobalIndex: Int = 0 ): List { val totalLen = globalText.length - if (totalLen == 0) return listOf( - PageTextLayout(0, AnnotatedString(""), 0, 0, pageHeightPx) + Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( + "android.paginate start textLen=$totalLen page=${pageWidthPx.richAndroidLogFloat()}x${pageHeightPx.richAndroidLogFloat()} " + + "margin=${marginX.richAndroidLogFloat()},${marginY.richAndroidLogFloat()} prev=${previousLayouts.size} dirty=$dirtyGlobalIndex" ) - if (pageWidthPx <= 0 || pageHeightPx <= 0) return emptyList() - - val validPages = if (dirtyGlobalIndex > 0 && previousLayouts.isNotEmpty()) { - previousLayouts.takeWhile { it.globalEndIndex < dirtyGlobalIndex } - } else { - emptyList() + if (totalLen == 0) { + Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d("android.paginate empty -> p0:0-0") + return listOf( + PageTextLayout(0, AnnotatedString(""), 0, 0, pageHeightPx) + ) + } + if (pageWidthPx <= 0 || pageHeightPx <= 0) { + Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d("android.paginate aborted invalid page size") + return emptyList() } - - val startPageIndex = validPages.size - val measurementStartIndex = validPages.lastOrNull()?.globalEndIndex ?: 0 - - if (measurementStartIndex >= totalLen) return validPages - - val textToMeasure = globalText.subSequence(measurementStartIndex, totalLen) - val fullString = textToMeasure.text val editorWidth = (pageWidthPx - (marginX * 2)).coerceAtLeast(10f) val editorHeight = (pageHeightPx - (marginY * 2)).coerceAtLeast(10f) - val measureResult = textMeasurer.measure( - text = textToMeasure, - style = TextStyle(fontSize = 16.sp, color = Color.Black), - constraints = Constraints(maxWidth = editorWidth.toInt(), maxHeight = Constraints.Infinity), - density = density - ) - val newPages = mutableListOf() - var currentPageIndex = startPageIndex - var currentPageStartRel = 0 - var currentPageAccumulatedHeight = 0f + var currentPageIndex = 0 + var segmentStart = 0 + val rawText = globalText.text - var currentLineIndex = 0 - val totalLines = measureResult.lineCount + while (segmentStart < totalLen) { + val breakIndex = rawText.indexOf(PAGE_BREAK_CHAR, startIndex = segmentStart) + val hasExplicitBreak = breakIndex != -1 + val contentEnd = if (hasExplicitBreak) breakIndex else totalLen + val segmentEnd = if (hasExplicitBreak) breakIndex + 1 else totalLen - Timber.tag("RichTextFlow").d("Pagination: Measuring ${fullString.length} chars from Global $measurementStartIndex. Lines: $totalLines") - - while (currentLineIndex < totalLines) { - val lineTop = measureResult.getLineTop(currentLineIndex) - val lineBottom = measureResult.getLineBottom(currentLineIndex) - val lineHeight = lineBottom - lineTop - - val lineStartRel = measureResult.getLineStart(currentLineIndex) - val lineEndRel = measureResult.getLineEnd(currentLineIndex) - - val localStartOffset = (currentPageStartRel - lineStartRel).coerceAtLeast(0) - - if (lineStartRel + localStartOffset >= lineEndRel && currentLineIndex < totalLines - 1) { - currentLineIndex++ - continue - } - - val safeEndRel = lineEndRel.coerceAtMost(fullString.length) - val lineContent = fullString.substring(lineStartRel, safeEndRel) - - val breakIndexInLine = lineContent.indexOf(PAGE_BREAK_CHAR, localStartOffset) - val hasPageBreak = breakIndexInLine != -1 - - val isStartOfPage = (currentPageAccumulatedHeight == 0f) - val willOverflow = !isStartOfPage && (currentPageAccumulatedHeight + lineHeight > editorHeight) - - if (hasPageBreak) { - val splitRelIndex = lineStartRel + breakIndexInLine + 1 - val globalStart = measurementStartIndex + currentPageStartRel - val globalEnd = measurementStartIndex + splitRelIndex - - Timber.tag("RichTextMigration").v("PaginationEngine: Found PAGE_BREAK_CHAR at relative ${breakIndexInLine}. Breaking Page $currentPageIndex at Global Index $globalEnd") - - if (globalEnd > globalStart) { - val visibleText = globalText.subSequence(globalStart, globalEnd) - newPages.add(PageTextLayout(currentPageIndex, visibleText, globalStart, globalEnd, pageHeightPx)) - currentPageIndex++ - } - - currentPageStartRel = splitRelIndex - currentPageAccumulatedHeight = 0f - continue - } - else if (willOverflow) { - val globalStart = measurementStartIndex + currentPageStartRel - val globalEnd = measurementStartIndex + lineStartRel - - if (globalEnd > globalStart) { - val visibleText = globalText.subSequence(globalStart, globalEnd) - newPages.add(PageTextLayout(currentPageIndex, visibleText, globalStart, globalEnd, pageHeightPx)) - Timber.tag("RichTextFlow").v("Page $currentPageIndex Created (Overflow): $globalStart -> $globalEnd") - currentPageIndex++ - } - - currentPageStartRel = lineStartRel - currentPageAccumulatedHeight = 0f - - continue - } - - currentPageAccumulatedHeight += lineHeight - currentLineIndex++ + currentPageIndex = newPages.appendMeasuredAndroidRichTextSegment( + globalText = globalText, + segmentStart = segmentStart, + contentEnd = contentEnd, + explicitBreakEnd = if (hasExplicitBreak) segmentEnd else null, + pageIndex = currentPageIndex, + pageHeightPx = pageHeightPx, + editorWidth = editorWidth, + editorHeight = editorHeight, + textMeasurer = textMeasurer, + density = density + ) + segmentStart = segmentEnd } - if (currentPageStartRel < fullString.length) { - val globalStart = measurementStartIndex + currentPageStartRel - val globalEnd = measurementStartIndex + fullString.length - val visibleText = globalText.subSequence(globalStart, globalEnd) - - newPages.add(PageTextLayout(currentPageIndex, visibleText, globalStart, globalEnd, pageHeightPx)) - } - - val resultLayouts = validPages + newPages + val resultLayouts = newPages.withTrailingAndroidBlankRichTextPageIfNeeded( + globalText = globalText, + pageHeightPx = pageHeightPx + ) val mapLog = resultLayouts.joinToString("\n") { " Page ${it.pageIndex}: Global[${it.globalStartIndex}..${it.globalEndIndex}]" } Timber.tag("RichTextMigration").i("Pagination Map Generated:\n$mapLog") + Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d("android.paginate done -> ${resultLayouts.richAndroidLayoutSummary()}") return resultLayouts } } +private fun MutableList.appendMeasuredAndroidRichTextSegment( + globalText: AnnotatedString, + segmentStart: Int, + contentEnd: Int, + explicitBreakEnd: Int?, + pageIndex: Int, + pageHeightPx: Float, + editorWidth: Float, + editorHeight: Float, + textMeasurer: TextMeasurer, + density: Density +): Int { + var nextPageIndex = pageIndex + if (segmentStart >= contentEnd) { + val breakEnd = explicitBreakEnd ?: return nextPageIndex + add( + PageTextLayout( + pageIndex = nextPageIndex, + visibleText = globalText.subSequence(segmentStart, breakEnd), + globalStartIndex = segmentStart, + globalEndIndex = breakEnd, + pageHeightPx = pageHeightPx + ) + ) + Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( + "android.paginate pageBreakOnly page=$nextPageIndex global=$segmentStart..$breakEnd" + ) + return nextPageIndex + 1 + } + + val contentLength = contentEnd - segmentStart + var relativeStart = 0 + while (relativeStart < contentLength) { + val globalStart = segmentStart + relativeStart + val remainingText = globalText.subSequence(globalStart, contentEnd) + val measureResult = textMeasurer.measure( + text = remainingText, + style = TextStyle(fontSize = 16.sp, color = Color.Black), + constraints = Constraints(maxWidth = editorWidth.toInt(), maxHeight = Constraints.Infinity), + density = density + ) + val fitsOnPage = measureResult.size.height.toFloat() <= editorHeight || measureResult.lineCount <= 1 + var overflowLineIndex: Int? = null + val relativeEnd = if (fitsOnPage) { + contentLength + } else { + val lineIndex = measureResult.richAndroidLastFittingLineIndex(editorHeight) + overflowLineIndex = lineIndex + val localEnd = measureResult.getLineEnd(lineIndex) + .coerceIn(0, remainingText.length) + .coerceAtLeast(1) + (relativeStart + localEnd) + .coerceAtLeast(relativeStart + 1) + .coerceAtMost(contentLength) + } + val isLastContentPage = relativeEnd >= contentLength + val globalEnd = if (isLastContentPage && explicitBreakEnd != null) { + explicitBreakEnd + } else { + segmentStart + relativeEnd + } + + add( + PageTextLayout( + pageIndex = nextPageIndex, + visibleText = globalText.subSequence(globalStart, globalEnd), + globalStartIndex = globalStart, + globalEndIndex = globalEnd, + pageHeightPx = pageHeightPx + ) + ) + if (isLastContentPage && explicitBreakEnd != null) { + Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( + "android.paginate pageBreak page=$nextPageIndex global=$globalStart..$globalEnd" + ) + } else if (!fitsOnPage) { + Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( + "android.paginate overflow page=$nextPageIndex global=$globalStart..$globalEnd line=$overflowLineIndex" + ) + } + nextPageIndex++ + relativeStart = relativeEnd + } + + return nextPageIndex +} + +private fun TextLayoutResult.richAndroidLastFittingLineIndex(editorHeight: Float): Int { + var lastFitting = 0 + for (lineIndex in 0 until lineCount) { + if (lineIndex == 0 || getLineBottom(lineIndex) <= editorHeight) { + lastFitting = lineIndex + } else { + break + } + } + return lastFitting.coerceIn(0, (lineCount - 1).coerceAtLeast(0)) +} + +private fun List.withTrailingAndroidBlankRichTextPageIfNeeded( + globalText: AnnotatedString, + pageHeightPx: Float +): List { + if (globalText.text.lastOrNull() != PAGE_BREAK_CHAR) return this + val lastLayout = lastOrNull() + val trailingStart = globalText.length + if (lastLayout != null && + lastLayout.globalStartIndex == trailingStart && + lastLayout.globalEndIndex == trailingStart + ) { + return this + } + return this + PageTextLayout( + pageIndex = (lastLayout?.pageIndex ?: -1) + 1, + visibleText = AnnotatedString(""), + globalStartIndex = trailingStart, + globalEndIndex = trailingStart, + pageHeightPx = pageHeightPx + ) +} + +private fun AnnotatedString.withoutTrailingAndroidPageBreak(): AnnotatedString { + return if (text.lastOrNull() == PAGE_BREAK_CHAR) { + subSequence(0, length - 1) + } else { + this + } +} + +private fun AnnotatedString.withRestoredTrailingAndroidPageBreak(shouldRestore: Boolean): AnnotatedString { + if (!shouldRestore) return this + if (text.lastOrNull() == PAGE_BREAK_CHAR) return this + return this + AnnotatedString(PAGE_BREAK_CHAR.toString()) +} + class PdfRichTextRepository(private val context: Context) { private val _document = MutableStateFlow(null) val document = _document.asStateFlow() @@ -396,8 +475,12 @@ class PdfRichTextRepository(private val context: Context) { suspend fun load(bookId: String) { withContext(Dispatchers.IO) { val file = getFile(bookId) + Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( + "android.repository.load start book=$bookId exists=${file.exists()} path=${file.absolutePath}" + ) if (!file.exists()) { _document.value = GlobalRichDocument("", emptyList()) + Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d("android.repository.load missing -> empty book=$bookId") return@withContext } try { @@ -425,7 +508,11 @@ class PdfRichTextRepository(private val context: Context) { ) } _document.value = GlobalRichDocument(text, spans) + Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( + "android.repository.load decoded book=$bookId rawLen=${jsonString.length} textLen=${text.length} spans=${spans.size}" + ) } catch (e: Exception) { + Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).e(e, "android.repository.load failed book=$bookId") Timber.e(e, "Failed to load rich text doc") _document.value = GlobalRichDocument("", emptyList()) } @@ -436,6 +523,9 @@ class PdfRichTextRepository(private val context: Context) { _document.value = document withContext(Dispatchers.IO) { try { + Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( + "android.repository.save start book=$bookId textLen=${document.text.length} spans=${document.spans.size}" + ) val obj = JSONObject().apply { put("text", document.text) val spansArray = JSONArray() @@ -456,14 +546,35 @@ class PdfRichTextRepository(private val context: Context) { } put("spans", spansArray) } - getFile(bookId).writeText(obj.toString()) + val file = getFile(bookId) + file.writeText(obj.toString()) + Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( + "android.repository.save done book=$bookId bytes=${file.length()} path=${file.absolutePath}" + ) } catch (e: Exception) { + Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).e(e, "android.repository.save failed book=$bookId") Timber.e(e, "Failed to save rich text doc") } } } } +private fun Float.richAndroidLogFloat(): String { + return if (isFinite()) { + val rounded = kotlin.math.round(this * 10f) / 10f + rounded.toString() + } else { + toString() + } +} + +private fun List.richAndroidLayoutSummary(): String { + if (isEmpty()) return "[]" + return joinToString(prefix = "[", postfix = "]", limit = 8, truncated = "...") { layout -> + "p${layout.pageIndex}:${layout.globalStartIndex}-${layout.globalEndIndex}/len${layout.visibleText.length}" + } +} + @Stable class RichTextController( private val repository: PdfRichTextRepository, @@ -485,6 +596,9 @@ class RichTextController( var pageLayouts by mutableStateOf(emptyList()) private set + val hasRenderableText: Boolean + get() = globalTextFieldValue.text.hasRenderableRichText() + var currentStyle: SpanStyle by mutableStateOf(SpanStyle(color = Color.Black, fontSize = 16.sp)) private set @@ -720,9 +834,11 @@ class RichTextController( val currentGlobal = globalTextFieldValue.annotatedString // FIX: Strip ZWSP (index 0) from local text - val localText = if (localTextFieldValue.annotatedString.isNotEmpty()) { + val localEditableText = if (localTextFieldValue.annotatedString.isNotEmpty()) { localTextFieldValue.annotatedString.subSequence(1, localTextFieldValue.annotatedString.length) } else AnnotatedString("") + val shouldPreservePageBreak = layout.visibleText.text.lastOrNull() == PAGE_BREAK_CHAR + val localText = localEditableText.withRestoredTrailingAndroidPageBreak(shouldPreservePageBreak) Timber.tag("RichTextFlow").d("Sync: Page $activePageIndex, GlobalRange [$globalStart..$globalEnd], LocalLen ${localText.length}") @@ -776,7 +892,7 @@ class RichTextController( activePageIndex = newActiveLayout.pageIndex val reExtractedText = newGlobalAnnotated.subSequence( newActiveLayout.globalStartIndex, newActiveLayout.globalEndIndex - ) + ).withoutTrailingAndroidPageBreak() val textWithZwsp = AnnotatedString(ZWSP) + reExtractedText val newLocalCursor = (newGlobalCursorPos - newActiveLayout.globalStartIndex + 1) .coerceIn(0, textWithZwsp.length) @@ -865,28 +981,26 @@ class RichTextController( val editorWidth = (lastPageWidth - (margin * 2)).coerceAtLeast(10f) val vText = currentLayout.visibleText + val editableText = vText.withoutTrailingAndroidPageBreak() // FIX: Prepend ZWSP to the visible text - val textWithZwsp = AnnotatedString(ZWSP) + vText - val safeLen = if (vText.isNotEmpty() && vText.last() == PAGE_BREAK_CHAR) vText.length - 1 else vText.length + val textWithZwsp = AnnotatedString(ZWSP) + editableText + val safeLen = editableText.length // FIX: Adjust initial selection by +1 because of ZWSP localTextFieldValue = TextFieldValue(textWithZwsp, TextRange(safeLen + 1)) val measureResult = measurer.measure( - text = currentLayout.visibleText, // We measure the original for layout tap calc + text = editableText, // We measure editable text, not the hidden page-break sentinel style = TextStyle(fontSize = 16.sp, color = Color.Black), constraints = Constraints(maxWidth = editorWidth.toInt()), density = density ) - val textHeight = measureResult.size.height.toFloat() + val textHeight = if (editableText.isEmpty()) 0f else measureResult.size.height.toFloat() - if (localTapOffset.y <= textHeight) { + if (editableText.isNotEmpty() && localTapOffset.y <= textHeight) { var localIndex = measureResult.getOffsetForPosition(localTapOffset) - - if (vText.isNotEmpty() && vText.last() == PAGE_BREAK_CHAR && localIndex >= vText.length) { - localIndex = vText.length - 1 - } + localIndex = localIndex.coerceIn(0, editableText.length) localTextFieldValue = localTextFieldValue.copy(selection = TextRange(localIndex + 1)) } else { val gap = localTapOffset.y - textHeight @@ -1132,7 +1246,9 @@ class RichTextController( val currentGlobal = globalTextFieldValue.annotatedString val localAnnotatedRaw = localTextFieldValue.annotatedString - val localAnnotated = if (localAnnotatedRaw.isNotEmpty()) localAnnotatedRaw.subSequence(1, localAnnotatedRaw.length) else AnnotatedString("") + val localEditableAnnotated = if (localAnnotatedRaw.isNotEmpty()) localAnnotatedRaw.subSequence(1, localAnnotatedRaw.length) else AnnotatedString("") + val shouldPreservePageBreak = layout.visibleText.text.lastOrNull() == PAGE_BREAK_CHAR + val localAnnotated = localEditableAnnotated.withRestoredTrailingAndroidPageBreak(shouldPreservePageBreak) val charBeforeSync = if (globalStart > 0) currentGlobal.text[globalStart - 1] else "START" val charAfterSync = if (globalEnd < currentGlobal.length) currentGlobal.text[globalEnd] else "END" @@ -1197,7 +1313,7 @@ class RichTextController( val reExtracted = newGlobalAnnotated.subSequence( newActiveLayout.globalStartIndex, newActiveLayout.globalEndIndex - ) + ).withoutTrailingAndroidPageBreak() val textWithZwsp = AnnotatedString(ZWSP) + reExtracted val newLocalCursor = (newGlobalCursorPos - newActiveLayout.globalStartIndex + 1).coerceIn(0, textWithZwsp.length) @@ -1301,7 +1417,7 @@ class RichTextController( activePageIndex = finalActiveLayout.pageIndex val reExtracted = intermediateGlobal.subSequence( finalActiveLayout.globalStartIndex, finalActiveLayout.globalEndIndex - ) + ).withoutTrailingAndroidPageBreak() val textWithZwsp = AnnotatedString(ZWSP) + reExtracted val localCursor = (newCursorPos - finalActiveLayout.globalStartIndex + 1).coerceIn(0, textWithZwsp.length) @@ -1351,7 +1467,7 @@ class RichTextController( val reExtracted = newGlobalText.subSequence( finalActiveLayout.globalStartIndex, finalActiveLayout.globalEndIndex - ) + ).withoutTrailingAndroidPageBreak() val textWithZwsp = AnnotatedString(ZWSP) + reExtracted val localCursor = (newCursorPos - finalActiveLayout.globalStartIndex + 1).coerceIn(0, textWithZwsp.length) @@ -1400,4 +1516,4 @@ class RichTextController( isSaving = false } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aryan/reader/pdf/UniversalDocument.kt b/app/src/main/java/com/aryan/reader/pdf/UniversalDocument.kt index 993ebb2..8205b6b 100644 --- a/app/src/main/java/com/aryan/reader/pdf/UniversalDocument.kt +++ b/app/src/main/java/com/aryan/reader/pdf/UniversalDocument.kt @@ -545,8 +545,11 @@ class OpdsStreamDocumentWrapper( private val client = com.aryan.reader.opds.OpdsRepository.sharedHttpClient.newBuilder() .apply { - if (!catalog?.username.isNullOrBlank() && !catalog.password.isNullOrBlank()) { - authenticator(com.aryan.reader.opds.OpdsRepository.OpdsAuthenticator(catalog.username, catalog.password)) + val streamCatalog = catalog + val username = streamCatalog?.username + val password = streamCatalog?.password + if (!username.isNullOrBlank() && !password.isNullOrBlank()) { + authenticator(com.aryan.reader.opds.OpdsRepository.OpdsAuthenticator(username, password)) } } .build() @@ -580,10 +583,11 @@ class OpdsStreamDocumentWrapper( } } - val finalUrlTemplate = if (catalog != null && urlTemplate.startsWith("http")) { + val streamCatalog = catalog + val finalUrlTemplate = if (streamCatalog != null && urlTemplate.startsWith("http")) { try { val oldUrl = java.net.URL(urlTemplate) - val newUrl = java.net.URL(catalog.url) + val newUrl = java.net.URL(streamCatalog.url) val oldBase = "${oldUrl.protocol}://${oldUrl.authority}" val newBase = "${newUrl.protocol}://${newUrl.authority}" urlTemplate.replace(oldBase, newBase) diff --git a/app/src/main/java/com/aryan/reader/tts/TtsController.kt b/app/src/main/java/com/aryan/reader/tts/TtsController.kt index c98c6ab..5eb0f10 100644 --- a/app/src/main/java/com/aryan/reader/tts/TtsController.kt +++ b/app/src/main/java/com/aryan/reader/tts/TtsController.kt @@ -186,11 +186,13 @@ class TtsController(context: Context) : Player.Listener { ) val textList = ArrayList(chunks.map { it.text }) + val spokenTextList = ArrayList(chunks.map { it.spokenText.ifBlank { it.text } }) val cfiList = ArrayList(chunks.map { it.sourceCfi }) val offsetList = ArrayList(chunks.map { it.startOffsetInSource }) val args = Bundle().apply { putStringArrayList(KEY_TEXT_CHUNKS, textList) + putStringArrayList(KEY_SPOKEN_TEXT_CHUNKS, spokenTextList) putStringArrayList(KEY_SOURCE_CFIS, cfiList) putIntegerArrayList(KEY_START_OFFSETS, offsetList) putString(KEY_SPEAKER_ID, _ttsState.value.speakerId) diff --git a/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt b/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt index cbb0949..f6af48b 100644 --- a/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt +++ b/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt @@ -61,6 +61,7 @@ val SET_PLAYBACK_PARAMS_COMMAND = SessionCommand("com.aryan.reader.tts.SET_PLAYB const val TTS_NOTIFICATION_DIAG_TAG = "TTS_NOTIFICATION_DIAG" const val KEY_TEXT_CHUNKS = "KEY_TEXT_CHUNKS" +const val KEY_SPOKEN_TEXT_CHUNKS = "KEY_SPOKEN_TEXT_CHUNKS" const val KEY_SOURCE_CFIS = "KEY_SOURCE_CFIS" const val KEY_START_OFFSETS = "KEY_START_OFFSETS" const val KEY_SPEAKER_ID = "KEY_SPEAKER_ID" @@ -205,6 +206,7 @@ class TtsPlaybackManager( ) val cfis = args.getStringArrayList(KEY_SOURCE_CFIS) val offsets = args.getIntegerArrayList(KEY_START_OFFSETS) + val spokenTexts = args.getStringArrayList(KEY_SPOKEN_TEXT_CHUNKS) val speakerId = args.getString(KEY_SPEAKER_ID, DEFAULT_SPEAKER_ID) val bookTitle = args.getString(KEY_BOOK_TITLE) val chapterTitle = args.getString(KEY_CHAPTER_TITLE) @@ -218,10 +220,23 @@ class TtsPlaybackManager( val richChunks = if (cfis != null && offsets != null && chunks.size == cfis.size && chunks.size == offsets.size) { chunks.mapIndexed { index, text -> val safeOffset = offsets.getOrNull(index) ?: -1 - TtsChunk(text, cfis[index], safeOffset) + val spokenText = spokenTexts?.getOrNull(index)?.ifBlank { text } ?: text + TtsChunk( + text = text, + sourceCfi = cfis[index], + startOffsetInSource = safeOffset, + spokenText = spokenText, + ) } } else { - chunks.map { TtsChunk(it, "", -1) } + chunks.mapIndexed { index, text -> + TtsChunk( + text = text, + sourceCfi = "", + startOffsetInSource = -1, + spokenText = spokenTexts?.getOrNull(index)?.ifBlank { text } ?: text, + ) + } } val authToken = args.getString(KEY_AUTH_TOKEN) @@ -337,7 +352,11 @@ class TtsPlaybackManager( } val slicedText = currentChunk.text.substring(relativeOffset) - val newChunk = currentChunk.copy(text = slicedText, startOffsetInSource = offset) + val newChunk = currentChunk.copy( + text = slicedText, + startOffsetInSource = offset, + spokenText = slicedText, + ) val mutableChunks = textChunks.toMutableList() mutableChunks[currentIdx] = newChunk @@ -540,7 +559,8 @@ class TtsPlaybackManager( "Preparing first chunk. startAtIndex=$startAtIndex, playWhenReady=$playWhenReady" ) - val ttsAudioData = generateAudioChunk(bookTitle ?: "Unknown Book", chapterTitle, startAtIndex, textChunks.size, firstChunk.text, currentSpeakerId, currentTtsMode, currentAuthToken) + val spokenText = firstChunk.spokenText.ifBlank { firstChunk.text } + val ttsAudioData = generateAudioChunk(bookTitle ?: "Unknown Book", chapterTitle, startAtIndex, textChunks.size, spokenText, currentSpeakerId, currentTtsMode, currentAuthToken) Timber.tag("TTS_CLOUD_DIAG").i("generateAudioChunk returned in ${System.currentTimeMillis() - chunkStartTime}ms") if (ttsAudioData.error == "INSUFFICIENT_CREDITS") { @@ -572,7 +592,7 @@ class TtsPlaybackManager( if (id != null) chunkStreamIds[startAtIndex] = id } val pathToUse = streamUri ?: audioFile!!.absolutePath - val mediaItem = createMediaItem(serverText, pathToUse, startAtIndex, updatedChunk) + val mediaItem = createMediaItem(updatedChunk.text, pathToUse, startAtIndex, updatedChunk) withContext(Dispatchers.Main) { val prepStartTime = System.currentTimeMillis() @@ -589,7 +609,7 @@ class TtsPlaybackManager( _ttsState.value = _ttsState.value.copy( isLoading = false, isPlaying = playWhenReady, - currentText = serverText, + currentText = updatedChunk.text, chapterTitle = chapterTitle, chapterIndex = chapterIndex, totalChapters = totalChapters, @@ -618,6 +638,9 @@ class TtsPlaybackManager( if (wordTimings.isNullOrEmpty()) { return originalChunk } + if (originalChunk.spokenText != originalChunk.text) { + return originalChunk.copy(timedWords = emptyList()) + } val timedWords = mutableListOf() var currentSearchIndex = 0 @@ -823,7 +846,8 @@ class TtsPlaybackManager( val prefetchStartTime = System.currentTimeMillis() Timber.tag("TTS_CLOUD_DIAG").i("Starting prefetch generation for chunk $targetIndex") - val ttsAudioData = generateAudioChunk(bookTitle ?: "Unknown Book", chapterTitle, targetIndex, textChunks.size, nextChunk.text, currentSpeakerId, currentTtsMode, currentAuthToken) + val spokenText = nextChunk.spokenText.ifBlank { nextChunk.text } + val ttsAudioData = generateAudioChunk(bookTitle ?: "Unknown Book", chapterTitle, targetIndex, textChunks.size, spokenText, currentSpeakerId, currentTtsMode, currentAuthToken) Timber.tag("TTS_CLOUD_DIAG").i("Prefetch audio setup for chunk $targetIndex took ${System.currentTimeMillis() - prefetchStartTime}ms") @@ -842,7 +866,7 @@ class TtsPlaybackManager( if ((audioFile != null || streamUri != null) && serverText != null) { val updatedChunk = processWordTimings(nextChunk, serverText, ttsAudioData.wordTimings) val pathToUse = streamUri ?: audioFile!!.absolutePath - val nextMediaItem = createMediaItem(serverText, pathToUse, targetIndex, updatedChunk) + val nextMediaItem = createMediaItem(updatedChunk.text, pathToUse, targetIndex, updatedChunk) withContext(Dispatchers.Main) { if (audioFile != null) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dce0da0..c603f45 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -707,6 +707,8 @@ Auto Scroll TTS Voice Settings + + TTS Word Replacements TTS Settings (Debug) Navigate with slider diff --git a/app/src/test/java/com/aryan/reader/FileHasherTest.kt b/app/src/test/java/com/aryan/reader/FileHasherTest.kt new file mode 100644 index 0000000..279948f --- /dev/null +++ b/app/src/test/java/com/aryan/reader/FileHasherTest.kt @@ -0,0 +1,54 @@ +package com.aryan.reader + +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.io.ByteArrayInputStream +import java.io.IOException +import java.io.InputStream + +class FileHasherTest { + + @Test + fun `calculateSha256 returns known SHA-256 for stream content`() = runTest { + val hash = FileHasher.calculateSha256 { + ByteArrayInputStream("hello world".toByteArray()) + } + + assertEquals( + "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", + hash + ) + } + + @Test + fun `calculateSha256 supports large multi-buffer streams`() = runTest { + val bytes = ByteArray(20_000) { index -> (index % 127).toByte() } + + val first = FileHasher.calculateSha256 { ByteArrayInputStream(bytes) } + val second = FileHasher.calculateSha256 { + object : InputStream() { + private var index = 0 + override fun read(): Int { + if (index >= bytes.size) return -1 + return bytes[index++].toInt() and 0xff + } + } + } + + assertEquals(first, second) + } + + @Test + fun `calculateSha256 returns null when provider is null or stream throws`() = runTest { + assertNull(FileHasher.calculateSha256 { null }) + assertNull( + FileHasher.calculateSha256 { + object : InputStream() { + override fun read(): Int = throw IOException("boom") + } + } + ) + } +} diff --git a/app/src/test/java/com/aryan/reader/FileTypeResolverTest.kt b/app/src/test/java/com/aryan/reader/FileTypeResolverTest.kt index 7aa8a92..b7a05ee 100644 --- a/app/src/test/java/com/aryan/reader/FileTypeResolverTest.kt +++ b/app/src/test/java/com/aryan/reader/FileTypeResolverTest.kt @@ -1,6 +1,8 @@ package com.aryan.reader import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test class FileTypeResolverTest { @@ -13,6 +15,27 @@ class FileTypeResolverTest { assertEquals(FileType.EPUB, resolveFileTypeFromName("book.epub.txt")) } + @Test + fun `code and data files resolve for manual viewing`() { + assertEquals(FileType.HTML, resolveFileTypeFromName("table.csv")) + assertEquals(FileType.HTML, resolveFileTypeFromName("script.kt")) + assertEquals(FileType.HTML, resolveFileTypeFromName("payload.json.txt")) + } + + @Test + fun `manual only reader files are excluded from folder sync eligibility`() { + assertTrue(isManualOnlyReaderFileName("table.csv")) + assertTrue(isManualOnlyReaderFileName("script.kt.txt")) + assertFalse(isManualOnlyReaderFileName("chapter.html")) + assertFalse(isManualOnlyReaderFileName("notes.txt")) + assertFalse(isManualOnlyReaderFileName("book.fodt")) + + assertFalse(isLocalFolderSyncEligibleFile("table.csv", "text/csv")) + assertFalse(isLocalFolderSyncEligibleFile("payload", "application/json")) + assertTrue(isLocalFolderSyncEligibleFile("chapter.html", "text/html")) + assertTrue(isLocalFolderSyncEligibleFile("book.fodt", "text/xml")) + } + @Test fun `plain txt remains txt when inner extension is unsupported`() { assertEquals(FileType.TXT, resolveFileTypeFromName("notes.txt")) diff --git a/app/src/test/java/com/aryan/reader/LibraryStateProjectorTest.kt b/app/src/test/java/com/aryan/reader/LibraryStateProjectorTest.kt new file mode 100644 index 0000000..2448ac6 --- /dev/null +++ b/app/src/test/java/com/aryan/reader/LibraryStateProjectorTest.kt @@ -0,0 +1,514 @@ +package com.aryan.reader + +import com.aryan.reader.data.BookShelfCrossRef +import com.aryan.reader.data.BookTagCrossRef +import com.aryan.reader.data.RecentFileItem +import com.aryan.reader.data.ShelfEntity +import com.aryan.reader.data.TagEntity +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class LibraryStateProjectorTest { + + @Test + fun `filterBySearch matches display name title author and tags`() { + val sciFi = tag("tag_scifi", "Sci-Fi") + val fantasy = tag("tag_fantasy", "Fantasy") + val files = listOf( + recentFile("display", displayName = "Android Patterns.pdf"), + recentFile("title", title = "Clean Architecture"), + recentFile("author", author = "Octavia Butler"), + recentFile("tagged", tags = listOf(sciFi)), + recentFile("miss", tags = listOf(fantasy)) + ) + + assertEquals(listOf("display"), filterBySearch(files, "android").ids()) + assertEquals(listOf("title"), filterBySearch(files, "architecture").ids()) + assertEquals(listOf("author"), filterBySearch(files, "butler").ids()) + assertEquals(listOf("tagged"), filterBySearch(files, "sci").ids()) + assertEquals(files.ids(), filterBySearch(files, " ").ids()) + } + + @Test + fun `applyLibraryFilters requires all active filters to match`() { + val activeTag = tag("active", "Active") + val files = listOf( + recentFile( + id = "match", + type = FileType.PDF, + sourceFolderUri = "content://sync", + progressPercentage = 50f, + tags = listOf(activeTag) + ), + recentFile( + id = "wrong_type", + type = FileType.EPUB, + sourceFolderUri = "content://sync", + progressPercentage = 50f, + tags = listOf(activeTag) + ), + recentFile( + id = "wrong_source", + type = FileType.PDF, + sourceFolderUri = null, + progressPercentage = 50f, + tags = listOf(activeTag) + ), + recentFile( + id = "completed", + type = FileType.PDF, + sourceFolderUri = "content://sync", + progressPercentage = 100f, + tags = listOf(activeTag) + ) + ) + + val filters = LibraryFilters( + fileTypes = setOf(FileType.PDF), + sourceFolders = setOf("content://sync"), + readStatus = ReadStatusFilter.IN_PROGRESS, + tagIds = setOf(activeTag.id) + ) + + assertEquals(listOf("match"), applyLibraryFilters(files, filters).ids()) + assertTrue(filters.isActive) + } + + @Test + fun `applyLibraryFilters supports in-app storage source`() { + val localBook = recentFile("local", uriString = "content://local", sourceFolderUri = null) + val streamedBook = recentFile("streamed", uriString = "opds-pse://book", sourceFolderUri = null) + val syncedBook = recentFile("synced", sourceFolderUri = "content://sync") + + val result = applyLibraryFilters( + listOf(localBook, streamedBook, syncedBook), + LibraryFilters(sourceFolders = setOf("IN_APP_STORAGE")) + ) + + assertEquals(listOf("local"), result.ids()) + } + + @Test + fun `applyLibraryFilters treats opds streams separately from in-app storage`() { + val localBook = recentFile("local", uriString = "file:///local/book.epub", sourceFolderUri = null) + val streamedBook = recentFile("streamed", uriString = "opds-pse://book", sourceFolderUri = null) + + assertEquals( + listOf("local"), + applyLibraryFilters( + listOf(localBook, streamedBook), + LibraryFilters(sourceFolders = setOf("IN_APP_STORAGE")) + ).ids() + ) + } + + @Test + fun `applyLibraryFilters separates unread in progress and completed books`() { + val unread = recentFile("unread", progressPercentage = null) + val started = recentFile("started", progressPercentage = 1f) + val middle = recentFile("middle", progressPercentage = 45f) + val done = recentFile("done", progressPercentage = 100f) + val files = listOf(unread, started, middle, done) + + assertEquals( + listOf("unread"), + applyLibraryFilters(files, LibraryFilters(readStatus = ReadStatusFilter.UNREAD)).ids() + ) + assertEquals( + listOf("started", "middle"), + applyLibraryFilters(files, LibraryFilters(readStatus = ReadStatusFilter.IN_PROGRESS)).ids() + ) + assertEquals( + listOf("done"), + applyLibraryFilters(files, LibraryFilters(readStatus = ReadStatusFilter.COMPLETED)).ids() + ) + } + + @Test + fun `sortFiles orders by title author progress size and recency`() { + val files = listOf( + recentFile("charlie", title = "Charlie", author = null, timestamp = 3L, progressPercentage = 50f, fileSize = 300L), + recentFile("alpha", title = "Alpha", author = "Zimmer", timestamp = 1L, progressPercentage = 10f, fileSize = 100L), + recentFile("bravo", title = "Bravo", author = "Asimov", timestamp = 2L, progressPercentage = 90f, fileSize = 200L) + ) + + assertEquals(listOf("charlie", "bravo", "alpha"), sortFiles(files, SortOrder.RECENT).ids()) + assertEquals(listOf("alpha", "bravo", "charlie"), sortFiles(files, SortOrder.TITLE_ASC).ids()) + assertEquals(listOf("bravo", "alpha", "charlie"), sortFiles(files, SortOrder.AUTHOR_ASC).ids()) + assertEquals(listOf("alpha", "charlie", "bravo"), sortFiles(files, SortOrder.PERCENT_ASC).ids()) + assertEquals(listOf("bravo", "charlie", "alpha"), sortFiles(files, SortOrder.PERCENT_DESC).ids()) + assertEquals(listOf("alpha", "bravo", "charlie"), sortFiles(files, SortOrder.SIZE_ASC).ids()) + assertEquals(listOf("charlie", "bravo", "alpha"), sortFiles(files, SortOrder.SIZE_DESC).ids()) + } + + @Test + fun `sortFiles falls back to display names and keeps unknown authors last`() { + val files = listOf( + recentFile("unknown", displayName = "Zulu.epub", title = null, author = null), + recentFile("known", displayName = "Beta.epub", title = null, author = "Ada"), + recentFile("title", displayName = "Alpha.epub", title = "Omega", author = "Grace") + ) + + assertEquals(listOf("known", "title", "unknown"), sortFiles(files, SortOrder.AUTHOR_ASC).ids()) + assertEquals(listOf("known", "title", "unknown"), sortFiles(files, SortOrder.TITLE_ASC).ids()) + } + + @Test + fun `project builds non-reader library state from repository data`() { + val tag = tag("tag_favorite", "Favorite") + val alpha = recentFile( + id = "alpha", + type = FileType.PDF, + title = "Zebra", + timestamp = 30L, + progressPercentage = 100f + ) + val beta = recentFile( + id = "beta", + type = FileType.EPUB, + title = "Alpha", + timestamp = 20L, + sourceFolderUri = "content://sync", + progressPercentage = 40f + ) + val gamma = recentFile( + id = "gamma", + type = FileType.MD, + title = "Notes", + timestamp = 10L, + isRecent = false + ) + val reflowCopy = recentFile(id = "beta_reflow", title = "Alpha Reflow") + val manualShelf = shelfEntity("manual", "Manual") + + val state = ReaderScreenState( + sortOrder = SortOrder.TITLE_ASC, + recentFilesLimit = 1, + openTabIds = listOf("beta", "missing"), + contextualActionItems = setOf(recentFile("beta"), recentFile("missing")), + viewingShelfId = "manual", + contextualActionShelfIds = setOf("manual", "missing") + ) + + val result = LibraryStateProjector().project( + LibraryProjectionInput( + state = state, + recentFilesFromDb = listOf(alpha, beta, gamma, reflowCopy), + dbShelves = listOf(manualShelf), + shelfRefs = listOf(BookShelfCrossRef(bookId = "alpha", shelfId = "manual", addedAt = 1L)), + dbTags = listOf(tag), + tagRefs = listOf(BookTagCrossRef(bookId = "beta", tagId = tag.id)) + ) + ) + + assertEquals(listOf("beta", "gamma", "alpha"), result.allRecentFiles.ids()) + assertEquals(listOf("alpha", "beta", "gamma"), result.rawLibraryFiles.ids()) + assertEquals(listOf("beta"), result.recentFiles.ids()) + assertEquals(listOf("beta"), result.openTabs.ids()) + assertEquals(setOf("beta"), result.contextualActionItems.mapTo(mutableSetOf()) { it.bookId }) + assertEquals(listOf(tag), result.contextualActionItems.first().tags) + assertEquals("manual", result.viewingShelfId) + assertEquals(setOf("manual"), result.contextualActionShelfIds) + assertEquals(listOf(tag), result.allTags) + assertFalse(result.rawLibraryFiles.any { it.bookId.endsWith("_reflow") }) + } + + @Test + fun `project applies search filters and sort only to library results`() { + val tag = tag("work", "Work") + val match = recentFile( + id = "match", + title = "Android Work", + type = FileType.PDF, + progressPercentage = 80f, + sourceFolderUri = "content://sync" + ) + val searchMiss = recentFile( + id = "search_miss", + title = "Poetry", + type = FileType.PDF, + progressPercentage = 80f, + sourceFolderUri = "content://sync" + ) + val filterMiss = recentFile( + id = "filter_miss", + title = "Android Notes", + type = FileType.EPUB, + progressPercentage = 80f, + sourceFolderUri = "content://sync" + ) + + val result = LibraryStateProjector().project( + LibraryProjectionInput( + state = ReaderScreenState( + searchQuery = "android", + sortOrder = SortOrder.TITLE_ASC, + libraryFilters = LibraryFilters( + fileTypes = setOf(FileType.PDF), + sourceFolders = setOf("content://sync"), + readStatus = ReadStatusFilter.IN_PROGRESS, + tagIds = setOf(tag.id) + ) + ), + recentFilesFromDb = listOf(searchMiss, filterMiss, match), + dbShelves = emptyList(), + shelfRefs = emptyList(), + dbTags = listOf(tag), + tagRefs = listOf(BookTagCrossRef(bookId = "match", tagId = tag.id)) + ) + ) + + assertEquals(listOf("match"), result.allRecentFiles.ids()) + assertEquals(listOf("search_miss", "filter_miss", "match"), result.rawLibraryFiles.ids()) + assertEquals(listOf(tag), result.allTags) + } + + @Test + fun `project builds manual tag series and unshelved shelves`() { + val favorite = tag("favorite", "Favorite") + val manualShelf = shelfEntity("manual", "Manual") + val manualBook = recentFile("manual", title = "Manual") + val taggedBook = recentFile("tagged", title = "Tagged") + val seriesOne = recentFile("series_1", title = "Series One", seriesName = "Saga", seriesIndex = 1.0) + val seriesTwo = recentFile("series_2", title = "Series Two", seriesName = "Saga", seriesIndex = 2.0) + val loose = recentFile("loose", title = "Loose") + + val result = LibraryStateProjector().project( + LibraryProjectionInput( + state = ReaderScreenState(sortOrder = SortOrder.TITLE_ASC), + recentFilesFromDb = listOf(manualBook, taggedBook, seriesTwo, loose, seriesOne), + dbShelves = listOf(manualShelf), + shelfRefs = listOf(BookShelfCrossRef(bookId = "manual", shelfId = "manual", addedAt = 1L)), + dbTags = listOf(favorite), + tagRefs = listOf(BookTagCrossRef(bookId = "tagged", tagId = favorite.id)) + ) + ) + + val manual = result.shelves.first { it.id == "manual" } + val tagShelf = result.shelves.first { it.id == "tag_favorite" } + val series = result.shelves.first { it.id == "series_Saga" } + val unshelved = result.shelves.first { it.id == "unshelved" } + + assertEquals(ShelfType.MANUAL, manual.type) + assertEquals(listOf("manual"), manual.books.ids()) + assertEquals(ShelfType.TAG, tagShelf.type) + assertEquals(listOf("tagged"), tagShelf.books.ids()) + assertEquals(ShelfType.SERIES, series.type) + assertEquals(listOf("series_1", "series_2"), series.books.ids()) + assertEquals(listOf("loose", "tagged"), unshelved.books.ids()) + } + + @Test + fun `project does not create series shelf for a single series book`() { + val single = recentFile("single", title = "Only Volume", seriesName = "Solo", seriesIndex = 1.0) + + val result = LibraryStateProjector().project( + LibraryProjectionInput( + state = ReaderScreenState(), + recentFilesFromDb = listOf(single), + dbShelves = emptyList(), + shelfRefs = emptyList(), + dbTags = emptyList(), + tagRefs = emptyList() + ) + ) + + assertTrue(result.shelves.none { it.type == ShelfType.SERIES }) + assertEquals(listOf("single"), result.shelves.first { it.id == "unshelved" }.books.ids()) + } + + @Test + fun `project exposes all books for adding except books already in current shelf`() { + val shelf = shelfEntity("manual", "Manual") + val shelved = recentFile("shelved", title = "Shelved") + val loose = recentFile("loose", title = "Loose") + + val result = LibraryStateProjector().project( + LibraryProjectionInput( + state = ReaderScreenState( + viewingShelfId = "manual", + isAddingBooksToShelf = true, + addBooksSource = AddBooksSource.ALL_BOOKS, + sortOrder = SortOrder.TITLE_ASC + ), + recentFilesFromDb = listOf(shelved, loose), + dbShelves = listOf(shelf), + shelfRefs = listOf(BookShelfCrossRef(bookId = "shelved", shelfId = "manual", addedAt = 1L)), + dbTags = emptyList(), + tagRefs = emptyList() + ) + ) + + assertEquals(listOf("loose"), result.booksAvailableForAdding.ids()) + } + + @Test + fun `project exposes only unshelved books for default add books source`() { + val shelf = shelfEntity("manual", "Manual") + val shelved = recentFile("shelved", title = "Shelved") + val loose = recentFile("loose", title = "Loose") + val tagged = recentFile("tagged", title = "Tagged") + val tag = tag("tagged", "Tagged") + + val result = LibraryStateProjector().project( + LibraryProjectionInput( + state = ReaderScreenState( + viewingShelfId = "manual", + isAddingBooksToShelf = true, + addBooksSource = AddBooksSource.UNSHELVED, + sortOrder = SortOrder.TITLE_ASC + ), + recentFilesFromDb = listOf(shelved, loose, tagged), + dbShelves = listOf(shelf), + shelfRefs = listOf(BookShelfCrossRef(bookId = "shelved", shelfId = "manual", addedAt = 1L)), + dbTags = listOf(tag), + tagRefs = listOf(BookTagCrossRef(bookId = "tagged", tagId = tag.id)) + ) + ) + + assertEquals(listOf("loose", "tagged"), result.booksAvailableForAdding.ids()) + } + + @Test + fun `project clears stale shelf mode when selected shelf disappears`() { + val result = LibraryStateProjector().project( + LibraryProjectionInput( + state = ReaderScreenState( + viewingShelfId = "deleted", + isAddingBooksToShelf = true, + contextualActionShelfIds = setOf("deleted") + ), + recentFilesFromDb = listOf(recentFile("book")), + dbShelves = emptyList(), + shelfRefs = emptyList(), + dbTags = emptyList(), + tagRefs = emptyList() + ) + ) + + assertNull(result.viewingShelfId) + assertFalse(result.isAddingBooksToShelf) + assertTrue(result.contextualActionShelfIds.isEmpty()) + } + + @Test + fun `project creates root and nested shelves for synced folders`() { + val rootBook = recentFile("root", sourceFolderUri = "content://library", timestamp = 2L) + val nestedBook = recentFile("nested", sourceFolderUri = "content://library", timestamp = 1L) + val projector = LibraryStateProjector( + FolderPathResolver { item -> + when (item.bookId) { + "nested" -> listOf("Series", "Volume 1") + else -> emptyList() + } + } + ) + + val result = projector.project( + LibraryProjectionInput( + state = ReaderScreenState( + syncedFolders = listOf( + SyncedFolder( + uriString = "content://library", + name = "Library", + lastScanTime = 1L + ) + ) + ), + recentFilesFromDb = listOf(rootBook, nestedBook), + dbShelves = emptyList(), + shelfRefs = emptyList(), + dbTags = emptyList(), + tagRefs = emptyList() + ) + ) + + val rootShelf = result.shelves.first { it.id == "folder_content://library" } + val seriesShelf = result.shelves.first { it.id == "folder_content://library::Series" } + val volumeShelf = result.shelves.first { it.id == "folder_content://library::Series/Volume 1" } + + assertEquals("Library", rootShelf.name) + assertEquals(listOf("root", "nested"), rootShelf.books.ids()) + assertEquals(listOf("root"), rootShelf.directBooks.ids()) + assertEquals(listOf(seriesShelf.id), rootShelf.childShelfIds) + + assertEquals(rootShelf.id, seriesShelf.parentShelfId) + assertEquals(listOf("nested"), seriesShelf.books.ids()) + assertEquals(listOf(volumeShelf.id), seriesShelf.childShelfIds) + + assertEquals(seriesShelf.id, volumeShelf.parentShelfId) + assertEquals(listOf("nested"), volumeShelf.directBooks.ids()) + assertEquals(2, volumeShelf.depth) + } + + @Test + fun `project names folder shelf local folder when synced folder metadata is missing`() { + val book = recentFile("folder_book", sourceFolderUri = "content://external") + + val result = LibraryStateProjector().project( + LibraryProjectionInput( + state = ReaderScreenState(), + recentFilesFromDb = listOf(book), + dbShelves = emptyList(), + shelfRefs = emptyList(), + dbTags = emptyList(), + tagRefs = emptyList() + ) + ) + + val folderShelf = result.shelves.first { it.id == "folder_content://external" } + assertEquals("Local Folder", folderShelf.name) + assertEquals(listOf("folder_book"), folderShelf.books.ids()) + assertEquals(listOf("folder_book"), folderShelf.directBooks.ids()) + } + + private fun recentFile( + id: String, + uriString: String? = "content://$id", + type: FileType = FileType.EPUB, + displayName: String = "$id.${type.name.lowercase()}", + title: String? = null, + author: String? = null, + timestamp: Long = 1L, + isRecent: Boolean = true, + sourceFolderUri: String? = null, + progressPercentage: Float? = null, + tags: List = emptyList(), + fileSize: Long = 0L, + seriesName: String? = null, + seriesIndex: Double? = null + ) = RecentFileItem( + bookId = id, + uriString = uriString, + type = type, + displayName = displayName, + title = title, + author = author, + timestamp = timestamp, + isRecent = isRecent, + sourceFolderUri = sourceFolderUri, + progressPercentage = progressPercentage, + tags = tags, + fileSize = fileSize, + seriesName = seriesName, + seriesIndex = seriesIndex + ) + + private fun tag(id: String, name: String) = TagEntity( + id = id, + name = name, + createdAt = 1L + ) + + private fun shelfEntity(id: String, name: String) = ShelfEntity( + id = id, + name = name, + createdAt = 1L, + updatedAt = 1L + ) + + private fun List.ids() = map { it.bookId } +} diff --git a/app/src/test/java/com/aryan/reader/MainViewModelTest.kt b/app/src/test/java/com/aryan/reader/MainViewModelTest.kt index c0f5da9..312726a 100644 --- a/app/src/test/java/com/aryan/reader/MainViewModelTest.kt +++ b/app/src/test/java/com/aryan/reader/MainViewModelTest.kt @@ -3,13 +3,26 @@ package com.aryan.reader import android.app.Application import android.content.SharedPreferences import android.content.res.Resources +import android.net.Uri import android.util.Log +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.credentials.CredentialManager import androidx.work.WorkManager +import com.android.billingclient.api.BillingClient +import com.android.billingclient.api.BillingResult import com.aryan.reader.data.* -import com.tom_roush.pdfbox.android.PDFBoxResourceLoader +import com.aryan.reader.paginatedreader.Locator +import com.aryan.reader.paginatedreader.data.BookCacheDao +import com.aryan.reader.paginatedreader.data.BookCacheDatabase +import com.aryan.reader.tts.TtsController +import com.aryan.reader.tts.TtsPlaybackManager +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.firestore.FirebaseFirestore import io.mockk.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch @@ -20,6 +33,7 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test +import java.io.File @OptIn(ExperimentalCoroutinesApi::class) class MainViewModelTest { @@ -33,9 +47,24 @@ class MainViewModelTest { private val billingStateFlow = MutableStateFlow(ProUpgradeState()) private val customFontsFlow = MutableStateFlow>(emptyList()) + private val ttsStateFlow = MutableStateFlow(TtsPlaybackManager.TtsState()) + private val recentFilesFlow = MutableStateFlow>(emptyList()) + private val shelvesFlow = MutableStateFlow>(emptyList()) + private val shelfRefsFlow = MutableStateFlow>(emptyList()) + private val tagsFlow = MutableStateFlow>(emptyList()) + private val tagRefsFlow = MutableStateFlow>(emptyList()) @Before fun setup() { + recentFilesFlow.value = emptyList() + shelvesFlow.value = emptyList() + shelfRefsFlow.value = emptyList() + tagsFlow.value = emptyList() + tagRefsFlow.value = emptyList() + billingStateFlow.value = ProUpgradeState() + customFontsFlow.value = emptyList() + ttsStateFlow.value = TtsPlaybackManager.TtsState() + mockkStatic(Log::class) every { Log.isLoggable(any(), any()) } returns false every { Log.d(any(), any()) } returns 0 @@ -49,10 +78,18 @@ class MainViewModelTest { mockPrefs = mockk(relaxed = true) mockEditor = mockk(relaxed = true) val mockResources = mockk(relaxed = true) + val testRoot = File("build/test-tmp/MainViewModelTest/${System.nanoTime()}") + val filesDir = File(testRoot, "files").apply { mkdirs() } + val cacheDir = File(testRoot, "cache").apply { mkdirs() } + val externalFilesDir = File(testRoot, "external-files").apply { mkdirs() } every { mockApplication.applicationContext } returns mockApplication every { mockApplication.getSharedPreferences(any(), any()) } returns mockPrefs every { mockApplication.resources } returns mockResources + every { mockApplication.packageName } returns "com.aryan.reader" + every { mockApplication.filesDir } returns filesDir + every { mockApplication.cacheDir } returns cacheDir + every { mockApplication.getExternalFilesDir(any()) } returns externalFilesDir every { mockPrefs.edit() } returns mockEditor every { mockPrefs.getString(any(), any()) } answers { secondArg() as String? } @@ -60,15 +97,39 @@ class MainViewModelTest { every { mockPrefs.getInt(any(), any()) } answers { secondArg() as Int } every { mockPrefs.getFloat(any(), any()) } answers { secondArg() as Float } - mockkStatic(AppDatabase::class) + mockkObject(AppDatabase.Companion) val mockDb = mockk(relaxed = true) every { AppDatabase.getDatabase(any()) } returns mockDb + mockkObject(BookCacheDatabase.Companion) + val mockBookCacheDb = mockk(relaxed = true) + every { mockBookCacheDb.bookCacheDao() } returns mockk(relaxed = true) + every { BookCacheDatabase.getDatabase(any()) } returns mockBookCacheDb - mockkStatic(WorkManager::class) - every { WorkManager.getInstance(any()) } returns mockk(relaxed = true) - mockkStatic(PDFBoxResourceLoader::class) - every { PDFBoxResourceLoader.init(any()) } just Runs - + mockkObject(WorkManager.Companion) + val mockWorkManager = mockk(relaxed = true) + every { WorkManager.getInstance(any()) } returns mockWorkManager + mockkStatic(FirebaseAuth::class) + every { FirebaseAuth.getInstance() } returns mockk(relaxed = true) + mockkStatic(FirebaseFirestore::class) + every { FirebaseFirestore.getInstance() } returns mockk(relaxed = true) + mockkObject(CredentialManager.Companion) + every { CredentialManager.create(any()) } returns mockk(relaxed = true) + mockkStatic(BillingClient::class) + val mockBillingClient = mockk(relaxed = true) + val mockBillingBuilder = mockk(relaxed = true) + every { BillingClient.newBuilder(any()) } returns mockBillingBuilder + every { mockBillingBuilder.setListener(any()) } returns mockBillingBuilder + every { mockBillingBuilder.enablePendingPurchases(any()) } returns mockBillingBuilder + every { mockBillingBuilder.build() } returns mockBillingClient + every { mockBillingClient.isReady } returns false + every { mockBillingClient.startConnection(any()) } answers { + firstArg() + .onBillingSetupFinished( + BillingResult.newBuilder() + .setResponseCode(BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE) + .build() + ) + } mockkConstructor(AuthRepository::class) mockkConstructor(RecentFilesRepository::class) mockkConstructor(BillingClientWrapper::class) @@ -76,18 +137,28 @@ class MainViewModelTest { mockkConstructor(FirestoreRepository::class) mockkConstructor(FeedbackRepository::class) mockkConstructor(FontsRepository::class) + mockkConstructor(TtsController::class) every { anyConstructed().proUpgradeState } returns billingStateFlow every { anyConstructed().getSignedInUser() } returns null every { anyConstructed().observeAuthState() } returns flowOf(null) - every { anyConstructed().getRecentFilesFlow() } returns flowOf(emptyList()) - every { anyConstructed().activeShelvesFlow } returns flowOf(emptyList()) - every { anyConstructed().shelfCrossRefsFlow } returns flowOf(emptyList()) - every { anyConstructed().tagsFlow } returns flowOf(emptyList()) - every { anyConstructed().tagCrossRefsFlow } returns flowOf(emptyList()) + every { anyConstructed().init() } just Runs + every { anyConstructed().ttsState } returns ttsStateFlow + every { anyConstructed().connect() } just Runs + every { anyConstructed().release() } just Runs + every { anyConstructed().getRecentFilesFlow() } returns recentFilesFlow + every { anyConstructed().activeShelvesFlow } returns shelvesFlow + every { anyConstructed().shelfCrossRefsFlow } returns shelfRefsFlow + every { anyConstructed().tagsFlow } returns tagsFlow + every { anyConstructed().tagCrossRefsFlow } returns tagRefsFlow coEvery { anyConstructed().migrateLegacyShelvesToRoom() } just Runs coEvery { anyConstructed().seedTagsIfEmpty(any()) } just Runs + coEvery { anyConstructed().assignTagToBook(any(), any()) } just Runs + coEvery { anyConstructed().removeTagFromBook(any(), any()) } just Runs + coEvery { anyConstructed().removeBooksFromShelf(any(), any()) } just Runs + coEvery { anyConstructed().addBooksToShelf(any(), any()) } just Runs + coEvery { anyConstructed().deleteShelf(any()) } just Runs every { anyConstructed().getAllFonts() } returns customFontsFlow @@ -109,8 +180,11 @@ class MainViewModelTest { viewModel.setSearchActive(true) viewModel.onSearchQueryChange("Moby Dick") - assertEquals("Moby Dick", viewModel.uiState.value.searchQuery) - assertTrue(viewModel.uiState.value.isSearchActive) + val state = viewModel.uiState.first { + it.searchQuery == "Moby Dick" && it.isSearchActive + } + assertEquals("Moby Dick", state.searchQuery) + assertTrue(state.isSearchActive) } @Test @@ -127,6 +201,18 @@ class MainViewModelTest { assertFalse(viewModel.uiState.value.isSearchActive) } + @Test + fun `search query change is ignored while search is inactive`() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + + viewModel.onSearchQueryChange("Invisible") + + assertEquals("", viewModel.uiState.value.searchQuery) + assertFalse(viewModel.uiState.value.isSearchActive) + } + @Test fun `switching theme updates internal state and preferences`() = runTest { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { @@ -135,7 +221,8 @@ class MainViewModelTest { viewModel.setAppThemeMode(AppThemeMode.DARK) - assertEquals(AppThemeMode.DARK, viewModel.uiState.value.appThemeMode) + val state = viewModel.uiState.first { it.appThemeMode == AppThemeMode.DARK } + assertEquals(AppThemeMode.DARK, state.appThemeMode) verify { mockEditor.putString("app_theme_mode", AppThemeMode.DARK.name) } } @@ -147,10 +234,688 @@ class MainViewModelTest { viewModel.setTabsEnabled(true) - assertTrue(viewModel.uiState.value.isTabsEnabled) + val state = viewModel.uiState.first { it.isTabsEnabled } + assertTrue(state.isTabsEnabled) verify { mockEditor.putBoolean("tabs_enabled", true) } } + @Test + fun `setRenderMode persists mode without touching saved epub position`() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + + viewModel.setRenderMode(RenderMode.PAGINATED) + + val state = viewModel.uiState.first { it.renderMode == RenderMode.PAGINATED } + assertEquals(RenderMode.PAGINATED, state.renderMode) + verify { mockEditor.putString(KEY_RENDER_MODE, RenderMode.PAGINATED.name) } + coVerify(exactly = 0) { + anyConstructed().updateEpubReadingPosition(any(), any(), any(), any()) + } + } + + @Test + fun `saveEpubReadingPosition forwards cfi locator and progress to repository`() = runTest { + val uriString = "content://books/one" + val uri = mockUri(uriString) + val locator = Locator(chapterIndex = 5, blockIndex = 77, charOffset = 14) + coEvery { anyConstructed().getFileByUri(uriString) } returns RecentFileItem( + bookId = "book-1", + uriString = uriString, + type = FileType.EPUB, + displayName = "One.epub", + timestamp = 1L + ) + coEvery { + anyConstructed().updateEpubReadingPosition(any(), any(), any(), any()) + } just Runs + + viewModel.saveEpubReadingPosition(uri, locator, "/4/2/6:14", 37.25f) + testDispatcher.scheduler.advanceUntilIdle() + + coVerify { + anyConstructed().updateEpubReadingPosition( + uriString = uriString, + locator = locator, + cfiForWebView = "/4/2/6:14", + progress = 37.25f + ) + } + } + + @Test + fun `setRecentFilesLimit persists and limits visible home recents`() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + val first = recentFile("first", isRecent = true) + val second = recentFile("second", isRecent = true) + recentFilesFlow.value = listOf(first, second) + viewModel.uiState.first { it.rawLibraryFiles.size == 2 } + + viewModel.setRecentFilesLimit(1) + val state = viewModel.uiState.first { it.recentFiles.bookIds() == setOf("first") } + + assertEquals(listOf("first"), state.recentFiles.map { it.bookId }) + verify { mockEditor.putInt("recent_files_limit", 1) } + } + + @Test + fun `strict file filter and external file behavior persist preferences`() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + + viewModel.setStrictFileFilter(true) + viewModel.setExternalFileBehavior("KEEP") + + val state = viewModel.uiState.first { + it.useStrictFileFilter && it.externalFileBehavior == "KEEP" + } + assertTrue(state.useStrictFileFilter) + assertEquals("KEEP", state.externalFileBehavior) + verify { mockEditor.putBoolean("use_strict_file_filter", true) } + verify { mockEditor.putString("external_file_behavior", "KEEP") } + } + + @Test + fun `setSortOrder persists preference and reorders visible home and library lists`() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + val beta = recentFile("beta", title = "Beta", timestamp = 3L) + val alpha = recentFile("alpha", title = "Alpha", timestamp = 1L) + val gamma = recentFile("gamma", title = "Gamma", timestamp = 2L, isRecent = false) + recentFilesFlow.value = listOf(beta, alpha, gamma) + viewModel.uiState.first { it.rawLibraryFiles.size == 3 } + + viewModel.setSortOrder(SortOrder.TITLE_ASC) + val state = viewModel.uiState.first { + it.sortOrder == SortOrder.TITLE_ASC && + it.allRecentFiles.map { item -> item.bookId } == listOf("alpha", "beta", "gamma") + } + + assertEquals(listOf("alpha", "beta"), state.recentFiles.map { it.bookId }) + assertEquals(listOf("alpha", "beta", "gamma"), state.allRecentFiles.map { it.bookId }) + verify { mockEditor.putString("sort_order", SortOrder.TITLE_ASC.name) } + } + + @Test + fun `setMainScreenPage clamps to bottom navigation bounds and persists`() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + + viewModel.setMainScreenPage(99) + + val state = viewModel.uiState.first { it.mainScreenStartPage == 1 } + assertEquals(1, state.mainScreenStartPage) + verify { mockEditor.putInt(KEY_MAIN_SCREEN_START_PAGE, 1) } + } + + @Test + fun `setLibraryScreenPage clamps to available library tabs and persists`() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + + viewModel.setLibraryScreenPage(99) + + val expectedMaxPage = if (BuildConfig.IS_OFFLINE) 2 else 3 + val state = viewModel.uiState.first { it.libraryScreenStartPage == expectedMaxPage } + assertEquals(expectedMaxPage, state.libraryScreenStartPage) + verify { mockEditor.putInt(KEY_LIBRARY_SCREEN_START_PAGE, expectedMaxPage) } + } + + @Test + fun `create shelf dialog state opens and dismisses`() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + + viewModel.showCreateShelfDialog() + val openedState = viewModel.uiState.first { it.showCreateShelfDialog } + assertTrue(openedState.showCreateShelfDialog) + + viewModel.dismissCreateShelfDialog() + val dismissedState = viewModel.uiState.first { !it.showCreateShelfDialog } + assertFalse(dismissedState.showCreateShelfDialog) + } + + @Test + fun `selectAllRecentFiles toggles only visible recent home items`() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + val recent = recentFile("recent", isRecent = true) + val notRecent = recentFile("not_recent", isRecent = false) + recentFilesFlow.value = listOf(recent, notRecent) + viewModel.uiState.first { it.rawLibraryFiles.size == 2 } + + viewModel.selectAllRecentFiles() + val selectedState = viewModel.uiState.first { + it.contextualActionItems.bookIds() == setOf("recent") + } + + assertEquals(setOf("recent"), selectedState.contextualActionItems.bookIds()) + + viewModel.selectAllRecentFiles() + val clearedState = viewModel.uiState.first { it.contextualActionItems.isEmpty() } + + assertTrue(clearedState.contextualActionItems.isEmpty()) + } + + @Test + fun `selectAllLibraryFiles toggles all filtered library items`() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + val pdf = recentFile("pdf", type = FileType.PDF) + val epub = recentFile("epub", type = FileType.EPUB) + recentFilesFlow.value = listOf(pdf, epub) + viewModel.uiState.first { it.rawLibraryFiles.size == 2 } + + viewModel.updateLibraryFilters(LibraryFilters(fileTypes = setOf(FileType.PDF))) + viewModel.uiState.first { it.allRecentFiles.bookIds() == setOf("pdf") } + viewModel.selectAllLibraryFiles() + val selectedState = viewModel.uiState.first { + it.contextualActionItems.bookIds() == setOf("pdf") + } + + assertEquals(setOf("pdf"), selectedState.contextualActionItems.bookIds()) + } + + @Test + fun `selectAllLibraryFiles clears selection when all visible library items are already selected`() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + val first = recentFile("first") + val second = recentFile("second") + recentFilesFlow.value = listOf(first, second) + viewModel.uiState.first { it.rawLibraryFiles.size == 2 } + + viewModel.selectAllLibraryFiles() + viewModel.uiState.first { it.contextualActionItems.bookIds() == setOf("first", "second") } + viewModel.selectAllLibraryFiles() + val clearedState = viewModel.uiState.first { it.contextualActionItems.isEmpty() } + + assertTrue(clearedState.contextualActionItems.isEmpty()) + } + + @Test + fun `togglePinForContextualItems pins selected home items and clears selection`() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + val book = recentFile("book") + recentFilesFlow.value = listOf(book) + viewModel.uiState.first { it.rawLibraryFiles.size == 1 } + + viewModel.onRecentItemLongPress(book) + viewModel.togglePinForContextualItems(isHome = true) + val pinnedState = viewModel.uiState.first { + it.pinnedHomeBookIds == setOf("book") && it.contextualActionItems.isEmpty() + } + + assertEquals(setOf("book"), pinnedState.pinnedHomeBookIds) + assertTrue(pinnedState.contextualActionItems.isEmpty()) + verify { mockEditor.putStringSet("pinned_home_books", setOf("book")) } + } + + @Test + fun `togglePinForContextualItems unpins when every selected item is already pinned`() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + val book = recentFile("book") + recentFilesFlow.value = listOf(book) + viewModel.uiState.first { it.rawLibraryFiles.size == 1 } + + viewModel.onRecentItemLongPress(book) + viewModel.togglePinForContextualItems(isHome = true) + viewModel.uiState.first { it.pinnedHomeBookIds == setOf("book") } + viewModel.onRecentItemLongPress(book) + viewModel.togglePinForContextualItems(isHome = true) + val state = viewModel.uiState.first { + it.pinnedHomeBookIds.isEmpty() && it.contextualActionItems.isEmpty() + } + + assertTrue(state.pinnedHomeBookIds.isEmpty()) + verify { mockEditor.putStringSet("pinned_home_books", emptySet()) } + } + + @Test + fun `clearContextualAction clears selected books without disturbing pinned state`() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + val book = recentFile("book") + recentFilesFlow.value = listOf(book) + viewModel.uiState.first { it.rawLibraryFiles.size == 1 } + + viewModel.onRecentItemLongPress(book) + viewModel.uiState.first { it.contextualActionItems.bookIds() == setOf("book") } + viewModel.clearContextualAction() + val state = viewModel.uiState.first { it.contextualActionItems.isEmpty() } + + assertTrue(state.contextualActionItems.isEmpty()) + assertTrue(state.pinnedHomeBookIds.isEmpty()) + assertTrue(state.pinnedLibraryBookIds.isEmpty()) + } + + @Test + fun `togglePinForContextualItems pins selected library items separately from home pins`() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + val book = recentFile("library_book") + recentFilesFlow.value = listOf(book) + viewModel.uiState.first { it.rawLibraryFiles.size == 1 } + + viewModel.onRecentItemLongPress(book) + viewModel.togglePinForContextualItems(isHome = false) + val state = viewModel.uiState.first { + it.pinnedLibraryBookIds == setOf("library_book") && it.contextualActionItems.isEmpty() + } + + assertEquals(setOf("library_book"), state.pinnedLibraryBookIds) + assertTrue(state.pinnedHomeBookIds.isEmpty()) + verify { mockEditor.putStringSet("pinned_library_books", setOf("library_book")) } + } + + @Test + fun `updateLibraryFilters updates state and persists every filter dimension`() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + val filters = LibraryFilters( + fileTypes = setOf(FileType.PDF, FileType.EPUB), + sourceFolders = setOf("IN_APP_STORAGE", "content://sync"), + readStatus = ReadStatusFilter.COMPLETED, + tagIds = setOf("favorite") + ) + + viewModel.updateLibraryFilters(filters) + + val state = viewModel.uiState.first { it.libraryFilters == filters } + assertEquals(filters, state.libraryFilters) + verify { mockEditor.putStringSet(KEY_FILTER_FILE_TYPES, setOf("PDF", "EPUB")) } + verify { mockEditor.putStringSet(KEY_FILTER_FOLDERS, filters.sourceFolders) } + verify { mockEditor.putString(KEY_FILTER_READ_STATUS, ReadStatusFilter.COMPLETED.name) } + verify { mockEditor.putStringSet(KEY_FILTER_TAG_IDS, filters.tagIds) } + } + + @Test + fun `updateLibraryFilters clears active filters and persists empty dimensions`() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + viewModel.updateLibraryFilters( + LibraryFilters( + fileTypes = setOf(FileType.PDF), + sourceFolders = setOf("content://sync"), + readStatus = ReadStatusFilter.IN_PROGRESS, + tagIds = setOf("favorite") + ) + ) + viewModel.uiState.first { it.libraryFilters.isActive } + + viewModel.updateLibraryFilters(LibraryFilters()) + val state = viewModel.uiState.first { !it.libraryFilters.isActive } + + assertEquals(LibraryFilters(), state.libraryFilters) + verify { mockEditor.putStringSet(KEY_FILTER_FILE_TYPES, emptySet()) } + verify { mockEditor.putStringSet(KEY_FILTER_FOLDERS, emptySet()) } + verify { mockEditor.putString(KEY_FILTER_READ_STATUS, ReadStatusFilter.ALL.name) } + verify { mockEditor.putStringSet(KEY_FILTER_TAG_IDS, emptySet()) } + } + + @Test + fun `tag selection ignores empty targets and closes after opening`() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + + viewModel.openTagSelection(emptySet()) + assertTrue(viewModel.uiState.value.showTagSelectionDialogFor.isEmpty()) + + viewModel.openTagSelection(setOf("book")) + val openedState = viewModel.uiState.first { it.showTagSelectionDialogFor == setOf("book") } + assertEquals(setOf("book"), openedState.showTagSelectionDialogFor) + + viewModel.closeTagSelection() + val closedState = viewModel.uiState.first { it.showTagSelectionDialogFor.isEmpty() } + assertTrue(closedState.showTagSelectionDialogFor.isEmpty()) + } + + @Test + fun `toggleTagForBooks assigns and removes tags for sanitized book ids`() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + + viewModel.toggleTagForBooks("favorite", setOf(" book ", "", "other"), assign = true) + advanceUntilIdle() + coVerify { anyConstructed().assignTagToBook("book", "favorite") } + coVerify { anyConstructed().assignTagToBook("other", "favorite") } + + viewModel.toggleTagForBooks("favorite", setOf("book"), assign = false) + advanceUntilIdle() + coVerify { anyConstructed().removeTagFromBook("book", "favorite") } + + viewModel.toggleTagForBooks(" ", setOf("book"), assign = true) + advanceUntilIdle() + coVerify(exactly = 0) { anyConstructed().assignTagToBook("book", " ") } + } + + @Test + fun `rename and delete shelf dialogs store their target and dismiss cleanly`() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + + viewModel.showRenameShelfDialog("manual") + val renameState = viewModel.uiState.first { it.showRenameShelfDialogFor == "manual" } + assertEquals("manual", renameState.showRenameShelfDialogFor) + + viewModel.dismissRenameShelfDialog() + viewModel.uiState.first { it.showRenameShelfDialogFor == null } + + viewModel.showDeleteShelfDialog("manual") + val deleteState = viewModel.uiState.first { it.showDeleteShelfDialogFor == "manual" } + assertEquals("manual", deleteState.showDeleteShelfDialogFor) + + viewModel.dismissDeleteShelfDialog() + val dismissedState = viewModel.uiState.first { it.showDeleteShelfDialogFor == null } + assertEquals(null, dismissedState.showDeleteShelfDialogFor) + } + + @Test + fun `shelf selection only allows manual mutable shelves and toggles by click`() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + shelvesFlow.value = listOf(shelfEntity("manual", "Manual")) + val manualShelf = viewModel.uiState.first { it.shelves.any { shelf -> shelf.id == "manual" } } + .shelves.first { it.id == "manual" } + val tagShelf = Shelf("tag_favorite", "Favorite", ShelfType.TAG, books = emptyList()) + + viewModel.onShelfLongPress(tagShelf) + assertTrue(viewModel.uiState.value.contextualActionShelfIds.isEmpty()) + + viewModel.onShelfLongPress(manualShelf) + val selectedState = viewModel.uiState.first { it.contextualActionShelfIds == setOf("manual") } + assertEquals(setOf("manual"), selectedState.contextualActionShelfIds) + + viewModel.onShelfClick(manualShelf) + val clearedState = viewModel.uiState.first { it.contextualActionShelfIds.isEmpty() } + assertTrue(clearedState.contextualActionShelfIds.isEmpty()) + } + + @Test + fun `onShelfClick navigates when shelf contextual mode is inactive`() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + shelvesFlow.value = listOf(shelfEntity("manual", "Manual")) + val manualShelf = viewModel.uiState.first { it.shelves.any { shelf -> shelf.id == "manual" } } + .shelves.first { it.id == "manual" } + + viewModel.onShelfClick(manualShelf) + val state = viewModel.uiState.first { + it.viewingShelfId == "manual" && it.mainScreenStartPage == 1 && it.libraryScreenStartPage == 1 + } + + assertEquals("manual", state.viewingShelfId) + } + + @Test + fun `shelf navigation sets library landing state and can be cleared`() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + shelvesFlow.value = listOf(shelfEntity("manual", "Manual")) + viewModel.uiState.first { it.shelves.any { shelf -> shelf.id == "manual" } } + + viewModel.navigateToShelf("manual") + val shelfState = viewModel.uiState.first { + it.viewingShelfId == "manual" && it.mainScreenStartPage == 1 && it.libraryScreenStartPage == 1 + } + assertEquals("manual", shelfState.viewingShelfId) + + viewModel.unselectShelf() + val clearedState = viewModel.uiState.first { it.viewingShelfId == null } + assertEquals(null, clearedState.viewingShelfId) + } + + @Test + fun `clearShelfContextualAction clears selected shelves`() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + shelvesFlow.value = listOf(shelfEntity("manual", "Manual")) + val manualShelf = viewModel.uiState.first { it.shelves.any { shelf -> shelf.id == "manual" } } + .shelves.first { it.id == "manual" } + + viewModel.onShelfLongPress(manualShelf) + viewModel.uiState.first { it.contextualActionShelfIds == setOf("manual") } + viewModel.clearShelfContextualAction() + val state = viewModel.uiState.first { it.contextualActionShelfIds.isEmpty() } + + assertTrue(state.contextualActionShelfIds.isEmpty()) + } + + @Test + fun `deleteSelectedShelves deletes only mutable selected shelves and clears selection`() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + shelvesFlow.value = listOf( + shelfEntity("manual", "Manual"), + shelfEntity("other", "Other") + ) + val shelves = viewModel.uiState.first { state -> + state.shelves.any { it.id == "manual" } && state.shelves.any { it.id == "unshelved" } + }.shelves + val manual = shelves.first { it.id == "manual" } + val unshelved = shelves.first { it.id == "unshelved" } + + viewModel.onShelfLongPress(manual) + viewModel.onShelfLongPress(unshelved) + viewModel.uiState.first { it.contextualActionShelfIds == setOf("manual") } + viewModel.deleteSelectedShelves() + advanceUntilIdle() + + coVerify { anyConstructed().deleteShelf("manual") } + coVerify(exactly = 0) { anyConstructed().deleteShelf("unshelved") } + val clearedState = viewModel.uiState.first { it.contextualActionShelfIds.isEmpty() } + assertTrue(clearedState.contextualActionShelfIds.isEmpty()) + } + + @Test + fun `add books mode resets selection and tracks source changes`() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + val shelved = recentFile("shelved") + val loose = recentFile("loose") + recentFilesFlow.value = listOf(shelved, loose) + shelvesFlow.value = listOf(shelfEntity("manual", "Manual")) + shelfRefsFlow.value = listOf(BookShelfCrossRef(bookId = "shelved", shelfId = "manual", addedAt = 1L)) + viewModel.uiState.first { it.shelves.any { shelf -> shelf.id == "manual" } } + + viewModel.navigateToShelf("manual") + viewModel.showAddBooksToShelf() + val addModeState = viewModel.uiState.first { + it.isAddingBooksToShelf && it.booksAvailableForAdding.bookIds() == setOf("loose") + } + assertEquals(AddBooksSource.UNSHELVED, addModeState.addBooksSource) + + viewModel.setAddBooksSource(AddBooksSource.ALL_BOOKS) + viewModel.toggleBookSelectionForAdding("loose") + val selectedState = viewModel.uiState.first { + it.addBooksSource == AddBooksSource.ALL_BOOKS && it.booksSelectedForAdding == setOf("loose") + } + assertEquals(setOf("loose"), selectedState.booksSelectedForAdding) + verify { mockEditor.putString("add_books_source", AddBooksSource.ALL_BOOKS.name) } + + viewModel.dismissAddBooksToShelf() + val dismissedState = viewModel.uiState.first { + !it.isAddingBooksToShelf && it.booksSelectedForAdding.isEmpty() + } + assertFalse(dismissedState.isAddingBooksToShelf) + } + + @Test + fun `toggleBookSelectionForAdding toggles individual books`() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + + viewModel.toggleBookSelectionForAdding("loose") + val selectedState = viewModel.uiState.first { it.booksSelectedForAdding == setOf("loose") } + assertEquals(setOf("loose"), selectedState.booksSelectedForAdding) + + viewModel.toggleBookSelectionForAdding("loose") + val clearedState = viewModel.uiState.first { it.booksSelectedForAdding.isEmpty() } + assertTrue(clearedState.booksSelectedForAdding.isEmpty()) + } + + @Test + fun `addBooksToShelf saves selected books for mutable shelves and exits add mode`() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + val loose = recentFile("loose") + recentFilesFlow.value = listOf(loose) + shelvesFlow.value = listOf(shelfEntity("manual", "Manual")) + viewModel.uiState.first { it.shelves.any { shelf -> shelf.id == "manual" } } + + viewModel.navigateToShelf("manual") + viewModel.showAddBooksToShelf() + viewModel.toggleBookSelectionForAdding("loose") + viewModel.addBooksToShelf("manual") + advanceUntilIdle() + + coVerify { anyConstructed().addBooksToShelf("manual", listOf("loose")) } + val state = viewModel.uiState.first { + !it.isAddingBooksToShelf && it.booksSelectedForAdding.isEmpty() + } + assertFalse(state.isAddingBooksToShelf) + assertTrue(state.booksSelectedForAdding.isEmpty()) + } + + @Test + fun `addBooksToShelf dismisses add mode when target shelf is not mutable`() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + + viewModel.toggleBookSelectionForAdding("loose") + viewModel.addBooksToShelf("unshelved") + val state = viewModel.uiState.first { + !it.isAddingBooksToShelf && it.booksSelectedForAdding.isEmpty() + } + + assertFalse(state.isAddingBooksToShelf) + assertTrue(state.booksSelectedForAdding.isEmpty()) + coVerify(exactly = 0) { anyConstructed().addBooksToShelf("unshelved", any()) } + } + + @Test + fun `removeContextualItemsFromShelf removes selected books from the current mutable shelf`() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + val book = recentFile("book") + recentFilesFlow.value = listOf(book) + shelvesFlow.value = listOf(shelfEntity("manual", "Manual")) + shelfRefsFlow.value = listOf(BookShelfCrossRef(bookId = "book", shelfId = "manual", addedAt = 1L)) + viewModel.uiState.first { it.shelves.any { shelf -> shelf.id == "manual" } } + + viewModel.navigateToShelf("manual") + viewModel.onRecentItemLongPress(book) + viewModel.uiState.first { it.contextualActionItems.bookIds() == setOf("book") } + viewModel.removeContextualItemsFromShelf() + advanceUntilIdle() + + coVerify { anyConstructed().removeBooksFromShelf("manual", listOf("book")) } + val clearedState = viewModel.uiState.first { it.contextualActionItems.isEmpty() } + assertTrue(clearedState.contextualActionItems.isEmpty()) + } + + @Test + fun `app appearance settings persist contrast brightness seed and custom themes`() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + val color = Color(0xFF006C4C) + val theme = CustomAppTheme(id = "forest", name = "Forest", seedColor = color) + + viewModel.setAppContrastOption(AppContrastOption.HIGH) + viewModel.setAppTextDimFactorLight(0.75f) + viewModel.setAppTextDimFactorDark(0.65f) + viewModel.addCustomAppTheme(theme) + val themedState = viewModel.uiState.first { + it.appContrastOption == AppContrastOption.HIGH && + it.appTextDimFactorLight == 0.75f && + it.appTextDimFactorDark == 0.65f && + it.customAppThemes == listOf(theme) && + it.appSeedColor == color + } + + assertEquals(AppContrastOption.HIGH, themedState.appContrastOption) + assertEquals(listOf(theme), themedState.customAppThemes) + verify { mockEditor.putString("app_contrast_option", AppContrastOption.HIGH.name) } + verify { mockEditor.putFloat("app_text_dim_factor_light", 0.75f) } + verify { mockEditor.putFloat("app_text_dim_factor_dark", 0.65f) } + verify { mockEditor.putInt("app_seed_color", color.toArgb()) } + + viewModel.deleteCustomAppTheme(theme.id) + val deletedState = viewModel.uiState.first { + it.customAppThemes.isEmpty() && it.appSeedColor == null + } + assertTrue(deletedState.customAppThemes.isEmpty()) + assertEquals(null, deletedState.appSeedColor) + verify { mockEditor.remove("app_seed_color") } + } + + @Test + fun `setAppSeedColor can clear a selected seed color`() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + val color = Color(0xFF123456) + + viewModel.setAppSeedColor(color) + viewModel.uiState.first { it.appSeedColor == color } + viewModel.setAppSeedColor(null) + val clearedState = viewModel.uiState.first { it.appSeedColor == null } + + assertEquals(null, clearedState.appSeedColor) + verify { mockEditor.putInt("app_seed_color", color.toArgb()) } + verify { mockEditor.remove("app_seed_color") } + } + + @Test + fun `addCustomAppTheme replaces existing theme with the same id`() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + val first = CustomAppTheme(id = "theme", name = "First", seedColor = Color(0xFF123456)) + val second = CustomAppTheme(id = "theme", name = "Second", seedColor = Color(0xFF654321)) + + viewModel.addCustomAppTheme(first) + viewModel.uiState.first { it.customAppThemes == listOf(first) } + viewModel.addCustomAppTheme(second) + val state = viewModel.uiState.first { it.customAppThemes == listOf(second) } + + assertEquals(listOf(second), state.customAppThemes) + assertEquals(second.seedColor, state.appSeedColor) + } + @Test fun `banner message logic works correctly`() = runTest { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { @@ -159,11 +924,46 @@ class MainViewModelTest { viewModel.showBanner("Test Message", isError = true) - val currentBanner = viewModel.uiState.value.bannerMessage + val currentBanner = viewModel.uiState.first { + it.bannerMessage?.message == "Test Message" + }.bannerMessage assertEquals("Test Message", currentBanner?.message) assertTrue(currentBanner?.isError == true) viewModel.bannerMessageShown() - assertEquals(null, viewModel.uiState.value.bannerMessage) + val clearedState = viewModel.uiState.first { it.bannerMessage == null } + assertEquals(null, clearedState.bannerMessage) } -} \ No newline at end of file + + private fun recentFile( + id: String, + type: FileType = FileType.EPUB, + isRecent: Boolean = true, + title: String? = null, + timestamp: Long = 1L + ) = RecentFileItem( + bookId = id, + uriString = "content://$id", + type = type, + displayName = "$id.${type.name.lowercase()}", + timestamp = timestamp, + isRecent = isRecent, + title = title + ) + + private fun mockUri(uriString: String): Uri { + return mockk().also { uri -> + every { uri.toString() } returns uriString + every { uri.scheme } returns uriString.substringBefore(":", "") + } + } + + private fun shelfEntity(id: String, name: String) = ShelfEntity( + id = id, + name = name, + createdAt = 1L, + updatedAt = 1L + ) + + private fun Iterable.bookIds(): Set = mapTo(mutableSetOf()) { it.bookId } +} diff --git a/app/src/test/java/com/aryan/reader/NonReaderScreenModelsTest.kt b/app/src/test/java/com/aryan/reader/NonReaderScreenModelsTest.kt new file mode 100644 index 0000000..62aa50b --- /dev/null +++ b/app/src/test/java/com/aryan/reader/NonReaderScreenModelsTest.kt @@ -0,0 +1,147 @@ +package com.aryan.reader + +import com.aryan.reader.data.RecentFileItem +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class NonReaderScreenModelsTest { + + @Test + fun `home model treats open tabs as non-empty content`() { + val tab = recentFile("tab") + + val model = ReaderScreenState( + isTabsEnabled = true, + openTabs = listOf(tab), + rawLibraryFiles = listOf(tab) + ).toHomeScreenModel() + + assertFalse(model.isEmpty) + assertTrue(model.isLibraryEmpty) + assertEquals(listOf(tab), model.openTabs) + } + + @Test + fun `home model reports empty when there are no recents or open tabs`() { + val archivedBook = recentFile("archived", isRecent = false) + + val model = ReaderScreenState( + recentFiles = emptyList(), + rawLibraryFiles = listOf(archivedBook) + ).toHomeScreenModel() + + assertTrue(model.isEmpty) + assertTrue(model.isLibraryEmpty) + } + + @Test + fun `home model ignores open tabs for empty state when tabs are disabled`() { + val tab = recentFile("tab") + + val model = ReaderScreenState( + isTabsEnabled = false, + openTabs = listOf(tab), + recentFiles = emptyList() + ).toHomeScreenModel() + + assertTrue(model.isEmpty) + assertEquals(listOf(tab), model.openTabs) + } + + @Test + fun `home model exposes contextual selection and device limit state`() { + val selected = recentFile("selected") + val deviceState = DeviceLimitReachedState(isLimitReached = true) + + val model = ReaderScreenState( + recentFiles = listOf(selected), + contextualActionItems = setOf(selected), + deviceLimitState = deviceState + ).toHomeScreenModel() + + assertTrue(model.isContextualModeActive) + assertEquals(setOf(selected), model.selectedItems) + assertEquals(deviceState, model.deviceLimitState) + assertFalse(model.isEmpty) + assertFalse(model.isLibraryEmpty) + } + + @Test + fun `library model exposes contextual and shelf selection state`() { + val folderBook = recentFile("folder", sourceFolderUri = "content://folder") + val shelf = Shelf( + id = "manual", + name = "Manual", + type = ShelfType.MANUAL, + books = listOf(folderBook) + ) + + val model = ReaderScreenState( + contextualActionItems = setOf(folderBook), + contextualActionShelfIds = setOf(shelf.id), + sortOrder = SortOrder.TITLE_ASC, + shelves = listOf(shelf), + rawLibraryFiles = listOf(folderBook), + searchQuery = "folder", + isSearchActive = true + ).toLibraryScreenModel() + + assertTrue(model.isContextualModeActive) + assertTrue(model.isShelfContextualModeActive) + assertTrue(model.containsFolderItemsInSelection) + assertEquals(setOf(folderBook), model.selectedItems) + assertEquals(setOf(shelf.id), model.selectedShelves) + assertEquals(SortOrder.TITLE_ASC, model.sortOrder) + assertEquals("folder", model.searchQuery) + assertTrue(model.isSearchActive) + } + + @Test + fun `library model reports inactive contextual states for normal browsing`() { + val book = recentFile("book") + + val model = ReaderScreenState( + allRecentFiles = listOf(book), + rawLibraryFiles = listOf(book), + sortOrder = SortOrder.RECENT + ).toLibraryScreenModel() + + assertFalse(model.isContextualModeActive) + assertFalse(model.isShelfContextualModeActive) + assertFalse(model.containsFolderItemsInSelection) + assertTrue(model.selectedItems.isEmpty()) + assertTrue(model.selectedShelves.isEmpty()) + assertEquals(listOf(book), model.rawLibraryFiles) + assertEquals(SortOrder.RECENT, model.sortOrder) + } + + @Test + fun `library model distinguishes folder and non-folder selections`() { + val localBook = recentFile("local") + + val model = ReaderScreenState( + contextualActionItems = setOf(localBook), + rawLibraryFiles = listOf(localBook) + ).toLibraryScreenModel() + + assertTrue(model.isContextualModeActive) + assertFalse(model.containsFolderItemsInSelection) + assertEquals(setOf(localBook), model.selectedItems) + } + + private fun recentFile( + id: String, + isRecent: Boolean = true, + sourceFolderUri: String? = null + ) = RecentFileItem( + bookId = id, + uriString = "content://$id", + type = FileType.EPUB, + displayName = "$id.epub", + timestamp = 1L, + isRecent = isRecent, + sourceFolderUri = sourceFolderUri + ) +} diff --git a/app/src/test/java/com/aryan/reader/TtsReplacementChunkTest.kt b/app/src/test/java/com/aryan/reader/TtsReplacementChunkTest.kt new file mode 100644 index 0000000..e0c8a23 --- /dev/null +++ b/app/src/test/java/com/aryan/reader/TtsReplacementChunkTest.kt @@ -0,0 +1,46 @@ +package com.aryan.reader + +import com.aryan.reader.paginatedreader.TtsChunk +import com.aryan.reader.shared.ReaderTtsReplacementPreferences +import com.aryan.reader.shared.ReaderTtsReplacementRule +import org.junit.Assert.assertEquals +import org.junit.Test + +class TtsReplacementChunkTest { + @Test + fun `tts chunk spoken text falls back to original text`() { + val chunk = TtsChunk( + text = "Dr. Smith", + sourceCfi = "epubcfi(/6/2)", + startOffsetInSource = 12 + ) + + assertEquals("Dr. Smith", chunk.spokenText) + } + + @Test + fun `chunk preparation keeps original text and writes spoken text`() { + val preferences = ReaderTtsReplacementPreferences( + globalRules = listOf( + ReaderTtsReplacementRule( + id = "dr", + from = "Dr.", + to = "Doctor", + wholeWord = false + ) + ) + ) + val chunk = TtsChunk( + text = "Dr. Smith", + sourceCfi = "epubcfi(/6/2)", + startOffsetInSource = 12 + ) + + val prepared = listOf(chunk).withTtsReplacements(preferences, "book").single() + + assertEquals("Dr. Smith", prepared.text) + assertEquals("Doctor Smith", prepared.spokenText) + assertEquals("epubcfi(/6/2)", prepared.sourceCfi) + assertEquals(12, prepared.startOffsetInSource) + } +} diff --git a/app/src/test/java/com/aryan/reader/data/FolderBookMetadataTest.kt b/app/src/test/java/com/aryan/reader/data/FolderBookMetadataTest.kt new file mode 100644 index 0000000..bd5009a --- /dev/null +++ b/app/src/test/java/com/aryan/reader/data/FolderBookMetadataTest.kt @@ -0,0 +1,90 @@ +package com.aryan.reader.data + +import com.aryan.reader.FileType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class FolderBookMetadataTest { + + @Test + fun `metadata JSON round trips nullable reader progress fields`() { + val metadata = FolderBookMetadata( + bookId = "book-1", + title = "Title", + author = null, + displayName = "Title.epub", + type = "EPUB", + lastChapterIndex = 4, + lastPage = null, + lastPositionCfi = "/4/2:10", + progressPercentage = 42.5f, + isRecent = false, + lastModifiedTimestamp = 1234L, + bookmarksJson = """[{"chapter":4}]""", + locatorBlockIndex = 99, + locatorCharOffset = null, + customName = "Custom", + highlightsJson = """[{"id":"h1"}]""" + ) + + val decoded = FolderBookMetadata.fromJsonString(metadata.toJsonString()) + + assertEquals(metadata.copy(author = null, lastPage = null, locatorCharOffset = null), decoded) + } + + @Test + fun `fromJsonString applies legacy defaults for missing optional fields`() { + val decoded = FolderBookMetadata.fromJsonString("""{"bookId":"legacy"}""") + + assertEquals("legacy", decoded.bookId) + assertEquals("Unknown", decoded.displayName) + assertEquals("PDF", decoded.type) + assertEquals(0f, decoded.progressPercentage) + assertTrue(decoded.isRecent) + assertEquals(0L, decoded.lastModifiedTimestamp) + assertNull(decoded.title) + assertNull(decoded.lastChapterIndex) + assertNull(decoded.locatorBlockIndex) + } + + @Test + fun `toRecentFileItem maps metadata and falls back to EPUB for unknown type`() { + val metadata = FolderBookMetadata( + bookId = "book-2", + title = "Remote Title", + author = "Author", + displayName = "Remote.bin", + type = "NOT_A_TYPE", + lastChapterIndex = 2, + lastPage = 12, + lastPositionCfi = "/6", + progressPercentage = 75f, + isRecent = true, + lastModifiedTimestamp = 500L, + bookmarksJson = "bookmarks", + locatorBlockIndex = 7, + locatorCharOffset = 8, + customName = "Shelf Name", + highlightsJson = "highlights" + ) + + val item = metadata.toRecentFileItem( + uriString = "content://book", + coverPath = "/covers/book.png", + sourceFolderUri = "content://folder" + ) + + assertEquals("book-2", item.bookId) + assertEquals(FileType.EPUB, item.type) + assertEquals("Remote Title", item.title) + assertEquals("Author", item.author) + assertEquals(12, item.lastPage) + assertEquals(7, item.locatorBlockIndex) + assertEquals(8, item.locatorCharOffset) + assertEquals("content://folder", item.sourceFolderUri) + assertEquals("Shelf Name", item.customName) + assertEquals("highlights", item.highlightsJson) + } +} diff --git a/app/src/test/java/com/aryan/reader/data/RecentFileDaoReadingPositionTest.kt b/app/src/test/java/com/aryan/reader/data/RecentFileDaoReadingPositionTest.kt new file mode 100644 index 0000000..bfc810b --- /dev/null +++ b/app/src/test/java/com/aryan/reader/data/RecentFileDaoReadingPositionTest.kt @@ -0,0 +1,138 @@ +package com.aryan.reader.data + +import androidx.room.Room +import com.aryan.reader.FileType +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class RecentFileDaoReadingPositionTest { + + private lateinit var db: AppDatabase + private lateinit var dao: RecentFileDao + + @Before + fun setUp() { + db = Room.inMemoryDatabaseBuilder( + RuntimeEnvironment.getApplication(), + AppDatabase::class.java + ).allowMainThreadQueries().build() + dao = db.recentFileDao() + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun `updateEpubReadingPosition persists cfi locator progress and timestamps`() = runTest { + dao.insertOrUpdateFile(recentFileEntity()) + + dao.updateEpubReadingPosition( + bookId = "book-1", + cfi = "/4/2/6:33", + chapterIndex = 7, + blockIndex = 42, + charOffset = 33, + progress = 58.5f, + timestamp = 9_000L + ) + + val saved = dao.getFileByUri("content://books/one")!! + assertEquals("/4/2/6:33", saved.lastPositionCfi) + assertEquals(7, saved.lastChapterIndex) + assertEquals(42, saved.locatorBlockIndex) + assertEquals(33, saved.locatorCharOffset) + assertEquals(58.5f, saved.progressPercentage) + assertEquals(9_000L, saved.timestamp) + assertEquals(9_000L, saved.lastModifiedTimestamp) + } + + @Test + fun `updateEpubReadingPosition can persist locator when webview cfi is unavailable`() = runTest { + dao.insertOrUpdateFile(recentFileEntity(lastPositionCfi = "/old:1")) + + dao.updateEpubReadingPosition( + bookId = "book-1", + cfi = null, + chapterIndex = 2, + blockIndex = 9, + charOffset = 0, + progress = 12f, + timestamp = 2_000L + ) + + val saved = dao.getFileByBookId("book-1")!! + assertNull(saved.lastPositionCfi) + assertEquals(2, saved.lastChapterIndex) + assertEquals(9, saved.locatorBlockIndex) + assertEquals(0, saved.locatorCharOffset) + assertEquals(12f, saved.progressPercentage) + } + + @Test + fun `recent file summary exposes persisted cfi and locator fields for reader restore`() = runTest { + dao.insertOrUpdateFile(recentFileEntity()) + dao.updateEpubReadingPosition( + bookId = "book-1", + cfi = "/6/4:12", + chapterIndex = 3, + blockIndex = 21, + charOffset = 12, + progress = 44f, + timestamp = 3_000L + ) + + val item = dao.getRecentFiles().first().single().toRecentFileItem() + + assertEquals("/6/4:12", item.lastPositionCfi) + assertEquals(3, item.lastChapterIndex) + assertEquals(21, item.locatorBlockIndex) + assertEquals(12, item.locatorCharOffset) + assertEquals(44f, item.progressPercentage) + assertTrue(item.isRecent) + } + + private fun recentFileEntity(lastPositionCfi: String? = null): RecentFileEntity { + return RecentFileEntity( + bookId = "book-1", + uriString = "content://books/one", + type = FileType.EPUB, + displayName = "One.epub", + timestamp = 1_000L, + coverImagePath = null, + title = "One", + author = "Author", + lastChapterIndex = null, + lastPage = null, + lastPositionCfi = lastPositionCfi, + progressPercentage = null, + isRecent = true, + isAvailable = true, + lastModifiedTimestamp = 1_000L, + isDeleted = false, + locatorBlockIndex = null, + locatorCharOffset = null, + bookmarks = null, + sourceFolderUri = null, + isReflowPreferred = false, + customName = null, + highlights = null, + fileSize = 123L, + seriesName = null, + seriesIndex = null, + description = null, + folderTextMetadataParsed = false + ) + } +} diff --git a/app/src/test/java/com/aryan/reader/data/RecentFileItemReadingPositionMappingTest.kt b/app/src/test/java/com/aryan/reader/data/RecentFileItemReadingPositionMappingTest.kt new file mode 100644 index 0000000..8a72724 --- /dev/null +++ b/app/src/test/java/com/aryan/reader/data/RecentFileItemReadingPositionMappingTest.kt @@ -0,0 +1,54 @@ +package com.aryan.reader.data + +import com.aryan.reader.FileType +import org.junit.Assert.assertEquals +import org.junit.Test + +class RecentFileItemReadingPositionMappingTest { + + @Test + fun `recent file entity mapping preserves epub cfi locator and progress fields`() { + val item = recentFileItem() + + val roundTripped = item.toRecentFileEntity().toRecentFileItem() + + assertEquals(item.lastPositionCfi, roundTripped.lastPositionCfi) + assertEquals(item.lastChapterIndex, roundTripped.lastChapterIndex) + assertEquals(item.locatorBlockIndex, roundTripped.locatorBlockIndex) + assertEquals(item.locatorCharOffset, roundTripped.locatorCharOffset) + assertEquals(item.progressPercentage, roundTripped.progressPercentage) + } + + @Test + fun `cloud metadata mapping preserves epub cfi locator and progress fields`() { + val item = recentFileItem() + + val roundTripped = item.toBookMetadata().toRecentFileItem() + + assertEquals(item.lastPositionCfi, roundTripped.lastPositionCfi) + assertEquals(item.lastChapterIndex, roundTripped.lastChapterIndex) + assertEquals(item.locatorBlockIndex, roundTripped.locatorBlockIndex) + assertEquals(item.locatorCharOffset, roundTripped.locatorCharOffset) + assertEquals(item.progressPercentage, roundTripped.progressPercentage) + } + + private fun recentFileItem(): RecentFileItem { + return RecentFileItem( + bookId = "book-1", + uriString = "content://books/one", + type = FileType.EPUB, + displayName = "One.epub", + timestamp = 1_000L, + title = "One", + author = "Author", + lastChapterIndex = 4, + lastPositionCfi = "/4/2/6:88", + locatorBlockIndex = 30, + locatorCharOffset = 88, + progressPercentage = 61.5f, + lastModifiedTimestamp = 2_000L, + bookmarksJson = """[{"cfi":"/4/2"}]""", + highlightsJson = """[{"cfi":"/4/2/6:88"}]""" + ) + } +} diff --git a/app/src/test/java/com/aryan/reader/data/RecentFilesRepositoryReadingPositionMergeTest.kt b/app/src/test/java/com/aryan/reader/data/RecentFilesRepositoryReadingPositionMergeTest.kt new file mode 100644 index 0000000..e210980 --- /dev/null +++ b/app/src/test/java/com/aryan/reader/data/RecentFilesRepositoryReadingPositionMergeTest.kt @@ -0,0 +1,148 @@ +package com.aryan.reader.data + +import android.content.Context +import com.aryan.reader.FileType +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.slot +import io.mockk.unmockkObject +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import java.io.File + +class RecentFilesRepositoryReadingPositionMergeTest { + + private lateinit var context: Context + private lateinit var recentFileDao: RecentFileDao + private lateinit var repository: RecentFilesRepository + + @Before + fun setUp() { + val testRoot = File("build/test-tmp/RecentFilesRepositoryReadingPositionMergeTest/${System.nanoTime()}") + val filesDir = File(testRoot, "files").apply { mkdirs() } + val cacheDir = File(testRoot, "cache").apply { mkdirs() } + + context = mockk(relaxed = true) + every { context.applicationContext } returns context + every { context.filesDir } returns filesDir + every { context.cacheDir } returns cacheDir + + recentFileDao = mockk() + val shelfDao = mockk() + val tagDao = mockk() + val db = mockk() + every { db.recentFileDao() } returns recentFileDao + every { db.shelfDao() } returns shelfDao + every { db.tagDao() } returns tagDao + every { shelfDao.getAllActiveShelves() } returns flowOf(emptyList()) + every { shelfDao.getAllBookShelfCrossRefs() } returns flowOf(emptyList()) + every { tagDao.getAllTags() } returns flowOf(emptyList()) + every { tagDao.getAllBookTagCrossRefs() } returns flowOf(emptyList()) + + mockkObject(AppDatabase.Companion) + every { AppDatabase.getDatabase(any()) } returns db + + repository = RecentFilesRepository(context) + } + + @After + fun tearDown() { + unmockkObject(AppDatabase.Companion) + } + + @Test + fun `addRecentFile preserves existing reading position when incoming metadata omits it`() = runTest { + val inserted = slot() + coEvery { recentFileDao.getFileByBookId("book-1") } returns existingEntity() + coEvery { recentFileDao.insertOrUpdateFile(capture(inserted)) } just Runs + + repository.addRecentFile( + RecentFileItem( + bookId = "book-1", + uriString = "content://new", + type = FileType.EPUB, + displayName = "New.epub", + timestamp = 2_000L, + isRecent = true + ) + ) + + assertEquals("/4/2/6:44", inserted.captured.lastPositionCfi) + assertEquals(6, inserted.captured.lastChapterIndex) + assertEquals(24, inserted.captured.locatorBlockIndex) + assertEquals(44, inserted.captured.locatorCharOffset) + assertEquals(71.5f, inserted.captured.progressPercentage) + coVerify { recentFileDao.insertOrUpdateFile(any()) } + } + + @Test + fun `addRecentFile uses incoming reading position when newer metadata includes it`() = runTest { + val inserted = slot() + coEvery { recentFileDao.getFileByBookId("book-1") } returns existingEntity() + coEvery { recentFileDao.insertOrUpdateFile(capture(inserted)) } just Runs + + repository.addRecentFile( + RecentFileItem( + bookId = "book-1", + uriString = "content://new", + type = FileType.EPUB, + displayName = "New.epub", + timestamp = 2_000L, + lastChapterIndex = 8, + lastPositionCfi = "/6/4:12", + locatorBlockIndex = 31, + locatorCharOffset = 12, + progressPercentage = 82f, + isRecent = true + ) + ) + + assertEquals("/6/4:12", inserted.captured.lastPositionCfi) + assertEquals(8, inserted.captured.lastChapterIndex) + assertEquals(31, inserted.captured.locatorBlockIndex) + assertEquals(12, inserted.captured.locatorCharOffset) + assertEquals(82f, inserted.captured.progressPercentage) + } + + private fun existingEntity(): RecentFileEntity { + return RecentFileEntity( + bookId = "book-1", + uriString = "content://old", + type = FileType.EPUB, + displayName = "Old.epub", + timestamp = 1_000L, + coverImagePath = "/covers/old.png", + title = "Old", + author = "Author", + lastChapterIndex = 6, + lastPage = null, + lastPositionCfi = "/4/2/6:44", + progressPercentage = 71.5f, + isRecent = true, + isAvailable = true, + lastModifiedTimestamp = 1_500L, + isDeleted = false, + locatorBlockIndex = 24, + locatorCharOffset = 44, + bookmarks = "bookmarks", + sourceFolderUri = "content://folder", + isReflowPreferred = false, + customName = "Custom", + highlights = "highlights", + fileSize = 123L, + seriesName = "Series", + seriesIndex = 1.0, + description = "Description", + folderTextMetadataParsed = true + ) + } +} diff --git a/app/src/test/java/com/aryan/reader/data/SmartCollectionEngineTest.kt b/app/src/test/java/com/aryan/reader/data/SmartCollectionEngineTest.kt new file mode 100644 index 0000000..ae64fb4 --- /dev/null +++ b/app/src/test/java/com/aryan/reader/data/SmartCollectionEngineTest.kt @@ -0,0 +1,143 @@ +package com.aryan.reader.data + +import com.aryan.reader.FileType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class SmartCollectionEngineTest { + + @Test + fun `definition JSON round trips and ignores unknown fields`() { + val definition = SmartCollectionDefinition( + matchAll = false, + rules = listOf( + SmartRule(SmartField.TITLE, SmartOperator.CONTAINS, "dune"), + SmartRule(SmartField.PROGRESS, SmartOperator.GREATER_THAN, "50") + ) + ) + + val encoded = SmartCollectionEngine.toJson(definition) + val decoded = SmartCollectionEngine.fromJson( + encoded.replaceFirst("{", """{"unknown":"kept-for-forward-compat",""") + ) + + assertEquals(definition, decoded) + } + + @Test + fun `fromJson returns null for blank malformed and incompatible payloads`() { + assertNull(SmartCollectionEngine.fromJson(null)) + assertNull(SmartCollectionEngine.fromJson(" ")) + assertNull(SmartCollectionEngine.fromJson("{not json")) + assertNull(SmartCollectionEngine.fromJson("""{"matchAll":true,"rules":[{"field":"NOPE"}]}""")) + } + + @Test + fun `matchAll requires every rule while matchAny accepts a single matching rule`() { + val book = book( + title = "Dune Messiah", + author = "Frank Herbert", + progressPercentage = 41f, + type = FileType.EPUB + ) + + val titleAndHighProgress = SmartCollectionDefinition( + matchAll = true, + rules = listOf( + SmartRule(SmartField.TITLE, SmartOperator.CONTAINS, "dune"), + SmartRule(SmartField.PROGRESS, SmartOperator.GREATER_THAN, "80") + ) + ) + val titleOrHighProgress = titleAndHighProgress.copy(matchAll = false) + + assertFalse(SmartCollectionEngine.evaluate(book, titleAndHighProgress)) + assertTrue(SmartCollectionEngine.evaluate(book, titleOrHighProgress)) + } + + @Test + fun `string folder file type and tag rules are case insensitive`() { + val book = book( + displayName = "fallback-name.pdf", + title = null, + author = "Ursula K. Le Guin", + sourceFolderUri = "content://library/Sci-Fi", + type = FileType.PDF, + tags = listOf( + TagEntity(id = "t1", name = "Classic Science Fiction", createdAt = 1L), + TagEntity(id = "t2", name = "Queued", createdAt = 2L) + ) + ) + + assertTrue( + SmartCollectionEngine.evaluate( + book, + SmartCollectionDefinition( + rules = listOf( + SmartRule(SmartField.TITLE, SmartOperator.EQUALS, "fallback-name.pdf"), + SmartRule(SmartField.AUTHOR, SmartOperator.CONTAINS, "le guin"), + SmartRule(SmartField.FOLDER, SmartOperator.CONTAINS, "SCI-FI"), + SmartRule(SmartField.FILE_TYPE, SmartOperator.EQUALS, "pdf"), + SmartRule(SmartField.TAG, SmartOperator.CONTAINS, "science") + ) + ) + ) + ) + } + + @Test + fun `numeric rules handle equals greater less missing progress and invalid values`() { + val startedBook = book(progressPercentage = 33.5f) + val missingProgressBook = book(progressPercentage = null) + + assertTrue(matchesProgress(startedBook, SmartOperator.EQUALS, "33.5")) + assertTrue(matchesProgress(startedBook, SmartOperator.GREATER_THAN, "33")) + assertTrue(matchesProgress(startedBook, SmartOperator.LESS_THAN, "34")) + assertFalse(matchesProgress(startedBook, SmartOperator.GREATER_THAN, "not-a-number")) + assertTrue(matchesProgress(missingProgressBook, SmartOperator.EQUALS, "0")) + } + + @Test + fun `empty definitions never match`() { + assertFalse(SmartCollectionEngine.evaluate(book(), SmartCollectionDefinition())) + } + + private fun matchesProgress( + book: RecentFileItem, + operator: SmartOperator, + value: String + ): Boolean { + return SmartCollectionEngine.evaluate( + book, + SmartCollectionDefinition( + rules = listOf(SmartRule(SmartField.PROGRESS, operator, value)) + ) + ) + } + + private fun book( + bookId: String = "book-id", + displayName: String = "display.epub", + title: String? = "Display", + author: String? = null, + progressPercentage: Float? = null, + sourceFolderUri: String? = null, + type: FileType = FileType.EPUB, + tags: List = emptyList() + ): RecentFileItem { + return RecentFileItem( + bookId = bookId, + uriString = "content://book/$bookId", + type = type, + displayName = displayName, + timestamp = 1L, + title = title, + author = author, + progressPercentage = progressPercentage, + sourceFolderUri = sourceFolderUri, + tags = tags + ) + } +} diff --git a/app/src/test/java/com/aryan/reader/epub/EpubParserUnitTest.kt b/app/src/test/java/com/aryan/reader/epub/EpubParserUnitTest.kt new file mode 100644 index 0000000..e643576 --- /dev/null +++ b/app/src/test/java/com/aryan/reader/epub/EpubParserUnitTest.kt @@ -0,0 +1,452 @@ +package com.aryan.reader.epub + +import android.content.Context +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.File +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +class EpubParserUnitTest { + + @get:Rule + val temp = TemporaryFolder() + + @Test + fun `createEpubBook parses metadata spine ncx toc page list css images and extracted files`() = runTest { + val cacheDir = temp.newFolder("cache") + val extractionDir = temp.newFolder("extract") + val parser = EpubParser(contextWithCache(cacheDir)) + + val book = parser.createEpubBook( + inputStream = ByteArrayInputStream(sampleEpubBytes()), + bookId = "book-id", + shouldUseToc = true, + originalBookNameHint = "fallback.epub", + parseContent = true, + extractionDirOverride = extractionDir + ) + + assertEquals("Sample/Book".asFileName(), book.fileName) + assertEquals("Sample/Book", book.title) + assertEquals("Jane Writer", book.author) + assertEquals("en", book.language) + assertEquals("Series Name", book.seriesName) + assertEquals(2.5, book.seriesIndex) + assertEquals("Long description", book.description) + assertEquals(extractionDir.absolutePath, book.extractionBasePath) + assertTrue(File(extractionDir, "OEBPS/chapters/chapter 2.xhtml").isFile) + + assertEquals(2, book.chapters.size) + assertEquals("NCX Chapter One", book.chapters[0].title) + assertEquals("OEBPS/chapters/chapter1.xhtml", book.chapters[0].htmlFilePath) + assertEquals(0, book.chapters[0].depth) + assertTrue(book.chapters[0].isInToc) + assertEquals("Nested Two", book.chapters[1].title) + assertEquals("OEBPS/chapters/chapter 2.xhtml", book.chapters[1].htmlFilePath) + assertEquals(1, book.chapters[1].depth) + assertTrue(book.chapters[1].plainTextContent.contains("Chapter Two")) + + assertEquals( + listOf( + EpubTocEntry("NCX Chapter One", "OEBPS/chapters/chapter1.xhtml", "start", 0), + EpubTocEntry("Nested Two", "OEBPS/chapters/chapter 2.xhtml", "top", 1) + ), + book.tableOfContents + ) + assertEquals(1, book.pageList.size) + assertEquals("7", book.pageList.single().value) + assertEquals("OEBPS/chapters/chapter 2.xhtml#page7", book.pageList.single().contentSrc) + assertEquals( + mapOf( + "OEBPS/styles/main.css" to "body { color: black; }", + "OEBPS/styles/extra.css" to "p { margin: 0; }" + ), + book.css + ) + assertEquals( + setOf("OEBPS/images/picture.jpg", "OEBPS/images/unlisted.png"), + book.images.map { it.absPath }.toSet() + ) + } + + @Test + fun `createEpubBook can parse metadata only without chapters css or images`() = runTest { + val cacheDir = temp.newFolder("cache-metadata") + val extractionDir = temp.newFolder("extract-metadata") + val parser = EpubParser(contextWithCache(cacheDir)) + + val book = parser.createEpubBook( + inputStream = ByteArrayInputStream(sampleEpubBytes()), + bookId = "book-id", + shouldUseToc = true, + originalBookNameHint = "fallback.epub", + parseContent = false, + extractionDirOverride = extractionDir + ) + + assertEquals("Sample/Book", book.title) + assertEquals(emptyList(), book.chapters) + assertEquals(emptyList(), book.images) + assertEquals(emptyMap(), book.css) + assertEquals(emptyList(), book.tableOfContents) + assertTrue(extractionDir.list().isNullOrEmpty()) + } + + @Test + fun `createEpubBook reuses active extraction cache on matching warm open`() = runTest { + val cacheDir = temp.newFolder("cache-warm-open") + val parser = EpubParser(contextWithCache(cacheDir)) + + val first = parser.createEpubBook( + inputStream = ByteArrayInputStream(sampleEpubBytes()), + bookId = "warm-book", + shouldUseToc = true, + originalBookNameHint = "warm.epub" + ) + val activeDir = ImportedFileCache.activeBookDir(contextWithCache(cacheDir), "warm-book") + File(activeDir, "sentinel.txt").writeText("still here") + + val second = parser.createEpubBook( + inputStream = ByteArrayInputStream(minimalEpubBytesWithoutOptionalMetadata()), + bookId = "warm-book", + shouldUseToc = true, + originalBookNameHint = "warm.epub" + ) + + assertEquals(first.title, second.title) + assertEquals(first.chapters.size, second.chapters.size) + assertTrue(File(activeDir, "sentinel.txt").isFile) + } + + @Test + fun `metadata only parse does not clear active extracted content`() = runTest { + val cacheDir = temp.newFolder("cache-metadata-preserve") + val context = contextWithCache(cacheDir) + val parser = EpubParser(context) + val activeDir = ImportedFileCache.ensureActiveBookDir(context, "metadata-book") + File(activeDir, "sentinel.txt").writeText("active") + + parser.createEpubBook( + inputStream = ByteArrayInputStream(sampleEpubBytes()), + bookId = "metadata-book", + parseContent = false, + originalBookNameHint = "metadata.epub" + ) + + assertTrue(File(activeDir, "sentinel.txt").isFile) + } + + @Test + fun `createEpubBook falls back to file hint author language and chapter titles when metadata and ncx are absent`() = runTest { + val parser = EpubParser(contextWithCache(temp.newFolder("cache-fallback"))) + val extractionDir = temp.newFolder("extract-fallback") + + val book = parser.createEpubBook( + inputStream = ByteArrayInputStream(minimalEpubBytesWithoutOptionalMetadata()), + bookId = "book-id", + shouldUseToc = false, + originalBookNameHint = "Original Name.epub", + parseContent = true, + extractionDirOverride = extractionDir + ) + + assertEquals("Original Name", book.title) + assertEquals("Unknown Author", book.author) + assertEquals("en", book.language) + assertEquals("HTML Heading", book.chapters.single().title) + assertEquals(0, book.chapters.single().depth) + assertTrue(book.chapters.single().isInToc) + assertEquals(emptyList(), book.tableOfContents) + } + + @Test + fun `createEpubBook throws parser exception for missing container rootfile or opf`() = runTest { + val parser = EpubParser(contextWithCache(temp.newFolder("cache-errors"))) + + val missingContainer = runCatching { + parser.createEpubBook(ByteArrayInputStream(zipBytes("OEBPS/content.opf" to "")), "id") + }.exceptionOrNull() + val missingOpf = runCatching { + parser.createEpubBook( + ByteArrayInputStream( + zipBytes( + "META-INF/container.xml" to """ + + """.trimIndent() + ) + ), + "id" + ) + }.exceptionOrNull() + + assertTrue(missingContainer is EpubParserException) + assertTrue(missingContainer!!.message!!.contains("container.xml")) + assertTrue(missingOpf is EpubParserException) + assertTrue(missingOpf!!.message!!.contains(".opf file missing")) + } + + @Test + fun `EpubXMLFileParser extracts first heading and preserves optional fragment`() { + val parser = EpubXMLFileParser( + fileRelativePath = "chapters/one.xhtml", + data = "

Chapter Title

Ignored

".toByteArray(), + fragmentId = "anchor" + ) + + val output = parser.parseForTitleAndPath() + + assertEquals("Chapter Title", output.title) + assertEquals("chapters/one.xhtml#anchor", output.effectiveHtmlPath) + } + + @Test + fun `xml helpers select tags attributes children and filename conversions`() { + val document = parseXMLFile( + """ + + AB + + + """.trimIndent().toByteArray() + )!! + + val firstItem = document.selectFirstTag("item")!! + + assertEquals("one", firstItem.getAttributeValue("id")) + assertEquals("A", firstItem.selectFirstChildTag("child")!!.textContent) + assertEquals(listOf("A", "B"), firstItem.selectChildTag("child").map { it.textContent }.toList()) + assertEquals("OPS_chapter_one.xhtml", "OPS/chapter/one.xhtml".asFileName()) + assertNull(document.selectFirstTag("missing")) + } + + @Test + fun `EpubXMLFileParser returns null title and unfragmented path when heading and fragment are absent`() { + val parser = EpubXMLFileParser( + fileRelativePath = "chapters/plain.xhtml", + data = "

No heading here.

".toByteArray() + ) + + val output = parser.parseForTitleAndPath() + + assertNull(output.title) + assertEquals("chapters/plain.xhtml", output.effectiveHtmlPath) + } + + @Test + fun `createEpubBook normalizes leading slash opf path from container`() = runTest { + val parser = EpubParser(contextWithCache(temp.newFolder("cache-leading-slash"))) + val extractionDir = temp.newFolder("extract-leading-slash") + + val book = parser.createEpubBook( + inputStream = ByteArrayInputStream( + zipBytes( + "META-INF/container.xml" to """ + + """.trimIndent(), + "OEBPS/content.opf" to """ + + Slash Book + + + + """.trimIndent(), + "OEBPS/chapter.xhtml" to "

Text

" + ) + ), + bookId = "book-id", + originalBookNameHint = "fallback.epub", + extractionDirOverride = extractionDir + ) + + assertEquals("Slash Book", book.title) + assertEquals("OEBPS/chapter.xhtml", book.chapters.single().htmlFilePath) + } + + @Test + fun `createEpubBook creates synthetic readable chapter for image spine items`() = runTest { + val parser = EpubParser(contextWithCache(temp.newFolder("cache-image-spine"))) + val extractionDir = temp.newFolder("extract-image-spine") + + val book = parser.createEpubBook( + inputStream = ByteArrayInputStream(imageSpineEpubBytes()), + bookId = "book-id", + shouldUseToc = false, + originalBookNameHint = "image-book.epub", + parseContent = true, + extractionDirOverride = extractionDir + ) + + val chapter = book.chapters.single() + assertEquals("Image", chapter.title) + assertEquals("OEBPS/images/page1.jpg", chapter.htmlFilePath) + assertEquals("[Image]", chapter.plainTextContent) + assertTrue(chapter.htmlContent.contains("One

") + val readable = epubBook( + extractionBasePath = chapterDir.absolutePath, + chapters = listOf(chapter("one.xhtml")) + ) + val missing = readable.copy(chapters = listOf(chapter("one.xhtml"), chapter("two.xhtml"))) + + assertTrue(readable.hasReadableExtractedContent()) + assertFalse(missing.hasReadableExtractedContent()) + } + + private fun contextWithCache(cacheDir: File): Context { + val context = mockk() + every { context.cacheDir } returns cacheDir + return context + } + + private fun sampleEpubBytes(): ByteArray = zipBytes( + "META-INF/container.xml" to """ + + + + """.trimIndent(), + "OEBPS/content.opf" to """ + + + Sample/Book + Jane Writer + en + Long description + + + + + + + + + + + + + + + + """.trimIndent(), + "OEBPS/toc.ncx" to """ + + + + NCX Chapter One + + + Nested Two + + + + + + + 7 + + + + + """.trimIndent(), + "OEBPS/chapters/chapter1.xhtml" to "

Ignored HTML Title

One

", + "OEBPS/chapters/chapter 2.xhtml" to "

Chapter Two

Two text

", + "OEBPS/styles/main.css" to "body { color: black; }", + "OEBPS/styles/extra.css" to "p { margin: 0; }", + "OEBPS/images/picture.jpg" to "not-real-image", + "OEBPS/images/unlisted.png" to "not-real-image" + ) + + private fun minimalEpubBytesWithoutOptionalMetadata(): ByteArray = zipBytes( + "META-INF/container.xml" to """ + + """.trimIndent(), + "OEBPS/content.opf" to """ + + + + + + + + """.trimIndent(), + "OEBPS/chapter.xhtml" to "

HTML Heading

Text

" + ) + + private fun imageSpineEpubBytes(): ByteArray = zipBytes( + "META-INF/container.xml" to """ + + """.trimIndent(), + "OEBPS/content.opf" to """ + + Image Book + + + + + + """.trimIndent(), + "OEBPS/images/page1.jpg" to "not-real-image" + ) + + private fun zipBytes(vararg entries: Pair): ByteArray { + val out = ByteArrayOutputStream() + ZipOutputStream(out).use { zip -> + entries.forEach { (name, content) -> + zip.putNextEntry(ZipEntry(name)) + zip.write(content.toByteArray(Charsets.UTF_8)) + zip.closeEntry() + } + } + return out.toByteArray() + } + + private fun epubBook( + extractionBasePath: String, + chapters: List = emptyList() + ): EpubBook = + EpubBook( + fileName = "book.epub", + title = "Book", + author = "Author", + language = "en", + coverImage = null, + chapters = chapters, + extractionBasePath = extractionBasePath + ) + + private fun chapter(path: String): EpubChapter = + EpubChapter( + chapterId = path, + absPath = path, + title = path, + htmlFilePath = path, + plainTextContent = "", + htmlContent = "" + ) +} diff --git a/app/src/test/java/com/aryan/reader/epub/ImportedFileCacheTest.kt b/app/src/test/java/com/aryan/reader/epub/ImportedFileCacheTest.kt new file mode 100644 index 0000000..b1c40c1 --- /dev/null +++ b/app/src/test/java/com/aryan/reader/epub/ImportedFileCacheTest.kt @@ -0,0 +1,119 @@ +package com.aryan.reader.epub + +import android.content.Context +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class ImportedFileCacheTest { + + @get:Rule + val temp = TemporaryFolder() + + @Test + fun `active directory names are sanitized stable and marked active`() { + val first = ImportedFileCache.activeBookDirName("Book:/One?*") + val second = ImportedFileCache.activeBookDirName("Book:/One?*") + + assertTrue(first.startsWith("imported_file_")) + assertFalse(first.contains(":")) + assertFalse(first.contains("/")) + assertFalse(first.contains("?")) + assertFalse(first.contains("*")) + assertTrue(ImportedFileCache.isActiveBookDir(first)) + assertFalse(ImportedFileCache.isTemporaryBookDir(first)) + assertTrue(first == second) + } + + @Test + fun `prepareDirectory clears stale contents before reusing directory`() { + val dir = temp.newFolder("active") + File(dir, "old.xhtml").writeText("stale") + + val prepared = ImportedFileCache.prepareDirectory(dir) + + assertTrue(prepared.isDirectory) + assertTrue(prepared.listFiles().isNullOrEmpty()) + } + + @Test + fun `ensureActiveBookDir preserves active contents and resetActiveBookDir clears them`() { + val context = contextWithCache(temp.newFolder("ensure-active-cache")) + val active = ImportedFileCache.ensureActiveBookDir(context, "Book") + File(active, "book_metadata.json").writeText("cached") + + val ensuredAgain = ImportedFileCache.ensureActiveBookDir(context, "Book") + + assertEquals("cached", File(ensuredAgain, "book_metadata.json").readText()) + + val reset = ImportedFileCache.resetActiveBookDir(context, "Book") + + assertTrue(reset.isDirectory) + assertTrue(reset.listFiles().isNullOrEmpty()) + } + + @Test + fun `temporary directory creation and targeted cleanup only remove matching book marker`() { + val context = contextWithCache(temp.newFolder("cache")) + val firstBookTemp = ImportedFileCache.createTemporaryBookDir(context, "Book One", "preview/import") + val secondBookTemp = ImportedFileCache.createTemporaryBookDir(context, "Book Two", "preview/import") + File(firstBookTemp, "file.txt").writeText("one") + File(secondBookTemp, "file.txt").writeText("two") + + ImportedFileCache.clearTemporaryBookDirs(context, "Book One") + + assertFalse(firstBookTemp.exists()) + assertTrue(secondBookTemp.exists()) + assertTrue(ImportedFileCache.isTemporaryBookDir(secondBookTemp.name)) + assertFalse(ImportedFileCache.isActiveBookDir(secondBookTemp.name)) + } + + @Test + fun `deleteStaleTemporaryBookDirs removes old temporary dirs and keeps fresh and active dirs`() { + val cacheDir = temp.newFolder("stale-cache") + val context = contextWithCache(cacheDir) + val staleTemp = ImportedFileCache.createTemporaryBookDir(context, "Book", "stale") + val freshTemp = ImportedFileCache.createTemporaryBookDir(context, "Book", "fresh") + val activeDir = ImportedFileCache.prepareActiveBookDir(context, "Book") + val now = 10_000L + staleTemp.setLastModified(1_000L) + freshTemp.setLastModified(9_500L) + activeDir.setLastModified(1_000L) + + ImportedFileCache.deleteStaleTemporaryBookDirs(context, olderThanMillis = 5_000L, nowMillis = now) + + assertFalse(staleTemp.exists()) + assertTrue(freshTemp.exists()) + assertTrue(activeDir.exists()) + } + + @Test + fun `clearBookCache removes active legacy and temporary cache directories`() { + val cacheDir = temp.newFolder("clear-book-cache") + val context = contextWithCache(cacheDir) + val active = ImportedFileCache.prepareActiveBookDir(context, "Book") + val legacy = File(cacheDir, "imported_file_Book").apply { mkdirs() } + val temporary = ImportedFileCache.createTemporaryBookDir(context, "Book", "tmp") + File(active, "active.txt").writeText("active") + File(legacy, "legacy.txt").writeText("legacy") + File(temporary, "temporary.txt").writeText("temporary") + + ImportedFileCache.clearBookCache(context, "Book") + + assertFalse(active.exists()) + assertFalse(legacy.exists()) + assertFalse(temporary.exists()) + } + + private fun contextWithCache(cacheDir: File): Context { + val context = mockk() + every { context.cacheDir } returns cacheDir + return context + } +} diff --git a/app/src/test/java/com/aryan/reader/epub/SingleFileImporterTest.kt b/app/src/test/java/com/aryan/reader/epub/SingleFileImporterTest.kt new file mode 100644 index 0000000..9e4e145 --- /dev/null +++ b/app/src/test/java/com/aryan/reader/epub/SingleFileImporterTest.kt @@ -0,0 +1,146 @@ +package com.aryan.reader.epub + +import android.content.Context +import com.aryan.reader.FileType +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.ByteArrayInputStream +import java.io.File + +class SingleFileImporterTest { + + @get:Rule + val temp = TemporaryFolder() + + @Test + fun `metadata-only import returns lightweight book for supported text formats`() = runTest { + val importer = SingleFileImporter(contextWithCache(temp.newFolder("metadata-cache"))) + + val book = importer.importSingleFile( + inputStream = ByteArrayInputStream("ignored".toByteArray()), + type = FileType.TXT, + originalBookNameHint = "Notes.txt", + bookId = "notes", + parseContent = false + ) + + assertEquals("Notes.txt", book.fileName) + assertEquals("Notes", book.title) + assertEquals("Unknown", book.author) + assertEquals("en", book.language) + assertEquals(emptyList(), book.chapters) + assertEquals("", book.extractionBasePath) + } + + @Test + fun `plain text import escapes html groups paragraphs and writes cached metadata`() = runTest { + val cache = temp.newFolder("txt-cache") + val importer = SingleFileImporter(contextWithCache(cache)) + + val book = importer.importSingleFile( + inputStream = ByteArrayInputStream("First \ncontinues\n\nSecond & final".toByteArray()), + type = FileType.TXT, + originalBookNameHint = "Plain.txt", + bookId = "plain-book" + ) + + assertEquals("Plain", book.title) + assertEquals(1, book.chapters.size) + assertEquals("Part 1", book.chapters.single().title) + assertTrue(book.chapters.single().plainTextContent.contains("First continues")) + assertTrue(File(book.extractionBasePath, "part_1.html").readText().contains("First <line>")) + assertTrue(File(book.extractionBasePath, "book_metadata.json").isFile) + } + + @Test + fun `plain text import reuses cached metadata before reading the stream`() = runTest { + val cache = temp.newFolder("txt-cache-reuse") + val importer = SingleFileImporter(contextWithCache(cache)) + + val first = importer.importSingleFile( + inputStream = ByteArrayInputStream("Cached content".toByteArray()), + type = FileType.TXT, + originalBookNameHint = "Cached.txt", + bookId = "cached-book" + ) + + val second = importer.importSingleFile( + inputStream = ByteArrayInputStream("Different content that should not be parsed".toByteArray()), + type = FileType.TXT, + originalBookNameHint = "Cached.txt", + bookId = "cached-book" + ) + + assertEquals(first.title, second.title) + assertEquals(first.chapters.single().plainTextContent, second.chapters.single().plainTextContent) + assertTrue(second.chapters.single().plainTextContent.contains("Cached content")) + } + + @Test + fun `html import extracts title author style skips scripts and splits page breaks`() = runTest { + val importer = SingleFileImporter(contextWithCache(temp.newFolder("html-cache"))) + val html = """ + + + HTML Title + + + + +

First page

+ + +

Second page

+ + + """.trimIndent() + + val book = importer.importSingleFile( + inputStream = ByteArrayInputStream(html.toByteArray()), + type = FileType.HTML, + originalBookNameHint = "fallback.html", + bookId = "html-book" + ) + + assertEquals("HTML Title", book.title) + assertEquals("HTML Author", book.author) + assertEquals(2, book.chapters.size) + assertEquals("HTML Title", book.chapters[0].title) + assertEquals("Page 2", book.chapters[1].title) + assertTrue(book.chapters[0].plainTextContent.contains("First page")) + assertTrue(book.chapters[1].plainTextContent.contains("Second page")) + assertFalse(File(book.extractionBasePath, "page_1.html").readText().contains("bad()")) + assertTrue(File(book.extractionBasePath, "page_1.html").readText().contains("p { color: red; }")) + } + + @Test + fun `csv txt wrapper imports as html table`() = runTest { + val importer = SingleFileImporter(contextWithCache(temp.newFolder("csv-cache"))) + + val book = importer.importSingleFile( + inputStream = ByteArrayInputStream("Name,Value\nA & B,".toByteArray()), + type = FileType.HTML, + originalBookNameHint = "data.csv.txt", + bookId = "csv-book" + ) + + val html = File(book.extractionBasePath, "page_1.html").readText() + assertEquals("data.csv", book.title) + assertTrue(html.contains("")) + assertTrue(html.contains("A & B")) + assertTrue(html.contains("<tag>")) + } + + private fun contextWithCache(cacheDir: File): Context { + val context = mockk() + every { context.cacheDir } returns cacheDir + return context + } +} diff --git a/app/src/test/java/com/aryan/reader/epubreader/EpubReaderBridgeAndControlsTest.kt b/app/src/test/java/com/aryan/reader/epubreader/EpubReaderBridgeAndControlsTest.kt new file mode 100644 index 0000000..e390f46 --- /dev/null +++ b/app/src/test/java/com/aryan/reader/epubreader/EpubReaderBridgeAndControlsTest.kt @@ -0,0 +1,200 @@ +package com.aryan.reader.epubreader + +import android.webkit.WebView +import com.aryan.reader.RenderMode +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.test.runTest +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class EpubReaderBridgeAndControlsTest { + + @Test + fun `sanitizePlaceholders keeps one header per toolbar section and inserts empty placeholders`() { + val input = listOf( + FlatToolItem("old_header", FlatItemType.SECTION_HEADER, section = ToolbarSection.BOTTOM), + FlatToolItem("format", FlatItemType.TOOL, tool = ReaderTool.FORMAT, section = ToolbarSection.BOTTOM), + FlatToolItem("more_header", FlatItemType.MORE_HEADER, title = "More"), + FlatToolItem("reading_mode", FlatItemType.MORE_TOOL, tool = ReaderTool.READING_MODE) + ) + + val sanitized = sanitizePlaceholders(input) + + assertEquals( + listOf( + FlatItemType.SECTION_HEADER, + FlatItemType.EMPTY_PLACEHOLDER, + FlatItemType.SECTION_HEADER, + FlatItemType.TOOL, + FlatItemType.SECTION_HEADER, + FlatItemType.EMPTY_PLACEHOLDER, + FlatItemType.MORE_HEADER, + FlatItemType.MORE_TOOL + ), + sanitized.map { it.type } + ) + assertEquals(listOf(ToolbarSection.TOP, ToolbarSection.BOTTOM, ToolbarSection.HIDDEN), sanitized.filter { it.type == FlatItemType.SECTION_HEADER }.map { it.section }) + assertEquals(ReaderTool.FORMAT, sanitized.single { it.type == FlatItemType.TOOL }.tool) + } + + @Test + fun `auto scroll bridge invokes chapter end callback`() { + var calls = 0 + + AutoScrollJsBridge { calls++ }.onChapterEnd() + + assertEquals(1, calls) + } + + @Test + fun `tts bridge relays nonblank structured text and normalizes blank payloads`() = runTest { + val received = CompletableDeferred() + val bridge = TtsJsBridge(scope = this, ttsStructuredTextHandler = { received.complete(it) }) + + bridge.onStructuredTextExtracted("[{\"text\":\"Hello\"}]") + + assertEquals("[{\"text\":\"Hello\"}]", received.await()) + + val blankReceived = CompletableDeferred() + TtsJsBridge(scope = this, ttsStructuredTextHandler = { blankReceived.complete(it) }).onStructuredTextExtracted(" ") + assertEquals("[]", blankReceived.await()) + } + + @Test + fun `highlight bridge forwards create and click events`() { + var created: Triple? = null + var clicked: List? = null + val bridge = HighlightJsBridge( + onCreateCallback = { cfi, text, color -> created = Triple(cfi, text, color) }, + onClickCallback = { cfi, text, left, top, right, bottom -> + clicked = listOf(cfi, text, left, top, right, bottom) + } + ) + + bridge.onHighlightCreated("/4", "Text", "yellow") + bridge.onHighlightClicked("/4", "Text", 1, 2, 3, 4) + + assertEquals(Triple("/4", "Text", "yellow"), created) + assertEquals(listOf("/4", "Text", 1, 2, 3, 4), clicked) + } + + @Test + fun `content snippet progress footnote and ai bridges forward callbacks`() = runTest { + var requestedChunk = -1 + var snippet = "" to "" + var progressCalls = 0 + var lastChunk = -1 + var footnote = "" + val aiContent = CompletableDeferred() + + ContentBridge { requestedChunk = it }.requestChunk(7) + SnippetJsBridge { cfi, text -> snippet = cfi to text }.onSnippetExtracted("/6", "Snippet") + val progress = ProgressJsBridge { + progressCalls++ + lastChunk = it + } + progress.updateTopChunk(2) + progress.updateTopChunk(2) + progress.updateTopChunk(3) + FootnoteJsBridge { footnote = it }.onFootnoteRequested("

Note

") + AiJsBridge(scope = this, onContentReady = { aiContent.complete(it) }).onContentExtractedForSummarization("Chapter text") + + assertEquals(7, requestedChunk) + assertEquals("/6" to "Snippet", snippet) + assertEquals(2, progressCalls) + assertEquals(3, lastChunk) + assertEquals("

Note

", footnote) + assertEquals("Chapter text", aiContent.await()) + } + + @Test + fun `ai bridge ignores blank content`() = runTest { + var called = false + + AiJsBridge(scope = this, onContentReady = { called = true }).onContentExtractedForSummarization(" ") + + assertFalse(called) + } + + @Test + fun `cfi bridge parses save bookmark and scroll callbacks with fallback for invalid save json`() { + val saved = mutableListOf() + val bookmark = mutableListOf() + val scrollResults = mutableListOf() + val bridge = CfiJsBridge( + onCfiReady = { saved.add(it) }, + onCfiForBookmarkReady = { bookmark.add(it) }, + onScrollFinishedCallback = { scrollResults.add(it) } + ) + + bridge.onCfiExtracted(JSONObject().put("cfi", "/4/2:8").put("log", JSONArray()).toString()) + bridge.onCfiExtracted(JSONObject().put("cfi", "").toString()) + bridge.onCfiExtracted("broken") + bridge.onCfiForBookmarkExtracted(JSONObject().put("cfi", "/6/4:1").toString()) + bridge.onCfiForBookmarkExtracted("broken") + bridge.onScrollFinished(true) + bridge.onScrollFinished(false) + + assertEquals(listOf("/4/2:8", "/4"), saved) + assertEquals(listOf("/6/4:1"), bookmark) + assertEquals(listOf(true, false), scrollResults) + } + + @Test + fun `cfi bridge preserves full reading position cfi payloads for save and bookmark callbacks`() { + val saved = mutableListOf() + val bookmark = mutableListOf() + val bridge = CfiJsBridge( + onCfiReady = { saved.add(it) }, + onCfiForBookmarkReady = { bookmark.add(it) }, + onScrollFinishedCallback = {} + ) + val cfi = "/6/4[chapter]!/4/2/8:137" + + bridge.onCfiExtracted(JSONObject().put("cfi", cfi).put("log", JSONArray().put("exact")).toString()) + bridge.onCfiForBookmarkExtracted(JSONObject().put("cfi", cfi).put("log", JSONArray()).toString()) + + assertEquals(listOf(cfi), saved) + assertEquals(listOf(cfi), bookmark) + } + + @Test + fun `updateAutoScrollJs emits start and stop commands`() { + val webView = mockk(relaxed = true) + + updateAutoScrollJs(webView, playing = true, speed = 1.25f) + updateAutoScrollJs(webView, playing = false, speed = 9f) + + verify { webView.evaluateJavascript("javascript:window.autoScroll.start(1.25);", null) } + verify { webView.evaluateJavascript("javascript:window.autoScroll.stop();", null) } + } + + @Test + fun `initiateTtsPlayback chooses web extraction for vertical mode and callback for paginated mode`() { + val webView = mockk(relaxed = true) + var paginatedStarts = 0 + + initiateTtsPlayback(RenderMode.VERTICAL_SCROLL, webView) { paginatedStarts++ } + initiateTtsPlayback(RenderMode.PAGINATED, webView) { paginatedStarts++ } + + verify { webView.evaluateJavascript("javascript:TtsBridgeHelper.extractAndRelayText();", null) } + assertEquals(1, paginatedStarts) + } + + @Test + fun `reader tool metadata has stable unique names and categories`() { + assertEquals(ReaderTool.entries.size, ReaderTool.entries.map { it.name }.toSet().size) + assertTrue(ReaderTool.entries.any { it.category == "Top Bar" }) + assertTrue(ReaderTool.entries.any { it.category == "Bottom Bar" }) + assertTrue(ReaderTool.entries.any { it.category == "Overflow Menu" }) + } +} diff --git a/app/src/test/java/com/aryan/reader/epubreader/EpubReaderContentTest.kt b/app/src/test/java/com/aryan/reader/epubreader/EpubReaderContentTest.kt new file mode 100644 index 0000000..6dab48b --- /dev/null +++ b/app/src/test/java/com/aryan/reader/epubreader/EpubReaderContentTest.kt @@ -0,0 +1,161 @@ +package com.aryan.reader.epubreader + +import android.content.Context +import com.aryan.reader.R +import com.aryan.reader.epub.EpubBook +import com.aryan.reader.epub.EpubChapter +import com.aryan.reader.paginatedreader.Locator +import com.aryan.reader.paginatedreader.LocatorConverter +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class EpubReaderContentTest { + + @get:Rule + val temp = TemporaryFolder() + + @Test + fun `loadChapterContent removes scripts keeps head and chunks body nodes by twenty`() = runTest { + val root = temp.newFolder("content") + val body = (1..21).joinToString("") { index -> + if (index == 3) "

Paragraph $index

" else "

Paragraph $index

" + } + writeChapter(root, "chapter.xhtml", "$body") + val book = epubBook(root, listOf(chapter("chapter.xhtml"))) + + val result = loadChapterContent( + context = contextWithStrings(), + epubBook = book, + chapterIndex = 0, + chunkTargetOverride = null, + isInitialCfiLoad = false, + cfiToLoad = null, + locatorConverter = mockk() + ) + + assertTrue(result.isSuccess) + assertEquals("", result.head.trim()) + assertEquals(2, result.chunks.size) + assertFalse(result.chunks.joinToString().contains(""), "") - .replace(Regex("(?is)"), "") - .replace(Regex("(?is)]*>"), "") - .replace(Regex("""(?i)\s+on[a-z]+\s*=\s*(['"]).*?\1"""), "") - } - - private fun String.withEmbeddedCssResources(zip: ZipFile, cssPath: String): String { - return replace(Regex("""url\((['"]?)([^)'"]+)\1\)""", RegexOption.IGNORE_CASE)) { match -> - val raw = match.groupValues[2].trim() - val dataUri = zip.toDataUri(raw, cssPath) - if (dataUri != null) "url('$dataUri')" else match.value - } - } - - private fun ZipFile.toDataUri(rawRef: String, ownerPath: String): String? { - val ref = rawRef.substringBefore('#').trim() - if (ref.isBlank() || ref.startsWith("data:", ignoreCase = true)) return null - if (ref.startsWith("http://", ignoreCase = true) || ref.startsWith("https://", ignoreCase = true)) return null - val base = ownerPath.substringBeforeLast('/', missingDelimiterValue = "") - val path = normalizeZipPath(if (base.isBlank()) ref else "$base/$ref") - val entry = getEntry(path) ?: return null - val bytes = getInputStream(entry).use { it.readBytes() } - return "data:${mimeType(path)};base64,${Base64.getEncoder().encodeToString(bytes)}" - } - - private fun mimeType(path: String): String { - return when (path.substringAfterLast('.', "").lowercase()) { - "jpg", "jpeg" -> "image/jpeg" - "png" -> "image/png" - "gif" -> "image/gif" - "svg" -> "image/svg+xml" - "webp" -> "image/webp" - "ttf" -> "font/ttf" - "otf" -> "font/otf" - "woff" -> "font/woff" - "woff2" -> "font/woff2" - "css" -> "text/css" - "js" -> "text/javascript" - else -> "application/octet-stream" - } - } - - private fun String.extractBodyOrSelf(): String { - return Regex("(?is)]*>(.*?)") - .find(this) - ?.groupValues - ?.get(1) - ?.trim() - ?: this - } - - private fun htmlToText(html: String): String { - return html - .replace(Regex("(?is)"), "") - .replace(Regex("(?is)"), "") - .replace(Regex("(?i)"), "\n") - .replace(Regex("(?i)"), "\n\n") - .replace(Regex("(?i)"), "\n\n") - .replace(Regex("<[^>]+>"), " ") - .decodeEntities() - .replace(Regex("[ \\t\\x0B\\f\\r]+"), " ") - .replace(Regex(" *\\n *"), "\n") - .replace(Regex("\\n{3,}"), "\n\n") - .trim() - } - - private fun String.decodeEntities(): String { - return replace(" ", " ") - .replace("&", "&") - .replace("<", "<") - .replace(">", ">") - .replace(""", "\"") - .replace("'", "'") - .replace(Regex("&#x([0-9a-fA-F]+);")) { match -> - match.groupValues[1].toIntOrNull(16)?.toChar()?.toString().orEmpty() - } - .replace(Regex("&#(\\d+);")) { match -> - match.groupValues[1].toIntOrNull()?.toChar()?.toString().orEmpty() - } + return SharedJvmBookLoader.load(file, FileType.EPUB) } } diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFolderMetadataExtractor.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFolderMetadataExtractor.kt new file mode 100644 index 0000000..c2e1d11 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFolderMetadataExtractor.kt @@ -0,0 +1,530 @@ +package com.aryan.reader.desktop + +import com.aryan.reader.shared.BookItem +import com.aryan.reader.shared.FileType +import com.aryan.reader.shared.ReaderPlatform +import com.aryan.reader.shared.SharedFileCapabilities +import com.aryan.reader.shared.reader.SharedJvmBookLoader +import java.awt.Color +import java.awt.Font +import java.awt.GradientPaint +import java.awt.RenderingHints +import java.awt.image.BufferedImage +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.util.zip.ZipFile +import javax.imageio.ImageIO +import kotlin.math.max + +data class DesktopFolderMetadataExtractionResult( + val books: List, + val stats: DesktopFolderMetadataExtractionStats = DesktopFolderMetadataExtractionStats() +) + +data class DesktopFolderMetadataExtractionStats( + val processedBooks: Int = 0, + val updatedBooks: Int = 0, + val coversUpdated: Int = 0, + val failedBooks: Int = 0 +) { + operator fun plus(other: DesktopFolderMetadataExtractionStats): DesktopFolderMetadataExtractionStats { + return DesktopFolderMetadataExtractionStats( + processedBooks = processedBooks + other.processedBooks, + updatedBooks = updatedBooks + other.updatedBooks, + coversUpdated = coversUpdated + other.coversUpdated, + failedBooks = failedBooks + other.failedBooks + ) + } +} + +object DesktopFolderMetadataExtractor { + private val textMetadataTypes = setOf( + FileType.PDF, + FileType.EPUB, + FileType.HTML, + FileType.MOBI, + FileType.FB2, + FileType.DOCX, + FileType.ODT, + FileType.FODT + ) + private val generatedCoverTypes = SharedFileCapabilities.readableTypesFor(ReaderPlatform.DESKTOP) + private val rasterCoverExtensions = setOf("jpg", "jpeg", "png", "gif", "webp", "bmp") + + fun enrichFolderBooks( + books: List, + sourceFolder: String + ): DesktopFolderMetadataExtractionResult { + return enrichBooks(books) { book -> book.sourceFolder == sourceFolder } + } + + fun enrichImportedBooks( + books: List, + importedBookIds: Set + ): DesktopFolderMetadataExtractionResult { + if (importedBookIds.isEmpty()) { + return DesktopFolderMetadataExtractionResult(books) + } + return enrichBooks(books) { book -> book.id in importedBookIds } + } + + private fun enrichBooks( + books: List, + shouldConsider: (BookItem) -> Boolean + ): DesktopFolderMetadataExtractionResult { + var stats = DesktopFolderMetadataExtractionStats() + val updatedBooks = books.map { book -> + if (!shouldConsider(book) || !book.needsFolderMetadataExtraction()) { + return@map book + } + + stats = stats.copy(processedBooks = stats.processedBooks + 1) + val updated = runCatching { enrichBook(book) } + .onFailure { stats = stats.copy(failedBooks = stats.failedBooks + 1) } + .getOrDefault(book) + + if (updated != book) { + stats = stats.copy(updatedBooks = stats.updatedBooks + 1) + if (updated.coverImagePath != book.coverImagePath) { + stats = stats.copy(coversUpdated = stats.coversUpdated + 1) + } + } + updated + } + return DesktopFolderMetadataExtractionResult(updatedBooks, stats) + } + + private fun BookItem.needsFolderMetadataExtraction(): Boolean { + val path = path?.takeIf { it.isNotBlank() } ?: return false + val file = File(path) + if (!file.isFile) return false + val needsTextMetadata = type in textMetadataTypes && !folderTextMetadataParsed + val needsCover = type in generatedCoverTypes && coverImagePath?.let { File(it).isFile } != true + return needsTextMetadata || needsCover + } + + private fun enrichBook(book: BookItem): BookItem { + val file = File(book.path.orEmpty()) + val size = file.length().takeIf { it > 0L } ?: book.fileSize + var title = book.title + var author = book.author + var textMetadataParsed = book.folderTextMetadataParsed + var embeddedCover: EmbeddedCover? = null + + when (book.type) { + FileType.EPUB -> { + val metadata = parseEpubMetadata(file) + title = sanitizeTitle(metadata.title) ?: title + author = sanitizeAuthor(metadata.author) ?: author + embeddedCover = metadata.cover + textMetadataParsed = true + } + FileType.PDF -> { + val metadata = runCatching { DesktopPdfium.extractMetadata(file) }.getOrNull() + title = sanitizeTitle(metadata?.title) ?: title + author = sanitizeAuthor(metadata?.author) ?: author + textMetadataParsed = true + } + FileType.HTML -> { + title = sanitizeTitle(parseHtmlTitle(file)) ?: title + textMetadataParsed = true + } + FileType.MOBI, + FileType.FB2, + FileType.DOCX, + FileType.ODT, + FileType.FODT -> { + runCatching { SharedJvmBookLoader.load(file, book.type) } + .onSuccess { loaded -> + title = sanitizeTitle(loaded.title) ?: title + author = sanitizeAuthor(loaded.author) ?: author + textMetadataParsed = true + } + } + else -> Unit + } + + val coverPath = book.coverImagePath?.takeIf { File(it).isFile } + ?: saveEmbeddedCover(book, embeddedCover) + ?: renderReaderSurfaceCover(book, file) + ?: saveGeneratedCover(book) + + return book.copy( + title = title ?: file.nameWithoutExtension, + author = author, + fileSize = size, + coverImagePath = coverPath, + folderTextMetadataParsed = textMetadataParsed + ) + } + + private fun parseEpubMetadata(file: File): ExtractedBookMetadata { + ZipFile(file).use { zip -> + val containerXml = zip.readTextOrNull("META-INF/container.xml") + val opfPath = containerXml + ?.let(::parseEpubRootfilePath) + ?: zip.entries().asSequence() + .map { it.name } + .firstOrNull { it.endsWith(".opf", ignoreCase = true) } + ?: return ExtractedBookMetadata() + val opf = zip.readTextOrNull(opfPath) ?: return ExtractedBookMetadata() + val basePath = opfPath.substringBeforeLast('/', missingDelimiterValue = "") + .let { if (it.isBlank()) "" else "$it/" } + val manifest = parseEpubManifest(opf) + val cover = findEpubCover(opf, manifest) + ?.takeIf { it.isRasterCover } + ?.let { item -> + val coverPath = normalizeZipPath(basePath + item.href) + zip.readBytesOrNull(coverPath)?.let { bytes -> + EmbeddedCover(bytes = bytes, extension = item.rasterExtension ?: "png") + } + } + + return ExtractedBookMetadata( + title = opf.tagText("title"), + author = opf.tagText("creator"), + cover = cover + ) + } + } + + private fun parseEpubRootfilePath(containerXml: String): String? { + return Regex("""]*\bfull-path=["']([^"']+)["'][^>]*>""", RegexOption.IGNORE_CASE) + .find(containerXml) + ?.groupValues + ?.get(1) + ?.takeIf { it.isNotBlank() } + } + + private fun parseEpubManifest(opf: String): List { + return Regex("""]*>""", RegexOption.IGNORE_CASE) + .findAll(opf) + .mapNotNull { match -> + val item = match.value + val id = item.attr("id") + val href = item.attr("href") + if (id.isBlank() || href.isBlank()) { + null + } else { + EpubManifestItem( + id = id, + href = href, + mediaType = item.attr("media-type"), + properties = item.attr("properties") + ) + } + } + .toList() + } + + private fun findEpubCover(opf: String, manifest: List): EpubManifestItem? { + val coverId = Regex("""]*>""", RegexOption.IGNORE_CASE) + .findAll(opf) + .firstOrNull { it.value.attr("name").equals("cover", ignoreCase = true) } + ?.value + ?.attr("content") + ?.takeIf { it.isNotBlank() } + return manifest.firstOrNull { it.id == coverId } + ?: manifest.firstOrNull { it.properties.split(Regex("\\s+")).any { property -> property == "cover-image" } } + ?: manifest.firstOrNull { it.isRasterCover && it.href.contains("cover", ignoreCase = true) } + ?: manifest.firstOrNull { it.isRasterCover && it.href.contains("front", ignoreCase = true) } + } + + private fun parseHtmlTitle(file: File): String? { + return runCatching { + val head = file.inputStream().bufferedReader(Charsets.UTF_8).use { reader -> + buildString { + var remaining = 64 * 1024 + val buffer = CharArray(2048) + while (remaining > 0) { + val read = reader.read(buffer, 0, minOf(buffer.size, remaining)) + if (read <= 0) break + append(buffer, 0, read) + remaining -= read + if (contains("", ignoreCase = true)) break + } + } + } + head.tagText("title") + }.getOrNull() + } + + private fun saveEmbeddedCover(book: BookItem, cover: EmbeddedCover?): String? { + if (cover == null || cover.bytes.isEmpty()) return null + val extension = cover.extension.takeIf { it in rasterCoverExtensions } ?: return null + return runCatching { + deleteExistingCoverFiles(book) + val target = coverCacheFile(book, extension) + target.parentFile?.mkdirs() + val temp = File(target.parentFile, "${target.name}.tmp") + temp.writeBytes(cover.bytes) + Files.move(temp.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING) + target.absolutePath + }.getOrNull() + } + + private fun renderReaderSurfaceCover(book: BookItem, file: File): String? { + if (book.type == FileType.PDF && !DesktopPdfium.isAvailable()) return null + if (book.type != FileType.PDF && !DesktopComicArchive.canLoad(book.type)) return null + return runCatching { + val document = if (book.type == FileType.PDF) { + DesktopPdfium.load(file) + } else { + DesktopPdfium.loadComic(file, book.type) + } + try { + if (document.pageCount <= 0) { + null + } else { + val firstPage = document.pageSizes.first() + val scale = 800f / firstPage.height.coerceAtLeast(1f) + val image = DesktopPdfium.renderPageBufferedImage( + document = document, + pageIndex = 0, + scale = scale, + renderAnnotations = false + ) + saveCoverImage(book, image) + } + } finally { + document.close() + } + }.getOrNull() + } + + private fun saveGeneratedCover(book: BookItem): String? { + if (book.type !in generatedCoverTypes) return null + return saveCoverImage(book, generatedCoverImage(book)) + } + + private fun saveCoverImage(book: BookItem, image: BufferedImage): String? { + return runCatching { + deleteExistingCoverFiles(book) + val target = coverCacheFile(book, "png") + target.parentFile?.mkdirs() + val temp = File(target.parentFile, "${target.name}.tmp") + ImageIO.write(image, "png", temp) + Files.move(temp.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING) + target.absolutePath + }.getOrNull() + } + + private fun generatedCoverImage(book: BookItem): BufferedImage { + val width = 480 + val height = 720 + val image = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB) + val base = coverColor(book.type) + val title = book.title?.takeIf { it.isNotBlank() } + ?: book.displayName.substringBeforeLast('.', missingDelimiterValue = book.displayName) + val author = book.author?.takeIf { it.isNotBlank() } + + val g = image.createGraphics() + try { + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + g.paint = GradientPaint(0f, 0f, base.brighter(), 0f, height.toFloat(), base.darker()) + g.fillRect(0, 0, width, height) + + g.color = Color(255, 255, 255, 36) + g.fillRoundRect(42, 42, width - 84, height - 84, 36, 36) + g.color = Color(255, 255, 255, 210) + g.font = Font("SansSerif", Font.BOLD, 34) + g.drawString(book.type.name, 64, 104) + + g.font = Font("Serif", Font.BOLD, 48) + val titleLines = wrapText(title, g.fontMetrics, width - 128, maxLines = 6) + var y = 250 + titleLines.forEach { line -> + g.drawString(line, 64, y) + y += 58 + } + + g.font = Font("SansSerif", Font.PLAIN, 28) + val footer = author ?: book.displayName + val footerLines = wrapText(footer, g.fontMetrics, width - 128, maxLines = 2) + val footerStart = max(y + 40, height - 150) + footerLines.forEachIndexed { index, line -> + g.drawString(line, 64, footerStart + index * 34) + } + } finally { + g.dispose() + } + return image + } + + private fun wrapText(text: String, metrics: java.awt.FontMetrics, maxWidth: Int, maxLines: Int): List { + val words = text.replace(Regex("\\s+"), " ").trim().split(' ').filter { it.isNotBlank() } + if (words.isEmpty()) return listOf("Untitled") + val lines = mutableListOf() + var current = "" + + for (word in words) { + val candidate = if (current.isBlank()) word else "$current $word" + if (metrics.stringWidth(candidate) <= maxWidth) { + current = candidate + } else { + if (current.isNotBlank()) lines += current + current = trimToWidth(word, metrics, maxWidth) + } + if (lines.size == maxLines) break + } + if (lines.size < maxLines && current.isNotBlank()) lines += current + return lines.take(maxLines) + } + + private fun trimToWidth(text: String, metrics: java.awt.FontMetrics, maxWidth: Int): String { + if (metrics.stringWidth(text) <= maxWidth) return text + var candidate = text + while (candidate.length > 1 && metrics.stringWidth("$candidate...") > maxWidth) { + candidate = candidate.dropLast(1) + } + return "$candidate..." + } + + private fun coverColor(type: FileType): Color { + return when (type) { + FileType.PDF -> Color(156, 65, 70) + FileType.EPUB -> Color(0, 108, 76) + FileType.CBZ, FileType.CBR, FileType.CB7 -> Color(112, 93, 73) + FileType.MD -> Color(83, 101, 120) + FileType.HTML -> Color(122, 87, 42) + FileType.TXT -> Color(74, 92, 112) + else -> Color(93, 107, 130) + } + } + + private fun coverCacheFile(book: BookItem, extension: String): File { + val key = book.path?.takeIf { it.isNotBlank() } ?: book.id + val hash = Integer.toUnsignedString(key.hashCode()) + return File(coverCacheDir(), "cover_$hash.$extension") + } + + private fun deleteExistingCoverFiles(book: BookItem) { + val key = book.path?.takeIf { it.isNotBlank() } ?: book.id + val hash = Integer.toUnsignedString(key.hashCode()) + coverCacheDir().listFiles() + ?.filter { it.isFile && it.name.startsWith("cover_$hash.") } + ?.forEach { runCatching { it.delete() } } + } + + private fun coverCacheDir(): File { + val overridePath = System.getProperty("reader.cover.cache.dir") + ?: System.getenv("READER_COVER_CACHE_DIR") + if (!overridePath.isNullOrBlank()) { + return File(overridePath).apply { mkdirs() } + } + val root = DesktopLibraryDatabase.defaultDatabaseFile().parentFile + ?: File(System.getProperty("user.home"), "AppData/Roaming/Episteme") + return File(root, "cover_cache").apply { mkdirs() } + } + + private fun ZipFile.readTextOrNull(path: String): String? { + val entry = getEntry(path) ?: return null + return getInputStream(entry).bufferedReader(Charsets.UTF_8).use { it.readText() } + } + + private fun ZipFile.readBytesOrNull(path: String): ByteArray? { + val entry = getEntry(path) ?: return null + return getInputStream(entry).use { it.readBytes() } + } + + private fun String.attr(name: String): String { + return Regex("""\b$name=["']([^"']+)["']""", RegexOption.IGNORE_CASE) + .find(this) + ?.groupValues + ?.get(1) + .orEmpty() + } + + private fun String.tagText(tag: String): String { + return Regex( + "<(?:[^:>]+:)?$tag\\b[^>]*>(.*?)]+:)?$tag>", + setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL) + ) + .find(this) + ?.groupValues + ?.get(1) + ?.replace(Regex("<[^>]+>"), " ") + ?.decodeEntities() + ?.replace(Regex("\\s+"), " ") + ?.trim() + .orEmpty() + } + + private fun String.decodeEntities(): String { + return replace(" ", " ") + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace(Regex("&#x([0-9a-fA-F]+);")) { match -> + match.groupValues[1].toIntOrNull(16)?.toChar()?.toString().orEmpty() + } + .replace(Regex("&#(\\d+);")) { match -> + match.groupValues[1].toIntOrNull()?.toChar()?.toString().orEmpty() + } + } + + private fun normalizeZipPath(path: String): String { + val parts = ArrayDeque() + path.split('/').forEach { part -> + when (part) { + "", "." -> Unit + ".." -> if (parts.isNotEmpty()) parts.removeLast() + else -> parts.addLast(part) + } + } + return parts.joinToString("/") + } + + private fun sanitizeTitle(value: String?): String? { + return value + ?.trim() + ?.takeIf { it.isNotBlank() && !it.equals("content", ignoreCase = true) } + } + + private fun sanitizeAuthor(value: String?): String? { + return value + ?.trim() + ?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) } + } + + private val EpubManifestItem.isRasterCover: Boolean + get() = rasterExtension != null + + private val EpubManifestItem.rasterExtension: String? + get() { + val extension = href.substringBefore('?') + .substringBefore('#') + .substringAfterLast('.', missingDelimiterValue = "") + .lowercase() + if (extension in rasterCoverExtensions) return extension + return when { + mediaType.equals("image/jpeg", ignoreCase = true) -> "jpg" + mediaType.equals("image/png", ignoreCase = true) -> "png" + mediaType.equals("image/gif", ignoreCase = true) -> "gif" + mediaType.equals("image/webp", ignoreCase = true) -> "webp" + mediaType.equals("image/bmp", ignoreCase = true) -> "bmp" + else -> null + } + } + + private data class ExtractedBookMetadata( + val title: String? = null, + val author: String? = null, + val cover: EmbeddedCover? = null + ) + + private data class EmbeddedCover( + val bytes: ByteArray, + val extension: String + ) + + private data class EpubManifestItem( + val id: String, + val href: String, + val mediaType: String, + val properties: String + ) +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopGeminiCloudTtsAdapter.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopGeminiCloudTtsAdapter.kt new file mode 100644 index 0000000..c2d88ae --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopGeminiCloudTtsAdapter.kt @@ -0,0 +1,732 @@ +package com.aryan.reader.desktop + +import com.aryan.reader.shared.GEMINI_CLOUD_TTS_MODEL +import com.aryan.reader.shared.ReaderAiByokSettings +import com.aryan.reader.shared.ReaderTtsCacheSummary +import com.aryan.reader.shared.ReaderTtsChunk +import com.aryan.reader.shared.ReaderTtsFileCacheManager +import com.aryan.reader.shared.ReaderTtsReadScope +import com.aryan.reader.shared.TtsAdapter +import com.aryan.reader.shared.createReaderTtsWavHeaderUnknownLength +import com.aryan.reader.shared.patchReaderTtsWavHeader +import com.aryan.reader.shared.splitReaderTextIntoTtsChunks +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.selects.select +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.io.File +import java.io.FileOutputStream +import java.net.URI +import java.net.URLEncoder +import java.net.http.HttpClient +import java.net.http.WebSocket +import java.nio.ByteBuffer +import java.util.Base64 +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CompletionStage +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.atomic.AtomicReference +import javax.sound.sampled.AudioFormat +import javax.sound.sampled.AudioSystem +import javax.sound.sampled.SourceDataLine +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.coroutineContext + +private data class DesktopTtsSequenceChunk( + val text: String, + val chapterTitle: String? +) + +class DesktopGeminiCloudTtsAdapter( + private val settingsProvider: () -> ReaderAiByokSettings, + private val httpClient: HttpClient = HttpClient.newHttpClient(), + private val cacheManager: ReaderTtsFileCacheManager = ReaderTtsFileCacheManager(defaultDesktopTtsCacheRoot()) +) : TtsAdapter { + @Volatile + private var activeLine: SourceDataLine? = null + + @Volatile + private var activeWebSocket: WebSocket? = null + + @Volatile + private var activePlayer: DesktopStreamingPcmPlayer? = null + + override val isAvailable: Boolean + get() = settingsProvider().sanitized().isCloudTtsAvailable + + override suspend fun speak(text: String) { + val trimmed = text.trim() + logDesktopTts("speak_start textChars=${trimmed.length}") + if (trimmed.isBlank()) return + speakSequence(splitReaderTextIntoTtsChunks(trimmed).ifEmpty { listOf(trimmed.take(5_000)) }) + logDesktopTts("speak_finished") + } + + suspend fun speakSequence( + texts: List, + onChunkStart: suspend (Int) -> Unit = {} + ) { + val normalizedChunks = texts + .flatMap { text -> splitReaderTextIntoTtsChunks(text).ifEmpty { listOf(text.trim()) } } + .map { text -> DesktopTtsSequenceChunk(text = text.trim().take(5_000), chapterTitle = null) } + .filter { it.text.isNotBlank() } + logDesktopTts( + "sequence_speak_start chunks=${normalizedChunks.size} totalTextChars=${normalizedChunks.sumOf { it.text.length }}" + ) + if (normalizedChunks.isEmpty()) return + val callbackContext = coroutineContext + stop() + streamSequence("Desktop selection", normalizedChunks, callbackContext, onChunkStart) + logDesktopTts("sequence_speak_finished chunks=${normalizedChunks.size}") + } + + suspend fun speakChunks( + bookTitle: String, + readScope: ReaderTtsReadScope, + chunks: List, + onChunkStart: suspend (Int) -> Unit = {} + ) { + val sequenceChunks = chunks + .map { chunk -> + DesktopTtsSequenceChunk( + text = chunk.spokenText.trim().ifBlank { chunk.text.trim() }.take(5_000), + chapterTitle = chunk.chapterTitle.ifBlank { readScope.label } + ) + } + .filter { it.text.isNotBlank() } + logDesktopTts( + "chunk_sequence_speak_start book=\"${bookTitle.desktopTtsPreview()}\" scope=${readScope.name} " + + "chunks=${sequenceChunks.size} totalTextChars=${sequenceChunks.sumOf { it.text.length }}" + ) + if (sequenceChunks.isEmpty()) return + val callbackContext = coroutineContext + stop() + streamSequence(bookTitle.ifBlank { "Untitled" }, sequenceChunks, callbackContext, onChunkStart) + logDesktopTts("chunk_sequence_speak_finished chunks=${sequenceChunks.size}") + } + + override suspend fun pause() { + withContext(Dispatchers.IO) { + activePlayer?.pause() + } + } + + override suspend fun resume() { + withContext(Dispatchers.IO) { + activePlayer?.resume() + } + } + + fun cacheSummary(bookTitle: String, speakerId: String? = settingsProvider().sanitized().ttsSpeakerId): ReaderTtsCacheSummary { + return cacheManager.getCacheSummary(bookTitle.ifBlank { "Untitled" }, speakerId) + } + + fun clearBookCacheForSpeaker(bookTitle: String, speakerId: String = settingsProvider().sanitized().ttsSpeakerId) { + cacheManager.clearBookCacheForSpeaker(bookTitle.ifBlank { "Untitled" }, speakerId) + } + + fun clearBookCache(bookTitle: String) { + cacheManager.clearBookCache(bookTitle.ifBlank { "Untitled" }) + } + + override suspend fun stop() { + withContext(Dispatchers.IO) { + logDesktopTts("stop_requested hasWebSocket=${activeWebSocket != null} hasLine=${activeLine != null}") + runCatching { activeWebSocket?.abort() } + activeWebSocket = null + runCatching { activePlayer?.closeNow() } + activePlayer = null + runCatching { activeLine?.stop() } + runCatching { activeLine?.flush() } + runCatching { activeLine?.close() } + activeLine = null + logDesktopTts("stop_complete") + } + } + + private suspend fun streamSequence( + bookTitle: String, + chunks: List, + callbackContext: CoroutineContext, + onChunkStart: suspend (Int) -> Unit + ) = withContext(Dispatchers.IO) { + val settings = settingsProvider().sanitized() + val totalTextChars = chunks.sumOf { it.text.length } + logDesktopTts( + "stream_start book=\"${bookTitle.desktopTtsPreview()}\" chunks=${chunks.size} totalTextChars=$totalTextChars keyPresent=${settings.geminiKey.isNotBlank()} " + + "ttsModel=\"${settings.ttsModel.desktopTtsPreview()}\" speaker=\"${settings.ttsSpeakerId.desktopTtsPreview()}\" " + + "available=${settings.isCloudTtsAvailable}" + ) + if (!settings.isCloudTtsAvailable) { + logDesktopTts("stream_blocked reason=not_available") + throw IllegalStateException("Cloud TTS needs a saved Gemini key and the Gemini cloud TTS model selected.") + } + + val audioBytesReceived = AtomicLong(0) + val currentTurnAudioBytesReceived = AtomicLong(0) + val player = DesktopStreamingPcmPlayer { activeLine = it } + activePlayer = player + val setupComplete = CompletableDeferred() + val currentTurnComplete = AtomicReference?>(null) + val activeCacheOutput = AtomicReference(null) + val failure = CompletableDeferred() + val messageBuffer = StringBuilder() + var webSocket: WebSocket? = null + var activeTempCacheFile: File? = null + + fun handleMessage(message: String) { + handleGeminiTtsMessage( + message = message, + setupComplete = setupComplete, + turnComplete = currentTurnComplete.get(), + failure = failure, + onAudioPart = { bytes -> + audioBytesReceived.addAndGet(bytes.size.toLong()) + currentTurnAudioBytesReceived.addAndGet(bytes.size.toLong()) + activeCacheOutput.get()?.let { output -> + runCatching { output.write(bytes) } + .onFailure { error -> + logDesktopTts("cache_write_failed error=\"${error.desktopTtsSummary()}\"") + failure.complete(error) + } + } + runCatching { player.write(bytes) } + .onFailure { error -> + logDesktopTts("stream_audio_write_failed error=\"${error.desktopTtsSummary()}\"") + failure.complete(error) + } + } + ) + } + + val listener = object : WebSocket.Listener { + override fun onOpen(webSocket: WebSocket) { + activeWebSocket = webSocket + webSocket.request(1) + logDesktopTts("ws_open send_setup model=\"$GEMINI_CLOUD_TTS_MODEL\" speaker=\"${settings.ttsSpeakerId.desktopTtsPreview()}\"") + webSocket.sendText(buildGeminiTtsSetup(settings.ttsSpeakerId), true) + .whenComplete { _, error -> + if (error != null) { + logDesktopTts("ws_setup_send_failed error=\"${error.desktopTtsSummary()}\"") + failure.complete(error) + } else { + logDesktopTts("ws_setup_send_complete") + } + } + } + + override fun onText(webSocket: WebSocket, data: CharSequence, last: Boolean): CompletionStage<*> { + messageBuffer.append(data) + logDesktopTts("ws_message_text chunkChars=${data.length} last=$last bufferChars=${messageBuffer.length}") + if (last) { + val message = messageBuffer.toString() + messageBuffer.clear() + handleMessage(message) + } + webSocket.request(1) + return CompletableFuture.completedFuture(null) + } + + override fun onBinary(webSocket: WebSocket, data: ByteBuffer, last: Boolean): CompletionStage<*> { + val bytes = ByteArray(data.remaining()) + data.get(bytes) + messageBuffer.append(bytes.decodeToString()) + logDesktopTts("ws_message_binary chunkBytes=${bytes.size} last=$last bufferChars=${messageBuffer.length}") + if (last) { + val message = messageBuffer.toString() + messageBuffer.clear() + handleMessage(message) + } + webSocket.request(1) + return CompletableFuture.completedFuture(null) + } + + override fun onError(webSocket: WebSocket, error: Throwable) { + logDesktopTts("ws_error error=\"${error.desktopTtsSummary()}\"") + failure.complete(error) + } + + override fun onClose(webSocket: WebSocket, statusCode: Int, reason: String): CompletionStage<*> { + val activeTurn = currentTurnComplete.get() + logDesktopTts( + "ws_close status=$statusCode reason=\"${reason.desktopTtsPreview()}\" " + + "setupComplete=${setupComplete.isCompleted} turnComplete=${activeTurn?.isCompleted}" + ) + if (!setupComplete.isCompleted && !failure.isCompleted) { + failure.complete(IllegalStateException("Cloud TTS connection closed before setup: $reason")) + } else if (activeTurn != null && !activeTurn.isCompleted && !failure.isCompleted) { + failure.complete(IllegalStateException("Cloud TTS connection closed: $reason")) + } + return CompletableFuture.completedFuture(null) + } + } + + suspend fun ensureWebSocket(): WebSocket { + webSocket?.let { return it } + val encodedKey = URLEncoder.encode(settings.geminiKey, Charsets.UTF_8.name()) + val uri = URI("wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent?key=$encodedKey") + logDesktopTts("ws_connect_start endpoint=GeminiLive keyChars=${settings.geminiKey.length}") + val connectedWebSocket = runCatching { + httpClient.newWebSocketBuilder() + .buildAsync(uri, listener) + .get(15, TimeUnit.SECONDS) + }.getOrElse { error -> + logDesktopTts("ws_connect_failed error=\"${error.desktopTtsSummary()}\"") + throw error + } + activeWebSocket = connectedWebSocket + webSocket = connectedWebSocket + logDesktopTts("ws_connect_complete") + + logDesktopTts("setup_wait_start timeoutMs=15000") + withTimeout(15_000) { + select { + setupComplete.onAwait { } + failure.onAwait { throw it } + } + } + logDesktopTts("setup_wait_complete") + return connectedWebSocket + } + + try { + val totalChunksByChapter = chunks.groupingBy { it.chapterTitle }.eachCount() + chunks.forEach { chunk -> + cacheManager.saveTotalChunks( + bookTitle = bookTitle, + chapterTitle = chunk.chapterTitle, + totalChunks = totalChunksByChapter[chunk.chapterTitle] ?: chunks.size + ) + } + chunks.forEachIndexed { index, chunk -> + val text = chunk.text + val turnComplete = CompletableDeferred() + currentTurnAudioBytesReceived.set(0) + currentTurnComplete.set(turnComplete) + logDesktopTts("sequence_turn_start index=${index + 1}/${chunks.size} textChars=${text.length}") + withContext(callbackContext) { + onChunkStart(index) + } + + val cacheFile = cacheManager.getCacheFile(bookTitle, chunk.chapterTitle, text, settings.ttsSpeakerId) + if (cacheFile.exists() && cacheFile.length() > 44) { + logDesktopTts( + "cache_hit index=${index + 1}/${chunks.size} bytes=${cacheFile.length()} " + + "file=\"${cacheFile.absolutePath.desktopTtsPreview(220)}\"" + ) + val cachedBytes = playCachedWav(cacheFile, player) + currentTurnAudioBytesReceived.set(cachedBytes) + audioBytesReceived.addAndGet(cachedBytes) + logDesktopTts("cache_play_complete index=${index + 1}/${chunks.size} audioBytes=$cachedBytes") + currentTurnComplete.compareAndSet(turnComplete, null) + return@forEachIndexed + } + + val socket = ensureWebSocket() + val tempCacheFile = File(cacheFile.absolutePath + ".tmp") + activeTempCacheFile = tempCacheFile + runCatching { + tempCacheFile.parentFile?.mkdirs() + FileOutputStream(tempCacheFile).also { output -> + output.write(createReaderTtsWavHeaderUnknownLength(24_000)) + activeCacheOutput.set(output) + } + }.onFailure { error -> + activeCacheOutput.set(null) + tempCacheFile.delete() + logDesktopTts("cache_prepare_failed index=${index + 1}/${chunks.size} error=\"${error.desktopTtsSummary()}\"") + } + + try { + logDesktopTts("text_send_start index=${index + 1}/${chunks.size} textChars=${text.length}") + runCatching { socket.sendText(buildGeminiTtsTextInput(text), true).join() } + .onFailure { error -> + logDesktopTts("text_send_failed index=${index + 1}/${chunks.size} error=\"${error.desktopTtsSummary()}\"") + throw error + } + logDesktopTts("text_send_complete index=${index + 1}/${chunks.size}") + + val turnTimeoutMs = (30_000L + text.length * 80L).coerceIn(60_000L, 600_000L) + logDesktopTts("turn_wait_start index=${index + 1}/${chunks.size} timeoutMs=$turnTimeoutMs") + withTimeout(turnTimeoutMs) { + select { + turnComplete.onAwait { } + failure.onAwait { throw it } + } + } + val turnAudioBytes = currentTurnAudioBytesReceived.get() + logDesktopTts( + "turn_wait_complete index=${index + 1}/${chunks.size} " + + "turnAudioBytes=$turnAudioBytes totalAudioBytes=${audioBytesReceived.get()}" + ) + if (turnAudioBytes == 0L) { + logDesktopTts("stream_failed reason=empty_turn_audio index=${index + 1}/${chunks.size}") + throw IllegalStateException("Cloud TTS returned no audio for a text chunk.") + } + activeCacheOutput.getAndSet(null)?.close() + runCatching { + patchReaderTtsWavHeader(tempCacheFile, turnAudioBytes.toInt()) + if (cacheFile.exists()) cacheFile.delete() + if (!tempCacheFile.renameTo(cacheFile)) { + throw IllegalStateException("Could not move temp cache file into place.") + } + }.onSuccess { + logDesktopTts( + "cache_store_complete index=${index + 1}/${chunks.size} bytes=${cacheFile.length()} " + + "file=\"${cacheFile.absolutePath.desktopTtsPreview(220)}\"" + ) + }.onFailure { error -> + tempCacheFile.delete() + logDesktopTts("cache_store_failed index=${index + 1}/${chunks.size} error=\"${error.desktopTtsSummary()}\"") + } + activeTempCacheFile = null + } finally { + activeCacheOutput.getAndSet(null)?.let { output -> + runCatching { output.close() } + } + } + currentTurnComplete.compareAndSet(turnComplete, null) + } + + if (audioBytesReceived.get() == 0L) { + logDesktopTts("stream_failed reason=empty_audio") + throw IllegalStateException("Cloud TTS returned no audio.") + } + player.drainAndClose() + webSocket?.let { socket -> runCatching { socket.sendClose(WebSocket.NORMAL_CLOSURE, "done").join() } } + activeWebSocket = null + activePlayer = null + logDesktopTts("stream_complete chunks=${chunks.size} audioBytes=${audioBytesReceived.get()}") + } catch (error: Throwable) { + currentTurnComplete.set(null) + activeCacheOutput.getAndSet(null)?.let { output -> runCatching { output.close() } } + activeTempCacheFile?.delete() + activeTempCacheFile = null + runCatching { webSocket?.abort() } + activeWebSocket = null + activePlayer = null + player.closeNow() + throw error + } + } +} + +private suspend fun playCachedWav(file: File, player: DesktopStreamingPcmPlayer): Long { + var totalBytes = 0L + file.inputStream().use { input -> + var skipped = 0L + while (skipped < 44L) { + val next = input.skip(44L - skipped) + if (next <= 0L) break + skipped += next + } + val buffer = ByteArray(8192) + while (true) { + coroutineContext.ensureActive() + val read = input.read(buffer) + if (read <= 0) break + player.write(buffer.copyOf(read)) + totalBytes += read + } + } + return totalBytes +} + +private fun defaultDesktopTtsCacheRoot(): File { + val baseDir = System.getenv("APPDATA")?.takeIf { it.isNotBlank() } + ?: File(System.getProperty("user.home"), "AppData/Roaming").absolutePath + return File(baseDir, "Episteme/TTS_Cache") +} + +private fun buildGeminiTtsSetup(speakerId: String): String { + val systemPrompt = """ + You are a professional audiobook narrator. + Read the exact text provided, word for word, with neutral emotion and good pacing. + Do not add conversational filler, acknowledgments, extra words, summaries, or commentary. + Skip non-verbal symbols or formatting noise that cannot be read naturally. + """.trimIndent() + return buildJsonObject { + put( + "setup", + buildJsonObject { + put("model", JsonPrimitive("models/$GEMINI_CLOUD_TTS_MODEL")) + put( + "systemInstruction", + buildJsonObject { + put("parts", buildJsonArray { + add(buildJsonObject { put("text", JsonPrimitive(systemPrompt)) }) + }) + } + ) + put( + "generationConfig", + buildJsonObject { + put("responseModalities", buildJsonArray { add(JsonPrimitive("AUDIO")) }) + put( + "speechConfig", + buildJsonObject { + put( + "voiceConfig", + buildJsonObject { + put( + "prebuiltVoiceConfig", + buildJsonObject { put("voiceName", JsonPrimitive(speakerId)) } + ) + } + ) + } + ) + } + ) + } + ) + }.toString() +} + +private fun buildGeminiTtsTextInput(text: String): String { + return buildJsonObject { + put( + "realtimeInput", + buildJsonObject { + put("text", JsonPrimitive(text)) + } + ) + }.toString() +} + +private fun handleGeminiTtsMessage( + message: String, + setupComplete: CompletableDeferred, + turnComplete: CompletableDeferred?, + failure: CompletableDeferred, + onAudioPart: (ByteArray) -> Unit +) { + logDesktopTts("message_handle chars=${message.length} preview=\"${message.desktopTtsPreview()}\"") + val json = runCatching { DesktopGeminiTtsJson.parseToJsonElement(message).jsonObject }.getOrElse { error -> + logDesktopTts("message_parse_failed error=\"${error.desktopTtsSummary()}\"") + return + } + json["error"]?.let { error -> + logDesktopTts("message_provider_error body=\"${error.toString().desktopTtsPreview(300)}\"") + failure.complete(IllegalStateException(error.toString())) + return + } + if (json.containsKey("setupComplete") || json.containsKey("setup_complete")) { + logDesktopTts("message_setup_complete") + setupComplete.complete(Unit) + } + + val serverContent = json.jsonObjectValue("serverContent", "server_content") ?: return + val modelTurn = serverContent.jsonObjectValue("modelTurn", "model_turn") + val parts = modelTurn?.get("parts")?.jsonArray + parts?.forEach { part -> + val inlineData = part.jsonObjectOrNull()?.jsonObjectValue("inlineData", "inline_data") + val encoded = inlineData?.get("data")?.jsonPrimitive?.contentOrNull + if (!encoded.isNullOrBlank()) { + val decoded = Base64.getMimeDecoder().decode(encoded) + onAudioPart(decoded) + logDesktopTts("message_audio_part bytes=${decoded.size}") + } + } + if (serverContent.booleanValue("turnComplete", "turn_complete")) { + logDesktopTts("message_turn_complete") + turnComplete?.complete(Unit) + } +} + +private val DesktopGeminiTtsJson = Json { ignoreUnknownKeys = true } + +private fun JsonObject.jsonObjectValue(vararg keys: String): JsonObject? { + return keys.firstNotNullOfOrNull { key -> get(key) as? JsonObject } +} + +private fun JsonObject.booleanValue(vararg keys: String): Boolean { + return keys.any { key -> get(key)?.jsonPrimitive?.booleanOrNull == true } +} + +private fun JsonElement.jsonObjectOrNull(): JsonObject? { + return this as? JsonObject +} + +private fun ByteArray.upsample16BitMonoLe2x(): ByteArray { + if (size < 2) return this + val sampleCount = size / 2 + val output = ByteArray(sampleCount * 4) + var outputIndex = 0 + fun sampleAt(index: Int): Int { + val byteIndex = index * 2 + val lo = this[byteIndex].toInt() and 0xFF + val hi = this[byteIndex + 1].toInt() + return (hi shl 8) or lo + } + fun writeSample(sample: Int) { + output[outputIndex] = (sample and 0xFF).toByte() + output[outputIndex + 1] = ((sample shr 8) and 0xFF).toByte() + outputIndex += 2 + } + for (index in 0 until sampleCount) { + val current = sampleAt(index) + val next = sampleAt((index + 1).coerceAtMost(sampleCount - 1)) + writeSample(current) + writeSample(((current + next) / 2).coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt())) + } + return output +} + +private class DesktopStreamingPcmPlayer( + private val onLineChanged: (SourceDataLine?) -> Unit +) { + @Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN") + private val stateLock = java.lang.Object() + private var line: SourceDataLine? = null + private var fallbackTo48Khz = true + @Volatile + private var closed = false + @Volatile + private var paused = false + private var bytesWritten = 0L + + init { + logDesktopTts("play_stream_start mixers=\"${availableAudioMixers().desktopTtsPreview(260)}\"") + } + + fun pause() { + synchronized(stateLock) { + if (closed || paused) return + paused = true + runCatching { line?.stop() } + logDesktopTts("play_stream_paused totalWritten=$bytesWritten") + } + } + + fun resume() { + synchronized(stateLock) { + if (closed || !paused) return + paused = false + runCatching { line?.start() } + stateLock.notifyAll() + logDesktopTts("play_stream_resumed totalWritten=$bytesWritten") + } + } + + fun write(pcm24Khz: ByteArray) { + if (closed || pcm24Khz.isEmpty()) return + waitIfPaused() + val activeLine = synchronized(stateLock) { + if (closed) return + line ?: openBestLine() + } + val bytes = if (fallbackTo48Khz) pcm24Khz.upsample16BitMonoLe2x() else pcm24Khz + var offset = 0 + var lineStarted = activeLine.isRunning + val primeTargetBytes = (activeLine.bufferSize / 2).coerceAtLeast(8192) + while (offset < bytes.size && !closed) { + waitIfPaused() + val maxWrite = if (lineStarted) 8192 else primeTargetBytes + val written = activeLine.write(bytes, offset, (bytes.size - offset).coerceAtMost(maxWrite)) + if (written <= 0) break + offset += written + bytesWritten += written + if (!lineStarted && (offset >= bytes.size || offset >= primeTargetBytes)) { + activeLine.start() + lineStarted = true + logDesktopTts("play_line_started_after_prime primeBytes=$offset") + } + } + if (!lineStarted && !closed) { + activeLine.start() + logDesktopTts("play_line_started_after_prime primeBytes=$offset") + } + logDesktopTts("play_stream_write inputBytes=${pcm24Khz.size} writtenBytes=$offset totalWritten=$bytesWritten") + } + + fun drainAndClose() { + val activeLine = line + if (activeLine != null && !closed) { + logDesktopTts("play_stream_drain totalWritten=$bytesWritten") + runCatching { activeLine.drain() } + .onFailure { error -> logDesktopTts("play_stream_drain_failed error=\"${error.desktopTtsSummary()}\"") } + } + closeNow() + } + + fun closeNow() { + val activeLine = synchronized(stateLock) { + if (closed) return + closed = true + paused = false + stateLock.notifyAll() + line.also { line = null } + } + activeLine?.let { + runCatching { it.stop() } + runCatching { it.flush() } + runCatching { it.close() } + } + onLineChanged(null) + logDesktopTts("play_stream_closed totalWritten=$bytesWritten") + } + + private fun waitIfPaused() { + synchronized(stateLock) { + while (paused && !closed) { + stateLock.wait(100) + } + } + } + + private fun openBestLine(): SourceDataLine { + fallbackTo48Khz = true + return runCatching { + openLine(48_000f) + }.getOrElse { firstError -> + logDesktopTts("play_primary_failed sampleRate=48000 error=\"${firstError.desktopTtsSummary()}\"") + fallbackTo48Khz = false + runCatching { + openLine(24_000f) + }.onFailure { secondError -> + logDesktopTts("play_fallback_failed sampleRate=24000 error=\"${secondError.desktopTtsSummary()}\"") + secondError.printStackTrace() + }.getOrElse { + throw firstError + } + } + } + + private fun openLine(sampleRate: Float): SourceDataLine { + val format = AudioFormat(sampleRate, 16, 1, true, false) + val bufferBytes = sampleRate.toInt().coerceAtLeast(16_384) + logDesktopTts("play_line_request sampleRate=${sampleRate.toInt()} bufferBytes=$bufferBytes") + val openedLine = AudioSystem.getSourceDataLine(format) + openedLine.open(format, bufferBytes) + line = openedLine + onLineChanged(openedLine) + logDesktopTts( + "play_line_opened sampleRate=${sampleRate.toInt()} output48Khz=$fallbackTo48Khz " + + "line=\"${openedLine.lineInfo.toString().desktopTtsPreview(160)}\"" + ) + return openedLine + } +} + +private fun availableAudioMixers(): String { + return runCatching { + AudioSystem.getMixerInfo() + .joinToString(limit = 8, truncated = "...") { "${it.name}/${it.description}" } + .ifBlank { "none" } + }.getOrDefault("unavailable") +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLibraryDatabase.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLibraryDatabase.kt index 4b92702..28906ca 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLibraryDatabase.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLibraryDatabase.kt @@ -1,66 +1,20 @@ package com.aryan.reader.desktop -import com.aryan.reader.shared.BookItem -import com.aryan.reader.shared.BookShelfRef -import com.aryan.reader.shared.FileType -import com.aryan.reader.shared.ShelfRecord -import com.aryan.reader.shared.Tag -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonNull -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.booleanOrNull -import kotlinx.serialization.json.doubleOrNull -import kotlinx.serialization.json.floatOrNull -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.json.longOrNull +import com.aryan.reader.shared.SharedLibrarySnapshot +import com.aryan.reader.shared.SharedLibrarySnapshotJson import java.io.File -data class DesktopLibrarySnapshot( - val books: List = emptyList(), - val shelfRecords: List = emptyList(), - val shelfRefs: List = emptyList(), - val tags: List = emptyList() -) - class DesktopLibraryDatabase( private val databaseFile: File = defaultDatabaseFile() ) { - private val json = Json { - prettyPrint = true - ignoreUnknownKeys = true + fun load(): SharedLibrarySnapshot { + if (!databaseFile.exists()) return SharedLibrarySnapshot() + return SharedLibrarySnapshotJson.decodeOrEmpty(databaseFile.readText()) } - fun load(): DesktopLibrarySnapshot { - if (!databaseFile.exists()) return DesktopLibrarySnapshot() - val root = runCatching { - json.parseToJsonElement(databaseFile.readText()).jsonObject - }.getOrNull() ?: return DesktopLibrarySnapshot() - - return DesktopLibrarySnapshot( - books = root.array("books").mapNotNull { it.asBookItemOrNull() }, - shelfRecords = root.array("shelves").mapNotNull { it.asShelfRecordOrNull() }, - shelfRefs = root.array("bookShelfRefs").mapNotNull { it.asBookShelfRefOrNull() }, - tags = root.array("tags").mapNotNull { it.asTagOrNull() } - ) - } - - fun save(snapshot: DesktopLibrarySnapshot) { + fun save(snapshot: SharedLibrarySnapshot) { databaseFile.parentFile?.mkdirs() - val root = JsonObject( - mapOf( - "schemaVersion" to JsonPrimitive(1), - "books" to JsonArray(snapshot.books.map { it.toJsonObject() }), - "shelves" to JsonArray(snapshot.shelfRecords.map { it.toJsonObject() }), - "bookShelfRefs" to JsonArray(snapshot.shelfRefs.map { it.toJsonObject() }), - "tags" to JsonArray(snapshot.tags.map { it.toJsonObject() }) - ) - ) - databaseFile.writeText(root.toString()) + databaseFile.writeText(SharedLibrarySnapshotJson.encode(snapshot)) } companion object { @@ -71,135 +25,3 @@ class DesktopLibraryDatabase( } } } - -private fun JsonObject.array(name: String): List { - return runCatching { this[name]?.jsonArray?.toList().orEmpty() }.getOrDefault(emptyList()) -} - -private fun JsonObject.string(name: String): String? { - return this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.content -} - -private fun JsonObject.long(name: String, fallback: Long = 0L): Long { - return this[name]?.jsonPrimitive?.longOrNull ?: fallback -} - -private fun JsonObject.float(name: String): Float? { - return this[name]?.jsonPrimitive?.floatOrNull -} - -private fun JsonObject.double(name: String): Double? { - return this[name]?.jsonPrimitive?.doubleOrNull -} - -private fun JsonObject.boolean(name: String, fallback: Boolean): Boolean { - return this[name]?.jsonPrimitive?.booleanOrNull ?: fallback -} - -private fun JsonElement.asBookItemOrNull(): BookItem? { - val obj = runCatching { jsonObject }.getOrNull() ?: return null - val id = obj.string("id") ?: return null - val displayName = obj.string("displayName") ?: return null - val type = obj.string("type")?.let { runCatching { FileType.valueOf(it) }.getOrNull() } ?: FileType.UNKNOWN - return BookItem( - id = id, - path = obj.string("path"), - type = type, - displayName = displayName, - timestamp = obj.long("timestamp"), - title = obj.string("title"), - author = obj.string("author"), - progressPercentage = obj.float("progressPercentage"), - isRecent = obj.boolean("isRecent", true), - fileSize = obj.long("fileSize"), - sourceFolder = obj.string("sourceFolder"), - seriesName = obj.string("seriesName"), - seriesIndex = obj.double("seriesIndex"), - tags = obj.array("tags").mapNotNull { it.asTagOrNull() } - ) -} - -private fun JsonElement.asShelfRecordOrNull(): ShelfRecord? { - val obj = runCatching { jsonObject }.getOrNull() ?: return null - return ShelfRecord( - id = obj.string("id") ?: return null, - name = obj.string("name") ?: return null, - isSmart = obj.boolean("isSmart", false), - smartRulesJson = obj.string("smartRulesJson") - ) -} - -private fun JsonElement.asBookShelfRefOrNull(): BookShelfRef? { - val obj = runCatching { jsonObject }.getOrNull() ?: return null - return BookShelfRef( - bookId = obj.string("bookId") ?: return null, - shelfId = obj.string("shelfId") ?: return null, - addedAt = obj.long("addedAt") - ) -} - -private fun JsonElement.asTagOrNull(): Tag? { - val obj = runCatching { jsonObject }.getOrNull() ?: return null - return Tag( - id = obj.string("id") ?: return null, - name = obj.string("name") ?: return null, - color = obj["color"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.content?.toIntOrNull() - ) -} - -private fun BookItem.toJsonObject(): JsonObject { - return JsonObject( - mapOf( - "id" to JsonPrimitive(id), - "path" to path.asJson(), - "type" to JsonPrimitive(type.name), - "displayName" to JsonPrimitive(displayName), - "timestamp" to JsonPrimitive(timestamp), - "title" to title.asJson(), - "author" to author.asJson(), - "progressPercentage" to progressPercentage.asJson(), - "isRecent" to JsonPrimitive(isRecent), - "fileSize" to JsonPrimitive(fileSize), - "sourceFolder" to sourceFolder.asJson(), - "seriesName" to seriesName.asJson(), - "seriesIndex" to seriesIndex.asJson(), - "tags" to JsonArray(tags.map { it.toJsonObject() }) - ) - ) -} - -private fun ShelfRecord.toJsonObject(): JsonObject { - return JsonObject( - mapOf( - "id" to JsonPrimitive(id), - "name" to JsonPrimitive(name), - "isSmart" to JsonPrimitive(isSmart), - "smartRulesJson" to smartRulesJson.asJson() - ) - ) -} - -private fun BookShelfRef.toJsonObject(): JsonObject { - return JsonObject( - mapOf( - "bookId" to JsonPrimitive(bookId), - "shelfId" to JsonPrimitive(shelfId), - "addedAt" to JsonPrimitive(addedAt) - ) - ) -} - -private fun Tag.toJsonObject(): JsonObject { - return JsonObject( - mapOf( - "id" to JsonPrimitive(id), - "name" to JsonPrimitive(name), - "color" to color.asJson() - ) - ) -} - -private fun String?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull -private fun Float?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull -private fun Double?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull -private fun Int?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLocalFolderSync.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLocalFolderSync.kt new file mode 100644 index 0000000..9dc01cd --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLocalFolderSync.kt @@ -0,0 +1,573 @@ +package com.aryan.reader.desktop + +import com.aryan.reader.shared.BookItem +import com.aryan.reader.shared.BookShelfRef +import com.aryan.reader.shared.FileType +import com.aryan.reader.shared.LOCAL_FOLDER_ANNOTATION_SUFFIX +import com.aryan.reader.shared.LOCAL_FOLDER_SYNC_DATA_DIR +import com.aryan.reader.shared.LocalFolderSyncEngine +import com.aryan.reader.shared.LocalFolderSyncStats +import com.aryan.reader.shared.ReaderPlatform +import com.aryan.reader.shared.SharedFileCapabilities +import com.aryan.reader.shared.SharedFolderBookMetadata +import com.aryan.reader.shared.SharedFolderScannedFile +import com.aryan.reader.shared.SharedReaderScreenState +import com.aryan.reader.shared.SyncedFolder +import com.aryan.reader.shared.pdf.SharedPdfAnnotationSerializer +import com.aryan.reader.shared.pdf.SharedPdfAnnotationSidecarCodec +import com.aryan.reader.shared.pdf.SharedPdfRichTextLog +import com.aryan.reader.shared.pdf.SharedPdfRichTextSerializer +import com.aryan.reader.shared.toSharedFolderBookMetadata +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull +import java.io.File +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.StandardCopyOption + +data class DesktopLocalFolderSyncResult( + val state: SharedReaderScreenState, + val shelfRefs: List, + val stats: LocalFolderSyncStats, + val metadataStats: DesktopFolderMetadataExtractionStats = DesktopFolderMetadataExtractionStats(), + val idMigrations: Map = emptyMap(), + val removedBookIds: Set = emptySet(), + val failedFolders: List = emptyList() +) + +object DesktopLocalFolderSync { + private val desktopSyncableTypes = SharedFileCapabilities.syncableTypesFor(ReaderPlatform.DESKTOP) + + fun hasSupportedFiles(folder: File): Boolean { + if (!folder.isDirectory) return false + return folder.walkTopDown() + .onEnter { it == folder || it.shouldEnterSyncedFolder() } + .any { file -> + file.isFile && + file.shouldSyncBookFile() && + SharedFileCapabilities.fileTypeForName(file.name) in desktopSyncableTypes + } + } + + fun sync( + state: SharedReaderScreenState, + shelfRefs: List, + targetFolder: File? = null, + nowMillis: Long = System.currentTimeMillis() + ): DesktopLocalFolderSyncResult { + val requestedFolders = foldersToSync(state, targetFolder, nowMillis) + var nextState = state + var nextShelfRefs = shelfRefs + var totalStats = LocalFolderSyncStats() + var totalMetadataStats = DesktopFolderMetadataExtractionStats() + val allMigrations = linkedMapOf() + val allRemovedBookIds = linkedSetOf() + val failedFolders = mutableListOf() + + requestedFolders.forEach { folder -> + val root = File(folder.uriString) + if (!root.isDirectory) { + failedFolders += folder.name + return@forEach + } + + val scannedFiles = scanFolder(root = root, sourceFolder = folder.uriString) + val remoteMetadata = readAllMetadata(root) + val syncResult = LocalFolderSyncEngine.syncFolder( + state = nextState, + folder = folder, + files = scannedFiles, + remoteMetadata = remoteMetadata, + nowMillis = nowMillis + ) + nextState = syncResult.state + nextShelfRefs = LocalFolderSyncEngine.applyIdMigrationsToShelfRefs( + nextShelfRefs, + syncResult.idMigrations + ).filterNot { it.bookId in syncResult.removedBookIds } + allMigrations += syncResult.idMigrations + allRemovedBookIds += syncResult.removedBookIds + totalStats += syncResult.stats + + var syncedBooks = nextState.rawLibraryBooks.filter { it.sourceFolder == folder.uriString } + importAnnotationSidecars(root, syncedBooks) + val metadataResult = DesktopFolderMetadataExtractor.enrichFolderBooks( + books = nextState.rawLibraryBooks, + sourceFolder = folder.uriString + ) + if (metadataResult.stats.updatedBooks > 0) { + nextState = nextState.copy(rawLibraryBooks = metadataResult.books) + syncedBooks = nextState.rawLibraryBooks.filter { it.sourceFolder == folder.uriString } + } + totalMetadataStats += metadataResult.stats + syncedBooks.forEach { book -> + saveBookMetadata(book) + savePdfAnnotationSidecar(book) + } + } + + return DesktopLocalFolderSyncResult( + state = nextState, + shelfRefs = nextShelfRefs, + stats = totalStats, + metadataStats = totalMetadataStats, + idMigrations = allMigrations, + removedBookIds = allRemovedBookIds, + failedFolders = failedFolders + ) + } + + fun saveBookSidecars(book: BookItem) { + saveBookMetadata(book) + savePdfAnnotationSidecar(book) + } + + fun saveBookMetadata(book: BookItem) { + val metadata = book.toSharedFolderBookMetadata() ?: return + val root = book.sourceFolder?.let(::File)?.takeIf { it.isDirectory } ?: return + saveMetadataToFolder(root, metadata) + } + + fun savePdfAnnotationSidecar(book: BookItem) { + val path = book.path?.takeIf { it.isNotBlank() } ?: return + if (book.type != FileType.PDF) return + val root = book.sourceFolder?.let(::File)?.takeIf { it.isDirectory } ?: return + val annotationFile = desktopPdfAnnotationFile(path) + val bookmarkFile = desktopPdfBookmarkFile(path) + val richTextFile = desktopPdfRichTextFile(path) + val data = buildMap { + if (annotationFile.isFile) { + val annotationJson = annotationFile.readText().trim() + val annotations = SharedPdfAnnotationSerializer.decode(annotationJson) + put( + SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS, + SharedPdfAnnotationSidecarCodec.encodeAnnotationsElement(annotations) + ) + } + if (bookmarkFile.isFile) { + val bookmarksJson = bookmarkFile.readText().trim() + desktopFolderSyncJson.parseElementOrNull(bookmarksJson)?.let { put("bookmarks", it) } + } + if (richTextFile.isFile) { + val richTextJson = richTextFile.readText().trim() + val richTextElement = desktopFolderSyncJson.parseElementOrNull(richTextJson) + if (richTextElement == null) { + SharedPdfRichTextLog.d( + "desktop.sync.exportRichTextParseFailed book=${book.id} " + + "file=\"${richTextFile.absolutePath.richSyncPreview()}\" rawLen=${richTextJson.length}" + ) + } else { + val richTextDocument = SharedPdfRichTextSerializer.decodeElement(richTextElement) + SharedPdfRichTextLog.d( + "desktop.sync.exportRichText book=${book.id} " + + "file=\"${richTextFile.absolutePath.richSyncPreview()}\" rawLen=${richTextJson.length} " + + "textLen=${richTextDocument.text.length} spans=${richTextDocument.spans.size}" + ) + put("text", SharedPdfRichTextSerializer.encodeElement(richTextDocument)) + } + } + } + if (data.isEmpty()) { + SharedPdfRichTextLog.d("desktop.sync.exportSkipNoSidecarData book=${book.id} pdfPath=\"${path.richSyncPreview()}\"") + return + } + val timestamp = maxOf( + annotationFile.lastModifiedIfFile(), + bookmarkFile.lastModifiedIfFile(), + richTextFile.lastModifiedIfFile(), + System.currentTimeMillis() + ) + val dataJson = desktopFolderSyncJson.encodeToString( + JsonElement.serializer(), + JsonObject(data) + ) + if (data.containsKey("text")) { + SharedPdfRichTextLog.d( + "desktop.sync.exportSidecar book=${book.id} timestamp=$timestamp " + + "keys=${data.keys.sorted()} root=\"${root.absolutePath.richSyncPreview()}\"" + ) + } + saveAnnotationSidecar( + root = root, + bookId = book.id, + jsonPayload = dataJson, + timestamp = timestamp + ) + } + + private fun foldersToSync( + state: SharedReaderScreenState, + targetFolder: File?, + nowMillis: Long + ): List { + if (targetFolder == null) return state.syncedFolders + val root = targetFolder.canonicalOrAbsolute() + val rootPath = root.absolutePath + val existing = state.syncedFolders.firstOrNull { File(it.uriString).canonicalOrAbsolute() == root } + return listOf( + existing ?: SyncedFolder( + uriString = rootPath, + name = root.name.takeIf { it.isNotBlank() } ?: rootPath, + lastScanTime = nowMillis, + allowedFileTypes = desktopSyncableTypes + ) + ) + } + + private fun scanFolder(root: File, sourceFolder: String): List { + val rootPath = root.toPath().toAbsolutePath().normalize() + return root.walkTopDown() + .onEnter { it == root || it.shouldEnterSyncedFolder() } + .filter { it.isFile && it.shouldSyncBookFile() } + .mapNotNull { file -> + val type = SharedFileCapabilities.fileTypeForName(file.name) + .takeIf { it in desktopSyncableTypes } + ?: return@mapNotNull null + val relativePath = runCatching { + rootPath.relativize(file.toPath().toAbsolutePath().normalize()) + .joinToString("/") + }.getOrNull() ?: file.name + SharedFolderScannedFile( + name = file.name, + path = file.absolutePath, + sourceFolder = sourceFolder, + relativePath = relativePath, + type = type, + size = file.length(), + lastModified = file.lastModified() + ) + } + .toList() + } + + private fun readAllMetadata(root: File): Map { + val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR) + if (!syncDir.isDirectory) return emptyMap() + return syncDir.listFiles().orEmpty() + .asSequence() + .filter { it.isFile } + .mapNotNull { file -> file.metadataBookIdOrNull()?.let { it to file } } + .groupBy({ it.first }, { it.second }) + .mapNotNull { (bookId, files) -> + val best = files + .mapNotNull { file -> + runCatching { SharedFolderBookMetadata.fromJsonString(file.readText()) }.getOrNull() + } + .filter { it.bookId == bookId } + .maxByOrNull { it.lastModifiedTimestamp } + best?.let { bookId to it } + } + .toMap() + } + + private fun saveMetadataToFolder(root: File, metadata: SharedFolderBookMetadata) { + val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR).apply { mkdirs() } + val existing = resolveMetadataConflicts(syncDir, metadata.bookId, cleanup = true) + if (existing != null && existing.lastModifiedTimestamp > metadata.lastModifiedTimestamp) return + + val target = File(syncDir, ".${metadata.bookId}.json") + val temp = File(syncDir, ".${metadata.bookId}.tmp") + runCatching { + temp.writeText(metadata.toJsonString()) + moveReplacing(temp, target) + }.onFailure { + runCatching { temp.delete() } + } + } + + private fun resolveMetadataConflicts( + syncDir: File, + bookId: String, + cleanup: Boolean + ): SharedFolderBookMetadata? { + val candidates = syncDir.listFiles().orEmpty().filter { file -> + val normalized = file.name.removePrefix(".") + file.isFile && ( + normalized == "$bookId.json" || + normalized.startsWith("$bookId.sync-conflict") || + normalized.startsWith("$bookId.json.sync-conflict") + ) + } + if (candidates.isEmpty()) return null + + val parsed = candidates.mapNotNull { file -> + val metadata = runCatching { SharedFolderBookMetadata.fromJsonString(file.readText()) }.getOrNull() + metadata?.takeIf { it.bookId == bookId }?.let { file to it } + } + val winner = parsed.maxByOrNull { it.second.lastModifiedTimestamp } ?: return null + + if (cleanup) { + candidates + .filterNot { it == winner.first } + .forEach { runCatching { it.delete() } } + val correctName = ".${bookId}.json" + if (winner.first.name != correctName) { + runCatching { moveReplacing(winner.first, File(syncDir, correctName)) } + } + } + + return winner.second + } + + private fun preloadAnnotationSidecars(root: File): Map { + val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR) + if (!syncDir.isDirectory) return emptyMap() + return syncDir.listFiles().orEmpty() + .asSequence() + .filter { it.isFile } + .mapNotNull { file -> file.annotationBookIdOrNull()?.let { it to file } } + .groupBy({ it.first }, { it.second }) + .mapNotNull { (bookId, files) -> + val best = files + .mapNotNull { it.readAnnotationSidecarOrNull() } + .maxByOrNull { it.timestamp } + best?.let { bookId to it } + } + .toMap() + } + + private fun importAnnotationSidecars(root: File, books: List) { + if (books.isEmpty()) return + val sidecars = preloadAnnotationSidecars(root) + if (sidecars.isEmpty()) return + + books.forEach { book -> + val path = book.path?.takeIf { it.isNotBlank() } ?: return@forEach + if (book.type != FileType.PDF) return@forEach + val sidecar = sidecars[book.id] ?: return@forEach + val annotationFile = desktopPdfAnnotationFile(path) + val bookmarkFile = desktopPdfBookmarkFile(path) + val richTextFile = desktopPdfRichTextFile(path) + val localTimestamp = maxOf( + annotationFile.lastModifiedIfFile(), + bookmarkFile.lastModifiedIfFile(), + richTextFile.lastModifiedIfFile() + ) + if (sidecar.timestamp <= localTimestamp + 1000L) { + if (sidecar.data.containsKey("text") || richTextFile.isFile) { + SharedPdfRichTextLog.d( + "desktop.sync.importSkipOlder book=${book.id} sidecarTs=${sidecar.timestamp} " + + "localTs=$localTimestamp hasSidecarText=${sidecar.data.containsKey("text")} " + + "richFile=\"${richTextFile.absolutePath.richSyncPreview()}\"" + ) + } + return@forEach + } + if (sidecar.data.hasPdfAnnotationPayload()) { + val annotations = SharedPdfAnnotationSidecarCodec.annotationsFromData(sidecar.data) + annotationFile.parentFile?.mkdirs() + annotationFile.writeText(SharedPdfAnnotationSerializer.encode(annotations)) + annotationFile.setLastModified(sidecar.timestamp) + } + sidecar.data["bookmarks"]?.let { bookmarks -> + bookmarkFile.parentFile?.mkdirs() + bookmarkFile.writeText(desktopFolderSyncJson.encodeToString(JsonElement.serializer(), bookmarks)) + bookmarkFile.setLastModified(sidecar.timestamp) + } + sidecar.data["text"]?.let { richText -> + val richDocument = SharedPdfRichTextSerializer.decodeElement(richText) + SharedPdfRichTextLog.d( + "desktop.sync.importRichText book=${book.id} timestamp=${sidecar.timestamp} " + + "textLen=${richDocument.text.length} spans=${richDocument.spans.size} " + + "file=\"${richTextFile.absolutePath.richSyncPreview()}\"" + ) + richTextFile.parentFile?.mkdirs() + richTextFile.writeText(SharedPdfRichTextSerializer.encode(richDocument)) + richTextFile.setLastModified(sidecar.timestamp) + } + } + } + + private fun saveAnnotationSidecar( + root: File, + bookId: String, + jsonPayload: String, + timestamp: Long + ) { + val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR).apply { mkdirs() } + val data = desktopFolderSyncJson.parseElementOrNull(jsonPayload)?.jsonObjectOrNull() ?: return + val existing = resolveAnnotationConflicts(syncDir, bookId, cleanup = true) + if (existing != null && existing.timestamp >= timestamp) { + if (data.containsKey("text")) { + SharedPdfRichTextLog.d( + "desktop.sync.saveSidecarSkipExisting book=$bookId existingTs=${existing.timestamp} " + + "candidateTs=$timestamp targetRoot=\"${root.absolutePath.richSyncPreview()}\"" + ) + } + return + } + + val wrapper = JsonObject( + mapOf( + "version" to JsonPrimitive(1), + "timestamp" to JsonPrimitive(timestamp), + "data" to data + ) + ) + val target = File(syncDir, ".${bookId}${LOCAL_FOLDER_ANNOTATION_SUFFIX}.json") + val temp = File(syncDir, ".${bookId}${LOCAL_FOLDER_ANNOTATION_SUFFIX}.tmp") + runCatching { + temp.writeText(desktopFolderSyncJson.encodeToString(JsonElement.serializer(), wrapper)) + moveReplacing(temp, target) + if (data.containsKey("text")) { + SharedPdfRichTextLog.d( + "desktop.sync.saveSidecar book=$bookId timestamp=$timestamp " + + "target=\"${target.absolutePath.richSyncPreview()}\"" + ) + } + }.onFailure { + if (data.containsKey("text")) { + SharedPdfRichTextLog.d( + "desktop.sync.saveSidecarFailed book=$bookId timestamp=$timestamp " + + "target=\"${target.absolutePath.richSyncPreview()}\" error=${it.message}" + ) + } + runCatching { temp.delete() } + } + } + + private fun resolveAnnotationConflicts( + syncDir: File, + bookId: String, + cleanup: Boolean + ): AnnotationSidecar? { + val candidates = syncDir.listFiles().orEmpty().filter { file -> + file.isFile && file.annotationBookIdOrNull() == bookId + } + if (candidates.isEmpty()) return null + val parsed = candidates.mapNotNull { file -> + file.readAnnotationSidecarOrNull()?.let { file to it } + } + val winner = parsed.maxByOrNull { it.second.timestamp } ?: return null + + if (cleanup) { + candidates + .filterNot { it == winner.first } + .forEach { runCatching { it.delete() } } + val correctName = ".${bookId}${LOCAL_FOLDER_ANNOTATION_SUFFIX}.json" + if (winner.first.name != correctName) { + runCatching { moveReplacing(winner.first, File(syncDir, correctName)) } + } + } + + return winner.second + } +} + +private data class AnnotationSidecar( + val timestamp: Long, + val data: JsonObject +) + +private val desktopFolderSyncJson = Json { + ignoreUnknownKeys = true + prettyPrint = true + encodeDefaults = true +} + +private fun File.shouldEnterSyncedFolder(): Boolean { + if (!isDirectory) return false + if (name == LOCAL_FOLDER_SYNC_DATA_DIR) return false + if (name.startsWith(".")) return false + return runCatching { !isHidden }.getOrDefault(true) +} + +private fun File.shouldSyncBookFile(): Boolean { + if (name.startsWith(".")) return false + if (extension.equals("json", ignoreCase = true)) return false + return parentFile?.name != LOCAL_FOLDER_SYNC_DATA_DIR +} + +private fun File.metadataBookIdOrNull(): String? { + val fileName = name + if (fileName.contains(LOCAL_FOLDER_ANNOTATION_SUFFIX)) return null + if (fileName.endsWith(".tmp") || fileName.contains(".syncthing.")) return null + if (!fileName.endsWith(".json") && !fileName.contains(".sync-conflict")) return null + val normalized = fileName.removePrefix(".") + val base = if (normalized.contains(".sync-conflict")) { + normalized.substringBefore(".sync-conflict") + } else { + normalized.substringBeforeLast(".json") + } + return base.removeSuffix(".json").takeIf { it.isNotBlank() } +} + +private fun File.annotationBookIdOrNull(): String? { + var candidate = name + if (!candidate.contains(LOCAL_FOLDER_ANNOTATION_SUFFIX)) return null + if (!candidate.endsWith(".json") || candidate.endsWith(".tmp")) return null + if (candidate.contains(".syncthing.")) return null + if (candidate.contains(".sync-conflict")) { + candidate = candidate.substringBefore(".sync-conflict") + } + candidate = candidate.substringBeforeLast(".json") + if (candidate.endsWith(LOCAL_FOLDER_ANNOTATION_SUFFIX)) { + candidate = candidate.substring(0, candidate.length - LOCAL_FOLDER_ANNOTATION_SUFFIX.length) + } + return candidate.removePrefix(".").takeIf { it.isNotBlank() } +} + +private fun File.readAnnotationSidecarOrNull(): AnnotationSidecar? { + return runCatching { + val root = desktopFolderSyncJson.parseToJsonElement(readText()).jsonObject + val timestamp = root["timestamp"]?.jsonPrimitive?.longOrNull ?: 0L + val data = root["data"]?.jsonObjectOrNull() ?: error("Missing annotation sidecar data") + AnnotationSidecar(timestamp = timestamp, data = data) + }.getOrNull() +} + +private fun Json.parseElementOrNull(raw: String): JsonElement? { + return runCatching { parseToJsonElement(raw) }.getOrNull() +} + +private fun JsonElement.jsonObjectOrNull(): JsonObject? { + if (this is JsonNull) return null + return runCatching { jsonObject }.getOrNull() +} + +private fun JsonObject.hasPdfAnnotationPayload(): Boolean { + return containsKey(SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS) || + containsKey(SharedPdfAnnotationSidecarCodec.KEY_LEGACY_INK) || + containsKey(SharedPdfAnnotationSidecarCodec.KEY_LEGACY_TEXT_BOXES) || + containsKey(SharedPdfAnnotationSidecarCodec.KEY_LEGACY_HIGHLIGHTS) +} + +private fun File.canonicalOrAbsolute(): File { + return runCatching { canonicalFile }.getOrElse { absoluteFile } +} + +private fun File.lastModifiedIfFile(): Long { + return if (isFile) lastModified() else 0L +} + +private fun String.richSyncPreview(maxLength: Int = 160): String { + return replace(Regex("\\s+"), " ") + .trim() + .let { if (it.length <= maxLength) it else it.take(maxLength) + "..." } + .replace("\"", "\\\"") +} + +private fun moveReplacing(source: File, target: File) { + target.parentFile?.mkdirs() + try { + Files.move( + source.toPath(), + target.toPath(), + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE + ) + } catch (_: AtomicMoveNotSupportedException) { + Files.move( + source.toPath(), + target.toPath(), + StandardCopyOption.REPLACE_EXISTING + ) + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopOpdsRepository.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopOpdsRepository.kt new file mode 100644 index 0000000..0fe5410 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopOpdsRepository.kt @@ -0,0 +1,190 @@ +package com.aryan.reader.desktop + +import com.aryan.reader.shared.opds.OpdsAcquisition +import com.aryan.reader.shared.opds.OpdsCatalog +import com.aryan.reader.shared.opds.OpdsEntry +import com.aryan.reader.shared.opds.OpdsFeed +import com.aryan.reader.shared.opds.SharedOpdsCatalogs +import com.aryan.reader.shared.opds.SharedOpdsDownloadNamer +import com.aryan.reader.shared.opds.SharedOpdsParser +import com.aryan.reader.shared.opds.SharedOpdsRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.net.Authenticator +import java.net.PasswordAuthentication +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.time.Duration +import java.util.UUID + +internal class DesktopOpdsRepository( + private val catalogFile: File = defaultCatalogFile(), + private val idFactory: () -> String = { UUID.randomUUID().toString() } +) : SharedOpdsRepository { + private val parser = SharedOpdsParser() + + override fun loadCatalogs(): List { + val rawJson = catalogFile.takeIf { it.exists() }?.readText() + val decodedCatalogs = SharedOpdsCatalogs.decode(rawJson) + val catalogs = decodedCatalogs.ifEmpty { SharedOpdsCatalogs.defaultCatalogs(idFactory) } + if (decodedCatalogs.isEmpty()) saveCatalogs(catalogs) + return catalogs + } + + override fun saveCatalogs(catalogs: List) { + catalogFile.parentFile?.mkdirs() + catalogFile.writeText(SharedOpdsCatalogs.encode(catalogs)) + } + + override suspend fun fetchFeed(url: String, username: String?, password: String?): Result = withContext(Dispatchers.IO) { + runCatching { + val response = DesktopOpdsHttp.fetchString(url, username, password) + if (response.statusCode !in 200..299) { + error("HTTP ${response.statusCode}") + } + if (response.body.isBlank()) error("Empty response body") + parser.parse(response.body, url) + } + } + + override suspend fun getSearchTemplate(openSearchUrl: String, username: String?, password: String?): String? = withContext(Dispatchers.IO) { + runCatching { + val response = DesktopOpdsHttp.fetchString(openSearchUrl, username, password) + if (response.statusCode !in 200..299) return@withContext null + parser.extractOpenSearchTemplate(response.body, openSearchUrl) + }.getOrNull() + } + + suspend fun downloadBook( + entry: OpdsEntry, + acquisition: OpdsAcquisition, + catalog: OpdsCatalog?, + onProgress: (Float?) -> Unit + ): File = withContext(Dispatchers.IO) { + val response = DesktopOpdsHttp.fetchStream(acquisition.url, catalog?.username, catalog?.password) + if (response.statusCode !in 200..299) { + response.body.close() + error("HTTP ${response.statusCode}") + } + + val contentLength = response.headers.firstValueAsLong("content-length").orElse(-1L) + val contentDisposition = response.headers.firstValue("content-disposition").orElse(null) + val urlName = runCatching { + URI(acquisition.url).path.substringAfterLast('/').takeIf { it.isNotBlank() } + }.getOrNull() + val extension = SharedOpdsDownloadNamer.resolveExtension(acquisition, contentDisposition, urlName) + val target = uniqueDownloadFile(SharedOpdsDownloadNamer.safeFileStem(entry.title), extension) + + response.body.use { input -> + target.outputStream().use { output -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var totalRead = 0L + var lastProgressAt = 0L + while (true) { + val read = input.read(buffer) + if (read < 0) break + if (read > 0) { + output.write(buffer, 0, read) + totalRead += read + if (contentLength > 0) { + val now = System.currentTimeMillis() + if (now - lastProgressAt >= 200L) { + onProgress((totalRead.toFloat() / contentLength.toFloat()).coerceIn(0f, 1f)) + lastProgressAt = now + } + } + } + } + } + } + onProgress(1f) + target + } + + fun catalogById(id: String?): OpdsCatalog? { + if (id.isNullOrBlank()) return null + return loadCatalogs().firstOrNull { it.id == id } + } + + private fun uniqueDownloadFile(stem: String, extension: String): File { + val dir = opdsDownloadsDir().apply { mkdirs() } + var candidate = File(dir, "$stem$extension") + var index = 1 + while (candidate.exists()) { + candidate = File(dir, "${stem}_$index$extension") + index += 1 + } + return candidate + } + + companion object { + fun defaultCatalogFile(): File { + return File(DesktopLibraryDatabase.defaultDatabaseFile().parentFile, "opds_catalogs.json") + } + + fun opdsDownloadsDir(): File { + return File(DesktopLibraryDatabase.defaultDatabaseFile().parentFile, "opds_downloads") + } + } +} + +internal data class DesktopOpdsTextResponse( + val statusCode: Int, + val body: String +) + +internal data class DesktopOpdsStreamResponse( + val statusCode: Int, + val headers: java.net.http.HttpHeaders, + val body: java.io.InputStream +) + +internal object DesktopOpdsHttp { + fun fetchString(url: String, username: String?, password: String?): DesktopOpdsTextResponse { + val request = request(url).build() + val response = client(username, password).send(request, HttpResponse.BodyHandlers.ofString()) + return DesktopOpdsTextResponse(response.statusCode(), response.body().orEmpty()) + } + + fun fetchStream(url: String, username: String?, password: String?): DesktopOpdsStreamResponse { + val request = request(url).build() + val response = client(username, password).send(request, HttpResponse.BodyHandlers.ofInputStream()) + return DesktopOpdsStreamResponse(response.statusCode(), response.headers(), response.body()) + } + + fun fetchBytes(url: String, catalog: OpdsCatalog?): ByteArray { + val request = request(url).build() + val response = client(catalog?.username, catalog?.password).send(request, HttpResponse.BodyHandlers.ofByteArray()) + if (response.statusCode() !in 200..299) { + error("HTTP ${response.statusCode()}") + } + return response.body() + } + + private fun request(url: String): HttpRequest.Builder { + return HttpRequest.newBuilder(URI(url.trim())) + .timeout(Duration.ofSeconds(45)) + .header("User-Agent", "EpistemeReader/1.0 (Desktop)") + } + + private fun client(username: String?, password: String?): HttpClient { + val builder = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(20)) + .followRedirects(HttpClient.Redirect.NORMAL) + + if (!username.isNullOrBlank() && !password.isNullOrBlank()) { + builder.authenticator( + object : Authenticator() { + override fun getPasswordAuthentication(): PasswordAuthentication { + return PasswordAuthentication(username, password.toCharArray()) + } + } + ) + } + + return builder.build() + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfium.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfium.kt index 8930272..d657369 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfium.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopPdfium.kt @@ -2,7 +2,18 @@ package com.aryan.reader.desktop import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.toComposeImageBitmap +import com.aryan.reader.shared.FileType +import com.aryan.reader.shared.PdfTocEntry +import com.aryan.reader.shared.opds.OpdsCatalog +import com.aryan.reader.shared.opds.OpdsStreamReference +import com.aryan.reader.shared.pdf.PdfPageBounds import com.aryan.reader.shared.pdf.PdfZoomSpec +import com.aryan.reader.shared.pdf.PdfiumAnnotationSubtype +import com.aryan.reader.shared.pdf.SharedPdfEmbeddedAnnotation +import com.aryan.reader.shared.pdf.SharedPdfEmbeddedAnnotationThreads +import com.aryan.reader.shared.pdf.SharedPdfIndexedPage +import com.aryan.reader.shared.pdf.SharedPdfSearchIndex +import com.aryan.reader.shared.pdf.SharedPdfSearchResult import com.sun.jna.Library import com.sun.jna.Memory import com.sun.jna.Native @@ -17,8 +28,53 @@ data class DesktopPdfDocument( val title: String, val pageCount: Int, val pageSizes: List, - val textPages: List + val formatLabel: String = "PDF", + val toc: List = emptyList(), + val embeddedAnnotations: List = emptyList() ) { + private val textPageCache = LinkedHashMap() + private val searchIndex = SharedPdfSearchIndex(pageCount) + + fun textPageData(pageIndex: Int): DesktopPdfTextPageData { + if (pageIndex !in 0 until pageCount) return DesktopPdfTextPageData() + val cached = synchronized(textPageCache) { textPageCache[pageIndex] } + if (cached != null) return cached + val loaded = DesktopPdfium.loadTextPageData(this, pageIndex) + return cacheTextPageData(pageIndex, loaded) + } + + fun cacheTextPageData(pageIndex: Int, data: DesktopPdfTextPageData): DesktopPdfTextPageData { + if (pageIndex !in 0 until pageCount) return data + synchronized(textPageCache) { + textPageCache[pageIndex] = data + } + cacheSearchTextPage(pageIndex, data.text) + return data + } + + fun cacheSearchTextPage(pageIndex: Int, text: String) { + if (pageIndex !in 0 until pageCount) return + synchronized(searchIndex) { + searchIndex.putPage(pageIndex, text) + } + } + + fun isSearchTextPageIndexed(pageIndex: Int): Boolean { + return synchronized(searchIndex) { searchIndex.hasPage(pageIndex) } + } + + fun indexedSearchTextPageCount(): Int { + return synchronized(searchIndex) { searchIndex.indexedPageCount } + } + + fun indexedSearchPages(): List { + return synchronized(searchIndex) { searchIndex.indexedPages() } + } + + fun searchIndexed(query: String): List { + return synchronized(searchIndex) { searchIndex.search(query) } + } + fun close() { DesktopPdfium.closeDocument(path) } @@ -35,12 +91,47 @@ data class DesktopPdfPageRender( val height: Int ) +data class DesktopPdfMetadata( + val title: String? = null, + val author: String? = null +) + +data class DesktopPdfTextChar( + val index: Int, + val char: Char, + val left: Float, + val top: Float, + val right: Float, + val bottom: Float +) { + val hasBounds: Boolean + get() = right > left && bottom > top +} + +data class DesktopPdfTextRect( + val left: Float, + val top: Float, + val right: Float, + val bottom: Float +) + +data class DesktopPdfLinkTarget( + val uri: String? = null, + val destPageIndex: Int? = null +) + +data class DesktopPdfTextPageData( + val text: String = "", + val chars: List = emptyList() +) + object DesktopPdfium { private const val FPDF_ANNOT = 0x01 private const val FPDF_LCD_TEXT = 0x02 private const val FPDF_RENDER_NO_SMOOTHTEXT = 0x1000 private const val FPDF_BITMAP_BGRA = 4 + private val textUrlRegex = Regex("""\b(?:https?://|www\.)[^\s<>"']+""", RegexOption.IGNORE_CASE) private val pdfiumDll: File by lazy(::resolvePdfiumDll) private val zoomSpec = PdfZoomSpec() private val api: PdfiumLibrary by lazy { @@ -51,50 +142,288 @@ object DesktopPdfium { } private var initialized = false - private val openDocuments = LinkedHashMap() + private val openDocuments = LinkedHashMap() + private val openComicDocuments = LinkedHashMap() fun isAvailable(): Boolean = pdfiumDll.exists() - fun load(file: File, password: String? = null): DesktopPdfDocument { - initLibrary() - val document = api.FPDF_LoadDocument(file.absolutePath, password) - ?: error("Pdfium could not open ${file.name}. It may be encrypted or unsupported.") - val pageCount = api.FPDF_GetPageCount(document) - openDocuments[file.absolutePath] = document + private fun loadDocument(file: File, password: String?): DesktopOpenPdfDocument { + val pathHasNonAscii = file.absolutePath.any { it.code > 0x7F } + logPdfiumOpen( + "open_start path=\"${file.absolutePath}\" exists=${file.exists()} " + + "canRead=${file.canRead()} size=${runCatching { file.length() }.getOrDefault(-1L)} " + + "nonAsciiPath=$pathHasNonAscii dll=\"${pdfiumDll.absolutePath}\"" + ) + val pathError = if (pathHasNonAscii) { + logPdfiumOpen("path_load_skipped reason=non_ascii_path path=\"${file.absolutePath}\"") + null + } else { + val pathDocument = api.FPDF_LoadDocument(file.absolutePath, password) + if (pathDocument != null) { + logPdfiumOpen("path_load_success path=\"${file.absolutePath}\"") + return DesktopOpenPdfDocument(pointer = pathDocument) + } - val pageSizes = (0 until pageCount).map { pageIndex -> - loadPage(document, pageIndex).usePointer { page -> - DesktopPdfPageSize( - width = api.FPDF_GetPageWidthF(page), - height = api.FPDF_GetPageHeightF(page) + api.FPDF_GetLastError().also { errorCode -> + logPdfiumOpen( + "path_load_failed code=$errorCode message=\"${pdfiumLoadErrorMessage(errorCode)}\" " + + "path=\"${file.absolutePath}\"" ) } } - val textPages = (0 until pageCount).map { pageIndex -> - extractPageText(document, pageIndex) + val bytes = runCatching { file.readBytes() } + .onFailure { throwable -> + logPdfiumOpen("read_bytes_failed path=\"${file.absolutePath}\" error=\"${throwable.message.orEmpty()}\"") + } + .getOrNull() + if (bytes != null && bytes.size > 0) { + logPdfiumOpen("memory_load_start bytes=${bytes.size} path=\"${file.absolutePath}\"") + val memory = Memory(bytes.size.toLong()) + memory.write(0, bytes, 0, bytes.size) + val memoryDocument = api.FPDF_LoadMemDocument(memory, bytes.size, password) + if (memoryDocument != null) { + logPdfiumOpen("memory_load_success bytes=${bytes.size} path=\"${file.absolutePath}\"") + return DesktopOpenPdfDocument(pointer = memoryDocument, backingMemory = memory) + } + val memoryError = api.FPDF_GetLastError() + logPdfiumOpen( + "memory_load_failed code=$memoryError message=\"${pdfiumLoadErrorMessage(memoryError)}\" " + + "bytes=${bytes.size} path=\"${file.absolutePath}\"" + ) + val pathMessage = pathError?.let { "path load: ${pdfiumLoadErrorMessage(it)}" } + ?: "path load skipped for non-ASCII path" + error( + "Pdfium could not open ${file.name}. ${pdfiumLoadErrorMessage(memoryError)} " + + "($pathMessage)." + ) } + logPdfiumOpen("memory_load_skipped reason=empty_or_unreadable path=\"${file.absolutePath}\"") + val pathMessage = pathError?.let(::pdfiumLoadErrorMessage) ?: "path load skipped for non-ASCII path" + error("Pdfium could not open ${file.name}. $pathMessage") + } + + @Synchronized + fun load(file: File, password: String? = null): DesktopPdfDocument { + initLibrary() + val startedAt = System.currentTimeMillis() + val loadedDocument = loadDocument(file, password) + val document = loadedDocument.pointer + closeDocument(file.absolutePath) + openDocuments[file.absolutePath] = loadedDocument + + try { + val pageCount = api.FPDF_GetPageCount(document) + logPdfiumOpen("metadata_loaded pageCount=$pageCount elapsedMs=${System.currentTimeMillis() - startedAt}") + val pageSizes = (0 until pageCount).map { pageIndex -> + loadPage(document, pageIndex).usePointer { page -> + DesktopPdfPageSize( + width = api.FPDF_GetPageWidthF(page), + height = api.FPDF_GetPageHeightF(page) + ) + } + } + logPdfiumOpen("page_sizes_loaded pages=$pageCount elapsedMs=${System.currentTimeMillis() - startedAt}") + + val metadata = extractDocumentMetadata(document) + logPdfiumOpen("text_index_deferred pages=$pageCount elapsedMs=${System.currentTimeMillis() - startedAt}") + val toc = extractTableOfContents(document, pageCount) + logPdfiumOpen("toc_extracted entries=${toc.size} elapsedMs=${System.currentTimeMillis() - startedAt}") + val embeddedAnnotations = extractEmbeddedAnnotations(document, pageSizes) + logPdfiumOpen( + "embedded_annotations_extracted count=${embeddedAnnotations.size} " + + "elapsedMs=${System.currentTimeMillis() - startedAt}" + ) + + val result = DesktopPdfDocument( + path = file.absolutePath, + title = metadata.title ?: file.nameWithoutExtension, + pageCount = pageCount, + pageSizes = pageSizes, + toc = toc, + embeddedAnnotations = embeddedAnnotations + ) + logPdfiumOpen("open_complete elapsedMs=${System.currentTimeMillis() - startedAt}") + return result + } catch (throwable: Throwable) { + openDocuments.remove(file.absolutePath) + api.FPDF_CloseDocument(document) + throw throwable + } + } + + @Synchronized + fun loadComic(file: File, type: FileType): DesktopPdfDocument { + val startedAt = System.currentTimeMillis() + val comic = DesktopComicArchive.load(file, type) + closeDocument(file.absolutePath) + openComicDocuments[file.absolutePath] = comic + logPdfiumOpen( + "comic_open_complete type=${type.name} pages=${comic.pageCount} " + + "elapsedMs=${System.currentTimeMillis() - startedAt}" + ) return DesktopPdfDocument( path = file.absolutePath, - title = file.nameWithoutExtension, - pageCount = pageCount, - pageSizes = pageSizes, - textPages = textPages + title = comic.title, + pageCount = comic.pageCount, + pageSizes = comic.pageSizes, + formatLabel = type.name ) } - fun closeDocument(path: String) { - openDocuments.remove(path)?.let(api::FPDF_CloseDocument) + @Synchronized + fun loadOpdsStream( + path: String, + title: String, + reference: OpdsStreamReference, + catalog: OpdsCatalog? + ): DesktopPdfDocument { + val startedAt = System.currentTimeMillis() + val comic = DesktopComicArchive.loadOpdsStream(path, title, reference, catalog) + closeDocument(path) + openComicDocuments[path] = comic + logPdfiumOpen( + "opds_stream_open_complete pages=${comic.pageCount} " + + "elapsedMs=${System.currentTimeMillis() - startedAt}" + ) + return DesktopPdfDocument( + path = path, + title = title, + pageCount = comic.pageCount, + pageSizes = comic.pageSizes, + formatLabel = "OPDS" + ) } + @Synchronized + fun extractMetadata(file: File, password: String? = null): DesktopPdfMetadata { + initLibrary() + val loadedDocument = loadDocument(file, password) + return try { + extractDocumentMetadata(loadedDocument.pointer) + } finally { + api.FPDF_CloseDocument(loadedDocument.pointer) + } + } + + @Synchronized + fun closeDocument(path: String) { + openDocuments.remove(path)?.let { api.FPDF_CloseDocument(it.pointer) } + openComicDocuments.remove(path)?.close() + } + + fun indexSearchPages( + document: DesktopPdfDocument, + onProgress: (indexedPageCount: Int, pageCount: Int) -> Unit = { _, _ -> }, + shouldContinue: () -> Boolean = { true } + ) { + val startedAt = System.currentTimeMillis() + onProgress(document.indexedSearchTextPageCount(), document.pageCount) + for (pageIndex in 0 until document.pageCount) { + if (!shouldContinue()) { + logPdfiumOpen( + "search_index_cancelled pages=${document.indexedSearchTextPageCount()}/${document.pageCount} " + + "elapsedMs=${System.currentTimeMillis() - startedAt}" + ) + return + } + val wasIndexed = document.isSearchTextPageIndexed(pageIndex) + if (!wasIndexed) { + val text = loadTextOnlyPage(document, pageIndex) + document.cacheSearchTextPage(pageIndex, text) + } + val indexed = document.indexedSearchTextPageCount() + if (pageIndex == document.pageCount - 1 || (!wasIndexed && indexed % 25 == 0)) { + onProgress(indexed, document.pageCount) + } + } + logPdfiumOpen( + "search_index_complete pages=${document.indexedSearchTextPageCount()}/${document.pageCount} " + + "elapsedMs=${System.currentTimeMillis() - startedAt}" + ) + } + + @Synchronized + fun loadTextOnlyPage(document: DesktopPdfDocument, pageIndex: Int): String { + if (openComicDocuments.containsKey(document.path)) return "" + val nativeDocument = openDocuments[document.path]?.pointer ?: return "" + if (document.pageSizes.getOrNull(pageIndex) == null) return "" + return extractPageText(nativeDocument, pageIndex) + } + + @Synchronized + fun loadTextPageData(document: DesktopPdfDocument, pageIndex: Int): DesktopPdfTextPageData { + if (openComicDocuments.containsKey(document.path)) return DesktopPdfTextPageData() + val nativeDocument = openDocuments[document.path]?.pointer ?: return DesktopPdfTextPageData() + val pageSize = document.pageSizes.getOrNull(pageIndex) ?: return DesktopPdfTextPageData() + return extractPageTextData(nativeDocument, pageIndex, pageSize) + } + + fun search(document: DesktopPdfDocument, query: String): List { + return document.searchIndexed(query) + } + + @Synchronized + fun linkAt( + document: DesktopPdfDocument, + pageIndex: Int, + normalizedX: Float, + normalizedY: Float, + viewportWidth: Int? = null, + viewportHeight: Int? = null + ): DesktopPdfLinkTarget? { + if (openComicDocuments.containsKey(document.path)) return null + val nativeDocument = openDocuments[document.path]?.pointer ?: run { + logPdfiumLink("hit_test_skipped reason=document_not_open page=${pageIndex + 1}") + return null + } + val pageSize = document.pageSizes.getOrNull(pageIndex) ?: run { + logPdfiumLink("hit_test_skipped reason=invalid_page page=${pageIndex + 1}") + return null + } + val viewport = pageSize.normalizedViewport(viewportWidth, viewportHeight) + logPdfiumLink( + "hit_test_start page=${pageIndex + 1} nx=${normalizedX.formatLogFloat()} ny=${normalizedY.formatLogFloat()} " + + "viewport=${viewport.width}x${viewport.height}" + ) + return runCatching { + loadPage(nativeDocument, pageIndex).usePointer { page -> + val pagePoint = deviceToPagePoint( + page = page, + viewport = viewport, + normalizedX = normalizedX, + normalizedY = normalizedY + ) + logPdfiumLink( + "hit_test_page_point page=${pageIndex + 1} " + + "x=${pagePoint.first.formatLogDouble()} y=${pagePoint.second.formatLogDouble()}" + ) + linkAnnotationAt(nativeDocument, page, pageIndex, pagePoint.first, pagePoint.second) + ?: webLinkAt(page, pageIndex, pagePoint.first, pagePoint.second, pageSize) + ?: textUrlAt(page, pageIndex, pagePoint.first, pagePoint.second, pageSize) + } + }.onFailure { throwable -> + logPdfiumLink("hit_test_failed page=${pageIndex + 1} error=\"${throwable.message.orEmpty().logPreview()}\"") + }.getOrNull() + } + + @Synchronized fun renderPage( document: DesktopPdfDocument, pageIndex: Int, scale: Float, renderAnnotations: Boolean = true ): DesktopPdfPageRender { - val nativeDocument = openDocuments[document.path] ?: error("PDF document is not open.") + openComicDocuments[document.path]?.let { comic -> + val image = comic.renderPageBufferedImage(pageIndex, scale) + return DesktopPdfPageRender( + image = image.toComposeImageBitmap(), + width = image.width, + height = image.height + ) + } + val nativeDocument = openDocuments[document.path]?.pointer ?: error("PDF document is not open.") val pageSize = document.pageSizes.getOrNull(pageIndex) ?: error("Invalid PDF page index $pageIndex.") val safeScale = zoomSpec.safeRenderScale(pageSize.width, pageSize.height, scale) val width = (pageSize.width * safeScale).roundToInt().coerceAtLeast(1) @@ -123,20 +452,323 @@ object DesktopPdfium { } } + @Synchronized + fun renderPageBufferedImage( + document: DesktopPdfDocument, + pageIndex: Int, + scale: Float, + renderAnnotations: Boolean = true + ): BufferedImage { + openComicDocuments[document.path]?.let { comic -> + return comic.renderPageBufferedImage(pageIndex, scale) + } + val nativeDocument = openDocuments[document.path]?.pointer ?: error("PDF document is not open.") + val pageSize = document.pageSizes.getOrNull(pageIndex) ?: error("Invalid PDF page index $pageIndex.") + val safeScale = zoomSpec.safeRenderScale(pageSize.width, pageSize.height, scale) + val width = (pageSize.width * safeScale).roundToInt().coerceAtLeast(1) + val height = (pageSize.height * safeScale).roundToInt().coerceAtLeast(1) + val stride = width * 4 + val memory = Memory((stride * height).toLong()) + memory.clear(memory.size()) + + val bitmap = api.FPDFBitmap_CreateEx(width, height, FPDF_BITMAP_BGRA, memory, stride) + ?: error("Pdfium could not allocate render bitmap.") + + try { + api.FPDFBitmap_FillRect(bitmap, 0, 0, width, height, -1) + loadPage(nativeDocument, pageIndex).usePointer { page -> + val flags = FPDF_LCD_TEXT or + (if (renderAnnotations) FPDF_ANNOT else FPDF_RENDER_NO_SMOOTHTEXT) + api.FPDF_RenderPageBitmap(bitmap, page, 0, 0, width, height, 0, flags) + } + return memory.toBufferedImage(width, height, stride) + } finally { + api.FPDFBitmap_Destroy(bitmap) + } + } + + @Synchronized + fun charIndexAt( + document: DesktopPdfDocument, + pageIndex: Int, + normalizedX: Float, + normalizedY: Float, + viewportWidth: Int? = null, + viewportHeight: Int? = null, + tolerance: Float = 0.006f + ): Int? { + val nativeDocument = openDocuments[document.path]?.pointer ?: return null + val pageSize = document.pageSizes.getOrNull(pageIndex) ?: return null + val viewport = pageSize.normalizedViewport(viewportWidth, viewportHeight) + return runCatching { + loadPage(nativeDocument, pageIndex).usePointer { page -> + val textPage = api.FPDFText_LoadPage(page) ?: return@usePointer null + try { + val pagePoint = deviceToPagePoint( + page = page, + viewport = viewport, + normalizedX = normalizedX, + normalizedY = normalizedY + ) + api.FPDFText_GetCharIndexAtPos( + textPage, + pagePoint.first, + pagePoint.second, + (pageSize.width * tolerance).toDouble(), + (pageSize.height * tolerance).toDouble() + ).takeIf { it >= 0 } + } finally { + api.FPDFText_ClosePage(textPage) + } + } + }.getOrNull() + } + + @Synchronized + fun textRectsForRange( + document: DesktopPdfDocument, + pageIndex: Int, + startIndex: Int, + endIndex: Int, + viewportWidth: Int? = null, + viewportHeight: Int? = null + ): List { + val nativeDocument = openDocuments[document.path]?.pointer ?: return emptyList() + val pageSize = document.pageSizes.getOrNull(pageIndex) ?: return emptyList() + val viewport = pageSize.normalizedViewport(viewportWidth, viewportHeight) + val first = minOf(startIndex, endIndex).coerceAtLeast(0) + val count = (maxOf(startIndex, endIndex) - first + 1).coerceAtLeast(1) + return runCatching { + loadPage(nativeDocument, pageIndex).usePointer { page -> + val textPage = api.FPDFText_LoadPage(page) ?: return@usePointer emptyList() + try { + val rectCount = api.FPDFText_CountRects(textPage, first, count) + (0 until rectCount).mapNotNull { rectIndex -> + val left = DoubleArray(1) + val top = DoubleArray(1) + val right = DoubleArray(1) + val bottom = DoubleArray(1) + val hasRect = api.FPDFText_GetRect(textPage, rectIndex, left, top, right, bottom) != 0 + if (!hasRect || right[0] <= left[0] || top[0] <= bottom[0]) { + null + } else { + val bounds = pageToNormalizedBounds( + page = page, + pageSize = pageSize, + viewport = viewport, + left = left[0], + top = top[0], + right = right[0], + bottom = bottom[0] + ) + DesktopPdfTextRect( + left = bounds.left, + top = bounds.top, + right = bounds.right, + bottom = bounds.bottom + ) + } + } + } finally { + api.FPDFText_ClosePage(textPage) + } + } + }.getOrDefault(emptyList()) + } + + private fun linkAnnotationAt( + document: Pointer, + page: Pointer, + pageIndex: Int, + pageX: Double, + pageY: Double + ): DesktopPdfLinkTarget? { + val link = runCatching { api.FPDFLink_GetLinkAtPoint(page, pageX, pageY) }.getOrNull() + ?: return null + + val action = runCatching { api.FPDFLink_GetAction(link) }.getOrNull() + if (action != null) { + when (val actionType = runCatching { api.FPDFAction_GetType(action) }.getOrDefault(0)) { + 1 -> actionDestinationPage(document, action)?.let { + logPdfiumLink("annotation_hit page=${pageIndex + 1} action=goto targetPage=${it + 1}") + return DesktopPdfLinkTarget(destPageIndex = it) + } + 2, 4 -> actionFilePath(action)?.let { + logPdfiumLink("annotation_hit page=${pageIndex + 1} action=file uri=\"${it.logPreview()}\"") + return DesktopPdfLinkTarget(uri = it) + } + 3 -> actionUri(document, action)?.let { + logPdfiumLink("annotation_hit page=${pageIndex + 1} action=uri uri=\"${it.logPreview()}\"") + return DesktopPdfLinkTarget(uri = it) + } + else -> logPdfiumLink("annotation_hit_unsupported page=${pageIndex + 1} actionType=$actionType") + } + } + + val dest = runCatching { api.FPDFLink_GetDest(document, link) }.getOrNull() + val targetPageIndex = dest?.let { runCatching { api.FPDFDest_GetDestPageIndex(document, it) }.getOrNull() } + return targetPageIndex + ?.takeIf { it >= 0 } + ?.let { + logPdfiumLink("annotation_hit page=${pageIndex + 1} action=dest targetPage=${it + 1}") + DesktopPdfLinkTarget(destPageIndex = it) + } + } + + private fun webLinkAt( + page: Pointer, + pageIndex: Int, + pageX: Double, + pageY: Double, + pageSize: DesktopPdfPageSize + ): DesktopPdfLinkTarget? { + val textPage = api.FPDFText_LoadPage(page) ?: run { + logPdfiumLink("web_link_skipped page=${pageIndex + 1} reason=text_page_unavailable") + return null + } + try { + val linkPage = runCatching { api.FPDFText_LoadWebLinks(textPage) }.getOrNull() + ?: run { + logPdfiumLink("web_link_skipped page=${pageIndex + 1} reason=web_links_unavailable") + return null + } + try { + val count = runCatching { api.FPDFLink_CountWebLinks(linkPage) }.getOrDefault(0) + logPdfiumLink("web_link_scan page=${pageIndex + 1} count=$count") + val toleranceX = pageSize.width.toDouble() * 0.006 + val toleranceY = pageSize.height.toDouble() * 0.006 + for (linkIndex in 0 until count) { + val rectCount = runCatching { api.FPDFLink_CountRects(linkPage, linkIndex) }.getOrDefault(0) + for (rectIndex in 0 until rectCount) { + val left = DoubleArray(1) + val top = DoubleArray(1) + val right = DoubleArray(1) + val bottom = DoubleArray(1) + val hasRect = runCatching { + api.FPDFLink_GetRect(linkPage, linkIndex, rectIndex, left, top, right, bottom) + }.getOrDefault(0) != 0 + if (!hasRect) continue + val minX = minOf(left[0], right[0]) - toleranceX + val maxX = maxOf(left[0], right[0]) + toleranceX + val minY = minOf(top[0], bottom[0]) - toleranceY + val maxY = maxOf(top[0], bottom[0]) + toleranceY + if (pageX in minX..maxX && pageY in minY..maxY) { + webLinkUrl(linkPage, linkIndex)?.let { + val url = it.normalizedDetectedTextUrl() + logPdfiumLink( + "web_link_hit page=${pageIndex + 1} link=$linkIndex rect=$rectIndex " + + "uri=\"${url.logPreview()}\"" + ) + return DesktopPdfLinkTarget(uri = url) + } + } + } + } + logPdfiumLink("web_link_miss page=${pageIndex + 1} count=$count") + } finally { + runCatching { api.FPDFLink_CloseWebLinks(linkPage) } + } + } finally { + api.FPDFText_ClosePage(textPage) + } + return null + } + + private fun textUrlAt( + page: Pointer, + pageIndex: Int, + pageX: Double, + pageY: Double, + pageSize: DesktopPdfPageSize + ): DesktopPdfLinkTarget? { + val textPage = api.FPDFText_LoadPage(page) ?: run { + logPdfiumLink("text_url_skipped page=${pageIndex + 1} reason=text_page_unavailable") + return null + } + try { + val charIndex = runCatching { + api.FPDFText_GetCharIndexAtPos( + textPage, + pageX, + pageY, + pageSize.width.toDouble() * 0.012, + pageSize.height.toDouble() * 0.012 + ) + }.getOrDefault(-1) + if (charIndex < 0) { + logPdfiumLink("text_url_miss page=${pageIndex + 1} reason=no_char") + return null + } + val charCount = api.FPDFText_CountChars(textPage) + if (charCount <= 0) { + logPdfiumLink("text_url_miss page=${pageIndex + 1} reason=no_text charIndex=$charIndex") + return null + } + val text = extractText(textPage, charCount) + val match = textUrlRegex.findAll(text).firstOrNull { result -> + val start = (result.range.first - 2).coerceAtLeast(0) + val end = (result.range.last + 2).coerceAtMost(text.lastIndex) + charIndex in start..end + } + if (match == null) { + logPdfiumLink("text_url_miss page=${pageIndex + 1} reason=no_url_at_char charIndex=$charIndex") + return null + } + val url = match.value.normalizedDetectedTextUrl() + logPdfiumLink( + "text_url_hit page=${pageIndex + 1} charIndex=$charIndex " + + "range=${match.range.first}..${match.range.last} uri=\"${url.logPreview()}\"" + ) + return DesktopPdfLinkTarget(uri = url) + } finally { + api.FPDFText_ClosePage(textPage) + } + } + + private fun actionDestinationPage(document: Pointer, action: Pointer): Int? { + val dest = runCatching { api.FPDFAction_GetDest(document, action) }.getOrNull() ?: return null + return runCatching { api.FPDFDest_GetDestPageIndex(document, dest) } + .getOrNull() + ?.takeIf { it >= 0 } + } + + private fun actionUri(document: Pointer, action: Pointer): String? { + val length = runCatching { api.FPDFAction_GetURIPath(document, action, null, 0) }.getOrDefault(0) + if (length <= 0) return null + val buffer = Memory(length.toLong()) + val written = runCatching { api.FPDFAction_GetURIPath(document, action, buffer, length) }.getOrDefault(0) + return if (written <= 0) null else buffer.getString(0).trimEnd('\u0000').takeIf { it.isNotBlank() } + } + + private fun actionFilePath(action: Pointer): String? { + val length = runCatching { api.FPDFAction_GetFilePath(action, null, 0) }.getOrDefault(0) + if (length <= 0) return null + val buffer = Memory(length.toLong()) + val written = runCatching { api.FPDFAction_GetFilePath(action, buffer, length) }.getOrDefault(0) + return if (written <= 0) null else buffer.getString(0).trimEnd('\u0000').takeIf { it.isNotBlank() } + } + + private fun webLinkUrl(linkPage: Pointer, linkIndex: Int): String? { + val maxChars = 2048 + val buffer = Memory(maxChars * 2L) + val written = runCatching { api.FPDFLink_GetURL(linkPage, linkIndex, buffer, maxChars) }.getOrDefault(0) + return if (written <= 0) { + null + } else { + buffer.getCharArray(0, written.coerceAtMost(maxChars)) + .concatToString() + .trimEnd('\u0000') + .takeIf { it.isNotBlank() } + } + } + private fun extractPageText(document: Pointer, pageIndex: Int): String { return runCatching { loadPage(document, pageIndex).usePointer { page -> val textPage = api.FPDFText_LoadPage(page) ?: return@usePointer "" try { val charCount = api.FPDFText_CountChars(textPage) - if (charCount <= 0) return@usePointer "" - val buffer = Memory(((charCount + 1) * 2L)) - val written = api.FPDFText_GetText(textPage, 0, charCount, buffer) - if (written <= 0) { - "" - } else { - buffer.getCharArray(0, written).concatToString().trimEnd('\u0000') - } + extractText(textPage, charCount) } finally { api.FPDFText_ClosePage(textPage) } @@ -144,6 +776,220 @@ object DesktopPdfium { }.getOrDefault("") } + private fun extractPageTextData(document: Pointer, pageIndex: Int, pageSize: DesktopPdfPageSize): DesktopPdfTextPageData { + return runCatching { + loadPage(document, pageIndex).usePointer { page -> + val textPage = api.FPDFText_LoadPage(page) ?: return@usePointer DesktopPdfTextPageData() + try { + val charCount = api.FPDFText_CountChars(textPage) + if (charCount <= 0) return@usePointer DesktopPdfTextPageData() + val text = extractText(textPage, charCount) + val chars = (0 until charCount).mapNotNull { index -> + val unicode = api.FPDFText_GetUnicode(textPage, index) + if (unicode <= 0) return@mapNotNull null + val left = DoubleArray(1) + val right = DoubleArray(1) + val bottom = DoubleArray(1) + val top = DoubleArray(1) + val hasBox = api.FPDFText_GetCharBox(textPage, index, left, right, bottom, top) != 0 + if (!hasBox) { + DesktopPdfTextChar(index, unicode.toChar(), 0f, 0f, 0f, 0f) + } else { + val bounds = pageToNormalizedBounds( + page = page, + pageSize = pageSize, + viewport = pageSize.normalizedViewport(), + left = left[0], + top = top[0], + right = right[0], + bottom = bottom[0] + ) + DesktopPdfTextChar( + index = index, + char = unicode.toChar(), + left = bounds.left, + top = bounds.top, + right = bounds.right, + bottom = bounds.bottom + ) + } + } + DesktopPdfTextPageData(text = text, chars = chars) + } finally { + api.FPDFText_ClosePage(textPage) + } + } + }.getOrDefault(DesktopPdfTextPageData()) + } + + private fun extractText(textPage: Pointer, charCount: Int): String { + if (charCount <= 0) return "" + val buffer = Memory(((charCount + 1) * 2L)) + val written = api.FPDFText_GetText(textPage, 0, charCount, buffer) + return if (written <= 0) { + "" + } else { + buffer.getCharArray(0, written).concatToString().trimEnd('\u0000') + } + } + + private fun extractDocumentMetadata(document: Pointer): DesktopPdfMetadata { + return DesktopPdfMetadata( + title = documentMetaText(document, "Title").cleanPdfMetadata(), + author = documentMetaText(document, "Author").cleanPdfMetadata() + ) + } + + private fun documentMetaText(document: Pointer, tag: String): String { + val lengthBytes = runCatching { api.FPDF_GetMetaText(document, tag, null, 0) }.getOrDefault(0) + if (lengthBytes <= 2) return "" + val buffer = Memory(lengthBytes.toLong()) + val writtenBytes = runCatching { api.FPDF_GetMetaText(document, tag, buffer, lengthBytes) }.getOrDefault(0) + if (writtenBytes <= 2) return "" + return String(buffer.getByteArray(0, writtenBytes), Charsets.UTF_16LE) + .trimEnd('\u0000') + } + + private fun String.cleanPdfMetadata(): String? { + return trim() + .takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) } + } + + private fun extractTableOfContents(document: Pointer, pageCount: Int): List { + val entries = mutableListOf() + + fun visit(parent: Pointer?, level: Int) { + var bookmark = api.FPDFBookmark_GetFirstChild(document, parent) + while (bookmark != null) { + val title = bookmarkTitle(bookmark) + val pageIndex = bookmarkPageIndex(document, bookmark, pageCount) + if (title.isNotBlank() && pageIndex != null) { + entries += PdfTocEntry( + title = title, + pageIndex = pageIndex, + nestLevel = level + ) + } + visit(bookmark, level + 1) + bookmark = api.FPDFBookmark_GetNextSibling(document, bookmark) + } + } + + runCatching { visit(null, 0) } + return entries + } + + private fun bookmarkTitle(bookmark: Pointer): String { + val lengthBytes = api.FPDFBookmark_GetTitle(bookmark, null, 0) + if (lengthBytes <= 2) return "" + val buffer = Memory(lengthBytes.toLong()) + val writtenBytes = api.FPDFBookmark_GetTitle(bookmark, buffer, lengthBytes) + if (writtenBytes <= 2) return "" + return String(buffer.getByteArray(0, writtenBytes), Charsets.UTF_16LE) + .trimEnd('\u0000') + } + + private fun bookmarkPageIndex(document: Pointer, bookmark: Pointer, pageCount: Int): Int? { + val dest = api.FPDFBookmark_GetDest(document, bookmark) ?: return null + return api.FPDFDest_GetDestPageIndex(document, dest) + .takeIf { it in 0 until pageCount } + } + + private fun extractEmbeddedAnnotations( + document: Pointer, + pageSizes: List + ): List { + return pageSizes.flatMapIndexed { pageIndex, pageSize -> + runCatching { + loadPage(document, pageIndex).usePointer { page -> + val count = api.FPDFPage_GetAnnotCount(page).coerceAtLeast(0) + val rawAnnotations = (0 until count).mapNotNull { index -> + extractEmbeddedAnnotation(page, pageIndex, index, pageSize) + } + SharedPdfEmbeddedAnnotationThreads.group(rawAnnotations) + } + }.getOrDefault(emptyList()) + } + } + + private fun extractEmbeddedAnnotation( + page: Pointer, + pageIndex: Int, + index: Int, + pageSize: DesktopPdfPageSize + ): SharedPdfEmbeddedAnnotation? { + val annotation = api.FPDFPage_GetAnnot(page, index) ?: return null + try { + val subtype = api.FPDFAnnot_GetSubtype(annotation) + if (subtype == PdfiumAnnotationSubtype.LINK) return null + val bounds = annotationBounds(page, annotation, pageSize) ?: return null + val contents = annotationStringValue(annotation, "Contents") + .ifBlank { annotationStringValue(annotation, "RC") } + val name = annotationStringValue(annotation, "NM") + return SharedPdfEmbeddedAnnotation( + id = "embedded_${pageIndex}_${name.ifBlank { index.toString() }}", + pageIndex = pageIndex, + index = index, + subtype = subtype, + bounds = bounds, + contents = contents, + author = annotationStringValue(annotation, "T"), + name = name, + inReplyTo = annotationStringValue(annotation, "IRT") + ) + } finally { + api.FPDFPage_CloseAnnot(annotation) + } + } + + private fun annotationBounds( + page: Pointer, + annotation: Pointer, + pageSize: DesktopPdfPageSize + ): PdfPageBounds? { + val rect = Memory(16) + if (api.FPDFAnnot_GetRect(annotation, rect) == 0) return null + val left = rect.getFloat(0).toDouble() + val top = rect.getFloat(4).toDouble() + val right = rect.getFloat(8).toDouble() + val bottom = rect.getFloat(12).toDouble() + if (left == right || top == bottom) return null + val normalized = pageToNormalizedBounds( + page = page, + pageSize = pageSize, + left = minOf(left, right), + top = maxOf(top, bottom), + right = maxOf(left, right), + bottom = minOf(top, bottom) + ) + return PdfPageBounds( + left = normalized.left, + top = normalized.top, + right = normalized.right, + bottom = normalized.bottom + ).takeIf { it.right > it.left && it.bottom > it.top } + } + + private fun annotationStringValue(annotation: Pointer, key: String): String { + val lengthBytes = api.FPDFAnnot_GetStringValue(annotation, key, null, 0) + if (lengthBytes <= 2) return "" + val buffer = Memory(lengthBytes.toLong()) + val writtenBytes = api.FPDFAnnot_GetStringValue(annotation, key, buffer, lengthBytes) + if (writtenBytes <= 2) return "" + return String(buffer.getByteArray(0, writtenBytes), Charsets.UTF_16LE) + .trimEnd('\u0000') + .cleanEmbeddedAnnotationText() + } + + private fun String.cleanEmbeddedAnnotationText(): String { + return replace(Regex("<[^>]+>"), "") + .replace(" ", " ") + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .trim() + } + private fun loadPage(document: Pointer, pageIndex: Int): PointerResource { val page = api.FPDF_LoadPage(document, pageIndex) ?: error("Pdfium could not open page ${pageIndex + 1}.") @@ -194,6 +1040,59 @@ object DesktopPdfium { return image } + private fun pdfiumLoadErrorMessage(errorCode: Int): String { + return when (errorCode) { + 0 -> "No Pdfium error detail was reported." + 1 -> "Pdfium reported an unknown load error." + 2 -> "The file was not found or could not be opened." + 3 -> "The file is not in a PDF format supported by this Pdfium build, or Pdfium detected corruption." + 4 -> "A password is required or the supplied password is incorrect." + 5 -> "The PDF uses an unsupported security scheme." + 6 -> "Pdfium could not load the document page tree." + 7 -> "Pdfium could not load XFA data." + 8 -> "Pdfium could not lay out XFA data." + else -> "Pdfium reported load error code $errorCode." + } + } + + private fun logPdfiumOpen(message: String) { + println("DesktopPdfiumOpen $message") + } + + private fun logPdfiumLink(message: String) { + println("DesktopPdfiumLink $message") + } + + private fun Float.formatLogFloat(): String { + return String.format("%.3f", this) + } + + private fun Double.formatLogDouble(): String { + return String.format("%.3f", this) + } + + private fun String.logPreview(maxLength: Int = 96): String { + return replace(Regex("\\s+"), " ") + .trim() + .let { if (it.length <= maxLength) it else it.take(maxLength) + "..." } + .replace("\"", "\\\"") + } + + private fun String.normalizedDetectedTextUrl(): String { + val cleaned = trim() + .trimEnd('.', ',', ';', ':', ')', ']', '}') + return if (cleaned.startsWith("www.", ignoreCase = true)) { + "https://$cleaned" + } else { + cleaned + } + } + + private data class DesktopOpenPdfDocument( + val pointer: Pointer, + val backingMemory: Memory? = null + ) + private class PointerResource( private val pointer: Pointer, private val closer: (Pointer) -> Unit @@ -207,16 +1106,125 @@ object DesktopPdfium { } } + private data class NormalizedViewport( + val width: Int, + val height: Int + ) + + private data class NormalizedBounds( + val left: Float, + val top: Float, + val right: Float, + val bottom: Float + ) + + private fun DesktopPdfPageSize.normalizedViewport(widthOverride: Int? = null, heightOverride: Int? = null): NormalizedViewport { + return NormalizedViewport( + width = widthOverride?.coerceAtLeast(1) ?: width.roundToInt().coerceAtLeast(1), + height = heightOverride?.coerceAtLeast(1) ?: height.roundToInt().coerceAtLeast(1) + ) + } + + private fun pageToNormalizedBounds( + page: Pointer, + pageSize: DesktopPdfPageSize, + viewport: NormalizedViewport = pageSize.normalizedViewport(), + left: Double, + top: Double, + right: Double, + bottom: Double + ): NormalizedBounds { + val topLeft = pageToDevicePoint(page, viewport, left, top) + val bottomRight = pageToDevicePoint(page, viewport, right, bottom) + val deviceLeft = minOf(topLeft.first, bottomRight.first).toFloat() + val deviceRight = maxOf(topLeft.first, bottomRight.first).toFloat() + val deviceTop = minOf(topLeft.second, bottomRight.second).toFloat() + val deviceBottom = maxOf(topLeft.second, bottomRight.second).toFloat() + return NormalizedBounds( + left = (deviceLeft / viewport.width).coerceIn(0f, 1f), + top = (deviceTop / viewport.height).coerceIn(0f, 1f), + right = (deviceRight / viewport.width).coerceIn(0f, 1f), + bottom = (deviceBottom / viewport.height).coerceIn(0f, 1f) + ) + } + + private fun pageToDevicePoint( + page: Pointer, + viewport: NormalizedViewport, + pageX: Double, + pageY: Double + ): Pair { + val deviceX = IntArray(1) + val deviceY = IntArray(1) + api.FPDF_PageToDevice( + page, + 0, + 0, + viewport.width, + viewport.height, + 0, + pageX, + pageY, + deviceX, + deviceY + ) + return deviceX[0] to deviceY[0] + } + + private fun deviceToPagePoint( + page: Pointer, + viewport: NormalizedViewport, + normalizedX: Float, + normalizedY: Float + ): Pair { + val pageX = DoubleArray(1) + val pageY = DoubleArray(1) + api.FPDF_DeviceToPage( + page, + 0, + 0, + viewport.width, + viewport.height, + 0, + (normalizedX.coerceIn(0f, 1f) * viewport.width).roundToInt(), + (normalizedY.coerceIn(0f, 1f) * viewport.height).roundToInt(), + pageX, + pageY + ) + return pageX[0] to pageY[0] + } + @Suppress("FunctionName") private interface PdfiumLibrary : Library { fun FPDF_InitLibrary() fun FPDF_LoadDocument(filePath: String, password: String?): Pointer? + fun FPDF_LoadMemDocument(dataBuf: Pointer, size: Int, password: String?): Pointer? fun FPDF_CloseDocument(document: Pointer) + fun FPDF_GetLastError(): Int + fun FPDF_GetMetaText(document: Pointer, tag: String, buffer: Pointer?, buflen: Int): Int fun FPDF_GetPageCount(document: Pointer): Int + fun FPDFBookmark_GetFirstChild(document: Pointer, bookmark: Pointer?): Pointer? + fun FPDFBookmark_GetNextSibling(document: Pointer, bookmark: Pointer): Pointer? + fun FPDFBookmark_GetTitle(bookmark: Pointer, buffer: Pointer?, buflen: Int): Int + fun FPDFBookmark_GetDest(document: Pointer, bookmark: Pointer): Pointer? + fun FPDFDest_GetDestPageIndex(document: Pointer, dest: Pointer): Int + fun FPDFLink_GetLinkAtPoint(page: Pointer, x: Double, y: Double): Pointer? + fun FPDFLink_GetAction(link: Pointer): Pointer? + fun FPDFAction_GetType(action: Pointer): Int + fun FPDFAction_GetURIPath(document: Pointer, action: Pointer, buffer: Pointer?, buflen: Int): Int + fun FPDFLink_GetDest(document: Pointer, link: Pointer): Pointer? + fun FPDFAction_GetDest(document: Pointer, action: Pointer): Pointer? + fun FPDFAction_GetFilePath(action: Pointer, buffer: Pointer?, buflen: Int): Int fun FPDF_LoadPage(document: Pointer, pageIndex: Int): Pointer? fun FPDF_ClosePage(page: Pointer) fun FPDF_GetPageWidthF(page: Pointer): Float fun FPDF_GetPageHeightF(page: Pointer): Float + fun FPDFPage_GetAnnotCount(page: Pointer): Int + fun FPDFPage_GetAnnot(page: Pointer, index: Int): Pointer? + fun FPDFPage_CloseAnnot(annotation: Pointer) + fun FPDFAnnot_GetSubtype(annotation: Pointer): Int + fun FPDFAnnot_GetRect(annotation: Pointer, rect: Pointer): Int + fun FPDFAnnot_GetStringValue(annotation: Pointer, key: String, buffer: Pointer?, buflen: Int): Int fun FPDFBitmap_CreateEx(width: Int, height: Int, format: Int, firstScan: Pointer, stride: Int): Pointer? fun FPDFBitmap_FillRect(bitmap: Pointer, left: Int, top: Int, width: Int, height: Int, color: Int) fun FPDFBitmap_Destroy(bitmap: Pointer) @@ -235,5 +1243,68 @@ object DesktopPdfium { fun FPDFText_ClosePage(textPage: Pointer) fun FPDFText_CountChars(textPage: Pointer): Int fun FPDFText_GetText(textPage: Pointer, startIndex: Int, count: Int, result: Pointer): Int + fun FPDFText_GetUnicode(textPage: Pointer, index: Int): Int + fun FPDFText_GetCharBox( + textPage: Pointer, + index: Int, + left: DoubleArray, + right: DoubleArray, + bottom: DoubleArray, + top: DoubleArray + ): Int + fun FPDFText_GetCharIndexAtPos( + textPage: Pointer, + x: Double, + y: Double, + xTolerance: Double, + yTolerance: Double + ): Int + fun FPDFText_CountRects(textPage: Pointer, startIndex: Int, count: Int): Int + fun FPDFText_GetRect( + textPage: Pointer, + rectIndex: Int, + left: DoubleArray, + top: DoubleArray, + right: DoubleArray, + bottom: DoubleArray + ): Int + fun FPDFText_LoadWebLinks(textPage: Pointer): Pointer? + fun FPDFLink_CountWebLinks(linkPage: Pointer): Int + fun FPDFLink_GetURL(linkPage: Pointer, linkIndex: Int, buffer: Pointer, buflen: Int): Int + fun FPDFLink_CountRects(linkPage: Pointer, linkIndex: Int): Int + fun FPDFLink_GetRect( + linkPage: Pointer, + linkIndex: Int, + rectIndex: Int, + left: DoubleArray, + top: DoubleArray, + right: DoubleArray, + bottom: DoubleArray + ): Int + fun FPDFLink_CloseWebLinks(linkPage: Pointer) + fun FPDF_PageToDevice( + page: Pointer, + startX: Int, + startY: Int, + sizeX: Int, + sizeY: Int, + rotate: Int, + pageX: Double, + pageY: Double, + deviceX: IntArray, + deviceY: IntArray + ) + fun FPDF_DeviceToPage( + page: Pointer, + startX: Int, + startY: Int, + sizeX: Int, + sizeY: Int, + rotate: Int, + deviceX: Int, + deviceY: Int, + pageX: DoubleArray, + pageY: DoubleArray + ) } } diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopTtsLog.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopTtsLog.kt new file mode 100644 index 0000000..baeb4a4 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopTtsLog.kt @@ -0,0 +1,19 @@ +package com.aryan.reader.desktop + +private const val DesktopTtsLogTag = "EpistemeDesktopTts" + +internal fun logDesktopTts(message: String) { + println("$DesktopTtsLogTag $message") +} + +internal fun Throwable.desktopTtsSummary(): String { + val type = this::class.java.simpleName.ifBlank { "Throwable" } + return "$type: ${message.orEmpty().desktopTtsPreview(220)}" +} + +internal fun String.desktopTtsPreview(maxLength: Int = 120): String { + return replace(Regex("\\s+"), " ") + .trim() + .let { if (it.length <= maxLength) it else it.take(maxLength) + "..." } + .replace("\"", "\\\"") +} diff --git a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/Main.kt b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/Main.kt index 1e2c715..a2edc2b 100644 --- a/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/Main.kt +++ b/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/Main.kt @@ -1,56 +1,45 @@ package com.aryan.reader.desktop +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable -import androidx.compose.foundation.Image -import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll -import androidx.compose.foundation.gestures.detectDragGestures -import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.LibraryBooks -import androidx.compose.material.icons.automirrored.filled.MenuBook import androidx.compose.material.icons.automirrored.filled.NavigateBefore import androidx.compose.material.icons.automirrored.filled.NavigateNext -import androidx.compose.material.icons.filled.Bookmark -import androidx.compose.material.icons.filled.BookmarkBorder -import androidx.compose.material.icons.filled.Brush -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Draw -import androidx.compose.material.icons.filled.EditNote -import androidx.compose.material.icons.filled.FormatColorText -import androidx.compose.material.icons.filled.Folder -import androidx.compose.material.icons.filled.Home -import androidx.compose.material.icons.filled.ImportExport -import androidx.compose.material.icons.filled.Remove -import androidx.compose.material.icons.filled.Sync -import androidx.compose.material.icons.filled.TextFields +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.ZoomIn import androidx.compose.material.icons.filled.ZoomOut import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider @@ -58,49 +47,57 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.NavigationRail -import androidx.compose.material3.NavigationRailItem import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold import androidx.compose.material3.Slider -import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key -import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.ImageShader +import androidx.compose.ui.graphics.ShaderBrush +import androidx.compose.ui.graphics.TileMode import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.isCtrlPressed import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.isPrimaryPressed +import androidx.compose.ui.input.pointer.isSecondaryPressed import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.platform.Font as DesktopFont +import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp @@ -108,6 +105,7 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.isSpecified import androidx.compose.ui.unit.sp +import androidx.compose.ui.zIndex import androidx.compose.ui.window.Window import androidx.compose.ui.window.application import com.aryan.reader.paginatedreader.SemanticBlock @@ -126,61 +124,231 @@ import com.aryan.reader.shared.AppAction import com.aryan.reader.shared.BannerMessage import com.aryan.reader.shared.BookItem import com.aryan.reader.shared.BookShelfRef +import com.aryan.reader.shared.BuiltInPdfReaderThemes +import com.aryan.reader.shared.CustomFontItem +import com.aryan.reader.shared.EpubAnnotationSerializer import com.aryan.reader.shared.FileType import com.aryan.reader.shared.ImportedBookFile import com.aryan.reader.shared.LibraryAction +import com.aryan.reader.shared.PdfDisplayMode +import com.aryan.reader.shared.GEMINI_CLOUD_TTS_MODEL +import com.aryan.reader.shared.GEMINI_CLOUD_TTS_MODEL_ID +import com.aryan.reader.shared.ReaderAiByokSettings +import com.aryan.reader.shared.ReaderAiFeature +import com.aryan.reader.shared.ReaderAiModelOption +import com.aryan.reader.shared.ReaderAiModelOptions +import com.aryan.reader.shared.ReaderAiResultState +import com.aryan.reader.shared.ReaderAction +import com.aryan.reader.shared.ReaderAutoScrollState +import com.aryan.reader.shared.ReaderCloudTtsState +import com.aryan.reader.shared.ReaderCloudTtsVoices +import com.aryan.reader.shared.ReaderContextExtractor +import com.aryan.reader.shared.ReaderExtrasState +import com.aryan.reader.shared.ReaderExternalLookupAction +import com.aryan.reader.shared.ReaderFeatureSurface +import com.aryan.reader.shared.ReaderHighlightPalette +import com.aryan.reader.shared.ReaderLocator +import com.aryan.reader.shared.ReaderPlatform +import com.aryan.reader.shared.ReaderTexture +import com.aryan.reader.shared.ReaderTextureFilePrefix +import com.aryan.reader.shared.ReaderTheme +import com.aryan.reader.shared.ReaderToolbarPreferences +import com.aryan.reader.shared.ReaderTtsChunk +import com.aryan.reader.shared.ReaderTtsPlanner +import com.aryan.reader.shared.ReaderTtsProgress +import com.aryan.reader.shared.ReaderTtsReadScope +import com.aryan.reader.shared.ReaderTtsReplacementPreferences +import com.aryan.reader.shared.SearchHighlightMode +import com.aryan.reader.shared.SharedFileCapabilities +import com.aryan.reader.shared.SharedFolderPathResolver +import com.aryan.reader.shared.SharedLibraryEditor import com.aryan.reader.shared.SharedLibraryProjectionInput +import com.aryan.reader.shared.SharedLibrarySnapshot import com.aryan.reader.shared.SharedLibraryStateProjector import com.aryan.reader.shared.SharedReaderScreenState import com.aryan.reader.shared.Shelf import com.aryan.reader.shared.ShelfRecord import com.aryan.reader.shared.ShelfType +import com.aryan.reader.shared.SmartCollectionDefinition +import com.aryan.reader.shared.SmartField +import com.aryan.reader.shared.SmartOperator +import com.aryan.reader.shared.SmartRule +import com.aryan.reader.shared.SyncedFolder import com.aryan.reader.shared.Tag -import com.aryan.reader.shared.withImportedFiles -import com.aryan.reader.shared.reduce -import com.aryan.reader.shared.reader.ReaderEngine -import com.aryan.reader.shared.reader.ReaderHtmlDocumentBuilder -import com.aryan.reader.shared.reader.ReaderReadingMode -import com.aryan.reader.shared.reader.ReaderSessionState -import com.aryan.reader.shared.reader.SharedReaderTextAlign -import com.aryan.reader.shared.reader.SampleReaderBooks -import com.aryan.reader.shared.ui.NonReaderLibraryTab -import com.aryan.reader.shared.ui.SharedHomeScreen -import com.aryan.reader.shared.ui.SharedLibraryScreen -import com.aryan.reader.shared.ui.SharedShelvesScreen +import com.aryan.reader.shared.UserHighlight +import com.aryan.reader.shared.externalLookupUrl +import com.aryan.reader.shared.maskedReaderAiKey +import com.aryan.reader.shared.withTtsReplacements import com.aryan.reader.shared.pdf.PdfAnnotationKind import com.aryan.reader.shared.pdf.PdfInkTool +import com.aryan.reader.shared.pdf.PdfNormalizedPoint import com.aryan.reader.shared.pdf.PdfPageBounds import com.aryan.reader.shared.pdf.PdfPagePoint +import com.aryan.reader.shared.pdf.PdfSelectionGeometry +import com.aryan.reader.shared.pdf.PdfTextCharBounds +import com.aryan.reader.shared.pdf.PdfVisiblePageLayout import com.aryan.reader.shared.pdf.PdfZoomSpec import com.aryan.reader.shared.pdf.SharedPdfAnnotation import com.aryan.reader.shared.pdf.SharedPdfAnnotationDefaults import com.aryan.reader.shared.pdf.SharedPdfAnnotationSerializer -import dev.datlag.kcef.KCEF +import com.aryan.reader.shared.pdf.SharedPdfBookmarkSerializer +import com.aryan.reader.shared.pdf.SharedPdfEmbeddedAnnotation +import com.aryan.reader.shared.pdf.SharedPdfInkRenderer +import com.aryan.reader.shared.pdf.SharedPdfJumpHistory +import com.aryan.reader.shared.pdf.SharedPdfReaderAction +import com.aryan.reader.shared.pdf.SharedPdfReaderState +import com.aryan.reader.shared.pdf.SharedPdfRichDocument +import com.aryan.reader.shared.pdf.SharedPdfRichTextController +import com.aryan.reader.shared.pdf.SharedPdfRichTextLog +import com.aryan.reader.shared.pdf.SharedPdfRichTextSerializer +import com.aryan.reader.shared.pdf.SharedPdfSearchEngine +import com.aryan.reader.shared.pdf.SharedPdfSearchResult +import com.aryan.reader.shared.pdf.SharedPdfTextAnnotationDefaults +import com.aryan.reader.shared.pdf.SharedPdfTextDraft +import com.aryan.reader.shared.pdf.SharedPdfTextStyleConfig +import com.aryan.reader.shared.pdf.currentSharedPdfTextStyleConfig +import com.aryan.reader.shared.pdf.mostVisiblePdfPageIndex +import com.aryan.reader.shared.pdf.reduce +import com.aryan.reader.shared.pdf.sharedPdfTextStyle +import com.aryan.reader.shared.pdf.sharedPdfStrokePercent +import com.aryan.reader.shared.pdf.sharedPdfStrokeWidthRange +import com.aryan.reader.shared.pdf.toAnnotation +import com.aryan.reader.shared.pdf.updateCurrentSharedPdfTextStyle +import com.aryan.reader.shared.pdf.withBounds +import com.aryan.reader.shared.pdf.withSharedPdfTextStyle +import com.aryan.reader.shared.pdf.withStyle +import com.aryan.reader.shared.pdf.withText +import com.aryan.reader.shared.reader.ReaderEngine +import com.aryan.reader.shared.reader.ReaderLinkTarget +import com.aryan.reader.shared.reader.ReaderSettings +import com.aryan.reader.shared.reader.ReaderSessionState +import com.aryan.reader.shared.reader.SampleReaderBooks +import com.aryan.reader.shared.reader.SharedReaderTextAlign +import com.aryan.reader.shared.reader.SharedJvmBookLoader +import com.aryan.reader.shared.opds.OpdsAcquisition +import com.aryan.reader.shared.opds.OpdsCatalog +import com.aryan.reader.shared.opds.OpdsEntry +import com.aryan.reader.shared.opds.OpdsStreamReference +import com.aryan.reader.shared.opds.SharedOpdsController +import com.aryan.reader.shared.opds.SharedOpdsDownloadState +import com.aryan.reader.shared.opds.SharedOpdsStreamUri +import com.aryan.reader.shared.reduce +import com.aryan.reader.shared.ui.NonReaderLibraryTab +import com.aryan.reader.shared.ui.ReaderContentNavigationTarget +import com.aryan.reader.shared.ui.ReaderWorkspaceShell +import com.aryan.reader.shared.ui.SharedAddToShelfDialog +import com.aryan.reader.shared.ui.SharedAppShell +import com.aryan.reader.shared.ui.SharedAppTab +import com.aryan.reader.shared.ui.SharedAppTheme +import com.aryan.reader.shared.ui.SharedAboutScreen +import com.aryan.reader.shared.ui.SharedBookEditDialog +import com.aryan.reader.shared.ui.SharedBookInfoDialog +import com.aryan.reader.shared.ui.SharedConfirmDialog +import com.aryan.reader.shared.ui.SharedCustomFontsScreen +import com.aryan.reader.shared.ui.SharedHelpFeedbackScreen +import com.aryan.reader.shared.ui.SharedHomeScreen +import com.aryan.reader.shared.ui.SharedLibraryScreen +import com.aryan.reader.shared.ui.SharedMarkdownText +import com.aryan.reader.shared.ui.SharedOpdsScreen +import com.aryan.reader.shared.ui.SharedPdfAnnotationOverlay +import com.aryan.reader.shared.ui.SharedPdfAnnotationToolDock +import com.aryan.reader.shared.ui.SharedPdfEmbeddedAnnotationOverlay +import com.aryan.reader.shared.ui.SharedPdfInlineTextEditorOverlay +import com.aryan.reader.shared.ui.SharedPdfPageNumberOverlay +import com.aryan.reader.shared.ui.SharedPdfRichTextHiddenInput +import com.aryan.reader.shared.ui.SharedPdfRichTextLayer +import com.aryan.reader.shared.ui.SharedPdfTextAnnotationDock +import com.aryan.reader.shared.ui.SharedPdfTextBoxEditorOverlay +import com.aryan.reader.shared.ui.SharedPdfTextStyleControls +import com.aryan.reader.shared.ui.SharedReaderScreen +import com.aryan.reader.shared.ui.SharedReaderThemeControls +import com.aryan.reader.shared.ui.SharedReaderTtsReplacementControls +import com.aryan.reader.shared.ui.SharedShelvesScreen +import com.aryan.reader.shared.ui.SharedSupportProjectScreen +import com.aryan.reader.shared.ui.SharedTextInputDialog +import com.aryan.reader.shared.ui.pdfReaderWorkspaceModel +import com.aryan.reader.shared.ui.sharedPdfEmbeddedHitTest +import com.aryan.reader.shared.ui.sharedPdfHitTest +import com.aryan.reader.shared.ui.toSharedPdfPoint +import com.aryan.reader.shared.withImportedFiles +import com.multiplatform.webview.jsbridge.IJsMessageHandler +import com.multiplatform.webview.jsbridge.JsMessage +import com.multiplatform.webview.jsbridge.rememberWebViewJsBridge +import com.multiplatform.webview.request.RequestInterceptor +import com.multiplatform.webview.request.WebRequest +import com.multiplatform.webview.request.WebRequestInterceptResult import com.multiplatform.webview.web.LoadingState import com.multiplatform.webview.web.WebView +import com.multiplatform.webview.web.WebViewNavigator +import com.multiplatform.webview.web.rememberWebViewNavigator import com.multiplatform.webview.web.rememberWebViewStateWithHTMLData +import dev.datlag.kcef.KCEF import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.awt.Desktop +import java.awt.Container +import java.awt.EventQueue import java.awt.FileDialog import java.awt.Frame +import java.awt.Component +import java.awt.datatransfer.DataFlavor +import java.awt.dnd.DnDConstants +import java.awt.dnd.DropTarget +import java.awt.dnd.DropTargetAdapter +import java.awt.dnd.DropTargetDragEvent +import java.awt.dnd.DropTargetEvent +import java.awt.dnd.DropTargetDropEvent +import java.io.ByteArrayInputStream import java.io.File +import java.net.URI +import java.net.URLDecoder +import java.net.URLEncoder +import java.util.Base64 +import java.util.Locale +import java.util.UUID +import java.util.concurrent.atomic.AtomicReference +import javax.imageio.ImageIO +import javax.swing.JOptionPane +import javax.swing.SwingUtilities +import javax.swing.JFileChooser import kotlin.math.abs import kotlin.math.max +import kotlin.math.roundToInt -fun main() = application { - Window( - onCloseRequest = ::exitApplication, - title = "Episteme", - ) { - EpistemeDesktopApp() +fun main() { + configureComposeSwingInterop() + application { + Window( + onCloseRequest = ::exitApplication, + title = "Episteme", + ) { + EpistemeDesktopApp(window) + } } } -private enum class DesktopTab { HOME, LIBRARY, SHELVES, READER } +internal const val ComposeInteropBlendingProperty = "compose.interop.blending" +internal const val ComposeInteropBlendingEnabled = "true" + +internal fun configureComposeSwingInterop() { + // Must run before Compose creates the desktop window. Vertical EPUB embeds a Swing-backed + // JCEF WebView, and current Compose interop can leave a stale black native rectangle after + // that reader surface is removed unless interop blending is enabled. + if (System.getProperty(ComposeInteropBlendingProperty).isNullOrBlank()) { + System.setProperty(ComposeInteropBlendingProperty, ComposeInteropBlendingEnabled) + } +} private data class DesktopWebViewRuntimeState( val initialized: Boolean = false, @@ -191,13 +359,30 @@ private data class DesktopWebViewRuntimeState( @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun EpistemeDesktopApp() { - val libraryProjector = remember { SharedLibraryStateProjector() } +private fun EpistemeDesktopApp(window: Component? = null) { + val libraryProjector = remember { SharedLibraryStateProjector(DesktopFolderPathResolver) } val readerEngine = remember { ReaderEngine() } val libraryDatabase = remember { DesktopLibraryDatabase() } + val customFontStore = remember { DesktopCustomFontStore() } + val opdsRepository = remember { DesktopOpdsRepository() } + val opdsController = remember { + SharedOpdsController( + repository = opdsRepository, + idFactory = { UUID.randomUUID().toString() } + ) + } + val aiByokStore = remember { DesktopAiByokStore() } + var aiByokSettings by remember { mutableStateOf(aiByokStore.load()) } + val desktopAiAdapter = remember { + DesktopByokAiAdapter { aiByokSettings } + } + val desktopTtsAdapter = remember { + DesktopGeminiCloudTtsAdapter(settingsProvider = { aiByokSettings }) + } val initialLibrarySnapshot = remember { libraryDatabase.load() } val scope = rememberCoroutineScope() var webViewRuntimeState by remember { mutableStateOf(DesktopWebViewRuntimeState()) } + var readerCustomTextureIds by remember { mutableStateOf(DesktopReaderTextures.importedTextureIds()) } LaunchedEffect(Unit) { withContext(Dispatchers.IO) { @@ -235,12 +420,28 @@ private fun EpistemeDesktopApp() { var shelfRecords by remember { mutableStateOf(initialLibrarySnapshot.shelfRecords) } var shelfRefs by remember { mutableStateOf(initialLibrarySnapshot.shelfRefs) } var state by remember { - val initialBooks = initialLibrarySnapshot.books + val initialBooks = initialLibrarySnapshot.books.filter { it.type in DesktopReadableFileTypes } val initialTags = initialLibrarySnapshot.tags.ifEmpty { initialBooks.collectTags() } val initialState = SharedReaderScreenState( rawLibraryBooks = initialBooks, - recentFilesLimit = 12, - allTags = initialTags + recentFilesLimit = initialLibrarySnapshot.recentFilesLimit, + allTags = initialTags, + syncedFolders = initialLibrarySnapshot.syncedFolders, + isTabsEnabled = initialLibrarySnapshot.isTabsEnabled, + openTabIds = initialLibrarySnapshot.openTabIds, + activeTabBookId = initialLibrarySnapshot.activeTabBookId, + pinnedHomeBookIds = initialLibrarySnapshot.pinnedHomeBookIds, + pinnedLibraryBookIds = initialLibrarySnapshot.pinnedLibraryBookIds, + useStrictFileFilter = initialLibrarySnapshot.useStrictFileFilter, + appThemeMode = initialLibrarySnapshot.appThemeMode, + appContrastOption = initialLibrarySnapshot.appContrastOption, + appTextDimFactorLight = initialLibrarySnapshot.appTextDimFactorLight, + appTextDimFactorDark = initialLibrarySnapshot.appTextDimFactorDark, + appSeedColor = initialLibrarySnapshot.appSeedColor, + customAppThemes = initialLibrarySnapshot.customAppThemes, + readerToolbarPreferences = initialLibrarySnapshot.readerToolbarPreferences, + readerHighlightPalette = initialLibrarySnapshot.readerHighlightPalette, + readerTtsReplacementPreferences = initialLibrarySnapshot.readerTtsReplacementPreferences ) mutableStateOf( libraryProjector.project( @@ -254,19 +455,41 @@ private fun EpistemeDesktopApp() { ) ) } - var selectedTab by remember { mutableStateOf(DesktopTab.HOME) } + var selectedTab by remember { mutableStateOf(SharedAppTab.HOME) } var selectedLibraryTab by remember { mutableStateOf(NonReaderLibraryTab.BOOKS) } + var customFonts by remember { + mutableStateOf(initialLibrarySnapshot.customFonts.filterNot { it.isDeleted }.sortedBy { it.displayName.lowercase() }) + } var activeReaderBookId by remember { mutableStateOf(null) } var readerSession by remember { mutableStateOf(readerEngine.createSession(SampleReaderBooks.desktopWelcomeBook())) } + var readerExtrasState by remember { + mutableStateOf( + ReaderExtrasState( + cloudTts = ReaderCloudTtsState( + isAvailable = aiByokSettings.isCloudTtsAvailable, + cacheSummary = desktopTtsAdapter.cacheSummary( + readerSession.reader.book.title, + aiByokSettings.sanitized().ttsSpeakerId + ) + ) + ) + ) + } var activePdfDocument by remember { mutableStateOf(null) } var showCreateShelfDialog by remember { mutableStateOf(false) } + var showCreateSmartShelfDialog by remember { mutableStateOf(false) } var shelfToRename by remember { mutableStateOf(null) } var shelfToDelete by remember { mutableStateOf(null) } + var folderToRemove by remember { mutableStateOf(null) } var showAddToShelfDialog by remember { mutableStateOf(false) } var showTagSelectionDialog by remember { mutableStateOf(false) } + var showAiByokSettingsDialog by remember { mutableStateOf(false) } var bookInfoDialogFor by remember { mutableStateOf(null) } var bookEditDialogFor by remember { mutableStateOf(null) } val snackbarHostState = remember { SnackbarHostState() } + var dropImportState by remember { mutableStateOf(DesktopDropImportState()) } + var opdsState by remember { mutableStateOf(opdsController.state) } + var readerTtsJob by remember { mutableStateOf(null) } fun projectState( next: SharedReaderScreenState, @@ -284,15 +507,38 @@ private fun EpistemeDesktopApp() { ) } - fun persistSnapshot(projected: SharedReaderScreenState, records: List = shelfRecords, refs: List = shelfRefs) { + fun persistSnapshot( + projected: SharedReaderScreenState, + records: List = shelfRecords, + refs: List = shelfRefs, + fonts: List = customFonts + ) { scope.launch(Dispatchers.IO) { runCatching { libraryDatabase.save( - DesktopLibrarySnapshot( + SharedLibrarySnapshot( books = projected.rawLibraryBooks, shelfRecords = records, shelfRefs = refs, - tags = projected.allTags + tags = projected.allTags, + customFonts = fonts, + syncedFolders = projected.syncedFolders, + recentFilesLimit = projected.recentFilesLimit, + isTabsEnabled = projected.isTabsEnabled, + openTabIds = projected.openTabIds, + activeTabBookId = projected.activeTabBookId, + pinnedHomeBookIds = projected.pinnedHomeBookIds, + pinnedLibraryBookIds = projected.pinnedLibraryBookIds, + useStrictFileFilter = projected.useStrictFileFilter, + appThemeMode = projected.appThemeMode, + appContrastOption = projected.appContrastOption, + appTextDimFactorLight = projected.appTextDimFactorLight, + appTextDimFactorDark = projected.appTextDimFactorDark, + appSeedColor = projected.appSeedColor, + customAppThemes = projected.customAppThemes, + readerToolbarPreferences = projected.readerToolbarPreferences, + readerHighlightPalette = projected.readerHighlightPalette, + readerTtsReplacementPreferences = projected.readerTtsReplacementPreferences ) ) } @@ -317,129 +563,673 @@ private fun EpistemeDesktopApp() { persistSnapshot(projected) } - fun importFiles(files: List) { - updateState(state.withImportedFiles(files)) - } - - fun removeSelectedBooks() { - if (state.selectedBookIds.isEmpty()) return - val selected = state.selectedBookIds - replaceLibrary( - state.copy( - rawLibraryBooks = state.rawLibraryBooks.filterNot { it.id in selected }, - selectedBookIds = emptySet(), - bannerMessage = BannerMessage("Removed ${selected.size} book(s) from the desktop library.") - ), - refs = shelfRefs.filterNot { it.bookId in selected } + fun updateAiByokSettings(next: ReaderAiByokSettings) { + val sanitized = next.sanitized() + logDesktopTts( + "settings_update keyPresent=${sanitized.geminiKey.isNotBlank()} " + + "ttsModel=\"${sanitized.ttsModel.desktopTtsPreview()}\" speaker=\"${sanitized.ttsSpeakerId.desktopTtsPreview()}\" " + + "cloudAvailable=${sanitized.isCloudTtsAvailable}" ) - } - - fun createShelf(name: String) { - val trimmed = name.trim() - if (trimmed.isBlank()) return - val id = "shelf_${System.currentTimeMillis()}" - replaceLibrary( - state.copy(bannerMessage = BannerMessage("Created shelf \"$trimmed\".")), - records = shelfRecords + ShelfRecord(id = id, name = trimmed) + aiByokSettings = sanitized + readerExtrasState = readerExtrasState.copy( + cloudTts = readerExtrasState.cloudTts.copy( + isAvailable = sanitized.isCloudTtsAvailable, + errorMessage = null, + cacheSummary = desktopTtsAdapter.cacheSummary(readerSession.reader.book.title, sanitized.ttsSpeakerId) + ) ) + runCatching { aiByokStore.save(sanitized) } + .onFailure { error -> + logDesktopTts("settings_save_failed error=\"${error.desktopTtsSummary()}\"") + scope.launch { + snackbarHostState.showSnackbar(error.message ?: "AI settings could not be saved securely.") + } + } } - fun renameShelf(shelf: Shelf, name: String) { - val trimmed = name.trim() - if (trimmed.isBlank()) return - replaceLibrary( - state.copy(bannerMessage = BannerMessage("Renamed shelf to \"$trimmed\".")), - records = shelfRecords.map { if (it.id == shelf.id) it.copy(name = trimmed) else it } + fun updateReaderAutoScroll(autoScroll: ReaderAutoScrollState) { + readerExtrasState = readerExtrasState.copy(autoScroll = autoScroll.sanitized()) + } + + fun currentReaderTtsCacheSummary() = + desktopTtsAdapter.cacheSummary(readerSession.reader.book.title, aiByokSettings.sanitized().ttsSpeakerId) + + fun readerCloudTtsStoppedState(statusMessage: String? = null, errorMessage: String? = null) = ReaderCloudTtsState( + isAvailable = aiByokSettings.sanitized().isCloudTtsAvailable, + statusMessage = statusMessage, + errorMessage = errorMessage, + cacheSummary = currentReaderTtsCacheSummary() + ) + + fun openReaderExternalLookup(action: ReaderExternalLookupAction, text: String) { + val normalizedText = text.trim() + if (normalizedText.isBlank()) return + openExternalUrl(externalLookupUrl(action, normalizedText.take(1800))) + } + + fun runReaderAiAction(feature: ReaderAiFeature, text: String) { + val normalizedText = text.trim() + if (normalizedText.isBlank()) return + if (!aiByokSettings.sanitized().areReaderAiFeaturesAvailable) return + readerExtrasState = readerExtrasState.copy( + aiResult = ReaderAiResultState( + title = feature.displayName, + isLoading = true + ) ) - } - - fun deleteShelf(shelf: Shelf) { - replaceLibrary( - state.copy(bannerMessage = BannerMessage("Deleted shelf \"${shelf.name}\".")), - records = shelfRecords.filterNot { it.id == shelf.id }, - refs = shelfRefs.filterNot { it.shelfId == shelf.id } - ) - } - - fun addSelectedBooksToShelf(shelfId: String) { - val selected = state.selectedBookIds - if (selected.isEmpty()) return - val existing = shelfRefs.mapTo(mutableSetOf()) { it.bookId to it.shelfId } - val now = System.currentTimeMillis() - val additions = selected.mapNotNull { bookId -> - if (!existing.add(bookId to shelfId)) null else BookShelfRef(bookId = bookId, shelfId = shelfId, addedAt = now) + scope.launch { + val result = when (feature) { + ReaderAiFeature.DEFINE -> desktopAiAdapter.define( + text = normalizedText.take(2400), + context = ReaderContextExtractor.currentPageText(readerSession) + ).let { it.definition to it.error } + ReaderAiFeature.SUMMARIZE -> desktopAiAdapter.summarize(normalizedText).let { it.summary to it.error } + ReaderAiFeature.RECAP -> desktopAiAdapter.recap(normalizedText).let { it.recap to it.error } + } + readerExtrasState = readerExtrasState.copy( + aiResult = ReaderAiResultState( + title = feature.displayName, + text = result.first.orEmpty(), + errorMessage = result.second, + isLoading = false + ) + ) } - replaceLibrary( - state.copy( - selectedBookIds = emptySet(), - bannerMessage = BannerMessage("Added ${additions.size} book(s) to shelf.") - ), - refs = shelfRefs + additions + } + + fun syncBookSidecars(book: BookItem) { + if (book.sourceFolder.isNullOrBlank()) return + scope.launch(Dispatchers.IO) { + DesktopLocalFolderSync.saveBookSidecars(book) + } + } + + fun updateActiveBookReadingState(pageIndex: Int, progress: Float, session: ReaderSessionState? = null) { + activeReaderBookId?.let { bookId -> + var updatedBook: BookItem? = null + val next = state.copy( + rawLibraryBooks = state.rawLibraryBooks.map { book -> + if (book.id == bookId) { + book.copy( + progressPercentage = progress, + timestamp = System.currentTimeMillis(), + isRecent = true, + lastPageIndex = pageIndex, + readerSettings = session?.reader?.settings ?: book.readerSettings, + readerBookmarks = session?.bookmarks ?: book.readerBookmarks, + readerHighlights = session?.highlights ?: book.readerHighlights + ).also { updatedBook = it } + } else { + book + } + } + ) + updateState(next) + updatedBook?.let(::syncBookSidecars) + } + } + + fun updateActiveBookReaderSettings(settings: ReaderSettings) { + activeReaderBookId?.let { bookId -> + var updatedBook: BookItem? = null + val next = state.copy( + rawLibraryBooks = state.rawLibraryBooks.map { book -> + if (book.id == bookId) { + book.copy( + timestamp = System.currentTimeMillis(), + isRecent = true, + readerSettings = settings + ).also { updatedBook = it } + } else { + book + } + } + ) + updateState(next) + updatedBook?.let(::syncBookSidecars) + } + } + + fun importDesktopReaderTexture(settings: ReaderSettings): ReaderSettings? { + val source = chooseReaderTextureFile() ?: return null + val textureId = DesktopReaderTextures.importTexture(source) ?: return null + readerCustomTextureIds = DesktopReaderTextures.importedTextureIds() + return settings.copy(textureId = textureId) + } + + fun stopReaderCloudTts() { + logDesktopTts("reader_stop_requested") + readerTtsJob?.cancel() + readerTtsJob = null + scope.launch { + desktopTtsAdapter.stop() + readerExtrasState = readerExtrasState.copy( + cloudTts = readerCloudTtsStoppedState(statusMessage = "Stopped") + ) + } + } + + fun pauseResumeReaderCloudTts() { + val current = readerExtrasState.cloudTts + if (current.isPaused) { + scope.launch { + desktopTtsAdapter.resume() + readerExtrasState = readerExtrasState.copy( + cloudTts = readerExtrasState.cloudTts.copy( + isPaused = false, + isPlaying = true, + statusMessage = readerExtrasState.cloudTts.progress.currentPositionLabel ?: "Reading" + ) + ) + } + } else if (current.isPlaying) { + scope.launch { + desktopTtsAdapter.pause() + readerExtrasState = readerExtrasState.copy( + cloudTts = readerExtrasState.cloudTts.copy( + isPlaying = false, + isPaused = true, + statusMessage = "Paused" + ) + ) + } + } + } + + fun clearReaderCloudTtsCache() { + desktopTtsAdapter.clearBookCacheForSpeaker(readerSession.reader.book.title, aiByokSettings.sanitized().ttsSpeakerId) + readerExtrasState = readerExtrasState.copy( + cloudTts = readerExtrasState.cloudTts.copy( + statusMessage = "Voice cache cleared", + cacheSummary = currentReaderTtsCacheSummary() + ) ) } - fun tagSelectedBooks(tagName: String) { - val selected = state.selectedBookIds - val trimmed = tagName.trim() - if (selected.isEmpty() || trimmed.isBlank()) return - val existingTag = state.allTags.firstOrNull { it.name.equals(trimmed, ignoreCase = true) } - val tag = existingTag ?: Tag( - id = trimmed.lowercase().replace(Regex("[^a-z0-9]+"), "_").trim('_').ifBlank { "tag_${System.currentTimeMillis()}" }, - name = trimmed, - color = 0xFF64B5F6.toInt() + fun startReaderCloudTts(readScope: ReaderTtsReadScope, chunks: List) { + val replacementBookId = activeReaderBookId ?: readerSession.reader.book.title + val ttsChunks = chunks + .filter { it.text.isNotBlank() } + .withTtsReplacements(state.readerTtsReplacementPreferences, replacementBookId) + val settings = aiByokSettings.sanitized() + logDesktopTts( + "reader_sequence_toggle scope=${readScope.name} chunks=${ttsChunks.size} " + + "isPlaying=${readerExtrasState.cloudTts.isPlaying} isLoading=${readerExtrasState.cloudTts.isLoading} " + + "keyPresent=${settings.geminiKey.isNotBlank()} ttsModel=\"${settings.ttsModel.desktopTtsPreview()}\" " + + "available=${desktopTtsAdapter.isAvailable}" ) - val allTags = (state.allTags + tag).distinctBy { it.id }.sortedBy { it.name.lowercase() } - val books = state.rawLibraryBooks.map { book -> - if (book.id in selected && book.tags.none { it.id == tag.id }) { - book.copy(tags = (book.tags + tag).sortedBy { it.name.lowercase() }) + if (readerExtrasState.cloudTts.isPlaying || readerExtrasState.cloudTts.isLoading || readerExtrasState.cloudTts.isPaused) { + stopReaderCloudTts() + return + } + if (ttsChunks.isEmpty()) { + logDesktopTts("reader_sequence_ignored reason=blank_text scope=${readScope.name}") + readerExtrasState = readerExtrasState.copy( + cloudTts = readerExtrasState.cloudTts.copy( + errorMessage = "There is no text here to read.", + cacheSummary = currentReaderTtsCacheSummary() + ) + ) + return + } + if (!desktopTtsAdapter.isAvailable) { + logDesktopTts("reader_sequence_blocked reason=adapter_unavailable") + readerExtrasState = readerExtrasState.copy( + cloudTts = ReaderCloudTtsState( + isAvailable = false, + errorMessage = "Add a Gemini key and select Gemini cloud TTS in AI keys and models.", + cacheSummary = currentReaderTtsCacheSummary() + ) + ) + return + } + val ttsSessionId = System.currentTimeMillis() + val initialProgress = ReaderTtsProgress( + sessionId = ttsSessionId, + scope = readScope, + chunks = ttsChunks, + currentChunkIndex = -1 + ) + readerExtrasState = readerExtrasState.copy( + cloudTts = ReaderCloudTtsState( + isAvailable = true, + isLoading = true, + statusMessage = "Preparing ${readScope.label.lowercase()}", + progress = initialProgress, + cacheSummary = currentReaderTtsCacheSummary() + ) + ) + readerTtsJob = scope.launch { + runCatching { + logDesktopTts("reader_sequence_start scope=${readScope.name} chunks=${ttsChunks.size}") + desktopTtsAdapter.speakChunks(readerSession.reader.book.title, readScope, ttsChunks) { index -> + if (!isActive) throw kotlinx.coroutines.CancellationException("Reader cloud TTS stopped") + val chunk = ttsChunks[index] + val progress = initialProgress.copy(currentChunkIndex = index) + if (readerSession.reader.currentPageIndex != chunk.pageIndex) { + val updatedSession = readerEngine.goToPage(readerSession, chunk.pageIndex) + readerSession = updatedSession + updateActiveBookReadingState( + pageIndex = updatedSession.reader.currentPageIndex, + progress = updatedSession.reader.progress, + session = updatedSession + ) + } + readerExtrasState = readerExtrasState.copy( + cloudTts = ReaderCloudTtsState( + isAvailable = true, + isPlaying = true, + statusMessage = progress.currentPositionLabel ?: "Reading", + progress = progress, + cacheSummary = currentReaderTtsCacheSummary() + ) + ) + logDesktopTts( + "reader_chunk_start scope=${readScope.name} index=${index + 1}/${ttsChunks.size} " + + "page=${chunk.pageIndex + 1} chapter=${chunk.chapterIndex} offsets=${chunk.startOffset}..${chunk.endOffset} " + + "sourceCfi=\"${chunk.sourceCfi.orEmpty().logPreview()}\" chars=${chunk.text.length} " + + "text=\"${chunk.text.logPreview()}\"" + ) + } + }.onFailure { error -> + logDesktopTts("reader_sequence_failed error=\"${error.desktopTtsSummary()}\"") + if (error !is kotlinx.coroutines.CancellationException) error.printStackTrace() + if (error is kotlinx.coroutines.CancellationException) { + readerExtrasState = readerExtrasState.copy( + cloudTts = readerCloudTtsStoppedState(statusMessage = "Stopped") + ) + } else { + readerExtrasState = readerExtrasState.copy( + cloudTts = readerCloudTtsStoppedState(errorMessage = error.message ?: "Cloud TTS failed.") + ) + } + }.onSuccess { + logDesktopTts("reader_sequence_success chunks=${ttsChunks.size}") + readerExtrasState = readerExtrasState.copy( + cloudTts = readerCloudTtsStoppedState(statusMessage = "Finished") + ) + } + } + } + + fun toggleReaderCloudTts(text: String) { + val normalizedText = text.trim() + val settings = aiByokSettings.sanitized() + logDesktopTts( + "reader_toggle textChars=${normalizedText.length} isPlaying=${readerExtrasState.cloudTts.isPlaying} " + + "isLoading=${readerExtrasState.cloudTts.isLoading} keyPresent=${settings.geminiKey.isNotBlank()} " + + "ttsModel=\"${settings.ttsModel.desktopTtsPreview()}\" available=${desktopTtsAdapter.isAvailable}" + ) + if (readerExtrasState.cloudTts.isPlaying || readerExtrasState.cloudTts.isLoading || readerExtrasState.cloudTts.isPaused) { + stopReaderCloudTts() + return + } + if (normalizedText.isBlank()) { + logDesktopTts("reader_toggle_ignored reason=blank_text") + readerExtrasState = readerExtrasState.copy( + cloudTts = readerExtrasState.cloudTts.copy( + errorMessage = "There is no text on this page to read.", + cacheSummary = currentReaderTtsCacheSummary() + ) + ) + return + } + if (!desktopTtsAdapter.isAvailable) { + logDesktopTts("reader_toggle_blocked reason=adapter_unavailable") + readerExtrasState = readerExtrasState.copy( + cloudTts = ReaderCloudTtsState( + isAvailable = false, + errorMessage = "Add a Gemini key and select Gemini cloud TTS in AI keys and models.", + cacheSummary = currentReaderTtsCacheSummary() + ) + ) + return + } + val page = readerSession.reader.currentPage + val selectionChunks = if (page != null) { + ReaderTtsPlanner.chunksForText( + text = normalizedText, + pageIndex = page.pageIndex, + chapterIndex = page.chapterIndex, + chapterTitle = page.chapterTitle, + sourceStartOffset = page.startOffset + ) + } else { + ReaderTtsPlanner.chunksForText( + text = normalizedText, + pageIndex = readerSession.reader.currentPageIndex, + chapterIndex = 0, + chapterTitle = "Selection" + ) + } + startReaderCloudTts(ReaderTtsReadScope.PAGE, selectionChunks) + } + + fun importFiles(files: List) { + val importableFiles = files.filter { it.desktopFileType() in DesktopReadableFileTypes } + if (importableFiles.isEmpty() && files.isNotEmpty()) { + updateState( + state.withBanner( + "No supported desktop reader files were selected. " + + "${SharedFileCapabilities.supportedFormatsLabel(ReaderPlatform.DESKTOP)} are supported.", + isError = true + ) + ) + return + } + val skipped = files.size - importableFiles.size + val existingIds = state.rawLibraryBooks.mapTo(mutableSetOf()) { it.id } + val importablePaths = importableFiles + .mapNotNull { it.localPath ?: it.uriString } + .toSet() + val syncedFolders = mergeSyncedFolders( + existing = state.syncedFolders, + folderRoots = importableFiles.mapNotNull { it.sourceFolder }.distinct(), + nowMillis = System.currentTimeMillis() + ) + val next = state.withImportedFiles(importableFiles) + .copy(syncedFolders = syncedFolders) + .let { + when { + skipped > 0 -> it.withBanner("Imported supported files. Skipped $skipped unsupported file(s).") + else -> it + } + } + updateState(next) + val targetBookIds = next.rawLibraryBooks + .asSequence() + .filter { book -> + book.id !in existingIds || + book.path in importablePaths || + book.id in importablePaths + } + .map { it.id } + .toSet() + if (targetBookIds.isEmpty()) return + val originalTargetBooksById = next.rawLibraryBooks + .filter { it.id in targetBookIds } + .associateBy { it.id } + + scope.launch { + val metadataResult = withContext(Dispatchers.IO) { + DesktopFolderMetadataExtractor.enrichImportedBooks( + books = next.rawLibraryBooks, + importedBookIds = targetBookIds + ) + } + if (metadataResult.stats.updatedBooks > 0) { + val enrichedBooksById = metadataResult.books + .filter { it.id in targetBookIds } + .associateBy { it.id } + updateState( + state.copy( + rawLibraryBooks = state.rawLibraryBooks.map { book -> + val enriched = enrichedBooksById[book.id] ?: return@map book + book.withDesktopImportMetadata( + enriched = enriched, + original = originalTargetBooksById[book.id] + ) + } + ) + ) + } + } + } + + fun syncLocalFolders(targetFolder: File? = null, showBanner: Boolean = true) { + if (targetFolder == null && state.syncedFolders.isEmpty()) { + updateState(state.withBanner("No local folders are linked yet.", isError = true)) + return + } + + val snapshotState = state + val snapshotShelfRefs = shelfRefs + if (showBanner) { + updateState(state.withBanner("Folder sync: scanning local folders...")) + } + + scope.launch { + val result = withContext(Dispatchers.IO) { + DesktopLocalFolderSync.sync( + state = snapshotState, + shelfRefs = snapshotShelfRefs, + targetFolder = targetFolder + ) + } + val failedCount = result.failedFolders.size + val stats = result.stats + val metadataStats = result.metadataStats + val message = when { + failedCount > 0 && stats.supportedFiles == 0 -> + "Folder sync failed for $failedCount folder(s)." + failedCount > 0 -> + "Folder sync finished with $failedCount folder(s) skipped." + else -> + "Folder sync complete: ${stats.newBooks} new, ${stats.updatedBooks + stats.remoteMetadataUpdates + metadataStats.updatedBooks} updated, ${stats.removedBooks} removed." + } + val completedState = if (showBanner || failedCount > 0) { + result.state.withBanner(message, isError = failedCount > 0) + } else { + result.state + } + activeReaderBookId = activeReaderBookId?.let { result.idMigrations[it] ?: it } + replaceLibrary( + completedState, + refs = result.shelfRefs + ) + if (activeReaderBookId != null && completedState.rawLibraryBooks.none { it.id == activeReaderBookId }) { + activePdfDocument?.close() + activePdfDocument = null + activeReaderBookId = null + readerSession = readerEngine.createSession(SampleReaderBooks.desktopWelcomeBook()) + selectedTab = SharedAppTab.HOME + } + } + } + + fun importFolder(folder: File) { + if (!DesktopLocalFolderSync.hasSupportedFiles(folder)) { + updateState(state.withBanner("That folder does not contain any supported desktop reader files.", isError = true)) + return + } + syncLocalFolders(targetFolder = folder) + } + + fun importCustomFont(file: File?): CustomFontItem? { + val source = file ?: return null + return customFontStore.importFont(source) + .onSuccess { font -> + customFonts = (customFonts.filterNot { it.id == font.id } + font) + .filterNot { it.isDeleted } + .sortedBy { it.displayName.lowercase() } + updateState(state.withBanner("Imported ${font.displayName}.")) + } + .onFailure { error -> + updateState(state.withBanner(error.message ?: "Could not import font.", isError = true)) + } + .getOrNull() + } + + fun downloadGoogleFont(fontName: String, onComplete: () -> Unit) { + scope.launch { + val result = withContext(Dispatchers.IO) { + customFontStore.downloadGoogleFont(fontName) + } + result + .onSuccess { font -> + customFonts = (customFonts.filterNot { it.id == font.id } + font) + .filterNot { it.isDeleted } + .sortedBy { it.displayName.lowercase() } + updateState(state.withBanner("${font.displayName} downloaded successfully.")) + } + .onFailure { error -> + updateState(state.withBanner(error.message ?: "Could not download $fontName.", isError = true)) + } + onComplete() + } + } + + fun deleteCustomFont(font: CustomFontItem) { + customFontStore.deleteFont(font) + customFonts = customFonts.filterNot { it.id == font.id } + val clearedSettings = state.rawLibraryBooks.map { book -> + val settings = book.readerSettings + if (settings?.customFontPath == font.path) { + book.copy(readerSettings = settings.copy(fontFamily = "Default", customFontPath = null)) } else { book } } - replaceLibrary( - state.copy( - rawLibraryBooks = books, - allTags = allTags, - selectedBookIds = emptySet(), - bannerMessage = BannerMessage("Tagged ${selected.size} book(s) with \"${tag.name}\".") + if (readerSession.reader.settings.customFontPath == font.path) { + readerSession = readerEngine.updateSettings( + readerSession, + readerSession.reader.settings.copy(fontFamily = "Default", customFontPath = null) ) - ) + } + updateState(state.copy(rawLibraryBooks = clearedSettings).withBanner("Deleted ${font.displayName}.")) + } + + fun removeSelectedBooks() { + SharedLibraryEditor.removeSelectedBooks(state, shelfRecords, shelfRefs)?.let { + replaceLibrary(it.state, records = it.shelfRecords, refs = it.shelfRefs) + } + } + + fun createShelf(name: String) { + SharedLibraryEditor.createShelf(state, shelfRecords, shelfRefs, name, System.currentTimeMillis())?.let { + replaceLibrary(it.state, records = it.shelfRecords, refs = it.shelfRefs) + } + } + + fun createSmartShelf(name: String, definition: SmartCollectionDefinition) { + SharedLibraryEditor.createSmartShelf(state, shelfRecords, shelfRefs, name, definition, System.currentTimeMillis())?.let { + replaceLibrary(it.state, records = it.shelfRecords, refs = it.shelfRefs) + } + } + + fun renameShelf(shelf: Shelf, name: String) { + SharedLibraryEditor.renameShelf(state, shelfRecords, shelfRefs, shelf, name)?.let { + replaceLibrary(it.state, records = it.shelfRecords, refs = it.shelfRefs) + } + } + + fun deleteShelf(shelf: Shelf) { + val result = SharedLibraryEditor.deleteShelf(state, shelfRecords, shelfRefs, shelf) + replaceLibrary(result.state, records = result.shelfRecords, refs = result.shelfRefs) + } + + fun addSelectedBooksToShelf(shelfId: String) { + SharedLibraryEditor.addSelectedBooksToShelf(state, shelfRecords, shelfRefs, shelfId, System.currentTimeMillis())?.let { + replaceLibrary(it.state, records = it.shelfRecords, refs = it.shelfRefs) + } + } + + fun tagSelectedBooks(tagName: String) { + SharedLibraryEditor.tagSelectedBooks(state, shelfRecords, shelfRefs, tagName, System.currentTimeMillis())?.let { + replaceLibrary(it.state, records = it.shelfRecords, refs = it.shelfRefs) + } } fun updateBookMetadata(updated: BookItem) { - replaceLibrary( - state.copy( - rawLibraryBooks = state.rawLibraryBooks.map { if (it.id == updated.id) updated.copy(timestamp = System.currentTimeMillis()) else it }, - allTags = (state.allTags + updated.tags).distinctBy { it.id }.sortedBy { it.name.lowercase() }, - bannerMessage = BannerMessage("Updated \"${updated.cardTitleForMessage()}\".") - ) - ) + val result = SharedLibraryEditor.updateBookMetadata(state, shelfRecords, shelfRefs, updated, System.currentTimeMillis()) + replaceLibrary(result.state, records = result.shelfRecords, refs = result.shelfRefs) + result.state.rawLibraryBooks.firstOrNull { it.id == updated.id }?.let(::syncBookSidecars) + } + + fun recordBookOpened(bookId: String) { + val now = System.currentTimeMillis() + val next = SharedLibraryEditor.markBookOpened(state, bookId, now) + val openedState = next.reduce(AppAction.BookTabOpened(bookId)) + updateState(openedState) + openedState.rawLibraryBooks.firstOrNull { it.id == bookId }?.let(::syncBookSidecars) } fun openReader(book: BookItem) { - if (book.type == FileType.PDF) { + val desktopReaderSurface = SharedFileCapabilities.surfaceFor(book.type, ReaderPlatform.DESKTOP) + if (desktopReaderSurface == ReaderFeatureSurface.PDF_VIEWER) { val path = book.path if (path.isNullOrBlank()) { - updateState(state.withBanner("This PDF does not have a local path.", isError = true)) + updateState( + state.withBanner( + "This ${SharedFileCapabilities.displayNameFor(book.type)} does not have a local path.", + isError = true + ) + ) + return + } + val streamReference = SharedOpdsStreamUri.parse(path) + if (streamReference != null) { + if (activePdfDocument?.path == path) { + activeReaderBookId = book.id + recordBookOpened(book.id) + selectedTab = SharedAppTab.READER + return + } + activePdfDocument?.close() + activePdfDocument = null + val document = runCatching { + DesktopPdfium.loadOpdsStream( + path = path, + title = book.title?.takeIf { it.isNotBlank() } ?: book.displayName, + reference = streamReference, + catalog = opdsRepository.catalogById(streamReference.catalogId) + ) + }.getOrElse { error -> + updateState( + state.withBanner( + "Could not open OPDS stream: ${error.message ?: "unknown error"}", + isError = true + ) + ) + return + } + activePdfDocument = document + activeReaderBookId = book.id + recordBookOpened(book.id) + selectedTab = SharedAppTab.READER + return + } + val readerFile = File(path) + val readerPath = readerFile.absolutePath + if (activePdfDocument?.path == readerPath) { + activeReaderBookId = book.id + recordBookOpened(book.id) + selectedTab = SharedAppTab.READER return } activePdfDocument?.close() activePdfDocument = null - val pdf = runCatching { - DesktopPdfium.load(File(path)) + val document = runCatching { + if (book.type == FileType.PDF) { + DesktopPdfium.load(readerFile) + } else { + DesktopPdfium.loadComic(readerFile, book.type) + } }.getOrElse { error -> - updateState(state.withBanner("Could not open PDF: ${error.message ?: "unknown error"}", isError = true)) + updateState( + state.withBanner( + "Could not open ${SharedFileCapabilities.displayNameFor(book.type)}: " + + (error.message ?: "unknown error"), + isError = true + ) + ) return } - activePdfDocument = pdf + activePdfDocument = document activeReaderBookId = book.id - selectedTab = DesktopTab.READER + recordBookOpened(book.id) + selectedTab = SharedAppTab.READER return } - if (book.type != FileType.EPUB) { - updateState(state.withBanner("${book.type.name} reader support comes later. EPUB and PDF are available on desktop.")) + if (desktopReaderSurface != ReaderFeatureSurface.EPUB_READER && desktopReaderSurface != ReaderFeatureSurface.TEXT_READER) { + updateState( + state.withBanner( + "${SharedFileCapabilities.displayNameFor(book.type)} reader support comes later. " + + "${SharedFileCapabilities.supportedFormatsLabel(ReaderPlatform.DESKTOP)} are available on desktop." + ) + ) return } @@ -448,28 +1238,111 @@ private fun EpistemeDesktopApp() { if (path.isNullOrBlank()) { SampleReaderBooks.desktopWelcomeBook() } else { - DesktopEpubLoader.load(File(path)) + SharedJvmBookLoader.load( + file = File(path), + type = book.type, + titleOverride = book.title?.takeIf { it.isNotBlank() }, + authorOverride = book.author?.takeIf { it.isNotBlank() } + ) } }.getOrElse { error -> - updateState(state.withBanner("Could not open EPUB: ${error.message ?: "unknown error"}", isError = true)) + updateState(state.withBanner("Could not open ${book.type.name}: ${error.message ?: "unknown error"}", isError = true)) return } activePdfDocument?.close() activePdfDocument = null - readerSession = readerEngine.createSession(loadedBook, readerSession.reader.settings) + val restoredSettings = book.readerSettings ?: readerSession.reader.settings + val restoredSession = readerEngine.createSession( + book = loadedBook, + settings = restoredSettings, + initialPageIndex = book.lastPageIndex ?: 0, + bookmarks = book.readerBookmarks, + highlights = book.readerHighlights + ) + val restoredProgress = book.progressPercentage + readerSession = if (book.lastPageIndex == null && restoredProgress != null) { + readerEngine.goToProgress(restoredSession, restoredProgress.coerceIn(0f, 100f) / 100f) + } else { + restoredSession + } activeReaderBookId = book.id - selectedTab = DesktopTab.READER + recordBookOpened(book.id) + selectedTab = SharedAppTab.READER } - fun importAndOpenEpub() { - val file = chooseEpubFile() ?: return - importFiles(listOf(file.toImportedBookFile())) + fun removeFolder(shelf: Shelf) { + val removedBookIds = shelf.books.mapTo(mutableSetOf()) { it.id } + val wasReadingRemovedBook = activeReaderBookId in removedBookIds + val nextTabBook = state.openTabIds + .filterNot { it in removedBookIds } + .lastOrNull() + ?.let { nextId -> state.rawLibraryBooks.firstOrNull { it.id == nextId } } + SharedLibraryEditor.removeFolder(state, shelfRecords, shelfRefs, shelf)?.let { + replaceLibrary(it.state, records = it.shelfRecords, refs = it.shelfRefs) + if (wasReadingRemovedBook) { + activePdfDocument?.close() + activePdfDocument = null + activeReaderBookId = null + if (nextTabBook != null) { + openReader(nextTabBook) + } else { + readerSession = readerEngine.createSession(SampleReaderBooks.desktopWelcomeBook()) + selectedTab = SharedAppTab.HOME + } + } + } + } + + fun closeReaderTab(book: BookItem) { + val wasActive = activeReaderBookId == book.id + val remainingIds = state.openTabIds.filterNot { it == book.id } + updateState(state.reduce(AppAction.BookTabClosed(book.id))) + if (!wasActive) return + + activePdfDocument?.close() + activePdfDocument = null + activeReaderBookId = null + val nextBook = remainingIds.lastOrNull()?.let { nextId -> + state.rawLibraryBooks.firstOrNull { it.id == nextId } + } + if (nextBook != null) { + openReader(nextBook) + } else { + readerSession = readerEngine.createSession(SampleReaderBooks.desktopWelcomeBook()) + selectedTab = SharedAppTab.HOME + } + } + + fun closeAllReaderTabs() { + activePdfDocument?.close() + activePdfDocument = null + activeReaderBookId = null + readerSession = readerEngine.createSession(SampleReaderBooks.desktopWelcomeBook()) + selectedTab = SharedAppTab.HOME + updateState(state.reduce(AppAction.AllTabsClosed)) + } + + fun importAndOpenBook() { + val file = chooseBookFile() ?: return + val importedFile = file.toImportedBookFile() + val type = importedFile.desktopFileType() + if (type !in DesktopBookFileTypes) { + updateState( + state.withBanner( + "No supported desktop reader file was selected. " + + "${SharedFileCapabilities.supportedFormatsLabel(ReaderPlatform.DESKTOP)} are supported.", + isError = true + ) + ) + return + } + importFiles(listOf(importedFile)) openReader( BookItem( id = file.absolutePath, path = file.absolutePath, - type = FileType.EPUB, + type = type, displayName = file.name, timestamp = System.currentTimeMillis(), title = file.nameWithoutExtension, @@ -494,12 +1367,140 @@ private fun EpistemeDesktopApp() { ) } + fun emitOpds(next: com.aryan.reader.shared.opds.SharedOpdsScreenState) { + opdsState = next + } + + fun openOpdsCatalog(catalog: OpdsCatalog) { + scope.launch { + opdsController.openCatalog(catalog, ::emitOpds) + } + } + + fun openOpdsFeedUrl(url: String) { + scope.launch { + opdsController.openFeedUrl(url, ::emitOpds) + } + } + + fun navigateOpdsBack() { + scope.launch { + opdsController.navigateBack(::emitOpds) + } + } + + fun searchOpds(query: String) { + scope.launch { + opdsController.search(query, ::emitOpds) + } + } + + fun loadNextOpdsPage() { + scope.launch { + opdsController.loadNextPage(::emitOpds) + } + } + + fun removeOpdsCatalog(catalog: OpdsCatalog) { + emitOpds(opdsController.removeCatalog(catalog.id)) + val streamBookIds = state.rawLibraryBooks + .filter { book -> SharedOpdsStreamUri.parse(book.path)?.catalogId == catalog.id } + .mapTo(mutableSetOf()) { it.id } + if (streamBookIds.isNotEmpty()) { + if (activeReaderBookId in streamBookIds) { + activePdfDocument?.close() + activePdfDocument = null + activeReaderBookId = null + readerSession = readerEngine.createSession(SampleReaderBooks.desktopWelcomeBook()) + selectedTab = SharedAppTab.HOME + } + updateState( + state.copy( + rawLibraryBooks = state.rawLibraryBooks.filterNot { it.id in streamBookIds }, + openTabIds = state.openTabIds.filterNot { it in streamBookIds }, + activeTabBookId = state.activeTabBookId?.takeUnless { it in streamBookIds } + ).withBanner("Removed ${streamBookIds.size} streamed OPDS book(s) from that catalog.") + ) + } + } + + fun downloadOpdsBook(entry: OpdsEntry, acquisition: OpdsAcquisition) { + val catalog = opdsState.currentCatalog + scope.launch { + emitOpds(opdsController.updateDownloadState(entry.id, SharedOpdsDownloadState(true, 0f))) + val result = runCatching { + opdsRepository.downloadBook(entry, acquisition, catalog) { progress -> + scope.launch { + if (opdsController.state.downloadingState[entry.id]?.isDownloading == true) { + emitOpds(opdsController.updateDownloadState(entry.id, SharedOpdsDownloadState(true, progress))) + } + } + } + } + emitOpds(opdsController.updateDownloadState(entry.id, null)) + result.onSuccess { file -> + importFiles(listOf(file.toImportedBookFile())) + updateState(state.withBanner("Downloaded ${file.name} from OPDS.")) + }.onFailure { error -> + updateState( + state.withBanner( + "Could not download ${entry.title}: ${error.message ?: "unknown error"}", + isError = true + ) + ) + } + } + } + + fun streamOpdsBook(entry: OpdsEntry, catalog: OpdsCatalog?) { + val pageCount = entry.pseCount + val urlTemplate = entry.pseUrlTemplate + if (pageCount == null || pageCount <= 0 || urlTemplate.isNullOrBlank()) { + updateState(state.withBanner("This OPDS entry does not expose a readable stream.", isError = true)) + return + } + val reference = OpdsStreamReference( + id = entry.id.ifBlank { "${entry.title}:$urlTemplate" }, + count = pageCount, + urlTemplate = urlTemplate, + catalogId = catalog?.id + ) + val uriString = SharedOpdsStreamUri.build(reference) + val now = System.currentTimeMillis() + val streamBook = BookItem( + id = uriString, + path = uriString, + type = FileType.CBZ, + displayName = entry.title, + timestamp = now, + title = entry.title, + author = entry.author, + fileSize = 0L + ) + if (state.rawLibraryBooks.none { it.id == streamBook.id }) { + updateState(state.copy(rawLibraryBooks = state.rawLibraryBooks + streamBook)) + } + openReader(streamBook) + } + DisposableEffect(Unit) { onDispose { activePdfDocument?.close() } } + DesktopFileDropTarget( + window = window, + onFilesDropped = ::importFiles, + onDragStateChange = { dropImportState = it } + ) + + LaunchedEffect(Unit) { + if (state.syncedFolders.isNotEmpty()) { + syncLocalFolders(showBanner = false) + } + } + LaunchedEffect(state.bannerMessage) { state.bannerMessage?.let { banner -> snackbarHostState.showSnackbar(banner.message) @@ -507,70 +1508,61 @@ private fun EpistemeDesktopApp() { } } - MaterialTheme( - colorScheme = lightColorScheme( - primary = Color(0xFF006C4C), - secondary = Color(0xFF705D49), - tertiary = Color(0xFF9C4146), - surface = Color(0xFFFCFCF8), - surfaceVariant = Color(0xFFE5E8DE) + LaunchedEffect(aiByokSettings, activeReaderBookId, readerSession.reader.book.title) { + readerExtrasState = readerExtrasState.copy( + cloudTts = readerExtrasState.cloudTts.copy( + isAvailable = aiByokSettings.isCloudTtsAvailable, + errorMessage = null, + cacheSummary = currentReaderTtsCacheSummary() + ) ) - ) { - Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) { padding -> - Row( - modifier = Modifier - .fillMaxSize() - .padding(padding) - ) { - NavigationRail(containerColor = MaterialTheme.colorScheme.surface) { - NavigationRailItem( - selected = selectedTab == DesktopTab.HOME, - onClick = { selectedTab = DesktopTab.HOME }, - icon = { Icon(Icons.Default.Home, contentDescription = null) }, - label = { Text("Home") } - ) - NavigationRailItem( - selected = selectedTab == DesktopTab.LIBRARY, - onClick = { selectedTab = DesktopTab.LIBRARY }, - icon = { Icon(Icons.AutoMirrored.Filled.LibraryBooks, contentDescription = null) }, - label = { Text("Library") } - ) - NavigationRailItem( - selected = selectedTab == DesktopTab.SHELVES, - onClick = { selectedTab = DesktopTab.SHELVES }, - icon = { Icon(Icons.Default.Folder, contentDescription = null) }, - label = { Text("Shelves") } - ) - NavigationRailItem( - selected = selectedTab == DesktopTab.READER, - onClick = { selectedTab = DesktopTab.READER }, - icon = { Icon(Icons.AutoMirrored.Filled.MenuBook, contentDescription = null) }, - label = { Text("Reader") } - ) - Spacer(Modifier.weight(1f)) - IconButton( - onClick = { - importFiles(chooseFiles()) - } - ) { - Icon(Icons.Default.ImportExport, contentDescription = "Import files") - } - IconButton( - onClick = { - updateState(state.reduce(AppAction.BannerShown(BannerMessage("Cloud sync is Android-only for now. Desktop sync will need a separate backend adapter.")))) - } - ) { - Icon(Icons.Default.Sync, contentDescription = "Sync") - } - } + } - Box(Modifier.fillMaxSize()) { - when (selectedTab) { - DesktopTab.HOME -> HomeScreen( + SharedAppTheme( + appThemeMode = state.appThemeMode, + appContrastOption = state.appContrastOption, + appTextDimFactorLight = state.appTextDimFactorLight, + appTextDimFactorDark = state.appTextDimFactorDark, + appSeedColor = state.appSeedColor + ) { + Box( + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + ) { + SharedAppShell( + selectedTab = selectedTab, + snackbarHostState = snackbarHostState, + appThemeMode = state.appThemeMode, + appContrastOption = state.appContrastOption, + appTextDimFactorLight = state.appTextDimFactorLight, + appTextDimFactorDark = state.appTextDimFactorDark, + appSeedColor = state.appSeedColor, + customAppThemes = state.customAppThemes, + isTabsEnabled = state.isTabsEnabled, + onTabSelected = { selectedTab = it }, + onImportFiles = { importFiles(chooseFiles()) }, + onImportFolder = { chooseFolder()?.let(::importFolder) }, + onSyncRequested = { + syncLocalFolders() + }, + onAppThemeModeChange = { mode -> updateState(state.reduce(AppAction.AppThemeChanged(mode))) }, + onAppContrastOptionChange = { option -> updateState(state.reduce(AppAction.AppContrastChanged(option))) }, + onAppTextDimFactorLightChange = { factor -> updateState(state.reduce(AppAction.AppTextDimFactorLightChanged(factor))) }, + onAppTextDimFactorDarkChange = { factor -> updateState(state.reduce(AppAction.AppTextDimFactorDarkChanged(factor))) }, + onAppSeedColorChange = { color -> updateState(state.reduce(AppAction.AppSeedColorChanged(color))) }, + onCustomAppThemeAdded = { theme -> updateState(state.reduce(AppAction.CustomAppThemeAdded(theme))) }, + onCustomAppThemeDeleted = { themeId -> updateState(state.reduce(AppAction.CustomAppThemeDeleted(themeId))) }, + onTabsEnabledChange = { enabled -> updateState(state.reduce(AppAction.TabsEnabledChanged(enabled))) }, + onAiSettingsRequested = { showAiByokSettingsDialog = true } + ) { tab -> + when (tab) { + SharedAppTab.HOME -> HomeScreen( state = state, onImportBooks = { importFiles(chooseFiles()) }, + onImportFolder = { chooseFolder()?.let(::importFolder) }, onRead = ::openReader, onSelect = { id -> updateState(state.reduce(LibraryAction.BookSelectionToggled(id))) }, onClearSelection = { updateState(state.reduce(LibraryAction.SelectionCleared)) }, @@ -578,10 +1570,15 @@ private fun EpistemeDesktopApp() { onShowBookInfo = { bookInfoDialogFor = it }, onEditBook = { bookEditDialogFor = it }, onTagSelectedBooks = { showTagSelectionDialog = true }, - onAddSelectedBooksToShelf = { showAddToShelfDialog = true } + onAddSelectedBooksToShelf = { showAddToShelfDialog = true }, + onOpenTab = ::openReader, + onCloseTab = ::closeReaderTab, + onCloseAllTabs = ::closeAllReaderTabs, + onRecentLimitChange = { limit -> updateState(state.reduce(LibraryAction.RecentLimitChanged(limit))) }, + onTogglePinned = { book -> updateState(state.reduce(AppAction.HomePinToggled(book.id))) } ) - DesktopTab.LIBRARY -> LibraryScreen( + SharedAppTab.LIBRARY -> LibraryScreen( state = state, selectedLibraryTab = selectedLibraryTab, onLibraryTabChange = { selectedLibraryTab = it }, @@ -589,6 +1586,7 @@ private fun EpistemeDesktopApp() { onImportBooks = { importFiles(chooseFiles()) }, + onImportFolder = { chooseFolder()?.let(::importFolder) }, onRead = ::openReader, onSelect = { id -> updateState(state.reduce(LibraryAction.BookSelectionToggled(id))) }, onClearSelection = { updateState(state.reduce(LibraryAction.SelectionCleared)) }, @@ -596,44 +1594,107 @@ private fun EpistemeDesktopApp() { onShowBookInfo = { bookInfoDialogFor = it }, onEditBook = { bookEditDialogFor = it }, onCreateShelf = { showCreateShelfDialog = true }, + onCreateSmartShelf = { showCreateSmartShelfDialog = true }, onRenameShelf = { shelfToRename = it }, onDeleteShelf = { shelfToDelete = it }, + onRemoveFolder = { folderToRemove = it }, onTagSelectedBooks = { showTagSelectionDialog = true }, - onAddSelectedBooksToShelf = { showAddToShelfDialog = true } + onAddSelectedBooksToShelf = { showAddToShelfDialog = true }, + onTogglePinned = { book -> updateState(state.reduce(AppAction.LibraryPinToggled(book.id))) } ) - DesktopTab.SHELVES -> ShelvesScreen( + SharedAppTab.SHELVES -> ShelvesScreen( shelves = state.shelves, onRead = ::openReader, onSelect = { id -> updateState(state.reduce(LibraryAction.BookSelectionToggled(id))) }, selectedBookIds = state.selectedBookIds, + pinnedBookIds = state.pinnedLibraryBookIds, onShowBookInfo = { bookInfoDialogFor = it }, onEditBook = { bookEditDialogFor = it }, + onTogglePinned = { book -> updateState(state.reduce(AppAction.LibraryPinToggled(book.id))) }, onCreateShelf = { showCreateShelfDialog = true }, + onCreateSmartShelf = { showCreateSmartShelfDialog = true }, onRenameShelf = { shelfToRename = it }, - onDeleteShelf = { shelfToDelete = it } + onDeleteShelf = { shelfToDelete = it }, + onRemoveFolder = { folderToRemove = it } ) - DesktopTab.READER -> { + SharedAppTab.CATALOGS -> SharedOpdsScreen( + state = opdsState, + localLibraryBooks = state.rawLibraryBooks, + onOpenCatalog = ::openOpdsCatalog, + onOpenFeedUrl = ::openOpdsFeedUrl, + onNavigateBack = ::navigateOpdsBack, + onSearch = ::searchOpds, + onLoadNextPage = ::loadNextOpdsPage, + onAddCatalog = { title, url, username, password -> + emitOpds(opdsController.addCatalog(title, url, username, password)) + }, + onUpdateCatalog = { id, title, url, username, password -> + emitOpds(opdsController.updateCatalog(id, title, url, username, password)) + }, + onRemoveCatalog = ::removeOpdsCatalog, + onDownloadBook = ::downloadOpdsBook, + onReadBook = ::openReader, + onStreamBook = ::streamOpdsBook, + onClearError = { emitOpds(opdsController.clearError()) } + ) + + SharedAppTab.CUSTOM_FONTS -> SharedCustomFontsScreen( + fonts = customFonts, + onImportFont = { importCustomFont(chooseFontFile()) }, + onDeleteFont = ::deleteCustomFont, + googleFontsAvailable = true, + getGoogleFonts = { customFontStore.loadGoogleFontsList() }, + onDownloadGoogleFont = ::downloadGoogleFont, + fontFamilyForPreview = { font -> font.toDesktopPreviewFontFamily() } + ) + + SharedAppTab.FEEDBACK -> SharedHelpFeedbackScreen( + onOpenGitHubIssues = { openExternalUrl(EpistemeIssuesUrl) }, + onEmailSupport = { + openExternalUrl("mailto:$EpistemeSupportEmail?subject=${EpistemeFeedbackSubject.urlEncode()}") + } + ) + + SharedAppTab.SUPPORT -> SharedSupportProjectScreen( + onOpenGitHubSponsors = { openExternalUrl(EpistemeGitHubSponsorsUrl) }, + onOpenPatreon = { openExternalUrl(EpistemePatreonUrl) } + ) + + SharedAppTab.ABOUT -> SharedAboutScreen( + versionName = desktopAppVersionName(), + buildLabel = "Desktop build", + onOpenSource = { openExternalUrl(EpistemeSourceUrl) }, + onOpenIssues = { openExternalUrl(EpistemeIssuesUrl) } + ) + + SharedAppTab.READER -> { val pdfDocument = activePdfDocument if (pdfDocument != null) { PdfReaderScreen( document = pdfDocument, + initialPageIndex = activeReaderBookId + ?.let { bookId -> state.rawLibraryBooks.find { it.id == bookId }?.lastPageIndex } + ?: 0, + initialReaderSettings = activeReaderBookId + ?.let { bookId -> state.rawLibraryBooks.find { it.id == bookId }?.readerSettings }, onOpenPdf = ::importAndOpenPdf, - onOpenEpub = ::importAndOpenEpub, - onProgressChange = { progress -> - activeReaderBookId?.let { bookId -> - updateState( - state.copy(rawLibraryBooks = state.rawLibraryBooks.map { book -> - if (book.id == bookId) { - book.copy(progressPercentage = progress, timestamp = System.currentTimeMillis()) - } else { - book - } - }) - ) - } - } + onOpenBook = ::importAndOpenBook, + onPageStateChange = { page, progress -> + updateActiveBookReadingState(page, progress) + }, + onReaderSettingsChange = ::updateActiveBookReaderSettings, + customTextureIds = readerCustomTextureIds, + onImportTexture = ::importDesktopReaderTexture, + onLocalSidecarsChanged = { + activeReaderBookId + ?.let { bookId -> state.rawLibraryBooks.firstOrNull { it.id == bookId } } + ?.let(::syncBookSidecars) + }, + aiByokSettings = aiByokSettings, + aiAdapter = desktopAiAdapter, + ttsAdapter = desktopTtsAdapter ) } else { ReaderScreen( @@ -641,31 +1702,64 @@ private fun EpistemeDesktopApp() { readerEngine = readerEngine, onSessionChange = { updated -> readerSession = updated - activeReaderBookId?.let { bookId -> - updateState( - state.copy(rawLibraryBooks = state.rawLibraryBooks.map { book -> - if (book.id == bookId) { - book.copy(progressPercentage = updated.reader.progress, timestamp = System.currentTimeMillis()) - } else { - book - } - }) - ) - } + updateActiveBookReadingState( + pageIndex = updated.reader.currentPageIndex, + progress = updated.reader.progress, + session = updated + ) }, - onOpenEpub = ::importAndOpenEpub, + onOpenBook = ::importAndOpenBook, onOpenPdf = ::importAndOpenPdf, + toolbarPreferences = state.readerToolbarPreferences, + onToolbarPreferencesChange = { preferences -> + updateState(state.reduce(AppAction.ReaderToolbarPreferencesChanged(preferences))) + }, + highlightPalette = state.readerHighlightPalette, + onHighlightPaletteChange = { palette -> + updateState(state.reduce(AppAction.ReaderHighlightPaletteChanged(palette))) + }, + ttsReplacementPreferences = state.readerTtsReplacementPreferences, + ttsReplacementBookId = activeReaderBookId ?: readerSession.reader.book.title, + onTtsReplacementPreferencesChange = { preferences -> + updateState(state.reduce(AppAction.ReaderTtsReplacementPreferencesChanged(preferences))) + }, + onPickCustomFont = { + importCustomFont(chooseFontFile())?.path + }, + customFonts = customFonts, + readerExtrasState = readerExtrasState, + aiByokSettings = aiByokSettings, + onExternalLookup = ::openReaderExternalLookup, + onAiAction = ::runReaderAiAction, + onCloudTtsToggle = ::toggleReaderCloudTts, + onCloudTtsStart = ::startReaderCloudTts, + onCloudTtsPauseResume = ::pauseResumeReaderCloudTts, + onCloudTtsStop = ::stopReaderCloudTts, + onCloudTtsClearCache = ::clearReaderCloudTtsCache, + onAutoScrollChange = ::updateReaderAutoScroll, + readerTextureDataUri = DesktopReaderTextures::dataUriFor, + readerCustomTextureIds = readerCustomTextureIds, + onImportReaderTexture = ::importDesktopReaderTexture, webViewRuntimeState = webViewRuntimeState ) } } - } } } + DesktopDropImportOverlay(dropImportState) + } + + if (showAiByokSettingsDialog) { + DesktopAiByokSettingsDialog( + settings = aiByokSettings, + secureStorageAvailable = aiByokStore.isSecureStorageAvailable, + onSettingsChange = ::updateAiByokSettings, + onDismiss = { showAiByokSettingsDialog = false } + ) } if (showCreateShelfDialog) { - TextInputDialog( + SharedTextInputDialog( title = "Create shelf", label = "Shelf name", initialValue = "", @@ -678,8 +1772,18 @@ private fun EpistemeDesktopApp() { ) } + if (showCreateSmartShelfDialog) { + SmartShelfDialog( + onDismiss = { showCreateSmartShelfDialog = false }, + onConfirm = { name, definition -> + createSmartShelf(name, definition) + showCreateSmartShelfDialog = false + } + ) + } + shelfToRename?.let { shelf -> - TextInputDialog( + SharedTextInputDialog( title = "Rename shelf", label = "Shelf name", initialValue = shelf.name, @@ -693,7 +1797,7 @@ private fun EpistemeDesktopApp() { } shelfToDelete?.let { shelf -> - ConfirmDialog( + SharedConfirmDialog( title = "Delete shelf", body = "Delete \"${shelf.name}\"? Books stay in your library.", confirmLabel = "Delete", @@ -705,8 +1809,21 @@ private fun EpistemeDesktopApp() { ) } + folderToRemove?.let { folder -> + SharedConfirmDialog( + title = "Remove folder", + body = "Remove \"${folder.name}\" and its ${folder.bookCount} book(s) from the app? Files on disk will not be deleted.", + confirmLabel = "Remove", + onDismiss = { folderToRemove = null }, + onConfirm = { + removeFolder(folder) + folderToRemove = null + } + ) + } + if (showAddToShelfDialog) { - AddToShelfDialog( + SharedAddToShelfDialog( shelves = state.shelves.filter { it.type == ShelfType.MANUAL && it.id != "unshelved" }, onDismiss = { showAddToShelfDialog = false }, onCreateShelf = { @@ -721,7 +1838,7 @@ private fun EpistemeDesktopApp() { } if (showTagSelectionDialog) { - TextInputDialog( + SharedTextInputDialog( title = "Tag selected books", label = "Tag name", initialValue = state.allTags.firstOrNull()?.name.orEmpty(), @@ -735,7 +1852,7 @@ private fun EpistemeDesktopApp() { } bookInfoDialogFor?.let { book -> - BookInfoDialog( + SharedBookInfoDialog( book = book, onDismiss = { bookInfoDialogFor = null }, onEdit = { @@ -746,7 +1863,7 @@ private fun EpistemeDesktopApp() { } bookEditDialogFor?.let { book -> - BookEditDialog( + SharedBookEditDialog( book = book, knownTags = state.allTags, onDismiss = { bookEditDialogFor = null }, @@ -759,219 +1876,220 @@ private fun EpistemeDesktopApp() { } } -@Composable -private fun TextInputDialog( - title: String, - label: String, - initialValue: String, - confirmLabel: String, - onDismiss: () -> Unit, - onConfirm: (String) -> Unit -) { - var value by remember(initialValue) { mutableStateOf(initialValue) } - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(title) }, - text = { - OutlinedTextField( - value = value, - onValueChange = { value = it }, - label = { Text(label) }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - }, - confirmButton = { - TextButton(onClick = { onConfirm(value) }, enabled = value.isNotBlank()) { - Text(confirmLabel) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text("Cancel") - } - } - ) -} +private data class DesktopDropImportState( + val active: Boolean = false, + val supportedCount: Int = 0, + val totalFileCount: Int = 0, + val hasFilePayload: Boolean = false +) @Composable -private fun ConfirmDialog( - title: String, - body: String, - confirmLabel: String, - onDismiss: () -> Unit, - onConfirm: () -> Unit +private fun DesktopFileDropTarget( + window: Component?, + onFilesDropped: (List) -> Unit, + onDragStateChange: (DesktopDropImportState) -> Unit ) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(title) }, - text = { Text(body) }, - confirmButton = { - TextButton(onClick = onConfirm) { - Text(confirmLabel) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text("Cancel") - } - } - ) -} + val onFilesDroppedState = rememberUpdatedState(onFilesDropped) + val onDragStateChangeState = rememberUpdatedState(onDragStateChange) -@Composable -private fun AddToShelfDialog( - shelves: List, - onDismiss: () -> Unit, - onCreateShelf: () -> Unit, - onShelfSelected: (Shelf) -> Unit -) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Add to shelf") }, - text = { - if (shelves.isEmpty()) { - Text("Create a shelf first, then add selected books to it.") - } else { - LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp)) { - items(shelves, key = { it.id }) { shelf -> - Surface( - shape = RoundedCornerShape(8.dp), - color = MaterialTheme.colorScheme.surfaceVariant, - modifier = Modifier.fillMaxWidth().clickable { onShelfSelected(shelf) } - ) { - Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { - Icon(Icons.Default.Folder, contentDescription = null, modifier = Modifier.size(20.dp)) - Spacer(Modifier.width(10.dp)) - Text(shelf.name, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) - Text("${shelf.bookCount}", color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } + DisposableEffect(window) { + if (window == null) { + onDispose { } + } else { + val installedTargets = mutableListOf() + var disposed = false + var lastDragState = DesktopDropImportState() + + fun publishDragState(state: DesktopDropImportState) { + if (state == lastDragState) return + lastDragState = state + onDragStateChangeState.value(state) + } + + val listener = object : DropTargetAdapter() { + override fun dragEnter(event: DropTargetDragEvent) { + handleDrag(event) + } + + override fun dragOver(event: DropTargetDragEvent) { + handleDrag(event) + } + + override fun dragExit(event: DropTargetEvent) { + publishDragState(DesktopDropImportState()) + } + + override fun drop(event: DropTargetDropEvent) { + if (!event.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) { + event.rejectDrop() + publishDragState(DesktopDropImportState()) + return + } + event.acceptDrop(DnDConstants.ACTION_COPY) + val files = event.transferable.localDraggedFiles().filter { it.isFile } + if (files.isEmpty()) { + event.dropComplete(false) + publishDragState(DesktopDropImportState()) + return + } + + onFilesDroppedState.value(files.map { it.toImportedBookFile() }) + event.dropComplete(true) + publishDragState(DesktopDropImportState()) + } + + private fun handleDrag(event: DropTargetDragEvent) { + val hasFilePayload = event.isDataFlavorSupported(DataFlavor.javaFileListFlavor) + publishDragState( + DesktopDropImportState( + active = true, + hasFilePayload = hasFilePayload + ) + ) + if (hasFilePayload) { + event.acceptDrag(DnDConstants.ACTION_COPY) + } else { + event.rejectDrag() } } } - }, - confirmButton = { - TextButton(onClick = onCreateShelf) { - Text("New shelf") - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text("Cancel") - } - } - ) -} - -@Composable -private fun BookInfoDialog( - book: BookItem, - onDismiss: () -> Unit, - onEdit: () -> Unit -) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(book.cardTitleForMessage()) }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - InfoRow("File", book.displayName) - InfoRow("Type", book.type.name) - InfoRow("Author", book.author.orEmpty().ifBlank { "Unknown" }) - InfoRow("Path", book.path.orEmpty().ifBlank { "Not available" }) - InfoRow("Size", book.fileSize.toReadableSize()) - InfoRow("Progress", "${(book.progressPercentage ?: 0f).toInt()}%") - if (!book.seriesName.isNullOrBlank()) { - InfoRow("Series", listOfNotNull(book.seriesName, book.seriesIndex?.toString()).joinToString(" #")) - } - if (book.tags.isNotEmpty()) { - InfoRow("Tags", book.tags.joinToString { it.name }) + window.installDropTargets(listener, installedTargets) + EventQueue.invokeLater { + if (!disposed) { + window.installDropTargets(listener, installedTargets) } } - }, - confirmButton = { - TextButton(onClick = onEdit) { - Text("Edit") - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text("Close") + + onDispose { + disposed = true + installedTargets.forEach { installed -> + runCatching { installed.dropTarget.removeDropTargetListener(listener) } + installed.component.dropTarget = installed.previous + } + publishDragState(DesktopDropImportState()) } } - ) -} - -@Composable -private fun InfoRow(label: String, value: String) { - Column { - Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - Text(value, style = MaterialTheme.typography.bodyMedium) } } -@Composable -private fun BookEditDialog( - book: BookItem, - knownTags: List, - onDismiss: () -> Unit, - onSave: (BookItem) -> Unit -) { - var title by remember(book.id) { mutableStateOf(book.title.orEmpty()) } - var author by remember(book.id) { mutableStateOf(book.author.orEmpty()) } - var seriesName by remember(book.id) { mutableStateOf(book.seriesName.orEmpty()) } - var seriesIndex by remember(book.id) { mutableStateOf(book.seriesIndex?.toString().orEmpty()) } - var tagText by remember(book.id) { mutableStateOf(book.tags.joinToString(", ") { it.name }) } +private data class InstalledDropTarget( + val component: Component, + val previous: DropTarget?, + val dropTarget: DropTarget +) - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Edit book") }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - OutlinedTextField(value = title, onValueChange = { title = it }, label = { Text("Title") }, singleLine = true, modifier = Modifier.fillMaxWidth()) - OutlinedTextField(value = author, onValueChange = { author = it }, label = { Text("Author") }, singleLine = true, modifier = Modifier.fillMaxWidth()) - OutlinedTextField(value = seriesName, onValueChange = { seriesName = it }, label = { Text("Series") }, singleLine = true, modifier = Modifier.fillMaxWidth()) - OutlinedTextField(value = seriesIndex, onValueChange = { seriesIndex = it }, label = { Text("Series index") }, singleLine = true, modifier = Modifier.fillMaxWidth()) - OutlinedTextField(value = tagText, onValueChange = { tagText = it }, label = { Text("Tags, comma separated") }, singleLine = true, modifier = Modifier.fillMaxWidth()) - if (knownTags.isNotEmpty()) { - Text("Existing: ${knownTags.joinToString { it.name }}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - }, - confirmButton = { - TextButton( - onClick = { - val parsedTags = tagText.split(',') - .map { it.trim() } - .filter { it.isNotBlank() } - .distinctBy { it.lowercase() } - .map { name -> - knownTags.firstOrNull { it.name.equals(name, ignoreCase = true) } - ?: Tag( - id = name.lowercase().replace(Regex("[^a-z0-9]+"), "_").trim('_').ifBlank { "tag_${System.currentTimeMillis()}" }, - name = name, - color = 0xFF64B5F6.toInt() - ) - } - onSave( - book.copy( - title = title.trim().ifBlank { null }, - author = author.trim().ifBlank { null }, - seriesName = seriesName.trim().ifBlank { null }, - seriesIndex = seriesIndex.toDoubleOrNull(), - tags = parsedTags - ) - ) - } +private fun Component.installDropTargets( + listener: DropTargetAdapter, + installedTargets: MutableList +) { + collectDropTargetComponents() + .distinct() + .filterNot { component -> installedTargets.any { it.component == component } } + .forEach { component -> + val previous = component.dropTarget + val target = DropTarget(component, DnDConstants.ACTION_COPY, listener, true) + installedTargets += InstalledDropTarget(component, previous, target) + } +} + +private fun Component.collectDropTargetComponents(): List { + val collected = mutableListOf() + + fun visit(component: Component) { + collected += component + if (component is Container) { + component.components.forEach(::visit) + } + } + + visit(this) + return collected +} + +@Composable +private fun DesktopDropImportOverlay(state: DesktopDropImportState) { + if (!state.active) return + + val hasSupportedFiles = state.supportedCount > 0 + val title = when { + hasSupportedFiles -> "Drop to import ${state.supportedCount} file${if (state.supportedCount == 1) "" else "s"}" + state.hasFilePayload -> "Drop supported files to import" + else -> "Drop files to import" + } + val body = if (hasSupportedFiles) { + val skipped = state.totalFileCount - state.supportedCount + if (skipped > 0) { + "$skipped unsupported file${if (skipped == 1) "" else "s"} will be skipped." + } else { + "Release to add to your library." + } + } else { + SharedFileCapabilities.supportedFormatsLabel(ReaderPlatform.DESKTOP) + } + + Box( + modifier = Modifier + .fillMaxSize() + .zIndex(20f) + .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.36f)), + contentAlignment = Alignment.Center + ) { + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + tonalElevation = 8.dp, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.55f)) + ) { + Column( + modifier = Modifier.padding(horizontal = 30.dp, vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) ) { - Text("Save") - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text("Cancel") + Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text( + body, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) } } + } +} + +private fun java.awt.datatransfer.Transferable.localDraggedFiles(): List { + if (!isDataFlavorSupported(DataFlavor.javaFileListFlavor)) return emptyList() + return runCatching { + @Suppress("UNCHECKED_CAST") + (getTransferData(DataFlavor.javaFileListFlavor) as? List<*>) + .orEmpty() + .filterIsInstance() + }.getOrDefault(emptyList()) +} + +private fun BookItem.withDesktopImportMetadata( + enriched: BookItem, + original: BookItem? +): BookItem { + fun shouldApplyText(current: String?, originalValue: String?): Boolean { + return current.isNullOrBlank() || current == originalValue + } + + return copy( + title = if (shouldApplyText(title, original?.title)) { + enriched.title ?: title + } else { + title + }, + author = if (shouldApplyText(author, original?.author)) { + enriched.author ?: author + } else { + author + }, + fileSize = enriched.fileSize.takeIf { it > 0L } ?: fileSize, + coverImagePath = coverImagePath?.takeIf { File(it).isFile } ?: enriched.coverImagePath, + folderTextMetadataParsed = folderTextMetadataParsed || enriched.folderTextMetadataParsed ) } @@ -979,6 +2097,7 @@ private fun BookEditDialog( private fun HomeScreen( state: SharedReaderScreenState, onImportBooks: () -> Unit, + onImportFolder: () -> Unit, onRead: (BookItem) -> Unit, onSelect: (String) -> Unit, onClearSelection: () -> Unit, @@ -986,11 +2105,17 @@ private fun HomeScreen( onShowBookInfo: (BookItem) -> Unit, onEditBook: (BookItem) -> Unit, onTagSelectedBooks: () -> Unit, - onAddSelectedBooksToShelf: () -> Unit + onAddSelectedBooksToShelf: () -> Unit, + onOpenTab: (BookItem) -> Unit, + onCloseTab: (BookItem) -> Unit, + onCloseAllTabs: () -> Unit, + onRecentLimitChange: (Int) -> Unit, + onTogglePinned: (BookItem) -> Unit ) { SharedHomeScreen( state = state, onImportBooks = onImportBooks, + onImportFolder = onImportFolder, onOpenBook = onRead, onToggleSelection = onSelect, onClearSelection = onClearSelection, @@ -998,7 +2123,12 @@ private fun HomeScreen( onShowBookInfo = onShowBookInfo, onEditBook = onEditBook, onTagSelectedBooks = onTagSelectedBooks, - onAddSelectedBooksToShelf = onAddSelectedBooksToShelf + onAddSelectedBooksToShelf = onAddSelectedBooksToShelf, + onOpenTab = onOpenTab, + onCloseTab = onCloseTab, + onCloseAllTabs = onCloseAllTabs, + onRecentLimitChange = onRecentLimitChange, + onTogglePinned = onTogglePinned ) } @@ -1016,10 +2146,14 @@ private fun LibraryScreen( onShowBookInfo: (BookItem) -> Unit, onEditBook: (BookItem) -> Unit, onCreateShelf: () -> Unit, + onCreateSmartShelf: () -> Unit, onRenameShelf: (Shelf) -> Unit, onDeleteShelf: (Shelf) -> Unit, + onRemoveFolder: (Shelf) -> Unit, onTagSelectedBooks: () -> Unit, - onAddSelectedBooksToShelf: () -> Unit + onAddSelectedBooksToShelf: () -> Unit, + onImportFolder: () -> Unit, + onTogglePinned: (BookItem) -> Unit ) { SharedLibraryScreen( state = state, @@ -1034,10 +2168,14 @@ private fun LibraryScreen( onShowBookInfo = onShowBookInfo, onEditBook = onEditBook, onCreateShelf = onCreateShelf, + onCreateSmartShelf = onCreateSmartShelf, onRenameShelf = onRenameShelf, onDeleteShelf = onDeleteShelf, + onRemoveFolder = onRemoveFolder, onTagSelectedBooks = onTagSelectedBooks, - onAddSelectedBooksToShelf = onAddSelectedBooksToShelf + onAddSelectedBooksToShelf = onAddSelectedBooksToShelf, + onImportFolder = onImportFolder, + onTogglePinned = onTogglePinned ) } @@ -1045,99 +2183,1423 @@ private fun LibraryScreen( private fun ShelvesScreen( shelves: List, selectedBookIds: Set, + pinnedBookIds: Set, onRead: (BookItem) -> Unit, onSelect: (String) -> Unit, onShowBookInfo: (BookItem) -> Unit, onEditBook: (BookItem) -> Unit, + onTogglePinned: (BookItem) -> Unit, onCreateShelf: () -> Unit, + onCreateSmartShelf: () -> Unit, onRenameShelf: (Shelf) -> Unit, - onDeleteShelf: (Shelf) -> Unit + onDeleteShelf: (Shelf) -> Unit, + onRemoveFolder: (Shelf) -> Unit ) { SharedShelvesScreen( shelves = shelves, selectedBookIds = selectedBookIds, + pinnedBookIds = pinnedBookIds, onOpenBook = onRead, onToggleSelection = onSelect, onShowBookInfo = onShowBookInfo, onEditBook = onEditBook, + onTogglePinned = onTogglePinned, onCreateShelf = onCreateShelf, + onCreateSmartShelf = onCreateSmartShelf, onRenameShelf = onRenameShelf, - onDeleteShelf = onDeleteShelf + onDeleteShelf = onDeleteShelf, + onRemoveFolder = onRemoveFolder ) } +private data class DesktopSmartRuleDraft( + val field: SmartField = SmartField.TITLE, + val operator: SmartOperator = SmartOperator.CONTAINS, + val value: String = "" +) { + fun toRule(): SmartRule? { + val trimmed = value.trim() + if (trimmed.isBlank()) return null + return SmartRule(field = field, operator = operator, value = trimmed) + } +} + +@Composable +private fun SmartShelfDialog( + onDismiss: () -> Unit, + onConfirm: (String, SmartCollectionDefinition) -> Unit +) { + var name by remember { mutableStateOf("") } + var matchAll by remember { mutableStateOf(true) } + var rules by remember { mutableStateOf(listOf(DesktopSmartRuleDraft())) } + val validRules = rules.mapNotNull { it.toRule() } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Create smart shelf") }, + text = { + Column( + modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Shelf name") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + FilterChip( + selected = matchAll, + onClick = { matchAll = true }, + label = { Text("All") } + ) + FilterChip( + selected = !matchAll, + onClick = { matchAll = false }, + label = { Text("Any") } + ) + Spacer(Modifier.weight(1f)) + TextButton( + onClick = { rules = rules + DesktopSmartRuleDraft() }, + enabled = rules.size < 4 + ) { + Text("Add rule") + } + } + rules.forEachIndexed { index, draft -> + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + SmartRuleDropdown( + label = "Field", + selected = draft.field, + options = SmartField.entries.toList(), + optionLabel = { it.desktopLabel() }, + onSelected = { field -> + rules = rules.updateAt(index) { + val operator = smartOperatorsFor(field).first() + copy(field = field, operator = operator, value = "") + } + } + ) + SmartRuleDropdown( + label = "Operator", + selected = draft.operator, + options = smartOperatorsFor(draft.field), + optionLabel = { it.desktopLabel() }, + onSelected = { operator -> + rules = rules.updateAt(index) { copy(operator = operator) } + } + ) + if (rules.size > 1) { + TextButton(onClick = { rules = rules.filterIndexed { i, _ -> i != index } }) { + Text("Remove") + } + } + } + OutlinedTextField( + value = draft.value, + onValueChange = { value -> rules = rules.updateAt(index) { copy(value = value) } }, + label = { Text(draft.field.valueLabel()) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + }, + confirmButton = { + TextButton( + onClick = { + onConfirm(name, SmartCollectionDefinition(matchAll = matchAll, rules = validRules)) + }, + enabled = name.isNotBlank() && validRules.isNotEmpty() + ) { + Text("Create") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} + +@Composable +private fun SmartRuleDropdown( + label: String, + selected: T, + options: List, + optionLabel: (T) -> String, + onSelected: (T) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + Box { + TextButton(onClick = { expanded = true }) { + Text("$label: ${optionLabel(selected)}") + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + options.forEach { option -> + DropdownMenuItem( + text = { Text(optionLabel(option)) }, + onClick = { + expanded = false + onSelected(option) + } + ) + } + } + } +} + +private fun smartOperatorsFor(field: SmartField): List { + return when (field) { + SmartField.PROGRESS -> listOf(SmartOperator.GREATER_THAN, SmartOperator.LESS_THAN, SmartOperator.EQUALS) + else -> listOf(SmartOperator.CONTAINS, SmartOperator.EQUALS) + } +} + +private fun SmartField.desktopLabel(): String { + return when (this) { + SmartField.TITLE -> "Title" + SmartField.AUTHOR -> "Author" + SmartField.PROGRESS -> "Progress" + SmartField.FILE_TYPE -> "File type" + SmartField.FOLDER -> "Folder" + SmartField.TAG -> "Tag" + } +} + +private fun SmartField.valueLabel(): String { + return when (this) { + SmartField.PROGRESS -> "Percent" + SmartField.FILE_TYPE -> "Type, e.g. PDF" + SmartField.FOLDER -> "Folder path" + SmartField.TAG -> "Tag name" + SmartField.TITLE -> "Title text" + SmartField.AUTHOR -> "Author text" + } +} + +private fun SmartOperator.desktopLabel(): String { + return when (this) { + SmartOperator.EQUALS -> "Equals" + SmartOperator.CONTAINS -> "Contains" + SmartOperator.GREATER_THAN -> "Greater than" + SmartOperator.LESS_THAN -> "Less than" + } +} + +private inline fun List.updateAt( + index: Int, + transform: DesktopSmartRuleDraft.() -> DesktopSmartRuleDraft +): List { + return mapIndexed { i, draft -> if (i == index) draft.transform() else draft } +} + +private val DesktopPdfAnnotationTools = listOf( + PdfInkTool.PEN, + PdfInkTool.FOUNTAIN_PEN, + PdfInkTool.PENCIL, + PdfInkTool.HIGHLIGHTER, + PdfInkTool.HIGHLIGHTER_ROUND, + PdfInkTool.TEXT, + PdfInkTool.ERASER +) + +private data class DesktopPdfThemeStyle( + val theme: ReaderTheme, + val viewerBackgroundColor: Color, + val colorFilter: ColorFilter?, + val textureBitmap: ImageBitmap?, + val textureAlpha: Float, + val textureBlendMode: BlendMode +) + +@Composable +private fun DesktopPdfThemedPageImage( + bitmap: ImageBitmap, + contentDescription: String, + themeStyle: DesktopPdfThemeStyle, + modifier: Modifier = Modifier +) { + Box(modifier = modifier) { + Image( + bitmap = bitmap, + contentDescription = contentDescription, + colorFilter = themeStyle.colorFilter, + modifier = Modifier.fillMaxSize() + ) + val textureBitmap = themeStyle.textureBitmap + if (textureBitmap != null && themeStyle.textureAlpha > 0f) { + Canvas(modifier = Modifier.fillMaxSize()) { + drawRect( + brush = ShaderBrush(ImageShader(textureBitmap, TileMode.Repeated, TileMode.Repeated)), + size = size, + blendMode = themeStyle.textureBlendMode, + alpha = themeStyle.textureAlpha + ) + } + } + } +} + +private fun ReaderSettings?.toDesktopPdfReaderSettings(): ReaderSettings { + val defaults = ReaderSettings(themeId = "no_theme") + val settings = this ?: defaults + val themeId = settings.themeId + val hasPdfTheme = BuiltInPdfReaderThemes.any { it.id == themeId } + val hasCustomColors = settings.backgroundColorArgb != null && settings.textColorArgb != null + return settings.copy( + themeId = when { + themeId == null -> "no_theme" + hasPdfTheme || hasCustomColors -> themeId + else -> "no_theme" + } + ) +} + +private fun ReaderSettings.toDesktopPdfThemeStyle(displayMode: PdfDisplayMode): DesktopPdfThemeStyle { + val theme = toDesktopPdfTheme() + val viewerBackground = when (theme.id) { + "no_theme", "system" -> if (displayMode == PdfDisplayMode.VERTICAL_SCROLL) Color.White else Color.Black + "reverse" -> if (displayMode == PdfDisplayMode.VERTICAL_SCROLL) Color.Black else Color.White + else -> theme.backgroundColor.takeIf { it.isSpecified } + ?: if (displayMode == PdfDisplayMode.VERTICAL_SCROLL) Color.White else Color.Black + } + val isDarkTexture = theme.isDark || theme.id == "reverse" + return DesktopPdfThemeStyle( + theme = theme, + viewerBackgroundColor = viewerBackground, + colorFilter = theme.toDesktopPdfColorFilter(), + textureBitmap = DesktopReaderTextures.imageBitmapFor(textureId), + textureAlpha = if (textureId == null) 0f else textureAlpha.coerceIn(0f, 1f), + textureBlendMode = if (isDarkTexture) BlendMode.Screen else BlendMode.Multiply + ) +} + +private fun ReaderSettings.toDesktopPdfTheme(): ReaderTheme { + BuiltInPdfReaderThemes.firstOrNull { it.id == themeId }?.let { return it } + val background = backgroundColorArgb?.toComposeColor() + val text = textColorArgb?.toComposeColor() + return if (background != null && text != null) { + ReaderTheme( + id = themeId ?: "desktop_pdf_custom", + name = "Custom", + backgroundColor = background, + textColor = text, + isDark = darkMode, + textureId = textureId, + isCustom = true + ) + } else { + BuiltInPdfReaderThemes.first() + } +} + +private fun ReaderTheme.toDesktopPdfColorFilter(): ColorFilter? { + return when (id) { + "no_theme", "system" -> null + "reverse" -> { + val colorMatrix = floatArrayOf( + -1f, 0f, 0f, 0f, 255f, + 0f, -1f, 0f, 0f, 255f, + 0f, 0f, -1f, 0f, 255f, + 0f, 0f, 0f, 1f, 0f + ) + ColorFilter.colorMatrix(ColorMatrix(colorMatrix)) + } + else -> { + if (!backgroundColor.isSpecified || !textColor.isSpecified) return null + val bgR = backgroundColor.red * 255f + val bgG = backgroundColor.green * 255f + val bgB = backgroundColor.blue * 255f + val fgR = textColor.red * 255f + val fgG = textColor.green * 255f + val fgB = textColor.blue * 255f + val dr = (bgR - fgR) / 255f + val dg = (bgG - fgG) / 255f + val db = (bgB - fgB) / 255f + val lumR = 0.2126f + val lumG = 0.7152f + val lumB = 0.0722f + val colorMatrix = floatArrayOf( + dr * lumR, dr * lumG, dr * lumB, 0f, fgR, + dg * lumR, dg * lumG, dg * lumB, 0f, fgG, + db * lumR, db * lumG, db * lumB, 0f, fgB, + 0f, 0f, 0f, 1f, 0f + ) + ColorFilter.colorMatrix(ColorMatrix(colorMatrix)) + } + } +} + +private object DesktopReaderTextures { + private val bytesCache = mutableMapOf() + private val dataUriCache = mutableMapOf() + private val imageCache = mutableMapOf() + private val importExtensions = setOf("jpg", "jpeg", "png", "webp", "gif", "bmp") + + fun importedTextureIds(): List { + return readerTextureDirectory() + .listFiles { file -> file.isFile && file.extension.lowercase(Locale.ROOT) in importExtensions } + ?.sortedBy { it.name.lowercase(Locale.ROOT) } + ?.map { ReaderTextureFilePrefix + it.absolutePath } + .orEmpty() + } + + fun importTexture(source: File): String? { + if (!source.isFile) return null + val extension = source.extension.lowercase(Locale.ROOT) + .takeIf { it in importExtensions } + ?: return null + val safeName = source.nameWithoutExtension + .replace(Regex("[^A-Za-z0-9._-]+"), "_") + .trim('_') + .ifBlank { "texture" } + val directory = readerTextureDirectory().apply { mkdirs() } + val target = File(directory, "texture_${System.currentTimeMillis()}_$safeName.$extension") + return runCatching { + source.copyTo(target, overwrite = false) + val textureId = ReaderTextureFilePrefix + target.absolutePath + bytesCache.remove(textureId) + dataUriCache.remove(textureId) + imageCache.remove(textureId) + textureId + }.getOrNull() + } + + fun dataUriFor(textureId: String): String? { + return dataUriCache.getOrPut(textureId) { + val bytes = bytesFor(textureId) ?: return@getOrPut null + val extension = textureExtension(textureId) + "data:${imageMimeTypeForExtension(extension)};base64," + + Base64.getEncoder().encodeToString(bytes) + } + } + + fun imageBitmapFor(textureId: String?): ImageBitmap? { + val id = textureId ?: return null + return imageCache.getOrPut(id) { + val bytes = bytesFor(id) ?: return@getOrPut null + runCatching { + ImageIO.read(ByteArrayInputStream(bytes))?.toComposeImageBitmap() + }.getOrNull() + } + } + + private fun bytesFor(textureId: String): ByteArray? { + return bytesCache.getOrPut(textureId) { + if (textureId.startsWith(ReaderTextureFilePrefix)) { + File(textureId.removePrefix(ReaderTextureFilePrefix)).takeIf { it.isFile }?.readBytes() + } else { + val texture = ReaderTexture.entries.firstOrNull { it.id == textureId } ?: return@getOrPut null + val classLoader = Thread.currentThread().contextClassLoader ?: DesktopReaderTextures::class.java.classLoader + classLoader + ?.getResourceAsStream(texture.assetPath) + ?.use { it.readBytes() } + ?: DesktopReaderTextures::class.java.classLoader + ?.getResourceAsStream(texture.assetPath) + ?.use { it.readBytes() } + } + } + } + + private fun textureExtension(textureId: String): String { + if (textureId.startsWith(ReaderTextureFilePrefix)) { + return File(textureId.removePrefix(ReaderTextureFilePrefix)).extension + } + return ReaderTexture.entries.firstOrNull { it.id == textureId } + ?.assetPath + ?.substringAfterLast('.', "png") + ?: "png" + } + + private fun readerTextureDirectory(): File { + val baseDir = System.getenv("APPDATA")?.takeIf { it.isNotBlank() } + ?: File(System.getProperty("user.home"), "AppData/Roaming").absolutePath + return File(baseDir, "Episteme/reader_textures") + } +} + +private fun imageMimeTypeForExtension(extension: String): String { + return when (extension.lowercase(Locale.ROOT)) { + "jpg", "jpeg" -> "image/jpeg" + "webp" -> "image/webp" + "gif" -> "image/gif" + "bmp" -> "image/bmp" + else -> "image/png" + } +} + +private fun Long.toComposeColor(): Color { + return Color(this and 0xFFFFFFFFL) +} + +private val PdfInkTool.isDesktopHighlighter: Boolean + get() = this == PdfInkTool.HIGHLIGHTER || this == PdfInkTool.HIGHLIGHTER_ROUND + +private fun List.withDesktopPdfDragPoint( + point: Offset, + canvasSize: IntSize, + tool: PdfInkTool, + snapHighlighter: Boolean, + timestamp: Long +): List { + val nextPoint = point.toSharedPdfPoint(canvasSize, timestamp) + if (snapHighlighter && tool.isDesktopHighlighter && isNotEmpty()) { + val pageAspectRatio = canvasSize.width.toFloat() / canvasSize.height.coerceAtLeast(1).toFloat() + return listOf( + first(), + SharedPdfInkRenderer.calculateSnappedPoint( + currentPoint = nextPoint, + startPoint = first(), + pageAspectRatio = pageAspectRatio + ) + ) + } + return this + nextPoint +} + @Composable private fun PdfReaderScreen( document: DesktopPdfDocument, + initialPageIndex: Int, + initialReaderSettings: ReaderSettings? = null, onOpenPdf: () -> Unit, - onOpenEpub: () -> Unit, - onProgressChange: (Float) -> Unit + onOpenBook: () -> Unit, + onPageStateChange: (pageIndex: Int, progress: Float) -> Unit, + onReaderSettingsChange: (ReaderSettings) -> Unit = {}, + customTextureIds: List = emptyList(), + onImportTexture: ((ReaderSettings) -> ReaderSettings?)? = null, + onLocalSidecarsChanged: () -> Unit = {}, + aiByokSettings: ReaderAiByokSettings, + aiAdapter: DesktopByokAiAdapter, + ttsAdapter: DesktopGeminiCloudTtsAdapter ) { - var pageIndex by remember(document.path) { mutableStateOf(0) } val zoomSpec = remember { PdfZoomSpec() } - var scale by remember(document.path) { mutableStateOf(zoomSpec.default) } - var searchQuery by remember(document.path) { mutableStateOf("") } - var activeSearchIndex by remember(document.path) { mutableStateOf(-1) } + var pdfReaderSettings by remember(document.path) { + mutableStateOf(initialReaderSettings.toDesktopPdfReaderSettings()) + } + var pdfState by remember(document.path) { + val defaultTool = PdfInkTool.PEN + val defaultToolConfig = SharedPdfAnnotationDefaults.configFor(defaultTool) + mutableStateOf( + SharedPdfReaderState.initial( + pageCount = document.pageCount, + initialPageIndex = initialPageIndex, + zoomSpec = zoomSpec + ).copy( + isTextSelectionMode = true, + selectedTool = defaultTool, + selectedColorArgb = defaultToolConfig.colorArgb, + strokeWidth = defaultToolConfig.strokeWidth + ) + ) + } var renderedPage by remember(document.path) { mutableStateOf(null) } var renderError by remember(document.path) { mutableStateOf(null) } var isRendering by remember(document.path) { mutableStateOf(false) } var renderJob by remember(document.path) { mutableStateOf(null) } - var selectedTool by remember(document.path) { mutableStateOf(PdfInkTool.PEN) } - var selectedColor by remember(document.path) { mutableStateOf(SharedPdfAnnotationDefaults.configFor(PdfInkTool.PEN).colorArgb) } - var strokeWidth by remember(document.path) { mutableStateOf(SharedPdfAnnotationDefaults.configFor(PdfInkTool.PEN).strokeWidth) } - var textDraft by remember(document.path) { mutableStateOf("") } + var activeTextDraft by remember(document.path) { mutableStateOf(null) } + var textStyleConfig by remember(document.path) { mutableStateOf(SharedPdfTextStyleConfig()) } var pageCanvasSize by remember(document.path) { mutableStateOf(IntSize.Zero) } - var activeStroke by remember(document.path, pageIndex) { mutableStateOf>(emptyList()) } - val annotations = remember(document.path) { mutableStateListOf() } + var activeStroke by remember(document.path, pdfState.pageIndex) { mutableStateOf>(emptyList()) } + var isHighlighterSnapEnabled by remember(document.path) { mutableStateOf(false) } + var selectionStartIndex by remember(document.path, pdfState.pageIndex) { mutableStateOf(null) } + var selectionEndIndex by remember(document.path, pdfState.pageIndex) { mutableStateOf(null) } + var selectionStartHit by remember(document.path, pdfState.pageIndex) { mutableStateOf(null) } + var selectionEndHit by remember(document.path, pdfState.pageIndex) { mutableStateOf(null) } + var textSelection by remember(document.path, pdfState.pageIndex) { mutableStateOf(null) } + var selectionMenuOffset by remember(document.path, pdfState.pageIndex) { mutableStateOf(null) } + var pageScrubPreview by remember(document.path) { mutableStateOf(null) } + var pageScrubStartPage by remember(document.path) { mutableStateOf(null) } + var jumpHistory by remember(document.path) { mutableStateOf(SharedPdfJumpHistory()) } + var externalLinkDialogUrl by remember(document.path) { mutableStateOf(null) } + var pdfExtrasState by remember(document.path) { + mutableStateOf( + ReaderExtrasState( + cloudTts = ReaderCloudTtsState( + isAvailable = aiByokSettings.isCloudTtsAvailable, + cacheSummary = ttsAdapter.cacheSummary(document.title, aiByokSettings.sanitized().ttsSpeakerId) + ) + ) + ) + } + var pdfTtsJob by remember(document.path) { mutableStateOf(null) } val annotationFile = remember(document.path) { desktopPdfAnnotationFile(document.path) } + val bookmarkFile = remember(document.path) { desktopPdfBookmarkFile(document.path) } + val richTextFile = remember(document.path) { desktopPdfRichTextFile(document.path) } + val searchIndexFile = remember(document.path) { desktopPdfSearchIndexFile(document.path) } + val clipboardManager = LocalClipboardManager.current + val density = LocalDensity.current + val pdfScope = rememberCoroutineScope() + var isRichTextMode by remember(document.path) { mutableStateOf(false) } + var isRichTextLoaded by remember(document.path) { mutableStateOf(false) } + val richTextController = remember(document.path) { + SharedPdfRichTextController( + scope = pdfScope, + onDocumentChange = { richDocument -> + if (isRichTextLoaded) { + SharedPdfRichTextLog.d( + "desktop.documentChange save path=\"${richTextFile.absolutePath.logPreview(160)}\" " + + "textLen=${richDocument.text.length} spans=${richDocument.spans.size}" + ) + withContext(Dispatchers.IO) { + richTextFile.parentFile?.mkdirs() + richTextFile.writeText(SharedPdfRichTextSerializer.encode(richDocument)) + } + SharedPdfRichTextLog.d( + "desktop.documentChange saved path=\"${richTextFile.absolutePath.logPreview(160)}\" " + + "lastModified=${richTextFile.lastModified()}" + ) + onLocalSidecarsChanged() + } else { + SharedPdfRichTextLog.d( + "desktop.documentChange ignoredBeforeLoad path=\"${richTextFile.absolutePath.logPreview(160)}\" " + + "textLen=${richDocument.text.length} spans=${richDocument.spans.size}" + ) + } + } + ) + } + val pageVerticalScrollState = rememberScrollState() + val pageHorizontalScrollState = rememberScrollState() + val verticalListState = rememberLazyListState(initialFirstVisibleItemIndex = pdfState.pageIndex) + val currentTextSelection by rememberUpdatedState(textSelection) + val currentPdfAnnotations by rememberUpdatedState(pdfState.annotations) + val currentPdfPageIndex by rememberUpdatedState(pdfState.pageIndex) + + fun clearPdfInteractionState() { + activeStroke = emptyList() + selectionStartIndex = null + selectionEndIndex = null + selectionStartHit = null + selectionEndHit = null + textSelection = null + selectionMenuOffset = null + } + + fun dispatchPdf(action: SharedPdfReaderAction) { + val previousPage = pdfState.pageIndex + val next = pdfState.reduce(action, zoomSpec) + pdfState = next + if (next.pageIndex != previousPage) { + clearPdfInteractionState() + } + } + + fun updatePdfReaderSettings(settings: ReaderSettings) { + val nextSettings = settings.toDesktopPdfReaderSettings() + pdfReaderSettings = nextSettings + onReaderSettingsChange(nextSettings) + } + + fun commitActiveTextDraft() { + val draft = activeTextDraft ?: return + activeTextDraft = null + val annotation = draft.toAnnotation() + if (annotation.text.isNotEmpty()) { + dispatchPdf(SharedPdfReaderAction.AnnotationAdded(annotation)) + } + } + + fun persistActiveTextDraftIfReady(draft: SharedPdfTextDraft) { + val annotation = draft.toAnnotation() + if (annotation.text.isNotEmpty()) { + activeTextDraft = null + textStyleConfig = draft.style + dispatchPdf(SharedPdfReaderAction.AnnotationAdded(annotation)) + } else { + activeTextDraft = draft + } + } + + fun startActiveTextDraft(pageIndex: Int, anchor: Offset, canvasSize: IntSize) { + if (canvasSize.width <= 0 || canvasSize.height <= 0) return + commitActiveTextDraft() + clearPdfInteractionState() + dispatchPdf(SharedPdfReaderAction.AnnotationSelected(null)) + val now = System.currentTimeMillis() + activeTextDraft = SharedPdfTextAnnotationDefaults.createDraft( + id = "text_$now", + pageIndex = pageIndex, + anchor = anchor.toSharedPdfPoint(canvasSize, now), + canvasSize = canvasSize, + style = textStyleConfig, + createdAt = now + ) + } + + fun updateActiveTextDraft(text: String, canvasSize: IntSize) { + activeTextDraft?.withText(text, canvasSize)?.let(::persistActiveTextDraftIfReady) + } + + fun updateActiveTextDraftBounds(bounds: PdfPageBounds) { + activeTextDraft = activeTextDraft?.withBounds(bounds) + } + + fun activeTextDraftContains(pageIndex: Int, offset: Offset, canvasSize: IntSize): Boolean { + return activeTextDraft?.containsOffset(pageIndex, offset, canvasSize) == true + } + + fun updateTextStyleConfig(style: SharedPdfTextStyleConfig) { + textStyleConfig = style + val draft = activeTextDraft + if (draft != null) { + activeTextDraft = if (draft.pageIndex == pdfState.pageIndex && pageCanvasSize.width > 0 && pageCanvasSize.height > 0) { + draft.withStyle(style, pageCanvasSize) + } else { + draft.copy(style = style) + } + return + } + + val selectedTextAnnotation = pdfState.annotations.firstOrNull { + it.id == pdfState.selectedAnnotationId && it.kind == PdfAnnotationKind.TEXT + } + if (selectedTextAnnotation != null) { + dispatchPdf(SharedPdfReaderAction.AnnotationUpdated(selectedTextAnnotation.withSharedPdfTextStyle(style))) + } + } + + fun selectTextAnnotation(annotation: SharedPdfAnnotation) { + if (annotation.kind != PdfAnnotationKind.TEXT) return + SharedPdfRichTextLog.d( + "desktop.textBox.select id=${annotation.id} page=${annotation.pageIndex} " + + "richMode=$isRichTextMode textLen=${annotation.text.length}" + ) + if (isRichTextMode) { + isRichTextMode = false + pdfScope.launch { richTextController.saveImmediate() } + } + commitActiveTextDraft() + clearPdfInteractionState() + textStyleConfig = annotation.sharedPdfTextStyle() + dispatchPdf(SharedPdfReaderAction.AnnotationSelected(annotation.id)) + } + + fun activateRichTextMode() { + SharedPdfRichTextLog.d( + "desktop.mode.activate page=${pdfState.pageIndex} " + + "globalLen=${richTextController.globalTextFieldValue.text.length} layouts=${richTextController.pageLayouts.size}" + ) + commitActiveTextDraft() + clearPdfInteractionState() + dispatchPdf(SharedPdfReaderAction.AnnotationSelected(null)) + if (pdfState.isTextSelectionMode) { + dispatchPdf(SharedPdfReaderAction.TextSelectionModeChanged(false)) + } + isRichTextMode = true + } + + fun deactivateRichTextMode(save: Boolean = true) { + if (!isRichTextMode) return + SharedPdfRichTextLog.d( + "desktop.mode.deactivate page=${pdfState.pageIndex} save=$save " + + "activePage=${richTextController.activePageIndex} globalLen=${richTextController.globalTextFieldValue.text.length}" + ) + isRichTextMode = false + if (save) { + pdfScope.launch { richTextController.saveImmediate() } + } else { + richTextController.clearSelection() + } + } + + fun selectPdfAnnotationTool(tool: PdfInkTool) { + SharedPdfRichTextLog.d( + "desktop.tool.select tool=$tool richMode=$isRichTextMode page=${pdfState.pageIndex}" + ) + deactivateRichTextMode() + if (tool != PdfInkTool.TEXT) { + commitActiveTextDraft() + } + if (tool == PdfInkTool.TEXT && pdfState.isTextSelectionMode) { + dispatchPdf(SharedPdfReaderAction.TextSelectionModeChanged(false)) + clearPdfInteractionState() + } + dispatchPdf(SharedPdfReaderAction.ToolSelected(tool)) + } + + val pageIndex = pdfState.pageIndex + val scale = pdfState.zoom + val displayMode = pdfState.displayMode + val searchQuery = pdfState.searchQuery + val activeSearchIndex = pdfState.activeSearchResultIndex + val searchHighlightMode = pdfState.searchHighlightMode + val selectedTool = pdfState.selectedTool + val selectedColor = pdfState.selectedColorArgb + val strokeWidth = pdfState.strokeWidth + val isTextSelectionMode = pdfState.isTextSelectionMode + val bookmarks = pdfState.bookmarks + val selectedAnnotationId = pdfState.selectedAnnotationId + val annotations = pdfState.annotations + val canGoPrevious = pdfState.canGoPrevious + val canGoNext = pdfState.canGoNext + val progressPercent = pdfState.progressPercent + val pdfThemeStyle = remember(pdfReaderSettings, displayMode) { + pdfReaderSettings.toDesktopPdfThemeStyle(displayMode) + } + val verticalRenderWindow = remember(pageIndex, document.pageCount) { + val start = (pageIndex - 1).coerceAtLeast(0) + val end = (pageIndex + 1).coerceAtMost((document.pageCount - 1).coerceAtLeast(0)) + start..end + } + var arePdfAnnotationsLoaded by remember(document.path) { mutableStateOf(false) } + var arePdfBookmarksLoaded by remember(document.path) { mutableStateOf(false) } + var indexedSearchPageCount by remember(document.path) { mutableStateOf(document.indexedSearchTextPageCount()) } + var isSearchIndexing by remember(document.path) { mutableStateOf(false) } + var searchResults by remember(document.path) { mutableStateOf>(emptyList()) } + var selectedEmbeddedAnnotationId by remember(document.path) { mutableStateOf(null) } + val selectedAnnotation = remember(annotations, selectedAnnotationId) { + annotations.firstOrNull { it.id == selectedAnnotationId } + } + val sortedAnnotations = remember(annotations) { + annotations.sortedWith(compareBy { it.pageIndex }.thenBy { it.createdAt }) + } + val sortedEmbeddedAnnotations = remember(document.embeddedAnnotations) { + document.embeddedAnnotations.sortedWith(compareBy { it.pageIndex }.thenBy { it.index }) + } + val selectedEmbeddedAnnotation = remember(document.embeddedAnnotations, selectedEmbeddedAnnotationId) { + document.embeddedAnnotations.firstOrNull { it.id == selectedEmbeddedAnnotationId } + } + val effectiveTextStyleConfig = remember(activeTextDraft, selectedAnnotation, textStyleConfig) { + activeTextDraft?.style + ?: selectedAnnotation?.takeIf { it.kind == PdfAnnotationKind.TEXT }?.sharedPdfTextStyle() + ?: textStyleConfig + } + val activePdfTtsChunk = pdfExtrasState.cloudTts.progress.currentChunk + + fun currentPdfTtsCacheSummary() = + ttsAdapter.cacheSummary(document.title, aiByokSettings.sanitized().ttsSpeakerId) + + DesktopExternalLinkDialog( + url = externalLinkDialogUrl, + onDismiss = { externalLinkDialogUrl = null } + ) + + LaunchedEffect(aiByokSettings) { + pdfExtrasState = pdfExtrasState.copy( + cloudTts = pdfExtrasState.cloudTts.copy( + isAvailable = aiByokSettings.isCloudTtsAvailable, + errorMessage = null, + cacheSummary = currentPdfTtsCacheSummary() + ) + ) + } LaunchedEffect(document.path) { - annotations.clear() - if (annotationFile.exists()) { - annotations.addAll( - withContext(Dispatchers.IO) { - SharedPdfAnnotationSerializer.decode(annotationFile.readText()) - } + arePdfAnnotationsLoaded = false + val loadedAnnotations = if (annotationFile.exists()) { + withContext(Dispatchers.IO) { + SharedPdfAnnotationSerializer.decode(annotationFile.readText()) + } + } else { + emptyList() + } + dispatchPdf(SharedPdfReaderAction.AnnotationsLoaded(loadedAnnotations)) + arePdfAnnotationsLoaded = true + } + + LaunchedEffect(document.path, annotations, arePdfAnnotationsLoaded) { + if (!arePdfAnnotationsLoaded) return@LaunchedEffect + withContext(Dispatchers.IO) { + runCatching { + annotationFile.parentFile?.mkdirs() + annotationFile.writeText(SharedPdfAnnotationSerializer.encode(annotations)) + } + } + onLocalSidecarsChanged() + } + + LaunchedEffect(document.path) { + isRichTextLoaded = false + SharedPdfRichTextLog.d( + "desktop.loadRichText start path=\"${richTextFile.absolutePath.logPreview(160)}\" exists=${richTextFile.exists()}" + ) + val loadedRichText = withContext(Dispatchers.IO) { + if (richTextFile.exists()) { + val raw = richTextFile.readText() + SharedPdfRichTextLog.d( + "desktop.loadRichText read path=\"${richTextFile.absolutePath.logPreview(160)}\" rawLen=${raw.length}" + ) + SharedPdfRichTextSerializer.decode(raw) + } else { + SharedPdfRichDocument() + } + } + SharedPdfRichTextLog.d( + "desktop.loadRichText decoded textLen=${loadedRichText.text.length} spans=${loadedRichText.spans.size}" + ) + richTextController.replaceDocument(loadedRichText) + isRichTextLoaded = true + SharedPdfRichTextLog.d("desktop.loadRichText ready") + } + + LaunchedEffect(document.path) { + arePdfBookmarksLoaded = false + val loadedBookmarks = if (bookmarkFile.exists()) { + withContext(Dispatchers.IO) { + SharedPdfBookmarkSerializer.decode(bookmarkFile.readText()) + } + } else { + emptyList() + } + dispatchPdf(SharedPdfReaderAction.BookmarksLoaded(loadedBookmarks)) + arePdfBookmarksLoaded = true + } + + LaunchedEffect(document.path, bookmarks, arePdfBookmarksLoaded) { + if (!arePdfBookmarksLoaded) return@LaunchedEffect + withContext(Dispatchers.IO) { + runCatching { + bookmarkFile.parentFile?.mkdirs() + bookmarkFile.writeText(SharedPdfBookmarkSerializer.encode(bookmarks)) + } + } + onLocalSidecarsChanged() + } + + LaunchedEffect(document.path) { + val restoredPageCount = withContext(Dispatchers.IO) { + restoreDesktopPdfSearchIndex(document, searchIndexFile) + } + indexedSearchPageCount = restoredPageCount + isSearchIndexing = indexedSearchPageCount < document.pageCount + withContext(Dispatchers.IO) { + DesktopPdfium.indexSearchPages( + document = document, + onProgress = { indexed, _ -> + indexedSearchPageCount = indexed + }, + shouldContinue = { isActive } + ) + if (isActive) { + saveDesktopPdfSearchIndex(document, searchIndexFile) + } + } + if (!isActive) return@LaunchedEffect + indexedSearchPageCount = document.indexedSearchTextPageCount() + isSearchIndexing = false + } + + LaunchedEffect(document.path, searchQuery, indexedSearchPageCount) { + val normalizedQuery = searchQuery.trim() + searchResults = if (normalizedQuery.isBlank()) { + emptyList() + } else { + withContext(Dispatchers.IO) { + DesktopPdfium.search(document, normalizedQuery) + } + } + } + + fun goToPage( + target: Int, + scrollVertical: Boolean = true, + recordJump: Boolean = false, + saveRichTextBeforePageChange: Boolean = true + ) { + val clampedTarget = target.coerceIn(0, (document.pageCount - 1).coerceAtLeast(0)) + val currentPage = pdfState.pageIndex + SharedPdfRichTextLog.d( + "desktop.goToPage target=$target clamped=$clampedTarget current=$currentPage " + + "richMode=$isRichTextMode scrollVertical=$scrollVertical recordJump=$recordJump " + + "saveRich=$saveRichTextBeforePageChange activePage=${richTextController.activePageIndex}" + ) + if (clampedTarget != currentPage) { + commitActiveTextDraft() + if (isRichTextMode && saveRichTextBeforePageChange) { + SharedPdfRichTextLog.d("desktop.goToPage savingRichTextBeforePageChange from=$currentPage to=$clampedTarget") + pdfScope.launch { richTextController.saveImmediate() } + } + } + if (recordJump) { + jumpHistory = jumpHistory.record( + currentPageIndex = currentPage, + targetPageIndex = clampedTarget, + pageCount = document.pageCount + ) + } + dispatchPdf(SharedPdfReaderAction.GoToPage(clampedTarget)) + if (scrollVertical && displayMode == PdfDisplayMode.VERTICAL_SCROLL) { + pdfScope.launch { + verticalListState.scrollToItem(clampedTarget) + } + } + } + + fun goBackInJumpHistory() { + val targetPage = jumpHistory.backPage ?: return + jumpHistory = jumpHistory.stepBack() + goToPage(targetPage) + } + + fun goForwardInJumpHistory() { + val targetPage = jumpHistory.forwardPage ?: return + jumpHistory = jumpHistory.stepForward() + goToPage(targetPage) + } + + fun activatePdfLink(target: DesktopPdfLinkTarget) { + target.destPageIndex + ?.takeIf { it in 0 until document.pageCount } + ?.let { + logPdfLink("activate_internal fromPage=${pageIndex + 1} targetPage=${it + 1}") + clearPdfInteractionState() + goToPage(it, recordJump = true) + return + } + target.uri + ?.takeIf { it.isNotBlank() } + ?.let { + val url = it.normalizedExternalUrl() + logPdfLink("activate_external fromPage=${pageIndex + 1} url=\"${url.logPreview()}\"") + clearPdfInteractionState() + externalLinkDialogUrl = url + return + } + logPdfLink( + "activate_ignored fromPage=${pageIndex + 1} " + + "dest=${target.destPageIndex} uri=\"${target.uri.orEmpty().logPreview()}\"" + ) + } + + fun toggleBookmark(targetPage: Int) { + val page = targetPage.coerceIn(0, (document.pageCount - 1).coerceAtLeast(0)) + dispatchPdf( + SharedPdfReaderAction.BookmarkToggled( + pageIndex = page, + label = "Page ${page + 1}", + createdAt = System.currentTimeMillis() + ) + ) + } + + fun copySelection(selection: DesktopPdfTextSelection) { + selection.text.takeIf { it.isNotBlank() }?.let { + clipboardManager.setText(AnnotatedString(it)) + } + } + + fun highlightSelection(pageIndex: Int, selection: DesktopPdfTextSelection, canvasSize: IntSize) { + val now = System.currentTimeMillis() + val highlightBounds = DesktopPdfium.textRectsForRange( + document = document, + pageIndex = pageIndex, + startIndex = selection.startIndex, + endIndex = selection.endIndex, + viewportWidth = canvasSize.width, + viewportHeight = canvasSize.height + ).map { it.toPdfPageBounds() } + .filter { it.right > it.left && it.bottom > it.top } + .mergePdfBoundsByLine() + .ifEmpty { selection.lineBounds } + logPdfSelection( + "highlight_create page=${pageIndex + 1} " + + "range=${selection.startIndex}..${selection.endIndex} " + + "chars=${selection.text.length} lines=${highlightBounds.size} " + + "text=\"${selection.text.logPreview()}\"" + ) + logPdfSelection( + "highlight_store page=${pageIndex + 1} " + + "range=${selection.startIndex}..${selection.endIndex} " + + "mode=dynamic_range" + ) + highlightBounds.forEachIndexed { index, bounds -> + logPdfSelection( + "highlight_bound page=${pageIndex + 1} index=$index " + + "left=${bounds.left.formatLogFloat()} top=${bounds.top.formatLogFloat()} " + + "right=${bounds.right.formatLogFloat()} bottom=${bounds.bottom.formatLogFloat()}" + ) + } + dispatchPdf( + SharedPdfReaderAction.AnnotationAdded( + SharedPdfAnnotation( + id = "highlight_${now}", + pageIndex = pageIndex, + kind = PdfAnnotationKind.HIGHLIGHT, + tool = PdfInkTool.HIGHLIGHTER, + bounds = highlightBounds.firstOrNull(), + boundsList = highlightBounds, + text = selection.text, + colorArgb = SharedPdfAnnotationDefaults.configFor(PdfInkTool.HIGHLIGHTER).colorArgb, + rangeStartIndex = selection.startIndex, + rangeEndIndex = selection.endIndex, + createdAt = now + ) + ) + ) + } + + fun clearSelection() { + textSelection = null + selectionStartIndex = null + selectionEndIndex = null + selectionStartHit = null + selectionEndHit = null + selectionMenuOffset = null + } + + fun highlightCurrentSelection() { + val selection = textSelection ?: return + highlightSelection(pageIndex, selection, pageCanvasSize) + clearSelection() + } + + fun searchSelection(selection: DesktopPdfTextSelection) { + dispatchPdf(SharedPdfReaderAction.SearchChanged(selection.text.take(120))) + } + + fun translateSelection(selection: DesktopPdfTextSelection) { + openExternalUrl(externalLookupUrl(ReaderExternalLookupAction.TRANSLATE, selection.text)) + } + + fun openPdfExternalLookup(action: ReaderExternalLookupAction, text: String) { + val normalizedText = text.trim() + if (normalizedText.isBlank()) return + openExternalUrl(externalLookupUrl(action, normalizedText.take(1800))) + } + + fun currentPdfPageText(maxChars: Int = 8000): String { + return runCatching { document.textPageData(pageIndex).text.trim().take(maxChars) }.getOrDefault("") + } + + fun pdfTtsChunksForPages(pageIndices: Iterable): List { + val chunks = mutableListOf() + pageIndices.forEach { targetPage -> + if (targetPage !in 0 until document.pageCount) return@forEach + val pageText = runCatching { document.textPageData(targetPage).text }.getOrDefault("") + ReaderTtsPlanner.chunksForText( + text = pageText, + pageIndex = targetPage, + chapterIndex = 0, + chapterTitle = "Page ${targetPage + 1}" + ).forEach { chunk -> + chunks += chunk.copy(index = chunks.size) + } + } + return chunks + } + + fun pdfTtsChunksForScope(readScope: ReaderTtsReadScope, startPageIndex: Int = pageIndex): List { + return when (readScope) { + ReaderTtsReadScope.PAGE -> pdfTtsChunksForPages(listOf(startPageIndex)) + ReaderTtsReadScope.CHAPTER, + ReaderTtsReadScope.BOOK -> pdfTtsChunksForPages(startPageIndex until document.pageCount) + } + } + + fun pdfTextBeforeCurrentPage(maxChars: Int = 24_000): String { + val indexedText = document.indexedSearchPages() + .filter { it.pageIndex <= pageIndex } + .joinToString("\n\n") { "Page ${it.pageIndex + 1}\n${it.text}" } + .trim() + return indexedText.ifBlank { currentPdfPageText(maxChars) }.takeLast(maxChars) + } + + fun updatePdfAutoScroll(autoScroll: ReaderAutoScrollState) { + pdfExtrasState = pdfExtrasState.copy(autoScroll = autoScroll.sanitized()) + } + + fun pdfCloudTtsStoppedState(statusMessage: String? = null, errorMessage: String? = null) = ReaderCloudTtsState( + isAvailable = aiByokSettings.sanitized().isCloudTtsAvailable, + statusMessage = statusMessage, + errorMessage = errorMessage, + cacheSummary = currentPdfTtsCacheSummary() + ) + + fun runPdfAiAction(feature: ReaderAiFeature, text: String) { + val normalizedText = text.trim() + if (normalizedText.isBlank()) return + if (!aiByokSettings.sanitized().areReaderAiFeaturesAvailable) return + pdfExtrasState = pdfExtrasState.copy( + aiResult = ReaderAiResultState( + title = feature.displayName, + isLoading = true + ) + ) + pdfScope.launch { + val result = when (feature) { + ReaderAiFeature.DEFINE -> aiAdapter.define(normalizedText.take(2400), currentPdfPageText()).let { it.definition to it.error } + ReaderAiFeature.SUMMARIZE -> aiAdapter.summarize(normalizedText).let { it.summary to it.error } + ReaderAiFeature.RECAP -> aiAdapter.recap(normalizedText).let { it.recap to it.error } + } + pdfExtrasState = pdfExtrasState.copy( + aiResult = ReaderAiResultState( + title = feature.displayName, + text = result.first.orEmpty(), + errorMessage = result.second, + isLoading = false + ) ) } } - LaunchedEffect(document.path, annotations.size) { - val snapshot = annotations.toList() - withContext(Dispatchers.IO) { + fun stopPdfCloudTts() { + logDesktopTts("pdf_stop_requested") + pdfTtsJob?.cancel() + pdfTtsJob = null + pdfScope.launch { + ttsAdapter.stop() + pdfExtrasState = pdfExtrasState.copy( + cloudTts = pdfCloudTtsStoppedState(statusMessage = "Stopped") + ) + } + } + + fun pauseResumePdfCloudTts() { + val current = pdfExtrasState.cloudTts + if (current.isPaused) { + pdfScope.launch { + ttsAdapter.resume() + pdfExtrasState = pdfExtrasState.copy( + cloudTts = pdfExtrasState.cloudTts.copy( + isPaused = false, + isPlaying = true, + statusMessage = pdfExtrasState.cloudTts.progress.currentPositionLabel ?: "Reading" + ) + ) + } + } else if (current.isPlaying) { + pdfScope.launch { + ttsAdapter.pause() + pdfExtrasState = pdfExtrasState.copy( + cloudTts = pdfExtrasState.cloudTts.copy( + isPlaying = false, + isPaused = true, + statusMessage = "Paused" + ) + ) + } + } + } + + fun clearPdfCloudTtsCache() { + ttsAdapter.clearBookCacheForSpeaker(document.title, aiByokSettings.sanitized().ttsSpeakerId) + pdfExtrasState = pdfExtrasState.copy( + cloudTts = pdfExtrasState.cloudTts.copy( + statusMessage = "Voice cache cleared", + cacheSummary = currentPdfTtsCacheSummary() + ) + ) + } + + fun startPdfCloudTts(readScope: ReaderTtsReadScope) { + val settings = aiByokSettings.sanitized() + val startPageIndex = pageIndex + logDesktopTts( + "pdf_sequence_toggle scope=${readScope.name} startPage=${startPageIndex + 1} " + + "isPlaying=${pdfExtrasState.cloudTts.isPlaying} isLoading=${pdfExtrasState.cloudTts.isLoading} " + + "keyPresent=${settings.geminiKey.isNotBlank()} ttsModel=\"${settings.ttsModel.desktopTtsPreview()}\" " + + "available=${ttsAdapter.isAvailable}" + ) + if (pdfExtrasState.cloudTts.isPlaying || pdfExtrasState.cloudTts.isLoading || pdfExtrasState.cloudTts.isPaused) { + stopPdfCloudTts() + return + } + if (!ttsAdapter.isAvailable) { + logDesktopTts("pdf_sequence_blocked reason=adapter_unavailable") + pdfExtrasState = pdfExtrasState.copy( + cloudTts = ReaderCloudTtsState( + isAvailable = false, + errorMessage = "Add a Gemini key and select Gemini cloud TTS in AI keys and models.", + cacheSummary = currentPdfTtsCacheSummary() + ) + ) + return + } + val ttsSessionId = System.currentTimeMillis() + pdfExtrasState = pdfExtrasState.copy( + cloudTts = ReaderCloudTtsState( + isAvailable = true, + isLoading = true, + statusMessage = "Preparing ${readScope.label.lowercase()}", + cacheSummary = currentPdfTtsCacheSummary() + ) + ) + val noTextMessage = "There is no text here to read." + pdfTtsJob = pdfScope.launch { + var completedChunkCount = 0 runCatching { - annotationFile.parentFile?.mkdirs() - annotationFile.writeText(SharedPdfAnnotationSerializer.encode(snapshot)) - } - } - } - - fun applyTool(tool: PdfInkTool) { - selectedTool = tool - val config = SharedPdfAnnotationDefaults.configFor(tool) - selectedColor = config.colorArgb - strokeWidth = config.strokeWidth - } - - val searchResults = remember(document.path, searchQuery) { - val normalized = searchQuery.trim() - if (normalized.isBlank()) { - emptyList() - } else { - document.textPages.mapIndexedNotNull { index, text -> - val matchIndex = text.indexOf(normalized, ignoreCase = true) - if (matchIndex < 0) { - null - } else { - ReaderPdfSearchResult(index, text.previewAround(matchIndex, normalized.length)) + val ttsChunks = withContext(Dispatchers.IO) { + pdfTtsChunksForScope(readScope, startPageIndex) + .filter { it.text.isNotBlank() } + .withTtsReplacements(state.readerTtsReplacementPreferences, document.path) } + if (ttsChunks.isEmpty()) { + logDesktopTts("pdf_sequence_ignored reason=blank_text scope=${readScope.name}") + throw IllegalStateException(noTextMessage) + } + val initialProgress = ReaderTtsProgress( + sessionId = ttsSessionId, + scope = readScope, + chunks = ttsChunks, + currentChunkIndex = -1 + ) + logDesktopTts("pdf_sequence_start scope=${readScope.name} chunks=${ttsChunks.size}") + ttsAdapter.speakChunks(document.title, readScope, ttsChunks) { index -> + if (!isActive) throw kotlinx.coroutines.CancellationException("PDF cloud TTS stopped") + val chunk = ttsChunks[index] + val progress = initialProgress.copy(currentChunkIndex = index) + if (chunk.pageIndex != pdfState.pageIndex) { + goToPage(chunk.pageIndex, recordJump = false) + } + pdfExtrasState = pdfExtrasState.copy( + cloudTts = ReaderCloudTtsState( + isAvailable = true, + isPlaying = true, + statusMessage = progress.currentPositionLabel ?: "Reading", + progress = progress, + cacheSummary = currentPdfTtsCacheSummary() + ) + ) + logDesktopTts( + "pdf_chunk_start scope=${readScope.name} index=${index + 1}/${ttsChunks.size} " + + "page=${chunk.pageIndex + 1} offsets=${chunk.startOffset}..${chunk.endOffset} chars=${chunk.text.length}" + ) + completedChunkCount = index + 1 + } + }.onFailure { error -> + logDesktopTts("pdf_sequence_failed error=\"${error.desktopTtsSummary()}\"") + if (error !is kotlinx.coroutines.CancellationException && error.message != noTextMessage) error.printStackTrace() + if (error is kotlinx.coroutines.CancellationException) { + pdfExtrasState = pdfExtrasState.copy( + cloudTts = pdfCloudTtsStoppedState(statusMessage = "Stopped") + ) + } else { + pdfExtrasState = pdfExtrasState.copy( + cloudTts = pdfCloudTtsStoppedState(errorMessage = error.message ?: "Cloud TTS failed.") + ) + } + }.onSuccess { + logDesktopTts("pdf_sequence_success chunks=$completedChunkCount") + pdfExtrasState = pdfExtrasState.copy( + cloudTts = pdfCloudTtsStoppedState(statusMessage = "Finished") + ) } } } - fun goToPage(target: Int) { - pageIndex = target.coerceIn(0, (document.pageCount - 1).coerceAtLeast(0)) - activeStroke = emptyList() + fun togglePdfCloudTts(text: String) { + val normalizedText = text.trim() + val settings = aiByokSettings.sanitized() + logDesktopTts( + "pdf_toggle textChars=${normalizedText.length} isPlaying=${pdfExtrasState.cloudTts.isPlaying} " + + "isLoading=${pdfExtrasState.cloudTts.isLoading} keyPresent=${settings.geminiKey.isNotBlank()} " + + "ttsModel=\"${settings.ttsModel.desktopTtsPreview()}\" available=${ttsAdapter.isAvailable}" + ) + if (pdfExtrasState.cloudTts.isPlaying || pdfExtrasState.cloudTts.isLoading || pdfExtrasState.cloudTts.isPaused) { + stopPdfCloudTts() + return + } + if (normalizedText.isBlank()) { + logDesktopTts("pdf_toggle_ignored reason=blank_text") + pdfExtrasState = pdfExtrasState.copy( + cloudTts = pdfExtrasState.cloudTts.copy( + errorMessage = "There is no text on this page to read.", + cacheSummary = currentPdfTtsCacheSummary() + ) + ) + return + } + if (!ttsAdapter.isAvailable) { + logDesktopTts("pdf_toggle_blocked reason=adapter_unavailable") + pdfExtrasState = pdfExtrasState.copy( + cloudTts = ReaderCloudTtsState( + isAvailable = false, + errorMessage = "Add a Gemini key and select Gemini cloud TTS in AI keys and models.", + cacheSummary = currentPdfTtsCacheSummary() + ) + ) + return + } + val selectionChunks = ReaderTtsPlanner.chunksForText( + text = normalizedText, + pageIndex = pageIndex, + chapterIndex = 0, + chapterTitle = "Page ${pageIndex + 1}" + ).withTtsReplacements(state.readerTtsReplacementPreferences, document.path) + if (selectionChunks.isEmpty()) { + pdfExtrasState = pdfExtrasState.copy( + cloudTts = pdfExtrasState.cloudTts.copy( + errorMessage = "There is no text on this page to read.", + cacheSummary = currentPdfTtsCacheSummary() + ) + ) + return + } + pdfTtsJob = null + pdfExtrasState = pdfExtrasState.copy( + cloudTts = pdfExtrasState.cloudTts.copy(cacheSummary = currentPdfTtsCacheSummary()) + ) + pdfTtsJob = pdfScope.launch { + val initialProgress = ReaderTtsProgress( + sessionId = System.currentTimeMillis(), + scope = ReaderTtsReadScope.PAGE, + chunks = selectionChunks, + currentChunkIndex = -1 + ) + runCatching { + pdfExtrasState = pdfExtrasState.copy( + cloudTts = ReaderCloudTtsState( + isAvailable = true, + isLoading = true, + statusMessage = "Preparing selection", + progress = initialProgress, + cacheSummary = currentPdfTtsCacheSummary() + ) + ) + ttsAdapter.speakChunks(document.title, ReaderTtsReadScope.PAGE, selectionChunks) { index -> + val progress = initialProgress.copy(currentChunkIndex = index) + pdfExtrasState = pdfExtrasState.copy( + cloudTts = ReaderCloudTtsState( + isAvailable = true, + isPlaying = true, + statusMessage = progress.currentPositionLabel ?: "Reading", + progress = progress, + cacheSummary = currentPdfTtsCacheSummary() + ) + ) + } + }.onFailure { error -> + logDesktopTts("pdf_job_failed error=\"${error.desktopTtsSummary()}\"") + if (error !is kotlinx.coroutines.CancellationException) error.printStackTrace() + pdfExtrasState = pdfExtrasState.copy( + cloudTts = if (error is kotlinx.coroutines.CancellationException) { + pdfCloudTtsStoppedState(statusMessage = "Stopped") + } else { + pdfCloudTtsStoppedState(errorMessage = error.message ?: "Cloud TTS failed.") + } + ) + }.onSuccess { + logDesktopTts("pdf_job_success") + pdfExtrasState = pdfExtrasState.copy( + cloudTts = pdfCloudTtsStoppedState(statusMessage = "Finished") + ) + } + } + } + + fun updateAnnotation(annotation: SharedPdfAnnotation) { + dispatchPdf(SharedPdfReaderAction.AnnotationUpdated(annotation)) + } + + fun deleteAnnotation(annotationId: String) { + dispatchPdf(SharedPdfReaderAction.AnnotationDeleted(annotationId)) + } + + fun selectAnnotation(annotation: SharedPdfAnnotation?) { + dispatchPdf(SharedPdfReaderAction.AnnotationSelected(annotation?.id)) + annotation?.let { goToPage(it.pageIndex, recordJump = true) } + } + + fun selectEmbeddedAnnotation(annotation: SharedPdfEmbeddedAnnotation?) { + selectedEmbeddedAnnotationId = annotation?.id + annotation?.let { goToPage(it.pageIndex, recordJump = true) } } fun goToSearchResult(targetIndex: Int) { @@ -1147,83 +3609,472 @@ private fun PdfReaderScreen( targetIndex > searchResults.lastIndex -> 0 else -> targetIndex } - activeSearchIndex = normalizedIndex - goToPage(searchResults[normalizedIndex].pageIndex) + val targetPage = searchResults[normalizedIndex].pageIndex + jumpHistory = jumpHistory.record( + currentPageIndex = pdfState.pageIndex, + targetPageIndex = targetPage, + pageCount = document.pageCount + ) + if (targetPage != pdfState.pageIndex) { + commitActiveTextDraft() + } + dispatchPdf(SharedPdfReaderAction.GoToSearchResult(targetIndex, searchResults)) + if (displayMode == PdfDisplayMode.VERTICAL_SCROLL) { + pdfScope.launch { + verticalListState.scrollToItem(targetPage) + } + } } - LaunchedEffect(document.path, pageIndex) { - onProgressChange(((pageIndex + 1).toFloat() / document.pageCount.coerceAtLeast(1)) * 100f) + LaunchedEffect(document.path, document.pageCount) { + jumpHistory = jumpHistory.pruned(document.pageCount) } - LaunchedEffect(document.path, pageIndex, scale) { + LaunchedEffect(document.path, pageIndex, progressPercent) { + onPageStateChange(pageIndex, progressPercent) + } + + LaunchedEffect(document.path, displayMode) { + if (displayMode == PdfDisplayMode.VERTICAL_SCROLL && pageIndex in 0 until document.pageCount) { + verticalListState.scrollToItem(pageIndex) + } + } + + LaunchedEffect(pdfExtrasState.autoScroll.sanitized(), pageIndex, canGoNext, displayMode) { + val autoScroll = pdfExtrasState.autoScroll.sanitized() + if (!autoScroll.enabled) return@LaunchedEffect + if (!canGoNext) { + updatePdfAutoScroll(autoScroll.copy(enabled = false)) + return@LaunchedEffect + } + val delayMs = (180_000f / autoScroll.speed).roundToInt().coerceIn(1_200, 12_000) + delay(delayMs.toLong()) + goToPage(pageIndex + 1) + } + + LaunchedEffect(document.path, displayMode, verticalListState) { + if (displayMode != PdfDisplayMode.VERTICAL_SCROLL) return@LaunchedEffect + snapshotFlow { + val layoutInfo = verticalListState.layoutInfo + val visibleItems = layoutInfo.visibleItemsInfo + if (visibleItems.isEmpty()) { + verticalListState.firstVisibleItemIndex + } else { + mostVisiblePdfPageIndex( + visiblePages = visibleItems.map { item -> + PdfVisiblePageLayout( + pageIndex = item.index, + top = item.offset.toFloat(), + bottom = (item.offset + item.size).toFloat() + ) + }, + viewportTop = layoutInfo.viewportStartOffset.toFloat(), + viewportBottom = layoutInfo.viewportEndOffset.toFloat(), + fallbackPageIndex = verticalListState.firstVisibleItemIndex + ) + } + } + .distinctUntilChanged() + .collect { visiblePage -> + if (visiblePage in 0 until document.pageCount && visiblePage != currentPdfPageIndex) { + goToPage(visiblePage, scrollVertical = false) + } + } + } + + LaunchedEffect(document.path, pageIndex, scale, displayMode) { renderJob?.cancel() + if (displayMode != PdfDisplayMode.PAGINATION) { + isRendering = false + renderError = null + renderedPage = null + return@LaunchedEffect + } renderJob = launch { delay(90) isRendering = true renderError = null + val pageSize = document.pageSizes[pageIndex] val safeScale = zoomSpec.safeRenderScale( - document.pageSizes[pageIndex].width, - document.pageSizes[pageIndex].height, - scale + pageSize.width, + pageSize.height, scale ) val result = withContext(Dispatchers.IO) { runCatching { DesktopPdfium.renderPage(document, pageIndex, safeScale) } } + if (pageIndex != pageIndex || scale != scale) { + return@launch + } renderedPage = result.getOrNull() renderError = result.exceptionOrNull()?.message ?: if (renderedPage == null) "Failed to render page." else null + renderedPage?.let { render -> + logPdfSelection( + "render page=${pageIndex + 1} " + + "requestedScale=${scale.formatLogFloat()} safeScale=${safeScale.formatLogFloat()} " + + "pageSize=${pageSize.width.formatLogFloat()}x${pageSize.height.formatLogFloat()} " + + "bitmap=${render.width}x${render.height} capped=${safeScale < zoomSpec.clamp( + scale + )}" + ) + } isRendering = false } } - ScreenScaffold( - title = document.title, - subtitle = "PDF - Page ${pageIndex + 1} of ${document.pageCount}", - trailing = { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - TextButton(onClick = onOpenPdf) { - Text("Open PDF") - } - TextButton(onClick = onOpenEpub) { - Text("Open EPUB") - } - Text("${(((pageIndex + 1).toFloat() / document.pageCount.coerceAtLeast(1)) * 100f).toInt()}%") - } + val pdfWorkspaceModel = pdfReaderWorkspaceModel( + state = pdfState, + displayMode = displayMode, + hasContents = document.toc.isNotEmpty(), + hasBookmarks = bookmarks.isNotEmpty(), + hasAnnotations = sortedAnnotations.isNotEmpty(), + hasEmbeddedComments = sortedEmbeddedAnnotations.isNotEmpty(), + searchActive = searchQuery.isNotBlank(), + annotationEditing = activeTextDraft != null || + selectedAnnotation != null || + selectedTool != PdfInkTool.PEN || + !isTextSelectionMode, + richTextEditing = isRichTextMode, + loading = isRendering || isSearchIndexing, + errorMessage = renderError, + extrasState = pdfExtrasState, + aiAvailable = aiByokSettings.sanitized().areReaderAiFeaturesAvailable + ) + + fun handlePdfReaderKeyEvent(event: androidx.compose.ui.input.key.KeyEvent): Boolean { + if (event.type != KeyEventType.KeyDown) return false + val isEditingTextAnnotation = + activeTextDraft != null || + (selectedTool == PdfInkTool.TEXT && selectedAnnotation?.kind == PdfAnnotationKind.TEXT) + if ((isEditingTextAnnotation || isRichTextMode) && !event.isCtrlPressed) { + return false } - ) { - Row( - Modifier - .fillMaxSize() - .onPreviewKeyEvent { event -> - if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false - when { - event.key == Key.DirectionLeft -> { - goToPage(pageIndex - 1) - true + return when { + event.key == Key.DirectionLeft -> { + goToPage(pageIndex - 1) + true + } + event.key == Key.DirectionRight -> { + goToPage(pageIndex + 1) + true + } + event.key == Key.DirectionUp && displayMode == PdfDisplayMode.VERTICAL_SCROLL -> { + goToPage(pageIndex - 1) + true + } + event.key == Key.DirectionDown && displayMode == PdfDisplayMode.VERTICAL_SCROLL -> { + goToPage(pageIndex + 1) + true + } + event.key == Key.PageUp -> { + goToPage(pageIndex - 1) + true + } + event.key == Key.PageDown -> { + goToPage(pageIndex + 1) + true + } + event.key == Key.MoveHome -> { + goToPage(0) + true + } + event.key == Key.MoveEnd -> { + goToPage(document.pageCount - 1) + true + } + event.isCtrlPressed && event.key == Key.Equals -> { + dispatchPdf(SharedPdfReaderAction.ZoomBy(0.15f)) + true + } + event.isCtrlPressed && event.key == Key.Minus -> { + dispatchPdf(SharedPdfReaderAction.ZoomBy(-0.15f)) + true + } + else -> false + } + } + + @Composable + fun PdfNavigationSidebar() { + Surface( + modifier = Modifier + .width(300.dp) + .fillMaxHeight(), + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(8.dp), + tonalElevation = 2.dp + ) { + LazyColumn( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + item { + Text("Contents", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + } + item { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + TextButton(onClick = { goToPage(pageIndex - 1) }, enabled = canGoPrevious) { + Text("Previous") } - event.key == Key.DirectionRight -> { - goToPage(pageIndex + 1) - true + TextButton(onClick = { goToPage(pageIndex + 1) }, enabled = canGoNext) { + Text("Next") } - event.isCtrlPressed && event.key == Key.Equals -> { - scale = zoomSpec.clamp(scale + 0.15f) - true - } - event.isCtrlPressed && event.key == Key.Minus -> { - scale = zoomSpec.clamp(scale - 0.15f) - true - } - else -> false } } - .focusable(), - horizontalArrangement = Arrangement.spacedBy(16.dp) + if (document.pageCount > 1) { + item { + Text( + "Page ${pageIndex + 1} of ${document.pageCount}", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Slider( + value = pageIndex.toFloat(), + onValueChange = { value -> + if (pageScrubStartPage == null) { + pageScrubStartPage = pdfState.pageIndex + } + val targetPage = value.toInt().coerceIn(0, document.pageCount - 1) + pageScrubPreview = targetPage + goToPage(targetPage) + }, + onValueChangeFinished = { + val startPage = pageScrubStartPage + val targetPage = currentPdfPageIndex + if (startPage != null) { + jumpHistory = jumpHistory.record( + currentPageIndex = startPage, + targetPageIndex = targetPage, + pageCount = document.pageCount + ) + } + pageScrubStartPage = null + pageScrubPreview = null + }, + valueRange = 0f..(document.pageCount - 1).toFloat(), + steps = (document.pageCount - 2).coerceAtLeast(0) + ) + } + } + item { + DesktopPdfJumpHistoryControls( + backPage = jumpHistory.backPage, + forwardPage = jumpHistory.forwardPage, + onBack = ::goBackInJumpHistory, + onForward = ::goForwardInJumpHistory, + onClear = { jumpHistory = jumpHistory.clear() } + ) + } + item { + val isBookmarked = bookmarks.any { it.pageIndex == pageIndex } + TextButton(onClick = { toggleBookmark(pageIndex) }) { + Text(if (isBookmarked) "Remove bookmark" else "Bookmark page") + } + } + item { + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + Text("Search", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = searchQuery, + onValueChange = { dispatchPdf(SharedPdfReaderAction.SearchChanged(it)) }, + label = { Text("Find in PDF") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + } + if (searchQuery.isNotBlank()) { + item { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Text( + when { + isSearchIndexing -> { + val progress = "Indexing ${indexedSearchPageCount.coerceAtMost(document.pageCount)}/${document.pageCount}" + if (searchResults.isEmpty()) progress else "${searchResults.size} matches - $progress" + } + searchResults.isEmpty() -> "No matches" + activeSearchIndex in searchResults.indices -> "${activeSearchIndex + 1} of ${searchResults.size}" + else -> "${searchResults.size} matches" + }, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) + ) + TextButton(onClick = { goToSearchResult(activeSearchIndex - 1) }, enabled = searchResults.isNotEmpty()) { + Text("Prev") + } + TextButton(onClick = { goToSearchResult(activeSearchIndex + 1) }, enabled = searchResults.isNotEmpty()) { + Text("Next") + } + } + } + items(searchResults, key = { "nav_search_${it.pageIndex}_${it.matchIndex}_${it.preview}" }) { result -> + Surface( + color = if (result.pageIndex == pageIndex) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(6.dp), + modifier = Modifier.fillMaxWidth().clickable { + goToSearchResult(searchResults.indexOf(result)) + } + ) { + Column(modifier = Modifier.padding(8.dp)) { + Text("Page ${result.pageIndex + 1}", fontWeight = FontWeight.SemiBold) + Text(result.preview, style = MaterialTheme.typography.bodySmall, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + } + if (document.toc.isNotEmpty()) { + item { + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + Text("Contents", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + } + itemsIndexed(document.toc, key = { index, entry -> "nav_toc_${index}_${entry.pageIndex}_${entry.nestLevel}" }) { _, entry -> + Surface( + color = if (entry.pageIndex == pageIndex) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(6.dp), + modifier = Modifier.fillMaxWidth().clickable { goToPage(entry.pageIndex, recordJump = true) } + ) { + Row( + modifier = Modifier + .padding(start = (entry.nestLevel * 12).dp) + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(entry.title, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) + Text("p. ${entry.pageIndex + 1}", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + } + if (bookmarks.isNotEmpty()) { + item { + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + Text("Bookmarks", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + } + items(bookmarks, key = { "nav_bookmark_${it.pageIndex}" }) { bookmark -> + Surface( + color = if (bookmark.pageIndex == pageIndex) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(6.dp), + modifier = Modifier.fillMaxWidth().clickable { goToPage(bookmark.pageIndex, recordJump = true) } + ) { + Text( + bookmark.label.ifBlank { "Page ${bookmark.pageIndex + 1}" }, + modifier = Modifier.padding(8.dp) + ) + } + } + } + if (sortedAnnotations.isNotEmpty() || sortedEmbeddedAnnotations.isNotEmpty()) { + item { + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + Text("Notes", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + } + items(sortedAnnotations, key = { "nav_annotation_${it.id}" }) { annotation -> + Surface( + color = if (annotation.id == selectedAnnotationId) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(6.dp), + modifier = Modifier.fillMaxWidth().clickable { selectAnnotation(annotation) } + ) { + Column(modifier = Modifier.padding(8.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(annotation.desktopLabel(), fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text("Page ${annotation.pageIndex + 1}", color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall) + } + } + } + items(sortedEmbeddedAnnotations, key = { "nav_embedded_${it.id}" }) { annotation -> + Surface( + color = if (annotation.id == selectedEmbeddedAnnotationId) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(6.dp), + modifier = Modifier.fillMaxWidth().clickable { selectEmbeddedAnnotation(annotation) } + ) { + Column(modifier = Modifier.padding(8.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(annotation.author.ifBlank { "PDF comment" }, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text("Page ${annotation.pageIndex + 1}", color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall) + } + } + } + } + } + } + } + + @Composable + fun PdfBottomChrome() { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 2.dp ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + TextButton(onClick = { goToPage(pageIndex - 1) }, enabled = canGoPrevious) { + Text("Previous") + } + Text("Page ${pageIndex + 1} of ${document.pageCount}", color = MaterialTheme.colorScheme.onSurfaceVariant) + if (document.pageCount > 1) { + Slider( + value = pageIndex.toFloat(), + onValueChange = { value -> + if (pageScrubStartPage == null) { + pageScrubStartPage = pdfState.pageIndex + } + val targetPage = value.toInt().coerceIn(0, document.pageCount - 1) + pageScrubPreview = targetPage + goToPage(targetPage) + }, + onValueChangeFinished = { + val startPage = pageScrubStartPage + val targetPage = currentPdfPageIndex + if (startPage != null) { + jumpHistory = jumpHistory.record( + currentPageIndex = startPage, + targetPageIndex = targetPage, + pageCount = document.pageCount + ) + } + pageScrubStartPage = null + pageScrubPreview = null + }, + valueRange = 0f..(document.pageCount - 1).toFloat(), + steps = (document.pageCount - 2).coerceAtLeast(0), + modifier = Modifier.weight(1f) + ) + } else { + Spacer(Modifier.weight(1f)) + } + Text("${progressPercent.toInt()}%", color = MaterialTheme.colorScheme.onSurfaceVariant) + TextButton(onClick = { goToPage(pageIndex + 1) }, enabled = canGoNext) { + Text("Next") + } + } + } + } + + ReaderWorkspaceShell( + model = pdfWorkspaceModel, + title = document.title, + subtitle = "${document.formatLabel} - Page ${pageIndex + 1} of ${document.pageCount}", + progressLabel = "${progressPercent.toInt()}%", + modifier = Modifier + .onPreviewKeyEvent(::handlePdfReaderKeyEvent) + .focusable(), + topActions = { + TextButton(onClick = onOpenBook) { + Text("Open Book") + } + TextButton(onClick = onOpenPdf) { + Text("Open PDF") + } + }, + leftSidebar = { PdfNavigationSidebar() }, + rightInspector = { Surface( modifier = Modifier - .width(300.dp) + .width(340.dp) .fillMaxHeight(), color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(8.dp) @@ -1233,71 +4084,350 @@ private fun PdfReaderScreen( verticalArrangement = Arrangement.spacedBy(8.dp) ) { item { - Text("Pages", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text("Tools", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) } item { Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - TextButton(onClick = { goToPage(pageIndex - 1) }, enabled = pageIndex > 0) { - Text("Prev") + FilterChip( + selected = displayMode == PdfDisplayMode.PAGINATION, + onClick = { + commitActiveTextDraft() + dispatchPdf(SharedPdfReaderAction.DisplayModeChanged(PdfDisplayMode.PAGINATION)) + }, + label = { Text("Page") } + ) + FilterChip( + selected = displayMode == PdfDisplayMode.VERTICAL_SCROLL, + onClick = { + commitActiveTextDraft() + dispatchPdf(SharedPdfReaderAction.DisplayModeChanged(PdfDisplayMode.VERTICAL_SCROLL)) + }, + label = { Text("Scroll") } + ) + } + } + item { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + TextButton(onClick = { goToPage(0) }, enabled = canGoPrevious) { + Text("First") + } + TextButton(onClick = { goToPage(pageIndex - 1) }, enabled = canGoPrevious) { + Text("Prev") + } } - TextButton(onClick = { goToPage(pageIndex + 1) }, enabled = pageIndex < document.pageCount - 1) { - Text("Next") + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + TextButton(onClick = { goToPage(pageIndex + 1) }, enabled = canGoNext) { + Text("Next") + } + TextButton(onClick = { goToPage(document.pageCount - 1) }, enabled = canGoNext) { + Text("Last") + } + } + } + } + item { + DesktopPdfJumpHistoryControls( + backPage = jumpHistory.backPage, + forwardPage = jumpHistory.forwardPage, + onBack = ::goBackInJumpHistory, + onForward = ::goForwardInJumpHistory, + onClear = { jumpHistory = jumpHistory.clear() } + ) + } + if (document.pageCount > 1) { + item { + Text("Page ${pageIndex + 1} of ${document.pageCount}", color = MaterialTheme.colorScheme.onSurfaceVariant) + Slider( + value = pageIndex.toFloat(), + onValueChange = { value -> + if (pageScrubStartPage == null) { + pageScrubStartPage = pdfState.pageIndex + } + val targetPage = value.toInt().coerceIn(0, document.pageCount - 1) + pageScrubPreview = targetPage + goToPage(targetPage) + }, + onValueChangeFinished = { + val startPage = pageScrubStartPage + val targetPage = currentPdfPageIndex + if (startPage != null) { + jumpHistory = jumpHistory.record( + currentPageIndex = startPage, + targetPageIndex = targetPage, + pageCount = document.pageCount + ) + } + pageScrubStartPage = null + pageScrubPreview = null + }, + valueRange = 0f..(document.pageCount - 1).toFloat(), + steps = (document.pageCount - 2).coerceAtLeast(0) + ) + } + } + item { + val isBookmarked = bookmarks.any { it.pageIndex == pageIndex } + TextButton(onClick = { toggleBookmark(pageIndex) }) { + Text(if (isBookmarked) "Remove bookmark" else "Bookmark page") + } + } + item { + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + SharedReaderThemeControls( + settings = pdfReaderSettings, + builtInThemes = BuiltInPdfReaderThemes, + customTextureIds = customTextureIds, + onImportTexture = onImportTexture, + onSettingsChange = ::updatePdfReaderSettings + ) + } + if (bookmarks.isNotEmpty()) { + item { + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + Text("Bookmarks", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + } + items(bookmarks, key = { "bookmark_${it.pageIndex}" }) { bookmark -> + Surface( + color = if (bookmark.pageIndex == pageIndex) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(6.dp), + modifier = Modifier.fillMaxWidth().clickable { goToPage(bookmark.pageIndex, recordJump = true) } + ) { + Row( + modifier = Modifier.padding(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + bookmark.label.ifBlank { "Page ${bookmark.pageIndex + 1}" }, + modifier = Modifier.weight(1f) + ) + TextButton(onClick = { toggleBookmark(bookmark.pageIndex) }) { + Text("Remove") + } + } + } + } + } + if (document.toc.isNotEmpty()) { + item { + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + Text("Contents", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + } + itemsIndexed(document.toc, key = { index, entry -> "toc_${index}_${entry.pageIndex}_${entry.nestLevel}" }) { _, entry -> + Surface( + color = if (entry.pageIndex == pageIndex) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(6.dp), + modifier = Modifier.fillMaxWidth().clickable { goToPage(entry.pageIndex, recordJump = true) } + ) { + Row( + modifier = Modifier + .padding(start = (entry.nestLevel * 12).dp) + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + entry.title, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + Text("p. ${entry.pageIndex + 1}", color = MaterialTheme.colorScheme.onSurfaceVariant) + } } } } item { Text("Zoom", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = { scale = zoomSpec.clamp(scale - 0.15f) }) { + IconButton(onClick = { dispatchPdf(SharedPdfReaderAction.ZoomBy(-0.15f)) }) { Icon(Icons.Default.ZoomOut, contentDescription = "Zoom out") } Text("${(scale * 100).toInt()}%", modifier = Modifier.weight(1f), textAlign = TextAlign.Center) - IconButton(onClick = { scale = zoomSpec.clamp(scale + 0.15f) }) { + IconButton(onClick = { dispatchPdf(SharedPdfReaderAction.ZoomBy(0.15f)) }) { Icon(Icons.Default.ZoomIn, contentDescription = "Zoom in") } } Slider( value = scale, - onValueChange = { scale = zoomSpec.clamp(it) }, + onValueChange = { dispatchPdf(SharedPdfReaderAction.ZoomChanged(it)) }, valueRange = zoomSpec.min..zoomSpec.max ) } item { HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) Text("Annotations", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - PdfAnnotationToolDock( + FilterChip( + selected = isTextSelectionMode, + onClick = { + val enabled = !isTextSelectionMode + if (enabled) { + deactivateRichTextMode() + } + if (enabled) { + commitActiveTextDraft() + } + dispatchPdf(SharedPdfReaderAction.TextSelectionModeChanged(enabled)) + if (!enabled) { + clearPdfInteractionState() + } + }, + label = { Text("Select text") } + ) + FilterChip( + selected = isRichTextMode, + onClick = { + if (isRichTextMode) { + deactivateRichTextMode() + } else { + activateRichTextMode() + } + }, + label = { Text("Document text") } + ) + SharedPdfAnnotationToolDock( selectedTool = selectedTool, selectedColor = selectedColor, strokeWidth = strokeWidth, - onToolSelected = ::applyTool, - onColorSelected = { selectedColor = it }, - onStrokeWidthChange = { strokeWidth = it }, + tools = DesktopPdfAnnotationTools, + onToolSelected = ::selectPdfAnnotationTool, + onColorSelected = { dispatchPdf(SharedPdfReaderAction.ColorSelected(it)) }, + onStrokeWidthChange = { dispatchPdf(SharedPdfReaderAction.StrokeWidthChanged(it)) }, onUndo = { - annotations.indexOfLast { it.pageIndex == pageIndex }.takeIf { it >= 0 }?.let { - annotations.removeAt(it) - } + dispatchPdf(SharedPdfReaderAction.UndoLastAnnotationOnPage(pageIndex)) }, onClearPage = { - annotations.removeAll { it.pageIndex == pageIndex } - } + dispatchPdf(SharedPdfReaderAction.ClearPageAnnotations(pageIndex)) + }, + isHighlighterSnapEnabled = isHighlighterSnapEnabled, + onHighlighterSnapChange = { isHighlighterSnapEnabled = it } ) } - if (selectedTool == PdfInkTool.TEXT) { + selectedAnnotation?.let { annotation -> item { - OutlinedTextField( - value = textDraft, - onValueChange = { textDraft = it }, - label = { Text("Text note") }, - minLines = 2, - modifier = Modifier.fillMaxWidth() - ) - Text( - "Click the page to place the note.", - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall + DesktopPdfAnnotationEditor( + annotation = annotation, + onUpdate = ::updateAnnotation, + onDelete = { deleteAnnotation(annotation.id) }, + onClose = { dispatchPdf(SharedPdfReaderAction.AnnotationSelected(null)) } ) } } + if (sortedAnnotations.isNotEmpty()) { + item { + Text("Annotation list", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + } + items(sortedAnnotations, key = { "annotation_${it.id}" }) { annotation -> + Surface( + color = if (annotation.id == selectedAnnotationId) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(6.dp), + modifier = Modifier.fillMaxWidth().clickable { selectAnnotation(annotation) } + ) { + Column(modifier = Modifier.padding(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + annotation.desktopLabel(), + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f) + ) + TextButton(onClick = { deleteAnnotation(annotation.id) }) { + Text("Delete") + } + } + Text( + "Page ${annotation.pageIndex + 1}${annotation.text.takeIf { it.isNotBlank() }?.let { " - ${it.logPreview(48)}" }.orEmpty()}", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + selectedEmbeddedAnnotation?.let { annotation -> + item { + DesktopPdfEmbeddedAnnotationPanel( + annotation = annotation, + onCopy = { clipboardManager.setText(AnnotatedString(annotation.threadText())) }, + onClose = { selectedEmbeddedAnnotationId = null } + ) + } + } + if (sortedEmbeddedAnnotations.isNotEmpty()) { + item { + Text("PDF comments", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + } + items(sortedEmbeddedAnnotations, key = { "embedded_${it.id}" }) { annotation -> + Surface( + color = if (annotation.id == selectedEmbeddedAnnotationId) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(6.dp), + modifier = Modifier.fillMaxWidth().clickable { selectEmbeddedAnnotation(annotation) } + ) { + Column(modifier = Modifier.padding(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + annotation.author.ifBlank { "PDF comment" }, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f) + ) + Text("p. ${annotation.pageIndex + 1}", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Text( + annotation.contents.ifBlank { annotation.replies.firstOrNull()?.contents.orEmpty() }.logPreview(80), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + if (annotation.replies.isNotEmpty()) { + Text( + "${annotation.replies.size} replies", + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelSmall + ) + } + } + } + } + } + if (isRichTextMode || selectedTool == PdfInkTool.TEXT) { + item { + SharedPdfTextAnnotationDock( + style = if (isRichTextMode) { + richTextController.currentSharedPdfTextStyleConfig() + } else { + effectiveTextStyleConfig + }, + onStyleChange = { style -> + if (isRichTextMode) { + richTextController.updateCurrentSharedPdfTextStyle(style) + } else { + updateTextStyleConfig(style) + } + } + ) + } + } + item { + DesktopPdfExtrasPanel( + pageText = currentPdfPageText(), + recapText = pdfTextBeforeCurrentPage(), + extrasState = pdfExtrasState, + aiByokSettings = aiByokSettings, + onExternalLookup = ::openPdfExternalLookup, + onAiAction = ::runPdfAiAction, + onCloudTtsStart = ::startPdfCloudTts, + onCloudTtsPauseResume = ::pauseResumePdfCloudTts, + onCloudTtsStop = ::stopPdfCloudTts, + onCloudTtsClearCache = ::clearPdfCloudTtsCache, + onAutoScrollChange = ::updatePdfAutoScroll, + ttsReplacementPreferences = state.readerTtsReplacementPreferences, + ttsReplacementBookId = document.path, + onTtsReplacementPreferencesChange = { preferences -> + updateState(state.reduce(AppAction.ReaderTtsReplacementPreferencesChanged(preferences))) + } + ) + } item { HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) Text("Search", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) @@ -1305,8 +4435,7 @@ private fun PdfReaderScreen( OutlinedTextField( value = searchQuery, onValueChange = { - searchQuery = it - activeSearchIndex = -1 + dispatchPdf(SharedPdfReaderAction.SearchChanged(it)) }, label = { Text("Find in PDF") }, singleLine = true, @@ -1317,7 +4446,15 @@ private fun PdfReaderScreen( item { Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { Text( - if (searchResults.isEmpty()) "No matches" else "${(activeSearchIndex + 1).coerceAtLeast(0)} of ${searchResults.size}", + when { + isSearchIndexing -> { + val progress = "Indexing ${indexedSearchPageCount.coerceAtMost(document.pageCount)}/${document.pageCount}" + if (searchResults.isEmpty()) progress else "${searchResults.size} matches - $progress" + } + searchResults.isEmpty() -> "No matches" + activeSearchIndex in searchResults.indices -> "${activeSearchIndex + 1} of ${searchResults.size}" + else -> "${searchResults.size} matches" + }, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f) ) @@ -1328,15 +4465,34 @@ private fun PdfReaderScreen( Text("Next") } } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Text( + "Highlights", + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) + ) + TextButton( + onClick = { + dispatchPdf(SharedPdfReaderAction.SearchHighlightModeToggled) + }, + enabled = searchResults.isNotEmpty() + ) { + Text( + when (searchHighlightMode) { + SearchHighlightMode.ALL -> "All" + SearchHighlightMode.FOCUSED -> "Focused" + } + ) + } + } } } - items(searchResults, key = { "${it.pageIndex}_${it.preview}" }) { result -> + items(searchResults, key = { "${it.pageIndex}_${it.matchIndex}_${it.preview}" }) { result -> Surface( color = if (result.pageIndex == pageIndex) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface, shape = RoundedCornerShape(6.dp), modifier = Modifier.fillMaxWidth().clickable { - activeSearchIndex = searchResults.indexOf(result) - goToPage(result.pageIndex) + goToSearchResult(searchResults.indexOf(result)) } ) { Column(modifier = Modifier.padding(8.dp)) { @@ -1347,95 +4503,955 @@ private fun PdfReaderScreen( } } } - - Box( - modifier = Modifier - .weight(1f) - .fillMaxHeight() - .background(Color(0xFFE8E5DC), RoundedCornerShape(8.dp)) - .verticalScroll(rememberScrollState()) - .padding(24.dp), - contentAlignment = Alignment.TopCenter - ) { + }, + bottomBar = { PdfBottomChrome() } + ) { + SharedPdfRichTextHiddenInput( + controller = richTextController, + enabled = isRichTextMode, + modifier = Modifier + .align(Alignment.BottomStart) + .padding(start = 16.dp, bottom = 24.dp) + .zIndex(10f) + ) + if (displayMode == PdfDisplayMode.VERTICAL_SCROLL) { + Box( + modifier = Modifier + .fillMaxSize() + .background(pdfThemeStyle.viewerBackgroundColor, RoundedCornerShape(8.dp)) + ) { + LazyColumn( + state = verticalListState, + modifier = Modifier + .fillMaxSize() + .horizontalScroll(pageHorizontalScrollState) + .padding(horizontal = 24.dp, vertical = 18.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + items((0 until document.pageCount).toList(), key = { it }) { verticalPageIndex -> + DesktopVerticalPdfPage( + document = document, + pageIndex = verticalPageIndex, + scale = scale, + zoomSpec = zoomSpec, + annotations = annotations, + searchResults = searchResults, + activeSearchIndex = activeSearchIndex, + searchHighlightMode = searchHighlightMode, + activeTtsChunk = activePdfTtsChunk, + searchQuery = searchQuery, + isTextSelectionMode = isTextSelectionMode, + selectedAnnotationId = selectedAnnotationId, + selectedEmbeddedAnnotationId = selectedEmbeddedAnnotationId, + selectedTool = selectedTool, + selectedColor = selectedColor, + strokeWidth = strokeWidth, + isHighlighterSnapEnabled = isHighlighterSnapEnabled, + activeTextDraft = activeTextDraft, + richTextController = richTextController, + isRichTextMode = isRichTextMode, + readerAiFeaturesAvailable = aiByokSettings.sanitized().areReaderAiFeaturesAvailable, + cloudTtsAvailable = aiByokSettings.sanitized().isCloudTtsAvailable, + themeStyle = pdfThemeStyle, + shouldRender = verticalPageIndex in verticalRenderWindow, + onSelectPage = { + goToPage( + target = it, + scrollVertical = false, + saveRichTextBeforePageChange = !isRichTextMode + ) + }, + onCopySelection = ::copySelection, + onHighlightSelection = ::highlightSelection, + onSearchSelection = ::searchSelection, + onWebSearchSelection = { openPdfExternalLookup(ReaderExternalLookupAction.SEARCH, it.text) }, + onDictionarySelection = { openPdfExternalLookup(ReaderExternalLookupAction.DICTIONARY, it.text) }, + onDefineSelection = { runPdfAiAction(ReaderAiFeature.DEFINE, it.text) }, + onSpeakSelection = { togglePdfCloudTts(it.text) }, + onTranslateSelection = ::translateSelection, + onEmbeddedAnnotationSelected = ::selectEmbeddedAnnotation, + onLinkActivated = ::activatePdfLink, + onAnnotationAdded = { dispatchPdf(SharedPdfReaderAction.AnnotationAdded(it)) }, + onAnnotationUpdated = ::updateAnnotation, + onAnnotationsChanged = { dispatchPdf(SharedPdfReaderAction.AnnotationsChanged(it)) }, + onTextAnnotationSelected = ::selectTextAnnotation, + onTextDraftStarted = ::startActiveTextDraft, + onTextDraftChanged = ::updateActiveTextDraft, + onTextDraftBoundsChanged = ::updateActiveTextDraftBounds + ) + } + } + DesktopPdfPageScrubOverlay( + pageIndex = pageScrubPreview, + pageCount = document.pageCount + ) + } + } else { + Box( + modifier = Modifier + .fillMaxSize() + .background(pdfThemeStyle.viewerBackgroundColor, RoundedCornerShape(8.dp)) + .horizontalScroll(pageHorizontalScrollState) + .verticalScroll(pageVerticalScrollState) + .padding(24.dp), + contentAlignment = Alignment.TopCenter + ) { when { isRendering -> CircularProgressIndicator(modifier = Modifier.padding(48.dp)) renderError != null -> Text(renderError ?: "Failed to render page.", color = MaterialTheme.colorScheme.error) renderedPage != null -> { val pageRender = renderedPage!! + val pageWidthDp = with(density) { pageRender.width.toDp() } + val pageHeightDp = with(density) { pageRender.height.toDp() } + val pageRenderScale = pageRender.width / document.pageSizes[pageIndex].width + val pageAnnotations = remember(annotations, pageIndex, pageCanvasSize) { + annotations + .filter { it.pageIndex == pageIndex } + .flatMap { annotation -> + annotation.toRenderablePdfAnnotations(document, pageIndex, pageCanvasSize) + } + } + val selectedTextAnnotationForPage = selectedAnnotation?.takeIf { + selectedTool == PdfInkTool.TEXT && + !isTextSelectionMode && + it.kind == PdfAnnotationKind.TEXT && + it.pageIndex == pageIndex + } + val visiblePageAnnotations = remember(pageAnnotations, selectedTextAnnotationForPage?.id) { + pageAnnotations.filterNot { + it.kind == PdfAnnotationKind.TEXT && it.id == selectedTextAnnotationForPage?.id + } + } + val pageEmbeddedAnnotations = remember(document.embeddedAnnotations, pageIndex) { + document.embeddedAnnotations.filter { it.pageIndex == pageIndex } + } + val searchHighlightBounds: List = remember( + document.path, + searchResults, + pageIndex, + activeSearchIndex, + searchHighlightMode, + pageCanvasSize, + searchQuery + ) { + val queryLength = searchQuery.trim().length + if (queryLength <= 0 || pageCanvasSize.width <= 0 || pageCanvasSize.height <= 0) { + emptyList() + } else { + SharedPdfSearchEngine.highlightsForPage( + results = searchResults, + pageIndex = pageIndex, + activeResultIndex = activeSearchIndex, + mode = searchHighlightMode + ).flatMap { result -> + val matchLength = result.matchLength.takeIf { it > 0 } ?: queryLength + DesktopPdfium.textRectsForRange( + document = document, + pageIndex = pageIndex, + startIndex = result.matchIndex, + endIndex = result.matchIndex + matchLength - 1, + viewportWidth = pageCanvasSize.width, + viewportHeight = pageCanvasSize.height + ).map { it.toPdfPageBounds() } + .filter { it.right > it.left && it.bottom > it.top } + .mergePdfBoundsByLine() + } + } + } + val ttsHighlightBounds: List = remember( + document.path, + activePdfTtsChunk, + pageIndex, + pageCanvasSize + ) { + val chunk = activePdfTtsChunk?.takeIf { it.pageIndex == pageIndex } + if (chunk == null || pageCanvasSize.width <= 0 || pageCanvasSize.height <= 0 || chunk.endOffset <= chunk.startOffset) { + emptyList() + } else { + DesktopPdfium.textRectsForRange( + document = document, + pageIndex = pageIndex, + startIndex = chunk.startOffset, + endIndex = chunk.endOffset - 1, + viewportWidth = pageCanvasSize.width, + viewportHeight = pageCanvasSize.height + ).map { it.toPdfPageBounds() } + .filter { it.right > it.left && it.bottom > it.top } + .mergePdfBoundsByLine() + } + } Box( modifier = Modifier - .size(pageRender.width.dp, pageRender.height.dp) - .onSizeChanged { pageCanvasSize = it } - .pointerInput(pageIndex, selectedTool, selectedColor, strokeWidth, textDraft) { - if (selectedTool == PdfInkTool.TEXT) { + .size(pageWidthDp, pageHeightDp) + .onSizeChanged { size -> + if (pageCanvasSize != size) { + logPdfSelection( + "layout page=${pageIndex + 1} " + + "canvas=${size.formatLogSize()} bitmap=${pageRender.width}x${pageRender.height} " + + "requestedScale=${scale.formatLogFloat()} renderScale=${pageRenderScale.formatLogFloat()}" + ) + } + pageCanvasSize = size + } + .pointerInput(pageIndex, pageCanvasSize, isTextSelectionMode, selectedTool, isRichTextMode) { + if (isRichTextMode) return@pointerInput + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent() + val point = event.changes.firstOrNull()?.position ?: continue + if (event.type == PointerEventType.Press && event.buttons.isPrimaryPressed) { + if (selectedTool != PdfInkTool.TEXT) { + val linkTarget = document.linkAt(pageIndex, point, pageCanvasSize) + if (linkTarget != null) { + logPdfLink( + "tap_hit mode=page page=${pageIndex + 1} " + + "x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()} " + + "textSelection=$isTextSelectionMode target=${linkTarget.formatLogTarget()}" + ) + activatePdfLink(linkTarget) + event.changes.forEach { it.consume() } + continue + } + } + val embeddedHit = pageEmbeddedAnnotations.findLast { + it.sharedPdfEmbeddedHitTest(point, pageCanvasSize) + } + if (embeddedHit != null) { + selectEmbeddedAnnotation(embeddedHit) + clearPdfInteractionState() + event.changes.forEach { it.consume() } + } else if ( + currentTextSelection != null && + selectionMenuOffset == null + ) { + selectionMenuOffset = null + textSelection = null + selectionStartHit = null + selectionEndHit = null + } + } else if (event.type == PointerEventType.Press && event.buttons.isSecondaryPressed) { + val selection = currentTextSelection + if (selection != null) { + selectionMenuOffset = point + logPdfSelection( + "menu_open page=${pageIndex + 1} " + + "x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()} " + + "range=${selection.startIndex}..${selection.endIndex} " + + "chars=${selection.text.length}" + ) + event.changes.forEach { it.consume() } + } + } + } + } + } + .pointerInput( + pageIndex, + isTextSelectionMode, + selectedTool, + selectedColor, + strokeWidth, + isHighlighterSnapEnabled, + textStyleConfig, + activeTextDraft?.id, + isRichTextMode, + pageCanvasSize, + pageRender.width, + pageRender.height + ) { + if (isRichTextMode) return@pointerInput + if (isTextSelectionMode) { + detectDragGestures( + onDragStart = { start -> + selectionMenuOffset = null + val hit = document.charHitAt(pageIndex, start, pageCanvasSize) + selectionStartHit = hit + selectionStartIndex = hit?.index + selectionEndHit = null + selectionEndIndex = null + logPdfSelection( + "drag_start page=${pageIndex + 1} " + + "canvas=${pageCanvasSize.formatLogSize()} bitmap=${pageRender.width}x${pageRender.height} " + + "requestedScale=${scale.formatLogFloat()} renderScale=${pageRenderScale.formatLogFloat()} " + + hit.formatLogHit("start") + ) + textSelection = null + }, + onDrag = { change, _ -> + val startIndex = selectionStartIndex + val hit = document.charHitAt(pageIndex, change.position, pageCanvasSize) + selectionEndHit = hit + val endIndex = hit?.index + val previousEndIndex = selectionEndIndex + selectionEndIndex = endIndex + if (endIndex != previousEndIndex || textSelection == null) { + textSelection = if (startIndex != null && endIndex != null) { + document.selectionBetweenIndexes( + pageIndex = pageIndex, + startIndex = startIndex, + endIndex = endIndex, + canvasSize = pageCanvasSize, + useNativeBounds = false + ) + } else { + null + } + } + }, + onDragEnd = { + val startIndex = selectionStartIndex + val endIndex = selectionEndIndex + val selection = if (startIndex != null && endIndex != null) { + document.selectionBetweenIndexes( + pageIndex = pageIndex, + startIndex = startIndex, + endIndex = endIndex, + canvasSize = pageCanvasSize, + useNativeBounds = true + )?.also { textSelection = it } + } else { + textSelection + } + logPdfSelection( + "drag_end page=${pageIndex + 1} " + + "canvas=${pageCanvasSize.formatLogSize()} bitmap=${pageRender.width}x${pageRender.height} " + + "requestedScale=${scale.formatLogFloat()} renderScale=${pageRenderScale.formatLogFloat()} " + + selectionStartHit.formatLogHit("start") + " " + + selectionEndHit.formatLogHit("end") + " " + + "range=${selection?.startIndex}..${selection?.endIndex} " + + "chars=${selection?.text?.length ?: 0} " + + "lines=${selection?.lineBounds?.size ?: 0} " + + "text=\"${selection?.text.orEmpty().logPreview()}\"" + ) + selectionStartIndex = null + selectionEndIndex = null + selectionStartHit = null + selectionEndHit = null + }, + onDragCancel = { + logPdfSelection( + "drag_cancel page=${pageIndex + 1} " + + "canvas=${pageCanvasSize.formatLogSize()} bitmap=${pageRender.width}x${pageRender.height} " + + "requestedScale=${scale.formatLogFloat()} renderScale=${pageRenderScale.formatLogFloat()} " + + selectionStartHit.formatLogHit("start") + " " + + selectionEndHit.formatLogHit("end") + ) + selectionStartIndex = null + selectionEndIndex = null + selectionStartHit = null + selectionEndHit = null + } + ) + } else if (selectedTool == PdfInkTool.TEXT) { detectTapGestures( onTap = { start -> - val text = textDraft.trim() - if (text.isNotEmpty()) { - val bounds = pageBoundsFromPoint(start, pageCanvasSize) - annotations.add( - SharedPdfAnnotation( - id = "text_${System.currentTimeMillis()}", + when { + activeTextDraftContains(pageIndex, start, pageCanvasSize) -> Unit + else -> { + val textHit = currentPdfAnnotations.textAnnotationHitAt( pageIndex = pageIndex, - kind = PdfAnnotationKind.TEXT, - tool = PdfInkTool.TEXT, - bounds = bounds, - text = text, - colorArgb = selectedColor, - fontSize = 18f, - createdAt = System.currentTimeMillis() + point = start, + canvasSize = pageCanvasSize ) - ) - textDraft = "" + if (textHit != null) { + selectTextAnnotation(textHit) + } else { + startActiveTextDraft(pageIndex, start, pageCanvasSize) + } + } } } ) } else { + var eraserPreviousPoint: Offset? = null detectDragGestures( onDragStart = { start -> - if (selectedTool != PdfInkTool.ERASER) { - activeStroke = listOf(start.toPdfPoint(pageCanvasSize)) - } + if (selectedTool == PdfInkTool.ERASER) { + val annotationSnapshot = currentPdfAnnotations + val updatedAnnotations = annotationSnapshot.filterNot { + it.pageIndex == pageIndex && it.sharedPdfHitTest( + point = start, + size = pageCanvasSize, + eraserStrokeWidth = strokeWidth + ) + } + if (updatedAnnotations.size != annotationSnapshot.size) { + dispatchPdf(SharedPdfReaderAction.AnnotationsChanged(updatedAnnotations)) + } + eraserPreviousPoint = start + } else { + activeStroke = listOf(start.toSharedPdfPoint(pageCanvasSize, System.currentTimeMillis())) + } }, onDrag = { change, _ -> if (selectedTool == PdfInkTool.ERASER) { val point = change.position - annotations.removeAll { it.pageIndex == pageIndex && it.hitTest(point, pageCanvasSize) } + val previousPoint = eraserPreviousPoint + val annotationSnapshot = currentPdfAnnotations + val updatedAnnotations = annotationSnapshot.filterNot { + it.pageIndex == pageIndex && it.sharedPdfHitTest( + point = point, + size = pageCanvasSize, + lastPoint = previousPoint, + eraserStrokeWidth = strokeWidth + ) + } + if (updatedAnnotations.size != annotationSnapshot.size) { + dispatchPdf(SharedPdfReaderAction.AnnotationsChanged(updatedAnnotations)) + } + eraserPreviousPoint = point } else { - activeStroke = activeStroke + change.position.toPdfPoint(pageCanvasSize) + activeStroke = activeStroke.withDesktopPdfDragPoint( + point = change.position, + canvasSize = pageCanvasSize, + tool = selectedTool, + snapHighlighter = isHighlighterSnapEnabled, + timestamp = System.currentTimeMillis() + ) } }, onDragEnd = { + eraserPreviousPoint = null if (activeStroke.size > 1) { - annotations.add( - SharedPdfAnnotation( - id = "ink_${System.currentTimeMillis()}", - pageIndex = pageIndex, - kind = PdfAnnotationKind.INK, - tool = selectedTool, - points = activeStroke, - colorArgb = selectedColor, - strokeWidth = strokeWidth, - createdAt = System.currentTimeMillis() + dispatchPdf( + SharedPdfReaderAction.AnnotationAdded( + SharedPdfAnnotation( + id = "ink_${System.currentTimeMillis()}", + pageIndex = pageIndex, + kind = PdfAnnotationKind.INK, + tool = selectedTool, + points = activeStroke, + colorArgb = selectedColor, + strokeWidth = strokeWidth, + createdAt = System.currentTimeMillis() + ) ) ) } activeStroke = emptyList() }, - onDragCancel = { activeStroke = emptyList() } + onDragCancel = { + eraserPreviousPoint = null + activeStroke = emptyList() + } ) } } ) { - Image( + DesktopPdfThemedPageImage( bitmap = pageRender.image, - contentDescription = "PDF page ${pageIndex + 1}" + contentDescription = "PDF page ${pageIndex + 1}", + themeStyle = pdfThemeStyle, + modifier = Modifier.fillMaxSize() ) - PdfAnnotationOverlay( - annotations = annotations.filter { it.pageIndex == pageIndex }, - activeStroke = activeStroke, + SharedPdfRichTextLayer( + pageIndex = pageIndex, + controller = richTextController, + pageWidth = pageCanvasSize.width.toFloat(), + pageHeight = pageCanvasSize.height.toFloat(), + isTextEditingEnabled = isRichTextMode, + onPageTapped = {} + ) + PdfSearchHighlightOverlay( + bounds = searchHighlightBounds, + canvasSize = pageCanvasSize, + color = when (searchHighlightMode) { + SearchHighlightMode.ALL -> Color(0x55FDD835) + SearchHighlightMode.FOCUSED -> Color(0x88FF9800) + } + ) + PdfSearchHighlightOverlay( + bounds = ttsHighlightBounds, + canvasSize = pageCanvasSize, + color = Color(0x887DD3FC) + ) + PdfTextSelectionOverlay( + selection = textSelection, canvasSize = pageCanvasSize ) + SharedPdfAnnotationOverlay( + annotations = visiblePageAnnotations, + activeStroke = activeStroke, + canvasSize = pageCanvasSize, + activeTool = selectedTool, + activeStrokeColorArgb = selectedColor, + activeStrokeWidth = strokeWidth, + selectedAnnotationId = selectedAnnotationId + ) + SharedPdfInlineTextEditorOverlay( + draft = activeTextDraft?.takeIf { it.pageIndex == pageIndex }, + canvasSize = pageCanvasSize, + onTextChange = { updateActiveTextDraft(it, pageCanvasSize) }, + onBoundsChange = ::updateActiveTextDraftBounds + ) + selectedTextAnnotationForPage?.let { annotation -> + val bounds = annotation.bounds + if (bounds != null && activeTextDraft == null) { + SharedPdfTextBoxEditorOverlay( + id = annotation.id, + text = annotation.text, + style = annotation.sharedPdfTextStyle(), + bounds = bounds, + canvasSize = pageCanvasSize, + onTextChange = { text -> + updateAnnotation(annotation.copy(text = text)) + }, + onBoundsChange = { nextBounds -> + updateAnnotation(annotation.copy(bounds = nextBounds)) + } + ) + } + } + SharedPdfEmbeddedAnnotationOverlay( + annotations = pageEmbeddedAnnotations, + canvasSize = pageCanvasSize, + selectedAnnotationId = selectedEmbeddedAnnotationId + ) + SharedPdfPageNumberOverlay( + pageIndex = pageIndex, + pageCount = document.pageCount + ) + if (textSelection != null && selectionMenuOffset != null) { + Box( + modifier = Modifier + .matchParentSize() + .pointerInput(pageIndex, selectionMenuOffset) { + detectTapGestures { + selectionMenuOffset = null + textSelection = null + selectionStartHit = null + selectionEndHit = null + } + } + ) + } + PdfSelectionMenu( + selection = textSelection, + menuOffset = selectionMenuOffset, + canvasSize = pageCanvasSize, + onCopy = { + textSelection?.let(::copySelection) + clearSelection() + }, + onHighlight = ::highlightCurrentSelection, + onSearch = { + textSelection?.let(::searchSelection) + selectionMenuOffset = null + }, + onWebSearch = { + textSelection?.let { openPdfExternalLookup(ReaderExternalLookupAction.SEARCH, it.text) } + selectionMenuOffset = null + }, + onDictionary = { + textSelection?.let { openPdfExternalLookup(ReaderExternalLookupAction.DICTIONARY, it.text) } + selectionMenuOffset = null + }, + onDefine = { + textSelection?.let { runPdfAiAction(ReaderAiFeature.DEFINE, it.text) } + selectionMenuOffset = null + }, + onSpeak = { + textSelection?.let { togglePdfCloudTts(it.text) } + selectionMenuOffset = null + }, + onTranslate = { + textSelection?.let(::translateSelection) + selectionMenuOffset = null + }, + showDefine = aiByokSettings.sanitized().areReaderAiFeaturesAvailable, + showSpeak = aiByokSettings.sanitized().isCloudTtsAvailable, + onClear = ::clearSelection + ) + } + } + } + DesktopPdfPageScrubOverlay( + pageIndex = pageScrubPreview, + pageCount = document.pageCount + ) + } + } + } +} + +@Composable +private fun DesktopAiByokSettingsDialog( + settings: ReaderAiByokSettings, + secureStorageAvailable: Boolean, + onSettingsChange: (ReaderAiByokSettings) -> Unit, + onDismiss: () -> Unit +) { + val sanitized = settings.sanitized() + var selectedProvider by remember { mutableStateOf("gemini") } + var pendingKey by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("AI keys and models") }, + text = { + Column( + modifier = Modifier + .heightIn(max = 640.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + if (!secureStorageAvailable) { + Text( + "Secure key storage is unavailable on this operating system. Keys entered here will be used for this session but will not be persisted.", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) + } + + Text("Saved keys", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + DesktopSavedAiKeyRow( + label = "Gemini", + keyValue = sanitized.geminiKey, + onClear = { onSettingsChange(sanitized.copy(geminiKey = "", ttsModel = "")) } + ) + DesktopSavedAiKeyRow( + label = "Groq", + keyValue = sanitized.groqKey, + onClear = { onSettingsChange(sanitized.copy(groqKey = "")) } + ) + + HorizontalDivider() + + Text("Add or replace key", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) { + listOf("gemini" to "Gemini", "groq" to "Groq").forEach { (provider, label) -> + FilterChip( + selected = selectedProvider == provider, + onClick = { selectedProvider = provider }, + label = { Text(label) } + ) + } + } + OutlinedTextField( + value = pendingKey, + onValueChange = { pendingKey = it }, + label = { Text("API key") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth() + ) + TextButton( + enabled = pendingKey.isNotBlank(), + onClick = { + val trimmed = pendingKey.trim() + val next = when (selectedProvider) { + "gemini" -> sanitized.copy( + geminiKey = trimmed, + ttsModel = sanitized.ttsModel.ifBlank { GEMINI_CLOUD_TTS_MODEL_ID } + ) + "groq" -> sanitized.copy(groqKey = trimmed) + else -> sanitized + } + onSettingsChange(next) + pendingKey = "" + }, + modifier = Modifier.align(Alignment.End) + ) { + Text("Save key") + } + + HorizontalDivider() + + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text("Show AI in reader", style = MaterialTheme.typography.titleMedium) + Text( + "Matches the Android hide toggle for smart dictionary, summaries, and recaps.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = !sanitized.hideReaderAiFeatures, + onCheckedChange = { enabled -> + onSettingsChange(sanitized.copy(hideReaderAiFeatures = !enabled)) + } + ) + } + + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text("Use one model for all features", style = MaterialTheme.typography.titleMedium) + Text( + "Turn this off to choose separate models per reader AI feature.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = sanitized.useOneModel, + onCheckedChange = { onSettingsChange(sanitized.copy(useOneModel = it)) } + ) + } + + if (sanitized.useOneModel) { + DesktopAiModelSelector( + title = "All AI features", + description = "Smart dictionary, summaries, and recaps all use this model.", + selectedId = sanitized.modelForAll, + onSelected = { onSettingsChange(sanitized.copy(modelForAll = it)) } + ) + } else { + DesktopAiModelSelector( + title = "Smart dictionary", + description = "Used when defining selected words or phrases.", + selectedId = sanitized.defineModel, + onSelected = { onSettingsChange(sanitized.copy(defineModel = it)) } + ) + DesktopAiModelSelector( + title = "Summaries", + description = "Used for EPUB summaries and PDF page summaries.", + selectedId = sanitized.summarizeModel, + onSelected = { onSettingsChange(sanitized.copy(summarizeModel = it)) } + ) + DesktopAiModelSelector( + title = "Recaps", + description = "Used for story recap generation.", + selectedId = sanitized.recapModel, + onSelected = { onSettingsChange(sanitized.copy(recapModel = it)) } + ) + } + + DesktopAiModelSelector( + title = "Cloud TTS", + description = "Uses the saved Gemini key. Only $GEMINI_CLOUD_TTS_MODEL is supported for now.", + selectedId = sanitized.ttsModel, + options = listOf(ReaderAiModelOption("gemini", GEMINI_CLOUD_TTS_MODEL)), + onSelected = { onSettingsChange(sanitized.copy(ttsModel = it)) } + ) + Text("Cloud TTS voice", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + ReaderCloudTtsVoices.chunked(3).forEach { rowVoices -> + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) { + rowVoices.forEach { voice -> + FilterChip( + selected = sanitized.ttsSpeakerId == voice.id, + onClick = { onSettingsChange(sanitized.copy(ttsSpeakerId = voice.id)) }, + label = { + Column { + Text(voice.name, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + voice.description, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + ) + } + } + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text("Done") + } + } + ) +} + +@Composable +private fun DesktopSavedAiKeyRow( + label: String, + keyValue: String, + onClear: () -> Unit +) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text(label, fontWeight = FontWeight.SemiBold) + Text( + keyValue.takeIf { it.isNotBlank() }?.let(::maskedReaderAiKey) ?: "No key saved", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + TextButton(enabled = keyValue.isNotBlank(), onClick = onClear) { + Text("Clear") + } + } +} + +@Composable +private fun DesktopAiModelSelector( + title: String, + description: String, + selectedId: String, + options: List = ReaderAiModelOptions, + onSelected: (String) -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) { + FilterChip( + selected = selectedId.isBlank(), + onClick = { onSelected("") }, + label = { Text("No model") } + ) + options.forEach { option -> + FilterChip( + selected = selectedId == option.id, + onClick = { onSelected(option.id) }, + label = { Text(option.label) } + ) + } + } + } +} + +@Composable +private fun DesktopPdfExtrasPanel( + pageText: String, + recapText: String, + extrasState: ReaderExtrasState, + aiByokSettings: ReaderAiByokSettings, + onExternalLookup: (ReaderExternalLookupAction, String) -> Unit, + onAiAction: (ReaderAiFeature, String) -> Unit, + onCloudTtsStart: (ReaderTtsReadScope) -> Unit, + onCloudTtsPauseResume: () -> Unit, + onCloudTtsStop: () -> Unit, + onCloudTtsClearCache: () -> Unit, + onAutoScrollChange: (ReaderAutoScrollState) -> Unit, + ttsReplacementPreferences: ReaderTtsReplacementPreferences, + ttsReplacementBookId: String, + onTtsReplacementPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit +) { + val settings = aiByokSettings.sanitized() + val autoScroll = extrasState.autoScroll.sanitized() + + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + Text("Extras", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) { + ReaderExternalLookupAction.entries.forEach { action -> + FilterChip( + selected = false, + enabled = pageText.isNotBlank(), + onClick = { onExternalLookup(action, pageText) }, + label = { Text(action.title) } + ) + } + } + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text("Auto scroll", modifier = Modifier.weight(1f)) + Switch( + checked = autoScroll.enabled, + onCheckedChange = { onAutoScrollChange(autoScroll.copy(enabled = it)) } + ) + } + Slider( + value = autoScroll.speed, + onValueChange = { onAutoScrollChange(autoScroll.copy(speed = it).sanitized()) }, + valueRange = 12f..160f + ) + val ttsBusy = extrasState.cloudTts.isLoading || extrasState.cloudTts.isPlaying || extrasState.cloudTts.isPaused + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text( + when { + extrasState.cloudTts.isLoading -> "Preparing audio" + extrasState.cloudTts.isPaused -> "Paused" + extrasState.cloudTts.isPlaying -> "Reading" + settings.isCloudTtsAvailable -> "Cloud TTS ready" + else -> "Cloud TTS needs Gemini" + }, + fontWeight = FontWeight.SemiBold + ) + extrasState.cloudTts.errorMessage?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + } + val statusMessage = extrasState.cloudTts.progress.currentPositionLabel + ?: extrasState.cloudTts.statusMessage?.takeIf { it.isNotBlank() } + statusMessage?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + TextButton( + enabled = settings.isCloudTtsAvailable || ttsBusy, + onClick = { + if (ttsBusy) { + onCloudTtsStop() + } else { + onCloudTtsStart(ReaderTtsReadScope.BOOK) + } + } + ) { + Text(if (ttsBusy) "Stop" else "Read") + } + } + if (extrasState.cloudTts.isPlaying || extrasState.cloudTts.isPaused) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) { + TextButton(onClick = onCloudTtsPauseResume) { + Text(if (extrasState.cloudTts.isPaused) "Resume" else "Pause") + } + } + } + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) { + TextButton( + enabled = settings.isCloudTtsAvailable && !ttsBusy && pageText.isNotBlank(), + onClick = { onCloudTtsStart(ReaderTtsReadScope.PAGE) } + ) { + Text("Page") + } + TextButton( + enabled = settings.isCloudTtsAvailable && !ttsBusy && pageText.isNotBlank(), + onClick = { onCloudTtsStart(ReaderTtsReadScope.BOOK) } + ) { + Text("From here") + } + } + val cacheSummary = extrasState.cloudTts.cacheSummary + if (cacheSummary.hasCachedAudio) { + Text( + "Cache: ${cacheSummary.currentVoiceLabel}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + if (cacheSummary.hasCurrentVoiceCachedAudio) { + TextButton(onClick = onCloudTtsClearCache) { + Text("Clear voice cache") + } + } + } + SharedReaderTtsReplacementControls( + preferences = ttsReplacementPreferences, + bookId = ttsReplacementBookId, + onPreferencesChange = onTtsReplacementPreferencesChange + ) + if (settings.areReaderAiFeaturesAvailable) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) { + TextButton( + enabled = pageText.isNotBlank() && !extrasState.aiResult.isLoading, + onClick = { onAiAction(ReaderAiFeature.SUMMARIZE, pageText) } + ) { + Text("Summarize page") + } + TextButton( + enabled = recapText.isNotBlank() && !extrasState.aiResult.isLoading, + onClick = { onAiAction(ReaderAiFeature.RECAP, recapText) } + ) { + Text("Recap") + } + } + if (extrasState.aiResult.hasContent) { + Surface(color = MaterialTheme.colorScheme.surface, shape = RoundedCornerShape(6.dp), modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(8.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + val aiErrorMessage = extrasState.aiResult.errorMessage + Text(extrasState.aiResult.title ?: "AI", fontWeight = FontWeight.SemiBold) + when { + extrasState.aiResult.isLoading -> Text("Working...", color = MaterialTheme.colorScheme.onSurfaceVariant) + aiErrorMessage != null -> Text(aiErrorMessage, color = MaterialTheme.colorScheme.error) + else -> SharedMarkdownText(extrasState.aiResult.text) } } } @@ -1445,390 +5461,1439 @@ private fun PdfReaderScreen( } @Composable -private fun PdfAnnotationToolDock( - selectedTool: PdfInkTool, - selectedColor: Int, - strokeWidth: Float, - onToolSelected: (PdfInkTool) -> Unit, - onColorSelected: (Int) -> Unit, - onStrokeWidthChange: (Float) -> Unit, - onUndo: () -> Unit, - onClearPage: () -> Unit +private fun DesktopPdfJumpHistoryControls( + backPage: Int?, + forwardPage: Int?, + onBack: () -> Unit, + onForward: () -> Unit, + onClear: () -> Unit ) { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - PdfToolButton(PdfInkTool.PEN, selectedTool, onToolSelected) - PdfToolButton(PdfInkTool.HIGHLIGHTER, selectedTool, onToolSelected) - PdfToolButton(PdfInkTool.PENCIL, selectedTool, onToolSelected) - PdfToolButton(PdfInkTool.FOUNTAIN_PEN, selectedTool, onToolSelected) - } - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - PdfToolButton(PdfInkTool.HIGHLIGHTER_ROUND, selectedTool, onToolSelected) - PdfToolButton(PdfInkTool.TEXT, selectedTool, onToolSelected) - PdfToolButton(PdfInkTool.ERASER, selectedTool, onToolSelected) - IconButton(onClick = onUndo) { - Icon(Icons.AutoMirrored.Filled.NavigateBefore, contentDescription = "Undo annotation") - } - IconButton(onClick = onClearPage) { - Icon(Icons.Default.Delete, contentDescription = "Clear page annotations") - } - } - Text("Color", style = MaterialTheme.typography.labelLarge) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - val palette = if (selectedTool == PdfInkTool.HIGHLIGHTER || selectedTool == PdfInkTool.HIGHLIGHTER_ROUND) { - SharedPdfAnnotationDefaults.highlighterPalette - } else { - SharedPdfAnnotationDefaults.penPalette - } - palette.forEach { argb -> - Surface( - modifier = Modifier - .size(28.dp) - .border( - width = if (argb == selectedColor) 3.dp else 1.dp, - color = if (argb == selectedColor) MaterialTheme.colorScheme.primary else Color.Black.copy(alpha = 0.25f), - shape = RoundedCornerShape(14.dp) - ) - .clickable { onColorSelected(argb) }, - color = Color(argb), - shape = RoundedCornerShape(14.dp), - content = {} - ) - } - } - Text("Thickness ${String.format("%.1f", strokeWidth)}", style = MaterialTheme.typography.labelLarge) - Slider( - value = strokeWidth, - onValueChange = onStrokeWidthChange, - valueRange = 1f..28f - ) - } -} - -@Composable -private fun PdfToolButton( - tool: PdfInkTool, - selectedTool: PdfInkTool, - onToolSelected: (PdfInkTool) -> Unit -) { - val selected = tool == selectedTool - val icon = when (tool) { - PdfInkTool.PEN -> Icons.Default.Draw - PdfInkTool.HIGHLIGHTER -> Icons.Default.Brush - PdfInkTool.HIGHLIGHTER_ROUND -> Icons.Default.FormatColorText - PdfInkTool.ERASER -> Icons.Default.Remove - PdfInkTool.FOUNTAIN_PEN -> Icons.Default.EditNote - PdfInkTool.PENCIL -> Icons.Default.Brush - PdfInkTool.TEXT -> Icons.Default.TextFields - } + val hasJumpTargets = backPage != null || forwardPage != null Surface( - color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(8.dp) + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(6.dp) ) { - IconButton(onClick = { onToolSelected(tool) }) { - Icon(icon, contentDescription = tool.name.lowercase().replace('_', ' ')) - } - } -} - -@Composable -private fun PdfAnnotationOverlay( - annotations: List, - activeStroke: List, - canvasSize: IntSize -) { - Canvas(Modifier.fillMaxSize()) { - annotations.forEach { annotation -> - when (annotation.kind) { - PdfAnnotationKind.INK -> { - if (annotation.points.size > 1) { - drawPath( - path = annotation.points.toPath(canvasSize), - color = Color(annotation.colorArgb), - style = Stroke( - width = annotation.strokeWidth, - cap = StrokeCap.Round - ) - ) - } + Column( + modifier = Modifier.padding(8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "Jump history", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f) + ) + IconButton( + onClick = onClear, + enabled = hasJumpTargets, + modifier = Modifier.size(32.dp) + ) { + Icon(Icons.Default.Close, contentDescription = "Clear jump history") } - PdfAnnotationKind.TEXT -> { - val bounds = annotation.bounds ?: return@forEach - drawRect( - color = Color(annotation.backgroundArgb).copy(alpha = 0.18f), - topLeft = Offset(bounds.left * canvasSize.width, bounds.top * canvasSize.height), - size = androidx.compose.ui.geometry.Size( - (bounds.right - bounds.left) * canvasSize.width, - (bounds.bottom - bounds.top) * canvasSize.height - ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + TextButton( + onClick = onBack, + enabled = backPage != null, + modifier = Modifier.weight(1f) + ) { + Icon( + Icons.AutoMirrored.Filled.NavigateBefore, + contentDescription = "Jump back", + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(4.dp)) + Text( + backPage?.let { "Jump back p. ${it + 1}" } ?: "Jump back", + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + TextButton( + onClick = onForward, + enabled = forwardPage != null, + modifier = Modifier.weight(1f) + ) { + Text( + forwardPage?.let { "Jump forward p. ${it + 1}" } ?: "Jump forward", + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(Modifier.width(4.dp)) + Icon( + Icons.AutoMirrored.Filled.NavigateNext, + contentDescription = "Jump forward", + modifier = Modifier.size(18.dp) ) } } } - if (activeStroke.size > 1) { - drawPath( - path = activeStroke.toPath(canvasSize), - color = Color(0xFF1976D2), - style = Stroke(width = 2.5f, cap = StrokeCap.Round) + } +} + +@Composable +private fun DesktopPdfPageScrubOverlay( + pageIndex: Int?, + pageCount: Int +) { + if (pageIndex == null || pageCount <= 0) return + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Surface( + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), + shape = RoundedCornerShape(16.dp), + tonalElevation = 6.dp, + shadowElevation = 8.dp + ) { + Text( + text = "Page ${pageIndex + 1} of $pageCount", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp) ) } } - annotations.filter { it.kind == PdfAnnotationKind.TEXT && it.text.isNotBlank() }.forEach { annotation -> - val bounds = annotation.bounds ?: return@forEach - Text( - text = annotation.text, - color = Color(annotation.colorArgb), - fontSize = annotation.fontSize.sp, - fontWeight = if (annotation.isBold) FontWeight.Bold else FontWeight.Normal, +} + +@Composable +private fun DesktopVerticalPdfPage( + document: DesktopPdfDocument, + pageIndex: Int, + scale: Float, + zoomSpec: PdfZoomSpec, + annotations: List, + searchResults: List, + activeSearchIndex: Int, + searchHighlightMode: SearchHighlightMode, + activeTtsChunk: ReaderTtsChunk?, + searchQuery: String, + isTextSelectionMode: Boolean, + selectedAnnotationId: String?, + selectedEmbeddedAnnotationId: String?, + selectedTool: PdfInkTool, + selectedColor: Int, + strokeWidth: Float, + isHighlighterSnapEnabled: Boolean, + activeTextDraft: SharedPdfTextDraft?, + richTextController: SharedPdfRichTextController, + isRichTextMode: Boolean, + readerAiFeaturesAvailable: Boolean, + cloudTtsAvailable: Boolean, + themeStyle: DesktopPdfThemeStyle, + shouldRender: Boolean, + onSelectPage: (Int) -> Unit, + onCopySelection: (DesktopPdfTextSelection) -> Unit, + onHighlightSelection: (Int, DesktopPdfTextSelection, IntSize) -> Unit, + onSearchSelection: (DesktopPdfTextSelection) -> Unit, + onWebSearchSelection: (DesktopPdfTextSelection) -> Unit, + onDictionarySelection: (DesktopPdfTextSelection) -> Unit, + onDefineSelection: (DesktopPdfTextSelection) -> Unit, + onSpeakSelection: (DesktopPdfTextSelection) -> Unit, + onTranslateSelection: (DesktopPdfTextSelection) -> Unit, + onEmbeddedAnnotationSelected: (SharedPdfEmbeddedAnnotation) -> Unit, + onLinkActivated: (DesktopPdfLinkTarget) -> Unit, + onAnnotationAdded: (SharedPdfAnnotation) -> Unit, + onAnnotationUpdated: (SharedPdfAnnotation) -> Unit, + onAnnotationsChanged: (List) -> Unit, + onTextAnnotationSelected: (SharedPdfAnnotation) -> Unit, + onTextDraftStarted: (Int, Offset, IntSize) -> Unit, + onTextDraftChanged: (String, IntSize) -> Unit, + onTextDraftBoundsChanged: (PdfPageBounds) -> Unit +) { + val density = LocalDensity.current + val pageInteractionSource = remember { MutableInteractionSource() } + var renderedPage by remember(document.path, pageIndex, scale) { mutableStateOf(null) } + var renderError by remember(document.path, pageIndex, scale) { mutableStateOf(null) } + var isRendering by remember(document.path, pageIndex, scale) { mutableStateOf(true) } + var pageCanvasSize by remember(document.path, pageIndex, scale) { mutableStateOf(IntSize.Zero) } + var selectionStartIndex by remember(document.path, pageIndex) { mutableStateOf(null) } + var selectionEndIndex by remember(document.path, pageIndex) { mutableStateOf(null) } + var selectionStartHit by remember(document.path, pageIndex) { mutableStateOf(null) } + var selectionEndHit by remember(document.path, pageIndex) { mutableStateOf(null) } + var textSelection by remember(document.path, pageIndex) { mutableStateOf(null) } + var selectionMenuOffset by remember(document.path, pageIndex) { mutableStateOf(null) } + var activeStroke by remember(document.path, pageIndex, selectedTool) { mutableStateOf>(emptyList()) } + val currentTextSelection by rememberUpdatedState(textSelection) + val currentAnnotations by rememberUpdatedState(annotations) + + fun clearSelection() { + selectionStartIndex = null + selectionEndIndex = null + selectionStartHit = null + selectionEndHit = null + textSelection = null + selectionMenuOffset = null + } + + fun clearInteractionState() { + clearSelection() + activeStroke = emptyList() + } + + LaunchedEffect(document.path, pageIndex, scale, shouldRender) { + if (!shouldRender) { + renderedPage = null + renderError = null + isRendering = false + clearInteractionState() + return@LaunchedEffect + } + isRendering = true + renderError = null + val pageSize = document.pageSizes.getOrNull(pageIndex) + if (pageSize == null) { + renderedPage = null + renderError = "Failed to render page." + isRendering = false + return@LaunchedEffect + } + delay(45) + val safeScale = zoomSpec.safeRenderScale(pageSize.width, pageSize.height, scale) + val result = withContext(Dispatchers.IO) { + runCatching { DesktopPdfium.renderPage(document, pageIndex, safeScale) } + } + renderedPage = result.getOrNull() + renderError = result.exceptionOrNull()?.message + ?: if (renderedPage == null) "Failed to render page." else null + isRendering = false + } + + LaunchedEffect(isTextSelectionMode) { + if (!isTextSelectionMode) { + clearSelection() + } else { + activeStroke = emptyList() + } + } + + LaunchedEffect(selectedTool) { + activeStroke = emptyList() + } + + Column( + modifier = Modifier.clickable( + interactionSource = pageInteractionSource, + indication = null, + onClick = { onSelectPage(pageIndex) } + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + val pageSize = document.pageSizes.getOrNull(pageIndex) + val placeholderScale = pageSize?.let { zoomSpec.safeRenderScale(it.width, it.height, scale) } ?: scale + val placeholderWidthDp = with(density) { ((pageSize?.width ?: 612f) * placeholderScale).toDp() } + val placeholderHeightDp = with(density) { ((pageSize?.height ?: 792f) * placeholderScale).toDp() } + val renderedPageWidth = renderedPage?.width ?: 0 + val renderedPageHeight = renderedPage?.height ?: 0 + val pageRenderScale = if (pageSize != null && pageSize.width > 0f && renderedPageWidth > 0) { + renderedPageWidth / pageSize.width + } else { + placeholderScale + } + val pageEmbeddedAnnotations = remember(document.embeddedAnnotations, pageIndex) { + document.embeddedAnnotations.filter { it.pageIndex == pageIndex } + } + + Box( modifier = Modifier - .padding( - start = (bounds.left * canvasSize.width).dp, - top = (bounds.top * canvasSize.height).dp - ) - .background(Color(annotation.backgroundArgb).copy(alpha = 0.18f), RoundedCornerShape(4.dp)) - .padding(horizontal = 6.dp, vertical = 4.dp) - ) - } -} + .size(placeholderWidthDp, placeholderHeightDp) + .background(Color.White, RoundedCornerShape(2.dp)) + .onSizeChanged { pageCanvasSize = it } + .pointerInput(pageIndex, pageCanvasSize, isTextSelectionMode, selectedTool, isRichTextMode) { + if (isRichTextMode) return@pointerInput + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent() + val point = event.changes.firstOrNull()?.position ?: continue + if (event.type == PointerEventType.Press && event.buttons.isPrimaryPressed) { + if (selectedTool != PdfInkTool.TEXT) { + val linkTarget = document.linkAt(pageIndex, point, pageCanvasSize) + if (linkTarget != null) { + logPdfLink( + "tap_hit mode=vertical page=${pageIndex + 1} " + + "x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()} " + + "textSelection=$isTextSelectionMode target=${linkTarget.formatLogTarget()}" + ) + onSelectPage(pageIndex) + onLinkActivated(linkTarget) + clearInteractionState() + event.changes.forEach { it.consume() } + continue + } + } + val embeddedHit = pageEmbeddedAnnotations.findLast { + it.sharedPdfEmbeddedHitTest(point, pageCanvasSize) + } + if (embeddedHit != null) { + onSelectPage(pageIndex) + onEmbeddedAnnotationSelected(embeddedHit) + clearInteractionState() + event.changes.forEach { it.consume() } + } else if ( + currentTextSelection != null && + selectionMenuOffset == null + ) { + clearSelection() + } + } else if (event.type == PointerEventType.Press && event.buttons.isSecondaryPressed) { + val selection = currentTextSelection + if (selection != null) { + onSelectPage(pageIndex) + selectionMenuOffset = point + logPdfSelection( + "menu_open page=${pageIndex + 1} " + + "x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()} " + + "range=${selection.startIndex}..${selection.endIndex} " + + "chars=${selection.text.length}" + ) + event.changes.forEach { it.consume() } + } + } + } + } + } + .pointerInput( + pageIndex, + isTextSelectionMode, + selectedTool, + selectedColor, + strokeWidth, + isHighlighterSnapEnabled, + activeTextDraft?.id, + isRichTextMode, + pageCanvasSize, + renderedPageWidth, + renderedPageHeight + ) { + if (renderedPageWidth > 0 && renderedPageHeight > 0) { + if (isRichTextMode) return@pointerInput + if (isTextSelectionMode) { + detectDragGestures( + onDragStart = { start -> + onSelectPage(pageIndex) + activeStroke = emptyList() + selectionMenuOffset = null + val hit = document.charHitAt(pageIndex, start, pageCanvasSize) + selectionStartHit = hit + selectionStartIndex = hit?.index + selectionEndHit = null + selectionEndIndex = null + logPdfSelection( + "drag_start page=${pageIndex + 1} " + + "canvas=${pageCanvasSize.formatLogSize()} bitmap=${renderedPageWidth}x$renderedPageHeight " + + "requestedScale=${scale.formatLogFloat()} renderScale=${pageRenderScale.formatLogFloat()} " + + hit.formatLogHit("start") + ) + textSelection = null + }, + onDrag = { change, _ -> + val startIndex = selectionStartIndex + val hit = document.charHitAt(pageIndex, change.position, pageCanvasSize) + selectionEndHit = hit + val endIndex = hit?.index + val previousEndIndex = selectionEndIndex + selectionEndIndex = endIndex + if (endIndex != previousEndIndex || textSelection == null) { + textSelection = if (startIndex != null && endIndex != null) { + document.selectionBetweenIndexes( + pageIndex = pageIndex, + startIndex = startIndex, + endIndex = endIndex, + canvasSize = pageCanvasSize, + useNativeBounds = false + ) + } else { + null + } + } + }, + onDragEnd = { + val startIndex = selectionStartIndex + val endIndex = selectionEndIndex + val selection = if (startIndex != null && endIndex != null) { + document.selectionBetweenIndexes( + pageIndex = pageIndex, + startIndex = startIndex, + endIndex = endIndex, + canvasSize = pageCanvasSize, + useNativeBounds = true + )?.also { + textSelection = it + selectionMenuOffset = selectionEndHit?.point ?: selectionStartHit?.point + } + } else { + textSelection + } + logPdfSelection( + "drag_end page=${pageIndex + 1} " + + "canvas=${pageCanvasSize.formatLogSize()} bitmap=${renderedPageWidth}x$renderedPageHeight " + + "requestedScale=${scale.formatLogFloat()} renderScale=${pageRenderScale.formatLogFloat()} " + + selectionStartHit.formatLogHit("start") + " " + + selectionEndHit.formatLogHit("end") + " " + + "range=${selection?.startIndex}..${selection?.endIndex} " + + "chars=${selection?.text?.length ?: 0} " + + "lines=${selection?.lineBounds?.size ?: 0} " + + "text=\"${selection?.text.orEmpty().logPreview()}\"" + ) + selectionStartIndex = null + selectionEndIndex = null + selectionStartHit = null + selectionEndHit = null + }, + onDragCancel = { + logPdfSelection( + "drag_cancel page=${pageIndex + 1} " + + "canvas=${pageCanvasSize.formatLogSize()} bitmap=${renderedPageWidth}x$renderedPageHeight " + + "requestedScale=${scale.formatLogFloat()} renderScale=${pageRenderScale.formatLogFloat()} " + + selectionStartHit.formatLogHit("start") + " " + + selectionEndHit.formatLogHit("end") + ) + selectionStartIndex = null + selectionEndIndex = null + selectionStartHit = null + selectionEndHit = null + } + ) + } else if (selectedTool == PdfInkTool.TEXT) { + detectTapGestures( + onTap = { start -> + onSelectPage(pageIndex) + when { + activeTextDraft?.containsOffset(pageIndex, start, pageCanvasSize) == true -> Unit + else -> { + val textHit = currentAnnotations.textAnnotationHitAt( + pageIndex = pageIndex, + point = start, + canvasSize = pageCanvasSize + ) + clearInteractionState() + if (textHit != null) { + onTextAnnotationSelected(textHit) + } else { + onTextDraftStarted(pageIndex, start, pageCanvasSize) + } + } + } + } + ) + } else { + var eraserPreviousPoint: Offset? = null + detectDragGestures( + onDragStart = { start -> + onSelectPage(pageIndex) + clearInteractionState() + if (selectedTool == PdfInkTool.ERASER) { + val annotationSnapshot = currentAnnotations + val updatedAnnotations = annotationSnapshot.filterNot { + it.pageIndex == pageIndex && it.sharedPdfHitTest( + point = start, + size = pageCanvasSize, + eraserStrokeWidth = strokeWidth + ) + } + if (updatedAnnotations.size != annotationSnapshot.size) { + onAnnotationsChanged(updatedAnnotations) + } + eraserPreviousPoint = start + } else { + activeStroke = listOf( + start.toSharedPdfPoint(pageCanvasSize, System.currentTimeMillis()) + ) + } + }, + onDrag = { change, _ -> + if (selectedTool == PdfInkTool.ERASER) { + val point = change.position + val previousPoint = eraserPreviousPoint + val annotationSnapshot = currentAnnotations + val updatedAnnotations = annotationSnapshot.filterNot { + it.pageIndex == pageIndex && it.sharedPdfHitTest( + point = point, + size = pageCanvasSize, + lastPoint = previousPoint, + eraserStrokeWidth = strokeWidth + ) + } + if (updatedAnnotations.size != annotationSnapshot.size) { + onAnnotationsChanged(updatedAnnotations) + } + eraserPreviousPoint = point + } else { + activeStroke = activeStroke.withDesktopPdfDragPoint( + point = change.position, + canvasSize = pageCanvasSize, + tool = selectedTool, + snapHighlighter = isHighlighterSnapEnabled, + timestamp = System.currentTimeMillis() + ) + } + }, + onDragEnd = { + eraserPreviousPoint = null + if (activeStroke.size > 1) { + onAnnotationAdded( + SharedPdfAnnotation( + id = "ink_${System.currentTimeMillis()}", + pageIndex = pageIndex, + kind = PdfAnnotationKind.INK, + tool = selectedTool, + points = activeStroke, + colorArgb = selectedColor, + strokeWidth = strokeWidth, + createdAt = System.currentTimeMillis() + ) + ) + } + activeStroke = emptyList() + }, + onDragCancel = { + eraserPreviousPoint = null + activeStroke = emptyList() + } + ) + } + } + }, + contentAlignment = Alignment.Center + ) { + when { + !shouldRender -> { + Text("Page ${pageIndex + 1}", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + isRendering -> CircularProgressIndicator() + renderError != null -> Text(renderError ?: "Failed to render page.", color = MaterialTheme.colorScheme.error) + renderedPage != null -> { + val pageRender = renderedPage!! + val pageAnnotations = remember(annotations, pageIndex, pageCanvasSize) { + annotations + .filter { it.pageIndex == pageIndex } + .flatMap { annotation -> + annotation.toRenderablePdfAnnotations(document, pageIndex, pageCanvasSize) + } + } + val selectedTextAnnotationForPage = remember(annotations, selectedAnnotationId, selectedTool, isTextSelectionMode, pageIndex) { + annotations.firstOrNull { + selectedTool == PdfInkTool.TEXT && + !isTextSelectionMode && + it.id == selectedAnnotationId && + it.kind == PdfAnnotationKind.TEXT && + it.pageIndex == pageIndex + } + } + val visiblePageAnnotations = remember(pageAnnotations, selectedTextAnnotationForPage?.id) { + pageAnnotations.filterNot { + it.kind == PdfAnnotationKind.TEXT && it.id == selectedTextAnnotationForPage?.id + } + } + val searchHighlightBounds: List = remember( + document.path, + searchResults, + pageIndex, + activeSearchIndex, + searchHighlightMode, + pageCanvasSize, + searchQuery + ) { + val queryLength = searchQuery.trim().length + if (queryLength <= 0 || pageCanvasSize.width <= 0 || pageCanvasSize.height <= 0) { + emptyList() + } else { + SharedPdfSearchEngine.highlightsForPage( + results = searchResults, + pageIndex = pageIndex, + activeResultIndex = activeSearchIndex, + mode = searchHighlightMode + ).flatMap { result -> + val matchLength = result.matchLength.takeIf { it > 0 } ?: queryLength + DesktopPdfium.textRectsForRange( + document = document, + pageIndex = pageIndex, + startIndex = result.matchIndex, + endIndex = result.matchIndex + matchLength - 1, + viewportWidth = pageCanvasSize.width, + viewportHeight = pageCanvasSize.height + ).map { it.toPdfPageBounds() } + .filter { it.right > it.left && it.bottom > it.top } + .mergePdfBoundsByLine() + } + } + } + val ttsHighlightBounds: List = remember( + document.path, + activeTtsChunk, + pageIndex, + pageCanvasSize + ) { + val chunk = activeTtsChunk?.takeIf { it.pageIndex == pageIndex } + if (chunk == null || pageCanvasSize.width <= 0 || pageCanvasSize.height <= 0 || chunk.endOffset <= chunk.startOffset) { + emptyList() + } else { + DesktopPdfium.textRectsForRange( + document = document, + pageIndex = pageIndex, + startIndex = chunk.startOffset, + endIndex = chunk.endOffset - 1, + viewportWidth = pageCanvasSize.width, + viewportHeight = pageCanvasSize.height + ).map { it.toPdfPageBounds() } + .filter { it.right > it.left && it.bottom > it.top } + .mergePdfBoundsByLine() + } + } -private fun Offset.toPdfPoint(size: IntSize): PdfPagePoint { - val width = size.width.coerceAtLeast(1) - val height = size.height.coerceAtLeast(1) - return PdfPagePoint( - x = (x / width).coerceIn(0f, 1f), - y = (y / height).coerceIn(0f, 1f), - timestamp = System.currentTimeMillis() - ) -} - -private fun List.toPath(size: IntSize): Path { - val path = Path() - forEachIndexed { index, point -> - val x = point.x * size.width - val y = point.y * size.height - if (index == 0) path.moveTo(x, y) else path.lineTo(x, y) - } - return path -} - -private fun pageBoundsFromPoint(point: Offset, size: IntSize): PdfPageBounds { - val width = size.width.coerceAtLeast(1) - val height = size.height.coerceAtLeast(1) - val left = (point.x / width).coerceIn(0f, 0.92f) - val top = (point.y / height).coerceIn(0f, 0.95f) - return PdfPageBounds( - left = left, - top = top, - right = (left + 0.32f).coerceAtMost(1f), - bottom = (top + 0.08f).coerceAtMost(1f) - ) -} - -private fun SharedPdfAnnotation.hitTest(point: Offset, size: IntSize): Boolean { - return when (kind) { - PdfAnnotationKind.TEXT -> { - val bounds = bounds ?: return false - val rect = Rect( - bounds.left * size.width, - bounds.top * size.height, - bounds.right * size.width, - bounds.bottom * size.height - ) - rect.contains(point) - } - PdfAnnotationKind.INK -> { - points.any { - abs((it.x * size.width) - point.x) <= strokeWidth + 8f && - abs((it.y * size.height) - point.y) <= strokeWidth + 8f + DesktopPdfThemedPageImage( + bitmap = pageRender.image, + contentDescription = "PDF page ${pageIndex + 1}", + themeStyle = themeStyle, + modifier = Modifier.fillMaxSize() + ) + SharedPdfRichTextLayer( + pageIndex = pageIndex, + controller = richTextController, + pageWidth = pageCanvasSize.width.toFloat(), + pageHeight = pageCanvasSize.height.toFloat(), + isTextEditingEnabled = isRichTextMode, + onPageTapped = { onSelectPage(pageIndex) } + ) + PdfSearchHighlightOverlay( + bounds = searchHighlightBounds, + canvasSize = pageCanvasSize, + color = when (searchHighlightMode) { + SearchHighlightMode.ALL -> Color(0x55FDD835) + SearchHighlightMode.FOCUSED -> Color(0x88FF9800) + } + ) + PdfSearchHighlightOverlay( + bounds = ttsHighlightBounds, + canvasSize = pageCanvasSize, + color = Color(0x887DD3FC) + ) + PdfTextSelectionOverlay( + selection = textSelection, + canvasSize = pageCanvasSize + ) + SharedPdfAnnotationOverlay( + annotations = visiblePageAnnotations, + activeStroke = activeStroke, + canvasSize = pageCanvasSize, + activeTool = selectedTool, + activeStrokeColorArgb = selectedColor, + activeStrokeWidth = strokeWidth, + selectedAnnotationId = selectedAnnotationId + ) + SharedPdfInlineTextEditorOverlay( + draft = activeTextDraft?.takeIf { it.pageIndex == pageIndex }, + canvasSize = pageCanvasSize, + onTextChange = { onTextDraftChanged(it, pageCanvasSize) }, + onBoundsChange = { onTextDraftBoundsChanged(it) } + ) + selectedTextAnnotationForPage?.let { annotation -> + val bounds = annotation.bounds + if (bounds != null && activeTextDraft == null) { + SharedPdfTextBoxEditorOverlay( + id = annotation.id, + text = annotation.text, + style = annotation.sharedPdfTextStyle(), + bounds = bounds, + canvasSize = pageCanvasSize, + onTextChange = { text -> + onAnnotationUpdated(annotation.copy(text = text)) + }, + onBoundsChange = { nextBounds -> + onAnnotationUpdated(annotation.copy(bounds = nextBounds)) + } + ) + } + } + SharedPdfEmbeddedAnnotationOverlay( + annotations = pageEmbeddedAnnotations, + canvasSize = pageCanvasSize, + selectedAnnotationId = selectedEmbeddedAnnotationId + ) + SharedPdfPageNumberOverlay( + pageIndex = pageIndex, + pageCount = document.pageCount + ) + if (textSelection != null && selectionMenuOffset != null) { + Box( + modifier = Modifier + .matchParentSize() + .pointerInput(pageIndex, selectionMenuOffset) { + detectTapGestures { + clearSelection() + } + } + ) + } + PdfSelectionMenu( + selection = textSelection, + menuOffset = selectionMenuOffset, + canvasSize = pageCanvasSize, + onCopy = { + textSelection?.let(onCopySelection) + clearSelection() + }, + onHighlight = { + textSelection?.let { onHighlightSelection(pageIndex, it, pageCanvasSize) } + clearSelection() + }, + onSearch = { + textSelection?.let(onSearchSelection) + selectionMenuOffset = null + }, + onWebSearch = { + textSelection?.let(onWebSearchSelection) + selectionMenuOffset = null + }, + onDictionary = { + textSelection?.let(onDictionarySelection) + selectionMenuOffset = null + }, + onDefine = { + textSelection?.let(onDefineSelection) + selectionMenuOffset = null + }, + onSpeak = { + textSelection?.let(onSpeakSelection) + selectionMenuOffset = null + }, + onTranslate = { + textSelection?.let(onTranslateSelection) + selectionMenuOffset = null + }, + showDefine = readerAiFeaturesAvailable, + showSpeak = cloudTtsAvailable, + onClear = ::clearSelection + ) + } } } } } -private data class ReaderPdfSearchResult( - val pageIndex: Int, - val preview: String +@Composable +private fun DesktopPdfAnnotationEditor( + annotation: SharedPdfAnnotation, + onUpdate: (SharedPdfAnnotation) -> Unit, + onDelete: () -> Unit, + onClose: () -> Unit +) { + Surface( + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(6.dp), + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "Selected ${annotation.desktopLabel()}", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f) + ) + TextButton(onClick = onClose) { + Text("Close") + } + } + Text( + "Page ${annotation.pageIndex + 1}", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall + ) + if (annotation.kind == PdfAnnotationKind.TEXT) { + OutlinedTextField( + value = annotation.text, + onValueChange = { onUpdate(annotation.copy(text = it)) }, + label = { Text("Text note") }, + minLines = 2, + modifier = Modifier.fillMaxWidth() + ) + SharedPdfTextStyleControls( + style = annotation.sharedPdfTextStyle(), + onStyleChange = { onUpdate(annotation.withSharedPdfTextStyle(it)) } + ) + } + if (annotation.kind != PdfAnnotationKind.TEXT) { + val palette = if ( + annotation.kind == PdfAnnotationKind.HIGHLIGHT || + annotation.tool == PdfInkTool.HIGHLIGHTER || + annotation.tool == PdfInkTool.HIGHLIGHTER_ROUND + ) { + SharedPdfAnnotationDefaults.highlighterPalette + } else { + SharedPdfAnnotationDefaults.penPalette + } + Text("Color", style = MaterialTheme.typography.labelLarge) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + palette.forEach { argb -> + Surface( + modifier = Modifier + .size(26.dp) + .clickable { onUpdate(annotation.copy(colorArgb = argb)) }, + color = Color(argb), + shape = RoundedCornerShape(13.dp), + content = {} + ) + } + } + } + if (annotation.kind == PdfAnnotationKind.INK) { + val strokeRange = annotation.tool.sharedPdfStrokeWidthRange() + val strokeValue = annotation.strokeWidth.coerceIn(strokeRange.start, strokeRange.endInclusive) + Text("Thickness ${strokeValue.sharedPdfStrokePercent(strokeRange)}", style = MaterialTheme.typography.labelLarge) + Slider( + value = strokeValue, + onValueChange = { onUpdate(annotation.copy(strokeWidth = it.coerceAtLeast(0.0001f))) }, + valueRange = strokeRange + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = onDelete) { + Text("Delete") + } + } + } + } +} + +@Composable +private fun DesktopPdfEmbeddedAnnotationPanel( + annotation: SharedPdfEmbeddedAnnotation, + onCopy: () -> Unit, + onClose: () -> Unit +) { + Surface( + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(6.dp), + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "Embedded PDF comment", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f) + ) + TextButton(onClick = onClose) { + Text("Close") + } + } + Text( + "Page ${annotation.pageIndex + 1}${annotation.author.takeIf { it.isNotBlank() }?.let { " - $it" }.orEmpty()}", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall + ) + DesktopPdfEmbeddedComment( + author = annotation.author, + contents = annotation.contents.ifBlank { "No comment" }, + depth = 0 + ) + DesktopPdfEmbeddedReplies(annotation.replies, depth = 1) + TextButton(onClick = onCopy) { + Text("Copy thread") + } + } + } +} + +@Composable +private fun DesktopPdfEmbeddedReplies( + replies: List, + depth: Int +) { + replies.forEach { reply -> + HorizontalDivider() + DesktopPdfEmbeddedComment( + author = reply.author, + contents = reply.contents, + depth = depth + ) + if (reply.replies.isNotEmpty()) { + DesktopPdfEmbeddedReplies(reply.replies, depth + 1) + } + } +} + +@Composable +private fun DesktopPdfEmbeddedComment( + author: String, + contents: String, + depth: Int +) { + Column( + modifier = Modifier.padding(start = (depth * 12).dp), + verticalArrangement = Arrangement.spacedBy(3.dp) + ) { + Text( + author.ifBlank { "Unknown" }, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold + ) + Text( + contents.ifBlank { "No comment" }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +private data class DesktopPdfTextSelection( + val text: String, + val lineBounds: List, + val startIndex: Int, + val endIndex: Int ) -private fun desktopPdfAnnotationFile(documentPath: String): File { +private data class DesktopPdfCharHit( + val index: Int, + val source: String, + val point: Offset, + val normalized: PdfNormalizedPoint +) + +private fun SharedPdfAnnotation.desktopLabel(): String { + return when (kind) { + PdfAnnotationKind.HIGHLIGHT -> "highlight" + PdfAnnotationKind.INK -> tool.name.lowercase().replace('_', ' ') + PdfAnnotationKind.TEXT -> "text note" + } +} + +private fun SharedPdfEmbeddedAnnotation.threadText(): String { + return buildString { + append(author.ifBlank { "Unknown" }) + append(": ") + appendLine(contents.ifBlank { "No comment" }) + fun appendReplies(replies: List, indent: String) { + replies.forEach { reply -> + append(indent) + append(reply.author.ifBlank { "Unknown" }) + append(": ") + appendLine(reply.contents.ifBlank { "No comment" }) + appendReplies(reply.replies, "$indent ") + } + } + appendReplies(replies, " ") + }.trimEnd() +} + +private fun DesktopPdfDocument.linkAt( + pageIndex: Int, + point: Offset, + canvasSize: IntSize +): DesktopPdfLinkTarget? { + if (canvasSize.width <= 0 || canvasSize.height <= 0) return null + return DesktopPdfium.linkAt( + document = this, + pageIndex = pageIndex, + normalizedX = point.x / canvasSize.width, + normalizedY = point.y / canvasSize.height, + viewportWidth = canvasSize.width, + viewportHeight = canvasSize.height + ) +} + +@Composable +private fun PdfSearchHighlightOverlay( + bounds: List, + canvasSize: IntSize, + color: Color +) { + if (bounds.isEmpty() || canvasSize.width <= 0 || canvasSize.height <= 0) return + Canvas(Modifier.fillMaxSize()) { + bounds.forEach { rect -> + drawRect( + color = color, + topLeft = Offset(rect.left * canvasSize.width, rect.top * canvasSize.height), + size = androidx.compose.ui.geometry.Size( + (rect.right - rect.left) * canvasSize.width, + (rect.bottom - rect.top) * canvasSize.height + ) + ) + } + } +} + +@Composable +private fun PdfTextSelectionOverlay( + selection: DesktopPdfTextSelection?, + canvasSize: IntSize +) { + val bounds = selection?.lineBounds.orEmpty() + if (bounds.isEmpty()) return + Canvas(Modifier.fillMaxSize()) { + bounds.forEach { rect -> + drawRect( + color = Color(0x663B82F6), + topLeft = Offset(rect.left * canvasSize.width, rect.top * canvasSize.height), + size = androidx.compose.ui.geometry.Size( + (rect.right - rect.left) * canvasSize.width, + (rect.bottom - rect.top) * canvasSize.height + ) + ) + } + } +} + +@Composable +private fun PdfSelectionMenu( + selection: DesktopPdfTextSelection?, + menuOffset: Offset?, + canvasSize: IntSize, + onCopy: () -> Unit, + onHighlight: () -> Unit, + onSearch: () -> Unit, + onWebSearch: () -> Unit, + onDictionary: () -> Unit, + onDefine: () -> Unit, + onSpeak: () -> Unit, + onTranslate: () -> Unit, + showDefine: Boolean, + showSpeak: Boolean, + onClear: () -> Unit +) { + selection ?: return + val anchor = menuOffset ?: return + Surface( + color = MaterialTheme.colorScheme.surface, + tonalElevation = 6.dp, + shadowElevation = 8.dp, + shape = RoundedCornerShape(8.dp), + modifier = Modifier.padding( + start = anchor.x.coerceIn( + PdfSelectionMenuMarginPx, + (canvasSize.width.toFloat() - PdfSelectionMenuWidthPx).coerceAtLeast(PdfSelectionMenuMarginPx) + ).dp, + top = anchor.y.coerceIn( + PdfSelectionMenuMarginPx, + (canvasSize.height.toFloat() - PdfSelectionMenuHeightPx).coerceAtLeast(PdfSelectionMenuMarginPx) + ).dp + ) + ) { + Row( + modifier = Modifier + .padding(horizontal = 6.dp, vertical = 4.dp) + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + TextButton(onClick = onCopy) { Text("Copy") } + TextButton(onClick = onHighlight) { Text("Highlight") } + if (showDefine) TextButton(onClick = onDefine) { Text("Define") } + if (showSpeak) TextButton(onClick = onSpeak) { Text("Speak") } + TextButton(onClick = onDictionary) { Text("Dict") } + TextButton(onClick = onSearch) { Text("Find") } + TextButton(onClick = onWebSearch) { Text("Web") } + TextButton(onClick = onTranslate) { Text("Translate") } + TextButton(onClick = onClear) { Text("Clear") } + } + } +} + +private fun DesktopPdfDocument.charHitAt( + pageIndex: Int, + point: Offset, + canvasSize: IntSize +): DesktopPdfCharHit? { + val normalized = PdfSelectionGeometry.normalizedPoint( + pointX = point.x, + pointY = point.y, + viewportWidth = canvasSize.width, + viewportHeight = canvasSize.height + ) ?: return null + val nativeIndex = DesktopPdfium.charIndexAt( + document = this, + pageIndex = pageIndex, + normalizedX = normalized.x, + normalizedY = normalized.y, + viewportWidth = canvasSize.width, + viewportHeight = canvasSize.height + ) + if (nativeIndex != null) { + return DesktopPdfCharHit( + index = nativeIndex, + source = "native", + point = point, + normalized = normalized + ) + } + val fallback = PdfSelectionGeometry.nearestCharOnLine( + chars = textPageData(pageIndex).chars.visiblePdfTextBounds(), + point = normalized + ) ?: return null + return DesktopPdfCharHit( + index = fallback.index, + source = "fallback_line", + point = point, + normalized = normalized + ) +} + +private fun DesktopPdfDocument.selectionBetweenIndexes( + pageIndex: Int, + startIndex: Int, + endIndex: Int, + canvasSize: IntSize, + useNativeBounds: Boolean = true +): DesktopPdfTextSelection? { + val chars = textPageData(pageIndex).chars + if (chars.isEmpty() || abs(startIndex - endIndex) < 1) return null + val firstIndex = minOf(startIndex, endIndex) + val lastIndex = maxOf(startIndex, endIndex) + val selectedChars = chars.filter { it.index in firstIndex..lastIndex } + val text = selectedChars.joinToString("") { it.char.toString() } + .replace(Regex("[ \\t\\x0B\\f\\r]+"), " ") + .replace(Regex("\\n{3,}"), "\n\n") + .trim() + if (text.isBlank()) return null + val fallbackBounds = PdfSelectionGeometry.lineBoundsForChars(selectedChars.visiblePdfTextBounds()) + val nativeBounds = if (useNativeBounds) { + DesktopPdfium.textRectsForRange( + document = this, + pageIndex = pageIndex, + startIndex = firstIndex, + endIndex = lastIndex, + viewportWidth = canvasSize.width, + viewportHeight = canvasSize.height + ).map { it.toPdfPageBounds() } + .filter { it.right > it.left && it.bottom > it.top } + .mergePdfBoundsByLine() + } else { + emptyList() + } + return DesktopPdfTextSelection( + text = text, + lineBounds = nativeBounds.ifEmpty { fallbackBounds }, + startIndex = firstIndex, + endIndex = lastIndex + ) +} + +private fun DesktopPdfTextRect.toPdfPageBounds(): PdfPageBounds { + return PdfPageBounds( + left = left, + top = top, + right = right, + bottom = bottom + ) +} + +private fun SharedPdfAnnotation.toRenderablePdfAnnotations( + document: DesktopPdfDocument, + pageIndex: Int, + canvasSize: IntSize +): List { + val startIndex = rangeStartIndex + val endIndex = rangeEndIndex + if (kind != PdfAnnotationKind.HIGHLIGHT || startIndex == null || endIndex == null) { + return listOf(this) + } + if (canvasSize.width <= 0 || canvasSize.height <= 0) { + return listOf(this) + } + val dynamicBounds = DesktopPdfium.textRectsForRange( + document = document, + pageIndex = pageIndex, + startIndex = startIndex, + endIndex = endIndex, + viewportWidth = canvasSize.width, + viewportHeight = canvasSize.height + ).map { it.toPdfPageBounds() } + .filter { it.right > it.left && it.bottom > it.top } + .mergePdfBoundsByLine() + + return dynamicBounds.ifEmpty { boundsList.ifEmpty { listOfNotNull(bounds) } } + .mapIndexed { index, dynamicBounds -> + copy( + id = "${id}_line_$index", + bounds = dynamicBounds + ) + } +} + +private fun SharedPdfTextDraft.containsOffset( + pageIndex: Int, + offset: Offset, + canvasSize: IntSize +): Boolean { + if (this.pageIndex != pageIndex || canvasSize.width <= 0 || canvasSize.height <= 0) return false + val left = bounds.left * canvasSize.width + val right = bounds.right * canvasSize.width + val top = bounds.top * canvasSize.height + val bottom = bounds.bottom * canvasSize.height + return offset.x in left..right && offset.y in top..bottom +} + +private fun List.textAnnotationHitAt( + pageIndex: Int, + point: Offset, + canvasSize: IntSize +): SharedPdfAnnotation? { + return asReversed().firstOrNull { annotation -> + annotation.kind == PdfAnnotationKind.TEXT && + annotation.pageIndex == pageIndex && + annotation.sharedPdfHitTest(point, canvasSize) + } +} + +private fun List.mergePdfBoundsByLine(): List { + return PdfSelectionGeometry.mergeBoundsByLine(this) +} + +private fun List.visiblePdfTextBounds(): List { + return asSequence() + .filter { it.hasBounds && !it.char.isISOControl() } + .map { it.toPdfTextCharBounds() } + .toList() +} + +private fun DesktopPdfTextChar.toPdfTextCharBounds(): PdfTextCharBounds { + return PdfTextCharBounds( + index = index, + left = left, + top = top, + right = right, + bottom = bottom + ) +} + +private const val PdfSelectionMenuWidthPx = 620f +private const val PdfSelectionMenuHeightPx = 54f +private const val PdfSelectionMenuMarginPx = 6f + +internal fun desktopPdfAnnotationFile(documentPath: String): File { val baseDir = System.getenv("APPDATA")?.takeIf { it.isNotBlank() } ?: File(System.getProperty("user.home"), "AppData/Roaming").absolutePath val safeName = documentPath.hashCode().toString().replace("-", "n") return File(baseDir, "Episteme/annotations/pdf_$safeName.json") } +internal fun desktopPdfBookmarkFile(documentPath: String): File { + val baseDir = System.getenv("APPDATA")?.takeIf { it.isNotBlank() } + ?: File(System.getProperty("user.home"), "AppData/Roaming").absolutePath + val safeName = documentPath.hashCode().toString().replace("-", "n") + return File(baseDir, "Episteme/annotations/pdf_${safeName}_bookmarks.json") +} + +internal fun desktopPdfRichTextFile(documentPath: String): File { + val baseDir = System.getenv("APPDATA")?.takeIf { it.isNotBlank() } + ?: File(System.getProperty("user.home"), "AppData/Roaming").absolutePath + val safeName = documentPath.hashCode().toString().replace("-", "n") + return File(baseDir, "Episteme/annotations/pdf_${safeName}_rich_text.json") +} + +private fun desktopPdfSearchIndexFile(documentPath: String): File { + val baseDir = System.getenv("APPDATA")?.takeIf { it.isNotBlank() } + ?: File(System.getProperty("user.home"), "AppData/Roaming").absolutePath + val safeName = documentPath.hashCode().toString().replace("-", "n") + return File(baseDir, "Episteme/search/pdf_${safeName}_text_index.tsv") +} + +private fun restoreDesktopPdfSearchIndex(document: DesktopPdfDocument, indexFile: File): Int { + val sourceFile = File(document.path) + val lines = runCatching { indexFile.readLines(Charsets.UTF_8) }.getOrNull() ?: return document.indexedSearchTextPageCount() + if (lines.firstOrNull() != DesktopPdfSearchIndexHeader) return 0 + val metadata = lines + .asSequence() + .drop(1) + .takeWhile { !it.startsWith("page\t") } + .mapNotNull { line -> + val parts = line.split('\t', limit = 2) + if (parts.size == 2) parts[0] to parts[1] else null + } + .toMap() + val isFresh = metadata["pathHash"] == document.path.hashCode().toString() && + metadata["fileSize"] == sourceFile.length().toString() && + metadata["lastModified"] == sourceFile.lastModified().toString() && + metadata["pageCount"] == document.pageCount.toString() + if (!isFresh) return 0 + + val decoder = Base64.getDecoder() + lines.asSequence() + .filter { it.startsWith("page\t") } + .forEach { line -> + val parts = line.split('\t', limit = 3) + val pageIndex = parts.getOrNull(1)?.toIntOrNull() ?: return@forEach + val text = runCatching { + String(decoder.decode(parts.getOrNull(2).orEmpty()), Charsets.UTF_8) + }.getOrDefault("") + document.cacheSearchTextPage(pageIndex, text) + } + return document.indexedSearchTextPageCount() +} + +private fun saveDesktopPdfSearchIndex(document: DesktopPdfDocument, indexFile: File) { + val sourceFile = File(document.path) + val pages = document.indexedSearchPages() + if (pages.isEmpty()) return + val encoder = Base64.getEncoder() + val payload = buildString { + appendLine(DesktopPdfSearchIndexHeader) + appendLine("pathHash\t${document.path.hashCode()}") + appendLine("fileSize\t${sourceFile.length()}") + appendLine("lastModified\t${sourceFile.lastModified()}") + appendLine("pageCount\t${document.pageCount}") + pages.forEach { page -> + append("page\t") + append(page.pageIndex) + append('\t') + appendLine(encoder.encodeToString(page.text.toByteArray(Charsets.UTF_8))) + } + } + runCatching { + indexFile.parentFile?.mkdirs() + indexFile.writeText(payload, Charsets.UTF_8) + } +} + +private const val DesktopPdfSearchIndexHeader = "EpistemePdfSearchIndex\t1" + @Composable private fun ReaderScreen( session: ReaderSessionState, readerEngine: ReaderEngine, onSessionChange: (ReaderSessionState) -> Unit, - onOpenEpub: () -> Unit, + onOpenBook: () -> Unit, onOpenPdf: () -> Unit, + toolbarPreferences: ReaderToolbarPreferences, + onToolbarPreferencesChange: (ReaderToolbarPreferences) -> Unit, + highlightPalette: ReaderHighlightPalette, + onHighlightPaletteChange: (ReaderHighlightPalette) -> Unit, + ttsReplacementPreferences: ReaderTtsReplacementPreferences, + ttsReplacementBookId: String?, + onTtsReplacementPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit, + onPickCustomFont: () -> String?, + customFonts: List, + readerExtrasState: ReaderExtrasState, + aiByokSettings: ReaderAiByokSettings, + onExternalLookup: (ReaderExternalLookupAction, String) -> Unit, + onAiAction: (ReaderAiFeature, String) -> Unit, + onCloudTtsToggle: (String) -> Unit, + onCloudTtsStart: (ReaderTtsReadScope, List) -> Unit, + onCloudTtsPauseResume: () -> Unit, + onCloudTtsStop: () -> Unit, + onCloudTtsClearCache: () -> Unit, + onAutoScrollChange: (ReaderAutoScrollState) -> Unit, + readerTextureDataUri: (String) -> String?, + readerCustomTextureIds: List, + onImportReaderTexture: ((ReaderSettings) -> ReaderSettings?)?, webViewRuntimeState: DesktopWebViewRuntimeState ) { - val readerState = session.reader - val page = readerState.currentPage - val settings = readerState.settings - val background = if (settings.darkMode) Color(0xFF171A17) else Color(0xFFFFFCF5) - val foreground = if (settings.darkMode) Color(0xFFE7E3D8) else Color(0xFF24231F) - val searchHighlight = if (settings.darkMode) Color(0xFF675A00) else Color(0xFFFFE36E) - val textAlign = settings.textAlign.toComposeTextAlign() - val fontFamily = settings.fontFamily.toComposeFontFamily() - val verticalListState = rememberLazyListState() + var externalLinkDialogUrl by remember { mutableStateOf(null) } + var lastHandledLink by remember { mutableStateOf(null) } - LaunchedEffect(settings.readingMode, page?.chapterIndex) { - if (settings.readingMode == ReaderReadingMode.VERTICAL && page != null) { - verticalListState.animateScrollToItem(page.chapterIndex) - } - } + DesktopExternalLinkDialog( + url = externalLinkDialogUrl, + onDismiss = { externalLinkDialogUrl = null } + ) - ScreenScaffold( - title = readerState.book.title, - subtitle = listOfNotNull(readerState.book.author, page?.chapterTitle).joinToString(" - "), - trailing = { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - TextButton(onClick = onOpenEpub) { - Text("Open EPUB") - } - TextButton(onClick = onOpenPdf) { - Text("Open PDF") - } - Text("${readerState.progress.toInt()}%") - IconButton(onClick = { onSessionChange(readerEngine.toggleBookmark(session)) }) { - Icon( - if (session.currentBookmark == null) Icons.Default.BookmarkBorder else Icons.Default.Bookmark, - contentDescription = "Bookmark" - ) - } - TextButton( - onClick = { - onSessionChange(session.copy(reader = readerState.copy(settings = settings.copy(darkMode = !settings.darkMode)))) - } - ) { - Text(if (settings.darkMode) "Light" else "Dark") - } - } - } - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), + SharedReaderScreen( + session = session, + readerEngine = readerEngine, + onSessionChange = onSessionChange, + onOpenBook = onOpenBook, + onOpenPdf = onOpenPdf, + toolbarPreferences = toolbarPreferences, + onToolbarPreferencesChange = onToolbarPreferencesChange, + highlightPalette = highlightPalette, + onHighlightPaletteChange = onHighlightPaletteChange, + ttsReplacementPreferences = ttsReplacementPreferences, + ttsReplacementBookId = ttsReplacementBookId, + onTtsReplacementPreferencesChange = onTtsReplacementPreferencesChange, + onPickCustomFont = onPickCustomFont, + customFonts = customFonts, + readerExtrasState = readerExtrasState, + aiByokSettings = aiByokSettings, + onExternalLookup = onExternalLookup, + onAiAction = onAiAction, + onCloudTtsStart = onCloudTtsStart, + onCloudTtsPauseResume = onCloudTtsPauseResume, + onCloudTtsStop = onCloudTtsStop, + onCloudTtsClearCache = onCloudTtsClearCache, + onAutoScrollChange = onAutoScrollChange, + readerTextureDataUri = readerTextureDataUri, + readerCustomTextureIds = readerCustomTextureIds, + onImportReaderTexture = onImportReaderTexture + ) { html, background, navigationTarget, highlights, onVisiblePageChanged -> + Surface( + color = background, + shape = RoundedCornerShape(8.dp), modifier = Modifier - .fillMaxSize() - .onPreviewKeyEvent { event -> - if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false - when { - event.key == Key.DirectionRight || event.key == Key.PageDown -> { - onSessionChange(readerEngine.next(session)) - true - } - - event.key == Key.DirectionLeft || event.key == Key.PageUp -> { - onSessionChange(readerEngine.previous(session)) - true - } - - event.key == Key.MoveHome -> { - onSessionChange(readerEngine.goToPage(session, 0)) - true - } - - event.key == Key.MoveEnd -> { - onSessionChange(readerEngine.goToPage(session, readerState.pages.lastIndex)) - true - } - - event.isCtrlPressed && event.key == Key.G -> { - onSessionChange(readerEngine.nextSearchResult(session)) - true - } - - else -> false - } - } - .focusable() + .fillMaxWidth() + .weight(1f) ) { - ReaderSidebar( - session = session, - onSearchChange = { onSessionChange(readerEngine.search(session, it)) }, - onPreviousSearchResult = { onSessionChange(readerEngine.previousSearchResult(session)) }, - onNextSearchResult = { onSessionChange(readerEngine.nextSearchResult(session)) }, - onGoToChapter = { onSessionChange(readerEngine.goToChapter(session, it)) }, - onGoToPage = { onSessionChange(readerEngine.goToPage(session, it)) } - ) - - Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(12.dp)) { - ReaderSettingsBar( - session = session, - readerEngine = readerEngine, - onSessionChange = onSessionChange - ) - - Surface( - color = background, - shape = RoundedCornerShape(8.dp), - modifier = Modifier - .fillMaxWidth() - .weight(1f) - ) { - val html = if (settings.readingMode == ReaderReadingMode.VERTICAL) { - ReaderHtmlDocumentBuilder.verticalDocument( - book = readerState.book, - settings = settings, - searchQuery = session.searchQuery - ) - } else { - ReaderHtmlDocumentBuilder.pageDocument( - book = readerState.book, - page = page, - settings = settings, - searchQuery = session.searchQuery - ) - } - if (webViewRuntimeState.initialized) { - DesktopEpubWebView( - html = html, - modifier = Modifier.fillMaxSize() - ) - } else { - DesktopWebViewRuntimeIndicator( - state = webViewRuntimeState, - modifier = Modifier.fillMaxSize() - ) - } - } - - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Slider( - value = if (readerState.pages.size <= 1) 0f else readerState.currentPageIndex.toFloat() / readerState.pages.lastIndex, - onValueChange = { progress -> onSessionChange(readerEngine.goToProgress(session, progress)) }, - enabled = readerState.pages.size > 1 - ) - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Button( - enabled = readerState.canGoPrevious, - onClick = { onSessionChange(readerEngine.previous(session)) } - ) { - Icon(Icons.AutoMirrored.Filled.NavigateBefore, contentDescription = null) - Text("Previous") - } - Spacer(Modifier.weight(1f)) - Text( - if (settings.readingMode == ReaderReadingMode.VERTICAL) { - "Continuous mode - page ${readerState.currentPageIndex + 1} of ${readerState.pages.size}" - } else { - "Page ${readerState.currentPageIndex + 1} of ${readerState.pages.size}" + if (webViewRuntimeState.initialized) { + DesktopEpubWebView( + html = html, + navigationTarget = navigationTarget, + highlights = highlights, + onHighlightCreated = { highlight -> + onSessionChange(session.reduce(ReaderAction.HighlightCreated(highlight), readerEngine)) + }, + onSelectionAction = { action, text -> + val settings = aiByokSettings.sanitized() + when (action) { + DesktopReaderSelectionAction.DEFINE -> { + if (settings.areReaderAiFeaturesAvailable) onAiAction(ReaderAiFeature.DEFINE, text) } - ) - Spacer(Modifier.weight(1f)) - Button( - enabled = readerState.canGoNext, - onClick = { onSessionChange(readerEngine.next(session)) } - ) { - Text("Next") - Icon(Icons.AutoMirrored.Filled.NavigateNext, contentDescription = null) + DesktopReaderSelectionAction.SPEAK -> { + if (settings.isCloudTtsAvailable) onCloudTtsToggle(text) + } + DesktopReaderSelectionAction.DICTIONARY -> onExternalLookup(ReaderExternalLookupAction.DICTIONARY, text) + DesktopReaderSelectionAction.TRANSLATE -> onExternalLookup(ReaderExternalLookupAction.TRANSLATE, text) + DesktopReaderSelectionAction.SEARCH -> onExternalLookup(ReaderExternalLookupAction.SEARCH, text) } - } - } + }, + onLinkClicked = { link -> + val now = System.currentTimeMillis() + val last = lastHandledLink + if (last != null && last.href == link.href && now - last.handledAtMs < 900L) { + logEpubLink( + "click_duplicate_ignored source=${link.source} href=\"${link.href.logPreview()}\" " + + "ageMs=${now - last.handledAtMs}" + ) + } else { + lastHandledLink = DesktopEpubHandledLink(link.href, now) + logEpubLink( + "click source=${link.source} href=\"${link.href.logPreview()}\" " + + "chapterIndex=${link.chapterIndex} chapterHref=\"${link.chapterHref.orEmpty().logPreview()}\" " + + "text=\"${link.text.orEmpty().logPreview()}\"" + ) + when (val target = readerEngine.resolveLink(session, link.href, link.chapterIndex)) { + is ReaderLinkTarget.External -> { + logEpubLink("resolved_external url=\"${target.url.logPreview()}\"") + externalLinkDialogUrl = target.url + } + is ReaderLinkTarget.Internal -> { + logEpubLink( + "resolved_internal chapter=${target.locator.chapterIndex} " + + "page=${target.locator.pageIndex} offset=${target.locator.startOffset}" + ) + onSessionChange(readerEngine.goToLocator(session, target.locator)) + } + ReaderLinkTarget.Ignored -> { + logEpubLink("resolved_ignored href=\"${link.href.logPreview()}\"") + } + } + } + }, + onVisiblePageChanged = onVisiblePageChanged, + modifier = Modifier.fillMaxSize() + ) + } else { + DesktopWebViewRuntimeIndicator( + state = webViewRuntimeState, + modifier = Modifier.fillMaxSize() + ) } } } @@ -1837,8 +6902,128 @@ private fun ReaderScreen( @Composable private fun DesktopEpubWebView( html: String, + navigationTarget: ReaderContentNavigationTarget, + highlights: List, + onHighlightCreated: (UserHighlight) -> Unit, + onSelectionAction: (DesktopReaderSelectionAction, String) -> Unit, + onLinkClicked: (DesktopEpubLinkClick) -> Unit, + onVisiblePageChanged: (Int, ReaderLocator?) -> Unit, modifier: Modifier = Modifier ) { + val latestOnHighlightCreated by rememberUpdatedState(onHighlightCreated) + val latestOnSelectionAction by rememberUpdatedState(onSelectionAction) + val latestOnLinkClicked by rememberUpdatedState(onLinkClicked) + val latestOnVisiblePageChanged by rememberUpdatedState(onVisiblePageChanged) + val scope = rememberCoroutineScope() + val linkRequestInterceptor = remember(scope) { + object : RequestInterceptor { + override fun onInterceptUrlRequest( + request: WebRequest, + navigator: WebViewNavigator + ): WebRequestInterceptResult { + if (!request.isForMainFrame) return WebRequestInterceptResult.Allow + val link = request.url.readerLinkClickFromIntercept() ?: return WebRequestInterceptResult.Allow + logEpubLink( + "request_intercept method=${request.method} redirect=${request.isRedirect} " + + "url=\"${request.url.logPreview()}\" href=\"${link.href.logPreview()}\"" + ) + scope.launch { + latestOnLinkClicked(link.copy(source = "request")) + } + return WebRequestInterceptResult.Reject + } + } + } + val navigator = rememberWebViewNavigator(requestInterceptor = linkRequestInterceptor) + val bridge = rememberWebViewJsBridge() + + DisposableEffect(bridge) { + val highlightHandler = object : IJsMessageHandler { + override fun methodName(): String = "readerHighlightCreated" + + override fun handle( + message: JsMessage, + navigator: WebViewNavigator?, + callback: (String) -> Unit + ) { + EpubAnnotationSerializer.parseHighlightJsonLenient(message.params)?.let { highlight -> + scope.launch { latestOnHighlightCreated(highlight) } + } + } + } + val positionHandler = object : IJsMessageHandler { + override fun methodName(): String = "readerPositionChanged" + + override fun handle( + message: JsMessage, + navigator: WebViewNavigator?, + callback: (String) -> Unit + ) { + message.params.readerPositionOrNull()?.let { position -> + scope.launch { latestOnVisiblePageChanged(position.pageIndex, position.locator) } + } + } + } + val selectionActionHandler = object : IJsMessageHandler { + override fun methodName(): String = "readerSelectionAction" + + override fun handle( + message: JsMessage, + navigator: WebViewNavigator?, + callback: (String) -> Unit + ) { + val selectionAction = message.params.readerSelectionActionOrNull() + if (selectionAction != null) { + scope.launch { latestOnSelectionAction(selectionAction.action, selectionAction.text) } + } + } + } + val ttsHighlightLogHandler = object : IJsMessageHandler { + override fun methodName(): String = "readerTtsHighlightLog" + + override fun handle( + message: JsMessage, + navigator: WebViewNavigator?, + callback: (String) -> Unit + ) { + logDesktopTts("epub_highlight_js ${message.params.logPreview(500)}") + } + } + val linkHandler = object : IJsMessageHandler { + override fun methodName(): String = "readerLinkClicked" + + override fun handle( + message: JsMessage, + navigator: WebViewNavigator?, + callback: (String) -> Unit + ) { + logEpubLink("bridge_message params=\"${message.params.logPreview()}\"") + val link = message.params.readerLinkClickOrNull() + if (link == null) { + logEpubLink("bridge_message_ignored reason=parse_failed") + } else { + logEpubLink( + "bridge_message_parsed href=\"${link.href.logPreview()}\" " + + "chapterIndex=${link.chapterIndex} chapterHref=\"${link.chapterHref.orEmpty().logPreview()}\"" + ) + scope.launch { latestOnLinkClicked(link) } + } + } + } + bridge.register(highlightHandler) + bridge.register(positionHandler) + bridge.register(selectionActionHandler) + bridge.register(ttsHighlightLogHandler) + bridge.register(linkHandler) + onDispose { + bridge.unregister(highlightHandler) + bridge.unregister(positionHandler) + bridge.unregister(selectionActionHandler) + bridge.unregister(ttsHighlightLogHandler) + bridge.unregister(linkHandler) + } + } + key(html) { val state = rememberWebViewStateWithHTMLData( data = html, @@ -1852,9 +7037,70 @@ private fun DesktopEpubWebView( WebView( state = state, modifier = Modifier.fillMaxSize(), - captureBackPresses = false + captureBackPresses = false, + navigator = navigator, + webViewJsBridge = bridge ) + LaunchedEffect( + navigationTarget.autoScroll, + navigationTarget.readingMode, + state.loadingState + ) { + if (navigationTarget.readingMode != com.aryan.reader.shared.reader.ReaderReadingMode.VERTICAL) return@LaunchedEffect + if (state.loadingState !is LoadingState.Finished) return@LaunchedEffect + val autoScroll = navigationTarget.autoScroll.sanitized() + val command = if (autoScroll.enabled) { + "window.readerAutoScroll && window.readerAutoScroll.start(${autoScroll.speed});" + } else { + "window.readerAutoScroll && window.readerAutoScroll.stop();" + } + navigator.evaluateJavaScript(command) + } + + LaunchedEffect( + navigationTarget.requestId, + navigationTarget.readingMode, + state.loadingState + ) { + if (navigationTarget.readingMode != com.aryan.reader.shared.reader.ReaderReadingMode.VERTICAL) return@LaunchedEffect + if (state.loadingState !is LoadingState.Finished) return@LaunchedEffect + val locator = navigationTarget.locator ?: return@LaunchedEffect + navigator.evaluateJavaScript("window.readerScrollToLocator && window.readerScrollToLocator(${locator.toReaderLocatorJson()});") + } + + LaunchedEffect( + navigationTarget.ttsRequestId, + navigationTarget.ttsLocator, + navigationTarget.readingMode, + state.loadingState + ) { + if (state.loadingState !is LoadingState.Finished) return@LaunchedEffect + val locator = navigationTarget.ttsLocator + val command = if (locator == null) { + logDesktopTts( + "epub_highlight_command clear mode=${navigationTarget.readingMode} request=${navigationTarget.ttsRequestId}" + ) + "window.readerSetTtsLocator && window.readerSetTtsLocator(null, false);" + } else { + val follow = navigationTarget.readingMode == com.aryan.reader.shared.reader.ReaderReadingMode.VERTICAL + logDesktopTts( + "epub_highlight_command set mode=${navigationTarget.readingMode} request=${navigationTarget.ttsRequestId} " + + "follow=$follow chapter=${locator.chapterIndex} page=${locator.pageIndex} " + + "offsets=${locator.startOffset}..${locator.endOffset} cfi=\"${locator.cfi.orEmpty().logPreview()}\" " + + "text=\"${locator.textQuote.orEmpty().logPreview()}\"" + ) + "window.readerSetTtsLocator && window.readerSetTtsLocator(${locator.toReaderLocatorJson()}, $follow);" + } + navigator.evaluateJavaScript(command) + } + + LaunchedEffect(highlights, navigationTarget.readingMode, state.loadingState) { + if (navigationTarget.readingMode != com.aryan.reader.shared.reader.ReaderReadingMode.VERTICAL) return@LaunchedEffect + if (state.loadingState !is LoadingState.Finished) return@LaunchedEffect + navigator.evaluateJavaScript("window.readerApplyHighlights && window.readerApplyHighlights(${EpubAnnotationSerializer.highlightsToJson(highlights)});") + } + val loadingState = state.loadingState if (loadingState is LoadingState.Loading) { LinearProgressIndicator( @@ -1866,6 +7112,207 @@ private fun DesktopEpubWebView( } } +private data class DesktopReaderPosition( + val pageIndex: Int, + val locator: ReaderLocator? +) + +private data class DesktopEpubLinkClick( + val href: String, + val chapterIndex: Int?, + val text: String? = null, + val chapterId: String? = null, + val chapterHref: String? = null, + val source: String = "bridge" +) + +private data class DesktopEpubHandledLink( + val href: String, + val handledAtMs: Long +) + +private enum class DesktopReaderSelectionAction { + DEFINE, + SPEAK, + DICTIONARY, + TRANSLATE, + SEARCH +} + +private data class DesktopReaderSelectionActionPayload( + val action: DesktopReaderSelectionAction, + val text: String +) + +private fun String.readerSelectionActionOrNull(): DesktopReaderSelectionActionPayload? { + fun parse(rawJson: String): DesktopReaderSelectionActionPayload? = runCatching { + val obj = Json.parseToJsonElement(rawJson).jsonObject + val text = obj["text"] + ?.takeUnless { it is JsonNull } + ?.jsonPrimitive + ?.contentOrNull + ?.takeIf { it.isNotBlank() } + ?: return@runCatching null + val action = when ( + obj["action"] + ?.takeUnless { it is JsonNull } + ?.jsonPrimitive + ?.contentOrNull + ?.lowercase() + ) { + "define" -> DesktopReaderSelectionAction.DEFINE + "speak" -> DesktopReaderSelectionAction.SPEAK + "dictionary" -> DesktopReaderSelectionAction.DICTIONARY + "translate" -> DesktopReaderSelectionAction.TRANSLATE + "web-search", "search" -> DesktopReaderSelectionAction.SEARCH + else -> return@runCatching null + } + DesktopReaderSelectionActionPayload(action, text) + }.getOrNull() + + parse(this)?.let { return it } + return runCatching { + Json.parseToJsonElement(this).jsonPrimitive.contentOrNull + }.getOrNull()?.let { parse(it) } +} + +private fun String.readerPositionOrNull(): DesktopReaderPosition? { + fun parse(rawJson: String): DesktopReaderPosition? = runCatching { + val obj = Json.parseToJsonElement(rawJson).jsonObject + val pageIndex = obj["pageIndex"] + ?.takeUnless { it is JsonNull } + ?.jsonPrimitive + ?.intOrNull + ?: return@runCatching null + val locator = ReaderLocator( + chapterIndex = obj["chapterIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, + pageIndex = pageIndex, + startOffset = obj["startOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, + endOffset = obj["endOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, + textQuote = obj["textQuote"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull, + cfi = obj["cfi"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull + ) + DesktopReaderPosition(pageIndex, locator) + }.getOrNull() + + parse(this)?.let { return it } + return runCatching { + Json.parseToJsonElement(this).jsonPrimitive.contentOrNull + }.getOrNull()?.let { parse(it) } +} + +private fun String.readerLinkClickOrNull(): DesktopEpubLinkClick? { + fun parse(rawJson: String): DesktopEpubLinkClick? = runCatching { + val obj = Json.parseToJsonElement(rawJson).jsonObject + val href = obj["href"] + ?.takeUnless { it is JsonNull } + ?.jsonPrimitive + ?.contentOrNull + ?.takeIf { it.isNotBlank() } + ?: return@runCatching null + DesktopEpubLinkClick( + href = href, + chapterIndex = obj["chapterIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, + text = obj["text"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull, + chapterId = obj["chapterId"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull, + chapterHref = obj["chapterHref"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull + ) + }.getOrNull() + + parse(this)?.let { return it } + return runCatching { + Json.parseToJsonElement(this).jsonPrimitive.contentOrNull + }.getOrNull()?.let { parse(it) } +} + +private fun String.readerLinkClickFromIntercept(): DesktopEpubLinkClick? { + val trimmed = trim() + if (trimmed.startsWith("readerlink:", ignoreCase = true)) { + logEpubLink("request_intercept_readerlink raw=\"${trimmed.logPreview()}\"") + val payload = trimmed.substringAfter("?", missingDelimiterValue = "") + .split('&') + .firstOrNull { it.substringBefore("=").equals("payload", ignoreCase = true) } + ?.substringAfter("=", missingDelimiterValue = "") + ?.takeIf { it.isNotBlank() } + if (payload == null) { + logEpubLink("request_intercept_readerlink_ignored reason=missing_payload") + return null + } + val decoded = runCatching { + URLDecoder.decode(payload, Charsets.UTF_8.name()) + }.getOrElse { + logEpubLink("request_intercept_payload_decode_failed error=\"${it.message.orEmpty().logPreview()}\"") + return null + } + val link = decoded.readerLinkClickOrNull()?.copy(source = "request") + if (link == null) { + logEpubLink("request_intercept_readerlink_ignored reason=parse_failed payload=\"${decoded.logPreview()}\"") + } + return link + } + return readerHrefFromIntercept()?.let { href -> + DesktopEpubLinkClick( + href = href, + chapterIndex = null, + source = "request" + ) + } +} + +private fun String.readerHrefFromIntercept(): String? { + val trimmed = trim() + if (trimmed.isBlank()) return null + if (trimmed.equals("about:blank", ignoreCase = true)) return null + if (trimmed.startsWith("file:///kcefbrowser/", ignoreCase = true)) return null + if (trimmed.startsWith("file:/kcefbrowser/", ignoreCase = true)) return null + if (trimmed.startsWith("file://", ignoreCase = true)) return null + if (trimmed.startsWith("about:blank#", ignoreCase = true)) return "#${trimmed.substringAfter('#')}" + if (trimmed.startsWith("data:", ignoreCase = true)) return null + if (trimmed.startsWith("blob:", ignoreCase = true)) return null + return trimmed +} + +private fun ReaderLocator.toReaderLocatorJson(): String { + return buildString { + append("{") + val values = buildList { + chapterIndex?.let { add("\"chapterIndex\":$it") } + pageIndex?.let { add("\"pageIndex\":$it") } + startOffset?.let { add("\"startOffset\":$it") } + endOffset?.let { add("\"endOffset\":$it") } + cfi?.let { add("\"cfi\":${it.toJsonStringLiteral()}") } + textQuote?.let { add("\"textQuote\":${it.toJsonStringLiteral()}") } + } + append(values.joinToString(",")) + append("}") + } +} + +private fun String.toJsonStringLiteral(): String { + val builder = StringBuilder("\"") + forEach { char -> + when (char) { + '\\' -> builder.append("\\\\") + '"' -> builder.append("\\\"") + '\n' -> builder.append("\\n") + '\r' -> builder.append("\\r") + '\t' -> builder.append("\\t") + '\b' -> builder.append("\\b") + '\u000C' -> builder.append("\\f") + else -> { + if (char.code < 0x20) { + builder.append("\\u") + builder.append(char.code.toString(16).padStart(4, '0')) + } else { + builder.append(char) + } + } + } + } + builder.append('"') + return builder.toString() +} + @Composable private fun DesktopWebViewRuntimeIndicator( state: DesktopWebViewRuntimeState, @@ -1904,94 +7351,6 @@ private fun DesktopWebViewRuntimeIndicator( } } -@Composable -private fun ReaderSettingsBar( - session: ReaderSessionState, - readerEngine: ReaderEngine, - onSessionChange: (ReaderSessionState) -> Unit -) { - val settings = session.reader.settings - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - FilterChip( - selected = settings.readingMode == ReaderReadingMode.PAGINATED, - onClick = { - onSessionChange(readerEngine.updateSettings(session, settings.copy(readingMode = ReaderReadingMode.PAGINATED))) - }, - label = { Text("Pages") } - ) - FilterChip( - selected = settings.readingMode == ReaderReadingMode.VERTICAL, - onClick = { - onSessionChange(readerEngine.updateSettings(session, settings.copy(readingMode = ReaderReadingMode.VERTICAL))) - }, - label = { Text("Vertical") } - ) - FilterChip( - selected = settings.textAlign == SharedReaderTextAlign.START, - onClick = { onSessionChange(readerEngine.updateSettings(session, settings.copy(textAlign = SharedReaderTextAlign.START))) }, - label = { Text("Left") } - ) - FilterChip( - selected = settings.textAlign == SharedReaderTextAlign.JUSTIFY, - onClick = { onSessionChange(readerEngine.updateSettings(session, settings.copy(textAlign = SharedReaderTextAlign.JUSTIFY))) }, - label = { Text("Justify") } - ) - FilterChip( - selected = settings.textAlign == SharedReaderTextAlign.CENTER, - onClick = { onSessionChange(readerEngine.updateSettings(session, settings.copy(textAlign = SharedReaderTextAlign.CENTER))) }, - label = { Text("Center") } - ) - listOf("Default", "Serif", "Sans", "Mono").forEach { family -> - FilterChip( - selected = settings.fontFamily == family, - onClick = { onSessionChange(readerEngine.updateSettings(session, settings.copy(fontFamily = family))) }, - label = { Text(family) } - ) - } - } - - Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { - Text("Font ${settings.fontSize}") - Slider( - value = settings.fontSize.toFloat(), - onValueChange = { value -> - onSessionChange(readerEngine.updateSettings(session, settings.copy(fontSize = value.toInt()))) - }, - valueRange = 14f..30f, - modifier = Modifier.width(140.dp) - ) - Text("Margin ${settings.margin}") - Slider( - value = settings.margin.toFloat(), - onValueChange = { value -> - onSessionChange(readerEngine.updateSettings(session, settings.copy(margin = value.toInt()))) - }, - valueRange = 16f..112f, - modifier = Modifier.width(140.dp) - ) - Text("Spacing ${String.format("%.2f", settings.lineSpacing)}") - Slider( - value = settings.lineSpacing, - onValueChange = { value -> - onSessionChange(readerEngine.updateSettings(session, settings.copy(lineSpacing = value))) - }, - valueRange = 1.1f..2.1f, - modifier = Modifier.width(140.dp) - ) - Text("Width ${settings.pageWidth}") - Slider( - value = settings.pageWidth.toFloat(), - onValueChange = { value -> - onSessionChange(readerEngine.updateSettings(session, settings.copy(pageWidth = value.toInt()))) - }, - valueRange = 520f..1100f, - modifier = Modifier.width(140.dp) - ) - } - } -} - private fun String.highlightQuery(query: String, color: Color): AnnotatedString { val normalized = query.trim() if (normalized.length < 2) return AnnotatedString(this) @@ -2215,6 +7574,10 @@ private fun String.toComposeFontFamily(): FontFamily { } } +private fun CustomFontItem.toDesktopPreviewFontFamily(): FontFamily? { + return runCatching { FontFamily(DesktopFont(File(path))) }.getOrNull() +} + @Composable private fun ReaderSidebar( session: ReaderSessionState, @@ -2314,7 +7677,7 @@ private fun ReaderSidebar( Text("No matches", color = MaterialTheme.colorScheme.onSurfaceVariant) } } else { - items(session.searchResults, key = { "${it.pageIndex}_${it.preview}" }) { result -> + items(session.searchResults, key = { "${it.pageIndex}_${it.matchIndex}_${it.preview}" }) { result -> Surface( color = MaterialTheme.colorScheme.surface, shape = RoundedCornerShape(6.dp), @@ -2363,9 +7726,9 @@ private fun chooseFiles(): List { return dialog.files.orEmpty().map { it.toImportedBookFile() } } -private fun chooseEpubFile(): File? { - val dialog = FileDialog(null as Frame?, "Open EPUB", FileDialog.LOAD).apply { - file = "*.epub" +private fun chooseBookFile(): File? { + val dialog = FileDialog(null as Frame?, "Open Book", FileDialog.LOAD).apply { + file = DesktopBookFileDialogPattern isVisible = true } val directory = dialog.directory ?: return null @@ -2383,10 +7746,108 @@ private fun choosePdfFile(): File? { return File(directory, file) } +private fun chooseFontFile(): File? { + val dialog = FileDialog(null as Frame?, "Choose font", FileDialog.LOAD).apply { + file = "*.ttf;*.otf;*.woff2" + isVisible = true + } + val directory = dialog.directory ?: return null + val file = dialog.file ?: return null + return File(directory, file) +} + +private fun chooseReaderTextureFile(): File? { + val dialog = FileDialog(null as Frame?, "Choose reader texture", FileDialog.LOAD).apply { + file = "*.png;*.jpg;*.jpeg;*.webp;*.gif;*.bmp" + isVisible = true + } + val directory = dialog.directory ?: return null + val file = dialog.file ?: return null + return File(directory, file) +} + +private fun chooseFolder(): File? { + val chooser = JFileChooser().apply { + dialogTitle = "Import folder" + fileSelectionMode = JFileChooser.DIRECTORIES_ONLY + isAcceptAllFileFilterUsed = false + } + return if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) { + chooser.selectedFile + } else { + null + } +} + private fun SharedReaderScreenState.withBanner(message: String, isError: Boolean = false): SharedReaderScreenState { return reduce(AppAction.BannerShown(BannerMessage(message, isError = isError))) } +private val DesktopReadableFileTypes = SharedFileCapabilities.readableTypesFor(ReaderPlatform.DESKTOP) +private val DesktopSyncableFileTypes = SharedFileCapabilities.syncableTypesFor(ReaderPlatform.DESKTOP) +private val DesktopBookFileTypes = SharedFileCapabilities.all + .filter { capability -> + capability.type in DesktopReadableFileTypes && capability.type != FileType.PDF + } + .mapTo(mutableSetOf()) { it.type } +private val DesktopBookFileDialogPattern = SharedFileCapabilities.all + .filter { it.type in DesktopBookFileTypes } + .flatMap { capability -> capability.extensions.map { extension -> "*.$extension" } } + .joinToString(";") + +private const val EpistemeSourceUrl = "https://github.com/Aryan-Raj3112/episteme" +private const val EpistemeIssuesUrl = "https://github.com/Aryan-Raj3112/episteme/issues" +private const val EpistemeGitHubSponsorsUrl = "https://github.com/sponsors/Aryan-Raj3112" +private const val EpistemePatreonUrl = "https://www.patreon.com/c/epistemereader" +private const val EpistemeSupportEmail = "epistemereader@gmail.com" +private const val EpistemeFeedbackSubject = "Feedback: Episteme Reader" + +private fun desktopAppVersionName(): String { + return EpistemeDesktopAppVersion::class.java.getPackage()?.implementationVersion + ?.let { "Version $it" } + ?: "Desktop development build" +} + +private object EpistemeDesktopAppVersion + +private fun ImportedBookFile.desktopFileType(): FileType { + return SharedFileCapabilities.fileTypeForName(name) +} + +private fun mergeSyncedFolders( + existing: List, + folderRoots: List, + nowMillis: Long +): List { + if (folderRoots.isEmpty()) return existing + val byRoot = existing.associateBy { it.uriString }.toMutableMap() + folderRoots.forEach { root -> + val rootFile = File(root) + byRoot[root] = SyncedFolder( + uriString = root, + name = rootFile.name.takeIf { it.isNotBlank() } ?: root, + lastScanTime = nowMillis, + allowedFileTypes = DesktopSyncableFileTypes + ) + } + return byRoot.values.sortedBy { it.name.lowercase() } +} + +private object DesktopFolderPathResolver : SharedFolderPathResolver { + override fun relativeFolderSegments(item: BookItem): List { + val sourceFolder = item.sourceFolder ?: return emptyList() + val bookPath = item.path ?: return emptyList() + val parentFile = File(bookPath).parentFile ?: return emptyList() + val paths = runCatching { + File(sourceFolder).toPath().toAbsolutePath().normalize() to + parentFile.toPath().toAbsolutePath().normalize() + }.getOrNull() ?: return emptyList() + val (root, parent) = paths + if (!parent.startsWith(root) || parent == root) return emptyList() + return root.relativize(parent).map { it.toString() }.filter { it.isNotBlank() } + } +} + private fun List.collectTags(): List { return flatMap { it.tags }.distinctBy { it.id }.sortedBy { it.name.lowercase() } } @@ -2405,25 +7866,164 @@ private fun Long.toReadableSize(): String { unitIndex += 1 } return if (unitIndex == 0) { - "${this} ${units[unitIndex]}" + "$this ${units[unitIndex]}" } else { "${String.format("%.1f", value)} ${units[unitIndex]}" } } -private fun File.toImportedBookFile(): ImportedBookFile { +private fun File.toImportedBookFile(sourceFolder: String? = null): ImportedBookFile { return ImportedBookFile( name = name, uriString = null, localPath = absolutePath, - size = length() + size = length(), + sourceFolder = sourceFolder ) } -private fun String.previewAround(index: Int, queryLength: Int): String { - val start = (index - 70).coerceAtLeast(0) - val end = (index + queryLength + 100).coerceAtMost(length) - val prefix = if (start > 0) "..." else "" - val suffix = if (end < length) "..." else "" - return prefix + substring(start, end).replace(Regex("\\s+"), " ").trim() + suffix +@Composable +private fun DesktopExternalLinkDialog( + url: String?, + onDismiss: () -> Unit +) { + if (url == null) return + val clipboardManager = LocalClipboardManager.current + LaunchedEffect(url) { + logExternalLink("dialog_show url=\"${url.logPreview()}\"") + when (withContext(Dispatchers.IO) { showNativeExternalLinkDialog(url) }) { + DesktopExternalLinkAction.COPY -> { + logExternalLink("dialog_copy url=\"${url.logPreview()}\"") + clipboardManager.setText(AnnotatedString(url)) + } + DesktopExternalLinkAction.OPEN -> { + logExternalLink("dialog_open url=\"${url.logPreview()}\"") + openExternalUrl(url) + } + DesktopExternalLinkAction.DISMISS -> { + logExternalLink("dialog_dismiss url=\"${url.logPreview()}\"") + } + } + onDismiss() + } +} + +private enum class DesktopExternalLinkAction { + COPY, + OPEN, + DISMISS +} + +private fun showNativeExternalLinkDialog(url: String): DesktopExternalLinkAction { + val result = AtomicReference(DesktopExternalLinkAction.DISMISS) + val options = arrayOf("Copy", "Open", "Cancel") + val showDialog = { + val pane = JOptionPane( + "You clicked on an external link:\n\n$url\n\nWhat would you like to do?", + JOptionPane.QUESTION_MESSAGE, + JOptionPane.DEFAULT_OPTION, + null, + options, + options[1] + ) + val dialog = pane.createDialog(null as java.awt.Component?, "External Link") + dialog.isModal = true + dialog.isAlwaysOnTop = true + dialog.isVisible = true + result.set( + when (pane.value) { + options[0] -> DesktopExternalLinkAction.COPY + options[1] -> DesktopExternalLinkAction.OPEN + else -> DesktopExternalLinkAction.DISMISS + } + ) + dialog.dispose() + } + if (SwingUtilities.isEventDispatchThread()) { + showDialog() + } else { + SwingUtilities.invokeAndWait { showDialog() } + } + return result.get() +} + +private fun openExternalUrl(url: String) { + val normalizedUrl = url.normalizedExternalUrl() + runCatching { + if (Desktop.isDesktopSupported()) { + val desktop = Desktop.getDesktop() + if (normalizedUrl.startsWith("mailto:", ignoreCase = true)) { + desktop.mail(URI(normalizedUrl)) + } else { + desktop.browse(URI(normalizedUrl)) + } + logExternalLink("open_system_browser_success url=\"${normalizedUrl.logPreview()}\"") + } else { + logExternalLink("open_system_browser_unavailable url=\"${normalizedUrl.logPreview()}\"") + } + }.onFailure { throwable -> + logExternalLink("open_system_browser_failed url=\"${normalizedUrl.logPreview()}\" error=\"${throwable.message.orEmpty().logPreview()}\"") + } +} + +private fun String.normalizedExternalUrl(): String { + val trimmed = trim() + return if (trimmed.startsWith("www.", ignoreCase = true)) { + "https://$trimmed" + } else { + trimmed + } +} + +private fun String.urlEncode(): String { + return URLEncoder.encode(this, Charsets.UTF_8.name()) +} + +private const val PdfSelectionLogTag = "EpistemePdfSelection" +private const val PdfLinkLogTag = "EpistemePdfLink" +private const val EpubLinkLogTag = "EpistemeEpubLink" +private const val ExternalLinkLogTag = "EpistemeExternalLink" + +private fun logPdfSelection(message: String) { + println("$PdfSelectionLogTag $message") +} + +private fun logPdfLink(message: String) { + println("$PdfLinkLogTag $message") +} + +private fun logEpubLink(message: String) { + println("$EpubLinkLogTag $message") +} + +private fun logExternalLink(message: String) { + println("$ExternalLinkLogTag $message") +} + +private fun DesktopPdfLinkTarget.formatLogTarget(): String { + return "dest=${destPageIndex?.let { it + 1 } ?: "null"} uri=\"${uri.orEmpty().logPreview()}\"" +} + +private fun String.logPreview(maxLength: Int = 96): String { + return replace(Regex("\\s+"), " ") + .trim() + .let { if (it.length <= maxLength) it else it.take(maxLength) + "..." } + .replace("\"", "\\\"") +} + +private fun Float.formatLogFloat(): String { + return String.format("%.3f", this) +} + +private fun IntSize.formatLogSize(): String { + return "${width}x${height}" +} + +private fun DesktopPdfCharHit?.formatLogHit(prefix: String): String { + if (this == null) { + return "${prefix}Index=null ${prefix}Source=none ${prefix}X=null ${prefix}Y=null ${prefix}Nx=null ${prefix}Ny=null" + } + return "${prefix}Index=$index ${prefix}Source=$source " + + "${prefix}X=${point.x.formatLogFloat()} ${prefix}Y=${point.y.formatLogFloat()} " + + "${prefix}Nx=${normalized.x.formatLogFloat()} ${prefix}Ny=${normalized.y.formatLogFloat()}" } diff --git a/desktopApp/src/desktopMain/resources/google_fonts.json b/desktopApp/src/desktopMain/resources/google_fonts.json new file mode 100644 index 0000000..52a81bc --- /dev/null +++ b/desktopApp/src/desktopMain/resources/google_fonts.json @@ -0,0 +1,2083 @@ +[ + "42dot Sans", + "ABeeZee", + "ADLaM Display", + "AR One Sans", + "Abel", + "Abhaya Libre", + "Aboreto", + "Abril Fatface", + "Abyssinica SIL", + "Aclonica", + "Acme", + "Actor", + "Adamina", + "Advent Pro", + "Adwaita Mono", + "Adwaita Sans", + "Afacad", + "Afacad Flux", + "Agbalumo", + "Agdasima", + "Agu Display", + "Aguafina Script", + "Aileron", + "Akatab", + "Akaya Kanadaka", + "Akaya Telivigala", + "Akronim", + "Akshar", + "Aladin", + "Alan Sans", + "Alata", + "Alatsi", + "Albert Sans", + "Aldrich", + "Alef", + "Alegreya", + "Alegreya SC", + "Alegreya Sans", + "Alegreya Sans SC", + "Aleo", + "Alex Brush", + "Alexandria", + "Alfa Slab One", + "Alice", + "Alike", + "Alike Angular", + "Alkalami", + "Alkatra", + "Allan", + "Allerta", + "Allerta Stencil", + "Allison", + "Allkin", + "Allura", + "Almarai", + "Almendra", + "Almendra Display", + "Almendra SC", + "Alumni Sans", + "Alumni Sans Collegiate One", + "Alumni Sans Inline One", + "Alumni Sans Pinstripe", + "Alumni Sans SC", + "Alyamama", + "Amarante", + "Amaranth", + "Amarna", + "Amatic SC", + "Amethysta", + "Amiko", + "Amiri", + "Amiri Quran", + "Amita", + "Anaheim", + "Ancizar Sans", + "Ancizar Serif", + "Andada Pro", + "Andika", + "Anek Bangla", + "Anek Devanagari", + "Anek Gujarati", + "Anek Gurmukhi", + "Anek Kannada", + "Anek Latin", + "Anek Malayalam", + "Anek Odia", + "Anek Tamil", + "Anek Telugu", + "Angkor", + "Annapurna SIL", + "Annie Use Your Telescope", + "Anonymous Pro", + "Anta", + "Antic", + "Antic Didone", + "Antic Slab", + "Anton", + "Anton SC", + "Antonio", + "Anuphan", + "Anybody", + "Aoboshi One", + "Apfel Grotezk", + "Arapey", + "Arbutus", + "Arbutus Slab", + "Architects Daughter", + "Archivo", + "Archivo Black", + "Archivo Narrow", + "Are You Serious", + "Aref Ruqaa", + "Aref Ruqaa Ink", + "Argentum Sans", + "Arima", + "Arima Madurai", + "Arimo", + "Arizonia", + "Armata", + "Arsenal", + "Arsenal SC", + "Artifika", + "Arvo", + "Arya", + "Asap", + "Asap Condensed", + "Asar", + "Asimovian", + "Asset", + "Assistant", + "Asta Sans", + "Astloch", + "Asul", + "Athiti", + "Atkinson Hyperlegible", + "Atkinson Hyperlegible Mono", + "Atkinson Hyperlegible Next", + "Atma", + "Atomic Age", + "Aubrey", + "Audiowide", + "Autour One", + "Average", + "Average Sans", + "Averia Gruesa Libre", + "Averia Libre", + "Averia Sans Libre", + "Averia Serif Libre", + "Azeret Mono", + "B612", + "B612 Mono", + "BBH Bartle", + "BBH Bogle", + "BBH Hegarty", + "BBH Sans Bartle", + "BBH Sans Bogle", + "BBH Sans Hegarty", + "BIZ UDGothic", + "BIZ UDMincho", + "BIZ UDPGothic", + "BIZ UDPMincho", + "BJ Cree", + "BJCree", + "Babylonica", + "Bacasime Antique", + "Bad Script", + "Badeen Display", + "Bagel Fat One", + "Bagnard", + "Bagnard Sans", + "Bahiana", + "Bahianita", + "Bai Jamjuree", + "Bakbak One", + "Ballet", + "Baloo 2", + "Baloo Bhai 2", + "Baloo Bhaijaan 2", + "Baloo Bhaina 2", + "Baloo Chettan 2", + "Baloo Da 2", + "Baloo Paaji 2", + "Baloo Tamma 2", + "Baloo Tammudu 2", + "Baloo Thambi 2", + "Balsamiq Sans", + "Balthazar", + "Bangers", + "Barlow", + "Barlow Condensed", + "Barlow Semi Condensed", + "Barriecito", + "Barrio", + "Basic", + "Baskervville", + "Baskervville SC", + "Battambang", + "Baumans", + "Bayon", + "Be Vietnam Pro", + "Beau Rivage", + "Bebas Neue", + "Beiruti", + "Belanosima", + "Belgrano", + "Bellefair", + "Belleza", + "Bellota", + "Bellota Text", + "BenchNine", + "Benne", + "Bentham", + "Berkshire Swash", + "Besley", + "Betania Patmos", + "Betania Patmos GDL", + "Betania Patmos In", + "Betania Patmos In GDL", + "Beth Ellen", + "Bevan", + "BhuTuka Expanded One", + "Big Shoulders", + "Big Shoulders Display", + "Big Shoulders Inline", + "Big Shoulders Inline Display", + "Big Shoulders Inline Text", + "Big Shoulders Stencil", + "Big Shoulders Stencil Display", + "Big Shoulders Stencil Text", + "Big Shoulders Text", + "Bigelow Rules", + "Bigshot One", + "Bilbo", + "Bilbo Swash Caps", + "BioRhyme", + "BioRhyme Expanded", + "Birthstone", + "Birthstone Bounce", + "Biryani", + "Bitcount", + "Bitcount Grid Double", + "Bitcount Grid Double Ink", + "Bitcount Grid Single", + "Bitcount Grid Single Ink", + "Bitcount Ink", + "Bitcount Prop Double", + "Bitcount Prop Double Ink", + "Bitcount Prop Single", + "Bitcount Prop Single Ink", + "Bitcount Single", + "Bitcount Single Ink", + "Bitter", + "Black And White Picture", + "Black Han Sans", + "Black Ops One", + "Blackout Midnight", + "Blackout Sunrise", + "Blackout Two AM", + "Blaka", + "Blaka Hollow", + "Blaka Ink", + "Blinker", + "Bluu Next", + "Bodoni Moda", + "Bodoni Moda SC", + "Bokor", + "Boldonse", + "Bona Nova", + "Bona Nova SC", + "Bonbon", + "Bonheur Royale", + "Boogaloo", + "Borel", + "Bowlby One", + "Bowlby One SC", + "Bpmf Huninn", + "Bpmf Iansui", + "Bpmf Zihi Kai Std", + "Braah One", + "Bravura", + "Bravura Text", + "Brawler", + "Bree Serif", + "Bricolage Grotesque", + "Briem Hand", + "Bruno Ace", + "Bruno Ace SC", + "Brygada 1918", + "Bubblegum Sans", + "Bubbler One", + "Buda", + "Buenard", + "Bungee", + "Bungee Hairline", + "Bungee Inline", + "Bungee Outline", + "Bungee Shade", + "Bungee Spice", + "Bungee Tint", + "Butcherman", + "Butterfly Kids", + "Bytesized", + "Cabin", + "Cabin Condensed", + "Cabin Sketch", + "Cactus Classical Serif", + "Caesar Dressing", + "Cagliostro", + "Cairo", + "Cairo Play", + "Cal Sans", + "Caladea", + "Calistoga", + "Calligraffitti", + "Cambay", + "Cambo", + "Candal", + "Cantarell", + "Cantata One", + "Cantora One", + "Caprasimo", + "Capriola", + "Caramel", + "Carattere", + "Cardo", + "Carlito", + "Carme", + "Carrois Gothic", + "Carrois Gothic SC", + "Carter One", + "Cascadia Code", + "Cascadia Mono", + "Castoro", + "Castoro Titling", + "Catamaran", + "Caudex", + "Cause", + "Caveat", + "Caveat Brush", + "Cedarville Cursive", + "Ceviche One", + "Chakra Petch", + "Changa", + "Changa One", + "Chango", + "Charis SIL", + "Charm", + "Charmonman", + "Chathura", + "Chau Philomene One", + "Chela One", + "Chelsea Market", + "Chenla", + "Cherish", + "Cherry Bomb One", + "Cherry Cream Soda", + "Cherry Swash", + "Chewy", + "Chicle", + "Chilanka", + "Chiron GoRound TC", + "Chiron Hei HK", + "Chiron Sung HK", + "Chivo", + "Chivo Mono", + "Chocolate Classical Sans", + "Chokokutai", + "Chonburi", + "Chunk Five", + "Cinzel", + "Cinzel Decorative", + "Clear Sans", + "Clicker Script", + "Climate Crisis", + "Coda", + "Coda Caption", + "Codystar", + "Coiny", + "Combo", + "Comfortaa", + "Comforter", + "Comforter Brush", + "Comic Mono", + "Comic Neue", + "Comic Relief", + "Coming Soon", + "Comme", + "Commissioner", + "Commit Mono", + "Concert One", + "Condiment", + "Content", + "Contrail One", + "Convergence", + "Cookie", + "Cooper Hewitt", + "Copse", + "Coral Pixels", + "Corben", + "Corinthia", + "Cormorant", + "Cormorant Garamond", + "Cormorant Infant", + "Cormorant SC", + "Cormorant Unicase", + "Cormorant Upright", + "Cossette Texte", + "Cossette Titre", + "Courgette", + "Courier Prime", + "Cousine", + "Coustard", + "Covered By Your Grace", + "Crafty Girls", + "Creepster", + "Crete Round", + "Crimson Pro", + "Crimson Text", + "Croissant One", + "Crushed", + "Cuprum", + "Cute Font", + "Cutive", + "Cutive Mono", + "DM Mono", + "DM Sans", + "DM Serif Display", + "DM Serif Text", + "DSEG Weather", + "DSEG14 Classic", + "DSEG14 Classic Mini", + "DSEG14 Modern", + "DSEG14 Modern Mini", + "DSEG7 Classic", + "DSEG7 Classic Mini", + "DSEG7 Modern", + "DSEG7 Modern Mini", + "DSEG7 SEGG CHAN", + "DSEG7 SEGG CHAN Mini", + "Dai Banna SIL", + "Damion", + "Dancing Script", + "Danfo", + "Dangrek", + "Darker Grotesque", + "Darumadrop One", + "Datatype", + "David Libre", + "Dawning of a New Day", + "Days One", + "DejaVu Math", + "DejaVu Mono", + "DejaVu Sans", + "DejaVu Serif", + "Dekko", + "Dela Gothic One", + "Delicious Handrawn", + "Delius", + "Delius Swash Caps", + "Delius Unicase", + "Della Respira", + "Denk One", + "Devonshire", + "Dhurjati", + "Didact Gothic", + "Diphylleia", + "Diplomata", + "Diplomata SC", + "Do Hyeon", + "Dokdo", + "Domine", + "Donegal One", + "Dongle", + "Doppio One", + "Dorsa", + "Dosis", + "DotGothic16", + "Doto", + "Dr Sugiyama", + "Duru Sans", + "DynaPuff", + "Dynalight", + "EB Garamond", + "Eagle Lake", + "East Sea Dokdo", + "Eater", + "Economica", + "Eczar", + "Edu AU VIC WA NT Arrows", + "Edu AU VIC WA NT Dots", + "Edu AU VIC WA NT Guides", + "Edu AU VIC WA NT Hand", + "Edu AU VIC WA NT Pre", + "Edu NSW ACT Cursive", + "Edu NSW ACT Foundation", + "Edu NSW ACT Hand Pre", + "Edu QLD Beginner", + "Edu QLD Hand", + "Edu SA Beginner", + "Edu SA Hand", + "Edu TAS Beginner", + "Edu VIC WA NT Beginner", + "Edu VIC WA NT Hand", + "Edu VIC WA NT Hand Pre", + "El Messiri", + "Electrolize", + "Elms Sans", + "Elsie", + "Elsie Swash Caps", + "Emblema One", + "Emilys Candy", + "Encode Sans", + "Encode Sans Condensed", + "Encode Sans Expanded", + "Encode Sans SC", + "Encode Sans Semi Condensed", + "Encode Sans Semi Expanded", + "Engagement", + "Englebert", + "Enriqueta", + "Ephesis", + "Epilogue", + "Epunda Sans", + "Epunda Slab", + "Erica One", + "Esteban", + "Estonia", + "Euphoria Script", + "Ewert", + "Exile", + "Exo", + "Exo 2", + "Expletus Sans", + "Explora", + "Faculty Glyphic", + "Fahkwang", + "Familjen Grotesk", + "Fanwood Text", + "Farro", + "Farsan", + "Fascinate", + "Fascinate Inline", + "Faster One", + "Fasthand", + "Fauna One", + "Faustina", + "Federant", + "Federo", + "Felipa", + "Fenix", + "Festive", + "Figtree", + "Finger Paint", + "Finlandica", + "Fira Code", + "Fira Mono", + "Fira Sans", + "Fira Sans Condensed", + "Fira Sans Extra Condensed", + "FiraGO", + "Fjalla One", + "Fjord One", + "Flamenco", + "Flavors", + "Fleur De Leah", + "Flow Block", + "Flow Circular", + "Flow Rounded", + "Foldit", + "Fondamento", + "Fontdiner Swanky", + "Forum", + "Fragment Mono", + "Francois One", + "Frank Ruhl Libre", + "Fraunces", + "Freckle Face", + "Fredericka the Great", + "Fredoka", + "Fredoka One", + "Freehand", + "Freeman", + "Fresca", + "Frijole", + "Fruktur", + "Fugaz One", + "Fuggles", + "Funnel Display", + "Funnel Sans", + "Fusion Kai G", + "Fusion Kai J", + "Fusion Kai T", + "Fusion Pixel 10px Monospaced JP", + "Fusion Pixel 10px Monospaced KR", + "Fusion Pixel 10px Monospaced SC", + "Fusion Pixel 10px Monospaced TC", + "Fusion Pixel 10px Proportional JP", + "Fusion Pixel 10px Proportional KR", + "Fusion Pixel 10px Proportional SC", + "Fusion Pixel 10px Proportional TC", + "Fusion Pixel 12px Monospaced JP", + "Fusion Pixel 12px Monospaced KR", + "Fusion Pixel 12px Monospaced SC", + "Fusion Pixel 12px Monospaced TC", + "Fusion Pixel 12px Proportional JP", + "Fusion Pixel 12px Proportional KR", + "Fusion Pixel 12px Proportional SC", + "Fusion Pixel 12px Proportional TC", + "Fusion Pixel 8px Monospaced JP", + "Fusion Pixel 8px Monospaced KR", + "Fusion Pixel 8px Monospaced SC", + "Fusion Pixel 8px Monospaced TC", + "Fusion Pixel 8px Proportional JP", + "Fusion Pixel 8px Proportional KR", + "Fusion Pixel 8px Proportional SC", + "Fusion Pixel 8px Proportional TC", + "Fustat", + "Fuzzy Bubbles", + "GFS Didot", + "GFS Neohellenic", + "Ga Maamli", + "Gabarito", + "Gabriela", + "Gaegu", + "Gafata", + "Gajraj One", + "Galada", + "Galdeano", + "Galindo", + "Gamja Flower", + "Gantari", + "Gasoek One", + "Gayathri", + "Geist", + "Geist Mono", + "Geist Sans", + "Gelasio", + "Gemunu Libre", + "Genjyuu Gothic", + "Genos", + "Gentium Book Basic", + "Gentium Book Plus", + "Gentium Plus", + "Geo", + "Geologica", + "Geom", + "Georama", + "Geostar", + "Geostar Fill", + "Germania One", + "Gideon Roman", + "Gidole", + "Gidugu", + "Gilda Display", + "Girassol", + "Give You Glory", + "Glass Antiqua", + "Glegoo", + "Gloock", + "Gloria Hallelujah", + "Glory", + "Gluten", + "Goblin One", + "Gochi Hand", + "Goldman", + "Golos Text", + "Google Sans", + "Google Sans Code", + "Google Sans Flex", + "Gorditas", + "Gothic A1", + "Gotu", + "Goudy Bookletter 1911", + "Gowun Batang", + "Gowun Dodum", + "Graduate", + "Grand Hotel", + "Grandiflora One", + "Grandstander", + "Grape Nuts", + "Gravitas One", + "Great Vibes", + "Grechen Fuemen", + "Grenze", + "Grenze Gotisch", + "Grey Qo", + "Griffy", + "Gruppo", + "Gudea", + "Gugi", + "Gulzar", + "Gupter", + "Gurajada", + "Gveret Levin", + "Gwendolyn", + "Habibi", + "Hachi Maru Pop", + "Hahmlet", + "Halant", + "Hammersmith One", + "Hanalei", + "Hanalei Fill", + "Handjet", + "Handlee", + "Hanken Grotesk", + "Hanuman", + "Happy Monkey", + "Harmattan", + "Hauora Sans", + "Headland One", + "Hedvig Letters Sans", + "Hedvig Letters Serif", + "Heebo", + "Henny Penny", + "Hepta Slab", + "Herr Von Muellerhoff", + "Hi Melody", + "Hina Mincho", + "Hind", + "Hind Guntur", + "Hind Madurai", + "Hind Mysuru", + "Hind Siliguri", + "Hind Vadodara", + "Holtwood One SC", + "Homemade Apple", + "Homenaje", + "Honk", + "Host Grotesk", + "Hubballi", + "Hubot Sans", + "Huninn", + "Hurricane", + "IBM Plex Mono", + "IBM Plex Sans", + "IBM Plex Sans Arabic", + "IBM Plex Sans Condensed", + "IBM Plex Sans Devanagari", + "IBM Plex Sans Hebrew", + "IBM Plex Sans JP", + "IBM Plex Sans KR", + "IBM Plex Sans Thai", + "IBM Plex Sans Thai Looped", + "IBM Plex Serif", + "IM Fell DW Pica", + "IM Fell DW Pica SC", + "IM Fell Double Pica", + "IM Fell Double Pica SC", + "IM Fell English", + "IM Fell English SC", + "IM Fell French Canon", + "IM Fell French Canon SC", + "IM Fell Great Primer", + "IM Fell Great Primer SC", + "Iansui", + "Ibarra Real Nova", + "Iceberg", + "Iceland", + "Idiqlat", + "Imbue", + "Imperial Script", + "Imprima", + "Inclusive Sans", + "Inconsolata", + "Inder", + "Indie Flower", + "Ingrid Darling", + "Inika", + "Inknut Antiqua", + "Inria Sans", + "Inria Serif", + "Inspiration", + "Instrument Sans", + "Instrument Serif", + "Intel One Mono", + "Inter", + "Inter Tight", + "Iosevka", + "Iosevka Aile", + "Iosevka Charon", + "Iosevka Charon Mono", + "Iosevka Curly", + "Iosevka Curly Slab", + "Iosevka Etoile", + "Irish Grover", + "Island Moments", + "Istok Web", + "Italiana", + "Italianno", + "Itim", + "Jacquard 12", + "Jacquard 12 Charted", + "Jacquard 24", + "Jacquard 24 Charted", + "Jacquarda Bastarda 9", + "Jacquarda Bastarda 9 Charted", + "Jacques Francois", + "Jacques Francois Shadow", + "Jaini", + "Jaini Purva", + "Jaldi", + "Jaro", + "Jersey 10", + "Jersey 10 Charted", + "Jersey 15", + "Jersey 15 Charted", + "Jersey 20", + "Jersey 20 Charted", + "Jersey 25", + "Jersey 25 Charted", + "JetBrains Mono", + "Jim Nightshade", + "Joan", + "Jockey One", + "Jolly Lodger", + "Jomhuria", + "Jomolhari", + "Josefin Sans", + "Josefin Slab", + "Jost", + "Joti One", + "Jua", + "Judson", + "Julee", + "Julius Sans One", + "Junction", + "Junge", + "Jura", + "Just Another Hand", + "Just Me Again Down Here", + "K2D", + "Kablammo", + "Kadwa", + "Kaisei Decol", + "Kaisei HarunoUmi", + "Kaisei Opti", + "Kaisei Tokumin", + "Kalam", + "Kalnia", + "Kalnia Glaze", + "Kameron", + "Kanchenjunga", + "Kanit", + "Kantumruy", + "Kantumruy Pro", + "Kapakana", + "Karantina", + "Karla", + "Karla Tamil Inclined", + "Karla Tamil Upright", + "Karma", + "Karmilla", + "Katibeh", + "Kaushan Script", + "Kavivanar", + "Kavoon", + "Kay Pho Du", + "Kdam Thmor Pro", + "Keania One", + "Kedebideri", + "Kelly Slab", + "Kenia", + "Khand", + "Khmer", + "Khula", + "Kings", + "Kirang Haerang", + "Kite One", + "Kiwi Maru", + "Klee One", + "Knewave", + "KoHo", + "Kodchasan", + "Kode Mono", + "Koh Santepheap", + "Kolker Brush", + "Konkhmer Sleokchher", + "Kosugi", + "Kosugi Maru", + "Kotta One", + "Koulen", + "Kranky", + "Kreon", + "Kristi", + "Krona One", + "Krub", + "Kufam", + "Kulim Park", + "Kumar One", + "Kumar One Outline", + "Kumbh Sans", + "Kurale", + "LINE Seed JP", + "LXGW Marker Gothic", + "LXGW WenKai", + "LXGW WenKai Mono TC", + "LXGW WenKai TC", + "La Belle Aurore", + "Labrada", + "Lacquer", + "Laila", + "Lakki Reddy", + "Lalezar", + "Lancelot", + "Langar", + "Lateef", + "Lato", + "Lavishly Yours", + "League Gothic", + "League Mono", + "League Script", + "League Spartan", + "Leckerli One", + "Ledger", + "Lekton", + "Lemon", + "Lemonada", + "Lexend", + "Lexend Deca", + "Lexend Exa", + "Lexend Giga", + "Lexend Mega", + "Lexend Peta", + "Lexend Tera", + "Lexend Zetta", + "Lextrall", + "Libertinus Keyboard", + "Libertinus Math", + "Libertinus Mono", + "Libertinus Sans", + "Libertinus Serif", + "Libertinus Serif Display", + "Libre Barcode 128", + "Libre Barcode 128 Text", + "Libre Barcode 39", + "Libre Barcode 39 Extended", + "Libre Barcode 39 Extended Text", + "Libre Barcode 39 Text", + "Libre Barcode EAN13 Text", + "Libre Baskerville", + "Libre Bodoni", + "Libre Caslon Condensed", + "Libre Caslon Display", + "Libre Caslon Text", + "Libre Franklin", + "Licorice", + "Life Savers", + "Lilex", + "Lilita One", + "Lily Script One", + "Limelight", + "Linden Hill", + "Linefont", + "Lisu Bosa", + "Liter", + "Literata", + "Liu Jian Mao Cao", + "Livvic", + "Lobster", + "Lobster Two", + "Londrina Outline", + "Londrina Shadow", + "Londrina Sketch", + "Londrina Solid", + "Long Cang", + "Lora", + "Love Light", + "Love Ya Like A Sister", + "Loved by the King", + "Lovers Quarrel", + "Luckiest Guy", + "Lugrasimo", + "Lumanosimo", + "Lunasima", + "Lusitana", + "Lustria", + "Luxurious Roman", + "Luxurious Script", + "M PLUS 1", + "M PLUS 1 Code", + "M PLUS 1p", + "M PLUS 2", + "M PLUS Code Latin", + "M PLUS Rounded 1c", + "Ma Shan Zheng", + "Macondo", + "Macondo Swash Caps", + "Mada", + "Madimi One", + "Magra", + "Maiden Orange", + "Maitree", + "Major Mono Display", + "Mako", + "Mali", + "Mallanna", + "Maname", + "Mandali", + "Manjari", + "Manrope", + "Mansalva", + "Manuale", + "Manufacturing Consent", + "Maple Mono", + "Marcellus", + "Marcellus SC", + "Marck Script", + "Margarine", + "Marhey", + "Markazi Text", + "Marko One", + "Marmelad", + "Martel", + "Martel Sans", + "Martian Mono", + "Marvel", + "Matangi", + "Mate", + "Mate SC", + "Matemasie", + "Material Icons", + "Material Icons Outlined", + "Material Icons Round", + "Material Icons Sharp", + "Material Icons Two Tone", + "Material Symbols", + "Material Symbols Outlined", + "Material Symbols Rounded", + "Material Symbols Sharp", + "Maven Pro", + "McLaren", + "Mea Culpa", + "Meddon", + "MedievalSharp", + "Medula One", + "Meera Inimai", + "Megrim", + "Meie Script", + "Menbere", + "Meow Script", + "Merienda", + "Merienda One", + "Merriweather", + "Merriweather Sans", + "Metal", + "Metal Mania", + "Metamorphous", + "Metrophobic", + "Metropolis", + "Michroma", + "Micro 5", + "Micro 5 Charted", + "Milonga", + "Miltonian", + "Miltonian Tattoo", + "Mina", + "Mingzat", + "Miniver", + "Miranda Sans", + "Miriam Libre", + "Mirza", + "Miss Fajardose", + "Mitr", + "Mochiy Pop One", + "Mochiy Pop P One", + "Modak", + "Modern Antiqua", + "Moderustic", + "Mogra", + "Mohave", + "Moirai One", + "Molengo", + "Molle", + "Momo Signature", + "Momo Trust Display", + "Momo Trust Sans", + "Mona Sans", + "Monaspace Argon", + "Monaspace Krypton", + "Monaspace Neon", + "Monaspace Radon", + "Monaspace Xenon", + "Monda", + "Monofett", + "Monomakh", + "Monomaniac One", + "Mononoki", + "Monoton", + "Monsieur La Doulaise", + "Montaga", + "Montagu Slab", + "MonteCarlo", + "Montez", + "Montserrat", + "Montserrat Alternates", + "Montserrat Subrayada", + "Montserrat Underline", + "Moo Lah Lah", + "Mooli", + "Moon Dance", + "Moul", + "Moulpali", + "Mountains of Christmas", + "Mouse Memoirs", + "Mozilla Headline", + "Mozilla Text", + "Mr Bedfort", + "Mr Dafoe", + "Mr De Haviland", + "Mrs Saint Delafield", + "Mrs Sheppards", + "Ms Madi", + "Mukta", + "Mukta Mahee", + "Mukta Malar", + "Mukta Vaani", + "Mulish", + "Murecho", + "MuseoModerno", + "My Soul", + "Mynerve", + "Mystery Quest", + "NTR", + "Nabla", + "Namdhinggo", + "Nanum Brush Script", + "Nanum Gothic", + "Nanum Gothic Coding", + "Nanum Myeongjo", + "Nanum Pen Script", + "Narnoor", + "Nata Sans", + "National Park", + "Nebula Sans", + "Neonderthaw", + "Nerko One", + "Neucha", + "Neuton", + "New Amsterdam", + "New Rocker", + "New Tegomin", + "News Cycle", + "Newsreader", + "Niconne", + "Niramit", + "Nixie One", + "Nobile", + "Nokora", + "Norican", + "Norwester", + "Nosifer", + "Notable", + "Nothing You Could Do", + "Noticia Text", + "Noto Color Emoji", + "Noto Emoji", + "Noto Kufi Arabic", + "Noto Mono", + "Noto Music", + "Noto Naskh Arabic", + "Noto Nastaliq Urdu", + "Noto Rashi Hebrew", + "Noto Sans", + "Noto Sans Adlam", + "Noto Sans Adlam Unjoined", + "Noto Sans Anatolian Hieroglyphs", + "Noto Sans Arabic", + "Noto Sans Armenian", + "Noto Sans Avestan", + "Noto Sans Balinese", + "Noto Sans Bamum", + "Noto Sans Bassa Vah", + "Noto Sans Batak", + "Noto Sans Bengali", + "Noto Sans Bhaiksuki", + "Noto Sans Brahmi", + "Noto Sans Buginese", + "Noto Sans Buhid", + "Noto Sans Canadian Aboriginal", + "Noto Sans Carian", + "Noto Sans Caucasian Albanian", + "Noto Sans Chakma", + "Noto Sans Cham", + "Noto Sans Cherokee", + "Noto Sans Chorasmian", + "Noto Sans Coptic", + "Noto Sans Cuneiform", + "Noto Sans Cypriot", + "Noto Sans Cypro Minoan", + "Noto Sans Deseret", + "Noto Sans Devanagari", + "Noto Sans Display", + "Noto Sans Duployan", + "Noto Sans Egyptian Hieroglyphs", + "Noto Sans Elbasan", + "Noto Sans Elymaic", + "Noto Sans Ethiopic", + "Noto Sans Georgian", + "Noto Sans Glagolitic", + "Noto Sans Gothic", + "Noto Sans Grantha", + "Noto Sans Gujarati", + "Noto Sans Gunjala Gondi", + "Noto Sans Gurmukhi", + "Noto Sans HK", + "Noto Sans Hanifi Rohingya", + "Noto Sans Hanunoo", + "Noto Sans Hatran", + "Noto Sans Hebrew", + "Noto Sans Imperial Aramaic", + "Noto Sans Indic Siyaq Numbers", + "Noto Sans Inscriptional Pahlavi", + "Noto Sans Inscriptional Parthian", + "Noto Sans JP", + "Noto Sans Javanese", + "Noto Sans KR", + "Noto Sans Kaithi", + "Noto Sans Kannada", + "Noto Sans Kawi", + "Noto Sans Kayah Li", + "Noto Sans Kharoshthi", + "Noto Sans Khmer", + "Noto Sans Khojki", + "Noto Sans Khudawadi", + "Noto Sans Lao", + "Noto Sans Lao Looped", + "Noto Sans Lepcha", + "Noto Sans Limbu", + "Noto Sans Linear A", + "Noto Sans Linear B", + "Noto Sans Lisu", + "Noto Sans Lycian", + "Noto Sans Lydian", + "Noto Sans Mahajani", + "Noto Sans Malayalam", + "Noto Sans Mandaic", + "Noto Sans Manichaean", + "Noto Sans Marchen", + "Noto Sans Masaram Gondi", + "Noto Sans Math", + "Noto Sans Mayan Numerals", + "Noto Sans Medefaidrin", + "Noto Sans Meetei Mayek", + "Noto Sans Mende Kikakui", + "Noto Sans Meroitic", + "Noto Sans Miao", + "Noto Sans Modi", + "Noto Sans Mongolian", + "Noto Sans Mono", + "Noto Sans Mro", + "Noto Sans Multani", + "Noto Sans Myanmar", + "Noto Sans NKo", + "Noto Sans NKo Unjoined", + "Noto Sans Nabataean", + "Noto Sans Nag Mundari", + "Noto Sans Nandinagari", + "Noto Sans New Tai Lue", + "Noto Sans Newa", + "Noto Sans Nushu", + "Noto Sans Ogham", + "Noto Sans Ol Chiki", + "Noto Sans Old Hungarian", + "Noto Sans Old Italic", + "Noto Sans Old North Arabian", + "Noto Sans Old Permic", + "Noto Sans Old Persian", + "Noto Sans Old Sogdian", + "Noto Sans Old South Arabian", + "Noto Sans Old Turkic", + "Noto Sans Oriya", + "Noto Sans Osage", + "Noto Sans Osmanya", + "Noto Sans Pahawh Hmong", + "Noto Sans Palmyrene", + "Noto Sans Pau Cin Hau", + "Noto Sans Phags Pa", + "Noto Sans PhagsPa", + "Noto Sans Phoenician", + "Noto Sans Psalter Pahlavi", + "Noto Sans Rejang", + "Noto Sans Runic", + "Noto Sans SC", + "Noto Sans Samaritan", + "Noto Sans Saurashtra", + "Noto Sans Sharada", + "Noto Sans Shavian", + "Noto Sans Siddham", + "Noto Sans SignWriting", + "Noto Sans Sinhala", + "Noto Sans Sogdian", + "Noto Sans Sora Sompeng", + "Noto Sans Soyombo", + "Noto Sans Sundanese", + "Noto Sans Sunuwar", + "Noto Sans Syloti Nagri", + "Noto Sans Symbols", + "Noto Sans Symbols 2", + "Noto Sans Syriac", + "Noto Sans Syriac Eastern", + "Noto Sans Syriac Western", + "Noto Sans TC", + "Noto Sans Tagalog", + "Noto Sans Tagbanwa", + "Noto Sans Tai Le", + "Noto Sans Tai Tham", + "Noto Sans Tai Viet", + "Noto Sans Takri", + "Noto Sans Tamil", + "Noto Sans Tamil Supplement", + "Noto Sans Tangsa", + "Noto Sans Telugu", + "Noto Sans Thaana", + "Noto Sans Thai", + "Noto Sans Thai Looped", + "Noto Sans Tifinagh", + "Noto Sans Tirhuta", + "Noto Sans Ugaritic", + "Noto Sans Vai", + "Noto Sans Vithkuqi", + "Noto Sans Wancho", + "Noto Sans Warang Citi", + "Noto Sans Yi", + "Noto Sans Zanabazar Square", + "Noto Serif", + "Noto Serif Ahom", + "Noto Serif Armenian", + "Noto Serif Balinese", + "Noto Serif Bengali", + "Noto Serif Devanagari", + "Noto Serif Display", + "Noto Serif Dives Akuru", + "Noto Serif Dogra", + "Noto Serif Ethiopic", + "Noto Serif Georgian", + "Noto Serif Grantha", + "Noto Serif Gujarati", + "Noto Serif Gurmukhi", + "Noto Serif HK", + "Noto Serif Hebrew", + "Noto Serif Hentaigana", + "Noto Serif JP", + "Noto Serif KR", + "Noto Serif Kannada", + "Noto Serif Khitan Small Script", + "Noto Serif Khmer", + "Noto Serif Khojki", + "Noto Serif Lao", + "Noto Serif Makasar", + "Noto Serif Malayalam", + "Noto Serif Myanmar", + "Noto Serif NP Hmong", + "Noto Serif Old Uyghur", + "Noto Serif Oriya", + "Noto Serif Ottoman Siyaq", + "Noto Serif SC", + "Noto Serif Sinhala", + "Noto Serif TC", + "Noto Serif Tamil", + "Noto Serif Tangut", + "Noto Serif Telugu", + "Noto Serif Thai", + "Noto Serif Tibetan", + "Noto Serif Todhri", + "Noto Serif Toto", + "Noto Serif Vithkuqi", + "Noto Serif Yezidi", + "Noto Traditional Nushu", + "Noto Znamenny Musical Notation", + "Nova Cut", + "Nova Flat", + "Nova Mono", + "Nova Oval", + "Nova Round", + "Nova Script", + "Nova Slim", + "Nova Square", + "Numans", + "Nunito", + "Nunito Sans", + "Nuosu SIL", + "Odibee Sans", + "Odor Mean Chey", + "Offside", + "Oi", + "Ojuju", + "Old Standard TT", + "Oldenburg", + "Ole", + "Oleo Script", + "Oleo Script Swash Caps", + "Onest", + "Oooh Baby", + "Open Runde", + "Open Sans", + "Open Sauce One", + "Open Sauce Sans", + "Open Sauce Two", + "OpenDyslexic", + "Oranienbaum", + "Orbit", + "Orbitron", + "Oregano", + "Orelega One", + "Orienta", + "Original Surfer", + "Ostrich Sans", + "Oswald", + "Outfit", + "Over the Rainbow", + "Overlock", + "Overlock SC", + "Overpass", + "Overpass Mono", + "Ovo", + "Oxanium", + "Oxygen", + "Oxygen Mono", + "PT Mono", + "PT Sans", + "PT Sans Caption", + "PT Sans Narrow", + "PT Serif", + "PT Serif Caption", + "Pacifico", + "Padauk", + "Padyakke Expanded One", + "Palanquin", + "Palanquin Dark", + "Palette Mosaic", + "Pangolin", + "Paprika", + "Parastoo", + "Parisienne", + "Parkinsans", + "Passero One", + "Passion One", + "Passions Conflict", + "Pathway Extreme", + "Pathway Gothic One", + "Patrick Hand", + "Patrick Hand SC", + "Pattaya", + "Patua One", + "Pavanam", + "Paytone One", + "Peace Sans", + "Peddana", + "Peralta", + "Permanent Marker", + "Petemoss", + "Petit Formal Script", + "Petrona", + "Phetsarath", + "Philosopher", + "Phudu", + "Piazzolla", + "Piedra", + "Pinyon Script", + "Pirata One", + "Pitagon Sans", + "Pitagon Sans Mono", + "Pitagon Sans Text", + "Pitagon Serif", + "Pixelify Sans", + "Plaster", + "Platypi", + "Play", + "Playball", + "Playfair", + "Playfair Display", + "Playfair Display SC", + "Playpen Sans", + "Playpen Sans Arabic", + "Playpen Sans Deva", + "Playpen Sans Hebrew", + "Playpen Sans Thai", + "Playwrite AR", + "Playwrite AR Guides", + "Playwrite AT", + "Playwrite AT Guides", + "Playwrite AU NSW", + "Playwrite AU NSW Guides", + "Playwrite AU QLD", + "Playwrite AU QLD Guides", + "Playwrite AU SA", + "Playwrite AU SA Guides", + "Playwrite AU TAS", + "Playwrite AU TAS Guides", + "Playwrite AU VIC", + "Playwrite AU VIC Guides", + "Playwrite BE VLG", + "Playwrite BE VLG Guides", + "Playwrite BE WAL", + "Playwrite BE WAL Guides", + "Playwrite BR", + "Playwrite BR Guides", + "Playwrite CA", + "Playwrite CA Guides", + "Playwrite CL", + "Playwrite CL Guides", + "Playwrite CO", + "Playwrite CO Guides", + "Playwrite CU", + "Playwrite CU Guides", + "Playwrite CZ", + "Playwrite CZ Guides", + "Playwrite DE Grund", + "Playwrite DE Grund Guides", + "Playwrite DE LA", + "Playwrite DE LA Guides", + "Playwrite DE SAS", + "Playwrite DE SAS Guides", + "Playwrite DE VA", + "Playwrite DE VA Guides", + "Playwrite DK Loopet", + "Playwrite DK Loopet Guides", + "Playwrite DK Uloopet", + "Playwrite DK Uloopet Guides", + "Playwrite ES", + "Playwrite ES Deco", + "Playwrite ES Deco Guides", + "Playwrite ES Guides", + "Playwrite FR Moderne", + "Playwrite FR Moderne Guides", + "Playwrite FR Trad", + "Playwrite FR Trad Guides", + "Playwrite GB J", + "Playwrite GB J Guides", + "Playwrite GB S", + "Playwrite GB S Guides", + "Playwrite HR", + "Playwrite HR Guides", + "Playwrite HR Lijeva", + "Playwrite HR Lijeva Guides", + "Playwrite HU", + "Playwrite HU Guides", + "Playwrite ID", + "Playwrite ID Guides", + "Playwrite IE", + "Playwrite IE Guides", + "Playwrite IN", + "Playwrite IN Guides", + "Playwrite IS", + "Playwrite IS Guides", + "Playwrite IT Moderna", + "Playwrite IT Moderna Guides", + "Playwrite IT Trad", + "Playwrite IT Trad Guides", + "Playwrite MX", + "Playwrite MX Guides", + "Playwrite NG Modern", + "Playwrite NG Modern Guides", + "Playwrite NL", + "Playwrite NL Guides", + "Playwrite NO", + "Playwrite NO Guides", + "Playwrite NZ", + "Playwrite NZ Basic", + "Playwrite NZ Basic Guides", + "Playwrite NZ Guides", + "Playwrite PE", + "Playwrite PE Guides", + "Playwrite PL", + "Playwrite PL Guides", + "Playwrite PT", + "Playwrite PT Guides", + "Playwrite RO", + "Playwrite RO Guides", + "Playwrite SK", + "Playwrite SK Guides", + "Playwrite TZ", + "Playwrite TZ Guides", + "Playwrite US Modern", + "Playwrite US Modern Guides", + "Playwrite US Trad", + "Playwrite US Trad Guides", + "Playwrite VN", + "Playwrite VN Guides", + "Playwrite ZA", + "Playwrite ZA Guides", + "Plus Jakarta Sans", + "Pochaevsk", + "Podkova", + "Poetsen One", + "Poiret One", + "Poller One", + "Poltawski Nowy", + "Poly", + "Pompiere", + "Ponnala", + "Ponomar", + "Pontano Sans", + "Poor Story", + "Poppins", + "Port Lligat Sans", + "Port Lligat Slab", + "Potta One", + "Pragati Narrow", + "Praise", + "Prata", + "Preahvihear", + "Press Start 2P", + "Pretendard", + "Pridi", + "Princess Sofia", + "Prociono", + "Prompt", + "Prosto One", + "Protest Guerrilla", + "Protest Revolution", + "Protest Riot", + "Protest Strike", + "Proza Libre", + "Public Sans", + "Puppies Play", + "Puritan", + "Purple Purse", + "Pushster", + "Qahiri", + "Quando", + "Quantico", + "Quattrocento", + "Quattrocento Sans", + "Questrial", + "Quicksand", + "Quintessential", + "Qwigley", + "Qwitcher Grypen", + "REM", + "Racing Sans One", + "Radio Canada", + "Radio Canada Big", + "Radley", + "Rajdhani", + "Rakkas", + "Raleway", + "Raleway Dots", + "Ramabhadra", + "Ramaraja", + "Rambla", + "Rammetto One", + "Rampart One", + "Ramsina", + "Ranchers", + "Rancho", + "Ranga", + "Rasa", + "Rationale", + "Ravi Prakash", + "Readex Pro", + "Recursive", + "Red Hat Display", + "Red Hat Mono", + "Red Hat Text", + "Red Rose", + "Redacted", + "Redacted Script", + "Redaction", + "Redaction 10", + "Redaction 100", + "Redaction 20", + "Redaction 35", + "Redaction 50", + "Redaction 70", + "Reddit Mono", + "Reddit Sans", + "Reddit Sans Condensed", + "Redressed", + "Reem Kufi", + "Reem Kufi Fun", + "Reem Kufi Ink", + "Reenie Beanie", + "Reggae One", + "Rethink Sans", + "Revalia", + "Rhodium Libre", + "Ribeye", + "Ribeye Marrow", + "Righteous", + "Risque", + "Road Rage", + "Roboto", + "Roboto Condensed", + "Roboto Flex", + "Roboto Mono", + "Roboto Serif", + "Roboto Slab", + "Rochester", + "Rock 3D", + "Rock Salt", + "RocknRoll One", + "Rokkitt", + "Romanesco", + "Ropa Sans", + "Rosario", + "Rosarivo", + "Rouge Script", + "Rowdies", + "Rozha One", + "Rubik", + "Rubik 80s Fade", + "Rubik Beastly", + "Rubik Broken Fax", + "Rubik Bubbles", + "Rubik Burned", + "Rubik Dirt", + "Rubik Distressed", + "Rubik Doodle Shadow", + "Rubik Doodle Triangles", + "Rubik Gemstones", + "Rubik Glitch", + "Rubik Glitch Pop", + "Rubik Iso", + "Rubik Lines", + "Rubik Maps", + "Rubik Marker Hatch", + "Rubik Maze", + "Rubik Microbe", + "Rubik Mono One", + "Rubik Moonrocks", + "Rubik One", + "Rubik Pixels", + "Rubik Puddles", + "Rubik Scribble", + "Rubik Spray Paint", + "Rubik Storm", + "Rubik Vinyl", + "Rubik Wet Paint", + "Ruda", + "Rufina", + "Ruge Boogie", + "Ruluko", + "Rum Raisin", + "Ruslan Display", + "Russo One", + "Ruthie", + "Ruwudu", + "Rye", + "SN Pro", + "STIX Two Text", + "SUSE", + "SUSE Mono", + "Sacramento", + "Sahitya", + "Sail", + "Saira", + "Saira Condensed", + "Saira Extra Condensed", + "Saira Semi Condensed", + "Saira Stencil", + "Saira Stencil One", + "Salsa", + "Sanchez", + "Sancreek", + "Sankofa Display", + "Sansation", + "Sansita", + "Sansita Swashed", + "Sarabun", + "Sarala", + "Sarina", + "Sarpanch", + "Sassy Frass", + "Satisfy", + "Savate", + "Sawarabi Gothic", + "Sawarabi Mincho", + "Scada", + "Scheherazade New", + "Schibsted Grotesk", + "Schoolbell", + "Science Gothic", + "Scope One", + "Seaweed Script", + "Secular One", + "Sedan", + "Sedan SC", + "Sedgwick Ave", + "Sedgwick Ave Display", + "Sekuya", + "Sen", + "Send Flowers", + "Sevillana", + "Seymour One", + "Shadows Into Light", + "Shadows Into Light Two", + "Shafarik", + "Shalimar", + "Shantell Sans", + "Shanti", + "Share", + "Share Tech", + "Share Tech Mono", + "Shippori Antique", + "Shippori Antique B1", + "Shippori Mincho", + "Shippori Mincho B1", + "Shizuru", + "Shojumaru", + "Short Stack", + "Shrikhand", + "Siemreap", + "Sigmar", + "Sigmar One", + "Signika", + "Signika Negative", + "Silkscreen", + "Simonetta", + "Single Day", + "Sintony", + "Sirin Stencil", + "Sirivennela", + "Six Caps", + "Sixtyfour", + "Sixtyfour Convergence", + "Skranji", + "Slabo 13px", + "Slabo 27px", + "Slackey", + "Slackside One", + "Smokum", + "Smooch", + "Smooch Sans", + "Smythe", + "Sniglet", + "Snippet", + "Snowburst One", + "Sofadi One", + "Sofia", + "Sofia Sans", + "Sofia Sans Condensed", + "Sofia Sans Extra Condensed", + "Sofia Sans Semi Condensed", + "Solitreo", + "Solway", + "Sometype Mono", + "Song Myung", + "Sono", + "Sonsie One", + "Sora", + "Sorts Mill Goudy", + "Sour Gummy", + "Source Code Pro", + "Source Sans 3", + "Source Sans Pro", + "Source Serif 4", + "Source Serif Pro", + "Space Grotesk", + "Space Mono", + "Special Elite", + "Special Gothic", + "Special Gothic Condensed One", + "Special Gothic Expanded One", + "Spectral", + "Spectral SC", + "Spicy Rice", + "Spinnaker", + "Spirax", + "Splash", + "Spline Sans", + "Spline Sans Mono", + "Squada One", + "Square Peg", + "Sree Krushnadevaraya", + "Sriracha", + "Srisakdi", + "Staatliches", + "Stack Sans Headline", + "Stack Sans Notch", + "Stack Sans Text", + "Stalemate", + "Stalinist One", + "Stardos Stencil", + "Stick", + "Stick No Bills", + "Stint Ultra Condensed", + "Stint Ultra Expanded", + "Stoke", + "Story Script", + "Strait", + "Style Script", + "Stylish", + "Sue Ellen Francisco", + "Suez One", + "Sulphur Point", + "Sumana", + "Sunflower", + "Sunshiney", + "Supermercado One", + "Sura", + "Suranna", + "Suravaram", + "Suwannaphum", + "Swanky and Moo Moo", + "Syncopate", + "Syne", + "Syne Italic", + "Syne Mono", + "Syne Tactile", + "TASA Explorer", + "TASA Orbiter", + "Tac One", + "Tagesschrift", + "Tai Heritage Pro", + "Tajawal", + "Tangerine", + "Tapestry", + "Taprom", + "Tauri", + "Taviraj", + "Teachers", + "Teko", + "Tektur", + "Telex", + "Tenali Ramakrishna", + "Tenor Sans", + "Text Me One", + "Texturina", + "Thasadith", + "The Girl Next Door", + "The Nautigal", + "Tienne", + "TikTok Sans", + "Tillana", + "Tilt Neon", + "Tilt Prism", + "Tilt Warp", + "Timmana", + "Tinos", + "Tiny5", + "Tiro Bangla", + "Tiro Devanagari Hindi", + "Tiro Devanagari Marathi", + "Tiro Devanagari Sanskrit", + "Tiro Gurmukhi", + "Tiro Kannada", + "Tiro Tamil", + "Tiro Telugu", + "Tirra", + "Titan One", + "Titillium Web", + "Tomorrow", + "Tourney", + "Trade Winds", + "Train One", + "Triodion", + "Trirong", + "Trispace", + "Trocchi", + "Trochut", + "Truculenta", + "Trykker", + "Tsukimi Rounded", + "Tuffy", + "Tulpen One", + "Turret Road", + "Twinkle Star", + "Ubuntu", + "Ubuntu Condensed", + "Ubuntu Mono", + "Ubuntu Sans", + "Ubuntu Sans Mono", + "Uchen", + "Ultra", + "Unbounded", + "Uncial Antiqua", + "Uncut Sans", + "Underdog", + "Unica One", + "Unifont", + "UnifontEX", + "UnifrakturCook", + "UnifrakturMaguntia", + "Unkempt", + "Unlock", + "Unna", + "UoqMunThenKhung", + "Updock", + "Urbanist", + "VT323", + "Vampiro One", + "Varela", + "Varela Round", + "Varta", + "Vast Shadow", + "Vazirmatn", + "Vend Sans", + "Vesper Libre", + "Viaoda Libre", + "Vibes", + "Vibur", + "Victor Mono", + "Vidaloka", + "Viga", + "Vina Sans", + "Voces", + "Volkhov", + "Vollkorn", + "Vollkorn SC", + "Voltaire", + "Vujahday Script", + "WDXL Lubrifont JP N", + "WDXL Lubrifont SC", + "WDXL Lubrifont TC", + "WIN95FA", + "Waiting for the Sunrise", + "Wallpoet", + "Walter Turncoat", + "Warnes", + "Water Brush", + "Waterfall", + "Wavefont", + "Wellfleet", + "Wendy One", + "Whisper", + "WindSong", + "Winky Rough", + "Winky Sans", + "Wire One", + "Wittgenstein", + "Wix Madefor Display", + "Wix Madefor Text", + "Work Sans", + "Workbench", + "Xanh Mono", + "YakuHanJP", + "YakuHanJPs", + "YakuHanMP", + "YakuHanMPs", + "YakuHanRP", + "YakuHanRPs", + "Yaldevi", + "Yanone Kaffeesatz", + "Yantramanav", + "Yarndings 12", + "Yarndings 12 Charted", + "Yarndings 20", + "Yarndings 20 Charted", + "Yatra One", + "Yellowtail", + "Yeon Sung", + "Yeseva One", + "Yesteryear", + "Yomogi", + "Young Serif", + "Yrsa", + "Ysabeau", + "Ysabeau Infant", + "Ysabeau Office", + "Ysabeau SC", + "Yuji Boku", + "Yuji Hentaigana Akari", + "Yuji Hentaigana Akebono", + "Yuji Mai", + "Yuji Syuku", + "Yusei Magic", + "ZCOOL KuaiLe", + "ZCOOL QingKe HuangYou", + "ZCOOL XiaoWei", + "Zain", + "Zalando Sans", + "Zalando Sans Expanded", + "Zalando Sans SemiExpanded", + "Zen Antique", + "Zen Antique Soft", + "Zen Dots", + "Zen Kaku Gothic Antique", + "Zen Kaku Gothic New", + "Zen Kurenaido", + "Zen Loop", + "Zen Maru Gothic", + "Zen Old Mincho", + "Zen Tokyo Zoo", + "Zeyada", + "Zhi Mang Xing", + "Zilla Slab", + "Zilla Slab Highlight", + "iA Writer Duo", + "iA Writer Mono", + "iA Writer Quattro" +] \ No newline at end of file diff --git a/desktopApp/src/desktopMain/resources/textures/classy_fabric.webp b/desktopApp/src/desktopMain/resources/textures/classy_fabric.webp new file mode 100644 index 0000000..40c01e9 Binary files /dev/null and b/desktopApp/src/desktopMain/resources/textures/classy_fabric.webp differ diff --git a/desktopApp/src/desktopMain/resources/textures/ep_naturalblack.webp b/desktopApp/src/desktopMain/resources/textures/ep_naturalblack.webp new file mode 100644 index 0000000..eeda652 Binary files /dev/null and b/desktopApp/src/desktopMain/resources/textures/ep_naturalblack.webp differ diff --git a/desktopApp/src/desktopMain/resources/textures/ep_naturalwhite.webp b/desktopApp/src/desktopMain/resources/textures/ep_naturalwhite.webp new file mode 100644 index 0000000..050f115 Binary files /dev/null and b/desktopApp/src/desktopMain/resources/textures/ep_naturalwhite.webp differ diff --git a/desktopApp/src/desktopMain/resources/textures/grey_wash_wall.webp b/desktopApp/src/desktopMain/resources/textures/grey_wash_wall.webp new file mode 100644 index 0000000..c6cba20 Binary files /dev/null and b/desktopApp/src/desktopMain/resources/textures/grey_wash_wall.webp differ diff --git a/desktopApp/src/desktopMain/resources/textures/light-veneer.webp b/desktopApp/src/desktopMain/resources/textures/light-veneer.webp new file mode 100644 index 0000000..2827d62 Binary files /dev/null and b/desktopApp/src/desktopMain/resources/textures/light-veneer.webp differ diff --git a/desktopApp/src/desktopMain/resources/textures/retina_wood.webp b/desktopApp/src/desktopMain/resources/textures/retina_wood.webp new file mode 100644 index 0000000..d697e3d Binary files /dev/null and b/desktopApp/src/desktopMain/resources/textures/retina_wood.webp differ diff --git a/desktopApp/src/desktopMain/resources/textures/retro_intro.webp b/desktopApp/src/desktopMain/resources/textures/retro_intro.webp new file mode 100644 index 0000000..ce031ae Binary files /dev/null and b/desktopApp/src/desktopMain/resources/textures/retro_intro.webp differ diff --git a/desktopApp/src/desktopMain/resources/textures/texture_canvas.png b/desktopApp/src/desktopMain/resources/textures/texture_canvas.png new file mode 100644 index 0000000..edd5c01 Binary files /dev/null and b/desktopApp/src/desktopMain/resources/textures/texture_canvas.png differ diff --git a/desktopApp/src/desktopMain/resources/textures/texture_eink.webp b/desktopApp/src/desktopMain/resources/textures/texture_eink.webp new file mode 100644 index 0000000..050f115 Binary files /dev/null and b/desktopApp/src/desktopMain/resources/textures/texture_eink.webp differ diff --git a/desktopApp/src/desktopMain/resources/textures/texture_paper.png b/desktopApp/src/desktopMain/resources/textures/texture_paper.png new file mode 100644 index 0000000..b5855b9 Binary files /dev/null and b/desktopApp/src/desktopMain/resources/textures/texture_paper.png differ diff --git a/desktopApp/src/desktopMain/resources/textures/texture_slate.png b/desktopApp/src/desktopMain/resources/textures/texture_slate.png new file mode 100644 index 0000000..9fddee6 Binary files /dev/null and b/desktopApp/src/desktopMain/resources/textures/texture_slate.png differ diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopAiByokStoreTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopAiByokStoreTest.kt new file mode 100644 index 0000000..f0f78f4 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopAiByokStoreTest.kt @@ -0,0 +1,106 @@ +package com.aryan.reader.desktop + +import com.aryan.reader.shared.GEMINI_CLOUD_TTS_MODEL_ID +import com.aryan.reader.shared.ReaderAiByokSettings +import java.nio.file.Files +import kotlin.io.path.readText +import kotlin.io.path.writeText +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DesktopAiByokStoreTest { + @Test + fun `save keeps keys out of plaintext settings file`() { + val settingsFile = Files.createTempDirectory("reader-ai-store").resolve("ai-byok.properties") + val store = DesktopAiByokStore(settingsFile.toFile(), ReversibleSecretCodec) + + store.save( + ReaderAiByokSettings( + geminiKey = "gemini_secret", + groqKey = "groq_secret", + modelForAll = "groq:qwen/qwen3-32b", + ttsModel = GEMINI_CLOUD_TTS_MODEL_ID + ) + ) + + val raw = settingsFile.readText() + assertFalse(raw.contains("gemini_secret")) + assertFalse(raw.contains("groq_secret")) + assertTrue(raw.contains("geminiKeyProtected=")) + assertTrue(raw.contains("groqKeyProtected=")) + + val loaded = store.load() + assertEquals("gemini_secret", loaded.geminiKey) + assertEquals("groq_secret", loaded.groqKey) + assertEquals("groq:qwen/qwen3-32b", loaded.modelForAll) + assertEquals(GEMINI_CLOUD_TTS_MODEL_ID, loaded.ttsModel) + } + + @Test + fun `load migrates legacy plaintext keys into protected entries`() { + val settingsFile = Files.createTempDirectory("reader-ai-store-legacy").resolve("ai-byok.properties") + settingsFile.writeText( + """ + geminiKey=old_gemini + groqKey=old_groq + modelForAll=groq:qwen/qwen3-32b + useOneModel=true + """.trimIndent() + ) + val store = DesktopAiByokStore(settingsFile.toFile(), ReversibleSecretCodec) + + val loaded = store.load() + + assertEquals("old_gemini", loaded.geminiKey) + assertEquals("old_groq", loaded.groqKey) + assertEquals(GEMINI_CLOUD_TTS_MODEL_ID, loaded.ttsModel) + val raw = settingsFile.readText() + assertFalse(raw.contains("geminiKey=old_gemini")) + assertFalse(raw.contains("groqKey=old_groq")) + assertTrue(raw.contains("geminiKeyProtected=")) + assertTrue(raw.contains("groqKeyProtected=")) + } + + @Test + fun `model settings persist when secure key storage is unavailable`() { + val settingsFile = Files.createTempDirectory("reader-ai-store-unavailable").resolve("ai-byok.properties") + val store = DesktopAiByokStore(settingsFile.toFile(), UnavailableSecretCodec) + + store.save( + ReaderAiByokSettings( + geminiKey = "session_only", + modelForAll = "groq:qwen/qwen3-32b", + ttsModel = GEMINI_CLOUD_TTS_MODEL_ID + ) + ) + + val raw = settingsFile.readText() + assertFalse(raw.contains("session_only")) + assertFalse(raw.contains("geminiKeyProtected=")) + + val loaded = store.load() + assertEquals("", loaded.geminiKey) + assertEquals("groq:qwen/qwen3-32b", loaded.modelForAll) + assertEquals(GEMINI_CLOUD_TTS_MODEL_ID, loaded.ttsModel) + } + + private object ReversibleSecretCodec : DesktopSecretCodec { + override val isAvailable: Boolean = true + + override fun protect(value: String): String { + return "test:" + value.reversed() + } + + override fun unprotect(value: String): String { + return value.removePrefix("test:").reversed() + } + } + + private object UnavailableSecretCodec : DesktopSecretCodec { + override val isAvailable: Boolean = false + override fun protect(value: String): String = "" + override fun unprotect(value: String): String = "" + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopComicArchiveTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopComicArchiveTest.kt new file mode 100644 index 0000000..f465330 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopComicArchiveTest.kt @@ -0,0 +1,59 @@ +package com.aryan.reader.desktop + +import com.aryan.reader.shared.FileType +import java.io.File +import java.nio.file.Files +import java.util.Base64 +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class DesktopComicArchiveTest { + @Test + fun `cbz archive loads image pages for pdf reader surface`() = withTempDir { dir -> + val cbz = File(dir, "comic.cbz") + ZipOutputStream(cbz.outputStream()).use { zip -> + zip.putNextEntry(ZipEntry("pages/001.png")) + zip.write(onePixelPngBytes()) + zip.closeEntry() + } + + val document = DesktopPdfium.loadComic(cbz, FileType.CBZ) + try { + assertEquals(1, document.pageCount) + assertEquals(1f, document.pageSizes.single().width) + assertEquals(1f, document.pageSizes.single().height) + + val image = DesktopPdfium.renderPageBufferedImage(document, pageIndex = 0, scale = 8f) + + assertEquals(8, image.width) + assertEquals(8, image.height) + } finally { + document.close() + } + } + + @Test + fun `desktop comic types are routed through shared reader capability map`() { + assertTrue(DesktopComicArchive.canLoad(FileType.CBZ)) + assertTrue(DesktopComicArchive.canLoad(FileType.CBR)) + assertTrue(DesktopComicArchive.canLoad(FileType.CB7)) + } + + private fun withTempDir(block: (File) -> Unit) { + val dir = Files.createTempDirectory("reader-desktop-comic").toFile() + try { + block(dir) + } finally { + dir.deleteRecursively() + } + } + + private fun onePixelPngBytes(): ByteArray { + return Base64.getDecoder().decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=" + ) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopComposeInteropTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopComposeInteropTest.kt new file mode 100644 index 0000000..2b5a4b2 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopComposeInteropTest.kt @@ -0,0 +1,55 @@ +package com.aryan.reader.desktop + +import kotlin.test.Test +import kotlin.test.assertEquals + +class DesktopComposeInteropTest { + @Test + fun `desktop enables Compose interop blending before app startup`() { + withSystemProperty(ComposeInteropBlendingProperty, null) { + configureComposeSwingInterop() + + assertEquals(ComposeInteropBlendingEnabled, System.getProperty(ComposeInteropBlendingProperty)) + } + } + + @Test + fun `desktop treats blank Compose interop blending value as unset`() { + withSystemProperty(ComposeInteropBlendingProperty, " ") { + configureComposeSwingInterop() + + assertEquals(ComposeInteropBlendingEnabled, System.getProperty(ComposeInteropBlendingProperty)) + } + } + + @Test + fun `desktop preserves explicit Compose interop blending override`() { + withSystemProperty(ComposeInteropBlendingProperty, "false") { + configureComposeSwingInterop() + + assertEquals("false", System.getProperty(ComposeInteropBlendingProperty)) + } + } + + private fun withSystemProperty( + key: String, + value: String?, + block: () -> Unit + ) { + val previous = System.getProperty(key) + try { + if (value == null) { + System.clearProperty(key) + } else { + System.setProperty(key, value) + } + block() + } finally { + if (previous == null) { + System.clearProperty(key) + } else { + System.setProperty(key, previous) + } + } + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopCustomFontStoreTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopCustomFontStoreTest.kt new file mode 100644 index 0000000..3725197 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopCustomFontStoreTest.kt @@ -0,0 +1,89 @@ +package com.aryan.reader.desktop + +import com.aryan.reader.shared.CustomFontItem +import java.io.File +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DesktopCustomFontStoreTest { + @Test + fun `import font copies supported file into desktop font store`() { + val tempRoot = Files.createTempDirectory("episteme-font-store-test").toFile() + try { + val source = File(tempRoot, "Literata.ttf").apply { writeText("font-bytes") } + val store = DesktopCustomFontStore(File(tempRoot, "store")) + + val font = store.importFont(source).getOrThrow() + + assertEquals("Literata", font.displayName) + assertEquals("ttf", font.fileExtension) + assertTrue(File(font.path).isFile) + assertEquals("font-bytes", File(font.path).readText()) + } finally { + tempRoot.deleteRecursively() + } + } + + @Test + fun `import font rejects unsupported extension`() { + val tempRoot = Files.createTempDirectory("episteme-font-store-test").toFile() + try { + val source = File(tempRoot, "not-a-font.txt").apply { writeText("nope") } + val store = DesktopCustomFontStore(File(tempRoot, "store")) + + assertTrue(store.importFont(source).isFailure) + } finally { + tempRoot.deleteRecursively() + } + } + + @Test + fun `delete font only removes files inside desktop font store`() { + val tempRoot = Files.createTempDirectory("episteme-font-store-test").toFile() + try { + val storeDir = File(tempRoot, "store").apply { mkdirs() } + val stored = File(storeDir, "font_a.ttf").apply { writeText("stored") } + val outside = File(tempRoot, "outside.ttf").apply { writeText("outside") } + val store = DesktopCustomFontStore(storeDir) + + assertTrue(store.deleteFont(stored.toFontItem())) + assertFalse(stored.exists()) + assertFalse(store.deleteFont(outside.toFontItem())) + assertTrue(outside.exists()) + } finally { + tempRoot.deleteRecursively() + } + } + + @Test + fun `google font css parser extracts first https font url`() { + val css = """ + @font-face { + font-family: 'Literata'; + src: url(https://fonts.gstatic.com/s/literata/v35/font.ttf) format('truetype'); + } + """.trimIndent() + + assertEquals("https://fonts.gstatic.com/s/literata/v35/font.ttf", googleFontDownloadUrlFromCss(css)) + assertEquals("ttf", googleFontFileExtension("https://fonts.gstatic.com/s/literata/v35/font.ttf?foo=bar")) + } + + @Test + fun `google fonts json parser ignores blank names`() { + assertEquals(listOf("Inter", "Literata"), googleFontsFromJson("""["Inter", "", " Literata "]""")) + } + + private fun File.toFontItem(): CustomFontItem { + return CustomFontItem( + id = nameWithoutExtension, + displayName = nameWithoutExtension, + fileName = name, + fileExtension = extension, + path = absolutePath, + timestamp = 1L + ) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopFolderMetadataExtractorTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopFolderMetadataExtractorTest.kt new file mode 100644 index 0000000..17a64ed --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopFolderMetadataExtractorTest.kt @@ -0,0 +1,184 @@ +package com.aryan.reader.desktop + +import com.aryan.reader.shared.BookItem +import com.aryan.reader.shared.FileType +import java.io.File +import java.nio.file.Files +import java.util.Base64 +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class DesktopFolderMetadataExtractorTest { + @Test + fun `direct imported epub gets text metadata and embedded cover`() = withCoverCacheDir { tempDir -> + val epub = File(tempDir, "direct.epub") + writeEpub( + target = epub, + opf = """ + + + Direct EPUB + Ada Lovelace + + + + + + + """.trimIndent() + ) + val book = bookFor(epub, FileType.EPUB) + + val result = DesktopFolderMetadataExtractor.enrichImportedBooks( + books = listOf(book), + importedBookIds = setOf(book.id) + ) + + val enriched = result.books.single() + assertEquals("Direct EPUB", enriched.title) + assertEquals("Ada Lovelace", enriched.author) + assertTrue(enriched.folderTextMetadataParsed) + assertTrue(File(assertNotNull(enriched.coverImagePath)).isFile) + assertEquals(1, result.stats.updatedBooks) + assertEquals(1, result.stats.coversUpdated) + } + + @Test + fun `direct imported text file gets generated cover`() = withCoverCacheDir { tempDir -> + val textFile = File(tempDir, "notes.txt").apply { writeText("Notes") } + val book = bookFor(textFile, FileType.TXT, title = "Notes") + + val result = DesktopFolderMetadataExtractor.enrichImportedBooks( + books = listOf(book), + importedBookIds = setOf(book.id) + ) + + val enriched = result.books.single() + assertEquals("Notes", enriched.title) + assertFalse(enriched.folderTextMetadataParsed) + assertTrue(File(assertNotNull(enriched.coverImagePath)).isFile) + assertEquals(1, result.stats.updatedBooks) + assertEquals(1, result.stats.coversUpdated) + } + + @Test + fun `direct imported docx gets text metadata and generated cover`() = withCoverCacheDir { tempDir -> + val docx = File(tempDir, "direct.docx") + writeDocx( + target = docx, + title = "Direct DOCX", + author = "Grace Hopper", + bodyText = "Portable desktop document text." + ) + val book = bookFor(docx, FileType.DOCX, title = null) + + val result = DesktopFolderMetadataExtractor.enrichImportedBooks( + books = listOf(book), + importedBookIds = setOf(book.id) + ) + + val enriched = result.books.single() + assertEquals("Direct DOCX", enriched.title) + assertEquals("Grace Hopper", enriched.author) + assertTrue(enriched.folderTextMetadataParsed) + assertTrue(File(assertNotNull(enriched.coverImagePath)).isFile) + assertEquals(1, result.stats.updatedBooks) + assertEquals(1, result.stats.coversUpdated) + } + + private fun withCoverCacheDir(block: (File) -> Unit) { + val tempDir = Files.createTempDirectory("reader-desktop-covers").toFile() + val oldCacheDir = System.getProperty("reader.cover.cache.dir") + System.setProperty("reader.cover.cache.dir", File(tempDir, "covers").absolutePath) + try { + block(tempDir) + } finally { + if (oldCacheDir == null) { + System.clearProperty("reader.cover.cache.dir") + } else { + System.setProperty("reader.cover.cache.dir", oldCacheDir) + } + tempDir.deleteRecursively() + } + } + + private fun bookFor( + file: File, + type: FileType, + title: String? = file.nameWithoutExtension + ): BookItem { + return BookItem( + id = file.absolutePath, + path = file.absolutePath, + type = type, + displayName = file.name, + timestamp = 1L, + title = title, + fileSize = file.length(), + isRecent = false + ) + } + + private fun writeEpub(target: File, opf: String) { + ZipOutputStream(target.outputStream()).use { zip -> + zip.putText( + "META-INF/container.xml", + """ + + + + + + """.trimIndent() + ) + zip.putText("OEBPS/content.opf", opf) + zip.putBytes("OEBPS/images/cover.png", onePixelPngBytes()) + } + } + + private fun writeDocx(target: File, title: String, author: String, bodyText: String) { + ZipOutputStream(target.outputStream()).use { zip -> + zip.putText( + "docProps/core.xml", + """ + + $title + $author + + """.trimIndent() + ) + zip.putText( + "word/document.xml", + """ + + + $bodyText + + + """.trimIndent() + ) + } + } + + private fun ZipOutputStream.putText(name: String, value: String) { + putBytes(name, value.toByteArray(Charsets.UTF_8)) + } + + private fun ZipOutputStream.putBytes(name: String, value: ByteArray) { + putNextEntry(ZipEntry(name)) + write(value) + closeEntry() + } + + private fun onePixelPngBytes(): ByteArray { + return Base64.getDecoder().decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=" + ) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopOpdsRepositoryTest.kt b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopOpdsRepositoryTest.kt new file mode 100644 index 0000000..a0b96cb --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopOpdsRepositoryTest.kt @@ -0,0 +1,56 @@ +package com.aryan.reader.desktop + +import java.io.File +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class DesktopOpdsRepositoryTest { + @Test + fun `desktop repository persists shared opds catalog rules`() = withTempDir { dir -> + var nextId = 0 + val repository = DesktopOpdsRepository( + catalogFile = File(dir, "opds_catalogs.json"), + idFactory = { "catalog-${nextId++}" } + ) + + val defaults = repository.loadCatalogs() + assertEquals(2, defaults.size) + assertTrue(defaults.all { it.isDefault }) + + repository.addCatalogForTest(" Custom ", " https://example.org/opds ", " user ", " pass ") + val custom = repository.loadCatalogs().single { !it.isDefault } + assertEquals("Custom", custom.title) + assertEquals("https://example.org/opds", custom.url) + assertEquals("user", custom.username) + assertEquals("pass", custom.password) + } + + private fun DesktopOpdsRepository.addCatalogForTest( + title: String, + url: String, + username: String?, + password: String? + ) { + saveCatalogs( + com.aryan.reader.shared.opds.SharedOpdsCatalogs.addCatalog( + catalogs = loadCatalogs(), + title = title, + url = url, + username = username, + password = password, + idFactory = { "custom" } + ) + ) + } + + private fun withTempDir(block: (File) -> Unit) { + val dir = Files.createTempDirectory("reader-desktop-opds").toFile() + try { + block(dir) + } finally { + dir.deleteRecursively() + } + } +} diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index c03c9ba..d2a7964 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -4,6 +4,7 @@ plugins { alias(libs.plugins.kotlin.compose) alias(libs.plugins.compose.multiplatform) id("org.jetbrains.kotlin.plugin.serialization") version "2.1.20" + alias(libs.plugins.kover) } kotlin { @@ -32,6 +33,7 @@ kotlin { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") implementation("org.jetbrains.kotlinx:kotlinx-serialization-protobuf:1.7.3") + implementation("com.materialkolor:material-kolor:5.0.0-alpha07") } commonTest.dependencies { implementation(kotlin("test")) diff --git a/shared/src/androidMain/kotlin/com/aryan/reader/shared/LocalFolderSync.android.kt b/shared/src/androidMain/kotlin/com/aryan/reader/shared/LocalFolderSync.android.kt new file mode 100644 index 0000000..700e05e --- /dev/null +++ b/shared/src/androidMain/kotlin/com/aryan/reader/shared/LocalFolderSync.android.kt @@ -0,0 +1,8 @@ +package com.aryan.reader.shared + +import java.security.MessageDigest + +internal actual fun localFolderSyncSha256ShortHex(value: String): String { + val bytes = MessageDigest.getInstance("SHA-256").digest(value.toByteArray()) + return bytes.joinToString("") { "%02x".format(it) }.take(12) +} diff --git a/shared/src/androidMain/kotlin/com/aryan/reader/shared/ui/LocalBookCoverImage.android.kt b/shared/src/androidMain/kotlin/com/aryan/reader/shared/ui/LocalBookCoverImage.android.kt new file mode 100644 index 0000000..6282dc2 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/aryan/reader/shared/ui/LocalBookCoverImage.android.kt @@ -0,0 +1,28 @@ +package com.aryan.reader.shared.ui + +import android.graphics.BitmapFactory +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale + +@Composable +internal actual fun LocalBookCoverImage( + path: String, + contentDescription: String?, + modifier: Modifier +) { + val bitmap = remember(path) { + runCatching { BitmapFactory.decodeFile(path)?.asImageBitmap() }.getOrNull() + } + if (bitmap != null) { + Image( + bitmap = bitmap, + contentDescription = contentDescription, + modifier = modifier, + contentScale = ContentScale.Crop + ) + } +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/paginatedreader/CssParser.kt b/shared/src/commonMain/kotlin/com/aryan/reader/paginatedreader/CssParser.kt index a1b9ccd..bfd18c3 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/paginatedreader/CssParser.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/paginatedreader/CssParser.kt @@ -178,7 +178,8 @@ object CssParser { constraints: Constraints, isDarkTheme: Boolean, themeBackgroundColor: Color = Color.Unspecified, - themeTextColor: Color = Color.Unspecified + themeTextColor: Color = Color.Unspecified, + adaptThemeColors: Boolean = true ): OptimizedCssParseResult { val byTag = mutableMapOf>() val byClass = mutableMapOf>() @@ -193,7 +194,7 @@ object CssParser { val mediaQueryRegex = Regex("@media[^{]+\\{((?>[^{}]+|\\{[^{}]*\\})*)\\}") mediaQueryRegex.findAll(cleanedCss).forEach { match -> val condition = match.groups[0]?.value?.trim() ?: "" - if (isDarkTheme && condition.contains("prefers-color-scheme: dark")) { + if (adaptThemeColors && isDarkTheme && condition.contains("prefers-color-scheme: dark")) { val darkCss = match.groups[1]?.value ?: "" cleanedCss += "\n$darkCss" } @@ -231,12 +232,26 @@ object CssParser { } val specificity = calculateSpecificity(originalSelector) val normalStyle = parseProperties( - propertiesGroup, baseFontSizeSp, density, constraints, onlyImportant = false, - isDarkTheme, themeBackgroundColor, themeTextColor + properties = propertiesGroup, + baseFontSizeSp = baseFontSizeSp, + density = density, + constraints = constraints, + onlyImportant = false, + isDarkTheme = isDarkTheme, + themeBackgroundColor = themeBackgroundColor, + themeTextColor = themeTextColor, + adaptThemeColors = adaptThemeColors ) val importantStyle = parseProperties( - propertiesGroup, baseFontSizeSp, density, constraints, onlyImportant = true, - isDarkTheme, themeBackgroundColor, themeTextColor + properties = propertiesGroup, + baseFontSizeSp = baseFontSizeSp, + density = density, + constraints = constraints, + onlyImportant = true, + isDarkTheme = isDarkTheme, + themeBackgroundColor = themeBackgroundColor, + themeTextColor = themeTextColor, + adaptThemeColors = adaptThemeColors ) fun addRule(style: CssStyle, spec: Int) { @@ -376,7 +391,8 @@ object CssParser { onlyImportant: Boolean, isDarkTheme: Boolean, themeBackgroundColor: Color = Color.Unspecified, - themeTextColor: Color = Color.Unspecified + themeTextColor: Color = Color.Unspecified, + adaptThemeColors: Boolean = true ): CssStyle { var spanStyle = SpanStyle() var paragraphStyle = ParagraphStyle() @@ -451,6 +467,14 @@ object CssParser { var borderBottomRightRadius: Dp = 0.dp var borderBottomLeftRadius: Dp = 0.dp + fun maybeAdaptColor(color: Color, isBackground: Boolean): Color { + return if (adaptThemeColors) { + this@CssParser.adaptColorForTheme(color, isDarkTheme, isBackground, themeBackgroundColor, themeTextColor) + } else { + color + } + } + splitDeclarations(properties).filter { it.isNotBlank() }.forEach { prop -> val parts = prop.split(':', limit = 2).map { it.trim() } if (parts.size == 2) { @@ -473,7 +497,7 @@ object CssParser { styleStr: String? ) { val parsedWidth = widthStr?.let { parseCssSizeToDp(it, baseFontSizeSp, density, containerWidthPx) } ?: 0.dp - val parsedColor = colorStr?.let { parseColor(it) }?.let { this@CssParser.adaptColorForTheme(it, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor) } + val parsedColor = colorStr?.let { parseColor(it) }?.let { maybeAdaptColor(it, isBackground = false) } val isExplicitWidth = widthStr != null @@ -528,7 +552,7 @@ object CssParser { } "color" -> { parseColor(value)?.let { - spanStyle = spanStyle.copy(color = this@CssParser.adaptColorForTheme(it, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor)) + spanStyle = spanStyle.copy(color = maybeAdaptColor(it, isBackground = false)) } } "text-align" -> { @@ -585,7 +609,7 @@ object CssParser { val styles = listOf("solid", "double", "dotted", "dashed", "wavy") parts.firstOrNull { it in styles }?.let { textDecorationStyle = it } parts.firstNotNullOfOrNull { parseColor(it) }?.let { color -> - textDecorationColor = this@CssParser.adaptColorForTheme(color, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor) + textDecorationColor = maybeAdaptColor(color, isBackground = false) } } "word-spacing" -> { @@ -601,7 +625,7 @@ object CssParser { } "text-decoration-color" -> { parseColor(value)?.let { - textDecorationColor = this@CssParser.adaptColorForTheme(it, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor) + textDecorationColor = maybeAdaptColor(it, isBackground = false) } } "text-underline-offset" -> { @@ -661,7 +685,7 @@ object CssParser { "background-color" -> { val originalColor = parseColor(value) ?: Color.Unspecified - backgroundColor = this@CssParser.adaptColorForTheme(originalColor, isDarkTheme, isBackground = true, themeBackgroundColor, themeTextColor) + backgroundColor = maybeAdaptColor(originalColor, isBackground = true) } // Border Properties @@ -801,7 +825,7 @@ object CssParser { textEmphasisStyleString = value } "text-emphasis-color", "-epub-text-emphasis-color" -> { - textEmphasisColor = parseColor(value)?.let { this@CssParser.adaptColorForTheme(it, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor) } + textEmphasisColor = parseColor(value)?.let { maybeAdaptColor(it, isBackground = false) } } "text-emphasis-position", "-epub-text-emphasis-position" -> { if (value in listOf("over", "under")) { @@ -859,7 +883,7 @@ object CssParser { val finalStyle = style ?: "none" val finalColor = color ?: spanStyle.color.takeIf { it.isSpecified } ?: Color.Black - val adaptedColor = this@CssParser.adaptColorForTheme(finalColor, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor) + val adaptedColor = maybeAdaptColor(finalColor, isBackground = false) if (finalWidth > 0.dp && finalStyle != "none" && finalStyle != "hidden") { return BorderStyle(finalWidth, adaptedColor, finalStyle) diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/AppActions.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/AppActions.kt index 63cf1c7..8ebb2de 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/AppActions.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/AppActions.kt @@ -1,5 +1,9 @@ package com.aryan.reader.shared +import androidx.compose.ui.graphics.Color +import com.aryan.reader.shared.reader.ReaderSettings +import com.aryan.reader.shared.reader.ReaderSearchOptions + sealed interface LibraryAction { data class SearchChanged(val query: String) : LibraryAction data class SortChanged(val sortOrder: SortOrder) : LibraryAction @@ -16,15 +20,36 @@ sealed interface ReaderAction { data object NextPage : ReaderAction data object PreviousPage : ReaderAction data class GoToPage(val pageIndex: Int) : ReaderAction + data class GoToPageNumber(val pageNumber: Int) : ReaderAction data class GoToProgress(val progress: Float) : ReaderAction data class GoToChapter(val chapterIndex: Int) : ReaderAction + data class GoToLocator(val locator: ReaderLocator) : ReaderAction + data class VisiblePageChanged(val pageIndex: Int, val locator: ReaderLocator? = null) : ReaderAction + data class GoToSearchResult(val resultIndex: Int) : ReaderAction data class SearchChanged(val query: String) : ReaderAction + data object SearchOpened : ReaderAction + data object SearchClosed : ReaderAction + data object SearchResultsPanelToggled : ReaderAction + data class SearchOptionsChanged(val options: ReaderSearchOptions) : ReaderAction data object NextSearchResult : ReaderAction data object PreviousSearchResult : ReaderAction data object ToggleBookmark : ReaderAction + data class ToggleBookmarkAtLocator( + val locator: ReaderLocator, + val title: String? = null, + val preview: String? = null + ) : ReaderAction + data class SettingsChanged(val settings: ReaderSettings) : ReaderAction data class RenderModeChanged(val renderMode: RenderMode) : ReaderAction data class ThemeChanged(val theme: ReaderTheme) : ReaderAction data class FormatChanged(val settings: FormatSettings) : ReaderAction + data class HighlightCreated(val highlight: UserHighlight) : ReaderAction + data class HighlightUpdated( + val highlightId: String, + val color: HighlightColor? = null, + val note: String? = null + ) : ReaderAction + data class HighlightDeleted(val highlightId: String) : ReaderAction } sealed interface AppAction { @@ -33,6 +58,25 @@ sealed interface AppAction { data class NavigationRequested(val event: NavigationEvent) : AppAction data class AppThemeChanged(val mode: AppThemeMode) : AppAction data class AppContrastChanged(val option: AppContrastOption) : AppAction + data class AppTextDimFactorLightChanged(val factor: Float) : AppAction + data class AppTextDimFactorDarkChanged(val factor: Float) : AppAction + data class AppSeedColorChanged(val color: Color?) : AppAction + data class CustomAppThemeAdded(val theme: CustomAppTheme) : AppAction + data class CustomAppThemeDeleted(val themeId: String) : AppAction data class SyncEnabledChanged(val enabled: Boolean) : AppAction data class FolderSyncEnabledChanged(val enabled: Boolean) : AppAction + data class TabsEnabledChanged(val enabled: Boolean) : AppAction + data class BookTabOpened(val bookId: String) : AppAction + data class BookTabClosed(val bookId: String) : AppAction + data object AllTabsClosed : AppAction + data class HomePinToggled(val bookId: String) : AppAction + data class LibraryPinToggled(val bookId: String) : AppAction + data class ReaderToolbarPreferencesChanged(val preferences: ReaderToolbarPreferences) : AppAction + data class ReaderToolVisibilityChanged(val tool: ReaderTool, val hidden: Boolean) : AppAction + data class ReaderToolPlacementChanged(val tool: ReaderTool, val bottom: Boolean) : AppAction + data class ReaderToolOrderChanged(val toolOrder: List) : AppAction + data class ReaderHighlightPaletteChanged(val palette: ReaderHighlightPalette) : AppAction + data class ReaderTtsReplacementPreferencesChanged( + val preferences: ReaderTtsReplacementPreferences, + ) : AppAction } diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/AppModels.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/AppModels.kt index 70f298d..35ba1c7 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/AppModels.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/AppModels.kt @@ -118,5 +118,8 @@ data class SharedReaderScreenState( val appSeedColor: Color? = null, val customAppThemes: List = emptyList(), val allTags: List = emptyList(), - val showTagSelectionDialogFor: Set = emptySet() + val showTagSelectionDialogFor: Set = emptySet(), + val readerToolbarPreferences: ReaderToolbarPreferences = ReaderToolbarPreferences(), + val readerHighlightPalette: ReaderHighlightPalette = ReaderHighlightPalette(), + val readerTtsReplacementPreferences: ReaderTtsReplacementPreferences = ReaderTtsReplacementPreferences() ) diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/CustomFontModels.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/CustomFontModels.kt new file mode 100644 index 0000000..95d1551 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/CustomFontModels.kt @@ -0,0 +1,12 @@ +package com.aryan.reader.shared + +data class CustomFontItem( + val id: String, + val displayName: String, + val fileName: String, + val fileExtension: String, + val path: String, + val timestamp: Long, + val isDeleted: Boolean = false +) + diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/FileCapabilities.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/FileCapabilities.kt new file mode 100644 index 0000000..6fd1dd0 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/FileCapabilities.kt @@ -0,0 +1,180 @@ +package com.aryan.reader.shared + +enum class ReaderPlatform { + ANDROID, + DESKTOP +} + +enum class ReaderFeatureSurface { + PDF_VIEWER, + EPUB_READER, + TEXT_READER +} + +data class FileTypeCapability( + val type: FileType, + val displayName: String, + val extensions: Set, + val androidSurface: ReaderFeatureSurface?, + val desktopSurface: ReaderFeatureSurface?, + val syncEligible: Boolean = true +) { + val isReadableOnAndroid: Boolean get() = androidSurface != null + val isReadableOnDesktop: Boolean get() = desktopSurface != null + + fun surfaceFor(platform: ReaderPlatform): ReaderFeatureSurface? { + return when (platform) { + ReaderPlatform.ANDROID -> androidSurface + ReaderPlatform.DESKTOP -> desktopSurface + } + } +} + +object SharedFileCapabilities { + val all: List = listOf( + FileTypeCapability( + type = FileType.EPUB, + displayName = "EPUB", + extensions = setOf("epub"), + androidSurface = ReaderFeatureSurface.EPUB_READER, + desktopSurface = ReaderFeatureSurface.EPUB_READER + ), + FileTypeCapability( + type = FileType.PDF, + displayName = "PDF", + extensions = setOf("pdf"), + androidSurface = ReaderFeatureSurface.PDF_VIEWER, + desktopSurface = ReaderFeatureSurface.PDF_VIEWER + ), + FileTypeCapability( + type = FileType.TXT, + displayName = "TXT", + extensions = setOf("txt"), + androidSurface = ReaderFeatureSurface.EPUB_READER, + desktopSurface = ReaderFeatureSurface.TEXT_READER + ), + FileTypeCapability( + type = FileType.MD, + displayName = "Markdown", + extensions = setOf("md", "markdown"), + androidSurface = ReaderFeatureSurface.EPUB_READER, + desktopSurface = ReaderFeatureSurface.TEXT_READER + ), + FileTypeCapability( + type = FileType.HTML, + displayName = "HTML", + extensions = setOf("html", "htm", "xhtml"), + androidSurface = ReaderFeatureSurface.EPUB_READER, + desktopSurface = ReaderFeatureSurface.TEXT_READER + ), + FileTypeCapability( + type = FileType.MOBI, + displayName = "MOBI", + extensions = setOf("mobi", "azw", "azw3", "prc"), + androidSurface = ReaderFeatureSurface.EPUB_READER, + desktopSurface = ReaderFeatureSurface.TEXT_READER + ), + FileTypeCapability( + type = FileType.FB2, + displayName = "FB2", + extensions = setOf("fb2"), + androidSurface = ReaderFeatureSurface.EPUB_READER, + desktopSurface = ReaderFeatureSurface.TEXT_READER + ), + FileTypeCapability( + type = FileType.CBZ, + displayName = "CBZ", + extensions = setOf("cbz"), + androidSurface = ReaderFeatureSurface.PDF_VIEWER, + desktopSurface = ReaderFeatureSurface.PDF_VIEWER + ), + FileTypeCapability( + type = FileType.CBR, + displayName = "CBR", + extensions = setOf("cbr"), + androidSurface = ReaderFeatureSurface.PDF_VIEWER, + desktopSurface = ReaderFeatureSurface.PDF_VIEWER + ), + FileTypeCapability( + type = FileType.CB7, + displayName = "CB7", + extensions = setOf("cb7"), + androidSurface = ReaderFeatureSurface.PDF_VIEWER, + desktopSurface = ReaderFeatureSurface.PDF_VIEWER + ), + FileTypeCapability( + type = FileType.DOCX, + displayName = "DOCX", + extensions = setOf("docx"), + androidSurface = ReaderFeatureSurface.EPUB_READER, + desktopSurface = ReaderFeatureSurface.TEXT_READER + ), + FileTypeCapability( + type = FileType.ODT, + displayName = "ODT", + extensions = setOf("odt"), + androidSurface = ReaderFeatureSurface.EPUB_READER, + desktopSurface = ReaderFeatureSurface.TEXT_READER + ), + FileTypeCapability( + type = FileType.FODT, + displayName = "FODT", + extensions = setOf("fodt"), + androidSurface = ReaderFeatureSurface.EPUB_READER, + desktopSurface = ReaderFeatureSurface.TEXT_READER + ) + ) + + private val capabilitiesByType: Map = all.associateBy { it.type } + private val typesByExtension: Map = all + .flatMap { capability -> capability.extensions.map { it.lowercase() to capability.type } } + .toMap() + + fun capabilityFor(type: FileType): FileTypeCapability? { + return capabilitiesByType[type] + } + + fun displayNameFor(type: FileType): String { + return capabilityFor(type)?.displayName ?: type.name + } + + fun fileTypeForName(fileName: String): FileType { + val extension = fileName.substringAfterLast('.', missingDelimiterValue = "") + .substringBefore('?') + .substringBefore('#') + .lowercase() + return typesByExtension[extension] ?: FileType.UNKNOWN + } + + fun surfaceFor(type: FileType, platform: ReaderPlatform): ReaderFeatureSurface? { + return capabilityFor(type)?.surfaceFor(platform) + } + + fun canOpen(type: FileType, platform: ReaderPlatform): Boolean { + return surfaceFor(type, platform) != null + } + + fun readableTypesFor(platform: ReaderPlatform): Set { + return all.mapNotNullTo(mutableSetOf()) { capability -> + capability.type.takeIf { capability.surfaceFor(platform) != null } + } + } + + fun syncableTypesFor(platform: ReaderPlatform): Set { + return all.mapNotNullTo(mutableSetOf()) { capability -> + capability.type.takeIf { capability.syncEligible && capability.surfaceFor(platform) != null } + } + } + + fun supportedFormatsLabel(platform: ReaderPlatform): String { + return all + .filter { it.surfaceFor(platform) != null } + .joinToString(", ") { it.displayName } + } + + fun desktopParityGaps(): List { + return all + .filter { it.isReadableOnAndroid && !it.isReadableOnDesktop } + .map { it.type } + } +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryModels.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryModels.kt index 414ccea..fdb7523 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryModels.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryModels.kt @@ -1,5 +1,8 @@ package com.aryan.reader.shared +import com.aryan.reader.shared.reader.ReaderBookmark +import com.aryan.reader.shared.reader.ReaderSettings + enum class FileType { PDF, EPUB, MOBI, MD, TXT, HTML, FB2, CBZ, CBR, CB7, DOCX, ODT, FODT, UNKNOWN } @@ -45,6 +48,8 @@ enum class ReadStatusFilter { COMPLETED } +const val IN_APP_STORAGE_SOURCE = "IN_APP_STORAGE" + enum class ShelfType { MANUAL, SMART, @@ -72,15 +77,21 @@ data class BookItem( val type: FileType, val displayName: String, val timestamp: Long, + val coverImagePath: String? = null, val title: String? = null, val author: String? = null, val progressPercentage: Float? = null, val isRecent: Boolean = true, val fileSize: Long = 0L, val sourceFolder: String? = null, + val folderTextMetadataParsed: Boolean = false, val seriesName: String? = null, val seriesIndex: Double? = null, - val tags: List = emptyList() + val tags: List = emptyList(), + val lastPageIndex: Int? = null, + val readerSettings: ReaderSettings? = null, + val readerBookmarks: List = emptyList(), + val readerHighlights: List = emptyList() ) data class Shelf( diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryMutations.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryMutations.kt new file mode 100644 index 0000000..3bd65a9 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryMutations.kt @@ -0,0 +1,293 @@ +package com.aryan.reader.shared + +data class SharedLibraryMutationResult( + val state: SharedReaderScreenState, + val shelfRecords: List, + val shelfRefs: List +) + +object SharedLibraryEditor { + fun cleanShelfName(name: String): String? { + return name.trim().takeIf { it.isNotBlank() } + } + + fun canMutateShelf(shelfId: String?): Boolean { + val trimmed = shelfId?.trim() + return !trimmed.isNullOrBlank() && trimmed != "unshelved" + } + + fun createShelfRecord( + name: String, + id: String, + isSmart: Boolean = false, + smartRulesJson: String? = null + ): ShelfRecord? { + val trimmed = cleanShelfName(name) ?: return null + val trimmedId = id.trim().takeIf { it.isNotBlank() } ?: return null + return ShelfRecord( + id = trimmedId, + name = trimmed, + isSmart = isSmart, + smartRulesJson = smartRulesJson + ) + } + + fun cleanTagName(name: String): String? { + return name.trim().takeIf { it.isNotBlank() } + } + + fun createTag( + name: String, + id: String, + color: Int? = 0xFF64B5F6.toInt() + ): Tag? { + val trimmed = cleanTagName(name) ?: return null + val trimmedId = id.trim().takeIf { it.isNotBlank() } ?: return null + return Tag( + id = trimmedId, + name = trimmed, + color = color + ) + } + + fun cleanBookIds(bookIds: Iterable): Set { + return bookIds.mapTo(mutableSetOf()) { it.trim() }.filterTo(mutableSetOf()) { it.isNotBlank() } + } + + fun removeSelectedBooks( + state: SharedReaderScreenState, + shelfRecords: List, + shelfRefs: List + ): SharedLibraryMutationResult? { + val selected = state.selectedBookIds + if (selected.isEmpty()) return null + return SharedLibraryMutationResult( + state = state.copy( + rawLibraryBooks = state.rawLibraryBooks.filterNot { it.id in selected }, + selectedBookIds = emptySet(), + bannerMessage = BannerMessage("Removed ${selected.size} book(s) from the library.") + ), + shelfRecords = shelfRecords, + shelfRefs = shelfRefs.filterNot { it.bookId in selected } + ) + } + + fun createShelf( + state: SharedReaderScreenState, + shelfRecords: List, + shelfRefs: List, + name: String, + nowMillis: Long = currentTimestamp() + ): SharedLibraryMutationResult? { + val trimmed = cleanShelfName(name) ?: return null + return SharedLibraryMutationResult( + state = state.copy(bannerMessage = BannerMessage("Created shelf \"$trimmed\".")), + shelfRecords = shelfRecords + ShelfRecord(id = "shelf_$nowMillis", name = trimmed), + shelfRefs = shelfRefs + ) + } + + fun createSmartShelf( + state: SharedReaderScreenState, + shelfRecords: List, + shelfRefs: List, + name: String, + definition: SmartCollectionDefinition, + nowMillis: Long = currentTimestamp() + ): SharedLibraryMutationResult? { + val trimmed = cleanShelfName(name) ?: return null + val cleanedRules = definition.rules.mapNotNull { rule -> + rule.value.trim().takeIf { it.isNotBlank() }?.let { value -> rule.copy(value = value) } + } + if (cleanedRules.isEmpty()) return null + val cleanedDefinition = definition.copy(rules = cleanedRules) + return SharedLibraryMutationResult( + state = state.copy(bannerMessage = BannerMessage("Created smart shelf \"$trimmed\".")), + shelfRecords = shelfRecords + ShelfRecord( + id = "smart_$nowMillis", + name = trimmed, + isSmart = true, + smartRulesJson = SmartCollectionEngine.toJson(cleanedDefinition) + ), + shelfRefs = shelfRefs + ) + } + + fun renameShelf( + state: SharedReaderScreenState, + shelfRecords: List, + shelfRefs: List, + shelf: Shelf, + name: String + ): SharedLibraryMutationResult? { + val trimmed = cleanShelfName(name) ?: return null + return SharedLibraryMutationResult( + state = state.copy(bannerMessage = BannerMessage("Renamed shelf to \"$trimmed\".")), + shelfRecords = shelfRecords.map { if (it.id == shelf.id) it.copy(name = trimmed) else it }, + shelfRefs = shelfRefs + ) + } + + fun deleteShelf( + state: SharedReaderScreenState, + shelfRecords: List, + shelfRefs: List, + shelf: Shelf + ): SharedLibraryMutationResult { + return SharedLibraryMutationResult( + state = state.copy(bannerMessage = BannerMessage("Deleted shelf \"${shelf.name}\".")), + shelfRecords = shelfRecords.filterNot { it.id == shelf.id }, + shelfRefs = shelfRefs.filterNot { it.shelfId == shelf.id } + ) + } + + fun removeFolder( + state: SharedReaderScreenState, + shelfRecords: List, + shelfRefs: List, + folder: Shelf + ): SharedLibraryMutationResult? { + if (folder.type != ShelfType.FOLDER) return null + val folderBookIds = cleanBookIds(folder.books.map { it.id }) + if (folderBookIds.isEmpty()) return null + val rootSourceFolder = folder.books.firstNotNullOfOrNull { it.sourceFolder } + val remainingTabs = state.openTabIds.filterNot { it in folderBookIds } + return SharedLibraryMutationResult( + state = state.copy( + rawLibraryBooks = state.rawLibraryBooks.filterNot { it.id in folderBookIds }, + selectedBookIds = state.selectedBookIds - folderBookIds, + pinnedHomeBookIds = state.pinnedHomeBookIds - folderBookIds, + pinnedLibraryBookIds = state.pinnedLibraryBookIds - folderBookIds, + openTabIds = remainingTabs, + activeTabBookId = state.activeTabBookId?.takeUnless { it in folderBookIds }, + syncedFolders = if (folder.parentShelfId == null && rootSourceFolder != null) { + state.syncedFolders.filterNot { it.uriString == rootSourceFolder } + } else { + state.syncedFolders + }, + libraryFilters = if (rootSourceFolder != null) { + state.libraryFilters.copy(sourceFolders = state.libraryFilters.sourceFolders - rootSourceFolder) + } else { + state.libraryFilters + }, + bannerMessage = BannerMessage("Removed folder \"${folder.name}\" and ${folderBookIds.size} book(s) from the app.") + ), + shelfRecords = shelfRecords, + shelfRefs = shelfRefs.filterNot { it.bookId in folderBookIds } + ) + } + + fun markBookOpened( + state: SharedReaderScreenState, + bookId: String, + nowMillis: Long = currentTimestamp() + ): SharedReaderScreenState { + val cleanedBookId = bookId.trim() + if (cleanedBookId.isBlank()) return state + return state.copy( + rawLibraryBooks = state.rawLibraryBooks.map { book -> + if (book.id == cleanedBookId) { + book.copy(isRecent = true, timestamp = nowMillis) + } else { + book + } + } + ) + } + + fun addSelectedBooksToShelf( + state: SharedReaderScreenState, + shelfRecords: List, + shelfRefs: List, + shelfId: String, + nowMillis: Long = currentTimestamp() + ): SharedLibraryMutationResult? { + val selected = state.selectedBookIds + if (selected.isEmpty()) return null + val existing = shelfRefs.mapTo(mutableSetOf()) { it.bookId to it.shelfId } + val additions = selected.mapNotNull { bookId -> + if (!existing.add(bookId to shelfId)) null else BookShelfRef(bookId = bookId, shelfId = shelfId, addedAt = nowMillis) + } + return SharedLibraryMutationResult( + state = state.copy( + selectedBookIds = emptySet(), + bannerMessage = BannerMessage("Added ${additions.size} book(s) to shelf.") + ), + shelfRecords = shelfRecords, + shelfRefs = shelfRefs + additions + ) + } + + fun tagSelectedBooks( + state: SharedReaderScreenState, + shelfRecords: List, + shelfRefs: List, + tagName: String, + nowMillis: Long = currentTimestamp() + ): SharedLibraryMutationResult? { + val selected = cleanBookIds(state.selectedBookIds) + val trimmed = cleanTagName(tagName) ?: return null + if (selected.isEmpty()) return null + val existingTag = state.allTags.firstOrNull { it.name.equals(trimmed, ignoreCase = true) } + val tag = existingTag ?: Tag( + id = trimmed.toStableTagId("tag_$nowMillis"), + name = trimmed, + color = 0xFF64B5F6.toInt() + ) + val allTags = (state.allTags + tag).distinctBy { it.id }.sortedBy { it.name.lowercase() } + val books = state.rawLibraryBooks.map { book -> + if (book.id in selected && book.tags.none { it.id == tag.id }) { + book.copy(tags = (book.tags + tag).sortedBy { it.name.lowercase() }) + } else { + book + } + } + return SharedLibraryMutationResult( + state = state.copy( + rawLibraryBooks = books, + allTags = allTags, + selectedBookIds = emptySet(), + bannerMessage = BannerMessage("Tagged ${selected.size} book(s) with \"${tag.name}\".") + ), + shelfRecords = shelfRecords, + shelfRefs = shelfRefs + ) + } + + fun updateBookMetadata( + state: SharedReaderScreenState, + shelfRecords: List, + shelfRefs: List, + updated: BookItem, + nowMillis: Long = currentTimestamp() + ): SharedLibraryMutationResult { + return SharedLibraryMutationResult( + state = state.copy( + rawLibraryBooks = state.rawLibraryBooks.map { if (it.id == updated.id) updated.copy(timestamp = nowMillis) else it }, + allTags = (state.allTags + updated.tags).distinctBy { it.id }.sortedBy { it.name.lowercase() }, + bannerMessage = BannerMessage("Updated \"${updated.cardTitle()}\".") + ), + shelfRecords = shelfRecords, + shelfRefs = shelfRefs + ) + } +} + +fun parseTagList(input: String, knownTags: List, nowMillis: Long = currentTimestamp()): List { + return input.split(',') + .map { it.trim() } + .filter { it.isNotBlank() } + .distinctBy { it.lowercase() } + .mapIndexed { index, name -> + knownTags.firstOrNull { it.name.equals(name, ignoreCase = true) } + ?: Tag( + id = name.toStableTagId("tag_${nowMillis + index}"), + name = name, + color = 0xFF64B5F6.toInt() + ) + } +} + +private fun String.toStableTagId(fallback: String): String { + return lowercase().replace(Regex("[^a-z0-9]+"), "_").trim('_').ifBlank { fallback } +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryProjector.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryProjector.kt index 791b5f4..04b56dc 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryProjector.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryProjector.kt @@ -43,7 +43,8 @@ class LibraryProjector { timestamp = now + index, title = file.name.substringBeforeLast('.'), fileSize = file.size, - sourceFolder = file.path?.parentPath() + sourceFolder = file.sourceFolder ?: file.path?.parentPath(), + isRecent = false ) } } @@ -57,7 +58,7 @@ class LibraryProjector { return when (sortOrder) { SortOrder.RECENT -> books.sortedByDescending { it.timestamp } SortOrder.TITLE_ASC -> books.sortedBy { it.title?.lowercase() ?: it.displayName.lowercase() } - SortOrder.AUTHOR_ASC -> books.sortedBy { it.author?.lowercase() ?: "" } + SortOrder.AUTHOR_ASC -> books.sortedWith(compareBy(nullsLast()) { it.author?.lowercase() }) SortOrder.PERCENT_ASC -> books.sortedBy { it.progressPercentage ?: 0f } SortOrder.PERCENT_DESC -> books.sortedByDescending { it.progressPercentage ?: 0f } SortOrder.SIZE_ASC -> books.sortedBy { it.fileSize } @@ -79,7 +80,7 @@ class LibraryProjector { fun applyFilters(books: List, filters: LibraryFilters): List { return books.filter { book -> val matchesType = filters.fileTypes.isEmpty() || book.type in filters.fileTypes - val matchesFolder = filters.sourceFolders.isEmpty() || book.sourceFolder in filters.sourceFolders + val matchesFolder = book.matchesSourceFolders(filters.sourceFolders) val progress = book.progressPercentage ?: 0f val matchesStatus = when (filters.readStatus) { ReadStatusFilter.ALL -> true @@ -148,26 +149,12 @@ private fun String.folderDisplayName(): String { data class ImportedFile( val name: String, val path: String?, - val size: Long + val size: Long, + val sourceFolder: String? = null ) expect fun currentTimestamp(): Long fun String.toFileType(): FileType { - return when (substringAfterLast('.', "").lowercase()) { - "pdf" -> FileType.PDF - "epub" -> FileType.EPUB - "mobi" -> FileType.MOBI - "md" -> FileType.MD - "txt" -> FileType.TXT - "html", "htm" -> FileType.HTML - "fb2" -> FileType.FB2 - "cbz" -> FileType.CBZ - "cbr" -> FileType.CBR - "cb7" -> FileType.CB7 - "docx" -> FileType.DOCX - "odt" -> FileType.ODT - "fodt" -> FileType.FODT - else -> FileType.UNKNOWN - } + return SharedFileCapabilities.fileTypeForName(this) } diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryStateProjector.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryStateProjector.kt index 888997a..2ee4fb3 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryStateProjector.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryStateProjector.kt @@ -38,11 +38,16 @@ class SharedLibraryStateProjector( val queried = filterBySearch(allLibraryBooks, current.searchQuery) val filtered = applyLibraryFilters(queried, current.libraryFilters) val sortedLibraryBooks = sortBooks(filtered, current.sortOrder) + .withPinnedFirst(current.pinnedLibraryBookIds) val visibleRecentBooks = sortBooks( allLibraryBooks.filter { it.isRecent }, current.sortOrder - ).take(if (current.recentFilesLimit > 0) current.recentFilesLimit else Int.MAX_VALUE) + ) + .withPinnedFirst(current.pinnedHomeBookIds) + .take(if (current.recentFilesLimit > 0) current.recentFilesLimit else Int.MAX_VALUE) val openTabs = current.openTabIds.mapNotNull { tabId -> allLibraryBooks.find { it.id == tabId } } + val openTabIds = openTabs.map { it.id } + val activeTabBookId = current.activeTabBookId?.takeIf { it in openTabIds } val shelfProjection = buildShelves( allLibraryBooks = allLibraryBooks, shelfRecords = input.shelfRecords, @@ -81,6 +86,8 @@ class SharedLibraryStateProjector( }, shelves = shelfProjection.shelves, openTabs = openTabs, + openTabIds = openTabIds, + activeTabBookId = activeTabBookId, booksAvailableForAdding = booksAvailableForAdding, allTags = input.tags ) @@ -99,13 +106,22 @@ class SharedLibraryStateProjector( val booksById = allLibraryBooks.associateBy { it.id } shelfRecords.forEach { shelf -> - val bookIds = shelfRefs - .filter { it.shelfId == shelf.id } - .sortedBy { it.addedAt } - .map { it.bookId } - val books = bookIds.mapNotNull { booksById[it] } - shelves.add(Shelf(shelf.id, shelf.name, ShelfType.MANUAL, sortBooks(books, sortOrder))) - shelvedBookIds.addAll(bookIds) + if (shelf.isSmart && shelf.smartRulesJson != null) { + val definition = SmartCollectionEngine.fromJson(shelf.smartRulesJson) + if (definition != null) { + val matchingBooks = allLibraryBooks.filter { SmartCollectionEngine.evaluate(it, definition) } + shelves.add(Shelf(shelf.id, shelf.name, ShelfType.SMART, sortBooks(matchingBooks, sortOrder))) + shelvedBookIds.addAll(matchingBooks.map { it.id }) + } + } else { + val bookIds = shelfRefs + .filter { it.shelfId == shelf.id } + .sortedBy { it.addedAt } + .map { it.bookId } + val books = bookIds.mapNotNull { booksById[it] } + shelves.add(Shelf(shelf.id, shelf.name, ShelfType.MANUAL, sortBooks(books, sortOrder))) + shelvedBookIds.addAll(bookIds) + } } val tagShelves = tags.mapNotNull { tag -> @@ -257,7 +273,7 @@ fun filterBySearch(books: List, searchQuery: String): List { fun applyLibraryFilters(books: List, filters: LibraryFilters): List { return books.filter { book -> val matchType = filters.fileTypes.isEmpty() || book.type in filters.fileTypes - val matchFolder = filters.sourceFolders.isEmpty() || book.sourceFolder in filters.sourceFolders + val matchFolder = book.matchesSourceFolders(filters.sourceFolders) val progress = book.progressPercentage ?: 0f val matchStatus = when (filters.readStatus) { ReadStatusFilter.ALL -> true @@ -274,7 +290,7 @@ fun sortBooks(books: List, sortOrder: SortOrder): List { return when (sortOrder) { SortOrder.RECENT -> books.sortedByDescending { it.timestamp } SortOrder.TITLE_ASC -> books.sortedBy { it.title?.lowercase() ?: it.displayName.lowercase() } - SortOrder.AUTHOR_ASC -> books.sortedBy { it.author?.lowercase() ?: "" } + SortOrder.AUTHOR_ASC -> books.sortedWith(compareBy(nullsLast()) { it.author?.lowercase() }) SortOrder.PERCENT_ASC -> books.sortedBy { it.progressPercentage ?: 0f } SortOrder.PERCENT_DESC -> books.sortedByDescending { it.progressPercentage ?: 0f } SortOrder.SIZE_ASC -> books.sortedBy { it.fileSize } @@ -301,7 +317,8 @@ fun SharedReaderScreenState.withImportedFiles( timestamp = now + index, title = file.name.substringBeforeLast('.'), fileSize = file.size, - sourceFolder = file.localPath?.parentPath() + sourceFolder = file.sourceFolder ?: file.localPath?.parentPath(), + isRecent = false ) } } @@ -322,3 +339,13 @@ private fun String.parentPath(): String? { val parent = normalized.substringBeforeLast('/', missingDelimiterValue = "") return parent.ifBlank { null } } + +private fun List.withPinnedFirst(pinnedBookIds: Set): List { + if (pinnedBookIds.isEmpty()) return this + return withIndex() + .sortedWith( + compareByDescending> { it.value.id in pinnedBookIds } + .thenBy { it.index } + ) + .map { it.value } +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/LocalFolderSync.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/LocalFolderSync.kt new file mode 100644 index 0000000..ec8fed1 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/LocalFolderSync.kt @@ -0,0 +1,507 @@ +package com.aryan.reader.shared + +import com.aryan.reader.shared.reader.ReaderBookmark +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull + +const val LOCAL_FOLDER_SYNC_DATA_DIR = "EpistemeSyncData" +const val LOCAL_FOLDER_ANNOTATION_SUFFIX = "_annotations" + +internal expect fun localFolderSyncSha256ShortHex(value: String): String + +data class SharedFolderBookMetadata( + val bookId: String, + val title: String?, + val author: String?, + val displayName: String, + val type: String, + val lastChapterIndex: Int?, + val lastPage: Int?, + val lastPositionCfi: String?, + val progressPercentage: Float, + val isRecent: Boolean, + val lastModifiedTimestamp: Long, + val bookmarksJson: String?, + val locatorBlockIndex: Int?, + val locatorCharOffset: Int?, + val customName: String?, + val highlightsJson: String? +) { + fun toJsonString(): String { + return folderSyncJson.encodeToString( + JsonElement.serializer(), + JsonObject( + mapOf( + "bookId" to JsonPrimitive(bookId), + "title" to title.asJson(), + "author" to author.asJson(), + "displayName" to JsonPrimitive(displayName), + "type" to JsonPrimitive(type), + "lastChapterIndex" to JsonPrimitive(lastChapterIndex ?: -1), + "lastPage" to JsonPrimitive(lastPage ?: -1), + "lastPositionCfi" to lastPositionCfi.asJson(), + "progressPercentage" to JsonPrimitive(progressPercentage.toDouble()), + "isRecent" to JsonPrimitive(isRecent), + "lastModifiedTimestamp" to JsonPrimitive(lastModifiedTimestamp), + "bookmarksJson" to bookmarksJson.asJson(), + "locatorBlockIndex" to JsonPrimitive(locatorBlockIndex ?: -1), + "locatorCharOffset" to JsonPrimitive(locatorCharOffset ?: -1), + "customName" to customName.asJson(), + "highlightsJson" to highlightsJson.asJson() + ) + ) + ) + } + + fun toBookItem( + file: SharedFolderScannedFile, + existing: BookItem? = null, + nowMillis: Long = currentTimestamp() + ): BookItem { + val parsedHighlights = highlightsJson + ?.let(EpubAnnotationSerializer::parseHighlightsJson) + ?.takeIf { it.isNotEmpty() } + val parsedBookmarks = parseReaderBookmarks(bookId) + .takeIf { it.isNotEmpty() } + val parsedType = runCatching { FileType.valueOf(type) }.getOrNull() ?: file.type + val metadataTimestamp = lastModifiedTimestamp.takeIf { it > 0L } ?: nowMillis + + return (existing ?: BookItem( + id = bookId, + path = file.path, + type = parsedType, + displayName = displayName.ifBlank { file.name }, + timestamp = metadataTimestamp, + title = title ?: displayName.ifBlank { file.name }, + author = author, + fileSize = file.size, + sourceFolder = file.sourceFolder, + isRecent = isRecent + )).copy( + id = bookId, + path = file.path, + type = parsedType, + displayName = displayName.ifBlank { file.name }, + timestamp = if (isRecent || existing == null) metadataTimestamp else existing.timestamp, + coverImagePath = existing?.coverImagePath, + title = title ?: existing?.title ?: displayName.ifBlank { file.name }, + author = author ?: existing?.author, + progressPercentage = progressPercentage, + isRecent = isRecent || (existing?.isRecent ?: false), + fileSize = file.size.takeIf { it > 0L } ?: existing?.fileSize ?: 0L, + sourceFolder = file.sourceFolder, + folderTextMetadataParsed = existing?.folderTextMetadataParsed ?: false, + lastPageIndex = lastPage, + readerBookmarks = parsedBookmarks ?: existing?.readerBookmarks.orEmpty(), + readerHighlights = parsedHighlights ?: existing?.readerHighlights.orEmpty() + ) + } + + private fun parseReaderBookmarks(bookId: String): List { + return EpubAnnotationSerializer.parseBookmarksJson(bookmarksJson) + .mapIndexed { index, bookmark -> + val locator = bookmark.locator.withFallbacks( + chapterIndex = bookmark.chapterIndex, + cfi = bookmark.cfi, + pageIndex = bookmark.pageInChapter?.minus(1), + textQuote = bookmark.snippet + ) + val pageIndex = locator.pageIndex ?: bookmark.pageInChapter?.minus(1) ?: 0 + ReaderBookmark( + id = "bookmark_${localFolderSyncSha256ShortHex("$bookId:$index:${bookmark.cfi}")}", + pageIndex = pageIndex.coerceAtLeast(0), + chapterTitle = bookmark.chapterTitle, + preview = bookmark.snippet, + locator = locator + ) + } + } + + companion object { + fun fromJsonString(rawJson: String): SharedFolderBookMetadata? { + val obj = runCatching { folderSyncJson.parseToJsonElement(rawJson).jsonObject }.getOrNull() + ?: return null + val bookId = obj.string("bookId")?.takeIf { it.isNotBlank() } ?: return null + return SharedFolderBookMetadata( + bookId = bookId, + title = obj.string("title"), + author = obj.string("author"), + displayName = obj.string("displayName") ?: "Unknown", + type = obj.string("type") ?: FileType.PDF.name, + lastChapterIndex = obj.sentinelInt("lastChapterIndex"), + lastPage = obj.sentinelInt("lastPage"), + lastPositionCfi = obj.string("lastPositionCfi"), + progressPercentage = obj.double("progressPercentage")?.toFloat() ?: 0f, + isRecent = obj.boolean("isRecent") ?: true, + lastModifiedTimestamp = obj.long("lastModifiedTimestamp") ?: 0L, + bookmarksJson = obj.string("bookmarksJson"), + locatorBlockIndex = obj.sentinelInt("locatorBlockIndex"), + locatorCharOffset = obj.sentinelInt("locatorCharOffset"), + customName = obj.string("customName"), + highlightsJson = obj.string("highlightsJson") + ) + } + } +} + +data class SharedFolderScannedFile( + val name: String, + val path: String, + val sourceFolder: String, + val relativePath: String, + val type: FileType, + val size: Long, + val lastModified: Long +) { + val stableBookId: String + get() = LocalFolderSyncEngine.buildStableBookId(name, relativePath) +} + +data class LocalFolderSyncStats( + val scannedFiles: Int = 0, + val supportedFiles: Int = 0, + val newBooks: Int = 0, + val updatedBooks: Int = 0, + val unchangedBooks: Int = 0, + val removedBooks: Int = 0, + val migratedBooks: Int = 0, + val remoteMetadataUpdates: Int = 0 +) { + operator fun plus(other: LocalFolderSyncStats): LocalFolderSyncStats { + return LocalFolderSyncStats( + scannedFiles = scannedFiles + other.scannedFiles, + supportedFiles = supportedFiles + other.supportedFiles, + newBooks = newBooks + other.newBooks, + updatedBooks = updatedBooks + other.updatedBooks, + unchangedBooks = unchangedBooks + other.unchangedBooks, + removedBooks = removedBooks + other.removedBooks, + migratedBooks = migratedBooks + other.migratedBooks, + remoteMetadataUpdates = remoteMetadataUpdates + other.remoteMetadataUpdates + ) + } +} + +data class LocalFolderSyncResult( + val state: SharedReaderScreenState, + val idMigrations: Map, + val removedBookIds: Set, + val stats: LocalFolderSyncStats +) + +object LocalFolderSyncEngine { + fun buildStableBookId(name: String, relativePath: String): String { + val normalizedRelativePath = relativePath.toSyncRelativePath().ifBlank { name } + return if (normalizedRelativePath.equals(name, ignoreCase = true)) { + "local_$name" + } else { + "local_${name}_${localFolderSyncSha256ShortHex(normalizedRelativePath.lowercase())}" + } + } + + fun syncFolder( + state: SharedReaderScreenState, + folder: SyncedFolder, + files: List, + remoteMetadata: Map, + nowMillis: Long = currentTimestamp(), + metadataOnly: Boolean = false + ): LocalFolderSyncResult { + val folderRoot = folder.uriString + val allowedTypes = folder.allowedFileTypes + val booksById = linkedMapOf() + state.rawLibraryBooks.forEach { booksById[it.id] = it } + val idMigrations = linkedMapOf() + var stats = LocalFolderSyncStats( + scannedFiles = files.size, + supportedFiles = files.count { it.type in allowedTypes } + ) + var removedIds = emptySet() + + val existingFolderBookIds = booksById.values + .filter { it.sourceFolder == folderRoot } + .mapTo(linkedSetOf()) { it.id } + + remoteMetadata.forEach { (bookId, metadata) -> + val existing = booksById[bookId]?.takeIf { it.sourceFolder == folderRoot } + if (existing != null && metadata.lastModifiedTimestamp > existing.localFolderModifiedTimestamp()) { + booksById[bookId] = existing.withAppliedFolderMetadata(metadata, nowMillis) + stats = stats.copy(remoteMetadataUpdates = stats.remoteMetadataUpdates + 1) + } + } + + if (!metadataOnly) { + val foundBookIds = linkedSetOf() + val folderBooksByPath = booksById.values + .filter { it.sourceFolder == folderRoot && !it.path.isNullOrBlank() } + .associateBy { it.path.orEmpty() } + .toMutableMap() + val legacyItemsByName = booksById.values + .asSequence() + .filter { it.sourceFolder == folderRoot } + .filter { it.id.startsWith("local_${it.displayName}_") || it.id == it.path } + .groupBy { it.displayName } + .mapValues { (_, books) -> ArrayDeque().apply { addAll(books) } } + .toMutableMap() + + files + .asSequence() + .filter { it.type in allowedTypes } + .sortedBy { it.relativePath.lowercase() } + .forEach { file -> + val stableId = file.stableBookId + foundBookIds += stableId + var existing = booksById[stableId]?.takeIf { it.sourceFolder == folderRoot } + + if (existing == null) { + val migrated = folderBooksByPath[file.path]?.takeIf { it.id != stableId } + ?: legacyItemsByName[file.name]?.firstOrNull { it.id != stableId } + if (migrated != null) { + val oldId = migrated.id + val migratedBook = migrated.copy(id = stableId).withScannedFile(file) + booksById.remove(oldId) + booksById[stableId] = migratedBook + idMigrations[oldId] = stableId + legacyItemsByName[file.name]?.remove(migrated) + existing = migratedBook + stats = stats.copy(migratedBooks = stats.migratedBooks + 1) + } + } + + val metadata = remoteMetadata[stableId] + if (existing == null) { + booksById[stableId] = metadata?.toBookItem(file, nowMillis = nowMillis) + ?: file.toBookItem(stableId, nowMillis) + stats = stats.copy(newBooks = stats.newBooks + 1) + } else { + val updatedForFile = existing.withScannedFile(file) + val updated = metadata + ?.takeIf { it.lastModifiedTimestamp > updatedForFile.localFolderModifiedTimestamp() } + ?.toBookItem(file = file, existing = updatedForFile, nowMillis = nowMillis) + ?: updatedForFile + booksById[stableId] = updated + if (updated != existing) { + stats = stats.copy(updatedBooks = stats.updatedBooks + 1) + } else { + stats = stats.copy(unchangedBooks = stats.unchangedBooks + 1) + } + } + } + + removedIds = existingFolderBookIds + .map { idMigrations[it] ?: it } + .filter { it !in foundBookIds } + .toSet() + removedIds.forEach(booksById::remove) + stats = stats.copy(removedBooks = removedIds.size) + } + + val syncedFolder = folder.copy(lastScanTime = nowMillis) + val syncedFolders = (state.syncedFolders.filterNot { it.uriString == folderRoot } + syncedFolder) + .sortedBy { it.name.lowercase() } + val migratedState = state + .withMigratedBookIds(idMigrations) + val nextState = migratedState + .withoutBookIds(removedIds) + .copy( + rawLibraryBooks = booksById.values.toList(), + syncedFolders = syncedFolders, + lastFolderScanTime = nowMillis + ) + + return LocalFolderSyncResult( + state = nextState, + idMigrations = idMigrations, + removedBookIds = removedIds, + stats = stats + ) + } + + fun applyIdMigrationsToShelfRefs( + shelfRefs: List, + migrations: Map + ): List { + if (migrations.isEmpty()) return shelfRefs + return shelfRefs.map { ref -> + migrations[ref.bookId]?.let { ref.copy(bookId = it) } ?: ref + }.distinctBy { it.bookId to it.shelfId } + } +} + +fun BookItem.toSharedFolderBookMetadata(): SharedFolderBookMetadata? { + if (sourceFolder.isNullOrBlank()) return null + + val bookmarksJson = readerBookmarks + .mapNotNull { it.toEpubBookmarkOrNull() } + .takeIf { it.isNotEmpty() } + ?.let(EpubAnnotationSerializer::bookmarksToJson) + val highlightsJson = readerHighlights + .takeIf { it.isNotEmpty() } + ?.let(EpubAnnotationSerializer::highlightsToJson) + val hasProgress = (progressPercentage ?: 0f) > 0f || lastPageIndex != null + val isDirty = isRecent || hasProgress || !bookmarksJson.isNullOrBlank() || !highlightsJson.isNullOrBlank() + if (!isDirty) return null + + return SharedFolderBookMetadata( + bookId = id, + title = title, + author = author, + displayName = displayName, + type = type.name, + lastChapterIndex = null, + lastPage = lastPageIndex, + lastPositionCfi = null, + progressPercentage = progressPercentage ?: 0f, + isRecent = isRecent, + lastModifiedTimestamp = localFolderModifiedTimestamp(), + bookmarksJson = bookmarksJson, + locatorBlockIndex = null, + locatorCharOffset = null, + customName = null, + highlightsJson = highlightsJson + ) +} + +private val folderSyncJson = Json { + ignoreUnknownKeys = true + encodeDefaults = true +} + +private fun BookItem.withAppliedFolderMetadata( + metadata: SharedFolderBookMetadata, + nowMillis: Long +): BookItem { + val file = SharedFolderScannedFile( + name = displayName, + path = path.orEmpty(), + sourceFolder = sourceFolder.orEmpty(), + relativePath = displayName, + type = runCatching { FileType.valueOf(metadata.type) }.getOrNull() ?: type, + size = fileSize, + lastModified = 0L + ) + return metadata.toBookItem(file = file, existing = this, nowMillis = nowMillis) +} + +private fun SharedFolderScannedFile.toBookItem(bookId: String, nowMillis: Long): BookItem { + return BookItem( + id = bookId, + path = path, + type = type, + displayName = name, + timestamp = nowMillis, + title = name.substringBeforeLast('.', missingDelimiterValue = name), + fileSize = size, + sourceFolder = sourceFolder, + isRecent = false + ) +} + +private fun BookItem.withScannedFile(file: SharedFolderScannedFile): BookItem { + val sizeChanged = fileSize > 0L && file.size > 0L && fileSize != file.size + return copy( + path = file.path, + type = file.type, + displayName = file.name, + coverImagePath = if (sizeChanged) null else coverImagePath, + fileSize = file.size.takeIf { it > 0L } ?: fileSize, + sourceFolder = file.sourceFolder, + folderTextMetadataParsed = if (sizeChanged) false else folderTextMetadataParsed + ) +} + +private fun BookItem.localFolderModifiedTimestamp(): Long { + return timestamp +} + +private fun ReaderBookmark.toEpubBookmarkOrNull(): EpubBookmark? { + val chapterIndex = locator.chapterIndex ?: 0 + val cfi = locator.cfi ?: "desktop:$chapterIndex:$pageIndex" + return EpubBookmark( + cfi = cfi, + chapterTitle = chapterTitle, + label = null, + snippet = preview, + pageInChapter = pageIndex + 1, + totalPagesInChapter = null, + chapterIndex = chapterIndex, + locator = locator.withFallbacks( + chapterIndex = chapterIndex, + cfi = cfi, + pageIndex = pageIndex, + textQuote = preview + ) + ) +} + +private fun SharedReaderScreenState.withMigratedBookIds( + migrations: Map +): SharedReaderScreenState { + if (migrations.isEmpty()) return this + + fun String.migrated(): String = migrations[this] ?: this + fun Set.migrated(): Set = mapTo(linkedSetOf()) { it.migrated() } + + return copy( + selectedBookIds = selectedBookIds.migrated(), + booksSelectedForAdding = booksSelectedForAdding.migrated(), + pinnedHomeBookIds = pinnedHomeBookIds.migrated(), + pinnedLibraryBookIds = pinnedLibraryBookIds.migrated(), + openTabIds = openTabIds.map { it.migrated() }.distinct(), + activeTabBookId = activeTabBookId?.migrated(), + selectedBookId = selectedBookId?.migrated() + ) +} + +private fun SharedReaderScreenState.withoutBookIds(bookIds: Set): SharedReaderScreenState { + if (bookIds.isEmpty()) return this + return copy( + selectedBookIds = selectedBookIds - bookIds, + booksSelectedForAdding = booksSelectedForAdding - bookIds, + pinnedHomeBookIds = pinnedHomeBookIds - bookIds, + pinnedLibraryBookIds = pinnedLibraryBookIds - bookIds, + openTabIds = openTabIds.filterNot { it in bookIds }, + activeTabBookId = activeTabBookId?.takeUnless { it in bookIds }, + selectedBookId = selectedBookId?.takeUnless { it in bookIds } + ) +} + +private fun String.toSyncRelativePath(): String { + return replace('\\', '/') + .split('/') + .filter { it.isNotBlank() && it != "." } + .joinToString("/") +} + +private fun JsonObject.string(name: String): String? { + return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull }.getOrNull() +} + +private fun JsonObject.long(name: String): Long? { + return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.longOrNull }.getOrNull() +} + +private fun JsonObject.double(name: String): Double? { + return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.doubleOrNull }.getOrNull() +} + +private fun JsonObject.boolean(name: String): Boolean? { + return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.booleanOrNull }.getOrNull() +} + +private fun JsonObject.sentinelInt(name: String): Int? { + val value = runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull }.getOrNull() + return value?.takeUnless { it == -1 } +} + +private fun String?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderAnnotationModels.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderAnnotationModels.kt index 46aa1af..9b30bc4 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderAnnotationModels.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderAnnotationModels.kt @@ -9,7 +9,13 @@ data class EpubBookmark( val snippet: String, val pageInChapter: Int?, val totalPagesInChapter: Int?, - val chapterIndex: Int + val chapterIndex: Int, + val locator: ReaderLocator = ReaderLocator.fromLegacy( + chapterIndex = chapterIndex, + cfi = cfi, + pageIndex = pageInChapter?.minus(1), + textQuote = snippet + ) ) enum class HighlightColor(val id: String, val color: Color, val cssClass: String) { @@ -29,13 +35,142 @@ enum class HighlightColor(val id: String, val color: Color, val cssClass: String WHITE("white", Color(0xFFF5F5F5), "user-highlight-white") } +data class ReaderLocator( + val chapterIndex: Int? = null, + val chapterId: String? = null, + val href: String? = null, + val pageIndex: Int? = null, + val startOffset: Int? = null, + val endOffset: Int? = null, + val textQuote: String? = null, + val cfi: String? = null +) { + val hasTextRange: Boolean + get() = startOffset != null && endOffset != null && endOffset >= startOffset + + fun withFallbacks( + chapterIndex: Int? = null, + chapterId: String? = null, + href: String? = null, + pageIndex: Int? = null, + startOffset: Int? = null, + endOffset: Int? = null, + textQuote: String? = null, + cfi: String? = null + ): ReaderLocator { + return copy( + chapterIndex = this.chapterIndex ?: chapterIndex, + chapterId = this.chapterId ?: chapterId, + href = this.href ?: href, + pageIndex = this.pageIndex ?: pageIndex, + startOffset = this.startOffset ?: startOffset, + endOffset = this.endOffset ?: endOffset, + textQuote = this.textQuote ?: textQuote, + cfi = this.cfi ?: cfi + ) + } + + fun sameLocation(other: ReaderLocator): Boolean { + val sameChapter = chapterIndex == null || other.chapterIndex == null || chapterIndex == other.chapterIndex + if (!sameChapter) return false + + if (hasTextRange && other.hasTextRange) { + return startOffset == other.startOffset && endOffset == other.endOffset + } + + if (pageIndex != null && other.pageIndex != null) { + return pageIndex == other.pageIndex + } + + return cfi != null && cfi == other.cfi + } + + companion object { + fun fromLegacy( + chapterIndex: Int? = null, + cfi: String? = null, + pageIndex: Int? = null, + textQuote: String? = null + ): ReaderLocator { + val desktopParts = cfi + ?.takeIf { it.startsWith("desktop:") } + ?.split(':') + .orEmpty() + val parsedChapterIndex = desktopParts.getOrNull(1)?.toIntOrNull() + val possibleStartOffset = desktopParts.getOrNull(2)?.toIntOrNull() + val possibleEndOffset = desktopParts.getOrNull(3)?.toIntOrNull() + val hasOffsetRange = desktopParts.size == 4 && + possibleStartOffset != null && + possibleEndOffset != null && + possibleStartOffset >= 0 && + possibleEndOffset >= possibleStartOffset && + possibleEndOffset - possibleStartOffset <= 100_000 + val parsedStartOffset = if (hasOffsetRange) possibleStartOffset else null + val parsedEndOffset = if (hasOffsetRange) possibleEndOffset else null + val parsedPageIndex = when { + pageIndex != null -> pageIndex + desktopParts.size == 3 || desktopParts.size >= 5 || (desktopParts.size == 4 && !hasOffsetRange) -> + desktopParts.getOrNull(2)?.toIntOrNull() + else -> null + } + return ReaderLocator( + chapterIndex = chapterIndex ?: parsedChapterIndex, + pageIndex = parsedPageIndex, + startOffset = parsedStartOffset, + endOffset = parsedEndOffset, + textQuote = textQuote, + cfi = cfi + ) + } + } +} + +data class ReaderHighlightPalette( + val colors: List = defaultColors +) { + fun sanitized(): ReaderHighlightPalette { + val distinct = colors.distinct().filter { it in HighlightColor.entries } + return copy(colors = distinct.ifEmpty { defaultColors }) + } + + fun contains(color: HighlightColor): Boolean { + return color in sanitized().colors + } + + fun withColor(color: HighlightColor, enabled: Boolean): ReaderHighlightPalette { + val next = if (enabled) { + colors + color + } else { + colors - color + } + return copy(colors = next).sanitized() + } + + companion object { + val defaultColors: List + get() = listOf( + HighlightColor.YELLOW, + HighlightColor.GREEN, + HighlightColor.BLUE, + HighlightColor.RED, + HighlightColor.PURPLE, + HighlightColor.ORANGE + ) + } +} + data class UserHighlight( val id: String, val cfi: String, val text: String, val color: HighlightColor, val chapterIndex: Int, - val note: String? = null + val note: String? = null, + val locator: ReaderLocator = ReaderLocator.fromLegacy( + chapterIndex = chapterIndex, + cfi = cfi, + textQuote = text + ) ) fun escapeJsString(value: String): String { diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderAnnotationSerializer.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderAnnotationSerializer.kt new file mode 100644 index 0000000..d8f34a4 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderAnnotationSerializer.kt @@ -0,0 +1,268 @@ +package com.aryan.reader.shared + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +object EpubAnnotationSerializer { + private val json = Json { + ignoreUnknownKeys = true + } + + fun parseBookmarksJson(rawJson: String?, chapterTitles: List = emptyList()): Set { + if (rawJson.isNullOrBlank()) return emptySet() + val root = runCatching { json.parseToJsonElement(rawJson).jsonArray }.getOrNull() ?: return emptySet() + return root.mapNotNull { element -> + when (element) { + is JsonObject -> element.asBookmarkOrNull(chapterTitles) + else -> element.contentOrNull() + ?.let { rawBookmark -> parseBookmarkObject(rawBookmark, chapterTitles) } + } + }.toSet() + } + + fun parseBookmarkEntries(entries: Collection, chapterTitles: List = emptyList()): Set { + return entries.mapNotNull { parseBookmarkObject(it, chapterTitles) }.toSet() + } + + fun bookmarksToJson(bookmarks: Collection): String { + val bookmarkEntries = bookmarks.map { JsonPrimitive(it.toJsonString()) } + return json.encodeToString(JsonElement.serializer(), JsonArray(bookmarkEntries)) + } + + fun parseHighlightsJson(rawJson: String?): List { + if (rawJson.isNullOrBlank()) return emptyList() + val root = runCatching { json.parseToJsonElement(rawJson).jsonArray }.getOrNull() ?: return emptyList() + return root.mapNotNull { element -> + runCatching { element.jsonObject.asHighlightOrNull() }.getOrNull() + } + } + + fun parseHighlightJson(rawJson: String?): UserHighlight? { + if (rawJson.isNullOrBlank()) return null + return runCatching { json.parseToJsonElement(rawJson).jsonObject.asHighlightOrNull() }.getOrNull() + } + + fun parseHighlightJsonLenient(rawJson: String?): UserHighlight? { + if (rawJson.isNullOrBlank()) return null + parseHighlightJson(rawJson)?.let { return it } + val unwrapped = runCatching { + json.parseToJsonElement(rawJson).jsonPrimitive.content + }.getOrNull() + return parseHighlightJson(unwrapped) + } + + fun highlightsToJson(highlights: Collection): String { + return json.encodeToString( + JsonElement.serializer(), + JsonArray(highlights.map { it.toJsonObject() }) + ) + } + + fun processAndAddHighlight( + newCfi: String, + newText: String, + newColor: HighlightColor, + chapterIndex: Int, + currentList: MutableList, + locator: ReaderLocator = ReaderLocator.fromLegacy( + chapterIndex = chapterIndex, + cfi = newCfi, + textQuote = newText + ) + ): String { + val normalizedLocator = locator.withFallbacks( + chapterIndex = chapterIndex, + cfi = newCfi, + textQuote = newText + ) + val exactMatchIndex = currentList.indexOfFirst { + it.chapterIndex == chapterIndex && + (it.cfi == newCfi || it.locator.sameLocation(normalizedLocator)) + } + + if (exactMatchIndex != -1) { + val existing = currentList[exactMatchIndex] + currentList[exactMatchIndex] = existing.copy( + cfi = newCfi, + color = newColor, + text = newText, + locator = existing.locator.copy(cfi = newCfi, textQuote = newText).withFallbacks( + chapterIndex = chapterIndex, + cfi = newCfi, + textQuote = newText + ) + ) + return newCfi + } + + currentList.add( + UserHighlight( + id = stableHighlightId(newCfi, chapterIndex), + cfi = newCfi, + text = newText, + color = newColor, + chapterIndex = chapterIndex, + note = null, + locator = normalizedLocator + ) + ) + return newCfi + } + + private fun parseBookmarkObject(rawJson: String, chapterTitles: List): EpubBookmark? { + return runCatching { json.parseToJsonElement(rawJson).jsonObject.asBookmarkOrNull(chapterTitles) }.getOrNull() + } + + private fun EpubBookmark.toJsonString(): String { + return json.encodeToString(JsonElement.serializer(), toJsonObject()) + } + + private fun EpubBookmark.toJsonObject(): JsonObject { + return JsonObject( + buildMap { + put("cfi", JsonPrimitive(cfi)) + put("chapterTitle", JsonPrimitive(chapterTitle)) + put("label", label.asJson()) + put("snippet", JsonPrimitive(snippet)) + pageInChapter?.let { put("pageInChapter", JsonPrimitive(it)) } + totalPagesInChapter?.let { put("totalPagesInChapter", JsonPrimitive(it)) } + put("chapterIndex", JsonPrimitive(chapterIndex)) + put("locator", locator.toJsonObject()) + } + ) + } + + private fun JsonObject.asBookmarkOrNull(chapterTitles: List): EpubBookmark? { + val cfi = string("cfi") ?: return null + val chapterTitle = string("chapterTitle") ?: return null + val chapterIndex = int("chapterIndex") + ?: chapterTitles.indexOfFirst { it == chapterTitle }.coerceAtLeast(0) + return EpubBookmark( + cfi = cfi, + chapterTitle = chapterTitle, + label = string("label"), + snippet = string("snippet") ?: "", + pageInChapter = int("pageInChapter"), + totalPagesInChapter = int("totalPagesInChapter"), + chapterIndex = chapterIndex, + locator = this["locator"] + ?.takeUnless { it is JsonNull } + ?.asReaderLocatorOrNull() + ?.withFallbacks( + chapterIndex = chapterIndex, + cfi = cfi, + pageIndex = int("pageInChapter")?.minus(1), + textQuote = string("snippet") ?: "" + ) + ?: ReaderLocator.fromLegacy( + chapterIndex = chapterIndex, + cfi = cfi, + pageIndex = int("pageInChapter")?.minus(1), + textQuote = string("snippet") ?: "" + ) + ) + } + + private fun JsonObject.asHighlightOrNull(): UserHighlight? { + val cfi = string("cfi") ?: return null + val text = string("text") ?: return null + val chapterIndex = int("chapterIndex") ?: return null + val colorId = string("colorId") + val color = HighlightColor.entries.firstOrNull { it.id == colorId } ?: HighlightColor.YELLOW + val note = string("note")?.takeIf { it.isNotBlank() } + return UserHighlight( + id = string("id")?.takeIf { it.isNotBlank() } ?: stableHighlightId(cfi, chapterIndex), + cfi = cfi, + text = text, + color = color, + chapterIndex = chapterIndex, + note = note, + locator = this["locator"] + ?.takeUnless { it is JsonNull } + ?.asReaderLocatorOrNull() + ?.withFallbacks( + chapterIndex = chapterIndex, + cfi = cfi, + textQuote = text + ) + ?: ReaderLocator.fromLegacy( + chapterIndex = chapterIndex, + cfi = cfi, + textQuote = text + ) + ) + } + + private fun UserHighlight.toJsonObject(): JsonObject { + return JsonObject( + mapOf( + "id" to JsonPrimitive(id), + "cfi" to JsonPrimitive(cfi), + "text" to JsonPrimitive(text), + "colorId" to JsonPrimitive(color.id), + "chapterIndex" to JsonPrimitive(chapterIndex), + "note" to (note ?: "").asJson(), + "locator" to locator.toJsonObject() + ) + ) + } + + private fun ReaderLocator.toJsonObject(): JsonObject { + return JsonObject( + buildMap { + chapterIndex?.let { put("chapterIndex", JsonPrimitive(it)) } + chapterId?.let { put("chapterId", JsonPrimitive(it)) } + href?.let { put("href", JsonPrimitive(it)) } + pageIndex?.let { put("pageIndex", JsonPrimitive(it)) } + startOffset?.let { put("startOffset", JsonPrimitive(it)) } + endOffset?.let { put("endOffset", JsonPrimitive(it)) } + textQuote?.let { put("textQuote", JsonPrimitive(it)) } + cfi?.let { put("cfi", JsonPrimitive(it)) } + } + ) + } + + private fun JsonElement.asReaderLocatorOrNull(): ReaderLocator? { + val obj = runCatching { jsonObject }.getOrNull() ?: return null + return ReaderLocator( + chapterIndex = obj.int("chapterIndex"), + chapterId = obj.string("chapterId"), + href = obj.string("href"), + pageIndex = obj.int("pageIndex"), + startOffset = obj.int("startOffset"), + endOffset = obj.int("endOffset"), + textQuote = obj.string("textQuote"), + cfi = obj.string("cfi") + ) + } + + private fun stableHighlightId(cfi: String, chapterIndex: Int): String { + val key = "$chapterIndex:$cfi" + var hash = 1125899906842597L + key.forEach { char -> hash = 31 * hash + char.code } + return "highlight_${hash.toString(16)}" + } + + private fun JsonObject.string(name: String): String? { + return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.content }.getOrNull() + } + + private fun JsonObject.int(name: String): Int? { + return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull }.getOrNull() + } + + private fun JsonElement.contentOrNull(): String? { + return runCatching { takeUnless { it is JsonNull }?.jsonPrimitive?.content }.getOrNull() + } + + private fun String?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderAppearanceModels.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderAppearanceModels.kt index a5a22e3..af06e0e 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderAppearanceModels.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderAppearanceModels.kt @@ -1,6 +1,13 @@ package com.aryan.reader.shared import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.graphics.toArgb +import com.aryan.reader.shared.reader.ReaderReadingMode +import com.aryan.reader.shared.reader.ReaderSettings +import com.aryan.reader.shared.reader.SharedReaderTextAlign +import kotlin.math.max +import kotlin.math.roundToInt enum class ReaderFont(val id: String, val displayName: String, val fontFamilyName: String) { ORIGINAL("original", "Original", "Original"), @@ -29,6 +36,11 @@ enum class PageInfoMode(val id: Int, val title: String) { HIDDEN(2, "Always Hide") } +enum class PageInfoPosition(val id: Int, val title: String) { + BOTTOM(0, "Bottom"), + TOP(1, "Top") +} + data class FormatSettings( val fontSize: Float, val lineHeight: Float, @@ -37,16 +49,26 @@ data class FormatSettings( val horizontalMargin: Float, val font: ReaderFont, val customPath: String?, - val textAlign: ReaderTextAlign + val textAlign: ReaderTextAlign, + val verticalMargin: Float = 1.0f ) -enum class ReaderTexture(val id: String, val displayName: String) { - PAPER("paper", "Paper"), - CANVAS("canvas", "Canvas"), - EINK("eink", "E-Ink"), - SLATE("slate", "Slate") +enum class ReaderTexture(val id: String, val displayName: String, val assetPath: String) { + NATURAL_WHITE("asset:ep_naturalwhite.webp", "Natural White", "textures/ep_naturalwhite.webp"), + NATURAL_BLACK("asset:ep_naturalblack.webp", "Natural Black", "textures/ep_naturalblack.webp"), + LIGHT_VENEER("asset:light-veneer.webp", "Light Veneer", "textures/light-veneer.webp"), + RETINA_WOOD("asset:retina_wood.webp", "Retina Wood", "textures/retina_wood.webp"), + GREY_WASH("asset:grey_wash_wall.webp", "Grey Wash", "textures/grey_wash_wall.webp"), + CLASSY_FABRIC("asset:classy_fabric.webp", "Classy Fabric", "textures/classy_fabric.webp"), + RETRO_INTRO("asset:retro_intro.webp", "Retro Intro", "textures/retro_intro.webp"), + PAPER("paper", "Paper", "textures/texture_paper.png"), + CANVAS("canvas", "Canvas", "textures/texture_canvas.png"), + EINK("eink", "E-Ink", "textures/texture_eink.webp"), + SLATE("slate", "Slate", "textures/texture_slate.png") } +const val ReaderTextureFilePrefix = "file:" + data class ReaderTheme( val id: String, val name: String, @@ -63,5 +85,124 @@ val BuiltInReaderThemes = listOf( ReaderTheme("dark", "Dark", Color(0xFF121212), Color(0xFFE0E0E0), true), ReaderTheme("sepia", "Sepia", Color(0xFFFBF0D9), Color(0xFF5F4B32), false), ReaderTheme("slate", "Slate", Color(0xFF2E3440), Color(0xFFECEFF4), true), - ReaderTheme("oled", "OLED", Color(0xFF000000), Color(0xFFB0B0B0), true) + ReaderTheme("oled", "OLED", Color(0xFF000000), Color(0xFFB0B0B0), true), + ReaderTheme("natural_white_texture", "Natural White", Color(0xFFF7F1E5), Color(0xFF1D1B18), false, textureId = ReaderTexture.NATURAL_WHITE.id), + ReaderTheme("retina_texture", "Retina", Color(0xFFF1E4CD), Color(0xFF2A2119), false, textureId = ReaderTexture.RETINA_WOOD.id), + ReaderTheme("veneer_texture", "Veneer", Color(0xFFF4E7CF), Color(0xFF2A2119), false, textureId = ReaderTexture.LIGHT_VENEER.id), + ReaderTheme("grey_wash_texture", "Grey Wash", Color(0xFF202124), Color(0xFFFFFFFF), true, textureId = ReaderTexture.GREY_WASH.id), + ReaderTheme("fabric_texture", "Fabric", Color(0xFF262626), Color(0xFFE8E2D8), true, textureId = ReaderTexture.CLASSY_FABRIC.id), + ReaderTheme("retro_texture", "Retro", Color(0xFFF6ECD8), Color(0xFF2F2118), false, textureId = ReaderTexture.RETRO_INTRO.id) ) + +val BuiltInPdfReaderThemes = listOf( + ReaderTheme("no_theme", "No Theme", Color.Unspecified, Color.Unspecified, false), + ReaderTheme("reverse", "Reverse", Color.Black, Color.White, true), + ReaderTheme("light", "Light", Color(0xFFFFFFFF), Color(0xFF000000), false), + ReaderTheme("dark", "Dark", Color(0xFF121212), Color(0xFFE0E0E0), true), + ReaderTheme("sepia", "Sepia", Color(0xFFFBF0D9), Color(0xFF5F4B32), false), + ReaderTheme("slate", "Slate", Color(0xFF2E3440), Color(0xFFECEFF4), true), + ReaderTheme("oled", "OLED", Color(0xFF000000), Color(0xFFB0B0B0), true), + ReaderTheme("pdf_natural_white_texture", "Natural White", Color(0xFFF7F1E5), Color(0xFF1D1B18), false, textureId = ReaderTexture.NATURAL_WHITE.id), + ReaderTheme("pdf_retina_texture", "Retina", Color(0xFFF1E4CD), Color(0xFF2A2119), false, textureId = ReaderTexture.RETINA_WOOD.id), + ReaderTheme("pdf_veneer_texture", "Veneer", Color(0xFFF4E7CF), Color(0xFF2A2119), false, textureId = ReaderTexture.LIGHT_VENEER.id), + ReaderTheme("pdf_grey_wash_texture", "Grey Wash", Color(0xFF202124), Color(0xFFFFFFFF), true, textureId = ReaderTexture.GREY_WASH.id), + ReaderTheme("pdf_fabric_texture", "Fabric", Color(0xFF262626), Color(0xFFE8E2D8), true, textureId = ReaderTexture.CLASSY_FABRIC.id), + ReaderTheme("pdf_retro_texture", "Retro", Color(0xFFF6ECD8), Color(0xFF2F2118), false, textureId = ReaderTexture.RETRO_INTRO.id) +) + +fun FormatSettings.toReaderSettings(base: ReaderSettings = ReaderSettings()): ReaderSettings { + val horizontalMarginPx = (ReaderAppearanceDefaults.marginPx * horizontalMargin).roundToInt() + .coerceIn(ReaderAppearanceDefaults.minMarginPx, ReaderAppearanceDefaults.maxMarginPx) + val verticalMarginPx = (ReaderAppearanceDefaults.marginPx * verticalMargin).roundToInt() + .coerceIn(ReaderAppearanceDefaults.minMarginPx, ReaderAppearanceDefaults.maxMarginPx) + return base.copy( + fontSize = (ReaderAppearanceDefaults.fontSizePx * fontSize).roundToInt() + .coerceIn(ReaderAppearanceDefaults.minFontSizePx, ReaderAppearanceDefaults.maxFontSizePx), + lineSpacing = (ReaderAppearanceDefaults.lineSpacing * lineHeight) + .coerceIn(ReaderAppearanceDefaults.minLineSpacing, ReaderAppearanceDefaults.maxLineSpacing), + margin = max(horizontalMarginPx, verticalMarginPx), + horizontalMargin = horizontalMarginPx, + verticalMargin = verticalMarginPx, + textAlign = textAlign.toSharedReaderTextAlign(), + fontFamily = customPath?.takeIf { it.isNotBlank() } ?: font.toReaderSettingsFontFamily(), + customFontPath = customPath?.takeIf { it.isNotBlank() }, + paragraphSpacing = paragraphGap.coerceIn( + ReaderAppearanceDefaults.minParagraphSpacing, + ReaderAppearanceDefaults.maxParagraphSpacing + ), + imageScale = imageSize.coerceIn( + ReaderAppearanceDefaults.minImageScale, + ReaderAppearanceDefaults.maxImageScale + ) + ) +} + +fun ReaderTheme.toReaderSettings(base: ReaderSettings = ReaderSettings()): ReaderSettings { + return base.copy( + darkMode = isDark, + themeId = id, + textureId = textureId, + backgroundColorArgb = backgroundColor.takeIf { it.isSpecified }?.toArgb()?.toLong(), + textColorArgb = textColor.takeIf { it.isSpecified }?.toArgb()?.toLong() + ) +} + +fun readerThemeById(themeId: String?): ReaderTheme? { + return BuiltInReaderThemes.firstOrNull { it.id == themeId } +} + +fun readerTextureDisplayName(textureId: String?): String { + return if (textureId == null) { + "None" + } else { + ReaderTexture.entries.firstOrNull { it.id == textureId }?.displayName + ?: textureId + .removePrefix(ReaderTextureFilePrefix) + .substringAfterLast('/') + .substringAfterLast('\\') + .let { fileName -> fileName.substringBeforeLast('.', missingDelimiterValue = fileName) } + .ifBlank { "Custom Image" } + } +} + +fun RenderMode.toReaderReadingMode(): ReaderReadingMode { + return when (this) { + RenderMode.VERTICAL_SCROLL -> ReaderReadingMode.VERTICAL + RenderMode.PAGINATED -> ReaderReadingMode.PAGINATED + } +} + +fun ReaderTextAlign.toSharedReaderTextAlign(): SharedReaderTextAlign { + return when (this) { + ReaderTextAlign.DEFAULT, + ReaderTextAlign.LEFT -> SharedReaderTextAlign.START + ReaderTextAlign.JUSTIFY -> SharedReaderTextAlign.JUSTIFY + } +} + +fun ReaderFont.toReaderSettingsFontFamily(): String { + return when (this) { + ReaderFont.ORIGINAL -> "Default" + ReaderFont.MERRIWEATHER, + ReaderFont.LORA -> "Serif" + ReaderFont.LATO, + ReaderFont.LEXEND -> "Sans" + ReaderFont.ROBOTO_MONO -> "Mono" + } +} + +private object ReaderAppearanceDefaults { + const val fontSizePx = 18f + const val minFontSizePx = 12 + const val maxFontSizePx = 42 + const val lineSpacing = 1.45f + const val minLineSpacing = 1.0f + const val maxLineSpacing = 2.8f + const val marginPx = 48f + const val minMarginPx = 0 + const val maxMarginPx = 160 + const val minParagraphSpacing = 0.5f + const val maxParagraphSpacing = 2.5f + const val minImageScale = 0.5f + const val maxImageScale = 2.0f +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderExtrasModels.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderExtrasModels.kt new file mode 100644 index 0000000..ae553b3 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderExtrasModels.kt @@ -0,0 +1,734 @@ +package com.aryan.reader.shared + +import com.aryan.reader.paginatedreader.SemanticBlock +import com.aryan.reader.paginatedreader.SemanticFlexContainer +import com.aryan.reader.paginatedreader.SemanticList +import com.aryan.reader.paginatedreader.SemanticTable +import com.aryan.reader.paginatedreader.SemanticTextBlock +import com.aryan.reader.paginatedreader.SemanticWrappingBlock +import com.aryan.reader.shared.reader.ReaderPage +import com.aryan.reader.shared.reader.ReaderSessionState +import com.aryan.reader.shared.reader.SharedEpubBook +import com.aryan.reader.shared.reader.SharedEpubChapter + +const val GEMINI_CLOUD_TTS_MODEL = "gemini-3.1-flash-live-preview" +const val GEMINI_CLOUD_TTS_MODEL_ID = "gemini:$GEMINI_CLOUD_TTS_MODEL" +const val DEFAULT_CLOUD_TTS_SPEAKER_ID = "Aoede" +const val READER_TTS_CHUNK_MAX_LENGTH = 250 + +data class ReaderCloudTtsVoice( + val id: String, + val name: String, + val description: String +) + +enum class ReaderAiFeature(val displayName: String) { + DEFINE("Smart dictionary"), + SUMMARIZE("Summaries"), + RECAP("Recaps") +} + +data class ReaderAiModelOption( + val provider: String, + val name: String, + val label: String = "${provider.replaceFirstChar { it.uppercaseChar() }} - $name" +) { + val id: String = "$provider:$name" +} + +data class ReaderAiByokSettings( + val geminiKey: String = "", + val groqKey: String = "", + val useOneModel: Boolean = true, + val modelForAll: String = "", + val defineModel: String = "", + val summarizeModel: String = "", + val recapModel: String = "", + val ttsModel: String = "", + val hideReaderAiFeatures: Boolean = false, + val ttsSpeakerId: String = DEFAULT_CLOUD_TTS_SPEAKER_ID +) { + fun sanitized(): ReaderAiByokSettings { + val knownTextModelIds = ReaderAiModelOptions.mapTo(mutableSetOf()) { it.id } + return copy( + geminiKey = geminiKey.trim(), + groqKey = groqKey.trim(), + modelForAll = modelForAll.takeIf { it in knownTextModelIds }.orEmpty(), + defineModel = defineModel.takeIf { it in knownTextModelIds }.orEmpty(), + summarizeModel = summarizeModel.takeIf { it in knownTextModelIds }.orEmpty(), + recapModel = recapModel.takeIf { it in knownTextModelIds }.orEmpty(), + ttsModel = ttsModel.takeIf { it == GEMINI_CLOUD_TTS_MODEL_ID }.orEmpty(), + ttsSpeakerId = ttsSpeakerId.ifBlank { DEFAULT_CLOUD_TTS_SPEAKER_ID } + ) + } + + fun modelIdFor(feature: ReaderAiFeature): String { + return if (useOneModel) { + modelForAll + } else { + when (feature) { + ReaderAiFeature.DEFINE -> defineModel + ReaderAiFeature.SUMMARIZE -> summarizeModel + ReaderAiFeature.RECAP -> recapModel + } + } + } + + fun apiKeyFor(provider: String): String { + return when (provider) { + "gemini" -> geminiKey + "groq" -> groqKey + else -> "" + }.trim() + } + + val hasAnyAiKey: Boolean get() = geminiKey.isNotBlank() || groqKey.isNotBlank() + val areReaderAiFeaturesAvailable: Boolean get() = !hideReaderAiFeatures && hasAnyAiKey + val isCloudTtsAvailable: Boolean get() = geminiKey.isNotBlank() && ttsModel == GEMINI_CLOUD_TTS_MODEL_ID +} + +val ReaderAiModelOptions = listOf( + ReaderAiModelOption("groq", "qwen/qwen3-32b"), + ReaderAiModelOption("groq", "llama-3.3-70b-versatile"), + ReaderAiModelOption("groq", "llama-3.1-8b-instant"), + ReaderAiModelOption("gemini", "gemma-4-26b-a4b-it"), + ReaderAiModelOption("gemini", "gemma-4-31b-it"), + ReaderAiModelOption("gemini", "gemini-flash-lite-latest"), + ReaderAiModelOption("gemini", "gemini-2.5-flash-lite"), + ReaderAiModelOption("gemini", "gemini-3.1-flash-lite-preview") +) + +val ReaderCloudTtsVoices = listOf( + ReaderCloudTtsVoice("Zephyr", "Zephyr", "Bright, Higher pitch"), + ReaderCloudTtsVoice("Puck", "Puck", "Upbeat, Middle pitch"), + ReaderCloudTtsVoice("Charon", "Charon", "Informative, Lower pitch"), + ReaderCloudTtsVoice("Kore", "Kore", "Firm, Middle pitch"), + ReaderCloudTtsVoice("Fenrir", "Fenrir", "Excitable, Lower middle pitch"), + ReaderCloudTtsVoice("Leda", "Leda", "Youthful, Higher pitch"), + ReaderCloudTtsVoice("Orus", "Orus", "Firm, Lower middle pitch"), + ReaderCloudTtsVoice("Aoede", "Aoede", "Breezy, Middle pitch"), + ReaderCloudTtsVoice("Callirrhoe", "Callirrhoe", "Easy-going, Middle pitch"), + ReaderCloudTtsVoice("Autonoe", "Autonoe", "Bright, Middle pitch"), + ReaderCloudTtsVoice("Enceladus", "Enceladus", "Breathy, Lower pitch"), + ReaderCloudTtsVoice("Iapetus", "Iapetus", "Clear, Lower middle pitch"), + ReaderCloudTtsVoice("Umbriel", "Umbriel", "Easy-going, Lower middle pitch"), + ReaderCloudTtsVoice("Algieba", "Algieba", "Smooth, Lower pitch"), + ReaderCloudTtsVoice("Despina", "Despina", "Smooth, Middle pitch"), + ReaderCloudTtsVoice("Erinome", "Erinome", "Clear, Middle pitch"), + ReaderCloudTtsVoice("Algenib", "Algenib", "Gravelly, Lower pitch"), + ReaderCloudTtsVoice("Rasalgethi", "Rasalgethi", "Informative, Middle pitch"), + ReaderCloudTtsVoice("Laomedeia", "Laomedeia", "Upbeat, Higher pitch"), + ReaderCloudTtsVoice("Achernar", "Achernar", "Soft, Higher pitch"), + ReaderCloudTtsVoice("Alnilam", "Alnilam", "Firm, Lower middle pitch"), + ReaderCloudTtsVoice("Schedar", "Schedar", "Even, Lower middle pitch"), + ReaderCloudTtsVoice("Gacrux", "Gacrux", "Mature, Middle pitch"), + ReaderCloudTtsVoice("Pulcherrima", "Pulcherrima", "Forward, Middle pitch"), + ReaderCloudTtsVoice("Achird", "Achird", "Friendly, Lower middle pitch"), + ReaderCloudTtsVoice("Zubenelgenubi", "Zubenelgenubi", "Casual, Lower middle pitch"), + ReaderCloudTtsVoice("Vindemiatrix", "Vindemiatrix", "Gentle, Middle pitch"), + ReaderCloudTtsVoice("Sadachbia", "Sadachbia", "Lively, Lower pitch"), + ReaderCloudTtsVoice("Sadaltager", "Sadaltager", "Lively, Lower pitch"), + ReaderCloudTtsVoice("Sulafat", "Sulafat", "Warm, Middle pitch") +) + +val ReaderCloudTtsSpeakers = ReaderCloudTtsVoices.map { it.id } + +fun readerCloudTtsVoiceById(id: String): ReaderCloudTtsVoice? { + return ReaderCloudTtsVoices.firstOrNull { it.id == id } +} + +fun formatReaderTtsBytes(bytes: Long): String { + if (bytes < 1024) return "$bytes B" + val units = listOf("KB", "MB", "GB", "TB", "PB") + var value = bytes.toDouble() / 1024.0 + var unitIndex = 0 + while (value >= 1024.0 && unitIndex < units.lastIndex) { + value /= 1024.0 + unitIndex++ + } + return "${(value * 10).toInt() / 10.0} ${units[unitIndex]}" +} + +fun splitReaderTextIntoTtsChunks( + text: String, + maxLength: Int = READER_TTS_CHUNK_MAX_LENGTH +): List { + if (text.isBlank()) return emptyList() + val sentenceBoundaryRegex = Regex("""(?() + val currentChunk = StringBuilder() + fun flush() { + if (currentChunk.isNotEmpty()) { + chunks += currentChunk.toString() + currentChunk.clear() + } + } + + sentences.forEach { sentence -> + if (sentence.length > maxLength) { + flush() + chunks += sentence + return@forEach + } + if (currentChunk.isNotEmpty() && currentChunk.length + sentence.length + 1 > maxLength) { + flush() + } + if (currentChunk.isNotEmpty()) currentChunk.append(' ') + currentChunk.append(sentence) + } + flush() + return chunks +} + +fun readerAiModelById(id: String): ReaderAiModelOption? { + return ReaderAiModelOptions.firstOrNull { it.id == id } +} + +fun maskedReaderAiKey(value: String): String { + val trimmed = value.trim() + return when { + trimmed.isBlank() -> "" + trimmed.length <= 6 -> "***" + else -> "${trimmed.take(3)}...${trimmed.takeLast(3)}" + } +} + +enum class ReaderExternalLookupAction(val title: String) { + DICTIONARY("Dictionary"), + TRANSLATE("Translate"), + SEARCH("Search") +} + +fun externalLookupUrl(action: ReaderExternalLookupAction, text: String): String { + val encoded = text.trim().urlEncoded() + return when (action) { + ReaderExternalLookupAction.DICTIONARY -> "https://www.google.com/search?q=define+$encoded" + ReaderExternalLookupAction.TRANSLATE -> "https://translate.google.com/?sl=auto&tl=en&text=$encoded&op=translate" + ReaderExternalLookupAction.SEARCH -> "https://www.google.com/search?q=$encoded" + } +} + +data class ReaderAutoScrollState( + val enabled: Boolean = false, + val speed: Float = 36f +) { + fun sanitized(): ReaderAutoScrollState { + return copy(speed = speed.coerceIn(12f, 160f)) + } +} + +enum class ReaderTtsReadScope(val label: String) { + PAGE("Page"), + CHAPTER("Chapter"), + BOOK("From here") +} + +data class ReaderTtsChunk( + val index: Int, + val pageIndex: Int, + val chapterIndex: Int, + val chapterTitle: String, + val text: String, + val startOffset: Int, + val endOffset: Int, + val sourceCfi: String? = null, + val spokenText: String = text +) { + fun toLocator(): ReaderLocator { + val boundedEnd = endOffset.coerceAtLeast(startOffset) + return ReaderLocator( + chapterIndex = chapterIndex, + pageIndex = pageIndex, + startOffset = startOffset, + endOffset = boundedEnd, + textQuote = text, + cfi = sourceCfi ?: "desktop:$chapterIndex:$startOffset:$boundedEnd" + ) + } + + fun toHighlight(sessionId: Long): UserHighlight { + val locator = toLocator() + return UserHighlight( + id = "tts_${sessionId}_$index", + cfi = locator.cfi.orEmpty(), + text = text, + color = HighlightColor.YELLOW, + chapterIndex = chapterIndex, + locator = locator + ) + } +} + +data class ReaderTtsProgress( + val sessionId: Long = 0L, + val scope: ReaderTtsReadScope = ReaderTtsReadScope.PAGE, + val chunks: List = emptyList(), + val currentChunkIndex: Int = -1 +) { + val currentChunk: ReaderTtsChunk? + get() = chunks.getOrNull(currentChunkIndex) + + val isActive: Boolean + get() = currentChunk != null + + val currentPositionLabel: String? + get() = currentChunk?.let { chunk -> + "Part ${currentChunkIndex + 1}/${chunks.size} - ${chunk.chapterTitle.ifBlank { scope.label }}" + } +} + +data class ReaderTtsCacheSummary( + val cachedChapterCount: Int = 0, + val cachedChunkCount: Int = 0, + val currentVoiceChunkCount: Int = 0, + val totalSizeBytes: Long = 0L, + val currentVoiceSizeBytes: Long = 0L +) { + val hasCachedAudio: Boolean get() = cachedChunkCount > 0 + val hasCurrentVoiceCachedAudio: Boolean get() = currentVoiceChunkCount > 0 + + val currentVoiceLabel: String + get() = if (hasCurrentVoiceCachedAudio) { + "$currentVoiceChunkCount chunks, ${formatReaderTtsBytes(currentVoiceSizeBytes)}" + } else { + "No cached chunks for this voice" + } +} + +object ReaderTtsPlanner { + fun chunksForCurrentPage(session: ReaderSessionState): List { + val page = session.reader.currentPage ?: return emptyList() + return chunksForPages(session.reader.book, listOf(page)) + } + + fun chunksForCurrentChapter(session: ReaderSessionState): List { + val page = session.reader.currentPage ?: return emptyList() + return chunksForPages( + session.reader.book, + session.reader.pages + .asSequence() + .filter { it.pageIndex >= page.pageIndex && it.chapterIndex == page.chapterIndex } + .toList() + ) + } + + fun chunksFromCurrentLocation(session: ReaderSessionState): List { + val pageIndex = session.reader.currentPageIndex + return chunksForPages(session.reader.book, session.reader.pages.drop(pageIndex.coerceAtLeast(0))) + } + + fun chunksForText( + text: String, + pageIndex: Int, + chapterIndex: Int, + chapterTitle: String, + sourceStartOffset: Int = 0 + ): List { + return splitTextIntoRanges(text).mapIndexed { index, range -> + ReaderTtsChunk( + index = index, + pageIndex = pageIndex, + chapterIndex = chapterIndex, + chapterTitle = chapterTitle, + text = range.text, + startOffset = sourceStartOffset + range.start, + endOffset = sourceStartOffset + range.end + ) + } + } + + private fun chunksForPages(book: SharedEpubBook, pages: List): List { + var nextIndex = 0 + return pages + .groupBy { it.chapterIndex } + .entries + .sortedBy { (chapterIndex, _) -> + pages.indexOfFirst { it.chapterIndex == chapterIndex }.takeIf { it >= 0 } ?: Int.MAX_VALUE + } + .flatMap { chapterPages -> + val chapter = book.chapters.getOrNull(chapterPages.key) + val semanticChunks = chapter + ?.let { chunksForSemanticPages(it, chapterPages.value) } + .orEmpty() + if (semanticChunks.isNotEmpty()) { + semanticChunks + } else { + chunksForPlainPages(book, chapterPages.value) + } + } + .distinctBy { "${it.sourceCfi}:${it.startOffset}:${it.endOffset}:${it.text}" } + .map { it.copy(index = nextIndex++) } + .toList() + } + + private fun chunksForPlainPages(book: SharedEpubBook, pages: List): List { + return pages.flatMap { page -> + val chapterText = book.chapters + .getOrNull(page.chapterIndex) + ?.normalizedTtsSourceText() + .orEmpty() + val sourceStartOffset = page.sourceTextStartOffset(chapterText) + splitTextIntoRanges(page.text).map { range -> + ReaderTtsChunk( + index = 0, + pageIndex = page.pageIndex, + chapterIndex = page.chapterIndex, + chapterTitle = page.chapterTitle, + text = range.text, + startOffset = sourceStartOffset + range.start, + endOffset = sourceStartOffset + range.end + ) + } + } + } + + private fun chunksForSemanticPages( + chapter: SharedEpubChapter, + pages: List + ): List { + if (chapter.semanticBlocks.isEmpty() || pages.isEmpty()) return emptyList() + val ranges = pages.map { it.startOffset to it.endOffset } + val textBlocks = chapter.semanticBlocks.semanticTextBlocks() + .filter { block -> + block.cfi != null && + block.text.isNotBlank() && + ranges.any { (start, end) -> block.intersects(start, end) } + } + return textBlocks.flatMap { block -> + val blockStart = block.startCharOffsetInSource.coerceAtLeast(0) + splitTextIntoRanges(block.text).mapNotNull { range -> + val chunkStart = blockStart + range.start + val chunkEnd = blockStart + range.end + if (ranges.none { (start, end) -> chunkStart < end && chunkEnd > start }) return@mapNotNull null + val page = pages.firstOrNull { it.intersects(chunkStart, chunkEnd) } + ?: pages.minByOrNull { kotlin.math.abs(it.startOffset - chunkStart) } + ?: return@mapNotNull null + ReaderTtsChunk( + index = 0, + pageIndex = page.pageIndex, + chapterIndex = page.chapterIndex, + chapterTitle = page.chapterTitle, + text = range.text, + startOffset = chunkStart, + endOffset = chunkEnd, + sourceCfi = block.cfi + ) + } + } + } + + private fun List.semanticTextBlocks(): List { + val blocks = mutableListOf() + fun visit(block: SemanticBlock) { + when (block) { + is SemanticTextBlock -> blocks += block + is SemanticFlexContainer -> block.children.forEach(::visit) + is SemanticTable -> block.rows.forEach { row -> row.forEach { cell -> cell.content.forEach(::visit) } } + is SemanticList -> block.items.forEach(::visit) + is SemanticWrappingBlock -> block.paragraphsToWrap.forEach(::visit) + else -> Unit + } + } + forEach(::visit) + return blocks + } + + private fun SemanticTextBlock.intersects(startOffset: Int, endOffset: Int): Boolean { + val start = startCharOffsetInSource + val end = start + text.length + return start < endOffset && end > startOffset + } + + private fun ReaderPage.intersects(startOffset: Int, endOffset: Int): Boolean { + return startOffset < endOffset && startOffset < this.endOffset && endOffset > this.startOffset + } + + private fun ReaderPage.sourceTextStartOffset(chapterText: String): Int { + if (chapterText.isBlank()) return startOffset + val boundedStart = startOffset.coerceIn(0, chapterText.length) + val boundedEnd = endOffset.coerceIn(boundedStart, chapterText.length) + val pageSlice = chapterText.substring(boundedStart, boundedEnd) + val trimAdjustedStart = boundedStart + pageSlice.leadingWhitespaceLength() + val exactTextStart = text + .takeIf { it.isNotBlank() } + ?.let { needle -> + chapterText.indexOf(needle, startIndex = boundedStart) + .takeIf { found -> found >= boundedStart && found + needle.length <= boundedEnd } + } + if (exactTextStart != null) return exactTextStart + val trimmedTextStart = text + .trim() + .takeIf { it.isNotBlank() } + ?.let { needle -> + chapterText.indexOf(needle, startIndex = boundedStart) + .takeIf { found -> found >= boundedStart && found + needle.length <= boundedEnd } + } + return trimmedTextStart ?: trimAdjustedStart + } + + private fun String.leadingWhitespaceLength(): Int { + return length - trimStart().length + } + + private fun SharedEpubChapter.normalizedTtsSourceText(): String { + return plainText + .replace("\r\n", "\n") + .replace(Regex("\\n{3,}"), "\n\n") + .trim() + } + + private fun splitTextIntoRanges( + text: String, + maxLength: Int = READER_TTS_CHUNK_MAX_LENGTH + ): List { + val sourceStart = text.indexOfFirst { !it.isWhitespace() } + if (sourceStart < 0) return emptyList() + val sourceEnd = text.indexOfLast { !it.isWhitespace() } + 1 + val source = text.substring(sourceStart, sourceEnd) + val sentenceRanges = androidStyleSentenceRanges(source, sourceStart) + if (sentenceRanges.isEmpty()) return emptyList() + + val chunks = mutableListOf() + var currentText = StringBuilder() + var currentStart = -1 + var currentEnd = -1 + fun flushCurrent() { + if (currentText.isNotEmpty() && currentStart >= 0 && currentEnd >= currentStart) { + chunks += ReaderTtsTextRange( + text = currentText.toString(), + start = currentStart, + end = currentStart + currentText.length + ) + } + currentText = StringBuilder() + currentStart = -1 + currentEnd = -1 + } + + for (sentence in sentenceRanges) { + if (sentence.text.length > maxLength) { + flushCurrent() + chunks += sentence + continue + } + if (currentText.isNotEmpty() && currentText.length + sentence.text.length + 1 > maxLength) { + flushCurrent() + currentText.append(sentence.text) + currentStart = sentence.start + currentEnd = sentence.end + } else { + if (currentText.isNotEmpty()) currentText.append(" ") + currentText.append(sentence.text) + if (currentStart < 0) currentStart = sentence.start + currentEnd = sentence.end + } + } + flushCurrent() + return chunks + } + + private fun androidStyleSentenceRanges(source: String, sourceOffset: Int): List { + val sentenceBoundaryRegex = Regex("""(?() + var start = 0 + sentenceBoundaryRegex.findAll(source).forEach { match -> + val end = match.range.first + if (end > start) { + source.substring(start, end) + .takeIf { it.isNotBlank() } + ?.let { sentence -> + ranges += ReaderTtsTextRange( + text = sentence, + start = sourceOffset + start, + end = sourceOffset + end + ) + } + } + start = match.range.last + 1 + } + if (start < source.length) { + val sentence = source.substring(start) + if (sentence.isNotBlank()) { + ranges += ReaderTtsTextRange( + text = sentence, + start = sourceOffset + start, + end = sourceOffset + source.length + ) + } + } + return ranges + } + + private data class ReaderTtsTextRange( + val text: String, + val start: Int, + val end: Int + ) +} + +data class ReaderCloudTtsState( + val isAvailable: Boolean = false, + val isPlaying: Boolean = false, + val isLoading: Boolean = false, + val isPaused: Boolean = false, + val statusMessage: String? = null, + val errorMessage: String? = null, + val progress: ReaderTtsProgress = ReaderTtsProgress(), + val cacheSummary: ReaderTtsCacheSummary = ReaderTtsCacheSummary() +) + +data class ReaderAiResultState( + val title: String? = null, + val text: String = "", + val isLoading: Boolean = false, + val errorMessage: String? = null +) { + val hasContent: Boolean get() = text.isNotBlank() || errorMessage != null || isLoading +} + +data class ReaderExtrasState( + val autoScroll: ReaderAutoScrollState = ReaderAutoScrollState(), + val cloudTts: ReaderCloudTtsState = ReaderCloudTtsState(), + val aiResult: ReaderAiResultState = ReaderAiResultState() +) + +data class ReaderByokTextRequest( + val model: ReaderAiModelOption, + val apiKey: String, + val systemInstruction: String, + val userPrompt: String, + val temperature: Double, + val maxTokens: Int +) + +sealed interface ReaderByokTextRequestResult { + data class Ready(val request: ReaderByokTextRequest) : ReaderByokTextRequestResult + data class MissingModel(val featureName: String) : ReaderByokTextRequestResult + data class MissingKey(val provider: String) : ReaderByokTextRequestResult + data object Hidden : ReaderByokTextRequestResult +} + +object ReaderByokTextRequests { + fun build( + settings: ReaderAiByokSettings, + feature: ReaderAiFeature, + text: String, + context: String? = null + ): ReaderByokTextRequestResult { + val sanitized = settings.sanitized() + if (sanitized.hideReaderAiFeatures) return ReaderByokTextRequestResult.Hidden + val model = readerAiModelById(sanitized.modelIdFor(feature)) + ?: return ReaderByokTextRequestResult.MissingModel(feature.displayName) + val apiKey = sanitized.apiKeyFor(model.provider) + if (apiKey.isBlank()) return ReaderByokTextRequestResult.MissingKey(model.provider) + val prompt = promptFor(feature, text, context) + return ReaderByokTextRequestResult.Ready( + ReaderByokTextRequest( + model = model, + apiKey = apiKey, + systemInstruction = prompt.systemInstruction, + userPrompt = prompt.userPrompt, + temperature = prompt.temperature, + maxTokens = prompt.maxTokens + ) + ) + } + + private fun promptFor(feature: ReaderAiFeature, text: String, context: String?): ReaderPrompt { + return when (feature) { + ReaderAiFeature.DEFINE -> ReaderPrompt( + systemInstruction = "You are a concise reading dictionary. Define the selected word or passage, explain nuance in context, and avoid unrelated commentary.", + userPrompt = buildString { + context?.takeIf { it.isNotBlank() }?.let { + append("Context:\n") + append(it.trim().take(3000)) + append("\n\n") + } + append("Selection:\n") + append(text.trim()) + }, + temperature = 0.15, + maxTokens = 1024 + ) + + ReaderAiFeature.SUMMARIZE -> ReaderPrompt( + systemInstruction = "You are an expert reading assistant. Summarize the provided passage clearly and concisely. Focus on the main ideas, plot points, and useful context. Do not add a preamble.", + userPrompt = text.trim(), + temperature = 0.2, + maxTokens = 4096 + ) + + ReaderAiFeature.RECAP -> ReaderPrompt( + systemInstruction = "You are a reading assistant creating a recap up to the reader's current position. Synthesize prior context and current text into a cohesive recap. Conclude exactly where the reader is positioned. Do not add a preamble.", + userPrompt = text.trim(), + temperature = 0.3, + maxTokens = 4096 + ) + } + } +} + +data class ReaderPrompt( + val systemInstruction: String, + val userPrompt: String, + val temperature: Double, + val maxTokens: Int +) + +object ReaderContextExtractor { + fun currentPageText(session: ReaderSessionState, maxChars: Int = 6000): String { + return session.reader.currentPage?.text.orEmpty().trim().take(maxChars) + } + + fun currentChapterText(session: ReaderSessionState, maxChars: Int = 20_000): String { + val chapterIndex = session.reader.currentPage?.chapterIndex ?: return currentPageText(session, maxChars) + return session.reader.book.chapters + .getOrNull(chapterIndex) + ?.plainText + .orEmpty() + .trim() + .take(maxChars) + } + + fun textBeforeCurrentLocation(session: ReaderSessionState, maxChars: Int = 24_000): String { + val page = session.reader.currentPage ?: return "" + val builder = StringBuilder() + session.reader.book.chapters.forEachIndexed { chapterIndex, chapter -> + when { + chapterIndex < page.chapterIndex -> { + builder.append(chapter.title).append('\n') + builder.append(chapter.plainText.trim()).append("\n\n") + } + chapterIndex == page.chapterIndex -> { + builder.append(chapter.title).append('\n') + builder.append(chapter.plainText.take(page.endOffset.coerceAtMost(chapter.plainText.length)).trim()) + } + } + } + return builder.toString().trim().takeLast(maxChars) + } +} + +private fun String.urlEncoded(): String { + val bytes = toByteArray(Charsets.UTF_8) + val builder = StringBuilder() + bytes.forEach { raw -> + val value = raw.toInt() and 0xFF + val char = value.toChar() + when { + value in 'A'.code..'Z'.code || + value in 'a'.code..'z'.code || + value in '0'.code..'9'.code || + char in "-_.~" -> builder.append(char) + char == ' ' -> builder.append('+') + else -> builder.append('%').append(value.toString(16).uppercase().padStart(2, '0')) + } + } + return builder.toString() +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderMarkdownModels.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderMarkdownModels.kt new file mode 100644 index 0000000..bafd853 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderMarkdownModels.kt @@ -0,0 +1,114 @@ +package com.aryan.reader.shared + +data class ReaderMarkdownDocument( + val blocks: List +) + +sealed interface ReaderMarkdownBlock { + data class Heading(val level: Int, val text: String) : ReaderMarkdownBlock + data class Paragraph(val text: String) : ReaderMarkdownBlock + data class ListItems(val ordered: Boolean, val items: List) : ReaderMarkdownBlock + data class CodeBlock(val text: String) : ReaderMarkdownBlock + data class Quote(val text: String) : ReaderMarkdownBlock +} + +object ReaderMarkdownParser { + fun parse(markdown: String): ReaderMarkdownDocument { + val lines = markdown.replace("\r\n", "\n").split('\n') + val blocks = mutableListOf() + val paragraph = mutableListOf() + var index = 0 + + fun flushParagraph() { + if (paragraph.isNotEmpty()) { + blocks += ReaderMarkdownBlock.Paragraph(paragraph.joinToString(" ").trim()) + paragraph.clear() + } + } + + while (index < lines.size) { + val line = lines[index] + val trimmed = line.trim() + when { + trimmed.isBlank() -> { + flushParagraph() + index += 1 + } + + trimmed.startsWith("```") -> { + flushParagraph() + val code = mutableListOf() + index += 1 + while (index < lines.size && !lines[index].trim().startsWith("```")) { + code += lines[index] + index += 1 + } + if (index < lines.size) index += 1 + blocks += ReaderMarkdownBlock.CodeBlock(code.joinToString("\n").trimEnd()) + } + + trimmed.headingLevel() != null -> { + flushParagraph() + val level = trimmed.headingLevel() ?: 1 + blocks += ReaderMarkdownBlock.Heading( + level = level, + text = trimmed.drop(level).trim() + ) + index += 1 + } + + trimmed.startsWith(">") -> { + flushParagraph() + val quote = mutableListOf() + while (index < lines.size && lines[index].trim().startsWith(">")) { + quote += lines[index].trim().removePrefix(">").trim() + index += 1 + } + blocks += ReaderMarkdownBlock.Quote(quote.joinToString(" ").trim()) + } + + trimmed.unorderedListText() != null || trimmed.orderedListText() != null -> { + flushParagraph() + val ordered = trimmed.orderedListText() != null + val items = mutableListOf() + while (index < lines.size) { + val itemLine = lines[index].trim() + val item = if (ordered) itemLine.orderedListText() else itemLine.unorderedListText() + if (item == null) break + items += item + index += 1 + } + blocks += ReaderMarkdownBlock.ListItems(ordered = ordered, items = items) + } + + else -> { + paragraph += trimmed + index += 1 + } + } + } + + flushParagraph() + return ReaderMarkdownDocument(blocks) + } +} + +private fun String.headingLevel(): Int? { + val count = takeWhile { it == '#' }.length + return count.takeIf { it in 1..6 && getOrNull(it) == ' ' } +} + +private fun String.unorderedListText(): String? { + return if (length > 2 && first() in listOf('-', '*', '+') && this[1] == ' ') { + drop(2).trim() + } else { + null + } +} + +private fun String.orderedListText(): String? { + val dotIndex = indexOf('.') + if (dotIndex <= 0 || dotIndex + 1 >= length || this[dotIndex + 1] != ' ') return null + return take(dotIndex).takeIf { number -> number.all { it.isDigit() } } + ?.let { drop(dotIndex + 2).trim() } +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderToolbarModels.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderToolbarModels.kt new file mode 100644 index 0000000..424ec75 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderToolbarModels.kt @@ -0,0 +1,91 @@ +package com.aryan.reader.shared + +private val DefaultReaderBottomToolIds: Set + get() = setOf( + ReaderTool.SLIDER.id, + ReaderTool.TOC.id, + ReaderTool.FORMAT.id, + ReaderTool.SEARCH.id, + ReaderTool.AI_FEATURES.id, + ReaderTool.TTS_CONTROLS.id + ) + +enum class ReaderTool( + val id: String, + val title: String, + val category: String, + val supportsDesktopQuickAction: Boolean = false +) { + DICTIONARY("dictionary", "External Apps", "Top Bar", supportsDesktopQuickAction = true), + THEME("theme", "Theme Settings", "Top Bar", supportsDesktopQuickAction = true), + SLIDER("slider", "Navigation Slider", "Bottom Bar"), + TOC("toc", "Sidebar", "Bottom Bar"), + FORMAT("format", "Text Formatting", "Bottom Bar"), + SEARCH("search", "Search", "Bottom Bar", supportsDesktopQuickAction = true), + AI_FEATURES("ai_features", "AI Features", "Bottom Bar", supportsDesktopQuickAction = true), + TTS_CONTROLS("tts_controls", "TTS Controls", "Bottom Bar", supportsDesktopQuickAction = true), + READING_MODE("reading_mode", "Reading Mode", "Overflow Menu"), + BOOKMARK("bookmark", "Bookmark", "Overflow Menu", supportsDesktopQuickAction = true), + TAP_TO_TURN("tap_to_turn", "Tap to Turn Pages", "Overflow Menu"), + VOLUME_SCROLL("volume_scroll", "Volume Button Scrolling", "Overflow Menu"), + PAGE_TURN_ANIM("page_turn_anim", "Realistic Page Turns", "Overflow Menu"), + KEEP_SCREEN_ON("keep_screen_on", "Keep Screen On", "Overflow Menu"), + VISUAL_OPTIONS("visual_options", "Visual Options", "Overflow Menu"), + AUTO_SCROLL("auto_scroll", "Auto Scroll", "Overflow Menu", supportsDesktopQuickAction = true), + TTS_SETTINGS("tts_settings", "TTS Voice Settings", "Overflow Menu"), + TTS_REPLACEMENTS("tts_replacements", "TTS Word Replacements", "Overflow Menu"); + + companion object { + fun fromId(id: String): ReaderTool? { + return entries.firstOrNull { it.id == id || it.name == id } + } + } +} + +data class ReaderToolbarPreferences( + val hiddenToolIds: Set = emptySet(), + val toolOrder: List = ReaderTool.entries.toList(), + val bottomToolIds: Set = DefaultReaderBottomToolIds +) { + fun sanitized(): ReaderToolbarPreferences { + val orderedTools = (toolOrder + ReaderTool.entries.toList()) + .distinct() + .filter { it in ReaderTool.entries } + val knownToolIds = ReaderTool.entries.mapTo(mutableSetOf()) { it.id } + return copy( + hiddenToolIds = hiddenToolIds.filterTo(mutableSetOf()) { it in knownToolIds }, + toolOrder = orderedTools, + bottomToolIds = bottomToolIds.filterTo(mutableSetOf()) { it in knownToolIds } + ) + } + + fun isVisible(tool: ReaderTool): Boolean { + return tool.id !in hiddenToolIds + } + + fun isBottom(tool: ReaderTool): Boolean { + return tool.id in bottomToolIds + } + + fun withVisibility(tool: ReaderTool, hidden: Boolean): ReaderToolbarPreferences { + val nextHidden = if (hidden) hiddenToolIds + tool.id else hiddenToolIds - tool.id + return copy(hiddenToolIds = nextHidden).sanitized() + } + + fun withBottomPlacement(tool: ReaderTool, bottom: Boolean): ReaderToolbarPreferences { + val nextBottom = if (bottom) bottomToolIds + tool.id else bottomToolIds - tool.id + return copy(bottomToolIds = nextBottom).sanitized() + } + + fun withToolOrder(order: List): ReaderToolbarPreferences { + return copy(toolOrder = order).sanitized() + } + + fun orderedVisibleTools(): List { + return sanitized().toolOrder.filter(::isVisible) + } + + companion object { + val defaultBottomToolIds: Set get() = DefaultReaderBottomToolIds + } +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderTtsReplacements.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderTtsReplacements.kt new file mode 100644 index 0000000..d2a30b1 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderTtsReplacements.kt @@ -0,0 +1,353 @@ +package com.aryan.reader.shared + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +data class ReaderTtsReplacementRule( + val id: String, + val from: String, + val to: String, + val enabled: Boolean = true, + val isRegex: Boolean = false, + val matchCase: Boolean = false, + val wholeWord: Boolean = true, +) + +data class ReaderTtsReplacementBookSettings( + val localRulesEnabled: Boolean = true, + val globalRulesEnabled: Boolean = true, + val disabledGlobalRuleIds: Set = emptySet(), +) + +data class ReaderTtsReplacementPreferences( + val isEnabled: Boolean = true, + val globalRules: List = emptyList(), + val bookRules: Map> = emptyMap(), + val bookSettings: Map = emptyMap(), +) { + fun settingsForBook(bookId: String?): ReaderTtsReplacementBookSettings { + return bookSettings[bookId.orEmpty()] ?: ReaderTtsReplacementBookSettings() + } + + fun rulesForBook(bookId: String?): List { + return bookRules[bookId.orEmpty()].orEmpty() + } + + fun activeRulesForBook(bookId: String?): List { + if (!isEnabled) return emptyList() + val settings = settingsForBook(bookId) + val inherited = if (settings.globalRulesEnabled) { + globalRules.filter { it.id !in settings.disabledGlobalRuleIds } + } else { + emptyList() + } + val local = if (settings.localRulesEnabled) rulesForBook(bookId) else emptyList() + return inherited + local + } + + fun withBookSettings( + bookId: String?, + settings: ReaderTtsReplacementBookSettings, + ): ReaderTtsReplacementPreferences { + return copy(bookSettings = bookSettings + (bookId.orEmpty() to settings)) + } + + fun withBookRules( + bookId: String?, + rules: List, + ): ReaderTtsReplacementPreferences { + return copy(bookRules = bookRules + (bookId.orEmpty() to rules)) + } +} + +data class ReaderTtsReplacementValidation( + val isValid: Boolean, + val message: String? = null, +) + +data class ReaderTtsReplacementError( + val ruleId: String, + val message: String, +) + +data class ReaderTtsReplacementApplyResult( + val text: String, + val appliedRuleIds: List = emptyList(), + val errors: List = emptyList(), +) { + val hasUnmappableChanges: Boolean + get() = appliedRuleIds.isNotEmpty() +} + +object ReaderTtsReplacementEngine { + fun validate(rule: ReaderTtsReplacementRule): ReaderTtsReplacementValidation { + if (rule.from.isBlank()) { + return ReaderTtsReplacementValidation(isValid = false, message = "Enter text to replace.") + } + if (!rule.isRegex) { + return ReaderTtsReplacementValidation(isValid = true) + } + return runCatching { rule.toRegex() } + .fold( + onSuccess = { ReaderTtsReplacementValidation(isValid = true) }, + onFailure = { + ReaderTtsReplacementValidation( + isValid = false, + message = it.message ?: "This regex is not valid.", + ) + }, + ) + } + + fun apply( + text: String, + preferences: ReaderTtsReplacementPreferences, + bookId: String? = null, + ): ReaderTtsReplacementApplyResult { + if (text.isEmpty() || !preferences.isEnabled) { + return ReaderTtsReplacementApplyResult(text = text) + } + + var current = text + val applied = mutableListOf() + val errors = mutableListOf() + + preferences.activeRulesForBook(bookId).forEach { rule -> + if (!rule.enabled || rule.from.isBlank()) return@forEach + val regex = runCatching { rule.toRegex() } + .onFailure { + errors += ReaderTtsReplacementError( + ruleId = rule.id, + message = it.message ?: "Invalid regex.", + ) + } + .getOrNull() ?: return@forEach + val replacement = if (rule.isRegex) rule.to else Regex.escapeReplacement(rule.to) + val next = runCatching { regex.replace(current, replacement) } + .onFailure { + errors += ReaderTtsReplacementError( + ruleId = rule.id, + message = it.message ?: "Invalid replacement.", + ) + } + .getOrNull() ?: return@forEach + if (next != current) { + applied += rule.id + current = next + } + } + + return ReaderTtsReplacementApplyResult( + text = current, + appliedRuleIds = applied, + errors = errors, + ) + } + + private fun ReaderTtsReplacementRule.toRegex(): Regex { + val source = if (isRegex) from else Regex.escape(from) + val boundedSource = if (wholeWord) { + """(?.withTtsReplacements( + preferences: ReaderTtsReplacementPreferences, + bookId: String? = null, +): List = map { it.withTtsReplacements(preferences, bookId) } + +object ReaderTtsReplacementSuggestions { + val presets: List = listOf( + ReaderTtsReplacementRule( + id = "suggestion_dr", + from = "Dr.", + to = "Doctor", + wholeWord = false, + ), + ReaderTtsReplacementRule( + id = "suggestion_mr", + from = "Mr.", + to = "Mister", + wholeWord = false, + ), + ReaderTtsReplacementRule( + id = "suggestion_mrs", + from = "Mrs.", + to = "Missus", + wholeWord = false, + ), + ReaderTtsReplacementRule( + id = "suggestion_ms", + from = "Ms.", + to = "Miss", + wholeWord = false, + ), + ReaderTtsReplacementRule( + id = "suggestion_vs", + from = "vs.", + to = "versus", + wholeWord = false, + ), + ReaderTtsReplacementRule( + id = "suggestion_et_al", + from = "et al.", + to = "and others", + wholeWord = false, + ), + ReaderTtsReplacementRule( + id = "suggestion_initials", + from = """\b([A-Z])\.\s*([A-Z])\.""", + to = "\$1 \$2", + isRegex = true, + wholeWord = false, + ), + ) +} + +object ReaderTtsReplacementPreferencesJson { + private val json = Json { + ignoreUnknownKeys = true + prettyPrint = false + } + + fun encode(preferences: ReaderTtsReplacementPreferences): String { + return json.encodeToString(JsonElement.serializer(), toJsonElement(preferences)) + } + + fun decodeOrEmpty(raw: String?): ReaderTtsReplacementPreferences { + if (raw.isNullOrBlank()) return ReaderTtsReplacementPreferences() + return runCatching { + fromJsonElement(json.parseToJsonElement(raw)) + }.getOrNull() ?: ReaderTtsReplacementPreferences() + } + + fun toJsonElement(preferences: ReaderTtsReplacementPreferences): JsonElement { + return JsonObject( + mapOf( + "isEnabled" to JsonPrimitive(preferences.isEnabled), + "globalRules" to rulesToJson(preferences.globalRules), + "bookRules" to JsonObject( + preferences.bookRules.mapValues { (_, rules) -> rulesToJson(rules) as JsonElement }, + ), + "bookSettings" to JsonObject( + preferences.bookSettings.mapValues { (_, settings) -> settingsToJson(settings) as JsonElement }, + ), + ), + ) + } + + fun fromJsonElement(element: JsonElement?): ReaderTtsReplacementPreferences { + val root = element as? JsonObject ?: return ReaderTtsReplacementPreferences() + val bookRules = root["bookRules"]?.jsonObjectOrNull() + ?.mapValues { (_, value) -> value.jsonArrayOrNull()?.mapNotNull(::ruleFromJson).orEmpty() } + .orEmpty() + val bookSettings = root["bookSettings"]?.jsonObjectOrNull() + ?.mapValues { (_, value) -> settingsFromJson(value) } + .orEmpty() + return ReaderTtsReplacementPreferences( + isEnabled = root.booleanValue("isEnabled") ?: true, + globalRules = root["globalRules"]?.jsonArrayOrNull()?.mapNotNull(::ruleFromJson).orEmpty(), + bookRules = bookRules, + bookSettings = bookSettings, + ) + } + + private fun rulesToJson(rules: List): JsonArray { + return JsonArray(rules.map(::ruleToJson)) + } + + private fun ruleToJson(rule: ReaderTtsReplacementRule): JsonObject { + return JsonObject( + mapOf( + "id" to JsonPrimitive(rule.id), + "from" to JsonPrimitive(rule.from), + "to" to JsonPrimitive(rule.to), + "enabled" to JsonPrimitive(rule.enabled), + "isRegex" to JsonPrimitive(rule.isRegex), + "matchCase" to JsonPrimitive(rule.matchCase), + "wholeWord" to JsonPrimitive(rule.wholeWord), + ), + ) + } + + private fun ruleFromJson(element: JsonElement): ReaderTtsReplacementRule? { + val root = element as? JsonObject ?: return null + val id = root.stringValue("id") ?: return null + val from = root.stringValue("from") ?: return null + return ReaderTtsReplacementRule( + id = id, + from = from, + to = root.stringValue("to").orEmpty(), + enabled = root.booleanValue("enabled") ?: true, + isRegex = root.booleanValue("isRegex") ?: false, + matchCase = root.booleanValue("matchCase") ?: false, + wholeWord = root.booleanValue("wholeWord") ?: true, + ) + } + + private fun settingsToJson(settings: ReaderTtsReplacementBookSettings): JsonObject { + return JsonObject( + mapOf( + "localRulesEnabled" to JsonPrimitive(settings.localRulesEnabled), + "globalRulesEnabled" to JsonPrimitive(settings.globalRulesEnabled), + "disabledGlobalRuleIds" to JsonArray(settings.disabledGlobalRuleIds.map(::JsonPrimitive)), + ), + ) + } + + private fun settingsFromJson(element: JsonElement): ReaderTtsReplacementBookSettings { + val root = element as? JsonObject ?: return ReaderTtsReplacementBookSettings() + return ReaderTtsReplacementBookSettings( + localRulesEnabled = root.booleanValue("localRulesEnabled") ?: true, + globalRulesEnabled = root.booleanValue("globalRulesEnabled") ?: true, + disabledGlobalRuleIds = root["disabledGlobalRuleIds"]?.jsonArrayOrNull() + ?.mapNotNull { it.jsonPrimitiveOrNull()?.contentOrNull } + ?.toSet() + .orEmpty(), + ) + } + + private fun JsonObject.stringValue(name: String): String? { + return get(name)?.jsonPrimitiveOrNull()?.contentOrNull + } + + private fun JsonObject.booleanValue(name: String): Boolean? { + return get(name)?.jsonPrimitiveOrNull()?.booleanOrNull + } + + private fun JsonElement.jsonObjectOrNull(): JsonObject? = this as? JsonObject + + private fun JsonElement.jsonArrayOrNull(): JsonArray? = this as? JsonArray + + private fun JsonElement.jsonPrimitiveOrNull() = when (this) { + is JsonPrimitive -> this + JsonNull -> null + else -> null + } +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/RepositoryContracts.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/RepositoryContracts.kt index 887e198..3cb82a4 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/RepositoryContracts.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/RepositoryContracts.kt @@ -6,7 +6,8 @@ data class ImportedBookFile( val name: String, val uriString: String?, val localPath: String?, - val size: Long + val size: Long, + val sourceFolder: String? = null ) interface BookRepository { @@ -54,5 +55,7 @@ interface AiAdapter { interface TtsAdapter { val isAvailable: Boolean suspend fun speak(text: String) + suspend fun pause() = Unit + suspend fun resume() = Unit suspend fun stop() } diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedFormatters.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedFormatters.kt index 8f3c9a1..f51770f 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedFormatters.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedFormatters.kt @@ -33,6 +33,14 @@ fun BookItem.isOpdsStream(): Boolean { return path?.startsWith("opds-pse://") == true } +fun BookItem.matchesSourceFolders(sourceFolders: Set): Boolean { + if (sourceFolders.isEmpty()) return true + val matchesInAppStorage = IN_APP_STORAGE_SOURCE in sourceFolders && + sourceFolder == null && + !isOpdsStream() + return matchesInAppStorage || sourceFolder in sourceFolders +} + private fun formatDecimal(value: Double, decimals: Int): String { val factor = 10.0.pow(decimals) val rounded = (value * factor).roundToInt() / factor diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedLibrarySnapshot.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedLibrarySnapshot.kt new file mode 100644 index 0000000..719efdb --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedLibrarySnapshot.kt @@ -0,0 +1,618 @@ +package com.aryan.reader.shared + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.floatOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import com.aryan.reader.shared.reader.ReaderBookmark +import com.aryan.reader.shared.reader.ReaderReadingMode +import com.aryan.reader.shared.reader.ReaderSettings +import com.aryan.reader.shared.reader.SharedReaderTextAlign + +data class SharedLibrarySnapshot( + val books: List = emptyList(), + val shelfRecords: List = emptyList(), + val shelfRefs: List = emptyList(), + val tags: List = emptyList(), + val customFonts: List = emptyList(), + val syncedFolders: List = emptyList(), + val recentFilesLimit: Int = 12, + val isTabsEnabled: Boolean = false, + val openTabIds: List = emptyList(), + val activeTabBookId: String? = null, + val pinnedHomeBookIds: Set = emptySet(), + val pinnedLibraryBookIds: Set = emptySet(), + val useStrictFileFilter: Boolean = false, + val appThemeMode: AppThemeMode = AppThemeMode.SYSTEM, + val appContrastOption: AppContrastOption = AppContrastOption.STANDARD, + val appTextDimFactorLight: Float = 1.0f, + val appTextDimFactorDark: Float = 1.0f, + val appSeedColor: Color? = null, + val customAppThemes: List = emptyList(), + val readerToolbarPreferences: ReaderToolbarPreferences = ReaderToolbarPreferences(), + val readerHighlightPalette: ReaderHighlightPalette = ReaderHighlightPalette(), + val readerTtsReplacementPreferences: ReaderTtsReplacementPreferences = ReaderTtsReplacementPreferences() +) + +object SharedLibrarySnapshotJson { + private const val SCHEMA_VERSION = 10 + + private val json = Json { + prettyPrint = true + ignoreUnknownKeys = true + } + + fun decodeOrEmpty(rawJson: String): SharedLibrarySnapshot { + val root = runCatching { + json.parseToJsonElement(rawJson).jsonObject + }.getOrNull() ?: return SharedLibrarySnapshot() + + val schemaVersion = root.int("schemaVersion", 1) + val openTabIds = root.stringArray("openTabIds") + return SharedLibrarySnapshot( + books = root.array("books") + .mapNotNull { it.asBookItemOrNull() } + .migrateLegacyRecentState(schemaVersion, openTabIds), + shelfRecords = root.array("shelves").mapNotNull { it.asShelfRecordOrNull() }, + shelfRefs = root.array("bookShelfRefs").mapNotNull { it.asBookShelfRefOrNull() }, + tags = root.array("tags").mapNotNull { it.asTagOrNull() }, + customFonts = root.array("customFonts").mapNotNull { it.asCustomFontItemOrNull() }, + syncedFolders = root.array("syncedFolders").mapNotNull { it.asSyncedFolderOrNull() }, + recentFilesLimit = root.int("recentFilesLimit", 12), + isTabsEnabled = root.boolean("isTabsEnabled", false), + openTabIds = openTabIds, + activeTabBookId = root.string("activeTabBookId"), + pinnedHomeBookIds = root.stringArray("pinnedHomeBookIds").toSet(), + pinnedLibraryBookIds = root.stringArray("pinnedLibraryBookIds").toSet(), + useStrictFileFilter = root.boolean("useStrictFileFilter", false), + appThemeMode = root.string("appThemeMode") + ?.let { runCatching { AppThemeMode.valueOf(it) }.getOrNull() } + ?: AppThemeMode.SYSTEM, + appContrastOption = root.string("appContrastOption") + ?.let { runCatching { AppContrastOption.valueOf(it) }.getOrNull() } + ?: AppContrastOption.STANDARD, + appTextDimFactorLight = root.float("appTextDimFactorLight") + ?: root.float("appTextDimFactor") + ?: 1.0f, + appTextDimFactorDark = root.float("appTextDimFactorDark") + ?: root.float("appTextDimFactor") + ?: 1.0f, + appSeedColor = root.int("appSeedColor")?.let { Color(it) }, + customAppThemes = root.array("customAppThemes").mapNotNull { it.asCustomAppThemeOrNull() }, + readerToolbarPreferences = root["readerToolbarPreferences"] + ?.takeUnless { it is JsonNull } + ?.asReaderToolbarPreferencesOrNull() + ?: ReaderToolbarPreferences(), + readerHighlightPalette = root["readerHighlightPalette"] + ?.takeUnless { it is JsonNull } + ?.asReaderHighlightPaletteOrNull() + ?: ReaderHighlightPalette(), + readerTtsReplacementPreferences = root["readerTtsReplacementPreferences"] + ?.takeUnless { it is JsonNull } + ?.let { ReaderTtsReplacementPreferencesJson.fromJsonElement(it) } + ?: ReaderTtsReplacementPreferences() + ) + } + + fun encode(snapshot: SharedLibrarySnapshot): String { + val root = JsonObject( + mapOf( + "schemaVersion" to JsonPrimitive(SCHEMA_VERSION), + "books" to JsonArray(snapshot.books.map { it.toJsonObject() }), + "shelves" to JsonArray(snapshot.shelfRecords.map { it.toJsonObject() }), + "bookShelfRefs" to JsonArray(snapshot.shelfRefs.map { it.toJsonObject() }), + "tags" to JsonArray(snapshot.tags.map { it.toJsonObject() }), + "customFonts" to JsonArray(snapshot.customFonts.map { it.toJsonObject() }), + "syncedFolders" to JsonArray(snapshot.syncedFolders.map { it.toJsonObject() }), + "recentFilesLimit" to JsonPrimitive(snapshot.recentFilesLimit), + "isTabsEnabled" to JsonPrimitive(snapshot.isTabsEnabled), + "openTabIds" to snapshot.openTabIds.asJsonArray(), + "activeTabBookId" to snapshot.activeTabBookId.asJson(), + "pinnedHomeBookIds" to snapshot.pinnedHomeBookIds.toList().asJsonArray(), + "pinnedLibraryBookIds" to snapshot.pinnedLibraryBookIds.toList().asJsonArray(), + "useStrictFileFilter" to JsonPrimitive(snapshot.useStrictFileFilter), + "appThemeMode" to JsonPrimitive(snapshot.appThemeMode.name), + "appContrastOption" to JsonPrimitive(snapshot.appContrastOption.name), + "appTextDimFactorLight" to JsonPrimitive(snapshot.appTextDimFactorLight), + "appTextDimFactorDark" to JsonPrimitive(snapshot.appTextDimFactorDark), + "appSeedColor" to snapshot.appSeedColor.asJson(), + "customAppThemes" to JsonArray(snapshot.customAppThemes.map { it.toJsonObject() }), + "readerToolbarPreferences" to snapshot.readerToolbarPreferences.sanitized().toJsonObject(), + "readerHighlightPalette" to snapshot.readerHighlightPalette.sanitized().toJsonObject(), + "readerTtsReplacementPreferences" to ReaderTtsReplacementPreferencesJson.toJsonElement( + snapshot.readerTtsReplacementPreferences, + ) + ) + ) + return json.encodeToString(JsonElement.serializer(), root) + } +} + +private fun JsonObject.array(name: String): List { + return runCatching { this[name]?.jsonArray?.toList().orEmpty() }.getOrDefault(emptyList()) +} + +private fun JsonObject.stringArray(name: String): List { + return array(name).mapNotNull { element -> + runCatching { element.jsonPrimitive.content }.getOrNull() + } +} + +private fun JsonObject.string(name: String): String? { + return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.content }.getOrNull() +} + +private fun JsonObject.long(name: String, fallback: Long = 0L): Long { + return runCatching { this[name]?.jsonPrimitive?.longOrNull }.getOrNull() ?: fallback +} + +private fun JsonObject.nullableLong(name: String): Long? { + return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.longOrNull }.getOrNull() +} + +private fun JsonObject.int(name: String): Int? { + return runCatching { this[name]?.jsonPrimitive?.content?.toIntOrNull() }.getOrNull() +} + +private fun JsonObject.int(name: String, fallback: Int): Int { + return int(name) ?: fallback +} + +private fun JsonObject.float(name: String): Float? { + return runCatching { this[name]?.jsonPrimitive?.floatOrNull }.getOrNull() +} + +private fun JsonObject.double(name: String): Double? { + return runCatching { this[name]?.jsonPrimitive?.doubleOrNull }.getOrNull() +} + +private fun JsonObject.boolean(name: String, fallback: Boolean): Boolean { + return runCatching { this[name]?.jsonPrimitive?.booleanOrNull }.getOrNull() ?: fallback +} + +private fun List.migrateLegacyRecentState(schemaVersion: Int, openTabIds: List): List { + if (schemaVersion >= 3) return this + val openedBookIds = openTabIds.toSet() + return map { book -> + if (book.isRecent && !book.hasReaderFootprint(openedBookIds)) { + book.copy(isRecent = false) + } else { + book + } + } +} + +private fun BookItem.hasReaderFootprint(openedBookIds: Set): Boolean { + return id in openedBookIds || + lastPageIndex != null || + (progressPercentage ?: 0f) > 0f || + readerSettings != null || + readerBookmarks.isNotEmpty() || + readerHighlights.isNotEmpty() +} + +private fun JsonElement.asBookItemOrNull(): BookItem? { + val obj = runCatching { jsonObject }.getOrNull() ?: return null + val id = obj.string("id") ?: return null + val displayName = obj.string("displayName") ?: return null + val type = obj.string("type")?.let { runCatching { FileType.valueOf(it) }.getOrNull() } ?: FileType.UNKNOWN + return BookItem( + id = id, + path = obj.string("path"), + type = type, + displayName = displayName, + timestamp = obj.long("timestamp"), + coverImagePath = obj.string("coverImagePath"), + title = obj.string("title"), + author = obj.string("author"), + progressPercentage = obj.float("progressPercentage"), + isRecent = obj.boolean("isRecent", true), + fileSize = obj.long("fileSize"), + sourceFolder = obj.string("sourceFolder"), + folderTextMetadataParsed = obj.boolean("folderTextMetadataParsed", false), + seriesName = obj.string("seriesName"), + seriesIndex = obj.double("seriesIndex"), + tags = obj.array("tags").mapNotNull { it.asTagOrNull() }, + lastPageIndex = obj.int("lastPageIndex"), + readerSettings = obj["readerSettings"]?.takeUnless { it is JsonNull }?.asReaderSettingsOrNull(), + readerBookmarks = obj.array("readerBookmarks").mapNotNull { it.asReaderBookmarkOrNull() }, + readerHighlights = obj.array("readerHighlights").mapNotNull { it.asReaderHighlightOrNull() } + ) +} + +private fun JsonElement.asShelfRecordOrNull(): ShelfRecord? { + val obj = runCatching { jsonObject }.getOrNull() ?: return null + return ShelfRecord( + id = obj.string("id") ?: return null, + name = obj.string("name") ?: return null, + isSmart = obj.boolean("isSmart", false), + smartRulesJson = obj.string("smartRulesJson") + ) +} + +private fun JsonElement.asBookShelfRefOrNull(): BookShelfRef? { + val obj = runCatching { jsonObject }.getOrNull() ?: return null + return BookShelfRef( + bookId = obj.string("bookId") ?: return null, + shelfId = obj.string("shelfId") ?: return null, + addedAt = obj.long("addedAt") + ) +} + +private fun JsonElement.asTagOrNull(): Tag? { + val obj = runCatching { jsonObject }.getOrNull() ?: return null + return Tag( + id = obj.string("id") ?: return null, + name = obj.string("name") ?: return null, + color = runCatching { + obj["color"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.content?.toIntOrNull() + }.getOrNull() + ) +} + +private fun JsonElement.asCustomFontItemOrNull(): CustomFontItem? { + val obj = runCatching { jsonObject }.getOrNull() ?: return null + return CustomFontItem( + id = obj.string("id") ?: return null, + displayName = obj.string("displayName") ?: return null, + fileName = obj.string("fileName") ?: return null, + fileExtension = obj.string("fileExtension") ?: return null, + path = obj.string("path") ?: return null, + timestamp = obj.long("timestamp"), + isDeleted = obj.boolean("isDeleted", false) + ) +} + +private fun JsonElement.asSyncedFolderOrNull(): SyncedFolder? { + val obj = runCatching { jsonObject }.getOrNull() ?: return null + return SyncedFolder( + uriString = obj.string("uriString") ?: return null, + name = obj.string("name") ?: return null, + lastScanTime = obj.long("lastScanTime"), + allowedFileTypes = obj.stringArray("allowedFileTypes") + .mapNotNull { runCatching { FileType.valueOf(it) }.getOrNull() } + .toSet() + .ifEmpty { FileType.entries.toSet() } + ) +} + +private fun JsonElement.asCustomAppThemeOrNull(): CustomAppTheme? { + val obj = runCatching { jsonObject }.getOrNull() ?: return null + return CustomAppTheme( + id = obj.string("id") ?: return null, + name = obj.string("name") ?: return null, + seedColor = obj.int("seedColor")?.let { Color(it) } ?: return null + ) +} + +private fun BookItem.toJsonObject(): JsonObject { + return JsonObject( + mapOf( + "id" to JsonPrimitive(id), + "path" to path.asJson(), + "type" to JsonPrimitive(type.name), + "displayName" to JsonPrimitive(displayName), + "timestamp" to JsonPrimitive(timestamp), + "coverImagePath" to coverImagePath.asJson(), + "title" to title.asJson(), + "author" to author.asJson(), + "progressPercentage" to progressPercentage.asJson(), + "isRecent" to JsonPrimitive(isRecent), + "fileSize" to JsonPrimitive(fileSize), + "sourceFolder" to sourceFolder.asJson(), + "folderTextMetadataParsed" to JsonPrimitive(folderTextMetadataParsed), + "seriesName" to seriesName.asJson(), + "seriesIndex" to seriesIndex.asJson(), + "tags" to JsonArray(tags.map { it.toJsonObject() }), + "lastPageIndex" to lastPageIndex.asJson(), + "readerSettings" to readerSettings.asJson(), + "readerBookmarks" to JsonArray(readerBookmarks.map { it.toJsonObject() }), + "readerHighlights" to JsonArray(readerHighlights.map { it.toJsonObject() }) + ) + ) +} + +private fun ShelfRecord.toJsonObject(): JsonObject { + return JsonObject( + mapOf( + "id" to JsonPrimitive(id), + "name" to JsonPrimitive(name), + "isSmart" to JsonPrimitive(isSmart), + "smartRulesJson" to smartRulesJson.asJson() + ) + ) +} + +private fun BookShelfRef.toJsonObject(): JsonObject { + return JsonObject( + mapOf( + "bookId" to JsonPrimitive(bookId), + "shelfId" to JsonPrimitive(shelfId), + "addedAt" to JsonPrimitive(addedAt) + ) + ) +} + +private fun Tag.toJsonObject(): JsonObject { + return JsonObject( + mapOf( + "id" to JsonPrimitive(id), + "name" to JsonPrimitive(name), + "color" to color.asJson() + ) + ) +} + +private fun CustomFontItem.toJsonObject(): JsonObject { + return JsonObject( + mapOf( + "id" to JsonPrimitive(id), + "displayName" to JsonPrimitive(displayName), + "fileName" to JsonPrimitive(fileName), + "fileExtension" to JsonPrimitive(fileExtension), + "path" to JsonPrimitive(path), + "timestamp" to JsonPrimitive(timestamp), + "isDeleted" to JsonPrimitive(isDeleted) + ) + ) +} + +private fun SyncedFolder.toJsonObject(): JsonObject { + return JsonObject( + mapOf( + "uriString" to JsonPrimitive(uriString), + "name" to JsonPrimitive(name), + "lastScanTime" to JsonPrimitive(lastScanTime), + "allowedFileTypes" to allowedFileTypes.map { it.name }.sorted().asJsonArray() + ) + ) +} + +private fun CustomAppTheme.toJsonObject(): JsonObject { + return JsonObject( + mapOf( + "id" to JsonPrimitive(id), + "name" to JsonPrimitive(name), + "seedColor" to JsonPrimitive(seedColor.toArgb()) + ) + ) +} + +private fun String?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull +private fun Float?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull +private fun Double?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull +private fun Int?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull +private fun Long?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull +private fun Color?.asJson(): JsonElement = this?.let { JsonPrimitive(it.toArgb()) } ?: JsonNull + +private fun List.asJsonArray(): JsonArray { + return JsonArray(map { JsonPrimitive(it) }) +} + +private fun JsonElement.asReaderSettingsOrNull(): ReaderSettings? { + val obj = runCatching { jsonObject }.getOrNull() ?: return null + val defaults = ReaderSettings() + return ReaderSettings( + fontSize = obj.int("fontSize") ?: defaults.fontSize, + lineSpacing = obj.float("lineSpacing") ?: defaults.lineSpacing, + margin = obj.int("margin") ?: defaults.margin, + darkMode = obj.boolean("darkMode", defaults.darkMode), + readingMode = obj.string("readingMode") + ?.let { runCatching { ReaderReadingMode.valueOf(it) }.getOrNull() } + ?: defaults.readingMode, + textAlign = obj.string("textAlign") + ?.let { runCatching { SharedReaderTextAlign.valueOf(it) }.getOrNull() } + ?: defaults.textAlign, + pageWidth = obj.int("pageWidth") ?: defaults.pageWidth, + fontFamily = obj.string("fontFamily") ?: defaults.fontFamily, + paragraphSpacing = obj.float("paragraphSpacing") ?: defaults.paragraphSpacing, + imageScale = obj.float("imageScale") ?: defaults.imageScale, + horizontalMargin = obj.int("horizontalMargin"), + verticalMargin = obj.int("verticalMargin"), + themeId = obj.string("themeId"), + textureId = obj.string("textureId"), + textureAlpha = obj.float("textureAlpha") ?: defaults.textureAlpha, + customFontPath = obj.string("customFontPath"), + backgroundColorArgb = obj.nullableLong("backgroundColorArgb"), + textColorArgb = obj.nullableLong("textColorArgb"), + systemUiMode = obj.string("systemUiMode") + ?.let { runCatching { SystemUiMode.valueOf(it) }.getOrNull() } + ?: defaults.systemUiMode, + pageInfoMode = obj.string("pageInfoMode") + ?.let { runCatching { PageInfoMode.valueOf(it) }.getOrNull() } + ?: defaults.pageInfoMode, + pageInfoPosition = obj.string("pageInfoPosition") + ?.let { runCatching { PageInfoPosition.valueOf(it) }.getOrNull() } + ?: defaults.pageInfoPosition, + seamlessChapterNavigation = obj.boolean("seamlessChapterNavigation", defaults.seamlessChapterNavigation), + chapterTurnDragMultiplier = obj.float("chapterTurnDragMultiplier") ?: defaults.chapterTurnDragMultiplier + ) +} + +private fun JsonElement.asReaderToolbarPreferencesOrNull(): ReaderToolbarPreferences? { + val obj = runCatching { jsonObject }.getOrNull() ?: return null + val order = obj.stringArray("toolOrder").mapNotNull(ReaderTool::fromId) + val bottomToolIds = if (obj["bottomToolIds"] == null) { + ReaderToolbarPreferences.defaultBottomToolIds + } else { + obj.stringArray("bottomToolIds").toSet() + } + return ReaderToolbarPreferences( + hiddenToolIds = obj.stringArray("hiddenToolIds").toSet(), + toolOrder = order.ifEmpty { ReaderTool.entries.toList() }, + bottomToolIds = bottomToolIds + ).sanitized() +} + +private fun JsonElement.asReaderHighlightPaletteOrNull(): ReaderHighlightPalette? { + val obj = runCatching { jsonObject }.getOrNull() ?: return null + val colors = obj.stringArray("colorIds") + .mapNotNull { colorId -> HighlightColor.entries.firstOrNull { it.id == colorId || it.name == colorId } } + return ReaderHighlightPalette(colors = colors).sanitized() +} + +private fun JsonElement.asReaderBookmarkOrNull(): ReaderBookmark? { + val obj = runCatching { jsonObject }.getOrNull() ?: return null + val pageIndex = obj.int("pageIndex") ?: return null + return ReaderBookmark( + id = obj.string("id") ?: return null, + pageIndex = pageIndex, + chapterTitle = obj.string("chapterTitle") ?: "", + preview = obj.string("preview") ?: "", + locator = obj["locator"] + ?.takeUnless { it is JsonNull } + ?.asReaderLocatorOrNull() + ?.withFallbacks(pageIndex = pageIndex, textQuote = obj.string("preview") ?: "") + ?: ReaderLocator( + pageIndex = pageIndex, + textQuote = obj.string("preview") ?: "" + ) + ) +} + +private fun JsonElement.asReaderHighlightOrNull(): UserHighlight? { + val obj = runCatching { jsonObject }.getOrNull() ?: return null + val cfi = obj.string("cfi") ?: return null + val text = obj.string("text") ?: return null + val chapterIndex = obj.int("chapterIndex") ?: return null + val color = obj.string("colorId") + ?.let { colorId -> HighlightColor.entries.firstOrNull { it.id == colorId } } + ?: HighlightColor.YELLOW + return UserHighlight( + id = obj.string("id") ?: return null, + cfi = cfi, + text = text, + color = color, + chapterIndex = chapterIndex, + note = obj.string("note")?.takeIf { it.isNotBlank() }, + locator = obj["locator"] + ?.takeUnless { it is JsonNull } + ?.asReaderLocatorOrNull() + ?.withFallbacks( + chapterIndex = chapterIndex, + cfi = cfi, + textQuote = text + ) + ?: ReaderLocator.fromLegacy( + chapterIndex = chapterIndex, + cfi = cfi, + textQuote = text + ) + ) +} + +private fun JsonElement.asReaderLocatorOrNull(): ReaderLocator? { + val obj = runCatching { jsonObject }.getOrNull() ?: return null + return ReaderLocator( + chapterIndex = obj.int("chapterIndex"), + chapterId = obj.string("chapterId"), + href = obj.string("href"), + pageIndex = obj.int("pageIndex"), + startOffset = obj.int("startOffset"), + endOffset = obj.int("endOffset"), + textQuote = obj.string("textQuote"), + cfi = obj.string("cfi") + ) +} + +private fun ReaderSettings?.asJson(): JsonElement { + val settings = this ?: return JsonNull + return JsonObject( + mapOf( + "fontSize" to JsonPrimitive(settings.fontSize), + "lineSpacing" to JsonPrimitive(settings.lineSpacing), + "margin" to JsonPrimitive(settings.margin), + "darkMode" to JsonPrimitive(settings.darkMode), + "readingMode" to JsonPrimitive(settings.readingMode.name), + "textAlign" to JsonPrimitive(settings.textAlign.name), + "pageWidth" to JsonPrimitive(settings.pageWidth), + "fontFamily" to JsonPrimitive(settings.fontFamily), + "paragraphSpacing" to JsonPrimitive(settings.paragraphSpacing), + "imageScale" to JsonPrimitive(settings.imageScale), + "horizontalMargin" to settings.horizontalMargin.asJson(), + "verticalMargin" to settings.verticalMargin.asJson(), + "themeId" to settings.themeId.asJson(), + "textureId" to settings.textureId.asJson(), + "textureAlpha" to JsonPrimitive(settings.textureAlpha), + "customFontPath" to settings.customFontPath.asJson(), + "backgroundColorArgb" to settings.backgroundColorArgb.asJson(), + "textColorArgb" to settings.textColorArgb.asJson(), + "systemUiMode" to JsonPrimitive(settings.systemUiMode.name), + "pageInfoMode" to JsonPrimitive(settings.pageInfoMode.name), + "pageInfoPosition" to JsonPrimitive(settings.pageInfoPosition.name), + "seamlessChapterNavigation" to JsonPrimitive(settings.seamlessChapterNavigation), + "chapterTurnDragMultiplier" to JsonPrimitive(settings.chapterTurnDragMultiplier) + ) + ) +} + +private fun ReaderToolbarPreferences.toJsonObject(): JsonObject { + val sanitized = sanitized() + return JsonObject( + mapOf( + "hiddenToolIds" to sanitized.hiddenToolIds.toList().sorted().asJsonArray(), + "toolOrder" to sanitized.toolOrder.map { it.id }.asJsonArray(), + "bottomToolIds" to sanitized.bottomToolIds.toList().sorted().asJsonArray() + ) + ) +} + +private fun ReaderHighlightPalette.toJsonObject(): JsonObject { + return JsonObject( + mapOf( + "colorIds" to sanitized().colors.map { it.id }.asJsonArray() + ) + ) +} + +private fun ReaderBookmark.toJsonObject(): JsonObject { + return JsonObject( + mapOf( + "id" to JsonPrimitive(id), + "pageIndex" to JsonPrimitive(pageIndex), + "chapterTitle" to JsonPrimitive(chapterTitle), + "preview" to JsonPrimitive(preview), + "locator" to locator.toJsonObject() + ) + ) +} + +private fun UserHighlight.toJsonObject(): JsonObject { + return JsonObject( + mapOf( + "id" to JsonPrimitive(id), + "cfi" to JsonPrimitive(cfi), + "text" to JsonPrimitive(text), + "colorId" to JsonPrimitive(color.id), + "chapterIndex" to JsonPrimitive(chapterIndex), + "note" to note.asJson(), + "locator" to locator.toJsonObject() + ) + ) +} + +private fun ReaderLocator.toJsonObject(): JsonObject { + return JsonObject( + buildMap { + chapterIndex?.let { put("chapterIndex", JsonPrimitive(it)) } + chapterId?.let { put("chapterId", JsonPrimitive(it)) } + href?.let { put("href", JsonPrimitive(it)) } + pageIndex?.let { put("pageIndex", JsonPrimitive(it)) } + startOffset?.let { put("startOffset", JsonPrimitive(it)) } + endOffset?.let { put("endOffset", JsonPrimitive(it)) } + textQuote?.let { put("textQuote", JsonPrimitive(it)) } + cfi?.let { put("cfi", JsonPrimitive(it)) } + } + ) +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedReducers.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedReducers.kt index 94f8a1f..bd885e7 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedReducers.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedReducers.kt @@ -1,5 +1,8 @@ package com.aryan.reader.shared +import com.aryan.reader.shared.reader.ReaderEngine +import com.aryan.reader.shared.reader.ReaderSessionState + fun LibraryState.reduce(action: LibraryAction): LibraryState { return when (action) { is LibraryAction.SearchChanged -> copy(searchQuery = action.query) @@ -56,7 +59,122 @@ fun SharedReaderScreenState.reduce(action: AppAction): SharedReaderScreenState { is AppAction.NavigationRequested -> this is AppAction.AppThemeChanged -> copy(appThemeMode = action.mode) is AppAction.AppContrastChanged -> copy(appContrastOption = action.option) + is AppAction.AppTextDimFactorLightChanged -> copy(appTextDimFactorLight = action.factor.coerceIn(0.3f, 1.0f)) + is AppAction.AppTextDimFactorDarkChanged -> copy(appTextDimFactorDark = action.factor.coerceIn(0.3f, 1.0f)) + is AppAction.AppSeedColorChanged -> copy(appSeedColor = action.color) + is AppAction.CustomAppThemeAdded -> { + val updatedThemes = customAppThemes.filterNot { it.id == action.theme.id } + action.theme + copy(customAppThemes = updatedThemes, appSeedColor = action.theme.seedColor) + } + is AppAction.CustomAppThemeDeleted -> { + val updatedThemes = customAppThemes.filterNot { it.id == action.themeId } + val shouldClearSeed = appSeedColor != null && updatedThemes.none { it.seedColor == appSeedColor } + copy( + customAppThemes = updatedThemes, + appSeedColor = if (shouldClearSeed) null else appSeedColor + ) + } is AppAction.SyncEnabledChanged -> copy(isSyncEnabled = action.enabled) is AppAction.FolderSyncEnabledChanged -> copy(isFolderSyncEnabled = action.enabled) + is AppAction.TabsEnabledChanged -> copy( + isTabsEnabled = action.enabled, + openTabIds = if (action.enabled) openTabIds else emptyList(), + activeTabBookId = if (action.enabled) activeTabBookId else null + ) + is AppAction.BookTabOpened -> { + val bookId = action.bookId.trim() + if (bookId.isBlank()) { + this + } else { + copy( + isTabsEnabled = true, + openTabIds = (openTabIds - bookId) + bookId, + activeTabBookId = bookId + ) + } + } + is AppAction.BookTabClosed -> { + val remaining = openTabIds.filterNot { it == action.bookId } + copy( + openTabIds = remaining, + activeTabBookId = if (activeTabBookId == action.bookId) remaining.lastOrNull() else activeTabBookId + ) + } + AppAction.AllTabsClosed -> copy(openTabIds = emptyList(), activeTabBookId = null) + is AppAction.HomePinToggled -> copy( + pinnedHomeBookIds = if (action.bookId in pinnedHomeBookIds) { + pinnedHomeBookIds - action.bookId + } else { + pinnedHomeBookIds + action.bookId + } + ) + is AppAction.LibraryPinToggled -> copy( + pinnedLibraryBookIds = if (action.bookId in pinnedLibraryBookIds) { + pinnedLibraryBookIds - action.bookId + } else { + pinnedLibraryBookIds + action.bookId + } + ) + is AppAction.ReaderToolbarPreferencesChanged -> copy( + readerToolbarPreferences = action.preferences.sanitized() + ) + is AppAction.ReaderToolVisibilityChanged -> copy( + readerToolbarPreferences = readerToolbarPreferences.withVisibility(action.tool, action.hidden) + ) + is AppAction.ReaderToolPlacementChanged -> copy( + readerToolbarPreferences = readerToolbarPreferences.withBottomPlacement(action.tool, action.bottom) + ) + is AppAction.ReaderToolOrderChanged -> copy( + readerToolbarPreferences = readerToolbarPreferences.withToolOrder(action.toolOrder) + ) + is AppAction.ReaderHighlightPaletteChanged -> copy( + readerHighlightPalette = action.palette.sanitized() + ) + is AppAction.ReaderTtsReplacementPreferencesChanged -> copy( + readerTtsReplacementPreferences = action.preferences + ) + } +} + +fun ReaderSessionState.reduce(action: ReaderAction, readerEngine: ReaderEngine): ReaderSessionState { + return when (action) { + ReaderAction.NextPage -> readerEngine.next(this) + ReaderAction.PreviousPage -> readerEngine.previous(this) + is ReaderAction.GoToPage -> readerEngine.goToPage(this, action.pageIndex) + is ReaderAction.GoToPageNumber -> readerEngine.goToPageNumber(this, action.pageNumber) + is ReaderAction.GoToProgress -> readerEngine.goToProgress(this, action.progress) + is ReaderAction.GoToChapter -> readerEngine.goToChapter(this, action.chapterIndex) + is ReaderAction.GoToLocator -> readerEngine.goToLocator(this, action.locator) + is ReaderAction.VisiblePageChanged -> readerEngine.syncVisiblePage(this, action.pageIndex, action.locator) + is ReaderAction.GoToSearchResult -> readerEngine.goToSearchResult(this, action.resultIndex) + is ReaderAction.SearchChanged -> readerEngine.search(this, action.query) + ReaderAction.SearchOpened -> readerEngine.openSearch(this) + ReaderAction.SearchClosed -> readerEngine.closeSearch(this) + ReaderAction.SearchResultsPanelToggled -> readerEngine.toggleSearchResultsPanel(this) + is ReaderAction.SearchOptionsChanged -> readerEngine.updateSearchOptions(this, action.options) + ReaderAction.NextSearchResult -> readerEngine.nextSearchResult(this) + ReaderAction.PreviousSearchResult -> readerEngine.previousSearchResult(this) + ReaderAction.ToggleBookmark -> readerEngine.toggleBookmark(this) + is ReaderAction.ToggleBookmarkAtLocator -> readerEngine.toggleBookmarkAtLocator( + state = this, + locator = action.locator, + chapterTitle = action.title, + preview = action.preview + ) + is ReaderAction.SettingsChanged -> readerEngine.updateSettings(this, action.settings) + is ReaderAction.RenderModeChanged -> readerEngine.updateSettings( + this, + reader.settings.copy(readingMode = action.renderMode.toReaderReadingMode()) + ) + is ReaderAction.ThemeChanged -> readerEngine.updateSettings(this, action.theme.toReaderSettings(reader.settings)) + is ReaderAction.FormatChanged -> readerEngine.updateSettings(this, action.settings.toReaderSettings(reader.settings)) + is ReaderAction.HighlightCreated -> readerEngine.upsertHighlight(this, action.highlight) + is ReaderAction.HighlightUpdated -> readerEngine.updateHighlight( + state = this, + highlightId = action.highlightId, + color = action.color, + note = action.note + ) + is ReaderAction.HighlightDeleted -> readerEngine.deleteHighlight(this, action.highlightId) } } diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/SmartCollectionEngine.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/SmartCollectionEngine.kt new file mode 100644 index 0000000..67e35e0 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/SmartCollectionEngine.kt @@ -0,0 +1,97 @@ +package com.aryan.reader.shared + +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +@Serializable +enum class SmartField { + TITLE, + AUTHOR, + PROGRESS, + FILE_TYPE, + FOLDER, + TAG +} + +@Serializable +enum class SmartOperator { + EQUALS, + CONTAINS, + GREATER_THAN, + LESS_THAN +} + +@Serializable +data class SmartRule( + val field: SmartField, + val operator: SmartOperator, + val value: String +) + +@Serializable +data class SmartCollectionDefinition( + val matchAll: Boolean = true, + val rules: List = emptyList() +) + +object SmartCollectionEngine { + private val json = Json { + encodeDefaults = true + ignoreUnknownKeys = true + } + + fun toJson(definition: SmartCollectionDefinition): String = json.encodeToString(definition) + + fun fromJson(rawJson: String?): SmartCollectionDefinition? { + if (rawJson.isNullOrBlank()) return null + return runCatching { + json.decodeFromString(rawJson) + }.getOrNull() + } + + fun evaluate(book: BookItem, definition: SmartCollectionDefinition): Boolean { + if (definition.rules.isEmpty()) return false + + val results = definition.rules.map { rule -> + when (rule.field) { + SmartField.TITLE -> evaluateString(book.title ?: book.displayName, rule) + SmartField.AUTHOR -> evaluateString(book.author.orEmpty(), rule) + SmartField.FILE_TYPE -> evaluateString(book.type.name, rule) + SmartField.FOLDER -> evaluateString(book.sourceFolder.orEmpty(), rule) + SmartField.TAG -> evaluateTags(book.tags.map { it.name }, rule) + SmartField.PROGRESS -> evaluateNumber(book.progressPercentage ?: 0f, rule) + } + } + return if (definition.matchAll) results.all { it } else results.any { it } + } + + private fun evaluateString(target: String, rule: SmartRule): Boolean { + return when (rule.operator) { + SmartOperator.EQUALS -> target.equals(rule.value, ignoreCase = true) + SmartOperator.CONTAINS -> target.contains(rule.value, ignoreCase = true) + SmartOperator.GREATER_THAN, + SmartOperator.LESS_THAN -> false + } + } + + private fun evaluateNumber(target: Float, rule: SmartRule): Boolean { + val ruleValue = rule.value.toFloatOrNull() ?: return false + return when (rule.operator) { + SmartOperator.EQUALS -> target == ruleValue + SmartOperator.GREATER_THAN -> target > ruleValue + SmartOperator.LESS_THAN -> target < ruleValue + SmartOperator.CONTAINS -> false + } + } + + private fun evaluateTags(tags: List, rule: SmartRule): Boolean { + return when (rule.operator) { + SmartOperator.EQUALS -> tags.any { it.equals(rule.value, ignoreCase = true) } + SmartOperator.CONTAINS -> tags.any { it.contains(rule.value, ignoreCase = true) } + SmartOperator.GREATER_THAN, + SmartOperator.LESS_THAN -> false + } + } +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/opds/SharedOpdsCatalogs.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/opds/SharedOpdsCatalogs.kt new file mode 100644 index 0000000..6bea4a2 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/opds/SharedOpdsCatalogs.kt @@ -0,0 +1,144 @@ +package com.aryan.reader.shared.opds + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +object SharedOpdsCatalogs { + private val json = Json { + prettyPrint = true + ignoreUnknownKeys = true + } + + fun defaultCatalogs(idFactory: () -> String): List { + return listOf( + OpdsCatalog( + id = idFactory(), + title = "Project Gutenberg", + url = "https://m.gutenberg.org/ebooks.opds/", + isDefault = true + ), + OpdsCatalog( + id = idFactory(), + title = "Standard Ebooks", + url = "https://standardebooks.org/feeds/opds", + isDefault = true + ) + ) + } + + fun decode(rawJson: String?): List { + if (rawJson.isNullOrBlank()) return emptyList() + return runCatching { + json.parseToJsonElement(rawJson) + .jsonArray + .mapNotNull { it.asCatalogOrNull() } + }.getOrDefault(emptyList()) + } + + fun decodeOrSeed(rawJson: String?, idFactory: () -> String): List { + return decode(rawJson).ifEmpty { defaultCatalogs(idFactory) } + } + + fun encode(catalogs: List): String { + val array = JsonArray(catalogs.map { it.toJsonObject() }) + return json.encodeToString(JsonElement.serializer(), array) + } + + fun addCatalog( + catalogs: List, + title: String, + url: String, + username: String?, + password: String?, + idFactory: () -> String + ): List { + val normalizedTitle = title.trim() + val normalizedUrl = url.trim() + if (normalizedTitle.isBlank() || normalizedUrl.isBlank()) return catalogs + return catalogs + OpdsCatalog( + id = idFactory(), + title = normalizedTitle, + url = normalizedUrl, + username = username.normalizedCredential(), + password = password.normalizedCredential() + ) + } + + fun updateCatalog( + catalogs: List, + id: String, + title: String, + url: String, + username: String?, + password: String? + ): List { + return catalogs.map { catalog -> + if (catalog.id != id || catalog.isDefault) { + catalog + } else { + catalog.copy( + title = title.trim(), + url = url.trim(), + username = username.normalizedCredential(), + password = password.normalizedCredential() + ) + } + } + } + + fun removeCatalog(catalogs: List, id: String): List { + val catalog = catalogs.firstOrNull { it.id == id } + if (catalog?.isDefault == true) return catalogs + return catalogs.filterNot { it.id == id } + } + + private fun JsonElement.asCatalogOrNull(): OpdsCatalog? { + val obj = runCatching { jsonObject }.getOrNull() ?: return null + val id = obj.string("id") ?: return null + val title = obj.string("title") ?: return null + val url = obj.string("url") ?: return null + return OpdsCatalog( + id = id, + title = title, + url = url, + isDefault = obj.boolean("isDefault") ?: false, + username = obj.string("username").normalizedCredential(), + password = obj.string("password").normalizedCredential() + ) + } + + private fun OpdsCatalog.toJsonObject(): JsonObject { + return JsonObject( + buildMap { + put("id", JsonPrimitive(id)) + put("title", JsonPrimitive(title)) + put("url", JsonPrimitive(url)) + put("isDefault", JsonPrimitive(isDefault)) + put("username", username?.let(::JsonPrimitive) ?: JsonNull) + put("password", password?.let(::JsonPrimitive) ?: JsonNull) + } + ) + } + + private fun JsonObject.string(name: String): String? { + val value = this[name]?.takeUnless { it is JsonNull } ?: return null + return runCatching { value.jsonPrimitive.contentOrNull }.getOrNull() + } + + private fun JsonObject.boolean(name: String): Boolean? { + return runCatching { this[name]?.jsonPrimitive?.booleanOrNull }.getOrNull() + } + + private fun String?.normalizedCredential(): String? { + return this?.trim()?.takeIf { it.isNotBlank() } + } +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/opds/SharedOpdsController.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/opds/SharedOpdsController.kt new file mode 100644 index 0000000..ab34018 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/opds/SharedOpdsController.kt @@ -0,0 +1,162 @@ +package com.aryan.reader.shared.opds + +class SharedOpdsController( + private val repository: SharedOpdsRepository, + private val idFactory: () -> String +) { + private val urlStack = mutableListOf() + + var state: SharedOpdsScreenState = SharedOpdsScreenState(catalogs = repository.loadCatalogs()) + private set + + fun reloadCatalogs(): SharedOpdsScreenState { + state = state.copy(catalogs = repository.loadCatalogs()) + return state + } + + fun addCatalog(title: String, url: String, username: String?, password: String?): SharedOpdsScreenState { + val nextCatalogs = SharedOpdsCatalogs.addCatalog( + catalogs = repository.loadCatalogs(), + title = title, + url = url, + username = username, + password = password, + idFactory = idFactory + ) + repository.saveCatalogs(nextCatalogs) + state = state.copy(catalogs = nextCatalogs) + return state + } + + fun updateCatalog(id: String, title: String, url: String, username: String?, password: String?): SharedOpdsScreenState { + val nextCatalogs = SharedOpdsCatalogs.updateCatalog( + catalogs = repository.loadCatalogs(), + id = id, + title = title, + url = url, + username = username, + password = password + ) + repository.saveCatalogs(nextCatalogs) + state = state.copy( + catalogs = nextCatalogs, + currentCatalog = state.currentCatalog?.let { current -> + nextCatalogs.firstOrNull { it.id == current.id } ?: current + } + ) + return state + } + + fun removeCatalog(id: String): SharedOpdsScreenState { + val nextCatalogs = SharedOpdsCatalogs.removeCatalog(repository.loadCatalogs(), id) + repository.saveCatalogs(nextCatalogs) + state = state.copy(catalogs = nextCatalogs) + return state + } + + suspend fun openCatalog(catalog: OpdsCatalog, emit: (SharedOpdsScreenState) -> Unit) { + urlStack.clear() + state = state.copy(searchUrlTemplate = null, currentCatalog = catalog) + fetchUrl(catalog.url, isPagination = false, emit = emit) + } + + suspend fun openFeedUrl(url: String, emit: (SharedOpdsScreenState) -> Unit) { + fetchUrl(url, isPagination = false, emit = emit) + } + + suspend fun loadNextPage(emit: (SharedOpdsScreenState) -> Unit) { + val nextUrl = state.currentFeed?.nextUrl ?: return + if (state.isLoading) return + fetchUrl(nextUrl, isPagination = true, emit = emit) + } + + suspend fun navigateBack(emit: (SharedOpdsScreenState) -> Unit): Boolean { + return if (urlStack.size > 1) { + urlStack.removeAt(urlStack.lastIndex) + val previousUrl = urlStack.removeAt(urlStack.lastIndex) + fetchUrl(previousUrl, isPagination = false, emit = emit) + true + } else { + urlStack.clear() + state = state.copy( + isViewingCatalog = false, + currentFeed = null, + searchUrlTemplate = null, + currentCatalog = null + ) + emit(state) + false + } + } + + suspend fun search(query: String, emit: (SharedOpdsScreenState) -> Unit) { + val searchLink = state.searchUrlTemplate ?: return + if (query.isBlank()) return + val catalog = state.currentCatalog + state = state.copy(isLoading = true, errorMessage = null) + emit(state) + val finalUrl = runCatching { + SharedOpdsSearch.buildSearchUrl(searchLink, query) { openSearchUrl -> + repository.getSearchTemplate(openSearchUrl, catalog?.username, catalog?.password) + } + }.getOrElse { error -> + state = state.copy(isLoading = false, errorMessage = "Failed to search catalog: ${error.message}") + emit(state) + return + } + fetchUrl(finalUrl, isPagination = false, emit = emit) + } + + fun clearError(): SharedOpdsScreenState { + state = state.copy(errorMessage = null) + return state + } + + fun updateDownloadState(entryId: String, downloadState: SharedOpdsDownloadState?): SharedOpdsScreenState { + val nextMap = if (downloadState == null) { + state.downloadingState - entryId + } else { + state.downloadingState + (entryId to downloadState) + } + state = state.copy(downloadingState = nextMap) + return state + } + + private suspend fun fetchUrl( + url: String, + isPagination: Boolean, + emit: (SharedOpdsScreenState) -> Unit + ) { + val catalog = state.currentCatalog + state = state.copy(isLoading = true, errorMessage = null, isViewingCatalog = true) + emit(state) + + val result = repository.fetchFeed(url, catalog?.username, catalog?.password) + result.onSuccess { newFeed -> + val template = newFeed.searchUrl ?: state.searchUrlTemplate + state = if (isPagination) { + val currentEntries = state.currentFeed?.entries.orEmpty() + state.copy( + isLoading = false, + currentFeed = newFeed.copy(entries = currentEntries + newFeed.entries), + searchUrlTemplate = template + ) + } else { + if (urlStack.isEmpty() || urlStack.last() != url) { + urlStack.add(url) + } + state.copy( + isLoading = false, + currentFeed = newFeed, + searchUrlTemplate = template + ) + } + }.onFailure { error -> + state = state.copy( + isLoading = false, + errorMessage = "Failed to load feed: ${error.message ?: "unknown error"}" + ) + } + emit(state) + } +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/opds/SharedOpdsModels.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/opds/SharedOpdsModels.kt new file mode 100644 index 0000000..9149873 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/opds/SharedOpdsModels.kt @@ -0,0 +1,130 @@ +package com.aryan.reader.shared.opds + +data class OpdsCatalog( + val id: String, + val title: String, + val url: String, + val isDefault: Boolean = false, + val username: String? = null, + val password: String? = null +) + +data class OpdsFacet( + val title: String, + val group: String, + val url: String, + val isActive: Boolean +) + +data class OpdsFeed( + val title: String, + val entries: List, + val nextUrl: String?, + val searchUrl: String? = null, + val facets: List = emptyList() +) + +data class OpdsAuthor( + val name: String, + val url: String? +) + +data class OpdsAcquisition( + val url: String, + val mimeType: String +) { + val formatName: String + get() = when { + mimeType.contains("epub", ignoreCase = true) -> "EPUB" + mimeType.contains("pdf", ignoreCase = true) -> "PDF" + mimeType.contains("markdown", ignoreCase = true) || + mimeType.contains("text/x-markdown", ignoreCase = true) -> "MD" + mimeType.contains("html", ignoreCase = true) || + mimeType.contains("xhtml", ignoreCase = true) -> "HTML" + mimeType.contains("mobi", ignoreCase = true) || + mimeType.contains("x-mobipocket-ebook", ignoreCase = true) -> "MOBI" + mimeType.contains("fictionbook", ignoreCase = true) || + mimeType.contains("fb2", ignoreCase = true) -> "FB2" + mimeType.contains("cbz", ignoreCase = true) || + mimeType.contains("comicbook", ignoreCase = true) -> "CBZ" + mimeType.contains("cbr", ignoreCase = true) || + mimeType.contains("rar", ignoreCase = true) -> "CBR" + mimeType.contains("txt", ignoreCase = true) || + mimeType.contains("text/plain", ignoreCase = true) -> "TXT" + else -> mimeType.substringAfterLast("/").uppercase() + } + + val priority: Int + get() = when (formatName) { + "EPUB" -> 5 + "PDF" -> 4 + "MOBI" -> 3 + "FB2", "MD", "HTML" -> 2 + "CBZ", "CBR", "CB7" -> 1 + "TXT" -> 0 + else -> -1 + } +} + +data class OpdsEntry( + val id: String, + val title: String, + val summary: String?, + val authors: List = emptyList(), + val coverUrl: String?, + val acquisitions: List = emptyList(), + val navigationUrl: String?, + val publisher: String? = null, + val published: String? = null, + val language: String? = null, + val series: String? = null, + val seriesIndex: String? = null, + val categories: List = emptyList(), + val pseCount: Int? = null, + val pseUrlTemplate: String? = null +) { + val author: String? + get() = authors.firstOrNull()?.name + + val bestAcquisition: OpdsAcquisition? + get() = acquisitions.maxByOrNull { it.priority } + + val isAcquisition: Boolean + get() = acquisitions.isNotEmpty() + + val isNavigation: Boolean + get() = navigationUrl != null && acquisitions.isEmpty() + + val isStreamable: Boolean + get() = pseUrlTemplate != null && pseCount != null && pseCount > 0 +} + +data class SharedOpdsDownloadState( + val isDownloading: Boolean, + val progress: Float? = null +) + +data class SharedOpdsScreenState( + val catalogs: List = emptyList(), + val currentCatalog: OpdsCatalog? = null, + val currentFeed: OpdsFeed? = null, + val isLoading: Boolean = false, + val errorMessage: String? = null, + val isViewingCatalog: Boolean = false, + val searchUrlTemplate: String? = null, + val downloadingState: Map = emptyMap() +) + +data class OpdsStreamReference( + val id: String, + val count: Int, + val urlTemplate: String, + val catalogId: String? = null +) + +interface SharedOpdsRepository { + fun loadCatalogs(): List + fun saveCatalogs(catalogs: List) + suspend fun fetchFeed(url: String, username: String? = null, password: String? = null): Result + suspend fun getSearchTemplate(openSearchUrl: String, username: String? = null, password: String? = null): String? +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/opds/SharedOpdsUtilities.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/opds/SharedOpdsUtilities.kt new file mode 100644 index 0000000..734603a --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/opds/SharedOpdsUtilities.kt @@ -0,0 +1,206 @@ +package com.aryan.reader.shared.opds + +import com.aryan.reader.shared.SharedFileCapabilities + +object SharedOpdsSearch { + suspend fun buildSearchUrl( + searchLink: String, + query: String, + openSearchTemplateResolver: suspend (String) -> String? + ): String { + val template = if (searchLink.hasSearchTemplateToken()) { + searchLink + } else { + openSearchTemplateResolver(searchLink) ?: searchLink + } + return expandSearchTemplate(template, query) + } + + fun expandSearchTemplate(template: String, query: String): String { + val encoded = query.percentEncode() + val expandedSearchTerms = template.replace("{searchTerms}", encoded) + if (expandedSearchTerms != template) return expandedSearchTerms + + val queryTemplate = Regex("""\{([?&])([^}]+)\}""").find(template) + if (queryTemplate != null) { + val operator = queryTemplate.groupValues[1] + val variables = queryTemplate.groupValues[2] + .split(',') + .map { it.substringBefore(':').substringBefore('*').trim() } + .filter { it.isNotBlank() } + val parameterName = variables.firstOrNull { it.equals("searchTerms", ignoreCase = true) } + ?: variables.firstOrNull() + ?: "query" + val prefix = template.substringBefore(queryTemplate.value) + val suffix = template.substringAfter(queryTemplate.value) + val separator = when { + operator == "&" -> "&" + prefix.contains("?") -> "&" + else -> "?" + } + return "$prefix$separator$parameterName=$encoded$suffix" + } + + val expandedQuery = template + .replace("{query}", encoded) + .replace("{keyword}", encoded) + if (expandedQuery != template) return expandedQuery + + val separator = if (template.contains("?")) "&" else "?" + return "$template${separator}query=$encoded" + } + + private fun String.hasSearchTemplateToken(): Boolean { + return contains("{searchTerms}") || + Regex("""\{[?&][^}]+\}""").containsMatchIn(this) || + contains("{query}") || + contains("{keyword}") + } +} + +object SharedOpdsDownloadNamer { + fun resolveExtension( + acquisition: OpdsAcquisition, + contentDisposition: String?, + urlPathSegment: String? + ): String { + val candidates = listOfNotNull( + extractContentDispositionFilename(contentDisposition), + urlPathSegment + ) + + candidates.forEach { candidate -> + extensionSuffixFromName(candidate.percentDecode())?.let { return it } + } + + return when (acquisition.formatName) { + "EPUB" -> ".epub" + "PDF" -> ".pdf" + "MOBI" -> ".mobi" + "FB2" -> ".fb2" + "CBZ" -> ".cbz" + "CBR" -> ".cbr" + "CB7" -> ".cb7" + "MD" -> ".md" + "HTML" -> ".html" + "TXT" -> ".txt" + else -> ".epub" + } + } + + fun safeFileStem(title: String, fallback: String = "opds_book"): String { + val safe = title + .replace(Regex("""[^a-zA-Z0-9._-]+"""), "_") + .trim('_') + .take(80) + return safe.ifBlank { fallback } + } + + fun extractContentDispositionFilename(contentDisposition: String?): String? { + if (contentDisposition.isNullOrBlank()) return null + val encodedFilename = Regex("""filename\*=UTF-8''([^;]+)""", RegexOption.IGNORE_CASE) + .find(contentDisposition) + ?.groupValues + ?.getOrNull(1) + if (!encodedFilename.isNullOrBlank()) return encodedFilename.trim('"') + + return Regex("""filename="?([^";]+)"?""", RegexOption.IGNORE_CASE) + .find(contentDisposition) + ?.groupValues + ?.getOrNull(1) + ?.trim() + ?.trim('"') + } + + private fun extensionSuffixFromName(fileName: String?): String? { + if (fileName.isNullOrBlank()) return null + val cleanName = fileName.substringBefore('?').substringBefore('#') + val extension = cleanName.substringAfterLast('.', missingDelimiterValue = "") + .lowercase() + .takeIf { it.isNotBlank() } + ?: return null + if (SharedFileCapabilities.fileTypeForName(cleanName) == com.aryan.reader.shared.FileType.UNKNOWN) return null + return ".$extension" + } +} + +object SharedOpdsStreamUri { + private const val SCHEME_PREFIX = "opds-pse://stream" + + fun build(reference: OpdsStreamReference): String { + return "$SCHEME_PREFIX?id=${reference.id.percentEncode()}" + + "&count=${reference.count}" + + "&url=${reference.urlTemplate.percentEncode()}" + + reference.catalogId?.let { "&catalogId=${it.percentEncode()}" }.orEmpty() + } + + fun parse(uriString: String?): OpdsStreamReference? { + if (uriString.isNullOrBlank() || !uriString.startsWith(SCHEME_PREFIX)) return null + val query = uriString.substringAfter('?', missingDelimiterValue = "") + val params = query.split('&') + .mapNotNull { pair -> + if (pair.isBlank()) return@mapNotNull null + val key = pair.substringBefore('=').percentDecode() + val value = pair.substringAfter('=', missingDelimiterValue = "").percentDecode() + key to value + } + .toMap() + val id = params["id"]?.takeIf { it.isNotBlank() } ?: return null + val count = params["count"]?.toIntOrNull()?.takeIf { it > 0 } ?: return null + val url = params["url"]?.takeIf { it.isNotBlank() } ?: return null + return OpdsStreamReference( + id = id, + count = count, + urlTemplate = url, + catalogId = params["catalogId"]?.takeIf { it.isNotBlank() } + ) + } +} + +fun String.percentEncode(): String { + val bytes = encodeToByteArray() + return buildString(bytes.size) { + bytes.forEach { byte -> + val value = byte.toInt() and 0xFF + val char = value.toChar() + if (char in 'A'..'Z' || char in 'a'..'z' || char in '0'..'9' || char in "-_.~") { + append(char) + } else { + append('%') + append(value.toString(16).uppercase().padStart(2, '0')) + } + } + } +} + +fun String.percentDecode(): String { + val bytes = mutableListOf() + var index = 0 + while (index < length) { + val char = this[index] + if (char == '%' && index + 2 < length) { + val value = substring(index + 1, index + 3).toIntOrNull(16) + if (value != null) { + bytes += value.toByte() + index += 3 + continue + } + } + val encoded = char.toString().encodeToByteArray() + encoded.forEach { bytes += it } + index += 1 + } + return bytes.toByteArray().decodeToString() +} + +object SharedOpdsText { + fun cleanSummary(summary: String?): String { + if (summary.isNullOrBlank()) return "" + return summary + .replace(Regex("""""", RegexOption.IGNORE_CASE), "\n") + .replace(Regex("""""", RegexOption.IGNORE_CASE), "\n\n") + .replace(Regex("""<[^>]+>"""), " ") + .replace(Regex("""\s+"""), " ") + .trim() + } +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfInteractionModels.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfInteractionModels.kt index 9b6c840..b751461 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfInteractionModels.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfInteractionModels.kt @@ -8,7 +8,8 @@ import kotlin.math.roundToInt enum class PdfAnnotationKind { INK, - TEXT + TEXT, + HIGHLIGHT } enum class PdfInkTool { @@ -44,16 +45,96 @@ data class SharedPdfAnnotation( val tool: PdfInkTool = PdfInkTool.PEN, val points: List = emptyList(), val bounds: PdfPageBounds? = null, + val boundsList: List = emptyList(), val text: String = "", + val note: String? = null, val colorArgb: Int, val backgroundArgb: Int = 0x00FFFFFF, val strokeWidth: Float = 2f, val fontSize: Float = 16f, val isBold: Boolean = false, val isItalic: Boolean = false, + val isUnderline: Boolean = false, + val isStrikeThrough: Boolean = false, + val fontPath: String? = null, + val fontName: String? = null, + val rangeStartIndex: Int? = null, + val rangeEndIndex: Int? = null, val createdAt: Long = 0L ) +@Serializable +data class SharedPdfEmbeddedAnnotation( + val id: String, + val pageIndex: Int, + val index: Int, + val subtype: Int, + val bounds: PdfPageBounds, + val contents: String = "", + val author: String = "", + val name: String = "", + val inReplyTo: String = "", + val replies: List = emptyList() +) { + val hasVisibleText: Boolean + get() = contents.isNotBlank() || replies.any { it.hasVisibleText } +} + +object SharedPdfEmbeddedAnnotationThreads { + fun group( + annotations: List, + geometryTolerance: Float = 0.02f + ): List { + if (annotations.isEmpty()) return emptyList() + + val byName = annotations + .filter { it.name.isNotBlank() } + .associateBy { it.name } + val childrenByParentId = mutableMapOf>() + val roots = mutableListOf() + + annotations.forEach { annotation -> + val parent = byName[annotation.inReplyTo] + if (parent != null && parent.id != annotation.id) { + childrenByParentId.getOrPut(parent.id) { mutableListOf() } += annotation + } else { + roots += annotation + } + } + + fun attachReplies( + annotation: SharedPdfEmbeddedAnnotation, + visitedIds: Set = emptySet() + ): SharedPdfEmbeddedAnnotation { + if (annotation.id in visitedIds) return annotation.copy(replies = emptyList()) + val nextVisited = visitedIds + annotation.id + val replies = childrenByParentId[annotation.id] + .orEmpty() + .map { attachReplies(it, nextVisited) } + return annotation.copy(replies = annotation.replies + replies) + } + + val groupedRoots = mutableListOf>() + roots.map { attachReplies(it) }.forEach { annotation -> + val group = groupedRoots.firstOrNull { existingGroup -> + existingGroup.firstOrNull()?.bounds?.inflatedBy(geometryTolerance)?.intersects(annotation.bounds) == true + } + if (group == null) { + groupedRoots += mutableListOf(annotation) + } else { + group += annotation + } + } + + return groupedRoots + .mapNotNull { group -> + val root = group.firstOrNull() ?: return@mapNotNull null + root.copy(replies = root.replies + group.drop(1)) + } + .filter { it.hasVisibleText } + } +} + data class PdfToolConfig( val colorArgb: Int, val strokeWidth: Float @@ -61,10 +142,10 @@ data class PdfToolConfig( object SharedPdfAnnotationDefaults { val penPalette: List = listOf( - 0xFF111111.toInt(), - 0xFFD32F2F.toInt(), - 0xFF1976D2.toInt(), - 0xFF388E3C.toInt(), + 0xFF000000.toInt(), + 0xFFFF0000.toInt(), + 0xFF0000FF.toInt(), + 0xFF4CAF50.toInt(), 0xFFFFFFFF.toInt() ) @@ -78,13 +159,13 @@ object SharedPdfAnnotationDefaults { fun configFor(tool: PdfInkTool): PdfToolConfig { return when (tool) { - PdfInkTool.PEN -> PdfToolConfig(0xFF111111.toInt(), 2.5f) - PdfInkTool.FOUNTAIN_PEN -> PdfToolConfig(0xFF111111.toInt(), 3.5f) - PdfInkTool.PENCIL -> PdfToolConfig(0xFF616161.toInt(), 1.8f) - PdfInkTool.HIGHLIGHTER -> PdfToolConfig(0x8CFFEB3B.toInt(), 12f) - PdfInkTool.HIGHLIGHTER_ROUND -> PdfToolConfig(0x8CFF9800.toInt(), 16f) - PdfInkTool.ERASER -> PdfToolConfig(0x00000000, 18f) - PdfInkTool.TEXT -> PdfToolConfig(0xFF111111.toInt(), 1f) + PdfInkTool.PEN -> PdfToolConfig(0xFFFF0000.toInt(), 0.008f) + PdfInkTool.FOUNTAIN_PEN -> PdfToolConfig(0xFF0000FF.toInt(), 0.008f) + PdfInkTool.PENCIL -> PdfToolConfig(0xFF444444.toInt(), 0.008f) + PdfInkTool.HIGHLIGHTER -> PdfToolConfig(0x8CFF9800.toInt(), 0.035f) + PdfInkTool.HIGHLIGHTER_ROUND -> PdfToolConfig(0x8CFFEB3B.toInt(), 0.035f) + PdfInkTool.ERASER -> PdfToolConfig(0x00000000, 0.03f) + PdfInkTool.TEXT -> PdfToolConfig(0xFF000000.toInt(), 0.02f) } } } @@ -116,6 +197,22 @@ object SharedPdfAnnotationSerializer { } } +private fun PdfPageBounds.inflatedBy(amount: Float): PdfPageBounds { + return PdfPageBounds( + left = (left - amount).coerceAtLeast(0f), + top = (top - amount).coerceAtLeast(0f), + right = (right + amount).coerceAtMost(1f), + bottom = (bottom + amount).coerceAtMost(1f) + ) +} + +private fun PdfPageBounds.intersects(other: PdfPageBounds): Boolean { + return left <= other.right && + right >= other.left && + top <= other.bottom && + bottom >= other.top +} + data class PdfZoomSpec( val min: Float = 0.65f, val max: Float = 3.0f, diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfReaderSession.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfReaderSession.kt new file mode 100644 index 0000000..07c7677 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfReaderSession.kt @@ -0,0 +1,573 @@ +package com.aryan.reader.shared.pdf + +import com.aryan.reader.shared.PdfDisplayMode +import com.aryan.reader.shared.SearchHighlightMode +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +data class SharedPdfSearchResult( + val pageIndex: Int, + val preview: String, + val matchIndex: Int, + val matchLength: Int = 0 +) + +@Serializable +data class SharedPdfBookmark( + val pageIndex: Int, + val label: String = "", + val createdAt: Long = 0L +) + +@Serializable +data class SharedPdfBookmarkStore( + val version: Int = 1, + val bookmarks: List = emptyList() +) + +object SharedPdfBookmarkSerializer { + private val json = Json { + ignoreUnknownKeys = true + prettyPrint = true + encodeDefaults = true + } + + fun encode(bookmarks: List): String { + return json.encodeToString(SharedPdfBookmarkStore(bookmarks = bookmarks)) + } + + fun decode(raw: String): List { + if (raw.isBlank()) return emptyList() + return runCatching { + json.decodeFromString(raw).bookmarks + }.getOrElse { + runCatching { json.decodeFromString>(raw) }.getOrDefault(emptyList()) + } + } +} + +data class SharedPdfJumpHistory( + val pages: List = emptyList(), + val cursor: Int = -1, + val maxEntries: Int = 21 +) { + val backPage: Int? get() = pages.getOrNull(cursor - 1) + val forwardPage: Int? get() = pages.getOrNull(cursor + 1) + val hasJumpTargets: Boolean get() = backPage != null || forwardPage != null + + fun record( + currentPageIndex: Int, + targetPageIndex: Int, + pageCount: Int + ): SharedPdfJumpHistory { + if ( + pageCount <= 0 || + currentPageIndex !in 0 until pageCount || + targetPageIndex !in 0 until pageCount || + currentPageIndex == targetPageIndex + ) { + return this + } + + val pruned = pruned(pageCount) + val nextPages = pruned.pages.toMutableList() + var nextCursor = pruned.cursor + + while (nextPages.lastIndex > nextCursor) { + nextPages.removeAt(nextPages.lastIndex) + } + + if (nextCursor > 0 && nextPages.getOrNull(nextCursor - 1) == currentPageIndex) { + nextPages[nextCursor] = targetPageIndex + return copy( + pages = nextPages, + cursor = nextCursor + ).bounded() + } + + if (nextCursor == -1 || nextPages.getOrNull(nextCursor) != currentPageIndex) { + nextPages += currentPageIndex + nextCursor = nextPages.lastIndex + } + + if (nextPages.lastOrNull() != targetPageIndex) { + nextPages += targetPageIndex + nextCursor = nextPages.lastIndex + } + + return copy( + pages = nextPages, + cursor = nextCursor + ).bounded() + } + + fun pruned(pageCount: Int): SharedPdfJumpHistory { + if (pageCount <= 0) return clear() + val nextPages = pages.toMutableList() + var nextCursor = cursor + var index = nextPages.lastIndex + while (index >= 0) { + if (nextPages[index] !in 0 until pageCount) { + nextPages.removeAt(index) + if (nextCursor >= index) nextCursor-- + } + index-- + } + return copy( + pages = nextPages, + cursor = nextCursor.coerceIn(-1, nextPages.lastIndex) + ).bounded() + } + + fun stepBack(): SharedPdfJumpHistory { + return if (backPage == null) this else copy(cursor = (cursor - 1).coerceAtLeast(0)) + } + + fun stepForward(): SharedPdfJumpHistory { + return if (forwardPage == null) this else copy(cursor = (cursor + 1).coerceAtMost(pages.lastIndex)) + } + + fun clear(): SharedPdfJumpHistory = copy(pages = emptyList(), cursor = -1) + + private fun bounded(): SharedPdfJumpHistory { + val safeMaxEntries = maxEntries.coerceAtLeast(2) + if (pages.size <= safeMaxEntries) { + return copy(cursor = cursor.coerceIn(-1, pages.lastIndex)) + } + val overflow = pages.size - safeMaxEntries + return copy( + pages = pages.drop(overflow), + cursor = (cursor - overflow).coerceIn(-1, pages.size - overflow - 1) + ) + } +} + +data class SharedPdfReaderState( + val pageIndex: Int = 0, + val pageCount: Int = 0, + val displayMode: PdfDisplayMode = PdfDisplayMode.PAGINATION, + val zoom: Float = PdfZoomSpec().default, + val searchQuery: String = "", + val activeSearchResultIndex: Int = -1, + val searchHighlightMode: SearchHighlightMode = SearchHighlightMode.ALL, + val selectedTool: PdfInkTool = PdfInkTool.PEN, + val selectedColorArgb: Int = SharedPdfAnnotationDefaults.configFor(PdfInkTool.PEN).colorArgb, + val strokeWidth: Float = SharedPdfAnnotationDefaults.configFor(PdfInkTool.PEN).strokeWidth, + val isTextSelectionMode: Boolean = false, + val bookmarks: List = emptyList(), + val selectedAnnotationId: String? = null, + val annotations: List = emptyList() +) { + val safePageCount: Int get() = pageCount.coerceAtLeast(0) + val lastPageIndex: Int get() = (safePageCount - 1).coerceAtLeast(0) + val canGoPrevious: Boolean get() = pageIndex > 0 + val canGoNext: Boolean get() = pageIndex < lastPageIndex + val progressPercent: Float get() = ((pageIndex + 1).toFloat() / safePageCount.coerceAtLeast(1)) * 100f + + fun coerced(zoomSpec: PdfZoomSpec = PdfZoomSpec()): SharedPdfReaderState { + val safePage = pageIndex.coerceIn(0, lastPageIndex) + return copy( + pageIndex = safePage, + pageCount = safePageCount, + activeSearchResultIndex = activeSearchResultIndex.coerceAtLeast(-1), + zoom = zoomSpec.clamp(zoom), + bookmarks = bookmarks.normalizedBookmarks(lastPageIndex), + selectedAnnotationId = selectedAnnotationId?.takeIf { selectedId -> + annotations.any { it.id == selectedId } + } + ) + } + + companion object { + fun initial( + pageCount: Int, + initialPageIndex: Int = 0, + zoomSpec: PdfZoomSpec = PdfZoomSpec() + ): SharedPdfReaderState { + val safePageCount = pageCount.coerceAtLeast(0) + val lastPageIndex = (safePageCount - 1).coerceAtLeast(0) + return SharedPdfReaderState( + pageIndex = initialPageIndex.coerceIn(0, lastPageIndex), + pageCount = safePageCount, + zoom = zoomSpec.clamp(zoomSpec.default) + ) + } + } +} + +sealed interface SharedPdfReaderAction { + data class GoToPage(val pageIndex: Int) : SharedPdfReaderAction + data object PreviousPage : SharedPdfReaderAction + data object NextPage : SharedPdfReaderAction + data object FirstPage : SharedPdfReaderAction + data object LastPage : SharedPdfReaderAction + data class DisplayModeChanged(val mode: PdfDisplayMode) : SharedPdfReaderAction + data object DisplayModeToggled : SharedPdfReaderAction + data class ZoomChanged(val zoom: Float) : SharedPdfReaderAction + data class ZoomBy(val delta: Float) : SharedPdfReaderAction + data class SearchChanged(val query: String) : SharedPdfReaderAction + data class SearchHighlightModeChanged(val mode: SearchHighlightMode) : SharedPdfReaderAction + data object SearchHighlightModeToggled : SharedPdfReaderAction + data class GoToSearchResult( + val resultIndex: Int, + val results: List + ) : SharedPdfReaderAction + data class ToolSelected(val tool: PdfInkTool) : SharedPdfReaderAction + data class ColorSelected(val colorArgb: Int) : SharedPdfReaderAction + data class StrokeWidthChanged(val strokeWidth: Float) : SharedPdfReaderAction + data class TextSelectionModeChanged(val enabled: Boolean) : SharedPdfReaderAction + data class BookmarksLoaded(val bookmarks: List) : SharedPdfReaderAction + data class BookmarkToggled( + val pageIndex: Int, + val label: String = "", + val createdAt: Long = 0L + ) : SharedPdfReaderAction + data class AnnotationsLoaded(val annotations: List) : SharedPdfReaderAction + data class AnnotationAdded(val annotation: SharedPdfAnnotation) : SharedPdfReaderAction + data class AnnotationSelected(val annotationId: String?) : SharedPdfReaderAction + data class AnnotationUpdated(val annotation: SharedPdfAnnotation) : SharedPdfReaderAction + data class AnnotationDeleted(val annotationId: String) : SharedPdfReaderAction + data class AnnotationsChanged(val annotations: List) : SharedPdfReaderAction + data class UndoLastAnnotationOnPage(val pageIndex: Int) : SharedPdfReaderAction + data class ClearPageAnnotations(val pageIndex: Int) : SharedPdfReaderAction +} + +fun SharedPdfReaderState.reduce( + action: SharedPdfReaderAction, + zoomSpec: PdfZoomSpec = PdfZoomSpec() +): SharedPdfReaderState { + fun goToPage(target: Int): SharedPdfReaderState { + return copy(pageIndex = target.coerceIn(0, lastPageIndex)).coerced(zoomSpec) + } + + return when (action) { + is SharedPdfReaderAction.GoToPage -> goToPage(action.pageIndex) + SharedPdfReaderAction.PreviousPage -> goToPage(pageIndex - 1) + SharedPdfReaderAction.NextPage -> goToPage(pageIndex + 1) + SharedPdfReaderAction.FirstPage -> goToPage(0) + SharedPdfReaderAction.LastPage -> goToPage(lastPageIndex) + is SharedPdfReaderAction.DisplayModeChanged -> copy(displayMode = action.mode) + SharedPdfReaderAction.DisplayModeToggled -> copy( + displayMode = when (displayMode) { + PdfDisplayMode.PAGINATION -> PdfDisplayMode.VERTICAL_SCROLL + PdfDisplayMode.VERTICAL_SCROLL -> PdfDisplayMode.PAGINATION + } + ) + is SharedPdfReaderAction.ZoomChanged -> copy(zoom = zoomSpec.clamp(action.zoom)) + is SharedPdfReaderAction.ZoomBy -> copy(zoom = zoomSpec.clamp(zoom + action.delta)) + is SharedPdfReaderAction.SearchChanged -> copy( + searchQuery = action.query, + activeSearchResultIndex = -1 + ) + is SharedPdfReaderAction.SearchHighlightModeChanged -> copy(searchHighlightMode = action.mode) + SharedPdfReaderAction.SearchHighlightModeToggled -> copy( + searchHighlightMode = when (searchHighlightMode) { + SearchHighlightMode.ALL -> SearchHighlightMode.FOCUSED + SearchHighlightMode.FOCUSED -> SearchHighlightMode.ALL + } + ) + is SharedPdfReaderAction.GoToSearchResult -> { + if (action.results.isEmpty()) { + this + } else { + val normalizedIndex = action.resultIndex.wrapIndex(action.results.size) + copy( + activeSearchResultIndex = normalizedIndex, + pageIndex = action.results[normalizedIndex].pageIndex.coerceIn(0, lastPageIndex) + ) + } + } + is SharedPdfReaderAction.ToolSelected -> { + val config = SharedPdfAnnotationDefaults.configFor(action.tool) + copy( + selectedTool = action.tool, + selectedColorArgb = config.colorArgb, + strokeWidth = config.strokeWidth + ) + } + is SharedPdfReaderAction.ColorSelected -> copy(selectedColorArgb = action.colorArgb) + is SharedPdfReaderAction.StrokeWidthChanged -> copy(strokeWidth = action.strokeWidth.coerceAtLeast(0.0001f)) + is SharedPdfReaderAction.TextSelectionModeChanged -> copy(isTextSelectionMode = action.enabled) + is SharedPdfReaderAction.BookmarksLoaded -> copy(bookmarks = action.bookmarks.normalizedBookmarks(lastPageIndex)) + is SharedPdfReaderAction.BookmarkToggled -> { + val page = action.pageIndex.coerceIn(0, lastPageIndex) + val withoutPage = bookmarks.filterNot { it.pageIndex == page } + val nextBookmarks = if (withoutPage.size == bookmarks.size) { + withoutPage + SharedPdfBookmark( + pageIndex = page, + label = action.label.ifBlank { "Page ${page + 1}" }, + createdAt = action.createdAt + ) + } else { + withoutPage + } + copy(bookmarks = nextBookmarks.normalizedBookmarks(lastPageIndex)) + } + is SharedPdfReaderAction.AnnotationsLoaded -> copy(annotations = action.annotations.toList()) + is SharedPdfReaderAction.AnnotationAdded -> copy( + annotations = annotations + action.annotation, + selectedAnnotationId = action.annotation.id + ) + is SharedPdfReaderAction.AnnotationSelected -> copy( + selectedAnnotationId = action.annotationId?.takeIf { id -> annotations.any { it.id == id } } + ) + is SharedPdfReaderAction.AnnotationUpdated -> { + val index = annotations.indexOfFirst { it.id == action.annotation.id } + if (index < 0) { + this + } else { + copy(annotations = annotations.toMutableList().also { it[index] = action.annotation }) + } + } + is SharedPdfReaderAction.AnnotationDeleted -> copy( + annotations = annotations.filterNot { it.id == action.annotationId }, + selectedAnnotationId = selectedAnnotationId?.takeIf { it != action.annotationId } + ) + is SharedPdfReaderAction.AnnotationsChanged -> copy(annotations = action.annotations.toList()) + is SharedPdfReaderAction.UndoLastAnnotationOnPage -> { + val index = annotations.indexOfLast { it.pageIndex == action.pageIndex } + if (index < 0) { + this + } else { + val removedId = annotations[index].id + copy( + annotations = annotations.toMutableList().also { it.removeAt(index) }, + selectedAnnotationId = selectedAnnotationId?.takeIf { it != removedId } + ) + } + } + is SharedPdfReaderAction.ClearPageAnnotations -> { + val removedIds = annotations.filter { it.pageIndex == action.pageIndex }.map { it.id }.toSet() + copy( + annotations = annotations.filterNot { it.pageIndex == action.pageIndex }, + selectedAnnotationId = selectedAnnotationId?.takeIf { it !in removedIds } + ) + } + }.coerced(zoomSpec) +} + +object SharedPdfSearchEngine { + fun search( + pageTexts: List, + query: String, + previewRadiusBefore: Int = 70, + previewRadiusAfter: Int = 100 + ): List { + val normalized = query.trim() + if (normalized.isBlank()) return emptyList() + return pageTexts.flatMapIndexed { pageIndex, text -> + val matches = mutableListOf() + var startIndex = 0 + while (startIndex < text.length) { + val matchIndex = text.indexOf(normalized, startIndex, ignoreCase = true) + if (matchIndex < 0) break + matches += SharedPdfSearchResult( + pageIndex = pageIndex, + preview = text.previewAround( + index = matchIndex, + queryLength = normalized.length, + before = previewRadiusBefore, + after = previewRadiusAfter + ), + matchIndex = matchIndex, + matchLength = normalized.length + ) + startIndex = matchIndex + normalized.length.coerceAtLeast(1) + } + matches + } + } + + fun highlightsForPage( + results: List, + pageIndex: Int, + activeResultIndex: Int, + mode: SearchHighlightMode + ): List { + return when (mode) { + SearchHighlightMode.ALL -> results.filter { it.pageIndex == pageIndex } + SearchHighlightMode.FOCUSED -> { + val active = results.getOrNull(activeResultIndex) + if (active?.pageIndex == pageIndex) listOf(active) else emptyList() + } + } + } +} + +class SharedPdfSearchIndex( + val pageCount: Int = 0 +) { + private val pageTexts = LinkedHashMap() + private val tokenPages = LinkedHashMap>() + + val indexedPageCount: Int + get() = pageTexts.size + + fun hasPage(pageIndex: Int): Boolean = pageTexts.containsKey(pageIndex) + + fun pageText(pageIndex: Int): String? = pageTexts[pageIndex] + + fun indexedPages(): List { + return pageTexts.entries + .sortedBy { it.key } + .map { SharedPdfIndexedPage(pageIndex = it.key, text = it.value) } + } + + fun putPage(pageIndex: Int, text: String) { + if (pageCount > 0 && pageIndex !in 0 until pageCount) return + removePageTokens(pageIndex) + pageTexts[pageIndex] = text + text.searchTokens().forEach { token -> + tokenPages.getOrPut(token) { linkedSetOf() } += pageIndex + } + } + + fun clear() { + pageTexts.clear() + tokenPages.clear() + } + + fun search( + query: String, + previewRadiusBefore: Int = 70, + previewRadiusAfter: Int = 100 + ): List { + val normalized = query.trim() + if (normalized.isBlank()) return emptyList() + val matcher = SharedPdfPhraseMatcher(normalized) + val candidates = candidatePages(matcher.tokens) + return candidates.flatMap { pageIndex -> + val text = pageTexts[pageIndex].orEmpty() + matcher.findAll(text).map { match -> + SharedPdfSearchResult( + pageIndex = pageIndex, + preview = text.previewAround( + index = match.startIndex, + queryLength = match.length, + before = previewRadiusBefore, + after = previewRadiusAfter + ), + matchIndex = match.startIndex, + matchLength = match.length + ) + } + } + } + + private fun candidatePages(tokens: List): List { + if (tokens.isEmpty()) return pageTexts.keys.sorted() + val candidateSets = tokens.map { token -> + tokenPages.asSequence() + .filter { (indexedToken, _) -> indexedToken.startsWith(token) } + .flatMap { (_, pages) -> pages.asSequence() } + .toSet() + } + if (candidateSets.any { it.isEmpty() }) return emptyList() + return candidateSets + .drop(1) + .fold(candidateSets.first()) { acc, pages -> acc.intersect(pages) } + .sorted() + } + + private fun removePageTokens(pageIndex: Int) { + if (!pageTexts.containsKey(pageIndex)) return + val emptyTokens = mutableListOf() + tokenPages.forEach { (token, pages) -> + pages.remove(pageIndex) + if (pages.isEmpty()) emptyTokens += token + } + emptyTokens.forEach(tokenPages::remove) + } +} + +data class SharedPdfIndexedPage( + val pageIndex: Int, + val text: String +) + +private data class SharedPdfPhraseMatch( + val startIndex: Int, + val length: Int +) + +private class SharedPdfPhraseMatcher(query: String) { + val tokens: List = query.searchTokens() + private val regex = query.toSearchPhraseRegex() + private val literal = query.takeIf { regex == null } + + fun findAll(text: String): List { + return if (regex != null) { + regex.findAll(text).map { match -> + SharedPdfPhraseMatch( + startIndex = match.range.first, + length = match.range.last - match.range.first + 1 + ) + }.toList() + } else { + val needle = literal.orEmpty() + val matches = mutableListOf() + var startIndex = 0 + while (startIndex < text.length) { + val matchIndex = text.indexOf(needle, startIndex, ignoreCase = true) + if (matchIndex < 0) break + matches += SharedPdfPhraseMatch(matchIndex, needle.length) + startIndex = matchIndex + needle.length.coerceAtLeast(1) + } + matches + } + } +} + +private fun String.toSearchPhraseRegex(): Regex? { + val tokens = trim().split(Regex("\\s+")).filter { it.isNotBlank() } + if (tokens.size <= 1) return null + val prefix = if (all { it.code < 128 }) "\\b" else "" + return Regex(prefix + tokens.joinToString("\\s+") { Regex.escape(it) }, RegexOption.IGNORE_CASE) +} + +private fun Int.wrapIndex(size: Int): Int { + if (size <= 0) return -1 + return when { + this < 0 -> size - 1 + this >= size -> 0 + else -> this + } +} + +private fun List.normalizedBookmarks(lastPageIndex: Int): List { + return asSequence() + .filter { it.pageIndex in 0..lastPageIndex } + .distinctBy { it.pageIndex } + .sortedBy { it.pageIndex } + .toList() +} + +private fun String.previewAround( + index: Int, + queryLength: Int, + before: Int, + after: Int +): String { + val start = (index - before).coerceAtLeast(0) + val end = (index + queryLength + after).coerceAtMost(length) + val prefix = if (start > 0) "..." else "" + val suffix = if (end < length) "..." else "" + return prefix + substring(start, end).replace(Regex("\\s+"), " ").trim() + suffix +} + +private fun String.searchTokens(): List { + val tokens = mutableListOf() + val current = StringBuilder() + forEach { char -> + if (char.isLetterOrDigit() || char == '_') { + current.append(char.lowercaseChar()) + } else if (current.isNotEmpty()) { + tokens += current.toString() + current.setLength(0) + } + } + if (current.isNotEmpty()) tokens += current.toString() + return tokens.distinct() +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfSelectionGeometry.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfSelectionGeometry.kt new file mode 100644 index 0000000..e257e7d --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfSelectionGeometry.kt @@ -0,0 +1,172 @@ +package com.aryan.reader.shared.pdf + +import kotlin.math.abs + +data class PdfNormalizedPoint( + val x: Float, + val y: Float +) + +data class PdfTextCharBounds( + val index: Int, + val left: Float, + val top: Float, + val right: Float, + val bottom: Float +) { + val hasBounds: Boolean + get() = right > left && bottom > top +} + +object PdfSelectionGeometry { + private const val DefaultMergedLineTolerance = 0.006f + private const val DefaultCharLineTolerance = 0.012f + private const val MinLineTolerance = 0.002f + + fun normalizedPoint( + pointX: Float, + pointY: Float, + viewportWidth: Int, + viewportHeight: Int + ): PdfNormalizedPoint? { + if (viewportWidth <= 0 || viewportHeight <= 0) return null + return PdfNormalizedPoint( + x = (pointX / viewportWidth).coerceIn(0f, 1f), + y = (pointY / viewportHeight).coerceIn(0f, 1f) + ) + } + + fun mergeBoundsByLine( + bounds: List, + lineTolerance: Float = DefaultMergedLineTolerance + ): List { + if (bounds.isEmpty()) return emptyList() + val lines = mutableListOf>() + bounds.sortedWith(compareBy { it.top }.thenBy { it.left }).forEach { boundsForChar -> + val line = lines.firstOrNull { existing -> + existing.any { it.isSameVisualLineAs(boundsForChar, lineTolerance) } + } + if (line == null) { + lines += mutableListOf(boundsForChar) + } else { + line += boundsForChar + } + } + return lines.map { it.toMergedBounds() } + } + + fun lineBoundsForChars( + chars: List, + lineTolerance: Float = DefaultCharLineTolerance + ): List { + return chars.groupByLine(lineTolerance).map { it.toCharLineBounds() } + } + + fun nearestCharOnLine( + chars: List, + point: PdfNormalizedPoint, + lineTolerance: Float = DefaultCharLineTolerance + ): PdfTextCharBounds? { + val lines = chars.groupByLine(lineTolerance) + val matchingLines = lines.filter { line -> + val top = line.minOf { it.top } + val bottom = line.maxOf { it.bottom } + val averageHeight = line.map { it.bottom - it.top }.average().toFloat() + val verticalPadding = maxOf(averageHeight * 0.45f, MinLineTolerance) + point.y in (top - verticalPadding)..(bottom + verticalPadding) + } + val line = matchingLines.minWithOrNull( + compareBy>( + { lineVerticalDistance(point.y, it) }, + { lineHorizontalDistance(point.x, it) } + ) + ) ?: return null + + return line.minByOrNull { char -> + horizontalDistance(point.x, char) + } + } + + private fun List.groupByLine(lineTolerance: Float): List> { + val lines = mutableListOf>() + filter { it.hasBounds } + .sortedWith(compareBy { it.top }.thenBy { it.left }) + .forEach { char -> + val line = lines.firstOrNull { existing -> + val averageHeight = existing.map { it.bottom - it.top }.average().toFloat() + val charHeight = char.bottom - char.top + val dynamicTolerance = maxOf(minOf(averageHeight, charHeight) * 0.55f, MinLineTolerance) + abs(existing.averageVerticalMidpoint() - char.verticalMidpoint()) <= minOf(lineTolerance, dynamicTolerance) + } + if (line == null) { + lines += mutableListOf(char) + } else { + line += char + } + } + return lines + } + + private fun List.toCharLineBounds(): PdfPageBounds { + return PdfPageBounds( + left = minOf { it.left }.coerceIn(0f, 1f), + top = minOf { it.top }.coerceIn(0f, 1f), + right = maxOf { it.right }.coerceIn(0f, 1f), + bottom = maxOf { it.bottom }.coerceIn(0f, 1f) + ) + } + + private fun List.toMergedBounds(): PdfPageBounds { + return PdfPageBounds( + left = minOf { it.left }.coerceIn(0f, 1f), + top = minOf { it.top }.coerceIn(0f, 1f), + right = maxOf { it.right }.coerceIn(0f, 1f), + bottom = maxOf { it.bottom }.coerceIn(0f, 1f) + ) + } + + private fun PdfTextCharBounds.verticalMidpoint(): Float = (top + bottom) / 2f + + private fun PdfPageBounds.isSameVisualLineAs(other: PdfPageBounds, lineTolerance: Float): Boolean { + val overlap = minOf(bottom, other.bottom) - maxOf(top, other.top) + val minHeight = minOf(bottom - top, other.bottom - other.top) + if (overlap > 0f && overlap >= minHeight * 0.45f) return true + + val dynamicTolerance = maxOf(minHeight * 0.35f, MinLineTolerance) + return abs(verticalMidpoint() - other.verticalMidpoint()) <= minOf(lineTolerance, dynamicTolerance) + } + + private fun PdfPageBounds.verticalMidpoint(): Float = (top + bottom) / 2f + + private fun List.averageVerticalMidpoint(): Float { + return map { it.verticalMidpoint() }.average().toFloat() + } + + private fun lineVerticalDistance(pointY: Float, line: List): Float { + val top = line.minOf { it.top } + val bottom = line.maxOf { it.bottom } + return when { + pointY < top -> top - pointY + pointY > bottom -> pointY - bottom + else -> 0f + } + } + + private fun lineHorizontalDistance(pointX: Float, line: List): Float { + val left = line.minOf { it.left } + val right = line.maxOf { it.right } + return when { + pointX < left -> left - pointX + pointX > right -> pointX - right + else -> 0f + } + } + + private fun horizontalDistance(pointX: Float, char: PdfTextCharBounds): Float { + return when { + pointX < char.left -> char.left - pointX + pointX > char.right -> pointX - char.right + else -> 0f + } + } +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfVerticalLayout.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfVerticalLayout.kt new file mode 100644 index 0000000..04e68eb --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfVerticalLayout.kt @@ -0,0 +1,30 @@ +package com.aryan.reader.shared.pdf + +data class PdfVisiblePageLayout( + val pageIndex: Int, + val top: Float, + val bottom: Float +) { + val visibleHeight: Float + get() = (bottom - top).coerceAtLeast(0f) +} + +fun mostVisiblePdfPageIndex( + visiblePages: List, + viewportTop: Float, + viewportBottom: Float, + fallbackPageIndex: Int +): Int { + return visiblePages + .filter { it.visibleHeight > 0f } + .map { page -> + val top = maxOf(page.top, viewportTop) + val bottom = minOf(page.bottom, viewportBottom) + page to (bottom - top).coerceAtLeast(0f) + } + .maxByOrNull { it.second } + ?.takeIf { it.second > 0f } + ?.first + ?.pageIndex + ?: fallbackPageIndex +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfAnnotationSidecarCodec.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfAnnotationSidecarCodec.kt new file mode 100644 index 0000000..53dccee --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfAnnotationSidecarCodec.kt @@ -0,0 +1,415 @@ +package com.aryan.reader.shared.pdf + +import com.aryan.reader.shared.localFolderSyncSha256ShortHex +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull +import kotlin.math.pow + +object SharedPdfAnnotationSidecarCodec { + const val KEY_PDF_ANNOTATIONS = "pdfAnnotations" + const val KEY_LEGACY_INK = "ink" + const val KEY_LEGACY_TEXT_BOXES = "textBoxes" + const val KEY_LEGACY_HIGHLIGHTS = "highlights" + + private const val LEGACY_TEXT_BOX_FONT_REFERENCE_DP = 500f + + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + prettyPrint = true + } + + fun encodeAnnotationsElement(annotations: List): JsonElement { + return json.parseToJsonElement(SharedPdfAnnotationSerializer.encode(annotations)) + } + + fun decodeAnnotationsElement(element: JsonElement): List { + return SharedPdfAnnotationSerializer.decode(json.encodeToString(JsonElement.serializer(), element)) + } + + fun annotationsFromData(data: JsonObject): List { + data[KEY_PDF_ANNOTATIONS]?.let { return decodeAnnotationsElement(it) } + + data[KEY_LEGACY_INK]?.let { ink -> + val decoded = decodeAnnotationsElement(ink) + if (decoded.isNotEmpty() || ink.looksLikeSharedAnnotationStore()) { + return decoded + } + } + + return legacyAndroidAnnotationsFromData(data) + } + + fun withCanonicalAnnotations(data: JsonObject): JsonObject { + if (data[KEY_PDF_ANNOTATIONS] != null) return data + val annotations = annotationsFromData(data) + if (annotations.isEmpty()) return data + return JsonObject(data + (KEY_PDF_ANNOTATIONS to encodeAnnotationsElement(annotations))) + } + + fun canonicalizeDataJson(rawDataJson: String): String { + val data = parseObjectOrNull(rawDataJson) ?: return rawDataJson + return json.encodeToString(JsonElement.serializer(), withCanonicalAnnotations(data)) + } + + fun legacyAndroidDataFromAnnotations( + annotations: List, + existingData: JsonObject = JsonObject(emptyMap()) + ): JsonObject { + if (annotations.isEmpty()) return existingData + + val next = existingData.toMutableMap() + if (!existingData[KEY_LEGACY_INK].isLegacyAndroidInkArray()) { + next[KEY_LEGACY_INK] = annotations.toLegacyAndroidInkArray() + } + if (!existingData[KEY_LEGACY_TEXT_BOXES].isJsonArray()) { + next[KEY_LEGACY_TEXT_BOXES] = annotations.toLegacyAndroidTextBoxArray() + } + if (!existingData[KEY_LEGACY_HIGHLIGHTS].isJsonArray()) { + next[KEY_LEGACY_HIGHLIGHTS] = annotations.toLegacyAndroidHighlightArray() + } + return JsonObject(next) + } + + fun legacyAndroidDataJsonFromCanonical(rawDataJson: String): String { + val data = parseObjectOrNull(rawDataJson) ?: return rawDataJson + val annotations = annotationsFromData(data) + if (annotations.isEmpty()) return rawDataJson + return json.encodeToString( + JsonElement.serializer(), + legacyAndroidDataFromAnnotations(annotations, data) + ) + } + + private fun legacyAndroidAnnotationsFromData(data: JsonObject): List { + return buildList { + addAll(data[KEY_LEGACY_INK].parseLegacyAndroidInk()) + addAll(data[KEY_LEGACY_TEXT_BOXES].parseLegacyAndroidTextBoxes()) + addAll(data[KEY_LEGACY_HIGHLIGHTS].parseLegacyAndroidHighlights()) + } + } + + private fun JsonElement?.parseLegacyAndroidInk(): List { + val array = this?.jsonArrayOrNull() ?: return emptyList() + if (!this.isLegacyAndroidInkArray()) return emptyList() + return array.mapNotNull { element -> + val obj = element.jsonObjectOrNull() ?: return@mapNotNull null + val points = obj.array("points") + ?.mapNotNull { pointElement -> + val point = pointElement.jsonObjectOrNull() ?: return@mapNotNull null + PdfPagePoint( + x = point.float("x") ?: return@mapNotNull null, + y = point.float("y") ?: return@mapNotNull null, + timestamp = point.long("t") ?: point.long("timestamp") ?: 0L + ) + } + .orEmpty() + if (points.isEmpty()) return@mapNotNull null + + val tool = obj.string("inkType") + ?: obj.string("type") + ?: PdfInkTool.PEN.name + SharedPdfAnnotation( + id = obj.string("id") ?: stableAnnotationId("ink", element), + pageIndex = obj.int("pageIndex") ?: return@mapNotNull null, + kind = PdfAnnotationKind.INK, + tool = tool.toPdfInkTool(), + points = points, + colorArgb = obj.int("color") ?: SharedPdfAnnotationDefaults.configFor(PdfInkTool.PEN).colorArgb, + strokeWidth = obj.float("strokeWidth") ?: SharedPdfAnnotationDefaults.configFor(PdfInkTool.PEN).strokeWidth, + createdAt = points.firstOrNull()?.timestamp ?: 0L + ) + } + } + + private fun JsonElement?.parseLegacyAndroidTextBoxes(): List { + val array = this?.jsonArrayOrNull() ?: return emptyList() + return array.mapNotNull { element -> + val obj = element.jsonObjectOrNull() ?: return@mapNotNull null + val bounds = obj.objectValue("bounds")?.toPdfPageBoundsOrNull() ?: return@mapNotNull null + val rawFontSize = obj.float("fontSize") ?: 16f + SharedPdfAnnotation( + id = obj.string("id") ?: stableAnnotationId("text", element), + pageIndex = obj.int("pageIndex") ?: return@mapNotNull null, + kind = PdfAnnotationKind.TEXT, + tool = PdfInkTool.TEXT, + bounds = bounds, + text = obj.string("text").orEmpty(), + colorArgb = obj.int("color") ?: 0xFF000000.toInt(), + backgroundArgb = obj.int("backgroundColor") ?: 0x00000000, + strokeWidth = SharedPdfAnnotationDefaults.configFor(PdfInkTool.TEXT).strokeWidth, + fontSize = rawFontSize.legacyTextBoxFontSizeToShared(), + isBold = obj.boolean("isBold") ?: false, + isItalic = obj.boolean("isItalic") ?: false, + isUnderline = obj.boolean("isUnderline") ?: false, + isStrikeThrough = obj.boolean("isStrikeThrough") ?: false, + fontPath = obj.string("fontPath"), + fontName = obj.string("fontName") + ) + } + } + + private fun JsonElement?.parseLegacyAndroidHighlights(): List { + val array = this?.jsonArrayOrNull() ?: return emptyList() + return array.mapNotNull { element -> + val obj = element.jsonObjectOrNull() ?: return@mapNotNull null + val boundsList = obj.array("bounds") + ?.mapNotNull { it.jsonObjectOrNull()?.toPdfPageBoundsOrNull() } + ?.filter { it.isNormalizedPageBounds() } + .orEmpty() + val rangeStart = obj.int("rangeStart") + val rangeEnd = obj.int("rangeEnd") + if (boundsList.isEmpty() && (rangeStart == null || rangeEnd == null)) return@mapNotNull null + val inclusiveRangeEnd = if (rangeStart != null && rangeEnd != null) { + (rangeEnd - 1).coerceAtLeast(rangeStart) + } else { + rangeEnd + } + + val colorName = obj.string("color") ?: "YELLOW" + SharedPdfAnnotation( + id = obj.string("id") ?: stableAnnotationId("highlight", element), + pageIndex = obj.int("pageIndex") ?: return@mapNotNull null, + kind = PdfAnnotationKind.HIGHLIGHT, + tool = PdfInkTool.HIGHLIGHTER, + bounds = boundsList.firstOrNull(), + boundsList = boundsList, + text = obj.string("text").orEmpty(), + note = obj.string("note"), + colorArgb = colorName.toSharedHighlightArgb(), + rangeStartIndex = rangeStart, + rangeEndIndex = inclusiveRangeEnd + ) + } + } + + private fun List.toLegacyAndroidInkArray(): JsonArray { + return JsonArray( + filter { it.kind == PdfAnnotationKind.INK && it.points.isNotEmpty() } + .map { annotation -> + JsonObject( + buildMap { + put("id", JsonPrimitive(annotation.id)) + put("pageIndex", JsonPrimitive(annotation.pageIndex)) + put("annotationType", JsonPrimitive("INK")) + put("inkType", JsonPrimitive(annotation.tool.name)) + put("color", JsonPrimitive(annotation.colorArgb)) + put("strokeWidth", JsonPrimitive(annotation.strokeWidth.toDouble())) + put( + "points", + JsonArray( + annotation.points.map { point -> + JsonObject( + mapOf( + "x" to JsonPrimitive(point.x.toDouble()), + "y" to JsonPrimitive(point.y.toDouble()), + "t" to JsonPrimitive(point.timestamp) + ) + ) + } + ) + ) + } + ) + } + ) + } + + private fun List.toLegacyAndroidTextBoxArray(): JsonArray { + return JsonArray( + filter { it.kind == PdfAnnotationKind.TEXT && it.bounds != null } + .map { annotation -> + val bounds = requireNotNull(annotation.bounds) + JsonObject( + buildMap { + put("id", JsonPrimitive(annotation.id)) + put("pageIndex", JsonPrimitive(annotation.pageIndex)) + put("text", JsonPrimitive(annotation.text)) + put("color", JsonPrimitive(annotation.colorArgb)) + put("backgroundColor", JsonPrimitive(annotation.backgroundArgb)) + put("fontSize", JsonPrimitive(annotation.fontSize.sharedFontSizeToLegacyTextBox().toDouble())) + put("isBold", JsonPrimitive(annotation.isBold)) + put("isItalic", JsonPrimitive(annotation.isItalic)) + put("isUnderline", JsonPrimitive(annotation.isUnderline)) + put("isStrikeThrough", JsonPrimitive(annotation.isStrikeThrough)) + annotation.fontPath?.let { put("fontPath", JsonPrimitive(it)) } + annotation.fontName?.let { put("fontName", JsonPrimitive(it)) } + put("bounds", bounds.toJsonObject()) + } + ) + } + ) + } + + private fun List.toLegacyAndroidHighlightArray(): JsonArray { + return JsonArray( + filter { it.kind == PdfAnnotationKind.HIGHLIGHT } + .map { annotation -> + JsonObject( + buildMap { + put("id", JsonPrimitive(annotation.id)) + put("pageIndex", JsonPrimitive(annotation.pageIndex)) + put("color", JsonPrimitive(annotation.colorArgb.toLegacyHighlightColorName())) + put("text", JsonPrimitive(annotation.text)) + val rangeStart = annotation.rangeStartIndex ?: 0 + val rangeEnd = annotation.rangeEndIndex?.plus(1)?.coerceAtLeast(rangeStart) ?: rangeStart + put("rangeStart", JsonPrimitive(rangeStart)) + put("rangeEnd", JsonPrimitive(rangeEnd)) + annotation.note?.takeIf { it.isNotBlank() }?.let { put("note", JsonPrimitive(it)) } + put("bounds", JsonArray(emptyList())) + } + ) + } + ) + } + + private fun parseObjectOrNull(raw: String): JsonObject? { + return runCatching { json.parseToJsonElement(raw).jsonObject }.getOrNull() + } + + private fun stableAnnotationId(prefix: String, element: JsonElement): String { + return "${prefix}_${localFolderSyncSha256ShortHex(json.encodeToString(JsonElement.serializer(), element))}" + } + + private fun JsonElement.looksLikeSharedAnnotationStore(): Boolean { + val obj = jsonObjectOrNull() + if (obj?.array("annotations") != null) return true + val array = jsonArrayOrNull() ?: return false + val first = array.firstOrNull()?.jsonObjectOrNull() ?: return false + return first["kind"] != null && first["colorArgb"] != null + } + + private fun JsonElement?.isLegacyAndroidInkArray(): Boolean { + val array = this?.jsonArrayOrNull() ?: return false + if (array.isEmpty()) return true + return array.all { element -> + val obj = element.jsonObjectOrNull() ?: return@all false + obj["kind"] == null && + obj["points"] != null && + (obj["annotationType"] != null || obj["inkType"] != null || obj["type"] != null) + } + } + + private fun JsonElement?.isJsonArray(): Boolean = this?.jsonArrayOrNull() != null + + private fun JsonElement.jsonArrayOrNull(): JsonArray? { + if (this is JsonNull) return null + return runCatching { jsonArray }.getOrNull() + } + + private fun JsonElement.jsonObjectOrNull(): JsonObject? { + if (this is JsonNull) return null + return runCatching { jsonObject }.getOrNull() + } + + private fun JsonObject.array(name: String): JsonArray? = this[name]?.jsonArrayOrNull() + + private fun JsonObject.objectValue(name: String): JsonObject? = this[name]?.jsonObjectOrNull() + + private fun JsonObject.string(name: String): String? { + return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull } + .getOrNull() + ?.takeIf { it.isNotBlank() } + } + + private fun JsonObject.int(name: String): Int? { + return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull }.getOrNull() + } + + private fun JsonObject.long(name: String): Long? { + return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.longOrNull }.getOrNull() + } + + private fun JsonObject.float(name: String): Float? { + return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.doubleOrNull?.toFloat() }.getOrNull() + } + + private fun JsonObject.boolean(name: String): Boolean? { + return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.booleanOrNull }.getOrNull() + } + + private fun JsonObject.toPdfPageBoundsOrNull(): PdfPageBounds? { + val left = float("left") ?: return null + val top = float("top") ?: return null + val right = float("right") ?: return null + val bottom = float("bottom") ?: return null + return PdfPageBounds( + left = minOf(left, right), + top = minOf(top, bottom), + right = maxOf(left, right), + bottom = maxOf(top, bottom) + ) + } + + private fun PdfPageBounds.toJsonObject(): JsonObject { + return JsonObject( + mapOf( + "left" to JsonPrimitive(left.toDouble()), + "top" to JsonPrimitive(top.toDouble()), + "right" to JsonPrimitive(right.toDouble()), + "bottom" to JsonPrimitive(bottom.toDouble()) + ) + ) + } + + private fun PdfPageBounds.isNormalizedPageBounds(): Boolean { + return left in 0f..1f && + top in 0f..1f && + right in 0f..1f && + bottom in 0f..1f && + right >= left && + bottom >= top + } + + private fun String.toPdfInkTool(): PdfInkTool { + return runCatching { PdfInkTool.valueOf(this) }.getOrDefault(PdfInkTool.PEN) + } + + private fun Float.legacyTextBoxFontSizeToShared(): Float { + return if (this in 0f..1f) { + (this * LEGACY_TEXT_BOX_FONT_REFERENCE_DP).coerceIn(8f, 48f) + } else { + coerceIn(8f, 96f) + } + } + + private fun Float.sharedFontSizeToLegacyTextBox(): Float { + return (this / LEGACY_TEXT_BOX_FONT_REFERENCE_DP).coerceIn(0.012f, 0.12f) + } + + private fun String.toSharedHighlightArgb(): Int { + val opaqueArgb = legacyHighlightColors[uppercase()] ?: legacyHighlightColors.getValue("YELLOW") + return 0x8C000000.toInt() or (opaqueArgb and 0x00FFFFFF) + } + + private fun Int.toLegacyHighlightColorName(): String { + val rgb = this and 0x00FFFFFF + return legacyHighlightColors.minByOrNull { (_, color) -> + val candidate = color and 0x00FFFFFF + val dr = ((rgb shr 16) and 0xFF) - ((candidate shr 16) and 0xFF) + val dg = ((rgb shr 8) and 0xFF) - ((candidate shr 8) and 0xFF) + val db = (rgb and 0xFF) - (candidate and 0xFF) + dr.toDouble().pow(2) + dg.toDouble().pow(2) + db.toDouble().pow(2) + }?.key ?: "YELLOW" + } + + private val legacyHighlightColors = mapOf( + "YELLOW" to 0xFFFBC02D.toInt(), + "GREEN" to 0xFF388E3C.toInt(), + "BLUE" to 0xFF1976D2.toInt(), + "RED" to 0xFFD32F2F.toInt() + ) +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfInkRendering.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfInkRendering.kt new file mode 100644 index 0000000..0f4a0bf --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfInkRendering.kt @@ -0,0 +1,412 @@ +package com.aryan.reader.shared.pdf + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.unit.IntSize +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt + +sealed interface SharedPdfInkRenderData { + data class Standard( + val path: Path, + val color: Color, + val strokeWidthPx: Float, + val cap: StrokeCap, + val blendMode: BlendMode + ) : SharedPdfInkRenderData + + data class Fountain( + val path: Path, + val color: Color + ) : SharedPdfInkRenderData + + data class Pencil( + val path: Path, + val color: Color, + val strokeWidthPx: Float, + val velocityAlpha: Float + ) : SharedPdfInkRenderData +} + +object SharedPdfInkRenderer { + fun createRenderData( + annotation: SharedPdfAnnotation, + canvasSize: IntSize + ): SharedPdfInkRenderData? { + if (annotation.kind != PdfAnnotationKind.INK || annotation.points.isEmpty()) return null + val widthPx = canvasSize.width.coerceAtLeast(1).toFloat() + val heightPx = canvasSize.height.coerceAtLeast(1).toFloat() + val strokeWidthPx = effectiveStrokeWidthPx(annotation.strokeWidth, widthPx) + val color = Color(annotation.colorArgb) + + if (annotation.points.size == 1) { + val point = annotation.points.first() + val x = point.x * widthPx + val y = point.y * heightPx + return when (annotation.tool) { + PdfInkTool.FOUNTAIN_PEN -> { + val path = Path().apply { + addOval(Rect(center = Offset(x, y), radius = strokeWidthPx / 2f)) + } + SharedPdfInkRenderData.Fountain(path = path, color = color) + } + PdfInkTool.PENCIL -> { + val path = Path().apply { + moveTo(x, y) + lineTo(x, y) + } + SharedPdfInkRenderData.Pencil( + path = path, + color = color, + strokeWidthPx = strokeWidthPx, + velocityAlpha = 1f + ) + } + else -> { + val path = Path().apply { + moveTo(x, y) + lineTo(x, y) + } + SharedPdfInkRenderData.Standard( + path = path, + color = color, + strokeWidthPx = strokeWidthPx, + cap = annotation.tool.strokeCap, + blendMode = annotation.tool.blendMode + ) + } + } + } + + return when (annotation.tool) { + PdfInkTool.PENCIL -> { + val path = annotation.points.toSmoothPath(widthPx, heightPx) + val velocityAlpha = annotation.points.velocityAlpha(widthPx, heightPx) + SharedPdfInkRenderData.Pencil( + path = path, + color = color, + strokeWidthPx = strokeWidthPx, + velocityAlpha = velocityAlpha + ) + } + PdfInkTool.FOUNTAIN_PEN -> { + val (leftSide, rightSide) = calculateFountainPenEdges( + points = annotation.points, + baseWidthPx = strokeWidthPx, + pageWidthPx = widthPx, + pageHeightPx = heightPx + ) + val path = Path() + if (leftSide.isNotEmpty()) { + path.moveTo(leftSide.first().x, leftSide.first().y) + leftSide.drop(1).forEach { path.lineTo(it.x, it.y) } + rightSide.asReversed().forEach { path.lineTo(it.x, it.y) } + path.close() + } + SharedPdfInkRenderData.Fountain(path = path, color = color) + } + PdfInkTool.PEN, + PdfInkTool.HIGHLIGHTER, + PdfInkTool.HIGHLIGHTER_ROUND, + PdfInkTool.ERASER, + PdfInkTool.TEXT -> { + SharedPdfInkRenderData.Standard( + path = annotation.points.toSmoothPath(widthPx, heightPx), + color = color, + strokeWidthPx = strokeWidthPx, + cap = annotation.tool.strokeCap, + blendMode = annotation.tool.blendMode + ) + } + } + } + + fun effectiveStrokeWidthPx(strokeWidth: Float, canvasSize: IntSize): Float { + return effectiveStrokeWidthPx(strokeWidth, canvasSize.width.coerceAtLeast(1).toFloat()) + } + + fun effectiveStrokeWidthPx(strokeWidth: Float, pageWidthPx: Float): Float { + val safeWidth = pageWidthPx.coerceAtLeast(1f) + return if (strokeWidth <= 1f) { + (strokeWidth * safeWidth).coerceAtLeast(0.1f) + } else { + strokeWidth.coerceAtLeast(0.1f) + } + } + + fun effectiveStrokeWidthNorm(strokeWidth: Float, pageWidthPx: Float): Float { + val safeWidth = pageWidthPx.coerceAtLeast(1f) + return if (strokeWidth <= 1f) strokeWidth.coerceAtLeast(0.0001f) else strokeWidth / safeWidth + } + + fun calculateSnappedPoint( + currentPoint: PdfPagePoint, + startPoint: PdfPagePoint?, + pageAspectRatio: Float, + thresholdDegrees: Double = 10.0 + ): PdfPagePoint { + if (startPoint == null) return currentPoint + val safeAspectRatio = pageAspectRatio.takeIf { it > 0f } ?: 1f + val dx = (currentPoint.x - startPoint.x) * safeAspectRatio + val dy = currentPoint.y - startPoint.y + val angleDeg = atan2(dy, dx) * 180 / PI + val absAngle = abs(angleDeg) + val isHorizontal = absAngle < thresholdDegrees || abs(absAngle - 180.0) < thresholdDegrees + val isVertical = abs(absAngle - 90.0) < thresholdDegrees + return when { + isHorizontal -> currentPoint.copy(y = startPoint.y) + isVertical -> currentPoint.copy(x = startPoint.x) + else -> currentPoint + } + } + + fun isAnnotationHit( + annotation: SharedPdfAnnotation, + hitPoint: PdfPagePoint, + pageWidthPx: Float, + pageAspectRatio: Float, + eraserStrokeWidth: Float = SharedPdfAnnotationDefaults.configFor(PdfInkTool.ERASER).strokeWidth, + lastHitPoint: PdfPagePoint? = null + ): Boolean { + return when (annotation.kind) { + PdfAnnotationKind.HIGHLIGHT, + PdfAnnotationKind.TEXT -> annotation.allBounds().any { it.contains(hitPoint.x, hitPoint.y) } + PdfAnnotationKind.INK -> isInkAnnotationHit( + annotation = annotation, + hitPoint = hitPoint, + pageWidthPx = pageWidthPx, + pageAspectRatio = pageAspectRatio, + eraserStrokeWidth = eraserStrokeWidth, + lastHitPoint = lastHitPoint + ) + } + } + + private fun isInkAnnotationHit( + annotation: SharedPdfAnnotation, + hitPoint: PdfPagePoint, + pageWidthPx: Float, + pageAspectRatio: Float, + eraserStrokeWidth: Float, + lastHitPoint: PdfPagePoint? + ): Boolean { + if (annotation.points.isEmpty()) return false + val safeAspectRatio = pageAspectRatio.takeIf { it > 0f } ?: 1f + val eraserWidthNorm = effectiveStrokeWidthNorm(eraserStrokeWidth, pageWidthPx) + val annotationWidthNorm = effectiveStrokeWidthNorm(annotation.strokeWidth, pageWidthPx) + val threshold = eraserWidthNorm + annotationWidthNorm / 2f + val thresholdSq = threshold * threshold + + fun distSqToEraser(px: Float, pyScaled: Float): Float { + val e1x = hitPoint.x + val e1yScaled = hitPoint.y / safeAspectRatio + if (lastHitPoint == null) { + val dx = px - e1x + val dy = pyScaled - e1yScaled + return dx * dx + dy * dy + } + + val e0x = lastHitPoint.x + val e0yScaled = lastHitPoint.y / safeAspectRatio + val ex = e1x - e0x + val ey = e1yScaled - e0yScaled + val segmentLenSq = ex * ex + ey * ey + if (segmentLenSq < 1e-8f) { + val dx = px - e1x + val dy = pyScaled - e1yScaled + return dx * dx + dy * dy + } + + val t = ((px - e0x) * ex + (pyScaled - e0yScaled) * ey) / segmentLenSq + val closestX = e0x + ex * t.coerceIn(0f, 1f) + val closestY = e0yScaled + ey * t.coerceIn(0f, 1f) + val dx = px - closestX + val dy = pyScaled - closestY + return dx * dx + dy * dy + } + + if (annotation.points.size == 1) { + val p = annotation.points.first() + return distSqToEraser(p.x, p.y / safeAspectRatio) < thresholdSq + } + + for (i in 0 until annotation.points.lastIndex) { + val a = annotation.points[i] + val b = annotation.points[i + 1] + val pax = hitPoint.x - a.x + val pay = (hitPoint.y - a.y) / safeAspectRatio + val bax = b.x - a.x + val bay = (b.y - a.y) / safeAspectRatio + val segmentLenSq = (bax * bax + bay * bay).coerceAtLeast(1e-6f) + val t = ((pax * bax + pay * bay) / segmentLenSq).coerceIn(0f, 1f) + val closestX = bax * t + val closestY = bay * t + val dx = pax - closestX + val dy = pay - closestY + if (dx * dx + dy * dy < thresholdSq) return true + + if (lastHitPoint != null) { + if (distSqToEraser(a.x, a.y / safeAspectRatio) < thresholdSq) return true + if (distSqToEraser(b.x, b.y / safeAspectRatio) < thresholdSq) return true + } + } + return false + } + + fun calculateFountainPenEdges( + points: List, + baseWidthPx: Float, + pageWidthPx: Float, + pageHeightPx: Float + ): Pair, List> { + if (points.size < 2) return emptyList() to emptyList() + + val leftSide = mutableListOf() + val rightSide = mutableListOf() + val computedWidths = FloatArray(points.size) + computedWidths[0] = baseWidthPx + val velocityFactor = 300f + + for (i in 1 until points.size) { + val p0 = points[i - 1] + val p1 = points[i] + val dx = p1.x - p0.x + val dy = p1.y - p0.y + val aspect = if (pageWidthPx > 0f && pageHeightPx > 0f) pageHeightPx / pageWidthPx else 1f + val scaledDy = dy * aspect + val distNorm = sqrt(dx * dx + scaledDy * scaledDy) + val timeDelta = (p1.timestamp - p0.timestamp).coerceAtLeast(1) + val velocityNorm = distNorm / timeDelta + val targetWidth = (baseWidthPx * (1f / (1f + velocityNorm * velocityFactor))).coerceIn( + baseWidthPx * 0.2f, + baseWidthPx * 1.4f + ) + computedWidths[i] = computedWidths[i - 1] * 0.6f + targetWidth * 0.4f + } + + for (i in 0 until points.lastIndex) { + val current = points[i] + val next = points[i + 1] + val currentX = current.x * pageWidthPx + val currentY = current.y * pageHeightPx + val nextX = next.x * pageWidthPx + val nextY = next.y * pageHeightPx + val angle = atan2(nextY - currentY, nextX - currentX) + val normalAngle = angle - (PI / 2f).toFloat() + val halfWidth = computedWidths[i] / 2f + leftSide += Offset( + x = currentX + cos(normalAngle) * halfWidth, + y = currentY + sin(normalAngle) * halfWidth + ) + rightSide += Offset( + x = currentX - cos(normalAngle) * halfWidth, + y = currentY - sin(normalAngle) * halfWidth + ) + } + + val last = points.last() + val previous = points[points.lastIndex - 1] + val lastX = last.x * pageWidthPx + val lastY = last.y * pageHeightPx + val previousX = previous.x * pageWidthPx + val previousY = previous.y * pageHeightPx + val lastAngle = atan2(lastY - previousY, lastX - previousX) + val lastNormal = lastAngle - (PI / 2f).toFloat() + val lastHalfWidth = computedWidths.last() / 2f + leftSide += Offset( + x = lastX + cos(lastNormal) * lastHalfWidth, + y = lastY + sin(lastNormal) * lastHalfWidth + ) + rightSide += Offset( + x = lastX - cos(lastNormal) * lastHalfWidth, + y = lastY - sin(lastNormal) * lastHalfWidth + ) + return leftSide to rightSide + } +} + +fun PdfInkTool.sharedPdfStrokeWidthRange(): ClosedFloatingPointRange { + return when (this) { + PdfInkTool.HIGHLIGHTER, + PdfInkTool.HIGHLIGHTER_ROUND -> 0.01f..0.06f + PdfInkTool.ERASER -> 0.002f..0.10f + PdfInkTool.TEXT -> 0.01f..0.08f + PdfInkTool.PEN, + PdfInkTool.FOUNTAIN_PEN, + PdfInkTool.PENCIL -> 0.001f..0.015f + } +} + +fun Float.sharedPdfStrokePercent(range: ClosedFloatingPointRange): Int { + val span = (range.endInclusive - range.start).coerceAtLeast(0.0001f) + return (((this - range.start) / span) * 100f).toInt().coerceIn(1, 100) +} + +private val PdfInkTool.strokeCap: StrokeCap + get() = when (this) { + PdfInkTool.HIGHLIGHTER -> StrokeCap.Butt + PdfInkTool.HIGHLIGHTER_ROUND -> StrokeCap.Round + else -> StrokeCap.Round + } + +private val PdfInkTool.blendMode: BlendMode + get() = when (this) { + PdfInkTool.HIGHLIGHTER, + PdfInkTool.HIGHLIGHTER_ROUND -> BlendMode.Multiply + else -> BlendMode.SrcOver + } + +private fun PdfPageBounds.contains(x: Float, y: Float): Boolean { + return x in left..right && y in top..bottom +} + +private fun SharedPdfAnnotation.allBounds(): List { + return boundsList.ifEmpty { listOfNotNull(bounds) } +} + +private fun List.toSmoothPath(widthPx: Float, heightPx: Float): Path { + val path = Path() + val first = first() + path.moveTo(first.x * widthPx, first.y * heightPx) + for (i in 1 until size) { + val p0 = this[i - 1] + val p1 = this[i] + val p0x = p0.x * widthPx + val p0y = p0.y * heightPx + val p1x = p1.x * widthPx + val p1y = p1.y * heightPx + val midX = (p0x + p1x) / 2f + val midY = (p0y + p1y) / 2f + if (i == 1) { + path.lineTo(midX, midY) + } else { + path.quadraticTo(p0x, p0y, midX, midY) + } + } + val last = last() + path.lineTo(last.x * widthPx, last.y * heightPx) + return path +} + +private fun List.velocityAlpha(widthPx: Float, heightPx: Float): Float { + if (size < 2) return 1f + var totalDistance = 0f + for (i in 1 until size) { + val p0 = this[i - 1] + val p1 = this[i] + val dx = (p1.x - p0.x) * widthPx + val dy = (p1.y - p0.y) * heightPx + totalDistance += sqrt(dx * dx + dy * dy) + } + val duration = (last().timestamp - first().timestamp).coerceAtLeast(1) + val velocity = totalDistance / duration + return (1f - (velocity - 0.2f) / 1.8f).coerceIn(0.4f, 1f) +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfRichText.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfRichText.kt new file mode 100644 index 0000000..5956c1a --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfRichText.kt @@ -0,0 +1,1743 @@ +package com.aryan.reader.shared.pdf + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +const val SHARED_PDF_PAGE_BREAK_CHAR: Char = '\u000C' + +private const val SHARED_PDF_ZWSP = "\u200B" +private const val SHARED_PDF_RICH_FONT_PATH_TAG = "pdf-rich-font-path" + +const val SHARED_PDF_RICH_TEXT_LOG_TAG: String = "PdfRichTextTrace" + +object SharedPdfRichTextLog { + var enabled: Boolean = true + + fun d(message: String) { + if (enabled) { + println("$SHARED_PDF_RICH_TEXT_LOG_TAG $message") + } + } +} + +data class SharedPdfRichSpan( + val start: Int, + val end: Int, + val color: Int, + val backgroundColor: Int, + val fontSizeNorm: Float, + val isBold: Boolean, + val isItalic: Boolean, + val isUnderline: Boolean, + val isStrikethrough: Boolean, + val fontPath: String? = null +) + +data class SharedPdfRichDocument( + val text: String = "", + val spans: List = emptyList() +) + +data class SharedPdfRichPageLayout( + val pageIndex: Int, + val visibleText: AnnotatedString, + val globalStartIndex: Int, + val globalEndIndex: Int, + val pageHeightPx: Float +) + +object SharedPdfRichTextSerializer { + private val json = Json { + ignoreUnknownKeys = true + prettyPrint = true + encodeDefaults = true + } + + fun encode(document: SharedPdfRichDocument): String { + return json.encodeToString( + JsonElement.serializer(), + encodeElement(document) + ) + } + + fun encodeElement(document: SharedPdfRichDocument): JsonElement { + return JsonObject( + mapOf( + "text" to JsonPrimitive(document.text), + "spans" to JsonArray( + document.spans.map { span -> + JsonObject( + buildMap { + put("s", JsonPrimitive(span.start)) + put("e", JsonPrimitive(span.end)) + put("c", JsonPrimitive(span.color)) + put("bg", JsonPrimitive(span.backgroundColor)) + put("sz", JsonPrimitive(span.fontSizeNorm.toDouble())) + put("b", JsonPrimitive(span.isBold)) + put("i", JsonPrimitive(span.isItalic)) + put("u", JsonPrimitive(span.isUnderline)) + put("st", JsonPrimitive(span.isStrikethrough)) + put("fp", span.fontPath?.let(::JsonPrimitive) ?: JsonNull) + } + ) + } + ) + ) + ) + } + + fun decode(raw: String): SharedPdfRichDocument { + if (raw.isBlank()) { + SharedPdfRichTextLog.d("serializer.decode blank -> empty document") + return SharedPdfRichDocument() + } + return runCatching { + decodeElement(json.parseToJsonElement(raw)) + }.onFailure { + SharedPdfRichTextLog.d("serializer.decode failed rawLen=${raw.length} error=${it.message}") + }.getOrDefault(SharedPdfRichDocument()) + } + + fun decodeElement(element: JsonElement): SharedPdfRichDocument { + val root = runCatching { element.jsonObject }.getOrNull() ?: return SharedPdfRichDocument() + val text = root.string("text").orEmpty() + val spans = root["spans"] + ?.jsonArrayOrNull() + ?.mapNotNull { spanElement -> + val obj = spanElement.jsonObjectOrNull() ?: return@mapNotNull null + val start = obj.int("s") ?: obj.int("start") ?: return@mapNotNull null + val end = obj.int("e") ?: obj.int("end") ?: return@mapNotNull null + if (start < 0 || end <= start || start >= text.length) return@mapNotNull null + SharedPdfRichSpan( + start = start, + end = end.coerceAtMost(text.length), + color = obj.int("c") ?: obj.int("color") ?: Color.Black.toArgb(), + backgroundColor = obj.int("bg") ?: obj.int("backgroundColor") ?: Color.Transparent.toArgb(), + fontSizeNorm = obj.float("sz") ?: obj.float("fontSizeNorm") ?: 0.015f, + isBold = obj.boolean("b") ?: obj.boolean("isBold") ?: false, + isItalic = obj.boolean("i") ?: obj.boolean("isItalic") ?: false, + isUnderline = obj.boolean("u") ?: obj.boolean("isUnderline") ?: false, + isStrikethrough = obj.boolean("st") ?: obj.boolean("isStrikethrough") ?: false, + fontPath = obj.string("fp") ?: obj.string("fontPath") + ) + } + ?.sortedBy { it.start } + .orEmpty() + SharedPdfRichTextLog.d("serializer.decodeElement textLen=${text.length} spans=${spans.size}") + return SharedPdfRichDocument(text = text, spans = spans) + } + + private fun JsonElement.jsonArrayOrNull(): JsonArray? { + if (this is JsonNull) return null + return runCatching { jsonArray }.getOrNull() + } + + private fun JsonElement.jsonObjectOrNull(): JsonObject? { + if (this is JsonNull) return null + return runCatching { jsonObject }.getOrNull() + } + + private fun JsonObject.string(name: String): String? { + return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull } + .getOrNull() + ?.takeIf { it.isNotBlank() } + } + + private fun JsonObject.int(name: String): Int? { + return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull }.getOrNull() + } + + private fun JsonObject.float(name: String): Float? { + return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.doubleOrNull?.toFloat() }.getOrNull() + } + + private fun JsonObject.boolean(name: String): Boolean? { + return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.booleanOrNull }.getOrNull() + } +} + +object SharedPdfRichTextMapper { + fun toAnnotatedString( + document: SharedPdfRichDocument, + pageHeightPx: Float, + rangeStart: Int = 0, + rangeEnd: Int = document.text.length + ): AnnotatedString { + val safeGlobalStart = rangeStart.coerceIn(0, document.text.length) + val safeGlobalEnd = rangeEnd.coerceIn(safeGlobalStart, document.text.length) + if (safeGlobalStart == safeGlobalEnd) return AnnotatedString("") + + val textSubstring = document.text.substring(safeGlobalStart, safeGlobalEnd) + return buildAnnotatedString { + append(textSubstring) + for (span in document.spans) { + if (span.start >= safeGlobalEnd) break + if (span.end <= safeGlobalStart) continue + + val intersectionStart = maxOf(span.start, safeGlobalStart) + val intersectionEnd = minOf(span.end, safeGlobalEnd) + if (intersectionStart >= intersectionEnd) continue + + val localStart = intersectionStart - safeGlobalStart + val localEnd = intersectionEnd - safeGlobalStart + val fontSizePx = if (pageHeightPx > 0) span.fontSizeNorm * pageHeightPx else 16f + addStyle( + style = SpanStyle( + color = Color(span.color), + background = Color(span.backgroundColor), + fontSize = fontSizePx.sp, + fontWeight = if (span.isBold) FontWeight.Bold else FontWeight.Normal, + fontStyle = if (span.isItalic) FontStyle.Italic else FontStyle.Normal, + textDecoration = richTextDecoration( + underline = span.isUnderline, + strikeThrough = span.isStrikethrough + ) + ), + start = localStart, + end = localEnd + ) + span.fontPath?.takeIf { it.isNotBlank() }?.let { fontPath -> + addStringAnnotation( + tag = SHARED_PDF_RICH_FONT_PATH_TAG, + annotation = fontPath, + start = localStart, + end = localEnd + ) + } + } + } + } + + fun fromAnnotatedString(text: AnnotatedString, pageHeightPx: Float): SharedPdfRichDocument { + if (text.text.isEmpty()) return SharedPdfRichDocument() + + val spans = mutableListOf() + val fontPathAnnotations = text.getStringAnnotations( + tag = SHARED_PDF_RICH_FONT_PATH_TAG, + start = 0, + end = text.length + ) + val changePoints = sortedSetOf(0, text.length) + text.spanStyles.forEach { + changePoints.add(it.start) + changePoints.add(it.end) + } + fontPathAnnotations.forEach { + changePoints.add(it.start) + changePoints.add(it.end) + } + + val sortedPoints = changePoints.toList() + for (i in 0 until sortedPoints.size - 1) { + val start = sortedPoints[i] + val end = sortedPoints[i + 1] + if (start >= end) continue + + val activeStyles = text.spanStyles.filter { it.start <= start && it.end >= end } + val activeFontPath = fontPathAnnotations + .lastOrNull { it.start <= start && it.end >= end } + ?.item + ?.takeIf { it.isNotBlank() } + if (activeStyles.isEmpty() && activeFontPath == null) continue + + var effective = SpanStyle(color = Color.Black, fontSize = 16.sp) + activeStyles.forEach { effective = effective.merge(it.item) } + val currentDecoration = effective.textDecoration ?: TextDecoration.None + val fontSizeNorm = if (effective.fontSize.isSp) { + if (pageHeightPx > 0) effective.fontSize.value / pageHeightPx else 0.015f + } else { + 0.015f + } + + val newSpan = SharedPdfRichSpan( + start = start, + end = end, + color = effective.color.takeIf { it.isSpecified }?.toArgb() ?: Color.Black.toArgb(), + backgroundColor = effective.background.takeIf { it.isSpecified }?.toArgb() ?: Color.Transparent.toArgb(), + fontSizeNorm = fontSizeNorm, + isBold = effective.fontWeight == FontWeight.Bold, + isItalic = effective.fontStyle == FontStyle.Italic, + isUnderline = currentDecoration.contains(TextDecoration.Underline), + isStrikethrough = currentDecoration.contains(TextDecoration.LineThrough), + fontPath = activeFontPath + ) + + if (spans.isNotEmpty()) { + val last = spans.last() + if (last.end == start && last.sameRichStyleAs(newSpan)) { + spans[spans.lastIndex] = last.copy(end = end) + } else { + spans += newSpan + } + } else { + spans += newSpan + } + } + return SharedPdfRichDocument(text = text.text, spans = spans) + } + + private fun SharedPdfRichSpan.sameRichStyleAs(other: SharedPdfRichSpan): Boolean { + return color == other.color && + backgroundColor == other.backgroundColor && + fontSizeNorm == other.fontSizeNorm && + isBold == other.isBold && + isItalic == other.isItalic && + isUnderline == other.isUnderline && + isStrikethrough == other.isStrikethrough && + fontPath == other.fontPath + } +} + +class SharedPdfRichTextPaginationEngine { + fun paginate( + globalText: AnnotatedString, + pageWidthPx: Float, + pageHeightPx: Float, + textMeasurer: TextMeasurer, + density: Density, + marginX: Float, + marginY: Float, + previousLayouts: List = emptyList(), + dirtyGlobalIndex: Int = 0 + ): List { + val totalLen = globalText.length + SharedPdfRichTextLog.d( + "paginate start textLen=$totalLen page=${pageWidthPx.richLogFloat()}x${pageHeightPx.richLogFloat()} " + + "margin=${marginX.richLogFloat()},${marginY.richLogFloat()} prev=${previousLayouts.size} dirty=$dirtyGlobalIndex" + ) + if (totalLen == 0) { + val emptyLayout = listOf( + SharedPdfRichPageLayout( + pageIndex = 0, + visibleText = AnnotatedString(""), + globalStartIndex = 0, + globalEndIndex = 0, + pageHeightPx = pageHeightPx + ) + ) + SharedPdfRichTextLog.d("paginate empty -> ${emptyLayout.richLayoutSummary()}") + return emptyLayout + } + if (pageWidthPx <= 0f || pageHeightPx <= 0f) { + SharedPdfRichTextLog.d("paginate aborted invalid page size") + return emptyList() + } + + val editorWidth = (pageWidthPx - (marginX * 2f)).coerceAtLeast(10f) + val editorHeight = (pageHeightPx - (marginY * 2f)).coerceAtLeast(10f) + + val newPages = mutableListOf() + var currentPageIndex = 0 + var segmentStart = 0 + val rawText = globalText.text + + while (segmentStart < totalLen) { + val breakIndex = rawText.indexOf(SHARED_PDF_PAGE_BREAK_CHAR, startIndex = segmentStart) + val hasExplicitBreak = breakIndex != -1 + val contentEnd = if (hasExplicitBreak) breakIndex else totalLen + val segmentEnd = if (hasExplicitBreak) breakIndex + 1 else totalLen + + currentPageIndex = newPages.appendMeasuredRichTextSegment( + globalText = globalText, + segmentStart = segmentStart, + contentEnd = contentEnd, + explicitBreakEnd = if (hasExplicitBreak) segmentEnd else null, + pageIndex = currentPageIndex, + pageHeightPx = pageHeightPx, + editorWidth = editorWidth, + editorHeight = editorHeight, + textMeasurer = textMeasurer, + density = density + ) + segmentStart = segmentEnd + } + + val result = newPages.withTrailingBlankRichTextPageIfNeeded( + globalText = globalText, + pageHeightPx = pageHeightPx + ) + SharedPdfRichTextLog.d("paginate done -> ${result.richLayoutSummary()}") + return result + } +} + +private fun MutableList.appendMeasuredRichTextSegment( + globalText: AnnotatedString, + segmentStart: Int, + contentEnd: Int, + explicitBreakEnd: Int?, + pageIndex: Int, + pageHeightPx: Float, + editorWidth: Float, + editorHeight: Float, + textMeasurer: TextMeasurer, + density: Density +): Int { + var nextPageIndex = pageIndex + if (segmentStart >= contentEnd) { + val breakEnd = explicitBreakEnd ?: return nextPageIndex + add( + SharedPdfRichPageLayout( + pageIndex = nextPageIndex, + visibleText = globalText.subSequence(segmentStart, breakEnd), + globalStartIndex = segmentStart, + globalEndIndex = breakEnd, + pageHeightPx = pageHeightPx + ) + ) + SharedPdfRichTextLog.d( + "paginate pageBreakOnly page=$nextPageIndex global=$segmentStart..$breakEnd" + ) + return nextPageIndex + 1 + } + + val contentLength = contentEnd - segmentStart + var relativeStart = 0 + while (relativeStart < contentLength) { + val globalStart = segmentStart + relativeStart + val remainingText = globalText.subSequence(globalStart, contentEnd) + val measureResult = textMeasurer.measure( + text = remainingText, + style = TextStyle(fontSize = 16.sp, color = Color.Black), + constraints = Constraints(maxWidth = editorWidth.toInt(), maxHeight = Constraints.Infinity), + density = density + ) + val fitsOnPage = measureResult.size.height.toFloat() <= editorHeight || measureResult.lineCount <= 1 + var overflowLineIndex: Int? = null + val relativeEnd = if (fitsOnPage) { + contentLength + } else { + val lineIndex = measureResult.richLastFittingLineIndex(editorHeight) + overflowLineIndex = lineIndex + val localEnd = measureResult.getLineEnd(lineIndex) + .coerceIn(0, remainingText.length) + .coerceAtLeast(1) + (relativeStart + localEnd) + .coerceAtLeast(relativeStart + 1) + .coerceAtMost(contentLength) + } + val isLastContentPage = relativeEnd >= contentLength + val globalEnd = if (isLastContentPage && explicitBreakEnd != null) { + explicitBreakEnd + } else { + segmentStart + relativeEnd + } + + add( + SharedPdfRichPageLayout( + pageIndex = nextPageIndex, + visibleText = globalText.subSequence(globalStart, globalEnd), + globalStartIndex = globalStart, + globalEndIndex = globalEnd, + pageHeightPx = pageHeightPx + ) + ) + if (isLastContentPage && explicitBreakEnd != null) { + SharedPdfRichTextLog.d( + "paginate pageBreak page=$nextPageIndex global=$globalStart..$globalEnd" + ) + } else if (!fitsOnPage) { + SharedPdfRichTextLog.d( + "paginate overflow page=$nextPageIndex global=$globalStart..$globalEnd line=$overflowLineIndex" + ) + } else { + SharedPdfRichTextLog.d("paginate final page=$nextPageIndex global=$globalStart..$globalEnd") + } + nextPageIndex++ + relativeStart = relativeEnd + } + + return nextPageIndex +} + +private fun TextLayoutResult.richLastFittingLineIndex(editorHeight: Float): Int { + var lastFitting = 0 + for (lineIndex in 0 until lineCount) { + if (lineIndex == 0 || getLineBottom(lineIndex) <= editorHeight) { + lastFitting = lineIndex + } else { + break + } + } + return lastFitting.coerceIn(0, (lineCount - 1).coerceAtLeast(0)) +} + +internal fun AnnotatedString.withoutTrailingSharedPdfPageBreak(): AnnotatedString { + return if (text.lastOrNull() == SHARED_PDF_PAGE_BREAK_CHAR) { + subSequence(0, length - 1) + } else { + this + } +} + +private fun AnnotatedString.withRestoredTrailingSharedPdfPageBreak(shouldRestore: Boolean): AnnotatedString { + if (!shouldRestore) return this + if (text.lastOrNull() == SHARED_PDF_PAGE_BREAK_CHAR) return this + return this + AnnotatedString(SHARED_PDF_PAGE_BREAK_CHAR.toString()) +} + +internal fun List.withTrailingBlankRichTextPageIfNeeded( + globalText: AnnotatedString, + pageHeightPx: Float +): List { + if (globalText.text.lastOrNull() != SHARED_PDF_PAGE_BREAK_CHAR) return this + val lastLayout = lastOrNull() + val trailingStart = globalText.length + if (lastLayout != null && + lastLayout.globalStartIndex == trailingStart && + lastLayout.globalEndIndex == trailingStart + ) { + SharedPdfRichTextLog.d("trailingBlank already present page=${lastLayout.pageIndex} index=$trailingStart") + return this + } + SharedPdfRichTextLog.d( + "trailingBlank added page=${(lastLayout?.pageIndex ?: -1) + 1} global=$trailingStart" + ) + return this + SharedPdfRichPageLayout( + pageIndex = (lastLayout?.pageIndex ?: -1) + 1, + visibleText = AnnotatedString(""), + globalStartIndex = trailingStart, + globalEndIndex = trailingStart, + pageHeightPx = pageHeightPx + ) +} + +@Stable +class SharedPdfRichTextController( + private val scope: CoroutineScope, + initialDocument: SharedPdfRichDocument = SharedPdfRichDocument(), + private val onDocumentChange: suspend (SharedPdfRichDocument) -> Unit = {} +) { + var globalTextFieldValue by mutableStateOf( + TextFieldValue(SharedPdfRichTextMapper.toAnnotatedString(initialDocument, 1414f)) + ) + private set + + var localTextFieldValue by mutableStateOf(TextFieldValue("")) + private set + + val editingValue: TextFieldValue + get() = if (activePageIndex != -1) localTextFieldValue else globalTextFieldValue + + var activePageIndex by mutableIntStateOf(-1) + private set + + var pageLayouts by mutableStateOf(emptyList()) + private set + + var currentStyle: SpanStyle by mutableStateOf(SpanStyle(color = Color.Black, fontSize = 16.sp)) + private set + + var currentFontPath: String? by mutableStateOf(null) + private set + + var currentFontName: String? by mutableStateOf(null) + private set + + var cursorPageIndex by mutableIntStateOf(-1) + private set + + var cursorRectInPage by mutableStateOf(null) + private set + + var isCursorVisible by mutableStateOf(false) + private set + + var showCursorOverride by mutableStateOf(true) + + val focusRequester = FocusRequester() + + private var lastPageWidth = 1000f + private var lastPageHeight = 1414f + private var lastDensity: Density? = null + private var lastTextMeasurer: TextMeasurer? = null + private val engine = SharedPdfRichTextPaginationEngine() + private var saveJob: Job? = null + private var syncJob: Job? = null + private var tapJob: Job? = null + private var isSaving = false + + fun replaceDocument(document: SharedPdfRichDocument) { + SharedPdfRichTextLog.d( + "controller.replaceDocument textLen=${document.text.length} spans=${document.spans.size} " + + "oldLayouts=${pageLayouts.size} activePage=$activePageIndex" + ) + saveJob?.cancel() + syncJob?.cancel() + tapJob?.cancel() + activePageIndex = -1 + cursorPageIndex = -1 + cursorRectInPage = null + isCursorVisible = false + localTextFieldValue = TextFieldValue("") + globalTextFieldValue = TextFieldValue( + SharedPdfRichTextMapper.toAnnotatedString(document, lastPageHeight) + ) + repaginate(dirtyStartIndex = 0) + } + + fun updateLayoutConfig(width: Float, height: Float, density: Density, measurer: TextMeasurer) { + if (lastPageWidth != width || lastPageHeight != height || lastDensity != density || lastTextMeasurer != measurer) { + SharedPdfRichTextLog.d( + "controller.layoutConfig width=${width.richLogFloat()} height=${height.richLogFloat()} " + + "density=${density.density.richLogFloat()} old=${lastPageWidth.richLogFloat()}x${lastPageHeight.richLogFloat()}" + ) + lastPageWidth = width + lastPageHeight = height + lastDensity = density + lastTextMeasurer = measurer + repaginate(dirtyStartIndex = 0) + } + } + + fun clearSelection() { + SharedPdfRichTextLog.d( + "controller.clearSelection activePage=$activePageIndex globalLen=${globalTextFieldValue.text.length} " + + "localLen=${localTextFieldValue.text.length}" + ) + isCursorVisible = false + val pageToSync = activePageIndex + if (pageToSync != -1) { + scope.launch { + performSync(pageToSync) + if (activePageIndex == pageToSync) activePageIndex = -1 + } + } + if (globalTextFieldValue.text.isNotEmpty()) { + globalTextFieldValue = globalTextFieldValue.copy( + selection = TextRange(globalTextFieldValue.text.length) + ) + } + cursorPageIndex = -1 + cursorRectInPage = null + } + + fun onValueChanged(newValue: TextFieldValue) { + if (isSaving) { + SharedPdfRichTextLog.d("controller.onValueChanged ignored because saveImmediate is running") + return + } + + if (activePageIndex != -1 && !newValue.text.startsWith(SHARED_PDF_ZWSP)) { + SharedPdfRichTextLog.d( + "controller.onValueChanged missing ZWSP activePage=$activePageIndex selection=${newValue.selection}" + ) + val handled = handleBackspaceAtStart() + if (!handled) { + localTextFieldValue = localTextFieldValue.copy(selection = TextRange(1)) + } + return + } + + val oldValue = if (activePageIndex != -1) localTextFieldValue else globalTextFieldValue + val newText = newValue.text + val oldText = oldValue.text + + if (newText == oldText) { + if (oldValue.selection != newValue.selection) { + SharedPdfRichTextLog.d( + "controller.selectionOnly activePage=$activePageIndex oldSel=${oldValue.selection} newSel=${newValue.selection}" + ) + } + if (activePageIndex != -1) { + if ( + localTextFieldValue.selection != newValue.selection || + localTextFieldValue.composition != newValue.composition + ) { + localTextFieldValue = newValue.copy(annotatedString = localTextFieldValue.annotatedString) + isCursorVisible = true + updateLocalCursor() + } + } else { + if ( + globalTextFieldValue.selection != newValue.selection || + globalTextFieldValue.composition != newValue.composition + ) { + globalTextFieldValue = newValue.copy(annotatedString = globalTextFieldValue.annotatedString) + updateGlobalCursor() + } + } + return + } + + val oldAnnotated = oldValue.annotatedString + val diff = newText.length - oldText.length + val cursor = newValue.selection.end + val changeStart = if (diff > 0) cursor - diff else cursor + val changeEndOld = if (diff > 0) changeStart else changeStart - diff + SharedPdfRichTextLog.d( + "controller.textChanged activePage=$activePageIndex oldLen=${oldText.length} newLen=${newText.length} " + + "diff=$diff cursor=$cursor change=$changeStart..$changeEndOld style=${currentStyle.richStyleSummary()} " + + "preview=\"${newText.richPreview()}\"" + ) + val mutableSpans = oldAnnotated.spanStyles.mapNotNull { + it.shiftedByTextChange( + diff = diff, + changeStart = changeStart, + changeEndOld = changeEndOld + ) + }.toMutableList() + val mutableFontAnnotations = oldAnnotated.getStringAnnotations( + tag = SHARED_PDF_RICH_FONT_PATH_TAG, + start = 0, + end = oldAnnotated.length + ).mapNotNull { + it.shiftedByTextChange( + diff = diff, + changeStart = changeStart, + changeEndOld = changeEndOld + ) + }.toMutableList() + + if (diff > 0) { + val start = (cursor - diff).coerceAtLeast(0) + mutableSpans += MutableSpan(start, cursor, currentStyle) + currentFontPath?.takeIf { it.isNotBlank() }?.let { fontPath -> + mutableFontAnnotations += MutableStringAnnotation( + start = start, + end = cursor, + tag = SHARED_PDF_RICH_FONT_PATH_TAG, + item = fontPath + ) + } + } + + val builder = AnnotatedString.Builder(newText) + mutableSpans.compactSpans().forEach { span -> + builder.addStyle(span.item, span.start, span.end) + } + mutableFontAnnotations.compactStringAnnotations().forEach { annotation -> + builder.addStringAnnotation(annotation.tag, annotation.item, annotation.start, annotation.end) + } + + val finalValue = newValue.copy(annotatedString = builder.toAnnotatedString()) + if (activePageIndex != -1) { + localTextFieldValue = finalValue + isCursorVisible = true + updateLocalCursor() + syncJob?.cancel() + SharedPdfRichTextLog.d("controller.textChanged schedule local sync page=$activePageIndex") + syncJob = scope.launch { + delay(300) + performSync(activePageIndex, checkCursorMove = true) + } + } else { + globalTextFieldValue = finalValue + debouncedSave(globalTextFieldValue) + repaginate(dirtyStartIndex = 0) + SharedPdfRichTextLog.d("controller.textChanged updated global directly") + } + } + + fun updateCurrentStyle(style: SpanStyle, fontPath: String? = currentFontPath, fontName: String? = currentFontName) { + SharedPdfRichTextLog.d( + "controller.updateStyle activePage=$activePageIndex localSel=${localTextFieldValue.selection} " + + "globalSel=${globalTextFieldValue.selection} fontPath=$fontPath fontName=$fontName style=${style.richStyleSummary()}" + ) + currentStyle = style + currentFontPath = fontPath + currentFontName = fontName + isCursorVisible = true + + if (activePageIndex != -1) { + if (!localTextFieldValue.selection.collapsed) { + localTextFieldValue = localTextFieldValue.copy( + annotatedString = localTextFieldValue.annotatedString.withAppliedRichStyle( + style = style, + fontPath = fontPath, + selection = localTextFieldValue.selection + ) + ) + syncJob?.cancel() + syncJob = scope.launch { + delay(500) + syncLocalToGlobal() + } + } + } else if (!globalTextFieldValue.selection.collapsed) { + globalTextFieldValue = globalTextFieldValue.copy( + annotatedString = globalTextFieldValue.annotatedString.withAppliedRichStyle( + style = style, + fontPath = fontPath, + selection = globalTextFieldValue.selection + ) + ) + debouncedSave(globalTextFieldValue) + repaginate(dirtyStartIndex = globalTextFieldValue.selection.min) + } + requestFocus() + } + + fun requestEditingFocus() { + SharedPdfRichTextLog.d( + "controller.requestEditingFocus activePage=$activePageIndex cursorVisible=$isCursorVisible " + + "localSel=${localTextFieldValue.selection}" + ) + requestFocus() + } + + fun handleTapOnPage(pageIndex: Int, localTapOffset: Offset) { + tapJob?.cancel() + tapJob = scope.launch { + handleTapOnPageAfterSync(pageIndex, localTapOffset) + } + } + + private suspend fun handleTapOnPageAfterSync(pageIndex: Int, localTapOffset: Offset) { + SharedPdfRichTextLog.d( + "controller.tap start page=$pageIndex offset=${localTapOffset.richOffsetSummary()} activePage=$activePageIndex " + + "layouts=${pageLayouts.richLayoutSummary()} globalLen=${globalTextFieldValue.text.length}" + ) + if (activePageIndex != -1) { + val previousActivePage = activePageIndex + SharedPdfRichTextLog.d("controller.tap syncing active page=$previousActivePage before placing cursor on $pageIndex") + performSync(previousActivePage) + } + + val measurer = lastTextMeasurer ?: run { + SharedPdfRichTextLog.d("controller.tap abort no TextMeasurer") + return + } + val density = lastDensity ?: run { + SharedPdfRichTextLog.d("controller.tap abort no Density") + return + } + var layout = pageLayouts.find { it.pageIndex == pageIndex } + var bridgeAttempts = 0 + while (layout == null && bridgeAttempts < 3) { + val currentLastPage = pageLayouts.lastOrNull()?.pageIndex ?: 0 + val breaksNeeded = (pageIndex - currentLastPage).coerceAtLeast(1) + SharedPdfRichTextLog.d( + "controller.tap bridge attempt=$bridgeAttempts target=$pageIndex last=$currentLastPage breaks=$breaksNeeded" + ) + val builder = AnnotatedString.Builder(globalTextFieldValue.annotatedString) + repeat(breaksNeeded) { + builder.append(SHARED_PDF_PAGE_BREAK_CHAR.toString()) + } + globalTextFieldValue = TextFieldValue(builder.toAnnotatedString()) + repaginateSync(0) + layout = pageLayouts.find { it.pageIndex == pageIndex } + SharedPdfRichTextLog.d( + "controller.tap bridge result layoutFound=${layout != null} layouts=${pageLayouts.richLayoutSummary()} " + + "globalLen=${globalTextFieldValue.text.length}" + ) + bridgeAttempts++ + } + + val currentLayout = layout ?: run { + SharedPdfRichTextLog.d("controller.tap abort no layout for page=$pageIndex after bridge attempts") + return + } + activePageIndex = pageIndex + val editorWidth = editorWidth() + val visibleText = currentLayout.visibleText + val editableText = visibleText.withoutTrailingSharedPdfPageBreak() + val textWithZwsp = AnnotatedString(SHARED_PDF_ZWSP) + editableText + val safeLen = editableText.length + localTextFieldValue = TextFieldValue(textWithZwsp, TextRange(safeLen + 1)) + SharedPdfRichTextLog.d( + "controller.tap localPrepared page=$pageIndex global=${currentLayout.globalStartIndex}..${currentLayout.globalEndIndex} " + + "visibleLen=${visibleText.length} editableLen=${editableText.length} safeLen=$safeLen initialSel=${localTextFieldValue.selection}" + ) + + val measureResult = measurer.measure( + text = editableText, + style = TextStyle(fontSize = 16.sp, color = Color.Black), + constraints = Constraints(maxWidth = editorWidth.toInt()), + density = density + ) + val textHeight = if (editableText.isEmpty()) 0f else measureResult.size.height.toFloat() + if (editableText.isNotEmpty() && localTapOffset.y <= textHeight) { + var localIndex = measureResult.getOffsetForPosition(localTapOffset) + localIndex = localIndex.coerceIn(0, editableText.length) + localTextFieldValue = localTextFieldValue.copy(selection = TextRange(localIndex + 1)) + SharedPdfRichTextLog.d( + "controller.tap placedInText page=$pageIndex textHeight=${textHeight.richLogFloat()} " + + "localIndex=$localIndex selection=${localTextFieldValue.selection}" + ) + } else { + val gap = localTapOffset.y - textHeight + SharedPdfRichTextLog.d( + "controller.tap belowText page=$pageIndex textHeight=${textHeight.richLogFloat()} " + + "gap=${gap.richLogFloat()} offsetY=${localTapOffset.y.richLogFloat()}" + ) + injectNewlinesLocal(gap) + } + + isCursorVisible = true + updateLocalCursor() + SharedPdfRichTextLog.d( + "controller.tap done page=$pageIndex cursorVisible=$isCursorVisible cursorPage=$cursorPageIndex " + + "cursor=${cursorRectInPage.richRectSummary()} localSel=${localTextFieldValue.selection}" + ) + requestFocus() + } + + fun insertPageBreakAt(insertPageIndex: Int, count: Int = 1) { + SharedPdfRichTextLog.d("controller.insertPageBreak requested page=$insertPageIndex count=$count") + scope.launch { + forceSyncAndClear() + val original = globalTextFieldValue.annotatedString + val insertionCharIndex = if (insertPageIndex == 0) { + 0 + } else { + pageLayouts.find { it.pageIndex == insertPageIndex - 1 }?.globalEndIndex ?: original.length + } + val safeIndex = insertionCharIndex.coerceIn(0, original.length) + val builder = AnnotatedString.Builder() + builder.append(original.subSequence(0, safeIndex)) + repeat(count) { builder.append(SHARED_PDF_PAGE_BREAK_CHAR.toString()) } + builder.append(original.subSequence(safeIndex, original.length)) + globalTextFieldValue = TextFieldValue(builder.toAnnotatedString(), TextRange(safeIndex + count)) + debouncedSave(globalTextFieldValue) + repaginate(dirtyStartIndex = safeIndex) + SharedPdfRichTextLog.d( + "controller.insertPageBreak inserted index=$safeIndex newLen=${globalTextFieldValue.text.length}" + ) + } + } + + fun deleteTextOnPage(pageIndex: Int) { + SharedPdfRichTextLog.d("controller.deleteTextOnPage requested page=$pageIndex") + scope.launch { + forceSyncAndClear() + val layout = pageLayouts.find { it.pageIndex == pageIndex } ?: return@launch + val start = layout.globalStartIndex + val end = layout.globalEndIndex + if (start >= end && start >= globalTextFieldValue.text.length) return@launch + val original = globalTextFieldValue.annotatedString + val builder = AnnotatedString.Builder() + builder.append(original.subSequence(0, start)) + if (end < original.length) { + builder.append(original.subSequence(end, original.length)) + } + globalTextFieldValue = TextFieldValue(builder.toAnnotatedString(), TextRange(start)) + debouncedSave(globalTextFieldValue) + repaginate(dirtyStartIndex = start) + SharedPdfRichTextLog.d( + "controller.deleteTextOnPage deleted page=$pageIndex range=$start..$end newLen=${globalTextFieldValue.text.length}" + ) + } + } + + fun handleBackspaceAtStart(): Boolean { + SharedPdfRichTextLog.d( + "controller.backspaceAtStart request activePage=$activePageIndex localSel=${localTextFieldValue.selection}" + ) + if (localTextFieldValue.selection.start != 0 && localTextFieldValue.selection.start != 1) { + SharedPdfRichTextLog.d("controller.backspaceAtStart not at local start") + return false + } + val originalActivePage = activePageIndex + if (originalActivePage <= 0) { + SharedPdfRichTextLog.d("controller.backspaceAtStart ignored first page") + return false + } + + scope.launch { + syncJob?.cancel() + performSync(originalActivePage) + val currentLayout = pageLayouts.find { it.pageIndex == originalActivePage } ?: return@launch + val globalText = globalTextFieldValue.annotatedString + val currentGlobalStart = currentLayout.globalStartIndex + if (currentGlobalStart <= 0) return@launch + + val charBefore = globalText.text[currentGlobalStart - 1] + SharedPdfRichTextLog.d( + "controller.backspaceAtStart charBefore=${charBefore.code} globalStart=$currentGlobalStart" + ) + if (charBefore == SHARED_PDF_PAGE_BREAK_CHAR) { + handleBackspaceAcrossExplicitBreak( + originalActivePage = originalActivePage, + currentGlobalStart = currentGlobalStart, + globalText = globalText + ) + } else { + handleBackspaceAcrossOverflow( + originalActivePage = originalActivePage, + currentGlobalStart = currentGlobalStart, + globalText = globalText + ) + } + } + return true + } + + suspend fun saveImmediate() { + if (isSaving) { + SharedPdfRichTextLog.d("controller.saveImmediate ignored already saving") + return + } + SharedPdfRichTextLog.d( + "controller.saveImmediate start activePage=$activePageIndex globalLen=${globalTextFieldValue.text.length} " + + "localLen=${localTextFieldValue.text.length}" + ) + isSaving = true + try { + tapJob?.cancel() + saveJob?.cancel() + syncJob?.cancel() + val pageToSync = activePageIndex + if (pageToSync != -1) { + performSync(pageToSync) + delay(50) + activePageIndex = -1 + cursorPageIndex = -1 + cursorRectInPage = null + localTextFieldValue = TextFieldValue("") + } + val document = withContext(Dispatchers.Default) { + SharedPdfRichTextMapper.fromAnnotatedString( + text = globalTextFieldValue.annotatedString, + pageHeightPx = lastPageHeight + ) + } + SharedPdfRichTextLog.d( + "controller.saveImmediate writing textLen=${document.text.length} spans=${document.spans.size}" + ) + onDocumentChange(document) + } finally { + delay(100) + isSaving = false + SharedPdfRichTextLog.d("controller.saveImmediate done") + } + } + + private suspend fun syncLocalToGlobal() { + if (activePageIndex == -1) { + SharedPdfRichTextLog.d("controller.syncLocalToGlobal abort no active page") + return + } + val layout = pageLayouts.find { it.pageIndex == activePageIndex } ?: run { + SharedPdfRichTextLog.d("controller.syncLocalToGlobal abort missing layout page=$activePageIndex") + return + } + val globalStart = layout.globalStartIndex + val globalEnd = layout.globalEndIndex + val currentGlobal = globalTextFieldValue.annotatedString + val localEditableText = if (localTextFieldValue.annotatedString.text.isNotEmpty()) { + localTextFieldValue.annotatedString.subSequence(1, localTextFieldValue.annotatedString.length) + } else { + AnnotatedString("") + } + val shouldPreservePageBreak = layout.visibleText.text.lastOrNull() == SHARED_PDF_PAGE_BREAK_CHAR + val localText = localEditableText.withRestoredTrailingSharedPdfPageBreak(shouldPreservePageBreak) + val builder = AnnotatedString.Builder() + builder.append(currentGlobal.subSequence(0, globalStart)) + builder.append(localText) + if (globalEnd < currentGlobal.length) { + builder.append(currentGlobal.subSequence(globalEnd, currentGlobal.length)) + } + val newGlobalAnnotated = builder.toAnnotatedString() + val localSelectionStart = (localTextFieldValue.selection.start - 1).coerceAtLeast(0) + val newGlobalCursorPos = globalStart + localSelectionStart + SharedPdfRichTextLog.d( + "controller.syncLocalToGlobal page=$activePageIndex global=$globalStart..$globalEnd " + + "localEditableLen=${localEditableText.length} restoredBreak=$shouldPreservePageBreak " + + "localLen=${localText.length} newLen=${newGlobalAnnotated.length} cursor=$newGlobalCursorPos" + ) + globalTextFieldValue = TextFieldValue(newGlobalAnnotated, TextRange(newGlobalCursorPos)) + debouncedSave(globalTextFieldValue) + + val measurer = lastTextMeasurer ?: return + val density = lastDensity ?: return + val newLayouts = withContext(Dispatchers.Default) { + engine.paginate( + globalText = newGlobalAnnotated, + pageWidthPx = lastPageWidth, + pageHeightPx = lastPageHeight, + textMeasurer = measurer, + density = density, + marginX = marginX(), + marginY = marginY(), + previousLayouts = pageLayouts, + dirtyGlobalIndex = globalStart + ) + } + pageLayouts = newLayouts + SharedPdfRichTextLog.d("controller.syncLocalToGlobal layouts=${newLayouts.richLayoutSummary()}") + val newActiveLayout = newLayouts.find { + newGlobalCursorPos >= it.globalStartIndex && newGlobalCursorPos <= it.globalEndIndex + } + if (newActiveLayout != null) { + activePageIndex = newActiveLayout.pageIndex + val reExtractedText = newGlobalAnnotated.subSequence( + newActiveLayout.globalStartIndex, + newActiveLayout.globalEndIndex + ).withoutTrailingSharedPdfPageBreak() + val textWithZwsp = AnnotatedString(SHARED_PDF_ZWSP) + reExtractedText + val newLocalCursor = (newGlobalCursorPos - newActiveLayout.globalStartIndex + 1) + .coerceIn(0, textWithZwsp.length) + localTextFieldValue = TextFieldValue(textWithZwsp, TextRange(newLocalCursor)) + updateLocalCursor() + SharedPdfRichTextLog.d( + "controller.syncLocalToGlobal activePage=${activePageIndex} localCursor=$newLocalCursor " + + "cursor=${cursorRectInPage.richRectSummary()}" + ) + } else { + SharedPdfRichTextLog.d("controller.syncLocalToGlobal no active layout for cursor=$newGlobalCursorPos") + } + } + + private suspend fun performSync(pageIdx: Int, checkCursorMove: Boolean = false) { + if (pageIdx == -1) { + SharedPdfRichTextLog.d("controller.performSync abort page=-1") + return + } + val layout = pageLayouts.find { it.pageIndex == pageIdx } ?: run { + SharedPdfRichTextLog.d("controller.performSync abort missing layout page=$pageIdx layouts=${pageLayouts.richLayoutSummary()}") + return + } + val globalStart = layout.globalStartIndex + val globalEnd = layout.globalEndIndex + val currentGlobal = globalTextFieldValue.annotatedString + val localAnnotatedRaw = localTextFieldValue.annotatedString + val localEditableAnnotated = if (localAnnotatedRaw.text.isNotEmpty()) { + localAnnotatedRaw.subSequence(1, localAnnotatedRaw.length) + } else { + AnnotatedString("") + } + val shouldPreservePageBreak = layout.visibleText.text.lastOrNull() == SHARED_PDF_PAGE_BREAK_CHAR + val localAnnotated = localEditableAnnotated.withRestoredTrailingSharedPdfPageBreak(shouldPreservePageBreak) + val builder = AnnotatedString.Builder() + builder.append(currentGlobal.subSequence(0, globalStart)) + builder.append(localAnnotated) + if (globalEnd < currentGlobal.length) { + builder.append(currentGlobal.subSequence(globalEnd, currentGlobal.length)) + } + val newGlobalAnnotated = builder.toAnnotatedString() + val localSelectionStart = (localTextFieldValue.selection.start - 1).coerceAtLeast(0) + val newGlobalCursorPos = (globalStart + localSelectionStart).coerceIn(0, newGlobalAnnotated.length) + SharedPdfRichTextLog.d( + "controller.performSync page=$pageIdx checkCursor=$checkCursorMove global=$globalStart..$globalEnd " + + "localEditableLen=${localEditableAnnotated.length} restoredBreak=$shouldPreservePageBreak " + + "localLen=${localAnnotated.length} newLen=${newGlobalAnnotated.length} cursor=$newGlobalCursorPos" + ) + globalTextFieldValue = TextFieldValue(newGlobalAnnotated, TextRange(newGlobalCursorPos)) + debouncedSave(globalTextFieldValue) + + val measurer = lastTextMeasurer ?: return + val density = lastDensity ?: return + val newLayouts = withContext(Dispatchers.Default) { + engine.paginate( + globalText = newGlobalAnnotated, + pageWidthPx = lastPageWidth, + pageHeightPx = lastPageHeight, + textMeasurer = measurer, + density = density, + marginX = marginX(), + marginY = marginY(), + previousLayouts = pageLayouts, + dirtyGlobalIndex = globalStart + ) + } + pageLayouts = newLayouts + SharedPdfRichTextLog.d("controller.performSync layouts=${newLayouts.richLayoutSummary()}") + + if (checkCursorMove) { + val newActiveLayout = newLayouts.find { + newGlobalCursorPos >= it.globalStartIndex && newGlobalCursorPos < it.globalEndIndex + } ?: newLayouts.find { newGlobalCursorPos == it.globalEndIndex } + if (newActiveLayout != null) { + activePageIndex = newActiveLayout.pageIndex + val reExtracted = newGlobalAnnotated.subSequence( + newActiveLayout.globalStartIndex, + newActiveLayout.globalEndIndex + ).withoutTrailingSharedPdfPageBreak() + val textWithZwsp = AnnotatedString(SHARED_PDF_ZWSP) + reExtracted + val newLocalCursor = (newGlobalCursorPos - newActiveLayout.globalStartIndex + 1) + .coerceIn(0, textWithZwsp.length) + localTextFieldValue = TextFieldValue(textWithZwsp, TextRange(newLocalCursor)) + updateLocalCursor() + if (newActiveLayout.pageIndex != pageIdx) { + requestFocus() + } + SharedPdfRichTextLog.d( + "controller.performSync cursorMoved activePage=$activePageIndex localCursor=$newLocalCursor " + + "cursor=${cursorRectInPage.richRectSummary()}" + ) + } else { + SharedPdfRichTextLog.d("controller.performSync no layout for cursor=$newGlobalCursorPos") + } + } + } + + private fun injectNewlinesLocal(gapPixels: Float) { + val fontSizeSp = currentStyle.fontSize.value + val densityValue = lastDensity?.density ?: 1f + val lineHeightPx = (if (fontSizeSp.isNaN()) 16f else fontSizeSp) * densityValue * 1.3f + val linesNeeded = (gapPixels / lineHeightPx).toInt().coerceAtLeast(1) + val padding = "\n".repeat(linesNeeded) + val original = localTextFieldValue.annotatedString + val endsWithBreak = original.text.isNotEmpty() && original.text.last() == SHARED_PDF_PAGE_BREAK_CHAR + SharedPdfRichTextLog.d( + "controller.injectNewlines gap=${gapPixels.richLogFloat()} lineHeight=${lineHeightPx.richLogFloat()} " + + "lines=$linesNeeded endsWithBreak=$endsWithBreak originalLen=${original.length}" + ) + val builder = AnnotatedString.Builder() + if (endsWithBreak) { + builder.append(original.subSequence(0, original.length - 1)) + builder.pushStyle(currentStyle) + builder.append(padding) + builder.pop() + currentFontPath?.takeIf { it.isNotBlank() }?.let { + builder.addStringAnnotation( + tag = SHARED_PDF_RICH_FONT_PATH_TAG, + annotation = it, + start = original.length - 1, + end = original.length - 1 + padding.length + ) + } + builder.append(SHARED_PDF_PAGE_BREAK_CHAR.toString()) + } else { + val start = original.length + builder.append(original) + builder.pushStyle(currentStyle) + builder.append(padding) + builder.pop() + currentFontPath?.takeIf { it.isNotBlank() }?.let { + builder.addStringAnnotation( + tag = SHARED_PDF_RICH_FONT_PATH_TAG, + annotation = it, + start = start, + end = start + padding.length + ) + } + } + val next = builder.toAnnotatedString() + val newCursor = if (endsWithBreak) next.length - 1 else next.length + localTextFieldValue = TextFieldValue(next, TextRange(newCursor)) + SharedPdfRichTextLog.d( + "controller.injectNewlines done newLen=${next.length} newCursor=$newCursor preview=\"${next.text.richPreview()}\"" + ) + onValueChanged(localTextFieldValue) + } + + private fun repaginate(dirtyStartIndex: Int) { + val measurer = lastTextMeasurer ?: run { + SharedPdfRichTextLog.d("controller.repaginate abort no TextMeasurer dirty=$dirtyStartIndex") + return + } + val density = lastDensity ?: run { + SharedPdfRichTextLog.d("controller.repaginate abort no Density dirty=$dirtyStartIndex") + return + } + val currentText = globalTextFieldValue.annotatedString + val currentLayouts = pageLayouts + SharedPdfRichTextLog.d( + "controller.repaginate schedule dirty=$dirtyStartIndex textLen=${currentText.length} layouts=${currentLayouts.richLayoutSummary()}" + ) + scope.launch { + val newLayouts = withContext(Dispatchers.Default) { + engine.paginate( + globalText = currentText, + pageWidthPx = lastPageWidth, + pageHeightPx = lastPageHeight, + textMeasurer = measurer, + density = density, + marginX = marginX(), + marginY = marginY(), + previousLayouts = currentLayouts, + dirtyGlobalIndex = dirtyStartIndex + ) + } + pageLayouts = newLayouts + SharedPdfRichTextLog.d("controller.repaginate done layouts=${newLayouts.richLayoutSummary()}") + } + } + + private fun repaginateSync(dirtyStartIndex: Int) { + val measurer = lastTextMeasurer ?: run { + SharedPdfRichTextLog.d("controller.repaginateSync abort no TextMeasurer dirty=$dirtyStartIndex") + return + } + val density = lastDensity ?: run { + SharedPdfRichTextLog.d("controller.repaginateSync abort no Density dirty=$dirtyStartIndex") + return + } + SharedPdfRichTextLog.d( + "controller.repaginateSync start dirty=$dirtyStartIndex textLen=${globalTextFieldValue.text.length}" + ) + pageLayouts = engine.paginate( + globalText = globalTextFieldValue.annotatedString, + pageWidthPx = lastPageWidth, + pageHeightPx = lastPageHeight, + textMeasurer = measurer, + density = density, + marginX = marginX(), + marginY = marginY(), + previousLayouts = pageLayouts, + dirtyGlobalIndex = dirtyStartIndex + ) + SharedPdfRichTextLog.d("controller.repaginateSync done layouts=${pageLayouts.richLayoutSummary()}") + } + + private fun updateLocalCursor() { + val measurer = lastTextMeasurer ?: run { + SharedPdfRichTextLog.d("controller.updateLocalCursor abort no TextMeasurer") + return + } + val density = lastDensity ?: run { + SharedPdfRichTextLog.d("controller.updateLocalCursor abort no Density") + return + } + val selection = localTextFieldValue.selection + if (selection.collapsed) { + val measureResult = measurer.measure( + text = localTextFieldValue.annotatedString, + style = TextStyle(fontSize = 16.sp), + constraints = Constraints(maxWidth = editorWidth().toInt()), + density = density + ) + val safeOffset = selection.start.coerceIn(0, localTextFieldValue.text.length) + cursorPageIndex = activePageIndex + cursorRectInPage = measureResult.getCursorRect(safeOffset).translate(marginX(), marginY()) + SharedPdfRichTextLog.d( + "controller.updateLocalCursor page=$cursorPageIndex safeOffset=$safeOffset " + + "rect=${cursorRectInPage.richRectSummary()}" + ) + } else { + SharedPdfRichTextLog.d("controller.updateLocalCursor skipped non-collapsed selection=$selection") + } + } + + private fun updateGlobalCursor() { + val selection = globalTextFieldValue.selection + if (isCursorVisible && showCursorOverride && selection.collapsed) { + val cursorIndex = selection.start + val layout = pageLayouts.find { + cursorIndex >= it.globalStartIndex && cursorIndex <= it.globalEndIndex + } + val measurer = lastTextMeasurer + val density = lastDensity + if (layout != null && measurer != null && density != null) { + val measureResult = measurer.measure( + text = layout.visibleText, + style = TextStyle(fontSize = 16.sp), + constraints = Constraints(maxWidth = editorWidth().toInt()), + density = density + ) + val localIndex = (cursorIndex - layout.globalStartIndex).coerceIn(0, layout.visibleText.length) + cursorPageIndex = layout.pageIndex + cursorRectInPage = measureResult.getCursorRect(localIndex).translate(marginX(), marginY()) + SharedPdfRichTextLog.d( + "controller.updateGlobalCursor global=$cursorIndex page=$cursorPageIndex local=$localIndex " + + "rect=${cursorRectInPage.richRectSummary()}" + ) + } else { + SharedPdfRichTextLog.d( + "controller.updateGlobalCursor missing layout/measurer cursor=$cursorIndex layouts=${pageLayouts.richLayoutSummary()}" + ) + } + } else { + cursorPageIndex = -1 + cursorRectInPage = null + SharedPdfRichTextLog.d("controller.updateGlobalCursor cleared visible=$isCursorVisible override=$showCursorOverride selection=$selection") + } + } + + private suspend fun forceSyncAndClear() { + if (activePageIndex != -1) { + performSync(activePageIndex) + activePageIndex = -1 + cursorPageIndex = -1 + cursorRectInPage = null + localTextFieldValue = TextFieldValue("") + } + } + + private suspend fun handleBackspaceAcrossExplicitBreak( + originalActivePage: Int, + currentGlobalStart: Int, + globalText: AnnotatedString + ) { + val targetPageIndex = originalActivePage - 1 + val builder = AnnotatedString.Builder() + builder.append(globalText.subSequence(0, currentGlobalStart - 1)) + builder.append(globalText.subSequence(currentGlobalStart, globalText.length)) + var intermediateGlobal = builder.toAnnotatedString() + var newCursorPos = (currentGlobalStart - 1).coerceAtLeast(0) + + val measurer = lastTextMeasurer ?: return + val density = lastDensity ?: return + val editorHeight = (lastPageHeight - (marginY() * 2f)).coerceAtLeast(10f) + val targetLayout = pageLayouts.find { it.pageIndex == targetPageIndex } + val safeTargetStart = (targetLayout?.globalStartIndex ?: 0).coerceIn(0, newCursorPos) + val pageTextToMeasure = intermediateGlobal.subSequence(safeTargetStart, newCursorPos) + val measureResult = measurer.measure( + text = pageTextToMeasure, + style = TextStyle(fontSize = 16.sp), + constraints = Constraints(maxWidth = editorWidth().toInt()), + density = density + ) + val gap = editorHeight - measureResult.size.height.toFloat() + if (gap > 0f) { + val fontSizeSp = currentStyle.fontSize.value + val lineHeightPx = (if (fontSizeSp.isNaN() || fontSizeSp <= 0f) 16f else fontSizeSp) * density.density * 1.3f + val linesNeeded = (gap / lineHeightPx).toInt().coerceAtLeast(0) + if (linesNeeded > 0) { + val padding = "\n".repeat(linesNeeded) + val paddedBuilder = AnnotatedString.Builder() + paddedBuilder.append(intermediateGlobal.subSequence(0, newCursorPos)) + paddedBuilder.pushStyle(currentStyle) + paddedBuilder.append(padding) + paddedBuilder.pop() + currentFontPath?.takeIf { it.isNotBlank() }?.let { + paddedBuilder.addStringAnnotation( + tag = SHARED_PDF_RICH_FONT_PATH_TAG, + annotation = it, + start = newCursorPos, + end = newCursorPos + padding.length + ) + } + paddedBuilder.append(intermediateGlobal.subSequence(newCursorPos, intermediateGlobal.length)) + intermediateGlobal = paddedBuilder.toAnnotatedString() + newCursorPos += padding.length + } + } + + globalTextFieldValue = TextFieldValue(intermediateGlobal, TextRange(newCursorPos)) + debouncedSave(globalTextFieldValue) + val finalLayouts = withContext(Dispatchers.Default) { + engine.paginate(intermediateGlobal, lastPageWidth, lastPageHeight, measurer, density, marginX(), marginY()) + } + pageLayouts = finalLayouts + val finalActiveLayout = finalLayouts.find { it.pageIndex == targetPageIndex } + ?: finalLayouts.findLast { newCursorPos >= it.globalStartIndex && newCursorPos <= it.globalEndIndex } + if (finalActiveLayout != null) { + activePageIndex = finalActiveLayout.pageIndex + val reExtracted = intermediateGlobal.subSequence( + finalActiveLayout.globalStartIndex, + finalActiveLayout.globalEndIndex + ).withoutTrailingSharedPdfPageBreak() + val textWithZwsp = AnnotatedString(SHARED_PDF_ZWSP) + reExtracted + val localCursor = (newCursorPos - finalActiveLayout.globalStartIndex + 1).coerceIn(0, textWithZwsp.length) + localTextFieldValue = TextFieldValue(textWithZwsp, TextRange(localCursor)) + updateLocalCursor() + requestFocus() + } + } + + private suspend fun handleBackspaceAcrossOverflow( + originalActivePage: Int, + currentGlobalStart: Int, + globalText: AnnotatedString + ) { + val builder = AnnotatedString.Builder() + builder.append(globalText.subSequence(0, currentGlobalStart - 1)) + builder.append(globalText.subSequence(currentGlobalStart, globalText.length)) + val newGlobalText = builder.toAnnotatedString() + val newCursorPos = (currentGlobalStart - 1).coerceAtLeast(0) + globalTextFieldValue = TextFieldValue(newGlobalText, TextRange(newCursorPos)) + debouncedSave(globalTextFieldValue) + + val measurer = lastTextMeasurer ?: return + val density = lastDensity ?: return + val finalLayouts = withContext(Dispatchers.Default) { + engine.paginate(newGlobalText, lastPageWidth, lastPageHeight, measurer, density, marginX(), marginY()) + } + pageLayouts = finalLayouts + val finalActiveLayout = finalLayouts.find { + newCursorPos >= it.globalStartIndex && newCursorPos < it.globalEndIndex + } ?: finalLayouts.find { newCursorPos == it.globalEndIndex } + if (finalActiveLayout != null) { + activePageIndex = finalActiveLayout.pageIndex + val reExtracted = newGlobalText.subSequence( + finalActiveLayout.globalStartIndex, + finalActiveLayout.globalEndIndex + ).withoutTrailingSharedPdfPageBreak() + val textWithZwsp = AnnotatedString(SHARED_PDF_ZWSP) + reExtracted + val localCursor = (newCursorPos - finalActiveLayout.globalStartIndex + 1).coerceIn(0, textWithZwsp.length) + localTextFieldValue = TextFieldValue(textWithZwsp, TextRange(localCursor)) + updateLocalCursor() + requestFocus() + } else { + activePageIndex = originalActivePage + } + } + + private fun debouncedSave(tfv: TextFieldValue) { + saveJob?.cancel() + saveJob = scope.launch { + delay(1000) + val document = withContext(Dispatchers.Default) { + SharedPdfRichTextMapper.fromAnnotatedString(tfv.annotatedString, lastPageHeight) + } + onDocumentChange(document) + } + } + + private fun requestFocus() { + runCatching { focusRequester.requestFocus() } + .onSuccess { SharedPdfRichTextLog.d("controller.requestFocus success") } + .onFailure { SharedPdfRichTextLog.d("controller.requestFocus failed error=${it.message}") } + } + + private fun editorWidth(): Float = (lastPageWidth - (marginX() * 2f)).coerceAtLeast(10f) + + private fun marginX(): Float = lastPageWidth * 0.1f + + private fun marginY(): Float = lastPageHeight * 0.08f +} + +fun SharedPdfTextStyleConfig.toSharedPdfRichSpanStyle(): SpanStyle { + return SpanStyle( + color = Color(colorArgb), + background = Color(backgroundColorArgb), + fontSize = fontSize.sp, + fontWeight = if (isBold) FontWeight.Bold else FontWeight.Normal, + fontStyle = if (isItalic) FontStyle.Italic else FontStyle.Normal, + textDecoration = richTextDecoration(isUnderline, isStrikeThrough) + ) +} + +fun SharedPdfRichTextController.currentSharedPdfTextStyleConfig(): SharedPdfTextStyleConfig { + val decoration = currentStyle.textDecoration ?: TextDecoration.None + return SharedPdfTextStyleConfig( + colorArgb = currentStyle.color.takeIf { it.isSpecified }?.toArgb() ?: Color.Black.toArgb(), + backgroundColorArgb = currentStyle.background.takeIf { it.isSpecified }?.toArgb() ?: Color.Transparent.toArgb(), + fontSize = if (currentStyle.fontSize.isSp) currentStyle.fontSize.value else 16f, + isBold = currentStyle.fontWeight == FontWeight.Bold, + isItalic = currentStyle.fontStyle == FontStyle.Italic, + isUnderline = decoration.contains(TextDecoration.Underline), + isStrikeThrough = decoration.contains(TextDecoration.LineThrough), + fontPath = currentFontPath, + fontName = currentFontName + ) +} + +fun SharedPdfRichTextController.updateCurrentSharedPdfTextStyle(style: SharedPdfTextStyleConfig) { + updateCurrentStyle( + style = style.toSharedPdfRichSpanStyle(), + fontPath = style.fontPath, + fontName = style.fontName + ) +} + +private data class MutableSpan( + var start: Int, + var end: Int, + val item: SpanStyle +) + +private data class MutableStringAnnotation( + var start: Int, + var end: Int, + val tag: String, + val item: String +) + +private fun AnnotatedString.Range.shiftedByTextChange( + diff: Int, + changeStart: Int, + changeEndOld: Int +): MutableSpan? { + return shiftRange(start, end, diff, changeStart, changeEndOld) + ?.let { (nextStart, nextEnd) -> MutableSpan(nextStart, nextEnd, item) } +} + +private fun AnnotatedString.Range.shiftedByTextChange( + diff: Int, + changeStart: Int, + changeEndOld: Int +): MutableStringAnnotation? { + return shiftRange(start, end, diff, changeStart, changeEndOld) + ?.let { (nextStart, nextEnd) -> + MutableStringAnnotation( + start = nextStart, + end = nextEnd, + tag = tag, + item = item + ) + } +} + +private fun shiftRange( + start: Int, + end: Int, + diff: Int, + changeStart: Int, + changeEndOld: Int +): Pair? { + val next = if (diff > 0) { + when { + end <= changeStart -> start to end + start >= changeStart -> (start + diff) to (end + diff) + else -> start to (end + diff) + } + } else { + when { + end <= changeStart -> start to end + start >= changeEndOld -> (start + diff) to (end + diff) + else -> { + val newStart = if (start < changeStart) start else changeStart + val newEnd = (end + diff).coerceAtLeast(changeStart) + newStart to newEnd + } + } + } + return next.takeIf { it.first < it.second } +} + +private fun List.compactSpans(): List { + return groupBy { it.item } + .flatMap { (_, spans) -> + spans.sortedBy { it.start }.mergeAdjacentRanges { current, next -> + current.copy(end = maxOf(current.end, next.end)) + } + } +} + +private fun List.compactStringAnnotations(): List { + return groupBy { it.tag to it.item } + .flatMap { (_, annotations) -> + annotations.sortedBy { it.start }.mergeAdjacentRanges { current, next -> + current.copy(end = maxOf(current.end, next.end)) + } + } +} + +private fun List.mergeAdjacentRanges(merge: (T, T) -> T): List + where T : Any { + if (isEmpty()) return emptyList() + val result = mutableListOf() + var current = first() + for (i in 1 until size) { + val next = this[i] + val currentEnd = current.richRangeEnd() + val nextStart = next.richRangeStart() + if (nextStart <= currentEnd) { + current = merge(current, next) + } else { + result += current + current = next + } + } + result += current + return result +} + +private fun Any.richRangeStart(): Int { + return when (this) { + is MutableSpan -> start + is MutableStringAnnotation -> start + else -> 0 + } +} + +private fun Any.richRangeEnd(): Int { + return when (this) { + is MutableSpan -> end + is MutableStringAnnotation -> end + else -> 0 + } +} + +private fun AnnotatedString.withAppliedRichStyle( + style: SpanStyle, + fontPath: String?, + selection: TextRange +): AnnotatedString { + val start = selection.min.coerceIn(0, length) + val end = selection.max.coerceIn(start, length) + if (start == end) return this + val builder = AnnotatedString.Builder(this) + builder.addStyle(style, start, end) + fontPath?.takeIf { it.isNotBlank() }?.let { + builder.addStringAnnotation( + tag = SHARED_PDF_RICH_FONT_PATH_TAG, + annotation = it, + start = start, + end = end + ) + } + return builder.toAnnotatedString() +} + +private fun richTextDecoration( + underline: Boolean, + strikeThrough: Boolean +): TextDecoration { + val decorations = mutableListOf() + if (underline) decorations += TextDecoration.Underline + if (strikeThrough) decorations += TextDecoration.LineThrough + return if (decorations.isEmpty()) TextDecoration.None else TextDecoration.combine(decorations) +} + +private fun List.richLayoutSummary(): String { + if (isEmpty()) return "[]" + return joinToString(prefix = "[", postfix = "]", limit = 8, truncated = "...") { layout -> + "p${layout.pageIndex}:${layout.globalStartIndex}-${layout.globalEndIndex}/len${layout.visibleText.length}" + } +} + +private fun String.richPreview(maxLength: Int = 80): String { + return replace("\n", "\\n") + .replace(SHARED_PDF_PAGE_BREAK_CHAR.toString(), "\\f") + .let { if (it.length <= maxLength) it else it.take(maxLength) + "..." } +} + +private fun Float.richLogFloat(): String { + return if (isFinite()) { + val rounded = kotlin.math.round(this * 10f) / 10f + rounded.toString() + } else { + toString() + } +} + +private fun Offset.richOffsetSummary(): String { + return "(${x.richLogFloat()},${y.richLogFloat()})" +} + +private fun Rect?.richRectSummary(): String { + if (this == null) return "null" + return "(${left.richLogFloat()},${top.richLogFloat()},${right.richLogFloat()},${bottom.richLogFloat()})" +} + +private fun SpanStyle.richStyleSummary(): String { + return "color=$color bg=$background size=$fontSize weight=$fontWeight style=$fontStyle deco=$textDecoration" +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfTextAnnotations.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfTextAnnotations.kt new file mode 100644 index 0000000..ec9eefb --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfTextAnnotations.kt @@ -0,0 +1,353 @@ +package com.aryan.reader.shared.pdf + +import androidx.compose.ui.unit.IntSize +import kotlinx.serialization.Serializable +import kotlin.math.ceil + +@Serializable +data class SharedPdfTextStyleConfig( + val colorArgb: Int = 0xFF000000.toInt(), + val backgroundColorArgb: Int = 0x00000000, + val fontSize: Float = 16f, + val isBold: Boolean = false, + val isItalic: Boolean = false, + val isUnderline: Boolean = false, + val isStrikeThrough: Boolean = false, + val fontPath: String? = null, + val fontName: String? = null +) + +@Serializable +data class SharedPdfTextFontPreset( + val name: String, + val fontPath: String? = null +) + +enum class SharedPdfTextResizeHandle { + TOP_LEFT, + TOP_CENTER, + TOP_RIGHT, + RIGHT_CENTER, + BOTTOM_RIGHT, + BOTTOM_CENTER, + BOTTOM_LEFT, + LEFT_CENTER +} + +@Serializable +data class SharedPdfTextDraft( + val id: String, + val pageIndex: Int, + val bounds: PdfPageBounds, + val text: String = "", + val style: SharedPdfTextStyleConfig = SharedPdfTextStyleConfig(), + val createdAt: Long = 0L, + val isManuallySized: Boolean = false +) + +object SharedPdfTextAnnotationDefaults { + val fontSizes: List = listOf(12f, 14f, 16f, 18f, 20f, 24f, 30f) + + val fontPresets: List = listOf( + SharedPdfTextFontPreset("Default"), + SharedPdfTextFontPreset("Merriweather", "asset:fonts/merriweather.ttf"), + SharedPdfTextFontPreset("Lato", "asset:fonts/lato.ttf"), + SharedPdfTextFontPreset("Lora", "asset:fonts/lora.ttf"), + SharedPdfTextFontPreset("Roboto Mono", "asset:fonts/roboto_mono.ttf"), + SharedPdfTextFontPreset("Lexend", "asset:fonts/lexend.ttf") + ) + + val textColorPalette: List + get() = SharedPdfAnnotationDefaults.penPalette + + val backgroundColorPalette: List = listOf( + 0x00000000, + 0x8CFF9800.toInt(), + 0x8CFFEB3B.toInt(), + 0x8C81C784.toInt(), + 0x8C64B5F6.toInt(), + 0x8CE1BEE7.toInt() + ) + + fun normalizeTextDraft(text: String): String { + return text + .replace("\r\n", "\n") + .replace('\r', '\n') + .trim() + } + + fun createAnnotation( + id: String, + pageIndex: Int, + anchor: PdfPagePoint, + canvasSize: IntSize, + text: String, + style: SharedPdfTextStyleConfig, + createdAt: Long + ): SharedPdfAnnotation { + val cleanText = normalizeTextDraft(text) + return SharedPdfAnnotation( + id = id, + pageIndex = pageIndex, + kind = PdfAnnotationKind.TEXT, + tool = PdfInkTool.TEXT, + bounds = boundsForPlacedText(anchor, canvasSize, cleanText, style), + text = cleanText, + colorArgb = style.colorArgb, + backgroundArgb = style.backgroundColorArgb, + strokeWidth = SharedPdfAnnotationDefaults.configFor(PdfInkTool.TEXT).strokeWidth, + fontSize = style.fontSize, + isBold = style.isBold, + isItalic = style.isItalic, + isUnderline = style.isUnderline, + isStrikeThrough = style.isStrikeThrough, + fontPath = style.fontPath, + fontName = style.fontName, + createdAt = createdAt + ) + } + + fun createDraft( + id: String, + pageIndex: Int, + anchor: PdfPagePoint, + canvasSize: IntSize, + style: SharedPdfTextStyleConfig, + createdAt: Long + ): SharedPdfTextDraft { + return SharedPdfTextDraft( + id = id, + pageIndex = pageIndex, + bounds = boundsForPlacedText(anchor, canvasSize, " ", style), + text = "", + style = style, + createdAt = createdAt + ) + } + + fun boundsForPlacedText( + anchor: PdfPagePoint, + canvasSize: IntSize, + text: String, + style: SharedPdfTextStyleConfig + ): PdfPageBounds { + val widthPx = canvasSize.width.coerceAtLeast(1).toFloat() + val heightPx = canvasSize.height.coerceAtLeast(1).toFloat() + val widthNorm = estimateWidthNorm(text, style, widthPx).coerceIn(0.18f, 0.62f) + val lineCount = estimateLineCount(text, style.fontSize, widthPx * widthNorm) + val heightNorm = (((style.fontSize * 1.35f * lineCount) + 14f) / heightPx).coerceIn(0.04f, 0.36f) + val left = anchor.x.coerceIn(0f, 1f - widthNorm) + val top = anchor.y.coerceIn(0f, 1f - heightNorm) + return PdfPageBounds( + left = left, + top = top, + right = left + widthNorm, + bottom = top + heightNorm + ) + } + + fun estimateLineCount(text: String, fontSize: Float, widthPx: Float): Int { + if (text.isBlank()) return 1 + val averageCharWidth = (fontSize * 0.55f).coerceAtLeast(1f) + val charsPerLine = (widthPx / averageCharWidth).toInt().coerceAtLeast(8) + return text.lineSequence().sumOf { rawLine -> + val length = rawLine.length.coerceAtLeast(1) + ceil(length / charsPerLine.toFloat()).toInt().coerceAtLeast(1) + }.coerceAtLeast(1) + } + + private fun estimateWidthNorm( + text: String, + style: SharedPdfTextStyleConfig, + pageWidthPx: Float + ): Float { + val longestLine = text.lineSequence().maxOfOrNull { it.length } ?: 0 + val estimatedTextWidth = (longestLine.coerceAtLeast(12) * style.fontSize * 0.55f) + 18f + return (estimatedTextWidth / pageWidthPx).coerceAtLeast(0.28f) + } +} + +fun SharedPdfTextDraft.withText( + text: String, + canvasSize: IntSize +): SharedPdfTextDraft { + val normalizedText = text + .replace("\r\n", "\n") + .replace('\r', '\n') + if (isManuallySized) { + return copy(text = normalizedText) + } + val anchor = PdfPagePoint(bounds.left, bounds.top, createdAt) + return copy( + text = normalizedText, + bounds = SharedPdfTextAnnotationDefaults.boundsForPlacedText( + anchor = anchor, + canvasSize = canvasSize, + text = normalizedText.ifBlank { " " }, + style = style + ) + ) +} + +fun SharedPdfTextDraft.withStyle( + style: SharedPdfTextStyleConfig, + canvasSize: IntSize +): SharedPdfTextDraft { + if (isManuallySized) { + return copy(style = style) + } + val anchor = PdfPagePoint(bounds.left, bounds.top, createdAt) + return copy( + style = style, + bounds = SharedPdfTextAnnotationDefaults.boundsForPlacedText( + anchor = anchor, + canvasSize = canvasSize, + text = text.ifBlank { " " }, + style = style + ) + ) +} + +fun SharedPdfTextDraft.withBounds(bounds: PdfPageBounds): SharedPdfTextDraft { + return copy(bounds = bounds.coercedToPage(), isManuallySized = true) +} + +fun SharedPdfTextDraft.toAnnotation(): SharedPdfAnnotation { + val cleanText = SharedPdfTextAnnotationDefaults.normalizeTextDraft(text) + return SharedPdfAnnotation( + id = id, + pageIndex = pageIndex, + kind = PdfAnnotationKind.TEXT, + tool = PdfInkTool.TEXT, + bounds = bounds, + text = cleanText, + colorArgb = style.colorArgb, + backgroundArgb = style.backgroundColorArgb, + strokeWidth = SharedPdfAnnotationDefaults.configFor(PdfInkTool.TEXT).strokeWidth, + fontSize = style.fontSize, + isBold = style.isBold, + isItalic = style.isItalic, + isUnderline = style.isUnderline, + isStrikeThrough = style.isStrikeThrough, + fontPath = style.fontPath, + fontName = style.fontName, + createdAt = createdAt + ) +} + +fun PdfPageBounds.resizedBy( + handle: SharedPdfTextResizeHandle, + deltaXPx: Float, + deltaYPx: Float, + canvasSize: IntSize, + minWidthPx: Float = 50f, + minHeightPx: Float = 50f +): PdfPageBounds { + val pageWidthPx = canvasSize.width.coerceAtLeast(1).toFloat() + val pageHeightPx = canvasSize.height.coerceAtLeast(1).toFloat() + val minWidth = minWidthPx.coerceIn(1f, pageWidthPx) + val minHeight = minHeightPx.coerceIn(1f, pageHeightPx) + + var leftPx = left * pageWidthPx + var topPx = top * pageHeightPx + var rightPx = right * pageWidthPx + var bottomPx = bottom * pageHeightPx + + when (handle) { + SharedPdfTextResizeHandle.TOP_LEFT -> { + leftPx = (leftPx + deltaXPx).coerceIn(0f, (rightPx - minWidth).coerceAtLeast(0f)) + topPx = (topPx + deltaYPx).coerceIn(0f, (bottomPx - minHeight).coerceAtLeast(0f)) + } + SharedPdfTextResizeHandle.TOP_CENTER -> { + topPx = (topPx + deltaYPx).coerceIn(0f, (bottomPx - minHeight).coerceAtLeast(0f)) + } + SharedPdfTextResizeHandle.TOP_RIGHT -> { + rightPx = (rightPx + deltaXPx).coerceIn((leftPx + minWidth).coerceAtMost(pageWidthPx), pageWidthPx) + topPx = (topPx + deltaYPx).coerceIn(0f, (bottomPx - minHeight).coerceAtLeast(0f)) + } + SharedPdfTextResizeHandle.RIGHT_CENTER -> { + rightPx = (rightPx + deltaXPx).coerceIn((leftPx + minWidth).coerceAtMost(pageWidthPx), pageWidthPx) + } + SharedPdfTextResizeHandle.BOTTOM_RIGHT -> { + rightPx = (rightPx + deltaXPx).coerceIn((leftPx + minWidth).coerceAtMost(pageWidthPx), pageWidthPx) + bottomPx = (bottomPx + deltaYPx).coerceIn((topPx + minHeight).coerceAtMost(pageHeightPx), pageHeightPx) + } + SharedPdfTextResizeHandle.BOTTOM_CENTER -> { + bottomPx = (bottomPx + deltaYPx).coerceIn((topPx + minHeight).coerceAtMost(pageHeightPx), pageHeightPx) + } + SharedPdfTextResizeHandle.BOTTOM_LEFT -> { + leftPx = (leftPx + deltaXPx).coerceIn(0f, (rightPx - minWidth).coerceAtLeast(0f)) + bottomPx = (bottomPx + deltaYPx).coerceIn((topPx + minHeight).coerceAtMost(pageHeightPx), pageHeightPx) + } + SharedPdfTextResizeHandle.LEFT_CENTER -> { + leftPx = (leftPx + deltaXPx).coerceIn(0f, (rightPx - minWidth).coerceAtLeast(0f)) + } + } + + return PdfPageBounds( + left = leftPx / pageWidthPx, + top = topPx / pageHeightPx, + right = rightPx / pageWidthPx, + bottom = bottomPx / pageHeightPx + ).coercedToPage() +} + +fun PdfPageBounds.movedBy( + deltaXPx: Float, + deltaYPx: Float, + canvasSize: IntSize +): PdfPageBounds { + val pageWidthPx = canvasSize.width.coerceAtLeast(1).toFloat() + val pageHeightPx = canvasSize.height.coerceAtLeast(1).toFloat() + val widthPx = ((right - left) * pageWidthPx).coerceIn(1f, pageWidthPx) + val heightPx = ((bottom - top) * pageHeightPx).coerceIn(1f, pageHeightPx) + val nextLeftPx = ((left * pageWidthPx) + deltaXPx).coerceIn(0f, (pageWidthPx - widthPx).coerceAtLeast(0f)) + val nextTopPx = ((top * pageHeightPx) + deltaYPx).coerceIn(0f, (pageHeightPx - heightPx).coerceAtLeast(0f)) + return PdfPageBounds( + left = nextLeftPx / pageWidthPx, + top = nextTopPx / pageHeightPx, + right = (nextLeftPx + widthPx) / pageWidthPx, + bottom = (nextTopPx + heightPx) / pageHeightPx + ).coercedToPage() +} + +fun SharedPdfAnnotation.sharedPdfTextStyle(): SharedPdfTextStyleConfig { + return SharedPdfTextStyleConfig( + colorArgb = colorArgb, + backgroundColorArgb = backgroundArgb, + fontSize = fontSize, + isBold = isBold, + isItalic = isItalic, + isUnderline = isUnderline, + isStrikeThrough = isStrikeThrough, + fontPath = fontPath, + fontName = fontName + ) +} + +fun SharedPdfAnnotation.withSharedPdfTextStyle(style: SharedPdfTextStyleConfig): SharedPdfAnnotation { + return copy( + colorArgb = style.colorArgb, + backgroundArgb = style.backgroundColorArgb, + fontSize = style.fontSize, + isBold = style.isBold, + isItalic = style.isItalic, + isUnderline = style.isUnderline, + isStrikeThrough = style.isStrikeThrough, + fontPath = style.fontPath, + fontName = style.fontName + ) +} + +private fun PdfPageBounds.coercedToPage(): PdfPageBounds { + val coercedLeft = left.coerceIn(0f, 1f) + val coercedTop = top.coerceIn(0f, 1f) + val coercedRight = right.coerceIn(coercedLeft, 1f) + val coercedBottom = bottom.coerceIn(coercedTop, 1f) + return PdfPageBounds( + left = coercedLeft, + top = coercedTop, + right = coercedRight, + bottom = coercedBottom + ) +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderEngine.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderEngine.kt index b5b7457..c3f6f1c 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderEngine.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderEngine.kt @@ -1,66 +1,152 @@ package com.aryan.reader.shared.reader +import com.aryan.reader.paginatedreader.SemanticBlock +import com.aryan.reader.paginatedreader.SemanticFlexContainer +import com.aryan.reader.paginatedreader.SemanticList +import com.aryan.reader.paginatedreader.SemanticTable +import com.aryan.reader.paginatedreader.SemanticTextBlock +import com.aryan.reader.paginatedreader.SemanticWrappingBlock +import com.aryan.reader.shared.HighlightColor +import com.aryan.reader.shared.UserHighlight + +sealed interface ReaderLinkTarget { + data class External(val url: String) : ReaderLinkTarget + data class Internal(val locator: ReaderLocator) : ReaderLinkTarget + data object Ignored : ReaderLinkTarget +} + data class ReaderBookmark( val id: String, val pageIndex: Int, val chapterTitle: String, - val preview: String + val preview: String, + val locator: ReaderLocator = ReaderLocator(pageIndex = pageIndex, textQuote = preview) ) data class ReaderSearchResult( val pageIndex: Int, val chapterTitle: String, - val preview: String + val preview: String, + val matchIndex: Int = 0, + val chapterIndex: Int = 0, + val locator: ReaderLocator = ReaderLocator( + chapterIndex = chapterIndex, + pageIndex = pageIndex, + startOffset = matchIndex, + textQuote = preview + ) +) + +data class ReaderSearchOptions( + val matchCase: Boolean = false, + val wholeWords: Boolean = false ) data class ReaderSessionState( val reader: PaginatedReaderState, val bookmarks: List = emptyList(), + val highlights: List = emptyList(), + val isSearchActive: Boolean = false, + val showSearchResultsPanel: Boolean = true, val searchQuery: String = "", + val searchOptions: ReaderSearchOptions = ReaderSearchOptions(), val searchResults: List = emptyList(), - val activeSearchResultIndex: Int = -1 + val activeSearchResultIndex: Int = -1, + val navigationLocator: ReaderLocator? = null, + val navigationRequestId: Long = 0L ) { val currentBookmark: ReaderBookmark? - get() = bookmarks.firstOrNull { it.pageIndex == reader.currentPageIndex } + get() = navigationLocator + ?.let { locator -> bookmarks.firstOrNull { it.locator.sameLocation(locator) } } + ?: bookmarks.firstOrNull { it.pageIndex == reader.currentPageIndex && !it.locator.hasTextRange } val activeSearchResult: ReaderSearchResult? get() = searchResults.getOrNull(activeSearchResultIndex) + + val canGoToPreviousSearchResult: Boolean + get() = when { + activeSearchResultIndex > 0 -> true + activeSearchResultIndex >= 0 -> false + else -> searchResults.any { it.pageIndex <= reader.currentPageIndex } + } + + val canGoToNextSearchResult: Boolean + get() = when { + activeSearchResultIndex in 0 until searchResults.lastIndex -> true + activeSearchResultIndex >= 0 -> false + else -> searchResults.any { it.pageIndex >= reader.currentPageIndex } + } } class ReaderEngine( private val paginator: SimplePaginator = SimplePaginator() ) { + private data class PaginationCacheKey( + val bookId: String, + val chapterSignature: Int, + val settings: ReaderSettings + ) + + private val paginationCache = object : LinkedHashMap>(8, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry>?): Boolean { + return size > 8 + } + } + fun createSession( book: SharedEpubBook, - settings: ReaderSettings = ReaderSettings() + settings: ReaderSettings = ReaderSettings(), + initialPageIndex: Int = 0, + bookmarks: List = emptyList(), + highlights: List = emptyList() ): ReaderSessionState { + val pages = pagesFor(book, settings) + val initialIndex = initialPageIndex.coerceIn(0, pages.lastIndex.coerceAtLeast(0)) + val reader = PaginatedReaderState( + book = book, + pages = pages, + currentPageIndex = initialIndex, + settings = settings + ) return ReaderSessionState( - reader = PaginatedReaderState( - book = book, - pages = paginator.paginate(book, settings), - settings = settings - ) + reader = reader, + bookmarks = bookmarks + .mapNotNull { it.normalizedForBook(book, pages) } + .distinctBy { it.locationKey() } + .sortedWith(compareBy { it.pageIndex }.thenBy { it.locator.startOffset ?: -1 }), + highlights = highlights + .map { it.withNormalizedLocator() } + .filter { (it.locator.chapterIndex ?: it.chapterIndex) in book.chapters.indices } + .distinctBy { it.id }, + navigationLocator = reader.currentPage?.toLocator(book) ) } fun next(state: ReaderSessionState): ReaderSessionState { if (!state.reader.canGoNext) return state - return state.copy(reader = state.reader.copy(currentPageIndex = state.reader.currentPageIndex + 1)) + return goToPage(state, state.reader.currentPageIndex + 1) } fun previous(state: ReaderSessionState): ReaderSessionState { if (!state.reader.canGoPrevious) return state - return state.copy(reader = state.reader.copy(currentPageIndex = state.reader.currentPageIndex - 1)) + return goToPage(state, state.reader.currentPageIndex - 1) } fun goToPage(state: ReaderSessionState, pageIndex: Int): ReaderSessionState { val target = pageIndex.coerceIn(0, state.reader.pages.lastIndex.coerceAtLeast(0)) + val page = state.reader.pages.getOrNull(target) return state.copy( reader = state.reader.copy(currentPageIndex = target), - activeSearchResultIndex = state.searchResults.indexOfFirst { it.pageIndex == target } + activeSearchResultIndex = state.searchResults.indexOfFirst { it.pageIndex == target }, + navigationLocator = page?.toLocator(state.reader.book), + navigationRequestId = state.navigationRequestId + 1 ) } + fun goToPageNumber(state: ReaderSessionState, pageNumber: Int): ReaderSessionState { + return goToPage(state, pageNumber - 1) + } + fun goToProgress(state: ReaderSessionState, progress: Float): ReaderSessionState { if (state.reader.pages.isEmpty()) return state val target = ((state.reader.pages.lastIndex) * progress.coerceIn(0f, 1f)).toInt() @@ -72,24 +158,307 @@ class ReaderEngine( return if (pageIndex >= 0) goToPage(state, pageIndex) else state } + fun goToLocator(state: ReaderSessionState, locator: ReaderLocator): ReaderSessionState { + val pageIndex = state.reader.pages.indexOfFirst { page -> page.contains(locator) } + .takeIf { it >= 0 } + ?: locator.pageIndex + ?.takeIf { it in state.reader.pages.indices } + ?: return state + val page = state.reader.pages.getOrNull(pageIndex) ?: return state + val chapter = state.reader.book.chapters.getOrNull(page.chapterIndex) + val normalizedLocator = locator.copy(pageIndex = pageIndex).withFallbacks( + chapterIndex = page.chapterIndex, + chapterId = chapter?.id, + href = chapter?.baseHref, + pageIndex = pageIndex, + startOffset = page.startOffset, + endOffset = page.endOffset, + textQuote = locator.textQuote ?: page.text.preview(), + cfi = locator.cfi ?: page.toDesktopCfi() + ) + return state.copy( + reader = state.reader.copy(currentPageIndex = pageIndex), + activeSearchResultIndex = state.searchResults.indexOfFirst { it.pageIndex == pageIndex }, + navigationLocator = normalizedLocator, + navigationRequestId = state.navigationRequestId + 1 + ) + } + + fun resolveLink( + state: ReaderSessionState, + href: String, + sourceChapterIndex: Int? = state.reader.currentPage?.chapterIndex + ): ReaderLinkTarget { + val trimmed = href.trim() + if (trimmed.isBlank()) { + logReaderLink("resolve_ignored reason=blank") + return ReaderLinkTarget.Ignored + } + val normalizedHref = when { + trimmed.startsWith("about:blank#", ignoreCase = true) -> "#${trimmed.substringAfter('#')}" + trimmed.startsWith("www.", ignoreCase = true) -> "https://$trimmed" + else -> trimmed + } + logReaderLink("resolve_start href=\"$trimmed\" normalized=\"$normalizedHref\" sourceChapter=$sourceChapterIndex") + + val scheme = normalizedHref.schemeOrNull() + if (scheme != null) { + return when (scheme.lowercase()) { + "http", "https", "mailto", "tel" -> { + logReaderLink("resolve_external scheme=$scheme") + ReaderLinkTarget.External(normalizedHref) + } + else -> { + logReaderLink("resolve_ignored reason=unsupported_scheme scheme=$scheme") + ReaderLinkTarget.Ignored + } + } + } + + val sourceIndex = sourceChapterIndex + ?.takeIf { it in state.reader.book.chapters.indices } + ?: state.reader.currentPage?.chapterIndex + ?: 0 + val sourceChapter = state.reader.book.chapters.getOrNull(sourceIndex) + ?: run { + logReaderLink("resolve_ignored reason=missing_source sourceChapter=$sourceIndex") + return ReaderLinkTarget.Ignored + } + + val pathPart = normalizedHref.substringBefore('#').substringBefore('?') + val fragment = normalizedHref.substringAfter('#', missingDelimiterValue = "").substringBefore('?') + .takeIf { it.isNotBlank() } + ?.percentDecodedOrSelf() + + val targetChapterIndex = if (pathPart.isBlank()) { + sourceIndex + } else { + val targetPath = resolveEpubPath(sourceChapter.baseHref, pathPart.percentDecodedOrSelf()) + state.reader.book.chapters.indexOfFirst { chapter -> + val chapterPath = normalizeEpubPath(chapter.baseHref.orEmpty()) + chapterPath == targetPath || + chapter.id == pathPart || + chapterPath.substringAfterLast('/') == targetPath.substringAfterLast('/') + } + } + + if (targetChapterIndex !in state.reader.book.chapters.indices) { + logReaderLink( + "resolve_ignored reason=missing_target path=\"$pathPart\" sourceChapter=$sourceIndex " + + "base=\"${sourceChapter.baseHref.orEmpty()}\"" + ) + return ReaderLinkTarget.Ignored + } + + val targetChapter = state.reader.book.chapters[targetChapterIndex] + val targetOffset = fragment + ?.let { targetChapter.semanticBlocks.findElementOffset(it) } + ?: 0 + val targetPageIndex = state.reader.pages.indexOfFirst { page -> + page.chapterIndex == targetChapterIndex && targetOffset in page.startOffset..page.endOffset + }.takeIf { it >= 0 } + + val locator = ReaderLocator( + chapterIndex = targetChapterIndex, + chapterId = targetChapter.id, + href = targetChapter.baseHref, + pageIndex = targetPageIndex, + startOffset = targetOffset, + endOffset = targetOffset, + cfi = "desktop:$targetChapterIndex:$targetOffset:$targetOffset" + ) + logReaderLink( + "resolve_internal targetChapter=$targetChapterIndex targetPage=$targetPageIndex " + + "fragment=\"${fragment.orEmpty()}\" offset=$targetOffset" + ) + return ReaderLinkTarget.Internal(locator) + } + + fun syncVisiblePage(state: ReaderSessionState, pageIndex: Int, locator: ReaderLocator? = null): ReaderSessionState { + val target = pageIndex.coerceIn(0, state.reader.pages.lastIndex.coerceAtLeast(0)) + val normalizedLocator = locator?.normalizedForPage(state, target) + if (target == state.reader.currentPageIndex && normalizedLocator == null) return state + return state.copy( + reader = state.reader.copy(currentPageIndex = target), + activeSearchResultIndex = state.searchResults.indexOfFirst { it.pageIndex == target }, + navigationLocator = normalizedLocator ?: state.navigationLocator + ) + } + fun updateSettings(state: ReaderSessionState, settings: ReaderSettings): ReaderSessionState { - return state.copy(reader = paginator.repaginate(state.reader, settings)) + val current = state.reader.currentPage + val pages = pagesFor(state.reader.book, settings) + val newIndex = if (current == null) { + 0 + } else { + pages.indexOfFirst { + it.chapterIndex == current.chapterIndex && it.startOffset <= current.startOffset && it.endOffset >= current.startOffset + }.takeIf { it >= 0 } ?: 0 + } + val updated = state.copy( + reader = state.reader.copy( + pages = pages, + currentPageIndex = newIndex.coerceIn(0, pages.lastIndex.coerceAtLeast(0)), + settings = settings + ) + ) + return if (updated.searchQuery.isNotBlank()) search(updated, updated.searchQuery) else updated + } + + private fun pagesFor(book: SharedEpubBook, settings: ReaderSettings): List { + val key = PaginationCacheKey( + bookId = book.id, + chapterSignature = book.chapters.fold(1) { acc, chapter -> + 31 * acc + chapter.id.hashCode() + chapter.plainText.length + chapter.plainText.hashCode() + }, + settings = settings + ) + return synchronized(paginationCache) { + paginationCache.getOrPut(key) { + paginator.paginate(book, settings) + } + } + } + + fun openSearch(state: ReaderSessionState): ReaderSessionState { + return state.copy(isSearchActive = true, showSearchResultsPanel = true) + } + + fun closeSearch(state: ReaderSessionState): ReaderSessionState { + return state.copy( + isSearchActive = false, + showSearchResultsPanel = true, + searchQuery = "", + searchResults = emptyList(), + activeSearchResultIndex = -1 + ) + } + + fun toggleSearchResultsPanel(state: ReaderSessionState): ReaderSessionState { + return state.copy(showSearchResultsPanel = !state.showSearchResultsPanel) + } + + fun updateSearchOptions(state: ReaderSessionState, options: ReaderSearchOptions): ReaderSessionState { + val updated = state.copy(searchOptions = options) + return if (updated.searchQuery.isBlank()) updated else search(updated, updated.searchQuery) } fun toggleBookmark(state: ReaderSessionState): ReaderSessionState { val page = state.reader.currentPage ?: return state - val existing = state.bookmarks.firstOrNull { it.pageIndex == state.reader.currentPageIndex } + val chapter = state.reader.book.chapters.getOrNull(page.chapterIndex) + val locator = state.navigationLocator + ?.takeIf { it.belongsTo(page) } + ?.normalizedForPage(state, page.pageIndex) + ?: ReaderLocator( + chapterIndex = page.chapterIndex, + chapterId = chapter?.id, + pageIndex = page.pageIndex, + startOffset = page.startOffset, + endOffset = page.endOffset, + textQuote = page.text.preview() + ) + val preview = locator.textQuote?.takeIf { it.isNotBlank() } ?: page.text.preview() + return toggleBookmarkAtLocator( + state = state, + locator = locator, + chapterTitle = page.chapterTitle, + preview = preview + ) + } + + fun toggleBookmarkAtLocator( + state: ReaderSessionState, + locator: ReaderLocator, + chapterTitle: String? = null, + preview: String? = null + ): ReaderSessionState { + val targetPageIndex = state.reader.pages.indexOfFirst { page -> page.contains(locator) } + .takeIf { it >= 0 } + ?: locator.pageIndex + ?.takeIf { it in state.reader.pages.indices } + ?: state.reader.currentPageIndex + val page = state.reader.pages.getOrNull(targetPageIndex) ?: return state + val chapter = state.reader.book.chapters.getOrNull(page.chapterIndex) + val normalizedLocator = locator.copy(pageIndex = targetPageIndex).withFallbacks( + chapterIndex = page.chapterIndex, + chapterId = chapter?.id, + href = chapter?.baseHref, + pageIndex = targetPageIndex, + startOffset = page.startOffset, + endOffset = page.endOffset, + textQuote = preview ?: page.text.preview(), + cfi = locator.cfi ?: "desktop:${page.chapterIndex}:${locator.startOffset ?: page.startOffset}:${locator.endOffset ?: locator.startOffset ?: page.startOffset}" + ) + val existing = state.bookmarks.firstOrNull { + it.locator.sameLocation(normalizedLocator) || + (!normalizedLocator.hasTextRange && it.pageIndex == targetPageIndex) + } val updated = if (existing != null) { state.bookmarks - existing } else { state.bookmarks + ReaderBookmark( - id = "${state.reader.book.id}_${state.reader.currentPageIndex}", - pageIndex = state.reader.currentPageIndex, - chapterTitle = page.chapterTitle, - preview = page.text.preview() + id = bookmarkId(state.reader.book.id, targetPageIndex, normalizedLocator), + pageIndex = targetPageIndex, + chapterTitle = chapterTitle ?: page.chapterTitle, + preview = preview ?: page.text.preview(), + locator = normalizedLocator ) } - return state.copy(bookmarks = updated.sortedBy { it.pageIndex }) + return state.copy( + bookmarks = updated.sortedWith( + compareBy { it.pageIndex }.thenBy { it.locator.startOffset ?: -1 } + ) + ) + } + + fun upsertHighlight(state: ReaderSessionState, highlight: UserHighlight): ReaderSessionState { + if (highlight.text.isBlank()) return state + val normalized = highlight.withNormalizedLocator() + val existingIndex = state.highlights.indexOfFirst { + it.id == normalized.id || + (it.chapterIndex == normalized.chapterIndex && it.locator.sameLocation(normalized.locator)) + } + val updated = state.highlights.toMutableList() + if (existingIndex >= 0) { + updated[existingIndex] = updated[existingIndex].copy( + cfi = normalized.cfi, + text = normalized.text, + color = normalized.color, + chapterIndex = normalized.chapterIndex, + locator = normalized.locator + ) + } else { + updated += normalized + } + return state.copy( + highlights = updated + .filter { (it.locator.chapterIndex ?: it.chapterIndex) in state.reader.book.chapters.indices } + .distinctBy { it.id } + ) + } + + fun updateHighlight( + state: ReaderSessionState, + highlightId: String, + color: HighlightColor? = null, + note: String? = null + ): ReaderSessionState { + return state.copy( + highlights = state.highlights.map { highlight -> + if (highlight.id == highlightId) { + highlight.copy( + color = color ?: highlight.color, + note = if (note != null) note.takeIf { it.isNotBlank() } else highlight.note + ) + } else { + highlight + } + } + ) + } + + fun deleteHighlight(state: ReaderSessionState, highlightId: String): ReaderSessionState { + return state.copy(highlights = state.highlights.filterNot { it.id == highlightId }) } fun search(state: ReaderSessionState, query: String): ReaderSessionState { @@ -97,57 +466,305 @@ class ReaderEngine( val results = if (normalized.isBlank()) { emptyList() } else { - state.reader.pages.mapNotNull { page -> - val index = page.text.indexOf(normalized, ignoreCase = true) - if (index < 0) { - null - } else { - ReaderSearchResult( - pageIndex = page.pageIndex, - chapterTitle = page.chapterTitle, - preview = page.text.previewAround(index, normalized.length) - ) + state.reader.pages.flatMap { page -> + val matches = mutableListOf() + var startIndex = 0 + while (startIndex < page.text.length) { + val index = page.text.indexOfSearch(normalized, startIndex, state.searchOptions) + if (index < 0) break + val endIndex = (index + normalized.length).coerceAtMost(page.text.length) + matches += + ReaderSearchResult( + pageIndex = page.pageIndex, + chapterTitle = page.chapterTitle, + preview = page.text.previewAround(index, normalized.length), + matchIndex = index, + chapterIndex = page.chapterIndex, + locator = ReaderLocator( + chapterIndex = page.chapterIndex, + pageIndex = page.pageIndex, + startOffset = page.startOffset + index, + endOffset = page.startOffset + endIndex, + textQuote = page.text.substring(index, endIndex) + ) + ) + startIndex = index + normalized.length.coerceAtLeast(1) } + matches } } val activeIndex = results.indexOfFirst { it.pageIndex >= state.reader.currentPageIndex } .takeIf { it >= 0 } ?: if (results.isNotEmpty()) 0 else -1 val updated = state.copy( + isSearchActive = state.isSearchActive || normalized.isNotBlank(), + showSearchResultsPanel = state.showSearchResultsPanel || normalized.isNotBlank(), searchQuery = query, searchResults = results, activeSearchResultIndex = activeIndex ) - return updated.activeSearchResult?.let { goToPage(updated, it.pageIndex) } ?: updated + return updated.activeSearchResult?.let { goToSearchResult(updated, activeIndex) } ?: updated } fun nextSearchResult(state: ReaderSessionState): ReaderSessionState { - if (state.searchResults.isEmpty()) return state - val nextIndex = if (state.activeSearchResultIndex < state.searchResults.lastIndex) { + val targetIndex = if (state.activeSearchResultIndex >= 0) { state.activeSearchResultIndex + 1 } else { - 0 + state.searchResults.indexOfFirst { it.pageIndex >= state.reader.currentPageIndex } } - return state.copy( - reader = state.reader.copy(currentPageIndex = state.searchResults[nextIndex].pageIndex), - activeSearchResultIndex = nextIndex - ) + if (targetIndex !in state.searchResults.indices) return state + return goToSearchResult(state, targetIndex) } fun previousSearchResult(state: ReaderSessionState): ReaderSessionState { - if (state.searchResults.isEmpty()) return state - val nextIndex = if (state.activeSearchResultIndex > 0) { + val targetIndex = if (state.activeSearchResultIndex >= 0) { state.activeSearchResultIndex - 1 } else { - state.searchResults.lastIndex + state.searchResults.indexOfLast { it.pageIndex <= state.reader.currentPageIndex } } + if (targetIndex !in state.searchResults.indices) return state + return goToSearchResult(state, targetIndex) + } + + fun goToSearchResult(state: ReaderSessionState, resultIndex: Int): ReaderSessionState { + if (state.searchResults.isEmpty()) return state + val targetIndex = resultIndex.coerceIn(0, state.searchResults.lastIndex) + val result = state.searchResults[targetIndex] + val targetPage = state.reader.pages.indexOfFirst { page -> page.contains(result.locator) } + .takeIf { it >= 0 } + ?: result.pageIndex.coerceIn(0, state.reader.pages.lastIndex.coerceAtLeast(0)) + val page = state.reader.pages.getOrNull(targetPage) + val chapter = page?.let { state.reader.book.chapters.getOrNull(it.chapterIndex) } return state.copy( - reader = state.reader.copy(currentPageIndex = state.searchResults[nextIndex].pageIndex), - activeSearchResultIndex = nextIndex + reader = state.reader.copy(currentPageIndex = targetPage), + activeSearchResultIndex = targetIndex, + navigationLocator = result.locator.copy(pageIndex = targetPage).withFallbacks( + chapterIndex = page?.chapterIndex, + chapterId = chapter?.id, + href = chapter?.baseHref, + pageIndex = targetPage + ), + navigationRequestId = state.navigationRequestId + 1 ) } } +private fun ReaderPage.contains(locator: ReaderLocator): Boolean { + val targetChapter = locator.chapterIndex + if (targetChapter != null && targetChapter != chapterIndex) return false + if (locator.hasTextRange) { + val start = locator.startOffset ?: return false + val end = locator.endOffset ?: start + return if (start == end) { + start in startOffset..endOffset + } else { + start < endOffset && end > startOffset + } + } + val targetPage = locator.pageIndex + return targetPage != null && targetPage == pageIndex +} + +private fun ReaderBookmark.normalizedForBook(book: SharedEpubBook, pages: List): ReaderBookmark? { + val targetPageIndex = pages.indexOfFirst { page -> page.contains(locator) } + .takeIf { it >= 0 } + ?: pageIndex.takeIf { it in pages.indices } + ?: return null + val page = pages.getOrNull(targetPageIndex) ?: return null + val chapter = book.chapters.getOrNull(page.chapterIndex) + val normalizedLocator = locator.copy(pageIndex = targetPageIndex).withFallbacks( + chapterIndex = page.chapterIndex, + chapterId = chapter?.id, + href = chapter?.baseHref, + pageIndex = targetPageIndex, + startOffset = page.startOffset, + endOffset = page.endOffset, + textQuote = preview.ifBlank { page.text.preview() }, + cfi = locator.cfi ?: page.toDesktopCfi() + ) + return copy( + pageIndex = targetPageIndex, + chapterTitle = chapterTitle.ifBlank { page.chapterTitle }, + preview = preview.ifBlank { normalizedLocator.textQuote ?: page.text.preview() }, + locator = normalizedLocator + ) +} + +private fun ReaderBookmark.locationKey(): String { + val locator = locator + return listOf( + locator.chapterIndex, + locator.pageIndex, + locator.startOffset, + locator.endOffset, + locator.cfi + ).joinToString(":") +} + +private fun bookmarkId(bookId: String, pageIndex: Int, locator: ReaderLocator): String { + val chapter = locator.chapterIndex ?: -1 + val start = locator.startOffset ?: -1 + val end = locator.endOffset ?: start + return "${bookId}_${pageIndex}_${chapter}_${start}_${end}" +} + +private fun ReaderLocator.belongsTo(page: ReaderPage): Boolean { + val targetChapter = chapterIndex + if (targetChapter != null && targetChapter != page.chapterIndex) return false + if (pageIndex == page.pageIndex) return true + val start = startOffset + val end = endOffset ?: start + if (start != null && end != null) { + return if (start == end) { + start in page.startOffset..page.endOffset + } else { + start < page.endOffset && end > page.startOffset + } + } + return pageIndex == page.pageIndex +} + +private fun ReaderLocator.normalizedForPage(state: ReaderSessionState, pageIndex: Int): ReaderLocator? { + val page = state.reader.pages.getOrNull(pageIndex) ?: return null + val chapter = state.reader.book.chapters.getOrNull(page.chapterIndex) + val start = startOffset ?: page.startOffset + val end = (endOffset ?: start).coerceAtLeast(start) + return copy(pageIndex = page.pageIndex).withFallbacks( + chapterIndex = page.chapterIndex, + chapterId = chapter?.id, + href = chapter?.baseHref, + pageIndex = page.pageIndex, + startOffset = start, + endOffset = end, + textQuote = textQuote ?: page.text.preview(), + cfi = cfi ?: "desktop:${page.chapterIndex}:$start:$end" + ) +} + +private fun ReaderPage.toLocator(book: SharedEpubBook): ReaderLocator { + val chapter = book.chapters.getOrNull(chapterIndex) + return ReaderLocator( + chapterIndex = chapterIndex, + chapterId = chapter?.id, + href = chapter?.baseHref, + pageIndex = pageIndex, + startOffset = startOffset, + endOffset = endOffset, + textQuote = text.preview(), + cfi = toDesktopCfi() + ) +} + +private fun ReaderPage.toDesktopCfi(): String { + return "desktop:$chapterIndex:$startOffset:$endOffset" +} + +private fun String.schemeOrNull(): String? { + val colonIndex = indexOf(':') + if (colonIndex <= 0) return null + val firstPathIndex = listOf(indexOf('/'), indexOf('?'), indexOf('#')) + .filter { it >= 0 } + .minOrNull() + if (firstPathIndex != null && firstPathIndex < colonIndex) return null + val candidate = substring(0, colonIndex) + return candidate.takeIf { it.all { char -> char.isLetterOrDigit() || char == '+' || char == '-' || char == '.' } } +} + +private fun resolveEpubPath(baseHref: String?, hrefPath: String): String { + val path = hrefPath.trimStart('/') + if (path.isBlank()) return normalizeEpubPath(baseHref.orEmpty()) + val base = baseHref.orEmpty() + val baseDirectory = if (base.substringAfterLast('/', base).contains('.')) { + base.substringBeforeLast('/', missingDelimiterValue = "") + } else { + base + } + return normalizeEpubPath(if (baseDirectory.isBlank()) path else "$baseDirectory/$path") +} + +private fun normalizeEpubPath(path: String): String { + val parts = mutableListOf() + path.replace('\\', '/') + .split('/') + .forEach { part -> + when (part) { + "", "." -> Unit + ".." -> if (parts.isNotEmpty()) parts.removeAt(parts.lastIndex) + else -> parts += part + } + } + return parts.joinToString("/") +} + +private fun String.percentDecodedOrSelf(): String { + return runCatching { + val output = StringBuilder() + val bytes = mutableListOf() + fun flushBytes() { + if (bytes.isNotEmpty()) { + output.append(bytes.toByteArray().decodeToString()) + bytes.clear() + } + } + var index = 0 + while (index < length) { + val char = this[index] + if (char == '%' && index + 2 < length) { + val value = substring(index + 1, index + 3).toIntOrNull(16) + if (value != null) { + bytes += value.toByte() + index += 3 + continue + } + } + flushBytes() + output.append(char) + index++ + } + flushBytes() + output.toString() + }.getOrDefault(this) +} + +private fun Iterable.findElementOffset(elementId: String): Int? { + for (block in this) { + block.findElementOffset(elementId)?.let { return it } + } + return null +} + +private fun SemanticBlock.findElementOffset(elementId: String): Int? { + if (this is SemanticTextBlock) { + if (this.elementId == elementId) return startCharOffsetInSource + spans.firstOrNull { it.elementId == elementId }?.let { span -> + return startCharOffsetInSource + span.start.coerceAtLeast(0) + } + } + return when (this) { + is SemanticList -> items.findElementOffset(elementId) + is SemanticTable -> rows.asSequence() + .flatMap { it.asSequence() } + .mapNotNull { it.content.findElementOffset(elementId) } + .firstOrNull() + is SemanticFlexContainer -> children.findElementOffset(elementId) + is SemanticWrappingBlock -> paragraphsToWrap.findElementOffset(elementId) + else -> null + } +} + +private fun UserHighlight.withNormalizedLocator(): UserHighlight { + val normalizedLocator = locator.copy(textQuote = text).withFallbacks( + chapterIndex = chapterIndex, + cfi = cfi, + textQuote = text + ) + return copy( + chapterIndex = normalizedLocator.chapterIndex ?: chapterIndex, + cfi = normalizedLocator.cfi ?: cfi, + locator = normalizedLocator + ) +} + private fun String.preview(): String { return trim() .replace(Regex("\\s+"), " ") @@ -161,3 +778,23 @@ private fun String.previewAround(index: Int, queryLength: Int): String { val suffix = if (end < length) "..." else "" return prefix + substring(start, end).replace(Regex("\\s+"), " ").trim() + suffix } + +private fun String.indexOfSearch(query: String, startIndex: Int, options: ReaderSearchOptions): Int { + var index = indexOf(query, startIndex, ignoreCase = !options.matchCase) + if (!options.wholeWords) return index + while (index >= 0) { + val before = getOrNull(index - 1) + val after = getOrNull(index + query.length) + if (!before.isWordChar() && !after.isWordChar()) return index + index = indexOf(query, index + query.length.coerceAtLeast(1), ignoreCase = !options.matchCase) + } + return -1 +} + +private fun Char?.isWordChar(): Boolean { + return this != null && (isLetterOrDigit() || this == '_') +} + +private fun logReaderLink(message: String) { + println("ReaderLinkResolve $message") +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderHtmlDocumentBuilder.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderHtmlDocumentBuilder.kt index 05f5faf..5d550c8 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderHtmlDocumentBuilder.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderHtmlDocumentBuilder.kt @@ -12,14 +12,45 @@ import com.aryan.reader.paginatedreader.SemanticSpacer import com.aryan.reader.paginatedreader.SemanticTable import com.aryan.reader.paginatedreader.SemanticTextBlock import com.aryan.reader.paginatedreader.SemanticWrappingBlock +import com.aryan.reader.paginatedreader.BorderStyle +import com.aryan.reader.paginatedreader.CssStyle +import com.aryan.reader.shared.HighlightColor +import com.aryan.reader.shared.ReaderHighlightPalette +import com.aryan.reader.shared.ReaderTexture +import com.aryan.reader.shared.UserHighlight +import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.isSpecified +import kotlin.math.roundToInt object ReaderHtmlDocumentBuilder { - fun verticalDocument(book: SharedEpubBook, settings: ReaderSettings, searchQuery: String = ""): String { + fun verticalDocument( + book: SharedEpubBook, + settings: ReaderSettings, + searchQuery: String = "", + searchOptions: ReaderSearchOptions = ReaderSearchOptions(), + highlights: List = emptyList(), + highlightPalette: ReaderHighlightPalette = ReaderHighlightPalette(), + navigationLocator: ReaderLocator? = null, + pages: List = emptyList(), + readerAiFeaturesEnabled: Boolean = true, + cloudTtsEnabled: Boolean = true, + textureDataUri: String? = null + ): String { val body = book.chapters.mapIndexed { index, chapter -> + val chapterText = chapter.normalizedReaderText() + val chapterHtml = chapter.toHtml(searchQuery, searchOptions) + .applyUserHighlights( + highlights = highlights.filter { it.locatedChapterIndex == index }, + contentStartOffset = 0, + contentEndOffset = chapterText.length + ) """ -
+

${chapter.title.escapeHtml()}

- ${chapter.toHtml(searchQuery)} +
+ $chapterHtml +
""".trimIndent() }.joinToString("\n") @@ -28,27 +59,60 @@ object ReaderHtmlDocumentBuilder { settings = settings, bookCss = book.css.values.joinToString("\n"), body = body, - searchQuery = searchQuery + searchQuery = searchQuery, + searchOptions = searchOptions, + highlightPalette = highlightPalette, + navigationLocator = navigationLocator, + pageAnchors = pages, + readerAiFeaturesEnabled = readerAiFeaturesEnabled, + cloudTtsEnabled = cloudTtsEnabled, + textureDataUri = textureDataUri ) } - fun pageDocument(book: SharedEpubBook, page: ReaderPage?, settings: ReaderSettings, searchQuery: String = ""): String { + fun pageDocument( + book: SharedEpubBook, + page: ReaderPage?, + settings: ReaderSettings, + searchQuery: String = "", + searchOptions: ReaderSearchOptions = ReaderSearchOptions(), + highlights: List = emptyList(), + highlightPalette: ReaderHighlightPalette = ReaderHighlightPalette(), + navigationLocator: ReaderLocator? = null, + readerAiFeaturesEnabled: Boolean = true, + cloudTtsEnabled: Boolean = true, + textureDataUri: String? = null + ): String { val chapter = page?.let { book.chapters.getOrNull(it.chapterIndex) } val body = if (page == null || chapter == null) { + logReaderHtml("page_document_empty reason=missing_page_or_chapter") "
" } else { - val blocks = chapter.semanticBlocks - .filter { block -> - val start = (block as? SemanticTextBlock)?.startCharOffsetInSource ?: return@filter false - start in page.startOffset..page.endOffset - } - .takeIf { it.isNotEmpty() } - ?.joinToString("\n") { it.toHtml(searchQuery) } - ?: page.text.textToParagraphHtml(searchQuery) + val semanticPageBlocks = chapter.semanticBlocks.blocksForPage(page) + val usedSemanticBlocks = semanticPageBlocks.isNotEmpty() + val blocks = if (usedSemanticBlocks) { + semanticPageBlocks.joinToString("") { it.toHtml(searchQuery, searchOptions) } + } else { + page.text.textToParagraphHtml(searchQuery, searchOptions, baseOffset = page.startOffset) + } + val pageHtml = blocks.applyUserHighlights( + highlights = highlights.filter { it.belongsToPage(page) }, + contentStartOffset = page.startOffset, + contentEndOffset = page.endOffset + ) + logReaderHtml( + "page_document page=${page.pageIndex + 1} chapter=${page.chapterIndex} " + + "range=${page.startOffset}..${page.endOffset} pageText=${page.text.length} " + + "semantic=$usedSemanticBlocks blocks=${semanticPageBlocks.size}/${chapter.semanticBlocks.size} " + + "htmlChars=${pageHtml.length} settingsFont=${settings.fontSize} lineSpacing=${settings.lineSpacing} " + + "summary=\"${semanticPageBlocks.blockSummary()}\" styles=\"${semanticPageBlocks.styleSummary()}\"" + ) """ -
+

${page.chapterTitle.escapeHtml()}

- $blocks +
+ $pageHtml +
""".trimIndent() } @@ -57,7 +121,14 @@ object ReaderHtmlDocumentBuilder { settings = settings, bookCss = book.css.values.joinToString("\n"), body = body, - searchQuery = searchQuery + searchQuery = searchQuery, + searchOptions = searchOptions, + highlightPalette = highlightPalette, + navigationLocator = navigationLocator, + pageAnchors = emptyList(), + readerAiFeaturesEnabled = readerAiFeaturesEnabled, + cloudTtsEnabled = cloudTtsEnabled, + textureDataUri = textureDataUri ) } @@ -66,22 +137,56 @@ object ReaderHtmlDocumentBuilder { settings: ReaderSettings, bookCss: String, body: String, - searchQuery: String + searchQuery: String, + searchOptions: ReaderSearchOptions, + highlightPalette: ReaderHighlightPalette, + navigationLocator: ReaderLocator?, + pageAnchors: List, + readerAiFeaturesEnabled: Boolean, + cloudTtsEnabled: Boolean, + textureDataUri: String? ): String { - val bg = if (settings.darkMode) "#171A17" else "#FFFCF5" - val fg = if (settings.darkMode) "#E7E3D8" else "#24231F" + val bg = settings.backgroundColorArgb?.toCssColor() ?: if (settings.darkMode) "#171A17" else "#FFFCF5" + val fg = settings.textColorArgb?.toCssColor() ?: if (settings.darkMode) "#E7E3D8" else "#24231F" val highlight = if (settings.darkMode) "#675A00" else "#FFE36E" val align = when (settings.textAlign) { SharedReaderTextAlign.START -> "left" SharedReaderTextAlign.JUSTIFY -> "justify" SharedReaderTextAlign.CENTER -> "center" } - val family = when (settings.fontFamily) { - "Serif" -> "Georgia, 'Times New Roman', serif" - "Sans" -> "Inter, Segoe UI, Arial, sans-serif" - "Mono" -> "'Roboto Mono', Consolas, monospace" - else -> "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" + val customFontUrl = settings.customFontPath?.takeIf { it.isNotBlank() }?.toCssFontUrl() + val customFontCss = customFontUrl?.let { + "@font-face { font-family: 'ReaderCustomFont'; src: url('$it'); font-display: swap; }" + }.orEmpty() + val family = if (customFontUrl != null) { + "'ReaderCustomFont', Georgia, 'Times New Roman', serif" + } else { + when (settings.fontFamily) { + "Serif" -> "Georgia, 'Times New Roman', serif" + "Sans" -> "Inter, Segoe UI, Arial, sans-serif" + "Mono" -> "'Roboto Mono', Consolas, monospace" + else -> "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" + } } + val textureOverlayCss = settings.textureId + ?.takeIf { settings.textureAlpha > 0.01f } + ?.toTextureOverlayCss(settings.textureAlpha, settings.darkMode, textureDataUri) + .orEmpty() + val highlightButtons = highlightPalette.sanitized().colors.joinToString("\n") { color -> + """""" + } + val defineButton = if (readerAiFeaturesEnabled) { + """""" + } else { + "" + } + val speakButton = if (cloudTtsEnabled) { + """""" + } else { + "" + } + val navigationAttributes = navigationLocator?.toNavigationAttributes().orEmpty() + val pageAnchorJson = pageAnchors.toPageAnchorJson() return """ @@ -91,6 +196,7 @@ object ReaderHtmlDocumentBuilder { ${title.escapeHtml()} - + $body + + """.trimIndent() } - private fun SharedEpubChapter.toHtml(searchQuery: String): String { + private fun SharedEpubChapter.toHtml(searchQuery: String, searchOptions: ReaderSearchOptions): String { htmlContent.takeIf { it.isNotBlank() }?.let { return it } semanticBlocks.takeIf { it.isNotEmpty() }?.let { blocks -> - return blocks.joinToString("\n") { it.toHtml(searchQuery) } + return blocks.joinToString("") { it.toHtml(searchQuery, searchOptions) } } - return plainText.textToParagraphHtml(searchQuery) + return normalizedReaderText().textToParagraphHtml(searchQuery, searchOptions) } - private fun SemanticBlock.toHtml(searchQuery: String): String { + private fun List.blocksForPage(page: ReaderPage): List { + return mapIndexedNotNull { index, block -> + block.clipToPage(page) + ?: block.takeIf { + val previousText = asSequence() + .take(index) + .mapNotNull { it.lastTextBlock() } + .lastOrNull() + val nextText = asSequence() + .drop(index + 1) + .mapNotNull { it.firstTextBlock() } + .firstOrNull() + val anchor = previousText?.let { it.startCharOffsetInSource + it.text.length } + ?: nextText?.startCharOffsetInSource + ?: 0 + anchor in page.startOffset..page.endOffset + } + } + } + + private fun SemanticTextBlock.intersects(startOffset: Int, endOffset: Int): Boolean { + val start = startCharOffsetInSource + val end = start + text.length + return start < endOffset && end > startOffset + } + + private fun SemanticBlock.clipToPage(page: ReaderPage): SemanticBlock? { return when (this) { - is SemanticHeader -> "${text.highlightAndEscape(searchQuery)}" - is SemanticParagraph -> "

${text.highlightAndEscape(searchQuery)}

" - is SemanticListItem -> "
  • ${text.highlightAndEscape(searchQuery)}
  • " + is SemanticTextBlock -> takeIf { intersects(page.startOffset, page.endOffset) } + is SemanticList -> { + val visibleItems = items.filter { it.intersects(page.startOffset, page.endOffset) } + takeIf { visibleItems.isNotEmpty() }?.copy(items = visibleItems) + } + is SemanticTable -> { + val visibleRows = rows.mapNotNull { row -> + val visibleCells = row.mapNotNull { cell -> + val visibleContent = cell.content.mapNotNull { it.clipToPage(page) } + cell.takeIf { visibleContent.isNotEmpty() }?.copy(content = visibleContent) + } + visibleCells.takeIf { it.isNotEmpty() } + } + takeIf { visibleRows.isNotEmpty() }?.copy(rows = visibleRows) + } + is SemanticFlexContainer -> { + val visibleChildren = children.mapNotNull { it.clipToPage(page) } + takeIf { visibleChildren.isNotEmpty() }?.copy(children = visibleChildren) + } + is SemanticWrappingBlock -> { + val visibleParagraphs = paragraphsToWrap.filter { it.intersects(page.startOffset, page.endOffset) } + takeIf { visibleParagraphs.isNotEmpty() }?.copy(paragraphsToWrap = visibleParagraphs) + } + else -> null + } + } + + private fun SemanticBlock.firstTextBlock(): SemanticTextBlock? { + return when (this) { + is SemanticTextBlock -> this + is SemanticList -> items.firstOrNull() + is SemanticTable -> rows.asSequence() + .flatMap { it.asSequence() } + .flatMap { it.content.asSequence() } + .mapNotNull { it.firstTextBlock() } + .firstOrNull() + is SemanticFlexContainer -> children.asSequence().mapNotNull { it.firstTextBlock() }.firstOrNull() + is SemanticWrappingBlock -> paragraphsToWrap.firstOrNull() + else -> null + } + } + + private fun SemanticBlock.lastTextBlock(): SemanticTextBlock? { + return when (this) { + is SemanticTextBlock -> this + is SemanticList -> items.lastOrNull() + is SemanticTable -> rows.asReversed().asSequence() + .flatMap { it.asReversed().asSequence() } + .flatMap { it.content.asReversed().asSequence() } + .mapNotNull { it.lastTextBlock() } + .firstOrNull() + is SemanticFlexContainer -> children.asReversed().asSequence().mapNotNull { it.lastTextBlock() }.firstOrNull() + is SemanticWrappingBlock -> paragraphsToWrap.lastOrNull() + else -> null + } + } + + private fun List.blockSummary(): String { + var textBlocks = 0 + var lists = 0 + var listItems = 0 + var tables = 0 + var tableCells = 0 + var flex = 0 + var images = 0 + var math = 0 + fun visit(block: SemanticBlock) { + when (block) { + is SemanticTextBlock -> textBlocks++ + is SemanticList -> { + lists++ + listItems += block.items.size + block.items.forEach(::visit) + } + is SemanticTable -> { + tables++ + tableCells += block.rows.sumOf { it.size } + block.rows.flatten().forEach { cell -> cell.content.forEach(::visit) } + } + is SemanticFlexContainer -> { + flex++ + block.children.forEach(::visit) + } + is SemanticWrappingBlock -> { + images++ + block.paragraphsToWrap.forEach(::visit) + } + is SemanticImage -> images++ + is SemanticMath -> math++ + else -> Unit + } + } + forEach(::visit) + return "text=$textBlocks lists=$lists items=$listItems tables=$tables cells=$tableCells flex=$flex images=$images math=$math" + } + + private fun List.styleSummary(): String { + val fontSizes = mutableListOf() + val listStyles = mutableListOf() + val displayValues = mutableListOf() + fun collectStyle(style: CssStyle) { + style.fontSize.toDiagnosticTextUnit()?.let { fontSizes += it } + style.spanStyle.fontSize.toDiagnosticTextUnit()?.let { fontSizes += it } + style.blockStyle.listStyleType?.takeIf { it.isNotBlank() }?.let { listStyles += "type=$it" } + style.blockStyle.listStyleImage?.takeIf { it.isNotBlank() }?.let { listStyles += "image=$it" } + style.display?.takeIf { it.isNotBlank() }?.let { displayValues += it } + style.blockStyle.display?.takeIf { it.isNotBlank() }?.let { displayValues += it } + } + fun visit(block: SemanticBlock) { + collectStyle(block.style) + when (block) { + is SemanticTextBlock -> block.spans.forEach { collectStyle(it.style) } + is SemanticList -> block.items.forEach(::visit) + is SemanticTable -> block.rows.flatten().forEach { cell -> + collectStyle(cell.style) + cell.content.forEach(::visit) + } + is SemanticFlexContainer -> block.children.forEach(::visit) + is SemanticWrappingBlock -> { + visit(block.floatedImage) + block.paragraphsToWrap.forEach(::visit) + } + else -> Unit + } + } + forEach(::visit) + return "fontSizes=${fontSizes.distinct().take(12)} listStyles=${listStyles.distinct().take(12)} display=${displayValues.distinct().take(12)}" + } + + private fun SemanticBlock.toHtml(searchQuery: String, searchOptions: ReaderSearchOptions): String { + return when (this) { + is SemanticHeader -> "${textHtml(searchQuery, searchOptions)}" + is SemanticParagraph -> "${textHtml(searchQuery, searchOptions)}

    " + is SemanticListItem -> "${textHtml(searchQuery, searchOptions)}" is SemanticList -> { val tag = if (isOrdered) "ol" else "ul" - "<$tag>${items.joinToString("") { it.toHtml(searchQuery) }}" + "<$tag${styleAttribute()}>${items.joinToString("") { it.toHtml(searchQuery, searchOptions) }}" } - is SemanticImage -> "
    \"${altText.orEmpty().escapeHtml()}\"
    " - is SemanticMath -> svgContent ?: "
    ${altText.orEmpty().highlightAndEscape(searchQuery)}
    " - is SemanticSpacer -> if (isExplicitLineBreak) "
    " else "
    " - is SemanticTable -> rows.joinToString("", "
    ", "
    ") { row -> + is SemanticImage -> "\"${altText.orEmpty().escapeHtml()}\"${imageSizeAttribute()}" + is SemanticMath -> svgContent ?: "${altText.orEmpty().highlightAndEscape(searchQuery, searchOptions)}" + is SemanticSpacer -> if (isExplicitLineBreak) "
    " else "" + is SemanticTable -> rows.joinToString("", "", "") { row -> row.joinToString("", "", "") { cell -> val tag = if (cell.isHeader) "th" else "td" - "<$tag colspan=\"${cell.colspan.coerceAtLeast(1)}\">${cell.content.joinToString("") { it.toHtml(searchQuery) }}" + "<$tag colspan=\"${cell.colspan.coerceAtLeast(1)}\"${cell.style.toStyleAttribute()}>${cell.content.joinToString("") { it.toHtml(searchQuery, searchOptions) }}" } } - is SemanticFlexContainer -> children.joinToString("", "
    ", "
    ") { it.toHtml(searchQuery) } - is SemanticWrappingBlock -> floatedImage.toHtml(searchQuery) + paragraphsToWrap.joinToString("") { it.toHtml(searchQuery) } - is SemanticTextBlock -> "

    ${text.highlightAndEscape(searchQuery)}

    " + is SemanticFlexContainer -> children.joinToString("", "", "") { it.toHtml(searchQuery, searchOptions) } + is SemanticWrappingBlock -> floatedImage.toHtml(searchQuery, searchOptions) + paragraphsToWrap.joinToString("") { it.toHtml(searchQuery, searchOptions) } + is SemanticTextBlock -> "${textHtml(searchQuery, searchOptions)}

    " } } - private fun String.textToParagraphHtml(searchQuery: String): String { - return split(Regex("\\n\\s*\\n")) - .filter { it.isNotBlank() } - .joinToString("\n") { "

    ${it.trim().highlightAndEscape(searchQuery)}

    " } + private fun String.textToParagraphHtml( + searchQuery: String, + searchOptions: ReaderSearchOptions, + baseOffset: Int = 0 + ): String { + return paragraphSegments() + .joinToString("") { paragraph -> + val start = baseOffset + paragraph.startOffset + val end = start + paragraph.text.length + """

    ${paragraph.text.highlightAndEscape(searchQuery, searchOptions)}

    """ + } .ifBlank { "

    " } } - private fun String.highlightAndEscape(searchQuery: String): String { - val escaped = escapeHtml() - val query = searchQuery.trim() - if (query.length < 2) return escaped - return escaped.replace(Regex(Regex.escape(query.escapeHtml()), RegexOption.IGNORE_CASE)) { - "${it.value}" + private fun String.paragraphSegments(): List { + val segments = mutableListOf() + var index = 0 + while (index < length) { + while (index < length && this[index].isWhitespace()) index++ + val start = index + if (start >= length) break + + var end = start + while (end < length) { + if (this[end] == '\n') { + var probe = end + var newlineCount = 0 + while (probe < length && this[probe].isWhitespace()) { + if (this[probe] == '\n') newlineCount++ + probe++ + } + if (newlineCount >= 2) break + } + end++ + } + + val raw = substring(start, end) + val trimmedEnd = raw.indexOfLast { !it.isWhitespace() } + if (trimmedEnd >= 0) { + segments += TextSegment( + text = raw.substring(0, trimmedEnd + 1), + startOffset = start + ) + } + index = end + 1 + } + return segments + } + + private fun SharedEpubChapter.normalizedReaderText(): String { + return plainText + .replace("\r\n", "\n") + .replace(Regex("\\n{3,}"), "\n\n") + .trim() + } + + private fun SemanticTextBlock.textOffsetAttributes(): String { + val start = startCharOffsetInSource.coerceAtLeast(0) + val end = (start + text.length).coerceAtLeast(start) + return buildString { + append(" data-reader-text-start=\"$start\" data-reader-text-end=\"$end\"") + elementId?.takeIf { it.isNotBlank() }?.let { + append(" id=\"${it.escapeHtml()}\" data-reader-element-id=\"${it.escapeHtml()}\"") + } + cfi?.takeIf { it.isNotBlank() }?.let { + append(" data-reader-cfi=\"${it.escapeHtml()}\"") + } } } + private fun SemanticTextBlock.textHtml( + searchQuery: String, + searchOptions: ReaderSearchOptions + ): String { + if (text.isEmpty()) return "" + val inlineSpans = spans + .filter { it.end > it.start } + .map { + it.copy( + start = it.start.coerceIn(0, text.length), + end = it.end.coerceIn(0, text.length) + ) + } + .filter { it.end > it.start } + .sortedWith(compareBy({ it.start }, { it.end })) + val linkSpans = inlineSpans.filter { !it.linkHref.isNullOrBlank() } + val markersByOffset = spans + .mapNotNull { span -> + span.elementId + ?.takeIf { it.isNotBlank() } + ?.let { id -> span.start.coerceIn(0, text.length) to id } + } + .groupBy({ it.first }, { it.second }) + + if (inlineSpans.isEmpty() && markersByOffset.isEmpty()) { + return text.highlightAndEscape(searchQuery, searchOptions) + } + + val boundaries = mutableSetOf(0, text.length) + inlineSpans.forEach { span -> + boundaries += span.start + boundaries += span.end + } + boundaries += markersByOffset.keys + + val ordered = boundaries.sorted() + val builder = StringBuilder() + fun appendMarkers(offset: Int) { + markersByOffset[offset].orEmpty().distinct().forEach { id -> + builder.append("""""") + } + } + + for (index in 0 until ordered.lastIndex) { + val start = ordered[index] + val end = ordered[index + 1] + appendMarkers(start) + if (end <= start) continue + val html = text.substring(start, end).highlightAndEscape(searchQuery, searchOptions) + val link = linkSpans.firstOrNull { it.start <= start && it.end >= end } + val segmentStyle = inlineSpans + .filter { it.start <= start && it.end >= end } + .fold(CssStyle()) { merged, span -> merged.merge(span.style) } + .toStyleAttribute() + if (link?.linkHref != null) { + builder.append("""$html""") + } else if (segmentStyle.isNotEmpty()) { + builder.append("""$html""") + } else { + builder.append(html) + } + } + appendMarkers(text.length) + return builder.toString() + } + + private fun SemanticBlock.styleAttribute(extra: String? = null): String { + return style.toStyleAttribute(extra) + } + + private fun SemanticListItem.listItemStyleAttribute(): String { + val markerStyle = itemMarkerImage + ?.takeIf { it.isNotBlank() } + ?.takeIf { style.blockStyle.listStyleImage.isNullOrBlank() } + ?.let { "list-style-image:url('${it.escapeHtml()}')" } + return style.toStyleAttribute(markerStyle) + } + + private fun CssStyle.toStyleAttribute(extra: String? = null): String { + val declarations = mutableListOf() + extra?.takeIf { it.isNotBlank() }?.let { declarations += it } + (spanStyle.fontSize.takeIf { it.isSpecified } ?: fontSize.takeIf { it.isSpecified }) + ?.toCssLength() + ?.let { declarations += "font-size:$it" } + wordSpacing.toCssLength()?.let { declarations += "word-spacing:$it" } + textTransform?.takeIf { it.isNotBlank() }?.let { declarations += "text-transform:$it" } + hyphens?.takeIf { it.isNotBlank() }?.let { declarations += "hyphens:$it" } + fontVariantNumeric?.takeIf { it.isNotBlank() }?.let { declarations += "font-variant-numeric:$it" } + if (spanStyle.color.isSpecified) declarations += "color:${spanStyle.color.toCssHex()}" + if (spanStyle.background.isSpecified) declarations += "background-color:${spanStyle.background.toCssHex()}" + spanStyle.fontWeight?.let { declarations += "font-weight:${it.weight}" } + spanStyle.fontStyle?.let { declarations += "font-style:${it.toString().substringAfterLast('.').lowercase()}" } + spanStyle.textDecoration + ?.takeIf { it.toString() != "None" } + ?.let { declarations += "text-decoration:${it.toString().lowercase()}" } + textDecorationStyle?.takeIf { it.isNotBlank() }?.let { declarations += "text-decoration-style:$it" } + if (textDecorationColor.isSpecified) declarations += "text-decoration-color:${textDecorationColor.toCssHex()}" + if (textUnderlineOffset.isSpecified) declarations += "text-underline-offset:${textUnderlineOffset.value}px" + fontFamilies.firstOrNull()?.takeIf { it.isNotBlank() }?.let { + declarations += "font-family:'${it.escapeHtml()}'" + } + paragraphStyle.lineHeight.toCssLength()?.let { declarations += "line-height:$it" } + paragraphStyle.textIndent?.firstLine + ?.takeIf { it.isSpecified && it.value != 0f } + ?.toCssLength() + ?.let { declarations += "text-indent:$it" } + paragraphStyle.textAlign + ?.takeIf { it.toString() != "Unspecified" } + ?.let { align -> + declarations += "text-align:${align.toString().lowercase()}" + } + val block = blockStyle + display?.takeIf { it.isNotBlank() }?.let { declarations += "display:$it" } + boxSizing?.takeIf { it.isNotBlank() }?.let { declarations += "box-sizing:$it" } + if (block.backgroundColor.isSpecified) declarations += "background-color:${block.backgroundColor.toCssHex()}" + if (block.width.isSpecified) declarations += "width:${block.width.value}px" + if (block.maxWidth.isSpecified) declarations += "max-width:${block.maxWidth.value}px" + if (block.height.isSpecified) declarations += "height:${block.height.value}px" + block.boxSizing?.takeIf { it.isNotBlank() }?.let { declarations += "box-sizing:$it" } + if (block.margin.top.isSpecified && block.margin.top.value != 0f) declarations += "margin-top:${block.margin.top.value}px" + if (block.margin.right.isSpecified && block.margin.right.value != 0f) declarations += "margin-right:${block.margin.right.value}px" + if (block.margin.bottom.isSpecified && block.margin.bottom.value != 0f) declarations += "margin-bottom:${block.margin.bottom.value}px" + if (block.margin.left.isSpecified && block.margin.left.value != 0f) declarations += "margin-left:${block.margin.left.value}px" + if (block.padding.top.isSpecified && block.padding.top.value != 0f) declarations += "padding-top:${block.padding.top.value}px" + if (block.padding.right.isSpecified && block.padding.right.value != 0f) declarations += "padding-right:${block.padding.right.value}px" + if (block.padding.bottom.isSpecified && block.padding.bottom.value != 0f) declarations += "padding-bottom:${block.padding.bottom.value}px" + if (block.padding.left.isSpecified && block.padding.left.value != 0f) declarations += "padding-left:${block.padding.left.value}px" + block.borderTop?.toCssBorder()?.let { declarations += "border-top:$it" } + block.borderRight?.toCssBorder()?.let { declarations += "border-right:$it" } + block.borderBottom?.toCssBorder()?.let { declarations += "border-bottom:$it" } + block.borderLeft?.toCssBorder()?.let { declarations += "border-left:$it" } + if (block.borderTopLeftRadius.isSpecified && block.borderTopLeftRadius.value != 0f) declarations += "border-top-left-radius:${block.borderTopLeftRadius.value}px" + if (block.borderTopRightRadius.isSpecified && block.borderTopRightRadius.value != 0f) declarations += "border-top-right-radius:${block.borderTopRightRadius.value}px" + if (block.borderBottomRightRadius.isSpecified && block.borderBottomRightRadius.value != 0f) declarations += "border-bottom-right-radius:${block.borderBottomRightRadius.value}px" + if (block.borderBottomLeftRadius.isSpecified && block.borderBottomLeftRadius.value != 0f) declarations += "border-bottom-left-radius:${block.borderBottomLeftRadius.value}px" + block.float?.takeIf { it.isNotBlank() }?.let { declarations += "float:$it" } + block.clear?.takeIf { it.isNotBlank() }?.let { declarations += "clear:$it" } + block.position?.takeIf { it.isNotBlank() }?.let { declarations += "position:$it" } + if (block.top.isSpecified) declarations += "top:${block.top.value}px" + if (block.right.isSpecified) declarations += "right:${block.right.value}px" + if (block.bottom.isSpecified) declarations += "bottom:${block.bottom.value}px" + if (block.left.isSpecified) declarations += "left:${block.left.value}px" + block.display?.takeIf { it.isNotBlank() }?.let { declarations += "display:$it" } + block.flexDirection?.takeIf { it.isNotBlank() }?.let { declarations += "flex-direction:$it" } + block.justifyContent?.takeIf { it.isNotBlank() }?.let { declarations += "justify-content:$it" } + block.alignItems?.takeIf { it.isNotBlank() }?.let { declarations += "align-items:$it" } + block.horizontalAlign?.takeIf { it.isNotBlank() }?.let { declarations += "text-align:$it" } + block.filter?.takeIf { it.isNotBlank() }?.let { declarations += "filter:$it" } + block.borderCollapse?.takeIf { it.isNotBlank() }?.let { declarations += "border-collapse:$it" } + if (block.borderSpacing.isSpecified && block.borderSpacing.value != 0f) declarations += "border-spacing:${block.borderSpacing.value}px" + block.listStyleType?.takeIf { it.isNotBlank() }?.let { declarations += "list-style-type:$it" } + block.listStyleImage?.takeIf { it.isNotBlank() }?.let { declarations += "list-style-image:url('${it.escapeHtml()}')" } + return if (declarations.isEmpty()) "" else " style=\"${declarations.joinToString(";").escapeHtml()}\"" + } + + private fun BorderStyle.toCssBorder(): String? { + if (!width.isSpecified || width.value <= 0f) return null + val styleValue = style.takeIf { it.isNotBlank() } ?: "solid" + val colorValue = if (color.isSpecified) color.toCssHex() else "currentColor" + return "${width.value}px $styleValue $colorValue" + } + + private fun TextUnit.toCssLength(): String? { + if (!isSpecified || value <= 0f) return null + return when { + isEm -> "${value}em" + isSp -> "${value}px" + else -> value.toString() + } + } + + private fun TextUnit.toDiagnosticTextUnit(): String? { + if (!isSpecified || value <= 0f) return null + return when { + isEm -> "${value}em" + isSp -> "${value}sp" + else -> value.toString() + } + } + + private fun SemanticImage.imageSizeAttribute(): String { + val declarations = buildList { + intrinsicWidth?.takeIf { it > 0f }?.let { add("width:${it}px") } + intrinsicHeight?.takeIf { it > 0f }?.let { add("height:${it}px") } + } + return if (declarations.isEmpty()) "" else " style=\"${declarations.joinToString(";")}\"" + } + + private fun String.highlightAndEscape(searchQuery: String, searchOptions: ReaderSearchOptions): String { + val escaped = escapeHtml() + val query = searchQuery.trim() + if (query.isEmpty()) return escaped + val escapedQuery = Regex.escape(query.escapeHtml()) + val pattern = if (searchOptions.wholeWords) { + "(^|[^A-Za-z0-9_])($escapedQuery)(?=$|[^A-Za-z0-9_])" + } else { + "($escapedQuery)" + } + val options: Set = if (searchOptions.matchCase) emptySet() else setOf(RegexOption.IGNORE_CASE) + return escaped.replace(Regex(pattern, options)) { + val leading = if (searchOptions.wholeWords) it.groupValues[1] else "" + val value = if (searchOptions.wholeWords) it.groupValues[2] else it.groupValues[1] + "$leading$value" + } + } + + private fun Long.toCssColor(): String { + val value = this and 0xFFFFFFFFL + val red = ((value shr 16) and 0xFF).toString(16).padStart(2, '0') + val green = ((value shr 8) and 0xFF).toString(16).padStart(2, '0') + val blue = (value and 0xFF).toString(16).padStart(2, '0') + return "#$red$green$blue" + } + + private fun String.toCssFontUrl(): String { + val trimmed = trim() + val normalizedInput = trimmed.replace("\\", "/") + val withScheme = when { + normalizedInput.startsWith("file:///") -> normalizedInput + normalizedInput.startsWith("file:/") -> "file:///" + normalizedInput.removePrefix("file:/") + normalizedInput.contains("://") -> normalizedInput + normalizedInput.matches(Regex("^[A-Za-z]:/.*")) -> "file:///$normalizedInput" + else -> normalizedInput + } + return withScheme + .replace(" ", "%20") + .replace("'", "%27") + .replace(")", "%29") + .replace("(", "%28") + } + + private fun String.toTextureOverlayCss(alpha: Float, darkMode: Boolean, dataUri: String?): String { + val hasTextureData = !dataUri.isNullOrBlank() + val texture = dataUri + ?.takeIf { hasTextureData } + ?.let { "url('${it.escapeCssString()}')" } + ?: when (this) { + ReaderTexture.NATURAL_WHITE.id, + ReaderTexture.PAPER.id -> "radial-gradient(circle at 20% 30%, rgba(0,0,0,.09) 0 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.22), rgba(0,0,0,.04))" + ReaderTexture.NATURAL_BLACK.id, + ReaderTexture.SLATE.id -> "radial-gradient(circle at 20% 30%, rgba(255,255,255,.12) 0 1px, transparent 1px), linear-gradient(120deg, rgba(255,255,255,.08), rgba(0,0,0,.18))" + ReaderTexture.LIGHT_VENEER.id, + ReaderTexture.RETINA_WOOD.id -> "repeating-linear-gradient(90deg, rgba(120,76,32,.10) 0 3px, rgba(255,255,255,.09) 3px 7px)" + ReaderTexture.GREY_WASH.id -> "repeating-linear-gradient(135deg, rgba(255,255,255,.07) 0 2px, rgba(0,0,0,.08) 2px 5px)" + ReaderTexture.CLASSY_FABRIC.id, + ReaderTexture.CANVAS.id -> "repeating-linear-gradient(0deg, rgba(255,255,255,.08) 0 1px, transparent 1px 4px), repeating-linear-gradient(90deg, rgba(0,0,0,.08) 0 1px, transparent 1px 4px)" + ReaderTexture.RETRO_INTRO.id, + ReaderTexture.EINK.id -> "radial-gradient(circle, rgba(0,0,0,.12) 0 1px, transparent 1px)" + else -> "linear-gradient(135deg, rgba(255,255,255,.08), rgba(0,0,0,.08))" + } + val size = if (hasTextureData) { + "auto" + } else { + when (this) { + ReaderTexture.EINK.id, + ReaderTexture.RETRO_INTRO.id, + ReaderTexture.PAPER.id, + ReaderTexture.NATURAL_WHITE.id, + ReaderTexture.NATURAL_BLACK.id -> "7px 7px, 100% 100%" + else -> "auto" + } + } + return """ + body::before { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + background-image: $texture; + background-size: $size; + opacity: ${alpha.coerceIn(0f, 1f)}; + mix-blend-mode: ${if (darkMode) "screen" else "multiply"}; + z-index: 0; + } + """.trimIndent() + } + + private fun String.escapeCssString(): String { + return replace("\\", "\\\\").replace("'", "\\'") + } + + private fun String.applyUserHighlights( + highlights: List, + contentStartOffset: Int, + contentEndOffset: Int + ): String { + val rangedHighlights = highlights + .mapNotNull { it.toRenderHighlight(contentStartOffset, contentEndOffset) } + .distinctBy { "${it.absoluteStart}:${it.absoluteEnd}:${it.id}" } + .sortedWith(compareByDescending { it.relativeStart }.thenByDescending { it.relativeEnd }) + + val rangedHtml = rangedHighlights.fold(this) { html, highlight -> + val htmlRange = html.htmlRangeForHighlight(highlight) ?: return@fold html + val startIndex = htmlRange.first + val endIndex = htmlRange.last + if (startIndex >= endIndex || endIndex > html.length) return@fold html + val markedText = html.substring(startIndex, endIndex) + if (markedText.isBlank()) return@fold html + val marker = """$markedText""" + html.replaceRange(startIndex, endIndex, marker) + } + + return highlights + .filterNot { it.locator.withFallbacks(chapterIndex = it.chapterIndex, cfi = it.cfi, textQuote = it.text).hasTextRange } + .fold(rangedHtml) { html, highlight -> + val text = highlight.text.trim().takeIf { it.isNotBlank() } ?: return@fold html + val escapedText = text.escapeHtml() + val markedText = """$escapedText""" + html.replaceFirst(escapedText, markedText) + } + } + + private fun String.htmlRangeForHighlight(highlight: RenderedHighlight): IntRange? { + val block = findTextBlockRange(highlight.absoluteStart, highlight.absoluteEnd) + if (block != null) { + val startIndex = htmlIndexForTextOffset( + targetOffset = highlight.absoluteStart - block.startOffset, + startIndex = block.contentStartIndex, + endIndex = block.contentEndIndex + ) ?: return null + val endIndex = htmlIndexForTextOffset( + targetOffset = highlight.absoluteEnd - block.startOffset, + startIndex = block.contentStartIndex, + endIndex = block.contentEndIndex + ) ?: return null + return startIndex..endIndex + } + val startIndex = htmlIndexForTextOffset(highlight.relativeStart) ?: return null + val endIndex = htmlIndexForTextOffset(highlight.relativeEnd) ?: return null + return startIndex..endIndex + } + + private fun String.findTextBlockRange(absoluteStart: Int, absoluteEnd: Int): HtmlTextBlockRange? { + return textBlockStartPattern.findAll(this).mapNotNull { match -> + val tagName = match.groupValues[1] + val blockStart = match.groupValues[2].toIntOrNull() ?: return@mapNotNull null + val blockEnd = match.groupValues[3].toIntOrNull() ?: return@mapNotNull null + if (absoluteStart < blockStart || absoluteEnd > blockEnd) return@mapNotNull null + val contentStart = match.range.last + 1 + val closingTag = "" + val contentEnd = indexOf(closingTag, startIndex = contentStart, ignoreCase = true) + if (contentEnd < contentStart) return@mapNotNull null + HtmlTextBlockRange( + startOffset = blockStart, + endOffset = blockEnd, + contentStartIndex = contentStart, + contentEndIndex = contentEnd + ) + }.firstOrNull() + } + + private fun String.htmlIndexForTextOffset( + targetOffset: Int, + startIndex: Int = 0, + endIndex: Int = length + ): Int? { + if (targetOffset < 0) return null + var index = startIndex.coerceIn(0, length) + val limit = endIndex.coerceIn(index, length) + var textOffset = 0 + var boundaryAfterText: Int? = null + while (index < limit) { + when (this[index]) { + '<' -> { + val tagEnd = indexOf('>', startIndex = index + 1) + if (tagEnd < 0 || tagEnd >= limit) return null + index = tagEnd + 1 + } + + '&' -> { + if (textOffset == targetOffset) return index + val entityEnd = indexOf(';', startIndex = index + 1) + if (entityEnd > index) { + textOffset++ + index = entityEnd + 1 + } else { + textOffset++ + index++ + } + boundaryAfterText = index + } + + else -> { + if (textOffset == targetOffset) return index + textOffset++ + index++ + boundaryAfterText = index + } + } + } + return if (textOffset == targetOffset) boundaryAfterText ?: startIndex else null + } + + private fun UserHighlight.toRenderHighlight(contentStartOffset: Int, contentEndOffset: Int): RenderedHighlight? { + val normalizedLocator = locator.withFallbacks(chapterIndex = chapterIndex, cfi = cfi, textQuote = text) + val start = normalizedLocator.startOffset ?: return null + val end = normalizedLocator.endOffset ?: start + if (end < start) return null + val boundedStart = start.coerceAtLeast(contentStartOffset) + val boundedEnd = end.coerceAtMost(contentEndOffset) + if (boundedEnd <= boundedStart) return null + return RenderedHighlight( + id = id, + color = color, + absoluteStart = boundedStart, + absoluteEnd = boundedEnd, + relativeStart = boundedStart - contentStartOffset, + relativeEnd = boundedEnd - contentStartOffset + ) + } + + private fun UserHighlight.belongsToPage(page: ReaderPage): Boolean { + val normalizedLocator = locator.withFallbacks(chapterIndex = chapterIndex, cfi = cfi, textQuote = text) + val locatorChapterIndex = normalizedLocator.chapterIndex ?: chapterIndex + if (locatorChapterIndex != page.chapterIndex) return false + if (normalizedLocator.hasTextRange) { + val start = normalizedLocator.startOffset ?: return false + val end = normalizedLocator.endOffset ?: start + return if (start == end) { + start in page.startOffset..page.endOffset + } else { + start < page.endOffset && end > page.startOffset + } + } + normalizedLocator.pageIndex?.let { return it == page.pageIndex } + val prefix = "desktop:${page.chapterIndex}:" + val desktopPageIndex = cfi + .takeIf { it.startsWith(prefix) } + ?.removePrefix(prefix) + ?.substringBefore(':') + ?.toIntOrNull() + return desktopPageIndex == null || desktopPageIndex < 0 || desktopPageIndex == page.pageIndex + } + + private val UserHighlight.locatedChapterIndex: Int + get() = locator.chapterIndex ?: chapterIndex + + private fun ReaderLocator.toNavigationAttributes(): String { + val attributes = buildList { + chapterIndex?.let { add("data-reader-active-chapter-index=\"$it\"") } + pageIndex?.let { add("data-reader-active-page-index=\"$it\"") } + startOffset?.let { add("data-reader-active-start-offset=\"$it\"") } + endOffset?.let { add("data-reader-active-end-offset=\"$it\"") } + cfi?.takeIf { it.isNotBlank() }?.let { add("data-reader-active-cfi=\"${it.escapeHtml()}\"") } + } + return if (attributes.isEmpty()) "" else " " + attributes.joinToString(" ") + } + + private fun List.toPageAnchorJson(): String { + if (isEmpty()) return "[]" + return joinToString(prefix = "[", postfix = "]") { page -> + """{"pageIndex":${page.pageIndex},"chapterIndex":${page.chapterIndex},"startOffset":${page.startOffset},"endOffset":${page.endOffset}}""" + } + } + + private data class TextSegment( + val text: String, + val startOffset: Int + ) + + private data class RenderedHighlight( + val id: String, + val color: HighlightColor, + val absoluteStart: Int, + val absoluteEnd: Int, + val relativeStart: Int, + val relativeEnd: Int + ) + + private data class HtmlTextBlockRange( + val startOffset: Int, + val endOffset: Int, + val contentStartIndex: Int, + val contentEndIndex: Int + ) + + private val textBlockStartPattern = Regex( + """<([A-Za-z][A-Za-z0-9]*)\b[^>]*\bdata-reader-text-start="(\d+)"[^>]*\bdata-reader-text-end="(\d+)"[^>]*>""" + ) + private fun String.escapeHtml(): String { return replace("&", "&") .replace("<", "<") @@ -216,4 +2026,13 @@ object ReaderHtmlDocumentBuilder { .replace("\"", """) .replace("'", "'") } + + private fun androidx.compose.ui.graphics.Color.toCssHex(): String { + fun channel(value: Float): String = (value * 255f).roundToInt().coerceIn(0, 255).toString(16).padStart(2, '0') + return "#${channel(red)}${channel(green)}${channel(blue)}" + } + + private fun logReaderHtml(message: String) { + println("ReaderHtmlRender $message") + } } diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderModels.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderModels.kt index 97740cc..9e89f65 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderModels.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/ReaderModels.kt @@ -1,6 +1,9 @@ package com.aryan.reader.shared.reader import com.aryan.reader.paginatedreader.SemanticBlock +import com.aryan.reader.shared.PageInfoMode +import com.aryan.reader.shared.PageInfoPosition +import com.aryan.reader.shared.SystemUiMode data class SharedEpubBook( val id: String, @@ -20,10 +23,7 @@ data class SharedEpubChapter( val baseHref: String? = null ) -data class ReaderLocator( - val chapterIndex: Int = 0, - val charOffset: Int = 0 -) +typealias ReaderLocator = com.aryan.reader.shared.ReaderLocator enum class ReaderReadingMode { PAGINATED, @@ -44,8 +44,26 @@ data class ReaderSettings( val readingMode: ReaderReadingMode = ReaderReadingMode.PAGINATED, val textAlign: SharedReaderTextAlign = SharedReaderTextAlign.START, val pageWidth: Int = 760, - val fontFamily: String = "Default" -) + val fontFamily: String = "Default", + val paragraphSpacing: Float = 1.0f, + val imageScale: Float = 1.0f, + val horizontalMargin: Int? = null, + val verticalMargin: Int? = null, + val themeId: String? = null, + val textureId: String? = null, + val textureAlpha: Float = 0.55f, + val customFontPath: String? = null, + val backgroundColorArgb: Long? = null, + val textColorArgb: Long? = null, + val systemUiMode: SystemUiMode = SystemUiMode.DEFAULT, + val pageInfoMode: PageInfoMode = PageInfoMode.DEFAULT, + val pageInfoPosition: PageInfoPosition = PageInfoPosition.BOTTOM, + val seamlessChapterNavigation: Boolean = true, + val chapterTurnDragMultiplier: Float = 1.0f +) { + val resolvedHorizontalMargin: Int get() = horizontalMargin ?: margin + val resolvedVerticalMargin: Int get() = verticalMargin ?: margin +} data class ReaderPage( val pageIndex: Int, diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/SharedTextBookFactory.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/SharedTextBookFactory.kt new file mode 100644 index 0000000..1e9d6bf --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/SharedTextBookFactory.kt @@ -0,0 +1,106 @@ +package com.aryan.reader.shared.reader + +object SharedTextBookFactory { + fun fromPlainText( + id: String, + fileName: String, + title: String, + plainText: String, + author: String? = null + ): SharedEpubBook { + return SharedEpubBook( + id = id, + fileName = fileName, + title = title, + author = author, + chapters = listOf( + SharedEpubChapter( + id = "chapter_0", + title = title, + plainText = plainText.ifBlank { "This document did not contain readable text." } + ) + ) + ) + } + + fun fromHtml( + id: String, + fileName: String, + title: String, + html: String, + author: String? = null + ): SharedEpubBook { + val sanitizedHtml = html.sanitizeReaderHtml() + val body = sanitizedHtml.extractBodyOrSelf() + return SharedEpubBook( + id = id, + fileName = fileName, + title = title, + author = author, + chapters = listOf( + SharedEpubChapter( + id = "chapter_0", + title = sanitizedHtml.tagText("h1") + .ifBlank { sanitizedHtml.tagText("title") } + .ifBlank { title }, + plainText = sanitizedHtml.htmlToText().ifBlank { title }, + htmlContent = body + ) + ) + ) + } + + private fun String.extractBodyOrSelf(): String { + return Regex("(?is)]*>(.*?)") + .find(this) + ?.groupValues + ?.get(1) + ?.trim() + ?: this + } + + private fun String.tagText(tag: String): String { + return Regex("<(?:[^:>]+:)?$tag\\b[^>]*>(.*?)]+:)?$tag>", RegexOption.IGNORE_CASE) + .find(this) + ?.groupValues + ?.get(1) + ?.htmlToText() + .orEmpty() + } + + private fun String.htmlToText(): String { + return replace(Regex("(?is)"), "") + .replace(Regex("(?is)"), "") + .replace(Regex("(?i)"), "\n") + .replace(Regex("(?i)"), "\n\n") + .replace(Regex("(?i)"), "\n\n") + .replace(Regex("<[^>]+>"), " ") + .decodeEntities() + .replace(Regex("[ \\t\\x0B\\f\\r]+"), " ") + .replace(Regex(" *\\n *"), "\n") + .replace(Regex("\\n{3,}"), "\n\n") + .trim() + } + + private fun String.decodeEntities(): String { + return replace(" ", " ") + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace(Regex("&#x([0-9a-fA-F]+);")) { match -> + match.groupValues[1].toIntOrNull(16)?.toChar()?.toString().orEmpty() + } + .replace(Regex("&#(\\d+);")) { match -> + match.groupValues[1].toIntOrNull()?.toChar()?.toString().orEmpty() + } + } + + private fun String.sanitizeReaderHtml(): String { + return replace(Regex("(?is)"), "") + .replace(Regex("(?is)"), "") + .replace(Regex("(?is)]*>"), "") + .replace(Regex("""(?i)\s+on[a-z]+\s*=\s*(['"]).*?\1"""), "") + } +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/SimplePaginator.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/SimplePaginator.kt index bbad7ae..e9caa98 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/SimplePaginator.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/SimplePaginator.kt @@ -98,8 +98,8 @@ class SimplePaginator { viewportWidth: Int, viewportHeight: Int ): Int { - val usableWidth = (viewportWidth - settings.margin * 2).coerceAtLeast(360) - val usableHeight = (viewportHeight - settings.margin * 2).coerceAtLeast(360) + val usableWidth = (viewportWidth - settings.resolvedHorizontalMargin * 2).coerceAtLeast(360) + val usableHeight = (viewportHeight - settings.resolvedVerticalMargin * 2).coerceAtLeast(360) val averageCharWidth = settings.fontSize * 0.55f val lineHeight = settings.fontSize * settings.lineSpacing val charsPerLine = (usableWidth / averageCharWidth).toInt().coerceAtLeast(35) diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/LocalBookCoverImage.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/LocalBookCoverImage.kt new file mode 100644 index 0000000..e02408f --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/LocalBookCoverImage.kt @@ -0,0 +1,11 @@ +package com.aryan.reader.shared.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +internal expect fun LocalBookCoverImage( + path: String, + contentDescription: String?, + modifier: Modifier = Modifier +) diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/NonReaderLayoutModels.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/NonReaderLayoutModels.kt new file mode 100644 index 0000000..2cc92ca --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/NonReaderLayoutModels.kt @@ -0,0 +1,147 @@ +package com.aryan.reader.shared.ui + +import com.aryan.reader.shared.BookItem +import com.aryan.reader.shared.FileType +import com.aryan.reader.shared.LibraryFilters +import com.aryan.reader.shared.ReadStatusFilter +import com.aryan.reader.shared.SharedReaderScreenState +import com.aryan.reader.shared.ShelfType +import com.aryan.reader.shared.isOpdsStream +import com.aryan.reader.shared.progressPercentValue +import com.aryan.reader.shared.toHomeScreenModel + +enum class SharedAppToolAction { + IMPORT_FILES, + IMPORT_FOLDER, + SYNC, + APP_THEME, + AI_SETTINGS, + CUSTOM_FONTS, + HELP_FEEDBACK, + SUPPORT, + ABOUT, + TABS_TOGGLE +} + +data class SharedAppShellModel( + val primaryTabs: List, + val selectedPrimaryTab: SharedAppTab, + val toolActions: List +) + +fun sharedAppShellModel( + selectedTab: SharedAppTab, + aiSettingsAvailable: Boolean +): SharedAppShellModel { + val primaryTabs = listOf( + SharedAppTab.HOME, + SharedAppTab.LIBRARY, + SharedAppTab.CATALOGS, + SharedAppTab.READER + ) + val selectedPrimaryTab = when (selectedTab) { + SharedAppTab.SHELVES -> SharedAppTab.LIBRARY + SharedAppTab.CUSTOM_FONTS, + SharedAppTab.SUPPORT, + SharedAppTab.FEEDBACK, + SharedAppTab.ABOUT -> SharedAppTab.HOME + else -> selectedTab + } + val toolActions = buildList { + add(SharedAppToolAction.IMPORT_FILES) + add(SharedAppToolAction.IMPORT_FOLDER) + add(SharedAppToolAction.SYNC) + add(SharedAppToolAction.APP_THEME) + if (aiSettingsAvailable) add(SharedAppToolAction.AI_SETTINGS) + add(SharedAppToolAction.CUSTOM_FONTS) + add(SharedAppToolAction.HELP_FEEDBACK) + add(SharedAppToolAction.SUPPORT) + add(SharedAppToolAction.ABOUT) + add(SharedAppToolAction.TABS_TOGGLE) + } + return SharedAppShellModel( + primaryTabs = primaryTabs, + selectedPrimaryTab = selectedPrimaryTab, + toolActions = toolActions + ) +} + +data class NonReaderHomeLayoutModel( + val continueBook: BookItem?, + val activeTabs: List, + val pinnedBooks: List, + val recentBooks: List, + val selectedBooks: List, + val isContextualModeActive: Boolean, + val isEmpty: Boolean, + val isLibraryEmpty: Boolean +) + +fun SharedReaderScreenState.toNonReaderHomeLayoutModel(): NonReaderHomeLayoutModel { + val model = toHomeScreenModel() + val activeTabs = if (isTabsEnabled) model.openTabs else emptyList() + val continueBook = activeTabs.firstOrNull { it.id == activeTabBookId } + ?: model.recentBooks.firstOrNull { progressPercentValue(it.progressPercentage) in 1..99 } + ?: model.recentBooks.firstOrNull() + val continueId = continueBook?.id + val pinnedBooks = model.recentBooks + .filter { it.id in pinnedHomeBookIds && it.id != continueId } + val recentBooks = model.recentBooks + .filter { it.id !in pinnedHomeBookIds && it.id != continueId } + return NonReaderHomeLayoutModel( + continueBook = continueBook, + activeTabs = activeTabs, + pinnedBooks = pinnedBooks, + recentBooks = recentBooks, + selectedBooks = model.selectedBooks, + isContextualModeActive = model.isContextualModeActive, + isEmpty = continueBook == null && pinnedBooks.isEmpty() && recentBooks.isEmpty() && activeTabs.isEmpty(), + isLibraryEmpty = model.isLibraryEmpty + ) +} + +data class NonReaderLibraryOrganizationModel( + val allBooksCount: Int, + val shelfCount: Int, + val smartShelfCount: Int, + val tagCount: Int, + val folderCount: Int, + val unreadCount: Int, + val inProgressCount: Int, + val completedCount: Int, + val activeFilterCount: Int, + val availableFileTypes: List, + val hasInAppBooks: Boolean, + val hasOpdsStreams: Boolean +) + +fun SharedReaderScreenState.toNonReaderLibraryOrganizationModel(): NonReaderLibraryOrganizationModel { + val books = rawLibraryBooks + val rootFolderCount = shelves.count { it.type == ShelfType.FOLDER && it.parentShelfId == null } + val tagIds = (allTags.map { it.id } + books.flatMap { book -> book.tags.map { it.id } }).toSet() + return NonReaderLibraryOrganizationModel( + allBooksCount = books.size, + shelfCount = shelves.count { it.type != ShelfType.FOLDER && it.type != ShelfType.TAG && it.type != ShelfType.SMART }, + smartShelfCount = shelves.count { it.type == ShelfType.SMART }, + tagCount = tagIds.size, + folderCount = maxOf(rootFolderCount, syncedFolders.size), + unreadCount = books.count { progressPercentValue(it.progressPercentage) == 0 }, + inProgressCount = books.count { progressPercentValue(it.progressPercentage) in 1..99 }, + completedCount = books.count { progressPercentValue(it.progressPercentage) >= 100 }, + activeFilterCount = libraryFilters.activeFilterCount(), + availableFileTypes = books + .map { it.type } + .filterNot { it == FileType.UNKNOWN } + .distinct() + .sortedBy { it.ordinal }, + hasInAppBooks = books.any { it.sourceFolder == null && !it.isOpdsStream() }, + hasOpdsStreams = books.any { it.isOpdsStream() } + ) +} + +private fun LibraryFilters.activeFilterCount(): Int { + return fileTypes.size + + sourceFolders.size + + tagIds.size + + if (readStatus == ReadStatusFilter.ALL) 0 else 1 +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/NonReaderScreens.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/NonReaderScreens.kt index 0c25eea..a289ded 100644 --- a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/NonReaderScreens.kt +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/NonReaderScreens.kt @@ -3,20 +3,25 @@ package com.aryan.reader.shared.ui import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -32,16 +37,21 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.LibraryBooks import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.automirrored.filled.MenuBook import androidx.compose.material.icons.automirrored.filled.Sort import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Book import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.FilterList import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.FormatListNumbered import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.PushPin import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Tag import androidx.compose.material3.AssistChip @@ -55,6 +65,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -68,6 +79,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -75,30 +87,41 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.aryan.reader.shared.BookItem import com.aryan.reader.shared.FileType -import com.aryan.reader.shared.LibraryFilters +import com.aryan.reader.shared.IN_APP_STORAGE_SOURCE import com.aryan.reader.shared.LibraryAction +import com.aryan.reader.shared.LibraryFilters import com.aryan.reader.shared.ReadStatusFilter +import com.aryan.reader.shared.SharedReaderScreenState import com.aryan.reader.shared.Shelf import com.aryan.reader.shared.ShelfType -import com.aryan.reader.shared.SharedReaderScreenState import com.aryan.reader.shared.SortOrder import com.aryan.reader.shared.cardAuthor import com.aryan.reader.shared.cardTitle import com.aryan.reader.shared.isOpdsStream import com.aryan.reader.shared.progressPercentValue import com.aryan.reader.shared.reduce -import com.aryan.reader.shared.toHomeScreenModel enum class NonReaderLibraryTab { BOOKS, SHELVES, - FOLDERS + SMART_SHELVES, + TAGS, + FOLDERS, + UNREAD, + IN_PROGRESS, + COMPLETED +} + +private enum class BookViewMode { + COVERS, + LIST } @Composable fun SharedHomeScreen( state: SharedReaderScreenState, onImportBooks: () -> Unit, + onImportFolder: () -> Unit = {}, onOpenBook: (BookItem) -> Unit, onToggleSelection: (String) -> Unit, onClearSelection: () -> Unit, @@ -107,50 +130,127 @@ fun SharedHomeScreen( onEditBook: (BookItem) -> Unit = {}, onTagSelectedBooks: () -> Unit = {}, onAddSelectedBooksToShelf: () -> Unit = {}, + onOpenTab: (BookItem) -> Unit = onOpenBook, + onCloseTab: (BookItem) -> Unit = {}, + onCloseAllTabs: () -> Unit = {}, + onRecentLimitChange: (Int) -> Unit = {}, + onTogglePinned: (BookItem) -> Unit = {}, modifier: Modifier = Modifier ) { - val model = state.toHomeScreenModel() + val model = state.toNonReaderHomeLayoutModel() NonReaderScreenScaffold( title = "Home", - subtitle = "Recent books and quick access", + subtitle = "Continue reading and recent books", modifier = modifier, trailing = { - Button(onClick = onImportBooks) { - Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(8.dp)) - Text("Import") + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + RecentLimitMenu( + currentLimit = state.recentFilesLimit, + onRecentLimitChange = onRecentLimitChange + ) + OutlinedButton(onClick = onImportFolder) { + Icon(Icons.Default.Folder, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Folder") + } + Button(onClick = onImportBooks) { + Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Import") + } } } ) { if (model.isContextualModeActive) { + val selectedBooks = model.selectedBooks + val allSelectedPinned = selectedBooks.isNotEmpty() && selectedBooks.all { it.id in state.pinnedHomeBookIds } SelectionToolbar( - count = model.selectedBooks.size, + count = selectedBooks.size, onClear = onClearSelection, onRemove = onRemoveSelected, onTag = onTagSelectedBooks, - onAddToShelf = onAddSelectedBooksToShelf + onAddToShelf = onAddSelectedBooksToShelf, + onPin = { + selectedBooks + .filter { book -> allSelectedPinned || book.id !in state.pinnedHomeBookIds } + .forEach(onTogglePinned) + }, + pinLabel = if (allSelectedPinned) "Unpin" else "Pin", + onInfo = selectedBooks.singleOrNull()?.let { book -> { onShowBookInfo(book) } } ) } if (model.isEmpty) { SharedEmptyState( icon = { Icon(Icons.AutoMirrored.Filled.LibraryBooks, contentDescription = null, modifier = Modifier.size(56.dp)) }, - title = "No recent files", - body = if (model.isLibraryEmpty) "Import a few books to populate your library." else "Open books from the library and they will appear here.", + title = if (model.isLibraryEmpty) "Your library is empty" else "No recent files", + body = if (model.isLibraryEmpty) "Import books or connect a folder to start building your desktop library." else "Open books from the library and they will appear here.", actionLabel = "Import books", onAction = onImportBooks, + secondaryActionLabel = "Import folder", + onSecondaryAction = onImportFolder, modifier = Modifier.weight(1f) ) } else { - BookGrid( - books = model.recentBooks, - selectedBookIds = state.selectedBookIds, - onOpenBook = onOpenBook, - onToggleSelection = onToggleSelection, - onShowBookInfo = onShowBookInfo, - onEditBook = onEditBook, - modifier = Modifier.weight(1f) - ) + LazyColumn( + modifier = Modifier.weight(1f).fillMaxWidth(), + contentPadding = PaddingValues(bottom = 28.dp), + verticalArrangement = Arrangement.spacedBy(22.dp) + ) { + model.continueBook?.let { book -> + item(key = "continue_${book.id}") { + ContinueReadingCard( + book = book, + pinned = book.id in state.pinnedHomeBookIds, + onOpenBook = { onOpenBook(book) }, + onShowBookInfo = { onShowBookInfo(book) }, + onEditBook = { onEditBook(book) }, + onTogglePinned = { onTogglePinned(book) } + ) + } + } + if (state.isTabsEnabled && model.activeTabs.isNotEmpty()) { + item(key = "tabs") { + ActiveTabStrip( + openTabs = model.activeTabs, + activeBookId = state.activeTabBookId, + onOpenTab = onOpenTab, + onCloseTab = onCloseTab, + onCloseAllTabs = onCloseAllTabs + ) + } + } + if (model.pinnedBooks.isNotEmpty()) { + item(key = "pinned") { + HomeBookShelf( + title = "Pinned", + books = model.pinnedBooks, + selectedBookIds = state.selectedBookIds, + pinnedBookIds = state.pinnedHomeBookIds, + onOpenBook = onOpenBook, + onToggleSelection = onToggleSelection, + onShowBookInfo = onShowBookInfo, + onEditBook = onEditBook, + onTogglePinned = onTogglePinned + ) + } + } + if (model.recentBooks.isNotEmpty()) { + item(key = "recent") { + HomeBookShelf( + title = "Recent", + books = model.recentBooks, + selectedBookIds = state.selectedBookIds, + pinnedBookIds = state.pinnedHomeBookIds, + onOpenBook = onOpenBook, + onToggleSelection = onToggleSelection, + onShowBookInfo = onShowBookInfo, + onEditBook = onEditBook, + onTogglePinned = onTogglePinned + ) + } + } + } } } } @@ -169,73 +269,665 @@ fun SharedLibraryScreen( onShowBookInfo: (BookItem) -> Unit = {}, onEditBook: (BookItem) -> Unit = {}, onCreateShelf: () -> Unit = {}, + onCreateSmartShelf: () -> Unit = {}, onRenameShelf: (Shelf) -> Unit = {}, onDeleteShelf: (Shelf) -> Unit = {}, + onRemoveFolder: (Shelf) -> Unit = {}, onTagSelectedBooks: () -> Unit = {}, onAddSelectedBooksToShelf: () -> Unit = {}, + onImportFolder: () -> Unit = {}, + onTogglePinned: (BookItem) -> Unit = {}, modifier: Modifier = Modifier ) { - val books = state.libraryBooks - val shelves = state.shelves - val folderShelves = remember(shelves) { shelves.filter { it.type == ShelfType.FOLDER } } + val organization = state.toNonReaderLibraryOrganizationModel() + var showFilters by remember { mutableStateOf(false) } + var viewMode by remember { mutableStateOf(BookViewMode.COVERS) } + + fun selectLibraryTab(tab: NonReaderLibraryTab) { + onTabChange(tab) + val status = tab.readStatusFilter() + if (status != null) { + onStateChange(state.reduce(LibraryAction.FiltersChanged(state.libraryFilters.copy(readStatus = status)))) + } else if (selectedTab.readStatusFilter() != null) { + onStateChange(state.reduce(LibraryAction.FiltersChanged(state.libraryFilters.copy(readStatus = ReadStatusFilter.ALL)))) + } + } + NonReaderScreenScaffold( title = "Library", subtitle = "Search, sort, filter, and organize local metadata", - modifier = modifier, - trailing = { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - SortMenu(sortOrder = state.sortOrder, onSortOrderChange = { onStateChange(state.reduce(LibraryAction.SortChanged(it))) }) - Button(onClick = onCreateShelf) { - Icon(Icons.Default.Folder, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(8.dp)) - Text("Shelf") - } - Button(onClick = onImportBooks) { - Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(8.dp)) - Text("Import") - } - } - } + modifier = modifier ) { if (state.selectedBookIds.isNotEmpty()) { + val selectedBooks = state.rawLibraryBooks.filter { it.id in state.selectedBookIds } + val allSelectedPinned = selectedBooks.isNotEmpty() && selectedBooks.all { it.id in state.pinnedLibraryBookIds } SelectionToolbar( count = state.selectedBookIds.size, onClear = onClearSelection, onRemove = onRemoveSelected, onTag = onTagSelectedBooks, - onAddToShelf = onAddSelectedBooksToShelf + onAddToShelf = onAddSelectedBooksToShelf, + onPin = { + selectedBooks + .filter { book -> allSelectedPinned || book.id !in state.pinnedLibraryBookIds } + .forEach(onTogglePinned) + }, + pinLabel = if (allSelectedPinned) "Unpin" else "Pin", + onInfo = selectedBooks.singleOrNull()?.let { book -> { onShowBookInfo(book) } } ) } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - NonReaderLibraryTab.entries.forEach { tab -> - FilterChip( - selected = selectedTab == tab, - onClick = { onTabChange(tab) }, - leadingIcon = { - Icon( - imageVector = when (tab) { - NonReaderLibraryTab.BOOKS -> Icons.Default.Book - NonReaderLibraryTab.SHELVES -> Icons.AutoMirrored.Filled.LibraryBooks - NonReaderLibraryTab.FOLDERS -> Icons.Default.Folder - }, - contentDescription = null, - modifier = Modifier.size(18.dp) + BoxWithConstraints(modifier = Modifier.weight(1f).fillMaxWidth()) { + val useSidebar = maxWidth >= 980.dp + if (useSidebar) { + Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(18.dp)) { + LibraryOrganizationSidebar( + organization = organization, + selectedTab = selectedTab, + onTabSelected = ::selectLibraryTab, + modifier = Modifier.width(232.dp).fillMaxHeight() + ) + Column(Modifier.weight(1f).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(12.dp)) { + LibraryToolbar( + state = state, + viewMode = viewMode, + showFilters = showFilters, + onViewModeChange = { viewMode = it }, + onToggleFilters = { showFilters = !showFilters }, + onStateChange = onStateChange, + onImportBooks = onImportBooks, + onImportFolder = onImportFolder, + onCreateShelf = onCreateShelf, + onCreateSmartShelf = onCreateSmartShelf ) - }, - label = { Text(tab.label) } + LibraryContent( + state = state, + selectedTab = selectedTab, + viewMode = viewMode, + showFilters = showFilters, + organization = organization, + onStateChange = onStateChange, + onImportBooks = onImportBooks, + onOpenBook = onOpenBook, + onToggleSelection = onToggleSelection, + onShowBookInfo = onShowBookInfo, + onEditBook = onEditBook, + onTogglePinned = onTogglePinned, + onRenameShelf = onRenameShelf, + onDeleteShelf = onDeleteShelf, + onRemoveFolder = onRemoveFolder, + modifier = Modifier.weight(1f) + ) + } + } + } else { + Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) { + LibraryTabStrip( + organization = organization, + selectedTab = selectedTab, + onTabSelected = ::selectLibraryTab + ) + LibraryToolbar( + state = state, + viewMode = viewMode, + showFilters = showFilters, + onViewModeChange = { viewMode = it }, + onToggleFilters = { showFilters = !showFilters }, + onStateChange = onStateChange, + onImportBooks = onImportBooks, + onImportFolder = onImportFolder, + onCreateShelf = onCreateShelf, + onCreateSmartShelf = onCreateSmartShelf + ) + LibraryContent( + state = state, + selectedTab = selectedTab, + viewMode = viewMode, + showFilters = showFilters, + organization = organization, + onStateChange = onStateChange, + onImportBooks = onImportBooks, + onOpenBook = onOpenBook, + onToggleSelection = onToggleSelection, + onShowBookInfo = onShowBookInfo, + onEditBook = onEditBook, + onTogglePinned = onTogglePinned, + onRenameShelf = onRenameShelf, + onDeleteShelf = onDeleteShelf, + onRemoveFolder = onRemoveFolder, + modifier = Modifier.weight(1f) + ) + } + } + } + } +} + +@Composable +fun SharedShelvesScreen( + shelves: List, + selectedBookIds: Set, + pinnedBookIds: Set = emptySet(), + onOpenBook: (BookItem) -> Unit, + onToggleSelection: (String) -> Unit, + onShowBookInfo: (BookItem) -> Unit = {}, + onEditBook: (BookItem) -> Unit = {}, + onTogglePinned: (BookItem) -> Unit = {}, + onCreateShelf: () -> Unit = {}, + onCreateSmartShelf: () -> Unit = {}, + onRenameShelf: (Shelf) -> Unit = {}, + onDeleteShelf: (Shelf) -> Unit = {}, + onRemoveFolder: (Shelf) -> Unit = {}, + modifier: Modifier = Modifier +) { + NonReaderScreenScaffold( + title = "Shelves", + subtitle = "Collections, series, tags, and folders", + modifier = modifier, + trailing = { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + OutlinedButton(onClick = onCreateSmartShelf) { + Icon(Icons.Default.FilterList, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Smart") + } + Button(onClick = onCreateShelf) { + Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Shelf") + } + } + } + ) { + ShelfCollection( + shelves = shelves, + selectedBookIds = selectedBookIds, + pinnedBookIds = pinnedBookIds, + onOpenBook = onOpenBook, + onToggleSelection = onToggleSelection, + onShowBookInfo = onShowBookInfo, + onEditBook = onEditBook, + onTogglePinned = onTogglePinned, + onRenameShelf = onRenameShelf, + onDeleteShelf = onDeleteShelf, + onRemoveFolder = onRemoveFolder, + emptyTitle = "No shelves yet", + emptyBody = "Add shelves, tags, or folder metadata to organize your library.", + modifier = Modifier.weight(1f) + ) + } +} + +@Composable +private fun NonReaderScreenScaffold( + title: String, + subtitle: String, + modifier: Modifier = Modifier, + trailing: @Composable () -> Unit = {}, + content: @Composable ColumnScope.() -> Unit +) { + Column( + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) + Text(subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + trailing() + } + content() + } +} + +@Composable +private fun ContinueReadingCard( + book: BookItem, + pinned: Boolean, + onOpenBook: () -> Unit, + onShowBookInfo: () -> Unit, + onEditBook: () -> Unit, + onTogglePinned: () -> Unit +) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f)) + ) { + Row( + modifier = Modifier.padding(18.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(18.dp) + ) { + BookCoverArt( + book = book, + selected = false, + modifier = Modifier.size(width = 112.dp, height = 164.dp) + ) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text("Continue reading", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold) + Text(book.cardTitle(), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text(book.cardAuthor(), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + ProgressSection(book.progressPercentage) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Button(onClick = onOpenBook) { + Icon(Icons.AutoMirrored.Filled.MenuBook, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Read") + } + IconButton(onClick = onTogglePinned) { + Icon( + Icons.Default.PushPin, + contentDescription = if (pinned) "Unpin" else "Pin", + tint = if (pinned) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + IconButton(onClick = onShowBookInfo) { + Icon(Icons.Default.Info, contentDescription = "Info") + } + IconButton(onClick = onEditBook) { + Icon(Icons.Default.Edit, contentDescription = "Edit") + } + } + } + } + } +} + +@Composable +private fun HomeBookShelf( + title: String, + books: List, + selectedBookIds: Set, + pinnedBookIds: Set, + onOpenBook: (BookItem) -> Unit, + onToggleSelection: (String) -> Unit, + onShowBookInfo: (BookItem) -> Unit, + onEditBook: (BookItem) -> Unit, + onTogglePinned: (BookItem) -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + LazyRow(horizontalArrangement = Arrangement.spacedBy(14.dp), contentPadding = PaddingValues(end = 12.dp)) { + items(books, key = { it.id }) { book -> + BookTile( + book = book, + selected = book.id in selectedBookIds, + pinned = book.id in pinnedBookIds, + onOpen = { onOpenBook(book) }, + onToggleSelection = { onToggleSelection(book.id) }, + onShowInfo = { onShowBookInfo(book) }, + onEdit = { onEditBook(book) }, + onTogglePinned = { onTogglePinned(book) }, + modifier = Modifier.width(168.dp) ) } } + } +} + +@Composable +private fun SelectionToolbar( + count: Int, + onClear: () -> Unit, + onRemove: () -> Unit, + onTag: () -> Unit = {}, + onAddToShelf: () -> Unit = {}, + onPin: (() -> Unit)? = null, + pinLabel: String = "Pin", + onInfo: (() -> Unit)? = null +) { + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text("$count selected", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.width(12.dp)) + Row( + modifier = Modifier.weight(1f).horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + onInfo?.let { info -> + TextButton(onClick = info) { + Icon(Icons.Default.Info, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text("Info") + } + } + onPin?.let { pin -> + TextButton(onClick = pin) { + Icon(Icons.Default.PushPin, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text(pinLabel) + } + } + TextButton(onClick = onTag) { + Icon(Icons.Default.Tag, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text("Tag") + } + TextButton(onClick = onAddToShelf) { + Icon(Icons.Default.Folder, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text("Shelf") + } + TextButton(onClick = onClear) { + Text("Clear") + } + TextButton(onClick = onRemove) { + Icon(Icons.Default.Delete, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text("Remove") + } + } + } + } +} + +@Composable +private fun ActiveTabStrip( + openTabs: List, + activeBookId: String?, + onOpenTab: (BookItem) -> Unit, + onCloseTab: (BookItem) -> Unit, + onCloseAllTabs: () -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Active tabs", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.weight(1f)) + TextButton(onClick = onCloseAllTabs) { + Text("Close all") + } + } + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(openTabs, key = { it.id }) { book -> + val active = book.id == activeBookId + Surface( + shape = RoundedCornerShape(8.dp), + color = if (active) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerLow, + contentColor = if (active) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), + modifier = Modifier.widthIn(min = 220.dp, max = 320.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onOpenTab(book) } + .padding(start = 12.dp, top = 8.dp, bottom = 8.dp, end = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.AutoMirrored.Filled.MenuBook, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text( + text = book.cardTitle(), + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + IconButton(onClick = { onCloseTab(book) }, modifier = Modifier.size(32.dp)) { + Icon(Icons.Default.Close, contentDescription = "Close tab", modifier = Modifier.size(18.dp)) + } + } + } + } + } + } +} + +@Composable +private fun RecentLimitMenu( + currentLimit: Int, + onRecentLimitChange: (Int) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + val normalizedLimit = currentLimit.coerceAtLeast(0) + Box { + OutlinedButton(onClick = { expanded = true }) { + Icon(Icons.Default.FormatListNumbered, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text(if (normalizedLimit == 0) "No limit" else "$normalizedLimit") + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + listOf(0, 10, 20, 50, 100).forEach { limit -> + DropdownMenuItem( + text = { Text(if (limit == 0) "No limit" else "$limit files") }, + onClick = { + expanded = false + onRecentLimitChange(limit) + }, + trailingIcon = if (normalizedLimit == limit) { + { Icon(Icons.Default.Check, contentDescription = "Selected") } + } else { + null + } + ) + } + } + } +} + +@Composable +private fun LibraryOrganizationSidebar( + organization: NonReaderLibraryOrganizationModel, + selectedTab: NonReaderLibraryTab, + onTabSelected: (NonReaderLibraryTab) -> Unit, + modifier: Modifier = Modifier +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f)) + ) { + LazyColumn( + modifier = Modifier.fillMaxSize().padding(10.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + item { + Text( + "Browse", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp) + ) + } + item { LibraryNavItem(Icons.Default.Book, "Books", organization.allBooksCount, selectedTab == NonReaderLibraryTab.BOOKS, { onTabSelected(NonReaderLibraryTab.BOOKS) }) } + item { LibraryNavItem(Icons.AutoMirrored.Filled.LibraryBooks, "Shelves", organization.shelfCount, selectedTab == NonReaderLibraryTab.SHELVES, { onTabSelected(NonReaderLibraryTab.SHELVES) }) } + item { LibraryNavItem(Icons.Default.FilterList, "Smart", organization.smartShelfCount, selectedTab == NonReaderLibraryTab.SMART_SHELVES, { onTabSelected(NonReaderLibraryTab.SMART_SHELVES) }) } + item { LibraryNavItem(Icons.Default.Tag, "Tags", organization.tagCount, selectedTab == NonReaderLibraryTab.TAGS, { onTabSelected(NonReaderLibraryTab.TAGS) }) } + item { LibraryNavItem(Icons.Default.Folder, "Folders", organization.folderCount, selectedTab == NonReaderLibraryTab.FOLDERS, { onTabSelected(NonReaderLibraryTab.FOLDERS) }) } + item { + Text( + "Reading", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 10.dp) + ) + } + item { LibraryNavItem(Icons.Default.Book, "Unread", organization.unreadCount, selectedTab == NonReaderLibraryTab.UNREAD, { onTabSelected(NonReaderLibraryTab.UNREAD) }) } + item { LibraryNavItem(Icons.AutoMirrored.Filled.MenuBook, "In progress", organization.inProgressCount, selectedTab == NonReaderLibraryTab.IN_PROGRESS, { onTabSelected(NonReaderLibraryTab.IN_PROGRESS) }) } + item { LibraryNavItem(Icons.Default.Check, "Complete", organization.completedCount, selectedTab == NonReaderLibraryTab.COMPLETED, { onTabSelected(NonReaderLibraryTab.COMPLETED) }) } + } + } +} + +@Composable +private fun LibraryTabStrip( + organization: NonReaderLibraryOrganizationModel, + selectedTab: NonReaderLibraryTab, + onTabSelected: (NonReaderLibraryTab) -> Unit +) { + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + NonReaderLibraryTab.entries.forEach { tab -> + FilterChip( + selected = selectedTab == tab, + onClick = { onTabSelected(tab) }, + leadingIcon = { Icon(tab.icon, contentDescription = null, modifier = Modifier.size(18.dp)) }, + label = { Text("${tab.label} ${tab.count(organization)}") } + ) + } + } +} + +@Composable +private fun LibraryNavItem( + icon: ImageVector, + label: String, + count: Int, + selected: Boolean, + onClick: () -> Unit +) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + color = if (selected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent, + contentColor = if (selected) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, + onClick = onClick + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 9.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Icon(icon, contentDescription = null, modifier = Modifier.size(19.dp)) + Text(label, modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium, fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal) + Text(count.toString(), style = MaterialTheme.typography.labelMedium) + } + } +} + +@Composable +private fun LibraryToolbar( + state: SharedReaderScreenState, + viewMode: BookViewMode, + showFilters: Boolean, + onViewModeChange: (BookViewMode) -> Unit, + onToggleFilters: () -> Unit, + onStateChange: (SharedReaderScreenState) -> Unit, + onImportBooks: () -> Unit, + onImportFolder: () -> Unit, + onCreateShelf: () -> Unit, + onCreateSmartShelf: () -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + OutlinedTextField( + value = state.searchQuery, + onValueChange = { onStateChange(state.reduce(LibraryAction.SearchChanged(it))) }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + label = { Text("Search books, authors, or tags") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + SortMenu(sortOrder = state.sortOrder, onSortOrderChange = { onStateChange(state.reduce(LibraryAction.SortChanged(it))) }) + OutlinedButton(onClick = { onViewModeChange(if (viewMode == BookViewMode.COVERS) BookViewMode.LIST else BookViewMode.COVERS) }) { + Icon(if (viewMode == BookViewMode.COVERS) Icons.AutoMirrored.Filled.List else Icons.Default.Book, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text(if (viewMode == BookViewMode.COVERS) "List" else "Covers") + } + OutlinedButton(onClick = onToggleFilters) { + Icon(Icons.Default.FilterList, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text(if (showFilters) "Hide filters" else "Filters") + if (state.libraryFilters.isActive) { + Spacer(Modifier.width(8.dp)) + Surface( + shape = RoundedCornerShape(50), + color = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) { + Text( + state.libraryFilters.activeFilterBadge(), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 7.dp, vertical = 2.dp) + ) + } + } + } + OutlinedButton(onClick = onCreateShelf) { + Icon(Icons.Default.Folder, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Shelf") + } + OutlinedButton(onClick = onCreateSmartShelf) { + Icon(Icons.Default.FilterList, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Smart") + } + OutlinedButton(onClick = onImportFolder) { + Icon(Icons.Default.Folder, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Folder") + } + Button(onClick = onImportBooks) { + Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Import") + } + } + } +} + +@Composable +private fun LibraryContent( + state: SharedReaderScreenState, + selectedTab: NonReaderLibraryTab, + viewMode: BookViewMode, + showFilters: Boolean, + organization: NonReaderLibraryOrganizationModel, + onStateChange: (SharedReaderScreenState) -> Unit, + onImportBooks: () -> Unit, + onOpenBook: (BookItem) -> Unit, + onToggleSelection: (String) -> Unit, + onShowBookInfo: (BookItem) -> Unit, + onEditBook: (BookItem) -> Unit, + onTogglePinned: (BookItem) -> Unit, + onRenameShelf: (Shelf) -> Unit, + onDeleteShelf: (Shelf) -> Unit, + onRemoveFolder: (Shelf) -> Unit, + modifier: Modifier = Modifier +) { + Column(modifier, verticalArrangement = Arrangement.spacedBy(12.dp)) { + if (showFilters) { + LibraryFilterPanel( + state = state, + organization = organization, + onStateChange = onStateChange + ) + } else if (state.libraryFilters.isActive || state.searchQuery.isNotBlank()) { + LibraryFilterSummary(state = state, onStateChange = onStateChange) + } when (selectedTab) { - NonReaderLibraryTab.BOOKS -> { - LibrarySearchAndFilters( - state = state, - onStateChange = onStateChange - ) - + NonReaderLibraryTab.BOOKS, + NonReaderLibraryTab.UNREAD, + NonReaderLibraryTab.IN_PROGRESS, + NonReaderLibraryTab.COMPLETED -> { + val books = state.libraryBooks if (books.isEmpty()) { SharedEmptyState( icon = { Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(56.dp)) }, @@ -254,37 +946,76 @@ fun SharedLibraryScreen( } else { BookGrid( books = books, + viewMode = viewMode, selectedBookIds = state.selectedBookIds, + pinnedBookIds = state.pinnedLibraryBookIds, onOpenBook = onOpenBook, onToggleSelection = onToggleSelection, onShowBookInfo = onShowBookInfo, onEditBook = onEditBook, + onTogglePinned = onTogglePinned, modifier = Modifier.weight(1f) ) } } NonReaderLibraryTab.SHELVES -> ShelfCollection( - shelves = shelves, + shelves = state.shelves.filter { it.type != ShelfType.FOLDER && it.type != ShelfType.TAG && it.type != ShelfType.SMART }, selectedBookIds = state.selectedBookIds, + pinnedBookIds = state.pinnedLibraryBookIds, onOpenBook = onOpenBook, onToggleSelection = onToggleSelection, onShowBookInfo = onShowBookInfo, onEditBook = onEditBook, + onTogglePinned = onTogglePinned, onRenameShelf = onRenameShelf, onDeleteShelf = onDeleteShelf, + onRemoveFolder = onRemoveFolder, emptyTitle = "No shelves yet", - emptyBody = "Series, tags, and imported metadata will appear here.", + emptyBody = "Manual shelves and series collections will appear here.", + modifier = Modifier.weight(1f) + ) + + NonReaderLibraryTab.SMART_SHELVES -> ShelfCollection( + shelves = state.shelves.filter { it.type == ShelfType.SMART }, + selectedBookIds = state.selectedBookIds, + pinnedBookIds = state.pinnedLibraryBookIds, + onOpenBook = onOpenBook, + onToggleSelection = onToggleSelection, + onShowBookInfo = onShowBookInfo, + onEditBook = onEditBook, + onTogglePinned = onTogglePinned, + onRenameShelf = onRenameShelf, + onDeleteShelf = onDeleteShelf, + emptyTitle = "No smart shelves yet", + emptyBody = "Create smart shelves to collect books by rules.", + modifier = Modifier.weight(1f) + ) + + NonReaderLibraryTab.TAGS -> ShelfCollection( + shelves = state.shelves.filter { it.type == ShelfType.TAG && it.bookCount > 0 }, + selectedBookIds = state.selectedBookIds, + pinnedBookIds = state.pinnedLibraryBookIds, + onOpenBook = onOpenBook, + onToggleSelection = onToggleSelection, + onShowBookInfo = onShowBookInfo, + onEditBook = onEditBook, + onTogglePinned = onTogglePinned, + emptyTitle = "No tags yet", + emptyBody = "Tags added to books will appear here.", modifier = Modifier.weight(1f) ) NonReaderLibraryTab.FOLDERS -> ShelfCollection( - shelves = folderShelves, + shelves = state.shelves.filter { it.type == ShelfType.FOLDER && it.parentShelfId == null }, selectedBookIds = state.selectedBookIds, + pinnedBookIds = state.pinnedLibraryBookIds, onOpenBook = onOpenBook, onToggleSelection = onToggleSelection, onShowBookInfo = onShowBookInfo, onEditBook = onEditBook, + onTogglePinned = onTogglePinned, + onRemoveFolder = onRemoveFolder, emptyTitle = "No folders yet", emptyBody = "Imported folder metadata will appear here when available.", modifier = Modifier.weight(1f) @@ -294,179 +1025,145 @@ fun SharedLibraryScreen( } @Composable -fun SharedShelvesScreen( - shelves: List, - selectedBookIds: Set, - onOpenBook: (BookItem) -> Unit, - onToggleSelection: (String) -> Unit, - onShowBookInfo: (BookItem) -> Unit = {}, - onEditBook: (BookItem) -> Unit = {}, - onCreateShelf: () -> Unit = {}, - onRenameShelf: (Shelf) -> Unit = {}, - onDeleteShelf: (Shelf) -> Unit = {}, - modifier: Modifier = Modifier -) { - NonReaderScreenScaffold( - title = "Shelves", - subtitle = "Series, folders, and tags from library metadata", - modifier = modifier, - trailing = { - Button(onClick = onCreateShelf) { - Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(8.dp)) - Text("Shelf") - } - } - ) { - ShelfCollection( - shelves = shelves, - selectedBookIds = selectedBookIds, - onOpenBook = onOpenBook, - onToggleSelection = onToggleSelection, - onShowBookInfo = onShowBookInfo, - onEditBook = onEditBook, - onRenameShelf = onRenameShelf, - onDeleteShelf = onDeleteShelf, - emptyTitle = "No shelves yet", - emptyBody = "Add metadata or import folders later to populate shelves.", - modifier = Modifier.weight(1f) - ) - } -} - -@Composable -private fun NonReaderScreenScaffold( - title: String, - subtitle: String, - modifier: Modifier = Modifier, - trailing: @Composable () -> Unit = {}, - content: @Composable ColumnScope.() -> Unit -) { - Column( - modifier = modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surface) - .padding(24.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Column(modifier = Modifier.weight(1f)) { - Text(title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) - Text(subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - trailing() - } - content() - } -} - -@Composable -private fun SelectionToolbar( - count: Int, - onClear: () -> Unit, - onRemove: () -> Unit, - onTag: () -> Unit = {}, - onAddToShelf: () -> Unit = {} -) { - Surface( - shape = RoundedCornerShape(8.dp), - color = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer - ) { - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text("$count selected", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) - Spacer(Modifier.weight(1f)) - TextButton(onClick = onTag) { - Icon(Icons.Default.Tag, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(6.dp)) - Text("Tag") - } - TextButton(onClick = onAddToShelf) { - Icon(Icons.Default.Folder, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(6.dp)) - Text("Shelf") - } - TextButton(onClick = onClear) { - Text("Clear") - } - TextButton(onClick = onRemove) { - Icon(Icons.Default.Delete, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(6.dp)) - Text("Remove") - } - } - } -} - -@Composable -private fun LibrarySearchAndFilters( +private fun LibraryFilterSummary( state: SharedReaderScreenState, onStateChange: (SharedReaderScreenState) -> Unit ) { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - OutlinedTextField( - value = state.searchQuery, - onValueChange = { onStateChange(state.reduce(LibraryAction.SearchChanged(it))) }, - leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, - label = { Text("Search books, authors, or tags") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - - Row( - modifier = Modifier.horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (state.searchQuery.isNotBlank()) { AssistChip( - onClick = {}, - label = { Text("Filters") }, - leadingIcon = { Icon(Icons.Default.FilterList, contentDescription = null, modifier = Modifier.size(18.dp)) } + onClick = { onStateChange(state.reduce(LibraryAction.SearchChanged(""))) }, + label = { Text("Search: ${state.searchQuery}") }, + trailingIcon = { Icon(Icons.Default.Close, contentDescription = "Clear search", modifier = Modifier.size(16.dp)) } ) - listOf(FileType.PDF, FileType.EPUB, FileType.MOBI, FileType.DOCX, FileType.TXT).forEach { type -> - FilterChip( - selected = type in state.libraryFilters.fileTypes, - onClick = { - val updated = if (type in state.libraryFilters.fileTypes) state.libraryFilters.fileTypes - type else state.libraryFilters.fileTypes + type - onStateChange(state.reduce(LibraryAction.FiltersChanged(state.libraryFilters.copy(fileTypes = updated)))) - }, - label = { Text(type.name) } - ) + } + if (state.libraryFilters.fileTypes.isNotEmpty()) { + AssistChip( + onClick = { onStateChange(state.reduce(LibraryAction.FiltersChanged(state.libraryFilters.copy(fileTypes = emptySet())))) }, + label = { Text("Types: ${state.libraryFilters.fileTypes.joinToString { it.name }}") }, + trailingIcon = { Icon(Icons.Default.Close, contentDescription = "Clear file types", modifier = Modifier.size(16.dp)) } + ) + } + if (state.libraryFilters.sourceFolders.isNotEmpty()) { + AssistChip( + onClick = { onStateChange(state.reduce(LibraryAction.FiltersChanged(state.libraryFilters.copy(sourceFolders = emptySet())))) }, + label = { Text("Sources: ${state.libraryFilters.sourceFolders.size}") }, + trailingIcon = { Icon(Icons.Default.Close, contentDescription = "Clear sources", modifier = Modifier.size(16.dp)) } + ) + } + if (state.libraryFilters.readStatus != ReadStatusFilter.ALL) { + AssistChip( + onClick = { onStateChange(state.reduce(LibraryAction.FiltersChanged(state.libraryFilters.copy(readStatus = ReadStatusFilter.ALL)))) }, + label = { Text("Status: ${state.libraryFilters.readStatus.label}") }, + trailingIcon = { Icon(Icons.Default.Close, contentDescription = "Clear status", modifier = Modifier.size(16.dp)) } + ) + } + if (state.libraryFilters.tagIds.isNotEmpty()) { + AssistChip( + onClick = { onStateChange(state.reduce(LibraryAction.FiltersChanged(state.libraryFilters.copy(tagIds = emptySet())))) }, + label = { Text("Tags: ${state.libraryFilters.tagIds.size}") }, + trailingIcon = { Icon(Icons.Default.Close, contentDescription = "Clear tags", modifier = Modifier.size(16.dp)) } + ) + } + TextButton(onClick = { onStateChange(state.reduce(LibraryAction.SearchChanged("")).reduce(LibraryAction.FiltersChanged(LibraryFilters()))) }) { + Text("Clear all") + } + } +} + +@Composable +@OptIn(ExperimentalLayoutApi::class) +private fun LibraryFilterPanel( + state: SharedReaderScreenState, + organization: NonReaderLibraryOrganizationModel, + onStateChange: (SharedReaderScreenState) -> Unit +) { + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f)) + ) { + Column(Modifier.fillMaxWidth().padding(14.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Filters", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Spacer(Modifier.weight(1f)) + if (state.libraryFilters.isActive || state.searchQuery.isNotBlank()) { + TextButton(onClick = { onStateChange(state.reduce(LibraryAction.SearchChanged("")).reduce(LibraryAction.FiltersChanged(LibraryFilters()))) }) { + Text("Clear") + } + } } - ReadStatusFilter.entries.filterNot { it == ReadStatusFilter.ALL }.forEach { status -> - FilterChip( - selected = state.libraryFilters.readStatus == status, - onClick = { - onStateChange( - state.reduce( - LibraryAction.FiltersChanged( - state.libraryFilters.copy( - readStatus = if (state.libraryFilters.readStatus == status) ReadStatusFilter.ALL else status + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + organization.availableFileTypes.forEach { type -> + FilterChip( + selected = type in state.libraryFilters.fileTypes, + onClick = { + val updated = if (type in state.libraryFilters.fileTypes) state.libraryFilters.fileTypes - type else state.libraryFilters.fileTypes + type + onStateChange(state.reduce(LibraryAction.FiltersChanged(state.libraryFilters.copy(fileTypes = updated)))) + }, + label = { Text(type.name) } + ) + } + if (organization.hasInAppBooks) { + FilterChip( + selected = IN_APP_STORAGE_SOURCE in state.libraryFilters.sourceFolders, + onClick = { + val updated = if (IN_APP_STORAGE_SOURCE in state.libraryFilters.sourceFolders) { + state.libraryFilters.sourceFolders - IN_APP_STORAGE_SOURCE + } else { + state.libraryFilters.sourceFolders + IN_APP_STORAGE_SOURCE + } + onStateChange(state.reduce(LibraryAction.FiltersChanged(state.libraryFilters.copy(sourceFolders = updated)))) + }, + label = { Text("In-app") } + ) + } + state.syncedFolders.forEach { folder -> + FilterChip( + selected = folder.uriString in state.libraryFilters.sourceFolders, + onClick = { + val updated = if (folder.uriString in state.libraryFilters.sourceFolders) { + state.libraryFilters.sourceFolders - folder.uriString + } else { + state.libraryFilters.sourceFolders + folder.uriString + } + onStateChange(state.reduce(LibraryAction.FiltersChanged(state.libraryFilters.copy(sourceFolders = updated)))) + }, + leadingIcon = { Icon(Icons.Default.Folder, contentDescription = null, modifier = Modifier.size(16.dp)) }, + label = { Text(folder.name) } + ) + } + ReadStatusFilter.entries.filterNot { it == ReadStatusFilter.ALL }.forEach { status -> + FilterChip( + selected = state.libraryFilters.readStatus == status, + onClick = { + onStateChange( + state.reduce( + LibraryAction.FiltersChanged( + state.libraryFilters.copy( + readStatus = if (state.libraryFilters.readStatus == status) ReadStatusFilter.ALL else status + ) ) ) ) - ) - }, - label = { Text(status.label) } - ) - } - state.allTags.forEach { tag -> - FilterChip( - selected = tag.id in state.libraryFilters.tagIds, - onClick = { - val updated = if (tag.id in state.libraryFilters.tagIds) state.libraryFilters.tagIds - tag.id else state.libraryFilters.tagIds + tag.id - onStateChange(state.reduce(LibraryAction.FiltersChanged(state.libraryFilters.copy(tagIds = updated)))) - }, - leadingIcon = { Icon(Icons.Default.Tag, contentDescription = null, modifier = Modifier.size(16.dp)) }, - label = { Text(tag.name) } - ) - } - if (state.libraryFilters.isActive || state.searchQuery.isNotBlank()) { - TextButton(onClick = { onStateChange(state.reduce(LibraryAction.SearchChanged("")).reduce(LibraryAction.FiltersChanged(LibraryFilters()))) }) { - Text("Clear") + }, + label = { Text(status.label) } + ) + } + state.allTags.forEach { tag -> + FilterChip( + selected = tag.id in state.libraryFilters.tagIds, + onClick = { + val updated = if (tag.id in state.libraryFilters.tagIds) state.libraryFilters.tagIds - tag.id else state.libraryFilters.tagIds + tag.id + onStateChange(state.reduce(LibraryAction.FiltersChanged(state.libraryFilters.copy(tagIds = updated)))) + }, + leadingIcon = { Icon(Icons.Default.Tag, contentDescription = null, modifier = Modifier.size(16.dp)) }, + label = { Text(tag.name) } + ) } } } @@ -477,168 +1174,318 @@ private fun LibrarySearchAndFilters( @OptIn(ExperimentalFoundationApi::class) private fun BookGrid( books: List, + viewMode: BookViewMode, selectedBookIds: Set, + pinnedBookIds: Set, onOpenBook: (BookItem) -> Unit, onToggleSelection: (String) -> Unit, onShowBookInfo: (BookItem) -> Unit, onEditBook: (BookItem) -> Unit, + onTogglePinned: (BookItem) -> Unit, modifier: Modifier = Modifier ) { - LazyVerticalGrid( - columns = GridCells.Adaptive(340.dp), - modifier = modifier.fillMaxWidth(), - contentPadding = PaddingValues(bottom = 24.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - items(books, key = { it.id }) { book -> - BookCard( - book = book, - selected = book.id in selectedBookIds, - onOpen = { onOpenBook(book) }, - onToggleSelection = { onToggleSelection(book.id) }, - onShowInfo = { onShowBookInfo(book) }, - onEdit = { onEditBook(book) } - ) + if (viewMode == BookViewMode.LIST) { + LazyColumn( + modifier = modifier.fillMaxWidth(), + contentPadding = PaddingValues(bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + items(books, key = { it.id }) { book -> + BookListItem( + book = book, + selected = book.id in selectedBookIds, + pinned = book.id in pinnedBookIds, + onOpen = { onOpenBook(book) }, + onToggleSelection = { onToggleSelection(book.id) }, + onShowInfo = { onShowBookInfo(book) }, + onEdit = { onEditBook(book) }, + onTogglePinned = { onTogglePinned(book) } + ) + } + } + } else { + LazyVerticalGrid( + columns = GridCells.Adaptive(164.dp), + modifier = modifier.fillMaxWidth(), + contentPadding = PaddingValues(bottom = 24.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalArrangement = Arrangement.spacedBy(18.dp) + ) { + items(books, key = { it.id }) { book -> + BookTile( + book = book, + selected = book.id in selectedBookIds, + pinned = book.id in pinnedBookIds, + onOpen = { onOpenBook(book) }, + onToggleSelection = { onToggleSelection(book.id) }, + onShowInfo = { onShowBookInfo(book) }, + onEdit = { onEditBook(book) }, + onTogglePinned = { onTogglePinned(book) } + ) + } } } } @Composable @OptIn(ExperimentalFoundationApi::class) -private fun BookCard( +private fun BookTile( book: BookItem, selected: Boolean, + pinned: Boolean, onOpen: () -> Unit, onToggleSelection: () -> Unit, onShowInfo: () -> Unit, - onEdit: () -> Unit + onEdit: () -> Unit, + onTogglePinned: () -> Unit, + modifier: Modifier = Modifier ) { + var menuExpanded by remember { mutableStateOf(false) } Card( - colors = CardDefaults.cardColors( - containerColor = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface - ), - border = if (selected) BorderStroke(1.dp, MaterialTheme.colorScheme.primary) else BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + border = if (selected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f)), shape = RoundedCornerShape(8.dp), - modifier = Modifier.fillMaxWidth().heightIn(min = 156.dp) + modifier = modifier + .fillMaxWidth() + .combinedClickable(onClick = onOpen, onLongClick = onToggleSelection) ) { - Row( - modifier = Modifier - .fillMaxWidth() - .combinedClickable(onClick = onOpen, onLongClick = onToggleSelection) - .padding(14.dp), - verticalAlignment = Alignment.Top - ) { - BookCover(book = book, selected = selected) - Spacer(Modifier.width(14.dp)) - Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row(verticalAlignment = Alignment.Top) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = book.cardTitle(), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - Text( - text = book.cardAuthor(), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + Column { + Box { + BookCoverArt( + book = book, + selected = selected, + modifier = Modifier.fillMaxWidth().aspectRatio(0.68f) + ) + Row( + modifier = Modifier.align(Alignment.TopStart).padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(5.dp) + ) { + if (pinned) { + OverlayBadge(Icons.Default.PushPin, "Pinned") } - Row { - IconButton(onClick = onShowInfo, modifier = Modifier.size(36.dp)) { - Icon(Icons.Default.Info, contentDescription = "Info") - } - IconButton(onClick = onEdit, modifier = Modifier.size(36.dp)) { - Icon(Icons.Default.Edit, contentDescription = "Edit") - } - IconButton(onClick = onToggleSelection, modifier = Modifier.size(36.dp)) { - Icon( - imageVector = if (selected) Icons.Default.Check else Icons.AutoMirrored.Filled.List, - contentDescription = if (selected) "Clear selection" else "Select" - ) - } - } - } - - Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { - TypeBadge(book.type) if (book.sourceFolder != null) { - StatusBadge(Icons.Default.Folder, "Folder") + OverlayBadge(Icons.Default.Folder, "Folder") } if (book.isOpdsStream()) { - StatusBadge(Icons.Default.Cloud, "Stream") + OverlayBadge(Icons.Default.Cloud, "Stream") } } - - ProgressSection(book.progressPercentage) - - if (book.tags.isNotEmpty()) { - LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - items(book.tags, key = { it.id }) { tag -> - TagChip(tag.name, tag.color) - } + Box(Modifier.align(Alignment.TopEnd).padding(4.dp)) { + IconButton(onClick = { menuExpanded = true }, modifier = Modifier.size(34.dp)) { + Icon(Icons.Default.MoreVert, contentDescription = "Book actions") + } + BookActionMenu( + expanded = menuExpanded, + pinned = pinned, + selected = selected, + onDismiss = { menuExpanded = false }, + onTogglePinned = onTogglePinned, + onShowInfo = onShowInfo, + onEdit = onEdit, + onToggleSelection = onToggleSelection + ) + } + TypeBadge(book.type, modifier = Modifier.align(Alignment.BottomEnd).padding(8.dp)) + val percent = progressPercentValue(book.progressPercentage) + if (percent > 0) { + Surface( + modifier = Modifier.align(Alignment.BottomStart).padding(8.dp), + shape = RoundedCornerShape(50), + color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.94f), + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) { + Text("$percent%", style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold, modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp)) } } } + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(book.cardTitle(), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, maxLines = 2, minLines = 2, overflow = TextOverflow.Ellipsis) + Text(book.cardAuthor(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, minLines = 1, overflow = TextOverflow.Ellipsis) + } } } } @Composable -private fun BookCover(book: BookItem, selected: Boolean) { - val color = fileTypeColor(book.type) +@OptIn(ExperimentalFoundationApi::class) +private fun BookListItem( + book: BookItem, + selected: Boolean, + pinned: Boolean, + onOpen: () -> Unit, + onToggleSelection: () -> Unit, + onShowInfo: () -> Unit, + onEdit: () -> Unit, + onTogglePinned: () -> Unit +) { + var menuExpanded by remember { mutableStateOf(false) } Surface( - modifier = Modifier.size(width = 64.dp, height = 94.dp), + modifier = Modifier + .fillMaxWidth() + .combinedClickable(onClick = onOpen, onLongClick = onToggleSelection), + shape = RoundedCornerShape(8.dp), + color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f)) + ) { + Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) { + BookCoverArt(book = book, selected = selected, modifier = Modifier.size(width = 58.dp, height = 84.dp)) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(book.cardTitle(), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(book.cardAuthor(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + TypeBadge(book.type) + if (pinned) StatusBadge(Icons.Default.PushPin, "Pinned") + if (book.sourceFolder != null) StatusBadge(Icons.Default.Folder, "Folder") + if (book.isOpdsStream()) StatusBadge(Icons.Default.Cloud, "Stream") + } + ProgressSection(book.progressPercentage) + } + Box { + IconButton(onClick = { menuExpanded = true }) { + Icon(Icons.Default.MoreVert, contentDescription = "Book actions") + } + BookActionMenu( + expanded = menuExpanded, + pinned = pinned, + selected = selected, + onDismiss = { menuExpanded = false }, + onTogglePinned = onTogglePinned, + onShowInfo = onShowInfo, + onEdit = onEdit, + onToggleSelection = onToggleSelection + ) + } + } + } +} + +@Composable +private fun BookActionMenu( + expanded: Boolean, + pinned: Boolean, + selected: Boolean, + onDismiss: () -> Unit, + onTogglePinned: () -> Unit, + onShowInfo: () -> Unit, + onEdit: () -> Unit, + onToggleSelection: () -> Unit +) { + DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) { + DropdownMenuItem( + leadingIcon = { Icon(Icons.Default.PushPin, contentDescription = null) }, + text = { Text(if (pinned) "Unpin" else "Pin") }, + onClick = { + onDismiss() + onTogglePinned() + } + ) + DropdownMenuItem( + leadingIcon = { Icon(Icons.Default.Info, contentDescription = null) }, + text = { Text("Info") }, + onClick = { + onDismiss() + onShowInfo() + } + ) + DropdownMenuItem( + leadingIcon = { Icon(Icons.Default.Edit, contentDescription = null) }, + text = { Text("Edit") }, + onClick = { + onDismiss() + onEdit() + } + ) + DropdownMenuItem( + leadingIcon = { Icon(if (selected) Icons.Default.Check else Icons.AutoMirrored.Filled.List, contentDescription = null) }, + text = { Text(if (selected) "Clear selection" else "Select") }, + onClick = { + onDismiss() + onToggleSelection() + } + ) + } +} + +@Composable +private fun BookCoverArt( + book: BookItem, + selected: Boolean, + modifier: Modifier = Modifier +) { + val color = fileTypeColor(book.type) + val coverPath = book.coverImagePath?.takeIf { it.isNotBlank() } + Surface( + modifier = modifier, color = color, contentColor = Color.White, - shape = RoundedCornerShape(7.dp), + shape = RoundedCornerShape(8.dp), tonalElevation = 2.dp ) { Box(contentAlignment = Alignment.Center) { - Icon(Icons.Default.Book, contentDescription = null, modifier = Modifier.size(30.dp)) - if (selected) { - Surface( - modifier = Modifier.align(Alignment.TopEnd).padding(6.dp), - shape = RoundedCornerShape(50), - color = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary - ) { - Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.padding(3.dp).size(12.dp)) - } - } + Icon(Icons.Default.Book, contentDescription = null, modifier = Modifier.size(34.dp)) Text( text = book.type.name, style = MaterialTheme.typography.labelSmall.copy(letterSpacing = 1.sp), fontWeight = FontWeight.Bold, - modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 8.dp) + modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 10.dp) ) + if (coverPath != null) { + LocalBookCoverImage( + path = coverPath, + contentDescription = book.cardTitle(), + modifier = Modifier.matchParentSize() + ) + } + if (selected) { + Box( + modifier = Modifier + .matchParentSize() + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.18f)), + contentAlignment = Alignment.Center + ) { + Surface( + shape = RoundedCornerShape(50), + color = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ) { + Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.padding(8.dp).size(28.dp)) + } + } + } } } } @Composable -private fun TypeBadge(type: FileType) { +private fun OverlayBadge(icon: ImageVector, label: String) { Surface( shape = RoundedCornerShape(50), - color = MaterialTheme.colorScheme.secondaryContainer, + color = Color.Black.copy(alpha = 0.52f), + contentColor = Color.White + ) { + Icon(icon, contentDescription = label, modifier = Modifier.padding(5.dp).size(13.dp)) + } +} + +@Composable +private fun TypeBadge(type: FileType, modifier: Modifier = Modifier) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(50), + color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.95f), contentColor = MaterialTheme.colorScheme.onSecondaryContainer ) { Text( type.name, style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold, - modifier = Modifier.padding(horizontal = 9.dp, vertical = 4.dp) + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp) ) } } @Composable -private fun StatusBadge(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String) { +private fun StatusBadge(icon: ImageVector, label: String) { Surface( shape = RoundedCornerShape(50), color = MaterialTheme.colorScheme.surfaceVariant, @@ -690,12 +1537,15 @@ private fun ProgressSection(progressPercentage: Float?) { private fun ShelfCollection( shelves: List, selectedBookIds: Set, + pinnedBookIds: Set, onOpenBook: (BookItem) -> Unit, onToggleSelection: (String) -> Unit, onShowBookInfo: (BookItem) -> Unit, onEditBook: (BookItem) -> Unit, + onTogglePinned: (BookItem) -> Unit, onRenameShelf: (Shelf) -> Unit = {}, onDeleteShelf: (Shelf) -> Unit = {}, + onRemoveFolder: (Shelf) -> Unit = {}, emptyTitle: String, emptyBody: String, modifier: Modifier = Modifier @@ -713,18 +1563,21 @@ private fun ShelfCollection( LazyColumn( modifier = modifier.fillMaxWidth(), contentPadding = PaddingValues(bottom = 24.dp), - verticalArrangement = Arrangement.spacedBy(18.dp) + verticalArrangement = Arrangement.spacedBy(16.dp) ) { items(shelves, key = { it.id }) { shelf -> ShelfSection( shelf = shelf, selectedBookIds = selectedBookIds, + pinnedBookIds = pinnedBookIds, onOpenBook = onOpenBook, onToggleSelection = onToggleSelection, onShowBookInfo = onShowBookInfo, onEditBook = onEditBook, + onTogglePinned = onTogglePinned, onRenameShelf = onRenameShelf, - onDeleteShelf = onDeleteShelf + onDeleteShelf = onDeleteShelf, + onRemoveFolder = onRemoveFolder ) } } @@ -734,52 +1587,91 @@ private fun ShelfCollection( private fun ShelfSection( shelf: Shelf, selectedBookIds: Set, + pinnedBookIds: Set, onOpenBook: (BookItem) -> Unit, onToggleSelection: (String) -> Unit, onShowBookInfo: (BookItem) -> Unit, onEditBook: (BookItem) -> Unit, + onTogglePinned: (BookItem) -> Unit, onRenameShelf: (Shelf) -> Unit, - onDeleteShelf: (Shelf) -> Unit + onDeleteShelf: (Shelf) -> Unit, + onRemoveFolder: (Shelf) -> Unit ) { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = when (shelf.type) { - ShelfType.FOLDER -> Icons.Default.Folder - ShelfType.TAG -> Icons.Default.Tag - else -> Icons.AutoMirrored.Filled.LibraryBooks - }, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f)) + ) { + Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + CollectionCoverStack(shelf) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon( + imageVector = shelf.type.icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Text(shelf.name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + Text("${shelf.bookCount} books", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + if ((shelf.type == ShelfType.MANUAL || shelf.type == ShelfType.SMART) && shelf.id != "unshelved") { + IconButton(onClick = { onRenameShelf(shelf) }, modifier = Modifier.size(34.dp)) { + Icon(Icons.Default.Edit, contentDescription = "Rename shelf", modifier = Modifier.size(18.dp)) + } + IconButton(onClick = { onDeleteShelf(shelf) }, modifier = Modifier.size(34.dp)) { + Icon(Icons.Default.Delete, contentDescription = "Delete shelf", modifier = Modifier.size(18.dp)) + } + } else if (shelf.type == ShelfType.FOLDER && shelf.parentShelfId == null) { + IconButton(onClick = { onRemoveFolder(shelf) }, modifier = Modifier.size(34.dp)) { + Icon(Icons.Default.Delete, contentDescription = "Remove folder", modifier = Modifier.size(18.dp)) + } + } + } + if (shelf.books.isNotEmpty()) { + LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + items(shelf.books.take(12), key = { it.id }) { book -> + BookTile( + book = book, + selected = book.id in selectedBookIds, + pinned = book.id in pinnedBookIds, + onOpen = { onOpenBook(book) }, + onToggleSelection = { onToggleSelection(book.id) }, + onShowInfo = { onShowBookInfo(book) }, + onEdit = { onEditBook(book) }, + onTogglePinned = { onTogglePinned(book) }, + modifier = Modifier.width(148.dp) + ) + } + } + } + } + } +} + +@Composable +private fun CollectionCoverStack(shelf: Shelf) { + Box(Modifier.size(width = 54.dp, height = 66.dp)) { + val colors = listOf( + MaterialTheme.colorScheme.primary.copy(alpha = 0.28f), + MaterialTheme.colorScheme.secondary.copy(alpha = 0.32f), + MaterialTheme.colorScheme.tertiary.copy(alpha = 0.36f) + ) + colors.forEachIndexed { index, color -> + Box( + modifier = Modifier + .size(width = 38.dp, height = 56.dp) + .align(Alignment.Center) + .padding(start = (index * 4).dp, top = (index * 2).dp) + .clip(RoundedCornerShape(7.dp)) + .background(color) + .border(1.dp, MaterialTheme.colorScheme.surface, RoundedCornerShape(7.dp)) ) - Spacer(Modifier.width(8.dp)) - Text(shelf.name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) - Spacer(Modifier.width(8.dp)) - AssistChip(onClick = {}, label = { Text("${shelf.bookCount}") }) - if (shelf.type == ShelfType.MANUAL && shelf.id != "unshelved") { - Spacer(Modifier.weight(1f)) - IconButton(onClick = { onRenameShelf(shelf) }, modifier = Modifier.size(32.dp)) { - Icon(Icons.Default.Edit, contentDescription = "Rename shelf", modifier = Modifier.size(18.dp)) - } - IconButton(onClick = { onDeleteShelf(shelf) }, modifier = Modifier.size(32.dp)) { - Icon(Icons.Default.Delete, contentDescription = "Delete shelf", modifier = Modifier.size(18.dp)) - } - } - } - LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - items(shelf.books, key = { it.id }) { book -> - Box(modifier = Modifier.width(360.dp)) { - BookCard( - book = book, - selected = book.id in selectedBookIds, - onOpen = { onOpenBook(book) }, - onToggleSelection = { onToggleSelection(book.id) }, - onShowInfo = { onShowBookInfo(book) }, - onEdit = { onEditBook(book) } - ) - } - } } + Icon(shelf.type.icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.align(Alignment.Center).size(22.dp)) } } @@ -790,7 +1682,7 @@ private fun SortMenu( ) { var expanded by remember { mutableStateOf(false) } Box { - Button(onClick = { expanded = true }) { + OutlinedButton(onClick = { expanded = true }) { Icon(Icons.AutoMirrored.Filled.Sort, contentDescription = null, modifier = Modifier.size(18.dp)) Spacer(Modifier.width(8.dp)) Text(sortOrder.label) @@ -802,6 +1694,11 @@ private fun SortMenu( onClick = { expanded = false onSortOrderChange(order) + }, + trailingIcon = if (sortOrder == order) { + { Icon(Icons.Default.Check, contentDescription = "Selected") } + } else { + null } ) } @@ -816,13 +1713,15 @@ private fun SharedEmptyState( body: String, modifier: Modifier = Modifier, actionLabel: String? = null, - onAction: (() -> Unit)? = null + onAction: (() -> Unit)? = null, + secondaryActionLabel: String? = null, + onSecondaryAction: (() -> Unit)? = null ) { Surface( modifier = modifier.fillMaxWidth().fillMaxHeight(), shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.surface, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f)) ) { Box(modifier = Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) { @@ -841,8 +1740,15 @@ private fun SharedEmptyState( ) if (actionLabel != null && onAction != null) { Spacer(Modifier.height(6.dp)) - Button(onClick = onAction) { - Text(actionLabel) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Button(onClick = onAction) { + Text(actionLabel) + } + if (secondaryActionLabel != null && onSecondaryAction != null) { + OutlinedButton(onClick = onSecondaryAction) { + Text(secondaryActionLabel) + } + } } } } @@ -854,9 +1760,48 @@ private val NonReaderLibraryTab.label: String get() = when (this) { NonReaderLibraryTab.BOOKS -> "Books" NonReaderLibraryTab.SHELVES -> "Shelves" + NonReaderLibraryTab.SMART_SHELVES -> "Smart" + NonReaderLibraryTab.TAGS -> "Tags" NonReaderLibraryTab.FOLDERS -> "Folders" + NonReaderLibraryTab.UNREAD -> "Unread" + NonReaderLibraryTab.IN_PROGRESS -> "In progress" + NonReaderLibraryTab.COMPLETED -> "Complete" } +private val NonReaderLibraryTab.icon: ImageVector + get() = when (this) { + NonReaderLibraryTab.BOOKS -> Icons.Default.Book + NonReaderLibraryTab.SHELVES -> Icons.AutoMirrored.Filled.LibraryBooks + NonReaderLibraryTab.SMART_SHELVES -> Icons.Default.FilterList + NonReaderLibraryTab.TAGS -> Icons.Default.Tag + NonReaderLibraryTab.FOLDERS -> Icons.Default.Folder + NonReaderLibraryTab.UNREAD -> Icons.Default.Book + NonReaderLibraryTab.IN_PROGRESS -> Icons.AutoMirrored.Filled.MenuBook + NonReaderLibraryTab.COMPLETED -> Icons.Default.Check + } + +private fun NonReaderLibraryTab.count(organization: NonReaderLibraryOrganizationModel): Int { + return when (this) { + NonReaderLibraryTab.BOOKS -> organization.allBooksCount + NonReaderLibraryTab.SHELVES -> organization.shelfCount + NonReaderLibraryTab.SMART_SHELVES -> organization.smartShelfCount + NonReaderLibraryTab.TAGS -> organization.tagCount + NonReaderLibraryTab.FOLDERS -> organization.folderCount + NonReaderLibraryTab.UNREAD -> organization.unreadCount + NonReaderLibraryTab.IN_PROGRESS -> organization.inProgressCount + NonReaderLibraryTab.COMPLETED -> organization.completedCount + } +} + +private fun NonReaderLibraryTab.readStatusFilter(): ReadStatusFilter? { + return when (this) { + NonReaderLibraryTab.UNREAD -> ReadStatusFilter.UNREAD + NonReaderLibraryTab.IN_PROGRESS -> ReadStatusFilter.IN_PROGRESS + NonReaderLibraryTab.COMPLETED -> ReadStatusFilter.COMPLETED + else -> null + } +} + private val SortOrder.label: String get() = when (this) { SortOrder.RECENT -> "Recent" @@ -876,6 +1821,22 @@ private val ReadStatusFilter.label: String ReadStatusFilter.COMPLETED -> "Complete" } +private val ShelfType.icon: ImageVector + get() = when (this) { + ShelfType.FOLDER -> Icons.Default.Folder + ShelfType.TAG -> Icons.Default.Tag + ShelfType.SMART -> Icons.Default.FilterList + else -> Icons.AutoMirrored.Filled.LibraryBooks + } + +private fun LibraryFilters.activeFilterBadge(): String { + val count = fileTypes.size + + sourceFolders.size + + tagIds.size + + if (readStatus == ReadStatusFilter.ALL) 0 else 1 + return count.toString() +} + private fun fileTypeColor(type: FileType): Color { return when (type) { FileType.PDF -> Color(0xFF9C4146) diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/ReaderWorkspaceModels.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/ReaderWorkspaceModels.kt new file mode 100644 index 0000000..0670b2d --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/ReaderWorkspaceModels.kt @@ -0,0 +1,235 @@ +package com.aryan.reader.shared.ui + +import com.aryan.reader.shared.PdfDisplayMode +import com.aryan.reader.shared.ReaderAutoScrollState +import com.aryan.reader.shared.ReaderExtrasState +import com.aryan.reader.shared.ReaderTool +import com.aryan.reader.shared.ReaderToolbarPreferences +import com.aryan.reader.shared.pdf.PdfInkTool +import com.aryan.reader.shared.pdf.SharedPdfReaderState +import com.aryan.reader.shared.reader.ReaderSessionState + +enum class ReaderWorkspaceKind { + EPUB, + PDF +} + +enum class ReaderWorkspaceLeftSection(val title: String) { + CONTENTS("Contents"), + SEARCH("Search"), + BOOKMARKS("Bookmarks"), + NOTES("Notes") +} + +enum class ReaderWorkspaceInspectorSection(val title: String) { + APPEARANCE("Appearance"), + TOOLS("Tools"), + AI_TTS("AI/TTS"), + TOOLBAR("Toolbar") +} + +enum class ReaderWorkspaceTopAction { + CONTENTS, + SEARCH, + BOOKMARK, + APPEARANCE, + READ_ALOUD, + AI, + AUTO_SCROLL, + TOOLS +} + +enum class ReaderWorkspaceBottomAction { + PAGE_SLIDER, + PREVIOUS, + NEXT +} + +data class ReaderWorkspaceChromeModel( + val preferAutoHide: Boolean, + val forceVisible: Boolean, + val forceVisibleReasons: Set = emptySet() +) + +data class ReaderWorkspaceModel( + val kind: ReaderWorkspaceKind, + val leftSections: List, + val inspectorSections: List, + val topActions: List, + val bottomActions: List, + val defaultPdfInteractionMode: PdfInkTool? = null, + val chrome: ReaderWorkspaceChromeModel +) + +fun epubReaderWorkspaceModel( + session: ReaderSessionState, + toolbarPreferences: ReaderToolbarPreferences, + extrasState: ReaderExtrasState, + aiAvailable: Boolean +): ReaderWorkspaceModel { + val preferences = toolbarPreferences.sanitized() + val leftSections = buildList { + if (preferences.isVisible(ReaderTool.TOC)) add(ReaderWorkspaceLeftSection.CONTENTS) + if (preferences.isVisible(ReaderTool.SEARCH)) add(ReaderWorkspaceLeftSection.SEARCH) + if (preferences.isVisible(ReaderTool.BOOKMARK)) add(ReaderWorkspaceLeftSection.BOOKMARKS) + if (preferences.isVisible(ReaderTool.BOOKMARK)) add(ReaderWorkspaceLeftSection.NOTES) + } + val inspectorSections = buildList { + if (preferences.isVisible(ReaderTool.THEME) || preferences.isVisible(ReaderTool.FORMAT)) { + add(ReaderWorkspaceInspectorSection.APPEARANCE) + } + if (preferences.isVisible(ReaderTool.READING_MODE) || preferences.isVisible(ReaderTool.VISUAL_OPTIONS)) { + add(ReaderWorkspaceInspectorSection.TOOLS) + } + if ( + preferences.isVisible(ReaderTool.DICTIONARY) || + preferences.isVisible(ReaderTool.AI_FEATURES) || + preferences.isVisible(ReaderTool.TTS_CONTROLS) || + preferences.isVisible(ReaderTool.AUTO_SCROLL) + ) { + add(ReaderWorkspaceInspectorSection.AI_TTS) + } + add(ReaderWorkspaceInspectorSection.TOOLBAR) + }.distinct() + val topActions = buildList { + if (ReaderWorkspaceLeftSection.CONTENTS in leftSections) add(ReaderWorkspaceTopAction.CONTENTS) + if (preferences.isVisible(ReaderTool.SEARCH)) add(ReaderWorkspaceTopAction.SEARCH) + if (preferences.isVisible(ReaderTool.BOOKMARK)) add(ReaderWorkspaceTopAction.BOOKMARK) + if (ReaderWorkspaceInspectorSection.APPEARANCE in inspectorSections) add(ReaderWorkspaceTopAction.APPEARANCE) + if (preferences.isVisible(ReaderTool.TTS_CONTROLS)) add(ReaderWorkspaceTopAction.READ_ALOUD) + if (aiAvailable && preferences.isVisible(ReaderTool.AI_FEATURES)) add(ReaderWorkspaceTopAction.AI) + if (preferences.isVisible(ReaderTool.AUTO_SCROLL)) add(ReaderWorkspaceTopAction.AUTO_SCROLL) + if (inspectorSections.isNotEmpty()) add(ReaderWorkspaceTopAction.TOOLS) + }.distinct() + val bottomActions = buildList { + if (preferences.isVisible(ReaderTool.SLIDER)) add(ReaderWorkspaceBottomAction.PAGE_SLIDER) + add(ReaderWorkspaceBottomAction.PREVIOUS) + add(ReaderWorkspaceBottomAction.NEXT) + } + return ReaderWorkspaceModel( + kind = ReaderWorkspaceKind.EPUB, + leftSections = leftSections, + inspectorSections = inspectorSections, + topActions = topActions, + bottomActions = bottomActions, + chrome = readerWorkspaceChromeModel( + preferAutoHide = true, + searchActive = session.isSearchActive, + leftPanelOpen = false, + inspectorOpen = false, + annotationEditing = false, + richTextEditing = false, + loading = false, + errorMessage = null, + autoScroll = extrasState.autoScroll, + ttsBusy = extrasState.cloudTts.isLoading || extrasState.cloudTts.isPlaying || extrasState.cloudTts.isPaused + ) + ) +} + +fun readerWorkspaceQuickActionTools( + toolbarPreferences: ReaderToolbarPreferences, + bottom: Boolean, + aiAvailable: Boolean +): List { + val preferences = toolbarPreferences.sanitized() + return preferences.orderedVisibleTools() + .filter { tool -> + tool.supportsDesktopQuickAction && + preferences.isBottom(tool) == bottom && + (tool != ReaderTool.AI_FEATURES || aiAvailable) + } +} + +fun pdfReaderWorkspaceModel( + state: SharedPdfReaderState, + displayMode: PdfDisplayMode, + hasContents: Boolean, + hasBookmarks: Boolean, + hasAnnotations: Boolean, + hasEmbeddedComments: Boolean, + searchActive: Boolean, + annotationEditing: Boolean, + richTextEditing: Boolean, + loading: Boolean, + errorMessage: String?, + extrasState: ReaderExtrasState, + aiAvailable: Boolean +): ReaderWorkspaceModel { + val leftSections = buildList { + add(ReaderWorkspaceLeftSection.CONTENTS) + add(ReaderWorkspaceLeftSection.SEARCH) + if (hasBookmarks) add(ReaderWorkspaceLeftSection.BOOKMARKS) + if (hasContents || hasAnnotations || hasEmbeddedComments) add(ReaderWorkspaceLeftSection.NOTES) + }.distinct() + val inspectorSections = listOf( + ReaderWorkspaceInspectorSection.APPEARANCE, + ReaderWorkspaceInspectorSection.TOOLS, + ReaderWorkspaceInspectorSection.AI_TTS, + ReaderWorkspaceInspectorSection.TOOLBAR + ) + val topActions = buildList { + add(ReaderWorkspaceTopAction.CONTENTS) + add(ReaderWorkspaceTopAction.SEARCH) + add(ReaderWorkspaceTopAction.BOOKMARK) + add(ReaderWorkspaceTopAction.APPEARANCE) + add(ReaderWorkspaceTopAction.READ_ALOUD) + if (aiAvailable) add(ReaderWorkspaceTopAction.AI) + add(ReaderWorkspaceTopAction.AUTO_SCROLL) + add(ReaderWorkspaceTopAction.TOOLS) + } + return ReaderWorkspaceModel( + kind = ReaderWorkspaceKind.PDF, + leftSections = leftSections, + inspectorSections = inspectorSections, + topActions = topActions, + bottomActions = listOf( + ReaderWorkspaceBottomAction.PAGE_SLIDER, + ReaderWorkspaceBottomAction.PREVIOUS, + ReaderWorkspaceBottomAction.NEXT + ), + defaultPdfInteractionMode = null, + chrome = readerWorkspaceChromeModel( + preferAutoHide = true, + searchActive = searchActive || state.searchQuery.isNotBlank(), + leftPanelOpen = false, + inspectorOpen = false, + annotationEditing = annotationEditing || state.selectedAnnotationId != null || state.selectedTool != PdfInkTool.PEN, + richTextEditing = richTextEditing, + loading = loading, + errorMessage = errorMessage, + autoScroll = extrasState.autoScroll, + ttsBusy = extrasState.cloudTts.isLoading || extrasState.cloudTts.isPlaying || extrasState.cloudTts.isPaused + ) + ) +} + +fun readerWorkspaceChromeModel( + preferAutoHide: Boolean, + searchActive: Boolean, + leftPanelOpen: Boolean, + inspectorOpen: Boolean, + annotationEditing: Boolean, + richTextEditing: Boolean, + loading: Boolean, + errorMessage: String?, + autoScroll: ReaderAutoScrollState, + ttsBusy: Boolean +): ReaderWorkspaceChromeModel { + val reasons = buildSet { + if (searchActive) add("search") + if (leftPanelOpen) add("left-panel") + if (inspectorOpen) add("inspector") + if (annotationEditing) add("annotation") + if (richTextEditing) add("rich-text") + if (loading) add("loading") + if (!errorMessage.isNullOrBlank()) add("error") + if (autoScroll.sanitized().enabled) add("auto-scroll") + if (ttsBusy) add("tts") + } + return ReaderWorkspaceChromeModel( + preferAutoHide = preferAutoHide, + forceVisible = reasons.isNotEmpty(), + forceVisibleReasons = reasons + ) +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/ReaderWorkspaceShell.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/ReaderWorkspaceShell.kt new file mode 100644 index 0000000..b992903 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/ReaderWorkspaceShell.kt @@ -0,0 +1,224 @@ +package com.aryan.reader.shared.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Tune +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay + +@Composable +fun ReaderWorkspaceShell( + model: ReaderWorkspaceModel, + title: String, + subtitle: String, + progressLabel: String, + modifier: Modifier = Modifier, + topActions: @Composable RowScope.() -> Unit = {}, + leftSidebar: @Composable () -> Unit, + rightInspector: @Composable () -> Unit, + bottomBar: @Composable () -> Unit, + content: @Composable BoxScope.() -> Unit +) { + var leftPanelOpen by remember(model.kind) { mutableStateOf(true) } + var rightPanelOpen by remember(model.kind) { mutableStateOf(true) } + var chromeVisible by remember(model.kind) { mutableStateOf(true) } + val forceChrome = model.chrome.forceVisible || leftPanelOpen || rightPanelOpen + + LaunchedEffect(forceChrome, model.chrome.preferAutoHide, model.chrome.forceVisibleReasons) { + chromeVisible = true + if (model.chrome.preferAutoHide && !forceChrome) { + delay(3_200) + chromeVisible = false + } + } + + BoxWithConstraints( + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + ) { + val wide = maxWidth >= 1120.dp + val showChrome = chromeVisible || forceChrome || !model.chrome.preferAutoHide + LaunchedEffect(wide, leftPanelOpen, rightPanelOpen) { + if (!wide && leftPanelOpen && rightPanelOpen) { + rightPanelOpen = false + } + } + + Column( + modifier = Modifier.fillMaxSize().padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + if (showChrome) { + ReaderWorkspaceTopChrome( + title = title, + subtitle = subtitle, + progressLabel = progressLabel, + wide = wide, + leftPanelOpen = leftPanelOpen, + rightPanelOpen = rightPanelOpen, + onToggleLeftPanel = { leftPanelOpen = !leftPanelOpen }, + onToggleRightPanel = { rightPanelOpen = !rightPanelOpen }, + topActions = topActions + ) + } + + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxSize(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (wide && leftPanelOpen && model.leftSections.isNotEmpty()) { + leftSidebar() + } + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + ) { + content() + } + if (wide && rightPanelOpen && model.inspectorSections.isNotEmpty()) { + rightInspector() + } + } + + if (!wide && leftPanelOpen && model.leftSections.isNotEmpty()) { + ReaderWorkspaceOverlayPanel( + title = "Reader", + onClose = { leftPanelOpen = false }, + modifier = Modifier.align(Alignment.CenterStart).width(320.dp) + ) { + leftSidebar() + } + } + if (!wide && rightPanelOpen && model.inspectorSections.isNotEmpty()) { + ReaderWorkspaceOverlayPanel( + title = "Tools", + onClose = { rightPanelOpen = false }, + modifier = Modifier.align(Alignment.CenterEnd).width(360.dp) + ) { + rightInspector() + } + } + } + + if (showChrome) { + bottomBar() + } else { + Box( + Modifier + .fillMaxWidth() + .height(20.dp) + .clickable { chromeVisible = true } + ) + } + } + } +} + +@Composable +private fun ReaderWorkspaceTopChrome( + title: String, + subtitle: String, + progressLabel: String, + wide: Boolean, + leftPanelOpen: Boolean, + rightPanelOpen: Boolean, + onToggleLeftPanel: () -> Unit, + onToggleRightPanel: () -> Unit, + topActions: @Composable RowScope.() -> Unit +) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 2.dp + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + IconButton(onClick = onToggleLeftPanel) { + Icon(Icons.Default.Menu, contentDescription = if (leftPanelOpen) "Hide reader navigation" else "Show reader navigation") + } + Column(Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + Text(progressLabel, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant) + Row(horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically) { + topActions() + } + IconButton(onClick = onToggleRightPanel) { + Icon(Icons.Default.Tune, contentDescription = if (rightPanelOpen) "Hide reader tools" else "Show reader tools") + } + if (!wide) { + TextButton(onClick = onToggleRightPanel, contentPadding = PaddingValues(horizontal = 8.dp)) { + Text("Tools") + } + } + } + } +} + +@Composable +private fun ReaderWorkspaceOverlayPanel( + title: String, + onClose: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + Surface( + modifier = modifier.fillMaxHeight().padding(vertical = 8.dp), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 8.dp, + shadowElevation = 8.dp + ) { + Column(Modifier.fillMaxSize().padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) + IconButton(onClick = onClose) { + Icon(Icons.Default.Close, contentDescription = "Close") + } + } + content() + } + } +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedAppShell.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedAppShell.kt new file mode 100644 index 0000000..08c893d --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedAppShell.kt @@ -0,0 +1,499 @@ +package com.aryan.reader.shared.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.LibraryBooks +import androidx.compose.material.icons.automirrored.filled.MenuBook +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Cloud +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.Feedback +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.ImportExport +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Palette +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Sync +import androidx.compose.material.icons.filled.TextFields +import androidx.compose.material3.Button +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationRail +import androidx.compose.material3.NavigationRailItem +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.aryan.reader.shared.AppContrastOption +import com.aryan.reader.shared.AppThemeMode +import com.aryan.reader.shared.CustomAppTheme + +enum class SharedAppTab { + HOME, + LIBRARY, + SHELVES, + CATALOGS, + READER, + CUSTOM_FONTS, + SUPPORT, + FEEDBACK, + ABOUT +} + +@Composable +fun SharedAppShell( + selectedTab: SharedAppTab, + snackbarHostState: SnackbarHostState, + appThemeMode: AppThemeMode = AppThemeMode.SYSTEM, + appContrastOption: AppContrastOption = AppContrastOption.STANDARD, + appTextDimFactorLight: Float = 1.0f, + appTextDimFactorDark: Float = 1.0f, + appSeedColor: Color? = null, + customAppThemes: List = emptyList(), + isTabsEnabled: Boolean = false, + onTabSelected: (SharedAppTab) -> Unit, + onImportFiles: () -> Unit, + onImportFolder: () -> Unit = {}, + onSyncRequested: () -> Unit, + onAppThemeModeChange: (AppThemeMode) -> Unit = {}, + onAppContrastOptionChange: (AppContrastOption) -> Unit = {}, + onAppTextDimFactorLightChange: (Float) -> Unit = {}, + onAppTextDimFactorDarkChange: (Float) -> Unit = {}, + onAppSeedColorChange: (Color?) -> Unit = {}, + onCustomAppThemeAdded: (CustomAppTheme) -> Unit = {}, + onCustomAppThemeDeleted: (String) -> Unit = {}, + onTabsEnabledChange: (Boolean) -> Unit = {}, + onAiSettingsRequested: (() -> Unit)? = null, + content: @Composable (SharedAppTab) -> Unit +) { + val shellModel = remember(selectedTab, onAiSettingsRequested != null) { + sharedAppShellModel( + selectedTab = selectedTab, + aiSettingsAvailable = onAiSettingsRequested != null + ) + } + var showToolsPanel by remember { mutableStateOf(false) } + var showAppThemeSettings by remember { mutableStateOf(false) } + + Scaffold( + containerColor = MaterialTheme.colorScheme.background, + snackbarHost = { SnackbarHost(snackbarHostState) } + ) { padding -> + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .padding(padding) + ) { + val useSidebar = maxWidth >= 900.dp + Row(Modifier.fillMaxSize()) { + if (useSidebar) { + SharedAppSidebar( + selectedTab = shellModel.selectedPrimaryTab, + primaryTabs = shellModel.primaryTabs, + onTabSelected = onTabSelected, + onToolsClick = { showToolsPanel = true } + ) + } else { + SharedAppCompactRail( + selectedTab = shellModel.selectedPrimaryTab, + primaryTabs = shellModel.primaryTabs, + onTabSelected = onTabSelected, + onToolsClick = { showToolsPanel = true } + ) + } + + Box( + Modifier + .weight(1f) + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + ) { + content(selectedTab) + } + } + + if (showToolsPanel) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.24f)) + .clickable { showToolsPanel = false } + ) + SharedToolsPanel( + modifier = Modifier + .align(Alignment.CenterEnd) + .fillMaxHeight() + .widthIn(max = 390.dp), + isTabsEnabled = isTabsEnabled, + aiSettingsAvailable = onAiSettingsRequested != null, + onClose = { showToolsPanel = false }, + onImportFiles = { + showToolsPanel = false + onImportFiles() + }, + onImportFolder = { + showToolsPanel = false + onImportFolder() + }, + onSyncRequested = { + showToolsPanel = false + onSyncRequested() + }, + onAppThemeRequested = { + showToolsPanel = false + showAppThemeSettings = true + }, + onAiSettingsRequested = { + showToolsPanel = false + onAiSettingsRequested?.invoke() + }, + onOpenTab = { tab -> + showToolsPanel = false + onTabSelected(tab) + }, + onTabsEnabledChange = onTabsEnabledChange + ) + } + } + } + + if (showAppThemeSettings) { + SharedAppThemeSettingsDialog( + appThemeMode = appThemeMode, + appContrastOption = appContrastOption, + appTextDimFactorLight = appTextDimFactorLight, + appTextDimFactorDark = appTextDimFactorDark, + appSeedColor = appSeedColor, + customAppThemes = customAppThemes, + onThemeModeChanged = onAppThemeModeChange, + onContrastOptionChanged = onAppContrastOptionChange, + onTextDimFactorLightChanged = onAppTextDimFactorLightChange, + onTextDimFactorDarkChanged = onAppTextDimFactorDarkChange, + onSeedColorChanged = onAppSeedColorChange, + onCustomThemeAdded = onCustomAppThemeAdded, + onCustomThemeDeleted = onCustomAppThemeDeleted, + onDismiss = { showAppThemeSettings = false } + ) + } +} + +@Composable +private fun SharedAppSidebar( + selectedTab: SharedAppTab, + primaryTabs: List, + onTabSelected: (SharedAppTab) -> Unit, + onToolsClick: () -> Unit +) { + Surface( + modifier = Modifier + .width(244.dp) + .fillMaxHeight(), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 1.dp + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Column(Modifier.padding(horizontal = 10.dp, vertical = 12.dp)) { + Text("Episteme", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text("Desktop library", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + primaryTabs.forEach { tab -> + SharedSidebarNavItem( + tab = tab, + selected = selectedTab == tab, + onClick = { onTabSelected(tab) } + ) + } + Spacer(Modifier.weight(1f)) + HorizontalDivider() + SharedSidebarButton( + label = "Tools", + icon = Icons.Default.Settings, + onClick = onToolsClick + ) + } + } +} + +@Composable +private fun SharedAppCompactRail( + selectedTab: SharedAppTab, + primaryTabs: List, + onTabSelected: (SharedAppTab) -> Unit, + onToolsClick: () -> Unit +) { + NavigationRail(containerColor = MaterialTheme.colorScheme.surface) { + primaryTabs.forEach { tab -> + NavigationRailItem( + selected = selectedTab == tab, + onClick = { onTabSelected(tab) }, + icon = { Icon(tab.icon, contentDescription = null) }, + label = { Text(tab.label) } + ) + } + Spacer(Modifier.weight(1f)) + IconButton(onClick = onToolsClick) { + Icon(Icons.Default.Settings, contentDescription = "Tools") + } + } +} + +@Composable +private fun SharedSidebarNavItem( + tab: SharedAppTab, + selected: Boolean, + onClick: () -> Unit +) { + val containerColor = if (selected) { + MaterialTheme.colorScheme.secondaryContainer + } else { + Color.Transparent + } + val contentColor = if (selected) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + color = containerColor, + contentColor = contentColor, + onClick = onClick + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon(tab.icon, contentDescription = null, modifier = Modifier.size(21.dp)) + Text(tab.label, style = MaterialTheme.typography.bodyMedium, fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal) + } + } +} + +@Composable +private fun SharedSidebarButton( + label: String, + icon: ImageVector, + onClick: () -> Unit +) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + color = Color.Transparent, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + onClick = onClick + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon(icon, contentDescription = null, modifier = Modifier.size(21.dp)) + Text(label, style = MaterialTheme.typography.bodyMedium) + } + } +} + +@Composable +private fun SharedToolsPanel( + modifier: Modifier, + isTabsEnabled: Boolean, + aiSettingsAvailable: Boolean, + onClose: () -> Unit, + onImportFiles: () -> Unit, + onImportFolder: () -> Unit, + onSyncRequested: () -> Unit, + onAppThemeRequested: () -> Unit, + onAiSettingsRequested: () -> Unit, + onOpenTab: (SharedAppTab) -> Unit, + onTabsEnabledChange: (Boolean) -> Unit +) { + Surface( + modifier = modifier, + color = MaterialTheme.colorScheme.surface, + tonalElevation = 8.dp, + shadowElevation = 8.dp + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text("Tools", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) + Text("Import, sync, and app settings", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + IconButton(onClick = onClose) { + Icon(Icons.Default.Close, contentDescription = "Close tools") + } + } + + SharedToolsSection("Library") { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { + Button(onClick = onImportFiles, modifier = Modifier.weight(1f)) { + Icon(Icons.Default.ImportExport, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Files") + } + OutlinedButton(onClick = onImportFolder, modifier = Modifier.weight(1f)) { + Icon(Icons.Default.Folder, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Folder") + } + } + FilledTonalButton(onClick = onSyncRequested, modifier = Modifier.fillMaxWidth()) { + Icon(Icons.Default.Sync, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Sync folders") + } + } + + SharedToolsSection("Appearance") { + SharedToolRow( + icon = Icons.Default.Palette, + title = "App theme", + onClick = onAppThemeRequested + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 2.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(Modifier.weight(1f)) { + Text("Active reader tabs", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) + Text(if (isTabsEnabled) "Enabled" else "Disabled", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Switch( + checked = isTabsEnabled, + onCheckedChange = onTabsEnabledChange + ) + } + } + + SharedToolsSection("Settings") { + if (aiSettingsAvailable) { + SharedToolRow(Icons.Default.Settings, "AI keys and models", onAiSettingsRequested) + } + SharedToolRow(Icons.Default.TextFields, "Custom fonts") { onOpenTab(SharedAppTab.CUSTOM_FONTS) } + } + + SharedToolsSection("Project") { + SharedToolRow(Icons.Default.Feedback, "Help & feedback") { onOpenTab(SharedAppTab.FEEDBACK) } + SharedToolRow(Icons.Default.Favorite, "Support project") { onOpenTab(SharedAppTab.SUPPORT) } + SharedToolRow(Icons.Default.Info, "About Episteme") { onOpenTab(SharedAppTab.ABOUT) } + } + + Spacer(Modifier.height(12.dp)) + } + } +} + +@Composable +private fun SharedToolsSection( + title: String, + content: @Composable ColumnScope.() -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(title, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold) + content() + } +} + +@Composable +private fun SharedToolRow( + icon: ImageVector, + title: String, + onClick: () -> Unit +) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + onClick = onClick + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon(icon, contentDescription = null, modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary) + Text(title, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } +} + +private val SharedAppTab.label: String + get() = when (this) { + SharedAppTab.HOME -> "Home" + SharedAppTab.LIBRARY -> "Library" + SharedAppTab.SHELVES -> "Shelves" + SharedAppTab.CATALOGS -> "OPDS" + SharedAppTab.READER -> "Reader" + SharedAppTab.CUSTOM_FONTS -> "Custom fonts" + SharedAppTab.SUPPORT -> "Support" + SharedAppTab.FEEDBACK -> "Feedback" + SharedAppTab.ABOUT -> "About" + } + +private val SharedAppTab.icon: ImageVector + get() = when (this) { + SharedAppTab.HOME -> Icons.Default.Home + SharedAppTab.LIBRARY -> Icons.AutoMirrored.Filled.LibraryBooks + SharedAppTab.SHELVES -> Icons.Default.Folder + SharedAppTab.CATALOGS -> Icons.Default.Cloud + SharedAppTab.READER -> Icons.AutoMirrored.Filled.MenuBook + SharedAppTab.CUSTOM_FONTS -> Icons.Default.TextFields + SharedAppTab.SUPPORT -> Icons.Default.Favorite + SharedAppTab.FEEDBACK -> Icons.Default.Feedback + SharedAppTab.ABOUT -> Icons.Default.Info + } diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedAppThemeSettings.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedAppThemeSettings.kt new file mode 100644 index 0000000..bcf9b28 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedAppThemeSettings.kt @@ -0,0 +1,980 @@ +package com.aryan.reader.shared.ui + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.drag +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.Typography +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aryan.reader.shared.AppContrastOption +import com.aryan.reader.shared.AppThemeMode +import com.aryan.reader.shared.CustomAppTheme +import com.materialkolor.PaletteStyle +import com.materialkolor.dynamicColorScheme +import kotlin.math.roundToInt +import kotlin.random.Random + +private val SharedLightColorScheme = lightColorScheme( + primary = Color(0xFF4C662B), + onPrimary = Color(0xFFFFFFFF), + primaryContainer = Color(0xFFCDEDA3), + onPrimaryContainer = Color(0xFF354E16), + secondary = Color(0xFF586249), + onSecondary = Color(0xFFFFFFFF), + secondaryContainer = Color(0xFFDCE7C8), + onSecondaryContainer = Color(0xFF404A33), + tertiary = Color(0xFF386663), + onTertiary = Color(0xFFFFFFFF), + tertiaryContainer = Color(0xFFBCECE7), + onTertiaryContainer = Color(0xFF1F4E4B), + error = Color(0xFFBA1A1A), + onError = Color(0xFFFFFFFF), + errorContainer = Color(0xFFFFDAD6), + onErrorContainer = Color(0xFF93000A), + background = Color(0xFFF9FAEF), + onBackground = Color(0xFF1A1C16), + surface = Color(0xFFF9FAEF), + onSurface = Color(0xFF1A1C16), + surfaceVariant = Color(0xFFE1E4D5), + onSurfaceVariant = Color(0xFF44483D), + outline = Color(0xFF75796C), + outlineVariant = Color(0xFFC5C8BA), + scrim = Color(0xFF000000), + inverseSurface = Color(0xFF2F312A), + inverseOnSurface = Color(0xFFF1F2E6), + inversePrimary = Color(0xFFB1D18A), + surfaceDim = Color(0xFFDADBD0), + surfaceBright = Color(0xFFF9FAEF), + surfaceContainerLowest = Color(0xFFFFFFFF), + surfaceContainerLow = Color(0xFFF3F4E9), + surfaceContainer = Color(0xFFEEEFE3), + surfaceContainerHigh = Color(0xFFE8E9DE), + surfaceContainerHighest = Color(0xFFE2E3D8) +) + +private val SharedDarkColorScheme = darkColorScheme( + primary = Color(0xFFB1D18A), + onPrimary = Color(0xFF1F3701), + primaryContainer = Color(0xFF354E16), + onPrimaryContainer = Color(0xFFCDEDA3), + secondary = Color(0xFFBFCBAD), + onSecondary = Color(0xFF2A331E), + secondaryContainer = Color(0xFF404A33), + onSecondaryContainer = Color(0xFFDCE7C8), + tertiary = Color(0xFFA0D0CB), + onTertiary = Color(0xFF003735), + tertiaryContainer = Color(0xFF1F4E4B), + onTertiaryContainer = Color(0xFFBCECE7), + error = Color(0xFFFFB4AB), + onError = Color(0xFF690005), + errorContainer = Color(0xFF93000A), + onErrorContainer = Color(0xFFFFDAD6), + background = Color(0xFF12140E), + onBackground = Color(0xFFE2E3D8), + surface = Color(0xFF12140E), + onSurface = Color(0xFFE2E3D8), + surfaceVariant = Color(0xFF44483D), + onSurfaceVariant = Color(0xFFC5C8BA), + outline = Color(0xFF8F9285), + outlineVariant = Color(0xFF44483D), + scrim = Color(0xFF000000), + inverseSurface = Color(0xFFE2E3D8), + inverseOnSurface = Color(0xFF2F312A), + inversePrimary = Color(0xFF4C662B), + surfaceDim = Color(0xFF12140E), + surfaceBright = Color(0xFF383A32), + surfaceContainerLowest = Color(0xFF0C0F09), + surfaceContainerLow = Color(0xFF1A1C16), + surfaceContainer = Color(0xFF1E201A), + surfaceContainerHigh = Color(0xFF282B24), + surfaceContainerHighest = Color(0xFF33362E) +) + +@Composable +fun SharedAppTheme( + appThemeMode: AppThemeMode, + appContrastOption: AppContrastOption, + appTextDimFactorLight: Float, + appTextDimFactorDark: Float, + appSeedColor: Color?, + content: @Composable () -> Unit +) { + val darkTheme = resolveSharedAppDarkTheme(appThemeMode, isSystemInDarkTheme()) + val textDimFactor = sharedAppTextDimFactor(darkTheme, appTextDimFactorLight, appTextDimFactorDark) + val colorScheme = remember(darkTheme, appContrastOption, textDimFactor, appSeedColor) { + sharedAppColorScheme( + darkTheme = darkTheme, + seedColor = appSeedColor, + contrastLevel = appContrastOption.value, + textDimFactor = textDimFactor + ) + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography(), + content = content + ) +} + +fun resolveSharedAppDarkTheme(mode: AppThemeMode, isSystemDark: Boolean): Boolean { + return when (mode) { + AppThemeMode.LIGHT -> false + AppThemeMode.DARK -> true + AppThemeMode.SYSTEM -> isSystemDark + } +} + +fun sharedAppTextDimFactor( + darkTheme: Boolean, + lightFactor: Float, + darkFactor: Float +): Float { + return if (darkTheme) darkFactor else lightFactor +} + +fun sharedAppColorScheme( + darkTheme: Boolean, + seedColor: Color?, + contrastLevel: Double, + textDimFactor: Float +): ColorScheme { + val baseColorScheme = seedColor?.let { + dynamicColorScheme( + seedColor = it, + isDark = darkTheme, + contrastLevel = contrastLevel, + style = PaletteStyle.Fidelity + ) + } ?: if (darkTheme) { + SharedDarkColorScheme + } else { + SharedLightColorScheme + } + + return baseColorScheme.withTextDimFactor(textDimFactor) +} + +@Composable +fun SharedAppThemeSettingsDialog( + appThemeMode: AppThemeMode, + appContrastOption: AppContrastOption, + appTextDimFactorLight: Float, + appTextDimFactorDark: Float, + appSeedColor: Color?, + customAppThemes: List, + onThemeModeChanged: (AppThemeMode) -> Unit, + onContrastOptionChanged: (AppContrastOption) -> Unit, + onTextDimFactorLightChanged: (Float) -> Unit, + onTextDimFactorDarkChanged: (Float) -> Unit, + onSeedColorChanged: (Color?) -> Unit, + onCustomThemeAdded: (CustomAppTheme) -> Unit, + onCustomThemeDeleted: (String) -> Unit, + onDismiss: () -> Unit +) { + var showCreateDialog by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("App theme", fontWeight = FontWeight.Bold) }, + text = { + Column( + modifier = Modifier + .widthIn(max = 620.dp) + .heightIn(max = 620.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + SettingsLabel("Appearance") + SegmentedControl( + values = AppThemeMode.entries, + selectedValue = appThemeMode, + label = { it.label }, + onValueSelected = onThemeModeChanged + ) + + SettingsLabel("Contrast") + SegmentedControl( + values = AppContrastOption.entries, + selectedValue = appContrastOption, + label = { it.label }, + onValueSelected = onContrastOptionChanged + ) + + if (appThemeMode == AppThemeMode.SYSTEM) { + TextBrightnessSlider( + label = "Text brightness (Light)", + value = appTextDimFactorLight, + onValueChange = onTextDimFactorLightChanged + ) + TextBrightnessSlider( + label = "Text brightness (Dark)", + value = appTextDimFactorDark, + onValueChange = onTextDimFactorDarkChanged + ) + } else { + TextBrightnessSlider( + label = "Text brightness", + value = if (appThemeMode == AppThemeMode.DARK) appTextDimFactorDark else appTextDimFactorLight, + onValueChange = if (appThemeMode == AppThemeMode.DARK) onTextDimFactorDarkChanged else onTextDimFactorLightChanged + ) + } + + SettingsLabel("Color scheme") + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + ThemeSwatch( + color = MaterialTheme.colorScheme.primary, + selected = appSeedColor == null, + label = "Dynamic", + onClick = { onSeedColorChanged(null) } + ) + AppThemePresets.forEach { preset -> + ThemeSwatch( + color = preset.color, + selected = appSeedColor == preset.color, + label = preset.name, + onClick = { onSeedColorChanged(preset.color) } + ) + } + } + + HorizontalDivider() + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + SettingsLabel("My themes") + IconButton(onClick = { showCreateDialog = true }, modifier = Modifier.size(32.dp)) { + Icon(Icons.Default.Add, contentDescription = "Add custom theme") + } + } + + if (customAppThemes.isEmpty()) { + Text( + "No custom themes yet", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + customAppThemes.forEach { theme -> + ThemeSwatch( + color = theme.seedColor, + selected = appSeedColor == theme.seedColor, + label = theme.name, + onClick = { onSeedColorChanged(theme.seedColor) }, + onDelete = { onCustomThemeDeleted(theme.id) } + ) + } + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text("Done") + } + } + ) + + if (showCreateDialog) { + SharedCreateAppThemeDialog( + onDismiss = { showCreateDialog = false }, + onSave = { name, color -> + onCustomThemeAdded( + CustomAppTheme( + id = Random.nextLong().toString(), + name = name.ifBlank { "Custom" }, + seedColor = color + ) + ) + showCreateDialog = false + } + ) + } +} + +@Composable +private fun SettingsLabel(label: String) { + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold + ) +} + +@Composable +private fun SegmentedControl( + values: List, + selectedValue: T, + label: (T) -> String, + onValueSelected: (T) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .background(MaterialTheme.colorScheme.surfaceContainerHigh, RoundedCornerShape(24.dp)) + .padding(4.dp) + ) { + values.forEach { value -> + val selected = selectedValue == value + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .clip(RoundedCornerShape(20.dp)) + .background(if (selected) MaterialTheme.colorScheme.primary else Color.Transparent) + .clickable { onValueSelected(value) }, + contentAlignment = Alignment.Center + ) { + Text( + text = label(value), + color = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } +} + +@Composable +private fun TextBrightnessSlider( + label: String, + value: Float, + onValueChange: (Float) -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + SettingsLabel(label) + Row( + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .background(MaterialTheme.colorScheme.surfaceContainerHigh, RoundedCornerShape(24.dp)) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "A", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) + ) + Slider( + value = value.coerceIn(0.3f, 1.0f), + onValueChange = { onValueChange(it.coerceIn(0.3f, 1.0f)) }, + valueRange = 0.3f..1.0f, + modifier = Modifier.weight(1f).padding(horizontal = 16.dp) + ) + Text( + "A", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +private fun ThemeSwatch( + color: Color, + selected: Boolean, + label: String, + onClick: () -> Unit, + onDelete: (() -> Unit)? = null +) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box( + modifier = Modifier + .size(56.dp) + .clip(CircleShape) + .background(color) + .border( + width = if (selected) 3.dp else 1.dp, + color = if (selected) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.outlineVariant, + shape = CircleShape + ) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center + ) { + if (selected) { + Icon( + Icons.Default.Check, + contentDescription = null, + tint = if (color.luminance() > 0.5f) Color.Black else Color.White + ) + } + } + Spacer(Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = 72.dp) + ) + if (onDelete != null) { + Icon( + Icons.Default.Close, + contentDescription = "Delete", + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(16.dp).clickable(onClick = onDelete) + ) + } + } + } +} + +@Composable +private fun SharedCreateAppThemeDialog( + initialColor: Color = Color(0xFF6750A4), + onDismiss: () -> Unit, + onSave: (String, Color) -> Unit +) { + var name by remember { mutableStateOf("") } + var hsv by remember(initialColor) { mutableStateOf(initialColor.toSharedHsvColor()) } + val color = hsv.toComposeColor() + + fun updateFromColor(nextColor: Color) { + hsv = nextColor.toSharedHsvColor() + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Create theme") }, + text = { + Column( + modifier = Modifier.widthIn(max = 560.dp).verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(18.dp) + ) { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Theme name") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + SharedSpectrumBox( + hue = hsv.hue, + saturation = hsv.saturation, + currentColor = color, + onHueSatChanged = { hue, saturation -> + hsv = hsv.copy(hue = hue, saturation = saturation) + }, + modifier = Modifier.fillMaxWidth().height(220.dp) + ) + + SharedBrightnessSlider( + hue = hsv.hue, + saturation = hsv.saturation, + value = hsv.value, + onValueChanged = { hsv = hsv.copy(value = it) }, + modifier = Modifier.fillMaxWidth().height(24.dp).clip(RoundedCornerShape(12.dp)) + ) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + SharedColorComparePill( + oldColor = initialColor, + newColor = color, + modifier = Modifier.width(64.dp).height(36.dp) + ) + + Column( + modifier = Modifier.weight(1.6f), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text("Hex", color = Color.Gray, fontSize = 12.sp, maxLines = 1) + Spacer(Modifier.height(4.dp)) + SharedHexInput(color = color, onHexChanged = { updateFromColor(it) }) + } + + Row( + modifier = Modifier.weight(2.4f), + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + SharedRgbInputColumn( + label = "R", + value = color.red, + onValueChange = { updateFromColor(color.copy(red = it)) }, + modifier = Modifier.weight(1f) + ) + SharedRgbInputColumn( + label = "G", + value = color.green, + onValueChange = { updateFromColor(color.copy(green = it)) }, + modifier = Modifier.weight(1f) + ) + SharedRgbInputColumn( + label = "B", + value = color.blue, + onValueChange = { updateFromColor(color.copy(blue = it)) }, + modifier = Modifier.weight(1f) + ) + } + } + } + }, + confirmButton = { + Button( + onClick = { onSave(name.trim().ifBlank { "Custom" }, color) }, + colors = ButtonDefaults.buttonColors( + containerColor = color, + contentColor = if (color.luminance() > 0.5f) Color.Black else Color.White + ) + ) { + Text("Save", fontWeight = FontWeight.Bold) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} + +@Composable +private fun SharedSpectrumBox( + hue: Float, + saturation: Float, + currentColor: Color, + onHueSatChanged: (Float, Float) -> Unit, + modifier: Modifier = Modifier +) { + val rainbowColors = listOf( + Color.Red, + Color.Yellow, + Color.Green, + Color.Cyan, + Color.Blue, + Color.Magenta, + Color.Red + ) + val touchPadding = 12.dp + + Box( + modifier = modifier.pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown() + val paddingPx = touchPadding.toPx() + val activeWidth = size.width.toFloat() - (paddingPx * 2) + val activeHeight = size.height.toFloat() - (paddingPx * 2) + + fun update(offset: Offset) { + val relativeX = offset.x - paddingPx + val relativeY = offset.y - paddingPx + val nextHue = (relativeX / activeWidth).coerceIn(0f, 1f) * 360f + val nextSaturation = (relativeY / activeHeight).coerceIn(0f, 1f) + onHueSatChanged(nextHue, nextSaturation) + } + + update(down.position) + drag(down.id) { change -> + change.consume() + update(change.position) + } + } + } + ) { + Canvas( + modifier = Modifier + .fillMaxSize() + .padding(touchPadding) + .clip(RoundedCornerShape(12.dp)) + ) { + drawRect(brush = Brush.horizontalGradient(rainbowColors)) + drawRect( + brush = Brush.verticalGradient( + colors = listOf(Color.White, Color.White.copy(alpha = 0f)) + ) + ) + } + + Canvas(modifier = Modifier.fillMaxSize()) { + val paddingPx = touchPadding.toPx() + val activeWidth = size.width - (paddingPx * 2) + val activeHeight = size.height - (paddingPx * 2) + val x = paddingPx + (hue / 360f) * activeWidth + val y = paddingPx + saturation * activeHeight + val pointerRadius = 10.dp.toPx() + val strokeWidth = 2.dp.toPx() + + drawCircle( + color = Color.Black.copy(alpha = 0.25f), + radius = pointerRadius + 1.dp.toPx(), + center = Offset(x, y + 1.dp.toPx()) + ) + drawCircle( + color = currentColor.copy(alpha = 1f), + radius = pointerRadius, + center = Offset(x, y) + ) + drawCircle( + color = Color.White, + radius = pointerRadius, + center = Offset(x, y), + style = Stroke(width = strokeWidth) + ) + } + } +} + +@Composable +private fun SharedBrightnessSlider( + hue: Float, + saturation: Float, + value: Float, + onValueChanged: (Float) -> Unit, + modifier: Modifier = Modifier +) { + val baseColor = remember(hue, saturation) { + Color.hsv(hue, saturation, 1f) + } + + Box( + modifier = modifier.pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown() + + fun update(offset: Offset) { + val nextValue = (offset.x / size.width.toFloat()).coerceIn(0f, 1f) + onValueChanged(nextValue) + } + + update(down.position) + drag(down.id) { change -> + change.consume() + update(change.position) + } + } + } + ) { + Canvas(modifier = Modifier.fillMaxSize()) { + drawRect( + brush = Brush.horizontalGradient( + colors = listOf(Color.Black, baseColor) + ) + ) + drawCircle( + color = Color.White, + radius = 8.dp.toPx(), + center = Offset(value.coerceIn(0f, 1f) * size.width, size.height / 2) + ) + } + } +} + +@Composable +private fun SharedRgbInputColumn( + label: String, + value: Float, + onValueChange: (Float) -> Unit, + modifier: Modifier = Modifier +) { + val intValue = (value.coerceIn(0f, 1f) * 255).roundToInt() + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + ) { + Text( + text = label, + color = Color.Gray, + fontSize = 11.sp, + maxLines = 1 + ) + Spacer(Modifier.height(4.dp)) + SharedRgbInput(value = intValue, onValueChange = onValueChange) + } +} + +@Composable +private fun SharedRgbInput( + value: Int, + onValueChange: (Float) -> Unit +) { + var text by remember(value) { mutableStateOf(value.coerceIn(0, 255).toString()) } + + BasicTextField( + value = text, + onValueChange = { newText -> + if (newText.length <= 3 && newText.all { it.isDigit() }) { + text = newText + newText.toIntOrNull()?.let { channel -> + onValueChange(channel.coerceIn(0, 255) / 255f) + } + } + }, + textStyle = TextStyle( + color = Color.White, + textAlign = TextAlign.Center, + fontSize = 13.sp + ), + singleLine = true, + cursorBrush = SolidColor(Color.White), + modifier = Modifier + .fillMaxWidth() + .height(36.dp) + .background(Color(0xFF3E3E3E), RoundedCornerShape(8.dp)) + .padding(vertical = 9.dp) + ) +} + +@Composable +private fun SharedHexInput( + color: Color, + onHexChanged: (Color) -> Unit +) { + val hexValue = color.toSharedHexString().removePrefix("#") + var text by remember(hexValue) { mutableStateOf(hexValue) } + + Row( + modifier = Modifier + .fillMaxWidth() + .height(36.dp) + .background(Color(0xFF3E3E3E), RoundedCornerShape(8.dp)) + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Text( + text = "#", + color = Color.Gray, + fontSize = 13.sp, + fontWeight = FontWeight.Bold + ) + BasicTextField( + value = text, + onValueChange = { newText -> + if (newText.length <= 6) { + val uppercased = newText.uppercase() + if (uppercased.all { it.isDigit() || it in 'A'..'F' }) { + text = uppercased + if (uppercased.length == 6) { + uppercased.toSharedHexColorOrNull()?.let(onHexChanged) + } + } + } + }, + textStyle = TextStyle( + color = Color.White, + textAlign = TextAlign.Start, + fontSize = 13.sp + ), + singleLine = true, + cursorBrush = SolidColor(Color.White), + modifier = Modifier + .padding(start = 2.dp) + .width(50.dp) + ) + } +} + +@Composable +private fun SharedColorComparePill( + oldColor: Color, + newColor: Color, + modifier: Modifier = Modifier +) { + Canvas(modifier = modifier.clip(RoundedCornerShape(8.dp))) { + drawRect( + color = oldColor.copy(alpha = 1f), + size = Size(size.width / 2, size.height) + ) + drawRect( + color = newColor.copy(alpha = 1f), + topLeft = Offset(size.width / 2, 0f), + size = Size(size.width / 2, size.height) + ) + } +} + +private fun ColorScheme.withTextDimFactor(factor: Float): ColorScheme { + val dimFactor = factor.coerceIn(0.3f, 1.0f) + if (dimFactor >= 1.0f) return this + return copy( + primary = primary.copy(alpha = dimFactor), + secondary = secondary.copy(alpha = dimFactor), + tertiary = tertiary.copy(alpha = dimFactor), + error = error.copy(alpha = dimFactor), + primaryContainer = primaryContainer.copy(alpha = dimFactor), + secondaryContainer = secondaryContainer.copy(alpha = dimFactor), + tertiaryContainer = tertiaryContainer.copy(alpha = dimFactor), + errorContainer = errorContainer.copy(alpha = dimFactor), + outline = outline.copy(alpha = dimFactor), + outlineVariant = outlineVariant.copy(alpha = dimFactor), + inversePrimary = inversePrimary.copy(alpha = dimFactor), + inverseOnSurface = inverseOnSurface.copy(alpha = dimFactor), + onPrimary = onPrimary.copy(alpha = dimFactor), + onSecondary = onSecondary.copy(alpha = dimFactor), + onTertiary = onTertiary.copy(alpha = dimFactor), + onBackground = onBackground.copy(alpha = dimFactor), + onSurface = onSurface.copy(alpha = dimFactor), + onSurfaceVariant = onSurfaceVariant.copy(alpha = dimFactor), + onError = onError.copy(alpha = dimFactor), + onPrimaryContainer = onPrimaryContainer.copy(alpha = dimFactor), + onSecondaryContainer = onSecondaryContainer.copy(alpha = dimFactor), + onTertiaryContainer = onTertiaryContainer.copy(alpha = dimFactor), + onErrorContainer = onErrorContainer.copy(alpha = dimFactor) + ) +} + +private data class AppThemePreset( + val name: String, + val color: Color +) + +private val AppThemePresets = listOf( + AppThemePreset("Ocean", Color(0xFF00668B)), + AppThemePreset("Mint", Color(0xFF006C4C)), + AppThemePreset("Rose", Color(0xFF9C4146)), + AppThemePreset("Sepia", Color(0xFF705D49)), + AppThemePreset("Amethyst", Color(0xFF9B59B6)), + AppThemePreset("Amber", Color(0xFFFFC107)), + AppThemePreset("Sapphire", Color(0xFF0F52BA)) +) + +private val AppThemeMode.label: String + get() = when (this) { + AppThemeMode.SYSTEM -> "System" + AppThemeMode.LIGHT -> "Light" + AppThemeMode.DARK -> "Dark" + } + +private val AppContrastOption.label: String + get() = when (this) { + AppContrastOption.STANDARD -> "Standard" + AppContrastOption.MEDIUM -> "Medium" + AppContrastOption.HIGH -> "High" + } + +internal data class SharedHsvColor( + val hue: Float, + val saturation: Float, + val value: Float +) { + fun toComposeColor(): Color { + return Color.hsv( + hue.normalizedHue(), + saturation.coerceIn(0f, 1f), + value.coerceIn(0f, 1f) + ) + } +} + +internal fun Color.toSharedHsvColor(): SharedHsvColor { + val maximum = maxOf(red, green, blue) + val minimum = minOf(red, green, blue) + val delta = maximum - minimum + val hue = when { + delta == 0f -> 0f + maximum == red -> 60f * (((green - blue) / delta) % 6f) + maximum == green -> 60f * (((blue - red) / delta) + 2f) + else -> 60f * (((red - green) / delta) + 4f) + } + val saturation = if (maximum == 0f) 0f else delta / maximum + return SharedHsvColor( + hue = hue.normalizedHue(), + saturation = saturation.coerceIn(0f, 1f), + value = maximum.coerceIn(0f, 1f) + ) +} + +internal fun Color.toSharedHexString(): String { + val rgb = toArgb() and 0x00FFFFFF + return "#${rgb.toString(16).padStart(6, '0').uppercase()}" +} + +internal fun String.toSharedHexColorOrNull(): Color? { + val normalized = trim().removePrefix("#") + if (normalized.length != 6 || normalized.any { !it.isDigit() && it.lowercaseChar() !in 'a'..'f' }) { + return null + } + val rgb = normalized.toLongOrNull(16) ?: return null + return Color((0xFF000000L or rgb).toInt()) +} + +private fun Float.normalizedHue(): Float { + return ((this % 360f) + 360f) % 360f +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedLibraryDialogs.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedLibraryDialogs.kt new file mode 100644 index 0000000..63e2ca1 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedLibraryDialogs.kt @@ -0,0 +1,242 @@ +package com.aryan.reader.shared.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.aryan.reader.shared.BookItem +import com.aryan.reader.shared.Shelf +import com.aryan.reader.shared.Tag +import com.aryan.reader.shared.cardTitle +import com.aryan.reader.shared.formatFileSize +import com.aryan.reader.shared.parseTagList + +@Composable +fun SharedTextInputDialog( + title: String, + label: String, + initialValue: String, + confirmLabel: String, + onDismiss: () -> Unit, + onConfirm: (String) -> Unit +) { + var value by remember(initialValue) { mutableStateOf(initialValue) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + OutlinedTextField( + value = value, + onValueChange = { value = it }, + label = { Text(label) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + }, + confirmButton = { + TextButton(onClick = { onConfirm(value) }, enabled = value.isNotBlank()) { + Text(confirmLabel) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} + +@Composable +fun SharedConfirmDialog( + title: String, + body: String, + confirmLabel: String, + onDismiss: () -> Unit, + onConfirm: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { Text(body) }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(confirmLabel) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} + +@Composable +fun SharedAddToShelfDialog( + shelves: List, + onDismiss: () -> Unit, + onCreateShelf: () -> Unit, + onShelfSelected: (Shelf) -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Add to shelf") }, + text = { + if (shelves.isEmpty()) { + Text("Create a shelf first, then add selected books to it.") + } else { + LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp)) { + items(shelves, key = { it.id }) { shelf -> + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.fillMaxWidth().clickable { onShelfSelected(shelf) } + ) { + Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.Folder, contentDescription = null, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(10.dp)) + Text(shelf.name, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) + Text("${shelf.bookCount}", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + } + } + }, + confirmButton = { + TextButton(onClick = onCreateShelf) { + Text("New shelf") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} + +@Composable +fun SharedBookInfoDialog( + book: BookItem, + onDismiss: () -> Unit, + onEdit: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(book.cardTitle()) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + SharedInfoRow("File", book.displayName) + SharedInfoRow("Type", book.type.name) + SharedInfoRow("Author", book.author.orEmpty().ifBlank { "Unknown" }) + SharedInfoRow("Path", book.path.orEmpty().ifBlank { "Not available" }) + SharedInfoRow("Size", formatFileSize(book.fileSize)) + SharedInfoRow("Progress", "${(book.progressPercentage ?: 0f).toInt()}%") + if (!book.seriesName.isNullOrBlank()) { + SharedInfoRow("Series", listOfNotNull(book.seriesName, book.seriesIndex?.toString()).joinToString(" #")) + } + if (book.tags.isNotEmpty()) { + SharedInfoRow("Tags", book.tags.joinToString { it.name }) + } + } + }, + confirmButton = { + TextButton(onClick = onEdit) { + Text("Edit") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Close") + } + } + ) +} + +@Composable +fun SharedBookEditDialog( + book: BookItem, + knownTags: List, + onDismiss: () -> Unit, + onSave: (BookItem) -> Unit +) { + var title by remember(book.id) { mutableStateOf(book.title.orEmpty()) } + var author by remember(book.id) { mutableStateOf(book.author.orEmpty()) } + var seriesName by remember(book.id) { mutableStateOf(book.seriesName.orEmpty()) } + var seriesIndex by remember(book.id) { mutableStateOf(book.seriesIndex?.toString().orEmpty()) } + var tagText by remember(book.id) { mutableStateOf(book.tags.joinToString(", ") { it.name }) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Edit book") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + OutlinedTextField(value = title, onValueChange = { title = it }, label = { Text("Title") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(value = author, onValueChange = { author = it }, label = { Text("Author") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(value = seriesName, onValueChange = { seriesName = it }, label = { Text("Series") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(value = seriesIndex, onValueChange = { seriesIndex = it }, label = { Text("Series index") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(value = tagText, onValueChange = { tagText = it }, label = { Text("Tags, comma separated") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + if (knownTags.isNotEmpty()) { + Text("Existing: ${knownTags.joinToString { it.name }}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + }, + confirmButton = { + TextButton( + onClick = { + onSave( + book.copy( + title = title.trim().ifBlank { null }, + author = author.trim().ifBlank { null }, + seriesName = seriesName.trim().ifBlank { null }, + seriesIndex = seriesIndex.toDoubleOrNull(), + tags = parseTagList(tagText, knownTags) + ) + ) + } + ) { + Text("Save") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} + +@Composable +private fun SharedInfoRow(label: String, value: String) { + Column { + Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(value, style = MaterialTheme.typography.bodyMedium) + } +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedMarkdownText.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedMarkdownText.kt new file mode 100644 index 0000000..bf415ec --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedMarkdownText.kt @@ -0,0 +1,194 @@ +package com.aryan.reader.shared.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import com.aryan.reader.shared.ReaderMarkdownBlock +import com.aryan.reader.shared.ReaderMarkdownParser + +@Composable +fun SharedMarkdownText( + markdown: String, + modifier: Modifier = Modifier, + style: TextStyle = MaterialTheme.typography.bodySmall +) { + val document = remember(markdown) { ReaderMarkdownParser.parse(markdown) } + val colorScheme = MaterialTheme.colorScheme + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) { + document.blocks.forEachIndexed { index, block -> + when (block) { + is ReaderMarkdownBlock.Heading -> { + val headingStyle = when (block.level) { + 1 -> MaterialTheme.typography.titleLarge + 2 -> MaterialTheme.typography.titleMedium + else -> MaterialTheme.typography.titleSmall + } + Text( + text = block.text.markdownInlineAnnotatedString(), + style = headingStyle, + fontWeight = FontWeight.SemiBold + ) + } + + is ReaderMarkdownBlock.Paragraph -> { + Text(text = block.text.markdownInlineAnnotatedString(), style = style) + } + + is ReaderMarkdownBlock.Quote -> { + Text( + text = block.text.markdownInlineAnnotatedString(), + style = style, + color = colorScheme.onSurfaceVariant, + modifier = Modifier + .fillMaxWidth() + .background(colorScheme.surfaceVariant.copy(alpha = 0.45f), RoundedCornerShape(6.dp)) + .padding(8.dp) + ) + } + + is ReaderMarkdownBlock.CodeBlock -> { + Surface( + color = colorScheme.surfaceVariant.copy(alpha = 0.6f), + shape = RoundedCornerShape(6.dp), + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = block.text, + style = style.copy(fontFamily = FontFamily.Monospace), + modifier = Modifier.padding(8.dp) + ) + } + } + + is ReaderMarkdownBlock.ListItems -> { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + block.items.forEachIndexed { itemIndex, item -> + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = if (block.ordered) "${itemIndex + 1}." else "-", + style = style, + color = colorScheme.onSurfaceVariant + ) + Text( + text = item.markdownInlineAnnotatedString(), + style = style, + modifier = Modifier.weight(1f) + ) + } + } + } + } + } + } + if (document.blocks.isEmpty() && markdown.isNotBlank()) { + Text(text = markdown, style = style) + } + } +} + +@Composable +private fun String.markdownInlineAnnotatedString(): AnnotatedString { + val colorScheme = MaterialTheme.colorScheme + return remember(this, colorScheme.primary, colorScheme.surfaceVariant) { + buildAnnotatedString { + appendMarkdownInline( + text = this@markdownInlineAnnotatedString, + codeStyle = SpanStyle( + fontFamily = FontFamily.Monospace, + background = colorScheme.surfaceVariant.copy(alpha = 0.7f) + ), + linkStyle = SpanStyle( + color = colorScheme.primary, + textDecoration = TextDecoration.Underline + ) + ) + } + } +} + +private fun AnnotatedString.Builder.appendMarkdownInline( + text: String, + codeStyle: SpanStyle, + linkStyle: SpanStyle +) { + var index = 0 + while (index < text.length) { + when { + text.startsWith("`", index) -> { + val end = text.indexOf('`', startIndex = index + 1) + if (end > index) { + withStyle(codeStyle) { append(text.substring(index + 1, end)) } + index = end + 1 + } else { + append(text[index]) + index += 1 + } + } + + text.startsWith("**", index) -> { + val end = text.indexOf("**", startIndex = index + 2) + if (end > index) { + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + appendMarkdownInline(text.substring(index + 2, end), codeStyle, linkStyle) + } + index = end + 2 + } else { + append(text[index]) + index += 1 + } + } + + text.startsWith("*", index) -> { + val end = text.indexOf('*', startIndex = index + 1) + if (end > index) { + withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { + appendMarkdownInline(text.substring(index + 1, end), codeStyle, linkStyle) + } + index = end + 1 + } else { + append(text[index]) + index += 1 + } + } + + text[index] == '[' -> { + val labelEnd = text.indexOf("](", startIndex = index + 1) + val urlEnd = if (labelEnd > index) text.indexOf(')', startIndex = labelEnd + 2) else -1 + if (labelEnd > index && urlEnd > labelEnd) { + withStyle(linkStyle) { + appendMarkdownInline(text.substring(index + 1, labelEnd), codeStyle, linkStyle) + } + index = urlEnd + 1 + } else { + append(text[index]) + index += 1 + } + } + + else -> { + append(text[index]) + index += 1 + } + } + } +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedOpdsScreen.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedOpdsScreen.kt new file mode 100644 index 0000000..e3b7f41 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedOpdsScreen.kt @@ -0,0 +1,841 @@ +package com.aryan.reader.shared.ui + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Cloud +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.aryan.reader.shared.BookItem +import com.aryan.reader.shared.opds.OpdsAcquisition +import com.aryan.reader.shared.opds.OpdsCatalog +import com.aryan.reader.shared.opds.OpdsEntry +import com.aryan.reader.shared.opds.SharedOpdsDownloadState +import com.aryan.reader.shared.opds.SharedOpdsScreenState +import com.aryan.reader.shared.opds.SharedOpdsText + +@Composable +fun SharedOpdsScreen( + state: SharedOpdsScreenState, + localLibraryBooks: List, + onOpenCatalog: (OpdsCatalog) -> Unit, + onOpenFeedUrl: (String) -> Unit, + onNavigateBack: () -> Unit, + onSearch: (String) -> Unit, + onLoadNextPage: () -> Unit, + onAddCatalog: (String, String, String?, String?) -> Unit, + onUpdateCatalog: (String, String, String, String?, String?) -> Unit, + onRemoveCatalog: (OpdsCatalog) -> Unit, + onDownloadBook: (OpdsEntry, OpdsAcquisition) -> Unit, + onReadBook: (BookItem) -> Unit, + onStreamBook: (OpdsEntry, OpdsCatalog?) -> Unit, + onClearError: () -> Unit, + modifier: Modifier = Modifier +) { + var selectedEntry by remember { mutableStateOf(null) } + var showCatalogDialog by remember { mutableStateOf(false) } + var editingCatalog by remember { mutableStateOf(null) } + var catalogToDelete by remember { mutableStateOf(null) } + + Box(modifier.fillMaxSize()) { + if (!state.isViewingCatalog) { + SharedOpdsCatalogList( + catalogs = state.catalogs, + onOpenCatalog = onOpenCatalog, + onEditCatalog = { catalog -> + editingCatalog = catalog + showCatalogDialog = true + }, + onDeleteCatalog = { catalogToDelete = it }, + onAddCatalog = { + editingCatalog = null + showCatalogDialog = true + } + ) + } else { + SharedOpdsFeedView( + state = state, + localLibraryBooks = localLibraryBooks, + onNavigateBack = onNavigateBack, + onSearch = onSearch, + onOpenFeedUrl = onOpenFeedUrl, + onLoadNextPage = onLoadNextPage, + onDownloadBook = onDownloadBook, + onReadBook = onReadBook, + onStreamBook = { entry -> onStreamBook(entry, state.currentCatalog) }, + onEntrySelected = { selectedEntry = it } + ) + } + + state.errorMessage?.let { error -> + Surface( + color = MaterialTheme.colorScheme.errorContainer, + shape = RoundedCornerShape(8.dp), + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(16.dp) + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = error, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.weight(1f) + ) + TextButton(onClick = onClearError) { + Text("Dismiss") + } + } + } + } + } + + if (showCatalogDialog) { + SharedOpdsCatalogDialog( + catalog = editingCatalog, + onDismiss = { + showCatalogDialog = false + editingCatalog = null + }, + onSave = { title, url, username, password -> + val editing = editingCatalog + if (editing == null) { + onAddCatalog(title, url, username, password) + } else { + onUpdateCatalog(editing.id, title, url, username, password) + } + showCatalogDialog = false + editingCatalog = null + } + ) + } + + catalogToDelete?.let { catalog -> + AlertDialog( + onDismissRequest = { catalogToDelete = null }, + title = { Text("Delete catalog") }, + text = { Text("Delete \"${catalog.title}\"? Streamed books from this catalog may stop opening if credentials change later.") }, + confirmButton = { + TextButton( + onClick = { + onRemoveCatalog(catalog) + catalogToDelete = null + } + ) { + Text("Delete") + } + }, + dismissButton = { + TextButton(onClick = { catalogToDelete = null }) { + Text("Cancel") + } + } + ) + } + + selectedEntry?.let { entry -> + SharedOpdsEntryDetailsDialog( + entry = entry, + localLibraryBook = entry.findLocalBook(localLibraryBooks), + downloadState = state.downloadingState[entry.id], + onDismiss = { selectedEntry = null }, + onDownloadBook = { acquisition -> onDownloadBook(entry, acquisition) }, + onReadBook = onReadBook, + onStreamBook = { + onStreamBook(entry, state.currentCatalog) + selectedEntry = null + }, + onOpenFeedUrl = { url -> + onOpenFeedUrl(url) + selectedEntry = null + }, + onSearch = { query -> + onSearch(query) + selectedEntry = null + } + ) + } +} + +@Composable +private fun SharedOpdsCatalogList( + catalogs: List, + onOpenCatalog: (OpdsCatalog) -> Unit, + onEditCatalog: (OpdsCatalog) -> Unit, + onDeleteCatalog: (OpdsCatalog) -> Unit, + onAddCatalog: () -> Unit +) { + Column(Modifier.fillMaxSize()) { + SharedScreenScaffold( + title = "OPDS", + subtitle = "Browse catalogs, streams, and downloads", + trailing = { + Button(onClick = onAddCatalog) { + Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Catalog") + } + } + ) { + if (catalogs.isEmpty()) { + SharedOpdsEmptyState(onAddCatalog = onAddCatalog, modifier = Modifier.weight(1f)) + } else { + LazyVerticalGrid( + columns = GridCells.Adaptive(320.dp), + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 24.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(catalogs, key = { it.id }) { catalog -> + SharedOpdsCatalogCard( + catalog = catalog, + onOpenCatalog = { onOpenCatalog(catalog) }, + onEditCatalog = { onEditCatalog(catalog) }, + onDeleteCatalog = { onDeleteCatalog(catalog) } + ) + } + } + } + } + } +} + +@Composable +private fun SharedOpdsFeedView( + state: SharedOpdsScreenState, + localLibraryBooks: List, + onNavigateBack: () -> Unit, + onSearch: (String) -> Unit, + onOpenFeedUrl: (String) -> Unit, + onLoadNextPage: () -> Unit, + onDownloadBook: (OpdsEntry, OpdsAcquisition) -> Unit, + onReadBook: (BookItem) -> Unit, + onStreamBook: (OpdsEntry) -> Unit, + onEntrySelected: (OpdsEntry) -> Unit +) { + var showSearch by remember { mutableStateOf(false) } + var query by remember { mutableStateOf("") } + Column(Modifier.fillMaxSize()) { + Surface(color = MaterialTheme.colorScheme.surface, tonalElevation = 2.dp) { + Column(Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(64.dp) + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = { + if (showSearch) { + showSearch = false + query = "" + } else { + onNavigateBack() + } + }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + if (showSearch) { + OutlinedTextField( + value = query, + onValueChange = { query = it }, + placeholder = { Text("Search catalog") }, + singleLine = true, + modifier = Modifier.weight(1f), + trailingIcon = { + IconButton(onClick = { + if (query.isNotBlank()) { + onSearch(query) + query = "" + showSearch = false + } + }) { + Icon(Icons.Default.Search, contentDescription = "Search") + } + } + ) + } else { + Column(Modifier.weight(1f)) { + Text( + text = state.currentFeed?.title ?: "Loading", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + state.currentCatalog?.title?.let { catalogTitle -> + Text( + catalogTitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + if (state.searchUrlTemplate != null) { + IconButton(onClick = { showSearch = true }) { + Icon(Icons.Default.Search, contentDescription = "Search") + } + } + } + } + if (state.isLoading) { + LinearProgressIndicator(Modifier.fillMaxWidth()) + } + } + } + + val facets = state.currentFeed?.facets.orEmpty() + if (facets.isNotEmpty()) { + LazyRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + facets.groupBy { it.group }.forEach { (groupName, groupFacets) -> + item(key = groupName) { + SharedOpdsFacetMenu( + groupName = groupName, + facets = groupFacets, + onOpenFeedUrl = onOpenFeedUrl + ) + } + } + } + } + + val entries = state.currentFeed?.entries.orEmpty() + if (entries.isEmpty() && !state.isLoading) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("This feed is empty.") + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + itemsIndexed(entries, key = { index, entry -> "${entry.id}_$index" }) { index, entry -> + val nextUrl = state.currentFeed?.nextUrl + if (index == entries.lastIndex && nextUrl != null) { + LaunchedEffect(index, nextUrl) { + onLoadNextPage() + } + } + if (entry.isNavigation) { + SharedOpdsNavigationCard(entry, onOpenFeedUrl) + } else { + SharedOpdsBookCard( + entry = entry, + localLibraryBook = entry.findLocalBook(localLibraryBooks), + downloadState = state.downloadingState[entry.id], + onDownloadBook = { acquisition -> onDownloadBook(entry, acquisition) }, + onReadBook = onReadBook, + onStreamBook = { onStreamBook(entry) }, + onClick = { onEntrySelected(entry) } + ) + } + } + } + } + } +} + +@Composable +private fun SharedOpdsCatalogCard( + catalog: OpdsCatalog, + onOpenCatalog: () -> Unit, + onEditCatalog: () -> Unit, + onDeleteCatalog: () -> Unit +) { + Surface( + onClick = onOpenCatalog, + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) { + Box(Modifier.size(46.dp), contentAlignment = Alignment.Center) { + Icon(Icons.Default.Cloud, contentDescription = null) + } + } + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text(catalog.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + catalog.url, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + Row(verticalAlignment = Alignment.CenterVertically) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (catalog.isDefault) { + Surface( + color = MaterialTheme.colorScheme.secondaryContainer, + shape = RoundedCornerShape(6.dp) + ) { + Text( + "Preset", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + } + } + Spacer(Modifier.weight(1f)) + if (!catalog.isDefault) { + IconButton(onClick = onEditCatalog) { + Icon(Icons.Default.Edit, contentDescription = "Edit") + } + IconButton(onClick = onDeleteCatalog) { + Icon(Icons.Default.Delete, contentDescription = "Delete") + } + } + } + } + } +} + +@Composable +private fun SharedOpdsEmptyState(onAddCatalog: () -> Unit, modifier: Modifier = Modifier) { + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f)) + ) { + Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) { + Icon(Icons.Default.Cloud, contentDescription = null, modifier = Modifier.size(56.dp), tint = MaterialTheme.colorScheme.primary) + Text("No catalogs", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text("Add an OPDS catalog to browse remote books.", color = MaterialTheme.colorScheme.onSurfaceVariant) + Button(onClick = onAddCatalog) { + Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Add catalog") + } + } + } + } +} + +@Composable +private fun SharedOpdsFacetMenu( + groupName: String, + facets: List, + onOpenFeedUrl: (String) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + val activeFacet = facets.firstOrNull { it.isActive } ?: facets.firstOrNull() + Box { + FilterChip( + selected = activeFacet?.isActive == true, + onClick = { expanded = true }, + label = { Text("$groupName: ${activeFacet?.title ?: "Select"}") }, + trailingIcon = { Icon(Icons.Default.ArrowDropDown, contentDescription = null) } + ) + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + facets.forEach { facet -> + DropdownMenuItem( + text = { Text(facet.title) }, + onClick = { + expanded = false + onOpenFeedUrl(facet.url) + }, + trailingIcon = if (facet.isActive) { + { Icon(Icons.Default.Check, contentDescription = null) } + } else { + null + } + ) + } + } + } +} + +@Composable +private fun SharedOpdsNavigationCard(entry: OpdsEntry, onOpenFeedUrl: (String) -> Unit) { + Surface( + onClick = { entry.navigationUrl?.let(onOpenFeedUrl) }, + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth() + ) { + Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.Folder, contentDescription = null, tint = MaterialTheme.colorScheme.secondary) + Spacer(Modifier.width(16.dp)) + Column(Modifier.weight(1f)) { + Text(entry.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + val summary = SharedOpdsText.cleanSummary(entry.summary) + if (summary.isNotBlank()) { + Text( + summary, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } +} + +@Composable +private fun SharedOpdsBookCard( + entry: OpdsEntry, + localLibraryBook: BookItem?, + downloadState: SharedOpdsDownloadState?, + onDownloadBook: (OpdsAcquisition) -> Unit, + onReadBook: (BookItem) -> Unit, + onStreamBook: () -> Unit, + onClick: () -> Unit +) { + val uniqueAcquisitions = remember(entry.acquisitions) { + entry.acquisitions.distinctBy { it.formatName }.sortedByDescending { it.priority } + } + val isDownloading = downloadState?.isDownloading == true + var showFormatMenu by remember { mutableStateOf(false) } + + Surface( + onClick = onClick, + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth() + ) { + Row(modifier = Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Box( + modifier = Modifier + .size(width = 70.dp, height = 100.dp) + .clip(RoundedCornerShape(6.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center + ) { + Text(entry.title.take(1).uppercase(), style = MaterialTheme.typography.headlineMedium) + } + Column(Modifier.weight(1f)) { + Text(entry.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 2, overflow = TextOverflow.Ellipsis) + entry.author?.let { + Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) + } + val summary = SharedOpdsText.cleanSummary(entry.summary) + if (summary.isNotBlank()) { + Text(summary, style = MaterialTheme.typography.bodySmall, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 4.dp)) + } + Spacer(Modifier.height(8.dp)) + when { + localLibraryBook != null -> { + OutlinedButton(onClick = { onReadBook(localLibraryBook) }, contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)) { + Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text("Read") + } + } + isDownloading -> SharedOpdsDownloadProgress(downloadState) + else -> Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + if (entry.isStreamable) { + FilledTonalButton(onClick = onStreamBook, contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)) { + Icon(Icons.Default.Cloud, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text("Stream") + } + } + Box { + FilledTonalButton( + onClick = { + when (uniqueAcquisitions.size) { + 0 -> Unit + 1 -> onDownloadBook(uniqueAcquisitions.first()) + else -> showFormatMenu = true + } + }, + enabled = uniqueAcquisitions.isNotEmpty(), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp) + ) { + Icon( + if (uniqueAcquisitions.isEmpty()) Icons.Default.Info else Icons.Default.Download, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(Modifier.width(6.dp)) + Text(if (uniqueAcquisitions.isEmpty()) "Unavailable" else "Download") + } + DropdownMenu(expanded = showFormatMenu, onDismissRequest = { showFormatMenu = false }) { + uniqueAcquisitions.forEach { acquisition -> + DropdownMenuItem( + text = { Text(acquisition.formatName) }, + onClick = { + showFormatMenu = false + onDownloadBook(acquisition) + } + ) + } + } + } + } + } + } + } + } +} + +@Composable +private fun SharedOpdsDownloadProgress(downloadState: SharedOpdsDownloadState?) { + val progress = downloadState?.progress + Column(Modifier.fillMaxWidth()) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Downloading", style = MaterialTheme.typography.labelMedium) + Spacer(Modifier.weight(1f)) + if (progress != null) { + Text("${(progress * 100).toInt()}%", style = MaterialTheme.typography.labelMedium) + } + } + Spacer(Modifier.height(4.dp)) + if (progress != null) { + LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth()) + } else { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + } +} + +@Composable +private fun SharedOpdsEntryDetailsDialog( + entry: OpdsEntry, + localLibraryBook: BookItem?, + downloadState: SharedOpdsDownloadState?, + onDismiss: () -> Unit, + onDownloadBook: (OpdsAcquisition) -> Unit, + onReadBook: (BookItem) -> Unit, + onStreamBook: () -> Unit, + onOpenFeedUrl: (String) -> Unit, + onSearch: (String) -> Unit +) { + val uniqueAcquisitions = remember(entry.acquisitions) { + entry.acquisitions.distinctBy { it.formatName }.sortedByDescending { it.priority } + } + AlertDialog( + onDismissRequest = onDismiss, + title = { + Column { + Text(entry.title, maxLines = 2, overflow = TextOverflow.Ellipsis) + entry.author?.let { + Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + }, + text = { + Column( + modifier = Modifier + .heightIn(max = 520.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + localLibraryBook?.let { book -> + Button(onClick = { onReadBook(book) }, modifier = Modifier.fillMaxWidth()) { + Icon(Icons.Default.Check, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text("Read") + } + } + if (downloadState?.isDownloading == true) { + SharedOpdsDownloadProgress(downloadState) + } else { + if (entry.isStreamable) { + Button(onClick = onStreamBook, modifier = Modifier.fillMaxWidth()) { + Icon(Icons.Default.Cloud, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text("Stream now") + } + } + if (uniqueAcquisitions.isNotEmpty()) { + Text("Download format", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + uniqueAcquisitions.take(4).forEach { acquisition -> + FilledTonalButton(onClick = { onDownloadBook(acquisition) }) { + Text(acquisition.formatName) + } + } + } + } + } + entry.series?.takeIf { it.isNotBlank() }?.let { series -> + Text( + text = if (entry.seriesIndex.isNullOrBlank()) series else "$series #${entry.seriesIndex}", + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(top = 4.dp) + ) + } + if (entry.authors.isNotEmpty()) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Authors", style = MaterialTheme.typography.labelLarge) + entry.authors.forEach { author -> + TextButton( + onClick = { + if (author.url != null) onOpenFeedUrl(author.url) else onSearch(author.name) + } + ) { + Text(author.name) + } + } + } + } + if (entry.categories.isNotEmpty()) { + Text("Categories", style = MaterialTheme.typography.labelLarge) + entry.categories.distinct().take(8).forEach { category -> + TextButton(onClick = { onSearch(category) }) { + Text(category) + } + } + } + val secondary = listOfNotNull( + entry.publisher?.takeIf { it.isNotBlank() }?.let { "Publisher: $it" }, + entry.published?.takeIf { it.isNotBlank() }?.substringBefore("T")?.let { "Published: $it" }, + entry.language?.takeIf { it.isNotBlank() }?.uppercase()?.let { "Language: $it" } + ) + secondary.forEach { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } + val summary = SharedOpdsText.cleanSummary(entry.summary) + if (summary.isNotBlank()) { + Text("Synopsis", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold) + Text(summary, style = MaterialTheme.typography.bodyMedium) + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text("Close") + } + } + ) +} + +@Composable +private fun SharedOpdsCatalogDialog( + catalog: OpdsCatalog?, + onDismiss: () -> Unit, + onSave: (String, String, String?, String?) -> Unit +) { + var title by remember(catalog) { mutableStateOf(catalog?.title.orEmpty()) } + var url by remember(catalog) { mutableStateOf(catalog?.url.orEmpty()) } + var username by remember(catalog) { mutableStateOf(catalog?.username.orEmpty()) } + var password by remember(catalog) { mutableStateOf(catalog?.password.orEmpty()) } + val isEditMode = catalog != null + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(if (isEditMode) "Edit catalog" else "Add OPDS catalog") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField(value = title, onValueChange = { title = it }, label = { Text("Catalog name") }, singleLine = true) + OutlinedTextField(value = url, onValueChange = { url = it }, label = { Text("URL") }, singleLine = true) + Text("Authentication optional", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary) + OutlinedTextField(value = username, onValueChange = { username = it }, label = { Text("Username") }, singleLine = true) + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("Password") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation() + ) + } + }, + confirmButton = { + TextButton( + onClick = { onSave(title, url, username, password) }, + enabled = title.isNotBlank() && url.isNotBlank() + ) { + Text("Save") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} + +private fun OpdsEntry.findLocalBook(localLibraryBooks: List): BookItem? { + return localLibraryBooks.firstOrNull { + it.title.equals(title, ignoreCase = true) || it.displayName.equals(title, ignoreCase = true) + } +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedPdfAnnotationUi.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedPdfAnnotationUi.kt new file mode 100644 index 0000000..9faa8b8 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedPdfAnnotationUi.kt @@ -0,0 +1,1404 @@ +package com.aryan.reader.shared.ui + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Undo +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Remove +import androidx.compose.material.icons.filled.TextFields +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Fill +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aryan.reader.shared.pdf.PdfAnnotationKind +import com.aryan.reader.shared.pdf.PdfInkTool +import com.aryan.reader.shared.pdf.PdfPageBounds +import com.aryan.reader.shared.pdf.PdfPagePoint +import com.aryan.reader.shared.pdf.SharedPdfAnnotation +import com.aryan.reader.shared.pdf.SharedPdfAnnotationDefaults +import com.aryan.reader.shared.pdf.SharedPdfEmbeddedAnnotation +import com.aryan.reader.shared.pdf.SharedPdfInkRenderData +import com.aryan.reader.shared.pdf.SharedPdfInkRenderer +import com.aryan.reader.shared.pdf.SharedPdfTextAnnotationDefaults +import com.aryan.reader.shared.pdf.SharedPdfTextDraft +import com.aryan.reader.shared.pdf.SharedPdfTextFontPreset +import com.aryan.reader.shared.pdf.SharedPdfTextResizeHandle +import com.aryan.reader.shared.pdf.SharedPdfTextStyleConfig +import com.aryan.reader.shared.pdf.movedBy +import com.aryan.reader.shared.pdf.resizedBy +import com.aryan.reader.shared.pdf.sharedPdfStrokePercent +import com.aryan.reader.shared.pdf.sharedPdfStrokeWidthRange +import kotlin.math.roundToInt + +val SharedPdfAnnotationDefaultTools: List = listOf( + PdfInkTool.PEN, + PdfInkTool.FOUNTAIN_PEN, + PdfInkTool.PENCIL, + PdfInkTool.HIGHLIGHTER, + PdfInkTool.HIGHLIGHTER_ROUND, + PdfInkTool.TEXT, + PdfInkTool.ERASER +) + +@Composable +fun SharedPdfAnnotationToolDock( + selectedTool: PdfInkTool, + selectedColor: Int, + strokeWidth: Float, + tools: List = SharedPdfAnnotationDefaultTools, + onToolSelected: (PdfInkTool) -> Unit, + onColorSelected: (Int) -> Unit, + onStrokeWidthChange: (Float) -> Unit, + onUndo: () -> Unit, + onClearPage: () -> Unit, + isHighlighterSnapEnabled: Boolean = false, + onHighlighterSnapChange: (Boolean) -> Unit = {} +) { + val strokeRange = selectedTool.sharedPdfStrokeWidthRange() + val sliderValue = strokeWidth.coerceIn(strokeRange.start, strokeRange.endInclusive) + val showColorPalette = selectedTool != PdfInkTool.TEXT && selectedTool != PdfInkTool.ERASER + val showStrokeSettings = selectedTool != PdfInkTool.TEXT + val palette = if (selectedTool.isHighlighter) { + SharedPdfAnnotationDefaults.highlighterPalette + } else { + SharedPdfAnnotationDefaults.penPalette + } + + Surface( + color = Color(0xFF1E1E1E), + contentColor = Color.White, + shape = RoundedCornerShape(24.dp), + shadowElevation = 8.dp, + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(14.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + tools.distinct().chunked(4).forEach { rowTools -> + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + rowTools.forEach { tool -> + SharedPdfToolButton( + tool = tool, + selectedTool = selectedTool, + selectedColor = selectedColor, + strokeWidth = strokeWidth, + onToolSelected = onToolSelected + ) + } + } + } + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + DockCircleButton(onClick = onUndo) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Undo, + contentDescription = "Undo annotation", + tint = Color.White, + modifier = Modifier.size(18.dp) + ) + } + DockCircleButton(onClick = onClearPage) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Clear page annotations", + tint = Color.White, + modifier = Modifier.size(18.dp) + ) + } + } + + if (showColorPalette) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + palette.forEach { argb -> + val selected = argb == selectedColor + Box( + modifier = Modifier + .size(28.dp) + .clip(CircleShape) + .background(Color(argb).copy(alpha = 1f)) + .border( + width = if (selected) 2.dp else 1.dp, + color = if (selected) Color.White else Color.White.copy(alpha = 0.22f), + shape = CircleShape + ) + .clickable { onColorSelected(argb) } + ) + } + } + } + + if (showStrokeSettings) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = "Thickness ${sliderValue.sharedPdfStrokePercent(strokeRange)}", + color = Color.White.copy(alpha = 0.86f), + style = MaterialTheme.typography.labelMedium + ) + Slider( + value = sliderValue, + onValueChange = onStrokeWidthChange, + valueRange = strokeRange, + colors = SliderDefaults.colors( + thumbColor = Color.White, + activeTrackColor = if (selectedTool == PdfInkTool.ERASER) Color.White else Color(selectedColor).copy(alpha = 1f), + inactiveTrackColor = Color.White.copy(alpha = 0.18f) + ) + ) + } + } + + if (selectedTool.isHighlighter) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "Straight line", + color = Color.White.copy(alpha = 0.86f), + style = MaterialTheme.typography.labelMedium + ) + Switch( + checked = isHighlighterSnapEnabled, + onCheckedChange = onHighlighterSnapChange, + colors = SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = Color(selectedColor).copy(alpha = 1f), + uncheckedThumbColor = Color.Gray, + uncheckedTrackColor = Color.White.copy(alpha = 0.16f) + ) + ) + } + } + } + } +} + +@Composable +fun SharedPdfTextAnnotationDock( + style: SharedPdfTextStyleConfig, + onStyleChange: (SharedPdfTextStyleConfig) -> Unit, + modifier: Modifier = Modifier +) { + Surface( + color = Color(0xFF1E1E1E), + contentColor = Color.White, + shape = RoundedCornerShape(18.dp), + shadowElevation = 8.dp, + modifier = modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(14.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + SharedPdfTextStyleControls( + style = style, + onStyleChange = onStyleChange, + dark = true + ) + } + } +} + +@Composable +fun SharedPdfInlineTextEditorOverlay( + draft: SharedPdfTextDraft?, + canvasSize: IntSize, + onTextChange: (String) -> Unit, + onBoundsChange: (PdfPageBounds) -> Unit, + modifier: Modifier = Modifier +) { + if (draft == null || canvasSize.width <= 0 || canvasSize.height <= 0) return + + SharedPdfTextBoxEditorOverlay( + id = draft.id, + text = draft.text, + style = draft.style, + bounds = draft.bounds, + canvasSize = canvasSize, + onTextChange = onTextChange, + onBoundsChange = onBoundsChange, + modifier = modifier + ) +} + +@Composable +fun SharedPdfTextBoxEditorOverlay( + id: String, + text: String, + style: SharedPdfTextStyleConfig, + bounds: PdfPageBounds, + canvasSize: IntSize, + onTextChange: (String) -> Unit, + onBoundsChange: (PdfPageBounds) -> Unit, + modifier: Modifier = Modifier +) { + if (canvasSize.width <= 0 || canvasSize.height <= 0) return + + val density = LocalDensity.current + val focusRequester = remember(id) { FocusRequester() } + var liveBounds by remember(id) { mutableStateOf(bounds) } + var isResizing by remember(id) { mutableStateOf(false) } + + LaunchedEffect(bounds) { + if (!isResizing) { + liveBounds = bounds + } + } + + val leftPx = liveBounds.left * canvasSize.width + val topPx = liveBounds.top * canvasSize.height + val widthPx = ((liveBounds.right - liveBounds.left) * canvasSize.width).coerceAtLeast(50f) + val heightPx = ((liveBounds.bottom - liveBounds.top) * canvasSize.height).coerceAtLeast(50f) + val textColor = Color(style.colorArgb) + val backgroundColor = Color(style.backgroundColorArgb) + val handleSize = 10.dp + val handleTouchSize = 38.dp + val handleTouchSizePx = with(density) { handleTouchSize.toPx() } + val moveHandleWidth = 54.dp + val moveHandleHeight = 24.dp + val moveHandleWidthPx = with(density) { moveHandleWidth.toPx() } + val moveHandleHeightPx = with(density) { moveHandleHeight.toPx() } + val moveHandleBelow = topPx + heightPx + moveHandleHeightPx + 10f <= canvasSize.height + + LaunchedEffect(id, style) { + focusRequester.requestFocus() + } + + Box(modifier = modifier.fillMaxSize()) { + BasicTextField( + value = text, + onValueChange = onTextChange, + textStyle = TextStyle( + color = textColor, + fontSize = style.fontSize.sp, + lineHeight = (style.fontSize * 1.25f).sp, + fontWeight = if (style.isBold) FontWeight.Bold else FontWeight.Normal, + fontStyle = if (style.isItalic) FontStyle.Italic else FontStyle.Normal, + fontFamily = sharedPdfFontFamily(style.fontName ?: style.fontPath), + textDecoration = style.textDecoration + ), + cursorBrush = SolidColor(textColor), + modifier = Modifier + .offset { IntOffset(leftPx.roundToInt(), topPx.roundToInt()) } + .width(with(density) { widthPx.toDp() }) + .height(with(density) { heightPx.toDp() }) + .background( + color = if (style.backgroundColorArgb.isTransparentArgb()) { + Color.Transparent + } else { + backgroundColor + }, + shape = RoundedCornerShape(4.dp) + ) + .border( + width = 1.dp, + color = Color(0xFF64B5F6), + shape = RoundedCornerShape(4.dp) + ) + .padding(horizontal = 8.dp, vertical = 6.dp) + .verticalScroll(rememberScrollState()) + .focusRequester(focusRequester) + ) + + SharedPdfTextResizeHandle.entries.forEach { handle -> + val center = handle.centerOffset( + leftPx = leftPx, + topPx = topPx, + widthPx = widthPx, + heightPx = heightPx + ) + Box( + modifier = Modifier + .offset { + IntOffset( + (center.x - handleTouchSizePx / 2f).roundToInt(), + (center.y - handleTouchSizePx / 2f).roundToInt() + ) + } + .size(handleTouchSize) + .pointerInput(id, handle, canvasSize) { + detectDragGestures( + onDragStart = { + isResizing = true + }, + onDragEnd = { + isResizing = false + onBoundsChange(liveBounds) + }, + onDragCancel = { + isResizing = false + liveBounds = bounds + }, + onDrag = { change, dragAmount -> + change.consume() + liveBounds = liveBounds.resizedBy( + handle = handle, + deltaXPx = dragAmount.x, + deltaYPx = dragAmount.y, + canvasSize = canvasSize + ) + } + ) + }, + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .size(handleSize) + .background(Color(0xFF64B5F6), CircleShape) + .border(1.dp, Color.White.copy(alpha = 0.92f), CircleShape) + ) + } + } + + Box( + modifier = Modifier + .offset { + IntOffset( + (leftPx + (widthPx / 2f) - (moveHandleWidthPx / 2f)).roundToInt(), + if (moveHandleBelow) { + (topPx + heightPx + 8f).roundToInt() + } else { + (topPx - moveHandleHeightPx - 8f).roundToInt() + } + ) + } + .size(width = moveHandleWidth, height = moveHandleHeight) + .clip(CircleShape) + .background(Color(0xFF64B5F6)) + .border(1.dp, Color.White.copy(alpha = 0.92f), CircleShape) + .pointerInput(id, canvasSize) { + detectDragGestures( + onDragStart = { + isResizing = true + }, + onDragEnd = { + isResizing = false + onBoundsChange(liveBounds) + }, + onDragCancel = { + isResizing = false + liveBounds = bounds + }, + onDrag = { change, dragAmount -> + change.consume() + liveBounds = liveBounds.movedBy( + deltaXPx = dragAmount.x, + deltaYPx = dragAmount.y, + canvasSize = canvasSize + ) + } + ) + }, + contentAlignment = Alignment.Center + ) { + Canvas(Modifier.size(width = 24.dp, height = 10.dp)) { + val lineColor = Color.White.copy(alpha = 0.92f) + drawLine( + color = lineColor, + start = Offset(size.width * 0.2f, size.height * 0.25f), + end = Offset(size.width * 0.8f, size.height * 0.25f), + strokeWidth = 2f + ) + drawLine( + color = lineColor, + start = Offset(size.width * 0.2f, size.height * 0.75f), + end = Offset(size.width * 0.8f, size.height * 0.75f), + strokeWidth = 2f + ) + } + } + } +} + +@Composable +fun SharedPdfTextStyleControls( + style: SharedPdfTextStyleConfig, + onStyleChange: (SharedPdfTextStyleConfig) -> Unit, + modifier: Modifier = Modifier, + dark: Boolean = false +) { + val labelColor = if (dark) Color.White.copy(alpha = 0.86f) else MaterialTheme.colorScheme.onSurfaceVariant + val buttonTextColor = if (dark) Color.White else MaterialTheme.colorScheme.onSurface + val selectedBackground = if (dark) Color.White.copy(alpha = 0.18f) else MaterialTheme.colorScheme.primary.copy(alpha = 0.16f) + val unselectedBackground = if (dark) Color.White.copy(alpha = 0.08f) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.65f) + var fontMenuExpanded by remember { mutableStateOf(false) } + + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text("Font", color = labelColor, style = MaterialTheme.typography.labelMedium) + Box { + TextButton(onClick = { fontMenuExpanded = true }) { + Text( + text = style.displayFontName(), + color = buttonTextColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + DropdownMenu( + expanded = fontMenuExpanded, + onDismissRequest = { fontMenuExpanded = false } + ) { + SharedPdfTextAnnotationDefaults.fontPresets.forEach { preset -> + DropdownMenuItem( + text = { Text(preset.name) }, + onClick = { + onStyleChange(style.withFontPreset(preset)) + fontMenuExpanded = false + } + ) + } + } + } + } + + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + SharedPdfTextAnnotationDefaults.fontSizes.chunked(4).forEach { rowSizes -> + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + rowSizes.forEach { size -> + SharedTextStyleChoiceButton( + selected = style.fontSize.toInt() == size.toInt(), + selectedBackground = selectedBackground, + unselectedBackground = unselectedBackground, + onClick = { onStyleChange(style.copy(fontSize = size)) } + ) { + Text( + text = size.toInt().toString(), + color = buttonTextColor, + style = MaterialTheme.typography.labelSmall + ) + } + } + } + } + } + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + SharedTextStyleChoiceButton( + selected = style.isBold, + selectedBackground = selectedBackground, + unselectedBackground = unselectedBackground, + onClick = { onStyleChange(style.copy(isBold = !style.isBold)) } + ) { + Text("B", color = buttonTextColor, fontWeight = FontWeight.Bold) + } + SharedTextStyleChoiceButton( + selected = style.isItalic, + selectedBackground = selectedBackground, + unselectedBackground = unselectedBackground, + onClick = { onStyleChange(style.copy(isItalic = !style.isItalic)) } + ) { + Text("I", color = buttonTextColor, fontStyle = FontStyle.Italic) + } + SharedTextStyleChoiceButton( + selected = style.isUnderline, + selectedBackground = selectedBackground, + unselectedBackground = unselectedBackground, + onClick = { onStyleChange(style.copy(isUnderline = !style.isUnderline)) } + ) { + Text("U", color = buttonTextColor, textDecoration = TextDecoration.Underline) + } + SharedTextStyleChoiceButton( + selected = style.isStrikeThrough, + selectedBackground = selectedBackground, + unselectedBackground = unselectedBackground, + onClick = { onStyleChange(style.copy(isStrikeThrough = !style.isStrikeThrough)) } + ) { + Text("S", color = buttonTextColor, textDecoration = TextDecoration.LineThrough) + } + } + + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text("Text", color = labelColor, style = MaterialTheme.typography.labelMedium) + SharedTextColorSwatches( + palette = SharedPdfTextAnnotationDefaults.textColorPalette, + selectedArgb = style.colorArgb, + allowTransparent = false, + dark = dark, + onColorSelected = { onStyleChange(style.copy(colorArgb = it)) } + ) + } + + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text("Fill", color = labelColor, style = MaterialTheme.typography.labelMedium) + SharedTextColorSwatches( + palette = SharedPdfTextAnnotationDefaults.backgroundColorPalette, + selectedArgb = style.backgroundColorArgb, + allowTransparent = true, + dark = dark, + onColorSelected = { onStyleChange(style.copy(backgroundColorArgb = it)) } + ) + } + } +} + +@Composable +fun SharedPdfAnnotationOverlay( + annotations: List, + activeStroke: List, + canvasSize: IntSize, + activeTool: PdfInkTool = PdfInkTool.PEN, + activeStrokeColorArgb: Int = 0xFF1976D2.toInt(), + activeStrokeWidth: Float = SharedPdfAnnotationDefaults.configFor(PdfInkTool.PEN).strokeWidth, + selectedAnnotationId: String? = null +) { + if (canvasSize.width <= 0 || canvasSize.height <= 0) return + val density = LocalDensity.current + + Box(Modifier.fillMaxSize()) { + Canvas(Modifier.fillMaxSize()) { + annotations.forEach { annotation -> + val isSelected = annotation.matchesSelectedAnnotation(selectedAnnotationId) + if (isSelected && annotation.kind == PdfAnnotationKind.INK) { + SharedPdfInkRenderer.createRenderData(annotation, canvasSize)?.let { renderData -> + drawInkRenderData(renderData, selectedOutline = true) + } + } + + when (annotation.kind) { + PdfAnnotationKind.HIGHLIGHT -> { + val highlightBounds = annotation.boundsList.ifEmpty { listOfNotNull(annotation.bounds) } + highlightBounds.forEach { bounds -> + drawRect( + color = Color(annotation.colorArgb), + topLeft = bounds.topLeft(canvasSize), + size = bounds.size(canvasSize), + blendMode = BlendMode.Multiply + ) + } + } + PdfAnnotationKind.INK -> { + SharedPdfInkRenderer.createRenderData(annotation, canvasSize)?.let(::drawInkRenderData) + } + PdfAnnotationKind.TEXT -> { + val bounds = annotation.bounds ?: return@forEach + if (!annotation.backgroundArgb.isTransparentArgb()) { + drawRoundRect( + color = Color(annotation.backgroundArgb), + topLeft = bounds.topLeft(canvasSize), + size = bounds.size(canvasSize), + cornerRadius = CornerRadius(4f, 4f) + ) + } + } + } + + if (isSelected && annotation.kind != PdfAnnotationKind.INK) { + val bounds = annotation.bounds ?: annotation.boundsList.firstOrNull() ?: return@forEach + drawRect( + color = Color(0xFF64B5F6), + topLeft = bounds.topLeft(canvasSize), + size = bounds.size(canvasSize), + style = Stroke(width = 2f) + ) + } + } + + if (activeStroke.size > 1) { + val activeAnnotation = SharedPdfAnnotation( + id = "active", + pageIndex = 0, + kind = PdfAnnotationKind.INK, + tool = activeTool, + points = activeStroke, + colorArgb = activeStrokeColorArgb, + strokeWidth = activeStrokeWidth + ) + SharedPdfInkRenderer.createRenderData(activeAnnotation, canvasSize)?.let(::drawInkRenderData) + } + } + + annotations + .filter { it.kind == PdfAnnotationKind.TEXT && it.text.isNotBlank() } + .forEach { annotation -> + val bounds = annotation.bounds ?: return@forEach + val leftPx = bounds.left * canvasSize.width + val topPx = bounds.top * canvasSize.height + val widthPx = ((bounds.right - bounds.left) * canvasSize.width).coerceAtLeast(24f) + val heightPx = ((bounds.bottom - bounds.top) * canvasSize.height).coerceAtLeast(18f) + Text( + text = annotation.text, + color = Color(annotation.colorArgb), + fontSize = annotation.fontSize.sp, + lineHeight = (annotation.fontSize * 1.25f).sp, + fontWeight = if (annotation.isBold) FontWeight.Bold else FontWeight.Normal, + fontStyle = if (annotation.isItalic) FontStyle.Italic else FontStyle.Normal, + fontFamily = annotation.sharedPdfTextFontFamily(), + textDecoration = annotation.textDecoration, + overflow = TextOverflow.Ellipsis, + maxLines = SharedPdfTextAnnotationDefaults.estimateLineCount(annotation.text, annotation.fontSize, widthPx), + modifier = Modifier + .offset { IntOffset(leftPx.roundToInt(), topPx.roundToInt()) } + .width(with(density) { widthPx.toDp() }) + .heightIn( + min = with(density) { heightPx.toDp() }, + max = with(density) { heightPx.toDp() } + ) + .padding(horizontal = 6.dp, vertical = 4.dp) + ) + } + } +} + +@Composable +fun SharedPdfPageNumberOverlay( + pageIndex: Int, + pageCount: Int, + modifier: Modifier = Modifier, + isDarkPage: Boolean = false +) { + if (pageCount <= 0 || pageIndex !in 0 until pageCount) return + val textColor = if (isDarkPage) Color.White else Color.Black + Box(modifier = modifier.fillMaxSize()) { + Text( + text = "${pageIndex + 1}/$pageCount", + color = textColor.copy(alpha = 0.5f), + style = MaterialTheme.typography.labelSmall.copy( + fontSize = 12.sp, + fontWeight = FontWeight.Bold + ), + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 12.dp, bottom = 12.dp) + ) + } +} + +@Composable +fun SharedPdfEmbeddedAnnotationOverlay( + annotations: List, + canvasSize: IntSize, + selectedAnnotationId: String? = null +) { + if (annotations.isEmpty() || canvasSize.width <= 0 || canvasSize.height <= 0) return + Canvas(Modifier.fillMaxSize()) { + annotations.forEach { annotation -> + val bounds = annotation.bounds + val isSelected = annotation.id == selectedAnnotationId + val color = if (isSelected) Color(0xFF1976D2) else Color(0xFFFF9800) + drawRect( + color = color.copy(alpha = if (isSelected) 0.12f else 0.07f), + topLeft = bounds.topLeft(canvasSize), + size = bounds.size(canvasSize) + ) + drawRect( + color = color, + topLeft = bounds.topLeft(canvasSize), + size = bounds.size(canvasSize), + style = Stroke(width = if (isSelected) 2.5f else 1.25f) + ) + } + } +} + +@Composable +private fun SharedPdfToolButton( + tool: PdfInkTool, + selectedTool: PdfInkTool, + selectedColor: Int, + strokeWidth: Float, + onToolSelected: (PdfInkTool) -> Unit +) { + val selected = tool == selectedTool + val toolColor = if (selected) { + selectedColor + } else { + SharedPdfAnnotationDefaults.configFor(tool).colorArgb + } + Box( + modifier = Modifier + .size(38.dp) + .clip(CircleShape) + .background(Color.White.copy(alpha = if (selected) 0.16f else 0f)) + .clickable { onToolSelected(tool) }, + contentAlignment = Alignment.Center + ) { + when (tool) { + PdfInkTool.TEXT -> Icon( + imageVector = Icons.Default.TextFields, + contentDescription = "text", + tint = Color.White, + modifier = Modifier.size(20.dp) + ) + PdfInkTool.ERASER -> Icon( + imageVector = Icons.Default.Remove, + contentDescription = "eraser", + tint = Color.White, + modifier = Modifier.size(20.dp) + ) + else -> SharedPdfPenIcon( + tool = tool, + color = Color(toolColor).copy(alpha = 1f), + inkColor = Color(toolColor), + isSelected = selected, + strokeWidth = strokeWidth, + modifier = Modifier.size(width = 28.dp, height = 34.dp) + ) + } + } +} + +@Composable +private fun SharedTextStyleChoiceButton( + selected: Boolean, + selectedBackground: Color, + unselectedBackground: Color, + onClick: () -> Unit, + content: @Composable () -> Unit +) { + Box( + modifier = Modifier + .size(34.dp) + .clip(CircleShape) + .background(if (selected) selectedBackground else unselectedBackground) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center + ) { + content() + } +} + +@Composable +private fun SharedTextColorSwatches( + palette: List, + selectedArgb: Int, + allowTransparent: Boolean, + dark: Boolean, + onColorSelected: (Int) -> Unit +) { + val borderBase = if (dark) Color.White else Color.Black + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + palette + .filter { allowTransparent || !it.isTransparentArgb() } + .forEach { argb -> + val selected = argb == selectedArgb || (argb.isTransparentArgb() && selectedArgb.isTransparentArgb()) + Box( + modifier = Modifier + .size(28.dp) + .clip(CircleShape) + .background(if (argb.isTransparentArgb()) Color.Transparent else Color(argb).copy(alpha = 1f)) + .border( + width = if (selected) 2.dp else 1.dp, + color = if (selected) borderBase.copy(alpha = 0.88f) else borderBase.copy(alpha = 0.22f), + shape = CircleShape + ) + .clickable { onColorSelected(argb) }, + contentAlignment = Alignment.Center + ) { + if (argb.isTransparentArgb()) { + Canvas(Modifier.fillMaxSize().padding(5.dp)) { + drawCircle(color = borderBase.copy(alpha = 0.18f)) + drawLine( + color = borderBase.copy(alpha = 0.68f), + start = Offset(size.width * 0.22f, size.height * 0.78f), + end = Offset(size.width * 0.78f, size.height * 0.22f), + strokeWidth = 2f + ) + } + } + } + } + } +} + +@Composable +private fun DockCircleButton( + onClick: () -> Unit, + content: @Composable () -> Unit +) { + Box( + modifier = Modifier + .size(34.dp) + .clip(CircleShape) + .background(Color.White.copy(alpha = 0.10f)) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center + ) { + content() + } +} + +@Composable +private fun SharedPdfPenIcon( + tool: PdfInkTool, + color: Color, + inkColor: Color, + isSelected: Boolean, + strokeWidth: Float, + modifier: Modifier = Modifier +) { + val animatedBodyColor by animateColorAsState(targetValue = color, label = "shared_pen_color") + val animatedInkColor by animateColorAsState(targetValue = inkColor, label = "shared_ink_color") + val inkProgress by animateFloatAsState( + targetValue = if (isSelected) 1f else 0f, + animationSpec = tween(durationMillis = 450, easing = LinearEasing), + label = "shared_ink_progress" + ) + + Canvas(modifier = modifier) { + val penWidth = size.width * 0.65f + val startX = (size.width - penWidth) / 2f + val tipHeight = size.height * 0.45f + val collarHeight = size.height * 0.15f + val bodyHeight = size.height * 0.35f + val topPadding = size.height * 0.05f + val tipRect = Rect(Offset(startX, topPadding), Size(penWidth, tipHeight)) + val collarRect = Rect(Offset(startX, topPadding + tipHeight), Size(penWidth, collarHeight)) + val bodyRect = Rect(Offset(startX, topPadding + tipHeight + collarHeight), Size(penWidth, bodyHeight)) + + drawMatteCylinder(Color(0xFF454545), bodyRect) + when (tool) { + PdfInkTool.FOUNTAIN_PEN -> { + drawMatteCylinder(animatedBodyColor, collarRect) + drawFountainNib(Color(0xFFCFD8DC), animatedBodyColor, tipRect) + } + PdfInkTool.PENCIL -> { + drawMatteCylinder(animatedBodyColor, collarRect) + drawPencilHead(animatedBodyColor, tipRect) + } + PdfInkTool.HIGHLIGHTER -> drawHighlighterChiselParts(animatedBodyColor, collarRect, tipRect) + PdfInkTool.HIGHLIGHTER_ROUND -> drawHighlighterRoundParts(animatedBodyColor, collarRect, tipRect) + PdfInkTool.PEN -> { + drawMatteCylinder(animatedBodyColor, collarRect) + drawMarkerHead(animatedBodyColor, tipRect) + } + PdfInkTool.TEXT, + PdfInkTool.ERASER -> Unit + } + + if (inkProgress > 0.01f) { + drawInkPreview( + tool = tool, + color = animatedInkColor, + progress = inkProgress, + startPoint = Offset(size.width / 2f, topPadding - 1f), + strokeWidth = strokeWidth + ) + } + } +} + +fun Offset.toSharedPdfPoint(size: IntSize, timestamp: Long): PdfPagePoint { + val width = size.width.coerceAtLeast(1) + val height = size.height.coerceAtLeast(1) + return PdfPagePoint( + x = (x / width).coerceIn(0f, 1f), + y = (y / height).coerceIn(0f, 1f), + timestamp = timestamp + ) +} + +fun pageBoundsFromSharedPdfPoint(point: Offset, size: IntSize): PdfPageBounds { + val width = size.width.coerceAtLeast(1) + val height = size.height.coerceAtLeast(1) + val left = (point.x / width).coerceIn(0f, 0.92f) + val top = (point.y / height).coerceIn(0f, 0.95f) + return PdfPageBounds( + left = left, + top = top, + right = (left + 0.32f).coerceAtMost(1f), + bottom = (top + 0.08f).coerceAtMost(1f) + ) +} + +fun SharedPdfAnnotation.sharedPdfHitTest( + point: Offset, + size: IntSize, + lastPoint: Offset? = null, + eraserStrokeWidth: Float = SharedPdfAnnotationDefaults.configFor(PdfInkTool.ERASER).strokeWidth +): Boolean { + val pageWidthPx = size.width.coerceAtLeast(1).toFloat() + val pageAspectRatio = size.width.toFloat() / size.height.coerceAtLeast(1).toFloat() + return SharedPdfInkRenderer.isAnnotationHit( + annotation = this, + hitPoint = point.toSharedPdfPoint(size, timestamp = 0L), + pageWidthPx = pageWidthPx, + pageAspectRatio = pageAspectRatio, + eraserStrokeWidth = eraserStrokeWidth, + lastHitPoint = lastPoint?.toSharedPdfPoint(size, timestamp = 0L) + ) +} + +fun SharedPdfEmbeddedAnnotation.sharedPdfEmbeddedHitTest( + point: Offset, + size: IntSize, + tolerancePx: Float = 24f +): Boolean { + val rect = bounds + val left = (rect.left * size.width) - tolerancePx + val top = (rect.top * size.height) - tolerancePx + val right = (rect.right * size.width) + tolerancePx + val bottom = (rect.bottom * size.height) + tolerancePx + return point.x in left..right && point.y in top..bottom +} + +private fun DrawScope.drawInkRenderData( + renderData: SharedPdfInkRenderData, + selectedOutline: Boolean = false +) { + when (renderData) { + is SharedPdfInkRenderData.Standard -> { + drawPath( + path = renderData.path, + color = if (selectedOutline) Color(0xFF64B5F6).copy(alpha = 0.30f) else renderData.color, + style = Stroke( + width = if (selectedOutline) renderData.strokeWidthPx + 7f else renderData.strokeWidthPx, + cap = renderData.cap, + join = StrokeJoin.Round + ), + blendMode = if (selectedOutline) BlendMode.SrcOver else renderData.blendMode + ) + } + is SharedPdfInkRenderData.Fountain -> { + drawPath( + path = renderData.path, + color = if (selectedOutline) Color(0xFF64B5F6).copy(alpha = 0.30f) else renderData.color, + style = Fill + ) + } + is SharedPdfInkRenderData.Pencil -> { + val color = if (selectedOutline) { + Color(0xFF64B5F6).copy(alpha = 0.28f) + } else { + renderData.color.copy(alpha = renderData.color.alpha * renderData.velocityAlpha) + } + val width = if (selectedOutline) renderData.strokeWidthPx + 7f else renderData.strokeWidthPx + drawPath( + path = renderData.path, + color = color, + style = Stroke(width = width, cap = StrokeCap.Round, join = StrokeJoin.Round) + ) + if (!selectedOutline) { + translate(left = 0.7f, top = 0.4f) { + drawPath( + path = renderData.path, + color = renderData.color.copy(alpha = renderData.color.alpha * 0.18f), + style = Stroke(width = (width * 0.55f).coerceAtLeast(0.5f), cap = StrokeCap.Round, join = StrokeJoin.Round) + ) + } + } + } + } +} + +private fun DrawScope.drawMatteCylinder(color: Color, rect: Rect) { + drawRect( + brush = Brush.horizontalGradient( + 0.0f to color.darker(0.6f), + 0.3f to color.lighter(0.1f), + 0.5f to color, + 0.85f to color.darker(0.5f), + 1.0f to color.darker(0.7f), + startX = rect.left, + endX = rect.right + ), + topLeft = rect.topLeft, + size = rect.size + ) +} + +private fun DrawScope.drawFountainNib(metalColor: Color, inkColor: Color, rect: Rect) { + val centerX = rect.left + rect.width / 2f + val path = Path().apply { + moveTo(rect.left + rect.width * 0.15f, rect.bottom) + lineTo(rect.right - rect.width * 0.15f, rect.bottom) + cubicTo(rect.right - rect.width * 0.1f, rect.bottom - rect.height * 0.6f, rect.right, rect.top + rect.height * 0.2f, centerX, rect.top) + cubicTo(rect.left, rect.top + rect.height * 0.2f, rect.left + rect.width * 0.1f, rect.bottom - rect.height * 0.6f, rect.left + rect.width * 0.15f, rect.bottom) + close() + } + drawPath( + path = path, + brush = Brush.horizontalGradient( + 0.0f to metalColor.darker(0.6f), + 0.4f to Color.White, + 0.6f to metalColor, + 1.0f to metalColor.darker(0.6f), + startX = rect.left, + endX = rect.right + ) + ) + drawCircle(Color.Black.copy(alpha = 0.7f), radius = rect.width * 0.06f, center = Offset(centerX, rect.bottom - rect.height * 0.5f)) + drawLine(Color.Black.copy(alpha = 0.6f), start = Offset(centerX, rect.top), end = Offset(centerX, rect.bottom - rect.height * 0.5f), strokeWidth = 1.2f) + drawCircle(inkColor.copy(alpha = 0.5f), radius = rect.width * 0.04f, center = Offset(centerX, rect.bottom - rect.height * 0.5f)) +} + +private fun DrawScope.drawMarkerHead(inkColor: Color, rect: Rect) { + val centerX = rect.left + rect.width / 2f + val plasticColor = Color(0xFF616161) + val coneHeight = rect.height * 0.8f + val conePath = Path().apply { + moveTo(rect.left, rect.bottom) + lineTo(rect.right, rect.bottom) + lineTo(centerX + rect.width * 0.15f, rect.top + (rect.height - coneHeight)) + lineTo(centerX - rect.width * 0.15f, rect.top + (rect.height - coneHeight)) + close() + } + drawPath( + path = conePath, + brush = Brush.horizontalGradient( + 0.0f to plasticColor.darker(0.5f), + 0.5f to plasticColor, + 1.0f to plasticColor.darker(0.5f), + startX = rect.left, + endX = rect.right + ) + ) + val tipPath = Path().apply { + moveTo(centerX - rect.width * 0.15f, rect.top + (rect.height - coneHeight)) + lineTo(centerX + rect.width * 0.15f, rect.top + (rect.height - coneHeight)) + quadraticTo(centerX, rect.top, centerX, rect.top) + close() + } + drawPath(path = tipPath, color = inkColor) +} + +private fun DrawScope.drawPencilHead(inkColor: Color, rect: Rect) { + val centerX = rect.left + rect.width / 2f + val woodColor = Color(0xFFFFCC80) + val woodPath = Path().apply { + moveTo(rect.left, rect.bottom) + val scallops = 3 + val step = rect.width / scallops + for (i in 0 until scallops) { + quadraticTo( + rect.left + i * step + step / 2f, + rect.bottom - rect.width * 0.1f, + rect.left + (i + 1) * step, + rect.bottom + ) + } + lineTo(centerX + rect.width * 0.12f, rect.top + rect.height * 0.25f) + lineTo(centerX - rect.width * 0.12f, rect.top + rect.height * 0.25f) + close() + } + drawPath( + path = woodPath, + brush = Brush.horizontalGradient( + 0.0f to woodColor.darker(0.3f), + 0.5f to woodColor.lighter(0.1f), + 1.0f to woodColor.darker(0.3f), + startX = rect.left, + endX = rect.right + ) + ) + val leadPath = Path().apply { + moveTo(centerX - rect.width * 0.12f, rect.top + rect.height * 0.25f) + lineTo(centerX + rect.width * 0.12f, rect.top + rect.height * 0.25f) + lineTo(centerX, rect.top) + close() + } + drawPath(path = leadPath, color = inkColor) +} + +private fun DrawScope.drawHighlighterChiselParts(color: Color, collarRect: Rect, tipRect: Rect) { + drawMatteCylinder(color, collarRect) + val bodyColor = Color(0xFF454545) + val neckHeight = tipRect.height * 0.65f + val inkTipHeight = tipRect.height - neckHeight + val neckTopY = tipRect.bottom - neckHeight + val centerX = tipRect.center.x + val neckTopHalfWidth = tipRect.width * 0.25f + val neckPath = Path().apply { + moveTo(tipRect.left, tipRect.bottom) + lineTo(tipRect.right, tipRect.bottom) + lineTo(centerX + neckTopHalfWidth, neckTopY) + lineTo(centerX - neckTopHalfWidth, neckTopY) + close() + } + drawPath( + path = neckPath, + brush = Brush.horizontalGradient( + 0.0f to bodyColor.darker(0.6f), + 0.3f to bodyColor.lighter(0.1f), + 0.5f to bodyColor, + 0.85f to bodyColor.darker(0.5f), + 1.0f to bodyColor.darker(0.7f), + startX = tipRect.left, + endX = tipRect.right + ) + ) + + val slantDrop = inkTipHeight * 0.4f + val tipPath = Path().apply { + moveTo(centerX - neckTopHalfWidth, neckTopY) + lineTo(centerX + neckTopHalfWidth, neckTopY) + lineTo(centerX + neckTopHalfWidth, tipRect.top + slantDrop) + lineTo(centerX - neckTopHalfWidth, tipRect.top) + close() + } + drawPath( + path = tipPath, + brush = Brush.horizontalGradient( + 0.0f to color.darker(0.8f), + 0.5f to color, + 1.0f to color.darker(0.8f), + startX = centerX - neckTopHalfWidth, + endX = centerX + neckTopHalfWidth + ) + ) +} + +private fun DrawScope.drawHighlighterRoundParts(color: Color, collarRect: Rect, tipRect: Rect) { + drawMatteCylinder(color, collarRect) + val bodyColor = Color(0xFF454545) + val neckHeight = tipRect.height * 0.65f + val neckTopY = tipRect.bottom - neckHeight + val centerX = tipRect.center.x + val neckTopHalfWidth = tipRect.width * 0.25f + val neckPath = Path().apply { + moveTo(tipRect.left, tipRect.bottom) + lineTo(tipRect.right, tipRect.bottom) + lineTo(centerX + neckTopHalfWidth, neckTopY) + lineTo(centerX - neckTopHalfWidth, neckTopY) + close() + } + drawPath( + path = neckPath, + brush = Brush.horizontalGradient( + 0.0f to bodyColor.darker(0.6f), + 0.3f to bodyColor.lighter(0.1f), + 0.5f to bodyColor, + 0.85f to bodyColor.darker(0.5f), + 1.0f to bodyColor.darker(0.7f), + startX = tipRect.left, + endX = tipRect.right + ) + ) + val tipHeight = tipRect.height - neckHeight + val domeRect = Rect( + left = centerX - neckTopHalfWidth, + top = neckTopY - tipHeight, + right = centerX + neckTopHalfWidth, + bottom = neckTopY + ) + val domePath = Path().apply { + moveTo(domeRect.left, domeRect.bottom) + lineTo(domeRect.right, domeRect.bottom) + arcTo(domeRect, startAngleDegrees = 0f, sweepAngleDegrees = -180f, forceMoveTo = false) + close() + } + drawPath( + path = domePath, + brush = Brush.radialGradient( + colors = listOf(color.lighter(0.3f), color, color.darker(0.6f)), + center = Offset(domeRect.center.x - domeRect.width * 0.2f, domeRect.top + domeRect.height * 0.4f), + radius = domeRect.width + ) + ) +} + +private fun DrawScope.drawInkPreview( + tool: PdfInkTool, + color: Color, + progress: Float, + startPoint: Offset, + strokeWidth: Float +) { + val path = Path().apply { + moveTo(startPoint.x, startPoint.y) + if (tool.isHighlighter) { + val waveWidth = 46f + cubicTo(startPoint.x + waveWidth * 0.35f, startPoint.y - 12f, startPoint.x + waveWidth * 0.65f, startPoint.y + 12f, startPoint.x + waveWidth, startPoint.y) + } else { + cubicTo(startPoint.x + 22f, startPoint.y - 24f, startPoint.x - 22f, startPoint.y - 52f, startPoint.x - 9f, startPoint.y - 28f) + cubicTo(startPoint.x - 3f, startPoint.y - 8f, startPoint.x + 32f, startPoint.y - 16f, startPoint.x + 44f, startPoint.y - 34f) + } + } + val width = SharedPdfInkRenderer.effectiveStrokeWidthPx(strokeWidth, pageWidthPx = 700f) + .coerceIn(if (tool.isHighlighter) 5f else 1.2f, if (tool.isHighlighter) 16f else 5f) + drawPath( + path = path, + color = color.copy(alpha = color.alpha * progress), + style = Stroke( + width = width, + cap = if (tool == PdfInkTool.HIGHLIGHTER) StrokeCap.Butt else StrokeCap.Round, + join = StrokeJoin.Round + ), + blendMode = if (tool.isHighlighter) BlendMode.SrcOver else BlendMode.SrcOver + ) +} + +private fun SharedPdfAnnotation.matchesSelectedAnnotation(selectedAnnotationId: String?): Boolean { + if (selectedAnnotationId == null) return false + return id == selectedAnnotationId || id.startsWith("${selectedAnnotationId}_line_") +} + +private val PdfInkTool.isHighlighter: Boolean + get() = this == PdfInkTool.HIGHLIGHTER || this == PdfInkTool.HIGHLIGHTER_ROUND + +private val SharedPdfAnnotation.textDecoration: TextDecoration + get() { + val decorations = mutableListOf() + if (isUnderline) decorations += TextDecoration.Underline + if (isStrikeThrough) decorations += TextDecoration.LineThrough + return if (decorations.isEmpty()) TextDecoration.None else TextDecoration.combine(decorations) + } + +private val SharedPdfTextStyleConfig.textDecoration: TextDecoration + get() { + val decorations = mutableListOf() + if (isUnderline) decorations += TextDecoration.Underline + if (isStrikeThrough) decorations += TextDecoration.LineThrough + return if (decorations.isEmpty()) TextDecoration.None else TextDecoration.combine(decorations) + } + +private fun SharedPdfAnnotation.sharedPdfTextFontFamily(): FontFamily? { + return sharedPdfFontFamily(fontName ?: fontPath) +} + +private fun SharedPdfTextResizeHandle.centerOffset( + leftPx: Float, + topPx: Float, + widthPx: Float, + heightPx: Float +): Offset { + return when (this) { + SharedPdfTextResizeHandle.TOP_LEFT -> Offset(leftPx, topPx) + SharedPdfTextResizeHandle.TOP_CENTER -> Offset(leftPx + widthPx / 2f, topPx) + SharedPdfTextResizeHandle.TOP_RIGHT -> Offset(leftPx + widthPx, topPx) + SharedPdfTextResizeHandle.RIGHT_CENTER -> Offset(leftPx + widthPx, topPx + heightPx / 2f) + SharedPdfTextResizeHandle.BOTTOM_RIGHT -> Offset(leftPx + widthPx, topPx + heightPx) + SharedPdfTextResizeHandle.BOTTOM_CENTER -> Offset(leftPx + widthPx / 2f, topPx + heightPx) + SharedPdfTextResizeHandle.BOTTOM_LEFT -> Offset(leftPx, topPx + heightPx) + SharedPdfTextResizeHandle.LEFT_CENTER -> Offset(leftPx, topPx + heightPx / 2f) + } +} + +private fun SharedPdfTextStyleConfig.withFontPreset(preset: SharedPdfTextFontPreset): SharedPdfTextStyleConfig { + return copy( + fontName = preset.name.takeUnless { it == "Default" }, + fontPath = preset.fontPath + ) +} + +private fun SharedPdfTextStyleConfig.displayFontName(): String { + return fontName + ?: fontPath?.substringAfterLast('/')?.substringBeforeLast('.')?.takeIf { it.isNotBlank() } + ?: "Default" +} + +private fun sharedPdfFontFamily(nameOrPath: String?): FontFamily? { + return when (nameOrPath) { + "Merriweather", + "Lora", + "asset:fonts/merriweather.ttf", + "asset:fonts/lora.ttf" -> FontFamily.Serif + "Roboto Mono", + "asset:fonts/roboto_mono.ttf" -> FontFamily.Monospace + "Lato", + "Lexend", + "asset:fonts/lato.ttf", + "asset:fonts/lexend.ttf" -> FontFamily.SansSerif + else -> null + } +} + +private fun Int.isTransparentArgb(): Boolean { + return (this ushr 24) == 0 +} + +private fun PdfPageBounds.topLeft(canvasSize: IntSize): Offset { + return Offset(left * canvasSize.width, top * canvasSize.height) +} + +private fun PdfPageBounds.size(canvasSize: IntSize): Size { + return Size((right - left) * canvasSize.width, (bottom - top) * canvasSize.height) +} + +private fun Color.darker(factor: Float = 0.7f): Color { + return Color( + red = red * factor, + green = green * factor, + blue = blue * factor, + alpha = alpha + ) +} + +private fun Color.lighter(factor: Float = 0.3f): Color { + return Color( + red = red + (1 - red) * factor, + green = green + (1 - green) * factor, + blue = blue + (1 - blue) * factor, + alpha = alpha + ) +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedPdfRichTextUi.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedPdfRichTextUi.kt new file mode 100644 index 0000000..d7e4e89 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedPdfRichTextUi.kt @@ -0,0 +1,261 @@ +package com.aryan.reader.shared.ui + +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.isSpecified +import androidx.compose.ui.unit.sp +import com.aryan.reader.shared.pdf.SharedPdfRichTextController +import com.aryan.reader.shared.pdf.SharedPdfRichTextLog +import com.aryan.reader.shared.pdf.withoutTrailingSharedPdfPageBreak +import kotlinx.coroutines.delay +import kotlin.math.roundToInt + +@Composable +fun SharedPdfRichTextHiddenInput( + controller: SharedPdfRichTextController, + enabled: Boolean, + modifier: Modifier = Modifier +) { + LaunchedEffect(enabled, controller.activePageIndex) { + SharedPdfRichTextLog.d( + "ui.hiddenInput enabled=$enabled activePage=${controller.activePageIndex} " + + "editingLen=${controller.editingValue.text.length} selection=${controller.editingValue.selection}" + ) + if (enabled && controller.activePageIndex != -1) { + controller.requestEditingFocus() + delay(16) + controller.requestEditingFocus() + } + } + + if (!enabled) return + + BasicTextField( + value = controller.editingValue, + onValueChange = controller::onValueChanged, + textStyle = TextStyle( + color = controller.currentStyle.color, + fontSize = controller.currentStyle.fontSize, + fontWeight = controller.currentStyle.fontWeight, + fontStyle = controller.currentStyle.fontStyle, + textDecoration = controller.currentStyle.textDecoration + ), + modifier = modifier + .size(1.dp) + .alpha(0f) + .clearAndSetSemantics { } + .focusRequester(controller.focusRequester) + .onKeyEvent { event -> + event.type == KeyEventType.KeyDown && + event.key == Key.Backspace && + controller.handleBackspaceAtStart() + } + ) +} + +@Composable +fun SharedPdfRichTextLayer( + pageIndex: Int, + controller: SharedPdfRichTextController, + pageWidth: Float, + pageHeight: Float, + isTextEditingEnabled: Boolean, + centeringOffsetX: Float = 0f, + centeringOffsetY: Float = 0f, + isDarkMode: Boolean = false, + isScrolling: Boolean = false, + onPageTapped: (Int) -> Unit = {} +) { + LaunchedEffect(pageIndex, pageWidth, pageHeight, isTextEditingEnabled) { + if (pageWidth <= 0f || pageHeight <= 0f) { + SharedPdfRichTextLog.d( + "ui.layer invalidSize page=$pageIndex size=${pageWidth.richTextUiFloat()}x${pageHeight.richTextUiFloat()} " + + "editing=$isTextEditingEnabled" + ) + } + } + + if (pageWidth <= 0f || pageHeight <= 0f) return + + val density = LocalDensity.current + val textMeasurer = rememberTextMeasurer() + + LaunchedEffect(pageWidth, pageHeight, density, textMeasurer) { + controller.updateLayoutConfig(pageWidth, pageHeight, density, textMeasurer) + } + + val pageLayout = remember(controller.pageLayouts, pageIndex) { + controller.pageLayouts.find { it.pageIndex == pageIndex } + } + + LaunchedEffect( + pageIndex, + pageWidth, + pageHeight, + isTextEditingEnabled, + controller.activePageIndex, + pageLayout?.globalStartIndex, + pageLayout?.globalEndIndex + ) { + SharedPdfRichTextLog.d( + "ui.layer page=$pageIndex size=${pageWidth.richTextUiFloat()}x${pageHeight.richTextUiFloat()} " + + "editing=$isTextEditingEnabled activePage=${controller.activePageIndex} " + + "layout=${pageLayout?.globalStartIndex}-${pageLayout?.globalEndIndex} " + + "visibleLen=${pageLayout?.visibleText?.length ?: 0}" + ) + } + + val marginX = pageWidth * 0.1f + val marginY = pageHeight * 0.08f + val editorWidth = (pageWidth - (marginX * 2f)).coerceAtLeast(10f) + val editorHeight = (pageHeight - (marginY * 2f)).coerceAtLeast(10f) + val editorWidthDp = with(density) { editorWidth.toDp() } + val editorHeightDp = with(density) { editorHeight.toDp() } + + Box( + modifier = Modifier + .offset { + IntOffset( + (centeringOffsetX + marginX).roundToInt(), + (centeringOffsetY + marginY).roundToInt() + ) + } + .size(editorWidthDp, editorHeightDp) + .graphicsLayer() + .clipToBounds() + .then( + if (isTextEditingEnabled) { + Modifier.pointerInput( + pageIndex, + editorWidth, + editorHeight, + controller.activePageIndex, + pageLayout?.globalStartIndex, + pageLayout?.globalEndIndex + ) { + detectTapGestures { tapOffset -> + SharedPdfRichTextLog.d( + "ui.layer.tap page=$pageIndex offset=${tapOffset.richTextUiOffsetSummary()} " + + "editor=${editorWidth.richTextUiFloat()}x${editorHeight.richTextUiFloat()} " + + "activePage=${controller.activePageIndex} hasLayout=${pageLayout != null}" + ) + onPageTapped(pageIndex) + controller.handleTapOnPage(pageIndex, tapOffset) + } + } + } else { + Modifier + } + ) + ) { + val textToRender = if (controller.activePageIndex == pageIndex) { + controller.localTextFieldValue.annotatedString + } else { + pageLayout?.visibleText?.withoutTrailingSharedPdfPageBreak() + } ?: return@Box + + val measureResult = remember(textToRender, editorWidth, density) { + textMeasurer.measure( + text = textToRender, + style = TextStyle(fontSize = 16.sp), + constraints = Constraints(maxWidth = editorWidth.toInt()), + density = density + ) + } + + Canvas(modifier = Modifier.fillMaxSize()) { + measureResult.multiParagraph.paint(drawContext.canvas) + } + + if (isTextEditingEnabled && controller.activePageIndex == pageIndex) { + val selection = controller.editingValue.selection + val localStart = selection.start.coerceIn(0, textToRender.length) + val localEnd = selection.end.coerceIn(0, textToRender.length) + + if (localStart != localEnd) { + val selectionPath = measureResult.getPathForRange(localStart, localEnd) + Canvas(modifier = Modifier.fillMaxSize()) { + drawPath(selectionPath, Color(0xFFB3D7FF).copy(alpha = 0.5f)) + } + } + + if (selection.collapsed && controller.isCursorVisible) { + val alpha = if (isScrolling) { + 1f + } else { + val infiniteTransition = rememberInfiniteTransition(label = "pdfRichCursor") + infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 0f, + animationSpec = infiniteRepeatable(tween(500), RepeatMode.Reverse), + label = "pdfRichCursorAlpha" + ).value + } + val cursorRect = measureResult.getCursorRect(localStart) + val styleFontSize = controller.currentStyle.fontSize + val cursorHeight = if (styleFontSize.isSpecified) { + with(density) { styleFontSize.toPx() } * 1.2f + } else { + cursorRect.height + } + val centerY = cursorRect.center.y + val cursorColor = if (isDarkMode) Color.White else Color.Black + + Canvas(modifier = Modifier.fillMaxSize()) { + drawLine( + color = cursorColor.copy(alpha = alpha), + start = Offset(cursorRect.left, centerY - cursorHeight / 2f), + end = Offset(cursorRect.left, centerY + cursorHeight / 2f), + strokeWidth = 2.dp.toPx() + ) + } + } + } + } +} + +private fun Float.richTextUiFloat(): String { + return if (isFinite()) { + val rounded = kotlin.math.round(this * 10f) / 10f + rounded.toString() + } else { + toString() + } +} + +private fun Offset.richTextUiOffsetSummary(): String { + return "(${x.richTextUiFloat()},${y.richTextUiFloat()})" +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedReaderChrome.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedReaderChrome.kt new file mode 100644 index 0000000..c2b8aa8 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedReaderChrome.kt @@ -0,0 +1,2345 @@ +package com.aryan.reader.shared.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.NavigateBefore +import androidx.compose.material.icons.automirrored.filled.NavigateNext +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Bookmark +import androidx.compose.material.icons.filled.BookmarkBorder +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Palette +import androidx.compose.material.icons.filled.Psychology +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Speed +import androidx.compose.material.icons.filled.Translate +import androidx.compose.material.icons.filled.VolumeUp +import androidx.compose.material3.Button +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isCtrlPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.aryan.reader.shared.BuiltInReaderThemes +import com.aryan.reader.shared.CustomFontItem +import com.aryan.reader.shared.HighlightColor +import com.aryan.reader.shared.PageInfoMode +import com.aryan.reader.shared.PageInfoPosition +import com.aryan.reader.shared.ReaderAiByokSettings +import com.aryan.reader.shared.ReaderAiFeature +import com.aryan.reader.shared.ReaderAutoScrollState +import com.aryan.reader.shared.ReaderContextExtractor +import com.aryan.reader.shared.ReaderExtrasState +import com.aryan.reader.shared.ReaderExternalLookupAction +import com.aryan.reader.shared.ReaderAction +import com.aryan.reader.shared.ReaderHighlightPalette +import com.aryan.reader.shared.ReaderLocator +import com.aryan.reader.shared.ReaderTexture +import com.aryan.reader.shared.ReaderTextureFilePrefix +import com.aryan.reader.shared.ReaderTheme +import com.aryan.reader.shared.ReaderTool +import com.aryan.reader.shared.ReaderToolbarPreferences +import com.aryan.reader.shared.ReaderTtsChunk +import com.aryan.reader.shared.ReaderTtsPlanner +import com.aryan.reader.shared.ReaderTtsReadScope +import com.aryan.reader.shared.ReaderTtsReplacementBookSettings +import com.aryan.reader.shared.ReaderTtsReplacementEngine +import com.aryan.reader.shared.ReaderTtsReplacementPreferences +import com.aryan.reader.shared.ReaderTtsReplacementRule +import com.aryan.reader.shared.ReaderTtsReplacementSuggestions +import com.aryan.reader.shared.UserHighlight +import com.aryan.reader.shared.SystemUiMode +import com.aryan.reader.shared.reduce +import com.aryan.reader.shared.readerTextureDisplayName +import com.aryan.reader.shared.toReaderSettings +import com.aryan.reader.shared.reader.PaginatedReaderState +import com.aryan.reader.shared.reader.ReaderBookmark +import com.aryan.reader.shared.reader.ReaderEngine +import com.aryan.reader.shared.reader.ReaderHtmlDocumentBuilder +import com.aryan.reader.shared.reader.ReaderReadingMode +import com.aryan.reader.shared.reader.ReaderSearchOptions +import com.aryan.reader.shared.reader.ReaderSessionState +import com.aryan.reader.shared.reader.ReaderSettings +import com.aryan.reader.shared.reader.SharedReaderTextAlign +import kotlinx.coroutines.delay +import kotlin.math.roundToInt + +data class ReaderContentNavigationTarget( + val locator: ReaderLocator?, + val requestId: Long, + val readingMode: ReaderReadingMode, + val autoScroll: ReaderAutoScrollState = ReaderAutoScrollState(), + val ttsLocator: ReaderLocator? = null, + val ttsRequestId: Long = 0L +) + +@Composable +fun SharedScreenScaffold( + title: String, + subtitle: String, + modifier: Modifier = Modifier, + trailing: @Composable () -> Unit = {}, + content: @Composable ColumnScope.() -> Unit +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(18.dp) + ) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) + Text(subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + trailing() + } + content() + } +} + +@Composable +fun SharedReaderScreen( + session: ReaderSessionState, + readerEngine: ReaderEngine, + onSessionChange: (ReaderSessionState) -> Unit, + onOpenBook: () -> Unit, + onOpenPdf: () -> Unit, + toolbarPreferences: ReaderToolbarPreferences = ReaderToolbarPreferences(), + onToolbarPreferencesChange: (ReaderToolbarPreferences) -> Unit = {}, + highlightPalette: ReaderHighlightPalette = ReaderHighlightPalette(), + onHighlightPaletteChange: (ReaderHighlightPalette) -> Unit = {}, + ttsReplacementPreferences: ReaderTtsReplacementPreferences = ReaderTtsReplacementPreferences(), + ttsReplacementBookId: String? = null, + onTtsReplacementPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit = {}, + onPickCustomFont: (() -> String?)? = null, + customFonts: List = emptyList(), + readerExtrasState: ReaderExtrasState = ReaderExtrasState(), + aiByokSettings: ReaderAiByokSettings = ReaderAiByokSettings(), + onExternalLookup: (ReaderExternalLookupAction, String) -> Unit = { _, _ -> }, + onAiAction: (ReaderAiFeature, String) -> Unit = { _, _ -> }, + onCloudTtsStart: (ReaderTtsReadScope, List) -> Unit = { _, _ -> }, + onCloudTtsPauseResume: () -> Unit = {}, + onCloudTtsStop: () -> Unit = {}, + onCloudTtsClearCache: () -> Unit = {}, + onAutoScrollChange: (ReaderAutoScrollState) -> Unit = {}, + readerTextureDataUri: (String) -> String? = { null }, + readerCustomTextureIds: List = emptyList(), + onImportReaderTexture: ((ReaderSettings) -> ReaderSettings?)? = null, + readerContent: @Composable ColumnScope.( + html: String, + background: Color, + navigationTarget: ReaderContentNavigationTarget, + highlights: List, + onVisiblePageChanged: (Int, ReaderLocator?) -> Unit + ) -> Unit +) { + val readerState = session.reader + val page = readerState.currentPage + val settings = readerState.settings + val byokSettings = aiByokSettings.sanitized() + val background = settings.backgroundColorArgb?.toComposeColor() ?: if (settings.darkMode) Color(0xFF171A17) else Color(0xFFFFFCF5) + val pageInfoText = readerState.pageInfoText() + val shouldShowPageInfo = settings.pageInfoMode != PageInfoMode.HIDDEN + val activeTtsProgress = readerExtrasState.cloudTts.progress + val activeTtsChunk = activeTtsProgress.currentChunk + val activeTtsLocator = activeTtsChunk?.toLocator() + val ttsRequestId = activeTtsChunk?.let { activeTtsProgress.sessionId + it.index + 1L } ?: 0L + val navigationLocator = session.navigationLocator ?: session.activeSearchResult?.locator ?: readerState.currentPageLocator() + fun dispatch(action: ReaderAction) { + onSessionChange(session.reduce(action, readerEngine)) + } + val workspaceModel = epubReaderWorkspaceModel( + session = session, + toolbarPreferences = toolbarPreferences, + extrasState = readerExtrasState, + aiAvailable = byokSettings.areReaderAiFeaturesAvailable + ) + + LaunchedEffect( + readerExtrasState.autoScroll.sanitized(), + settings.readingMode, + readerState.currentPageIndex, + readerState.canGoNext + ) { + val autoScroll = readerExtrasState.autoScroll.sanitized() + if (!autoScroll.enabled || settings.readingMode != ReaderReadingMode.PAGINATED || !readerState.canGoNext) return@LaunchedEffect + val delayMs = (180_000f / autoScroll.speed).roundToInt().coerceIn(1_200, 12_000) + delay(delayMs.toLong()) + dispatch(ReaderAction.NextPage) + } + + ReaderWorkspaceShell( + model = workspaceModel, + title = readerState.book.title, + subtitle = listOfNotNull(readerState.book.author, page?.chapterTitle).joinToString(" - "), + progressLabel = "${readerState.progress.toInt()}%", + modifier = Modifier + .fillMaxSize() + .onPreviewKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + when { + event.key == Key.DirectionRight || event.key == Key.PageDown -> { + dispatch(ReaderAction.NextPage) + true + } + + event.key == Key.DirectionLeft || event.key == Key.PageUp -> { + dispatch(ReaderAction.PreviousPage) + true + } + + event.key == Key.MoveHome -> { + dispatch(ReaderAction.GoToPage(0)) + true + } + + event.key == Key.MoveEnd -> { + dispatch(ReaderAction.GoToPage(readerState.pages.lastIndex)) + true + } + + event.isCtrlPressed && event.key == Key.G -> { + dispatch(ReaderAction.NextSearchResult) + true + } + + event.isCtrlPressed && event.key == Key.F -> { + dispatch(ReaderAction.SearchOpened) + true + } + + else -> false + } + } + .focusable(), + topActions = { + TextButton(onClick = onOpenBook) { + Text("Open Book") + } + TextButton(onClick = onOpenPdf) { + Text("Open PDF") + } + SharedReaderQuickActions( + toolbarPreferences = toolbarPreferences, + bottom = false, + isBookmarked = session.currentBookmark != null, + isDarkMode = settings.darkMode, + isSearchActive = session.isSearchActive, + onToggleBookmark = { dispatch(ReaderAction.ToggleBookmark) }, + onToggleTheme = { dispatch(ReaderAction.SettingsChanged(settings.copy(darkMode = !settings.darkMode))) }, + onToggleSearch = { + dispatch(if (session.isSearchActive) ReaderAction.SearchClosed else ReaderAction.SearchOpened) + }, + onExternalLookup = onExternalLookup, + onAiAction = onAiAction, + onCloudTtsStart = onCloudTtsStart, + onCloudTtsPauseResume = onCloudTtsPauseResume, + onCloudTtsStop = onCloudTtsStop, + onCloudTtsClearCache = onCloudTtsClearCache, + onAutoScrollChange = onAutoScrollChange, + session = session, + extrasState = readerExtrasState, + aiByokSettings = byokSettings + ) + }, + leftSidebar = { + SharedReaderSidebar( + session = session, + onSearchChange = { dispatch(ReaderAction.SearchChanged(it)) }, + onPreviousSearchResult = { dispatch(ReaderAction.PreviousSearchResult) }, + onNextSearchResult = { dispatch(ReaderAction.NextSearchResult) }, + onOpenSearch = { dispatch(ReaderAction.SearchOpened) }, + onCloseSearch = { dispatch(ReaderAction.SearchClosed) }, + onToggleSearchResultsPanel = { dispatch(ReaderAction.SearchResultsPanelToggled) }, + onSearchOptionsChange = { dispatch(ReaderAction.SearchOptionsChanged(it)) }, + onGoToChapter = { dispatch(ReaderAction.GoToChapter(it)) }, + onGoToBookmark = { dispatch(ReaderAction.GoToLocator(it.locator)) }, + onGoToSearchResult = { dispatch(ReaderAction.GoToSearchResult(it)) }, + toolbarPreferences = toolbarPreferences, + highlightPalette = highlightPalette, + onHighlightPaletteChange = onHighlightPaletteChange, + onGoToHighlight = { dispatch(ReaderAction.GoToLocator(it.locator)) }, + onHighlightColorChange = { highlight, color -> + dispatch(ReaderAction.HighlightUpdated(highlight.id, color = color)) + }, + onHighlightNoteChange = { highlight, note -> + dispatch(ReaderAction.HighlightUpdated(highlight.id, note = note)) + }, + onHighlightDelete = { highlight -> + dispatch(ReaderAction.HighlightDeleted(highlight.id)) + } + ) + }, + rightInspector = { + SharedReaderControlPanel( + session = session, + toolbarPreferences = toolbarPreferences, + onToolbarPreferencesChange = onToolbarPreferencesChange, + onPickCustomFont = onPickCustomFont, + customFonts = customFonts, + extrasState = readerExtrasState, + aiByokSettings = byokSettings, + onExternalLookup = onExternalLookup, + onAiAction = onAiAction, + onCloudTtsStart = onCloudTtsStart, + onCloudTtsPauseResume = onCloudTtsPauseResume, + onCloudTtsStop = onCloudTtsStop, + onCloudTtsClearCache = onCloudTtsClearCache, + onAutoScrollChange = onAutoScrollChange, + ttsReplacementPreferences = ttsReplacementPreferences, + ttsReplacementBookId = ttsReplacementBookId ?: session.reader.book.title, + onTtsReplacementPreferencesChange = onTtsReplacementPreferencesChange, + readerCustomTextureIds = readerCustomTextureIds, + onImportReaderTexture = onImportReaderTexture, + onReaderAction = { action -> dispatch(action) } + ) + }, + bottomBar = { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 2.dp + ) { + Column(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (toolbarPreferences.isVisible(ReaderTool.SLIDER)) { + SharedReaderPageSlider( + session = session, + onPageNumberChange = { pageNumber -> dispatch(ReaderAction.GoToPageNumber(pageNumber)) } + ) + } + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Button( + enabled = readerState.canGoPrevious, + onClick = { dispatch(ReaderAction.PreviousPage) } + ) { + Icon(Icons.AutoMirrored.Filled.NavigateBefore, contentDescription = null) + Text("Previous") + } + Spacer(Modifier.weight(1f)) + if (shouldShowPageInfo && settings.pageInfoPosition == PageInfoPosition.BOTTOM) { + Text(pageInfoText) + } + Spacer(Modifier.weight(1f)) + Button( + enabled = readerState.canGoNext, + onClick = { dispatch(ReaderAction.NextPage) } + ) { + Text("Next") + Icon(Icons.AutoMirrored.Filled.NavigateNext, contentDescription = null) + } + } + SharedReaderQuickActions( + toolbarPreferences = toolbarPreferences, + bottom = true, + isBookmarked = session.currentBookmark != null, + isDarkMode = settings.darkMode, + isSearchActive = session.isSearchActive, + onToggleBookmark = { dispatch(ReaderAction.ToggleBookmark) }, + onToggleTheme = { dispatch(ReaderAction.SettingsChanged(settings.copy(darkMode = !settings.darkMode))) }, + onToggleSearch = { + dispatch(if (session.isSearchActive) ReaderAction.SearchClosed else ReaderAction.SearchOpened) + }, + onExternalLookup = onExternalLookup, + onAiAction = onAiAction, + onCloudTtsStart = onCloudTtsStart, + onCloudTtsPauseResume = onCloudTtsPauseResume, + onCloudTtsStop = onCloudTtsStop, + onCloudTtsClearCache = onCloudTtsClearCache, + onAutoScrollChange = onAutoScrollChange, + session = session, + extrasState = readerExtrasState, + aiByokSettings = byokSettings + ) + } + } + } + ) { + Column(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) { + if (shouldShowPageInfo && settings.pageInfoPosition == PageInfoPosition.TOP) { + Text(pageInfoText, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + + val html = if (settings.readingMode == ReaderReadingMode.VERTICAL) { + remember( + readerState.book, + settings, + session.searchQuery, + session.searchOptions, + highlightPalette, + readerState.pages, + byokSettings.areReaderAiFeaturesAvailable, + byokSettings.isCloudTtsAvailable + ) { + ReaderHtmlDocumentBuilder.verticalDocument( + book = readerState.book, + settings = settings, + searchQuery = session.searchQuery, + searchOptions = session.searchOptions, + highlights = emptyList(), + highlightPalette = highlightPalette, + navigationLocator = null, + pages = readerState.pages, + readerAiFeaturesEnabled = byokSettings.areReaderAiFeaturesAvailable, + cloudTtsEnabled = byokSettings.isCloudTtsAvailable, + textureDataUri = settings.textureId?.let(readerTextureDataUri) + ) + } + } else { + remember( + readerState.book, + page, + settings, + session.searchQuery, + session.searchOptions, + session.highlights, + highlightPalette, + navigationLocator, + byokSettings.areReaderAiFeaturesAvailable, + byokSettings.isCloudTtsAvailable + ) { + ReaderHtmlDocumentBuilder.pageDocument( + book = readerState.book, + page = page, + settings = settings, + searchQuery = session.searchQuery, + searchOptions = session.searchOptions, + highlights = session.highlights, + highlightPalette = highlightPalette, + navigationLocator = navigationLocator, + readerAiFeaturesEnabled = byokSettings.areReaderAiFeaturesAvailable, + cloudTtsEnabled = byokSettings.isCloudTtsAvailable, + textureDataUri = settings.textureId?.let(readerTextureDataUri) + ) + } + } + readerContent( + html, + background, + ReaderContentNavigationTarget( + locator = navigationLocator, + requestId = session.navigationRequestId, + readingMode = settings.readingMode, + autoScroll = readerExtrasState.autoScroll.sanitized(), + ttsLocator = activeTtsLocator, + ttsRequestId = ttsRequestId + ), + if (settings.readingMode == ReaderReadingMode.VERTICAL) session.highlights else emptyList(), + { pageIndex, locator -> dispatch(ReaderAction.VisiblePageChanged(pageIndex, locator)) } + ) + } + } +} + +@Composable +private fun SharedReaderQuickActions( + toolbarPreferences: ReaderToolbarPreferences, + bottom: Boolean, + isBookmarked: Boolean, + isDarkMode: Boolean, + isSearchActive: Boolean, + onToggleBookmark: () -> Unit, + onToggleTheme: () -> Unit, + onToggleSearch: () -> Unit, + onExternalLookup: (ReaderExternalLookupAction, String) -> Unit, + onAiAction: (ReaderAiFeature, String) -> Unit, + onCloudTtsStart: (ReaderTtsReadScope, List) -> Unit, + onCloudTtsPauseResume: () -> Unit, + onCloudTtsStop: () -> Unit, + onCloudTtsClearCache: () -> Unit, + onAutoScrollChange: (ReaderAutoScrollState) -> Unit, + session: ReaderSessionState, + extrasState: ReaderExtrasState, + aiByokSettings: ReaderAiByokSettings +) { + val tools = readerWorkspaceQuickActionTools( + toolbarPreferences = toolbarPreferences, + bottom = bottom, + aiAvailable = aiByokSettings.areReaderAiFeaturesAvailable + ) + if (tools.isEmpty()) return + + Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) { + tools.forEach { tool -> + when (tool) { + ReaderTool.BOOKMARK -> IconButton(onClick = onToggleBookmark) { + Icon( + if (isBookmarked) Icons.Default.Bookmark else Icons.Default.BookmarkBorder, + contentDescription = "Bookmark" + ) + } + + ReaderTool.THEME -> IconButton(onClick = onToggleTheme) { + Icon(Icons.Default.Palette, contentDescription = if (isDarkMode) "Use light theme" else "Use dark theme") + } + + ReaderTool.SEARCH -> IconButton(onClick = onToggleSearch) { + Icon( + if (isSearchActive) Icons.Default.Close else Icons.Default.Search, + contentDescription = "Search" + ) + } + + ReaderTool.DICTIONARY -> IconButton( + onClick = { onExternalLookup(ReaderExternalLookupAction.DICTIONARY, ReaderContextExtractor.currentPageText(session)) } + ) { + Icon(Icons.Default.Translate, contentDescription = "External lookup") + } + + ReaderTool.AI_FEATURES -> Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) { + IconButton( + enabled = aiByokSettings.areReaderAiFeaturesAvailable && + ReaderContextExtractor.currentPageText(session).isNotBlank() && + !extrasState.aiResult.isLoading, + onClick = { onAiAction(ReaderAiFeature.DEFINE, ReaderContextExtractor.currentPageText(session).take(1200)) } + ) { + Icon(Icons.Default.Psychology, contentDescription = "Define page") + } + TextButton( + enabled = aiByokSettings.areReaderAiFeaturesAvailable && + ReaderContextExtractor.currentChapterText(session).isNotBlank() && + !extrasState.aiResult.isLoading, + onClick = { onAiAction(ReaderAiFeature.SUMMARIZE, ReaderContextExtractor.currentChapterText(session)) } + ) { + Text("Summary") + } + } + + ReaderTool.TTS_CONTROLS -> IconButton( + enabled = extrasState.cloudTts.isAvailable || + extrasState.cloudTts.isPlaying || + extrasState.cloudTts.isLoading || + extrasState.cloudTts.isPaused, + onClick = { + if (extrasState.cloudTts.isPlaying || extrasState.cloudTts.isLoading || extrasState.cloudTts.isPaused) { + onCloudTtsStop() + } else { + onCloudTtsStart( + ReaderTtsReadScope.BOOK, + ReaderTtsPlanner.chunksFromCurrentLocation(session) + ) + } + } + ) { + Icon(Icons.Default.VolumeUp, contentDescription = if (extrasState.cloudTts.isPlaying || extrasState.cloudTts.isLoading || extrasState.cloudTts.isPaused) "Stop read aloud" else "Read aloud") + } + + ReaderTool.AUTO_SCROLL -> IconButton( + onClick = { + val autoScroll = extrasState.autoScroll.sanitized() + onAutoScrollChange(autoScroll.copy(enabled = !autoScroll.enabled)) + } + ) { + Icon(Icons.Default.Speed, contentDescription = if (extrasState.autoScroll.enabled) "Stop auto scroll" else "Start auto scroll") + } + + else -> Unit + } + } + } +} + +@Composable +private fun SharedReaderControlPanel( + session: ReaderSessionState, + toolbarPreferences: ReaderToolbarPreferences, + onToolbarPreferencesChange: (ReaderToolbarPreferences) -> Unit, + onPickCustomFont: (() -> String?)?, + customFonts: List, + extrasState: ReaderExtrasState, + aiByokSettings: ReaderAiByokSettings, + onExternalLookup: (ReaderExternalLookupAction, String) -> Unit, + onAiAction: (ReaderAiFeature, String) -> Unit, + onCloudTtsStart: (ReaderTtsReadScope, List) -> Unit, + onCloudTtsPauseResume: () -> Unit, + onCloudTtsStop: () -> Unit, + onCloudTtsClearCache: () -> Unit, + onAutoScrollChange: (ReaderAutoScrollState) -> Unit, + ttsReplacementPreferences: ReaderTtsReplacementPreferences, + ttsReplacementBookId: String, + onTtsReplacementPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit, + readerCustomTextureIds: List, + onImportReaderTexture: ((ReaderSettings) -> ReaderSettings?)?, + onReaderAction: (ReaderAction) -> Unit +) { + val sections = toolbarPreferences.availableReaderControlSections() + if (sections.isEmpty()) return + var selectedSection by remember { mutableStateOf(sections.first()) } + val activeSection = selectedSection.takeIf { it in sections } ?: sections.first() + + Surface( + modifier = Modifier + .width(340.dp) + .fillMaxHeight(), + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp) + ) { + LazyColumn( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + item { + Text("Reader controls", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(8.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.horizontalScroll(rememberScrollState()) + ) { + sections.forEach { section -> + FilterChip( + selected = activeSection == section, + onClick = { selectedSection = section }, + label = { Text(section.title) } + ) + } + } + } + item { + HorizontalDivider() + } + item { + when (activeSection) { + ReaderControlSection.FORMAT -> SharedReaderFormatControls( + settings = session.reader.settings, + toolbarPreferences = toolbarPreferences, + onPickCustomFont = onPickCustomFont, + customFonts = customFonts, + onReaderAction = onReaderAction + ) + + ReaderControlSection.THEME -> SharedReaderThemeControls( + settings = session.reader.settings, + customTextureIds = readerCustomTextureIds, + onImportTexture = onImportReaderTexture, + onSettingsChange = { onReaderAction(ReaderAction.SettingsChanged(it)) } + ) + + ReaderControlSection.VISUAL -> SharedReaderVisualOptionsControls( + settings = session.reader.settings, + onReaderAction = onReaderAction + ) + + ReaderControlSection.EXTRAS -> SharedReaderExtrasControls( + session = session, + extrasState = extrasState, + aiByokSettings = aiByokSettings, + toolbarPreferences = toolbarPreferences, + onExternalLookup = onExternalLookup, + onAiAction = onAiAction, + onCloudTtsStart = onCloudTtsStart, + onCloudTtsPauseResume = onCloudTtsPauseResume, + onCloudTtsStop = onCloudTtsStop, + onCloudTtsClearCache = onCloudTtsClearCache, + onAutoScrollChange = onAutoScrollChange, + ttsReplacementPreferences = ttsReplacementPreferences, + ttsReplacementBookId = ttsReplacementBookId, + onTtsReplacementPreferencesChange = onTtsReplacementPreferencesChange + ) + + ReaderControlSection.TOOLBAR -> SharedReaderToolbarControls( + toolbarPreferences = toolbarPreferences, + onToolbarPreferencesChange = onToolbarPreferencesChange + ) + } + } + } + } +} + +private enum class ReaderControlSection(val title: String) { + FORMAT("Format"), + THEME("Theme"), + VISUAL("Visual"), + EXTRAS("Extras"), + TOOLBAR("Toolbar") +} + +private fun ReaderToolbarPreferences.availableReaderControlSections(): List { + return buildList { + if (isVisible(ReaderTool.FORMAT) || isVisible(ReaderTool.READING_MODE)) add(ReaderControlSection.FORMAT) + if (isVisible(ReaderTool.THEME)) add(ReaderControlSection.THEME) + if (isVisible(ReaderTool.VISUAL_OPTIONS)) add(ReaderControlSection.VISUAL) + if ( + isVisible(ReaderTool.DICTIONARY) || + isVisible(ReaderTool.AI_FEATURES) || + isVisible(ReaderTool.TTS_CONTROLS) || + isVisible(ReaderTool.TTS_SETTINGS) || + isVisible(ReaderTool.TTS_REPLACEMENTS) || + isVisible(ReaderTool.AUTO_SCROLL) + ) { + add(ReaderControlSection.EXTRAS) + } + add(ReaderControlSection.TOOLBAR) + } +} + +@Composable +private fun SharedReaderFormatControls( + settings: ReaderSettings, + toolbarPreferences: ReaderToolbarPreferences, + onPickCustomFont: (() -> String?)?, + customFonts: List, + onReaderAction: (ReaderAction) -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(18.dp)) { + if (toolbarPreferences.isVisible(ReaderTool.READING_MODE)) { + SharedReaderPanelSection("Reading") { + SharedReaderChoiceRow { + FilterChip( + selected = settings.readingMode == ReaderReadingMode.PAGINATED, + onClick = { + onReaderAction(ReaderAction.SettingsChanged(settings.copy(readingMode = ReaderReadingMode.PAGINATED))) + }, + label = { Text("Pages") } + ) + FilterChip( + selected = settings.readingMode == ReaderReadingMode.VERTICAL, + onClick = { + onReaderAction(ReaderAction.SettingsChanged(settings.copy(readingMode = ReaderReadingMode.VERTICAL))) + }, + label = { Text("Vertical") } + ) + } + } + } + + if (toolbarPreferences.isVisible(ReaderTool.FORMAT)) { + SharedReaderPanelSection("Font & Alignment") { + val customFontName = settings.customFontPath + ?.substringAfterLast('/') + ?.substringAfterLast('\\') + ?.takeIf { it.isNotBlank() } + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Box( + modifier = Modifier + .width(42.dp) + .height(42.dp) + .background(MaterialTheme.colorScheme.secondaryContainer, RoundedCornerShape(8.dp)), + contentAlignment = Alignment.Center + ) { + Text("Aa", fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSecondaryContainer) + } + Column(modifier = Modifier.weight(1f)) { + Text(customFontName ?: settings.fontFamily, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text("Font", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + TextButton( + enabled = onPickCustomFont != null, + onClick = { + onPickCustomFont?.invoke()?.takeIf { it.isNotBlank() }?.let { path -> + onReaderAction( + ReaderAction.SettingsChanged( + settings.copy( + fontFamily = path.substringAfterLast('/').substringAfterLast('\\'), + customFontPath = path + ) + ) + ) + } + } + ) { + Text("Choose") + } + } + + SharedReaderChoiceRow { + listOf("Default", "Serif", "Sans", "Mono").forEach { family -> + FilterChip( + selected = settings.customFontPath == null && settings.fontFamily == family, + onClick = { + onReaderAction( + ReaderAction.SettingsChanged(settings.copy(fontFamily = family, customFontPath = null)) + ) + }, + label = { Text(family) } + ) + } + if (settings.customFontPath != null) { + TextButton( + onClick = { + onReaderAction( + ReaderAction.SettingsChanged(settings.copy(fontFamily = "Default", customFontPath = null)) + ) + } + ) { + Text("Clear") + } + } + } + + val activeCustomFonts = customFonts.filterNot { it.isDeleted }.sortedBy { it.displayName.lowercase() } + if (activeCustomFonts.isNotEmpty()) { + Text( + "Imported fonts", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + SharedReaderChoiceRow { + activeCustomFonts.forEach { font -> + FilterChip( + selected = settings.customFontPath == font.path, + onClick = { + onReaderAction( + ReaderAction.SettingsChanged( + settings.copy( + fontFamily = font.displayName, + customFontPath = font.path + ) + ) + ) + }, + label = { Text(font.displayName, maxLines = 1, overflow = TextOverflow.Ellipsis) } + ) + } + } + } + + SharedReaderChoiceRow { + FilterChip( + selected = settings.textAlign == SharedReaderTextAlign.START, + onClick = { + onReaderAction(ReaderAction.SettingsChanged(settings.copy(textAlign = SharedReaderTextAlign.START))) + }, + label = { Text("Left") } + ) + FilterChip( + selected = settings.textAlign == SharedReaderTextAlign.JUSTIFY, + onClick = { + onReaderAction(ReaderAction.SettingsChanged(settings.copy(textAlign = SharedReaderTextAlign.JUSTIFY))) + }, + label = { Text("Justify") } + ) + FilterChip( + selected = settings.textAlign == SharedReaderTextAlign.CENTER, + onClick = { + onReaderAction(ReaderAction.SettingsChanged(settings.copy(textAlign = SharedReaderTextAlign.CENTER))) + }, + label = { Text("Center") } + ) + } + } + + SharedReaderPanelSection("Layout & Spacing") { + SharedReaderSettingSlider( + label = "Font size", + value = settings.fontSize.toFloat(), + onValueChange = { value -> + onReaderAction(ReaderAction.SettingsChanged(settings.copy(fontSize = value.toInt()))) + }, + valueRange = 14f..30f, + valueLabel = settings.fontSize.toString() + ) + SharedReaderSettingSlider( + label = "Line height", + value = settings.lineSpacing, + onValueChange = { value -> + onReaderAction(ReaderAction.SettingsChanged(settings.copy(lineSpacing = value))) + }, + valueRange = 1.1f..2.1f, + valueLabel = "${settings.lineSpacing.formatTwoDecimals()}x" + ) + SharedReaderSettingSlider( + label = "Paragraph gap", + value = settings.paragraphSpacing, + onValueChange = { value -> + onReaderAction(ReaderAction.SettingsChanged(settings.copy(paragraphSpacing = value))) + }, + valueRange = 0.5f..2.5f, + valueLabel = "${settings.paragraphSpacing.formatTwoDecimals()}x" + ) + SharedReaderSettingSlider( + label = "Image size", + value = settings.imageScale, + onValueChange = { value -> + onReaderAction(ReaderAction.SettingsChanged(settings.copy(imageScale = value))) + }, + valueRange = 0.5f..2.0f, + valueLabel = "${settings.imageScale.formatTwoDecimals()}x" + ) + SharedReaderSettingSlider( + label = "Horizontal margin", + value = settings.resolvedHorizontalMargin.toFloat(), + onValueChange = { value -> + val nextHorizontal = value.toInt() + val nextMargin = maxOf(nextHorizontal, settings.resolvedVerticalMargin) + onReaderAction( + ReaderAction.SettingsChanged( + settings.copy(horizontalMargin = nextHorizontal, margin = nextMargin) + ) + ) + }, + valueRange = 0f..160f, + valueLabel = settings.resolvedHorizontalMargin.toString() + ) + SharedReaderSettingSlider( + label = "Vertical margin", + value = settings.resolvedVerticalMargin.toFloat(), + onValueChange = { value -> + val nextVertical = value.toInt() + val nextMargin = maxOf(settings.resolvedHorizontalMargin, nextVertical) + onReaderAction( + ReaderAction.SettingsChanged( + settings.copy(verticalMargin = nextVertical, margin = nextMargin) + ) + ) + }, + valueRange = 0f..160f, + valueLabel = settings.resolvedVerticalMargin.toString() + ) + SharedReaderSettingSlider( + label = "Page width", + value = settings.pageWidth.toFloat(), + onValueChange = { value -> + onReaderAction(ReaderAction.SettingsChanged(settings.copy(pageWidth = value.toInt()))) + }, + valueRange = 520f..1100f, + valueLabel = settings.pageWidth.toString() + ) + } + } + } +} + +@Composable +fun SharedReaderThemeControls( + settings: ReaderSettings, + builtInThemes: List = BuiltInReaderThemes, + customTextureIds: List = emptyList(), + onImportTexture: ((ReaderSettings) -> ReaderSettings?)? = null, + onSettingsChange: (ReaderSettings) -> Unit +) { + var textured by remember(settings.themeId, settings.textureId) { mutableStateOf(settings.textureId != null) } + val activeThemes = builtInThemes.filter { (it.textureId != null) == textured } + val visibleCustomTextureIds = remember(customTextureIds, settings.textureId) { + buildList { + addAll(customTextureIds.distinct()) + settings.textureId + ?.takeIf { it.startsWith(ReaderTextureFilePrefix) && it !in this } + ?.let(::add) + } + } + + Column(verticalArrangement = Arrangement.spacedBy(18.dp)) { + SharedReaderPanelSection("Reading Themes") { + SharedReaderChoiceRow { + FilterChip( + selected = !textured, + onClick = { textured = false }, + label = { Text("Solid") } + ) + FilterChip( + selected = textured, + onClick = { textured = true }, + label = { Text("Textured") } + ) + } + activeThemes.chunked(3).forEach { rowThemes -> + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { + rowThemes.forEach { theme -> + SharedReaderThemeChoice( + theme = theme, + selected = settings.themeId == theme.id || (settings.themeId == null && theme.id == "system"), + onSelected = { onSettingsChange(theme.toReaderSettings(settings)) }, + modifier = Modifier.weight(1f) + ) + } + repeat(3 - rowThemes.size) { + Spacer(Modifier.weight(1f)) + } + } + } + } + + if (textured) { + SharedReaderPanelSection("Texture") { + SharedReaderChoiceRow { + FilterChip( + selected = settings.textureId == null, + onClick = { onSettingsChange(settings.copy(textureId = null)) }, + label = { Text("None") } + ) + if (onImportTexture != null) { + FilterChip( + selected = settings.textureId?.startsWith(ReaderTextureFilePrefix) == true, + onClick = { + onImportTexture(settings)?.let(onSettingsChange) + }, + leadingIcon = { Icon(Icons.Default.Add, contentDescription = null) }, + label = { Text("Import") } + ) + } + ReaderTexture.entries.forEach { texture -> + FilterChip( + selected = settings.textureId == texture.id, + onClick = { onSettingsChange(settings.copy(textureId = texture.id)) }, + label = { Text(texture.displayName) } + ) + } + visibleCustomTextureIds.forEach { textureId -> + FilterChip( + selected = settings.textureId == textureId, + onClick = { onSettingsChange(settings.copy(textureId = textureId)) }, + label = { Text(readerTextureDisplayName(textureId)) } + ) + } + } + if (settings.textureId != null) { + SharedReaderSettingSlider( + label = "Texture strength", + value = settings.textureAlpha.coerceIn(0f, 1f), + onValueChange = { value -> + onSettingsChange(settings.copy(textureAlpha = value)) + }, + valueRange = 0f..1f, + valueLabel = "${(settings.textureAlpha.coerceIn(0f, 1f) * 100).roundToInt()}%" + ) + } + } + } + } +} + +@Composable +private fun SharedReaderVisualOptionsControls( + settings: ReaderSettings, + onReaderAction: (ReaderAction) -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(18.dp)) { + SharedReaderPanelSection("System UI") { + SharedReaderChoiceRow { + SystemUiMode.entries.forEach { mode -> + FilterChip( + selected = settings.systemUiMode == mode, + onClick = { onReaderAction(ReaderAction.SettingsChanged(settings.copy(systemUiMode = mode))) }, + label = { Text(mode.title) } + ) + } + } + } + + SharedReaderPanelSection("Page Info") { + SharedReaderChoiceRow { + PageInfoMode.entries.forEach { mode -> + FilterChip( + selected = settings.pageInfoMode == mode, + onClick = { onReaderAction(ReaderAction.SettingsChanged(settings.copy(pageInfoMode = mode))) }, + label = { Text(mode.title) } + ) + } + } + SharedReaderChoiceRow { + PageInfoPosition.entries.forEach { position -> + FilterChip( + selected = settings.pageInfoPosition == position, + onClick = { onReaderAction(ReaderAction.SettingsChanged(settings.copy(pageInfoPosition = position))) }, + label = { Text(position.title) } + ) + } + } + } + + SharedReaderPanelSection("Chapter Turns") { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("Seamless chapters", modifier = Modifier.weight(1f)) + Switch( + checked = settings.seamlessChapterNavigation, + onCheckedChange = { enabled -> + onReaderAction(ReaderAction.SettingsChanged(settings.copy(seamlessChapterNavigation = enabled))) + } + ) + } + SharedReaderSettingSlider( + label = "Pull distance", + value = settings.chapterTurnDragMultiplier.coerceIn(0.5f, 2.0f), + onValueChange = { value -> + onReaderAction(ReaderAction.SettingsChanged(settings.copy(chapterTurnDragMultiplier = value))) + }, + valueRange = 0.5f..2.0f, + valueLabel = "${settings.chapterTurnDragMultiplier.formatTwoDecimals()}x" + ) + } + } +} + +@Composable +private fun SharedReaderExtrasControls( + session: ReaderSessionState, + extrasState: ReaderExtrasState, + aiByokSettings: ReaderAiByokSettings, + toolbarPreferences: ReaderToolbarPreferences, + onExternalLookup: (ReaderExternalLookupAction, String) -> Unit, + onAiAction: (ReaderAiFeature, String) -> Unit, + onCloudTtsStart: (ReaderTtsReadScope, List) -> Unit, + onCloudTtsPauseResume: () -> Unit, + onCloudTtsStop: () -> Unit, + onCloudTtsClearCache: () -> Unit, + onAutoScrollChange: (ReaderAutoScrollState) -> Unit, + ttsReplacementPreferences: ReaderTtsReplacementPreferences, + ttsReplacementBookId: String, + onTtsReplacementPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit +) { + val settings = aiByokSettings.sanitized() + val currentPageText = ReaderContextExtractor.currentPageText(session) + val currentChapterText = ReaderContextExtractor.currentChapterText(session) + val recapText = ReaderContextExtractor.textBeforeCurrentLocation(session) + + Column(verticalArrangement = Arrangement.spacedBy(18.dp)) { + SharedReaderPanelSection("External Apps") { + SharedReaderChoiceRow { + ReaderExternalLookupAction.entries.forEach { action -> + FilterChip( + selected = false, + enabled = currentPageText.isNotBlank(), + onClick = { onExternalLookup(action, currentPageText) }, + label = { Text(action.title) } + ) + } + } + } + + SharedReaderPanelSection("Auto Scroll") { + val autoScroll = extrasState.autoScroll.sanitized() + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("Auto scroll", modifier = Modifier.weight(1f)) + Switch( + checked = autoScroll.enabled, + onCheckedChange = { enabled -> onAutoScrollChange(autoScroll.copy(enabled = enabled)) } + ) + } + SharedReaderSettingSlider( + label = "Speed", + value = autoScroll.speed, + onValueChange = { speed -> onAutoScrollChange(autoScroll.copy(speed = speed).sanitized()) }, + valueRange = 12f..160f, + valueLabel = "${autoScroll.speed.roundToInt()}" + ) + } + + SharedReaderPanelSection("Cloud TTS") { + val ttsBusy = extrasState.cloudTts.isLoading || extrasState.cloudTts.isPlaying || extrasState.cloudTts.isPaused + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + when { + extrasState.cloudTts.isLoading -> "Preparing audio" + extrasState.cloudTts.isPaused -> "Paused" + extrasState.cloudTts.isPlaying -> "Reading" + settings.isCloudTtsAvailable -> "Ready" + else -> "Needs Gemini key" + }, + fontWeight = FontWeight.SemiBold + ) + val errorMessage = extrasState.cloudTts.errorMessage?.takeIf { it.isNotBlank() } + val statusMessage = extrasState.cloudTts.progress.currentPositionLabel + ?: extrasState.cloudTts.statusMessage?.takeIf { it.isNotBlank() } + when { + errorMessage != null -> Text(errorMessage, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + statusMessage != null -> Text(statusMessage, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + TextButton( + enabled = settings.isCloudTtsAvailable || ttsBusy, + onClick = { + if (ttsBusy) { + onCloudTtsStop() + } else { + onCloudTtsStart( + ReaderTtsReadScope.BOOK, + ReaderTtsPlanner.chunksFromCurrentLocation(session) + ) + } + } + ) { + Text(if (ttsBusy) "Stop" else "Read") + } + } + if (extrasState.cloudTts.isPlaying || extrasState.cloudTts.isPaused) { + SharedReaderChoiceRow { + TextButton(onClick = onCloudTtsPauseResume) { + Text(if (extrasState.cloudTts.isPaused) "Resume" else "Pause") + } + } + } + SharedReaderChoiceRow { + TextButton( + enabled = settings.isCloudTtsAvailable && !ttsBusy && currentPageText.isNotBlank(), + onClick = { + onCloudTtsStart( + ReaderTtsReadScope.PAGE, + ReaderTtsPlanner.chunksForCurrentPage(session) + ) + } + ) { + Text("Page") + } + TextButton( + enabled = settings.isCloudTtsAvailable && !ttsBusy && currentChapterText.isNotBlank(), + onClick = { + onCloudTtsStart( + ReaderTtsReadScope.CHAPTER, + ReaderTtsPlanner.chunksForCurrentChapter(session) + ) + } + ) { + Text("Chapter") + } + TextButton( + enabled = settings.isCloudTtsAvailable && !ttsBusy && currentPageText.isNotBlank(), + onClick = { + onCloudTtsStart( + ReaderTtsReadScope.BOOK, + ReaderTtsPlanner.chunksFromCurrentLocation(session) + ) + } + ) { + Text("From here") + } + } + val cacheSummary = extrasState.cloudTts.cacheSummary + if (cacheSummary.hasCachedAudio) { + Text( + "Cache: ${cacheSummary.currentVoiceLabel}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + if (cacheSummary.hasCurrentVoiceCachedAudio) { + TextButton(onClick = onCloudTtsClearCache) { + Text("Clear voice cache") + } + } + } + } + + if (toolbarPreferences.isVisible(ReaderTool.TTS_REPLACEMENTS)) { + SharedReaderTtsReplacementControls( + preferences = ttsReplacementPreferences, + bookId = ttsReplacementBookId, + onPreferencesChange = onTtsReplacementPreferencesChange + ) + } + + if (settings.areReaderAiFeaturesAvailable) { + SharedReaderPanelSection("AI") { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.horizontalScroll(rememberScrollState()) + ) { + TextButton( + enabled = currentPageText.isNotBlank() && !extrasState.aiResult.isLoading, + onClick = { onAiAction(ReaderAiFeature.DEFINE, currentPageText.take(1200)) } + ) { + Text("Define page") + } + TextButton( + enabled = currentChapterText.isNotBlank() && !extrasState.aiResult.isLoading, + onClick = { onAiAction(ReaderAiFeature.SUMMARIZE, currentChapterText) } + ) { + Text("Summarize chapter") + } + TextButton( + enabled = recapText.isNotBlank() && !extrasState.aiResult.isLoading, + onClick = { onAiAction(ReaderAiFeature.RECAP, recapText) } + ) { + Text("Recap") + } + } + if (extrasState.aiResult.hasContent) { + Surface( + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(6.dp), + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(extrasState.aiResult.title ?: "AI", fontWeight = FontWeight.SemiBold) + when { + extrasState.aiResult.isLoading -> Text("Working...", color = MaterialTheme.colorScheme.onSurfaceVariant) + extrasState.aiResult.errorMessage != null -> Text(extrasState.aiResult.errorMessage, color = MaterialTheme.colorScheme.error) + else -> SharedMarkdownText(extrasState.aiResult.text) + } + } + } + } + } + } + } +} + +private enum class SharedTtsReplacementScope { + GLOBAL, + BOOK +} + +@Composable +fun SharedReaderTtsReplacementControls( + preferences: ReaderTtsReplacementPreferences, + bookId: String, + onPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit +) { + var selectedScope by remember(bookId) { mutableStateOf(SharedTtsReplacementScope.GLOBAL) } + var editingRuleId by remember(bookId, selectedScope) { mutableStateOf(null) } + var isAddingRule by remember(bookId, selectedScope) { mutableStateOf(false) } + val bookSettings = preferences.settingsForBook(bookId) + val bookRules = preferences.rulesForBook(bookId) + + SharedReaderPanelSection("TTS Word Replacements") { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text("Replace only what is spoken", fontWeight = FontWeight.SemiBold) + Text( + "Reader text, highlights, and locations stay unchanged.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = preferences.isEnabled, + onCheckedChange = { onPreferencesChange(preferences.copy(isEnabled = it)) } + ) + } + + SharedReaderChoiceRow { + FilterChip( + selected = selectedScope == SharedTtsReplacementScope.GLOBAL, + onClick = { + selectedScope = SharedTtsReplacementScope.GLOBAL + editingRuleId = null + isAddingRule = false + }, + label = { Text("Global") } + ) + FilterChip( + selected = selectedScope == SharedTtsReplacementScope.BOOK, + onClick = { + selectedScope = SharedTtsReplacementScope.BOOK + editingRuleId = null + isAddingRule = false + }, + label = { Text("This book") } + ) + } + + when (selectedScope) { + SharedTtsReplacementScope.GLOBAL -> { + SharedTtsReplacementSuggestionsRow { suggestion -> + onPreferencesChange( + preferences.copy( + globalRules = preferences.globalRules + suggestion.asDesktopEditableRule( + prefix = "global", + existingRules = preferences.globalRules + ) + ) + ) + } + TextButton(onClick = { isAddingRule = true; editingRuleId = null }) { + Icon(Icons.Default.Add, contentDescription = null) + Spacer(Modifier.width(6.dp)) + Text("Add rule") + } + val editingRule = editingRuleId?.let { id -> preferences.globalRules.firstOrNull { it.id == id } } + if (isAddingRule || editingRule != null) { + SharedTtsReplacementRuleEditor( + seedRule = editingRule, + newRuleId = newSharedReplacementRuleId("global", preferences.globalRules), + onCancel = { isAddingRule = false; editingRuleId = null }, + onSave = { rule -> + val updated = if (editingRule == null) { + preferences.globalRules + rule + } else { + preferences.globalRules.map { if (it.id == editingRule.id) rule else it } + } + onPreferencesChange(preferences.copy(globalRules = updated)) + isAddingRule = false + editingRuleId = null + } + ) + } + SharedTtsReplacementRuleList( + rules = preferences.globalRules, + emptyText = "No global rules yet.", + onToggle = { rule, enabled -> + onPreferencesChange( + preferences.copy( + globalRules = preferences.globalRules.map { + if (it.id == rule.id) it.copy(enabled = enabled) else it + } + ) + ) + }, + onEdit = { rule -> editingRuleId = rule.id; isAddingRule = false }, + onDelete = { rule -> + onPreferencesChange(preferences.copy(globalRules = preferences.globalRules.filterNot { it.id == rule.id })) + } + ) + } + + SharedTtsReplacementScope.BOOK -> { + SharedTtsBookReplacementSettings( + settings = bookSettings, + onSettingsChange = { onPreferencesChange(preferences.withBookSettings(bookId, it)) } + ) + SharedTtsInheritedGlobalRules( + globalRules = preferences.globalRules, + settings = bookSettings, + onSettingsChange = { onPreferencesChange(preferences.withBookSettings(bookId, it)) } + ) + SharedTtsReplacementSuggestionsRow { suggestion -> + onPreferencesChange( + preferences.withBookRules( + bookId, + bookRules + suggestion.asDesktopEditableRule( + prefix = "book", + existingRules = bookRules + ) + ) + ) + } + TextButton(onClick = { isAddingRule = true; editingRuleId = null }) { + Icon(Icons.Default.Add, contentDescription = null) + Spacer(Modifier.width(6.dp)) + Text("Add book rule") + } + val editingRule = editingRuleId?.let { id -> bookRules.firstOrNull { it.id == id } } + if (isAddingRule || editingRule != null) { + SharedTtsReplacementRuleEditor( + seedRule = editingRule, + newRuleId = newSharedReplacementRuleId("book", bookRules), + onCancel = { isAddingRule = false; editingRuleId = null }, + onSave = { rule -> + val updated = if (editingRule == null) { + bookRules + rule + } else { + bookRules.map { if (it.id == editingRule.id) rule else it } + } + onPreferencesChange(preferences.withBookRules(bookId, updated)) + isAddingRule = false + editingRuleId = null + } + ) + } + SharedTtsReplacementRuleList( + rules = bookRules, + emptyText = "No book rules yet.", + onToggle = { rule, enabled -> + onPreferencesChange( + preferences.withBookRules( + bookId, + bookRules.map { if (it.id == rule.id) it.copy(enabled = enabled) else it } + ) + ) + }, + onEdit = { rule -> editingRuleId = rule.id; isAddingRule = false }, + onDelete = { rule -> + onPreferencesChange(preferences.withBookRules(bookId, bookRules.filterNot { it.id == rule.id })) + } + ) + } + } + } +} + +@Composable +private fun SharedTtsReplacementSuggestionsRow( + onSuggestionClick: (ReaderTtsReplacementRule) -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text("Suggestions", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.horizontalScroll(rememberScrollState()) + ) { + ReaderTtsReplacementSuggestions.presets.forEach { suggestion -> + FilterChip( + selected = false, + onClick = { onSuggestionClick(suggestion) }, + label = { Text(suggestion.desktopSummary(), maxLines = 1, overflow = TextOverflow.Ellipsis) } + ) + } + } + } +} + +@Composable +private fun SharedTtsBookReplacementSettings( + settings: ReaderTtsReplacementBookSettings, + onSettingsChange: (ReaderTtsReplacementBookSettings) -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text("Use global rules here", modifier = Modifier.weight(1f)) + Switch( + checked = settings.globalRulesEnabled, + onCheckedChange = { onSettingsChange(settings.copy(globalRulesEnabled = it)) } + ) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text("Enable book rules", modifier = Modifier.weight(1f)) + Switch( + checked = settings.localRulesEnabled, + onCheckedChange = { onSettingsChange(settings.copy(localRulesEnabled = it)) } + ) + } + } +} + +@Composable +private fun SharedTtsInheritedGlobalRules( + globalRules: List, + settings: ReaderTtsReplacementBookSettings, + onSettingsChange: (ReaderTtsReplacementBookSettings) -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text("Inherited global rules", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + if (globalRules.isEmpty()) { + Text("No global rules to inherit.", color = MaterialTheme.colorScheme.onSurfaceVariant) + } else { + globalRules.forEach { rule -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text(rule.desktopSummary(), maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + if (rule.id in settings.disabledGlobalRuleIds) "Disabled for this book" else "Enabled for this book", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = rule.id !in settings.disabledGlobalRuleIds, + onCheckedChange = { enabled -> + val disabledIds = if (enabled) { + settings.disabledGlobalRuleIds - rule.id + } else { + settings.disabledGlobalRuleIds + rule.id + } + onSettingsChange(settings.copy(disabledGlobalRuleIds = disabledIds)) + } + ) + } + } + } + } +} + +@Composable +private fun SharedTtsReplacementRuleEditor( + seedRule: ReaderTtsReplacementRule?, + newRuleId: String, + onCancel: () -> Unit, + onSave: (ReaderTtsReplacementRule) -> Unit +) { + val seedId = seedRule?.id ?: newRuleId + var from by remember(seedId) { mutableStateOf(seedRule?.from.orEmpty()) } + var to by remember(seedId) { mutableStateOf(seedRule?.to.orEmpty()) } + var enabled by remember(seedId) { mutableStateOf(seedRule?.enabled ?: true) } + var isRegex by remember(seedId) { mutableStateOf(seedRule?.isRegex ?: false) } + var wholeWord by remember(seedId) { mutableStateOf(seedRule?.wholeWord ?: true) } + var matchCase by remember(seedId) { mutableStateOf(seedRule?.matchCase ?: false) } + var previewText by remember(seedId) { mutableStateOf(seedRule?.from?.takeIf { it.isNotBlank() } ?: "Dr. Smith met NASA.") } + val draft = ReaderTtsReplacementRule( + id = seedId, + from = from, + to = to, + enabled = enabled, + isRegex = isRegex, + matchCase = matchCase, + wholeWord = wholeWord + ) + val validation = ReaderTtsReplacementEngine.validate(draft) + val previewOutput = if (validation.isValid) { + ReaderTtsReplacementEngine.apply( + text = previewText, + preferences = ReaderTtsReplacementPreferences(globalRules = listOf(draft.copy(enabled = true))) + ).text + } else { + previewText + } + + Surface( + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text(if (seedRule == null) "New rule" else "Edit rule", fontWeight = FontWeight.SemiBold) + OutlinedTextField( + value = from, + onValueChange = { from = it }, + label = { Text("Replace") }, + modifier = Modifier.fillMaxWidth(), + isError = !validation.isValid + ) + if (!validation.isValid && validation.message != null) { + Text(validation.message, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + } + OutlinedTextField( + value = to, + onValueChange = { to = it }, + label = { Text("Speak as") }, + modifier = Modifier.fillMaxWidth() + ) + SharedReaderChoiceRow { + FilterChip(selected = enabled, onClick = { enabled = !enabled }, label = { Text("Enabled") }) + FilterChip(selected = isRegex, onClick = { isRegex = !isRegex }, label = { Text("Regex") }) + FilterChip(selected = wholeWord, onClick = { wholeWord = !wholeWord }, label = { Text("Whole word") }) + FilterChip(selected = matchCase, onClick = { matchCase = !matchCase }, label = { Text("Match case") }) + } + OutlinedTextField( + value = previewText, + onValueChange = { previewText = it }, + label = { Text("Preview") }, + modifier = Modifier.fillMaxWidth() + ) + Text(previewOutput, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(onClick = onCancel) { Text("Cancel") } + TextButton(enabled = validation.isValid, onClick = { onSave(draft) }) { Text("Save") } + } + } + } +} + +@Composable +private fun SharedTtsReplacementRuleList( + rules: List, + emptyText: String, + onToggle: (ReaderTtsReplacementRule, Boolean) -> Unit, + onEdit: (ReaderTtsReplacementRule) -> Unit, + onDelete: (ReaderTtsReplacementRule) -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (rules.isEmpty()) { + Text(emptyText, color = MaterialTheme.colorScheme.onSurfaceVariant) + } else { + rules.forEach { rule -> + Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text(rule.desktopSummary(), fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(rule.desktopOptions(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Switch(checked = rule.enabled, onCheckedChange = { onToggle(rule, it) }) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = { onEdit(rule) }) { Text("Edit") } + TextButton(onClick = { onDelete(rule) }) { Text("Delete") } + } + HorizontalDivider() + } + } + } + } +} + +private fun ReaderTtsReplacementRule.asDesktopEditableRule( + prefix: String, + existingRules: List +): ReaderTtsReplacementRule { + return copy( + id = newSharedReplacementRuleId(prefix, existingRules + this), + enabled = true + ) +} + +private fun ReaderTtsReplacementRule.desktopSummary(): String { + val replacement = to.ifBlank { "silence" } + return "$from -> $replacement" +} + +private fun ReaderTtsReplacementRule.desktopOptions(): String { + val options = buildList { + add(if (isRegex) "Regex" else "Plain text") + if (wholeWord) add("whole word") + if (matchCase) add("case-sensitive") + } + return options.joinToString(" - ") +} + +private fun newSharedReplacementRuleId( + prefix: String, + existingRules: List +): String { + val stableSuffix = existingRules.joinToString("|") { it.id }.hashCode().toString().replace("-", "n") + return "${prefix}_${existingRules.size + 1}_$stableSuffix" +} + +@Composable +private fun SharedReaderToolbarControls( + toolbarPreferences: ReaderToolbarPreferences, + onToolbarPreferencesChange: (ReaderToolbarPreferences) -> Unit +) { + val orderedTools = toolbarPreferences.sanitized().toolOrder + val toolbarTools = orderedTools.filter { it.category != "Overflow Menu" } + val moreTools = orderedTools.filter { it.category == "Overflow Menu" } + Column(verticalArrangement = Arrangement.spacedBy(18.dp)) { + SharedToolbarSection( + title = "Top Bar", + tools = toolbarTools.filter { + toolbarPreferences.isVisible(it) && !toolbarPreferences.isBottom(it) + }, + toolbarPreferences = toolbarPreferences, + onToolbarPreferencesChange = onToolbarPreferencesChange + ) + SharedToolbarSection( + title = "Bottom Bar", + tools = toolbarTools.filter { + toolbarPreferences.isVisible(it) && toolbarPreferences.isBottom(it) + }, + toolbarPreferences = toolbarPreferences, + onToolbarPreferencesChange = onToolbarPreferencesChange + ) + SharedToolbarSection( + title = "More Menu", + tools = moreTools.filter { toolbarPreferences.isVisible(it) }, + toolbarPreferences = toolbarPreferences, + onToolbarPreferencesChange = onToolbarPreferencesChange + ) + SharedToolbarSection( + title = "Hidden Tools", + tools = orderedTools.filterNot { toolbarPreferences.isVisible(it) }, + toolbarPreferences = toolbarPreferences, + onToolbarPreferencesChange = onToolbarPreferencesChange + ) + } +} + +@Composable +private fun SharedToolbarSection( + title: String, + tools: List, + toolbarPreferences: ReaderToolbarPreferences, + onToolbarPreferencesChange: (ReaderToolbarPreferences) -> Unit +) { + SharedReaderPanelSection(title) { + if (tools.isEmpty()) { + Text("No tools", color = MaterialTheme.colorScheme.onSurfaceVariant) + } else { + tools.forEach { tool -> + Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) { + Text(tool.title, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.horizontalScroll(rememberScrollState()) + ) { + FilterChip( + selected = toolbarPreferences.isVisible(tool), + onClick = { + onToolbarPreferencesChange( + toolbarPreferences.withVisibility(tool, hidden = toolbarPreferences.isVisible(tool)) + ) + }, + label = { Text("Visible") } + ) + FilterChip( + selected = toolbarPreferences.isBottom(tool), + enabled = tool.category != "Overflow Menu", + onClick = { + onToolbarPreferencesChange( + toolbarPreferences.withBottomPlacement(tool, bottom = !toolbarPreferences.isBottom(tool)) + ) + }, + label = { Text("Bottom") } + ) + TextButton( + enabled = toolbarPreferences.toolOrder.indexOf(tool) > 0, + onClick = { onToolbarPreferencesChange(toolbarPreferences.moveTool(tool, -1)) } + ) { + Text("Up") + } + TextButton( + enabled = toolbarPreferences.toolOrder.indexOf(tool) in 0 until toolbarPreferences.toolOrder.lastIndex, + onClick = { onToolbarPreferencesChange(toolbarPreferences.moveTool(tool, 1)) } + ) { + Text("Down") + } + } + } + if (tool != tools.last()) { + HorizontalDivider() + } + } + } + } +} + +@Composable +private fun SharedReaderPanelSection( + title: String, + content: @Composable ColumnScope.() -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { + Text(title, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold) + content() + } +} + +@Composable +private fun SharedReaderChoiceRow( + content: @Composable () -> Unit +) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.horizontalScroll(rememberScrollState()) + ) { + content() + } +} + +@Composable +private fun SharedReaderSettingSlider( + label: String, + value: Float, + onValueChange: (Float) -> Unit, + valueRange: ClosedFloatingPointRange, + valueLabel: String +) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp), modifier = Modifier.fillMaxWidth()) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(label, style = MaterialTheme.typography.bodyMedium) + Text(valueLabel, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary) + } + Slider( + value = value.coerceIn(valueRange.start, valueRange.endInclusive), + onValueChange = onValueChange, + valueRange = valueRange + ) + } +} + +@Composable +private fun SharedReaderThemeChoice( + theme: com.aryan.reader.shared.ReaderTheme, + selected: Boolean, + onSelected: () -> Unit, + modifier: Modifier = Modifier +) { + val swatch = if (theme.backgroundColor == Color.Unspecified) { + MaterialTheme.colorScheme.surface + } else { + theme.backgroundColor + } + val textColor = if (theme.textColor == Color.Unspecified) { + MaterialTheme.colorScheme.onSurface + } else { + theme.textColor + } + Column( + modifier = modifier.clickable(onClick = onSelected), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(52.dp) + .background( + if (selected) MaterialTheme.colorScheme.primaryContainer else swatch, + RoundedCornerShape(8.dp) + ), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .width(44.dp) + .height(32.dp) + .background(swatch, RoundedCornerShape(6.dp)), + contentAlignment = Alignment.Center + ) { + Text("Aa", color = textColor, fontWeight = FontWeight.Bold) + } + } + Text( + theme.name, + style = MaterialTheme.typography.labelSmall, + color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +private fun SharedReaderPageSlider( + session: ReaderSessionState, + onPageNumberChange: (Int) -> Unit +) { + val readerState = session.reader + val totalPages = readerState.pages.size.coerceAtLeast(1) + val sliderMax = totalPages.coerceAtLeast(2) + val currentPageNumber = (readerState.currentPageIndex + 1).coerceIn(1, totalPages) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text("$currentPageNumber / $totalPages") + Slider( + value = currentPageNumber.toFloat(), + onValueChange = { value -> onPageNumberChange(value.roundToInt().coerceIn(1, totalPages)) }, + valueRange = 1f..sliderMax.toFloat(), + steps = if (totalPages > 2) totalPages - 2 else 0, + enabled = totalPages > 1, + modifier = Modifier.weight(1f) + ) + Text( + readerState.currentPage?.chapterTitle.orEmpty(), + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.width(180.dp) + ) + } +} + +@Composable +private fun SharedReaderSidebar( + session: ReaderSessionState, + onSearchChange: (String) -> Unit, + onPreviousSearchResult: () -> Unit, + onNextSearchResult: () -> Unit, + onOpenSearch: () -> Unit, + onCloseSearch: () -> Unit, + onToggleSearchResultsPanel: () -> Unit, + onSearchOptionsChange: (ReaderSearchOptions) -> Unit, + onGoToChapter: (Int) -> Unit, + onGoToBookmark: (ReaderBookmark) -> Unit, + onGoToSearchResult: (Int) -> Unit, + toolbarPreferences: ReaderToolbarPreferences, + highlightPalette: ReaderHighlightPalette, + onHighlightPaletteChange: (ReaderHighlightPalette) -> Unit, + onGoToHighlight: (UserHighlight) -> Unit, + onHighlightColorChange: (UserHighlight, HighlightColor) -> Unit, + onHighlightNoteChange: (UserHighlight, String) -> Unit, + onHighlightDelete: (UserHighlight) -> Unit +) { + Surface( + modifier = Modifier + .width(280.dp) + .fillMaxHeight(), + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp) + ) { + LazyColumn( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (toolbarPreferences.isVisible(ReaderTool.TOC)) { + item { + Text("Contents", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + } + items(session.reader.book.chapters.indices.toList()) { index -> + val chapter = session.reader.book.chapters[index] + val selected = session.reader.currentPage?.chapterIndex == index + Surface( + color = if (selected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent, + shape = RoundedCornerShape(6.dp), + modifier = Modifier.fillMaxWidth().clickable { onGoToChapter(index) } + ) { + Text( + chapter.title, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 6.dp), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + } + + if (toolbarPreferences.isVisible(ReaderTool.BOOKMARK)) { + item { + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + Text("Bookmarks", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + } + if (session.bookmarks.isEmpty()) { + item { + Text("No bookmarks yet", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } else { + items(session.bookmarks, key = { it.id }) { bookmark -> + Surface( + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(6.dp), + modifier = Modifier.fillMaxWidth().clickable { onGoToBookmark(bookmark) } + ) { + Column( + modifier = Modifier + .padding(8.dp) + .fillMaxWidth() + ) { + Text(bookmark.chapterTitle, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(bookmark.preview, style = MaterialTheme.typography.bodySmall, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + } + } + + if (toolbarPreferences.isVisible(ReaderTool.BOOKMARK)) { + item { + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + Text("Highlights", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + } + if (session.highlights.isEmpty()) { + item { + Text("No highlights yet", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } else { + items(session.highlights, key = { it.id }) { highlight -> + SharedHighlightListItem( + session = session, + highlight = highlight, + palette = highlightPalette, + onGoToHighlight = onGoToHighlight, + onColorChange = onHighlightColorChange, + onNoteChange = onHighlightNoteChange, + onDelete = onHighlightDelete + ) + } + } + item { + SharedHighlightPaletteEditor( + palette = highlightPalette, + onPaletteChange = onHighlightPaletteChange + ) + } + } + + if (toolbarPreferences.isVisible(ReaderTool.SEARCH)) { + item { + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text("Search", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f)) + TextButton(onClick = if (session.isSearchActive) onCloseSearch else onOpenSearch) { + Text(if (session.isSearchActive) "Close" else "Open") + } + } + Spacer(Modifier.height(8.dp)) + if (session.isSearchActive) { + OutlinedTextField( + value = session.searchQuery, + onValueChange = onSearchChange, + label = { Text("Find in book") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.horizontalScroll(rememberScrollState()) + ) { + FilterChip( + selected = session.searchOptions.matchCase, + onClick = { + onSearchOptionsChange(session.searchOptions.copy(matchCase = !session.searchOptions.matchCase)) + }, + label = { Text("Match case") } + ) + FilterChip( + selected = session.searchOptions.wholeWords, + onClick = { + onSearchOptionsChange(session.searchOptions.copy(wholeWords = !session.searchOptions.wholeWords)) + }, + label = { Text("Whole words") } + ) + if (session.searchQuery.isNotBlank()) { + TextButton(onClick = onToggleSearchResultsPanel) { + Text(if (session.showSearchResultsPanel) "Hide results" else "Show results") + } + } + } + } + if (session.isSearchActive && session.searchQuery.isNotBlank() && session.searchResults.isNotEmpty()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "${session.activeSearchResultIndex + 1} of ${session.searchResults.size}", + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) + ) + TextButton( + enabled = session.canGoToPreviousSearchResult, + onClick = onPreviousSearchResult + ) { + Text("Prev") + } + TextButton( + enabled = session.canGoToNextSearchResult, + onClick = onNextSearchResult + ) { + Text("Next") + } + } + } + } + if (session.isSearchActive && session.searchQuery.isNotBlank() && session.searchResults.isEmpty()) { + item { + Text("No matches", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } else if (session.isSearchActive && session.showSearchResultsPanel) { + itemsIndexed( + session.searchResults, + key = { _, result -> "${result.pageIndex}_${result.matchIndex}_${result.chapterIndex}_${result.preview}" } + ) { index, result -> + Surface( + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(6.dp), + modifier = Modifier.fillMaxWidth().clickable { onGoToSearchResult(index) } + ) { + Column(modifier = Modifier.padding(8.dp)) { + Text("Page ${result.pageIndex + 1} - ${result.chapterTitle}", fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(result.preview, style = MaterialTheme.typography.bodySmall, maxLines = 3, overflow = TextOverflow.Ellipsis) + } + } + } + } + } + } + } +} + +@Composable +private fun SharedHighlightListItem( + session: ReaderSessionState, + highlight: UserHighlight, + palette: ReaderHighlightPalette, + onGoToHighlight: (UserHighlight) -> Unit, + onColorChange: (UserHighlight, HighlightColor) -> Unit, + onNoteChange: (UserHighlight, String) -> Unit, + onDelete: (UserHighlight) -> Unit +) { + val locator = highlight.locator.withFallbacks( + chapterIndex = highlight.chapterIndex, + cfi = highlight.cfi, + textQuote = highlight.text + ) + val chapterTitle = session.reader.book.chapters + .getOrNull(locator.chapterIndex ?: highlight.chapterIndex) + ?.title + ?: "Chapter ${(locator.chapterIndex ?: highlight.chapterIndex) + 1}" + val pageLabel = locator.pageIndex?.let { "Page ${it + 1}" } + val colors = palette.sanitized().colors + + Surface( + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(6.dp), + modifier = Modifier.fillMaxWidth().clickable { onGoToHighlight(highlight) } + ) { + Column( + modifier = Modifier + .padding(8.dp) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Box( + modifier = Modifier + .width(12.dp) + .height(12.dp) + .background(highlight.color.color, RoundedCornerShape(2.dp)) + ) + Text( + listOfNotNull(chapterTitle, pageLabel).joinToString(" - "), + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + } + Text(highlight.text, style = MaterialTheme.typography.bodySmall, maxLines = 3, overflow = TextOverflow.Ellipsis) + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.horizontalScroll(rememberScrollState()) + ) { + colors.forEach { color -> + FilterChip( + selected = highlight.color == color, + onClick = { onColorChange(highlight, color) }, + label = { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .width(10.dp) + .height(10.dp) + .background(color.color, RoundedCornerShape(2.dp)) + ) + Text(color.id) + } + } + ) + } + } + OutlinedTextField( + value = highlight.note.orEmpty(), + onValueChange = { onNoteChange(highlight, it) }, + label = { Text("Note") }, + maxLines = 2, + modifier = Modifier.fillMaxWidth() + ) + TextButton(onClick = { onDelete(highlight) }) { + Text("Delete") + } + } + } +} + +@Composable +private fun SharedHighlightPaletteEditor( + palette: ReaderHighlightPalette, + onPaletteChange: (ReaderHighlightPalette) -> Unit +) { + val sanitized = palette.sanitized() + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text("Palette", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold) + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.horizontalScroll(rememberScrollState()) + ) { + HighlightColor.entries.forEach { color -> + FilterChip( + selected = sanitized.contains(color), + onClick = { + onPaletteChange(sanitized.withColor(color, enabled = !sanitized.contains(color))) + }, + label = { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .width(10.dp) + .height(10.dp) + .background(color.color, RoundedCornerShape(2.dp)) + ) + Text(color.id) + } + } + ) + } + } + } +} + +private fun Float.formatTwoDecimals(): String { + val scaled = (this * 100).toInt() + return "${scaled / 100}.${(scaled % 100).toString().padStart(2, '0')}" +} + +private fun ReaderToolbarPreferences.moveTool(tool: ReaderTool, delta: Int): ReaderToolbarPreferences { + val order = sanitized().toolOrder.toMutableList() + val index = order.indexOf(tool) + if (index < 0) return this + val target = (index + delta).coerceIn(0, order.lastIndex) + if (index == target) return this + val moved = order.removeAt(index) + order.add(target, moved) + return withToolOrder(order) +} + +private fun Long.toComposeColor(): Color { + val value = this and 0xFFFFFFFFL + val alpha = ((value shr 24) and 0xFF) / 255f + val red = ((value shr 16) and 0xFF) / 255f + val green = ((value shr 8) and 0xFF) / 255f + val blue = (value and 0xFF) / 255f + return Color(red = red, green = green, blue = blue, alpha = alpha.takeIf { it > 0f } ?: 1f) +} + +private fun PaginatedReaderState.pageInfoText(): String { + val current = currentPageIndex + 1 + val total = pages.size.coerceAtLeast(1) + val percent = progress.roundToInt().coerceIn(0, 100) + val mode = if (settings.readingMode == ReaderReadingMode.VERTICAL) "Continuous" else "Page" + val chapter = currentPage?.chapterTitle?.takeIf { it.isNotBlank() } + return listOfNotNull("$mode $current of $total ($percent%)", chapter).joinToString(" - ") +} + +private fun PaginatedReaderState.currentPageLocator(): ReaderLocator? { + val page = currentPage ?: return null + val chapter = book.chapters.getOrNull(page.chapterIndex) + return ReaderLocator( + chapterIndex = page.chapterIndex, + chapterId = chapter?.id, + href = chapter?.baseHref, + pageIndex = page.pageIndex, + startOffset = page.startOffset, + endOffset = page.endOffset, + textQuote = page.text.trim().replace(Regex("\\s+"), " ").take(140), + cfi = "desktop:${page.chapterIndex}:${page.startOffset}:${page.endOffset}" + ) +} diff --git a/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedUtilityScreens.kt b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedUtilityScreens.kt new file mode 100644 index 0000000..c287936 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedUtilityScreens.kt @@ -0,0 +1,600 @@ +package com.aryan.reader.shared.ui + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.CloudDownload +import androidx.compose.material.icons.filled.Code +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.Feedback +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.OpenInNew +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.TextFields +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aryan.reader.shared.CustomFontItem + +@Composable +fun SharedCustomFontsScreen( + fonts: List, + onImportFont: () -> Unit, + onDeleteFont: (CustomFontItem) -> Unit, + googleFontsAvailable: Boolean = false, + getGoogleFonts: () -> List = { emptyList() }, + onDownloadGoogleFont: (String, () -> Unit) -> Unit = { _, onComplete -> onComplete() }, + fontFamilyForPreview: (CustomFontItem) -> FontFamily? = { null }, + modifier: Modifier = Modifier +) { + var fontPendingDelete by remember { mutableStateOf(null) } + var showGoogleFontsDialog by remember { mutableStateOf(false) } + + SharedScreenScaffold( + title = "Custom Fonts", + subtitle = "Imported fonts for the reader", + modifier = modifier, + trailing = { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + if (googleFontsAvailable) { + Button(onClick = { showGoogleFontsDialog = true }) { + Icon(Icons.Default.CloudDownload, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Google Fonts") + } + } + Button(onClick = onImportFont) { + Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Import") + } + } + } + ) { + val activeFonts = fonts.filterNot { it.isDeleted }.sortedBy { it.displayName.lowercase() } + if (activeFonts.isEmpty()) { + SharedUtilityEmptyState( + icon = { Icon(Icons.Default.TextFields, contentDescription = null, modifier = Modifier.size(56.dp)) }, + title = "No custom fonts", + body = "Import TTF, OTF, or WOFF2 files to use them in books.", + actionLabel = "Import font", + onAction = onImportFont, + modifier = Modifier.weight(1f) + ) + } else { + LazyColumn( + modifier = Modifier.weight(1f).fillMaxWidth(), + contentPadding = PaddingValues(bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(activeFonts, key = { it.id }) { font -> + SharedFontListItem( + font = font, + onDelete = { fontPendingDelete = font }, + fontFamilyForPreview = fontFamilyForPreview + ) + } + } + } + } + + if (googleFontsAvailable && showGoogleFontsDialog) { + SharedGoogleFontsDialog( + existingFonts = fonts, + getGoogleFonts = getGoogleFonts, + onDownloadGoogleFont = onDownloadGoogleFont, + onDismiss = { showGoogleFontsDialog = false } + ) + } + + fontPendingDelete?.let { font -> + AlertDialog( + onDismissRequest = { fontPendingDelete = null }, + title = { Text("Delete font?") }, + text = { Text("Delete ${font.displayName}? Books using it will fall back to the default font.") }, + confirmButton = { + TextButton( + onClick = { + onDeleteFont(font) + fontPendingDelete = null + } + ) { + Text("Delete", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { fontPendingDelete = null }) { + Text("Cancel") + } + } + ) + } +} + +@Composable +private fun SharedGoogleFontsDialog( + existingFonts: List, + getGoogleFonts: () -> List, + onDownloadGoogleFont: (String, () -> Unit) -> Unit, + onDismiss: () -> Unit +) { + var searchQuery by remember { mutableStateOf("") } + var downloadingFontName by remember { mutableStateOf(null) } + val popularPresets = remember { + listOf( + "Merriweather", + "Open Sans", + "Playfair Display", + "Montserrat", + "Oswald", + "Raleway", + "Nunito", + "Poppins", + "Ubuntu", + "Fira Sans", + "Quicksand", + "Crimson Text", + "Literata", + "EB Garamond", + "Libre Baskerville", + "Inter", + "Work Sans" + ) + } + val displayList = remember(searchQuery) { + if (searchQuery.isBlank()) { + popularPresets + } else { + getGoogleFonts() + .filter { it.contains(searchQuery, ignoreCase = true) } + .take(50) + } + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text("Browse Google Fonts", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("Search 1900+ fonts...") }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + singleLine = true, + shape = RoundedCornerShape(8.dp) + ) + + LazyColumn( + modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp), + contentPadding = PaddingValues(bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + if (searchQuery.isBlank()) { + item { + Text( + text = "Popular choices", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary + ) + } + } else if (displayList.isEmpty()) { + item { + Text( + text = "No fonts found matching '$searchQuery'", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(16.dp) + ) + } + } + + items(displayList, key = { it }) { fontName -> + val isDownloaded = existingFonts.any { it.displayName.equals(fontName, ignoreCase = true) } + val isDownloading = downloadingFontName == fontName + fun startDownload() { + downloadingFontName = fontName + onDownloadGoogleFont(fontName) { + if (downloadingFontName == fontName) { + downloadingFontName = null + } + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .background( + if (isDownloaded) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f) + else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + RoundedCornerShape(8.dp) + ) + .clickable(enabled = !isDownloaded && !isDownloading) { startDownload() } + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = fontName, + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (isDownloaded) FontWeight.Bold else FontWeight.Medium, + color = if (isDownloaded) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface + ) + Box( + modifier = Modifier.padding(start = 12.dp), + contentAlignment = Alignment.Center + ) { + when { + isDownloaded -> Icon(Icons.Default.Check, contentDescription = "Already downloaded", tint = MaterialTheme.colorScheme.primary) + isDownloading -> CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + else -> Icon(Icons.Default.CloudDownload, contentDescription = "Download") + } + } + } + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text("Close") + } + } + ) +} + +@Composable +private fun SharedFontListItem( + font: CustomFontItem, + onDelete: () -> Unit, + fontFamilyForPreview: (CustomFontItem) -> FontFamily? +) { + val previewFontFamily = remember(font.path) { fontFamilyForPreview(font) } + + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f)) + ) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer + ) { + Box(Modifier.size(42.dp), contentAlignment = Alignment.Center) { + Text("Aa", fontWeight = FontWeight.Bold) + } + } + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = font.displayName, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = font.path, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Surface( + shape = RoundedCornerShape(50), + color = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ) { + Text( + text = font.fileExtension.uppercase(), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + ) + } + IconButton(onClick = onDelete, modifier = Modifier.size(40.dp)) { + Icon(Icons.Default.Delete, contentDescription = "Delete font", tint = MaterialTheme.colorScheme.error) + } + } + Box( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f), RoundedCornerShape(8.dp)) + .padding(12.dp) + ) { + Text( + text = "Grumpy wizards make toxic brew for the evil queen! 1234567890 ?.,;:", + style = MaterialTheme.typography.bodyLarge.copy(fontSize = 18.sp), + fontFamily = previewFontFamily, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + } +} + +@Composable +fun SharedHelpFeedbackScreen( + onOpenGitHubIssues: () -> Unit, + onEmailSupport: () -> Unit, + modifier: Modifier = Modifier +) { + SharedScreenScaffold( + title = "Help & Feedback", + subtitle = "Bug reports, feature requests, and support", + modifier = modifier + ) { + SharedUtilityHeader( + icon = { Icon(Icons.Default.Feedback, contentDescription = null, modifier = Modifier.size(52.dp)) }, + title = "Get in touch", + body = "Report bugs, request features, or contact support directly." + ) + SharedUtilityOptionCard( + title = "GitHub Issues", + body = "Report bugs, request features, and track development progress.", + icon = { Icon(Icons.Default.Code, contentDescription = null, modifier = Modifier.size(28.dp)) }, + onClick = onOpenGitHubIssues + ) + SharedUtilityOptionCard( + title = "Email Support", + body = "Contact us directly by email for anything else.", + icon = { Icon(Icons.Default.Email, contentDescription = null, modifier = Modifier.size(28.dp)) }, + onClick = onEmailSupport + ) + } +} + +@Composable +fun SharedSupportProjectScreen( + onOpenGitHubSponsors: () -> Unit, + onOpenPatreon: () -> Unit, + modifier: Modifier = Modifier +) { + SharedScreenScaffold( + title = "Support Project", + subtitle = "Ways to support Episteme development", + modifier = modifier + ) { + SharedUtilityHeader( + icon = { Icon(Icons.Default.Favorite, contentDescription = null, modifier = Modifier.size(52.dp)) }, + title = "Support Episteme", + body = "Contributions help keep the reader improving across Android and desktop." + ) + SharedUtilityOptionCard( + title = "GitHub Sponsors", + body = "Support development through GitHub Sponsors.", + icon = { Icon(Icons.Default.Code, contentDescription = null, modifier = Modifier.size(28.dp)) }, + onClick = onOpenGitHubSponsors + ) + SharedUtilityOptionCard( + title = "Patreon", + body = "Support the project on Patreon.", + icon = { Icon(Icons.Default.Favorite, contentDescription = null, modifier = Modifier.size(28.dp)) }, + onClick = onOpenPatreon + ) + } +} + +@Composable +fun SharedAboutScreen( + versionName: String, + buildLabel: String, + onOpenSource: () -> Unit, + onOpenIssues: () -> Unit, + modifier: Modifier = Modifier +) { + SharedScreenScaffold( + title = "About Episteme", + subtitle = "Desktop reader", + modifier = modifier + ) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f)) + ) { + Row( + modifier = Modifier.padding(20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp) + ) { + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) { + Box(Modifier.size(52.dp), contentAlignment = Alignment.Center) { + Icon(Icons.Default.Info, contentDescription = null) + } + } + Column { + Text("Episteme", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) + Text(versionName, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(buildLabel, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + SharedUtilityOptionCard( + title = "Source Code", + body = "Browse the project source on GitHub.", + icon = { Icon(Icons.Default.Code, contentDescription = null, modifier = Modifier.size(28.dp)) }, + onClick = onOpenSource + ) + SharedUtilityOptionCard( + title = "Issues", + body = "Open the issue tracker for bugs and feature requests.", + icon = { Icon(Icons.Default.Feedback, contentDescription = null, modifier = Modifier.size(28.dp)) }, + onClick = onOpenIssues + ) + } +} + +@Composable +private fun SharedUtilityHeader( + icon: @Composable () -> Unit, + title: String, + body: String +) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f)) + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(18.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) { + Box(Modifier.size(58.dp), contentAlignment = Alignment.Center) { + icon() + } + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) + Text( + body, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +@Composable +private fun SharedUtilityOptionCard( + title: String, + body: String, + icon: @Composable () -> Unit, + onClick: () -> Unit +) { + OutlinedCard( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(18.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ) { + Box(Modifier.size(46.dp), contentAlignment = Alignment.Center) { + icon() + } + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Text(body, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Icon(Icons.AutoMirrored.Filled.ArrowForward, contentDescription = "Open") + } + } +} + +@Composable +private fun SharedUtilityEmptyState( + icon: @Composable () -> Unit, + title: String, + body: String, + actionLabel: String, + onAction: () -> Unit, + modifier: Modifier = Modifier +) { + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f)) + ) { + Box(modifier = Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) { + Surface(shape = RoundedCornerShape(18.dp), color = MaterialTheme.colorScheme.surfaceVariant) { + Box(Modifier.padding(18.dp), contentAlignment = Alignment.Center) { + icon() + } + } + Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center) + Text( + body, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(0.7f) + ) + TextButton(onClick = onAction) { + Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text(actionLabel) + } + } + } + } +} diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/EpubAnnotationSerializerTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/EpubAnnotationSerializerTest.kt new file mode 100644 index 0000000..3c22ea1 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/EpubAnnotationSerializerTest.kt @@ -0,0 +1,176 @@ +package com.aryan.reader.shared + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class EpubAnnotationSerializerTest { + + @Test + fun `highlights json round trips and tolerates legacy missing ids`() { + val highlights = listOf( + UserHighlight( + id = "highlight-1", + cfi = "epubcfi(/6/2!/4/2)", + text = "A marked sentence", + color = HighlightColor.BLUE, + chapterIndex = 2, + note = "Important", + locator = ReaderLocator( + chapterIndex = 2, + chapterId = "chapter-2", + pageIndex = 5, + startOffset = 120, + endOffset = 137, + textQuote = "A marked sentence", + cfi = "epubcfi(/6/2!/4/2)" + ) + ) + ) + + val decoded = EpubAnnotationSerializer.parseHighlightsJson( + EpubAnnotationSerializer.highlightsToJson(highlights) + ) + val legacyDecoded = EpubAnnotationSerializer.parseHighlightsJson( + """[{"cfi":"legacy","text":"Legacy mark","colorId":"missing","chapterIndex":1,"note":""}]""" + ) + + assertEquals(highlights, decoded) + assertEquals(HighlightColor.YELLOW, legacyDecoded.single().color) + assertEquals(null, legacyDecoded.single().note) + assertEquals(1, legacyDecoded.single().locator.chapterIndex) + assertEquals("legacy", legacyDecoded.single().locator.cfi) + assertTrue(legacyDecoded.single().id.startsWith("highlight_")) + } + + @Test + fun `bookmarks json supports stored string entries and object arrays`() { + val bookmark = EpubBookmark( + cfi = "epubcfi(/6/4!/4/8)", + chapterTitle = "Two", + label = "Saved place", + snippet = "A useful bookmark", + pageInChapter = 3, + totalPagesInChapter = 9, + chapterIndex = 1, + locator = ReaderLocator( + chapterIndex = 1, + pageIndex = 2, + startOffset = 80, + endOffset = 110, + textQuote = "A useful bookmark", + cfi = "epubcfi(/6/4!/4/8)" + ) + ) + + val decoded = EpubAnnotationSerializer.parseBookmarksJson( + EpubAnnotationSerializer.bookmarksToJson(listOf(bookmark)), + chapterTitles = listOf("One", "Two") + ) + val objectDecoded = EpubAnnotationSerializer.parseBookmarksJson( + """[{"cfi":"cfi","chapterTitle":"Two","snippet":"By title"}]""", + chapterTitles = listOf("One", "Two") + ) + + assertEquals(setOf(bookmark), decoded) + assertEquals(1, objectDecoded.single().chapterIndex) + } + + @Test + fun `processAndAddHighlight updates exact matches and appends new highlights`() { + val highlights = mutableListOf() + val cfi = EpubAnnotationSerializer.processAndAddHighlight( + newCfi = "same-cfi", + newText = "First", + newColor = HighlightColor.YELLOW, + chapterIndex = 0, + currentList = highlights + ) + val initialId = highlights.single().id + + EpubAnnotationSerializer.processAndAddHighlight( + newCfi = "same-cfi", + newText = "Updated", + newColor = HighlightColor.GREEN, + chapterIndex = 0, + currentList = highlights + ) + EpubAnnotationSerializer.processAndAddHighlight( + newCfi = "other-cfi", + newText = "Other", + newColor = HighlightColor.BLUE, + chapterIndex = 0, + currentList = highlights + ) + + assertEquals("same-cfi", cfi) + assertEquals(2, highlights.size) + assertEquals(initialId, highlights.first().id) + assertEquals("Updated", highlights.first().text) + assertEquals(HighlightColor.GREEN, highlights.first().color) + assertNotEquals(initialId, highlights.last().id) + } + + @Test + fun `processAndAddHighlight matches shared locator ranges when cfi changes`() { + val highlights = mutableListOf() + val locator = ReaderLocator( + chapterIndex = 0, + pageIndex = 3, + startOffset = 42, + endOffset = 58, + textQuote = "Stable quote", + cfi = "desktop:0:42:58" + ) + + EpubAnnotationSerializer.processAndAddHighlight( + newCfi = "desktop:0:42:58", + newText = "Stable quote", + newColor = HighlightColor.YELLOW, + chapterIndex = 0, + currentList = highlights, + locator = locator + ) + val initialId = highlights.single().id + + EpubAnnotationSerializer.processAndAddHighlight( + newCfi = "changed-cfi", + newText = "Stable quote updated", + newColor = HighlightColor.BLUE, + chapterIndex = 0, + currentList = highlights, + locator = locator.copy(cfi = "changed-cfi", textQuote = "Stable quote updated") + ) + + assertEquals(1, highlights.size) + assertEquals(initialId, highlights.single().id) + assertEquals(HighlightColor.BLUE, highlights.single().color) + assertEquals(42, highlights.single().locator.startOffset) + } + + @Test + fun `highlight bridge parser accepts raw or wrapped json payloads`() { + val payload = """{"cfi":"desktop:0:4:9","text":"word","colorId":"yellow","chapterIndex":0,"locator":{"chapterIndex":0,"startOffset":4,"endOffset":9,"textQuote":"word","cfi":"desktop:0:4:9"}}""" + val wrappedPayload = "\"${payload.replace("\"", "\\\"")}\"" + + assertEquals(4, EpubAnnotationSerializer.parseHighlightJsonLenient(payload)?.locator?.startOffset) + assertEquals(9, EpubAnnotationSerializer.parseHighlightJsonLenient(wrappedPayload)?.locator?.endOffset) + } + + @Test + fun `legacy desktop cfi values hydrate shared locators`() { + val oldDesktopLocator = ReaderLocator.fromLegacy(cfi = "desktop:2:7:123456:abc") + val timestampFallbackLocator = ReaderLocator.fromLegacy(cfi = "desktop:2:7:1780000000000") + val rangedDesktopLocator = ReaderLocator.fromLegacy(cfi = "desktop:2:40:55") + + assertEquals(2, oldDesktopLocator.chapterIndex) + assertEquals(7, oldDesktopLocator.pageIndex) + assertEquals(7, timestampFallbackLocator.pageIndex) + assertEquals(null, timestampFallbackLocator.startOffset) + assertEquals(null, timestampFallbackLocator.endOffset) + assertEquals(2, rangedDesktopLocator.chapterIndex) + assertEquals(40, rangedDesktopLocator.startOffset) + assertEquals(55, rangedDesktopLocator.endOffset) + } +} diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/FileCapabilitiesTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/FileCapabilitiesTest.kt new file mode 100644 index 0000000..aaee5bf --- /dev/null +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/FileCapabilitiesTest.kt @@ -0,0 +1,78 @@ +package com.aryan.reader.shared + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class FileCapabilitiesTest { + + @Test + fun `shared file capabilities expose Android and desktop readable formats`() { + assertEquals( + PDF_VIEWER_FILE_TYPES + EPUB_READER_FILE_TYPES, + SharedFileCapabilities.readableTypesFor(ReaderPlatform.ANDROID) + ) + assertEquals( + setOf( + FileType.EPUB, + FileType.PDF, + FileType.TXT, + FileType.MD, + FileType.HTML, + FileType.MOBI, + FileType.FB2, + FileType.CBZ, + FileType.CBR, + FileType.CB7, + FileType.DOCX, + FileType.ODT, + FileType.FODT + ), + SharedFileCapabilities.readableTypesFor(ReaderPlatform.DESKTOP) + ) + assertEquals( + SharedFileCapabilities.readableTypesFor(ReaderPlatform.DESKTOP), + SharedFileCapabilities.syncableTypesFor(ReaderPlatform.DESKTOP) + ) + } + + @Test + fun `shared file capabilities map reader surfaces per platform`() { + assertEquals( + ReaderFeatureSurface.PDF_VIEWER, + SharedFileCapabilities.surfaceFor(FileType.PDF, ReaderPlatform.DESKTOP) + ) + assertEquals( + ReaderFeatureSurface.TEXT_READER, + SharedFileCapabilities.surfaceFor(FileType.MD, ReaderPlatform.DESKTOP) + ) + assertEquals( + ReaderFeatureSurface.TEXT_READER, + SharedFileCapabilities.surfaceFor(FileType.DOCX, ReaderPlatform.DESKTOP) + ) + assertEquals( + ReaderFeatureSurface.PDF_VIEWER, + SharedFileCapabilities.surfaceFor(FileType.CBR, ReaderPlatform.DESKTOP) + ) + assertEquals( + ReaderFeatureSurface.EPUB_READER, + SharedFileCapabilities.surfaceFor(FileType.MD, ReaderPlatform.ANDROID) + ) + assertTrue(SharedFileCapabilities.canOpen(FileType.CBZ, ReaderPlatform.ANDROID)) + assertTrue(SharedFileCapabilities.canOpen(FileType.CBZ, ReaderPlatform.DESKTOP)) + } + + @Test + fun `shared file type resolver recognizes aliases used by desktop imports`() { + assertEquals(FileType.MD, SharedFileCapabilities.fileTypeForName("notes.markdown")) + assertEquals(FileType.HTML, SharedFileCapabilities.fileTypeForName("chapter.xhtml")) + assertEquals(FileType.HTML, "chapter.xhtml".toFileType()) + assertEquals(FileType.MOBI, SharedFileCapabilities.fileTypeForName("book.azw3")) + assertEquals(FileType.UNKNOWN, SharedFileCapabilities.fileTypeForName("archive.zip")) + } + + @Test + fun `desktop parity gaps list Android readable formats not yet available on desktop`() { + assertEquals(emptyList(), SharedFileCapabilities.desktopParityGaps()) + } +} diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/LocalFolderSyncEngineTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/LocalFolderSyncEngineTest.kt new file mode 100644 index 0000000..0684eb2 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/LocalFolderSyncEngineTest.kt @@ -0,0 +1,285 @@ +package com.aryan.reader.shared + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class LocalFolderSyncEngineTest { + @Test + fun `stable ids match android folder-relative scheme`() { + assertEquals( + "local_Book.pdf", + LocalFolderSyncEngine.buildStableBookId("Book.pdf", "Book.pdf") + ) + assertEquals( + "local_Book.pdf_488206341973", + LocalFolderSyncEngine.buildStableBookId("Book.pdf", "Series/Book.pdf") + ) + } + + @Test + fun `sync imports scanned folder books with remote metadata`() { + val state = SharedReaderScreenState() + val folder = syncedFolder() + val result = LocalFolderSyncEngine.syncFolder( + state = state, + folder = folder, + files = listOf(scannedFile("Book.pdf", "Book.pdf")), + remoteMetadata = mapOf( + "local_Book.pdf" to metadata( + id = "local_Book.pdf", + title = "Remote Title", + lastPage = 4, + progress = 25f, + modified = 2_000L + ) + ), + nowMillis = 3_000L + ) + + val book = result.state.rawLibraryBooks.single() + assertEquals("local_Book.pdf", book.id) + assertEquals("Remote Title", book.title) + assertEquals(4, book.lastPageIndex) + assertEquals(25f, book.progressPercentage) + assertEquals("C:/Library", book.sourceFolder) + assertEquals(1, result.stats.newBooks) + } + + @Test + fun `newer remote metadata updates existing folder book`() { + val existing = book( + id = "local_Book.pdf", + timestamp = 100L, + title = "Local", + progress = 10f + ) + val result = LocalFolderSyncEngine.syncFolder( + state = SharedReaderScreenState(rawLibraryBooks = listOf(existing)), + folder = syncedFolder(), + files = listOf(scannedFile("Book.pdf", "Book.pdf")), + remoteMetadata = mapOf( + "local_Book.pdf" to metadata( + id = "local_Book.pdf", + title = "Remote", + progress = 80f, + modified = 500L + ) + ), + nowMillis = 1_000L + ) + + val book = result.state.rawLibraryBooks.single() + assertEquals("Remote", book.title) + assertEquals(80f, book.progressPercentage) + assertEquals(1, result.stats.remoteMetadataUpdates) + } + + @Test + fun `older remote metadata does not clobber local book state`() { + val existing = book( + id = "local_Book.pdf", + timestamp = 500L, + title = "Local", + progress = 60f + ) + val result = LocalFolderSyncEngine.syncFolder( + state = SharedReaderScreenState(rawLibraryBooks = listOf(existing)), + folder = syncedFolder(), + files = listOf(scannedFile("Book.pdf", "Book.pdf")), + remoteMetadata = mapOf( + "local_Book.pdf" to metadata( + id = "local_Book.pdf", + title = "Remote", + progress = 5f, + modified = 100L + ) + ), + nowMillis = 1_000L + ) + + val book = result.state.rawLibraryBooks.single() + assertEquals("Local", book.title) + assertEquals(60f, book.progressPercentage) + assertEquals(0, result.stats.remoteMetadataUpdates) + } + + @Test + fun `sync migrates desktop path ids and preserves references`() { + val oldId = "C:/Library/Series/Book.pdf" + val state = SharedReaderScreenState( + rawLibraryBooks = listOf( + book( + id = oldId, + path = oldId, + displayName = "Book.pdf", + sourceFolder = "C:/Library" + ) + ), + selectedBookIds = setOf(oldId), + pinnedHomeBookIds = setOf(oldId), + openTabIds = listOf(oldId), + activeTabBookId = oldId + ) + + val result = LocalFolderSyncEngine.syncFolder( + state = state, + folder = syncedFolder(), + files = listOf(scannedFile("Book.pdf", "Series/Book.pdf")), + remoteMetadata = emptyMap(), + nowMillis = 1_000L + ) + val newId = "local_Book.pdf_488206341973" + + assertEquals(newId, result.state.rawLibraryBooks.single().id) + assertEquals(setOf(newId), result.state.selectedBookIds) + assertEquals(setOf(newId), result.state.pinnedHomeBookIds) + assertEquals(listOf(newId), result.state.openTabIds) + assertEquals(newId, result.state.activeTabBookId) + assertEquals(mapOf(oldId to newId), result.idMigrations) + } + + @Test + fun `sync removes missing books from linked folder only`() { + val missing = book(id = "local_Missing.pdf", path = "C:/Library/Missing.pdf") + val keptExternal = book( + id = "external", + path = "C:/Other/External.pdf", + sourceFolder = "C:/Other" + ) + val result = LocalFolderSyncEngine.syncFolder( + state = SharedReaderScreenState( + rawLibraryBooks = listOf(missing, keptExternal), + selectedBookIds = setOf(missing.id), + pinnedHomeBookIds = setOf(missing.id), + openTabIds = listOf(missing.id), + activeTabBookId = missing.id + ), + folder = syncedFolder(), + files = listOf(scannedFile("Book.pdf", "Book.pdf")), + remoteMetadata = emptyMap(), + nowMillis = 1_000L + ) + + assertNull(result.state.rawLibraryBooks.firstOrNull { it.id == "local_Missing.pdf" }) + assertTrue(result.state.rawLibraryBooks.any { it.id == "external" }) + assertTrue(result.state.selectedBookIds.isEmpty()) + assertTrue(result.state.openTabIds.isEmpty()) + assertNull(result.state.activeTabBookId) + assertEquals(setOf("local_Missing.pdf"), result.removedBookIds) + assertEquals(1, result.stats.removedBooks) + } + + @Test + fun `metadata sidecar is skipped for clean unread folder books`() { + assertNull(book(id = "local_Book.pdf", isRecent = false, progress = null).toSharedFolderBookMetadata()) + assertNotNull(book(id = "local_Book.pdf", isRecent = true).toSharedFolderBookMetadata()) + } + + @Test + fun `sync resets extracted metadata and cover when folder file size changes`() { + val existing = book( + id = "local_Book.pdf", + fileSize = 123L, + coverImagePath = "C:/Covers/book.png", + folderTextMetadataParsed = true + ) + val result = LocalFolderSyncEngine.syncFolder( + state = SharedReaderScreenState(rawLibraryBooks = listOf(existing)), + folder = syncedFolder(), + files = listOf(scannedFile("Book.pdf", "Book.pdf", size = 456L)), + remoteMetadata = emptyMap(), + nowMillis = 1_000L + ) + + val book = result.state.rawLibraryBooks.single() + assertEquals(456L, book.fileSize) + assertNull(book.coverImagePath) + assertFalse(book.folderTextMetadataParsed) + assertEquals(1, result.stats.updatedBooks) + } + + private fun syncedFolder(): SyncedFolder { + return SyncedFolder( + uriString = "C:/Library", + name = "Library", + lastScanTime = 0L, + allowedFileTypes = setOf(FileType.PDF, FileType.EPUB) + ) + } + + private fun scannedFile( + name: String, + relativePath: String, + size: Long = 123L + ): SharedFolderScannedFile { + return SharedFolderScannedFile( + name = name, + path = "C:/Library/$relativePath", + sourceFolder = "C:/Library", + relativePath = relativePath, + type = FileType.PDF, + size = size, + lastModified = 100L + ) + } + + private fun book( + id: String, + path: String = "C:/Library/Book.pdf", + displayName: String = "Book.pdf", + sourceFolder: String = "C:/Library", + timestamp: Long = 100L, + title: String = "Book", + progress: Float? = null, + isRecent: Boolean = false, + fileSize: Long = 0L, + coverImagePath: String? = null, + folderTextMetadataParsed: Boolean = false + ): BookItem { + return BookItem( + id = id, + path = path, + type = FileType.PDF, + displayName = displayName, + timestamp = timestamp, + coverImagePath = coverImagePath, + title = title, + progressPercentage = progress, + fileSize = fileSize, + sourceFolder = sourceFolder, + isRecent = isRecent, + folderTextMetadataParsed = folderTextMetadataParsed + ) + } + + private fun metadata( + id: String, + title: String = "Book", + lastPage: Int? = null, + progress: Float = 0f, + modified: Long + ): SharedFolderBookMetadata { + return SharedFolderBookMetadata( + bookId = id, + title = title, + author = null, + displayName = "Book.pdf", + type = FileType.PDF.name, + lastChapterIndex = null, + lastPage = lastPage, + lastPositionCfi = null, + progressPercentage = progress, + isRecent = true, + lastModifiedTimestamp = modified, + bookmarksJson = null, + locatorBlockIndex = null, + locatorCharOffset = null, + customName = null, + highlightsJson = null + ) + } +} diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/ReaderActionReducerTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/ReaderActionReducerTest.kt new file mode 100644 index 0000000..af6bc74 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/ReaderActionReducerTest.kt @@ -0,0 +1,327 @@ +package com.aryan.reader.shared + +import androidx.compose.ui.graphics.Color +import com.aryan.reader.shared.reader.ReaderEngine +import com.aryan.reader.shared.reader.ReaderReadingMode +import com.aryan.reader.shared.reader.ReaderSearchOptions +import com.aryan.reader.shared.reader.ReaderSettings +import com.aryan.reader.shared.reader.SharedEpubBook +import com.aryan.reader.shared.reader.SharedEpubChapter +import com.aryan.reader.shared.reader.SharedReaderTextAlign +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ReaderActionReducerTest { + + @Test + fun `reader actions navigate search and toggle bookmarks through shared reducer`() { + val engine = ReaderEngine() + val session = engine.createSession(longBook(), settings = compactSettings()) + assertTrue(session.reader.pages.size > 2) + + val pageTwo = session.reduce(ReaderAction.NextPage, engine) + assertEquals(1, pageTwo.reader.currentPageIndex) + + val previous = pageTwo.reduce(ReaderAction.PreviousPage, engine) + assertEquals(0, previous.reader.currentPageIndex) + + val pageByNumber = previous.reduce(ReaderAction.GoToPageNumber(2), engine) + assertEquals(1, pageByNumber.reader.currentPageIndex) + + val lastPage = previous.reduce(ReaderAction.GoToProgress(1f), engine) + assertEquals(lastPage.reader.pages.lastIndex, lastPage.reader.currentPageIndex) + + val chapterTwo = lastPage.reduce(ReaderAction.GoToChapter(1), engine) + assertEquals(1, chapterTwo.reader.currentPage?.chapterIndex) + + val searched = chapterTwo.reduce(ReaderAction.SearchChanged("needle"), engine) + assertTrue(searched.searchResults.size >= 2) + assertTrue(searched.activeSearchResultIndex >= 0) + + val nextSearch = searched.reduce(ReaderAction.NextSearchResult, engine) + assertEquals(searched.activeSearchResultIndex + 1, nextSearch.activeSearchResultIndex) + + val directSearch = searched.reduce(ReaderAction.GoToSearchResult(0), engine) + assertEquals(0, directSearch.activeSearchResultIndex) + + val bookmarked = directSearch.reduce(ReaderAction.ToggleBookmark, engine) + assertEquals(listOf(directSearch.reader.currentPageIndex), bookmarked.bookmarks.map { it.pageIndex }) + + val unbookmarked = bookmarked.reduce(ReaderAction.ToggleBookmark, engine) + assertTrue(unbookmarked.bookmarks.isEmpty()) + } + + @Test + fun `search options and search chrome state are owned by shared reducer`() { + val engine = ReaderEngine() + val session = engine.createSession( + book = SharedEpubBook( + id = "search", + fileName = "search.epub", + title = "Search", + chapters = listOf( + SharedEpubChapter( + id = "one", + title = "One", + plainText = "Alpha alphabet alpha ALPHA" + ) + ) + ), + settings = compactSettings() + ) + + val opened = session.reduce(ReaderAction.SearchOpened, engine) + val caseSensitive = opened + .reduce(ReaderAction.SearchOptionsChanged(ReaderSearchOptions(matchCase = true)), engine) + .reduce(ReaderAction.SearchChanged("alpha"), engine) + val wholeWords = caseSensitive + .reduce( + ReaderAction.SearchOptionsChanged( + ReaderSearchOptions(matchCase = true, wholeWords = true) + ), + engine + ) + val hiddenPanel = wholeWords.reduce(ReaderAction.SearchResultsPanelToggled, engine) + val closed = hiddenPanel.reduce(ReaderAction.SearchClosed, engine) + + assertTrue(opened.isSearchActive) + assertEquals(2, caseSensitive.searchResults.size) + assertEquals(1, wholeWords.searchResults.size) + assertEquals(false, hiddenPanel.showSearchResultsPanel) + assertEquals("", closed.searchQuery) + assertTrue(closed.searchResults.isEmpty()) + } + + @Test + fun `search navigation resumes from page position after page slider moves off a match`() { + val engine = ReaderEngine() + val book = SharedEpubBook( + id = "spaced-search", + fileName = "spaced.epub", + title = "Spaced", + chapters = listOf( + SharedEpubChapter( + id = "one", + title = "One", + plainText = buildString { + append("needle\n\n") + repeat(320) { index -> + append("Paragraph ") + append(index) + append(" contains filler words for pagination only.\n\n") + } + append("final needle") + } + ) + ) + ) + val session = engine.createSession(book, settings = compactSettings()) + val searched = session.reduce(ReaderAction.SearchChanged("needle"), engine) + val middlePage = searched.reader.pages.indices.first { pageIndex -> + searched.searchResults.none { result -> result.pageIndex == pageIndex } + } + + val moved = searched.reduce(ReaderAction.GoToPage(middlePage), engine) + val next = moved.reduce(ReaderAction.NextSearchResult, engine) + val previous = moved.reduce(ReaderAction.PreviousSearchResult, engine) + + assertEquals(2, searched.searchResults.size) + assertEquals(-1, moved.activeSearchResultIndex) + assertTrue(moved.canGoToPreviousSearchResult) + assertTrue(moved.canGoToNextSearchResult) + assertEquals(1, next.activeSearchResultIndex) + assertEquals(0, previous.activeSearchResultIndex) + } + + @Test + fun `settings theme and render actions update shared reader settings`() { + val engine = ReaderEngine() + val session = engine.createSession(longBook(), settings = compactSettings()) + + val settings = session.reader.settings.copy(fontSize = 24, pageWidth = 900, textAlign = SharedReaderTextAlign.CENTER) + val changed = session.reduce(ReaderAction.SettingsChanged(settings), engine) + assertEquals(24, changed.reader.settings.fontSize) + assertEquals(900, changed.reader.settings.pageWidth) + assertEquals(SharedReaderTextAlign.CENTER, changed.reader.settings.textAlign) + + val vertical = changed.reduce(ReaderAction.RenderModeChanged(RenderMode.VERTICAL_SCROLL), engine) + assertEquals(ReaderReadingMode.VERTICAL, vertical.reader.settings.readingMode) + + val dark = vertical.reduce( + ReaderAction.ThemeChanged( + ReaderTheme( + id = "dark", + name = "Dark", + backgroundColor = Color.Black, + textColor = Color.White, + isDark = true + ) + ), + engine + ) + assertTrue(dark.reader.settings.darkMode) + assertEquals(-16777216L, dark.reader.settings.backgroundColorArgb) + assertEquals(-1L, dark.reader.settings.textColorArgb) + } + + @Test + fun `annotation actions use shared locators for navigation and edits`() { + val engine = ReaderEngine() + val session = engine.createSession(longBook(), settings = compactSettings()) + .reduce(ReaderAction.GoToPage(1), engine) + val page = session.reader.currentPage ?: error("Expected current page") + val locator = ReaderLocator( + chapterIndex = page.chapterIndex, + pageIndex = page.pageIndex, + startOffset = page.startOffset + 4, + endOffset = page.startOffset + 18, + textQuote = "shared locator", + cfi = "desktop:${page.chapterIndex}:${page.startOffset + 4}:${page.startOffset + 18}" + ) + + val highlighted = session.reduce( + ReaderAction.HighlightCreated( + UserHighlight( + id = "highlight-1", + cfi = locator.cfi ?: "desktop", + text = "shared locator", + color = HighlightColor.YELLOW, + chapterIndex = page.chapterIndex, + locator = locator + ) + ), + engine + ) + val noted = highlighted.reduce(ReaderAction.HighlightUpdated("highlight-1", note = "Keep this"), engine) + val recolored = noted.reduce(ReaderAction.HighlightUpdated("highlight-1", color = HighlightColor.GREEN), engine) + val jumped = session.reduce(ReaderAction.GoToLocator(locator), engine) + val deleted = recolored.reduce(ReaderAction.HighlightDeleted("highlight-1"), engine) + + assertEquals(locator.startOffset, highlighted.highlights.single().locator.startOffset) + assertEquals("Keep this", recolored.highlights.single().note) + assertEquals(HighlightColor.GREEN, recolored.highlights.single().color) + assertEquals(page.pageIndex, jumped.reader.currentPageIndex) + assertEquals(locator.startOffset, jumped.navigationLocator?.startOffset) + assertEquals(locator.endOffset, jumped.navigationLocator?.endOffset) + assertTrue(deleted.highlights.isEmpty()) + } + + @Test + fun `reader navigation stores locator for vertical scroll targets`() { + val engine = ReaderEngine() + val session = engine.createSession(longBook(), settings = compactSettings()) + val secondPage = session.reduce(ReaderAction.GoToPage(1), engine) + val secondChapter = secondPage.reduce(ReaderAction.GoToChapter(1), engine) + val search = secondChapter.reduce(ReaderAction.SearchChanged("needle"), engine) + val searchTarget = search.searchResults.first() + val jumpedToSearch = search.reduce(ReaderAction.GoToSearchResult(0), engine) + + assertEquals(secondPage.reader.currentPage?.startOffset, secondPage.navigationLocator?.startOffset) + assertEquals(1, secondChapter.navigationLocator?.chapterIndex) + assertEquals(searchTarget.locator.startOffset, jumpedToSearch.navigationLocator?.startOffset) + assertEquals(searchTarget.locator.endOffset, jumpedToSearch.navigationLocator?.endOffset) + } + + @Test + fun `visible page sync updates slider position without creating navigation request`() { + val engine = ReaderEngine() + val session = engine.createSession(longBook(), settings = compactSettings()) + val navigated = session.reduce(ReaderAction.GoToPage(1), engine) + val requestId = navigated.navigationRequestId + val synced = navigated.reduce(ReaderAction.VisiblePageChanged(3), engine) + + assertEquals(3, synced.reader.currentPageIndex) + assertEquals(requestId, synced.navigationRequestId) + assertEquals(navigated.navigationLocator, synced.navigationLocator) + } + + @Test + fun `visible locator sync feeds top visible bookmark location`() { + val engine = ReaderEngine() + val session = engine.createSession(longBook(), settings = compactSettings()) + val page = session.reader.pages[1] + val locator = ReaderLocator( + chapterIndex = page.chapterIndex, + pageIndex = page.pageIndex, + startOffset = page.startOffset + 25, + endOffset = page.startOffset + 25, + textQuote = "top visible text", + cfi = "desktop:${page.chapterIndex}:${page.startOffset + 25}:${page.startOffset + 25}" + ) + + val synced = session.reduce(ReaderAction.VisiblePageChanged(page.pageIndex, locator), engine) + val bookmarked = synced.reduce(ReaderAction.ToggleBookmark, engine) + + assertEquals(locator.startOffset, synced.navigationLocator?.startOffset) + assertEquals(locator.startOffset, bookmarked.bookmarks.single().locator.startOffset) + assertEquals("top visible text", bookmarked.bookmarks.single().preview) + assertTrue(bookmarked.reduce(ReaderAction.ToggleBookmark, engine).bookmarks.isEmpty()) + } + + @Test + fun `format action maps Android style reader appearance to shared reader settings`() { + val engine = ReaderEngine() + val session = engine.createSession( + book = longBook(), + settings = compactSettings().copy(darkMode = true, readingMode = ReaderReadingMode.VERTICAL, pageWidth = 812) + ) + + val updated = session.reduce( + ReaderAction.FormatChanged( + FormatSettings( + fontSize = 1.5f, + lineHeight = 1.2f, + paragraphGap = 0.8f, + imageSize = 1.3f, + horizontalMargin = 0.5f, + verticalMargin = 2.0f, + font = ReaderFont.ROBOTO_MONO, + customPath = null, + textAlign = ReaderTextAlign.JUSTIFY + ) + ), + engine + ) + + assertEquals(27, updated.reader.settings.fontSize) + assertEquals(1.74f, updated.reader.settings.lineSpacing, 0.0001f) + assertEquals(96, updated.reader.settings.margin) + assertEquals(24, updated.reader.settings.resolvedHorizontalMargin) + assertEquals(96, updated.reader.settings.resolvedVerticalMargin) + assertEquals(0.8f, updated.reader.settings.paragraphSpacing, 0.0001f) + assertEquals(1.3f, updated.reader.settings.imageScale, 0.0001f) + assertEquals("Mono", updated.reader.settings.fontFamily) + assertEquals(SharedReaderTextAlign.JUSTIFY, updated.reader.settings.textAlign) + assertTrue(updated.reader.settings.darkMode) + assertEquals(ReaderReadingMode.VERTICAL, updated.reader.settings.readingMode) + assertEquals(812, updated.reader.settings.pageWidth) + } + + private fun compactSettings(): ReaderSettings { + return ReaderSettings(fontSize = 14, margin = 16, lineSpacing = 1.1f, pageWidth = 560) + } + + private fun longBook(): SharedEpubBook { + val repeated = List(240) { index -> + "Paragraph $index gives the paginator enough text to create several pages with a needle hidden inside." + }.joinToString("\n\n") + return SharedEpubBook( + id = "long", + fileName = "long.epub", + title = "Long", + chapters = listOf( + SharedEpubChapter( + id = "one", + title = "One", + plainText = repeated + ), + SharedEpubChapter( + id = "two", + title = "Two", + plainText = "Second chapter starts here. Another needle appears for search navigation. $repeated" + ) + ) + ) + } +} diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/ReaderAppearanceModelsTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/ReaderAppearanceModelsTest.kt new file mode 100644 index 0000000..185ec90 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/ReaderAppearanceModelsTest.kt @@ -0,0 +1,56 @@ +package com.aryan.reader.shared + +import androidx.compose.ui.graphics.toArgb +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class ReaderAppearanceModelsTest { + + @Test + fun `pdf built in themes include android pdf defaults and textured presets`() { + assertEquals("no_theme", BuiltInPdfReaderThemes.first().id) + assertNotNull(BuiltInPdfReaderThemes.firstOrNull { it.id == "reverse" }) + + val texturedThemeIds = BuiltInPdfReaderThemes + .filter { it.textureId != null } + .mapTo(mutableSetOf()) { it.id } + + assertEquals( + setOf( + "pdf_natural_white_texture", + "pdf_retina_texture", + "pdf_veneer_texture", + "pdf_grey_wash_texture", + "pdf_fabric_texture", + "pdf_retro_texture" + ), + texturedThemeIds + ) + } + + @Test + fun `reader textures expose shared desktop resource paths`() { + assertTrue(ReaderTexture.entries.all { it.assetPath.startsWith("textures/") }) + assertEquals("textures/ep_naturalwhite.webp", ReaderTexture.NATURAL_WHITE.assetPath) + assertEquals("textures/texture_paper.png", ReaderTexture.PAPER.assetPath) + } + + @Test + fun `file texture display names use imported file names`() { + assertEquals("custom-paper", readerTextureDisplayName("${ReaderTextureFilePrefix}C:\\textures\\custom-paper.png")) + } + + @Test + fun `pdf textured theme maps into reader settings`() { + val theme = BuiltInPdfReaderThemes.first { it.id == "pdf_fabric_texture" } + val settings = theme.toReaderSettings() + + assertEquals("pdf_fabric_texture", settings.themeId) + assertEquals(ReaderTexture.CLASSY_FABRIC.id, settings.textureId) + assertTrue(settings.darkMode) + assertEquals(theme.backgroundColor.toArgb().toLong(), settings.backgroundColorArgb) + assertEquals(theme.textColor.toArgb().toLong(), settings.textColorArgb) + } +} diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/ReaderExtrasModelsTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/ReaderExtrasModelsTest.kt new file mode 100644 index 0000000..e586384 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/ReaderExtrasModelsTest.kt @@ -0,0 +1,287 @@ +package com.aryan.reader.shared + +import com.aryan.reader.paginatedreader.CssStyle +import com.aryan.reader.paginatedreader.SemanticParagraph +import com.aryan.reader.shared.reader.ReaderEngine +import com.aryan.reader.shared.reader.PaginatedReaderState +import com.aryan.reader.shared.reader.ReaderPage +import com.aryan.reader.shared.reader.ReaderReadingMode +import com.aryan.reader.shared.reader.ReaderSessionState +import com.aryan.reader.shared.reader.ReaderSettings +import com.aryan.reader.shared.reader.SharedEpubBook +import com.aryan.reader.shared.reader.SharedEpubChapter +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ReaderExtrasModelsTest { + + @Test + fun `reader ai settings require BYO key and selected model`() { + val missingModel = ReaderByokTextRequests.build( + settings = ReaderAiByokSettings(groqKey = "gsk_test"), + feature = ReaderAiFeature.DEFINE, + text = "epistemic" + ) + + assertIs(missingModel) + + val missingKey = ReaderByokTextRequests.build( + settings = ReaderAiByokSettings(modelForAll = "groq:qwen/qwen3-32b"), + feature = ReaderAiFeature.DEFINE, + text = "epistemic" + ) + + assertIs(missingKey) + + val ready = ReaderByokTextRequests.build( + settings = ReaderAiByokSettings( + groqKey = "gsk_test", + modelForAll = "groq:qwen/qwen3-32b" + ), + feature = ReaderAiFeature.DEFINE, + text = "epistemic" + ) + + assertIs(ready) + } + + @Test + fun `cloud tts is available only with gemini key and cloud tts model`() { + assertFalse(ReaderAiByokSettings(geminiKey = "key").isCloudTtsAvailable) + assertFalse(ReaderAiByokSettings(ttsModel = GEMINI_CLOUD_TTS_MODEL_ID).isCloudTtsAvailable) + + assertTrue( + ReaderAiByokSettings( + geminiKey = "key", + ttsModel = GEMINI_CLOUD_TTS_MODEL_ID + ).isCloudTtsAvailable + ) + } + + @Test + fun `shared cloud tts voices mirror android voice catalog`() { + assertEquals("Aoede", DEFAULT_CLOUD_TTS_SPEAKER_ID) + assertTrue(ReaderCloudTtsVoices.size >= 30) + assertEquals(ReaderCloudTtsVoices.map { it.id }, ReaderCloudTtsSpeakers) + assertEquals("Breezy, Middle pitch", readerCloudTtsVoiceById("Aoede")?.description) + } + + @Test + fun `shared cloud tts chunking keeps android sentence behavior`() { + val chunks = splitReaderTextIntoTtsChunks( + "First sentence. Second sentence? Third sentence!", + maxLength = 32 + ) + + assertEquals( + listOf("First sentence. Second sentence?", "Third sentence!"), + chunks + ) + } + + @Test + fun `shared cloud tts cache summary formats current voice label`() { + val empty = ReaderTtsCacheSummary() + val populated = ReaderTtsCacheSummary( + cachedChapterCount = 2, + cachedChunkCount = 3, + currentVoiceChunkCount = 2, + totalSizeBytes = 4096, + currentVoiceSizeBytes = 2048 + ) + + assertEquals("No cached chunks for this voice", empty.currentVoiceLabel) + assertEquals("2 chunks, 2.0 KB", populated.currentVoiceLabel) + assertFalse(empty.hasCurrentVoiceCachedAudio) + assertTrue(populated.hasCurrentVoiceCachedAudio) + } + + @Test + fun `hidden reader ai follows android availability logic`() { + val visible = ReaderAiByokSettings( + groqKey = "gsk_test", + modelForAll = "groq:qwen/qwen3-32b" + ) + val hidden = visible.copy(hideReaderAiFeatures = true) + + assertTrue(visible.areReaderAiFeaturesAvailable) + assertFalse(hidden.areReaderAiFeaturesAvailable) + assertIs( + ReaderByokTextRequests.build(hidden, ReaderAiFeature.DEFINE, "epistemic") + ) + } + + @Test + fun `chapter summary context follows current chapter in pagination and vertical modes`() { + val book = SharedEpubBook( + id = "context", + fileName = "context.epub", + title = "Context", + chapters = listOf( + SharedEpubChapter("one", "One", "First chapter text"), + SharedEpubChapter("two", "Two", "Second chapter text") + ) + ) + val engine = ReaderEngine() + val paginated = engine.createSession(book) + .reduce(ReaderAction.GoToChapter(1), engine) + val vertical = engine.createSession(book, settings = ReaderSettings(readingMode = ReaderReadingMode.VERTICAL)) + .reduce(ReaderAction.GoToChapter(1), engine) + + assertEquals("Second chapter text", ReaderContextExtractor.currentChapterText(paginated)) + assertEquals("Second chapter text", ReaderContextExtractor.currentChapterText(vertical)) + } + + @Test + fun `tts planner follows android sentence chunking`() { + val sentenceOne = "First " + "word ".repeat(20).trim() + "." + val sentenceTwo = "Second " + "word ".repeat(20).trim() + "!" + val sentenceThree = "Third " + "word ".repeat(20).trim() + "?" + val text = listOf(sentenceOne, sentenceTwo, sentenceThree).joinToString(" ") + val chunks = ReaderTtsPlanner.chunksForText( + text = text, + pageIndex = 4, + chapterIndex = 2, + chapterTitle = "Offsets", + sourceStartOffset = 12 + ) + + assertEquals( + listOf( + "$sentenceOne $sentenceTwo", + sentenceThree + ), + chunks.map { it.text } + ) + assertTrue(chunks.all { it.text.length <= READER_TTS_CHUNK_MAX_LENGTH }) + assertEquals(chunks.indices.toList(), chunks.map { it.index }) + assertEquals(12, chunks.first().startOffset) + assertEquals(12 + text.trimEnd().length, chunks.last().endOffset) + assertTrue(chunks.all { it.pageIndex == 4 && it.chapterIndex == 2 }) + } + + @Test + fun `tts planner keeps android long sentence behavior`() { + val text = "word ".repeat(80).trim() + val chunks = ReaderTtsPlanner.chunksForText( + text = text, + pageIndex = 4, + chapterIndex = 2, + chapterTitle = "Offsets" + ) + + assertEquals(listOf(text), chunks.map { it.text }) + } + + @Test + fun `tts planner can read page chapter or onward from current location`() { + val book = SharedEpubBook( + id = "tts", + fileName = "tts.epub", + title = "TTS", + chapters = listOf( + SharedEpubChapter("one", "One", "First page text."), + SharedEpubChapter("two", "Two", "Second page text.") + ) + ) + val session = ReaderEngine().createSession(book) + + assertEquals(listOf(0), ReaderTtsPlanner.chunksForCurrentPage(session).map { it.chapterIndex }.distinct()) + assertEquals(listOf(0), ReaderTtsPlanner.chunksForCurrentChapter(session).map { it.chapterIndex }.distinct()) + assertEquals(listOf(0, 1), ReaderTtsPlanner.chunksFromCurrentLocation(session).map { it.chapterIndex }.distinct()) + } + + @Test + fun `tts planner maps trimmed page text back to source offsets`() { + val source = "Intro.\n\n Leading words continue." + val book = SharedEpubBook( + id = "tts-offsets", + fileName = "tts-offsets.epub", + title = "TTS offsets", + chapters = listOf(SharedEpubChapter("one", "One", source)) + ) + val page = ReaderPage( + pageIndex = 0, + chapterIndex = 0, + chapterTitle = "One", + text = "Leading words continue.", + startOffset = 8, + endOffset = source.length + ) + val session = ReaderSessionState( + reader = PaginatedReaderState( + book = book, + pages = listOf(page), + currentPageIndex = 0 + ) + ) + + val chunk = ReaderTtsPlanner.chunksForCurrentPage(session).first() + + assertEquals(source.indexOf("Leading"), chunk.startOffset) + assertEquals("Leading words continue.", source.substring(chunk.startOffset, chunk.endOffset)) + } + + @Test + fun `tts planner prefers semantic source cfi chunks when available`() { + val source = "First sentence. Second sentence." + val semanticBlock = SemanticParagraph( + text = source, + spans = emptyList(), + style = CssStyle(), + elementId = null, + cfi = "/4/2", + startCharOffsetInSource = 5, + blockIndex = 1 + ) + val book = SharedEpubBook( + id = "tts-semantic", + fileName = "tts-semantic.epub", + title = "TTS semantic", + chapters = listOf( + SharedEpubChapter( + id = "one", + title = "One", + plainText = source, + semanticBlocks = listOf(semanticBlock) + ) + ) + ) + val page = ReaderPage( + pageIndex = 0, + chapterIndex = 0, + chapterTitle = "One", + text = source, + startOffset = 0, + endOffset = source.length + 5 + ) + val session = ReaderSessionState( + reader = PaginatedReaderState( + book = book, + pages = listOf(page), + currentPageIndex = 0 + ) + ) + + val chunks = ReaderTtsPlanner.chunksForCurrentPage(session) + + assertEquals("/4/2", chunks.first().sourceCfi) + assertEquals(5, chunks.first().startOffset) + assertEquals("/4/2", chunks.first().toLocator().cfi) + } + + @Test + fun `external lookup urls encode selected text`() { + assertEquals( + "https://www.google.com/search?q=define+hello+world", + externalLookupUrl(ReaderExternalLookupAction.DICTIONARY, "hello world") + ) + assertEquals( + "https://translate.google.com/?sl=auto&tl=en&text=hello+world&op=translate", + externalLookupUrl(ReaderExternalLookupAction.TRANSLATE, "hello world") + ) + } +} diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/ReaderMarkdownParserTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/ReaderMarkdownParserTest.kt new file mode 100644 index 0000000..adc1982 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/ReaderMarkdownParserTest.kt @@ -0,0 +1,31 @@ +package com.aryan.reader.shared + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class ReaderMarkdownParserTest { + @Test + fun `parses headings lists quotes and code blocks`() { + val document = ReaderMarkdownParser.parse( + """ + ## Summary + + - first point + - second point + + > quoted context + + ``` + code line + ``` + """.trimIndent() + ) + + assertIs(document.blocks[0]) + assertEquals("Summary", (document.blocks[0] as ReaderMarkdownBlock.Heading).text) + assertEquals(listOf("first point", "second point"), (document.blocks[1] as ReaderMarkdownBlock.ListItems).items) + assertEquals("quoted context", (document.blocks[2] as ReaderMarkdownBlock.Quote).text) + assertEquals("code line", (document.blocks[3] as ReaderMarkdownBlock.CodeBlock).text) + } +} diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/ReaderToolbarPreferencesTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/ReaderToolbarPreferencesTest.kt new file mode 100644 index 0000000..1db58d2 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/ReaderToolbarPreferencesTest.kt @@ -0,0 +1,51 @@ +package com.aryan.reader.shared + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ReaderToolbarPreferencesTest { + + @Test + fun `toolbar preferences sanitize unknown ids and preserve missing tools`() { + val preferences = ReaderToolbarPreferences( + hiddenToolIds = setOf(ReaderTool.SEARCH.id, "missing"), + toolOrder = listOf(ReaderTool.BOOKMARK, ReaderTool.THEME), + bottomToolIds = setOf(ReaderTool.BOOKMARK.id, "missing") + ).sanitized() + + assertEquals(setOf(ReaderTool.SEARCH.id), preferences.hiddenToolIds) + assertEquals(ReaderTool.BOOKMARK, preferences.toolOrder.first()) + assertEquals(ReaderTool.THEME, preferences.toolOrder[1]) + assertTrue(ReaderTool.SEARCH in preferences.toolOrder) + assertEquals(setOf(ReaderTool.BOOKMARK.id), preferences.bottomToolIds) + } + + @Test + fun `toolbar reducers update shared screen state`() { + val state = SharedReaderScreenState() + .reduce(AppAction.ReaderToolVisibilityChanged(ReaderTool.SEARCH, hidden = true)) + .reduce(AppAction.ReaderToolPlacementChanged(ReaderTool.BOOKMARK, bottom = true)) + .reduce(AppAction.ReaderToolOrderChanged(listOf(ReaderTool.BOOKMARK, ReaderTool.THEME))) + + assertFalse(state.readerToolbarPreferences.isVisible(ReaderTool.SEARCH)) + assertTrue(state.readerToolbarPreferences.isBottom(ReaderTool.BOOKMARK)) + assertEquals(ReaderTool.BOOKMARK, state.readerToolbarPreferences.toolOrder.first()) + assertEquals(ReaderTool.THEME, state.readerToolbarPreferences.toolOrder[1]) + } + + @Test + fun `highlight palette reducer sanitizes colors`() { + val state = SharedReaderScreenState() + .reduce( + AppAction.ReaderHighlightPaletteChanged( + ReaderHighlightPalette( + colors = listOf(HighlightColor.CYAN, HighlightColor.CYAN, HighlightColor.YELLOW) + ) + ) + ) + + assertEquals(listOf(HighlightColor.CYAN, HighlightColor.YELLOW), state.readerHighlightPalette.colors) + } +} diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/ReaderTtsReplacementEngineTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/ReaderTtsReplacementEngineTest.kt new file mode 100644 index 0000000..849ec73 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/ReaderTtsReplacementEngineTest.kt @@ -0,0 +1,156 @@ +package com.aryan.reader.shared + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ReaderTtsReplacementEngineTest { + @Test + fun `literal replacement changes spoken text only`() { + val preferences = ReaderTtsReplacementPreferences( + globalRules = listOf(rule(from = "Dr.", to = "Doctor", wholeWord = false)) + ) + + val result = ReaderTtsReplacementEngine.apply("Dr. Smith arrived.", preferences) + + assertEquals("Doctor Smith arrived.", result.text) + assertEquals(listOf("rule"), result.appliedRuleIds) + } + + @Test + fun `phrase replacement handles multi word phrases`() { + val preferences = ReaderTtsReplacementPreferences( + globalRules = listOf(rule(from = "et al.", to = "and others", wholeWord = false)) + ) + + val result = ReaderTtsReplacementEngine.apply("Smith et al. wrote it.", preferences) + + assertEquals("Smith and others wrote it.", result.text) + } + + @Test + fun `whole word replacement does not replace inside larger words`() { + val preferences = ReaderTtsReplacementPreferences( + globalRules = listOf(rule(from = "he", to = "they", wholeWord = true)) + ) + + val result = ReaderTtsReplacementEngine.apply("he heard the theme", preferences) + + assertEquals("they heard the theme", result.text) + } + + @Test + fun `case sensitivity can be required per rule`() { + val preferences = ReaderTtsReplacementPreferences( + globalRules = listOf(rule(from = "NASA", to = "N A S A", matchCase = true)) + ) + + val result = ReaderTtsReplacementEngine.apply("NASA and nasa", preferences) + + assertEquals("N A S A and nasa", result.text) + } + + @Test + fun `regex rule supports capture replacements`() { + val preferences = ReaderTtsReplacementPreferences( + globalRules = listOf( + rule( + from = """\b([A-Z])\.\s*([A-Z])\.""", + to = "\$1 \$2", + isRegex = true, + wholeWord = false + ) + ) + ) + + val result = ReaderTtsReplacementEngine.apply("J. R. wrote it.", preferences) + + assertEquals("J R wrote it.", result.text) + } + + @Test + fun `invalid regex is skipped and reported`() { + val preferences = ReaderTtsReplacementPreferences( + globalRules = listOf(rule(from = "(", to = "open", isRegex = true)) + ) + + val result = ReaderTtsReplacementEngine.apply("Keep this text.", preferences) + + assertEquals("Keep this text.", result.text) + assertTrue(result.errors.isNotEmpty()) + } + + @Test + fun `global rules run before book rules`() { + val preferences = ReaderTtsReplacementPreferences( + globalRules = listOf(rule(id = "global", from = "Dr.", to = "Doctor", wholeWord = false)), + bookRules = mapOf( + "book" to listOf(rule(id = "book", from = "Doctor", to = "Professor")) + ) + ) + + val result = ReaderTtsReplacementEngine.apply("Dr. Smith", preferences, bookId = "book") + + assertEquals("Professor Smith", result.text) + assertEquals(listOf("global", "book"), result.appliedRuleIds) + } + + @Test + fun `book settings can disable inherited global rules`() { + val preferences = ReaderTtsReplacementPreferences( + globalRules = listOf(rule(id = "global", from = "Dr.", to = "Doctor", wholeWord = false)), + bookSettings = mapOf( + "book" to ReaderTtsReplacementBookSettings(disabledGlobalRuleIds = setOf("global")) + ) + ) + + val result = ReaderTtsReplacementEngine.apply("Dr. Smith", preferences, bookId = "book") + + assertEquals("Dr. Smith", result.text) + assertTrue(result.appliedRuleIds.isEmpty()) + } + + @Test + fun `preferences serialize and deserialize without losing rules`() { + val preferences = ReaderTtsReplacementPreferences( + isEnabled = false, + globalRules = listOf(rule(id = "global", from = "Mr.", to = "Mister", wholeWord = false)), + bookRules = mapOf( + "book" to listOf(rule(id = "book", from = "St.", to = "Saint", wholeWord = false)) + ), + bookSettings = mapOf( + "book" to ReaderTtsReplacementBookSettings( + localRulesEnabled = false, + globalRulesEnabled = true, + disabledGlobalRuleIds = setOf("global") + ) + ) + ) + + val decoded = ReaderTtsReplacementPreferencesJson.decodeOrEmpty( + ReaderTtsReplacementPreferencesJson.encode(preferences) + ) + + assertEquals(preferences, decoded) + } + + private fun rule( + id: String = "rule", + from: String, + to: String, + enabled: Boolean = true, + isRegex: Boolean = false, + matchCase: Boolean = false, + wholeWord: Boolean = true + ): ReaderTtsReplacementRule { + return ReaderTtsReplacementRule( + id = id, + from = from, + to = to, + enabled = enabled, + isRegex = isRegex, + matchCase = matchCase, + wholeWord = wholeWord + ) + } +} diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/SharedAppThemeReducerTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/SharedAppThemeReducerTest.kt new file mode 100644 index 0000000..c43f804 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/SharedAppThemeReducerTest.kt @@ -0,0 +1,61 @@ +package com.aryan.reader.shared + +import androidx.compose.ui.graphics.Color +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SharedAppThemeReducerTest { + + @Test + fun `app appearance actions update shared settings`() { + val seedColor = Color(0xFF006C4C) + val state = SharedReaderScreenState() + .reduce(AppAction.AppThemeChanged(AppThemeMode.DARK)) + .reduce(AppAction.AppContrastChanged(AppContrastOption.HIGH)) + .reduce(AppAction.AppTextDimFactorLightChanged(0.75f)) + .reduce(AppAction.AppTextDimFactorDarkChanged(0.65f)) + .reduce(AppAction.AppSeedColorChanged(seedColor)) + + assertEquals(AppThemeMode.DARK, state.appThemeMode) + assertEquals(AppContrastOption.HIGH, state.appContrastOption) + assertEquals(0.75f, state.appTextDimFactorLight) + assertEquals(0.65f, state.appTextDimFactorDark) + assertEquals(seedColor, state.appSeedColor) + } + + @Test + fun `custom app theme add replaces matching id and selects seed color`() { + val first = CustomAppTheme(id = "theme", name = "First", seedColor = Color(0xFF123456)) + val second = CustomAppTheme(id = "theme", name = "Second", seedColor = Color(0xFF654321)) + + val state = SharedReaderScreenState() + .reduce(AppAction.CustomAppThemeAdded(first)) + .reduce(AppAction.CustomAppThemeAdded(second)) + + assertEquals(listOf(second), state.customAppThemes) + assertEquals(second.seedColor, state.appSeedColor) + } + + @Test + fun `deleting selected custom app theme clears orphaned seed color`() { + val theme = CustomAppTheme(id = "forest", name = "Forest", seedColor = Color(0xFF006C4C)) + val state = SharedReaderScreenState() + .reduce(AppAction.CustomAppThemeAdded(theme)) + .reduce(AppAction.CustomAppThemeDeleted(theme.id)) + + assertTrue(state.customAppThemes.isEmpty()) + assertNull(state.appSeedColor) + } + + @Test + fun `text dim factors stay inside supported slider range`() { + val state = SharedReaderScreenState() + .reduce(AppAction.AppTextDimFactorLightChanged(0.1f)) + .reduce(AppAction.AppTextDimFactorDarkChanged(1.2f)) + + assertEquals(0.3f, state.appTextDimFactorLight) + assertEquals(1.0f, state.appTextDimFactorDark) + } +} diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/SharedLibraryEditorTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/SharedLibraryEditorTest.kt new file mode 100644 index 0000000..f476896 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/SharedLibraryEditorTest.kt @@ -0,0 +1,229 @@ +package com.aryan.reader.shared + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SharedLibraryEditorTest { + + @Test + fun `clean helpers trim names and reject blank values`() { + assertEquals("Favorites", SharedLibraryEditor.cleanShelfName(" Favorites ")) + assertEquals("Reference", SharedLibraryEditor.cleanTagName(" Reference ")) + assertNull(SharedLibraryEditor.cleanShelfName(" ")) + assertNull(SharedLibraryEditor.cleanTagName("")) + assertTrue(SharedLibraryEditor.canMutateShelf("manual")) + assertTrue(!SharedLibraryEditor.canMutateShelf("unshelved")) + assertTrue(!SharedLibraryEditor.canMutateShelf(" ")) + assertEquals(setOf("a", "b"), SharedLibraryEditor.cleanBookIds(listOf(" a ", "", "b", "a"))) + } + + @Test + fun `create records trim input and reject blank ids`() { + val shelf = SharedLibraryEditor.createShelfRecord(" Manual ", " shelf ") + val tag = SharedLibraryEditor.createTag(" Sci-Fi ", " tag ", color = 7) + + assertEquals(ShelfRecord(id = "shelf", name = "Manual"), shelf) + assertEquals(Tag(id = "tag", name = "Sci-Fi", color = 7), tag) + assertNull(SharedLibraryEditor.createShelfRecord("Manual", " ")) + assertNull(SharedLibraryEditor.createTag(" ", "tag")) + } + + @Test + fun `removeSelectedBooks removes books and shelf refs then clears selection`() { + val state = SharedReaderScreenState( + rawLibraryBooks = listOf(book("keep"), book("remove")), + selectedBookIds = setOf("remove") + ) + val refs = listOf( + BookShelfRef(bookId = "keep", shelfId = "manual", addedAt = 1L), + BookShelfRef(bookId = "remove", shelfId = "manual", addedAt = 2L) + ) + + val result = SharedLibraryEditor.removeSelectedBooks(state, shelfRecords = emptyList(), shelfRefs = refs) + + requireNotNull(result) + assertEquals(listOf("keep"), result.state.rawLibraryBooks.ids()) + assertTrue(result.state.selectedBookIds.isEmpty()) + assertEquals(listOf("keep"), result.shelfRefs.map { it.bookId }) + assertEquals("Removed 1 book(s) from the library.", result.state.bannerMessage?.message) + } + + @Test + fun `addSelectedBooksToShelf adds only missing refs and clears selection`() { + val state = SharedReaderScreenState(selectedBookIds = setOf("existing", "new")) + val refs = listOf(BookShelfRef(bookId = "existing", shelfId = "manual", addedAt = 1L)) + + val result = SharedLibraryEditor.addSelectedBooksToShelf( + state = state, + shelfRecords = listOf(ShelfRecord("manual", "Manual")), + shelfRefs = refs, + shelfId = "manual", + nowMillis = 5L + ) + + requireNotNull(result) + assertTrue(result.state.selectedBookIds.isEmpty()) + assertEquals( + listOf( + BookShelfRef(bookId = "existing", shelfId = "manual", addedAt = 1L), + BookShelfRef(bookId = "new", shelfId = "manual", addedAt = 5L) + ), + result.shelfRefs + ) + assertEquals("Added 1 book(s) to shelf.", result.state.bannerMessage?.message) + } + + @Test + fun `createSmartShelf stores trimmed shared rules and rejects blank definitions`() { + val definition = SmartCollectionDefinition( + rules = listOf( + SmartRule(SmartField.TITLE, SmartOperator.CONTAINS, " dune "), + SmartRule(SmartField.AUTHOR, SmartOperator.CONTAINS, " ") + ) + ) + + val result = SharedLibraryEditor.createSmartShelf( + state = SharedReaderScreenState(), + shelfRecords = emptyList(), + shelfRefs = emptyList(), + name = " Smart Picks ", + definition = definition, + nowMillis = 7L + ) + + requireNotNull(result) + val shelf = result.shelfRecords.single() + val decoded = SmartCollectionEngine.fromJson(shelf.smartRulesJson) + assertEquals(ShelfRecord("smart_7", "Smart Picks", isSmart = true, smartRulesJson = shelf.smartRulesJson), shelf) + assertEquals(listOf(SmartRule(SmartField.TITLE, SmartOperator.CONTAINS, "dune")), decoded?.rules) + assertEquals("Created smart shelf \"Smart Picks\".", result.state.bannerMessage?.message) + assertNull( + SharedLibraryEditor.createSmartShelf( + state = SharedReaderScreenState(), + shelfRecords = emptyList(), + shelfRefs = emptyList(), + name = "Blank", + definition = SmartCollectionDefinition(rules = listOf(SmartRule(SmartField.TITLE, SmartOperator.CONTAINS, " "))), + nowMillis = 8L + ) + ) + } + + @Test + fun `tagSelectedBooks reuses matching tags case insensitively`() { + val favorite = Tag(id = "favorite", name = "Favorite") + val state = SharedReaderScreenState( + rawLibraryBooks = listOf(book("one"), book("two", tags = listOf(favorite))), + allTags = listOf(favorite), + selectedBookIds = setOf("one", "two") + ) + + val result = SharedLibraryEditor.tagSelectedBooks( + state = state, + shelfRecords = emptyList(), + shelfRefs = emptyList(), + tagName = " favorite ", + nowMillis = 10L + ) + + requireNotNull(result) + assertEquals(listOf(favorite), result.state.allTags) + assertEquals(listOf(favorite), result.state.rawLibraryBooks.first { it.id == "one" }.tags) + assertEquals(listOf(favorite), result.state.rawLibraryBooks.first { it.id == "two" }.tags) + assertTrue(result.state.selectedBookIds.isEmpty()) + } + + @Test + fun `updateBookMetadata updates book timestamp and merges tags`() { + val old = book("book", title = "Old") + val newTag = Tag("new", "New") + + val result = SharedLibraryEditor.updateBookMetadata( + state = SharedReaderScreenState(rawLibraryBooks = listOf(old)), + shelfRecords = emptyList(), + shelfRefs = emptyList(), + updated = old.copy(title = "New", tags = listOf(newTag)), + nowMillis = 99L + ) + + val updatedBook = result.state.rawLibraryBooks.single() + assertEquals("New", updatedBook.title) + assertEquals(99L, updatedBook.timestamp) + assertEquals(listOf(newTag), result.state.allTags) + assertEquals("Updated \"New\".", result.state.bannerMessage?.message) + } + + @Test + fun `removeFolder removes folder books tabs pins refs and synced folder metadata`() { + val folderBook = book("folder_book").copy(sourceFolder = "C:/Books") + val otherBook = book("other") + val folder = Shelf( + id = "folder_C:/Books", + name = "Books", + type = ShelfType.FOLDER, + books = listOf(folderBook) + ) + val state = SharedReaderScreenState( + rawLibraryBooks = listOf(folderBook, otherBook), + selectedBookIds = setOf("folder_book", "other"), + pinnedHomeBookIds = setOf("folder_book"), + pinnedLibraryBookIds = setOf("folder_book", "other"), + openTabIds = listOf("folder_book", "other"), + activeTabBookId = "folder_book", + syncedFolders = listOf(SyncedFolder("C:/Books", "Books", lastScanTime = 1L)), + libraryFilters = LibraryFilters(sourceFolders = setOf("C:/Books")) + ) + val refs = listOf( + BookShelfRef(bookId = "folder_book", shelfId = "manual", addedAt = 1L), + BookShelfRef(bookId = "other", shelfId = "manual", addedAt = 2L) + ) + + val result = SharedLibraryEditor.removeFolder(state, emptyList(), refs, folder) + + requireNotNull(result) + assertEquals(listOf("other"), result.state.rawLibraryBooks.ids()) + assertEquals(setOf("other"), result.state.selectedBookIds) + assertTrue(result.state.pinnedHomeBookIds.isEmpty()) + assertEquals(setOf("other"), result.state.pinnedLibraryBookIds) + assertEquals(listOf("other"), result.state.openTabIds) + assertNull(result.state.activeTabBookId) + assertTrue(result.state.syncedFolders.isEmpty()) + assertTrue(result.state.libraryFilters.sourceFolders.isEmpty()) + assertEquals(listOf("other"), result.shelfRefs.map { it.bookId }) + } + + @Test + fun `markBookOpened marks book recent and updates timestamp`() { + val state = SharedReaderScreenState( + rawLibraryBooks = listOf( + book("opened").copy(isRecent = false, timestamp = 1L), + book("other").copy(isRecent = false, timestamp = 2L) + ) + ) + + val result = SharedLibraryEditor.markBookOpened(state, "opened", nowMillis = 99L) + + assertTrue(result.rawLibraryBooks.first { it.id == "opened" }.isRecent) + assertEquals(99L, result.rawLibraryBooks.first { it.id == "opened" }.timestamp) + assertTrue(!result.rawLibraryBooks.first { it.id == "other" }.isRecent) + assertEquals(2L, result.rawLibraryBooks.first { it.id == "other" }.timestamp) + } + + private fun book( + id: String, + title: String? = id, + tags: List = emptyList() + ) = BookItem( + id = id, + path = "/library/$id.epub", + type = FileType.EPUB, + displayName = "$id.epub", + timestamp = 1L, + title = title, + tags = tags + ) + + private fun List.ids() = map { it.id } +} diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/SharedLibraryProjectorTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/SharedLibraryProjectorTest.kt new file mode 100644 index 0000000..d8cd77e --- /dev/null +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/SharedLibraryProjectorTest.kt @@ -0,0 +1,373 @@ +package com.aryan.reader.shared + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SharedLibraryProjectorTest { + + @Test + fun `LibraryProjector searches filters sorts and builds selected library model`() { + val tag = Tag("favorite", "Favorite") + val matching = book( + id = "matching", + title = "Clean Android", + author = "Ada", + type = FileType.PDF, + progressPercentage = 50f, + sourceFolder = "/books", + tags = listOf(tag), + timestamp = 3L + ) + val wrongTag = book("wrong_tag", title = "Clean Kotlin", type = FileType.PDF, progressPercentage = 50f) + val wrongStatus = book("wrong_status", title = "Clean Done", type = FileType.PDF, progressPercentage = 100f, tags = listOf(tag)) + + val model = LibraryProjector().library( + LibraryState( + books = listOf(wrongTag, matching, wrongStatus), + searchQuery = "clean", + sortOrder = SortOrder.TITLE_ASC, + filters = LibraryFilters( + fileTypes = setOf(FileType.PDF), + sourceFolders = setOf("/books"), + readStatus = ReadStatusFilter.IN_PROGRESS, + tagIds = setOf(tag.id) + ), + selectedBookIds = setOf("matching", "missing") + ) + ) + + assertEquals(listOf("matching"), model.books.ids()) + assertEquals(listOf("matching"), model.selectedBooks.ids()) + assertEquals(SortOrder.TITLE_ASC, model.sortOrder) + assertEquals("clean", model.searchQuery) + assertTrue(model.filters.isActive) + } + + @Test + fun `LibraryProjector home limits sorted recent books and keeps selected books`() { + val model = LibraryProjector().home( + LibraryState( + books = listOf( + book("old", timestamp = 1L), + book("new", timestamp = 3L), + book("archived", timestamp = 2L, isRecent = false) + ), + selectedBookIds = setOf("old", "archived"), + recentLimit = 1, + sortOrder = SortOrder.RECENT + ) + ) + + assertEquals(listOf("new"), model.recentBooks.ids()) + assertEquals(listOf("old", "archived"), model.selectedBooks.ids()) + assertFalse(model.isEmpty) + } + + @Test + fun `LibraryProjector imports only new files and maps extensions and folders`() { + val projector = LibraryProjector() + val state = LibraryState(books = listOf(book("C:/books/existing.pdf", displayName = "existing.pdf", isRecent = false))) + + val result = projector.withImportedFiles( + state, + listOf( + ImportedFile(name = "existing.pdf", path = "C:/books/existing.pdf", size = 1L), + ImportedFile(name = "notes.md", path = "C:/books/notes.md", size = 2L, sourceFolder = "C:/books"), + ImportedFile(name = "mystery.bin", path = null, size = 3L) + ) + ) + + assertEquals(listOf("C:/books/notes.md", "mystery.bin", "C:/books/existing.pdf"), result.books.ids()) + assertEquals(FileType.MD, result.books[0].type) + assertEquals("C:/books", result.books[0].sourceFolder) + assertFalse(result.books[0].isRecent) + assertEquals(FileType.UNKNOWN, result.books[1].type) + assertFalse(result.books[1].isRecent) + assertTrue(projector.home(result).recentBooks.isEmpty()) + assertEquals("Imported 2 file(s). Reader support comes later.", result.message) + } + + @Test + fun `SharedLibraryStateProjector prunes stale selections tabs and shelf state`() { + val existing = book("existing") + val result = SharedLibraryStateProjector().project( + SharedLibraryProjectionInput( + state = SharedReaderScreenState( + selectedBookIds = setOf("existing", "missing"), + openTabIds = listOf("missing", "existing"), + activeTabBookId = "missing", + viewingShelfId = "missing_shelf", + isAddingBooksToShelf = true, + selectedShelfIds = setOf("missing_shelf") + ), + booksFromStore = listOf(existing), + shelfRecords = emptyList(), + shelfRefs = emptyList(), + tags = emptyList() + ) + ) + + assertEquals(setOf("existing"), result.selectedBookIds) + assertEquals(listOf("existing"), result.openTabs.ids()) + assertEquals(listOf("existing"), result.openTabIds) + assertNull(result.activeTabBookId) + assertNull(result.viewingShelfId) + assertFalse(result.isAddingBooksToShelf) + assertTrue(result.selectedShelfIds.isEmpty()) + } + + @Test + fun `SharedLibraryStateProjector keeps pinned home and library books first`() { + val older = book("older", title = "Zulu", timestamp = 1L) + val newer = book("newer", title = "Alpha", timestamp = 2L) + + val result = SharedLibraryStateProjector().project( + SharedLibraryProjectionInput( + state = SharedReaderScreenState( + rawLibraryBooks = listOf(older, newer), + pinnedHomeBookIds = setOf("older"), + pinnedLibraryBookIds = setOf("older"), + sortOrder = SortOrder.TITLE_ASC + ), + booksFromStore = listOf(older, newer), + shelfRecords = emptyList(), + shelfRefs = emptyList(), + tags = emptyList() + ) + ) + + assertEquals(listOf("older", "newer"), result.recentBooks.ids()) + assertEquals(listOf("older", "newer"), result.libraryBooks.ids()) + } + + @Test + fun `shared app actions manage tabs and pins`() { + val opened = SharedReaderScreenState() + .reduce(AppAction.BookTabOpened("one")) + .reduce(AppAction.BookTabOpened("two")) + .reduce(AppAction.HomePinToggled("one")) + .reduce(AppAction.LibraryPinToggled("two")) + + assertTrue(opened.isTabsEnabled) + assertEquals(listOf("one", "two"), opened.openTabIds) + assertEquals("two", opened.activeTabBookId) + assertEquals(setOf("one"), opened.pinnedHomeBookIds) + assertEquals(setOf("two"), opened.pinnedLibraryBookIds) + + val closedActive = opened.reduce(AppAction.BookTabClosed("two")) + + assertEquals(listOf("one"), closedActive.openTabIds) + assertEquals("one", closedActive.activeTabBookId) + assertTrue(closedActive.reduce(AppAction.TabsEnabledChanged(false)).openTabIds.isEmpty()) + } + + @Test + fun `SharedLibraryStateProjector builds manual tag series folder and unshelved shelves`() { + val tag = Tag("favorite", "Favorite") + val manual = book("manual") + val tagged = book("tagged", tags = listOf(tag)) + val seriesOne = book("series_1", seriesName = "Saga", seriesIndex = 1.0) + val seriesTwo = book("series_2", seriesName = "Saga", seriesIndex = 2.0) + val folderBook = book("folder", sourceFolder = "content://library") + val loose = book("loose") + + val result = SharedLibraryStateProjector( + SharedFolderPathResolver { item -> + if (item.id == "folder") listOf("Nested") else emptyList() + } + ).project( + SharedLibraryProjectionInput( + state = SharedReaderScreenState( + syncedFolders = listOf(SyncedFolder("content://library", "Library", lastScanTime = 1L)), + sortOrder = SortOrder.TITLE_ASC + ), + booksFromStore = listOf(tagged, seriesTwo, loose, folderBook, manual, seriesOne), + shelfRecords = listOf(ShelfRecord("manual_shelf", "Manual")), + shelfRefs = listOf(BookShelfRef(bookId = "manual", shelfId = "manual_shelf", addedAt = 1L)), + tags = listOf(tag) + ) + ) + + assertEquals(listOf("manual"), result.shelves.first { it.id == "manual_shelf" }.books.ids()) + assertEquals(listOf("tagged"), result.shelves.first { it.id == "tag_favorite" }.books.ids()) + assertEquals(listOf("series_1", "series_2"), result.shelves.first { it.id == "series_Saga" }.books.ids()) + assertEquals(listOf("folder"), result.shelves.first { it.id == "folder_content://library" }.books.ids()) + assertEquals(listOf("folder"), result.shelves.first { it.id == "folder_content://library::Nested" }.directBooks.ids()) + assertEquals(listOf("loose", "tagged"), result.shelves.first { it.id == "unshelved" }.books.ids()) + } + + @Test + fun `SharedLibraryStateProjector builds smart shelves from shared rules`() { + val smartRules = SmartCollectionEngine.toJson( + SmartCollectionDefinition( + rules = listOf( + SmartRule(SmartField.FILE_TYPE, SmartOperator.EQUALS, "PDF"), + SmartRule(SmartField.PROGRESS, SmartOperator.GREATER_THAN, "75") + ) + ) + ) + val matching = book("matching", type = FileType.PDF, progressPercentage = 90f) + val wrongType = book("wrong_type", type = FileType.EPUB, progressPercentage = 90f) + val wrongProgress = book("wrong_progress", type = FileType.PDF, progressPercentage = 20f) + + val result = SharedLibraryStateProjector().project( + SharedLibraryProjectionInput( + state = SharedReaderScreenState(sortOrder = SortOrder.TITLE_ASC), + booksFromStore = listOf(wrongType, wrongProgress, matching), + shelfRecords = listOf(ShelfRecord("smart", "Almost Done PDFs", isSmart = true, smartRulesJson = smartRules)), + shelfRefs = emptyList(), + tags = emptyList() + ) + ) + + val smartShelf = result.shelves.first { it.id == "smart" } + assertEquals(ShelfType.SMART, smartShelf.type) + assertEquals(listOf("matching"), smartShelf.books.ids()) + assertEquals(listOf("wrong_progress", "wrong_type"), result.shelves.first { it.id == "unshelved" }.books.ids()) + } + + @Test + fun `SharedReaderScreenState withImportedFiles dedupes imports and reports duplicates`() { + val state = SharedReaderScreenState(rawLibraryBooks = listOf(book("/books/existing.epub", isRecent = false))) + + val imported = state.withImportedFiles( + listOf( + ImportedBookFile(name = "existing.epub", uriString = null, localPath = "/books/existing.epub", size = 1L), + ImportedBookFile(name = "new.pdf", uriString = "content://new", localPath = null, size = 2L, sourceFolder = "content://folder") + ), + now = 10L + ) + val duplicateOnly = imported.withImportedFiles( + listOf(ImportedBookFile(name = "new.pdf", uriString = "content://new", localPath = null, size = 2L)), + now = 20L + ) + + assertEquals(listOf("content://new", "/books/existing.epub"), imported.rawLibraryBooks.ids()) + assertEquals(FileType.PDF, imported.rawLibraryBooks.first().type) + assertEquals("content://folder", imported.rawLibraryBooks.first().sourceFolder) + assertEquals(11L, imported.rawLibraryBooks.first().timestamp) + assertFalse(imported.rawLibraryBooks.first().isRecent) + val projected = SharedLibraryStateProjector().project( + SharedLibraryProjectionInput( + state = imported, + booksFromStore = imported.rawLibraryBooks, + shelfRecords = emptyList(), + shelfRefs = emptyList(), + tags = emptyList() + ) + ) + assertTrue(projected.recentBooks.isEmpty()) + assertEquals("Imported 1 file(s).", imported.bannerMessage?.message) + assertEquals("Those files are already in the library.", duplicateOnly.bannerMessage?.message) + } + + @Test + fun `shared filters treat in app storage separately from opds streams`() { + val localBook = book("local", sourceFolder = null, path = "file:///local/book.epub") + val streamedBook = book("streamed", sourceFolder = null, path = "opds-pse://book") + val syncedBook = book("synced", sourceFolder = "content://sync", path = "content://synced") + + assertEquals( + listOf("local"), + applyLibraryFilters( + listOf(localBook, streamedBook, syncedBook), + LibraryFilters(sourceFolders = setOf(IN_APP_STORAGE_SOURCE)) + ).ids() + ) + assertEquals( + listOf("synced"), + applyLibraryFilters( + listOf(localBook, streamedBook, syncedBook), + LibraryFilters(sourceFolders = setOf("content://sync")) + ).ids() + ) + } + + @Test + fun `shared sort keeps books without authors last`() { + val unknown = book("unknown", title = null, author = null, displayName = "Zulu.epub") + val known = book("known", title = null, author = "Ada", displayName = "Beta.epub") + val title = book("title", title = "Omega", author = "Grace", displayName = "Alpha.epub") + + assertEquals(listOf("known", "title", "unknown"), sortBooks(listOf(unknown, known, title), SortOrder.AUTHOR_ASC).ids()) + } + + @Test + fun `shared screen models expose home and library derived state`() { + val folderBook = book("folder", sourceFolder = "/books") + val recent = book("recent") + val state = SharedReaderScreenState( + recentBooks = listOf(recent), + openTabs = listOf(folderBook), + rawLibraryBooks = listOf(folderBook, recent), + selectedBookIds = setOf("folder"), + selectedShelfIds = setOf("manual"), + isTabsEnabled = true, + deviceLimitState = DeviceLimitReachedState(isLimitReached = true), + searchQuery = "folder", + isSearchActive = true + ) + + val home = state.toHomeScreenModel() + val library = state.toLibraryScreenModel() + + assertEquals(listOf("recent"), home.recentBooks.ids()) + assertEquals(listOf("folder"), home.openTabs.ids()) + assertEquals(listOf("folder"), home.selectedBooks.ids()) + assertTrue(home.isContextualModeActive) + assertFalse(home.isEmpty) + assertFalse(home.isLibraryEmpty) + assertTrue(home.deviceLimitState.isLimitReached) + + assertEquals(listOf("folder"), library.selectedBooks.ids()) + assertEquals(setOf("manual"), library.selectedShelves) + assertTrue(library.containsFolderItemsInSelection) + assertTrue(library.isSearchActive) + assertEquals("folder", library.searchQuery) + } + + @Test + fun `toFileType maps known document and archive extensions case insensitively`() { + assertEquals(FileType.PDF, "REPORT.PDF".toFileType()) + assertEquals(FileType.HTML, "page.htm".toFileType()) + assertEquals(FileType.CBZ, "comic.cbz".toFileType()) + assertEquals(FileType.UNKNOWN, "archive.zip".toFileType()) + } + + private fun book( + id: String, + displayName: String = "$id.epub", + type: FileType = FileType.EPUB, + title: String? = id, + author: String? = null, + timestamp: Long = 1L, + progressPercentage: Float? = null, + isRecent: Boolean = true, + fileSize: Long = 0L, + sourceFolder: String? = null, + path: String? = "/library/$displayName", + seriesName: String? = null, + seriesIndex: Double? = null, + tags: List = emptyList() + ) = BookItem( + id = id, + path = path, + type = type, + displayName = displayName, + timestamp = timestamp, + title = title, + author = author, + progressPercentage = progressPercentage, + isRecent = isRecent, + fileSize = fileSize, + sourceFolder = sourceFolder, + seriesName = seriesName, + seriesIndex = seriesIndex, + tags = tags + ) + + private fun List.ids() = map { it.id } +} diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/SharedLibrarySnapshotJsonTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/SharedLibrarySnapshotJsonTest.kt new file mode 100644 index 0000000..b1b9cce --- /dev/null +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/SharedLibrarySnapshotJsonTest.kt @@ -0,0 +1,204 @@ +package com.aryan.reader.shared + +import androidx.compose.ui.graphics.Color +import com.aryan.reader.shared.reader.ReaderBookmark +import com.aryan.reader.shared.reader.ReaderReadingMode +import com.aryan.reader.shared.reader.ReaderSettings +import com.aryan.reader.shared.reader.SharedReaderTextAlign +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SharedLibrarySnapshotJsonTest { + + @Test + fun `snapshot json round trips library records used by desktop persistence`() { + val tag = Tag(id = "favorite", name = "Favorite", color = 7) + val snapshot = SharedLibrarySnapshot( + books = listOf( + BookItem( + id = "book", + path = "C:/Books/book.epub", + type = FileType.EPUB, + displayName = "book.epub", + timestamp = 10L, + coverImagePath = "C:/Covers/book.png", + title = "Book", + author = "Ada", + progressPercentage = 42f, + fileSize = 99L, + sourceFolder = "C:/Books", + folderTextMetadataParsed = true, + seriesName = "Series", + seriesIndex = 2.0, + tags = listOf(tag), + lastPageIndex = 4, + readerSettings = ReaderSettings( + fontSize = 22, + lineSpacing = 1.7f, + margin = 64, + darkMode = true, + readingMode = ReaderReadingMode.VERTICAL, + textAlign = SharedReaderTextAlign.JUSTIFY, + pageWidth = 840, + fontFamily = "Serif", + paragraphSpacing = 1.4f, + imageScale = 1.2f, + horizontalMargin = 40, + verticalMargin = 72, + themeId = "sepia", + textureId = "paper", + textureAlpha = 0.35f, + customFontPath = "C:/Fonts/custom.ttf", + backgroundColorArgb = -328967L, + textColorArgb = -12345678L, + systemUiMode = SystemUiMode.HIDDEN, + pageInfoMode = PageInfoMode.SYNC, + pageInfoPosition = PageInfoPosition.TOP, + seamlessChapterNavigation = false, + chapterTurnDragMultiplier = 1.6f + ), + readerBookmarks = listOf( + ReaderBookmark( + id = "book_4", + pageIndex = 4, + chapterTitle = "Chapter", + preview = "A useful paragraph", + locator = ReaderLocator( + chapterIndex = 0, + pageIndex = 4, + startOffset = 100, + endOffset = 180, + textQuote = "A useful paragraph" + ) + ) + ), + readerHighlights = listOf( + UserHighlight( + id = "highlight_1", + cfi = "desktop:0:128:144", + text = "useful paragraph", + color = HighlightColor.YELLOW, + chapterIndex = 0, + note = "Remember this", + locator = ReaderLocator( + chapterIndex = 0, + pageIndex = 4, + startOffset = 128, + endOffset = 144, + textQuote = "useful paragraph", + cfi = "desktop:0:128:144" + ) + ) + ) + ) + ), + shelfRecords = listOf(ShelfRecord(id = "shelf", name = "Shelf", isSmart = true, smartRulesJson = "{}")), + shelfRefs = listOf(BookShelfRef(bookId = "book", shelfId = "shelf", addedAt = 11L)), + tags = listOf(tag), + customFonts = listOf( + CustomFontItem( + id = "font", + displayName = "Literata", + fileName = "font.ttf", + fileExtension = "ttf", + path = "C:/Fonts/font.ttf", + timestamp = 13L + ) + ), + syncedFolders = listOf(SyncedFolder("C:/Books", "Books", lastScanTime = 12L, allowedFileTypes = setOf(FileType.EPUB, FileType.PDF))), + recentFilesLimit = 20, + isTabsEnabled = true, + openTabIds = listOf("book"), + activeTabBookId = "book", + pinnedHomeBookIds = setOf("book"), + pinnedLibraryBookIds = setOf("book"), + useStrictFileFilter = true, + appThemeMode = AppThemeMode.DARK, + appContrastOption = AppContrastOption.HIGH, + appTextDimFactorLight = 0.75f, + appTextDimFactorDark = 0.65f, + appSeedColor = Color(0xFF006C4C), + customAppThemes = listOf( + CustomAppTheme(id = "forest", name = "Forest", seedColor = Color(0xFF006C4C)) + ), + readerToolbarPreferences = ReaderToolbarPreferences( + hiddenToolIds = setOf(ReaderTool.SEARCH.id), + toolOrder = listOf(ReaderTool.BOOKMARK, ReaderTool.THEME, ReaderTool.SEARCH), + bottomToolIds = setOf(ReaderTool.BOOKMARK.id) + ).sanitized(), + readerHighlightPalette = ReaderHighlightPalette( + colors = listOf(HighlightColor.YELLOW, HighlightColor.CYAN) + ), + readerTtsReplacementPreferences = ReaderTtsReplacementPreferences( + globalRules = listOf( + ReaderTtsReplacementRule( + id = "dr", + from = "Dr.", + to = "Doctor", + wholeWord = false + ) + ), + bookRules = mapOf( + "book" to listOf( + ReaderTtsReplacementRule( + id = "st", + from = "St.", + to = "Saint", + wholeWord = false + ) + ) + ), + bookSettings = mapOf( + "book" to ReaderTtsReplacementBookSettings(disabledGlobalRuleIds = setOf("dr")) + ) + ) + ) + + val decoded = SharedLibrarySnapshotJson.decodeOrEmpty(SharedLibrarySnapshotJson.encode(snapshot)) + + assertEquals(snapshot, decoded) + } + + @Test + fun `snapshot json tolerates malformed or missing data`() { + val decoded = SharedLibrarySnapshotJson.decodeOrEmpty("""{"books":[{"id":"missingName"}]}""") + + assertTrue(SharedLibrarySnapshotJson.decodeOrEmpty("not json").books.isEmpty()) + assertTrue(decoded.books.isEmpty()) + } + + @Test + fun `legacy snapshot hides imported only books from recent home`() { + val decoded = SharedLibrarySnapshotJson.decodeOrEmpty( + """ + { + "schemaVersion": 2, + "books": [ + { + "id": "imported", + "path": "C:/Books/imported.epub", + "type": "EPUB", + "displayName": "imported.epub", + "timestamp": 10, + "isRecent": true + }, + { + "id": "opened", + "path": "C:/Books/opened.epub", + "type": "EPUB", + "displayName": "opened.epub", + "timestamp": 11, + "isRecent": true + } + ], + "openTabIds": ["opened"] + } + """.trimIndent() + ) + + assertFalse(decoded.books.first { it.id == "imported" }.isRecent) + assertTrue(decoded.books.first { it.id == "opened" }.isRecent) + } +} diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/SmartCollectionEngineTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/SmartCollectionEngineTest.kt new file mode 100644 index 0000000..d83cc96 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/SmartCollectionEngineTest.kt @@ -0,0 +1,142 @@ +package com.aryan.reader.shared + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SmartCollectionEngineTest { + + @Test + fun `definition JSON round trips and ignores unknown fields`() { + val definition = SmartCollectionDefinition( + matchAll = false, + rules = listOf( + SmartRule(SmartField.TITLE, SmartOperator.CONTAINS, "dune"), + SmartRule(SmartField.PROGRESS, SmartOperator.GREATER_THAN, "50") + ) + ) + + val encoded = SmartCollectionEngine.toJson(definition) + val decoded = SmartCollectionEngine.fromJson( + encoded.replaceFirst("{", """{"unknown":"kept-for-forward-compat",""") + ) + + assertEquals(definition, decoded) + } + + @Test + fun `fromJson returns null for blank malformed and incompatible payloads`() { + assertNull(SmartCollectionEngine.fromJson(null)) + assertNull(SmartCollectionEngine.fromJson(" ")) + assertNull(SmartCollectionEngine.fromJson("{not json")) + assertNull(SmartCollectionEngine.fromJson("""{"matchAll":true,"rules":[{"field":"NOPE"}]}""")) + } + + @Test + fun `matchAll requires every rule while matchAny accepts a single matching rule`() { + val book = book( + title = "Dune Messiah", + author = "Frank Herbert", + progressPercentage = 41f, + type = FileType.EPUB + ) + + val titleAndHighProgress = SmartCollectionDefinition( + matchAll = true, + rules = listOf( + SmartRule(SmartField.TITLE, SmartOperator.CONTAINS, "dune"), + SmartRule(SmartField.PROGRESS, SmartOperator.GREATER_THAN, "80") + ) + ) + val titleOrHighProgress = titleAndHighProgress.copy(matchAll = false) + + assertFalse(SmartCollectionEngine.evaluate(book, titleAndHighProgress)) + assertTrue(SmartCollectionEngine.evaluate(book, titleOrHighProgress)) + } + + @Test + fun `string folder file type and tag rules are case insensitive`() { + val book = book( + displayName = "fallback-name.pdf", + title = null, + author = "Ursula K. Le Guin", + sourceFolder = "content://library/Sci-Fi", + type = FileType.PDF, + tags = listOf( + Tag(id = "t1", name = "Classic Science Fiction"), + Tag(id = "t2", name = "Queued") + ) + ) + + assertTrue( + SmartCollectionEngine.evaluate( + book, + SmartCollectionDefinition( + rules = listOf( + SmartRule(SmartField.TITLE, SmartOperator.EQUALS, "fallback-name.pdf"), + SmartRule(SmartField.AUTHOR, SmartOperator.CONTAINS, "le guin"), + SmartRule(SmartField.FOLDER, SmartOperator.CONTAINS, "SCI-FI"), + SmartRule(SmartField.FILE_TYPE, SmartOperator.EQUALS, "pdf"), + SmartRule(SmartField.TAG, SmartOperator.CONTAINS, "science") + ) + ) + ) + ) + } + + @Test + fun `numeric rules handle equals greater less missing progress and invalid values`() { + val startedBook = book(progressPercentage = 33.5f) + val missingProgressBook = book(progressPercentage = null) + + assertTrue(matchesProgress(startedBook, SmartOperator.EQUALS, "33.5")) + assertTrue(matchesProgress(startedBook, SmartOperator.GREATER_THAN, "33")) + assertTrue(matchesProgress(startedBook, SmartOperator.LESS_THAN, "34")) + assertFalse(matchesProgress(startedBook, SmartOperator.GREATER_THAN, "not-a-number")) + assertTrue(matchesProgress(missingProgressBook, SmartOperator.EQUALS, "0")) + } + + @Test + fun `empty definitions never match`() { + assertFalse(SmartCollectionEngine.evaluate(book(), SmartCollectionDefinition())) + } + + private fun matchesProgress( + book: BookItem, + operator: SmartOperator, + value: String + ): Boolean { + return SmartCollectionEngine.evaluate( + book, + SmartCollectionDefinition( + rules = listOf(SmartRule(SmartField.PROGRESS, operator, value)) + ) + ) + } + + private fun book( + id: String = "book-id", + displayName: String = "display.epub", + title: String? = "Display", + author: String? = null, + progressPercentage: Float? = null, + sourceFolder: String? = null, + type: FileType = FileType.EPUB, + tags: List = emptyList() + ): BookItem { + return BookItem( + id = id, + path = "/library/$displayName", + type = type, + displayName = displayName, + timestamp = 1L, + title = title, + author = author, + progressPercentage = progressPercentage, + sourceFolder = sourceFolder, + tags = tags + ) + } +} diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/opds/SharedOpdsCatalogsTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/opds/SharedOpdsCatalogsTest.kt new file mode 100644 index 0000000..22ead5f --- /dev/null +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/opds/SharedOpdsCatalogsTest.kt @@ -0,0 +1,107 @@ +package com.aryan.reader.shared.opds + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SharedOpdsCatalogsTest { + @Test + fun `catalog json seeds defaults and preserves edits`() { + var nextId = 0 + fun id() = "id-${nextId++}" + + val defaults = SharedOpdsCatalogs.decodeOrSeed(null, ::id) + assertEquals(2, defaults.size) + assertTrue(defaults.all { it.isDefault }) + + val added = SharedOpdsCatalogs.addCatalog(defaults, " Custom ", " https://example.org/opds ", " user ", " pass ", ::id) + val updated = SharedOpdsCatalogs.updateCatalog( + catalogs = added, + id = "id-2", + title = " Updated ", + url = " https://example.org/new ", + username = " ", + password = " token " + ) + val custom = updated.single { !it.isDefault } + assertEquals("Updated", custom.title) + assertEquals("https://example.org/new", custom.url) + assertNull(custom.username) + assertEquals("token", custom.password) + + val encoded = SharedOpdsCatalogs.encode(updated) + assertEquals(updated, SharedOpdsCatalogs.decode(encoded)) + assertEquals(updated, SharedOpdsCatalogs.removeCatalog(updated, defaults.first().id)) + assertTrue(SharedOpdsCatalogs.removeCatalog(updated, custom.id).all { it.isDefault }) + } + + @Test + fun `catalog json decodes null credentials as absent credentials`() { + val catalogs = SharedOpdsCatalogs.decode( + """ + [ + { + "id": "catalog", + "title": "Catalog", + "url": "https://example.org/opds", + "username": null, + "password": null + } + ] + """.trimIndent() + ) + + val catalog = catalogs.single() + assertNull(catalog.username) + assertNull(catalog.password) + } + + @Test + fun `search templates expand opds uri template variants`() { + assertEquals( + "https://example.org/search?query=ada%20lovelace", + SharedOpdsSearch.expandSearchTemplate("https://example.org/search{?query}", "ada lovelace") + ) + assertEquals( + "https://example.org/search?q=ada%20lovelace", + SharedOpdsSearch.expandSearchTemplate("https://example.org/search?q={searchTerms}", "ada lovelace") + ) + assertEquals( + "https://example.org/search?existing=1&query=ada%20lovelace", + SharedOpdsSearch.expandSearchTemplate("https://example.org/search?existing=1", "ada lovelace") + ) + } + + @Test + fun `stream uri round trips encoded template and catalog`() { + val reference = OpdsStreamReference( + id = "book 1", + count = 12, + urlTemplate = "https://example.org/page/{pageNumber}?w={maxWidth}", + catalogId = "catalog 1" + ) + + assertEquals(reference, SharedOpdsStreamUri.parse(SharedOpdsStreamUri.build(reference))) + } + + @Test + fun `download namer prefers content disposition and falls back to acquisition format`() { + assertEquals( + ".azw3", + SharedOpdsDownloadNamer.resolveExtension( + acquisition = OpdsAcquisition("https://example.org/download", "application/octet-stream"), + contentDisposition = "attachment; filename*=UTF-8''Book.azw3", + urlPathSegment = null + ) + ) + assertEquals( + ".pdf", + SharedOpdsDownloadNamer.resolveExtension( + acquisition = OpdsAcquisition("https://example.org/download", "application/pdf"), + contentDisposition = null, + urlPathSegment = null + ) + ) + } +} diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/pdf/PdfReaderSessionTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/pdf/PdfReaderSessionTest.kt new file mode 100644 index 0000000..9b0d4ad --- /dev/null +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/pdf/PdfReaderSessionTest.kt @@ -0,0 +1,308 @@ +package com.aryan.reader.shared.pdf + +import com.aryan.reader.shared.PdfDisplayMode +import com.aryan.reader.shared.SearchHighlightMode +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class PdfReaderSessionTest { + + @Test + fun `initial state clamps page and reports progress`() { + val state = SharedPdfReaderState.initial(pageCount = 5, initialPageIndex = 99) + + assertEquals(4, state.pageIndex) + assertEquals(5, state.pageCount) + assertEquals(100f, state.progressPercent) + assertTrue(state.canGoPrevious) + } + + @Test + fun `page navigation clamps to document bounds`() { + val state = SharedPdfReaderState.initial(pageCount = 3, initialPageIndex = 1) + .reduce(SharedPdfReaderAction.NextPage) + .reduce(SharedPdfReaderAction.NextPage) + .reduce(SharedPdfReaderAction.PreviousPage) + .reduce(SharedPdfReaderAction.GoToPage(-20)) + + assertEquals(0, state.pageIndex) + } + + @Test + fun `first last and display mode actions are shared`() { + val vertical = SharedPdfReaderState.initial(pageCount = 4, initialPageIndex = 1) + .reduce(SharedPdfReaderAction.LastPage) + .reduce(SharedPdfReaderAction.FirstPage) + .reduce(SharedPdfReaderAction.DisplayModeToggled) + val state = vertical.reduce(SharedPdfReaderAction.DisplayModeChanged(PdfDisplayMode.PAGINATION)) + + assertEquals(0, state.pageIndex) + assertEquals(PdfDisplayMode.VERTICAL_SCROLL, vertical.displayMode) + assertEquals(PdfDisplayMode.PAGINATION, state.displayMode) + } + + @Test + fun `zoom changes use provided zoom spec`() { + val zoomSpec = PdfZoomSpec(min = 0.5f, max = 4f, default = 1f) + val state = SharedPdfReaderState.initial(pageCount = 1, zoomSpec = zoomSpec) + .reduce(SharedPdfReaderAction.ZoomChanged(10f), zoomSpec) + .reduce(SharedPdfReaderAction.ZoomBy(-10f), zoomSpec) + + assertEquals(0.5f, state.zoom) + } + + @Test + fun `initial zoom is clamped to provided zoom spec`() { + val zoomSpec = PdfZoomSpec(min = 0.5f, max = 4f, default = 10f) + + val state = SharedPdfReaderState.initial(pageCount = 1, zoomSpec = zoomSpec) + + assertEquals(4f, state.zoom) + } + + @Test + fun `search query resets active result and result navigation wraps`() { + val results = listOf( + SharedPdfSearchResult(pageIndex = 1, preview = "first", matchIndex = 5), + SharedPdfSearchResult(pageIndex = 3, preview = "second", matchIndex = 7) + ) + + val state = SharedPdfReaderState.initial(pageCount = 5) + .reduce(SharedPdfReaderAction.GoToSearchResult(0, results)) + .reduce(SharedPdfReaderAction.SearchChanged("needle")) + .reduce(SharedPdfReaderAction.GoToSearchResult(-1, results)) + + assertEquals("needle", state.searchQuery) + assertEquals(1, state.activeSearchResultIndex) + assertEquals(3, state.pageIndex) + } + + @Test + fun `search highlight mode toggles between all and focused`() { + val focused = SharedPdfReaderState.initial(pageCount = 1) + .reduce(SharedPdfReaderAction.SearchHighlightModeToggled) + val all = focused.reduce(SharedPdfReaderAction.SearchHighlightModeToggled) + val explicit = all.reduce(SharedPdfReaderAction.SearchHighlightModeChanged(SearchHighlightMode.FOCUSED)) + + assertEquals(SearchHighlightMode.FOCUSED, focused.searchHighlightMode) + assertEquals(SearchHighlightMode.ALL, all.searchHighlightMode) + assertEquals(SearchHighlightMode.FOCUSED, explicit.searchHighlightMode) + } + + @Test + fun `tool selection applies shared defaults`() { + val state = SharedPdfReaderState.initial(pageCount = 1) + .reduce(SharedPdfReaderAction.ToolSelected(PdfInkTool.HIGHLIGHTER)) + + val config = SharedPdfAnnotationDefaults.configFor(PdfInkTool.HIGHLIGHTER) + assertEquals(PdfInkTool.HIGHLIGHTER, state.selectedTool) + assertEquals(config.colorArgb, state.selectedColorArgb) + assertEquals(config.strokeWidth, state.strokeWidth) + } + + @Test + fun `annotation actions mutate immutable annotation list`() { + val first = annotation("first", pageIndex = 0) + val second = annotation("second", pageIndex = 0) + val third = annotation("third", pageIndex = 1) + + val state = SharedPdfReaderState.initial(pageCount = 2) + .reduce(SharedPdfReaderAction.AnnotationsLoaded(listOf(first))) + .reduce(SharedPdfReaderAction.AnnotationAdded(second)) + .reduce(SharedPdfReaderAction.AnnotationAdded(third)) + .reduce(SharedPdfReaderAction.UndoLastAnnotationOnPage(0)) + .reduce(SharedPdfReaderAction.ClearPageAnnotations(1)) + + assertEquals(listOf(first), state.annotations) + } + + @Test + fun `bookmark actions toggle and normalize pages`() { + val state = SharedPdfReaderState.initial(pageCount = 4) + .reduce( + SharedPdfReaderAction.BookmarksLoaded( + listOf( + SharedPdfBookmark(pageIndex = 2, label = "Two"), + SharedPdfBookmark(pageIndex = 99, label = "Invalid"), + SharedPdfBookmark(pageIndex = 2, label = "Duplicate") + ) + ) + ) + .reduce(SharedPdfReaderAction.BookmarkToggled(pageIndex = 1, createdAt = 10L)) + .reduce(SharedPdfReaderAction.BookmarkToggled(pageIndex = 2)) + + assertEquals(listOf(1), state.bookmarks.map { it.pageIndex }) + assertEquals("Page 2", state.bookmarks.single().label) + } + + @Test + fun `bookmark serializer round trips store and legacy arrays`() { + val bookmarks = listOf( + SharedPdfBookmark(pageIndex = 0, label = "Start", createdAt = 11L), + SharedPdfBookmark(pageIndex = 3, label = "Appendix", createdAt = 22L) + ) + + assertEquals(bookmarks, SharedPdfBookmarkSerializer.decode(SharedPdfBookmarkSerializer.encode(bookmarks))) + assertEquals( + listOf(SharedPdfBookmark(pageIndex = 1, label = "Legacy", createdAt = 33L)), + SharedPdfBookmarkSerializer.decode("""[{"pageIndex":1,"label":"Legacy","createdAt":33}]""") + ) + } + + @Test + fun `jump history records explicit jumps and exposes back and forward pages`() { + val recorded = SharedPdfJumpHistory() + .record(currentPageIndex = 0, targetPageIndex = 4, pageCount = 10) + .record(currentPageIndex = 4, targetPageIndex = 8, pageCount = 10) + + val steppedBack = recorded.stepBack() + val branched = steppedBack.record(currentPageIndex = 4, targetPageIndex = 2, pageCount = 10) + + assertEquals(listOf(0, 4, 8), recorded.pages) + assertEquals(4, recorded.backPage) + assertEquals(null, recorded.forwardPage) + assertEquals(0, steppedBack.backPage) + assertEquals(8, steppedBack.forwardPage) + assertEquals(listOf(0, 4, 2), branched.pages) + assertEquals(4, branched.backPage) + } + + @Test + fun `jump history ignores invalid jumps prunes document bounds and caps entries`() { + val unchanged = SharedPdfJumpHistory() + .record(currentPageIndex = 0, targetPageIndex = 0, pageCount = 10) + .record(currentPageIndex = 0, targetPageIndex = 99, pageCount = 10) + + val pruned = SharedPdfJumpHistory(pages = listOf(0, 3, 99, 4), cursor = 3) + .pruned(pageCount = 5) + + val capped = (0 until 40).fold(SharedPdfJumpHistory(maxEntries = 5)) { history, page -> + history.record( + currentPageIndex = page, + targetPageIndex = page + 1, + pageCount = 50 + ) + } + + assertTrue(unchanged.pages.isEmpty()) + assertEquals(listOf(0, 3, 4), pruned.pages) + assertEquals(2, pruned.cursor) + assertEquals(listOf(36, 37, 38, 39, 40), capped.pages) + assertEquals(4, capped.cursor) + } + + @Test + fun `annotation selection update and delete are shared`() { + val first = annotation("first", pageIndex = 0) + val second = annotation("second", pageIndex = 1) + val updated = second.copy(text = "changed", colorArgb = 0xFF222222.toInt()) + + val state = SharedPdfReaderState.initial(pageCount = 2) + .reduce(SharedPdfReaderAction.AnnotationsLoaded(listOf(first, second))) + .reduce(SharedPdfReaderAction.AnnotationSelected("second")) + .reduce(SharedPdfReaderAction.AnnotationUpdated(updated)) + .reduce(SharedPdfReaderAction.AnnotationDeleted("second")) + + assertEquals(listOf(first), state.annotations) + assertEquals(null, state.selectedAnnotationId) + } + + @Test + fun `search engine finds all case-insensitive matches with previews`() { + val results = SharedPdfSearchEngine.search( + pageTexts = listOf("Alpha beta alpha", "nothing", "ALPHA at the end"), + query = "alpha" + ) + + assertEquals(listOf(0, 0, 2), results.map { it.pageIndex }) + assertEquals(listOf(0, 11, 0), results.map { it.matchIndex }) + assertEquals(listOf(5, 5, 5), results.map { it.matchLength }) + assertTrue(results.first().preview.contains("Alpha")) + } + + @Test + fun `search index reuses indexed page text and preserves raw match ranges`() { + val index = SharedPdfSearchIndex(pageCount = 3) + index.putPage(0, "Alpha beta") + index.putPage(1, "hello,\nworld appears here") + index.putPage(2, "alpha again") + + val punctuationResults = index.search("hello, world") + val alphaResults = index.search("alp") + + assertEquals(3, index.indexedPageCount) + assertEquals(listOf(1), punctuationResults.map { it.pageIndex }) + assertEquals(0, punctuationResults.single().matchIndex) + assertEquals("hello,\nworld".length, punctuationResults.single().matchLength) + assertEquals(listOf(0, 2), alphaResults.map { it.pageIndex }) + } + + @Test + fun `search highlights return all page matches or only focused match`() { + val results = listOf( + SharedPdfSearchResult(pageIndex = 0, preview = "first", matchIndex = 0), + SharedPdfSearchResult(pageIndex = 0, preview = "second", matchIndex = 12), + SharedPdfSearchResult(pageIndex = 1, preview = "third", matchIndex = 3) + ) + + assertEquals( + listOf(results[0], results[1]), + SharedPdfSearchEngine.highlightsForPage( + results = results, + pageIndex = 0, + activeResultIndex = 2, + mode = SearchHighlightMode.ALL + ) + ) + assertEquals( + listOf(results[1]), + SharedPdfSearchEngine.highlightsForPage( + results = results, + pageIndex = 0, + activeResultIndex = 1, + mode = SearchHighlightMode.FOCUSED + ) + ) + } + + @Test + fun `most visible page follows largest viewport overlap`() { + val visiblePages = listOf( + PdfVisiblePageLayout(pageIndex = 2, top = -120f, bottom = 320f), + PdfVisiblePageLayout(pageIndex = 3, top = 320f, bottom = 920f), + PdfVisiblePageLayout(pageIndex = 4, top = 920f, bottom = 1300f) + ) + + val pageIndex = mostVisiblePdfPageIndex( + visiblePages = visiblePages, + viewportTop = 0f, + viewportBottom = 800f, + fallbackPageIndex = 2 + ) + + assertEquals(3, pageIndex) + } + + @Test + fun `most visible page falls back when no measured page overlaps`() { + val pageIndex = mostVisiblePdfPageIndex( + visiblePages = listOf(PdfVisiblePageLayout(pageIndex = 8, top = 900f, bottom = 1200f)), + viewportTop = 0f, + viewportBottom = 800f, + fallbackPageIndex = 5 + ) + + assertEquals(5, pageIndex) + } + + private fun annotation(id: String, pageIndex: Int): SharedPdfAnnotation { + return SharedPdfAnnotation( + id = id, + pageIndex = pageIndex, + kind = PdfAnnotationKind.INK, + points = listOf(PdfPagePoint(0.1f, 0.2f)), + colorArgb = 0xFF111111.toInt() + ) + } +} diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/pdf/PdfSelectionGeometryTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/pdf/PdfSelectionGeometryTest.kt new file mode 100644 index 0000000..ea91972 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/pdf/PdfSelectionGeometryTest.kt @@ -0,0 +1,67 @@ +package com.aryan.reader.shared.pdf + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class PdfSelectionGeometryTest { + + @Test + fun `normalizes points against the current viewport size`() { + val point = PdfSelectionGeometry.normalizedPoint( + pointX = 50f, + pointY = 200f, + viewportWidth = 200, + viewportHeight = 400 + ) + + assertEquals(PdfNormalizedPoint(0.25f, 0.5f), point) + assertNull(PdfSelectionGeometry.normalizedPoint(50f, 200f, 0, 400)) + } + + @Test + fun `line fallback picks the nearest character only on a matching line`() { + val chars = listOf( + PdfTextCharBounds(index = 1, left = 0.10f, top = 0.10f, right = 0.12f, bottom = 0.13f), + PdfTextCharBounds(index = 2, left = 0.13f, top = 0.10f, right = 0.15f, bottom = 0.13f), + PdfTextCharBounds(index = 20, left = 0.10f, top = 0.30f, right = 0.12f, bottom = 0.33f) + ) + + assertEquals( + 2, + PdfSelectionGeometry.nearestCharOnLine(chars, PdfNormalizedPoint(0.90f, 0.115f))?.index + ) + assertNull(PdfSelectionGeometry.nearestCharOnLine(chars, PdfNormalizedPoint(0.90f, 0.22f))) + } + + @Test + fun `merges text rects by visual line`() { + val merged = PdfSelectionGeometry.mergeBoundsByLine( + listOf( + PdfPageBounds(left = 0.10f, top = 0.10f, right = 0.20f, bottom = 0.13f), + PdfPageBounds(left = 0.21f, top = 0.101f, right = 0.35f, bottom = 0.131f), + PdfPageBounds(left = 0.10f, top = 0.20f, right = 0.25f, bottom = 0.23f) + ) + ) + + assertEquals( + listOf( + PdfPageBounds(left = 0.10f, top = 0.10f, right = 0.35f, bottom = 0.131f), + PdfPageBounds(left = 0.10f, top = 0.20f, right = 0.25f, bottom = 0.23f) + ), + merged + ) + } + + @Test + fun `keeps nearby paragraph lines separate`() { + val merged = PdfSelectionGeometry.mergeBoundsByLine( + listOf( + PdfPageBounds(left = 0.10f, top = 0.10f, right = 0.80f, bottom = 0.13f), + PdfPageBounds(left = 0.10f, top = 0.118f, right = 0.75f, bottom = 0.148f) + ) + ) + + assertEquals(2, merged.size) + } +} diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/pdf/SharedPdfAnnotationSerializerTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/pdf/SharedPdfAnnotationSerializerTest.kt new file mode 100644 index 0000000..1074fea --- /dev/null +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/pdf/SharedPdfAnnotationSerializerTest.kt @@ -0,0 +1,216 @@ +package com.aryan.reader.shared.pdf + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class SharedPdfAnnotationSerializerTest { + + @Test + fun `serializer round trips text highlight annotations`() { + val annotation = SharedPdfAnnotation( + id = "highlight", + pageIndex = 3, + kind = PdfAnnotationKind.HIGHLIGHT, + tool = PdfInkTool.HIGHLIGHTER, + bounds = PdfPageBounds(left = 0.1f, top = 0.2f, right = 0.5f, bottom = 0.24f), + text = "Selected text", + colorArgb = 0x8CFFEB3B.toInt(), + createdAt = 42L + ) + + val decoded = SharedPdfAnnotationSerializer.decode( + SharedPdfAnnotationSerializer.encode(listOf(annotation)) + ) + + assertEquals(listOf(annotation), decoded) + } + + @Test + fun `sidecar codec canonicalizes legacy android annotation payloads`() { + val legacyPayload = """ + { + "ink": [ + { + "pageIndex": 1, + "annotationType": "INK", + "inkType": "PENCIL", + "color": -16777216, + "strokeWidth": 0.008, + "points": [{"x":0.1,"y":0.2,"t":10},{"x":0.3,"y":0.4,"t":12}] + } + ], + "textBoxes": [ + { + "id": "box-1", + "pageIndex": 2, + "text": "Typed note", + "color": -15654349, + "backgroundColor": 1712398870, + "fontSize": 0.032, + "isBold": true, + "bounds": {"left":0.1,"top":0.2,"right":0.5,"bottom":0.3} + } + ], + "highlights": [ + { + "id": "highlight-1", + "pageIndex": 3, + "color": "BLUE", + "text": "Selected text", + "rangeStart": 4, + "rangeEnd": 18, + "note": "Keep this", + "bounds": [] + } + ] + } + """.trimIndent() + + val canonical = SharedPdfAnnotationSidecarCodec.canonicalizeDataJson(legacyPayload) + val data = testJson.parseToJsonElement(canonical).jsonObject + val annotations = SharedPdfAnnotationSidecarCodec.annotationsFromData(data) + + assertNotNull(data[SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS]) + assertEquals(listOf(PdfAnnotationKind.INK, PdfAnnotationKind.TEXT, PdfAnnotationKind.HIGHLIGHT), annotations.map { it.kind }) + assertEquals(PdfInkTool.PENCIL, annotations[0].tool) + assertEquals(16f, annotations[1].fontSize, 0.001f) + assertTrue(annotations[1].isBold) + assertEquals("Keep this", annotations[2].note) + assertEquals(4, annotations[2].rangeStartIndex) + assertEquals(17, annotations[2].rangeEndIndex) + } + + @Test + fun `sidecar codec expands canonical annotations for android legacy readers`() { + val annotations = listOf( + SharedPdfAnnotation( + id = "ink-1", + pageIndex = 0, + kind = PdfAnnotationKind.INK, + tool = PdfInkTool.FOUNTAIN_PEN, + points = listOf(PdfPagePoint(0.1f, 0.2f, 1L), PdfPagePoint(0.2f, 0.3f, 2L)), + colorArgb = 0xFF0000FF.toInt(), + strokeWidth = 0.009f + ), + SharedPdfAnnotation( + id = "text-1", + pageIndex = 1, + kind = PdfAnnotationKind.TEXT, + tool = PdfInkTool.TEXT, + bounds = PdfPageBounds(0.2f, 0.3f, 0.6f, 0.5f), + text = "Desktop text", + colorArgb = 0xFF112233.toInt(), + backgroundArgb = 0x66112233, + fontSize = 20f + ), + SharedPdfAnnotation( + id = "highlight-1", + pageIndex = 2, + kind = PdfAnnotationKind.HIGHLIGHT, + tool = PdfInkTool.HIGHLIGHTER, + text = "Desktop highlight", + note = "Synced note", + colorArgb = 0x8C64B5F6.toInt(), + rangeStartIndex = 7, + rangeEndIndex = 21 + ) + ) + val canonicalPayload = testJson.encodeToString( + JsonElement.serializer(), + JsonObject( + mapOf( + SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS to + SharedPdfAnnotationSidecarCodec.encodeAnnotationsElement(annotations) + ) + ) + ) + + val legacyPayload = SharedPdfAnnotationSidecarCodec.legacyAndroidDataJsonFromCanonical(canonicalPayload) + val legacy = testJson.parseToJsonElement(legacyPayload).jsonObject + + assertEquals(1, legacy.getValue("ink").jsonArray.size) + assertEquals("FOUNTAIN_PEN", legacy.getValue("ink").jsonArray[0].jsonObject.getValue("inkType").jsonPrimitive.content) + assertEquals(1, legacy.getValue("textBoxes").jsonArray.size) + assertEquals( + 0.04, + legacy.getValue("textBoxes").jsonArray[0].jsonObject.getValue("fontSize").jsonPrimitive.content.toDouble(), + 0.0001 + ) + assertEquals(1, legacy.getValue("highlights").jsonArray.size) + assertEquals("Synced note", legacy.getValue("highlights").jsonArray[0].jsonObject.getValue("note").jsonPrimitive.content) + assertEquals(22, legacy.getValue("highlights").jsonArray[0].jsonObject.getValue("rangeEnd").jsonPrimitive.content.toInt()) + } + + @Test + fun `embedded annotation threads link replies and nearby orphan comments`() { + val root = embeddedAnnotation( + id = "root", + index = 0, + contents = "Root comment", + name = "root-name", + bounds = PdfPageBounds(0.1f, 0.1f, 0.2f, 0.2f) + ) + val reply = embeddedAnnotation( + id = "reply", + index = 1, + contents = "Reply comment", + name = "reply-name", + inReplyTo = "root-name", + bounds = PdfPageBounds(0.11f, 0.11f, 0.21f, 0.21f) + ) + val nearbyOrphan = embeddedAnnotation( + id = "nearby", + index = 2, + contents = "Nearby comment", + name = "nearby-name", + bounds = PdfPageBounds(0.12f, 0.12f, 0.22f, 0.22f) + ) + val empty = embeddedAnnotation( + id = "empty", + index = 3, + contents = "", + name = "empty-name", + bounds = PdfPageBounds(0.8f, 0.8f, 0.9f, 0.9f) + ) + + val grouped = SharedPdfEmbeddedAnnotationThreads.group(listOf(root, reply, nearbyOrphan, empty)) + + assertEquals(listOf("root"), grouped.map { it.id }) + assertEquals(listOf("reply", "nearby"), grouped.single().replies.map { it.id }) + } + + private fun embeddedAnnotation( + id: String, + index: Int, + contents: String, + name: String, + bounds: PdfPageBounds, + inReplyTo: String = "" + ): SharedPdfEmbeddedAnnotation { + return SharedPdfEmbeddedAnnotation( + id = id, + pageIndex = 0, + index = index, + subtype = PdfiumAnnotationSubtype.TEXT, + bounds = bounds, + contents = contents, + author = "Reader", + name = name, + inReplyTo = inReplyTo + ) + } + + private val testJson = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } +} diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/pdf/SharedPdfInkRenderingTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/pdf/SharedPdfInkRenderingTest.kt new file mode 100644 index 0000000..c5dd416 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/pdf/SharedPdfInkRenderingTest.kt @@ -0,0 +1,114 @@ +package com.aryan.reader.shared.pdf + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SharedPdfInkRenderingTest { + + @Test + fun `normalized Android stroke widths scale from page width`() { + assertEquals( + expected = 8f, + actual = SharedPdfInkRenderer.effectiveStrokeWidthPx(0.008f, pageWidthPx = 1_000f), + absoluteTolerance = 0.0001f + ) + assertEquals( + expected = 35f, + actual = SharedPdfInkRenderer.effectiveStrokeWidthPx(0.035f, pageWidthPx = 1_000f), + absoluteTolerance = 0.0001f + ) + } + + @Test + fun `legacy desktop pixel stroke widths remain usable`() { + assertEquals( + expected = 12f, + actual = SharedPdfInkRenderer.effectiveStrokeWidthPx(12f, pageWidthPx = 1_000f), + absoluteTolerance = 0.0001f + ) + assertEquals( + expected = 0.012f, + actual = SharedPdfInkRenderer.effectiveStrokeWidthNorm(12f, pageWidthPx = 1_000f), + absoluteTolerance = 0.0001f + ) + } + + @Test + fun `snap helper follows Android horizontal and vertical threshold behavior`() { + val start = PdfPagePoint(0.2f, 0.2f) + val horizontal = SharedPdfInkRenderer.calculateSnappedPoint( + currentPoint = PdfPagePoint(0.8f, 0.215f), + startPoint = start, + pageAspectRatio = 1f + ) + val vertical = SharedPdfInkRenderer.calculateSnappedPoint( + currentPoint = PdfPagePoint(0.215f, 0.8f), + startPoint = start, + pageAspectRatio = 1f + ) + + assertEquals(start.y, horizontal.y) + assertEquals(start.x, vertical.x) + } + + @Test + fun `eraser hit test checks full ink segments instead of only sampled points`() { + val annotation = SharedPdfAnnotation( + id = "ink", + pageIndex = 0, + kind = PdfAnnotationKind.INK, + tool = PdfInkTool.PEN, + points = listOf(PdfPagePoint(0.1f, 0.2f), PdfPagePoint(0.9f, 0.2f)), + colorArgb = 0xFFFF0000.toInt(), + strokeWidth = 0.008f + ) + + assertTrue( + SharedPdfInkRenderer.isAnnotationHit( + annotation = annotation, + hitPoint = PdfPagePoint(0.5f, 0.205f), + pageWidthPx = 1_000f, + pageAspectRatio = 1f, + eraserStrokeWidth = 0.01f + ) + ) + assertFalse( + SharedPdfInkRenderer.isAnnotationHit( + annotation = annotation, + hitPoint = PdfPagePoint(0.5f, 0.4f), + pageWidthPx = 1_000f, + pageAspectRatio = 1f, + eraserStrokeWidth = 0.01f + ) + ) + } + + @Test + fun `serializer preserves richer shared text annotation style`() { + val annotation = SharedPdfAnnotation( + id = "text", + pageIndex = 2, + kind = PdfAnnotationKind.TEXT, + tool = PdfInkTool.TEXT, + bounds = PdfPageBounds(0.1f, 0.2f, 0.5f, 0.3f), + text = "Styled note", + colorArgb = 0xFF101010.toInt(), + backgroundArgb = 0x55FFEB3B, + fontSize = 20f, + isBold = true, + isItalic = true, + isUnderline = true, + isStrikeThrough = true, + fontName = "Merriweather", + fontPath = "asset:fonts/merriweather.ttf" + ) + + val decoded = SharedPdfAnnotationSerializer.decode( + SharedPdfAnnotationSerializer.encode(listOf(annotation)) + ) + + assertEquals(listOf(annotation), decoded) + } +} diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/pdf/SharedPdfRichTextTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/pdf/SharedPdfRichTextTest.kt new file mode 100644 index 0000000..342c882 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/pdf/SharedPdfRichTextTest.kt @@ -0,0 +1,246 @@ +package com.aryan.reader.shared.pdf + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.sp +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SharedPdfRichTextTest { + + @Test + fun `mapper clips global rich spans into requested local range`() { + val document = SharedPdfRichDocument( + text = "0123456789", + spans = listOf( + SharedPdfRichSpan( + start = 2, + end = 6, + color = Color.Red.toArgb(), + backgroundColor = Color.Yellow.toArgb(), + fontSizeNorm = 0.02f, + isBold = true, + isItalic = true, + isUnderline = true, + isStrikethrough = true, + fontPath = "asset:fonts/lora.ttf" + ) + ) + ) + + val annotated = SharedPdfRichTextMapper.toAnnotatedString( + document = document, + pageHeightPx = 1_000f, + rangeStart = 4, + rangeEnd = 8 + ) + + assertEquals("4567", annotated.text) + val range = annotated.spanStyles.single() + assertEquals(0, range.start) + assertEquals(2, range.end) + assertEquals(Color.Red, range.item.color) + assertEquals(Color.Yellow, range.item.background) + assertEquals(20.sp, range.item.fontSize) + assertEquals(FontWeight.Bold, range.item.fontWeight) + assertEquals(FontStyle.Italic, range.item.fontStyle) + assertTrue(range.item.textDecoration!!.contains(TextDecoration.Underline)) + assertTrue(range.item.textDecoration!!.contains(TextDecoration.LineThrough)) + + val roundTrip = SharedPdfRichTextMapper.fromAnnotatedString(annotated, pageHeightPx = 1_000f) + assertEquals("4567", roundTrip.text) + assertEquals("asset:fonts/lora.ttf", roundTrip.spans.single().fontPath) + } + + @Test + fun `mapper fromAnnotatedString splits overlapping styles and preserves page breaks`() { + val text = "Hello${SHARED_PDF_PAGE_BREAK_CHAR}World" + val annotated = buildAnnotatedString { + append(text) + addStyle( + SpanStyle( + color = Color.Black, + background = Color.Transparent, + fontSize = 20.sp + ), + start = 0, + end = text.length + ) + addStyle( + SpanStyle( + color = Color.Magenta, + background = Color.Cyan, + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + fontStyle = FontStyle.Italic, + textDecoration = TextDecoration.combine( + listOf(TextDecoration.Underline, TextDecoration.LineThrough) + ) + ), + start = 0, + end = 5 + ) + } + + val document = SharedPdfRichTextMapper.fromAnnotatedString(annotated, pageHeightPx = 1_000f) + + assertEquals(text, document.text) + assertEquals(2, document.spans.size) + val first = document.spans[0] + assertEquals(0, first.start) + assertEquals(5, first.end) + assertEquals(Color.Magenta.toArgb(), first.color) + assertEquals(Color.Cyan.toArgb(), first.backgroundColor) + assertEquals(0.024f, first.fontSizeNorm, 0.0001f) + assertTrue(first.isBold) + assertTrue(first.isItalic) + assertTrue(first.isUnderline) + assertTrue(first.isStrikethrough) + val second = document.spans[1] + assertEquals(5, second.start) + assertEquals(text.length, second.end) + assertEquals(Color.Black.toArgb(), second.color) + assertFalse(second.isBold) + } + + @Test + fun `serializer uses android rich text sidecar schema`() { + val document = SharedPdfRichDocument( + text = "Saved rich text", + spans = listOf( + SharedPdfRichSpan( + start = 0, + end = 5, + color = Color.Red.toArgb(), + backgroundColor = Color.Transparent.toArgb(), + fontSizeNorm = 0.018f, + isBold = true, + isItalic = false, + isUnderline = true, + isStrikethrough = false, + fontPath = "asset:fonts/lora.ttf" + ) + ) + ) + + val encoded = SharedPdfRichTextSerializer.encode(document) + val decoded = SharedPdfRichTextSerializer.decode(encoded) + + assertTrue(encoded.contains("\"s\"")) + assertTrue(encoded.contains("\"fp\"")) + assertEquals(document, decoded) + } + + @Test + fun `serializer returns empty document for blank and corrupt payloads`() { + assertEquals(SharedPdfRichDocument(), SharedPdfRichTextSerializer.decode("")) + assertEquals(SharedPdfRichDocument(), SharedPdfRichTextSerializer.decode("{not json")) + assertEquals( + SharedPdfRichDocument("", emptyList()), + SharedPdfRichTextMapper.fromAnnotatedString(AnnotatedString(""), pageHeightPx = 1_000f) + ) + } + + @Test + fun `trailing page break creates editable blank page layout`() { + val globalText = AnnotatedString("$SHARED_PDF_PAGE_BREAK_CHAR") + val layouts = listOf( + SharedPdfRichPageLayout( + pageIndex = 0, + visibleText = globalText, + globalStartIndex = 0, + globalEndIndex = 1, + pageHeightPx = 1_000f + ) + ) + + val withBlankPage = layouts.withTrailingBlankRichTextPageIfNeeded( + globalText = globalText, + pageHeightPx = 1_000f + ) + + assertEquals(2, withBlankPage.size) + assertEquals(1, withBlankPage.last().pageIndex) + assertEquals("", withBlankPage.last().visibleText.text) + assertEquals(1, withBlankPage.last().globalStartIndex) + assertEquals(1, withBlankPage.last().globalEndIndex) + } + + @Test + fun `trailing blank page helper is idempotent`() { + val globalText = AnnotatedString("A$SHARED_PDF_PAGE_BREAK_CHAR") + val layouts = listOf( + SharedPdfRichPageLayout( + pageIndex = 0, + visibleText = AnnotatedString("A$SHARED_PDF_PAGE_BREAK_CHAR"), + globalStartIndex = 0, + globalEndIndex = 2, + pageHeightPx = 1_000f + ), + SharedPdfRichPageLayout( + pageIndex = 1, + visibleText = AnnotatedString(""), + globalStartIndex = 2, + globalEndIndex = 2, + pageHeightPx = 1_000f + ) + ) + + val withBlankPage = layouts.withTrailingBlankRichTextPageIfNeeded( + globalText = globalText, + pageHeightPx = 1_000f + ) + + assertEquals(layouts, withBlankPage) + } + + @Test + fun `consecutive explicit page breaks keep editable blank pages`() { + val globalText = AnnotatedString("A$SHARED_PDF_PAGE_BREAK_CHAR$SHARED_PDF_PAGE_BREAK_CHAR") + val layouts = listOf( + SharedPdfRichPageLayout( + pageIndex = 0, + visibleText = AnnotatedString("A$SHARED_PDF_PAGE_BREAK_CHAR"), + globalStartIndex = 0, + globalEndIndex = 2, + pageHeightPx = 1_000f + ), + SharedPdfRichPageLayout( + pageIndex = 1, + visibleText = AnnotatedString("$SHARED_PDF_PAGE_BREAK_CHAR"), + globalStartIndex = 2, + globalEndIndex = 3, + pageHeightPx = 1_000f + ) + ) + + val withBlankPage = layouts.withTrailingBlankRichTextPageIfNeeded( + globalText = globalText, + pageHeightPx = 1_000f + ) + + assertEquals(3, withBlankPage.size) + assertEquals("A$SHARED_PDF_PAGE_BREAK_CHAR", withBlankPage[0].visibleText.text) + assertEquals("$SHARED_PDF_PAGE_BREAK_CHAR", withBlankPage[1].visibleText.text) + assertEquals("", withBlankPage[2].visibleText.text) + assertEquals(3, withBlankPage[2].globalStartIndex) + assertEquals(3, withBlankPage[2].globalEndIndex) + } + + @Test + fun `editable rich text hides trailing structural page break`() { + val text = AnnotatedString("Body$SHARED_PDF_PAGE_BREAK_CHAR") + + val editable = text.withoutTrailingSharedPdfPageBreak() + + assertEquals("Body", editable.text) + } +} diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/pdf/SharedPdfTextAnnotationsTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/pdf/SharedPdfTextAnnotationsTest.kt new file mode 100644 index 0000000..361cb55 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/pdf/SharedPdfTextAnnotationsTest.kt @@ -0,0 +1,222 @@ +package com.aryan.reader.shared.pdf + +import androidx.compose.ui.unit.IntSize +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SharedPdfTextAnnotationsTest { + + @Test + fun `createAnnotation applies Android-style text config`() { + val style = SharedPdfTextStyleConfig( + colorArgb = 0xFF123456.toInt(), + backgroundColorArgb = 0x8CFFEB3B.toInt(), + fontSize = 20f, + isBold = true, + isItalic = true, + isUnderline = true, + isStrikeThrough = true, + fontPath = "asset:fonts/lora.ttf", + fontName = "Lora" + ) + + val annotation = SharedPdfTextAnnotationDefaults.createAnnotation( + id = "text-1", + pageIndex = 3, + anchor = PdfPagePoint(0.8f, 0.92f, 42L), + canvasSize = IntSize(1_000, 1_400), + text = " Styled note ", + style = style, + createdAt = 99L + ) + + assertEquals(PdfAnnotationKind.TEXT, annotation.kind) + assertEquals(PdfInkTool.TEXT, annotation.tool) + assertEquals("Styled note", annotation.text) + assertEquals(style, annotation.sharedPdfTextStyle()) + assertEquals(99L, annotation.createdAt) + assertTrue(annotation.bounds!!.left >= 0f) + assertTrue(annotation.bounds.right <= 1f) + assertTrue(annotation.bounds.top >= 0f) + assertTrue(annotation.bounds.bottom <= 1f) + } + + @Test + fun `withSharedPdfTextStyle replaces all style fields only`() { + val original = SharedPdfAnnotation( + id = "text-2", + pageIndex = 1, + kind = PdfAnnotationKind.TEXT, + tool = PdfInkTool.TEXT, + bounds = PdfPageBounds(0.1f, 0.2f, 0.5f, 0.3f), + text = "Keep me", + colorArgb = 0xFF000000.toInt(), + backgroundArgb = 0x00000000, + fontSize = 16f, + createdAt = 5L + ) + val style = SharedPdfTextStyleConfig( + colorArgb = 0xFFFF0000.toInt(), + backgroundColorArgb = 0x8C64B5F6.toInt(), + fontSize = 24f, + isBold = true, + fontName = "Roboto Mono", + fontPath = "asset:fonts/roboto_mono.ttf" + ) + + val updated = original.withSharedPdfTextStyle(style) + + assertEquals("text-2", updated.id) + assertEquals("Keep me", updated.text) + assertEquals(original.bounds, updated.bounds) + assertEquals(5L, updated.createdAt) + assertEquals(style, updated.sharedPdfTextStyle()) + } + + @Test + fun `text bounds grow for wrapped content and stay on page`() { + val style = SharedPdfTextStyleConfig(fontSize = 18f) + val shortBounds = SharedPdfTextAnnotationDefaults.boundsForPlacedText( + anchor = PdfPagePoint(0.1f, 0.1f), + canvasSize = IntSize(800, 1_200), + text = "Short", + style = style + ) + val longBounds = SharedPdfTextAnnotationDefaults.boundsForPlacedText( + anchor = PdfPagePoint(0.92f, 0.96f), + canvasSize = IntSize(800, 1_200), + text = "This is a much longer text annotation that should wrap across multiple lines.", + style = style + ) + + assertTrue(longBounds.bottom - longBounds.top > shortBounds.bottom - shortBounds.top) + assertTrue(longBounds.right <= 1f) + assertTrue(longBounds.bottom <= 1f) + } + + @Test + fun `draft starts empty at click location and commits as text annotation`() { + val style = SharedPdfTextStyleConfig( + colorArgb = 0xFF4A148C.toInt(), + backgroundColorArgb = 0x8CFFEB3B.toInt(), + fontSize = 18f, + isBold = true + ) + val draft = SharedPdfTextAnnotationDefaults.createDraft( + id = "text-draft", + pageIndex = 2, + anchor = PdfPagePoint(0.2f, 0.3f, 7L), + canvasSize = IntSize(1_000, 1_400), + style = style, + createdAt = 7L + ).withText(" Inline note ", IntSize(1_000, 1_400)) + + val annotation = draft.toAnnotation() + + assertEquals(PdfAnnotationKind.TEXT, annotation.kind) + assertEquals(PdfInkTool.TEXT, annotation.tool) + assertEquals("Inline note", annotation.text) + assertEquals(style, annotation.sharedPdfTextStyle()) + assertEquals(draft.bounds, annotation.bounds) + } + + @Test + fun `draft reflows when text or style changes`() { + val canvasSize = IntSize(800, 1_200) + val draft = SharedPdfTextAnnotationDefaults.createDraft( + id = "text-draft-2", + pageIndex = 0, + anchor = PdfPagePoint(0.82f, 0.9f), + canvasSize = canvasSize, + style = SharedPdfTextStyleConfig(fontSize = 14f), + createdAt = 11L + ) + val expanded = draft.withText( + "A longer inline text annotation that wraps across more than one row.", + canvasSize + ) + val restyled = expanded.withStyle(expanded.style.copy(fontSize = 24f), canvasSize) + + assertTrue(expanded.bounds.bottom - expanded.bounds.top > draft.bounds.bottom - draft.bounds.top) + assertTrue(restyled.bounds.bottom - restyled.bounds.top > expanded.bounds.bottom - expanded.bounds.top) + assertTrue(restyled.bounds.right <= 1f) + assertTrue(restyled.bounds.bottom <= 1f) + } + + @Test + fun `manually sized draft preserves bounds while typing and styling`() { + val canvasSize = IntSize(800, 1_200) + val resizedBounds = PdfPageBounds(0.2f, 0.3f, 0.7f, 0.48f) + val draft = SharedPdfTextAnnotationDefaults.createDraft( + id = "text-draft-3", + pageIndex = 0, + anchor = PdfPagePoint(0.2f, 0.3f), + canvasSize = canvasSize, + style = SharedPdfTextStyleConfig(fontSize = 14f), + createdAt = 12L + ).withBounds(resizedBounds) + + val typed = draft.withText("Manual size should stay fixed", canvasSize) + val styled = typed.withStyle(typed.style.copy(fontSize = 24f), canvasSize) + + assertEquals(resizedBounds, typed.bounds) + assertEquals(resizedBounds, styled.bounds) + assertTrue(styled.isManuallySized) + } + + @Test + fun `resize handle updates normalized bounds and keeps box on page`() { + val resized = PdfPageBounds(0.2f, 0.2f, 0.5f, 0.4f).resizedBy( + handle = SharedPdfTextResizeHandle.BOTTOM_RIGHT, + deltaXPx = 160f, + deltaYPx = 120f, + canvasSize = IntSize(1_000, 1_000) + ) + val clamped = resized.resizedBy( + handle = SharedPdfTextResizeHandle.TOP_LEFT, + deltaXPx = -1_000f, + deltaYPx = -1_000f, + canvasSize = IntSize(1_000, 1_000) + ) + + assertTrue(abs(resized.right - 0.66f) < 0.001f) + assertTrue(abs(resized.bottom - 0.52f) < 0.001f) + assertEquals(0f, clamped.left) + assertEquals(0f, clamped.top) + assertTrue(clamped.right <= 1f) + assertTrue(clamped.bottom <= 1f) + } + + @Test + fun `move keeps text box size and clamps to page`() { + val moved = PdfPageBounds(0.2f, 0.3f, 0.5f, 0.45f).movedBy( + deltaXPx = 100f, + deltaYPx = -120f, + canvasSize = IntSize(1_000, 1_000) + ) + val clamped = moved.movedBy( + deltaXPx = 1_000f, + deltaYPx = 1_000f, + canvasSize = IntSize(1_000, 1_000) + ) + + assertTrue(abs((moved.right - moved.left) - 0.3f) < 0.001f) + assertTrue(abs((moved.bottom - moved.top) - 0.15f) < 0.001f) + assertTrue(abs(moved.left - 0.3f) < 0.001f) + assertTrue(abs(moved.top - 0.18f) < 0.001f) + assertTrue(abs(clamped.left - 0.7f) < 0.001f) + assertTrue(abs(clamped.top - 0.85f) < 0.001f) + assertEquals(1f, clamped.right) + assertEquals(1f, clamped.bottom) + } + + @Test + fun `normalizeTextDraft trims and normalizes line endings`() { + assertEquals( + "Line one\nLine two", + SharedPdfTextAnnotationDefaults.normalizeTextDraft(" \r\nLine one\r\nLine two\n ") + ) + } +} diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/reader/ReaderEngineTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/reader/ReaderEngineTest.kt new file mode 100644 index 0000000..19b660f --- /dev/null +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/reader/ReaderEngineTest.kt @@ -0,0 +1,190 @@ +package com.aryan.reader.shared.reader + +import com.aryan.reader.paginatedreader.CssStyle +import com.aryan.reader.paginatedreader.SemanticParagraph +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class ReaderEngineTest { + + @Test + fun `createSession restores page and valid bookmarks`() { + val engine = ReaderEngine() + val book = longBook() + val restored = engine.createSession( + book = book, + initialPageIndex = 2, + bookmarks = listOf( + ReaderBookmark("keep", pageIndex = 1, chapterTitle = "One", preview = "Valid"), + ReaderBookmark("drop", pageIndex = 200, chapterTitle = "One", preview = "Invalid") + ) + ) + + assertEquals(2, restored.reader.currentPageIndex) + assertEquals(listOf("keep"), restored.bookmarks.map { it.id }) + } + + @Test + fun `createSession reuses paginated pages for the same book and settings`() { + val engine = ReaderEngine() + val book = longBook() + + val first = engine.createSession(book) + val second = engine.createSession(book) + + assertSame(first.reader.pages, second.reader.pages) + } + + @Test + fun `search returns every match on a page`() { + val engine = ReaderEngine() + val session = engine.createSession( + SharedEpubBook( + id = "book", + fileName = "book.epub", + title = "Book", + chapters = listOf( + SharedEpubChapter( + id = "one", + title = "One", + plainText = "Alpha beta alpha gamma ALPHA." + ) + ) + ) + ) + + val searched = engine.search(session, "alpha") + + assertEquals(3, searched.searchResults.size) + assertEquals(listOf(0, 11, 23), searched.searchResults.map { it.matchIndex }) + assertTrue(searched.searchResults.all { it.pageIndex == 0 }) + + val secondMatch = engine.goToSearchResult(searched, 1) + + assertEquals(1, secondMatch.activeSearchResultIndex) + } + + @Test + fun `resolveLink returns external target for web urls`() { + val engine = ReaderEngine() + val session = engine.createSession(longBook()) + + val target = engine.resolveLink(session, "https://example.com/page", sourceChapterIndex = 0) + + assertTrue(target is ReaderLinkTarget.External) + target as ReaderLinkTarget.External + assertEquals("https://example.com/page", target.url) + } + + @Test + fun `resolveLink normalizes scheme-less web links`() { + val engine = ReaderEngine() + val session = engine.createSession(longBook()) + + val target = engine.resolveLink(session, "www.example.com/page", sourceChapterIndex = 0) + + assertTrue(target is ReaderLinkTarget.External) + target as ReaderLinkTarget.External + assertEquals("https://www.example.com/page", target.url) + } + + @Test + fun `resolveLink maps relative epub href to target chapter locator`() { + val engine = ReaderEngine() + val targetText = "Intro target paragraph" + val session = engine.createSession( + SharedEpubBook( + id = "links", + fileName = "links.epub", + title = "Links", + chapters = listOf( + SharedEpubChapter( + id = "one", + title = "One", + plainText = "Source chapter", + baseHref = "Text/one.xhtml" + ), + SharedEpubChapter( + id = "two", + title = "Two", + plainText = targetText, + semanticBlocks = listOf( + SemanticParagraph( + text = targetText, + spans = emptyList(), + style = CssStyle(), + elementId = "target", + cfi = null, + startCharOffsetInSource = 6 + ) + ), + baseHref = "Text/two.xhtml" + ) + ) + ) + ) + + val target = engine.resolveLink(session, "two.xhtml?unused=1#target", sourceChapterIndex = 0) + + assertTrue(target is ReaderLinkTarget.Internal) + target as ReaderLinkTarget.Internal + assertEquals(1, target.locator.chapterIndex) + assertEquals(6, target.locator.startOffset) + } + + @Test + fun `resolveLink maps intercepted about blank fragment to source chapter locator`() { + val engine = ReaderEngine() + val text = "Source target paragraph" + val session = engine.createSession( + SharedEpubBook( + id = "links", + fileName = "links.epub", + title = "Links", + chapters = listOf( + SharedEpubChapter( + id = "one", + title = "One", + plainText = text, + semanticBlocks = listOf( + SemanticParagraph( + text = text, + spans = emptyList(), + style = CssStyle(), + elementId = "spot", + cfi = null, + startCharOffsetInSource = 7 + ) + ), + baseHref = "Text/one.xhtml" + ) + ) + ) + ) + + val target = engine.resolveLink(session, "about:blank#spot", sourceChapterIndex = 0) + + assertTrue(target is ReaderLinkTarget.Internal) + target as ReaderLinkTarget.Internal + assertEquals(0, target.locator.chapterIndex) + assertEquals(7, target.locator.startOffset) + } + + private fun longBook(): SharedEpubBook { + return SharedEpubBook( + id = "long", + fileName = "long.epub", + title = "Long", + chapters = listOf( + SharedEpubChapter( + id = "one", + title = "One", + plainText = List(280) { "This paragraph gives the paginator enough text to create several pages." } + .joinToString("\n\n") + ) + ) + ) + } +} diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/reader/ReaderHtmlDocumentBuilderTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/reader/ReaderHtmlDocumentBuilderTest.kt new file mode 100644 index 0000000..f7145e7 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/reader/ReaderHtmlDocumentBuilderTest.kt @@ -0,0 +1,377 @@ +package com.aryan.reader.shared.reader + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em +import com.aryan.reader.paginatedreader.BlockStyle +import com.aryan.reader.paginatedreader.BorderStyle +import com.aryan.reader.paginatedreader.BoxBorders +import com.aryan.reader.paginatedreader.CssStyle +import com.aryan.reader.paginatedreader.SemanticImage +import com.aryan.reader.paginatedreader.SemanticList +import com.aryan.reader.paginatedreader.SemanticListItem +import com.aryan.reader.paginatedreader.SemanticParagraph +import com.aryan.reader.paginatedreader.SemanticSpan +import com.aryan.reader.paginatedreader.SemanticTable +import com.aryan.reader.paginatedreader.SemanticTableCell +import com.aryan.reader.shared.HighlightColor +import com.aryan.reader.shared.ReaderLocator +import com.aryan.reader.shared.ReaderTexture +import com.aryan.reader.shared.UserHighlight +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ReaderHtmlDocumentBuilderTest { + + @Test + fun `page document renders only the highlighted occurrence from locator offsets`() { + val text = "alpha beta alpha beta" + val page = ReaderPage( + pageIndex = 0, + chapterIndex = 0, + chapterTitle = "One", + text = text, + startOffset = 0, + endOffset = text.length + ) + val highlight = UserHighlight( + id = "highlight-1", + cfi = "desktop:0:11:16", + text = "alpha", + color = HighlightColor.YELLOW, + chapterIndex = 0, + locator = ReaderLocator( + chapterIndex = 0, + pageIndex = 0, + startOffset = 11, + endOffset = 16, + textQuote = "alpha", + cfi = "desktop:0:11:16" + ) + ) + + val html = ReaderHtmlDocumentBuilder.pageDocument( + book = repeatedWordBook(text), + page = page, + settings = ReaderSettings(), + highlights = listOf(highlight) + ) + + assertEquals(1, Regex("alpha beta""")) + } + + @Test + fun `vertical document carries active locator for shared scroll navigation`() { + val html = ReaderHtmlDocumentBuilder.verticalDocument( + book = SharedEpubBook( + id = "book", + fileName = "book.epub", + title = "Book", + chapters = listOf( + SharedEpubChapter("one", "One", "First chapter text."), + SharedEpubChapter("two", "Two", "Second chapter text.") + ) + ), + settings = ReaderSettings(readingMode = ReaderReadingMode.VERTICAL), + navigationLocator = ReaderLocator( + chapterIndex = 1, + startOffset = 7, + endOffset = 14, + cfi = "desktop:1:7:14" + ) + ) + + assertTrue(html.contains("data-reader-active-chapter-index=\"1\"")) + assertTrue(html.contains("data-reader-active-start-offset=\"7\"")) + assertTrue(html.contains("scrollToActiveLocator")) + } + + @Test + fun `selection menu omits ai and tts actions when disabled`() { + val html = ReaderHtmlDocumentBuilder.pageDocument( + book = repeatedWordBook("alpha beta"), + page = ReaderPage( + pageIndex = 0, + chapterIndex = 0, + chapterTitle = "One", + text = "alpha beta", + startOffset = 0, + endOffset = 10 + ), + settings = ReaderSettings(), + readerAiFeaturesEnabled = false, + cloudTtsEnabled = false + ) + + assertFalse(html.contains("""data-action="define"""")) + assertFalse(html.contains("""data-action="speak"""")) + assertTrue(html.contains("""data-action="dictionary"""")) + assertTrue(html.contains("""data-action="web-search"""")) + } + + @Test + fun `page document uses supplied texture data uri`() { + val html = ReaderHtmlDocumentBuilder.pageDocument( + book = repeatedWordBook("alpha beta"), + page = ReaderPage( + pageIndex = 0, + chapterIndex = 0, + chapterTitle = "One", + text = "alpha beta", + startOffset = 0, + endOffset = 10 + ), + settings = ReaderSettings( + textureId = ReaderTexture.PAPER.id, + textureAlpha = 0.5f + ), + textureDataUri = "data:image/png;base64,readertexture" + ) + + assertTrue(html.contains("url('data:image/png;base64,readertexture')")) + assertTrue(html.contains("mix-blend-mode: multiply")) + assertTrue(html.contains("opacity: 0.5")) + } + + @Test + fun `page document keeps semantic images anchored to surrounding text page`() { + val book = SharedEpubBook( + id = "book", + fileName = "book.epub", + title = "Book", + chapters = listOf( + SharedEpubChapter( + id = "one", + title = "One", + plainText = "Before image after image.", + semanticBlocks = listOf( + SemanticParagraph("Before image", emptyList(), CssStyle(), null, null, startCharOffsetInSource = 0), + SemanticImage("data:image/png;base64,abc", "Cover", null, null, CssStyle(), null, null), + SemanticParagraph("after image", emptyList(), CssStyle(), null, null, startCharOffsetInSource = 13) + ) + ) + ) + ) + + val html = ReaderHtmlDocumentBuilder.pageDocument( + book = book, + page = ReaderPage(0, 0, "One", "Before image after image.", 0, 24), + settings = ReaderSettings() + ) + + assertTrue(html.contains("""Coverreference""")) + assertTrue(html.contains("readerLinkClicked")) + assertTrue(html.contains("bridge_missing")) + assertTrue(html.contains("readerlink://click?payload=")) + assertTrue(html.contains("fallback_navigation_error")) + assertTrue(html.contains("event.preventDefault();")) + } + + @Test + fun `page document carries semantic table and inline css without forced table grid`() { + val text = "Styled cell" + val book = SharedEpubBook( + id = "book", + fileName = "book.epub", + title = "Book", + chapters = listOf( + SharedEpubChapter( + id = "one", + title = "One", + plainText = text, + semanticBlocks = listOf( + SemanticTable( + rows = listOf( + listOf( + SemanticTableCell( + content = listOf( + SemanticParagraph( + text = text, + spans = listOf( + SemanticSpan( + start = 0, + end = 6, + style = CssStyle( + spanStyle = SpanStyle(fontWeight = FontWeight.Bold), + textTransform = "uppercase" + ), + tag = "span" + ) + ), + style = CssStyle(), + elementId = null, + cfi = null, + startCharOffsetInSource = 0 + ) + ), + isHeader = false, + colspan = 1, + style = CssStyle( + blockStyle = BlockStyle( + padding = BoxBorders(left = 4.dp), + borderBottom = BorderStyle(width = 2.dp, color = Color.Red, style = "solid") + ) + ) + ) + ) + ), + style = CssStyle(), + elementId = null, + cfi = null + ) + ) + ) + ) + ) + + val html = ReaderHtmlDocumentBuilder.pageDocument( + book = book, + page = ReaderPage(0, 0, "One", text, 0, text.length), + settings = ReaderSettings() + ) + + assertTrue(html.contains("border-bottom:2.0px solid #ff0000")) + assertTrue(html.contains("padding-left:4.0px")) + assertTrue(html.contains("font-weight:700")) + assertTrue(html.contains("text-transform:uppercase")) + assertTrue(!Regex("""td,\s*th\s*\{\s*border:""").containsMatchIn(html)) + } + + @Test + fun `page document clips semantic lists to visible items and keeps marker styles`() { + val first = "Chapter one" + val second = "Chapter two" + val book = SharedEpubBook( + id = "book", + fileName = "book.epub", + title = "Book", + chapters = listOf( + SharedEpubChapter( + id = "toc", + title = "Contents", + plainText = "$first\n$second", + semanticBlocks = listOf( + SemanticList( + items = listOf( + SemanticListItem( + text = first, + spans = emptyList(), + style = CssStyle(), + elementId = null, + cfi = null, + startCharOffsetInSource = 0, + itemMarkerImage = null + ), + SemanticListItem( + text = second, + spans = listOf( + SemanticSpan( + start = 0, + end = second.length, + style = CssStyle(), + linkHref = "chap02.xhtml", + tag = "a" + ) + ), + style = CssStyle( + blockStyle = BlockStyle( + padding = BoxBorders(left = 2.dp), + listStyleImage = "icons/toc-dot.png" + ) + ), + elementId = null, + cfi = null, + startCharOffsetInSource = first.length + 1, + itemMarkerImage = "icons/toc-dot.png" + ) + ), + isOrdered = false, + style = CssStyle( + fontSize = 0.85.em, + blockStyle = BlockStyle(listStyleType = "none") + ), + elementId = null, + cfi = null + ) + ) + ) + ) + ) + + val html = ReaderHtmlDocumentBuilder.pageDocument( + book = book, + page = ReaderPage(0, 0, "Contents", second, first.length + 1, first.length + 1 + second.length), + settings = ReaderSettings() + ) + + assertTrue(!html.contains(first)) + assertTrue(html.contains(second)) + assertTrue(html.contains("list-style-type:none")) + assertTrue(html.contains("font-size:0.85em")) + assertTrue(html.contains("list-style-image:url('icons/toc-dot.png')")) + assertTrue(html.contains("""Chapter two""")) + } + + private fun repeatedWordBook(text: String): SharedEpubBook { + return SharedEpubBook( + id = "book", + fileName = "book.epub", + title = "Book", + chapters = listOf( + SharedEpubChapter( + id = "one", + title = "One", + plainText = text + ) + ) + ) + } +} diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/ui/NonReaderLayoutModelsTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/ui/NonReaderLayoutModelsTest.kt new file mode 100644 index 0000000..0889a47 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/ui/NonReaderLayoutModelsTest.kt @@ -0,0 +1,166 @@ +package com.aryan.reader.shared.ui + +import com.aryan.reader.shared.BookItem +import com.aryan.reader.shared.FileType +import com.aryan.reader.shared.LibraryFilters +import com.aryan.reader.shared.ReadStatusFilter +import com.aryan.reader.shared.SharedReaderScreenState +import com.aryan.reader.shared.Shelf +import com.aryan.reader.shared.ShelfType +import com.aryan.reader.shared.SyncedFolder +import com.aryan.reader.shared.Tag +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class NonReaderLayoutModelsTest { + + @Test + fun `home layout separates active tab pinned and recent books`() { + val activeTab = book("tab", title = "Open Tab", progress = 12f) + val inProgress = book("continue", title = "Continue", progress = 40f) + val pinned = book("pinned", title = "Pinned") + val recent = book("recent", title = "Recent") + + val layout = SharedReaderScreenState( + rawLibraryBooks = listOf(activeTab, inProgress, pinned, recent), + recentBooks = listOf(inProgress, pinned, recent), + openTabs = listOf(activeTab), + openTabIds = listOf(activeTab.id), + activeTabBookId = activeTab.id, + isTabsEnabled = true, + pinnedHomeBookIds = setOf(pinned.id), + selectedBookIds = setOf(recent.id) + ).toNonReaderHomeLayoutModel() + + assertEquals(activeTab.id, layout.continueBook?.id) + assertEquals(listOf(activeTab.id), layout.activeTabs.map { it.id }) + assertEquals(listOf(pinned.id), layout.pinnedBooks.map { it.id }) + assertEquals(listOf(inProgress.id, recent.id), layout.recentBooks.map { it.id }) + assertEquals(listOf(recent.id), layout.selectedBooks.map { it.id }) + assertTrue(layout.isContextualModeActive) + assertFalse(layout.isEmpty) + } + + @Test + fun `home layout ignores open tabs when tabs are disabled`() { + val activeTab = book("tab", title = "Open Tab", progress = 12f) + + val layout = SharedReaderScreenState( + rawLibraryBooks = listOf(activeTab), + openTabs = listOf(activeTab), + openTabIds = listOf(activeTab.id), + activeTabBookId = activeTab.id, + isTabsEnabled = false + ).toNonReaderHomeLayoutModel() + + assertEquals(null, layout.continueBook) + assertTrue(layout.activeTabs.isEmpty()) + assertTrue(layout.isEmpty) + assertFalse(layout.isLibraryEmpty) + } + + @Test + fun `library organization counts shelves tags folders status and filters`() { + val favorite = Tag("favorite", "Favorite") + val unread = book("unread", type = FileType.EPUB, progress = 0f) + val inProgress = book("progress", type = FileType.PDF, progress = 50f, tags = listOf(favorite), sourceFolder = "/sync") + val complete = book("complete", type = FileType.CBZ, progress = 100f, path = "opds-pse://stream") + + val organization = SharedReaderScreenState( + rawLibraryBooks = listOf(unread, inProgress, complete), + allTags = listOf(favorite), + syncedFolders = listOf(SyncedFolder("/sync", "Sync", lastScanTime = 1L)), + shelves = listOf( + Shelf("manual", "Manual", ShelfType.MANUAL, listOf(unread)), + Shelf("series", "Series", ShelfType.SERIES, listOf(inProgress)), + Shelf("smart", "Smart", ShelfType.SMART, listOf(complete)), + Shelf("tag_favorite", "Favorite", ShelfType.TAG, listOf(inProgress)), + Shelf("folder_root", "Sync", ShelfType.FOLDER, listOf(inProgress)), + Shelf("folder_child", "Nested", ShelfType.FOLDER, listOf(inProgress), parentShelfId = "folder_root") + ), + libraryFilters = LibraryFilters( + fileTypes = setOf(FileType.PDF), + sourceFolders = setOf("/sync"), + readStatus = ReadStatusFilter.IN_PROGRESS, + tagIds = setOf(favorite.id) + ) + ).toNonReaderLibraryOrganizationModel() + + assertEquals(3, organization.allBooksCount) + assertEquals(2, organization.shelfCount) + assertEquals(1, organization.smartShelfCount) + assertEquals(1, organization.tagCount) + assertEquals(1, organization.folderCount) + assertEquals(1, organization.unreadCount) + assertEquals(1, organization.inProgressCount) + assertEquals(1, organization.completedCount) + assertEquals(4, organization.activeFilterCount) + assertEquals(listOf(FileType.PDF, FileType.EPUB, FileType.CBZ), organization.availableFileTypes) + assertTrue(organization.hasInAppBooks) + assertTrue(organization.hasOpdsStreams) + } + + @Test + fun `library organization falls back to book tags and synced folders`() { + val favorite = Tag("favorite", "Favorite") + val tagged = book("tagged", tags = listOf(favorite), sourceFolder = "/sync") + + val organization = SharedReaderScreenState( + rawLibraryBooks = listOf(tagged), + syncedFolders = listOf(SyncedFolder("/sync", "Sync", lastScanTime = 1L)) + ).toNonReaderLibraryOrganizationModel() + + assertEquals(1, organization.tagCount) + assertEquals(1, organization.folderCount) + } + + @Test + fun `shell model keeps primary navigation simple and exposes all tool actions`() { + val model = sharedAppShellModel( + selectedTab = SharedAppTab.CUSTOM_FONTS, + aiSettingsAvailable = true + ) + + assertEquals( + listOf(SharedAppTab.HOME, SharedAppTab.LIBRARY, SharedAppTab.CATALOGS, SharedAppTab.READER), + model.primaryTabs + ) + assertEquals(SharedAppTab.HOME, model.selectedPrimaryTab) + assertTrue(SharedAppToolAction.IMPORT_FILES in model.toolActions) + assertTrue(SharedAppToolAction.IMPORT_FOLDER in model.toolActions) + assertTrue(SharedAppToolAction.SYNC in model.toolActions) + assertTrue(SharedAppToolAction.APP_THEME in model.toolActions) + assertTrue(SharedAppToolAction.AI_SETTINGS in model.toolActions) + assertTrue(SharedAppToolAction.CUSTOM_FONTS in model.toolActions) + assertTrue(SharedAppToolAction.HELP_FEEDBACK in model.toolActions) + assertTrue(SharedAppToolAction.SUPPORT in model.toolActions) + assertTrue(SharedAppToolAction.ABOUT in model.toolActions) + assertTrue(SharedAppToolAction.TABS_TOGGLE in model.toolActions) + + val withoutAi = sharedAppShellModel(SharedAppTab.SHELVES, aiSettingsAvailable = false) + assertEquals(SharedAppTab.LIBRARY, withoutAi.selectedPrimaryTab) + assertFalse(SharedAppToolAction.AI_SETTINGS in withoutAi.toolActions) + } + + private fun book( + id: String, + title: String = id, + type: FileType = FileType.EPUB, + progress: Float? = null, + tags: List = emptyList(), + sourceFolder: String? = null, + path: String? = "/books/$id.epub" + ) = BookItem( + id = id, + path = path, + type = type, + displayName = "$id.epub", + timestamp = 1L, + title = title, + progressPercentage = progress, + tags = tags, + sourceFolder = sourceFolder + ) +} diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/ui/ReaderWorkspaceModelsTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/ui/ReaderWorkspaceModelsTest.kt new file mode 100644 index 0000000..dbd9186 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/ui/ReaderWorkspaceModelsTest.kt @@ -0,0 +1,166 @@ +package com.aryan.reader.shared.ui + +import com.aryan.reader.shared.PdfDisplayMode +import com.aryan.reader.shared.ReaderAutoScrollState +import com.aryan.reader.shared.ReaderCloudTtsState +import com.aryan.reader.shared.ReaderExtrasState +import com.aryan.reader.shared.ReaderTool +import com.aryan.reader.shared.ReaderToolbarPreferences +import com.aryan.reader.shared.pdf.SharedPdfReaderState +import com.aryan.reader.shared.reader.ReaderEngine +import com.aryan.reader.shared.reader.SampleReaderBooks +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ReaderWorkspaceModelsTest { + + @Test + fun `epub workspace maps shared toolbar preferences to reader sidebars and inspector`() { + val session = ReaderEngine().createSession(SampleReaderBooks.desktopWelcomeBook()) + val preferences = ReaderToolbarPreferences( + hiddenToolIds = setOf(ReaderTool.THEME.id, ReaderTool.FORMAT.id), + bottomToolIds = setOf(ReaderTool.SLIDER.id, ReaderTool.SEARCH.id) + ) + + val model = epubReaderWorkspaceModel( + session = session, + toolbarPreferences = preferences, + extrasState = ReaderExtrasState(), + aiAvailable = true + ) + + assertEquals(ReaderWorkspaceKind.EPUB, model.kind) + assertTrue(ReaderWorkspaceLeftSection.CONTENTS in model.leftSections) + assertTrue(ReaderWorkspaceLeftSection.SEARCH in model.leftSections) + assertTrue(ReaderWorkspaceLeftSection.BOOKMARKS in model.leftSections) + assertFalse(ReaderWorkspaceInspectorSection.APPEARANCE in model.inspectorSections) + assertTrue(ReaderWorkspaceInspectorSection.AI_TTS in model.inspectorSections) + assertTrue(ReaderWorkspaceInspectorSection.TOOLBAR in model.inspectorSections) + assertTrue(ReaderWorkspaceTopAction.SEARCH in model.topActions) + assertTrue(ReaderWorkspaceTopAction.AI in model.topActions) + assertTrue(ReaderWorkspaceBottomAction.PAGE_SLIDER in model.bottomActions) + } + + @Test + fun `chrome model is forced visible for active reader states`() { + val model = readerWorkspaceChromeModel( + preferAutoHide = true, + searchActive = true, + leftPanelOpen = false, + inspectorOpen = true, + annotationEditing = true, + richTextEditing = true, + loading = true, + errorMessage = "Failed", + autoScroll = ReaderAutoScrollState(enabled = true), + ttsBusy = true + ) + + assertTrue(model.preferAutoHide) + assertTrue(model.forceVisible) + assertEquals( + setOf("search", "inspector", "annotation", "rich-text", "loading", "error", "auto-scroll", "tts"), + model.forceVisibleReasons + ) + } + + @Test + fun `toolbar quick actions preserve visibility order and bottom placement`() { + val preferences = ReaderToolbarPreferences( + hiddenToolIds = setOf(ReaderTool.BOOKMARK.id), + toolOrder = listOf( + ReaderTool.AUTO_SCROLL, + ReaderTool.SEARCH, + ReaderTool.AI_FEATURES, + ReaderTool.THEME, + ReaderTool.BOOKMARK + ) + ReaderTool.entries, + bottomToolIds = setOf(ReaderTool.SEARCH.id, ReaderTool.AI_FEATURES.id) + ) + + val topTools = readerWorkspaceQuickActionTools( + toolbarPreferences = preferences, + bottom = false, + aiAvailable = true + ) + val bottomToolsWithoutAi = readerWorkspaceQuickActionTools( + toolbarPreferences = preferences, + bottom = true, + aiAvailable = false + ) + val bottomToolsWithAi = readerWorkspaceQuickActionTools( + toolbarPreferences = preferences, + bottom = true, + aiAvailable = true + ) + + assertEquals(listOf(ReaderTool.AUTO_SCROLL, ReaderTool.THEME), topTools.take(2)) + assertEquals(listOf(ReaderTool.SEARCH), bottomToolsWithoutAi) + assertEquals(listOf(ReaderTool.SEARCH, ReaderTool.AI_FEATURES), bottomToolsWithAi) + assertFalse(ReaderTool.BOOKMARK in topTools) + assertFalse(ReaderTool.BOOKMARK in bottomToolsWithAi) + } + + @Test + fun `pdf workspace defaults to reading first while keeping annotation tools in inspector`() { + val model = pdfReaderWorkspaceModel( + state = SharedPdfReaderState.initial(pageCount = 4), + displayMode = PdfDisplayMode.PAGINATION, + hasContents = true, + hasBookmarks = true, + hasAnnotations = true, + hasEmbeddedComments = true, + searchActive = false, + annotationEditing = false, + richTextEditing = false, + loading = false, + errorMessage = null, + extrasState = ReaderExtrasState(), + aiAvailable = true + ) + + assertEquals(ReaderWorkspaceKind.PDF, model.kind) + assertNull(model.defaultPdfInteractionMode) + assertTrue(ReaderWorkspaceLeftSection.CONTENTS in model.leftSections) + assertTrue(ReaderWorkspaceLeftSection.SEARCH in model.leftSections) + assertTrue(ReaderWorkspaceLeftSection.BOOKMARKS in model.leftSections) + assertTrue(ReaderWorkspaceLeftSection.NOTES in model.leftSections) + assertTrue(ReaderWorkspaceInspectorSection.APPEARANCE in model.inspectorSections) + assertTrue(ReaderWorkspaceInspectorSection.TOOLS in model.inspectorSections) + assertTrue(ReaderWorkspaceInspectorSection.AI_TTS in model.inspectorSections) + assertTrue(ReaderWorkspaceTopAction.AI in model.topActions) + } + + @Test + fun `pdf workspace forces chrome for search editing errors tts and vertical auto scroll`() { + val model = pdfReaderWorkspaceModel( + state = SharedPdfReaderState.initial(pageCount = 4).copy(searchQuery = "needle"), + displayMode = PdfDisplayMode.VERTICAL_SCROLL, + hasContents = false, + hasBookmarks = false, + hasAnnotations = false, + hasEmbeddedComments = false, + searchActive = false, + annotationEditing = true, + richTextEditing = false, + loading = false, + errorMessage = "Problem", + extrasState = ReaderExtrasState( + autoScroll = ReaderAutoScrollState(enabled = true), + cloudTts = ReaderCloudTtsState(isPlaying = true) + ), + aiAvailable = false + ) + + assertTrue(model.chrome.forceVisible) + assertTrue("search" in model.chrome.forceVisibleReasons) + assertTrue("annotation" in model.chrome.forceVisibleReasons) + assertTrue("error" in model.chrome.forceVisibleReasons) + assertTrue("auto-scroll" in model.chrome.forceVisibleReasons) + assertTrue("tts" in model.chrome.forceVisibleReasons) + assertFalse(ReaderWorkspaceTopAction.AI in model.topActions) + } +} diff --git a/shared/src/commonTest/kotlin/com/aryan/reader/shared/ui/SharedAppThemeColorMathTest.kt b/shared/src/commonTest/kotlin/com/aryan/reader/shared/ui/SharedAppThemeColorMathTest.kt new file mode 100644 index 0000000..bbb7629 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/aryan/reader/shared/ui/SharedAppThemeColorMathTest.kt @@ -0,0 +1,56 @@ +package com.aryan.reader.shared.ui + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SharedAppThemeColorMathTest { + + @Test + fun `rgb color converts to expected hsv components`() { + val hsv = Color(0xFFFF0000).toSharedHsvColor() + + assertClose(0f, hsv.hue) + assertClose(1f, hsv.saturation) + assertClose(1f, hsv.value) + } + + @Test + fun `hsv color converts back to compose rgb color`() { + val color = SharedHsvColor(hue = 120f, saturation = 1f, value = 1f).toComposeColor() + + assertEquals(Color(0xFF00FF00).toArgb(), color.toArgb()) + } + + @Test + fun `hex parser accepts android style six digit colors`() { + val color = "#006C4C".toSharedHexColorOrNull() + + assertEquals(Color(0xFF006C4C).toArgb(), color?.toArgb()) + assertEquals("#006C4C", color?.toSharedHexString()) + } + + @Test + fun `hex parser rejects incomplete and invalid colors`() { + assertNull("006C4".toSharedHexColorOrNull()) + assertNull("#006C4Z".toSharedHexColorOrNull()) + } + + @Test + fun `rgb hsv conversion round trips common custom theme colors`() { + val original = Color(0xFF2D6A4F) + val roundTripped = original.toSharedHsvColor().toComposeColor() + + assertTrue(abs(original.red - roundTripped.red) < 0.01f) + assertTrue(abs(original.green - roundTripped.green) < 0.01f) + assertTrue(abs(original.blue - roundTripped.blue) < 0.01f) + } + + private fun assertClose(expected: Float, actual: Float) { + assertTrue(abs(expected - actual) < 0.01f, "Expected $expected but was $actual") + } +} diff --git a/shared/src/desktopMain/kotlin/com/aryan/reader/shared/LocalFolderSync.desktop.kt b/shared/src/desktopMain/kotlin/com/aryan/reader/shared/LocalFolderSync.desktop.kt new file mode 100644 index 0000000..700e05e --- /dev/null +++ b/shared/src/desktopMain/kotlin/com/aryan/reader/shared/LocalFolderSync.desktop.kt @@ -0,0 +1,8 @@ +package com.aryan.reader.shared + +import java.security.MessageDigest + +internal actual fun localFolderSyncSha256ShortHex(value: String): String { + val bytes = MessageDigest.getInstance("SHA-256").digest(value.toByteArray()) + return bytes.joinToString("") { "%02x".format(it) }.take(12) +} diff --git a/shared/src/desktopMain/kotlin/com/aryan/reader/shared/ui/LocalBookCoverImage.desktop.kt b/shared/src/desktopMain/kotlin/com/aryan/reader/shared/ui/LocalBookCoverImage.desktop.kt new file mode 100644 index 0000000..383c1b7 --- /dev/null +++ b/shared/src/desktopMain/kotlin/com/aryan/reader/shared/ui/LocalBookCoverImage.desktop.kt @@ -0,0 +1,36 @@ +package com.aryan.reader.shared.ui + +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toComposeImageBitmap +import androidx.compose.ui.layout.ContentScale +import org.jetbrains.skia.Image as SkiaImage +import java.io.File + +@Composable +internal actual fun LocalBookCoverImage( + path: String, + contentDescription: String?, + modifier: Modifier +) { + val bitmap = remember(path) { + runCatching { + val file = File(path) + if (!file.isFile) { + null + } else { + SkiaImage.makeFromEncoded(file.readBytes()).toComposeImageBitmap() + } + }.getOrNull() + } + if (bitmap != null) { + Image( + bitmap = bitmap, + contentDescription = contentDescription, + modifier = modifier, + contentScale = ContentScale.Crop + ) + } +} diff --git a/shared/src/desktopTest/kotlin/com/aryan/reader/shared/ReaderTtsFileCacheManagerTest.kt b/shared/src/desktopTest/kotlin/com/aryan/reader/shared/ReaderTtsFileCacheManagerTest.kt new file mode 100644 index 0000000..5abf8a9 --- /dev/null +++ b/shared/src/desktopTest/kotlin/com/aryan/reader/shared/ReaderTtsFileCacheManagerTest.kt @@ -0,0 +1,49 @@ +package com.aryan.reader.shared + +import java.nio.file.Files +import kotlin.io.path.toFile +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ReaderTtsFileCacheManagerTest { + + @Test + fun `cache files are stable for book chapter text and speaker`() { + val root = Files.createTempDirectory("reader-tts-cache").toFile() + try { + val cache = ReaderTtsFileCacheManager(root) + + val first = cache.getCacheFile("Book: One", "Chapter/One", "Hello world.", "Aoede") + val second = cache.getCacheFile("Book: One", "Chapter/One", "Hello world.", "Aoede") + val otherSpeaker = cache.getCacheFile("Book: One", "Chapter/One", "Hello world.", "Kore") + + assertEquals(first.absolutePath, second.absolutePath) + assertFalse(first.absolutePath == otherSpeaker.absolutePath) + assertTrue(first.parentFile.exists()) + } finally { + root.deleteRecursively() + } + } + + @Test + fun `cache summary filters current speaker`() { + val root = Files.createTempDirectory("reader-tts-cache").toFile() + try { + val cache = ReaderTtsFileCacheManager(root) + cache.saveTotalChunks("Book", "One", 3) + cache.getCacheFile("Book", "One", "Hello.", "Aoede").writeBytes(ByteArray(144)) + cache.getCacheFile("Book", "One", "World.", "Kore").writeBytes(ByteArray(244)) + + val summary = cache.getCacheSummary("Book", "Aoede") + + assertEquals(2, summary.cachedChunkCount) + assertEquals(1, summary.currentVoiceChunkCount) + assertEquals(388, summary.totalSizeBytes) + assertEquals(144, summary.currentVoiceSizeBytes) + } finally { + root.deleteRecursively() + } + } +} diff --git a/shared/src/desktopTest/kotlin/com/aryan/reader/shared/opds/SharedOpdsParserTest.kt b/shared/src/desktopTest/kotlin/com/aryan/reader/shared/opds/SharedOpdsParserTest.kt new file mode 100644 index 0000000..94579c4 --- /dev/null +++ b/shared/src/desktopTest/kotlin/com/aryan/reader/shared/opds/SharedOpdsParserTest.kt @@ -0,0 +1,181 @@ +package com.aryan.reader.shared.opds + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SharedOpdsParserTest { + @Test + fun `parse OPDS 2 feed resolves links facets navigation publications and metadata`() { + val feed = SharedOpdsParser().parse( + bodyString = """ + { + "metadata": {"title": "Catalog"}, + "links": [ + {"rel": "next", "href": "page/2"}, + {"rel": ["search"], "href": "search{?query}"} + ], + "facets": [ + { + "metadata": {"title": "Format"}, + "links": [ + {"title": "EPUB", "href": "?format=epub", "properties": {"active": true}} + ] + } + ], + "navigation": [ + {"title": "Authors", "href": "../authors", "description": "Browse authors"} + ], + "publications": [ + { + "metadata": { + "identifier": "pub-1", + "title": "Example Book", + "description": "Long summary", + "author": [{"name": "Ada Writer", "links": [{"href": "/authors/ada"}]}], + "language": "en", + "publisher": "Example Press", + "published": "2026-01-02", + "subject": [{"name": "Fiction"}], + "belongsTo": {"series": {"name": "Series", "position": 2}} + }, + "images": [ + {"href": "images/thumb.jpg"}, + {"rel": "cover", "href": "images/cover.jpg"} + ], + "links": [ + { + "rel": "http://opds-spec.org/acquisition", + "href": "downloads/book.epub", + "type": "application/epub+zip" + }, + { + "rel": ["http://vaemendis.net/opds-pse/stream"], + "href": "stream/{pageNumber}", + "properties": {"numberOfItems": 12} + } + ] + } + ] + } + """.trimIndent(), + baseUrl = "https://example.org/opds/catalog/index.json" + ) + + assertEquals("Catalog", feed.title) + assertEquals("https://example.org/opds/catalog/page/2", feed.nextUrl) + assertEquals("https://example.org/opds/catalog/search{?query}", feed.searchUrl) + assertEquals(OpdsFacet("EPUB", "Format", "https://example.org/opds/catalog/?format=epub", true), feed.facets.single()) + + val navigation = feed.entries.first { it.isNavigation } + assertEquals("Authors", navigation.title) + assertEquals("https://example.org/opds/authors", navigation.navigationUrl) + + val publication = feed.entries.first { it.isAcquisition } + assertEquals("pub-1", publication.id) + assertEquals("Example Book", publication.title) + assertEquals("Ada Writer", publication.author) + assertEquals("https://example.org/authors/ada", publication.authors.single().url) + assertEquals("Long summary", publication.summary) + assertEquals("https://example.org/opds/catalog/images/cover.jpg", publication.coverUrl) + assertEquals("Example Press", publication.publisher) + assertEquals("2026-01-02", publication.published) + assertEquals("en", publication.language) + assertEquals("Series", publication.series) + assertEquals("2", publication.seriesIndex) + assertEquals(listOf("Fiction"), publication.categories) + assertEquals("https://example.org/opds/catalog/downloads/book.epub", publication.bestAcquisition?.url) + assertEquals("EPUB", publication.bestAcquisition?.formatName) + assertEquals(12, publication.pseCount) + assertEquals("https://example.org/opds/catalog/stream/{pageNumber}", publication.pseUrlTemplate) + assertTrue(publication.isStreamable) + } + + @Test + fun `parse OPDS 1 feed extracts metadata acquisitions and stream info`() { + val feed = SharedOpdsParser().parse( + bodyString = """ + + + XML Catalog + + + + + xml-1 + XML Book + Summary text + + XML Author + /people/xml-author + + XML Press + en + 2025-12-31 + + XML Series + 3 + + + + + + + """.trimIndent(), + baseUrl = "https://example.org/root/feed.xml" + ) + + assertEquals("XML Catalog", feed.title) + assertEquals("https://example.org/root/next.xml", feed.nextUrl) + assertEquals("https://example.org/search.xml", feed.searchUrl) + assertEquals(OpdsFacet("English", "Language", "https://example.org/root/?lang=en", true), feed.facets.single()) + + val entry = feed.entries.single() + assertEquals("xml-1", entry.id) + assertEquals("XML Book", entry.title) + assertEquals("Summary text", entry.summary) + assertEquals(OpdsAuthor("XML Author", "https://example.org/people/xml-author"), entry.authors.single()) + assertEquals("https://example.org/root/thumb.jpg", entry.coverUrl) + assertEquals("XML Press", entry.publisher) + assertEquals("2025-12-31", entry.published) + assertEquals("en", entry.language) + assertEquals("XML Series", entry.series) + assertEquals("3", entry.seriesIndex) + assertEquals(listOf("Fiction"), entry.categories) + assertEquals(OpdsAcquisition("https://example.org/root/book.pdf", "application/pdf"), entry.acquisitions.single()) + assertEquals(8, entry.pseCount) + assertEquals("https://example.org/root/stream/{pageNumber}", entry.pseUrlTemplate) + } + + @Test + fun `parse OPDS 2 groups and fallback metadata produce navigation entries`() { + val feed = SharedOpdsParser().parse( + bodyString = """ + { + "groups": [ + { + "metadata": {"title": "Group Title"}, + "links": [{"href": "group-feed"}], + "navigation": [{"title": "Nested Nav", "href": "nested"}], + "publications": [{"links": [], "metadata": {"title": "No Identifier"}}] + } + ] + } + """.trimIndent(), + baseUrl = "https://example.org/catalog/" + ) + + assertEquals("OPDS 2.0 Feed", feed.title) + assertEquals("Nested Nav", feed.entries[0].title) + assertEquals("https://example.org/catalog/nested", feed.entries[0].navigationUrl) + assertEquals("Group Title", feed.entries[2].title) + assertEquals("https://example.org/catalog/group-feed", feed.entries[2].navigationUrl) + assertEquals("No Identifier", feed.entries[1].title) + assertFalse(feed.entries[1].isAcquisition) + assertNull(feed.entries[1].bestAcquisition) + } +} diff --git a/shared/src/desktopTest/kotlin/com/aryan/reader/shared/reader/SharedJvmBookLoaderTest.kt b/shared/src/desktopTest/kotlin/com/aryan/reader/shared/reader/SharedJvmBookLoaderTest.kt new file mode 100644 index 0000000..d55695b --- /dev/null +++ b/shared/src/desktopTest/kotlin/com/aryan/reader/shared/reader/SharedJvmBookLoaderTest.kt @@ -0,0 +1,201 @@ +package com.aryan.reader.shared.reader + +import com.aryan.reader.shared.FileType +import java.io.File +import java.nio.file.Files +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SharedJvmBookLoaderTest { + @Test + fun `docx loader extracts core metadata and body text`() = withTempDir { dir -> + val file = File(dir, "sample.docx") + writeZip(file) { + text( + "docProps/core.xml", + """ + + Portable DOCX + Casey Writer + + """.trimIndent() + ) + text( + "word/document.xml", + """ + + + Hello from DOCX. + + + """.trimIndent() + ) + } + + val book = SharedJvmBookLoader.load(file, FileType.DOCX) + + assertEquals("Portable DOCX", book.title) + assertEquals("Casey Writer", book.author) + assertTrue(book.chapters.single().plainText.contains("Hello from DOCX.")) + } + + @Test + fun `odt loader extracts metadata and document text`() = withTempDir { dir -> + val file = File(dir, "sample.odt") + writeZip(file) { + text( + "meta.xml", + """ + + + Portable ODT + Open Author + + + """.trimIndent() + ) + text( + "content.xml", + """ + + + + ODT Heading + Hello from ODT. + + + + """.trimIndent() + ) + } + + val book = SharedJvmBookLoader.load(file, FileType.ODT) + + assertEquals("Portable ODT", book.title) + assertEquals("Open Author", book.author) + assertTrue(book.chapters.single().plainText.contains("Hello from ODT.")) + } + + @Test + fun `fb2 loader splits readable sections`() = withTempDir { dir -> + val file = File(dir, "sample.fb2").apply { + writeText( + """ + + + + AdaByron + Portable FB2 + + + +
    + <p>First Section</p> +

    Hello from FB2.

    +
    + +
    + """.trimIndent() + ) + } + + val book = SharedJvmBookLoader.load(file, FileType.FB2) + + assertEquals("Portable FB2", book.title) + assertEquals("Ada Byron", book.author) + assertEquals("First Section", book.chapters.single().title) + assertTrue(book.chapters.single().plainText.contains("Hello from FB2.")) + } + + @Test + fun `mobi loader reads uncompressed palmdoc text records`() = withTempDir { dir -> + val file = File(dir, "sample.mobi").apply { + writeBytes( + minimalMobi( + "

    Hello from MOBI.

    ".toByteArray(Charsets.UTF_8) + ) + ) + } + + val book = SharedJvmBookLoader.load(file, FileType.MOBI) + + assertEquals("sample", book.title) + assertTrue(book.chapters.single().plainText.contains("Hello from MOBI.")) + } + + @Test + fun `mobi loader reads bundled huff cdic sample`() { + val file = findRepoFile("app/src/main/cpp/libmobi/tests/samples/sample-unicode-huffdic.mobi") + + val book = SharedJvmBookLoader.load(file, FileType.MOBI) + + assertEquals("Libmobi", book.title) + assertTrue(book.chapters.joinToString("\n") { it.plainText }.length > 100) + } + + private fun withTempDir(block: (File) -> Unit) { + val dir = Files.createTempDirectory("reader-shared-loader").toFile() + try { + block(dir) + } finally { + dir.deleteRecursively() + } + } + + private fun findRepoFile(path: String): File { + return generateSequence(File(System.getProperty("user.dir")).absoluteFile) { it.parentFile } + .take(8) + .map { File(it, path) } + .firstOrNull { it.isFile } + ?: error("Missing test fixture: $path") + } + + private fun writeZip(file: File, block: ZipBuilder.() -> Unit) { + ZipOutputStream(file.outputStream()).use { zip -> + ZipBuilder(zip).block() + } + } + + private fun minimalMobi(textRecord: ByteArray): ByteArray { + val record0 = ByteArray(16) + record0.writeU16(0, 1) + record0.writeU32(4, textRecord.size) + record0.writeU16(8, 1) + record0.writeU16(10, 4096) + record0.writeU16(12, 0) + + val record0Offset = 78 + 16 + val record1Offset = record0Offset + record0.size + val header = ByteArray(record0Offset) + header.writeU16(76, 2) + header.writeU32(78, record0Offset) + header.writeU32(86, record1Offset) + return header + record0 + textRecord + } + + private fun ByteArray.writeU16(offset: Int, value: Int) { + this[offset] = ((value ushr 8) and 0xFF).toByte() + this[offset + 1] = (value and 0xFF).toByte() + } + + private fun ByteArray.writeU32(offset: Int, value: Int) { + this[offset] = ((value ushr 24) and 0xFF).toByte() + this[offset + 1] = ((value ushr 16) and 0xFF).toByte() + this[offset + 2] = ((value ushr 8) and 0xFF).toByte() + this[offset + 3] = (value and 0xFF).toByte() + } + + private class ZipBuilder(private val zip: ZipOutputStream) { + fun text(path: String, value: String) { + zip.putNextEntry(ZipEntry(path)) + zip.write(value.toByteArray(Charsets.UTF_8)) + zip.closeEntry() + } + } +} diff --git a/shared/src/readerJvmMain/kotlin/com/aryan/reader/paginatedreader/HtmlParser.kt b/shared/src/readerJvmMain/kotlin/com/aryan/reader/paginatedreader/HtmlParser.kt index 72e8492..294f045 100644 --- a/shared/src/readerJvmMain/kotlin/com/aryan/reader/paginatedreader/HtmlParser.kt +++ b/shared/src/readerJvmMain/kotlin/com/aryan/reader/paginatedreader/HtmlParser.kt @@ -107,7 +107,8 @@ fun htmlToSemanticBlocks( imageDimensionsCache: Map> = emptyMap(), mathSvgCache: Map = emptyMap(), resourceResolver: HtmlResourceResolver = NoOpHtmlResourceResolver, - fontFamilyLoader: HtmlFontFamilyLoader = NoOpHtmlFontFamilyLoader + fontFamilyLoader: HtmlFontFamilyLoader = NoOpHtmlFontFamilyLoader, + adaptThemeColors: Boolean = false ): List { return SemanticHtmlParser( cssRules, @@ -120,7 +121,8 @@ fun htmlToSemanticBlocks( imageDimensionsCache, mathSvgCache, resourceResolver, - fontFamilyLoader + fontFamilyLoader, + adaptThemeColors ).parse(html) } @@ -138,7 +140,8 @@ private class SemanticHtmlParser( private val imageDimensionsCache: Map>, private val mathSvgCache: Map, private val resourceResolver: HtmlResourceResolver, - private val fontFamilyLoader: HtmlFontFamilyLoader + private val fontFamilyLoader: HtmlFontFamilyLoader, + private val adaptThemeColors: Boolean ) { private val styleCache = mutableMapOf() private var combinedRules: OptimizedCssRules = cssRules @@ -157,7 +160,8 @@ private class SemanticHtmlParser( baseFontSizeSp = textStyle.fontSize.value, density = density.density, constraints = constraints, - isDarkTheme = false + isDarkTheme = false, + adaptThemeColors = adaptThemeColors ) if (inlineParseResult.fontFaces.isNotEmpty()) { @@ -242,7 +246,15 @@ private class SemanticHtmlParser( var elementStyle = baseStyle val inlineStyleAttribute = element.attr("style") if (inlineStyleAttribute.isNotBlank()) { - val inlineStyle = CssParser.parseProperties(inlineStyleAttribute, textStyle.fontSize.value, density.density, constraints, onlyImportant = false, isDarkTheme = false) + val inlineStyle = CssParser.parseProperties( + inlineStyleAttribute, + textStyle.fontSize.value, + density.density, + constraints, + onlyImportant = false, + isDarkTheme = false, + adaptThemeColors = adaptThemeColors + ) elementStyle = elementStyle.merge(inlineStyle) } diff --git a/shared/src/readerJvmMain/kotlin/com/aryan/reader/shared/ReaderTtsFileCacheManager.kt b/shared/src/readerJvmMain/kotlin/com/aryan/reader/shared/ReaderTtsFileCacheManager.kt new file mode 100644 index 0000000..372a91d --- /dev/null +++ b/shared/src/readerJvmMain/kotlin/com/aryan/reader/shared/ReaderTtsFileCacheManager.kt @@ -0,0 +1,177 @@ +package com.aryan.reader.shared + +import java.io.File +import java.io.RandomAccessFile +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.security.MessageDigest + +data class ReaderTtsChapterCacheInfo( + val chapterTitle: String, + val chunkCount: Int, + val totalChunks: Int?, + val sizeBytes: Long, + val directoryPath: String, + val matchingFilePaths: List = emptyList() +) + +class ReaderTtsFileCacheManager( + cacheRoot: File +) { + private val baseDir = cacheRoot + + fun saveTotalChunks(bookTitle: String, chapterTitle: String?, totalChunks: Int) { + val chapterDir = chapterDir(bookTitle, chapterTitle) + if (!chapterDir.exists()) chapterDir.mkdirs() + File(chapterDir, "total_chunks.txt").writeText(totalChunks.toString()) + } + + fun getCacheFile( + bookTitle: String, + chapterTitle: String?, + text: String, + speakerId: String + ): File { + val chapterDir = chapterDir(bookTitle, chapterTitle) + if (!chapterDir.exists()) chapterDir.mkdirs() + val hashParams = hash(text + speakerId + "CLOUD") + val safeSpeaker = sanitize(speakerId) + return File(chapterDir, "cached_chunk_${safeSpeaker}_$hashParams.wav") + } + + fun getBookCacheDir(bookTitle: String): File { + return File(baseDir, sanitize(bookTitle.take(50))) + } + + fun getChapterCaches(bookTitle: String, speakerFilter: String? = null): List { + val bookDir = getBookCacheDir(bookTitle) + if (!bookDir.exists()) return emptyList() + + return bookDir.listFiles() + ?.filter { it.isDirectory } + ?.mapNotNull { chapterDir -> + val files = chapterDir.listFiles() + ?.filter { file -> file.isFile && file.name.endsWith(".wav") && file.matchesSpeaker(speakerFilter) } + .orEmpty() + if (files.isEmpty()) return@mapNotNull null + + val metaFile = File(chapterDir, "total_chunks.txt") + ReaderTtsChapterCacheInfo( + chapterTitle = chapterDir.name, + chunkCount = files.size, + totalChunks = metaFile.takeIf { it.exists() }?.readText()?.toIntOrNull(), + sizeBytes = files.sumOf { it.length() }, + directoryPath = chapterDir.absolutePath, + matchingFilePaths = files.map { it.absolutePath } + ) + } + ?.sortedBy { it.chapterTitle } + .orEmpty() + } + + fun getCacheSummary(bookTitle: String, speakerId: String? = null): ReaderTtsCacheSummary { + val allChapters = getChapterCaches(bookTitle, speakerFilter = null) + val voiceChapters = speakerId + ?.takeIf { it.isNotBlank() } + ?.let { getChapterCaches(bookTitle, speakerFilter = it) } + .orEmpty() + return ReaderTtsCacheSummary( + cachedChapterCount = allChapters.size, + cachedChunkCount = allChapters.sumOf { it.chunkCount }, + currentVoiceChunkCount = voiceChapters.sumOf { it.chunkCount }, + totalSizeBytes = allChapters.sumOf { it.sizeBytes }, + currentVoiceSizeBytes = voiceChapters.sumOf { it.sizeBytes } + ) + } + + fun cachedSpeakers(bookTitle: String): List { + val bookDir = getBookCacheDir(bookTitle) + if (!bookDir.exists()) return emptyList() + return bookDir.listFiles() + ?.filter { it.isDirectory } + ?.flatMap { chapterDir -> + chapterDir.listFiles() + ?.mapNotNull { it.speakerFromCacheFileName() } + .orEmpty() + } + ?.distinct() + ?.sorted() + .orEmpty() + } + + fun deleteSpecificFiles(filePaths: List, chapterDirectoryPath: String) { + filePaths.forEach { path -> File(path).delete() } + val chapterDir = File(chapterDirectoryPath) + if (chapterDir.listFiles()?.isEmpty() == true) { + chapterDir.deleteRecursively() + } + } + + fun clearBookCache(bookTitle: String) { + getBookCacheDir(bookTitle).deleteRecursively() + } + + fun clearBookCacheForSpeaker(bookTitle: String, speakerId: String) { + getChapterCaches(bookTitle, speakerFilter = speakerId).forEach { chapter -> + deleteSpecificFiles(chapter.matchingFilePaths, chapter.directoryPath) + } + } + + private fun chapterDir(bookTitle: String, chapterTitle: String?): File { + return File(getBookCacheDir(bookTitle), sanitize((chapterTitle ?: "Unknown_Chapter").take(50))) + } + + private fun File.matchesSpeaker(speakerFilter: String?): Boolean { + if (speakerFilter.isNullOrBlank() || speakerFilter == "All") return true + return speakerFromCacheFileName() == speakerFilter + } + + private fun File.speakerFromCacheFileName(): String? { + if (!name.startsWith("cached_chunk_") || !name.endsWith(".wav")) return null + val withoutPrefix = name.removePrefix("cached_chunk_") + return withoutPrefix.substringBeforeLast('_').takeIf { it.isNotBlank() } + } + + private fun sanitize(name: String): String { + return name.replace(Regex("[^a-zA-Z0-9.-]"), "_") + } + + private fun hash(input: String): String { + val bytes = MessageDigest.getInstance("SHA-256").digest(input.toByteArray()) + return bytes.joinToString("") { "%02x".format(it) }.take(16) + } +} + +fun createReaderTtsWavHeaderUnknownLength(sampleRate: Int): ByteArray { + val numChannels = 1 + val bitsPerSample = 16 + val byteRate = sampleRate * numChannels * bitsPerSample / 8 + val blockAlign = numChannels * bitsPerSample / 8 + + val header = ByteBuffer.allocate(44) + header.order(ByteOrder.LITTLE_ENDIAN) + header.put("RIFF".toByteArray(Charsets.US_ASCII)) + header.putInt(0x7FFFFFFF) + header.put("WAVE".toByteArray(Charsets.US_ASCII)) + header.put("fmt ".toByteArray(Charsets.US_ASCII)) + header.putInt(16) + header.putShort(1.toShort()) + header.putShort(numChannels.toShort()) + header.putInt(sampleRate) + header.putInt(byteRate) + header.putShort(blockAlign.toShort()) + header.putShort(bitsPerSample.toShort()) + header.put("data".toByteArray(Charsets.US_ASCII)) + header.putInt(0x7FFFFFFF - 36) + + return header.array() +} + +fun patchReaderTtsWavHeader(file: File, pcmDataLength: Int) { + RandomAccessFile(file, "rw").use { raf -> + raf.seek(4) + raf.writeInt(Integer.reverseBytes(36 + pcmDataLength)) + raf.seek(40) + raf.writeInt(Integer.reverseBytes(pcmDataLength)) + } +} diff --git a/shared/src/readerJvmMain/kotlin/com/aryan/reader/shared/opds/SharedOpdsParser.kt b/shared/src/readerJvmMain/kotlin/com/aryan/reader/shared/opds/SharedOpdsParser.kt new file mode 100644 index 0000000..48dfb58 --- /dev/null +++ b/shared/src/readerJvmMain/kotlin/com/aryan/reader/shared/opds/SharedOpdsParser.kt @@ -0,0 +1,447 @@ +package com.aryan.reader.shared.opds + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.contentOrNull +import org.jsoup.Jsoup +import org.jsoup.nodes.Element +import org.jsoup.parser.Parser +import java.net.URL +import java.util.UUID + +class SharedOpdsParser { + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + } + + fun parse(bodyString: String, baseUrl: String): OpdsFeed { + val trimmed = bodyString.trimStart() + return if (trimmed.startsWith("{")) { + parseOpds2(trimmed, baseUrl) + } else { + parseOpds1(trimmed, baseUrl) + } + } + + fun extractOpenSearchTemplate(bodyString: String, openSearchUrl: String): String? { + val document = Jsoup.parse(bodyString, openSearchUrl, Parser.xmlParser()) + return document.allElements + .asSequence() + .filter { it.localTagName().equals("url", ignoreCase = true) } + .firstNotNullOfOrNull { urlElement -> + val type = urlElement.attrAny("type").orEmpty() + val template = urlElement.attrAny("template") + if ( + template != null && + (type.contains("atom+xml", ignoreCase = true) || type.contains("opds+xml", ignoreCase = true)) + ) { + resolveUrl(openSearchUrl, template) + } else { + null + } + } + } + + private fun parseOpds2(jsonString: String, baseUrl: String): OpdsFeed { + val root = json.parseToJsonElement(jsonString).jsonObject + val metadata = root.obj("metadata") + val title = metadata?.string("title") ?: "OPDS 2.0 Feed" + + var nextUrl: String? = null + var searchUrl: String? = null + val facets = mutableListOf() + + root.array("links").forEach { link -> + val href = link.string("href") + if (!href.isNullOrBlank()) { + val resolvedHref = resolveUrl(baseUrl, href) + val rels = link.rels() + when { + "next" in rels -> nextUrl = resolvedHref + "search" in rels -> searchUrl = resolvedHref + } + } + } + + root.array("facets").forEach { facetObj -> + val group = facetObj.obj("metadata")?.string("title") ?: "Filter" + facetObj.array("links").forEach { link -> + val href = link.string("href") + if (!href.isNullOrBlank()) { + facets.add( + OpdsFacet( + title = link.string("title") ?: "Facet", + group = group, + url = resolveUrl(baseUrl, href), + isActive = link.obj("properties")?.boolean("active") ?: false + ) + ) + } + } + } + + val entries = mutableListOf() + root.array("publications").forEach { entries.add(parseOpds2Publication(it, baseUrl)) } + root.array("navigation").forEach { entries.add(parseOpds2Navigation(it, baseUrl)) } + root.array("groups").forEach { group -> + val groupTitle = group.obj("metadata")?.string("title").orEmpty() + group.array("navigation").forEach { entries.add(parseOpds2Navigation(it, baseUrl)) } + group.array("publications").forEach { entries.add(parseOpds2Publication(it, baseUrl)) } + group.array("links").forEach { link -> + val href = link.string("href") + if (!href.isNullOrBlank()) { + entries.add( + OpdsEntry( + id = href, + title = link.string("title") ?: groupTitle, + summary = null, + authors = emptyList(), + coverUrl = null, + acquisitions = emptyList(), + navigationUrl = resolveUrl(baseUrl, href) + ) + ) + } + } + } + + return OpdsFeed(title = title, entries = entries, nextUrl = nextUrl, searchUrl = searchUrl, facets = facets) + } + + private fun parseOpds2Publication(pub: JsonObject, baseUrl: String): OpdsEntry { + val metadata = pub.obj("metadata") + val title = metadata?.string("title") ?: "Unknown Title" + val id = metadata?.string("identifier") ?: pub.string("id") ?: UUID.randomUUID().toString() + val summary = metadata?.string("description") ?: metadata?.string("summary") + val language = metadata?.string("language") + val publisher = metadata?.string("publisher") + val published = metadata?.string("published") + val authors = parseOpds2Authors(metadata?.get("author"), baseUrl) + val categories = parseOpds2Categories(metadata?.get("subject")) + val (series, seriesIndex) = parseOpds2Series(metadata?.obj("belongsTo")) + + var coverUrl: String? = null + pub.array("images").forEach { image -> + val href = image.string("href") + if (!href.isNullOrBlank()) { + val resolvedHref = resolveUrl(baseUrl, href) + if (coverUrl == null) coverUrl = resolvedHref + if ("cover" in image.rels()) { + coverUrl = resolvedHref + return@forEach + } + } + } + + val acquisitions = mutableListOf() + var pseCount: Int? = null + var pseUrlTemplate: String? = null + pub.array("links").forEach { link -> + val href = link.string("href") + if (!href.isNullOrBlank()) { + val rels = link.rels() + if (rels.any { it == PSE_STREAM_REL }) { + pseUrlTemplate = resolveUrl(baseUrl, href) + pseCount = link.obj("properties")?.int("numberOfItems")?.takeIf { it > 0 } + } + if (rels.any { it.contains("acquisition") }) { + acquisitions.add(OpdsAcquisition(resolveUrl(baseUrl, href), link.string("type").orEmpty())) + } + } + } + + return OpdsEntry( + id = id, + title = title, + summary = summary, + authors = authors, + coverUrl = coverUrl, + acquisitions = acquisitions, + navigationUrl = null, + publisher = publisher, + published = published, + language = language, + series = series, + seriesIndex = seriesIndex, + categories = categories, + pseCount = pseCount, + pseUrlTemplate = pseUrlTemplate + ) + } + + private fun parseOpds2Navigation(nav: JsonObject, baseUrl: String): OpdsEntry { + val href = nav.string("href") + return OpdsEntry( + id = href.orEmpty(), + title = nav.string("title") ?: "Unknown", + summary = nav.string("description"), + authors = emptyList(), + coverUrl = null, + acquisitions = emptyList(), + navigationUrl = href?.takeIf { it.isNotBlank() }?.let { resolveUrl(baseUrl, it) } + ) + } + + private fun parseOpds1(xmlString: String, baseUrl: String): OpdsFeed { + val document = Jsoup.parse(xmlString, baseUrl, Parser.xmlParser()) + val feed = document.allElements.firstOrNull { it.localTagName() == "feed" } + ?: return OpdsFeed("OPDS Feed", emptyList(), nextUrl = null) + var title = "" + var nextUrl: String? = null + var searchUrl: String? = null + val entries = mutableListOf() + val facets = mutableListOf() + + feed.children().forEach { child -> + when (child.localTagName()) { + "title" -> title = child.cleanText() + "entry" -> entries.add(readOpds1Entry(child, baseUrl)) + "link" -> { + val rel = child.attrAny("rel") + val href = child.attrAny("href") + val linkTitle = child.attrAny("title") + val facetGroup = child.attrAny("opds:facetGroup", "facetGroup") ?: "Filter" + val activeFacet = child.attrAny("opds:activeFacet", "activeFacet") == "true" + when { + rel == "next" -> nextUrl = href?.let { resolveUrl(baseUrl, it) } + rel == "search" -> searchUrl = href?.let { resolveUrl(baseUrl, it) } + rel == "facet" || rel == "http://opds-spec.org/facet" -> { + if (href != null && linkTitle != null) { + facets.add(OpdsFacet(linkTitle, facetGroup, resolveUrl(baseUrl, href), activeFacet)) + } + } + } + } + } + } + + return OpdsFeed(title, entries, nextUrl, searchUrl, facets) + } + + private fun readOpds1Entry(entry: Element, baseUrl: String): OpdsEntry { + var id = "" + var title = "" + var summary: String? = null + var coverUrl: String? = null + var navigationUrl: String? = null + var publisher: String? = null + var published: String? = null + var language: String? = null + var series: String? = null + var seriesIndex: String? = null + var pseCount: Int? = null + var pseUrlTemplate: String? = null + val authors = mutableListOf() + val categories = mutableListOf() + val acquisitions = mutableListOf() + + entry.children().forEach { child -> + when (val tagName = child.localTagName()) { + "id" -> id = child.cleanText() + "title" -> title = child.cleanText() + "summary", "content" -> summary = child.text().trim() + "author" -> authors.add(readOpds1Author(child, baseUrl)) + "publisher" -> publisher = child.cleanText() + "language" -> if (language == null) language = child.cleanText() + "issued", "published", "updated" -> { + val date = child.cleanText() + if (published == null || tagName != "updated") published = date + } + "category" -> { + val category = child.attrAny("label") ?: child.attrAny("term") + if (!category.isNullOrBlank()) categories.add(category) + } + "meta" -> { + val property = child.attrAny("property", "name") + val content = child.attrAny("content") + val textContent = child.cleanText() + when (property) { + "calibre:series" -> series = content ?: textContent.takeIf { it.isNotBlank() } + "calibre:series_index" -> seriesIndex = content ?: textContent.takeIf { it.isNotBlank() } + } + } + "link" -> { + val rel = child.attrAny("rel").orEmpty() + val href = child.attrAny("href").orEmpty() + val type = child.attrAny("type").orEmpty() + val linkTitle = child.attrAny("title") + + if (rel == PSE_STREAM_REL) { + pseUrlTemplate = resolveUrl(baseUrl, href) + pseCount = child.attrAny("pse:count", "count")?.toIntOrNull() + } + + if (rel == "http://calibre-ebook.com/opds/series" && series == null) { + series = linkTitle + } + + if (href.isNotEmpty()) { + val absoluteUrl = resolveUrl(baseUrl, href) + when { + rel.contains("http://opds-spec.org/image") -> { + if (coverUrl == null || rel.contains("thumbnail")) coverUrl = absoluteUrl + } + rel.contains("http://opds-spec.org/acquisition") -> { + acquisitions.add(OpdsAcquisition(absoluteUrl, type)) + } + type.contains("profile=opds-catalog") || type.contains("application/atom+xml") -> { + if (navigationUrl == null) navigationUrl = absoluteUrl + } + rel == "subsection" || rel == "collection" || rel == "start" -> { + if (navigationUrl == null) navigationUrl = absoluteUrl + } + } + } + } + } + } + + return OpdsEntry( + id = id, + title = title, + summary = summary, + authors = authors, + coverUrl = coverUrl, + acquisitions = acquisitions, + navigationUrl = navigationUrl, + publisher = publisher, + published = published, + language = language, + series = series, + seriesIndex = seriesIndex, + categories = categories, + pseCount = pseCount, + pseUrlTemplate = pseUrlTemplate + ) + } + + private fun readOpds1Author(author: Element, baseUrl: String): OpdsAuthor { + var name = "" + var uri: String? = null + author.children().forEach { child -> + when (child.localTagName()) { + "name" -> name = child.cleanText() + "uri" -> uri = resolveUrl(baseUrl, child.cleanText()) + } + } + return OpdsAuthor(name, uri) + } + + private fun parseOpds2Authors(authorElement: JsonElement?, baseUrl: String): List { + return when (authorElement) { + is JsonArray -> authorElement.mapNotNull { parseOpds2Author(it, baseUrl) } + null -> emptyList() + else -> listOfNotNull(parseOpds2Author(authorElement, baseUrl)) + } + } + + private fun parseOpds2Author(authorElement: JsonElement, baseUrl: String): OpdsAuthor? { + authorElement.primitiveString()?.let { return OpdsAuthor(it, null) } + val obj = authorElement.asObjectOrNull() ?: return null + val name = obj.string("name")?.takeIf { it.isNotBlank() } ?: return null + val uri = obj.array("links") + .firstOrNull() + ?.string("href") + ?.let { resolveUrl(baseUrl, it) } + return OpdsAuthor(name, uri) + } + + private fun parseOpds2Categories(subjectElement: JsonElement?): List { + return when (subjectElement) { + is JsonArray -> subjectElement.mapNotNull(::parseOpds2Category) + null -> emptyList() + else -> listOfNotNull(parseOpds2Category(subjectElement)) + } + } + + private fun parseOpds2Category(subjectElement: JsonElement): String? { + subjectElement.primitiveString()?.let { return it } + return subjectElement.asObjectOrNull()?.string("name")?.takeIf { it.isNotBlank() } + } + + private fun parseOpds2Series(belongsTo: JsonObject?): Pair { + val seriesElement = belongsTo?.get("series") ?: return null to null + val first = if (seriesElement is JsonArray) seriesElement.firstOrNull() else seriesElement + first?.primitiveString()?.let { return it to null } + val seriesObj = first?.asObjectOrNull() ?: return null to null + val name = seriesObj.string("name") + val index = seriesObj.get("position") + ?.jsonPrimitive + ?.doubleOrNull + ?.toString() + ?.removeSuffix(".0") + return name to index + } + + private fun resolveUrl(baseUrl: String, href: String): String { + return runCatching { + URL(URL(baseUrl), href).toString() + .replace("http://m.gutenberg.org", "https://m.gutenberg.org") + .replace("http://www.gutenberg.org", "https://www.gutenberg.org") + }.getOrDefault(href) + } + + private fun JsonObject.obj(name: String): JsonObject? = get(name)?.asObjectOrNull() + + private fun JsonObject.array(name: String): List { + return runCatching { get(name)?.jsonArray?.mapNotNull { it.asObjectOrNull() }.orEmpty() } + .getOrDefault(emptyList()) + } + + private fun JsonObject.string(name: String): String? { + return runCatching { get(name)?.jsonPrimitive?.contentOrNull }.getOrNull() + } + + private fun JsonObject.boolean(name: String): Boolean? { + return runCatching { get(name)?.jsonPrimitive?.contentOrNull?.toBooleanStrictOrNull() }.getOrNull() + } + + private fun JsonObject.int(name: String): Int? { + return runCatching { get(name)?.jsonPrimitive?.intOrNull }.getOrNull() + } + + private fun JsonObject.rels(): List { + val rel = get("rel") ?: return emptyList() + rel.primitiveString()?.let { return listOf(it) } + return runCatching { rel.jsonArray.mapNotNull { it.primitiveString() } }.getOrDefault(emptyList()) + } + + private fun JsonElement.primitiveString(): String? { + return runCatching { jsonPrimitive.contentOrNull }.getOrNull()?.takeIf { it.isNotBlank() } + } + + private fun JsonElement.asObjectOrNull(): JsonObject? { + return runCatching { jsonObject }.getOrNull() + } + + private fun Element.localTagName(): String = tagName().substringAfter(":") + + private fun Element.cleanText(): String = wholeText().trim().ifBlank { text().trim() } + + private fun Element.attrAny(vararg names: String): String? { + names.forEach { name -> + val direct = attr(name) + if (direct.isNotBlank()) return direct + } + val localNames = names.map { it.substringAfter(":") } + return attributes() + .asList() + .firstOrNull { attribute -> + localNames.any { local -> attribute.key.substringAfter(":").equals(local, ignoreCase = true) } + } + ?.value + ?.takeIf { it.isNotBlank() } + } + + private companion object { + private const val PSE_STREAM_REL = "http://vaemendis.net/opds-pse/stream" + } +} diff --git a/shared/src/readerJvmMain/kotlin/com/aryan/reader/shared/reader/SharedJvmBookLoader.kt b/shared/src/readerJvmMain/kotlin/com/aryan/reader/shared/reader/SharedJvmBookLoader.kt new file mode 100644 index 0000000..897805a --- /dev/null +++ b/shared/src/readerJvmMain/kotlin/com/aryan/reader/shared/reader/SharedJvmBookLoader.kt @@ -0,0 +1,1396 @@ +package com.aryan.reader.shared.reader + +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.sp +import com.aryan.reader.paginatedreader.CssParser +import com.aryan.reader.paginatedreader.OptimizedCssRules +import com.aryan.reader.paginatedreader.UserAgentStylesheet +import com.aryan.reader.paginatedreader.htmlToSemanticBlocks +import com.aryan.reader.shared.FileType +import org.jsoup.Jsoup +import org.jsoup.nodes.Element +import org.jsoup.nodes.Node +import org.jsoup.nodes.TextNode +import org.jsoup.parser.Parser +import java.io.ByteArrayOutputStream +import java.io.ByteArrayInputStream +import java.io.File +import java.nio.charset.Charset +import java.util.Base64 +import java.util.UUID +import java.util.zip.ZipFile + +object SharedJvmBookLoader { + private data class LoaderCacheKey( + val canonicalPath: String, + val type: FileType, + val length: Long, + val lastModified: Long + ) + + private val loadedBookCache = object : LinkedHashMap(12, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean { + return size > 12 + } + } + + fun load( + file: File, + type: FileType, + titleOverride: String? = null, + authorOverride: String? = null + ): SharedEpubBook { + require(file.isFile) { "Missing reader file: ${file.absolutePath}" } + val key = LoaderCacheKey( + canonicalPath = file.canonicalPath, + type = type, + length = file.length(), + lastModified = file.lastModified() + ) + val loaded = synchronized(loadedBookCache) { + loadedBookCache.getOrPut(key) { + when (type) { + FileType.EPUB -> loadEpub(file) + FileType.HTML -> loadHtml(file) + FileType.TXT, + FileType.MD -> loadPlainText(file) + FileType.FB2 -> loadFb2(file) + FileType.DOCX -> loadDocx(file) + FileType.ODT -> loadOdt(file, isFlat = false) + FileType.FODT -> loadOdt(file, isFlat = true) + FileType.MOBI -> loadMobi(file) + else -> error("${type.name} is not supported by the shared JVM reader loader.") + } + } + } + return loaded.withOverrides(titleOverride = titleOverride, authorOverride = authorOverride) + } + + fun loadEpub(file: File): SharedEpubBook { + ZipFile(file).use { zip -> + val container = zip.readTextOrNull("META-INF/container.xml") + val opfPath = container + ?.substringAfter("full-path=\"", missingDelimiterValue = "") + ?.substringBefore("\"") + ?.takeIf { it.isNotBlank() } + ?: zip.entries().asSequence() + .map { it.name } + .firstOrNull { it.endsWith(".opf", ignoreCase = true) } + ?: error("EPUB container does not point to an OPF package.") + val opf = zip.readText(opfPath) + val basePath = opfPath.substringBeforeLast('/', missingDelimiterValue = "") + .let { if (it.isBlank()) "" else "$it/" } + + val title = opf.tagText("title").ifBlank { file.nameWithoutExtension } + val author = opf.tagText("creator").ifBlank { null } + val manifest = parseEpubManifest(opf) + val cssByPath = loadEpubCss(zip, manifest, basePath) + val cssRules = parseCssRules(cssByPath) + val spine = Regex("]*idref=[\"']([^\"']+)[\"'][^>]*/?>") + .findAll(opf) + .mapNotNull { match -> manifest[match.groupValues[1]] } + .toList() + + val chapterPaths = spine.ifEmpty { + manifest.values.filter { it.endsWith(".xhtml", ignoreCase = true) || it.endsWith(".html", ignoreCase = true) } + } + + val chapters = chapterPaths.mapIndexedNotNull { index, href -> + val path = normalizeZipPath(basePath + href) + val html = zip.readTextOrNull(path) ?: return@mapIndexedNotNull null + val resourceReadyHtml = html.sanitizeReaderHtml().withEmbeddedResources(zip, path) + val text = html.htmlToText() + if (text.isBlank()) { + null + } else { + chapterFromHtml( + id = "chapter_$index", + title = html.tagText("h1") + .ifBlank { html.tagText("h2") } + .ifBlank { html.tagText("title") } + .ifBlank { "Chapter ${index + 1}" }, + html = resourceReadyHtml, + plainText = text, + baseHref = path, + cssRules = cssRules + ) + } + } + + return SharedEpubBook( + id = file.absolutePath, + fileName = file.name, + title = title, + author = author, + css = cssByPath, + chapters = chapters.ifEmpty { + listOf( + SharedEpubChapter( + id = UUID.randomUUID().toString(), + title = title, + plainText = "This EPUB opened, but no readable spine text was found by the shared JVM loader." + ) + ) + } + ) + } + } + + private fun loadPlainText(file: File): SharedEpubBook { + val text = file.readTextLenient() + return SharedTextBookFactory.fromPlainText( + id = file.absolutePath, + fileName = file.name, + title = file.nameWithoutExtension, + plainText = text + ) + } + + private fun loadHtml(file: File): SharedEpubBook { + val html = file.readTextLenient() + val sanitized = html.sanitizeReaderHtml() + val title = sanitized.tagText("title").ifBlank { sanitized.tagText("h1") }.ifBlank { file.nameWithoutExtension } + return SharedEpubBook( + id = file.absolutePath, + fileName = file.name, + title = title, + chapters = listOf( + chapterFromHtml( + id = "chapter_0", + title = sanitized.tagText("h1").ifBlank { title }, + html = sanitized, + plainText = sanitized.htmlToText().ifBlank { title }, + baseHref = file.absolutePath, + cssRules = parseCssRules(emptyMap()) + ) + ) + ) + } + + private fun loadFb2(file: File): SharedEpubBook { + val bytes = if (file.extension.equals("zip", ignoreCase = true)) { + ZipFile(file).use { zip -> + val entry = zip.entries().asSequence().firstOrNull { it.name.endsWith(".fb2", ignoreCase = true) } + ?: error("No .fb2 file found inside the ZIP archive.") + zip.getInputStream(entry).use { it.readBytes() } + } + } else { + file.readBytes() + } + val parsed = parseFb2(bytes, file.nameWithoutExtension) + return parsed.toBook(file, parseCssRules(emptyMap())) + } + + private fun loadDocx(file: File): SharedEpubBook { + ZipFile(file).use { zip -> + val documentXml = zip.readBytesOrNull("word/document.xml") + ?: error("word/document.xml not found in DOCX archive.") + val metadata = zip.readBytesOrNull("docProps/core.xml")?.let(::parseCoreMetadata) ?: ParsedMetadata() + val html = parseDocxBody(documentXml) + val title = metadata.title.takeUnlessBlank() ?: file.nameWithoutExtension + return htmlBook( + file = file, + title = title, + author = metadata.author.takeUnlessBlank(), + html = html.ifBlank { "

    This DOCX did not contain readable text.

    " }, + chapterTitle = title + ) + } + } + + private fun loadOdt(file: File, isFlat: Boolean): SharedEpubBook { + val contentBytes: ByteArray + val metadata: ParsedMetadata + if (isFlat) { + contentBytes = file.readBytes() + metadata = parseCoreMetadata(contentBytes) + } else { + ZipFile(file).use { zip -> + contentBytes = zip.readBytesOrNull("content.xml") ?: error("content.xml not found in ODT archive.") + metadata = zip.readBytesOrNull("meta.xml")?.let(::parseCoreMetadata) + ?: parseCoreMetadata(contentBytes) + } + } + + val title = metadata.title.takeUnlessBlank() ?: file.nameWithoutExtension + val html = parseOdtBody(contentBytes) + return htmlBook( + file = file, + title = title, + author = metadata.author.takeUnlessBlank(), + html = html.ifBlank { "

    This document did not contain readable text.

    " }, + chapterTitle = title + ) + } + + private fun loadMobi(file: File): SharedEpubBook { + val mobi = parseMobi(file.readBytes(), file.nameWithoutExtension) + val title = mobi.title.takeUnlessBlank() ?: file.nameWithoutExtension + val author = mobi.author.takeUnlessBlank() + return if (mobi.chapters.isNotEmpty()) { + val cssRules = parseCssRules(emptyMap()) + SharedEpubBook( + id = file.absolutePath, + fileName = file.name, + title = title, + author = author, + chapters = mobi.chapters.mapIndexed { index, chapter -> + chapterFromHtml( + id = "mobi_chapter_$index", + title = chapter.title.takeUnlessBlank() ?: "Chapter ${index + 1}", + html = chapter.html, + plainText = chapter.plainText.takeUnlessBlank() ?: chapter.html.htmlToText(), + baseHref = file.absolutePath, + cssRules = cssRules + ) + } + ) + } else if (mobi.html.isNotBlank()) { + htmlBook( + file = file, + title = title, + author = author, + html = mobi.html, + chapterTitle = title + ) + } else { + SharedTextBookFactory.fromPlainText( + id = file.absolutePath, + fileName = file.name, + title = title, + plainText = mobi.text.ifBlank { "This MOBI did not contain readable text." }, + author = author + ) + } + } + + private fun htmlBook( + file: File, + title: String, + author: String?, + html: String, + chapterTitle: String + ): SharedEpubBook { + val sanitized = html.sanitizeReaderHtml() + return SharedEpubBook( + id = file.absolutePath, + fileName = file.name, + title = title, + author = author, + chapters = listOf( + chapterFromHtml( + id = "chapter_0", + title = sanitized.tagText("h1").ifBlank { sanitized.tagText("h2") }.ifBlank { chapterTitle }, + html = sanitized, + plainText = sanitized.htmlToText().ifBlank { title }, + baseHref = file.absolutePath, + cssRules = parseCssRules(emptyMap()) + ) + ) + ) + } + + private fun ParsedDocument.toBook(file: File, cssRules: OptimizedCssRules): SharedEpubBook { + val safeTitle = title.takeUnlessBlank() ?: file.nameWithoutExtension + val chapterDrafts = chapters.ifEmpty { + listOf( + ParsedChapter( + title = safeTitle, + html = "

    ${plainText.escapeHtml()}

    ", + plainText = plainText + ) + ) + } + return SharedEpubBook( + id = file.absolutePath, + fileName = file.name, + title = safeTitle, + author = author.takeUnlessBlank(), + chapters = chapterDrafts.mapIndexed { index, chapter -> + val html = chapter.html.ifBlank { "

    ${chapter.plainText.escapeHtml()}

    " } + chapterFromHtml( + id = "chapter_$index", + title = chapter.title.takeUnlessBlank() ?: "Chapter ${index + 1}", + html = html, + plainText = chapter.plainText.takeUnlessBlank() ?: html.htmlToText(), + baseHref = file.absolutePath, + cssRules = cssRules + ) + } + ) + } + + private fun chapterFromHtml( + id: String, + title: String, + html: String, + plainText: String, + baseHref: String?, + cssRules: OptimizedCssRules + ): SharedEpubChapter { + val semanticBlocks = runCatching { + htmlToSemanticBlocks( + html = html, + cssRules = cssRules, + textStyle = TextStyle(fontSize = 18.sp), + chapterAbsPath = baseHref.orEmpty(), + extractionBasePath = "", + density = Density(1f), + fontFamilyMap = emptyMap(), + constraints = Constraints(maxWidth = 980, maxHeight = 720) + ) + }.getOrDefault(emptyList()) + return SharedEpubChapter( + id = id, + title = title, + plainText = plainText, + semanticBlocks = semanticBlocks, + htmlContent = html.extractBodyOrSelf(), + baseHref = baseHref + ) + } + + private fun parseFb2(bytes: ByteArray, fallbackTitle: String): ParsedDocument { + val document = xmlDocument(bytes) + val titleInfo = document.allElementsByLocalTag("title-info").firstOrNull() + val bookTitle = titleInfo + ?.allElementsByLocalTag("book-title") + ?.firstOrNull() + ?.text() + ?.normalizeReaderWhitespace() + val authors = titleInfo + ?.childrenByLocalTag("author") + ?.mapNotNull { it.fb2AuthorName() } + ?.distinct() + .orEmpty() + val body = document.allElementsByLocalTag("body").firstOrNull() + val topLevelSections = body?.childrenByLocalTag("section").orEmpty() + val chapters = if (topLevelSections.isNotEmpty()) { + topLevelSections.mapIndexedNotNull { index, section -> + section.toFb2Chapter(index) + } + } else { + val chapter = body?.toFb2Chapter(0) + if (chapter == null) emptyList() else listOf(chapter) + } + return ParsedDocument( + title = bookTitle.takeUnlessBlank() ?: fallbackTitle, + author = authors.joinToString(", ").takeUnlessBlank(), + chapters = chapters + ) + } + + private fun parseDocxBody(bytes: ByteArray): String { + val document = xmlDocument(bytes) + val html = StringBuilder() + document.allElementsByLocalTag("p").forEach { paragraph -> + val paragraphStyle = paragraph.allElementsByLocalTag("pstyle") + .firstOrNull() + ?.xmlAttr("val") + val text = StringBuilder() + paragraph.getAllElements().forEach { element -> + when (element.xmlTag()) { + "t" -> text.append(element.wholeText().escapeHtml()) + "tab" -> text.append(" ") + "br" -> text.append("
    ") + } + } + val paragraphHtml = text.toString() + if (paragraphHtml.htmlToText().isNotBlank()) { + val tag = if (paragraphStyle.orEmpty().contains("heading", ignoreCase = true)) "h2" else "p" + html.append("<$tag>").append(paragraphHtml).append("\n") + } + } + return html.toString() + } + + private fun parseOdtBody(bytes: ByteArray): String { + val document = xmlDocument(bytes) + val body = document.allElementsByLocalTag("text").firstOrNull() ?: document + val html = StringBuilder() + val plain = StringBuilder() + body.childNodes().forEach { appendOdtNode(it, html, plain) } + return html.toString() + } + + private fun parseCoreMetadata(bytes: ByteArray): ParsedMetadata { + val document = xmlDocument(bytes) + val title = document.allElementsByLocalTag("title") + .firstOrNull() + ?.text() + ?.normalizeReaderWhitespace() + val author = document.allElementsByLocalTag("creator") + .firstOrNull() + ?.text() + ?.normalizeReaderWhitespace() + ?: document.allElementsByLocalTag("initial-creator") + .firstOrNull() + ?.text() + ?.normalizeReaderWhitespace() + return ParsedMetadata(title = title, author = author) + } + + private fun Element.toFb2Chapter(index: Int): ParsedChapter? { + val html = StringBuilder() + val plain = StringBuilder() + if (xmlTag() == "section" || xmlTag() == "body") { + childNodes().forEach { appendFb2Node(it, html, plain, headingLevel = 2) } + } else { + appendFb2Element(this, html, plain, headingLevel = 2) + } + val text = plain.toString().normalizeReaderWhitespace() + if (text.isBlank() && html.isBlank()) return null + val title = childrenByLocalTag("title") + .firstOrNull() + ?.text() + ?.normalizeReaderWhitespace() + .takeUnlessBlank() + ?: "Chapter ${index + 1}" + return ParsedChapter( + title = title, + html = html.toString(), + plainText = text + ) + } + + private fun Element.fb2AuthorName(): String? { + return listOf("first-name", "middle-name", "last-name", "nickname") + .mapNotNull { part -> + childrenByLocalTag(part) + .firstOrNull() + ?.text() + ?.normalizeReaderWhitespace() + .takeUnlessBlank() + } + .joinToString(" ") + .takeUnlessBlank() + } + + private fun appendFb2Node(node: Node, html: StringBuilder, plain: StringBuilder, headingLevel: Int) { + when (node) { + is TextNode -> { + val text = node.text() + if (text.isNotBlank()) { + html.append(text.escapeHtml()) + plain.append(text) + } + } + is Element -> appendFb2Element(node, html, plain, headingLevel) + } + } + + private fun appendFb2Element(element: Element, html: StringBuilder, plain: StringBuilder, headingLevel: Int) { + when (element.xmlTag()) { + "section" -> element.childNodes().forEach { + appendFb2Node(it, html, plain, (headingLevel + 1).coerceAtMost(6)) + } + "title" -> { + val tag = "h${headingLevel.coerceIn(2, 6)}" + val text = element.text().normalizeReaderWhitespace() + if (text.isNotBlank()) { + html.append("<$tag>").append(text.escapeHtml()).append("\n") + plain.append(text).append('\n') + } + } + "p", "v" -> appendWrappedFb2Children(element, "p", html, plain, headingLevel) + "subtitle" -> appendWrappedFb2Children(element, "h3", html, plain, headingLevel) + "empty-line" -> { + html.append("
    ") + plain.append('\n') + } + "strong" -> appendWrappedFb2Children(element, "b", html, plain, headingLevel, block = false) + "emphasis" -> appendWrappedFb2Children(element, "i", html, plain, headingLevel, block = false) + "strikethrough" -> appendWrappedFb2Children(element, "s", html, plain, headingLevel, block = false) + "sup" -> appendWrappedFb2Children(element, "sup", html, plain, headingLevel, block = false) + "sub" -> appendWrappedFb2Children(element, "sub", html, plain, headingLevel, block = false) + "poem", "stanza", "epigraph" -> appendWrappedFb2Children(element, "div", html, plain, headingLevel) + "cite" -> appendWrappedFb2Children(element, "blockquote", html, plain, headingLevel) + "a" -> { + val href = element.xmlAttr("href") + html.append(if (href.isNullOrBlank()) "" else "") + element.childNodes().forEach { appendFb2Node(it, html, plain, headingLevel) } + html.append("") + } + "image" -> { + val href = element.xmlAttr("href")?.removePrefix("#").orEmpty() + if (href.isNotBlank()) { + html.append("

    ").append(href.escapeHtml()).append("

    \n") + plain.append(href).append('\n') + } + } + else -> element.childNodes().forEach { + appendFb2Node(it, html, plain, headingLevel) + } + } + } + + private fun appendWrappedFb2Children( + element: Element, + tag: String, + html: StringBuilder, + plain: StringBuilder, + headingLevel: Int, + block: Boolean = true + ) { + html.append("<$tag>") + element.childNodes().forEach { appendFb2Node(it, html, plain, headingLevel) } + html.append("") + if (block) { + html.append('\n') + plain.append('\n') + } + } + + private fun appendOdtNode(node: Node, html: StringBuilder, plain: StringBuilder) { + when (node) { + is TextNode -> { + val text = node.text() + if (text.isNotBlank()) { + html.append(text.escapeHtml()) + plain.append(text) + } + } + is Element -> appendOdtElement(node, html, plain) + } + } + + private fun appendOdtElement(element: Element, html: StringBuilder, plain: StringBuilder) { + when (element.xmlTag()) { + "h" -> { + val level = element.xmlAttr("outline-level") + ?.toIntOrNull() + ?.coerceIn(1, 6) + ?: 2 + appendOdtWrappedElement(element, "h$level", html, plain) + } + "p" -> appendOdtWrappedElement(element, "p", html, plain) + "span" -> appendOdtWrappedElement(element, "span", html, plain, block = false) + "a" -> { + val href = element.xmlAttr("href") + html.append(if (href.isNullOrBlank()) "" else "") + element.childNodes().forEach { appendOdtNode(it, html, plain) } + html.append("") + } + "list" -> appendOdtWrappedElement(element, "ul", html, plain) + "list-item" -> appendOdtWrappedElement(element, "li", html, plain) + "table" -> appendOdtWrappedElement(element, "table", html, plain) + "table-row" -> appendOdtWrappedElement(element, "tr", html, plain) + "table-cell" -> appendOdtWrappedElement(element, "td", html, plain, block = false) + "line-break" -> { + html.append("
    ") + plain.append('\n') + } + "tab" -> { + html.append("    ") + plain.append(" ") + } + else -> element.childNodes().forEach { appendOdtNode(it, html, plain) } + } + } + + private fun appendOdtWrappedElement( + element: Element, + tag: String, + html: StringBuilder, + plain: StringBuilder, + block: Boolean = true + ) { + html.append("<$tag>") + element.childNodes().forEach { appendOdtNode(it, html, plain) } + html.append("") + if (block) { + html.append('\n') + plain.append('\n') + } + } + + private fun parseMobi(bytes: ByteArray, fallbackTitle: String): ParsedMobi { + require(bytes.size > 86) { "Invalid MOBI/Palm database." } + val recordCount = bytes.u16(76) + require(recordCount > 1) { "MOBI file does not contain text records." } + val offsets = (0 until recordCount).map { index -> + bytes.u32(78 + index * 8).toInt() + }.filter { it in bytes.indices } + require(offsets.size > 1) { "MOBI file has invalid record offsets." } + val records = offsets.mapIndexed { index, offset -> + val end = offsets.getOrNull(index + 1) ?: bytes.size + bytes.copyOfRange(offset, end.coerceAtLeast(offset)) + } + val header = records.first() + require(header.size >= 16) { "MOBI text header is missing." } + + val compression = header.u16(0) + val textLength = header.u32(4).toInt() + val textRecordCount = header.u16(8).coerceAtMost(records.lastIndex) + val textRecordSize = header.u16(10).takeIf { it > 0 } ?: 4096 + val encryption = header.u16(12) + require(encryption == 0) { "Encrypted MOBI files are not supported." } + require(compression == MOBI_COMPRESSION_NONE || + compression == MOBI_COMPRESSION_PALMDOC || + compression == MOBI_COMPRESSION_HUFFCDIC + ) { + "MOBI compression $compression is not supported by the shared JVM loader." + } + + val mobiHeader = parseMobiHeaderInfo(header) + val encoding = mobiHeader.encoding ?: 1252 + val charset = when (encoding) { + 65001 -> Charsets.UTF_8 + 1200 -> Charsets.UTF_16 + 1252 -> Charset.forName("windows-1252") + else -> Charsets.UTF_8 + } + val huffCdic = if (compression == MOBI_COMPRESSION_HUFFCDIC) { + parseMobiHuffCdic(records, mobiHeader.huffRecordIndex, mobiHeader.huffRecordCount) + } else { + null + } + + val rawTextBytes = buildList { + for (index in 1..textRecordCount) { + val record = records.getOrNull(index) ?: continue + val textRecord = record.withoutMobiTrailingData(mobiHeader.extraFlags) + add( + when (compression) { + MOBI_COMPRESSION_NONE -> textRecord.withoutOldMobiZeros() + MOBI_COMPRESSION_PALMDOC -> decompressPalmDoc(textRecord) + MOBI_COMPRESSION_HUFFCDIC -> decompressHuffman(textRecord, huffCdic, textRecordSize) + else -> textRecord + } + ) + } + }.flattenBytes() + .let { if (textLength in 1 until it.size) it.copyOf(textLength) else it } + + val resourceMap = mobiHeader.imageIndex + ?.let { imageIndex -> parseMobiResources(records, imageIndex) } + .orEmpty() + val rawText = decodeMobiText(rawTextBytes, charset).withMobiEmbeddedResources(resourceMap) + val metadata = parseMobiMetadata(header, charset) + val title = metadata.title.takeUnlessBlank() ?: fallbackTitle + val author = metadata.author.takeUnlessBlank() + val looksLikeHtml = rawText.contains(" header.size) return@repeat + val type = header.u32(offset).toInt() + val size = header.u32(offset + 4).toInt() + if (size < 8 || offset + size > header.size) return@repeat + val value = header.safeString(offset + 8, size - 8, charset) + when (type) { + 100 -> author = author ?: value + 99 -> exthTitle = exthTitle ?: value + 503 -> exthTitle = exthTitle ?: value + } + offset += size + } + } + return ParsedMetadata(title = exthTitle.takeUnlessBlank() ?: fullName.takeUnlessBlank(), author = author) + } + + private fun parseMobiHeaderInfo(header: ByteArray): MobiHeaderInfo { + if (header.size < 32 || header.asciiAt(16, 4) != "MOBI") return MobiHeaderInfo() + val mobiHeaderLength = header.u32(20).toInt() + fun u32InHeader(offset: Int): Int? { + if (mobiHeaderLength < offset + 4 || 16 + offset + 4 > header.size) return null + return header.u32(16 + offset).toInt() + .takeIf { it >= 0 && it != MOBI_NOT_SET } + } + fun u16InHeader(offset: Int): Int { + if (mobiHeaderLength < offset + 2 || 16 + offset + 2 > header.size) return 0 + return header.u16(16 + offset) + } + return MobiHeaderInfo( + encoding = u32InHeader(12), + imageIndex = u32InHeader(92), + huffRecordIndex = u32InHeader(96), + huffRecordCount = u32InHeader(100), + extraFlags = u16InHeader(242) + ) + } + + private fun parseMobiHuffCdic( + records: List, + huffRecordIndex: Int?, + huffRecordCount: Int? + ): MobiHuffCdic { + val start = huffRecordIndex ?: error("HUFF/CDIC MOBI is missing HUFF record metadata.") + val count = huffRecordCount ?: error("HUFF/CDIC MOBI is missing CDIC record metadata.") + require(count >= 2 && start > 0 && start + count <= records.size) { + "HUFF/CDIC record metadata points outside the MOBI record table." + } + + val huff = records[start] + require(huff.size >= HUFF_RECORD_MIN_SIZE && huff.asciiAt(0, 4) == "HUFF") { + "MOBI HUFF record is missing or corrupt." + } + val huffHeaderLength = huff.u32(4).toInt() + require(huffHeaderLength >= HUFF_HEADER_LENGTH) { "MOBI HUFF record header is too short." } + val data1Offset = huff.u32(8).toInt() + val data2Offset = huff.u32(12).toInt() + require(data1Offset >= 0 && data1Offset + 256 * 4 <= huff.size) { "MOBI HUFF table 1 is corrupt." } + require(data2Offset >= 0 && data2Offset + 64 * 4 <= huff.size) { "MOBI HUFF table 2 is corrupt." } + + val table1 = IntArray(256) { index -> huff.u32(data1Offset + index * 4).toInt() } + val mincodeTable = LongArray(HUFF_CODETABLE_SIZE) + val maxcodeTable = LongArray(HUFF_CODETABLE_SIZE) + mincodeTable[0] = 0L + maxcodeTable[0] = UINT32_MAX + var tableOffset = data2Offset + for (index in 1 until HUFF_CODETABLE_SIZE) { + val mincode = huff.u32(tableOffset) + val maxcode = huff.u32(tableOffset + 4) + mincodeTable[index] = (mincode shl (32 - index)) and UINT32_MAX + maxcodeTable[index] = (((maxcode + 1L) shl (32 - index)) - 1L) and UINT32_MAX + tableOffset += 8 + } + + var codeLength = 0 + var indexCount = 0 + var indexRead = 0 + val symbolOffsets = mutableListOf() + val symbols = mutableListOf() + + for (recordOffset in 1 until count) { + val cdic = records[start + recordOffset] + require(cdic.size >= CDIC_HEADER_LENGTH && cdic.asciiAt(0, 4) == "CDIC") { + "MOBI CDIC record is missing or corrupt." + } + val cdicHeaderLength = cdic.u32(4).toInt() + require(cdicHeaderLength >= CDIC_HEADER_LENGTH) { "MOBI CDIC record header is too short." } + val totalIndexCount = cdic.u32(8).toInt() + val currentCodeLength = cdic.u32(12).toInt() + require(currentCodeLength in 1..HUFF_CODELEN_MAX) { "MOBI CDIC code length is invalid." } + if (codeLength == 0) codeLength = currentCodeLength + if (indexCount == 0) indexCount = totalIndexCount + require(codeLength == currentCodeLength && indexCount == totalIndexCount) { + "MOBI CDIC records disagree about dictionary dimensions." + } + + var entriesToRead = totalIndexCount - indexRead + if ((entriesToRead ushr codeLength) > 0) { + entriesToRead = 1 shl codeLength + } + require(entriesToRead >= 0 && CDIC_HEADER_LENGTH + entriesToRead * 2 <= cdic.size) { + "MOBI CDIC symbol table is corrupt." + } + var offset = CDIC_HEADER_LENGTH + repeat(entriesToRead) { + val symbolOffset = cdic.u16(offset) + val symbolStart = CDIC_HEADER_LENGTH + symbolOffset + require(symbolStart + 2 <= cdic.size) { "MOBI CDIC symbol offset is corrupt." } + val symbolLength = cdic.u16(symbolStart) and 0x7FFF + require(symbolStart + 2 + symbolLength <= cdic.size) { "MOBI CDIC symbol data is corrupt." } + symbolOffsets += symbolOffset + indexRead += 1 + offset += 2 + } + symbols += cdic.copyOfRange(CDIC_HEADER_LENGTH, cdic.size) + } + + require(indexCount == indexRead && symbolOffsets.size == indexCount) { + "MOBI CDIC dictionary did not provide all symbol offsets." + } + return MobiHuffCdic( + indexCount = indexCount, + codeLength = codeLength, + table1 = table1, + mincodeTable = mincodeTable, + maxcodeTable = maxcodeTable, + symbolOffsets = symbolOffsets.toIntArray(), + symbols = symbols + ) + } + + private fun decompressHuffman(input: ByteArray, huffCdic: MobiHuffCdic?, textRecordSize: Int): ByteArray { + require(huffCdic != null) { "MOBI HUFF/CDIC dictionary is missing." } + val output = ByteArrayOutputStream((textRecordSize * 2).coerceAtLeast(input.size)) + decompressHuffmanInto(input, output, huffCdic, depth = 0) + return output.toByteArray() + } + + private fun decompressHuffmanInto( + input: ByteArray, + output: ByteArrayOutputStream, + huffCdic: MobiHuffCdic, + depth: Int + ) { + require(depth <= MOBI_HUFFMAN_MAX_DEPTH) { "MOBI HUFF/CDIC recursion limit exceeded." } + var bitCount = 32 + var bitsLeft = input.size * 8 + var inputOffset = 0 + var buffer = input.huffmanFill64(inputOffset) + inputOffset += 4 + + while (true) { + if (bitCount <= 0) { + bitCount += 32 + buffer = input.huffmanFill64(inputOffset) + inputOffset += 4 + } + val code = (buffer ushr bitCount) and UINT32_MAX + val tableEntry = huffCdic.table1[(code ushr 24).toInt()].toLong() and UINT32_MAX + var codeLength = (tableEntry and 0x1F).toInt() + if (codeLength <= 0 || codeLength >= HUFF_CODETABLE_SIZE) { + break + } + var maxcode = ((((tableEntry ushr 8) + 1L) shl (32 - codeLength)) - 1L) and UINT32_MAX + if ((tableEntry and 0x80L) == 0L) { + while (code < huffCdic.mincodeTable[codeLength]) { + codeLength += 1 + require(codeLength < HUFF_CODETABLE_SIZE) { "MOBI HUFF code table offset is corrupt." } + } + maxcode = huffCdic.maxcodeTable[codeLength] + } + + bitCount -= codeLength + bitsLeft -= codeLength + if (bitsLeft < 0) break + + val symbolIndex = ((maxcode - code) ushr (32 - codeLength)).toInt() + require(symbolIndex in 0 until huffCdic.indexCount) { "MOBI HUFF symbol index is corrupt." } + val cdicIndex = symbolIndex ushr huffCdic.codeLength + val symbols = huffCdic.symbols.getOrNull(cdicIndex) + ?: error("MOBI HUFF symbol record is missing.") + val offset = huffCdic.symbolOffsets[symbolIndex] + require(offset + 2 <= symbols.size) { "MOBI HUFF symbol offset is corrupt." } + val symbolHeader = symbols.u16(offset) + val isDecompressed = (symbolHeader and 0x8000) != 0 + val symbolLength = symbolHeader and 0x7FFF + require(offset + 2 + symbolLength <= symbols.size) { "MOBI HUFF symbol data is corrupt." } + + if (isDecompressed) { + output.write(symbols, offset + 2, symbolLength) + } else { + decompressHuffmanInto( + input = symbols.copyOfRange(offset + 2, offset + 2 + symbolLength), + output = output, + huffCdic = huffCdic, + depth = depth + 1 + ) + } + } + } + + private fun ByteArray.huffmanFill64(offset: Int): Long { + var value = 0L + var shiftIndex = 8 + var index = offset + var bytesLeft = (size - offset).coerceAtLeast(0) + while (shiftIndex > 0 && bytesLeft > 0) { + shiftIndex -= 1 + value = value or ((this[index].toLong() and 0xFFL) shl (shiftIndex * 8)) + index += 1 + bytesLeft -= 1 + } + return value + } + + private fun ByteArray.withoutMobiTrailingData(extraFlags: Int): ByteArray { + if (extraFlags == 0 || isEmpty()) return this + val extraSize = mobiTrailingDataSize(extraFlags) + return if (extraSize in 1 until size) copyOf(size - extraSize) else this + } + + private fun ByteArray.mobiTrailingDataSize(extraFlags: Int): Int { + var position = lastIndex + var extraSize = 0 + for (bit in 15 downTo 1) { + if ((extraFlags and (1 shl bit)) == 0) continue + val value = readBackwardVarlen(position) ?: return 0 + position = value.nextPosition - (value.size - value.byteCount) + if (position < -1) return 0 + extraSize += value.size + } + if ((extraFlags and 1) != 0 && position in indices) { + extraSize += (this[position].toInt() and 0x03) + 1 + } + return extraSize.coerceIn(0, size) + } + + private fun ByteArray.readBackwardVarlen(start: Int): MobiBackwardVarlen? { + var value = 0 + var shift = 0 + var count = 0 + var index = start + while (index >= 0 && count < 4) { + val byte = this[index].toInt() and 0xFF + value = value or ((byte and 0x7F) shl shift) + count += 1 + index -= 1 + if ((byte and 0x80) != 0) { + return MobiBackwardVarlen(size = value, byteCount = count, nextPosition = start - count) + } + shift += 7 + } + return null + } + + private fun ByteArray.withoutOldMobiZeros(): ByteArray { + return if (0.toByte() in this) filter { it != 0.toByte() }.toByteArray() else this + } + + private fun parseMobiResources(records: List, imageIndex: Int): Map { + if (imageIndex <= 0 || imageIndex >= records.size) return emptyMap() + var imageNumber = 1 + val resources = mutableMapOf() + for (recordIndex in imageIndex until records.size) { + val bytes = records[recordIndex] + val mimeType = bytes.mobiResourceMimeType() ?: continue + resources[imageNumber] = "data:$mimeType;base64,${Base64.getEncoder().encodeToString(bytes)}" + imageNumber += 1 + } + return resources + } + + private fun ByteArray.mobiResourceMimeType(): String? { + return when { + size >= 3 && + (this[0].toInt() and 0xFF) == 0xFF && + (this[1].toInt() and 0xFF) == 0xD8 && + (this[2].toInt() and 0xFF) == 0xFF -> "image/jpeg" + size >= 8 && asciiAt(1, 3) == "PNG" -> "image/png" + size >= 6 && (asciiAt(0, 6) == "GIF87a" || asciiAt(0, 6) == "GIF89a") -> "image/gif" + size >= 12 && asciiAt(0, 4) == "RIFF" && asciiAt(8, 4) == "WEBP" -> "image/webp" + size >= 2 && asciiAt(0, 2) == "BM" -> "image/bmp" + else -> null + } + } + + private fun String.withMobiEmbeddedResources(resources: Map): String { + if (resources.isEmpty() || !contains("kindle:", ignoreCase = true) && !contains("recindex", ignoreCase = true)) { + return this + } + val document = Jsoup.parse(this) + document.select("img").forEach { image -> + val embedIndex = image.attr("src") + .substringAfter("kindle:embed:", missingDelimiterValue = "") + .substringBefore("?") + .toIntOrNull() + val recordIndex = image.attr("recindex").toIntOrNull() + val replacement = embedIndex?.let(resources::get) + ?: recordIndex?.let(resources::get) + if (replacement != null) { + image.attr("src", replacement) + image.removeAttr("recindex") + } + } + return document.outerHtml() + } + + private fun splitMobiHtmlChapters(html: String, fallbackTitle: String): List { + val parts = Regex("(?is)]*>").split(html) + .map { it.trim() } + .filter { it.htmlToText().isNotBlank() } + if (parts.size <= 1) return emptyList() + return parts.mapIndexed { index, chapterHtml -> + val title = chapterHtml.tagText("h1") + .ifBlank { chapterHtml.tagText("h2") } + .ifBlank { if (index == 0) fallbackTitle else "Chapter ${index + 1}" } + ParsedChapter( + title = title, + html = chapterHtml, + plainText = chapterHtml.htmlToText() + ) + } + } + + private fun decompressPalmDoc(input: ByteArray): ByteArray { + val output = ArrayList(input.size * 2) + var i = 0 + while (i < input.size) { + val c = input[i].toInt() and 0xFF + i += 1 + when (c) { + 0 -> output.add(0) + in 1..8 -> { + repeat(c) { + if (i < input.size) output.add(input[i++]) + } + } + in 9..0x7F -> output.add(c.toByte()) + in 0x80..0xBF -> { + if (i >= input.size) return output.toByteArray() + val pair = (c shl 8) or (input[i].toInt() and 0xFF) + i += 1 + val distance = (pair shr 3) and 0x7FF + val length = (pair and 0x7) + 3 + val start = output.size - distance + if (distance > 0 && start >= 0) { + repeat(length) { index -> + output.add(output[start + index]) + } + } + } + else -> { + output.add(' '.code.toByte()) + output.add((c xor 0x80).toByte()) + } + } + } + return output.toByteArray() + } + + private fun parseEpubManifest(opf: String): Map { + return Regex("]*>").findAll(opf).mapNotNull { match -> + val item = match.value + val id = item.attr("id") + val href = item.attr("href") + if (id.isBlank() || href.isBlank()) null else id to href + }.toMap() + } + + private fun loadEpubCss(zip: ZipFile, manifest: Map, basePath: String): Map { + return manifest.values + .filter { it.endsWith(".css", ignoreCase = true) } + .mapNotNull { href -> + val path = normalizeZipPath(basePath + href) + val css = zip.readTextOrNull(path)?.withEmbeddedCssResources(zip, path).orEmpty() + if (css.isBlank()) null else path to css + } + .toMap() + } + + private fun parseCssRules(cssByPath: Map): OptimizedCssRules { + val constraints = Constraints(maxWidth = 980, maxHeight = 720) + val baseRules = CssParser.parse( + cssContent = UserAgentStylesheet.default, + cssPath = null, + baseFontSizeSp = 18f, + density = 1f, + constraints = constraints, + isDarkTheme = false, + adaptThemeColors = false + ).rules + + return cssByPath.entries.fold(baseRules) { rules, (path, css) -> + if (css.isBlank()) { + rules + } else { + rules.merge( + CssParser.parse( + cssContent = css, + cssPath = path, + baseFontSizeSp = 18f, + density = 1f, + constraints = constraints, + isDarkTheme = false, + adaptThemeColors = false + ).rules + ) + } + } + } + + private fun xmlDocument(bytes: ByteArray): Element { + return ByteArrayInputStream(bytes).use { input -> + Jsoup.parse(input, null, "", Parser.xmlParser()) + } + } + + private fun Element.xmlTag(): String { + return tagName().substringAfter(':').lowercase() + } + + private fun Element.xmlAttr(name: String): String? { + val expectedLocal = name.substringAfter(':') + for (attribute in attributes().asList()) { + val key = attribute.key + if (key.equals(name, ignoreCase = true) || + key.substringAfter(':').equals(expectedLocal, ignoreCase = true) + ) { + return attribute.value.takeUnlessBlank() + } + } + return null + } + + private fun Element.allElementsByLocalTag(tag: String): List { + return getAllElements().filter { it.xmlTag() == tag } + } + + private fun Element.childrenByLocalTag(tag: String): List { + return children().filter { it.xmlTag() == tag } + } + + private fun ZipFile.readText(path: String): String { + val entry = getEntry(path) ?: error("Missing EPUB entry: $path") + return getInputStream(entry).bufferedReader().use { it.readText() } + } + + private fun ZipFile.readTextOrNull(path: String): String? { + val entry = getEntry(path) ?: return null + return getInputStream(entry).bufferedReader().use { it.readText() } + } + + private fun ZipFile.readBytesOrNull(path: String): ByteArray? { + val entry = getEntry(path) ?: return null + return getInputStream(entry).use { it.readBytes() } + } + + private fun String.attr(name: String): String { + return Regex("""\b$name=["']([^"']+)["']""").find(this)?.groupValues?.get(1).orEmpty() + } + + private fun String.tagText(tag: String): String { + return Regex("<(?:[^:>]+:)?$tag\\b[^>]*>(.*?)]+:)?$tag>", RegexOption.IGNORE_CASE) + .find(this) + ?.groupValues + ?.get(1) + ?.htmlToText() + .orEmpty() + } + + private fun normalizeZipPath(path: String): String { + val parts = ArrayDeque() + path.split('/').forEach { part -> + when (part) { + "", "." -> Unit + ".." -> if (parts.isNotEmpty()) parts.removeLast() + else -> parts.addLast(part) + } + } + return parts.joinToString("/") + } + + private fun String.withEmbeddedResources(zip: ZipFile, chapterPath: String): String { + return replace(Regex("""(?i)\b(src|href)=["']([^"']+)["']""")) { match -> + val attr = match.groupValues[1] + val raw = match.groupValues[2] + if (attr.equals("href", ignoreCase = true) && !raw.looksLikeEmbeddableResource()) { + return@replace match.value + } + val dataUri = zip.toDataUri(raw, chapterPath) + if (dataUri != null) "$attr=\"$dataUri\"" else match.value + } + } + + private fun String.looksLikeEmbeddableResource(): Boolean { + return substringBefore('#') + .substringBefore('?') + .substringAfterLast('.', "") + .lowercase() in setOf("css", "jpg", "jpeg", "png", "gif", "svg", "webp", "ttf", "otf", "woff", "woff2") + } + + private fun String.withEmbeddedCssResources(zip: ZipFile, cssPath: String): String { + return replace(Regex("""url\((['"]?)([^)'"]+)\1\)""", RegexOption.IGNORE_CASE)) { match -> + val raw = match.groupValues[2].trim() + val dataUri = zip.toDataUri(raw, cssPath) + if (dataUri != null) "url('$dataUri')" else match.value + } + } + + private fun ZipFile.toDataUri(rawRef: String, ownerPath: String): String? { + val ref = rawRef.substringBefore('#').trim() + if (ref.isBlank() || ref.startsWith("data:", ignoreCase = true)) return null + if (ref.startsWith("http://", ignoreCase = true) || ref.startsWith("https://", ignoreCase = true)) return null + val base = ownerPath.substringBeforeLast('/', missingDelimiterValue = "") + val path = normalizeZipPath(if (base.isBlank()) ref else "$base/$ref") + val entry = getEntry(path) ?: return null + val bytes = getInputStream(entry).use { it.readBytes() } + return "data:${mimeType(path)};base64,${Base64.getEncoder().encodeToString(bytes)}" + } + + private fun mimeType(path: String): String { + return when (path.substringAfterLast('.', "").lowercase()) { + "jpg", "jpeg" -> "image/jpeg" + "png" -> "image/png" + "gif" -> "image/gif" + "svg" -> "image/svg+xml" + "webp" -> "image/webp" + "ttf" -> "font/ttf" + "otf" -> "font/otf" + "woff" -> "font/woff" + "woff2" -> "font/woff2" + "css" -> "text/css" + "js" -> "text/javascript" + else -> "application/octet-stream" + } + } + + private fun File.readTextLenient(): String { + val bytes = readBytes() + return bytes.toString(Charsets.UTF_8).takeIf { '\uFFFD' !in it } + ?: bytes.toString(Charset.forName("windows-1252")) + } + + private fun String.extractBodyOrSelf(): String { + return Regex("(?is)]*>(.*?)") + .find(this) + ?.groupValues + ?.get(1) + ?.trim() + ?: this + } + + private fun String.htmlToText(): String { + return Jsoup.parse(this).text().normalizeReaderWhitespace() + } + + private fun String.sanitizeReaderHtml(): String { + return replace(Regex("(?is)"), "") + .replace(Regex("(?is)"), "") + .replace(Regex("(?is)]*>"), "") + .replace(Regex("""(?i)\s+on[a-z]+\s*=\s*(['"]).*?\1"""), "") + } + + private fun String.escapeHtml(): String { + return replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'") + } + + private fun String.escapeHtmlAttribute(): String { + return escapeHtml() + } + + private fun String.normalizeReaderWhitespace(): String { + return replace('\u0000', ' ') + .replace(Regex("[ \\t\\x0B\\f\\r]+"), " ") + .replace(Regex(" *\\n *"), "\n") + .replace(Regex("\\n{3,}"), "\n\n") + .trim() + } + + private fun String?.takeUnlessBlank(): String? { + return this?.trim()?.takeIf { it.isNotBlank() } + } + + private fun SharedEpubBook.withOverrides(titleOverride: String?, authorOverride: String?): SharedEpubBook { + return copy( + title = titleOverride.takeUnlessBlank() ?: title, + author = authorOverride.takeUnlessBlank() ?: author + ) + } + + private fun ByteArray.u16(offset: Int): Int { + if (offset + 2 > size) return 0 + return ((this[offset].toInt() and 0xFF) shl 8) or (this[offset + 1].toInt() and 0xFF) + } + + private fun ByteArray.u32(offset: Int): Long { + if (offset + 4 > size) return 0 + return ((this[offset].toLong() and 0xFF) shl 24) or + ((this[offset + 1].toLong() and 0xFF) shl 16) or + ((this[offset + 2].toLong() and 0xFF) shl 8) or + (this[offset + 3].toLong() and 0xFF) + } + + private fun ByteArray.asciiAt(offset: Int, length: Int): String { + if (offset < 0 || offset + length > size) return "" + return copyOfRange(offset, offset + length).toString(Charsets.US_ASCII) + } + + private fun ByteArray.safeString(offset: Int, length: Int, charset: Charset): String? { + if (offset < 0 || length <= 0 || offset + length > size) return null + return copyOfRange(offset, offset + length).toString(charset) + .trim('\u0000', ' ', '\n', '\r', '\t') + .takeUnlessBlank() + } + + private fun decodeMobiText(bytes: ByteArray, preferred: Charset): String { + val primary = bytes.toString(preferred) + if ('\uFFFD' !in primary) return primary.trim('\u0000') + return bytes.toString(Charset.forName("windows-1252")).trim('\u0000') + } + + private fun List.flattenBytes(): ByteArray { + val total = sumOf { it.size } + val result = ByteArray(total) + var offset = 0 + forEach { bytes -> + bytes.copyInto(result, offset) + offset += bytes.size + } + return result + } + + private data class ParsedChapter( + val title: String, + val html: String, + val plainText: String + ) + + private data class ParsedDocument( + val title: String?, + val author: String? = null, + val chapters: List = emptyList(), + val plainText: String = chapters.joinToString("\n\n") { it.plainText } + ) + + private data class ParsedMetadata( + val title: String? = null, + val author: String? = null + ) + + private data class ParsedMobi( + val title: String?, + val author: String?, + val html: String, + val text: String, + val chapters: List = emptyList() + ) + + private data class MobiHeaderInfo( + val encoding: Int? = null, + val imageIndex: Int? = null, + val huffRecordIndex: Int? = null, + val huffRecordCount: Int? = null, + val extraFlags: Int = 0 + ) + + private data class MobiHuffCdic( + val indexCount: Int, + val codeLength: Int, + val table1: IntArray, + val mincodeTable: LongArray, + val maxcodeTable: LongArray, + val symbolOffsets: IntArray, + val symbols: List + ) + + private data class MobiBackwardVarlen( + val size: Int, + val byteCount: Int, + val nextPosition: Int + ) + + private const val MOBI_COMPRESSION_NONE = 1 + private const val MOBI_COMPRESSION_PALMDOC = 2 + private const val MOBI_COMPRESSION_HUFFCDIC = 17480 + private const val MOBI_NOT_SET = -1 + private const val HUFF_HEADER_LENGTH = 24 + private const val HUFF_RECORD_MIN_SIZE = 2584 + private const val HUFF_CODETABLE_SIZE = 33 + private const val HUFF_CODELEN_MAX = 16 + private const val CDIC_HEADER_LENGTH = 16 + private const val MOBI_HUFFMAN_MAX_DEPTH = 20 + private const val UINT32_MAX = 0xFFFF_FFFFL +}