From 8366d76dcd7bc3367a4caa5eb243a530f7efdd06 Mon Sep 17 00:00:00 2001 From: Aryan Date: Sun, 10 May 2026 10:07:37 +0530 Subject: [PATCH] Windows (#291) * Centralize library management logic and introduce support for plain text and HTML formats * Centralize library management logic and introduce support for plain text and HTML formats * Expand unit test coverage for library state management, UI models, and MainViewModel features. * Add comprehensive unit tests for PDF reader core logic, preferences, and data persistence * Add unit tests for EPUB parsing, content loading, search functionality, and reader JavaScript bridges. * Add unit tests for OPDS parsing and Smart Collection engine, and integrate Kover plugin * Add comprehensive unit tests * Centralize library snapshot serialization in the `shared` module and improve filtering and sorting logic. * Implement text selection, highlighting, and reading state persistence for PDF and EPUB engines in desktop version * Folder import support for desktop app * Introduce Smart Shelves with rule-based filtering in desktop version * Implement shared EPUB annotation serialization and highlight rendering * Centralize file type capabilities and platform-specific support logic * Refactor reader state management to use a central reducer * Implement customizable reader toolbar and advanced formatting settings in shared * Implement locator-based navigation and customizable highlight palette for desktop app * Enhance reader customization and expand search functionality in desktop app * Redesign reader settings and tools into a tabbed control panel in desktop app * Enhance reader navigation and highlight precision in desktop app * Implement bidirectional position synchronization and dynamic highlights in the desktop reader * Implement shared state management and enhanced search for the PDF reader in desktop app * Add vertical scroll support to the desktop PDF reader * Implement ink, text, and eraser annotation support in desktop PDF viewer * Implement PDF bookmarks, Table of Contents, and annotation editing in desktop app * Implement link handling and navigation for PDF and EPUB readers in desktop app * Implement PDF jump history for navigation in desktop app * Enhance PDF ink rendering and annotation capabilities in desktop app * Implement advanced PDF text annotations with inline editing and rich styling in desktop app * Add move handle and movement logic for PDF text annotations in desktop app * Implement local folder synchronization and metadata sidecar support in desktop app * Implement book metadata extraction and drag-and-drop import for Desktop * Implement dynamic and custom app theme management for desktop * Introduce canonical PDF annotation codec and support for multi-segment highlights * Implement rich text editing and pagination support for the PDF reader in desktop app * Improve PDF rich text pagination, synchronization, and observability in desktop * Hide trailing structural page breaks in rich text editor * Implement a unified JVM book loader and expand supported formats on Desktop * Add comic archive support for Desktop and enhance MOBI parsing * Implement shared OPDS catalog support and UI for Android and Desktop * Improve native WebView lifecycle and surface transition management on Desktop * Enable Compose Swing interop blending and simplify Desktop WebView management * Integrate BYOK AI features and Cloud TTS for desktop * Enhance Desktop TTS with streaming audio and improved secure storage for AI key * Implement scoped Cloud TTS with synchronized highlighting for EPUB and PDF in desktop app * Implement custom font management and utility screens in desktop app * Implement PDFium-based PDF annotation export * Remove PdfBox dependency and standardize PDF export via Pdfium * Implement local audio caching and playback controls for Gemini Cloud TTS in desktop app * Implement reader themes and custom texture support in desktop app * Redesign non-reader UI with responsive navigation and enhanced library management in desktop app * Introduce ReaderWorkspaceShell to unify EPUB and PDF reader layouts in desktop app * Exclude manual-only files from automated sync and import * Implement customizable Text-to-Speech (TTS) word replacements * Optimize reader performance with persistent layout caching and decoupled theme rendering * Improve position restoration during reader reconfiguration in epub pagination * Use independent thickness for eraser tool and stylus override --- app/build.gradle.kts | 20 +- app/src/main/cpp/pdfium_bridge.cpp | 1195 +++ .../java/com/aryan/reader/FileTypeResolver.kt | 36 + .../java/com/aryan/reader/FolderSyncWorker.kt | 30 +- .../java/com/aryan/reader/LibraryScreen.kt | 7 +- .../com/aryan/reader/LibraryStateProjector.kt | 4 +- .../java/com/aryan/reader/MainViewModel.kt | 214 +- .../com/aryan/reader/SharedModelMappers.kt | 26 +- .../com/aryan/reader/TtsReplacementStore.kt | 43 + .../aryan/reader/TtsWordReplacementsSheet.kt | 667 ++ .../reader/data/RecentFilesRepository.kt | 37 +- .../reader/data/SmartCollectionEngine.kt | 85 +- .../java/com/aryan/reader/epub/EpubParser.kt | 107 +- .../java/com/aryan/reader/epub/Fb2Parser.kt | 6 +- .../aryan/reader/epub/ImportedFileCache.kt | 10 +- .../java/com/aryan/reader/epub/MobiParser.kt | 6 +- .../java/com/aryan/reader/epub/OdtParser.kt | 6 +- .../aryan/reader/epub/SingleFileImporter.kt | 85 +- .../epubreader/EpubReaderAnnotations.kt | 191 +- .../reader/epubreader/EpubReaderControls.kt | 21 +- .../reader/epubreader/EpubReaderDrawer.kt | 5 +- .../reader/epubreader/EpubReaderScreen.kt | 123 +- .../aryan/reader/epubreader/EpubReaderTts.kt | 30 +- .../java/com/aryan/reader/opds/OpdsModels.kt | 101 +- .../java/com/aryan/reader/opds/OpdsParser.kt | 485 +- .../com/aryan/reader/opds/OpdsRepository.kt | 123 +- .../com/aryan/reader/opds/OpdsViewModel.kt | 66 +- .../AndroidHtmlParserPlatform.kt | 6 +- .../reader/paginatedreader/BookPaginator.kt | 417 +- .../reader/paginatedreader/ContentStyler.kt | 18 +- .../aryan/reader/paginatedreader/Locator.kt | 68 +- .../reader/paginatedreader/PaginatedReader.kt | 159 +- .../PaginatedReaderViewModel.kt | 25 +- .../PaginatedReconfiguration.kt | 6 + .../aryan/reader/paginatedreader/Paginator.kt | 90 +- .../paginatedreader/RenderThemeApplier.kt | 265 + .../paginatedreader/data/BookCacheDatabase.kt | 177 +- .../paginatedreader/data/BookCacheEntities.kt | 121 +- .../data/BookProcessingWorker.kt | 50 +- .../aryan/reader/pdf/NativePdfiumBridge.kt | 32 + .../java/com/aryan/reader/pdf/PdfExporter.kt | 1057 --- .../java/com/aryan/reader/pdf/PdfModels.kt | 2 +- .../com/aryan/reader/pdf/PdfPageComposable.kt | 24 +- .../com/aryan/reader/pdf/PdfPreferences.kt | 9 +- .../java/com/aryan/reader/pdf/PdfToolbars.kt | 10 + .../com/aryan/reader/pdf/PdfVerticalReader.kt | 11 +- .../com/aryan/reader/pdf/PdfViewerScreen.kt | 185 +- .../reader/pdf/PdfiumAnnotationExporter.kt | 768 ++ .../com/aryan/reader/pdf/RichTextSystem.kt | 348 +- .../com/aryan/reader/pdf/UniversalDocument.kt | 12 +- .../com/aryan/reader/tts/TtsController.kt | 2 + .../aryan/reader/tts/TtsPlaybackManager.kt | 40 +- app/src/main/res/values/strings.xml | 2 + .../java/com/aryan/reader/FileHasherTest.kt | 54 + .../com/aryan/reader/FileTypeResolverTest.kt | 23 + .../aryan/reader/LibraryStateProjectorTest.kt | 514 ++ .../com/aryan/reader/MainViewModelTest.kt | 838 +- .../aryan/reader/NonReaderScreenModelsTest.kt | 147 + .../aryan/reader/TtsReplacementChunkTest.kt | 46 + .../reader/data/FolderBookMetadataTest.kt | 90 + .../data/RecentFileDaoReadingPositionTest.kt | 138 + ...ecentFileItemReadingPositionMappingTest.kt | 54 + ...FilesRepositoryReadingPositionMergeTest.kt | 148 + .../reader/data/SmartCollectionEngineTest.kt | 143 + .../aryan/reader/epub/EpubParserUnitTest.kt | 452 + .../reader/epub/ImportedFileCacheTest.kt | 119 + .../reader/epub/SingleFileImporterTest.kt | 146 + .../EpubReaderBridgeAndControlsTest.kt | 200 + .../epubreader/EpubReaderContentTest.kt | 161 + ...EpubReaderPreferencesAndAnnotationsTest.kt | 352 + .../reader/epubreader/EpubReaderSearchTest.kt | 271 + .../epubreader/TestSharedPreferences.kt | 57 + .../com/aryan/reader/opds/OpdsParserTest.kt | 208 + .../aryan/reader/opds/OpdsRepositoryTest.kt | 134 + .../reader/paginatedreader/CfiUtilsTest.kt | 30 + .../paginatedreader/ContentStylerTest.kt | 201 + .../paginatedreader/CssParserThemeModeTest.kt | 51 + .../paginatedreader/LocatorConverterTest.kt | 277 + .../paginatedreader/PageCountEstimatorTest.kt | 81 + .../PaginatedReconfigurationTest.kt | 43 + .../paginatedreader/RenderThemeApplierTest.kt | 75 + .../paginatedreader/data/BookCacheDaoTest.kt | 167 + .../reader/pdf/PdfReaderCoreLogicTest.kt | 229 + .../reader/pdf/PdfReaderPreferencesTest.kt | 219 + .../reader/pdf/PdfReaderRepositoryTest.kt | 165 + .../aryan/reader/pdf/PdfReaderRichTextTest.kt | 240 + .../reader/pdf/PdfReaderSerializerTest.kt | 218 + .../PdfReaderSettingsAndSharedModelsTest.kt | 147 + .../aryan/reader/pdf/PdfTextRepositoryTest.kt | 139 + .../pdf/PdfiumAnnotationExporterTest.kt | 171 + build.gradle.kts | 1 + desktopApp/build.gradle.kts | 8 + .../reader/desktop/DesktopAiByokStore.kt | 527 ++ .../reader/desktop/DesktopByokAiAdapter.kt | 310 + .../reader/desktop/DesktopComicArchive.kt | 586 ++ .../reader/desktop/DesktopCustomFontStore.kt | 143 + .../aryan/reader/desktop/DesktopEpubLoader.kt | 276 +- .../desktop/DesktopFolderMetadataExtractor.kt | 530 ++ .../desktop/DesktopGeminiCloudTtsAdapter.kt | 732 ++ .../reader/desktop/DesktopLibraryDatabase.kt | 192 +- .../reader/desktop/DesktopLocalFolderSync.kt | 573 ++ .../reader/desktop/DesktopOpdsRepository.kt | 190 + .../com/aryan/reader/desktop/DesktopPdfium.kt | 1131 ++- .../com/aryan/reader/desktop/DesktopTtsLog.kt | 19 + .../kotlin/com/aryan/reader/desktop/Main.kt | 7764 ++++++++++++++--- .../desktopMain/resources/google_fonts.json | 2083 +++++ .../resources/textures/classy_fabric.webp | Bin 0 -> 1944 bytes .../resources/textures/ep_naturalblack.webp | Bin 0 -> 19270 bytes .../resources/textures/ep_naturalwhite.webp | Bin 0 -> 15394 bytes .../resources/textures/grey_wash_wall.webp | Bin 0 -> 7450 bytes .../resources/textures/light-veneer.webp | Bin 0 -> 2606 bytes .../resources/textures/retina_wood.webp | Bin 0 -> 9196 bytes .../resources/textures/retro_intro.webp | Bin 0 -> 1620 bytes .../resources/textures/texture_canvas.png | Bin 0 -> 80065 bytes .../resources/textures/texture_eink.webp | Bin 0 -> 15394 bytes .../resources/textures/texture_paper.png | Bin 0 -> 56983 bytes .../resources/textures/texture_slate.png | Bin 0 -> 81533 bytes .../reader/desktop/DesktopAiByokStoreTest.kt | 106 + .../reader/desktop/DesktopComicArchiveTest.kt | 59 + .../desktop/DesktopComposeInteropTest.kt | 55 + .../desktop/DesktopCustomFontStoreTest.kt | 89 + .../DesktopFolderMetadataExtractorTest.kt | 184 + .../desktop/DesktopOpdsRepositoryTest.kt | 56 + shared/build.gradle.kts | 2 + .../reader/shared/LocalFolderSync.android.kt | 8 + .../shared/ui/LocalBookCoverImage.android.kt | 28 + .../aryan/reader/paginatedreader/CssParser.kt | 52 +- .../com/aryan/reader/shared/AppActions.kt | 44 + .../com/aryan/reader/shared/AppModels.kt | 5 +- .../aryan/reader/shared/CustomFontModels.kt | 12 + .../aryan/reader/shared/FileCapabilities.kt | 180 + .../com/aryan/reader/shared/LibraryModels.kt | 13 +- .../aryan/reader/shared/LibraryMutations.kt | 293 + .../aryan/reader/shared/LibraryProjector.kt | 27 +- .../reader/shared/LibraryStateProjector.kt | 49 +- .../aryan/reader/shared/LocalFolderSync.kt | 507 ++ .../reader/shared/ReaderAnnotationModels.kt | 139 +- .../shared/ReaderAnnotationSerializer.kt | 268 + .../reader/shared/ReaderAppearanceModels.kt | 155 +- .../aryan/reader/shared/ReaderExtrasModels.kt | 734 ++ .../reader/shared/ReaderMarkdownModels.kt | 114 + .../reader/shared/ReaderToolbarModels.kt | 91 + .../reader/shared/ReaderTtsReplacements.kt | 353 + .../reader/shared/RepositoryContracts.kt | 5 +- .../aryan/reader/shared/SharedFormatters.kt | 8 + .../reader/shared/SharedLibrarySnapshot.kt | 618 ++ .../com/aryan/reader/shared/SharedReducers.kt | 118 + .../reader/shared/SmartCollectionEngine.kt | 97 + .../reader/shared/opds/SharedOpdsCatalogs.kt | 144 + .../shared/opds/SharedOpdsController.kt | 162 + .../reader/shared/opds/SharedOpdsModels.kt | 130 + .../reader/shared/opds/SharedOpdsUtilities.kt | 206 + .../reader/shared/pdf/PdfInteractionModels.kt | 121 +- .../reader/shared/pdf/PdfReaderSession.kt | 573 ++ .../reader/shared/pdf/PdfSelectionGeometry.kt | 172 + .../reader/shared/pdf/PdfVerticalLayout.kt | 30 + .../pdf/SharedPdfAnnotationSidecarCodec.kt | 415 + .../shared/pdf/SharedPdfInkRendering.kt | 412 + .../reader/shared/pdf/SharedPdfRichText.kt | 1743 ++++ .../shared/pdf/SharedPdfTextAnnotations.kt | 353 + .../reader/shared/reader/ReaderEngine.kt | 723 +- .../reader/ReaderHtmlDocumentBuilder.kt | 1933 +++- .../reader/shared/reader/ReaderModels.kt | 30 +- .../shared/reader/SharedTextBookFactory.kt | 106 + .../reader/shared/reader/SimplePaginator.kt | 4 +- .../reader/shared/ui/LocalBookCoverImage.kt | 11 + .../reader/shared/ui/NonReaderLayoutModels.kt | 147 + .../reader/shared/ui/NonReaderScreens.kt | 1711 +++- .../reader/shared/ui/ReaderWorkspaceModels.kt | 235 + .../reader/shared/ui/ReaderWorkspaceShell.kt | 224 + .../aryan/reader/shared/ui/SharedAppShell.kt | 499 ++ .../shared/ui/SharedAppThemeSettings.kt | 980 +++ .../reader/shared/ui/SharedLibraryDialogs.kt | 242 + .../reader/shared/ui/SharedMarkdownText.kt | 194 + .../reader/shared/ui/SharedOpdsScreen.kt | 841 ++ .../reader/shared/ui/SharedPdfAnnotationUi.kt | 1404 +++ .../reader/shared/ui/SharedPdfRichTextUi.kt | 261 + .../reader/shared/ui/SharedReaderChrome.kt | 2345 +++++ .../reader/shared/ui/SharedUtilityScreens.kt | 600 ++ .../shared/EpubAnnotationSerializerTest.kt | 176 + .../reader/shared/FileCapabilitiesTest.kt | 78 + .../shared/LocalFolderSyncEngineTest.kt | 285 + .../reader/shared/ReaderActionReducerTest.kt | 327 + .../shared/ReaderAppearanceModelsTest.kt | 56 + .../reader/shared/ReaderExtrasModelsTest.kt | 287 + .../reader/shared/ReaderMarkdownParserTest.kt | 31 + .../shared/ReaderToolbarPreferencesTest.kt | 51 + .../shared/ReaderTtsReplacementEngineTest.kt | 156 + .../shared/SharedAppThemeReducerTest.kt | 61 + .../reader/shared/SharedLibraryEditorTest.kt | 229 + .../shared/SharedLibraryProjectorTest.kt | 373 + .../shared/SharedLibrarySnapshotJsonTest.kt | 204 + .../shared/SmartCollectionEngineTest.kt | 142 + .../shared/opds/SharedOpdsCatalogsTest.kt | 107 + .../reader/shared/pdf/PdfReaderSessionTest.kt | 308 + .../shared/pdf/PdfSelectionGeometryTest.kt | 67 + .../pdf/SharedPdfAnnotationSerializerTest.kt | 216 + .../shared/pdf/SharedPdfInkRenderingTest.kt | 114 + .../shared/pdf/SharedPdfRichTextTest.kt | 246 + .../pdf/SharedPdfTextAnnotationsTest.kt | 222 + .../reader/shared/reader/ReaderEngineTest.kt | 190 + .../reader/ReaderHtmlDocumentBuilderTest.kt | 377 + .../shared/ui/NonReaderLayoutModelsTest.kt | 166 + .../shared/ui/ReaderWorkspaceModelsTest.kt | 166 + .../shared/ui/SharedAppThemeColorMathTest.kt | 56 + .../reader/shared/LocalFolderSync.desktop.kt | 8 + .../shared/ui/LocalBookCoverImage.desktop.kt | 36 + .../shared/ReaderTtsFileCacheManagerTest.kt | 49 + .../shared/opds/SharedOpdsParserTest.kt | 181 + .../shared/reader/SharedJvmBookLoaderTest.kt | 201 + .../reader/paginatedreader/HtmlParser.kt | 22 +- .../shared/ReaderTtsFileCacheManager.kt | 177 + .../reader/shared/opds/SharedOpdsParser.kt | 447 + .../shared/reader/SharedJvmBookLoader.kt | 1396 +++ 214 files changed, 53372 insertions(+), 4702 deletions(-) create mode 100644 app/src/main/java/com/aryan/reader/TtsReplacementStore.kt create mode 100644 app/src/main/java/com/aryan/reader/TtsWordReplacementsSheet.kt create mode 100644 app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReconfiguration.kt create mode 100644 app/src/main/java/com/aryan/reader/paginatedreader/RenderThemeApplier.kt create mode 100644 app/src/main/java/com/aryan/reader/pdf/PdfiumAnnotationExporter.kt create mode 100644 app/src/test/java/com/aryan/reader/FileHasherTest.kt create mode 100644 app/src/test/java/com/aryan/reader/LibraryStateProjectorTest.kt create mode 100644 app/src/test/java/com/aryan/reader/NonReaderScreenModelsTest.kt create mode 100644 app/src/test/java/com/aryan/reader/TtsReplacementChunkTest.kt create mode 100644 app/src/test/java/com/aryan/reader/data/FolderBookMetadataTest.kt create mode 100644 app/src/test/java/com/aryan/reader/data/RecentFileDaoReadingPositionTest.kt create mode 100644 app/src/test/java/com/aryan/reader/data/RecentFileItemReadingPositionMappingTest.kt create mode 100644 app/src/test/java/com/aryan/reader/data/RecentFilesRepositoryReadingPositionMergeTest.kt create mode 100644 app/src/test/java/com/aryan/reader/data/SmartCollectionEngineTest.kt create mode 100644 app/src/test/java/com/aryan/reader/epub/EpubParserUnitTest.kt create mode 100644 app/src/test/java/com/aryan/reader/epub/ImportedFileCacheTest.kt create mode 100644 app/src/test/java/com/aryan/reader/epub/SingleFileImporterTest.kt create mode 100644 app/src/test/java/com/aryan/reader/epubreader/EpubReaderBridgeAndControlsTest.kt create mode 100644 app/src/test/java/com/aryan/reader/epubreader/EpubReaderContentTest.kt create mode 100644 app/src/test/java/com/aryan/reader/epubreader/EpubReaderPreferencesAndAnnotationsTest.kt create mode 100644 app/src/test/java/com/aryan/reader/epubreader/EpubReaderSearchTest.kt create mode 100644 app/src/test/java/com/aryan/reader/epubreader/TestSharedPreferences.kt create mode 100644 app/src/test/java/com/aryan/reader/opds/OpdsParserTest.kt create mode 100644 app/src/test/java/com/aryan/reader/opds/OpdsRepositoryTest.kt create mode 100644 app/src/test/java/com/aryan/reader/paginatedreader/CfiUtilsTest.kt create mode 100644 app/src/test/java/com/aryan/reader/paginatedreader/ContentStylerTest.kt create mode 100644 app/src/test/java/com/aryan/reader/paginatedreader/CssParserThemeModeTest.kt create mode 100644 app/src/test/java/com/aryan/reader/paginatedreader/LocatorConverterTest.kt create mode 100644 app/src/test/java/com/aryan/reader/paginatedreader/PageCountEstimatorTest.kt create mode 100644 app/src/test/java/com/aryan/reader/paginatedreader/PaginatedReconfigurationTest.kt create mode 100644 app/src/test/java/com/aryan/reader/paginatedreader/RenderThemeApplierTest.kt create mode 100644 app/src/test/java/com/aryan/reader/paginatedreader/data/BookCacheDaoTest.kt create mode 100644 app/src/test/java/com/aryan/reader/pdf/PdfReaderCoreLogicTest.kt create mode 100644 app/src/test/java/com/aryan/reader/pdf/PdfReaderPreferencesTest.kt create mode 100644 app/src/test/java/com/aryan/reader/pdf/PdfReaderRepositoryTest.kt create mode 100644 app/src/test/java/com/aryan/reader/pdf/PdfReaderRichTextTest.kt create mode 100644 app/src/test/java/com/aryan/reader/pdf/PdfReaderSerializerTest.kt create mode 100644 app/src/test/java/com/aryan/reader/pdf/PdfReaderSettingsAndSharedModelsTest.kt create mode 100644 app/src/test/java/com/aryan/reader/pdf/PdfTextRepositoryTest.kt create mode 100644 app/src/test/java/com/aryan/reader/pdf/PdfiumAnnotationExporterTest.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopAiByokStore.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopByokAiAdapter.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopComicArchive.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopCustomFontStore.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopFolderMetadataExtractor.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopGeminiCloudTtsAdapter.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopLocalFolderSync.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopOpdsRepository.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopTtsLog.kt create mode 100644 desktopApp/src/desktopMain/resources/google_fonts.json create mode 100644 desktopApp/src/desktopMain/resources/textures/classy_fabric.webp create mode 100644 desktopApp/src/desktopMain/resources/textures/ep_naturalblack.webp create mode 100644 desktopApp/src/desktopMain/resources/textures/ep_naturalwhite.webp create mode 100644 desktopApp/src/desktopMain/resources/textures/grey_wash_wall.webp create mode 100644 desktopApp/src/desktopMain/resources/textures/light-veneer.webp create mode 100644 desktopApp/src/desktopMain/resources/textures/retina_wood.webp create mode 100644 desktopApp/src/desktopMain/resources/textures/retro_intro.webp create mode 100644 desktopApp/src/desktopMain/resources/textures/texture_canvas.png create mode 100644 desktopApp/src/desktopMain/resources/textures/texture_eink.webp create mode 100644 desktopApp/src/desktopMain/resources/textures/texture_paper.png create mode 100644 desktopApp/src/desktopMain/resources/textures/texture_slate.png create mode 100644 desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopAiByokStoreTest.kt create mode 100644 desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopComicArchiveTest.kt create mode 100644 desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopComposeInteropTest.kt create mode 100644 desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopCustomFontStoreTest.kt create mode 100644 desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopFolderMetadataExtractorTest.kt create mode 100644 desktopApp/src/desktopTest/kotlin/com/aryan/reader/desktop/DesktopOpdsRepositoryTest.kt create mode 100644 shared/src/androidMain/kotlin/com/aryan/reader/shared/LocalFolderSync.android.kt create mode 100644 shared/src/androidMain/kotlin/com/aryan/reader/shared/ui/LocalBookCoverImage.android.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/CustomFontModels.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/FileCapabilities.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/LibraryMutations.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/LocalFolderSync.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderAnnotationSerializer.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderExtrasModels.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderMarkdownModels.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderToolbarModels.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ReaderTtsReplacements.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/SharedLibrarySnapshot.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/SmartCollectionEngine.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/opds/SharedOpdsCatalogs.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/opds/SharedOpdsController.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/opds/SharedOpdsModels.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/opds/SharedOpdsUtilities.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfReaderSession.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfSelectionGeometry.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/PdfVerticalLayout.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfAnnotationSidecarCodec.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfInkRendering.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfRichText.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/pdf/SharedPdfTextAnnotations.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/reader/SharedTextBookFactory.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/LocalBookCoverImage.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/NonReaderLayoutModels.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/ReaderWorkspaceModels.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/ReaderWorkspaceShell.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedAppShell.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedAppThemeSettings.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedLibraryDialogs.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedMarkdownText.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedOpdsScreen.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedPdfAnnotationUi.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedPdfRichTextUi.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedReaderChrome.kt create mode 100644 shared/src/commonMain/kotlin/com/aryan/reader/shared/ui/SharedUtilityScreens.kt create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/EpubAnnotationSerializerTest.kt create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/FileCapabilitiesTest.kt create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/LocalFolderSyncEngineTest.kt create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/ReaderActionReducerTest.kt create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/ReaderAppearanceModelsTest.kt create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/ReaderExtrasModelsTest.kt create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/ReaderMarkdownParserTest.kt create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/ReaderToolbarPreferencesTest.kt create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/ReaderTtsReplacementEngineTest.kt create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/SharedAppThemeReducerTest.kt create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/SharedLibraryEditorTest.kt create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/SharedLibraryProjectorTest.kt create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/SharedLibrarySnapshotJsonTest.kt create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/SmartCollectionEngineTest.kt create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/opds/SharedOpdsCatalogsTest.kt create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/pdf/PdfReaderSessionTest.kt create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/pdf/PdfSelectionGeometryTest.kt create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/pdf/SharedPdfAnnotationSerializerTest.kt create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/pdf/SharedPdfInkRenderingTest.kt create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/pdf/SharedPdfRichTextTest.kt create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/pdf/SharedPdfTextAnnotationsTest.kt create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/reader/ReaderEngineTest.kt create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/reader/ReaderHtmlDocumentBuilderTest.kt create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/ui/NonReaderLayoutModelsTest.kt create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/ui/ReaderWorkspaceModelsTest.kt create mode 100644 shared/src/commonTest/kotlin/com/aryan/reader/shared/ui/SharedAppThemeColorMathTest.kt create mode 100644 shared/src/desktopMain/kotlin/com/aryan/reader/shared/LocalFolderSync.desktop.kt create mode 100644 shared/src/desktopMain/kotlin/com/aryan/reader/shared/ui/LocalBookCoverImage.desktop.kt create mode 100644 shared/src/desktopTest/kotlin/com/aryan/reader/shared/ReaderTtsFileCacheManagerTest.kt create mode 100644 shared/src/desktopTest/kotlin/com/aryan/reader/shared/opds/SharedOpdsParserTest.kt create mode 100644 shared/src/desktopTest/kotlin/com/aryan/reader/shared/reader/SharedJvmBookLoaderTest.kt create mode 100644 shared/src/readerJvmMain/kotlin/com/aryan/reader/shared/ReaderTtsFileCacheManager.kt create mode 100644 shared/src/readerJvmMain/kotlin/com/aryan/reader/shared/opds/SharedOpdsParser.kt create mode 100644 shared/src/readerJvmMain/kotlin/com/aryan/reader/shared/reader/SharedJvmBookLoader.kt 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 0000000000000000000000000000000000000000..40c01e99c4a9d0c26c9384e3e126231865486142 GIT binary patch literal 1944 zcmV;J2WR+FNk&GH2LJ$9MM6+kP&goj2LJ$28UUREDrNv?06s}1jz%J)At4~DP*4d8 zX-pfTbW#tHP5peo{SYz%4F8kJ5bnRlc^LY~`yPrNuK&aH5z;408DU3LyKnG~G4^d} zS2OHa{BAgRJ{bXy-We zG}tdI#9++*5Mh;Q6uXHvSz|_RL;xUI_0I`hQ7*OlvuaCYu$#%xrN||&mkCnhzDd;0 zC?b96@+Bg-lqsx(2THIC24x1h42aF1qt?^@`drIJwUz-)lYjvJ`BJ?c15%`bVK%JF zBK)j>(e*mSj7<91)`}C-Rc2Z4&4Vwrp?x`>IFz>xG?*~JInqK%bwxlxIGy+uog4*A zF?Ce5Mtv)C$(BiM9ShAd0uo=&Or8G~2=K;Sx^UtrVWo?mH#c|DzQLIxWg343ddLk4 z4xC6gi4vq2#>t|*dRx9zjP+snKJ&uPHwvs&jU^sp0e#;bSR=jyH1(t09Afx@&lAQ< z#bG78d#=}~kHg=~5T+P92@5mg60J9kVR9z3PLbd8~*X^ zMcvlQt>amMm5iVz3Y*$lyC{-i?>9`Y!=K;s zOBVW2$zs+Ou)~a-em0mL;p!ZWMp;W;juKXWLC<3Yns#mP(-*>Fyf#P_TE+EYK@*i- z>vqP|*R0LP)FaWqU2eOLNhtstoom-6W^_S@e_tY*dC6j zAzNTjB6SoJMgby@m-y!FdVQ%@Izf!{Mpm0kTwaj82gqCgQcgS6=Mk67NAzM*l7B}8 zHNpDca>-8y#wMTgg7V2NB$2#Dyl^Fhrasr*D-7RImxJ|O)h48X zRaPhe1K1h!bQ6&frwEz)&WR zoG#oYDk!=LE*iP{F#ZT!$N?ETt1&x|bKE=ixf}*$tAzlSwX zqe|;?xT=_^4GFxRdP(zuat!-EH!kl`y88fU2u=!LMC4<>l@P(%fx&6|Ye@V{3GPqT z1XRf$Gl#1_L;uqK#q$LZeGuCcU{LPCqO(To^75)0qJFBJ5H-OymjcVJ-Nc#&vo3vQ z!0+3cv*COIP1iw`G41beb3FerrQET;ZsSD`3n+CbW-*>n&4#?xU4KBDqpz6&# zqZ#HOe2_?MGpNj>gLb5O+PU$Vs+7uQw^QAhJ7Eg*$pQ@V=FF*Cp)B>eVhEY=FjOl3 ztuf4NEFN+~=Oa}$age`_GGT^30A(zT(7xcz2z=PLLC$8<)L(6r+h0?oiU*rg&)cw7 zz3~vP<);itJeuVKV{jFG8r*YG*Cb(cc-kVIK5xgm%85~rjE}TsU1h4N>kqA@Gd@MB z4wd!`Y6)h%C1$4oWaKFJH7hzz67i}Cf;UGab=8~uA%ETV+6|3qp}^f@_=r;l6}osL z|3t*mo`4?X_hY^2)G@SYosDV2fsVj*fOo?sTHX8Cf|0-hI+vQ3&w=Ot=t!uvG=;xc e7g&mCD{SwJ3GmKjj?82GrttQja%{)~27my4kGk{# literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..eeda652ce9327033e73771a1b74017666b73f112 GIT binary patch literal 19270 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..050f115dfc6e9f5454bf3194c869097ba1100380 GIT binary patch literal 15394 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πrtz_XRejDELRE-a)?zPT&J;-8eWA^ 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..050f115dfc6e9f5454bf3194c869097ba1100380 GIT binary patch literal 15394 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πrtz_XRejDELRE-a)?zPT&J;-8eWA^ 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

X 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�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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9fddee646a4cabf8332d4ed11d8ffac62fc64703 GIT binary patch literal 81533 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 + 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 +}