From 0d9439e8c3bee1d1810dd1c531670b3b628327c0 Mon Sep 17 00:00:00 2001 From: Atte149 Date: Wed, 24 Jun 2026 10:36:09 +0300 Subject: [PATCH] =?UTF-8?q?feat(build):=20KMP=20=E2=86=92=20Android-only?= =?UTF-8?q?=20migration=20(Phase=201.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete desktopApp/ and shared/ modules. - Merge shared commonMain + androidMain + readerJvmMain sources into app/src/main/kotlin. - Resolve expect/actual declarations by keeping android implementations. - Remove project(':shared') dependency and KMP plugins from build files. - Simplify settings.gradle.kts to single :app module; root project renamed BookReader. - Remove JVM-only code (javax.imageio/ImageIO in SharedJvmBookLoader). - Make LocalFolderSync helpers public for cross-package use in Android module. - Baseline ':app:assembleOssDebug' passes (APK ~80 MB). --- app/build.gradle.kts | 2 - .../androidx/compose/material/icons/Icons.kt | 0 .../filled/AutoMirroredFilledIcons.kt | 0 .../material/icons/filled/FilledIcons.kt | 0 .../material/icons/outlined/OutlinedIcons.kt | 0 .../reader/paginatedreader/CssParser.kt | 0 .../paginatedreader/FontFamilyMapper.kt | 0 .../reader/paginatedreader/HtmlParser.kt | 0 .../paginatedreader/PaginatedReaderData.kt | 0 .../reader/paginatedreader/SemanticModel.kt | 0 .../reader/paginatedreader/StyleUtils.kt | 0 .../paginatedreader/UserAgentStylesheet.kt | 0 .../serialization/ComposeTypeSerializers.kt | 0 .../bookreader/reader/shared/AppActions.kt | 0 .../bookreader/reader/shared/AppModels.kt | 0 .../reader/shared/CloudSyncDecisions.kt | 0 .../reader/shared/CustomFontModels.kt | 0 .../reader/shared/FileCapabilities.kt | 0 .../reader/shared/FontVariantInference.kt | 0 .../reader/shared/ImportContracts.kt | 0 .../bookreader/reader/shared/LibraryModels.kt | 0 .../reader/shared/LibraryMutations.kt | 0 .../reader/shared/LibraryProjector.kt | 2 - .../reader/shared/LibraryStateProjector.kt | 0 .../reader/shared/LocalFolderSync.kt | 5 +- .../reader/shared/PdfReaderModels.kt | 0 .../bookreader/reader/shared/Platform.kt | 3 + .../reader/shared/ReaderAnnotationModels.kt | 0 .../shared/ReaderAnnotationSerializer.kt | 0 .../reader/shared/ReaderAppearanceModels.kt | 0 .../reader/shared/ReaderExtrasModels.kt | 0 .../reader/shared/ReaderMarkdownModels.kt | 0 .../reader/shared/ReaderSearchModels.kt | 0 .../reader/shared/ReaderToolbarModels.kt | 0 .../shared/ReaderTtsFileCacheManager.kt | 0 .../reader/shared/ReaderTtsReplacements.kt | 0 .../reader/shared/ReaderWordReplacements.kt | 0 .../reader/shared/RepositoryContracts.kt | 0 .../bookreader/reader/shared/SampleLibrary.kt | 0 .../reader/shared/ScreenProjectors.kt | 0 .../reader/shared/SettingsHubModels.kt | 0 .../reader/shared/SharedFeaturePolicy.kt | 0 .../reader/shared/SharedFormatters.kt | 0 .../reader/shared/SharedLegalLinks.kt | 0 .../reader/shared/SharedLibrarySnapshot.kt | 0 .../reader/shared/SharedReducers.kt | 0 .../reader/shared/SmartCollectionEngine.kt | 0 .../reader/shared/opds/SharedOpdsCatalogs.kt | 0 .../shared/opds/SharedOpdsController.kt | 0 .../reader/shared/opds/SharedOpdsModels.kt | 0 .../reader/shared/opds/SharedOpdsParser.kt | 0 .../reader/shared/opds/SharedOpdsUtilities.kt | 0 .../reader/shared/pdf/PdfInteractionModels.kt | 0 .../reader/shared/pdf/PdfReaderSession.kt | 0 .../reader/shared/pdf/PdfSelectionGeometry.kt | 0 .../reader/shared/pdf/PdfSpreadLayout.kt | 0 .../reader/shared/pdf/PdfVerticalLayout.kt | 0 .../reader/shared/pdf/PdfiumBridge.kt | 0 .../pdf/SharedPdfAnnotationExportMapper.kt | 0 .../pdf/SharedPdfAnnotationSidecarCodec.kt | 0 .../shared/pdf/SharedPdfInkRendering.kt | 0 .../reader/shared/pdf/SharedPdfReflow.kt | 0 .../reader/shared/pdf/SharedPdfRichText.kt | 0 .../shared/pdf/SharedPdfTextAnnotations.kt | 0 .../reader/shared/pptx/SharedPptxDocument.kt | 0 .../reader/shared/reader/ReaderEngine.kt | 0 .../reader/ReaderHtmlDocumentBuilder.kt | 0 .../reader/shared/reader/ReaderImageModels.kt | 0 .../reader/shared/reader/ReaderJumpHistory.kt | 0 .../reader/shared/reader/ReaderModels.kt | 0 .../shared/reader/SharedEpubMetadataEditor.kt | 0 .../reader/SharedEpubPaginationCache.kt | 0 .../shared/reader/SharedJvmBookLoadCache.kt | 0 .../shared/reader/SharedJvmLruMemoryCache.kt | 0 .../shared/reader/SharedJvmUserDirectories.kt | 0 .../reader/SharedMeasuredEpubPaginator.kt | 0 .../shared/reader/SharedReaderDiagnostics.kt | 27 + .../reader/SharedReaderTextAlignment.kt | 0 .../shared/reader/SharedTextBookFactory.kt | 0 .../reader/shared/reader/SimplePaginator.kt | 0 .../reader/shared/ui/LocalBookCoverImage.kt | 2 +- .../reader/shared/ui/NonReaderLayoutModels.kt | 0 .../reader/shared/ui/NonReaderScreens.kt | 0 .../shared/ui/ReaderContentRenderPlan.kt | 0 .../reader/shared/ui/ReaderMinimalSlider.kt | 0 .../reader/shared/ui/ReaderTooltips.kt | 0 .../reader/shared/ui/ReaderWorkspaceModels.kt | 0 .../reader/shared/ui/ReaderWorkspaceShell.kt | 0 .../reader/shared/ui/SharedAppShell.kt | 0 .../shared/ui/SharedAppThemeSettings.kt | 0 .../reader/shared/ui/SharedLibraryDialogs.kt | 0 .../reader/shared/ui/SharedMarkdownText.kt | 0 .../shared/ui/SharedNativePaginatedReader.kt | 0 .../reader/shared/ui/SharedOpdsScreen.kt | 0 .../reader/shared/ui/SharedPdfAnnotationUi.kt | 0 .../reader/shared/ui/SharedPdfRichTextUi.kt | 0 .../reader/shared/ui/SharedReaderChrome.kt | 0 .../shared/ui/SharedReaderModalLayer.kt | 24 +- .../shared/ui/SharedReaderScrollbars.kt | 0 .../ui/SharedReaderTtsOverlayControls.kt | 0 .../shared/ui/SharedSelectionMenuPlacement.kt | 0 .../reader/shared/ui/SharedSettingsHub.kt | 0 .../shared/ui/SharedStableTextFields.kt | 0 .../reader/shared/ui/SharedStrings.kt | 0 .../reader/shared/ui/SharedUiTokens.kt | 0 .../reader/shared/ui/SharedUtilityScreens.kt | 0 .../reader/shared/CloudSyncDecisionsTest.kt | 0 .../shared/EpubAnnotationSerializerTest.kt | 0 .../reader/shared/FileCapabilitiesTest.kt | 0 .../reader/shared/FontVariantInferenceTest.kt | 0 .../shared/LocalFolderSyncEngineTest.kt | 0 .../reader/shared/ReaderActionReducerTest.kt | 0 .../shared/ReaderAppearanceModelsTest.kt | 0 .../shared/ReaderBookReplacementEngineTest.kt | 0 .../shared/ReaderDefaultSettingsStateTest.kt | 0 .../reader/shared/ReaderExtrasModelsTest.kt | 0 .../reader/shared/ReaderMarkdownParserTest.kt | 0 .../shared/ReaderToolbarPreferencesTest.kt | 0 .../shared/ReaderTtsReplacementEngineTest.kt | 0 .../reader/shared/SettingsHubModelsTest.kt | 0 .../shared/SharedAppThemeReducerTest.kt | 0 .../reader/shared/SharedImportPlannerTest.kt | 0 .../reader/shared/SharedLegalLinksTest.kt | 0 .../reader/shared/SharedLibraryEditorTest.kt | 0 .../shared/SharedLibraryProjectorTest.kt | 0 .../shared/SharedLibrarySnapshotJsonTest.kt | 0 .../reader/shared/SharedReducersTest.kt | 0 .../shared/SmartCollectionEngineTest.kt | 0 .../shared/opds/SharedOpdsCatalogsTest.kt | 0 .../shared/opds/SharedOpdsControllerTest.kt | 0 .../reader/shared/pdf/PdfReaderSessionTest.kt | 0 .../shared/pdf/PdfSelectionGeometryTest.kt | 0 .../reader/shared/pdf/PdfSpreadLayoutTest.kt | 0 .../pdf/SharedPdfAnnotationCommentsTest.kt | 0 .../SharedPdfAnnotationExportMapperTest.kt | 0 .../pdf/SharedPdfAnnotationSerializerTest.kt | 0 .../shared/pdf/SharedPdfInkRenderingTest.kt | 0 .../reader/shared/pdf/SharedPdfReflowTest.kt | 0 .../shared/pdf/SharedPdfRichTextTest.kt | 0 .../pdf/SharedPdfTextAnnotationsTest.kt | 0 .../reader/shared/reader/ReaderEngineTest.kt | 0 .../reader/ReaderHtmlDocumentBuilderTest.kt | 0 .../shared/reader/ReaderImageModelsTest.kt | 0 .../shared/reader/ReaderJumpHistoryTest.kt | 0 .../shared/reader/ReaderSpreadLayoutTest.kt | 0 .../shared/ui/NonReaderLayoutModelsTest.kt | 0 .../shared/ui/ReaderMinimalSliderTest.kt | 0 .../shared/ui/ReaderWorkspaceModelsTest.kt | 0 .../shared/ui/SharedAppThemeColorMathTest.kt | 0 ...redNativePaginatedReaderInteractionTest.kt | 0 .../ui/SharedNativeVerticalReaderFlowTest.kt | 0 .../shared/ui/SharedPdfAnnotationUiTest.kt | 0 .../shared/ui/SharedReaderModalSizingTest.kt | 0 .../ui/SharedSelectionMenuPlacementTest.kt | 0 .../reader/shared/ui/SharedStringsTest.kt | 0 build.gradle.kts | 35 +- desktopApp/build.gradle.kts | 1621 ------ desktopApp/compose-desktop.pro | 26 - desktopApp/packaging/README.md | 229 - .../DesktopAccountProfileRepository.kt | 138 - .../reader/desktop/DesktopAiByokStore.kt | 1023 ---- .../bookreader/reader/desktop/DesktopAiHub.kt | 338 -- .../reader/desktop/DesktopAppHost.kt | 703 --- .../reader/desktop/DesktopAppState.kt | 159 - .../reader/desktop/DesktopAtomicFile.kt | 56 - .../reader/desktop/DesktopBookImporter.kt | 109 - .../reader/desktop/DesktopBuildProfile.kt | 85 - .../reader/desktop/DesktopByokAiAdapter.kt | 342 -- .../reader/desktop/DesktopCloudConfig.kt | 85 - .../desktop/DesktopCloudRepositories.kt | 683 --- .../reader/desktop/DesktopCloudSidecarSync.kt | 311 - .../reader/desktop/DesktopCloudSync.kt | 1204 ---- .../desktop/DesktopCloudSyncDiagnostics.kt | 82 - .../desktop/DesktopCloudSyncSettingsStore.kt | 60 - .../reader/desktop/DesktopComicArchive.kt | 614 -- .../reader/desktop/DesktopCustomFontStore.kt | 162 - .../reader/desktop/DesktopDiagnostics.kt | 40 - .../desktop/DesktopEpubBridgeParsing.kt | 373 -- .../reader/desktop/DesktopEpubLoader.kt | 12 - .../reader/desktop/DesktopEpubPagination.kt | 105 - .../reader/desktop/DesktopEpubWebView.kt | 279 - .../reader/desktop/DesktopExternalLinks.kt | 159 - .../desktop/DesktopFeatureNoticePlacement.kt | 13 - .../reader/desktop/DesktopFileDialogs.kt | 119 - .../reader/desktop/DesktopFileDropTarget.kt | 242 - .../desktop/DesktopFirebaseAuthRepository.kt | 503 -- .../desktop/DesktopFolderMetadataExtractor.kt | 664 --- .../desktop/DesktopFolderSyncFeedback.kt | 16 - .../reader/desktop/DesktopFolderSyncLog.kt | 19 - .../desktop/DesktopGeminiCloudTtsAdapter.kt | 829 --- .../reader/desktop/DesktopLanguageSettings.kt | 163 - .../reader/desktop/DesktopLibraryDatabase.kt | 47 - .../reader/desktop/DesktopLibraryUi.kt | 493 -- .../reader/desktop/DesktopLocalFolderSync.kt | 951 --- .../reader/desktop/DesktopLogFormatting.kt | 8 - .../reader/desktop/DesktopOpdsCoverImage.kt | 106 - .../reader/desktop/DesktopOpdsRepository.kt | 288 - .../reader/desktop/DesktopPaidAiAdapter.kt | 341 -- .../reader/desktop/DesktopPdfAnnotationUi.kt | 880 --- .../reader/desktop/DesktopPdfAppearance.kt | 218 - .../reader/desktop/DesktopPdfChromeUi.kt | 593 -- .../reader/desktop/DesktopPdfFileActions.kt | 500 -- .../reader/desktop/DesktopPdfInspectorUi.kt | 444 -- .../reader/desktop/DesktopPdfKeyCommands.kt | 97 - .../reader/desktop/DesktopPdfNavigationUi.kt | 786 --- .../reader/desktop/DesktopPdfPage.kt | 988 ---- .../desktop/DesktopPdfPageInteractions.kt | 446 -- .../desktop/DesktopPdfPasswordDialog.kt | 82 - .../reader/desktop/DesktopPdfReaderScreen.kt | 4376 -------------- .../reader/desktop/DesktopPdfReflow.kt | 95 - .../reader/desktop/DesktopPdfScrubbing.kt | 28 - .../reader/desktop/DesktopPdfSelectionUi.kt | 543 -- .../desktop/DesktopPdfSidecarEffects.kt | 215 - .../reader/desktop/DesktopPdfSidecars.kt | 132 - .../reader/desktop/DesktopPdfSyncSidecars.kt | 156 - .../reader/desktop/DesktopPdfTheme.kt | 56 - .../reader/desktop/DesktopPdfZoom.kt | 644 --- .../reader/desktop/DesktopPdfium.kt | 2420 -------- .../reader/desktop/DesktopPlatformPaths.kt | 138 - .../reader/desktop/DesktopPptxDocument.kt | 648 --- .../reader/desktop/DesktopProScreen.kt | 160 - .../reader/desktop/DesktopProfileAvatar.kt | 114 - .../reader/desktop/DesktopReaderDefaults.kt | 91 - .../desktop/DesktopReaderDiagnostics.kt | 146 - .../reader/desktop/DesktopReaderOpenTrace.kt | 29 - .../reader/desktop/DesktopReaderOpening.kt | 44 - .../reader/desktop/DesktopReaderPanels.kt | 450 -- .../reader/desktop/DesktopReaderScreen.kt | 1088 ---- .../desktop/DesktopReaderTexturePreview.kt | 43 - .../reader/desktop/DesktopReaderTextures.kt | 98 - .../reader/desktop/DesktopReaderTypography.kt | 105 - .../desktop/DesktopReaderWindowState.kt | 190 - .../reader/desktop/DesktopStartupSplash.kt | 163 - .../reader/desktop/DesktopStringResources.kt | 250 - .../desktop/DesktopSummaryCacheStore.kt | 88 - .../reader/desktop/DesktopTtsLog.kt | 40 - .../reader/desktop/DesktopWindowPolish.kt | 291 - .../reader/desktop/DesktopWindowStateStore.kt | 159 - .../DesktopWindowsWebView2EpubWebView.kt | 1910 ------ .../bookreader/reader/desktop/Launcher.kt | 6 - .../bookreader/reader/desktop/Main.kt | 5152 ----------------- .../src/desktopMain/resources/episteme.ico | Bin 15057 -> 0 bytes .../desktopMain/resources/episteme_icon.png | Bin 12890 -> 0 bytes .../desktopMain/resources/google_fonts.json | 2083 ------- .../resources/textures/classy_fabric.webp | Bin 1944 -> 0 bytes .../resources/textures/ep_naturalblack.webp | Bin 19270 -> 0 bytes .../resources/textures/ep_naturalwhite.webp | Bin 15394 -> 0 bytes .../resources/textures/grey_wash_wall.webp | Bin 7450 -> 0 bytes .../resources/textures/light-veneer.webp | Bin 2606 -> 0 bytes .../resources/textures/retina_wood.webp | Bin 9196 -> 0 bytes .../resources/textures/retro_intro.webp | Bin 1620 -> 0 bytes .../resources/textures/texture_canvas.png | Bin 80065 -> 0 bytes .../resources/textures/texture_eink.webp | Bin 15394 -> 0 bytes .../resources/textures/texture_paper.png | Bin 56983 -> 0 bytes .../resources/textures/texture_slate.png | Bin 81533 -> 0 bytes .../reader/desktop/DesktopAiByokStoreTest.kt | 173 - .../DesktopAurPackagingMetadataTest.kt | 32 - .../reader/desktop/DesktopAuthStoreTest.kt | 108 - .../reader/desktop/DesktopBookImporterTest.kt | 76 - .../reader/desktop/DesktopBuildProfileTest.kt | 175 - .../reader/desktop/DesktopCloudConfigTest.kt | 60 - .../desktop/DesktopCloudSyncMappingTest.kt | 391 -- .../reader/desktop/DesktopComicArchiveTest.kt | 115 - .../desktop/DesktopComposeInteropTest.kt | 62 - .../desktop/DesktopCustomFontStoreTest.kt | 104 - .../desktop/DesktopEpubBridgeParsingTest.kt | 112 - .../desktop/DesktopEpubPaginationTest.kt | 119 - .../DesktopFeatureNoticePlacementTest.kt | 24 - .../DesktopFolderMetadataExtractorTest.kt | 224 - .../desktop/DesktopLibraryDatabaseTest.kt | 55 - .../desktop/DesktopLocalFolderSyncTest.kt | 171 - .../desktop/DesktopOpdsRepositoryTest.kt | 118 - .../reader/desktop/DesktopPaidAiUsageTest.kt | 15 - .../desktop/DesktopPdfFileActionsTest.kt | 138 - .../DesktopPdfNavigationSidebarTest.kt | 56 - .../reader/desktop/DesktopPdfReflowTest.kt | 93 - .../reader/desktop/DesktopPdfScrubbingTest.kt | 60 - .../reader/desktop/DesktopPdfSidecarsTest.kt | 16 - .../DesktopPdfTextHighlightStateTest.kt | 69 - .../reader/desktop/DesktopPdfThemeTest.kt | 84 - .../desktop/DesktopPlatformPathsTest.kt | 70 - .../reader/desktop/DesktopPptxDocumentTest.kt | 154 - .../desktop/DesktopReaderDefaultsTest.kt | 537 -- .../desktop/DesktopReaderKeyCommandsTest.kt | 174 - .../desktop/DesktopReaderTypographyTest.kt | 69 - .../desktop/DesktopReaderWindowStateTest.kt | 141 - .../reader/desktop/DesktopStartupTest.kt | 231 - .../desktop/DesktopStringResourcesTest.kt | 162 - .../desktop/DesktopSummaryCacheStoreTest.kt | 48 - .../reader/desktop/DesktopTtsLogTest.kt | 18 - .../desktop/DesktopWebView2LayoutTest.kt | 44 - .../reader/desktop/DesktopWindowPolishTest.kt | 46 - .../desktop/DesktopWindowStateStoreTest.kt | 43 - .../desktop/LinuxSecretToolCodecTest.kt | 115 - settings.gradle.kts | 28 +- shared/build.gradle.kts | 81 - .../reader/shared/LocalFolderSync.android.kt | 8 - .../reader/shared/Platform.android.kt | 3 - .../reader/SharedReaderDiagnostics.android.kt | 17 - .../ui/SharedReaderModalLayer.android.kt | 31 - .../shared/reader/SharedReaderDiagnostics.kt | 15 - .../reader/shared/ui/LocalBookCoverImage.kt | 11 - .../reader/shared/LocalFolderSync.desktop.kt | 8 - .../reader/shared/Platform.desktop.kt | 3 - .../reader/SharedReaderDiagnostics.desktop.kt | 36 - .../shared/ui/DesktopBookCoverImageCache.kt | 110 - .../ui/DesktopEpubNativeImage.desktop.kt | 326 -- .../shared/ui/LocalBookCoverImage.desktop.kt | 42 - .../ui/SharedReaderModalLayer.desktop.kt | 499 -- .../paginatedreader/HtmlParserLinkTest.kt | 151 - .../shared/ReaderTtsFileCacheManagerTest.kt | 48 - .../shared/opds/SharedOpdsParserTest.kt | 264 - .../reader/SharedEpubMetadataEditorTest.kt | 166 - .../reader/SharedEpubPaginationCacheTest.kt | 280 - .../reader/SharedJvmBookLoadCacheTest.kt | 126 - .../shared/reader/SharedJvmBookLoaderTest.kt | 476 -- .../reader/SharedJvmLruMemoryCacheTest.kt | 22 - .../reader/SharedJvmUserDirectoriesTest.kt | 32 - .../reader/SharedMeasuredEpubPaginatorTest.kt | 211 - .../ui/DesktopBookCoverImageCacheTest.kt | 69 - .../shared/ui/DesktopEpubNativeImageTest.kt | 16 - .../ui/SharedReaderModalLayerDesktopTest.kt | 179 - .../shared/reader/SharedJvmBookLoader.kt | 1827 ------ 323 files changed, 74 insertions(+), 50598 deletions(-) rename {shared/src/commonMain => app/src/main}/kotlin/androidx/compose/material/icons/Icons.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/androidx/compose/material/icons/automirrored/filled/AutoMirroredFilledIcons.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/androidx/compose/material/icons/filled/FilledIcons.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/androidx/compose/material/icons/outlined/OutlinedIcons.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/CssParser.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/FontFamilyMapper.kt (100%) rename {shared/src/readerJvmMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/HtmlParser.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/PaginatedReaderData.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/SemanticModel.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/StyleUtils.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/UserAgentStylesheet.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/serialization/ComposeTypeSerializers.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/AppActions.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/AppModels.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/CloudSyncDecisions.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/CustomFontModels.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/FileCapabilities.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/FontVariantInference.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/ImportContracts.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryModels.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryMutations.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryProjector.kt (99%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryStateProjector.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSync.kt (99%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/PdfReaderModels.kt (100%) create mode 100644 app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/Platform.kt rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAnnotationModels.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAnnotationSerializer.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAppearanceModels.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderExtrasModels.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderMarkdownModels.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderSearchModels.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderToolbarModels.kt (100%) rename {shared/src/readerJvmMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderTtsFileCacheManager.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderTtsReplacements.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderWordReplacements.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/RepositoryContracts.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/SampleLibrary.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/ScreenProjectors.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/SettingsHubModels.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/SharedFeaturePolicy.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/SharedFormatters.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLegalLinks.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibrarySnapshot.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/SharedReducers.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/SmartCollectionEngine.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsCatalogs.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsController.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsModels.kt (100%) rename {shared/src/readerJvmMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsParser.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsUtilities.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfInteractionModels.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfReaderSession.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSelectionGeometry.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSpreadLayout.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfVerticalLayout.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfiumBridge.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationExportMapper.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationSidecarCodec.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfInkRendering.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfReflow.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfRichText.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfTextAnnotations.kt (100%) rename {shared/src/readerJvmMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/pptx/SharedPptxDocument.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderEngine.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderHtmlDocumentBuilder.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderImageModels.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderJumpHistory.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderModels.kt (100%) rename {shared/src/readerJvmMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedEpubMetadataEditor.kt (100%) rename {shared/src/readerJvmMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedEpubPaginationCache.kt (100%) rename {shared/src/readerJvmMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmBookLoadCache.kt (100%) rename {shared/src/readerJvmMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmLruMemoryCache.kt (100%) rename {shared/src/readerJvmMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmUserDirectories.kt (100%) rename {shared/src/readerJvmMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedMeasuredEpubPaginator.kt (100%) create mode 100644 app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedReaderDiagnostics.kt rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedReaderTextAlignment.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedTextBookFactory.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SimplePaginator.kt (100%) rename shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/LocalBookCoverImage.android.kt => app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/LocalBookCoverImage.kt (95%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/ui/NonReaderLayoutModels.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/ui/NonReaderScreens.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderContentRenderPlan.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderMinimalSlider.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderTooltips.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderWorkspaceModels.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderWorkspaceShell.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedAppShell.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedAppThemeSettings.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedLibraryDialogs.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedMarkdownText.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedNativePaginatedReader.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedOpdsScreen.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedPdfAnnotationUi.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedPdfRichTextUi.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderChrome.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalLayer.kt (79%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderScrollbars.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderTtsOverlayControls.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedSelectionMenuPlacement.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedSettingsHub.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedStableTextFields.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedStrings.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedUiTokens.kt (100%) rename {shared/src/commonMain => app/src/main}/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedUtilityScreens.kt (100%) rename {shared/src/commonTest => app/src/test}/kotlin/com/dueattendant149/bookreader/reader/shared/CloudSyncDecisionsTest.kt (100%) rename {shared/src/commonTest => app/src/test}/kotlin/com/dueattendant149/bookreader/reader/shared/EpubAnnotationSerializerTest.kt (100%) rename {shared/src/commonTest => app/src/test}/kotlin/com/dueattendant149/bookreader/reader/shared/FileCapabilitiesTest.kt (100%) rename {shared/src/commonTest => app/src/test}/kotlin/com/dueattendant149/bookreader/reader/shared/FontVariantInferenceTest.kt (100%) rename {shared/src/commonTest => app/src/test}/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSyncEngineTest.kt (100%) rename {shared/src/commonTest => app/src/test}/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderActionReducerTest.kt (100%) rename {shared/src/commonTest => app/src/test}/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAppearanceModelsTest.kt (100%) rename {shared/src/commonTest => app/src/test}/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderBookReplacementEngineTest.kt (100%) rename {shared/src/commonTest => app/src/test}/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderDefaultSettingsStateTest.kt (100%) rename {shared/src/commonTest => app/src/test}/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderExtrasModelsTest.kt (100%) rename {shared/src/commonTest => app/src/test}/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderMarkdownParserTest.kt (100%) rename {shared/src/commonTest => app/src/test}/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderToolbarPreferencesTest.kt (100%) rename {shared/src/commonTest => app/src/test}/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderTtsReplacementEngineTest.kt (100%) rename {shared/src/commonTest => app/src/test}/kotlin/com/dueattendant149/bookreader/reader/shared/SettingsHubModelsTest.kt (100%) rename {shared/src/commonTest => app/src/test}/kotlin/com/dueattendant149/bookreader/reader/shared/SharedAppThemeReducerTest.kt (100%) rename {shared/src/commonTest => app/src/test}/kotlin/com/dueattendant149/bookreader/reader/shared/SharedImportPlannerTest.kt (100%) rename {shared/src/commonTest => app/src/test}/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLegalLinksTest.kt (100%) rename {shared/src/commonTest => app/src/test}/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibraryEditorTest.kt (100%) rename {shared/src/commonTest => app/src/test}/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibraryProjectorTest.kt (100%) rename {shared/src/commonTest => app/src/test}/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibrarySnapshotJsonTest.kt (100%) rename {shared/src/commonTest => app/src/test}/kotlin/com/dueattendant149/bookreader/reader/shared/SharedReducersTest.kt (100%) rename {shared/src/commonTest => app/src/test}/kotlin/com/dueattendant149/bookreader/reader/shared/SmartCollectionEngineTest.kt (100%) rename {shared/src/commonTest => app/src/test}/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsCatalogsTest.kt (100%) rename {shared/src/commonTest => app/src/test}/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsControllerTest.kt (100%) rename {shared/src/commonTest => app/src/test}/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfReaderSessionTest.kt (100%) rename {shared/src/commonTest => app/src/test}/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSelectionGeometryTest.kt (100%) rename {shared/src/commonTest => app/src/test}/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSpreadLayoutTest.kt (100%) rename {shared/src/commonTest => app/src/test}/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationCommentsTest.kt (100%) rename {shared/src/commonTest => app/src/test}/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationExportMapperTest.kt (100%) rename {shared/src/commonTest => app/src/test}/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationSerializerTest.kt (100%) rename {shared/src/commonTest => app/src/test}/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfInkRenderingTest.kt (100%) rename {shared/src/commonTest => app/src/test}/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfReflowTest.kt (100%) rename {shared/src/commonTest => app/src/test}/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfRichTextTest.kt (100%) rename {shared/src/commonTest => app/src/test}/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfTextAnnotationsTest.kt (100%) rename {shared/src/commonTest => app/src/test}/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderEngineTest.kt (100%) rename {shared/src/commonTest => app/src/test}/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderHtmlDocumentBuilderTest.kt (100%) rename {shared/src/commonTest => app/src/test}/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderImageModelsTest.kt (100%) rename {shared/src/commonTest => app/src/test}/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderJumpHistoryTest.kt (100%) rename {shared/src/commonTest => app/src/test}/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderSpreadLayoutTest.kt (100%) rename {shared/src/commonTest => app/src/test}/kotlin/com/dueattendant149/bookreader/reader/shared/ui/NonReaderLayoutModelsTest.kt (100%) rename {shared/src/commonTest => app/src/test}/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderMinimalSliderTest.kt (100%) rename {shared/src/commonTest => app/src/test}/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderWorkspaceModelsTest.kt (100%) rename {shared/src/commonTest => app/src/test}/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedAppThemeColorMathTest.kt (100%) rename {shared/src/commonTest => app/src/test}/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedNativePaginatedReaderInteractionTest.kt (100%) rename {shared/src/commonTest => app/src/test}/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedNativeVerticalReaderFlowTest.kt (100%) rename {shared/src/commonTest => app/src/test}/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedPdfAnnotationUiTest.kt (100%) rename {shared/src/commonTest => app/src/test}/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalSizingTest.kt (100%) rename {shared/src/commonTest => app/src/test}/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedSelectionMenuPlacementTest.kt (100%) rename {shared/src/commonTest => app/src/test}/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedStringsTest.kt (100%) delete mode 100644 desktopApp/build.gradle.kts delete mode 100644 desktopApp/compose-desktop.pro delete mode 100644 desktopApp/packaging/README.md delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAccountProfileRepository.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAiByokStore.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAiHub.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAppHost.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAppState.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAtomicFile.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopBookImporter.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopBuildProfile.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopByokAiAdapter.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudConfig.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudRepositories.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSidecarSync.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSync.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSyncDiagnostics.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSyncSettingsStore.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopComicArchive.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCustomFontStore.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopDiagnostics.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubBridgeParsing.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubLoader.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubPagination.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubWebView.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopExternalLinks.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFeatureNoticePlacement.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFileDialogs.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFileDropTarget.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFirebaseAuthRepository.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFolderMetadataExtractor.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFolderSyncFeedback.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFolderSyncLog.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopGeminiCloudTtsAdapter.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLanguageSettings.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLibraryDatabase.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLibraryUi.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLocalFolderSync.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLogFormatting.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopOpdsCoverImage.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopOpdsRepository.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPaidAiAdapter.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfAnnotationUi.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfAppearance.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfChromeUi.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfFileActions.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfInspectorUi.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfKeyCommands.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfNavigationUi.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfPage.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfPageInteractions.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfPasswordDialog.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfReaderScreen.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfReflow.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfScrubbing.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfSelectionUi.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfSidecarEffects.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfSidecars.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfSyncSidecars.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfTheme.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfZoom.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfium.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPlatformPaths.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPptxDocument.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopProScreen.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopProfileAvatar.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderDefaults.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderDiagnostics.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderOpenTrace.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderOpening.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderPanels.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderScreen.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderTexturePreview.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderTextures.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderTypography.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderWindowState.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopStartupSplash.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopStringResources.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopSummaryCacheStore.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopTtsLog.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWindowPolish.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWindowStateStore.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWindowsWebView2EpubWebView.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/Launcher.kt delete mode 100644 desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/Main.kt delete mode 100644 desktopApp/src/desktopMain/resources/episteme.ico delete mode 100644 desktopApp/src/desktopMain/resources/episteme_icon.png delete mode 100644 desktopApp/src/desktopMain/resources/google_fonts.json delete mode 100644 desktopApp/src/desktopMain/resources/textures/classy_fabric.webp delete mode 100644 desktopApp/src/desktopMain/resources/textures/ep_naturalblack.webp delete mode 100644 desktopApp/src/desktopMain/resources/textures/ep_naturalwhite.webp delete mode 100644 desktopApp/src/desktopMain/resources/textures/grey_wash_wall.webp delete mode 100644 desktopApp/src/desktopMain/resources/textures/light-veneer.webp delete mode 100644 desktopApp/src/desktopMain/resources/textures/retina_wood.webp delete mode 100644 desktopApp/src/desktopMain/resources/textures/retro_intro.webp delete mode 100644 desktopApp/src/desktopMain/resources/textures/texture_canvas.png delete mode 100644 desktopApp/src/desktopMain/resources/textures/texture_eink.webp delete mode 100644 desktopApp/src/desktopMain/resources/textures/texture_paper.png delete mode 100644 desktopApp/src/desktopMain/resources/textures/texture_slate.png delete mode 100644 desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAiByokStoreTest.kt delete mode 100644 desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAurPackagingMetadataTest.kt delete mode 100644 desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAuthStoreTest.kt delete mode 100644 desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopBookImporterTest.kt delete mode 100644 desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopBuildProfileTest.kt delete mode 100644 desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudConfigTest.kt delete mode 100644 desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSyncMappingTest.kt delete mode 100644 desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopComicArchiveTest.kt delete mode 100644 desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopComposeInteropTest.kt delete mode 100644 desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCustomFontStoreTest.kt delete mode 100644 desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubBridgeParsingTest.kt delete mode 100644 desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubPaginationTest.kt delete mode 100644 desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFeatureNoticePlacementTest.kt delete mode 100644 desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFolderMetadataExtractorTest.kt delete mode 100644 desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLibraryDatabaseTest.kt delete mode 100644 desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLocalFolderSyncTest.kt delete mode 100644 desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopOpdsRepositoryTest.kt delete mode 100644 desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPaidAiUsageTest.kt delete mode 100644 desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfFileActionsTest.kt delete mode 100644 desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfNavigationSidebarTest.kt delete mode 100644 desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfReflowTest.kt delete mode 100644 desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfScrubbingTest.kt delete mode 100644 desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfSidecarsTest.kt delete mode 100644 desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfTextHighlightStateTest.kt delete mode 100644 desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfThemeTest.kt delete mode 100644 desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPlatformPathsTest.kt delete mode 100644 desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPptxDocumentTest.kt delete mode 100644 desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderDefaultsTest.kt delete mode 100644 desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderKeyCommandsTest.kt delete mode 100644 desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderTypographyTest.kt delete mode 100644 desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderWindowStateTest.kt delete mode 100644 desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopStartupTest.kt delete mode 100644 desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopStringResourcesTest.kt delete mode 100644 desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopSummaryCacheStoreTest.kt delete mode 100644 desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopTtsLogTest.kt delete mode 100644 desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWebView2LayoutTest.kt delete mode 100644 desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWindowPolishTest.kt delete mode 100644 desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWindowStateStoreTest.kt delete mode 100644 desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/LinuxSecretToolCodecTest.kt delete mode 100644 shared/build.gradle.kts delete mode 100644 shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSync.android.kt delete mode 100644 shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/Platform.android.kt delete mode 100644 shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedReaderDiagnostics.android.kt delete mode 100644 shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalLayer.android.kt delete mode 100644 shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedReaderDiagnostics.kt delete mode 100644 shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/LocalBookCoverImage.kt delete mode 100644 shared/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSync.desktop.kt delete mode 100644 shared/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/shared/Platform.desktop.kt delete mode 100644 shared/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedReaderDiagnostics.desktop.kt delete mode 100644 shared/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/DesktopBookCoverImageCache.kt delete mode 100644 shared/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/DesktopEpubNativeImage.desktop.kt delete mode 100644 shared/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/LocalBookCoverImage.desktop.kt delete mode 100644 shared/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalLayer.desktop.kt delete mode 100644 shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/HtmlParserLinkTest.kt delete mode 100644 shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderTtsFileCacheManagerTest.kt delete mode 100644 shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsParserTest.kt delete mode 100644 shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedEpubMetadataEditorTest.kt delete mode 100644 shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedEpubPaginationCacheTest.kt delete mode 100644 shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmBookLoadCacheTest.kt delete mode 100644 shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmBookLoaderTest.kt delete mode 100644 shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmLruMemoryCacheTest.kt delete mode 100644 shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmUserDirectoriesTest.kt delete mode 100644 shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedMeasuredEpubPaginatorTest.kt delete mode 100644 shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/DesktopBookCoverImageCacheTest.kt delete mode 100644 shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/DesktopEpubNativeImageTest.kt delete mode 100644 shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalLayerDesktopTest.kt delete mode 100644 shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmBookLoader.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4ee68a4..02360b8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -217,8 +217,6 @@ kover { //noinspection UseTomlInstead dependencies { - implementation(project(":shared")) - implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.activity.compose) diff --git a/shared/src/commonMain/kotlin/androidx/compose/material/icons/Icons.kt b/app/src/main/kotlin/androidx/compose/material/icons/Icons.kt similarity index 100% rename from shared/src/commonMain/kotlin/androidx/compose/material/icons/Icons.kt rename to app/src/main/kotlin/androidx/compose/material/icons/Icons.kt diff --git a/shared/src/commonMain/kotlin/androidx/compose/material/icons/automirrored/filled/AutoMirroredFilledIcons.kt b/app/src/main/kotlin/androidx/compose/material/icons/automirrored/filled/AutoMirroredFilledIcons.kt similarity index 100% rename from shared/src/commonMain/kotlin/androidx/compose/material/icons/automirrored/filled/AutoMirroredFilledIcons.kt rename to app/src/main/kotlin/androidx/compose/material/icons/automirrored/filled/AutoMirroredFilledIcons.kt diff --git a/shared/src/commonMain/kotlin/androidx/compose/material/icons/filled/FilledIcons.kt b/app/src/main/kotlin/androidx/compose/material/icons/filled/FilledIcons.kt similarity index 100% rename from shared/src/commonMain/kotlin/androidx/compose/material/icons/filled/FilledIcons.kt rename to app/src/main/kotlin/androidx/compose/material/icons/filled/FilledIcons.kt diff --git a/shared/src/commonMain/kotlin/androidx/compose/material/icons/outlined/OutlinedIcons.kt b/app/src/main/kotlin/androidx/compose/material/icons/outlined/OutlinedIcons.kt similarity index 100% rename from shared/src/commonMain/kotlin/androidx/compose/material/icons/outlined/OutlinedIcons.kt rename to app/src/main/kotlin/androidx/compose/material/icons/outlined/OutlinedIcons.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/CssParser.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/CssParser.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/CssParser.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/CssParser.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/FontFamilyMapper.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/FontFamilyMapper.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/FontFamilyMapper.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/FontFamilyMapper.kt diff --git a/shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/HtmlParser.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/HtmlParser.kt similarity index 100% rename from shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/HtmlParser.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/HtmlParser.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/PaginatedReaderData.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/PaginatedReaderData.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/PaginatedReaderData.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/PaginatedReaderData.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/SemanticModel.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/SemanticModel.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/SemanticModel.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/SemanticModel.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/StyleUtils.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/StyleUtils.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/StyleUtils.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/StyleUtils.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/UserAgentStylesheet.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/UserAgentStylesheet.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/UserAgentStylesheet.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/UserAgentStylesheet.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/serialization/ComposeTypeSerializers.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/serialization/ComposeTypeSerializers.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/serialization/ComposeTypeSerializers.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/serialization/ComposeTypeSerializers.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/AppActions.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/AppActions.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/AppActions.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/AppActions.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/AppModels.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/AppModels.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/AppModels.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/AppModels.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/CloudSyncDecisions.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/CloudSyncDecisions.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/CloudSyncDecisions.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/CloudSyncDecisions.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/CustomFontModels.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/CustomFontModels.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/CustomFontModels.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/CustomFontModels.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/FileCapabilities.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/FileCapabilities.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/FileCapabilities.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/FileCapabilities.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/FontVariantInference.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/FontVariantInference.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/FontVariantInference.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/FontVariantInference.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ImportContracts.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ImportContracts.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ImportContracts.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ImportContracts.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryModels.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryModels.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryModels.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryModels.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryMutations.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryMutations.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryMutations.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryMutations.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryProjector.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryProjector.kt similarity index 99% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryProjector.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryProjector.kt index ddff77e..2ea3920 100644 --- a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryProjector.kt +++ b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryProjector.kt @@ -160,8 +160,6 @@ private fun ImportedFile.toImportedBookFile(): ImportedBookFile { ) } -expect fun currentTimestamp(): Long - fun String.toFileType(): FileType { return SharedFileCapabilities.fileTypeForName(this) } diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryStateProjector.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryStateProjector.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryStateProjector.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryStateProjector.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSync.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSync.kt similarity index 99% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSync.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSync.kt index 3cceb68..42c244e 100644 --- a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSync.kt +++ b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSync.kt @@ -19,7 +19,10 @@ const val LOCAL_FOLDER_SYNC_DATA_DIR = "EpistemeSyncData" const val LOCAL_FOLDER_ANNOTATION_SUFFIX = "_annotations" const val LOCAL_FOLDER_SIDECAR_HASH_PREFIX = "book_" -internal expect fun localFolderSyncSha256ShortHex(value: String): String +fun localFolderSyncSha256ShortHex(value: String): String { + val bytes = java.security.MessageDigest.getInstance("SHA-256").digest(value.toByteArray()) + return bytes.joinToString("") { "%02x".format(it) }.take(12) +} fun localFolderSyncSidecarStem(bookId: String): String { return LOCAL_FOLDER_SIDECAR_HASH_PREFIX + localFolderSyncSha256ShortHex(bookId) diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/PdfReaderModels.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/PdfReaderModels.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/PdfReaderModels.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/PdfReaderModels.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/Platform.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/Platform.kt new file mode 100644 index 0000000..21048f6 --- /dev/null +++ b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/Platform.kt @@ -0,0 +1,3 @@ +package org.dueattendant149.bookreader.shared + +fun currentTimestamp(): Long = System.currentTimeMillis() diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAnnotationModels.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAnnotationModels.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAnnotationModels.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAnnotationModels.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAnnotationSerializer.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAnnotationSerializer.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAnnotationSerializer.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAnnotationSerializer.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAppearanceModels.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAppearanceModels.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAppearanceModels.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAppearanceModels.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderExtrasModels.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderExtrasModels.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderExtrasModels.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderExtrasModels.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderMarkdownModels.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderMarkdownModels.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderMarkdownModels.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderMarkdownModels.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderSearchModels.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderSearchModels.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderSearchModels.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderSearchModels.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderToolbarModels.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderToolbarModels.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderToolbarModels.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderToolbarModels.kt diff --git a/shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderTtsFileCacheManager.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderTtsFileCacheManager.kt similarity index 100% rename from shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderTtsFileCacheManager.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderTtsFileCacheManager.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderTtsReplacements.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderTtsReplacements.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderTtsReplacements.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderTtsReplacements.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderWordReplacements.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderWordReplacements.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderWordReplacements.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderWordReplacements.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/RepositoryContracts.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/RepositoryContracts.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/RepositoryContracts.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/RepositoryContracts.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SampleLibrary.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/SampleLibrary.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SampleLibrary.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/SampleLibrary.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ScreenProjectors.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ScreenProjectors.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ScreenProjectors.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ScreenProjectors.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SettingsHubModels.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/SettingsHubModels.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SettingsHubModels.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/SettingsHubModels.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SharedFeaturePolicy.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/SharedFeaturePolicy.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SharedFeaturePolicy.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/SharedFeaturePolicy.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SharedFormatters.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/SharedFormatters.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SharedFormatters.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/SharedFormatters.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLegalLinks.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLegalLinks.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLegalLinks.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLegalLinks.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibrarySnapshot.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibrarySnapshot.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibrarySnapshot.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibrarySnapshot.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SharedReducers.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/SharedReducers.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SharedReducers.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/SharedReducers.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SmartCollectionEngine.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/SmartCollectionEngine.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SmartCollectionEngine.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/SmartCollectionEngine.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsCatalogs.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsCatalogs.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsCatalogs.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsCatalogs.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsController.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsController.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsController.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsController.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsModels.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsModels.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsModels.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsModels.kt diff --git a/shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsParser.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsParser.kt similarity index 100% rename from shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsParser.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsParser.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsUtilities.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsUtilities.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsUtilities.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsUtilities.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfInteractionModels.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfInteractionModels.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfInteractionModels.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfInteractionModels.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfReaderSession.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfReaderSession.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfReaderSession.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfReaderSession.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSelectionGeometry.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSelectionGeometry.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSelectionGeometry.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSelectionGeometry.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSpreadLayout.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSpreadLayout.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSpreadLayout.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSpreadLayout.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfVerticalLayout.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfVerticalLayout.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfVerticalLayout.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfVerticalLayout.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfiumBridge.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfiumBridge.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfiumBridge.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfiumBridge.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationExportMapper.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationExportMapper.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationExportMapper.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationExportMapper.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationSidecarCodec.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationSidecarCodec.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationSidecarCodec.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationSidecarCodec.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfInkRendering.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfInkRendering.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfInkRendering.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfInkRendering.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfReflow.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfReflow.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfReflow.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfReflow.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfRichText.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfRichText.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfRichText.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfRichText.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfTextAnnotations.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfTextAnnotations.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfTextAnnotations.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfTextAnnotations.kt diff --git a/shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/pptx/SharedPptxDocument.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pptx/SharedPptxDocument.kt similarity index 100% rename from shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/pptx/SharedPptxDocument.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pptx/SharedPptxDocument.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderEngine.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderEngine.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderEngine.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderEngine.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderHtmlDocumentBuilder.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderHtmlDocumentBuilder.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderHtmlDocumentBuilder.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderHtmlDocumentBuilder.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderImageModels.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderImageModels.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderImageModels.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderImageModels.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderJumpHistory.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderJumpHistory.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderJumpHistory.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderJumpHistory.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderModels.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderModels.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderModels.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderModels.kt diff --git a/shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedEpubMetadataEditor.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedEpubMetadataEditor.kt similarity index 100% rename from shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedEpubMetadataEditor.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedEpubMetadataEditor.kt diff --git a/shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedEpubPaginationCache.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedEpubPaginationCache.kt similarity index 100% rename from shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedEpubPaginationCache.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedEpubPaginationCache.kt diff --git a/shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmBookLoadCache.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmBookLoadCache.kt similarity index 100% rename from shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmBookLoadCache.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmBookLoadCache.kt diff --git a/shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmLruMemoryCache.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmLruMemoryCache.kt similarity index 100% rename from shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmLruMemoryCache.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmLruMemoryCache.kt diff --git a/shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmUserDirectories.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmUserDirectories.kt similarity index 100% rename from shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmUserDirectories.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmUserDirectories.kt diff --git a/shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedMeasuredEpubPaginator.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedMeasuredEpubPaginator.kt similarity index 100% rename from shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedMeasuredEpubPaginator.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedMeasuredEpubPaginator.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedReaderDiagnostics.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedReaderDiagnostics.kt new file mode 100644 index 0000000..7027816 --- /dev/null +++ b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedReaderDiagnostics.kt @@ -0,0 +1,27 @@ +package org.dueattendant149.bookreader.shared.reader + +import android.util.Log +import org.dueattendant149.bookreader.BuildConfig + +const val SharedReaderDiagnosticsProperty = "episteme.desktop.diagnostics" +const val SharedReaderDiagnosticsTagsProperty = "episteme.desktop.diagnostics.tags" +const val SharedEpubCutoffDiagnosticsTag = "EpistemeEpubCutoff" + +val SharedReaderDiagnosticsEnabled: Boolean = BuildConfig.DEBUG + +fun isSharedReaderDiagnosticTagEnabled(tag: String): Boolean { + if (!BuildConfig.DEBUG) return false + return tag == SharedEpubCutoffDiagnosticsTag || + runCatching { Log.isLoggable(tag, Log.DEBUG) }.getOrDefault(false) +} + +fun writeSharedReaderDiagnostic(tag: String, message: String) { + if (!BuildConfig.DEBUG) return + Log.d(tag, message) +} + +inline fun logSharedReaderDiagnostic(tag: String, message: () -> String) { + if (SharedReaderDiagnosticsEnabled && isSharedReaderDiagnosticTagEnabled(tag)) { + writeSharedReaderDiagnostic(tag, message()) + } +} diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedReaderTextAlignment.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedReaderTextAlignment.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedReaderTextAlignment.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedReaderTextAlignment.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedTextBookFactory.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedTextBookFactory.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedTextBookFactory.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedTextBookFactory.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SimplePaginator.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SimplePaginator.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SimplePaginator.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SimplePaginator.kt diff --git a/shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/LocalBookCoverImage.android.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/LocalBookCoverImage.kt similarity index 95% rename from shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/LocalBookCoverImage.android.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/LocalBookCoverImage.kt index bea2290..035461c 100644 --- a/shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/LocalBookCoverImage.android.kt +++ b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/LocalBookCoverImage.kt @@ -9,7 +9,7 @@ import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale @Composable -internal actual fun LocalBookCoverImage( +internal fun LocalBookCoverImage( path: String, contentDescription: String?, modifier: Modifier diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/NonReaderLayoutModels.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/NonReaderLayoutModels.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/NonReaderLayoutModels.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/NonReaderLayoutModels.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/NonReaderScreens.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/NonReaderScreens.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/NonReaderScreens.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/NonReaderScreens.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderContentRenderPlan.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderContentRenderPlan.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderContentRenderPlan.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderContentRenderPlan.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderMinimalSlider.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderMinimalSlider.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderMinimalSlider.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderMinimalSlider.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderTooltips.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderTooltips.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderTooltips.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderTooltips.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderWorkspaceModels.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderWorkspaceModels.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderWorkspaceModels.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderWorkspaceModels.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderWorkspaceShell.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderWorkspaceShell.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderWorkspaceShell.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderWorkspaceShell.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedAppShell.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedAppShell.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedAppShell.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedAppShell.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedAppThemeSettings.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedAppThemeSettings.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedAppThemeSettings.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedAppThemeSettings.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedLibraryDialogs.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedLibraryDialogs.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedLibraryDialogs.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedLibraryDialogs.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedMarkdownText.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedMarkdownText.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedMarkdownText.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedMarkdownText.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedNativePaginatedReader.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedNativePaginatedReader.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedNativePaginatedReader.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedNativePaginatedReader.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedOpdsScreen.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedOpdsScreen.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedOpdsScreen.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedOpdsScreen.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedPdfAnnotationUi.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedPdfAnnotationUi.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedPdfAnnotationUi.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedPdfAnnotationUi.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedPdfRichTextUi.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedPdfRichTextUi.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedPdfRichTextUi.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedPdfRichTextUi.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderChrome.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderChrome.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderChrome.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderChrome.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalLayer.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalLayer.kt similarity index 79% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalLayer.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalLayer.kt index b1730a5..8038072 100644 --- a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalLayer.kt +++ b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalLayer.kt @@ -2,6 +2,8 @@ package org.dueattendant149.bookreader.shared.ui import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.Composable +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -40,20 +42,32 @@ fun sharedReaderPopupWidth( return (availableWidth * widthFraction.coerceIn(0f, 1f)).coerceIn(lowerBound, upperBound) } + @Composable -internal expect fun SharedReaderModalLayer( +internal fun SharedReaderModalLayer( onDismiss: () -> Unit, level: SharedReaderModalLevel = SharedReaderModalLevel.Popup, content: @Composable () -> Unit -) +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + content() + } +} -internal expect fun sharedReaderModalLayerUsesSizedEdgeWindow(level: SharedReaderModalLevel): Boolean +internal fun sharedReaderModalLayerUsesSizedEdgeWindow(level: SharedReaderModalLevel): Boolean { + return false +} @Composable -expect fun SharedReaderModalOwnerWindowProvider( +fun SharedReaderModalOwnerWindowProvider( ownerWindow: Any?, content: @Composable () -> Unit -) +) { + content() +} @Composable fun SharedReaderPopupLayer( diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderScrollbars.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderScrollbars.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderScrollbars.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderScrollbars.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderTtsOverlayControls.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderTtsOverlayControls.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderTtsOverlayControls.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderTtsOverlayControls.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedSelectionMenuPlacement.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedSelectionMenuPlacement.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedSelectionMenuPlacement.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedSelectionMenuPlacement.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedSettingsHub.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedSettingsHub.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedSettingsHub.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedSettingsHub.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedStableTextFields.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedStableTextFields.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedStableTextFields.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedStableTextFields.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedStrings.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedStrings.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedStrings.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedStrings.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedUiTokens.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedUiTokens.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedUiTokens.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedUiTokens.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedUtilityScreens.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedUtilityScreens.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedUtilityScreens.kt rename to app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedUtilityScreens.kt diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/CloudSyncDecisionsTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/CloudSyncDecisionsTest.kt similarity index 100% rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/CloudSyncDecisionsTest.kt rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/CloudSyncDecisionsTest.kt diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/EpubAnnotationSerializerTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/EpubAnnotationSerializerTest.kt similarity index 100% rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/EpubAnnotationSerializerTest.kt rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/EpubAnnotationSerializerTest.kt diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/FileCapabilitiesTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/FileCapabilitiesTest.kt similarity index 100% rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/FileCapabilitiesTest.kt rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/FileCapabilitiesTest.kt diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/FontVariantInferenceTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/FontVariantInferenceTest.kt similarity index 100% rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/FontVariantInferenceTest.kt rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/FontVariantInferenceTest.kt diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSyncEngineTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSyncEngineTest.kt similarity index 100% rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSyncEngineTest.kt rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSyncEngineTest.kt diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderActionReducerTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderActionReducerTest.kt similarity index 100% rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderActionReducerTest.kt rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderActionReducerTest.kt diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAppearanceModelsTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAppearanceModelsTest.kt similarity index 100% rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAppearanceModelsTest.kt rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAppearanceModelsTest.kt diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderBookReplacementEngineTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderBookReplacementEngineTest.kt similarity index 100% rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderBookReplacementEngineTest.kt rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderBookReplacementEngineTest.kt diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderDefaultSettingsStateTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderDefaultSettingsStateTest.kt similarity index 100% rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderDefaultSettingsStateTest.kt rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderDefaultSettingsStateTest.kt diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderExtrasModelsTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderExtrasModelsTest.kt similarity index 100% rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderExtrasModelsTest.kt rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderExtrasModelsTest.kt diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderMarkdownParserTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderMarkdownParserTest.kt similarity index 100% rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderMarkdownParserTest.kt rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderMarkdownParserTest.kt diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderToolbarPreferencesTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderToolbarPreferencesTest.kt similarity index 100% rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderToolbarPreferencesTest.kt rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderToolbarPreferencesTest.kt diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderTtsReplacementEngineTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderTtsReplacementEngineTest.kt similarity index 100% rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderTtsReplacementEngineTest.kt rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderTtsReplacementEngineTest.kt diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SettingsHubModelsTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SettingsHubModelsTest.kt similarity index 100% rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SettingsHubModelsTest.kt rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SettingsHubModelsTest.kt diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SharedAppThemeReducerTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SharedAppThemeReducerTest.kt similarity index 100% rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SharedAppThemeReducerTest.kt rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SharedAppThemeReducerTest.kt diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SharedImportPlannerTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SharedImportPlannerTest.kt similarity index 100% rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SharedImportPlannerTest.kt rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SharedImportPlannerTest.kt diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLegalLinksTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLegalLinksTest.kt similarity index 100% rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLegalLinksTest.kt rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLegalLinksTest.kt diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibraryEditorTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibraryEditorTest.kt similarity index 100% rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibraryEditorTest.kt rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibraryEditorTest.kt diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibraryProjectorTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibraryProjectorTest.kt similarity index 100% rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibraryProjectorTest.kt rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibraryProjectorTest.kt diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibrarySnapshotJsonTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibrarySnapshotJsonTest.kt similarity index 100% rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibrarySnapshotJsonTest.kt rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibrarySnapshotJsonTest.kt diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SharedReducersTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SharedReducersTest.kt similarity index 100% rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SharedReducersTest.kt rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SharedReducersTest.kt diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SmartCollectionEngineTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SmartCollectionEngineTest.kt similarity index 100% rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SmartCollectionEngineTest.kt rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SmartCollectionEngineTest.kt diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsCatalogsTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsCatalogsTest.kt similarity index 100% rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsCatalogsTest.kt rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsCatalogsTest.kt diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsControllerTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsControllerTest.kt similarity index 100% rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsControllerTest.kt rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsControllerTest.kt diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfReaderSessionTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfReaderSessionTest.kt similarity index 100% rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfReaderSessionTest.kt rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfReaderSessionTest.kt diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSelectionGeometryTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSelectionGeometryTest.kt similarity index 100% rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSelectionGeometryTest.kt rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSelectionGeometryTest.kt diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSpreadLayoutTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSpreadLayoutTest.kt similarity index 100% rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSpreadLayoutTest.kt rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSpreadLayoutTest.kt diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationCommentsTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationCommentsTest.kt similarity index 100% rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationCommentsTest.kt rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationCommentsTest.kt diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationExportMapperTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationExportMapperTest.kt similarity index 100% rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationExportMapperTest.kt rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationExportMapperTest.kt diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationSerializerTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationSerializerTest.kt similarity index 100% rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationSerializerTest.kt rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationSerializerTest.kt diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfInkRenderingTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfInkRenderingTest.kt similarity index 100% rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfInkRenderingTest.kt rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfInkRenderingTest.kt diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfReflowTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfReflowTest.kt similarity index 100% rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfReflowTest.kt rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfReflowTest.kt diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfRichTextTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfRichTextTest.kt similarity index 100% rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfRichTextTest.kt rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfRichTextTest.kt diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfTextAnnotationsTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfTextAnnotationsTest.kt similarity index 100% rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfTextAnnotationsTest.kt rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfTextAnnotationsTest.kt diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderEngineTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderEngineTest.kt similarity index 100% rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderEngineTest.kt rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderEngineTest.kt diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderHtmlDocumentBuilderTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderHtmlDocumentBuilderTest.kt similarity index 100% rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderHtmlDocumentBuilderTest.kt rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderHtmlDocumentBuilderTest.kt diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderImageModelsTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderImageModelsTest.kt similarity index 100% rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderImageModelsTest.kt rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderImageModelsTest.kt diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderJumpHistoryTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderJumpHistoryTest.kt similarity index 100% rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderJumpHistoryTest.kt rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderJumpHistoryTest.kt diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderSpreadLayoutTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderSpreadLayoutTest.kt similarity index 100% rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderSpreadLayoutTest.kt rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderSpreadLayoutTest.kt diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/NonReaderLayoutModelsTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/NonReaderLayoutModelsTest.kt similarity index 100% rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/NonReaderLayoutModelsTest.kt rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/NonReaderLayoutModelsTest.kt diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderMinimalSliderTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderMinimalSliderTest.kt similarity index 100% rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderMinimalSliderTest.kt rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderMinimalSliderTest.kt diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderWorkspaceModelsTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderWorkspaceModelsTest.kt similarity index 100% rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderWorkspaceModelsTest.kt rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderWorkspaceModelsTest.kt diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedAppThemeColorMathTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedAppThemeColorMathTest.kt similarity index 100% rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedAppThemeColorMathTest.kt rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedAppThemeColorMathTest.kt diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedNativePaginatedReaderInteractionTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedNativePaginatedReaderInteractionTest.kt similarity index 100% rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedNativePaginatedReaderInteractionTest.kt rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedNativePaginatedReaderInteractionTest.kt diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedNativeVerticalReaderFlowTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedNativeVerticalReaderFlowTest.kt similarity index 100% rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedNativeVerticalReaderFlowTest.kt rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedNativeVerticalReaderFlowTest.kt diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedPdfAnnotationUiTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedPdfAnnotationUiTest.kt similarity index 100% rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedPdfAnnotationUiTest.kt rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedPdfAnnotationUiTest.kt diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalSizingTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalSizingTest.kt similarity index 100% rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalSizingTest.kt rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalSizingTest.kt diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedSelectionMenuPlacementTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedSelectionMenuPlacementTest.kt similarity index 100% rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedSelectionMenuPlacementTest.kt rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedSelectionMenuPlacementTest.kt diff --git a/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedStringsTest.kt b/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedStringsTest.kt similarity index 100% rename from shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedStringsTest.kt rename to app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedStringsTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index 2a827b8..8f005dd 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,9 +3,9 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.android.library) apply false alias(libs.plugins.kotlin.android) apply false - alias(libs.plugins.kotlin.multiplatform) apply false alias(libs.plugins.kotlin.compose) apply false - alias(libs.plugins.compose.multiplatform) apply false + alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.kotlin.ksp) apply false alias(libs.plugins.kover) apply false } @@ -14,6 +14,21 @@ val test by tasks.registering { description = "Runs available unit tests for the included projects." } +subprojects { + val rootTest = rootProject.tasks.named("test") + tasks.matching { + it.name == "allTests" || + it.name.endsWith("DebugUnitTest") + }.configureEach { + rootTest.configure { + dependsOn(this@configureEach) + } + } + tasks.withType().configureEach { + maxHeapSize = "4g" + } +} + allprojects { configurations.all { resolutionStrategy { @@ -24,19 +39,3 @@ allprojects { } } } - -subprojects { - val rootTest = rootProject.tasks.named("test") - tasks.matching { - it.name == "allTests" || - it.name == "desktopTest" || - it.name.endsWith("DebugUnitTest") - }.configureEach { - rootTest.configure { - dependsOn(this@configureEach) - } - } - tasks.withType().configureEach { - maxHeapSize = "4g" - } -} diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts deleted file mode 100644 index e0cd987..0000000 --- a/desktopApp/build.gradle.kts +++ /dev/null @@ -1,1621 +0,0 @@ -import org.gradle.api.GradleException -import org.gradle.api.DefaultTask -import org.gradle.api.file.DirectoryProperty -import org.gradle.api.file.RegularFileProperty -import org.gradle.api.provider.ListProperty -import org.gradle.api.provider.MapProperty -import org.gradle.api.provider.Property -import org.gradle.api.tasks.JavaExec -import org.gradle.api.tasks.Exec -import org.gradle.api.tasks.Input -import org.gradle.api.tasks.InputDirectory -import org.gradle.api.tasks.InputFile -import org.gradle.api.tasks.OutputDirectory -import org.gradle.api.tasks.OutputFile -import org.gradle.api.tasks.PathSensitive -import org.gradle.api.tasks.PathSensitivity -import org.gradle.api.tasks.Sync -import org.gradle.api.tasks.TaskAction -import org.gradle.api.tasks.bundling.Compression -import org.gradle.api.tasks.bundling.Tar -import org.gradle.jvm.tasks.Jar -import org.gradle.process.ExecOperations -import org.jetbrains.compose.desktop.application.dsl.TargetFormat -import org.gradle.work.DisableCachingByDefault -import java.io.File -import java.security.MessageDigest -import java.awt.RenderingHints -import java.awt.image.BufferedImage -import java.nio.file.AtomicMoveNotSupportedException -import java.nio.file.Files -import java.nio.file.StandardCopyOption -import java.util.Properties -import java.util.zip.ZipEntry -import java.util.zip.ZipFile -import java.util.zip.ZipOutputStream -import javax.imageio.ImageIO -import javax.inject.Inject - -plugins { - alias(libs.plugins.kotlin.multiplatform) - alias(libs.plugins.kotlin.compose) - alias(libs.plugins.compose.multiplatform) -} - -@DisableCachingByDefault(because = "Verification task has no outputs.") -abstract class CheckBundledPdfiumRuntimeTask : DefaultTask() { - @get:Input - abstract val bundleRootPath: Property - - @get:Input - abstract val libraryPath: Property - - @TaskAction - fun checkRuntime() { - val bundleRoot = File(bundleRootPath.get()) - val library = bundleRoot.resolve(libraryPath.get()) - if (!library.isFile) { - throw GradleException( - "Missing bundled Pdfium runtime at ${library.absolutePath}. " + - "Expected ${libraryPath.get()} inside ${bundleRoot.absolutePath}." - ) - } - } -} - -@DisableCachingByDefault(because = "Renames package output produced by jpackage.") -abstract class RenameDesktopMsiOutputTask : DefaultTask() { - @get:Input - abstract val msiDirectoryPath: Property - - @get:Input - abstract val packageName: Property - - @get:Input - abstract val packageVersion: Property - - @get:Input - abstract val architecture: Property - - @TaskAction - fun renameOutput() { - val msiDirectory = File(msiDirectoryPath.get()) - val outputPackageName = packageName.get() - val outputPackageVersion = packageVersion.get() - val source = msiDirectory.resolve("$outputPackageName-$outputPackageVersion.msi") - if (!source.isFile) return - - val target = msiDirectory.resolve("$outputPackageName-$outputPackageVersion-${architecture.get()}.msi") - if (target.exists() && !target.delete()) { - throw GradleException("Could not replace existing MSI at ${target.absolutePath}.") - } - if (!source.renameTo(target)) { - throw GradleException("Could not rename MSI from ${source.absolutePath} to ${target.absolutePath}.") - } - } -} - -@DisableCachingByDefault(because = "Generates an MSIX manifest from package metadata.") -abstract class GenerateDesktopMsixManifestTask : DefaultTask() { - @get:Input - abstract val identityName: Property - - @get:Input - abstract val publisher: Property - - @get:Input - abstract val publisherDisplayName: Property - - @get:Input - abstract val packageName: Property - - @get:Input - abstract val packageDescription: Property - - @get:Input - abstract val packageVersion: Property - - @get:Input - abstract val architecture: Property - - @get:Input - abstract val executablePath: Property - - @get:OutputFile - abstract val outputFile: RegularFileProperty - - @TaskAction - fun generate() { - fun xmlEscaped(value: String): String { - return value.replace("&", "&") - .replace("\"", """) - .replace("'", "'") - .replace("<", "<") - .replace(">", ">") - } - - val file = outputFile.get().asFile - file.parentFile.mkdirs() - file.writeText( - """ - - - - - ${xmlEscaped(packageName.get())} - ${xmlEscaped(publisherDisplayName.get())} - Assets\StoreLogo.png - - - - - - - - - - - - - - - - - """.trimIndent() + "\n", - Charsets.UTF_8 - ) - } -} - -@DisableCachingByDefault(because = "Generates fixed-size MSIX logo assets from the desktop icon.") -abstract class GenerateDesktopMsixAssetsTask : DefaultTask() { - @get:InputFile - @get:PathSensitive(PathSensitivity.NONE) - abstract val sourceIconFile: RegularFileProperty - - @get:OutputDirectory - abstract val outputDirectory: DirectoryProperty - - @TaskAction - fun generate() { - val source = ImageIO.read(sourceIconFile.get().asFile) - ?: throw GradleException("Could not read MSIX source icon ${sourceIconFile.get().asFile.absolutePath}.") - val output = outputDirectory.get().asFile - output.mkdirs() - writePng(source, output.resolve("Square44x44Logo.png"), 44) - writePng(source, output.resolve("Square150x150Logo.png"), 150) - writePng(source, output.resolve("StoreLogo.png"), 50) - } - - private fun writePng(source: BufferedImage, target: File, size: Int) { - val image = BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB) - val graphics = image.createGraphics() - try { - graphics.setRenderingHint( - RenderingHints.KEY_INTERPOLATION, - RenderingHints.VALUE_INTERPOLATION_BICUBIC - ) - graphics.setRenderingHint( - RenderingHints.KEY_RENDERING, - RenderingHints.VALUE_RENDER_QUALITY - ) - graphics.drawImage(source, 0, 0, size, size, null) - } finally { - graphics.dispose() - } - ImageIO.write(image, "png", target) - } -} - -@DisableCachingByDefault(because = "Packages the staged MSIX app image with Windows SDK makeappx.") -abstract class PackageDesktopMsixTask @Inject constructor( - private val execOperations: ExecOperations -) : DefaultTask() { - @get:InputDirectory - @get:PathSensitive(PathSensitivity.RELATIVE) - abstract val packageRootDirectory: DirectoryProperty - - @get:OutputFile - abstract val outputFile: RegularFileProperty - - @get:Input - abstract val makeAppxPath: Property - - @get:Input - abstract val hostOsId: Property - - @get:Input - abstract val hostArchId: Property - - @TaskAction - fun packageMsix() { - if (hostOsId.get() != "windows" || hostArchId.get() != "x64") { - throw GradleException( - "MSIX packaging requires a Windows x64 packaging host. " + - "Current host: ${hostOsId.get()} ${hostArchId.get()}." - ) - } - - val makeAppx = File(makeAppxPath.get()) - if (!makeAppx.isFile) { - throw GradleException( - "Windows SDK makeappx.exe was not found at ${makeAppx.absolutePath}. " + - "Install the Windows SDK MSIX packaging tools or set " + - "-PdesktopMakeAppxPath=." - ) - } - - val output = outputFile.get().asFile - output.parentFile.mkdirs() - if (output.exists() && !output.delete()) { - throw GradleException("Could not replace existing MSIX at ${output.absolutePath}.") - } - - execOperations.exec { - executable = makeAppx.absolutePath - args( - "pack", - "/d", - packageRootDirectory.get().asFile.absolutePath, - "/p", - output.absolutePath, - "/o" - ) - } - } -} - -@DisableCachingByDefault(because = "Signs the MSIX package with Windows SDK signtool.") -abstract class SignDesktopMsixTask @Inject constructor( - private val execOperations: ExecOperations -) : DefaultTask() { - @get:InputFile - @get:PathSensitive(PathSensitivity.NONE) - abstract val unsignedMsixFile: RegularFileProperty - - @get:InputFile - @get:PathSensitive(PathSensitivity.NONE) - abstract val certificateFile: RegularFileProperty - - @get:Input - abstract val signToolPath: Property - - @get:Input - abstract val certificatePassword: Property - - @get:Input - abstract val timestampUrl: Property - - @TaskAction - fun signMsix() { - val signTool = File(signToolPath.get()) - if (!signTool.isFile) { - throw GradleException( - "Windows SDK signtool.exe was not found at ${signTool.absolutePath}. " + - "Install the Windows SDK or set -PdesktopSignToolPath=." - ) - } - - val signArgs = mutableListOf( - "sign", - "/fd", - "SHA256", - "/f", - certificateFile.get().asFile.absolutePath - ) - val password = certificatePassword.get().trim() - if (password.isNotEmpty()) { - signArgs += listOf("/p", password) - } - val timestamp = timestampUrl.get().trim() - if (timestamp.isNotEmpty()) { - signArgs += listOf("/tr", timestamp, "/td", "SHA256") - } - signArgs += unsignedMsixFile.get().asFile.absolutePath - - execOperations.exec { - executable = signTool.absolutePath - args(signArgs) - } - } -} - -@DisableCachingByDefault(because = "Generates local desktop service config for native packages.") -abstract class GenerateDesktopCloudConfigTask : DefaultTask() { - @get:Input - abstract val configValues: MapProperty - - @get:OutputFile - abstract val outputFile: RegularFileProperty - - @TaskAction - fun generate() { - val file = outputFile.get().asFile - file.parentFile.mkdirs() - file.writeText( - configValues.get().entries.joinToString(separator = "\n", postfix = "\n") { (key, value) -> - "$key=${value.replace("\\", "\\\\").replace("\n", "")}" - } - ) - } -} - -@DisableCachingByDefault(because = "Verification task has no outputs.") -abstract class VerifyDesktopNativePackagingTask : DefaultTask() { - @get:Input - abstract val supportedHost: Property - - @get:Input - abstract val hostOsId: Property - - @get:Input - abstract val hostArchId: Property - - @get:Input - abstract val missingStandardServiceConfig: ListProperty - - @TaskAction - fun verify() { - if (!supportedHost.get()) { - throw GradleException( - "Desktop native packaging is currently release-supported only on Windows x64 and Linux x64. " + - "Current host: ${hostOsId.get()} ${hostArchId.get()}." - ) - } - val missing = missingStandardServiceConfig.get() - if (missing.isNotEmpty()) { - throw GradleException( - "Standard desktop packages require account/sync service config. Missing: " + - missing.joinToString(", ") + ". " + - "Set DESKTOP_FIREBASE_WEB_API_KEY and DESKTOP_GOOGLE_OAUTH_CLIENT_ID, " + - "use -PdesktopFlavor=oss for the offline build, or set " + - "-PdesktopAllowUnconfiguredStandardServices=true for a local non-GA package." - ) - } - } -} - -@DisableCachingByDefault(because = "Generates AUR package metadata from the local Linux distributable.") -abstract class PrepareDesktopAurPackageTask : DefaultTask() { - @get:Input - abstract val aurPackageName: Property - - @get:Input - abstract val providedPackageName: Property - - @get:Input - abstract val packageVersion: Property - - @get:Input - abstract val packageRelease: Property - - @get:Input - abstract val packageDescription: Property - - @get:Input - abstract val appDisplayName: Property - - @get:Input - abstract val installDirectoryName: Property - - @get:Input - abstract val launcherName: Property - - @get:Input - abstract val executableName: Property - - @get:Input - abstract val sourceUrl: Property - - @get:Input - abstract val projectUrl: Property - - @get:InputFile - @get:PathSensitive(PathSensitivity.NONE) - abstract val linuxTarFile: RegularFileProperty - - @get:OutputDirectory - abstract val outputDirectory: DirectoryProperty - - @TaskAction - fun prepare() { - val output = outputDirectory.get().asFile - val sourceTar = linuxTarFile.get().asFile - if (!sourceTar.isFile) { - throw GradleException("Missing Linux tarball for AUR packaging: ${sourceTar.absolutePath}") - } - - output.deleteRecursively() - output.mkdirs() - - val stagedTar = output.resolve(sourceTar.name) - sourceTar.copyTo(stagedTar, overwrite = true) - val sha256 = stagedTar.sha256() - val configuredSourceUrl = sourceUrl.get().trim() - val sourceEntry = if (configuredSourceUrl.isBlank()) { - stagedTar.name - } else { - "${stagedTar.name}::$configuredSourceUrl" - } - - output.resolve("PKGBUILD").writeText( - aurPkgbuild( - pkgname = aurPackageName.get(), - providedPackage = providedPackageName.get(), - pkgver = packageVersion.get(), - pkgrel = packageRelease.get(), - pkgdesc = packageDescription.get(), - appName = appDisplayName.get(), - installDir = installDirectoryName.get(), - launcher = launcherName.get(), - executable = executableName.get(), - source = sourceEntry, - sha256 = sha256, - projectUrl = projectUrl.get() - ) - ) - output.resolve(".SRCINFO").writeText( - aurSrcInfo( - pkgname = aurPackageName.get(), - providedPackage = providedPackageName.get(), - pkgver = packageVersion.get(), - pkgrel = packageRelease.get(), - pkgdesc = packageDescription.get(), - source = sourceEntry, - sha256 = sha256, - projectUrl = projectUrl.get() - ) - ) - } - - private fun File.sha256(): String { - val digest = MessageDigest.getInstance("SHA-256") - inputStream().use { input -> - val buffer = ByteArray(DEFAULT_BUFFER_SIZE) - while (true) { - val read = input.read(buffer) - if (read < 0) break - digest.update(buffer, 0, read) - } - } - return digest.digest().joinToString("") { "%02x".format(it) } - } - - private fun shellSingleQuoted(value: String): String { - return "'" + value.replace("'", "'\"'\"'") + "'" - } - - private fun archRuntimeDependencies(): List { - return listOf( - "alsa-lib", - "atk", - "cairo", - "dbus", - "expat", - "fontconfig", - "freetype2", - "gcc-libs", - "gdk-pixbuf2", - "glib2", - "glibc", - "gtk3", - "libcups", - "libarchive", - "libsecret", - "libx11", - "libxcomposite", - "libxdamage", - "libxext", - "libxi", - "libxrandr", - "libxrender", - "libxtst", - "nss", - "pango", - "zlib" - ) - } - - private fun aurPkgbuild( - pkgname: String, - providedPackage: String, - pkgver: String, - pkgrel: String, - pkgdesc: String, - appName: String, - installDir: String, - launcher: String, - executable: String, - source: String, - sha256: String, - projectUrl: String - ): String { - val desktopFile = "$providedPackage.desktop" - val iconName = providedPackage - val depends = archRuntimeDependencies() - val mimeTypes = archDesktopMimeTypes() - return """ -pkgname=${shellSingleQuoted(pkgname)} -pkgver=${shellSingleQuoted(pkgver)} -pkgrel=${shellSingleQuoted(pkgrel)} -pkgdesc=${shellSingleQuoted(pkgdesc)} -arch=('x86_64') -url=${shellSingleQuoted(projectUrl)} -license=('AGPL-3.0-only') -depends=(${depends.joinToString(" ") { shellSingleQuoted(it) }}) -provides=(${shellSingleQuoted(providedPackage)}) -conflicts=(${shellSingleQuoted(providedPackage)}) -source=(${shellSingleQuoted(source)}) -sha256sums=(${shellSingleQuoted(sha256)}) -options=('!debug') - -package() { - install -dm755 "${'$'}pkgdir/opt/$installDir" - cp -a "$installDir/." "${'$'}pkgdir/opt/$installDir/" - chmod 755 "${'$'}pkgdir/opt/$installDir/bin/$executable" - - install -dm755 "${'$'}pkgdir/usr/bin" - ln -sf "/opt/$installDir/bin/$executable" "${'$'}pkgdir/usr/bin/$launcher" - - install -Dm644 "${'$'}pkgdir/opt/$installDir/share/licenses/LICENSE" "${'$'}pkgdir/usr/share/licenses/${'$'}pkgname/LICENSE" - - local icon_path - icon_path="${'$'}(find "${'$'}pkgdir/opt/$installDir" -name 'episteme_icon.png' -print -quit)" - if [[ -n "${'$'}icon_path" ]]; then - install -Dm644 "${'$'}icon_path" "${'$'}pkgdir/usr/share/icons/hicolor/512x512/apps/$iconName.png" - install -Dm644 "${'$'}icon_path" "${'$'}pkgdir/usr/share/pixmaps/$iconName.png" - fi - - install -Dm644 /dev/stdin "${'$'}pkgdir/usr/share/applications/$desktopFile" <<'EOF' -[Desktop Entry] -Type=Application -Name=$appName -Comment=$pkgdesc -Exec=$launcher %F -Icon=$iconName -Terminal=false -Categories=Office;Viewer; -MimeType=${mimeTypes.joinToString(";")}; -EOF -} -""".trimIndent() + "\n" - } - - private fun aurSrcInfo( - pkgname: String, - providedPackage: String, - pkgver: String, - pkgrel: String, - pkgdesc: String, - source: String, - sha256: String, - projectUrl: String - ): String { - val depends = archRuntimeDependencies() - return """ -pkgbase = $pkgname - pkgdesc = $pkgdesc - pkgver = $pkgver - pkgrel = $pkgrel - url = $projectUrl - arch = x86_64 - license = AGPL-3.0-only -${depends.joinToString("\n") { "\tdepends = $it" }} - provides = $providedPackage - conflicts = $providedPackage - source = $source - sha256sums = $sha256 - -pkgname = $pkgname -""".trimIndent() + "\n" - } - - private fun archDesktopMimeTypes(): List { - return listOf( - "application/pdf", - "application/epub+zip", - "application/x-mobipocket-ebook", - "application/vnd.amazon.ebook", - "application/vnd.amazon.mobi8-ebook", - "text/markdown", - "text/x-markdown", - "text/plain", - "text/html", - "application/xhtml+xml", - "application/x-fictionbook+xml", - "application/x-zip-compressed-fb2", - "application/zip", - "application/vnd.comicbook+zip", - "application/x-cbz", - "application/vnd.comicbook-rar", - "application/x-cbr", - "application/x-rar-compressed", - "application/x-cb7", - "application/x-7z-compressed", - "application/vnd.comicbook+tar", - "application/x-cbt", - "application/x-tar", - "application/tar", - "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - "application/vnd.openxmlformats-officedocument.presentationml.presentation", - "application/vnd.oasis.opendocument.text", - "application/x-vnd.oasis.opendocument.text-flat-xml" - ) - } -} - -@DisableCachingByDefault(because = "Strips stale jar signatures in-place after ProGuard rewrites signed dependencies.") -abstract class StripInvalidJarSignaturesTask : DefaultTask() { - @get:Input - abstract val jarDirectoryPath: Property - - @TaskAction - fun stripSignatures() { - val jarDirectory = File(jarDirectoryPath.get()) - if (!jarDirectory.isDirectory) return - - var strippedJarCount = 0 - jarDirectory.walkTopDown() - .filter { it.isFile && it.extension.equals("jar", ignoreCase = true) } - .forEach { jar -> - val strippedEntries = stripInvalidJarSignatures(jar) - if (strippedEntries > 0) { - strippedJarCount += 1 - logger.lifecycle("Stripped $strippedEntries stale jar signature entr${if (strippedEntries == 1) "y" else "ies"} from ${jar.name}") - } - } - - if (strippedJarCount > 0) { - logger.lifecycle("Stripped stale jar signatures from $strippedJarCount ProGuard output jar${if (strippedJarCount == 1) "" else "s"}.") - } - } - - private fun stripInvalidJarSignatures(jar: File): Int { - val temp = Files.createTempFile(jar.parentFile.toPath(), "${jar.nameWithoutExtension}-unsigned-", ".jar") - var strippedEntries = 0 - - ZipFile(jar).use { source -> - ZipOutputStream(Files.newOutputStream(temp)).use { target -> - val seenEntries = mutableSetOf() - val entries = source.entries() - while (entries.hasMoreElements()) { - val sourceEntry = entries.nextElement() - val entryName = sourceEntry.name - if (!seenEntries.add(entryName)) continue - if (isJarSignatureResource(entryName)) { - strippedEntries += 1 - continue - } - - val targetEntry = ZipEntry(entryName) - if (sourceEntry.time >= 0) { - targetEntry.time = sourceEntry.time - } - target.putNextEntry(targetEntry) - if (!sourceEntry.isDirectory) { - source.getInputStream(sourceEntry).use { input -> - input.copyTo(target) - } - } - target.closeEntry() - } - } - } - - if (strippedEntries > 0) { - try { - Files.move(temp, jar.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE) - } catch (_: AtomicMoveNotSupportedException) { - Files.move(temp, jar.toPath(), StandardCopyOption.REPLACE_EXISTING) - } - } else { - Files.deleteIfExists(temp) - } - - return strippedEntries - } - - private fun isJarSignatureResource(entryName: String): Boolean { - val normalized = entryName.replace('\\', '/').uppercase() - if (!normalized.startsWith("META-INF/")) return false - - val metaInfName = normalized.removePrefix("META-INF/") - if (metaInfName.contains("/")) return false - - return metaInfName.startsWith("SIG-") || - metaInfName.endsWith(".SF") || - metaInfName.endsWith(".DSA") || - metaInfName.endsWith(".RSA") || - metaInfName.endsWith(".EC") - } -} - -fun desktopOsId(osName: String = System.getProperty("os.name")): String { - val normalized = osName.lowercase() - return when { - normalized.startsWith("windows") -> "windows" - normalized == "linux" || normalized.contains("linux") -> "linux" - normalized.startsWith("mac") || normalized.contains("darwin") -> "macos" - else -> "other" - } -} - -fun desktopArchId(osArch: String = System.getProperty("os.arch")): String { - return when (osArch.lowercase()) { - "amd64", "x86_64", "x64" -> "x64" - "aarch64", "arm64" -> "arm64" - "x86", "i386", "i686" -> "x86" - else -> "unknown" - } -} - -fun desktopSwtArtifactId( - osName: String = System.getProperty("os.name"), - osArch: String = System.getProperty("os.arch") -): String? { - return when (desktopOsId(osName)) { - "windows" -> when (desktopArchId(osArch)) { - "arm64" -> "org.eclipse.swt.win32.win32.aarch64" - else -> "org.eclipse.swt.win32.win32.x86_64" - } - - "linux" -> when (desktopArchId(osArch)) { - "arm64" -> "org.eclipse.swt.gtk.linux.aarch64" - else -> "org.eclipse.swt.gtk.linux.x86_64" - } - - "macos" -> when (desktopArchId(osArch)) { - "arm64" -> "org.eclipse.swt.cocoa.macosx.aarch64" - else -> "org.eclipse.swt.cocoa.macosx.x86_64" - } - - else -> null - } -} - -fun desktopPdfiumDirectoryName( - osName: String = System.getProperty("os.name"), - osArch: String = System.getProperty("os.arch") -): String { - return when (desktopOsId(osName)) { - "windows" -> "win-${desktopArchId(osArch)}-v8" - "linux" -> "linux-${desktopArchId(osArch)}-v8" - "macos" -> "mac-${desktopArchId(osArch)}-v8" - else -> "${desktopArchId(osArch)}-v8" - } -} - -fun desktopPdfiumLibraryPath( - osName: String = System.getProperty("os.name"), - osArch: String = System.getProperty("os.arch") -): String { - return when (desktopOsId(osName)) { - "windows" -> "bin/pdfium.dll" - "linux" -> "lib/libpdfium.so" - "macos" -> "lib/libpdfium.dylib" - else -> "lib/pdfium" - } -} - -fun desktopJdkToolName(toolName: String, osName: String = System.getProperty("os.name")): String { - return if (desktopOsId(osName) == "windows") "$toolName.exe" else toolName -} - -fun File.asDesktopJdkHome(): File { - val absolute = absoluteFile - return when { - absolute.isFile && absolute.parentFile?.name?.equals("bin", ignoreCase = true) == true -> - absolute.parentFile?.parentFile ?: absolute - - absolute.isDirectory && absolute.name.equals("bin", ignoreCase = true) -> - absolute.parentFile ?: absolute - - absolute.resolve("Contents/Home/bin").isDirectory -> - absolute.resolve("Contents/Home") - - else -> absolute - } -} - -fun File.desktopJdkTool(toolName: String, osName: String = System.getProperty("os.name")): File { - return resolve("bin/${desktopJdkToolName(toolName, osName)}") -} - -fun File.isDesktopPackagingJdk(osName: String = System.getProperty("os.name")): Boolean { - return desktopJdkTool("java", osName).isFile && - desktopJdkTool("jlink", osName).isFile && - desktopJdkTool("jpackage", osName).isFile -} - -fun File.desktopJdkMajorVersion(): Int? { - val releaseFile = resolve("release") - if (!releaseFile.isFile) return null - - val version = runCatching { - releaseFile.useLines { lines -> - lines.firstOrNull { it.startsWith("JAVA_VERSION=") } - ?.substringAfter("=") - ?.trim() - ?.trim('"') - } - }.getOrNull() ?: return null - - return version.removePrefix("1.").substringBefore(".").toIntOrNull() -} - -fun safeChildDirectories(root: File): List { - return runCatching { - root.listFiles()?.filter { it.isDirectory }.orEmpty() - }.getOrDefault(emptyList()) -} - -fun stableDesktopJdkCandidates(candidates: List, preferredMajorVersion: Int = 21): List { - return candidates - .map { it.asDesktopJdkHome() } - .distinctBy { runCatching { it.canonicalPath }.getOrElse { _ -> it.absolutePath }.lowercase() } - .sortedWith( - compareBy { if (it.desktopJdkMajorVersion() == preferredMajorVersion) 0 else 1 } - .thenBy { it.desktopJdkMajorVersion() ?: Int.MAX_VALUE } - .thenBy { it.absolutePath.lowercase() } - ) -} - -fun desktopPathJdkCandidates(osName: String = System.getProperty("os.name")): List { - return System.getenv("PATH") - ?.split(File.pathSeparator) - .orEmpty() - .asSequence() - .map { it.trim() } - .filter { it.isNotEmpty() } - .map { File(it).resolve(desktopJdkToolName("jpackage", osName)) } - .filter { it.isFile } - .mapNotNull { it.parentFile?.parentFile } - .toList() -} - -fun desktopGradleJdkCandidates(): List { - val userHome = System.getProperty("user.home")?.let(::File) ?: return emptyList() - return stableDesktopJdkCandidates(safeChildDirectories(userHome.resolve(".gradle/jdks"))) -} - -fun desktopPlatformJdkCandidates(osName: String = System.getProperty("os.name")): List { - val roots = when (desktopOsId(osName)) { - "windows" -> listOfNotNull( - System.getenv("ProgramFiles")?.let { File(it, "Java") }, - System.getenv("ProgramFiles")?.let { File(it, "Eclipse Adoptium") }, - System.getenv("ProgramFiles")?.let { File(it, "Microsoft") }, - System.getenv("ProgramFiles(x86)")?.let { File(it, "Java") } - ) - - "linux" -> listOf( - File("/usr/lib/jvm"), - File("/usr/java"), - File("/opt/java"), - File("/opt/jdk") - ) - - "macos" -> listOfNotNull( - File("/Library/Java/JavaVirtualMachines"), - System.getProperty("user.home")?.let { File(it, "Library/Java/JavaVirtualMachines") } - ) - - else -> emptyList() - } - - return stableDesktopJdkCandidates(roots + roots.flatMap(::safeChildDirectories)) -} - -fun findDesktopPackagingJavaHome( - explicitCandidates: List, - implicitCandidates: List, - osName: String = System.getProperty("os.name") -): File? { - val explicitJavaHome = explicitCandidates.firstOrNull { it.isNotBlank() } - if (explicitJavaHome != null) { - val candidate = File(explicitJavaHome).asDesktopJdkHome() - if (!candidate.isDesktopPackagingJdk(osName)) { - throw GradleException( - "Desktop packaging JDK must include java, jlink, and jpackage under " + - "${candidate.resolve("bin").absolutePath}. " + - "Set -PdesktopPackagingJavaHome= or DESKTOP_PACKAGING_JAVA_HOME to a full JDK." - ) - } - return candidate - } - - return implicitCandidates - .map { it.asDesktopJdkHome() } - .distinctBy { runCatching { it.canonicalPath }.getOrElse { _ -> it.absolutePath }.lowercase() } - .firstOrNull { it.isDesktopPackagingJdk(osName) } -} - -fun normalizeDesktopPackageVersion(rawVersion: String): String { - val coreVersion = rawVersion.trim() - .substringBefore("-") - .substringBefore("+") - .takeIf { it.isNotBlank() } - ?: "1.0.0" - val parts = coreVersion.split(".") - val numericParts = parts.map { it.toIntOrNull() } - val normalizedParts = when { - parts.size in 1..3 && numericParts.all { it != null } -> - numericParts.map { it ?: 0 } + List(3 - parts.size) { 0 } - - else -> throw GradleException( - "desktopPackageVersion must be numeric MAJOR[.MINOR[.BUILD]], but was '$rawVersion'." - ) - } - val (major, minor, build) = normalizedParts - if (major !in 0..255 || minor !in 0..255 || build !in 0..65535) { - throw GradleException( - "desktopPackageVersion '$rawVersion' is outside the Windows package version range. " + - "Expected MAJOR 0..255, MINOR 0..255, BUILD 0..65535." - ) - } - return "$major.$minor.$build" -} - -fun normalizeDesktopVersionName(rawVersion: String): String { - return rawVersion.trim().takeIf { it.isNotBlank() } ?: "1.0.0" -} - -fun normalizeDesktopFlavor(rawFlavor: String): String { - val flavor = rawFlavor.trim().lowercase() - return when (flavor) { - "oss", "oss-offline", "episteme-oss" -> "oss-offline" - else -> "standard" - } -} - -fun normalizeDesktopPackageArchitecture(osArch: String): String { - val normalizedArch = desktopArchId(osArch) - return if (normalizedArch != "unknown") { - normalizedArch - } else { - osArch.lowercase() - .replace(Regex("[^a-z0-9]+"), "-") - .trim('-') - .ifBlank { "unknown" } - } -} - -fun desktopDefaultPackageFormats(osName: String = System.getProperty("os.name")): String { - return when (desktopOsId(osName)) { - "windows" -> "msi" - "linux" -> "deb,rpm" - "macos" -> "dmg" - else -> "" - } -} - -fun desktopTargetFormatForId(format: String): TargetFormat { - return when (format.lowercase()) { - "exe" -> TargetFormat.Exe - "msi" -> TargetFormat.Msi - "deb" -> TargetFormat.Deb - "rpm" -> TargetFormat.Rpm - "dmg" -> TargetFormat.Dmg - "pkg" -> TargetFormat.Pkg - else -> throw GradleException( - "Unsupported desktopPackageFormats entry '$format'. " + - "Use one or more of: msi, exe, deb, rpm, dmg, pkg." - ) - } -} - -fun desktopPackageFormatId(format: TargetFormat): String { - return when (format) { - TargetFormat.Exe -> "exe" - TargetFormat.Msi -> "msi" - TargetFormat.Deb -> "deb" - TargetFormat.Rpm -> "rpm" - TargetFormat.Dmg -> "dmg" - TargetFormat.Pkg -> "pkg" - else -> format.name.lowercase() - } -} - -fun desktopPackageFormatSupportedOnHost( - format: TargetFormat, - osName: String = System.getProperty("os.name") -): Boolean { - return when (desktopOsId(osName)) { - "windows" -> format == TargetFormat.Msi || format == TargetFormat.Exe - "linux" -> format == TargetFormat.Deb || format == TargetFormat.Rpm - "macos" -> format == TargetFormat.Dmg || format == TargetFormat.Pkg - else -> false - } -} - -fun normalizeDesktopPackageFormats( - rawFormats: String, - osName: String = System.getProperty("os.name") -): List { - val formats = rawFormats - .split(',', ';', ' ', '\n', '\t') - .map { it.trim() } - .filter { it.isNotBlank() } - .map(::desktopTargetFormatForId) - .distinct() - if (formats.isEmpty()) { - throw GradleException( - "desktopPackageFormats resolved to no package formats for ${desktopOsId(osName)}. " + - "Set -PdesktopPackageFormats=msi on Windows or -PdesktopPackageFormats=deb,rpm on Linux." - ) - } - val unsupported = formats.filterNot { desktopPackageFormatSupportedOnHost(it, osName) } - if (unsupported.isNotEmpty()) { - throw GradleException( - "desktopPackageFormats=${formats.joinToString(",") { desktopPackageFormatId(it) }} does not match " + - "the current packaging host ${desktopOsId(osName)}. Unsupported here: " + - unsupported.joinToString(",") { desktopPackageFormatId(it) } + "." - ) - } - return formats -} - -fun normalizeDesktopMsixVersion(rawVersion: String): String { - val parts = rawVersion.trim().split('.') - if (parts.size !in 3..4 || parts.any { it.isBlank() || it.all(Char::isDigit).not() }) { - throw GradleException( - "desktopMsixVersion must be a numeric Windows package version with three or four parts, " + - "for example 1.0.1 or 1.0.1.0." - ) - } - val normalized = if (parts.size == 3) parts + "0" else parts - normalized.forEach { part -> - val value = part.toIntOrNull() - if (value == null || value !in 0..65535) { - throw GradleException("desktopMsixVersion part '$part' is outside the MSIX range 0..65535.") - } - } - return normalized.joinToString(".") -} - -fun normalizeDesktopMsixIdentityName(rawName: String): String { - val normalized = rawName.trim() - if (!Regex("[A-Za-z0-9][A-Za-z0-9.-]{2,49}").matches(normalized)) { - throw GradleException( - "desktopMsixIdentityName must be 3-50 characters using letters, numbers, dots, or hyphens." - ) - } - return normalized -} - -fun desktopMsixArchitecture(osArch: String = System.getProperty("os.arch")): String { - return when (desktopArchId(osArch)) { - "x64" -> "x64" - "arm64" -> "arm64" - "x86" -> "x86" - else -> "neutral" - } -} - -fun latestExistingFile(candidates: List): File? { - return candidates.filter { it.isFile }.maxByOrNull { it.absolutePath } -} - -fun windowsSdkToolCandidates(toolName: String): List { - val roots = listOfNotNull( - System.getenv("WindowsSdkDir")?.let(::File), - File("C:/Program Files (x86)/Windows Kits/10"), - File("C:/Program Files/Windows Kits/10"), - File("C:/Program Files (x86)/Windows Kits/10/App Certification Kit"), - File("C:/Program Files/Windows Kits/10/App Certification Kit") - ).distinctBy { it.absolutePath.lowercase() } - val sdkBins = roots.flatMap { root -> - safeChildDirectories(root.resolve("bin")).flatMap { versionDir -> - listOf( - versionDir.resolve("x64/$toolName.exe"), - versionDir.resolve("x86/$toolName.exe"), - versionDir.resolve(toolName) - ) - } - } - val directBins = roots.map { root -> root.resolve("$toolName.exe") } - val pathBins = (System.getenv("PATH") ?: "") - .split(File.pathSeparator) - .filter { it.isNotBlank() } - .map { File(it).resolve("$toolName.exe") } - return sdkBins + directBins + pathBins -} - -fun findWindowsSdkTool(toolName: String, explicitPath: String?): File { - val explicit = explicitPath?.trim()?.takeIf { it.isNotEmpty() }?.let(::File) - if (explicit != null) return explicit - return latestExistingFile(windowsSdkToolCandidates(toolName)) - ?: File(rootProject.projectDir, "__missing_windows_sdk_tool__/$toolName.exe") -} - -val desktopVersionName = "1.0.1" -val desktopFlavor = providers.gradleProperty("desktopFlavor") - .orElse("standard") - .map(::normalizeDesktopFlavor) - .get() -val isOssOfflineDesktop = desktopFlavor == "oss-offline" -val desktopDiagnostics = providers.gradleProperty("desktopDiagnostics") - .map { it.equals("true", ignoreCase = true) } - .orElse(false) -val desktopDiagnosticTags = providers.gradleProperty("desktopDiagnosticTags") - .orElse("") -val desktopResolvedVersionName = providers.gradleProperty("desktopVersionName") - .orElse(providers.gradleProperty("desktopVersion")) - .orElse(desktopVersionName) - .map(::normalizeDesktopVersionName) -val desktopPackageVersion = providers.gradleProperty("desktopPackageVersion") - .orElse(desktopResolvedVersionName) - .map(::normalizeDesktopPackageVersion) -val desktopPackageName = if (isOssOfflineDesktop) "Episteme oss" else "Episteme" -val desktopLinuxPackageName = if (isOssOfflineDesktop) "episteme-oss" else "episteme" -val desktopPackageDescription = if (isOssOfflineDesktop) { - "Episteme oss offline desktop reader" -} else { - "Episteme desktop reader" -} -val desktopVendor = providers.gradleProperty("desktopVendor").orElse("Aryan") -val desktopVendorName = desktopVendor.get() -val desktopProjectUrl = providers.gradleProperty("desktopProjectUrl") - .orElse("https://github.com/Aryan-Raj3112/episteme") -val desktopOsName = System.getProperty("os.name") -val desktopOsArch = System.getProperty("os.arch") -val desktopPackageArchitecture = normalizeDesktopPackageArchitecture(desktopOsArch) -val desktopAurPackageName = providers.gradleProperty("desktopAurPackageName") - .orElse(if (isOssOfflineDesktop) "episteme-oss-bin" else "episteme-bin") -val desktopAurPackageRelease = providers.gradleProperty("desktopAurPackageRelease") - .orElse("1") -val desktopAurSourceUrl = providers.gradleProperty("desktopAurSourceUrl") - .orElse("") -val desktopPackageTargetFormats = providers.gradleProperty("desktopPackageFormats") - .orElse(desktopDefaultPackageFormats(desktopOsName)) - .map { normalizeDesktopPackageFormats(it, desktopOsName) } - .get() -val desktopMsixIdentityName = providers.gradleProperty("desktopMsixIdentityName") - .orElse(if (isOssOfflineDesktop) "Aryan.EpistemeOss" else "Aryan.Episteme") - .map(::normalizeDesktopMsixIdentityName) - .get() -val desktopMsixPublisher = providers.gradleProperty("desktopMsixPublisher") - .orElse("CN=$desktopVendorName") -val desktopMsixPublisherDisplayName = providers.gradleProperty("desktopMsixPublisherDisplayName") - .orElse(desktopVendor) -val desktopMsixVersion = providers.gradleProperty("desktopMsixVersion") - .orElse(desktopPackageVersion) - .map(::normalizeDesktopMsixVersion) - .get() -val desktopMsixArchitecture = desktopMsixArchitecture(desktopOsArch) -val desktopMakeAppxPath = providers.gradleProperty("desktopMakeAppxPath").orNull -val desktopSignToolPath = providers.gradleProperty("desktopSignToolPath").orNull -val desktopMsixCertificatePath = providers.gradleProperty("desktopMsixCertificatePath").orNull -val desktopMsixCertificatePassword = providers.gradleProperty("desktopMsixCertificatePassword") - .orElse("") -val desktopMsixTimestampUrl = providers.gradleProperty("desktopMsixTimestampUrl") - .orElse("http://timestamp.digicert.com") -val desktopNativePackageSupportedHost = desktopOsId(desktopOsName) in setOf("windows", "linux") && - desktopArchId(desktopOsArch) == "x64" -val desktopReleaseProguardEnabled = providers.gradleProperty("desktopReleaseProguard") - .map { it.equals("true", ignoreCase = true) } - .orElse(false) - .get() -val desktopSwtVersion = "3.133.0" -val desktopSwtDependency = desktopSwtArtifactId(desktopOsName, desktopOsArch) - ?.let { artifactId -> "org.eclipse.platform:$artifactId:$desktopSwtVersion" } -val generatedDesktopResourcesDir = layout.buildDirectory.dir("generated/desktopAppResources") -val generatedDesktopCloudConfigFile = layout.buildDirectory.file("generated/desktopCloudConfig/desktop-cloud.properties") -val generatedDesktopStringResourcesDir = layout.buildDirectory.dir("generated/desktopStringResources") -val rootLocalProperties = Properties() -val rootLocalPropertiesFile = rootProject.file("local.properties") -if (rootLocalPropertiesFile.exists()) { - rootLocalPropertiesFile.inputStream().use(rootLocalProperties::load) -} -fun desktopConfigValue(vararg keys: String): String { - return keys.firstNotNullOfOrNull { key -> - providers.gradleProperty(key).orNull - ?: rootLocalProperties.getProperty(key) - ?: System.getenv(key) - }?.trim().orEmpty() -} -val desktopCloudConfig = mapOf( - "AI_WORKER_URL" to desktopConfigValue("DESKTOP_AI_WORKER_URL", "AI_WORKER_URL"), - "TTS_WORKER_URL" to desktopConfigValue("DESKTOP_TTS_WORKER_URL", "TTS_WORKER_URL"), - "FIREBASE_WEB_API_KEY" to desktopConfigValue("DESKTOP_FIREBASE_WEB_API_KEY", "FIREBASE_WEB_API_KEY", "GOOGLE_API_KEY"), - "FIREBASE_PROJECT_ID" to desktopConfigValue("DESKTOP_FIREBASE_PROJECT_ID", "FIREBASE_PROJECT_ID").ifBlank { "reader-9fc469d7" }, - "GOOGLE_OAUTH_CLIENT_ID" to desktopConfigValue("DESKTOP_GOOGLE_OAUTH_CLIENT_ID", "GOOGLE_OAUTH_CLIENT_ID", "GOOGLE_WEB_CLIENT_ID", "DEFAULT_WEB_CLIENT_ID"), - "GOOGLE_OAUTH_CLIENT_SECRET" to desktopConfigValue("DESKTOP_GOOGLE_OAUTH_CLIENT_SECRET", "GOOGLE_OAUTH_CLIENT_SECRET", "GOOGLE_WEB_CLIENT_SECRET", "DEFAULT_WEB_CLIENT_SECRET") -) -val desktopAllowUnconfiguredStandardServices = providers.gradleProperty("desktopAllowUnconfiguredStandardServices") - .map { it.equals("true", ignoreCase = true) } - .orElse(false) - .get() -val desktopMissingStandardServiceConfig = if (isOssOfflineDesktop || desktopAllowUnconfiguredStandardServices) { - emptyList() -} else { - listOf("FIREBASE_WEB_API_KEY", "GOOGLE_OAUTH_CLIENT_ID") - .filter { key -> desktopCloudConfig[key].isNullOrBlank() } -} -val bundledPdfiumDir = layout.projectDirectory.dir( - "../third_party/pdfium/${desktopPdfiumDirectoryName(desktopOsName, desktopOsArch)}" -) -val bundledPdfiumLibraryPath = desktopPdfiumLibraryPath(desktopOsName, desktopOsArch) -val desktopWindowsIconFile = layout.projectDirectory.file("src/desktopMain/resources/episteme.ico") -val desktopLinuxIconFile = layout.projectDirectory.file("src/desktopMain/resources/episteme_icon.png") -val desktopWindowsUpgradeUuid = if (isOssOfflineDesktop) { - "ca13b201-940a-420a-8a3f-16e7d83d12a8" -} else { - "c04c5823-b25a-4f38-a1cf-0da7b02ac397" -} -val desktopPackagingJavaHome = findDesktopPackagingJavaHome( - explicitCandidates = listOfNotNull( - providers.gradleProperty("desktopPackagingJavaHome").orNull, - providers.environmentVariable("DESKTOP_PACKAGING_JAVA_HOME").orNull, - providers.environmentVariable("JPACKAGE_HOME").orNull - ), - implicitCandidates = buildList { - addAll(desktopGradleJdkCandidates()) - providers.gradleProperty("org.gradle.java.home").orNull?.let { add(File(it)) } - providers.environmentVariable("GRADLE_LOCAL_JAVA_HOME").orNull?.let { add(File(it)) } - providers.environmentVariable("JAVA_HOME").orNull?.let { add(File(it)) } - providers.environmentVariable("JDK_HOME").orNull?.let { add(File(it)) } - add(File(System.getProperty("java.home"))) - addAll(desktopPathJdkCandidates(desktopOsName)) - addAll(desktopPlatformJdkCandidates(desktopOsName)) - }, - osName = desktopOsName -)?.absolutePath - -val checkBundledPdfiumRuntime by tasks.registering(CheckBundledPdfiumRuntimeTask::class) { - bundleRootPath.set(bundledPdfiumDir.asFile.absolutePath) - libraryPath.set(bundledPdfiumLibraryPath) -} - -val generateDesktopCloudConfig by tasks.registering(GenerateDesktopCloudConfigTask::class) { - configValues.set(desktopCloudConfig) - outputFile.set(generatedDesktopCloudConfigFile) -} - -val prepareBundledDesktopResources by tasks.registering(Sync::class) { - dependsOn(checkBundledPdfiumRuntime, generateDesktopCloudConfig) - from(bundledPdfiumDir) { - into("common/third_party/pdfium/${desktopPdfiumDirectoryName(desktopOsName, desktopOsArch)}") - } - into("common") { - from(generatedDesktopCloudConfigFile) - } - into(generatedDesktopResourcesDir) -} - -val prepareDesktopStringResources by tasks.registering(Sync::class) { - // Reuse Android string resources as the localization source for desktop. - from(rootProject.layout.projectDirectory.dir("app/src/main/res")) { - include("values*/strings.xml") - include("values*/plurals.xml") - into("desktop-android-res") - } - into(generatedDesktopStringResourcesDir) -} - -val verifyDesktopNativePackaging by tasks.registering(VerifyDesktopNativePackagingTask::class) { - supportedHost.set(desktopNativePackageSupportedHost) - hostOsId.set(desktopOsId(desktopOsName)) - hostArchId.set(desktopArchId(desktopOsArch)) - missingStandardServiceConfig.set(desktopMissingStandardServiceConfig) -} - -val desktopDistributableAppDir = layout.buildDirectory.dir("compose/binaries/main/app/$desktopPackageName") -val desktopReleaseDistributableAppDir = layout.buildDirectory.dir("compose/binaries/main-release/app/$desktopPackageName") -val desktopLinuxTarFileName = "${desktopLinuxPackageName}-${desktopPackageVersion.get()}-linux-$desktopPackageArchitecture.tar.gz" -val desktopAurOutputDir = layout.buildDirectory.dir("aur/${desktopAurPackageName.get()}") -val desktopMsixPackageDir = layout.buildDirectory.dir("msix/package") -val desktopMsixAssetsDir = layout.buildDirectory.dir("msix/generated/assets") -val desktopMsixManifestFile = layout.buildDirectory.file("msix/generated/AppxManifest.xml") -val desktopMsixOutputFile = layout.buildDirectory.file( - "compose/binaries/main-release/msix/${desktopLinuxPackageName}-${desktopPackageVersion.get()}-windows-$desktopPackageArchitecture.msix" -) - -val packageLinuxTar by tasks.registering(Tar::class) { - group = "distribution" - description = "Packages the Linux desktop distributable as a tar.gz for Arch/AUR packaging." - dependsOn("createDistributable") - - archiveFileName.set(desktopLinuxTarFileName) - destinationDirectory.set(layout.buildDirectory.dir("compose/binaries/main/linux-tar")) - compression = Compression.GZIP - - from(desktopDistributableAppDir) { - into(desktopLinuxPackageName) - } - from(desktopLinuxIconFile) { - into("$desktopLinuxPackageName/share") - } - from(rootProject.layout.projectDirectory.file("LICENSE")) { - into("$desktopLinuxPackageName/share/licenses") - } -} - -val prepareAurPackage by tasks.registering(PrepareDesktopAurPackageTask::class) { - group = "distribution" - description = "Generates a local AUR package directory with PKGBUILD and .SRCINFO." - dependsOn(packageLinuxTar) - - aurPackageName.set(desktopAurPackageName) - providedPackageName.set(desktopLinuxPackageName) - packageVersion.set(desktopPackageVersion) - packageRelease.set(desktopAurPackageRelease) - packageDescription.set(desktopPackageDescription) - appDisplayName.set(desktopPackageName) - installDirectoryName.set(desktopLinuxPackageName) - launcherName.set(desktopLinuxPackageName) - executableName.set(desktopPackageName) - sourceUrl.set(desktopAurSourceUrl) - projectUrl.set(desktopProjectUrl) - linuxTarFile.set(packageLinuxTar.flatMap { it.archiveFile }) - outputDirectory.set(desktopAurOutputDir) -} - -tasks.register("packageAur") { - group = "distribution" - description = "Builds the generated AUR package with makepkg. Run this on Arch Linux." - dependsOn(prepareAurPackage) - - commandLine("makepkg", "-sf", "--cleanbuild") - workingDir = desktopAurOutputDir.get().asFile -} - -val generateDesktopMsixManifest by tasks.registering(GenerateDesktopMsixManifestTask::class) { - identityName.set(desktopMsixIdentityName) - publisher.set(desktopMsixPublisher) - publisherDisplayName.set(desktopMsixPublisherDisplayName) - packageName.set(desktopPackageName) - packageDescription.set(desktopPackageDescription) - packageVersion.set(desktopMsixVersion) - architecture.set(desktopMsixArchitecture) - executablePath.set("$desktopPackageName.exe") - outputFile.set(desktopMsixManifestFile) -} - -val generateDesktopMsixAssets by tasks.registering(GenerateDesktopMsixAssetsTask::class) { - sourceIconFile.set(desktopLinuxIconFile) - outputDirectory.set(desktopMsixAssetsDir) -} - -val prepareReleaseMsixPackage by tasks.registering(Sync::class) { - group = "distribution" - description = "Stages the release Windows app image and MSIX metadata for makeappx." - dependsOn("createReleaseDistributable", generateDesktopMsixManifest, generateDesktopMsixAssets) - - from(desktopReleaseDistributableAppDir) - from(desktopMsixManifestFile) - from(desktopMsixAssetsDir) { - into("Assets") - } - into(desktopMsixPackageDir) -} - -val packageReleaseMsix by tasks.registering(PackageDesktopMsixTask::class) { - group = "distribution" - description = "Packages the release Windows app image as an MSIX using Windows SDK makeappx." - dependsOn(prepareReleaseMsixPackage) - - val makeAppx = findWindowsSdkTool("makeappx", desktopMakeAppxPath) - packageRootDirectory.set(desktopMsixPackageDir) - outputFile.set(desktopMsixOutputFile) - makeAppxPath.set(makeAppx.absolutePath) - hostOsId.set(desktopOsId(desktopOsName)) - hostArchId.set(desktopArchId(desktopOsArch)) -} - -val signReleaseMsix = desktopMsixCertificatePath?.trim()?.takeIf { it.isNotEmpty() }?.let { certificatePath -> - tasks.register("signReleaseMsix") { - group = "distribution" - description = "Signs the release MSIX with signtool when -PdesktopMsixCertificatePath is configured." - dependsOn(packageReleaseMsix) - - val signTool = findWindowsSdkTool("signtool", desktopSignToolPath) - val resolvedCertificateFile = File(certificatePath).let { file -> - if (file.isAbsolute) file else project.file(certificatePath) - } - unsignedMsixFile.set(desktopMsixOutputFile) - certificateFile.set(resolvedCertificateFile) - signToolPath.set(signTool.absolutePath) - certificatePassword.set(desktopMsixCertificatePassword) - timestampUrl.set(desktopMsixTimestampUrl) - } -} - -kotlin { - jvm("desktop") - jvmToolchain(21) - - sourceSets { - val desktopMain by getting { - resources.srcDir(prepareDesktopStringResources) - dependencies { - implementation(project(":shared")) - implementation(compose.desktop.currentOs) - implementation(compose.material3) - desktopSwtDependency?.let { dependency -> - compileOnly(dependency) - runtimeOnly(dependency) - } - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.8.1") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") - implementation("net.java.dev.jna:jna:5.17.0") - implementation("org.apache.commons:commons-compress:1.28.0") - implementation("org.tukaani:xz:1.10") - implementation("com.twelvemonkeys.imageio:imageio-webp:3.13.1") - } - } - val desktopTest by getting { - dependencies { - implementation(kotlin("test")) - } - } - } -} - -compose.desktop { - application { - mainClass = "org.dueattendant149.bookreader.desktop.LauncherKt" - desktopPackagingJavaHome?.let { javaHome = it } - - jvmArgs("--add-opens", "java.desktop/sun.awt=ALL-UNNAMED") - jvmArgs("--add-opens", "java.desktop/java.awt.peer=ALL-UNNAMED") - jvmArgs("-Depisteme.desktop.flavor=$desktopFlavor") - jvmArgs("-Depisteme.desktop.diagnostics=${desktopDiagnostics.get()}") - jvmArgs("-Depisteme.desktop.diagnostics.tags=${desktopDiagnosticTags.get()}") - jvmArgs("-Depisteme.desktop.version=${desktopResolvedVersionName.get()}") - - buildTypes.release.proguard { - // ProGuard still rewrites and shrinks release jars even when optimization and - // obfuscation are disabled. That has produced invalid stack-map frames in large - // Compose/PDF lambdas and stripped WebView bridge behavior in packaged MSIs. - isEnabled.set(desktopReleaseProguardEnabled) - obfuscate.set(false) - // Compose/Kotlin generated methods can produce very large stack-map frames. - // ProGuard optimization has emitted invalid frames for SharedAppTheme in release builds. - optimize.set(false) - configurationFiles.from(project.file("compose-desktop.pro")) - } - - nativeDistributions { - targetFormats(*desktopPackageTargetFormats.toTypedArray()) - modules( - "java.datatransfer", - "java.desktop", - "java.logging", - "java.management", - "java.net.http", - "jdk.charsets", - "jdk.httpserver", - "jdk.unsupported" - ) - packageName = desktopPackageName - packageVersion = desktopPackageVersion.get() - description = desktopPackageDescription - vendor = desktopVendor.get() - appResourcesRootDir.set(generatedDesktopResourcesDir) - windows { - iconFile.set(desktopWindowsIconFile) - dirChooser = true - shortcut = true - menu = true - menuGroup = "Episteme" - perUserInstall = true - upgradeUuid = desktopWindowsUpgradeUuid - } - linux { - iconFile.set(desktopLinuxIconFile) - packageName = desktopLinuxPackageName - debMaintainer = "epistemereader@gmail.com" - menuGroup = "Office" - appCategory = "Office" - } - } - } -} - -tasks.withType().configureEach { - jvmArgs("--add-opens", "java.desktop/sun.awt=ALL-UNNAMED") - jvmArgs("--add-opens", "java.desktop/java.awt.peer=ALL-UNNAMED") - jvmArgs("-Depisteme.desktop.flavor=$desktopFlavor") - jvmArgs("-Depisteme.desktop.diagnostics=${desktopDiagnostics.get()}") - jvmArgs("-Depisteme.desktop.diagnostics.tags=${desktopDiagnosticTags.get()}") - jvmArgs("-Depisteme.desktop.version=${desktopResolvedVersionName.get()}") - if (System.getProperty("os.name").contains("Mac")) { - jvmArgs("--add-opens", "java.desktop/sun.lwawt=ALL-UNNAMED") - jvmArgs("--add-opens", "java.desktop/sun.lwawt.macosx=ALL-UNNAMED") - } -} - -tasks.withType().configureEach { - manifest { - attributes( - "Implementation-Title" to desktopPackageName, - "Implementation-Version" to desktopResolvedVersionName.get(), - "Implementation-Vendor" to desktopVendor.get() - ) - } -} - -val stripReleaseProguardJarSignatures = if (desktopReleaseProguardEnabled) { - tasks.registering(StripInvalidJarSignaturesTask::class) { - dependsOn("proguardReleaseJars") - jarDirectoryPath.set(layout.buildDirectory.dir("compose/tmp/main-release/proguard").map { it.asFile.absolutePath }) - } -} else { - null -} - -tasks.matching { - it.name in setOf( - "createReleaseDistributable", - "packageReleaseDistributionForCurrentOS", - "packageReleaseExe", - "packageReleaseMsi", - "packageReleaseMsix", - "packageReleaseDeb", - "packageReleaseRpm", - "runReleaseDistributable" - ) -}.configureEach { - stripReleaseProguardJarSignatures?.let { dependsOn(it) } -} - -mapOf( - "packageMsi" to "main", - "packageReleaseMsi" to "main-release" -).forEach { (taskName, distributionName) -> - val renameTask = tasks.register("rename${taskName.replaceFirstChar(Char::titlecase)}Output") { - msiDirectoryPath.set(layout.buildDirectory.dir("compose/binaries/$distributionName/msi").get().asFile.absolutePath) - packageName.set(desktopPackageName) - packageVersion.set(desktopPackageVersion.get()) - architecture.set(desktopPackageArchitecture) - } - tasks.matching { it.name == taskName }.configureEach { - finalizedBy(renameTask) - } -} - -tasks.matching { - it.name in setOf( - "createDistributable", - "createReleaseDistributable", - "prepareAppResources", - "prepareReleaseAppResources", - "packageDistributionForCurrentOS", - "packageReleaseDistributionForCurrentOS", - "packageExe", - "packageReleaseExe", - "packageMsi", - "packageReleaseMsi", - "prepareReleaseMsixPackage", - "packageReleaseMsix", - "signReleaseMsix", - "packageDeb", - "packageReleaseDeb", - "packageRpm", - "packageReleaseRpm", - "packageLinuxTar", - "prepareAurPackage", - "packageAur", - "runDistributable", - "runReleaseDistributable" - ) -}.configureEach { - dependsOn(verifyDesktopNativePackaging) - dependsOn(prepareBundledDesktopResources) - inputs.dir(generatedDesktopResourcesDir) - .withPropertyName("bundledDesktopResources") - .withPathSensitivity(PathSensitivity.RELATIVE) -} diff --git a/desktopApp/compose-desktop.pro b/desktopApp/compose-desktop.pro deleted file mode 100644 index d3805a8..0000000 --- a/desktopApp/compose-desktop.pro +++ /dev/null @@ -1,26 +0,0 @@ --keep class io.ktor.serialization.kotlinx.** { *; } --keep class io.ktor.serialization.kotlinx.json.** { *; } --keep class com.sun.jna.** { *; } --keep class * implements com.sun.jna.Library { *; } --keep class * extends com.sun.jna.Structure { *; } --keep class kotlinx.coroutines.swing.SwingDispatcherFactory - -# Desktop release shrinking sees optional integrations from JOGL, Commons Compress -# Pack200, and OkHttp platform probes, so keep ProGuard from treating them as blockers. --dontwarn com.jetbrains.JBR --dontwarn com.jogamp.** --dontwarn jogamp.** --dontwarn org.apache.commons.compress.harmony.pack200.** --dontwarn org.objectweb.asm.** --dontwarn io.ktor.serialization.kotlinx.** --dontwarn com.sun.jna.** --dontwarn org.eclipse.swt.** --dontwarn javafx.** --dontwarn com.sun.javafx.** --dontwarn okhttp3.internal.platform.** --dontwarn org.bouncycastle.** --dontwarn org.conscrypt.** --dontwarn org.openjsse.** --dontwarn android.** --dontwarn com.github.luben.zstd.** --dontwarn org.brotli.dec.** diff --git a/desktopApp/packaging/README.md b/desktopApp/packaging/README.md deleted file mode 100644 index 65cd664..0000000 --- a/desktopApp/packaging/README.md +++ /dev/null @@ -1,229 +0,0 @@ -# Desktop package builds - -Build Linux packages on the matching distro VM when testing manually: - -```bash -cd ~/Reader -./gradlew :desktopApp:packageDeb -x test -./gradlew :desktopApp:packageRpm -x test -./gradlew :desktopApp:packageAur -x test -``` - -Build a Windows MSIX locally on Windows with the Windows SDK installed: - -```powershell -cd C:\Users\aryan\Desktop\Reader -.\gradlew.bat -PdesktopOnly=true -PdesktopAllowUnconfiguredStandardServices=true :desktopApp:packageReleaseMsix -x test -``` - -Copy the newest generated MSIX to your desktop: - -```powershell -$msix = Get-ChildItem .\desktopApp\build\compose\binaries\main-release\msix -Filter *.msix | Sort-Object LastWriteTime -Descending | Select-Object -First 1 -Copy-Item -Force $msix.FullName "$env:USERPROFILE\Desktop\" -``` - -The MSIX task is separate from MSI packaging. It stages the release app image at -`desktopApp/build/msix/package`, packages it with Windows SDK `makeappx.exe`, and -writes the MSIX to: - -```text -desktopApp/build/compose/binaries/main-release/msix -``` - -For Microsoft Store submission, set the package identity values from Partner -Center so `AppxManifest.xml` matches the reserved app identity: - -```powershell -.\gradlew.bat ` - -PdesktopOnly=true ` - -PdesktopMsixIdentityName= ` - -PdesktopMsixPublisher= ` - -PdesktopMsixPublisherDisplayName= ` - :desktopApp:packageReleaseMsix -x test -``` - -If Windows SDK tools are not on `PATH`, pass them explicitly: - -```powershell -.\gradlew.bat ` - -PdesktopMakeAppxPath="C:\Program Files (x86)\Windows Kits\10\bin\\x64\makeappx.exe" ` - :desktopApp:packageReleaseMsix -x test -``` - -Local signing is optional and separate: - -```powershell -.\gradlew.bat ` - -PdesktopMsixCertificatePath=C:\path\to\certificate.pfx ` - -PdesktopMsixCertificatePassword= ` - :desktopApp:signReleaseMsix -x test -``` - -Recommended VM split: - -- Ubuntu: `./gradlew :desktopApp:packageDeb -x test` -- Fedora: `./gradlew :desktopApp:packageRpm -x test` -- Arch: `./gradlew :desktopApp:packageAur -x test` - -Desktop-only Gradle invocations automatically skip the Android app module and the -Android target in `:shared`, so desktop packaging does not require `sdk.dir`, -Android SDK installation, or Android release signing values. You can force that -mode for unusual command shapes with: - -```bash -./gradlew -PdesktopOnly=true :desktopApp:packageDeb -x test -``` - -Desktop release values are centralized in `gradle.properties`: - -```properties -desktopVersion=1.0.1 -desktopPackageVersion=1.0.1 -desktopAurPackageRelease=1 -``` - -The AUR path is native Arch packaging. It does not wrap the `.deb` or `.rpm`. -`packageAur` first creates a Linux app tarball, then generates an AUR worktree at: - -```text -desktopApp/build/aur/episteme-bin -``` - -On Arch, install/test the generated package with: - -```bash -sudo pacman -U ~/Reader/desktopApp/build/aur/episteme-bin/*.pkg.tar.zst -episteme -``` - -For the OSS/offline flavor: - -```bash -./gradlew :desktopApp:packageAur -PdesktopFlavor=oss -x test -sudo pacman -U ~/Reader/desktopApp/build/aur/episteme-oss-bin/*.pkg.tar.zst -episteme-oss -``` - -To inspect the AUR recipe manually instead: - -```bash -./gradlew :desktopApp:prepareAurPackage -x test -cd ~/Reader/desktopApp/build/aur/episteme-bin -makepkg -si -``` - -For publish-ready AUR metadata, pass the release tarball URL: - -```bash -./gradlew :desktopApp:prepareAurPackage \ - -PdesktopAurSourceUrl=https://example.com/releases/episteme-1.0.1-linux-x64.tar.gz \ - -x test -``` - -Then publish the generated `PKGBUILD` and `.SRCINFO` from the AUR directory. - -The generated AUR recipes use `license=('AGPL-3.0-only')` and install the root -`LICENSE` file into `/usr/share/licenses/$pkgname/`. - -## AUR repository setup - -Create an account at: - -```text -https://aur.archlinux.org/register/ -``` - -Add your public SSH key in the account settings, then confirm SSH works: - -```bash -ssh aur@aur.archlinux.org -``` - -The command should authenticate and print AUR help text. It will not open a -normal shell. - -Create the package repos by cloning their not-yet-existing names: - -```bash -git clone ssh://aur@aur.archlinux.org/episteme-bin.git -git clone ssh://aur@aur.archlinux.org/episteme-oss-bin.git -``` - -If a name already exists, inspect it first. If it is abandoned, follow the AUR -orphan/adoption process instead of creating a duplicate package name. - -For each release, extract the matching `aur--.tar.gz` metadata -archive from the GitHub release, copy `PKGBUILD` and `.SRCINFO` into the matching -AUR clone, then commit and push: - -```bash -tar -xzf aur-episteme-bin-1.0.1.tar.gz -C episteme-bin -cd episteme-bin -git add PKGBUILD .SRCINFO -git commit -m "Update to 1.0.1" -git push -``` - -Repeat the same flow for `episteme-oss-bin`. - -## CI release workflow - -`Desktop release` in GitHub Actions builds desktop artifacts for standard and -OSS flavors: - -- Windows MSI -- Ubuntu/Debian DEB -- Fedora RPM -- Linux tarball used by AUR -- Direct Arch `.pkg.tar.zst` -- AUR metadata archives containing `PKGBUILD` and `.SRCINFO` -- `SHA256SUMS.txt` - -Before running it, publish Pdfium once from a machine that has the ignored -`third_party/pdfium` folders: - -```powershell -.\scripts\desktop\publish-pdfium-release.ps1 ` - -Repository Aryan-Raj3112/episteme ` - -Tag pdfium-desktop-v1 -``` - -That release must contain: - -```text -pdfium-linux-x64-v8.zip -pdfium-win-x64-v8.zip -``` - -The desktop release workflow downloads those assets with: - -```powershell -.\scripts\desktop\download-pdfium.ps1 -Tag pdfium-desktop-v1 -``` - -Required GitHub Secrets for standard desktop packages: - -```text -DESKTOP_FIREBASE_PROJECT_ID -DESKTOP_FIREBASE_WEB_API_KEY -DESKTOP_GOOGLE_OAUTH_CLIENT_ID -DESKTOP_GOOGLE_OAUTH_CLIENT_SECRET -``` - -`MYAPP_RELEASE_STORE_FILE` is not used by desktop packaging. Android is skipped -for `:desktopApp:*` tasks. - -AUR publishing still needs the two AUR repos: - -```text -episteme-bin -episteme-oss-bin -``` - -Upload the generated `PKGBUILD` and `.SRCINFO` from: - -```text -aur-episteme-bin-.tar.gz -aur-episteme-oss-bin-.tar.gz -``` diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAccountProfileRepository.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAccountProfileRepository.kt deleted file mode 100644 index 03181c1..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAccountProfileRepository.kt +++ /dev/null @@ -1,138 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import java.io.File -import java.net.HttpURLConnection -import java.net.URL -import java.net.URLEncoder -import java.util.Properties - -internal data class DesktopAccountProfile( - val isProUser: Boolean = false, - val credits: Int = 0, - val fetchedAtEpochMillis: Long = 0L -) - -// Credits and Pro status are server-owned, so startup only trusts a recent snapshot. -internal const val DesktopAccountProfileCacheTtlMillis: Long = 30L * 60L * 1000L - -internal fun DesktopAccountProfile.isFresh( - nowEpochMillis: Long = System.currentTimeMillis(), - ttlMillis: Long = DesktopAccountProfileCacheTtlMillis -): Boolean { - if (fetchedAtEpochMillis <= 0L || ttlMillis <= 0L) return false - val ageMillis = nowEpochMillis - fetchedAtEpochMillis - return ageMillis in 0L..ttlMillis -} - -internal class DesktopAccountProfileRepository( - private val config: DesktopCloudConfig, - private val store: DesktopAccountProfileStore = DesktopAccountProfileStore() -) { - fun cachedProfile( - uid: String, - nowEpochMillis: Long = System.currentTimeMillis() - ): DesktopAccountProfile? { - return store.load(uid)?.takeIf { profile -> profile.isFresh(nowEpochMillis) } - } - - fun saveFetchedProfile(uid: String, profile: DesktopAccountProfile) { - store.save(uid, profile) - } - - fun clearCachedProfiles() { - store.clear() - } - - suspend fun fetchProfile(uid: String, idToken: String): DesktopAccountProfile = withContext(Dispatchers.IO) { - if (uid.isBlank() || idToken.isBlank()) return@withContext DesktopAccountProfile() - val url = "https://firestore.googleapis.com/v1/projects/${urlEncode(config.firebaseProjectId)}/databases/(default)/documents/users/${urlEncode(uid)}" - val connection = (URL(url).openConnection() as HttpURLConnection).apply { - requestMethod = "GET" - setRequestProperty("Authorization", "Bearer $idToken") - setRequestProperty("Accept", "application/json") - connectTimeout = 12_000 - readTimeout = 20_000 - } - try { - if (connection.responseCode == HttpURLConnection.HTTP_NOT_FOUND) { - return@withContext DesktopAccountProfile(fetchedAtEpochMillis = System.currentTimeMillis()) - } - val stream = if (connection.responseCode in 200..299) connection.inputStream else connection.errorStream - val text = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty() - if (connection.responseCode !in 200..299) { - throw IllegalStateException("Could not check account status: HTTP ${connection.responseCode}") - } - val fields = DesktopAccountJson.parseToJsonElement(text).jsonObject["fields"].jsonObjectOrNull() - val profile = DesktopAccountProfile( - isProUser = fields?.booleanField("isPro") == true, - credits = fields?.numberField("credits")?.toInt() ?: 0, - fetchedAtEpochMillis = System.currentTimeMillis() - ) - profile - } finally { - connection.disconnect() - } - } -} - -internal class DesktopAccountProfileStore( - private val settingsFile: File = File(desktopUserConfigRoot(), "account_profile.properties") -) { - fun load(uid: String): DesktopAccountProfile? { - if (uid.isBlank() || !settingsFile.isFile) return null - val properties = Properties() - return runCatching { - settingsFile.inputStream().use(properties::load) - if (properties.getProperty("uid", "") != uid) return null - DesktopAccountProfile( - isProUser = properties.getProperty("isProUser", "false").toBooleanStrictOrNull() ?: false, - credits = properties.getProperty("credits", "0").toIntOrNull() ?: 0, - fetchedAtEpochMillis = properties.getProperty("fetchedAtEpochMillis", "0").toLongOrNull() ?: 0L - ) - }.getOrNull() - } - - fun save(uid: String, profile: DesktopAccountProfile) { - if (uid.isBlank()) return - val properties = Properties().apply { - setProperty("uid", uid) - setProperty("isProUser", profile.isProUser.toString()) - setProperty("credits", profile.credits.toString()) - setProperty("fetchedAtEpochMillis", profile.fetchedAtEpochMillis.toString()) - } - settingsFile.storePropertiesAtomically(properties, "Episteme desktop account profile") - } - - fun clear() { - settingsFile.delete() - } -} - -private val DesktopAccountJson = Json { ignoreUnknownKeys = true } - -private fun JsonObject?.booleanField(key: String): Boolean? { - return this?.get(key) - ?.jsonObjectOrNull() - ?.get("booleanValue") - ?.jsonPrimitive - ?.contentOrNull - ?.toBooleanStrictOrNull() -} - -private fun JsonObject?.numberField(key: String): Double? { - val field = this?.get(key)?.jsonObjectOrNull() ?: return null - return field["integerValue"]?.jsonPrimitive?.contentOrNull?.toDoubleOrNull() - ?: field["doubleValue"]?.jsonPrimitive?.contentOrNull?.toDoubleOrNull() -} - -private fun JsonElement?.jsonObjectOrNull(): JsonObject? = this as? JsonObject - -private fun urlEncode(value: String): String = URLEncoder.encode(value, Charsets.UTF_8.name()) diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAiByokStore.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAiByokStore.kt deleted file mode 100644 index 5a5dfdf..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAiByokStore.kt +++ /dev/null @@ -1,1023 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import org.dueattendant149.bookreader.shared.DEFAULT_CLOUD_TTS_SPEAKER_ID -import org.dueattendant149.bookreader.shared.GEMINI_CLOUD_TTS_MODEL_ID -import org.dueattendant149.bookreader.shared.ReaderAiByokSettings -import com.sun.jna.Library -import com.sun.jna.Memory -import com.sun.jna.Native -import com.sun.jna.Pointer -import com.sun.jna.Structure -import com.sun.jna.WString -import com.sun.jna.ptr.PointerByReference -import com.sun.jna.win32.StdCallLibrary -import java.io.File -import java.util.Base64 -import java.util.Properties -import java.util.concurrent.TimeUnit - -private const val WINDOWS_CRED_TYPE_GENERIC = 1 -private const val WINDOWS_CRED_PERSIST_LOCAL_MACHINE = 2 -private const val WINDOWS_ERROR_NOT_FOUND = 1168 -private const val LINUX_SECRET_SCHEMA_DONT_MATCH_NAME = 2 -private const val LINUX_SECRET_SCHEMA_ATTRIBUTE_STRING = 0 -private const val LINUX_SECRET_SCHEMA_NAME = "org.dueattendant149.bookreader.Secret" -private const val LINUX_SECRET_COLLECTION_DEFAULT = "default" -private const val LINUX_SECRET_SERVICE_APPLICATION_ATTRIBUTE = "application" -private const val LINUX_SECRET_SERVICE_APPLICATION_VALUE = "Episteme.Reader" -private const val LINUX_SECRET_SERVICE_KEY_ATTRIBUTE = "key" -private const val LINUX_LIBSECRET_PREFIX = "linux-libsecret:" -private const val LINUX_SECRET_TOOL_PREFIX = "secret-tool:" - -internal class DesktopAiByokStore( - private val settingsFile: File = defaultSettingsFile(), - private val secretCodec: DesktopSecretCodec = DesktopSecretCodec.platform() -) { - val isSecureStorageAvailable: Boolean - get() = secretCodec.isAvailable.also { available -> - logDesktopTts("settings_secure_available codec=${secretCodec.name} available=$available") - } - - fun load(): ReaderAiByokSettings { - val settingsFileExists = settingsFile.exists() - logDesktopTts( - "settings_load_start file=\"${settingsFile.absolutePath.desktopTtsPreview(220)}\" " + - "exists=$settingsFileExists secureStorage=${if (settingsFileExists) "checking" else "skipped"}" - ) - if (!settingsFileExists) { - logDesktopTts("settings_load_empty reason=file_missing") - return ReaderAiByokSettings() - } - val secureStorageAvailable = secretCodec.isAvailable - logDesktopTts("settings_load_secure_storage codec=${secretCodec.name} available=$secureStorageAvailable") - val properties = Properties() - return runCatching { - settingsFile.inputStream().use(properties::load) - val legacyGeminiKey = properties.getProperty(LegacyGeminiKey, "") - val legacyGroqKey = properties.getProperty(LegacyGroqKey, "") - val loadedSettings = ReaderAiByokSettings( - geminiKey = loadSecret(properties, GeminiKey, legacyGeminiKey, secureStorageAvailable), - groqKey = loadSecret(properties, GroqKey, legacyGroqKey, secureStorageAvailable), - useOneModel = properties.getProperty("useOneModel", "true").toBooleanStrictOrNull() ?: true, - modelForAll = properties.getProperty("modelForAll", ""), - defineModel = properties.getProperty("defineModel", ""), - summarizeModel = properties.getProperty("summarizeModel", ""), - recapModel = properties.getProperty("recapModel", ""), - ttsModel = properties.getProperty("ttsModel", ""), - hideReaderAiFeatures = properties.getProperty("hideReaderAiFeatures", "false").toBooleanStrictOrNull() ?: false, - ttsSpeakerId = properties.getProperty("ttsSpeakerId", DEFAULT_CLOUD_TTS_SPEAKER_ID) - ).sanitized() - val settings = if (loadedSettings.geminiKey.isNotBlank() && loadedSettings.ttsModel.isBlank()) { - loadedSettings.copy(ttsModel = GEMINI_CLOUD_TTS_MODEL_ID) - } else { - loadedSettings - }.toDesktopPersistableAiSettings() - if (secureStorageAvailable && - (legacyGeminiKey.isNotBlank() || legacyGroqKey.isNotBlank() || settings != loadedSettings) - ) { - logDesktopTts( - "settings_load_migrate legacyGemini=${legacyGeminiKey.isNotBlank()} " + - "legacyGroq=${legacyGroqKey.isNotBlank()} autoTtsModel=${settings != loadedSettings}" - ) - runCatching { save(settings) } - } - logDesktopTts( - "settings_load_complete geminiKey=${settings.geminiKey.isNotBlank()} groqKey=${settings.groqKey.isNotBlank()} " + - "ttsModel=\"${settings.ttsModel.desktopTtsPreview()}\" cloudAvailable=${settings.isCloudTtsAvailable}" - ) - settings - }.getOrElse { error -> - logDesktopTts("settings_load_failed error=\"${error.desktopTtsSummary()}\"") - ReaderAiByokSettings() - } - } - - fun save(settings: ReaderAiByokSettings) { - val sanitized = settings.toDesktopPersistableAiSettings() - logDesktopTts( - "settings_save_start file=\"${settingsFile.absolutePath.desktopTtsPreview(220)}\" " + - "secureStorage=${secretCodec.isAvailable} geminiKey=${sanitized.geminiKey.isNotBlank()} " + - "groqKey=${sanitized.groqKey.isNotBlank()} ttsModel=\"${sanitized.ttsModel.desktopTtsPreview()}\"" - ) - val properties = Properties().apply { - setProtectedSecret(GeminiKey, sanitized.geminiKey) - setProtectedSecret(GroqKey, sanitized.groqKey) - setProperty("useOneModel", sanitized.useOneModel.toString()) - setProperty("modelForAll", sanitized.modelForAll) - setProperty("defineModel", sanitized.defineModel) - setProperty("summarizeModel", sanitized.summarizeModel) - setProperty("recapModel", sanitized.recapModel) - setProperty("ttsModel", sanitized.ttsModel) - setProperty("hideReaderAiFeatures", sanitized.hideReaderAiFeatures.toString()) - setProperty("ttsSpeakerId", sanitized.ttsSpeakerId) - } - settingsFile.parentFile?.mkdirs() - settingsFile.storePropertiesAtomically(properties, "Episteme desktop AI keys and models") - logDesktopTts( - "settings_save_complete geminiProtected=${properties.getProperty(GeminiKey, "").isNotBlank()} " + - "groqProtected=${properties.getProperty(GroqKey, "").isNotBlank()}" - ) - } - - private fun loadSecret( - properties: Properties, - key: String, - legacyPlaintext: String, - secureStorageAvailable: Boolean - ): String { - val protectedValue = properties.getProperty(key, "") - val decrypted = protectedValue - .takeIf { it.isNotBlank() } - ?.let { - runCatching { secretCodec.unprotect(key, it) } - .onFailure { error -> logDesktopTts("settings_secret_unprotect_failed key=$key error=\"${error.desktopTtsSummary()}\"") } - .getOrDefault("") - } - .orEmpty() - if (decrypted.isNotBlank()) return decrypted - return legacyPlaintext.takeIf { secureStorageAvailable }.orEmpty() - } - - private fun Properties.setProtectedSecret(key: String, value: String) { - val trimmed = value.trim() - if (trimmed.isBlank()) { - secretCodec.delete(key) - return - } - runCatching { secretCodec.protect(key, trimmed) } - .onSuccess { protectedValue -> - if (protectedValue.isBlank()) { - logDesktopTts("settings_secret_protect_empty key=$key codec=${secretCodec.name}") - } else { - setProperty(key, protectedValue) - logDesktopTts("settings_secret_protect_success key=$key codec=${secretCodec.name} prefix=\"${protectedValue.substringBefore(':', protectedValue)}\"") - } - } - .onFailure { error -> - logDesktopTts("settings_secret_protect_failed key=$key codec=${secretCodec.name} error=\"${error.desktopTtsSummary()}\"") - } - } - - companion object { - private const val GeminiKey = "geminiKeyProtected" - private const val GroqKey = "groqKeyProtected" - private const val LegacyGeminiKey = "geminiKey" - private const val LegacyGroqKey = "groqKey" - - fun defaultSettingsFile(): File { - return File(desktopUserConfigRoot(), "ai-byok.properties") - } - } -} - -internal interface DesktopSecretCodec { - val name: String get() = this::class.java.simpleName.ifBlank { "DesktopSecretCodec" } - val isAvailable: Boolean - fun protect(value: String): String - fun unprotect(value: String): String - fun protect(keyName: String, value: String): String = protect(value) - fun unprotect(keyName: String, value: String): String = unprotect(value) - fun delete(keyName: String) = Unit - - companion object { - fun platform(): DesktopSecretCodec { - val osName = System.getProperty("os.name").orEmpty() - val codec = when { - osName.startsWith("Windows", ignoreCase = true) -> WindowsSecretCodec - osName.contains("Linux", ignoreCase = true) -> LinuxSecretServiceCodec() - else -> UnavailableDesktopSecretCodec - } - logDesktopTts("settings_platform os=\"${osName.desktopTtsPreview()}\" codec=${codec.name}") - return codec - } - } -} - -private object UnavailableDesktopSecretCodec : DesktopSecretCodec { - override val name: String = "unavailable" - override val isAvailable: Boolean = false - override fun protect(value: String): String { - throw IllegalStateException("Secure key storage is unavailable on this operating system.") - } - override fun unprotect(value: String): String = "" -} - -internal data class DesktopSecretCommandResult( - val exitCode: Int, - val stdout: String, - val stderr: String -) { - val isSuccess: Boolean get() = exitCode == 0 - val errorSummary: String - get() = stderr.ifBlank { stdout }.desktopTtsPreview(240).ifBlank { "exit code $exitCode" } -} - -internal interface DesktopSecretCommandRunner { - fun isExecutableAvailable(command: String): Boolean - fun run(command: List, input: String? = null, timeoutMillis: Long = 5_000L): DesktopSecretCommandResult -} - -private object DesktopProcessSecretCommandRunner : DesktopSecretCommandRunner { - override fun isExecutableAvailable(command: String): Boolean { - val path = System.getenv("PATH").orEmpty() - return path.split(File.pathSeparator) - .asSequence() - .map { it.trim() } - .filter { it.isNotEmpty() } - .any { directory -> - File(directory, command).let { it.isFile && it.canExecute() } - } - } - - override fun run(command: List, input: String?, timeoutMillis: Long): DesktopSecretCommandResult { - require(command.isNotEmpty()) { "Secret command cannot be empty." } - val process = ProcessBuilder(command).start() - input?.let { value -> - process.outputStream.use { output -> - output.write(value.toByteArray(Charsets.UTF_8)) - } - } ?: process.outputStream.close() - - val completed = process.waitFor(timeoutMillis, TimeUnit.MILLISECONDS) - if (!completed) { - process.destroyForcibly() - throw IllegalStateException("Timed out waiting for ${command.first()} secure storage command.") - } - return DesktopSecretCommandResult( - exitCode = process.exitValue(), - stdout = process.inputStream.readBytes().toString(Charsets.UTF_8), - stderr = process.errorStream.readBytes().toString(Charsets.UTF_8) - ) - } -} - -internal class LinuxSecretServiceCodec( - private val libsecretCodec: DesktopSecretCodec = LinuxLibsecretCodec(), - private val secretToolCodec: DesktopSecretCodec = LinuxSecretToolCodec() -) : DesktopSecretCodec { - private val codecs: List = listOf(libsecretCodec, secretToolCodec) - - private val selectedCodec: DesktopSecretCodec? by lazy { - codecs.firstOrNull { codec -> - runCatching { codec.isAvailable } - .onFailure { error -> - logDesktopTts("settings_linux_secret_service_probe_failed codec=${codec.name} error=\"${error.desktopTtsSummary()}\"") - } - .getOrDefault(false) - } - } - - override val name: String = "linux-secret-service" - - override val isAvailable: Boolean - get() = selectedCodec != null - - override fun protect(value: String): String { - return protect("secret", value) - } - - override fun unprotect(value: String): String { - return unprotect("secret", value) - } - - override fun protect(keyName: String, value: String): String { - val codec = selectedCodec ?: throw IllegalStateException( - "Linux Secret Service is unavailable. Install gnome-keyring or another Secret Service provider." - ) - return codec.protect(keyName, value) - } - - override fun unprotect(keyName: String, value: String): String { - val orderedCodecs = when { - value.startsWith(LINUX_LIBSECRET_PREFIX) -> listOf(libsecretCodec, secretToolCodec) - value.startsWith(LINUX_SECRET_TOOL_PREFIX) -> listOf(libsecretCodec, secretToolCodec) - else -> codecs - } - for (codec in orderedCodecs) { - val secret = runCatching { - if (!codec.isAvailable) "" else codec.unprotect(keyName, value) - }.onFailure { error -> - logDesktopTts( - "settings_linux_secret_service_read_failed codec=${codec.name} key=$keyName " + - "error=\"${error.desktopTtsSummary()}\"" - ) - }.getOrDefault("") - if (secret.isNotBlank()) return secret - } - return "" - } - - override fun delete(keyName: String) { - codecs.forEach { codec -> - runCatching { codec.delete(keyName) } - .onFailure { error -> - logDesktopTts( - "settings_linux_secret_service_delete_failed codec=${codec.name} key=$keyName " + - "error=\"${error.desktopTtsSummary()}\"" - ) - } - } - } -} - -internal interface LinuxSecretServiceClient { - val isAvailable: Boolean - fun store(key: String, label: String, password: String) - fun lookup(key: String): String? - fun clear(key: String) -} - -internal class LinuxLibsecretCodec( - private val client: LinuxSecretServiceClient = JnaLinuxSecretServiceClient -) : DesktopSecretCodec { - override val name: String = "linux-libsecret" - - override val isAvailable: Boolean by lazy { - val available = if (!client.isAvailable) { - false - } else { - val probeKey = linuxSecretKey("probe") - val probeSecret = "episteme-linux-libsecret-probe" - runCatching { - client.store(probeKey, "Episteme secure storage probe", probeSecret) - client.lookup(probeKey) == probeSecret - }.onFailure { error -> - logDesktopTts("settings_linux_libsecret_unavailable error=\"${error.desktopTtsSummary()}\"") - }.also { - runCatching { client.clear(probeKey) } - }.getOrDefault(false) - } - logDesktopTts("settings_linux_libsecret_available available=$available") - available - } - - override fun protect(value: String): String { - return protect("secret", value) - } - - override fun unprotect(value: String): String { - return unprotect("secret", value) - } - - override fun protect(keyName: String, value: String): String { - if (!isAvailable) { - throw IllegalStateException( - "Linux Secret Service is unavailable. Install gnome-keyring or another Secret Service provider." - ) - } - val key = linuxSecretKey(keyName) - logDesktopTts("settings_linux_libsecret_write_start key=$keyName valueChars=${value.length}") - client.store(key, "Episteme $keyName", value) - logDesktopTts("settings_linux_libsecret_write_result key=$keyName") - return LINUX_LIBSECRET_PREFIX + key - } - - override fun unprotect(keyName: String, value: String): String { - if (!isAvailable) return "" - val key = linuxSecretReferenceKey(keyName, value) - logDesktopTts("settings_linux_libsecret_read_start key=$keyName") - val secret = client.lookup(key).orEmpty() - logDesktopTts("settings_linux_libsecret_read_result key=$keyName chars=${secret.length}") - return secret - } - - override fun delete(keyName: String) { - if (!client.isAvailable) return - val key = linuxSecretKey(keyName) - runCatching { client.clear(key) } - .onFailure { error -> - logDesktopTts("settings_linux_libsecret_delete_failed key=$keyName error=\"${error.desktopTtsSummary()}\"") - } - } -} - -private object JnaLinuxSecretServiceClient : LinuxSecretServiceClient { - override val isAvailable: Boolean by lazy { - runCatching { - LinuxLibsecretNative.INSTANCE - LinuxGlibNative.INSTANCE - true - }.onFailure { error -> - logDesktopTts("settings_linux_libsecret_load_failed error=\"${error.desktopTtsSummary()}\"") - }.getOrDefault(false) - } - - private val schema: Pointer by lazy { - LinuxLibsecretNative.INSTANCE.secret_schema_new( - LINUX_SECRET_SCHEMA_NAME, - LINUX_SECRET_SCHEMA_DONT_MATCH_NAME, - LINUX_SECRET_SERVICE_APPLICATION_ATTRIBUTE, - LINUX_SECRET_SCHEMA_ATTRIBUTE_STRING, - LINUX_SECRET_SERVICE_KEY_ATTRIBUTE, - LINUX_SECRET_SCHEMA_ATTRIBUTE_STRING, - null - ) ?: throw IllegalStateException("Linux Secret Service schema creation failed.") - } - - override fun store(key: String, label: String, password: String) { - val error = PointerByReference() - val stored = LinuxLibsecretNative.INSTANCE.secret_password_store_sync( - schema, - LINUX_SECRET_COLLECTION_DEFAULT, - label, - password, - null, - error, - LINUX_SECRET_SERVICE_APPLICATION_ATTRIBUTE, - LINUX_SECRET_SERVICE_APPLICATION_VALUE, - LINUX_SECRET_SERVICE_KEY_ATTRIBUTE, - key, - null - ) - takeLibsecretError(error)?.let { message -> - throw IllegalStateException("Linux Secret Service write failed: $message") - } - if (!stored) { - throw IllegalStateException("Linux Secret Service write failed: libsecret returned false.") - } - } - - override fun lookup(key: String): String? { - val error = PointerByReference() - val passwordPointer = LinuxLibsecretNative.INSTANCE.secret_password_lookup_sync( - schema, - null, - error, - LINUX_SECRET_SERVICE_APPLICATION_ATTRIBUTE, - LINUX_SECRET_SERVICE_APPLICATION_VALUE, - LINUX_SECRET_SERVICE_KEY_ATTRIBUTE, - key, - null - ) - val errorMessage = takeLibsecretError(error) - if (errorMessage != null) { - passwordPointer?.let { pointer -> LinuxLibsecretNative.INSTANCE.secret_password_free(pointer) } - throw IllegalStateException("Linux Secret Service read failed: $errorMessage") - } - return passwordPointer?.let { pointer -> - try { - pointer.getString(0, Charsets.UTF_8.name()) - } finally { - LinuxLibsecretNative.INSTANCE.secret_password_free(pointer) - } - } - } - - override fun clear(key: String) { - val error = PointerByReference() - LinuxLibsecretNative.INSTANCE.secret_password_clear_sync( - schema, - null, - error, - LINUX_SECRET_SERVICE_APPLICATION_ATTRIBUTE, - LINUX_SECRET_SERVICE_APPLICATION_VALUE, - LINUX_SECRET_SERVICE_KEY_ATTRIBUTE, - key, - null - ) - takeLibsecretError(error)?.let { message -> - throw IllegalStateException("Linux Secret Service delete failed: $message") - } - } - - private fun takeLibsecretError(error: PointerByReference): String? { - val errorPointer = error.value ?: return null - return try { - LinuxGError(errorPointer).message - ?.getString(0, Charsets.UTF_8.name()) - ?.ifBlank { null } - ?: "unknown libsecret error" - } finally { - LinuxGlibNative.INSTANCE.g_error_free(errorPointer) - } - } -} - -private interface LinuxLibsecretNative : Library { - fun secret_schema_new(name: String, flags: Int, vararg attributes: Any?): Pointer? - - fun secret_password_store_sync( - schema: Pointer, - collection: String, - label: String, - password: String, - cancellable: Pointer?, - error: PointerByReference, - vararg attributes: Any? - ): Boolean - - fun secret_password_lookup_sync( - schema: Pointer, - cancellable: Pointer?, - error: PointerByReference, - vararg attributes: Any? - ): Pointer? - - fun secret_password_clear_sync( - schema: Pointer, - cancellable: Pointer?, - error: PointerByReference, - vararg attributes: Any? - ): Boolean - - fun secret_password_free(password: Pointer?) - - companion object { - val INSTANCE: LinuxLibsecretNative by lazy { - loadLinuxNativeLibrary( - LinuxLibsecretNative::class.java, - "secret-1", - "libsecret-1.so.0" - ) - } - } -} - -private interface LinuxGlibNative : Library { - fun g_error_free(error: Pointer?) - - companion object { - val INSTANCE: LinuxGlibNative by lazy { - loadLinuxNativeLibrary( - LinuxGlibNative::class.java, - "glib-2.0", - "libglib-2.0.so.0" - ) - } - } -} - -@Structure.FieldOrder("domain", "code", "message") -internal class LinuxGError(pointer: Pointer) : Structure(pointer) { - @JvmField - var domain: Int = 0 - - @JvmField - var code: Int = 0 - - @JvmField - var message: Pointer? = null - - init { - read() - } -} - -private fun loadLinuxNativeLibrary(type: Class, vararg names: String): T { - var lastError: Throwable? = null - for (name in names) { - val loaded = runCatching { Native.load(name, type) as T } - .onFailure { error -> lastError = error } - .getOrNull() - if (loaded != null) return loaded - } - throw IllegalStateException("Could not load Linux native library ${names.joinToString(" or ")}.", lastError) -} - -internal class LinuxSecretToolCodec( - private val commandRunner: DesktopSecretCommandRunner = DesktopProcessSecretCommandRunner -) : DesktopSecretCodec { - override val name: String = "linux-secret-tool" - - override val isAvailable: Boolean by lazy { - val available = commandRunner.isExecutableAvailable(SecretToolCommand) && - runCatching { - commandRunner.run(listOf(SecretToolCommand, "--help"), timeoutMillis = 3_000L).isSuccess - }.getOrDefault(false) - logDesktopTts("settings_linux_secret_tool_available available=$available") - available - } - - override fun protect(value: String): String { - return protect("secret", value) - } - - override fun unprotect(value: String): String { - return unprotect("secret", value) - } - - override fun protect(keyName: String, value: String): String { - if (!isAvailable) { - throw IllegalStateException( - "Linux Secret Service is unavailable. Install libsecret-tools and make sure a desktop keyring is running." - ) - } - val key = linuxSecretKey(keyName) - logDesktopTts("settings_linux_secret_tool_write_start key=$keyName valueChars=${value.length}") - val result = commandRunner.run( - command = listOf( - SecretToolCommand, - "store", - "--label", - "Episteme $keyName", - LINUX_SECRET_SERVICE_APPLICATION_ATTRIBUTE, - LINUX_SECRET_SERVICE_APPLICATION_VALUE, - LINUX_SECRET_SERVICE_KEY_ATTRIBUTE, - key - ), - input = value, - timeoutMillis = 15_000L - ) - logDesktopTts("settings_linux_secret_tool_write_result key=$keyName exit=${result.exitCode}") - if (!result.isSuccess) { - throw IllegalStateException("Linux Secret Service write failed: ${result.errorSummary}") - } - return LINUX_SECRET_TOOL_PREFIX + key - } - - override fun unprotect(keyName: String, value: String): String { - if (!isAvailable) return "" - val key = linuxSecretReferenceKey(keyName, value) - logDesktopTts("settings_linux_secret_tool_read_start key=$keyName") - val result = commandRunner.run( - command = listOf( - SecretToolCommand, - "lookup", - LINUX_SECRET_SERVICE_APPLICATION_ATTRIBUTE, - LINUX_SECRET_SERVICE_APPLICATION_VALUE, - LINUX_SECRET_SERVICE_KEY_ATTRIBUTE, - key - ), - timeoutMillis = 8_000L - ) - logDesktopTts("settings_linux_secret_tool_read_result key=$keyName exit=${result.exitCode} chars=${result.stdout.length}") - if (!result.isSuccess) { - throw IllegalStateException("Linux Secret Service read failed: ${result.errorSummary}") - } - return result.stdout.trimEnd('\r', '\n') - } - - override fun delete(keyName: String) { - val key = linuxSecretKey(keyName) - runCatching { - commandRunner.run( - command = listOf( - SecretToolCommand, - "clear", - LINUX_SECRET_SERVICE_APPLICATION_ATTRIBUTE, - LINUX_SECRET_SERVICE_APPLICATION_VALUE, - LINUX_SECRET_SERVICE_KEY_ATTRIBUTE, - key - ), - timeoutMillis = 8_000L - ) - }.onFailure { error -> - logDesktopTts("settings_linux_secret_tool_delete_failed key=$keyName error=\"${error.desktopTtsSummary()}\"") - } - } - - private companion object { - const val SecretToolCommand = "secret-tool" - } -} - -private fun linuxSecretKey(keyName: String): String { - return "Episteme.Reader.$keyName" -} - -private fun linuxSecretReferenceKey(keyName: String, reference: String): String { - return when { - reference.startsWith(LINUX_LIBSECRET_PREFIX) -> reference.removePrefix(LINUX_LIBSECRET_PREFIX) - reference.startsWith(LINUX_SECRET_TOOL_PREFIX) -> reference.removePrefix(LINUX_SECRET_TOOL_PREFIX) - else -> "" - }.ifBlank { linuxSecretKey(keyName) } -} - -private object WindowsSecretCodec : DesktopSecretCodec { - override val name: String = "windows" - - override val isAvailable: Boolean - get() { - val wincred = WindowsCredentialSecretCodec.isAvailable - val dpapi = WindowsDpapiSecretCodec.isAvailable - logDesktopTts("settings_windows_available wincred=$wincred dpapi=$dpapi") - return wincred || dpapi - } - - override fun protect(value: String): String { - return protect("secret", value) - } - - override fun unprotect(value: String): String { - return unprotect("secret", value) - } - - override fun protect(keyName: String, value: String): String { - val wincredFailure = runCatching { return WindowsCredentialSecretCodec.protect(keyName, value) } - .exceptionOrNull() - ?.also { error -> logDesktopTts("settings_wincred_protect_failed key=$keyName error=\"${error.desktopTtsSummary()}\"") } - val dpapiFailure = runCatching { return WindowsDpapiSecretCodec.protect(value) } - .exceptionOrNull() - ?.also { error -> logDesktopTts("settings_dpapi_protect_failed key=$keyName error=\"${error.desktopTtsSummary()}\"") } - throw IllegalStateException( - "No Windows secure key store write succeeded. " + - "Credential Manager: ${wincredFailure?.desktopTtsSummary() ?: "not attempted"}; " + - "DPAPI: ${dpapiFailure?.desktopTtsSummary() ?: "not attempted"}" - ) - } - - override fun unprotect(keyName: String, value: String): String { - return when { - value.startsWith(WindowsCredentialSecretCodec.Prefix) -> WindowsCredentialSecretCodec.unprotect(keyName, value) - value.startsWith(WindowsDpapiSecretCodec.Prefix) -> WindowsDpapiSecretCodec.unprotect(value) - else -> "" - } - } - - override fun delete(keyName: String) { - if (WindowsCredentialSecretCodec.isAvailable) WindowsCredentialSecretCodec.delete(keyName) - } -} - -private object WindowsCredentialSecretCodec : DesktopSecretCodec { - override val name: String = "wincred" - - const val Prefix = "wincred:" - - override val isAvailable: Boolean by lazy { - val probeKey = "probe" - val probe = "episteme-wincred-probe" - logDesktopTts("settings_wincred_probe_start") - runCatching { - val reference = protect(probeKey, probe) - val restored = unprotect(probeKey, reference) - val matches = restored == probe - logDesktopTts( - "settings_wincred_probe_result matches=$matches referencePrefix=\"${reference.substringBefore(':', reference)}\" " + - "restoredChars=${restored.length}" - ) - matches - }.onFailure { error -> - logDesktopTts("settings_wincred_unavailable error=\"${error.desktopTtsSummary()}\"") - }.also { - runCatching { delete(probeKey) } - }.getOrDefault(false) - } - - override fun protect(value: String): String { - return protect("secret", value) - } - - override fun unprotect(value: String): String { - return unprotect("secret", value) - } - - override fun protect(keyName: String, value: String): String { - val target = credentialTarget(keyName) - logDesktopTts("settings_wincred_write_start key=$keyName target=\"$target\" valueChars=${value.length}") - val credential = WindowsCredential(target, value) - credential.write() - val ok = Advapi32.INSTANCE.CredWriteW(credential, 0) - val errorCode = Native.getLastError() - logDesktopTts("settings_wincred_write_result key=$keyName ok=$ok error=$errorCode") - if (!ok) throw IllegalStateException("Windows Credential Manager write failed: $errorCode") - return Prefix + target - } - - override fun unprotect(keyName: String, value: String): String { - if (!value.startsWith(Prefix)) return "" - val target = value.removePrefix(Prefix).ifBlank { credentialTarget(keyName) } - logDesktopTts("settings_wincred_read_start key=$keyName target=\"$target\"") - val credentialPointer = PointerByReference() - val ok = Advapi32.INSTANCE.CredReadW(WString(target), WINDOWS_CRED_TYPE_GENERIC, 0, credentialPointer) - val errorCode = Native.getLastError() - logDesktopTts("settings_wincred_read_result key=$keyName ok=$ok error=$errorCode hasPointer=${credentialPointer.value != null}") - if (!ok) throw IllegalStateException("Windows Credential Manager read failed: $errorCode") - val pointer = credentialPointer.value ?: return "" - return try { - val credential = WindowsCredential(pointer) - val blobPointer = credential.CredentialBlob ?: return "" - logDesktopTts("settings_wincred_read_blob key=$keyName bytes=${credential.CredentialBlobSize}") - String(blobPointer.getByteArray(0, credential.CredentialBlobSize), Charsets.UTF_8) - } finally { - Advapi32.INSTANCE.CredFree(pointer) - } - } - - override fun delete(keyName: String) { - val ok = Advapi32.INSTANCE.CredDeleteW(WString(credentialTarget(keyName)), WINDOWS_CRED_TYPE_GENERIC, 0) - val errorCode = Native.getLastError() - logDesktopTts("settings_wincred_delete_result key=$keyName ok=$ok error=$errorCode") - if (!ok && errorCode != WINDOWS_ERROR_NOT_FOUND) { - logDesktopTts("settings_wincred_delete_failed key=$keyName error=$errorCode") - } - } - - private fun credentialTarget(keyName: String): String { - return "Episteme.Reader.AI.$keyName" - } - - private interface Advapi32 : StdCallLibrary { - fun CredWriteW(credential: WindowsCredential, flags: Int): Boolean - fun CredReadW(targetName: WString, type: Int, flags: Int, credential: PointerByReference): Boolean - fun CredDeleteW(targetName: WString, type: Int, flags: Int): Boolean - fun CredFree(buffer: Pointer?) - - companion object { - val INSTANCE: Advapi32 by lazy { - Native.load("Advapi32", Advapi32::class.java) as Advapi32 - } - } - } -} - -private object WindowsDpapiSecretCodec : DesktopSecretCodec { - override val name: String = "dpapi" - - const val Prefix = "dpapi:" - - override val isAvailable: Boolean by lazy { - logDesktopTts("settings_dpapi_probe_start") - runCatching { - Crypt32.INSTANCE - Kernel32.INSTANCE - val probe = "episteme-dpapi-probe" - val encrypted = protect(probe) - val restored = unprotect(encrypted) - val matches = restored == probe - logDesktopTts("settings_dpapi_probe_result matches=$matches encryptedChars=${encrypted.length} restoredChars=${restored.length}") - matches - }.onFailure { error -> - logDesktopTts("settings_dpapi_unavailable error=\"${error.desktopTtsSummary()}\"") - }.getOrDefault(false) - } - - override fun protect(value: String): String { - val input = DataBlob(value.toByteArray(Charsets.UTF_8)) - val output = DataBlob() - logDesktopTts("settings_dpapi_protect_start bytes=${value.toByteArray(Charsets.UTF_8).size}") - val ok = Crypt32.INSTANCE.CryptProtectData(input, null, null, null, null, 0, output) - val errorCode = Native.getLastError() - logDesktopTts("settings_dpapi_protect_result ok=$ok error=$errorCode") - if (!ok) throw IllegalStateException("Windows DPAPI protect failed: $errorCode") - return try { - Prefix + Base64.getEncoder().encodeToString(output.toByteArray()) - } finally { - output.free() - } - } - - override fun unprotect(value: String): String { - if (!value.startsWith(Prefix)) return "" - val encrypted = Base64.getDecoder().decode(value.removePrefix(Prefix)) - val input = DataBlob(encrypted) - val output = DataBlob() - logDesktopTts("settings_dpapi_unprotect_start bytes=${encrypted.size}") - val ok = Crypt32.INSTANCE.CryptUnprotectData(input, null, null, null, null, 0, output) - val errorCode = Native.getLastError() - logDesktopTts("settings_dpapi_unprotect_result ok=$ok error=$errorCode") - if (!ok) throw IllegalStateException("Windows DPAPI unprotect failed: $errorCode") - return try { - String(output.toByteArray(), Charsets.UTF_8) - } finally { - output.free() - } - } -} - -@Structure.FieldOrder("dwLowDateTime", "dwHighDateTime") -internal class WindowsFileTime : Structure() { - @JvmField - var dwLowDateTime: Int = 0 - - @JvmField - var dwHighDateTime: Int = 0 -} - -@Structure.FieldOrder( - "Flags", - "Type", - "TargetName", - "Comment", - "LastWritten", - "CredentialBlobSize", - "CredentialBlob", - "Persist", - "AttributeCount", - "Attributes", - "TargetAlias", - "UserName" -) -internal open class WindowsCredential : Structure { - @JvmField - var Flags: Int = 0 - - @JvmField - var Type: Int = WINDOWS_CRED_TYPE_GENERIC - - @JvmField - var TargetName: WString? = null - - @JvmField - var Comment: WString? = null - - @JvmField - var LastWritten: WindowsFileTime = WindowsFileTime() - - @JvmField - var CredentialBlobSize: Int = 0 - - @JvmField - var CredentialBlob: Pointer? = null - - @JvmField - var Persist: Int = WINDOWS_CRED_PERSIST_LOCAL_MACHINE - - @JvmField - var AttributeCount: Int = 0 - - @JvmField - var Attributes: Pointer? = null - - @JvmField - var TargetAlias: WString? = null - - @JvmField - var UserName: WString? = WString("Episteme") - - private var blobMemory: Memory? = null - - constructor() : super() - - constructor(pointer: Pointer) : super(pointer) { - read() - } - - constructor(target: String, secret: String) : super() { - val bytes = secret.toByteArray(Charsets.UTF_8) - TargetName = WString(target) - CredentialBlobSize = bytes.size - blobMemory = Memory(bytes.size.toLong()).also { memory -> - memory.write(0, bytes, 0, bytes.size) - CredentialBlob = memory - } - } -} - -@Structure.FieldOrder("cbData", "pbData") -internal open class DataBlob() : Structure() { - @JvmField - var cbData: Int = 0 - - @JvmField - var pbData: Pointer? = null - - private var memory: Memory? = null - - constructor(bytes: ByteArray) : this() { - cbData = bytes.size - memory = Memory(bytes.size.toLong()).also { allocated -> - allocated.write(0, bytes, 0, bytes.size) - pbData = allocated - } - } - - fun toByteArray(): ByteArray { - read() - return pbData?.getByteArray(0, cbData) ?: ByteArray(0) - } - - fun free() { - pbData?.let { Kernel32.INSTANCE.LocalFree(it) } - pbData = null - cbData = 0 - } -} - -private interface Crypt32 : StdCallLibrary { - fun CryptProtectData( - pDataIn: DataBlob, - szDataDescr: String?, - pOptionalEntropy: DataBlob?, - pvReserved: Pointer?, - pPromptStruct: Pointer?, - dwFlags: Int, - pDataOut: DataBlob - ): Boolean - - fun CryptUnprotectData( - pDataIn: DataBlob, - ppszDataDescr: Pointer?, - pOptionalEntropy: DataBlob?, - pvReserved: Pointer?, - pPromptStruct: Pointer?, - dwFlags: Int, - pDataOut: DataBlob - ): Boolean - - companion object { - val INSTANCE: Crypt32 by lazy { - Native.load("Crypt32", Crypt32::class.java) as Crypt32 - } - } -} - -private interface Kernel32 : StdCallLibrary { - fun LocalFree(hMem: Pointer?): Pointer? - - companion object { - val INSTANCE: Kernel32 by lazy { - Native.load("Kernel32", Kernel32::class.java) as Kernel32 - } - } -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAiHub.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAiHub.kt deleted file mode 100644 index 391979b..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAiHub.kt +++ /dev/null @@ -1,338 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.verticalScroll -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.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ContentCopy -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material3.Button -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ScrollableTabRow -import androidx.compose.material3.Surface -import androidx.compose.material3.Tab -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -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.platform.LocalClipboardManager -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import org.dueattendant149.bookreader.shared.RecapResult -import org.dueattendant149.bookreader.shared.SummarizationResult -import org.dueattendant149.bookreader.shared.ui.SharedMarkdownText -import org.dueattendant149.bookreader.shared.ui.readerString - -@Composable -internal fun DesktopAiHubSheet( - bookKey: String, - bookTitle: String, - itemIndex: Int, - itemTitle: String, - summaryCacheStore: DesktopSummaryCacheStore, - summaryResult: SummarizationResult?, - isSummaryLoading: Boolean, - recapResult: RecapResult?, - isRecapLoading: Boolean, - recapProgressMessage: String?, - onGenerateSummary: (force: Boolean) -> Unit, - onClearSummary: () -> Unit, - onGenerateRecap: (() -> Unit)?, - onClearRecap: () -> Unit, - onDismiss: () -> Unit, - credits: Int, - showCredits: Boolean -) { - var selectedTab by remember { mutableIntStateOf(0) } - var cacheRefresh by remember { mutableIntStateOf(0) } - val cachedSummary = remember(bookKey, itemIndex, cacheRefresh) { - summaryCacheStore.getSummary(bookKey, itemIndex) - } - val effectiveSummary = summaryResult ?: cachedSummary?.let { - SummarizationResult(summary = it, isCacheHit = true) - } - val summaryTab = readerString("label_summary", "Summary") - val recapTab = readerString("ai_tab_recap", "Recap") - val cacheTab = readerString("ai_tab_cache", "Cache") - val tabs = buildList { - add(summaryTab) - if (onGenerateRecap != null) add(recapTab) - add(cacheTab) - } - val selectedTabIndex = selectedTab.coerceIn(0, tabs.lastIndex) - val activeTab = tabs.getOrElse(selectedTabIndex) { summaryTab } - - DesktopReaderBottomSheet( - title = readerString("desktop_ai_hub", "AI hub"), - onDismiss = onDismiss - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text(bookTitle, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text(itemTitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) - } - if (showCredits) { - Surface( - color = MaterialTheme.colorScheme.tertiaryContainer, - shape = RoundedCornerShape(10.dp) - ) { - Text( - readerString("credits_count", "%1\$d credits", credits), - modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp), - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onTertiaryContainer - ) - } - } - } - - ScrollableTabRow(selectedTabIndex = selectedTabIndex, edgePadding = 0.dp) { - tabs.forEachIndexed { index, title -> - Tab( - selected = selectedTabIndex == index, - onClick = { selectedTab = index }, - text = { Text(title) } - ) - } - } - - when (activeTab) { - summaryTab -> { - DesktopAiHubResultView( - title = itemTitle, - resultText = effectiveSummary?.summary, - errorText = effectiveSummary?.error, - cost = effectiveSummary?.cost, - freeRemaining = effectiveSummary?.freeRemaining, - isCacheHit = effectiveSummary?.isCacheHit == true, - isLoading = isSummaryLoading, - loadingLabel = readerString("generating_summary", "Generating summary..."), - emptyTitle = readerString("desktop_no_summary_cached_section", "No summary cached for this section."), - primaryActionLabel = readerString("desktop_generate_summary", "Generate summary"), - onPrimaryAction = { onGenerateSummary(false) }, - onRegenerate = { onGenerateSummary(true) }, - onClear = { - summaryCacheStore.deleteSummary(bookKey, itemIndex) - cacheRefresh++ - onClearSummary() - } - ) - } - recapTab -> { - DesktopAiHubResultView( - title = readerString("ai_story_recap", "Story recap"), - resultText = recapResult?.recap, - errorText = recapResult?.error, - cost = recapResult?.cost, - freeRemaining = recapResult?.freeRemaining, - isCacheHit = false, - isLoading = isRecapLoading, - loadingLabel = recapProgressMessage?.takeIf { it.isNotBlank() } ?: readerString("ai_generating_recap", "Generating recap..."), - emptyTitle = readerString("desktop_create_recap_current_position", "Create a recap up to your current position."), - primaryActionLabel = readerString("desktop_generate_recap", "Generate recap"), - onPrimaryAction = { onGenerateRecap?.invoke() }, - onRegenerate = { onGenerateRecap?.invoke() }, - onClear = onClearRecap - ) - } - cacheTab -> { - DesktopSummaryCachePanel( - bookKey = bookKey, - summaryCacheStore = summaryCacheStore, - onCacheChanged = { - cacheRefresh++ - onClearSummary() - } - ) - } - } - } -} - -@Composable -private fun DesktopAiHubResultView( - title: String, - resultText: String?, - errorText: String?, - cost: Double?, - freeRemaining: Int?, - isCacheHit: Boolean, - isLoading: Boolean, - loadingLabel: String, - emptyTitle: String, - primaryActionLabel: String, - onPrimaryAction: () -> Unit, - onRegenerate: () -> Unit, - onClear: () -> Unit -) { - val clipboard = LocalClipboardManager.current - val hasText = !resultText.isNullOrBlank() - val hasError = !errorText.isNullOrBlank() - Column( - modifier = Modifier.fillMaxWidth().heightIn(min = 260.dp, max = 430.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Text(title, modifier = Modifier.weight(1f), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) - DesktopAiUsageBadge( - isCacheHit = isCacheHit, - cost = cost, - freeRemaining = freeRemaining, - isLoading = isLoading - ) - } - if (isLoading) { - LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) - Text(loadingLabel, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - when { - hasText -> { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { - TextButton(enabled = !isLoading, onClick = onRegenerate) { - Text(readerString("ai_regenerate", "Regenerate")) - } - TextButton(enabled = !isLoading, onClick = onClear) { - Text(readerString("action_clear", "Clear")) - } - Spacer(Modifier.weight(1f)) - IconButton(onClick = { clipboard.setText(AnnotatedString(resultText.orEmpty())) }) { - Icon(Icons.Default.ContentCopy, contentDescription = readerString("action_copy", "Copy")) - } - } - HorizontalDivider() - Column(modifier = Modifier.weight(1f).heightIn(min = 120.dp).padding(end = 2.dp).verticalScroll(rememberScrollState())) { - SharedMarkdownText(resultText.orEmpty()) - } - } - hasError -> { - Text(errorText.orEmpty(), color = MaterialTheme.colorScheme.error) - Button(onClick = onPrimaryAction, enabled = !isLoading) { - Text(primaryActionLabel) - } - } - !isLoading -> { - Text(emptyTitle, color = MaterialTheme.colorScheme.onSurfaceVariant) - Button(onClick = onPrimaryAction) { - Text(primaryActionLabel) - } - } - } - } -} - -@Composable -private fun DesktopAiUsageBadge( - isCacheHit: Boolean, - cost: Double?, - freeRemaining: Int?, - isLoading: Boolean -) { - val text = when { - isCacheHit -> readerString("desktop_cached", "Cached") - cost == 0.0 && freeRemaining != null -> readerString("desktop_free_remaining_format", "Free, %1\$d left", freeRemaining) - cost != null -> readerString("desktop_credits_decimal_format", "%1\$s credits", cost) - isLoading -> readerString("desktop_cost_calculating", "Cost calculating") - else -> null - } ?: return - Surface( - color = if (isCacheHit || cost == 0.0) { - MaterialTheme.colorScheme.secondaryContainer - } else { - MaterialTheme.colorScheme.primaryContainer - }, - shape = RoundedCornerShape(10.dp) - ) { - Text( - text, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - style = MaterialTheme.typography.labelSmall, - color = if (isCacheHit || cost == 0.0) { - MaterialTheme.colorScheme.onSecondaryContainer - } else { - MaterialTheme.colorScheme.onPrimaryContainer - } - ) - } -} - -@Composable -private fun DesktopSummaryCachePanel( - bookKey: String, - summaryCacheStore: DesktopSummaryCacheStore, - onCacheChanged: () -> Unit -) { - var cachedItems by remember(bookKey) { mutableStateOf(summaryCacheStore.getAllSummaries(bookKey)) } - if (cachedItems.isEmpty()) { - Text(readerString("desktop_no_cached_summaries_book", "No cached summaries for this book yet."), color = MaterialTheme.colorScheme.onSurfaceVariant) - return - } - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - cachedItems.forEach { item -> - var expanded by remember(item.index, item.summary) { mutableStateOf(false) } - Surface( - color = MaterialTheme.colorScheme.surfaceContainerLow, - shape = RoundedCornerShape(8.dp), - tonalElevation = 1.dp - ) { - Column(modifier = Modifier.fillMaxWidth().padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Column(modifier = Modifier.weight(1f)) { - Text(item.title, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text(readerString("desktop_cached_summary", "Cached summary"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - TextButton(onClick = { expanded = !expanded }) { - Text(if (expanded) readerString("desktop_hide", "Hide") else readerString("desktop_view", "View")) - } - IconButton( - onClick = { - summaryCacheStore.deleteSummary(bookKey, item.index) - cachedItems = summaryCacheStore.getAllSummaries(bookKey) - onCacheChanged() - } - ) { - Icon(Icons.Default.Delete, contentDescription = readerString("desktop_delete_summary", "Delete summary"), tint = MaterialTheme.colorScheme.error) - } - } - if (expanded) { - SharedMarkdownText(item.summary) - } - } - } - } - TextButton( - onClick = { - summaryCacheStore.clearBookCache(bookKey) - cachedItems = emptyList() - onCacheChanged() - }, - modifier = Modifier.align(Alignment.End) - ) { - Text(readerString("clear_all", "Clear all"), color = MaterialTheme.colorScheme.error) - } - } -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAppHost.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAppHost.kt deleted file mode 100644 index ac63f0b..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAppHost.kt +++ /dev/null @@ -1,703 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import androidx.compose.foundation.Image -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.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow -import androidx.compose.runtime.withFrameNanos -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Window -import androidx.compose.ui.window.WindowPlacement -import androidx.compose.ui.window.WindowPosition -import androidx.compose.ui.window.WindowState -import androidx.compose.ui.window.application -import androidx.compose.ui.window.rememberWindowState -import org.dueattendant149.bookreader.shared.AppContrastOption -import org.dueattendant149.bookreader.shared.AppThemeMode -import org.dueattendant149.bookreader.shared.reader.ReaderReadingMode -import org.dueattendant149.bookreader.shared.reader.ReaderSettings -import org.dueattendant149.bookreader.shared.reader.SharedJvmBookLoadSemanticMode -import org.dueattendant149.bookreader.shared.ui.SharedAppTheme -import org.dueattendant149.bookreader.shared.ui.readerString -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.withContext -import java.awt.Component -import java.awt.EventQueue -import java.awt.Frame -import java.awt.GraphicsDevice -import java.awt.KeyboardFocusManager -import java.awt.Rectangle -import java.awt.Toolkit -import java.awt.event.KeyEvent as AwtKeyEvent -import java.util.concurrent.atomic.AtomicReference - -internal val DesktopDefaultAppSeedColor = Color(0xFFFFB300) - -private val DesktopReaderWindowFullscreenExitFocusRetryDelaysMillis = longArrayOf(160L, 200L) - -internal fun launchEpistemeDesktopApplication(startupSplash: DesktopStartupSplash? = null) { - configureComposeSwingInterop() - application { - val windowDefaults = remember { epistemeDesktopWindowDefaults() } - val windowStateStore = remember { DesktopWindowStateStore() } - val restoredWindowState = remember { windowStateStore.load() } - val windowState = rememberWindowState( - placement = restoredWindowState?.toWindowPlacement() - ?: DesktopWindowStateSnapshot.default().toWindowPlacement(), - position = restoredWindowState?.toWindowPosition() ?: WindowPosition(Alignment.Center), - size = restoredWindowState?.toWindowSize(windowDefaults.defaultSize) ?: windowDefaults.defaultSize - ) - var readerFullscreen by remember { mutableStateOf(false) } - DesktopWindowStatePersistenceEffect( - windowState = windowState, - store = windowStateStore, - enabled = !readerFullscreen - ) - Window( - onCloseRequest = ::exitApplication, - title = windowDefaults.title, - state = windowState, - icon = painterResource(windowDefaults.iconResourcePath) - ) { - DisposableEffect(window, windowDefaults.minimumSize) { - window.minimumSize = windowDefaults.minimumSize - onDispose { - startupSplash?.close() - } - } - EpistemeDesktopStartupGate( - window = window, - startupSplash = startupSplash, - appWindowPlacement = windowState.placement, - readerFullscreen = readerFullscreen, - onReaderFullscreenChange = { readerFullscreen = it } - ) - } - } -} - -@Composable -private fun EpistemeDesktopStartupGate( - window: Component?, - startupSplash: DesktopStartupSplash?, - appWindowPlacement: WindowPlacement, - readerFullscreen: Boolean, - onReaderFullscreenChange: (Boolean) -> Unit -) { - var showApp by remember { mutableStateOf(false) } - - DisposableEffect(startupSplash) { - onDispose { - startupSplash?.close() - } - } - - if (showApp) { - EpistemeDesktopApp( - window = window, - appWindowPlacement = appWindowPlacement, - readerFullscreen = readerFullscreen, - onReaderFullscreenChange = onReaderFullscreenChange - ) - } else { - EpistemeDesktopStartupScreen(window = window) - } - - LaunchedEffect(Unit) { - withFrameNanos { } - startupSplash?.close() - delay(80L) - showApp = true - } -} - -@Composable -private fun EpistemeDesktopStartupScreen(window: Component?) { - val appTitle = remember { epistemeDesktopWindowDefaults().title } - SharedAppTheme( - appThemeMode = AppThemeMode.SYSTEM, - appContrastOption = AppContrastOption.STANDARD, - appTextDimFactorLight = 1.0f, - appTextDimFactorDark = 1.0f, - appSeedColor = DesktopDefaultAppSeedColor - ) { - EpistemeDesktopWindowChromeEffect( - window = window, - captionColor = MaterialTheme.colorScheme.surface, - textColor = MaterialTheme.colorScheme.onSurface, - borderColor = MaterialTheme.colorScheme.background - ) - Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(14.dp), - modifier = Modifier.padding(32.dp) - ) { - Image( - painter = painterResource(EpistemeDesktopWindowIconResource), - contentDescription = null, - modifier = Modifier.size(64.dp) - ) - Text( - text = appTitle, - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onBackground - ) - Text( - text = readerString("desktop_opening_your_library", "Opening your library"), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - CircularProgressIndicator( - modifier = Modifier.size(28.dp), - strokeWidth = 3.dp - ) - } - } - } -} - -internal const val ComposeInteropBlendingProperty = "compose.interop.blending" -internal const val ComposeInteropBlendingEnabled = "true" -private const val DesktopWindowStatePersistDebounceMillis = 450L - -internal fun composeInteropBlendingDefault( - platform: DesktopPlatform = currentDesktopPlatform() -): String? { - return if (desktopEpubWebViewUsesNativeSwtBrowser(platform)) { - null - } else { - ComposeInteropBlendingEnabled - } -} - -internal fun configureComposeSwingInterop( - platform: DesktopPlatform = currentDesktopPlatform() -) { - // Must run before Compose creates the desktop window. Vertical EPUB embeds native SWT/AWT - // browser surfaces; the blending path can prevent those native children from painting. - if (System.getProperty(ComposeInteropBlendingProperty).isNullOrBlank()) { - composeInteropBlendingDefault(platform)?.let { defaultValue -> - System.setProperty(ComposeInteropBlendingProperty, defaultValue) - } - } - logDesktopWebView2( - "compose_interop platform=${platform.os} blending=${System.getProperty(ComposeInteropBlendingProperty).orEmpty().ifBlank { "default" }}" - ) -} - -@Composable -internal fun DesktopWindowStatePersistenceEffect( - windowState: WindowState, - store: DesktopWindowStateStore, - enabled: Boolean, - transformSnapshot: (DesktopWindowStateSnapshot) -> DesktopWindowStateSnapshot? = { it }, - onSnapshotSaved: (DesktopWindowStateSnapshot) -> Unit = {} -) { - val persistenceEnabled by rememberUpdatedState(enabled) - val latestTransformSnapshot by rememberUpdatedState(transformSnapshot) - val latestOnSnapshotSaved by rememberUpdatedState(onSnapshotSaved) - LaunchedEffect(windowState, store) { - snapshotFlow { DesktopWindowStateSnapshot.fromWindowState(windowState) } - .distinctUntilChanged() - .collectLatest { snapshot -> - if (!persistenceEnabled || snapshot == null) return@collectLatest - val persistableSnapshot = latestTransformSnapshot(snapshot) ?: return@collectLatest - delay(DesktopWindowStatePersistDebounceMillis) - if (persistenceEnabled) { - withContext(Dispatchers.IO) { - store.save(persistableSnapshot) - } - latestOnSnapshotSaved(persistableSnapshot) - } - } - } - DisposableEffect(windowState, store) { - onDispose { - if (persistenceEnabled) { - DesktopWindowStateSnapshot.fromWindowState(windowState) - ?.let(latestTransformSnapshot) - ?.let { snapshot -> - runCatching { store.save(snapshot) } - latestOnSnapshotSaved(snapshot) - } - } - } - } -} - -@Composable -internal fun DesktopReaderFullscreenEffect( - window: Component?, - enabled: Boolean -) { - val awtWindow = window as? java.awt.Window ?: return - val fullscreenSnapshot = remember(awtWindow) { - AtomicReference() - } - val pendingExitSnapshot = remember(awtWindow) { - AtomicReference() - } - - LaunchedEffect(awtWindow, enabled) { - if (!enabled && fullscreenSnapshot.get() == null) { - return@LaunchedEffect - } - if (enabled) { - EventQueue.invokeLater { - awtWindow.captureDesktopReaderFullscreenSnapshot(fullscreenSnapshot) - } - } - delay(if (enabled) 180L else 80L) - EventQueue.invokeLater { - if (enabled) { - pendingExitSnapshot.set(null) - awtWindow.enterDesktopReaderFullscreen(fullscreenSnapshot) - } else { - awtWindow.beginDesktopReaderFullscreenExit(fullscreenSnapshot, pendingExitSnapshot) - } - } - delay(120L) - EventQueue.invokeLater { - if (!enabled) { - awtWindow.restoreDesktopReaderFullscreenExitBounds(pendingExitSnapshot.getAndSet(null)) - } - awtWindow.refreshDesktopReaderWindowFocus() - } - if (!enabled) { - for (delayMillis in DesktopReaderWindowFullscreenExitFocusRetryDelaysMillis) { - delay(delayMillis) - EventQueue.invokeLater { - awtWindow.refreshDesktopReaderWindowFocus() - } - } - } - } - - DisposableEffect(awtWindow) { - onDispose { - EventQueue.invokeLater { - awtWindow.beginDesktopReaderFullscreenExit(fullscreenSnapshot) - } - } - } -} - -private data class DesktopReaderFullscreenSnapshot( - val device: GraphicsDevice?, - val frameState: Int?, - val frameBounds: Rectangle?, - val alwaysOnTop: Boolean -) - -private fun java.awt.Window.enterDesktopReaderFullscreen( - snapshotRef: AtomicReference -) { - if (!isDisplayable) return - focusableWindowState = true - captureDesktopReaderFullscreenSnapshot(snapshotRef) - applyDesktopReaderBorderlessFullscreen(snapshotRef.get()) - refreshDesktopReaderWindowFocus() -} - -private fun java.awt.Window.captureDesktopReaderFullscreenSnapshot( - snapshotRef: AtomicReference -) { - snapshotRef.compareAndSet( - null, - DesktopReaderFullscreenSnapshot( - device = graphicsConfiguration?.device, - frameState = (this as? Frame)?.extendedState, - frameBounds = bounds.desktopReaderCopy(), - alwaysOnTop = isAlwaysOnTop - ) - ) -} - -private fun java.awt.Window.applyDesktopReaderBorderlessFullscreen(snapshot: DesktopReaderFullscreenSnapshot?) { - if (!isDisplayable) return - val device = snapshot?.device ?: graphicsConfiguration?.device - val frame = this as? Frame - focusableWindowState = true - if (frame != null) { - frame.extendedState = frame.extendedState and Frame.ICONIFIED.inv() and Frame.MAXIMIZED_BOTH.inv() - frame.state = Frame.NORMAL - } - device?.let { fullscreenDevice -> - runCatching { - if (fullscreenDevice.fullScreenWindow == this) { - fullscreenDevice.fullScreenWindow = null - } - } - } - runCatching { - bounds = device?.desktopReaderScreenBounds() ?: graphicsConfiguration?.bounds?.desktopReaderCopy() ?: bounds - } - runCatching { - isAlwaysOnTop = true - } -} - -private fun java.awt.Window.beginDesktopReaderFullscreenExit( - snapshotRef: AtomicReference, - pendingExitSnapshotRef: AtomicReference? = null -) { - val snapshot = snapshotRef.getAndSet(null) - if (snapshot == null) return - pendingExitSnapshotRef?.set(snapshot) - val device = snapshot.device ?: graphicsConfiguration?.device - device?.let { fullscreenDevice -> - runCatching { - if (fullscreenDevice.fullScreenWindow == this) { - fullscreenDevice.fullScreenWindow = null - } - } - } - runCatching { - isAlwaysOnTop = snapshot.alwaysOnTop - } - if (!isVisible) { - isVisible = true - } - (this as? Frame)?.let { frame -> - frame.state = Frame.NORMAL - frame.extendedState = Frame.NORMAL - } -} - -private fun java.awt.Window.restoreDesktopReaderFullscreenExitBounds(snapshot: DesktopReaderFullscreenSnapshot?) { - if (snapshot == null) return - runCatching { - isAlwaysOnTop = snapshot.alwaysOnTop - } - if (!isVisible) { - isVisible = true - } - val frame = this as? Frame - if (frame == null) { - bounds = snapshot.frameBounds.desktopReaderRestoreBounds(snapshot.device) - return - } - val restoreMaximized = snapshot.frameState?.let { state -> - state and Frame.MAXIMIZED_BOTH == Frame.MAXIMIZED_BOTH - } == true - frame.extendedState = Frame.NORMAL - frame.state = Frame.NORMAL - frame.bounds = snapshot.frameBounds.desktopReaderRestoreBounds(snapshot.device) - if (restoreMaximized) { - frame.maximizedBounds = snapshot.device?.desktopReaderUsableBounds() - EventQueue.invokeLater { - if (frame.isDisplayable && frame.isShowing) { - frame.extendedState = Frame.MAXIMIZED_BOTH - } - } - } - frame.toFront() - frame.requestFocus() - frame.validate() -} - -private fun GraphicsDevice.desktopReaderScreenBounds(): Rectangle { - return defaultConfiguration.bounds.desktopReaderCopy() -} - -private fun GraphicsDevice.desktopReaderUsableBounds(): Rectangle? { - val configuration = defaultConfiguration ?: return null - return runCatching { - val bounds = configuration.bounds - val insets = Toolkit.getDefaultToolkit().getScreenInsets(configuration) - Rectangle( - bounds.x + insets.left, - bounds.y + insets.top, - (bounds.width - insets.left - insets.right).coerceAtLeast(1), - (bounds.height - insets.top - insets.bottom).coerceAtLeast(1) - ) - }.getOrNull() -} - -private fun Rectangle?.desktopReaderRestoreBounds(device: GraphicsDevice?): Rectangle { - val usableBounds = device?.desktopReaderUsableBounds() - ?: return this?.desktopReaderCopy() ?: Rectangle(80, 80, 1280, 820) - val source = this ?: usableBounds - val width = source.width.coerceIn(640, usableBounds.width.coerceAtLeast(640)) - val height = source.height.coerceIn(480, usableBounds.height.coerceAtLeast(480)) - val looksFullscreen = source.x <= usableBounds.x && - source.y <= usableBounds.y && - source.width >= usableBounds.width && - source.height >= usableBounds.height - if (looksFullscreen) { - return usableBounds.desktopReaderCopy() - } - val maxX = (usableBounds.x + usableBounds.width - width).coerceAtLeast(usableBounds.x) - val maxY = (usableBounds.y + usableBounds.height - height).coerceAtLeast(usableBounds.y) - return Rectangle( - source.x.coerceIn(usableBounds.x, maxX), - source.y.coerceIn(usableBounds.y, maxY), - width, - height - ) -} - -private fun Rectangle.desktopReaderCopy(): Rectangle { - return Rectangle(x, y, width, height) -} - -private fun java.awt.Window.refreshDesktopReaderWindowFocus() { - if (!isDisplayable) return - if (this is Frame && extendedState and Frame.ICONIFIED != 0) { - extendedState = extendedState and Frame.ICONIFIED.inv() - } - toFront() - requestFocus() - requestFocusInWindow() - focusOwner?.requestFocus() -} - -@Composable -internal fun DesktopReaderFullscreenKeyEffect( - enabled: Boolean, - onKeyPressed: (AwtKeyEvent) -> Boolean -) { - DesktopReaderKeyDispatcherEffect( - enabled = enabled, - allowChromeModalWindows = false, - onKeyPressed = onKeyPressed - ) -} - -@Composable -internal fun DesktopReaderKeyDispatcherEffect( - enabled: Boolean, - allowChromeModalWindows: Boolean = false, - allowPanelModalWindows: Boolean = false, - dispatchWhenOwnerWindowActive: Boolean = true, - onKeyPressed: (AwtKeyEvent) -> Boolean -) { - val currentOnKeyPressed by rememberUpdatedState(onKeyPressed) - DisposableEffect( - enabled, - allowChromeModalWindows, - allowPanelModalWindows, - dispatchWhenOwnerWindowActive - ) { - if (!enabled) { - onDispose {} - } else { - val focusManager = KeyboardFocusManager.getCurrentKeyboardFocusManager() - val dispatcher = java.awt.KeyEventDispatcher { event -> - val keyWindow = focusManager.focusedWindow ?: focusManager.activeWindow - val activeReaderModalKind = keyWindow?.desktopReaderModalWindowKind() - val activeWindowAllowed = desktopReaderKeyDispatchAllowedForActiveWindowKind( - activeReaderModalKind = activeReaderModalKind, - allowChromeModalWindows = allowChromeModalWindows, - allowPanelModalWindows = allowPanelModalWindows, - dispatchWhenOwnerWindowActive = dispatchWhenOwnerWindowActive - ) - activeWindowAllowed && event.id == AwtKeyEvent.KEY_PRESSED && currentOnKeyPressed(event) - } - focusManager.addKeyEventDispatcher(dispatcher) - onDispose { - focusManager.removeKeyEventDispatcher(dispatcher) - } - } - } -} - -internal enum class DesktopReaderModalWindowKind { - CHROME, - PANEL, - POPUP -} - -internal fun desktopReaderKeyDispatchAllowedForActiveWindowKind( - activeReaderModalKind: DesktopReaderModalWindowKind?, - allowChromeModalWindows: Boolean, - allowPanelModalWindows: Boolean, - dispatchWhenOwnerWindowActive: Boolean -): Boolean { - return when (activeReaderModalKind) { - null -> dispatchWhenOwnerWindowActive - DesktopReaderModalWindowKind.CHROME -> allowChromeModalWindows - DesktopReaderModalWindowKind.PANEL -> allowPanelModalWindows - DesktopReaderModalWindowKind.POPUP -> false - } -} - -private fun java.awt.Window.desktopReaderModalWindowKind(): DesktopReaderModalWindowKind? { - val windowTitle = when (this) { - is java.awt.Dialog -> title - is Frame -> title - else -> "" - } - return desktopReaderModalWindowKind( - windowName = name.orEmpty(), - windowTitle = windowTitle - ) -} - -internal fun desktopReaderModalWindowKind( - windowName: String, - windowTitle: String -): DesktopReaderModalWindowKind? { - return when { - windowName == "${DesktopReaderModalWindowNamePrefix}ChromeTop" || - windowName == "${DesktopReaderModalWindowNamePrefix}ChromeBottom" || - windowTitle.startsWith("Reader Chrome") -> DesktopReaderModalWindowKind.CHROME - - windowName == "${DesktopReaderModalWindowNamePrefix}Panel" || - windowName == "${DesktopReaderModalWindowNamePrefix}PanelLeft" || - windowName == "${DesktopReaderModalWindowNamePrefix}PanelRight" || - windowTitle.startsWith("Reader Panel") || - windowTitle.startsWith("Reader Navigation") || - windowTitle.startsWith("Reader Tools") -> DesktopReaderModalWindowKind.PANEL - - windowName.startsWith(DesktopReaderModalWindowNamePrefix) || - windowTitle.startsWith("Reader Popup") -> DesktopReaderModalWindowKind.POPUP - - else -> null - } -} - -internal const val DesktopReaderModalWindowNamePrefix = "shared-reader-modal:" - -internal data class DesktopWebViewRuntimeState( - val initialized: Boolean = false, - val restartRequired: Boolean = false, - val downloadProgress: Float = -1f, - val errorMessage: String? = null -) - -internal enum class DesktopEpubWebViewBackend( - val logName: String, - val displayName: String -) { - WINDOWS_WEBVIEW2("webview2", "Microsoft Edge WebView2"), - WEBKIT("webkit", "WebKit"), - UNSUPPORTED("unsupported", "native webview") -} - -internal fun desktopEpubWebViewBackend( - platform: DesktopPlatform = currentDesktopPlatform() -): DesktopEpubWebViewBackend { - return when (platform.os) { - DesktopOperatingSystem.WINDOWS -> DesktopEpubWebViewBackend.WINDOWS_WEBVIEW2 - DesktopOperatingSystem.LINUX, - DesktopOperatingSystem.MACOS -> DesktopEpubWebViewBackend.WEBKIT - DesktopOperatingSystem.OTHER -> DesktopEpubWebViewBackend.UNSUPPORTED - } -} - -internal fun desktopEpubWebViewUsesNativeSwtBrowser( - platform: DesktopPlatform = currentDesktopPlatform() -): Boolean { - return desktopEpubWebViewBackend(platform) != DesktopEpubWebViewBackend.UNSUPPORTED -} - -internal fun desktopEpubWebViewUsesWebView2( - platform: DesktopPlatform = currentDesktopPlatform() -): Boolean { - return desktopEpubWebViewBackend(platform) == DesktopEpubWebViewBackend.WINDOWS_WEBVIEW2 -} - -internal fun desktopShouldUseNativeVerticalEpubReader( - platform: DesktopPlatform = currentDesktopPlatform() -): Boolean { - return platform.os == DesktopOperatingSystem.LINUX -} - -internal fun desktopEpubBookLoadSemanticMode( - settings: ReaderSettings, - platform: DesktopPlatform = currentDesktopPlatform() -): SharedJvmBookLoadSemanticMode { - return if ( - settings.readingMode == ReaderReadingMode.VERTICAL && - !desktopShouldUseNativeVerticalEpubReader(platform) - ) { - SharedJvmBookLoadSemanticMode.SKIP - } else { - SharedJvmBookLoadSemanticMode.FULL - } -} - -internal fun desktopEpubWebViewCanRender( - state: DesktopWebViewRuntimeState, - platform: DesktopPlatform = currentDesktopPlatform() -): Boolean { - return desktopEpubWebViewUsesNativeSwtBrowser(platform) -} - -@Composable -internal fun DesktopWebViewRuntimeIndicator( - state: DesktopWebViewRuntimeState, - modifier: Modifier = Modifier -) { - val platform = currentDesktopPlatform() - val message = when { - !desktopEpubWebViewUsesNativeSwtBrowser(platform) -> - readerString("desktop_webview_unsupported", "Embedded webview is unavailable on this desktop platform.") - state.errorMessage != null -> readerString("desktop_webview_start_error", "Embedded webview could not start: %1\$s", state.errorMessage) - state.restartRequired -> readerString("desktop_webview_restart_required", "Embedded webview installed. Restart Episteme to finish setup.") - state.downloadProgress >= 0f -> readerString("desktop_webview_preparing_progress", "Preparing bundled embedded webview %1\$d%%", state.downloadProgress.toInt()) - else -> readerString("desktop_webview_preparing", "Preparing embedded webview...") - } - - Box( - modifier = modifier.padding(32.dp), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - if (state.errorMessage == null && !state.restartRequired) { - if (desktopEpubWebViewUsesNativeSwtBrowser(platform)) { - CircularProgressIndicator() - } - } - Text( - text = message, - color = if (state.errorMessage == null) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.error, - textAlign = TextAlign.Center - ) - if (state.downloadProgress in 0f..100f) { - LinearProgressIndicator( - progress = { state.downloadProgress / 100f }, - modifier = Modifier.width(260.dp) - ) - } - } - } -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAppState.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAppState.kt deleted file mode 100644 index 686e4ae..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAppState.kt +++ /dev/null @@ -1,159 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import org.dueattendant149.bookreader.shared.BookShelfRef -import org.dueattendant149.bookreader.shared.CustomFontItem -import org.dueattendant149.bookreader.shared.SharedLibraryProjectionInput -import org.dueattendant149.bookreader.shared.SharedLibrarySnapshot -import org.dueattendant149.bookreader.shared.SharedLibraryStateProjector -import org.dueattendant149.bookreader.shared.SharedReaderScreenState -import org.dueattendant149.bookreader.shared.ShelfRecord -import org.dueattendant149.bookreader.shared.reader.ReaderSettings -import org.dueattendant149.bookreader.shared.reader.SharedEpubBook -import org.dueattendant149.bookreader.shared.reader.SharedEpubChapter -import org.dueattendant149.bookreader.shared.ui.SharedAppTab - -internal val DesktopInitialAppTab = SharedAppTab.LIBRARY - -internal fun desktopEmptyReaderBook(): SharedEpubBook { - val noBookOpen = loadDesktopStringResolver().string("desktop_no_book_open", "No book open") - return SharedEpubBook( - id = "desktop_empty_reader", - fileName = "", - title = noBookOpen, - chapters = listOf( - SharedEpubChapter( - id = "empty", - title = noBookOpen, - plainText = "" - ) - ) - ) -} - -internal fun SharedLibrarySnapshot.withDesktopDefaults(): SharedLibrarySnapshot { - val shouldMigrateReaderDefaults = desktopReaderDefaultsVersion < DesktopReaderDefaultsVersion - val migratedTextDefaults = if (shouldMigrateReaderDefaults && readerDefaultSettings == ReaderSettings()) { - DesktopDefaultTextReaderSettings - } else { - readerDefaultSettings - } - val migratedPdfDefaults = if (shouldMigrateReaderDefaults && pdfReaderDefaultSettings == ReaderSettings(themeId = "no_theme")) { - DesktopDefaultPdfReaderSettings - } else { - pdfReaderDefaultSettings - } - val migratedBooks = if (shouldMigrateReaderDefaults) { - books.map { book -> - when { - book.usesDesktopReaderSettingsEngine(DesktopReaderSettingsEngine.TEXT) && - book.readerSettings == ReaderSettings() -> book.copy(readerSettings = migratedTextDefaults) - book.usesDesktopReaderSettingsEngine(DesktopReaderSettingsEngine.PDF) && - book.readerSettings == ReaderSettings(themeId = "no_theme") -> book.copy(readerSettings = migratedPdfDefaults) - else -> book - } - } - } else { - books - } - return copy( - books = migratedBooks, - appSeedColor = appSeedColor ?: DesktopDefaultAppSeedColor, - readerDefaultSettings = migratedTextDefaults, - pdfReaderDefaultSettings = migratedPdfDefaults, - desktopReaderDefaultsVersion = DesktopReaderDefaultsVersion - ) -} - -internal fun SharedLibrarySnapshot.toDesktopReaderScreenState(): SharedReaderScreenState { - val readableBooks = books.filter { it.type in DesktopReadableFileTypes } - return SharedReaderScreenState( - rawLibraryBooks = readableBooks, - recentFilesLimit = recentFilesLimit, - allTags = tags.ifEmpty { readableBooks.collectTags() }, - syncedFolders = syncedFolders, - isTabsEnabled = isTabsEnabled, - openTabIds = openTabIds, - activeTabBookId = activeTabBookId, - pinnedHomeBookIds = pinnedHomeBookIds, - pinnedLibraryBookIds = pinnedLibraryBookIds, - useStrictFileFilter = useStrictFileFilter, - appThemeMode = appThemeMode, - appContrastOption = appContrastOption, - appTextDimFactorLight = appTextDimFactorLight, - appTextDimFactorDark = appTextDimFactorDark, - appSeedColor = appSeedColor, - appFontPreference = appFontPreference, - customAppThemes = customAppThemes, - customReaderThemes = customReaderThemes, - readerDefaultSettings = readerDefaultSettings, - pdfReaderDefaultSettings = pdfReaderDefaultSettings, - readerToolbarPreferences = readerToolbarPreferences, - readerHighlightPalette = readerHighlightPalette, - pdfHighlighterPalette = pdfHighlighterPalette, - readerTtsReplacementPreferences = readerTtsReplacementPreferences - ) -} - -internal fun SharedLibraryStateProjector.projectDesktopLibraryState( - state: SharedReaderScreenState, - shelfRecords: List, - shelfRefs: List -): SharedReaderScreenState { - val allBooks = state.rawLibraryBooks - val visibleBooks = allBooks.filterNot { isDesktopPdfReflowBookId(it.id) } - val projected = project( - SharedLibraryProjectionInput( - state = state, - booksFromStore = visibleBooks, - shelfRecords = shelfRecords, - shelfRefs = shelfRefs, - tags = state.allTags.ifEmpty { visibleBooks.collectTags() } - ) - ) - val booksById = allBooks.associateBy { it.id } - val openTabs = state.openTabIds.mapNotNull { booksById[it] } - val openTabIds = openTabs.map { it.id } - return projected.copy( - rawLibraryBooks = allBooks, - openTabs = openTabs, - openTabIds = openTabIds, - activeTabBookId = state.activeTabBookId?.takeIf { it in openTabIds } - ) -} - -internal fun SharedReaderScreenState.toDesktopLibrarySnapshot( - shelfRecords: List, - shelfRefs: List, - customFonts: List -): SharedLibrarySnapshot { - return SharedLibrarySnapshot( - books = rawLibraryBooks, - shelfRecords = shelfRecords, - shelfRefs = shelfRefs, - tags = allTags, - customFonts = customFonts, - syncedFolders = syncedFolders, - recentFilesLimit = recentFilesLimit, - isTabsEnabled = isTabsEnabled, - openTabIds = openTabIds, - activeTabBookId = activeTabBookId, - pinnedHomeBookIds = pinnedHomeBookIds, - pinnedLibraryBookIds = pinnedLibraryBookIds, - useStrictFileFilter = useStrictFileFilter, - appThemeMode = appThemeMode, - appContrastOption = appContrastOption, - appTextDimFactorLight = appTextDimFactorLight, - appTextDimFactorDark = appTextDimFactorDark, - appSeedColor = appSeedColor, - appFontPreference = appFontPreference, - customAppThemes = customAppThemes, - customReaderThemes = customReaderThemes, - readerDefaultSettings = readerDefaultSettings, - pdfReaderDefaultSettings = pdfReaderDefaultSettings, - desktopReaderDefaultsVersion = DesktopReaderDefaultsVersion, - readerToolbarPreferences = readerToolbarPreferences, - readerHighlightPalette = readerHighlightPalette, - pdfHighlighterPalette = pdfHighlighterPalette, - readerTtsReplacementPreferences = readerTtsReplacementPreferences - ) -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAtomicFile.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAtomicFile.kt deleted file mode 100644 index c58f3df..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAtomicFile.kt +++ /dev/null @@ -1,56 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import java.io.File -import java.nio.file.AtomicMoveNotSupportedException -import java.nio.file.Files -import java.nio.file.StandardCopyOption -import java.util.Properties - -internal fun File.writeTextAtomically(text: String) { - parentFile?.mkdirs() - val temp = createSiblingTempFile() - try { - temp.writeText(text) - moveReplacing(temp, this) - } finally { - runCatching { if (temp.exists()) temp.delete() } - } -} - -internal fun File.storePropertiesAtomically(properties: Properties, comments: String) { - parentFile?.mkdirs() - val temp = createSiblingTempFile() - try { - temp.outputStream().use { output -> - properties.store(output, comments) - } - moveReplacing(temp, this) - } finally { - runCatching { if (temp.exists()) temp.delete() } - } -} - -private fun File.createSiblingTempFile(): File { - val directory = parentFile ?: File(".") - directory.mkdirs() - val prefix = ".$name." - return Files.createTempFile(directory.toPath(), prefix, ".tmp").toFile() -} - -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/dueattendant149/bookreader/reader/desktop/DesktopBookImporter.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopBookImporter.kt deleted file mode 100644 index 55268cf..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopBookImporter.kt +++ /dev/null @@ -1,109 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import org.dueattendant149.bookreader.shared.ImportedBookFile -import org.dueattendant149.bookreader.shared.ReaderPlatform -import org.dueattendant149.bookreader.shared.SharedFileCapabilities -import java.io.File -import java.nio.file.Files -import java.nio.file.StandardCopyOption -import java.security.MessageDigest - -internal data class DesktopPreparedImport( - val files: List, - val failedCount: Int -) - -internal class DesktopBookImporter( - private val booksDirectory: File = File(desktopUserDataRoot(), "books") -) { - fun createBookFile(fileName: String): File { - booksDirectory.mkdirs() - return File(booksDirectory, fileName) - } - - fun prepareImports(files: List): DesktopPreparedImport { - val preparedFiles = mutableListOf() - var failedCount = 0 - booksDirectory.mkdirs() - - files.forEach { file -> - val type = SharedFileCapabilities.fileTypeForName(file.name) - if (!SharedFileCapabilities.canOpen(type, ReaderPlatform.DESKTOP)) { - preparedFiles += file.copy(sourceFolder = null) - return@forEach - } - - val source = file.localPath - ?.let(::File) - ?.takeIf { it.isFile } - - if (source == null) { - failedCount += 1 - return@forEach - } - - val hashResult = runCatching { source.sha256() } - if (hashResult.isFailure) { - failedCount += 1 - return@forEach - } - val hash = hashResult.getOrThrow() - val destination = File(booksDirectory, "$hash${file.storageSuffix(source)}") - - val copyResult = runCatching { - copyIfNeeded(source, destination) - destination - } - if (copyResult.isFailure) { - failedCount += 1 - return@forEach - } - val copied = copyResult.getOrThrow() - - preparedFiles += ImportedBookFile( - name = file.name, - uriString = null, - localPath = copied.absolutePath, - size = copied.length(), - sourceFolder = null, - id = hash - ) - } - - return DesktopPreparedImport( - files = preparedFiles, - failedCount = failedCount - ) - } - - private fun copyIfNeeded(source: File, destination: File) { - val sourceFile = source.canonicalFile - val destinationFile = destination.canonicalFile - if (sourceFile == destinationFile) return - destination.parentFile?.mkdirs() - Files.copy(source.toPath(), destination.toPath(), StandardCopyOption.REPLACE_EXISTING) - } - - private fun ImportedBookFile.storageSuffix(source: File): String { - return SharedFileCapabilities.fileExtensionSuffixForName(name) - ?: source.extension.takeIf { it.isNotBlank() }?.let { ".$it" } - ?: ".book" - } -} - -private fun File.sha256(): String { - val digest = MessageDigest.getInstance("SHA-256") - inputStream().use { input -> - val buffer = ByteArray(8 * 1024) - while (true) { - val read = input.read(buffer) - if (read == -1) break - digest.update(buffer, 0, read) - } - } - return digest.digest().toHexString() -} - -private fun ByteArray.toHexString(): String { - return joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and 0xff) } -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopBuildProfile.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopBuildProfile.kt deleted file mode 100644 index 4055007..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopBuildProfile.kt +++ /dev/null @@ -1,85 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import org.dueattendant149.bookreader.shared.ReaderAiByokSettings -import org.dueattendant149.bookreader.shared.SharedFeaturePolicy -import org.dueattendant149.bookreader.shared.SharedLegalLinks -import org.dueattendant149.bookreader.shared.SharedLegalProfile -import org.dueattendant149.bookreader.shared.sharedLegalLinksForProfile - -internal const val DesktopFlavorProperty = "episteme.desktop.flavor" -internal const val DesktopVersionProperty = "episteme.desktop.version" -internal const val DesktopFlavorStandard = "standard" -internal const val DesktopFlavorOssOffline = "oss-offline" -internal const val EpistemeDesktopStandardAppName = "Episteme" -internal const val EpistemeDesktopOssAppName = "Episteme oss" -internal const val ComposeApplicationResourcesDirProperty = "compose.application.resources.dir" - -internal data class DesktopBuildProfile( - val flavor: String, - val appName: String, - val buildLabel: String, - val featurePolicy: SharedFeaturePolicy, - val legalProfile: SharedLegalProfile = if (featurePolicy.byokAi) { - SharedLegalProfile.OSS - } else { - SharedLegalProfile.STANDARD - } -) { - val isOssOffline: Boolean get() = flavor == DesktopFlavorOssOffline - val aiKeySettingsAvailable: Boolean - get() = featurePolicy.aiAndCloud && featurePolicy.networkAccess && legalProfile != SharedLegalProfile.OSS - val byokAiAvailable: Boolean get() = featurePolicy.byokAi && featurePolicy.aiAndCloud && featurePolicy.networkAccess - val creditBackedCloudTtsControlsAvailable: Boolean - get() = featurePolicy.aiAndCloud && featurePolicy.networkAccess && !byokAiAvailable - val legalLinks: SharedLegalLinks - get() = sharedLegalLinksForProfile(legalProfile) -} - -internal fun currentDesktopBuildProfile(): DesktopBuildProfile { - return desktopBuildProfileForFlavor( - System.getProperty(DesktopFlavorProperty, DesktopFlavorStandard) - ) -} - -internal fun desktopBuildProfileForFlavor(rawFlavor: String?): DesktopBuildProfile { - val flavor = normalizedDesktopFlavor(rawFlavor) - return when (flavor) { - DesktopFlavorOssOffline -> DesktopBuildProfile( - flavor = DesktopFlavorOssOffline, - appName = EpistemeDesktopOssAppName, - buildLabel = "Offline OSS edition", - featurePolicy = SharedFeaturePolicy.OssOffline, - legalProfile = SharedLegalProfile.OSS - ) - else -> DesktopBuildProfile( - flavor = DesktopFlavorStandard, - appName = EpistemeDesktopStandardAppName, - buildLabel = "Standard edition", - featurePolicy = SharedFeaturePolicy.Standard, - legalProfile = SharedLegalProfile.STANDARD - ) - } -} - -private fun normalizedDesktopFlavor(rawFlavor: String?): String { - return when (rawFlavor?.trim()?.lowercase()) { - DesktopFlavorOssOffline, - "oss", - "episteme-oss" -> DesktopFlavorOssOffline - else -> DesktopFlavorStandard - } -} - -internal fun ReaderAiByokSettings.withDesktopFeaturePolicy( - featurePolicy: SharedFeaturePolicy -): ReaderAiByokSettings { - return if (featurePolicy.byokAi && featurePolicy.aiAndCloud && featurePolicy.networkAccess) { - toDesktopPersistableAiSettings() - } else { - ReaderAiByokSettings() - } -} - -internal fun ReaderAiByokSettings.toDesktopPersistableAiSettings(): ReaderAiByokSettings { - return sanitized().copy(hideReaderAiFeatures = false) -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopByokAiAdapter.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopByokAiAdapter.kt deleted file mode 100644 index e51f5dc..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopByokAiAdapter.kt +++ /dev/null @@ -1,342 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import org.dueattendant149.bookreader.shared.AiAdapter -import org.dueattendant149.bookreader.shared.AiDefinitionResult -import org.dueattendant149.bookreader.shared.ReaderAiByokSettings -import org.dueattendant149.bookreader.shared.ReaderAiFeature -import org.dueattendant149.bookreader.shared.ReaderByokTextRequest -import org.dueattendant149.bookreader.shared.ReaderByokTextRequestResult -import org.dueattendant149.bookreader.shared.ReaderByokTextRequests -import org.dueattendant149.bookreader.shared.RecapResult -import org.dueattendant149.bookreader.shared.SummarizationResult -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -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.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.net.HttpURLConnection -import java.net.URL - -class DesktopByokAiAdapter( - private val settingsProvider: () -> ReaderAiByokSettings, - private val networkAccess: () -> Boolean = { true } -) : AiAdapter { - override val isAvailable: Boolean - get() = networkAccess() && settingsProvider().sanitized().areReaderAiFeaturesAvailable - - override suspend fun define(text: String, context: String?): AiDefinitionResult { - val result = callTextAi(ReaderAiFeature.DEFINE, text, context) - return AiDefinitionResult(definition = result.getOrNull(), error = result.exceptionOrNull()?.message) - } - - override suspend fun defineStreaming( - text: String, - context: String?, - onUpdate: (String) -> Unit - ): AiDefinitionResult { - val result = callTextAi(ReaderAiFeature.DEFINE, text, context, onUpdate) - return AiDefinitionResult(definition = result.getOrNull(), error = result.exceptionOrNull()?.message) - } - - override suspend fun summarize(text: String): SummarizationResult { - val result = callTextAi(ReaderAiFeature.SUMMARIZE, text) - return SummarizationResult(summary = result.getOrNull(), error = result.exceptionOrNull()?.message) - } - - override suspend fun summarizeStreaming( - text: String, - onUsageReceived: (cost: Double?, freeRemaining: Int?) -> Unit, - onUpdate: (String) -> Unit - ): SummarizationResult { - val result = callTextAi(ReaderAiFeature.SUMMARIZE, text, onUpdate = onUpdate) - return SummarizationResult(summary = result.getOrNull(), error = result.exceptionOrNull()?.message) - } - - override suspend fun recap(textBeforeCurrentLocation: String): RecapResult { - val result = callTextAi(ReaderAiFeature.RECAP, textBeforeCurrentLocation) - return RecapResult(recap = result.getOrNull(), error = result.exceptionOrNull()?.message) - } - - suspend fun callTextAi( - feature: ReaderAiFeature, - text: String, - context: String? = null, - onUpdate: (String) -> Unit = {} - ): Result = withContext(Dispatchers.IO) { - if (!networkAccess()) return@withContext Result.failure(IllegalStateException("AI features are unavailable in this desktop build.")) - if (text.isBlank()) return@withContext Result.failure(IllegalArgumentException("There is no text to send.")) - when (val requestResult = ReaderByokTextRequests.build(settingsProvider(), feature, text, context)) { - ReaderByokTextRequestResult.Hidden -> Result.failure(IllegalStateException("Reader AI features are hidden.")) - is ReaderByokTextRequestResult.MissingKey -> { - Result.failure(IllegalStateException("Add a ${requestResult.provider.replaceFirstChar { it.uppercaseChar() }} API key in AI keys and models.")) - } - is ReaderByokTextRequestResult.MissingModel -> { - Result.failure(IllegalStateException("Choose a model for ${requestResult.featureName} in AI keys and models.")) - } - is ReaderByokTextRequestResult.Ready -> runCatching { - requestResult.request.execute(onUpdate) - } - } - } - - private fun ReaderByokTextRequest.execute(onUpdate: (String) -> Unit): String { - var connection: HttpURLConnection? = null - try { - val url = if (model.provider == "groq") { - URL("https://api.groq.com/openai/v1/chat/completions") - } else { - URL("https://generativelanguage.googleapis.com/v1beta/models/${model.name}:streamGenerateContent?key=$apiKey") - } - connection = (url.openConnection() as HttpURLConnection).apply { - requestMethod = "POST" - setRequestProperty("Content-Type", "application/json; charset=UTF-8") - setRequestProperty("Accept", "application/json") - if (model.provider == "groq") { - setRequestProperty("Authorization", "Bearer $apiKey") - } - connectTimeout = 15_000 - readTimeout = 120_000 - doOutput = true - doInput = true - } - val payload = if (model.provider == "groq") buildGroqPayload(this) else buildGeminiPayload(this) - connection.outputStream.use { output -> - output.write(payload.toByteArray(Charsets.UTF_8)) - } - val responseCode = connection.responseCode - if (responseCode != HttpURLConnection.HTTP_OK) { - val errorBody = runCatching { - connection.errorStream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() } - }.getOrNull() - throw IllegalStateException("AI provider error: $responseCode. ${errorBody.orEmpty().take(300)}") - } - val text = if (model.provider == "groq") { - streamGroqResponse(connection, onUpdate) - } else { - streamGeminiResponse(connection, onUpdate) - }.trim() - if (text.isBlank()) throw IllegalStateException("The AI provider returned an empty response.") - return text - } finally { - connection?.disconnect() - } - } - - private fun buildGroqPayload(request: ReaderByokTextRequest): String { - return buildJsonObject { - put("model", JsonPrimitive(request.model.name)) - put( - "messages", - buildJsonArray { - add(buildJsonObject { - put("role", JsonPrimitive("system")) - put("content", JsonPrimitive(request.systemInstruction)) - }) - add(buildJsonObject { - put("role", JsonPrimitive("user")) - put("content", JsonPrimitive(request.userPrompt)) - }) - } - ) - put("temperature", JsonPrimitive(request.temperature)) - put("top_p", JsonPrimitive(0.95)) - put("max_tokens", JsonPrimitive(request.maxTokens)) - put("stream", JsonPrimitive(true)) - if (request.model.name.contains("qwen")) put("reasoning_effort", JsonPrimitive("none")) - }.toString() - } - - private fun buildGeminiPayload(request: ReaderByokTextRequest): String { - return buildJsonObject { - put( - "contents", - buildJsonArray { - add(buildJsonObject { - put("parts", buildJsonArray { - add(buildJsonObject { put("text", JsonPrimitive(request.userPrompt)) }) - }) - }) - } - ) - put( - "systemInstruction", - buildJsonObject { - put("parts", buildJsonArray { - add(buildJsonObject { put("text", JsonPrimitive(request.systemInstruction)) }) - }) - } - ) - put( - "generationConfig", - buildJsonObject { - put("temperature", JsonPrimitive(request.temperature)) - put("topP", JsonPrimitive(0.95)) - put("topK", JsonPrimitive(40)) - put("maxOutputTokens", JsonPrimitive(request.maxTokens)) - put("response_mime_type", JsonPrimitive("text/plain")) - if (request.model.name.startsWith("gemini")) { - put( - "thinkingConfig", - buildJsonObject { put("thinkingBudget", JsonPrimitive(0)) } - ) - } - } - ) - }.toString() - } - - private fun streamGeminiResponse(connection: HttpURLConnection, onUpdate: (String) -> Unit): String { - val output = StringBuilder() - connection.inputStream.bufferedReader(Charsets.UTF_8).use { reader -> - var buffer = "" - var line: String? - while (reader.readLine().also { line = it } != null) { - buffer += line - while (true) { - val start = buffer.indexOf('{') - if (start == -1) { - buffer = "" - break - } - var depth = 0 - var end = -1 - scan@ for (index in start until buffer.length) { - when (buffer[index]) { - '{' -> depth++ - '}' -> { - depth-- - if (depth == 0) { - end = index - break@scan - } - } - } - } - if (end == -1) break - val jsonObject = buffer.substring(start, end + 1) - buffer = buffer.substring(end + 1) - val parsed = runCatching { DesktopAiJson.parseToJsonElement(jsonObject).jsonObject }.getOrNull() - val chunk = parsed.geminiTextChunk() - if (chunk.isNotEmpty()) { - output.append(chunk) - onUpdate(chunk) - } - if (parsed.geminiFinishReason() == "SAFETY") { - throw IllegalStateException("Blocked for safety reasons.") - } - } - } - } - return output.toString() - } - - private fun streamGroqResponse(connection: HttpURLConnection, onUpdate: (String) -> Unit): String { - val output = StringBuilder() - var inThink = false - var thinkBuffer = "" - - fun cleanChunk(text: String): String { - thinkBuffer += text - val cleaned = StringBuilder() - while (true) { - if (inThink) { - val end = thinkBuffer.indexOf("") - if (end == -1) { - if (thinkBuffer.length > 7) thinkBuffer = thinkBuffer.takeLast(7) - break - } - inThink = false - thinkBuffer = thinkBuffer.substring(end + 8) - } else { - val start = thinkBuffer.indexOf("") - if (start == -1) { - if (thinkBuffer.length > 6) { - cleaned.append(thinkBuffer.dropLast(6)) - thinkBuffer = thinkBuffer.takeLast(6) - } - break - } - cleaned.append(thinkBuffer.substring(0, start)) - inThink = true - thinkBuffer = thinkBuffer.substring(start + 7) - } - } - return cleaned.toString() - } - - connection.inputStream.bufferedReader(Charsets.UTF_8).use { reader -> - var line: String? - while (reader.readLine().also { line = it } != null) { - val trimmed = line!!.trim() - if (!trimmed.startsWith("data: ")) continue - val data = trimmed.removePrefix("data: ").trim() - if (data == "[DONE]") continue - val chunk = runCatching { - DesktopAiJson.parseToJsonElement(data).jsonObject["choices"] - ?.jsonArray - ?.firstOrNull() - ?.jsonObject - ?.get("delta") - ?.jsonObject - ?.get("content") - ?.jsonPrimitive - ?.contentOrNull - }.getOrNull().orEmpty() - val cleaned = cleanChunk(chunk) - if (cleaned.isNotEmpty()) { - output.append(cleaned) - onUpdate(cleaned) - } - } - } - if (!inThink && thinkBuffer.isNotBlank()) { - output.append(thinkBuffer) - onUpdate(thinkBuffer) - } - return output.toString() - } -} - -private val DesktopAiJson = Json { ignoreUnknownKeys = true } - -private fun JsonObject?.geminiTextChunk(): String { - if (this == null) return "" - return this["candidates"] - ?.jsonArrayOrNull() - ?.firstOrNull() - ?.jsonObjectOrNull() - ?.get("content") - ?.jsonObjectOrNull() - ?.get("parts") - ?.jsonArrayOrNull() - ?.firstOrNull() - ?.jsonObjectOrNull() - ?.get("text") - ?.jsonPrimitiveOrNull() - ?.contentOrNull - .orEmpty() -} - -private fun JsonObject?.geminiFinishReason(): String? { - if (this == null) return null - return this["candidates"] - ?.jsonArrayOrNull() - ?.firstOrNull() - ?.jsonObjectOrNull() - ?.get("finishReason") - ?.jsonPrimitiveOrNull() - ?.contentOrNull -} - -private fun JsonElement.jsonObjectOrNull(): JsonObject? = this as? JsonObject -private fun JsonElement.jsonArrayOrNull(): JsonArray? = this as? JsonArray -private fun JsonElement.jsonPrimitiveOrNull(): JsonPrimitive? = this as? JsonPrimitive diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudConfig.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudConfig.kt deleted file mode 100644 index 2ca06c6..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudConfig.kt +++ /dev/null @@ -1,85 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import java.io.File -import java.util.Properties - -internal data class DesktopCloudConfig( - val aiWorkerUrl: String, - val ttsWorkerUrl: String, - val firebaseWebApiKey: String, - val firebaseProjectId: String, - val googleOAuthClientId: String, - val googleOAuthClientSecret: String -) { - val isAuthConfigured: Boolean - get() = firebaseWebApiKey.isNotBlank() && - firebaseProjectId.isNotBlank() && - googleOAuthClientId.isNotBlank() - - val isAiWorkerConfigured: Boolean get() = aiWorkerUrl.isNotBlank() - val isTtsWorkerConfigured: Boolean get() = ttsWorkerUrl.isNotBlank() -} - -internal fun loadDesktopCloudConfig(): DesktopCloudConfig { - return desktopCloudConfigFromProperties( - resourceProperties = loadDesktopCloudResourceProperties(), - localProperties = loadDesktopLocalProperties() - ) -} - -private fun loadDesktopCloudResourceProperties(): Properties { - return Properties().apply { - val classLoader = DesktopCloudConfig::class.java.classLoader - val stream = classLoader.getResourceAsStream("desktop-cloud.properties") - ?: classLoader.getResourceAsStream("common/desktop-cloud.properties") - ?: System.getProperty(ComposeApplicationResourcesDirProperty) - ?.let(::File) - ?.let { resourcesDir -> - listOf( - resourcesDir.resolve("desktop-cloud.properties"), - resourcesDir.resolve("common/desktop-cloud.properties") - ).firstOrNull { it.isFile }?.inputStream() - } - stream?.use { input -> load(input) } - } -} - -private fun loadDesktopLocalProperties(file: File = File("local.properties")): Properties { - return Properties().apply { - file.takeIf { it.isFile } - ?.inputStream() - ?.use { input -> load(input) } - } -} - -internal fun desktopCloudConfigFromProperties( - resourceProperties: Properties, - localProperties: Properties = Properties(), - systemProperty: (String) -> String? = { key -> System.getProperty("episteme.desktop.$key") }, - environment: (String) -> String? = { key -> System.getenv(key) } -): DesktopCloudConfig { - fun value(vararg keys: String): String { - return keys.firstNotNullOfOrNull { key -> - systemProperty(key) - ?: environment("EPISTEME_DESKTOP_${key.uppercase()}") - ?: environment(key) - ?: localProperties.getProperty("DESKTOP_$key") - ?: localProperties.getProperty(key) - ?: resourceProperties.getProperty(key) - }?.trim().orEmpty() - } - - val aiWorkerUrl = value("AI_WORKER_URL").ifBlank { - "https://reader-ai.aryanrajttps.workers.dev" - } - val ttsWorkerUrl = value("TTS_WORKER_URL") - - return DesktopCloudConfig( - aiWorkerUrl = aiWorkerUrl, - ttsWorkerUrl = ttsWorkerUrl, - firebaseWebApiKey = value("FIREBASE_WEB_API_KEY", "GOOGLE_API_KEY"), - firebaseProjectId = value("FIREBASE_PROJECT_ID").ifBlank { "reader-9fc469d7" }, - googleOAuthClientId = value("GOOGLE_OAUTH_CLIENT_ID", "GOOGLE_WEB_CLIENT_ID", "DEFAULT_WEB_CLIENT_ID"), - googleOAuthClientSecret = value("GOOGLE_OAUTH_CLIENT_SECRET", "GOOGLE_WEB_CLIENT_SECRET", "DEFAULT_WEB_CLIENT_SECRET") - ) -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudRepositories.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudRepositories.kt deleted file mode 100644 index 5b3ea79..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudRepositories.kt +++ /dev/null @@ -1,683 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import org.dueattendant149.bookreader.shared.FileType -import org.dueattendant149.bookreader.shared.SharedFileCapabilities -import kotlinx.coroutines.Dispatchers -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.buildJsonObject -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 java.io.ByteArrayInputStream -import java.io.File -import java.io.InputStream -import java.io.SequenceInputStream -import java.net.URI -import java.net.URLEncoder -import java.net.http.HttpClient -import java.net.http.HttpRequest -import java.net.http.HttpResponse -import java.nio.file.Files -import java.nio.file.StandardCopyOption -import java.time.Duration -import java.time.Instant -import java.util.Collections -import java.util.UUID - -internal data class DesktopCloudBookMetadata( - val bookId: String = "", - val title: String? = null, - val author: String? = null, - val displayName: String = "", - val type: String = "", - val lastPositionCfi: String? = null, - val lastChapterIndex: Int? = null, - val locatorBlockIndex: Int? = null, - val locatorCharOffset: Int? = null, - val lastPage: Int? = null, - val progressPercentage: Float? = null, - val isRecent: Boolean = true, - val isDeleted: Boolean = false, - val lastModifiedTimestamp: Long = 0L, - val readingPositionModifiedTimestamp: Long = 0L, - val annotationModifiedTimestamp: Long = 0L, - val bookmarksJson: String? = null, - val hasAnnotations: Boolean = false, - val fileContentModifiedTimestamp: Long = 0L, - val customName: String? = null, - val highlightsJson: String? = null, - val seriesName: String? = null, - val seriesIndex: Double? = null, - val description: String? = null, - val originalTitle: String? = null, - val originalAuthor: String? = null, - val originalSeriesName: String? = null, - val originalSeriesIndex: Double? = null, - val originalDescription: String? = null -) - -internal data class DesktopCloudShelfMetadata( - val name: String = "", - val bookIds: List = emptyList(), - val lastModifiedTimestamp: Long = 0L, - val isDeleted: Boolean = false -) - -internal data class DesktopCloudFontMetadata( - val id: String = "", - val displayName: String = "", - val fileName: String = "", - val fileExtension: String = "", - val timestamp: Long = 0L, - val isDeleted: Boolean = false -) - -internal data class DesktopDriveFile( - val id: String, - val name: String, - val modifiedTimeMillis: Long = 0L -) - -internal class DesktopFirestoreRepository( - private val config: DesktopCloudConfig, - private val client: HttpClient = defaultDesktopCloudHttpClient() -) { - suspend fun getAllBooks(userId: String, idToken: String): List = - firestoreCollection(userId, "books", idToken).mapNotNull { document -> - document.fields?.toBookMetadata(document.id) - } - - suspend fun getBookMetadata(userId: String, bookId: String, idToken: String): DesktopCloudBookMetadata? = - firestoreDocument(userId, "books", bookId, idToken)?.let { document -> - document.fields?.toBookMetadata(document.id) - } - - suspend fun syncBookMetadata( - userId: String, - book: DesktopCloudBookMetadata, - originDeviceId: String, - idToken: String - ) { - val fields = book.toFirestoreFields() + ("originDeviceId" to firestoreString(originDeviceId)) - writeFirestoreDocument(userId, "books", book.bookId, fields, idToken) - } - - suspend fun getAllShelves(userId: String, idToken: String): List = - firestoreCollection(userId, "shelves", idToken).mapNotNull { document -> - document.fields?.toShelfMetadata(document.id) - } - - suspend fun syncShelf( - userId: String, - shelf: DesktopCloudShelfMetadata, - originDeviceId: String, - idToken: String - ) { - val fields = shelf.toFirestoreFields() + ("originDeviceId" to firestoreString(originDeviceId)) - writeFirestoreDocument(userId, "shelves", shelf.name, fields, idToken) - } - - suspend fun getAllFonts(userId: String, idToken: String): List = - firestoreCollection(userId, "fonts", idToken).mapNotNull { document -> - document.fields?.toFontMetadata(document.id) - } - - suspend fun syncFontMetadata(userId: String, font: DesktopCloudFontMetadata, idToken: String) { - writeFirestoreDocument(userId, "fonts", font.id, font.toFirestoreFields(), idToken) - } - - suspend fun deleteFontMetadata(userId: String, fontId: String, idToken: String) { - deleteFirestoreDocument(userId, "fonts", fontId, idToken) - } - - suspend fun deleteAllUserFirestoreData(userId: String, idToken: String) { - listOf("books", "shelves", "fonts").forEach { collection -> - firestoreCollection(userId, collection, idToken).forEach { document -> - deleteFirestoreDocument(userId, collection, document.id, idToken) - } - } - } - - private suspend fun firestoreCollection( - userId: String, - collection: String, - idToken: String - ): List = withContext(Dispatchers.IO) { - val response = sendFirestore( - request = HttpRequest.newBuilder(firestoreCollectionUri(userId, collection)) - .GET() - .build(), - idToken = idToken, - allowNotFound = true - ) ?: return@withContext emptyList() - val root = DesktopCloudJson.parseToJsonElement(response).jsonObject - root["documents"]?.jsonArrayOrNull().orEmpty().mapNotNull { element -> - val document = element.jsonObjectOrNull() ?: return@mapNotNull null - DesktopFirestoreDocument( - id = document.string("name")?.substringAfterLast('/').orEmpty(), - fields = document["fields"]?.jsonObjectOrNull() - ) - } - } - - private suspend fun firestoreDocument( - userId: String, - collection: String, - documentId: String, - idToken: String - ): DesktopFirestoreDocument? = withContext(Dispatchers.IO) { - val response = sendFirestore( - request = HttpRequest.newBuilder(firestoreDocumentUri(userId, collection, documentId)) - .GET() - .build(), - idToken = idToken, - allowNotFound = true - ) ?: return@withContext null - val document = DesktopCloudJson.parseToJsonElement(response).jsonObject - DesktopFirestoreDocument( - id = document.string("name")?.substringAfterLast('/').orEmpty(), - fields = document["fields"]?.jsonObjectOrNull() - ) - } - - private suspend fun writeFirestoreDocument( - userId: String, - collection: String, - documentId: String, - fields: Map, - idToken: String - ) = withContext(Dispatchers.IO) { - val body = JsonObject(mapOf("fields" to JsonObject(fields))) - sendFirestore( - request = HttpRequest.newBuilder(firestoreDocumentUri(userId, collection, documentId)) - .header("Content-Type", "application/json; charset=UTF-8") - .method("PATCH", HttpRequest.BodyPublishers.ofString(DesktopCloudJson.encodeToString(JsonElement.serializer(), body))) - .build(), - idToken = idToken - ) - Unit - } - - private suspend fun deleteFirestoreDocument( - userId: String, - collection: String, - documentId: String, - idToken: String - ) = withContext(Dispatchers.IO) { - sendFirestore( - request = HttpRequest.newBuilder(firestoreDocumentUri(userId, collection, documentId)) - .DELETE() - .build(), - idToken = idToken, - allowNotFound = true - ) - Unit - } - - private fun firestoreCollectionUri(userId: String, collection: String): URI { - return URI.create("${firestoreBaseUrl()}/users/${pathEncode(userId)}/$collection") - } - - private fun firestoreDocumentUri(userId: String, collection: String, documentId: String): URI { - return URI.create("${firestoreCollectionUri(userId, collection)}/${pathEncode(documentId)}") - } - - private fun firestoreBaseUrl(): String { - return "https://firestore.googleapis.com/v1/projects/${pathEncode(config.firebaseProjectId)}/databases/(default)/documents" - } - - private fun sendFirestore( - request: HttpRequest, - idToken: String, - allowNotFound: Boolean = false - ): String? { - val authed = HttpRequest.newBuilder(request.uri()) - .timeout(Duration.ofSeconds(30)) - .copyMethodAndBodyFrom(request) - .header("Authorization", "Bearer $idToken") - .header("Accept", "application/json") - .apply { - request.headers().map().forEach { (name, values) -> - values.forEach { value -> header(name, value) } - } - } - .build() - val response = client.send(authed, HttpResponse.BodyHandlers.ofString(Charsets.UTF_8)) - if (allowNotFound && response.statusCode() == 404) return null - if (response.statusCode() !in 200..299) { - throw IllegalStateException("Firestore HTTP ${response.statusCode()}: ${response.body().take(240)}") - } - return response.body() - } -} - -internal class DesktopGoogleDriveRepository( - private val client: HttpClient = defaultDesktopCloudHttpClient() -) { - suspend fun getFiles(accessToken: String): List = withContext(Dispatchers.IO) { - listFiles(accessToken = accessToken, query = null) - } - - suspend fun getFileByName(accessToken: String, fileName: String): DesktopDriveFile? = withContext(Dispatchers.IO) { - listFiles(accessToken, "name = '${driveQueryStringValue(fileName)}' and trashed = false").firstOrNull() - } - - suspend fun uploadFont(accessToken: String, fileName: String, file: File, extension: String): DesktopDriveFile? = - uploadNamedFile( - accessToken = accessToken, - fileName = fileName, - file = file, - contentType = when (extension.lowercase()) { - "ttf" -> "font/ttf" - "otf" -> "font/otf" - "woff2" -> "font/woff2" - else -> "application/octet-stream" - } - ) - - suspend fun uploadFile(accessToken: String, bookId: String, file: File, type: FileType): DesktopDriveFile? { - val extension = SharedFileCapabilities.primaryExtensionFor(type) ?: return null - val mimeType = SharedFileCapabilities.mimeTypeFor(type) ?: return null - return uploadNamedFile( - accessToken = accessToken, - fileName = "$bookId.$extension", - file = file, - contentType = mimeType - ) - } - - suspend fun uploadAnnotationFile(accessToken: String, bookId: String, file: File): DesktopDriveFile? { - return uploadNamedFile( - accessToken = accessToken, - fileName = desktopCloudAnnotationDriveFileName(bookId), - file = file, - contentType = "application/json" - ) - } - - suspend fun downloadAnnotationFile(accessToken: String, bookId: String, destination: File): Boolean { - val driveFile = getFileByName(accessToken, desktopCloudAnnotationDriveFileName(bookId)) - ?: return false - return downloadFile(accessToken, driveFile.id, destination).also { downloaded -> - if (downloaded && driveFile.modifiedTimeMillis > 0L) { - destination.setLastModified(driveFile.modifiedTimeMillis) - } - } - } - - suspend fun downloadFile(accessToken: String, fileId: String, destination: File): Boolean = withContext(Dispatchers.IO) { - destination.parentFile?.mkdirs() - val temp = File(destination.parentFile ?: File("."), "${destination.name}.${System.nanoTime()}.tmp") - val request = HttpRequest.newBuilder( - URI.create("https://www.googleapis.com/drive/v3/files/${pathEncode(fileId)}?alt=media") - ) - .timeout(Duration.ofMinutes(3)) - .header("Authorization", "Bearer $accessToken") - .GET() - .build() - val response = client.send(request, HttpResponse.BodyHandlers.ofFile(temp.toPath())) - if (response.statusCode() !in 200..299) { - temp.delete() - return@withContext false - } - Files.move(temp.toPath(), destination.toPath(), StandardCopyOption.REPLACE_EXISTING) - true - } - - suspend fun deleteAllFiles(accessToken: String): Boolean = withContext(Dispatchers.IO) { - getFiles(accessToken).forEach { file -> - deleteDriveFile(accessToken, file.id) - } - true - } - - suspend fun deleteDriveFile(accessToken: String, fileId: String): Boolean = withContext(Dispatchers.IO) { - val request = HttpRequest.newBuilder( - URI.create("https://www.googleapis.com/drive/v3/files/${pathEncode(fileId)}") - ) - .timeout(Duration.ofSeconds(30)) - .header("Authorization", "Bearer $accessToken") - .DELETE() - .build() - val response = client.send(request, HttpResponse.BodyHandlers.ofString(Charsets.UTF_8)) - response.statusCode() in 200..299 || response.statusCode() == 404 - } - - private suspend fun uploadNamedFile( - accessToken: String, - fileName: String, - file: File, - contentType: String - ): DesktopDriveFile? = withContext(Dispatchers.IO) { - if (!file.isFile) return@withContext null - val existingFiles = listFiles(accessToken, "name = '${driveQueryStringValue(fileName)}' and trashed = false") - existingFiles.drop(1).forEach { duplicate -> deleteDriveFile(accessToken, duplicate.id) } - val existingFileId = existingFiles.firstOrNull()?.id - val boundary = "episteme_${UUID.randomUUID().toString().replace("-", "")}" - val metadata = buildJsonObject { - put("name", JsonPrimitive(fileName)) - if (existingFileId == null) { - put("parents", JsonArray(listOf(JsonPrimitive("appDataFolder")))) - } - } - val prefix = buildString { - append("--") - append(boundary) - append("\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n") - append(DesktopCloudJson.encodeToString(JsonElement.serializer(), metadata)) - append("\r\n--") - append(boundary) - append("\r\nContent-Type: ") - append(contentType) - append("\r\n\r\n") - }.toByteArray(Charsets.UTF_8) - val suffix = "\r\n--$boundary--\r\n".toByteArray(Charsets.UTF_8) - val uploadUri = if (existingFileId == null) { - URI.create("https://www.googleapis.com/upload/drive/v3/files?${query("uploadType" to "multipart", "fields" to "id,name,modifiedTime")}") - } else { - URI.create("https://www.googleapis.com/upload/drive/v3/files/${pathEncode(existingFileId)}?${query("uploadType" to "multipart", "fields" to "id,name,modifiedTime")}") - } - val request = HttpRequest.newBuilder(uploadUri) - .timeout(Duration.ofMinutes(5)) - .header("Authorization", "Bearer $accessToken") - .header("Content-Type", "multipart/related; boundary=$boundary") - .method( - if (existingFileId == null) "POST" else "PATCH", - HttpRequest.BodyPublishers.ofInputStream { - sequenceInputStream( - ByteArrayInputStream(prefix), - file.inputStream(), - ByteArrayInputStream(suffix) - ) - } - ) - .build() - val response = client.send(request, HttpResponse.BodyHandlers.ofString(Charsets.UTF_8)) - if (response.statusCode() !in 200..299) return@withContext null - val root = DesktopCloudJson.parseToJsonElement(response.body()).jsonObject - DesktopDriveFile( - id = root.string("id").orEmpty(), - name = root.string("name").orEmpty(), - modifiedTimeMillis = parseDriveModifiedTimeMillis(root.string("modifiedTime")) - ) - } - - private fun listFiles(accessToken: String, query: String?): List { - val params = buildList { - add("spaces" to "appDataFolder") - add("fields" to "files(id,name,modifiedTime)") - if (!query.isNullOrBlank()) add("q" to query) - } - val request = HttpRequest.newBuilder( - URI.create("https://www.googleapis.com/drive/v3/files?${query(params)}") - ) - .timeout(Duration.ofSeconds(30)) - .header("Authorization", "Bearer $accessToken") - .header("Accept", "application/json") - .GET() - .build() - val response = client.send(request, HttpResponse.BodyHandlers.ofString(Charsets.UTF_8)) - if (response.statusCode() !in 200..299) { - throw IllegalStateException("Google Drive HTTP ${response.statusCode()}: ${response.body().take(240)}") - } - val root = DesktopCloudJson.parseToJsonElement(response.body()).jsonObject - return root["files"]?.jsonArrayOrNull().orEmpty().mapNotNull { element -> - val obj = element.jsonObjectOrNull() ?: return@mapNotNull null - val id = obj.string("id") ?: return@mapNotNull null - val name = obj.string("name") ?: return@mapNotNull null - DesktopDriveFile( - id = id, - name = name, - modifiedTimeMillis = parseDriveModifiedTimeMillis(obj.string("modifiedTime")) - ) - } - } -} - -private fun parseDriveModifiedTimeMillis(value: String?): Long { - if (value.isNullOrBlank()) return 0L - return runCatching { Instant.parse(value).toEpochMilli() }.getOrDefault(0L) -} - -private data class DesktopFirestoreDocument( - val id: String, - val fields: JsonObject? -) - -private val DesktopCloudJson = Json { - ignoreUnknownKeys = true - encodeDefaults = true - prettyPrint = true -} - -private fun defaultDesktopCloudHttpClient(): HttpClient { - return HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(20)) - .followRedirects(HttpClient.Redirect.NORMAL) - .build() -} - -private fun DesktopCloudBookMetadata.toFirestoreFields(): Map = mapOf( - "bookId" to firestoreString(bookId), - "title" to firestoreNullableString(title), - "author" to firestoreNullableString(author), - "displayName" to firestoreString(displayName), - "type" to firestoreString(type), - "lastPositionCfi" to firestoreNullableString(lastPositionCfi), - "lastChapterIndex" to firestoreNullableInt(lastChapterIndex), - "locatorBlockIndex" to firestoreNullableInt(locatorBlockIndex), - "locatorCharOffset" to firestoreNullableInt(locatorCharOffset), - "lastPage" to firestoreNullableInt(lastPage), - "progressPercentage" to firestoreNullableFloat(progressPercentage), - "isRecent" to firestoreBoolean(isRecent), - "isDeleted" to firestoreBoolean(isDeleted), - "lastModifiedTimestamp" to firestoreLong(lastModifiedTimestamp), - "readingPositionModifiedTimestamp" to firestoreLong(readingPositionModifiedTimestamp), - "annotationModifiedTimestamp" to firestoreLong(annotationModifiedTimestamp), - "bookmarksJson" to firestoreNullableString(bookmarksJson), - "hasAnnotations" to firestoreBoolean(hasAnnotations), - "fileContentModifiedTimestamp" to firestoreLong(fileContentModifiedTimestamp), - "customName" to firestoreNullableString(customName), - "highlightsJson" to firestoreNullableString(highlightsJson), - "seriesName" to firestoreNullableString(seriesName), - "seriesIndex" to firestoreNullableDouble(seriesIndex), - "description" to firestoreNullableString(description), - "originalTitle" to firestoreNullableString(originalTitle), - "originalAuthor" to firestoreNullableString(originalAuthor), - "originalSeriesName" to firestoreNullableString(originalSeriesName), - "originalSeriesIndex" to firestoreNullableDouble(originalSeriesIndex), - "originalDescription" to firestoreNullableString(originalDescription) -) - -private fun DesktopCloudShelfMetadata.toFirestoreFields(): Map = mapOf( - "name" to firestoreString(name), - "bookIds" to firestoreStringArray(bookIds), - "lastModifiedTimestamp" to firestoreLong(lastModifiedTimestamp), - "isDeleted" to firestoreBoolean(isDeleted) -) - -private fun DesktopCloudFontMetadata.toFirestoreFields(): Map = mapOf( - "id" to firestoreString(id), - "displayName" to firestoreString(displayName), - "fileName" to firestoreString(fileName), - "fileExtension" to firestoreString(fileExtension), - "timestamp" to firestoreLong(timestamp), - "isDeleted" to firestoreBoolean(isDeleted) -) - -private fun JsonObject.toBookMetadata(documentId: String): DesktopCloudBookMetadata? { - val bookId = stringField("bookId") ?: documentId.takeIf { it.isNotBlank() } ?: return null - return DesktopCloudBookMetadata( - bookId = bookId, - title = stringField("title"), - author = stringField("author"), - displayName = stringField("displayName").orEmpty(), - type = stringField("type").orEmpty(), - lastPositionCfi = stringField("lastPositionCfi"), - lastChapterIndex = intField("lastChapterIndex"), - locatorBlockIndex = intField("locatorBlockIndex"), - locatorCharOffset = intField("locatorCharOffset"), - lastPage = intField("lastPage"), - progressPercentage = doubleField("progressPercentage")?.toFloat(), - isRecent = booleanField("isRecent") ?: true, - isDeleted = booleanField("isDeleted") ?: false, - lastModifiedTimestamp = longField("lastModifiedTimestamp"), - readingPositionModifiedTimestamp = longField("readingPositionModifiedTimestamp"), - annotationModifiedTimestamp = longField("annotationModifiedTimestamp"), - bookmarksJson = stringField("bookmarksJson"), - hasAnnotations = booleanField("hasAnnotations") ?: false, - fileContentModifiedTimestamp = longField("fileContentModifiedTimestamp"), - customName = stringField("customName"), - highlightsJson = stringField("highlightsJson"), - seriesName = stringField("seriesName"), - seriesIndex = doubleField("seriesIndex"), - description = stringField("description"), - originalTitle = stringField("originalTitle"), - originalAuthor = stringField("originalAuthor"), - originalSeriesName = stringField("originalSeriesName"), - originalSeriesIndex = doubleField("originalSeriesIndex"), - originalDescription = stringField("originalDescription") - ) -} - -private fun JsonObject.toShelfMetadata(documentId: String): DesktopCloudShelfMetadata? { - val name = stringField("name") ?: documentId.takeIf { it.isNotBlank() } ?: return null - return DesktopCloudShelfMetadata( - name = name, - bookIds = stringArrayField("bookIds"), - lastModifiedTimestamp = longField("lastModifiedTimestamp"), - isDeleted = booleanField("isDeleted") ?: false - ) -} - -private fun JsonObject.toFontMetadata(documentId: String): DesktopCloudFontMetadata? { - val id = stringField("id") ?: documentId.takeIf { it.isNotBlank() } ?: return null - return DesktopCloudFontMetadata( - id = id, - displayName = stringField("displayName").orEmpty(), - fileName = stringField("fileName").orEmpty(), - fileExtension = stringField("fileExtension").orEmpty(), - timestamp = longField("timestamp"), - isDeleted = booleanField("isDeleted") ?: false - ) -} - -private fun firestoreString(value: String): JsonElement = JsonObject(mapOf("stringValue" to JsonPrimitive(value))) - -private fun firestoreNullableString(value: String?): JsonElement { - return value?.let(::firestoreString) ?: firestoreNull() -} - -private fun firestoreNullableInt(value: Int?): JsonElement { - return value?.let { JsonObject(mapOf("integerValue" to JsonPrimitive(it.toString()))) } ?: firestoreNull() -} - -private fun firestoreLong(value: Long): JsonElement = JsonObject(mapOf("integerValue" to JsonPrimitive(value.toString()))) - -private fun firestoreNullableFloat(value: Float?): JsonElement { - return value?.let { JsonObject(mapOf("doubleValue" to JsonPrimitive(it.toDouble()))) } ?: firestoreNull() -} - -private fun firestoreNullableDouble(value: Double?): JsonElement { - return value?.let { JsonObject(mapOf("doubleValue" to JsonPrimitive(it))) } ?: firestoreNull() -} - -private fun firestoreBoolean(value: Boolean): JsonElement = JsonObject(mapOf("booleanValue" to JsonPrimitive(value))) - -private fun firestoreStringArray(values: List): JsonElement { - return JsonObject( - mapOf( - "arrayValue" to JsonObject( - mapOf("values" to JsonArray(values.map(::firestoreString))) - ) - ) - ) -} - -private fun firestoreNull(): JsonElement = JsonObject(mapOf("nullValue" to JsonPrimitive("NULL_VALUE"))) - -private fun JsonObject.stringField(key: String): String? { - return field(key)?.get("stringValue")?.jsonPrimitive?.contentOrNull -} - -private fun JsonObject.booleanField(key: String): Boolean? { - return field(key)?.get("booleanValue")?.jsonPrimitive?.booleanOrNull -} - -private fun JsonObject.longField(key: String): Long { - val field = field(key) ?: return 0L - return field["integerValue"]?.jsonPrimitive?.longOrNull - ?: field["doubleValue"]?.jsonPrimitive?.doubleOrNull?.toLong() - ?: 0L -} - -private fun JsonObject.intField(key: String): Int? { - val field = field(key) ?: return null - return field["integerValue"]?.jsonPrimitive?.intOrNull - ?: field["doubleValue"]?.jsonPrimitive?.doubleOrNull?.toInt() -} - -private fun JsonObject.doubleField(key: String): Double? { - val field = field(key) ?: return null - return field["doubleValue"]?.jsonPrimitive?.doubleOrNull - ?: field["integerValue"]?.jsonPrimitive?.contentOrNull?.toDoubleOrNull() -} - -private fun JsonObject.stringArrayField(key: String): List { - val values = field(key) - ?.get("arrayValue") - ?.jsonObjectOrNull() - ?.get("values") - ?.jsonArrayOrNull() - .orEmpty() - return values.mapNotNull { it.jsonObjectOrNull()?.get("stringValue")?.jsonPrimitive?.contentOrNull } -} - -private fun JsonObject.field(key: String): JsonObject? = this[key]?.jsonObjectOrNull() - -private fun JsonElement?.jsonObjectOrNull(): JsonObject? { - if (this == null || this is JsonNull) return null - return runCatching { jsonObject }.getOrNull() -} - -private fun JsonElement?.jsonArrayOrNull(): JsonArray? { - if (this == null || this is JsonNull) return null - return runCatching { jsonArray }.getOrNull() -} - -private fun JsonObject.string(key: String): String? = this[key]?.jsonPrimitive?.contentOrNull - -private fun query(vararg pairs: Pair): String = query(pairs.asIterable()) - -private fun query(pairs: Iterable>): String { - return pairs.joinToString("&") { (key, value) -> "${formEncode(key)}=${formEncode(value)}" } -} - -private fun formEncode(value: String): String = URLEncoder.encode(value, Charsets.UTF_8.name()) - -private fun pathEncode(value: String): String = formEncode(value).replace("+", "%20") - -private fun driveQueryStringValue(value: String): String { - return value.replace("\\", "\\\\").replace("'", "\\'") -} - -private fun sequenceInputStream(vararg streams: InputStream): SequenceInputStream { - return SequenceInputStream(Collections.enumeration(streams.toList())) -} - -private fun HttpRequest.Builder.copyMethodAndBodyFrom(source: HttpRequest): HttpRequest.Builder { - val publisher = source.bodyPublisher().orElse(HttpRequest.BodyPublishers.noBody()) - return method(source.method(), publisher) -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSidecarSync.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSidecarSync.kt deleted file mode 100644 index 31f86bb..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSidecarSync.kt +++ /dev/null @@ -1,311 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import org.dueattendant149.bookreader.shared.BookItem -import org.dueattendant149.bookreader.shared.FileType -import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationSerializer -import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationSidecarCodec -import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichTextSerializer -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 java.io.File - -internal object DesktopCloudSidecarSync { - fun localAnnotationDebugSummary(book: BookItem): String { - val path = book.path?.takeIf { it.isNotBlank() } ?: return "path=null type=${book.type}" - if (book.type != FileType.PDF) return "path=${path.logPreview(140)} type=${book.type}" - val annotationFile = desktopPdfAnnotationFile(path) - val deletedAnnotationFile = desktopPdfAnnotationDeletionFile(path) - val bookmarkFile = desktopPdfBookmarkFile(path) - val richTextFile = desktopPdfRichTextFile(path) - return "path=${path.logPreview(140)} " + - "annotations{exists=${annotationFile.isFile} syncable=${annotationFile.hasSyncablePdfAnnotations()} " + - "bytes=${annotationFile.length()} ts=${annotationFile.lastModifiedIfFile()}} " + - "deletedAnnotations{exists=${deletedAnnotationFile.isFile} count=${deletedAnnotationFile.annotationDeletionCount()} " + - "bytes=${deletedAnnotationFile.length()} ts=${deletedAnnotationFile.lastModifiedIfFile()}} " + - "bookmarks{exists=${bookmarkFile.isFile} bytes=${bookmarkFile.length()} ts=${bookmarkFile.lastModifiedIfFile()}} " + - "text{exists=${richTextFile.isFile} syncable=${richTextFile.hasSyncablePdfRichText()} " + - "bytes=${richTextFile.length()} ts=${richTextFile.lastModifiedIfFile()}} " + - "payloadTs=${localAnnotationPayloadTimestamp(book)} totalTs=${localAnnotationTimestamp(book)}" - } - - fun hasLocalAnnotationData(book: BookItem): Boolean { - val path = book.path?.takeIf { it.isNotBlank() } ?: return false - if (book.type != FileType.PDF) return false - return desktopPdfAnnotationFile(path).hasSyncablePdfAnnotations() || - desktopPdfAnnotationDeletionFile(path).hasSyncablePdfAnnotationDeletions() || - desktopPdfBookmarkFile(path).isFile || - desktopPdfRichTextFile(path).hasSyncablePdfRichText() - } - - fun localAnnotationTimestamp(book: BookItem): Long { - val path = book.path?.takeIf { it.isNotBlank() } ?: return 0L - if (book.type != FileType.PDF) return 0L - return maxOf( - localAnnotationPayloadTimestamp(path), - desktopPdfBookmarkFile(path).lastModifiedIfFile() - ) - } - - fun localAnnotationPayloadTimestamp(book: BookItem): Long { - val path = book.path?.takeIf { it.isNotBlank() } ?: return 0L - if (book.type != FileType.PDF) return 0L - return localAnnotationPayloadTimestamp(path) - } - - fun markAnnotationPayloadSynced(book: BookItem, timestamp: Long) { - val path = book.path?.takeIf { it.isNotBlank() } ?: return - if (book.type != FileType.PDF || timestamp <= 0L) return - listOf(desktopPdfAnnotationFile(path), desktopPdfAnnotationDeletionFile(path), desktopPdfRichTextFile(path)) - .filter { it.isFile } - .forEach { it.setLastModified(timestamp) } - } - - fun recordAnnotationDeletions(book: BookItem, annotationIds: Collection) { - val path = book.path?.takeIf { it.isNotBlank() } ?: return - if (book.type != FileType.PDF) return - recordAnnotationDeletions(path, book.id, annotationIds) - } - - fun recordAnnotationDeletions(documentPath: String, logBookId: String, annotationIds: Collection) { - val ids = annotationIds.mapNotNull { it.takeIf(String::isNotBlank) }.toSet() - if (ids.isEmpty()) return - val file = desktopPdfAnnotationDeletionFile(documentPath) - val existing = if (file.isFile) { - SharedPdfAnnotationSidecarCodec.annotationDeletionsFromJson(file.readText()) - } else { - emptyMap() - } - val now = System.currentTimeMillis() - val next = existing.toMutableMap() - ids.forEach { id -> next[id] = maxOf(next[id] ?: 0L, now) } - val nextJson = SharedPdfAnnotationSidecarCodec.annotationDeletionsJson(next) - if (file.isFile && file.readText() == nextJson) return - file.parentFile?.mkdirs() - file.writeText(nextJson) - logDesktopCloudAnnotations { - "desktop.local.mark_deleted_annotations book=$logBookId ids=${ids.sorted()} " + - "bytes=${file.length()} ts=${file.lastModified()}" - } - } - - fun exportAnnotationBundle(book: BookItem): File? { - val path = book.path?.takeIf { it.isNotBlank() } ?: return null - if (book.type != FileType.PDF) return null - val annotationFile = desktopPdfAnnotationFile(path) - val deletedAnnotationFile = desktopPdfAnnotationDeletionFile(path) - val richTextFile = desktopPdfRichTextFile(path) - logDesktopCloudAnnotations { - "desktop.export.inspect book=${book.id} ${localAnnotationDebugSummary(book)}" - } - val data = buildMap { - if (annotationFile.isFile) { - desktopPdfAnnotationElementForSync(annotationFile.readText())?.let { annotations -> - put(SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS, annotations) - } - } - if (deletedAnnotationFile.hasSyncablePdfAnnotationDeletions()) { - val deletions = SharedPdfAnnotationSidecarCodec.annotationDeletionsFromJson(deletedAnnotationFile.readText()) - put( - SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATION_DELETIONS, - SharedPdfAnnotationSidecarCodec.encodeAnnotationDeletionsElement(deletions) - ) - } - if (richTextFile.isFile) { - desktopPdfRichTextElementForSync(richTextFile.readText())?.let { put("text", it) } - } - } - if (data.isEmpty()) { - logDesktopCloudAnnotations { "desktop.export.skip book=${book.id} reason=no_syncable_payload" } - return null - } - val payload = JsonObject(mapOf("version" to JsonPrimitive(2)) + data) - val canonical = SharedPdfAnnotationSidecarCodec.canonicalizeDataJson( - cloudSidecarJson.encodeToString(JsonElement.serializer(), payload) - ) - val tempFile = File( - desktopUserCacheRoot(), - "sync_bundle_${book.id.toDesktopSafeFileName()}_${System.nanoTime()}.json" - ) - tempFile.parentFile?.mkdirs() - tempFile.writeText(canonical) - logDesktopCloudAnnotations { - "desktop.export.bundle_ready book=${book.id} keys=${data.keys.toList()} " + - "canonicalBytes=${canonical.length} fileBytes=${tempFile.length()} temp=${tempFile.name}" - } - return tempFile - } - - fun importAnnotationBundle(book: BookItem, rawJson: String, timestamp: Long): Boolean { - val path = book.path?.takeIf { it.isNotBlank() } ?: run { - logDesktopCloudAnnotations { "desktop.import.skip book=${book.id} reason=missing_path bytes=${rawJson.length}" } - return false - } - if (book.type != FileType.PDF) { - logDesktopCloudAnnotations { "desktop.import.skip book=${book.id} reason=not_pdf type=${book.type} bytes=${rawJson.length}" } - return false - } - val root = cloudSidecarJson.parseElementOrNull(rawJson)?.jsonObjectOrNull() ?: run { - logDesktopCloudAnnotations { "desktop.import.skip book=${book.id} reason=parse_failed bytes=${rawJson.length}" } - return false - } - val data = root["data"]?.jsonObjectOrNull() ?: root - val canonicalData = SharedPdfAnnotationSidecarCodec.withCanonicalAnnotations(data) - val annotationFile = desktopPdfAnnotationFile(path) - val deletedAnnotationFile = desktopPdfAnnotationDeletionFile(path) - val bookmarkFile = desktopPdfBookmarkFile(path) - val richTextFile = desktopPdfRichTextFile(path) - logDesktopCloudAnnotations { - "desktop.import.inspect book=${book.id} remoteTs=$timestamp rawBytes=${rawJson.length} " + - "rawKeys=${data.keys.toList()} canonicalKeys=${canonicalData.keys.toList()} " + - localAnnotationDebugSummary(book) - } - - if (canonicalData.hasPdfAnnotationPayload()) { - val annotations = SharedPdfAnnotationSidecarCodec.annotationsFromData(canonicalData) - if (annotations.isEmpty()) { - if (annotationFile.isFile) { - val deleted = annotationFile.delete() - logDesktopCloudAnnotations { "desktop.import.delete_annotations book=${book.id} deleted=$deleted" } - } else { - logDesktopCloudAnnotations { "desktop.import.annotations_empty book=${book.id} existing=false" } - } - } else { - annotationFile.parentFile?.mkdirs() - annotationFile.writeText(SharedPdfAnnotationSerializer.encode(annotations)) - annotationFile.setLastModified(timestamp) - logDesktopCloudAnnotations { - "desktop.import.write_annotations book=${book.id} count=${annotations.size} " + - "bytes=${annotationFile.length()} ts=${annotationFile.lastModified()}" - } - } - } else if (annotationFile.isFile) { - val deleted = annotationFile.delete() - logDesktopCloudAnnotations { "desktop.import.delete_annotations_missing_payload book=${book.id} deleted=$deleted" } - } else { - logDesktopCloudAnnotations { "desktop.import.no_annotation_payload book=${book.id} existing=false" } - } - - val deletions = SharedPdfAnnotationSidecarCodec.annotationDeletionsFromData(canonicalData) - if (deletions.isEmpty()) { - if (deletedAnnotationFile.isFile) { - val deleted = deletedAnnotationFile.delete() - logDesktopCloudAnnotations { "desktop.import.delete_annotation_tombstones book=${book.id} deleted=$deleted" } - } - } else { - deletedAnnotationFile.parentFile?.mkdirs() - deletedAnnotationFile.writeText(SharedPdfAnnotationSidecarCodec.annotationDeletionsJson(deletions)) - deletedAnnotationFile.setLastModified(timestamp) - logDesktopCloudAnnotations { - "desktop.import.write_annotation_tombstones book=${book.id} count=${deletions.size} " + - "bytes=${deletedAnnotationFile.length()} ts=${deletedAnnotationFile.lastModified()}" - } - } - - canonicalData["bookmarks"]?.let { bookmarks -> - bookmarkFile.parentFile?.mkdirs() - bookmarkFile.writeText(cloudSidecarJson.encodeToString(JsonElement.serializer(), bookmarks)) - bookmarkFile.setLastModified(timestamp) - logDesktopCloudAnnotations { - "desktop.import.write_bookmarks book=${book.id} bytes=${bookmarkFile.length()} ts=${bookmarkFile.lastModified()}" - } - } - - canonicalData["text"]?.let { richText -> - val richDocument = SharedPdfRichTextSerializer.decodeElement(richText) - if (richDocument.text.isEmpty() && richDocument.spans.isEmpty()) { - if (richTextFile.isFile) { - val deleted = richTextFile.delete() - logDesktopCloudAnnotations { "desktop.import.delete_text_empty book=${book.id} deleted=$deleted" } - } else { - logDesktopCloudAnnotations { "desktop.import.text_empty book=${book.id} existing=false" } - } - } else { - richTextFile.parentFile?.mkdirs() - richTextFile.writeText(SharedPdfRichTextSerializer.encode(richDocument)) - richTextFile.setLastModified(timestamp) - logDesktopCloudAnnotations { - "desktop.import.write_text book=${book.id} textChars=${richDocument.text.length} " + - "spans=${richDocument.spans.size} bytes=${richTextFile.length()} ts=${richTextFile.lastModified()}" - } - } - } ?: run { - if (richTextFile.isFile) { - val deleted = richTextFile.delete() - logDesktopCloudAnnotations { "desktop.import.delete_text_missing book=${book.id} deleted=$deleted" } - } else { - logDesktopCloudAnnotations { "desktop.import.no_text_payload book=${book.id} existing=false" } - } - } - logDesktopCloudAnnotations { - "desktop.import.done book=${book.id} remoteTs=$timestamp ${localAnnotationDebugSummary(book)}" - } - return true - } -} - -private fun localAnnotationPayloadTimestamp(path: String): Long { - return maxOf( - desktopPdfAnnotationFile(path).lastModifiedIfSyncableAnnotations(), - desktopPdfAnnotationDeletionFile(path).lastModifiedIfSyncableAnnotationDeletions(), - desktopPdfRichTextFile(path).lastModifiedIfSyncableRichText() - ) -} - -private val cloudSidecarJson = Json { - ignoreUnknownKeys = true - prettyPrint = true - encodeDefaults = true -} - -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.lastModifiedIfFile(): Long { - return if (isFile) lastModified() else 0L -} - -private fun File.hasSyncablePdfAnnotations(): Boolean { - return isFile && desktopPdfAnnotationElementForSync(readText()) != null -} - -private fun File.lastModifiedIfSyncableAnnotations(): Long { - return if (hasSyncablePdfAnnotations()) lastModified() else 0L -} - -private fun File.annotationDeletionCount(): Int { - return if (isFile) SharedPdfAnnotationSidecarCodec.annotationDeletionsFromJson(readText()).size else 0 -} - -private fun File.hasSyncablePdfAnnotationDeletions(): Boolean { - return annotationDeletionCount() > 0 -} - -private fun File.lastModifiedIfSyncableAnnotationDeletions(): Long { - return if (hasSyncablePdfAnnotationDeletions()) lastModified() else 0L -} - -private fun File.hasSyncablePdfRichText(): Boolean { - return isFile && desktopPdfRichTextElementForSync(readText()) != null -} - -private fun File.lastModifiedIfSyncableRichText(): Long { - return if (hasSyncablePdfRichText()) lastModified() else 0L -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSync.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSync.kt deleted file mode 100644 index 382ac97..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSync.kt +++ /dev/null @@ -1,1204 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import org.dueattendant149.bookreader.shared.BookItem -import org.dueattendant149.bookreader.shared.BookShelfRef -import org.dueattendant149.bookreader.shared.CustomFontItem -import org.dueattendant149.bookreader.shared.EpubAnnotationSerializer -import org.dueattendant149.bookreader.shared.EpubBookmark -import org.dueattendant149.bookreader.shared.FileType -import org.dueattendant149.bookreader.shared.ReaderLocator -import org.dueattendant149.bookreader.shared.SharedCloudBookMetadataWinner -import org.dueattendant149.bookreader.shared.SharedFileCapabilities -import org.dueattendant149.bookreader.shared.SharedReaderScreenState -import org.dueattendant149.bookreader.shared.ShelfRecord -import org.dueattendant149.bookreader.shared.sharedCloudBookMetadataWinner -import org.dueattendant149.bookreader.shared.shouldDownloadRemoteCloudBookContent -import org.dueattendant149.bookreader.shared.shouldUploadLocalCloudBookContent -import org.dueattendant149.bookreader.shared.sharedCloudBookContentFileName -import org.dueattendant149.bookreader.shared.toStablePositionCfi -import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationSidecarCodec -import org.dueattendant149.bookreader.shared.pdf.SharedPdfReaderViewport -import org.dueattendant149.bookreader.shared.reader.ReaderBookmark -import java.io.File - -internal data class DesktopCloudSyncInput( - val userId: String, - val idToken: String, - val driveAccessToken: String, - val deviceId: String, - val state: SharedReaderScreenState, - val shelfRecords: List, - val shelfRefs: List, - val customFonts: List, - val includeFolderBooks: Boolean -) - -internal data class DesktopCloudSyncResult( - val state: SharedReaderScreenState, - val shelfRecords: List, - val shelfRefs: List, - val customFonts: List, - val uploadedBooks: Int = 0, - val downloadedBooks: Int = 0, - val pendingContentDownloads: Int = 0 -) - -internal class DesktopCloudSync( - private val firestoreRepository: DesktopFirestoreRepository, - private val driveRepository: DesktopGoogleDriveRepository, - private val bookImporter: DesktopBookImporter, - private val customFontStore: DesktopCustomFontStore -) { - suspend fun sync(input: DesktopCloudSyncInput): DesktopCloudSyncResult { - var state = input.state - var shelfRecords = input.shelfRecords - var shelfRefs = input.shelfRefs - var customFonts = input.customFonts - var uploadedBooks = 0 - var downloadedBooks = 0 - var pendingContentDownloads = 0 - - logDesktopCloudSync { - "desktop.engine.full_sync.start user=${input.userId} device=${input.deviceId} " + - "localBooks=${input.state.rawLibraryBooks.size} includeFolderBooks=${input.includeFolderBooks}" - } - val remoteBooks = firestoreRepository.getAllBooks(input.userId, input.idToken) - .filterNot { isDesktopPdfReflowBookId(it.bookId) } - .filterNot { SharedFileCapabilities.isManualOnlyReaderFileName(it.displayName) } - val remoteShelves = firestoreRepository.getAllShelves(input.userId, input.idToken) - val remoteFonts = firestoreRepository.getAllFonts(input.userId, input.idToken) - var driveFiles = driveRepository.getFiles(input.driveAccessToken).associateBy { it.name } - logDesktopCloudSync { - "desktop.engine.full_sync.loaded user=${input.userId} remoteBooks=${remoteBooks.size} " + - "remoteShelves=${remoteShelves.size} remoteFonts=${remoteFonts.size} driveFiles=${driveFiles.size}" - } - - val localBooks = state.rawLibraryBooks - .filterNot { isDesktopPdfReflowBookId(it.id) } - .filter { input.includeFolderBooks || it.sourceFolder == null } - .filterNot { it.path?.startsWith("opds-pse") == true } - .filterNot { SharedFileCapabilities.isManualOnlyReaderFileName(it.displayName) } - val localBooksMap = localBooks.associateBy { it.id } - val remoteBooksMap = remoteBooks.associateBy { it.bookId } - val allBookIds = (localBooksMap.keys + remoteBooksMap.keys).distinct() - - allBookIds.forEach { bookId -> - val local = localBooksMap[bookId] - val remote = remoteBooksMap[bookId] - if (local?.sourceFolder != null) return@forEach - - when { - local != null && remote == null -> { - logDesktopCloudSync { "desktop.engine.book_decision action=upload_new ${local.desktopCloudSyncSummary()}" } - uploadBookAndMetadata(input, local, uploadContent = true)?.let { synced -> - state = state.upsertCloudBook(synced) - uploadedBooks += 1 - } - } - - local == null && remote != null -> { - if (remote.isDeleted) { - logDesktopCloudSync { "desktop.engine.book_decision action=skip_deleted_remote_only ${remote.desktopCloudSyncSummary()}" } - return@forEach - } - logDesktopCloudSync { "desktop.engine.book_decision action=apply_remote_new ${remote.desktopCloudSyncSummary()}" } - val downloaded = downloadRemoteBook(input.driveAccessToken, remote, null, driveFiles) - if (downloaded == null) { - pendingContentDownloads += 1 - logDesktopCloudSync { - "desktop.engine.book_decision action=defer_remote_new_pending_content " + - remote.desktopCloudSyncSummary() - } - return@forEach - } - val remoteBook = downloaded - state = state.upsertCloudBook(remoteBook) - downloadedBooks += 1 - importDesktopPdfBookmarksMetadata(remoteBook, remote.bookmarksJson, remote.lastModifiedTimestamp) - if (remote.hasAnnotations) { - val remoteAnnotationTimestamp = remote.effectiveCloudAnnotationModifiedTimestamp( - remoteAnnotationDriveFileTimestamp(remote.bookId, driveFiles) - ) - downloadAnnotations(input.driveAccessToken, remoteBook, remoteAnnotationTimestamp) - } - } - - local != null && remote != null -> { - val remoteBook = remote.toDesktopBookItem(existing = local) - val shouldDownloadContent = shouldDownloadRemoteBookContent(local, remote) - val downloaded = if (shouldDownloadContent) { - downloadRemoteBook(input.driveAccessToken, remote, local, driveFiles) - } else { - null - } - if (shouldDownloadContent && downloaded == null) { - pendingContentDownloads += 1 - } - val localContentAvailable = local.path?.let(::File)?.isFile == true - if (shouldDownloadContent && downloaded == null && !localContentAvailable) { - logDesktopCloudSync { - "desktop.engine.book_decision action=defer_existing_pending_content book=$bookId " + - local.desktopCloudSyncSummary() + " " + remote.desktopCloudSyncSummary() - } - state = state.removeCloudBook(bookId) - return@forEach - } - val localSidecarTimestampBeforeMerge = DesktopCloudSidecarSync.localAnnotationTimestamp(local) - val metadataWinner = sharedCloudBookMetadataWinner( - localModifiedTimestamp = local.timestamp, - remoteModifiedTimestamp = remote.lastModifiedTimestamp - ) - val localMetadataWins = metadataWinner == SharedCloudBookMetadataWinner.LOCAL - val localReadingTimestamp = local.effectiveCloudReadingPositionModifiedTimestamp() - val remoteReadingTimestamp = remote.effectiveCloudReadingPositionModifiedTimestamp() - val remoteAnnotationDriveTimestamp = remoteAnnotationDriveFileTimestamp(bookId, driveFiles) - val remoteAnnotationTimestamp = remote.effectiveCloudAnnotationModifiedTimestamp(remoteAnnotationDriveTimestamp) - val localReadingPositionShouldUpload = localReadingTimestamp > remoteReadingTimestamp - val localAnnotationsShouldUpload = shouldUploadLocalAnnotations( - local = local, - remote = remote, - remoteAnnotationModifiedTimestamp = remoteAnnotationTimestamp, - localSidecarTimestamp = localSidecarTimestampBeforeMerge - ) - logDesktopCloudAnnotations { - "desktop.sync.inspect book=$bookId remoteHas=${remote.hasAnnotations} " + - "remoteTs=${remote.lastModifiedTimestamp} remoteAnnTs=$remoteAnnotationTimestamp " + - "remoteDriveAnnTs=$remoteAnnotationDriveTimestamp localTs=${local.timestamp} " + - "remoteReadTs=$remoteReadingTimestamp localReadTs=$localReadingTimestamp " + - "localShouldUpload=$localAnnotationsShouldUpload " + - DesktopCloudSidecarSync.localAnnotationDebugSummary(local) - } - logDesktopCloudSync { - "desktop.engine.book_compare book=$bookId winner=$metadataWinner shouldDownloadContent=$shouldDownloadContent " + - "downloadedContent=${downloaded != null} sidecarTs=$localSidecarTimestampBeforeMerge " + - "uploadAnnotations=$localAnnotationsShouldUpload uploadReading=$localReadingPositionShouldUpload " + - local.desktopCloudSyncSummary() + " " + remote.desktopCloudSyncSummary() - } - - if (remote.isDeleted) { - if (localMetadataWins) { - logDesktopCloudSync { "desktop.engine.book_decision action=resurrect_upload_local book=$bookId" } - uploadBookAndMetadata( - input = input, - book = local, - uploadContent = shouldUploadLocalBookContent(local, null), - uploadAnnotations = DesktopCloudSidecarSync.hasLocalAnnotationData(local) - )?.let { synced -> - state = state.upsertCloudBook(synced) - uploadedBooks += 1 - } - } else if (metadataWinner == SharedCloudBookMetadataWinner.REMOTE) { - logDesktopCloudSync { "desktop.engine.book_decision action=apply_remote_delete book=$bookId" } - state = state.removeCloudBook(bookId) - } else { - logDesktopCloudSync { "desktop.engine.book_decision action=skip_equal_delete book=$bookId" } - } - return@forEach - } - - if (localMetadataWins) { - logDesktopCloudSync { - "desktop.engine.book_decision action=upload_local book=$bookId " + - "uploadContent=${shouldUploadLocalBookContent(local, remote)} " + - "uploadAnnotations=$localAnnotationsShouldUpload " + - "preserveRemoteReading=${remoteReadingTimestamp > localReadingTimestamp}" - } - val localForMetadata = if (remoteReadingTimestamp > localReadingTimestamp) { - local.withCloudReadingPosition(remote) - } else { - local - } - val bookForMetadata = localForMetadata.withDownloadedCloudContent(downloaded, replacePath = false) - uploadBookAndMetadata( - input = input, - book = bookForMetadata, - uploadContent = shouldUploadLocalBookContent(local, remote), - uploadAnnotations = localAnnotationsShouldUpload, - remoteHasAnnotations = remote.hasAnnotations, - remoteAnnotationModifiedTimestamp = remoteAnnotationTimestamp, - remoteContentModifiedTimestamp = remote.fileContentModifiedTimestamp - )?.let { synced -> - state = state.upsertCloudBook(synced.withDownloadedCloudContent(downloaded)) - uploadedBooks += 1 - } - } else if (metadataWinner == SharedCloudBookMetadataWinner.REMOTE || downloaded != null) { - logDesktopCloudSync { - "desktop.engine.book_decision action=apply_remote book=$bookId " + - "metadataWinner=$metadataWinner downloadedContent=${downloaded != null}" - } - val mergedBook = downloaded ?: remoteBook - state = state.upsertCloudBook(mergedBook) - importDesktopPdfBookmarksMetadata(mergedBook, remote.bookmarksJson, remote.lastModifiedTimestamp) - } - - if (!localMetadataWins && (localAnnotationsShouldUpload || localReadingPositionShouldUpload)) { - val metadataBook = state.rawLibraryBooks.firstOrNull { it.id == bookId } - ?: remoteBook - logDesktopCloudAnnotations { - "desktop.sync.upload_local_supplement book=$bookId winner=$metadataWinner " + - "remoteHas=${remote.hasAnnotations} remoteTs=${remote.lastModifiedTimestamp} " + - "remoteAnnTs=$remoteAnnotationTimestamp " + - "localSidecarTs=$localSidecarTimestampBeforeMerge " + - "uploadAnnotations=$localAnnotationsShouldUpload uploadReading=$localReadingPositionShouldUpload " + - "localReadTs=$localReadingTimestamp remoteReadTs=$remoteReadingTimestamp" - } - logDesktopCloudSync { - "desktop.engine.book_decision action=upload_local_supplement book=$bookId " + - "metadataWinner=$metadataWinner sidecarTs=$localSidecarTimestampBeforeMerge " + - "uploadAnnotations=$localAnnotationsShouldUpload uploadReading=$localReadingPositionShouldUpload " + - metadataBook.desktopCloudSyncSummary() - } - uploadBookAndMetadata( - input = input, - book = metadataBook, - uploadContent = false, - uploadAnnotations = localAnnotationsShouldUpload, - remoteHasAnnotations = remote.hasAnnotations, - remoteAnnotationModifiedTimestamp = remoteAnnotationTimestamp, - remoteContentModifiedTimestamp = remote.fileContentModifiedTimestamp - )?.let { synced -> - state = state.upsertCloudBook(synced.withDownloadedCloudContent(downloaded)) - uploadedBooks += 1 - } - } - - val localSidecarTimestamp = DesktopCloudSidecarSync.localAnnotationPayloadTimestamp(downloaded ?: local) - val needsAnnotationDownload = !localMetadataWins && - !localAnnotationsShouldUpload && - remote.hasAnnotations && - (remoteAnnotationTimestamp > localSidecarTimestamp || localSidecarTimestamp == 0L) - if (needsAnnotationDownload) { - logDesktopCloudAnnotations { - "desktop.sync.download_remote_annotations book=$bookId remoteTs=${remote.lastModifiedTimestamp} " + - "remoteAnnTs=$remoteAnnotationTimestamp " + - "localSidecarTs=$localSidecarTimestamp localMetadataWins=$localMetadataWins " + - "localShouldUpload=$localAnnotationsShouldUpload" - } - logDesktopCloudSync { - "desktop.engine.sidecar_download_start book=$bookId remoteTs=${remote.lastModifiedTimestamp} " + - "remoteAnnTs=$remoteAnnotationTimestamp localSidecarTs=$localSidecarTimestamp localMetadataWins=$localMetadataWins" - } - val targetBook = downloaded ?: state.rawLibraryBooks.firstOrNull { it.id == bookId } ?: local - downloadAnnotations(input.driveAccessToken, targetBook, remoteAnnotationTimestamp) - } else { - logDesktopCloudAnnotations { - "desktop.sync.skip_remote_annotations book=$bookId remoteHas=${remote.hasAnnotations} " + - "remoteAnnTs=$remoteAnnotationTimestamp localSidecarTs=$localSidecarTimestamp localMetadataWins=$localMetadataWins " + - "localShouldUpload=$localAnnotationsShouldUpload" - } - logDesktopCloudSync { - "desktop.engine.sidecar_download_skip book=$bookId remoteHasAnnotations=${remote.hasAnnotations} " + - "localSidecarTs=$localSidecarTimestamp localMetadataWins=$localMetadataWins" - } - } - } - } - } - - driveFiles = driveRepository.getFiles(input.driveAccessToken).associateBy { it.name } - state.rawLibraryBooks - .filterNot { isDesktopPdfReflowBookId(it.id) } - .filter { it.sourceFolder == null } - .filterNot { it.path?.startsWith("opds-pse") == true } - .filterNot { SharedFileCapabilities.isManualOnlyReaderFileName(it.displayName) } - .forEach { book -> - val driveName = desktopCloudBookDriveFileName(book.id, book.type) ?: return@forEach - val localFile = book.path?.let(::File) - when { - localFile?.isFile == true && driveFiles[driveName] == null -> { - val remote = remoteBooksMap[book.id] - if (remote == null || shouldUploadLocalBookContent(book, remote)) { - logDesktopCloudSync { "desktop.engine.content_upload_missing_remote book=${book.id} driveName=$driveName" } - uploadBookAndMetadata( - input = input, - book = book, - uploadContent = true, - uploadAnnotations = false, - remoteHasAnnotations = remote?.hasAnnotations == true, - remoteAnnotationModifiedTimestamp = remote?.effectiveCloudAnnotationModifiedTimestamp( - remoteAnnotationDriveFileTimestamp(book.id, driveFiles) - ) ?: 0L, - remoteContentModifiedTimestamp = remote?.fileContentModifiedTimestamp - )?.let { synced -> - state = state.upsertCloudBook(synced) - uploadedBooks += 1 - } - } else { - pendingContentDownloads += 1 - logDesktopCloudSync { - "desktop.engine.content_wait_missing_remote book=${book.id} driveName=$driveName " + - "localContentTs=${book.fileContentModifiedTimestamp} remoteContentTs=${remote.fileContentModifiedTimestamp}" - } - } - } - - (localFile == null || !localFile.isFile) && driveFiles[driveName] != null -> { - val remote = remoteBooksMap[book.id] ?: return@forEach - logDesktopCloudSync { "desktop.engine.content_download_missing_local book=${book.id} driveName=$driveName" } - val downloaded = downloadRemoteBook(input.driveAccessToken, remote, book, driveFiles) - if (downloaded != null) { - state = state.upsertCloudBook(downloaded) - downloadedBooks += 1 - } else { - pendingContentDownloads += 1 - } - } - - (localFile == null || !localFile.isFile) && driveFiles[driveName] == null -> { - pendingContentDownloads += 1 - logDesktopCloudSync { "desktop.engine.content_wait_missing_remote book=${book.id} driveName=$driveName" } - state = state.removeCloudBook(book.id) - } - } - } - - val shelfSync = syncShelves( - userId = input.userId, - idToken = input.idToken, - deviceId = input.deviceId, - shelfRecords = shelfRecords, - shelfRefs = shelfRefs, - syncableBookIds = state.rawLibraryBooks - .filterNot { isDesktopPdfReflowBookId(it.id) } - .mapTo(mutableSetOf()) { it.id }, - remoteShelves = remoteShelves - ) - shelfRecords = shelfSync.records - shelfRefs = shelfSync.refs - - customFonts = syncFonts( - userId = input.userId, - idToken = input.idToken, - accessToken = input.driveAccessToken, - localFonts = customFonts, - remoteFonts = remoteFonts - ) - - logDesktopCloudSync { - "desktop.engine.full_sync.complete user=${input.userId} uploaded=$uploadedBooks downloaded=$downloadedBooks " + - "pendingContent=$pendingContentDownloads books=${state.rawLibraryBooks.size}" - } - return DesktopCloudSyncResult( - state = state, - shelfRecords = shelfRecords, - shelfRefs = shelfRefs, - customFonts = customFonts.filterNot { it.isDeleted }.sortedBy { it.displayName.lowercase() }, - uploadedBooks = uploadedBooks, - downloadedBooks = downloadedBooks, - pendingContentDownloads = pendingContentDownloads - ) - } - - suspend fun uploadBookAndMetadata( - input: DesktopCloudSyncInput, - book: BookItem, - uploadContent: Boolean, - uploadAnnotations: Boolean = true, - remoteHasAnnotations: Boolean = false, - remoteAnnotationModifiedTimestamp: Long = 0L, - remoteContentModifiedTimestamp: Long? = null - ): BookItem? { - if (isDesktopPdfReflowBookId(book.id)) { - logDesktopCloudSync { "desktop.upload.skip reason=reflow ${book.desktopCloudSyncSummary()}" } - return null - } - if (book.sourceFolder != null) { - logDesktopCloudSync { "desktop.upload.skip reason=folder_book ${book.desktopCloudSyncSummary()}" } - return null - } - if (book.path?.startsWith("opds-pse") == true) { - logDesktopCloudSync { "desktop.upload.skip reason=opds_stream ${book.desktopCloudSyncSummary()}" } - return null - } - if (SharedFileCapabilities.isManualOnlyReaderFileName(book.displayName)) { - logDesktopCloudSync { "desktop.upload.skip reason=manual_only ${book.desktopCloudSyncSummary()}" } - return null - } - logDesktopCloudSync { - "desktop.upload.start uploadContent=$uploadContent uploadAnnotations=$uploadAnnotations " + - "remoteHasAnnotations=$remoteHasAnnotations ${book.desktopCloudSyncSummary()}" - } - if (uploadContent) { - val source = book.path?.let(::File)?.takeIf { it.isFile } - if (source != null && driveRepository.uploadFile(input.driveAccessToken, book.id, source, book.type) == null) { - logDesktopCloudSync { "desktop.upload.content_failed book=${book.id} path=${source.absolutePath}" } - return null - } - logDesktopCloudSync { "desktop.upload.content_success book=${book.id} path=${source?.absolutePath ?: "none"}" } - } - - val hasLocalAnnotations = DesktopCloudSidecarSync.hasLocalAnnotationData(book) - val shouldUploadAnnotations = uploadAnnotations || (!remoteHasAnnotations && hasLocalAnnotations) - val bundle = if (shouldUploadAnnotations) DesktopCloudSidecarSync.exportAnnotationBundle(book) else null - var uploadedAnnotationTimestamp = 0L - logDesktopCloudAnnotations { - "desktop.upload.annotation_decision book=${book.id} uploadAnnotations=$uploadAnnotations " + - "remoteHas=$remoteHasAnnotations hasLocal=$hasLocalAnnotations shouldUpload=$shouldUploadAnnotations " + - "bundleBytes=${bundle?.length() ?: 0L} " + DesktopCloudSidecarSync.localAnnotationDebugSummary(book) - } - try { - if (bundle != null) { - val mergedRemoteIntoUpload = mergeRemoteAnnotationsIntoUploadBundle( - accessToken = input.driveAccessToken, - book = book, - bundle = bundle, - remoteHasAnnotations = remoteHasAnnotations - ) - val uploadedAnnotationFile = driveRepository.uploadAnnotationFile(input.driveAccessToken, book.id, bundle) - if (uploadedAnnotationFile == null) { - logDesktopCloudAnnotations { "desktop.upload.sidecar_failed book=${book.id} bytes=${bundle.length()}" } - logDesktopCloudSync { "desktop.upload.sidecar_failed book=${book.id} bytes=${bundle.length()}" } - return null - } - uploadedAnnotationTimestamp = uploadedAnnotationFile.modifiedTimeMillis - if (mergedRemoteIntoUpload) { - val appliedMergedLocal = DesktopCloudSidecarSync.importAnnotationBundle( - book = book, - rawJson = bundle.readText(), - timestamp = uploadedAnnotationTimestamp - ) - logDesktopCloudAnnotations { - "desktop.upload.local_apply_merged book=${book.id} applied=$appliedMergedLocal " + - "driveTs=$uploadedAnnotationTimestamp bytes=${bundle.length()}" - } - } - DesktopCloudSidecarSync.markAnnotationPayloadSynced(book, uploadedAnnotationTimestamp) - } - if (bundle != null) { - logDesktopCloudAnnotations { - "desktop.upload.sidecar_success book=${book.id} bytes=${bundle.length()} driveTs=$uploadedAnnotationTimestamp" - } - } else { - logDesktopCloudAnnotations { - "desktop.upload.sidecar_skipped book=${book.id} shouldUpload=$shouldUploadAnnotations hasLocal=$hasLocalAnnotations" - } - } - logDesktopCloudSync { - "desktop.upload.sidecar_decision book=${book.id} hasLocal=$hasLocalAnnotations " + - "shouldUpload=$shouldUploadAnnotations uploaded=${bundle != null} bytes=${bundle?.length() ?: 0L}" - } - } finally { - bundle?.delete() - } - - val now = System.currentTimeMillis() - val syncedBook = book.copy( - timestamp = now, - readingPositionModifiedTimestamp = book.effectiveCloudReadingPositionModifiedTimestamp() - ) - val localAnnotationTimestamp = DesktopCloudSidecarSync.localAnnotationPayloadTimestamp(book) - val syncedAnnotationTimestamp = if (bundle != null) { - uploadedAnnotationTimestamp.takeIf { it > 0L } ?: maxOf(localAnnotationTimestamp, now) - } else if (remoteHasAnnotations) { - remoteAnnotationModifiedTimestamp - } else { - 0L - } - val syncedHasAnnotations = if (uploadAnnotations) { - syncedAnnotationTimestamp > 0L || (bundle != null && hasLocalAnnotations) - } else { - remoteHasAnnotations || syncedAnnotationTimestamp > 0L || bundle != null || hasLocalAnnotations - } - firestoreRepository.syncBookMetadata( - userId = input.userId, - book = syncedBook.toDesktopCloudBookMetadata( - hasAnnotations = syncedHasAnnotations, - timestamp = now, - annotationModifiedTimestamp = syncedAnnotationTimestamp, - contentTimestampOverride = if (uploadContent) null else remoteContentModifiedTimestamp - ), - originDeviceId = input.deviceId, - idToken = input.idToken - ) - logDesktopCloudSync { - "desktop.upload.metadata_success user=${input.userId} device=${input.deviceId} " + - "oldTs=${book.timestamp} newTs=$now hasAnnotations=$syncedHasAnnotations " + - syncedBook.desktopCloudSyncSummary("synced") - } - logDesktopCloudAnnotations { - "desktop.upload.metadata_success book=${book.id} oldTs=${book.timestamp} newTs=$now " + - "readTs=${syncedBook.effectiveCloudReadingPositionModifiedTimestamp()} " + - "annTs=$syncedAnnotationTimestamp hasAnnotations=$syncedHasAnnotations" - } - return syncedBook - } - - private suspend fun mergeRemoteAnnotationsIntoUploadBundle( - accessToken: String, - book: BookItem, - bundle: File, - remoteHasAnnotations: Boolean - ): Boolean { - if (!remoteHasAnnotations || !bundle.isFile) return false - val remoteTemp = File(desktopUserCacheRoot(), "remote_annotation_${book.id.toDesktopSafeFileName()}_${System.nanoTime()}.json") - try { - val didDownload = driveRepository.downloadAnnotationFile(accessToken, book.id, remoteTemp) - if (!didDownload || !remoteTemp.isFile) { - logDesktopCloudAnnotations { - "desktop.upload.merge_remote_missing book=${book.id} didDownload=$didDownload " + - "tempExists=${remoteTemp.exists()} localBytes=${bundle.length()}" - } - return false - } - val localRaw = bundle.readText() - val remoteRaw = remoteTemp.readText() - val mergedRaw = SharedPdfAnnotationSidecarCodec.mergeAnnotationDataJson( - localDataJson = localRaw, - remoteDataJson = remoteRaw, - preferRemoteOnConflict = false - ) - val localCount = SharedPdfAnnotationSidecarCodec.annotationCountFromDataJson(localRaw) - val remoteCount = SharedPdfAnnotationSidecarCodec.annotationCountFromDataJson(remoteRaw) - val mergedCount = SharedPdfAnnotationSidecarCodec.annotationCountFromDataJson(mergedRaw) - if (mergedRaw != localRaw) { - bundle.writeText(mergedRaw) - logDesktopCloudAnnotations { - "desktop.upload.merge_remote_applied book=${book.id} localCount=$localCount " + - "remoteCount=$remoteCount mergedCount=$mergedCount mergedBytes=${bundle.length()}" - } - return true - } else { - logDesktopCloudAnnotations { - "desktop.upload.merge_remote_noop book=${book.id} localCount=$localCount " + - "remoteCount=$remoteCount mergedCount=$mergedCount" - } - } - } catch (error: Exception) { - logDesktopCloudAnnotations { - "desktop.upload.merge_remote_failed book=${book.id} error=${error.message.orEmpty().logPreview(240)}" - } - } finally { - remoteTemp.delete() - } - return false - } - - suspend fun deleteBooksFromCloud( - userId: String, - idToken: String, - accessToken: String, - deviceId: String, - books: List - ) { - val driveFiles = driveRepository.getFiles(accessToken).associateBy { it.name } - books - .filterNot { isDesktopPdfReflowBookId(it.id) } - .filter { it.sourceFolder == null } - .filterNot { it.path?.startsWith("opds-pse") == true } - .filterNot { SharedFileCapabilities.isManualOnlyReaderFileName(it.displayName) } - .forEach { book -> - firestoreRepository.syncBookMetadata( - userId = userId, - book = book.toDesktopCloudBookMetadata( - hasAnnotations = false, - timestamp = System.currentTimeMillis() - ).copy(isDeleted = true), - originDeviceId = deviceId, - idToken = idToken - ) - desktopCloudBookDriveFileName(book.id, book.type) - ?.let { driveFiles[it]?.id } - ?.let { driveRepository.deleteDriveFile(accessToken, it) } - driveFiles[desktopCloudAnnotationDriveFileName(book.id)]?.id - ?.let { driveRepository.deleteDriveFile(accessToken, it) } - } - } - - suspend fun syncShelfChange( - userId: String, - idToken: String, - deviceId: String, - record: ShelfRecord, - refs: List, - isDeleted: Boolean = false - ) { - if (record.isSmart) return - firestoreRepository.syncShelf( - userId = userId, - shelf = DesktopCloudShelfMetadata( - name = record.name, - bookIds = refs.filter { it.shelfId == record.id }.map { it.bookId }.distinct(), - lastModifiedTimestamp = System.currentTimeMillis(), - isDeleted = isDeleted - ), - originDeviceId = deviceId, - idToken = idToken - ) - } - - suspend fun clearCloudData(userId: String, idToken: String, accessToken: String) { - driveRepository.deleteAllFiles(accessToken) - firestoreRepository.deleteAllUserFirestoreData(userId, idToken) - } - - suspend fun deleteFontFromCloud( - userId: String, - idToken: String, - accessToken: String, - font: CustomFontItem - ) { - val driveFiles = driveRepository.getFiles(accessToken).associateBy { it.name } - driveFiles[font.fileName]?.id?.let { driveRepository.deleteDriveFile(accessToken, it) } - firestoreRepository.deleteFontMetadata(userId, font.id, idToken) - } - - private suspend fun downloadAnnotations(accessToken: String, book: BookItem, timestamp: Long): Boolean { - val temp = File(desktopUserCacheRoot(), "temp_download_${book.id.toDesktopSafeFileName()}_${System.nanoTime()}.json") - return try { - logDesktopCloudAnnotations { - "desktop.download.start book=${book.id} remoteTs=$timestamp temp=${temp.name} " + - DesktopCloudSidecarSync.localAnnotationDebugSummary(book) - } - logDesktopCloudSync { "desktop.sidecar_download.start book=${book.id} remoteTs=$timestamp temp=${temp.name}" } - if (!driveRepository.downloadAnnotationFile(accessToken, book.id, temp) || !temp.isFile) { - logDesktopCloudAnnotations { - "desktop.download.missing book=${book.id} remoteTs=$timestamp tempExists=${temp.exists()} tempBytes=${temp.length()}" - } - logDesktopCloudSync { "desktop.sidecar_download.missing book=${book.id} remoteTs=$timestamp" } - return false - } - val raw = temp.readText() - logDesktopCloudAnnotations { - "desktop.download.success book=${book.id} remoteTs=$timestamp bytes=${raw.length}" - } - val appliedTimestamp = timestamp.takeIf { it > 0L } ?: temp.lastModified().takeIf { it > 0L } ?: 0L - val applied = DesktopCloudSidecarSync.importAnnotationBundle(book, raw, appliedTimestamp) - logDesktopCloudAnnotations { - "desktop.download.applied book=${book.id} remoteTs=$timestamp appliedTs=$appliedTimestamp applied=$applied " + - DesktopCloudSidecarSync.localAnnotationDebugSummary(book) - } - logDesktopCloudSync { - "desktop.sidecar_download.applied book=${book.id} remoteTs=$timestamp appliedTs=$appliedTimestamp bytes=${temp.length()} applied=$applied" - } - applied - } finally { - temp.delete() - } - } - - private suspend fun downloadRemoteBook( - accessToken: String, - remote: DesktopCloudBookMetadata, - existing: BookItem?, - driveFiles: Map - ): BookItem? { - val type = remote.fileType() - val driveName = desktopCloudBookDriveFileName(remote.bookId, type) ?: return null - val driveFile = driveFiles[driveName] ?: return null - val extension = SharedFileCapabilities.primaryExtensionFor(type) ?: return null - val destination = bookImporter.createBookFile("${remote.bookId.toDesktopSafeFileName()}.$extension") - logDesktopCloudSync { "desktop.content_download.start book=${remote.bookId} driveName=$driveName remoteContentTs=${remote.fileContentModifiedTimestamp}" } - if (!driveRepository.downloadFile(accessToken, driveFile.id, destination)) { - destination.delete() - logDesktopCloudSync { "desktop.content_download.failed book=${remote.bookId} driveName=$driveName" } - return null - } - val contentTimestamp = remote.fileContentModifiedTimestamp.takeIf { it > 0L } ?: destination.lastModified() - if (contentTimestamp > 0L) destination.setLastModified(contentTimestamp) - val downloaded = remote.toDesktopBookItem(existing = existing, downloadedPath = destination.absolutePath).copy( - fileSize = destination.length(), - fileContentModifiedTimestamp = contentTimestamp - ) - logDesktopCloudSync { - "desktop.content_download.success book=${remote.bookId} bytes=${destination.length()} contentTs=$contentTimestamp " + - downloaded.desktopCloudSyncSummary("downloaded") - } - return downloaded - } - - private suspend fun syncFonts( - userId: String, - idToken: String, - accessToken: String, - localFonts: List, - remoteFonts: List - ): List { - val localFontsMap = localFonts.associateBy { it.id } - val remoteFontsMap = remoteFonts.associateBy { it.id } - val driveFiles = driveRepository.getFiles(accessToken).associateBy { it.name } - val nextFonts = localFonts.toMutableList() - - (localFontsMap.keys + remoteFontsMap.keys).forEach { fontId -> - val local = localFontsMap[fontId] - val remote = remoteFontsMap[fontId] - when { - local != null && remote == null -> { - firestoreRepository.syncFontMetadata(userId, local.toDesktopCloudFontMetadata(), idToken) - } - - local == null && remote != null && !remote.isDeleted -> { - val target = customFontStore.getFontFile(remote.fileName) - driveFiles[remote.fileName]?.id?.let { driveRepository.downloadFile(accessToken, it, target) } - nextFonts += customFontStore.syncedFontItem(remote) - } - - local != null && remote != null -> { - when { - local.isDeleted && !remote.isDeleted -> { - firestoreRepository.syncFontMetadata(userId, remote.copy(isDeleted = true), idToken) - } - - !local.isDeleted && remote.isDeleted -> { - customFontStore.deleteFont(local) - nextFonts.removeAll { it.id == local.id } - } - } - } - } - } - - nextFonts.toList().forEach { font -> - val localFile = File(font.path) - if (!font.isDeleted && localFile.isFile && driveFiles[font.fileName] == null) { - driveRepository.uploadFont(accessToken, font.fileName, localFile, font.fileExtension) - } else if (!font.isDeleted && !localFile.isFile) { - driveFiles[font.fileName]?.id?.let { driveRepository.downloadFile(accessToken, it, localFile) } - } - } - return nextFonts.distinctBy { it.id } - } - - private suspend fun syncShelves( - userId: String, - idToken: String, - deviceId: String, - shelfRecords: List, - shelfRefs: List, - syncableBookIds: Set, - remoteShelves: List - ): ShelfSyncResult { - val localShelves = shelfRecords - .filterNot { it.isSmart } - .map { record -> - DesktopCloudShelfRecord( - record = record, - metadata = DesktopCloudShelfMetadata( - name = record.name, - bookIds = shelfRefs.filter { it.shelfId == record.id } - .map { it.bookId } - .filter { it in syncableBookIds } - .distinct(), - lastModifiedTimestamp = desktopShelfTimestamp(record, shelfRefs), - isDeleted = false - ) - ) - } - val localShelvesByName = localShelves.associateBy { it.metadata.name } - val remoteShelvesByName = remoteShelves.associateBy { it.name } - var records = shelfRecords - var refs = shelfRefs - - (localShelvesByName.keys + remoteShelvesByName.keys).forEach { shelfName -> - val local = localShelvesByName[shelfName] - val remote = remoteShelvesByName[shelfName] - when { - local != null && remote == null -> { - firestoreRepository.syncShelf(userId, local.metadata, deviceId, idToken) - } - - local == null && remote != null -> { - if (!remote.isDeleted) { - val record = ShelfRecord(id = "shelf_${remote.lastModifiedTimestamp}_${shelfName.hashCode()}", name = remote.name) - records += record - refs = refs.filterNot { it.shelfId == record.id } + - remote.bookIds.filter { it in syncableBookIds }.map { bookId -> - BookShelfRef(bookId, record.id, remote.lastModifiedTimestamp) - } - } - } - - local != null && remote != null -> { - if (local.metadata.lastModifiedTimestamp > remote.lastModifiedTimestamp) { - firestoreRepository.syncShelf(userId, local.metadata, deviceId, idToken) - } else if (remote.lastModifiedTimestamp > local.metadata.lastModifiedTimestamp) { - if (remote.isDeleted) { - records = records.filterNot { it.id == local.record.id } - refs = refs.filterNot { it.shelfId == local.record.id } - } else { - refs = refs.filterNot { it.shelfId == local.record.id } + - remote.bookIds.filter { it in syncableBookIds }.map { bookId -> - BookShelfRef(bookId, local.record.id, remote.lastModifiedTimestamp) - } - } - } - } - } - } - return ShelfSyncResult(records, refs) - } -} - -internal fun BookItem.toDesktopCloudBookMetadata( - hasAnnotations: Boolean, - timestamp: Long = this.timestamp, - annotationModifiedTimestamp: Long = 0L, - contentTimestampOverride: Long? = null -): DesktopCloudBookMetadata { - val position = readerPosition.takeIf { type.usesCloudLocatorMetadata() } - val supportsReaderAnnotations = type.usesCloudLocatorMetadata() - val bookmarksJson = desktopPdfBookmarksMetadataJson(this) - ?: if (supportsReaderAnnotations) { - readerBookmarks - .mapNotNull { it.toDesktopCloudEpubBookmarkOrNull() } - .let(EpubAnnotationSerializer::bookmarksToJson) - } else { - null - } - val highlightsJson = if (supportsReaderAnnotations) { - EpubAnnotationSerializer.highlightsToJson(readerHighlights) - } else { - null - } - val localFile = path?.let(::File) - val contentTimestamp = contentTimestampOverride - ?: fileContentModifiedTimestamp.takeIf { it > 0L } - ?: localFile?.takeIf { it.isFile }?.lastModified() - ?: 0L - return DesktopCloudBookMetadata( - bookId = id, - title = title, - author = author, - displayName = displayName, - type = type.name, - lastPositionCfi = position?.cloudPositionCfi(), - lastChapterIndex = position?.chapterIndex, - locatorBlockIndex = position?.blockIndex, - locatorCharOffset = position?.charOffset, - lastPage = if (type.usesCloudLocatorMetadata()) position?.pageIndex ?: lastPageIndex else lastPageIndex, - progressPercentage = progressPercentage, - isRecent = isRecent, - isDeleted = false, - lastModifiedTimestamp = timestamp, - readingPositionModifiedTimestamp = effectiveCloudReadingPositionModifiedTimestamp(), - annotationModifiedTimestamp = annotationModifiedTimestamp, - bookmarksJson = bookmarksJson, - hasAnnotations = hasAnnotations, - fileContentModifiedTimestamp = contentTimestamp, - customName = null, - highlightsJson = highlightsJson, - seriesName = seriesName, - seriesIndex = seriesIndex, - description = description, - originalTitle = originalTitle ?: title, - originalAuthor = originalAuthor ?: author, - originalSeriesName = originalSeriesName ?: seriesName, - originalSeriesIndex = originalSeriesIndex ?: seriesIndex, - originalDescription = originalDescription ?: description - ) -} - -internal fun DesktopCloudBookMetadata.toDesktopBookItem( - existing: BookItem? = null, - downloadedPath: String? = null -): BookItem { - val type = fileType() - val pageIndex = lastPage - val locator = if (type.usesCloudLocatorMetadata()) { - ReaderLocator.fromLegacy( - chapterIndex = lastChapterIndex, - cfi = lastPositionCfi, - pageIndex = pageIndex - ).withFallbacks( - blockIndex = locatorBlockIndex, - charOffset = locatorCharOffset - ) - } else { - null - } - val remoteReadingTimestamp = effectiveCloudReadingPositionModifiedTimestamp() - val localReadingTimestamp = existing?.effectiveCloudReadingPositionModifiedTimestamp() ?: 0L - val useRemoteReadingPosition = existing == null || - remoteReadingTimestamp > localReadingTimestamp || - (localReadingTimestamp == 0L && hasCloudReadingPosition()) - val restoredPageIndex = if (useRemoteReadingPosition) pageIndex ?: existing?.lastPageIndex else existing?.lastPageIndex - val restoredReaderPosition = if (type.usesCloudLocatorMetadata()) { - if (useRemoteReadingPosition) { - locator?.takeIf { - it.chapterIndex != null || - it.pageIndex != null || - it.cfi != null || - it.startOffset != null || - it.blockIndex != null - } ?: existing?.readerPosition - } else { - existing?.readerPosition - } - } else { - null - } - return BookItem( - id = bookId, - path = downloadedPath ?: existing?.path, - type = type, - displayName = displayName.ifBlank { existing?.displayName ?: bookId }, - timestamp = lastModifiedTimestamp, - coverImagePath = existing?.coverImagePath, - title = title ?: existing?.title, - author = author ?: existing?.author, - description = description ?: existing?.description, - originalTitle = originalTitle ?: existing?.originalTitle, - originalAuthor = originalAuthor ?: existing?.originalAuthor, - originalSeriesName = originalSeriesName ?: existing?.originalSeriesName, - originalSeriesIndex = originalSeriesIndex ?: existing?.originalSeriesIndex, - originalDescription = originalDescription ?: existing?.originalDescription, - progressPercentage = if (useRemoteReadingPosition) progressPercentage ?: existing?.progressPercentage else existing?.progressPercentage, - isRecent = isRecent, - fileSize = existing?.fileSize ?: 0L, - fileContentModifiedTimestamp = fileContentModifiedTimestamp.takeIf { it > 0L } - ?: existing?.fileContentModifiedTimestamp - ?: 0L, - sourceFolder = null, - folderTextMetadataParsed = existing?.folderTextMetadataParsed ?: false, - seriesName = seriesName ?: existing?.seriesName, - seriesIndex = seriesIndex ?: existing?.seriesIndex, - tags = existing?.tags.orEmpty(), - lastPageIndex = restoredPageIndex, - readerPosition = restoredReaderPosition, - readerSettings = existing?.readerSettings, - readerBookmarks = if (type == FileType.PDF || bookmarksJson.isNullOrBlank()) { - existing?.readerBookmarks.orEmpty() - } else { - EpubAnnotationSerializer.parseBookmarksJson(bookmarksJson).map { bookmark -> - ReaderBookmark( - id = "${bookmark.chapterIndex}:${bookmark.cfi}", - pageIndex = bookmark.pageInChapter?.minus(1) ?: bookmark.locator.pageIndex ?: 0, - chapterTitle = bookmark.chapterTitle, - preview = bookmark.snippet, - locator = bookmark.locator - ) - } - }, - readerHighlights = if (highlightsJson.isNullOrBlank()) { - existing?.readerHighlights.orEmpty() - } else { - EpubAnnotationSerializer.parseHighlightsJson(highlightsJson) - }, - pdfReaderViewport = if (useRemoteReadingPosition) remotePdfViewport(existing, pageIndex) else existing?.pdfReaderViewport, - readingPositionModifiedTimestamp = if (useRemoteReadingPosition) remoteReadingTimestamp else localReadingTimestamp - ) -} - -internal fun BookItem.withCloudReadingPosition(remote: DesktopCloudBookMetadata): BookItem { - val remoteType = remote.fileType() - val pageIndex = remote.lastPage - val locator = if (remoteType.usesCloudLocatorMetadata()) { - ReaderLocator.fromLegacy( - chapterIndex = remote.lastChapterIndex, - cfi = remote.lastPositionCfi, - pageIndex = pageIndex - ).withFallbacks( - blockIndex = remote.locatorBlockIndex, - charOffset = remote.locatorCharOffset - ).takeIf { - it.chapterIndex != null || - it.pageIndex != null || - it.cfi != null || - it.startOffset != null || - it.blockIndex != null - } - } else { - null - } - return copy( - lastPageIndex = pageIndex ?: lastPageIndex, - readerPosition = if (remoteType.usesCloudLocatorMetadata()) locator ?: readerPosition else null, - progressPercentage = remote.progressPercentage ?: progressPercentage, - pdfReaderViewport = if (remoteType.usesCloudLocatorMetadata()) { - pdfReaderViewport - } else { - remote.remotePdfViewport(this, pageIndex) - }, - readingPositionModifiedTimestamp = remote.effectiveCloudReadingPositionModifiedTimestamp() - ) -} - -private fun DesktopCloudBookMetadata.remotePdfViewport( - existing: BookItem?, - pageIndex: Int? -): SharedPdfReaderViewport? { - if (fileType().usesCloudLocatorMetadata() || pageIndex == null) return existing?.pdfReaderViewport - val base = existing?.pdfReaderViewport ?: SharedPdfReaderViewport() - return base.copy( - pageIndex = pageIndex, - horizontalScrollOffset = 0, - paginatedVerticalScrollOffset = 0, - verticalFirstPageIndex = pageIndex, - verticalFirstPageScrollOffset = 0 - ) -} - -private fun FileType.usesCloudLocatorMetadata(): Boolean { - return this != FileType.PDF && this != FileType.PPTX && !SharedFileCapabilities.isComicArchive(this) -} - -internal fun BookItem.hasCloudReadingPosition(): Boolean { - return lastPageIndex != null || - readerPosition != null || - (progressPercentage ?: 0f) > 0f -} - -internal fun BookItem.effectiveCloudReadingPositionModifiedTimestamp(): Long { - return readingPositionModifiedTimestamp.takeIf { it > 0L } - ?: timestamp.takeIf { hasCloudReadingPosition() } - ?: 0L -} - -internal fun DesktopCloudBookMetadata.hasCloudReadingPosition(): Boolean { - return lastChapterIndex != null || - lastPage != null || - !lastPositionCfi.isNullOrBlank() || - locatorBlockIndex != null || - locatorCharOffset != null || - (progressPercentage ?: 0f) > 0f -} - -internal fun DesktopCloudBookMetadata.effectiveCloudReadingPositionModifiedTimestamp(): Long { - return readingPositionModifiedTimestamp.takeIf { it > 0L } - ?: lastModifiedTimestamp.takeIf { hasCloudReadingPosition() } - ?: 0L -} - -internal fun DesktopCloudBookMetadata.effectiveCloudAnnotationModifiedTimestamp(): Long { - return annotationModifiedTimestamp.takeIf { it > 0L } - ?: 0L -} - -internal fun DesktopCloudBookMetadata.effectiveCloudAnnotationModifiedTimestamp(sidecarModifiedTimestamp: Long): Long { - return sidecarModifiedTimestamp.takeIf { it > 0L } - ?: effectiveCloudAnnotationModifiedTimestamp() -} - -internal fun CustomFontItem.toDesktopCloudFontMetadata(): DesktopCloudFontMetadata { - return DesktopCloudFontMetadata( - id = id, - displayName = displayName, - fileName = fileName, - fileExtension = fileExtension, - timestamp = timestamp, - isDeleted = isDeleted - ) -} - -internal fun desktopCloudBookDriveFileName(bookId: String, type: FileType): String? { - return sharedCloudBookContentFileName(bookId, type) -} - -private data class DesktopCloudShelfRecord( - val record: ShelfRecord, - val metadata: DesktopCloudShelfMetadata -) - -private data class ShelfSyncResult( - val records: List, - val refs: List -) - -private fun DesktopCloudBookMetadata.fileType(): FileType { - return runCatching { FileType.valueOf(type) }.getOrDefault(FileType.EPUB) -} - -private fun SharedReaderScreenState.upsertCloudBook(book: BookItem): SharedReaderScreenState { - val existing = rawLibraryBooks.any { it.id == book.id } - val nextBooks = if (existing) { - rawLibraryBooks.map { if (it.id == book.id) book else it } - } else { - listOf(book) + rawLibraryBooks - } - return copy(rawLibraryBooks = nextBooks) -} - -private fun SharedReaderScreenState.removeCloudBook(bookId: String): SharedReaderScreenState { - return copy( - rawLibraryBooks = rawLibraryBooks.filterNot { it.id == bookId }, - selectedBookIds = selectedBookIds - bookId, - pinnedHomeBookIds = pinnedHomeBookIds - bookId, - pinnedLibraryBookIds = pinnedLibraryBookIds - bookId, - openTabIds = openTabIds.filterNot { it == bookId }, - activeTabBookId = activeTabBookId?.takeUnless { it == bookId } - ) -} - -private fun shouldDownloadRemoteBookContent(local: BookItem, remote: DesktopCloudBookMetadata): Boolean { - val localFile = local.path?.let(::File) - val localTimestamp = local.fileContentModifiedTimestamp.takeIf { it > 0L } - ?: localFile?.takeIf { it.isFile }?.lastModified() - ?: 0L - return local.sourceFolder == null && - remote.fileType() == local.type && - shouldDownloadRemoteCloudBookContent( - localFileAvailable = localFile?.isFile == true, - localContentModifiedTimestamp = localTimestamp, - remoteContentModifiedTimestamp = remote.fileContentModifiedTimestamp, - remoteDeleted = remote.isDeleted - ) -} - -private fun shouldUploadLocalBookContent(local: BookItem, remote: DesktopCloudBookMetadata?): Boolean { - val localFile = local.path?.let(::File)?.takeIf { it.isFile } ?: return false - val localTimestamp = local.fileContentModifiedTimestamp.takeIf { it > 0L } ?: localFile.lastModified() - return local.sourceFolder == null && - shouldUploadLocalCloudBookContent( - localFileAvailable = true, - localContentModifiedTimestamp = localTimestamp, - remoteContentModifiedTimestamp = remote?.fileContentModifiedTimestamp - ) -} - -private fun shouldUploadLocalAnnotations( - local: BookItem, - remote: DesktopCloudBookMetadata?, - remoteAnnotationModifiedTimestamp: Long = remote?.effectiveCloudAnnotationModifiedTimestamp() ?: 0L, - localSidecarTimestamp: Long = DesktopCloudSidecarSync.localAnnotationTimestamp(local) -): Boolean { - return DesktopCloudSidecarSync.hasLocalAnnotationData(local) && - (remote == null || !remote.hasAnnotations || localSidecarTimestamp > remoteAnnotationModifiedTimestamp) -} - -private fun remoteAnnotationDriveFileTimestamp( - bookId: String, - driveFiles: Map -): Long { - return driveFiles[desktopCloudAnnotationDriveFileName(bookId)]?.modifiedTimeMillis ?: 0L -} - -internal fun desktopCloudAnnotationDriveFileName(bookId: String): String = "annotation_$bookId.json" - -private fun BookItem.withDownloadedCloudContent(downloaded: BookItem?, replacePath: Boolean = true): BookItem { - if (downloaded == null) return this - return copy( - path = if (replacePath) downloaded.path ?: path else path, - fileSize = downloaded.fileSize.takeIf { it > 0L } ?: fileSize, - fileContentModifiedTimestamp = downloaded.fileContentModifiedTimestamp.takeIf { it > 0L } - ?: fileContentModifiedTimestamp - ) -} - -private fun desktopShelfTimestamp(record: ShelfRecord, refs: List): Long { - val idTimestamp = record.id.split('_').firstNotNullOfOrNull { it.toLongOrNull() } - val refsTimestamp = refs.filter { it.shelfId == record.id }.maxOfOrNull { it.addedAt } - return maxOf(idTimestamp ?: 0L, refsTimestamp ?: 0L) -} - -private fun ReaderLocator.cloudPositionCfi(): String? { - return toStablePositionCfi() -} - -private fun ReaderBookmark.toDesktopCloudEpubBookmarkOrNull(): EpubBookmark? { - val chapterIndex = locator.chapterIndex ?: 0 - val cfi = locator.cloudPositionCfi() ?: "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 - ) - ) -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSyncDiagnostics.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSyncDiagnostics.kt deleted file mode 100644 index 4ac30f5..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSyncDiagnostics.kt +++ /dev/null @@ -1,82 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import org.dueattendant149.bookreader.shared.BookItem -import org.dueattendant149.bookreader.shared.FileType -import org.dueattendant149.bookreader.shared.SharedFileCapabilities - -internal const val DesktopCloudSyncLogTag = "EpistemeCloudSync" -internal const val DesktopCloudAnnotationSyncLogTag = "EpistemeCloudAnnotations" - -internal fun logDesktopCloudSync(message: () -> String) { - logDesktopDiagnostic(DesktopCloudSyncLogTag, message) -} - -internal fun logDesktopCloudAnnotations(message: () -> String) { - logDesktopDiagnostic(DesktopCloudAnnotationSyncLogTag, message) -} - -internal fun BookItem.desktopCloudSyncSummary(prefix: String = "local"): String { - val position = readerPosition - val page = if (type.usesCloudLocatorForDiagnostics()) { - position?.pageIndex ?: lastPageIndex - } else { - lastPageIndex - } - return "$prefix{id=$id type=$type ts=$timestamp readTs=${effectiveCloudReadingPositionModifiedTimestamp()} " + - "contentTs=$fileContentModifiedTimestamp " + - "page=$page chapter=${position?.chapterIndex} " + - "block=${position?.blockIndex} char=${position?.charOffset} progress=$progressPercentage " + - "cfi=${position?.cfi.cloudSyncPreview()} sourceFolder=${sourceFolder != null} " + - "bookmarks=${readerBookmarks.size} highlights=${readerHighlights.size}}" -} - -internal fun DesktopCloudBookMetadata.desktopCloudSyncSummary(prefix: String = "remote"): String { - return "$prefix{id=$bookId type=$type ts=$lastModifiedTimestamp readTs=${effectiveCloudReadingPositionModifiedTimestamp()} " + - "annTs=${effectiveCloudAnnotationModifiedTimestamp()} contentTs=$fileContentModifiedTimestamp " + - "page=$lastPage chapter=$lastChapterIndex block=$locatorBlockIndex char=$locatorCharOffset " + - "progress=$progressPercentage cfi=${lastPositionCfi.cloudSyncPreview()} deleted=$isDeleted " + - "recent=$isRecent hasAnnotations=$hasAnnotations bookmarks=${bookmarksJson.cloudSyncAnnotationSummary()} " + - "highlights=${highlightsJson.cloudSyncAnnotationSummary()}}" -} - -internal fun BookItem.hasSameCloudReaderPosition(other: BookItem): Boolean { - val thisPage = if (type.usesCloudLocatorForDiagnostics()) readerPosition?.pageIndex ?: lastPageIndex else lastPageIndex - val otherPage = if (other.type.usesCloudLocatorForDiagnostics()) { - other.readerPosition?.pageIndex ?: other.lastPageIndex - } else { - other.lastPageIndex - } - val thisProgress = progressPercentage - val otherProgress = other.progressPercentage - val progressMatches = when { - thisProgress == null && otherProgress == null -> true - thisProgress != null && otherProgress != null -> kotlin.math.abs(thisProgress - otherProgress) < 0.001f - else -> false - } - val locatorMatches = if (type.usesCloudLocatorForDiagnostics() || other.type.usesCloudLocatorForDiagnostics()) { - readerPosition == other.readerPosition - } else { - true - } - return thisPage == otherPage && - locatorMatches && - progressMatches -} - -private fun org.dueattendant149.bookreader.shared.FileType.usesCloudLocatorForDiagnostics(): Boolean { - return this != FileType.PDF && this != FileType.PPTX && !SharedFileCapabilities.isComicArchive(this) -} - -private fun String?.cloudSyncPreview(maxLength: Int = 80): String { - val value = this ?: return "null" - return if (value.length <= maxLength) value else value.take(maxLength) + "..." -} - -private fun String?.cloudSyncAnnotationSummary(): String { - val value = this?.trim() ?: return "null" - return when { - value.isEmpty() -> "blank" - value == "[]" -> "empty" - else -> "present(${value.length})" - } -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSyncSettingsStore.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSyncSettingsStore.kt deleted file mode 100644 index 0b2b0e9..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSyncSettingsStore.kt +++ /dev/null @@ -1,60 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import java.io.File -import java.util.Properties -import java.util.UUID - -internal data class DesktopCloudSyncSettings( - val isSyncEnabled: Boolean = false, - val isFolderSyncEnabled: Boolean = false -) - -internal class DesktopCloudSyncSettingsStore( - private val settingsFile: File = File(desktopUserConfigRoot(), "cloud-sync.properties") -) { - fun load(): DesktopCloudSyncSettings { - if (!settingsFile.isFile) return DesktopCloudSyncSettings() - val properties = Properties() - return runCatching { - settingsFile.inputStream().use(properties::load) - DesktopCloudSyncSettings( - isSyncEnabled = properties.getProperty("syncEnabled", "false").toBooleanStrictOrNull() == true, - isFolderSyncEnabled = properties.getProperty("folderSyncEnabled", "false").toBooleanStrictOrNull() == true - ) - }.getOrDefault(DesktopCloudSyncSettings()) - } - - fun save(settings: DesktopCloudSyncSettings) { - settingsFile.parentFile?.mkdirs() - val properties = Properties().apply { - setProperty("syncEnabled", settings.isSyncEnabled.toString()) - setProperty("folderSyncEnabled", settings.isFolderSyncEnabled.toString()) - } - settingsFile.outputStream().use { output -> - properties.store(output, "Episteme desktop cloud sync") - } - } -} - -internal class DesktopInstallationIdStore( - private val settingsFile: File = File(desktopUserConfigRoot(), "installation.properties") -) { - fun getOrCreateId(): String { - val properties = Properties() - val existing = runCatching { - if (!settingsFile.isFile) return@runCatching null - settingsFile.inputStream().use(properties::load) - properties.getProperty("installationId")?.takeIf { it.isNotBlank() } - }.getOrNull() - if (existing != null) return existing - - val generated = UUID.randomUUID().toString() - settingsFile.parentFile?.mkdirs() - settingsFile.outputStream().use { output -> - Properties().apply { - setProperty("installationId", generated) - }.store(output, "Episteme desktop installation") - } - return generated - } -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopComicArchive.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopComicArchive.kt deleted file mode 100644 index ab7ad33..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopComicArchive.kt +++ /dev/null @@ -1,614 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import org.dueattendant149.bookreader.shared.FileType -import org.dueattendant149.bookreader.shared.SharedFileCapabilities -import org.dueattendant149.bookreader.shared.opds.OpdsCatalog -import org.dueattendant149.bookreader.shared.opds.OpdsStreamReference -import com.sun.jna.Library -import com.sun.jna.Native -import com.sun.jna.Pointer -import com.sun.jna.ptr.PointerByReference -import org.apache.commons.compress.archivers.sevenz.SevenZFile -import org.apache.commons.compress.archivers.tar.TarArchiveInputStream -import java.awt.Color -import java.awt.Font -import java.awt.RenderingHints -import java.awt.image.BufferedImage -import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream -import java.io.File -import java.io.OutputStream -import java.net.URL -import java.nio.file.Files -import java.util.concurrent.TimeUnit -import java.util.zip.ZipFile -import javax.imageio.ImageIO -import kotlin.math.roundToInt - -internal object DesktopComicArchive { - private val comicTypes = SharedFileCapabilities.comicArchiveTypes - private val imageExtensions = setOf("jpg", "jpeg", "png", "webp", "bmp", "gif") - - fun canLoad(type: FileType): Boolean = type in comicTypes - - fun load(file: File, type: FileType): DesktopComicDocument { - require(file.isFile) { "Missing comic archive: ${file.absolutePath}" } - require(canLoad(type)) { "${type.name} is not a comic archive type." } - return when (type) { - FileType.CBZ -> loadZip(file) - FileType.CBR -> loadRar(file) - FileType.CB7 -> loadSevenZ(file) - FileType.CBT -> loadTar(file) - else -> error("${type.name} is not a comic archive type.") - } - } - - fun loadOpdsStream( - path: String, - title: String, - reference: OpdsStreamReference, - catalog: OpdsCatalog? - ): DesktopComicDocument { - val cacheDir = File( - DesktopLibraryDatabase.defaultDatabaseFile().parentFile, - "opds_stream_cache/${reference.id.hashCode()}" - ).apply { mkdirs() } - val pages = (0 until reference.count).map { pageIndex -> - DesktopComicPage( - name = "opds_${pageIndex + 1}.jpg", - width = 800, - height = 1200, - source = OpdsStreamComicPageSource( - pageIndex = pageIndex, - urlTemplate = reference.urlTemplate, - catalog = catalog, - cacheDir = cacheDir - ) - ) - } - return DesktopComicDocument( - path = path, - title = title, - pages = pages, - closeAction = {} - ) - } - - private fun loadZip(file: File): DesktopComicDocument { - val zip = ZipFile(file) - return try { - val pages = zip.entries() - .asSequence() - .filter { entry -> !entry.isDirectory && entry.name.isComicImageName() } - .sortedBy { entry -> entry.name.comicSortKey() } - .mapNotNull { entry -> - val bytes = zip.getInputStream(entry).use { it.readBytes() } - val size = decodeImageSize(bytes) ?: return@mapNotNull null - DesktopComicPage( - name = entry.name, - width = size.first, - height = size.second, - source = ZipComicPageSource(zip, entry.name) - ) - } - .toList() - require(pages.isNotEmpty()) { "No readable image pages were found in ${file.name}." } - DesktopComicDocument( - path = file.absolutePath, - title = file.nameWithoutExtension, - pages = pages, - closeAction = { zip.close() } - ) - } catch (throwable: Throwable) { - runCatching { zip.close() } - throw throwable - } - } - - private fun loadRar(file: File): DesktopComicDocument { - val nativeResult = runCatching { loadRarWithNativeLibarchive(file) } - if (nativeResult.isSuccess) return nativeResult.getOrThrow() - - return runCatching { loadWithArchiveCommand(file) } - .getOrElse { commandError -> - error( - "Could not open CBR with libarchive. " + - "Bundle libarchive for this desktop platform, or keep tar/bsdtar available on PATH. " + - "Native: ${nativeResult.exceptionOrNull()?.shortMessage().orEmpty()} " + - "Command: ${commandError.shortMessage()}" - ) - } - } - - private fun loadRarWithNativeLibarchive(file: File): DesktopComicDocument { - val tempDir = Files.createTempDirectory("reader-comic-").toFile() - return try { - val extracted = DesktopLibarchive.extractImagePages(file, tempDir, imageExtensions) - documentFromExtracted(file, extracted, tempDir) - } catch (throwable: Throwable) { - runCatching { tempDir.deleteRecursively() } - throw throwable - } - } - - private fun loadSevenZ(file: File): DesktopComicDocument { - val tempDir = Files.createTempDirectory("reader-comic-").toFile() - val commonsResult = runCatching { - val extracted = mutableListOf() - @Suppress("DEPRECATION") - SevenZFile(file).use { archive -> - var entry = archive.nextEntry - while (entry != null) { - val name = entry.name.orEmpty() - if (!entry.isDirectory && name.isComicImageName()) { - val target = File(tempDir, "page_${extracted.size}.${name.imageExtension()}") - target.outputStream().use { output -> - archive.copyCurrentEntryTo(output) - } - extracted += ExtractedComicPage(name = name, file = target) - } - entry = archive.nextEntry - } - } - documentFromExtracted(file, extracted, tempDir) - } - if (commonsResult.isSuccess) return commonsResult.getOrThrow() - - runCatching { tempDir.deleteRecursively() } - return runCatching { loadWithArchiveCommand(file) } - .getOrElse { commandError -> - error( - "Could not open CB7 with Commons Compress or system tar/bsdtar. " + - "Commons: ${commonsResult.exceptionOrNull()?.shortMessage().orEmpty()} " + - "Command: ${commandError.shortMessage()}" - ) - } - } - - private fun loadTar(file: File): DesktopComicDocument { - val tempDir = Files.createTempDirectory("reader-comic-").toFile() - return try { - val extracted = mutableListOf() - TarArchiveInputStream(file.inputStream().buffered()).use { archive -> - var entry = archive.nextEntry - while (entry != null) { - val name = entry.name.orEmpty() - if (!entry.isDirectory && name.isComicImageName()) { - val target = File(tempDir, "page_${extracted.size}.${name.imageExtension()}") - target.outputStream().use { output -> - archive.copyTo(output) - } - extracted += ExtractedComicPage(name = name, file = target) - } - entry = archive.nextEntry - } - } - documentFromExtracted(file, extracted, tempDir) - } catch (throwable: Throwable) { - runCatching { tempDir.deleteRecursively() } - throw throwable - } - } - - private fun loadWithArchiveCommand(file: File): DesktopComicDocument { - val tempDir = Files.createTempDirectory("reader-comic-").toFile() - return try { - val extracted = DesktopArchiveCommand.extractImagePages(file, tempDir, imageExtensions) - documentFromExtracted(file, extracted, tempDir) - } catch (throwable: Throwable) { - runCatching { tempDir.deleteRecursively() } - throw throwable - } - } - - private fun documentFromExtracted( - file: File, - extracted: List, - tempDir: File - ): DesktopComicDocument { - val pages = extracted - .sortedBy { page -> page.name.comicSortKey() } - .mapNotNull { page -> - val bytes = page.file.readBytes() - val size = decodeImageSize(bytes) ?: return@mapNotNull null - DesktopComicPage( - name = page.name, - width = size.first, - height = size.second, - source = FileComicPageSource(page.file) - ) - } - require(pages.isNotEmpty()) { "No readable image pages were found in ${file.name}." } - return DesktopComicDocument( - path = file.absolutePath, - title = file.nameWithoutExtension, - pages = pages, - closeAction = { tempDir.deleteRecursively() } - ) - } - - private fun SevenZFile.copyCurrentEntryTo(output: OutputStream) { - val buffer = ByteArray(DEFAULT_BUFFER_SIZE) - while (true) { - val read = read(buffer, 0, buffer.size) - if (read < 0) break - if (read > 0) output.write(buffer, 0, read) - } - } - - private fun decodeImageSize(bytes: ByteArray): Pair? { - val image = ByteArrayInputStream(bytes).use { input -> - ImageIO.read(input) - } ?: return null - return image.width.coerceAtLeast(1) to image.height.coerceAtLeast(1) - } - - internal fun String.isComicImageName(): Boolean { - return imageExtension() in imageExtensions - } - - internal fun String.imageExtension(): String { - return substringBefore('?') - .substringBefore('#') - .substringAfterLast('.', missingDelimiterValue = "img") - .lowercase() - .takeIf { it in imageExtensions } - ?: "img" - } - - internal fun String.comicSortKey(): String { - return replace('\\', '/').lowercase() - } - - internal fun Throwable.shortMessage(): String { - return message - ?.replace(Regex("\\s+"), " ") - ?.take(240) - ?.ifBlank { null } - ?: javaClass.simpleName - } - - internal data class ExtractedComicPage( - val name: String, - val file: File - ) -} - -internal class DesktopComicDocument( - val path: String, - val title: String, - pages: List, - private val closeAction: () -> Unit -) { - private val pages = pages.toList() - - val pageCount: Int = pages.size - val pageSizes: List = pages.map { page -> - DesktopPdfPageSize(page.width.toFloat(), page.height.toFloat()) - } - - fun renderPageBufferedImage(pageIndex: Int, scale: Float): BufferedImage { - val page = pages.getOrNull(pageIndex) ?: error("Invalid comic page index $pageIndex.") - val sourceImage = ByteArrayInputStream(page.source.readBytes()).use { input -> - ImageIO.read(input) - } ?: error("Could not decode comic page ${pageIndex + 1}.") - val safeScale = scale.takeIf { it.isFinite() && it > 0f } ?: 1f - val sourceWidth = sourceImage.width.coerceAtLeast(1) - val sourceHeight = sourceImage.height.coerceAtLeast(1) - val width = (sourceWidth * safeScale).roundToInt().coerceAtLeast(1) - val height = (sourceHeight * safeScale).roundToInt().coerceAtLeast(1) - val image = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB) - val graphics = image.createGraphics() - try { - graphics.color = Color.WHITE - graphics.fillRect(0, 0, width, height) - graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR) - graphics.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY) - graphics.drawImage(sourceImage, 0, 0, width, height, null) - } finally { - graphics.dispose() - sourceImage.flush() - } - return image - } - - fun close() { - closeAction() - } -} - -internal data class DesktopComicPage( - val name: String, - val width: Int, - val height: Int, - val source: ComicPageSource -) - -internal interface ComicPageSource { - fun readBytes(): ByteArray -} - -private class ZipComicPageSource( - private val zip: ZipFile, - private val entryName: String -) : ComicPageSource { - override fun readBytes(): ByteArray { - val entry = zip.getEntry(entryName) ?: error("Missing comic page entry: $entryName") - return zip.getInputStream(entry).use { it.readBytes() } - } -} - -private class FileComicPageSource( - private val file: File -) : ComicPageSource { - override fun readBytes(): ByteArray = file.readBytes() -} - -private class OpdsStreamComicPageSource( - private val pageIndex: Int, - private val urlTemplate: String, - private val catalog: OpdsCatalog?, - private val cacheDir: File -) : ComicPageSource { - override fun readBytes(): ByteArray { - val cachedFile = File(cacheDir, "page_$pageIndex.jpg") - if (cachedFile.isFile && cachedFile.length() > 0L) { - return cachedFile.readBytes() - } - - return runCatching { - val bytes = DesktopOpdsHttp.fetchBytes(streamPageUrl(), catalog) - if (bytes.isNotEmpty()) { - cachedFile.writeBytes(bytes) - bytes - } else { - error("Empty OPDS stream page response.") - } - }.getOrElse { - errorPageBytes() - } - } - - private fun streamPageUrl(): String { - return effectiveTemplate() - .replace("{pageNumber}", pageIndex.toString()) - .replace("{page}", pageIndex.toString()) - .replace("{maxWidth}", "1600") - .replace("{maxHeight}", "2400") - } - - private fun effectiveTemplate(): String { - val catalogUrl = catalog?.url ?: return urlTemplate - if (!urlTemplate.startsWith("http", ignoreCase = true)) return urlTemplate - return runCatching { - val oldUrl = URL(urlTemplate) - val newUrl = URL(catalogUrl) - val oldBase = "${oldUrl.protocol}://${oldUrl.authority}" - val newBase = "${newUrl.protocol}://${newUrl.authority}" - urlTemplate.replace(oldBase, newBase) - }.getOrDefault(urlTemplate) - } - - private fun errorPageBytes(): ByteArray { - val image = BufferedImage(800, 1200, BufferedImage.TYPE_INT_RGB) - val graphics = image.createGraphics() - try { - graphics.color = Color.DARK_GRAY - graphics.fillRect(0, 0, image.width, image.height) - graphics.color = Color.WHITE - graphics.font = Font(Font.SANS_SERIF, Font.BOLD, 36) - val text = "Page unavailable" - val metrics = graphics.fontMetrics - graphics.drawString(text, (image.width - metrics.stringWidth(text)) / 2, image.height / 2) - } finally { - graphics.dispose() - } - return ByteArrayOutputStream().use { output -> - ImageIO.write(image, "jpg", output) - output.toByteArray() - } - } -} - -private object DesktopArchiveCommand { - private const val EXTRACT_TIMEOUT_SECONDS = 60L - - fun extractImagePages( - file: File, - tempDir: File, - imageExtensions: Set - ): List { - val command = resolveCommand() - ?: error("No tar/bsdtar command was found on PATH.") - val names = listArchiveEntries(command, file) - .filter { name -> - val extension = name.substringBefore('?') - .substringBefore('#') - .substringAfterLast('.', missingDelimiterValue = "") - .lowercase() - extension in imageExtensions - } - .sortedBy { name -> with(DesktopComicArchive) { name.comicSortKey() } } - require(names.isNotEmpty()) { "No readable image pages were found in ${file.name}." } - return names.mapIndexed { index, name -> - val extension = name.substringBefore('?') - .substringBefore('#') - .substringAfterLast('.', missingDelimiterValue = "img") - .lowercase() - .ifBlank { "img" } - val target = File(tempDir, "page_$index.$extension") - extractEntry(command, file, name, target) - DesktopComicArchive.ExtractedComicPage(name = name, file = target) - } - } - - private fun resolveCommand(): String? { - val override = System.getProperty("reader.archive.command") - ?: System.getenv("READER_ARCHIVE_COMMAND") - return listOfNotNull(override?.takeIf { it.isNotBlank() }, "bsdtar", "tar") - .firstOrNull(::isCommandAvailable) - } - - private fun isCommandAvailable(command: String): Boolean { - return runCatching { - val process = ProcessBuilder(command, "--version") - .redirectErrorStream(true) - .start() - process.inputStream.use { it.readBytes() } - process.waitFor(5, TimeUnit.SECONDS) && process.exitValue() == 0 - }.getOrDefault(false) - } - - private fun listArchiveEntries(command: String, file: File): List { - val process = ProcessBuilder(command, "-tf", file.absolutePath) - .redirectErrorStream(true) - .start() - val output = process.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() } - val finished = process.waitFor(EXTRACT_TIMEOUT_SECONDS, TimeUnit.SECONDS) - if (!finished) { - process.destroyForcibly() - error("$command timed out while listing ${file.name}.") - } - if (process.exitValue() != 0) { - error(output.ifBlank { "$command could not list ${file.name}." }) - } - return output - .lineSequence() - .map { it.trim() } - .filter { it.isNotBlank() } - .toList() - } - - private fun extractEntry(command: String, file: File, entryName: String, target: File) { - val process = ProcessBuilder(command, "-xOf", file.absolutePath, entryName) - .start() - val errorText = StringBuilder() - val errorThread = Thread { - process.errorStream.bufferedReader(Charsets.UTF_8).use { reader -> - errorText.append(reader.readText()) - } - }.apply { - isDaemon = true - start() - } - process.inputStream.use { input -> - target.outputStream().use { output -> - input.copyTo(output) - } - } - val finished = process.waitFor(EXTRACT_TIMEOUT_SECONDS, TimeUnit.SECONDS) - if (!finished) { - process.destroyForcibly() - error("$command timed out while extracting $entryName.") - } - errorThread.join(1000) - if (process.exitValue() != 0) { - error(errorText.toString().ifBlank { "$command could not extract $entryName." }) - } - } -} - -private object DesktopLibarchive { - private const val ARCHIVE_OK = 0 - private const val ARCHIVE_EOF = 1 - private const val ARCHIVE_ENTRY_DIRECTORY = 0x4000 - private const val BUFFER_SIZE = 128 * 1024 - - fun extractImagePages( - file: File, - tempDir: File, - imageExtensions: Set - ): List { - val archive = api.archive_read_new() - ?: error("libarchive could not allocate a reader.") - try { - checkArchive(api.archive_read_support_filter_all(archive), archive, "enable archive filters") - checkArchive(api.archive_read_support_format_all(archive), archive, "enable archive formats") - checkArchive( - api.archive_read_open_filename(archive, file.absolutePath, BUFFER_SIZE.toLong()), - archive, - "open ${file.name}" - ) - - val pages = mutableListOf() - val entryRef = PointerByReference() - while (true) { - when (val status = api.archive_read_next_header(archive, entryRef)) { - ARCHIVE_OK -> Unit - ARCHIVE_EOF -> break - else -> checkArchive(status, archive, "read archive header") - } - - val entry = entryRef.value ?: continue - val name = api.archive_entry_pathname_utf8(entry) - ?: api.archive_entry_pathname(entry) - ?: continue - val extension = name.substringBefore('?') - .substringBefore('#') - .substringAfterLast('.', missingDelimiterValue = "") - .lowercase() - if (api.archive_entry_filetype(entry) == ARCHIVE_ENTRY_DIRECTORY || extension !in imageExtensions) { - api.archive_read_data_skip(archive) - continue - } - - val target = File(tempDir, "page_${pages.size}.${extension.ifBlank { "img" }}") - target.outputStream().use { output -> - val buffer = ByteArray(BUFFER_SIZE) - while (true) { - val read = api.archive_read_data(archive, buffer, buffer.size.toLong()) - when { - read > 0 -> output.write(buffer, 0, read.toInt()) - read == 0L -> break - else -> checkArchive(read.toInt(), archive, "extract $name") - } - } - } - pages += DesktopComicArchive.ExtractedComicPage(name = name, file = target) - } - return pages.sortedBy { page -> with(DesktopComicArchive) { page.name.comicSortKey() } } - } finally { - api.archive_read_free(archive) - } - } - - private val api: LibarchiveLibrary by lazy { - val overridePath = System.getProperty("reader.libarchive.path") - ?: System.getenv("READER_LIBARCHIVE_PATH") - val candidates = if (overridePath.isNullOrBlank()) { - listOf("archive", "libarchive", "libarchive-13", "libarchive-14") - } else { - listOf(File(overridePath).absolutePath, overridePath) - } - - candidates.firstNotNullOfOrNull { candidate -> - runCatching { Native.load(candidate, LibarchiveLibrary::class.java) } - .getOrNull() - } ?: error( - "Native libarchive was not found. Set READER_LIBARCHIVE_PATH/reader.libarchive.path " + - "or bundle libarchive for this platform." - ) - } - - private fun checkArchive(status: Int, archive: Pointer, action: String) { - if (status >= ARCHIVE_OK) return - val message = api.archive_error_string(archive).orEmpty() - error("libarchive could not $action: ${message.ifBlank { "error code $status" }}") - } - - @Suppress("FunctionName") - private interface LibarchiveLibrary : Library { - fun archive_read_new(): Pointer? - fun archive_read_support_filter_all(archive: Pointer): Int - fun archive_read_support_format_all(archive: Pointer): Int - fun archive_read_open_filename(archive: Pointer, fileName: String, blockSize: Long): Int - fun archive_read_next_header(archive: Pointer, entry: PointerByReference): Int - fun archive_read_data(archive: Pointer, buffer: ByteArray, size: Long): Long - fun archive_read_data_skip(archive: Pointer): Int - fun archive_read_free(archive: Pointer): Int - fun archive_error_string(archive: Pointer): String? - fun archive_entry_pathname_utf8(entry: Pointer): String? - fun archive_entry_pathname(entry: Pointer): String? - fun archive_entry_filetype(entry: Pointer): Int - } -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCustomFontStore.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCustomFontStore.kt deleted file mode 100644 index 54a86b9..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCustomFontStore.kt +++ /dev/null @@ -1,162 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import org.dueattendant149.bookreader.shared.CustomFontItem -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonPrimitive -import java.io.File -import java.net.HttpURLConnection -import java.net.URLEncoder -import java.net.URL -import java.util.UUID - -class DesktopCustomFontStore( - private val fontsDir: File = defaultFontsDir(), - private val googleFontsDownloadAvailable: () -> Boolean = { true } -) { - private var googleFontsCache: List? = null - - fun importFont(source: File, displayNameOverride: String? = null): Result { - if (!source.isFile) { - return Result.failure(IllegalArgumentException("Choose a font file.")) - } - val extension = source.extension.lowercase() - if (extension !in SupportedFontExtensions) { - return Result.failure(IllegalArgumentException("Unsupported font format. Use TTF, OTF, or WOFF2.")) - } - - return runCatching { - fontsDir.mkdirs() - val fontId = UUID.randomUUID().toString() - val fileName = "font_$fontId.$extension" - val destination = File(fontsDir, fileName) - source.inputStream().use { input -> - destination.outputStream().use { output -> input.copyTo(output) } - } - CustomFontItem( - id = fontId, - displayName = displayNameOverride?.takeIf { it.isNotBlank() } - ?: source.nameWithoutExtension.ifBlank { "Imported font" }, - fileName = fileName, - fileExtension = extension, - path = destination.absolutePath, - timestamp = System.currentTimeMillis() - ) - } - } - - fun deleteFont(font: CustomFontItem): Boolean { - val target = runCatching { File(font.path).canonicalFile }.getOrNull() ?: return false - val root = runCatching { fontsDir.canonicalFile }.getOrNull() ?: return false - val insideFontStore = generateSequence(target) { it.parentFile }.any { it == root } - if (!insideFontStore) return false - return !target.exists() || target.delete() - } - - fun getFontFile(fileName: String): File { - fontsDir.mkdirs() - return File(fontsDir, fileName) - } - - internal fun syncedFontItem(metadata: DesktopCloudFontMetadata): CustomFontItem { - return CustomFontItem( - id = metadata.id, - displayName = metadata.displayName, - fileName = metadata.fileName, - fileExtension = metadata.fileExtension, - path = getFontFile(metadata.fileName).absolutePath, - timestamp = metadata.timestamp, - isDeleted = metadata.isDeleted - ) - } - - fun loadGoogleFontsList(): List { - googleFontsCache?.let { return it } - val loaded = runCatching { - val stream = Thread.currentThread().contextClassLoader?.getResourceAsStream(GoogleFontsResource) - ?: DesktopCustomFontStore::class.java.classLoader?.getResourceAsStream(GoogleFontsResource) - ?: return@runCatching emptyList() - stream.bufferedReader(Charsets.UTF_8).use { reader -> - googleFontsFromJson(reader.readText()) - } - }.getOrDefault(emptyList()) - googleFontsCache = loaded - return loaded - } - - fun downloadGoogleFont(fontName: String): Result { - if (!googleFontsDownloadAvailable()) { - return Result.failure(IllegalStateException("Google Fonts download is unavailable in this desktop build.")) - } - val normalizedFontName = fontName.trim() - if (normalizedFontName.isBlank()) { - return Result.failure(IllegalArgumentException("Choose a Google Font.")) - } - - return runCatching { - val encodedName = URLEncoder.encode(normalizedFontName, Charsets.UTF_8.name()) - val cssConnection = URL("https://fonts.googleapis.com/css?family=$encodedName") - .openConnection() as HttpURLConnection - cssConnection.setRequestProperty("User-Agent", GoogleFontsSafariUserAgent) - cssConnection.connectTimeout = 15_000 - cssConnection.readTimeout = 15_000 - - if (cssConnection.responseCode != HttpURLConnection.HTTP_OK) { - throw IllegalStateException("Font '$normalizedFontName' was not found on Google Fonts.") - } - - val css = cssConnection.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() } - val fontUrl = googleFontDownloadUrlFromCss(css) - ?: throw IllegalStateException("Could not parse a download link for $normalizedFontName.") - val extension = googleFontFileExtension(fontUrl) - if (extension !in SupportedFontExtensions) { - throw IllegalStateException("Unsupported format ($extension) returned for $normalizedFontName.") - } - - val tempFile = File.createTempFile("episteme_google_font_", ".$extension") - try { - val fontConnection = URL(fontUrl).openConnection() as HttpURLConnection - fontConnection.connectTimeout = 15_000 - fontConnection.readTimeout = 30_000 - fontConnection.inputStream.use { input -> - tempFile.outputStream().use { output -> input.copyTo(output) } - } - importFont(tempFile, displayNameOverride = normalizedFontName).getOrThrow() - } finally { - tempFile.delete() - } - } - } - - companion object { - private val SupportedFontExtensions = setOf("ttf", "otf", "woff2") - private const val GoogleFontsResource = "google_fonts.json" - private const val GoogleFontsSafariUserAgent = - "Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_6_8; en-us) AppleWebKit/533.21.1 (KHTML, like Gecko) Version/5.0.5 Safari/533.21.1" - - fun defaultFontsDir(): File { - return File(desktopUserDataRoot(), "custom_fonts") - } - } -} - -internal fun googleFontDownloadUrlFromCss(css: String): String? { - return Regex("""url\((https://[^)]+)\)""") - .find(css) - ?.groupValues - ?.getOrNull(1) -} - -internal fun googleFontFileExtension(fontUrl: String): String { - return fontUrl.substringBefore('?') - .substringAfterLast('.', "ttf") - .lowercase() -} - -internal fun googleFontsFromJson(rawJson: String): List { - return Json.parseToJsonElement(rawJson) - .jsonArray - .mapNotNull { element -> - runCatching { element.jsonPrimitive.content.trim().takeIf { it.isNotBlank() } }.getOrNull() - } -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopDiagnostics.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopDiagnostics.kt deleted file mode 100644 index ded1895..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopDiagnostics.kt +++ /dev/null @@ -1,40 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -internal const val DesktopDiagnosticsProperty = "episteme.desktop.diagnostics" -private const val DesktopDiagnosticsTagsProperty = "episteme.desktop.diagnostics.tags" -private const val DesktopDiagnosticsEnv = "EPISTEME_DESKTOP_DIAGNOSTICS" -private const val DesktopDiagnosticsTagsEnv = "EPISTEME_DESKTOP_DIAGNOSTICS_TAGS" - -private val DesktopDiagnosticTags: Set = - listOfNotNull( - System.getProperty(DesktopDiagnosticsTagsProperty), - System.getenv(DesktopDiagnosticsTagsEnv) - ) - .joinToString(" ") - .split(',', ';', ' ', '\t', '\n') - .mapNotNull { rawTag -> - rawTag.trim() - .takeIf { it.isNotBlank() } - ?.lowercase() - } - .toSet() - -internal val DesktopDiagnosticsEnabled: Boolean = - desktopDiagnosticsFlag(System.getProperty(DesktopDiagnosticsProperty)) || - desktopDiagnosticsFlag(System.getenv(DesktopDiagnosticsEnv)) || - DesktopDiagnosticTags.isNotEmpty() - -internal fun desktopDiagnosticsFlag(rawValue: String?): Boolean { - return rawValue?.trim()?.equals("true", ignoreCase = true) == true -} - -private fun isDesktopDiagnosticTagEnabled(tag: String): Boolean { - if (DesktopDiagnosticTags.isEmpty()) return true - return "*" in DesktopDiagnosticTags || tag.lowercase() in DesktopDiagnosticTags -} - -internal fun logDesktopDiagnostic(tag: String, message: () -> String) { - if (DesktopDiagnosticsEnabled && isDesktopDiagnosticTagEnabled(tag)) { - println("$tag ${message()}") - } -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubBridgeParsing.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubBridgeParsing.kt deleted file mode 100644 index 9884b1e..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubBridgeParsing.kt +++ /dev/null @@ -1,373 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import org.dueattendant149.bookreader.shared.ReaderLocator -import org.dueattendant149.bookreader.shared.toStableReaderPositionCfi -import org.dueattendant149.bookreader.shared.ui.SharedNativeReaderLinkClick -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.event.KeyEvent as AwtKeyEvent -import java.net.URLDecoder - -internal data class DesktopReaderPosition( - val pageIndex: Int, - val locator: ReaderLocator? -) - -internal data class DesktopReaderHighlightClick( - val highlightId: String -) - -internal 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" -) - -internal fun SharedNativeReaderLinkClick.toDesktopEpubLinkClick(): DesktopEpubLinkClick { - return DesktopEpubLinkClick( - href = href, - chapterIndex = chapterIndex, - text = text, - source = "native" - ) -} - -internal data class DesktopEpubHandledLink( - val href: String, - val handledAtMs: Long -) - -internal enum class DesktopReaderSelectionAction { - DEFINE, - SPEAK, - SEARCH, - PALETTE -} - -internal enum class DesktopReaderKeyNavigation { - NEXT, - PREVIOUS, - FIRST, - LAST, - SEARCH, - NEXT_SEARCH, - EXIT_FULLSCREEN -} - -internal fun AwtKeyEvent.desktopReaderKeyNavigationOrNull( - fullscreen: Boolean, - rightToLeftPagination: Boolean = false -): DesktopReaderKeyNavigation? { - if (id != AwtKeyEvent.KEY_PRESSED) return null - if (fullscreen && keyCode == AwtKeyEvent.VK_ESCAPE) { - return DesktopReaderKeyNavigation.EXIT_FULLSCREEN - } - if (isControlDown && keyCode == AwtKeyEvent.VK_F) { - return DesktopReaderKeyNavigation.SEARCH - } - if (isControlDown && keyCode == AwtKeyEvent.VK_G) { - return DesktopReaderKeyNavigation.NEXT_SEARCH - } - return when (keyCode) { - AwtKeyEvent.VK_RIGHT -> if (rightToLeftPagination) { - DesktopReaderKeyNavigation.PREVIOUS - } else { - DesktopReaderKeyNavigation.NEXT - } - AwtKeyEvent.VK_LEFT -> if (rightToLeftPagination) { - DesktopReaderKeyNavigation.NEXT - } else { - DesktopReaderKeyNavigation.PREVIOUS - } - AwtKeyEvent.VK_PAGE_DOWN -> DesktopReaderKeyNavigation.NEXT - AwtKeyEvent.VK_PAGE_UP -> DesktopReaderKeyNavigation.PREVIOUS - AwtKeyEvent.VK_HOME -> DesktopReaderKeyNavigation.FIRST - AwtKeyEvent.VK_END -> DesktopReaderKeyNavigation.LAST - else -> null - } -} - -internal data class DesktopReaderSelectionActionPayload( - val action: DesktopReaderSelectionAction, - val text: String, - val locator: ReaderLocator? = null -) - -internal fun String.readerHighlightClickOrNull(): DesktopReaderHighlightClick? { - fun parse(rawJson: String): DesktopReaderHighlightClick? = runCatching { - val obj = Json.parseToJsonElement(rawJson).jsonObject - val highlightId = obj["id"] - ?.takeUnless { it is JsonNull } - ?.jsonPrimitive - ?.contentOrNull - ?.takeIf { it.isNotBlank() } - ?: obj["highlightId"] - ?.takeUnless { it is JsonNull } - ?.jsonPrimitive - ?.contentOrNull - ?.takeIf { it.isNotBlank() } - ?: return@runCatching null - DesktopReaderHighlightClick(highlightId) - }.getOrNull() - - parse(this)?.let { return it } - return runCatching { - Json.parseToJsonElement(this).jsonPrimitive.contentOrNull - }.getOrNull()?.let { parse(it) } -} - -internal 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 - "web-search", "search" -> DesktopReaderSelectionAction.SEARCH - "palette" -> DesktopReaderSelectionAction.PALETTE - else -> return@runCatching null - } - val locator = obj["locator"] - ?.takeUnless { it is JsonNull } - ?.jsonObject - ?.let { locatorObj -> - ReaderLocator( - chapterIndex = locatorObj["chapterIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, - chapterId = locatorObj["chapterId"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull, - href = locatorObj["href"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull, - pageIndex = locatorObj["pageIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, - startOffset = locatorObj["startOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, - endOffset = locatorObj["endOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, - blockIndex = locatorObj["blockIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, - charOffset = locatorObj["charOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, - textQuote = locatorObj["textQuote"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull, - cfi = locatorObj["cfi"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull?.toStableReaderPositionCfi() - ) - } - DesktopReaderSelectionActionPayload(action, text, locator) - }.getOrNull() - - parse(this)?.let { return it } - return runCatching { - Json.parseToJsonElement(this).jsonPrimitive.contentOrNull - }.getOrNull()?.let { parse(it) } -} - -internal fun String.readerSelectionDebugMessageOrNull(): String? { - fun parse(rawJson: String): String? = runCatching { - Json.parseToJsonElement(rawJson) - .jsonObject["message"] - ?.takeUnless { it is JsonNull } - ?.jsonPrimitive - ?.contentOrNull - ?.takeIf { it.isNotBlank() } - }.getOrNull() - - parse(this)?.let { return it } - return runCatching { - Json.parseToJsonElement(this).jsonPrimitive.contentOrNull - }.getOrNull()?.let { parse(it) } -} - -internal fun String.readerPaginationLogMessageOrNull(): String? { - fun parse(rawJson: String): String? = runCatching { - Json.parseToJsonElement(rawJson) - .jsonObject["message"] - ?.takeUnless { it is JsonNull } - ?.jsonPrimitive - ?.contentOrNull - ?.takeIf { it.isNotBlank() } - }.getOrNull() - - parse(this)?.let { return it } - return runCatching { - Json.parseToJsonElement(this).jsonPrimitive.contentOrNull - }.getOrNull()?.let { parse(it) } -} - -internal 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, - chapterId = obj["chapterId"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull, - href = obj["href"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull, - pageIndex = pageIndex, - startOffset = obj["startOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, - endOffset = obj["endOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, - blockIndex = obj["blockIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, - charOffset = obj["charOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, - textQuote = obj["textQuote"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull, - cfi = obj["cfi"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull?.toStableReaderPositionCfi() - ) - DesktopReaderPosition(pageIndex, locator) - }.getOrNull() - - parse(this)?.let { return it } - return runCatching { - Json.parseToJsonElement(this).jsonPrimitive.contentOrNull - }.getOrNull()?.let { parse(it) } -} - -internal fun String.readerKeyNavigationOrNull(): DesktopReaderKeyNavigation? { - fun parse(rawJson: String): DesktopReaderKeyNavigation? = runCatching { - val action = Json.parseToJsonElement(rawJson) - .jsonObject["action"] - ?.takeUnless { it is JsonNull } - ?.jsonPrimitive - ?.contentOrNull - ?: return@runCatching null - when (action) { - "next" -> DesktopReaderKeyNavigation.NEXT - "previous" -> DesktopReaderKeyNavigation.PREVIOUS - "first" -> DesktopReaderKeyNavigation.FIRST - "last" -> DesktopReaderKeyNavigation.LAST - "search" -> DesktopReaderKeyNavigation.SEARCH - "nextSearch" -> DesktopReaderKeyNavigation.NEXT_SEARCH - "exitFullscreen" -> DesktopReaderKeyNavigation.EXIT_FULLSCREEN - else -> null - } - }.getOrNull() - - parse(this)?.let { return it } - return runCatching { - Json.parseToJsonElement(this).jsonPrimitive.contentOrNull - }.getOrNull()?.let { parse(it) } -} - -internal 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) } -} - -internal 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:/", 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 -} - -internal fun ReaderLocator.toReaderLocatorJson(): String { - return buildString { - append("{") - val values = buildList { - chapterIndex?.let { add("\"chapterIndex\":$it") } - chapterId?.let { add("\"chapterId\":${it.toJsonStringLiteral()}") } - href?.let { add("\"href\":${it.toJsonStringLiteral()}") } - pageIndex?.let { add("\"pageIndex\":$it") } - startOffset?.let { add("\"startOffset\":$it") } - endOffset?.let { add("\"endOffset\":$it") } - blockIndex?.let { add("\"blockIndex\":$it") } - charOffset?.let { add("\"charOffset\":$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() -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubLoader.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubLoader.kt deleted file mode 100644 index e5e9e62..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubLoader.kt +++ /dev/null @@ -1,12 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import org.dueattendant149.bookreader.shared.FileType -import org.dueattendant149.bookreader.shared.reader.SharedEpubBook -import org.dueattendant149.bookreader.shared.reader.SharedJvmBookLoader -import java.io.File - -object DesktopEpubLoader { - fun load(file: File): SharedEpubBook { - return SharedJvmBookLoader.load(file, FileType.EPUB) - } -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubPagination.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubPagination.kt deleted file mode 100644 index 7d7b5de..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubPagination.kt +++ /dev/null @@ -1,105 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import org.dueattendant149.bookreader.shared.reader.ReaderLayoutSignature -import org.dueattendant149.bookreader.shared.reader.ReaderPage -import org.dueattendant149.bookreader.shared.reader.ReaderReadingMode -import org.dueattendant149.bookreader.shared.reader.ReaderViewportSpec -import org.dueattendant149.bookreader.shared.reader.SharedEpubBook - -internal data class DesktopEpubPaginationRequest( - val bookId: String, - val chapterSignature: Int, - val layoutSignature: ReaderLayoutSignature, - val viewport: ReaderViewportSpec, - val density: DesktopEpubPaginationDensity, - val cacheGeneration: Int -) - -internal data class DesktopEpubPaginationDensity( - val density: Float, - val fontScale: Float -) - -internal fun desktopMeasuredPaginationReady( - request: DesktopEpubPaginationRequest?, - completedRequest: DesktopEpubPaginationRequest?, - currentPages: List, - measuredPages: List -): Boolean { - return request != null && - completedRequest == request && - measuredPages.isNotEmpty() && - currentPages.samePageLayoutAs(measuredPages) -} - -internal fun desktopPaginatedLayoutReadyForDisplay( - readingMode: ReaderReadingMode, - measuredPagesApplied: Boolean -): Boolean { - return readingMode != ReaderReadingMode.PAGINATED || measuredPagesApplied -} - -internal fun desktopPagesWithMeasuredChapter( - currentPages: List, - chapterIndex: Int, - measuredChapterPages: List -): List { - if (currentPages.isEmpty() || measuredChapterPages.isEmpty()) return currentPages - val firstChapterPage = currentPages.indexOfFirst { it.chapterIndex == chapterIndex } - if (firstChapterPage < 0) return currentPages - val lastChapterPage = currentPages.indexOfLast { it.chapterIndex == chapterIndex } - val combined = currentPages.take(firstChapterPage) + - measuredChapterPages + - currentPages.drop(lastChapterPage + 1) - return combined.mapIndexed { index, page -> page.copy(pageIndex = index) } -} - -internal fun List.firstPageIndexForChapter(chapterIndex: Int): Int? { - return indexOfFirst { it.chapterIndex == chapterIndex }.takeIf { it >= 0 } -} - -internal fun SharedEpubBook.desktopPaginationContentSignature(): Int { - return chapters.fold(31 * id.hashCode() + css.hashCode()) { acc, chapter -> - 31 * acc + - chapter.id.hashCode() + - chapter.plainText.length + - chapter.plainText.hashCode() + - chapter.semanticBlocks.hashCode() + - chapter.htmlContent.length + - chapter.htmlContent.hashCode() + - chapter.baseHref.orEmpty().hashCode() - } -} - -@Composable -internal fun DesktopEpubPaginationPreparing( - active: Boolean, - modifier: Modifier = Modifier -) { - Box( - modifier = modifier, - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - CircularProgressIndicator() - Text( - if (active) "Preparing pages" else "Measuring reader layout", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubWebView.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubWebView.kt deleted file mode 100644 index 8013732..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubWebView.kt +++ /dev/null @@ -1,279 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import org.dueattendant149.bookreader.shared.EpubAnnotationSerializer -import org.dueattendant149.bookreader.shared.ReaderLocator -import org.dueattendant149.bookreader.shared.UserHighlight -import org.dueattendant149.bookreader.shared.ui.ReaderContentNavigationTarget -import kotlinx.coroutines.launch - -@Composable -internal fun DesktopEpubWebView( - html: String, - appearanceScript: String, - highlightPaletteScript: String, - navigationTarget: ReaderContentNavigationTarget, - highlights: List, - onHighlightCreated: (UserHighlight) -> Unit, - onHighlightSelected: (String) -> Unit, - isFullscreen: Boolean, - onKeyboardNavigation: (DesktopReaderKeyNavigation) -> Unit, - onSelectionAction: (DesktopReaderSelectionActionPayload) -> Unit, - onLinkClicked: (DesktopEpubLinkClick) -> Unit, - onVisiblePageChanged: (Int, ReaderLocator?) -> Unit, - onPointerActivity: () -> Unit = {}, - networkAccessEnabled: Boolean, - backgroundColor: Color, - modifier: Modifier = Modifier -) { - val backend = desktopEpubWebViewBackend() - LaunchedEffect(html, networkAccessEnabled, highlights.size, navigationTarget.readingMode, backend) { - logDesktopWebView2( - "backend_selected backend=${backend.logName} htmlChars=${html.length} htmlHash=${html.hashCode()} " + - "network=$networkAccessEnabled highlights=${highlights.size} navMode=${navigationTarget.readingMode}" - ) - logDesktopReaderOpenTrace { - "event=desktop_webview_selected backend=${backend.logName} htmlChars=${html.length} " + - "htmlHash=${html.hashCode()} network=$networkAccessEnabled highlights=${highlights.size} " + - "navMode=${navigationTarget.readingMode}" - } - } - DesktopNativeSwtEpubWebView( - html = html, - appearanceScript = appearanceScript, - highlightPaletteScript = highlightPaletteScript, - navigationTarget = navigationTarget, - highlights = highlights, - onHighlightCreated = onHighlightCreated, - onHighlightSelected = onHighlightSelected, - isFullscreen = isFullscreen, - onKeyboardNavigation = onKeyboardNavigation, - onSelectionAction = onSelectionAction, - onLinkClicked = onLinkClicked, - onVisiblePageChanged = onVisiblePageChanged, - onPointerActivity = onPointerActivity, - networkAccessEnabled = networkAccessEnabled, - backgroundColor = backgroundColor, - modifier = modifier - ) -} - -internal data class DesktopEpubBridgeHandler( - val methodName: String, - val onMessage: (String) -> Unit -) - -@Composable -internal fun rememberDesktopEpubBridgeHandlers( - onHighlightCreated: (UserHighlight) -> Unit, - onHighlightSelected: (String) -> Unit, - onKeyboardNavigation: (DesktopReaderKeyNavigation) -> Unit, - onSelectionAction: (DesktopReaderSelectionActionPayload) -> Unit, - onLinkClicked: (DesktopEpubLinkClick) -> Unit, - onVisiblePageChanged: (Int, ReaderLocator?) -> Unit, - onPointerActivity: () -> Unit -): List { - val latestOnHighlightCreated by rememberUpdatedState(onHighlightCreated) - val latestOnHighlightSelected by rememberUpdatedState(onHighlightSelected) - val latestOnKeyboardNavigation by rememberUpdatedState(onKeyboardNavigation) - val latestOnSelectionAction by rememberUpdatedState(onSelectionAction) - val latestOnLinkClicked by rememberUpdatedState(onLinkClicked) - val latestOnVisiblePageChanged by rememberUpdatedState(onVisiblePageChanged) - val latestOnPointerActivity by rememberUpdatedState(onPointerActivity) - val scope = rememberCoroutineScope() - return remember(scope) { - listOf( - DesktopEpubBridgeHandler("readerHighlightCreated") { params -> - logEpubHighlightFlow("bridge_received method=readerHighlightCreated params=\"${params.logPreview(900)}\"") - val highlight = EpubAnnotationSerializer.parseHighlightJsonLenient(params) - if (highlight == null) { - logEpubHighlightFlow("bridge_parse_failed method=readerHighlightCreated") - logEpubSelectionDebug("highlight_parse_failed params=${params.logPreview(900)}") - } else { - logEpubHighlightFlow( - "bridge_parse_success id=${highlight.id} color=${highlight.color.id} " + - "chapter=${highlight.chapterIndex} offsets=${highlight.locator.startOffset}..${highlight.locator.endOffset} " + - "page=${highlight.locator.pageIndex} textChars=${highlight.text.length} cfi=\"${highlight.cfi.logPreview()}\"" - ) - logDesktopHighlightMap( - "bridge_highlight_created id=${highlight.id} color=${highlight.color.id} " + - "chapter=${highlight.chapterIndex} locatorChapter=${highlight.locator.chapterIndex} " + - "page=${highlight.locator.pageIndex} offsets=${highlight.locator.startOffset}..${highlight.locator.endOffset} " + - "block=${highlight.locator.blockIndex} char=${highlight.locator.charOffset} " + - "chapterId=${highlight.locator.chapterId.orEmpty().logPreview()} href=${highlight.locator.href.orEmpty().logPreview()} " + - "textChars=${highlight.text.length} cfi=\"${highlight.cfi.logPreview()}\"" - ) - scope.launch { latestOnHighlightCreated(highlight) } - } - }, - DesktopEpubBridgeHandler("readerHighlightClicked") { params -> - params.readerHighlightClickOrNull()?.let { highlightClick -> - scope.launch { latestOnHighlightSelected(highlightClick.highlightId) } - } - }, - DesktopEpubBridgeHandler("readerPositionChanged") { params -> - params.readerPositionOrNull()?.let { position -> - logDesktopPositionTrace( - "event=bridge_position_changed page=${position.pageIndex} " + - "locator=${position.locator.desktopPositionTraceSummary()}" - ) - logDesktopHighlightMap( - "bridge_position_changed page=${position.pageIndex} chapter=${position.locator?.chapterIndex} " + - "offsets=${position.locator?.startOffset}..${position.locator?.endOffset} " + - "block=${position.locator?.blockIndex} char=${position.locator?.charOffset} " + - "chapterId=${position.locator?.chapterId.orEmpty().logPreview()} href=${position.locator?.href.orEmpty().logPreview()} " + - "text=\"${position.locator?.textQuote.orEmpty().logPreview(120)}\" " + - "cfi=\"${position.locator?.cfi.orEmpty().logPreview(160)}\"" - ) - logDesktopTtsStartTrace { - "event=bridge_position_changed page=${position.pageIndex} " + - "locator=${position.locator.desktopPositionTraceSummary(160)}" - } - scope.launch { latestOnVisiblePageChanged(position.pageIndex, position.locator) } - } - }, - DesktopEpubBridgeHandler("readerDesktopPositionTraceLog") { params -> - logDesktopPositionTrace(params.readerSelectionDebugMessageOrNull() ?: params.logPreview(900)) - }, - DesktopEpubBridgeHandler("readerTtsStartTraceLog") { params -> - logDesktopTtsStartTrace { params.readerSelectionDebugMessageOrNull() ?: params.logPreview(900) } - }, - DesktopEpubBridgeHandler("readerSelectionAction") { params -> - val selectionAction = params.readerSelectionActionOrNull() - if (selectionAction != null) { - scope.launch { latestOnSelectionAction(selectionAction) } - } - }, - DesktopEpubBridgeHandler("readerKeyNavigation") { params -> - params.readerKeyNavigationOrNull()?.let { action -> - scope.launch { latestOnKeyboardNavigation(action) } - } - }, - DesktopEpubBridgeHandler("readerPointerActivity") { - scope.launch { latestOnPointerActivity() } - }, - DesktopEpubBridgeHandler("readerTtsHighlightLog") { params -> - logDesktopTts("epub_highlight_js ${params.logPreview(500)}") - }, - DesktopEpubBridgeHandler("readerSelectionDebugLog") { params -> - logEpubSelectionDebug(params.readerSelectionDebugMessageOrNull() ?: params.logPreview(900)) - }, - DesktopEpubBridgeHandler("readerHighlightFlowLog") { params -> - logEpubHighlightFlow(params.readerSelectionDebugMessageOrNull() ?: params.logPreview(900)) - }, - DesktopEpubBridgeHandler("readerDesktopHighlightMapLog") { params -> - logDesktopHighlightMap(params.readerSelectionDebugMessageOrNull() ?: params.logPreview(900)) - }, - DesktopEpubBridgeHandler("readerPaginationLayoutLog") { params -> - logEpubPagination(params.readerPaginationLogMessageOrNull() ?: params.logPreview(900)) - }, - DesktopEpubBridgeHandler("readerGapLayoutLog") { params -> - logReaderGap(params.readerPaginationLogMessageOrNull() ?: params.logPreview(900)) - }, - DesktopEpubBridgeHandler("readerLinkClicked") { params -> - logEpubLink("bridge_message params=\"${params.logPreview()}\"") - val link = 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) } - } - } - ) - } -} - -internal val DesktopEpubKeyNavigationScript = """ - (function () { - if (!window.readerDesktopChromeTapInstalled) { - window.readerDesktopChromeTapInstalled = true; - var chromeTapStart = null; - var lastChromeTapNotifiedAt = 0; - function notifyChromeTap() { - if (!window.kmpJsBridge || !window.kmpJsBridge.callNative) return; - window.kmpJsBridge.callNative('readerPointerActivity', '{}'); - lastChromeTapNotifiedAt = Date.now(); - } - function chromeTapIgnored(target) { - if (!target || !target.closest) return false; - return !!target.closest( - 'a[href], button, input, textarea, select, [contenteditable="true"], #reader-selection-menu, .reader-selection-handle' - ); - } - function hasActiveReaderSelection() { - var selection = window.getSelection && window.getSelection(); - return !!selection && selection.toString().trim().length > 0; - } - function beginChromeTap(event) { - if (event.button !== undefined && event.button !== 0) return; - if (chromeTapIgnored(event.target)) { - chromeTapStart = null; - return; - } - chromeTapStart = { - pointerId: event.pointerId, - x: event.clientX || 0, - y: event.clientY || 0, - at: Date.now() - }; - } - function finishChromeTap(event) { - if (!chromeTapStart) return; - if (event.pointerId !== undefined && chromeTapStart.pointerId !== undefined && event.pointerId !== chromeTapStart.pointerId) return; - var dx = (event.clientX || 0) - chromeTapStart.x; - var dy = (event.clientY || 0) - chromeTapStart.y; - var elapsed = Date.now() - chromeTapStart.at; - chromeTapStart = null; - if ((dx * dx + dy * dy) > 64 || elapsed > 650) return; - if (chromeTapIgnored(event.target) || hasActiveReaderSelection()) return; - notifyChromeTap(); - } - function maybeNotifyChromeTapFromClick(event) { - if (Date.now() - lastChromeTapNotifiedAt < 250) return; - if (chromeTapIgnored(event.target) || hasActiveReaderSelection()) return; - notifyChromeTap(); - } - document.addEventListener('pointerdown', beginChromeTap, true); - document.addEventListener('pointerup', finishChromeTap, true); - document.addEventListener('pointercancel', function () { chromeTapStart = null; }, true); - document.addEventListener('click', function (event) { - if (window.PointerEvent) { - maybeNotifyChromeTapFromClick(event); - return; - } - beginChromeTap(event); - finishChromeTap(event); - }, true); - } - if (window.readerDesktopKeyNavigationInstalled) return; - window.readerDesktopKeyNavigationInstalled = true; - document.addEventListener('keydown', function (event) { - var target = event.target; - var tag = target && target.tagName ? target.tagName.toLowerCase() : ''; - if (target && (target.isContentEditable || tag === 'input' || tag === 'textarea' || tag === 'select')) return; - var action = null; - if (event.ctrlKey && (event.key === 'f' || event.key === 'F')) action = 'search'; - else if (event.ctrlKey && (event.key === 'g' || event.key === 'G')) action = 'nextSearch'; - else if (event.key === 'ArrowRight' || event.key === 'PageDown') action = 'next'; - else if (event.key === 'ArrowLeft' || event.key === 'PageUp') action = 'previous'; - else if (event.key === 'Home') action = 'first'; - else if (event.key === 'End') action = 'last'; - else if (event.key === 'Escape' && window.readerDesktopFullscreen) action = 'exitFullscreen'; - if (!action || !window.kmpJsBridge || !window.kmpJsBridge.callNative) return; - event.preventDefault(); - event.stopPropagation(); - window.kmpJsBridge.callNative('readerKeyNavigation', JSON.stringify({ action: action })); - }, true); - })(); -""".trimIndent() diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopExternalLinks.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopExternalLinks.kt deleted file mode 100644 index 2b8c4a9..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopExternalLinks.kt +++ /dev/null @@ -1,159 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.layout.Arrangement -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.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.unit.dp -import org.dueattendant149.bookreader.shared.ui.readerString -import java.awt.Desktop -import java.net.URI -import java.net.URLEncoder - -internal const val EpistemeSourceUrl = "https://github.com/Aryan-Raj3112/episteme" -internal const val EpistemeIssuesUrl = "https://github.com/Aryan-Raj3112/episteme/issues" -internal const val EpistemeGitHubSponsorsUrl = "https://github.com/sponsors/Aryan-Raj3112" -internal const val EpistemePatreonUrl = "https://www.patreon.com/c/epistemereader" -internal const val EpistemeSupportEmail = "epistemereader@gmail.com" - -private const val ExternalLinkLogTag = "EpistemeExternalLink" - -internal fun desktopFeedbackSubject(profile: DesktopBuildProfile): String { - return "Feedback: ${profile.appName}" -} - -internal fun desktopAppVersionName(): String { - val version = System.getProperty(DesktopVersionProperty) - ?.takeIf { it.isNotBlank() } - ?: EpistemeDesktopAppVersion::class.java.getPackage()?.implementationVersion - ?.takeIf { it.isNotBlank() } - return version?.let { "Version $it" } ?: "Version unavailable" -} - -private object EpistemeDesktopAppVersion - -@Composable -internal fun DesktopExternalLinkDialog( - url: String?, - onDismiss: () -> Unit -) { - if (url == null) return - val clipboardManager = LocalClipboardManager.current - LaunchedEffect(url) { - logExternalLink("dialog_show url=\"${url.logPreview()}\"") - } - fun dismiss() { - logExternalLink("dialog_dismiss url=\"${url.logPreview()}\"") - onDismiss() - } - DesktopReaderBottomSheet( - title = readerString("dialog_external_link_title", "External link"), - onDismiss = ::dismiss - ) { - Text( - readerString("desktop_external_link_desc", "You clicked an external link."), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface - ) - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(10.dp), - color = MaterialTheme.colorScheme.surfaceVariant, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) - ) { - Text( - url, - modifier = Modifier.padding(12.dp), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically - ) { - TextButton(onClick = ::dismiss) { - Text(readerString("action_cancel", "Cancel")) - } - TextButton( - onClick = { - logExternalLink("dialog_copy url=\"${url.logPreview()}\"") - clipboardManager.setText(AnnotatedString(url)) - onDismiss() - } - ) { - Text(readerString("action_copy", "Copy")) - } - TextButton( - onClick = { - logExternalLink("dialog_open url=\"${url.logPreview()}\"") - openExternalUrl(url) - onDismiss() - } - ) { - Text(readerString("action_open", "Open")) - } - } - } -} - -internal fun openExternalUrl(url: String) { - if (!currentDesktopBuildProfile().featurePolicy.projectLinks) { - logExternalLink("open_blocked_offline url=\"${url.logPreview()}\"") - return - } - 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()}\"") - } -} - -internal fun String.normalizedExternalUrl(): String { - val trimmed = trim() - return if (trimmed.startsWith("www.", ignoreCase = true)) { - "https://$trimmed" - } else { - trimmed - } -} - -internal fun String.isRemoteNetworkUrl(): Boolean { - val trimmed = trim() - return trimmed.startsWith("http://", ignoreCase = true) || - trimmed.startsWith("https://", ignoreCase = true) || - trimmed.startsWith("ws://", ignoreCase = true) || - trimmed.startsWith("wss://", ignoreCase = true) -} - -internal fun String.urlEncode(): String { - return URLEncoder.encode(this, Charsets.UTF_8.name()) -} - -private fun logExternalLink(message: String) { - logDesktopDiagnostic(ExternalLinkLogTag) { message } -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFeatureNoticePlacement.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFeatureNoticePlacement.kt deleted file mode 100644 index a106401..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFeatureNoticePlacement.kt +++ /dev/null @@ -1,13 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -internal data class DesktopFeatureNoticePlacement( - val readerWindowId: String? = null -) { - fun rendersInMainWindow(): Boolean = readerWindowId == null - - fun rendersInReaderWindow(windowId: String): Boolean = readerWindowId == windowId -} - -internal fun desktopFeatureNoticePlacement(readerWindowId: String?): DesktopFeatureNoticePlacement { - return DesktopFeatureNoticePlacement(readerWindowId = readerWindowId?.takeIf { it.isNotBlank() }) -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFileDialogs.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFileDialogs.kt deleted file mode 100644 index ebacf70..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFileDialogs.kt +++ /dev/null @@ -1,119 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import org.dueattendant149.bookreader.shared.FileType -import org.dueattendant149.bookreader.shared.ImportedBookFile -import org.dueattendant149.bookreader.shared.ReaderPlatform -import org.dueattendant149.bookreader.shared.SharedFileCapabilities -import java.awt.FileDialog -import java.awt.Frame -import java.io.File -import javax.swing.JFileChooser - -internal val DesktopReadableFileTypes = SharedFileCapabilities.readableTypesFor(ReaderPlatform.DESKTOP) -internal val DesktopSyncableFileTypes = SharedFileCapabilities.syncableTypesFor(ReaderPlatform.DESKTOP) -internal val DesktopBookFileTypes = DesktopReadableFileTypes -private val DesktopBookFileDialogPattern = SharedFileCapabilities.all - .filter { it.type in DesktopBookFileTypes } - .flatMap { capability -> capability.extensions.map { extension -> "*.$extension" } } - .joinToString(";") - -internal fun desktopBookFileTypesForDialog(): Set = DesktopBookFileTypes - -internal fun chooseFiles(): List { - val dialog = FileDialog(null as Frame?, desktopDialogString("desktop_import_books", "Import books"), FileDialog.LOAD).apply { - isMultipleMode = true - isVisible = true - } - return dialog.files.orEmpty().map { it.toDesktopImportedBookFile() } -} - -internal fun chooseBookFile(): File? { - val dialog = FileDialog(null as Frame?, desktopDialogString("desktop_open_book", "Open Book"), FileDialog.LOAD).apply { - file = DesktopBookFileDialogPattern - isVisible = true - } - val directory = dialog.directory ?: return null - val file = dialog.file ?: return null - return File(directory, file) -} - -internal fun choosePdfFile(): File? { - val dialog = FileDialog(null as Frame?, desktopDialogString("desktop_open_pdf", "Open PDF"), FileDialog.LOAD).apply { - file = "*.pdf" - isVisible = true - } - val directory = dialog.directory ?: return null - val file = dialog.file ?: return null - return File(directory, file) -} - -internal fun chooseFontFile(): File? { - val dialog = FileDialog(null as Frame?, desktopDialogString("desktop_choose_font", "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) -} - -internal fun chooseReaderTextureFile(): File? { - val dialog = FileDialog(null as Frame?, desktopDialogString("desktop_choose_reader_texture", "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) -} - -internal fun chooseSaveImageFile(defaultFileName: String): File? { - val dialog = FileDialog(null as Frame?, desktopDialogString("desktop_save_image", "Save image"), FileDialog.SAVE).apply { - file = defaultFileName - isVisible = true - } - val directory = dialog.directory ?: return null - val file = dialog.file ?: return null - return File(directory, file) -} - -internal fun chooseSaveBookFile(defaultFileName: String): File? { - val dialog = FileDialog(null as Frame?, desktopDialogString("action_save_copy_to_device", "Save copy to device"), FileDialog.SAVE).apply { - file = defaultFileName - isVisible = true - } - val directory = dialog.directory ?: return null - val file = dialog.file ?: return null - return File(directory, file) -} - -internal fun chooseFolder(): File? { - val chooser = JFileChooser().apply { - dialogTitle = desktopDialogString("desktop_import_folder", "Import folder") - fileSelectionMode = JFileChooser.DIRECTORIES_ONLY - isAcceptAllFileFilterUsed = false - } - return if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) { - chooser.selectedFile - } else { - null - } -} - -private fun desktopDialogString(name: String, fallback: String): String { - return loadDesktopStringResolver().string(name, fallback) -} - -internal fun ImportedBookFile.desktopFileType(): FileType { - return SharedFileCapabilities.fileTypeForName(name) -} - -internal fun File.toDesktopImportedBookFile(sourceFolder: String? = null): ImportedBookFile { - return ImportedBookFile( - name = name, - uriString = null, - localPath = absolutePath, - size = length(), - sourceFolder = sourceFolder - ) -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFileDropTarget.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFileDropTarget.kt deleted file mode 100644 index 240ef38..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFileDropTarget.kt +++ /dev/null @@ -1,242 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -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.fillMaxSize -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.DisposableEffect -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.zIndex -import org.dueattendant149.bookreader.shared.ImportedBookFile -import org.dueattendant149.bookreader.shared.ReaderPlatform -import org.dueattendant149.bookreader.shared.SharedFileCapabilities -import org.dueattendant149.bookreader.shared.ui.readerQuantityString -import org.dueattendant149.bookreader.shared.ui.readerString -import java.awt.Component -import java.awt.Container -import java.awt.EventQueue -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.DropTargetDropEvent -import java.awt.dnd.DropTargetEvent -import java.io.File - -internal data class DesktopDropImportState( - val active: Boolean = false, - val supportedCount: Int = 0, - val totalFileCount: Int = 0, - val hasFilePayload: Boolean = false -) - -@Composable -internal fun DesktopFileDropTarget( - window: Component?, - onFilesDropped: (List) -> Unit, - onDragStateChange: (DesktopDropImportState) -> Unit -) { - val onFilesDroppedState = rememberUpdatedState(onFilesDropped) - val onDragStateChangeState = rememberUpdatedState(onDragStateChange) - - 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.toDesktopImportedBookFile() }) - 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() - } - } - } - window.installDropTargets(listener, installedTargets) - EventQueue.invokeLater { - if (!disposed) { - window.installDropTargets(listener, installedTargets) - } - } - - onDispose { - disposed = true - installedTargets.forEach { installed -> - runCatching { installed.dropTarget.removeDropTargetListener(listener) } - installed.component.dropTarget = installed.previous - } - publishDragState(DesktopDropImportState()) - } - } - } -} - -private data class InstalledDropTarget( - val component: Component, - val previous: DropTarget?, - val dropTarget: DropTarget -) - -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 -internal fun DesktopDropImportOverlay(state: DesktopDropImportState) { - if (!state.active) return - - val hasSupportedFiles = state.supportedCount > 0 - val title = when { - hasSupportedFiles -> readerQuantityString( - "desktop_drop_import_file_count", - state.supportedCount, - "Drop to import %1\$d file", - "Drop to import %1\$d files", - state.supportedCount - ) - state.hasFilePayload -> readerString("desktop_drop_supported_files_to_import", "Drop supported files to import") - else -> readerString("desktop_drop_files_to_import", "Drop files to import") - } - val body = if (hasSupportedFiles) { - val skipped = state.totalFileCount - state.supportedCount - if (skipped > 0) { - readerQuantityString( - "desktop_unsupported_import_file_count", - skipped, - "%1\$d unsupported file will be skipped.", - "%1\$d unsupported files will be skipped.", - skipped - ) - } else { - readerString("desktop_release_add_library", "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(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()) -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFirebaseAuthRepository.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFirebaseAuthRepository.kt deleted file mode 100644 index 89f21c4..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFirebaseAuthRepository.kt +++ /dev/null @@ -1,503 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import org.dueattendant149.bookreader.shared.UserData -import com.sun.net.httpserver.HttpServer -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import java.io.File -import java.net.HttpURLConnection -import java.net.InetAddress -import java.net.InetSocketAddress -import java.net.URL -import java.net.URLEncoder -import java.security.MessageDigest -import java.security.SecureRandom -import java.util.Base64 -import java.util.Properties -import java.util.UUID -import java.util.concurrent.CompletableFuture -import java.util.concurrent.Executors -import java.util.concurrent.TimeUnit - -internal data class DesktopAuthSession( - val user: UserData, - val idToken: String, - val refreshToken: String, - val expiresAtEpochMillis: Long, - val googleAccessToken: String = "", - val googleRefreshToken: String = "", - val googleAccessTokenExpiresAtEpochMillis: Long = 0L -) { - val isFresh: Boolean get() = idToken.isNotBlank() && expiresAtEpochMillis - System.currentTimeMillis() > 60_000L - val isGoogleAccessTokenFresh: Boolean - get() = googleAccessToken.isNotBlank() && - googleAccessTokenExpiresAtEpochMillis - System.currentTimeMillis() > 60_000L -} - -internal class DesktopFirebaseAuthRepository( - private val config: DesktopCloudConfig, - private val store: DesktopAuthStore = DesktopAuthStore() -) { - private var session: DesktopAuthSession? = store.load() - - fun currentSession(): DesktopAuthSession? = session - - suspend fun restoreSavedSession(): DesktopAuthSession? { - val restored = session ?: store.load()?.also { session = it } - return restored?.let { refreshSessionIfNeeded(it) } - } - - suspend fun signIn(openUrl: (String) -> Unit): DesktopAuthSession { - if (!config.isAuthConfigured) { - throw IllegalStateException("Desktop Google sign-in is not configured.") - } - val oauthCode = requestGoogleOAuthCode(openUrl) - val googleTokens = exchangeCodeForGoogleTokens(oauthCode.code, oauthCode.redirectUri, oauthCode.codeVerifier) - val existingGoogleRefreshToken = session?.googleRefreshToken.orEmpty() - val nextSession = signInWithFirebase(googleTokens.idToken).copy( - googleAccessToken = googleTokens.accessToken, - googleRefreshToken = googleTokens.refreshToken.ifBlank { existingGoogleRefreshToken }, - googleAccessTokenExpiresAtEpochMillis = googleTokens.expiresAtEpochMillis - ) - session = nextSession - persistSession(nextSession) - return nextSession - } - - fun signOut() { - session = null - store.clear() - } - - suspend fun freshIdToken(): String? { - val current = session ?: store.load()?.also { session = it } ?: return null - return refreshSessionIfNeeded(current)?.idToken - } - - suspend fun freshGoogleAccessToken(): String? { - val current = session ?: store.load()?.also { session = it } ?: return null - if (current.isGoogleAccessTokenFresh) return current.googleAccessToken - if (current.googleRefreshToken.isBlank()) return null - val refreshed = runCatching { - refreshGoogleAccessToken(current) - }.getOrNull() ?: return null - session = refreshed - persistSession(refreshed) - return refreshed.googleAccessToken - } - - private suspend fun refreshSessionIfNeeded(current: DesktopAuthSession): DesktopAuthSession? { - if (current.isFresh) return current - val refreshed = runCatching { - refreshFirebaseSession(current) - }.onFailure { - signOut() - }.getOrNull() ?: return null - session = refreshed - persistSession(refreshed) - return refreshed - } - - private suspend fun persistSession(session: DesktopAuthSession) { - withContext(Dispatchers.IO) { - store.save(session) - } - } - - private suspend fun requestGoogleOAuthCode(openUrl: (String) -> Unit): DesktopOAuthCode = withContext(Dispatchers.IO) { - val codeVerifier = randomUrlToken(64) - val state = UUID.randomUUID().toString() - val server = HttpServer.create(InetSocketAddress(InetAddress.getByName("127.0.0.1"), 0), 0) - val callback = CompletableFuture>() - val redirectUri = "http://127.0.0.1:${server.address.port}/callback" - server.executor = Executors.newSingleThreadExecutor() - server.createContext("/callback") { exchange -> - val params = exchange.requestURI.rawQuery.orEmpty().split("&") - .mapNotNull { part -> - val key = part.substringBefore("=", "") - val value = part.substringAfter("=", "") - key.takeIf { it.isNotBlank() }?.let { it to java.net.URLDecoder.decode(value, Charsets.UTF_8.name()) } - } - .toMap() - val (title, message, result) = if (params["state"] != state) { - Triple( - "Google sign-in failed", - "Google sign-in could not be completed. Return to Episteme and try again.", - Result.failure(IllegalStateException("Google sign-in returned an invalid state.")) - ) - } else if (params["error"].isNullOrBlank().not()) { - Triple( - "Google sign-in failed", - "Google sign-in was cancelled or failed. Return to Episteme and try again.", - Result.failure(IllegalStateException(params["error"] ?: "Google sign-in failed.")) - ) - } else { - val code = params["code"].orEmpty() - if (code.isBlank()) { - Triple( - "Google sign-in failed", - "Google sign-in could not be completed. Return to Episteme and try again.", - Result.failure(IllegalStateException("Google sign-in did not return an authorization code.")) - ) - } else { - Triple( - "Google sign-in complete", - "Return to Episteme to continue.", - Result.success(code) - ) - } - } - val body = googleOAuthCallbackPage(title, message).toByteArray(Charsets.UTF_8) - try { - exchange.responseHeaders.add("Content-Type", "text/html; charset=UTF-8") - exchange.sendResponseHeaders(200, body.size.toLong()) - exchange.responseBody.use { it.write(body) } - } finally { - callback.complete(result) - } - } - server.start() - try { - val authUrl = buildGoogleAuthUrl( - redirectUri = redirectUri, - codeVerifier = codeVerifier, - state = state - ) - openUrl(authUrl) - val code = runCatching { callback.get(120, TimeUnit.SECONDS) } - .getOrElse { throw IllegalStateException("Google sign-in timed out.") } - .getOrThrow() - DesktopOAuthCode(code = code, redirectUri = redirectUri, codeVerifier = codeVerifier) - } finally { - server.stop(0) - (server.executor as? java.util.concurrent.ExecutorService)?.shutdownNow() - } - } - - private fun buildGoogleAuthUrl( - redirectUri: String, - codeVerifier: String, - state: String - ): String { - val codeChallenge = Base64.getUrlEncoder().withoutPadding() - .encodeToString(MessageDigest.getInstance("SHA-256").digest(codeVerifier.toByteArray(Charsets.US_ASCII))) - return "https://accounts.google.com/o/oauth2/v2/auth?" + formEncode( - "client_id" to config.googleOAuthClientId, - "redirect_uri" to redirectUri, - "response_type" to "code", - "scope" to DesktopGoogleOAuthScopes, - "code_challenge" to codeChallenge, - "code_challenge_method" to "S256", - "state" to state, - "access_type" to "offline", - "prompt" to "consent select_account" - ) - } - - private suspend fun exchangeCodeForGoogleTokens( - code: String, - redirectUri: String, - codeVerifier: String - ): DesktopGoogleTokens = withContext(Dispatchers.IO) { - val tokenRequest = listOfNotNull( - "client_id" to config.googleOAuthClientId, - config.googleOAuthClientSecret.takeIf { it.isNotBlank() }?.let { "client_secret" to it }, - "code" to code, - "code_verifier" to codeVerifier, - "grant_type" to "authorization_code", - "redirect_uri" to redirectUri - ) - val response = postForm( - url = "https://oauth2.googleapis.com/token", - body = formEncode(tokenRequest) - ) - val parsed = DesktopAuthJson.parseToJsonElement(response).jsonObject - val idToken = parsed.string("id_token") - ?: throw IllegalStateException(parsed.string("error_description") ?: "Google sign-in did not return an ID token.") - val accessToken = parsed.string("access_token") - ?: throw IllegalStateException(parsed.string("error_description") ?: "Google sign-in did not return a Drive access token.") - DesktopGoogleTokens( - idToken = idToken, - accessToken = accessToken, - refreshToken = parsed.string("refresh_token").orEmpty(), - expiresAtEpochMillis = System.currentTimeMillis() + ((parsed.string("expires_in")?.toLongOrNull() ?: 3600L) * 1000L) - ) - } - - private suspend fun signInWithFirebase(googleIdToken: String): DesktopAuthSession = withContext(Dispatchers.IO) { - val payload = buildJsonObject { - put("postBody", JsonPrimitive("id_token=${urlEncode(googleIdToken)}&providerId=google.com")) - put("requestUri", JsonPrimitive("http://localhost")) - put("returnIdpCredential", JsonPrimitive(true)) - put("returnSecureToken", JsonPrimitive(true)) - }.toString() - val parsed = postJson( - url = "https://identitytoolkit.googleapis.com/v1/accounts:signInWithIdp?key=${urlEncode(config.firebaseWebApiKey)}", - body = payload - ).let { DesktopAuthJson.parseToJsonElement(it).jsonObject } - - val idToken = parsed.string("idToken") - ?: throw IllegalStateException(parsed.errorMessage() ?: "Firebase sign-in failed.") - val refreshToken = parsed.string("refreshToken") - ?: throw IllegalStateException("Firebase sign-in did not return a refresh token.") - val expiresAt = System.currentTimeMillis() + ((parsed.string("expiresIn")?.toLongOrNull() ?: 3600L) * 1000L) - val user = UserData( - uid = parsed.string("localId").orEmpty(), - displayName = parsed.string("displayName"), - photoUrl = parsed.string("photoUrl"), - email = parsed.string("email") - ) - DesktopAuthSession(user = user, idToken = idToken, refreshToken = refreshToken, expiresAtEpochMillis = expiresAt) - } - - private suspend fun refreshFirebaseSession(current: DesktopAuthSession): DesktopAuthSession = withContext(Dispatchers.IO) { - val parsed = postForm( - url = "https://securetoken.googleapis.com/v1/token?key=${urlEncode(config.firebaseWebApiKey)}", - body = formEncode( - "grant_type" to "refresh_token", - "refresh_token" to current.refreshToken - ) - ).let { DesktopAuthJson.parseToJsonElement(it).jsonObject } - val idToken = parsed.string("id_token") - ?: throw IllegalStateException(parsed.errorMessage() ?: "Could not refresh Google account session.") - val refreshToken = parsed.string("refresh_token") ?: current.refreshToken - val expiresAt = System.currentTimeMillis() + ((parsed.string("expires_in")?.toLongOrNull() ?: 3600L) * 1000L) - current.copy( - idToken = idToken, - refreshToken = refreshToken, - expiresAtEpochMillis = expiresAt - ) - } - - private suspend fun refreshGoogleAccessToken(current: DesktopAuthSession): DesktopAuthSession = withContext(Dispatchers.IO) { - val tokenRequest = listOfNotNull( - "client_id" to config.googleOAuthClientId, - config.googleOAuthClientSecret.takeIf { it.isNotBlank() }?.let { "client_secret" to it }, - "refresh_token" to current.googleRefreshToken, - "grant_type" to "refresh_token" - ) - val parsed = postForm( - url = "https://oauth2.googleapis.com/token", - body = formEncode(tokenRequest) - ).let { DesktopAuthJson.parseToJsonElement(it).jsonObject } - val accessToken = parsed.string("access_token") - ?: throw IllegalStateException(parsed.string("error_description") ?: "Could not refresh Google Drive access.") - val expiresAt = System.currentTimeMillis() + ((parsed.string("expires_in")?.toLongOrNull() ?: 3600L) * 1000L) - current.copy( - googleAccessToken = accessToken, - googleAccessTokenExpiresAtEpochMillis = expiresAt - ) - } - - private data class DesktopOAuthCode( - val code: String, - val redirectUri: String, - val codeVerifier: String - ) - - private data class DesktopGoogleTokens( - val idToken: String, - val accessToken: String, - val refreshToken: String, - val expiresAtEpochMillis: Long - ) - - private fun googleOAuthCallbackPage(title: String, message: String): String { - return """ - - - - - - ${title.escapeHtml()} - - - -
-

${title.escapeHtml()}

-

${message.escapeHtml()}

-
- - - """.trimIndent() - } -} - -internal class DesktopAuthStore( - private val settingsFile: File = File(desktopUserConfigRoot(), "auth.properties"), - private val secretCodec: DesktopSecretCodec = DesktopSecretCodec.platform() -) { - fun load(): DesktopAuthSession? { - if (!settingsFile.isFile) return null - val properties = Properties() - return runCatching { - settingsFile.inputStream().use(properties::load) - val refreshTokenRef = properties.getProperty(RefreshTokenKey, "") - val refreshToken = refreshTokenRef.takeIf { it.isNotBlank() } - ?.let { secretCodec.unprotect(RefreshTokenKey, it) } - .orEmpty() - val googleRefreshTokenRef = properties.getProperty(GoogleRefreshTokenKey, "") - val googleRefreshToken = googleRefreshTokenRef.takeIf { it.isNotBlank() } - ?.let { secretCodec.unprotect(GoogleRefreshTokenKey, it) } - .orEmpty() - if (refreshToken.isBlank()) return null - DesktopAuthSession( - user = UserData( - uid = properties.getProperty("uid", ""), - displayName = properties.getProperty("displayName", "").takeIf { it.isNotBlank() }, - photoUrl = properties.getProperty("photoUrl", "").takeIf { it.isNotBlank() }, - email = properties.getProperty("email", "").takeIf { it.isNotBlank() } - ), - idToken = "", - refreshToken = refreshToken, - expiresAtEpochMillis = 0L, - googleRefreshToken = googleRefreshToken - ) - }.getOrNull() - } - - fun save(session: DesktopAuthSession) { - if (session.refreshToken.isBlank()) { - throw IllegalArgumentException("Cannot save a desktop account without a refresh token.") - } - val protectedTokens = runCatching { - ProtectedDesktopAuthTokens( - refreshToken = protectRequired(RefreshTokenKey, session.refreshToken), - googleRefreshToken = session.googleRefreshToken - .takeIf { it.isNotBlank() } - ?.let { protectRequired(GoogleRefreshTokenKey, it) } - ) - }.getOrElse { error -> - if (secretCodec.isAvailable) { - throw error - } - logDesktopCloudSync { - "desktop.auth.persist_skipped reason=secure_storage_unavailable codec=${secretCodec.name} " + - "error=\"${error.desktopTtsSummary()}\"" - } - clear() - return - } - val properties = Properties().apply { - setProperty("uid", session.user.uid) - setProperty("displayName", session.user.displayName.orEmpty()) - setProperty("photoUrl", session.user.photoUrl.orEmpty()) - setProperty("email", session.user.email.orEmpty()) - setProperty(RefreshTokenKey, protectedTokens.refreshToken) - protectedTokens.googleRefreshToken?.let { setProperty(GoogleRefreshTokenKey, it) } - } - settingsFile.storePropertiesAtomically(properties, "Episteme desktop account") - } - - fun clear() { - secretCodec.delete(RefreshTokenKey) - secretCodec.delete(GoogleRefreshTokenKey) - settingsFile.delete() - } - - private companion object { - const val RefreshTokenKey = "firebaseRefreshTokenProtected" - const val GoogleRefreshTokenKey = "googleRefreshTokenProtected" - } - - private data class ProtectedDesktopAuthTokens( - val refreshToken: String, - val googleRefreshToken: String? - ) - - private fun protectRequired(keyName: String, value: String): String { - val protectedValue = secretCodec.protect(keyName, value) - if (protectedValue.isBlank()) { - throw IllegalStateException("Desktop secure key storage returned an empty value for $keyName.") - } - return protectedValue - } -} - -private val DesktopAuthJson = Json { ignoreUnknownKeys = true } -private const val DesktopGoogleOAuthScopes = "openid email profile https://www.googleapis.com/auth/drive.appdata" - -private fun JsonObject.string(key: String): String? = this[key]?.jsonPrimitive?.contentOrNull - -private fun JsonObject.errorMessage(): String? { - val error = this["error"].jsonObjectOrNull() ?: return null - return error.string("message") -} - -private fun randomUrlToken(length: Int): String { - val bytes = ByteArray(length) - SecureRandom().nextBytes(bytes) - return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) -} - -private fun postForm(url: String, body: String): String { - return postBody(url, body, "application/x-www-form-urlencoded") -} - -private fun postJson(url: String, body: String): String { - return postBody(url, body, "application/json; charset=UTF-8") -} - -private fun postBody(url: String, body: String, contentType: String): String { - val connection = (URL(url).openConnection() as HttpURLConnection).apply { - requestMethod = "POST" - setRequestProperty("Content-Type", contentType) - setRequestProperty("Accept", "application/json") - connectTimeout = 15_000 - readTimeout = 30_000 - doOutput = true - doInput = true - } - return try { - connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) } - val stream = if (connection.responseCode in 200..299) connection.inputStream else connection.errorStream - val text = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty() - if (connection.responseCode !in 200..299) { - val message = runCatching { - DesktopAuthJson.parseToJsonElement(text).jsonObject.errorMessage() - }.getOrNull() - throw IllegalStateException(message ?: "HTTP ${connection.responseCode}: ${text.take(240)}") - } - text - } finally { - connection.disconnect() - } -} - -private fun formEncode(vararg pairs: Pair): String { - return formEncode(pairs.asIterable()) -} - -private fun formEncode(pairs: Iterable>): String { - return pairs.joinToString("&") { (key, value) -> "${urlEncode(key)}=${urlEncode(value)}" } -} - -private fun urlEncode(value: String): String = URLEncoder.encode(value, Charsets.UTF_8.name()) - -private fun JsonElement?.jsonObjectOrNull(): JsonObject? = this as? JsonObject - -private fun String.escapeHtml(): String = buildString(length) { - this@escapeHtml.forEach { char -> - when (char) { - '&' -> append("&") - '<' -> append("<") - '>' -> append(">") - '"' -> append(""") - '\'' -> append("'") - else -> append(char) - } - } -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFolderMetadataExtractor.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFolderMetadataExtractor.kt deleted file mode 100644 index 7a1a4bb..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFolderMetadataExtractor.kt +++ /dev/null @@ -1,664 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import org.dueattendant149.bookreader.shared.BookItem -import org.dueattendant149.bookreader.shared.FileType -import org.dueattendant149.bookreader.shared.ReaderPlatform -import org.dueattendant149.bookreader.shared.SharedFileCapabilities -import org.dueattendant149.bookreader.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 enrichFolderBooks( - books: List, - sourceFolders: Set - ): DesktopFolderMetadataExtractionResult { - if (sourceFolders.isEmpty()) { - return DesktopFolderMetadataExtractionResult(books) - } - return enrichBooks(books) { book -> book.sourceFolder in sourceFolders } - } - - fun enrichImportedBooks( - books: List, - importedBookIds: Set - ): DesktopFolderMetadataExtractionResult { - if (importedBookIds.isEmpty()) { - return DesktopFolderMetadataExtractionResult(books) - } - return enrichBooks(books) { book -> book.id in importedBookIds } - } - - fun enrichOpenedBook(book: BookItem): BookItem { - if (!book.needsFolderMetadataExtraction()) return book - return runCatching { enrichBook(book) }.getOrDefault(book) - } - - 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 extractedTitle: String? = null - var extractedAuthor: String? = null - var extractedDescription: String? = null - var extractedSeriesName: String? = null - var extractedSeriesIndex: Double? = null - var textMetadataParsed = book.folderTextMetadataParsed - var embeddedCover: EmbeddedCover? = null - - when (book.type) { - FileType.EPUB -> { - val metadata = parseEpubMetadata(file) - extractedTitle = sanitizeTitle(metadata.title) - extractedAuthor = sanitizeAuthor(metadata.author) - extractedDescription = sanitizeDescription(metadata.description) - extractedSeriesName = sanitizeDescription(metadata.seriesName) - extractedSeriesIndex = metadata.seriesIndex?.takeIf { it > 0.0 } - embeddedCover = metadata.cover - textMetadataParsed = true - } - FileType.PDF -> { - val metadata = runCatching { DesktopPdfium.extractMetadata(file) }.getOrNull() - extractedTitle = sanitizeTitle(metadata?.title) - extractedAuthor = sanitizeAuthor(metadata?.author) - extractedDescription = sanitizeDescription(metadata?.description) - textMetadataParsed = true - } - FileType.HTML -> { - extractedTitle = sanitizeTitle(parseHtmlTitle(file)) - extractedDescription = sanitizeDescription(parseHtmlDescription(file)) - textMetadataParsed = true - } - FileType.MOBI, - FileType.FB2, - FileType.DOCX, - FileType.ODT, - FileType.FODT -> { - runCatching { SharedJvmBookLoader.load(file, book.type) } - .onSuccess { loaded -> - extractedTitle = sanitizeTitle(loaded.title) - extractedAuthor = sanitizeAuthor(loaded.author) - textMetadataParsed = true - } - } - else -> Unit - } - - val coverPath = book.coverImagePath?.takeIf { File(it).isFile } - ?: saveEmbeddedCover(book, embeddedCover) - ?: renderReaderSurfaceCover(book, file) - ?: saveGeneratedCover(book) - - val nextTitle = if (book.shouldApplyExtractedTitle(file)) { - extractedTitle ?: book.title ?: file.nameWithoutExtension - } else { - book.title - } - val nextAuthor = if (book.shouldApplyExtractedText(book.author, book.originalAuthor)) { - extractedAuthor ?: book.author - } else { - book.author - } - val nextDescription = if (book.shouldApplyExtractedText(book.description, book.originalDescription)) { - extractedDescription ?: book.description - } else { - book.description - } - val nextSeriesName = if (book.shouldApplyExtractedText(book.seriesName, book.originalSeriesName)) { - extractedSeriesName ?: book.seriesName - } else { - book.seriesName - } - val nextSeriesIndex = if (book.seriesIndex == null || book.seriesIndex == book.originalSeriesIndex) { - extractedSeriesIndex ?: book.seriesIndex - } else { - book.seriesIndex - } - - return book.copy( - title = nextTitle, - author = nextAuthor, - description = nextDescription, - seriesName = nextSeriesName, - seriesIndex = nextSeriesIndex, - originalTitle = book.originalTitle ?: extractedTitle, - originalAuthor = book.originalAuthor ?: extractedAuthor, - originalSeriesName = book.originalSeriesName ?: extractedSeriesName, - originalSeriesIndex = book.originalSeriesIndex ?: extractedSeriesIndex, - originalDescription = book.originalDescription ?: extractedDescription, - fileSize = size, - fileContentModifiedTimestamp = file.lastModified(), - 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"), - description = opf.tagInnerContent("description"), - seriesName = opf.metaContent("calibre:series"), - seriesIndex = opf.metaContent("calibre:series_index")?.toDoubleOrNull(), - 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 parseHtmlDescription(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 - } - } - } - Regex("""]*>""", RegexOption.IGNORE_CASE) - .findAll(head) - .firstOrNull { meta -> - val name = meta.value.attr("name") - val property = meta.value.attr("property") - name.equals("description", ignoreCase = true) || - property.equals("og:description", ignoreCase = true) - } - ?.value - ?.attr("content") - ?.decodeEntities() - }.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, FileType.CBT -> 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() } - } - return File(desktopUserCacheRoot(), "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.tagInnerContent(tag: String): String { - return Regex( - "<(?:[^:>]+:)?$tag\\b[^>]*>(.*?)]+:)?$tag>", - setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL) - ) - .find(this) - ?.groupValues - ?.get(1) - ?.trim() - ?.removeSurrounding("") - ?.decodeEntities() - ?.trim() - .orEmpty() - } - - private fun String.metaContent(name: String): String? { - return Regex("""]*>""", RegexOption.IGNORE_CASE) - .findAll(this) - .firstOrNull { it.value.attr("name").equals(name, ignoreCase = true) } - ?.value - ?.attr("content") - ?.decodeEntities() - ?.trim() - ?.takeIf { it.isNotBlank() } - } - - 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 fun sanitizeDescription(value: String?): String? { - return value - ?.trim() - ?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) } - } - - private fun BookItem.shouldApplyExtractedTitle(file: File): Boolean { - val current = title?.trim() - val fallback = file.nameWithoutExtension - return current.isNullOrBlank() || current == fallback || current == originalTitle?.trim() - } - - private fun BookItem.shouldApplyExtractedText(current: String?, original: String?): Boolean { - val normalized = current?.trim() - return normalized.isNullOrBlank() || normalized == original?.trim() - } - - 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 description: String? = null, - val seriesName: String? = null, - val seriesIndex: Double? = 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/dueattendant149/bookreader/reader/desktop/DesktopFolderSyncFeedback.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFolderSyncFeedback.kt deleted file mode 100644 index e612816..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFolderSyncFeedback.kt +++ /dev/null @@ -1,16 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import org.dueattendant149.bookreader.shared.SharedReaderScreenState - -internal fun desktopFolderSyncCompletedState( - state: SharedReaderScreenState, - message: String, - failedFolderCount: Int, - showBanner: Boolean -): SharedReaderScreenState { - return if (showBanner) { - state.withBanner(message, isError = failedFolderCount > 0) - } else { - state - } -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFolderSyncLog.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFolderSyncLog.kt deleted file mode 100644 index 44149df..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFolderSyncLog.kt +++ /dev/null @@ -1,19 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -private const val DesktopFolderSyncLogTag = "EpistemeFolderSync" - -internal fun logDesktopFolderSync(message: String) { - logDesktopDiagnostic(DesktopFolderSyncLogTag) { message } -} - -internal fun Throwable.folderSyncSummary(): String { - val type = this::class.java.simpleName.ifBlank { "Throwable" } - return "$type: ${message.orEmpty().folderSyncPreview(220)}" -} - -internal fun String.folderSyncPreview(maxLength: Int = 160): 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/dueattendant149/bookreader/reader/desktop/DesktopGeminiCloudTtsAdapter.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopGeminiCloudTtsAdapter.kt deleted file mode 100644 index 2cba6df..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopGeminiCloudTtsAdapter.kt +++ /dev/null @@ -1,829 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import org.dueattendant149.bookreader.shared.GEMINI_CLOUD_TTS_MODEL -import org.dueattendant149.bookreader.shared.ReaderAiByokSettings -import org.dueattendant149.bookreader.shared.ReaderTtsCacheSummary -import org.dueattendant149.bookreader.shared.ReaderTtsChunk -import org.dueattendant149.bookreader.shared.ReaderTtsFileCacheManager -import org.dueattendant149.bookreader.shared.ReaderTtsReadScope -import org.dueattendant149.bookreader.shared.TtsAdapter -import org.dueattendant149.bookreader.shared.createReaderTtsWavHeaderUnknownLength -import org.dueattendant149.bookreader.shared.patchReaderTtsWavHeader -import org.dueattendant149.bookreader.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.net.http.WebSocketHandshakeException -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 networkAccess: () -> Boolean = { true }, - private val workerUrlProvider: () -> String = { "" }, - private val authTokenProvider: suspend () -> String? = { null }, - private val useWorkerProvider: () -> Boolean = { true }, - private val onWorkerUsageCompleted: suspend () -> Unit = {}, - httpClient: HttpClient? = null, - private val cacheManager: ReaderTtsFileCacheManager = ReaderTtsFileCacheManager(defaultDesktopTtsCacheRoot()) -) : TtsAdapter { - private val providedHttpClient = httpClient - private val httpClient: HttpClient by lazy(LazyThreadSafetyMode.PUBLICATION) { - providedHttpClient ?: HttpClient.newHttpClient() - } - - @Volatile - private var activeLine: SourceDataLine? = null - - @Volatile - private var activeWebSocket: WebSocket? = null - - @Volatile - private var activePlayer: DesktopStreamingPcmPlayer? = null - - val isPlaybackActive: Boolean - get() = activePlayer != null || activeWebSocket != null || activeLine != null - - override val isAvailable: Boolean - get() { - val settings = settingsProvider().sanitized() - return networkAccess() && - (settings.isByokCloudTtsAvailable || - (useWorkerProvider() && settings.serverBackedCloudTts && workerUrlProvider().isNotBlank())) - } - - 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() } - logDesktopTtsStartTrace { - "event=adapter_speak_chunks book=\"${bookTitle.desktopTtsPreview()}\" scope=${readScope.name} " + - "inputChunks=${chunks.size} sequenceChunks=${sequenceChunks.size} " + - "inputFirst=${chunks.firstOrNull().desktopTtsStartTraceSummary(160)} " + - "sequenceFirstText=\"${sequenceChunks.firstOrNull()?.text.orEmpty().desktopTtsPreview(180)}\"" - } - 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 useWorker = useWorkerProvider() && !settings.isByokCloudTtsAvailable - 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} serverBacked=${settings.serverBackedCloudTts} worker=$useWorker" - ) - if (!networkAccess()) { - logDesktopTts("stream_blocked reason=network_disabled") - throw IllegalStateException("Cloud TTS is unavailable in this desktop build.") - } - if (useWorker) { - if (!settings.serverBackedCloudTts || workerUrlProvider().isBlank()) { - logDesktopTts("stream_blocked reason=server_backed_not_available") - throw IllegalStateException("Cloud TTS needs a signed-in account with credits.") - } - } else if (!settings.isByokCloudTtsAvailable) { - logDesktopTts("stream_blocked reason=byok_not_available") - throw IllegalStateException("Cloud TTS needs a saved Gemini key and the Gemini cloud TTS model selected.") - } - val authToken = if (useWorker) authTokenProvider() else null - if (useWorker && authToken.isNullOrBlank()) { - logDesktopTts("stream_blocked reason=missing_auth_token") - throw IllegalStateException("Sign in with Google to use cloud TTS.") - } - - 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 - var workerGeneratedAudio = false - - 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 uri = if (useWorker) { - val workerUrl = workerUrlProvider().removeSuffix("/") - val wsUrl = workerUrl - .replace("https://", "wss://") - .replace("http://", "ws://") - val speaker = URLEncoder.encode(settings.ttsSpeakerId, Charsets.UTF_8.name()) - val token = URLEncoder.encode(authToken.orEmpty(), Charsets.UTF_8.name()) - URI("$wsUrl/live?speaker=$speaker&token=$token") - } else { - val encodedKey = URLEncoder.encode(settings.geminiKey, Charsets.UTF_8.name()) - URI("wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent?key=$encodedKey") - } - logDesktopTts("ws_connect_start endpoint=${if (useWorker) "Worker" else "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 IllegalStateException(desktopTtsConnectionMessage(error), 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}") - logDesktopTtsStartTrace { - "event=adapter_turn_start index=${index + 1}/${chunks.size} chapter=\"${chunk.chapterTitle.orEmpty().desktopTtsPreview()}\" " + - "textChars=${text.length} text=\"${text.desktopTtsPreview(220)}\"" - } - 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.") - } - if (useWorker) { - workerGeneratedAudio = true - onWorkerUsageCompleted() - } - 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()}") - if (useWorker && workerGeneratedAudio) onWorkerUsageCompleted() - } catch (error: Throwable) { - if (useWorker && desktopTtsShouldRefreshAccountAfterError(error)) { - try { - onWorkerUsageCompleted() - } catch (_: Throwable) { - // Keep the original TTS failure as the visible error. - } - } - 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 { - return File(desktopUserCacheRoot(), "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 fun desktopTtsConnectionMessage(error: Throwable): String { - val causes = generateSequence(error) { it.cause }.toList() - val handshake = causes.filterIsInstance().firstOrNull() - return when (handshake?.response?.statusCode()) { - 401 -> "Sign in again to use cloud TTS." - 402 -> "Out of credits. Pro and credits can only be purchased from the Android app." - 403 -> "Cloud TTS is unavailable for this account." - 405 -> "Cloud TTS is not configured for this desktop build." - 426 -> "Cloud TTS is not configured for this desktop build." - 502 -> "Cloud TTS service is temporarily unavailable." - else -> { - val details = causes - .joinToString(" ") { it.message.orEmpty() } - .trim() - when { - details.contains("INSUFFICIENT_CREDITS", ignoreCase = true) -> - "Out of credits. Pro and credits can only be purchased from the Android app." - details.contains("401") || details.contains("Unauthorized", ignoreCase = true) -> - "Sign in again to use cloud TTS." - else -> "Cloud TTS failed to connect." - } - } - } -} - -private fun desktopTtsShouldRefreshAccountAfterError(error: Throwable): Boolean { - val details = generateSequence(error) { it.cause } - .joinToString(" ") { it.message.orEmpty() } - return details.contains("Out of credits", ignoreCase = true) || - details.contains("INSUFFICIENT_CREDITS", ignoreCase = true) || - details.contains("402", ignoreCase = true) -} - -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()}\"") - }.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/dueattendant149/bookreader/reader/desktop/DesktopLanguageSettings.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLanguageSettings.kt deleted file mode 100644 index 71f3cf2..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLanguageSettings.kt +++ /dev/null @@ -1,163 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import androidx.compose.foundation.BorderStroke -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.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.RadioButton -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import org.dueattendant149.bookreader.shared.ui.readerString -import java.io.File -import java.util.Properties - -internal data class DesktopLanguageSettings( - val languageTag: String? = null -) - -internal data class DesktopLanguageOption( - val languageTag: String?, - val labelKey: String, - val fallbackLabel: String -) { - val normalizedTag: String? = normalizeDesktopLanguageTag(languageTag) -} - -internal val DesktopLanguageOptions = listOf( - DesktopLanguageOption(null, "language_system_default", "System default"), - DesktopLanguageOption("en", "language_english_default", "English (Default)"), - DesktopLanguageOption("ar", "language_arabic", "Arabic"), - DesktopLanguageOption("de", "language_german", "German"), - DesktopLanguageOption("tr", "language_turkish", "Turkish"), - DesktopLanguageOption("fr", "language_french", "French"), - DesktopLanguageOption("ru", "language_russian", "Russian"), - DesktopLanguageOption("be", "language_belarusian", "Belarusian"), - DesktopLanguageOption("es", "language_spanish", "Spanish"), - DesktopLanguageOption("pt-BR", "language_portuguese_brazilian", "Portuguese (Brazil)"), - DesktopLanguageOption("it", "language_italian", "Italian"), - DesktopLanguageOption("pl", "language_polish", "Polish"), - DesktopLanguageOption("vi", "language_vietnamese", "Vietnamese"), - DesktopLanguageOption("ja", "language_japanese", "Japanese"), - DesktopLanguageOption("ko", "language_korean", "Korean"), - DesktopLanguageOption("hi", "language_hindi", "Hindi"), - DesktopLanguageOption("zh-CN", "language_chinese_simplified", "Chinese, Simplified"), - DesktopLanguageOption("nl", "language_dutch", "Dutch"), - DesktopLanguageOption("uk", "language_ukrainian", "Ukrainian"), - DesktopLanguageOption("id", "language_indonesian", "Indonesian"), - DesktopLanguageOption("et", "language_estonian", "Estonian") -) - -internal fun selectedDesktopLanguageOption(languageTag: String?): DesktopLanguageOption { - val normalized = normalizeDesktopLanguageTag(languageTag) - return DesktopLanguageOptions.firstOrNull { it.normalizedTag == normalized } - ?: DesktopLanguageOptions.first() -} - -internal class DesktopLanguageSettingsStore( - private val settingsFile: File = File(desktopUserConfigRoot(), "language.properties") -) { - fun load(): DesktopLanguageSettings { - if (!settingsFile.isFile) return DesktopLanguageSettings() - val properties = Properties() - return runCatching { - settingsFile.inputStream().use(properties::load) - DesktopLanguageSettings( - languageTag = normalizeDesktopLanguageTag(properties.getProperty(LanguageTag)) - ) - }.getOrDefault(DesktopLanguageSettings()) - } - - fun save(settings: DesktopLanguageSettings) { - settingsFile.parentFile?.mkdirs() - val properties = Properties().apply { - normalizeDesktopLanguageTag(settings.languageTag)?.let { languageTag -> - setProperty(LanguageTag, languageTag) - } - } - settingsFile.outputStream().use { output -> - properties.store(output, "Episteme desktop language") - } - } - - private companion object { - const val LanguageTag = "languageTag" - } -} - -@Composable -internal fun DesktopLanguageDialog( - selectedLanguageTag: String?, - onLanguageSelected: (String?) -> Unit, - onDismiss: () -> Unit -) { - val selectedOption = selectedDesktopLanguageOption(selectedLanguageTag) - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(readerString("options_language", "Language"), fontWeight = FontWeight.Bold) }, - text = { - LazyColumn( - modifier = Modifier.heightIn(max = 520.dp), - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - items(DesktopLanguageOptions, key = { it.languageTag ?: "system" }) { option -> - val selected = option.normalizedTag == selectedOption.normalizedTag - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(8.dp), - color = MaterialTheme.colorScheme.surface, - border = if (selected) { - BorderStroke(1.dp, MaterialTheme.colorScheme.primary) - } else { - null - }, - onClick = { - onLanguageSelected(option.normalizedTag) - onDismiss() - } - ) { - Row( - modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp) - ) { - RadioButton( - selected = selected, - onClick = { - onLanguageSelected(option.normalizedTag) - onDismiss() - } - ) - Column(Modifier.weight(1f)) { - Text(readerString(option.labelKey, option.fallbackLabel)) - } - } - } - if (option != DesktopLanguageOptions.last()) { - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)) - } - } - } - }, - confirmButton = { - TextButton(onClick = onDismiss) { - Text(readerString("action_cancel", "Cancel")) - } - } - ) -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLibraryDatabase.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLibraryDatabase.kt deleted file mode 100644 index 1b087c9..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLibraryDatabase.kt +++ /dev/null @@ -1,47 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import org.dueattendant149.bookreader.shared.SharedLibrarySnapshot -import org.dueattendant149.bookreader.shared.SharedLibrarySnapshotJson -import java.io.File -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.jsonObject - -class DesktopLibraryDatabase( - private val databaseFile: File = defaultDatabaseFile() -) { - fun load(): SharedLibrarySnapshot { - return loadFile(databaseFile) - ?: loadFile(backupFile()) - ?: SharedLibrarySnapshot() - } - - fun save(snapshot: SharedLibrarySnapshot) { - val encoded = SharedLibrarySnapshotJson.encode(snapshot) - databaseFile.writeTextAtomically(encoded) - runCatching { - backupFile().writeTextAtomically(encoded) - } - } - - private fun loadFile(file: File): SharedLibrarySnapshot? { - if (!file.isFile) return null - val raw = runCatching { file.readText() }.getOrNull() ?: return null - val isJsonObject = runCatching { - libraryDatabaseJson.parseToJsonElement(raw).jsonObject - }.isSuccess - if (!isJsonObject) return null - return SharedLibrarySnapshotJson.decodeOrEmpty(raw) - } - - private fun backupFile(): File { - return File(databaseFile.parentFile ?: File("."), "${databaseFile.name}.bak") - } - - companion object { - fun defaultDatabaseFile(): File { - return File(desktopUserDataRoot(), "library.json") - } - } -} - -private val libraryDatabaseJson = Json { ignoreUnknownKeys = true } diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLibraryUi.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLibraryUi.kt deleted file mode 100644 index b93b635..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLibraryUi.kt +++ /dev/null @@ -1,493 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -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.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.FilterChip -import androidx.compose.material3.MaterialTheme -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.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import org.dueattendant149.bookreader.shared.AppAction -import org.dueattendant149.bookreader.shared.BannerMessage -import org.dueattendant149.bookreader.shared.BookItem -import org.dueattendant149.bookreader.shared.ReaderPlatform -import org.dueattendant149.bookreader.shared.SharedFileCapabilities -import org.dueattendant149.bookreader.shared.SharedFolderPathResolver -import org.dueattendant149.bookreader.shared.SharedReaderScreenState -import org.dueattendant149.bookreader.shared.Shelf -import org.dueattendant149.bookreader.shared.SmartCollectionDefinition -import org.dueattendant149.bookreader.shared.SmartField -import org.dueattendant149.bookreader.shared.SmartOperator -import org.dueattendant149.bookreader.shared.SmartRule -import org.dueattendant149.bookreader.shared.Tag -import org.dueattendant149.bookreader.shared.reader.ReaderSettings -import org.dueattendant149.bookreader.shared.reduce -import org.dueattendant149.bookreader.shared.ui.NonReaderLibraryTab -import org.dueattendant149.bookreader.shared.ui.SharedLibraryScreen -import org.dueattendant149.bookreader.shared.ui.SharedStableOutlinedTextField -import org.dueattendant149.bookreader.shared.ui.readerString -import java.io.File - -internal fun BookItem.hasEmbeddedMetadataChange(updated: BookItem): Boolean { - return title != updated.title || - author != updated.author || - description != updated.description || - seriesName != updated.seriesName || - seriesIndex != updated.seriesIndex -} - -internal fun String.toDesktopSafeFileName(): String { - return replace(Regex("[^A-Za-z0-9._-]"), "_").take(120).ifBlank { "book" } -} - -internal fun BookItem.desktopSuggestedOriginalFileName(): String { - val extension = path - ?.let(::File) - ?.extension - ?.takeIf { it.isNotBlank() } - ?: SharedFileCapabilities.primaryExtensionFor(type) - val safeName = displayName - .takeIf { it.isNotBlank() } - ?: title?.takeIf { it.isNotBlank() } - ?: "book" - val sanitized = safeName.toDesktopSafeFileName() - return if (extension != null && !sanitized.endsWith(".$extension", ignoreCase = true)) { - "$sanitized.$extension" - } else { - sanitized - } -} - -internal 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 - }, - description = if (shouldApplyText(description, original?.description)) { - enriched.description ?: description - } else { - description - }, - seriesName = if (shouldApplyText(seriesName, original?.seriesName)) { - enriched.seriesName ?: seriesName - } else { - seriesName - }, - seriesIndex = if (seriesIndex == null || seriesIndex == original?.seriesIndex) { - enriched.seriesIndex ?: seriesIndex - } else { - seriesIndex - }, - originalTitle = originalTitle ?: enriched.originalTitle ?: enriched.title, - originalAuthor = originalAuthor ?: enriched.originalAuthor ?: enriched.author, - originalSeriesName = originalSeriesName ?: enriched.originalSeriesName ?: enriched.seriesName, - originalSeriesIndex = originalSeriesIndex ?: enriched.originalSeriesIndex ?: enriched.seriesIndex, - originalDescription = originalDescription ?: enriched.originalDescription ?: enriched.description, - fileSize = enriched.fileSize.takeIf { it > 0L } ?: fileSize, - fileContentModifiedTimestamp = enriched.fileContentModifiedTimestamp.takeIf { it > 0L } - ?: fileContentModifiedTimestamp, - coverImagePath = coverImagePath?.takeIf { File(it).isFile } ?: enriched.coverImagePath, - folderTextMetadataParsed = folderTextMetadataParsed || enriched.folderTextMetadataParsed - ) -} - -internal fun resolvedDesktopReaderSettings( - book: BookItem, - readerDefaultSettings: ReaderSettings -): ReaderSettings { - return book.readerSettings ?: readerDefaultSettings -} - -@Composable -internal fun DesktopReaderOpeningScreen( - opening: DesktopReaderOpening, - readerSettings: ReaderSettings? = null -) { - LaunchedEffect(opening.requestId) { - logDesktopReaderOpenTrace { - opening.openTracePrefix("desktop_opening_screen_composed") - } - } - val background = readerSettings?.desktopOpeningBackgroundColor() ?: MaterialTheme.colorScheme.background - val foreground = readerSettings?.desktopOpeningForegroundColor() ?: MaterialTheme.colorScheme.onBackground - Box( - modifier = Modifier - .fillMaxSize() - .background(background) - .padding(32.dp), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - CircularProgressIndicator(color = foreground) - Text( - text = readerString("desktop_opening_title", "Opening %1\$s", opening.title), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - color = foreground, - textAlign = TextAlign.Center - ) - Text( - text = opening.formatLabel, - style = MaterialTheme.typography.bodyMedium, - color = foreground.copy(alpha = 0.72f), - textAlign = TextAlign.Center - ) - } - } -} - -private fun ReaderSettings.desktopOpeningBackgroundColor(): Color { - return backgroundColorArgb?.toDesktopOpeningComposeColor() - ?: if (darkMode) Color(0xFF171A17) else Color(0xFFFFFCF5) -} - -private fun ReaderSettings.desktopOpeningForegroundColor(): Color { - return textColorArgb?.toDesktopOpeningComposeColor() - ?: if (darkMode) Color(0xFFE7E3D8) else Color(0xFF24231F) -} - -private fun Long.toDesktopOpeningComposeColor(): 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) -} - -@Composable -internal fun LibraryScreen( - state: SharedReaderScreenState, - selectedLibraryTab: NonReaderLibraryTab, - onLibraryTabChange: (NonReaderLibraryTab) -> Unit, - onStateChange: (SharedReaderScreenState) -> Unit, - onImportBooks: () -> Unit, - onRead: (BookItem) -> Unit, - onSelect: (String) -> Unit, - onClearSelection: () -> Unit, - onRemoveSelected: () -> Unit, - onShowBookInfo: (BookItem) -> Unit, - onEditBook: (BookItem) -> Unit, - onCreateShelf: () -> Unit, - onCreateShelfWithBooks: (String, Set) -> Unit, - onCreateSmartShelf: () -> Unit, - onRenameShelf: (Shelf) -> Unit, - onDeleteShelf: (Shelf) -> Unit, - onRemoveFolder: (Shelf) -> Unit, - onTagSelectedBooks: () -> Unit, - onAddSelectedBooksToShelf: () -> Unit, - onAddBooksToShelf: (Set) -> Unit, - onManageShelfBooks: (Shelf) -> Unit, - onImportFolder: () -> Unit, - onSyncFolderMetadata: () -> Unit, - onScanFolders: () -> Unit, - onTogglePinned: (BookItem) -> Unit, - onSaveOriginalFile: (BookItem) -> Unit = {} -) { - SharedLibraryScreen( - state = state, - selectedTab = selectedLibraryTab, - onTabChange = onLibraryTabChange, - onStateChange = onStateChange, - onImportBooks = onImportBooks, - onOpenBook = onRead, - onToggleSelection = onSelect, - onClearSelection = onClearSelection, - onRemoveSelected = onRemoveSelected, - onShowBookInfo = onShowBookInfo, - onEditBook = onEditBook, - onCreateShelf = onCreateShelf, - onCreateShelfWithBooks = onCreateShelfWithBooks, - onCreateSmartShelf = onCreateSmartShelf, - onRenameShelf = onRenameShelf, - onDeleteShelf = onDeleteShelf, - onRemoveFolder = onRemoveFolder, - onTagSelectedBooks = onTagSelectedBooks, - onAddSelectedBooksToShelf = onAddSelectedBooksToShelf, - onAddBooksToShelf = onAddBooksToShelf, - onManageShelfBooks = onManageShelfBooks, - onImportFolder = onImportFolder, - onSyncFolderMetadata = onSyncFolderMetadata, - onScanFolders = onScanFolders, - onTogglePinned = onTogglePinned, - onSaveOriginalFile = onSaveOriginalFile, - platform = ReaderPlatform.DESKTOP, - useImportEmptyStateWhenLibraryEmpty = true - ) -} - -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 -internal 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(readerString("desktop_create_smart_shelf", "Create smart shelf")) }, - text = { - Column( - modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - SharedStableOutlinedTextField( - value = name, - onValueChange = { name = it }, - label = { Text(readerString("shelf_name_hint", "Shelf name")) }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - FilterChip( - selected = matchAll, - onClick = { matchAll = true }, - label = { Text(readerString("filter_all", "All")) } - ) - FilterChip( - selected = !matchAll, - onClick = { matchAll = false }, - label = { Text(readerString("desktop_match_any", "Any")) } - ) - Spacer(Modifier.weight(1f)) - TextButton( - onClick = { rules = rules + DesktopSmartRuleDraft() }, - enabled = rules.size < 4 - ) { - Text(readerString("tts_replacements_add_rule", "Add rule")) - } - } - rules.forEachIndexed { index, draft -> - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - SmartRuleDropdown( - label = readerString("desktop_field", "Field"), - selected = draft.field, - options = SmartField.entries.toList(), - optionLabel = { it.localizedLabel() }, - onSelected = { field -> - rules = rules.updateAt(index) { - val operator = smartOperatorsFor(field).first() - copy(field = field, operator = operator, value = "") - } - } - ) - SmartRuleDropdown( - label = readerString("desktop_operator", "Operator"), - selected = draft.operator, - options = smartOperatorsFor(draft.field), - optionLabel = { it.localizedLabel() }, - onSelected = { operator -> - rules = rules.updateAt(index) { copy(operator = operator) } - } - ) - if (rules.size > 1) { - TextButton(onClick = { rules = rules.filterIndexed { i, _ -> i != index } }) { - Text(readerString("action_remove", "Remove")) - } - } - } - SharedStableOutlinedTextField( - value = draft.value, - onValueChange = { value -> rules = rules.updateAt(index) { copy(value = value) } }, - label = { Text(draft.field.localizedValueLabel()) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - selectionKey = index - ) - } - } - } - }, - confirmButton = { - TextButton( - onClick = { - onConfirm(name, SmartCollectionDefinition(matchAll = matchAll, rules = validRules)) - }, - enabled = name.isNotBlank() && validRules.isNotEmpty() - ) { - Text(readerString("action_create", "Create")) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(readerString("action_cancel", "Cancel")) - } - } - ) -} - -@Composable -private fun SmartRuleDropdown( - label: String, - selected: T, - options: List, - optionLabel: @Composable (T) -> String, - onSelected: (T) -> Unit -) { - var expanded by remember { mutableStateOf(false) } - Box { - TextButton(onClick = { expanded = true }) { - val selectedLabel = optionLabel(selected) - Text(readerString("filter_facet", "%1\$s: %2\$s", label, selectedLabel)) - } - 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) - } -} - -@Composable -private fun SmartField.localizedLabel(): String { - return when (this) { - SmartField.TITLE -> readerString("label_title", "Title") - SmartField.AUTHOR -> readerString("author", "Author") - SmartField.PROGRESS -> readerString("desktop_progress", "Progress") - SmartField.FILE_TYPE -> readerString("filter_file_type", "File type") - SmartField.FOLDER -> readerString("desktop_smart_field_folder", "Folder") - SmartField.TAG -> readerString("content_desc_tag", "Tag") - } -} - -@Composable -private fun SmartField.localizedValueLabel(): String { - return when (this) { - SmartField.PROGRESS -> readerString("desktop_percent", "Percent") - SmartField.FILE_TYPE -> readerString("desktop_type_example_pdf", "Type, e.g. PDF") - SmartField.FOLDER -> readerString("desktop_folder_path", "Folder path") - SmartField.TAG -> readerString("desktop_tag_name", "Tag name") - SmartField.TITLE -> readerString("desktop_title_text", "Title text") - SmartField.AUTHOR -> readerString("desktop_author_text", "Author text") - } -} - -@Composable -private fun SmartOperator.localizedLabel(): String { - return when (this) { - SmartOperator.EQUALS -> readerString("desktop_equals", "Equals") - SmartOperator.CONTAINS -> readerString("desktop_contains", "Contains") - SmartOperator.GREATER_THAN -> readerString("desktop_greater_than", "Greater than") - SmartOperator.LESS_THAN -> readerString("desktop_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 } -} - -internal fun SharedReaderScreenState.withBanner(message: String, isError: Boolean = false): SharedReaderScreenState { - return reduce(AppAction.BannerShown(BannerMessage(message, isError = isError))) -} - -internal 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() } - } -} - -internal fun List.collectTags(): List { - return flatMap { it.tags }.distinctBy { it.id }.sortedBy { it.name.lowercase() } -} - -internal fun BookItem.cardTitleForMessage(): String { - return title?.takeIf { it.isNotBlank() } ?: displayName -} - -internal fun Long.toReadableSize(): String { - if (this <= 0L) return "Unknown" - val units = listOf("B", "KB", "MB", "GB", "TB") - var value = this.toDouble() - var unitIndex = 0 - while (value >= 1024.0 && unitIndex < units.lastIndex) { - value /= 1024.0 - unitIndex += 1 - } - return if (unitIndex == 0) { - "$this ${units[unitIndex]}" - } else { - "${String.format("%.1f", value)} ${units[unitIndex]}" - } -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLocalFolderSync.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLocalFolderSync.kt deleted file mode 100644 index 9c078af..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLocalFolderSync.kt +++ /dev/null @@ -1,951 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import org.dueattendant149.bookreader.shared.BookItem -import org.dueattendant149.bookreader.shared.BookShelfRef -import org.dueattendant149.bookreader.shared.FileType -import org.dueattendant149.bookreader.shared.LOCAL_FOLDER_ANNOTATION_SUFFIX -import org.dueattendant149.bookreader.shared.LOCAL_FOLDER_SIDECAR_HASH_PREFIX -import org.dueattendant149.bookreader.shared.LOCAL_FOLDER_SYNC_DATA_DIR -import org.dueattendant149.bookreader.shared.LocalFolderSyncEngine -import org.dueattendant149.bookreader.shared.LocalFolderSyncStats -import org.dueattendant149.bookreader.shared.ReaderPlatform -import org.dueattendant149.bookreader.shared.SharedFileCapabilities -import org.dueattendant149.bookreader.shared.SharedFolderBookMetadata -import org.dueattendant149.bookreader.shared.SharedFolderScannedFile -import org.dueattendant149.bookreader.shared.SharedReaderScreenState -import org.dueattendant149.bookreader.shared.SyncedFolder -import org.dueattendant149.bookreader.shared.localFolderSyncAnnotationFileName -import org.dueattendant149.bookreader.shared.localFolderSyncAnnotationTempFileName -import org.dueattendant149.bookreader.shared.localFolderSyncMetadataFileName -import org.dueattendant149.bookreader.shared.localFolderSyncMetadataTempFileName -import org.dueattendant149.bookreader.shared.localFolderSyncSidecarStem -import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationSerializer -import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationSidecarCodec -import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichTextLog -import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichTextSerializer -import org.dueattendant149.bookreader.shared.toSharedFolderBookMetadata -import org.dueattendant149.bookreader.shared.toStablePositionCfi -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.contentOrNull -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(), - val processedFolderUris: 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() } - .onFail { file, error -> - logDesktopFolderSync( - "folder.supportedFiles.skipInaccessible path=\"${file.absolutePath.folderSyncPreview()}\" " + - "error=${error.folderSyncSummary()}" - ) - } - .any { file -> - runCatching { - file.isFile && - file.shouldSyncBookFile() && - SharedFileCapabilities.fileTypeForName(file.name) in desktopSyncableTypes - }.getOrDefault(false) - } - } - - fun sync( - state: SharedReaderScreenState, - shelfRefs: List, - targetFolder: File? = null, - nowMillis: Long = System.currentTimeMillis(), - metadataOnly: Boolean = false, - extractMetadata: Boolean = true - ): DesktopLocalFolderSyncResult { - val requestedFolders = foldersToSync(state, targetFolder, nowMillis) - .filter { it.localSyncEnabled } - val mode = if (metadataOnly) "metadata" else "full" - logDesktopFolderSync( - "sync.start mode=$mode target=\"${targetFolder?.absolutePath?.folderSyncPreview() ?: "ALL"}\" " + - "requestedFolders=${requestedFolders.size} linkedFolders=${state.syncedFolders.size} " + - "books=${state.rawLibraryBooks.size}" - ) - var nextState = state - var nextShelfRefs = shelfRefs - var totalStats = LocalFolderSyncStats() - var totalMetadataStats = DesktopFolderMetadataExtractionStats() - val allMigrations = linkedMapOf() - val allRemovedBookIds = linkedSetOf() - val failedFolders = mutableListOf() - val processedFolderUris = mutableListOf() - - requestedFolders.forEach { folder -> - val root = File(folder.uriString) - if (!root.isDirectory) { - logDesktopFolderSync( - "folder.skipMissing mode=$mode name=\"${folder.name.folderSyncPreview()}\" " + - "root=\"${root.absolutePath.folderSyncPreview()}\"" - ) - failedFolders += folder.name - return@forEach - } - processedFolderUris += folder.uriString - - logDesktopFolderSync( - "folder.start mode=$mode name=\"${folder.name.folderSyncPreview()}\" " + - "root=\"${root.absolutePath.folderSyncPreview()}\" allowed=${folder.allowedFileTypes.sortedBy { it.name }}" - ) - val scannedFiles = if (metadataOnly) { - emptyList() - } else { - scanFolder(root = root, sourceFolder = folder.uriString) - } - val remoteMetadata = readAllMetadata(root) - logDesktopFolderSync( - "folder.inputs mode=$mode name=\"${folder.name.folderSyncPreview()}\" " + - "scanned=${scannedFiles.size} supported=${scannedFiles.count { it.type in folder.allowedFileTypes }} " + - "remoteMetadata=${remoteMetadata.size}" - ) - val syncResult = LocalFolderSyncEngine.syncFolder( - state = nextState, - folder = folder, - files = scannedFiles, - remoteMetadata = remoteMetadata, - nowMillis = nowMillis, - metadataOnly = metadataOnly - ) - nextState = syncResult.state - nextShelfRefs = LocalFolderSyncEngine.applyIdMigrationsToShelfRefs( - nextShelfRefs, - syncResult.idMigrations - ).filterNot { it.bookId in syncResult.removedBookIds } - allMigrations += syncResult.idMigrations - allRemovedBookIds += syncResult.removedBookIds - totalStats += syncResult.stats - logDesktopFolderSync( - "folder.engine mode=$mode name=\"${folder.name.folderSyncPreview()}\" " + - "new=${syncResult.stats.newBooks} updated=${syncResult.stats.updatedBooks} " + - "remoteUpdates=${syncResult.stats.remoteMetadataUpdates} removed=${syncResult.stats.removedBooks} " + - "migrated=${syncResult.stats.migratedBooks} idMigrations=${syncResult.idMigrations.size}" - ) - - var syncedBooks = nextState.rawLibraryBooks.filter { it.sourceFolder == folder.uriString } - logDesktopFolderSync( - "folder.sidecars.importCheck mode=$mode name=\"${folder.name.folderSyncPreview()}\" books=${syncedBooks.size}" - ) - runCatching { - importAnnotationSidecars(root, syncedBooks) - }.onFailure { error -> - logDesktopFolderSync( - "annotation.import.failed mode=$mode name=\"${folder.name.folderSyncPreview()}\" " + - "root=\"${root.absolutePath.folderSyncPreview()}\" error=${error.folderSyncSummary()}" - ) - } - syncedBooks.forEach { book -> - remoteMetadata[book.id]?.let { metadata -> - runCatching { - importDesktopPdfBookmarksMetadata(book, metadata.bookmarksJson, metadata.lastModifiedTimestamp) - }.onFailure { error -> - logDesktopFolderSync( - "metadata.bookmarks.importFailed book=${book.id} " + - "error=${error.folderSyncSummary()}" - ) - } - } - } - if (!metadataOnly && extractMetadata) { - 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 - logDesktopFolderSync( - "folder.metadataExtraction name=\"${folder.name.folderSyncPreview()}\" " + - "updated=${metadataResult.stats.updatedBooks} covers=${metadataResult.stats.coversUpdated}" - ) - } - syncedBooks.forEach { book -> - saveBookMetadata(book) - if (!metadataOnly) { - savePdfAnnotationSidecar(book) - } - } - logDesktopFolderSync( - "folder.done mode=$mode name=\"${folder.name.folderSyncPreview()}\" " + - "savedCandidates=${syncedBooks.size}" - ) - } - - val result = DesktopLocalFolderSyncResult( - state = nextState, - shelfRefs = nextShelfRefs, - stats = totalStats, - metadataStats = totalMetadataStats, - idMigrations = allMigrations, - removedBookIds = allRemovedBookIds, - failedFolders = failedFolders, - processedFolderUris = processedFolderUris - ) - logDesktopFolderSync( - "sync.done mode=$mode failed=${failedFolders.size} new=${totalStats.newBooks} " + - "updated=${totalStats.updatedBooks} remoteUpdates=${totalStats.remoteMetadataUpdates} " + - "removed=${totalStats.removedBooks} metadataExtracted=${totalMetadataStats.updatedBooks}" - ) - return result - } - - fun saveBookSidecars(book: BookItem) { - saveBookMetadata(book) - savePdfAnnotationSidecar(book) - } - - fun deleteSyncDataFolder(root: File): Boolean { - val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR) - return !syncDir.exists() || syncDir.isDirectory && syncDir.deleteRecursively() - } - - fun saveBookMetadata(book: BookItem) { - val metadata = book.toDesktopFolderBookMetadata() - if (metadata == null) { - logDesktopFolderSync( - "metadata.export.skipClean book=${book.id} title=\"${book.title.orEmpty().folderSyncPreview()}\" " + - "progress=${book.progressPercentage} recent=${book.isRecent} bookmarks=${book.readerBookmarks.size} " + - "highlights=${book.readerHighlights.size}" - ) - return - } - val root = book.sourceFolder?.let(::File)?.takeIf { it.isDirectory } - if (root == null) { - logDesktopFolderSync( - "metadata.export.skipNoFolder book=${book.id} sourceFolder=\"${book.sourceFolder.orEmpty().folderSyncPreview()}\"" - ) - return - } - logDesktopFolderSync( - "metadata.export.request book=${book.id} timestamp=${metadata.lastModifiedTimestamp} " + - "progress=${metadata.progressPercentage} recent=${metadata.isRecent} " + - "root=\"${root.absolutePath.folderSyncPreview()}\"" - ) - saveMetadataToFolder(root, metadata) - } - - fun savePdfAnnotationSidecar(book: BookItem) { - val path = book.path?.takeIf { it.isNotBlank() } - if (path == null) { - logDesktopFolderSync("annotation.export.skipNoPath book=${book.id}") - return - } - if (book.type != FileType.PDF) { - logDesktopFolderSync("annotation.export.skipNonPdf book=${book.id} type=${book.type}") - return - } - val root = book.sourceFolder?.let(::File)?.takeIf { it.isDirectory } - if (root == null) { - logDesktopFolderSync( - "annotation.export.skipNoFolder book=${book.id} sourceFolder=\"${book.sourceFolder.orEmpty().folderSyncPreview()}\"" - ) - return - } - val annotationFile = desktopPdfAnnotationFile(path) - val bookmarkFile = desktopPdfBookmarkFile(path) - val richTextFile = desktopPdfRichTextFile(path) - logDesktopFolderSync( - "annotation.export.check book=${book.id} root=\"${root.absolutePath.folderSyncPreview()}\" " + - "pdfPath=\"${path.folderSyncPreview()}\" hasAnnotations=${annotationFile.isFile} " + - "hasBookmarks=${bookmarkFile.isFile} hasText=${richTextFile.isFile} " + - "localTs=${maxOf(annotationFile.lastModifiedIfFile(), bookmarkFile.lastModifiedIfFile(), richTextFile.lastModifiedIfFile())}" - ) - val data = buildMap { - if (annotationFile.isFile) { - val annotationJson = annotationFile.readText().trim() - desktopPdfAnnotationElementForSync(annotationJson)?.let { annotations -> - put(SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS, 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}" - ) - desktopPdfRichTextElementForSync(richTextJson)?.let { put("text", it) } - } - } - } - if (data.isEmpty()) { - logDesktopFolderSync( - "annotation.export.skipNoLocalData book=${book.id} pdfPath=\"${path.folderSyncPreview()}\"" - ) - SharedPdfRichTextLog.d("desktop.sync.exportSkipNoSidecarData book=${book.id} pdfPath=\"${path.richSyncPreview()}\"") - return - } - val timestamp = maxOf( - annotationFile.lastModifiedIfSyncableAnnotations(), - bookmarkFile.lastModifiedIfFile(), - richTextFile.lastModifiedIfSyncableRichText(), - System.currentTimeMillis() - ) - val dataJson = desktopFolderSyncJson.encodeToString( - JsonElement.serializer(), - JsonObject(data) - ) - logDesktopFolderSync( - "annotation.export.request book=${book.id} timestamp=$timestamp keys=${data.keys.sorted()} " + - "root=\"${root.absolutePath.folderSyncPreview()}\"" - ) - 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() } - .onFail { file, error -> - logDesktopFolderSync( - "folder.scan.skipInaccessible root=\"${root.absolutePath.folderSyncPreview()}\" " + - "path=\"${file.absolutePath.folderSyncPreview()}\" error=${error.folderSyncSummary()}" - ) - } - .filter { file -> - runCatching { file.isFile && file.shouldSyncBookFile() }.getOrDefault(false) - } - .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 = runCatching { file.length() }.getOrDefault(0L), - lastModified = runCatching { file.lastModified() }.getOrDefault(0L) - ) - } - .toList() - } - - private fun readAllMetadata(root: File): Map { - val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR) - if (!syncDir.isDirectory) { - logDesktopFolderSync("metadata.read.noSyncDir root=\"${root.absolutePath.folderSyncPreview()}\"") - return emptyMap() - } - var candidates = 0 - var parsed = 0 - var failed = 0 - val result = syncDir.listFiles().orEmpty() - .asSequence() - .filter { it.isFile && it.isMetadataSidecarCandidate() } - .mapNotNull { file -> - candidates++ - runCatching { SharedFolderBookMetadata.fromJsonString(file.readText()) } - .onSuccess { parsed++ } - .onFailure { error -> - failed++ - logDesktopFolderSync( - "metadata.read.parseFailed file=\"${file.absolutePath.folderSyncPreview()}\" " + - "error=${error.folderSyncSummary()}" - ) - } - .getOrNull() - } - .groupBy { it.bookId } - .mapValues { (_, metadata) -> metadata.maxBy { it.lastModifiedTimestamp } } - .toMap() - logDesktopFolderSync( - "metadata.read.done root=\"${root.absolutePath.folderSyncPreview()}\" " + - "candidates=$candidates parsed=$parsed failed=$failed winners=${result.size}" - ) - return result - } - - 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) { - logDesktopFolderSync( - "metadata.save.skipNewerRemote book=${metadata.bookId} existingTs=${existing.lastModifiedTimestamp} " + - "candidateTs=${metadata.lastModifiedTimestamp} root=\"${root.absolutePath.folderSyncPreview()}\"" - ) - return - } - - val target = File(syncDir, localFolderSyncMetadataFileName(metadata.bookId)) - val temp = File(syncDir, uniqueFolderSyncTempName(localFolderSyncMetadataTempFileName(metadata.bookId))) - runCatching { - temp.writeText(metadata.toJsonString()) - moveReplacing(temp, target) - logDesktopFolderSync( - "metadata.save.done book=${metadata.bookId} timestamp=${metadata.lastModifiedTimestamp} " + - "target=\"${target.absolutePath.folderSyncPreview()}\" bytes=${target.length()}" - ) - }.onFailure { - logDesktopFolderSync( - "metadata.save.failed book=${metadata.bookId} timestamp=${metadata.lastModifiedTimestamp} " + - "target=\"${target.absolutePath.folderSyncPreview()}\" error=${it.folderSyncSummary()}" - ) - runCatching { temp.delete() } - } - } - - private fun resolveMetadataConflicts( - syncDir: File, - bookId: String, - cleanup: Boolean - ): SharedFolderBookMetadata? { - val hashedStem = localFolderSyncSidecarStem(bookId) - val candidates = syncDir.listFiles().orEmpty().filter { file -> - val normalized = file.normalizedSidecarName() - file.isFile && - file.isMetadataSidecarCandidate() && - ( - normalized.matchesJsonSidecarStem(hashedStem) || - normalized.matchesJsonSidecarStem(bookId) - ) - } - if (candidates.isEmpty()) return null - if (candidates.size > 1) { - logDesktopFolderSync( - "metadata.conflicts book=$bookId candidates=${candidates.size} " + - "dir=\"${syncDir.absolutePath.folderSyncPreview()}\" cleanup=$cleanup" - ) - } - - val parsed = candidates.mapNotNull { file -> - val metadata = runCatching { SharedFolderBookMetadata.fromJsonString(file.readText()) } - .onFailure { error -> - logDesktopFolderSync( - "metadata.conflict.parseFailed book=$bookId file=\"${file.absolutePath.folderSyncPreview()}\" " + - "error=${error.folderSyncSummary()}" - ) - } - .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 { file -> - runCatching { file.delete() } - logDesktopFolderSync( - "metadata.conflict.deleteLoser book=$bookId file=\"${file.absolutePath.folderSyncPreview()}\"" - ) - } - val correctName = localFolderSyncMetadataFileName(bookId) - if (winner.first.name != correctName) { - val target = File(syncDir, correctName) - runCatching { moveReplacing(winner.first, target) } - .onSuccess { - logDesktopFolderSync( - "metadata.conflict.renameWinner book=$bookId target=\"${target.absolutePath.folderSyncPreview()}\"" - ) - } - } - } - - return winner.second - } - - private fun preloadAnnotationSidecars(root: File): Map { - val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR) - if (!syncDir.isDirectory) { - logDesktopFolderSync("annotation.read.noSyncDir root=\"${root.absolutePath.folderSyncPreview()}\"") - return emptyMap() - } - var candidates = 0 - var parsed = 0 - val result = syncDir.listFiles().orEmpty() - .asSequence() - .filter { it.isFile && it.isAnnotationSidecarCandidate() } - .mapNotNull { file -> - candidates++ - file.readAnnotationSidecarOrNull(fallbackBookId = file.legacyAnnotationBookIdOrNull()) - ?.also { parsed++ } - } - .groupBy { it.bookId } - .mapValues { (_, sidecars) -> sidecars.maxBy { it.timestamp } } - .toMap() - logDesktopFolderSync( - "annotation.read.done root=\"${root.absolutePath.folderSyncPreview()}\" " + - "candidates=$candidates parsed=$parsed winners=${result.size}" - ) - return result - } - - private fun importAnnotationSidecars(root: File, books: List) { - if (books.isEmpty()) { - logDesktopFolderSync("annotation.import.skipNoBooks root=\"${root.absolutePath.folderSyncPreview()}\"") - return - } - val sidecars = preloadAnnotationSidecars(root) - if (sidecars.isEmpty()) { - logDesktopFolderSync( - "annotation.import.skipNoSidecars root=\"${root.absolutePath.folderSyncPreview()}\" books=${books.size}" - ) - return - } - - books.forEach { book -> - val path = book.path?.takeIf { it.isNotBlank() } - if (path == null) { - logDesktopFolderSync("annotation.import.skipNoPath book=${book.id}") - return@forEach - } - if (book.type != FileType.PDF) { - logDesktopFolderSync("annotation.import.skipNonPdf book=${book.id} type=${book.type}") - return@forEach - } - val sidecar = sidecars[book.id] - if (sidecar == null) { - logDesktopFolderSync( - "annotation.import.skipNoMatchingSidecar book=${book.id} available=${sidecars.keys.size} " + - "root=\"${root.absolutePath.folderSyncPreview()}\"" - ) - return@forEach - } - val annotationFile = desktopPdfAnnotationFile(path) - val bookmarkFile = desktopPdfBookmarkFile(path) - val richTextFile = desktopPdfRichTextFile(path) - val localTimestamp = maxOf( - annotationFile.lastModifiedIfFile(), - bookmarkFile.lastModifiedIfFile(), - richTextFile.lastModifiedIfFile() - ) - logDesktopFolderSync( - "annotation.import.compare book=${book.id} remoteTs=${sidecar.timestamp} localTs=$localTimestamp " + - "keys=${sidecar.data.keys.sorted()}" - ) - if (sidecar.timestamp <= localTimestamp + 1000L) { - logDesktopFolderSync( - "annotation.import.skipOlder book=${book.id} remoteTs=${sidecar.timestamp} localTs=$localTimestamp" - ) - 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) - if (annotations.isEmpty()) { - if (annotationFile.isFile) annotationFile.delete() - logDesktopFolderSync("annotation.import.deleteEmptyAnnotations book=${book.id}") - } else { - annotationFile.parentFile?.mkdirs() - annotationFile.writeText(SharedPdfAnnotationSerializer.encode(annotations)) - annotationFile.setLastModified(sidecar.timestamp) - logDesktopFolderSync( - "annotation.import.writeAnnotations book=${book.id} count=${annotations.size} " + - "file=\"${annotationFile.absolutePath.folderSyncPreview()}\"" - ) - } - } - sidecar.data["bookmarks"]?.let { bookmarks -> - bookmarkFile.parentFile?.mkdirs() - bookmarkFile.writeText(desktopFolderSyncJson.encodeToString(JsonElement.serializer(), bookmarks)) - bookmarkFile.setLastModified(sidecar.timestamp) - logDesktopFolderSync( - "annotation.import.writeBookmarks book=${book.id} file=\"${bookmarkFile.absolutePath.folderSyncPreview()}\"" - ) - } - 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()}\"" - ) - if (richDocument.text.isEmpty() && richDocument.spans.isEmpty()) { - if (richTextFile.isFile) richTextFile.delete() - logDesktopFolderSync("annotation.import.deleteEmptyText book=${book.id}") - } else { - richTextFile.parentFile?.mkdirs() - richTextFile.writeText(SharedPdfRichTextSerializer.encode(richDocument)) - richTextFile.setLastModified(sidecar.timestamp) - logDesktopFolderSync( - "annotation.import.writeText book=${book.id} textLen=${richDocument.text.length} " + - "spans=${richDocument.spans.size} file=\"${richTextFile.absolutePath.folderSyncPreview()}\"" - ) - } - } - } - } - - 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() - if (data == null) { - logDesktopFolderSync( - "annotation.save.skipInvalidPayload book=$bookId timestamp=$timestamp " + - "root=\"${root.absolutePath.folderSyncPreview()}\" payloadLen=${jsonPayload.length}" - ) - return - } - val existing = resolveAnnotationConflicts(syncDir, bookId, cleanup = true) - if (existing != null && existing.timestamp >= timestamp) { - logDesktopFolderSync( - "annotation.save.skipNewerExisting book=$bookId existingTs=${existing.timestamp} " + - "candidateTs=$timestamp root=\"${root.absolutePath.folderSyncPreview()}\" keys=${data.keys.sorted()}" - ) - 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), - "bookId" to JsonPrimitive(bookId), - "timestamp" to JsonPrimitive(timestamp), - "data" to data - ) - ) - val target = File(syncDir, localFolderSyncAnnotationFileName(bookId)) - val temp = File(syncDir, uniqueFolderSyncTempName(localFolderSyncAnnotationTempFileName(bookId))) - runCatching { - temp.writeText(desktopFolderSyncJson.encodeToString(JsonElement.serializer(), wrapper)) - moveReplacing(temp, target) - logDesktopFolderSync( - "annotation.save.done book=$bookId timestamp=$timestamp keys=${data.keys.sorted()} " + - "target=\"${target.absolutePath.folderSyncPreview()}\" bytes=${target.length()}" - ) - if (data.containsKey("text")) { - SharedPdfRichTextLog.d( - "desktop.sync.saveSidecar book=$bookId timestamp=$timestamp " + - "target=\"${target.absolutePath.richSyncPreview()}\"" - ) - } - }.onFailure { - logDesktopFolderSync( - "annotation.save.failed book=$bookId timestamp=$timestamp keys=${data.keys.sorted()} " + - "target=\"${target.absolutePath.folderSyncPreview()}\" error=${it.folderSyncSummary()}" - ) - 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 parsed = syncDir.listFiles().orEmpty() - .filter { file -> file.isFile && file.isAnnotationSidecarCandidate() } - .mapNotNull { file -> - file.readAnnotationSidecarOrNull(fallbackBookId = file.legacyAnnotationBookIdOrNull()) - ?.takeIf { it.bookId == bookId } - ?.let { file to it } - } - if (parsed.isEmpty()) return null - if (parsed.size > 1) { - logDesktopFolderSync( - "annotation.conflicts book=$bookId candidates=${parsed.size} " + - "dir=\"${syncDir.absolutePath.folderSyncPreview()}\" cleanup=$cleanup" - ) - } - val winner = parsed.maxByOrNull { it.second.timestamp } ?: return null - - if (cleanup) { - parsed.map { it.first } - .filterNot { it == winner.first } - .forEach { file -> - runCatching { file.delete() } - logDesktopFolderSync( - "annotation.conflict.deleteLoser book=$bookId file=\"${file.absolutePath.folderSyncPreview()}\"" - ) - } - val correctName = localFolderSyncAnnotationFileName(bookId) - if (winner.first.name != correctName) { - val target = File(syncDir, correctName) - runCatching { moveReplacing(winner.first, target) } - .onSuccess { - logDesktopFolderSync( - "annotation.conflict.renameWinner book=$bookId target=\"${target.absolutePath.folderSyncPreview()}\"" - ) - } - } - } - - return winner.second - } -} - -private data class AnnotationSidecar( - val bookId: String, - 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.isMetadataSidecarCandidate(): Boolean { - val fileName = name - if (fileName.contains(LOCAL_FOLDER_ANNOTATION_SUFFIX)) return false - if (fileName.contains(".tmp") || fileName.contains(".syncthing.")) return false - return fileName.endsWith(".json") || fileName.contains(".sync-conflict") -} - -private fun File.isAnnotationSidecarCandidate(): Boolean { - val fileName = name - if (!fileName.contains(LOCAL_FOLDER_ANNOTATION_SUFFIX)) return false - if (fileName.contains(".tmp") || fileName.contains(".syncthing.")) return false - return fileName.endsWith(".json") || fileName.contains(".sync-conflict") -} - -private fun File.legacyAnnotationBookIdOrNull(): String? { - var candidate = name - if (!isAnnotationSidecarCandidate()) 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) - } - val normalized = candidate.removePrefix(".") - if (normalized.startsWith(LOCAL_FOLDER_SIDECAR_HASH_PREFIX)) return null - return normalized.takeIf { it.isNotBlank() } -} - -private fun File.normalizedSidecarName(): String { - return name.removePrefix(".") -} - -private fun String.matchesJsonSidecarStem(stem: String): Boolean { - return this == "$stem.json" || - startsWith("$stem.sync-conflict") || - startsWith("$stem.json.sync-conflict") -} - -private fun File.readAnnotationSidecarOrNull(fallbackBookId: String? = null): AnnotationSidecar? { - return runCatching { - val root = desktopFolderSyncJson.parseToJsonElement(readText()).jsonObject - val bookId = root["bookId"] - ?.takeUnless { it is JsonNull } - ?.jsonPrimitive - ?.contentOrNull - ?: fallbackBookId - ?: error("Missing annotation sidecar bookId") - val timestamp = root["timestamp"]?.jsonPrimitive?.longOrNull ?: 0L - val data = root["data"]?.jsonObjectOrNull() ?: error("Missing annotation sidecar data") - AnnotationSidecar(bookId = bookId, timestamp = timestamp, data = data) - }.onFailure { error -> - logDesktopFolderSync( - "annotation.read.parseFailed file=\"${absolutePath.folderSyncPreview()}\" error=${error.folderSyncSummary()}" - ) - }.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 File.hasSyncablePdfAnnotations(): Boolean { - return isFile && desktopPdfAnnotationElementForSync(readText()) != null -} - -private fun File.lastModifiedIfSyncableAnnotations(): Long { - return if (hasSyncablePdfAnnotations()) lastModified() else 0L -} - -private fun File.hasSyncablePdfRichText(): Boolean { - return isFile && desktopPdfRichTextElementForSync(readText()) != null -} - -private fun File.lastModifiedIfSyncableRichText(): Long { - return if (hasSyncablePdfRichText()) lastModified() else 0L -} - -private fun BookItem.toDesktopFolderBookMetadata(): SharedFolderBookMetadata? { - val base = toSharedFolderBookMetadata() - val pdfBookmarksJson = desktopPdfBookmarksMetadataJson(this) - if (base == null && pdfBookmarksJson == null) return null - - val timestamp = maxOf( - base?.lastModifiedTimestamp ?: 0L, - desktopPdfBookmarkMetadataTimestamp(this), - this.timestamp - ) - - return (base ?: SharedFolderBookMetadata( - bookId = id, - title = null, - author = null, - displayName = displayName, - type = type.name, - lastChapterIndex = readerPosition?.chapterIndex, - lastPage = readerPosition?.pageIndex ?: lastPageIndex, - lastPositionCfi = readerPosition?.toStablePositionCfi(), - progressPercentage = progressPercentage ?: 0f, - isRecent = isRecent, - lastModifiedTimestamp = timestamp, - bookmarksJson = null, - locatorBlockIndex = readerPosition?.blockIndex, - locatorCharOffset = readerPosition?.charOffset, - customName = null, - highlightsJson = null - )).copy( - lastModifiedTimestamp = timestamp, - bookmarksJson = pdfBookmarksJson ?: base?.bookmarksJson - ) -} - -private fun uniqueFolderSyncTempName(baseName: String): String { - val stem = baseName.removeSuffix(".tmp") - val nonce = "${System.currentTimeMillis()}_${Thread.currentThread().id}_${System.nanoTime().toString(36)}" - return "$stem.$nonce.tmp" -} - -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/dueattendant149/bookreader/reader/desktop/DesktopLogFormatting.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLogFormatting.kt deleted file mode 100644 index 95d9de9..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLogFormatting.kt +++ /dev/null @@ -1,8 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -internal fun String.logPreview(maxLength: Int = 96): 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/dueattendant149/bookreader/reader/desktop/DesktopOpdsCoverImage.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopOpdsCoverImage.kt deleted file mode 100644 index 7a984be..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopOpdsCoverImage.kt +++ /dev/null @@ -1,106 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -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.graphics.ImageBitmap -import androidx.compose.ui.graphics.toComposeImageBitmap -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.font.FontWeight -import org.dueattendant149.bookreader.shared.opds.OpdsCatalog -import org.dueattendant149.bookreader.shared.opds.OpdsEntry -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import org.jetbrains.skia.Image as SkiaImage - -@Composable -internal fun DesktopOpdsCoverImage( - entry: OpdsEntry, - catalog: OpdsCatalog?, - modifier: Modifier = Modifier -) { - val coverUrl = entry.coverUrl?.takeIf { it.isNotBlank() } - val cacheKey = remember(coverUrl, catalog?.id, catalog?.username) { - coverUrl?.let { DesktopOpdsCoverImageCache.cacheKey(it, catalog) } - } - var bitmap by remember(cacheKey) { mutableStateOf(cacheKey?.let { DesktopOpdsCoverImageCache.peek(it) }) } - - LaunchedEffect(cacheKey) { - bitmap = if (coverUrl == null || cacheKey == null) { - null - } else { - withContext(Dispatchers.IO) { - DesktopOpdsCoverImageCache.load(cacheKey, coverUrl, catalog) - } - } - } - - Box( - modifier = modifier - .clip(MaterialTheme.shapes.small) - .background(MaterialTheme.colorScheme.surfaceVariant), - contentAlignment = Alignment.Center - ) { - val imageBitmap = bitmap - if (imageBitmap != null) { - Image( - bitmap = imageBitmap, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.matchParentSize() - ) - } else { - Text( - text = entry.title.take(1).uppercase(), - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } -} - -private object DesktopOpdsCoverImageCache { - private const val MaxEntries = 160 - - private val cache = object : LinkedHashMap(MaxEntries, 0.75f, true) { - override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean { - return size > MaxEntries - } - } - - fun cacheKey(url: String, catalog: OpdsCatalog?): String { - return "${catalog?.id.orEmpty()}|${catalog?.username.orEmpty()}|$url" - } - - fun peek(cacheKey: String): ImageBitmap? { - return synchronized(cache) { cache[cacheKey] } - } - - fun load(cacheKey: String, url: String, catalog: OpdsCatalog?): ImageBitmap? { - peek(cacheKey)?.let { return it } - val bitmap = runCatching { - DesktopOpdsHttp.fetchBytes(url, catalog).toImageBitmap() - }.getOrNull() ?: return null - - synchronized(cache) { - cache[cacheKey] = bitmap - } - return bitmap - } - - private fun ByteArray.toImageBitmap(): ImageBitmap? { - return runCatching { SkiaImage.makeFromEncoded(this).toComposeImageBitmap() }.getOrNull() - } -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopOpdsRepository.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopOpdsRepository.kt deleted file mode 100644 index f694c4e..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopOpdsRepository.kt +++ /dev/null @@ -1,288 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import org.dueattendant149.bookreader.shared.opds.OpdsAcquisition -import org.dueattendant149.bookreader.shared.opds.OpdsCatalog -import org.dueattendant149.bookreader.shared.opds.OpdsEntry -import org.dueattendant149.bookreader.shared.opds.OpdsFeed -import org.dueattendant149.bookreader.shared.opds.SharedOpdsCatalogs -import org.dueattendant149.bookreader.shared.opds.SharedOpdsDownloadNamer -import org.dueattendant149.bookreader.shared.opds.SharedOpdsParser -import org.dueattendant149.bookreader.shared.opds.SharedOpdsRepository -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.io.Closeable -import java.io.File -import java.net.URI -import java.net.http.HttpClient -import java.net.http.HttpRequest -import java.net.http.HttpResponse -import java.security.MessageDigest -import java.time.Duration -import java.util.Base64 -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 response = send(url, username, password, HttpResponse.BodyHandlers.ofString()) - return DesktopOpdsTextResponse(response.statusCode(), response.body().orEmpty()) - } - - fun fetchStream(url: String, username: String?, password: String?): DesktopOpdsStreamResponse { - val response = send(url, username, password, HttpResponse.BodyHandlers.ofInputStream()) - return DesktopOpdsStreamResponse(response.statusCode(), response.headers(), response.body()) - } - - fun fetchBytes(url: String, catalog: OpdsCatalog?): ByteArray { - val response = send(url, catalog?.username, catalog?.password, HttpResponse.BodyHandlers.ofByteArray()) - if (response.statusCode() !in 200..299) { - error("HTTP ${response.statusCode()}") - } - return response.body() - } - - private fun send( - url: String, - username: String?, - password: String?, - bodyHandler: HttpResponse.BodyHandler - ): HttpResponse { - ensureNetworkAccess() - val uri = URI(url.trim()) - val response = client().send(request(uri).build(), bodyHandler) - val challenge = response.headers().firstValue("www-authenticate").orElse(null) - val authorization = if (response.statusCode() == 401) { - authorizationHeaderForChallenge( - challenge = challenge, - url = uri.toString(), - username = username, - password = password - ) - } else { - null - } - if (authorization == null) return response - - (response.body() as? Closeable)?.close() - return client().send( - request(uri) - .header("Authorization", authorization) - .build(), - bodyHandler - ) - } - - private fun request(uri: URI): HttpRequest.Builder { - return HttpRequest.newBuilder(uri) - .timeout(Duration.ofSeconds(45)) - .header("User-Agent", "EpistemeReader/1.0 (Desktop)") - } - - private fun client(): HttpClient { - return HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(20)) - .followRedirects(HttpClient.Redirect.NORMAL) - .build() - } - - private fun ensureNetworkAccess() { - check(currentDesktopBuildProfile().featurePolicy.networkAccess) { - "Network access is disabled in this desktop build." - } - } - - internal fun authorizationHeaderForChallenge( - challenge: String?, - url: String, - username: String?, - password: String?, - method: String = "GET", - cnonce: String = UUID.randomUUID().toString().replace("-", ""), - nonceCount: String = "00000001" - ): String? { - if (challenge.isNullOrBlank() || username.isNullOrBlank() || password.isNullOrBlank()) return null - return when { - challenge.startsWith("Basic", ignoreCase = true) -> { - val credentials = "$username:$password".toByteArray(Charsets.ISO_8859_1) - "Basic ${Base64.getEncoder().encodeToString(credentials)}" - } - - challenge.startsWith("Digest", ignoreCase = true) -> { - val params = parseAuthParams(challenge) - val realm = params["realm"].orEmpty() - val nonce = params["nonce"] ?: return null - val qop = params["qop"] - ?.split(',') - ?.map { it.trim().trim('"') } - ?.firstOrNull { it.equals("auth", ignoreCase = true) } - val opaque = params["opaque"] - val uri = URI(url) - val requestUri = buildString { - append(uri.rawPath.takeIf { !it.isNullOrBlank() } ?: "/") - uri.rawQuery?.let { append('?').append(it) } - } - val ha1 = md5("$username:$realm:$password") - val ha2 = md5("${method.uppercase()}:$requestUri") - val responseHash = if (qop != null) { - md5("$ha1:$nonce:$nonceCount:$cnonce:$qop:$ha2") - } else { - md5("$ha1:$nonce:$ha2") - } - - buildString { - append("Digest username=\"${username.escapeAuthQuote()}\", ") - append("realm=\"${realm.escapeAuthQuote()}\", ") - append("nonce=\"${nonce.escapeAuthQuote()}\", ") - append("uri=\"${requestUri.escapeAuthQuote()}\", ") - append("response=\"$responseHash\"") - if (qop != null) { - append(", qop=$qop, nc=$nonceCount, cnonce=\"${cnonce.escapeAuthQuote()}\"") - } - if (opaque != null) { - append(", opaque=\"${opaque.escapeAuthQuote()}\"") - } - } - } - - else -> null - } - } - - private fun parseAuthParams(challenge: String): Map { - return Regex("""(\w+)=(?:"([^"]*)"|([^,\s]+))""") - .findAll(challenge) - .associate { match -> - match.groupValues[1].lowercase() to (match.groupValues[2].ifBlank { match.groupValues[3] }) - } - } - - private fun md5(input: String): String { - val bytes = MessageDigest.getInstance("MD5").digest(input.toByteArray()) - return bytes.joinToString("") { "%02x".format(it) } - } - - private fun String.escapeAuthQuote(): String { - return replace("\\", "\\\\").replace("\"", "\\\"") - } -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPaidAiAdapter.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPaidAiAdapter.kt deleted file mode 100644 index 95a9d08..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPaidAiAdapter.kt +++ /dev/null @@ -1,341 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import org.dueattendant149.bookreader.shared.AiAdapter -import org.dueattendant149.bookreader.shared.AiDefinitionResult -import org.dueattendant149.bookreader.shared.RecapResult -import org.dueattendant149.bookreader.shared.SummarizationResult -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.buildJsonArray -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import java.io.InputStream -import java.net.HttpURLConnection -import java.net.URL -import kotlin.math.ceil - -internal class DesktopPaidAiAdapter( - private val config: DesktopCloudConfig, - private val networkAccess: () -> Boolean, - private val hideReaderAiFeatures: () -> Boolean, - private val currentAuthToken: suspend () -> String?, - private val currentSignedIn: () -> Boolean, - private val currentIsProUser: () -> Boolean, - private val currentCredits: () -> Int, - private val onUsageReported: (DesktopPaidAiUsage) -> Unit = {} -) : AiAdapter { - override val isAvailable: Boolean - get() = networkAccess() && - config.isAiWorkerConfigured && - !hideReaderAiFeatures() - - override suspend fun define(text: String, context: String?): AiDefinitionResult { - val trimmed = text.trim() - if (trimmed.isBlank()) return AiDefinitionResult(error = "There is no text to define.") - val multiWord = wordCount(trimmed) > 1 - if (multiWord && !currentSignedIn()) { - return AiDefinitionResult(error = "Sign in with Google to use multi-word smart dictionary.") - } - if (multiWord && !currentIsProUser()) { - return AiDefinitionResult(error = "Multi-word smart dictionary requires Pro. Pro can only be purchased from the Android app.") - } - val result = callWorker( - path = "/define", - body = buildJsonObject { put("text", JsonPrimitive(trimmed.take(2400))) }.toString(), - authRequired = multiWord - ) - return AiDefinitionResult(definition = result.getOrNull()?.text, error = result.exceptionOrNull()?.message) - } - - override suspend fun defineStreaming( - text: String, - context: String?, - onUpdate: (String) -> Unit - ): AiDefinitionResult { - val trimmed = text.trim() - if (trimmed.isBlank()) return AiDefinitionResult(error = "There is no text to define.") - val multiWord = wordCount(trimmed) > 1 - if (multiWord && !currentSignedIn()) { - return AiDefinitionResult(error = "Sign in with Google to use multi-word smart dictionary.") - } - if (multiWord && !currentIsProUser()) { - return AiDefinitionResult(error = "Multi-word smart dictionary requires Pro. Pro can only be purchased from the Android app.") - } - val result = callWorker( - path = "/define", - body = buildJsonObject { put("text", JsonPrimitive(trimmed.take(2400))) }.toString(), - authRequired = multiWord, - onChunk = onUpdate - ) - return AiDefinitionResult(definition = result.getOrNull()?.text, error = result.exceptionOrNull()?.message) - } - - override suspend fun summarize(text: String): SummarizationResult { - val trimmed = text.trim() - if (trimmed.isBlank()) return SummarizationResult(error = "There is no text to summarize.") - val gate = paidGenerationGate(freeProSummaryAllowed = true) - if (gate != null) return SummarizationResult(error = gate) - val result = callWorker( - path = "/summarize", - body = buildJsonObject { - put("content_type", JsonPrimitive("text")) - put("data", JsonPrimitive(trimmed)) - }.toString(), - authRequired = true - ) - val response = result.getOrNull() - return SummarizationResult( - summary = response?.text, - error = result.exceptionOrNull()?.message, - cost = response?.cost, - freeRemaining = response?.freeRemaining - ) - } - - override suspend fun summarizeStreaming( - text: String, - onUsageReceived: (cost: Double?, freeRemaining: Int?) -> Unit, - onUpdate: (String) -> Unit - ): SummarizationResult { - val trimmed = text.trim() - if (trimmed.isBlank()) return SummarizationResult(error = "There is no text to summarize.") - val gate = paidGenerationGate(freeProSummaryAllowed = true) - if (gate != null) return SummarizationResult(error = gate) - val result = callWorker( - path = "/summarize", - body = buildJsonObject { - put("content_type", JsonPrimitive("text")) - put("data", JsonPrimitive(trimmed)) - }.toString(), - authRequired = true, - onChunk = onUpdate, - onUsageReceived = onUsageReceived - ) - val response = result.getOrNull() - return SummarizationResult( - summary = response?.text, - error = result.exceptionOrNull()?.message, - cost = response?.cost, - freeRemaining = response?.freeRemaining - ) - } - - override suspend fun recap(textBeforeCurrentLocation: String): RecapResult { - return recapWithContext(emptyList(), textBeforeCurrentLocation) - } - - suspend fun recapWithContext(pastSummaries: List, currentText: String): RecapResult { - val trimmed = currentText.trim() - if (trimmed.isBlank()) return RecapResult(error = "There is no reading context for a recap.") - val gate = paidGenerationGate(freeProSummaryAllowed = false) - if (gate != null) return RecapResult(error = gate) - val result = callWorker( - path = "/recap", - body = buildJsonObject { - put( - "past_summaries", - buildJsonArray { - pastSummaries.filter { it.isNotBlank() }.forEach { summary -> - add(JsonPrimitive(summary)) - } - } - ) - put("current_text", JsonPrimitive(trimmed)) - }.toString(), - authRequired = true - ) - val response = result.getOrNull() - return RecapResult( - recap = response?.text, - error = result.exceptionOrNull()?.message, - cost = response?.cost, - freeRemaining = response?.freeRemaining - ) - } - - private fun paidGenerationGate(freeProSummaryAllowed: Boolean): String? { - if (!config.isAiWorkerConfigured) return "Desktop AI is not configured." - if (!networkAccess()) return "AI features are unavailable in this desktop build." - if (hideReaderAiFeatures()) return "Reader AI features are hidden." - if (!currentSignedIn()) return "Sign in with Google to use this AI feature." - if (!(freeProSummaryAllowed && currentIsProUser()) && currentCredits() <= 0) { - return "This action needs credits. Pro and credits can only be purchased from the Android app." - } - return null - } - - private suspend fun callWorker( - path: String, - body: String, - authRequired: Boolean, - onChunk: (String) -> Unit = {}, - onUsageReceived: (cost: Double?, freeRemaining: Int?) -> Unit = { _, _ -> } - ): Result = withContext(Dispatchers.IO) { - if (!isAvailable) return@withContext Result.failure(IllegalStateException("AI features are unavailable.")) - val token = currentAuthToken() - if (authRequired && token.isNullOrBlank()) { - return@withContext Result.failure(IllegalStateException("Sign in with Google to use this AI feature.")) - } - runCatching { - val url = URL(config.aiWorkerUrl.removeSuffix("/") + path) - val connection = (url.openConnection() as HttpURLConnection).apply { - requestMethod = "POST" - setRequestProperty("Content-Type", "application/json; charset=UTF-8") - setRequestProperty("Accept", "application/json") - if (!token.isNullOrBlank()) setRequestProperty("Authorization", "Bearer $token") - connectTimeout = 15_000 - readTimeout = 120_000 - doOutput = true - doInput = true - } - try { - connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) } - val responseCode = connection.responseCode - val stream = if (responseCode in 200..299) connection.inputStream else connection.errorStream - if (responseCode in 200..299) { - val parsed = readWorkerStream( - stream = stream, - onChunk = onChunk, - onUsageReceived = onUsageReceived, - onUsageReported = onUsageReported - ) - if (parsed.text.isBlank()) throw IllegalStateException("The AI service returned an empty response.") - return@runCatching parsed - } - val responseText = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty() - if (connection.responseCode == 402 || responseText.contains("INSUFFICIENT_CREDITS")) { - onUsageReported(DesktopPaidAiUsage()) - throw IllegalStateException("Out of credits. Pro and credits can only be purchased from the Android app.") - } - if (connection.responseCode == 401) { - throw IllegalStateException("Sign in again to use this AI feature.") - } - if (connection.responseCode == 403 && responseText.contains("MULTI_WORD_REQUIRES_PRO")) { - throw IllegalStateException("Multi-word smart dictionary requires Pro. Pro can only be purchased from the Android app.") - } - if (connection.responseCode !in 200..299) { - throw IllegalStateException(workerErrorMessage(responseText) ?: "AI request failed: HTTP ${connection.responseCode}") - } - error("Unreachable AI response state.") - } finally { - connection.disconnect() - } - } - } -} - -internal data class DesktopPaidAiUsage( - val cost: Double? = null, - val freeRemaining: Int? = null -) - -internal fun desktopCreditsAfterPaidAiUsage(currentCredits: Int, cost: Double?): Int { - val deducted = cost - ?.takeIf { it.isFinite() && it > 0.0 } - ?.let { ceil(it).toInt() } - ?: return currentCredits - return (currentCredits - deducted).coerceAtLeast(0) -} - -private data class DesktopPaidAiResponse( - val text: String, - val cost: Double? = null, - val freeRemaining: Int? = null -) - -private val DesktopPaidAiJson = Json { ignoreUnknownKeys = true } - -private fun readWorkerStream( - stream: InputStream?, - onChunk: (String) -> Unit, - onUsageReceived: (cost: Double?, freeRemaining: Int?) -> Unit, - onUsageReported: (DesktopPaidAiUsage) -> Unit -): DesktopPaidAiResponse { - val output = StringBuilder() - var cost: Double? = null - var freeRemaining: Int? = null - var paidUsageReported = false - var freeUsageReported = false - stream?.bufferedReader(Charsets.UTF_8)?.useLines { lines -> - lines.forEach { line -> - val parsed = try { - parseWorkerStreamLine(line) - } catch (error: IllegalStateException) { - if (desktopPaidAiShouldRefreshAccountAfterError(error)) { - onUsageReported(DesktopPaidAiUsage()) - } - throw error - } ?: return@forEach - parsed.cost?.let { cost = it } - parsed.freeRemaining?.let { freeRemaining = it } - if (parsed.cost != null || parsed.freeRemaining != null) { - onUsageReceived(parsed.cost, parsed.freeRemaining) - if (parsed.cost != null && !paidUsageReported) { - paidUsageReported = true - onUsageReported(DesktopPaidAiUsage(cost = parsed.cost, freeRemaining = parsed.freeRemaining)) - } else if (parsed.freeRemaining != null && !freeUsageReported) { - freeUsageReported = true - onUsageReported(DesktopPaidAiUsage(freeRemaining = parsed.freeRemaining)) - } - } - parsed.chunk?.let { chunk -> - output.append(chunk) - onChunk(chunk) - } - } - } - return DesktopPaidAiResponse(text = output.toString().trim(), cost = cost, freeRemaining = freeRemaining) -} - -private fun parseWorkerStreamLine(line: String): DesktopPaidAiStreamLine? { - val trimmed = line.trim() - if (trimmed.isBlank()) return null - val parsed = runCatching { DesktopPaidAiJson.parseToJsonElement(trimmed).jsonObject }.getOrNull() ?: return null - parsed.get("error")?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }?.let { error -> - throw IllegalStateException(workerErrorMessage(error) ?: error) - } - return DesktopPaidAiStreamLine( - chunk = parsed.get("chunk")?.jsonPrimitive?.contentOrNull, - cost = parsed.get("cost_deducted")?.jsonPrimitive?.contentOrNull?.toDoubleOrNull(), - freeRemaining = parsed.get("free_summaries_remaining")?.jsonPrimitive?.contentOrNull?.toIntOrNull() - ) -} - -private data class DesktopPaidAiStreamLine( - val chunk: String? = null, - val cost: Double? = null, - val freeRemaining: Int? = null -) - -private fun desktopPaidAiShouldRefreshAccountAfterError(error: Throwable): Boolean { - val details = generateSequence(error) { it.cause } - .joinToString(" ") { it.message.orEmpty() } - return details.contains("Out of credits", ignoreCase = true) || - details.contains("INSUFFICIENT_CREDITS", ignoreCase = true) || - details.contains("402", ignoreCase = true) -} - -private fun workerErrorMessage(errorBody: String): String? { - return when { - errorBody.contains("INSUFFICIENT_CREDITS") -> "Out of credits. Pro and credits can only be purchased from the Android app." - errorBody.contains("SUMMARY_LIMIT") || - (errorBody.contains("free summar", ignoreCase = true) && errorBody.contains("limit", ignoreCase = true)) -> - "Free summaries are used up for today. More summaries need credits, and credits can only be purchased from the Android app." - errorBody.contains("MULTI_WORD_REQUIRES_PRO") -> "Multi-word smart dictionary requires Pro. Pro can only be purchased from the Android app." - errorBody.contains("Authentication required") -> "Sign in with Google to use this AI feature." - else -> runCatching { - DesktopPaidAiJson.parseToJsonElement(errorBody) - .jsonObject["error"] - ?.jsonPrimitive - ?.contentOrNull - }.getOrNull() - } -} - -private fun wordCount(text: String): Int { - return text.trim().split(Regex("\\s+")).count { it.isNotBlank() } -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfAnnotationUi.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfAnnotationUi.kt deleted file mode 100644 index 5b6d718..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfAnnotationUi.kt +++ /dev/null @@ -1,880 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -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.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -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.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ContentCopy -import androidx.compose.material.icons.filled.Search -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Slider -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.draw.clip -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.luminance -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import org.dueattendant149.bookreader.shared.pdf.DEFAULT_SHARED_PDF_COMMENT_AUTHOR -import org.dueattendant149.bookreader.shared.pdf.PdfAnnotationKind -import org.dueattendant149.bookreader.shared.pdf.PdfInkTool -import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotation -import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationComment -import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationDefaults -import org.dueattendant149.bookreader.shared.pdf.SharedPdfEmbeddedAnnotation -import org.dueattendant149.bookreader.shared.pdf.SharedPdfHighlighterPalette -import org.dueattendant149.bookreader.shared.pdf.pdfCommentChildren -import org.dueattendant149.bookreader.shared.pdf.sharedPdfStrokePercent -import org.dueattendant149.bookreader.shared.pdf.sharedPdfStrokeWidthRange -import org.dueattendant149.bookreader.shared.pdf.sharedPdfTextStyle -import org.dueattendant149.bookreader.shared.pdf.visiblePdfAnnotationComments -import org.dueattendant149.bookreader.shared.pdf.withoutPdfCommentThread -import org.dueattendant149.bookreader.shared.pdf.withSharedPdfTextStyle -import org.dueattendant149.bookreader.shared.ui.SharedHsvColorPickerDialog -import org.dueattendant149.bookreader.shared.ui.SharedPdfTextStyleControls -import org.dueattendant149.bookreader.shared.ui.SharedStableOutlinedTextField -import org.dueattendant149.bookreader.shared.ui.readerString -import java.text.DateFormat -import java.util.Date -import java.util.UUID - -internal val DesktopPdfAnnotationTools = listOf( - PdfInkTool.PEN, - PdfInkTool.FOUNTAIN_PEN, - PdfInkTool.PENCIL, - PdfInkTool.HIGHLIGHTER, - PdfInkTool.HIGHLIGHTER_ROUND, - PdfInkTool.TEXT, - PdfInkTool.ERASER -) - -private enum class DesktopPdfAnnotationSheetSection { - NOTE, - COMMENTS -} - -@Composable -internal fun DesktopPdfAnnotationEditor( - annotation: SharedPdfAnnotation, - onUpdate: (SharedPdfAnnotation) -> Unit, - onDelete: () -> Unit, - onClose: () -> Unit, - onCopy: () -> Unit, - showSearch: Boolean, - highlighterPalette: List = SharedPdfHighlighterPalette.defaultColors, - onHighlighterPaletteChange: (SharedPdfHighlighterPalette) -> Unit = {}, - onSearch: () -> Unit -) { - val highlighterColors = remember(highlighterPalette) { - SharedPdfHighlighterPalette(highlighterPalette).sanitized().colors - } - var editingHighlighterSlot by remember(annotation.id, highlighterColors) { mutableStateOf(null) } - var editingHighlighterDraftColors by remember(annotation.id, highlighterColors) { - mutableStateOf>(emptyList()) - } - val isHighlighterAnnotation = annotation.kind == PdfAnnotationKind.HIGHLIGHT || - annotation.tool == PdfInkTool.HIGHLIGHTER || - annotation.tool == PdfInkTool.HIGHLIGHTER_ROUND - var selectedSection by remember(annotation.id) { mutableStateOf(DesktopPdfAnnotationSheetSection.NOTE) } - var commentText by remember(annotation.id) { mutableStateOf("") } - var replyTargetId by remember(annotation.id) { mutableStateOf(null) } - var editingCommentId by remember(annotation.id) { mutableStateOf(null) } - var commentAuthor by remember(annotation.id) { - mutableStateOf( - annotation.comments - .lastOrNull { it.author.isNotBlank() } - ?.author - ?: DEFAULT_SHARED_PDF_COMMENT_AUTHOR - ) - } - - fun updateComments(nextComments: List) { - onUpdate(annotation.copy(comments = nextComments)) - } - - fun highlighterDraftColors(): List { - return editingHighlighterDraftColors.ifEmpty { highlighterColors } - } - - fun updateHighlighterDraft(slotIndex: Int, color: Color): List { - val nextColors = highlighterDraftColors().toMutableList() - if (slotIndex in nextColors.indices) { - nextColors[slotIndex] = color.copy(alpha = SharedPdfHighlighterPalette.DefaultAlpha / 255f).toArgb() - editingHighlighterDraftColors = nextColors - } - return nextColors - } - - fun openHighlighterEditor(slotIndex: Int) { - if (editingHighlighterSlot == null) { - editingHighlighterDraftColors = highlighterColors - } - editingHighlighterSlot = slotIndex - } - - Surface( - color = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(12.dp), - modifier = Modifier.fillMaxWidth() - ) { - Column(modifier = Modifier.padding(2.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - readerString("desktop_selected_annotation_format", "Selected %1\$s", annotation.desktopLabel()), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.weight(1f) - ) - TextButton(onClick = onClose) { - Text(readerString("action_close", "Close")) - } - } - Text( - readerString("pdf_page_short", "Page %1\$d", annotation.pageIndex + 1), - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall - ) - if (annotation.text.isNotBlank()) { - Surface( - color = Color(annotation.colorArgb).copy(alpha = 0.10f), - shape = RoundedCornerShape(12.dp), - border = BorderStroke(1.dp, Color(annotation.colorArgb).copy(alpha = 0.28f)), - modifier = Modifier.fillMaxWidth() - ) { - Row(modifier = Modifier.heightIn(min = 72.dp)) { - Box( - modifier = Modifier - .width(6.dp) - .fillMaxHeight() - .background(Color(annotation.colorArgb)) - ) - Text( - "\"${annotation.text}\"", - style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic), - maxLines = 4, - overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.88f), - modifier = Modifier.padding(14.dp) - ) - } - } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically - ) { - DesktopBottomSheetToolButton( - icon = Icons.Default.ContentCopy, - label = readerString("action_copy", "Copy"), - onClick = onCopy - ) - if (showSearch) { - DesktopBottomSheetToolButton( - icon = Icons.Default.Search, - label = readerString("action_search", "Search"), - onClick = onSearch - ) - } - } - } - if (annotation.kind == PdfAnnotationKind.TEXT) { - SharedStableOutlinedTextField( - value = annotation.text, - onValueChange = { onUpdate(annotation.copy(text = it)) }, - label = { Text(readerString("desktop_text_note", "Text note")) }, - minLines = 2, - modifier = Modifier.fillMaxWidth(), - selectionKey = annotation.id - ) - SharedPdfTextStyleControls( - style = annotation.sharedPdfTextStyle(), - onStyleChange = { onUpdate(annotation.withSharedPdfTextStyle(it)) } - ) - } - if (annotation.kind != PdfAnnotationKind.TEXT) { - val palette = if (isHighlighterAnnotation) { - highlighterColors - } else { - SharedPdfAnnotationDefaults.penPalette - } - Text(readerString("desktop_color", "Color"), style = MaterialTheme.typography.labelLarge) - Row( - modifier = Modifier.horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - palette.forEachIndexed { _, argb -> - Surface( - modifier = Modifier - .size(26.dp) - .clickable { - onUpdate(annotation.copy(colorArgb = argb)) - }, - color = Color(argb), - shape = RoundedCornerShape(13.dp), - content = {} - ) - } - if (isHighlighterAnnotation) { - Box( - modifier = Modifier - .size(30.dp) - .clip(RoundedCornerShape(15.dp)) - .background( - Brush.sweepGradient( - listOf( - Color.Red, - Color.Yellow, - Color.Green, - Color.Cyan, - Color.Blue, - Color.Magenta, - Color.Red - ) - ) - ) - .border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.32f), RoundedCornerShape(15.dp)) - .clickable { - openHighlighterEditor( - highlighterColors - .indexOf(annotation.colorArgb) - .takeIf { it >= 0 } - ?: 0 - ) - } - ) - } - } - DesktopPdfAnnotationSheetTabs( - selectedSection = selectedSection, - commentCount = annotation.comments.count { it.contents.isNotBlank() }, - onSectionChange = { selectedSection = it } - ) - if (selectedSection == DesktopPdfAnnotationSheetSection.NOTE) { - SharedStableOutlinedTextField( - value = annotation.note.orEmpty(), - onValueChange = { note -> onUpdate(annotation.copy(note = note.takeIf { it.isNotBlank() })) }, - label = { Text(readerString("label_note", "Note")) }, - minLines = 3, - maxLines = 5, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - selectionKey = annotation.id - ) - } else { - DesktopPdfHighlightCommentsEditor( - comments = annotation.comments, - commentText = commentText, - commentAuthor = commentAuthor, - replyTargetId = replyTargetId, - editingCommentId = editingCommentId, - onCommentTextChange = { commentText = it }, - onCommentAuthorChange = { commentAuthor = it }, - onReply = { comment -> - editingCommentId = null - replyTargetId = comment.id - commentText = "" - }, - onCancelReply = { replyTargetId = null }, - onEdit = { comment -> - editingCommentId = comment.id - replyTargetId = null - commentText = comment.contents - commentAuthor = comment.author.ifBlank { DEFAULT_SHARED_PDF_COMMENT_AUTHOR } - }, - onCancelEdit = { - editingCommentId = null - commentText = "" - }, - onDelete = { comment -> - val nextComments = annotation.comments.withoutPdfCommentThread(comment.id) - updateComments(nextComments) - if (replyTargetId != null && (replyTargetId == comment.id || nextComments.none { it.id == replyTargetId })) { - replyTargetId = null - } - if (editingCommentId != null && (editingCommentId == comment.id || nextComments.none { it.id == editingCommentId })) { - editingCommentId = null - commentText = "" - } - }, - onAddComment = { - val contents = commentText.trim() - if (contents.isNotBlank()) { - val now = System.currentTimeMillis() - val author = commentAuthor.trim().ifBlank { DEFAULT_SHARED_PDF_COMMENT_AUTHOR } - val nextComments = if (editingCommentId != null) { - annotation.comments.map { comment -> - if (comment.id == editingCommentId) { - comment.copy( - author = author, - contents = contents, - modifiedAt = now - ) - } else { - comment - } - } - } else { - annotation.comments + SharedPdfAnnotationComment( - id = UUID.randomUUID().toString(), - parentId = replyTargetId, - author = author, - contents = contents, - createdAt = now, - modifiedAt = now - ) - } - updateComments(nextComments) - commentText = "" - replyTargetId = null - editingCommentId = null - } - } - ) - } - } - if (annotation.kind == PdfAnnotationKind.INK) { - val strokeRange = annotation.tool.sharedPdfStrokeWidthRange() - val strokeValue = annotation.strokeWidth.coerceIn(strokeRange.start, strokeRange.endInclusive) - Text( - readerString( - "desktop_thickness_format", - "Thickness %1\$s", - 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(readerString("action_delete", "Delete")) - } - } - } - } - editingHighlighterSlot?.let { requestedSlot -> - val draftColors = highlighterDraftColors() - val safeDraftColors = draftColors.ifEmpty { SharedPdfHighlighterPalette.defaultColors } - val slot = requestedSlot.coerceIn(0, safeDraftColors.lastIndex) - val initialColor = remember(slot) { Color(safeDraftColors[slot]).copy(alpha = 1f) } - SharedHsvColorPickerDialog( - initialColor = initialColor, - title = readerString("desktop_highlight_color_format", "Highlight color %1\$d", slot + 1), - onDismiss = { editingHighlighterSlot = null }, - onSave = { color -> - val nextArgb = color.copy(alpha = SharedPdfHighlighterPalette.DefaultAlpha / 255f).toArgb() - val nextColors = updateHighlighterDraft(slot, color) - onHighlighterPaletteChange( - SharedPdfHighlighterPalette(nextColors).sanitized() - ) - onUpdate(annotation.copy(colorArgb = nextArgb)) - editingHighlighterSlot = null - }, - resetColor = Color(SharedPdfHighlighterPalette.defaultColors.getOrElse(slot) { - SharedPdfHighlighterPalette.defaultColors.first() - }).copy(alpha = 1f), - stateKey = slot, - onLiveColorChange = { color -> - updateHighlighterDraft(slot, color) - } - ) { liveColor -> - Row( - modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically - ) { - highlighterDraftColors().forEachIndexed { index, argb -> - val color = if (index == slot) liveColor else Color(argb).copy(alpha = 1f) - Box( - modifier = Modifier - .size(42.dp) - .clip(RoundedCornerShape(21.dp)) - .background(color) - .border( - width = if (index == slot) 3.dp else 1.dp, - color = if (index == slot) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.outline.copy(alpha = 0.35f) - }, - shape = RoundedCornerShape(21.dp) - ) - .clickable { openHighlighterEditor(index) }, - contentAlignment = Alignment.Center - ) { - Text( - text = "${index + 1}", - color = if (color.luminance() > 0.5f) Color.Black else Color.White, - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Bold - ) - } - } - } - } - } -} - -@Composable -private fun DesktopPdfAnnotationSheetTabs( - selectedSection: DesktopPdfAnnotationSheetSection, - commentCount: Int, - onSectionChange: (DesktopPdfAnnotationSheetSection) -> Unit -) { - Surface( - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f), - shape = RoundedCornerShape(8.dp), - modifier = Modifier.fillMaxWidth() - ) { - Row(modifier = Modifier.padding(4.dp)) { - DesktopPdfAnnotationSheetTab( - label = readerString("label_note", "Note"), - selected = selectedSection == DesktopPdfAnnotationSheetSection.NOTE, - modifier = Modifier.weight(1f), - onClick = { onSectionChange(DesktopPdfAnnotationSheetSection.NOTE) } - ) - DesktopPdfAnnotationSheetTab( - label = "${readerString("label_comments", "Comments")} ($commentCount)", - selected = selectedSection == DesktopPdfAnnotationSheetSection.COMMENTS, - modifier = Modifier.weight(1f), - onClick = { onSectionChange(DesktopPdfAnnotationSheetSection.COMMENTS) } - ) - } - } -} - -@Composable -private fun DesktopPdfAnnotationSheetTab( - label: String, - selected: Boolean, - modifier: Modifier = Modifier, - onClick: () -> Unit -) { - Surface( - color = if (selected) MaterialTheme.colorScheme.primary else Color.Transparent, - contentColor = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface, - shape = RoundedCornerShape(6.dp), - modifier = modifier - .height(40.dp) - .clip(RoundedCornerShape(6.dp)) - .clickable(onClick = onClick) - ) { - Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxWidth().fillMaxHeight()) { - Text( - text = label, - style = MaterialTheme.typography.labelLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } -} - -@Composable -private fun DesktopPdfHighlightCommentsEditor( - comments: List, - commentText: String, - commentAuthor: String, - replyTargetId: String?, - editingCommentId: String?, - onCommentTextChange: (String) -> Unit, - onCommentAuthorChange: (String) -> Unit, - onReply: (SharedPdfAnnotationComment) -> Unit, - onCancelReply: () -> Unit, - onEdit: (SharedPdfAnnotationComment) -> Unit, - onCancelEdit: () -> Unit, - onDelete: (SharedPdfAnnotationComment) -> Unit, - onAddComment: () -> Unit -) { - val visibleComments = comments.visiblePdfAnnotationComments() - val replyTarget = visibleComments.firstOrNull { it.id == replyTargetId } - val editingComment = visibleComments.firstOrNull { it.id == editingCommentId } - - Column { - Column( - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 220.dp) - .verticalScroll(rememberScrollState()) - ) { - DesktopPdfHighlightCommentThread( - comments = visibleComments, - parentId = null, - depth = 0, - visitedIds = emptySet(), - onReply = onReply, - onEdit = onEdit, - onDelete = onDelete - ) - } - - if (editingComment != null || replyTarget != null) { - Row( - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = if (editingComment != null) { - readerString("label_editing_comment", "Editing comment") - } else { - readerString( - "label_replying_to", - "Replying to %1\$s", - replyTarget?.author?.ifBlank { DEFAULT_SHARED_PDF_COMMENT_AUTHOR }.orEmpty() - ) - }, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.weight(1f) - ) - TextButton(onClick = if (editingComment != null) onCancelEdit else onCancelReply) { - Text(readerString("action_cancel", "Cancel")) - } - } - } - - SharedStableOutlinedTextField( - value = commentAuthor, - onValueChange = onCommentAuthorChange, - label = { Text(readerString("author", "Author")) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - shape = RoundedCornerShape(12.dp), - selectionKey = "comment-author-${editingCommentId ?: replyTargetId ?: "new"}" - ) - - Spacer(Modifier.height(8.dp)) - - SharedStableOutlinedTextField( - value = commentText, - onValueChange = onCommentTextChange, - placeholder = { Text(readerString("placeholder_add_comment", "Add a comment...")) }, - modifier = Modifier.fillMaxWidth().heightIn(min = 88.dp), - minLines = 3, - maxLines = 4, - shape = RoundedCornerShape(12.dp), - selectionKey = "comment-text-${editingCommentId ?: replyTargetId ?: "new"}" - ) - - Row( - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - horizontalArrangement = Arrangement.End - ) { - TextButton(onClick = onAddComment, enabled = commentText.isNotBlank()) { - Text( - readerString( - if (editingComment != null) "action_save_comment" else "action_add_comment", - if (editingComment != null) "Save Comment" else "Add Comment" - ) - ) - } - } - } -} - -@Composable -private fun DesktopPdfHighlightCommentThread( - comments: List, - parentId: String?, - depth: Int, - visitedIds: Set, - onReply: (SharedPdfAnnotationComment) -> Unit, - onEdit: (SharedPdfAnnotationComment) -> Unit, - onDelete: (SharedPdfAnnotationComment) -> Unit -) { - comments.pdfCommentChildren(parentId).forEach { comment -> - if (comment.id in visitedIds) return@forEach - DesktopPdfHighlightCommentItem( - comment = comment, - depth = depth, - onReply = { onReply(comment) }, - onEdit = { onEdit(comment) }, - onDelete = { onDelete(comment) } - ) - DesktopPdfHighlightCommentThread( - comments = comments, - parentId = comment.id, - depth = depth + 1, - visitedIds = visitedIds + comment.id, - onReply = onReply, - onEdit = onEdit, - onDelete = onDelete - ) - } -} - -@Composable -private fun DesktopPdfHighlightCommentItem( - comment: SharedPdfAnnotationComment, - depth: Int, - onReply: () -> Unit, - onEdit: () -> Unit, - onDelete: () -> Unit -) { - val indentSize = (depth * 16).dp - Row( - modifier = Modifier - .fillMaxWidth() - .padding(start = indentSize, top = 6.dp, bottom = 6.dp) - ) { - if (depth > 0) { - Box( - modifier = Modifier - .width(2.dp) - .fillMaxHeight() - .background(MaterialTheme.colorScheme.outlineVariant) - ) - Spacer(modifier = Modifier.width(12.dp)) - } - Column(modifier = Modifier.weight(1f)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = comment.author.ifBlank { DEFAULT_SHARED_PDF_COMMENT_AUTHOR }, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) - ) - val timestamp = comment.createdAt.formatDesktopPdfCommentTimestamp() - if (timestamp.isNotBlank()) { - Text( - text = timestamp, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - Spacer(Modifier.height(2.dp)) - Text( - text = comment.contents, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface - ) - Row { - TextButton(onClick = onReply) { - Text(readerString("action_reply", "Reply")) - } - TextButton(onClick = onEdit) { - Text(readerString("label_edit", "Edit")) - } - TextButton(onClick = onDelete) { - Text(readerString("action_delete", "Delete"), color = MaterialTheme.colorScheme.error) - } - } - } - } -} - -private fun Long.formatDesktopPdfCommentTimestamp(): String { - if (this <= 0L) return "" - return runCatching { - DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(Date(this)) - }.getOrDefault("") -} - -@Composable -private fun DesktopBottomSheetToolButton( - icon: ImageVector, - label: String, - onClick: () -> Unit -) { - Column( - modifier = Modifier - .clickable(onClick = onClick) - .padding(horizontal = 10.dp, vertical = 8.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(6.dp) - ) { - Icon( - imageVector = icon, - contentDescription = label, - tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.78f), - modifier = Modifier.size(22.dp) - ) - Text( - label, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } -} - -@Composable -internal 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( - readerString("desktop_embedded_pdf_comment", "Embedded PDF comment"), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.weight(1f) - ) - TextButton(onClick = onClose) { - Text(readerString("action_close", "Close")) - } - } - Text( - annotation.author.takeIf { it.isNotBlank() }?.let { author -> - readerString( - "desktop_pdf_page_author_format", - "Page %1\$d - %2\$s", - annotation.pageIndex + 1, - author - ) - } ?: readerString("pdf_page_short", "Page %1\$d", annotation.pageIndex + 1), - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall - ) - DesktopPdfEmbeddedComment( - author = annotation.author, - contents = annotation.contents, - depth = 0 - ) - DesktopPdfEmbeddedReplies(annotation.replies, depth = 1) - TextButton(onClick = onCopy) { - Text(readerString("action_copy_thread", "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 { readerString("unknown", "Unknown") }, - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold - ) - Text( - contents.ifBlank { readerString("desktop_no_comment", "No comment") }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } -} - -@Composable -internal fun SharedPdfAnnotation.desktopLabel(): String { - return when (kind) { - PdfAnnotationKind.HIGHLIGHT -> readerString("label_highlight_color", "highlight") - PdfAnnotationKind.INK -> tool.desktopLabel() - PdfAnnotationKind.TEXT -> readerString("desktop_text_note_lowercase", "text note") - } -} - -@Composable -internal fun SharedPdfAnnotation.desktopSheetTitle(): String { - return when (kind) { - PdfAnnotationKind.HIGHLIGHT -> readerString("label_highlight_color", "Highlight") - PdfAnnotationKind.INK -> readerString("desktop_annotation", "Annotation") - PdfAnnotationKind.TEXT -> readerString("desktop_text_note", "Text note") - } -} - -@Composable -private fun PdfInkTool.desktopLabel(): String { - return when (this) { - PdfInkTool.PEN -> readerString("content_desc_pen", "Pen") - PdfInkTool.FOUNTAIN_PEN -> readerString("desktop_fountain_pen", "Fountain pen") - PdfInkTool.PENCIL -> readerString("desktop_pencil", "Pencil") - PdfInkTool.HIGHLIGHTER -> readerString("content_desc_highlighter", "Highlighter") - PdfInkTool.HIGHLIGHTER_ROUND -> readerString("desktop_round_highlighter", "Round highlighter") - PdfInkTool.TEXT -> readerString("desktop_text_note", "Text note") - PdfInkTool.ERASER -> readerString("content_desc_eraser", "Eraser") - PdfInkTool.NONE -> readerString("label_none", "None") - } -} - -internal 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() -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfAppearance.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfAppearance.kt deleted file mode 100644 index b56fb3e..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfAppearance.kt +++ /dev/null @@ -1,218 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import androidx.compose.foundation.Canvas -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.ColumnScope -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.BlendMode -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.ColorMatrix -import androidx.compose.ui.graphics.FilterQuality -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.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.dp -import org.dueattendant149.bookreader.shared.BuiltInPdfReaderThemes -import org.dueattendant149.bookreader.shared.PdfDisplayMode -import org.dueattendant149.bookreader.shared.ReaderTheme -import org.dueattendant149.bookreader.shared.reader.ReaderSettings - -internal enum class DesktopPdfInspectorTab(val title: String) { - APPEARANCE("Appearance"), - APP_THEME("App theme"), - VISUAL("Visual"), - MARKUP("Markup"), - TTS("TTS") -} - -internal data class DesktopPdfThemeStyle( - val theme: ReaderTheme, - val viewerBackgroundColor: Color, - val pageBackgroundColor: Color, - val colorFilter: ColorFilter?, - val textureBitmap: ImageBitmap?, - val textureAlpha: Float, - val textureBlendMode: BlendMode -) - -@Composable -internal fun DesktopPdfThemedPageImage( - bitmap: ImageBitmap, - contentDescription: String, - themeStyle: DesktopPdfThemeStyle, - modifier: Modifier = Modifier -) { - Box(modifier = modifier.background(themeStyle.pageBackgroundColor)) { - val textureBitmap = themeStyle.textureBitmap - Canvas( - modifier = Modifier - .fillMaxSize() - .semantics { this.contentDescription = contentDescription } - ) { - drawImage( - image = bitmap, - srcOffset = IntOffset.Zero, - srcSize = IntSize(bitmap.width, bitmap.height), - dstOffset = IntOffset.Zero, - dstSize = IntSize( - size.width.toInt().coerceAtLeast(1), - size.height.toInt().coerceAtLeast(1) - ), - colorFilter = themeStyle.colorFilter, - filterQuality = FilterQuality.High - ) - if (textureBitmap != null && themeStyle.textureAlpha > 0f) { - drawRect( - brush = ShaderBrush(ImageShader(textureBitmap, TileMode.Repeated, TileMode.Repeated)), - size = size, - blendMode = themeStyle.textureBlendMode, - alpha = themeStyle.textureAlpha - ) - } - } - } -} - -internal fun ReaderSettings?.toDesktopPdfReaderSettings(): ReaderSettings { - val defaults = DesktopDefaultPdfReaderSettings - 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" - } - ) -} - -internal fun ReaderSettings.toDesktopPdfThemeStyle(displayMode: PdfDisplayMode): DesktopPdfThemeStyle { - val theme = toDesktopPdfTheme() - val pageBackground = desktopPdfPageBackgroundColor(theme, displayMode) - val isDarkTexture = theme.isDark || theme.id == "reverse" - return DesktopPdfThemeStyle( - theme = theme, - viewerBackgroundColor = pageBackground, - pageBackgroundColor = pageBackground, - 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)) - } - } -} - -@Composable -internal fun DesktopPdfInspectorSection( - title: String, - content: @Composable ColumnScope.() -> Unit -) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) - content() - } -} - -@Composable -internal fun DesktopPdfVisualOptionSwitch( - title: String, - description: String, - checked: Boolean, - onCheckedChange: (Boolean) -> Unit -) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp) - ) { - Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text(title, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) - Text( - description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Switch(checked = checked, onCheckedChange = onCheckedChange) - } -} - -private fun Long.toComposeColor(): Color { - return Color(this and 0xFFFFFFFFL) -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfChromeUi.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfChromeUi.kt deleted file mode 100644 index 5018a0a..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfChromeUi.kt +++ /dev/null @@ -1,593 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -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.BoxScope -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.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.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -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.Close -import androidx.compose.material.icons.filled.KeyboardArrowDown -import androidx.compose.material.icons.filled.KeyboardArrowUp -import androidx.compose.material.icons.filled.Visibility -import androidx.compose.material.icons.filled.VisibilityOff -import androidx.compose.material.icons.filled.ZoomOut -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -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.graphics.Color -import androidx.compose.ui.graphics.luminance -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.zIndex -import org.dueattendant149.bookreader.shared.SearchHighlightMode -import org.dueattendant149.bookreader.shared.pdf.SharedPdfSearchResult -import org.dueattendant149.bookreader.shared.ui.ReaderMinimalSlider -import org.dueattendant149.bookreader.shared.ui.ReaderTooltipIconButton -import org.dueattendant149.bookreader.shared.ui.SharedStableOutlinedTextField -import org.dueattendant149.bookreader.shared.ui.readerString -import kotlinx.coroutines.delay - -@Composable -internal fun DesktopPdfFullscreenBottomChrome( - pageIndex: Int, - pageCount: Int, - pageLabel: String = "Page ${pageIndex + 1} of $pageCount", - canGoPrevious: Boolean = pageIndex > 0, - canGoNext: Boolean = pageIndex < pageCount - 1, - showJumpHistory: Boolean, - jumpBackPage: Int?, - jumpForwardPage: Int?, - onPrevious: () -> Unit, - onNext: () -> Unit, - onPageScrub: (Float) -> Unit, - onPageScrubFinished: () -> Unit, - onJumpBack: () -> Unit, - onJumpForward: () -> Unit, - onClearJumpHistory: () -> Unit, - extraContent: @Composable ColumnScope.() -> Unit = {} -) { - val chromeBackground = MaterialTheme.colorScheme.surfaceVariant - val chromeContent = MaterialTheme.colorScheme.onSurface - val sliderActive = MaterialTheme.colorScheme.primary - val sliderInactive = chromeContent.copy(alpha = if (chromeBackground.luminance() > 0.5f) 0.44f else 0.52f) - Column(modifier = Modifier.fillMaxWidth()) { - extraContent() - Surface( - modifier = Modifier - .fillMaxWidth(), - shape = RoundedCornerShape(0.dp), - color = chromeBackground, - contentColor = chromeContent, - tonalElevation = 0.dp, - shadowElevation = 1.dp, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) - ) { - Column(modifier = Modifier.fillMaxWidth()) { - val hasJumpTargets = jumpBackPage != null || jumpForwardPage != null - DesktopPdfJumpHistoryControls( - visible = showJumpHistory, - backPage = jumpBackPage, - forwardPage = jumpForwardPage, - onBack = onJumpBack, - onForward = onJumpForward, - onClear = onClearJumpHistory - ) - if (showJumpHistory && hasJumpTargets) { - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) - } - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - ReaderTooltipIconButton( - tooltip = readerString("desktop_previous_page", "Previous page"), - onClick = onPrevious, - enabled = canGoPrevious - ) { - Icon( - Icons.AutoMirrored.Filled.NavigateBefore, - contentDescription = readerString("desktop_previous_page", "Previous page"), - tint = chromeContent.copy(alpha = if (canGoPrevious) 0.78f else 0.32f) - ) - } - Text( - pageLabel, - style = MaterialTheme.typography.labelSmall, - color = chromeContent.copy(alpha = 0.72f) - ) - ReaderMinimalSlider( - value = pageIndex.toFloat(), - onValueChange = onPageScrub, - onValueChangeFinished = onPageScrubFinished, - valueRange = 0f..(pageCount - 1).coerceAtLeast(0).toFloat(), - enabled = pageCount > 1, - activeColor = sliderActive, - inactiveColor = sliderInactive, - thumbColor = sliderActive, - modifier = Modifier.weight(1f) - ) - ReaderTooltipIconButton( - tooltip = readerString("desktop_next_page", "Next page"), - onClick = onNext, - enabled = canGoNext - ) { - Icon( - Icons.AutoMirrored.Filled.NavigateNext, - contentDescription = readerString("desktop_next_page", "Next page"), - tint = chromeContent.copy(alpha = if (canGoNext) 0.78f else 0.32f) - ) - } - } - } - } - } -} - -@Composable -internal fun DesktopPdfBottomChrome( - pageIndex: Int, - pageCount: Int, - pageLabel: String = "Page ${pageIndex + 1} of $pageCount", - progressPercent: Float, - canGoPrevious: Boolean, - canGoNext: Boolean, - showJumpHistory: Boolean, - jumpBackPage: Int?, - jumpForwardPage: Int?, - onPrevious: () -> Unit, - onNext: () -> Unit, - onPageScrub: (Float) -> Unit, - onPageScrubFinished: () -> Unit, - onJumpBack: () -> Unit, - onJumpForward: () -> Unit, - onClearJumpHistory: () -> Unit, - extraContent: @Composable ColumnScope.() -> Unit = {} -) { - val chromeBackground = MaterialTheme.colorScheme.surfaceVariant - val chromeContent = MaterialTheme.colorScheme.onSurface - val sliderActive = MaterialTheme.colorScheme.primary - val sliderInactive = chromeContent.copy(alpha = if (chromeBackground.luminance() > 0.5f) 0.44f else 0.52f) - Column(modifier = Modifier.fillMaxWidth()) { - extraContent() - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(0.dp), - color = chromeBackground, - contentColor = chromeContent, - tonalElevation = 0.dp, - shadowElevation = 1.dp, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) - ) { - Column(modifier = Modifier.fillMaxWidth()) { - DesktopPdfJumpHistoryControls( - visible = showJumpHistory, - backPage = jumpBackPage, - forwardPage = jumpForwardPage, - onBack = onJumpBack, - onForward = onJumpForward, - onClear = onClearJumpHistory - ) - if (showJumpHistory && (jumpBackPage != null || jumpForwardPage != null)) { - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) - } - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - ReaderTooltipIconButton( - tooltip = readerString("desktop_previous_page", "Previous page"), - onClick = onPrevious, - enabled = canGoPrevious - ) { - Icon( - Icons.AutoMirrored.Filled.NavigateBefore, - contentDescription = readerString("desktop_previous_page", "Previous page"), - tint = chromeContent.copy(alpha = if (canGoPrevious) 0.78f else 0.32f) - ) - } - Text( - pageLabel, - style = MaterialTheme.typography.labelSmall, - color = chromeContent.copy(alpha = 0.72f) - ) - if (pageCount > 1) { - ReaderMinimalSlider( - value = pageIndex.toFloat(), - onValueChange = onPageScrub, - onValueChangeFinished = onPageScrubFinished, - valueRange = 0f..(pageCount - 1).toFloat(), - activeColor = sliderActive, - inactiveColor = sliderInactive, - thumbColor = sliderActive, - modifier = Modifier.weight(1f) - ) - } else { - Spacer(Modifier.weight(1f)) - } - Text( - "${progressPercent.toInt()}%", - style = MaterialTheme.typography.labelSmall, - color = chromeContent.copy(alpha = 0.72f) - ) - ReaderTooltipIconButton( - tooltip = readerString("desktop_next_page", "Next page"), - onClick = onNext, - enabled = canGoNext - ) { - Icon( - Icons.AutoMirrored.Filled.NavigateNext, - contentDescription = readerString("desktop_next_page", "Next page"), - tint = chromeContent.copy(alpha = if (canGoNext) 0.78f else 0.32f) - ) - } - } - } - } - } -} - -@Composable -internal fun DesktopPdfZoomPercentageIndicator( - percentage: Int, - onResetZoomClick: () -> Unit -) { - Surface( - shape = RoundedCornerShape(8.dp), - color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.8f) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) - ) { - Text( - text = "$percentage%", - color = Color.White, - style = MaterialTheme.typography.bodyLarge - ) - Spacer(Modifier.width(8.dp)) - Box( - modifier = Modifier - .width(1.dp) - .height(16.dp) - .background(Color.White.copy(alpha = 0.5f)) - ) - Spacer(Modifier.width(8.dp)) - Icon( - imageVector = Icons.Default.ZoomOut, - contentDescription = readerString("content_desc_reset_zoom", "Reset zoom"), - tint = Color.White, - modifier = Modifier - .size(20.dp) - .clip(RoundedCornerShape(4.dp)) - .clickable(onClick = onResetZoomClick) - ) - } - } -} - -@Composable -internal fun DesktopPdfSearchTopBar( - query: String, - showResultsPanel: Boolean, - onQueryChange: (String) -> Unit, - onClose: () -> Unit, - onToggleResults: () -> Unit -) { - val focusRequester = remember { FocusRequester() } - - LaunchedEffect(Unit) { - delay(80) - runCatching { focusRequester.requestFocus() } - } - - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(0.dp), - color = MaterialTheme.colorScheme.surface, - tonalElevation = 2.dp - ) { - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - ReaderTooltipIconButton( - tooltip = readerString("tooltip_close_search_desc", "Exit search and go back to the reader"), - onClick = onClose, - modifier = Modifier.size(36.dp) - ) { - Icon(Icons.Default.Close, contentDescription = readerString("content_desc_close_search", "Close search")) - } - SharedStableOutlinedTextField( - value = query, - onValueChange = onQueryChange, - placeholder = { Text(readerString("desktop_search_in_pdf", "Search in PDF")) }, - singleLine = true, - modifier = Modifier.weight(1f).focusRequester(focusRequester), - trailingIcon = if (query.isNotEmpty()) { - { - ReaderTooltipIconButton( - tooltip = readerString("tooltip_clear_search_desc", "Erase your current search query and start over"), - onClick = { onQueryChange("") } - ) { - Icon(Icons.Default.Close, contentDescription = readerString("tooltip_clear_search", "Clear search")) - } - } - } else { - null - }, - selectionKey = "desktop-pdf-search" - ) - val resultsTooltip = if (showResultsPanel) { - readerString("tooltip_hide_results_desc", "Collapse the search results panel") - } else { - readerString("tooltip_show_results_desc", "Expand the panel to see all search matches") - } - ReaderTooltipIconButton( - tooltip = resultsTooltip, - onClick = onToggleResults, - modifier = Modifier.size(36.dp) - ) { - Icon( - if (showResultsPanel) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, - contentDescription = if (showResultsPanel) { - readerString("desktop_hide_search_results", "Hide search results") - } else { - readerString("desktop_show_search_results", "Show search results") - } - ) - } - } - } -} - -@Composable -internal fun BoxScope.DesktopPdfSearchOverlay( - isSearchActive: Boolean, - showResultsPanel: Boolean, - query: String, - results: List, - activeSearchIndex: Int, - highlightMode: SearchHighlightMode, - isIndexing: Boolean, - indexedPageCount: Int, - pageCount: Int, - onResultClick: (Int) -> Unit, - onShowResults: () -> Unit, - onPrevious: () -> Unit, - onNext: () -> Unit, - onToggleHighlightMode: () -> Unit -) { - AnimatedVisibility( - visible = isSearchActive && showResultsPanel, - enter = slideInVertically { -it } + fadeIn(), - exit = slideOutVertically { -it } + fadeOut(), - modifier = Modifier.fillMaxSize().zIndex(30f) - ) { - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background - ) { - Column(Modifier.fillMaxSize()) { - if (isIndexing) { - val progress = indexedPageCount.toFloat() / pageCount.coerceAtLeast(1).toFloat() - Surface( - color = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer, - modifier = Modifier.fillMaxWidth() - ) { - Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp)) { - Text( - readerString( - "desktop_indexing_pages_format", - "Indexing %1\$d/%2\$d pages", - indexedPageCount.coerceAtMost(pageCount), - pageCount - ), - style = MaterialTheme.typography.bodySmall - ) - LinearProgressIndicator( - progress = { progress.coerceIn(0f, 1f) }, - modifier = Modifier.fillMaxWidth().padding(top = 6.dp) - ) - } - } - } - - when { - query.isBlank() -> { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text(readerString("desktop_type_to_search_pdf", "Type to search this PDF"), color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - - results.isEmpty() -> { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text( - if (isIndexing) { - readerString("desktop_no_matches_indexed_pages_yet", "No matches in indexed pages yet") - } else { - readerString("desktop_no_matches", "No matches") - }, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - - else -> { - Text( - when { - isIndexing -> readerString("desktop_matches_so_far_format", "%1\$d matches so far", results.size) - else -> readerString("desktop_matches_format", "%1\$d matches", results.size) - }, - style = MaterialTheme.typography.titleSmall, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp) - ) - HorizontalDivider() - LazyColumn(Modifier.fillMaxSize()) { - itemsIndexed( - items = results, - key = { index, result -> "${result.pageIndex}_${result.matchIndex}_$index" } - ) { index, result -> - Surface( - modifier = Modifier.fillMaxWidth().clickable { onResultClick(index) }, - color = if (index == activeSearchIndex) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surface - } - ) { - Column( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Text( - readerString("pdf_page_short", "Page %1\$d", result.pageIndex + 1), - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - result.preview, - style = MaterialTheme.typography.bodyMedium, - maxLines = 3, - overflow = TextOverflow.Ellipsis - ) - } - } - HorizontalDivider() - } - } - } - } - } - } - } - - AnimatedVisibility( - visible = isSearchActive && !showResultsPanel && results.isNotEmpty(), - enter = fadeIn(), - exit = fadeOut(), - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(bottom = 18.dp) - .zIndex(31f) - ) { - DesktopPdfSearchNavigationPill( - activeSearchIndex = activeSearchIndex, - resultCount = results.size, - highlightMode = highlightMode, - onShowResults = onShowResults, - onPrevious = onPrevious, - onNext = onNext, - onToggleHighlightMode = onToggleHighlightMode - ) - } -} - -@Composable -private fun DesktopPdfSearchNavigationPill( - activeSearchIndex: Int, - resultCount: Int, - highlightMode: SearchHighlightMode, - onShowResults: () -> Unit, - onPrevious: () -> Unit, - onNext: () -> Unit, - onToggleHighlightMode: () -> Unit -) { - Surface( - shape = RoundedCornerShape(50), - color = MaterialTheme.colorScheme.surfaceVariant, - contentColor = MaterialTheme.colorScheme.onSurfaceVariant, - tonalElevation = 6.dp, - shadowElevation = 8.dp - ) { - Row( - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - ReaderTooltipIconButton( - tooltip = if (highlightMode == SearchHighlightMode.ALL) { - readerString("desktop_show_current_match_only", "Show current match only") - } else { - readerString("desktop_show_all_search_matches", "Show all search matches") - }, - onClick = onToggleHighlightMode, - modifier = Modifier.size(36.dp) - ) { - Icon( - if (highlightMode == SearchHighlightMode.ALL) Icons.Default.Visibility else Icons.Default.VisibilityOff, - contentDescription = readerString("content_desc_toggle_search_highlights", "Toggle search highlights"), - tint = if (highlightMode == SearchHighlightMode.ALL) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - } - ) - } - ReaderTooltipIconButton( - tooltip = readerString("tooltip_prev_result_desc", "Jump to the previous search match in the document"), - onClick = onPrevious, - enabled = resultCount > 0, - modifier = Modifier.size(36.dp) - ) { - Icon(Icons.AutoMirrored.Filled.NavigateBefore, contentDescription = readerString("desktop_previous_search_result", "Previous search result")) - } - Text( - text = if (activeSearchIndex in 0 until resultCount) { - "${activeSearchIndex + 1}/$resultCount" - } else { - readerString("desktop_matches_format", "%1\$d matches", resultCount) - }, - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.clickable(onClick = onShowResults).padding(horizontal = 8.dp) - ) - ReaderTooltipIconButton( - tooltip = readerString("tooltip_next_result_desc", "Jump to the next search match in the document"), - onClick = onNext, - enabled = resultCount > 0, - modifier = Modifier.size(36.dp) - ) { - Icon(Icons.AutoMirrored.Filled.NavigateNext, contentDescription = readerString("desktop_next_search_result", "Next search result")) - } - } - } -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfFileActions.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfFileActions.kt deleted file mode 100644 index 59c88bf..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfFileActions.kt +++ /dev/null @@ -1,500 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -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.FontStyle -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.unit.isSpecified -import org.dueattendant149.bookreader.shared.SaveMode -import org.dueattendant149.bookreader.shared.pdf.PdfAnnotationKind -import org.dueattendant149.bookreader.shared.pdf.SHARED_PDF_PAGE_BREAK_CHAR -import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotation -import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationExportMapper -import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichPageLayout -import org.dueattendant149.bookreader.shared.pdf.sharedPdfTextPageRelativeFontSize -import java.awt.FileDialog -import java.awt.Font -import java.awt.Frame -import java.awt.Graphics2D -import java.awt.RenderingHints -import java.awt.font.LineBreakMeasurer -import java.awt.font.TextAttribute -import java.awt.image.BufferedImage -import java.awt.print.Book -import java.awt.print.PageFormat -import java.awt.print.Printable -import java.awt.print.PrinterJob -import java.io.File -import java.text.AttributedString -import kotlin.math.ceil -import kotlin.math.roundToInt -import kotlin.random.Random - -private const val DesktopPdfTextBoxPaddingPx = 8f -private const val DesktopPdfTextRasterPointScale = 3f -private const val DesktopPdfTextRasterMinPageHeightPx = 1200f -private const val DesktopPdfTextRasterMaxPageHeightPx = 3600f -private const val DesktopPdfRichTextMarginX = 0.1f -private const val DesktopPdfRichTextMarginY = 0.08f - -internal data class DesktopPdfFileActionNotice( - val title: String, - val message: String, - val isError: Boolean = false -) - -internal data class DesktopPdfRasterOverlay( - val pageIndex: Int, - val left: Float, - val top: Float, - val right: Float, - val bottom: Float, - val width: Int, - val height: Int, - val pixels: IntArray -) - -internal fun desktopSuggestedPdfFilename( - originalName: String?, - isAnnotated: Boolean, - shortId: String = Random.nextInt(1000, 9999).toString() -): String { - val base = originalName - ?.substringBeforeLast('.') - ?.takeIf { it.isNotBlank() } - ?: "Document" - val safeBase = base.replace("[^a-zA-Z0-9._-]".toRegex(), "_") - .take(50) - .ifBlank { "Document" } - val suffix = if (isAnnotated) "_annotated" else "" - return "${safeBase}${suffix}_$shortId.pdf" -} - -internal fun hasExportableDesktopPdfAnnotations( - annotations: List, - richTextPageLayouts: List -): Boolean { - return SharedPdfAnnotationExportMapper.build(annotations).hasPdfAnnotations || - annotations.any { annotation -> - if (annotation.kind != PdfAnnotationKind.HIGHLIGHT) return@any false - val startIndex = annotation.rangeStartIndex ?: return@any false - val endIndex = annotation.rangeEndIndex ?: return@any false - endIndex >= startIndex - } || - annotations.any { annotation -> - annotation.kind == PdfAnnotationKind.TEXT && - annotation.bounds != null && - annotation.text.isNotBlank() - } || - richTextPageLayouts.any { layout -> - layout.visibleText.text - .replace(SHARED_PDF_PAGE_BREAK_CHAR.toString(), "") - .isNotBlank() - } -} - -internal fun shouldShowDesktopPdfAnnotationExportChoice( - sidecarsReady: Boolean, - annotations: List, - richTextPageLayouts: List -): Boolean { - return !sidecarsReady || hasExportableDesktopPdfAnnotations(annotations, richTextPageLayouts) -} - -internal fun chooseSavePdfFile(defaultFileName: String): File? { - val dialog = FileDialog(null as Frame?, "Save PDF", FileDialog.SAVE).apply { - file = defaultFileName - isVisible = true - } - val directory = dialog.directory ?: return null - val fileName = dialog.file ?: return null - val selected = File(directory, fileName) - return if (selected.extension.equals("pdf", ignoreCase = true)) { - selected - } else { - File(selected.parentFile, "${selected.name}.pdf") - } -} - -internal fun saveDesktopPdfCopy( - document: DesktopPdfDocument, - target: File, - mode: SaveMode, - annotations: List = emptyList(), - richTextPageLayouts: List = emptyList() -) { - val source = File(document.path) - require(source.isFile) { "The original PDF is not available as a local file." } - require(document.formatLabel == "PDF") { "Only PDF files can be saved as PDF copies." } - target.parentFile?.mkdirs() - - when (mode) { - SaveMode.ORIGINAL -> { - if (source.canonicalFile == target.canonicalFile) return - source.copyTo(target, overwrite = true) - } - SaveMode.ANNOTATED -> { - require(source.canonicalFile != target.canonicalFile) { - "Choose a different file name for an annotated copy." - } - DesktopPdfium.exportAnnotatedPdf( - document = document, - destination = target, - annotations = annotations, - richTextPageLayouts = richTextPageLayouts - ) - } - } -} - -internal fun printDesktopPdfDocument(document: DesktopPdfDocument) { - require(document.formatLabel == "PDF") { "Only PDF files can be printed from the PDF reader." } - val job = PrinterJob.getPrinterJob() - job.jobName = "Episteme - ${document.title}" - val printableBook = Book() - val pageFormat = job.defaultPage() - for (pageIndex in 0 until document.pageCount) { - printableBook.append( - Printable { graphics, format, _ -> - drawPdfPageForPrint(document, pageIndex, graphics as Graphics2D, format) - Printable.PAGE_EXISTS - }, - pageFormat - ) - } - job.setPageable(printableBook) - if (job.printDialog()) { - job.print() - } -} - -internal fun buildDesktopPdfRasterOverlays( - annotations: List, - richTextPageLayouts: List, - pageSizes: List -): List { - val overlays = mutableListOf() - annotations.mapNotNullTo(overlays) { annotation -> - if (annotation.kind != PdfAnnotationKind.TEXT) return@mapNotNullTo null - renderDesktopTextBoxOverlay(annotation, pageSizes.getOrNull(annotation.pageIndex) ?: return@mapNotNullTo null) - } - richTextPageLayouts.mapNotNullTo(overlays) { layout -> - renderDesktopRichTextOverlay(layout, pageSizes.getOrNull(layout.pageIndex) ?: return@mapNotNullTo null) - } - return overlays -} - -private fun drawPdfPageForPrint( - document: DesktopPdfDocument, - pageIndex: Int, - graphics: Graphics2D, - pageFormat: PageFormat -) { - val pageSize = document.pageSizes.getOrNull(pageIndex) ?: return - val availableWidth = pageFormat.imageableWidth - val availableHeight = pageFormat.imageableHeight - val fit = minOf( - availableWidth / pageSize.width.toDouble(), - availableHeight / pageSize.height.toDouble() - ).coerceAtLeast(0.01) - val drawWidth = pageSize.width * fit - val drawHeight = pageSize.height * fit - val drawX = pageFormat.imageableX + (availableWidth - drawWidth) / 2.0 - val drawY = pageFormat.imageableY + (availableHeight - drawHeight) / 2.0 - val image = DesktopPdfium.renderPageBufferedImage(document, pageIndex, scale = 2f, renderAnnotations = true) - graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC) - graphics.drawImage( - image, - drawX.roundToInt(), - drawY.roundToInt(), - drawWidth.roundToInt().coerceAtLeast(1), - drawHeight.roundToInt().coerceAtLeast(1), - null - ) -} - -private fun renderDesktopTextBoxOverlay( - annotation: SharedPdfAnnotation, - pageSize: DesktopPdfPageSize -): DesktopPdfRasterOverlay? { - val text = annotation.text.sanitizeDesktopRasterText() - val bounds = annotation.bounds ?: return null - if (annotation.pageIndex < 0 || text.isBlank()) return null - - 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 = DesktopPdfTextBoxPaddingPx - .coerceAtMost((minOf(bitmapWidth, bitmapHeight) / 2f).coerceAtLeast(0f)) - val contentWidth = (bitmapWidth - paddingPx * 2f).roundToInt().coerceAtLeast(1) - val fontSizePx = (annotation.sharedPdfTextPageRelativeFontSize() * pageHeightPx).coerceAtLeast(1f) - val bitmap = BufferedImage(bitmapWidth, bitmapHeight, BufferedImage.TYPE_INT_ARGB) - - val plainText = AnnotatedString(text) - val baseStyle = DesktopTextRasterStyle( - color = annotation.colorArgb.toAwtColor(), - background = annotation.backgroundArgb.toAwtColor().takeIf { it.alpha > 0 }, - fontSize = fontSizePx, - isBold = annotation.isBold, - isItalic = annotation.isItalic, - isUnderline = annotation.isUnderline, - isStrikeThrough = annotation.isStrikeThrough, - fontName = annotation.fontName - ) - drawDesktopAttributedText( - bitmap = bitmap, - text = plainText, - baseStyle = baseStyle, - width = contentWidth, - translateX = paddingPx, - translateY = paddingPx - ) - return bitmap.toDesktopRasterOverlay(annotation.pageIndex, left, top, right, bottom) -} - -private fun renderDesktopRichTextOverlay( - layout: SharedPdfRichPageLayout, - pageSize: DesktopPdfPageSize -): DesktopPdfRasterOverlay? { - val visibleText = layout.visibleText.withoutTrailingDesktopPageBreak() - 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 = DesktopPdfRichTextMarginX - val top = DesktopPdfRichTextMarginY - val right = 1f - DesktopPdfRichTextMarginX - val bottom = 1f - DesktopPdfRichTextMarginY - val bitmapWidth = ceil((right - left) * pageWidthPx).toInt().coerceAtLeast(1) - val bitmapHeight = ceil((bottom - top) * pageHeightPx).toInt().coerceAtLeast(1) - val bitmap = BufferedImage(bitmapWidth, bitmapHeight, BufferedImage.TYPE_INT_ARGB) - - drawDesktopAttributedText( - bitmap = bitmap, - text = visibleText, - baseStyle = DesktopTextRasterStyle( - color = java.awt.Color.BLACK, - background = null, - fontSize = 16f, - isBold = false, - isItalic = false, - isUnderline = false, - isStrikeThrough = false, - fontName = null - ), - width = bitmapWidth, - translateX = 0f, - translateY = 0f - ) - return bitmap.toDesktopRasterOverlay(layout.pageIndex, left, top, right, bottom) -} - -private fun drawDesktopAttributedText( - bitmap: BufferedImage, - text: AnnotatedString, - baseStyle: DesktopTextRasterStyle, - width: Int, - translateX: Float, - translateY: Float -) { - val graphics = bitmap.createGraphics() - try { - graphics.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON) - graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) - graphics.clipRect(0, 0, bitmap.width, bitmap.height) - var paragraphStart = 0 - var drawY = translateY - val raw = text.text.sanitizeDesktopRasterTextPreservingLength() - raw.split('\n').forEach { paragraph -> - val paragraphEnd = paragraphStart + paragraph.length - val attributed = attributedParagraph( - paragraph = paragraph, - paragraphStart = paragraphStart, - text = text, - baseStyle = baseStyle - ) - val iterator = attributed.iterator - val measurer = LineBreakMeasurer(iterator, graphics.fontRenderContext) - while (measurer.position < iterator.endIndex && drawY < bitmap.height) { - val layout = measurer.nextLayout(width.toFloat()) - drawY += layout.ascent - layout.draw(graphics, translateX, drawY) - drawY += layout.descent + layout.leading - } - if (paragraph.isEmpty()) { - drawY += baseStyle.fontSize * 1.2f - } - paragraphStart = paragraphEnd + 1 - } - } finally { - graphics.dispose() - } -} - -private fun attributedParagraph( - paragraph: String, - paragraphStart: Int, - text: AnnotatedString, - baseStyle: DesktopTextRasterStyle -): AttributedString { - val safeParagraph = paragraph.ifEmpty { " " } - val attributed = AttributedString(safeParagraph) - attributed.applyRasterStyle(baseStyle, 0, safeParagraph.length) - text.spanStyles.forEach { range -> - val start = maxOf(range.start, paragraphStart) - paragraphStart - val end = minOf(range.end, paragraphStart + paragraph.length) - paragraphStart - if (start < end) { - attributed.applyRasterStyle(range.item.toDesktopTextRasterStyle(baseStyle), start, end) - } - } - return attributed -} - -private fun AttributedString.applyRasterStyle(style: DesktopTextRasterStyle, start: Int, end: Int) { - val safeStart = start.coerceAtLeast(0) - val safeEnd = end.coerceAtLeast(safeStart) - if (safeStart >= safeEnd) return - addAttribute(TextAttribute.FONT, style.awtFont(), safeStart, safeEnd) - addAttribute(TextAttribute.FOREGROUND, style.color, safeStart, safeEnd) - style.background?.let { addAttribute(TextAttribute.BACKGROUND, it, safeStart, safeEnd) } - if (style.isUnderline) { - addAttribute(TextAttribute.UNDERLINE, TextAttribute.UNDERLINE_ON, safeStart, safeEnd) - } - if (style.isStrikeThrough) { - addAttribute(TextAttribute.STRIKETHROUGH, TextAttribute.STRIKETHROUGH_ON, safeStart, safeEnd) - } -} - -private data class DesktopTextRasterStyle( - val color: java.awt.Color, - val background: java.awt.Color?, - val fontSize: Float, - val isBold: Boolean, - val isItalic: Boolean, - val isUnderline: Boolean, - val isStrikeThrough: Boolean, - val fontName: String? -) { - fun awtFont(): Font { - val style = (if (isBold) Font.BOLD else Font.PLAIN) or (if (isItalic) Font.ITALIC else Font.PLAIN) - return Font(awtFontFamily(fontName), style, fontSize.roundToInt().coerceAtLeast(1)) - } -} - -private fun SpanStyle.toDesktopTextRasterStyle(base: DesktopTextRasterStyle): DesktopTextRasterStyle { - val color = this.color.takeUnless { it == Color.Unspecified }?.toAwtColor() ?: base.color - val background = this.background.takeUnless { it == Color.Unspecified || it.alpha <= 0f }?.toAwtColor() - ?: base.background - val fontSize = if (this.fontSize.isSpecified && this.fontSize.isSp) { - this.fontSize.value - } else { - base.fontSize - } - val fontWeight = this.fontWeight?.weight ?: if (base.isBold) 700 else 400 - return base.copy( - color = color, - background = background, - fontSize = fontSize, - isBold = fontWeight >= 600, - isItalic = this.fontStyle == FontStyle.Italic || base.isItalic, - isUnderline = this.textDecoration?.contains(TextDecoration.Underline) == true || - base.isUnderline, - isStrikeThrough = this.textDecoration?.contains(TextDecoration.LineThrough) == true || - base.isStrikeThrough - ) -} - -private fun awtFontFamily(fontName: String?): String { - return when (fontName?.lowercase()) { - "serif" -> Font.SERIF - "monospace" -> Font.MONOSPACED - else -> Font.SANS_SERIF - } -} - -private fun BufferedImage.toDesktopRasterOverlay( - pageIndex: Int, - boundsLeft: Float, - boundsTop: Float, - boundsRight: Float, - boundsBottom: Float -): DesktopPdfRasterOverlay? { - val allPixels = IntArray(width * height) - getRGB(0, 0, width, height, allPixels, 0, width) - - 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 DesktopPdfRasterOverlay( - 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 val DesktopPdfPageSize.aspect: Float - get() = if (width > 0f && height > 0f) width / height else 612f / 792f - -private fun DesktopPdfPageSize.exportHeightPx(): Float { - return (height * DesktopPdfTextRasterPointScale) - .coerceIn(DesktopPdfTextRasterMinPageHeightPx, DesktopPdfTextRasterMaxPageHeightPx) -} - -private fun AnnotatedString.withoutTrailingDesktopPageBreak(): AnnotatedString = - if (text.lastOrNull() == SHARED_PDF_PAGE_BREAK_CHAR) subSequence(0, length - 1) else this - -private fun String.sanitizeDesktopRasterText(): String = - replace(SHARED_PDF_PAGE_BREAK_CHAR, '\n') - .replace("\u200B", "") - .replace('\r', ' ') - -private fun String.sanitizeDesktopRasterTextPreservingLength(): String = - replace(SHARED_PDF_PAGE_BREAK_CHAR, '\n') - .replace('\r', ' ') - -private fun Int.toAwtColor(): java.awt.Color = java.awt.Color(this, true) - -private fun Color.toAwtColor(): java.awt.Color = java.awt.Color(toArgb(), true) diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfInspectorUi.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfInspectorUi.kt deleted file mode 100644 index 780bc1b..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfInspectorUi.kt +++ /dev/null @@ -1,444 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -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.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.VolumeUp -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.Palette -import androidx.compose.material.icons.filled.Tune -import androidx.compose.material3.FilterChip -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ScrollableTabRow -import androidx.compose.material3.Surface -import androidx.compose.material3.Tab -import androidx.compose.material3.Text -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.vector.ImageVector -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import org.dueattendant149.bookreader.shared.BuiltInPdfReaderThemes -import org.dueattendant149.bookreader.shared.PdfDisplayMode -import org.dueattendant149.bookreader.shared.ReaderAiByokSettings -import org.dueattendant149.bookreader.shared.ReaderExtrasState -import org.dueattendant149.bookreader.shared.ReaderTheme -import org.dueattendant149.bookreader.shared.ReaderTtsReplacementPreferences -import org.dueattendant149.bookreader.shared.pdf.PdfInkTool -import org.dueattendant149.bookreader.shared.pdf.SharedPdfHighlighterPalette -import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichTextController -import org.dueattendant149.bookreader.shared.pdf.SharedPdfTextStyleConfig -import org.dueattendant149.bookreader.shared.pdf.currentSharedPdfTextStyleConfig -import org.dueattendant149.bookreader.shared.pdf.updateCurrentSharedPdfTextStyle -import org.dueattendant149.bookreader.shared.reader.ReaderPageSpreadMode -import org.dueattendant149.bookreader.shared.reader.ReaderSettings -import org.dueattendant149.bookreader.shared.ui.SharedPdfHighlighterPaletteEditor -import org.dueattendant149.bookreader.shared.ui.SharedPdfTextAnnotationDock -import org.dueattendant149.bookreader.shared.ui.SharedReaderThemeControls -import org.dueattendant149.bookreader.shared.ui.SharedReaderVerticalScrollbar -import org.dueattendant149.bookreader.shared.ui.readerString -import org.dueattendant149.bookreader.shared.ui.sharedAcceleratedLazyWheelScroll - -@Composable -internal fun DesktopPdfInspectorPanel( - document: DesktopPdfDocument, - displayMode: PdfDisplayMode, - pdfReaderSettings: ReaderSettings, - appThemeControls: (@Composable () -> Unit)? = null, - customReaderThemes: List, - onCustomReaderThemesChange: (List) -> Unit, - customTextureIds: List, - onImportTexture: ((ReaderSettings) -> ReaderSettings?)?, - onReaderSettingsChange: (ReaderSettings) -> Unit, - selectedTool: PdfInkTool, - isRichTextMode: Boolean, - pdfHighlighterPalette: SharedPdfHighlighterPalette, - effectiveTextStyleConfig: SharedPdfTextStyleConfig, - richTextController: SharedPdfRichTextController, - pdfExtrasState: ReaderExtrasState, - aiByokSettings: ReaderAiByokSettings, - cloudTtsFeatureAvailable: Boolean, - ttsReplacementPreferences: ReaderTtsReplacementPreferences, - onDisplayModeSelected: (PdfDisplayMode) -> Unit, - onRichTextModeToggle: () -> Unit, - onHighlighterPaletteChange: (SharedPdfHighlighterPalette) -> Unit, - onTextStyleChange: (SharedPdfTextStyleConfig) -> Unit, - onCloudTtsClearCache: () -> Unit, - onCloudTtsVoiceChange: (String) -> Unit, - onTtsReplacementPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit -) { - val inspectorTabs = remember(appThemeControls != null) { - desktopPdfInspectorTabs(appThemeControlsAvailable = appThemeControls != null) - } - var selectedPdfInspectorTab by remember(document.handleId) { mutableStateOf(DesktopPdfInspectorTab.VISUAL) } - LaunchedEffect(inspectorTabs) { - if (selectedPdfInspectorTab !in inspectorTabs) { - selectedPdfInspectorTab = DesktopPdfInspectorTab.VISUAL.takeIf { it in inspectorTabs } - ?: inspectorTabs.first() - } - } - val appThemeInspectorListState = rememberLazyListState() - val appearanceInspectorListState = rememberLazyListState() - val visualInspectorListState = rememberLazyListState() - val markupInspectorListState = rememberLazyListState() - val ttsInspectorListState = rememberLazyListState() - val pdfInspectorListState = when (selectedPdfInspectorTab) { - DesktopPdfInspectorTab.APP_THEME -> appThemeInspectorListState - DesktopPdfInspectorTab.APPEARANCE -> appearanceInspectorListState - DesktopPdfInspectorTab.VISUAL -> visualInspectorListState - DesktopPdfInspectorTab.MARKUP -> markupInspectorListState - DesktopPdfInspectorTab.TTS -> ttsInspectorListState - } - - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(0.dp) - ) { - Column(modifier = Modifier.fillMaxSize()) { - DesktopPdfInspectorHeader( - tabs = inspectorTabs, - selectedTab = selectedPdfInspectorTab, - onTabSelected = { selectedPdfInspectorTab = it } - ) - HorizontalDivider() - DesktopPdfInspectorContent( - document = document, - displayMode = displayMode, - pdfReaderSettings = pdfReaderSettings, - appThemeControls = appThemeControls, - customReaderThemes = customReaderThemes, - onCustomReaderThemesChange = onCustomReaderThemesChange, - customTextureIds = customTextureIds, - onImportTexture = onImportTexture, - onReaderSettingsChange = onReaderSettingsChange, - selectedTool = selectedTool, - isRichTextMode = isRichTextMode, - pdfHighlighterPalette = pdfHighlighterPalette, - effectiveTextStyleConfig = effectiveTextStyleConfig, - richTextController = richTextController, - pdfExtrasState = pdfExtrasState, - aiByokSettings = aiByokSettings, - cloudTtsFeatureAvailable = cloudTtsFeatureAvailable, - ttsReplacementPreferences = ttsReplacementPreferences, - selectedTab = selectedPdfInspectorTab, - listState = pdfInspectorListState, - onDisplayModeSelected = onDisplayModeSelected, - onRichTextModeToggle = onRichTextModeToggle, - onHighlighterPaletteChange = onHighlighterPaletteChange, - onTextStyleChange = onTextStyleChange, - onCloudTtsClearCache = onCloudTtsClearCache, - onCloudTtsVoiceChange = onCloudTtsVoiceChange, - onTtsReplacementPreferencesChange = onTtsReplacementPreferencesChange - ) - } - } -} - -@Composable -private fun DesktopPdfInspectorHeader( - tabs: List, - selectedTab: DesktopPdfInspectorTab, - onTabSelected: (DesktopPdfInspectorTab) -> Unit -) { - ScrollableTabRow( - selectedTabIndex = tabs.indexOf(selectedTab).coerceAtLeast(0), - edgePadding = 0.dp, - modifier = Modifier.fillMaxWidth() - ) { - tabs.forEach { tab -> - Tab( - selected = selectedTab == tab, - onClick = { onTabSelected(tab) }, - icon = { - Icon( - tab.icon(), - contentDescription = null - ) - }, - text = { - Text( - tab.localizedTitle(), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - ) - } - } -} - -@Composable -private fun ColumnScope.DesktopPdfInspectorContent( - document: DesktopPdfDocument, - displayMode: PdfDisplayMode, - pdfReaderSettings: ReaderSettings, - appThemeControls: (@Composable () -> Unit)?, - customReaderThemes: List, - onCustomReaderThemesChange: (List) -> Unit, - customTextureIds: List, - onImportTexture: ((ReaderSettings) -> ReaderSettings?)?, - onReaderSettingsChange: (ReaderSettings) -> Unit, - selectedTool: PdfInkTool, - isRichTextMode: Boolean, - pdfHighlighterPalette: SharedPdfHighlighterPalette, - effectiveTextStyleConfig: SharedPdfTextStyleConfig, - richTextController: SharedPdfRichTextController, - pdfExtrasState: ReaderExtrasState, - aiByokSettings: ReaderAiByokSettings, - cloudTtsFeatureAvailable: Boolean, - ttsReplacementPreferences: ReaderTtsReplacementPreferences, - selectedTab: DesktopPdfInspectorTab, - listState: LazyListState, - onDisplayModeSelected: (PdfDisplayMode) -> Unit, - onRichTextModeToggle: () -> Unit, - onHighlighterPaletteChange: (SharedPdfHighlighterPalette) -> Unit, - onTextStyleChange: (SharedPdfTextStyleConfig) -> Unit, - onCloudTtsClearCache: () -> Unit, - onCloudTtsVoiceChange: (String) -> Unit, - onTtsReplacementPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit -) { - Box(modifier = Modifier.weight(1f).fillMaxWidth()) { - LazyColumn( - state = listState, - modifier = Modifier - .fillMaxSize() - .sharedAcceleratedLazyWheelScroll(listState, multiplier = 2.8f) - .padding(start = 12.dp, top = 12.dp, bottom = 12.dp, end = 24.dp), - verticalArrangement = Arrangement.spacedBy(14.dp) - ) { - when (selectedTab) { - DesktopPdfInspectorTab.APP_THEME -> { - appThemeControls?.let { controls -> - item { - controls() - } - } - } - DesktopPdfInspectorTab.APPEARANCE -> { - item { - DesktopPdfInspectorSection(readerString("desktop_pdf_theme", "PDF theme")) { - SharedReaderThemeControls( - settings = pdfReaderSettings, - builtInThemes = BuiltInPdfReaderThemes, - customThemes = customReaderThemes, - onCustomThemesChange = onCustomReaderThemesChange, - customTextureIds = customTextureIds, - onImportTexture = onImportTexture, - texturePreviewContent = { textureId, previewModifier -> - DesktopReaderTexturePreview(textureId = textureId, modifier = previewModifier) - }, - onSettingsChange = onReaderSettingsChange - ) - } - } - } - DesktopPdfInspectorTab.VISUAL -> { - item { - DesktopPdfInspectorSection(readerString("visual_options_title", "Visual options")) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.horizontalScroll(rememberScrollState()) - ) { - FilterChip( - selected = displayMode == PdfDisplayMode.PAGINATION && !pdfReaderSettings.rightToLeftPagination, - onClick = { - onReaderSettingsChange(pdfReaderSettings.copy(rightToLeftPagination = false)) - onDisplayModeSelected(PdfDisplayMode.PAGINATION) - }, - label = { Text(readerString("menu_reading_mode_paginated", "Paginated (left-to-right)")) } - ) - FilterChip( - selected = displayMode == PdfDisplayMode.PAGINATION && pdfReaderSettings.rightToLeftPagination, - onClick = { - onReaderSettingsChange(pdfReaderSettings.copy(rightToLeftPagination = true)) - onDisplayModeSelected(PdfDisplayMode.PAGINATION) - }, - label = { Text(readerString("menu_right_to_left_pagination", "Paginated (right-to-left)")) } - ) - FilterChip( - selected = displayMode == PdfDisplayMode.VERTICAL_SCROLL, - onClick = { onDisplayModeSelected(PdfDisplayMode.VERTICAL_SCROLL) }, - label = { Text(readerString("desktop_scroll", "Scroll")) } - ) - } - if (displayMode == PdfDisplayMode.PAGINATION) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - FilterChip( - selected = pdfReaderSettings.pageSpreadMode == ReaderPageSpreadMode.SINGLE, - onClick = { - onReaderSettingsChange( - pdfReaderSettings.copy(pageSpreadMode = ReaderPageSpreadMode.SINGLE) - ) - }, - label = { Text(readerString("visual_options_pdf_spread_single", "Single page")) } - ) - FilterChip( - selected = pdfReaderSettings.pageSpreadMode == ReaderPageSpreadMode.TWO_PAGE, - onClick = { - onReaderSettingsChange( - pdfReaderSettings.copy(pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE) - ) - }, - label = { Text(readerString("visual_options_pdf_spread_two", "Two pages")) } - ) - } - if (pdfReaderSettings.pageSpreadMode == ReaderPageSpreadMode.TWO_PAGE) { - DesktopPdfVisualOptionSwitch( - title = readerString("visual_options_pdf_first_page_alone", "First page alone"), - description = readerString( - "visual_options_pdf_first_page_alone_desc", - "Starts facing-page spreads after the cover page." - ), - checked = pdfReaderSettings.pdfFirstPageStandaloneInSpread, - onCheckedChange = { enabled -> - onReaderSettingsChange( - pdfReaderSettings.copy(pdfFirstPageStandaloneInSpread = enabled) - ) - } - ) - } - } - DesktopPdfVisualOptionSwitch( - title = readerString("visual_options_remove_page_gap", "Remove gap between pages"), - description = readerString( - "desktop_remove_gap_between_pages_desc", - "Applies to vertical reading and two-page spreads." - ), - checked = !pdfReaderSettings.pdfVerticalPageGapVisible, - onCheckedChange = { removeGap -> - onReaderSettingsChange( - pdfReaderSettings.copy(pdfVerticalPageGapVisible = !removeGap) - ) - } - ) - DesktopPdfVisualOptionSwitch( - title = readerString("visual_options_hide_page_number_overlay", "Hide page number overlay"), - description = readerString( - "visual_options_hide_page_number_overlay_desc", - "Removes the small page count label from each page." - ), - checked = !pdfReaderSettings.pdfPageNumberOverlayVisible, - onCheckedChange = { hideOverlay -> - onReaderSettingsChange( - pdfReaderSettings.copy(pdfPageNumberOverlayVisible = !hideOverlay) - ) - } - ) - } - } - } - DesktopPdfInspectorTab.MARKUP -> { - item { - DesktopPdfInspectorSection(readerString("desktop_document_text", "Document text")) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - FilterChip( - selected = isRichTextMode, - onClick = onRichTextModeToggle, - label = { Text(readerString("desktop_document_text", "Document text")) } - ) - } - } - } - item { - DesktopPdfInspectorSection(readerString("desktop_highlighter_palette", "Highlighter palette")) { - SharedPdfHighlighterPaletteEditor( - palette = pdfHighlighterPalette, - onPaletteChange = onHighlighterPaletteChange - ) - } - } - if (isRichTextMode || selectedTool == PdfInkTool.TEXT) { - item { - DesktopPdfInspectorSection(readerString("desktop_text_style", "Text style")) { - SharedPdfTextAnnotationDock( - style = if (isRichTextMode) { - richTextController.currentSharedPdfTextStyleConfig() - } else { - effectiveTextStyleConfig - }, - onStyleChange = { style -> - if (isRichTextMode) { - richTextController.updateCurrentSharedPdfTextStyle(style) - } else { - onTextStyleChange(style) - } - } - ) - } - } - } - } - DesktopPdfInspectorTab.TTS -> { - item { - DesktopPdfTtsPanel( - extrasState = pdfExtrasState, - aiByokSettings = aiByokSettings, - cloudTtsFeatureAvailable = cloudTtsFeatureAvailable, - onCloudTtsClearCache = onCloudTtsClearCache, - onCloudTtsVoiceChange = onCloudTtsVoiceChange, - ttsReplacementPreferences = ttsReplacementPreferences, - ttsReplacementBookId = document.path, - onTtsReplacementPreferencesChange = onTtsReplacementPreferencesChange - ) - } - } - } - } - SharedReaderVerticalScrollbar( - listState = listState, - modifier = Modifier.align(Alignment.CenterEnd) - ) - } -} - -@Composable -private fun DesktopPdfInspectorTab.localizedTitle(): String { - return when (this) { - DesktopPdfInspectorTab.APP_THEME -> readerString("app_theme_title", "App theme") - DesktopPdfInspectorTab.APPEARANCE -> readerString("desktop_pdf_theme", "PDF theme") - DesktopPdfInspectorTab.VISUAL -> readerString("visual_options_title", "Visual") - DesktopPdfInspectorTab.MARKUP -> readerString("desktop_markup", "Markup") - DesktopPdfInspectorTab.TTS -> readerString("menu_tts_settings", "TTS") - } -} - -private fun DesktopPdfInspectorTab.icon(): ImageVector { - return when (this) { - DesktopPdfInspectorTab.APP_THEME -> Icons.Default.Palette - DesktopPdfInspectorTab.APPEARANCE -> Icons.Default.Palette - DesktopPdfInspectorTab.VISUAL -> Icons.Default.Tune - DesktopPdfInspectorTab.MARKUP -> Icons.Default.Edit - DesktopPdfInspectorTab.TTS -> Icons.AutoMirrored.Filled.VolumeUp - } -} - -private fun desktopPdfInspectorTabs(appThemeControlsAvailable: Boolean): List { - return buildList { - add(DesktopPdfInspectorTab.APPEARANCE) - if (appThemeControlsAvailable) add(DesktopPdfInspectorTab.APP_THEME) - add(DesktopPdfInspectorTab.VISUAL) - add(DesktopPdfInspectorTab.TTS) - } -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfKeyCommands.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfKeyCommands.kt deleted file mode 100644 index a5b3adb..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfKeyCommands.kt +++ /dev/null @@ -1,97 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.input.key.KeyEvent -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.type -import java.awt.event.KeyEvent as AwtKeyEvent - -internal enum class DesktopPdfKeyCommand { - PREVIOUS_PAGE, - NEXT_PAGE, - SCROLL_UP, - SCROLL_DOWN, - FIRST_PAGE, - LAST_PAGE, - SEARCH, - ZOOM_IN, - ZOOM_OUT, - EXIT_FULLSCREEN -} - -internal fun KeyEvent.desktopPdfKeyCommandOrNull( - fullscreen: Boolean, - editingText: Boolean, - rightToLeftPagination: Boolean = false -): DesktopPdfKeyCommand? { - if (type != KeyEventType.KeyDown) return null - if (fullscreen && key == Key.Escape) { - return DesktopPdfKeyCommand.EXIT_FULLSCREEN - } - if (editingText && !isCtrlPressed) { - return null - } - return when { - key == Key.DirectionLeft -> if (rightToLeftPagination) { - DesktopPdfKeyCommand.NEXT_PAGE - } else { - DesktopPdfKeyCommand.PREVIOUS_PAGE - } - key == Key.DirectionRight -> if (rightToLeftPagination) { - DesktopPdfKeyCommand.PREVIOUS_PAGE - } else { - DesktopPdfKeyCommand.NEXT_PAGE - } - key == Key.DirectionUp -> DesktopPdfKeyCommand.SCROLL_UP - key == Key.DirectionDown -> DesktopPdfKeyCommand.SCROLL_DOWN - key == Key.PageUp -> DesktopPdfKeyCommand.PREVIOUS_PAGE - key == Key.PageDown -> DesktopPdfKeyCommand.NEXT_PAGE - key == Key.MoveHome -> DesktopPdfKeyCommand.FIRST_PAGE - key == Key.MoveEnd -> DesktopPdfKeyCommand.LAST_PAGE - isCtrlPressed && key == Key.F -> DesktopPdfKeyCommand.SEARCH - isCtrlPressed && key == Key.Equals -> DesktopPdfKeyCommand.ZOOM_IN - isCtrlPressed && key == Key.Minus -> DesktopPdfKeyCommand.ZOOM_OUT - else -> null - } -} - -internal fun AwtKeyEvent.desktopPdfKeyCommandOrNull( - fullscreen: Boolean, - editingText: Boolean, - rightToLeftPagination: Boolean = false -): DesktopPdfKeyCommand? { - if (id != AwtKeyEvent.KEY_PRESSED) return null - if (fullscreen && keyCode == AwtKeyEvent.VK_ESCAPE) { - return DesktopPdfKeyCommand.EXIT_FULLSCREEN - } - if (editingText && !isControlDown) { - return null - } - return when (keyCode) { - AwtKeyEvent.VK_LEFT -> if (rightToLeftPagination) { - DesktopPdfKeyCommand.NEXT_PAGE - } else { - DesktopPdfKeyCommand.PREVIOUS_PAGE - } - AwtKeyEvent.VK_RIGHT -> if (rightToLeftPagination) { - DesktopPdfKeyCommand.PREVIOUS_PAGE - } else { - DesktopPdfKeyCommand.NEXT_PAGE - } - AwtKeyEvent.VK_UP -> DesktopPdfKeyCommand.SCROLL_UP - AwtKeyEvent.VK_DOWN -> DesktopPdfKeyCommand.SCROLL_DOWN - AwtKeyEvent.VK_PAGE_UP -> DesktopPdfKeyCommand.PREVIOUS_PAGE - AwtKeyEvent.VK_PAGE_DOWN -> DesktopPdfKeyCommand.NEXT_PAGE - AwtKeyEvent.VK_HOME -> DesktopPdfKeyCommand.FIRST_PAGE - AwtKeyEvent.VK_END -> DesktopPdfKeyCommand.LAST_PAGE - AwtKeyEvent.VK_F -> if (isControlDown) DesktopPdfKeyCommand.SEARCH else null - AwtKeyEvent.VK_EQUALS, - AwtKeyEvent.VK_PLUS, - AwtKeyEvent.VK_ADD -> if (isControlDown) DesktopPdfKeyCommand.ZOOM_IN else null - AwtKeyEvent.VK_MINUS, - AwtKeyEvent.VK_SUBTRACT -> if (isControlDown) DesktopPdfKeyCommand.ZOOM_OUT else null - else -> null - } -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfNavigationUi.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfNavigationUi.kt deleted file mode 100644 index d9ee5b1..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfNavigationUi.kt +++ /dev/null @@ -1,786 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.Image -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.fillMaxHeight -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio -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.rememberLazyListState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.automirrored.filled.ArrowForward -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.KeyboardArrowDown -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material3.AlertDialog -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 -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.ListItem -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ScrollableTabRow -import androidx.compose.material3.Surface -import androidx.compose.material3.Tab -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -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.layout.ContentScale -import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import org.dueattendant149.bookreader.shared.PdfTocEntry -import org.dueattendant149.bookreader.shared.pdf.PdfAnnotationKind -import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotation -import org.dueattendant149.bookreader.shared.pdf.SharedPdfBookmark -import org.dueattendant149.bookreader.shared.ui.SharedReaderVerticalScrollbar -import org.dueattendant149.bookreader.shared.ui.readerString -import org.dueattendant149.bookreader.shared.ui.sharedAcceleratedLazyWheelScroll -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -@Composable -internal fun DesktopPdfJumpHistoryControls( - visible: Boolean, - modifier: Modifier = Modifier, - backPage: Int?, - forwardPage: Int?, - onBack: () -> Unit, - onForward: () -> Unit, - onClear: () -> Unit -) { - val hasJumpTargets = backPage != null || forwardPage != null - AnimatedVisibility( - visible = visible && hasJumpTargets, - enter = slideInVertically { fullHeight -> fullHeight } + fadeIn(), - exit = slideOutVertically { fullHeight -> fullHeight } + fadeOut(), - modifier = modifier.fillMaxWidth() - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .height(40.dp) - .padding(horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - TextButton( - onClick = onBack, - enabled = backPage != null, - modifier = Modifier.weight(1f) - ) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = readerString("content_desc_jump_back", "Jump back"), - modifier = Modifier.size(18.dp) - ) - Spacer(Modifier.width(4.dp)) - Text( - backPage?.let { readerString("desktop_pdf_compact_page_number", "p. %1\$d", it + 1) } ?: "", - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - - TextButton( - onClick = onClear, - modifier = Modifier.weight(0.8f) - ) { - Icon( - Icons.Default.Close, - contentDescription = readerString("desktop_clear_jump_history", "Clear jump history"), - modifier = Modifier.size(18.dp) - ) - Spacer(Modifier.width(4.dp)) - Text(readerString("action_clear", "Clear"), maxLines = 1, overflow = TextOverflow.Ellipsis) - } - - TextButton( - onClick = onForward, - enabled = forwardPage != null, - modifier = Modifier.weight(1f) - ) { - Text( - forwardPage?.let { readerString("desktop_pdf_compact_page_number", "p. %1\$d", it + 1) } ?: "", - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Spacer(Modifier.width(4.dp)) - Icon( - Icons.AutoMirrored.Filled.ArrowForward, - contentDescription = readerString("content_desc_jump_forward", "Jump forward"), - modifier = Modifier.size(18.dp) - ) - } - } - } -} - -internal fun desktopPdfTocParentIndices(toc: List): Set { - return toc.indices.filter { index -> - val next = toc.getOrNull(index + 1) - next != null && next.nestLevel > toc[index].nestLevel - }.toSet() -} - -internal fun desktopPdfTocAncestorIndices( - toc: List, - originalIndex: Int -): Set { - val targetDepth = toc.getOrNull(originalIndex)?.nestLevel ?: return emptySet() - val ancestors = mutableSetOf() - var currentDepth = targetDepth - for (index in originalIndex downTo 0) { - val entry = toc[index] - if (entry.nestLevel < currentDepth) { - ancestors += index - currentDepth = entry.nestLevel - } - if (currentDepth == 0) break - } - return ancestors -} - -internal fun desktopVisiblePdfTocEntries( - toc: List, - expandedIndices: Set -): List> { - val result = mutableListOf>() - val visibilityStack = BooleanArray(50) { false } - visibilityStack[0] = true - - toc.forEachIndexed { index, entry -> - val depth = entry.nestLevel.coerceIn(0, visibilityStack.lastIndex) - if (visibilityStack[depth]) { - result += index to entry - if (depth + 1 < visibilityStack.size) { - visibilityStack[depth + 1] = index in expandedIndices - } - } else if (depth + 1 < visibilityStack.size) { - visibilityStack[depth + 1] = false - } - } - return result -} - -internal fun desktopPdfSidebarHighlights(annotations: List): List { - return annotations - .filter { it.kind == PdfAnnotationKind.HIGHLIGHT } - .sortedBy { it.pageIndex } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -internal fun DesktopPdfNavigationSidebar( - document: DesktopPdfDocument, - pageIndex: Int, - sortedHighlights: List, - bookmarks: List, - onPageSelected: (Int) -> Unit, - onAnnotationOpened: (SharedPdfAnnotation) -> Unit, - onAnnotationSelected: (SharedPdfAnnotation) -> Unit, - onAnnotationDeleted: (SharedPdfAnnotation) -> Unit -) { - val documentHandleId = document.handleId - val tabs = listOf( - readerString("desktop_toc", "TOC"), - readerString("tab_highlights", "Highlights"), - readerString("tab_bookmarks", "Bookmarks"), - readerString("tab_pages", "Pages") - ) - var selectedTabIndex by remember(documentHandleId) { mutableStateOf(0) } - val navigationScope = rememberCoroutineScope() - val pdfTocParentIndices = remember(document.toc) { desktopPdfTocParentIndices(document.toc) } - var expandedPdfTocEntryIndices by remember(documentHandleId, document.toc) { - mutableStateOf(pdfTocParentIndices) - } - - Surface( - modifier = Modifier - .width(300.dp) - .fillMaxHeight(), - color = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(8.dp), - tonalElevation = 2.dp - ) { - Column(Modifier.fillMaxSize()) { - ScrollableTabRow( - selectedTabIndex = selectedTabIndex, - edgePadding = 0.dp - ) { - tabs.forEachIndexed { index, title -> - Tab( - selected = selectedTabIndex == index, - onClick = { selectedTabIndex = index }, - text = { - Text( - title, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - ) - } - } - - when (selectedTabIndex) { - 0 -> { - if (document.toc.isEmpty()) { - DesktopPdfNavigationEmpty(readerString("desktop_no_table_of_contents", "No table of contents")) - } else { - val tocListState = rememberLazyListState() - val visibleTocItems by remember(document.toc, expandedPdfTocEntryIndices) { - derivedStateOf { desktopVisiblePdfTocEntries(document.toc, expandedPdfTocEntryIndices) } - } - val currentOriginalIndex = remember(document.toc, pageIndex) { - document.toc.indexOfLast { it.pageIndex <= pageIndex } - .takeIf { it >= 0 } - ?: document.toc.indexOfFirst { it.pageIndex == pageIndex }.takeIf { it >= 0 } - } - fun locateCurrentTocEntry() { - val originalIndex = currentOriginalIndex ?: return - navigationScope.launch { - expandedPdfTocEntryIndices = expandedPdfTocEntryIndices + - desktopPdfTocAncestorIndices(document.toc, originalIndex) - repeat(4) { - val visibleIndex = visibleTocItems.indexOfFirst { it.first == originalIndex } - if (visibleIndex >= 0) { - tocListState.animateScrollToItem(visibleIndex) - return@launch - } - delay(30) - } - } - } - Column(modifier = Modifier.fillMaxSize()) { - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp), - horizontalArrangement = Arrangement.SpaceEvenly - ) { - TextButton(onClick = { expandedPdfTocEntryIndices = pdfTocParentIndices }) { - Text(readerString("action_expand_all", "Expand all")) - } - TextButton(onClick = { expandedPdfTocEntryIndices = emptySet() }) { - Text(readerString("action_collapse_all", "Collapse all")) - } - TextButton(onClick = ::locateCurrentTocEntry, enabled = currentOriginalIndex != null) { - Text(readerString("action_locate", "Locate")) - } - } - HorizontalDivider() - Box(modifier = Modifier.fillMaxWidth().weight(1f)) { - LazyColumn( - state = tocListState, - modifier = Modifier - .fillMaxSize() - .sharedAcceleratedLazyWheelScroll(tocListState) - .padding(start = 12.dp, top = 12.dp, bottom = 12.dp, end = 24.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - items( - visibleTocItems, - key = { (index, entry) -> "nav_toc_${index}_${entry.pageIndex}_${entry.nestLevel}" } - ) { (originalIndex, entry) -> - val nextItem = document.toc.getOrNull(originalIndex + 1) - val hasChildren = nextItem != null && nextItem.nestLevel > entry.nestLevel - val isExpanded = originalIndex in expandedPdfTocEntryIndices - DesktopPdfTocTreeItem( - entry = entry, - selected = originalIndex == currentOriginalIndex, - hasChildren = hasChildren, - isExpanded = isExpanded, - onToggleExpand = { - expandedPdfTocEntryIndices = if (isExpanded) { - expandedPdfTocEntryIndices - originalIndex - } else { - expandedPdfTocEntryIndices + originalIndex - } - }, - onClick = { onPageSelected(entry.pageIndex) } - ) - } - } - SharedReaderVerticalScrollbar( - listState = tocListState, - modifier = Modifier.align(Alignment.CenterEnd) - ) - } - } - } - } - 1 -> { - if (sortedHighlights.isEmpty()) { - DesktopPdfNavigationEmpty(readerString("no_highlights_yet", "No highlights yet")) - } else { - val highlightsListState = rememberLazyListState() - var deleteHighlightConfirmFor by remember { mutableStateOf(null) } - var filterWithNotesOnly by remember { mutableStateOf(false) } - val filteredHighlights = remember(sortedHighlights, filterWithNotesOnly) { - if (filterWithNotesOnly) { - sortedHighlights.filter { !it.note.isNullOrBlank() } - } else { - sortedHighlights - } - } - - Column(modifier = Modifier.fillMaxSize()) { - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - FilterChip( - selected = !filterWithNotesOnly, - onClick = { filterWithNotesOnly = false }, - label = { Text(readerString("read_status_all", "All")) } - ) - FilterChip( - selected = filterWithNotesOnly, - onClick = { filterWithNotesOnly = true }, - label = { Text(readerString("filter_with_notes", "With notes")) } - ) - } - Box(modifier = Modifier.fillMaxWidth().weight(1f)) { - LazyColumn( - state = highlightsListState, - modifier = Modifier - .fillMaxSize() - .sharedAcceleratedLazyWheelScroll(highlightsListState) - .padding(end = 12.dp) - ) { - items(filteredHighlights, key = { "nav_highlight_${it.id}" }) { highlight -> - ListItem( - headlineContent = { - Text( - text = highlight.text.ifBlank { - readerString( - "msg_highlighted_section_default", - "Highlighted section" - ) - }, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - fontWeight = FontWeight.SemiBold - ) - }, - supportingContent = { - Column { - Row(verticalAlignment = Alignment.CenterVertically) { - Box( - modifier = Modifier - .size(12.dp) - .background(Color(highlight.colorArgb).copy(alpha = 1f), CircleShape) - ) - Spacer(Modifier.width(8.dp)) - Text( - readerString("pdf_page_short", "Page %1\$d", highlight.pageIndex + 1), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - highlight.note?.takeIf { it.isNotBlank() }?.let { note -> - Spacer(Modifier.height(8.dp)) - Surface( - shape = RoundedCornerShape(8.dp), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = note, - style = MaterialTheme.typography.bodySmall.copy(fontStyle = FontStyle.Italic), - modifier = Modifier.padding(12.dp), - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - }, - trailingContent = { - Box { - var highlightMenuExpanded by remember(highlight.id) { mutableStateOf(false) } - IconButton(onClick = { highlightMenuExpanded = true }) { - Icon( - Icons.Default.MoreVert, - contentDescription = readerString("content_desc_options", "Options") - ) - } - DropdownMenu( - expanded = highlightMenuExpanded, - onDismissRequest = { highlightMenuExpanded = false } - ) { - DropdownMenuItem( - text = { - Text( - if (highlight.note.isNullOrBlank()) { - readerString("menu_add_note", "Add note") - } else { - readerString("menu_edit_note", "Edit note") - } - ) - }, - onClick = { - onAnnotationSelected(highlight) - highlightMenuExpanded = false - } - ) - DropdownMenuItem( - text = { Text(readerString("action_delete", "Delete")) }, - onClick = { - deleteHighlightConfirmFor = highlight - highlightMenuExpanded = false - } - ) - } - } - }, - modifier = Modifier.clickable { onAnnotationOpened(highlight) } - ) - HorizontalDivider() - } - } - SharedReaderVerticalScrollbar( - listState = highlightsListState, - modifier = Modifier.align(Alignment.CenterEnd) - ) - } - } - - deleteHighlightConfirmFor?.let { highlight -> - AlertDialog( - onDismissRequest = { deleteHighlightConfirmFor = null }, - title = { Text(readerString("dialog_delete_highlight", "Delete highlight?")) }, - text = { Text(readerString("dialog_delete_highlight_desc", "This removes the highlight from this PDF.")) }, - confirmButton = { - TextButton( - onClick = { - onAnnotationDeleted(highlight) - deleteHighlightConfirmFor = null - } - ) { - Text(readerString("action_delete", "Delete")) - } - }, - dismissButton = { - TextButton(onClick = { deleteHighlightConfirmFor = null }) { - Text(readerString("action_cancel", "Cancel")) - } - } - ) - } - } - } - 2 -> { - if (bookmarks.isEmpty()) { - DesktopPdfNavigationEmpty(readerString("desktop_no_bookmarks_yet", "No bookmarks yet")) - } else { - val bookmarksListState = rememberLazyListState() - Box(modifier = Modifier.fillMaxSize()) { - LazyColumn( - state = bookmarksListState, - modifier = Modifier - .fillMaxSize() - .sharedAcceleratedLazyWheelScroll(bookmarksListState) - .padding(start = 12.dp, top = 12.dp, bottom = 12.dp, end = 24.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - 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 { onPageSelected(bookmark.pageIndex) } - ) { - Text( - bookmark.label.ifBlank { - readerString("pdf_page_short", "Page %1\$d", bookmark.pageIndex + 1) - }, - modifier = Modifier.padding(8.dp) - ) - } - } - } - SharedReaderVerticalScrollbar( - listState = bookmarksListState, - modifier = Modifier.align(Alignment.CenterEnd) - ) - } - } - } - 3 -> { - val pageRows = remember(document.pageCount) { (0 until document.pageCount).chunked(3) } - val pagesListState = rememberLazyListState() - val currentRowIndex = pageIndex / 3 - Column(modifier = Modifier.fillMaxSize()) { - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp), - horizontalArrangement = Arrangement.SpaceEvenly - ) { - TextButton( - onClick = { - navigationScope.launch { - pagesListState.animateScrollToItem( - currentRowIndex.coerceIn(0, pageRows.lastIndex.coerceAtLeast(0)) - ) - } - } - ) { - Text(readerString("action_locate", "Locate")) - } - } - HorizontalDivider() - Box(modifier = Modifier.fillMaxWidth().weight(1f)) { - LazyColumn( - state = pagesListState, - modifier = Modifier - .fillMaxSize() - .sharedAcceleratedLazyWheelScroll(pagesListState) - .padding(start = 12.dp, top = 12.dp, bottom = 12.dp, end = 24.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - items(pageRows, key = { row -> row.firstOrNull() ?: 0 }) { row -> - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - row.forEach { page -> - DesktopPdfThumbnailTile( - document = document, - pageIndex = page, - selected = page == pageIndex, - onClick = { onPageSelected(page) }, - modifier = Modifier.weight(1f) - ) - } - repeat(3 - row.size) { - Spacer(Modifier.weight(1f)) - } - } - } - } - SharedReaderVerticalScrollbar( - listState = pagesListState, - modifier = Modifier.align(Alignment.CenterEnd) - ) - } - } - } - } - } - } -} - -@Composable -internal fun DesktopPdfTocTreeItem( - entry: PdfTocEntry, - selected: Boolean, - hasChildren: Boolean, - isExpanded: Boolean, - onToggleExpand: () -> Unit, - onClick: () -> Unit -) { - Surface( - color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, - shape = RoundedCornerShape(6.dp), - modifier = Modifier.fillMaxWidth().clickable { onClick() } - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 46.dp) - .padding(start = (entry.nestLevel.coerceAtLeast(0) * 14).dp) - .padding(horizontal = 4.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = Modifier - .size(34.dp) - .clickable(enabled = hasChildren) { onToggleExpand() }, - contentAlignment = Alignment.Center - ) { - if (hasChildren) { - Icon( - imageVector = if (isExpanded) Icons.Default.KeyboardArrowDown else Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = if (isExpanded) { - readerString("content_desc_collapse", "Collapse") - } else { - readerString("content_desc_expand", "Expand") - }, - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - Text( - entry.title, - fontWeight = if (selected) FontWeight.Bold else if (entry.nestLevel == 0) FontWeight.SemiBold else FontWeight.Normal, - color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) - ) - Text( - readerString("desktop_pdf_compact_page_number", "p. %1\$d", entry.pageIndex + 1), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 8.dp) - ) - } - } -} - -@Composable -internal fun DesktopPdfNavigationEmpty(message: String) { - Box( - modifier = Modifier.fillMaxSize().padding(16.dp), - contentAlignment = Alignment.Center - ) { - Text(message, color = MaterialTheme.colorScheme.onSurfaceVariant) - } -} - -@Composable -internal fun DesktopPdfThumbnailTile( - document: DesktopPdfDocument, - pageIndex: Int, - selected: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier -) { - val documentHandleId = document.handleId - var thumbnail by remember(documentHandleId, pageIndex) { mutableStateOf(null) } - var renderFailed by remember(documentHandleId, pageIndex) { mutableStateOf(false) } - val pageSize = document.pageSizes.getOrNull(pageIndex) - val thumbnailScale = remember(pageSize) { - val width = pageSize?.width?.coerceAtLeast(1f) ?: 612f - (120f / width).coerceIn(0.08f, 0.35f) - } - - LaunchedEffect(documentHandleId, pageIndex, thumbnailScale) { - thumbnail = null - renderFailed = false - val rendered = withContext(Dispatchers.IO) { - runCatching { - DesktopPdfium.renderPage( - document = document, - pageIndex = pageIndex, - scale = thumbnailScale, - renderAnnotations = false - ) - }.getOrNull() - } - thumbnail = rendered - renderFailed = rendered == null - } - - Surface( - modifier = modifier.aspectRatio(0.707f).clickable(onClick = onClick), - shape = RoundedCornerShape(4.dp), - color = MaterialTheme.colorScheme.surfaceVariant, - border = BorderStroke( - width = if (selected) 2.dp else 1.dp, - color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant - ) - ) { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - val render = thumbnail - if (render != null) { - Image( - bitmap = render.image, - contentDescription = readerString("pdf_page_short", "Page %1\$d", pageIndex + 1), - contentScale = ContentScale.Fit, - modifier = Modifier.fillMaxSize().padding(3.dp) - ) - } else { - Text( - if (renderFailed) "!" else "${pageIndex + 1}", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Text( - text = "${pageIndex + 1}", - style = MaterialTheme.typography.labelSmall, - color = Color.White, - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(4.dp) - .background(Color.Black.copy(alpha = 0.58f), RoundedCornerShape(4.dp)) - .padding(horizontal = 5.dp, vertical = 1.dp) - ) - } - } -} - -@Composable -internal fun DesktopPdfPageScrubOverlay( - pageIndex: Int?, - pageCount: Int, - pageLabel: String? = pageIndex?.let { "Page ${it + 1} of $pageCount" } -) { - 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 = pageLabel ?: readerString( - "desktop_pdf_page_of_count", - "Page %1\$s of %2\$d", - "${pageIndex + 1}", - pageCount - ), - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp) - ) - } - } -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfPage.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfPage.kt deleted file mode 100644 index bf7c1e0..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfPage.kt +++ /dev/null @@ -1,988 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import androidx.compose.animation.Crossfade -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.awaitEachGesture -import androidx.compose.foundation.gestures.awaitFirstDown -import androidx.compose.foundation.gestures.detectDragGestures -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -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.rememberUpdatedState -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.PointerEventType -import androidx.compose.ui.input.pointer.changedToUp -import androidx.compose.ui.input.pointer.isPrimaryPressed -import androidx.compose.ui.input.pointer.isSecondaryPressed -import androidx.compose.ui.input.pointer.positionChange -import androidx.compose.ui.input.pointer.positionChanged -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.layout.positionInRoot -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.dp -import org.dueattendant149.bookreader.shared.ReaderTtsChunk -import org.dueattendant149.bookreader.shared.SearchHighlightMode -import org.dueattendant149.bookreader.shared.pdf.PdfAnnotationKind -import org.dueattendant149.bookreader.shared.pdf.PdfInkTool -import org.dueattendant149.bookreader.shared.pdf.PdfPageBounds -import org.dueattendant149.bookreader.shared.pdf.PdfPagePoint -import org.dueattendant149.bookreader.shared.pdf.PdfZoomSpec -import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotation -import org.dueattendant149.bookreader.shared.pdf.SharedPdfEmbeddedAnnotation -import org.dueattendant149.bookreader.shared.pdf.SharedPdfHighlighterPalette -import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichTextController -import org.dueattendant149.bookreader.shared.pdf.SharedPdfSearchEngine -import org.dueattendant149.bookreader.shared.pdf.SharedPdfSearchResult -import org.dueattendant149.bookreader.shared.pdf.SharedPdfTextDraft -import org.dueattendant149.bookreader.shared.pdf.sharedPdfTextStyle -import org.dueattendant149.bookreader.shared.ui.SharedPdfAnnotationOverlay -import org.dueattendant149.bookreader.shared.ui.SharedPdfEmbeddedAnnotationOverlay -import org.dueattendant149.bookreader.shared.ui.SharedPdfInlineTextEditorOverlay -import org.dueattendant149.bookreader.shared.ui.SharedPdfPageNumberOverlay -import org.dueattendant149.bookreader.shared.ui.SharedPdfRichTextLayer -import org.dueattendant149.bookreader.shared.ui.SharedPdfTextBoxEditorOverlay -import org.dueattendant149.bookreader.shared.ui.readerString -import org.dueattendant149.bookreader.shared.ui.sharedPdfEmbeddedHitTest -import org.dueattendant149.bookreader.shared.ui.sharedPdfHitTest -import org.dueattendant149.bookreader.shared.ui.toSharedPdfPoint -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.withContext - -private const val DesktopVerticalPdfPageTurnAnimationMillis = 140 - -@Composable -internal 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, - highlighterPalette: List, - strokeWidth: Float, - isHighlighterSnapEnabled: Boolean, - activeTextDraft: SharedPdfTextDraft?, - richTextController: SharedPdfRichTextController, - isRichTextMode: Boolean, - readerAiFeaturesAvailable: Boolean, - cloudTtsAvailable: Boolean, - externalLookupAvailable: Boolean, - themeStyle: DesktopPdfThemeStyle, - shouldRender: Boolean, - zoomPreview: DesktopPdfZoomPreview?, - zoomPreviewAnchorPageRootOffset: Offset? = null, - zoomPreviewScrollBounds: DesktopPdfZoomScrollBounds? = null, - zoomViewportRootOffset: Offset, - showPageNumberOverlay: Boolean = true, - onSelectPage: (Int) -> Unit, - onCopySelection: (DesktopPdfTextSelection) -> Unit, - onHighlightSelection: (Int, DesktopPdfTextSelection, IntSize, Int) -> Unit, - onExternalSearchSelection: (DesktopPdfTextSelection) -> Unit, - onHighlighterPaletteChange: (SharedPdfHighlighterPalette) -> Unit, - onDefineSelection: (DesktopPdfTextSelection) -> Unit, - onSpeakSelection: (DesktopPdfTextSelection) -> Unit, - onEmbeddedAnnotationSelected: (SharedPdfEmbeddedAnnotation) -> Unit, - onAnnotationSelected: (SharedPdfAnnotation?) -> 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, - onPan: (Offset) -> Unit, - onPageSizeChanged: (Int, IntSize) -> Unit = { _, _ -> }, - onPagePositioned: (Int, Offset) -> Unit -) { - val documentHandleId = document.handleId - val density = LocalDensity.current - var renderedPage by remember(documentHandleId) { mutableStateOf(null) } - var renderedPageIndex by remember(documentHandleId) { mutableStateOf(null) } - var renderedPageScale by remember(documentHandleId) { mutableStateOf(null) } - var renderError by remember(documentHandleId) { mutableStateOf(null) } - var isRendering by remember(documentHandleId) { mutableStateOf(true) } - var pageCanvasSize by remember(documentHandleId, pageIndex) { mutableStateOf(IntSize.Zero) } - var pageRootOffset by remember(documentHandleId, pageIndex) { mutableStateOf(Offset.Zero) } - var selectionStartIndex by remember(documentHandleId, pageIndex) { mutableStateOf(null) } - var selectionEndIndex by remember(documentHandleId, pageIndex) { mutableStateOf(null) } - var selectionStartHit by remember(documentHandleId, pageIndex) { mutableStateOf(null) } - var selectionEndHit by remember(documentHandleId, pageIndex) { mutableStateOf(null) } - var textSelection by remember(documentHandleId, pageIndex) { mutableStateOf(null) } - var selectionMenuOffset by remember(documentHandleId, pageIndex) { mutableStateOf(null) } - var activeSelectionHandle by remember(documentHandleId, pageIndex) { mutableStateOf(null) } - var activeStroke by remember(documentHandleId, pageIndex, selectedTool) { mutableStateOf>(emptyList()) } - var eraserPosition by remember(documentHandleId, pageIndex, selectedTool) { mutableStateOf(null) } - val currentTextSelection by rememberUpdatedState(textSelection) - val currentAnnotations by rememberUpdatedState(annotations) - - fun clearSelection() { - selectionStartIndex = null - selectionEndIndex = null - selectionStartHit = null - selectionEndHit = null - textSelection = null - selectionMenuOffset = null - activeSelectionHandle = null - } - - fun clearInteractionState() { - clearSelection() - activeStroke = emptyList() - eraserPosition = null - } - val failedRenderMessage = readerString("desktop_failed_render_page", "Failed to render page.") - - LaunchedEffect(documentHandleId, pageIndex, scale, shouldRender) { - if (!shouldRender) { - logPdfZoomSettle { - "item_render_skip page=${pageIndex + 1} reason=outside_window scale=${scale.formatLogFloat()}" - } - renderedPage = null - renderedPageIndex = null - renderedPageScale = null - renderError = null - isRendering = false - clearInteractionState() - return@LaunchedEffect - } - val hasPageRender = renderedPage != null && desktopPdfRenderBelongsToPage(renderedPageIndex, pageIndex) - logPdfZoomSettle { - "item_render_effect page=${pageIndex + 1} scale=${scale.formatLogFloat()} shouldRender=$shouldRender " + - "hasRender=$hasPageRender renderedPage=${renderedPageIndex?.plus(1) ?: "none"} " + - "renderScale=${renderedPageScale?.formatLogFloat() ?: "none"}" - } - if (!hasPageRender) { - renderedPage = null - renderedPageIndex = null - renderedPageScale = null - isRendering = true - } - renderError = null - val pageSize = document.pageSizes.getOrNull(pageIndex) - if (pageSize == null) { - renderedPage = null - renderedPageIndex = null - renderedPageScale = null - renderError = failedRenderMessage - isRendering = false - return@LaunchedEffect - } - val safeScale = zoomSpec.safeRenderScale(pageSize.width, pageSize.height, scale) - if (hasPageRender && !desktopPdfRenderScaleNeedsUpgrade(renderedPageScale, safeScale)) { - logPdfZoomSettle { - "item_render_skip page=${pageIndex + 1} reason=no_scale_upgrade " + - "safeScale=${safeScale.formatLogFloat()} existingScale=${renderedPageScale?.formatLogFloat() ?: "none"}" - } - isRendering = false - return@LaunchedEffect - } - logPdfZoomSettle { - "item_render_scheduled page=${pageIndex + 1} safeScale=${safeScale.formatLogFloat()} " + - "delayMs=${if (hasPageRender) DesktopPdfZoomRenderDebounceMillis else 45L} hasRender=$hasPageRender" - } - delay(if (hasPageRender) DesktopPdfZoomRenderDebounceMillis else 45L) - isRendering = true - val renderStartedAt = System.currentTimeMillis() - val result = withContext(Dispatchers.IO) { - runCatching { DesktopPdfium.renderPage(document, pageIndex, safeScale) } - } - val renderElapsedMs = System.currentTimeMillis() - renderStartedAt - result.getOrNull()?.let { - renderedPage = it - renderedPageIndex = pageIndex - renderedPageScale = safeScale - } - val renderedCurrentPage = renderedPage != null && desktopPdfRenderBelongsToPage(renderedPageIndex, pageIndex) - renderError = result.exceptionOrNull()?.message - ?: if (!renderedCurrentPage && renderedPage == null) failedRenderMessage else null - isRendering = false - logPdfZoomSettle { - "item_render_end page=${pageIndex + 1} safeScale=${safeScale.formatLogFloat()} " + - "elapsedMs=$renderElapsedMs success=${result.isSuccess} bitmap=${renderedPage?.width ?: 0}x${renderedPage?.height ?: 0} " + - "canvas=${pageCanvasSize.formatLogSize()} root=${pageRootOffset.formatLogOffset()}" - } - } - - LaunchedEffect(isTextSelectionMode) { - if (!isTextSelectionMode) { - clearSelection() - } else { - activeStroke = emptyList() - eraserPosition = null - } - } - - LaunchedEffect(selectedTool) { - activeStroke = emptyList() - eraserPosition = null - } - - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(6.dp) - ) { - val pageSize = document.pageSizes.getOrNull(pageIndex) - val displayPageIndex = renderedPageIndex ?: pageIndex - val displayPageIsCurrent = displayPageIndex == pageIndex - val placeholderScale = zoomSpec.clamp(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 - .size(placeholderWidthDp, placeholderHeightDp) - .onGloballyPositioned { coordinates -> - val rootOffset = coordinates.positionInRoot() - if (rootOffset != pageRootOffset) { - logPdfZoomSettle { - "item_layout page=${pageIndex + 1} prevRoot=${pageRootOffset.formatLogOffset()} " + - "nextRoot=${rootOffset.formatLogOffset()} scale=${scale.formatLogFloat()} " + - "preview=${zoomPreview != null} canvas=${pageCanvasSize.formatLogSize()}" - } - } - pageRootOffset = rootOffset - onPagePositioned(pageIndex, rootOffset) - } - .onSizeChanged { size -> - if (pageCanvasSize != size) { - logPdfZoomSettle { - "item_size page=${pageIndex + 1} prev=${pageCanvasSize.formatLogSize()} " + - "next=${size.formatLogSize()} scale=${scale.formatLogFloat()} " + - "preview=${zoomPreview != null} renderScale=${pageRenderScale.formatLogFloat()}" - } - } - pageCanvasSize = size - onPageSizeChanged(pageIndex, size) - } - .desktopPdfDocumentZoomPreviewLayer( - preview = zoomPreview, - currentZoom = scale, - viewportRootOffset = zoomViewportRootOffset, - pageRootOffset = pageRootOffset, - anchorPageRootOffset = zoomPreviewAnchorPageRootOffset, - scrollBounds = zoomPreviewScrollBounds - ) - .background(themeStyle.pageBackgroundColor, RoundedCornerShape(2.dp)) - .pointerInput( - pageIndex, - displayPageIsCurrent, - pageCanvasSize, - isTextSelectionMode, - selectedTool, - isRichTextMode - ) { - if (!displayPageIsCurrent || 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 (isTextSelectionMode) { - logPdfChromeTap { - "page_press source=vertical_page page=${pageIndex + 1} " + - "x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()} " + - "consumedBefore=${event.changes.any { it.isConsumed }} " + - "selectionActive=${currentTextSelection != null} " + - "selectionMenuOpen=${selectionMenuOffset != null} " + - "selectedTool=$selectedTool richText=$isRichTextMode" - } - } - val highlightHit = if (selectedTool != PdfInkTool.TEXT && selectedTool != PdfInkTool.ERASER) { - currentAnnotations.asReversed().firstOrNull { - it.isDesktopTextSelectionHighlight && - it.pageIndex == pageIndex && - it.sharedPdfHitTest(point, pageCanvasSize) - } - } else { - null - } - if (highlightHit != null) { - logPdfChromeTap { - "page_press_consume source=vertical_page page=${pageIndex + 1} " + - "reason=text_selection_highlight annotation=${highlightHit.id}" - } - onSelectPage(pageIndex) - onAnnotationSelected(highlightHit) - clearInteractionState() - event.changes.forEach { it.consume() } - continue - } - if (selectedTool != PdfInkTool.TEXT) { - val linkTarget = document.linkAt(pageIndex, point, pageCanvasSize) - if (linkTarget != null) { - logPdfChromeTap { - "page_press_consume source=vertical_page page=${pageIndex + 1} " + - "reason=link target=${linkTarget.formatLogTarget()}" - } - 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) { - logPdfChromeTap { - "page_press_consume source=vertical_page page=${pageIndex + 1} " + - "reason=embedded_annotation annotation=${embeddedHit.id}" - } - onSelectPage(pageIndex) - onEmbeddedAnnotationSelected(embeddedHit) - clearInteractionState() - event.changes.forEach { it.consume() } - } else if ( - currentTextSelection != null && - selectionMenuOffset == null - ) { - logPdfChromeTap { - "page_press_passthrough source=vertical_page page=${pageIndex + 1} " + - "action=clear_selection consumed=false" - } - clearSelection() - } else if (isTextSelectionMode) { - logPdfChromeTap { - "page_press_passthrough source=vertical_page page=${pageIndex + 1} " + - "action=none consumed=false" - } - } - } 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, displayPageIsCurrent, pageCanvasSize, isTextSelectionMode, isRichTextMode) { - if (!displayPageIsCurrent || isRichTextMode || !isTextSelectionMode) return@pointerInput - detectDesktopPdfTextSelectionLongPress( - source = "vertical_page", - pageIndex = pageIndex - ) { point -> - val selection = document.wordSelectionAt(pageIndex, point, pageCanvasSize) - logPdfChromeTap { - "long_press_selection source=vertical_page page=${pageIndex + 1} " + - "selectionFound=${selection != null} " + - "x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()}" - } - if (selection != null) { - onSelectPage(pageIndex) - selectionStartIndex = null - selectionEndIndex = null - selectionStartHit = null - selectionEndHit = null - activeSelectionHandle = null - textSelection = selection - selectionMenuOffset = selection.menuAnchor(pageCanvasSize, point) - logPdfSelection( - "long_press page=${pageIndex + 1} " + - "x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()} " + - "range=${selection.startIndex}..${selection.endIndex} " + - "chars=${selection.text.length} " + - "text=\"${selection.text.logPreview()}\"" - ) - } - } - } - .pointerInput(pageIndex, displayPageIsCurrent, selectedTool, isTextSelectionMode, isRichTextMode) { - if (!displayPageIsCurrent || isRichTextMode || isTextSelectionMode || selectedTool != PdfInkTool.NONE) { - return@pointerInput - } - awaitEachGesture { - val down = awaitFirstDown(requireUnconsumed = false) - if (!currentEvent.buttons.isPrimaryPressed) return@awaitEachGesture - val pointerId = down.id - var dragStarted = false - var dragDistance = 0f - while (true) { - val event = awaitPointerEvent() - val change = event.changes.firstOrNull { it.id == pointerId } - ?: return@awaitEachGesture - if (change.changedToUp()) { - return@awaitEachGesture - } - if (!change.positionChanged()) continue - val delta = change.positionChange() - if (!dragStarted) { - dragDistance += delta.getDistance() - if (dragDistance <= viewConfiguration.touchSlop) { - continue - } - dragStarted = true - change.consume() - continue - } - onPan(delta) - change.consume() - } - } - } - .pointerInput( - pageIndex, - isTextSelectionMode, - selectedTool, - selectedColor, - strokeWidth, - isHighlighterSnapEnabled, - activeTextDraft?.id, - isRichTextMode, - pageCanvasSize, - renderedPageWidth, - renderedPageHeight, - displayPageIsCurrent - ) { - if (displayPageIsCurrent && renderedPageWidth > 0 && renderedPageHeight > 0) { - if (isRichTextMode) return@pointerInput - if (isTextSelectionMode) { - var latestSelectionDragPoint: Offset? = null - var lastSelectionPreviewAt = 0L - detectDragGestures( - onDragStart = { start -> - latestSelectionDragPoint = start - lastSelectionPreviewAt = 0L - onSelectPage(pageIndex) - activeStroke = emptyList() - selectionMenuOffset = null - val existingSelection = textSelection - val handle = existingSelection?.handleAt(start, pageCanvasSize) - activeSelectionHandle = handle - val hit = document.charHitAt(pageIndex, start, pageCanvasSize) - if (handle != null && existingSelection != null) { - selectionStartHit = null - selectionStartIndex = when (handle) { - DesktopPdfSelectionHandle.START -> existingSelection.endIndex - DesktopPdfSelectionHandle.END -> existingSelection.startIndex - } - selectionEndHit = hit - selectionEndIndex = hit?.index ?: when (handle) { - DesktopPdfSelectionHandle.START -> existingSelection.startIndex - DesktopPdfSelectionHandle.END -> existingSelection.endIndex - } - } else { - selectionStartHit = hit - selectionStartIndex = hit?.index - selectionEndHit = null - selectionEndIndex = null - textSelection = null - } - logPdfSelection( - "drag_start page=${pageIndex + 1} " + - "canvas=${pageCanvasSize.formatLogSize()} bitmap=${renderedPageWidth}x$renderedPageHeight " + - "requestedScale=${scale.formatLogFloat()} renderScale=${pageRenderScale.formatLogFloat()} " + - "handle=${handle?.name ?: "none"} " + - hit.formatLogHit("start") - ) - }, - onDrag = { change, _ -> - latestSelectionDragPoint = change.position - val now = System.currentTimeMillis() - if (lastSelectionPreviewAt == 0L || - now - lastSelectionPreviewAt >= DesktopPdfSelectionPreviewThrottleMillis - ) { - lastSelectionPreviewAt = now - 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.selectionPreviewBetweenIndexes( - pageIndex = pageIndex, - startIndex = startIndex, - endIndex = endIndex, - canvasSize = pageCanvasSize - ) - } else { - null - } - } - } - change.consume() - }, - onDragEnd = { - val finalHit = latestSelectionDragPoint - ?.let { document.charHitAt(pageIndex, it, pageCanvasSize) } - ?: selectionEndHit - if (finalHit != null) { - selectionEndHit = finalHit - selectionEndIndex = finalHit.index - } - 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 - ) - } else { - textSelection?.takeIf { it.text.isNotBlank() } - } - textSelection = selection - selectionMenuOffset = selection?.menuAnchor( - pageCanvasSize, - finalHit?.point ?: selectionEndHit?.point ?: selectionStartHit?.point - ) - 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 - activeSelectionHandle = null - latestSelectionDragPoint = null - lastSelectionPreviewAt = 0L - }, - 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 - activeSelectionHandle = null - latestSelectionDragPoint = null - lastSelectionPreviewAt = 0L - } - ) - } 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 if (selectedTool != PdfInkTool.NONE) { - var eraserPreviousPoint: Offset? = null - awaitEachGesture { - val down = awaitFirstDown(requireUnconsumed = false) - if (!currentEvent.buttons.isPrimaryPressed) return@awaitEachGesture - val start = down.position - onSelectPage(pageIndex) - clearInteractionState() - if (selectedTool == PdfInkTool.ERASER) { - eraserPosition = start - 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()) - ) - } - - val pointerId = down.id - var dragStarted = false - while (true) { - val event = awaitPointerEvent() - if (event.changes.size > 1) { - eraserPreviousPoint = null - eraserPosition = null - activeStroke = emptyList() - return@awaitEachGesture - } - val change = event.changes.firstOrNull { it.id == pointerId } - ?: run { - eraserPreviousPoint = null - eraserPosition = null - activeStroke = emptyList() - return@awaitEachGesture - } - if (change.changedToUp()) { - change.consume() - if (selectedTool != PdfInkTool.ERASER && activeStroke.isNotEmpty()) { - onAnnotationAdded( - SharedPdfAnnotation( - id = "ink_${System.currentTimeMillis()}", - pageIndex = pageIndex, - kind = PdfAnnotationKind.INK, - tool = selectedTool, - points = activeStroke, - colorArgb = selectedColor, - strokeWidth = strokeWidth, - createdAt = System.currentTimeMillis() - ) - ) - } - eraserPreviousPoint = null - eraserPosition = null - activeStroke = emptyList() - return@awaitEachGesture - } - if (!change.positionChanged()) continue - val distance = (change.position - start).getDistance() - if (selectedTool != PdfInkTool.ERASER && !dragStarted && distance <= viewConfiguration.touchSlop) continue - dragStarted = true - if (selectedTool == PdfInkTool.ERASER) { - val point = change.position - eraserPosition = point - 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() - ) - } - change.consume() - } - } - } - } - }, - contentAlignment = Alignment.Center - ) { - when { - !shouldRender -> { - Text( - readerString("pdf_page_short", "Page %1\$d", pageIndex + 1), - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - renderError != null && renderedPageIndex != pageIndex -> Text( - renderError ?: readerString("desktop_failed_render_page", "Failed to render page."), - color = MaterialTheme.colorScheme.error - ) - renderedPage != null && desktopPdfRenderBelongsToPage(renderedPageIndex, pageIndex) -> { - val currentRenderedPageIndex = renderedPageIndex!! - Crossfade( - targetState = currentRenderedPageIndex, - animationSpec = tween(DesktopVerticalPdfPageTurnAnimationMillis), - label = "DesktopVerticalPdfPage" - ) { pageIndex -> - val pageRender = renderedPage!! - val pageEmbeddedAnnotations = remember(document.embeddedAnnotations, pageIndex) { - document.embeddedAnnotations.filter { it.pageIndex == pageIndex } - } - 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() - } - } - - DesktopPdfThemedPageImage( - bitmap = pageRender.image, - contentDescription = readerString("desktop_pdf_page_content_desc", "PDF page %1\$d", pageIndex + 1), - themeStyle = themeStyle, - modifier = Modifier.fillMaxSize() - ) - 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, - eraserPosition = eraserPosition, - showEraserIndicator = selectedTool == PdfInkTool.ERASER, - eraserStrokeWidth = strokeWidth - ) - PdfTextSelectionHandles( - selection = textSelection, - canvasSize = pageCanvasSize, - activeHandle = activeSelectionHandle - ) - 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 - ) - if (showPageNumberOverlay) { - SharedPdfPageNumberOverlay( - pageIndex = pageIndex, - pageCount = document.pageCount - ) - } - if (textSelection != null && selectionMenuOffset != null) { - Box( - modifier = Modifier - .matchParentSize() - .pointerInput(pageIndex, selectionMenuOffset) { - detectTapGestures { - logPdfChromeTap { - "selection_menu_scrim_tap source=vertical_page page=${pageIndex + 1} " + - "consumedByScrim=true" - } - clearSelection() - } - } - ) - } - PdfSelectionMenu( - selection = textSelection, - menuOffset = selectionMenuOffset, - canvasSize = pageCanvasSize, - highlighterPalette = highlighterPalette, - onHighlighterPaletteChange = onHighlighterPaletteChange, - onCopy = { - textSelection?.let(onCopySelection) - clearSelection() - }, - onHighlight = { colorArgb -> - textSelection?.let { onHighlightSelection(pageIndex, it, pageCanvasSize, colorArgb) } - clearSelection() - }, - onSearch = { - textSelection?.let(onExternalSearchSelection) - clearSelection() - }, - onDefine = { - textSelection?.let(onDefineSelection) - clearSelection() - }, - onSpeak = { - textSelection?.let(onSpeakSelection) - clearSelection() - }, - showDefine = readerAiFeaturesAvailable, - showSpeak = cloudTtsAvailable, - showSearch = externalLookupAvailable, - onClear = ::clearSelection - ) - } - } - isRendering -> CircularProgressIndicator() - renderError != null -> Text( - renderError ?: readerString("desktop_failed_render_page", "Failed to render page."), - color = MaterialTheme.colorScheme.error - ) - } - } - } -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfPageInteractions.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfPageInteractions.kt deleted file mode 100644 index 064ad73..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfPageInteractions.kt +++ /dev/null @@ -1,446 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import androidx.compose.foundation.gestures.awaitEachGesture -import androidx.compose.foundation.gestures.awaitFirstDown -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.input.pointer.PointerInputScope -import androidx.compose.ui.input.pointer.changedToUp -import androidx.compose.ui.input.pointer.isSecondaryPressed -import androidx.compose.ui.unit.IntSize -import org.dueattendant149.bookreader.shared.pdf.PdfAnnotationKind -import org.dueattendant149.bookreader.shared.pdf.PdfInkTool -import org.dueattendant149.bookreader.shared.pdf.PdfNormalizedPoint -import org.dueattendant149.bookreader.shared.pdf.PdfPageBounds -import org.dueattendant149.bookreader.shared.pdf.PdfPagePoint -import org.dueattendant149.bookreader.shared.pdf.PdfSelectionGeometry -import org.dueattendant149.bookreader.shared.pdf.PdfTextCharBounds -import org.dueattendant149.bookreader.shared.pdf.PdfZoomSpec -import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotation -import org.dueattendant149.bookreader.shared.pdf.SharedPdfInkRenderer -import org.dueattendant149.bookreader.shared.pdf.SharedPdfReaderAction -import org.dueattendant149.bookreader.shared.pdf.SharedPdfReaderState -import org.dueattendant149.bookreader.shared.pdf.SharedPdfTextDraft -import org.dueattendant149.bookreader.shared.pdf.reduce -import org.dueattendant149.bookreader.shared.ui.sharedPdfHitTest -import org.dueattendant149.bookreader.shared.ui.toSharedPdfPoint -import kotlinx.coroutines.TimeoutCancellationException -import kotlinx.coroutines.withTimeout - -internal val PdfInkTool.isDesktopHighlighter: Boolean - get() = this == PdfInkTool.HIGHLIGHTER || this == PdfInkTool.HIGHLIGHTER_ROUND - -internal val SharedPdfAnnotation.isDesktopTextSelectionHighlight: Boolean - get() = kind == PdfAnnotationKind.HIGHLIGHT && - text.isNotBlank() && - rangeStartIndex != null && - rangeEndIndex != null - -internal fun SharedPdfReaderState.withDesktopPdfTextSelectionHighlightAdded( - annotation: SharedPdfAnnotation, - zoomSpec: PdfZoomSpec = PdfZoomSpec() -): SharedPdfReaderState { - val next = reduce(SharedPdfReaderAction.AnnotationAdded(annotation), zoomSpec) - return if (annotation.isDesktopTextSelectionHighlight) { - next.reduce(SharedPdfReaderAction.AnnotationSelected(null), zoomSpec) - } else { - next - } -} - -internal fun SharedPdfReaderState.withDesktopPdfTextHighlightSheetDismissed( - zoomSpec: PdfZoomSpec = PdfZoomSpec() -): SharedPdfReaderState { - return reduce(SharedPdfReaderAction.AnnotationSelected(null), zoomSpec) -} - -internal 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 -} - -internal suspend fun PointerInputScope.detectDesktopPdfTextSelectionLongPress( - source: String, - pageIndex: Int, - onLongPress: (Offset) -> Unit -) { - awaitEachGesture { - val down = awaitFirstDown(requireUnconsumed = false) - val secondaryDown = currentEvent.buttons.isSecondaryPressed - logPdfChromeTap { - "long_press_down source=$source page=${pageIndex + 1} " + - "x=${down.position.x.formatLogFloat()} y=${down.position.y.formatLogFloat()} " + - "downConsumed=${down.isConsumed} secondary=$secondaryDown" - } - if (down.isConsumed || secondaryDown) { - logPdfChromeTap { - "long_press_skip source=$source page=${pageIndex + 1} " + - "reason=${if (down.isConsumed) "down_consumed" else "secondary_button"}" - } - return@awaitEachGesture - } - val pointerId = down.id - val start = down.position - var latestPosition = start - var canceledBeforeLongPress = false - var longPressReached = false - var cancelReason = "" - - try { - withTimeout(viewConfiguration.longPressTimeoutMillis) { - while (true) { - val event = awaitPointerEvent() - if (event.buttons.isSecondaryPressed) { - canceledBeforeLongPress = true - cancelReason = "secondary_button" - return@withTimeout - } - val change = event.changes.firstOrNull { it.id == pointerId } - if (change == null) { - canceledBeforeLongPress = true - cancelReason = "pointer_lost" - return@withTimeout - } - latestPosition = change.position - val distance = (latestPosition - start).getDistance() - when { - change.isConsumed -> { - canceledBeforeLongPress = true - cancelReason = "change_consumed" - return@withTimeout - } - change.changedToUp() || !change.pressed -> { - canceledBeforeLongPress = true - cancelReason = "up_before_long_press" - return@withTimeout - } - distance > viewConfiguration.touchSlop -> { - canceledBeforeLongPress = true - cancelReason = "moved distance=${distance.formatLogFloat()}" - return@withTimeout - } - } - } - } - } catch (_: TimeoutCancellationException) { - longPressReached = !canceledBeforeLongPress - } - - if (!longPressReached) { - logPdfChromeTap { - "long_press_cancel source=$source page=${pageIndex + 1} " + - "reason=${cancelReason.ifBlank { "unknown" }} " + - "x=${latestPosition.x.formatLogFloat()} y=${latestPosition.y.formatLogFloat()}" - } - return@awaitEachGesture - } - logPdfChromeTap { - "long_press_reached source=$source page=${pageIndex + 1} " + - "x=${latestPosition.x.formatLogFloat()} y=${latestPosition.y.formatLogFloat()}" - } - onLongPress(latestPosition) - while (true) { - val event = awaitPointerEvent() - val change = event.changes.firstOrNull { it.id == pointerId } ?: return@awaitEachGesture - change.consume() - if (change.changedToUp() || !change.pressed) return@awaitEachGesture - } - } -} - -internal data class DesktopPdfCharHit( - val index: Int, - val source: String, - val point: Offset, - val normalized: PdfNormalizedPoint -) - -internal fun SharedPdfAnnotation.toDesktopPdfTextSelection(): DesktopPdfTextSelection { - return DesktopPdfTextSelection( - text = text, - lineBounds = boundsList.ifEmpty { listOfNotNull(bounds) }, - startIndex = rangeStartIndex ?: 0, - endIndex = rangeEndIndex ?: text.length - ) -} - -internal 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 - ) -} - -internal 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 - ) -} - -internal fun DesktopPdfDocument.wordSelectionAt( - pageIndex: Int, - point: Offset, - canvasSize: IntSize -): DesktopPdfTextSelection? { - val hit = charHitAt(pageIndex, point, canvasSize) ?: return null - if (hit.source == "fallback_line" && !isPointNearTextChar(pageIndex, hit.index, hit.normalized)) { - return null - } - val pageText = textPageData(pageIndex).text - if (pageText.isEmpty()) return null - val hitIndex = hit.index.coerceIn(0, pageText.lastIndex) - if (!pageText[hitIndex].isDesktopPdfWordPart()) return null - var startIndex = hitIndex - while (startIndex > 0 && pageText[startIndex - 1].isDesktopPdfWordPart()) { - startIndex -= 1 - } - var endIndex = hitIndex - while (endIndex < pageText.lastIndex && pageText[endIndex + 1].isDesktopPdfWordPart()) { - endIndex += 1 - } - return selectionBetweenIndexes( - pageIndex = pageIndex, - startIndex = startIndex, - endIndex = endIndex, - canvasSize = canvasSize, - useNativeBounds = true - ) -} - -private fun DesktopPdfDocument.isPointNearTextChar( - pageIndex: Int, - charIndex: Int, - point: PdfNormalizedPoint -): Boolean { - val charBounds = textPageData(pageIndex).chars - .visiblePdfTextBounds() - .firstOrNull { it.index == charIndex } - ?: return false - val horizontalPadding = maxOf((charBounds.right - charBounds.left) * 2f, 0.025f) - val verticalPadding = maxOf((charBounds.bottom - charBounds.top) * 0.65f, 0.006f) - return point.x in (charBounds.left - horizontalPadding)..(charBounds.right + horizontalPadding) && - point.y in (charBounds.top - verticalPadding)..(charBounds.bottom + verticalPadding) -} - -private fun Char.isDesktopPdfWordPart(): Boolean { - return isLetterOrDigit() || this == '\'' || this == '-' || this == '_' -} - -internal fun DesktopPdfDocument.selectionPreviewBetweenIndexes( - pageIndex: Int, - startIndex: Int, - endIndex: Int, - canvasSize: IntSize -): DesktopPdfTextSelection? { - return selectionBetweenIndexes( - pageIndex = pageIndex, - startIndex = startIndex, - endIndex = endIndex, - canvasSize = canvasSize, - useNativeBounds = false, - includeText = false - ) -} - -internal fun DesktopPdfDocument.selectionBetweenIndexes( - pageIndex: Int, - startIndex: Int, - endIndex: Int, - canvasSize: IntSize, - useNativeBounds: Boolean = true, - includeText: Boolean = true -): DesktopPdfTextSelection? { - val chars = textPageData(pageIndex).chars - if (chars.isEmpty()) return null - val firstIndex = minOf(startIndex, endIndex) - val lastIndex = maxOf(startIndex, endIndex) - val selectedChars = chars.filter { it.index in firstIndex..lastIndex } - if (selectedChars.isEmpty()) return null - val text = if (includeText) { - selectedChars.joinToString("") { it.char.toString() } - .replace(DesktopPdfSelectionInlineWhitespaceRegex, " ") - .replace(DesktopPdfSelectionBlankLinesRegex, "\n\n") - .trim() - } else { - "" - } - if (includeText && text.isBlank()) return null - val fallbackBounds = PdfSelectionGeometry.lineBoundsForChars(selectedChars.visiblePdfTextBounds()) - if (!includeText && fallbackBounds.isEmpty()) return null - 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 - ) -} - -internal fun DesktopPdfTextRect.toPdfPageBounds(): PdfPageBounds { - return PdfPageBounds( - left = left, - top = top, - right = right, - bottom = bottom - ) -} - -internal 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 - ) - } -} - -internal 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 -} - -internal 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) - } -} - -internal 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 - ) -} - -internal const val DesktopPdfSelectionPreviewThrottleMillis = 32L -internal const val DesktopPdfZoomCommitDebounceMillis = 260L -internal const val DesktopPdfZoomRenderDebounceMillis = 300L -internal const val DesktopPdfViewportPersistDebounceMillis = 300L -internal const val DesktopPdfPaginationPrefetchDelayMillis = 450L -internal const val DesktopPdfRenderScaleTolerance = 0.01f -internal const val DesktopPdfPaginationRenderCacheRadius = 2 -private val DesktopPdfSelectionInlineWhitespaceRegex = Regex("[ \\t\\x0B\\f\\r]+") -private val DesktopPdfSelectionBlankLinesRegex = Regex("\\n{3,}") diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfPasswordDialog.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfPasswordDialog.kt deleted file mode 100644 index adafc75..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfPasswordDialog.kt +++ /dev/null @@ -1,82 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.MaterialTheme -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.Modifier -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.unit.dp -import org.dueattendant149.bookreader.shared.ui.SharedStableOutlinedTextField -import org.dueattendant149.bookreader.shared.ui.readerString - -@Composable -internal fun DesktopPdfPasswordDialog( - title: String, - isError: Boolean, - onDismiss: () -> Unit, - onConfirm: (String) -> Unit -) { - var password by remember(title, isError) { mutableStateOf("") } - - LaunchedEffect(title, isError) { - password = "" - } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(readerString("desktop_password_protected_pdf", "Password protected PDF")) }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text( - if (isError) { - readerString( - "desktop_pdf_password_retry_desc", - "That password did not open %1\$s. Enter the PDF password and try again.", - title - ) - } else { - readerString("desktop_pdf_password_required_desc", "%1\$s requires a password before it can be opened.", title) - } - ) - SharedStableOutlinedTextField( - value = password, - onValueChange = { password = it }, - label = { Text(readerString("password", "Password")) }, - singleLine = true, - visualTransformation = PasswordVisualTransformation(), - modifier = Modifier.fillMaxWidth() - ) - if (isError) { - Text( - readerString("desktop_pdf_password_required_or_incorrect", "Password is required or incorrect."), - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall - ) - } - } - }, - confirmButton = { - TextButton( - enabled = password.isNotEmpty(), - onClick = { onConfirm(password) } - ) { - Text(readerString("action_open", "Open")) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(readerString("action_cancel", "Cancel")) - } - } - ) -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfReaderScreen.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfReaderScreen.kt deleted file mode 100644 index 1be1c5c..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfReaderScreen.kt +++ /dev/null @@ -1,4376 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.Crossfade -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.focusable -import androidx.compose.foundation.gestures.awaitEachGesture -import androidx.compose.foundation.gestures.awaitFirstDown -import androidx.compose.foundation.gestures.detectDragGestures -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.gestures.scrollBy -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -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.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.filled.Close -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateMapOf -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.runtime.withFrameNanos -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.luminance -import androidx.compose.ui.input.key.onPreviewKeyEvent -import androidx.compose.ui.input.pointer.PointerEventType -import androidx.compose.ui.input.pointer.changedToUp -import androidx.compose.ui.input.pointer.isPrimaryPressed -import androidx.compose.ui.input.pointer.isSecondaryPressed -import androidx.compose.ui.input.pointer.positionChange -import androidx.compose.ui.input.pointer.positionChanged -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.layout.positionInRoot -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.dp -import androidx.compose.ui.zIndex -import org.dueattendant149.bookreader.shared.AiAdapter -import org.dueattendant149.bookreader.shared.PdfDisplayMode -import org.dueattendant149.bookreader.shared.ReaderAiByokSettings -import org.dueattendant149.bookreader.shared.ReaderAiFeature -import org.dueattendant149.bookreader.shared.ReaderAiResultState -import org.dueattendant149.bookreader.shared.ReaderCloudTtsState -import org.dueattendant149.bookreader.shared.ReaderExtrasState -import org.dueattendant149.bookreader.shared.ReaderExternalLookupAction -import org.dueattendant149.bookreader.shared.ReaderTtsChunk -import org.dueattendant149.bookreader.shared.ReaderTtsPlanner -import org.dueattendant149.bookreader.shared.ReaderTtsProgress -import org.dueattendant149.bookreader.shared.ReaderTtsReadScope -import org.dueattendant149.bookreader.shared.ReaderTtsReplacementPreferences -import org.dueattendant149.bookreader.shared.ReaderTheme -import org.dueattendant149.bookreader.shared.SaveMode -import org.dueattendant149.bookreader.shared.SearchHighlightMode -import org.dueattendant149.bookreader.shared.SharedFeaturePolicy -import org.dueattendant149.bookreader.shared.SummarizationResult -import org.dueattendant149.bookreader.shared.externalLookupUrl -import org.dueattendant149.bookreader.shared.withTtsReplacements -import org.dueattendant149.bookreader.shared.pdf.PdfAnnotationKind -import org.dueattendant149.bookreader.shared.pdf.PdfInkTool -import org.dueattendant149.bookreader.shared.pdf.PdfPageBounds -import org.dueattendant149.bookreader.shared.pdf.PdfPagePoint -import org.dueattendant149.bookreader.shared.pdf.PdfSpreadLayout -import org.dueattendant149.bookreader.shared.pdf.PdfVisiblePageLayout -import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotation -import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationDefaults -import org.dueattendant149.bookreader.shared.pdf.SharedPdfEmbeddedAnnotation -import org.dueattendant149.bookreader.shared.pdf.SharedPdfHighlighterPalette -import org.dueattendant149.bookreader.shared.pdf.SharedPdfJumpHistory -import org.dueattendant149.bookreader.shared.pdf.SharedPdfReaderAction -import org.dueattendant149.bookreader.shared.pdf.SharedPdfReaderState -import org.dueattendant149.bookreader.shared.pdf.SharedPdfReaderViewport -import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichTextController -import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichTextLog -import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichTextSerializer -import org.dueattendant149.bookreader.shared.pdf.SharedPdfSearchEngine -import org.dueattendant149.bookreader.shared.pdf.SharedPdfSearchResult -import org.dueattendant149.bookreader.shared.pdf.SharedPdfTextAnnotationDefaults -import org.dueattendant149.bookreader.shared.pdf.SharedPdfTextDraft -import org.dueattendant149.bookreader.shared.pdf.SharedPdfTextStyleConfig -import org.dueattendant149.bookreader.shared.pdf.mostVisiblePdfPageIndex -import org.dueattendant149.bookreader.shared.pdf.pdfVerticalPageGapDp -import org.dueattendant149.bookreader.shared.pdf.reduce -import org.dueattendant149.bookreader.shared.pdf.sharedPdfTextStyle -import org.dueattendant149.bookreader.shared.pdf.toAnnotation -import org.dueattendant149.bookreader.shared.pdf.withBounds -import org.dueattendant149.bookreader.shared.pdf.withSharedPdfTextStyle -import org.dueattendant149.bookreader.shared.pdf.withStyle -import org.dueattendant149.bookreader.shared.pdf.withText -import org.dueattendant149.bookreader.shared.reader.ReaderSettings -import org.dueattendant149.bookreader.shared.readerCloudTtsControlsModel -import org.dueattendant149.bookreader.shared.reduce -import org.dueattendant149.bookreader.shared.ui.ReaderWorkspaceFileActionState -import org.dueattendant149.bookreader.shared.ui.ReaderWorkspaceShell -import org.dueattendant149.bookreader.shared.ui.LocalSharedStringResolver -import org.dueattendant149.bookreader.shared.ui.SharedPdfAnnotationOverlay -import org.dueattendant149.bookreader.shared.ui.SharedPdfEmbeddedAnnotationOverlay -import org.dueattendant149.bookreader.shared.ui.SharedPdfInlineTextEditorOverlay -import org.dueattendant149.bookreader.shared.ui.SharedPdfInteractionDock -import org.dueattendant149.bookreader.shared.ui.SharedPdfPageNumberOverlay -import org.dueattendant149.bookreader.shared.ui.SharedPdfRichTextHiddenInput -import org.dueattendant149.bookreader.shared.ui.SharedPdfRichTextLayer -import org.dueattendant149.bookreader.shared.ui.SharedPdfTextBoxEditorOverlay -import org.dueattendant149.bookreader.shared.ui.SharedPdfVerticalScrollbar -import org.dueattendant149.bookreader.shared.ui.SharedReaderTtsOverlayControls -import org.dueattendant149.bookreader.shared.ui.pdfReaderWorkspaceModel -import org.dueattendant149.bookreader.shared.ui.readerString -import org.dueattendant149.bookreader.shared.ui.sharedPdfEmbeddedHitTest -import org.dueattendant149.bookreader.shared.ui.sharedPdfHitTest -import org.dueattendant149.bookreader.shared.ui.toSharedPdfPoint -import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.awt.event.KeyEvent as AwtKeyEvent -import java.io.File -import java.util.concurrent.atomic.AtomicReference -import kotlin.math.abs -import kotlin.math.roundToInt - -private val DesktopPdfReaderFullscreenFocusRetryDelaysMillis = longArrayOf(80L, 120L, 160L, 240L) -private const val DesktopPdfPaginationPageTurnAnimationMillis = 140 - -private data class DesktopPdfPaginatedPageDisplay( - val pageIndex: Int, - val render: DesktopPdfPageRender -) - -private data class DesktopPdfPendingPaginatedScrollRestore( - val requestId: Int, - val pageIndex: Int, - val zoom: Float, - val horizontalScroll: Int, - val verticalScroll: Int -) - -internal fun desktopPdfInitialPageIndex( - requestedPageIndex: Int, - pageCount: Int, - displayMode: PdfDisplayMode, - settings: ReaderSettings -): Int { - val clampedPageIndex = requestedPageIndex.coerceIn(0, (pageCount - 1).coerceAtLeast(0)) - return if (displayMode == PdfDisplayMode.PAGINATION) { - PdfSpreadLayout.normalizePageIndex(clampedPageIndex, pageCount, settings) - } else { - clampedPageIndex - } -} - -@Composable -internal fun PdfReaderScreen( - document: DesktopPdfDocument, - initialPageIndex: Int, - initialViewport: SharedPdfReaderViewport? = null, - initialReaderSettings: ReaderSettings? = null, - onReturnToLibrary: (() -> Unit)? = null, - onFullscreenChange: (Boolean) -> Unit = {}, - appThemeControls: (@Composable () -> Unit)? = null, - onPageStateChange: (pageIndex: Int, progress: Float, viewport: SharedPdfReaderViewport) -> Unit, - onReaderSettingsChange: (ReaderSettings) -> Unit = {}, - pdfHighlighterPalette: SharedPdfHighlighterPalette = SharedPdfHighlighterPalette(), - onPdfHighlighterPaletteChange: (SharedPdfHighlighterPalette) -> Unit = {}, - customReaderThemes: List = emptyList(), - onCustomReaderThemesChange: (List) -> Unit = {}, - customTextureIds: List = emptyList(), - onImportTexture: ((ReaderSettings) -> ReaderSettings?)? = null, - onLocalSidecarsChanged: () -> Unit = {}, - aiByokSettings: ReaderAiByokSettings, - aiAdapter: AiAdapter, - ttsAdapter: DesktopGeminiCloudTtsAdapter, - ttsReplacementPreferences: ReaderTtsReplacementPreferences, - onTtsReplacementPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit, - summaryCacheStore: DesktopSummaryCacheStore = DesktopSummaryCacheStore(), - credits: Int = 0, - showPaidCredits: Boolean = false, - onAiByokSettingsChange: (ReaderAiByokSettings) -> Unit = {}, - featurePolicy: SharedFeaturePolicy = SharedFeaturePolicy.Standard, - cloudTtsControlsAvailable: Boolean = true, - onReaderAiEntitlementRequired: (ReaderAiFeature, String) -> Boolean = { _, _ -> false }, - onCloudTtsEntitlementRequired: () -> Boolean = { false }, - onPaidFeatureError: (String?) -> Unit = {}, - hasReflowFile: Boolean = false, - isReflowingThisBook: Boolean = false, - onReflowAction: ((pageIndex: Int) -> Unit)? = null -) { - val documentHandleId = document.handleId - val stringResolver = LocalSharedStringResolver.current - fun pdfString(name: String, fallback: String, vararg args: Any?): String { - return stringResolver.string(name, fallback, *args) - } - val zoomSpec = remember { DesktopPdfZoomSpec } - val initialDesktopPdfReaderSettings = remember(documentHandleId, initialReaderSettings) { - initialReaderSettings.toDesktopPdfReaderSettings() - } - val initialPdfDisplayMode = initialDesktopPdfReaderSettings.toDesktopPdfDisplayMode() - val restoredInitialViewport = remember( - documentHandleId, - initialViewport, - initialDesktopPdfReaderSettings, - initialPdfDisplayMode - ) { - initialViewport?.sanitized(document.pageCount, zoomSpec)?.let { viewport -> - viewport.copy( - pageIndex = desktopPdfInitialPageIndex( - requestedPageIndex = viewport.pageIndex, - pageCount = document.pageCount, - displayMode = initialPdfDisplayMode, - settings = initialDesktopPdfReaderSettings - ) - ) - } - } - val initialPdfPageIndex = remember( - documentHandleId, - initialPageIndex, - restoredInitialViewport, - initialPdfDisplayMode, - initialDesktopPdfReaderSettings - ) { - desktopPdfInitialPageIndex( - requestedPageIndex = restoredInitialViewport?.pageIndex ?: initialPageIndex, - pageCount = document.pageCount, - displayMode = initialPdfDisplayMode, - settings = initialDesktopPdfReaderSettings - ) - } - var pdfReaderSettings by remember(documentHandleId) { - mutableStateOf(initialDesktopPdfReaderSettings) - } - var pdfState by remember(documentHandleId) { - mutableStateOf( - SharedPdfReaderState.initial( - pageCount = document.pageCount, - initialPageIndex = initialPdfPageIndex, - zoomSpec = zoomSpec - ).copy( - displayMode = initialPdfDisplayMode, - zoom = restoredInitialViewport?.zoom ?: zoomSpec.clamp(zoomSpec.default) - ) - ) - } - var renderedPage by remember(documentHandleId) { mutableStateOf(null) } - var renderedPageIndex by remember(documentHandleId) { mutableStateOf(null) } - var renderedPageScale by remember(documentHandleId) { mutableStateOf(null) } - var renderError by remember(documentHandleId) { mutableStateOf(null) } - var isRendering by remember(documentHandleId) { mutableStateOf(false) } - var renderJob by remember(documentHandleId) { mutableStateOf(null) } - val zoomAnchorJob = remember(documentHandleId) { AtomicReference(null) } - val zoomCommitJob = remember(documentHandleId) { AtomicReference(null) } - var pdfZoomPreview by remember(documentHandleId) { mutableStateOf(null) } - var pdfZoomSettleSequence by remember(documentHandleId) { mutableIntStateOf(0) } - var pdfNavigationScrollRestoreSequence by remember(documentHandleId) { mutableIntStateOf(0) } - var pendingPdfNavigationScrollRestore by remember(documentHandleId) { - mutableStateOf(null) - } - var activeTextDraft by remember(documentHandleId) { mutableStateOf(null) } - var textStyleConfig by remember(documentHandleId) { mutableStateOf(SharedPdfTextStyleConfig()) } - var pageCanvasSize by remember(documentHandleId) { mutableStateOf(IntSize.Zero) } - var pdfZoomViewportRootOffset by remember(documentHandleId) { mutableStateOf(Offset.Zero) } - var pdfZoomViewportSize by remember(documentHandleId) { mutableStateOf(IntSize.Zero) } - var paginatedPageRootOffset by remember(documentHandleId) { mutableStateOf(Offset.Zero) } - val paginatedPageRootOffsets = remember(documentHandleId) { mutableStateMapOf() } - val paginatedPageCanvasSizes = remember(documentHandleId) { mutableStateMapOf() } - val verticalPageRootOffsets = remember(documentHandleId) { mutableStateMapOf() } - val paginatedRenderCache = remember(documentHandleId) { mutableStateMapOf() } - var activeStroke by remember(documentHandleId, pdfState.pageIndex) { mutableStateOf>(emptyList()) } - var eraserPosition by remember(documentHandleId, pdfState.pageIndex, pdfState.selectedTool) { mutableStateOf(null) } - var isHighlighterSnapEnabled by remember(documentHandleId) { mutableStateOf(false) } - var selectionStartIndex by remember(documentHandleId, pdfState.pageIndex) { mutableStateOf(null) } - var selectionEndIndex by remember(documentHandleId, pdfState.pageIndex) { mutableStateOf(null) } - var selectionStartHit by remember(documentHandleId, pdfState.pageIndex) { mutableStateOf(null) } - var selectionEndHit by remember(documentHandleId, pdfState.pageIndex) { mutableStateOf(null) } - var textSelection by remember(documentHandleId, pdfState.pageIndex) { mutableStateOf(null) } - var selectionMenuOffset by remember(documentHandleId, pdfState.pageIndex) { mutableStateOf(null) } - var activeSelectionHandle by remember(documentHandleId, pdfState.pageIndex) { mutableStateOf(null) } - var pageScrubPreview by remember(documentHandleId) { mutableStateOf(null) } - var pageScrubStartPage by remember(documentHandleId) { mutableStateOf(null) } - var showPdfZoomIndicator by remember(documentHandleId) { mutableStateOf(false) } - var isPdfZoomIndicatorInitialized by remember(documentHandleId) { mutableStateOf(false) } - var jumpHistory by remember(documentHandleId) { mutableStateOf(SharedPdfJumpHistory()) } - var externalLinkDialogUrl by remember(documentHandleId) { mutableStateOf(null) } - var pdfExtrasState by remember(documentHandleId) { - mutableStateOf( - ReaderExtrasState( - cloudTts = ReaderCloudTtsState( - isAvailable = cloudTtsControlsAvailable && aiByokSettings.isCloudTtsAvailable, - cacheSummary = ttsAdapter.cacheSummary(document.title, aiByokSettings.sanitized().ttsSpeakerId) - ) - ) - ) - } - var pdfTtsJob by remember(documentHandleId) { mutableStateOf(null) } - var showPdfAiHub by remember(documentHandleId) { mutableStateOf(false) } - var pdfAiResultRequestId by remember(documentHandleId) { mutableStateOf(0L) } - var dismissedPdfAiResultRequestId by remember(documentHandleId) { mutableStateOf(null) } - var pdfHubSummaryResult by remember(documentHandleId) { mutableStateOf(null) } - var isPdfHubSummaryLoading by remember(documentHandleId) { mutableStateOf(false) } - var isPdfTtsOverlayCollapsed by remember(documentHandleId) { mutableStateOf(false) } - val annotationFile = remember(documentHandleId) { desktopPdfAnnotationFile(document.path) } - val bookmarkFile = remember(documentHandleId) { desktopPdfBookmarkFile(document.path) } - val richTextFile = remember(documentHandleId) { desktopPdfRichTextFile(document.path) } - val searchIndexFile = remember(documentHandleId) { desktopPdfSearchIndexFile(document.path) } - val clipboardManager = LocalClipboardManager.current - val density = LocalDensity.current - val pdfScope = rememberCoroutineScope() - var isFullscreen by remember(documentHandleId) { mutableStateOf(false) } - var showPdfSaveDialog by remember(documentHandleId) { mutableStateOf(false) } - var isPdfFileActionLoading by remember(documentHandleId) { mutableStateOf(false) } - var pdfFileActionNotice by remember(documentHandleId) { mutableStateOf(null) } - val currentPdfFullscreen by rememberUpdatedState(isFullscreen) - val currentOnPdfFullscreenChange by rememberUpdatedState(onFullscreenChange) - DisposableEffect(documentHandleId) { - onDispose { - renderJob?.cancel() - pdfTtsJob?.cancel() - zoomCommitJob.getAndSet(null)?.cancel() - zoomAnchorJob.getAndSet(null)?.cancel() - if (currentPdfFullscreen) { - currentOnPdfFullscreenChange(false) - } - document.close() - } - } - var isRichTextMode by remember(documentHandleId) { mutableStateOf(false) } - var isRichTextLoaded by remember(documentHandleId) { mutableStateOf(false) } - val richTextController = remember(documentHandleId) { - 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( - initial = restoredInitialViewport?.paginatedVerticalScrollOffset ?: 0 - ) - val pageHorizontalScrollState = rememberScrollState( - initial = restoredInitialViewport?.horizontalScrollOffset ?: 0 - ) - val verticalListState = rememberLazyListState( - initialFirstVisibleItemIndex = restoredInitialViewport - ?.takeIf { it.displayMode == PdfDisplayMode.VERTICAL_SCROLL } - ?.verticalFirstPageIndex - ?: pdfState.pageIndex, - initialFirstVisibleItemScrollOffset = restoredInitialViewport - ?.takeIf { it.displayMode == PdfDisplayMode.VERTICAL_SCROLL } - ?.verticalFirstPageScrollOffset - ?: 0 - ) - val pdfReaderFocusRequester = remember(documentHandleId) { FocusRequester() } - var pdfReaderFocusRestoreRequest by remember(documentHandleId) { mutableIntStateOf(0) } - val currentTextSelection by rememberUpdatedState(textSelection) - val currentPdfAnnotations by rememberUpdatedState(pdfState.annotations) - val currentPdfPageIndex by rememberUpdatedState(pdfState.pageIndex) - val currentPdfScale by rememberUpdatedState(pdfState.zoom) - val currentPdfDisplayMode by rememberUpdatedState(pdfState.displayMode) - val pdfSelectionSheetActive = pdfState.selectedAnnotationId?.let { selectedId -> - pdfState.annotations.any { it.id == selectedId && it.isDesktopTextSelectionHighlight } - } == true - val shouldRestorePdfReaderFocus = - !pdfState.isSearchActive && - !pdfSelectionSheetActive && - externalLinkDialogUrl == null && - !pdfExtrasState.aiResult.hasContent && - activeTextDraft == null && - !isRichTextMode && - (textSelection == null || selectionMenuOffset == null) - val currentShouldRestorePdfReaderFocus by rememberUpdatedState(shouldRestorePdfReaderFocus) - fun requestPdfReaderFocusRestore() { - pdfReaderFocusRestoreRequest += 1 - } - - LaunchedEffect(isFullscreen, documentHandleId) { - for (delayMillis in DesktopPdfReaderFullscreenFocusRetryDelaysMillis) { - delay(delayMillis) - if (currentShouldRestorePdfReaderFocus) { - runCatching { pdfReaderFocusRequester.requestFocus() } - } - } - } - - LaunchedEffect(shouldRestorePdfReaderFocus, documentHandleId) { - if (shouldRestorePdfReaderFocus) { - delay(120L) - runCatching { pdfReaderFocusRequester.requestFocus() } - } - } - - fun clearPdfInteractionState() { - activeStroke = emptyList() - eraserPosition = null - selectionStartIndex = null - selectionEndIndex = null - selectionStartHit = null - selectionEndHit = null - textSelection = null - selectionMenuOffset = null - activeSelectionHandle = null - } - - fun dispatchPdf(action: SharedPdfReaderAction) { - val previousPage = pdfState.pageIndex - val previousAnnotationIds = pdfState.annotations.mapTo(mutableSetOf()) { it.id } - val next = pdfState.reduce(action, zoomSpec) - val nextAnnotationIds = next.annotations.mapTo(mutableSetOf()) { it.id } - val removedAnnotationIds = previousAnnotationIds - nextAnnotationIds - if (removedAnnotationIds.isNotEmpty()) { - DesktopCloudSidecarSync.recordAnnotationDeletions( - documentPath = document.path, - logBookId = documentHandleId.toString(), - annotationIds = removedAnnotationIds - ) - } - pdfState = next - if (next.pageIndex != previousPage) { - clearPdfInteractionState() - } - } - - fun setPdfFullscreen(enabled: Boolean) { - isFullscreen = enabled - onFullscreenChange(enabled) - } - - 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}" - ) - val previousTool = pdfState.selectedTool - deactivateRichTextMode() - if (tool != PdfInkTool.TEXT) { - commitActiveTextDraft() - } - if (pdfState.isTextSelectionMode) { - dispatchPdf(SharedPdfReaderAction.TextSelectionModeChanged(false)) - clearPdfInteractionState() - } - if (previousTool != tool) { - dispatchPdf(SharedPdfReaderAction.ToolSelected(tool)) - } - } - - val pageIndex = pdfState.pageIndex - val scale = pdfState.zoom - val displayMode = pdfState.displayMode - val rightToLeftPdfPaginationActive = displayMode == PdfDisplayMode.PAGINATION && - pdfReaderSettings.rightToLeftPagination - val isPdfTwoPageSpread = displayMode == PdfDisplayMode.PAGINATION && - PdfSpreadLayout.isTwoPageSpreadEnabled(pdfReaderSettings) - val paginatedSpreadPageIndices: List = remember( - pageIndex, - document.pageCount, - displayMode, - pdfReaderSettings.pageSpreadMode, - pdfReaderSettings.pdfFirstPageStandaloneInSpread - ) { - if (displayMode == PdfDisplayMode.PAGINATION) { - PdfSpreadLayout.visiblePageIndices(pageIndex, document.pageCount, pdfReaderSettings) - } else { - listOf(pageIndex.coerceIn(0, (document.pageCount - 1).coerceAtLeast(0))) - } - } - val paginatedVisiblePageIndices = remember( - paginatedSpreadPageIndices, - rightToLeftPdfPaginationActive - ) { - if (rightToLeftPdfPaginationActive) paginatedSpreadPageIndices.asReversed() else paginatedSpreadPageIndices - } - val pdfPageLabel = desktopPdfPageLabel(pageIndex, document.pageCount, displayMode, pdfReaderSettings) - val pdfPageScrubPreviewLabel = pageScrubPreview?.let { - desktopPdfPageLabel(it, document.pageCount, displayMode, pdfReaderSettings) - } - val zoomControlScale = pdfZoomPreview?.zoom ?: scale - val shouldShowPdfZoomIndicator = abs(zoomControlScale - 1f) > 0.001f - - LaunchedEffect( - documentHandleId, - displayMode, - pdfReaderSettings.pageSpreadMode, - pdfReaderSettings.pdfFirstPageStandaloneInSpread, - pageIndex - ) { - if (displayMode != PdfDisplayMode.PAGINATION) return@LaunchedEffect - val normalizedPage = PdfSpreadLayout.normalizePageIndex(pageIndex, document.pageCount, pdfReaderSettings) - if (normalizedPage != pageIndex) { - dispatchPdf(SharedPdfReaderAction.GoToPage(normalizedPage)) - } - } - - LaunchedEffect(documentHandleId, pageIndex) { - pdfHubSummaryResult = null - isPdfHubSummaryLoading = false - } - - LaunchedEffect(zoomControlScale, document.path) { - if (!isPdfZoomIndicatorInitialized) { - isPdfZoomIndicatorInitialized = true - showPdfZoomIndicator = false - return@LaunchedEffect - } - if (shouldShowPdfZoomIndicator) { - showPdfZoomIndicator = true - delay(1_500) - showPdfZoomIndicator = false - } else { - showPdfZoomIndicator = false - } - } - - LaunchedEffect(documentHandleId, displayMode) { - if (!DesktopDiagnosticsEnabled) return@LaunchedEffect - snapshotFlow { - "mode=$displayMode page=${currentPdfPageIndex + 1} scale=${currentPdfScale.formatLogFloat()} " + - "preview=${pdfZoomPreview != null} h=${pageHorizontalScrollState.value} " + - "v=${pageVerticalScrollState.value} list=${verticalListState.firstVisibleItemIndex}:" + - verticalListState.firstVisibleItemScrollOffset - } - .distinctUntilChanged() - .collect { summary -> - logPdfZoomSettle { "scroll_state seq=$pdfZoomSettleSequence $summary" } - } - } - - fun verticalZoomAnchorItem(anchor: Offset) = verticalListState.layoutInfo.visibleItemsInfo - .firstOrNull { item -> - anchor.y >= item.offset.toFloat() && anchor.y <= (item.offset + item.size).toFloat() - } - ?: verticalListState.layoutInfo.visibleItemsInfo.minByOrNull { item -> - when { - anchor.y < item.offset.toFloat() -> item.offset.toFloat() - anchor.y - anchor.y > (item.offset + item.size).toFloat() -> anchor.y - (item.offset + item.size).toFloat() - else -> 0f - } - } - - fun paginatedZoomPageRoot(page: Int?): Offset? { - if (page == null) return null - return paginatedPageRootOffsets[page] - ?: paginatedPageRootOffset.takeIf { page == currentPdfPageIndex } - } - - fun paginatedZoomAnchorPageIndex(anchor: Offset?): Int { - val activePageIndex = currentPdfPageIndex - if (!isPdfTwoPageSpread) return activePageIndex - val rootOffsets = paginatedPageRootOffsets.toMutableMap() - paginatedSpreadPageIndices.firstOrNull()?.let { firstSpreadPage -> - rootOffsets.putIfAbsent(firstSpreadPage, paginatedPageRootOffset) - } - val pageSizes = paginatedPageCanvasSizes.toMutableMap() - if (pageCanvasSize.width > 0 && pageCanvasSize.height > 0) { - pageSizes.putIfAbsent(activePageIndex, pageCanvasSize) - } - return desktopPdfSpreadZoomAnchorPageIndex( - viewportRootOffset = pdfZoomViewportRootOffset, - anchor = anchor, - visiblePageIndices = paginatedVisiblePageIndices, - pageRootOffsets = rootOffsets, - pageSizes = pageSizes, - fallbackPageIndex = activePageIndex - ) - } - - LaunchedEffect(scale, displayMode, pageIndex, isPdfTwoPageSpread, paginatedSpreadPageIndices) { - val preview = pdfZoomPreview ?: return@LaunchedEffect - val paginationPreviewPageVisible = if (isPdfTwoPageSpread) { - preview.pageIndex in paginatedSpreadPageIndices - } else { - preview.pageIndex == pageIndex - } - if ( - preview.displayMode != displayMode || - (!paginationPreviewPageVisible && displayMode == PdfDisplayMode.PAGINATION) || - !desktopPdfZoomPreviewMatchesScale(preview, scale) - ) { - logPdfZoomSettle { - "preview_cancel seq=$pdfZoomSettleSequence reason=state_mismatch mode=$displayMode " + - "page=${pageIndex + 1} scale=${scale.formatLogFloat()} previewMode=${preview.displayMode} " + - "previewPage=${preview.pageIndex?.plus(1) ?: "none"} base=${preview.baseZoom.formatLogFloat()} " + - "zoom=${preview.zoom.formatLogFloat()}" - } - pdfZoomPreview = null - zoomCommitJob.getAndSet(null)?.cancel() - } - } - - fun applyAnchoredPdfZoom(oldZoom: Float, newZoom: Float, anchor: Offset?) { - if (pdfZoomSettleSequence == 0) { - pdfZoomSettleSequence = 1 - } - val settleSequence = pdfZoomSettleSequence - val activePageIndex = currentPdfPageIndex - val activeDisplayMode = currentPdfDisplayMode - logPdfZoomPerf { - "commit_start mode=$activeDisplayMode page=${activePageIndex + 1} old=${oldZoom.formatLogFloat()} " + - "new=${newZoom.formatLogFloat()} anchor=${anchor.formatLogOffset()} " + - "renderPage=${renderedPageIndex?.let { it + 1 } ?: "none"} " + - "renderScale=${renderedPageScale?.formatLogFloat() ?: "none"} " + - "renderJobActive=${renderJob?.isActive == true}" - } - val committedPreview = pdfZoomPreview - val viewportRootOffsetAtZoomStart = committedPreview?.viewportRootOffset ?: pdfZoomViewportRootOffset - val committedPreviewPageIndex = committedPreview?.pageIndex ?: activePageIndex - val pageRootOffsetAtZoomStart = committedPreview?.pageRootOffset - ?: paginatedZoomPageRoot(committedPreviewPageIndex) - ?: paginatedPageRootOffset - val pageRootOffsetAtCommitStart = paginatedZoomPageRoot(committedPreviewPageIndex) - ?: paginatedPageRootOffset - logPdfZoomSettle { - "commit_start seq=$settleSequence mode=$activeDisplayMode page=${activePageIndex + 1} " + - "old=${oldZoom.formatLogFloat()} new=${newZoom.formatLogFloat()} anchor=${anchor.formatLogOffset()} " + - "preview=${committedPreview != null} previewPage=${committedPreview?.pageIndex?.plus(1) ?: "none"} " + - "previewBase=${committedPreview?.baseZoom?.formatLogFloat() ?: "none"} " + - "previewZoom=${committedPreview?.zoom?.formatLogFloat() ?: "none"} " + - "viewportStart=${viewportRootOffsetAtZoomStart.formatLogOffset()} " + - "pageStart=${pageRootOffsetAtZoomStart.formatLogOffset()} " + - "pageNow=${pageRootOffsetAtCommitStart.formatLogOffset()} h=${pageHorizontalScrollState.value} " + - "v=${pageVerticalScrollState.value} list=${verticalListState.firstVisibleItemIndex}:" + - "${verticalListState.firstVisibleItemScrollOffset} renderPage=${renderedPageIndex?.plus(1) ?: "none"} " + - "renderScale=${renderedPageScale?.formatLogFloat() ?: "none"} renderJob=${renderJob?.isActive == true}" - } - val rawTargetHorizontalScroll = anchor?.let { - desktopPdfAnchoredScrollTarget(pageHorizontalScrollState.value, it.x, oldZoom, newZoom) - } - val rawTargetVerticalScroll = anchor?.let { - desktopPdfAnchoredScrollTarget(pageVerticalScrollState.value, it.y, oldZoom, newZoom) - } - val paginationCommitPrediction: DesktopPdfLayoutScrollPrediction? = if (activeDisplayMode == PdfDisplayMode.PAGINATION) { - val predictedScale = zoomSpec.clamp(newZoom) - if (isPdfTwoPageSpread) { - val predictedSizes = paginatedVisiblePageIndices.mapNotNull { visiblePageIndex -> - document.pageSizes.getOrNull(visiblePageIndex)?.let { pageSize -> - visiblePageIndex to IntSize( - width = (pageSize.width * predictedScale).roundToInt().coerceAtLeast(1), - height = (pageSize.height * predictedScale).roundToInt().coerceAtLeast(1) - ) - } - }.toMap() - desktopPdfSpreadLayoutPrediction( - viewportRootOffset = pdfZoomViewportRootOffset, - viewportSize = pdfZoomViewportSize, - visiblePageIndices = paginatedVisiblePageIndices, - pageCanvasSizes = predictedSizes, - horizontalScroll = rawTargetHorizontalScroll ?: pageHorizontalScrollState.value, - verticalScroll = rawTargetVerticalScroll ?: pageVerticalScrollState.value, - paddingPx = with(density) { 24.dp.toPx() }, - pageGapPx = with(density) { - desktopPdfSpreadPageGapDp(pdfReaderSettings.pdfVerticalPageGapVisible).toPx() - } - ) - } else { - document.pageSizes.getOrNull(committedPreviewPageIndex)?.let { pageSize -> - desktopPdfSinglePageLayoutPrediction( - viewportRootOffset = pdfZoomViewportRootOffset, - viewportSize = pdfZoomViewportSize, - pageCanvasSize = IntSize( - width = (pageSize.width * predictedScale).roundToInt().coerceAtLeast(1), - height = (pageSize.height * predictedScale).roundToInt().coerceAtLeast(1) - ), - horizontalScroll = rawTargetHorizontalScroll ?: pageHorizontalScrollState.value, - verticalScroll = rawTargetVerticalScroll ?: pageVerticalScrollState.value, - paddingPx = with(density) { 24.dp.toPx() } - ) - } - } - } else { - null - } - val targetHorizontalScroll = rawTargetHorizontalScroll?.let { target -> - paginationCommitPrediction?.maxHorizontalScroll?.let { maxScroll -> - target.coerceIn(0, maxScroll) - } ?: target - } - val targetVerticalScroll = rawTargetVerticalScroll?.let { target -> - paginationCommitPrediction?.maxVerticalScroll?.let { maxScroll -> - target.coerceIn(0, maxScroll) - } ?: target - } - val targetVerticalItem = if (activeDisplayMode == PdfDisplayMode.VERTICAL_SCROLL && anchor != null) { - verticalZoomAnchorItem(anchor) - ?.let { item -> - val fallbackOffset = desktopPdfAnchoredLazyItemScrollOffset( - itemOffset = item.offset, - anchor = anchor.y, - oldZoom = oldZoom, - newZoom = newZoom - ) - val pageRootOffset = if (committedPreview?.pageIndex == item.index) { - committedPreview.pageRootOffset - } else { - verticalPageRootOffsets[item.index] - } - Triple(item.index, fallbackOffset, pageRootOffset) - } - } else { - null - } - logPdfZoomSettle { - "commit_targets seq=$settleSequence mode=$activeDisplayMode targetH=${targetHorizontalScroll ?: "none"} " + - "targetV=${targetVerticalScroll ?: "none"} rawH=${rawTargetHorizontalScroll ?: "none"} " + - "rawV=${rawTargetVerticalScroll ?: "none"} predictedMaxH=${paginationCommitPrediction?.maxHorizontalScroll ?: "none"} " + - "predictedMaxV=${paginationCommitPrediction?.maxVerticalScroll ?: "none"} " + - "targetItem=${targetVerticalItem?.first?.plus(1) ?: "none"} " + - "targetItemOffset=${targetVerticalItem?.second ?: "none"} targetItemRoot=${targetVerticalItem?.third.formatLogOffset()}" - } - var committedPreviewForClear = committedPreview - committedPreview?.let { preview -> - val previewWithCommitTargets = preview.copy( - commitTargetHorizontalScroll = targetHorizontalScroll, - commitTargetVerticalScroll = targetVerticalScroll.takeIf { - activeDisplayMode == PdfDisplayMode.PAGINATION - } - ) - if (pdfZoomPreview == preview) { - pdfZoomPreview = previewWithCommitTargets - committedPreviewForClear = previewWithCommitTargets - logPdfZoomSettle { - "preview_commit_targets seq=$settleSequence targetH=${targetHorizontalScroll ?: "none"} " + - "targetV=${targetVerticalScroll ?: "none"}" - } - } - } - dispatchPdf(SharedPdfReaderAction.ZoomChanged(newZoom)) - fun clearCommittedPreview() { - val matchesCommittedPreview = pdfZoomPreview == committedPreviewForClear - logPdfZoomSettle { - "preview_clear seq=$settleSequence match=$matchesCommittedPreview " + - "current=${pdfZoomPreview != null} committed=${committedPreview != null}" - } - if (matchesCommittedPreview) { - pdfZoomPreview = null - } - } - logPdfZoomSettle { - "zoom_dispatched seq=$settleSequence new=${newZoom.formatLogFloat()} h=${pageHorizontalScrollState.value} " + - "v=${pageVerticalScrollState.value} list=${verticalListState.firstVisibleItemIndex}:" + - verticalListState.firstVisibleItemScrollOffset - } - if (anchor != null) { - zoomAnchorJob.getAndSet(null)?.cancel() - val nextAnchorJob = pdfScope.launch(start = CoroutineStart.UNDISPATCHED) { - when (activeDisplayMode) { - PdfDisplayMode.PAGINATION -> { - if (targetHorizontalScroll != null || targetVerticalScroll != null) { - val beforeH = pageHorizontalScrollState.value - val beforeV = pageVerticalScrollState.value - targetHorizontalScroll?.let { pageHorizontalScrollState.scrollTo(it) } - targetVerticalScroll?.let { pageVerticalScrollState.scrollTo(it) } - logPdfZoomSettle { - "anchor_pre_scroll seq=$settleSequence mode=pagination beforeH=$beforeH beforeV=$beforeV " + - "targetH=${targetHorizontalScroll ?: "none"} targetV=${targetVerticalScroll ?: "none"} " + - "afterH=${pageHorizontalScrollState.value} afterV=${pageVerticalScrollState.value} " + - "maxH=${pageHorizontalScrollState.maxValue} maxV=${pageVerticalScrollState.maxValue}" - } - } - withFrameNanos { } - suspend fun correctPageAnchor(pass: Int) { - val beforeH = pageHorizontalScrollState.value - val beforeV = pageVerticalScrollState.value - val currentRoot = paginatedZoomPageRoot(committedPreviewPageIndex) - ?: paginatedPageRootOffset - val pageDelta = desktopPdfAnchoredPageScrollDelta( - viewportRootOffset = viewportRootOffsetAtZoomStart, - oldPageRootOffset = pageRootOffsetAtZoomStart, - currentPageRootOffset = currentRoot, - anchor = anchor, - oldZoom = oldZoom, - newZoom = newZoom - ) - val reachableDelta = pageDelta?.let { - desktopPdfReachableScrollDelta( - requestedDelta = it, - scrollBounds = DesktopPdfZoomScrollBounds( - currentHorizontalScroll = pageHorizontalScrollState.value, - maxHorizontalScroll = pageHorizontalScrollState.maxValue, - currentVerticalScroll = pageVerticalScrollState.value, - maxVerticalScroll = pageVerticalScrollState.maxValue - ) - ) - } - logPdfZoomSettle { - "anchor_pass seq=$settleSequence pass=$pass mode=pagination beforeH=$beforeH " + - "beforeV=$beforeV delta=${pageDelta.formatLogIntOffset()} " + - "reachable=${reachableDelta.formatLogIntOffset()} " + - "maxH=${pageHorizontalScrollState.maxValue} maxV=${pageVerticalScrollState.maxValue} " + - "rootStart=${pageRootOffsetAtZoomStart.formatLogOffset()} " + - "rootNow=${currentRoot.formatLogOffset()} viewport=${viewportRootOffsetAtZoomStart.formatLogOffset()}" - } - if (reachableDelta != null) { - if (abs(reachableDelta.x) > 1) { - pageHorizontalScrollState.scrollTo( - (pageHorizontalScrollState.value + reachableDelta.x).coerceAtLeast( - 0 - ) - ) - } - if (abs(reachableDelta.y) > 1) { - pageVerticalScrollState.scrollTo( - (pageVerticalScrollState.value + reachableDelta.y).coerceAtLeast( - 0 - ) - ) - } - } else if (targetHorizontalScroll != null && targetVerticalScroll != null) { - pageHorizontalScrollState.scrollTo(targetHorizontalScroll) - pageVerticalScrollState.scrollTo(targetVerticalScroll) - } - logPdfZoomSettle { - "anchor_pass_end seq=$settleSequence pass=$pass mode=pagination afterH=${pageHorizontalScrollState.value} " + - "afterV=${pageVerticalScrollState.value} delta=${pageDelta.formatLogIntOffset()} " + - "reachable=${reachableDelta.formatLogIntOffset()}" - } - } - correctPageAnchor(pass = 1) - withFrameNanos { } - correctPageAnchor(pass = 2) - } - - PdfDisplayMode.VERTICAL_SCROLL -> { - withFrameNanos { } - suspend fun correctVerticalAnchor(pass: Int) { - val beforeH = pageHorizontalScrollState.value - val beforeItem = verticalListState.firstVisibleItemIndex - val beforeItemOffset = verticalListState.firstVisibleItemScrollOffset - val oldPageRootOffset = targetVerticalItem?.third - val currentPageRootOffset = - targetVerticalItem?.first?.let { verticalPageRootOffsets[it] } - val pageDelta = - if (oldPageRootOffset != null && currentPageRootOffset != null) { - desktopPdfAnchoredPageScrollDelta( - viewportRootOffset = viewportRootOffsetAtZoomStart, - oldPageRootOffset = oldPageRootOffset, - currentPageRootOffset = currentPageRootOffset, - anchor = anchor, - oldZoom = oldZoom, - newZoom = newZoom - ) - } else { - null - } - logPdfZoomSettle { - "anchor_pass seq=$settleSequence pass=$pass mode=vertical beforeH=$beforeH " + - "beforeList=$beforeItem:$beforeItemOffset delta=${pageDelta.formatLogIntOffset()} " + - "targetItem=${targetVerticalItem?.first?.plus(1) ?: "none"} " + - "oldRoot=${oldPageRootOffset.formatLogOffset()} " + - "currentRoot=${currentPageRootOffset.formatLogOffset()} " + - "viewport=${viewportRootOffsetAtZoomStart.formatLogOffset()}" - } - if (pageDelta != null) { - if (abs(pageDelta.x) > 1) { - pageHorizontalScrollState.scrollTo( - (pageHorizontalScrollState.value + pageDelta.x).coerceAtLeast( - 0 - ) - ) - } - if (abs(pageDelta.y) > 1) { - verticalListState.scrollBy(pageDelta.y.toFloat()) - } - } else { - targetHorizontalScroll?.let { pageHorizontalScrollState.scrollTo(it) } - targetVerticalItem?.let { (itemIndex, scrollOffset, _) -> - verticalListState.scrollToItem(itemIndex, scrollOffset) - } - } - logPdfZoomSettle { - "anchor_pass_end seq=$settleSequence pass=$pass mode=vertical afterH=${pageHorizontalScrollState.value} " + - "afterList=${verticalListState.firstVisibleItemIndex}:${verticalListState.firstVisibleItemScrollOffset} " + - "delta=${pageDelta.formatLogIntOffset()}" - } - } - correctVerticalAnchor(pass = 1) - withFrameNanos { } - correctVerticalAnchor(pass = 2) - } - } - clearCommittedPreview() - } - zoomAnchorJob.set(nextAnchorJob) - } else { - val nextAnchorJob = pdfScope.launch { - withFrameNanos { } - logPdfZoomSettle { - "anchor_skip seq=$settleSequence reason=no_anchor mode=$activeDisplayMode h=${pageHorizontalScrollState.value} " + - "v=${pageVerticalScrollState.value} list=${verticalListState.firstVisibleItemIndex}:" + - verticalListState.firstVisibleItemScrollOffset - } - clearCommittedPreview() - } - zoomAnchorJob.getAndSet(nextAnchorJob)?.cancel() - } - } - - fun previewAnchoredPdfZoom(oldZoom: Float, newZoom: Float, anchor: Offset?) { - val activePageIndex = currentPdfPageIndex - val activeScale = currentPdfScale - val activeDisplayMode = currentPdfDisplayMode - logPdfZoomPerf { - "preview mode=$activeDisplayMode page=${activePageIndex + 1} old=${oldZoom.formatLogFloat()} " + - "new=${newZoom.formatLogFloat()} anchor=${anchor.formatLogOffset()} " + - "hasRender=${renderedPage != null && renderedPageIndex == activePageIndex} " + - "renderScale=${renderedPageScale?.formatLogFloat() ?: "none"} " + - "renderJobActive=${renderJob?.isActive == true} cacheKeys=${paginatedRenderCache.keys.sorted().map { it + 1 }}" - } - val previewPageIndex = when (activeDisplayMode) { - PdfDisplayMode.PAGINATION -> paginatedZoomAnchorPageIndex(anchor) - PdfDisplayMode.VERTICAL_SCROLL -> anchor?.let(::verticalZoomAnchorItem)?.index ?: activePageIndex - } - val existingPreview = pdfZoomPreview?.takeIf { - it.displayMode == activeDisplayMode && - it.pageIndex == previewPageIndex && - it.baseZoom.isFinite() && - it.baseZoom > 0f && - abs(it.baseZoom - activeScale) <= 0.0001f - } - if (existingPreview == null && currentShouldRestorePdfReaderFocus) { - runCatching { pdfReaderFocusRequester.requestFocus() } - } - if (existingPreview == null) { - pdfZoomSettleSequence += 1 - } - val settleSequence = pdfZoomSettleSequence - val baseZoom = existingPreview - ?.baseZoom - ?: oldZoom.takeIf { it.isFinite() && it > 0f } - ?: activeScale - val previewPageRootOffset = existingPreview?.pageRootOffset ?: when (activeDisplayMode) { - PdfDisplayMode.PAGINATION -> paginatedZoomPageRoot(previewPageIndex) - PdfDisplayMode.VERTICAL_SCROLL -> verticalPageRootOffsets[previewPageIndex] - } - pdfZoomPreview = DesktopPdfZoomPreview( - baseZoom = baseZoom, - zoom = newZoom, - anchor = anchor, - displayMode = activeDisplayMode, - pageIndex = previewPageIndex, - viewportRootOffset = existingPreview?.viewportRootOffset ?: pdfZoomViewportRootOffset, - pageRootOffset = previewPageRootOffset, - diagnosticSequence = settleSequence - ) - logPdfZoomSettle { - "preview_update seq=$settleSequence mode=$activeDisplayMode page=${activePageIndex + 1} " + - "previewPage=${previewPageIndex + 1} oldEvent=${oldZoom.formatLogFloat()} " + - "activeScale=${activeScale.formatLogFloat()} base=${baseZoom.formatLogFloat()} " + - "new=${newZoom.formatLogFloat()} anchor=${anchor.formatLogOffset()} existing=${existingPreview != null} " + - "viewport=${pdfZoomViewportRootOffset.formatLogOffset()} pageRoot=${previewPageRootOffset.formatLogOffset()} " + - "h=${pageHorizontalScrollState.value} v=${pageVerticalScrollState.value} " + - "list=${verticalListState.firstVisibleItemIndex}:${verticalListState.firstVisibleItemScrollOffset} " + - "renderPage=${renderedPageIndex?.plus(1) ?: "none"} renderScale=${renderedPageScale?.formatLogFloat() ?: "none"}" - } - val nextCommitJob = pdfScope.launch { - delay(DesktopPdfZoomCommitDebounceMillis) - val preview = pdfZoomPreview ?: return@launch - logPdfZoomSettle { - "commit_debounce_fire seq=$settleSequence base=${preview.baseZoom.formatLogFloat()} " + - "zoom=${preview.zoom.formatLogFloat()} page=${preview.pageIndex?.plus(1) ?: "none"} " + - "anchor=${preview.anchor.formatLogOffset()}" - } - applyAnchoredPdfZoom(preview.baseZoom, preview.zoom, preview.anchor) - } - zoomCommitJob.getAndSet(nextCommitJob)?.cancel() - if ( - activeDisplayMode == PdfDisplayMode.PAGINATION && - renderedPage != null && - renderedPageIndex == activePageIndex - ) { - renderJob?.cancel() - } - } - - fun cancelPendingPdfZoomPreview() { - logPdfZoomSettle { - "preview_cancel seq=$pdfZoomSettleSequence reason=explicit pending=${pdfZoomPreview != null}" - } - pdfZoomPreview = null - zoomCommitJob.getAndSet(null)?.cancel() - } - - fun commitPendingPdfZoomPreviewForNavigation(targetPageIndex: Int) { - val snapshot = desktopPdfNavigationZoomSnapshot( - preview = pdfZoomPreview, - currentHorizontalScroll = pageHorizontalScrollState.value, - currentVerticalScroll = pageVerticalScrollState.value - ) ?: return - val committedZoom = zoomSpec.clamp(snapshot.zoom) - logPdfZoomSettle { - "preview_navigation_commit seq=$pdfZoomSettleSequence page=${pageIndex + 1} " + - "target=${targetPageIndex + 1} zoom=${committedZoom.formatLogFloat()} " + - "h=${snapshot.horizontalScroll} v=${snapshot.verticalScroll}" - } - zoomCommitJob.getAndSet(null)?.cancel() - zoomAnchorJob.getAndSet(null)?.cancel() - pdfZoomPreview = null - dispatchPdf(SharedPdfReaderAction.ZoomChanged(committedZoom)) - if (displayMode == PdfDisplayMode.PAGINATION) { - pdfNavigationScrollRestoreSequence += 1 - pendingPdfNavigationScrollRestore = DesktopPdfPendingPaginatedScrollRestore( - requestId = pdfNavigationScrollRestoreSequence, - pageIndex = targetPageIndex.coerceIn(0, (document.pageCount - 1).coerceAtLeast(0)), - zoom = committedZoom, - horizontalScroll = snapshot.horizontalScroll, - verticalScroll = snapshot.verticalScroll - ) - } else { - pdfScope.launch { - pageHorizontalScrollState.scrollTo(snapshot.horizontalScroll) - } - } - } - - fun cachePaginatedRender(page: Int, renderScale: Float, render: DesktopPdfPageRender) { - paginatedRenderCache[page] = DesktopPdfCachedPageRender(render, renderScale) - val activePageIndex = currentPdfPageIndex - val keepRange = - (activePageIndex - DesktopPdfPaginationRenderCacheRadius)..(activePageIndex + DesktopPdfPaginationRenderCacheRadius) - val evictedPages = paginatedRenderCache.keys - .filter { it !in keepRange } - evictedPages.forEach { paginatedRenderCache.remove(it) } - logPdfZoomPerf { - "cache_put page=${page + 1} scale=${renderScale.formatLogFloat()} " + - "bitmap=${render.width}x${render.height} current=${activePageIndex + 1} " + - "keys=${paginatedRenderCache.keys.sorted().map { it + 1 }} evicted=${evictedPages.map { it + 1 }}" - } - logPdfZoomSettle { - "cache_put seq=$pdfZoomSettleSequence page=${page + 1} scale=${renderScale.formatLogFloat()} " + - "bitmap=${render.width}x${render.height} current=${activePageIndex + 1} " + - "keys=${paginatedRenderCache.keys.sorted().map { it + 1 }} evicted=${evictedPages.map { it + 1 }}" - } - } - - LaunchedEffect(documentHandleId, pageIndex, displayMode, scale) { - if (currentShouldRestorePdfReaderFocus) { - runCatching { pdfReaderFocusRequester.requestFocus() } - } - } - - val searchQuery = pdfState.searchQuery - val isPdfSearchActive = pdfState.isSearchActive - val showPdfSearchResultsPanel = pdfState.showSearchResultsPanel - val activeSearchIndex = pdfState.activeSearchResultIndex - val searchHighlightMode = pdfState.searchHighlightMode - val selectedTool = pdfState.selectedTool - val selectedColor = pdfState.selectedColorArgb - val strokeWidth = pdfState.strokeWidth - val pdfHighlighterColors = pdfHighlighterPalette.sanitized().colors - val isTextSelectionMode = pdfState.isTextSelectionMode - val bookmarks = pdfState.bookmarks - val selectedAnnotationId = pdfState.selectedAnnotationId - val annotations = pdfState.annotations - val canGoPrevious = if (displayMode == PdfDisplayMode.PAGINATION) { - PdfSpreadLayout.canGoPrevious(pageIndex, document.pageCount, pdfReaderSettings) - } else { - pdfState.canGoPrevious - } - val canGoNext = if (displayMode == PdfDisplayMode.PAGINATION) { - PdfSpreadLayout.canGoNext(pageIndex, document.pageCount, pdfReaderSettings) - } else { - pdfState.canGoNext - } - val progressPercent = if (displayMode == PdfDisplayMode.PAGINATION) { - PdfSpreadLayout.progressPercent(pageIndex, document.pageCount, pdfReaderSettings) - } else { - pdfState.progressPercent - } - val latestOnPageStateChange by rememberUpdatedState(onPageStateChange) - - fun pdfViewportSnapshot(): SharedPdfReaderViewport { - val state = pdfState - return SharedPdfReaderViewport( - pageIndex = state.pageIndex, - displayMode = state.displayMode, - zoom = pdfZoomPreview?.zoom ?: state.zoom, - horizontalScrollOffset = pageHorizontalScrollState.value, - paginatedVerticalScrollOffset = pageVerticalScrollState.value, - verticalFirstPageIndex = verticalListState.firstVisibleItemIndex, - verticalFirstPageScrollOffset = verticalListState.firstVisibleItemScrollOffset - ).sanitized(document.pageCount, zoomSpec) - } - - fun pdfProgressPercentFor(pageIndex: Int): Float { - return if (displayMode == PdfDisplayMode.PAGINATION) { - PdfSpreadLayout.progressPercent(pageIndex, document.pageCount, pdfReaderSettings) - } else { - ((pageIndex + 1).toFloat() / document.pageCount.coerceAtLeast(1)) * 100f - } - } - - var latestPdfViewport by remember(documentHandleId) { - mutableStateOf(restoredInitialViewport ?: pdfViewportSnapshot()) - } - - fun persistPdfViewport(viewport: SharedPdfReaderViewport = pdfViewportSnapshot()) { - latestPdfViewport = viewport - latestOnPageStateChange(viewport.pageIndex, pdfProgressPercentFor(viewport.pageIndex), viewport) - } - - 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(documentHandleId) { mutableStateOf(false) } - var arePdfBookmarksLoaded by remember(documentHandleId) { mutableStateOf(false) } - var indexedSearchPageCount by remember(documentHandleId) { mutableStateOf(document.indexedSearchTextPageCount()) } - var isSearchIndexing by remember(documentHandleId) { mutableStateOf(false) } - var searchResults by remember(documentHandleId) { mutableStateOf>(emptyList()) } - var selectedEmbeddedAnnotationId by remember(documentHandleId) { mutableStateOf(null) } - val selectedAnnotation = remember(annotations, selectedAnnotationId) { - annotations.firstOrNull { it.id == selectedAnnotationId } - } - val selectedTextHighlight = selectedAnnotation?.takeIf { it.isDesktopTextSelectionHighlight } - val sortedSidebarHighlights = remember(annotations) { - desktopPdfSidebarHighlights(annotations) - } - 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 - val localPdfFile = remember(document.path, document.formatLabel) { - File(document.path).takeIf { document.formatLabel == "PDF" && it.isFile } - } - val pdfFileActions = remember(localPdfFile, hasReflowFile, isReflowingThisBook, onReflowAction) { - ReaderWorkspaceFileActionState( - canSaveCopy = localPdfFile != null, - canPrint = localPdfFile != null, - canGenerateTextView = localPdfFile != null && onReflowAction != null, - hasGeneratedTextView = hasReflowFile, - isGeneratingTextView = isReflowingThisBook - ) - } - val sidecarsReadyForExport = arePdfAnnotationsLoaded && isRichTextLoaded - val annotationsForExportChoice = remember(annotations, activeTextDraft) { - val draftAnnotation = activeTextDraft - ?.toAnnotation() - ?.takeIf { it.text.isNotBlank() } - if (draftAnnotation == null) annotations else annotations + draftAnnotation - } - val shouldShowAnnotationExportChoice = shouldShowDesktopPdfAnnotationExportChoice( - sidecarsReady = sidecarsReadyForExport, - annotations = annotationsForExportChoice, - richTextPageLayouts = richTextController.pageLayouts - ) - - fun runPdfFileAction( - successTitle: String, - action: suspend () -> String - ) { - if (isPdfFileActionLoading) return - pdfScope.launch { - isPdfFileActionLoading = true - try { - val message = action() - pdfFileActionNotice = DesktopPdfFileActionNotice( - title = successTitle, - message = message - ) - } catch (error: Throwable) { - pdfFileActionNotice = DesktopPdfFileActionNotice( - title = pdfString("desktop_pdf_action_failed", "PDF action failed"), - message = error.message ?: pdfString( - "desktop_pdf_action_failed_desc", - "The PDF action could not be completed." - ), - isError = true - ) - } finally { - isPdfFileActionLoading = false - } - } - } - - suspend fun preparePdfAnnotationExport() { - commitActiveTextDraft() - if (isRichTextMode) { - isRichTextMode = false - } - richTextController.saveImmediate() - } - - fun savePdfCopy(mode: SaveMode) { - val target = chooseSavePdfFile( - desktopSuggestedPdfFilename( - originalName = localPdfFile?.name ?: document.title, - isAnnotated = mode == SaveMode.ANNOTATED - ) - ) ?: return - runPdfFileAction(successTitle = pdfString("desktop_pdf_saved", "PDF saved")) { - if (mode == SaveMode.ANNOTATED) { - preparePdfAnnotationExport() - } - val annotationSnapshot = pdfState.annotations - val richTextSnapshot = richTextController.pageLayouts - withContext(Dispatchers.IO) { - saveDesktopPdfCopy( - document = document, - target = target, - mode = mode, - annotations = annotationSnapshot, - richTextPageLayouts = richTextSnapshot - ) - } - pdfString("desktop_saved_to_path_format", "Saved to %1\$s", target.absolutePath) - } - } - - val requestSaveCopy: () -> Unit = { - if (shouldShowAnnotationExportChoice) { - showPdfSaveDialog = true - } else { - savePdfCopy(SaveMode.ORIGINAL) - } - } - val requestPrint: () -> Unit = { - runPdfFileAction(successTitle = pdfString("action_print", "Print")) { - withContext(Dispatchers.IO) { - printDesktopPdfDocument(document) - } - pdfString("desktop_print_dialog_finished", "The print dialog has finished.") - } - } - - LaunchedEffect(selectedTool) { - activeStroke = emptyList() - eraserPosition = null - } - - fun updatePdfHighlighterPalette(nextPalette: SharedPdfHighlighterPalette) { - fun sameRgb(left: Int, right: Int): Boolean = (left and 0x00FFFFFF) == (right and 0x00FFFFFF) - - val previousSlot = pdfHighlighterPalette.sanitized().colors.indexOfFirst { sameRgb(it, selectedColor) } - val sanitizedPalette = nextPalette.sanitized() - onPdfHighlighterPaletteChange(sanitizedPalette) - if (selectedTool.isDesktopHighlighter && sanitizedPalette.colors.none { sameRgb(it, selectedColor) }) { - val colorArgb = sanitizedPalette.colors.getOrNull(previousSlot) - ?: sanitizedPalette.colors.firstOrNull() - colorArgb?.let { nextSelectedColor -> - dispatchPdf(SharedPdfReaderAction.ColorSelected(nextSelectedColor)) - } - } - } - - fun currentPdfTtsCacheSummary() = - ttsAdapter.cacheSummary(document.title, aiByokSettings.sanitized().ttsSpeakerId) - - DesktopExternalLinkDialog( - url = externalLinkDialogUrl, - onDismiss = { externalLinkDialogUrl = null } - ) - - val pdfPopupActive = - externalLinkDialogUrl != null || - showPdfAiHub || - showPdfSaveDialog || - pdfFileActionNotice != null || - isPdfFileActionLoading || - selectedTextHighlight != null || - selectedEmbeddedAnnotation != null || - pdfExtrasState.aiResult.hasContent || - (textSelection != null && selectionMenuOffset != null) - LaunchedEffect(pdfPopupActive, documentHandleId) { - if (!pdfPopupActive) { - delay(120L) - runCatching { pdfReaderFocusRequester.requestFocus() } - } - } - - LaunchedEffect(pdfReaderFocusRestoreRequest, documentHandleId) { - if (pdfReaderFocusRestoreRequest > 0) { - delay(140L) - if (currentShouldRestorePdfReaderFocus && !pdfPopupActive) { - runCatching { pdfReaderFocusRequester.requestFocus() } - } - } - } - - LaunchedEffect(aiByokSettings, cloudTtsControlsAvailable) { - pdfExtrasState = pdfExtrasState.copy( - cloudTts = pdfExtrasState.cloudTts.copy( - isAvailable = cloudTtsControlsAvailable && aiByokSettings.isCloudTtsAvailable, - errorMessage = null, - cacheSummary = currentPdfTtsCacheSummary() - ) - ) - } - - DesktopPdfAnnotationSidecarEffect( - documentHandleId = documentHandleId, - annotationFile = annotationFile, - annotations = annotations, - annotationsLoaded = arePdfAnnotationsLoaded, - onAnnotationsLoadedChange = { arePdfAnnotationsLoaded = it }, - onAnnotationsLoaded = { loadedAnnotations -> - dispatchPdf(SharedPdfReaderAction.AnnotationsLoaded(loadedAnnotations)) - }, - onLocalSidecarsChanged = onLocalSidecarsChanged - ) - - DesktopPdfRichTextSidecarEffect( - documentHandleId = documentHandleId, - richTextFile = richTextFile, - richTextController = richTextController, - onRichTextLoadedChange = { isRichTextLoaded = it } - ) - - DesktopPdfBookmarkSidecarEffect( - documentHandleId = documentHandleId, - bookmarkFile = bookmarkFile, - bookmarks = bookmarks, - bookmarksLoaded = arePdfBookmarksLoaded, - onBookmarksLoadedChange = { arePdfBookmarksLoaded = it }, - onBookmarksLoaded = { loadedBookmarks -> - dispatchPdf(SharedPdfReaderAction.BookmarksLoaded(loadedBookmarks)) - }, - onLocalSidecarsChanged = onLocalSidecarsChanged - ) - - DesktopPdfSearchIndexSidecarEffect( - documentHandleId = documentHandleId, - document = document, - searchIndexFile = searchIndexFile, - onIndexedSearchPageCountChange = { indexedSearchPageCount = it }, - onSearchIndexingChange = { isSearchIndexing = it } - ) - - DesktopPdfSearchResultsEffect( - documentHandleId = documentHandleId, - document = document, - searchQuery = searchQuery, - indexedSearchPageCount = indexedSearchPageCount, - onSearchResultsChange = { searchResults = it } - ) - - fun goToPage( - target: Int, - scrollVertical: Boolean = true, - recordJump: Boolean = false, - saveRichTextBeforePageChange: Boolean = true, - commitPendingZoomPreview: Boolean = true - ) { - val boundedTarget = target.coerceIn(0, (document.pageCount - 1).coerceAtLeast(0)) - val clampedTarget = if (displayMode == PdfDisplayMode.PAGINATION) { - PdfSpreadLayout.normalizePageIndex(boundedTarget, document.pageCount, pdfReaderSettings) - } else { - boundedTarget - } - val currentPage = pdfState.pageIndex - val selectingDifferentPageInSpread = displayMode == PdfDisplayMode.PAGINATION && - boundedTarget != currentPage - SharedPdfRichTextLog.d( - "desktop.goToPage target=$target clamped=$clampedTarget current=$currentPage " + - "richMode=$isRichTextMode scrollVertical=$scrollVertical recordJump=$recordJump " + - "saveRich=$saveRichTextBeforePageChange activePage=${richTextController.activePageIndex}" - ) - if (clampedTarget != currentPage || selectingDifferentPageInSpread) { - 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 - ) - } - if (commitPendingZoomPreview) { - commitPendingPdfZoomPreviewForNavigation(clampedTarget) - } - dispatchPdf(SharedPdfReaderAction.GoToPage(clampedTarget)) - if (scrollVertical && displayMode == PdfDisplayMode.VERTICAL_SCROLL) { - pdfScope.launch { - verticalListState.scrollToItem(clampedTarget) - } - } - } - - fun updatePdfPageScrub(value: Float) { - if (pageScrubStartPage == null) { - pageScrubStartPage = pdfState.pageIndex - } - val targetPage = desktopPdfPageScrubTarget( - value = value, - pageCount = document.pageCount, - displayMode = displayMode, - settings = pdfReaderSettings - ) - pageScrubPreview = targetPage - } - - fun finishPdfPageScrub() { - val startPage = pageScrubStartPage - val targetPage = desktopPdfPageScrubCommitTarget( - previewPage = pageScrubPreview, - currentPage = pdfState.pageIndex, - pageCount = document.pageCount - ) - pageScrubStartPage = null - pageScrubPreview = null - if (startPage != null) { - jumpHistory = jumpHistory.record( - currentPageIndex = startPage, - targetPageIndex = targetPage, - pageCount = document.pageCount - ) - } - goToPage(targetPage) - } - - fun previousPdfPageTarget(): Int { - return if (displayMode == PdfDisplayMode.PAGINATION) { - PdfSpreadLayout.previousPageIndex(pageIndex, document.pageCount, pdfReaderSettings) - } else { - pageIndex - 1 - } - } - - fun nextPdfPageTarget(): Int { - return if (displayMode == PdfDisplayMode.PAGINATION) { - PdfSpreadLayout.nextPageIndex(pageIndex, document.pageCount, pdfReaderSettings) - } else { - pageIndex + 1 - } - } - - 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() - if (featurePolicy.externalLookup) { - 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, - colorArgb: Int = SharedPdfAnnotationDefaults.configFor(PdfInkTool.HIGHLIGHTER).colorArgb - ) { - 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()}" - ) - } - val annotation = SharedPdfAnnotation( - id = "highlight_${now}", - pageIndex = pageIndex, - kind = PdfAnnotationKind.HIGHLIGHT, - tool = PdfInkTool.HIGHLIGHTER, - bounds = highlightBounds.firstOrNull(), - boundsList = highlightBounds, - text = selection.text, - colorArgb = SharedPdfHighlighterPalette(listOf(colorArgb)).sanitized().colors.first(), - rangeStartIndex = selection.startIndex, - rangeEndIndex = selection.endIndex, - createdAt = now - ) - pdfState = pdfState.withDesktopPdfTextSelectionHighlightAdded(annotation, zoomSpec) - clearPdfInteractionState() - } - - fun clearSelection() { - textSelection = null - selectionStartIndex = null - selectionEndIndex = null - selectionStartHit = null - selectionEndHit = null - selectionMenuOffset = null - activeSelectionHandle = null - } - - fun openPdfExternalLookup(action: ReaderExternalLookupAction, text: String) { - if (!featurePolicy.externalLookup) return - 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 = pdfString("pdf_page_short", "Page %1\$d", 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 pdfCloudTtsStoppedState(statusMessage: String? = null, errorMessage: String? = null) = ReaderCloudTtsState( - isAvailable = cloudTtsControlsAvailable && aiByokSettings.sanitized().isCloudTtsAvailable, - statusMessage = statusMessage, - errorMessage = errorMessage, - cacheSummary = currentPdfTtsCacheSummary() - ) - - fun pdfHubBookKey(): String { - val path = document.path.trim() - if (path.isNotBlank()) return path - val title = document.title.trim() - return if (title.isNotBlank()) title else document.handleId.toString() - } - - fun pdfHubBookTitle(): String { - val title = document.title.trim() - if (title.isNotBlank()) return title - val fileName = document.path.substringAfterLast('\\').substringAfterLast('/').trim() - return if (fileName.isNotBlank()) fileName else "PDF" - } - - fun clearPdfHubSummary() { - pdfHubSummaryResult = null - isPdfHubSummaryLoading = false - } - - fun generatePdfHubSummary(force: Boolean) { - val pageText = currentPdfPageText(16_000) - val bookKey = pdfHubBookKey() - val pageTitle = pdfString("pdf_page_short", "Page %1\$d", pageIndex + 1) - if (pageText.isBlank()) { - pdfHubSummaryResult = SummarizationResult(error = pdfString("desktop_no_text_to_summarize", "There is no text to summarize.")) - return - } - if (!force) { - summaryCacheStore.getSummary(bookKey, pageIndex)?.let { cached -> - pdfHubSummaryResult = SummarizationResult(summary = cached, isCacheHit = true) - return - } - } - if (onReaderAiEntitlementRequired(ReaderAiFeature.SUMMARIZE, pageText)) return - isPdfHubSummaryLoading = true - pdfHubSummaryResult = null - pdfScope.launch { - var streamedSummary = "" - var streamedCost: Double? = null - var streamedFreeRemaining: Int? = null - fun updateStreamingSummary(error: String? = null) { - pdfHubSummaryResult = SummarizationResult( - summary = streamedSummary.takeIf { it.isNotBlank() }, - error = error, - cost = streamedCost, - freeRemaining = streamedFreeRemaining - ) - } - val result = aiAdapter.summarizeStreaming( - text = pageText, - onUsageReceived = { cost, freeRemaining -> - cost?.let { streamedCost = it } - freeRemaining?.let { streamedFreeRemaining = it } - updateStreamingSummary() - }, - onUpdate = { chunk -> - streamedSummary += chunk - updateStreamingSummary() - } - ) - val finalSummary = result.summary?.takeIf { it.isNotBlank() } ?: streamedSummary.takeIf { it.isNotBlank() } - finalSummary?.let { summary -> - summaryCacheStore.saveSummary(bookKey, pageIndex, pageTitle, summary) - } - pdfHubSummaryResult = result.copy(summary = finalSummary) - isPdfHubSummaryLoading = false - onPaidFeatureError(result.error) - } - } - - fun isPdfAiResultVisible(requestId: Long): Boolean = - pdfAiResultRequestId == requestId && dismissedPdfAiResultRequestId != requestId - - fun updatePdfAiResult(requestId: Long, aiResult: ReaderAiResultState) { - if (isPdfAiResultVisible(requestId)) { - pdfExtrasState = pdfExtrasState.copy(aiResult = aiResult) - } - } - - fun runPdfAiAction(feature: ReaderAiFeature, text: String) { - val normalizedText = text.trim() - if (normalizedText.isBlank()) return - if (!aiByokSettings.sanitized().areReaderAiFeaturesAvailable) return - if (onReaderAiEntitlementRequired(feature, normalizedText)) return - pdfAiResultRequestId += 1 - val aiResultRequestId = pdfAiResultRequestId - dismissedPdfAiResultRequestId = null - updatePdfAiResult( - aiResultRequestId, - ReaderAiResultState( - title = feature.displayName, - isLoading = true - ) - ) - pdfScope.launch { - val result = when (feature) { - ReaderAiFeature.DEFINE -> { - var streamedDefinition = "" - val definition = aiAdapter.defineStreaming( - text = normalizedText.take(2400), - context = currentPdfPageText(), - onUpdate = { chunk -> - streamedDefinition += chunk - updatePdfAiResult( - aiResultRequestId, - ReaderAiResultState( - title = feature.displayName, - text = streamedDefinition, - isLoading = true - ) - ) - } - ) - (definition.definition?.takeIf { it.isNotBlank() } ?: streamedDefinition) to definition.error - } - ReaderAiFeature.SUMMARIZE -> { - var streamedSummary = "" - var streamedCost: Double? = null - var streamedFreeRemaining: Int? = null - fun updateStreamingSummary() { - val partial = SummarizationResult( - summary = streamedSummary.takeIf { it.isNotBlank() }, - cost = streamedCost, - freeRemaining = streamedFreeRemaining - ) - pdfHubSummaryResult = partial - updatePdfAiResult( - aiResultRequestId, - ReaderAiResultState( - title = feature.displayName, - text = streamedSummary, - isLoading = true - ) - ) - } - val summary = aiAdapter.summarizeStreaming( - text = normalizedText, - onUsageReceived = { cost, freeRemaining -> - cost?.let { streamedCost = it } - freeRemaining?.let { streamedFreeRemaining = it } - updateStreamingSummary() - }, - onUpdate = { chunk -> - streamedSummary += chunk - updateStreamingSummary() - } - ) - val finalSummary = summary.summary?.takeIf { it.isNotBlank() } ?: streamedSummary.takeIf { it.isNotBlank() } - finalSummary?.let { generated -> - summaryCacheStore.saveSummary(pdfHubBookKey(), pageIndex, "Page ${pageIndex + 1}", generated) - } - pdfHubSummaryResult = summary.copy(summary = finalSummary) - finalSummary to summary.error - } - ReaderAiFeature.RECAP -> aiAdapter.recap(normalizedText).let { it.recap to it.error } - } - updatePdfAiResult( - aiResultRequestId, - ReaderAiResultState( - title = feature.displayName, - text = result.first.orEmpty(), - errorMessage = result.second, - isLoading = false - ) - ) - if (isPdfAiResultVisible(aiResultRequestId)) { - onPaidFeatureError(result.second) - } - } - } - - 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 = pdfString("desktop_voice_cache_cleared", "Voice cache cleared"), - cacheSummary = currentPdfTtsCacheSummary() - ) - ) - } - - fun pdfCloudTtsUnavailableMessage(): String { - return pdfString( - "desktop_cloud_tts_signed_in_credits_required_desc", - "Cloud TTS needs a signed-in account with credits. Pro and credits can only be purchased from the Android app." - ) - } - - fun pdfReadScopeLabel(readScope: ReaderTtsReadScope): String { - return when (readScope) { - ReaderTtsReadScope.PAGE -> pdfString("desktop_page", "Page") - ReaderTtsReadScope.CHAPTER -> pdfString("chapter", "Chapter") - ReaderTtsReadScope.BOOK -> pdfString("desktop_from_here", "From here") - } - } - - fun startPdfCloudTts( - readScope: ReaderTtsReadScope, - startChunkIndex: Int = 0, - chunksOverride: List? = null, - restartActive: Boolean = false - ) { - val settings = aiByokSettings.sanitized() - logDesktopTts( - "pdf_sequence_toggle scope=${readScope.name} startPage=${pageIndex + 1} " + - "isPlaying=${pdfExtrasState.cloudTts.isPlaying} isLoading=${pdfExtrasState.cloudTts.isLoading} " + - "keyPresent=${settings.geminiKey.isNotBlank()} ttsModel=\"${settings.ttsModel.desktopTtsPreview()}\" " + - "available=${ttsAdapter.isAvailable}" - ) - val ttsActive = pdfExtrasState.cloudTts.isPlaying || pdfExtrasState.cloudTts.isLoading || pdfExtrasState.cloudTts.isPaused - if (ttsActive && !restartActive) { - stopPdfCloudTts() - return - } - if (ttsActive) { - pdfTtsJob?.cancel() - pdfTtsJob = null - } - if (!ttsAdapter.isAvailable) { - logDesktopTts("pdf_sequence_blocked reason=adapter_unavailable") - onCloudTtsEntitlementRequired() - pdfExtrasState = pdfExtrasState.copy( - cloudTts = ReaderCloudTtsState( - isAvailable = false, - errorMessage = pdfCloudTtsUnavailableMessage(), - cacheSummary = currentPdfTtsCacheSummary() - ) - ) - return - } - val ttsSessionId = System.currentTimeMillis() - pdfExtrasState = pdfExtrasState.copy( - cloudTts = ReaderCloudTtsState( - isAvailable = true, - isLoading = true, - statusMessage = pdfString( - "desktop_preparing_scope_format", - "Preparing %1\$s", - pdfReadScopeLabel(readScope) - ), - progress = ReaderTtsProgress(sessionId = ttsSessionId, scope = readScope), - cacheSummary = currentPdfTtsCacheSummary() - ) - ) - fun updatePdfTtsSession(transform: (ReaderExtrasState) -> ReaderExtrasState) { - if (pdfExtrasState.cloudTts.progress.sessionId == ttsSessionId) { - pdfExtrasState = transform(pdfExtrasState) - } - } - val noTextMessage = pdfString("desktop_no_text_here_to_read", "There is no text here to read.") - pdfTtsJob = pdfScope.launch { - var completedChunkCount = 0 - runCatching { - val ttsChunks = chunksOverride - ?.filter { it.text.isNotBlank() } - ?: withContext(Dispatchers.IO) { - pdfTtsChunksForScope(readScope, pageIndex) - .filter { it.text.isNotBlank() } - .withTtsReplacements(ttsReplacementPreferences, document.path) - } - if (ttsChunks.isEmpty()) { - logDesktopTts("pdf_sequence_ignored reason=blank_text scope=${readScope.name}") - throw IllegalStateException(noTextMessage) - } - val boundedStartChunkIndex = startChunkIndex.coerceIn(0, ttsChunks.lastIndex) - val playbackChunks = ttsChunks.drop(boundedStartChunkIndex) - val initialProgress = ReaderTtsProgress( - sessionId = ttsSessionId, - scope = readScope, - chunks = ttsChunks, - currentChunkIndex = boundedStartChunkIndex - 1 - ) - updatePdfTtsSession { extras -> - extras.copy( - cloudTts = extras.cloudTts.copy( - progress = initialProgress, - cacheSummary = currentPdfTtsCacheSummary() - ) - ) - } - logDesktopTts( - "pdf_sequence_start scope=${readScope.name} chunks=${ttsChunks.size} " + - "startChunk=${boundedStartChunkIndex + 1}" - ) - ttsAdapter.speakChunks(document.title, readScope, playbackChunks) { relativeIndex -> - if (!isActive) throw kotlinx.coroutines.CancellationException("PDF cloud TTS stopped") - val index = boundedStartChunkIndex + relativeIndex - val chunk = ttsChunks[index] - val progress = initialProgress.copy(currentChunkIndex = index) - if (chunk.pageIndex != pdfState.pageIndex) { - goToPage(chunk.pageIndex, recordJump = false) - } - updatePdfTtsSession { extras -> - extras.copy( - cloudTts = ReaderCloudTtsState( - isAvailable = true, - isPlaying = true, - statusMessage = progress.currentPositionLabel ?: pdfString("label_reading", "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()}\"") - updatePdfTtsSession { extras -> - if (error is kotlinx.coroutines.CancellationException) { - extras.copy( - cloudTts = pdfCloudTtsStoppedState(statusMessage = pdfString("desktop_stopped", "Stopped")) - ) - } else { - onPaidFeatureError(error.message) - extras.copy( - cloudTts = pdfCloudTtsStoppedState( - errorMessage = error.message ?: pdfString("desktop_cloud_tts_failed", "Cloud TTS failed.") - ) - ) - } - } - }.onSuccess { - logDesktopTts("pdf_sequence_success chunks=$completedChunkCount") - updatePdfTtsSession { extras -> - extras.copy( - cloudTts = pdfCloudTtsStoppedState(statusMessage = pdfString("desktop_finished", "Finished")) - ) - } - } - } - } - - fun skipPdfCloudTtsChunk(delta: Int) { - val progress = pdfExtrasState.cloudTts.progress - if (progress.chunks.isEmpty()) return - val currentIndex = progress.currentChunkIndex.takeIf { it >= 0 } ?: return - val targetIndex = (currentIndex + delta).coerceIn(0, progress.chunks.lastIndex) - if (targetIndex == currentIndex) return - startPdfCloudTts( - readScope = progress.scope, - startChunkIndex = targetIndex, - chunksOverride = progress.chunks, - restartActive = true - ) - } - - fun locatePdfCloudTtsChunk() { - val chunk = pdfExtrasState.cloudTts.progress.currentChunk ?: return - goToPage(chunk.pageIndex, recordJump = false) - } - - 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 = pdfString("desktop_no_text_on_page_to_read", "There is no text on this page to read."), - cacheSummary = currentPdfTtsCacheSummary() - ) - ) - return - } - if (!ttsAdapter.isAvailable) { - logDesktopTts("pdf_toggle_blocked reason=adapter_unavailable") - onCloudTtsEntitlementRequired() - pdfExtrasState = pdfExtrasState.copy( - cloudTts = ReaderCloudTtsState( - isAvailable = false, - errorMessage = pdfCloudTtsUnavailableMessage(), - cacheSummary = currentPdfTtsCacheSummary() - ) - ) - return - } - val selectionChunks = ReaderTtsPlanner.chunksForText( - text = normalizedText, - pageIndex = pageIndex, - chapterIndex = 0, - chapterTitle = pdfString("pdf_page_short", "Page %1\$d", pageIndex + 1) - ).withTtsReplacements(ttsReplacementPreferences, document.path) - if (selectionChunks.isEmpty()) { - pdfExtrasState = pdfExtrasState.copy( - cloudTts = pdfExtrasState.cloudTts.copy( - errorMessage = pdfString("desktop_no_text_on_page_to_read", "There is no text on this page to read."), - cacheSummary = currentPdfTtsCacheSummary() - ) - ) - return - } - startPdfCloudTts( - readScope = ReaderTtsReadScope.PAGE, - chunksOverride = selectionChunks - ) - } - - fun updateAnnotation(annotation: SharedPdfAnnotation) { - dispatchPdf(SharedPdfReaderAction.AnnotationUpdated(annotation)) - } - - fun deleteAnnotation(annotationId: String) { - dispatchPdf(SharedPdfReaderAction.AnnotationDeleted(annotationId)) - } - - fun goToAnnotation(annotation: SharedPdfAnnotation) { - dispatchPdf(SharedPdfReaderAction.AnnotationSelected(null)) - selectedEmbeddedAnnotationId = null - goToPage(annotation.pageIndex, recordJump = true) - } - - fun selectAnnotation(annotation: SharedPdfAnnotation?) { - dispatchPdf(SharedPdfReaderAction.AnnotationSelected(annotation?.id)) - annotation?.let { goToPage(it.pageIndex, recordJump = true) } - } - - fun goToEmbeddedAnnotation(annotation: SharedPdfEmbeddedAnnotation) { - dispatchPdf(SharedPdfReaderAction.AnnotationSelected(null)) - selectedEmbeddedAnnotationId = null - goToPage(annotation.pageIndex, recordJump = true) - } - - fun selectEmbeddedAnnotation(annotation: SharedPdfEmbeddedAnnotation?) { - selectedEmbeddedAnnotationId = annotation?.id - annotation?.let { goToPage(it.pageIndex, recordJump = true) } - } - - fun dismissSelectedTextHighlightSheet() { - clearPdfInteractionState() - pdfState = pdfState.withDesktopPdfTextHighlightSheetDismissed(zoomSpec) - requestPdfReaderFocusRestore() - } - - fun deleteSelectedTextHighlight(annotation: SharedPdfAnnotation) { - clearPdfInteractionState() - pdfState = pdfState.withDesktopPdfTextHighlightSheetDismissed(zoomSpec) - dispatchPdf(SharedPdfReaderAction.AnnotationDeleted(annotation.id)) - requestPdfReaderFocusRestore() - } - - fun goToSearchResult(targetIndex: Int) { - if (searchResults.isEmpty()) return - val normalizedIndex = when { - targetIndex < 0 -> searchResults.lastIndex - targetIndex > searchResults.lastIndex -> 0 - else -> targetIndex - } - 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.PAGINATION) { - val normalizedTarget = PdfSpreadLayout.normalizePageIndex(targetPage, document.pageCount, pdfReaderSettings) - if (normalizedTarget != pdfState.pageIndex) { - dispatchPdf(SharedPdfReaderAction.GoToPage(normalizedTarget)) - } - } else if (displayMode == PdfDisplayMode.VERTICAL_SCROLL) { - pdfScope.launch { - verticalListState.scrollToItem(targetPage) - } - } - } - - LaunchedEffect(documentHandleId, document.pageCount) { - jumpHistory = jumpHistory.pruned(document.pageCount) - } - - LaunchedEffect(documentHandleId, document.pageCount) { - snapshotFlow { pdfViewportSnapshot() } - .distinctUntilChanged() - .collectLatest { viewport -> - latestPdfViewport = viewport - delay(DesktopPdfViewportPersistDebounceMillis) - persistPdfViewport(viewport) - } - } - - DisposableEffect(documentHandleId) { - onDispose { - persistPdfViewport() - } - } - - var pendingInitialViewportRestore by remember(documentHandleId) { mutableStateOf(restoredInitialViewport) } - LaunchedEffect(documentHandleId, displayMode) { - if (displayMode == PdfDisplayMode.VERTICAL_SCROLL && pageIndex in 0 until document.pageCount) { - if (pendingInitialViewportRestore?.displayMode == PdfDisplayMode.VERTICAL_SCROLL) return@LaunchedEffect - verticalListState.scrollToItem(pageIndex) - } - } - - LaunchedEffect( - documentHandleId, - pendingInitialViewportRestore, - displayMode, - isPdfTwoPageSpread, - renderedPageIndex, - renderedPageScale - ) { - val viewport = pendingInitialViewportRestore ?: return@LaunchedEffect - if (viewport.displayMode != displayMode) { - pendingInitialViewportRestore = null - return@LaunchedEffect - } - when (viewport.displayMode) { - PdfDisplayMode.PAGINATION -> { - if (!isPdfTwoPageSpread && renderedPageIndex != viewport.pageIndex) return@LaunchedEffect - withFrameNanos { } - pageHorizontalScrollState.scrollTo(viewport.horizontalScrollOffset) - pageVerticalScrollState.scrollTo(viewport.paginatedVerticalScrollOffset) - pendingInitialViewportRestore = null - latestPdfViewport = viewport - } - - PdfDisplayMode.VERTICAL_SCROLL -> { - withFrameNanos { } - verticalListState.scrollToItem( - viewport.verticalFirstPageIndex, - viewport.verticalFirstPageScrollOffset - ) - pageHorizontalScrollState.scrollTo(viewport.horizontalScrollOffset) - pendingInitialViewportRestore = null - latestPdfViewport = viewport - } - } - } - - LaunchedEffect( - documentHandleId, - pendingPdfNavigationScrollRestore?.requestId, - pageIndex, - scale, - displayMode - ) { - val restore = pendingPdfNavigationScrollRestore ?: return@LaunchedEffect - if ( - displayMode != PdfDisplayMode.PAGINATION || - restore.pageIndex != pageIndex || - abs(restore.zoom - scale) > 0.001f - ) { - return@LaunchedEffect - } - withFrameNanos { } - pageHorizontalScrollState.scrollTo(restore.horizontalScroll) - pageVerticalScrollState.scrollTo(restore.verticalScroll) - withFrameNanos { } - pageHorizontalScrollState.scrollTo(restore.horizontalScroll) - pageVerticalScrollState.scrollTo(restore.verticalScroll) - logPdfZoomSettle { - "preview_navigation_restore request=${restore.requestId} page=${pageIndex + 1} " + - "zoom=${scale.formatLogFloat()} h=${pageHorizontalScrollState.value} " + - "v=${pageVerticalScrollState.value}" - } - if (pendingPdfNavigationScrollRestore == restore) { - pendingPdfNavigationScrollRestore = null - } - } - - fun selectPdfPanMode() { - SharedPdfRichTextLog.d( - "desktop.tool.select tool=${PdfInkTool.NONE} richMode=$isRichTextMode page=${pdfState.pageIndex}" - ) - deactivateRichTextMode() - commitActiveTextDraft() - clearPdfInteractionState() - if (pdfState.isTextSelectionMode) { - dispatchPdf(SharedPdfReaderAction.TextSelectionModeChanged(false)) - } - dispatchPdf(SharedPdfReaderAction.ToolSelected(PdfInkTool.NONE)) - } - - fun togglePdfTextSelectionMode() { - val enabled = !isTextSelectionMode - if (enabled) { - deactivateRichTextMode() - commitActiveTextDraft() - } - dispatchPdf(SharedPdfReaderAction.TextSelectionModeChanged(enabled)) - if (!enabled) { - clearPdfInteractionState() - } - } - - @Composable - fun DesktopPdfBottomMarkupDock(modifier: Modifier = Modifier) { - SharedPdfInteractionDock( - isTextSelectionMode = isTextSelectionMode, - selectedTool = selectedTool, - selectedColor = selectedColor, - strokeWidth = strokeWidth, - toolConfigs = pdfState.toolConfigs, - penPalette = pdfState.penPalette, - highlighterPalette = pdfHighlighterColors, - lastActivePenTool = pdfState.lastActivePenTool, - lastActiveHighlighterTool = pdfState.lastActiveHighlighterTool, - onPanSelected = ::selectPdfPanMode, - onTextSelectionSelected = ::togglePdfTextSelectionMode, - onToolSelected = ::selectPdfAnnotationTool, - onColorSelected = { dispatchPdf(SharedPdfReaderAction.ColorSelected(it)) }, - onStrokeWidthChange = { dispatchPdf(SharedPdfReaderAction.StrokeWidthChanged(it)) }, - onUndo = { dispatchPdf(SharedPdfReaderAction.UndoAnnotationEdit) }, - onRedo = { dispatchPdf(SharedPdfReaderAction.RedoAnnotationEdit) }, - onClearPage = { dispatchPdf(SharedPdfReaderAction.ClearPageAnnotations(pageIndex)) }, - modifier = modifier, - allowExpandedSettings = !isPdfSearchActive && - activeTextDraft == null && - !isRichTextMode && - textSelection == null && - selectionMenuOffset == null && - !pdfSelectionSheetActive && - externalLinkDialogUrl == null && - !pdfExtrasState.aiResult.hasContent, - canUndo = pdfState.canUndoAnnotationEdit, - canRedo = pdfState.canRedoAnnotationEdit, - canClearPage = annotations.any { it.pageIndex == pageIndex }, - isHighlighterSnapEnabled = isHighlighterSnapEnabled, - onHighlighterSnapChange = { isHighlighterSnapEnabled = it }, - onHighlighterPaletteChange = { colors -> - onPdfHighlighterPaletteChange(SharedPdfHighlighterPalette(colors).sanitized()) - }, - onPenPaletteChange = { colors -> dispatchPdf(SharedPdfReaderAction.PenPaletteChanged(colors)) } - ) - } - - LaunchedEffect(documentHandleId, 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, commitPendingZoomPreview = false) - } - } - } - - LaunchedEffect(documentHandleId, pageIndex, scale, displayMode, isPdfTwoPageSpread) { - renderJob?.cancel() - if (displayMode != PdfDisplayMode.PAGINATION || isPdfTwoPageSpread) { - isRendering = false - renderError = null - renderedPage = null - renderedPageIndex = null - renderedPageScale = null - return@LaunchedEffect - } - logPdfZoomPerf { - "render_effect page=${pageIndex + 1} scale=${scale.formatLogFloat()} " + - "existingPage=${renderedPageIndex?.let { it + 1 } ?: "none"} " + - "existingScale=${renderedPageScale?.formatLogFloat() ?: "none"} " + - "searchIndexing=$isSearchIndexing indexed=$indexedSearchPageCount/${document.pageCount} " + - "cacheKeys=${paginatedRenderCache.keys.sorted().map { it + 1 }}" - } - logPdfZoomSettle { - "render_effect seq=$pdfZoomSettleSequence page=${pageIndex + 1} scale=${scale.formatLogFloat()} " + - "existingPage=${renderedPageIndex?.plus(1) ?: "none"} " + - "existingScale=${renderedPageScale?.formatLogFloat() ?: "none"} " + - "preview=${pdfZoomPreview != null} h=${pageHorizontalScrollState.value} v=${pageVerticalScrollState.value} " + - "pageRoot=${paginatedPageRootOffset.formatLogOffset()} canvas=${pageCanvasSize.formatLogSize()}" - } - if (renderedPageIndex != pageIndex) { - paginatedRenderCache[pageIndex]?.let { cached -> - logPdfZoomPerf { - "cache_hit page=${pageIndex + 1} scale=${cached.scale.formatLogFloat()} " + - "bitmap=${cached.render.width}x${cached.render.height}" - } - logPdfZoomSettle { - "cache_hit seq=$pdfZoomSettleSequence page=${pageIndex + 1} " + - "scale=${cached.scale.formatLogFloat()} bitmap=${cached.render.width}x${cached.render.height}" - } - renderedPage = cached.render - renderedPageIndex = pageIndex - renderedPageScale = cached.scale - renderError = null - isRendering = false - } - } - val hasPageRender = renderedPage != null && desktopPdfRenderBelongsToPage(renderedPageIndex, pageIndex) - if (!hasPageRender) { - logPdfZoomPerf { - "cache_miss page=${pageIndex + 1}; stale=${renderedPageIndex?.let { it + 1 } ?: "none"}" - } - logPdfZoomSettle { - "cache_miss seq=$pdfZoomSettleSequence page=${pageIndex + 1} " + - "stale=${renderedPageIndex?.plus(1) ?: "none"}" - } - renderedPage = null - renderedPageIndex = null - renderedPageScale = null - isRendering = true - } - renderJob = launch { - val pageSize = document.pageSizes.getOrNull(pageIndex) - if (pageSize == null) { - renderedPage = null - renderedPageIndex = null - renderedPageScale = null - renderError = pdfString("desktop_failed_render_page", "Failed to render page.") - isRendering = false - return@launch - } - val safeScale = zoomSpec.safeRenderScale( - pageSize.width, - pageSize.height, scale - ) - val isOpeningRender = paginatedRenderCache.isEmpty() && !hasPageRender - val firstRenderScale = desktopPdfPaginationFirstRenderScale( - requestedScale = safeScale, - hasPageRender = hasPageRender, - isOpeningRender = isOpeningRender - ) - logPdfZoomPerf { - "render_plan page=${pageIndex + 1} requestedScale=${scale.formatLogFloat()} " + - "safeScale=${safeScale.formatLogFloat()} firstScale=${firstRenderScale.formatLogFloat()} " + - "hasRender=$hasPageRender opening=$isOpeningRender" - } - logPdfZoomSettle { - "render_plan seq=$pdfZoomSettleSequence page=${pageIndex + 1} requestedScale=${scale.formatLogFloat()} " + - "safeScale=${safeScale.formatLogFloat()} firstScale=${firstRenderScale.formatLogFloat()} " + - "hasRender=$hasPageRender opening=$isOpeningRender existingScale=${renderedPageScale?.formatLogFloat() ?: "none"}" - } - - suspend fun renderAt(renderScale: Float, delayMillis: Long, showSpinner: Boolean): Boolean { - logPdfZoomPerf { - "render_scheduled page=${pageIndex + 1} renderScale=${renderScale.formatLogFloat()} " + - "requestedScale=${scale.formatLogFloat()} delayMs=$delayMillis showSpinner=$showSpinner " + - "hasPageRender=$hasPageRender" - } - logPdfZoomSettle { - "render_scheduled seq=$pdfZoomSettleSequence page=${pageIndex + 1} " + - "renderScale=${renderScale.formatLogFloat()} requestedScale=${scale.formatLogFloat()} " + - "delayMs=$delayMillis showSpinner=$showSpinner preview=${pdfZoomPreview != null}" - } - delay(delayMillis) - if (showSpinner) { - isRendering = true - } - renderError = null - val startedAt = System.currentTimeMillis() - val result = withContext(Dispatchers.IO) { - runCatching { - DesktopPdfium.renderPage(document, pageIndex, renderScale) - } - } - val elapsedMs = System.currentTimeMillis() - startedAt - if (currentPdfPageIndex != pageIndex || currentPdfScale != scale || - currentPdfDisplayMode != PdfDisplayMode.PAGINATION - ) { - logPdfZoomPerf { - "render_stale page=${pageIndex + 1} renderScale=${renderScale.formatLogFloat()} " + - "elapsedMs=$elapsedMs currentPage=${currentPdfPageIndex + 1} " + - "currentScale=${currentPdfScale.formatLogFloat()} mode=$currentPdfDisplayMode" - } - logPdfZoomSettle { - "render_stale seq=$pdfZoomSettleSequence page=${pageIndex + 1} " + - "renderScale=${renderScale.formatLogFloat()} elapsedMs=$elapsedMs " + - "currentPage=${currentPdfPageIndex + 1} currentScale=${currentPdfScale.formatLogFloat()} " + - "mode=$currentPdfDisplayMode" - } - return false - } - result.getOrNull()?.let { render -> - cachePaginatedRender(pageIndex, renderScale, render) - renderedPage = render - renderedPageIndex = pageIndex - renderedPageScale = renderScale - } - renderError = result.exceptionOrNull()?.message - ?: if (renderedPage == null || renderedPageIndex != pageIndex) { - pdfString("desktop_failed_render_page", "Failed to render page.") - } else { - null - } - logPdfZoomPerf { - "render_end page=${pageIndex + 1} renderScale=${renderScale.formatLogFloat()} " + - "requestedScale=${scale.formatLogFloat()} elapsedMs=$elapsedMs success=${result.isSuccess} " + - "error=${result.exceptionOrNull()?.message?.logPreview() ?: "none"}" - } - logPdfZoomSettle { - "render_end seq=$pdfZoomSettleSequence page=${pageIndex + 1} " + - "renderScale=${renderScale.formatLogFloat()} requestedScale=${scale.formatLogFloat()} " + - "elapsedMs=$elapsedMs success=${result.isSuccess} bitmap=${renderedPage?.width ?: 0}x${renderedPage?.height ?: 0} " + - "h=${pageHorizontalScrollState.value} v=${pageVerticalScrollState.value} " + - "pageRoot=${paginatedPageRootOffset.formatLogOffset()} canvas=${pageCanvasSize.formatLogSize()}" - } - renderedPage?.let { render -> - logPdfSelection( - "render page=${pageIndex + 1} " + - "requestedScale=${scale.formatLogFloat()} renderScale=${renderScale.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 - return result.isSuccess && renderedPageIndex == pageIndex - } - - suspend fun prefetchPage(pageToPrefetch: Int) { - if (pageToPrefetch !in 0 until document.pageCount) return - val cached = paginatedRenderCache[pageToPrefetch] - if ( - cached != null && - cached.scale >= DesktopPdfPaginationFastFirstRenderMaxScale - DesktopPdfRenderScaleTolerance - ) { - logPdfZoomPerf { - "prefetch_skip_cached page=${pageToPrefetch + 1} scale=${cached.scale.formatLogFloat()}" - } - return - } - val prefetchPageSize = document.pageSizes.getOrNull(pageToPrefetch) ?: return - val prefetchScale = zoomSpec.safeRenderScale( - prefetchPageSize.width, - prefetchPageSize.height, - DesktopPdfPaginationFastFirstRenderMaxScale - ) - logPdfZoomPerf { - "prefetch_start page=${pageToPrefetch + 1} scale=${prefetchScale.formatLogFloat()} " + - "current=${pageIndex + 1}" - } - val startedAt = System.currentTimeMillis() - val result = withContext(Dispatchers.IO) { - runCatching { - DesktopPdfium.renderPage(document, pageToPrefetch, prefetchScale) - } - } - val elapsedMs = System.currentTimeMillis() - startedAt - if (currentPdfPageIndex != pageIndex || currentPdfScale != scale || - currentPdfDisplayMode != PdfDisplayMode.PAGINATION || - pdfZoomPreview != null - ) { - logPdfZoomPerf { - "prefetch_stale page=${pageToPrefetch + 1} elapsedMs=$elapsedMs " + - "currentPage=${currentPdfPageIndex + 1} currentScale=${currentPdfScale.formatLogFloat()} " + - "mode=$currentPdfDisplayMode preview=${pdfZoomPreview != null}" - } - return - } - result.getOrNull()?.let { render -> - cachePaginatedRender(pageToPrefetch, prefetchScale, render) - } - logPdfZoomPerf { - "prefetch_end page=${pageToPrefetch + 1} scale=${prefetchScale.formatLogFloat()} " + - "elapsedMs=$elapsedMs success=${result.isSuccess} " + - "error=${result.exceptionOrNull()?.message?.logPreview() ?: "none"}" - } - } - - val existingScale = renderedPageScale - val needsFirstRender = !hasPageRender || - desktopPdfRenderScaleNeedsUpgrade(existingScale, firstRenderScale) - if (needsFirstRender) { - renderAt( - renderScale = firstRenderScale, - delayMillis = if (hasPageRender) DesktopPdfZoomRenderDebounceMillis else 45L, - showSpinner = !hasPageRender - ) - } else { - logPdfZoomSettle { - "render_skip seq=$pdfZoomSettleSequence page=${pageIndex + 1} reason=no_scale_upgrade " + - "existingScale=${existingScale?.formatLogFloat() ?: "none"} firstScale=${firstRenderScale.formatLogFloat()}" - } - } - delay(DesktopPdfPaginationPrefetchDelayMillis) - if (currentPdfPageIndex == pageIndex && currentPdfScale == scale && - currentPdfDisplayMode == PdfDisplayMode.PAGINATION && - pdfZoomPreview == null - ) { - prefetchPage(pageIndex + 1) - prefetchPage(pageIndex - 1) - } - } - } - - val pdfWorkspaceModel = pdfReaderWorkspaceModel( - state = pdfState, - displayMode = displayMode, - hasContents = document.toc.isNotEmpty(), - hasBookmarks = bookmarks.isNotEmpty(), - hasAnnotations = sortedSidebarHighlights.isNotEmpty(), - hasEmbeddedComments = sortedEmbeddedAnnotations.isNotEmpty(), - searchActive = isPdfSearchActive || searchQuery.isNotBlank(), - annotationEditing = activeTextDraft != null || - selectedAnnotation != null || - selectedTool != PdfInkTool.NONE, - richTextEditing = isRichTextMode, - loading = isRendering || isSearchIndexing || isPdfFileActionLoading || isReflowingThisBook, - errorMessage = renderError, - extrasState = pdfExtrasState, - aiAvailable = featurePolicy.aiAndCloud && aiByokSettings.sanitized().areReaderAiFeaturesAvailable, - cloudTtsAvailable = cloudTtsControlsAvailable && aiByokSettings.sanitized().isCloudTtsAvailable, - externalLookupAvailable = featurePolicy.externalLookup - ) - - fun runPdfKeyCommand(command: DesktopPdfKeyCommand): Boolean { - fun scrollVertically(delta: Float): Boolean { - pdfScope.launch { - if (displayMode == PdfDisplayMode.VERTICAL_SCROLL) { - verticalListState.scrollBy(delta) - } else { - pageVerticalScrollState.scrollBy(delta) - } - } - return true - } - - return when (command) { - DesktopPdfKeyCommand.EXIT_FULLSCREEN -> { - setPdfFullscreen(false) - true - } - DesktopPdfKeyCommand.PREVIOUS_PAGE -> { - goToPage(previousPdfPageTarget()) - true - } - DesktopPdfKeyCommand.NEXT_PAGE -> { - goToPage(nextPdfPageTarget()) - true - } - DesktopPdfKeyCommand.SCROLL_UP -> scrollVertically(-96f) - DesktopPdfKeyCommand.SCROLL_DOWN -> scrollVertically(96f) - DesktopPdfKeyCommand.FIRST_PAGE -> { - goToPage(0) - true - } - DesktopPdfKeyCommand.LAST_PAGE -> { - goToPage(document.pageCount - 1) - true - } - DesktopPdfKeyCommand.SEARCH -> { - dispatchPdf(SharedPdfReaderAction.SearchOpened) - true - } - DesktopPdfKeyCommand.ZOOM_IN -> { - cancelPendingPdfZoomPreview() - dispatchPdf(SharedPdfReaderAction.ZoomBy(0.15f)) - true - } - DesktopPdfKeyCommand.ZOOM_OUT -> { - cancelPendingPdfZoomPreview() - dispatchPdf(SharedPdfReaderAction.ZoomBy(-0.15f)) - true - } - } - } - - fun isPdfTextEditingActive(): Boolean { - return activeTextDraft != null || - (selectedTool == PdfInkTool.TEXT && selectedAnnotation?.kind == PdfAnnotationKind.TEXT) || - isRichTextMode - } - - fun handlePdfReaderKeyEvent(event: androidx.compose.ui.input.key.KeyEvent): Boolean { - val command = event.desktopPdfKeyCommandOrNull( - fullscreen = isFullscreen, - editingText = isPdfTextEditingActive(), - rightToLeftPagination = rightToLeftPdfPaginationActive - ) ?: return false - return runPdfKeyCommand(command) - } - - fun handlePdfReaderAwtKeyEvent(event: AwtKeyEvent): Boolean { - val command = event.desktopPdfKeyCommandOrNull( - fullscreen = isFullscreen, - editingText = isPdfTextEditingActive(), - rightToLeftPagination = rightToLeftPdfPaginationActive - ) ?: return false - return runPdfKeyCommand(command) - } - - fun handlePdfReaderFullscreenAwtKeyEvent(event: AwtKeyEvent): Boolean { - if (isPdfSearchActive) { - if (event.id == AwtKeyEvent.KEY_PRESSED && isFullscreen && event.keyCode == AwtKeyEvent.VK_ESCAPE) { - return runPdfKeyCommand(DesktopPdfKeyCommand.EXIT_FULLSCREEN) - } - return false - } - return handlePdfReaderAwtKeyEvent(event) - } - - fun handlePdfReaderGlobalShortcutAwtKeyEvent(event: AwtKeyEvent): Boolean { - if (event.id != AwtKeyEvent.KEY_PRESSED || !event.isControlDown) return false - return when (event.keyCode) { - AwtKeyEvent.VK_F -> runPdfKeyCommand(DesktopPdfKeyCommand.SEARCH) - else -> false - } - } - - DesktopReaderKeyDispatcherEffect( - enabled = !pdfPopupActive, - allowChromeModalWindows = true, - onKeyPressed = { event -> handlePdfReaderGlobalShortcutAwtKeyEvent(event) } - ) - - DesktopReaderKeyDispatcherEffect( - enabled = !pdfPopupActive && !isPdfSearchActive, - allowPanelModalWindows = true, - dispatchWhenOwnerWindowActive = false, - onKeyPressed = { event -> handlePdfReaderAwtKeyEvent(event) } - ) - - DesktopReaderFullscreenKeyEffect( - enabled = isFullscreen && !pdfPopupActive, - onKeyPressed = { event -> handlePdfReaderFullscreenAwtKeyEvent(event) } - ) - - ReaderWorkspaceShell( - model = pdfWorkspaceModel, - title = document.title, - subtitle = pdfString("desktop_label_pair_format", "%1\$s - %2\$s", document.formatLabel, pdfPageLabel), - progressLabel = "${progressPercent.toInt()}%", - onReturnToLibrary = onReturnToLibrary?.let { returnToLibrary -> - { - persistPdfViewport() - returnToLibrary() - } - }, - isFullscreen = isFullscreen, - onFullscreenChange = ::setPdfFullscreen, - isBookmarked = bookmarks.any { it.pageIndex == pageIndex }, - onToggleBookmark = { toggleBookmark(pageIndex) }, - onSearchAction = { dispatchPdf(SharedPdfReaderAction.SearchOpened) }, - onReadAloudAction = if (cloudTtsControlsAvailable && aiByokSettings.sanitized().isCloudTtsAvailable) { - { startPdfCloudTts(ReaderTtsReadScope.BOOK) } - } else { - null - }, - onAiHubAction = if (aiByokSettings.sanitized().areReaderAiFeaturesAvailable) { - { showPdfAiHub = true } - } else { - null - }, - fileActions = pdfFileActions, - onSaveCopyAction = requestSaveCopy, - onPrintAction = requestPrint, - onTextViewAction = onReflowAction?.let { action -> { action(pdfState.pageIndex) } }, - topSearchBar = if (isPdfSearchActive) { - { - DesktopPdfSearchTopBar( - query = searchQuery, - showResultsPanel = showPdfSearchResultsPanel, - onQueryChange = { dispatchPdf(SharedPdfReaderAction.SearchChanged(it)) }, - onClose = { dispatchPdf(SharedPdfReaderAction.SearchClosed) }, - onToggleResults = { dispatchPdf(SharedPdfReaderAction.SearchResultsPanelToggled) } - ) - } - } else { - null - }, - modifier = Modifier - .focusRequester(pdfReaderFocusRequester) - .onPreviewKeyEvent(::handlePdfReaderKeyEvent) - .focusable(), - closeRightPanelOnReaderTap = true, - onReaderFocusRestoreRequest = ::requestPdfReaderFocusRestore, - leftSidebar = { _ -> - DesktopPdfNavigationSidebar( - document = document, - pageIndex = pageIndex, - sortedHighlights = sortedSidebarHighlights, - bookmarks = bookmarks, - onPageSelected = { page -> goToPage(page, recordJump = true) }, - onAnnotationOpened = ::goToAnnotation, - onAnnotationSelected = ::selectAnnotation, - onAnnotationDeleted = { annotation -> deleteAnnotation(annotation.id) } - ) - }, - rightInspector = { - DesktopPdfInspectorPanel( - document = document, - displayMode = displayMode, - pdfReaderSettings = pdfReaderSettings, - appThemeControls = appThemeControls, - customReaderThemes = customReaderThemes, - onCustomReaderThemesChange = onCustomReaderThemesChange, - customTextureIds = customTextureIds, - onImportTexture = onImportTexture, - onReaderSettingsChange = ::updatePdfReaderSettings, - selectedTool = selectedTool, - isRichTextMode = isRichTextMode, - pdfHighlighterPalette = pdfHighlighterPalette, - effectiveTextStyleConfig = effectiveTextStyleConfig, - richTextController = richTextController, - pdfExtrasState = pdfExtrasState, - aiByokSettings = aiByokSettings, - cloudTtsFeatureAvailable = cloudTtsControlsAvailable, - ttsReplacementPreferences = ttsReplacementPreferences, - onDisplayModeSelected = { mode -> - commitActiveTextDraft() - updatePdfReaderSettings( - pdfReaderSettings.copy(readingMode = mode.toDesktopReaderReadingMode()) - ) - dispatchPdf(SharedPdfReaderAction.DisplayModeChanged(mode)) - }, - onRichTextModeToggle = { - if (isRichTextMode) { - deactivateRichTextMode() - } else { - activateRichTextMode() - } - }, - onHighlighterPaletteChange = ::updatePdfHighlighterPalette, - onTextStyleChange = ::updateTextStyleConfig, - onCloudTtsClearCache = ::clearPdfCloudTtsCache, - onCloudTtsVoiceChange = { voiceId -> - onAiByokSettingsChange(aiByokSettings.sanitized().copy(ttsSpeakerId = voiceId)) - }, - onTtsReplacementPreferencesChange = onTtsReplacementPreferencesChange - ) - }, - bottomBar = { - DesktopPdfBottomChrome( - pageIndex = pageIndex, - pageCount = document.pageCount, - pageLabel = pdfPageLabel, - progressPercent = progressPercent, - canGoPrevious = canGoPrevious, - canGoNext = canGoNext, - showJumpHistory = !isPdfSearchActive, - jumpBackPage = jumpHistory.backPage, - jumpForwardPage = jumpHistory.forwardPage, - onPrevious = { goToPage(previousPdfPageTarget()) }, - onNext = { goToPage(nextPdfPageTarget()) }, - onPageScrub = ::updatePdfPageScrub, - onPageScrubFinished = ::finishPdfPageScrub, - onJumpBack = ::goBackInJumpHistory, - onJumpForward = ::goForwardInJumpHistory, - onClearJumpHistory = { jumpHistory = jumpHistory.clear() }, - extraContent = { - if (cloudTtsControlsAvailable) { - val ttsControls = readerCloudTtsControlsModel(pdfExtrasState.cloudTts) - if (ttsControls.isVisible) { - SharedReaderTtsOverlayControls( - settings = aiByokSettings, - cloudTts = pdfExtrasState.cloudTts, - credits = credits, - showCredits = showPaidCredits, - isCollapsed = isPdfTtsOverlayCollapsed, - onCollapseChange = { isPdfTtsOverlayCollapsed = it }, - onPauseResume = ::pauseResumePdfCloudTts, - onSkipPrevious = { skipPdfCloudTtsChunk(-1) }, - onSkipNext = { skipPdfCloudTtsChunk(1) }, - onLocateCurrentChunk = ::locatePdfCloudTtsChunk, - onClose = ::stopPdfCloudTts, - modifier = Modifier.align(Alignment.CenterHorizontally).padding(top = 8.dp, bottom = 4.dp) - ) - } - } - DesktopPdfBottomMarkupDock( - modifier = Modifier - .align(Alignment.CenterHorizontally) - .padding(start = 16.dp, top = 6.dp, end = 16.dp, bottom = 4.dp) - ) - } - ) - }, - fullscreenBottomBar = { - DesktopPdfFullscreenBottomChrome( - pageIndex = pageIndex, - pageCount = document.pageCount, - pageLabel = pdfPageLabel, - canGoPrevious = canGoPrevious, - canGoNext = canGoNext, - showJumpHistory = !isPdfSearchActive, - jumpBackPage = jumpHistory.backPage, - jumpForwardPage = jumpHistory.forwardPage, - onPrevious = { goToPage(previousPdfPageTarget()) }, - onNext = { goToPage(nextPdfPageTarget()) }, - onPageScrub = ::updatePdfPageScrub, - onPageScrubFinished = ::finishPdfPageScrub, - onJumpBack = ::goBackInJumpHistory, - onJumpForward = ::goForwardInJumpHistory, - onClearJumpHistory = { jumpHistory = jumpHistory.clear() }, - extraContent = { - if (cloudTtsControlsAvailable) { - val ttsControls = readerCloudTtsControlsModel(pdfExtrasState.cloudTts) - if (ttsControls.isVisible) { - SharedReaderTtsOverlayControls( - settings = aiByokSettings, - cloudTts = pdfExtrasState.cloudTts, - credits = credits, - showCredits = showPaidCredits, - isCollapsed = isPdfTtsOverlayCollapsed, - onCollapseChange = { isPdfTtsOverlayCollapsed = it }, - onPauseResume = ::pauseResumePdfCloudTts, - onSkipPrevious = { skipPdfCloudTtsChunk(-1) }, - onSkipNext = { skipPdfCloudTtsChunk(1) }, - onLocateCurrentChunk = ::locatePdfCloudTtsChunk, - onClose = ::stopPdfCloudTts, - modifier = Modifier.align(Alignment.CenterHorizontally).padding(top = 8.dp, bottom = 4.dp) - ) - } - } - DesktopPdfBottomMarkupDock( - modifier = Modifier - .align(Alignment.CenterHorizontally) - .padding(start = 16.dp, top = 6.dp, end = 16.dp, bottom = 4.dp) - ) - } - ) - } - ) { _ -> - SharedPdfRichTextHiddenInput( - controller = richTextController, - enabled = isRichTextMode, - modifier = Modifier - .align(Alignment.BottomStart) - .padding(start = 16.dp, bottom = 24.dp) - .zIndex(10f) - ) - DesktopPdfSearchOverlay( - isSearchActive = isPdfSearchActive, - showResultsPanel = showPdfSearchResultsPanel, - query = searchQuery, - results = searchResults, - activeSearchIndex = activeSearchIndex, - highlightMode = searchHighlightMode, - isIndexing = isSearchIndexing, - indexedPageCount = indexedSearchPageCount, - pageCount = document.pageCount, - onResultClick = { index -> - goToSearchResult(index) - dispatchPdf(SharedPdfReaderAction.SearchResultsPanelToggled) - }, - onShowResults = { dispatchPdf(SharedPdfReaderAction.SearchResultsPanelToggled) }, - onPrevious = { goToSearchResult(activeSearchIndex - 1) }, - onNext = { goToSearchResult(activeSearchIndex + 1) }, - onToggleHighlightMode = { dispatchPdf(SharedPdfReaderAction.SearchHighlightModeToggled) } - ) - val pdfViewportBackground = desktopPdfViewportBackgroundColor( - displayMode = displayMode, - pageBackgroundColor = pdfThemeStyle.pageBackgroundColor, - appBackgroundColor = MaterialTheme.colorScheme.surfaceVariant, - isVerticalPageGapVisible = pdfReaderSettings.pdfVerticalPageGapVisible - ) - if (displayMode == PdfDisplayMode.VERTICAL_SCROLL) { - val verticalPageGap = pdfVerticalPageGapDp( - isPageGapVisible = pdfReaderSettings.pdfVerticalPageGapVisible, - defaultGap = DesktopDefaultPdfVerticalPageGap - ) - Box( - modifier = Modifier - .fillMaxSize() - .background(pdfViewportBackground, RoundedCornerShape(if (isFullscreen) 0.dp else 4.dp)) - .onSizeChanged { size -> pdfZoomViewportSize = size } - .onGloballyPositioned { coordinates -> - val rootOffset = coordinates.positionInRoot() - if (rootOffset != pdfZoomViewportRootOffset) { - logPdfZoomSettle { - "viewport_layout seq=$pdfZoomSettleSequence mode=vertical " + - "prev=${pdfZoomViewportRootOffset.formatLogOffset()} next=${rootOffset.formatLogOffset()} " + - "scale=${scale.formatLogFloat()} preview=${pdfZoomPreview != null}" - } - } - pdfZoomViewportRootOffset = rootOffset - } - .desktopPdfZoomGestures( - currentZoom = scale, - zoomSpec = zoomSpec, - onZoomChanged = ::previewAnchoredPdfZoom - ) - ) { - LazyColumn( - state = verticalListState, - modifier = Modifier - .fillMaxSize() - .horizontalScroll(pageHorizontalScrollState) - .padding(horizontal = 24.dp, vertical = 18.dp), - verticalArrangement = Arrangement.spacedBy(verticalPageGap), - horizontalAlignment = Alignment.CenterHorizontally - ) { - items((0 until document.pageCount).toList(), key = { it }) { verticalPageIndex -> - val verticalZoomPreview = pdfZoomPreview?.takeIf { - it.displayMode == PdfDisplayMode.VERTICAL_SCROLL - } - 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, - highlighterPalette = pdfHighlighterColors, - strokeWidth = strokeWidth, - isHighlighterSnapEnabled = isHighlighterSnapEnabled, - activeTextDraft = activeTextDraft, - richTextController = richTextController, - isRichTextMode = isRichTextMode, - readerAiFeaturesAvailable = aiByokSettings.sanitized().areReaderAiFeaturesAvailable, - cloudTtsAvailable = cloudTtsControlsAvailable && aiByokSettings.sanitized().isCloudTtsAvailable, - externalLookupAvailable = featurePolicy.externalLookup, - themeStyle = pdfThemeStyle, - shouldRender = verticalPageIndex in verticalRenderWindow, - zoomPreview = verticalZoomPreview, - zoomPreviewAnchorPageRootOffset = verticalZoomPreview - ?.pageIndex - ?.let { verticalPageRootOffsets[it] }, - zoomPreviewScrollBounds = verticalZoomPreview?.let { - desktopPdfZoomScrollBoundsWithCommitTargets( - preview = it, - currentHorizontalScroll = pageHorizontalScrollState.value, - maxHorizontalScroll = pageHorizontalScrollState.maxValue - ) - }, - zoomViewportRootOffset = pdfZoomViewportRootOffset, - showPageNumberOverlay = pdfReaderSettings.pdfPageNumberOverlayVisible, - onSelectPage = { - goToPage( - target = it, - scrollVertical = false, - saveRichTextBeforePageChange = !isRichTextMode - ) - }, - onCopySelection = ::copySelection, - onHighlightSelection = ::highlightSelection, - onExternalSearchSelection = { openPdfExternalLookup(ReaderExternalLookupAction.SEARCH, it.text) }, - onHighlighterPaletteChange = ::updatePdfHighlighterPalette, - onDefineSelection = { runPdfAiAction(ReaderAiFeature.DEFINE, it.text) }, - onSpeakSelection = { togglePdfCloudTts(it.text) }, - onEmbeddedAnnotationSelected = ::selectEmbeddedAnnotation, - onAnnotationSelected = ::selectAnnotation, - onLinkActivated = ::activatePdfLink, - onAnnotationAdded = { dispatchPdf(SharedPdfReaderAction.AnnotationAdded(it)) }, - onAnnotationUpdated = ::updateAnnotation, - onAnnotationsChanged = { dispatchPdf(SharedPdfReaderAction.AnnotationsChanged(it)) }, - onTextAnnotationSelected = ::selectTextAnnotation, - onTextDraftStarted = ::startActiveTextDraft, - onTextDraftChanged = ::updateActiveTextDraft, - onTextDraftBoundsChanged = ::updateActiveTextDraftBounds, - onPan = { delta -> - pdfScope.launch { - pageHorizontalScrollState.scrollBy(-delta.x) - verticalListState.scrollBy(-delta.y) - } - }, - onPagePositioned = { page, offset -> - val previousOffset = verticalPageRootOffsets[page] - if (previousOffset != offset) { - logPdfZoomSettle { - "page_layout seq=$pdfZoomSettleSequence mode=vertical page=${page + 1} " + - "prevRoot=${previousOffset.formatLogOffset()} nextRoot=${offset.formatLogOffset()} " + - "scale=${scale.formatLogFloat()} preview=${verticalZoomPreview != null} " + - "h=${pageHorizontalScrollState.value} list=${verticalListState.firstVisibleItemIndex}:" + - verticalListState.firstVisibleItemScrollOffset - } - } - verticalPageRootOffsets[page] = offset - } - ) - } - } - SharedPdfVerticalScrollbar( - listState = verticalListState, - pageCount = document.pageCount, - currentPage = pageIndex, - isDarkMode = pdfViewportBackground.luminance() < 0.5f, - modifier = Modifier.align(Alignment.CenterEnd) - ) - DesktopPdfPageScrubOverlay( - pageIndex = pageScrubPreview, - pageCount = document.pageCount, - pageLabel = pdfPageScrubPreviewLabel - ) - } - } else { - if (isPdfTwoPageSpread) { - Box( - modifier = Modifier - .fillMaxSize() - .background(pdfViewportBackground, RoundedCornerShape(if (isFullscreen) 0.dp else 4.dp)) - .onSizeChanged { size -> pdfZoomViewportSize = size } - .onGloballyPositioned { coordinates -> - val rootOffset = coordinates.positionInRoot() - if (rootOffset != pdfZoomViewportRootOffset) { - logPdfZoomSettle { - "viewport_layout seq=$pdfZoomSettleSequence mode=spread " + - "prev=${pdfZoomViewportRootOffset.formatLogOffset()} next=${rootOffset.formatLogOffset()} " + - "scale=${scale.formatLogFloat()} preview=${pdfZoomPreview != null}" - } - } - pdfZoomViewportRootOffset = rootOffset - } - .desktopPdfZoomGestures( - currentZoom = scale, - zoomSpec = zoomSpec, - onZoomChanged = ::previewAnchoredPdfZoom - ) - .horizontalScroll(pageHorizontalScrollState) - .verticalScroll(pageVerticalScrollState) - .padding(24.dp), - contentAlignment = Alignment.TopCenter - ) { - val spreadPageGap = desktopPdfSpreadPageGapDp( - isPageGapVisible = pdfReaderSettings.pdfVerticalPageGapVisible - ) - Row( - horizontalArrangement = Arrangement.spacedBy(spreadPageGap, Alignment.CenterHorizontally), - verticalAlignment = Alignment.Top - ) { - val spreadZoomPreview = pdfZoomPreview?.takeIf { - it.displayMode == PdfDisplayMode.PAGINATION - } - val spreadPredictedPageCanvasSizes = paginatedVisiblePageIndices.mapNotNull { visiblePageIndex -> - document.pageSizes.getOrNull(visiblePageIndex)?.let { pageSize -> - val pageDisplayScale = zoomSpec.clamp(scale) - visiblePageIndex to IntSize( - width = (pageSize.width * pageDisplayScale).roundToInt().coerceAtLeast(1), - height = (pageSize.height * pageDisplayScale).roundToInt().coerceAtLeast(1) - ) - } - }.toMap() - val spreadLayoutPrediction = spreadZoomPreview?.let { - desktopPdfSpreadLayoutPrediction( - viewportRootOffset = pdfZoomViewportRootOffset, - viewportSize = pdfZoomViewportSize, - visiblePageIndices = paginatedVisiblePageIndices, - pageCanvasSizes = spreadPredictedPageCanvasSizes, - horizontalScroll = pageHorizontalScrollState.value, - verticalScroll = pageVerticalScrollState.value, - paddingPx = with(density) { 24.dp.toPx() }, - pageGapPx = with(density) { spreadPageGap.toPx() } - ) - } - val spreadZoomAnchorPageRootOffset = spreadZoomPreview - ?.pageIndex - ?.let { spreadLayoutPrediction?.pageRootOffsets?.get(it) ?: paginatedZoomPageRoot(it) } - val spreadZoomScrollBounds = spreadZoomPreview?.let { - DesktopPdfZoomScrollBounds( - currentHorizontalScroll = pageHorizontalScrollState.value, - maxHorizontalScroll = spreadLayoutPrediction?.maxHorizontalScroll - ?: pageHorizontalScrollState.maxValue, - currentVerticalScroll = pageVerticalScrollState.value, - maxVerticalScroll = spreadLayoutPrediction?.maxVerticalScroll - ?: pageVerticalScrollState.maxValue - ) - } - paginatedVisiblePageIndices.forEach { spreadPageIndex -> - DesktopVerticalPdfPage( - document = document, - pageIndex = spreadPageIndex, - 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, - highlighterPalette = pdfHighlighterColors, - strokeWidth = strokeWidth, - isHighlighterSnapEnabled = isHighlighterSnapEnabled, - activeTextDraft = activeTextDraft, - richTextController = richTextController, - isRichTextMode = isRichTextMode, - readerAiFeaturesAvailable = aiByokSettings.sanitized().areReaderAiFeaturesAvailable, - cloudTtsAvailable = cloudTtsControlsAvailable && aiByokSettings.sanitized().isCloudTtsAvailable, - externalLookupAvailable = featurePolicy.externalLookup, - themeStyle = pdfThemeStyle, - shouldRender = true, - zoomPreview = spreadZoomPreview, - zoomPreviewAnchorPageRootOffset = spreadZoomAnchorPageRootOffset, - zoomPreviewScrollBounds = spreadZoomScrollBounds, - zoomViewportRootOffset = pdfZoomViewportRootOffset, - showPageNumberOverlay = pdfReaderSettings.pdfPageNumberOverlayVisible, - onSelectPage = { - goToPage( - target = it, - saveRichTextBeforePageChange = !isRichTextMode - ) - }, - onCopySelection = ::copySelection, - onHighlightSelection = ::highlightSelection, - onExternalSearchSelection = { - openPdfExternalLookup(ReaderExternalLookupAction.SEARCH, it.text) - }, - onHighlighterPaletteChange = ::updatePdfHighlighterPalette, - onDefineSelection = { runPdfAiAction(ReaderAiFeature.DEFINE, it.text) }, - onSpeakSelection = { togglePdfCloudTts(it.text) }, - onEmbeddedAnnotationSelected = ::selectEmbeddedAnnotation, - onAnnotationSelected = ::selectAnnotation, - onLinkActivated = ::activatePdfLink, - onAnnotationAdded = { dispatchPdf(SharedPdfReaderAction.AnnotationAdded(it)) }, - onAnnotationUpdated = ::updateAnnotation, - onAnnotationsChanged = { dispatchPdf(SharedPdfReaderAction.AnnotationsChanged(it)) }, - onTextAnnotationSelected = ::selectTextAnnotation, - onTextDraftStarted = ::startActiveTextDraft, - onTextDraftChanged = ::updateActiveTextDraft, - onTextDraftBoundsChanged = ::updateActiveTextDraftBounds, - onPan = { delta -> - pdfScope.launch { - pageHorizontalScrollState.scrollBy(-delta.x) - pageVerticalScrollState.scrollBy(-delta.y) - } - }, - onPageSizeChanged = { page, size -> - paginatedPageCanvasSizes[page] = size - }, - onPagePositioned = { page, offset -> - paginatedPageRootOffsets[page] = offset - if (page == paginatedSpreadPageIndices.firstOrNull()) { - if (offset != paginatedPageRootOffset) { - logPdfZoomSettle { - "page_layout seq=$pdfZoomSettleSequence mode=spread page=${page + 1} " + - "prevRoot=${paginatedPageRootOffset.formatLogOffset()} " + - "nextRoot=${offset.formatLogOffset()} scale=${scale.formatLogFloat()} " + - "preview=${spreadZoomPreview != null} h=${pageHorizontalScrollState.value} " + - "v=${pageVerticalScrollState.value}" - } - } - paginatedPageRootOffset = offset - } - } - ) - } - } - DesktopPdfPageScrubOverlay( - pageIndex = pageScrubPreview, - pageCount = document.pageCount, - pageLabel = pdfPageScrubPreviewLabel - ) - } - } else { - Box( - modifier = Modifier - .fillMaxSize() - .background(pdfViewportBackground, RoundedCornerShape(if (isFullscreen) 0.dp else 4.dp)) - .onSizeChanged { size -> pdfZoomViewportSize = size } - .onGloballyPositioned { coordinates -> - val rootOffset = coordinates.positionInRoot() - if (rootOffset != pdfZoomViewportRootOffset) { - logPdfZoomSettle { - "viewport_layout seq=$pdfZoomSettleSequence mode=pagination " + - "prev=${pdfZoomViewportRootOffset.formatLogOffset()} next=${rootOffset.formatLogOffset()} " + - "scale=${scale.formatLogFloat()} preview=${pdfZoomPreview != null}" - } - } - pdfZoomViewportRootOffset = rootOffset - } - .desktopPdfZoomGestures( - currentZoom = scale, - zoomSpec = zoomSpec, - onZoomChanged = ::previewAnchoredPdfZoom - ) - .horizontalScroll(pageHorizontalScrollState) - .verticalScroll(pageVerticalScrollState) - .padding(24.dp), - contentAlignment = Alignment.TopCenter - ) { - val paginatedPageDisplay = renderedPageIndex - ?.takeIf { displayPageIndex -> - renderedPage != null && desktopPdfRenderBelongsToPage(displayPageIndex, pageIndex) - } - ?.let { displayPageIndex -> - renderedPage?.let { render -> - DesktopPdfPaginatedPageDisplay( - pageIndex = displayPageIndex, - render = render - ) - } - } - when { - renderError != null && paginatedPageDisplay?.pageIndex != pageIndex -> Text( - renderError ?: readerString("desktop_failed_render_page", "Failed to render page."), - color = MaterialTheme.colorScheme.error - ) - paginatedPageDisplay != null -> { - Crossfade( - targetState = paginatedPageDisplay.pageIndex, - animationSpec = tween(DesktopPdfPaginationPageTurnAnimationMillis), - label = "DesktopPdfPaginatedPage" - ) { displayPageIndex -> - val displayPageIsCurrent = displayPageIndex == currentPdfPageIndex - val pageIndex = displayPageIndex - val currentPageRender = if (displayPageIndex == paginatedPageDisplay.pageIndex) { - paginatedPageDisplay.render - } else { - paginatedRenderCache[displayPageIndex]?.render ?: paginatedPageDisplay.render - } - val pageSize = document.pageSizes.getOrNull(pageIndex) - if (pageSize == null) { - Text(readerString("desktop_failed_render_page", "Failed to render page."), color = MaterialTheme.colorScheme.error) - return@Crossfade - } - val pageDisplayScale = zoomSpec.clamp(scale) - val pageWidthDp = with(density) { (pageSize.width * pageDisplayScale).toDp() } - val pageHeightDp = with(density) { (pageSize.height * pageDisplayScale).toDp() } - val predictedPageCanvasSize = IntSize( - width = (pageSize.width * pageDisplayScale).roundToInt().coerceAtLeast(1), - height = (pageSize.height * pageDisplayScale).roundToInt().coerceAtLeast(1) - ) - val pageRenderScale = currentPageRender.width / pageSize.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() - } - } - val pageZoomPreview = pdfZoomPreview?.takeIf { - it.displayMode == PdfDisplayMode.PAGINATION && - it.pageIndex == pageIndex - } - val pageLayoutPrediction = pageZoomPreview?.let { - desktopPdfSinglePageLayoutPrediction( - viewportRootOffset = pdfZoomViewportRootOffset, - viewportSize = pdfZoomViewportSize, - pageCanvasSize = predictedPageCanvasSize, - horizontalScroll = pageHorizontalScrollState.value, - verticalScroll = pageVerticalScrollState.value, - paddingPx = with(density) { 24.dp.toPx() } - ) - } - Box( - modifier = Modifier - .size(pageWidthDp, pageHeightDp) - .onGloballyPositioned { coordinates -> - val rootOffset = coordinates.positionInRoot() - if (rootOffset != paginatedPageRootOffset) { - logPdfZoomSettle { - "page_layout seq=$pdfZoomSettleSequence mode=pagination page=${pageIndex + 1} " + - "prevRoot=${paginatedPageRootOffset.formatLogOffset()} " + - "nextRoot=${rootOffset.formatLogOffset()} scale=${scale.formatLogFloat()} " + - "preview=${pageZoomPreview != null} h=${pageHorizontalScrollState.value} " + - "v=${pageVerticalScrollState.value} canvas=${pageCanvasSize.formatLogSize()}" - } - } - paginatedPageRootOffset = rootOffset - paginatedPageRootOffsets[pageIndex] = rootOffset - } - .onSizeChanged { size -> - if (pageCanvasSize != size) { - logPdfZoomSettle { - "page_size seq=$pdfZoomSettleSequence mode=pagination page=${pageIndex + 1} " + - "prev=${pageCanvasSize.formatLogSize()} next=${size.formatLogSize()} " + - "scale=${scale.formatLogFloat()} preview=${pageZoomPreview != null} " + - "bitmap=${currentPageRender.width}x${currentPageRender.height}" - } - logPdfSelection( - "layout page=${pageIndex + 1} " + - "canvas=${size.formatLogSize()} bitmap=${currentPageRender.width}x${currentPageRender.height} " + - "requestedScale=${scale.formatLogFloat()} displayScale=${pageDisplayScale.formatLogFloat()} " + - "renderScale=${pageRenderScale.formatLogFloat()}" - ) - } - pageCanvasSize = size - paginatedPageCanvasSizes[pageIndex] = size - } - .desktopPdfZoomPreviewLayer( - preview = pageZoomPreview, - currentZoom = scale, - viewportRootOffset = pdfZoomViewportRootOffset, - pageRootOffset = paginatedPageRootOffset, - pageCanvasSize = pageCanvasSize, - commitPageRootOffset = pageLayoutPrediction?.rootOffset, - scrollBounds = pageZoomPreview?.let { - DesktopPdfZoomScrollBounds( - currentHorizontalScroll = pageHorizontalScrollState.value, - maxHorizontalScroll = pageLayoutPrediction?.maxHorizontalScroll - ?: pageHorizontalScrollState.maxValue, - currentVerticalScroll = pageVerticalScrollState.value, - maxVerticalScroll = pageLayoutPrediction?.maxVerticalScroll - ?: pageVerticalScrollState.maxValue - ) - } - ) - .background(pdfThemeStyle.pageBackgroundColor, RoundedCornerShape(2.dp)) - .pointerInput( - pageIndex, - pageCanvasSize, - isTextSelectionMode, - selectedTool, - isRichTextMode, - displayPageIsCurrent - ) { - if (!displayPageIsCurrent || 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 (isTextSelectionMode) { - logPdfChromeTap { - "page_press source=paginated_inline_page page=${pageIndex + 1} " + - "x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()} " + - "consumedBefore=${event.changes.any { it.isConsumed }} " + - "selectionActive=${currentTextSelection != null} " + - "selectionMenuOpen=${selectionMenuOffset != null} " + - "selectedTool=$selectedTool richText=$isRichTextMode" - } - } - val highlightHit = if (selectedTool != PdfInkTool.TEXT && selectedTool != PdfInkTool.ERASER) { - currentPdfAnnotations.asReversed().firstOrNull { - it.isDesktopTextSelectionHighlight && - it.pageIndex == pageIndex && - it.sharedPdfHitTest(point, pageCanvasSize) - } - } else { - null - } - if (highlightHit != null) { - logPdfChromeTap { - "page_press_consume source=paginated_inline_page page=${pageIndex + 1} " + - "reason=text_selection_highlight annotation=${highlightHit.id}" - } - selectAnnotation(highlightHit) - clearPdfInteractionState() - event.changes.forEach { it.consume() } - continue - } - if (selectedTool != PdfInkTool.TEXT) { - val linkTarget = document.linkAt(pageIndex, point, pageCanvasSize) - if (linkTarget != null) { - logPdfChromeTap { - "page_press_consume source=paginated_inline_page page=${pageIndex + 1} " + - "reason=link target=${linkTarget.formatLogTarget()}" - } - 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) { - logPdfChromeTap { - "page_press_consume source=paginated_inline_page page=${pageIndex + 1} " + - "reason=embedded_annotation annotation=${embeddedHit.id}" - } - selectEmbeddedAnnotation(embeddedHit) - clearPdfInteractionState() - event.changes.forEach { it.consume() } - } else if ( - currentTextSelection != null && - selectionMenuOffset == null - ) { - logPdfChromeTap { - "page_press_passthrough source=paginated_inline_page page=${pageIndex + 1} " + - "action=clear_selection consumed=false" - } - selectionMenuOffset = null - textSelection = null - selectionStartHit = null - selectionEndHit = null - } else if (isTextSelectionMode) { - logPdfChromeTap { - "page_press_passthrough source=paginated_inline_page page=${pageIndex + 1} " + - "action=none consumed=false" - } - } - } 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, - pageCanvasSize, - isTextSelectionMode, - isRichTextMode, - displayPageIsCurrent - ) { - if (!displayPageIsCurrent || isRichTextMode || !isTextSelectionMode) return@pointerInput - detectDesktopPdfTextSelectionLongPress( - source = "paginated_inline_page", - pageIndex = pageIndex - ) { point -> - val selection = document.wordSelectionAt(pageIndex, point, pageCanvasSize) - logPdfChromeTap { - "long_press_selection source=paginated_inline_page page=${pageIndex + 1} " + - "selectionFound=${selection != null} " + - "x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()}" - } - if (selection != null) { - selectionStartIndex = null - selectionEndIndex = null - selectionStartHit = null - selectionEndHit = null - activeSelectionHandle = null - textSelection = selection - selectionMenuOffset = selection.menuAnchor(pageCanvasSize, point) - logPdfSelection( - "long_press page=${pageIndex + 1} " + - "x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()} " + - "range=${selection.startIndex}..${selection.endIndex} " + - "chars=${selection.text.length} " + - "text=\"${selection.text.logPreview()}\"" - ) - } - } - } - .pointerInput( - pageIndex, - selectedTool, - isTextSelectionMode, - isRichTextMode, - displayPageIsCurrent - ) { - if (!displayPageIsCurrent || isRichTextMode || isTextSelectionMode || selectedTool != PdfInkTool.NONE) return@pointerInput - awaitEachGesture { - val down = awaitFirstDown(requireUnconsumed = false) - if (!currentEvent.buttons.isPrimaryPressed) return@awaitEachGesture - val pointerId = down.id - var dragStarted = false - var dragDistance = 0f - while (true) { - val event = awaitPointerEvent() - val change = event.changes.firstOrNull { it.id == pointerId } - ?: return@awaitEachGesture - if (change.changedToUp()) { - return@awaitEachGesture - } - if (!change.positionChanged()) continue - val delta = change.positionChange() - if (!dragStarted) { - dragDistance += delta.getDistance() - if (dragDistance <= viewConfiguration.touchSlop) { - continue - } - dragStarted = true - change.consume() - continue - } - pdfScope.launch { - pageHorizontalScrollState.scrollBy(-delta.x) - pageVerticalScrollState.scrollBy(-delta.y) - } - change.consume() - } - } - } - .pointerInput( - pageIndex, - isTextSelectionMode, - selectedTool, - selectedColor, - strokeWidth, - isHighlighterSnapEnabled, - textStyleConfig, - activeTextDraft?.id, - isRichTextMode, - displayPageIsCurrent, - pageCanvasSize, currentPageRender.width, - currentPageRender.height - ) { - if (!displayPageIsCurrent || isRichTextMode) return@pointerInput - if (isTextSelectionMode) { - var latestSelectionDragPoint: Offset? = null - var lastSelectionPreviewAt = 0L - detectDragGestures( - onDragStart = { start -> - latestSelectionDragPoint = start - lastSelectionPreviewAt = 0L - selectionMenuOffset = null - val existingSelection = textSelection - val handle = existingSelection?.handleAt(start, pageCanvasSize) - activeSelectionHandle = handle - val hit = document.charHitAt(pageIndex, start, pageCanvasSize) - if (handle != null && existingSelection != null) { - selectionStartHit = null - selectionStartIndex = when (handle) { - DesktopPdfSelectionHandle.START -> existingSelection.endIndex - DesktopPdfSelectionHandle.END -> existingSelection.startIndex - } - selectionEndHit = hit - selectionEndIndex = hit?.index ?: when (handle) { - DesktopPdfSelectionHandle.START -> existingSelection.startIndex - DesktopPdfSelectionHandle.END -> existingSelection.endIndex - } - } else { - selectionStartHit = hit - selectionStartIndex = hit?.index - selectionEndHit = null - selectionEndIndex = null - textSelection = null - } - logPdfSelection( - "drag_start page=${pageIndex + 1} " + - "canvas=${pageCanvasSize.formatLogSize()} bitmap=${currentPageRender.width}x${currentPageRender.height} " + - "requestedScale=${scale.formatLogFloat()} renderScale=${pageRenderScale.formatLogFloat()} " + - "handle=${handle?.name ?: "none"} " + - hit.formatLogHit("start") - ) - }, - onDrag = { change, _ -> - latestSelectionDragPoint = change.position - val now = System.currentTimeMillis() - if (lastSelectionPreviewAt == 0L || - now - lastSelectionPreviewAt >= DesktopPdfSelectionPreviewThrottleMillis - ) { - lastSelectionPreviewAt = now - 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.selectionPreviewBetweenIndexes( - pageIndex = pageIndex, - startIndex = startIndex, - endIndex = endIndex, - canvasSize = pageCanvasSize - ) - } else { - null - } - } - } - change.consume() - }, - onDragEnd = { - val finalHit = latestSelectionDragPoint - ?.let { document.charHitAt(pageIndex, it, pageCanvasSize) } - ?: selectionEndHit - if (finalHit != null) { - selectionEndHit = finalHit - selectionEndIndex = finalHit.index - } - 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 - ) - } else { - textSelection?.takeIf { it.text.isNotBlank() } - } - textSelection = selection - selectionMenuOffset = selection?.menuAnchor( - pageCanvasSize, - finalHit?.point ?: selectionEndHit?.point ?: selectionStartHit?.point - ) - logPdfSelection( - "drag_end page=${pageIndex + 1} " + - "canvas=${pageCanvasSize.formatLogSize()} bitmap=${currentPageRender.width}x${currentPageRender.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 - activeSelectionHandle = null - latestSelectionDragPoint = null - lastSelectionPreviewAt = 0L - }, - onDragCancel = { - logPdfSelection( - "drag_cancel page=${pageIndex + 1} " + - "canvas=${pageCanvasSize.formatLogSize()} bitmap=${currentPageRender.width}x${currentPageRender.height} " + - "requestedScale=${scale.formatLogFloat()} renderScale=${pageRenderScale.formatLogFloat()} " + - selectionStartHit.formatLogHit("start") + " " + - selectionEndHit.formatLogHit("end") - ) - selectionStartIndex = null - selectionEndIndex = null - selectionStartHit = null - selectionEndHit = null - activeSelectionHandle = null - latestSelectionDragPoint = null - lastSelectionPreviewAt = 0L - } - ) - } else if (selectedTool == PdfInkTool.TEXT) { - detectTapGestures( - onTap = { start -> - when { - activeTextDraftContains(pageIndex, start, pageCanvasSize) -> Unit - else -> { - val textHit = currentPdfAnnotations.textAnnotationHitAt( - pageIndex = pageIndex, - point = start, - canvasSize = pageCanvasSize - ) - if (textHit != null) { - selectTextAnnotation(textHit) - } else { - startActiveTextDraft(pageIndex, start, pageCanvasSize) - } - } - } - } - ) - } else if (selectedTool != PdfInkTool.NONE) { - var eraserPreviousPoint: Offset? = null - awaitEachGesture { - val down = awaitFirstDown(requireUnconsumed = false) - if (!currentEvent.buttons.isPrimaryPressed) return@awaitEachGesture - val start = down.position - if (selectedTool == PdfInkTool.ERASER) { - eraserPosition = start - 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())) - } - - val pointerId = down.id - var dragStarted = false - while (true) { - val event = awaitPointerEvent() - if (event.changes.size > 1) { - eraserPreviousPoint = null - eraserPosition = null - activeStroke = emptyList() - return@awaitEachGesture - } - val change = event.changes.firstOrNull { it.id == pointerId } - ?: run { - eraserPreviousPoint = null - eraserPosition = null - activeStroke = emptyList() - return@awaitEachGesture - } - if (change.changedToUp()) { - change.consume() - if (selectedTool != PdfInkTool.ERASER && activeStroke.isNotEmpty()) { - dispatchPdf( - SharedPdfReaderAction.AnnotationAdded( - SharedPdfAnnotation( - id = "ink_${System.currentTimeMillis()}", - pageIndex = pageIndex, - kind = PdfAnnotationKind.INK, - tool = selectedTool, - points = activeStroke, - colorArgb = selectedColor, - strokeWidth = strokeWidth, - createdAt = System.currentTimeMillis() - ) - ) - ) - } - eraserPreviousPoint = null - eraserPosition = null - activeStroke = emptyList() - return@awaitEachGesture - } - if (!change.positionChanged()) continue - val distance = (change.position - start).getDistance() - if (selectedTool != PdfInkTool.ERASER && !dragStarted && distance <= viewConfiguration.touchSlop) continue - dragStarted = true - if (selectedTool == PdfInkTool.ERASER) { - val point = change.position - eraserPosition = point - 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.withDesktopPdfDragPoint( - point = change.position, - canvasSize = pageCanvasSize, - tool = selectedTool, - snapHighlighter = isHighlighterSnapEnabled, - timestamp = System.currentTimeMillis() - ) - } - change.consume() - } - } - } - } - ) { - DesktopPdfThemedPageImage( - bitmap = currentPageRender.image, - contentDescription = readerString("desktop_pdf_page_content_desc", "PDF page %1\$d", pageIndex + 1), - themeStyle = pdfThemeStyle, - modifier = Modifier.fillMaxSize() - ) - 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, - eraserPosition = eraserPosition, - showEraserIndicator = selectedTool == PdfInkTool.ERASER, - eraserStrokeWidth = strokeWidth - ) - PdfTextSelectionHandles( - selection = textSelection, - canvasSize = pageCanvasSize, - activeHandle = activeSelectionHandle - ) - 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 - ) - if (pdfReaderSettings.pdfPageNumberOverlayVisible) { - SharedPdfPageNumberOverlay( - pageIndex = pageIndex, - pageCount = document.pageCount - ) - } - if (textSelection != null && selectionMenuOffset != null) { - Box( - modifier = Modifier - .matchParentSize() - .pointerInput(pageIndex, selectionMenuOffset) { - detectTapGestures { - logPdfChromeTap { - "selection_menu_scrim_tap source=paginated_inline_page page=${pageIndex + 1} " + - "consumedByScrim=true" - } - selectionMenuOffset = null - textSelection = null - selectionStartHit = null - selectionEndHit = null - } - } - ) - } - PdfSelectionMenu( - selection = textSelection, - menuOffset = selectionMenuOffset, - canvasSize = pageCanvasSize, - highlighterPalette = pdfHighlighterColors, - onHighlighterPaletteChange = ::updatePdfHighlighterPalette, - onCopy = { - textSelection?.let(::copySelection) - clearSelection() - }, - onHighlight = { colorArgb -> - textSelection?.let { selection -> - highlightSelection(pageIndex, selection, pageCanvasSize, colorArgb) - } - clearSelection() - }, - onSearch = { - textSelection?.let { openPdfExternalLookup(ReaderExternalLookupAction.SEARCH, it.text) } - clearSelection() - }, - onDefine = { - textSelection?.let { runPdfAiAction(ReaderAiFeature.DEFINE, it.text) } - clearSelection() - }, - onSpeak = { - textSelection?.let { togglePdfCloudTts(it.text) } - clearSelection() - }, - showDefine = aiByokSettings.sanitized().areReaderAiFeaturesAvailable, - showSpeak = cloudTtsControlsAvailable && aiByokSettings.sanitized().isCloudTtsAvailable, - showSearch = featurePolicy.externalLookup, - onClear = ::clearSelection - ) - } - } - } - isRendering -> CircularProgressIndicator(modifier = Modifier.padding(48.dp)) - renderError != null -> Text( - renderError ?: readerString("desktop_failed_render_page", "Failed to render page."), - color = MaterialTheme.colorScheme.error - ) - } - DesktopPdfPageScrubOverlay( - pageIndex = pageScrubPreview, - pageCount = document.pageCount, - pageLabel = pdfPageScrubPreviewLabel - ) - } - } - } - AnimatedVisibility( - visible = showPdfZoomIndicator, - modifier = Modifier - .align(Alignment.TopEnd) - .padding(top = 16.dp, end = 16.dp), - enter = fadeIn(), - exit = fadeOut() - ) { - DesktopPdfZoomPercentageIndicator( - percentage = (zoomControlScale * 100).roundToInt(), - onResetZoomClick = { - cancelPendingPdfZoomPreview() - dispatchPdf(SharedPdfReaderAction.ZoomChanged(1f)) - } - ) - } - when { - showPdfAiHub -> { - DesktopAiHubSheet( - bookKey = pdfHubBookKey(), - bookTitle = pdfHubBookTitle(), - itemIndex = pageIndex, - itemTitle = readerString("pdf_page_short", "Page %1\$d", pageIndex + 1), - summaryCacheStore = summaryCacheStore, - summaryResult = pdfHubSummaryResult, - isSummaryLoading = isPdfHubSummaryLoading, - recapResult = null, - isRecapLoading = false, - recapProgressMessage = null, - onGenerateSummary = ::generatePdfHubSummary, - onClearSummary = ::clearPdfHubSummary, - onGenerateRecap = null, - onClearRecap = {}, - onDismiss = { showPdfAiHub = false }, - credits = credits, - showCredits = showPaidCredits - ) - } - selectedTextHighlight != null -> { - DesktopReaderBottomSheet( - title = selectedTextHighlight.desktopSheetTitle(), - onDismiss = ::dismissSelectedTextHighlightSheet - ) { - DesktopPdfAnnotationEditor( - annotation = selectedTextHighlight, - onUpdate = ::updateAnnotation, - onDelete = { deleteSelectedTextHighlight(selectedTextHighlight) }, - onClose = ::dismissSelectedTextHighlightSheet, - onCopy = { - clipboardManager.setText(AnnotatedString(selectedTextHighlight.text)) - dismissSelectedTextHighlightSheet() - }, - showSearch = featurePolicy.externalLookup, - highlighterPalette = pdfHighlighterColors, - onHighlighterPaletteChange = ::updatePdfHighlighterPalette, - onSearch = { - openPdfExternalLookup(ReaderExternalLookupAction.SEARCH, selectedTextHighlight.text) - dismissSelectedTextHighlightSheet() - } - ) - } - } - selectedEmbeddedAnnotation != null -> { - DesktopReaderBottomSheet( - title = readerString("desktop_pdf_comment", "PDF comment"), - onDismiss = { selectedEmbeddedAnnotationId = null } - ) { - DesktopPdfEmbeddedAnnotationPanel( - annotation = selectedEmbeddedAnnotation, - onCopy = { clipboardManager.setText(AnnotatedString(selectedEmbeddedAnnotation.threadText())) }, - onClose = { selectedEmbeddedAnnotationId = null } - ) - } - } - pdfExtrasState.aiResult.hasContent -> { - DesktopReaderAiResultSheet( - result = pdfExtrasState.aiResult, - onDismiss = { - dismissedPdfAiResultRequestId = pdfAiResultRequestId - pdfExtrasState = pdfExtrasState.copy(aiResult = ReaderAiResultState()) - } - ) - } - } - if (isPdfFileActionLoading) { - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.12f)) - .zIndex(20_000f), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator() - } - } - } - - if (showPdfSaveDialog) { - DesktopPdfExportChoiceDialog( - title = readerString("title_save_to_device", "Save to device"), - message = readerString("desktop_choose_pdf_to_save", "Choose which PDF to save."), - onOriginal = { - showPdfSaveDialog = false - savePdfCopy(SaveMode.ORIGINAL) - }, - onAnnotated = { - showPdfSaveDialog = false - savePdfCopy(SaveMode.ANNOTATED) - }, - onDismiss = { showPdfSaveDialog = false } - ) - } - - pdfFileActionNotice?.let { notice -> - AlertDialog( - onDismissRequest = { pdfFileActionNotice = null }, - title = { Text(notice.title) }, - text = { - Text( - text = notice.message, - color = if (notice.isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface - ) - }, - confirmButton = { - TextButton(onClick = { pdfFileActionNotice = null }) { - Text(readerString("action_ok", "OK")) - } - } - ) - } -} - -@Composable -private fun desktopPdfPageLabel( - pageIndex: Int, - pageCount: Int, - displayMode: PdfDisplayMode, - settings: ReaderSettings -): String { - val pageRange = if (displayMode == PdfDisplayMode.PAGINATION) { - PdfSpreadLayout.pageRangeLabel(pageIndex, pageCount, settings) - } else { - "${pageIndex.coerceIn(0, (pageCount - 1).coerceAtLeast(0)) + 1}" - } - return if ('-' in pageRange) { - readerString("desktop_pdf_pages_of_count", "Pages %1\$s of %2\$d", pageRange, pageCount) - } else { - readerString("desktop_pdf_page_of_count", "Page %1\$s of %2\$d", pageRange, pageCount) - } -} - -@Composable -private fun DesktopPdfExportChoiceDialog( - title: String, - message: String, - onOriginal: () -> Unit, - onAnnotated: () -> Unit, - onDismiss: () -> Unit -) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(title) }, - text = { Text(message) }, - confirmButton = { - TextButton(onClick = onAnnotated) { - Text(readerString("action_with_annotations", "With annotations")) - } - }, - dismissButton = { - TextButton(onClick = onOriginal) { - Text(readerString("action_original", "Original")) - } - TextButton(onClick = onDismiss) { - Text(readerString("action_cancel", "Cancel")) - } - } - ) -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfReflow.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfReflow.kt deleted file mode 100644 index 2e57abc..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfReflow.kt +++ /dev/null @@ -1,95 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import org.dueattendant149.bookreader.shared.BookItem -import org.dueattendant149.bookreader.shared.FileType -import org.dueattendant149.bookreader.shared.pdf.SharedPdfReflowHtml -import java.io.File - -private const val DesktopPdfReflowSuffix = "_reflow" - -internal object DesktopPdfReflowGenerator { - fun generateHtmlFile( - document: DesktopPdfDocument, - destFile: File, - startPage: Int = 1, - onProgress: (Float) -> Unit - ): Boolean { - require(document.formatLabel == "PDF") { "Only PDF documents can be converted to text view." } - if (document.pageCount <= 0) return false - - val firstPageIndex = (startPage - 1).coerceIn(0, document.pageCount - 1) - val totalPagesToGenerate = (document.pageCount - firstPageIndex).coerceAtLeast(1) - val headerFooterStrings = detectRepeatingHeaderFooter(document) - destFile.parentFile?.mkdirs() - - return runCatching { - destFile.bufferedWriter(Charsets.UTF_8).use { writer -> - writer.write(SharedPdfReflowHtml.buildGlobalHtmlHeader()) - for (pageIndex in firstPageIndex until document.pageCount) { - if (pageIndex > firstPageIndex) { - writer.write("\n\n") - } - val page = DesktopPdfium.loadReflowPage(document, pageIndex) - writer.write(SharedPdfReflowHtml.buildPageHtml(page, headerFooterStrings)) - if (pageIndex % 5 == 0 || pageIndex == document.pageCount - 1) { - val completedPages = pageIndex - firstPageIndex + 1 - onProgress(completedPages.toFloat() / totalPagesToGenerate.toFloat()) - } - } - writer.write(SharedPdfReflowHtml.buildGlobalHtmlFooter()) - } - true - }.getOrDefault(false) - } - - private fun detectRepeatingHeaderFooter(document: DesktopPdfDocument): Set { - if (document.pageCount < 5) return emptySet() - val step = maxOf(1, document.pageCount / 8) - val samplePageLines = (0 until document.pageCount) - .filter { it % step == 0 } - .take(8) - .map { pageIndex -> DesktopPdfium.loadReflowEdgeLines(document, pageIndex) } - return SharedPdfReflowHtml.detectRepeatingHeaderFooter(samplePageLines) - } -} - -internal fun desktopPdfReflowBookId(pdfBookId: String): String = "${pdfBookId}$DesktopPdfReflowSuffix" - -internal fun isDesktopPdfReflowBookId(bookId: String): Boolean = bookId.endsWith(DesktopPdfReflowSuffix) - -internal fun desktopPdfReflowDisplayName(originalTitle: String): String = "$originalTitle (Text View)" - -internal fun desktopPdfReflowTitle(originalTitle: String): String = "$originalTitle (Reflow)" - -internal fun desktopPdfReflowGeneratedAuthor(): String = "Generated" - -internal fun desktopPdfReflowFileName(pdfBookId: String, originalTitle: String): String { - val stem = (pdfBookId.ifBlank { originalTitle }) - .toDesktopSafeFileName() - return "${stem}$DesktopPdfReflowSuffix.html" -} - -internal fun desktopPdfReflowBookItem( - sourceBook: BookItem, - generatedFile: File, - nowMillis: Long, - initialPageIndex: Int? = null -): BookItem { - val originalTitle = sourceBook.title?.takeIf { it.isNotBlank() } - ?: sourceBook.displayName.substringBeforeLast('.', sourceBook.displayName) - .takeIf { it.isNotBlank() } - ?: "Document" - return BookItem( - id = desktopPdfReflowBookId(sourceBook.id), - path = generatedFile.absolutePath, - type = FileType.HTML, - displayName = desktopPdfReflowDisplayName(originalTitle), - timestamp = nowMillis, - title = desktopPdfReflowTitle(originalTitle), - author = desktopPdfReflowGeneratedAuthor(), - isRecent = true, - fileSize = generatedFile.length(), - fileContentModifiedTimestamp = generatedFile.lastModified(), - lastPageIndex = initialPageIndex - ) -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfScrubbing.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfScrubbing.kt deleted file mode 100644 index f77e5fa..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfScrubbing.kt +++ /dev/null @@ -1,28 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import org.dueattendant149.bookreader.shared.PdfDisplayMode -import org.dueattendant149.bookreader.shared.pdf.PdfSpreadLayout -import org.dueattendant149.bookreader.shared.reader.ReaderSettings -import kotlin.math.roundToInt - -internal fun desktopPdfPageScrubTarget( - value: Float, - pageCount: Int, - displayMode: PdfDisplayMode, - settings: ReaderSettings -): Int { - val clampedPage = value.roundToInt().coerceIn(0, (pageCount - 1).coerceAtLeast(0)) - return if (displayMode == PdfDisplayMode.PAGINATION) { - PdfSpreadLayout.normalizePageIndex(clampedPage, pageCount, settings) - } else { - clampedPage - } -} - -internal fun desktopPdfPageScrubCommitTarget( - previewPage: Int?, - currentPage: Int, - pageCount: Int -): Int { - return (previewPage ?: currentPage).coerceIn(0, (pageCount - 1).coerceAtLeast(0)) -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfSelectionUi.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfSelectionUi.kt deleted file mode 100644 index f1faa3f..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfSelectionUi.kt +++ /dev/null @@ -1,543 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -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.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -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.layout.widthIn -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.VolumeUp -import androidx.compose.material.icons.filled.Close -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -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.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.graphics.TransformOrigin -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.luminance -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.graphics.vector.PathParser -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.font.FontWeight -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 org.dueattendant149.bookreader.shared.pdf.PdfPageBounds -import org.dueattendant149.bookreader.shared.pdf.SharedPdfHighlighterPalette -import org.dueattendant149.bookreader.shared.ui.SharedHsvColorPickerDialog -import org.dueattendant149.bookreader.shared.ui.SharedSelectionMenuRect -import org.dueattendant149.bookreader.shared.ui.SharedSelectionMenuSize -import org.dueattendant149.bookreader.shared.ui.SharedSelectionMenuViewport -import org.dueattendant149.bookreader.shared.ui.readerString -import org.dueattendant149.bookreader.shared.ui.sharedSelectionMenuPlacement -import kotlin.math.roundToInt - -internal data class DesktopPdfTextSelection( - val text: String, - val lineBounds: List, - val startIndex: Int, - val endIndex: Int -) - -private data class DesktopPdfSelectionCanvasBounds( - val left: Float, - val top: Float, - val right: Float, - val bottom: Float -) { - val centerX: Float get() = (left + right) / 2f -} - -private fun DesktopPdfTextSelection.canvasBounds(canvasSize: IntSize): DesktopPdfSelectionCanvasBounds? { - val validBounds = lineBounds.filter { it.right > it.left && it.bottom > it.top } - if (validBounds.isEmpty() || canvasSize.width <= 0 || canvasSize.height <= 0) return null - return DesktopPdfSelectionCanvasBounds( - left = validBounds.minOf { it.left } * canvasSize.width, - top = validBounds.minOf { it.top } * canvasSize.height, - right = validBounds.maxOf { it.right } * canvasSize.width, - bottom = validBounds.maxOf { it.bottom } * canvasSize.height - ) -} - -internal fun DesktopPdfTextSelection.menuAnchor( - canvasSize: IntSize, - fallback: Offset? -): Offset { - val bounds = canvasBounds(canvasSize) ?: return fallback ?: Offset.Zero - return Offset(x = bounds.centerX, y = bounds.top) -} - -private fun DesktopPdfTextSelection.startHandleOffset(canvasSize: IntSize): Offset? { - if (canvasSize.width <= 0 || canvasSize.height <= 0) return null - val first = lineBounds.firstOrNull { it.right > it.left && it.bottom > it.top } ?: return null - return Offset( - x = first.left * canvasSize.width, - y = first.bottom * canvasSize.height - ) -} - -private fun DesktopPdfTextSelection.endHandleOffset(canvasSize: IntSize): Offset? { - if (canvasSize.width <= 0 || canvasSize.height <= 0) return null - val last = lineBounds.lastOrNull { it.right > it.left && it.bottom > it.top } ?: return null - return Offset( - x = last.right * canvasSize.width, - y = last.bottom * canvasSize.height - ) -} - -internal fun DesktopPdfTextSelection.handleAt( - point: Offset, - canvasSize: IntSize -): DesktopPdfSelectionHandle? { - val start = startHandleOffset(canvasSize) - val end = endHandleOffset(canvasSize) - - fun Offset.containsHandlePoint(): Boolean { - val halfWidth = DesktopPdfSelectionHandleTouchWidthPx / 2f - return point.x in (x - halfWidth)..(x + halfWidth) && - point.y in (y - DesktopPdfSelectionHandleTouchTopPx)..(y + DesktopPdfSelectionHandleTouchBottomPx) - } - - return when { - start != null && start.containsHandlePoint() -> DesktopPdfSelectionHandle.START - end != null && end.containsHandlePoint() -> DesktopPdfSelectionHandle.END - else -> null - } -} - -@Composable -internal 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 -internal 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 -internal fun PdfTextSelectionHandles( - selection: DesktopPdfTextSelection?, - canvasSize: IntSize, - activeHandle: DesktopPdfSelectionHandle? -) { - selection ?: return - if (canvasSize.width <= 0 || canvasSize.height <= 0) return - val density = LocalDensity.current - val handleSize = 24.dp - val handleWidthPx = with(density) { handleSize.toPx() } - val start = selection.startHandleOffset(canvasSize) - val end = selection.endHandleOffset(canvasSize) - val handleColor = MaterialTheme.colorScheme.primary - - fun Modifier.handleOffset(position: Offset): Modifier = offset { - IntOffset( - x = (position.x - handleWidthPx / 2f).roundToInt(), - y = position.y.roundToInt() - ) - } - - Box(Modifier.fillMaxSize()) { - start?.let { position -> - Icon( - imageVector = DesktopPdfSelectionMenuIcons.Teardrop, - contentDescription = readerString("desktop_selection_start_handle", "Selection start handle"), - tint = handleColor.copy(alpha = if (activeHandle == DesktopPdfSelectionHandle.END) 0.72f else 1f), - modifier = Modifier - .handleOffset(position) - .size(handleSize) - .graphicsLayer { - rotationZ = 30f - transformOrigin = TransformOrigin(0.5f, 0f) - } - ) - } - end?.let { position -> - Icon( - imageVector = DesktopPdfSelectionMenuIcons.Teardrop, - contentDescription = readerString("desktop_selection_end_handle", "Selection end handle"), - tint = handleColor.copy(alpha = if (activeHandle == DesktopPdfSelectionHandle.START) 0.72f else 1f), - modifier = Modifier - .handleOffset(position) - .size(handleSize) - .graphicsLayer { - rotationZ = -30f - transformOrigin = TransformOrigin(0.5f, 0f) - } - ) - } - } -} - -private object DesktopPdfSelectionMenuIcons { - val Copy = vector( - name = "DesktopPdfSelectionCopy", - pathData = "M360,720Q327,720 303.5,696.5Q280,673 280,640L280,160Q280,127 303.5,103.5Q327,80 360,80L720,80Q753,80 776.5,103.5Q800,127 800,160L800,640Q800,673 776.5,696.5Q753,720 720,720L360,720ZM360,640L720,640Q720,640 720,640Q720,640 720,640L720,160Q720,160 720,160Q720,160 720,160L360,160Q360,160 360,160Q360,160 360,160L360,640Q360,640 360,640Q360,640 360,640ZM200,880Q167,880 143.5,856.5Q120,833 120,800L120,240L200,240L200,800Q200,800 200,800Q200,800 200,800L640,800L640,880L200,880ZM360,640Q360,640 360,640Q360,640 360,640L360,160Q360,160 360,160Q360,160 360,160L360,160Q360,160 360,160Q360,160 360,160L360,640Q360,640 360,640Q360,640 360,640Z" - ) - val Dictionary = vector( - name = "DesktopPdfSelectionDictionary", - pathData = "M160,569L205,569L228,503L332,503L356,569L400,569L303,311L257,311L160,569ZM241,466L279,359L281,359L319,466L241,466ZM560,396L560,328Q593,314 627.5,307Q662,300 700,300Q726,300 751,304Q776,308 800,314L800,378Q776,369 751.5,364.5Q727,360 700,360Q662,360 627,369.5Q592,379 560,396ZM560,616L560,548Q593,534 627.5,527Q662,520 700,520Q726,520 751,524Q776,528 800,534L800,598Q776,589 751.5,584.5Q727,580 700,580Q662,580 627,589Q592,598 560,616ZM560,506L560,438Q593,424 627.5,417Q662,410 700,410Q726,410 751,414Q776,418 800,424L800,488Q776,479 751.5,474.5Q727,470 700,470Q662,470 627,479.5Q592,489 560,506ZM260,640Q307,640 351.5,650.5Q396,661 440,682L440,288Q399,264 353,252Q307,240 260,240Q224,240 188.5,247Q153,254 120,268Q120,268 120,268Q120,268 120,268L120,664Q120,664 120,664Q120,664 120,664Q155,652 189.5,646Q224,640 260,640ZM520,682Q564,661 608.5,650.5Q653,640 700,640Q736,640 770.5,646Q805,652 840,664Q840,664 840,664Q840,664 840,664L840,268Q840,268 840,268Q840,268 840,268Q807,254 771.5,247Q736,240 700,240Q653,240 607,252Q561,264 520,288L520,682ZM480,800Q432,762 376,741Q320,720 260,720Q218,720 177.5,731Q137,742 100,762Q79,773 59.5,761Q40,749 40,726L40,244Q40,233 45.5,223Q51,213 62,208Q108,184 158,172Q208,160 260,160Q318,160 373.5,175Q429,190 480,220Q531,190 586.5,175Q642,160 700,160Q752,160 802,172Q852,184 898,208Q909,213 914.5,223Q920,233 920,244L920,726Q920,749 900.5,761Q881,773 860,762Q823,742 782.5,731Q742,720 700,720Q640,720 584,741Q528,762 480,800ZM280,461Q280,461 280,461Q280,461 280,461Q280,461 280,461Q280,461 280,461L280,461Q280,461 280,461Q280,461 280,461Q280,461 280,461Q280,461 280,461Q280,461 280,461Q280,461 280,461L280,461Q280,461 280,461Q280,461 280,461Z" - ) - val Search = vector( - name = "DesktopPdfSelectionSearch", - pathData = "M784,840L532,588Q502,612 463,626Q424,640 380,640Q271,640 195.5,564.5Q120,489 120,380Q120,271 195.5,195.5Q271,120 380,120Q489,120 564.5,195.5Q640,271 640,380Q640,424 626,463Q612,502 588,532L840,784L784,840ZM380,560Q455,560 507.5,507.5Q560,455 560,380Q560,305 507.5,252.5Q455,200 380,200Q305,200 252.5,252.5Q200,305 200,380Q200,455 252.5,507.5Q305,560 380,560Z" - ) - val Teardrop = vector( - name = "DesktopPdfSelectionTeardrop", - pathData = "M480,860Q347,860 253.5,768Q160,676 160,544Q160,481 184.5,423.5Q209,366 254,322L480,100L706,322Q751,366 775.5,423.5Q800,481 800,544Q800,676 706.5,768Q613,860 480,860Z" - ) - - private fun vector(name: String, pathData: String): ImageVector { - return ImageVector.Builder( - name = name, - defaultWidth = 24.dp, - defaultHeight = 24.dp, - viewportWidth = 960f, - viewportHeight = 960f - ).apply { - addPath( - pathData = PathParser().parsePathString(pathData).toNodes(), - fill = SolidColor(Color.Black) - ) - }.build() - } -} - -internal enum class DesktopPdfSelectionHandle { - START, - END -} - -@Composable -internal fun PdfSelectionMenu( - selection: DesktopPdfTextSelection?, - menuOffset: Offset?, - canvasSize: IntSize, - highlighterPalette: List = SharedPdfHighlighterPalette.defaultColors, - onHighlighterPaletteChange: (SharedPdfHighlighterPalette) -> Unit, - onCopy: () -> Unit, - onHighlight: (Int) -> Unit, - onSearch: () -> Unit, - onDefine: () -> Unit, - onSpeak: () -> Unit, - showDefine: Boolean, - showSpeak: Boolean, - showSearch: Boolean, - onClear: () -> Unit -) { - selection ?: return - val anchor = menuOffset ?: return - val selectionBounds = selection.canvasBounds(canvasSize) - val paletteColors = remember(highlighterPalette) { - SharedPdfHighlighterPalette(highlighterPalette).sanitized().colors - } - val density = LocalDensity.current - var editingHighlighterSlot by remember(selection.startIndex, selection.endIndex, paletteColors) { - mutableStateOf(null) - } - var editingHighlighterDraftColors by remember(selection.startIndex, selection.endIndex, paletteColors) { - mutableStateOf>(emptyList()) - } - val actions = buildList { - add(PdfSelectionMenuAction(readerString("action_copy", "Copy"), DesktopPdfSelectionMenuIcons.Copy, onCopy)) - if (showDefine) add(PdfSelectionMenuAction(readerString("action_define", "Define"), DesktopPdfSelectionMenuIcons.Dictionary, onDefine)) - if (showSpeak) add(PdfSelectionMenuAction(readerString("label_speak", "Speak"), Icons.AutoMirrored.Filled.VolumeUp, onSpeak)) - if (showSearch) add(PdfSelectionMenuAction(readerString("action_search", "Search"), DesktopPdfSelectionMenuIcons.Search, onSearch)) - add(PdfSelectionMenuAction(readerString("action_clear", "Clear"), Icons.Default.Close, onClear, isDestructive = true)) - } - - fun highlighterDraftColors(): List { - return editingHighlighterDraftColors.ifEmpty { paletteColors } - } - - fun updateHighlighterDraft(slotIndex: Int, color: Color): List { - val nextColors = highlighterDraftColors().toMutableList() - if (slotIndex in nextColors.indices) { - nextColors[slotIndex] = color.copy(alpha = SharedPdfHighlighterPalette.DefaultAlpha / 255f).toArgb() - editingHighlighterDraftColors = nextColors - } - return nextColors - } - - fun openHighlighterEditor(slotIndex: Int) { - if (editingHighlighterSlot == null) { - editingHighlighterDraftColors = paletteColors - } - editingHighlighterSlot = slotIndex - } - - val actionRowCount = ((actions.size + 2) / 3).coerceAtLeast(1) - val popupWidthPx = with(density) { PdfSelectionMenuWidth.toPx() } - val estimatedHeightPx = with(density) { - PdfSelectionMenuPaletteHeight.toPx() + - (actionRowCount * PdfSelectionMenuActionRowHeight.toPx()) - } - val placement = sharedSelectionMenuPlacement( - viewport = SharedSelectionMenuViewport(canvasSize.width, canvasSize.height), - popup = SharedSelectionMenuSize( - width = popupWidthPx.roundToInt(), - height = estimatedHeightPx.roundToInt() - ), - selection = if (selectionBounds != null) { - SharedSelectionMenuRect( - left = selectionBounds.left, - top = selectionBounds.top, - right = selectionBounds.right, - bottom = selectionBounds.bottom - ) - } else { - SharedSelectionMenuRect( - left = anchor.x, - top = anchor.y, - right = anchor.x, - bottom = anchor.y - ) - }, - marginPx = with(density) { PdfSelectionMenuMargin.toPx() }, - gapPx = with(density) { PdfSelectionMenuAnchorGap.toPx() } - ) - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopStart) { - Surface( - color = MaterialTheme.colorScheme.surface, - tonalElevation = 4.dp, - shadowElevation = 10.dp, - shape = RoundedCornerShape(12.dp), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), - modifier = Modifier.offset { - IntOffset(placement.x, placement.y) - } - ) { - Column( - modifier = Modifier - .widthIn(min = 180.dp, max = 220.dp) - .padding(bottom = 6.dp) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - .padding(horizontal = 10.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - paletteColors.forEach { colorArgb -> - Surface( - modifier = Modifier - .padding(horizontal = 4.dp) - .size(28.dp) - .clickable { onHighlight(colorArgb) }, - color = Color(colorArgb), - shape = RoundedCornerShape(16.dp), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.28f)), - content = {} - ) - } - Box( - modifier = Modifier - .padding(horizontal = 4.dp) - .size(28.dp) - .clip(RoundedCornerShape(16.dp)) - .background( - Brush.sweepGradient( - listOf( - Color.Red, - Color.Yellow, - Color.Green, - Color.Cyan, - Color.Blue, - Color.Magenta, - Color.Red - ) - ) - ) - .border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.32f), RoundedCornerShape(16.dp)) - .clickable { openHighlighterEditor(0) } - ) - } - HorizontalDivider() - actions.chunked(3).forEach { rowActions -> - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 6.dp, vertical = 3.dp), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically - ) { - rowActions.forEach { action -> - val tint = if (action.isDestructive) { - MaterialTheme.colorScheme.error - } else { - MaterialTheme.colorScheme.onSurface - } - Column( - modifier = Modifier - .width(58.dp) - .clip(RoundedCornerShape(8.dp)) - .clickable { action.onClick() } - .padding(vertical = 6.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Icon( - imageVector = action.icon, - contentDescription = action.label, - tint = tint, - modifier = Modifier.size(22.dp) - ) - Text( - action.label, - style = MaterialTheme.typography.labelSmall, - color = tint, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } - repeat(3 - rowActions.size) { - Spacer(modifier = Modifier.width(58.dp)) - } - } - } - } - } - } - editingHighlighterSlot?.let { requestedSlot -> - val draftColors = highlighterDraftColors() - val safeDraftColors = draftColors.ifEmpty { SharedPdfHighlighterPalette.defaultColors } - val slot = requestedSlot.coerceIn(0, safeDraftColors.lastIndex) - val initialColor = remember(slot) { Color(safeDraftColors[slot]).copy(alpha = 1f) } - SharedHsvColorPickerDialog( - initialColor = initialColor, - title = readerString("desktop_highlight_color_format", "Highlight color %1\$d", slot + 1), - onDismiss = { editingHighlighterSlot = null }, - onSave = { color -> - val nextColors = updateHighlighterDraft(slot, color) - onHighlighterPaletteChange( - SharedPdfHighlighterPalette(nextColors).sanitized() - ) - editingHighlighterSlot = null - }, - resetColor = Color(SharedPdfHighlighterPalette.defaultColors.getOrElse(slot) { - SharedPdfHighlighterPalette.defaultColors.first() - }).copy(alpha = 1f), - stateKey = slot, - onLiveColorChange = { color -> - updateHighlighterDraft(slot, color) - } - ) { liveColor -> - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Row( - modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically - ) { - highlighterDraftColors().forEachIndexed { index, argb -> - val color = if (index == slot) liveColor else Color(argb).copy(alpha = 1f) - Box( - modifier = Modifier - .size(42.dp) - .clip(RoundedCornerShape(21.dp)) - .background(color) - .border( - width = if (index == slot) 3.dp else 1.dp, - color = if (index == slot) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.outline.copy(alpha = 0.35f) - }, - shape = RoundedCornerShape(21.dp) - ) - .clickable { openHighlighterEditor(index) }, - contentAlignment = Alignment.Center - ) { - Text( - text = "${index + 1}", - color = if (color.luminance() > 0.5f) Color.Black else Color.White, - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Bold - ) - } - } - } - } - } - } -} - -private data class PdfSelectionMenuAction( - val label: String, - val icon: ImageVector, - val onClick: () -> Unit, - val isDestructive: Boolean = false -) - -private val PdfSelectionMenuWidth = 220.dp -private val PdfSelectionMenuPaletteHeight = 54.dp -private val PdfSelectionMenuActionRowHeight = 66.dp -private val PdfSelectionMenuAnchorGap = 16.dp -private val PdfSelectionMenuMargin = 6.dp -private const val DesktopPdfSelectionHandleTouchWidthPx = 44f -private const val DesktopPdfSelectionHandleTouchTopPx = 8f -private const val DesktopPdfSelectionHandleTouchBottomPx = 40f diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfSidecarEffects.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfSidecarEffects.kt deleted file mode 100644 index a0976ce..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfSidecarEffects.kt +++ /dev/null @@ -1,215 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotation -import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationSerializer -import org.dueattendant149.bookreader.shared.pdf.SharedPdfBookmark -import org.dueattendant149.bookreader.shared.pdf.SharedPdfBookmarkSerializer -import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichDocument -import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichTextController -import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichTextLog -import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichTextSerializer -import org.dueattendant149.bookreader.shared.pdf.SharedPdfSearchResult -import java.io.File -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.isActive -import kotlinx.coroutines.withContext - -@Composable -internal fun DesktopPdfAnnotationSidecarEffect( - documentHandleId: Long, - annotationFile: File, - annotations: List, - annotationsLoaded: Boolean, - onAnnotationsLoadedChange: (Boolean) -> Unit, - onAnnotationsLoaded: (List) -> Unit, - onLocalSidecarsChanged: () -> Unit -) { - LaunchedEffect(documentHandleId) { - onAnnotationsLoadedChange(false) - val loadedAnnotations = if (annotationFile.exists()) { - withContext(Dispatchers.IO) { - SharedPdfAnnotationSerializer.decode(annotationFile.readText()) - } - } else { - emptyList() - } - onAnnotationsLoaded(loadedAnnotations) - logDesktopCloudAnnotations { - "desktop.local.load_annotations document=$documentHandleId count=${loadedAnnotations.size} " + - "exists=${annotationFile.exists()} bytes=${annotationFile.length()} ts=${annotationFile.lastModifiedIfFileForCloudLog()}" - } - onAnnotationsLoadedChange(true) - } - - LaunchedEffect(documentHandleId, annotations, annotationsLoaded) { - if (!annotationsLoaded) return@LaunchedEffect - val changed = withContext(Dispatchers.IO) { - runCatching { - val nextJson = SharedPdfAnnotationSerializer.encode(annotations) - when { - annotations.isEmpty() && annotationFile.isFile -> { - annotationFile.delete() - } - annotations.isEmpty() -> false - annotationFile.isFile && annotationFile.readText() == nextJson -> false - else -> { - annotationFile.parentFile?.mkdirs() - annotationFile.writeText(nextJson) - true - } - } - }.getOrDefault(false) - } - if (changed) { - logDesktopCloudAnnotations { - "desktop.local.save_annotations document=$documentHandleId count=${annotations.size} " + - "bytes=${annotationFile.length()} ts=${annotationFile.lastModifiedIfFileForCloudLog()} " + - "path=${annotationFile.absolutePath.logPreview(140)}" - } - onLocalSidecarsChanged() - } - } -} - -@Composable -internal fun DesktopPdfBookmarkSidecarEffect( - documentHandleId: Long, - bookmarkFile: File, - bookmarks: List, - bookmarksLoaded: Boolean, - onBookmarksLoadedChange: (Boolean) -> Unit, - onBookmarksLoaded: (List) -> Unit, - onLocalSidecarsChanged: () -> Unit -) { - LaunchedEffect(documentHandleId) { - onBookmarksLoadedChange(false) - val loadedBookmarks = if (bookmarkFile.exists()) { - withContext(Dispatchers.IO) { - SharedPdfBookmarkSerializer.decode(bookmarkFile.readText()) - } - } else { - emptyList() - } - onBookmarksLoaded(loadedBookmarks) - onBookmarksLoadedChange(true) - } - - LaunchedEffect(documentHandleId, bookmarks, bookmarksLoaded) { - if (!bookmarksLoaded) return@LaunchedEffect - val changed = withContext(Dispatchers.IO) { - runCatching { - val nextJson = SharedPdfBookmarkSerializer.encode(bookmarks) - when { - bookmarks.isEmpty() && !bookmarkFile.isFile -> false - bookmarkFile.isFile && bookmarkFile.readText() == nextJson -> false - else -> { - bookmarkFile.parentFile?.mkdirs() - bookmarkFile.writeText(nextJson) - true - } - } - }.getOrDefault(false) - } - if (changed) { - onLocalSidecarsChanged() - } - } -} - -@Composable -internal fun DesktopPdfRichTextSidecarEffect( - documentHandleId: Long, - richTextFile: File, - richTextController: SharedPdfRichTextController, - onRichTextLoadedChange: (Boolean) -> Unit -) { - LaunchedEffect(documentHandleId) { - onRichTextLoadedChange(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) - onRichTextLoadedChange(true) - SharedPdfRichTextLog.d("desktop.loadRichText ready") - } -} - -@Composable -internal fun DesktopPdfSearchIndexSidecarEffect( - documentHandleId: Long, - document: DesktopPdfDocument, - searchIndexFile: File, - onIndexedSearchPageCountChange: (Int) -> Unit, - onSearchIndexingChange: (Boolean) -> Unit -) { - LaunchedEffect(documentHandleId) { - val restoredPageCount = withContext(Dispatchers.IO) { - restoreDesktopPdfSearchIndex(document, searchIndexFile) - } - onIndexedSearchPageCountChange(restoredPageCount) - onSearchIndexingChange(restoredPageCount < document.pageCount) - logPdfZoomPerf { - "search_index_restore indexed=$restoredPageCount/${document.pageCount} " + - "active=${restoredPageCount < document.pageCount}" - } - withContext(Dispatchers.IO) { - DesktopPdfium.indexSearchPages( - document = document, - onProgress = { indexed, _ -> - onIndexedSearchPageCountChange(indexed) - logPdfZoomPerf { "search_index_progress indexed=$indexed/${document.pageCount}" } - }, - shouldContinue = { isActive } - ) - if (isActive) { - saveDesktopPdfSearchIndex(document, searchIndexFile) - } - } - if (!isActive) return@LaunchedEffect - val indexedPageCount = document.indexedSearchTextPageCount() - onIndexedSearchPageCountChange(indexedPageCount) - onSearchIndexingChange(false) - logPdfZoomPerf { "search_index_done indexed=$indexedPageCount/${document.pageCount}" } - } -} - -@Composable -internal fun DesktopPdfSearchResultsEffect( - documentHandleId: Long, - document: DesktopPdfDocument, - searchQuery: String, - indexedSearchPageCount: Int, - onSearchResultsChange: (List) -> Unit -) { - LaunchedEffect(documentHandleId, searchQuery, indexedSearchPageCount) { - val normalizedQuery = searchQuery.trim() - val results = if (normalizedQuery.isBlank()) { - emptyList() - } else { - withContext(Dispatchers.IO) { - DesktopPdfium.search(document, normalizedQuery) - } - } - onSearchResultsChange(results) - } -} - -private fun File.lastModifiedIfFileForCloudLog(): Long { - return if (isFile) lastModified() else 0L -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfSidecars.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfSidecars.kt deleted file mode 100644 index 433796c..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfSidecars.kt +++ /dev/null @@ -1,132 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import java.io.File -import java.security.MessageDigest -import java.util.Base64 - -private const val DesktopPdfSearchIndexHeader = "EpistemePdfSearchIndex\t2" - -internal fun desktopPdfAnnotationFile(documentPath: String): File { - val safeName = desktopPdfDocumentKey(documentPath) - val legacyName = desktopPdfLegacyDocumentKey(documentPath) - return sidecarFileWithLegacyMigration( - file = File(desktopUserDataRoot(), "annotations/pdf_$safeName.json"), - legacyFile = File(desktopUserDataRoot(), "annotations/pdf_$legacyName.json") - ) -} - -internal fun desktopPdfAnnotationDeletionFile(documentPath: String): File { - val safeName = desktopPdfDocumentKey(documentPath) - val legacyName = desktopPdfLegacyDocumentKey(documentPath) - return sidecarFileWithLegacyMigration( - file = File(desktopUserDataRoot(), "annotations/pdf_${safeName}_deleted_annotations.json"), - legacyFile = File(desktopUserDataRoot(), "annotations/pdf_${legacyName}_deleted_annotations.json") - ) -} - -internal fun desktopPdfBookmarkFile(documentPath: String): File { - val safeName = desktopPdfDocumentKey(documentPath) - val legacyName = desktopPdfLegacyDocumentKey(documentPath) - return sidecarFileWithLegacyMigration( - file = File(desktopUserDataRoot(), "annotations/pdf_${safeName}_bookmarks.json"), - legacyFile = File(desktopUserDataRoot(), "annotations/pdf_${legacyName}_bookmarks.json") - ) -} - -internal fun desktopPdfRichTextFile(documentPath: String): File { - val safeName = desktopPdfDocumentKey(documentPath) - val legacyName = desktopPdfLegacyDocumentKey(documentPath) - return sidecarFileWithLegacyMigration( - file = File(desktopUserDataRoot(), "annotations/pdf_${safeName}_rich_text.json"), - legacyFile = File(desktopUserDataRoot(), "annotations/pdf_${legacyName}_rich_text.json") - ) -} - -internal fun desktopPdfSearchIndexFile(documentPath: String): File { - val safeName = desktopPdfDocumentKey(documentPath) - return File(desktopUserCacheRoot(), "search/pdf_${safeName}_text_index.tsv") -} - -internal 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["pathKey"] == desktopPdfDocumentKey(document.path) && - 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() -} - -internal 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("pathKey\t${desktopPdfDocumentKey(document.path)}") - 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) - } -} - -internal fun desktopPdfDocumentKey(documentPath: String): String { - val normalizedPath = runCatching { File(documentPath).canonicalPath } - .getOrElse { documentPath.trim() } - return sha256Hex(normalizedPath).take(32) -} - -private fun desktopPdfLegacyDocumentKey(documentPath: String): String { - return documentPath.hashCode().toString().replace("-", "n") -} - -private fun sidecarFileWithLegacyMigration(file: File, legacyFile: File): File { - if (!file.exists() && legacyFile.isFile && legacyFile != file) { - runCatching { - file.parentFile?.mkdirs() - if (!legacyFile.renameTo(file)) { - legacyFile.copyTo(file, overwrite = false) - } - } - } - return file -} - -private fun sha256Hex(value: String): String { - val digest = MessageDigest.getInstance("SHA-256").digest(value.toByteArray(Charsets.UTF_8)) - return digest.joinToString("") { "%02x".format(it) } -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfSyncSidecars.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfSyncSidecars.kt deleted file mode 100644 index d602e08..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfSyncSidecars.kt +++ /dev/null @@ -1,156 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import org.dueattendant149.bookreader.shared.BookItem -import org.dueattendant149.bookreader.shared.FileType -import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationSerializer -import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationSidecarCodec -import org.dueattendant149.bookreader.shared.pdf.SharedPdfBookmark -import org.dueattendant149.bookreader.shared.pdf.SharedPdfBookmarkSerializer -import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichTextSerializer -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.contentOrNull -import kotlinx.serialization.json.intOrNull -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import java.io.File - -private val desktopPdfSyncJson = Json { - ignoreUnknownKeys = true - prettyPrint = true - encodeDefaults = true -} - -internal fun desktopPdfAnnotationElementForSync(rawJson: String): JsonElement? { - val annotations = SharedPdfAnnotationSerializer.decode(rawJson) - if (annotations.isEmpty()) return null - return SharedPdfAnnotationSidecarCodec.encodeAnnotationsElement(annotations) -} - -internal fun desktopPdfRichTextElementForSync(rawJson: String): JsonElement? { - val element = runCatching { desktopPdfSyncJson.parseToJsonElement(rawJson) }.getOrNull() - ?: return null - val document = SharedPdfRichTextSerializer.decodeElement(element) - if (document.text.isEmpty() && document.spans.isEmpty()) return null - return SharedPdfRichTextSerializer.encodeElement(document) -} - -internal fun desktopPdfBookmarksMetadataJson(book: BookItem): String? { - if (book.type != FileType.PDF) return null - val path = book.path?.takeIf { it.isNotBlank() } ?: return null - val bookmarkFile = desktopPdfBookmarkFile(path).takeIf { it.isFile } ?: return null - return desktopPdfBookmarksMetadataJson( - bookmarks = SharedPdfBookmarkSerializer.decode(bookmarkFile.readText()), - lastPageIndex = book.lastPageIndex - ) -} - -internal fun desktopPdfBookmarksMetadataJson( - bookmarks: List, - lastPageIndex: Int? -): String { - val totalPages = maxOf( - (lastPageIndex ?: 0) + 1, - (bookmarks.maxOfOrNull { it.pageIndex } ?: 0) + 1 - ).coerceAtLeast(1) - return desktopPdfSyncJson.encodeToString( - JsonElement.serializer(), - JsonArray( - bookmarks.map { bookmark -> - JsonObject( - mapOf( - "pageIndex" to JsonPrimitive(bookmark.pageIndex.coerceAtLeast(0)), - "title" to JsonPrimitive(bookmark.label.ifBlank { "Page ${bookmark.pageIndex + 1}" }), - "totalPages" to JsonPrimitive(totalPages) - ) - ) - } - ) - ) -} - -internal fun desktopPdfBookmarkMetadataTimestamp(book: BookItem): Long { - if (book.type != FileType.PDF) return 0L - val path = book.path?.takeIf { it.isNotBlank() } ?: return 0L - return desktopPdfBookmarkFile(path).lastModifiedIfFile() -} - -internal fun importDesktopPdfBookmarksMetadata( - book: BookItem, - bookmarksJson: String?, - timestamp: Long -): Boolean { - if (book.type != FileType.PDF) return false - val path = book.path?.takeIf { it.isNotBlank() } ?: return false - val rawJson = bookmarksJson?.takeIf { it.isNotBlank() } ?: return false - val bookmarks = desktopPdfBookmarksFromMetadataJson(rawJson) - val bookmarkFile = desktopPdfBookmarkFile(path) - val localTimestamp = bookmarkFile.lastModifiedIfFile() - if (timestamp <= localTimestamp + 1000L) return false - - if (bookmarks.isEmpty()) { - if (bookmarkFile.isFile) bookmarkFile.delete() - return true - } - - bookmarkFile.parentFile?.mkdirs() - bookmarkFile.writeText(SharedPdfBookmarkSerializer.encode(bookmarks)) - bookmarkFile.setLastModified(timestamp) - return true -} - -internal fun desktopPdfBookmarksFromMetadataJson(rawJson: String): List { - val root = runCatching { desktopPdfSyncJson.parseToJsonElement(rawJson) }.getOrNull() - ?: return emptyList() - - root.jsonArrayOrNull()?.let { androidBookmarks -> - return androidBookmarks.mapNotNull { element -> - val obj = element.jsonObjectOrNull() ?: return@mapNotNull null - val pageIndex = obj.int("pageIndex") ?: return@mapNotNull null - SharedPdfBookmark( - pageIndex = pageIndex.coerceAtLeast(0), - label = obj.string("title") ?: obj.string("label") ?: "Page ${pageIndex + 1}", - createdAt = obj.longString("createdAt") ?: 0L - ) - } - } - - return SharedPdfBookmarkSerializer.decode(rawJson) -} - -private fun File.lastModifiedIfFile(): Long { - return if (isFile()) lastModified() else 0L -} - -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 this[name] - ?.takeUnless { it is JsonNull } - ?.jsonPrimitive - ?.contentOrNull - ?.takeIf { it.isNotBlank() } -} - -private fun JsonObject.int(name: String): Int? { - return this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull -} - -private fun JsonObject.longString(name: String): Long? { - return string(name)?.toLongOrNull() - ?: this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull?.toLongOrNull() -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfTheme.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfTheme.kt deleted file mode 100644 index bd23c2d..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfTheme.kt +++ /dev/null @@ -1,56 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.isSpecified -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import org.dueattendant149.bookreader.shared.PdfDisplayMode -import org.dueattendant149.bookreader.shared.ReaderTheme -import org.dueattendant149.bookreader.shared.pdf.pdfVerticalPageGapDp - -internal val DesktopDefaultPdfDisplayMode = PdfDisplayMode.PAGINATION -internal val DesktopDefaultPdfVerticalPageGap = 8.dp -internal val DesktopDefaultPdfSpreadPageGap = 18.dp - -internal fun desktopPdfPageBackgroundColor( - theme: ReaderTheme, - displayMode: PdfDisplayMode -): Color { - return 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 - } -} - -internal fun desktopPdfVerticalViewportBackgroundColor( - pageBackgroundColor: Color, - gapBackgroundColor: Color, - isPageGapVisible: Boolean -): Color { - return if (isPageGapVisible) gapBackgroundColor else pageBackgroundColor -} - -internal fun desktopPdfViewportBackgroundColor( - displayMode: PdfDisplayMode, - pageBackgroundColor: Color, - appBackgroundColor: Color, - isVerticalPageGapVisible: Boolean -): Color { - return when (displayMode) { - PdfDisplayMode.VERTICAL_SCROLL -> desktopPdfVerticalViewportBackgroundColor( - pageBackgroundColor = pageBackgroundColor, - gapBackgroundColor = appBackgroundColor, - isPageGapVisible = isVerticalPageGapVisible - ) - PdfDisplayMode.PAGINATION -> appBackgroundColor - } -} - -internal fun desktopPdfSpreadPageGapDp( - isPageGapVisible: Boolean -): Dp = pdfVerticalPageGapDp( - isPageGapVisible = isPageGapVisible, - defaultGap = DesktopDefaultPdfSpreadPageGap -) diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfZoom.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfZoom.kt deleted file mode 100644 index 509c355..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfZoom.kt +++ /dev/null @@ -1,644 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import androidx.compose.foundation.gestures.calculateCentroid -import androidx.compose.foundation.gestures.calculateZoom -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.TransformOrigin -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.PointerEventPass -import androidx.compose.ui.input.pointer.PointerEventType -import androidx.compose.ui.input.pointer.isCtrlPressed as isPointerCtrlPressed -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.IntSize -import org.dueattendant149.bookreader.shared.PdfDisplayMode -import org.dueattendant149.bookreader.shared.pdf.PdfZoomSpec -import kotlin.math.abs -import kotlin.math.exp -import kotlin.math.roundToInt - -private const val DesktopPdfZoomGestureFrameMillis = 16L -private const val DesktopPdfZoomPreviewTolerance = 0.0001f -internal const val DesktopPdfPaginationFastFirstRenderMaxScale = 2.0f - -internal fun desktopPdfScrollZoomFactor(scrollDelta: Float): Float { - if (!scrollDelta.isFinite() || abs(scrollDelta) < 0.01f) return 1f - val normalizedDelta = scrollDelta.coerceIn(-8f, 8f) - return exp((-normalizedDelta * 0.12f).toDouble()).toFloat() -} - -internal fun desktopPdfZoomTarget( - currentZoom: Float, - zoomSpec: PdfZoomSpec, - factor: Float -): Float { - val baseZoom = currentZoom.takeIf { it.isFinite() } ?: zoomSpec.default - val safeFactor = factor.takeIf { it.isFinite() && it > 0f } ?: 1f - return zoomSpec.clamp(baseZoom * safeFactor) -} - -internal fun desktopPdfAnchoredScrollTarget( - currentScroll: Int, - anchor: Float, - oldZoom: Float, - newZoom: Float -): Int { - if ( - !anchor.isFinite() || - !oldZoom.isFinite() || - !newZoom.isFinite() || - oldZoom <= 0f || - newZoom <= 0f - ) { - return currentScroll.coerceAtLeast(0) - } - val zoomRatio = newZoom / oldZoom - return (((currentScroll + anchor) * zoomRatio) - anchor).roundToInt().coerceAtLeast(0) -} - -internal fun desktopPdfAnchoredLazyItemScrollOffset( - itemOffset: Int, - anchor: Float, - oldZoom: Float, - newZoom: Float -): Int { - if ( - !anchor.isFinite() || - !oldZoom.isFinite() || - !newZoom.isFinite() || - oldZoom <= 0f || - newZoom <= 0f - ) { - return (-itemOffset).coerceAtLeast(0) - } - val zoomRatio = newZoom / oldZoom - val offsetWithinItem = anchor - itemOffset - return ((offsetWithinItem * zoomRatio) - anchor).roundToInt().coerceAtLeast(0) -} - -internal fun desktopPdfAnchoredPageScrollDelta( - viewportRootOffset: Offset, - oldPageRootOffset: Offset, - currentPageRootOffset: Offset, - anchor: Offset, - oldZoom: Float, - newZoom: Float -): IntOffset? { - if ( - !anchor.x.isFinite() || - !anchor.y.isFinite() || - !oldZoom.isFinite() || - !newZoom.isFinite() || - oldZoom <= 0f || - newZoom <= 0f - ) { - return null - } - val rootAnchor = viewportRootOffset + anchor - val oldPageLocal = rootAnchor - oldPageRootOffset - val zoomRatio = newZoom / oldZoom - val newPageLocal = Offset(oldPageLocal.x * zoomRatio, oldPageLocal.y * zoomRatio) - val desiredPageRoot = rootAnchor - newPageLocal - val delta = currentPageRootOffset - desiredPageRoot - return IntOffset(delta.x.roundToInt(), delta.y.roundToInt()) -} - -internal fun desktopPdfPaginationFirstRenderScale( - requestedScale: Float, - hasPageRender: Boolean, - isOpeningRender: Boolean = false -): Float { - if (hasPageRender || isOpeningRender || !requestedScale.isFinite() || requestedScale <= 0f) { - return requestedScale - } - return requestedScale.coerceAtMost(DesktopPdfPaginationFastFirstRenderMaxScale) -} - -internal fun desktopPdfRenderBelongsToPage( - renderedPageIndex: Int?, - requestedPageIndex: Int -): Boolean { - return renderedPageIndex == requestedPageIndex -} - -internal fun desktopPdfRenderScaleNeedsUpgrade( - renderedScale: Float?, - requestedScale: Float -): Boolean { - if (renderedScale == null) return true - if (!requestedScale.isFinite() || requestedScale <= 0f) return false - if (!renderedScale.isFinite() || renderedScale <= 0f) return true - return requestedScale - renderedScale > DesktopPdfRenderScaleTolerance -} - -internal fun desktopPdfSpreadZoomAnchorPageIndex( - viewportRootOffset: Offset, - anchor: Offset?, - visiblePageIndices: List, - pageRootOffsets: Map, - pageSizes: Map, - fallbackPageIndex: Int -): Int { - if (anchor == null || visiblePageIndices.isEmpty()) return fallbackPageIndex - if (!anchor.x.isFinite() || !anchor.y.isFinite()) return fallbackPageIndex - val rootAnchor = viewportRootOffset + anchor - if (!rootAnchor.x.isFinite() || !rootAnchor.y.isFinite()) return fallbackPageIndex - val candidates = visiblePageIndices.mapNotNull { pageIndex -> - pageRootOffsets[pageIndex]?.let { root -> - pageIndex to root - } - } - if (candidates.isEmpty()) return fallbackPageIndex - candidates.firstOrNull { (pageIndex, root) -> - val size = pageSizes[pageIndex] ?: return@firstOrNull false - val width = size.width.toFloat() - val height = size.height.toFloat() - rootAnchor.x >= root.x && - rootAnchor.x <= root.x + width && - rootAnchor.y >= root.y && - rootAnchor.y <= root.y + height - }?.let { return it.first } - return candidates.minByOrNull { (pageIndex, root) -> - val size = pageSizes[pageIndex] - val dx: Float - val dy: Float - if (size == null) { - dx = rootAnchor.x - root.x - dy = rootAnchor.y - root.y - } else { - val right = root.x + size.width.toFloat() - val bottom = root.y + size.height.toFloat() - dx = when { - rootAnchor.x < root.x -> root.x - rootAnchor.x - rootAnchor.x > right -> rootAnchor.x - right - else -> 0f - } - dy = when { - rootAnchor.y < root.y -> root.y - rootAnchor.y - rootAnchor.y > bottom -> rootAnchor.y - bottom - else -> 0f - } - } - dx * dx + dy * dy - }?.first ?: fallbackPageIndex -} - -internal data class DesktopPdfZoomPreview( - val baseZoom: Float, - val zoom: Float, - val anchor: Offset?, - val displayMode: PdfDisplayMode, - val pageIndex: Int?, - val viewportRootOffset: Offset = Offset.Zero, - val pageRootOffset: Offset? = null, - val commitTargetHorizontalScroll: Int? = null, - val commitTargetVerticalScroll: Int? = null, - val diagnosticSequence: Int = 0 -) - -internal data class DesktopPdfZoomScrollBounds( - val currentHorizontalScroll: Int? = null, - val maxHorizontalScroll: Int? = null, - val currentVerticalScroll: Int? = null, - val maxVerticalScroll: Int? = null -) - -internal interface DesktopPdfLayoutScrollPrediction { - val maxHorizontalScroll: Int - val maxVerticalScroll: Int -} - -internal data class DesktopPdfSinglePageLayoutPrediction( - val rootOffset: Offset, - override val maxHorizontalScroll: Int, - override val maxVerticalScroll: Int -) : DesktopPdfLayoutScrollPrediction - -internal data class DesktopPdfSpreadLayoutPrediction( - val pageRootOffsets: Map, - override val maxHorizontalScroll: Int, - override val maxVerticalScroll: Int -) : DesktopPdfLayoutScrollPrediction - -internal data class DesktopPdfCachedPageRender( - val render: DesktopPdfPageRender, - val scale: Float -) - -internal data class DesktopPdfNavigationZoomSnapshot( - val zoom: Float, - val horizontalScroll: Int, - val verticalScroll: Int -) - -internal fun desktopPdfNavigationZoomSnapshot( - preview: DesktopPdfZoomPreview?, - currentHorizontalScroll: Int, - currentVerticalScroll: Int -): DesktopPdfNavigationZoomSnapshot? { - val activePreview = preview ?: return null - val baseZoom = activePreview.baseZoom.takeIf { it.isFinite() && it > 0f } ?: return null - val targetZoom = activePreview.zoom.takeIf { it.isFinite() && it > 0f } ?: return null - val anchor = activePreview.anchor - return DesktopPdfNavigationZoomSnapshot( - zoom = targetZoom, - horizontalScroll = anchor?.let { - desktopPdfAnchoredScrollTarget(currentHorizontalScroll, it.x, baseZoom, targetZoom) - } ?: currentHorizontalScroll.coerceAtLeast(0), - verticalScroll = anchor?.let { - desktopPdfAnchoredScrollTarget(currentVerticalScroll, it.y, baseZoom, targetZoom) - } ?: currentVerticalScroll.coerceAtLeast(0) - ) -} - -internal fun desktopPdfZoomPreviewMatchesScale( - preview: DesktopPdfZoomPreview, - scale: Float -): Boolean { - return abs(preview.baseZoom - scale) <= DesktopPdfZoomPreviewTolerance || - abs(preview.zoom - scale) <= DesktopPdfZoomPreviewTolerance -} - -internal fun desktopPdfReachableScrollDelta( - currentScroll: Int?, - maxScroll: Int?, - requestedDelta: Int -): Int { - if (currentScroll == null || maxScroll == null) return requestedDelta - val safeMax = maxScroll.coerceAtLeast(0) - val safeCurrent = currentScroll.coerceIn(0, safeMax) - val targetScroll = (safeCurrent + requestedDelta).coerceIn(0, safeMax) - return targetScroll - safeCurrent -} - -internal fun desktopPdfReachableScrollDelta( - requestedDelta: IntOffset, - scrollBounds: DesktopPdfZoomScrollBounds? -): IntOffset { - if (scrollBounds == null) return requestedDelta - return IntOffset( - x = desktopPdfReachableScrollDelta( - currentScroll = scrollBounds.currentHorizontalScroll, - maxScroll = scrollBounds.maxHorizontalScroll, - requestedDelta = requestedDelta.x - ), - y = desktopPdfReachableScrollDelta( - currentScroll = scrollBounds.currentVerticalScroll, - maxScroll = scrollBounds.maxVerticalScroll, - requestedDelta = requestedDelta.y - ) - ) -} - -internal fun desktopPdfZoomScrollBoundsWithCommitTargets( - preview: DesktopPdfZoomPreview?, - currentHorizontalScroll: Int, - maxHorizontalScroll: Int, - currentVerticalScroll: Int? = null, - maxVerticalScroll: Int? = null -): DesktopPdfZoomScrollBounds { - return DesktopPdfZoomScrollBounds( - currentHorizontalScroll = currentHorizontalScroll, - maxHorizontalScroll = maxOf(maxHorizontalScroll, preview?.commitTargetHorizontalScroll ?: 0), - currentVerticalScroll = currentVerticalScroll, - maxVerticalScroll = maxVerticalScroll?.let { - maxOf(it, preview?.commitTargetVerticalScroll ?: 0) - } - ) -} - -internal fun desktopPdfSinglePageLayoutPrediction( - viewportRootOffset: Offset, - viewportSize: IntSize, - pageCanvasSize: IntSize, - horizontalScroll: Int, - verticalScroll: Int, - paddingPx: Float -): DesktopPdfSinglePageLayoutPrediction? { - if (viewportSize.width <= 0 || viewportSize.height <= 0) return null - if (pageCanvasSize.width <= 0 || pageCanvasSize.height <= 0) return null - if (!viewportRootOffset.x.isFinite() || !viewportRootOffset.y.isFinite()) return null - if (!paddingPx.isFinite() || paddingPx < 0f) return null - val viewportWidth = viewportSize.width.toFloat() - val viewportHeight = viewportSize.height.toFloat() - val pageWidth = pageCanvasSize.width.toFloat() - val contentWidth = (viewportWidth - paddingPx * 2f).coerceAtLeast(0f) - val maxHorizontalScroll = (pageWidth + paddingPx * 2f - viewportWidth) - .roundToInt() - .coerceAtLeast(0) - val maxVerticalScroll = (pageCanvasSize.height.toFloat() + paddingPx * 2f - viewportHeight) - .roundToInt() - .coerceAtLeast(0) - val safeHorizontalScroll = horizontalScroll.coerceIn(0, maxHorizontalScroll) - val safeVerticalScroll = verticalScroll.coerceIn(0, maxVerticalScroll) - val pageX = if (pageWidth <= contentWidth) { - ((viewportWidth - pageWidth) / 2f) - safeHorizontalScroll.toFloat() - } else { - paddingPx - safeHorizontalScroll.toFloat() - } - val pageY = paddingPx - safeVerticalScroll.toFloat() - return DesktopPdfSinglePageLayoutPrediction( - rootOffset = Offset( - x = viewportRootOffset.x + pageX, - y = viewportRootOffset.y + pageY - ), - maxHorizontalScroll = maxHorizontalScroll, - maxVerticalScroll = maxVerticalScroll - ) -} - -internal fun desktopPdfSpreadLayoutPrediction( - viewportRootOffset: Offset, - viewportSize: IntSize, - visiblePageIndices: List, - pageCanvasSizes: Map, - horizontalScroll: Int, - verticalScroll: Int, - paddingPx: Float, - pageGapPx: Float -): DesktopPdfSpreadLayoutPrediction? { - if (viewportSize.width <= 0 || viewportSize.height <= 0) return null - if (visiblePageIndices.isEmpty()) return null - if (!viewportRootOffset.x.isFinite() || !viewportRootOffset.y.isFinite()) return null - if (!paddingPx.isFinite() || paddingPx < 0f) return null - if (!pageGapPx.isFinite() || pageGapPx < 0f) return null - val pageSizes = visiblePageIndices.map { pageIndex -> - val pageSize = pageCanvasSizes[pageIndex] ?: return null - if (pageSize.width <= 0 || pageSize.height <= 0) return null - pageSize - } - val viewportWidth = viewportSize.width.toFloat() - val viewportHeight = viewportSize.height.toFloat() - val rowWidth = pageSizes.sumOf { it.width }.toFloat() + - (pageGapPx * (pageSizes.size - 1).coerceAtLeast(0)) - val rowHeight = pageSizes.maxOf { it.height }.toFloat() - val contentWidth = (viewportWidth - paddingPx * 2f).coerceAtLeast(0f) - val maxHorizontalScroll = (rowWidth + paddingPx * 2f - viewportWidth) - .roundToInt() - .coerceAtLeast(0) - val maxVerticalScroll = (rowHeight + paddingPx * 2f - viewportHeight) - .roundToInt() - .coerceAtLeast(0) - val safeHorizontalScroll = horizontalScroll.coerceIn(0, maxHorizontalScroll) - val safeVerticalScroll = verticalScroll.coerceIn(0, maxVerticalScroll) - val rowX = if (rowWidth <= contentWidth) { - ((viewportWidth - rowWidth) / 2f) - safeHorizontalScroll.toFloat() - } else { - paddingPx - safeHorizontalScroll.toFloat() - } - val rowY = paddingPx - safeVerticalScroll.toFloat() - var pageX = viewportRootOffset.x + rowX - val pageY = viewportRootOffset.y + rowY - val roots = visiblePageIndices.mapIndexed { index, pageIndex -> - val root = Offset(pageX, pageY) - pageX += pageSizes[index].width.toFloat() + pageGapPx - pageIndex to root - }.toMap() - return DesktopPdfSpreadLayoutPrediction( - pageRootOffsets = roots, - maxHorizontalScroll = maxHorizontalScroll, - maxVerticalScroll = maxVerticalScroll - ) -} - -internal fun desktopPdfZoomCommitPreviewTranslation( - viewportRootOffset: Offset, - oldPageRootOffset: Offset?, - currentAnchorPageRootOffset: Offset, - anchor: Offset?, - oldZoom: Float, - newZoom: Float, - currentZoom: Float, - scrollBounds: DesktopPdfZoomScrollBounds? = null -): Offset? { - if (oldPageRootOffset == null || anchor == null) return null - if (abs(currentZoom - newZoom) > DesktopPdfZoomPreviewTolerance) return null - val pageDelta = desktopPdfAnchoredPageScrollDelta( - viewportRootOffset = viewportRootOffset, - oldPageRootOffset = oldPageRootOffset, - currentPageRootOffset = currentAnchorPageRootOffset, - anchor = anchor, - oldZoom = oldZoom, - newZoom = newZoom - ) ?: return null - val reachableDelta = desktopPdfReachableScrollDelta(pageDelta, scrollBounds) - return Offset( - x = if (reachableDelta.x == 0) 0f else -reachableDelta.x.toFloat(), - y = if (reachableDelta.y == 0) 0f else -reachableDelta.y.toFloat() - ) -} - -internal fun desktopPdfZoomPreviewPivotFraction( - viewportRootOffset: Offset, - pageRootOffset: Offset, - anchor: Offset, - pageCanvasSize: IntSize -): Offset? { - if (pageCanvasSize.width <= 0 || pageCanvasSize.height <= 0) return null - if (!anchor.x.isFinite() || !anchor.y.isFinite()) return null - val pageAnchor = viewportRootOffset + anchor - pageRootOffset - if (!pageAnchor.x.isFinite() || !pageAnchor.y.isFinite()) return null - return Offset( - x = (pageAnchor.x / pageCanvasSize.width).coerceIn(0f, 1f), - y = (pageAnchor.y / pageCanvasSize.height).coerceIn(0f, 1f) - ) -} - -internal fun desktopPdfDocumentZoomPreviewTranslation( - viewportRootOffset: Offset, - pageRootOffset: Offset, - anchor: Offset, - previewScale: Float -): Offset? { - if (!anchor.x.isFinite() || !anchor.y.isFinite()) return null - if (!previewScale.isFinite() || previewScale <= 0f) return null - val rootAnchor = viewportRootOffset + anchor - if (!rootAnchor.x.isFinite() || !rootAnchor.y.isFinite()) return null - return Offset( - x = (pageRootOffset.x - rootAnchor.x) * (previewScale - 1f), - y = (pageRootOffset.y - rootAnchor.y) * (previewScale - 1f) - ) -} - -internal fun Modifier.desktopPdfZoomPreviewLayer( - preview: DesktopPdfZoomPreview?, - currentZoom: Float, - viewportRootOffset: Offset, - pageRootOffset: Offset, - pageCanvasSize: IntSize, - commitPageRootOffset: Offset? = null, - scrollBounds: DesktopPdfZoomScrollBounds? = null -): Modifier { - val activePreview = preview ?: return this - if (pageCanvasSize.width <= 0 || pageCanvasSize.height <= 0) return this - if (!currentZoom.isFinite() || currentZoom <= 0f) return this - if (!activePreview.zoom.isFinite() || activePreview.zoom <= 0f) return this - val previewScale = activePreview.zoom / currentZoom - val commitTranslation = desktopPdfZoomCommitPreviewTranslation( - viewportRootOffset = activePreview.viewportRootOffset, - oldPageRootOffset = activePreview.pageRootOffset, - currentAnchorPageRootOffset = commitPageRootOffset ?: pageRootOffset, - anchor = activePreview.anchor, - oldZoom = activePreview.baseZoom, - newZoom = activePreview.zoom, - currentZoom = currentZoom, - scrollBounds = scrollBounds - ) - if ( - !previewScale.isFinite() || - (abs(previewScale - 1f) < DesktopPdfZoomPreviewTolerance && commitTranslation == null) - ) { - return this - } - logPdfZoomSettle { - "preview_layer seq=${activePreview.diagnosticSequence} kind=page currentZoom=${currentZoom.formatLogFloat()} " + - "previewZoom=${activePreview.zoom.formatLogFloat()} scale=${previewScale.formatLogFloat()} " + - "pageRoot=${pageRootOffset.formatLogOffset()} commitRoot=${commitPageRootOffset.formatLogOffset()} " + - "commit=${commitTranslation.formatLogOffset()} " + - "h=${scrollBounds?.currentHorizontalScroll ?: "none"}/${scrollBounds?.maxHorizontalScroll ?: "none"} " + - "v=${scrollBounds?.currentVerticalScroll ?: "none"}/${scrollBounds?.maxVerticalScroll ?: "none"}" - } - val transformOrigin = activePreview.anchor?.let { anchor -> - desktopPdfZoomPreviewPivotFraction( - viewportRootOffset = viewportRootOffset, - pageRootOffset = pageRootOffset, - anchor = anchor, - pageCanvasSize = pageCanvasSize - )?.let { pivot -> - TransformOrigin(pivotFractionX = pivot.x, pivotFractionY = pivot.y) - } ?: TransformOrigin.Center - } ?: TransformOrigin.Center - return graphicsLayer { - scaleX = previewScale - scaleY = previewScale - translationX = commitTranslation?.x ?: 0f - translationY = commitTranslation?.y ?: 0f - this.transformOrigin = transformOrigin - } -} - -internal fun Modifier.desktopPdfDocumentZoomPreviewLayer( - preview: DesktopPdfZoomPreview?, - currentZoom: Float, - viewportRootOffset: Offset, - pageRootOffset: Offset, - anchorPageRootOffset: Offset? = null, - scrollBounds: DesktopPdfZoomScrollBounds? = null -): Modifier { - val activePreview = preview ?: return this - if (!currentZoom.isFinite() || currentZoom <= 0f) return this - if (!activePreview.zoom.isFinite() || activePreview.zoom <= 0f) return this - val previewScale = activePreview.zoom / currentZoom - val commitTranslation = desktopPdfZoomCommitPreviewTranslation( - viewportRootOffset = activePreview.viewportRootOffset, - oldPageRootOffset = activePreview.pageRootOffset, - currentAnchorPageRootOffset = anchorPageRootOffset ?: pageRootOffset, - anchor = activePreview.anchor, - oldZoom = activePreview.baseZoom, - newZoom = activePreview.zoom, - currentZoom = currentZoom, - scrollBounds = scrollBounds - ) - if ( - !previewScale.isFinite() || - (abs(previewScale - 1f) < DesktopPdfZoomPreviewTolerance && commitTranslation == null) - ) { - return this - } - logPdfZoomSettle { - "preview_layer seq=${activePreview.diagnosticSequence} kind=document currentZoom=${currentZoom.formatLogFloat()} " + - "previewZoom=${activePreview.zoom.formatLogFloat()} scale=${previewScale.formatLogFloat()} " + - "pageRoot=${pageRootOffset.formatLogOffset()} anchorRoot=${(anchorPageRootOffset ?: pageRootOffset).formatLogOffset()} " + - "commit=${commitTranslation.formatLogOffset()} h=${scrollBounds?.currentHorizontalScroll ?: "none"}/" + - "${scrollBounds?.maxHorizontalScroll ?: "none"} v=${scrollBounds?.currentVerticalScroll ?: "none"}/" + - "${scrollBounds?.maxVerticalScroll ?: "none"}" - } - val translation = activePreview.anchor?.let { anchor -> - desktopPdfDocumentZoomPreviewTranslation( - viewportRootOffset = viewportRootOffset, - pageRootOffset = pageRootOffset, - anchor = anchor, - previewScale = previewScale - ) - } ?: Offset.Zero - return graphicsLayer { - scaleX = previewScale - scaleY = previewScale - translationX = translation.x + (commitTranslation?.x ?: 0f) - translationY = translation.y + (commitTranslation?.y ?: 0f) - transformOrigin = TransformOrigin(0f, 0f) - } -} - -@Composable -internal fun Modifier.desktopPdfZoomGestures( - currentZoom: Float, - zoomSpec: PdfZoomSpec, - onZoomChanged: (oldZoom: Float, newZoom: Float, anchor: Offset?) -> Unit -): Modifier { - val latestZoom by rememberUpdatedState(currentZoom) - val latestOnZoomChanged by rememberUpdatedState(onZoomChanged) - return this.pointerInput(zoomSpec) { - var gestureZoom = latestZoom - var appliedGestureZoom = latestZoom - var lastZoomEventAt = 0L - var lastAppliedZoomAt = 0L - fun applyZoomFactor(factor: Float, eventTime: Long, anchor: Offset?) { - if (lastZoomEventAt == 0L || eventTime - lastZoomEventAt > 180L) { - gestureZoom = latestZoom - appliedGestureZoom = latestZoom - lastAppliedZoomAt = 0L - } - val newZoom = desktopPdfZoomTarget(gestureZoom, zoomSpec, factor) - gestureZoom = newZoom - lastZoomEventAt = eventTime - val shouldApplyNow = lastAppliedZoomAt == 0L || - eventTime - lastAppliedZoomAt >= DesktopPdfZoomGestureFrameMillis || - newZoom == zoomSpec.min || - newZoom == zoomSpec.max - if (shouldApplyNow && newZoom != appliedGestureZoom) { - latestOnZoomChanged(appliedGestureZoom, newZoom, anchor) - appliedGestureZoom = newZoom - lastAppliedZoomAt = eventTime - } - } - - awaitPointerEventScope { - while (true) { - val event = awaitPointerEvent(PointerEventPass.Initial) - val eventTime = event.changes.maxOfOrNull { it.uptimeMillis } ?: 0L - if (event.type == PointerEventType.Scroll && event.keyboardModifiers.isPointerCtrlPressed) { - val scrollDelta = event.changes.fold(Offset.Zero) { total, change -> - total + change.scrollDelta - } - val zoomDelta = if (abs(scrollDelta.y) >= abs(scrollDelta.x)) scrollDelta.y else scrollDelta.x - val factor = desktopPdfScrollZoomFactor(zoomDelta) - if (abs(factor - 1f) > 0.0001f) { - applyZoomFactor(factor, eventTime, event.changes.firstOrNull()?.position) - event.changes.forEach { it.consume() } - } - continue - } - - val pressedPointers = event.changes.count { it.pressed } - if (pressedPointers > 1) { - val zoomChange = event.calculateZoom() - if (zoomChange.isFinite() && abs(zoomChange - 1f) > 0.005f) { - val centroid = event.calculateCentroid(useCurrent = false) - val anchor = if (centroid == Offset.Unspecified) { - event.changes.firstOrNull { it.pressed }?.position - } else { - centroid - } - applyZoomFactor(zoomChange, eventTime, anchor) - } - event.changes.forEach { it.consume() } - } - } - } - } -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfium.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfium.kt deleted file mode 100644 index f71a86d..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfium.kt +++ /dev/null @@ -1,2420 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.toComposeImageBitmap -import org.dueattendant149.bookreader.shared.FileType -import org.dueattendant149.bookreader.shared.PdfTocEntry -import org.dueattendant149.bookreader.shared.opds.OpdsCatalog -import org.dueattendant149.bookreader.shared.opds.OpdsStreamReference -import org.dueattendant149.bookreader.shared.pdf.PdfInkTool -import org.dueattendant149.bookreader.shared.pdf.PdfPageBounds -import org.dueattendant149.bookreader.shared.pdf.PdfZoomSpec -import org.dueattendant149.bookreader.shared.pdf.PdfiumAnnotationSubtype -import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotation -import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationExportMapper -import org.dueattendant149.bookreader.shared.pdf.SharedPdfEmbeddedAnnotation -import org.dueattendant149.bookreader.shared.pdf.SharedPdfEmbeddedAnnotationThreads -import org.dueattendant149.bookreader.shared.pdf.SharedPdfHighlightAnnotationExport -import org.dueattendant149.bookreader.shared.pdf.SharedPdfInkAnnotationExport -import org.dueattendant149.bookreader.shared.pdf.SharedPdfIndexedPage -import org.dueattendant149.bookreader.shared.pdf.SharedPdfReflowImageElement -import org.dueattendant149.bookreader.shared.pdf.SharedPdfReflowPage -import org.dueattendant149.bookreader.shared.pdf.SharedPdfReflowPageElement -import org.dueattendant149.bookreader.shared.pdf.SharedPdfReflowTextElement -import org.dueattendant149.bookreader.shared.pdf.SharedPdfReflowTextLine -import org.dueattendant149.bookreader.shared.pdf.SharedPdfReflowTextSpan -import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichPageLayout -import org.dueattendant149.bookreader.shared.pdf.SharedPdfSearchIndex -import org.dueattendant149.bookreader.shared.pdf.SharedPdfSearchResult -import org.dueattendant149.bookreader.shared.pdf.pdfInkAppearancePoints -import com.sun.jna.Callback -import com.sun.jna.Library -import com.sun.jna.Memory -import com.sun.jna.Native -import com.sun.jna.NativeLong -import com.sun.jna.Pointer -import com.sun.jna.Structure -import com.sun.jna.ptr.PointerByReference -import java.io.ByteArrayOutputStream -import java.awt.image.BufferedImage -import java.io.File -import java.io.FileOutputStream -import java.nio.ByteOrder -import java.text.SimpleDateFormat -import java.util.Base64 -import java.util.Date -import java.util.Locale -import java.util.TimeZone -import javax.imageio.ImageIO -import kotlin.math.roundToInt - -data class DesktopPdfDocument( - val path: String, - val title: String, - val pageCount: Int, - val pageSizes: List, - internal val handleId: Long, - val formatLabel: String = "PDF", - val toc: List = emptyList(), - private val initialEmbeddedAnnotations: List = emptyList() -) { - var embeddedAnnotations: List by mutableStateOf(initialEmbeddedAnnotations) - private set - - private val textPageCache = LinkedHashMap() - private val searchIndex = SharedPdfSearchIndex(pageCount) - - fun replaceEmbeddedAnnotations(annotations: List) { - embeddedAnnotations = annotations - } - - 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(this) - } -} - -data class DesktopPdfPageSize( - val width: Float, - val height: Float -) - -data class DesktopPdfPageRender( - val image: ImageBitmap, - val width: Int, - val height: Int -) - -data class DesktopPdfMetadata( - val title: String? = null, - val author: String? = null, - val description: String? = null -) - -internal val DesktopPdfZoomSpec = PdfZoomSpec( - max = 8.0f, - maxRenderPixels = 64_000_000 -) - -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() -) - -internal class DesktopPdfPasswordException(fileName: String) : IllegalStateException( - "A password is required or the supplied password is incorrect for $fileName." -) - -internal fun Throwable.isDesktopPdfPasswordException(): Boolean { - return this is DesktopPdfPasswordException || cause?.isDesktopPdfPasswordException() == true -} - -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 const val FPDF_ANNOT_COLOR = 0 - private const val FPDF_ANNOT_TEXT = 1 - private const val FPDF_ANNOT_HIGHLIGHT = 9 - private const val FPDF_ANNOT_INK = 15 - private const val FPDF_ANNOT_FLAG_PRINT = 1 shl 2 - private const val FPDF_NO_INCREMENTAL = 1L shl 1 - private const val FPDF_PAGEOBJ_IMAGE = 3 - private const val FPDF_TEXT_FONT_FLAG_ITALIC = 64 - - private val textUrlRegex = Regex("""\b(?:https?://|www\.)[^\s<>"']+""", RegexOption.IGNORE_CASE) - private val pdfiumDll: File by lazy(::resolvePdfiumDll) - private val zoomSpec = DesktopPdfZoomSpec - private val api: PdfiumLibrary by lazy { - require(pdfiumDll.exists()) { - missingPdfiumLibraryMessage(pdfiumDll) - } - Native.load(pdfiumDll.absolutePath, PdfiumLibrary::class.java) - } - - private var initialized = false - private var nextHandleId = 0L - private val openDocuments = LinkedHashMap() - private val openComicDocuments = LinkedHashMap() - private val openPptxDocuments = LinkedHashMap() - - fun isAvailable(): Boolean = pdfiumDll.exists() - - 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, password = password) - } - - api.FPDF_GetLastError().also { errorCode -> - logPdfiumOpen( - "path_load_failed code=$errorCode message=\"${pdfiumLoadErrorMessage(errorCode)}\" " + - "path=\"${file.absolutePath}\"" - ) - } - } - - 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, password = password) - } - val memoryError = api.FPDF_GetLastError() - logPdfiumOpen( - "memory_load_failed code=$memoryError message=\"${pdfiumLoadErrorMessage(memoryError)}\" " + - "bytes=${bytes.size} path=\"${file.absolutePath}\"" - ) - if (memoryError == 4 || pathError == 4) { - throw DesktopPdfPasswordException(file.name) - } - 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" - if (pathError == 4) { - throw DesktopPdfPasswordException(file.name) - } - error("Pdfium could not open ${file.name}. $pathMessage") - } - - @Synchronized - fun load(file: File, password: String? = null, loadEmbeddedAnnotations: Boolean = true): DesktopPdfDocument { - initLibrary() - val startedAt = System.currentTimeMillis() - val loadedDocument = loadDocument(file, password) - val document = loadedDocument.pointer - val handleId = nextDocumentHandleId() - closeDocument(file.absolutePath) - openDocuments[handleId] = loadedDocument.copy(path = file.absolutePath, password = password) - - try { - val pageCount = api.FPDF_GetPageCount(document) - logPdfiumOpen("metadata_loaded pageCount=$pageCount elapsedMs=${System.currentTimeMillis() - startedAt}") - val pageSizes = (0 until pageCount).map { pageIndex -> - pageSizeByIndex(document, 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 = if (loadEmbeddedAnnotations) { - extractEmbeddedAnnotations(document, pageSizes).also { annotations -> - logPdfiumOpen( - "embedded_annotations_extracted count=${annotations.size} " + - "elapsedMs=${System.currentTimeMillis() - startedAt}" - ) - } - } else { - logPdfiumOpen("embedded_annotations_deferred elapsedMs=${System.currentTimeMillis() - startedAt}") - emptyList() - } - - val result = DesktopPdfDocument( - path = file.absolutePath, - title = metadata.title ?: file.nameWithoutExtension, - pageCount = pageCount, - pageSizes = pageSizes, - toc = toc, - handleId = handleId, - initialEmbeddedAnnotations = embeddedAnnotations - ) - logPdfiumOpen("open_complete elapsedMs=${System.currentTimeMillis() - startedAt}") - return result - } catch (throwable: Throwable) { - openDocuments.remove(handleId) - api.FPDF_CloseDocument(document) - throw throwable - } - } - - @Synchronized - fun loadComic(file: File, type: FileType): DesktopPdfDocument { - val startedAt = System.currentTimeMillis() - val comic = DesktopComicArchive.load(file, type) - val handleId = nextDocumentHandleId() - closeDocument(file.absolutePath) - openComicDocuments[handleId] = comic - logPdfiumOpen( - "comic_open_complete type=${type.name} pages=${comic.pageCount} " + - "elapsedMs=${System.currentTimeMillis() - startedAt}" - ) - return DesktopPdfDocument( - path = file.absolutePath, - title = comic.title, - pageCount = comic.pageCount, - pageSizes = comic.pageSizes, - formatLabel = type.name, - handleId = handleId - ) - } - - @Synchronized - fun loadPptx(file: File): DesktopPdfDocument { - val startedAt = System.currentTimeMillis() - val pptx = DesktopPptxDocuments.load(file) - val handleId = nextDocumentHandleId() - closeDocument(file.absolutePath) - openPptxDocuments[handleId] = pptx - logPdfiumOpen( - "pptx_open_complete pages=${pptx.pageCount} " + - "elapsedMs=${System.currentTimeMillis() - startedAt}" - ) - return DesktopPdfDocument( - path = file.absolutePath, - title = pptx.title, - pageCount = pptx.pageCount, - pageSizes = pptx.pageSizes, - formatLabel = "PPTX", - handleId = handleId - ) - } - - @Synchronized - fun loadOpdsStream( - path: String, - title: String, - reference: OpdsStreamReference, - catalog: OpdsCatalog? - ): DesktopPdfDocument { - val startedAt = System.currentTimeMillis() - val comic = DesktopComicArchive.loadOpdsStream(path, title, reference, catalog) - val handleId = nextDocumentHandleId() - closeDocument(path) - openComicDocuments[handleId] = 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", - handleId = handleId - ) - } - - fun loadEmbeddedAnnotations(document: DesktopPdfDocument): List { - if (synchronized(this) { - openComicDocuments.containsKey(document.handleId) || openPptxDocuments.containsKey(document.handleId) - }) return emptyList() - val startedAt = System.currentTimeMillis() - val annotations = mutableListOf() - for ((pageIndex, pageSize) in document.pageSizes.withIndex()) { - val pageAnnotations = synchronized(this) { - if (openComicDocuments.containsKey(document.handleId) || openPptxDocuments.containsKey(document.handleId)) { - return annotations - } - val nativeDocument = openDocuments[document.handleId]?.pointer ?: return annotations - extractEmbeddedAnnotationsForPage(nativeDocument, pageIndex, pageSize) - } - annotations += pageAnnotations - } - logPdfiumOpen( - "embedded_annotations_loaded_async count=${annotations.size} " + - "elapsedMs=${System.currentTimeMillis() - startedAt}" - ) - return annotations - } - - @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 exportAnnotatedPdf( - document: DesktopPdfDocument, - destination: File, - annotations: List, - richTextPageLayouts: List = emptyList() - ) { - initLibrary() - require(document.formatLabel == "PDF") { "Only PDF files can be exported with PDF annotations." } - val source = File(document.path) - require(source.isFile) { "The original PDF is not available as a local file." } - val activeDocument = openDocuments[document.handleId] - ?: error("PDF document is not open.") - val exportDocument = loadDocument(source, activeDocument.password) - val rasterOverlays = buildDesktopPdfRasterOverlays( - annotations = annotations, - richTextPageLayouts = richTextPageLayouts, - pageSizes = document.pageSizes - ) - val exportPayload = SharedPdfAnnotationExportMapper.build( - annotations = annotations, - resolveHighlightBounds = resolver@ { annotation -> - val startIndex = annotation.rangeStartIndex ?: return@resolver emptyList() - val endIndex = annotation.rangeEndIndex ?: return@resolver emptyList() - val pageSize = document.pageSizes.getOrNull(annotation.pageIndex) ?: return@resolver emptyList() - if (endIndex < startIndex) return@resolver emptyList() - textRectsForRange( - document = document, - pageIndex = annotation.pageIndex, - startIndex = startIndex, - endIndex = endIndex, - viewportWidth = pageSize.width.roundToInt().coerceAtLeast(1), - viewportHeight = pageSize.height.roundToInt().coerceAtLeast(1) - ).map { it.toPdfPageBounds() } - .filter { it.right > it.left && it.bottom > it.top } - .mergePdfBoundsByLine() - } - ) - val rasterResources = mutableListOf() - try { - val nativeDocument = exportDocument.pointer - val pageCount = api.FPDF_GetPageCount(nativeDocument) - var insertedAny = false - exportPayload.inkAnnotations.forEach { annotation -> - insertedAny = insertInkAnnotation(nativeDocument, pageCount, annotation) || insertedAny - } - exportPayload.highlightAnnotations.forEach { annotation -> - insertedAny = insertHighlightAnnotation(nativeDocument, pageCount, annotation) || insertedAny - } - rasterOverlays.forEach { overlay -> - insertedAny = insertRasterOverlay(nativeDocument, pageCount, overlay, rasterResources) || insertedAny - } - - if (!insertedAny) { - destination.parentFile?.mkdirs() - source.copyTo(destination, overwrite = true) - return - } - savePdfDocument(nativeDocument, destination) - } finally { - rasterResources.forEach { resource -> - runCatching { api.FPDFBitmap_Destroy(resource.bitmap) } - } - api.FPDF_CloseDocument(exportDocument.pointer) - } - } - - @Synchronized - fun closeDocument(path: String) { - val pdfHandleIds = openDocuments - .filterValues { it.path == path } - .keys - .toList() - val comicHandleIds = openComicDocuments - .filterValues { it.path == path } - .keys - .toList() - val pptxHandleIds = openPptxDocuments - .filterValues { it.path == path } - .keys - .toList() - pdfHandleIds.forEach(::closePdfDocumentHandle) - comicHandleIds.forEach(::closeComicDocumentHandle) - pptxHandleIds.forEach(::closePptxDocumentHandle) - } - - @Synchronized - fun closeDocument(document: DesktopPdfDocument) { - closePdfDocumentHandle(document.handleId) - closeComicDocumentHandle(document.handleId) - closePptxDocumentHandle(document.handleId) - } - - private fun nextDocumentHandleId(): Long { - nextHandleId += 1 - return nextHandleId - } - - private fun closePdfDocumentHandle(handleId: Long) { - openDocuments.remove(handleId)?.let { api.FPDF_CloseDocument(it.pointer) } - } - - private fun closeComicDocumentHandle(handleId: Long) { - openComicDocuments.remove(handleId)?.close() - } - - private fun closePptxDocumentHandle(handleId: Long) { - openPptxDocuments.remove(handleId)?.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.handleId)) return "" - openPptxDocuments[document.handleId]?.let { return it.textOnlyPage(pageIndex) } - val nativeDocument = openDocuments[document.handleId]?.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.handleId)) return DesktopPdfTextPageData() - openPptxDocuments[document.handleId]?.let { return it.textPageData(pageIndex) } - val nativeDocument = openDocuments[document.handleId]?.pointer ?: return DesktopPdfTextPageData() - val pageSize = document.pageSizes.getOrNull(pageIndex) ?: return DesktopPdfTextPageData() - return extractPageTextData(nativeDocument, pageIndex, pageSize) - } - - @Synchronized - fun loadReflowPage(document: DesktopPdfDocument, pageIndex: Int): SharedPdfReflowPage { - if (document.formatLabel != "PDF") { - return SharedPdfReflowPage(pageNumber = pageIndex + 1, elements = emptyList()) - } - val nativeDocument = openDocuments[document.handleId]?.pointer - ?: return SharedPdfReflowPage(pageNumber = pageIndex + 1, elements = emptyList()) - return extractReflowPage(nativeDocument, pageIndex, pageIndex + 1) - } - - @Synchronized - fun loadReflowEdgeLines(document: DesktopPdfDocument, pageIndex: Int): List { - if (document.formatLabel != "PDF") return emptyList() - val nativeDocument = openDocuments[document.handleId]?.pointer ?: return emptyList() - return extractPageText(nativeDocument, pageIndex) - .split('\n') - .map { it.trim() } - .filter { it.length > 2 } - } - - 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.handleId)) return null - openPptxDocuments[document.handleId]?.let { pptx -> - return pptx.linkAt(pageIndex = pageIndex, normalizedX = normalizedX, normalizedY = normalizedY) - } - val nativeDocument = openDocuments[document.handleId]?.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 pageSize = document.pageSizes.getOrNull(pageIndex) ?: error("Invalid PDF page index $pageIndex.") - val safeScale = zoomSpec.safeRenderScale(pageSize.width, pageSize.height, scale) - openComicDocuments[document.handleId]?.let { comic -> - val image = comic.renderPageBufferedImage(pageIndex, safeScale) - return DesktopPdfPageRender( - image = image.toComposeImageBitmap(), - width = image.width, - height = image.height - ) - } - openPptxDocuments[document.handleId]?.let { pptx -> - val image = pptx.renderPageBufferedImage(pageIndex, safeScale) - return DesktopPdfPageRender( - image = image.toComposeImageBitmap(), - width = image.width, - height = image.height - ) - } - val nativeDocument = openDocuments[document.handleId]?.pointer ?: error("PDF document is not open.") - 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 DesktopPdfPageRender( - image = memory.toBufferedImage(width, height, stride).toComposeImageBitmap(), - width = width, - height = height - ) - } finally { - api.FPDFBitmap_Destroy(bitmap) - } - } - - @Synchronized - fun renderPageBufferedImage( - document: DesktopPdfDocument, - pageIndex: Int, - scale: Float, - renderAnnotations: Boolean = true - ): BufferedImage { - val pageSize = document.pageSizes.getOrNull(pageIndex) ?: error("Invalid PDF page index $pageIndex.") - val safeScale = zoomSpec.safeRenderScale(pageSize.width, pageSize.height, scale) - openComicDocuments[document.handleId]?.let { comic -> - return comic.renderPageBufferedImage(pageIndex, safeScale) - } - openPptxDocuments[document.handleId]?.let { pptx -> - return pptx.renderPageBufferedImage(pageIndex, safeScale) - } - val nativeDocument = openDocuments[document.handleId]?.pointer ?: error("PDF document is not open.") - 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? { - openPptxDocuments[document.handleId]?.let { pptx -> - return pptx.charIndexAt(pageIndex, normalizedX, normalizedY, tolerance) - } - val nativeDocument = openDocuments[document.handleId]?.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 { - openPptxDocuments[document.handleId]?.let { pptx -> - return pptx.textRectsForRange(pageIndex, startIndex, endIndex) - } - val nativeDocument = openDocuments[document.handleId]?.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) - extractText(textPage, charCount) - } finally { - api.FPDFText_ClosePage(textPage) - } - } - }.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 extractReflowPage(document: Pointer, pageIndex: Int, pageNumber: Int): SharedPdfReflowPage { - return runCatching { - loadPage(document, pageIndex).usePointer { page -> - val imageElements = extractReflowImageElements(page) - val textPage = api.FPDFText_LoadPage(page) - ?: return@usePointer SharedPdfReflowPage( - pageNumber = pageNumber, - elements = imageElements.sortedByDescending { it.yPos } - ) - try { - val charCount = api.FPDFText_CountChars(textPage) - if (charCount <= 0) { - return@usePointer SharedPdfReflowPage( - pageNumber = pageNumber, - elements = imageElements.sortedByDescending { it.yPos } - ) - } - - val rawText = extractText(textPage, charCount) - val actualCount = minOf(charCount, rawText.length) - if (actualCount <= 0) { - return@usePointer SharedPdfReflowPage( - pageNumber = pageNumber, - elements = imageElements.sortedByDescending { it.yPos } - ) - } - - val sizes = reflowFontSizes(textPage, actualCount) - val weights = reflowFontWeights(textPage, actualCount) - val flags = reflowFontFlags(textPage, actualCount) - val charBoxes = reflowCharBoxes(textPage, actualCount) - - val textLines = buildReflowTextLines( - rawText = rawText, - actualCount = actualCount, - sizes = sizes, - weights = weights, - flags = flags, - charBoxes = charBoxes - ) - SharedPdfReflowPage( - pageNumber = pageNumber, - elements = mergeReflowElements(textLines, imageElements) - ) - } finally { - api.FPDFText_ClosePage(textPage) - } - } - }.getOrDefault(SharedPdfReflowPage(pageNumber = pageNumber, elements = emptyList())) - } - - private fun buildReflowTextLines( - rawText: String, - actualCount: Int, - sizes: FloatArray, - weights: IntArray, - flags: IntArray, - charBoxes: FloatArray - ): List { - val textLines = mutableListOf() - val currentSpans = mutableListOf() - val currentSpanBuf = StringBuilder() - var curSize = -1f - var curBold = false - var curItalic = false - var lineBaseline = 0f - - fun commitSpan() { - if (currentSpanBuf.isNotEmpty()) { - currentSpans.add( - SharedPdfReflowTextSpan( - text = currentSpanBuf.toString(), - size = curSize, - isBold = curBold, - isItalic = curItalic - ) - ) - currentSpanBuf.clear() - } - } - - fun commitLine() { - commitSpan() - if (currentSpans.isNotEmpty()) { - val text = currentSpans.joinToString("") { it.text } - if (text.isNotBlank()) { - textLines += SharedPdfReflowTextLine( - spans = currentSpans.toList(), - yPos = lineBaseline, - charCount = text.length - ) - } - currentSpans.clear() - } - lineBaseline = 0f - } - - for (index in 0 until actualCount) { - val char = rawText[index] - if (char.code == 0) continue - - if (char == '\r') { - commitLine() - continue - } - if (char == '\n') { - if (index > 0 && rawText[index - 1] == '\r') continue - commitLine() - continue - } - - val charToProcess = when (char) { - '\u00A0' -> ' ' - '\u00AD' -> '-' - '\t' -> ' ' - else -> char - } - if (char.isDesktopPdfReflowJunk()) continue - - val size = sizes.getOrElse(index) { 12f }.coerceAtLeast(0f) - val isBold = weights.getOrElse(index) { 0 } > 600 - val isItalic = (flags.getOrElse(index) { 0 } and FPDF_TEXT_FONT_FLAG_ITALIC) != 0 - - if (currentSpanBuf.isEmpty() && currentSpans.isEmpty() && !charToProcess.isWhitespace()) { - lineBaseline = if (index * 4 + 1 < charBoxes.size) charBoxes[index * 4 + 1] else 0f - } - - if (currentSpanBuf.isEmpty()) { - curSize = size - curBold = isBold - curItalic = isItalic - currentSpanBuf.append(charToProcess) - } else if (!charToProcess.isWhitespace() && (size != curSize || isBold != curBold || isItalic != curItalic)) { - commitSpan() - curSize = size - curBold = isBold - curItalic = isItalic - currentSpanBuf.append(charToProcess) - } else { - currentSpanBuf.append(charToProcess) - } - } - commitLine() - return textLines - } - - private fun mergeReflowElements( - textLines: List, - imageElements: List - ): List { - val finalElements = mutableListOf() - val sortedImages = imageElements.sortedByDescending { it.yPos } - var imageIndex = 0 - for (line in textLines) { - while (imageIndex < sortedImages.size && sortedImages[imageIndex].yPos >= line.yPos) { - finalElements += sortedImages[imageIndex] - imageIndex += 1 - } - finalElements += SharedPdfReflowTextElement(line) - } - while (imageIndex < sortedImages.size) { - finalElements += sortedImages[imageIndex] - imageIndex += 1 - } - return finalElements - } - - private fun reflowFontSizes(textPage: Pointer, count: Int): FloatArray { - return FloatArray(count) { index -> - runCatching { api.FPDFText_GetFontSize(textPage, index).toFloat() } - .getOrDefault(12f) - } - } - - private fun reflowFontWeights(textPage: Pointer, count: Int): IntArray { - return IntArray(count) { index -> - runCatching { api.FPDFText_GetFontWeight(textPage, index) } - .getOrDefault(0) - } - } - - private fun reflowFontFlags(textPage: Pointer, count: Int): IntArray { - return IntArray(count) { index -> - val flags = IntArray(1) - runCatching { - api.FPDFText_GetFontInfo(textPage, index, null, NativeLong(0), flags) - } - flags[0] - } - } - - private fun reflowCharBoxes(textPage: Pointer, count: Int): FloatArray { - val result = FloatArray(count * 4) - for (index in 0 until count) { - val left = DoubleArray(1) - val right = DoubleArray(1) - val bottom = DoubleArray(1) - val top = DoubleArray(1) - runCatching { - api.FPDFText_GetCharBox(textPage, index, left, right, bottom, top) - } - result[index * 4] = left[0].toFloat() - result[index * 4 + 1] = bottom[0].toFloat() - result[index * 4 + 2] = right[0].toFloat() - result[index * 4 + 3] = top[0].toFloat() - } - return result - } - - private fun extractReflowImageElements(page: Pointer): List { - val objectCount = runCatching { api.FPDFPage_CountObjects(page) }.getOrDefault(0) - if (objectCount <= 0) return emptyList() - - val images = mutableListOf() - for (index in 0 until objectCount) { - val pageObject = runCatching { api.FPDFPage_GetObject(page, index) }.getOrNull() ?: continue - val objectType = runCatching { api.FPDFPageObj_GetType(pageObject) }.getOrDefault(0) - if (objectType != FPDF_PAGEOBJ_IMAGE) continue - - val left = FloatArray(1) - val bottom = FloatArray(1) - val right = FloatArray(1) - val top = FloatArray(1) - val hasBounds = runCatching { - api.FPDFPageObj_GetBounds(pageObject, left, bottom, right, top) - }.getOrDefault(0) != 0 - if (!hasBounds) continue - - val bitmap = runCatching { api.FPDFImageObj_GetBitmap(pageObject) }.getOrNull() ?: continue - try { - val width = runCatching { api.FPDFBitmap_GetWidth(bitmap) }.getOrDefault(0) - val height = runCatching { api.FPDFBitmap_GetHeight(bitmap) }.getOrDefault(0) - val stride = runCatching { api.FPDFBitmap_GetStride(bitmap) }.getOrDefault(0) - val buffer = runCatching { api.FPDFBitmap_GetBuffer(bitmap) }.getOrNull() - if (width > 0 && height > 0 && stride > 0 && buffer != null) { - val image = buffer.toDesktopReflowImage(width, height, stride) - val output = ByteArrayOutputStream() - if (ImageIO.write(image, "jpg", output)) { - images += SharedPdfReflowImageElement( - base64Data = Base64.getEncoder().encodeToString(output.toByteArray()), - width = width, - height = height, - yPos = top[0], - mimeType = "image/jpeg" - ) - } - } - } finally { - runCatching { api.FPDFBitmap_Destroy(bitmap) } - } - } - return images - } - - 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(), - description = documentMetaText(document, "Subject").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 -> - extractEmbeddedAnnotationsForPage(document, pageIndex, pageSize) - } - } - - private fun extractEmbeddedAnnotationsForPage( - document: Pointer, - pageIndex: Int, - pageSize: DesktopPdfPageSize - ): List { - return 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 = annotationReplyToValue(annotation) - ) - } 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 annotationReplyToValue(annotation: Pointer): String { - val parent = runCatching { api.FPDFAnnot_GetLinkedAnnot(annotation, "IRT") }.getOrNull() - if (parent != null) { - return try { - annotationStringValue(parent, "NM") - } finally { - runCatching { api.FPDFPage_CloseAnnot(parent) } - } - } - return annotationStringValue(annotation, "IRT") - } - - private fun String.cleanEmbeddedAnnotationText(): String { - return replace(Regex("<[^>]+>"), "") - .replace(" ", " ") - .replace("&", "&") - .replace("<", "<") - .replace(">", ">") - .trim() - } - - private fun insertInkAnnotation( - document: Pointer, - pageCount: Int, - annotation: SharedPdfInkAnnotationExport - ): Boolean { - if (annotation.pageIndex !in 0 until pageCount || - annotation.points.size < 2 - ) return false - - return runCatching { - loadPage(document, annotation.pageIndex).usePointer { page -> - val pageWidth = api.FPDF_GetPageWidthF(page).takeIf { it > 0f } ?: return@usePointer false - val pageHeight = api.FPDF_GetPageHeightF(page).takeIf { it > 0f } ?: return@usePointer false - val exportPoints = annotation.pdfInkAppearancePoints(pageWidth, pageHeight) - if (exportPoints.size < 2) return@usePointer false - val nativePoints = FsPointF().toArray(exportPoints.size) as Array - var minX = pageWidth - var maxX = 0f - var minY = pageHeight - var maxY = 0f - exportPoints.forEachIndexed { index, point -> - val x = point.x.coerceIn(0f, 1f) * pageWidth - val y = (1f - point.y.coerceIn(0f, 1f)) * pageHeight - nativePoints[index].x = x - nativePoints[index].y = y - nativePoints[index].write() - minX = minOf(minX, x) - maxX = maxOf(maxX, x) - minY = minOf(minY, y) - maxY = maxOf(maxY, y) - } - val strokeWidth = (annotation.strokeWidth * pageWidth).coerceAtLeast(0.25f) - val annot = api.FPDFPage_CreateAnnot(page, FPDF_ANNOT_INK) ?: return@usePointer false - try { - api.FPDFAnnot_SetRect(annot, pdfRect(minX, maxY, maxX, minY, strokeWidth * 1.5f)) - val color = annotation.colorArgb.toPdfiumRgba().let { rgba -> - if (annotation.tool == PdfInkTool.HIGHLIGHTER || annotation.tool == PdfInkTool.HIGHLIGHTER_ROUND) { - rgba.withDefaultAlpha(102) - } else { - rgba - } - } - api.FPDFAnnot_SetColor(annot, FPDF_ANNOT_COLOR, color.r, color.g, color.b, color.a) - api.FPDFAnnot_SetBorder(annot, 0f, 0f, strokeWidth) - runCatching { api.FPDFAnnot_SetFlags(annot, FPDF_ANNOT_FLAG_PRINT) } - setPdfiumAnnotationString(annot, "NM", annotation.id) - annotation.contents.takeIf { it.isNotBlank() }?.let { contents -> - setPdfiumAnnotationString(annot, "Contents", contents) - } - val added = api.FPDFAnnot_AddInkStroke( - annot, - nativePoints.first(), - NativeLong(exportPoints.size.toLong()) - ) >= 0 - runCatching { api.FPDFPage_GenerateContent(page) } - added - } finally { - api.FPDFPage_CloseAnnot(annot) - } - } - }.getOrDefault(false) - } - - private fun insertHighlightAnnotation( - document: Pointer, - pageCount: Int, - annotation: SharedPdfHighlightAnnotationExport - ): Boolean { - if (annotation.pageIndex !in 0 until pageCount) return false - val bounds = annotation.boundsList - if (bounds.isEmpty()) return false - - return runCatching { - loadPage(document, annotation.pageIndex).usePointer { page -> - val pageWidth = api.FPDF_GetPageWidthF(page).takeIf { it > 0f } ?: return@usePointer false - val pageHeight = api.FPDF_GetPageHeightF(page).takeIf { it > 0f } ?: return@usePointer false - val quads = bounds.mapNotNull { bound -> - val left = minOf(bound.left, bound.right).coerceIn(0f, 1f) * pageWidth - val right = maxOf(bound.left, bound.right).coerceIn(0f, 1f) * pageWidth - val top = (1f - minOf(bound.top, bound.bottom).coerceIn(0f, 1f)) * pageHeight - val bottom = (1f - maxOf(bound.top, bound.bottom).coerceIn(0f, 1f)) * pageHeight - if (right <= left || top <= bottom) { - null - } else { - FsQuadPointsF(left, top, right, top, left, bottom, right, bottom) - } - } - if (quads.isEmpty()) return@usePointer false - - val annot = api.FPDFPage_CreateAnnot(page, FPDF_ANNOT_HIGHLIGHT) ?: return@usePointer false - try { - var unionLeft = quads.first().x1 - var unionRight = quads.first().x2 - var unionTop = quads.first().y1 - var unionBottom = quads.first().y3 - var appendedAll = true - quads.forEach { quad -> - unionLeft = minOf(unionLeft, quad.x1, quad.x3) - unionRight = maxOf(unionRight, quad.x2, quad.x4) - unionTop = maxOf(unionTop, quad.y1, quad.y2) - unionBottom = minOf(unionBottom, quad.y3, quad.y4) - quad.write() - appendedAll = api.FPDFAnnot_AppendAttachmentPoints(annot, quad) != 0 && appendedAll - } - api.FPDFAnnot_SetRect(annot, pdfRect(unionLeft, unionTop, unionRight, unionBottom, 1f)) - val color = annotation.colorArgb.toPdfiumRgba().withDefaultAlpha(102) - api.FPDFAnnot_SetColor(annot, FPDF_ANNOT_COLOR, color.r, color.g, color.b, color.a) - runCatching { api.FPDFAnnot_SetFlags(annot, FPDF_ANNOT_FLAG_PRINT) } - setPdfiumAnnotationString(annot, "NM", annotation.id) - annotation.contents.takeIf { it.isNotBlank() }?.let { contents -> - setPdfiumAnnotationString(annot, "Contents", contents) - } - val insertedComments = insertHighlightComments( - page = page, - highlightAnnot = annot, - annotation = annotation, - unionRight = unionRight, - unionTop = unionTop, - pageWidth = pageWidth, - pageHeight = pageHeight - ) - runCatching { api.FPDFPage_GenerateContent(page) } - appendedAll && insertedComments - } finally { - api.FPDFPage_CloseAnnot(annot) - } - } - }.getOrDefault(false) - } - - private fun insertHighlightComments( - page: Pointer, - highlightAnnot: Pointer, - annotation: SharedPdfHighlightAnnotationExport, - unionRight: Float, - unionTop: Float, - pageWidth: Float, - pageHeight: Float - ): Boolean { - if (annotation.comments.isEmpty()) return true - - val commentAnnots = mutableListOf() - val commentAnnotsById = mutableMapOf() - var insertedAll = true - try { - annotation.comments.forEach { comment -> - val commentAnnot = api.FPDFPage_CreateAnnot(page, FPDF_ANNOT_TEXT) - if (commentAnnot == null) { - insertedAll = false - return@forEach - } - commentAnnots += commentAnnot - commentAnnotsById[comment.id] = commentAnnot - - api.FPDFAnnot_SetRect( - commentAnnot, - pdfCommentRect( - anchorRight = unionRight, - anchorTop = unionTop, - pageWidth = pageWidth, - pageHeight = pageHeight, - commentIndex = 0 - ) - ) - val color = annotation.colorArgb.toPdfiumRgba().copy(a = 255) - api.FPDFAnnot_SetColor(commentAnnot, FPDF_ANNOT_COLOR, color.r, color.g, color.b, color.a) - runCatching { api.FPDFAnnot_SetFlags(commentAnnot, FPDF_ANNOT_FLAG_PRINT) } - setPdfiumAnnotationString(commentAnnot, "NM", comment.id) - comment.author.takeIf { it.isNotBlank() }?.let { setPdfiumAnnotationString(commentAnnot, "T", it) } - setPdfiumAnnotationString(commentAnnot, "Contents", comment.contents) - comment.createdAt.toPdfDateString().takeIf { it.isNotBlank() }?.let { - setPdfiumAnnotationString(commentAnnot, "CreationDate", it) - } - val modifiedDate = comment.modifiedAt.toPdfDateString() - .ifBlank { comment.createdAt.toPdfDateString() } - modifiedDate.takeIf { it.isNotBlank() }?.let { setPdfiumAnnotationString(commentAnnot, "M", it) } - - val parent = comment.parentId?.let(commentAnnotsById::get) ?: highlightAnnot - runCatching { api.FPDFAnnot_SetLinkedAnnot(commentAnnot, "IRT", parent) } - } - } finally { - commentAnnots.forEach { commentAnnot -> - runCatching { api.FPDFPage_CloseAnnot(commentAnnot) } - } - } - return insertedAll - } - - private fun insertRasterOverlay( - document: Pointer, - pageCount: Int, - overlay: DesktopPdfRasterOverlay, - resources: MutableList - ): Boolean { - if (overlay.pageIndex !in 0 until pageCount || - overlay.width <= 0 || - overlay.height <= 0 || - overlay.pixels.isEmpty() - ) return false - - return runCatching { - loadPage(document, overlay.pageIndex).usePointer { page -> - val pageWidth = api.FPDF_GetPageWidthF(page).takeIf { it > 0f } ?: return@usePointer false - val pageHeight = api.FPDF_GetPageHeightF(page).takeIf { it > 0f } ?: return@usePointer false - val left = overlay.left.coerceIn(0f, 1f) * pageWidth - val top = (1f - overlay.top.coerceIn(0f, 1f)) * pageHeight - val right = overlay.right.coerceIn(0f, 1f) * pageWidth - val bottom = (1f - overlay.bottom.coerceIn(0f, 1f)) * pageHeight - val rect = pdfRect(left, top, right, bottom, 0f) - val rectWidth = rect.right - rect.left - val rectHeight = rect.top - rect.bottom - if (rectWidth <= 0.5f || rectHeight <= 0.5f) return@usePointer false - - val pixelMemory = Memory(overlay.pixels.size * 4L) - pixelMemory.write(0, overlay.pixels, 0, overlay.pixels.size) - val bitmap = api.FPDFBitmap_CreateEx( - overlay.width, - overlay.height, - FPDF_BITMAP_BGRA, - pixelMemory, - overlay.width * 4 - ) ?: return@usePointer false - val imageObject = api.FPDFPageObj_NewImageObj(document) ?: run { - api.FPDFBitmap_Destroy(bitmap) - return@usePointer false - } - val pages = PointerByReference(page) - val assigned = api.FPDFImageObj_SetBitmap(pages, 1, imageObject, bitmap) != 0 - val positioned = assigned && positionRasterImageObject( - imageObject = imageObject, - width = rectWidth.toDouble(), - height = rectHeight.toDouble(), - left = rect.left.toDouble(), - bottom = rect.bottom.toDouble() - ) - if (!positioned) { - api.FPDFBitmap_Destroy(bitmap) - return@usePointer false - } - api.FPDFPage_InsertObject(page, imageObject) - resources += DesktopPdfRasterResource(bitmap = bitmap, memory = pixelMemory) - api.FPDFPage_GenerateContent(page) != 0 - } - }.getOrDefault(false) - } - - private fun positionRasterImageObject( - imageObject: Pointer, - width: Double, - height: Double, - left: Double, - bottom: Double - ): Boolean { - return runCatching { - api.FPDFImageObj_SetMatrix(imageObject, width, 0.0, 0.0, height, left, bottom) != 0 - }.getOrElse { - runCatching { - api.FPDFPageObj_Transform(imageObject, width, 0.0, 0.0, height, left, bottom) - true - }.getOrDefault(false) - } - } - - private fun savePdfDocument(document: Pointer, destination: File) { - destination.parentFile?.mkdirs() - FileOutputStream(destination).use { output -> - val callback = FpdfWriteBlockCallback { _, data, size -> - if (data == null) { - 0 - } else { - runCatching { - val byteCount = size.toLong().coerceAtMost(Int.MAX_VALUE.toLong()).toInt() - output.write(data.getByteArray(0, byteCount)) - 1 - }.getOrDefault(0) - } - } - val writer = FpdfFileWrite().apply { - version = 1 - writeBlock = callback - write() - } - val saved = api.FPDF_SaveAsCopy(document, writer, NativeLong(FPDF_NO_INCREMENTAL)) - if (saved == 0) { - error("PDFium failed to write annotated PDF.") - } - } - } - - private fun setPdfiumAnnotationString(annotation: Pointer, key: String, value: String) { - val bytes = (value + "\u0000").toByteArray(Charsets.UTF_16LE) - val memory = Memory(bytes.size.toLong()) - memory.write(0, bytes, 0, bytes.size) - api.FPDFAnnot_SetStringValue(annotation, key, memory) - } - - private fun pdfRect(left: Float, top: Float, right: Float, bottom: Float, padding: Float): FsRectF { - return FsRectF( - left = minOf(left, right) - padding, - top = maxOf(top, bottom) + padding, - right = maxOf(left, right) + padding, - bottom = minOf(top, bottom) - padding - ).also { it.write() } - } - - private fun pdfCommentRect( - anchorRight: Float, - anchorTop: Float, - pageWidth: Float, - pageHeight: Float, - commentIndex: Int - ): FsRectF { - val iconSize = minOf(18f, maxOf(10f, pageWidth * 0.03f)) - val left = (anchorRight + 2f).coerceIn(0f, (pageWidth - iconSize).coerceAtLeast(0f)) - var top = anchorTop - commentIndex * (iconSize + 2f) - if (top > pageHeight) top = pageHeight - if (top - iconSize < 0f) top = minOf(pageHeight, iconSize) - return pdfRect(left, top, left + iconSize, top - iconSize, 0f) - } - - private fun Long.toPdfDateString(): String { - if (this <= 0L) return "" - return SimpleDateFormat("'D:'yyyyMMddHHmmss'Z'", Locale.US).apply { - timeZone = TimeZone.getTimeZone("UTC") - }.format(Date(this)) - } - - private fun Int.toPdfiumRgba(): PdfiumRgba { - return PdfiumRgba( - r = (this ushr 16) and 0xFF, - g = (this ushr 8) and 0xFF, - b = this and 0xFF, - a = (this ushr 24) and 0xFF - ) - } - - private fun loadPage(document: Pointer, pageIndex: Int): PointerResource { - val page = api.FPDF_LoadPage(document, pageIndex) - ?: error("Pdfium could not open page ${pageIndex + 1}.") - return PointerResource(page, api::FPDF_ClosePage) - } - - private fun pageSizeByIndex(document: Pointer, pageIndex: Int): DesktopPdfPageSize? { - val width = DoubleArray(1) - val height = DoubleArray(1) - val loaded = runCatching { - api.FPDF_GetPageSizeByIndex(document, pageIndex, width, height) - }.getOrDefault(0) - return if (loaded != 0 && width[0] > 0.0 && height[0] > 0.0) { - DesktopPdfPageSize(width[0].toFloat(), height[0].toFloat()) - } else { - null - } - } - - private fun initLibrary() { - if (!initialized) { - api.FPDF_InitLibrary() - initialized = true - } - } - - private fun resolvePdfiumDll(): File { - val overridePath = System.getProperty("reader.pdfium.path") - ?: System.getenv("READER_PDFIUM_PATH") - ?: System.getProperty("reader.pdfium.dll") - ?: System.getenv("READER_PDFIUM_DLL") - if (!overridePath.isNullOrBlank()) { - return File(overridePath).absoluteFile - } - - val platform = currentDesktopPlatform() - val relativePath = desktopPdfiumRelativePath(platform) - val resourceDir = System.getProperty(ComposeApplicationResourcesDirProperty) - ?.takeIf { it.isNotBlank() } - ?.let(::File) - resourceDir?.resolve(relativePath)?.absoluteFile?.takeIf { it.exists() }?.let { return it } - - val roots = generateSequence(File(System.getProperty("user.dir")).absoluteFile) { it.parentFile } - .take(6) - .toList() - - return roots - .map { File(it, relativePath).absoluteFile } - .firstOrNull { it.exists() } - ?: File(File(System.getProperty("user.dir")).absoluteFile, relativePath).absoluteFile - } - - private fun desktopPdfiumRelativePath(platform: DesktopPlatform): String { - return listOf( - "third_party", - "pdfium", - platform.pdfiumDirectoryName, - platform.pdfiumLibraryDirectoryName, - platform.pdfiumLibraryFileName - ).joinToString(File.separator) - } - - private fun missingPdfiumLibraryMessage(expectedFile: File): String { - val platform = currentDesktopPlatform() - return "Missing Pdfium library for ${platform.os.name.lowercase()}-${platform.architecture.resourceName}. " + - "Expected ${expectedFile.absolutePath}. You can also set reader.pdfium.path or READER_PDFIUM_PATH." - } - - private fun Memory.toBufferedImage(width: Int, height: Int, stride: Int): BufferedImage { - val image = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB) - val buffer = getByteBuffer(0, size()).order(ByteOrder.LITTLE_ENDIAN) - val pixels = IntArray(width * height) - for (y in 0 until height) { - buffer.position(y * stride) - for (x in 0 until width) { - val b = buffer.get().toInt() and 0xFF - val g = buffer.get().toInt() and 0xFF - val r = buffer.get().toInt() and 0xFF - val a = buffer.get().toInt() and 0xFF - pixels[y * width + x] = (a shl 24) or (r shl 16) or (g shl 8) or b - } - } - image.setRGB(0, 0, width, height, pixels, 0, width) - return image - } - - private fun Pointer.toDesktopReflowImage(width: Int, height: Int, stride: Int): BufferedImage { - val image = BufferedImage(width, height, BufferedImage.TYPE_INT_RGB) - val buffer = getByteBuffer(0, stride.toLong() * height.toLong()).order(ByteOrder.LITTLE_ENDIAN) - val pixels = IntArray(width * height) - val bytesPerPixel = (stride / width.coerceAtLeast(1)).coerceAtLeast(1) - for (y in 0 until height) { - val rowStart = y * stride - for (x in 0 until width) { - val offset = x * bytesPerPixel - val b: Int - val g: Int - val r: Int - val a: Int - if (bytesPerPixel >= 3) { - b = buffer.get(rowStart + offset).toInt() and 0xFF - g = buffer.get(rowStart + offset + 1).toInt() and 0xFF - r = buffer.get(rowStart + offset + 2).toInt() and 0xFF - a = if (bytesPerPixel >= 4) buffer.get(rowStart + offset + 3).toInt() and 0xFF else 255 - } else { - val gray = buffer.get(rowStart + offset).toInt() and 0xFF - b = gray - g = gray - r = gray - a = 255 - } - val alpha = a / 255f - val outR = (r * alpha + 255f * (1f - alpha)).roundToInt().coerceIn(0, 255) - val outG = (g * alpha + 255f * (1f - alpha)).roundToInt().coerceIn(0, 255) - val outB = (b * alpha + 255f * (1f - alpha)).roundToInt().coerceIn(0, 255) - pixels[y * width + x] = (outR shl 16) or (outG shl 8) or outB - } - } - image.setRGB(0, 0, width, height, pixels, 0, width) - return image - } - - private fun Char.isDesktopPdfReflowJunk(): Boolean { - val type = Character.getType(this) - return code == 0xFFFE || - code == 0xFFFF || - code == 0xFFFD || - type == Character.PRIVATE_USE.toInt() || - type == Character.SURROGATE.toInt() || - type == Character.UNASSIGNED.toInt() || - (type == Character.CONTROL.toInt() && code > 31) - } - - 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) { - logDesktopDiagnostic("DesktopPdfiumOpen") { message } - } - - private fun logPdfiumLink(message: String) { - logDesktopDiagnostic("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, - val path: String = "", - val password: String? = null - ) - - private data class DesktopPdfRasterResource( - val bitmap: Pointer, - @Suppress("unused") val memory: Memory - ) - - private data class PdfiumRgba( - val r: Int, - val g: Int, - val b: Int, - val a: Int - ) { - fun withDefaultAlpha(defaultAlpha: Int): PdfiumRgba { - return if (a == 255) copy(a = defaultAlpha.coerceIn(0, 255)) else this - } - } - - @Suppress("MemberVisibilityCanBePrivate") - open class FsRectF() : Structure() { - @JvmField var left: Float = 0f - @JvmField var top: Float = 0f - @JvmField var right: Float = 0f - @JvmField var bottom: Float = 0f - - constructor(left: Float, top: Float, right: Float, bottom: Float) : this() { - this.left = left - this.top = top - this.right = right - this.bottom = bottom - } - - override fun getFieldOrder(): List = listOf("left", "top", "right", "bottom") - } - - @Suppress("MemberVisibilityCanBePrivate") - open class FsPointF() : Structure() { - @JvmField var x: Float = 0f - @JvmField var y: Float = 0f - - override fun getFieldOrder(): List = listOf("x", "y") - } - - @Suppress("MemberVisibilityCanBePrivate") - open class FsQuadPointsF() : Structure() { - @JvmField var x1: Float = 0f - @JvmField var y1: Float = 0f - @JvmField var x2: Float = 0f - @JvmField var y2: Float = 0f - @JvmField var x3: Float = 0f - @JvmField var y3: Float = 0f - @JvmField var x4: Float = 0f - @JvmField var y4: Float = 0f - - constructor( - x1: Float, - y1: Float, - x2: Float, - y2: Float, - x3: Float, - y3: Float, - x4: Float, - y4: Float - ) : this() { - this.x1 = x1 - this.y1 = y1 - this.x2 = x2 - this.y2 = y2 - this.x3 = x3 - this.y3 = y3 - this.x4 = x4 - this.y4 = y4 - } - - override fun getFieldOrder(): List = listOf("x1", "y1", "x2", "y2", "x3", "y3", "x4", "y4") - } - - fun interface FpdfWriteBlockCallback : Callback { - fun invoke(fileWrite: Pointer?, data: Pointer?, size: NativeLong): Int - } - - @Suppress("MemberVisibilityCanBePrivate") - open class FpdfFileWrite : Structure() { - @JvmField var version: Int = 1 - @JvmField var writeBlock: FpdfWriteBlockCallback? = null - - override fun getFieldOrder(): List = listOf("version", "writeBlock") - } - - private class PointerResource( - private val pointer: Pointer, - private val closer: (Pointer) -> Unit - ) { - fun usePointer(block: (Pointer) -> T): T { - try { - return block(pointer) - } finally { - closer(pointer) - } - } - } - - 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 FPDF_GetPageSizeByIndex(document: Pointer, pageIndex: Int, width: DoubleArray, height: DoubleArray): 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_CountObjects(page: Pointer): Int - fun FPDFPage_GetObject(page: Pointer, index: Int): Pointer? - fun FPDFPageObj_GetType(pageObject: Pointer): Int - fun FPDFPageObj_GetBounds( - pageObject: Pointer, - left: FloatArray, - bottom: FloatArray, - right: FloatArray, - top: FloatArray - ): Int - fun FPDFPage_GetAnnotCount(page: Pointer): Int - fun FPDFPage_GetAnnot(page: Pointer, index: Int): Pointer? - fun FPDFPage_CreateAnnot(page: Pointer, subtype: 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 FPDFAnnot_GetLinkedAnnot(annotation: Pointer, key: String): Pointer? - fun FPDFAnnot_SetRect(annotation: Pointer, rect: FsRectF): Int - fun FPDFAnnot_SetColor(annotation: Pointer, type: Int, r: Int, g: Int, b: Int, a: Int): Int - fun FPDFAnnot_SetBorder(annotation: Pointer, horizontalRadius: Float, verticalRadius: Float, borderWidth: Float): Int - fun FPDFAnnot_SetStringValue(annotation: Pointer, key: String, value: Pointer): Int - fun FPDFAnnot_SetLinkedAnnot(annotation: Pointer, key: String, linkedAnnotation: Pointer): Int - fun FPDFAnnot_AddInkStroke(annotation: Pointer, points: FsPointF, pointCount: NativeLong): Int - fun FPDFAnnot_AppendAttachmentPoints(annotation: Pointer, quadPoints: FsQuadPointsF): Int - fun FPDFAnnot_SetFlags(annotation: Pointer, flags: Int): Int - fun FPDFPage_InsertObject(page: Pointer, pageObject: Pointer) - fun FPDFPageObj_NewImageObj(document: Pointer): Pointer? - fun FPDFImageObj_SetMatrix( - imageObject: Pointer, - a: Double, - b: Double, - c: Double, - d: Double, - e: Double, - f: Double - ): Int - fun FPDFPageObj_Transform( - pageObject: Pointer, - a: Double, - b: Double, - c: Double, - d: Double, - e: Double, - f: Double - ) - fun FPDFImageObj_SetBitmap(pages: PointerByReference, pageCount: Int, imageObject: Pointer, bitmap: Pointer): Int - fun FPDFPage_GenerateContent(page: Pointer): Int - fun FPDF_SaveAsCopy(document: Pointer, writer: FpdfFileWrite, flags: NativeLong): Int - fun FPDFImageObj_GetBitmap(imageObject: Pointer): Pointer? - fun FPDFBitmap_GetWidth(bitmap: Pointer): Int - fun FPDFBitmap_GetHeight(bitmap: Pointer): Int - fun FPDFBitmap_GetStride(bitmap: Pointer): Int - fun FPDFBitmap_GetBuffer(bitmap: Pointer): Pointer? - 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) - fun FPDF_RenderPageBitmap( - bitmap: Pointer, - page: Pointer, - startX: Int, - startY: Int, - sizeX: Int, - sizeY: Int, - rotate: Int, - flags: Int - ) - - fun FPDFText_LoadPage(page: Pointer): Pointer? - 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_GetFontSize(textPage: Pointer, index: Int): Double - fun FPDFText_GetFontWeight(textPage: Pointer, index: Int): Int - fun FPDFText_GetFontInfo( - textPage: Pointer, - index: Int, - buffer: Pointer?, - buflen: NativeLong, - flags: IntArray - ): NativeLong - 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/dueattendant149/bookreader/reader/desktop/DesktopPlatformPaths.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPlatformPaths.kt deleted file mode 100644 index fcb4b6f..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPlatformPaths.kt +++ /dev/null @@ -1,138 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import java.io.File -import java.util.Locale - -internal enum class DesktopOperatingSystem { - WINDOWS, - LINUX, - MACOS, - OTHER -} - -internal enum class DesktopArchitecture(val resourceName: String) { - X64("x64"), - ARM64("arm64"), - X86("x86"), - OTHER("unknown") -} - -internal data class DesktopPlatform( - val os: DesktopOperatingSystem, - val architecture: DesktopArchitecture -) { - val isLinux: Boolean get() = os == DesktopOperatingSystem.LINUX - val isWindows: Boolean get() = os == DesktopOperatingSystem.WINDOWS - - val pdfiumDirectoryName: String - get() = when (os) { - DesktopOperatingSystem.WINDOWS -> "win-${architecture.resourceName}-v8" - DesktopOperatingSystem.LINUX -> "linux-${architecture.resourceName}-v8" - DesktopOperatingSystem.MACOS -> "mac-${architecture.resourceName}-v8" - DesktopOperatingSystem.OTHER -> "${architecture.resourceName}-v8" - } - - val pdfiumLibraryFileName: String - get() = when (os) { - DesktopOperatingSystem.WINDOWS -> "pdfium.dll" - DesktopOperatingSystem.LINUX -> "libpdfium.so" - DesktopOperatingSystem.MACOS -> "libpdfium.dylib" - DesktopOperatingSystem.OTHER -> "pdfium" - } - - val pdfiumLibraryDirectoryName: String - get() = when (os) { - DesktopOperatingSystem.WINDOWS -> "bin" - DesktopOperatingSystem.LINUX, - DesktopOperatingSystem.MACOS, - DesktopOperatingSystem.OTHER -> "lib" - } -} - -internal fun currentDesktopPlatform( - osName: String = System.getProperty("os.name").orEmpty(), - osArch: String = System.getProperty("os.arch").orEmpty() -): DesktopPlatform { - return DesktopPlatform( - os = desktopOperatingSystem(osName), - architecture = desktopArchitecture(osArch) - ) -} - -internal fun desktopOperatingSystem(osName: String): DesktopOperatingSystem { - val normalized = osName.trim().lowercase(Locale.ROOT) - return when { - normalized.startsWith("windows") -> DesktopOperatingSystem.WINDOWS - normalized == "linux" || normalized.contains("linux") -> DesktopOperatingSystem.LINUX - normalized.startsWith("mac") || normalized.contains("darwin") -> DesktopOperatingSystem.MACOS - else -> DesktopOperatingSystem.OTHER - } -} - -internal fun desktopArchitecture(osArch: String): DesktopArchitecture { - return when (osArch.trim().lowercase(Locale.ROOT)) { - "amd64", "x86_64", "x64" -> DesktopArchitecture.X64 - "aarch64", "arm64" -> DesktopArchitecture.ARM64 - "x86", "i386", "i686" -> DesktopArchitecture.X86 - else -> DesktopArchitecture.OTHER - } -} - -internal fun desktopUserDataRoot( - platform: DesktopPlatform = currentDesktopPlatform(), - env: (String) -> String? = System::getenv, - userHome: String = System.getProperty("user.home").orEmpty() -): File { - return when (platform.os) { - DesktopOperatingSystem.WINDOWS -> File(windowsRoamingBase(env, userHome), "Episteme") - DesktopOperatingSystem.LINUX -> File(xdgBase("XDG_DATA_HOME", ".local/share", env, userHome), "episteme") - DesktopOperatingSystem.MACOS -> File(userHome, "Library/Application Support/Episteme") - DesktopOperatingSystem.OTHER -> File(userHome, ".episteme") - } -} - -internal fun desktopUserConfigRoot( - platform: DesktopPlatform = currentDesktopPlatform(), - env: (String) -> String? = System::getenv, - userHome: String = System.getProperty("user.home").orEmpty() -): File { - return when (platform.os) { - DesktopOperatingSystem.WINDOWS -> File(windowsRoamingBase(env, userHome), "Episteme") - DesktopOperatingSystem.LINUX -> File(xdgBase("XDG_CONFIG_HOME", ".config", env, userHome), "episteme") - DesktopOperatingSystem.MACOS -> File(userHome, "Library/Application Support/Episteme") - DesktopOperatingSystem.OTHER -> File(userHome, ".episteme") - } -} - -internal fun desktopUserCacheRoot( - platform: DesktopPlatform = currentDesktopPlatform(), - env: (String) -> String? = System::getenv, - userHome: String = System.getProperty("user.home").orEmpty() -): File { - return when (platform.os) { - DesktopOperatingSystem.WINDOWS -> File(windowsRoamingBase(env, userHome), "Episteme") - DesktopOperatingSystem.LINUX -> File(xdgBase("XDG_CACHE_HOME", ".cache", env, userHome), "episteme") - DesktopOperatingSystem.MACOS -> File(userHome, "Library/Caches/Episteme") - DesktopOperatingSystem.OTHER -> File(userHome, ".episteme/cache") - } -} - -private fun windowsRoamingBase(env: (String) -> String?, userHome: String): File { - return env("APPDATA") - ?.takeIf { it.isNotBlank() } - ?.let(::File) - ?: File(userHome, "AppData/Roaming") -} - -private fun xdgBase( - envName: String, - fallbackRelativePath: String, - env: (String) -> String?, - userHome: String -): File { - return env(envName) - ?.takeIf { it.isNotBlank() } - ?.takeIf { it.startsWith("/") } - ?.let(::File) - ?: File(userHome, fallbackRelativePath) -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPptxDocument.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPptxDocument.kt deleted file mode 100644 index 14885d2..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPptxDocument.kt +++ /dev/null @@ -1,648 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import org.dueattendant149.bookreader.shared.pptx.SharedPptxCharBox as DesktopPptxCharBox -import org.dueattendant149.bookreader.shared.pptx.SharedPptxDeck as DesktopPptxDeck -import org.dueattendant149.bookreader.shared.pptx.SharedPptxDeckCache -import org.dueattendant149.bookreader.shared.pptx.SharedPptxImageCrop as DesktopPptxImageCrop -import org.dueattendant149.bookreader.shared.pptx.SharedPptxImageElement as DesktopPptxImageElement -import org.dueattendant149.bookreader.shared.pptx.SharedPptxParagraph as DesktopPptxParagraph -import org.dueattendant149.bookreader.shared.pptx.SharedPptxRect as DesktopPptxRect -import org.dueattendant149.bookreader.shared.pptx.SharedPptxShapeElement as DesktopPptxShapeElement -import org.dueattendant149.bookreader.shared.pptx.SharedPptxSlide as DesktopPptxSlide -import org.dueattendant149.bookreader.shared.pptx.SharedPptxTableCell as DesktopPptxTableCell -import org.dueattendant149.bookreader.shared.pptx.SharedPptxTableElement as DesktopPptxTableElement -import org.dueattendant149.bookreader.shared.pptx.SharedPptxTextAlign as DesktopPptxTextAlign -import org.dueattendant149.bookreader.shared.pptx.SharedPptxTextInsets as DesktopPptxTextInsets -import org.dueattendant149.bookreader.shared.pptx.SharedPptxVerticalAnchor as DesktopPptxVerticalAnchor -import java.awt.AlphaComposite -import java.awt.BasicStroke -import java.awt.Color -import java.awt.Font -import java.awt.Graphics2D -import java.awt.RenderingHints -import java.awt.Shape -import java.awt.geom.Ellipse2D -import java.awt.geom.Line2D -import java.awt.geom.Path2D -import java.awt.geom.Rectangle2D -import java.awt.geom.RoundRectangle2D -import java.awt.image.BufferedImage -import java.io.ByteArrayInputStream -import java.io.File -import javax.imageio.ImageIO -import kotlin.math.abs -import kotlin.math.cos -import kotlin.math.max -import kotlin.math.min -import kotlin.math.roundToInt -import kotlin.math.sin - -private const val EmuPerPoint = 12_700f -private const val DefaultTextSizePoint = 18f -private const val DefaultTextMarginPoint = 91_440f / EmuPerPoint - -private val PptxWhite = pptxRgb(255, 255, 255) -private val PptxBlack = pptxRgb(0, 0, 0) -private val PptxLightGray = pptxRgb(245, 246, 248) -private val PptxGray = pptxRgb(128, 128, 128) - -internal class DesktopPptxDocument private constructor( - val path: String, - val title: String, - private val deck: DesktopPptxDeck -) { - val pageCount: Int = deck.slides.size - val pageSizes: List = deck.slides.map { slide -> - DesktopPdfPageSize(slide.widthPoint.toFloat(), slide.heightPoint.toFloat()) - } - - fun renderPageBufferedImage(pageIndex: Int, scale: Float): BufferedImage { - val slide = slideAt(pageIndex) - return DesktopPptxRenderer.render(slide, scale) - } - - fun textOnlyPage(pageIndex: Int): String { - return deck.slides.getOrNull(pageIndex)?.text.orEmpty() - } - - fun textPageData(pageIndex: Int): DesktopPdfTextPageData { - val slide = deck.slides.getOrNull(pageIndex) ?: return DesktopPdfTextPageData() - return DesktopPdfTextPageData( - text = slide.text, - chars = slide.charBoxes.mapIndexed { index, box -> - DesktopPdfTextChar( - index = index, - char = box.char, - left = (box.bounds.left / slide.widthPoint).coerceIn(0f, 1f), - top = (box.bounds.top / slide.heightPoint).coerceIn(0f, 1f), - right = (box.bounds.right / slide.widthPoint).coerceIn(0f, 1f), - bottom = (box.bounds.bottom / slide.heightPoint).coerceIn(0f, 1f) - ) - } - ) - } - - fun linkAt(pageIndex: Int, normalizedX: Float, normalizedY: Float): DesktopPdfLinkTarget? { - val slide = deck.slides.getOrNull(pageIndex) ?: return null - val pointX = normalizedX.coerceIn(0f, 1f) * slide.widthPoint - val pointY = normalizedY.coerceIn(0f, 1f) * slide.heightPoint - return slide.elements - .asReversed() - .filterIsInstance() - .firstNotNullOfOrNull { shape -> - val link = shape.hyperlink?.takeIf { it.isNotBlank() } ?: return@firstNotNullOfOrNull null - link.takeIf { shape.bounds.rotatedBounds(shape.bounds, shape.rotationDegrees).contains(pointX, pointY) } - } - ?.let { DesktopPdfLinkTarget(uri = it) } - } - - fun charIndexAt(pageIndex: Int, normalizedX: Float, normalizedY: Float, tolerance: Float): Int? { - val slide = deck.slides.getOrNull(pageIndex) ?: return null - val pointX = normalizedX.coerceIn(0f, 1f) * slide.widthPoint - val pointY = normalizedY.coerceIn(0f, 1f) * slide.heightPoint - val toleranceX = slide.widthPoint * tolerance - val toleranceY = slide.heightPoint * tolerance - slide.charBoxes.forEachIndexed { index, box -> - if (box.bounds.expanded(toleranceX, toleranceY).contains(pointX, pointY)) { - return index - } - } - return slide.charBoxes - .mapIndexedNotNull { index, box -> - if (pointY < box.bounds.top - toleranceY || pointY > box.bounds.bottom + toleranceY) { - null - } else { - index to abs(pointX - box.bounds.centerX()) - } - } - .minByOrNull { it.second } - ?.takeIf { it.second <= toleranceX * 3f } - ?.first - } - - fun textRectsForRange(pageIndex: Int, startIndex: Int, endIndex: Int): List { - val slide = deck.slides.getOrNull(pageIndex) ?: return emptyList() - if (slide.charBoxes.isEmpty()) return emptyList() - val first = min(startIndex, endIndex).coerceIn(0, slide.charBoxes.size) - val lastExclusive = (max(startIndex, endIndex) + 1).coerceIn(first, slide.charBoxes.size) - return slide.charBoxes - .subList(first, lastExclusive) - .filterNot { it.char.isWhitespace() } - .groupBy { it.bounds.top.roundToInt() } - .values - .mapNotNull { boxes -> - boxes.fold(null) { acc, box -> - acc?.union(box.bounds) ?: box.bounds - } - } - .map { rect -> - DesktopPdfTextRect( - left = (rect.left / slide.widthPoint).coerceIn(0f, 1f), - top = (rect.top / slide.heightPoint).coerceIn(0f, 1f), - right = (rect.right / slide.widthPoint).coerceIn(0f, 1f), - bottom = (rect.bottom / slide.heightPoint).coerceIn(0f, 1f) - ) - } - } - - fun close() = Unit - - private fun slideAt(pageIndex: Int): DesktopPptxSlide { - return deck.slides.getOrNull(pageIndex) ?: error("Invalid PPTX slide index $pageIndex.") - } - - companion object { - fun load(file: File): DesktopPptxDocument { - require(file.isFile) { "Missing PPTX file: ${file.absolutePath}" } - val deck = DesktopPptxDeckCache.load(file) - return DesktopPptxDocument( - path = file.absolutePath, - title = file.nameWithoutExtension, - deck = deck - ) - } - } -} - -internal object DesktopPptxDocuments { - fun load(file: File): DesktopPptxDocument = DesktopPptxDocument.load(file) -} - -private fun DesktopPptxRect.inset(insets: DesktopPptxTextInsets): DesktopPptxRect { - return DesktopPptxRect( - left = left + insets.left, - top = top + insets.top, - right = right - insets.right, - bottom = bottom - insets.bottom - ) -} - -private fun DesktopPptxRect.toAwtRect(): Rectangle2D.Float { - return Rectangle2D.Float(left, top, width(), height()) -} - -private fun DesktopPptxRect.rotatedBounds(rotationBounds: DesktopPptxRect, rotationDegrees: Float): DesktopPptxRect { - if (rotationDegrees == 0f) return this - val radians = Math.toRadians(rotationDegrees.toDouble()) - val cosValue = cos(radians).toFloat() - val sinValue = sin(radians).toFloat() - val cx = rotationBounds.centerX() - val cy = rotationBounds.centerY() - val points = arrayOf( - left to top, - right to top, - right to bottom, - left to bottom - ).map { (x, y) -> - val dx = x - cx - val dy = y - cy - (cx + dx * cosValue - dy * sinValue) to (cy + dx * sinValue + dy * cosValue) - } - return DesktopPptxRect( - left = points.minOf { it.first }, - top = points.minOf { it.second }, - right = points.maxOf { it.first }, - bottom = points.maxOf { it.second } - ) -} - -private object DesktopPptxDeckCache { - fun load(file: File): DesktopPptxDeck = SharedPptxDeckCache.load(file) -} - -private data class DesktopPptxLaidOutLine( - val text: String, - val x: Float, - val baselineY: Float, - val font: Font, - val color: Int, - val charBoxes: List -) { - fun newlineBounds(): DesktopPptxRect { - val last = charBoxes.lastOrNull()?.bounds - return if (last == null) { - DesktopPptxRect(x, baselineY, x, baselineY) - } else { - DesktopPptxRect(last.right, last.top, last.right, last.bottom) - } - } -} - -private object DesktopPptxTextLayout { - fun layout(shape: DesktopPptxShapeElement): List { - if (!shape.renderText || shape.paragraphs.isEmpty()) return emptyList() - val textBounds = shape.textBounds() - if (textBounds.width() <= 0f || textBounds.height() <= 0f) return emptyList() - val measured = withMeasureGraphics { graphics -> - val paragraphs = shape.paragraphs.mapNotNull { paragraph -> - val text = paragraph.displayText().takeIf { it.isNotBlank() } ?: return@mapNotNull null - val font = paragraph.font(shape) - graphics.font = font - val metrics = graphics.fontMetrics - val lines = text.split('\n').flatMap { rawLine -> - wrapLine(rawLine, textBounds.width()) { value -> metrics.stringWidth(value) } - }.ifEmpty { listOf("") } - PreparedPptxParagraph( - paragraph = paragraph, - lines = lines, - font = font, - color = paragraph.runs.firstOrNull()?.color ?: PptxBlack, - ascent = metrics.ascent.toFloat(), - lineHeight = metrics.height.toFloat().coerceAtLeast(1f) - ) - } - val totalHeight = paragraphs.sumOf { item -> - (item.paragraph.spaceBeforePt + item.lines.size * item.lineHeight + item.paragraph.spaceAfterPt).toDouble() - }.toFloat() - paragraphs to totalHeight - } - val paragraphs = measured.first - if (paragraphs.isEmpty()) return emptyList() - val totalHeight = measured.second - var top = when (shape.verticalAnchor) { - DesktopPptxVerticalAnchor.TOP -> textBounds.top - DesktopPptxVerticalAnchor.MIDDLE -> textBounds.top + ((textBounds.height() - totalHeight) / 2f).coerceAtLeast(0f) - DesktopPptxVerticalAnchor.BOTTOM -> textBounds.bottom - totalHeight.coerceAtMost(textBounds.height()) - } - val lines = mutableListOf() - withMeasureGraphics { graphics -> - paragraphs.forEach { paragraph -> - graphics.font = paragraph.font - val metrics = graphics.fontMetrics - top += paragraph.paragraph.spaceBeforePt - paragraph.lines.forEach { text -> - val textWidth = metrics.stringWidth(text).toFloat() - val x = when (paragraph.paragraph.alignment) { - DesktopPptxTextAlign.START -> textBounds.left - DesktopPptxTextAlign.CENTER -> textBounds.left + ((textBounds.width() - textWidth) / 2f).coerceAtLeast(0f) - DesktopPptxTextAlign.END -> textBounds.right - textWidth - } - val baseline = top + paragraph.ascent - val boxes = text.charBoxes( - x = x, - top = top, - bottom = top + paragraph.lineHeight, - metrics = { char -> metrics.charWidth(char) }, - rotationBounds = shape.bounds, - rotationDegrees = shape.rotationDegrees - ) - lines += DesktopPptxLaidOutLine( - text = text, - x = x, - baselineY = baseline, - font = paragraph.font, - color = paragraph.color, - charBoxes = boxes - ) - top += paragraph.lineHeight - } - top += paragraph.paragraph.spaceAfterPt - } - } - return lines - } - - private fun wrapLine(rawLine: String, maxWidth: Float, measure: (String) -> Int): List { - if (rawLine.isBlank()) return listOf(rawLine) - if (measure(rawLine) <= maxWidth) return listOf(rawLine) - val lines = mutableListOf() - var current = "" - rawLine.split(Regex("(?<=\\s)|(?=\\s)")).forEach { token -> - val candidate = current + token - when { - candidate.isBlank() -> current = candidate - measure(candidate) <= maxWidth || current.isBlank() -> current = candidate - else -> { - lines += current.trimEnd() - current = token.trimStart() - } - } - } - if (current.isNotBlank()) lines += current.trimEnd() - return lines.ifEmpty { listOf(rawLine.take(1)) } - } -} - -private data class PreparedPptxParagraph( - val paragraph: DesktopPptxParagraph, - val lines: List, - val font: Font, - val color: Int, - val ascent: Float, - val lineHeight: Float -) - -private object DesktopPptxRenderer { - fun render(slide: DesktopPptxSlide, scale: Float): BufferedImage { - val safeScale = scale.takeIf { it.isFinite() && it > 0f } ?: 1f - val width = (slide.widthPoint * safeScale).roundToInt().coerceAtLeast(1) - val height = (slide.heightPoint * safeScale).roundToInt().coerceAtLeast(1) - val image = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB) - val graphics = image.createGraphics() - try { - graphics.enablePptxRenderingHints() - graphics.color = (slide.backgroundColor ?: PptxWhite).toAwtColor() - graphics.fillRect(0, 0, width, height) - graphics.scale( - width.toDouble() / slide.widthPoint.toDouble().coerceAtLeast(1.0), - height.toDouble() / slide.heightPoint.toDouble().coerceAtLeast(1.0) - ) - slide.elements.forEach { element -> - when (element) { - is DesktopPptxShapeElement -> graphics.drawShape(element) - is DesktopPptxImageElement -> graphics.drawImageElement(element) - is DesktopPptxTableElement -> graphics.drawTable(element) - } - } - } finally { - graphics.dispose() - } - return image - } - - private fun Graphics2D.drawShape(shape: DesktopPptxShapeElement) { - withRotation(shape.bounds, shape.rotationDegrees) { - val geometry = shape.geometry() - val fillColor = shape.fillColor - if (shape.preset != "line" && fillColor != null && fillColor.pptxAlpha() > 0) { - paint = fillColor.toAwtColor() - fill(geometry) - } - val lineColor = shape.lineColor - if (lineColor != null && lineColor.pptxAlpha() > 0) { - color = lineColor.toAwtColor() - stroke = BasicStroke(shape.lineWidthPoint.coerceAtLeast(0.25f)) - draw(geometry) - } - drawShapeText(shape) - } - } - - private fun Graphics2D.drawShapeText(shape: DesktopPptxShapeElement) { - if (!shape.renderText) return - val oldClip = clip - clip = shape.textBounds().toAwtRect() - try { - DesktopPptxTextLayout.layout(shape).forEach { line -> - font = line.font - color = line.color.toAwtColor() - drawString(line.text, line.x, line.baselineY) - } - } finally { - clip = oldClip - } - } - - private fun Graphics2D.drawImageElement(image: DesktopPptxImageElement) { - withRotation(image.bounds, image.rotationDegrees) { - val source = ByteArrayInputStream(image.bytes).use { input -> ImageIO.read(input) } - if (source == null) { - drawImagePlaceholder(image.bounds) - return@withRotation - } - val oldComposite = composite - composite = AlphaComposite.getInstance(AlphaComposite.SRC_OVER, image.opacity.coerceIn(0f, 1f)) - try { - val sourceRect = image.crop.sourceRect(source.width, source.height) - drawImage( - source, - image.bounds.left.roundToInt(), - image.bounds.top.roundToInt(), - image.bounds.right.roundToInt(), - image.bounds.bottom.roundToInt(), - sourceRect.left.roundToInt(), - sourceRect.top.roundToInt(), - sourceRect.right.roundToInt(), - sourceRect.bottom.roundToInt(), - null - ) - } finally { - composite = oldComposite - source.flush() - } - } - } - - private fun Graphics2D.drawTable(table: DesktopPptxTableElement) { - withRotation(table.bounds, table.rotationDegrees) { - layoutTableCells(table).forEach { laidOutCell -> - val rect = laidOutCell.rect - val cell = laidOutCell.cell - cell.fillColor?.takeIf { it.pptxAlpha() > 0 }?.let { - paint = it.toAwtColor() - fill(rect.toAwtRect()) - } - cell.lineColor?.takeIf { it.pptxAlpha() > 0 }?.let { - color = it.toAwtColor() - stroke = BasicStroke(0.5f) - draw(rect.toAwtRect()) - } - drawShapeText(laidOutCell.asShape()) - } - } - } - - private fun Graphics2D.drawImagePlaceholder(bounds: DesktopPptxRect) { - color = PptxLightGray.toAwtColor() - fill(bounds.toAwtRect()) - color = PptxGray.toAwtColor() - stroke = BasicStroke(0.75f) - draw(bounds.toAwtRect()) - } - - private fun Graphics2D.withRotation(bounds: DesktopPptxRect, rotationDegrees: Float, block: Graphics2D.() -> Unit) { - val oldTransform = transform - try { - if (rotationDegrees != 0f) { - rotate(Math.toRadians(rotationDegrees.toDouble()), bounds.centerX().toDouble(), bounds.centerY().toDouble()) - } - block() - } finally { - transform = oldTransform - } - } -} - -private data class LaidOutDesktopPptxTableCell( - val rect: DesktopPptxRect, - val cell: DesktopPptxTableCell -) - -private fun layoutTableCells(table: DesktopPptxTableElement): List { - if (table.rows.isEmpty() || table.bounds.width() <= 0f || table.bounds.height() <= 0f) return emptyList() - val explicitHeight = table.rows - .mapNotNull { it.heightPoint?.takeIf { height -> height > 0f } } - .sumOf { it.toDouble() } - .toFloat() - val missingRows = table.rows.count { row -> - val heightPoint = row.heightPoint - heightPoint == null || heightPoint <= 0f - } - val fallbackHeight = if (missingRows > 0) { - ((table.bounds.height() - explicitHeight).coerceAtLeast(1f)) / missingRows - } else { - table.bounds.height() / table.rows.size - } - val cells = mutableListOf() - var y = table.bounds.top - table.rows.forEach { row -> - val rowHeight = row.heightPoint?.takeIf { it > 0f } ?: fallbackHeight - val explicitWidth = row.cells - .mapNotNull { it.widthPoint?.takeIf { width -> width > 0f } } - .sumOf { it.toDouble() } - .toFloat() - val missingCells = row.cells.count { cell -> - val widthPoint = cell.widthPoint - widthPoint == null || widthPoint <= 0f - } - val fallbackWidth = if (missingCells > 0) { - ((table.bounds.width() - explicitWidth).coerceAtLeast(1f)) / missingCells - } else if (row.cells.isNotEmpty()) { - table.bounds.width() / row.cells.size - } else { - table.bounds.width() - } - var x = table.bounds.left - row.cells.forEach { cell -> - val cellWidth = cell.widthPoint?.takeIf { it > 0f } ?: fallbackWidth - cells += LaidOutDesktopPptxTableCell( - rect = DesktopPptxRect(x, y, x + cellWidth, y + rowHeight), - cell = cell - ) - x += cellWidth - } - y += rowHeight - } - return cells -} - -private fun LaidOutDesktopPptxTableCell.asShape(): DesktopPptxShapeElement { - return DesktopPptxShapeElement( - bounds = rect, - preset = "rect", - fillColor = cell.fillColor, - lineColor = cell.lineColor, - lineWidthPoint = 0.75f, - paragraphs = cell.paragraphs, - hyperlink = null, - placeholderKey = null, - textInsets = cell.textInsets, - verticalAnchor = cell.verticalAnchor - ) -} - -private fun DesktopPptxShapeElement.textBounds(): DesktopPptxRect { - return bounds.inset(textInsets) -} - -private fun DesktopPptxShapeElement.geometry(): Shape { - val shapeBounds = bounds - val rect = shapeBounds.toAwtRect() - return when (preset) { - "line" -> Line2D.Float(shapeBounds.left, shapeBounds.top, shapeBounds.right, shapeBounds.bottom) - "ellipse" -> Ellipse2D.Float(shapeBounds.left, shapeBounds.top, shapeBounds.width(), shapeBounds.height()) - "roundrect", "roundRect" -> RoundRectangle2D.Float( - shapeBounds.left, - shapeBounds.top, - shapeBounds.width(), - shapeBounds.height(), - shapeBounds.width() * 0.16f, - shapeBounds.height() * 0.16f - ) - "triangle" -> Path2D.Float().apply { - moveTo(shapeBounds.centerX(), shapeBounds.top) - lineTo(shapeBounds.right, shapeBounds.bottom) - lineTo(shapeBounds.left, shapeBounds.bottom) - closePath() - } - "diamond" -> Path2D.Float().apply { - moveTo(shapeBounds.centerX(), shapeBounds.top) - lineTo(shapeBounds.right, shapeBounds.centerY()) - lineTo(shapeBounds.centerX(), shapeBounds.bottom) - lineTo(shapeBounds.left, shapeBounds.centerY()) - closePath() - } - else -> rect - } -} - -private fun DesktopPptxParagraph.displayText(): String { - val text = runs.joinToString("") { it.text } - val prefix = bullet?.takeIf { it.isNotBlank() }?.let { "$it " }.orEmpty() - return prefix + text -} - -private fun DesktopPptxParagraph.font(shape: DesktopPptxShapeElement): Font { - val firstRun = runs.firstOrNull() - val style = (if (firstRun?.bold == true) Font.BOLD else Font.PLAIN) or - (if (firstRun?.italic == true) Font.ITALIC else Font.PLAIN) - val family = firstRun?.typeface - ?.takeIf { it.isNotBlank() && !it.startsWith("+") } - ?: Font.SANS_SERIF - val size = ((firstRun?.sizePt ?: DefaultTextSizePoint) * shape.fontScale.coerceIn(0.4f, 2f)) - .roundToInt() - .coerceAtLeast(1) - return Font(family, style, size) -} - -private fun String.charBoxes( - x: Float, - top: Float, - bottom: Float, - metrics: (Char) -> Int, - rotationBounds: DesktopPptxRect, - rotationDegrees: Float -): List { - val boxes = mutableListOf() - var left = x - forEach { char -> - val advance = metrics(char).toFloat().coerceAtLeast(0.5f) - val rect = DesktopPptxRect(left, top, left + advance, bottom) - .rotatedBounds(rotationBounds, rotationDegrees) - boxes += DesktopPptxCharBox(char, rect) - left += advance - } - return boxes -} - -private inline fun withMeasureGraphics(block: (Graphics2D) -> T): T { - val image = BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB) - val graphics = image.createGraphics() - return try { - graphics.enablePptxRenderingHints() - block(graphics) - } finally { - graphics.dispose() - } -} - -private fun Graphics2D.enablePptxRenderingHints() { - setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) - setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON) - setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR) - setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY) -} - -private fun DesktopPptxImageCrop.sourceRect(width: Int, height: Int): DesktopPptxRect { - val leftPx = (width * left).coerceIn(0f, (width - 1).toFloat()) - val topPx = (height * top).coerceIn(0f, (height - 1).toFloat()) - val rightPx = (width * (1f - right)).coerceIn(leftPx + 1f, width.toFloat()) - val bottomPx = (height * (1f - bottom)).coerceIn(topPx + 1f, height.toFloat()) - return DesktopPptxRect(leftPx, topPx, rightPx, bottomPx) -} - -private fun pptxRgb(red: Int, green: Int, blue: Int): Int = pptxArgb(255, red, green, blue) - -private fun pptxArgb(alpha: Int, red: Int, green: Int, blue: Int): Int { - return ((alpha and 0xFF) shl 24) or - ((red and 0xFF) shl 16) or - ((green and 0xFF) shl 8) or - (blue and 0xFF) -} - -private fun Int.pptxAlpha(): Int = (this ushr 24) and 0xFF -private fun Int.pptxRed(): Int = (this ushr 16) and 0xFF -private fun Int.pptxGreen(): Int = (this ushr 8) and 0xFF -private fun Int.pptxBlue(): Int = this and 0xFF -private fun Int.toAwtColor(): Color = Color(this, true) - diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopProScreen.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopProScreen.kt deleted file mode 100644 index 655ae2f..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopProScreen.kt +++ /dev/null @@ -1,160 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import androidx.compose.foundation.BorderStroke -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.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.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.filled.Star -import androidx.compose.material.icons.filled.Verified -import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import org.dueattendant149.bookreader.shared.UserData -import org.dueattendant149.bookreader.shared.ui.readerString - -@Composable -internal fun DesktopProScreen( - user: UserData?, - isProUser: Boolean, - credits: Int, - authConfigured: Boolean, - isBusy: Boolean, - statusMessage: String?, - onSignIn: () -> Unit, - onSignOut: () -> Unit, - onRefresh: () -> Unit -) { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 28.dp, vertical = 24.dp), - verticalArrangement = Arrangement.spacedBy(18.dp) - ) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Icon(Icons.Default.Star, contentDescription = null, modifier = Modifier.size(30.dp), tint = MaterialTheme.colorScheme.primary) - Column(Modifier.weight(1f)) { - Text(readerString("desktop_account_and_credits", "Account & credits"), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) - Text( - readerString("desktop_pro_sign_in_desc", "Sign in to check your account status on desktop."), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - - Surface( - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.medium, - color = MaterialTheme.colorScheme.surface, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) - ) { - Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { - Icon(Icons.Default.Verified, contentDescription = null, tint = MaterialTheme.colorScheme.primary) - Text(readerString("desktop_account_overview", "Account overview"), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) - } - if (user == null) { - Text( - if (authConfigured) { - readerString("desktop_no_google_account_connected", "No Google account is connected.") - } else { - readerString("desktop_google_sign_in_not_configured", "Google sign-in is not configured for this desktop build.") - }, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Button(onClick = onSignIn, enabled = authConfigured && !isBusy) { - if (isBusy) { - CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) - Spacer(Modifier.size(8.dp)) - } - Text(readerString("drawer_sign_in", "Sign in with Google")) - } - } else { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text(user.displayName ?: user.email ?: readerString("desktop_signed_in", "Signed in"), style = MaterialTheme.typography.titleMedium) - user.email?.let { - Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - OutlinedButton(onClick = onRefresh, enabled = !isBusy) { - Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.size(8.dp)) - Text(readerString("desktop_refresh", "Refresh")) - } - OutlinedButton(onClick = onSignOut, enabled = !isBusy) { - Text(readerString("drawer_sign_out", "Sign out")) - } - } - } - - HorizontalDivider() - - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(18.dp)) { - DesktopAccountValue( - label = readerString("desktop_plan", "Plan"), - value = if (isProUser) { - readerString("desktop_pro_unlocked_account", "Pro is unlocked for this account.") - } else { - readerString("desktop_pro_not_unlocked_account", "Pro is not unlocked for this account.") - }, - modifier = Modifier.weight(1f) - ) - DesktopAccountValue( - label = readerString("credits_tab", "Credits"), - value = readerString("desktop_credits_available_format", "%1\$d credits available", credits), - modifier = Modifier.weight(1f) - ) - } - - Text( - readerString( - "desktop_pro_purchase_android_desc", - "Pro and credits can only be purchased from the Android app. Desktop checks the same signed-in account and uses those credits for cloud TTS, summaries, recaps, and other paid AI features." - ), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - statusMessage?.takeIf { it.isNotBlank() }?.let { - Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - } - - Spacer(Modifier.height(12.dp)) - } -} - -@Composable -private fun DesktopAccountValue( - label: String, - value: String, - modifier: Modifier = Modifier -) { - Column(modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - Text(value, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) - } -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopProfileAvatar.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopProfileAvatar.kt deleted file mode 100644 index e60ad28..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopProfileAvatar.kt +++ /dev/null @@ -1,114 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Box -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AccountCircle -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -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.graphics.ImageBitmap -import androidx.compose.ui.graphics.toComposeImageBitmap -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.foundation.shape.CircleShape -import org.dueattendant149.bookreader.shared.UserData -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import org.jetbrains.skia.Image as SkiaImage - -@Composable -internal fun DesktopProfileAvatar( - user: UserData, - modifier: Modifier = Modifier -) { - val photoUrl = user.photoUrl?.takeIf { it.isNotBlank() } - var bitmap by remember(photoUrl) { mutableStateOf(photoUrl?.let(DesktopProfileAvatarCache::peek)) } - - LaunchedEffect(photoUrl) { - bitmap = if (photoUrl == null) { - null - } else { - withContext(Dispatchers.IO) { - DesktopProfileAvatarCache.load(photoUrl) - } - } - } - - val imageBitmap = bitmap - if (imageBitmap != null) { - Image( - bitmap = imageBitmap, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = modifier.clip(CircleShape) - ) - } else { - DesktopProfileAvatarFallback(user = user, modifier = modifier) - } -} - -@Composable -private fun DesktopProfileAvatarFallback( - user: UserData, - modifier: Modifier = Modifier -) { - Surface( - modifier = modifier, - shape = CircleShape, - color = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer - ) { - Box(contentAlignment = Alignment.Center) { - val initial = (user.displayName ?: user.email) - ?.trim() - ?.firstOrNull() - ?.uppercase() - if (initial != null) { - Text(initial, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - } else { - Icon(Icons.Default.AccountCircle, contentDescription = null) - } - } - } -} - -private object DesktopProfileAvatarCache { - private const val MaxEntries = 24 - - private val cache = object : LinkedHashMap(MaxEntries, 0.75f, true) { - override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean { - return size > MaxEntries - } - } - - fun peek(url: String): ImageBitmap? { - return synchronized(cache) { cache[url] } - } - - fun load(url: String): ImageBitmap? { - peek(url)?.let { return it } - val bitmap = runCatching { - DesktopOpdsHttp.fetchBytes(url, catalog = null).toImageBitmap() - }.getOrNull() ?: return null - - synchronized(cache) { - cache[url] = bitmap - } - return bitmap - } - - private fun ByteArray.toImageBitmap(): ImageBitmap? { - return runCatching { SkiaImage.makeFromEncoded(this).toComposeImageBitmap() }.getOrNull() - } -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderDefaults.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderDefaults.kt deleted file mode 100644 index c0e0a73..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderDefaults.kt +++ /dev/null @@ -1,91 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import org.dueattendant149.bookreader.shared.BookItem -import org.dueattendant149.bookreader.shared.FileType -import org.dueattendant149.bookreader.shared.PdfDisplayMode -import org.dueattendant149.bookreader.shared.ReaderFeatureSurface -import org.dueattendant149.bookreader.shared.ReaderPlatform -import org.dueattendant149.bookreader.shared.SharedFileCapabilities -import org.dueattendant149.bookreader.shared.SharedReaderScreenState -import org.dueattendant149.bookreader.shared.reader.ReaderPageSpreadMode -import org.dueattendant149.bookreader.shared.reader.ReaderReadingMode -import org.dueattendant149.bookreader.shared.reader.ReaderSettings - -internal const val DesktopReaderDefaultsVersion = 1 - -internal enum class DesktopReaderSettingsEngine { - TEXT, - PDF -} - -internal val DesktopDefaultTextReaderSettings = ReaderSettings( - readingMode = ReaderReadingMode.PAGINATED, - pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE -) - -internal val DesktopDefaultPdfReaderSettings = ReaderSettings( - themeId = "no_theme", - readingMode = ReaderReadingMode.PAGINATED, - pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE -) - -internal fun FileType.desktopReaderSettingsEngine(): DesktopReaderSettingsEngine? { - return when (SharedFileCapabilities.surfaceFor(this, ReaderPlatform.DESKTOP)) { - ReaderFeatureSurface.PDF_VIEWER -> DesktopReaderSettingsEngine.PDF - ReaderFeatureSurface.EPUB_READER, - ReaderFeatureSurface.TEXT_READER -> DesktopReaderSettingsEngine.TEXT - null -> null - } -} - -internal fun BookItem.usesDesktopReaderSettingsEngine(engine: DesktopReaderSettingsEngine): Boolean { - return type.desktopReaderSettingsEngine() == engine -} - -internal fun List.withDesktopReaderEngineSettings( - engine: DesktopReaderSettingsEngine, - settings: ReaderSettings -): List { - return map { book -> - if (book.usesDesktopReaderSettingsEngine(engine)) { - book.copy(readerSettings = settings) - } else { - book - } - } -} - -internal fun SharedReaderScreenState.withDesktopReaderEngineDefaultSettings( - engine: DesktopReaderSettingsEngine, - settings: ReaderSettings -): SharedReaderScreenState { - val engineSettings = if (engine == DesktopReaderSettingsEngine.PDF) { - settings.toDesktopPdfReaderSettings() - } else { - settings - } - return when (engine) { - DesktopReaderSettingsEngine.TEXT -> copy( - readerDefaultSettings = engineSettings, - rawLibraryBooks = rawLibraryBooks.withDesktopReaderEngineSettings(engine, engineSettings) - ) - DesktopReaderSettingsEngine.PDF -> copy( - pdfReaderDefaultSettings = engineSettings, - rawLibraryBooks = rawLibraryBooks.withDesktopReaderEngineSettings(engine, engineSettings) - ) - } -} - -internal fun ReaderSettings.toDesktopPdfDisplayMode(): PdfDisplayMode { - return when (readingMode) { - ReaderReadingMode.PAGINATED -> PdfDisplayMode.PAGINATION - ReaderReadingMode.VERTICAL -> PdfDisplayMode.VERTICAL_SCROLL - } -} - -internal fun PdfDisplayMode.toDesktopReaderReadingMode(): ReaderReadingMode { - return when (this) { - PdfDisplayMode.PAGINATION -> ReaderReadingMode.PAGINATED - PdfDisplayMode.VERTICAL_SCROLL -> ReaderReadingMode.VERTICAL - } -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderDiagnostics.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderDiagnostics.kt deleted file mode 100644 index 8ee7aeb..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderDiagnostics.kt +++ /dev/null @@ -1,146 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.IntSize -import org.dueattendant149.bookreader.shared.ReaderLocator - -private const val PdfZoomPerfLogTag = "EpistemePdfZoomPerf" -private const val PdfZoomSettleLogTag = "EpistemePdfZoomSettle" -private const val PdfLinkLogTag = "EpistemePdfLink" -private const val PdfChromeTapLogTag = "EpistemePdfChromeTap" -private const val EpubLinkLogTag = "EpistemeEpubLink" -private const val EpubPaginationLogTag = "EpistemeEpubPagination" -private const val EpubCutoffLogTag = "EpistemeEpubCutoff" -private const val ReaderGapLogTag = "EpistemeReaderGap" -private const val EpubSelectionDebugLogTag = "EPUB_SELECTION_DEBUG" -private const val EpubHighlightFlowLogTag = "EpistemeEpubHighlightFlow" -private const val DesktopHighlightMapLogTag = "EpistemeDesktopHighlightMap" -private const val DesktopPositionTraceLogTag = "EpistemeDesktopPositionTrace" -private const val DesktopReaderCloseLogTag = "EpistemeDesktopReaderClose" -private const val DesktopNativeWebViewLogTag = "EpistemeNativeWebView" -private const val WebViewLayoutLogTag = "EpistemeWebViewLayout" -private const val ReaderModeSwitchLogTag = "EpistemeReaderModeSwitch" - -internal fun logPdfSelection(message: String) { -} - -internal fun logPdfZoomPerf(message: String) { - logDesktopDiagnostic(PdfZoomPerfLogTag) { message } -} - -internal fun logPdfZoomPerf(message: () -> String) { - logDesktopDiagnostic(PdfZoomPerfLogTag, message) -} - -internal fun logPdfZoomSettle(message: String) { - logDesktopDiagnostic(PdfZoomSettleLogTag) { message } -} - -internal fun logPdfZoomSettle(message: () -> String) { - logDesktopDiagnostic(PdfZoomSettleLogTag, message) -} - -internal fun logPdfLink(message: String) { - logDesktopDiagnostic(PdfLinkLogTag) { message } -} - -internal fun logPdfChromeTap(message: String) { - logDesktopDiagnostic(PdfChromeTapLogTag) { message } -} - -internal fun logPdfChromeTap(message: () -> String) { - logDesktopDiagnostic(PdfChromeTapLogTag, message) -} - -internal fun logEpubLink(message: String) { - logDesktopDiagnostic(EpubLinkLogTag) { message } -} - -internal fun logEpubPagination(message: String) { - logDesktopDiagnostic(EpubPaginationLogTag) { message } -} - -internal fun logEpubCutoff(message: String) { - logDesktopDiagnostic(EpubCutoffLogTag) { message } -} - -internal fun logReaderGap(message: String) { - logDesktopDiagnostic(ReaderGapLogTag) { message } -} - -internal fun logEpubSelectionDebug(message: String) { - logDesktopDiagnostic(EpubSelectionDebugLogTag) { message } -} - -internal fun logEpubHighlightFlow(message: String) { - logDesktopDiagnostic(EpubHighlightFlowLogTag) { message } -} - -internal fun logDesktopHighlightMap(message: String) { - logDesktopDiagnostic(DesktopHighlightMapLogTag) { message } -} - -internal fun logDesktopPositionTrace(message: String) { - logDesktopDiagnostic(DesktopPositionTraceLogTag) { message } -} - -internal fun logDesktopPositionTrace(message: () -> String) { - logDesktopDiagnostic(DesktopPositionTraceLogTag, message) -} - -internal fun logDesktopReaderClose(message: String) { - logDesktopDiagnostic(DesktopReaderCloseLogTag) { message } -} - -internal fun logDesktopWebView2(message: String) { - logDesktopDiagnostic(DesktopNativeWebViewLogTag) { message } -} - -internal fun logWebViewLayoutDiag(message: String) { - logDesktopDiagnostic(WebViewLayoutLogTag) { message } -} - -internal fun logReaderModeSwitch(message: String) { - logDesktopDiagnostic(ReaderModeSwitchLogTag) { message } -} - -internal fun DesktopPdfLinkTarget.formatLogTarget(): String { - return "dest=${destPageIndex?.let { it + 1 } ?: "null"} uri=\"${uri.orEmpty().logPreview()}\"" -} - -internal fun Float.formatLogFloat(): String { - return String.format("%.3f", this) -} - -internal fun Offset?.formatLogOffset(): String { - if (this == null) return "none" - return "${x.formatLogFloat()},${y.formatLogFloat()}" -} - -internal fun IntOffset?.formatLogIntOffset(): String { - if (this == null) return "none" - return "${this.x},${this.y}" -} - -internal fun IntSize.formatLogSize(): String { - return "${width}x${height}" -} - -internal 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()}" -} - -internal fun ReaderLocator?.desktopPositionTraceSummary(maxTextLength: Int = 90): String { - if (this == null) return "null" - return "chapter=${chapterIndex ?: "null"} page=${pageIndex ?: "null"} " + - "offsets=${startOffset ?: "null"}..${endOffset ?: "null"} " + - "block=${blockIndex ?: "null"} char=${charOffset ?: "null"} " + - "chapterId=\"${chapterId.orEmpty().logPreview(80)}\" href=\"${href.orEmpty().logPreview(120)}\" " + - "cfi=\"${cfi.orEmpty().logPreview(180)}\" text=\"${textQuote.orEmpty().logPreview(maxTextLength)}\"" -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderOpenTrace.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderOpenTrace.kt deleted file mode 100644 index 540e7db..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderOpenTrace.kt +++ /dev/null @@ -1,29 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -internal const val DesktopReaderOpenTraceTag = "EpistemeDesktopOpenTrace" - -internal fun logDesktopReaderOpenTrace(message: () -> String) { - logDesktopDiagnostic(DesktopReaderOpenTraceTag, message) -} - -internal fun Long.elapsedOpenTraceMs(nowNanos: Long = System.nanoTime()): Long { - return ((nowNanos - this).coerceAtLeast(0L)) / 1_000_000L -} - -internal fun DesktopReaderOpening.elapsedOpenTraceMs(nowNanos: Long = System.nanoTime()): Long { - return startedAtNanos.elapsedOpenTraceMs(nowNanos) -} - -internal fun DesktopReaderOpening.openTracePrefix(event: String): String { - return "event=$event requestId=$requestId bookId=\"${bookId.logPreview(80)}\" " + - "title=\"${title.logPreview(120)}\" format=\"$formatLabel\" elapsedMs=${elapsedOpenTraceMs()}" -} - -internal fun DesktopReaderOpenResult.openTraceKind(): String { - return when (this) { - is DesktopReaderOpenResult.Failure -> "failure" - is DesktopReaderOpenResult.PasswordRequired -> "password_required" - is DesktopReaderOpenResult.Pdf -> "pdf" - is DesktopReaderOpenResult.Text -> "text" - } -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderOpening.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderOpening.kt deleted file mode 100644 index 909ac08..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderOpening.kt +++ /dev/null @@ -1,44 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import org.dueattendant149.bookreader.shared.BookItem -import org.dueattendant149.bookreader.shared.reader.ReaderSessionState -import org.dueattendant149.bookreader.shared.ui.SharedAppTab - -internal data class DesktopReaderOpening( - val requestId: Long, - val bookId: String, - val title: String, - val formatLabel: String, - val returnTab: SharedAppTab, - val password: String? = null, - val startedAtNanos: Long = System.nanoTime() -) - -internal sealed interface DesktopReaderOpenResult { - val opening: DesktopReaderOpening - val book: BookItem - - data class Pdf( - override val opening: DesktopReaderOpening, - override val book: BookItem, - val document: DesktopPdfDocument - ) : DesktopReaderOpenResult - - data class Text( - override val opening: DesktopReaderOpening, - override val book: BookItem, - val session: ReaderSessionState - ) : DesktopReaderOpenResult - - data class Failure( - override val opening: DesktopReaderOpening, - override val book: BookItem, - val message: String - ) : DesktopReaderOpenResult - - data class PasswordRequired( - override val opening: DesktopReaderOpening, - override val book: BookItem, - val attemptedPassword: Boolean - ) : DesktopReaderOpenResult -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderPanels.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderPanels.kt deleted file mode 100644 index 90474f9..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderPanels.kt +++ /dev/null @@ -1,450 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -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.BoxWithConstraints -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.padding -import androidx.compose.foundation.layout.width -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.filled.Close -import androidx.compose.material3.AlertDialog -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.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.text.font.FontWeight -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.zIndex -import org.dueattendant149.bookreader.shared.GEMINI_CLOUD_TTS_MODEL -import org.dueattendant149.bookreader.shared.GEMINI_CLOUD_TTS_MODEL_ID -import org.dueattendant149.bookreader.shared.ReaderAiByokSettings -import org.dueattendant149.bookreader.shared.ReaderAiModelOption -import org.dueattendant149.bookreader.shared.ReaderAiModelOptions -import org.dueattendant149.bookreader.shared.ReaderAiResultState -import org.dueattendant149.bookreader.shared.ReaderCloudTtsVoices -import org.dueattendant149.bookreader.shared.ReaderExtrasState -import org.dueattendant149.bookreader.shared.ReaderTtsReplacementPreferences -import org.dueattendant149.bookreader.shared.maskedReaderAiKey -import org.dueattendant149.bookreader.shared.ui.SharedMarkdownText -import org.dueattendant149.bookreader.shared.ui.SharedReaderPopupLayer -import org.dueattendant149.bookreader.shared.ui.SharedReaderTtsReplacementControls -import org.dueattendant149.bookreader.shared.ui.SharedStableOutlinedTextField -import org.dueattendant149.bookreader.shared.ui.readerString -import org.dueattendant149.bookreader.shared.ui.sharedReaderPopupWidth - -@Composable -internal fun DesktopReaderBottomSheet( - title: String, - onDismiss: () -> Unit, - modifier: Modifier = Modifier, - content: @Composable () -> Unit -) { - SharedReaderPopupLayer(onDismiss = onDismiss) { - BoxWithConstraints( - modifier = modifier - .fillMaxSize() - .zIndex(40f) - ) { - Box( - modifier = Modifier - .matchParentSize() - .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.24f)) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = onDismiss - ) - ) - val sheetHorizontalPadding = 24.dp - val sheetAvailableWidth = (maxWidth - sheetHorizontalPadding - sheetHorizontalPadding).coerceAtLeast(0.dp) - Surface( - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(horizontal = sheetHorizontalPadding, vertical = 16.dp) - .width(sharedReaderPopupWidth(sheetAvailableWidth)) - .heightIn(max = 560.dp), - shape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp, bottomStart = 10.dp, bottomEnd = 10.dp), - color = MaterialTheme.colorScheme.surface, - tonalElevation = 8.dp, - shadowElevation = 16.dp - ) { - Column( - modifier = Modifier.padding(horizontal = 20.dp, vertical = 14.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Box( - modifier = Modifier - .align(Alignment.CenterHorizontally) - .width(42.dp) - .height(4.dp) - .background(MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(999.dp)) - ) - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - title, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.weight(1f), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - IconButton(onClick = onDismiss) { - Icon(Icons.Default.Close, contentDescription = readerString("action_close", "Close")) - } - } - HorizontalDivider() - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - content() - } - } - } - } - } -} - -@Composable -internal fun DesktopReaderAiResultSheet( - result: ReaderAiResultState, - onDismiss: () -> Unit -) { - DesktopReaderBottomSheet( - title = result.title ?: "AI", - onDismiss = onDismiss - ) { - val errorMessage = result.errorMessage - when { - result.isLoading && result.text.isBlank() -> Text(readerString("desktop_working", "Working..."), color = MaterialTheme.colorScheme.onSurfaceVariant) - errorMessage != null -> Text(errorMessage, color = MaterialTheme.colorScheme.error) - else -> { - if (result.isLoading) { - Text(readerString("desktop_working", "Working..."), color = MaterialTheme.colorScheme.onSurfaceVariant) - } - SharedMarkdownText(result.text) - } - } - } -} - -@Composable -internal 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(readerString("ai_settings_title", "AI keys and models")) }, - text = { - Column( - modifier = Modifier - .heightIn(max = 640.dp) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - if (!secureStorageAvailable) { - Text( - readerString( - "desktop_secure_key_storage_unavailable", - "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(readerString("ai_settings_saved_keys", "Saved keys"), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) - DesktopSavedAiKeyRow( - label = readerString("provider_gemini", "Gemini"), - keyValue = sanitized.geminiKey, - onClear = { onSettingsChange(sanitized.copy(geminiKey = "")) } - ) - DesktopSavedAiKeyRow( - label = readerString("provider_groq", "Groq"), - keyValue = sanitized.groqKey, - onClear = { onSettingsChange(sanitized.copy(groqKey = "")) } - ) - - HorizontalDivider() - - Text(readerString("ai_settings_add_or_replace_key", "Add or replace key"), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) { - listOf( - "gemini" to readerString("provider_gemini", "Gemini"), - "groq" to readerString("provider_groq", "Groq") - ).forEach { (provider, label) -> - FilterChip( - selected = selectedProvider == provider, - onClick = { selectedProvider = provider }, - label = { Text(label) } - ) - } - } - SharedStableOutlinedTextField( - value = pendingKey, - onValueChange = { pendingKey = it }, - label = { Text(readerString("label_api_key", "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(readerString("ai_settings_save_key", "Save key")) - } - - HorizontalDivider() - - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Column(modifier = Modifier.weight(1f)) { - Text(readerString("ai_settings_use_one_model", "Use one model for all features"), style = MaterialTheme.typography.titleMedium) - Text( - readerString("ai_settings_use_one_model_desc", "When off, each reader AI feature uses its own selected model."), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Switch( - checked = sanitized.useOneModel, - onCheckedChange = { onSettingsChange(sanitized.copy(useOneModel = it)) } - ) - } - - if (sanitized.useOneModel) { - DesktopAiModelSelector( - title = readerString("ai_settings_all_features", "All AI features"), - description = readerString("ai_settings_all_features_desc", "Smart dictionary, summaries, and recaps all use this model."), - selectedId = sanitized.modelForAll, - onSelected = { onSettingsChange(sanitized.copy(modelForAll = it)) } - ) - } else { - DesktopAiModelSelector( - title = readerString("ai_settings_smart_dictionary", "Smart dictionary"), - description = readerString("ai_settings_smart_dictionary_desc", "Used when defining selected words or phrases."), - selectedId = sanitized.defineModel, - onSelected = { onSettingsChange(sanitized.copy(defineModel = it)) } - ) - DesktopAiModelSelector( - title = readerString("ai_settings_summaries", "Summaries"), - description = readerString("desktop_ai_settings_summaries_desc", "Used for EPUB summaries and PDF page summaries."), - selectedId = sanitized.summarizeModel, - onSelected = { onSettingsChange(sanitized.copy(summarizeModel = it)) } - ) - DesktopAiModelSelector( - title = readerString("ai_settings_recaps", "Recaps"), - description = readerString("ai_settings_recaps_desc", "Used for story recap generation."), - selectedId = sanitized.recapModel, - onSelected = { onSettingsChange(sanitized.copy(recapModel = it)) } - ) - } - - DesktopAiModelSelector( - title = readerString("credits_cloud_tts_title", "Cloud TTS"), - description = readerString("ai_settings_cloud_tts_desc", "Uses the saved Gemini key. Only %1\$s is supported for now.", GEMINI_CLOUD_TTS_MODEL), - selectedId = sanitized.ttsModel, - options = listOf(ReaderAiModelOption("gemini", GEMINI_CLOUD_TTS_MODEL)), - onSelected = { onSettingsChange(sanitized.copy(ttsModel = it)) } - ) - } - }, - confirmButton = { - TextButton(onClick = onDismiss) { - Text(readerString("action_done", "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) ?: readerString("ai_settings_no_key_saved", "No key saved"), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - TextButton(enabled = keyValue.isNotBlank(), onClick = onClear) { - Text(readerString("action_clear", "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(readerString("ai_settings_no_model_selected", "No model selected")) } - ) - options.forEach { option -> - FilterChip( - selected = selectedId == option.id, - onClick = { onSelected(option.id) }, - label = { Text(option.label) } - ) - } - } - } -} - -@Composable -internal fun DesktopPdfTtsPanel( - extrasState: ReaderExtrasState, - aiByokSettings: ReaderAiByokSettings, - cloudTtsFeatureAvailable: Boolean, - onCloudTtsClearCache: () -> Unit, - onCloudTtsVoiceChange: (String) -> Unit, - ttsReplacementPreferences: ReaderTtsReplacementPreferences, - ttsReplacementBookId: String, - onTtsReplacementPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit -) { - val settings = aiByokSettings.sanitized() - - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) - Text(readerString("menu_tts_settings", "TTS"), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - val ttsBusy = extrasState.cloudTts.isLoading || extrasState.cloudTts.isPlaying || extrasState.cloudTts.isPaused - if (cloudTtsFeatureAvailable) { - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Column(modifier = Modifier.weight(1f)) { - Text( - when { - extrasState.cloudTts.isLoading -> readerString("desktop_preparing_audio", "Preparing audio") - extrasState.cloudTts.isPaused -> readerString("desktop_paused", "Paused") - extrasState.cloudTts.isPlaying -> readerString("label_reading", "Reading") - settings.isCloudTtsAvailable -> readerString("desktop_cloud_tts_ready", "Cloud TTS ready") - settings.serverBackedReaderAiFeatures -> readerString("desktop_cloud_tts_needs_signed_in_credits", "Cloud TTS needs signed-in credits") - else -> readerString("desktop_cloud_tts_needs_gemini", "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) - } - } - } - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - Text(readerString("desktop_cloud_tts_voice", "Cloud TTS voice"), fontWeight = FontWeight.SemiBold) - if (ttsBusy) { - Text( - readerString("desktop_stop_reading_change_voices", "Stop reading to change voices."), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) { - ReaderCloudTtsVoices.forEach { voice -> - FilterChip( - selected = settings.ttsSpeakerId == voice.id, - enabled = !ttsBusy, - onClick = { onCloudTtsVoiceChange(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 - ) - } - } - ) - } - } - } - val cacheSummary = extrasState.cloudTts.cacheSummary - if (cacheSummary.hasCachedAudio) { - Text( - readerString("desktop_cache_format", "Cache: %1\$s", cacheSummary.currentVoiceLabel), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - if (cacheSummary.hasCurrentVoiceCachedAudio) { - TextButton(onClick = onCloudTtsClearCache) { - Text(readerString("desktop_clear_voice_cache", "Clear voice cache")) - } - } - } - } - SharedReaderTtsReplacementControls( - preferences = ttsReplacementPreferences, - bookId = ttsReplacementBookId, - onPreferencesChange = onTtsReplacementPreferencesChange - ) - } -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderScreen.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderScreen.kt deleted file mode 100644 index e3db9d4..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderScreen.kt +++ /dev/null @@ -1,1088 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -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.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.boundsInWindow -import androidx.compose.ui.layout.onGloballyPositioned -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.rememberTextMeasurer -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.isSpecified -import org.dueattendant149.bookreader.paginatedreader.CssStyle -import org.dueattendant149.bookreader.paginatedreader.SemanticImage -import org.dueattendant149.bookreader.shared.CustomFontItem -import org.dueattendant149.bookreader.shared.ReaderAction -import org.dueattendant149.bookreader.shared.ReaderAiByokSettings -import org.dueattendant149.bookreader.shared.ReaderAiFeature -import org.dueattendant149.bookreader.shared.ReaderAutoScrollState -import org.dueattendant149.bookreader.shared.ReaderExtrasState -import org.dueattendant149.bookreader.shared.ReaderExternalLookupAction -import org.dueattendant149.bookreader.shared.ReaderHighlightPalette -import org.dueattendant149.bookreader.shared.ReaderLocator -import org.dueattendant149.bookreader.shared.ReaderToolbarPreferences -import org.dueattendant149.bookreader.shared.ReaderTtsChunk -import org.dueattendant149.bookreader.shared.ReaderTtsReadScope -import org.dueattendant149.bookreader.shared.ReaderTtsReplacementPreferences -import org.dueattendant149.bookreader.shared.ReaderTheme -import org.dueattendant149.bookreader.shared.reader.ReaderEngine -import org.dueattendant149.bookreader.shared.reader.ReaderImageReference -import org.dueattendant149.bookreader.shared.reader.ReaderLinkTarget -import org.dueattendant149.bookreader.shared.reader.ReaderPage -import org.dueattendant149.bookreader.shared.reader.ReaderReadingMode -import org.dueattendant149.bookreader.shared.reader.ReaderSettings -import org.dueattendant149.bookreader.shared.reader.ReaderSessionState -import org.dueattendant149.bookreader.shared.reader.ReaderViewportSpec -import org.dueattendant149.bookreader.shared.reader.SharedEpubPaginationCache -import org.dueattendant149.bookreader.shared.reader.SharedMeasuredEpubPaginator -import org.dueattendant149.bookreader.shared.reader.isRightToLeftPaginationEnabled -import org.dueattendant149.bookreader.shared.reader.layoutSignature -import org.dueattendant149.bookreader.shared.reduce -import org.dueattendant149.bookreader.shared.ui.DesktopEpubNativeImage -import org.dueattendant149.bookreader.shared.ui.ReaderContentRenderPlan -import org.dueattendant149.bookreader.shared.ui.SharedNativePaginatedReader -import org.dueattendant149.bookreader.shared.ui.SharedNativeReaderSelectionAction -import org.dueattendant149.bookreader.shared.ui.SharedNativeVerticalReader -import org.dueattendant149.bookreader.shared.ui.SharedReaderScreen -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.withContext -import java.awt.EventQueue -import java.awt.Window -import java.awt.event.KeyEvent as AwtKeyEvent - -@Composable -internal fun DesktopReaderScreen( - session: ReaderSessionState, - readerEngine: ReaderEngine, - onSessionChange: (ReaderSessionState) -> Unit, - onReturnToLibrary: (() -> Unit)? = null, - onFullscreenChange: (Boolean) -> Unit = {}, - readerAwtWindow: Window? = null, - toolbarPreferences: ReaderToolbarPreferences, - onToolbarPreferencesChange: (ReaderToolbarPreferences) -> Unit, - appThemeControls: (@Composable () -> Unit)? = null, - customReaderThemes: List, - onCustomReaderThemesChange: (List) -> Unit, - highlightPalette: ReaderHighlightPalette, - onHighlightPaletteChange: (ReaderHighlightPalette) -> Unit, - ttsReplacementPreferences: ReaderTtsReplacementPreferences, - ttsReplacementBookId: String?, - onTtsReplacementPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit, - onPickCustomFont: () -> String?, - customFonts: List, - readerExtrasState: ReaderExtrasState, - aiByokSettings: ReaderAiByokSettings, - externalLookupAvailable: Boolean, - cloudTtsControlsAvailable: Boolean, - onExternalLookup: (ReaderExternalLookupAction, String) -> Unit, - onAiAction: (ReaderAiFeature, String) -> Unit, - onAiResultDismiss: () -> Unit, - onCloudTtsToggle: (String, ReaderLocator?) -> Unit, - onCloudTtsStart: (ReaderTtsReadScope, List) -> Unit, - onCloudTtsPauseResume: () -> Unit, - onCloudTtsStop: () -> Unit, - onCloudTtsClearCache: () -> Unit, - onCloudTtsVoiceChange: (String) -> Unit, - onOpenAiHub: (() -> Unit)? = null, - onDownloadReaderImage: (ReaderImageReference) -> Unit, - readerTextureDataUri: (String) -> String?, - readerCustomTextureIds: List, - onImportReaderTexture: ((ReaderSettings) -> ReaderSettings?)?, - bottomChromeExtraContent: @Composable ColumnScope.() -> Unit = {}, - webViewRuntimeState: DesktopWebViewRuntimeState, - webViewNetworkAccessEnabled: Boolean, - epubPaginationCache: SharedEpubPaginationCache, - epubPaginationCacheGeneration: Int, - useDetachedChromeLayer: Boolean = true, - useDetachedPanelLayer: Boolean = true -) { - val clipboardManager = LocalClipboardManager.current - val density = LocalDensity.current - val textMeasurer = rememberTextMeasurer() - val paginationCacheWriteScope = rememberCoroutineScope() - val measuredPaginator = remember( - textMeasurer, - density, - session.reader.settings.fontFamily, - session.reader.settings.customFontPath, - epubPaginationCache, - paginationCacheWriteScope - ) { - SharedMeasuredEpubPaginator( - textMeasurer = textMeasurer, - density = density, - fontFamily = session.reader.settings.toDesktopReaderFontFamily(), - pageCache = epubPaginationCache, - cacheWriteScope = paginationCacheWriteScope - ) - } - LaunchedEffect(session.reader.book.id) { - logDesktopReaderOpenTrace { - "event=desktop_text_reader_screen_composed bookId=\"${session.reader.book.id.logPreview(120)}\" " + - "title=\"${session.reader.book.title.logPreview(120)}\" mode=${session.reader.settings.readingMode} " + - "chapters=${session.reader.book.chapters.size} pages=${session.reader.pages.size} " + - "currentPage=${session.reader.currentPageIndex + 1} " + - "textChars=${session.reader.book.chapters.sumOf { it.plainText.length }} " + - "htmlChars=${session.reader.book.chapters.sumOf { it.htmlContent.length }} " + - "semanticBlocks=${session.reader.book.chapters.sumOf { it.semanticBlocks.size }} " + - "bookmarks=${session.bookmarks.size} highlights=${session.highlights.size}" - } - } - var readerViewport by remember(session.reader.book.id) { mutableStateOf(ReaderViewportSpec(0, 0)) } - val paginationLayoutSignature = session.reader.settings.layoutSignature() - val paginationContentSignature = remember(session.reader.book) { - session.reader.book.desktopPaginationContentSignature() - } - val paginationDensitySignature = DesktopEpubPaginationDensity( - density = density.density, - fontScale = density.fontScale - ) - val measuredPaginationRequest = remember( - session.reader.book.id, - paginationContentSignature, - paginationLayoutSignature, - readerViewport, - paginationDensitySignature, - epubPaginationCacheGeneration - ) { - if (session.reader.settings.readingMode == ReaderReadingMode.PAGINATED && readerViewport.isSpecified) { - DesktopEpubPaginationRequest( - bookId = session.reader.book.id, - chapterSignature = paginationContentSignature, - layoutSignature = paginationLayoutSignature, - viewport = readerViewport, - density = paginationDensitySignature, - cacheGeneration = epubPaginationCacheGeneration - ) - } else { - null - } - } - var completedMeasuredPaginationRequest by remember(session.reader.book.id) { - mutableStateOf(null) - } - var completedMeasuredPaginationPages by remember(session.reader.book.id) { - mutableStateOf(emptyList()) - } - var warmMeasuredPaginationRequest by remember(session.reader.book.id) { - mutableStateOf(null) - } - var warmMeasuredPaginationPages by remember(session.reader.book.id) { - mutableStateOf(emptyList()) - } - var runningMeasuredPaginationRequest by remember(session.reader.book.id) { - mutableStateOf(null) - } - val measuredPaginationPagesApplied = desktopMeasuredPaginationReady( - request = measuredPaginationRequest, - completedRequest = completedMeasuredPaginationRequest, - currentPages = session.reader.pages, - measuredPages = completedMeasuredPaginationPages - ) - val warmMeasuredPaginationPagesApplied = desktopMeasuredPaginationReady( - request = measuredPaginationRequest, - completedRequest = warmMeasuredPaginationRequest, - currentPages = session.reader.pages, - measuredPages = warmMeasuredPaginationPages - ) - val paginatedLayoutReady = desktopPaginatedLayoutReadyForDisplay( - readingMode = session.reader.settings.readingMode, - measuredPagesApplied = measuredPaginationPagesApplied - ) - val latestSession by rememberUpdatedState(session) - val latestOnSessionChange by rememberUpdatedState(onSessionChange) - var externalLinkDialogUrl by remember { mutableStateOf(null) } - var lastHandledLink by remember { mutableStateOf(null) } - var isFullscreen by remember(session.reader.book.id) { mutableStateOf(false) } - val desktopReaderExtrasState = readerExtrasState.copy(autoScroll = ReaderAutoScrollState()) - val currentReaderFullscreen by rememberUpdatedState(isFullscreen) - val currentOnReaderFullscreenChange by rememberUpdatedState(onFullscreenChange) - - fun setReaderFullscreen(enabled: Boolean) { - isFullscreen = enabled - onFullscreenChange(enabled) - } - - DesktopExternalLinkDialog( - url = externalLinkDialogUrl, - onDismiss = { externalLinkDialogUrl = null } - ) - - fun handleReaderAwtKeyEvent(event: AwtKeyEvent): Boolean { - val currentSession = latestSession - val action = event.desktopReaderKeyNavigationOrNull( - fullscreen = isFullscreen, - rightToLeftPagination = currentSession.reader.settings.isRightToLeftPaginationEnabled() - ) ?: return false - val nextSession = currentSession.reduceDesktopReaderKeyNavigation(action, readerEngine) - if (nextSession == null) { - if (action == DesktopReaderKeyNavigation.EXIT_FULLSCREEN && isFullscreen) { - setReaderFullscreen(false) - } - } else { - latestOnSessionChange(nextSession) - } - return true - } - - fun handleReaderFullscreenAwtKeyEvent(event: AwtKeyEvent): Boolean { - if (latestSession.isSearchActive) { - if (event.id == AwtKeyEvent.KEY_PRESSED && isFullscreen && event.keyCode == AwtKeyEvent.VK_ESCAPE) { - setReaderFullscreen(false) - return true - } - return false - } - return handleReaderAwtKeyEvent(event) - } - - fun handleReaderGlobalShortcutAwtKeyEvent(event: AwtKeyEvent): Boolean { - if (event.id != AwtKeyEvent.KEY_PRESSED || !event.isControlDown) return false - val action = when (event.keyCode) { - AwtKeyEvent.VK_F -> DesktopReaderKeyNavigation.SEARCH - AwtKeyEvent.VK_G -> DesktopReaderKeyNavigation.NEXT_SEARCH - else -> return false - } - val nextSession = latestSession.reduceDesktopReaderKeyNavigation(action, readerEngine) ?: return false - latestOnSessionChange(nextSession) - return true - } - - DesktopReaderKeyDispatcherEffect( - enabled = externalLinkDialogUrl == null, - allowChromeModalWindows = true, - onKeyPressed = { event -> handleReaderGlobalShortcutAwtKeyEvent(event) } - ) - - DesktopReaderKeyDispatcherEffect( - enabled = externalLinkDialogUrl == null && !session.isSearchActive, - allowPanelModalWindows = true, - dispatchWhenOwnerWindowActive = false, - onKeyPressed = { event -> handleReaderAwtKeyEvent(event) } - ) - - DesktopReaderFullscreenKeyEffect( - enabled = isFullscreen && externalLinkDialogUrl == null, - onKeyPressed = { event -> handleReaderFullscreenAwtKeyEvent(event) } - ) - - LaunchedEffect(session.reader.settings.readingMode) { - if (session.reader.settings.readingMode != ReaderReadingMode.PAGINATED) { - completedMeasuredPaginationRequest = null - completedMeasuredPaginationPages = emptyList() - warmMeasuredPaginationRequest = null - warmMeasuredPaginationPages = emptyList() - runningMeasuredPaginationRequest = null - } - } - - DisposableEffect(session.reader.book.id) { - onDispose { - if (currentReaderFullscreen) { - currentOnReaderFullscreenChange(false) - } - } - } - - LaunchedEffect( - measuredPaginationRequest, - measuredPaginator - ) { - val request = measuredPaginationRequest ?: return@LaunchedEffect - if (completedMeasuredPaginationRequest == request) { - logEpubPagination( - "reflow_skip reason=request_already_measured book=\"${session.reader.book.title.logPreview()}\" " + - "viewport=${request.viewport.widthPx}x${request.viewport.heightPx}" - ) - return@LaunchedEffect - } - runningMeasuredPaginationRequest = request - try { - val cacheProbeStartedAt = System.nanoTime() - val cacheProbeSettings = latestSession.reader.settings - val cachedPages = if ( - cacheProbeSettings.readingMode == ReaderReadingMode.PAGINATED && - cacheProbeSettings.layoutSignature() == request.layoutSignature - ) { - withContext(Dispatchers.Default) { - epubPaginationCache.loadMemory( - book = session.reader.book, - settings = cacheProbeSettings, - viewport = request.viewport, - density = request.density.density, - fontScale = request.density.fontScale - ) - } - } else { - null - } - val settingsAfterCacheProbe = latestSession.reader.settings - if (settingsAfterCacheProbe.readingMode != ReaderReadingMode.PAGINATED) return@LaunchedEffect - if (settingsAfterCacheProbe.layoutSignature() != request.layoutSignature) return@LaunchedEffect - if (cachedPages != null) { - val cacheLayoutChanged = !latestSession.reader.pages.samePageLayoutAs(cachedPages) - logEpubPagination( - "cache_warm_result book=\"${session.reader.book.title.logPreview()}\" pages=${cachedPages.size} " + - "layoutChanged=$cacheLayoutChanged viewport=${request.viewport.widthPx}x${request.viewport.heightPx} " + - "elapsedMs=${cacheProbeStartedAt.elapsedMillis()}" - ) - if (cacheLayoutChanged) { - val cacheApplySession = latestSession - latestOnSessionChange( - readerEngine.replacePages( - state = cacheApplySession, - pages = cachedPages, - reflowAnchor = readerEngine.reflowAnchorFor(cacheApplySession) - ) - ) - } - completedMeasuredPaginationPages = cachedPages - completedMeasuredPaginationRequest = request - return@LaunchedEffect - } - - val warmStartSession = latestSession - val warmAnchor = readerEngine.reflowAnchorFor(warmStartSession) - val warmChapterIndex = warmAnchor?.chapterIndex - ?: warmStartSession.reader.currentPage?.chapterIndex - ?: 0 - val warmFirstPageIndex = warmStartSession.reader.pages.firstPageIndexForChapter(warmChapterIndex) ?: 0 - val warmStartedAt = System.nanoTime() - logEpubPagination( - "chapter_warm_start book=\"${session.reader.book.title.logPreview()}\" chapter=$warmChapterIndex " + - "firstPage=${warmFirstPageIndex + 1} viewport=${request.viewport.widthPx}x${request.viewport.heightPx}" - ) - val cachedWarmChapterPages = epubPaginationCache.loadChapter( - book = session.reader.book, - settings = settingsAfterCacheProbe, - viewport = request.viewport, - chapterIndex = warmChapterIndex, - density = request.density.density, - fontScale = request.density.fontScale - ) - val warmChapterPages = cachedWarmChapterPages ?: withContext(Dispatchers.Default) { - measuredPaginator.paginateChapterWindow( - book = session.reader.book, - settings = settingsAfterCacheProbe, - viewport = request.viewport, - chapterIndex = warmChapterIndex, - firstPageIndex = warmFirstPageIndex - ) - } - val warmPages = desktopPagesWithMeasuredChapter( - currentPages = warmStartSession.reader.pages, - chapterIndex = warmChapterIndex, - measuredChapterPages = warmChapterPages - ) - val warmLayoutChanged = warmPages.isNotEmpty() && !warmStartSession.reader.pages.samePageLayoutAs(warmPages) - logEpubPagination( - "chapter_warm_result book=\"${session.reader.book.title.logPreview()}\" chapter=$warmChapterIndex " + - "source=${if (cachedWarmChapterPages != null) "cache" else "measured"} " + - "chapterPages=${warmChapterPages.size} pages=${warmPages.size} layoutChanged=$warmLayoutChanged " + - "elapsedMs=${warmStartedAt.elapsedMillis()}" - ) - if (warmLayoutChanged) { - logReaderModeSwitch( - "pagination_warm_apply_dispatch requestViewport=${request.viewport.widthPx}x${request.viewport.heightPx} " + - "chapter=$warmChapterIndex chapterPages=${warmChapterPages.size} currentPages=${warmStartSession.reader.pages.size}" - ) - latestOnSessionChange( - readerEngine.replacePages( - state = warmStartSession, - pages = warmPages, - reflowAnchor = warmAnchor - ) - ) - warmMeasuredPaginationPages = warmPages - warmMeasuredPaginationRequest = request - } - - val reflowStartSession = latestSession - val reflowStartRequestId = reflowStartSession.navigationRequestId - val reflowAnchor = readerEngine.reflowAnchorFor(reflowStartSession) - val settings = reflowStartSession.reader.settings - if (settings.readingMode != ReaderReadingMode.PAGINATED) return@LaunchedEffect - if (settings.layoutSignature() != request.layoutSignature) return@LaunchedEffect - logEpubPagination( - "reflow_start book=\"${session.reader.book.title.logPreview()}\" " + - "viewport=${request.viewport.widthPx}x${request.viewport.heightPx} " + - "spread=${settings.pageSpreadMode} font=${settings.fontSize} lineSpacing=${settings.lineSpacing} " + - "margins=${settings.resolvedHorizontalMargin}x${settings.resolvedVerticalMargin} " + - "pageWidthSetting=${settings.pageWidth} oldPages=${reflowStartSession.reader.pages.size} " + - "anchorPage=${reflowAnchor?.pageIndex} anchorOffsets=${reflowAnchor?.startOffset}..${reflowAnchor?.endOffset}" - ) - val pages = withContext(Dispatchers.Default) { - measuredPaginator.paginate( - book = session.reader.book, - settings = settings, - viewport = request.viewport, - readCache = true - ) - } - val layoutChanged = pages.isNotEmpty() && !latestSession.reader.pages.samePageLayoutAs(pages) - logEpubPagination( - "reflow_result book=\"${session.reader.book.title.logPreview()}\" pages=${pages.size} " + - "layoutChanged=$layoutChanged currentPages=${latestSession.reader.pages.size}" - ) - val currentVisiblePageDetails = latestSession.reader.visiblePages.map { page -> - "${page.pageIndex + 1}:text=${page.text.length}:blocks=${page.semanticBlocks.size}" - } - val measuredCurrentPageDetails = pages.getOrNull(latestSession.reader.currentPageIndex) - ?.let { page -> "${page.pageIndex + 1}:text=${page.text.length}:blocks=${page.semanticBlocks.size}" } - ?: "none" - logReaderModeSwitch( - "pagination_result requestViewport=${request.viewport.widthPx}x${request.viewport.heightPx} " + - "measuredPages=${pages.size} currentPages=${latestSession.reader.pages.size} layoutChanged=$layoutChanged " + - "currentVisible=$currentVisiblePageDetails measuredAtCurrent=$measuredCurrentPageDetails" - ) - if (layoutChanged) { - logReaderModeSwitch( - "pagination_apply_dispatch requestViewport=${request.viewport.widthPx}x${request.viewport.heightPx} " + - "measuredPages=${pages.size} currentPages=${latestSession.reader.pages.size}" - ) - latestOnSessionChange( - readerEngine.replacePages( - state = latestSession, - pages = pages, - reflowAnchor = reflowAnchor, - navigationRequestIdAtReflowStart = reflowStartRequestId - ) - ) - } - if (pages.isNotEmpty()) { - completedMeasuredPaginationPages = pages - completedMeasuredPaginationRequest = request - } - } catch (error: Throwable) { - if (error is CancellationException) throw error - logEpubPagination( - "reflow_failed book=\"${session.reader.book.title.logPreview()}\" " + - "viewport=${request.viewport.widthPx}x${request.viewport.heightPx} " + - "error=\"${error.message.orEmpty().logPreview(300)}\"" - ) - } finally { - if (runningMeasuredPaginationRequest == request) { - runningMeasuredPaginationRequest = null - } - } - } - - LaunchedEffect( - measuredPaginationRequest, - completedMeasuredPaginationRequest, - completedMeasuredPaginationPages, - session.reader.pages - ) { - val request = measuredPaginationRequest ?: return@LaunchedEffect - val measuredPages = completedMeasuredPaginationPages - if (session.reader.settings.readingMode != ReaderReadingMode.PAGINATED) return@LaunchedEffect - if (completedMeasuredPaginationRequest != request || measuredPages.isEmpty()) return@LaunchedEffect - if (session.reader.pages.samePageLayoutAs(measuredPages)) return@LaunchedEffect - val currentVisiblePageDetails = session.reader.visiblePages.map { page -> - "${page.pageIndex + 1}:text=${page.text.length}:blocks=${page.semanticBlocks.size}" - } - logReaderModeSwitch( - "pagination_apply_pending requestViewport=${request.viewport.widthPx}x${request.viewport.heightPx} " + - "currentPages=${session.reader.pages.size} measuredPages=${measuredPages.size} " + - "currentVisible=$currentVisiblePageDetails" - ) - onSessionChange( - readerEngine.replacePages( - state = session, - pages = measuredPages, - reflowAnchor = readerEngine.reflowAnchorFor(session) - ) - ) - } - - val handleDesktopSelectionAction: (DesktopReaderSelectionAction, String, ReaderLocator?) -> Unit = { action, text, locator -> - val settings = aiByokSettings.sanitized() - when (action) { - DesktopReaderSelectionAction.DEFINE -> { - if (settings.areReaderAiFeaturesAvailable) onAiAction(ReaderAiFeature.DEFINE, text) - } - DesktopReaderSelectionAction.SPEAK -> { - if (settings.isCloudTtsAvailable) onCloudTtsToggle(text, locator) - } - DesktopReaderSelectionAction.SEARCH -> onExternalLookup(ReaderExternalLookupAction.SEARCH, text) - DesktopReaderSelectionAction.PALETTE -> Unit - } - } - val nativeSelectionActions = buildSet { - val settings = aiByokSettings.sanitized() - if (settings.areReaderAiFeaturesAvailable) add(SharedNativeReaderSelectionAction.DEFINE) - if (externalLookupAvailable) add(SharedNativeReaderSelectionAction.SEARCH) - if (settings.isCloudTtsAvailable) add(SharedNativeReaderSelectionAction.SPEAK) - } - val handleNativeSelectionAction: (SharedNativeReaderSelectionAction, String, ReaderLocator?) -> Unit = { action, text, locator -> - when (action) { - SharedNativeReaderSelectionAction.DEFINE -> - handleDesktopSelectionAction(DesktopReaderSelectionAction.DEFINE, text, locator) - SharedNativeReaderSelectionAction.SPEAK -> - handleDesktopSelectionAction(DesktopReaderSelectionAction.SPEAK, text, locator) - SharedNativeReaderSelectionAction.SEARCH -> - handleDesktopSelectionAction(DesktopReaderSelectionAction.SEARCH, text, locator) - } - } - val handleDesktopEpubLinkClicked: (DesktopEpubLinkClick) -> Unit = { 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()}\"") - if (externalLookupAvailable) { - externalLinkDialogUrl = target.url - } - } - is ReaderLinkTarget.Internal -> { - logEpubLink( - "resolved_internal chapter=${target.locator.chapterIndex} " + - "page=${target.locator.pageIndex} offset=${target.locator.startOffset}" - ) - onSessionChange(readerEngine.jumpToLocator(session, target.locator)) - } - ReaderLinkTarget.Ignored -> { - logEpubLink("resolved_ignored href=\"${link.href.logPreview()}\"") - } - } - } - } - - SharedReaderScreen( - session = session, - readerEngine = readerEngine, - onSessionChange = onSessionChange, - onReturnToLibrary = onReturnToLibrary, - isFullscreen = isFullscreen, - onFullscreenChange = ::setReaderFullscreen, - toolbarPreferences = toolbarPreferences, - onToolbarPreferencesChange = onToolbarPreferencesChange, - appThemeControls = appThemeControls, - customReaderThemes = customReaderThemes, - onCustomReaderThemesChange = onCustomReaderThemesChange, - highlightPalette = highlightPalette, - onHighlightPaletteChange = onHighlightPaletteChange, - ttsReplacementPreferences = ttsReplacementPreferences, - ttsReplacementBookId = ttsReplacementBookId, - onTtsReplacementPreferencesChange = onTtsReplacementPreferencesChange, - onPickCustomFont = onPickCustomFont, - customFonts = customFonts, - readerExtrasState = desktopReaderExtrasState, - aiByokSettings = aiByokSettings, - externalLookupAvailable = externalLookupAvailable, - cloudTtsControlsAvailable = cloudTtsControlsAvailable, - onExternalLookup = onExternalLookup, - onAiAction = onAiAction, - onAiResultDismiss = onAiResultDismiss, - onCopyText = { text -> clipboardManager.setText(AnnotatedString(text)) }, - onCloudTtsStart = onCloudTtsStart, - onCloudTtsPauseResume = onCloudTtsPauseResume, - onCloudTtsStop = onCloudTtsStop, - onCloudTtsClearCache = onCloudTtsClearCache, - onCloudTtsVoiceChange = onCloudTtsVoiceChange, - onOpenAiHub = onOpenAiHub, - onDownloadReaderImage = onDownloadReaderImage, - readerImagePreviewContent = { image, previewModifier -> - DesktopEpubNativeImage( - image = image.toDesktopPreviewSemanticImage(), - modifier = previewModifier.clip(RoundedCornerShape(3.dp)) - ) - }, - readerTextureDataUri = readerTextureDataUri, - readerTexturePreviewContent = { textureId, previewModifier -> - DesktopReaderTexturePreview(textureId = textureId, modifier = previewModifier) - }, - readerCustomTextureIds = readerCustomTextureIds, - onImportReaderTexture = onImportReaderTexture, - preferNativeVerticalReader = desktopShouldUseNativeVerticalEpubReader(), - bottomChromeExtraContent = bottomChromeExtraContent, - useDetachedChromeLayer = useDetachedChromeLayer, - useDetachedPanelLayer = useDetachedPanelLayer - ) { renderPlan, onVisiblePageChanged, onHighlightSelected, onOpenHighlightPaletteManager, onChromeActivity -> - val renderPlanModeKey = renderPlan.desktopReaderSurfaceModeKey() - val readerSurfaceKey = renderPlan.desktopReaderSurfaceContentKey(paginatedLayoutReady) - val readerModeSwitchLayoutModifier = - if (renderPlan.desktopReaderUsesNativeComposeSurface()) { - Modifier.onGloballyPositioned { coordinates -> - val bounds = coordinates.boundsInWindow() - logReaderModeSwitch( - "native_surface_layout modeKey=$renderPlanModeKey surfaceKey=$readerSurfaceKey " + - "paginatedReady=$paginatedLayoutReady size=${coordinates.size.width}x${coordinates.size.height} " + - "windowBounds=${bounds.left.formatLogFloat()},${bounds.top.formatLogFloat()} " + - "${bounds.width.formatLogFloat()}x${bounds.height.formatLogFloat()}" - ) - } - } else { - Modifier - } - val readerSurfaceModifier = Modifier - .fillMaxWidth() - .weight(1f) - .onSizeChanged { size -> - val next = ReaderViewportSpec(size.width, size.height) - logReaderGap( - "desktop_epub_reader_surface size=${size.width}x${size.height} " + - "mode=${session.reader.settings.readingMode} " + - "page=${session.reader.currentPageIndex + 1}/${session.reader.pages.size.coerceAtLeast(1)}" - ) - logEpubCutoff( - "cutoff_probe layer=desktop_surface size=${size.width}x${size.height} " + - "mode=${session.reader.settings.readingMode} spread=${session.reader.settings.pageSpreadMode} " + - "page=${session.reader.currentPageIndex + 1}/${session.reader.pages.size.coerceAtLeast(1)} " + - "margins=${session.reader.settings.resolvedHorizontalMargin}x${session.reader.settings.resolvedVerticalMargin} " + - "pageWidthSetting=${session.reader.settings.pageWidth}" - ) - logWebViewLayoutDiag( - "compose_reader_surface size=${size.width}x${size.height} " + - "renderPlan=${if (renderPlan is ReaderContentRenderPlan.WebDocument) "web" else "native"} " + - "mode=${session.reader.settings.readingMode} " + - "fullscreen=$isFullscreen margins=${session.reader.settings.resolvedHorizontalMargin}x${session.reader.settings.resolvedVerticalMargin} " + - "pageWidth=${session.reader.settings.pageWidth} fontSize=${session.reader.settings.fontSize} " + - "lineSpacing=${session.reader.settings.lineSpacing} textAlign=${session.reader.settings.textAlign} " + - "paragraphSpacing=${session.reader.settings.paragraphSpacing} imageScale=${session.reader.settings.imageScale}" - ) - logDesktopReaderOpenTrace { - "event=desktop_reader_surface_size bookId=\"${session.reader.book.id.logPreview(120)}\" " + - "title=\"${session.reader.book.title.logPreview(120)}\" size=${size.width}x${size.height} " + - "renderPlan=${renderPlan.desktopReaderSurfaceModeLabel()} mode=${session.reader.settings.readingMode} " + - "page=${session.reader.currentPageIndex + 1}/${session.reader.pages.size.coerceAtLeast(1)}" - } - if (next != readerViewport) { - logEpubPagination( - "viewport_changed width=${next.widthPx} height=${next.heightPx} " + - "previous=${readerViewport.widthPx}x${readerViewport.heightPx}" - ) - readerViewport = next - } - } - LaunchedEffect( - renderPlanModeKey, - session.reader.settings.readingMode, - paginatedLayoutReady - ) { - logReaderModeSwitch( - "surface_state modeKey=$renderPlanModeKey readingMode=${session.reader.settings.readingMode} " + - "renderPlan=${renderPlan.desktopReaderSurfaceModeLabel()} viewport=${readerViewport.widthPx}x${readerViewport.heightPx} " + - "paginatedReady=$paginatedLayoutReady runningPagination=${runningMeasuredPaginationRequest != null} " + - "completedPagination=${completedMeasuredPaginationRequest != null} measuredApplied=$measuredPaginationPagesApplied " + - "warmApplied=$warmMeasuredPaginationPagesApplied warmPageCount=${warmMeasuredPaginationPages.size} " + - "completedMatchesRequest=${completedMeasuredPaginationRequest == measuredPaginationRequest} " + - "measuredPageCount=${completedMeasuredPaginationPages.size} " + - "currentPage=${session.reader.currentPageIndex + 1} " + - "pageCount=${session.reader.pages.size} visiblePages=${session.reader.visiblePages.map { it.pageIndex + 1 }} " + - "fullscreen=$isFullscreen surfaceKey=$readerSurfaceKey" - ) - logDesktopReaderOpenTrace { - "event=desktop_render_plan_ready bookId=\"${session.reader.book.id.logPreview(120)}\" " + - "title=\"${session.reader.book.title.logPreview(120)}\" " + - "renderPlan=${renderPlan.desktopReaderSurfaceModeLabel()} mode=${session.reader.settings.readingMode} " + - "viewport=${readerViewport.widthPx}x${readerViewport.heightPx} " + - "page=${session.reader.currentPageIndex + 1}/${session.reader.pages.size.coerceAtLeast(1)} " + - "htmlChars=${(renderPlan as? ReaderContentRenderPlan.WebDocument)?.html?.length ?: 0} " + - "paginatedReady=$paginatedLayoutReady" - } - if (renderPlan.desktopReaderUsesNativeComposeSurface()) { - cleanupRetiredDesktopWebView2InteropHosts( - readerAwtWindow, - "native_surface_state_ready_$paginatedLayoutReady" - ) - logDesktopWebView2ModeSwitchSnapshot("surface_state_${renderPlanModeKey}_after_sweep_request") - readerAwtWindow.requestDesktopReaderModeSwitchRepaint( - "native_surface_state_ready_$paginatedLayoutReady" - ) - DesktopReaderModeSwitchProbeDelaysMillis.forEach { delayMillis -> - delay(delayMillis) - cleanupRetiredDesktopWebView2InteropHosts( - readerAwtWindow, - "native_probe_after_${delayMillis}ms_ready_$paginatedLayoutReady" - ) - readerAwtWindow.requestDesktopReaderModeSwitchRepaint( - "native_probe_after_${delayMillis}ms_ready_$paginatedLayoutReady" - ) - logReaderModeSwitch( - "native_probe_after delayMs=$delayMillis modeKey=$renderPlanModeKey " + - "viewport=${readerViewport.widthPx}x${readerViewport.heightPx} " + - "paginatedReady=$paginatedLayoutReady currentPage=${session.reader.currentPageIndex + 1} " + - "visiblePages=${session.reader.visiblePages.map { it.pageIndex + 1 }}" - ) - logDesktopWebView2ModeSwitchSnapshot("native_probe_after_${delayMillis}ms") - } - } else { - logDesktopWebView2ModeSwitchSnapshot("surface_state_$renderPlanModeKey") - } - } - DisposableEffect(renderPlanModeKey) { - logReaderModeSwitch( - "surface_enter modeKey=$renderPlanModeKey readingMode=${session.reader.settings.readingMode} " + - "renderPlan=${renderPlan.desktopReaderSurfaceModeLabel()}" - ) - logDesktopWebView2ModeSwitchSnapshot("surface_enter_$renderPlanModeKey") - onDispose { - logReaderModeSwitch( - "surface_exit modeKey=$renderPlanModeKey readingMode=${session.reader.settings.readingMode} " + - "renderPlan=${renderPlan.desktopReaderSurfaceModeLabel()}" - ) - logDesktopWebView2ModeSwitchSnapshot("surface_exit_$renderPlanModeKey") - } - } - @Composable - fun ReaderSurfaceContent() { - if (renderPlan is ReaderContentRenderPlan.NativePaginatedPages && !paginatedLayoutReady) { - DesktopEpubPaginationPreparing( - active = runningMeasuredPaginationRequest != null, - modifier = Modifier.fillMaxSize() - ) - } else { - when (renderPlan) { - is ReaderContentRenderPlan.WebDocument -> { - val canRenderWebDocument = desktopEpubWebViewCanRender(webViewRuntimeState) - LaunchedEffect( - renderPlan.html, - canRenderWebDocument, - webViewRuntimeState, - webViewNetworkAccessEnabled - ) { - logDesktopWebView2( - "reader_screen_web_document canRender=$canRenderWebDocument " + - "backend=${desktopEpubWebViewBackend().logName} " + - "runtimeInitialized=${webViewRuntimeState.initialized} restart=${webViewRuntimeState.restartRequired} " + - "error=${webViewRuntimeState.errorMessage != null} network=$webViewNetworkAccessEnabled " + - "htmlChars=${renderPlan.html.length} htmlHash=${renderPlan.html.hashCode()}" - ) - } - if (canRenderWebDocument) { - DesktopEpubWebView( - html = renderPlan.html, - appearanceScript = renderPlan.appearanceScript, - highlightPaletteScript = renderPlan.highlightPaletteScript, - navigationTarget = renderPlan.navigationTarget, - highlights = renderPlan.highlights, - onHighlightCreated = { highlight -> - logEpubHighlightFlow( - "state_reduce_start id=${highlight.id} before=${session.highlights.size} " + - "color=${highlight.color.id} chapter=${highlight.chapterIndex} " + - "offsets=${highlight.locator.startOffset}..${highlight.locator.endOffset} " + - "page=${highlight.locator.pageIndex} textChars=${highlight.text.length}" - ) - val nextSession = session.reduce(ReaderAction.HighlightCreated(highlight), readerEngine) - logEpubHighlightFlow( - "state_reduce_done id=${highlight.id} after=${nextSession.highlights.size} " + - "contains=${nextSession.highlights.any { it.id == highlight.id }}" - ) - onSessionChange(nextSession) - }, - onHighlightSelected = onHighlightSelected, - isFullscreen = isFullscreen, - onKeyboardNavigation = { action -> - val nextSession = session.reduceDesktopReaderKeyNavigation(action, readerEngine) - if (nextSession == null) { - if (action == DesktopReaderKeyNavigation.EXIT_FULLSCREEN && isFullscreen) { - setReaderFullscreen(false) - } - } else { - onSessionChange(nextSession) - } - }, - onSelectionAction = { payload -> - if (payload.action == DesktopReaderSelectionAction.PALETTE) { - onOpenHighlightPaletteManager() - } else { - handleDesktopSelectionAction(payload.action, payload.text, payload.locator) - } - }, - onLinkClicked = handleDesktopEpubLinkClicked, - onVisiblePageChanged = onVisiblePageChanged, - onPointerActivity = onChromeActivity, - networkAccessEnabled = webViewNetworkAccessEnabled, - backgroundColor = renderPlan.background, - modifier = Modifier.fillMaxSize() - ) - } else { - DesktopWebViewRuntimeIndicator( - state = webViewRuntimeState, - modifier = Modifier.fillMaxSize() - ) - } - } - is ReaderContentRenderPlan.NativePaginatedPages -> { - LaunchedEffect(renderPlan.visiblePages, paginatedLayoutReady) { - val pageDetails = renderPlan.visiblePages.joinToString(prefix = "[", postfix = "]") { page -> - "${page.pageIndex + 1}:text=${page.text.length}:blocks=${page.semanticBlocks.size}" - } - logReaderModeSwitch( - "native_reader_render paginatedReady=$paginatedLayoutReady " + - "visiblePages=${renderPlan.visiblePages.map { it.pageIndex + 1 }} " + - "pageDetails=$pageDetails " + - "background=${renderPlan.background} foreground=${renderPlan.foreground}" - ) - } - Box( - modifier = Modifier - .fillMaxSize() - .onGloballyPositioned { coordinates -> - val bounds = coordinates.boundsInWindow() - logReaderModeSwitch( - "native_reader_content_layout size=${coordinates.size.width}x${coordinates.size.height} " + - "windowBounds=${bounds.left.formatLogFloat()},${bounds.top.formatLogFloat()} " + - "${bounds.width.formatLogFloat()}x${bounds.height.formatLogFloat()} " + - "visiblePages=${renderPlan.visiblePages.map { it.pageIndex + 1 }}" - ) - } - ) { - SharedNativePaginatedReader( - renderPlan = renderPlan, - readerFontFamily = renderPlan.settings.toDesktopReaderFontFamily(), - searchHighlight = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.72f), - onVisiblePageChanged = onVisiblePageChanged, - enabledSelectionActions = nativeSelectionActions, - onCopyText = { text -> clipboardManager.setText(AnnotatedString(text)) }, - onSelectionAction = handleNativeSelectionAction, - onOpenHighlightPaletteManager = onOpenHighlightPaletteManager, - onHighlightCreated = { highlight -> - logDesktopHighlightMap( - "native_state_reduce_start id=${highlight.id.logPreview(80)} before=${session.highlights.size} " + - "color=${highlight.color.id} chapter=${highlight.chapterIndex} " + - "page=${highlight.locator.pageIndex} offsets=${highlight.locator.startOffset}..${highlight.locator.endOffset} " + - "block=${highlight.locator.blockIndex} char=${highlight.locator.charOffset} " + - "textChars=${highlight.text.length} cfi=\"${highlight.cfi.logPreview(160)}\"" - ) - val nextSession = session.reduce(ReaderAction.HighlightCreated(highlight), readerEngine) - logDesktopHighlightMap( - "native_state_reduce_done id=${highlight.id.logPreview(80)} after=${nextSession.highlights.size} " + - "contains=${nextSession.highlights.any { it.id == highlight.id }}" - ) - onSessionChange(nextSession) - }, - onHighlightSelected = onHighlightSelected, - onLinkClicked = { link -> - handleDesktopEpubLinkClicked(link.toDesktopEpubLinkClick()) - }, - onReaderTap = onChromeActivity, - imageContent = { image, imageModifier -> - DesktopEpubNativeImage( - image = image, - modifier = imageModifier - ) - }, - modifier = Modifier.fillMaxSize() - ) - } - } - is ReaderContentRenderPlan.NativeVerticalPages -> { - LaunchedEffect(renderPlan.book.id, renderPlan.pages, renderPlan.currentPageIndex) { - logReaderModeSwitch( - "native_vertical_reader_render currentPage=${renderPlan.currentPageIndex + 1} " + - "pages=${renderPlan.pages.size} chapters=${renderPlan.book.chapters.size} " + - "semanticBlocks=${renderPlan.book.chapters.sumOf { it.semanticBlocks.size }} " + - "background=${renderPlan.background} foreground=${renderPlan.foreground}" - ) - } - Box( - modifier = Modifier - .fillMaxSize() - .onGloballyPositioned { coordinates -> - val bounds = coordinates.boundsInWindow() - logReaderModeSwitch( - "native_vertical_reader_content_layout size=${coordinates.size.width}x${coordinates.size.height} " + - "windowBounds=${bounds.left.formatLogFloat()},${bounds.top.formatLogFloat()} " + - "${bounds.width.formatLogFloat()}x${bounds.height.formatLogFloat()} " + - "currentPage=${renderPlan.currentPageIndex + 1}" - ) - } - ) { - SharedNativeVerticalReader( - renderPlan = renderPlan, - readerFontFamily = renderPlan.settings.toDesktopReaderFontFamily(), - searchHighlight = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.72f), - onVisiblePageChanged = onVisiblePageChanged, - enabledSelectionActions = nativeSelectionActions, - onCopyText = { text -> clipboardManager.setText(AnnotatedString(text)) }, - onSelectionAction = handleNativeSelectionAction, - onOpenHighlightPaletteManager = onOpenHighlightPaletteManager, - onHighlightCreated = { highlight -> - logDesktopHighlightMap( - "native_vertical_state_reduce_start id=${highlight.id.logPreview(80)} before=${session.highlights.size} " + - "color=${highlight.color.id} chapter=${highlight.chapterIndex} " + - "page=${highlight.locator.pageIndex} offsets=${highlight.locator.startOffset}..${highlight.locator.endOffset} " + - "block=${highlight.locator.blockIndex} char=${highlight.locator.charOffset} " + - "textChars=${highlight.text.length} cfi=\"${highlight.cfi.logPreview(160)}\"" - ) - val nextSession = session.reduce(ReaderAction.HighlightCreated(highlight), readerEngine) - logDesktopHighlightMap( - "native_vertical_state_reduce_done id=${highlight.id.logPreview(80)} after=${nextSession.highlights.size} " + - "contains=${nextSession.highlights.any { it.id == highlight.id }}" - ) - onSessionChange(nextSession) - }, - onHighlightSelected = onHighlightSelected, - onLinkClicked = { link -> - handleDesktopEpubLinkClicked(link.toDesktopEpubLinkClick()) - }, - onReaderTap = onChromeActivity, - imageContent = { image, imageModifier -> - DesktopEpubNativeImage( - image = image, - modifier = imageModifier - ) - }, - modifier = Modifier.fillMaxSize() - ) - } - } - } - } - } - - key(readerSurfaceKey) { - if (renderPlan is ReaderContentRenderPlan.WebDocument) { - Box( - modifier = readerSurfaceModifier - .fillMaxSize() - .background(renderPlan.background) - ) { - ReaderSurfaceContent() - } - } else { - Surface( - color = renderPlan.background, - shape = RoundedCornerShape(if (isFullscreen) 0.dp else 4.dp), - modifier = readerSurfaceModifier - .fillMaxSize() - .clip(RoundedCornerShape(if (isFullscreen) 0.dp else 4.dp)) - .then(readerModeSwitchLayoutModifier) - ) { - ReaderSurfaceContent() - } - } - } - } -} - -private fun ReaderContentRenderPlan.desktopReaderSurfaceModeKey(): String { - return when (this) { - is ReaderContentRenderPlan.WebDocument -> "desktop-reader-web" - is ReaderContentRenderPlan.NativePaginatedPages -> "desktop-reader-native" - is ReaderContentRenderPlan.NativeVerticalPages -> "desktop-reader-native-vertical" - } -} - -private fun ReaderContentRenderPlan.desktopReaderSurfaceModeLabel(): String { - return when (this) { - is ReaderContentRenderPlan.WebDocument -> "web" - is ReaderContentRenderPlan.NativePaginatedPages -> "native" - is ReaderContentRenderPlan.NativeVerticalPages -> "native-vertical" - } -} - -private fun ReaderContentRenderPlan.desktopReaderSurfaceContentKey(paginatedLayoutReady: Boolean): String { - return when (this) { - is ReaderContentRenderPlan.WebDocument -> "desktop-reader-web" - is ReaderContentRenderPlan.NativePaginatedPages -> - "desktop-reader-native-${if (paginatedLayoutReady) "ready" else "preparing"}" - is ReaderContentRenderPlan.NativeVerticalPages -> "desktop-reader-native-vertical" - } -} - -private fun ReaderContentRenderPlan.desktopReaderUsesNativeComposeSurface(): Boolean { - return this is ReaderContentRenderPlan.NativePaginatedPages || - this is ReaderContentRenderPlan.NativeVerticalPages -} - -private fun Window?.requestDesktopReaderModeSwitchRepaint(reason: String) { - val targetWindow = this - EventQueue.invokeLater { - if (targetWindow == null) { - logReaderModeSwitch("awt_repaint_skip reason=$reason window=null") - return@invokeLater - } - if (!targetWindow.isDisplayable) { - logReaderModeSwitch( - "awt_repaint_skip reason=$reason window=${targetWindow.javaClass.simpleName} " + - "displayable=false visible=${targetWindow.isVisible} showing=${targetWindow.isShowing} " + - "size=${targetWindow.width}x${targetWindow.height}" - ) - return@invokeLater - } - targetWindow.invalidate() - targetWindow.validate() - targetWindow.repaint() - (targetWindow as? javax.swing.RootPaneContainer)?.contentPane?.let { contentPane -> - contentPane.invalidate() - contentPane.validate() - contentPane.repaint() - } - logReaderModeSwitch( - "awt_repaint reason=$reason window=${targetWindow.javaClass.simpleName} " + - "visible=${targetWindow.isVisible} displayable=${targetWindow.isDisplayable} " + - "showing=${targetWindow.isShowing} size=${targetWindow.width}x${targetWindow.height}" - ) - } -} - -private val DesktopReaderModeSwitchProbeDelaysMillis = longArrayOf(120L, 350L, 900L) - -private fun Long.elapsedMillis(): Long { - return ((System.nanoTime() - this) / 1_000_000L).coerceAtLeast(0L) -} - -private fun ReaderImageReference.toDesktopPreviewSemanticImage(): SemanticImage { - return SemanticImage( - path = source, - altText = altText, - intrinsicWidth = intrinsicWidth, - intrinsicHeight = intrinsicHeight, - style = CssStyle(), - elementId = null, - cfi = cfi, - blockIndex = blockIndex - ) -} - -private fun ReaderSessionState.reduceDesktopReaderKeyNavigation( - action: DesktopReaderKeyNavigation, - readerEngine: ReaderEngine -): ReaderSessionState? { - return when (action) { - DesktopReaderKeyNavigation.NEXT -> reduce(ReaderAction.NextPage, readerEngine) - DesktopReaderKeyNavigation.PREVIOUS -> reduce(ReaderAction.PreviousPage, readerEngine) - DesktopReaderKeyNavigation.FIRST -> reduce(ReaderAction.JumpToPage(0), readerEngine) - DesktopReaderKeyNavigation.LAST -> reduce(ReaderAction.JumpToPage(reader.pages.lastIndex), readerEngine) - DesktopReaderKeyNavigation.SEARCH -> reduce(ReaderAction.SearchOpened, readerEngine) - DesktopReaderKeyNavigation.NEXT_SEARCH -> reduce(ReaderAction.JumpToNextSearchResult, readerEngine) - DesktopReaderKeyNavigation.EXIT_FULLSCREEN -> null - } -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderTexturePreview.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderTexturePreview.kt deleted file mode 100644 index 6789d5c..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderTexturePreview.kt +++ /dev/null @@ -1,43 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.font.FontWeight - -@Composable -internal fun DesktopReaderTexturePreview( - textureId: String, - modifier: Modifier = Modifier -) { - val bitmap = remember(textureId) { DesktopReaderTextures.imageBitmapFor(textureId) } - if (bitmap != null) { - Image( - bitmap = bitmap, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = modifier - ) - } else { - Box( - modifier = modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surfaceVariant), - contentAlignment = Alignment.Center - ) { - Text( - "Aa", - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontWeight = FontWeight.Bold - ) - } - } -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderTextures.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderTextures.kt deleted file mode 100644 index 143aefa..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderTextures.kt +++ /dev/null @@ -1,98 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.toComposeImageBitmap -import org.dueattendant149.bookreader.shared.ReaderTexture -import org.dueattendant149.bookreader.shared.ReaderTextureFilePrefix -import org.dueattendant149.bookreader.shared.ReaderTextureImportExtensions -import org.dueattendant149.bookreader.shared.readerTextureMimeTypeForExtension -import java.io.ByteArrayInputStream -import java.io.File -import java.util.Base64 -import java.util.Locale -import javax.imageio.ImageIO - -internal object DesktopReaderTextures { - private val bytesCache = mutableMapOf() - private val dataUriCache = mutableMapOf() - private val imageCache = mutableMapOf() - - fun importedTextureIds(): List { - return readerTextureDirectory() - .listFiles { file -> file.isFile && file.extension.lowercase(Locale.ROOT) in ReaderTextureImportExtensions } - ?.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 ReaderTextureImportExtensions } - ?: 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:${readerTextureMimeTypeForExtension(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 { - return File(desktopUserDataRoot(), "reader_textures") - } -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderTypography.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderTypography.kt deleted file mode 100644 index c1301d3..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderTypography.kt +++ /dev/null @@ -1,105 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.platform.Font as DesktopFont -import org.dueattendant149.bookreader.shared.AppFontPreference -import org.dueattendant149.bookreader.shared.AppFontPreferenceKind -import org.dueattendant149.bookreader.shared.CustomFontItem -import org.dueattendant149.bookreader.shared.reader.ReaderPage -import org.dueattendant149.bookreader.shared.reader.ReaderSettings -import org.dueattendant149.bookreader.shared.detectFontVariant -import org.dueattendant149.bookreader.shared.familyFilenameSignature -import org.dueattendant149.bookreader.shared.supportsVariableWeightAxis -import java.io.File - -internal fun ReaderSettings.toDesktopReaderFontFamily(): FontFamily { - customFontPath?.takeIf { it.isNotBlank() }?.let { path -> - val baseFile = File(path) - val signature = baseFile.nameWithoutExtension.familyFilenameSignature() - val siblings = baseFile.parentFile?.listFiles()?.filter { - it.isFile && it.extension.lowercase() in setOf("ttf", "otf", "woff", "woff2") && - it.nameWithoutExtension.familyFilenameSignature() == signature - } ?: listOf(baseFile) - - val seenVariants = mutableSetOf() - val fontList = siblings.flatMap { sibling -> - try { - val variant = sibling.nameWithoutExtension.detectFontVariant() - val weights = if (sibling.nameWithoutExtension.supportsVariableWeightAxis()) { - variableDesktopReaderFontWeights - } else { - listOf(variant?.weight ?: androidx.compose.ui.text.font.FontWeight.Normal) - } - weights.mapNotNull { weight -> - val style = variant?.style ?: androidx.compose.ui.text.font.FontStyle.Normal - if (seenVariants.add("${weight.weight}|$style")) { - DesktopFont(sibling, weight, style) - } else { - null - } - } - } catch (e: Exception) { - emptyList() - } - } - if (fontList.isNotEmpty()) { - return FontFamily(fontList) - } - } - return fontFamily.toComposeFontFamily() -} - -private fun String.toComposeFontFamily(): FontFamily { - return when (this) { - "Serif" -> FontFamily.Serif - "Sans" -> FontFamily.SansSerif - "Mono" -> FontFamily.Monospace - else -> FontFamily.Default - } -} - -private val variableDesktopReaderFontWeights = listOf( - androidx.compose.ui.text.font.FontWeight.Thin, - androidx.compose.ui.text.font.FontWeight.ExtraLight, - androidx.compose.ui.text.font.FontWeight.Light, - androidx.compose.ui.text.font.FontWeight.Normal, - androidx.compose.ui.text.font.FontWeight.Medium, - androidx.compose.ui.text.font.FontWeight.SemiBold, - androidx.compose.ui.text.font.FontWeight.Bold, - androidx.compose.ui.text.font.FontWeight.ExtraBold, - androidx.compose.ui.text.font.FontWeight.Black -) - -internal fun List.samePageLayoutAs(other: List): Boolean { - if (size != other.size) return false - return indices.all { index -> - val left = this[index] - val right = other[index] - left.pageIndex == right.pageIndex && - left.chapterIndex == right.chapterIndex && - left.startOffset == right.startOffset && - left.endOffset == right.endOffset && - left.text.length == right.text.length && - left.semanticBlocks == right.semanticBlocks - } -} - -internal fun CustomFontItem.toDesktopPreviewFontFamily(): FontFamily? { - val file = File(path).takeIf { it.isFile } ?: return null - return runCatching { FontFamily(DesktopFont(file)) }.getOrNull() -} - -internal fun AppFontPreference.toDesktopAppFontFamily(customFonts: List): FontFamily? { - val sanitized = sanitized() - return when (sanitized.kind) { - AppFontPreferenceKind.SYSTEM -> null - AppFontPreferenceKind.SERIF -> FontFamily.Serif - AppFontPreferenceKind.SANS_SERIF -> FontFamily.SansSerif - AppFontPreferenceKind.MONOSPACE -> FontFamily.Monospace - AppFontPreferenceKind.CUSTOM -> { - val fontId = sanitized.customFontId ?: return null - customFonts.firstOrNull { it.id == fontId && !it.isDeleted } - ?.toDesktopPreviewFontFamily() - } - } -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderWindowState.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderWindowState.kt deleted file mode 100644 index 99ccd30..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderWindowState.kt +++ /dev/null @@ -1,190 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import androidx.compose.ui.unit.DpSize -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.WindowPlacement -import org.dueattendant149.bookreader.shared.BookItem -import org.dueattendant149.bookreader.shared.ReaderCloudTtsState -import org.dueattendant149.bookreader.shared.ReaderExtrasState -import org.dueattendant149.bookreader.shared.RecapResult -import org.dueattendant149.bookreader.shared.SummarizationResult -import org.dueattendant149.bookreader.shared.reader.ReaderReadingMode -import org.dueattendant149.bookreader.shared.reader.ReaderSessionState -import kotlinx.coroutines.Job - -internal const val DesktopReaderWindowDefaultWidthDp = 1120f -internal const val DesktopReaderWindowDefaultHeightDp = 760f -internal val DesktopReaderWindowDefaultSize = DpSize( - DesktopReaderWindowDefaultWidthDp.dp, - DesktopReaderWindowDefaultHeightDp.dp -) - -internal fun DesktopWindowStateSnapshot.toReaderWindowPlacement(): WindowPlacement { - return when (placement) { - DesktopSavedWindowPlacement.FULLSCREEN -> WindowPlacement.Floating - else -> toWindowPlacement() - } -} - -internal fun DesktopWindowStateSnapshot.toPersistableReaderWindowSnapshot(): DesktopWindowStateSnapshot? { - if (placement == DesktopSavedWindowPlacement.FULLSCREEN) return null - return sanitized() -} - -internal fun shouldResetDesktopTextReaderWindowSurface( - previousMode: ReaderReadingMode, - currentMode: ReaderReadingMode, - usesNativeWebView: Boolean -): Boolean { - return usesNativeWebView && - previousMode == ReaderReadingMode.VERTICAL && - currentMode == ReaderReadingMode.PAGINATED -} - -internal data class DesktopReaderWindowState( - val id: String, - val opening: DesktopReaderOpening, - val content: DesktopReaderWindowContent = DesktopReaderWindowContent.Opening, - val focusRequestId: Long = 0L, - val fullscreen: Boolean = false, - val surfaceResetId: Long = 0L -) { - val bookId: String - get() = opening.bookId - - val title: String - get() = when (content) { - DesktopReaderWindowContent.Opening -> opening.title - is DesktopReaderWindowContent.PasswordRequired -> content.book.cardTitleForMessage() - is DesktopReaderWindowContent.Pdf -> content.book.cardTitleForMessage() - is DesktopReaderWindowContent.Text -> content.book.cardTitleForMessage() - } - - val formatLabel: String - get() = opening.formatLabel -} - -internal sealed interface DesktopReaderWindowContent { - data object Opening : DesktopReaderWindowContent - - data class PasswordRequired( - val book: BookItem, - val attemptedPassword: Boolean - ) : DesktopReaderWindowContent - - data class Pdf( - val book: BookItem, - val document: DesktopPdfDocument - ) : DesktopReaderWindowContent - - data class Text( - val book: BookItem, - val session: ReaderSessionState, - val extrasState: ReaderExtrasState = ReaderExtrasState( - cloudTts = ReaderCloudTtsState() - ), - val showAiHub: Boolean = false, - val readerAiResultRequestId: Long = 0L, - val dismissedReaderAiResultRequestId: Long? = null, - val summaryResult: SummarizationResult? = null, - val recapResult: RecapResult? = null, - val isSummaryLoading: Boolean = false, - val isRecapLoading: Boolean = false, - val recapProgressMessage: String? = null, - val ttsJob: Job? = null - ) : DesktopReaderWindowContent -} - -internal data class DesktopReaderWindowOpenDecision( - val windows: List, - val shouldStartOpen: Boolean -) - -internal fun List.openOrFocusDesktopReaderWindow( - opening: DesktopReaderOpening, - force: Boolean -): DesktopReaderWindowOpenDecision { - val existing = firstOrNull { it.bookId == opening.bookId } - if (existing != null && !force) { - return DesktopReaderWindowOpenDecision( - windows = map { window -> - if (window.id == existing.id) { - window.copy(focusRequestId = window.focusRequestId + 1) - } else { - window - } - }, - shouldStartOpen = false - ) - } - - val replacement = DesktopReaderWindowState( - id = existing?.id ?: opening.bookId.ifBlank { opening.requestId.toString() }, - opening = opening, - focusRequestId = (existing?.focusRequestId ?: 0L) + 1 - ) - return DesktopReaderWindowOpenDecision( - windows = filterNot { it.bookId == opening.bookId } + replacement, - shouldStartOpen = true - ) -} - -internal fun List.focusDesktopReaderWindow(bookId: String): List { - return map { window -> - if (window.bookId == bookId) { - window.copy(focusRequestId = window.focusRequestId + 1) - } else { - window - } - } -} - -internal fun List.withDesktopReaderWindowContent( - requestId: Long, - content: DesktopReaderWindowContent -): List { - return map { window -> - if (window.opening.requestId == requestId) { - window.copy(content = content) - } else { - window - } - } -} - -internal fun List.withoutDesktopReaderWindow(windowId: String): List { - return filterNot { it.id == windowId } -} - -internal fun List.withoutDesktopReaderBookIds( - bookIds: Set -): List { - return filterNot { it.bookId in bookIds } -} - -internal fun List.replaceDesktopTextReaderContent( - windowId: String, - transform: (DesktopReaderWindowContent.Text) -> DesktopReaderWindowContent.Text -): List { - return map { window -> - val content = window.content - if (window.id == windowId && content is DesktopReaderWindowContent.Text) { - window.copy(content = transform(content)) - } else { - window - } - } -} - -internal fun List.replaceAllDesktopTextReaderContent( - transform: (DesktopReaderWindowContent.Text) -> DesktopReaderWindowContent.Text -): List { - return map { window -> - val content = window.content - if (content is DesktopReaderWindowContent.Text) { - window.copy(content = transform(content)) - } else { - window - } - } -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopStartupSplash.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopStartupSplash.kt deleted file mode 100644 index 11c49d9..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopStartupSplash.kt +++ /dev/null @@ -1,163 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import java.awt.BorderLayout -import java.awt.Color -import java.awt.Component -import java.awt.Dimension -import java.awt.EventQueue -import java.awt.Font -import java.awt.GraphicsEnvironment -import java.awt.Image -import java.lang.reflect.InvocationTargetException -import java.util.concurrent.atomic.AtomicReference -import javax.swing.BorderFactory -import javax.swing.Box -import javax.swing.BoxLayout -import javax.swing.ImageIcon -import javax.swing.JLabel -import javax.swing.JPanel -import javax.swing.JProgressBar -import javax.swing.JWindow -import javax.swing.SwingConstants - -internal data class DesktopStartupSplashSpec( - val title: String = EpistemeDesktopWindowTitle, - val message: String = "Opening your library", - val width: Int = 360, - val height: Int = 220 -) - -internal fun epistemeDesktopStartupSplashSpec( - profile: DesktopBuildProfile = currentDesktopBuildProfile() -): DesktopStartupSplashSpec { - return DesktopStartupSplashSpec(title = profile.appName) -} - -internal class DesktopStartupSplash private constructor( - private val window: JWindow -) { - fun close() { - runOnSplashEventThread { - window.isVisible = false - window.dispose() - } - } - - companion object { - fun show(spec: DesktopStartupSplashSpec = epistemeDesktopStartupSplashSpec()): DesktopStartupSplash? { - if (GraphicsEnvironment.isHeadless()) return null - - val splashRef = AtomicReference() - runOnSplashEventThreadAndWait { - runCatching { - val window = JWindow().apply { - name = "episteme-startup-splash" - preferredSize = Dimension(spec.width, spec.height) - minimumSize = Dimension(spec.width, spec.height) - background = SplashBackground - contentPane = startupSplashContent(spec) - pack() - setLocationRelativeTo(null) - isAlwaysOnTop = true - isVisible = true - } - splashRef.set(DesktopStartupSplash(window)) - } - } - return splashRef.get() - } - } -} - -private fun startupSplashContent(spec: DesktopStartupSplashSpec): JPanel { - return JPanel(BorderLayout()).apply { - preferredSize = Dimension(spec.width, spec.height) - background = SplashBackground - border = BorderFactory.createLineBorder(SplashBorder) - - val body = JPanel().apply { - background = SplashBackground - layout = BoxLayout(this, BoxLayout.Y_AXIS) - border = BorderFactory.createEmptyBorder(24, 28, 22, 28) - } - - startupSplashIcon()?.let { icon -> - body.add( - JLabel(icon).apply { - alignmentX = Component.CENTER_ALIGNMENT - horizontalAlignment = SwingConstants.CENTER - } - ) - body.add(Box.createVerticalStrut(14)) - } - - body.add( - JLabel(spec.title).apply { - alignmentX = Component.CENTER_ALIGNMENT - horizontalAlignment = SwingConstants.CENTER - foreground = SplashTitle - font = font.deriveFont(Font.BOLD, 24f) - } - ) - body.add(Box.createVerticalStrut(8)) - body.add( - JLabel(spec.message).apply { - alignmentX = Component.CENTER_ALIGNMENT - horizontalAlignment = SwingConstants.CENTER - foreground = SplashText - font = font.deriveFont(Font.PLAIN, 13f) - } - ) - body.add(Box.createVerticalStrut(20)) - body.add( - JProgressBar().apply { - alignmentX = Component.CENTER_ALIGNMENT - isIndeterminate = true - isBorderPainted = false - preferredSize = Dimension(220, 8) - maximumSize = Dimension(220, 8) - foreground = SplashAccent - background = SplashTrack - } - ) - - add(body, BorderLayout.CENTER) - } -} - -private fun startupSplashIcon(): ImageIcon? { - val resource = Thread.currentThread().contextClassLoader?.getResource(EpistemeDesktopWindowIconResource) - ?: DesktopStartupSplash::class.java.classLoader?.getResource(EpistemeDesktopWindowIconResource) - ?: return null - val icon = ImageIcon(resource) - return ImageIcon(icon.image.getScaledInstance(56, 56, Image.SCALE_SMOOTH)) -} - -private fun runOnSplashEventThread(block: () -> Unit) { - if (EventQueue.isDispatchThread()) { - block() - } else { - EventQueue.invokeLater { block() } - } -} - -private fun runOnSplashEventThreadAndWait(block: () -> Unit) { - if (EventQueue.isDispatchThread()) { - block() - return - } - try { - EventQueue.invokeAndWait { block() } - } catch (_: InterruptedException) { - Thread.currentThread().interrupt() - } catch (_: InvocationTargetException) { - // Startup feedback should never prevent the app from launching. - } -} - -private val SplashBackground = Color(0xF9, 0xF7, 0xEF) -private val SplashBorder = Color(0xD8, 0xD2, 0xC3) -private val SplashTitle = Color(0x1E, 0x22, 0x1A) -private val SplashText = Color(0x61, 0x64, 0x58) -private val SplashAccent = Color(0x2F, 0x6F, 0x68) -private val SplashTrack = Color(0xE3, 0xDE, 0xD1) diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopStringResources.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopStringResources.kt deleted file mode 100644 index 6432e60..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopStringResources.kt +++ /dev/null @@ -1,250 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import org.dueattendant149.bookreader.shared.ui.SharedStringResolver -import org.w3c.dom.Element -import java.io.InputStream -import java.util.Locale -import javax.xml.XMLConstants -import javax.xml.parsers.DocumentBuilderFactory -import kotlin.math.abs - -internal fun loadDesktopStringResolver( - locale: Locale = currentDesktopStringsLocale(), - classLoader: ClassLoader = Thread.currentThread().contextClassLoader - ?: DesktopAndroidStringResources::class.java.classLoader -): SharedStringResolver { - val resources = DesktopAndroidStringResources.load(locale = locale, classLoader = classLoader) - return SharedStringResolver( - resolve = resources::stringOrNull, - resolveQuantity = resources::quantityStringOrNull - ) -} - -internal data class DesktopAndroidStringResources( - private val strings: Map, - private val plurals: Map>, - private val locale: Locale -) { - fun stringOrNull(name: String): String? = strings[name] - - fun quantityStringOrNull(name: String, quantity: Int): String? { - val items = plurals[name].orEmpty() - if (items.isEmpty()) return null - val quantityName = desktopAndroidPluralQuantity(locale, quantity, items.keys) - return items[quantityName] ?: items["other"] ?: items.values.firstOrNull() - } - - companion object { - fun load( - locale: Locale, - classLoader: ClassLoader - ): DesktopAndroidStringResources { - val fallback = loadResourceMap(classLoader, "$DesktopAndroidStringsRoot/values/strings.xml") - val localized = desktopAndroidStringResourcePaths(locale) - .asReversed() - .fold(emptyMap()) { merged, path -> - merged + loadResourceMap(classLoader, path) - } - val fallbackPlurals = loadPluralMap(classLoader, "$DesktopAndroidStringsRoot/values/plurals.xml") - val localizedPlurals = desktopAndroidPluralResourcePaths(locale) - .asReversed() - .fold(emptyMap>()) { merged, path -> - merged + loadPluralMap(classLoader, path) - } - return DesktopAndroidStringResources( - strings = fallback + localized, - plurals = fallbackPlurals + localizedPlurals, - locale = locale - ) - } - - private fun loadResourceMap(classLoader: ClassLoader, path: String): Map { - val stream = classLoader.getResourceAsStream(path) ?: return emptyMap() - return stream.use(::parseAndroidStringXml) - } - - private fun loadPluralMap(classLoader: ClassLoader, path: String): Map> { - val stream = classLoader.getResourceAsStream(path) ?: return emptyMap() - return stream.use(::parseAndroidPluralXml) - } - } -} - -internal fun desktopAndroidStringResourcePaths(locale: Locale): List { - return desktopAndroidResourcePaths(locale, "strings.xml") -} - -internal fun desktopAndroidPluralResourcePaths(locale: Locale): List { - return desktopAndroidResourcePaths(locale, "plurals.xml") -} - -private fun desktopAndroidResourcePaths(locale: Locale, fileName: String): List { - val language = locale.language.takeIf { it.isNotBlank() } ?: return emptyList() - val country = locale.country.takeIf { it.isNotBlank() } - val exact = country?.let { androidValuesFolderFor(language, it) } - val languageOnly = androidValuesFolderFor(language, null) - return listOfNotNull(exact, languageOnly) - .filterNot { it == "values" } - .distinct() - .map { "$DesktopAndroidStringsRoot/$it/$fileName" } -} - -internal fun currentDesktopStringsLocale(): Locale { - val overrideTag = System.getProperty(DesktopLocaleProperty) - ?.trim() - ?.takeIf { it.isNotBlank() } - return overrideTag?.let(Locale::forLanguageTag)?.takeUnless { it.language.isBlank() } - ?: Locale.getDefault() -} - -internal fun desktopLocaleForLanguageTag(languageTag: String?): Locale { - return normalizeDesktopLanguageTag(languageTag) - ?.let(Locale::forLanguageTag) - ?.takeUnless { it.language.isBlank() } - ?: currentDesktopStringsLocale() -} - -internal fun parseAndroidStringXml(stream: InputStream): Map { - val factory = DocumentBuilderFactory.newInstance().apply { - isIgnoringComments = true - isNamespaceAware = false - runCatching { setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true) } - runCatching { setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) } - runCatching { setFeature("http://xml.org/sax/features/external-general-entities", false) } - runCatching { setFeature("http://xml.org/sax/features/external-parameter-entities", false) } - } - val document = factory.newDocumentBuilder().parse(stream) - val nodes = document.getElementsByTagName("string") - val strings = linkedMapOf() - for (index in 0 until nodes.length) { - val element = nodes.item(index) as? Element ?: continue - val name = element.getAttribute("name").takeIf { it.isNotBlank() } ?: continue - strings[name] = element.textContent.orEmpty().decodeAndroidStringEscapes() - } - return strings -} - -internal fun parseAndroidPluralXml(stream: InputStream): Map> { - val factory = secureAndroidXmlDocumentBuilderFactory() - val document = factory.newDocumentBuilder().parse(stream) - val nodes = document.getElementsByTagName("plurals") - val plurals = linkedMapOf>() - for (index in 0 until nodes.length) { - val element = nodes.item(index) as? Element ?: continue - val name = element.getAttribute("name").takeIf { it.isNotBlank() } ?: continue - val items = linkedMapOf() - val itemNodes = element.getElementsByTagName("item") - for (itemIndex in 0 until itemNodes.length) { - val item = itemNodes.item(itemIndex) as? Element ?: continue - val quantity = item.getAttribute("quantity").takeIf { it.isNotBlank() } ?: continue - items[quantity] = item.textContent.orEmpty().decodeAndroidStringEscapes() - } - if (items.isNotEmpty()) plurals[name] = items - } - return plurals -} - -internal fun desktopAndroidPluralQuantity(locale: Locale, quantity: Int, availableQuantities: Set): String { - val preferred = desktopAndroidPluralQuantity(locale, quantity) - return when { - preferred in availableQuantities -> preferred - "other" in availableQuantities -> "other" - else -> availableQuantities.firstOrNull().orEmpty() - } -} - -private fun desktopAndroidPluralQuantity(locale: Locale, quantity: Int): String { - val language = locale.language.lowercase(Locale.ROOT) - val absolute = abs(quantity) - return when (language) { - "ar" -> { - val mod100 = absolute % 100 - when { - absolute == 0 -> "zero" - absolute == 1 -> "one" - absolute == 2 -> "two" - mod100 in 3..10 -> "few" - mod100 in 11..99 -> "many" - else -> "other" - } - } - "ru", "uk", "be" -> { - val mod10 = absolute % 10 - val mod100 = absolute % 100 - when { - mod10 == 1 && mod100 != 11 -> "one" - mod10 in 2..4 && mod100 !in 12..14 -> "few" - mod10 == 0 || mod10 in 5..9 || mod100 in 11..14 -> "many" - else -> "other" - } - } - "pl" -> { - val mod10 = absolute % 10 - val mod100 = absolute % 100 - when { - absolute == 1 -> "one" - mod10 in 2..4 && mod100 !in 12..14 -> "few" - mod10 == 0 || mod10 == 1 || mod10 in 5..9 || mod100 in 12..14 -> "many" - else -> "other" - } - } - "fr" -> if (absolute == 0 || absolute == 1) "one" else "other" - "ja", "ko", "zh", "vi", "id", "in", "tr" -> "other" - else -> if (absolute == 1) "one" else "other" - } -} - -private fun secureAndroidXmlDocumentBuilderFactory(): DocumentBuilderFactory { - return DocumentBuilderFactory.newInstance().apply { - isIgnoringComments = true - isNamespaceAware = false - runCatching { setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true) } - runCatching { setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) } - runCatching { setFeature("http://xml.org/sax/features/external-general-entities", false) } - runCatching { setFeature("http://xml.org/sax/features/external-parameter-entities", false) } - } -} - -private fun androidValuesFolderFor(language: String, country: String?): String { - val normalizedLanguage = language.lowercase(Locale.ROOT) - val resourceLanguage = if (normalizedLanguage == "id") "in" else normalizedLanguage - return if (country.isNullOrBlank()) { - if (resourceLanguage == "en") "values" else "values-$resourceLanguage" - } else { - "values-$resourceLanguage-r${country.uppercase(Locale.ROOT)}" - } -} - -internal fun normalizeDesktopLanguageTag(languageTag: String?): String? { - val normalizedInput = languageTag - ?.trim() - ?.replace('_', '-') - ?.takeIf { it.isNotBlank() } - ?: return null - val canonicalInput = when { - normalizedInput.equals("in", ignoreCase = true) -> "id" - normalizedInput.startsWith("in-", ignoreCase = true) -> "id-${normalizedInput.substringAfter('-')}" - else -> normalizedInput - } - val locale = Locale.forLanguageTag(canonicalInput).takeUnless { it.language.isBlank() } ?: return null - val language = when (locale.language) { - "in" -> "id" - else -> locale.language - } - val country = locale.country.takeIf { it.isNotBlank() } - return if (country == null) { - language - } else { - "$language-${country.uppercase(Locale.ROOT)}" - } -} - -private fun String.decodeAndroidStringEscapes(): String { - return replace("\\'", "'") - .replace("\\\"", "\"") - .replace("\\n", "\n") - .replace("\\t", "\t") -} - -private const val DesktopAndroidStringsRoot = "desktop-android-res" -private const val DesktopLocaleProperty = "episteme.desktop.locale" diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopSummaryCacheStore.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopSummaryCacheStore.kt deleted file mode 100644 index f3f741f..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopSummaryCacheStore.kt +++ /dev/null @@ -1,88 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import java.io.File -import java.security.MessageDigest -import java.util.Base64 -import java.util.Properties - -internal data class DesktopCachedSummaryItem( - val index: Int, - val title: String, - val summary: String -) - -internal class DesktopSummaryCacheStore( - private val root: File = File(desktopUserCacheRoot(), "summary-cache") -) { - fun getSummary(bookKey: String, index: Int): String? { - val file = summaryFile(bookKey, index) - if (!file.isFile) return null - return load(file).getProperty("summary", "").takeIf { it.isNotBlank() } - } - - fun saveSummary(bookKey: String, index: Int, title: String, summary: String) { - if (summary.isBlank()) return - val file = summaryFile(bookKey, index) - file.parentFile?.mkdirs() - Properties().apply { - setProperty("index", index.toString()) - setProperty("title", title) - setProperty("summary", summary) - }.also { properties -> - file.outputStream().use { output -> - properties.store(output, "Episteme desktop summary cache") - } - } - } - - fun getAllSummaries(bookKey: String): List { - val directory = bookDirectory(bookKey) - return directory.listFiles { file -> file.isFile && file.extension == "properties" } - .orEmpty() - .mapNotNull { file -> - val properties = load(file) - val index = properties.getProperty("index")?.toIntOrNull() - ?: file.nameWithoutExtension.toIntOrNull() - ?: return@mapNotNull null - val summary = properties.getProperty("summary", "").takeIf { it.isNotBlank() } - ?: return@mapNotNull null - DesktopCachedSummaryItem( - index = index, - title = properties.getProperty("title", "Chapter ${index + 1}"), - summary = summary - ) - } - .sortedBy { it.index } - } - - fun deleteSummary(bookKey: String, index: Int) { - summaryFile(bookKey, index).delete() - } - - fun clearBookCache(bookKey: String) { - val directory = bookDirectory(bookKey) - directory.listFiles()?.forEach { file -> - if (file.isFile) file.delete() - } - } - - private fun summaryFile(bookKey: String, index: Int): File { - return bookDirectory(bookKey).resolve("$index.properties") - } - - private fun bookDirectory(bookKey: String): File { - return root.resolve(bookKey.cacheKey()) - } - - private fun load(file: File): Properties { - return Properties().apply { - runCatching { file.inputStream().use { input -> load(input) } } - } - } -} - -private fun String.cacheKey(): String { - val digest = MessageDigest.getInstance("SHA-256") - .digest(trim().ifBlank { "untitled" }.toByteArray(Charsets.UTF_8)) - return Base64.getUrlEncoder().withoutPadding().encodeToString(digest).take(32) -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopTtsLog.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopTtsLog.kt deleted file mode 100644 index ddd623d..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopTtsLog.kt +++ /dev/null @@ -1,40 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import org.dueattendant149.bookreader.shared.ReaderTtsChunk - -private const val DesktopTtsLogTag = "EpistemeDesktopTts" -private const val DesktopTtsStartTraceLogTag = "EpistemeDesktopTtsStartTrace" -private val DesktopTtsSensitiveQueryRegex = Regex("""(?i)([?&](?:key|token)=)[^&\s"]+""") -private val DesktopTtsSensitiveLabelRegex = Regex( - """(?i)\b((?:geminiKey|groqKey|api[_-]?key|authorization|token)\s*[:=]\s*)[^\s,;"]+""" -) - -internal fun logDesktopTts(message: String) { - logDesktopDiagnostic(DesktopTtsLogTag) { message } -} - -internal fun logDesktopTtsStartTrace(message: () -> String) { - logDesktopDiagnostic(DesktopTtsStartTraceLogTag, message) -} - -internal fun ReaderTtsChunk?.desktopTtsStartTraceSummary(maxTextLength: Int = 120): String { - if (this == null) return "null" - return "index=$index page=${pageIndex + 1} chapter=$chapterIndex " + - "offsets=$startOffset..$endOffset sourceCfi=\"${sourceCfi.orEmpty().logPreview(160)}\" " + - "textChars=${text.length} spokenChars=${spokenText.length} " + - "text=\"${text.logPreview(maxTextLength)}\" spoken=\"${spokenText.logPreview(maxTextLength)}\"" -} - -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(DesktopTtsSensitiveQueryRegex) { match -> match.groupValues[1] + "" } - .replace(DesktopTtsSensitiveLabelRegex) { match -> match.groupValues[1] + "" } - .replace(Regex("\\s+"), " ") - .trim() - .let { if (it.length <= maxLength) it else it.take(maxLength) + "..." } - .replace("\"", "\\\"") -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWindowPolish.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWindowPolish.kt deleted file mode 100644 index 50aac69..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWindowPolish.kt +++ /dev/null @@ -1,291 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.luminance -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.unit.DpSize -import androidx.compose.ui.unit.dp -import com.sun.jna.Native -import com.sun.jna.Pointer -import com.sun.jna.ptr.IntByReference -import com.sun.jna.win32.StdCallLibrary -import java.awt.Component -import java.awt.Container -import java.awt.Dimension -import java.awt.EventQueue -import java.awt.Color as AwtColor -import java.awt.Window as AwtWindow -import java.util.concurrent.atomic.AtomicReference -import javax.swing.RootPaneContainer -import javax.swing.SwingUtilities -import kotlinx.coroutines.delay - -internal const val EpistemeDesktopWindowTitle = EpistemeDesktopStandardAppName -internal const val EpistemeDesktopWindowIconResource = "episteme_icon.png" -internal const val EpistemeDesktopWindowMinimumWidthPx = 960 -internal const val EpistemeDesktopWindowMinimumHeightPx = 640 - -internal data class DesktopWindowDefaults( - val title: String, - val defaultSize: DpSize, - val minimumSize: Dimension, - val iconResourcePath: String -) - -internal fun epistemeDesktopWindowDefaults( - profile: DesktopBuildProfile = currentDesktopBuildProfile() -): DesktopWindowDefaults { - return DesktopWindowDefaults( - title = profile.appName, - defaultSize = DpSize(1280.dp, 820.dp), - minimumSize = Dimension(EpistemeDesktopWindowMinimumWidthPx, EpistemeDesktopWindowMinimumHeightPx), - iconResourcePath = EpistemeDesktopWindowIconResource - ) -} - -internal data class DesktopWindowChromeColors( - val useDarkMode: Boolean, - val captionColorRef: Int, - val textColorRef: Int, - val borderColorRef: Int -) - -internal fun desktopWindowChromeColors( - captionColor: Color, - textColor: Color, - borderColor: Color -): DesktopWindowChromeColors { - return DesktopWindowChromeColors( - useDarkMode = captionColor.luminance() < 0.5f, - captionColorRef = captionColor.toWindowsColorRef(), - textColorRef = textColor.toWindowsColorRef(), - borderColorRef = borderColor.toWindowsColorRef() - ) -} - -@Composable -internal fun EpistemeDesktopWindowChromeEffect( - window: Component?, - captionColor: Color, - textColor: Color, - borderColor: Color -) { - DisposableEffect(window, captionColor, textColor, borderColor) { - applyDesktopWindowBackground(window, borderColor) - applyWindowsDesktopWindowChrome( - window = window, - colors = desktopWindowChromeColors( - captionColor = captionColor, - textColor = textColor, - borderColor = borderColor - ) - ) - onDispose {} - } -} - -@Composable -internal fun EpistemeDesktopWindowDecorationEffect( - window: Component?, - hideDecoration: Boolean -) { - val originalStyle = remember(window) { AtomicReference(null) } - LaunchedEffect(window, hideDecoration) { - delay(if (hideDecoration) 120L else 80L) - applyWindowsDesktopWindowDecoration( - window = window, - hideDecoration = hideDecoration, - originalStyle = originalStyle - ) - } - DisposableEffect(window, hideDecoration) { - onDispose { - if (hideDecoration) { - applyWindowsDesktopWindowDecoration( - window = window, - hideDecoration = false, - originalStyle = originalStyle - ) - } - } - } -} - -internal fun isWindowsDesktop(osName: String = System.getProperty("os.name").orEmpty()): Boolean { - return osName.startsWith("Windows", ignoreCase = true) -} - -private fun applyDesktopWindowBackground(window: Component?, color: Color) { - val awtColor = color.toAwtOpaqueColor() - runOnEventDispatchThread { - val awtWindow = window.toAwtWindowOrNull() - window?.background = awtColor - awtWindow?.background = awtColor - (awtWindow as? Container)?.background = awtColor - (awtWindow as? RootPaneContainer)?.let { rootPaneContainer -> - rootPaneContainer.contentPane.background = awtColor - rootPaneContainer.rootPane.background = awtColor - rootPaneContainer.layeredPane.background = awtColor - rootPaneContainer.glassPane.background = awtColor - } - } -} - -private fun applyWindowsDesktopWindowChrome( - window: Component?, - colors: DesktopWindowChromeColors, - osName: String = System.getProperty("os.name").orEmpty() -) { - if (!isWindowsDesktop(osName)) return - runOnEventDispatchThread { - val awtWindow = window.toAwtWindowOrNull() ?: return@runOnEventDispatchThread - val hwnd = runCatching { Native.getWindowPointer(awtWindow) }.getOrNull() ?: return@runOnEventDispatchThread - WindowsDwmApi.applyWindowChrome(hwnd, colors) - } -} - -private fun applyWindowsDesktopWindowDecoration( - window: Component?, - hideDecoration: Boolean, - originalStyle: AtomicReference, - osName: String = System.getProperty("os.name").orEmpty() -) { - if (!isWindowsDesktop(osName)) return - EventQueue.invokeLater decoration@{ - val awtWindow = window.toAwtWindowOrNull() ?: return@decoration - val hwnd = runCatching { Native.getWindowPointer(awtWindow) }.getOrNull() ?: return@decoration - val api = runCatching { User32Api.INSTANCE }.getOrNull() ?: return@decoration - if (hideDecoration) { - val style = api.GetWindowLongW(hwnd, GWL_STYLE) - originalStyle.compareAndSet(null, style) - val fullscreenStyle = style and WS_CAPTION.inv() and WS_THICKFRAME.inv() - if (fullscreenStyle != style) { - api.SetWindowLongW(hwnd, GWL_STYLE, fullscreenStyle) - api.SetWindowPos( - hwnd, - null, - 0, - 0, - 0, - 0, - SWP_NOMOVE or SWP_NOSIZE or SWP_NOZORDER or SWP_NOACTIVATE or SWP_FRAMECHANGED - ) - } - } else { - val restoredStyle = originalStyle.getAndSet(null) ?: return@decoration - api.SetWindowLongW(hwnd, GWL_STYLE, restoredStyle) - api.SetWindowPos( - hwnd, - null, - 0, - 0, - 0, - 0, - SWP_NOMOVE or SWP_NOSIZE or SWP_NOZORDER or SWP_NOACTIVATE or SWP_FRAMECHANGED - ) - } - } -} - -private fun Component?.toAwtWindowOrNull(): AwtWindow? { - return when (this) { - null -> null - is AwtWindow -> this - else -> SwingUtilities.getWindowAncestor(this) - } -} - -private fun runOnEventDispatchThread(block: () -> Unit) { - if (EventQueue.isDispatchThread()) { - block() - } else { - EventQueue.invokeLater(block) - } -} - -private fun Color.toAwtOpaqueColor(): AwtColor { - val argb = toArgb() - return AwtColor( - (argb shr 16) and 0xFF, - (argb shr 8) and 0xFF, - argb and 0xFF - ) -} - -private fun Color.toWindowsColorRef(): Int { - val argb = toArgb() - val red = (argb shr 16) and 0xFF - val green = (argb shr 8) and 0xFF - val blue = argb and 0xFF - return red or (green shl 8) or (blue shl 16) -} - -private const val GWL_STYLE = -16 -private const val WS_CAPTION = 0x00C00000 -private const val WS_THICKFRAME = 0x00040000 -private const val SWP_NOSIZE = 0x0001 -private const val SWP_NOMOVE = 0x0002 -private const val SWP_NOZORDER = 0x0004 -private const val SWP_NOACTIVATE = 0x0010 -private const val SWP_FRAMECHANGED = 0x0020 - -private object WindowsDwmApi { - private const val DWMWA_USE_IMMERSIVE_DARK_MODE_BEFORE_20H1 = 19 - private const val DWMWA_USE_IMMERSIVE_DARK_MODE = 20 - private const val DWMWA_BORDER_COLOR = 34 - private const val DWMWA_CAPTION_COLOR = 35 - private const val DWMWA_TEXT_COLOR = 36 - - fun applyWindowChrome(hwnd: Pointer, colors: DesktopWindowChromeColors) { - val api = runCatching { DwmApi.INSTANCE }.getOrNull() ?: return - val darkModeValue = if (colors.useDarkMode) 1 else 0 - val darkModeResult = api.setIntAttribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, darkModeValue) - if (darkModeResult != 0) { - api.setIntAttribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE_BEFORE_20H1, darkModeValue) - } - api.setIntAttribute(hwnd, DWMWA_CAPTION_COLOR, colors.captionColorRef) - api.setIntAttribute(hwnd, DWMWA_TEXT_COLOR, colors.textColorRef) - api.setIntAttribute(hwnd, DWMWA_BORDER_COLOR, colors.borderColorRef) - } - - private fun DwmApi.setIntAttribute(hwnd: Pointer, attribute: Int, value: Int): Int { - return runCatching { - val ref = IntByReference(value) - DwmSetWindowAttribute(hwnd, attribute, ref.pointer, Int.SIZE_BYTES) - }.getOrDefault(-1) - } -} - -private interface DwmApi : StdCallLibrary { - fun DwmSetWindowAttribute(hwnd: Pointer, attribute: Int, value: Pointer, valueSize: Int): Int - - companion object { - val INSTANCE: DwmApi by lazy { - Native.load("dwmapi", DwmApi::class.java) as DwmApi - } - } -} - -private interface User32Api : StdCallLibrary { - fun GetWindowLongW(hwnd: Pointer, index: Int): Int - fun SetWindowLongW(hwnd: Pointer, index: Int, value: Int): Int - fun SetWindowPos( - hwnd: Pointer, - insertAfter: Pointer?, - x: Int, - y: Int, - cx: Int, - cy: Int, - flags: Int - ): Boolean - - companion object { - val INSTANCE: User32Api by lazy { - Native.load("user32", User32Api::class.java) as User32Api - } - } -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWindowStateStore.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWindowStateStore.kt deleted file mode 100644 index 3b5408b..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWindowStateStore.kt +++ /dev/null @@ -1,159 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import androidx.compose.ui.unit.DpSize -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.WindowPlacement -import androidx.compose.ui.window.WindowPosition -import androidx.compose.ui.window.WindowState -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.floatOrNull -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import java.io.File - -private const val DesktopWindowStateSchemaVersion = 1 - -internal enum class DesktopSavedWindowPlacement { - FLOATING, - MAXIMIZED, - FULLSCREEN -} - -internal data class DesktopWindowStateSnapshot( - val placement: DesktopSavedWindowPlacement, - val widthDp: Float, - val heightDp: Float, - val xDp: Float? = null, - val yDp: Float? = null -) { - fun toWindowPlacement(): WindowPlacement { - return when (placement) { - DesktopSavedWindowPlacement.FLOATING -> WindowPlacement.Floating - DesktopSavedWindowPlacement.MAXIMIZED -> WindowPlacement.Maximized - DesktopSavedWindowPlacement.FULLSCREEN -> WindowPlacement.Fullscreen - } - } - - fun toWindowSize(defaultSize: DpSize): DpSize { - val width = widthDp.takeIf { it.isFinite() && it >= EpistemeDesktopWindowMinimumWidthPx.toFloat() } - val height = heightDp.takeIf { it.isFinite() && it >= EpistemeDesktopWindowMinimumHeightPx.toFloat() } - return DpSize( - width = width?.dp ?: defaultSize.width, - height = height?.dp ?: defaultSize.height - ) - } - - fun toWindowPosition(): WindowPosition { - val x = xDp?.takeIf { it.isFinite() } ?: return WindowPosition.PlatformDefault - val y = yDp?.takeIf { it.isFinite() } ?: return WindowPosition.PlatformDefault - return WindowPosition(x.dp, y.dp) - } - - fun sanitized(): DesktopWindowStateSnapshot { - return copy( - widthDp = widthDp.takeIf { it.isFinite() }?.coerceAtLeast(EpistemeDesktopWindowMinimumWidthPx.toFloat()) - ?: EpistemeDesktopWindowMinimumWidthPx.toFloat(), - heightDp = heightDp.takeIf { it.isFinite() }?.coerceAtLeast(EpistemeDesktopWindowMinimumHeightPx.toFloat()) - ?: EpistemeDesktopWindowMinimumHeightPx.toFloat(), - xDp = xDp?.takeIf { it.isFinite() }, - yDp = yDp?.takeIf { it.isFinite() } - ) - } - - fun toJsonObject(): JsonObject { - val sanitized = sanitized() - return JsonObject( - buildMap { - put("schemaVersion", JsonPrimitive(DesktopWindowStateSchemaVersion)) - put("placement", JsonPrimitive(sanitized.placement.name)) - put("widthDp", JsonPrimitive(sanitized.widthDp)) - put("heightDp", JsonPrimitive(sanitized.heightDp)) - put("xDp", sanitized.xDp?.let { JsonPrimitive(it) } ?: JsonNull) - put("yDp", sanitized.yDp?.let { JsonPrimitive(it) } ?: JsonNull) - } - ) - } - - companion object { - fun default(): DesktopWindowStateSnapshot { - return DesktopWindowStateSnapshot( - placement = DesktopSavedWindowPlacement.MAXIMIZED, - widthDp = 1280f, - heightDp = 820f - ) - } - - fun fromWindowState(state: WindowState): DesktopWindowStateSnapshot? { - if (state.isMinimized) return null - val size = state.size - val width = size.width.value.takeIf { it.isFinite() } ?: return null - val height = size.height.value.takeIf { it.isFinite() } ?: return null - val placement = when (state.placement) { - WindowPlacement.Floating -> DesktopSavedWindowPlacement.FLOATING - WindowPlacement.Maximized -> DesktopSavedWindowPlacement.MAXIMIZED - WindowPlacement.Fullscreen -> DesktopSavedWindowPlacement.FULLSCREEN - } - val position = state.position.takeIf { it.isSpecified } - return DesktopWindowStateSnapshot( - placement = placement, - widthDp = width, - heightDp = height, - xDp = position?.x?.value?.takeIf { it.isFinite() }, - yDp = position?.y?.value?.takeIf { it.isFinite() } - ).sanitized() - } - - fun fromJsonElement(element: JsonElement): DesktopWindowStateSnapshot? { - val obj = runCatching { element.jsonObject }.getOrNull() ?: return null - val placement = obj["placement"] - ?.jsonPrimitive - ?.content - ?.let { runCatching { DesktopSavedWindowPlacement.valueOf(it) }.getOrNull() } - ?: DesktopSavedWindowPlacement.MAXIMIZED - val width = obj["widthDp"]?.jsonPrimitive?.floatOrNull ?: return null - val height = obj["heightDp"]?.jsonPrimitive?.floatOrNull ?: return null - return DesktopWindowStateSnapshot( - placement = placement, - widthDp = width, - heightDp = height, - xDp = obj["xDp"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.floatOrNull, - yDp = obj["yDp"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.floatOrNull - ).sanitized() - } - } -} - -internal class DesktopWindowStateStore( - private val stateFile: File = defaultWindowStateFile() -) { - private val json = Json { - prettyPrint = true - ignoreUnknownKeys = true - } - - fun load(): DesktopWindowStateSnapshot? { - if (!stateFile.exists()) return null - return runCatching { - DesktopWindowStateSnapshot.fromJsonElement(json.parseToJsonElement(stateFile.readText())) - }.getOrNull() - } - - fun save(snapshot: DesktopWindowStateSnapshot) { - stateFile.parentFile?.mkdirs() - stateFile.writeText(json.encodeToString(JsonElement.serializer(), snapshot.toJsonObject())) - } - - companion object { - fun defaultWindowStateFile(): File { - return File(desktopUserConfigRoot(), "window_state.json") - } - - fun defaultReaderWindowStateFile(): File { - return File(desktopUserConfigRoot(), "reader_window_state.json") - } - } -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWindowsWebView2EpubWebView.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWindowsWebView2EpubWebView.kt deleted file mode 100644 index 56e2ce9..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWindowsWebView2EpubWebView.kt +++ /dev/null @@ -1,1910 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.awt.SwingPanel -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import org.dueattendant149.bookreader.shared.EpubAnnotationSerializer -import org.dueattendant149.bookreader.shared.ReaderLocator -import org.dueattendant149.bookreader.shared.UserHighlight -import org.dueattendant149.bookreader.shared.reader.ReaderReadingMode -import org.dueattendant149.bookreader.shared.ui.ReaderContentNavigationTarget -import org.dueattendant149.bookreader.shared.ui.readerString -import kotlinx.coroutines.delay -import org.eclipse.swt.SWT -import org.eclipse.swt.awt.SWT_AWT -import org.eclipse.swt.browser.Browser -import org.eclipse.swt.browser.BrowserFunction -import org.eclipse.swt.browser.LocationEvent -import org.eclipse.swt.browser.LocationListener -import org.eclipse.swt.browser.ProgressAdapter -import org.eclipse.swt.browser.ProgressEvent -import org.eclipse.swt.widgets.Display -import org.eclipse.swt.widgets.Shell -import java.awt.Canvas -import java.awt.EventQueue -import java.awt.event.ComponentAdapter -import java.awt.event.ComponentEvent -import java.awt.event.WindowAdapter -import java.awt.event.WindowEvent -import java.io.File -import java.util.concurrent.CountDownLatch -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.TimeUnit -import javax.swing.SwingUtilities - -@Composable -internal fun DesktopNativeSwtEpubWebView( - html: String, - appearanceScript: String, - highlightPaletteScript: String, - navigationTarget: ReaderContentNavigationTarget, - highlights: List, - onHighlightCreated: (UserHighlight) -> Unit, - onHighlightSelected: (String) -> Unit, - isFullscreen: Boolean, - onKeyboardNavigation: (DesktopReaderKeyNavigation) -> Unit, - onSelectionAction: (DesktopReaderSelectionActionPayload) -> Unit, - onLinkClicked: (DesktopEpubLinkClick) -> Unit, - onVisiblePageChanged: (Int, ReaderLocator?) -> Unit, - onPointerActivity: () -> Unit = {}, - networkAccessEnabled: Boolean, - backgroundColor: Color, - modifier: Modifier = Modifier -) { - val backend = remember { desktopEpubWebViewBackend() } - if (backend == DesktopEpubWebViewBackend.UNSUPPORTED) { - DesktopNativeWebViewError( - backend = backend, - message = desktopNativeWebViewUnavailableMessage(backend), - modifier = modifier.fillMaxSize() - ) - return - } - val latestOnLinkClicked by rememberUpdatedState(onLinkClicked) - val bridgeHandlers = rememberDesktopEpubBridgeHandlers( - onHighlightCreated = onHighlightCreated, - onHighlightSelected = onHighlightSelected, - onKeyboardNavigation = onKeyboardNavigation, - onSelectionAction = onSelectionAction, - onLinkClicked = onLinkClicked, - onVisiblePageChanged = onVisiblePageChanged, - onPointerActivity = onPointerActivity - ) - val bridgeHandlersByMethod = remember(bridgeHandlers) { - bridgeHandlers.associateBy { it.methodName } - } - val hostBackground = remember(backgroundColor) { backgroundColor.toAwtColor() } - val panel = remember { DesktopWindowsWebView2Panel(hostBackground, backend) } - val composeDensity = LocalDensity.current - var loaded by remember { mutableStateOf(false) } - var loadProgress by remember { mutableFloatStateOf(-1f) } - var errorMessage by remember { mutableStateOf(null) } - val webViewHtml = remember(html, networkAccessEnabled) { - html.withDesktopWebView2Bootstrap(networkAccessEnabled = networkAccessEnabled) - } - - DisposableEffect(panel) { - onDispose { - logDesktopWebView2("compose_dispose panel=${panel.instanceId}") - panel.disposeWebView(waitForSwtDisposal = true) - } - } - - LaunchedEffect(hostBackground) { - panel.updateBackground(hostBackground) - } - - Box( - modifier = modifier.fillMaxSize().onSizeChanged { size -> - logWebViewLayoutDiag( - "compose_webview_box panel=${panel.instanceId} size=${size.width}x${size.height} " + - "loaded=$loaded network=$networkAccessEnabled navMode=${navigationTarget.readingMode} " + - "composeDensity=${composeDensity.density.formatLogFloat()}" - ) - } - ) { - SwingPanel( - background = backgroundColor, - factory = { panel }, - update = { currentPanel -> - currentPanel.configure( - bridgeHandlersByMethod = bridgeHandlersByMethod, - networkAccessEnabled = networkAccessEnabled, - onLinkIntercepted = { link -> latestOnLinkClicked(link) }, - onLoadStateChanged = { isLoaded, progress -> - loaded = isLoaded - loadProgress = progress - }, - onError = { message -> - errorMessage = message - loaded = false - loadProgress = -1f - } - ) - }, - modifier = Modifier - .matchParentSize() - .onSizeChanged { size -> - logWebViewLayoutDiag( - "compose_swing_panel panel=${panel.instanceId} size=${size.width}x${size.height} " + - "loaded=$loaded composeDensity=${composeDensity.density.formatLogFloat()}" - ) - } - ) - - LaunchedEffect(webViewHtml) { - loaded = false - loadProgress = -1f - errorMessage = null - logDesktopWebView2( - "compose_load_request panel=${panel.instanceId} rawHtmlChars=${html.length} " + - "wrappedHtmlChars=${webViewHtml.length} rawHash=${html.hashCode()} wrappedHash=${webViewHtml.hashCode()} " + - "network=$networkAccessEnabled" - ) - logWebViewLayoutDiag( - "compose_load_request panel=${panel.instanceId} rawHtmlChars=${html.length} " + - "wrappedHtmlChars=${webViewHtml.length} navMode=${navigationTarget.readingMode} " + - "background=${backgroundColor.toArgb()}" - ) - panel.loadHtml(webViewHtml) - } - - LaunchedEffect(loaded) { - if (!loaded) return@LaunchedEffect - logDesktopWebView2("compose_loaded panel=${panel.instanceId} action=install_key_navigation") - panel.executeJavaScript(DesktopEpubKeyNavigationScript) - } - - LaunchedEffect(isFullscreen, loaded) { - if (!loaded) return@LaunchedEffect - logDesktopWebView2("compose_script panel=${panel.instanceId} name=fullscreen value=$isFullscreen") - panel.executeJavaScript( - "window.readerDesktopFullscreen = ${if (isFullscreen) "true" else "false"};" + - "window.dispatchEvent(new Event('resize'));" - ) - panel.relayoutWebView("fullscreen_state_changed") - DesktopWebView2FullscreenRelayoutDelaysMillis.forEach { delayMillis -> - delay(delayMillis) - panel.relayoutWebView("fullscreen_state_changed_after_${delayMillis}ms") - panel.executeJavaScript("window.dispatchEvent(new Event('resize'));") - } - } - - LaunchedEffect(html, loaded) { - if (!loaded) return@LaunchedEffect - logDesktopWebView2("compose_script panel=${panel.instanceId} name=desktop_finished") - panel.executeJavaScript("window.readerPaginationLayoutLog && window.readerPaginationLayoutLog('desktop_finished');") - } - - LaunchedEffect(appearanceScript, loaded) { - if (!loaded) return@LaunchedEffect - logDesktopWebView2( - "compose_script panel=${panel.instanceId} name=appearance chars=${appearanceScript.length} hash=${appearanceScript.hashCode()}" - ) - panel.executeJavaScript(appearanceScript + "\n" + desktopWebView2DocumentProbeScript("appearance_applied")) - } - - LaunchedEffect(highlightPaletteScript, loaded) { - if (!loaded) return@LaunchedEffect - logDesktopWebView2( - "compose_script panel=${panel.instanceId} name=highlight_palette chars=${highlightPaletteScript.length} " + - "hash=${highlightPaletteScript.hashCode()}" - ) - panel.executeJavaScript(highlightPaletteScript) - } - - LaunchedEffect( - navigationTarget.requestId, - navigationTarget.readingMode, - loaded - ) { - if (navigationTarget.readingMode != ReaderReadingMode.VERTICAL) return@LaunchedEffect - if (!loaded) return@LaunchedEffect - val locator = navigationTarget.locator ?: return@LaunchedEffect - logDesktopWebView2( - "compose_script panel=${panel.instanceId} name=scroll_locator request=${navigationTarget.requestId} " + - "chapter=${locator.chapterIndex} page=${locator.pageIndex}" - ) - panel.executeJavaScript("window.readerScrollToLocator && window.readerScrollToLocator(${locator.toReaderLocatorJson()});") - } - - LaunchedEffect( - navigationTarget.ttsRequestId, - navigationTarget.ttsLocator, - navigationTarget.readingMode, - loaded - ) { - if (!loaded) 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 == 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);" - } - panel.executeJavaScript(command) - } - - LaunchedEffect(highlights, loaded) { - if (!loaded) return@LaunchedEffect - val highlightsJson = EpubAnnotationSerializer.highlightsToJson(highlights) - logDesktopWebView2( - "compose_script panel=${panel.instanceId} name=apply_highlights count=${highlights.size} chars=${highlightsJson.length}" - ) - panel.executeJavaScript("window.readerApplyHighlights && window.readerApplyHighlights($highlightsJson);") - } - - if (errorMessage != null) { - DesktopNativeWebViewError( - backend = backend, - message = errorMessage.orEmpty(), - modifier = Modifier.fillMaxSize() - ) - } else if (!loaded) { - if (loadProgress in 0f..1f) { - LinearProgressIndicator( - progress = { loadProgress }, - modifier = Modifier.fillMaxWidth() - ) - } else { - LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) - } - } - } -} - -@Composable -private fun DesktopNativeWebViewError( - backend: DesktopEpubWebViewBackend, - message: String, - modifier: Modifier = Modifier -) { - Box( - modifier = modifier.padding(32.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = readerString( - "desktop_native_webview_start_error", - "%1\$s could not start: %2\$s", - backend.displayName, - message - ), - color = MaterialTheme.colorScheme.error, - textAlign = TextAlign.Center - ) - } -} - -private class DesktopWindowsWebView2Panel( - initialBackground: java.awt.Color, - private val backend: DesktopEpubWebViewBackend -) : Canvas() { - val instanceId: Int = nextDesktopWebView2InstanceId() - - @Volatile - private var bridgeHandlersByMethod: Map = emptyMap() - - @Volatile - private var networkAccessEnabled: Boolean = true - - @Volatile - private var onLinkIntercepted: (DesktopEpubLinkClick) -> Unit = {} - - @Volatile - private var onLoadStateChanged: (Boolean, Float) -> Unit = { _, _ -> } - - @Volatile - private var onError: (String) -> Unit = {} - - private var controller: DesktopWindowsWebView2Controller? = null - private var requestedHtml: String? = null - - @Volatile - private var lastLoadStartedAtNanos: Long = 0L - - val hasController: Boolean get() = controller != null - - @Volatile - private var disposeInProgress = false - - @Volatile - private var hostWindowClosing = false - - private var hostWindow: java.awt.Window? = null - private var hostWindowListener: WindowAdapter? = null - - init { - background = initialBackground - updateModeSwitchPanelState("init") - addComponentListener( - object : ComponentAdapter() { - override fun componentResized(event: ComponentEvent) { - logDesktopWebView2("panel_resized panel=$instanceId size=${width}x${height}") - logWebViewLayoutDiag( - "awt_canvas_resized panel=$instanceId size=${width}x${height} " + - "bounds=${bounds.formatAwtBounds()} screen=${safeScreenLocationLog()}" - ) - updateModeSwitchPanelState("component_resized") - controller?.resize(width, height, reason = "component_resized") - } - - override fun componentMoved(event: ComponentEvent) { - logWebViewLayoutDiag( - "awt_canvas_moved panel=$instanceId size=${width}x${height} " + - "bounds=${bounds.formatAwtBounds()} screen=${safeScreenLocationLog()}" - ) - updateModeSwitchPanelState("component_moved") - controller?.resize(width, height, reason = "component_moved") - } - - override fun componentShown(event: ComponentEvent) { - logWebViewLayoutDiag( - "awt_canvas_shown panel=$instanceId size=${width}x${height} " + - "bounds=${bounds.formatAwtBounds()} screen=${safeScreenLocationLog()}" - ) - updateModeSwitchPanelState("component_shown") - controller?.resize(width, height, reason = "component_shown") - } - } - ) - } - - fun relayoutWebView(reason: String) { - EventQueue.invokeLater { - updateModeSwitchPanelState("relayout_$reason") - logWebViewLayoutDiag( - "awt_canvas_relayout panel=$instanceId reason=$reason size=${width}x${height} " + - "bounds=${bounds.formatAwtBounds()} screen=${safeScreenLocationLog()} displayable=$isDisplayable" - ) - revalidate() - repaint() - controller?.resize(width, height, reason = reason) - } - } - - fun updateBackground(color: java.awt.Color) { - EventQueue.invokeLater { - if (background != color) { - background = color - repaint() - } - } - } - - fun configure( - bridgeHandlersByMethod: Map, - networkAccessEnabled: Boolean, - onLinkIntercepted: (DesktopEpubLinkClick) -> Unit, - onLoadStateChanged: (Boolean, Float) -> Unit, - onError: (String) -> Unit - ) { - updateHostWindowListener() - this.bridgeHandlersByMethod = bridgeHandlersByMethod - this.networkAccessEnabled = networkAccessEnabled - this.onLinkIntercepted = onLinkIntercepted - this.onLoadStateChanged = { isLoaded, progress -> - if (isLoaded) { - val startedAt = lastLoadStartedAtNanos - if (startedAt > 0L) { - logDesktopReaderOpenTrace { - "event=desktop_webview_panel_loaded panel=$instanceId " + - "durationMs=${startedAt.elapsedOpenTraceMs()} progress=$progress" - } - lastLoadStartedAtNanos = 0L - } - } - onLoadStateChanged(isLoaded, progress) - } - this.onError = { message -> - val startedAt = lastLoadStartedAtNanos - logDesktopReaderOpenTrace { - "event=desktop_webview_panel_error panel=$instanceId " + - "durationMs=${if (startedAt > 0L) startedAt.elapsedOpenTraceMs() else -1L} " + - "message=\"${message.logPreview(240)}\"" - } - lastLoadStartedAtNanos = 0L - onError(message) - } - logDesktopWebView2( - "panel_configure panel=$instanceId handlers=${bridgeHandlersByMethod.size} network=$networkAccessEnabled " + - "controller=${controller != null}" - ) - updateModeSwitchPanelState("configure") - } - - fun loadHtml(html: String) { - if (requestedHtml == html) { - logDesktopWebView2("panel_load_skip_duplicate panel=$instanceId htmlHash=${html.hashCode()}") - logDesktopReaderOpenTrace { - "event=desktop_webview_panel_load_skip_duplicate panel=$instanceId htmlHash=${html.hashCode()}" - } - return - } - lastLoadStartedAtNanos = System.nanoTime() - requestedHtml = html - logDesktopWebView2( - "panel_load_requested panel=$instanceId htmlChars=${html.length} htmlHash=${html.hashCode()} " + - "controller=${controller != null}" - ) - logDesktopReaderOpenTrace { - "event=desktop_webview_panel_load_requested panel=$instanceId htmlChars=${html.length} " + - "htmlHash=${html.hashCode()} controller=${controller != null} canvas=${width}x${height}" - } - logWebViewLayoutDiag( - "panel_load_requested panel=$instanceId canvas=${width}x${height} " + - "bounds=${bounds.formatAwtBounds()} controller=${controller != null}" - ) - updateModeSwitchPanelState("load_requested") - ensureController(reason = "load_requested") - controller?.loadHtml(html) - } - - fun executeJavaScript(script: String) { - logDesktopWebView2( - "panel_execute panel=$instanceId scriptChars=${script.length} scriptHash=${script.hashCode()} controller=${controller != null}" - ) - controller?.executeJavaScript(script) - } - - fun disposeWebView( - waitForSwtDisposal: Boolean = false, - detachAwtCanvas: Boolean = true - ) { - if (disposeInProgress) { - logDesktopWebView2( - "panel_dispose_skip panel=$instanceId reason=in_progress controller=${controller != null}" - ) - updateModeSwitchPanelState("dispose_skip_in_progress") - return - } - disposeInProgress = true - logDesktopWebView2( - "panel_dispose panel=$instanceId controller=${controller != null} " + - "waitForSwtDisposal=$waitForSwtDisposal detachAwtCanvas=$detachAwtCanvas" - ) - try { - updateModeSwitchPanelState("dispose_begin") - if (detachAwtCanvas) { - retireAwtCanvasFromReaderSurface() - } - controller?.dispose(waitForCompletion = waitForSwtDisposal) - controller = null - updateModeSwitchPanelState("dispose_end") - } finally { - disposeInProgress = false - } - } - - override fun addNotify() { - super.addNotify() - updateHostWindowListener() - updateModeSwitchPanelState("add_notify") - logDesktopWebView2( - "panel_add_notify panel=$instanceId displayable=$isDisplayable showing=$isShowing " + - "canvas=${width}x${height} controller=${controller != null} hasHtml=${requestedHtml != null}" - ) - logWebViewLayoutDiag( - "awt_canvas_add_notify panel=$instanceId displayable=$isDisplayable showing=$isShowing " + - "size=${width}x${height} bounds=${bounds.formatAwtBounds()} screen=${safeScreenLocationLog()} " + - "hasHtml=${requestedHtml != null}" - ) - ensureController(reason = "add_notify") - requestedHtml?.let { html -> controller?.loadHtml(html) } - controller?.resize(width, height, reason = "add_notify") - } - - override fun removeNotify() { - logDesktopWebView2("panel_remove_notify panel=$instanceId") - updateModeSwitchPanelState("remove_notify_begin") - updateHostWindowListener() - disposeWebView( - waitForSwtDisposal = true, - detachAwtCanvas = shouldRetireAwtCanvasFromReaderSurface() - ) - clearHostWindowListener() - super.removeNotify() - updateModeSwitchPanelState("remove_notify_end") - } - - private fun updateHostWindowListener() { - val window = SwingUtilities.getWindowAncestor(this) - if (hostWindow === window) return - clearHostWindowListener() - hostWindow = window - hostWindowClosing = false - if (window == null) return - val listener = object : WindowAdapter() { - override fun windowClosing(event: WindowEvent?) { - hostWindowClosing = true - logDesktopWebView2("panel_host_window_closing panel=$instanceId") - updateModeSwitchPanelState("host_window_closing") - } - - override fun windowClosed(event: WindowEvent?) { - hostWindowClosing = true - logDesktopWebView2("panel_host_window_closed panel=$instanceId") - updateModeSwitchPanelState("host_window_closed") - } - } - hostWindowListener = listener - window.addWindowListener(listener) - } - - private fun clearHostWindowListener() { - hostWindowListener?.let { listener -> - hostWindow?.removeWindowListener(listener) - } - hostWindowListener = null - hostWindow = null - } - - private fun shouldRetireAwtCanvasFromReaderSurface(): Boolean { - val window = hostWindow ?: SwingUtilities.getWindowAncestor(this) - val shouldRetire = desktopWebView2ShouldRetireAwtCanvas( - hostWindowClosing = hostWindowClosing, - hostWindowDisplayable = window?.isDisplayable == true - ) - if (!shouldRetire) { - logDesktopWebView2( - "panel_retire_skip panel=$instanceId reason=host_window_closing_or_disposed " + - "hostClosing=$hostWindowClosing host=${window.formatAwtComponentState()}" - ) - updateModeSwitchPanelState("retire_skip_host_window_closing_or_disposed") - } - return shouldRetire - } - - private fun retireAwtCanvasFromReaderSurface() { - if (!shouldRetireAwtCanvasFromReaderSurface()) return - runOnAwtEventThreadBlocking( - onError = { error -> - logDesktopWebView2( - "panel_retire_failed panel=$instanceId error=\"${error.message.orEmpty().logPreview(300)}\"" - ) - } - ) { - logWebViewLayoutDiag( - "awt_canvas_retire panel=$instanceId size=${width}x${height} " + - "bounds=${bounds.formatAwtBounds()} displayable=$isDisplayable visible=$isVisible" - ) - updateModeSwitchPanelState("retire_begin") - val parentContainer = parent - val grandParent = parentContainer?.parent - logReaderModeSwitch( - "webview2_interop_retire_begin panel=$instanceId " + - "parent=${parentContainer.formatAwtComponentState()} grandParent=${grandParent.formatAwtComponentState()}" - ) - isVisible = false - setBounds(0, 0, 0, 0) - parentContainer?.isVisible = false - parentContainer?.setBounds(0, 0, 0, 0) - parentContainer?.revalidate() - parentContainer?.repaint() - grandParent?.revalidate() - grandParent?.repaint() - repaint() - scheduleRetiredInteropHostCleanup(parentContainer, grandParent, reason = "retire") - updateModeSwitchPanelState("retire_end") - logReaderModeSwitch( - "webview2_interop_retire_end panel=$instanceId " + - "parent=${parentContainer.formatAwtComponentState()} grandParent=${grandParent.formatAwtComponentState()}" - ) - } - } - - private fun scheduleRetiredInteropHostCleanup( - parentContainer: java.awt.Container?, - grandParent: java.awt.Container?, - reason: String - ) { - if (parentContainer == null || grandParent == null) return - EventQueue.invokeLater { - cleanupRetiredInteropHost(parentContainer, grandParent, "${reason}_next_event") - } - EventQueue.invokeLater { - DesktopWebView2InteropHostCleanupDelaysMillis.forEach { delayMillis -> - javax.swing.Timer(delayMillis.toInt()) { _ -> - cleanupRetiredInteropHost(parentContainer, grandParent, "${reason}_after_${delayMillis}ms") - }.apply { - isRepeats = false - start() - } - } - } - } - - private fun cleanupRetiredInteropHost( - parentContainer: java.awt.Container, - grandParent: java.awt.Container, - reason: String - ) { - val isInteropHost = parentContainer.javaClass.simpleName == DesktopSwingInteropHostClassName - val ownsOnlyRetiredPanel = parentContainer.components.all { component -> - component === this || !component.isDisplayable || !component.isShowing - } - if (!isInteropHost || !ownsOnlyRetiredPanel || parentContainer.parent !== grandParent) { - logReaderModeSwitch( - "webview2_interop_host_cleanup_skip panel=$instanceId reason=$reason " + - "isInteropHost=$isInteropHost ownsOnlyRetiredPanel=$ownsOnlyRetiredPanel " + - "parent=${parentContainer.formatAwtComponentState()} " + - "grandParent=${grandParent.formatAwtComponentState()} " + - "parentParent=${parentContainer.parent?.javaClass?.simpleName ?: "none"} " + - "children=${parentContainer.formatAwtChildrenState()}" - ) - return - } - logReaderModeSwitch( - "webview2_interop_host_cleanup_begin panel=$instanceId reason=$reason " + - "parent=${parentContainer.formatAwtComponentState()} " + - "grandParent=${grandParent.formatAwtComponentState()} " + - "children=${parentContainer.formatAwtChildrenState()}" - ) - if (parent === parentContainer) { - parentContainer.remove(this) - } - parentContainer.removeAll() - grandParent.remove(parentContainer) - parentContainer.invalidate() - grandParent.invalidate() - grandParent.validate() - grandParent.repaint() - updateModeSwitchPanelState("interop_host_cleanup_$reason") - logReaderModeSwitch( - "webview2_interop_host_cleanup_end panel=$instanceId reason=$reason " + - "parent=${parentContainer.formatAwtComponentState()} " + - "grandParent=${grandParent.formatAwtComponentState()} " + - "parentParent=${parentContainer.parent?.javaClass?.simpleName ?: "none"} " + - "grandParentChildren=${grandParent.componentCount}" - ) - } - - private fun updateModeSwitchPanelState(event: String) { - val snapshot = modeSwitchPanelSnapshot(event) - DesktopWebView2ModeSwitchPanelStates[instanceId] = snapshot - logReaderModeSwitch("webview2_panel $snapshot") - } - - fun modeSwitchPanelSnapshot(event: String): String { - val parentContainer = parent - val parentName = parentContainer?.javaClass?.simpleName ?: "none" - val parentDetails = parentContainer.formatAwtComponentState() - return "panel=$instanceId event=$event visible=$isVisible displayable=$isDisplayable " + - "showing=$isShowing size=${width}x${height} bounds=${bounds.formatAwtBounds()} " + - "parent=$parentName parentState=$parentDetails controller=${controller != null} hasHtml=${requestedHtml != null}" - } - - private fun ensureController(reason: String) { - if (controller != null) return - if (!isDisplayable) { - logDesktopWebView2("panel_controller_skip panel=$instanceId reason=$reason displayable=false") - return - } - logDesktopWebView2( - "panel_controller_create panel=$instanceId reason=$reason backend=${backend.logName} hasHtml=${requestedHtml != null}" - ) - logDesktopReaderOpenTrace { - "event=desktop_webview_controller_create panel=$instanceId reason=$reason " + - "backend=${backend.logName} hasHtml=${requestedHtml != null} canvas=${width}x${height}" - } - var createdController: DesktopWindowsWebView2Controller? = null - val newController = DesktopWindowsWebView2Controller( - instanceId = instanceId, - backend = backend, - canvas = this, - isNetworkAccessEnabled = { networkAccessEnabled }, - dispatchBridgeMessage = { method, params -> - EventQueue.invokeLater { - bridgeHandlersByMethod[method]?.onMessage(params) - } - }, - dispatchLinkClick = { link -> - EventQueue.invokeLater { - onLinkIntercepted(link) - } - }, - updateLoadState = { isLoaded, progress -> - EventQueue.invokeLater { - onLoadStateChanged(isLoaded, progress) - } - }, - reportError = { error -> - val message = error.desktopNativeWebViewMessage(backend) - EventQueue.invokeLater { - createdController?.let { failedController -> - if (controller === failedController) { - controller = null - } - } - onError(message) - } - } - ) - createdController = newController - controller = newController - } -} - -private class DesktopWindowsWebView2Controller( - private val instanceId: Int, - private val backend: DesktopEpubWebViewBackend, - private val canvas: Canvas, - private val isNetworkAccessEnabled: () -> Boolean, - private val dispatchBridgeMessage: (String, String) -> Unit, - private val dispatchLinkClick: (DesktopEpubLinkClick) -> Unit, - private val updateLoadState: (Boolean, Float) -> Unit, - private val reportError: (Throwable) -> Unit -) { - @Volatile - private var disposed = false - - private var shell: Shell? = null - private var browser: Browser? = null - private var bridgeFunction: BrowserFunction? = null - - @Volatile - private var pendingHtml: String? = null - - @Volatile - private var lastBrowserBoundsLog: String = "" - - init { - logDesktopWebView2("controller_init panel=$instanceId backend=${backend.logName} canvas=${canvas.width}x${canvas.height}") - logDesktopReaderOpenTrace { - "event=desktop_webview_controller_init panel=$instanceId backend=${backend.logName} " + - "canvas=${canvas.width}x${canvas.height}" - } - logWebViewLayoutDiag( - "controller_init panel=$instanceId backend=${backend.logName} canvas=${canvas.width}x${canvas.height} " + - "canvasBounds=${canvas.bounds.formatAwtBounds()} screen=${canvas.safeScreenLocationLog()}" - ) - DesktopSwtWebView2EventLoop.asyncExec(reportError) { display -> - if (!disposed) createBrowser(display) - } - } - - fun loadHtml(html: String) { - logDesktopWebView2( - "controller_load_enqueue panel=$instanceId htmlChars=${html.length} htmlHash=${html.hashCode()} browser=${browser != null}" - ) - logDesktopReaderOpenTrace { - "event=desktop_webview_controller_load_enqueue panel=$instanceId htmlChars=${html.length} " + - "htmlHash=${html.hashCode()} browser=${browser != null}" - } - logWebViewLayoutDiag( - "controller_load_enqueue panel=$instanceId htmlChars=${html.length} browser=${browser != null} " + - "canvas=${canvas.width}x${canvas.height} browserBounds=$lastBrowserBoundsLog" - ) - DesktopSwtWebView2EventLoop.asyncExec(reportError) { - if (disposed) return@asyncExec - updateLoadState(false, -1f) - pendingHtml = html - val webView = browser - if (webView == null || webView.isDisposed) { - logDesktopWebView2("controller_load_pending panel=$instanceId reason=browser_not_ready") - logDesktopReaderOpenTrace { - "event=desktop_webview_controller_load_pending panel=$instanceId reason=browser_not_ready" - } - } else { - pendingHtml = null - setBrowserText(webView, html, reason = "load") - } - } - } - - fun executeJavaScript(script: String) { - DesktopSwtWebView2EventLoop.asyncExec(reportError) { - if (disposed) return@asyncExec - val webView = browser - if (webView == null || webView.isDisposed) { - logDesktopWebView2( - "controller_execute_drop panel=$instanceId reason=browser_not_ready " + - "scriptChars=${script.length} scriptHash=${script.hashCode()}" - ) - } else { - val executed = webView.execute(script) - logDesktopWebView2( - "controller_execute panel=$instanceId executed=$executed scriptChars=${script.length} scriptHash=${script.hashCode()}" - ) - } - } - } - - fun resize(width: Int, height: Int, reason: String = "resize") { - DesktopSwtWebView2EventLoop.asyncExec(reportError) { - if (disposed) return@asyncExec - applyCanvasSizeToBrowser(width, height, reason = reason) - } - } - - fun dispose(waitForCompletion: Boolean = false) { - if (disposed) return - disposed = true - logDesktopWebView2("controller_dispose panel=$instanceId waitForCompletion=$waitForCompletion") - logReaderModeSwitch("webview2_controller_dispose panel=$instanceId waitForCompletion=$waitForCompletion") - if (waitForCompletion) { - DesktopSwtWebView2EventLoop.syncExec({}) { - disposeSwtWidgets() - } - } else { - DesktopSwtWebView2EventLoop.asyncExec({}) { - disposeSwtWidgets() - } - } - } - - private fun disposeSwtWidgets() { - logReaderModeSwitch( - "webview2_swt_dispose_begin panel=$instanceId shell=${shell?.isDisposed == false} browser=${browser?.isDisposed == false}" - ) - bridgeFunction?.takeUnless { it.isDisposed }?.dispose() - bridgeFunction = null - browser?.takeUnless { it.isDisposed }?.dispose() - browser = null - shell?.takeUnless { it.isDisposed }?.dispose() - shell = null - lastBrowserBoundsLog = "" - logReaderModeSwitch("webview2_swt_dispose_end panel=$instanceId") - } - - private fun createBrowser(display: Display) { - logDesktopWebView2("controller_create_start panel=$instanceId displayDisposed=${display.isDisposed}") - logDesktopReaderOpenTrace { - "event=desktop_webview_controller_create_start panel=$instanceId displayDisposed=${display.isDisposed}" - } - runCatching { - shell = SWT_AWT.new_Shell(display, canvas) - logDesktopWebView2("controller_shell_created panel=$instanceId shellDisposed=${shell?.isDisposed == true}") - logWebViewLayoutDiag( - "swt_shell_created panel=$instanceId canvas=${canvas.width}x${canvas.height} " + - "canvasBounds=${canvas.bounds.formatAwtBounds()} shellBounds=${shell?.bounds?.formatSwtBounds().orEmpty()}" - ) - val webView = Browser(shell, backend.swtBrowserStyle()) - browser = webView - val swtBackground = org.eclipse.swt.graphics.Color( - display, - canvas.background.red, - canvas.background.green, - canvas.background.blue - ) - shell?.background = swtBackground - webView.background = swtBackground - shell?.addDisposeListener { - if (!swtBackground.isDisposed) swtBackground.dispose() - } - val browserType = webView.browserType.orEmpty() - logDesktopWebView2( - "controller_browser_created panel=$instanceId backend=${backend.logName} browserType=\"$browserType\"" - ) - logDesktopReaderOpenTrace { - "event=desktop_webview_controller_browser_created panel=$instanceId backend=${backend.logName} " + - "browserType=\"${browserType.logPreview(120)}\"" - } - logWebViewLayoutDiag( - "swt_browser_created panel=$instanceId backend=${backend.logName} browserType=\"$browserType\" " + - "browserBounds=${webView.bounds.formatSwtBounds()} shellBounds=${shell?.bounds?.formatSwtBounds().orEmpty()}" - ) - check(backend.acceptsBrowserType(browserType)) { - "${backend.displayName} is not available; SWT opened '${browserType.ifBlank { "unknown" }}' instead." - } - val warmupHtml = desktopWebView2WarmupHtml(canvas.background) - val warmupAccepted = webView.setText(warmupHtml) - logDesktopReaderOpenTrace { - "event=desktop_webview_warmup_loaded panel=$instanceId accepted=$warmupAccepted " + - "background=\"${canvas.background.toCssHex()}\"" - } - run { - bridgeFunction = object : BrowserFunction(webView, DesktopWebView2NativeBridgeName) { - override fun function(arguments: Array): Any? { - val method = arguments.getOrNull(0)?.toString().orEmpty() - if (method.isBlank()) return null - val params = arguments.getOrNull(1)?.toString() ?: "{}" - if (method == DesktopWebView2DiagnosticMethodName) { - val preview = params.logPreview(6000) - logDesktopWebView2("bridge_diagnostic panel=$instanceId params=\"$preview\"") - logWebViewLayoutDiag("document_probe panel=$instanceId params=\"$preview\"") - } else { - logDesktopWebView2( - "bridge_message panel=$instanceId method=$method paramsChars=${params.length} params=\"${params.logPreview()}\"" - ) - dispatchBridgeMessage(method, params) - } - return null - } - } - webView.addLocationListener( - object : LocationListener { - override fun changing(event: LocationEvent) { - val location = event.location.orEmpty() - logDesktopWebView2( - "location_changing panel=$instanceId top=${event.top} doit=${event.doit} " + - "location=\"${location.logPreview()}\"" - ) - if (!isNetworkAccessEnabled() && location.isRemoteNetworkUrl()) { - logEpubLink("request_blocked_offline url=\"${location.logPreview()}\"") - event.doit = false - return - } - val link = location.readerLinkClickFromIntercept() ?: return - logEpubLink( - "request_intercept_webview2 url=\"${location.logPreview()}\" " + - "href=\"${link.href.logPreview()}\"" - ) - event.doit = false - dispatchLinkClick(link.copy(source = "request")) - } - - override fun changed(event: LocationEvent) = Unit - } - ) - webView.addProgressListener( - object : ProgressAdapter() { - private var lastLoggedProgressBucket = -1 - - override fun changed(event: ProgressEvent) { - val total = event.total - val progress = if (total > 0) { - event.current.coerceIn(0, total).toFloat() / total.toFloat() - } else { - -1f - } - val bucket = if (progress < 0f) { - -1 - } else { - (progress * 4).toInt().coerceIn(0, 4) - } - if (bucket != lastLoggedProgressBucket) { - lastLoggedProgressBucket = bucket - logDesktopWebView2( - "progress_changed panel=$instanceId current=${event.current} total=${event.total} " + - "progress=${if (progress < 0f) "unknown" else progress.formatLogFloat()}" - ) - } - updateLoadState(false, progress) - } - - override fun completed(event: ProgressEvent) { - val bridgeInjected = webView.execute(DesktopWebView2BridgeRuntimeScript) - applyCanvasSizeToBrowser(canvas.width, canvas.height, reason = "load_completed") - val probeInjected = webView.execute(desktopWebView2DocumentProbeScript("load_completed")) - logDesktopWebView2( - "progress_completed panel=$instanceId bridgeInjected=$bridgeInjected probeInjected=$probeInjected " + - "current=${event.current} total=${event.total}" - ) - logDesktopReaderOpenTrace { - "event=desktop_webview_progress_completed panel=$instanceId " + - "bridgeInjected=$bridgeInjected probeInjected=$probeInjected " + - "current=${event.current} total=${event.total}" - } - logWebViewLayoutDiag( - "progress_completed panel=$instanceId bridgeInjected=$bridgeInjected " + - "current=${event.current} total=${event.total}" - ) - updateLoadState(true, 1f) - } - } - ) - pendingHtml?.let { html -> - pendingHtml = null - setBrowserText(webView, html, reason = "browser_ready") - } - } - applyCanvasSizeToBrowser(canvas.width, canvas.height, reason = "open") - shell?.open() - logDesktopWebView2( - "controller_open panel=$instanceId shellVisible=${shell?.isVisible == true} " + - "initial=${canvas.width}x${canvas.height} " + - "browserBounds=${browser?.bounds?.width ?: -1}x${browser?.bounds?.height ?: -1}" - ) - logDesktopReaderOpenTrace { - "event=desktop_webview_controller_open panel=$instanceId shellVisible=${shell?.isVisible == true} " + - "initial=${canvas.width}x${canvas.height} " + - "browserBounds=${browser?.bounds?.width ?: -1}x${browser?.bounds?.height ?: -1}" - } - logWebViewLayoutDiag( - "controller_open panel=$instanceId shellVisible=${shell?.isVisible == true} " + - "initial=${canvas.width}x${canvas.height} " + - "hostScale=${canvas.webView2HostScale().scaleX.formatLogFloat()}x${canvas.webView2HostScale().scaleY.formatLogFloat()} " + - "shellBounds=${shell?.bounds?.formatSwtBounds().orEmpty()} " + - "browserBounds=${browser?.bounds?.formatSwtBounds().orEmpty()} canvasBounds=${canvas.bounds.formatAwtBounds()}" - ) - }.onFailure { error -> - logDesktopWebView2( - "controller_create_failed panel=$instanceId error=\"${error.desktopNativeWebViewMessage(backend).logPreview(300)}\"" - ) - logDesktopReaderOpenTrace { - "event=desktop_webview_controller_create_failed panel=$instanceId " + - "error=\"${error.desktopNativeWebViewMessage(backend).logPreview(300)}\"" - } - reportError(error) - dispose() - } - } - - private fun setBrowserText(webView: Browser, html: String, reason: String) { - val accepted = webView.setText(html) - logDesktopWebView2( - "controller_set_text panel=$instanceId reason=$reason accepted=$accepted " + - "htmlChars=${html.length} htmlHash=${html.hashCode()}" - ) - logDesktopReaderOpenTrace { - "event=desktop_webview_controller_set_text panel=$instanceId reason=$reason accepted=$accepted " + - "htmlChars=${html.length} htmlHash=${html.hashCode()}" - } - } - - private fun applyCanvasSizeToBrowser(width: Int, height: Int, reason: String) { - val webShell = shell ?: return - val webBrowser = browser - if (webShell.isDisposed || webBrowser?.isDisposed == true) return - val hostScale = canvas.webView2HostScale() - if (width <= 0 || height <= 0) { - logWebViewLayoutDiag( - "controller_resize_skip panel=$instanceId reason=$reason requested=${width}x${height} " + - "hostScale=${hostScale.scaleX.formatLogFloat()}x${hostScale.scaleY.formatLogFloat()} " + - "canvas=${canvas.width}x${canvas.height} shellBounds=${webShell.bounds.formatSwtBounds()} " + - "browserBounds=${webBrowser?.bounds?.formatSwtBounds().orEmpty()}" - ) - return - } - val targetBounds = desktopWebView2TargetBoundsForCanvas(width, height) ?: return - webShell.setBounds(targetBounds.x, targetBounds.y, targetBounds.width, targetBounds.height) - webBrowser?.setBounds(targetBounds.x, targetBounds.y, targetBounds.width, targetBounds.height) - lastBrowserBoundsLog = webBrowser?.bounds?.formatSwtBounds().orEmpty() - logDesktopWebView2( - "controller_resize panel=$instanceId reason=$reason requested=${width}x${height} " + - "target=${targetBounds.width}x${targetBounds.height} axisMode=logicalCanvas_zeroOrigin " + - "shellBounds=${webShell.bounds.x},${webShell.bounds.y} ${webShell.bounds.width}x${webShell.bounds.height} " + - "browserBounds=${webBrowser?.bounds?.width ?: -1}x${webBrowser?.bounds?.height ?: -1}" - ) - logWebViewLayoutDiag( - "controller_resize panel=$instanceId reason=$reason requested=${width}x${height} " + - "target=${targetBounds.width}x${targetBounds.height} axisMode=logicalCanvas_zeroOrigin " + - "hostScale=${hostScale.scaleX.formatLogFloat()}x${hostScale.scaleY.formatLogFloat()} " + - "canvas=${canvas.width}x${canvas.height} canvasBounds=${canvas.bounds.formatAwtBounds()} " + - "shellBounds=${webShell.bounds.formatSwtBounds()} " + - "browserBounds=${webBrowser?.bounds?.formatSwtBounds().orEmpty()}" - ) - } -} - -private object DesktopSwtWebView2EventLoop { - private val ready = CountDownLatch(1) - - @Volatile - private var display: Display? = null - - @Volatile - private var startupError: Throwable? = null - - init { - Thread( - { - runCatching { - logDesktopWebView2("swt_event_loop_start") - runCatching { Display.setAppName(EpistemeDesktopWindowTitle) } - if (desktopEpubWebViewUsesWebView2() && - System.getProperty(DesktopWebView2EdgeDataDirProperty).isNullOrBlank() - ) { - System.setProperty( - DesktopWebView2EdgeDataDirProperty, - File(desktopUserCacheRoot(), "webview2").absolutePath - ) - } - if (desktopEpubWebViewUsesWebView2()) { - logDesktopWebView2( - "swt_event_loop_user_data_dir path=\"${System.getProperty(DesktopWebView2EdgeDataDirProperty).orEmpty().logPreview(200)}\"" - ) - } - val swtDisplay = Display() - display = swtDisplay - ready.countDown() - logDesktopWebView2("swt_event_loop_ready") - while (!swtDisplay.isDisposed) { - if (!swtDisplay.readAndDispatch()) { - swtDisplay.sleep() - } - } - }.onFailure { error -> - startupError = error - ready.countDown() - logDesktopWebView2("swt_event_loop_failed error=\"${error.message.orEmpty().logPreview(300)}\"") - } - }, - "Episteme SWT Browser" - ).apply { - isDaemon = true - start() - } - } - - fun asyncExec( - onError: (Throwable) -> Unit, - block: (Display) -> Unit - ) { - val displayReady = runCatching { - ready.await(DesktopSwtReadyTimeoutSeconds, TimeUnit.SECONDS) - }.getOrElse { error -> - Thread.currentThread().interrupt() - onError(error) - return - } - if (!displayReady) { - logDesktopWebView2("swt_async_timeout") - onError(IllegalStateException("SWT display did not become ready.")) - return - } - startupError?.let { error -> - logDesktopWebView2("swt_async_startup_error error=\"${error.message.orEmpty().logPreview(300)}\"") - onError(error) - return - } - val swtDisplay = display - if (swtDisplay == null) { - logDesktopWebView2("swt_async_display_unavailable") - onError(IllegalStateException("SWT display is not available.")) - return - } - runCatching { - swtDisplay.asyncExec { - runCatching { - if (!swtDisplay.isDisposed) { - block(swtDisplay) - } - }.onFailure(onError) - } - }.onFailure { error -> - logDesktopWebView2("swt_async_enqueue_failed error=\"${error.message.orEmpty().logPreview(300)}\"") - onError(error) - } - } - - fun syncExec( - onError: (Throwable) -> Unit, - block: (Display) -> Unit - ) { - val displayReady = runCatching { - ready.await(DesktopSwtReadyTimeoutSeconds, TimeUnit.SECONDS) - }.getOrElse { error -> - Thread.currentThread().interrupt() - onError(error) - return - } - if (!displayReady) { - logDesktopWebView2("swt_sync_timeout") - onError(IllegalStateException("SWT display did not become ready.")) - return - } - startupError?.let { error -> - logDesktopWebView2("swt_sync_startup_error error=\"${error.message.orEmpty().logPreview(300)}\"") - onError(error) - return - } - val swtDisplay = display - if (swtDisplay == null) { - logDesktopWebView2("swt_sync_display_unavailable") - onError(IllegalStateException("SWT display is not available.")) - return - } - runCatching { - swtDisplay.syncExec { - runCatching { - if (!swtDisplay.isDisposed) { - block(swtDisplay) - } - }.onFailure(onError) - } - }.onFailure { error -> - logDesktopWebView2("swt_sync_enqueue_failed error=\"${error.message.orEmpty().logPreview(300)}\"") - onError(error) - } - } -} - -private fun Color.toAwtColor(): java.awt.Color = java.awt.Color(toArgb(), true) - -private fun desktopWebView2WarmupHtml(background: java.awt.Color): String { - val cssColor = background.toCssHex() - return """ - - - - - - - - - """.trimIndent() -} - -private fun java.awt.Color.toCssHex(): String { - return "#${red.toTwoDigitHex()}${green.toTwoDigitHex()}${blue.toTwoDigitHex()}" -} - -private fun Int.toTwoDigitHex(): String { - return coerceIn(0, 255).toString(16).padStart(2, '0') -} - -private fun runOnAwtEventThreadBlocking( - onError: (Throwable) -> Unit = {}, - block: () -> Unit -) { - if (EventQueue.isDispatchThread()) { - runCatching(block).onFailure(onError) - return - } - runCatching { - EventQueue.invokeAndWait { - runCatching(block).onFailure(onError) - } - }.onFailure(onError) -} - -private fun java.awt.Rectangle.formatAwtBounds(): String { - return "${x},${y} ${width}x$height" -} - -private fun java.awt.Component?.formatAwtComponentState(): String { - if (this == null) return "none" - return "${javaClass.simpleName}{visible=$isVisible displayable=$isDisplayable showing=$isShowing " + - "size=${width}x$height bounds=${bounds.formatAwtBounds()}}" -} - -private fun java.awt.Container.formatAwtChildrenState(): String { - if (componentCount == 0) return "none" - return components.joinToString(prefix = "[", postfix = "]") { component -> - component.formatAwtComponentState() - } -} - -private fun java.awt.Component.desktopWebView2Descendants(includeSelf: Boolean = false): List { - val descendants = mutableListOf() - if (includeSelf) descendants += this - fun collect(component: java.awt.Component) { - if (component is java.awt.Container) { - component.components.forEach { child -> - descendants += child - collect(child) - } - } - } - collect(this) - return descendants -} - -private fun org.eclipse.swt.graphics.Rectangle.formatSwtBounds(): String { - return "${x},${y} ${width}x$height" -} - -private fun Canvas.safeScreenLocationLog(): String { - return runCatching { - val point = locationOnScreen - "${point.x},${point.y}" - }.getOrDefault("unavailable") -} - -private data class DesktopWebView2HostScale( - val scaleX: Float, - val scaleY: Float -) - -internal data class DesktopWebView2TargetBounds( - val x: Int, - val y: Int, - val width: Int, - val height: Int -) - -internal fun desktopWebView2TargetBoundsForCanvas(width: Int, height: Int): DesktopWebView2TargetBounds? { - if (width <= 0 || height <= 0) return null - return DesktopWebView2TargetBounds( - x = 0, - y = 0, - width = width.coerceAtLeast(1), - height = height.coerceAtLeast(1) - ) -} - -internal fun desktopWebView2ShouldRetireAwtCanvas( - hostWindowClosing: Boolean, - hostWindowDisplayable: Boolean -): Boolean { - return !hostWindowClosing && hostWindowDisplayable -} - -private fun java.awt.Component.webView2HostScale(): DesktopWebView2HostScale { - val transform = graphicsConfiguration?.defaultTransform - return DesktopWebView2HostScale( - scaleX = transform?.scaleX?.takeIf { it.isFinite() && it > 0.0 }?.toFloat() ?: 1f, - scaleY = transform?.scaleY?.takeIf { it.isFinite() && it > 0.0 }?.toFloat() ?: 1f - ) -} - -private fun String.withDesktopWebView2Bootstrap(networkAccessEnabled: Boolean): String { - val injection = buildString { - if (!networkAccessEnabled) { - append(DesktopWebView2OfflineCspMetaTag) - append('\n') - } - append(DesktopWebView2ReaderSurfaceCssTag) - append('\n') - append(DesktopWebView2BridgeScriptTag) - } - val headStart = Regex("]*>", RegexOption.IGNORE_CASE).find(this) - if (headStart != null) { - val insertAt = headStart.range.last + 1 - return substring(0, insertAt) + "\n" + injection + "\n" + substring(insertAt) - } - return "$injection\n$this" -} - -internal fun desktopNativeWebViewUnavailableMessage( - backend: DesktopEpubWebViewBackend, - detail: String? = null -): String { - val base = when (backend) { - DesktopEpubWebViewBackend.WINDOWS_WEBVIEW2 -> - "Microsoft Edge WebView2 runtime is unavailable. Install or repair the WebView2 Runtime." - DesktopEpubWebViewBackend.WEBKIT -> - "WebKitGTK is unavailable. Install WebKitGTK from your Linux distribution packages." - DesktopEpubWebViewBackend.UNSUPPORTED -> - "Native webview is unavailable on this desktop platform." - } - val trimmedDetail = detail?.trim().orEmpty() - return if (trimmedDetail.isBlank()) base else "$base $trimmedDetail" -} - -private fun Throwable.desktopNativeWebViewMessage(backend: DesktopEpubWebViewBackend): String { - return desktopNativeWebViewUnavailableMessage( - backend = backend, - detail = message?.takeIf { it.isNotBlank() } ?: javaClass.simpleName - ) -} - -private fun desktopWebView2DocumentProbeScript(eventName: String): String { - return """ - (function () { - try { - var body = document.body; - var root = document.documentElement; - var firstChapter = document.querySelector('.chapter'); - var firstContent = document.querySelector('.reader-content'); - var blockSelector = 'p, div, h1, h2, h3, h4, h5, h6, li, blockquote, figure, table, pre'; - function round(value) { - return Math.round(Number(value || 0)); - } - function cssValue(element, name) { - if (!element) return ''; - var style = window.getComputedStyle(element); - return style ? (style.getPropertyValue(name) || '') : ''; - } - function cssVar(name) { - return cssValue(root, name).trim(); - } - function rectPayload(element) { - if (!element) return null; - var rect = element.getBoundingClientRect(); - var centerX = rect.left + (rect.width / 2); - var centerY = rect.top + (rect.height / 2); - var viewportHeight = window.innerHeight || 0; - return { - left: round(rect.left), - top: round(rect.top), - right: round(rect.right), - bottom: round(rect.bottom), - width: round(rect.width), - height: round(rect.height), - centerX: round(centerX), - centerDelta: round(centerX - ((window.innerWidth || 0) / 2)), - centerY: round(centerY), - viewportHeightDelta: round(rect.height - viewportHeight), - marginLeft: cssValue(element, 'margin-left').trim(), - marginRight: cssValue(element, 'margin-right').trim(), - paddingLeft: cssValue(element, 'padding-left').trim(), - paddingRight: cssValue(element, 'padding-right').trim(), - paddingTop: cssValue(element, 'padding-top').trim(), - paddingBottom: cssValue(element, 'padding-bottom').trim(), - textAlign: cssValue(element, 'text-align').trim(), - display: cssValue(element, 'display').trim(), - cssFloat: cssValue(element, 'float').trim(), - clear: cssValue(element, 'clear').trim(), - cssWidth: cssValue(element, 'width').trim(), - maxWidth: cssValue(element, 'max-width').trim(), - minHeight: cssValue(element, 'min-height').trim(), - boxSizing: cssValue(element, 'box-sizing').trim() - }; - } - function visibleChapter() { - var chapters = Array.prototype.slice.call(document.querySelectorAll('[data-reader-chapter-index]')); - var viewportTop = 0; - var viewportBottom = window.innerHeight || 0; - var best = null; - var bestVisibleHeight = -1; - chapters.forEach(function (candidate) { - var rect = candidate.getBoundingClientRect(); - var visibleHeight = Math.min(rect.bottom, viewportBottom) - Math.max(rect.top, viewportTop); - if (visibleHeight > bestVisibleHeight && rect.bottom >= viewportTop && rect.top <= viewportBottom) { - best = candidate; - bestVisibleHeight = visibleHeight; - } - }); - return best || firstChapter; - } - function visibleBlockIn(content) { - if (!content) return null; - var blocks = Array.prototype.slice.call(content.querySelectorAll(blockSelector)); - for (var i = 0; i < blocks.length; i++) { - var rect = blocks[i].getBoundingClientRect(); - if (rect.width > 0 && rect.height > 0 && rect.bottom >= 0 && rect.top <= (window.innerHeight || 0)) { - return blocks[i]; - } - } - return blocks[0] || null; - } - var chapter = visibleChapter(); - var content = chapter ? (chapter.querySelector('.reader-content') || chapter) : firstContent; - var firstBlock = firstContent ? firstContent.querySelector(blockSelector) : null; - var visibleBlock = visibleBlockIn(content); - var viewportCenterX = Math.max(0, Math.min((window.innerWidth || 0) - 1, Math.round((window.innerWidth || 0) / 2))); - var viewportTopY = Math.max(0, Math.min((window.innerHeight || 0) - 1, 8)); - var topElement = document.elementFromPoint(viewportCenterX, viewportTopY); - var topBlock = topElement && topElement.closest ? topElement.closest(blockSelector) : null; - var sampledElement = document.elementFromPoint( - viewportCenterX, - Math.max(0, Math.min((window.innerHeight || 0) - 1, Math.round((window.innerHeight || 0) / 2))) - ); - var sampledBlock = sampledElement && sampledElement.closest ? sampledElement.closest(blockSelector) : null; - var payload = { - event: '$eventName', - readyState: document.readyState || '', - title: document.title || '', - url: location.href || '', - devicePixelRatio: window.devicePixelRatio || 1, - bodyClass: body ? body.className : '', - rootClass: root ? root.className : '', - readerAlign: cssVar('--reader-align'), - readerMarginX: cssVar('--reader-margin-x'), - readerMarginY: cssVar('--reader-margin-y'), - readerVerticalMarginY: cssVar('--reader-vertical-margin-y'), - readerVerticalContentWidth: cssVar('--reader-vertical-content-width'), - readerVerticalPageWidth: cssVar('--reader-vertical-page-width'), - readerFontSize: cssVar('--reader-font-size'), - bodyZoom: cssValue(body, 'zoom').trim(), - bodyChildren: body ? body.children.length : -1, - bodyTextChars: body && body.innerText ? body.innerText.length : 0, - bodyHtmlChars: body && body.innerHTML ? body.innerHTML.length : 0, - bodyClientWidth: body ? body.clientWidth : -1, - bodyScrollWidth: body ? body.scrollWidth : -1, - rootClientWidth: root ? root.clientWidth : -1, - rootScrollWidth: root ? root.scrollWidth : -1, - scrollHeight: root ? root.scrollHeight : -1, - clientHeight: root ? root.clientHeight : -1, - viewportWidth: window.innerWidth || -1, - viewportHeight: window.innerHeight || -1, - visualViewportWidth: window.visualViewport ? round(window.visualViewport.width) : -1, - visualViewportHeight: window.visualViewport ? round(window.visualViewport.height) : -1, - visualViewportScale: window.visualViewport ? window.visualViewport.scale : -1, - scrollX: window.scrollX || 0, - topElementTag: topElement ? topElement.tagName : '', - topElementClass: topElement && topElement.className ? String(topElement.className) : '', - topBlockTag: topBlock ? topBlock.tagName : '', - topBlockRect: rectPayload(topBlock), - bodyRect: rectPayload(body), - rootRect: rectPayload(root), - firstChapterRect: rectPayload(firstChapter), - firstContentRect: rectPayload(firstContent), - visibleChapterIndex: chapter ? chapter.getAttribute('data-reader-chapter-index') : '', - chapterRect: rectPayload(chapter), - contentRect: rectPayload(content), - firstBlockTag: firstBlock ? firstBlock.tagName : '', - firstBlockRect: rectPayload(firstBlock), - visibleBlockTag: visibleBlock ? visibleBlock.tagName : '', - visibleBlockRect: rectPayload(visibleBlock), - sampledBlockTag: sampledBlock ? sampledBlock.tagName : '', - sampledBlockRect: rectPayload(sampledBlock) - }; - if (window.kmpJsBridge && window.kmpJsBridge.callNative) { - window.kmpJsBridge.callNative('$DesktopWebView2DiagnosticMethodName', JSON.stringify(payload)); - } - } catch (error) { - if (window.kmpJsBridge && window.kmpJsBridge.callNative) { - window.kmpJsBridge.callNative('$DesktopWebView2DiagnosticMethodName', JSON.stringify({ - event: '$eventName', - error: String(error && error.message ? error.message : error) - })); - } - } - })(); - """.trimIndent() -} - -private var DesktopWebView2InstanceSeed = 0 -private val DesktopWebView2ModeSwitchPanelStates = ConcurrentHashMap() - -@Synchronized -private fun nextDesktopWebView2InstanceId(): Int { - DesktopWebView2InstanceSeed += 1 - return DesktopWebView2InstanceSeed -} - -internal fun logDesktopWebView2ModeSwitchSnapshot(reason: String) { - val states = DesktopWebView2ModeSwitchPanelStates - .toSortedMap() - .values - .joinToString(separator = " | ") - .ifBlank { "none" } - logReaderModeSwitch( - "webview2_snapshot reason=$reason knownPanelCount=${DesktopWebView2ModeSwitchPanelStates.size} panels=$states" - ) -} - -internal fun cleanupRetiredDesktopWebView2InteropHosts(window: java.awt.Window?, reason: String) { - EventQueue.invokeLater { - if (window == null) { - logReaderModeSwitch("webview2_interop_host_sweep_skip reason=$reason window=null") - return@invokeLater - } - val interopHosts = window - .desktopWebView2Descendants() - .filterIsInstance() - .filter { component -> component.javaClass.simpleName == DesktopSwingInteropHostClassName } - if (interopHosts.isEmpty()) { - logReaderModeSwitch( - "webview2_interop_host_sweep reason=$reason window=${window.formatAwtComponentState()} hosts=none" - ) - return@invokeLater - } - interopHosts.forEach { host -> - cleanupRetiredDesktopWebView2InteropHost(window, host, reason) - } - } -} - -private fun cleanupRetiredDesktopWebView2InteropHost( - window: java.awt.Window, - host: java.awt.Container, - reason: String -) { - val panels = host - .desktopWebView2Descendants(includeSelf = true) - .filterIsInstance() - val hostRetired = !host.isShowing || !host.isVisible || host.width <= 0 || host.height <= 0 - val panelsRetired = panels.isNotEmpty() && panels.all { panel -> - !panel.isDisplayable || !panel.isShowing || panel.width <= 0 || panel.height <= 0 || !panel.hasController - } - val parent = host.parent - val removable = parent != null && hostRetired && panelsRetired - val panelStates = panels.joinToString(prefix = "[", postfix = "]") { panel -> - "panel=${panel.instanceId}{visible=${panel.isVisible} displayable=${panel.isDisplayable} " + - "showing=${panel.isShowing} size=${panel.width}x${panel.height} controller=${panel.hasController}}" - }.ifBlank { "none" } - logReaderModeSwitch( - "webview2_interop_host_sweep_candidate reason=$reason removable=$removable " + - "hostRetired=$hostRetired panelsRetired=$panelsRetired " + - "window=${window.formatAwtComponentState()} host=${host.formatAwtComponentState()} " + - "parent=${parent.formatAwtComponentState()} panels=$panelStates children=${host.formatAwtChildrenState()}" - ) - if (!removable) return - panels.forEach { panel -> - if (panel.parent === host) { - host.remove(panel) - } - } - host.removeAll() - parent?.remove(host) - host.invalidate() - parent?.invalidate() - parent?.validate() - parent?.repaint() - window.invalidate() - window.validate() - window.repaint() - panels.forEach { panel -> - DesktopWebView2ModeSwitchPanelStates[panel.instanceId] = - panel.modeSwitchPanelSnapshot("interop_host_sweep_removed_$reason") - logReaderModeSwitch("webview2_panel ${DesktopWebView2ModeSwitchPanelStates[panel.instanceId]}") - } - logReaderModeSwitch( - "webview2_interop_host_sweep_removed reason=$reason " + - "window=${window.formatAwtComponentState()} host=${host.formatAwtComponentState()} " + - "parent=${parent.formatAwtComponentState()} parentChildren=${parent?.componentCount ?: -1}" - ) -} - -private const val DesktopSwtReadyTimeoutSeconds = 10L -private val DesktopWebView2FullscreenRelayoutDelaysMillis = longArrayOf(180L, 260L, 420L) -private val DesktopWebView2InteropHostCleanupDelaysMillis = longArrayOf(80L, 220L) -private const val DesktopWebView2NativeBridgeName = "epistemeCallNative" -private const val DesktopWebView2DiagnosticMethodName = "readerWebView2Diagnostic" -private const val DesktopSwingInteropHostClassName = "SwingInteropViewGroup" -private const val DesktopWebView2EdgeDataDirProperty = "org.eclipse.swt.browser.EdgeDataDir" - -private fun DesktopEpubWebViewBackend.swtBrowserStyle(): Int { - return when (this) { - DesktopEpubWebViewBackend.WINDOWS_WEBVIEW2 -> SWT.EDGE - DesktopEpubWebViewBackend.WEBKIT -> SWT.WEBKIT - DesktopEpubWebViewBackend.UNSUPPORTED -> SWT.NONE - } -} - -private fun DesktopEpubWebViewBackend.acceptsBrowserType(browserType: String): Boolean { - return when (this) { - DesktopEpubWebViewBackend.WINDOWS_WEBVIEW2 -> browserType.equals("edge", ignoreCase = true) - DesktopEpubWebViewBackend.WEBKIT -> - browserType.contains("webkit", ignoreCase = true) || browserType.equals("safari", ignoreCase = true) - DesktopEpubWebViewBackend.UNSUPPORTED -> false - } -} - -private val DesktopWebView2BridgeRuntimeScript = """ - (function () { - window.kmpJsBridge = window.kmpJsBridge || {}; - window.kmpJsBridge.callNative = function (method, params) { - if (!window.$DesktopWebView2NativeBridgeName) return null; - var payload = '{}'; - if (typeof params === 'string') { - payload = params; - } else { - try { payload = JSON.stringify(params || {}); } catch (error) { payload = '{}'; } - } - return window.$DesktopWebView2NativeBridgeName(String(method || ''), payload); - }; - })(); -""".trimIndent() - -private val DesktopWebView2ReaderSurfaceCssTag = """ - -""".trimIndent() - -private val DesktopWebView2HorizontalClampScript = """ - (function () { - if (window.readerWebView2HorizontalClampInstalled) return; - window.readerWebView2HorizontalClampInstalled = true; - var clampQueued = false; - function clampHorizontalScroll() { - clampQueued = false; - var root = document.documentElement; - var body = document.body; - var changed = false; - if (window.scrollX) { - window.scrollTo({ top: window.scrollY || 0, left: 0, behavior: 'auto' }); - changed = true; - } - if (root && root.scrollLeft) { - root.scrollLeft = 0; - changed = true; - } - if (body && body.scrollLeft) { - body.scrollLeft = 0; - changed = true; - } - if (changed && window.kmpJsBridge && window.kmpJsBridge.callNative) { - try { - window.kmpJsBridge.callNative('$DesktopWebView2DiagnosticMethodName', JSON.stringify({ - event: 'horizontal_scroll_clamped' - })); - } catch (error) {} - } - } - function scheduleClamp() { - if (clampQueued) return; - clampQueued = true; - window.requestAnimationFrame(clampHorizontalScroll); - } - window.addEventListener('scroll', scheduleClamp, { passive: true }); - window.addEventListener('resize', scheduleClamp, { passive: true }); - document.addEventListener('DOMContentLoaded', scheduleClamp, { once: true }); - window.addEventListener('load', scheduleClamp, { once: true }); - scheduleClamp(); - })(); -""".trimIndent() - -private val DesktopWebView2BridgeScriptTag = """ - -""".trimIndent() - -private const val DesktopWebView2OfflineCspMetaTag = - "" diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/Launcher.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/Launcher.kt deleted file mode 100644 index 6893a1e..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/Launcher.kt +++ /dev/null @@ -1,6 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -fun main() { - val startupSplash = DesktopStartupSplash.show() - launchEpistemeDesktopApplication(startupSplash) -} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/Main.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/Main.kt deleted file mode 100644 index ef77e24..0000000 --- a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/Main.kt +++ /dev/null @@ -1,5152 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.key -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.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Window -import androidx.compose.ui.window.WindowPlacement -import androidx.compose.ui.window.WindowPosition -import androidx.compose.ui.window.rememberWindowState -import org.dueattendant149.bookreader.shared.AppAction -import org.dueattendant149.bookreader.shared.AppFontPreference -import org.dueattendant149.bookreader.shared.BannerMessage -import org.dueattendant149.bookreader.shared.BookItem -import org.dueattendant149.bookreader.shared.BookShelfRef -import org.dueattendant149.bookreader.shared.CustomFontItem -import org.dueattendant149.bookreader.shared.FileType -import org.dueattendant149.bookreader.shared.ImportedBookFile -import org.dueattendant149.bookreader.shared.LibraryAction -import org.dueattendant149.bookreader.shared.ReaderAiByokSettings -import org.dueattendant149.bookreader.shared.ReaderAiFeature -import org.dueattendant149.bookreader.shared.ReaderAiResultState -import org.dueattendant149.bookreader.shared.ReaderCloudTtsState -import org.dueattendant149.bookreader.shared.ReaderContextExtractor -import org.dueattendant149.bookreader.shared.RecapResult -import org.dueattendant149.bookreader.shared.ReaderExternalLookupAction -import org.dueattendant149.bookreader.shared.ReaderExtrasState -import org.dueattendant149.bookreader.shared.ReaderFeatureSurface -import org.dueattendant149.bookreader.shared.ReaderLocator -import org.dueattendant149.bookreader.shared.ReaderPlatform -import org.dueattendant149.bookreader.shared.ReaderTtsCacheSummary -import org.dueattendant149.bookreader.shared.ReaderTtsChunk -import org.dueattendant149.bookreader.shared.ReaderTtsPlanner -import org.dueattendant149.bookreader.shared.ReaderTtsProgress -import org.dueattendant149.bookreader.shared.ReaderTtsReadScope -import org.dueattendant149.bookreader.shared.SharedFileCapabilities -import org.dueattendant149.bookreader.shared.SharedImportOutcomeCounts -import org.dueattendant149.bookreader.shared.SharedImportPlanner -import org.dueattendant149.bookreader.shared.SharedLibraryEditor -import org.dueattendant149.bookreader.shared.SharedLibraryStateProjector -import org.dueattendant149.bookreader.shared.SharedReaderScreenState -import org.dueattendant149.bookreader.shared.SharedSettingsAction -import org.dueattendant149.bookreader.shared.SharedSettingsDestination -import org.dueattendant149.bookreader.shared.SharedSettingsHubInput -import org.dueattendant149.bookreader.shared.SharedSettingsPlatform -import org.dueattendant149.bookreader.shared.Shelf -import org.dueattendant149.bookreader.shared.ShelfRecord -import org.dueattendant149.bookreader.shared.ShelfType -import org.dueattendant149.bookreader.shared.SmartCollectionDefinition -import org.dueattendant149.bookreader.shared.SummarizationResult -import org.dueattendant149.bookreader.shared.externalLookupUrl -import org.dueattendant149.bookreader.shared.opds.OpdsAcquisition -import org.dueattendant149.bookreader.shared.opds.OpdsCatalog -import org.dueattendant149.bookreader.shared.opds.OpdsEntry -import org.dueattendant149.bookreader.shared.opds.OpdsStreamReference -import org.dueattendant149.bookreader.shared.opds.SharedOpdsController -import org.dueattendant149.bookreader.shared.opds.SharedOpdsDownloadState -import org.dueattendant149.bookreader.shared.opds.SharedOpdsStreamUri -import org.dueattendant149.bookreader.shared.pdf.SharedPdfReaderViewport -import org.dueattendant149.bookreader.shared.reader.ReaderEngine -import org.dueattendant149.bookreader.shared.reader.ReaderImageReference -import org.dueattendant149.bookreader.shared.reader.ReaderSessionState -import org.dueattendant149.bookreader.shared.reader.ReaderSettings -import org.dueattendant149.bookreader.shared.reader.SharedEpubMetadataEditor -import org.dueattendant149.bookreader.shared.reader.SharedEpubMetadataUpdate -import org.dueattendant149.bookreader.shared.reader.SharedEpubPaginationCache -import org.dueattendant149.bookreader.shared.reader.SharedJvmBookLoadSemanticMode -import org.dueattendant149.bookreader.shared.reader.SharedJvmBookLoader -import org.dueattendant149.bookreader.shared.readerCloudTtsControlsModel -import org.dueattendant149.bookreader.shared.reduce -import org.dueattendant149.bookreader.shared.sharedSettingsHubModel -import org.dueattendant149.bookreader.shared.shouldApplyRemoteCloudBookMetadataUpdate -import org.dueattendant149.bookreader.shared.shouldUploadLocalCloudBookContent -import org.dueattendant149.bookreader.shared.shouldUploadLocalCloudBookMetadataUpdate -import org.dueattendant149.bookreader.shared.ui.NonReaderLibraryTab -import org.dueattendant149.bookreader.shared.ui.SharedAboutScreen -import org.dueattendant149.bookreader.shared.ui.SharedAddToShelfDialog -import org.dueattendant149.bookreader.shared.ui.SharedAppShell -import org.dueattendant149.bookreader.shared.ui.SharedAppTab -import org.dueattendant149.bookreader.shared.ui.SharedAppTheme -import org.dueattendant149.bookreader.shared.ui.SharedAppThemeControls -import org.dueattendant149.bookreader.shared.ui.SharedAppThemeSettingsDialog -import org.dueattendant149.bookreader.shared.ui.SharedBookInfoDialog -import org.dueattendant149.bookreader.shared.ui.SharedConfirmDialog -import org.dueattendant149.bookreader.shared.ui.SharedCustomFontsScreen -import org.dueattendant149.bookreader.shared.ui.SharedHelpFeedbackScreen -import org.dueattendant149.bookreader.shared.ui.LocalSharedStringResolver -import org.dueattendant149.bookreader.shared.ui.SharedManageShelfBooksDialog -import org.dueattendant149.bookreader.shared.ui.SharedOpdsScreen -import org.dueattendant149.bookreader.shared.ui.SharedReaderModalOwnerWindowProvider -import org.dueattendant149.bookreader.shared.ui.SharedReaderTtsOverlayControls -import org.dueattendant149.bookreader.shared.ui.SharedSettingsHub -import org.dueattendant149.bookreader.shared.ui.SharedSupportProjectScreen -import org.dueattendant149.bookreader.shared.ui.SharedTextInputDialog -import org.dueattendant149.bookreader.shared.ui.readerString -import org.dueattendant149.bookreader.shared.withTtsReplacements -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.awt.Component -import java.awt.EventQueue -import java.io.File -import java.net.URI -import java.util.Base64 -import java.util.UUID -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.atomic.AtomicReference - -private const val DesktopReaderCloseDisposeSyncDelayMillis = 350L -private const val DesktopVerticalInitialPreparedHtmlChapterRadius = 2 -private const val DesktopLibraryOpenPersistDebounceMillis = 300L -private const val DesktopCloudContentRetryDelayMillis = 10_000L -private const val DesktopReaderPositionPersistDebounceMillis = 650L -private const val DesktopProgressEpsilon = 0.001f - -private enum class DesktopFeatureNoticeAction { - SIGN_IN, - OPEN_PRO -} - -private data class DesktopFeatureNotice( - val titleKey: String, - val titleFallback: String, - val messageKey: String, - val messageFallback: String, - val confirmKey: String = "action_ok", - val confirmFallback: String = "OK", - val action: DesktopFeatureNoticeAction? = null -) - -private data class DesktopFeatureNoticeState( - val notice: DesktopFeatureNotice, - val placement: DesktopFeatureNoticePlacement -) - -private data class DesktopCloudSyncCredentials( - val userId: String, - val idToken: String, - val driveAccessToken: String, - val deviceId: String -) - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -internal fun EpistemeDesktopApp( - window: Component? = null, - appWindowPlacement: WindowPlacement, - readerFullscreen: Boolean, - onReaderFullscreenChange: (Boolean) -> Unit -) { - val desktopBuildProfile = remember { currentDesktopBuildProfile() } - val desktopLanguageSettingsStore = remember { DesktopLanguageSettingsStore() } - var desktopLanguageTag by remember { mutableStateOf(desktopLanguageSettingsStore.load().languageTag) } - val desktopStringLocale = remember(desktopLanguageTag) { desktopLocaleForLanguageTag(desktopLanguageTag) } - val desktopStringResolver = remember(desktopStringLocale) { loadDesktopStringResolver(locale = desktopStringLocale) } - fun desktopString(name: String, fallback: String, vararg args: Any?): String { - return desktopStringResolver.string(name, fallback, *args) - } - fun desktopQuantityString( - name: String, - quantity: Int, - fallbackOne: String, - fallbackOther: String, - vararg args: Any? - ): String { - return desktopStringResolver.quantityString(name, quantity, fallbackOne, fallbackOther, *args) - } - val featurePolicy = desktopBuildProfile.featurePolicy - val desktopAiKeySettingsAvailable = desktopBuildProfile.aiKeySettingsAvailable - val libraryProjector = remember { SharedLibraryStateProjector(DesktopFolderPathResolver) } - val readerEngine = remember { ReaderEngine() } - val libraryDatabase = remember { DesktopLibraryDatabase() } - val desktopBookImporter = remember { DesktopBookImporter() } - val customFontStore = remember { - DesktopCustomFontStore( - googleFontsDownloadAvailable = { featurePolicy.googleFontsDownload } - ) - } - val opdsRepository = remember { DesktopOpdsRepository() } - val opdsController = remember { - SharedOpdsController( - repository = opdsRepository, - idFactory = { UUID.randomUUID().toString() } - ) - } - val desktopCloudConfig = remember { loadDesktopCloudConfig() } - val desktopAuthRepository = remember { DesktopFirebaseAuthRepository(desktopCloudConfig) } - val desktopAccountProfileRepository = remember { DesktopAccountProfileRepository(desktopCloudConfig) } - val desktopCloudSyncSettingsStore = remember { DesktopCloudSyncSettingsStore() } - val initialDesktopCloudSyncSettings = remember { desktopCloudSyncSettingsStore.load() } - val initialDesktopAccountSession = remember(desktopBuildProfile, featurePolicy) { - if (featurePolicy.aiAndCloud && featurePolicy.networkAccess && !desktopBuildProfile.byokAiAvailable) { - desktopAuthRepository.currentSession() - } else { - null - } - } - val initialDesktopAccountProfile = remember(initialDesktopAccountSession?.user?.uid) { - initialDesktopAccountSession?.user?.uid?.let(desktopAccountProfileRepository::cachedProfile) - } - val desktopInstallationIdStore = remember { DesktopInstallationIdStore() } - val desktopFirestoreRepository = remember { DesktopFirestoreRepository(desktopCloudConfig) } - val desktopGoogleDriveRepository = remember { DesktopGoogleDriveRepository() } - val desktopCloudSync = remember { - DesktopCloudSync( - firestoreRepository = desktopFirestoreRepository, - driveRepository = desktopGoogleDriveRepository, - bookImporter = desktopBookImporter, - customFontStore = customFontStore - ) - } - val aiByokStore = remember { DesktopAiByokStore() } - var aiByokSettings by remember { - mutableStateOf(aiByokStore.load()) - } - val sanitizedAiByokSettings = aiByokSettings.toDesktopPersistableAiSettings() - val desktopByokCloudTtsAvailable = featurePolicy.aiAndCloud && - featurePolicy.networkAccess && - sanitizedAiByokSettings.isByokCloudTtsAvailable - val desktopCreditCloudTtsControlsAvailable = - desktopBuildProfile.creditBackedCloudTtsControlsAvailable && desktopCloudConfig.isTtsWorkerConfigured - val desktopCloudTtsControlsAvailable = desktopByokCloudTtsAvailable || desktopCreditCloudTtsControlsAvailable - val desktopCloudTtsUsesCredits = desktopCreditCloudTtsControlsAvailable && !desktopByokCloudTtsAvailable - val initialLibrarySnapshot = remember { libraryDatabase.load().withDesktopDefaults() } - val scope = rememberCoroutineScope() - val webViewRuntimeState = remember { - DesktopWebViewRuntimeState(initialized = desktopEpubWebViewUsesNativeSwtBrowser()) - } - var readerCustomTextureIds by remember { mutableStateOf(DesktopReaderTextures.importedTextureIds()) } - val appWindowFullscreen = appWindowPlacement == WindowPlacement.Fullscreen - - EpistemeDesktopWindowDecorationEffect( - window = window, - hideDecoration = readerFullscreen && !appWindowFullscreen - ) - DesktopReaderFullscreenEffect( - window = window, - enabled = readerFullscreen && !appWindowFullscreen - ) - - var shelfRecords by remember { mutableStateOf(initialLibrarySnapshot.shelfRecords) } - var shelfRefs by remember { mutableStateOf(initialLibrarySnapshot.shelfRefs) } - var state by remember { - val initialState = initialLibrarySnapshot.toDesktopReaderScreenState().copy( - currentUser = initialDesktopAccountSession?.user, - isProUser = initialDesktopAccountProfile?.isProUser == true, - credits = initialDesktopAccountProfile?.credits ?: 0, - isSyncEnabled = initialDesktopCloudSyncSettings.isSyncEnabled, - isFolderSyncEnabled = initialDesktopCloudSyncSettings.isFolderSyncEnabled - ) - mutableStateOf( - libraryProjector.projectDesktopLibraryState( - state = initialState, - shelfRecords = shelfRecords, - shelfRefs = shelfRefs - ) - ) - } - var accountStatusMessage by remember { mutableStateOf(null) } - var accountBusy by remember { mutableStateOf(false) } - var accountRefreshRequestCount by remember { mutableStateOf(0) } - var desktopAccountProfileRefreshCompleted by remember { - mutableStateOf( - !featurePolicy.aiAndCloud || - desktopBuildProfile.byokAiAvailable || - initialDesktopAccountSession == null - ) - } - fun requestDesktopAccountRefreshAfterUsage(usage: DesktopPaidAiUsage = DesktopPaidAiUsage()) { - if (!featurePolicy.aiAndCloud || desktopBuildProfile.byokAiAvailable) return - scope.launch { - val nextCredits = desktopCreditsAfterPaidAiUsage(state.credits, usage.cost) - if (nextCredits != state.credits) { - state = libraryProjector.projectDesktopLibraryState( - state = state.copy(credits = nextCredits), - shelfRecords = shelfRecords, - shelfRefs = shelfRefs - ) - } - accountRefreshRequestCount++ - } - } - fun effectiveAiSettings(): ReaderAiByokSettings { - val sanitized = aiByokSettings.toDesktopPersistableAiSettings() - val byokCloudTtsAvailable = featurePolicy.aiAndCloud && - featurePolicy.networkAccess && - sanitized.isByokCloudTtsAvailable - return if (desktopBuildProfile.byokAiAvailable) { - aiByokSettings.withDesktopFeaturePolicy(featurePolicy) - } else { - ReaderAiByokSettings( - geminiKey = if (featurePolicy.aiAndCloud && featurePolicy.networkAccess) sanitized.geminiKey else "", - hideReaderAiFeatures = false, - ttsModel = if (featurePolicy.aiAndCloud && featurePolicy.networkAccess) sanitized.ttsModel else "", - ttsSpeakerId = sanitized.ttsSpeakerId, - serverBackedReaderAiFeatures = featurePolicy.aiAndCloud && featurePolicy.networkAccess, - serverBackedCloudTts = !byokCloudTtsAvailable && - desktopCreditCloudTtsControlsAvailable && - state.currentUser != null && - state.credits > 0 - ) - } - } - val desktopAiAdapter = remember(desktopBuildProfile) { - if (desktopBuildProfile.byokAiAvailable) { - DesktopByokAiAdapter( - settingsProvider = { effectiveAiSettings() }, - networkAccess = { featurePolicy.networkAccess } - ) - } else { - DesktopPaidAiAdapter( - config = desktopCloudConfig, - networkAccess = { featurePolicy.networkAccess }, - hideReaderAiFeatures = { effectiveAiSettings().hideReaderAiFeatures }, - currentAuthToken = { desktopAuthRepository.freshIdToken() }, - currentSignedIn = { state.currentUser != null }, - currentIsProUser = { state.isProUser }, - currentCredits = { state.credits }, - onUsageReported = ::requestDesktopAccountRefreshAfterUsage - ) - } - } - val desktopTtsAdapter = remember(desktopBuildProfile) { - DesktopGeminiCloudTtsAdapter( - settingsProvider = { effectiveAiSettings() }, - networkAccess = { featurePolicy.networkAccess }, - workerUrlProvider = { desktopCloudConfig.ttsWorkerUrl }, - authTokenProvider = { desktopAuthRepository.freshIdToken() }, - useWorkerProvider = { true }, - onWorkerUsageCompleted = { - requestDesktopAccountRefreshAfterUsage() - Unit - } - ) - } - val desktopSummaryCacheStore = remember { DesktopSummaryCacheStore() } - var selectedTab by remember { mutableStateOf(DesktopInitialAppTab) } - var selectedLibraryTab by remember { mutableStateOf(NonReaderLibraryTab.BOOKS) } - var customFonts by remember { - mutableStateOf(initialLibrarySnapshot.customFonts.filterNot { it.isDeleted }.sortedBy { it.displayName.lowercase() }) - } - var readerWindows by remember { mutableStateOf>(emptyList()) } - var reflowingPdfBookIds by remember { mutableStateOf>(emptySet()) } - val desktopEpubPaginationCache = remember { SharedEpubPaginationCache() } - var epubPaginationCacheGeneration by remember { mutableStateOf(0) } - var nextReaderOpenRequestId by remember { mutableStateOf(0L) } - var showCreateShelfDialog by remember { mutableStateOf(false) } - var createShelfBookIds by remember { mutableStateOf>(emptySet()) } - var createShelfClearsSelection 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 addToShelfBookIds by remember { mutableStateOf>(emptySet()) } - var addToShelfClearsSelection by remember { mutableStateOf(false) } - var shelfToManageBooks by remember { mutableStateOf(null) } - var showTagSelectionDialog by remember { mutableStateOf(false) } - var showAiByokSettingsDialog by remember { mutableStateOf(false) } - var showDesktopAppThemeSettingsDialog by remember { mutableStateOf(false) } - var showDesktopLanguageDialog by remember { mutableStateOf(false) } - var showClearBookCacheDialog by remember { mutableStateOf(false) } - var desktopFeatureNoticeState by remember { mutableStateOf(null) } - var settingsQuery by remember { mutableStateOf("") } - var settingsDestination by remember { mutableStateOf(SharedSettingsDestination.ROOT) } - var bookInfoDialogFor by remember { mutableStateOf(null) } - var bookInfoInitiallyEditing by remember { mutableStateOf(false) } - val snackbarHostState = remember { SnackbarHostState() } - var dropImportState by remember { mutableStateOf(DesktopDropImportState()) } - var opdsState by remember { mutableStateOf(opdsController.state) } - var desktopCloudSyncJob by remember { mutableStateOf(null) } - var desktopCloudContentRetryJob by remember { mutableStateOf(null) } - var pendingDesktopCloudSyncAfterActive by remember { mutableStateOf(false) } - val desktopBookCloudSyncJobs = remember { mutableMapOf() } - val pendingLibraryPersistJob = remember { AtomicReference(null) } - val desktopBookSidecarSaveJobs = remember { ConcurrentHashMap() } - var readerCloudDirtyBookIds by remember { mutableStateOf>(emptySet()) } - var readerCloudDirtyBaseTimestamps by remember { mutableStateOf>(emptyMap()) } - var readerCloudDirtySidecarBookIds by remember { mutableStateOf>(emptySet()) } - var readerCloudStalePositionGuards by remember { mutableStateOf>(emptyMap()) } - var closingReaderBookIds by remember { mutableStateOf>(emptySet()) } - var initialDesktopCloudSyncDone by remember { mutableStateOf(false) } - val readerWindowDefaults = remember(desktopBuildProfile) { epistemeDesktopWindowDefaults(desktopBuildProfile) } - val readerWindowStateStore = remember { - DesktopWindowStateStore(DesktopWindowStateStore.defaultReaderWindowStateFile()) - } - var savedReaderWindowState by remember { - mutableStateOf(readerWindowStateStore.load()?.toPersistableReaderWindowSnapshot()) - } - - fun showDesktopFeatureNotice( - notice: DesktopFeatureNotice, - readerWindowId: String? = null - ) { - desktopFeatureNoticeState = DesktopFeatureNoticeState( - notice = notice, - placement = desktopFeatureNoticePlacement(readerWindowId) - ) - } - - fun dismissDesktopFeatureNotice() { - desktopFeatureNoticeState = null - } - - fun projectState( - next: SharedReaderScreenState, - records: List = shelfRecords, - refs: List = shelfRefs - ): SharedReaderScreenState { - return libraryProjector.projectDesktopLibraryState( - state = next, - shelfRecords = records, - shelfRefs = refs - ) - } - - fun persistSnapshot( - projected: SharedReaderScreenState, - records: List = shelfRecords, - refs: List = shelfRefs, - fonts: List = customFonts, - persistDebounceMillis: Long = 0L - ) { - val snapshot = projected.toDesktopLibrarySnapshot( - shelfRecords = records, - shelfRefs = refs, - customFonts = fonts - ) - pendingLibraryPersistJob.getAndSet(null)?.cancel() - val persistJob = scope.launch(Dispatchers.IO) { - runCatching { - if (persistDebounceMillis > 0L) { - delay(persistDebounceMillis) - } - libraryDatabase.save(snapshot) - } - } - pendingLibraryPersistJob.set(persistJob) - persistJob.invokeOnCompletion { - pendingLibraryPersistJob.compareAndSet(persistJob, null) - } - } - - fun replaceLibrary( - next: SharedReaderScreenState, - records: List = shelfRecords, - refs: List = shelfRefs, - fonts: List = customFonts, - persistDebounceMillis: Long = 0L - ) { - shelfRecords = records - shelfRefs = refs - val projected = projectState(next, records, refs) - state = projected - persistSnapshot(projected, records, refs, fonts, persistDebounceMillis) - } - - fun updateState(next: SharedReaderScreenState, persistDebounceMillis: Long = 0L) { - val projected = projectState(next) - state = projected - persistSnapshot(projected, persistDebounceMillis = persistDebounceMillis) - } - - fun flushDesktopPersistenceBeforeDispose( - projected: SharedReaderScreenState, - records: List, - refs: List, - fonts: List - ) { - pendingLibraryPersistJob.getAndSet(null)?.cancel() - runCatching { - libraryDatabase.save( - projected.toDesktopLibrarySnapshot( - shelfRecords = records, - shelfRefs = refs, - customFonts = fonts - ) - ) - } - - val pendingSidecarBookIds = desktopBookSidecarSaveJobs.keys.toList() - if (pendingSidecarBookIds.isEmpty()) return - - desktopBookSidecarSaveJobs.values.forEach { it.cancel() } - desktopBookSidecarSaveJobs.clear() - val booksById = projected.rawLibraryBooks.associateBy { it.id } - pendingSidecarBookIds - .mapNotNull(booksById::get) - .filter { book -> - val sourceFolder = book.sourceFolder ?: return@filter false - projected.syncedFolders.firstOrNull { it.uriString == sourceFolder }?.localSyncEnabled ?: true - } - .forEach { book -> - runCatching { DesktopLocalFolderSync.saveBookSidecars(book) } - } - } - - fun DesktopReaderWindowState.cancelReaderWork() { - when (val content = content) { - DesktopReaderWindowContent.Opening, - is DesktopReaderWindowContent.PasswordRequired, - is DesktopReaderWindowContent.Pdf -> Unit - is DesktopReaderWindowContent.Text -> content.ttsJob?.cancel() - } - } - - fun DesktopReaderWindowState.readerCloseContentLabel(): String { - return when (content) { - DesktopReaderWindowContent.Opening -> "opening" - is DesktopReaderWindowContent.PasswordRequired -> "password_required" - is DesktopReaderWindowContent.Pdf -> "pdf" - is DesktopReaderWindowContent.Text -> "text" - } - } - - fun DesktopReaderWindowState.closeReaderResources() { - logDesktopReaderClose( - "close_resources_begin windowId=${id.logPreview(80)} bookId=${bookId.logPreview(80)} " + - "content=${readerCloseContentLabel()} fullscreen=$fullscreen" - ) - cancelReaderWork() - runCatching { - when (val content = content) { - DesktopReaderWindowContent.Opening, - is DesktopReaderWindowContent.PasswordRequired -> Unit - is DesktopReaderWindowContent.Pdf -> content.document.close() - is DesktopReaderWindowContent.Text -> Unit - } - }.onSuccess { - logDesktopReaderClose( - "close_resources_end windowId=${id.logPreview(80)} bookId=${bookId.logPreview(80)} " + - "content=${readerCloseContentLabel()}" - ) - }.onFailure { error -> - logDesktopReaderClose( - "close_resources_fail windowId=${id.logPreview(80)} bookId=${bookId.logPreview(80)} " + - "content=${readerCloseContentLabel()} error=\"${error.message.orEmpty().logPreview(240)}\" " + - "type=${error.javaClass.simpleName}" - ) - throw error - } - } - - fun updateReaderWindow( - windowId: String, - transform: (DesktopReaderWindowState) -> DesktopReaderWindowState - ) { - readerWindows = readerWindows.map { window -> - if (window.id == windowId) transform(window) else window - } - } - - fun updateTextReaderWindow( - windowId: String, - transform: (DesktopReaderWindowContent.Text) -> DesktopReaderWindowContent.Text - ) { - readerWindows = readerWindows.replaceDesktopTextReaderContent(windowId, transform) - } - - fun textReaderWindowContent(windowId: String): DesktopReaderWindowContent.Text? { - return readerWindows.firstOrNull { it.id == windowId }?.content as? DesktopReaderWindowContent.Text - } - - fun saveReaderWindowStateSnapshot(snapshot: DesktopWindowStateSnapshot?) { - val persistable = snapshot?.toPersistableReaderWindowSnapshot() ?: return - savedReaderWindowState = persistable - scope.launch(Dispatchers.IO) { - runCatching { readerWindowStateStore.save(persistable) } - } - } - - fun markReaderCloudDirty( - bookId: String, - baseTimestamp: Long? = null, - sidecarsDirty: Boolean = false - ) { - if (bookId.isBlank()) return - if (bookId !in readerCloudDirtyBookIds) { - val resolvedBaseTimestamp = baseTimestamp - ?: state.rawLibraryBooks.firstOrNull { it.id == bookId }?.timestamp - ?: 0L - readerCloudDirtyBaseTimestamps = readerCloudDirtyBaseTimestamps + (bookId to resolvedBaseTimestamp) - logDesktopCloudSync { - "desktop.reader.dirty_start book=$bookId baseTs=$resolvedBaseTimestamp sidecarsDirty=$sidecarsDirty" - } - } - if (sidecarsDirty) { - readerCloudDirtySidecarBookIds = readerCloudDirtySidecarBookIds + bookId - } - readerCloudDirtyBookIds = readerCloudDirtyBookIds + bookId - } - - fun clearReaderCloudDirty(bookIds: Set) { - if (bookIds.isEmpty()) return - readerCloudDirtyBookIds = readerCloudDirtyBookIds - bookIds - readerCloudDirtyBaseTimestamps = readerCloudDirtyBaseTimestamps - bookIds - readerCloudDirtySidecarBookIds = readerCloudDirtySidecarBookIds - bookIds - } - - fun markReaderBooksClosing(bookIds: Set) { - if (bookIds.isEmpty()) return - closingReaderBookIds = closingReaderBookIds + bookIds - readerCloudStalePositionGuards = readerCloudStalePositionGuards - bookIds - } - - fun downloadReaderImage(image: ReaderImageReference) { - val target = chooseSaveImageFile(image.suggestedDownloadFileName()) ?: return - runCatching { - target.parentFile?.mkdirs() - target.writeBytes(image.desktopImageBytes()) - }.onSuccess { - updateState(state.withBanner("Saved ${target.name}.")) - }.onFailure { error -> - updateState(state.withBanner(error.message ?: "Could not save image.", isError = true)) - } - } - - fun saveDesktopOriginalFile(book: BookItem) { - val source = book.path?.let(::File) - if (source?.isFile != true) { - updateState(state.withBanner("Original file is not available.", isError = true)) - return - } - val target = chooseSaveBookFile(book.desktopSuggestedOriginalFileName()) ?: return - runCatching { - target.parentFile?.mkdirs() - source.copyTo(target, overwrite = true) - }.onSuccess { - updateState(state.withBanner("Saved ${target.name}.")) - }.onFailure { error -> - updateState(state.withBanner(error.message ?: "Could not save file.", isError = true)) - } - } - - fun clearDesktopBookCache() { - scope.launch { - withContext(Dispatchers.IO) { - desktopEpubPaginationCache.clearAll() - SharedJvmBookLoader.clearCache() - } - epubPaginationCacheGeneration++ - updateState(state.withBanner("Book cache cleared. EPUB pagination will be recreated on demand.")) - } - } - - fun desktopCloudSyncAvailable(): Boolean { - return featurePolicy.aiAndCloud && - featurePolicy.networkAccess && - !desktopBuildProfile.byokAiAvailable && - desktopCloudConfig.isAuthConfigured - } - - fun desktopAccountAvailable(): Boolean { - return featurePolicy.aiAndCloud && - featurePolicy.networkAccess && - !desktopBuildProfile.byokAiAvailable - } - - fun saveDesktopCloudSyncSettings( - syncEnabled: Boolean = state.isSyncEnabled, - folderSyncEnabled: Boolean = state.isFolderSyncEnabled - ) { - desktopCloudSyncSettingsStore.save( - DesktopCloudSyncSettings( - isSyncEnabled = syncEnabled, - isFolderSyncEnabled = folderSyncEnabled - ) - ) - } - - suspend fun refreshDesktopAccountProfile(showBanner: Boolean = false) { - if (!featurePolicy.aiAndCloud || desktopBuildProfile.byokAiAvailable) { - desktopAccountProfileRefreshCompleted = true - return - } - val session = desktopAuthRepository.restoreSavedSession() - if (session == null) { - if (state.isSyncEnabled) saveDesktopCloudSyncSettings(syncEnabled = false) - updateState(state.copy(currentUser = null, isProUser = false, credits = 0, isSyncEnabled = false)) - desktopAccountProfileRefreshCompleted = true - return - } - val token = desktopAuthRepository.freshIdToken() - if (token.isNullOrBlank()) { - if (state.isSyncEnabled) saveDesktopCloudSyncSettings(syncEnabled = false) - updateState(state.copy(currentUser = null, isProUser = false, credits = 0, isSyncEnabled = false)) - desktopAccountProfileRefreshCompleted = true - return - } - runCatching { - desktopAccountProfileRepository.fetchProfile(session.user.uid, token) - }.onSuccess { profile -> - if (desktopAuthRepository.currentSession()?.user?.uid == session.user.uid) { - desktopAccountProfileRepository.saveFetchedProfile(session.user.uid, profile) - val nextSyncEnabled = state.isSyncEnabled && profile.isProUser - if (!nextSyncEnabled && state.isSyncEnabled) { - saveDesktopCloudSyncSettings(syncEnabled = false) - } - updateState( - state.copy( - currentUser = session.user, - isProUser = profile.isProUser, - credits = profile.credits, - isSyncEnabled = nextSyncEnabled - ) - ) - accountStatusMessage = if (profile.isProUser) { - "Account checked. Pro is unlocked." - } else { - "Account checked. Pro is not unlocked." - } - if (showBanner) updateState(state.withBanner("Account status refreshed.")) - } - }.onFailure { error -> - if (desktopAuthRepository.currentSession()?.user?.uid == session.user.uid) { - accountStatusMessage = error.message ?: "Could not check account status." - if (showBanner) updateState(state.withBanner(accountStatusMessage.orEmpty(), isError = true)) - } - } - desktopAccountProfileRefreshCompleted = true - } - - fun signInDesktopAccount() { - if (!desktopCloudConfig.isAuthConfigured) { - updateState(state.withBanner("Desktop Google sign-in is not configured for this build.", isError = true)) - return - } - scope.launch { - accountBusy = true - accountStatusMessage = "Waiting for Google sign-in..." - runCatching { - desktopAuthRepository.signIn(::openExternalUrl) - }.onSuccess { session -> - updateState(state.copy(currentUser = session.user, isProUser = false, credits = 0)) - accountStatusMessage = "Signed in. Checking account and credits..." - desktopAccountProfileRefreshCompleted = false - refreshDesktopAccountProfile() - }.onFailure { error -> - accountStatusMessage = error.message ?: "Google sign-in failed." - updateState(state.withBanner(accountStatusMessage.orEmpty(), isError = true)) - } - accountBusy = false - } - } - - suspend fun desktopCloudSyncCredentials(showBanner: Boolean): DesktopCloudSyncCredentials? { - if (!desktopCloudSyncAvailable()) { - if (showBanner) { - updateState(state.withBanner("Desktop cloud sync is not configured for this build.", isError = true)) - } - return null - } - - val session = desktopAuthRepository.restoreSavedSession() - val user = state.currentUser ?: session?.user - if (user == null) { - if (showBanner) updateState(state.withBanner("Sign in with Google to use cloud sync.", isError = true)) - return null - } - - if (!state.isProUser) { - if (showBanner) updateState(state.withBanner("A Pro account is required for cloud sync.", isError = true)) - return null - } - - val idToken = desktopAuthRepository.freshIdToken() - if (idToken.isNullOrBlank()) { - if (showBanner) updateState(state.withBanner("Sign in again to use cloud sync.", isError = true)) - return null - } - - val driveAccessToken = desktopAuthRepository.freshGoogleAccessToken() - if (driveAccessToken.isNullOrBlank()) { - if (showBanner) { - updateState(state.withBanner("Sign in again to grant Google Drive sync access.", isError = true)) - } - return null - } - - return DesktopCloudSyncCredentials( - userId = user.uid, - idToken = idToken, - driveAccessToken = driveAccessToken, - deviceId = desktopInstallationIdStore.getOrCreateId() - ) - } - - fun desktopCloudSyncCompleteMessage(result: DesktopCloudSyncResult): String { - val details = buildList { - if (result.uploadedBooks > 0) add("Uploaded ${result.uploadedBooks}.") - if (result.downloadedBooks > 0) add("Downloaded ${result.downloadedBooks}.") - if (result.pendingContentDownloads > 0) add("Waiting for ${result.pendingContentDownloads} upload(s) to finish.") - } - return if (details.isEmpty()) { - "Cloud sync complete." - } else { - "Cloud sync complete. ${details.joinToString(" ")}" - } - } - - fun syncDesktopCloud(showBanner: Boolean = false): Job { - desktopCloudSyncJob?.takeIf { it.isActive }?.let { - logDesktopCloudSync { "desktop.full_sync.reuse_active showBanner=$showBanner" } - return it - } - val job = scope.launch { - if (!state.isSyncEnabled) { - logDesktopCloudSync { "desktop.full_sync.skip reason=sync_disabled showBanner=$showBanner" } - return@launch - } - val credentials = desktopCloudSyncCredentials(showBanner) ?: run { - logDesktopCloudSync { "desktop.full_sync.skip reason=missing_credentials showBanner=$showBanner" } - return@launch - } - val snapshotState = state - val snapshotShelfRecords = shelfRecords - val snapshotShelfRefs = shelfRefs - val snapshotFonts = customFonts - logDesktopCloudSync { - "desktop.full_sync.start user=${credentials.userId} device=${credentials.deviceId} showBanner=$showBanner " + - "books=${snapshotState.rawLibraryBooks.size} shelves=${snapshotShelfRecords.size} folderSync=${snapshotState.isFolderSyncEnabled}" - } - - if (showBanner) { - updateState(state.copy(isRefreshing = true).withBanner("Cloud sync: checking library...")) - } - - runCatching { - withContext(Dispatchers.IO) { - desktopCloudSync.sync( - DesktopCloudSyncInput( - userId = credentials.userId, - idToken = credentials.idToken, - driveAccessToken = credentials.driveAccessToken, - deviceId = credentials.deviceId, - state = snapshotState, - shelfRecords = snapshotShelfRecords, - shelfRefs = snapshotShelfRefs, - customFonts = snapshotFonts, - includeFolderBooks = snapshotState.isFolderSyncEnabled - ) - ) - } - }.onSuccess { result -> - val openBookIds = readerWindows.mapTo(mutableSetOf()) { it.bookId } - val snapshotBooksById = snapshotState.rawLibraryBooks.associateBy { it.id } - val syncedBooksById = result.state.rawLibraryBooks.associateBy { it.id } - val staleGuards = openBookIds.mapNotNull { bookId -> - val before = snapshotBooksById[bookId] ?: return@mapNotNull null - val after = syncedBooksById[bookId] ?: return@mapNotNull null - if (after.timestamp > before.timestamp && !before.hasSameCloudReaderPosition(after)) { - bookId to before - } else { - null - } - }.toMap() - if (staleGuards.isNotEmpty()) { - readerCloudStalePositionGuards = readerCloudStalePositionGuards + staleGuards - clearReaderCloudDirty(staleGuards.keys) - logDesktopCloudSync { - "desktop.full_sync.open_reader_guard books=${staleGuards.keys.joinToString()} " + - "reason=remote_advanced_while_reader_open" - } - } - logDesktopCloudSync { - "desktop.full_sync.success user=${credentials.userId} uploaded=${result.uploadedBooks} " + - "downloaded=${result.downloadedBooks} pendingContent=${result.pendingContentDownloads} " + - "books=${result.state.rawLibraryBooks.size}" - } - if (result.pendingContentDownloads <= 0) { - desktopCloudContentRetryJob?.cancel() - desktopCloudContentRetryJob = null - } else if (desktopCloudContentRetryJob?.isActive != true) { - desktopCloudContentRetryJob = scope.launch { - delay(DesktopCloudContentRetryDelayMillis) - if (state.isSyncEnabled) { - logDesktopCloudSync { "desktop.full_sync.content_retry pending=${result.pendingContentDownloads}" } - syncDesktopCloud(showBanner = false).join() - } - } - } - customFonts = result.customFonts - val syncedState = result.state.copy( - isSyncEnabled = state.isSyncEnabled, - isFolderSyncEnabled = state.isFolderSyncEnabled, - isRefreshing = false - ) - replaceLibrary( - next = if (showBanner) syncedState.withBanner(desktopCloudSyncCompleteMessage(result)) else syncedState, - records = result.shelfRecords, - refs = result.shelfRefs, - fonts = result.customFonts - ) - }.onFailure { error -> - logDesktopCloudSync { "desktop.full_sync.failed user=${credentials.userId} error=${error.message.orEmpty()}" } - val failed = state.copy(isRefreshing = false) - if (showBanner) { - updateState(failed.withBanner(error.message ?: "Cloud sync failed.", isError = true)) - } else { - updateState(failed) - } - } - } - desktopCloudSyncJob = job - job.invokeOnCompletion { - if (desktopCloudSyncJob == job) desktopCloudSyncJob = null - } - return job - } - - fun setDesktopCloudSyncEnabled(enabled: Boolean) { - if (enabled && !desktopCloudSyncAvailable()) { - updateState(state.withBanner("Desktop cloud sync is not configured for this build.", isError = true)) - return - } - if (enabled && state.currentUser == null) { - updateState(state.withBanner("Sign in with Google to use cloud sync.", isError = true)) - return - } - if (enabled && !state.isProUser) { - updateState(state.withBanner("A Pro account is required for cloud sync.", isError = true)) - return - } - - saveDesktopCloudSyncSettings(syncEnabled = enabled) - val next = state.reduce(AppAction.SyncEnabledChanged(enabled)) - updateState(next) - if (enabled) { - syncDesktopCloud(showBanner = true) - } - } - - fun setDesktopFolderSyncEnabled(enabled: Boolean) { - saveDesktopCloudSyncSettings(folderSyncEnabled = enabled) - val next = state.reduce(AppAction.FolderSyncEnabledChanged(enabled)) - updateState(next) - if (enabled && next.isSyncEnabled) { - syncDesktopCloud(showBanner = false) - } - } - - fun queueCloudBookMetadataSync( - book: BookItem, - uploadContent: Boolean = false, - debounce: Boolean = true, - dirtyBaseTimestamp: Long? = null, - forceUploadAnnotations: Boolean = false - ) { - if (!state.isSyncEnabled) return - if (isDesktopPdfReflowBookId(book.id)) return - if (book.sourceFolder != null) return - if (book.path?.startsWith("opds-pse") == true) return - if (SharedFileCapabilities.isManualOnlyReaderFileName(book.displayName)) return - - logDesktopCloudSync { - "desktop.book_queue.request uploadContent=$uploadContent debounce=$debounce dirtyBaseTs=$dirtyBaseTimestamp " + - "forceAnnotations=$forceUploadAnnotations ${book.desktopCloudSyncSummary()}" - } - desktopBookCloudSyncJobs.remove(book.id)?.cancel() - val job = scope.launch { - if (!uploadContent && debounce) delay(1_200L) - val credentials = desktopCloudSyncCredentials(showBanner = false) ?: run { - logDesktopCloudSync { "desktop.book_queue.skip reason=missing_credentials book=${book.id}" } - return@launch - } - val latestBook = state.rawLibraryBooks.firstOrNull { it.id == book.id } ?: run { - logDesktopCloudSync { "desktop.book_queue.skip reason=missing_local book=${book.id}" } - return@launch - } - if (isDesktopPdfReflowBookId(latestBook.id)) return@launch - if (latestBook.sourceFolder != null) return@launch - if (latestBook.path?.startsWith("opds-pse") == true) return@launch - if (SharedFileCapabilities.isManualOnlyReaderFileName(latestBook.displayName)) return@launch - - if (uploadContent) { - updateState(state.copy(uploadingBookIds = state.uploadingBookIds + latestBook.id)) - } - - try { - val remoteBook = withContext(Dispatchers.IO) { - desktopFirestoreRepository.getBookMetadata( - userId = credentials.userId, - bookId = latestBook.id, - idToken = credentials.idToken - ) - } - val localSidecarTimestamp = withContext(Dispatchers.IO) { - DesktopCloudSidecarSync.localAnnotationTimestamp(latestBook) - } - val hasLocalAnnotations = withContext(Dispatchers.IO) { - DesktopCloudSidecarSync.hasLocalAnnotationData(latestBook) - } - val remoteAnnotationDriveTimestamp = if (remoteBook?.hasAnnotations == true) { - withContext(Dispatchers.IO) { - desktopGoogleDriveRepository.getFileByName( - credentials.driveAccessToken, - desktopCloudAnnotationDriveFileName(latestBook.id) - )?.modifiedTimeMillis ?: 0L - } - } else { - 0L - } - val localReadingTimestamp = latestBook.effectiveCloudReadingPositionModifiedTimestamp() - val remoteReadingTimestamp = remoteBook?.effectiveCloudReadingPositionModifiedTimestamp() ?: 0L - val remoteAnnotationTimestamp = remoteBook?.effectiveCloudAnnotationModifiedTimestamp( - remoteAnnotationDriveTimestamp - ) ?: 0L - val latestBookForMetadata = if (remoteBook != null && remoteReadingTimestamp > localReadingTimestamp) { - latestBook.withCloudReadingPosition(remoteBook) - } else { - latestBook - } - val localFile = latestBook.path?.let(::File) - val localFileAvailable = localFile?.isFile == true - val localContentTimestamp = latestBook.fileContentModifiedTimestamp.takeIf { it > 0L } - ?: localFile?.takeIf { it.isFile }?.lastModified() - ?: 0L - val remoteChangedSinceDirtyStart = dirtyBaseTimestamp != null && - remoteBook != null && - remoteBook.lastModifiedTimestamp != dirtyBaseTimestamp - logDesktopCloudSync { - "desktop.book_queue.preflight book=${latestBook.id} dirtyBaseTs=$dirtyBaseTimestamp " + - "remoteChangedSinceDirtyStart=$remoteChangedSinceDirtyStart " + - latestBook.desktopCloudSyncSummary() + " " + - (remoteBook?.desktopCloudSyncSummary() ?: "remote=null") + - " localSidecarTs=$localSidecarTimestamp localContentTs=$localContentTimestamp" - } - logDesktopCloudAnnotations { - "desktop.queue.inspect book=${latestBook.id} dirtyBaseTs=$dirtyBaseTimestamp " + - "remoteChangedSinceDirtyStart=$remoteChangedSinceDirtyStart " + - "remoteHas=${remoteBook?.hasAnnotations} remoteTs=${remoteBook?.lastModifiedTimestamp ?: 0L} " + - "remoteAnnTs=$remoteAnnotationTimestamp remoteDriveAnnTs=$remoteAnnotationDriveTimestamp " + - "remoteReadTs=$remoteReadingTimestamp localReadTs=$localReadingTimestamp " + - "localHas=$hasLocalAnnotations localSidecarTs=$localSidecarTimestamp " + - DesktopCloudSidecarSync.localAnnotationDebugSummary(latestBook) - } - if (remoteChangedSinceDirtyStart && !(forceUploadAnnotations && hasLocalAnnotations)) { - logDesktopCloudAnnotations { - "desktop.queue.skip_upload book=${latestBook.id} reason=remote_changed_since_dirty " + - "dirtyBaseTs=$dirtyBaseTimestamp remoteTs=${remoteBook?.lastModifiedTimestamp ?: 0L}" - } - logDesktopCloudSync { "desktop.book_queue.decision action=pull_remote_changed_since_dirty book=${latestBook.id}" } - syncDesktopCloud(showBanner = false).join() - return@launch - } - val canUploadMetadata = remoteBook == null || shouldUploadLocalCloudBookMetadataUpdate( - localModifiedTimestamp = latestBook.timestamp, - remoteModifiedTimestamp = remoteBook.lastModifiedTimestamp - ) - val canUploadContent = when { - remoteBook?.isDeleted == true && canUploadMetadata -> localFileAvailable - uploadContent -> shouldUploadLocalCloudBookContent( - localFileAvailable = localFileAvailable, - localContentModifiedTimestamp = localContentTimestamp, - remoteContentModifiedTimestamp = remoteBook?.fileContentModifiedTimestamp - ) - else -> false - } - val canUploadAnnotations = (forceUploadAnnotations && hasLocalAnnotations) || - (hasLocalAnnotations && - (remoteBook == null || - !remoteBook.hasAnnotations || - localSidecarTimestamp > remoteAnnotationTimestamp)) - val shouldApplyRemote = remoteBook != null && shouldApplyRemoteCloudBookMetadataUpdate( - localModifiedTimestamp = latestBook.timestamp, - remoteModifiedTimestamp = remoteBook.lastModifiedTimestamp - ) - - if (remoteBook != null && !canUploadMetadata && !canUploadContent && !canUploadAnnotations) { - logDesktopCloudAnnotations { - "desktop.queue.no_upload book=${latestBook.id} canUploadAnnotations=$canUploadAnnotations " + - "canUploadMetadata=$canUploadMetadata shouldApplyRemote=$shouldApplyRemote " + - "remoteHas=${remoteBook.hasAnnotations} remoteTs=${remoteBook.lastModifiedTimestamp} " + - "remoteAnnTs=$remoteAnnotationTimestamp remoteDriveAnnTs=$remoteAnnotationDriveTimestamp " + - "localSidecarTs=$localSidecarTimestamp" - } - logDesktopCloudSync { - "desktop.book_queue.decision action=${if (remoteBook.isDeleted || shouldApplyRemote) "pull_remote" else "noop"} " + - "book=${latestBook.id} canUploadMetadata=$canUploadMetadata canUploadContent=$canUploadContent " + - "canUploadAnnotations=$canUploadAnnotations shouldApplyRemote=$shouldApplyRemote" - } - if (remoteBook.isDeleted || shouldApplyRemote) { - syncDesktopCloud(showBanner = false).join() - } - return@launch - } - - val usesRemoteMetadataForUpload = !canUploadMetadata && shouldApplyRemote && remoteBook != null - val bookForUpload = if (usesRemoteMetadataForUpload && remoteBook != null) { - remoteBook.toDesktopBookItem(existing = latestBook).let { remoteMetadataBook -> - if (canUploadContent) { - remoteMetadataBook.copy(fileContentModifiedTimestamp = localContentTimestamp) - } else { - remoteMetadataBook - } - } - } else { - latestBookForMetadata - } - logDesktopCloudSync { - "desktop.book_queue.decision action=${if (usesRemoteMetadataForUpload) "upload_annotations_with_remote_metadata" else "upload_local"} " + - "book=${latestBook.id} canUploadMetadata=$canUploadMetadata canUploadContent=$canUploadContent " + - "canUploadAnnotations=$canUploadAnnotations shouldApplyRemote=$shouldApplyRemote" - } - logDesktopCloudAnnotations { - "desktop.queue.upload book=${latestBook.id} action=${if (usesRemoteMetadataForUpload) "upload_annotations_with_remote_metadata" else "upload_local"} " + - "canUploadAnnotations=$canUploadAnnotations canUploadMetadata=$canUploadMetadata " + - "remoteHas=${remoteBook?.hasAnnotations} remoteTs=${remoteBook?.lastModifiedTimestamp ?: 0L} " + - "remoteAnnTs=$remoteAnnotationTimestamp remoteDriveAnnTs=$remoteAnnotationDriveTimestamp " + - "localSidecarTs=$localSidecarTimestamp" - } - val syncedBook = withContext(Dispatchers.IO) { - desktopCloudSync.uploadBookAndMetadata( - input = DesktopCloudSyncInput( - userId = credentials.userId, - idToken = credentials.idToken, - driveAccessToken = credentials.driveAccessToken, - deviceId = credentials.deviceId, - state = state, - shelfRecords = shelfRecords, - shelfRefs = shelfRefs, - customFonts = customFonts, - includeFolderBooks = state.isFolderSyncEnabled - ), - book = bookForUpload, - uploadContent = canUploadContent, - uploadAnnotations = canUploadAnnotations, - remoteHasAnnotations = remoteBook?.hasAnnotations == true, - remoteAnnotationModifiedTimestamp = remoteAnnotationTimestamp, - remoteContentModifiedTimestamp = remoteBook?.fileContentModifiedTimestamp - ) - } ?: return@launch - - logDesktopCloudSync { - "desktop.book_queue.upload_success oldTs=${latestBook.timestamp} newTs=${syncedBook.timestamp} " + - syncedBook.desktopCloudSyncSummary("synced") - } - updateState( - state.copy( - rawLibraryBooks = state.rawLibraryBooks.map { current -> - if (current.id == syncedBook.id && current.timestamp == latestBook.timestamp) { - syncedBook - } else { - current - } - } - ) - ) - } finally { - if (uploadContent) { - updateState(state.copy(uploadingBookIds = state.uploadingBookIds - latestBook.id)) - } - } - } - desktopBookCloudSyncJobs[book.id] = job - job.invokeOnCompletion { - if (desktopBookCloudSyncJobs[book.id] == job) { - desktopBookCloudSyncJobs.remove(book.id) - } - } - } - - fun syncClosedReaderBooksIfDirty(bookIds: Set) { - val dirtyBookIds = bookIds.intersect(readerCloudDirtyBookIds) - if (dirtyBookIds.isEmpty()) return - logDesktopCloudSync { "desktop.reader.close_dirty books=${dirtyBookIds.joinToString()} requested=${bookIds.joinToString()}" } - val dirtyBooks = dirtyBookIds.mapNotNull { bookId -> - state.rawLibraryBooks.firstOrNull { it.id == bookId } - ?.let { book -> - Triple( - book, - readerCloudDirtyBaseTimestamps[bookId], - bookId in readerCloudDirtySidecarBookIds - ) - } - } - clearReaderCloudDirty(dirtyBookIds) - dirtyBooks.forEach { (book, baseTimestamp, sidecarsDirty) -> - queueCloudBookMetadataSync( - book = book, - debounce = false, - dirtyBaseTimestamp = baseTimestamp, - forceUploadAnnotations = sidecarsDirty - ) - } - } - - fun syncClosedReaderBooksAfterDispose(bookIds: Set) { - if (bookIds.isEmpty()) return - scope.launch { - delay(DesktopReaderCloseDisposeSyncDelayMillis) - val stillClosedBookIds = bookIds - .filter { bookId -> readerWindows.none { it.bookId == bookId } } - .toSet() - closingReaderBookIds = closingReaderBookIds - bookIds - readerCloudStalePositionGuards = readerCloudStalePositionGuards - stillClosedBookIds - syncClosedReaderBooksIfDirty(stillClosedBookIds) - clearReaderCloudDirty(stillClosedBookIds) - } - } - - fun closeReaderWindow(windowId: String) { - logDesktopReaderClose("close_window_request windowId=${windowId.logPreview(80)} openWindows=${readerWindows.size}") - val closing = readerWindows.firstOrNull { it.id == windowId } ?: run { - logDesktopReaderClose("close_window_missing windowId=${windowId.logPreview(80)} openWindows=${readerWindows.size}") - return - } - val closingBookIds = setOf(closing.bookId) - val shouldStopTts = (closing.content as? DesktopReaderWindowContent.Text)?.extrasState?.cloudTts?.let { - it.isLoading || it.isPlaying || it.isPaused - } == true - logDesktopReaderClose( - "close_window_begin windowId=${closing.id.logPreview(80)} bookId=${closing.bookId.logPreview(80)} " + - "content=${closing.readerCloseContentLabel()} fullscreen=${closing.fullscreen} shouldStopTts=$shouldStopTts" - ) - if (desktopFeatureNoticeState?.placement?.readerWindowId == windowId) { - dismissDesktopFeatureNotice() - } - markReaderBooksClosing(closingBookIds) - closing.cancelReaderWork() - readerWindows = readerWindows.withoutDesktopReaderWindow(windowId) - logDesktopReaderClose( - "close_window_removed windowId=${closing.id.logPreview(80)} bookId=${closing.bookId.logPreview(80)} " + - "remainingWindows=${readerWindows.size}" - ) - updateState(state.reduce(AppAction.BookTabClosed(closing.bookId))) - if (shouldStopTts) { - scope.launch { desktopTtsAdapter.stop() } - } - syncClosedReaderBooksAfterDispose(closingBookIds) - } - - fun closeReaderWindowsForBookIds(bookIds: Set) { - if (bookIds.isEmpty()) return - val closing = readerWindows.filter { it.bookId in bookIds } - val closingBookIds = closing.mapTo(mutableSetOf()) { it.bookId } - val closingWindowIds = closing.mapTo(mutableSetOf()) { it.id } - val shouldStopTts = closing.any { window -> - (window.content as? DesktopReaderWindowContent.Text)?.extrasState?.cloudTts?.let { - it.isLoading || it.isPlaying || it.isPaused - } == true - } - val targetNoticeWindowId = desktopFeatureNoticeState?.placement?.readerWindowId - if (targetNoticeWindowId != null && targetNoticeWindowId in closingWindowIds) { - dismissDesktopFeatureNotice() - } - markReaderBooksClosing(closingBookIds) - closing.forEach { it.cancelReaderWork() } - readerWindows = readerWindows.withoutDesktopReaderBookIds(bookIds) - if (closingBookIds.isNotEmpty()) { - var nextState = state - closingBookIds.forEach { bookId -> - nextState = nextState.reduce(AppAction.BookTabClosed(bookId)) - } - updateState(nextState) - } - if (shouldStopTts) { - scope.launch { desktopTtsAdapter.stop() } - } - closingReaderBookIds = closingReaderBookIds - closingBookIds - readerCloudStalePositionGuards = readerCloudStalePositionGuards - closingBookIds - clearReaderCloudDirty(closingBookIds) - } - - fun closeAllReaderWindows() { - val closingBookIds = readerWindows.mapTo(mutableSetOf()) { it.bookId } - val shouldStopTts = readerWindows.any { window -> - (window.content as? DesktopReaderWindowContent.Text)?.extrasState?.cloudTts?.let { - it.isLoading || it.isPlaying || it.isPaused - } == true - } - markReaderBooksClosing(closingBookIds) - readerWindows.forEach { it.cancelReaderWork() } - readerWindows = emptyList() - if (desktopFeatureNoticeState?.placement?.readerWindowId != null) { - dismissDesktopFeatureNotice() - } - updateState(state.reduce(AppAction.AllTabsClosed)) - if (shouldStopTts) { - scope.launch { desktopTtsAdapter.stop() } - } - syncClosedReaderBooksAfterDispose(closingBookIds) - } - - fun syncCloudShelfChange(record: ShelfRecord, refs: List, isDeleted: Boolean = false) { - if (!state.isSyncEnabled || record.isSmart) return - scope.launch { - val credentials = desktopCloudSyncCredentials(showBanner = false) ?: return@launch - withContext(Dispatchers.IO) { - desktopCloudSync.syncShelfChange( - userId = credentials.userId, - idToken = credentials.idToken, - deviceId = credentials.deviceId, - record = record, - refs = refs, - isDeleted = isDeleted - ) - } - } - } - - fun deleteBooksFromDesktopCloud(books: List) { - if (!state.isSyncEnabled || books.isEmpty()) return - scope.launch { - val credentials = desktopCloudSyncCredentials(showBanner = false) ?: return@launch - withContext(Dispatchers.IO) { - desktopCloudSync.deleteBooksFromCloud( - userId = credentials.userId, - idToken = credentials.idToken, - accessToken = credentials.driveAccessToken, - deviceId = credentials.deviceId, - books = books - ) - } - } - } - - fun deleteCustomFontFromDesktopCloud(font: CustomFontItem) { - if (!state.isSyncEnabled) return - scope.launch { - val credentials = desktopCloudSyncCredentials(showBanner = false) ?: return@launch - withContext(Dispatchers.IO) { - desktopCloudSync.deleteFontFromCloud( - userId = credentials.userId, - idToken = credentials.idToken, - accessToken = credentials.driveAccessToken, - font = font - ) - } - } - } - - fun queueFullCloudSyncAfterLocalChange() { - if (!state.isSyncEnabled) return - val active = desktopCloudSyncJob - if (active?.isActive == true) { - if (pendingDesktopCloudSyncAfterActive) return - pendingDesktopCloudSyncAfterActive = true - scope.launch { - active.join() - pendingDesktopCloudSyncAfterActive = false - if (state.isSyncEnabled) { - syncDesktopCloud(showBanner = false) - } - } - } else { - syncDesktopCloud(showBanner = false) - } - } - - fun updateAiByokSettings(next: ReaderAiByokSettings) { - val sanitized = next.toDesktopPersistableAiSettings() - if (sanitized.ttsSpeakerId != aiByokSettings.toDesktopPersistableAiSettings().ttsSpeakerId && desktopTtsAdapter.isPlaybackActive) { - scope.launch { - snackbarHostState.showSnackbar( - desktopString("desktop_stop_reading_change_voices", "Stop reading to change voices.") - ) - } - return - } - val settingsToSave = sanitized - logDesktopTts( - "settings_update keyPresent=${sanitized.geminiKey.isNotBlank()} " + - "ttsModel=\"${sanitized.ttsModel.desktopTtsPreview()}\" speaker=\"${sanitized.ttsSpeakerId.desktopTtsPreview()}\" " + - "cloudAvailable=${sanitized.isCloudTtsAvailable}" - ) - - aiByokSettings = settingsToSave - readerWindows = readerWindows.replaceAllDesktopTextReaderContent { content -> - content.copy( - extrasState = content.extrasState.copy( - cloudTts = content.extrasState.cloudTts.copy( - isAvailable = effectiveAiSettings().isCloudTtsAvailable, - errorMessage = null, - cacheSummary = desktopTtsAdapter.cacheSummary( - content.session.reader.book.title, - settingsToSave.ttsSpeakerId - ) - ) - ) - ) - } - runCatching { aiByokStore.save(settingsToSave) } - .onFailure { error -> - logDesktopTts("settings_save_failed error=\"${error.desktopTtsSummary()}\"") - scope.launch { - snackbarHostState.showSnackbar(error.message ?: "AI settings could not be saved securely.") - } - } - } - - fun textReaderTtsCacheSummary(content: DesktopReaderWindowContent.Text): ReaderTtsCacheSummary { - return desktopTtsAdapter.cacheSummary( - content.session.reader.book.title, - aiByokSettings.sanitized().ttsSpeakerId - ) - } - - fun readerCloudTtsStoppedState( - content: DesktopReaderWindowContent.Text, - statusMessage: String? = null, - errorMessage: String? = null - ) = ReaderCloudTtsState( - isAvailable = effectiveAiSettings().isCloudTtsAvailable, - statusMessage = statusMessage, - errorMessage = errorMessage, - cacheSummary = textReaderTtsCacheSummary(content) - ) - - fun cloudTtsUnavailableMessage(): String { - return if (!featurePolicy.aiAndCloud || !featurePolicy.networkAccess) { - desktopString( - "desktop_cloud_tts_unavailable", - "Cloud TTS unavailable" - ) - } else if (!desktopByokCloudTtsAvailable && !desktopCreditCloudTtsControlsAvailable) { - desktopString( - "desktop_cloud_tts_not_configured_desc", - "Cloud TTS is not configured for this desktop build." - ) - } else if (state.currentUser == null) { - desktopString("desktop_cloud_tts_sign_in_required_desc", "Sign in with Google to use cloud TTS.") - } else if (state.credits <= 0) { - desktopString( - "desktop_out_of_credits_android_purchase_desc", - "Out of credits. Pro and credits can only be purchased from the Android app." - ) - } else { - desktopString( - "desktop_cloud_tts_not_configured_desc", - "Cloud TTS is not configured for this desktop build." - ) - } - } - - fun desktopReadScopeLabel(readScope: ReaderTtsReadScope): String { - return when (readScope) { - ReaderTtsReadScope.PAGE -> desktopString("desktop_page", "Page") - ReaderTtsReadScope.CHAPTER -> desktopString("chapter", "Chapter") - ReaderTtsReadScope.BOOK -> desktopString("desktop_from_here", "From here") - } - } - - fun desktopFeatureNoticeForReaderAi(feature: ReaderAiFeature, text: String): DesktopFeatureNotice? { - if (desktopBuildProfile.byokAiAvailable) return null - if (!featurePolicy.networkAccess || !desktopCloudConfig.isAiWorkerConfigured) { - return desktopFeatureUnavailableNotice( - messageKey = "desktop_ai_not_configured_desc", - messageFallback = "Desktop AI is not configured for this build." - ) - } - if (feature == ReaderAiFeature.DEFINE && desktopReaderWordCount(text) > 1 && state.currentUser == null) { - return desktopSignInRequiredNotice( - messageKey = "desktop_sign_in_required_multi_word_dictionary_desc", - messageFallback = "Sign in with Google to use multi-word smart dictionary on desktop." - ) - } - if (feature == ReaderAiFeature.DEFINE && desktopReaderWordCount(text) > 1 && !state.isProUser) { - return desktopProRequiredNotice( - messageKey = "desktop_pro_required_multi_word_dictionary_desc", - messageFallback = "Multi-word smart dictionary requires Pro. Pro can only be purchased from the Android app, then desktop will use the upgraded account after sign-in." - ) - } - if (feature == ReaderAiFeature.SUMMARIZE && state.currentUser == null) { - return desktopSignInRequiredNotice( - messageKey = "desktop_sign_in_required_summaries_desc", - messageFallback = "Sign in with Google to use summaries on desktop." - ) - } - if (feature == ReaderAiFeature.SUMMARIZE && !state.isProUser && state.credits <= 0) { - return desktopOutOfCreditsNotice( - messageKey = "desktop_out_of_credits_summaries_desc", - messageFallback = "Using summaries needs credits on desktop. Pro and credits can only be purchased from the Android app." - ) - } - if (feature == ReaderAiFeature.RECAP && state.currentUser == null) { - return desktopSignInRequiredNotice( - messageKey = "desktop_sign_in_required_recaps_desc", - messageFallback = "Sign in with Google to use recaps on desktop." - ) - } - if (feature == ReaderAiFeature.RECAP && state.credits <= 0) { - return desktopOutOfCreditsNotice( - messageKey = "desktop_out_of_credits_recaps_desc", - messageFallback = "Using recaps needs credits on desktop. Pro and credits can only be purchased from the Android app." - ) - } - return null - } - - fun desktopFeatureNoticeForCloudTts(): DesktopFeatureNotice? { - if (!featurePolicy.aiAndCloud || !featurePolicy.networkAccess) { - return desktopFeatureUnavailableNotice( - messageKey = "desktop_cloud_tts_not_configured_desc", - messageFallback = "Cloud TTS is not configured for this desktop build." - ) - } - if (desktopByokCloudTtsAvailable) return null - if (!desktopCreditCloudTtsControlsAvailable) { - return desktopFeatureUnavailableNotice( - messageKey = "desktop_cloud_tts_not_configured_desc", - messageFallback = "Cloud TTS is not configured for this desktop build." - ) - } - if (state.currentUser == null) { - return desktopSignInRequiredNotice( - messageKey = "desktop_cloud_tts_sign_in_required_desc", - messageFallback = "Sign in with Google to use cloud TTS." - ) - } - if (state.credits <= 0) { - return desktopOutOfCreditsNotice( - messageKey = "desktop_out_of_credits_cloud_tts_desc", - messageFallback = "Using cloud TTS needs credits on desktop. Pro and credits can only be purchased from the Android app." - ) - } - return null - } - - fun openReaderExternalLookup(action: ReaderExternalLookupAction, text: String) { - if (!featurePolicy.externalLookup) return - val normalizedText = text.trim() - if (normalizedText.isBlank()) return - openExternalUrl(externalLookupUrl(action, normalizedText.take(1800))) - } - - fun readerHubBookKey(content: DesktopReaderWindowContent.Text): String { - return content.book.id.ifBlank { - content.session.reader.book.id.ifBlank { content.session.reader.book.title.ifBlank { "Untitled" } } - } - } - - fun readerHubChapterIndex(content: DesktopReaderWindowContent.Text): Int { - return content.session.reader.currentPage?.chapterIndex - ?: content.session.reader.currentPageIndex - } - - fun readerHubChapterTitle( - content: DesktopReaderWindowContent.Text, - index: Int = readerHubChapterIndex(content) - ): String { - return content.session.reader.book.chapters.getOrNull(index)?.title?.takeIf { it.isNotBlank() } - ?: content.session.reader.currentPage?.chapterTitle?.takeIf { it.isNotBlank() } - ?: "Chapter ${index + 1}" - } - - fun readerHubChapterText( - content: DesktopReaderWindowContent.Text, - index: Int = readerHubChapterIndex(content) - ): String { - return content.session.reader.book.chapters.getOrNull(index)?.plainText?.trim().orEmpty() - } - - fun readerHubCurrentChapterText(content: DesktopReaderWindowContent.Text): String { - return ReaderContextExtractor.currentChapterText(content.session).trim() - .ifBlank { readerHubChapterText(content) } - .ifBlank { content.session.reader.currentPage?.text?.trim().orEmpty() } - } - - fun readerHubCurrentTextForRecap(content: DesktopReaderWindowContent.Text): String { - val chapterText = readerHubChapterText(content) - val endOffset = content.session.reader.currentPage?.endOffset ?: chapterText.length - return if (chapterText.isNotBlank()) { - chapterText.take(endOffset.coerceIn(0, chapterText.length)).trim() - .ifBlank { chapterText.take(500).trim() } - } else { - ReaderContextExtractor.textBeforeCurrentLocation(content.session).trim().takeLast(24_000) - } - } - - fun clearReaderHubSummary(windowId: String) { - updateTextReaderWindow(windowId) { content -> - content.copy(summaryResult = null, isSummaryLoading = false) - } - } - - fun clearReaderHubRecap(windowId: String) { - updateTextReaderWindow(windowId) { content -> - content.copy(recapResult = null, isRecapLoading = false, recapProgressMessage = null) - } - } - - fun generateReaderHubSummary(windowId: String, force: Boolean) { - val content = textReaderWindowContent(windowId) ?: return - val text = readerHubCurrentChapterText(content) - val chapterIndex = readerHubChapterIndex(content) - val chapterTitle = readerHubChapterTitle(content, chapterIndex) - val bookKey = readerHubBookKey(content) - if (text.isBlank()) { - updateTextReaderWindow(windowId) { - it.copy( - summaryResult = SummarizationResult( - error = desktopString("desktop_no_text_to_summarize", "There is no text to summarize.") - ) - ) - } - return - } - if (!force) { - desktopSummaryCacheStore.getSummary(bookKey, chapterIndex)?.let { cached -> - updateTextReaderWindow(windowId) { - it.copy(summaryResult = SummarizationResult(summary = cached, isCacheHit = true)) - } - return - } - } - desktopFeatureNoticeForReaderAi(ReaderAiFeature.SUMMARIZE, text)?.let { notice -> - showDesktopFeatureNotice(notice, readerWindowId = windowId) - return - } - updateTextReaderWindow(windowId) { it.copy(isSummaryLoading = true, summaryResult = null) } - scope.launch { - var streamedSummary = "" - var streamedCost: Double? = null - var streamedFreeRemaining: Int? = null - fun updateStreamingSummary(error: String? = null) { - updateTextReaderWindow(windowId) { current -> - current.copy( - summaryResult = SummarizationResult( - summary = streamedSummary.takeIf { it.isNotBlank() }, - error = error, - cost = streamedCost, - freeRemaining = streamedFreeRemaining - ) - ) - } - } - val result = desktopAiAdapter.summarizeStreaming( - text = text, - onUsageReceived = { cost, freeRemaining -> - cost?.let { streamedCost = it } - freeRemaining?.let { streamedFreeRemaining = it } - updateStreamingSummary() - }, - onUpdate = { chunk -> - streamedSummary += chunk - updateStreamingSummary() - } - ) - val finalSummary = result.summary?.takeIf { it.isNotBlank() } ?: streamedSummary.takeIf { it.isNotBlank() } - finalSummary?.let { summary -> - desktopSummaryCacheStore.saveSummary(bookKey, chapterIndex, chapterTitle, summary) - } - updateTextReaderWindow(windowId) { current -> - current.copy( - summaryResult = result.copy(summary = finalSummary), - isSummaryLoading = false - ) - } - desktopFeatureNoticeForError(result.error)?.let { showDesktopFeatureNotice(it, readerWindowId = windowId) } - } - } - - fun generateReaderHubRecap(windowId: String) { - val content = textReaderWindowContent(windowId) ?: return - val currentText = readerHubCurrentTextForRecap(content) - if (currentText.isBlank()) { - updateTextReaderWindow(windowId) { it.copy(recapResult = RecapResult(error = "There is no reading context for a recap.")) } - return - } - desktopFeatureNoticeForReaderAi(ReaderAiFeature.RECAP, currentText)?.let { notice -> - showDesktopFeatureNotice(notice, readerWindowId = windowId) - return - } - val book = content.session.reader.book - val bookKey = readerHubBookKey(content) - val currentChapterIndex = readerHubChapterIndex(content).coerceIn(0, book.chapters.size.coerceAtLeast(1) - 1) - updateTextReaderWindow(windowId) { - it.copy( - isRecapLoading = true, - recapResult = null, - recapProgressMessage = "Checking past chapters..." - ) - } - scope.launch { - val pastSummaries = mutableListOf() - for (chapterIndex in 0 until currentChapterIndex) { - updateTextReaderWindow(windowId) { it.copy(recapProgressMessage = "Analyzing Chapter ${chapterIndex + 1}...") } - val cached = desktopSummaryCacheStore.getSummary(bookKey, chapterIndex) - if (!cached.isNullOrBlank()) { - pastSummaries += cached - continue - } - val latest = textReaderWindowContent(windowId) ?: return@launch - val chapterText = readerHubChapterText(latest, chapterIndex) - if (chapterText.length <= 100) continue - val summary = desktopAiAdapter.summarize(chapterText) - summary.summary?.takeIf { it.isNotBlank() }?.let { generated -> - val title = readerHubChapterTitle(latest, chapterIndex) - desktopSummaryCacheStore.saveSummary(bookKey, chapterIndex, title, generated) - pastSummaries += generated - } - if (summary.error != null) { - desktopFeatureNoticeForError(summary.error)?.let { showDesktopFeatureNotice(it, readerWindowId = windowId) } - } - delay(500) - } - - updateTextReaderWindow(windowId) { it.copy(recapProgressMessage = "Generating recap...") } - val recap = (desktopAiAdapter as? DesktopPaidAiAdapter) - ?.recapWithContext(pastSummaries, currentText) - ?: desktopAiAdapter.recap( - buildString { - pastSummaries.forEachIndexed { index, summary -> - append("Past chapter ${index + 1} summary:\n") - append(summary) - append("\n\n") - } - append(currentText) - } - ) - updateTextReaderWindow(windowId) { - it.copy( - recapResult = recap, - isRecapLoading = false, - recapProgressMessage = null - ) - } - desktopFeatureNoticeForError(recap.error)?.let { showDesktopFeatureNotice(it, readerWindowId = windowId) } - } - } - - fun isReaderAiResultVisible(content: DesktopReaderWindowContent.Text, requestId: Long): Boolean = - content.readerAiResultRequestId == requestId && content.dismissedReaderAiResultRequestId != requestId - - fun updateReaderAiResult(windowId: String, requestId: Long, aiResult: ReaderAiResultState) { - updateTextReaderWindow(windowId) { content -> - if (isReaderAiResultVisible(content, requestId)) { - content.copy(extrasState = content.extrasState.copy(aiResult = aiResult)) - } else { - content - } - } - } - - fun runReaderAiAction(windowId: String, feature: ReaderAiFeature, text: String) { - val content = textReaderWindowContent(windowId) ?: return - val normalizedText = text.trim() - if (normalizedText.isBlank()) return - if (!effectiveAiSettings().areReaderAiFeaturesAvailable) return - desktopFeatureNoticeForReaderAi(feature, normalizedText)?.let { notice -> - showDesktopFeatureNotice(notice, readerWindowId = windowId) - return - } - val aiResultRequestId = content.readerAiResultRequestId + 1 - updateTextReaderWindow(windowId) { - it.copy( - readerAiResultRequestId = aiResultRequestId, - dismissedReaderAiResultRequestId = null - ) - } - updateReaderAiResult( - windowId, - aiResultRequestId, - ReaderAiResultState( - title = feature.displayName, - isLoading = true - ) - ) - scope.launch { - val result = when (feature) { - ReaderAiFeature.DEFINE -> { - var streamedDefinition = "" - val latest = textReaderWindowContent(windowId) ?: return@launch - val definition = desktopAiAdapter.defineStreaming( - text = normalizedText.take(2400), - context = ReaderContextExtractor.currentPageText(latest.session), - onUpdate = { chunk -> - streamedDefinition += chunk - updateReaderAiResult( - windowId, - aiResultRequestId, - ReaderAiResultState( - title = feature.displayName, - text = streamedDefinition, - isLoading = true - ) - ) - } - ) - (definition.definition?.takeIf { it.isNotBlank() } ?: streamedDefinition) to definition.error - } - ReaderAiFeature.SUMMARIZE -> { - var streamedSummary = "" - var streamedCost: Double? = null - var streamedFreeRemaining: Int? = null - fun updateStreamingSummary() { - val partial = SummarizationResult( - summary = streamedSummary.takeIf { it.isNotBlank() }, - cost = streamedCost, - freeRemaining = streamedFreeRemaining - ) - updateTextReaderWindow(windowId) { current -> - current.copy(summaryResult = partial) - } - updateReaderAiResult( - windowId, - aiResultRequestId, - ReaderAiResultState( - title = feature.displayName, - text = streamedSummary, - isLoading = true - ) - ) - } - val summary = desktopAiAdapter.summarizeStreaming( - text = normalizedText, - onUsageReceived = { cost, freeRemaining -> - cost?.let { streamedCost = it } - freeRemaining?.let { streamedFreeRemaining = it } - updateStreamingSummary() - }, - onUpdate = { chunk -> - streamedSummary += chunk - updateStreamingSummary() - } - ) - val finalSummary = summary.summary?.takeIf { it.isNotBlank() } ?: streamedSummary.takeIf { it.isNotBlank() } - finalSummary?.let { generated -> - val latest = textReaderWindowContent(windowId) ?: return@let - desktopSummaryCacheStore.saveSummary( - readerHubBookKey(latest), - readerHubChapterIndex(latest), - readerHubChapterTitle(latest), - generated - ) - } - updateTextReaderWindow(windowId) { current -> - current.copy(summaryResult = summary.copy(summary = finalSummary)) - } - finalSummary to summary.error - } - ReaderAiFeature.RECAP -> { - val recap = desktopAiAdapter.recap(normalizedText) - updateTextReaderWindow(windowId) { current -> - current.copy(recapResult = recap) - } - recap.recap to recap.error - } - } - updateReaderAiResult( - windowId, - aiResultRequestId, - ReaderAiResultState( - title = feature.displayName, - text = result.first.orEmpty(), - errorMessage = result.second, - isLoading = false - ) - ) - val latest = textReaderWindowContent(windowId) - if (latest != null && isReaderAiResultVisible(latest, aiResultRequestId)) { - desktopFeatureNoticeForError(result.second)?.let { showDesktopFeatureNotice(it, readerWindowId = windowId) } - } - } - } - - fun isDesktopFolderLocalSyncEnabled(sourceFolder: String?): Boolean { - if (sourceFolder.isNullOrBlank()) return false - return state.syncedFolders.firstOrNull { it.uriString == sourceFolder }?.localSyncEnabled ?: true - } - - fun syncBookSidecars(book: BookItem, debounceMillis: Long = 0L) { - if (book.sourceFolder.isNullOrBlank()) { - logDesktopFolderSync("bookSidecars.skipNoFolder book=${book.id}") - return - } - if (!isDesktopFolderLocalSyncEnabled(book.sourceFolder)) { - logDesktopFolderSync( - "bookSidecars.skipDisabled book=${book.id} " + - "sourceFolder=\"${book.sourceFolder.orEmpty().folderSyncPreview()}\"" - ) - return - } - logDesktopFolderSync( - "bookSidecars.request book=${book.id} sourceFolder=\"${book.sourceFolder.orEmpty().folderSyncPreview()}\"" - ) - desktopBookSidecarSaveJobs.remove(book.id)?.cancel() - val saveJob = scope.launch(Dispatchers.IO) { - if (debounceMillis > 0L) { - delay(debounceMillis) - } - DesktopLocalFolderSync.saveBookSidecars(book) - } - desktopBookSidecarSaveJobs[book.id] = saveJob - saveJob.invokeOnCompletion { - desktopBookSidecarSaveJobs.remove(book.id, saveJob) - } - } - - fun scheduleFolderMetadataExtraction(sourceFolders: Set) { - val enabledSourceFolders = sourceFolders.filterTo(mutableSetOf()) { isDesktopFolderLocalSyncEnabled(it) } - if (enabledSourceFolders.isEmpty()) return - val snapshotBooks = state.rawLibraryBooks - val originalBooksById = snapshotBooks - .filter { it.sourceFolder in enabledSourceFolders } - .associateBy { it.id } - if (originalBooksById.isEmpty()) return - - scope.launch { - val metadataResult = withContext(Dispatchers.IO) { - DesktopFolderMetadataExtractor.enrichFolderBooks( - books = snapshotBooks, - sourceFolders = enabledSourceFolders - ) - } - if (metadataResult.stats.updatedBooks <= 0) return@launch - - val enrichedBooksById = metadataResult.books - .filter { it.sourceFolder in enabledSourceFolders } - .associateBy { it.id } - val booksToSave = mutableListOf() - val mergedBooks = state.rawLibraryBooks.map { current -> - val enriched = enrichedBooksById[current.id] - ?.takeIf { current.sourceFolder in enabledSourceFolders } - ?: return@map current - val merged = current.withDesktopImportMetadata( - enriched = enriched, - original = originalBooksById[current.id] - ) - if (merged != current) booksToSave += merged - merged - } - if (booksToSave.isEmpty()) return@launch - - updateState(state.copy(rawLibraryBooks = mergedBooks)) - withContext(Dispatchers.IO) { - booksToSave.forEach { syncBook -> - DesktopLocalFolderSync.saveBookSidecars(syncBook) - } - } - } - } - - fun BookItem.matchesIncomingReaderPosition( - pageIndex: Int, - progress: Float, - session: ReaderSessionState? - ): Boolean { - val savedPage = if (type == FileType.PDF || type == FileType.PPTX || SharedFileCapabilities.isComicArchive(type)) { - lastPageIndex - } else { - readerPosition?.pageIndex ?: lastPageIndex - } - val savedProgress = progressPercentage - val progressMatches = savedProgress != null && kotlin.math.abs(savedProgress - progress) < 0.001f - val locatorMatches = session == null || readerPosition == session.navigationLocator - return savedPage == pageIndex && progressMatches && locatorMatches - } - - fun shouldIgnoreStaleReaderEcho( - bookId: String, - pageIndex: Int, - progress: Float, - session: ReaderSessionState? - ): Boolean { - val guard = readerCloudStalePositionGuards[bookId] ?: return false - if (guard.matchesIncomingReaderPosition(pageIndex, progress, session)) { - logDesktopPositionTrace { - "event=persist_skip_stale_echo bookId=\"${bookId.logPreview(80)}\" page=$pageIndex progress=$progress " + - "incomingLocator=${session?.navigationLocator.desktopPositionTraceSummary()} " + - "guardLocator=${guard.readerPosition.desktopPositionTraceSummary()}" - } - logDesktopCloudSync { - "desktop.reader.position_skip_stale_echo book=$bookId page=$pageIndex progress=$progress " + - guard.desktopCloudSyncSummary("guard") - } - return true - } - readerCloudStalePositionGuards = readerCloudStalePositionGuards - bookId - logDesktopPositionTrace { - "event=persist_stale_guard_cleared bookId=\"${bookId.logPreview(80)}\" page=$pageIndex progress=$progress " + - "incomingLocator=${session?.navigationLocator.desktopPositionTraceSummary()} " + - "guardLocator=${guard.readerPosition.desktopPositionTraceSummary()}" - } - logDesktopCloudSync { - "desktop.reader.position_guard_cleared book=$bookId page=$pageIndex progress=$progress" - } - return false - } - - fun updateBookReadingState( - bookId: String, - pageIndex: Int, - progress: Float, - session: ReaderSessionState? = null, - pdfViewport: SharedPdfReaderViewport? = null - ) { - val hasOpenReaderWindow = readerWindows.any { it.bookId == bookId } - val previousBook = state.rawLibraryBooks.firstOrNull { it.id == bookId } - logDesktopPositionTrace { - "event=persist_request bookId=\"${bookId.logPreview(80)}\" page=$pageIndex progress=$progress " + - "hasSession=${session != null} mode=${session?.reader?.settings?.readingMode ?: "none"} " + - "openWindow=$hasOpenReaderWindow closing=${bookId in closingReaderBookIds} " + - "incomingLocator=${session?.navigationLocator.desktopPositionTraceSummary()} " + - "previousPage=${previousBook?.lastPageIndex ?: "null"} " + - "previousProgress=${previousBook?.progressPercentage ?: "null"} " + - "previousLocator=${previousBook?.readerPosition.desktopPositionTraceSummary()}" - } - if (!hasOpenReaderWindow && bookId !in closingReaderBookIds) { - logDesktopPositionTrace { - "event=persist_skip_closed bookId=\"${bookId.logPreview(80)}\" page=$pageIndex progress=$progress" - } - logDesktopCloudSync { - "desktop.reader.position_skip_closed book=$bookId page=$pageIndex progress=$progress" - } - return - } - if (shouldIgnoreStaleReaderEcho(bookId, pageIndex, progress, session)) return - - var updatedBook: BookItem? = null - var shouldSyncSidecars = false - var dirtyBaseTimestamp: Long? = null - if (previousBook == null) { - logDesktopPositionTrace { - "event=persist_skip_missing_book bookId=\"${bookId.logPreview(80)}\" page=$pageIndex progress=$progress" - } - return - } - val textReaderSettings = session?.reader?.settings - val updatedTextReaderDefaults = textReaderSettings - ?.takeIf { it != state.readerDefaultSettings } - val readerPosition = session?.navigationLocator - val nextReaderSettings = textReaderSettings ?: previousBook.readerSettings - val nextBookmarks = session?.bookmarks ?: previousBook.readerBookmarks - val nextHighlights = session?.highlights ?: previousBook.readerHighlights - val nextPdfViewport = pdfViewport ?: previousBook.pdfReaderViewport - val progressChanged = previousBook.progressPercentage - ?.let { kotlin.math.abs(it - progress) >= DesktopProgressEpsilon } - ?: true - val isReaderDirty = - previousBook.lastPageIndex != pageIndex || - progressChanged || - previousBook.readerPosition != readerPosition || - previousBook.readerSettings != nextReaderSettings || - previousBook.readerBookmarks != nextBookmarks || - previousBook.readerHighlights != nextHighlights || - previousBook.pdfReaderViewport != nextPdfViewport - if (!isReaderDirty && updatedTextReaderDefaults == null) { - logDesktopPositionTrace { - "event=persist_skip_unchanged bookId=\"${bookId.logPreview(80)}\" page=$pageIndex progress=$progress" - } - return - } - val stateWithReaderDefaults = if (updatedTextReaderDefaults != null) { - state.withDesktopReaderEngineDefaultSettings( - DesktopReaderSettingsEngine.TEXT, - updatedTextReaderDefaults - ) - } else { - state - } - val next = stateWithReaderDefaults.copy( - readerDefaultSettings = textReaderSettings ?: state.readerDefaultSettings, - rawLibraryBooks = stateWithReaderDefaults.rawLibraryBooks.map { book -> - if (book.id == bookId) { - shouldSyncSidecars = isReaderDirty - if (isReaderDirty && book.id !in readerCloudDirtyBookIds) { - dirtyBaseTimestamp = book.timestamp - } - if (isReaderDirty) { - val now = System.currentTimeMillis() - book.copy( - progressPercentage = progress, - timestamp = now, - isRecent = true, - lastPageIndex = pageIndex, - readerPosition = readerPosition, - readerSettings = nextReaderSettings, - readerBookmarks = nextBookmarks, - readerHighlights = nextHighlights, - pdfReaderViewport = nextPdfViewport, - readingPositionModifiedTimestamp = now - ).also { updatedBook = it } - } else { - book - } - } else { - book - } - } - ) - updateState( - next, - persistDebounceMillis = if (isReaderDirty) DesktopReaderPositionPersistDebounceMillis else 0L - ) - logDesktopPositionTrace { - val saved = updatedBook - "event=persist_done bookId=\"${bookId.logPreview(80)}\" updated=${saved != null} " + - "requestedPage=$pageIndex requestedProgress=$progress " + - "savedPage=${saved?.lastPageIndex ?: "null"} savedProgress=${saved?.progressPercentage ?: "null"} " + - "savedLocator=${saved?.readerPosition.desktopPositionTraceSummary()} " + - "shouldSyncSidecars=$shouldSyncSidecars dirtyBaseTimestamp=${dirtyBaseTimestamp ?: "null"}" - } - if (updatedTextReaderDefaults != null) { - readerWindows = readerWindows.map { windowState -> - val content = windowState.content - if (content is DesktopReaderWindowContent.Text && - content.session.reader.settings != updatedTextReaderDefaults - ) { - windowState.copy( - content = content.copy( - session = readerEngine.updateSettings(content.session, updatedTextReaderDefaults) - ) - ) - } else { - windowState - } - } - } - if (shouldSyncSidecars) { - updatedBook?.let { book -> - syncBookSidecars(book, debounceMillis = DesktopReaderPositionPersistDebounceMillis) - } - markReaderCloudDirty(bookId, baseTimestamp = dirtyBaseTimestamp) - } - } - - fun updateBookReaderSettings(bookId: String, settings: ReaderSettings) { - val pdfSettings = settings.toDesktopPdfReaderSettings() - var updatedBook: BookItem? = null - var dirtyBaseTimestamp: Long? = null - val stateWithPdfDefaults = state.withDesktopReaderEngineDefaultSettings( - DesktopReaderSettingsEngine.PDF, - pdfSettings - ) - val next = stateWithPdfDefaults.copy( - rawLibraryBooks = stateWithPdfDefaults.rawLibraryBooks.map { book -> - if (book.id == bookId) { - if (book.id !in readerCloudDirtyBookIds) { - dirtyBaseTimestamp = book.timestamp - } - book.copy( - timestamp = System.currentTimeMillis(), - isRecent = true, - readerSettings = pdfSettings - ).also { updatedBook = it } - } else { - book - } - } - ) - updateState(next) - updatedBook?.let(::syncBookSidecars) - markReaderCloudDirty(bookId, baseTimestamp = dirtyBaseTimestamp) - } - - 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(windowId: String? = null) { - logDesktopTts("reader_stop_requested") - val targetWindowIds = readerWindows - .filter { window -> windowId == null || window.id == windowId } - .map { it.id } - .toSet() - readerWindows - .filter { window -> window.id in targetWindowIds } - .mapNotNull { window -> window.content as? DesktopReaderWindowContent.Text } - .forEach { it.ttsJob?.cancel() } - scope.launch { - desktopTtsAdapter.stop() - readerWindows = readerWindows.map { window -> - val content = window.content - if (window.id in targetWindowIds && content is DesktopReaderWindowContent.Text) { - window.copy( - content = content.copy( - ttsJob = null, - extrasState = content.extrasState.copy( - cloudTts = readerCloudTtsStoppedState( - content, - statusMessage = desktopString("desktop_stopped", "Stopped") - ) - ) - ) - ) - } else { - window - } - } - } - } - - fun signOutDesktopAccount() { - desktopAuthRepository.signOut() - desktopAccountProfileRepository.clearCachedProfiles() - desktopAccountProfileRefreshCompleted = true - stopReaderCloudTts() - saveDesktopCloudSyncSettings(syncEnabled = false) - updateState(state.copy(currentUser = null, isProUser = false, credits = 0, isSyncEnabled = false)) - accountStatusMessage = "Signed out." - } - - fun pauseResumeReaderCloudTts(windowId: String) { - val content = textReaderWindowContent(windowId) ?: return - val current = content.extrasState.cloudTts - if (current.isPaused) { - scope.launch { - desktopTtsAdapter.resume() - updateTextReaderWindow(windowId) { latest -> - latest.copy( - extrasState = latest.extrasState.copy( - cloudTts = latest.extrasState.cloudTts.copy( - isPaused = false, - isPlaying = true, - statusMessage = latest.extrasState.cloudTts.progress.currentPositionLabel - ?: desktopString("label_reading", "Reading") - ) - ) - ) - } - } - } else if (current.isPlaying) { - scope.launch { - desktopTtsAdapter.pause() - updateTextReaderWindow(windowId) { latest -> - latest.copy( - extrasState = latest.extrasState.copy( - cloudTts = latest.extrasState.cloudTts.copy( - isPlaying = false, - isPaused = true, - statusMessage = desktopString("desktop_paused", "Paused") - ) - ) - ) - } - } - } - } - - fun clearReaderCloudTtsCache(windowId: String) { - val content = textReaderWindowContent(windowId) ?: return - desktopTtsAdapter.clearBookCacheForSpeaker(content.session.reader.book.title, aiByokSettings.sanitized().ttsSpeakerId) - updateTextReaderWindow(windowId) { latest -> - latest.copy( - extrasState = latest.extrasState.copy( - cloudTts = latest.extrasState.cloudTts.copy( - statusMessage = desktopString("desktop_voice_cache_cleared", "Voice cache cleared"), - cacheSummary = textReaderTtsCacheSummary(latest) - ) - ) - ) - } - } - - fun startReaderCloudTts( - windowId: String, - readScope: ReaderTtsReadScope, - chunks: List, - startChunkIndex: Int = 0, - restartActive: Boolean = false, - applyReplacements: Boolean = true - ) { - val content = textReaderWindowContent(windowId) ?: return - val replacementBookId = content.book.id.ifBlank { content.session.reader.book.title } - val sourceChunks = chunks.filter { it.text.isNotBlank() } - logDesktopTtsStartTrace { - "event=desktop_start_request windowId=\"${windowId.logPreview(80)}\" scope=${readScope.name} " + - "incomingChunks=${chunks.size} sourceChunks=${sourceChunks.size} startChunkIndex=$startChunkIndex " + - "restartActive=$restartActive applyReplacements=$applyReplacements " + - "currentPage=${content.session.reader.currentPageIndex} sessionLocator=${content.session.navigationLocator.desktopPositionTraceSummary(160)} " + - "incomingFirst=${chunks.firstOrNull().desktopTtsStartTraceSummary(160)} " + - "sourceFirst=${sourceChunks.firstOrNull().desktopTtsStartTraceSummary(160)}" - } - val ttsChunks = if (applyReplacements) { - sourceChunks.withTtsReplacements(state.readerTtsReplacementPreferences, replacementBookId) - } else { - sourceChunks - } - logDesktopTtsStartTrace { - "event=desktop_start_prepared windowId=\"${windowId.logPreview(80)}\" scope=${readScope.name} " + - "ttsChunks=${ttsChunks.size} boundedStart=${startChunkIndex.coerceIn(0, ttsChunks.lastIndex.coerceAtLeast(0))} " + - "first=${ttsChunks.firstOrNull().desktopTtsStartTraceSummary(160)} " + - "second=${ttsChunks.getOrNull(1).desktopTtsStartTraceSummary(160)}" - } - val settings = aiByokSettings.sanitized() - val currentCloudTts = content.extrasState.cloudTts - logDesktopTts( - "reader_sequence_toggle scope=${readScope.name} chunks=${ttsChunks.size} " + - "isPlaying=${currentCloudTts.isPlaying} isLoading=${currentCloudTts.isLoading} " + - "keyPresent=${settings.geminiKey.isNotBlank()} ttsModel=\"${settings.ttsModel.desktopTtsPreview()}\" " + - "available=${desktopTtsAdapter.isAvailable}" - ) - val ttsActive = currentCloudTts.isPlaying || currentCloudTts.isLoading || currentCloudTts.isPaused - if (ttsActive && !restartActive) { - stopReaderCloudTts(windowId) - return - } - if (ttsActive) { - content.ttsJob?.cancel() - } - if (ttsChunks.isEmpty()) { - logDesktopTts("reader_sequence_ignored reason=blank_text scope=${readScope.name}") - updateTextReaderWindow(windowId) { latest -> - latest.copy( - extrasState = latest.extrasState.copy( - cloudTts = latest.extrasState.cloudTts.copy( - errorMessage = desktopString("desktop_no_text_here_to_read", "There is no text here to read."), - cacheSummary = textReaderTtsCacheSummary(latest) - ) - ) - ) - } - return - } - if (!desktopTtsAdapter.isAvailable) { - logDesktopTts("reader_sequence_blocked reason=adapter_unavailable") - desktopFeatureNoticeForCloudTts()?.let { showDesktopFeatureNotice(it, readerWindowId = windowId) } - updateTextReaderWindow(windowId) { latest -> - latest.copy( - extrasState = latest.extrasState.copy( - cloudTts = ReaderCloudTtsState( - isAvailable = false, - errorMessage = cloudTtsUnavailableMessage(), - cacheSummary = textReaderTtsCacheSummary(latest) - ) - ) - ) - } - return - } - readerWindows = readerWindows.map { window -> - val textContent = window.content as? DesktopReaderWindowContent.Text - if (window.id != windowId && textContent != null) { - textContent.ttsJob?.cancel() - window.copy( - content = textContent.copy( - ttsJob = null, - extrasState = textContent.extrasState.copy( - cloudTts = readerCloudTtsStoppedState( - textContent, - statusMessage = desktopString("desktop_stopped", "Stopped") - ) - ) - ) - ) - } else { - window - } - } - val ttsSessionId = System.currentTimeMillis() - val boundedStartChunkIndex = startChunkIndex.coerceIn(0, ttsChunks.lastIndex) - val playbackChunks = ttsChunks.drop(boundedStartChunkIndex) - logDesktopTtsStartTrace { - "event=desktop_playback_window windowId=\"${windowId.logPreview(80)}\" scope=${readScope.name} " + - "boundedStart=$boundedStartChunkIndex playbackChunks=${playbackChunks.size} " + - "playbackFirst=${playbackChunks.firstOrNull().desktopTtsStartTraceSummary(160)}" - } - val initialProgress = ReaderTtsProgress( - sessionId = ttsSessionId, - scope = readScope, - chunks = ttsChunks, - currentChunkIndex = boundedStartChunkIndex - 1 - ) - updateTextReaderWindow(windowId) { latest -> - latest.copy( - extrasState = latest.extrasState.copy( - cloudTts = ReaderCloudTtsState( - isAvailable = true, - isLoading = true, - statusMessage = desktopString( - "desktop_preparing_scope_format", - "Preparing %1\$s", - desktopReadScopeLabel(readScope) - ), - progress = initialProgress, - cacheSummary = textReaderTtsCacheSummary(latest) - ) - ) - ) - } - fun updateTextReaderTtsSession(transform: (DesktopReaderWindowContent.Text) -> DesktopReaderWindowContent.Text) { - updateTextReaderWindow(windowId) { latest -> - if (latest.extrasState.cloudTts.progress.sessionId == ttsSessionId) { - transform(latest) - } else { - latest - } - } - } - val ttsJob = scope.launch { - runCatching { - logDesktopTts( - "reader_sequence_start scope=${readScope.name} chunks=${ttsChunks.size} " + - "startChunk=${boundedStartChunkIndex + 1}" - ) - desktopTtsAdapter.speakChunks(content.session.reader.book.title, readScope, playbackChunks) { relativeIndex -> - if (!isActive) throw kotlinx.coroutines.CancellationException("Reader cloud TTS stopped") - val index = boundedStartChunkIndex + relativeIndex - val chunk = ttsChunks[index] - val progress = initialProgress.copy(currentChunkIndex = index) - val latest = textReaderWindowContent(windowId) - ?: throw kotlinx.coroutines.CancellationException("Reader window closed") - if (latest.session.reader.currentPageIndex != chunk.pageIndex) { - val updatedSession = readerEngine.goToPage(latest.session, chunk.pageIndex) - updateTextReaderWindow(windowId) { current -> current.copy(session = updatedSession) } - updateBookReadingState( - bookId = latest.book.id, - pageIndex = updatedSession.reader.currentPageIndex, - progress = updatedSession.reader.progress, - session = updatedSession - ) - } - updateTextReaderTtsSession { current -> - current.copy( - extrasState = current.extrasState.copy( - cloudTts = ReaderCloudTtsState( - isAvailable = true, - isPlaying = true, - statusMessage = progress.currentPositionLabel - ?: desktopString("label_reading", "Reading"), - progress = progress, - cacheSummary = textReaderTtsCacheSummary(current) - ) - ) - ) - } - 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()}\"" - ) - logDesktopTtsStartTrace { - "event=desktop_chunk_start scope=${readScope.name} index=${index + 1}/${ttsChunks.size} " + - "chunk=${chunk.desktopTtsStartTraceSummary(180)}" - } - } - }.onFailure { error -> - logDesktopTts("reader_sequence_failed error=\"${error.desktopTtsSummary()}\"") - updateTextReaderTtsSession { latest -> - if (error is kotlinx.coroutines.CancellationException) { - latest.copy( - ttsJob = null, - extrasState = latest.extrasState.copy( - cloudTts = readerCloudTtsStoppedState( - latest, - statusMessage = desktopString("desktop_stopped", "Stopped") - ) - ) - ) - } else { - desktopFeatureNoticeForError(error.message)?.let { showDesktopFeatureNotice(it, readerWindowId = windowId) } - latest.copy( - ttsJob = null, - extrasState = latest.extrasState.copy( - cloudTts = readerCloudTtsStoppedState( - latest, - errorMessage = error.message - ?: desktopString("desktop_cloud_tts_failed", "Cloud TTS failed.") - ) - ) - ) - } - } - }.onSuccess { - logDesktopTts("reader_sequence_success chunks=${ttsChunks.size}") - updateTextReaderTtsSession { latest -> - latest.copy( - ttsJob = null, - extrasState = latest.extrasState.copy( - cloudTts = readerCloudTtsStoppedState( - latest, - statusMessage = desktopString("desktop_finished", "Finished") - ) - ) - ) - } - } - } - updateTextReaderWindow(windowId) { latest -> latest.copy(ttsJob = ttsJob) } - } - - fun skipReaderCloudTtsChunk(windowId: String, delta: Int) { - val content = textReaderWindowContent(windowId) ?: return - val progress = content.extrasState.cloudTts.progress - if (progress.chunks.isEmpty()) return - val currentIndex = progress.currentChunkIndex.takeIf { it >= 0 } ?: return - val targetIndex = (currentIndex + delta).coerceIn(0, progress.chunks.lastIndex) - if (targetIndex == currentIndex) return - startReaderCloudTts( - windowId = windowId, - readScope = progress.scope, - chunks = progress.chunks, - startChunkIndex = targetIndex, - restartActive = true, - applyReplacements = false - ) - } - - fun locateReaderCloudTtsChunk(windowId: String) { - val content = textReaderWindowContent(windowId) ?: return - val chunk = content.extrasState.cloudTts.progress.currentChunk ?: return - val updatedSession = readerEngine.goToPage(content.session, chunk.pageIndex) - updateTextReaderWindow(windowId) { current -> current.copy(session = updatedSession) } - updateBookReadingState( - bookId = content.book.id, - pageIndex = updatedSession.reader.currentPageIndex, - progress = updatedSession.reader.progress, - session = updatedSession - ) - } - - fun toggleReaderCloudTts(windowId: String, text: String, locator: ReaderLocator? = null) { - val content = textReaderWindowContent(windowId) ?: return - val normalizedText = text.trim() - val settings = aiByokSettings.sanitized() - val currentCloudTts = content.extrasState.cloudTts - logDesktopTts( - "reader_toggle textChars=${normalizedText.length} isPlaying=${currentCloudTts.isPlaying} " + - "isLoading=${currentCloudTts.isLoading} keyPresent=${settings.geminiKey.isNotBlank()} " + - "ttsModel=\"${settings.ttsModel.desktopTtsPreview()}\" available=${desktopTtsAdapter.isAvailable}" - ) - if (currentCloudTts.isPlaying || currentCloudTts.isLoading || currentCloudTts.isPaused) { - stopReaderCloudTts(windowId) - return - } - if (normalizedText.isBlank()) { - logDesktopTts("reader_toggle_ignored reason=blank_text") - updateTextReaderWindow(windowId) { latest -> - latest.copy( - extrasState = latest.extrasState.copy( - cloudTts = latest.extrasState.cloudTts.copy( - errorMessage = desktopString( - "desktop_no_text_on_page_to_read", - "There is no text on this page to read." - ), - cacheSummary = textReaderTtsCacheSummary(latest) - ) - ) - ) - } - return - } - if (!desktopTtsAdapter.isAvailable) { - logDesktopTts("reader_toggle_blocked reason=adapter_unavailable") - desktopFeatureNoticeForCloudTts()?.let { showDesktopFeatureNotice(it, readerWindowId = windowId) } - updateTextReaderWindow(windowId) { latest -> - latest.copy( - extrasState = latest.extrasState.copy( - cloudTts = ReaderCloudTtsState( - isAvailable = false, - errorMessage = cloudTtsUnavailableMessage(), - cacheSummary = textReaderTtsCacheSummary(latest) - ) - ) - ) - } - return - } - val locatorChunks = locator - ?.takeIf { it.startOffset != null || !it.cfi.isNullOrBlank() } - ?.let { selectionLocator -> - ReaderTtsPlanner.chunksFromCurrentLocation( - content.session.copy(navigationLocator = selectionLocator) - ).takeIf { it.isNotEmpty() } - } - val page = locator - ?.pageIndex - ?.let { content.session.reader.pages.getOrNull(it) } - ?: content.session.reader.currentPage - val selectionChunks = locatorChunks ?: if (page != null) { - ReaderTtsPlanner.chunksForText( - text = normalizedText, - pageIndex = locator?.pageIndex ?: page.pageIndex, - chapterIndex = locator?.chapterIndex ?: page.chapterIndex, - chapterTitle = page.chapterTitle, - sourceStartOffset = locator?.startOffset ?: page.startOffset - ) - } else { - ReaderTtsPlanner.chunksForText( - text = normalizedText, - pageIndex = content.session.reader.currentPageIndex, - chapterIndex = 0, - chapterTitle = desktopString("desktop_selection", "Selection") - ) - } - startReaderCloudTts( - windowId = windowId, - readScope = if (locatorChunks != null) ReaderTtsReadScope.BOOK else ReaderTtsReadScope.PAGE, - chunks = selectionChunks - ) - } - - fun finishImportFiles( - files: List, - failedCount: Int, - onImported: (List) -> Unit = {} - ) { - val importStart = System.currentTimeMillis() - val existingIds = state.rawLibraryBooks.mapTo(mutableSetOf()) { it.id } - val importPlan = SharedImportPlanner.plan( - files = files, - existingBookIds = existingIds, - platform = ReaderPlatform.DESKTOP, - nowMillis = importStart - ) - val counts = SharedImportOutcomeCounts( - addedCount = importPlan.importedCount, - duplicateCount = importPlan.duplicateCount, - unsupportedCount = importPlan.unsupportedCount, - failedCount = failedCount - ) - if (files.isEmpty() && failedCount > 0) { - updateState( - state.withBanner( - desktopQuantityString( - "desktop_import_failed_file_count", - failedCount, - "Could not import %1\$d file.", - "Could not import %1\$d files.", - failedCount - ), - isError = true - ) - ) - return - } - if (importPlan.supportedFiles.isEmpty() && files.isNotEmpty()) { - updateState( - state.withBanner( - "No supported desktop reader files were selected. " + - "${SharedFileCapabilities.supportedFormatsLabel(ReaderPlatform.DESKTOP)} are supported.", - isError = true - ) - ) - return - } - val next = state.copy(rawLibraryBooks = importPlan.importedBooks + state.rawLibraryBooks) - .let { - when { - counts.addedCount > 0 && (counts.unsupportedCount > 0 || counts.failedCount > 0) -> { - val skippedCount = counts.unsupportedCount + counts.failedCount - val importedMessage = desktopQuantityString( - "desktop_imported_file_count", - counts.addedCount, - "Imported %1\$d file.", - "Imported %1\$d files.", - counts.addedCount - ) - val skippedMessage = desktopQuantityString( - "desktop_skipped_file_count", - skippedCount, - "Skipped %1\$d file.", - "Skipped %1\$d files.", - skippedCount - ) - it.withBanner( - desktopString( - "desktop_import_result_pair", - "%1\$s %2\$s", - importedMessage, - skippedMessage - ) - ) - } - counts.addedCount > 0 -> it.withBanner( - desktopQuantityString( - "desktop_imported_file_count", - counts.addedCount, - "Imported %1\$d file.", - "Imported %1\$d files.", - counts.addedCount - ) - ) - counts.duplicateCount > 0 -> it.withBanner("Those files are already in the library.") - counts.failedCount > 0 -> it.withBanner( - desktopQuantityString( - "desktop_import_failed_file_count", - counts.failedCount, - "Could not import %1\$d file.", - "Could not import %1\$d files.", - counts.failedCount - ), - isError = true - ) - else -> it - } - } - updateState(next) - onImported(importPlan.importedBooks) - importPlan.importedBooks.forEach { imported -> - queueCloudBookMetadataSync(imported, uploadContent = true) - } - val targetBookIds = importPlan.importedBooks.mapTo(mutableSetOf()) { it.id } - 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] - ).copy(timestamp = System.currentTimeMillis()) - } - ) - ) - enrichedBooksById.values.forEach { enriched -> - queueCloudBookMetadataSync(enriched, uploadContent = false) - } - } - } - } - - fun importFiles(files: List, onImported: (List) -> Unit = {}) { - if (files.isEmpty()) return - updateState( - state.withBanner( - desktopQuantityString( - "desktop_importing_file_count", - files.size, - "Importing %1\$d file...", - "Importing %1\$d files...", - files.size - ) - ) - ) - scope.launch { - val preparedImport = withContext(Dispatchers.IO) { - desktopBookImporter.prepareImports(files) - } - finishImportFiles( - files = preparedImport.files, - failedCount = preparedImport.failedCount, - onImported = onImported - ) - } - } - - fun syncLocalFolders( - targetFolder: File? = null, - showBanner: Boolean = true, - metadataOnly: Boolean = false - ) { - val mode = if (metadataOnly) "metadata" else "full" - logDesktopFolderSync( - "ui.sync.request mode=$mode target=\"${targetFolder?.absolutePath?.folderSyncPreview() ?: "ALL"}\" " + - "showBanner=$showBanner linkedFolders=${state.syncedFolders.size} books=${state.rawLibraryBooks.size}" - ) - if (targetFolder == null && state.syncedFolders.isEmpty()) { - logDesktopFolderSync("ui.sync.skipNoFolders mode=$mode") - updateState(state.withBanner("No local folders are linked yet.", isError = true)) - return - } - if (targetFolder == null && state.syncedFolders.none { it.localSyncEnabled }) { - logDesktopFolderSync("ui.sync.skipNoEnabledFolders mode=$mode") - updateState(state.withBanner("No local folders have sync enabled.", isError = true)) - return - } - - val snapshotState = state - val snapshotShelfRefs = shelfRefs - if (showBanner) { - val message = if (metadataOnly) { - "Folder sync: updating metadata..." - } else { - "Folder sync: scanning local folders..." - } - updateState(state.withBanner(message)) - } - - scope.launch { - val result = runCatching { - withContext(Dispatchers.IO) { - DesktopLocalFolderSync.sync( - state = snapshotState, - shelfRefs = snapshotShelfRefs, - targetFolder = targetFolder, - metadataOnly = metadataOnly, - extractMetadata = false - ) - } - }.onFailure { error -> - logDesktopFolderSync("ui.sync.failed mode=$mode error=${error.folderSyncSummary()}") - if (showBanner) { - updateState(state.withBanner(error.message ?: "Folder sync failed.", isError = true)) - } - }.getOrNull() ?: return@launch - val failedCount = result.failedFolders.size - val stats = result.stats - val metadataStats = result.metadataStats - val message = when { - failedCount > 0 && stats.supportedFiles == 0 -> - desktopQuantityString( - "desktop_folder_sync_failed_folder_count", - failedCount, - "Folder sync failed for %1\$d folder.", - "Folder sync failed for %1\$d folders.", - failedCount - ) - failedCount > 0 -> - desktopQuantityString( - "desktop_folder_sync_skipped_folder_count", - failedCount, - "Folder sync finished with %1\$d folder skipped.", - "Folder sync finished with %1\$d folders skipped.", - failedCount - ) - metadataOnly -> - "Folder metadata sync complete." - else -> - "Folder sync complete: ${stats.newBooks} new, ${stats.updatedBooks + stats.remoteMetadataUpdates + metadataStats.updatedBooks} updated, ${stats.removedBooks} removed." - } - logDesktopFolderSync( - "ui.sync.result mode=$mode failed=$failedCount message=\"${message.folderSyncPreview()}\" " + - "new=${stats.newBooks} updated=${stats.updatedBooks} remoteUpdates=${stats.remoteMetadataUpdates} " + - "removed=${stats.removedBooks} metadataExtracted=${metadataStats.updatedBooks}" - ) - val completedState = desktopFolderSyncCompletedState( - state = result.state, - message = message, - failedFolderCount = failedCount, - showBanner = showBanner - ) - replaceLibrary( - completedState, - refs = result.shelfRefs - ) - if (!metadataOnly) { - scheduleFolderMetadataExtraction(result.processedFolderUris.toSet()) - } - val existingBookIds = completedState.rawLibraryBooks.mapTo(mutableSetOf()) { it.id } - readerWindows = readerWindows.mapNotNull { window -> - val migratedBookId = result.idMigrations[window.bookId] ?: window.bookId - if (migratedBookId !in existingBookIds) { - window.cancelReaderWork() - null - } else if (migratedBookId != window.bookId) { - val migratedContent = when (val content = window.content) { - DesktopReaderWindowContent.Opening -> content - is DesktopReaderWindowContent.PasswordRequired -> content.copy( - book = content.book.copy(id = migratedBookId) - ) - is DesktopReaderWindowContent.Pdf -> content.copy( - book = content.book.copy(id = migratedBookId) - ) - is DesktopReaderWindowContent.Text -> content.copy( - book = content.book.copy(id = migratedBookId) - ) - } - window.copy( - id = migratedBookId, - opening = window.opening.copy(bookId = migratedBookId), - content = migratedContent - ) - } else { - window - } - } - queueFullCloudSyncAfterLocalChange() - } - } - - fun syncFolderMetadata(showBanner: Boolean = true) { - syncLocalFolders(showBanner = showBanner, metadataOnly = true) - } - - fun scanSyncedFolders(showBanner: Boolean = true) { - syncLocalFolders(showBanner = showBanner, metadataOnly = false) - } - - fun syncDesktopLibrary(showBanner: Boolean = true) { - val hasCloud = state.isSyncEnabled - val hasFolders = state.syncedFolders.any { it.localSyncEnabled } - if (!hasCloud && !hasFolders) { - updateState(state.withBanner("No sync methods are active.", isError = true)) - return - } - if (hasFolders) { - scanSyncedFolders(showBanner = showBanner) - } else if (hasCloud) { - syncDesktopCloud(showBanner = showBanner) - } - } - - fun importFolder(folder: File) { - logDesktopFolderSync("ui.importFolder.request folder=\"${folder.absolutePath.folderSyncPreview()}\"") - if (!DesktopLocalFolderSync.hasSupportedFiles(folder)) { - logDesktopFolderSync("ui.importFolder.skipNoSupportedFiles folder=\"${folder.absolutePath.folderSyncPreview()}\"") - 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}.")) - queueFullCloudSyncAfterLocalChange() - } - .onFailure { error -> - updateState(state.withBanner(error.message ?: "Could not import font.", isError = true)) - } - .getOrNull() - } - - fun downloadGoogleFont(fontName: String, onComplete: () -> Unit) { - if (!featurePolicy.googleFontsDownload) { - updateState(state.withBanner("Google Fonts download is unavailable in this desktop build.", isError = true)) - onComplete() - return - } - 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.")) - queueFullCloudSyncAfterLocalChange() - } - .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 resetAppFont = state.appFontPreference.referencesCustomFont(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 - } - } - readerWindows = readerWindows.replaceAllDesktopTextReaderContent { content -> - if (content.session.reader.settings.customFontPath == font.path) { - content.copy( - session = readerEngine.updateSettings( - content.session, - content.session.reader.settings.copy(fontFamily = "Default", customFontPath = null) - ) - ) - } else { - content - } - } - val nextState = state.copy( - rawLibraryBooks = clearedSettings, - appFontPreference = if (resetAppFont) AppFontPreference.System else state.appFontPreference - ) - updateState(nextState.withBanner("Deleted ${font.displayName}.")) - deleteCustomFontFromDesktopCloud(font) - } - - fun removeSelectedBooks() { - val booksToRemove = state.rawLibraryBooks.filter { it.id in state.selectedBookIds } - closeReaderWindowsForBookIds(booksToRemove.mapTo(mutableSetOf()) { it.id }) - SharedLibraryEditor.removeSelectedBooks(state, shelfRecords, shelfRefs)?.let { - replaceLibrary(it.state, records = it.shelfRecords, refs = it.shelfRefs) - deleteBooksFromDesktopCloud(booksToRemove) - } - } - - fun createShelf(name: String) { - SharedLibraryEditor.createShelf(state, shelfRecords, shelfRefs, name, System.currentTimeMillis())?.let { result -> - replaceLibrary(result.state, records = result.shelfRecords, refs = result.shelfRefs) - result.shelfRecords.lastOrNull { record -> record.name == name.trim() } - ?.let { record -> syncCloudShelfChange(record, result.shelfRefs) } - } - } - - fun createShelfWithBooks(name: String, bookIds: Set, clearSelection: Boolean = true) { - SharedLibraryEditor.createShelfWithBooks( - state = state, - shelfRecords = shelfRecords, - shelfRefs = shelfRefs, - name = name, - bookIds = bookIds, - clearSelection = clearSelection, - nowMillis = System.currentTimeMillis() - )?.let { result -> - replaceLibrary(result.state, records = result.shelfRecords, refs = result.shelfRefs) - result.shelfRecords.lastOrNull { record -> record.name == name.trim() } - ?.let { record -> syncCloudShelfChange(record, result.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) { - val previousRecord = shelfRecords.firstOrNull { it.id == shelf.id } - SharedLibraryEditor.renameShelf(state, shelfRecords, shelfRefs, shelf, name)?.let { result -> - replaceLibrary(result.state, records = result.shelfRecords, refs = result.shelfRefs) - result.shelfRecords.firstOrNull { record -> record.id == shelf.id } - ?.let { record -> - if (previousRecord != null && previousRecord.name != record.name) { - syncCloudShelfChange(previousRecord, shelfRefs, isDeleted = true) - } - syncCloudShelfChange(record, result.shelfRefs) - } - } - } - - fun deleteShelf(shelf: Shelf) { - val record = shelfRecords.firstOrNull { it.id == shelf.id } - val result = SharedLibraryEditor.deleteShelf(state, shelfRecords, shelfRefs, shelf) - replaceLibrary(result.state, records = result.shelfRecords, refs = result.shelfRefs) - record?.let { syncCloudShelfChange(it, shelfRefs, isDeleted = true) } - } - - fun addSelectedBooksToShelf(shelfId: String) { - SharedLibraryEditor.addSelectedBooksToShelf(state, shelfRecords, shelfRefs, shelfId, System.currentTimeMillis())?.let { result -> - replaceLibrary(result.state, records = result.shelfRecords, refs = result.shelfRefs) - result.shelfRecords.firstOrNull { record -> record.id == shelfId } - ?.let { record -> syncCloudShelfChange(record, result.shelfRefs) } - } - } - - fun addBooksToShelves(bookIds: Set, shelfIds: Set, clearSelection: Boolean) { - val targetShelfIds = shelfIds.filterTo(linkedSetOf()) { SharedLibraryEditor.canMutateShelf(it) } - SharedLibraryEditor.addBooksToShelves( - state = state, - shelfRecords = shelfRecords, - shelfRefs = shelfRefs, - bookIds = bookIds, - shelfIds = targetShelfIds, - clearSelection = clearSelection, - nowMillis = System.currentTimeMillis() - )?.let { result -> - replaceLibrary(result.state, records = result.shelfRecords, refs = result.shelfRefs) - targetShelfIds.forEach { shelfId -> - result.shelfRecords.firstOrNull { record -> record.id == shelfId } - ?.let { record -> syncCloudShelfChange(record, result.shelfRefs) } - } - } - } - - fun replaceShelfBooks(shelf: Shelf, bookIds: Set) { - SharedLibraryEditor.replaceShelfBooks(state, shelfRecords, shelfRefs, shelf.id, bookIds, System.currentTimeMillis())?.let { result -> - replaceLibrary(result.state, records = result.shelfRecords, refs = result.shelfRefs) - result.shelfRecords.firstOrNull { record -> record.id == shelf.id } - ?.let { record -> syncCloudShelfChange(record, result.shelfRefs) } - } - } - - fun tagSelectedBooks(tagName: String) { - SharedLibraryEditor.tagSelectedBooks(state, shelfRecords, shelfRefs, tagName, System.currentTimeMillis())?.let { - replaceLibrary(it.state, records = it.shelfRecords, refs = it.shelfRefs) - } - } - - fun applyBookMetadataUpdate(updated: BookItem) { - 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 { book -> - syncBookSidecars(book) - queueCloudBookMetadataSync(book, uploadContent = book.fileContentModifiedTimestamp > 0L) - } - } - - fun writeDesktopEpubMetadata(original: BookItem, updated: BookItem): BookItem { - val file = File(original.path ?: error("Book path is missing.")) - require(file.isFile && file.canWrite()) { "EPUB file is not writable." } - val backup = File( - File(desktopUserDataRoot(), "metadata_backups").apply { mkdirs() }, - "${original.id.toDesktopSafeFileName()}.epub" - ) - val snapshot = SharedEpubMetadataEditor.rewriteInPlace( - source = file, - backup = backup, - update = SharedEpubMetadataUpdate( - title = updated.title, - author = updated.author, - description = updated.description, - seriesName = updated.seriesName, - seriesIndex = updated.seriesIndex - ) - ) - return updated.copy( - title = snapshot.title ?: updated.title, - author = snapshot.author, - description = snapshot.description, - seriesName = snapshot.seriesName, - seriesIndex = snapshot.seriesIndex, - originalTitle = original.originalTitle ?: original.title, - originalAuthor = original.originalAuthor ?: original.author, - originalSeriesName = original.originalSeriesName ?: original.seriesName, - originalSeriesIndex = original.originalSeriesIndex ?: original.seriesIndex, - originalDescription = original.originalDescription ?: original.description, - fileSize = file.length(), - fileContentModifiedTimestamp = file.lastModified() - ) - } - - fun updateBookMetadata(updated: BookItem) { - val original = state.rawLibraryBooks.firstOrNull { it.id == updated.id } - if (original != null && original.type == FileType.EPUB && original.hasEmbeddedMetadataChange(updated)) { - scope.launch { - val rewritten = runCatching { - withContext(Dispatchers.IO) { - writeDesktopEpubMetadata(original, updated) - } - } - rewritten.onSuccess(::applyBookMetadataUpdate) - .onFailure { error -> - logDesktopDiagnostic("EpistemeDesktopMetadata") { - "epub_metadata_update_failed book=${updated.id} error=\"${error.message.orEmpty().logPreview()}\"" - } - updateState(state.copy(bannerMessage = BannerMessage("Could not update EPUB metadata."))) - } - } - return - } - - applyBookMetadataUpdate(updated) - } - - fun recordBookOpened(bookId: String) { - val now = System.currentTimeMillis() - val next = SharedLibraryEditor.markBookOpened(state, bookId, now) - val openedState = next.reduce(AppAction.BookTabOpened(bookId)) - updateState(openedState, persistDebounceMillis = DesktopLibraryOpenPersistDebounceMillis) - openedState.rawLibraryBooks.firstOrNull { it.id == bookId }?.let { book -> - syncBookSidecars(book, debounceMillis = DesktopLibraryOpenPersistDebounceMillis) - } - } - - fun scheduleOpenedBookMetadataExtraction(book: BookItem) { - scope.launch { - val enriched = withContext(Dispatchers.IO) { - DesktopFolderMetadataExtractor.enrichOpenedBook(book) - } - if (enriched == book) return@launch - updateState( - state.copy( - rawLibraryBooks = state.rawLibraryBooks.map { current -> - if (current.id == book.id) { - current.withDesktopImportMetadata(enriched = enriched, original = book) - .copy(timestamp = System.currentTimeMillis()) - } else { - current - } - } - ) - ) - state.rawLibraryBooks.firstOrNull { it.id == book.id }?.let { - markReaderCloudDirty(it.id, baseTimestamp = book.timestamp) - } - } - } - - fun schedulePdfEmbeddedAnnotationsLoad(windowId: String, document: DesktopPdfDocument) { - scope.launch { - delay(650L) - val stillOpen = readerWindows.any { window -> - window.id == windowId && - (window.content as? DesktopReaderWindowContent.Pdf)?.document?.handleId == document.handleId - } - if (!stillOpen) return@launch - val annotations = withContext(Dispatchers.IO) { - DesktopPdfium.loadEmbeddedAnnotations(document) - } - val stillCurrent = readerWindows.any { window -> - window.id == windowId && - (window.content as? DesktopReaderWindowContent.Pdf)?.document?.handleId == document.handleId - } - if (stillCurrent) { - document.replaceEmbeddedAnnotations(annotations) - } - } - } - - fun exitReaderTo(tab: SharedAppTab) { - if (tab == SharedAppTab.SHELVES) { - selectedLibraryTab = NonReaderLibraryTab.SHELVES - selectedTab = SharedAppTab.LIBRARY - } else { - selectedTab = tab.takeUnless { it == SharedAppTab.READER } ?: SharedAppTab.LIBRARY - } - } - - fun selectAppTab(tab: SharedAppTab) { - val nextTab = when { - tab == SharedAppTab.CATALOGS && !featurePolicy.opdsCatalogs -> SharedAppTab.LIBRARY - tab == SharedAppTab.PRO && !desktopAccountAvailable() -> SharedAppTab.LIBRARY - tab == SharedAppTab.SHELVES -> { - selectedLibraryTab = NonReaderLibraryTab.SHELVES - SharedAppTab.LIBRARY - } - else -> tab - } - if (nextTab == SharedAppTab.SETTINGS) { - settingsQuery = "" - settingsDestination = SharedSettingsDestination.ROOT - } - if (nextTab == SharedAppTab.READER) { - state.activeTabBookId?.let { bookId -> - readerWindows = readerWindows.focusDesktopReaderWindow(bookId) - } - selectedTab = SharedAppTab.LIBRARY - } else { - selectedTab = nextTab - } - } - - fun focusDesktopAppWindow() { - val ownerWindow = (window as? java.awt.Window) - ?: window?.let { javax.swing.SwingUtilities.getWindowAncestor(it) } - ?: return - EventQueue.invokeLater { - if (!ownerWindow.isDisplayable || !ownerWindow.isShowing) return@invokeLater - if (ownerWindow is java.awt.Frame && ownerWindow.extendedState and java.awt.Frame.ICONIFIED != 0) { - ownerWindow.extendedState = ownerWindow.extendedState and java.awt.Frame.ICONIFIED.inv() - } - ownerWindow.toFront() - ownerWindow.requestFocus() - ownerWindow.requestFocusInWindow() - } - } - - fun confirmDesktopFeatureNotice(notice: DesktopFeatureNotice) { - dismissDesktopFeatureNotice() - when (notice.action) { - DesktopFeatureNoticeAction.SIGN_IN -> signInDesktopAccount() - DesktopFeatureNoticeAction.OPEN_PRO -> { - selectAppTab(SharedAppTab.PRO) - focusDesktopAppWindow() - } - null -> Unit - } - } - - fun applyReaderOpenResult(result: DesktopReaderOpenResult) { - val applyStartedAt = System.nanoTime() - logDesktopReaderOpenTrace { - result.opening.openTracePrefix("desktop_apply_start") + - " result=${result.openTraceKind()}" - } - val window = readerWindows.firstOrNull { it.opening.requestId == result.opening.requestId } - if (window == null) { - if (result is DesktopReaderOpenResult.Pdf) { - result.document.close() - } - logDesktopReaderOpenTrace { - result.opening.openTracePrefix("desktop_apply_missing_window") + - " result=${result.openTraceKind()} durationMs=${applyStartedAt.elapsedOpenTraceMs()}" - } - return - } - - when (result) { - is DesktopReaderOpenResult.Failure -> { - readerWindows = readerWindows.withoutDesktopReaderWindow(window.id) - updateState(state.withBanner(result.message, isError = true)) - } - - is DesktopReaderOpenResult.PasswordRequired -> { - readerWindows = readerWindows.withDesktopReaderWindowContent( - requestId = result.opening.requestId, - content = DesktopReaderWindowContent.PasswordRequired( - book = result.book, - attemptedPassword = result.attemptedPassword - ) - ) - } - - is DesktopReaderOpenResult.Pdf -> { - readerWindows = readerWindows.withDesktopReaderWindowContent( - requestId = result.opening.requestId, - content = DesktopReaderWindowContent.Pdf( - book = result.book, - document = result.document - ) - ) - recordBookOpened(result.book.id) - if (result.book.type == FileType.PDF) { - schedulePdfEmbeddedAnnotationsLoad(window.id, result.document) - } - } - - is DesktopReaderOpenResult.Text -> { - val cloudTts = ReaderCloudTtsState( - isAvailable = effectiveAiSettings().isCloudTtsAvailable, - cacheSummary = desktopTtsAdapter.cacheSummary( - result.session.reader.book.title, - aiByokSettings.sanitized().ttsSpeakerId - ) - ) - readerWindows = readerWindows.withDesktopReaderWindowContent( - requestId = result.opening.requestId, - content = DesktopReaderWindowContent.Text( - book = result.book, - session = result.session, - extrasState = ReaderExtrasState(cloudTts = cloudTts) - ) - ) - recordBookOpened(result.book.id) - } - } - logDesktopReaderOpenTrace { - result.opening.openTracePrefix("desktop_apply_done") + - " result=${result.openTraceKind()} windowId=\"${window.id.logPreview(80)}\" " + - "durationMs=${applyStartedAt.elapsedOpenTraceMs()} openWindows=${readerWindows.size}" - } - } - - fun openReader( - book: BookItem, - password: String? = null, - force: Boolean = false, - returnTabOverride: SharedAppTab? = null - ) { - val desktopReaderSurface = SharedFileCapabilities.surfaceFor(book.type, ReaderPlatform.DESKTOP) - if (desktopReaderSurface == ReaderFeatureSurface.PDF_VIEWER) { - val path = book.path - if (path.isNullOrBlank()) { - 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 && !featurePolicy.opdsCatalogs) { - updateState(state.withBanner("OPDS streams are unavailable in this desktop build.", isError = true)) - return - } - } else if ( - desktopReaderSurface == ReaderFeatureSurface.EPUB_READER || - desktopReaderSurface == ReaderFeatureSurface.TEXT_READER - ) { - } else { - updateState( - state.withBanner( - "${SharedFileCapabilities.displayNameFor(book.type)} reader support comes later. " + - "${SharedFileCapabilities.supportedFormatsLabel(ReaderPlatform.DESKTOP)} are available on desktop." - ) - ) - return - } - - scheduleOpenedBookMetadataExtraction(book) - - val opening = DesktopReaderOpening( - requestId = ++nextReaderOpenRequestId, - bookId = book.id, - title = book.cardTitleForMessage(), - formatLabel = SharedFileCapabilities.displayNameFor(book.type), - returnTab = returnTabOverride - ?: selectedTab.takeUnless { it == SharedAppTab.READER } - ?: SharedAppTab.LIBRARY, - password = password - ) - logDesktopReaderOpenTrace { - opening.openTracePrefix("desktop_open_request") + - " type=${book.type} surface=$desktopReaderSurface force=$force " + - "path=\"${book.path.orEmpty().logPreview(180)}\"" - } - val readerDefaultSettings = state.readerDefaultSettings - val previousWindowCount = readerWindows.size - if (force) { - readerWindows.firstOrNull { it.bookId == book.id }?.let { existingWindow -> - logDesktopReaderOpenTrace { - opening.openTracePrefix("desktop_force_cancel_existing") + - " windowId=\"${existingWindow.id.logPreview(80)}\"" - } - existingWindow.cancelReaderWork() - } - } - val openDecision = readerWindows.openOrFocusDesktopReaderWindow(opening, force) - readerWindows = openDecision.windows - logDesktopReaderOpenTrace { - opening.openTracePrefix("desktop_window_decision") + - " shouldStart=${openDecision.shouldStartOpen} force=$force " + - "previousWindows=$previousWindowCount nextWindows=${openDecision.windows.size}" - } - if (!openDecision.shouldStartOpen) { - recordBookOpened(book.id) - logDesktopReaderOpenTrace { - opening.openTracePrefix("desktop_focus_existing_done") - } - return - } - - scope.launch { - logDesktopReaderOpenTrace { - opening.openTracePrefix("desktop_open_coroutine_start") - } - val result = withContext(Dispatchers.IO) { - val ioStartedAt = System.nanoTime() - logDesktopReaderOpenTrace { - opening.openTracePrefix("desktop_open_io_start") + - " surface=$desktopReaderSurface" - } - runCatching { - when (desktopReaderSurface) { - ReaderFeatureSurface.PDF_VIEWER -> { - val pdfStartedAt = System.nanoTime() - val path = book.path.orEmpty() - logDesktopReaderOpenTrace { - opening.openTracePrefix("desktop_pdf_load_start") + - " type=${book.type} path=\"${path.logPreview(180)}\" " + - "passwordSupplied=${!opening.password.isNullOrEmpty()}" - } - val streamReference = SharedOpdsStreamUri.parse(path) - val document = if (streamReference != null) { - DesktopPdfium.loadOpdsStream( - path = path, - title = book.title?.takeIf { it.isNotBlank() } ?: book.displayName, - reference = streamReference, - catalog = opdsRepository.catalogById(streamReference.catalogId) - ) - } else { - val readerFile = File(path) - when (book.type) { - FileType.PDF -> DesktopPdfium.load( - readerFile, - password = opening.password, - loadEmbeddedAnnotations = false - ) - FileType.PPTX -> DesktopPdfium.loadPptx(readerFile) - else -> DesktopPdfium.loadComic(readerFile, book.type) - } - } - logDesktopReaderOpenTrace { - opening.openTracePrefix("desktop_pdf_load_done") + - " type=${book.type} durationMs=${pdfStartedAt.elapsedOpenTraceMs()} " + - "pages=${document.pageCount}" - } - DesktopReaderOpenResult.Pdf(opening, book, document) - } - - ReaderFeatureSurface.EPUB_READER, - ReaderFeatureSurface.TEXT_READER -> { - val path = book.path?.takeIf { it.isNotBlank() } ?: error("Book path is missing.") - val readerFile = File(path) - val settingsStartedAt = System.nanoTime() - val restoredSettings = resolvedDesktopReaderSettings(book, readerDefaultSettings) - val semanticMode = desktopEpubBookLoadSemanticMode(restoredSettings) - val preparedHtmlChapterRange = if (semanticMode == SharedJvmBookLoadSemanticMode.SKIP) { - val initialChapter = book.readerPosition?.chapterIndex?.takeIf { it >= 0 } ?: 0 - (initialChapter - DesktopVerticalInitialPreparedHtmlChapterRadius).coerceAtLeast(0).. - (initialChapter + DesktopVerticalInitialPreparedHtmlChapterRadius) - } else { - null - } - logDesktopReaderOpenTrace { - opening.openTracePrefix("desktop_settings_restored") + - " durationMs=${settingsStartedAt.elapsedOpenTraceMs()} " + - "mode=${restoredSettings.readingMode} semanticMode=${semanticMode.name} " + - "preparedHtmlChapters=${preparedHtmlChapterRange?.let { "${it.first}..${it.last}" } ?: "all"} " + - "fontSize=${restoredSettings.fontSize} textAlign=${restoredSettings.textAlign} " + - "pageWidth=${restoredSettings.pageWidth}" - } - val loadStartedAt = System.nanoTime() - logDesktopReaderOpenTrace { - opening.openTracePrefix("desktop_text_load_start") + - " type=${book.type} semanticMode=${semanticMode.name} fileBytes=${readerFile.length()} " + - "path=\"${path.logPreview(180)}\"" - } - val loadedBook = SharedJvmBookLoader.load( - file = readerFile, - type = book.type, - titleOverride = book.title?.takeIf { it.isNotBlank() }, - authorOverride = book.author?.takeIf { it.isNotBlank() }, - semanticMode = semanticMode, - preparedHtmlChapterRange = preparedHtmlChapterRange - ) - logDesktopReaderOpenTrace { - opening.openTracePrefix("desktop_text_load_done") + - " durationMs=${loadStartedAt.elapsedOpenTraceMs()} " + - "loadedTitle=\"${loadedBook.title.logPreview(120)}\" " + - "chapters=${loadedBook.chapters.size} pagesBeforeSession=n/a " + - "textChars=${loadedBook.chapters.sumOf { it.plainText.length }} " + - "htmlChars=${loadedBook.chapters.sumOf { it.htmlContent.length }} " + - "semanticBlocks=${loadedBook.chapters.sumOf { it.semanticBlocks.size }} " + - "cssFiles=${loadedBook.css.size} cssChars=${loadedBook.css.values.sumOf { it.length }}" - } - val sessionStartedAt = System.nanoTime() - logDesktopReaderOpenTrace { - opening.openTracePrefix("desktop_session_create_start") + - " initialPage=${book.lastPageIndex ?: 0} hasLocator=${book.readerPosition != null} " + - "locator=${book.readerPosition.desktopPositionTraceSummary(70)} " + - "bookmarks=${book.readerBookmarks.size} highlights=${book.readerHighlights.size}" - } - logDesktopPositionTrace { - opening.openTracePrefix("desktop_position_restore_start") + - " initialPage=${book.lastPageIndex ?: 0} storedProgress=${book.progressPercentage ?: "null"} " + - "storedLocator=${book.readerPosition.desktopPositionTraceSummary()} " + - "mode=${restoredSettings.readingMode}" - } - val restoredSession = readerEngine.createSession( - book = loadedBook, - settings = restoredSettings, - initialPageIndex = book.lastPageIndex ?: 0, - initialLocator = book.readerPosition, - bookmarks = book.readerBookmarks, - highlights = book.readerHighlights - ) - logDesktopReaderOpenTrace { - opening.openTracePrefix("desktop_session_create_done") + - " durationMs=${sessionStartedAt.elapsedOpenTraceMs()} " + - "pages=${restoredSession.reader.pages.size} " + - "currentPage=${restoredSession.reader.currentPageIndex + 1} " + - "navigationLocator=${restoredSession.navigationLocator.desktopPositionTraceSummary(70)} " + - "visiblePages=${restoredSession.reader.visiblePages.map { it.pageIndex + 1 }}" - } - logDesktopPositionTrace { - opening.openTracePrefix("desktop_position_restore_session_done") + - " durationMs=${sessionStartedAt.elapsedOpenTraceMs()} " + - "page=${restoredSession.reader.currentPageIndex} pages=${restoredSession.reader.pages.size} " + - "navigationLocator=${restoredSession.navigationLocator.desktopPositionTraceSummary()}" - } - val restoredProgress = book.progressPercentage - val session = if (book.readerPosition == null && book.lastPageIndex == null && restoredProgress != null) { - val progressStartedAt = System.nanoTime() - logDesktopReaderOpenTrace { - opening.openTracePrefix("desktop_progress_restore_start") + - " progress=$restoredProgress" - } - readerEngine.goToProgress(restoredSession, restoredProgress.coerceIn(0f, 100f) / 100f) - .also { restored -> - logDesktopReaderOpenTrace { - opening.openTracePrefix("desktop_progress_restore_done") + - " durationMs=${progressStartedAt.elapsedOpenTraceMs()} " + - "currentPage=${restored.reader.currentPageIndex + 1} " + - "navigationLocator=${restored.navigationLocator.desktopPositionTraceSummary(70)}" - } - logDesktopPositionTrace { - opening.openTracePrefix("desktop_position_restore_progress_done") + - " durationMs=${progressStartedAt.elapsedOpenTraceMs()} " + - "page=${restored.reader.currentPageIndex} " + - "navigationLocator=${restored.navigationLocator.desktopPositionTraceSummary()}" - } - } - } else { - restoredSession - } - DesktopReaderOpenResult.Text(opening, book, session) - } - - else -> error("${SharedFileCapabilities.displayNameFor(book.type)} reader support comes later.") - } - }.getOrElse { error -> - logDesktopReaderOpenTrace { - opening.openTracePrefix("desktop_open_io_exception") + - " durationMs=${ioStartedAt.elapsedOpenTraceMs()} " + - "error=\"${error.message.orEmpty().logPreview(240)}\"" - } - if (desktopReaderSurface == ReaderFeatureSurface.PDF_VIEWER && - book.type == FileType.PDF && - error.isDesktopPdfPasswordException() - ) { - DesktopReaderOpenResult.PasswordRequired( - opening = opening, - book = book, - attemptedPassword = !opening.password.isNullOrEmpty() - ) - } else { - DesktopReaderOpenResult.Failure( - opening = opening, - book = book, - message = "Could not open ${SharedFileCapabilities.displayNameFor(book.type)}: " + - (error.message ?: "unknown error") - ) - } - }.also { loadedResult -> - logDesktopReaderOpenTrace { - opening.openTracePrefix("desktop_open_io_done") + - " result=${loadedResult.openTraceKind()} durationMs=${ioStartedAt.elapsedOpenTraceMs()}" - } - } - } - logDesktopReaderOpenTrace { - opening.openTracePrefix("desktop_open_result_ready") + - " result=${result.openTraceKind()}" - } - applyReaderOpenResult(result) - } - } - - fun requestPdfReflow( - sourceBook: BookItem, - document: DesktopPdfDocument, - pageIndex: Int - ) { - if (document.formatLabel != "PDF") return - val reflowBookId = desktopPdfReflowBookId(sourceBook.id) - val existingReflowBook = state.rawLibraryBooks.firstOrNull { book -> - book.id == reflowBookId && - book.path?.takeIf { it.isNotBlank() }?.let { File(it).isFile } == true - } - if (existingReflowBook != null) { - openReader( - existingReflowBook.copy(lastPageIndex = pageIndex), - force = true - ) - return - } - if (sourceBook.id in reflowingPdfBookIds) return - - reflowingPdfBookIds = reflowingPdfBookIds + sourceBook.id - updateState(state.withBanner("Generating Text View...")) - scope.launch { - try { - val originalTitle = sourceBook.title?.takeIf { it.isNotBlank() } - ?: sourceBook.displayName.substringBeforeLast('.', sourceBook.displayName) - .takeIf { it.isNotBlank() } - ?: document.title - val destination = desktopBookImporter.createBookFile( - desktopPdfReflowFileName(sourceBook.id, originalTitle) - ) - val generated = withContext(Dispatchers.IO) { - DesktopPdfReflowGenerator.generateHtmlFile( - document = document, - destFile = destination, - startPage = 1, - onProgress = {} - ) - } - if (!generated || !destination.isFile || destination.length() <= 0L) { - runCatching { destination.delete() } - updateState(state.withBanner("Text view generation failed.", isError = true)) - return@launch - } - - val reflowBook = desktopPdfReflowBookItem( - sourceBook = sourceBook, - generatedFile = destination, - nowMillis = System.currentTimeMillis(), - initialPageIndex = pageIndex - ) - updateState( - state.copy( - rawLibraryBooks = listOf(reflowBook) + state.rawLibraryBooks.filterNot { it.id == reflowBook.id } - ).withBanner("Generated Text View.") - ) - openReader( - reflowBook, - force = true - ) - } catch (error: Throwable) { - updateState( - state.withBanner( - error.message ?: "Text view generation failed.", - isError = true - ) - ) - } finally { - reflowingPdfBookIds = reflowingPdfBookIds - sourceBook.id - } - } - } - - fun removeFolder(shelf: Shelf) { - val removedBookIds = shelf.books.mapTo(mutableSetOf()) { it.id } - closeReaderWindowsForBookIds(removedBookIds) - SharedLibraryEditor.removeFolder(state, shelfRecords, shelfRefs, shelf)?.let { - replaceLibrary(it.state, records = it.shelfRecords, refs = it.shelfRefs) - queueFullCloudSyncAfterLocalChange() - } - } - - fun closeReaderTab(book: BookItem) { - readerWindows.firstOrNull { it.bookId == book.id }?.let { window -> - closeReaderWindow(window.id) - } ?: updateState(state.reduce(AppAction.BookTabClosed(book.id))) - } - - fun closeAllReaderTabs() { - closeAllReaderWindows() - } - - fun importAndOpenBook() { - val file = chooseBookFile() ?: return - val importedFile = file.toDesktopImportedBookFile() - 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)) { importedBooks -> - importedBooks.firstOrNull()?.let(::openReader) - } - } - - fun importAndOpenPdf() { - val file = choosePdfFile() ?: return - importFiles(listOf(file.toDesktopImportedBookFile())) { importedBooks -> - importedBooks.firstOrNull()?.let(::openReader) - } - } - - fun emitOpds(next: org.dueattendant149.bookreader.shared.opds.SharedOpdsScreenState) { - opdsState = next - } - - fun openOpdsCatalog(catalog: OpdsCatalog) { - if (!featurePolicy.opdsCatalogs) return - scope.launch { - opdsController.openCatalog(catalog, ::emitOpds) - } - } - - fun openOpdsFeedUrl(url: String) { - if (!featurePolicy.opdsCatalogs) return - scope.launch { - opdsController.openFeedUrl(url, ::emitOpds) - } - } - - fun navigateOpdsBack() { - scope.launch { - opdsController.navigateBack(::emitOpds) - } - } - - fun searchOpds(query: String) { - if (!featurePolicy.opdsCatalogs) return - scope.launch { - opdsController.search(query, ::emitOpds) - } - } - - fun loadNextOpdsPage() { - if (!featurePolicy.opdsCatalogs) return - 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()) { - closeReaderWindowsForBookIds(streamBookIds) - 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( - desktopQuantityString( - "desktop_opds_removed_stream_book_count", - streamBookIds.size, - "Removed %1\$d streamed OPDS book from that catalog.", - "Removed %1\$d streamed OPDS books from that catalog.", - streamBookIds.size - ) - ) - ) - } - } - - fun downloadOpdsBook(entry: OpdsEntry, acquisition: OpdsAcquisition) { - if (!featurePolicy.opdsCatalogs) { - updateState(state.withBanner("OPDS downloads are unavailable in this desktop build.", isError = true)) - return - } - 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.toDesktopImportedBookFile())) - 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?) { - if (!featurePolicy.opdsCatalogs) { - updateState(state.withBanner("OPDS streams are unavailable in this desktop build.", isError = true)) - return - } - 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) - } - - val latestReaderWindows by rememberUpdatedState(readerWindows) - val latestStateForDispose by rememberUpdatedState(state) - val latestShelfRecordsForDispose by rememberUpdatedState(shelfRecords) - val latestShelfRefsForDispose by rememberUpdatedState(shelfRefs) - val latestCustomFontsForDispose by rememberUpdatedState(customFonts) - DisposableEffect(Unit) { - onDispose { - flushDesktopPersistenceBeforeDispose( - projected = latestStateForDispose, - records = latestShelfRecordsForDispose, - refs = latestShelfRefsForDispose, - fonts = latestCustomFontsForDispose - ) - latestReaderWindows.forEach { it.closeReaderResources() } - } - } - - DesktopFileDropTarget( - window = window, - onFilesDropped = ::importFiles, - onDragStateChange = { dropImportState = it } - ) - - LaunchedEffect(Unit) { - if (featurePolicy.aiAndCloud && !desktopBuildProfile.byokAiAvailable) { - refreshDesktopAccountProfile(showBanner = false) - } - } - - LaunchedEffect(accountRefreshRequestCount) { - if (accountRefreshRequestCount > 0 && featurePolicy.aiAndCloud && !desktopBuildProfile.byokAiAvailable) { - refreshDesktopAccountProfile(showBanner = false) - } - } - - LaunchedEffect(state.isSyncEnabled, state.currentUser?.uid, state.isProUser, desktopAccountProfileRefreshCompleted) { - if ( - !initialDesktopCloudSyncDone && - desktopAccountProfileRefreshCompleted && - state.isSyncEnabled && - state.currentUser != null && - state.isProUser - ) { - initialDesktopCloudSyncDone = true - syncDesktopCloud(showBanner = false).join() - } - } - - LaunchedEffect(Unit) { - if (state.syncedFolders.any { it.localSyncEnabled }) { - scanSyncedFolders(showBanner = false) - } - } - - LaunchedEffect(state.bannerMessage) { - state.bannerMessage?.let { banner -> - snackbarHostState.showSnackbar(banner.text?.let(desktopStringResolver::sharedText) ?: banner.message) - updateState(state.reduce(AppAction.BannerDismissed)) - } - } - - LaunchedEffect(aiByokSettings, state.currentUser, state.credits) { - readerWindows = readerWindows.replaceAllDesktopTextReaderContent { content -> - content.copy( - extrasState = content.extrasState.copy( - cloudTts = content.extrasState.cloudTts.copy( - isAvailable = effectiveAiSettings().isCloudTtsAvailable, - errorMessage = null, - cacheSummary = textReaderTtsCacheSummary(content) - ) - ) - ) - } - } - - val desktopAppFontFamily = remember(state.appFontPreference, customFonts) { - state.appFontPreference.toDesktopAppFontFamily(customFonts) - } - - CompositionLocalProvider(LocalSharedStringResolver provides desktopStringResolver) { - SharedAppTheme( - appThemeMode = state.appThemeMode, - appContrastOption = state.appContrastOption, - appTextDimFactorLight = state.appTextDimFactorLight, - appTextDimFactorDark = state.appTextDimFactorDark, - appSeedColor = state.appSeedColor, - appFontFamily = desktopAppFontFamily - ) { - val appThemeControls: @Composable () -> Unit = { - SharedAppThemeControls( - appThemeMode = state.appThemeMode, - appContrastOption = state.appContrastOption, - appTextDimFactorLight = state.appTextDimFactorLight, - appTextDimFactorDark = state.appTextDimFactorDark, - appSeedColor = state.appSeedColor, - customAppThemes = state.customAppThemes, - onThemeModeChanged = { mode -> updateState(state.reduce(AppAction.AppThemeChanged(mode))) }, - onContrastOptionChanged = { option -> updateState(state.reduce(AppAction.AppContrastChanged(option))) }, - onTextDimFactorLightChanged = { factor -> updateState(state.reduce(AppAction.AppTextDimFactorLightChanged(factor))) }, - onTextDimFactorDarkChanged = { factor -> updateState(state.reduce(AppAction.AppTextDimFactorDarkChanged(factor))) }, - onSeedColorChanged = { color -> updateState(state.reduce(AppAction.AppSeedColorChanged(color))) }, - onCustomThemeAdded = { theme -> updateState(state.reduce(AppAction.CustomAppThemeAdded(theme))) }, - onCustomThemeDeleted = { themeId -> updateState(state.reduce(AppAction.CustomAppThemeDeleted(themeId))) } - ) - } - EpistemeDesktopWindowChromeEffect( - window = window, - captionColor = MaterialTheme.colorScheme.surface, - textColor = MaterialTheme.colorScheme.onSurface, - borderColor = MaterialTheme.colorScheme.background - ) - 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, - featurePolicy = featurePolicy, - currentUser = if (desktopAccountAvailable()) state.currentUser else null, - accountAvailable = desktopAccountAvailable(), - isOssBuild = desktopBuildProfile.isOssOffline, - isProUser = state.isProUser, - isSyncEnabled = state.isSyncEnabled, - syncAvailable = desktopCloudSyncAvailable(), - onSignInRequested = if (desktopAccountAvailable()) { - ::signInDesktopAccount - } else { - null - }, - accountAvatar = { user, modifier -> - DesktopProfileAvatar(user = user, modifier = modifier) - }, - onSyncEnabledChange = { enabled -> - setDesktopCloudSyncEnabled(enabled) - }, - onTabSelected = { tab -> - selectAppTab(tab) - }, - onImportFiles = { importFiles(chooseFiles()) }, - onImportFolder = { chooseFolder()?.let(::importFolder) }, - onSyncRequested = { syncDesktopLibrary() }, - onFolderMetadataSyncRequested = { syncFolderMetadata() }, - 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 -> - if (!enabled) closeAllReaderWindows() - updateState(state.reduce(AppAction.TabsEnabledChanged(enabled))) - }, - onAiSettingsRequested = if (desktopAiKeySettingsAvailable) { - { showAiByokSettingsDialog = true } - } else { - null - } - ) { tab -> - when (tab) { - SharedAppTab.SETTINGS -> SharedSettingsHub( - model = sharedSettingsHubModel( - SharedSettingsHubInput( - platform = SharedSettingsPlatform.DESKTOP, - featurePolicy = featurePolicy, - isDebugBuild = false, - isSignedIn = state.currentUser != null, - isProUser = state.isProUser, - accountAvailable = featurePolicy.aiAndCloud && !desktopBuildProfile.byokAiAvailable, - includeAccountAuthActions = false, - syncAvailable = desktopCloudSyncAvailable(), - folderSyncAvailable = true, - aiSettingsAvailable = desktopAiKeySettingsAvailable, - includeLanguage = true, - includeScreenCaptureProtection = false, - includeExternalFileBehavior = false, - includeStrictFileFilter = false, - includeReaderTabs = false, - includeHideReaderAi = false, - isTabsEnabled = state.isTabsEnabled, - isSyncEnabled = state.isSyncEnabled, - isFolderSyncEnabled = state.isFolderSyncEnabled, - hideReaderAi = false, - languageTitle = desktopString("options_language", "Language"), - languageSummary = selectedDesktopLanguageOption(desktopLanguageTag).let { option -> - desktopString(option.labelKey, option.fallbackLabel) - } - ) - ), - query = settingsQuery, - onQueryChange = { settingsQuery = it }, - destination = settingsDestination, - onDestinationChange = { settingsDestination = it }, - readerDefaultSettings = state.readerDefaultSettings, - onReaderDefaultSettingsChange = { settings -> - updateState( - state.withDesktopReaderEngineDefaultSettings( - DesktopReaderSettingsEngine.TEXT, - settings - ) - ) - }, - pdfReaderDefaultSettings = state.pdfReaderDefaultSettings, - onPdfReaderDefaultSettingsChange = { settings -> - updateState( - state.withDesktopReaderEngineDefaultSettings( - DesktopReaderSettingsEngine.PDF, - settings - ) - ) - }, - readerToolbarPreferences = state.readerToolbarPreferences, - onReaderToolbarPreferencesChange = { preferences -> - updateState(state.reduce(AppAction.ReaderToolbarPreferencesChanged(preferences))) - }, - ttsReplacementPreferences = state.readerTtsReplacementPreferences, - onTtsReplacementPreferencesChange = { preferences -> - updateState(state.reduce(AppAction.ReaderTtsReplacementPreferencesChanged(preferences))) - }, - customFonts = customFonts, - onPickCustomFont = { importCustomFont(chooseFontFile())?.path }, - customReaderThemes = state.customReaderThemes, - onCustomReaderThemesChange = { themes -> - updateState(state.reduce(AppAction.CustomReaderThemesChanged(themes))) - }, - readerCustomTextureIds = readerCustomTextureIds, - onImportReaderTexture = ::importDesktopReaderTexture, - onAction = { action -> - when (action) { - SharedSettingsAction.APP_THEME -> showDesktopAppThemeSettingsDialog = true - SharedSettingsAction.TABS_TOGGLE -> { - if (state.isTabsEnabled) closeAllReaderWindows() - updateState(state.reduce(AppAction.TabsEnabledChanged(!state.isTabsEnabled))) - } - SharedSettingsAction.FOLDER_SYNC -> setDesktopFolderSyncEnabled(!state.isFolderSyncEnabled) - SharedSettingsAction.AI_SETTINGS -> if (desktopAiKeySettingsAvailable) showAiByokSettingsDialog = true - SharedSettingsAction.SIGN_IN -> signInDesktopAccount() - SharedSettingsAction.SIGN_OUT -> signOutDesktopAccount() - SharedSettingsAction.HIDE_READER_AI -> Unit - SharedSettingsAction.CUSTOM_FONTS -> selectAppTab(SharedAppTab.CUSTOM_FONTS) - SharedSettingsAction.HELP_FEEDBACK -> selectAppTab(SharedAppTab.FEEDBACK) - SharedSettingsAction.SUPPORT -> selectAppTab(SharedAppTab.SUPPORT) - SharedSettingsAction.ABOUT -> selectAppTab(SharedAppTab.ABOUT) - SharedSettingsAction.CLEAR_BOOK_CACHE -> showClearBookCacheDialog = true - SharedSettingsAction.CLEAR_REFLOW_CACHE, - SharedSettingsAction.CLEAR_CLOUD_LOCAL_DATA, - SharedSettingsAction.TEST_PANEL_DETECTION, - SharedSettingsAction.TEST_SPEECH_BUBBLE_DETECTION, - SharedSettingsAction.EXPORT_LOGS, - SharedSettingsAction.DEBUG_ACTIONS, - SharedSettingsAction.DEVICE_MANAGEMENT, - SharedSettingsAction.RECENT_LIMIT, - SharedSettingsAction.STRICT_FILE_FILTER, - SharedSettingsAction.PDF_FILENAME_DISPLAY_NAME, - SharedSettingsAction.EXTERNAL_FILE_BEHAVIOR, - SharedSettingsAction.SCREEN_CAPTURE_PROTECTION, - SharedSettingsAction.TTS_SETTINGS, - SharedSettingsAction.PDF_READER_DEFAULTS, - SharedSettingsAction.TEXT_READER_DEFAULTS, - SharedSettingsAction.READER_TOOLBAR, - SharedSettingsAction.TTS_REPLACEMENTS, - SharedSettingsAction.LOCAL_OVERRIDE_NOTE -> Unit - SharedSettingsAction.LANGUAGE -> showDesktopLanguageDialog = true - SharedSettingsAction.CLOUD_SYNC -> setDesktopCloudSyncEnabled(!state.isSyncEnabled) - } - } - ) - - SharedAppTab.PRO -> DesktopProScreen( - user = state.currentUser, - isProUser = state.isProUser, - credits = state.credits, - authConfigured = desktopCloudConfig.isAuthConfigured, - isBusy = accountBusy, - statusMessage = accountStatusMessage, - onSignIn = ::signInDesktopAccount, - onSignOut = ::signOutDesktopAccount, - onRefresh = { - scope.launch { - accountBusy = true - refreshDesktopAccountProfile(showBanner = true) - accountBusy = false - } - } - ) - - SharedAppTab.LIBRARY -> LibraryScreen( - state = state, - selectedLibraryTab = selectedLibraryTab, - onLibraryTabChange = { selectedLibraryTab = it }, - onStateChange = ::updateState, - onImportBooks = { - importFiles(chooseFiles()) - }, - onImportFolder = { chooseFolder()?.let(::importFolder) }, - onRead = ::openReader, - onSelect = { id -> updateState(state.reduce(LibraryAction.BookSelectionToggled(id))) }, - onClearSelection = { updateState(state.reduce(LibraryAction.SelectionCleared)) }, - onRemoveSelected = ::removeSelectedBooks, - onShowBookInfo = { - bookInfoInitiallyEditing = false - bookInfoDialogFor = it - }, - onEditBook = { - bookInfoInitiallyEditing = true - bookInfoDialogFor = it - }, - onCreateShelf = { - createShelfBookIds = emptySet() - createShelfClearsSelection = false - showCreateShelfDialog = true - }, - onCreateShelfWithBooks = { name, bookIds -> createShelfWithBooks(name, bookIds) }, - onCreateSmartShelf = { showCreateSmartShelfDialog = true }, - onRenameShelf = { shelfToRename = it }, - onDeleteShelf = { shelfToDelete = it }, - onRemoveFolder = { folderToRemove = it }, - onTagSelectedBooks = { showTagSelectionDialog = true }, - onAddSelectedBooksToShelf = { - addToShelfBookIds = state.selectedBookIds - addToShelfClearsSelection = true - }, - onAddBooksToShelf = { bookIds -> - addToShelfBookIds = bookIds - addToShelfClearsSelection = false - }, - onManageShelfBooks = { shelfToManageBooks = it }, - onSyncFolderMetadata = { syncFolderMetadata() }, - onScanFolders = { scanSyncedFolders() }, - onTogglePinned = { book -> updateState(state.reduce(AppAction.LibraryPinToggled(book.id))) }, - onSaveOriginalFile = ::saveDesktopOriginalFile - ) - - SharedAppTab.SHELVES -> LibraryScreen( - state = state, - selectedLibraryTab = NonReaderLibraryTab.SHELVES, - onLibraryTabChange = { - selectedLibraryTab = it - selectedTab = SharedAppTab.LIBRARY - }, - onStateChange = ::updateState, - onImportBooks = { - importFiles(chooseFiles()) - }, - onImportFolder = { chooseFolder()?.let(::importFolder) }, - onRead = ::openReader, - onSelect = { id -> updateState(state.reduce(LibraryAction.BookSelectionToggled(id))) }, - onClearSelection = { updateState(state.reduce(LibraryAction.SelectionCleared)) }, - onRemoveSelected = ::removeSelectedBooks, - onShowBookInfo = { - bookInfoInitiallyEditing = false - bookInfoDialogFor = it - }, - onEditBook = { - bookInfoInitiallyEditing = true - bookInfoDialogFor = it - }, - onCreateShelf = { - createShelfBookIds = emptySet() - createShelfClearsSelection = false - showCreateShelfDialog = true - }, - onCreateShelfWithBooks = { name, bookIds -> createShelfWithBooks(name, bookIds) }, - onCreateSmartShelf = { showCreateSmartShelfDialog = true }, - onRenameShelf = { shelfToRename = it }, - onDeleteShelf = { shelfToDelete = it }, - onRemoveFolder = { folderToRemove = it }, - onTagSelectedBooks = { showTagSelectionDialog = true }, - onAddSelectedBooksToShelf = { - addToShelfBookIds = state.selectedBookIds - addToShelfClearsSelection = true - }, - onAddBooksToShelf = { bookIds -> - addToShelfBookIds = bookIds - addToShelfClearsSelection = false - }, - onManageShelfBooks = { shelfToManageBooks = it }, - onSyncFolderMetadata = { syncFolderMetadata() }, - onScanFolders = { scanSyncedFolders() }, - onTogglePinned = { book -> updateState(state.reduce(AppAction.LibraryPinToggled(book.id))) }, - onSaveOriginalFile = ::saveDesktopOriginalFile - ) - - SharedAppTab.CATALOGS -> { - if (featurePolicy.opdsCatalogs) { - 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()) }, - coverContent = { entry, modifier -> - DesktopOpdsCoverImage( - entry = entry, - catalog = opdsState.currentCatalog, - modifier = modifier - ) - } - ) - } else { - Box(Modifier.fillMaxSize()) - } - } - - SharedAppTab.CUSTOM_FONTS -> SharedCustomFontsScreen( - fonts = customFonts, - appFontPreference = state.appFontPreference, - onAppFontPreferenceChange = { preference -> - updateState(state.reduce(AppAction.AppFontPreferenceChanged(preference))) - }, - onImportFont = { importCustomFont(chooseFontFile()) }, - onDeleteFont = ::deleteCustomFont, - googleFontsAvailable = featurePolicy.googleFontsDownload, - getGoogleFonts = { customFontStore.loadGoogleFontsList() }, - onDownloadGoogleFont = ::downloadGoogleFont, - fontFamilyForPreview = { font -> font.toDesktopPreviewFontFamily() } - ) - - SharedAppTab.FEEDBACK -> SharedHelpFeedbackScreen( - onOpenGitHubIssues = { openExternalUrl(EpistemeIssuesUrl) }, - onEmailSupport = { - val subject = desktopFeedbackSubject(desktopBuildProfile).urlEncode() - openExternalUrl("mailto:$EpistemeSupportEmail?subject=$subject") - } - ) - - SharedAppTab.SUPPORT -> SharedSupportProjectScreen( - onOpenGitHubSponsors = { openExternalUrl(EpistemeGitHubSponsorsUrl) }, - onOpenPatreon = { openExternalUrl(EpistemePatreonUrl) } - ) - - SharedAppTab.ABOUT -> SharedAboutScreen( - versionName = desktopAppVersionName(), - buildLabel = desktopBuildProfile.buildLabel, - onOpenSource = if (featurePolicy.projectLinks) { - { openExternalUrl(EpistemeSourceUrl) } - } else { - null - }, - onOpenIssues = if (featurePolicy.projectLinks) { - { openExternalUrl(EpistemeIssuesUrl) } - } else { - null - }, - onOpenPrivacyPolicy = if (featurePolicy.projectLinks) { - { openExternalUrl(desktopBuildProfile.legalLinks.privacyPolicyUrl) } - } else { - null - }, - onOpenTerms = if (featurePolicy.projectLinks) { - { openExternalUrl(desktopBuildProfile.legalLinks.termsUrl) } - } else { - null - }, - onOpenLicenses = if (featurePolicy.projectLinks) { - { openExternalUrl(desktopBuildProfile.legalLinks.licensesUrl) } - } else { - null - } - ) - - SharedAppTab.READER -> Box(Modifier.fillMaxSize()) - } - } - DesktopDropImportOverlay(dropImportState) - } - - readerWindows.forEach { readerWindow -> - key(readerWindow.id, readerWindow.surfaceResetId) { - val restoredReaderWindowState = savedReaderWindowState - val windowState = rememberWindowState( - placement = restoredReaderWindowState?.toReaderWindowPlacement() ?: WindowPlacement.Floating, - position = WindowPosition(Alignment.Center), - size = restoredReaderWindowState?.toWindowSize(DesktopReaderWindowDefaultSize) - ?: DesktopReaderWindowDefaultSize - ) - Window( - onCloseRequest = { - logDesktopReaderClose( - "window_on_close_request windowId=${readerWindow.id.logPreview(80)} " + - "bookId=${readerWindow.bookId.logPreview(80)} fullscreen=${readerWindow.fullscreen}" - ) - if (!readerWindow.fullscreen) { - saveReaderWindowStateSnapshot(DesktopWindowStateSnapshot.fromWindowState(windowState)) - } - closeReaderWindow(readerWindow.id) - }, - title = desktopString("desktop_label_pair_format", "%1\$s - %2\$s", readerWindow.title, readerWindowDefaults.title), - state = windowState, - icon = painterResource(readerWindowDefaults.iconResourcePath) - ) { - val readerAwtWindow = this.window - val latestReaderWindowForDispose by rememberUpdatedState(readerWindow) - DisposableEffect(readerWindow.id) { - onDispose { - logDesktopReaderClose( - "window_dispose_effect windowId=${latestReaderWindowForDispose.id.logPreview(80)} " + - "bookId=${latestReaderWindowForDispose.bookId.logPreview(80)} " + - "content=${latestReaderWindowForDispose.readerCloseContentLabel()}" - ) - latestReaderWindowForDispose.closeReaderResources() - } - } - DesktopWindowStatePersistenceEffect( - windowState = windowState, - store = readerWindowStateStore, - enabled = !readerWindow.fullscreen, - transformSnapshot = { it.toPersistableReaderWindowSnapshot() }, - onSnapshotSaved = { savedReaderWindowState = it } - ) - DisposableEffect(readerAwtWindow, readerWindowDefaults.minimumSize) { - readerAwtWindow.minimumSize = readerWindowDefaults.minimumSize - onDispose {} - } - LaunchedEffect(readerWindow.focusRequestId) { - EventQueue.invokeLater { - val awtWindow = readerAwtWindow as? java.awt.Window ?: return@invokeLater - if (awtWindow is java.awt.Frame && awtWindow.extendedState and java.awt.Frame.ICONIFIED != 0) { - awtWindow.extendedState = awtWindow.extendedState and java.awt.Frame.ICONIFIED.inv() - } - awtWindow.toFront() - awtWindow.requestFocus() - awtWindow.requestFocusInWindow() - } - } - EpistemeDesktopWindowDecorationEffect( - window = readerAwtWindow, - hideDecoration = readerWindow.fullscreen && windowState.placement != WindowPlacement.Fullscreen - ) - DesktopReaderFullscreenEffect( - window = readerAwtWindow, - enabled = readerWindow.fullscreen && windowState.placement != WindowPlacement.Fullscreen - ) - SharedAppTheme( - appThemeMode = state.appThemeMode, - appContrastOption = state.appContrastOption, - appTextDimFactorLight = state.appTextDimFactorLight, - appTextDimFactorDark = state.appTextDimFactorDark, - appSeedColor = state.appSeedColor, - appFontFamily = desktopAppFontFamily - ) { - EpistemeDesktopWindowChromeEffect( - window = readerAwtWindow, - captionColor = MaterialTheme.colorScheme.surfaceVariant, - textColor = MaterialTheme.colorScheme.onSurface, - borderColor = MaterialTheme.colorScheme.background - ) - SharedReaderModalOwnerWindowProvider(ownerWindow = readerAwtWindow) { - Box( - Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background) - ) { - when (val content = readerWindow.content) { - DesktopReaderWindowContent.Opening -> { - val openingBook = state.rawLibraryBooks.firstOrNull { it.id == readerWindow.bookId } - DesktopReaderOpeningScreen( - opening = readerWindow.opening, - readerSettings = openingBook?.let { resolvedDesktopReaderSettings(it, state.readerDefaultSettings) } - ) - } - - is DesktopReaderWindowContent.PasswordRequired -> { - DesktopReaderOpeningScreen( - opening = readerWindow.opening, - readerSettings = resolvedDesktopReaderSettings(content.book, state.readerDefaultSettings) - ) - DesktopPdfPasswordDialog( - title = content.book.displayName, - isError = content.attemptedPassword, - onDismiss = { closeReaderWindow(readerWindow.id) }, - onConfirm = { enteredPassword -> - openReader( - book = content.book, - password = enteredPassword, - force = true, - returnTabOverride = readerWindow.opening.returnTab - ) - } - ) - } - - is DesktopReaderWindowContent.Pdf -> { - val activePdfBook = state.rawLibraryBooks.firstOrNull { it.id == content.book.id } - ?: content.book - val activePdfReflowBookId = desktopPdfReflowBookId(activePdfBook.id) - val activePdfHasReflowFile = state.rawLibraryBooks.any { book -> - book.id == activePdfReflowBookId && - book.path?.takeIf { it.isNotBlank() }?.let { File(it).isFile } == true - } - PdfReaderScreen( - document = content.document, - initialPageIndex = activePdfBook.lastPageIndex ?: 0, - initialViewport = activePdfBook.pdfReaderViewport, - initialReaderSettings = resolvedDesktopReaderSettings( - activePdfBook, - state.pdfReaderDefaultSettings - ), - onReturnToLibrary = null, - onFullscreenChange = { enabled -> - updateReaderWindow(readerWindow.id) { it.copy(fullscreen = enabled) } - }, - appThemeControls = appThemeControls, - onPageStateChange = { page, progress, viewport -> - updateBookReadingState( - bookId = content.book.id, - pageIndex = page, - progress = progress, - pdfViewport = viewport - ) - }, - onReaderSettingsChange = { settings -> - updateBookReaderSettings(content.book.id, settings) - }, - pdfHighlighterPalette = state.pdfHighlighterPalette, - onPdfHighlighterPaletteChange = { palette -> - updateState(state.reduce(AppAction.PdfHighlighterPaletteChanged(palette))) - }, - customReaderThemes = state.customReaderThemes, - onCustomReaderThemesChange = { themes -> - updateState(state.reduce(AppAction.CustomReaderThemesChanged(themes))) - }, - customTextureIds = readerCustomTextureIds, - onImportTexture = ::importDesktopReaderTexture, - onLocalSidecarsChanged = { - state.rawLibraryBooks.firstOrNull { it.id == content.book.id }?.let { book -> - syncBookSidecars(book) - markReaderCloudDirty( - bookId = book.id, - baseTimestamp = book.timestamp, - sidecarsDirty = true - ) - queueCloudBookMetadataSync( - book = book, - debounce = true, - dirtyBaseTimestamp = book.timestamp, - forceUploadAnnotations = true - ) - } - }, - aiByokSettings = effectiveAiSettings(), - aiAdapter = desktopAiAdapter, - ttsAdapter = desktopTtsAdapter, - ttsReplacementPreferences = state.readerTtsReplacementPreferences, - onTtsReplacementPreferencesChange = { preferences -> - updateState(state.reduce(AppAction.ReaderTtsReplacementPreferencesChanged(preferences))) - }, - summaryCacheStore = desktopSummaryCacheStore, - credits = state.credits, - showPaidCredits = desktopCloudTtsUsesCredits, - onAiByokSettingsChange = ::updateAiByokSettings, - featurePolicy = featurePolicy, - cloudTtsControlsAvailable = desktopCloudTtsControlsAvailable, - onReaderAiEntitlementRequired = { feature, text -> - desktopFeatureNoticeForReaderAi(feature, text)?.let { notice -> - showDesktopFeatureNotice(notice, readerWindowId = readerWindow.id) - true - } ?: false - }, - onCloudTtsEntitlementRequired = { - desktopFeatureNoticeForCloudTts()?.let { notice -> - showDesktopFeatureNotice(notice, readerWindowId = readerWindow.id) - true - } ?: false - }, - onPaidFeatureError = { errorMessage -> - desktopFeatureNoticeForError(errorMessage)?.let { - showDesktopFeatureNotice(it, readerWindowId = readerWindow.id) - } - }, - hasReflowFile = activePdfHasReflowFile, - isReflowingThisBook = activePdfBook.id in reflowingPdfBookIds, - onReflowAction = if (content.document.formatLabel == "PDF") { - { pageIndex -> requestPdfReflow(activePdfBook, content.document, pageIndex) } - } else { - null - } - ) - } - - is DesktopReaderWindowContent.Text -> { - var previousTextReaderMode by remember(readerWindow.id) { - mutableStateOf(content.session.reader.settings.readingMode) - } - LaunchedEffect(content.session.reader.settings.readingMode) { - val previousMode = previousTextReaderMode - val currentMode = content.session.reader.settings.readingMode - previousTextReaderMode = currentMode - if ( - shouldResetDesktopTextReaderWindowSurface( - previousMode = previousMode, - currentMode = currentMode, - usesNativeWebView = desktopEpubWebViewUsesNativeSwtBrowser() - ) - ) { - if (!readerWindow.fullscreen) { - saveReaderWindowStateSnapshot( - DesktopWindowStateSnapshot.fromWindowState(windowState) - ) - } - logReaderModeSwitch( - "window_surface_reset_request windowId=${readerWindow.id.logPreview()} " + - "bookId=${readerWindow.bookId.logPreview()} previousMode=$previousMode currentMode=$currentMode " + - "surfaceResetId=${readerWindow.surfaceResetId} fullscreen=${readerWindow.fullscreen} " + - "windowState=${windowState.size.width.value.formatLogFloat()}x" + - "${windowState.size.height.value.formatLogFloat()} placement=${windowState.placement}" - ) - updateReaderWindow(readerWindow.id) { currentWindow -> - currentWindow.copy( - surfaceResetId = currentWindow.surfaceResetId + 1, - focusRequestId = currentWindow.focusRequestId + 1 - ) - } - } - } - LaunchedEffect( - readerWindow.id, - content.session.reader.book.id, - content.session.reader.currentPage?.chapterIndex - ) { - clearReaderHubSummary(readerWindow.id) - clearReaderHubRecap(readerWindow.id) - } - DesktopReaderScreen( - session = content.session, - readerEngine = readerEngine, - onSessionChange = { updated -> - updateTextReaderWindow(readerWindow.id) { current -> - current.copy(session = updated) - } - updateBookReadingState( - bookId = content.book.id, - pageIndex = updated.reader.currentPageIndex, - progress = updated.reader.progress, - session = updated - ) - }, - onReturnToLibrary = null, - onFullscreenChange = { enabled -> - updateReaderWindow(readerWindow.id) { it.copy(fullscreen = enabled) } - }, - readerAwtWindow = readerAwtWindow, - toolbarPreferences = state.readerToolbarPreferences, - onToolbarPreferencesChange = { preferences -> - updateState(state.reduce(AppAction.ReaderToolbarPreferencesChanged(preferences))) - }, - appThemeControls = appThemeControls, - customReaderThemes = state.customReaderThemes, - onCustomReaderThemesChange = { themes -> - updateState(state.reduce(AppAction.CustomReaderThemesChanged(themes))) - }, - highlightPalette = state.readerHighlightPalette, - onHighlightPaletteChange = { palette -> - updateState(state.reduce(AppAction.ReaderHighlightPaletteChanged(palette))) - }, - ttsReplacementPreferences = state.readerTtsReplacementPreferences, - ttsReplacementBookId = content.book.id.ifBlank { content.session.reader.book.title }, - onTtsReplacementPreferencesChange = { preferences -> - updateState(state.reduce(AppAction.ReaderTtsReplacementPreferencesChanged(preferences))) - }, - onPickCustomFont = { - importCustomFont(chooseFontFile())?.path - }, - customFonts = customFonts, - readerExtrasState = content.extrasState, - aiByokSettings = effectiveAiSettings(), - externalLookupAvailable = featurePolicy.externalLookup, - cloudTtsControlsAvailable = desktopCloudTtsControlsAvailable, - onExternalLookup = ::openReaderExternalLookup, - onAiAction = { feature, text -> - runReaderAiAction(readerWindow.id, feature, text) - }, - onAiResultDismiss = { - updateTextReaderWindow(readerWindow.id) { current -> - current.copy( - dismissedReaderAiResultRequestId = current.readerAiResultRequestId, - extrasState = current.extrasState.copy(aiResult = ReaderAiResultState()) - ) - } - }, - onCloudTtsToggle = { text, locator -> toggleReaderCloudTts(readerWindow.id, text, locator) }, - onCloudTtsStart = { readScope, chunks -> - startReaderCloudTts(readerWindow.id, readScope, chunks) - }, - onCloudTtsPauseResume = { pauseResumeReaderCloudTts(readerWindow.id) }, - onCloudTtsStop = { stopReaderCloudTts(readerWindow.id) }, - onCloudTtsClearCache = { clearReaderCloudTtsCache(readerWindow.id) }, - onCloudTtsVoiceChange = { voiceId -> - updateAiByokSettings(effectiveAiSettings().copy(ttsSpeakerId = voiceId)) - }, - onOpenAiHub = { - updateTextReaderWindow(readerWindow.id) { current -> - current.copy(showAiHub = true) - } - }, - onDownloadReaderImage = ::downloadReaderImage, - readerTextureDataUri = DesktopReaderTextures::dataUriFor, - readerCustomTextureIds = readerCustomTextureIds, - onImportReaderTexture = ::importDesktopReaderTexture, - bottomChromeExtraContent = { - if (desktopCloudTtsControlsAvailable) { - val settings = effectiveAiSettings() - var isTtsOverlayCollapsed by remember(readerWindow.id) { mutableStateOf(false) } - val ttsControls = readerCloudTtsControlsModel(content.extrasState.cloudTts) - if (ttsControls.isVisible) { - SharedReaderTtsOverlayControls( - settings = settings, - cloudTts = content.extrasState.cloudTts, - credits = state.credits, - showCredits = desktopCloudTtsUsesCredits, - isCollapsed = isTtsOverlayCollapsed, - onCollapseChange = { isTtsOverlayCollapsed = it }, - onPauseResume = { pauseResumeReaderCloudTts(readerWindow.id) }, - onSkipPrevious = { skipReaderCloudTtsChunk(readerWindow.id, -1) }, - onSkipNext = { skipReaderCloudTtsChunk(readerWindow.id, 1) }, - onLocateCurrentChunk = { locateReaderCloudTtsChunk(readerWindow.id) }, - onClose = { stopReaderCloudTts(readerWindow.id) }, - modifier = Modifier.align(Alignment.CenterHorizontally).padding(top = 8.dp, bottom = 4.dp) - ) - } - } - }, - webViewRuntimeState = webViewRuntimeState, - webViewNetworkAccessEnabled = featurePolicy.networkAccess, - epubPaginationCache = desktopEpubPaginationCache, - epubPaginationCacheGeneration = epubPaginationCacheGeneration, - useDetachedChromeLayer = true, - useDetachedPanelLayer = true - ) - - if (content.showAiHub) { - DesktopAiHubSheet( - bookKey = readerHubBookKey(content), - bookTitle = content.session.reader.book.title.ifBlank { - desktopString("desktop_untitled", "Untitled") - }, - itemIndex = readerHubChapterIndex(content), - itemTitle = readerHubChapterTitle(content), - summaryCacheStore = desktopSummaryCacheStore, - summaryResult = content.summaryResult, - isSummaryLoading = content.isSummaryLoading, - recapResult = content.recapResult, - isRecapLoading = content.isRecapLoading, - recapProgressMessage = content.recapProgressMessage, - onGenerateSummary = { force -> - generateReaderHubSummary(readerWindow.id, force) - }, - onClearSummary = { clearReaderHubSummary(readerWindow.id) }, - onGenerateRecap = { generateReaderHubRecap(readerWindow.id) }, - onClearRecap = { clearReaderHubRecap(readerWindow.id) }, - onDismiss = { - updateTextReaderWindow(readerWindow.id) { current -> - current.copy(showAiHub = false) - } - }, - credits = state.credits, - showCredits = desktopCloudTtsUsesCredits - ) - } - } - } - desktopFeatureNoticeState - ?.takeIf { it.placement.rendersInReaderWindow(readerWindow.id) } - ?.let { noticeState -> - DesktopFeatureNoticeDialog( - notice = noticeState.notice, - onDismiss = ::dismissDesktopFeatureNotice, - onConfirm = { confirmDesktopFeatureNotice(noticeState.notice) } - ) - } - } - } - } - } - } - } - - desktopFeatureNoticeState - ?.takeIf { it.placement.rendersInMainWindow() } - ?.let { noticeState -> - DesktopFeatureNoticeDialog( - notice = noticeState.notice, - onDismiss = ::dismissDesktopFeatureNotice, - onConfirm = { confirmDesktopFeatureNotice(noticeState.notice) } - ) - } - - if (showAiByokSettingsDialog && desktopAiKeySettingsAvailable) { - DesktopAiByokSettingsDialog( - settings = aiByokSettings, - secureStorageAvailable = aiByokStore.isSecureStorageAvailable, - onSettingsChange = ::updateAiByokSettings, - onDismiss = { showAiByokSettingsDialog = false } - ) - } - - if (showDesktopAppThemeSettingsDialog) { - SharedAppThemeSettingsDialog( - appThemeMode = state.appThemeMode, - appContrastOption = state.appContrastOption, - appTextDimFactorLight = state.appTextDimFactorLight, - appTextDimFactorDark = state.appTextDimFactorDark, - appSeedColor = state.appSeedColor, - customAppThemes = state.customAppThemes, - onThemeModeChanged = { mode -> updateState(state.reduce(AppAction.AppThemeChanged(mode))) }, - onContrastOptionChanged = { option -> updateState(state.reduce(AppAction.AppContrastChanged(option))) }, - onTextDimFactorLightChanged = { factor -> updateState(state.reduce(AppAction.AppTextDimFactorLightChanged(factor))) }, - onTextDimFactorDarkChanged = { factor -> updateState(state.reduce(AppAction.AppTextDimFactorDarkChanged(factor))) }, - onSeedColorChanged = { color -> updateState(state.reduce(AppAction.AppSeedColorChanged(color))) }, - onCustomThemeAdded = { theme -> updateState(state.reduce(AppAction.CustomAppThemeAdded(theme))) }, - onCustomThemeDeleted = { themeId -> updateState(state.reduce(AppAction.CustomAppThemeDeleted(themeId))) }, - onDismiss = { showDesktopAppThemeSettingsDialog = false } - ) - } - - if (showDesktopLanguageDialog) { - DesktopLanguageDialog( - selectedLanguageTag = desktopLanguageTag, - onLanguageSelected = { languageTag -> - desktopLanguageTag = languageTag - desktopLanguageSettingsStore.save(DesktopLanguageSettings(languageTag)) - }, - onDismiss = { showDesktopLanguageDialog = false } - ) - } - - if (showClearBookCacheDialog) { - SharedConfirmDialog( - title = readerString("options_clear_book_cache", "Clear book cache"), - body = readerString( - "desktop_clear_book_cache_desc", - "Delete generated desktop book and EPUB pagination cache files? They will be recreated the next time books are opened." - ), - confirmLabel = readerString("action_clear", "Clear"), - onDismiss = { showClearBookCacheDialog = false }, - onConfirm = { - clearDesktopBookCache() - showClearBookCacheDialog = false - } - ) - } - - if (showCreateShelfDialog) { - SharedTextInputDialog( - title = readerString("create_new_shelf", "Create shelf"), - label = readerString("shelf_name_hint", "Shelf name"), - initialValue = "", - confirmLabel = readerString("action_create", "Create"), - onDismiss = { - showCreateShelfDialog = false - createShelfBookIds = emptySet() - createShelfClearsSelection = false - }, - onConfirm = { name -> - if (createShelfBookIds.isEmpty()) { - createShelf(name) - } else { - createShelfWithBooks(name, createShelfBookIds, clearSelection = createShelfClearsSelection) - } - showCreateShelfDialog = false - createShelfBookIds = emptySet() - createShelfClearsSelection = false - } - ) - } - - if (showCreateSmartShelfDialog) { - SmartShelfDialog( - onDismiss = { showCreateSmartShelfDialog = false }, - onConfirm = { name, definition -> - createSmartShelf(name, definition) - showCreateSmartShelfDialog = false - } - ) - } - - shelfToRename?.let { shelf -> - SharedTextInputDialog( - title = readerString("dialog_rename_shelf", "Rename shelf"), - label = readerString("shelf_name_hint", "Shelf name"), - initialValue = shelf.name, - confirmLabel = readerString("action_rename", "Rename"), - onDismiss = { shelfToRename = null }, - onConfirm = { name -> - renameShelf(shelf, name) - shelfToRename = null - } - ) - } - - shelfToDelete?.let { shelf -> - SharedConfirmDialog( - title = readerString("menu_delete_shelf", "Delete shelf"), - body = readerString("desktop_delete_shelf_desc", "Delete \"%1\$s\"? Books stay in your library.", shelf.name), - confirmLabel = readerString("action_delete", "Delete"), - onDismiss = { shelfToDelete = null }, - onConfirm = { - deleteShelf(shelf) - shelfToDelete = null - } - ) - } - - folderToRemove?.let { folder -> - SharedConfirmDialog( - title = readerString("menu_remove_folder", "Remove folder"), - body = desktopQuantityString( - "desktop_remove_folder_desc_with_book_count", - folder.bookCount, - "Remove \"%1\$s\" and its %2\$d book from the app? Files on disk will not be deleted.", - "Remove \"%1\$s\" and its %2\$d books from the app? Files on disk will not be deleted.", - folder.name, - folder.bookCount - ), - confirmLabel = readerString("action_remove", "Remove"), - onDismiss = { folderToRemove = null }, - onConfirm = { - removeFolder(folder) - folderToRemove = null - } - ) - } - - if (addToShelfBookIds.isNotEmpty()) { - SharedAddToShelfDialog( - shelves = state.shelves.filter { it.type == ShelfType.MANUAL && it.id != "unshelved" }, - onDismiss = { - addToShelfBookIds = emptySet() - addToShelfClearsSelection = false - }, - onCreateShelf = { - createShelfBookIds = addToShelfBookIds - createShelfClearsSelection = addToShelfClearsSelection - addToShelfBookIds = emptySet() - addToShelfClearsSelection = false - showCreateShelfDialog = true - }, - onShelvesSelected = { shelfIds -> - addBooksToShelves(addToShelfBookIds, shelfIds, clearSelection = addToShelfClearsSelection) - addToShelfBookIds = emptySet() - addToShelfClearsSelection = false - } - ) - } - - shelfToManageBooks?.let { shelf -> - SharedManageShelfBooksDialog( - shelf = shelf, - books = state.rawLibraryBooks, - onDismiss = { shelfToManageBooks = null }, - onSave = { bookIds -> - replaceShelfBooks(shelf, bookIds) - shelfToManageBooks = null - } - ) - } - - if (showTagSelectionDialog) { - SharedTextInputDialog( - title = readerString("desktop_tag_selected_books", "Tag selected books"), - label = readerString("desktop_tag_name", "Tag name"), - initialValue = state.allTags.firstOrNull()?.name.orEmpty(), - confirmLabel = readerString("action_apply", "Apply"), - onDismiss = { showTagSelectionDialog = false }, - onConfirm = { name -> - tagSelectedBooks(name) - showTagSelectionDialog = false - } - ) - } - - bookInfoDialogFor?.let { book -> - val canEditEmbeddedMetadata = book.type == FileType.EPUB && - book.path?.let { File(it).isFile && File(it).canWrite() } == true - val canRenameDisplayName = book.type != FileType.EPUB - SharedBookInfoDialog( - book = book, - knownTags = state.allTags, - initiallyEditing = bookInfoInitiallyEditing && (canEditEmbeddedMetadata || canRenameDisplayName), - canEditEmbeddedMetadata = canEditEmbeddedMetadata, - canRenameDisplayName = canRenameDisplayName, - canRestoreEmbeddedMetadata = canEditEmbeddedMetadata, - onDismiss = { - bookInfoInitiallyEditing = false - bookInfoDialogFor = null - }, - onSave = { updated -> - updateBookMetadata(updated) - bookInfoInitiallyEditing = false - bookInfoDialogFor = null - }, - onRestore = { restored -> - updateBookMetadata(restored) - bookInfoInitiallyEditing = false - bookInfoDialogFor = null - } - ) - } - } - } -} - -@Composable -private fun DesktopFeatureNoticeDialog( - notice: DesktopFeatureNotice, - onDismiss: () -> Unit, - onConfirm: () -> Unit -) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(readerString(notice.titleKey, notice.titleFallback)) }, - text = { Text(readerString(notice.messageKey, notice.messageFallback)) }, - confirmButton = { - TextButton(onClick = onConfirm) { - Text(readerString(notice.confirmKey, notice.confirmFallback)) - } - }, - dismissButton = if (notice.action != null) { - { - TextButton(onClick = onDismiss) { - Text(readerString("action_not_now", "Not now")) - } - } - } else { - null - } - ) -} - -private fun desktopSignInRequiredNotice( - messageKey: String, - messageFallback: String -): DesktopFeatureNotice { - return DesktopFeatureNotice( - titleKey = "sign_in_required", - titleFallback = "Sign in required", - messageKey = messageKey, - messageFallback = messageFallback, - confirmKey = "drawer_sign_in", - confirmFallback = "Sign in", - action = DesktopFeatureNoticeAction.SIGN_IN - ) -} - -private fun desktopOutOfCreditsNotice( - messageKey: String, - messageFallback: String -): DesktopFeatureNotice { - return DesktopFeatureNotice( - titleKey = "dialog_out_of_credits_title", - titleFallback = "Out of credits", - messageKey = messageKey, - messageFallback = messageFallback, - confirmKey = "desktop_view_pro_and_credits", - confirmFallback = "View account & credits", - action = DesktopFeatureNoticeAction.OPEN_PRO - ) -} - -private fun desktopProRequiredNotice( - messageKey: String, - messageFallback: String -): DesktopFeatureNotice { - return DesktopFeatureNotice( - titleKey = "desktop_pro_required", - titleFallback = "Pro required", - messageKey = messageKey, - messageFallback = messageFallback, - confirmKey = "desktop_view_pro_and_credits", - confirmFallback = "View account & credits", - action = DesktopFeatureNoticeAction.OPEN_PRO - ) -} - -private fun desktopFeatureUnavailableNotice( - messageKey: String, - messageFallback: String -): DesktopFeatureNotice { - return DesktopFeatureNotice( - titleKey = "desktop_feature_unavailable", - titleFallback = "Feature unavailable", - messageKey = messageKey, - messageFallback = messageFallback - ) -} - -private fun desktopFeatureNoticeForError(errorMessage: String?): DesktopFeatureNotice? { - val message = errorMessage?.trim().orEmpty() - if (message.isBlank()) return null - return when { - message.contains("INSUFFICIENT_CREDITS", ignoreCase = true) || - message.contains("Out of credits", ignoreCase = true) || - message.contains("HTTP 402", ignoreCase = true) || - message.contains("status code 402", ignoreCase = true) || - message.contains("SUMMARY_LIMIT", ignoreCase = true) || - (message.contains("free summar", ignoreCase = true) && message.contains("limit", ignoreCase = true)) || - message.contains("needs credits", ignoreCase = true) || - message.contains("This action needs credits", ignoreCase = true) -> - desktopOutOfCreditsNotice( - messageKey = "desktop_out_of_credits_generic_feature_desc", - messageFallback = "Using this feature needs credits on desktop. Pro and credits can only be purchased from the Android app." - ) - - message.contains("Sign in", ignoreCase = true) || - message.contains("HTTP 401", ignoreCase = true) || - message.contains("status code 401", ignoreCase = true) || - message.contains("Authentication required", ignoreCase = true) -> - desktopSignInRequiredNotice( - messageKey = "desktop_sign_in_required_generic_feature_desc", - messageFallback = "Sign in with Google to use this feature on desktop." - ) - - message.contains("requires Pro", ignoreCase = true) || - message.contains("REQUIRES_PRO", ignoreCase = true) -> - desktopProRequiredNotice( - messageKey = "desktop_pro_required_generic_feature_desc", - messageFallback = "This feature requires Pro. Pro can only be purchased from the Android app, then desktop will use the upgraded account after sign-in." - ) - - else -> null - } -} - -private fun desktopReaderWordCount(text: String): Int { - return text.trim().split(Regex("\\s+")).count { it.isNotBlank() } -} - -private fun ReaderImageReference.desktopImageBytes(): ByteArray { - val trimmedSource = source.trim() - if (trimmedSource.startsWith("data:", ignoreCase = true)) { - val commaIndex = trimmedSource.indexOf(',') - require(commaIndex > 0 && trimmedSource.substring(0, commaIndex).contains(";base64", ignoreCase = true)) { - "This image data could not be decoded." - } - return Base64.getMimeDecoder().decode(trimmedSource.substring(commaIndex + 1)) - } - - val file = runCatching { - if (trimmedSource.startsWith("file:", ignoreCase = true)) { - File(URI(trimmedSource)) - } else { - File(trimmedSource) - } - }.getOrElse { - File(trimmedSource) - } - require(file.isFile) { "Could not find the source image file." } - return file.readBytes() -} diff --git a/desktopApp/src/desktopMain/resources/episteme.ico b/desktopApp/src/desktopMain/resources/episteme.ico deleted file mode 100644 index a78642963de5468491cb60f36b2c7a6e059d303f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15057 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 G!v6sBn7|MK diff --git a/desktopApp/src/desktopMain/resources/episteme_icon.png b/desktopApp/src/desktopMain/resources/episteme_icon.png deleted file mode 100644 index 2063689d6e4f026d569c850d8079c54310c46f9b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12890 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 x4J{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{# diff --git a/desktopApp/src/desktopMain/resources/textures/ep_naturalblack.webp b/desktopApp/src/desktopMain/resources/textures/ep_naturalblack.webp deleted file mode 100644 index eeda652ce9327033e73771a1b74017666b73f112..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 19270 zcmV(vK7yea*=`M>`E&-WF*pUm#5|J%R6;Q!S03-kYv z9@@YG-e)?msPzEh0-zU^SABopKkoYme|Z0K*9*~T6r>2s^1)~j&QUoD+d^937*3rF zy2%66so5<~Z z?)vD{Kx*I5f03kRpwGyk+aoZ2-H#qaDgS+fPXKbiCgA%@pzwf>ny!|jU9Mxh3SuUU zT;^oySpkn%0kp2e%_Tevp$a84s2yfS_YZ%WpFHw>GhmVA{%EQ3dtX{DRT+$Csm>>A zz(rh5FQja^cA^bveVAhGj&Ax8ZT5CNBM(w9Bc`EP@0;uo}Y_RH?U>h#jR3&-f(6-rU&eG^`6h&@n?r3uS%*r4{ZR(jwE+MIx(mAE$gZRjnZ)3)uK&Va7oI|+~ zb2AK&vBWk6zIAX9>WXW(?u}rDS1u}zp!}R{%W2zdZQDg3r;rwj?Me!B8hE&gv7zQK zsFZFhp0#>TRUUXE#8RN-HKu4Nr@!6uPPHgtV1u!v1ZbZgLXw!iNodA6&Z=FujRa`Ip76Vl!AM{~=LsMh@ht_E6@> z72nFXSPretxX@)XVGQL~8C9+rnEjM8EFwQGnBTZ)Oo|~B*k^Oi#GCrT$BW{BW#+0! zh&grtHjVYe0^wWNlZE_0qLN9KtPnWpFWhCgglTr@oszr!GWBT@BK|UUj_<)L2m@6C z=U*hqO^)&kUiX;h^OhvD9S?A7gC4&17rWmv3w$mo&nOZGnl zU&D}M0uURDQ{s6@y^J&tu^mw(OcD4v5-G{eczr@AB=__RHwHEmoO^|F$-<-ipNxSa zah2e|nk}DfHpuozz$m=u7Q8qPu8}d3Rol%rYp%v z1+VFloF5-*0TE>fM&mIU7ViakWC@q2*FGEiIilZlW}}mB{C)f*YFI03XyZURM3H2K zg)P^$@$A1*h!g;pXT1R`fts&Y*kxo601apIA^pD*+)3%~_p+0%zh)$HFq2Xv9jx9u;B=1X9e`HcY%2E+WQn1YKxm5Y z1r@9W2sA%qOO;0Wkc!@xK&Hc^3;>Fo^Z3QXa#PvfpVm!b7TM8F1`G}}78+*Wfueui zrPWFp`+u#qr1F2FWyqzRby)6AHd&IbfQFZ`UBx+Dl5Qt<=r*Mpc{F>nFeU}9N&{SN z#^%#s|4rb|+l#(XtkX-21o|Bng6brjH4nn? z^rfUxAXQBz^Ta+*Cr*Gw9U;1szv?LhBmUo~JNTo1ICcw78g?&m_fKev0!s<&C7$Pn zTP^LO&NRD-^eQ8gcD_Of)l=n^M=q|Y~7qL ztf_a;S`^`Im>HsO!H^t<`DcB%)}W`Ov0P8phR7{NK&ACa)o}GZj9c*=kP{{A;?^*{g*_5*%K^NZYjgqoRV&u3DY>7gZkAaSUE z$gh&ieNDY6l|Ch=!0FA-z*4s_DEYt-5{B*J-vo~NGgf}v!eO{g^vBa|rPm@)8_%0t zC%AX|ZxsiQ04Dww6UE!40lV&n%3wYH`Pu~^%i0hD(_B$K#c=vp&zEAb0eE<|FVJL3 z7-;-6`-WHWY&;UQfHWN_O~fpi1N9c9%EJt;URX|z-cj*=xS8>EjWNQr4J?WoyTtU1 z78G~MzD|tlfVck>alF;C?=Aa@c%~$&0HQ_w{-~oZxlF zm<%Q`%(Q|&>FqaTw5Cf;-hyIlOXduNa^50|?Hc%^-K2i>fIp-gWqCnLhX?0P^mt$R z$ZLN;x5k6LVL#IP%rh^XEuphM$EZ3v&u817;;mLx3K298VwEXU@itXlgOa5p&?(8F zi6R@=$fa@R+*?GoZgfhRY&3)khnd$=4e4#AGs%CZaqM@h+<<;&Pb)kc=`a4kz)w10 zlP?_K2!%G1U}vrB_J`A+zX2-a``ZMFQZf{hh*H(x%Y+o{Pjn}dEG)Rb&yGRF@VoRo zJ&R`PGNVU5Amvd+m=rXfZV}Uq&x%?kemZcSC4;U!PGI18yWoeGT;!DmZdYN2CUyV7 zuS{JKkf6?-yDxkjkK3O&hR%~xO}u;+lBYUBq$m9WeI@>Huf$-2td^;)_+d<<)bUV_ zYk#x7e&dY-{qgRjZ!wU3<5$GE05xx_^>_i(SZQc?rp9&0MsT%5{63(8)eXJV_V~vW zk(xO))#MCUExHfb*Q<0GEgF-ch7KzikYPL%>TZH+X`s36VLkm62SBlm*=`Nn74wDN zM0emvZ40k5?7x>M{%Pz?>%f;F%8udGxO+_v1E=d`v>llr|0pKw*tB%Ijyt@0+|kR` zi+eT+?hY>8?BRe1?atmD5j^zHe4fMX&Q*Fh-)z92yyNVL1{&;Si3i(mrqaqPIyKz{ zBkLS$2hXkhR@&t{ml$`!>aPTN%Xalp0ehyhotlV#3&u}|9Sj*zWhwQ&`rd=JXIv5R z^ZJ&W2!%+>28+?(qDj8wnp)2#Yp8nakUvNLMnVmMx-QIgX>(i4<`;1DyRVwk^5lBJ zz8RipjM?CEm%G@$t`cB>H%U+a8Rj3!-m(xs5mj^~&C(7D2%AVX%A5*1(Tne4vYjEH z=sY-eUzTMMLY}BFT%|k;+Zv#h#%j9YNqJZj_@B%tLum1dMZNB>#GNKzm~GKR*%iH_ z3X~V$<5v}>#cpisDtP9OnD{c`c8j}ulleKOJ4@IW%G44TIwTmo@+Qg3HGuc(v9Ra7 z`Ql9R@bLi5g?7sJfhna@^QA(=BB}E_wou$~i$l;OQV9&b%cDB0Q4 zYD0h!gGALTtm(eXox#F=400b~A80B1mutMIjJ%aDJ0y+Q-{gvI&|VxY;ti>eH$z>6 zdwj*(vtzqcBhUIb5^YN&bhb!l}TO>IjvVz5Y#M zg}~?hB?5PRF^AqGE-eBMiucrkC9Fu!yErCjh7Xs$$c7G}atNo!SP366pahtB4V*d_ z%+7}V(44^l=)wdjG|%X*Qto~v7Y~rb|B>geW67SdzRNSh*{3luJ-`rOoH6G$g9lV3 z4v(o$Rrw&{G^lIIj_s*7`QHMg6AY4w(27WNJq2k>+D-CN0z6%~ER*e7OdsJAV%rk? zHWM-wbVi6g{geR$8-K=gp7&qxC*bDcHLK4!p|{&jzofQSOb4yqyd<4Rl28ig8{?)LO=Yt)fFs#*){tfF4%~uu5_`Q??Yq7XS83JTv@P9)VUbT~ z;Hi#g!|LEo9#-BVOZnzw(g?}~)b$U01X-s^rhBGvKH;jueBB{Po{jE&5LS`L|7%Aa2Mh7_5y+DlCh-&chbZdJH#!V}F`#hh%@#W_VEx;)v%xn?R0355(n{>>QsKGx56~9NX4ajg z1_??4lAMJ_b?m}~@%q2xKN#$E?p7oGxSpjV(L`c9b~FI<=h9<(KGwi)J}Q1+HBo98 zuI!T4J#4!1_3dRI0c?O%LwaBQm>N|8x41-MyEy$0DI0$_BgS=r*FcKUGa?56RZGW| z^GYdp>J#Q%;~ggq)o1$Cw*BPy0OH|w(OBnVsik}m0YPn{n%lo;-kVp3!LoTx9x-Yn z75i(YJPWJi+Jl46VQVtu)`JOX$VOc=i@w=mXsZ1dI7)7v1nJ2X26$ll03{CCCnM)A z;>d#+K#3@kW?75`&4+c*>|JdcRkI4vINJKMl%82sHBPf*jnAv$!~l+)fWCMW3*q3< z8PCWXp;T&|?B9;3*2jfW4aPHbJLbw35`cbe&c@k7QY1k^xve)MNTX#__7U#%4pVdbYP6(p>OX6wtd5YIQy z4L|g76+77m7R2tR^U+f4S{;oov3_6F$DBA;lCy^FPRctggb;=(c4GJ9HaF{EVANT~ zR@@{Hu@h)u(mrKzpN{U8^6(%Yjy8Vmxwnv+VFW&IaBDZ!i<~hBVV+%O8bS(8|0t)o zZ;SRjdfx3@3OmC=v0sIJ5o%WC$KVIljPgQ1E2vT$?sPO!6u5*4krSGEs z$s_H@p<5;?9BD|Z8kYGIwduPB^=^I<-%EIrWTH(&WW>`|P)pbVBFy4DJ@WM3Sl;gE zr8`)f?6BNr3HX?U{V*Gz732zh7R~|Xuy&!3y-yNzMzIx6h@bl z?!EXyn2>?;@GGLySA}A{qVe3cyzozugG+Tip#?j`4v6>#oE}e=6vQ>}alc07Z0`Kq zW|cy8>8*6JqL1x>O>V*sth-3Akk3tj$V)Bl@GG@xT_LBB_EV5l7XY$}8))6-0w1p51Y z3{{!h4yDyKD%gSqoB)nqEym^i+Is-x;_u(4 zJ&Bj+GFV12T_hjab6yKzxJnb)KbUL#Xt0zUL4&*T`JKkg8VjTBeBByy(_>wUm+;Yq?*3#LA z9SO2=c0fN;Li`-aygLeV=ur$Qq+HalxNWz%bImhm4)R8J(%E|J1IJM}IsSBL^RL`o z)o~)fZQpPNM|^!{Z>dW6sl{pQ(9)8FYbavFS(NG6py9GAV%P440;N*DTZt=1LKQ7O z*29VX!#81gFJiH>Tcz5mwUM2%_1EE$If|04G9*|9)6NgK zl%;|rluN6=LbmEGLcn`!aAI8MSWKLls8j&}Ok#d|fWPyD1igR@*F%go>yV znPCKoz(N`EV?vL69!&3^k~%4OAB)YHP}cXsp~86JMf3hPGYtlT?Hl=~PKtip1929C zUH|Oi*7099I$k*o%M1;d9n*&1k_Kn2Wdha zn2%gV+g@k`Jf@=qfICdSB*`Y+8K9{HF-i&ISb>(MqH#uS>weyxHBz{x3D;_aK1(4k zH!-%hJQ+*ReAbaPrbZyO<(o^_J`IHsL$GY{#%@}0VGX$ln(#<7PFYCqjnPY3fz5J( zZ~FFKU-wAbVU8!+$Gk6aGf|gz9#RpziT8{YuB0tc>OmRb{l#m%<)yMt^^2{qq z^WJVzu6c3njBp$zgjQXu;>RBQ0s{oNT6`zCW(<{aGDe)M8>77dWl;cj5Y%h=R^HE* zmC7i-a-D3>r;{tH!<4L!0Lu$Z=lo6pLaZdQ(E{k6JA7jzJj{TpEwt<^O)kQ)LbKJ9 zP&M?7JAFGQCzmGc+Ucnbwu!)xsvi_78*|}%on$Z5WW4r;?h7M*$FmB7o2h@ z@aRY+Q`^`|@kbMG-)X?L(bPkVg|E zs_^m22LN(BwgPg~rN_XoK5X+qaKkwW?4bxUC9!}Ra(VFpHNulo-ZI*^5?B5ych9+V z!ryS1LlEAHiJnsx<9Cs(6zy(;o&ZK7tlcH*BKh;KCI7TTpi~&IV-CQa?=7qHJ(IA4 zrRgFxUBOAkwq_Gse6GR^FeYDc_$3wD7`^`)W8|QR%kgAekhW1MB77BU>Y;z8sRi#@ zVEOJDVR^>+?By9Vu(K0P_yFS1DhqtTBj_@K%cL{eCT_K$+KME_N0vqJ>mS-Pf5Jpe zvUqnY_y+z@??&e7xU<-5s?rx}yeSD%#9$e`%bI9+ygHdyR`_M0!RS-`Hx_9Max7Qy zjWeY8qv$sQ@T~&gVF{#8BW3T7&Z4%lX;}`|{^4-`gL28b5(EJ2kz+iA$R5^)Z6x_EPrscO1xE`5KMPY zkZC698JIMTZ_jCd#GIJ;DU=v9dv=>O7TM{sbDoOI7*GRMG!SnHGs!gi-jFpWWRC22 zmlN>Z-$@CWkJDUwBG(8)>w3ioCYhPEBlQdr=7x+rJ++15$j{mviKmzexD{iBjKFH? zsyS>*lZba7|9kmx5SJx`r_H&Evy{CcRvk!G6y#c8q8;TLlN)g@>+KX~Quu%Sa>bdn z;5&vmOafSl;hX1Z-)IH;fzyU(#B|*!8$O6N)LKP@i71Jmf69@IF}g3TmElX*t_3kw zRaaf~oFAz1m2vJar7H>B3D4Id{mD!@f>0GY{Cp+g8yuIZ>4Dj!GkjXnSlKF~8wLwZ zYH~4dx$2{hGTgy`ximmhU}UeIT(2 zG-ns$kB`l|j#@jp z=*_!@{@9lWBe@wlOkgp|WtWO2Nn=C=&B^=-DJnf2FbosrOns2Dd!%7^--B{RfzU44 z27KMnMWm0E(sY7t>vB-4*b4^py(;!$Dfb% zJD6LocG&BpE9y`ng&%dhC0QL~gC zV*pY{AHD?0wRu(8(-w+ah-hI$!=H5$ zjy*VZiy_4eVv=>(sYDRAe_vD@_)YiOX%7CoDRI=_AiU z_S4b7%G}CGQd=A!+^%0&V0g%QKVcys!E)P$aD_jHrX;murWM30AS z0wRu3D4rG`dB3vIXL7E`UDK zDp5FI=6da#0|Rejdan{Yl2#taz&FQai?7cQ1>tN^`_9y#9>Zl!y5I4ySgzfY2U)Yi z&JGo)iVm9`f>w{*Qz^keOAz0m7JM0o5z`w=%&(bGodk9u+(XQV6RE?7T(oh0_iY!% zKe2Z{j2)QpGgezme0Ui`6^_i$=S}pOJkR~Bu?1(F=L&a}6U*Q{pilu49}xbSOKzvl z_9%QJ=tJar5!|1)by=23;-1=;Y3B92F3re3+1~KV7=pKB9kLaVCDC*zmD1o+zp3H< z5u2#DO$jHqTMEePt8kMZ*2V%-N_255fbRyALV~+?|7*W4aYO>t(#1=Bx2PvqR#^x< zR}j7`QZ!>>7v#jSRC29mW}+9|-Q&x~IZsk8COi*^GHbR+4hex1=mJK}3&M+nbj!QQHo?fN zUD3`lug|Hcio`sf=$!n4P2p074AsN^uvrB&{sGeFKl)&h4gwIAixh<#Gd5xhd9&Fn zKP=Ox*jMC~twA3XH5Zzr)G^72d%=gj-f6ow5?QAkjyas5=0J9_lb9(eVe&6r+DJQb z7{@V`k0SBHplFAX9{j1A#BI2R(aNaOFX3@ox^WW;FNiWou~zs`CnX@(s8a^Xf0i*v z9g?O?zPsqzj6>;Nli@g6BK~xG-lvz8e1K)6E`P}U3o?!O4DHE2-8%pa>V=2j@Ms=! z3>$OWZ^;LBTyfeKpys=4{Wqlaz&WPLa~6(pM3E^geS{h)=JY!Wf56?%5gMy=^}Jfc z!GMDJEi| zcFb;2oZ;$!=(49R<4b1@Z5N=x zH6foVf+n^K^nzDN=e$QfVmj8nx$6w#H?1A;ZhS8o(#Zyn*s-C6sLzkn+0^6c{Yfij z@vT1Y6C$?{UxN=gjwLRTV7Mv4RI!==8O{ab-InuxjTju(DrYXqjDjnnI8NM4w6LXZ zwZYNEJOZ2bpS@rPc$+E#u`*PE2T1bMKxH*C2s!wm03p)xp90cW?BBk2hdIWzxhPwL zN-~NKy9bkiJU^{jp_Lp$lx%H7`u+&@!=lltN@d5HITK@=%n20<05YQ5u?G+Q6Ro*5 zl-sE+gK0W6gG^F4XU9uG;+vcxIR`|KZF+`?#mVqF*_N+K?M3MPcKG}$BZ17bI81}q za6pNUM7%SfM^Jio_5W=NDQCEf_(ZsH_MwF4CO@i|!%{{OPi$34>J^%*KJ0knx3MTi zHPl;F*xCuQTwq4=EN=8{UwpdmTZn*HH8Ri*v=EXm+1*m7H<S}B|qMr!{pvIli28qhT?vvp$VB;M2KKMPLB!6)DJBJAS6ux}FN*j(cTr@C@3HZRX zd7TyxuvKH3HcDLQFAA?uGK7!$(sl)o2?JK}+A=Ns@4bY3GZMA%XM)_2(?W~JZEAN& zZem`;uhX-*^+sT$qMM4Kdzk(uhG`=k=FDceTPSA9^!-iV2Nt;{SL`mBF|sBO7^~s* zvC+iX6_pjsj>wh8$*77Kw08Yk-<>odg>-AKt3p;h#hoAvMb%5D&LX-JvxK^S2t7{F z(F>sHBr|cAEDZih1S;i*wR)s}two14LN-Y9rGR|3&3&ifw>)3~0tFkkZLy(Q1WKoJ z(^>i7TN(J;%LzN{Hl2@Fm%)#gnpMrGUaTqIOUzxacw>`;b0ur2vLJ9%;rET59o7fa zwB!%XcU4U;$(L#Tb3zUOzOF!|!kwsq^L+&@*CaWTm-*ifD)LBv^`s0`MORf{uhy?p zDeyw>k!~9^(j9ad6km3?_Fl(Q7&RS!+GXUCzbK@{&8bZso{>uPe4Ri|(#EH;&i=F@ zv7?4wHP*1?Uut-}?XkS3c8ldln=m!_r^WAatIuXlZ{*&uYq-^vpc}jZSR_{CvX5-< zpUtbF%*3iDl5HG>s0CO*E9yG`A*8ne8v4hYy_gNHD9`Wa4`tb8-x<|Lw1=ZiQH1?U z;hjm;j@`;cIZKjQbNJzgbl&(HKxEz-wqM!R%u#Xt zzRx#9FcmZ7DQJ&gmc2P?T*9_g<<|!9funebLsKUCnsdF;e1Yzzxi^+-@Kx+$+M_Zu zQ{zR?Kgv}|^I!89KKLA!9o3J?swL~-DH}x0;TS8Hx>%5s1!FghU7XV}FJj}?xL%2CTWuNE0z1O%B($*3t-Tzb8 zQ0)e?5^mrKUyr{1y_?G_Q%vOR?A9CrGl=FpzI&Wq?Ph%VrL>>vC**rOD8*`DjB?-< zm9Cwb0uY@zDRhyB_7ScCpOsryylTRtxZgAXAIRU{N!#%1`(UrU5$5AEe=m8iz8$S_ zs;g~=E4L$9aBFib`Q*cC$&PJJaYTLG-*Zc7itTUrTuQqHIw(hvVE^^}=}0?sMxq?GaNFoqzVa<05r)?Hi8+QV;bs&XQLSqY39u#b>m!#{|@6 zO8qS~NyCW+reZ#BlWI46dE(L>-&gACOqoetgmZ3emYAXNpr(QGgy;`UMGe(8`lmA8 zzn@c}CH9N;HW$u`7Wq~J+||8K$e`3G(KRb!K8BFl8hWJFlBg8JaifaDwfh@PfP^sH zn71JGdDg&*G~HBC1li=U(8O!W>a9B8u)@P{K)QQVbwAiTQCzl^0|oR=e#gDs65?T( z%H*CbS((w31hJ!pLcUgHW4@twEF8n*!Tp%*gL<|><@FS$bh93bn(d;iN*U9i2fTLzn z9vfr!W1_~VJ!0%lXpyD=wZHy4+;Kq*Axvh5rVD3q#G1u@N4ncr3$pamTXxnwSDZ9nu2?$&2PFVjmora^P#2h{? z*Vn`}&Z|%aB0$I2+t@N$s?w%d=t{Or(V`5iDiitOL#38Q{jeh{mFh8=+6=v64zNsc zZOlYU3ci|Nz7c4!C^^9Bp64cch-O|G5-%h0&z2N;mI5<`w*Ij5BB?M;z z)xydZGdAc<)VAN<1F|p$^%}}qY!m+M5To_*4C3Y=tBb#JSun17s7M)WmHGW9IoSy6 z%AtV=KwbPTY!e>2p|5Vu2r25e>**}*>%;g z^Y-%DkI4&P)~Y)S2AM!KU@AI|YZ+i!Ziny*jSFw`LZm2%ILxL$fL;F>V^Ia2s;J>f zAMHV3A((o5J0LAeZGBfjz{_7#h&iEipqT8s1lS0#=ojwR^)8^qIT5^?)NYFpOr;Q6 zP6hnht7kHs-5QQzPC8%sVFn3VItd(c#}nL-nB(GLua`9F?*{Av%W9gOYYDg)s485ir z3VvZCGx1|G5iGii@qW#eOU9f2se-Zdu9pVIWUWb$BlH|G*VIhsZfJlQ?07b6+6pkv zjREp^jn&EpYisVT|Gxsoq^c=QqIx5 z)}W;ERd-C&3`p*=OJvsZ2+{y6{V7v;E$;}Qem97GV#ptUZKL1TEpyQHUk@cAXk^EP zE6;CPDOHrXQe{ARXjU7qyD})>1#YChq#b3&((4uWJ{Qzs=$d1GMCi44{YW}h5(b#n zga5xVu0wkLlS7UPJ72l7{iW>d2NhoYGS1cHR2ulu^p+w{HakjZrL^b@q3A!Yp!-X5 zZltGoMJP4Bhy=wBPIY>>$WDM&&%bwyiRtd=4%*sEyAyduEzOIVPzJV`s(~iRV;p?} zqj@FMaSFp)Zu*h@y3CVFvropDCt+)0gkm}PaV-6MXa|JIv%R2{N)8qJYOx?s3Iy0XIvIV?&c_0vx6)(1YQAy)? zvsrhxZ>n&bS0xr&qYnuKgJCoW%754%7P!LohtoT&kkS5}vVddUFpy8-@CHmCy4ExJ z56@+jD&qRAPTsX@^1_Q_)KRA6peAL^%^oGjw8I{Qssi`cUx9IECu~*G;Xc^tEBiPZf(QR-dfRP=)&t*&k`l+l+s0GlA%rYm4d9!4 zRG>^G$$ZK%RK!yD2`^uz@f)z>REAtP4Z%X%MO=64o{|x5JVR}+MG8y6gXP6(3`YQ_ zX?6EnP)StiMi8;61P(*RyvFD`moQjJSG^fZ`{!mRHn2ffI#2l*%Ds7$@N3oQ^Fy#`RIPh-`6vFBOip87@j zdf#6Cwg5NBveF2Z9f<=X^GnlyjYyV5KdJ?ex_Y@}H zr=o(vd&NRX{Q?BTo1O^#i1c(s0O`Lh=;B=PoJy!ngssa!O!+uI*pm}x2K*$q^sSmY zx^dBLu@_V7v03Km{t5%i`XAPg_MDY`?MH^D=*#%28613uqwd+*1*g+(@(D@IiEqmr z-LUPGMP~shVvL+z^qycj&$0L@TB?8hT5B9?D_0AJUXgWbvl6`A5cBp7gb8a(G-6;u zvUh>KRn*XC{>*yF>Ed^A$6@}|>YJIVpX=e`clhJ}OZ62@!NOWvpa&ANlQ*jdOz#)u zGF8?m-?=_#J8PJa96i%54%-DL{_YQ`lYBN0D#I{8jmoBJ&xhBRg9Ap#ihY?(*> z(V{axbUHsJso1_p#;?hqi|qz?^#yZ9SIP5_L|Y|3%bpm4Wsz#sEj#^elUmGZ`G-vJEVM>|;%@rVt4+Wd+8 zlh8qx%YwkxRp`8EbJy7HnM5m25uzVfHI{%%`F-@x7$C*O0Tf3yJ(pM=q9vENzJXMu zds3zX4)iSITLY=E5z9W%(>j+^4|<4`{TEZ`&9!M0vJM=muXBtgha==uw{XX(Kp@SF zJG$$AjovZ6F(X2 zLl_ZpeS#q*Oke!h4E_>$mv%3?(1N5?Fgj8=Y$9%pEhF)xfQLdaDbbzNY%zGSol|_Q zj)3g$K#TUC@dj-vN7!rPqRck3&A*nFOL}vwVzwq9^rXY8I~W3E(|C^E1|YkF8LXG& z+HC#4{Ji=e+J#fRw~3AmzwXcTvn8WD4pR5!D}{R1Adqz~knJxyEKSNO%i%C%R)UN& z+gp0v3xra((*#^D6Vq5!TAeO`Cv`XZ(zPmq4ka|~)|@{xwEXBEO3t2gdOL?(06ad( ztHo-uiV8B5eO-BK#deBO98Fx*h|}FUrt55qqMDY6UQzSN7+OD-@3pEw$eMJo@FE?& z%&I;d|A=kR@UQAZVCgPcNBmmY7$Q_bz6%2e1{eSQ5*b1UtO^*Yhs%7s+idbv49Lhs zItsE%Y4DL8%E*X=25(ke#`i_@T40>nSzHj78?-EBRyiqtUFU+${L@BtvM&k2y;Pyt zM)okpy%W+wrM;|-oFU&L5dmmm_DtLKS*fxlAotzOHeqR{xN4{gmPCi9ETlnDb@FHoZG!kaVk~& zA7f`+d|0qVqOnwnWa&* zubKYdb!t=QY`a7Wd`UTc!Q>xJ#^~s+z5rNE!B}03*jIjMw&V7 z@!6ffV%6F`gmf_Mkuy8Y2*o>=1Hi1eXxq)zR(_%!HQ2slG+d(2!Zxt)g-`Gy%F?=8B?(uZdj|oHH_K& zMGO>=s**W8f)4C>S8r?xQ&V1eh$iwi`)MkgH-`m1ZZ2546Y)1+O zUh{4Xd=PW!(7>xPuA9#bY#)HWId7Imn~N|rSFs5rWjqdgL=IV4Z8E3v!Xx^%)tLCF zWzO*V2pL{;e+gmN-=f{<4MXQ&wbRF7Ga`&*yBVbq;K)d!9(+8l(H|kzJk5%=;tRg% zDwB$woaXZ+aag%XI?=Iu$k?R7pO(0;Kqzlf6%BbpH@l~37%gKQNliM}PT^L;zn+YZ zzFkydftYPVEW)78wp`zGK$#;jVRPy$)}e!oXP|)4FkX2+s?S(3Y)B&nEx>U|2&=cb z^sw{}Sef*hF0V$Y<=X2E9djlE^hE<>o6DasGsfCAvZE`fmU zVU7DBS=ITxnAgx)vjQ+DT{*(;qnXsT<#~p>t+3|+ zXrJBE#pFcHnqMnIf+wwf)B(_LP7nMwNb&)M#%ZQUN8#s*I`i-uCa2UFDsUNSMdzu> zK)N*w?ZD8TSriDBLzs&QpkNj8HL6-Pl0Rm+GgXY@hR9Zh7;E0yE~tVUPP{}7-a(o1 zi5s8~b5;*DDuxT1-HYX}ls%PJ{4#wTCtV6cHC{)WZ`AL-CSKxv%ew2p0`maQ_U(8y&Q5C}6Kr!%Ke&e$XLXXg(N-KUEYDy4 zMe%JkbH)z*{c}F#P)A0yufhmSaes$JPK?_i6FEVqJeQ?Kb0X<`Jh;b+sF>FwjN5VU zyPEgFOB=h5*H&kdg}dFmtHe5FRq`2t&O)KJT5L;O$wI<2A1FVFvz2jKeOy#sXGFG2?Nm}Sq ztw)Lm>qt&+9kmi@V7q1ruUaajhTMDT#Y5?YMatbZZ&v>*z{qi?-LW{uhlf{D{1Q6P zAGMV21gKLBH}r`>dEKI3uACav;g6#X+O9VZ{~i46lY1I8x~0fH(}y%wRgmft);;7H53UE`u3mTOw!ZQt z;6?BvJa<_UaKVI6VCzg4fQmXXy1dA2dALNE8M}n4wCqLn(0I>dtD`(+B-C#T}ml& z8mNDzm9;KH%ncedzp|xthS84}Pdv%F0>GjFnVB>xDbfiRSL#Ox%|&k< zH%PMCi?1YQ;T<2*>na`joPNIqL#3ocnebquWIY7>INkf2W+~o0DkGOVICv>Uu*N(> zfn5eDEwuhY@`K)Sw8hxYKs>3^LB1e>JBwJ+c36Jgw-*qxelQcl_f^SwHO7zYoPL&V zkc?2zP7uq$Y@AB|mf32h;Lp6GNfnfzAmpVGNh3l20Wk~C^zpH_f}pkY{O@_=^r)W{ zUGdt6<~7iqi6ghw1T%WOKA2Sya$Oe{&Z-;@PwhkpgXjgQowMhi%=v&=2VA*jUc!n^ zDtThEB|S{+g~s*SZujyt)8-!B9@(&-8@Vx@EO(wkssG zB&gu7(ki^^@@2&mr~?P zDl+^##?5*%X%Y z+1HP=8KQJzytsYJnVT^)zC=4ZG!*(x#jWAg>iGuUpX3^PwtBgNvgI5Fh@^(T=^0XL z6~!ZPSnS3>{sVN%OK}mdtCFc|Od{S_AG9}7 zo$IT=dYSPi?g@kYcJ*knlR3Qr_Mu0$)(!x@^)NrUhp(goC3}@-QKgfl0c;fUW|O`V zAT|y-tdKYm)YRq8D&dVjD_GFx?U#}$g_l>hZkFNzq}{u71{qx* zAFp5Cd_e|=8i()#$6b*XhVbFPVnnu~vr)_iEvfoX;SFto6qjC_I*05F+cBqH>X$8+ z!+?Vy;o0hE_d~jK6NAN{^S8g@7_tY~MpfTmzk5jGOlc3sUi8kLefFe+dgc4z_!8O$ z=O#-PT_wGknS|$dHua@(+7z2^kD!l5t%TR%B>TeCpKg7;PhjWhS>>n|klZGsBib|f z@`erRwl1)@NvhgcMoDlGj956laF)xD%R5WO>z3mh{6>ks@=`a4aacsq; zCQV$B8BxnOrwFqc^}cY)r1b#XLQxzE1U4fXQAlU-AFzaXd4eMO(@>nIx~*+hqU}uh zX@b_ULWcTFm8F^E)hArmym7eIgqOrX1a6={r0(h(vS+alr;hHE8+n>BNuQc6A@nunN}2nvb1hz5Ef?KF@~ScA zzS>y-IppyO{2!Gua(T^^9z!(8!C{1*Zg9OqBq_@DTRahtJUu6}P1NO+z%PxHW6F;A z8WNK7z-h~wKYX@I^wq)=(z5uwvzza8LBG6dU*j^+7vfYqMR-txDR07ggYr_OL(qC%e z4io6FAP}dg%sAp;%DWU-;Nsz=YTA(swnoad%%e4NaY>muRvN8lgS^zW83gpQPm7Sg z$UzM6SwE<(r|+LaaH9CYU6GHF?Q>af-L?AKv&&(ezVJarz&7+dWEpN?Wp6JwIna8+xw%TU;0u%XhYc6 z3bZav=`L`z#*i{(AK8V2jM$#8eQQPLwp36e?nhj2?X-FyJS5P~thMmXE_ed(b2DqR zcNHK$fcl9JfsHmT4hf^1gwUOLCJTA;J;Z`E_|L|8-l*or@!Qd#T;k8!AvT%x6$2iA z!dmuaD#&Lu3?)>0h`KjJrRG(B>8!#~Jctr?+aIoY;!KjYRaS_twa6{cM~KJvUoKxm zLYw(_1yM0`RUUs7&sWogB*>16L zkxHi*OdMs?!b!$>J=9+|uAChw`8)TMF1KB3k@F=t>@~no#35!m-pKJg42=Uzz^IpOUc=WTReDl z2(JkndBQ4?yHKPi{C-X8Xcv51%`z*cI)n##NhN+kglj}~Ae+)zb)eAE?Z7=lyyj(L zeOJb?h77cf({t(E&r>O^=sMBAXsHkcq{1FcWO^H&5zNA;@3Z5U zPi2r8z-iu0s6Y6IWDMW8IS=DlqGl{w4$MG!g~rRCt5=`nn^jG&A%`1pR~h$f+>R)K z4#J7;AjiK~Pf{hb$a;Kr)-7k(5@T1yoiL9X;d>+`57)n@I< z%|C9~D8uj`ocWdI$y^Ehb|?000%Fs}BGG diff --git a/desktopApp/src/desktopMain/resources/textures/ep_naturalwhite.webp b/desktopApp/src/desktopMain/resources/textures/ep_naturalwhite.webp deleted file mode 100644 index 050f115dfc6e9f5454bf3194c869097ba1100380..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15394 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πrtz_XRejDELRE-a)?zPT&J;-8eWA^ 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 diff --git a/desktopApp/src/desktopMain/resources/textures/texture_eink.webp b/desktopApp/src/desktopMain/resources/textures/texture_eink.webp deleted file mode 100644 index 050f115dfc6e9f5454bf3194c869097ba1100380..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15394 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πrtz_XRejDELRE-a)?zPT&J;-8eWA^ zeW7Jnej!xPX;PuV0rZ-01abQ4mxg~jqo^ij|Ifl=ZI{`TdhWuLLiS|oW;c^4DHDTa z2%eNqN$FqS{hEhAVaZJ1G$f{~`hB-${M^$!mElAI{YWl3#@iMV}kr3LMpdGeO=W z`KULR7#lL;JN#w_h%l~zBN z+vjve8nle*OPD`vabkmA0BTcE+^K}PmQT~|LKyfKOZPDD;lH;q5&U4m^0#JWb0zUy zb}Cf6Xjug_B*f8T6tNcajk#Q0=(;$`D>mSSER8N#_+W&6@7!eVvq0O`bmsya?K4|e z_sh#*VDOa#=MAQn*rZDjJ!$ImLdP* zF|8f9D^FQ);>-uD84$@(x~vLx>w9kSQQr>zjF2JWk1dg(AA?jJ#7ZN%het&``$u%s zzgPUfpiWfWz6aaGYU9zIQ$ao%Obw>$ZF>@@A*1%Y{(ehU@n}q1?swEto87q2((N~S z+)+_<*M5Y>`VF_sQ_J~KN56st@93oVS1?(F@K?dr)9VpR7cTB9fk2m(;t*A1OgsHl z&z`LtQ9Xbi`Aq#N5=-kPuur|E23zcyX?hh@PNNb5 zs@B2=GiWe9G8aH_h$8MJiY8`VsJED6mX48+6xTfsu<>OmIGvAa%WDz`0Lz_{kk>T}_{QfIbG1lDXr(i~TTS z3q9~-GPwbHf!{n9Ucc`UGCTPo zug&#>XO`ha-oVXTUxV6fF}hrW#M<@jU+86FeHhX87$>Um{r+7g?xjZJWvbjvUs+V_k}CwpNm+Lr|68F z>c$VX{Hl;?q%Oe;jeOK&N`Tp37q5gCZ>!r z@D8%JRsxQ;*%y^`De!uS$Ah!(Ewa?EOdG0kAEH_&fvv>yj7UV-pzl{zI78Z-4EFlW zi{ma8m>!qcZ!q#Gk7M|%g&4L6m!y<2;_X5M<#if3p6u(`T_7U|g6hK=83ZA@;ayla z4C(u6t)*i0ecAC&TGL2q4wIZQa!#M2u7I2Qli&EPP~ozr+aGoK0TPE#M`Uq(6Va{! zZg~tP91e;RjrpUtqrZ$SoB1HUZzLm0gr35Obc*&2O0P)VPZ=a&*5| zow|{hHQ%rgZPAUe_fKudcZPdMFd^W3q}KNGbJy>IdIv>PW~Qm?ux7%G0YV04Vn|R3w9E+iqG2hq4UX}@eUfZmS9gp7nwA?4oS9wSz?=Tqab#kVONy^nZ5ph$BZ0J zio}D8uxDJjDNmUTI|^{mMy z;LuOJSqat}KU`wFlF93+2XOp}o_>wKA5DdwcH6WIp*uBbzucKzE|P z$P;wt3|F!crccoHsjh7ES!948sQBuBmC5uWFTN{s*=X2n;5ubNao_V+!7F_+4Wn18 zvw%bE$CGRBzps#!Zjh1Y8&hn)nnE}aBAPv*oW1Er7KVHs2A%8SCGBgt=8>Ul%(~!A2x{*)TEP@^6n(p|A zaokm8YL&TC3elVbM{7S_E}KC_cD>b*Yjw$KV^xn%Oe%AWSS)Zt5ta6E zJZ`d$Bav&2ZSf?D9Ir)U(X^_`B|_{1W?4(vr13=x7(|kRdEE9K?W_VZ-t;N`ZH7Bs z=PcSR+Zs-`3^p@ZhkT^r2N&Nf86dOp!v9kH&BiL}s?4UaU_QVeDxu;K0gCXM5N=&T zlomsS=%?c3^$Nc;cLYRVK3>*eAFD`uLT=tNy;D5c1iHZ*(ugg>S0|0MvQuK%zVt+H zPX~H*#z`guz^m*_+RSapx6G3?yguo8p16*do>0XD?ONDL>C3zT_B)>c^d?nmnM~bh zXru)jPIBX2yuI8~^&4=M<^_%H`$!lmvoKB-!beoJg-hcSg`sRl&uda8>=~8dT{ykN zoWG@DidXjph|3PsvSZl3$*uPi9k%CyrNrfSlau1UYqwzYmuJXmZ({h3qg0I!9;rB@ zLx$dw63a#iMYv9m7(;jWUvcxx>iExDZ%J0Rnx_c}ze z8g$Tps(+eIu-M?5$59%6#g}>($>V*nmVB)|L@pzWH#Kinw+VcQOe9RCA#h-BS?ucv z#V=S=+m6tuI9L4F5{xcJVpMwMif$V@BxqNMMaf~i&PPUeEbZ8WBhzt)fpHP?)@hb)McH%~1> z4mQMU_O}e|TN_#sKADF3JWEsN@L}nH)k=<@0Q>Pgm%Gq>1?Y5%KOoaw@JVZ$KKx#y z;;#P@XU}`NV*WXc&3tBQK^Bu>(Bgm?uy?;{vIQ%Cm+`PaJ5X+2&)mq`1o5$jpraX@l%*3p5$j1--C z1O25EeXyeHPrL~nYs{r2gB$eoGswc=ijvq|moBbWPmdWBI^N~%PjkP%T9i&+l?a=z zw@!mNo-)0z;=>g>(9yMqq8*2_ey~~~?4r_DqOru0V`gD1gt`&187%s6*cPt`{pEZ> zIW_iR0CkY1nCwhMLQSRA{E=iM$|i(U=G_^5S(G14ZML|_|8|X|m0A*o!g<_~*<#(Q zcE(yv(g$l~=gPQ5%uJ9M2(&d5Zf?M^eUY$uu9){%ArAP(Eu&~4>pzaaOom()gWm@D zE*@j6K4=Ct1AiWQl?-H~LRJM~Ixf9@7+PA9Fj7FJUl^K|LAgX(dO7Cir7!F2eLWcy zDWoQpvjg6tl_o**!}*|uzT^J2yqO?j6vG_L;&%haR$80fFb5+-r0Y{taqLY~?Y>R6 z^i^&)@^_=iUNeVwUz_iFZn}+X4%njXMGp=tj%D-#u``m)KXdDA!>~CEiyw@inoEI}jYo1*IuC%=1P80(4=vU7h24VzR5G5r0W{D_UrH)X>K5cfIK!PdT+q zmgk_@;g6D&bpw9C&fn|T#GQeEO3~j0a2^YqlpNi6x3{=s_P!QvMQ%djh^pl%Z=C*W zdTRo(ZS*@Ch-$v8L2W~J6cH9fm6m8pJ!a9 zn3urq&OxsfE!SAvJCrm|M14ONsnrx`5;|OinywiJJKU< zEUtI$$;krHpMpAJy6Oa)?kHag>kL)exuW5H|6z4}MEqR6i@t&~sQvvYxR7-*nOafP z68R`?8aSp#c1V%aj;LHe{!=hQ3{RZq=jswgI?>kwqm(|!Q?nmuom=ovU2;%xhK|4asA~DDs2m9Qf zL|*TbZRFqskQ4DvJOEI6bY2)Q9BQVxc`Bn2KU7v%=YuBYY$W73)#OV~OraLoTR zI%;syk0ax+NBm|h&{5TB9J-Mr@TT1(uzF8YC~TtOAl!M?bKg^~+FN11S&x(|P96Nu zJ{8IT8<0-p085cc;e;#SN;f7j5gHX7UQt#evh;zRawKVN=t3Yt;pM2&YjQT3A(=O9 zs|y|JRET&0yWxcH-oo_Y9Dr(jUpHwqXUgTo&~fRNEU=C0md1QKe74hYNEO0_h8--K zSydqKY@bl@i`_0l^RAYAL4l?b80T$1?%h@bgKH+O>^MYTs+74if3}hF3pysS=!Efs zoXUHfyOE}pFh1nV_>_NhNAI z(Z@U=ja&iBr|UkoOQui|n&wFk4aW+8y&2B&&&zVKq%GKR+@OAH@0egUFV)=eJqx^d zC*ie$Kw0I5`KtZzBX|yxA8`GsUoa_Pv&-l^WCB_vE+|~CBJ83C0Y{V9kmNVUci=+p z!C-7p;=KWFbtn<&2f73Rv0d{E*)mSvv{m>KCZmfLH0g%F>W-F@W4CfG2vLHf+dL$I zEitN)`n_YY1rQ?hmGxXm^4|5tcVAMhe`-9`*J_p9Yi?HvTK1WuTpeOKU;e%4f8ceA-#xYI%jF~0JO85nnWH$26tUWlP?9l*{sBPAUc~F zAoh0=&t@JWX+4SpFfzz3c_7QxR7#zdUrbKv2H7YD_O{f@j+n9oo*Njero-ej40n4h zjSyFd5zCvb3_llA>m+PjhQ zN>EWb<0RK;0s(=u>o~hVQ}|Vzm^H+^J%uFBBWWN|o1&v4nwoHBRqYA%mS5Y zPJp&R!zL_)F3^Kv3z2$EKJ|ZqZw&ha;X)47=AkExoyd>YvF8e34RVlJOQK7J)@gHc z35CujM7f6j+-Rws#_u3fZgv5oz|vF@zW3g`zBQ4u%U-qcv5$~(0qQs2m9X82Mfi;e zV~b9~ain;Ta9^nA3iXPdx>tdZL#dE;ESJS%`WCwXMJ|=JE=d7oTeOrS#~v<{8h%DI zoGdJ;Qv17QM5vp@My69GQ|`Zkh#p~%v#%k-IsyUNCHqa8OSX$cow1Fq{DTC2qYVcP zK72$-7`xlN)_bGhl%%e6g<7%P(vV+2cnCjlQ)Z`KKI)%I8U>nwXeIl$t7nt2sV_=j;0 z@rc662+q8gSo0)xv!-GWuIhiop4E1tF+`vy(0E+}C|S@kvfJkYcrdht5z;8eu(N|95W^)y!hQUUbDL)vJn7+_JsfYxEqQOJM=0Af4eFfud)Ga6fqtD( z!uY+SyXMosG)PY6hGKZwX?HoY4}OOGYNOMMF}u)lj!UFY=xGs?mKXFClE1kWrabiq zvQ)wiBsw|w1X5#riS609x!n6DhLZ_B_F$*FR+_u;u^EKqa*xZi+tU!~>(`i$j~?o| z5`|-iy9#ycT1r|{PqsLU!*=;ICn;^152PK5C%g9+V5$_4@^-OLfsGQfe~xxGVIG4 z!ZNv-6JNfpm54M#5)Pu)a0%Rb9s6nQ1XH-6Pe(&;Fv@ODWkE~Vezl!?;`VRHuQJC~ z*u#1B&g2>Lk?iz<(oqyczt3XKfZ`37bUiFpLN5De%xxwwV6bN!f0(mnE~Lt9^1e5G z4n!569X>5dm!?6eC;#u@&B}90jj3*+N$wh>!nnOwi6gewrpG#GNsO2IiMLNhioT|u zD9X7$aJF=wIF#gq~?y8aR!-6Ff-)@L-=Bm0RVEz9^{lU$}9J7y<_dH(0$HA z39#SlslZu{F*dB7DdcqUw@1G#J@bj9N((0^NwNFzYto=dy|+Z$h{{NZt)oSA`FngA zgvT$zTmLDh!;VR%k7hdMbOK9*w7_Po6Uoe#A-1ZP+O;NxZV4on(CKQ~7h5tOT7=eA zunMP5*=GTBJ?-;-t8rN``p;%=yp2{+tzf89V}4C|1f!{1c7%xaf?L666Pa`T87M(U_B*wy9%#csC=*7vsb_1{Aaue+)@Qezh-Uo?Ut}AL9aH@eXGJFT zAdWoLBHf8>F5E6?&7dF5aH)go2T!%)7?2 z5w=GodElv08=^>I1*Tv126>l&Mo2juJ_5F*C*!jlood3I;A2jGjd8T^YmLh9AJwO< z9IXIQO8>rYyL<2et2>)72hT)2Fq?IS>ps(&*EI0$OO{A*DYo!n^#@pnejmm(M~Z^O z7P?*|$lwpVkm{yNw~_roc=3~y)a!2){qxK!shWgO>+wnbnzRji@iO{0}S0Zrd+ z@d3mhM!`)E_C?7%q$EVA_Wxw4YtuDnwWq~B&svU8U=O0Qt5(`=FszCXgbHJ2E1FJ- zCAPEN7`R-gsm+a@q2zWY1-Ia%%Oy_zBI_q@2@4^QX1GUFwaRrU<_z<^BStU_%T2-U z`9X7+N+P~Q1gqEIABO5w@4}MBzHpn1Ki`jcYCyi@0e7-qXzb$O!JU$KF=&dWeEMR@ z#GqreyU`p6mE~1f&%Jl}!&6U-Y`JjztP8Z+y{cO~C&ftw*r`u)f228zw$Uhsmse<&aCV72sU7Y$2xNk1; zm|%)pIq{yXp-^HlL+s214D6o+<>)QvLZiTWUy#-MyMLu+%6!WzNx}!3e*_;zHST!m zWDhMtPRA_^CGx>Pf(&;JT2lbCS7{oQD_7rLH25nayR%t*`^JO*r~_!giL9jpUR!f- z3p*mFCM;@q_#N<^u>MrV0RNVK5P?o1y=PJLeShjJ03xnr*_-bC15KSFRI)t#yZkBw z)c#Bu9Gx_9z!1f0PxV#RM!saqR!D@T|9_L|w?i`=%+bvjGXWK)6@c;9tO;yx9v1Lz zBkDa_>8ieNM>V?l0cDjp@qrd)RDzHq_v1O_gXLp+duSd|^&ryY7d41h14PC)NOO{a zv3#~+Un|EyP~h-8_-?@C*UDS(y``*Ijpo2coS<8im1{0L(I>w3lxk7?-%cL%|DGA?`ca)k;fz_;uV?< zl`^&t8?=M;sN=6C(WSK5Va*i!TK$eTK-@T}k8V#A{Q42&G=@Oy0J>Li{)e`2Ih70Q zHYSnY=3|Bn^b9DwB2kk3{sRk4@FO98ai4K@#WjtdZqtOqHz=Z5;Gs1748pf2*3e8D zhXiyYUnQO{@l~R=xR~Lv+?6SjI?8w`gC3W4SoWqpyq%#8``( zxNxpibO(fM+R!53?(Xqovrc`>l8w|0w5M%p0Y^Vz<+mxcX?p!SV$Wr@G%+R4B$2U2ON0+kq@t50KxP!(mpeaS3Xl zJYMGJ9KMALzh>U&!nPrS_WA;1l5|Xi%{Q{jl|OA)LpWBEmu|PCZKY>~nSUkIL*d$E8Tu z3r2*c3M~MkQYwVReleu30)+QbRpPt9Ix*h!G#-7thmCYD3D=+2AALUdg=tCODu!6C zR`-{FyOxnd0u7; z`xKj<7Z;o$mO)g-==p`iu*GM5`eI528lc1eS9QsM7YLS)d)-P#uVRad1#C-?5^@Jx zj-Y!povOEOaoktWw0J?2op${Q|A8?6d$7Y({sY!va3x$I=^`Hk_ZlLm?SG_HDNa;Y zsaqx@og_SI)Gsul24)Yt3_#Wi8EnW)xXrcY$?BKD?~5AF{e*m{&DF^lD(ZU?x<~;j zl7(H3l67NwMgc)+h!CH?WPMkdM(VsX;8c~j#+YkD&E^bGt3hHq+h=~4tWayquivyG zl~;)FJvI&Y1HOjb(eyItyrG%n_k;N^N&n>|R6)=EdLkmO9TX<2Yugu4;D8XCD0i~) zxI|8a(c3~OB%k4q*e)?GuyDEudy8c<{c@&=>}D9GRj)-hcJw<$=1CQmqarkw$HXe= zV^9hD%TheeU zrb)^)Vqo{{?>bnLKa};gH5_IQ+&Nxtd-lXTlw`4ox=z(n%T`$m6A400%ba=Wv!;l7 zr-CsBsk%&NxN|9*M{H$BVDQ-`t z*w6V0=$2}Qk;B!Q>>X#|{vHD9LSb83FS{R?4cHbIC)U<}5uICvdnY*!@cCVw7JRA- zLX5s(9N)!K(=D~p?U05)tjT)6qBSH3{jAiO?ZdjWM6?TyI4kO8?zJEMhOv^vek%+x zLB>%MSV$+Ck<8j$_28(6$db8m77 zDf(oUnU@d1d(%Vf#vYczNw@A!5M!f$u@FJ7(c+&E{;{G!>U(#NA7(H%=uEpZ`1tc4 z$M*7jA=wSBMN+J0;i-l&=eDMttor?D85rk(lc2v>Y}>(RLpKXHF!cE2we~}D94fZ# zNT!9yO%U|(JdZ#)>St^aiB=oDV9HOcWFzLvnBI27^JAq_mD7|dnd0Jt^=mpl3p(qPXwHV9AsF$*q| z{sG1&w#P3t+~zkZ*5b_!QN$Ltk`BwEBg!*_#yKvqrr5- znkv{$bnEri7-WJ7kc=~sEyQ`;6;2F1mcAFay&CKX4wCZSPg=$gxEawC)md3tJ^g29Iwn#< zP8=2r3km=L09H~$L7TWeuD-fB|G&7n>NIBctStTWe^DAF=3rdA0|2m{CMhDQ;sMAa z4~R6x0C64=QM>ii$NjXrtx6-nvi1bziW27dE}?5sp=m+sq6%j}R22_Ah%v7fqI3St zU?|@-`Ym#UYZ^M%XBnF%(zw5)h>6KUt+h_vSe*hb?x<~Q(5SkL>RuJHBJgRhq9PU4 z6tNRbT7o4Wb6KD$Xol$(7N`{I-t1`q53u|E8<@N)+qVRef$Vx^{K=HI(+R3@I~34x zg)Q0u&V2Q+V{+;o$-zU9A5Ri^BQ^(&@DHS$qKw3r^tJyOOrhkVR2Hb$Eqz~55uYlLS zL!6X;S1yP`)Y$%Xp=b|W|IWxWP@kPERsr*;U!6lOoaP|Qa|hD|fswju zbE3fK5^5HkJ0a=HAYyuYa{r$fl>NHg5%#gXm5>)gRG!I+b z;!Y>yZcmWFvu6=Cc1_ZW&;EjhcNLeZ$EQvocG5Trr>TcB!k`jqe2vZ~vRs!M6%+$G zh2mXodlSrboTR?c%~JGpu8AU%pC}Y8n@TNTS(2=>_Wtc*RfSQ(cC(VN4oqQ#?$5IN zi;-;+d;bWLjWTyq@VF({8a9;%_Ve*`v6Wwk?1RraAjElsF#}_>yocwFQg+mV0;eKU z)TWp;_^&`Lz~yz+TEE$dlN(Z-qSQ4TXxKT{UC6Llga`3qx#V#{7{Xogml|oJ}04{^X53^|;bP7@N`o!Q#t@IZNN}?7fRs{MWBnCqY1G zgElE4>L<9t)`@y>rQL!G*SKVfX(P>x7c{1?r&_*$?($$SGCaq~MgP=J`~57H<@Zr> zWXD&(y9Y8>voy9yq@8CC`-BCO@2igJbM_?W*^RBmGdN$OYKl^noB{mH>acPb$|zN* zrCIH-UZ()+|$ZBn*=f`c{c&h`<$N7_w~91)7_31 zaPG?X5K)`Cz^##bHbZQFB`hMwPlPr1c@58YRhQkMKN378zaMK3oP)nHT+E6(cs^EV z$HqrneD{MdNwXJWakR&TM`R+9E zdO=)96;AL#=OXuk|6V?@%D^Qa^fX-0ysH^LzAcx$jR4a|eP-vSnVl5WYMr&M5Mmyl zDGxYk)&gAB3EV|i>e*M>S&zfV&8CDUJt@lZ6^=SK(DP^uwu*dq~bvJ|i@d8`!{aOXdSBC2S!C=?I!?e@+=sv;kV*ut&I7gYZ_gs>@SZeCU z6lTu>bV7q?%G!bq8dmeh(k?ao=dTJ*NWmN1P$>_@WN^yj)Z)szH%{DY46;ktFi{d& z(Mfv|p*BG#^hTdOuSde&zYX2^<;v^NrWDUKBE?3F%wQgNozW5A;$txZR*T~IE30F3c3$fOB*tMk_(P4LI+sG-61!qo2e&N28mQ}Eq~6McIVQ0ukG;Y9DRAJ*-p036Voti>EioxOR zUgT1}WFVZGF06OiOg~A`WGFvc#KOJT7yfSVKtyvT*R$`y3t-vIzx%Dv&% z@5(m1TnKOsu9go76*f0$AIUM)DtK@d{?JVZjnn1ZGCTS{9&MFHn2b3bOKN}iXHhR+ zR|)JSTI7H8Y4ND9%HPfdilY0M`+n(1?-RU zUAGc()D7HaE(LCrER)Ol8LzP4clwO>r%fT4^FsNHTO_yUKZwM$vN%y>zp+lVGWn(8 z(*z8#mTMTy=cr8NRL|?)SsDFmmS$CThu$jB*rtVx4hLy@1{{^DarDqDg68h|^1Fb< zMD~<_YUSb5il%vBSD_Cv?@iso^_S*B$qVaRNiCazSE|~dZMXv9Rkf-ts8wI$DV_z| zaXALf=>Sx$<9RvGavyseFeeUuT6)OGtDP<1Y>IDp$a&o39nO7AK(fa)UFIzXj z_N*c!Ow^S@6B%GYMt7-^kdyP(UZ>>u9VcibWGNg$BX%A~w1!NKyu5>h*pv`iOzNlcjmxd1+a!N^`kJ z&Tf|C7zRX1P@~IJtlrG6@GS{lH3Oyh%!^90ima;OHr2fm2 ziRjabas$&(UXC+8(FqFOS>A#NFe@sGFgGvkfCE?#bo|HD@Fh%(*#nmuU+I?n(V;AQ zmTQ%Xr%N2>$QoDrJvfH(0OT8IH0vF5P_8klOOwKDjPiotpH;QI>=ok@=2-5BrgGYI zjYs8D`nOmZ;wjEPtjxs(BLLsank&=RyRf_B0#M=%aP#Mp^RGSkN=wHbZdr1S=XJ}E z22B+qC&?C1GA`d8<9%Z8?VMUC%SeS7e|a#8MJsYW!fLKN?E--quilHA@08^(%Fysb zyipliiGWhnkC&X%rLqY*{@6Q9t24#RjK4&zViAaVJCh>Ub&~gop?Ug2vPs2Q`i9bD zRe#tO^10(d9w)DpiDp#tp>0$Q_KG}@!}?oEiHJ0%KqSY;*3F!<6vQExvXYI!N!>1U zy;l;^n!)M=Z})nLaZsf)#nq)CWS(MMOA_VT$3>ncHThRIvKA72m>o5@97ZJGLq;C_|HZ-_4 zl=uy5903CI*wYd(Y&bKMH%tIAd2ls~N!wu9LSeY>nN}zJ=uWi$yQvcBq?Y1rcRoUh z(aG4Gn9&M>L1dBz*%m~_D`w6c1CbW2ZEyhd(|`N#(|? zu@7TDDWM!U6_t7&VY?iO{nR#O^1Ri{wnSEs3^49_gsE!4sVw(&MCGAp-Q{-3_A>1c z=E6?8JnN4)@UficQC=e-yL-KceJDWzw!F@j)~+rT03o{UJXKv(raP+WN%mdZ5)WU& ztor6PL8~NhB+k!!z;e}#h=o;Ap%J(bL}z}dW7Q0Z=z!z{DdH@q7BiJ~Q3Cx7lrs2- zTFFa{$u~u7k>sP^Ds2(zpc~_0R=(ybx_@QadP;s2c|jEM259NIGQ6_vm@35dFT#^d zi?ypLhz6YDT_CK@wklk|hzcw(UV+NPq~|oV2E~;7fyZbp&{eiKwCuipBv~3wK!ukC z3e*PB=wBz}V6~?j{rVu?ZuqcjIEi2r@^e7wvIP7u+Heu47H9ggDIr<)v-DQV(UQ6el)vIPXm6pZ8Sm67J%m7g{(xJk zR1bE(nf+liY#d&ACZ%EesNB&+0MT7DU&oG<`~fs*YxBrRR)j!Q z$1kp-oyZ41rZk%2CL>TSzYH~-o*@$5!rC8)zIqgEsA*qPt zPVh6sF<<%Xcj(%`NnVf5gWE_`dDTroQyne_iV(@`>tza_ZE4~>c;v3#Ne=w0iz^S(Cf+IeIWf=wyq{H`1_NLL z0OG>av+2Y{elB?+-B#ryCvSA$J!q><$J0R(+C^!k*N+BljA|Yz9NQ64D^?euTIEB> z76zTfJ`*BC>$f{TX$`VL*kNFrIenYM=YRWW&LZ36&qH+&cPDCZ`qq)Q=Jce7&Fc}6 zvbH@D5&NTOV1m=URnTPye|$r{;KIXOwNQ0Xc1MP7W6D>VdX(cfwp0(tCRMTW*swff zN;tR;3GGPittl&)zqKsO*VnMl8#ort%17T7?S%5SoTMn|?L$B7^*ub1aaE6r-;jpX zbnixZd2r}Pj;K;u_x+7`PG}#LSH@&L3I*&5h@Xp-U_yp@>S&g~PC%6JKw@slS#6QU==M5Q#2Os?8kz~P8c2(2Lm-S z%)8?Y!{}dHU|K#Ke|OC8K%dKzneZkyv7r(gzp19%Vh&Gik;V(2!_vOQJ^dF(Yf8Bwnn_$ z9ywczCMlZjFt&_k@OQ^9{n%6A>G`{gi>n-yIIzh%vjxL5NA(p@6`}BNr$BIWX?U^F zp)lK*Q;v|TN+xT}5bvxatBP0BjGxw+GG`RR&XB-FPb+d~nh>&Fl{XNSGL?ULRYyO* zC$xl>19wYEa+j4OW0_IRsg&RBB=#xR=uUVuWWfaY^T?1IE7*31DpFqzuw3~+gWbvm zN3ploh;#vWz2Tqsd?%bx+*1l{k`|^TS_^uCMsKs$U}fOI;WMIKgG^oFlp{*Tr@>8w z4?1hc0G}*o>aA!W#6lNu1_XRIu|&N^cD8#?DXPyf1bF}G%Cad(WkpI>+B_EL>DEYa ztgh(7m*w@6c|HTke)vz09k{lJ(=!s!$<<{Bmx#%G3ymFHbQet3Luzo+mtB!IrGwx< z_v3%R3&I4lMTE;=OiqFcNG63C)5-L+4QMQG!op!IitXg}kx&Z2v&98Am~_ncWsQR- zugqOW?*p%kaOlE+d*Uml9KgF2O*XGwOHU&qcn7*1&~vLrWT()C#nkK$RhXGy5Q?aW ze2WsqrhpBBedB!}0<2w}>WULxl|HR%G4Sb3QdG5JOg53Q{-Z{ewy)c#8c%&zrH zbzNj?_#_~^Qr)d0q#kwJGjj(@h*`y{2z>?OVy5D~epsPtQ^xi2`F^lf;AEs#qP|4i zb$0eA$Y+z?>O7&Q7P`}!qt4|x0SOxPcDXfNBIJoGG=CU}VAiPHQi~3ps6Q5Kc-v|> z1A;IqbFgXK>$JMTD1p6;Bs=ubjDh@V{Xk1hGCp&|*m=?OAxEv1K28xs3RzKi!7sl& z$V^y^l$7s}uj3~3n%(nDr|%L60{jy*UAC6`f+zEn|EJy%W<&G}wYyeW_)t}W9TIq~ z>u5xGS(K2~?4fD(sdpwY-gE(n;O)K`YT#GS;ZXs!3@>ih0xOU`S3QjgFryh#pm@i~ z=nF!w?TG`nwpAySdWs+)nhLTalUY|!_c9@R8}@6v%Z=t-PYeU&md>pS3BqppwrD4> zP(I#VUy+LWa$QlS?~~@6FaPqc|7S;k4atzWCBM)BbV|Epz*#k)lT4!>f$xI|Z^N2^ z=U9nJoCh8pz`EDhW36LcMO^!^>;oiKBK7Cv*`!r}m`mvV<|2T76`Sb(-or#t z#D*1yu(yowI*&T)V^MOt`F{YUNB>z0gBIJJ)6AqFBlOUbvpJ6*CnvNIbu&LNj# zqVDt9y-vmU$1Te+3Ye4reR2|tg&(&h9Rb>58op@Rpc(pQ|9gHbdP_|$HtKYf#)kQ> zcNEZn4y~wiW=f#6*S!2o#`Tqsze`~~r&n&N7F~Zs#vsjR?0}6)DZdKY*%M8$yxD zKAOFcid{~u;vx6Z4l4oxVpaSa(}54OSBm|ug_U~Vk}W68!F^7pUnOffx2j;InR8%N znw(ev2HycUF~m>GPf62V^APHd9JQq5YuRq$zB>c&5A1)oyT31Y z81jeI`5GD|&kP)2QVH;A|FguQXqs$dk+SYe(uTA1RE@jxR(j<}g|790IkJ4<*Y@eE zUEgZQ+9m^sfujxrAM74y)LCQd`C`eT> z(_Gih6Y)m}N7`bHqx>>DGSJ0lCC2SEn*L9Yn6LXc`EUV_d{!hWIT$IQg{4d=KhM+6 zcT73%ZpOW6{TO=2f+yztT-?m3T9Ug&|HNJC7n!JI*d`^_aDtbqT$>jVyRISuYCNhX z*n@F3^eO_Y(B2K(Z2FDu;aEel;$ncfH-HN4NltRZ117Ni!|$XGhxK=J1OR={@^K6+ zM2`*O(yehk+MFH%skIVk3LP=-DjD>2uEt;!BE&Z^5*^*N5o{-B3n(H1`+AMSof7h`kFI=nT7d;eH|=q*L@QcP7ryZIbe?=Yl518_ z0nI_bdOGi0c3<&$fVR?;Mt~l}zhF`8)0<7at(sYZ5tWp~Q=FW08tfWqak82NKmKPD zMi7Em*MGbty(&11=VFlUr_zo&2=Lohq)$~5*sh7ZYUMaO(eZ4lC9M*=^_Uk;>0Gq`x#g$0 zPHbt?2ik#BF9WGFhe({B+O1M5Qin;|n3>|>)IOCQ99WB+xkZ=4xQ2tI6no9t0-hRe znFxuz{%T-IdM7VJEWlGg$;E^7>{_W~AldZXKj2&2teUPZx_=VdnTY)JfY|!&E

X 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�yoFyhKo)?LiXW=7xpQpjj^}^x@IP@F&Sx4AF4mdtPX%=>Uv&1q`EMY zj%@^Mfr$GIx+u3lC-q<9J~%ylO^1FQ_SB=ZY|C5+jUh&z?-Nn>eOy)g6Ui@3^Y4q- zPoHIp2M?u~Zc;$NR*v$Z(rZgA-%hBl+2SOUJ@lW+UBa00GJOiQvEBpKnr6;+kx+{` zpzG8>J{9{PcEMfCEqRZBow|Sa#SKZVU4Av!3jY-NuvRsF6I8tPDaWduiz>r!6*8X* zJKwRs<}7;HvdAK};dHLlah$qXQ*1LqROfIpvnm78q#}}|30_6@4hFCcqIt?Gz0{mb zd%OCe$j|FLLG1 zY;|Hjqn1-Ox_9|2k4{K;{)pN!yPFFdd$=hvi(@m%HMU*-{vSWhXK3eT;v{|f7<=TmGD{T`Mlr9dCW8W)SX+#7+vK$BzI@ zRG`~|^q!})W{VkH24?)S3~^@~@$fMSnpt4J zB`xQ$KqPPIvpH3wjuE0f2EEVwEbFTYmB@fanJ;J=Pr$4WAl620OqA#*ltbD^?|<&o z7)R$KNhPvZjoOQ}avv-JhS9_*nYK+S)>daG($(^QU|FOiyv7{odGAO>R;{|Y5~)yb7-*&3>+FP6b#6dZ zXv}}K$UHiy&~mawVLu>F38fk^u3S%AcHZV+X;5U#bN)_Ifn*~x&bgAMaxu6 z5iRNlURkOzeGE+R;Bhzy5-6!&x1vrmxDSF9+gkN$#H&HmhgmJO;tci@%z}__|-u6CG;h90cN7-XG7X7@|Vy)G6g79^rE_y6VSuK z)Va*X>3{iheuWq`;q=o=Df92)Yno7=lF|5!Qt<6|^G#E`B>Q#a^cN3@xU&aU-r93S z6s52TMgO@*7PI%F;!Q~rHWsQU<;!;41x?knx^zJ2!Q-_>9(K~A?CG3PU!3;xp8Oy= z{L>F?64ZJiOp*34$B}_s5lB(*`7&x7zM*aT$tP+`fPLFFZl2P)0qMowhbj0FQzi9t zWRdw%R_}E!+wJ%JJ=peHP>~0ijrpR9-F&)3ZV}qz?3{PB%9C2>Jw%y(D&f^oBsuCO z-IH=*I7Q${Qf~-l^R+aS6+#@Ayx4U*ns=z7MiLH4-hcf22mHQX`19I+d{G{E?2K6N z5-lLss1jp=_dD@%To`sK*HMo;Z_Q*k(1@WL@B3H`y&!yK z(QLzNsaT8!HeR2;MWB0u^mc7BJAehNk@)cg%lbhJBK_>2a2L+_&Q&LLNla#46)=Bs z#<5%Pv=#-=DxQMOr@^XL1^Hs2Ko+FI2$M!3nACCEi`_}RpTIt*D`tN zDk(D9QymuC)xhGJE6A-kp&1#NYC~_tZRSaDT3dwEHXQC4-ol9`a}m^sCzeV5Q?O%Q z3-_T_oip0qX=OrOC(p@3-)OopA7|i)*#!`PUD*I9n<}w9ppGRp7HI)m`kw0~>A4Lb zkdMH805r_HBa*9|MUG_?m&kfhhsMAHc!0^S9K3!0S}^ms%)OF3+{x~7&$D}y_oJGu zW}SFyinL7G-BqooSq`FcKWrK5o^$60LM6@H$m~+@Mn_a=Zg?(!lq`;m(eMu1Ie(FE zHB+PQn+E-%SD}cJiu1n6A<`-kM3y==7s3ai{E6wGq!<;S z;xrS$jaRlC8(HxucX^r+aH6j2@@{&X_uc$RQS++qTUw%19* zN4?vXT^mT#z%8@h<%+o@g+DAt+jS7F3>mm95c)p)K*=Q0dfMmBJjqiDCFxA(2RVkp zc)P5ovc^SIFb!1YILy`^0*>hY(rT>|T%MBEs5iGS2des!f7P*~DdT2M-AR|q>yNUJ zO`zuSsP9-QGdw(#*FEOKpVI~Hq3|Q`Tfcnf7|!cn9HT;ATrU1hp9RB1->R5p|X8ER$4|5&fJ#+ThQ)!AbC59_S2g%9Kz`u1X)3|>p0CE5PG8#>MU3?H9 zc)(`h;O1ck-lWa$j}`8pey)!0z^9G7Nm3@N6R8%kX{ktq@w)!;_nCV)&rS0Da9^fi zM#enaW^?8Zd<^ya@CL4n08dXJ6rofM3c06p_`I6xWMG$G!x8ILmvz}w77G>_9(fMU zFgvu90_~$Tp=74yCGxL1K%mc|#dIO#uq8`a;BJj(RYCJM84B1r!wp;S*v34wvfmEO zBWe5VfBSZ^ml4K*l5ahyrrWd6`_aKFTkmXVzz*4cTG#OXbvS*7=^*_m?%F7yiqvSp zlZ-w7T9;&hQJ^;1hbOHZF?E1jXP?Yxnr@7)bhXHiK9p;+Jww*J-7ND9+I;xKA`}AX(n@hJLgn^Lai9!{qE=tSxTfM{Pk~ip)w4EZW8qA zp{z;(O@7aCwb^UaQ?b&VHMww1)mqkD3w?PcFb*_?Cv(ZM0XeSk-M(?muo|hsb5We| zKKmvnH1L2h?H9^g6;v;+vjjG}clS8{fo3u7J&}0fHd9v8Zf_rUEi{v4jWF#yvE29M z#%Cxe$bdME-eNja`~7>wl`sPy!OjmX#JwS}Z2Lp-sTK!d>;j2zo+A4-(z$jw1*aIe zhN+lwL{2nPb$U?^ErrcAERHqJ3yJ<*VErrHcqlVh0<*mjx7f2{SJ9o*k@R&{tV;q^ z18ztdR={e6uZ`}i!|-vsWhA*D8k%x+JI#(@rASX7SgYbR42ewq8q9K-YLAHI7O4Cl zV^Q&eWqnB6vK}vzIRtEZSVW`r<7(VOchL(|g%-e`0`{W&aW3_CPM1zD(k>< zld;<;Aya}vO6`MVLBU}t4IbawiFDHba|VqwPp|1Z8-b8bwfNRA|M5w3z$ASG%joGX zL3_#1@#&tRy4#|b4wE8Gx?#S{9U5#|tk(Uhkj)Kya_$m&YL|Dn`Uwi+4LwcW{V66_J#1aS`=w0e&`nV-*3_baB&|OhZKs3P?Py(wu8EYJ5Gv{_DpL`Iyw_OSE?bbQ7%JkSID-sDZe8NOxLPP#Sv$ zM(vb(5rxV$3;nTgktH{%#G*YYo60N=w}1R(wVKiVNV6vTTo~jLmB37p~eS*N_z51>?jlHc zCYjp4|I9Ju<{VTDg;{L{4Ry7h%1f3dG~cdtv9kxvS)YfTasHmfRC}_4GPNb`&PMAp zVjLLuhSvlB!wLz`%u^wIL>}m3Bcjfco$z^S@K5nzeGl?ZBe(&tm|{k5){IuL}rI`KNh1AI?Jq|J`_~H)l6#l zeJbO13hZ&Ze35~qnaY7K^IarLi>rLKt79a@$T0qo1)wq{sB~MHZkYun+A%pvt zyPWRqZe#LE^T{wzALJAFSiMoa-5JGWTC%X#p0|@V!t=$S;y=*>WA@A)aAh1BJWaPc z`E~VJ%bu3kV*=0^EEukjOq=PAIDg)mi3uTmCfnD{eh+k?Cwt)cQ$y|_?+QE{bn3Pv zbuTnq|AMm@uT>fVAr-}Cs!G_#4*|I6IRs6Jl?JhP&8`zEtr2>!4k4;@DjvS*XN|*5^5bWwPPE zbKhrHios6u_!waLM-D&QfXE0Cl=HsV4cjk>KBw{zaJVN9Fj?;EGyJvA8CJF}?7_-* z5FkwY&o-6?#Ep#cAZpLFZ+_;Op@Z zwR?VrH$GIu6G3% zv=!fl8!RrdiCcm-k?0>)s*&77R`Wc8oe5#sL3Ck&LctxUMi%`W%6Wfao_;1>SRM=U zd@Ex;BTyj0DS_j@az6MD_w2&dGjJqtF6OxB;6JvH{51Z?dEd*?Ly|;vhLg}E69R1? zqKm|}x4sBO2ZJ(4+#4(@&p?#(6Bx~$6i*G%y*2jMwm@%L-n1zOv;$PkjU9(nl9Q73WU2ICKd!6SDT zzh}tSk1s|H8z8y@+Y3-mPgs<+^iO>fH06#rS)9P_Db=QVwt+_53Qs|wHTUT~ar(CT zh@;A%X&jfp;gp4S2cP>ao`N{SQb{ z(w5E;&uYf^>|ojY?Pj(YFT!L$*C*MoZ*WCu)FSomq@Af_@IHsxS_lL_#>vdzIj&Xw zj^i1qj|3vo9t0oh226+(bdZ_dt&+5Qzn8=P*TzCD#!sKoB0yqgt!YA^Gwk@79C)_7 z=aw8aRCatIb^5%#)O}>L+Gr)g_SCIY2tsTWVaP?K6nnAIUq0s$*yg95h`-#R8){<0 z_^ne%7I-}v9OgU^dW}MXWd}{Y4GK5JD&}q+u)GbfbBS&(Thq9G_E=8U(8SJ})muIe zbTKAT=Xi|!L+Da%9Ic)PBI1K=GlR4B$!;ix^?t9i$sIu9j@mSz8;b65N~dTlp-7Tk z^E^wgodd9ar1`b{v=HnV1oS$=)&#WJx$E37fqsBtq4Vyq!)6PlYKOf?GBkjb3Agbc z)B3Hr(6@^=ml4gO$$G$EgD(ipP2k9ZQDHQ#taEL7MUwn6#aOjPnyww|a>Yavx^b>k zbTsq24GGWYd_ekeBQCQ0h|@Cfqf6rX$G#zXJ=b?5H#)b8h7kNS4niUdo{N9(n`4zA z6FoUf@LD4IL++Hu3v2o5ZH>}9%qEb5f)&v#z-23inT2J-ORbnc)aa}ow%xmsl z6%?j9Ds5W%9Z~F=-b{>k4$>Wofko=(;s6u)7wwLJHHP0cPBu@vY-X~uihAJ$qx-ZJ zpm9vW&=6H8F!wug?QRe6Omm%UIc9Zsqqn&u%a6{$ZHD}Qsr2t*(J|!)Mj~aRT*HMk z`2qur7;?!hhB?V`4}}AAkamNVyB2Lwj35Bi579d7CJJa zsR3lyLH2eIN=2@DkbQ0bYI^P5w668QXMV^ZehBgDv(|>yjd(f;+GR@N37%uLDq!Yj z1;?SPI!yz31a+q9G4&|n=#G~UD8dL|a;e_k5#j?HE@VcePe)4~&U@b{!>zMybbw!9 zk|)Q0I<89eDiOUKFP)|(t_(bAwIPk8&kb0}Zl$1Tn1{nCX6d|X{No*da&_8VcM~JF@ZsbvHG?I6m3hs9BZguGrbmryVOZ>w& z$ReBR*{zazxZ2i;r!(CCnD>FhgL*AoWq&%UEBE8;+{;4qG}2DI_r`&h`YbgxhLEr8 zK`5^hFslZmNs(Ay*rt%}2`0Y6hgvA3yYIX5Nw0vHD2}(wrAOG;cR9xiVYN?in9mmb z#Z&od&-CAaX8you54fcwP(xJNN&pR_ON*W(H4x?EFd#FZwimLTx{4RlF-wzI+U;E4 zl)7z2UM>etjJsW`)LYg?2nS`JX5SFH37!W0aDgaeXDC)Toy4?u9o0YTbG9Qu0hrsc z;)xX?GkFawk&Bg@q;H}f=g>Xa!4AeXDLZ@T}FH{>tBb|$7z zsc?>IC1H|*AGM<5!6QIz^3-jOxg#JjwU?ta8yWW?+kBpQQscEVC-)ra9hA#8X(l&> zBHZjL5qceCq>RGYjw-ToYzg02p{B%(Vqwh)WYe3u;n-Wb$mj_zunbR}T>>1F2)JS4 zfyy;u&npv^i3lyjY5ntXb*+PJ@!f8}aokw@FHIUg7gWDBI`S^NXsEm#t&E&>`qCe~ zOfeM1I2osM*&tk#KI~@`T?D|zG)%=>ws*@y>>%JSkVuoqOGtYa2 zi{Mo7<`x1b9C=M!mu{C%)bopAnC4IHp7Y=3jnM`^v9ppzYmui1HknyoUuoyVadiUZ zp)037R2bzKbBok_{2u0gdF$~a^XP+Y{jo7Dl|9OUv3}{nV?q1QE6$YRB>`l?SDj)o zZgT>37}lN&)}nOSXXyJ?0}enh5F+#7c;@L4&wvX$Pkh$EZi(?^pV)v}At#SX-{8Xz z{Iw-+Um%Ip!Z$YragVtR%@ z=IXd?#l+hSWdM9jeE} znbPhb52bm*hpx(~*?hfq6xh@E-H@p{8KQMiYC-~lYx|^dt(+cBS3G53v|AHza@;7t z;{ASJS=wynsElM1zGexSNPRPQRmeRwoK`n}(HJ-~bhFvsEKTUy2e7E*(z|^7@gF~K z$ZWN#I?xYP!7buH+^Rtx4zB)6zrHw%iOJ7@Y3@#U-IMHqyTRsk=2%=Ly8*%D z%pZ+>-y$MXUxb=2i8cP?xmNg)t1h=5y5&>j;H!IJVxp4FE;~a;KnFq|dm+x-> zfeIn_E$M>^N1p5+CP56|eA@0a&NXdPkHUb*R@@9A`g&h(5IQL~e6~#1^SbT?1GQ;D?)D>~q#ITW1+`fPr@kRj>wx!4* z-v?UCsO$?B#bB|wzhJt!NBA6aK|rAFgu^mz`<{U5wQuA_`6C;{t{l870JiBmD9)@& z=RUwy>xM#`)$D5(nG;@e4oHFiyRy9KTcE;^B#Quzy~s09p7nwlN;BOV4q!r_nw_-I zu%v1x6IfmMlyY_Q1&{PR4w@TSF<|*OAQJO=f$;|$TFie>#daTVPpkP{_+St|On}w0 zW)@ZM_YH;W!`9z_h8epo-N&xEKSyl{M}Qo?Pz2zV%v4! zYs2a)Dy2~847JjYPS&?~r|r}Sbn@Ys^}G2D5pKnXtiy(B3jj9L&3PYrn>L*;^S&#> zKQz{wQ9fKL@=KS_PN;++labuVGJ7-WPDbBcQk4{=(S_XioMtmNoW@TwQc1L@a7349 z3tO;T-VmmZZmpq}yC50I{8oz<=A&MAy;YX_J>F}Exol+4h@qUrv8l7KRsN!saDv`m zTJ9o89jAW|rNX%U^M?G}uY)zHMHp%ZS)b=Jr)U*}|0qIBdISgYCEkd6wn0>gN7 zg!+9sE~)qclARQ&3_0aJT@-nXbyDU##<=rtwfj+9G=>c`faNHfl(94*I5H0}g|9g% zMr?}aeCjWm@zuIInCF@E>m$LB+yPyJrKp{{#ujF-jgf@w_`hQpW{lA zM;7}AcI@u9q7%YfC2r_6KYZa1@#LyO?OS?L7Qz*!Gd-{QDq!j)Cq=Ioe*F}?AH)AB z3Dl3+Yn|Ka7R`(HdGL1&RI2;3n1YoDmd-LYtD_y-;VjG>@~7XRLJWO7m0}Y|i{VqN zqgopsFjCE0HT5vKGA|NzPDXHbkd8W)kYyYTSLKz-y#*|vDp@?4>KGS~AeReXAz#2d zi`p%zdFD5ivLKG3JIP}y3S=AJs^qlBg)vQHU5~q8FwM&vUm=E?>m6n&IfEJ=0gUV-Z+(6VOwL)x<(=Xn5jhU%?kC>yP(K_6=){ z)2c6J2bdkn-h}La0Ef~sx)9^L!X-)^22m;vbXIB z9&RlO$HDyKEL&BF~duZON`;b5VzySAnOn-YP7qQ;e@dCuhxJi#Gc4Yn? z$nK)h`qk_j3NxwYHtSa)C&Jt~0eOzbbwP**(r3MZTX!sllA2*B&{QP(2q2Z7cwI~l z+>o?8+AUl>tQN+k2blF}uy{u|+AsiFU>{L372z8TDn@7XIj*47_iRrvHza$p%q+jt zBkB7e|NDn=z^$Dk!m%Y@Cs~1Z^XlGQ^MM%(BEB^Lx+he!TG%W5Xl~T*03ql`x6;d} zoAlDj7_0k+|EObnvLSI!qVXfDdRtNrxL^*jK{7M9}L0_+OvS);3?BL>O6-qBKm_ zC&z3;s!`WVAfGx2(!6XAc|1%gChV+AH>srl`wF%_9ti+kf&HN z-|J;~bPjlNsU86FMzrZFyJ7+H-3>#h5*g)Q>b4pOKTH?R1#su4M+%^7;&%nBYle<< ziV!*al1<9yk=uES(~H~e1NGy7d@>iotSGV5FJnu2a}MOH=fw^h3DJQ^olba(Y-9Ul zHX>hZ99>;EWP9ry45tDv#lr=27%jT}SyJl>pJ#Hdy&-hx^{lvrcQdkArtq!wtn`NX zOZr=4hM~h@vyxS&z1{wJx{OLEhDV*>h)k61d0X{y{4U_WB0@q9T=26qp^Xe1^}^X_h#WM%NLa)hnf&f z4m#=YDjX-2wY;0Cx;0%t@=Gb+Frt>k!&J{`Cl8ct4o&>nwrM|F zbs*Dp%}Q_^yK{PX1}e4k%SH}_lFLXZhx$NW4~#w8@-x|uPh%zX=BenIakb|KVUIhJ z)KL>5oRp2%Yc=uv2IFA)eiJ$a+%n&^d#I%LjH|1eU|5byUj-fFLJJ7s?re0k^yuCy z5uP221oI0|F|(P8Czr&tP z;5BLvuturz{tbCT5xJt)Cs6?k1P}%M>+$P6B^;v1LX!nhJshs-X6U5>{Y(hUCnPLe z88ulau@t?DOv=ETm>=BW2<-<-R{1xXZ->8W;3smntGKj==06yPx<0qTGNg)>0EXVqklg3Kp8*HwzOVtigbZ z2IYmAH{1?laJ;*N5Wi-OA^h>-JS5`SzKtRm+N4Q09Faf%{U84O-wNCL45=)vm={m8 zv5n%UBv$E-8_zh7^@yHWv*(O;@5AzKaI@zTAL-#pk|@aG^zgLR2de)qEvjNkgcHK$ zW2Z1v^|p%jX7+?7=}>NYsH3$tyYWE-1y~FA1t|J(Ejz1Rz^V+}Rc^9r?ObLJyuvOj z-THgr`gZRmJv&~9QLp}58N@uh?6@a&u$(}bzy2_o8aIs7Num6O=eAV z({f9w?xs0_YH0*0zE=9D;G?6k2t*|CR&rmF!%H~+*tsyGL{6?K`&@H!+z_XC)ZT+{ zID*Wq^Zs^ZqvEMv=;U7aeQ)FBTHPlS{uoppaegoX;EK~3y?!6TJ|R-p060hVJd%|( zAI?FZDGJ29bC)tkbO*lpAJ!V53##Y~7FogRnt52eBTlRZ{N^8HMF(|AZ~wc{lTb9tKmI`an`_4=2$mzS;k!A#0;N{E6qjEU%=>hV*+d!NVom4pz=tG7)A;J+ zu0yXl^BIF>NFSnmM|rpi>ITQoZ^SzzWe!yfQ*-37PBf1gUmCOn^~e8hG=`db6k6si zbR|i5%4n;JN9d-I(qNp7r|_hNG|%cfhg=F-3{ay8+`coxb`MW&1RP%}cP(zRQJ^bZ zO}>S;Y#BJ!XE)?u_9`_^#n_`Ji&t476!#1_;Y%?B`RE9LPI}#6SSyX>U`)QwU`i}c z;-(TQV?nuT27JZVa;d!?csA1s4DqJZ!aepDFXKSwdHd-D%ld5+Au97ADZ+^g`iuwp zLZ<}~K@_Oo^~)r+sCZ5rc9~+5`GWH|?m04L!{M?c53LV<9(jO}r=8B>G_wzOkBmVl zDbw`PsDnwzrpK1mK%W;UmU-EkDZEF6VIsaEfTZkGhU!-4^o@6}4ytz;j@bGiB?x&V z+KG;$QTH{D`HviY%1GdR01gJ(HtIC1^2n$PN&eR9dC#fIFLzw4O!w2b# zde_psz-e%b7|M8$e!-UV{`9|no*`I%K4PB(=da!s)r0?3@1y=z7o7s<^yge-lP%XY z!nSkz8%Do6?}3u4ld*xTMPz@Kokzow`N-{^F7P#Wj-RuE3^tP7g37`7Ym82g^7dsTW z_%sgk?)s1a^#PsmdHK%WW8#`=WcE4JDC1_AdD@Mers6A&~ z6=5}=hwR*4u8x&t3VhU=!CJRfU3!zkd&kQeiejXWPWbrbeSgCq^fo9S&aC1mSv@2K zOlPX9Jpk5q#LM&O3t@HK_K8(YmCZ2~fuj{$Gtt+oxw||1^f_h$@uUsuyf$DL#;&CE zpZ@2E2`2w5uN->LR+PgYJ`Cx|5*B~~z$_BQc1iyZC~j_lSUsaL1m}cbfuRasw$V`~aheZUMqGg>M$Q?}O+Zr_@d`i6WUB%G`|6)$(#s>2*MfX1n6m?C9o< zbZ*$>ICNC+Uh3TvN(_(b5KQU!jZm3NlUL1boq=Ug9a9!HX9NO?)0*78e*8E?eoc9| zyMD}N0zp{z;Q{KF1Nz|em9h=A+8^@^)CJ=&z8;OcGUZ@nw!j+;-JqMZ9@NEDHwys2 zOk=fPT)N2VYNBehjfayZ=_$9!#!N@yef z?F1n|ZD5nXjb6=emQFD}bW<0C^=k#2m5LcAot+3jrD;1SbDS`(a-1-x>vsB2+&zMe zv+eIG0(AmFh6Lx1#cVQxq@6lT3L8G6GoB|l_ZWqzrlLKqegwtXepb;l-JM27jvhB+ z$bRr;PQgJtOIgZ38xtQ5F^^s6rHel)%Fps$1B-@G`SO#^Hhu(Cu z;i5B-@8NZ;D!aiuP7kaB<9@usxkM)&KBQvI4)&F}`)jwM@{D&bPwh629v&$zde@=Q z^FDOq$AZd)-I8n}kX-zj`@GD;bsA{!2q-d=Ct|IQ-X-=3B;19k2n2A~lG)R#MxW4g z6#?`{DTcU|Z@e_MPX3U_LN|vj^FR;SA~YMQ$8Rj~8exp-;H^~b6Ay<4ihG}4f9RQ5tzE**{II8Uo1@1*;E>y)eaAC3}G5vg^>r-MY-bMP3MNpF1A)<&}A?5Wf> zHgFLLDL&4SfBTNf+;C{^A6TfC?a#Q|xB)PtZnFS!S&5BGqx(MWplIxB?F+hEUV-v^ zb(TxPqm4L0d!cw3&0qq`^hxaV8XnxP(1{kQd?7#x1K9{fXB*cAa+;}W(`w^1OVZ*I zD?r{`^Y>|<+a#wiYjR^a@Y&!?_w*$GbVeoD1b|WaQ#a(maxctp0!y&tGrs$WTHLKr z-}N8@=lzD)gl#ev8%!s(mv}aF=&h5w3XriYQ~smQQRsd2vg~BNn2r7#Ng*m7ooyB6 z^dT3mHI`<3NS;ZeS(z=57$7Yp`^S{3hova}2aO|O&-T40SLn$`Ds>%y!_}lV0?v$X zWR2VJ(J*kTCSt<$XsDdp=?JGH8$B-~0Iv=~q(6;51a)zYQ10|=rX~5Ir)LC8myc|N zvhQ)Yrc1<8EEFJkpWK+4z746Lo#Fhae!rU?t*gA`e0TG!%%H}WsNT1@Br6}hi8h@K zc4|1?ARX(H67{i%#?>!pBzYOaALb@yAV*FYk3v{gT0+o% zo*_U#Y9JnSY}_M;butjXJMza^HGe0Dt|2auK4Z|JD7UCUuud@CX2D54WH7=%(=6GE z9Ahvy-#eNhxfk^Bj4YvlI||GhToQc|UgWFetzR8^&K!~KY*)UdGp)7)0huu&^aF&+ zES!oJ16>-9Q@vLsX&j@y|I74qGwU&UxlZWG9ts!*0<)gt1`!+x<^mjrfCOjJPbY^T z62@L2W^~7Ki_WgIv1-|QW2c>S!C|r++7%{R^aRUQj+{GjiKC+5==M*n-*<1AFKhkq zq~0SpVmEWqoMA(Gn#SnXG*Ii(($yH8piz|SQk6@v7#3iWN}i;+d48T42C5x%a;{6d zMv1@tm}f5-8!5}XV?VbWjKNLV`J#8-0dkJuE@~TQz8d6>q>Tj?*#R>ztp$wmR^D8n zXw!BmTi7Mf$IHOJH)8|QRTW9O8h6UmZ0i|p+I045{{=ObWQkUY)k)d1&rt$&@E8ha zyo{T(xr#0}Uf7X{$1tFQCR@nBO7h{DuqF*?T+CL+tmiy;r=pOlVxow;1JD4=AlhZ5 z6`ie$LB7vp%AGV6jgNf823 z6fz84znv!3f$pCAM#@d+x!TbJidt6VwI{2QgxCs%Kv6H8mu)y%}C>A%ywrd8LU~1Z4&x zt3$@rlkxzj$$pvl@Mf&lyUarv{pe)zX|pDhN-@4^QrqT`A_P?PH|RU4>Yp3J!_w`% zl$;Et^%-_~vdTy-7nFfgwb7y9WfIY3M!@F5Xs_!e+z=2;#=+4!Kx*m1YLj_7Py~Pf zxl`9cC0Q|cAE(b(a1_?<#7TfxDZ9I^&eAhjY+EFKxkM#9`(8L8b8u+VJ3nKF4w5y` zl@^l8ASC#to}1nc#~Fy34hY!MU|y9PWHq)l0N#i)>8)91Y`i>P@eOQWsbV++LmOVew|L-2KF~p(3D-)|fz9d|ckob&%ieRQT{^RvS3!mf_bA|lQ~Z=)qdtTw;bRAu zi7`h&tM=IFSh;Z=A!lY7urUBtiHK1@;g=P8@6+@ktOZTg$e-s2!-7TCC7mafI&A8> z)_d7EgdbLe&7>{5bZyytGKJ@T3gSelUubRN;(mE|!s@mQ_6*kdo#2b(;{msmA%+7C zh0)ZLn(mMd{OqNTmHLvr z6=%yrz^v=DrJc&cDk+#<=X3{mn9Qr(YtB$M$`AcxVvjWzK}|~qLR(sCub0YeCvFfMQqa$ci0cYMXOmP zLDQjAAetUKVqpFbFi9RP)^%Le$4!48-XhK0E#qz+A|?5M|6d=6I*|M6VeaGLlKj|XVhImsvE=0)T~WcG4*I}~nOOm3)NBF)DCp@iuemQ=ihiGn^ysHM z_kseu4)3pqMP@&=36aQL0r4BC$0j?jCm1I{gFc5duSr?bYCz|XYEZR;fRR#zstn)_lmtE+V|)Y z;_GtW1-A0_V9>C1>CrhixJ zuweO~`tLB8ilQ93L0~0m0Xoatm^N?Yx1*qvQWPy2sfL6Fl!Q|gW?I2zjf}=6cTP{ay|8|Dm>TI%3_|j&jZZ9MxUvzjgoDnH$32fH7h& zd^QN`$E5z3-?UGkH1jJ6|K#{IA)eC!0i%c1?wR%zZs(0W*(L4-gGPQvg!()h4}F|2 zXS+8kwci=KSEnj840~&CHd#is;u(7Atih9_#YlFNpwqHAiR2NN9D1w^)OW@CR*<&? zmTq=_4p8;K-tls$&V6~Yna=eKkTmfG+_-9LVly~0t&bBK4)05KQkD;nEhEcp~pi2-3`vq6ft{O1NMgkOI7bx6auRJC#y=L{>+5+fBG!uSIAHeQpyW~g{m8zpl>FF6tZ1;MwNVZf1Ooq-A zvVC#{wh4Dw?=KS2rcwX;gL?$S^@1~sVOf8$PJaKl8UfxMA>(K}mi|I+>yhbkd}_}x%Y0k;U20IZ{maaJ z)qU&Ms}2`HhFP2`><&^#UIlqNE@F1ZoUXcyz)t4;DH^cgm@4QhkNDYJ>ees^|FC*kzZL-_rN#oylwq$+6QUpf+Q*vz#4L_gt_Kv)2+hp!%2 z^mh8k$}wg>&%0X zbbF)e@A?2VvZn%&3Hso#E+mYO2vsjKD`Ax1eQfz7SnwCk#{NsOqbXoUk!h>f%J6p&6b;pWq zv7v+X8*5Ze9bKgI#j&!U;{BBk)HAVhP)_im8AWpgA+YaeGiuJYq{7678Q7t+)@7iS zySdh@r5BjY)Zo0Tzxus5vV(MAhe|DmpE^olMnyWOcntdh6AA3kwLcQp?c6Jx9d+?P zhl?7D#_Cj__wC}jOvJf>Sci4i6TJ1iAOH6cUe-ApvuzRR76`%F(~W^Q?RJu^2#h); zy6PP~bYXma5Iue2bDfke*I`g|bmC~v(b_u`U^y{MB9soVRVNa8mwB=w-i8laA~8h4y} z-@>Jo8)72lz1pkLpnhgh{W)?DmjLPMGK~TR`jR{(Pk6WO_&^Wb4JT`2TMQVLRw%!> z*w7Ih2v6el8UklEDD&cwO&_If>J+ri?wDUPX#=3L8RsATY176zWUIWgY9p$ZLrOfZ z4vpR;*EBgE@Jbn2##i)TkYp0`tuhwA0_WSsx`NTl@m$MlVQ2V+DA`!YXNO}}^t*DV z2^yvkqTKI)0B2^{!Ogpet&&{>F;r*y+RveC{}yT$=Gq>;2Wa@<=wxz2W)j5`V?1&z z!5sh)!OgnY#sM}dm<^Pb=RGJm#z~0ev;&dgT(!?v#oC7kl4NG--9W#!i zNPn_^aQ;gaqP#|l&XgCO*1B9vVVfyMEVu@EOD}K0hkw6QW*F6i-2roVnJ(Ht{o_ZM zf@5fjYB;6^5%3&xCfEn{njHRyxkTe4*=)Ae3p+ zI0~X6x+3CeOpV%NE8ULPZT_#$og0cv6Re8`?shKrfqPZ=+I=`_f!BcR+AUEB%#$(K z7xl;~?5(^XZ2kjiRym3hcw!~8A=Jb7v+SizXkS5hQ~#&``kd5#If+q?JjqHvyr^?c z&lq>h!@Y;76c3tZN8_j%(|AD$nT{}P`W>#`km|kGR!` z$x>3hsfI$XcR z^9+{ux$Nkf;~Zu6x^~u4$^gfM4qqfGt9Ga8HhCz{0$aWrF47_JMa%Zx(x8)yc5?UI z`;Jx6(huv3_GL8=4h0kW06=ilIeuoGdbV%CI>h=x#zf$Cag3r2<6IVyzqlbOuO0OE z`;U#`4@uw148*zzwy|An`iogkrTn2{CWeCsrG5~YhoWb98q863&)o=HU77@DquM6L`<_xL zDb4X{)ejhsLXjx=sXv< zhc&ZbPzlTi+`C!R>6|L%<(}Zi4Z5B}5Wb!?$z5vzHE0@OG*OtF8vH#F7O<<*Rpr|HwC-4mugl~y|w68tezs=Uj;M%E)0GD5DVYC4Uf*=o7 z%=O2FlQ#@_Zhli1E&0XQ#n!M-S673RZKr5$SKMGIT(Xa=Z?_ZoGR_ zw_h(ye0xfnvb*95Ls1FD3E%O)Axs|F9twxnPfN!U29#H*Z3i+W8#! z8(`k8reK{OZ@Te-C_Y@KVe6GjH#X-dx!JJjDy-Tpwm*|TfB$ovPR^<6%)i8d`-OJ#ve#$i9D7$o(<44&d-&VFM?gC zA*eKQA)hmZk?~>U_vyJ%srw#cIv`aA+D!k`JhcY} zBen!p4)xcge;w#5hOXFMy7ZxX|8yn&l(fBmcTOfRDErH><!s-W>ut6u3VbDps3pWIMI6rz1 z-c$LysYly5O#bV~4f&0q$T8e-s$eWsEi7qyiDtDsTL=F&-3B&2U2-yMrw>~Un3{jT5z=WcNCIpPq7Vs!SnG51OSE7upaGlSa2 zX8@w0%8an>KLk7^YP$vVnX!xCXcQWp3#{c{8Vk1Sc z97(-ljhjR1)qWm-aV-^hyQnIv4=M2XT*vqal*j0LXs5S!mD$Yh%^~?x1dNTZ9A=6x ztdo{a2l|H!6LW%^CEb^NKed{=ht0?z7tlTF}sNj zozOw!?a&9B@s-s>W7=(&JNJiunU1>3&w<#wUkjAv(N<1f>b{|m~ zGBs`fFpU}yl-O4CRRMlZD5$))Ds$94PdeLYTs9?%NVeUR9@)g5`)V{Z`x$NcL zs^l$#xRT$f2Ze{K@8dzj!^MiH`%!kjy zrjG5YfLT_!;={Y9f+}vQwJTT*Qs2P&SlzQ`kG;z*3!<}bcP%NAU;cJ<Q990jxE=}YP`?J$#+c(pxScC9P(mJ7sXP3t9#Q&}W z(hfG^cs=oHY!57JUs!22*Sji$S(bMK3if*%vqkD;{0B_yXmmc1gLHljyEH^~H1)+_ zNrOH`tZF&x%KWi_p}qa_gDCg&d>2%)2q&~8XtcHX)MX0|QGxv063{e|$zl-$wHf=8 zZ#^gj?b4(~%yDekcAUd>$Tyh>5j+pyEBwaRw$!}kn`9cUHC#gw#m4`>irxY zW4pi1VA~L{ITD+Q@DZ*@SL$H)EO`wV%pr~zrB1mj!+MQ=H!?ZRhcoQPUJlO1blVsN#9w}NHg z(d;{rYZ?c~w~SK-KqEZBl6S+oe1&KrvUWd364)B=9ouL9?`qxA=Bo*Qrv|MSW*BY`A^0N_;YHlrUfnD~LsADDx1+26teEd4LpKDx zM8&HpCbX+#Z!6 z`kC7)L_?;$R_eWOZ&62k-Nz3~H!GaY!d!z_#L(VR5K#*7(_x`9C|kr;Ze5kGjE90< zfyy)7lBT>O;RRFObtA$GQ8%!DpII>Y>iS23khRkc=#P9BtmQpfjpE zcQ(uF({fP0&SD>Zc8ouM66JW`Xd(IjkCv$1VOsJzOv#5TZo9BO*JDoD>djXIU*X1x zTCDlq?Eas)=S&xZ=J$~TlyK1}(JAL(5=_HF99ka@gT`X+H^s=cxnBXPb|m4o7+q1O zF`~J;qdN%x)!`j=lBVkI4r51~5X(46hlpnRHbU*wD>~ZfziFK2bL=NrgDJ2#?5Hy<*j;GTyhE2tTtyLNNuMPOB+;0hL0zMp$+!uA;%C0PsdS``kemjcrGUKdQUUw zNTWD-HkCd{HLt`|l_l~cRPXpY3=?9}K+vM;yB)`T>R^u;-6+qPIr+8uB4|VMd)&sN zVuqK}T3E>`r(Mi99t@%?U6(t1vkWGt%@F(fS(#2YG#^H^&IiqN(A+XB7pP6I6+HA+ z6PNemNR&LB6;*2I*+a~zV*MSH3zVp2HJ(lwALldMJ`NFHsHK=zFDTYKA?OT`AcyFbr zLS(uw4>74+iQlMY=3G&=J0gV#AY!rLMiJXR{vPksrr~>1YvbKJOAUuMJu0!iVd=%d zV7~Snbv&c59dY=!{P_3Jgq_gK!cHBO3IJJuA#iQ(BMvM4>9GdmHSe=CUvkS*FJ?GX zs|jTtTJZA54#}vzl11V){c5M@TL^N2#T{8BLnm-pj@b8HBDrn>^E+Epjm~LPc(cbB z%g0K7NcX03cCJU9m&FaeJ*#vr-%k>Q)~b$@eMnAw3QnJmIVJ+;)*4WC?*Re1t`Y;Y z<mRH(9pmXlWF7UPA;ND* z{SLT^Sf;-5BurQ1qytvA{U=@AA_(0}Zg0a{2GfT+r6mcL=%-^~YNPU}sSU#Zx)QEAQ91qaWN@YWy=u+`9zBQy^fwGvfvk8L zlF*4`ZuFh=B*aZfCVkcOGQ}Cz$vIWWkpbmNkl%vkNmC)%{&wiOcniE;MkB?4U2i!T zzfDVXd~i;A3UO^fxm|$dNgcZAsa;caw^l~y{mvmC!cBzpe@j*4zTf*kq;Aq$!(vMv zuqm5!J95R|oWIyUKbgzx;gnMhs#dI;C$o+Ng0z3_oF()Gnt;GYFitIfVy2vcYSyi? zYXIDS{4~G+7z}?NJ|VC&B`=!XGf7YD=?7z)SG+FZ$P9`E-6a5v%6u%QnX|U9tYEoY z97~1!1v2W_GgpW0C~{ANPfa|oEG3FA(DAw%HtG}I*O+CxINKmay6nl-*#U$7de6*- zpT!r$dQ>5zJ#yN)~;kDmbpZsC^-#-IE+9h4`>FMHE#lXR;H5=Ztws3 z|6+SDi>j|V^-#Bae|LU!`1ept0&y=lMBL~MOi5p4CtfXe58gV~*F)&zIR%R4mxnR| z&Yh?r#@W0TbeP~1*q8udOk71Cx@zu1l^R$IJH^1hI4NTc72@FpWKx@3<*v!5OEu-7G()($gXXJ$kx_SJM z7q`^9`m(d`ldqF`*ksi)8nSn&TOiKSTo{=Y^@K2n4{O-l6Qcqr4FWOGo#0R|*N{@s zDp{1+@v4I83oq=Mnz5|mUB#9F2#OU{MIe`nzO<{ZIXOvct;+ z4f_4(?>~7qZsK~!_kYd&9}|fd@eOf8I*ydJBt_mXqK1g+RhhN(BI6>3b(rlS0lPLt z*@`|m4|fg6%sA{ssgq;nlG8)l5CzZoIR?KW}oSZ_To5qJns_3ZC^;7tms# vS=%UGd8m18BH^ra{<28c|0Taaet!P|GjB-c3vRlD00000NkvXXu0mjfVU?8t diff --git a/desktopApp/src/desktopMain/resources/textures/texture_slate.png b/desktopApp/src/desktopMain/resources/textures/texture_slate.png deleted file mode 100644 index 9fddee646a4cabf8332d4ed11d8ffac62fc64703..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 81533 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

Metadata summary

", enriched.description) - assertEquals("Computing Notes", enriched.seriesName) - assertEquals(2.0, enriched.seriesIndex) - assertEquals("Direct EPUB", enriched.originalTitle) - assertEquals("Ada Lovelace", enriched.originalAuthor) - assertEquals("Computing Notes", enriched.originalSeriesName) - assertEquals(2.0, enriched.originalSeriesIndex) - assertEquals("

Metadata summary

", enriched.originalDescription) - assertEquals(epub.lastModified(), enriched.fileContentModifiedTimestamp) - assertTrue(enriched.folderTextMetadataParsed) - assertTrue(File(assertNotNull(enriched.coverImagePath)).isFile) - assertEquals(1, result.stats.updatedBooks) - assertEquals(1, result.stats.coversUpdated) - } - - @Test - fun `opened epub gets embedded cover`() = withCoverCacheDir { tempDir -> - val epub = File(tempDir, "opened.epub") - writeEpub( - target = epub, - opf = """ - - - Opened EPUB - Mary Shelley - - - - - - - """.trimIndent() - ) - val book = bookFor(epub, FileType.EPUB, title = null) - - val enriched = DesktopFolderMetadataExtractor.enrichOpenedBook(book) - - assertEquals("Opened EPUB", enriched.title) - assertEquals("Mary Shelley", enriched.author) - assertTrue(enriched.folderTextMetadataParsed) - assertTrue(File(assertNotNull(enriched.coverImagePath)).isFile) - } - - @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/dueattendant149/bookreader/reader/desktop/DesktopLibraryDatabaseTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLibraryDatabaseTest.kt deleted file mode 100644 index 8b2d450..0000000 --- a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLibraryDatabaseTest.kt +++ /dev/null @@ -1,55 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import org.dueattendant149.bookreader.shared.SharedLibrarySnapshot -import org.dueattendant149.bookreader.shared.SharedLibrarySnapshotJson -import java.nio.file.Files -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -class DesktopLibraryDatabaseTest { - @Test - fun `save writes readable library and backup snapshots`() { - val databaseFile = Files.createTempDirectory("reader-library-db") - .resolve("library.json") - .toFile() - val database = DesktopLibraryDatabase(databaseFile) - val snapshot = SharedLibrarySnapshot( - recentFilesLimit = 37, - openTabIds = listOf("book-a"), - activeTabBookId = "book-a" - ) - - database.save(snapshot) - - val loaded = database.load() - assertEquals(37, loaded.recentFilesLimit) - assertEquals(listOf("book-a"), loaded.openTabIds) - assertEquals("book-a", loaded.activeTabBookId) - assertTrue(databaseFile.isFile) - assertTrue(databaseFile.parentFile.resolve("library.json.bak").isFile) - } - - @Test - fun `load falls back to backup when primary library is corrupt`() { - val databaseFile = Files.createTempDirectory("reader-library-db-corrupt") - .resolve("library.json") - .toFile() - val backupSnapshot = SharedLibrarySnapshot( - recentFilesLimit = 19, - openTabIds = listOf("backup-book"), - activeTabBookId = "backup-book" - ) - databaseFile.parentFile.mkdirs() - databaseFile.writeText("""{"books":[""") - databaseFile.parentFile - .resolve("library.json.bak") - .writeText(SharedLibrarySnapshotJson.encode(backupSnapshot)) - - val loaded = DesktopLibraryDatabase(databaseFile).load() - - assertEquals(19, loaded.recentFilesLimit) - assertEquals(listOf("backup-book"), loaded.openTabIds) - assertEquals("backup-book", loaded.activeTabBookId) - } -} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLocalFolderSyncTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLocalFolderSyncTest.kt deleted file mode 100644 index dd60247..0000000 --- a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLocalFolderSyncTest.kt +++ /dev/null @@ -1,171 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import org.dueattendant149.bookreader.shared.BookItem -import org.dueattendant149.bookreader.shared.FileType -import org.dueattendant149.bookreader.shared.LOCAL_FOLDER_SYNC_DATA_DIR -import org.dueattendant149.bookreader.shared.SharedFolderBookMetadata -import org.dueattendant149.bookreader.shared.SharedReaderScreenState -import org.dueattendant149.bookreader.shared.SyncedFolder -import java.io.File -import java.nio.file.Files -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull -import kotlin.test.assertTrue - -class DesktopLocalFolderSyncTest { - @Test - fun `target folder sync imports files before desktop metadata extraction`() { - val root = Files.createTempDirectory("reader-desktop-folder-sync").toFile() - try { - val bookFile = File(root, "Notes.txt").apply { writeText("Notes") } - - val result = DesktopLocalFolderSync.sync( - state = SharedReaderScreenState(), - shelfRefs = emptyList(), - targetFolder = root, - nowMillis = 3_000L, - extractMetadata = false - ) - - val syncedBook = result.state.rawLibraryBooks.single() - assertEquals("local_Notes.txt", syncedBook.id) - assertEquals(bookFile.absolutePath, syncedBook.path) - assertEquals(root.absolutePath, syncedBook.sourceFolder) - assertEquals(listOf(root.absolutePath), result.processedFolderUris) - assertEquals(1, result.state.syncedFolders.size) - assertEquals(1, result.stats.newBooks) - assertEquals(0, result.metadataStats.updatedBooks) - assertNull(syncedBook.coverImagePath) - } finally { - root.deleteRecursively() - } - } - - @Test - fun `metadata-only sync imports sidecar metadata without scanning physical files`() { - val root = Files.createTempDirectory("reader-desktop-folder-sync").toFile() - try { - File(root, "New.pdf").writeText("%PDF") - val existingFile = File(root, "Existing.pdf") - val existingId = "local_Existing.pdf" - writeMetadataSidecar( - root = root, - metadata = metadata( - id = existingId, - title = "Remote Title", - progress = 72f, - modified = 2_000L - ) - ) - - val existingBook = BookItem( - id = existingId, - path = existingFile.absolutePath, - type = FileType.PDF, - displayName = existingFile.name, - timestamp = 100L, - title = "Local Title", - progressPercentage = 5f, - sourceFolder = root.absolutePath - ) - - val result = DesktopLocalFolderSync.sync( - state = SharedReaderScreenState( - rawLibraryBooks = listOf(existingBook), - syncedFolders = listOf(syncedFolder(root)) - ), - shelfRefs = emptyList(), - nowMillis = 3_000L, - metadataOnly = true - ) - - assertEquals(1, result.state.rawLibraryBooks.size) - val syncedBook = result.state.rawLibraryBooks.single() - assertEquals(existingId, syncedBook.id) - assertEquals("Local Title", syncedBook.title) - assertEquals(72f, syncedBook.progressPercentage) - assertNull(result.state.rawLibraryBooks.firstOrNull { it.id == "local_New.pdf" }) - assertEquals(0, result.stats.scannedFiles) - assertEquals(0, result.stats.newBooks) - assertEquals(0, result.stats.removedBooks) - assertEquals(1, result.stats.remoteMetadataUpdates) - assertTrue(result.processedFolderUris.contains(root.absolutePath)) - } finally { - root.deleteRecursively() - } - } - - @Test - fun `disabled folder is not scanned or written`() { - val root = Files.createTempDirectory("reader-desktop-folder-sync").toFile() - try { - File(root, "Notes.txt").writeText("Notes") - val existingBook = BookItem( - id = "local_Existing.pdf", - path = File(root, "Existing.pdf").absolutePath, - type = FileType.PDF, - displayName = "Existing.pdf", - timestamp = 100L, - progressPercentage = 50f, - sourceFolder = root.absolutePath - ) - - val result = DesktopLocalFolderSync.sync( - state = SharedReaderScreenState( - rawLibraryBooks = listOf(existingBook), - syncedFolders = listOf(syncedFolder(root).copy(localSyncEnabled = false)) - ), - shelfRefs = emptyList(), - nowMillis = 3_000L - ) - - assertEquals(listOf(existingBook), result.state.rawLibraryBooks) - assertTrue(result.processedFolderUris.isEmpty()) - assertEquals(0, result.stats.newBooks) - assertTrue(!File(root, LOCAL_FOLDER_SYNC_DATA_DIR).exists()) - } finally { - root.deleteRecursively() - } - } - - private fun syncedFolder(root: File): SyncedFolder { - return SyncedFolder( - uriString = root.absolutePath, - name = root.name, - lastScanTime = 0L, - allowedFileTypes = setOf(FileType.PDF) - ) - } - - private fun writeMetadataSidecar(root: File, metadata: SharedFolderBookMetadata) { - val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR).apply { mkdirs() } - File(syncDir, ".${metadata.bookId}.json").writeText(metadata.toJsonString()) - } - - private fun metadata( - id: String, - title: String, - progress: Float, - modified: Long - ): SharedFolderBookMetadata { - return SharedFolderBookMetadata( - bookId = id, - title = title, - author = null, - displayName = "Existing.pdf", - type = FileType.PDF.name, - lastChapterIndex = null, - lastPage = null, - lastPositionCfi = null, - progressPercentage = progress, - isRecent = true, - lastModifiedTimestamp = modified, - bookmarksJson = null, - locatorBlockIndex = null, - locatorCharOffset = null, - customName = null, - highlightsJson = null - ) - } -} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopOpdsRepositoryTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopOpdsRepositoryTest.kt deleted file mode 100644 index 4ea13be..0000000 --- a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopOpdsRepositoryTest.kt +++ /dev/null @@ -1,118 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import java.io.File -import java.nio.file.Files -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -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) - } - - @Test - fun `desktop opds http blocks before network in offline flavor`() { - withSystemProperty(DesktopFlavorProperty, DesktopFlavorOssOffline) { - assertFailsWith { - DesktopOpdsHttp.fetchString("https://example.org/opds", null, null) - } - } - } - - @Test - fun `desktop opds http creates basic authorization header for challenged catalogs`() { - assertEquals( - "Basic dXNlcjpwYXNz", - DesktopOpdsHttp.authorizationHeaderForChallenge( - challenge = "Basic realm=\"Catalog\"", - url = "https://example.org/opds", - username = "user", - password = "pass" - ) - ) - } - - @Test - fun `desktop opds http creates digest authorization header for challenged catalogs`() { - assertEquals( - "Digest username=\"Mufasa\", realm=\"testrealm@host.com\", nonce=\"abcdef\", " + - "uri=\"/dir/index.atom?x=1\", response=\"ca833912ad1f4339630e23476d538d67\", " + - "qop=auth, nc=00000001, cnonce=\"0a4f113b\", opaque=\"xyz\"", - DesktopOpdsHttp.authorizationHeaderForChallenge( - challenge = "Digest realm=\"testrealm@host.com\", nonce=\"abcdef\", qop=\"auth\", opaque=\"xyz\"", - url = "https://example.org/dir/index.atom?x=1", - username = "Mufasa", - password = "Circle Of Life", - cnonce = "0a4f113b", - nonceCount = "00000001" - ) - ) - } - - private fun DesktopOpdsRepository.addCatalogForTest( - title: String, - url: String, - username: String?, - password: String? - ) { - saveCatalogs( - org.dueattendant149.bookreader.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() - } - } - - 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/dueattendant149/bookreader/reader/desktop/DesktopPaidAiUsageTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPaidAiUsageTest.kt deleted file mode 100644 index 28541ab..0000000 --- a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPaidAiUsageTest.kt +++ /dev/null @@ -1,15 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import kotlin.test.Test -import kotlin.test.assertEquals - -class DesktopPaidAiUsageTest { - @Test - fun `desktop paid AI usage applies an optimistic integer credit decrement`() { - assertEquals(9, desktopCreditsAfterPaidAiUsage(currentCredits = 10, cost = 1.0)) - assertEquals(7, desktopCreditsAfterPaidAiUsage(currentCredits = 10, cost = 2.2)) - assertEquals(10, desktopCreditsAfterPaidAiUsage(currentCredits = 10, cost = 0.0)) - assertEquals(10, desktopCreditsAfterPaidAiUsage(currentCredits = 10, cost = null)) - assertEquals(0, desktopCreditsAfterPaidAiUsage(currentCredits = 1, cost = 4.0)) - } -} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfFileActionsTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfFileActionsTest.kt deleted file mode 100644 index cc7376e..0000000 --- a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfFileActionsTest.kt +++ /dev/null @@ -1,138 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import androidx.compose.ui.text.AnnotatedString -import org.dueattendant149.bookreader.shared.pdf.PdfAnnotationKind -import org.dueattendant149.bookreader.shared.pdf.PdfInkTool -import org.dueattendant149.bookreader.shared.pdf.PdfPageBounds -import org.dueattendant149.bookreader.shared.pdf.PdfPagePoint -import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotation -import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichPageLayout -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class DesktopPdfFileActionsTest { - - @Test - fun `desktop pdf suggested filename follows android suffix format`() { - assertEquals( - "My_PDF_annotated_1234.pdf", - desktopSuggestedPdfFilename("My PDF.pdf", isAnnotated = true, shortId = "1234") - ) - assertEquals( - "My_PDF_1234.pdf", - desktopSuggestedPdfFilename("My PDF.pdf", isAnnotated = false, shortId = "1234") - ) - } - - @Test - fun `desktop pdf export choice waits for sidecars before defaulting to original`() { - assertTrue( - shouldShowDesktopPdfAnnotationExportChoice( - sidecarsReady = false, - annotations = emptyList(), - richTextPageLayouts = emptyList() - ) - ) - assertFalse( - shouldShowDesktopPdfAnnotationExportChoice( - sidecarsReady = true, - annotations = emptyList(), - richTextPageLayouts = emptyList() - ) - ) - } - - @Test - fun `desktop pdf export choice appears for exportable annotations`() { - val ink = SharedPdfAnnotation( - id = "ink", - pageIndex = 0, - kind = PdfAnnotationKind.INK, - tool = PdfInkTool.PEN, - points = listOf(PdfPagePoint(0.1f, 0.1f), PdfPagePoint(0.2f, 0.2f)), - colorArgb = 0xFF000000.toInt() - ) - val highlight = SharedPdfAnnotation( - id = "highlight", - pageIndex = 0, - kind = PdfAnnotationKind.HIGHLIGHT, - bounds = PdfPageBounds(0.1f, 0.1f, 0.3f, 0.2f), - colorArgb = 0x66FFFF00 - ) - - assertTrue( - shouldShowDesktopPdfAnnotationExportChoice( - sidecarsReady = true, - annotations = listOf(ink, highlight), - richTextPageLayouts = emptyList() - ) - ) - } - - @Test - fun `desktop pdf export choice ignores non-exportable ink by itself`() { - val eraser = SharedPdfAnnotation( - id = "eraser", - pageIndex = 0, - kind = PdfAnnotationKind.INK, - tool = PdfInkTool.ERASER, - points = listOf(PdfPagePoint(0.1f, 0.1f), PdfPagePoint(0.2f, 0.2f)), - colorArgb = 0x00000000 - ) - - assertFalse( - shouldShowDesktopPdfAnnotationExportChoice( - sidecarsReady = true, - annotations = listOf(eraser), - richTextPageLayouts = emptyList() - ) - ) - } - - @Test - fun `desktop pdf export choice appears for recomputable highlight ranges`() { - val highlight = SharedPdfAnnotation( - id = "highlight-range", - pageIndex = 0, - kind = PdfAnnotationKind.HIGHLIGHT, - colorArgb = 0x66FFFF00, - rangeStartIndex = 4, - rangeEndIndex = 12 - ) - - assertTrue( - shouldShowDesktopPdfAnnotationExportChoice( - sidecarsReady = true, - annotations = listOf(highlight), - richTextPageLayouts = emptyList() - ) - ) - } - - @Test - fun `desktop pdf export choice appears for rich text layouts`() { - val richLayout = SharedPdfRichPageLayout( - pageIndex = 0, - visibleText = AnnotatedString("Margin note"), - globalStartIndex = 0, - globalEndIndex = 11, - pageHeightPx = 1200f - ) - - assertTrue( - shouldShowDesktopPdfAnnotationExportChoice( - sidecarsReady = true, - annotations = emptyList(), - richTextPageLayouts = listOf(richLayout) - ) - ) - } - - @Test - fun `desktop pdf password errors can be detected through wrappers`() { - assertTrue(DesktopPdfPasswordException("locked.pdf").isDesktopPdfPasswordException()) - assertTrue(RuntimeException(DesktopPdfPasswordException("locked.pdf")).isDesktopPdfPasswordException()) - } -} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfNavigationSidebarTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfNavigationSidebarTest.kt deleted file mode 100644 index 8d7dc06..0000000 --- a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfNavigationSidebarTest.kt +++ /dev/null @@ -1,56 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import org.dueattendant149.bookreader.shared.pdf.PdfAnnotationKind -import org.dueattendant149.bookreader.shared.pdf.PdfInkTool -import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotation -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -class DesktopPdfNavigationSidebarTest { - @Test - fun `sidebar highlights exclude ink and text annotations`() { - val result = desktopPdfSidebarHighlights( - listOf( - annotation(id = "ink", pageIndex = 0, kind = PdfAnnotationKind.INK, createdAt = 1L), - annotation(id = "later-highlight", pageIndex = 2, kind = PdfAnnotationKind.HIGHLIGHT, createdAt = 4L), - annotation(id = "text", pageIndex = 1, kind = PdfAnnotationKind.TEXT, createdAt = 1L), - annotation( - id = "first-same-page-highlight", - pageIndex = 1, - kind = PdfAnnotationKind.HIGHLIGHT, - createdAt = 3L - ), - annotation( - id = "second-same-page-highlight", - pageIndex = 1, - kind = PdfAnnotationKind.HIGHLIGHT, - createdAt = 2L - ) - ) - ) - - assertEquals( - listOf("first-same-page-highlight", "second-same-page-highlight", "later-highlight"), - result.map { it.id } - ) - assertTrue(result.all { it.kind == PdfAnnotationKind.HIGHLIGHT }) - } - - private fun annotation( - id: String, - pageIndex: Int, - kind: PdfAnnotationKind, - createdAt: Long - ): SharedPdfAnnotation { - return SharedPdfAnnotation( - id = id, - pageIndex = pageIndex, - kind = kind, - tool = if (kind == PdfAnnotationKind.TEXT) PdfInkTool.TEXT else PdfInkTool.HIGHLIGHTER, - text = "selected text", - colorArgb = 0x55FFEB3B, - createdAt = createdAt - ) - } -} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfReflowTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfReflowTest.kt deleted file mode 100644 index 7339f3d..0000000 --- a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfReflowTest.kt +++ /dev/null @@ -1,93 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import org.dueattendant149.bookreader.shared.BookItem -import org.dueattendant149.bookreader.shared.FileType -import org.dueattendant149.bookreader.shared.SharedLibraryStateProjector -import org.dueattendant149.bookreader.shared.SharedReaderScreenState -import org.dueattendant149.bookreader.shared.ui.toNonReaderLibraryOrganizationModel -import java.io.File -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -class DesktopPdfReflowTest { - @Test - fun `desktop reflow ids and labels match Android text view convention`() { - assertEquals("abc_reflow", desktopPdfReflowBookId("abc")) - assertTrue(isDesktopPdfReflowBookId("abc_reflow")) - assertEquals("Source (Text View)", desktopPdfReflowDisplayName("Source")) - assertEquals("Source (Reflow)", desktopPdfReflowTitle("Source")) - assertEquals("Generated", desktopPdfReflowGeneratedAuthor()) - } - - @Test - fun `desktop reflow filename is safe for path-like book ids`() { - val fileName = desktopPdfReflowFileName("C:/Books/My Source.pdf", "My Source") - - assertEquals("C__Books_My_Source.pdf_reflow.html", fileName) - } - - @Test - fun `desktop reflow book item maps generated html into text reader format`() { - val source = BookItem( - id = "pdf-id", - path = "C:/Books/source.pdf", - type = FileType.PDF, - displayName = "source.pdf", - timestamp = 1L, - title = "Source" - ) - val generatedFile = File("build/test-tmp/source_reflow.html") - - val item = desktopPdfReflowBookItem( - sourceBook = source, - generatedFile = generatedFile, - nowMillis = 42L, - initialPageIndex = 7 - ) - - assertEquals("pdf-id_reflow", item.id) - assertEquals(FileType.HTML, item.type) - assertEquals(generatedFile.absolutePath, item.path) - assertEquals("Source (Text View)", item.displayName) - assertEquals("Source (Reflow)", item.title) - assertEquals("Generated", item.author) - assertEquals(42L, item.timestamp) - assertEquals(7, item.lastPageIndex) - assertTrue(item.isRecent) - } - - @Test - fun `desktop library projection hides generated reflow books but keeps tabs open`() { - val source = BookItem( - id = "pdf-id", - path = "C:/Books/source.pdf", - type = FileType.PDF, - displayName = "source.pdf", - timestamp = 1L, - title = "Source" - ) - val reflow = desktopPdfReflowBookItem( - sourceBook = source, - generatedFile = File("build/test-tmp/source_reflow.html"), - nowMillis = 2L, - initialPageIndex = 3 - ) - val projected = SharedLibraryStateProjector().projectDesktopLibraryState( - state = SharedReaderScreenState( - rawLibraryBooks = listOf(source, reflow), - openTabIds = listOf(reflow.id), - activeTabBookId = reflow.id - ), - shelfRecords = emptyList(), - shelfRefs = emptyList() - ) - - assertEquals(listOf(source.id), projected.libraryBooks.map { it.id }) - assertTrue(projected.rawLibraryBooks.any { it.id == reflow.id }) - assertTrue(projected.recentBooks.none { it.id == reflow.id }) - assertEquals(1, projected.toNonReaderLibraryOrganizationModel().allBooksCount) - assertEquals(listOf(reflow.id), projected.openTabIds) - assertEquals(reflow.id, projected.activeTabBookId) - } -} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfScrubbingTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfScrubbingTest.kt deleted file mode 100644 index 333f4bf..0000000 --- a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfScrubbingTest.kt +++ /dev/null @@ -1,60 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import org.dueattendant149.bookreader.shared.PdfDisplayMode -import org.dueattendant149.bookreader.shared.reader.ReaderPageSpreadMode -import org.dueattendant149.bookreader.shared.reader.ReaderSettings -import kotlin.test.Test -import kotlin.test.assertEquals - -class DesktopPdfScrubbingTest { - @Test - fun `scrub target clamps to valid page range`() { - val settings = ReaderSettings() - - assertEquals( - 0, - desktopPdfPageScrubTarget( - value = -10f, - pageCount = 6, - displayMode = PdfDisplayMode.VERTICAL_SCROLL, - settings = settings - ) - ) - assertEquals( - 5, - desktopPdfPageScrubTarget( - value = 99f, - pageCount = 6, - displayMode = PdfDisplayMode.VERTICAL_SCROLL, - settings = settings - ) - ) - } - - @Test - fun `paginated scrub target normalizes to spread start`() { - val settings = ReaderSettings(pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE) - - assertEquals( - 2, - desktopPdfPageScrubTarget( - value = 3f, - pageCount = 8, - displayMode = PdfDisplayMode.PAGINATION, - settings = settings - ) - ) - } - - @Test - fun `scrub commit prefers preview before page state catches up`() { - assertEquals( - 7, - desktopPdfPageScrubCommitTarget( - previewPage = 7, - currentPage = 2, - pageCount = 10 - ) - ) - } -} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfSidecarsTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfSidecarsTest.kt deleted file mode 100644 index c47a802..0000000 --- a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfSidecarsTest.kt +++ /dev/null @@ -1,16 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import kotlin.test.Test -import kotlin.test.assertNotEquals -import kotlin.test.assertTrue - -class DesktopPdfSidecarsTest { - @Test - fun `pdf sidecar keys avoid String hashCode collisions`() { - val first = desktopPdfDocumentKey("C:/Books/Aa.pdf") - val second = desktopPdfDocumentKey("C:/Books/BB.pdf") - - assertTrue("C:/Books/Aa.pdf".hashCode() == "C:/Books/BB.pdf".hashCode()) - assertNotEquals(first, second) - } -} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfTextHighlightStateTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfTextHighlightStateTest.kt deleted file mode 100644 index 19562e0..0000000 --- a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfTextHighlightStateTest.kt +++ /dev/null @@ -1,69 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import org.dueattendant149.bookreader.shared.pdf.PdfAnnotationKind -import org.dueattendant149.bookreader.shared.pdf.PdfInkTool -import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotation -import org.dueattendant149.bookreader.shared.pdf.SharedPdfReaderAction -import org.dueattendant149.bookreader.shared.pdf.SharedPdfReaderState -import org.dueattendant149.bookreader.shared.pdf.reduce -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull -import kotlin.test.assertTrue - -class DesktopPdfTextHighlightStateTest { - @Test - fun `text selection highlight keeps chosen text selection mode after creation`() { - val annotation = textSelectionHighlight() - val state = SharedPdfReaderState.initial(pageCount = 1) - .reduce(SharedPdfReaderAction.TextSelectionModeChanged(true)) - - val next = state.withDesktopPdfTextSelectionHighlightAdded(annotation) - - assertEquals(listOf(annotation), next.annotations) - assertTrue(next.isTextSelectionMode) - assertEquals(PdfInkTool.NONE, next.selectedTool) - assertNull(next.selectedAnnotationId) - } - - @Test - fun `dismissing selected text highlight sheet keeps chosen text selection mode`() { - val annotation = textSelectionHighlight() - val state = SharedPdfReaderState.initial(pageCount = 1) - .reduce(SharedPdfReaderAction.TextSelectionModeChanged(true)) - .copy(annotations = listOf(annotation), selectedAnnotationId = annotation.id) - - val next = state.withDesktopPdfTextHighlightSheetDismissed() - - assertTrue(next.isTextSelectionMode) - assertEquals(PdfInkTool.NONE, next.selectedTool) - assertNull(next.selectedAnnotationId) - } - - @Test - fun `dismissing non text highlight annotation keeps text selection mode unchanged`() { - val annotation = textSelectionHighlight().copy(rangeStartIndex = null, rangeEndIndex = null) - val state = SharedPdfReaderState.initial(pageCount = 1) - .reduce(SharedPdfReaderAction.TextSelectionModeChanged(true)) - .copy(annotations = listOf(annotation), selectedAnnotationId = annotation.id) - - val next = state.withDesktopPdfTextHighlightSheetDismissed() - - assertTrue(next.isTextSelectionMode) - assertNull(next.selectedAnnotationId) - } - - private fun textSelectionHighlight(): SharedPdfAnnotation { - return SharedPdfAnnotation( - id = "highlight-1", - pageIndex = 0, - kind = PdfAnnotationKind.HIGHLIGHT, - tool = PdfInkTool.HIGHLIGHTER, - text = "selected text", - colorArgb = 0x55FFEB3B, - rangeStartIndex = 1, - rangeEndIndex = 12, - createdAt = 1L - ) - } -} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfThemeTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfThemeTest.kt deleted file mode 100644 index 09648a7..0000000 --- a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfThemeTest.kt +++ /dev/null @@ -1,84 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import org.dueattendant149.bookreader.shared.PdfDisplayMode -import org.dueattendant149.bookreader.shared.ReaderTheme -import kotlin.test.Test -import kotlin.test.assertEquals - -class DesktopPdfThemeTest { - @Test - fun `desktop pdf defaults to paginated display mode`() { - assertEquals(PdfDisplayMode.PAGINATION, DesktopDefaultPdfDisplayMode) - assertEquals(8.dp, DesktopDefaultPdfVerticalPageGap) - assertEquals(18.dp, DesktopDefaultPdfSpreadPageGap) - } - - @Test - fun `page background follows android pdf theme defaults`() { - val noTheme = ReaderTheme("no_theme", "No Theme", Color.Unspecified, Color.Unspecified, false) - val reverse = ReaderTheme("reverse", "Reverse", Color.Black, Color.White, true) - val sepia = ReaderTheme("sepia", "Sepia", Color(0xFFFBF0D9), Color(0xFF5F4B32), false) - - assertEquals(Color.White, desktopPdfPageBackgroundColor(noTheme, PdfDisplayMode.VERTICAL_SCROLL)) - assertEquals(Color.Black, desktopPdfPageBackgroundColor(noTheme, PdfDisplayMode.PAGINATION)) - assertEquals(Color.Black, desktopPdfPageBackgroundColor(reverse, PdfDisplayMode.VERTICAL_SCROLL)) - assertEquals(Color.White, desktopPdfPageBackgroundColor(reverse, PdfDisplayMode.PAGINATION)) - assertEquals(Color(0xFFFBF0D9), desktopPdfPageBackgroundColor(sepia, PdfDisplayMode.VERTICAL_SCROLL)) - } - - @Test - fun `vertical viewport uses app gap color only when page gaps are visible`() { - val pageBackground = Color.White - val gapBackground = Color(0xFFE2E2E2) - - assertEquals( - gapBackground, - desktopPdfVerticalViewportBackgroundColor( - pageBackgroundColor = pageBackground, - gapBackgroundColor = gapBackground, - isPageGapVisible = true - ) - ) - assertEquals( - pageBackground, - desktopPdfVerticalViewportBackgroundColor( - pageBackgroundColor = pageBackground, - gapBackgroundColor = gapBackground, - isPageGapVisible = false - ) - ) - } - - @Test - fun `pagination viewport uses app theme color outside pages`() { - val pageBackground = Color.Black - val appBackground = Color(0xFFE2E2E2) - - assertEquals( - appBackground, - desktopPdfViewportBackgroundColor( - displayMode = PdfDisplayMode.PAGINATION, - pageBackgroundColor = pageBackground, - appBackgroundColor = appBackground, - isVerticalPageGapVisible = false - ) - ) - assertEquals( - appBackground, - desktopPdfViewportBackgroundColor( - displayMode = PdfDisplayMode.PAGINATION, - pageBackgroundColor = pageBackground, - appBackgroundColor = appBackground, - isVerticalPageGapVisible = true - ) - ) - } - - @Test - fun `spread page gap follows pdf page gap visibility setting`() { - assertEquals(18.dp, desktopPdfSpreadPageGapDp(isPageGapVisible = true)) - assertEquals(0.dp, desktopPdfSpreadPageGapDp(isPageGapVisible = false)) - } -} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPlatformPathsTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPlatformPathsTest.kt deleted file mode 100644 index 95f55de..0000000 --- a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPlatformPathsTest.kt +++ /dev/null @@ -1,70 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import kotlin.test.Test -import kotlin.test.assertEquals - -class DesktopPlatformPathsTest { - @Test - fun `desktop platform detects linux x64 resource names`() { - val platform = currentDesktopPlatform(osName = "Linux", osArch = "amd64") - - assertEquals(DesktopOperatingSystem.LINUX, platform.os) - assertEquals(DesktopArchitecture.X64, platform.architecture) - assertEquals("linux-x64-v8", platform.pdfiumDirectoryName) - assertEquals("lib", platform.pdfiumLibraryDirectoryName) - assertEquals("libpdfium.so", platform.pdfiumLibraryFileName) - } - - @Test - fun `desktop platform keeps existing windows resource names`() { - val platform = currentDesktopPlatform(osName = "Windows 11", osArch = "amd64") - - assertEquals(DesktopOperatingSystem.WINDOWS, platform.os) - assertEquals(DesktopArchitecture.X64, platform.architecture) - assertEquals("win-x64-v8", platform.pdfiumDirectoryName) - assertEquals("bin", platform.pdfiumLibraryDirectoryName) - assertEquals("pdfium.dll", platform.pdfiumLibraryFileName) - } - - @Test - fun `linux user directories follow xdg environment variables`() { - val platform = DesktopPlatform(DesktopOperatingSystem.LINUX, DesktopArchitecture.X64) - val env = mapOf( - "XDG_DATA_HOME" to "/tmp/xdg-data", - "XDG_CONFIG_HOME" to "/tmp/xdg-config", - "XDG_CACHE_HOME" to "/tmp/xdg-cache" - ) - - assertEquals("/tmp/xdg-data/episteme", desktopUserDataRoot(platform, env::get, "/home/reader").portablePath()) - assertEquals("/tmp/xdg-config/episteme", desktopUserConfigRoot(platform, env::get, "/home/reader").portablePath()) - assertEquals("/tmp/xdg-cache/episteme", desktopUserCacheRoot(platform, env::get, "/home/reader").portablePath()) - } - - @Test - fun `linux user directories ignore relative xdg environment values`() { - val platform = DesktopPlatform(DesktopOperatingSystem.LINUX, DesktopArchitecture.X64) - val env = mapOf( - "XDG_DATA_HOME" to "relative-data", - "XDG_CONFIG_HOME" to "relative-config", - "XDG_CACHE_HOME" to "relative-cache" - ) - - assertEquals("/home/reader/.local/share/episteme", desktopUserDataRoot(platform, env::get, "/home/reader").portablePath()) - assertEquals("/home/reader/.config/episteme", desktopUserConfigRoot(platform, env::get, "/home/reader").portablePath()) - assertEquals("/home/reader/.cache/episteme", desktopUserCacheRoot(platform, env::get, "/home/reader").portablePath()) - } - - @Test - fun `windows user directories keep appdata compatible root`() { - val platform = DesktopPlatform(DesktopOperatingSystem.WINDOWS, DesktopArchitecture.X64) - val env = mapOf("APPDATA" to "C:/Users/reader/AppData/Roaming") - - assertEquals("C:/Users/reader/AppData/Roaming/Episteme", desktopUserDataRoot(platform, env::get, "C:/Users/reader").portablePath()) - assertEquals("C:/Users/reader/AppData/Roaming/Episteme", desktopUserConfigRoot(platform, env::get, "C:/Users/reader").portablePath()) - assertEquals("C:/Users/reader/AppData/Roaming/Episteme", desktopUserCacheRoot(platform, env::get, "C:/Users/reader").portablePath()) - } -} - -private fun java.io.File.portablePath(): String { - return path.replace('\\', '/') -} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPptxDocumentTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPptxDocumentTest.kt deleted file mode 100644 index 387ce4b..0000000 --- a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPptxDocumentTest.kt +++ /dev/null @@ -1,154 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import org.dueattendant149.bookreader.shared.pptx.SharedPptxDeckCache -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.assertNotNull -import kotlin.test.assertTrue - -class DesktopPptxDocumentTest { - @Test - fun `pptx document loads text links and renderable slides for pdf reader surface`() = withTempDir { dir -> - val pptx = File(dir, "slides.pptx") - writeMinimalPptx(pptx) - - val sharedDeck = SharedPptxDeckCache.load(pptx) - assertEquals(1, sharedDeck.slides.size) - assertTrue(sharedDeck.slides.single().text.contains("Hello PPTX")) - - val document = DesktopPdfium.loadPptx(pptx) - try { - assertEquals("PPTX", document.formatLabel) - assertEquals(1, document.pageCount) - assertEquals(720f, document.pageSizes.single().width) - assertEquals(540f, document.pageSizes.single().height) - - val textPage = document.textPageData(0) - assertTrue(textPage.text.contains("Hello PPTX")) - assertTrue(textPage.chars.isNotEmpty()) - assertNotNull(DesktopPdfium.charIndexAt(document, pageIndex = 0, normalizedX = 0.13f, normalizedY = 0.16f)) - assertTrue(DesktopPdfium.textRectsForRange(document, pageIndex = 0, startIndex = 0, endIndex = 4).isNotEmpty()) - - val link = DesktopPdfium.linkAt(document, pageIndex = 0, normalizedX = 0.3f, normalizedY = 0.2f) - assertEquals("https://example.com/slides", link?.uri) - - val image = DesktopPdfium.renderPageBufferedImage(document, pageIndex = 0, scale = 1f) - assertEquals(720, image.width) - assertEquals(540, image.height) - } finally { - document.close() - } - } - - private fun withTempDir(block: (File) -> Unit) { - val dir = Files.createTempDirectory("reader-desktop-pptx").toFile() - try { - block(dir) - } finally { - dir.deleteRecursively() - } - } - - private fun writeMinimalPptx(file: File) { - ZipOutputStream(file.outputStream()).use { zip -> - zip.writeEntry( - "[Content_Types].xml", - """ - - - - - - - """.trimIndent() - ) - zip.writeEntry( - "ppt/presentation.xml", - """ - - - - - - - """.trimIndent() - ) - zip.writeEntry( - "ppt/_rels/presentation.xml.rels", - """ - - - - """.trimIndent() - ) - zip.writeEntry( - "ppt/slides/slide1.xml", - """ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Hello PPTX - - - - - - - - """.trimIndent() - ) - zip.writeEntry( - "ppt/slides/_rels/slide1.xml.rels", - """ - - - - """.trimIndent() - ) - } - } - - private fun ZipOutputStream.writeEntry(name: String, contents: String) { - putNextEntry(ZipEntry(name)) - write(contents.toByteArray(Charsets.UTF_8)) - closeEntry() - } -} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderDefaultsTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderDefaultsTest.kt deleted file mode 100644 index 6e81891..0000000 --- a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderDefaultsTest.kt +++ /dev/null @@ -1,537 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.IntSize -import org.dueattendant149.bookreader.shared.BookItem -import org.dueattendant149.bookreader.shared.FileType -import org.dueattendant149.bookreader.shared.PdfDisplayMode -import org.dueattendant149.bookreader.shared.ReaderPlatform -import org.dueattendant149.bookreader.shared.SharedFileCapabilities -import org.dueattendant149.bookreader.shared.SharedLibrarySnapshot -import org.dueattendant149.bookreader.shared.pdf.PdfZoomSpec -import org.dueattendant149.bookreader.shared.reader.ReaderPageSpreadMode -import org.dueattendant149.bookreader.shared.reader.ReaderReadingMode -import org.dueattendant149.bookreader.shared.reader.ReaderSettings -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class DesktopReaderDefaultsTest { - - @Test - fun `desktop open book dialog accepts every shared desktop readable format`() { - assertEquals( - SharedFileCapabilities.readableTypesFor(ReaderPlatform.DESKTOP), - desktopBookFileTypesForDialog() - ) - assertTrue(FileType.PDF in desktopBookFileTypesForDialog()) - } - - @Test - fun `desktop uses global reader defaults when book has no local settings`() { - val defaults = ReaderSettings(fontSize = 23, readingMode = ReaderReadingMode.VERTICAL) - val book = bookItem("without-local") - - assertEquals(defaults, resolvedDesktopReaderSettings(book, defaults)) - } - - @Test - fun `desktop keeps local book reader settings ahead of global defaults`() { - val defaults = ReaderSettings(fontSize = 23, readingMode = ReaderReadingMode.VERTICAL) - val local = ReaderSettings(fontSize = 17, readingMode = ReaderReadingMode.PAGINATED, themeId = "sepia") - val book = bookItem("with-local").copy(readerSettings = local) - - assertEquals(local, resolvedDesktopReaderSettings(book, defaults)) - } - - @Test - fun `desktop library defaults migrate untouched reader defaults to two page pagination`() { - val migrated = SharedLibrarySnapshot().withDesktopDefaults() - - assertEquals(DesktopReaderDefaultsVersion, migrated.desktopReaderDefaultsVersion) - assertEquals(ReaderReadingMode.PAGINATED, migrated.readerDefaultSettings.readingMode) - assertEquals(ReaderPageSpreadMode.TWO_PAGE, migrated.readerDefaultSettings.pageSpreadMode) - assertEquals(ReaderReadingMode.PAGINATED, migrated.pdfReaderDefaultSettings.readingMode) - assertEquals(ReaderPageSpreadMode.TWO_PAGE, migrated.pdfReaderDefaultSettings.pageSpreadMode) - assertEquals("no_theme", migrated.pdfReaderDefaultSettings.themeId) - } - - @Test - fun `desktop reader settings engines are separated by shared reader surface`() { - assertEquals(DesktopReaderSettingsEngine.TEXT, FileType.EPUB.desktopReaderSettingsEngine()) - assertEquals(DesktopReaderSettingsEngine.TEXT, FileType.MOBI.desktopReaderSettingsEngine()) - assertEquals(DesktopReaderSettingsEngine.TEXT, FileType.DOCX.desktopReaderSettingsEngine()) - assertEquals(DesktopReaderSettingsEngine.PDF, FileType.PDF.desktopReaderSettingsEngine()) - assertEquals(DesktopReaderSettingsEngine.PDF, FileType.CBZ.desktopReaderSettingsEngine()) - assertEquals(DesktopReaderSettingsEngine.PDF, FileType.CBT.desktopReaderSettingsEngine()) - assertEquals(DesktopReaderSettingsEngine.PDF, FileType.PPTX.desktopReaderSettingsEngine()) - } - - @Test - fun `desktop engine settings update only matching reader family books`() { - val textSettings = ReaderSettings(themeId = "sepia", readingMode = ReaderReadingMode.PAGINATED) - val pdfSettings = ReaderSettings(themeId = "reverse", readingMode = ReaderReadingMode.PAGINATED) - val books = listOf( - bookItem("epub"), - bookItem("mobi").copy(path = "C:/Books/mobi.mobi", type = FileType.MOBI, displayName = "mobi.mobi"), - bookItem("pdf").copy(path = "C:/Books/pdf.pdf", type = FileType.PDF, displayName = "pdf.pdf") - ) - - val withTextDefaults = books.withDesktopReaderEngineSettings(DesktopReaderSettingsEngine.TEXT, textSettings) - assertEquals(textSettings, withTextDefaults[0].readerSettings) - assertEquals(textSettings, withTextDefaults[1].readerSettings) - assertEquals(null, withTextDefaults[2].readerSettings) - - val withPdfDefaults = withTextDefaults.withDesktopReaderEngineSettings(DesktopReaderSettingsEngine.PDF, pdfSettings) - assertEquals(textSettings, withPdfDefaults[0].readerSettings) - assertEquals(textSettings, withPdfDefaults[1].readerSettings) - assertEquals(pdfSettings, withPdfDefaults[2].readerSettings) - } - - @Test - fun `desktop pdf display mode is carried by pdf reader settings`() { - assertEquals(PdfDisplayMode.PAGINATION, DesktopDefaultPdfDisplayMode) - assertEquals(PdfDisplayMode.PAGINATION, DesktopDefaultPdfReaderSettings.toDesktopPdfDisplayMode()) - assertEquals( - PdfDisplayMode.VERTICAL_SCROLL, - ReaderSettings(readingMode = ReaderReadingMode.VERTICAL).toDesktopPdfDisplayMode() - ) - } - - @Test - fun `desktop pdf initial page is normalized before paginated spread display`() { - val spreadSettings = ReaderSettings( - readingMode = ReaderReadingMode.PAGINATED, - pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE - ) - - assertEquals( - 2, - desktopPdfInitialPageIndex( - requestedPageIndex = 3, - pageCount = 10, - displayMode = PdfDisplayMode.PAGINATION, - settings = spreadSettings - ) - ) - assertEquals( - 3, - desktopPdfInitialPageIndex( - requestedPageIndex = 3, - pageCount = 10, - displayMode = PdfDisplayMode.VERTICAL_SCROLL, - settings = spreadSettings - ) - ) - assertEquals( - 3, - desktopPdfInitialPageIndex( - requestedPageIndex = 3, - pageCount = 10, - displayMode = PdfDisplayMode.PAGINATION, - settings = spreadSettings.copy(pageSpreadMode = ReaderPageSpreadMode.SINGLE) - ) - ) - } - - @Test - fun `desktop pdf zoom allows deeper page magnification`() { - val sharedDefaultMax = PdfZoomSpec().max - val letterPageScale = DesktopPdfZoomSpec.safeRenderScale( - pageWidth = 612f, - pageHeight = 792f, - requestedScale = 6f - ) - - assertEquals(8f, DesktopPdfZoomSpec.max) - assertTrue(letterPageScale > sharedDefaultMax) - } - - @Test - fun `desktop pdf touchpad zoom factors zoom in and out`() { - val zoomSpec = PdfZoomSpec(min = 0.5f, max = 8f, default = 1f) - - assertTrue(desktopPdfScrollZoomFactor(-1f) > 1.1f) - assertTrue(desktopPdfScrollZoomFactor(1f) < 0.9f) - assertEquals(8f, desktopPdfZoomTarget(currentZoom = 7.8f, zoomSpec = zoomSpec, factor = 2f)) - assertEquals(0.5f, desktopPdfZoomTarget(currentZoom = 0.6f, zoomSpec = zoomSpec, factor = 0.1f)) - } - - @Test - fun `desktop pdf page navigation commits pending zoom preview position`() { - val preview = DesktopPdfZoomPreview( - baseZoom = 1f, - zoom = 2f, - anchor = Offset(100f, 80f), - displayMode = PdfDisplayMode.PAGINATION, - pageIndex = 0 - ) - val snapshot = desktopPdfNavigationZoomSnapshot( - preview = preview, - currentHorizontalScroll = 40, - currentVerticalScroll = 20 - ) ?: error("Expected navigation zoom snapshot") - - assertEquals(2f, snapshot.zoom) - assertEquals(180, snapshot.horizontalScroll) - assertEquals(120, snapshot.verticalScroll) - } - - @Test - fun `desktop paginated pdf page changes avoid high resolution first render`() { - assertEquals( - DesktopPdfPaginationFastFirstRenderMaxScale, - desktopPdfPaginationFirstRenderScale(requestedScale = 6f, hasPageRender = false) - ) - assertEquals( - 6f, - desktopPdfPaginationFirstRenderScale( - requestedScale = 6f, - hasPageRender = false, - isOpeningRender = true - ) - ) - assertEquals( - 6f, - desktopPdfPaginationFirstRenderScale(requestedScale = 6f, hasPageRender = true) - ) - assertEquals( - 1.25f, - desktopPdfPaginationFirstRenderScale(requestedScale = 1.25f, hasPageRender = false) - ) - assertEquals( - 0.75f, - desktopPdfPaginationFirstRenderScale(requestedScale = 0.75f, hasPageRender = false) - ) - } - - @Test - fun `desktop pdf only displays renders for the requested page`() { - assertTrue(desktopPdfRenderBelongsToPage(renderedPageIndex = 0, requestedPageIndex = 0)) - assertFalse(desktopPdfRenderBelongsToPage(renderedPageIndex = null, requestedPageIndex = 0)) - assertFalse(desktopPdfRenderBelongsToPage(renderedPageIndex = 1, requestedPageIndex = 0)) - } - - @Test - fun `desktop pdf render scale rerenders only for missing or lower quality renders`() { - assertTrue(desktopPdfRenderScaleNeedsUpgrade(renderedScale = null, requestedScale = 1f)) - assertTrue(desktopPdfRenderScaleNeedsUpgrade(renderedScale = 1f, requestedScale = 1.02f)) - assertTrue(desktopPdfRenderScaleNeedsUpgrade(renderedScale = Float.NaN, requestedScale = 1f)) - assertFalse(desktopPdfRenderScaleNeedsUpgrade(renderedScale = 1f, requestedScale = 1.005f)) - assertFalse(desktopPdfRenderScaleNeedsUpgrade(renderedScale = 2f, requestedScale = 1f)) - assertFalse(desktopPdfRenderScaleNeedsUpgrade(renderedScale = 1f, requestedScale = Float.NaN)) - } - - @Test - fun `desktop pdf spread zoom anchors to page under cursor`() { - val visiblePages = listOf(199, 200) - val pageRoots = mapOf( - 199 to Offset(424f, 30f), - 200 to Offset(972f, 30f) - ) - val pageSizes = mapOf( - 199 to IntSize(525, 693), - 200 to IntSize(525, 693) - ) - - assertEquals( - 200, - desktopPdfSpreadZoomAnchorPageIndex( - viewportRootOffset = Offset.Zero, - anchor = Offset(1048.75f, 465f), - visiblePageIndices = visiblePages, - pageRootOffsets = pageRoots, - pageSizes = pageSizes, - fallbackPageIndex = 199 - ) - ) - assertEquals( - 199, - desktopPdfSpreadZoomAnchorPageIndex( - viewportRootOffset = Offset.Zero, - anchor = Offset(500f, 465f), - visiblePageIndices = visiblePages, - pageRootOffsets = pageRoots, - pageSizes = pageSizes, - fallbackPageIndex = 199 - ) - ) - assertEquals( - 199, - desktopPdfSpreadZoomAnchorPageIndex( - viewportRootOffset = Offset.Zero, - anchor = null, - visiblePageIndices = visiblePages, - pageRootOffsets = pageRoots, - pageSizes = pageSizes, - fallbackPageIndex = 199 - ) - ) - val fittedSpread = desktopPdfSpreadLayoutPrediction( - viewportRootOffset = Offset.Zero, - viewportSize = IntSize(1920, 991), - visiblePageIndices = visiblePages, - pageCanvasSizes = mapOf( - 199 to IntSize(667, 881), - 200 to IntSize(667, 881) - ), - horizontalScroll = 0, - verticalScroll = 112, - paddingPx = 30f, - pageGapPx = 22.5f - ) ?: error("Expected fitted spread prediction") - assertEquals(0, fittedSpread.maxHorizontalScroll) - assertEquals(0, fittedSpread.maxVerticalScroll) - assertEquals(282f, fittedSpread.pageRootOffsets[199]?.x ?: -1f, 0.5f) - assertEquals(972f, fittedSpread.pageRootOffsets[200]?.x ?: -1f, 0.5f) - assertEquals(30f, fittedSpread.pageRootOffsets[200]?.y ?: -1f, 0.0001f) - - val scrollableSpread = desktopPdfSpreadLayoutPrediction( - viewportRootOffset = Offset.Zero, - viewportSize = IntSize(1920, 991), - visiblePageIndices = visiblePages, - pageCanvasSizes = mapOf( - 199 to IntSize(1371, 1810), - 200 to IntSize(1371, 1810) - ), - horizontalScroll = 696, - verticalScroll = 575, - paddingPx = 30f, - pageGapPx = 22.5f - ) ?: error("Expected scrollable spread prediction") - assertEquals(905, scrollableSpread.maxHorizontalScroll) - assertEquals(879, scrollableSpread.maxVerticalScroll) - assertEquals(-666f, scrollableSpread.pageRootOffsets[199]?.x ?: 0f, 0.5f) - assertEquals(728f, scrollableSpread.pageRootOffsets[200]?.x ?: 0f, 0.5f) - assertEquals(-545f, scrollableSpread.pageRootOffsets[200]?.y ?: 0f, 0.0001f) - } - - @Test - fun `desktop pdf zoom preview bridges committed anchored zoom`() { - val preview = DesktopPdfZoomPreview( - baseZoom = 1f, - zoom = 2f, - anchor = Offset(100f, 100f), - displayMode = PdfDisplayMode.PAGINATION, - pageIndex = 0, - viewportRootOffset = Offset.Zero, - pageRootOffset = Offset.Zero - ) - - assertTrue(desktopPdfZoomPreviewMatchesScale(preview, 1f)) - assertTrue(desktopPdfZoomPreviewMatchesScale(preview, 2f)) - assertFalse(desktopPdfZoomPreviewMatchesScale(preview, 1.5f)) - assertEquals( - Offset(-100f, -100f), - desktopPdfZoomCommitPreviewTranslation( - viewportRootOffset = Offset.Zero, - oldPageRootOffset = Offset.Zero, - currentAnchorPageRootOffset = Offset.Zero, - anchor = Offset(100f, 100f), - oldZoom = 1f, - newZoom = 2f, - currentZoom = 2f - ) - ) - assertEquals( - Offset.Zero, - desktopPdfZoomCommitPreviewTranslation( - viewportRootOffset = Offset.Zero, - oldPageRootOffset = Offset.Zero, - currentAnchorPageRootOffset = Offset(-100f, -100f), - anchor = Offset(100f, 100f), - oldZoom = 1f, - newZoom = 2f, - currentZoom = 2f - ) - ) - assertEquals( - null, - desktopPdfZoomCommitPreviewTranslation( - viewportRootOffset = Offset.Zero, - oldPageRootOffset = Offset.Zero, - currentAnchorPageRootOffset = Offset.Zero, - anchor = Offset(100f, 100f), - oldZoom = 1f, - newZoom = 2f, - currentZoom = 1f - ) - ) - assertEquals(0, desktopPdfReachableScrollDelta(currentScroll = 0, maxScroll = 0, requestedDelta = 100)) - assertEquals(0, desktopPdfReachableScrollDelta(currentScroll = 0, maxScroll = 200, requestedDelta = -40)) - assertEquals(-40, desktopPdfReachableScrollDelta(currentScroll = 80, maxScroll = 200, requestedDelta = -40)) - assertEquals( - Offset.Zero, - desktopPdfZoomCommitPreviewTranslation( - viewportRootOffset = Offset.Zero, - oldPageRootOffset = Offset.Zero, - currentAnchorPageRootOffset = Offset.Zero, - anchor = Offset(100f, 100f), - oldZoom = 1f, - newZoom = 2f, - currentZoom = 2f, - scrollBounds = DesktopPdfZoomScrollBounds( - currentHorizontalScroll = 0, - maxHorizontalScroll = 0, - currentVerticalScroll = 0, - maxVerticalScroll = 0 - ) - ) - ) - assertEquals( - Offset(-50f, -25f), - desktopPdfZoomCommitPreviewTranslation( - viewportRootOffset = Offset.Zero, - oldPageRootOffset = Offset.Zero, - currentAnchorPageRootOffset = Offset.Zero, - anchor = Offset(100f, 100f), - oldZoom = 1f, - newZoom = 2f, - currentZoom = 2f, - scrollBounds = DesktopPdfZoomScrollBounds( - currentHorizontalScroll = 0, - maxHorizontalScroll = 50, - currentVerticalScroll = 0, - maxVerticalScroll = 25 - ) - ) - ) - val pendingCommitBounds = desktopPdfZoomScrollBoundsWithCommitTargets( - preview = preview.copy( - commitTargetHorizontalScroll = 300, - commitTargetVerticalScroll = 300 - ), - currentHorizontalScroll = 0, - maxHorizontalScroll = 0, - currentVerticalScroll = 0, - maxVerticalScroll = 0 - ) - assertEquals(300, pendingCommitBounds.maxHorizontalScroll) - assertEquals(300, pendingCommitBounds.maxVerticalScroll) - assertEquals( - Offset(-100f, -100f), - desktopPdfZoomCommitPreviewTranslation( - viewportRootOffset = Offset.Zero, - oldPageRootOffset = Offset.Zero, - currentAnchorPageRootOffset = Offset.Zero, - anchor = Offset(100f, 100f), - oldZoom = 1f, - newZoom = 2f, - currentZoom = 2f, - scrollBounds = pendingCommitBounds - ) - ) - val fittingPagePrediction = desktopPdfSinglePageLayoutPrediction( - viewportRootOffset = Offset.Zero, - viewportSize = IntSize(1920, 991), - pageCanvasSize = IntSize(1216, 1605), - horizontalScroll = 0, - verticalScroll = 0, - paddingPx = 30f - ) ?: error("Expected fitting page prediction") - assertEquals(Offset(352f, 30f), fittingPagePrediction.rootOffset) - assertEquals(0, fittingPagePrediction.maxHorizontalScroll) - assertEquals(674, fittingPagePrediction.maxVerticalScroll) - - val oversizedPagePrediction = desktopPdfSinglePageLayoutPrediction( - viewportRootOffset = Offset.Zero, - viewportSize = IntSize(1920, 991), - pageCanvasSize = IntSize(2498, 3298), - horizontalScroll = 409, - verticalScroll = 1122, - paddingPx = 30f - ) ?: error("Expected oversized page prediction") - assertEquals(Offset(-379f, -1092f), oversizedPagePrediction.rootOffset) - assertEquals(638, oversizedPagePrediction.maxHorizontalScroll) - assertEquals(2367, oversizedPagePrediction.maxVerticalScroll) - } - - @Test - fun `desktop pdf anchored zoom keeps cursor content stable`() { - assertEquals( - 300, - desktopPdfAnchoredScrollTarget(currentScroll = 100, anchor = 100f, oldZoom = 1f, newZoom = 2f) - ) - assertEquals( - 25, - desktopPdfAnchoredScrollTarget(currentScroll = 150, anchor = 100f, oldZoom = 2f, newZoom = 1f) - ) - assertEquals( - 100, - desktopPdfAnchoredLazyItemScrollOffset(itemOffset = 0, anchor = 100f, oldZoom = 1f, newZoom = 2f) - ) - assertEquals( - 200, - desktopPdfAnchoredLazyItemScrollOffset(itemOffset = -50, anchor = 100f, oldZoom = 1f, newZoom = 2f) - ) - assertEquals( - IntOffset(100, 100), - desktopPdfAnchoredPageScrollDelta( - viewportRootOffset = Offset.Zero, - oldPageRootOffset = Offset.Zero, - currentPageRootOffset = Offset.Zero, - anchor = Offset(100f, 100f), - oldZoom = 1f, - newZoom = 2f - ) - ) - assertEquals( - IntOffset(0, 0), - desktopPdfAnchoredPageScrollDelta( - viewportRootOffset = Offset.Zero, - oldPageRootOffset = Offset.Zero, - currentPageRootOffset = Offset(-100f, -100f), - anchor = Offset(100f, 100f), - oldZoom = 1f, - newZoom = 2f - ) - ) - val offCenterPivot = desktopPdfZoomPreviewPivotFraction( - viewportRootOffset = Offset(20f, 30f), - pageRootOffset = Offset(120f, 230f), - anchor = Offset(250f, 450f), - pageCanvasSize = IntSize(500, 1000) - ) ?: error("Expected off-center pivot") - assertEquals(0.3f, offCenterPivot.x, 0.0001f) - assertEquals(0.25f, offCenterPivot.y, 0.0001f) - - val clampedPivot = desktopPdfZoomPreviewPivotFraction( - viewportRootOffset = Offset.Zero, - pageRootOffset = Offset.Zero, - anchor = Offset(900f, -20f), - pageCanvasSize = IntSize(500, 1000) - ) ?: error("Expected clamped pivot") - assertEquals(1f, clampedPivot.x, 0.0001f) - assertEquals(0f, clampedPivot.y, 0.0001f) - - val firstPageDocumentTranslation = desktopPdfDocumentZoomPreviewTranslation( - viewportRootOffset = Offset.Zero, - pageRootOffset = Offset(0f, 0f), - anchor = Offset(100f, 200f), - previewScale = 2f - ) ?: error("Expected first page document translation") - assertEquals(-100f, firstPageDocumentTranslation.x, 0.0001f) - assertEquals(-200f, firstPageDocumentTranslation.y, 0.0001f) - - val secondPageDocumentTranslation = desktopPdfDocumentZoomPreviewTranslation( - viewportRootOffset = Offset.Zero, - pageRootOffset = Offset(0f, 900f), - anchor = Offset(100f, 200f), - previewScale = 2f - ) ?: error("Expected second page document translation") - assertEquals(-100f, secondPageDocumentTranslation.x, 0.0001f) - assertEquals(700f, secondPageDocumentTranslation.y, 0.0001f) - } - - private fun bookItem(id: String): BookItem { - return BookItem( - id = id, - path = "C:/Books/$id.epub", - type = FileType.EPUB, - displayName = "$id.epub", - timestamp = 1L - ) - } -} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderKeyCommandsTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderKeyCommandsTest.kt deleted file mode 100644 index d383823..0000000 --- a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderKeyCommandsTest.kt +++ /dev/null @@ -1,174 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import java.awt.Canvas -import java.awt.event.KeyEvent -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNull -import kotlin.test.assertTrue - -class DesktopReaderKeyCommandsTest { - - @Test - fun `ctrl f opens epub reader search`() { - assertEquals( - DesktopReaderKeyNavigation.SEARCH, - awtKeyEvent(KeyEvent.VK_F, KeyEvent.CTRL_DOWN_MASK, 'F') - .desktopReaderKeyNavigationOrNull(fullscreen = false) - ) - } - - @Test - fun `epub right to left pagination swaps physical arrow navigation`() { - assertEquals( - DesktopReaderKeyNavigation.PREVIOUS, - awtKeyEvent(KeyEvent.VK_RIGHT, 0, KeyEvent.CHAR_UNDEFINED) - .desktopReaderKeyNavigationOrNull(fullscreen = false, rightToLeftPagination = true) - ) - assertEquals( - DesktopReaderKeyNavigation.NEXT, - awtKeyEvent(KeyEvent.VK_LEFT, 0, KeyEvent.CHAR_UNDEFINED) - .desktopReaderKeyNavigationOrNull(fullscreen = false, rightToLeftPagination = true) - ) - assertEquals( - DesktopReaderKeyNavigation.NEXT, - awtKeyEvent(KeyEvent.VK_PAGE_DOWN, 0, KeyEvent.CHAR_UNDEFINED) - .desktopReaderKeyNavigationOrNull(fullscreen = false, rightToLeftPagination = true) - ) - } - - @Test - fun `ctrl f opens pdf reader search while reading`() { - assertEquals( - DesktopPdfKeyCommand.SEARCH, - awtKeyEvent(KeyEvent.VK_F, KeyEvent.CTRL_DOWN_MASK, 'F') - .desktopPdfKeyCommandOrNull(fullscreen = false, editingText = false) - ) - } - - @Test - fun `ctrl f opens pdf reader search while text editing`() { - assertEquals( - DesktopPdfKeyCommand.SEARCH, - awtKeyEvent(KeyEvent.VK_F, KeyEvent.CTRL_DOWN_MASK, 'F') - .desktopPdfKeyCommandOrNull(fullscreen = false, editingText = true) - ) - } - - @Test - fun `pdf text editing keeps unmodified arrows for the editor`() { - assertNull( - awtKeyEvent(KeyEvent.VK_LEFT, 0, KeyEvent.CHAR_UNDEFINED) - .desktopPdfKeyCommandOrNull(fullscreen = false, editingText = true) - ) - } - - @Test - fun `pdf right to left pagination swaps physical arrow navigation`() { - assertEquals( - DesktopPdfKeyCommand.PREVIOUS_PAGE, - awtKeyEvent(KeyEvent.VK_RIGHT, 0, KeyEvent.CHAR_UNDEFINED) - .desktopPdfKeyCommandOrNull( - fullscreen = false, - editingText = false, - rightToLeftPagination = true - ) - ) - assertEquals( - DesktopPdfKeyCommand.NEXT_PAGE, - awtKeyEvent(KeyEvent.VK_LEFT, 0, KeyEvent.CHAR_UNDEFINED) - .desktopPdfKeyCommandOrNull( - fullscreen = false, - editingText = false, - rightToLeftPagination = true - ) - ) - assertEquals( - DesktopPdfKeyCommand.NEXT_PAGE, - awtKeyEvent(KeyEvent.VK_PAGE_DOWN, 0, KeyEvent.CHAR_UNDEFINED) - .desktopPdfKeyCommandOrNull( - fullscreen = false, - editingText = false, - rightToLeftPagination = true - ) - ) - } - - @Test - fun `reader side panels can opt into global key dispatch without enabling popups`() { - assertEquals( - DesktopReaderModalWindowKind.PANEL, - desktopReaderModalWindowKind( - windowName = "${DesktopReaderModalWindowNamePrefix}PanelLeft", - windowTitle = "Reader Navigation" - ) - ) - assertTrue( - desktopReaderKeyDispatchAllowedForActiveWindowKind( - activeReaderModalKind = DesktopReaderModalWindowKind.PANEL, - allowChromeModalWindows = false, - allowPanelModalWindows = true, - dispatchWhenOwnerWindowActive = false - ) - ) - assertFalse( - desktopReaderKeyDispatchAllowedForActiveWindowKind( - activeReaderModalKind = DesktopReaderModalWindowKind.POPUP, - allowChromeModalWindows = true, - allowPanelModalWindows = true, - dispatchWhenOwnerWindowActive = true - ) - ) - } - - @Test - fun `reader chrome and owner window dispatch remain separately gated`() { - assertEquals( - DesktopReaderModalWindowKind.CHROME, - desktopReaderModalWindowKind( - windowName = "${DesktopReaderModalWindowNamePrefix}ChromeTop", - windowTitle = "Reader Chrome Top" - ) - ) - assertEquals( - DesktopReaderModalWindowKind.POPUP, - desktopReaderModalWindowKind( - windowName = "${DesktopReaderModalWindowNamePrefix}Popup", - windowTitle = "Reader Popup" - ) - ) - assertNull(desktopReaderModalWindowKind(windowName = "", windowTitle = "Episteme")) - assertFalse( - desktopReaderKeyDispatchAllowedForActiveWindowKind( - activeReaderModalKind = null, - allowChromeModalWindows = false, - allowPanelModalWindows = true, - dispatchWhenOwnerWindowActive = false - ) - ) - assertTrue( - desktopReaderKeyDispatchAllowedForActiveWindowKind( - activeReaderModalKind = DesktopReaderModalWindowKind.CHROME, - allowChromeModalWindows = true, - allowPanelModalWindows = false, - dispatchWhenOwnerWindowActive = false - ) - ) - } - - private fun awtKeyEvent( - keyCode: Int, - modifiers: Int, - keyChar: Char - ): KeyEvent { - return KeyEvent( - Canvas(), - KeyEvent.KEY_PRESSED, - 0L, - modifiers, - keyCode, - keyChar - ) - } -} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderTypographyTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderTypographyTest.kt deleted file mode 100644 index b3c8c3b..0000000 --- a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderTypographyTest.kt +++ /dev/null @@ -1,69 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import androidx.compose.ui.unit.sp -import org.dueattendant149.bookreader.paginatedreader.CssStyle -import org.dueattendant149.bookreader.paginatedreader.SemanticParagraph -import org.dueattendant149.bookreader.shared.reader.ReaderPage -import kotlin.test.Test -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class DesktopReaderTypographyTest { - - @Test - fun `same page layout includes semantic styling`() { - val plain = pageWith( - SemanticParagraph( - text = "Styled text", - spans = emptyList(), - style = CssStyle(), - elementId = null, - cfi = null, - startCharOffsetInSource = 0, - blockIndex = 0 - ) - ) - val styled = pageWith( - SemanticParagraph( - text = "Styled text", - spans = emptyList(), - style = CssStyle(fontSize = 24.sp), - elementId = null, - cfi = null, - startCharOffsetInSource = 0, - blockIndex = 0 - ) - ) - - assertFalse(listOf(plain).samePageLayoutAs(listOf(styled))) - } - - @Test - fun `same page layout still matches identical semantic pages`() { - val page = pageWith( - SemanticParagraph( - text = "Styled text", - spans = emptyList(), - style = CssStyle(fontSize = 24.sp), - elementId = null, - cfi = null, - startCharOffsetInSource = 0, - blockIndex = 0 - ) - ) - - assertTrue(listOf(page).samePageLayoutAs(listOf(page.copy()))) - } - - private fun pageWith(block: SemanticParagraph): ReaderPage { - return ReaderPage( - pageIndex = 0, - chapterIndex = 0, - chapterTitle = "One", - text = block.text, - startOffset = 0, - endOffset = block.text.length, - semanticBlocks = listOf(block) - ) - } -} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderWindowStateTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderWindowStateTest.kt deleted file mode 100644 index 99e93cf..0000000 --- a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderWindowStateTest.kt +++ /dev/null @@ -1,141 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.WindowPlacement -import org.dueattendant149.bookreader.shared.FileType -import org.dueattendant149.bookreader.shared.reader.ReaderReadingMode -import org.dueattendant149.bookreader.shared.ui.SharedAppTab -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNull -import kotlin.test.assertTrue - -class DesktopReaderWindowStateTest { - - @Test - fun `desktop starts on library instead of home`() { - assertEquals(SharedAppTab.LIBRARY, DesktopInitialAppTab) - } - - @Test - fun `opening a new reader creates a window`() { - val opening = readerOpening("book-1", requestId = 1) - - val decision = emptyList().openOrFocusDesktopReaderWindow( - opening = opening, - force = false - ) - - assertTrue(decision.shouldStartOpen) - assertEquals(listOf("book-1"), decision.windows.map { it.bookId }) - assertEquals(1L, decision.windows.single().focusRequestId) - } - - @Test - fun `opening an already open reader focuses the existing window`() { - val opening = readerOpening("book-1", requestId = 1) - val first = emptyList() - .openOrFocusDesktopReaderWindow(opening, force = false) - .windows - - val decision = first.openOrFocusDesktopReaderWindow( - opening = readerOpening("book-1", requestId = 2), - force = false - ) - - assertFalse(decision.shouldStartOpen) - assertEquals(1, decision.windows.size) - assertEquals(2L, decision.windows.single().focusRequestId) - assertEquals(1L, decision.windows.single().opening.requestId) - } - - @Test - fun `forcing an already open reader replaces the opening request`() { - val opening = readerOpening("book-1", requestId = 1) - val first = emptyList() - .openOrFocusDesktopReaderWindow(opening, force = false) - .windows - - val decision = first.openOrFocusDesktopReaderWindow( - opening = readerOpening("book-1", requestId = 2), - force = true - ) - - assertTrue(decision.shouldStartOpen) - assertEquals(1, decision.windows.size) - assertEquals(2L, decision.windows.single().opening.requestId) - assertEquals(2L, decision.windows.single().focusRequestId) - } - - @Test - fun `reader window uses persisted size instead of hardcoded fallback`() { - val snapshot = DesktopWindowStateSnapshot( - placement = DesktopSavedWindowPlacement.FLOATING, - widthDp = 1340f, - heightDp = 840f - ) - - val size = snapshot.toWindowSize(DesktopReaderWindowDefaultSize) - - assertEquals(1340.dp, size.width) - assertEquals(840.dp, size.height) - } - - @Test - fun `reader window defaults preserve previous detached reader size`() { - assertEquals(1120.dp, DesktopReaderWindowDefaultSize.width) - assertEquals(760.dp, DesktopReaderWindowDefaultSize.height) - } - - @Test - fun `reader window persistence ignores fullscreen snapshots`() { - val snapshot = DesktopWindowStateSnapshot( - placement = DesktopSavedWindowPlacement.FULLSCREEN, - widthDp = 1920f, - heightDp = 1080f - ) - - assertEquals(WindowPlacement.Floating, snapshot.toReaderWindowPlacement()) - assertNull(snapshot.toPersistableReaderWindowSnapshot()) - } - - @Test - fun `native webview text reader resets surface when switching from vertical to paginated`() { - assertTrue( - shouldResetDesktopTextReaderWindowSurface( - previousMode = ReaderReadingMode.VERTICAL, - currentMode = ReaderReadingMode.PAGINATED, - usesNativeWebView = true - ) - ) - } - - @Test - fun `text reader surface reset is limited to native webview vertical to paginated switches`() { - assertFalse( - shouldResetDesktopTextReaderWindowSurface( - previousMode = ReaderReadingMode.PAGINATED, - currentMode = ReaderReadingMode.VERTICAL, - usesNativeWebView = true - ) - ) - assertFalse( - shouldResetDesktopTextReaderWindowSurface( - previousMode = ReaderReadingMode.VERTICAL, - currentMode = ReaderReadingMode.PAGINATED, - usesNativeWebView = false - ) - ) - } - - private fun readerOpening(bookId: String, requestId: Long): DesktopReaderOpening { - return DesktopReaderOpening( - requestId = requestId, - bookId = bookId, - title = "Book $bookId", - formatLabel = FileType.EPUB.name, - returnTab = SharedAppTab.LIBRARY - ) - } -} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopStartupTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopStartupTest.kt deleted file mode 100644 index 3538120..0000000 --- a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopStartupTest.kt +++ /dev/null @@ -1,231 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import org.dueattendant149.bookreader.shared.SharedReaderScreenState -import org.dueattendant149.bookreader.shared.reader.ReaderReadingMode -import org.dueattendant149.bookreader.shared.reader.ReaderSettings -import org.dueattendant149.bookreader.shared.reader.SharedJvmBookLoadSemanticMode -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNull -import kotlin.test.assertTrue -import java.io.File -import java.nio.file.Files - -class DesktopStartupTest { - @Test - fun `startup splash uses compact branded feedback`() { - val spec = epistemeDesktopStartupSplashSpec(desktopBuildProfileForFlavor("standard")) - - assertEquals(EpistemeDesktopWindowTitle, spec.title) - assertTrue(spec.message.isNotBlank()) - assertTrue(spec.width in 320..480) - assertTrue(spec.height in 180..280) - } - - @Test - fun `oss startup splash uses oss branding`() { - val spec = epistemeDesktopStartupSplashSpec(desktopBuildProfileForFlavor("oss-offline")) - - assertEquals(EpistemeDesktopOssAppName, spec.title) - } - - @Test - fun `desktop epub webview uses native browser backends without bundled runtime`() { - val linux = DesktopPlatform(DesktopOperatingSystem.LINUX, DesktopArchitecture.X64) - val windows = DesktopPlatform(DesktopOperatingSystem.WINDOWS, DesktopArchitecture.X64) - val macos = DesktopPlatform(DesktopOperatingSystem.MACOS, DesktopArchitecture.ARM64) - val other = DesktopPlatform(DesktopOperatingSystem.OTHER, DesktopArchitecture.X64) - - assertEquals(DesktopEpubWebViewBackend.WEBKIT, desktopEpubWebViewBackend(linux)) - assertEquals(DesktopEpubWebViewBackend.WINDOWS_WEBVIEW2, desktopEpubWebViewBackend(windows)) - assertEquals(DesktopEpubWebViewBackend.WEBKIT, desktopEpubWebViewBackend(macos)) - assertEquals(DesktopEpubWebViewBackend.UNSUPPORTED, desktopEpubWebViewBackend(other)) - - assertTrue(desktopEpubWebViewUsesNativeSwtBrowser(linux)) - assertTrue(desktopEpubWebViewUsesNativeSwtBrowser(windows)) - assertTrue(desktopEpubWebViewUsesNativeSwtBrowser(macos)) - assertFalse(desktopEpubWebViewUsesNativeSwtBrowser(other)) - } - - @Test - fun `native webviews can render without bundled runtime state`() { - val windows = DesktopPlatform(DesktopOperatingSystem.WINDOWS, DesktopArchitecture.X64) - val linux = DesktopPlatform(DesktopOperatingSystem.LINUX, DesktopArchitecture.X64) - val macos = DesktopPlatform(DesktopOperatingSystem.MACOS, DesktopArchitecture.ARM64) - val other = DesktopPlatform(DesktopOperatingSystem.OTHER, DesktopArchitecture.X64) - - assertTrue(desktopEpubWebViewCanRender(DesktopWebViewRuntimeState(), windows)) - assertTrue(desktopEpubWebViewCanRender(DesktopWebViewRuntimeState(), linux)) - assertTrue(desktopEpubWebViewCanRender(DesktopWebViewRuntimeState(), macos)) - assertTrue(desktopEpubWebViewCanRender(DesktopWebViewRuntimeState(initialized = true), linux)) - assertFalse(desktopEpubWebViewCanRender(DesktopWebViewRuntimeState(initialized = true), other)) - } - - @Test - fun `desktop vertical epub native reader is Linux only`() { - val windows = DesktopPlatform(DesktopOperatingSystem.WINDOWS, DesktopArchitecture.X64) - val linux = DesktopPlatform(DesktopOperatingSystem.LINUX, DesktopArchitecture.X64) - val macos = DesktopPlatform(DesktopOperatingSystem.MACOS, DesktopArchitecture.ARM64) - val other = DesktopPlatform(DesktopOperatingSystem.OTHER, DesktopArchitecture.X64) - - assertTrue(desktopShouldUseNativeVerticalEpubReader(linux)) - assertFalse(desktopShouldUseNativeVerticalEpubReader(windows)) - assertFalse(desktopShouldUseNativeVerticalEpubReader(macos)) - assertFalse(desktopShouldUseNativeVerticalEpubReader(other)) - } - - @Test - fun `desktop vertical epub load keeps semantic blocks for Linux native reader`() { - val windows = DesktopPlatform(DesktopOperatingSystem.WINDOWS, DesktopArchitecture.X64) - val linux = DesktopPlatform(DesktopOperatingSystem.LINUX, DesktopArchitecture.X64) - val macos = DesktopPlatform(DesktopOperatingSystem.MACOS, DesktopArchitecture.ARM64) - val verticalSettings = ReaderSettings(readingMode = ReaderReadingMode.VERTICAL) - val paginatedSettings = ReaderSettings(readingMode = ReaderReadingMode.PAGINATED) - - assertEquals(SharedJvmBookLoadSemanticMode.FULL, desktopEpubBookLoadSemanticMode(verticalSettings, linux)) - assertEquals(SharedJvmBookLoadSemanticMode.SKIP, desktopEpubBookLoadSemanticMode(verticalSettings, windows)) - assertEquals(SharedJvmBookLoadSemanticMode.SKIP, desktopEpubBookLoadSemanticMode(verticalSettings, macos)) - assertEquals(SharedJvmBookLoadSemanticMode.FULL, desktopEpubBookLoadSemanticMode(paginatedSettings, windows)) - } - - @Test - fun `native webview unavailable messages point to the platform runtime`() { - val windowsMessage = desktopNativeWebViewUnavailableMessage( - backend = DesktopEpubWebViewBackend.WINDOWS_WEBVIEW2, - detail = "missing runtime" - ) - val linuxMessage = desktopNativeWebViewUnavailableMessage( - backend = DesktopEpubWebViewBackend.WEBKIT, - detail = "missing library" - ) - - assertTrue(windowsMessage.contains("WebView2 Runtime")) - assertTrue(windowsMessage.contains("missing runtime")) - assertTrue(linuxMessage.contains("WebKitGTK")) - assertTrue(linuxMessage.contains("Linux distribution packages")) - assertTrue(linuxMessage.contains("missing library")) - } - - @Test - fun `compose interop blending stays off by default for native swt webviews`() { - val windows = DesktopPlatform(DesktopOperatingSystem.WINDOWS, DesktopArchitecture.X64) - val linux = DesktopPlatform(DesktopOperatingSystem.LINUX, DesktopArchitecture.X64) - val other = DesktopPlatform(DesktopOperatingSystem.OTHER, DesktopArchitecture.X64) - - assertNull(composeInteropBlendingDefault(windows)) - assertNull(composeInteropBlendingDefault(linux)) - assertEquals(ComposeInteropBlendingEnabled, composeInteropBlendingDefault(other)) - } - - @Test - fun `silent startup folder sync does not surface missing folder banner`() { - val completed = desktopFolderSyncCompletedState( - state = SharedReaderScreenState(), - message = "Folder sync failed for 1 folder.", - failedFolderCount = 1, - showBanner = false - ) - - assertNull(completed.bannerMessage) - } - - @Test - fun `manual folder sync still surfaces missing folder banner`() { - val completed = desktopFolderSyncCompletedState( - state = SharedReaderScreenState(), - message = "Folder sync failed for 1 folder.", - failedFolderCount = 1, - showBanner = true - ) - - assertEquals("Folder sync failed for 1 folder.", completed.bannerMessage?.message) - assertTrue(completed.bannerMessage?.isError == true) - } - - @Test - fun `desktop account profile store restores cached profile for matching user`() { - val directory = Files.createTempDirectory("episteme-account-profile-test").toFile() - try { - val store = DesktopAccountProfileStore(File(directory, "account_profile.properties")) - store.save("user-1", DesktopAccountProfile(isProUser = true, credits = 42, fetchedAtEpochMillis = 123L)) - - assertEquals( - DesktopAccountProfile(isProUser = true, credits = 42, fetchedAtEpochMillis = 123L), - store.load("user-1") - ) - assertNull(store.load("user-2")) - } finally { - directory.deleteRecursively() - } - } - - @Test - fun `desktop account profile freshness uses fetched timestamp`() { - val now = 10_000L - val ttl = 1_000L - - assertTrue(DesktopAccountProfile(fetchedAtEpochMillis = now - ttl).isFresh(now, ttl)) - assertTrue(DesktopAccountProfile(fetchedAtEpochMillis = now - 1L).isFresh(now, ttl)) - assertFalse(DesktopAccountProfile(fetchedAtEpochMillis = now - ttl - 1L).isFresh(now, ttl)) - assertFalse(DesktopAccountProfile(fetchedAtEpochMillis = now + 1L).isFresh(now, ttl)) - assertFalse(DesktopAccountProfile(fetchedAtEpochMillis = 0L).isFresh(now, ttl)) - } - - @Test - fun `desktop account profile repository ignores stale startup cache`() { - val directory = Files.createTempDirectory("episteme-account-profile-policy-test").toFile() - try { - val store = DesktopAccountProfileStore(File(directory, "account_profile.properties")) - val repository = DesktopAccountProfileRepository(testDesktopCloudConfig(), store) - val now = DesktopAccountProfileCacheTtlMillis + 10_000L - val freshProfile = DesktopAccountProfile( - isProUser = true, - credits = 42, - fetchedAtEpochMillis = now - DesktopAccountProfileCacheTtlMillis + 1L - ) - - repository.saveFetchedProfile("user-1", freshProfile) - assertEquals(freshProfile, repository.cachedProfile("user-1", now)) - - repository.saveFetchedProfile( - "user-1", - freshProfile.copy(fetchedAtEpochMillis = now - DesktopAccountProfileCacheTtlMillis - 1L) - ) - assertNull(repository.cachedProfile("user-1", now)) - } finally { - directory.deleteRecursively() - } - } - - @Test - fun `desktop account profile repository clear removes sign out cache`() { - val directory = Files.createTempDirectory("episteme-account-profile-clear-test").toFile() - try { - val store = DesktopAccountProfileStore(File(directory, "account_profile.properties")) - val repository = DesktopAccountProfileRepository(testDesktopCloudConfig(), store) - - repository.saveFetchedProfile( - "user-1", - DesktopAccountProfile(isProUser = true, credits = 42, fetchedAtEpochMillis = 10_000L) - ) - repository.clearCachedProfiles() - - assertNull(repository.cachedProfile("user-1", 10_001L)) - assertNull(store.load("user-1")) - } finally { - directory.deleteRecursively() - } - } - - private fun testDesktopCloudConfig(): DesktopCloudConfig { - return DesktopCloudConfig( - aiWorkerUrl = "", - ttsWorkerUrl = "", - firebaseWebApiKey = "", - firebaseProjectId = "reader-test", - googleOAuthClientId = "", - googleOAuthClientSecret = "" - ) - } -} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopStringResourcesTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopStringResourcesTest.kt deleted file mode 100644 index 9c16d69..0000000 --- a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopStringResourcesTest.kt +++ /dev/null @@ -1,162 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue -import java.io.ByteArrayInputStream -import java.nio.file.Files -import java.util.Locale - -class DesktopStringResourcesTest { - @Test - fun buildsAndroidResourcePathsForRegionalLocale() { - val paths = desktopAndroidStringResourcePaths(Locale("pt", "BR")) - - assertEquals( - listOf( - "desktop-android-res/values-pt-rBR/strings.xml", - "desktop-android-res/values-pt/strings.xml" - ), - paths - ) - } - - @Test - fun buildsAndroidPluralResourcePathsForRegionalLocale() { - val paths = desktopAndroidPluralResourcePaths(Locale("pt", "BR")) - - assertEquals( - listOf( - "desktop-android-res/values-pt-rBR/plurals.xml", - "desktop-android-res/values-pt/plurals.xml" - ), - paths - ) - } - - @Test - fun parsesAndroidStringXmlAndDecodesEscapes() { - val xml = """ - - One\nTwo - Don\'t stop - - """.trimIndent() - - val parsed = parseAndroidStringXml(ByteArrayInputStream(xml.toByteArray())) - - assertEquals("One\nTwo", parsed["line"]) - assertEquals("Don't stop", parsed["quote"]) - assertTrue(parsed.containsKey("line")) - } - - @Test - fun parsesAndroidPluralXmlAndDecodesEscapes() { - val xml = """ - - - %1${'$'}d book - %1${'$'}d books - - - Don\'t skip %1${'$'}d file - Don\'t skip %1${'$'}d files - - - """.trimIndent() - - val parsed = parseAndroidPluralXml(ByteArrayInputStream(xml.toByteArray())) - - assertEquals("%1${'$'}d book", parsed["book_count"]?.get("one")) - assertEquals("%1${'$'}d books", parsed["book_count"]?.get("other")) - assertEquals("Don't skip %1${'$'}d file", parsed["quoted_count"]?.get("one")) - } - - @Test - fun loadsAndroidToolbarTooltipDescriptionsForDesktop() { - val resources = DesktopAndroidStringResources.load( - locale = Locale.ENGLISH, - classLoader = Thread.currentThread().contextClassLoader - ?: DesktopStringResourcesTest::class.java.classLoader - ) - - assertEquals( - "Exit search and go back to the reader", - resources.stringOrNull("tooltip_close_search_desc") - ) - assertEquals( - "Jump to the next search match in the document", - resources.stringOrNull("tooltip_next_result_desc") - ) - } - - @Test - fun choosesDesktopPluralQuantityForSupportedLanguages() { - val slavicQuantities = setOf("one", "few", "many", "other") - val arabicQuantities = setOf("zero", "one", "two", "few", "many", "other") - - assertEquals("one", desktopAndroidPluralQuantity(Locale("ru"), 21, slavicQuantities)) - assertEquals("few", desktopAndroidPluralQuantity(Locale("ru"), 22, slavicQuantities)) - assertEquals("many", desktopAndroidPluralQuantity(Locale("ru"), 25, slavicQuantities)) - assertEquals("few", desktopAndroidPluralQuantity(Locale("pl"), 2, slavicQuantities)) - assertEquals("one", desktopAndroidPluralQuantity(Locale("fr"), 0, setOf("one", "other"))) - assertEquals("zero", desktopAndroidPluralQuantity(Locale("ar"), 0, arabicQuantities)) - assertEquals("other", desktopAndroidPluralQuantity(Locale("ja"), 1, setOf("other"))) - } - - @Test - fun fallsBackToOtherPluralQuantityWhenPreferredIsUnavailable() { - val selected = desktopAndroidPluralQuantity(Locale("ru"), 2, setOf("one", "other")) - - assertEquals("other", selected) - } - - @Test - fun normalizesDesktopLanguageTagsForAndroidResources() { - assertEquals(null, normalizeDesktopLanguageTag(null)) - assertEquals("id", normalizeDesktopLanguageTag("in")) - assertEquals("pt-BR", normalizeDesktopLanguageTag("pt_br")) - assertEquals("zh-CN", normalizeDesktopLanguageTag("zh-cn")) - } - - @Test - fun resolvesSelectedDesktopLanguageOptionByNormalizedTag() { - val option = selectedDesktopLanguageOption("pt_br") - - assertEquals("pt-BR", option.normalizedTag) - assertEquals("language_portuguese_brazilian", option.labelKey) - } - - @Test - fun resolvesSelectedDesktopLanguageOptionForEstonian() { - val option = selectedDesktopLanguageOption("et") - - assertEquals("et", option.normalizedTag) - assertEquals("language_estonian", option.labelKey) - } - - @Test - fun desktopLanguageSettingsStorePersistsLanguageAcrossInstances() { - val tempDirectory = Files.createTempDirectory("episteme-desktop-language-test") - val settingsFile = tempDirectory.resolve("language.properties").toFile() - - try { - DesktopLanguageSettingsStore(settingsFile).save(DesktopLanguageSettings("pt_br")) - - assertEquals( - "pt-BR", - DesktopLanguageSettingsStore(settingsFile).load().languageTag - ) - - DesktopLanguageSettingsStore(settingsFile).save(DesktopLanguageSettings(null)) - - assertEquals( - null, - DesktopLanguageSettingsStore(settingsFile).load().languageTag - ) - } finally { - settingsFile.delete() - tempDirectory.toFile().delete() - } - } -} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopSummaryCacheStoreTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopSummaryCacheStoreTest.kt deleted file mode 100644 index 0775a3f..0000000 --- a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopSummaryCacheStoreTest.kt +++ /dev/null @@ -1,48 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import java.nio.file.Files -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull - -class DesktopSummaryCacheStoreTest { - @Test - fun `stores lists and deletes cached summaries`() { - val store = DesktopSummaryCacheStore(Files.createTempDirectory("reader-summary-cache").toFile()) - - store.saveSummary("book-a", 2, "Chapter 3", "A cached summary.") - store.saveSummary("book-a", 0, "Chapter 1", "The first cached summary.") - - assertEquals("A cached summary.", store.getSummary("book-a", 2)) - assertEquals( - listOf( - DesktopCachedSummaryItem(0, "Chapter 1", "The first cached summary."), - DesktopCachedSummaryItem(2, "Chapter 3", "A cached summary.") - ), - store.getAllSummaries("book-a") - ) - - store.deleteSummary("book-a", 2) - - assertNull(store.getSummary("book-a", 2)) - assertEquals(1, store.getAllSummaries("book-a").size) - } - - @Test - fun `keeps books isolated and ignores blank summaries`() { - val store = DesktopSummaryCacheStore(Files.createTempDirectory("reader-summary-cache-isolated").toFile()) - - store.saveSummary("book-a", 0, "Chapter 1", "A summary.") - store.saveSummary("book-b", 0, "Chapter 1", "Another summary.") - store.saveSummary("book-a", 1, "Chapter 2", " ") - - assertEquals("A summary.", store.getSummary("book-a", 0)) - assertEquals("Another summary.", store.getSummary("book-b", 0)) - assertNull(store.getSummary("book-a", 1)) - - store.clearBookCache("book-a") - - assertNull(store.getSummary("book-a", 0)) - assertEquals("Another summary.", store.getSummary("book-b", 0)) - } -} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopTtsLogTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopTtsLogTest.kt deleted file mode 100644 index 24e4728..0000000 --- a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopTtsLogTest.kt +++ /dev/null @@ -1,18 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import kotlin.test.Test -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class DesktopTtsLogTest { - @Test - fun `desktop tts preview redacts key and token query values`() { - val preview = "wss://example.test/live?key=gemini_secret&token=firebase_secret" - .desktopTtsPreview(300) - - assertFalse(preview.contains("gemini_secret")) - assertFalse(preview.contains("firebase_secret")) - assertTrue(preview.contains("key=")) - assertTrue(preview.contains("token=")) - } -} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWebView2LayoutTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWebView2LayoutTest.kt deleted file mode 100644 index 1ff5168..0000000 --- a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWebView2LayoutTest.kt +++ /dev/null @@ -1,44 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNull -import kotlin.test.assertTrue - -class DesktopWebView2LayoutTest { - @Test - fun `webview2 host bounds match the awt canvas logical size`() { - val bounds = desktopWebView2TargetBoundsForCanvas(width = 1440, height = 900) - - assertEquals(DesktopWebView2TargetBounds(x = 0, y = 0, width = 1440, height = 900), bounds) - } - - @Test - fun `webview2 host bounds are unavailable before the canvas has size`() { - assertNull(desktopWebView2TargetBoundsForCanvas(width = 0, height = 900)) - assertNull(desktopWebView2TargetBoundsForCanvas(width = 1440, height = 0)) - } - - @Test - fun `webview2 awt canvas is not retired while host window is closing`() { - assertFalse( - desktopWebView2ShouldRetireAwtCanvas( - hostWindowClosing = true, - hostWindowDisplayable = true - ) - ) - assertFalse( - desktopWebView2ShouldRetireAwtCanvas( - hostWindowClosing = false, - hostWindowDisplayable = false - ) - ) - assertTrue( - desktopWebView2ShouldRetireAwtCanvas( - hostWindowClosing = false, - hostWindowDisplayable = true - ) - ) - } -} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWindowPolishTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWindowPolishTest.kt deleted file mode 100644 index c250c25..0000000 --- a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWindowPolishTest.kt +++ /dev/null @@ -1,46 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import androidx.compose.ui.graphics.Color -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class DesktopWindowPolishTest { - @Test - fun `desktop window defaults use app branding and a useful first launch size`() { - val defaults = epistemeDesktopWindowDefaults(desktopBuildProfileForFlavor("standard")) - - assertEquals(EpistemeDesktopWindowTitle, defaults.title) - assertEquals(EpistemeDesktopWindowIconResource, defaults.iconResourcePath) - assertTrue(defaults.defaultSize.width.value > defaults.minimumSize.width.toFloat()) - assertTrue(defaults.defaultSize.height.value > defaults.minimumSize.height.toFloat()) - assertEquals(EpistemeDesktopWindowMinimumWidthPx, defaults.minimumSize.width) - assertEquals(EpistemeDesktopWindowMinimumHeightPx, defaults.minimumSize.height) - } - - @Test - fun `oss desktop window defaults use oss branding`() { - val defaults = epistemeDesktopWindowDefaults(desktopBuildProfileForFlavor("oss-offline")) - - assertEquals(EpistemeDesktopOssAppName, defaults.title) - } - - @Test - fun `desktop chrome colors choose dark mode from dark theme surfaces`() { - val darkChrome = desktopWindowChromeColors( - captionColor = Color(0xFF12140E), - textColor = Color(0xFFE2E3D8), - borderColor = Color(0xFF0C0F09) - ) - - val lightChrome = desktopWindowChromeColors( - captionColor = Color(0xFFF9FAEF), - textColor = Color(0xFF1A1C16), - borderColor = Color(0xFFFFFFFF) - ) - - assertTrue(darkChrome.useDarkMode) - assertFalse(lightChrome.useDarkMode) - } -} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWindowStateStoreTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWindowStateStoreTest.kt deleted file mode 100644 index 23c27e6..0000000 --- a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWindowStateStoreTest.kt +++ /dev/null @@ -1,43 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import kotlin.io.path.createTempFile -import kotlin.test.Test -import kotlin.test.assertEquals - -class DesktopWindowStateStoreTest { - - @Test - fun `desktop window state round trips through config store`() { - val file = createTempFile("episteme-window-state", ".json").toFile() - val store = DesktopWindowStateStore(file) - val snapshot = DesktopWindowStateSnapshot( - placement = DesktopSavedWindowPlacement.FLOATING, - widthDp = 1440f, - heightDp = 900f, - xDp = 120f, - yDp = 80f - ) - - store.save(snapshot) - - assertEquals(snapshot, store.load()) - } - - @Test - fun `desktop window state clamps too small saved bounds`() { - val snapshot = DesktopWindowStateSnapshot( - placement = DesktopSavedWindowPlacement.FLOATING, - widthDp = 12f, - heightDp = 34f - ).sanitized() - - assertEquals(EpistemeDesktopWindowMinimumWidthPx.toFloat(), snapshot.widthDp) - assertEquals(EpistemeDesktopWindowMinimumHeightPx.toFloat(), snapshot.heightDp) - } - - @Test - fun `reader window state uses a separate config file`() { - assertEquals("window_state.json", DesktopWindowStateStore.defaultWindowStateFile().name) - assertEquals("reader_window_state.json", DesktopWindowStateStore.defaultReaderWindowStateFile().name) - } -} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/LinuxSecretToolCodecTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/LinuxSecretToolCodecTest.kt deleted file mode 100644 index 4ae7a89..0000000 --- a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/LinuxSecretToolCodecTest.kt +++ /dev/null @@ -1,115 +0,0 @@ -package org.dueattendant149.bookreader.desktop - -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class LinuxSecretToolCodecTest { - @Test - fun `libsecret codec stores looks up legacy secret tool references and clears secrets by key`() { - val client = FakeLinuxSecretServiceClient() - val codec = LinuxLibsecretCodec(client) - - assertTrue(codec.isAvailable) - val reference = codec.protect("geminiKeyProtected", "linux_gemini_key") - - assertTrue(reference.startsWith("linux-libsecret:")) - assertEquals("linux_gemini_key", codec.unprotect("geminiKeyProtected", reference)) - assertEquals( - "linux_gemini_key", - codec.unprotect("geminiKeyProtected", "secret-tool:Episteme.Reader.geminiKeyProtected") - ) - codec.delete("geminiKeyProtected") - assertTrue(client.storedSecrets.isEmpty()) - } - - @Test - fun `linux secret service codec falls back to secret tool when libsecret is unavailable`() { - val runner = FakeSecretCommandRunner() - val codec = LinuxSecretServiceCodec( - libsecretCodec = LinuxLibsecretCodec(FakeLinuxSecretServiceClient(available = false)), - secretToolCodec = LinuxSecretToolCodec(runner) - ) - - assertTrue(codec.isAvailable) - val reference = codec.protect("geminiKeyProtected", "linux_gemini_key") - - assertTrue(reference.startsWith("secret-tool:")) - assertEquals("linux_gemini_key", codec.unprotect("geminiKeyProtected", reference)) - assertFalse(runner.storedSecrets.isEmpty()) - } - - @Test - fun `secret tool codec stores looks up and clears secrets by key`() { - val runner = FakeSecretCommandRunner() - val codec = LinuxSecretToolCodec(runner) - - assertTrue(codec.isAvailable) - val reference = codec.protect("firebaseRefreshTokenProtected", "linux_refresh") - - assertEquals("linux_refresh", codec.unprotect("firebaseRefreshTokenProtected", reference)) - codec.delete("firebaseRefreshTokenProtected") - assertTrue(runner.storedSecrets.isEmpty()) - } - - private class FakeLinuxSecretServiceClient( - private val available: Boolean = true - ) : LinuxSecretServiceClient { - val storedSecrets = linkedMapOf() - - override val isAvailable: Boolean - get() = available - - override fun store(key: String, label: String, password: String) { - check(available) { "libsecret unavailable" } - storedSecrets[key] = password - } - - override fun lookup(key: String): String? { - check(available) { "libsecret unavailable" } - return storedSecrets[key] - } - - override fun clear(key: String) { - if (available) { - storedSecrets.remove(key) - } - } - } - - private class FakeSecretCommandRunner : DesktopSecretCommandRunner { - val storedSecrets = linkedMapOf() - - override fun isExecutableAvailable(command: String): Boolean { - return command == "secret-tool" - } - - override fun run( - command: List, - input: String?, - timeoutMillis: Long - ): DesktopSecretCommandResult { - return when (command.getOrNull(1)) { - "--help" -> DesktopSecretCommandResult(0, "usage", "") - "store" -> { - storedSecrets[command.last()] = input.orEmpty() - DesktopSecretCommandResult(0, "", "") - } - "lookup" -> { - val secret = storedSecrets[command.last()] - if (secret == null) { - DesktopSecretCommandResult(1, "", "not found") - } else { - DesktopSecretCommandResult(0, "$secret\n", "") - } - } - "clear" -> { - storedSecrets.remove(command.last()) - DesktopSecretCommandResult(0, "", "") - } - else -> DesktopSecretCommandResult(1, "", "unexpected command") - } - } - } -} diff --git a/settings.gradle.kts b/settings.gradle.kts index 093a81c..18b1bac 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,12 +1,6 @@ pluginManagement { repositories { - google { - content { - includeGroupByRegex("com\\.android.*") - includeGroupByRegex("com\\.google.*") - includeGroupByRegex("androidx.*") - } - } + google() mavenCentral() gradlePluginPortal() } @@ -18,25 +12,9 @@ dependencyResolutionManagement { mavenCentral() maven("https://jitpack.io") maven("https://jogamp.org/deployment/maven") - maven("https://jitpack.io") } } -rootProject.name = "Reader" +rootProject.name = "BookReader" -fun isDesktopOnlyBuild(): Boolean { - providers.gradleProperty("desktopOnly").orNull - ?.let { return it.equals("true", ignoreCase = true) } - - val requestedTasks = gradle.startParameter.taskNames - return requestedTasks.isNotEmpty() && requestedTasks.all { taskName -> - val normalized = taskName.removePrefix(":") - normalized.startsWith("desktopApp:") - } -} - -if (!isDesktopOnlyBuild()) { - include(":app") -} -include(":shared") -include(":desktopApp") +include(":app") diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts deleted file mode 100644 index 2db5bba..0000000 --- a/shared/build.gradle.kts +++ /dev/null @@ -1,81 +0,0 @@ -import com.android.build.api.dsl.LibraryExtension - -plugins { - alias(libs.plugins.kotlin.multiplatform) - alias(libs.plugins.android.library) apply false - alias(libs.plugins.kotlin.compose) - alias(libs.plugins.compose.multiplatform) - alias(libs.plugins.kotlin.serialization) - alias(libs.plugins.kover) -} - -fun isDesktopOnlyBuild(): Boolean { - providers.gradleProperty("desktopOnly").orNull - ?.let { return it.equals("true", ignoreCase = true) } - - val requestedTasks = gradle.startParameter.taskNames - return requestedTasks.isNotEmpty() && requestedTasks.all { taskName -> - val normalized = taskName.removePrefix(":") - normalized.startsWith("desktopApp:") - } -} - -val desktopOnlyBuild = isDesktopOnlyBuild() - -if (!desktopOnlyBuild) { - apply(plugin = "com.android.library") -} - -kotlin { - if (!desktopOnlyBuild) { - androidTarget() - } - jvm("desktop") - jvmToolchain { - languageVersion.set(JavaLanguageVersion.of(21)) - } - - sourceSets { - val commonMain by getting - val desktopMain by getting - val readerJvmMain by creating { - dependsOn(commonMain) - dependencies { - implementation("org.jsoup:jsoup:1.17.2") - } - } - if (!desktopOnlyBuild) { - val androidMain by getting - androidMain.dependsOn(readerJvmMain) - } - desktopMain.dependsOn(readerJvmMain) - - commonMain.dependencies { - implementation(compose.foundation) - implementation(compose.material3) - implementation(compose.ui) - 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")) - } - } -} - -if (!desktopOnlyBuild) { - extensions.configure("android") { - namespace = "org.dueattendant149.bookreader.shared" - compileSdk = 36 - - defaultConfig { - minSdk = 26 - } - - buildFeatures { - buildConfig = true - } - } -} diff --git a/shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSync.android.kt b/shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSync.android.kt deleted file mode 100644 index cabc21b..0000000 --- a/shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSync.android.kt +++ /dev/null @@ -1,8 +0,0 @@ -package org.dueattendant149.bookreader.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/dueattendant149/bookreader/reader/shared/Platform.android.kt b/shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/Platform.android.kt deleted file mode 100644 index 92ed9f2..0000000 --- a/shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/Platform.android.kt +++ /dev/null @@ -1,3 +0,0 @@ -package org.dueattendant149.bookreader.shared - -actual fun currentTimestamp(): Long = System.currentTimeMillis() diff --git a/shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedReaderDiagnostics.android.kt b/shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedReaderDiagnostics.android.kt deleted file mode 100644 index 4555979..0000000 --- a/shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedReaderDiagnostics.android.kt +++ /dev/null @@ -1,17 +0,0 @@ -package org.dueattendant149.bookreader.shared.reader - -import android.util.Log -import org.dueattendant149.bookreader.shared.BuildConfig - -internal actual val SharedReaderDiagnosticsEnabled: Boolean = BuildConfig.DEBUG - -internal actual fun isSharedReaderDiagnosticTagEnabled(tag: String): Boolean { - if (!BuildConfig.DEBUG) return false - return tag == SharedEpubCutoffDiagnosticsTag || - runCatching { Log.isLoggable(tag, Log.DEBUG) }.getOrDefault(false) -} - -internal actual fun writeSharedReaderDiagnostic(tag: String, message: String) { - if (!BuildConfig.DEBUG) return - Log.d(tag, message) -} diff --git a/shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalLayer.android.kt b/shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalLayer.android.kt deleted file mode 100644 index b092083..0000000 --- a/shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalLayer.android.kt +++ /dev/null @@ -1,31 +0,0 @@ -package org.dueattendant149.bookreader.shared.ui - -import androidx.compose.runtime.Composable -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties - -@Composable -internal actual fun SharedReaderModalLayer( - onDismiss: () -> Unit, - level: SharedReaderModalLevel, - content: @Composable () -> Unit -) { - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false) - ) { - content() - } -} - -internal actual fun sharedReaderModalLayerUsesSizedEdgeWindow(level: SharedReaderModalLevel): Boolean { - return false -} - -@Composable -actual fun SharedReaderModalOwnerWindowProvider( - ownerWindow: Any?, - content: @Composable () -> Unit -) { - content() -} diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedReaderDiagnostics.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedReaderDiagnostics.kt deleted file mode 100644 index e639d3d..0000000 --- a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedReaderDiagnostics.kt +++ /dev/null @@ -1,15 +0,0 @@ -package org.dueattendant149.bookreader.shared.reader - -internal const val SharedReaderDiagnosticsProperty = "episteme.desktop.diagnostics" -internal const val SharedReaderDiagnosticsTagsProperty = "episteme.desktop.diagnostics.tags" -internal const val SharedEpubCutoffDiagnosticsTag = "EpistemeEpubCutoff" - -internal expect val SharedReaderDiagnosticsEnabled: Boolean -internal expect fun isSharedReaderDiagnosticTagEnabled(tag: String): Boolean -internal expect fun writeSharedReaderDiagnostic(tag: String, message: String) - -internal inline fun logSharedReaderDiagnostic(tag: String, message: () -> String) { - if (SharedReaderDiagnosticsEnabled && isSharedReaderDiagnosticTagEnabled(tag)) { - writeSharedReaderDiagnostic(tag, message()) - } -} diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/LocalBookCoverImage.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/LocalBookCoverImage.kt deleted file mode 100644 index 3533a66..0000000 --- a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/LocalBookCoverImage.kt +++ /dev/null @@ -1,11 +0,0 @@ -package org.dueattendant149.bookreader.shared.ui - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier - -@Composable -internal expect fun LocalBookCoverImage( - path: String, - contentDescription: String?, - modifier: Modifier -) diff --git a/shared/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSync.desktop.kt b/shared/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSync.desktop.kt deleted file mode 100644 index cabc21b..0000000 --- a/shared/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSync.desktop.kt +++ /dev/null @@ -1,8 +0,0 @@ -package org.dueattendant149.bookreader.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/dueattendant149/bookreader/reader/shared/Platform.desktop.kt b/shared/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/shared/Platform.desktop.kt deleted file mode 100644 index 92ed9f2..0000000 --- a/shared/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/shared/Platform.desktop.kt +++ /dev/null @@ -1,3 +0,0 @@ -package org.dueattendant149.bookreader.shared - -actual fun currentTimestamp(): Long = System.currentTimeMillis() diff --git a/shared/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedReaderDiagnostics.desktop.kt b/shared/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedReaderDiagnostics.desktop.kt deleted file mode 100644 index 091d2de..0000000 --- a/shared/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedReaderDiagnostics.desktop.kt +++ /dev/null @@ -1,36 +0,0 @@ -package org.dueattendant149.bookreader.shared.reader - -private const val SharedReaderDiagnosticsEnv = "EPISTEME_DESKTOP_DIAGNOSTICS" -private const val SharedReaderDiagnosticsTagsEnv = "EPISTEME_DESKTOP_DIAGNOSTICS_TAGS" - -private val SharedReaderDiagnosticTags: Set = - listOfNotNull( - System.getProperty(SharedReaderDiagnosticsTagsProperty), - System.getenv(SharedReaderDiagnosticsTagsEnv) - ) - .joinToString(" ") - .split(',', ';', ' ', '\t', '\n') - .mapNotNull { rawTag -> - rawTag.trim() - .takeIf { it.isNotBlank() } - ?.lowercase() - } - .toSet() - -internal actual val SharedReaderDiagnosticsEnabled: Boolean = - System.getProperty(SharedReaderDiagnosticsProperty) - ?.trim() - ?.equals("true", ignoreCase = true) == true || - System.getenv(SharedReaderDiagnosticsEnv) - ?.trim() - ?.equals("true", ignoreCase = true) == true || - SharedReaderDiagnosticTags.isNotEmpty() - -internal actual fun isSharedReaderDiagnosticTagEnabled(tag: String): Boolean { - if (SharedReaderDiagnosticTags.isEmpty()) return true - return "*" in SharedReaderDiagnosticTags || tag.lowercase() in SharedReaderDiagnosticTags -} - -internal actual fun writeSharedReaderDiagnostic(tag: String, message: String) { - println("$tag $message") -} diff --git a/shared/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/DesktopBookCoverImageCache.kt b/shared/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/DesktopBookCoverImageCache.kt deleted file mode 100644 index 1b61da7..0000000 --- a/shared/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/DesktopBookCoverImageCache.kt +++ /dev/null @@ -1,110 +0,0 @@ -package org.dueattendant149.bookreader.shared.ui - -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.toComposeImageBitmap -import org.jetbrains.skia.Image as SkiaImage -import java.awt.RenderingHints -import java.awt.image.BufferedImage -import java.io.File -import java.util.concurrent.Semaphore -import javax.imageio.ImageIO -import kotlin.math.roundToInt - -internal object DesktopBookCoverImageCache { - private const val MaxEntries = 160 - private const val MaxCoverDimensionPx = 512 - - private data class Entry( - val length: Long, - val lastModified: Long, - val bitmap: ImageBitmap - ) - - private val entries = LinkedHashMap(MaxEntries, 0.75f, true) - private val decodeSlots = Semaphore(2) - - fun peek(path: String): ImageBitmap? { - return synchronized(entries) { - entries[File(path).absolutePath]?.bitmap - } - } - - fun load(path: String): ImageBitmap? { - val file = File(path) - if (!file.isFile) return null - val key = file.absolutePath - val length = file.length() - val lastModified = file.lastModified() - synchronized(entries) { - val entry = entries[key] - if (entry != null && entry.length == length && entry.lastModified == lastModified) { - return entry.bitmap - } - entries.remove(key) - } - decodeSlots.acquireUninterruptibly() - val bitmap = try { - decodeCover(file) - } finally { - decodeSlots.release() - } ?: return null - val entry = Entry( - length = length, - lastModified = lastModified, - bitmap = bitmap - ) - synchronized(entries) { - entries[key] = entry - trimToMaxEntries() - } - return bitmap - } - - fun clearForTests() { - synchronized(entries) { - entries.clear() - } - } - - private fun trimToMaxEntries() { - while (entries.size > MaxEntries) { - val eldestKey = entries.keys.firstOrNull() ?: return - entries.remove(eldestKey) - } - } - - private fun decodeCover(file: File): ImageBitmap? { - runCatching { ImageIO.read(file) }.getOrNull() - ?.scaledToFit(MaxCoverDimensionPx) - ?.toComposeImageBitmap() - ?.let { return it } - - return runCatching { - SkiaImage.makeFromEncoded(file.readBytes()).toComposeImageBitmap() - }.getOrNull() - } - - private fun BufferedImage.scaledToFit(maxDimension: Int): BufferedImage { - val largestDimension = maxOf(width, height) - if (largestDimension <= maxDimension) return this - val scale = maxDimension.toDouble() / largestDimension.toDouble() - val targetWidth = (width * scale).roundToInt().coerceAtLeast(1) - val targetHeight = (height * scale).roundToInt().coerceAtLeast(1) - val target = BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_INT_ARGB) - val graphics = target.createGraphics() - try { - graphics.setRenderingHint( - RenderingHints.KEY_INTERPOLATION, - RenderingHints.VALUE_INTERPOLATION_BILINEAR - ) - graphics.setRenderingHint( - RenderingHints.KEY_RENDERING, - RenderingHints.VALUE_RENDER_QUALITY - ) - graphics.drawImage(this, 0, 0, targetWidth, targetHeight, null) - } finally { - graphics.dispose() - } - return target - } -} diff --git a/shared/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/DesktopEpubNativeImage.desktop.kt b/shared/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/DesktopEpubNativeImage.desktop.kt deleted file mode 100644 index 852cadf..0000000 --- a/shared/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/DesktopEpubNativeImage.desktop.kt +++ /dev/null @@ -1,326 +0,0 @@ -package org.dueattendant149.bookreader.shared.ui - -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Spacer -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -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.ColorFilter -import androidx.compose.ui.graphics.ColorMatrix -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.toComposeImageBitmap -import androidx.compose.ui.layout.ContentScale -import org.dueattendant149.bookreader.paginatedreader.SemanticImage -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import org.jetbrains.skia.Data -import org.jetbrains.skia.Surface -import org.jetbrains.skia.svg.SVGDOM -import org.jetbrains.skia.svg.SVGLengthContext -import org.jetbrains.skia.Color as SkiaColor -import org.jetbrains.skia.Image as SkiaImage -import java.io.ByteArrayInputStream -import java.io.File -import java.util.Base64 -import javax.imageio.ImageIO -import kotlin.math.roundToInt - -@Composable -fun DesktopEpubNativeImage( - image: SemanticImage, - modifier: Modifier = Modifier -) { - var bitmap by remember(image.path) { - mutableStateOf(DesktopEpubNativeImageCache.peek(image.path)) - } - - LaunchedEffect(image.path) { - if (bitmap == null) { - bitmap = withContext(Dispatchers.IO) { - DesktopEpubNativeImageCache.load(image.path) - } - } - } - - val currentBitmap = bitmap - val isDecorative = image.altText != null && image.altText.isBlank() - if (currentBitmap != null) { - Image( - bitmap = currentBitmap, - contentDescription = image.altText - ?.takeIf { it.isNotBlank() } - ?: if (isDecorative) null else "Image from EPUB", - modifier = modifier, - contentScale = image.readerImageContentScale(), - alignment = desktopEpubImageContentAlignment(image.style.blockStyle.objectPosition), - colorFilter = image.readerImageColorFilter() - ) - } else if (isDecorative) { - Spacer(modifier = modifier) - } else { - Text( - text = image.altText?.takeIf { it.isNotBlank() } ?: image.path.substringAfterLast('/').substringAfterLast('\\'), - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = modifier, - style = MaterialTheme.typography.bodySmall - ) - } -} - -private object DesktopEpubNativeImageCache { - private const val MaxEntries = 160 - - private data class Entry( - val length: Long?, - val lastModified: Long?, - val bitmap: ImageBitmap - ) - - private val entries = LinkedHashMap(MaxEntries, 0.75f, true) - - fun peek(path: String): ImageBitmap? { - val source = DesktopEpubImageSource.from(path) ?: return null - return synchronized(entries) { - val entry = entries[source.key] - if (entry != null && entry.length == source.length && entry.lastModified == source.lastModified) { - entry.bitmap - } else { - entries.remove(source.key) - null - } - } - } - - fun load(path: String): ImageBitmap? { - peek(path)?.let { return it } - val source = DesktopEpubImageSource.from(path) ?: return null - val bitmap = decode(source) ?: return null - synchronized(entries) { - entries[source.key] = Entry( - length = source.length, - lastModified = source.lastModified, - bitmap = bitmap - ) - trimToMaxEntries() - } - return bitmap - } - - private fun trimToMaxEntries() { - while (entries.size > MaxEntries) { - val eldestKey = entries.keys.firstOrNull() ?: return - entries.remove(eldestKey) - } - } - - private fun decode(source: DesktopEpubImageSource): ImageBitmap? { - val bytes = source.bytes() ?: return null - if (source.isSvg) { - decodeSvg(bytes)?.let { return it } - } - runCatching { - ImageIO.read(ByteArrayInputStream(bytes))?.toComposeImageBitmap() - }.getOrNull()?.let { return it } - - return runCatching { - SkiaImage.makeFromEncoded(bytes).toComposeImageBitmap() - }.getOrNull() - } - - private fun decodeSvg(bytes: ByteArray): ImageBitmap? { - var data: Data? = null - var dom: SVGDOM? = null - var surface: Surface? = null - return runCatching { - data = Data.makeFromBytes(bytes) - dom = SVGDOM(data!!) - val root = dom?.root - val viewBox = root?.viewBox - val intrinsic = root?.getIntrinsicSize(SVGLengthContext(DefaultSvgViewportPx, DefaultSvgViewportPx)) - val width = (intrinsic?.x?.takeIf { it.isFinite() && it > 0f } - ?: viewBox?.width?.takeIf { it.isFinite() && it > 0f } - ?: DefaultSvgViewportPx) - .roundToInt() - .coerceIn(1, MaxSvgRasterDimensionPx) - val height = (intrinsic?.y?.takeIf { it.isFinite() && it > 0f } - ?: viewBox?.height?.takeIf { it.isFinite() && it > 0f } - ?: DefaultSvgViewportPx) - .roundToInt() - .coerceIn(1, MaxSvgRasterDimensionPx) - dom?.setContainerSize(width.toFloat(), height.toFloat()) - surface = Surface.makeRasterN32Premul(width, height) - val canvas = surface!!.canvas - canvas.clear(SkiaColor.TRANSPARENT) - dom?.render(canvas) - surface!!.makeImageSnapshot().toComposeImageBitmap() - }.getOrNull().also { - surface?.close() - dom?.close() - data?.close() - } - } -} - -private sealed class DesktopEpubImageSource( - val key: String, - val length: Long?, - val lastModified: Long?, - val mimeType: String? -) { - abstract fun bytes(): ByteArray? - - val isSvg: Boolean - get() = mimeType.equals("image/svg+xml", ignoreCase = true) || - key.substringBefore('?').substringBefore('#').endsWith(".svg", ignoreCase = true) - - data class FileSource(private val file: File) : DesktopEpubImageSource( - key = file.absolutePath, - length = file.length(), - lastModified = file.lastModified(), - mimeType = file.extension - .takeIf { it.equals("svg", ignoreCase = true) } - ?.let { "image/svg+xml" } - ) { - override fun bytes(): ByteArray? = runCatching { file.readBytes() }.getOrNull() - } - - data class DataUriSource(private val path: String) : DesktopEpubImageSource( - key = path, - length = path.length.toLong(), - lastModified = null, - mimeType = path.substringAfter("data:", missingDelimiterValue = "") - .substringBefore(';') - .substringBefore(',') - .takeIf { it.isNotBlank() } - ) { - override fun bytes(): ByteArray? { - val marker = "base64," - val markerIndex = path.indexOf(marker, ignoreCase = true) - if (markerIndex < 0) return null - val base64 = path.substring(markerIndex + marker.length) - if (base64.isBlank()) return null - return runCatching { Base64.getDecoder().decode(base64) }.getOrNull() - } - } - - companion object { - fun from(path: String): DesktopEpubImageSource? { - if (path.startsWith("data:image/", ignoreCase = true)) { - return DataUriSource(path) - } - val file = File(path) - return if (file.isFile) FileSource(file) else null - } - } -} - -private const val DefaultSvgViewportPx = 512f -private const val MaxSvgRasterDimensionPx = 4096 - -private fun SemanticImage.readerImageColorFilter(): ColorFilter? { - if (style.blockStyle.filter != "invert(100%)") return null - return ColorFilter.colorMatrix( - ColorMatrix( - floatArrayOf( - -1f, 0f, 0f, 0f, 255f, - 0f, -1f, 0f, 0f, 255f, - 0f, 0f, -1f, 0f, 255f, - 0f, 0f, 0f, 1f, 0f - ) - ) - ) -} - -private fun SemanticImage.readerImageContentScale(): ContentScale { - return when (style.blockStyle.objectFit) { - "cover" -> ContentScale.Crop - "fill" -> ContentScale.FillBounds - "contain", "scale-down" -> ContentScale.Fit - else -> ContentScale.Fit - } -} - -internal fun desktopEpubImageContentAlignment(objectPosition: String?): Alignment { - val tokens = objectPosition - ?.lowercase() - ?.split(Regex("\\s+")) - ?.map { it.trim() } - ?.filter { it.isNotBlank() } - ?: return Alignment.Center - - val orderedHorizontal = tokens.getOrNull(0)?.toDesktopObjectPositionHorizontal() - val orderedVertical = tokens.getOrNull(1)?.toDesktopObjectPositionVertical() - val horizontal = orderedHorizontal - ?: tokens.firstNotNullOfOrNull { it.toDesktopObjectPositionHorizontalKeyword() } - ?: DesktopObjectPositionAxis.CENTER - val vertical = orderedVertical - ?: tokens.firstNotNullOfOrNull { it.toDesktopObjectPositionVerticalKeyword() } - ?: DesktopObjectPositionAxis.CENTER - - return when (vertical) { - DesktopObjectPositionAxis.START -> when (horizontal) { - DesktopObjectPositionAxis.START -> Alignment.TopStart - DesktopObjectPositionAxis.END -> Alignment.TopEnd - else -> Alignment.TopCenter - } - DesktopObjectPositionAxis.END -> when (horizontal) { - DesktopObjectPositionAxis.START -> Alignment.BottomStart - DesktopObjectPositionAxis.END -> Alignment.BottomEnd - else -> Alignment.BottomCenter - } - DesktopObjectPositionAxis.CENTER -> when (horizontal) { - DesktopObjectPositionAxis.START -> Alignment.CenterStart - DesktopObjectPositionAxis.END -> Alignment.CenterEnd - else -> Alignment.Center - } - } -} - -private enum class DesktopObjectPositionAxis { - START, - CENTER, - END -} - -private fun String.toDesktopObjectPositionHorizontal(): DesktopObjectPositionAxis? { - return when (this) { - "left", "0%" -> DesktopObjectPositionAxis.START - "center", "50%" -> DesktopObjectPositionAxis.CENTER - "right", "100%" -> DesktopObjectPositionAxis.END - else -> null - } -} - -private fun String.toDesktopObjectPositionVertical(): DesktopObjectPositionAxis? { - return when (this) { - "top", "0%" -> DesktopObjectPositionAxis.START - "center", "50%" -> DesktopObjectPositionAxis.CENTER - "bottom", "100%" -> DesktopObjectPositionAxis.END - else -> null - } -} - -private fun String.toDesktopObjectPositionHorizontalKeyword(): DesktopObjectPositionAxis? { - return when (this) { - "left" -> DesktopObjectPositionAxis.START - "center" -> DesktopObjectPositionAxis.CENTER - "right" -> DesktopObjectPositionAxis.END - else -> null - } -} - -private fun String.toDesktopObjectPositionVerticalKeyword(): DesktopObjectPositionAxis? { - return when (this) { - "top" -> DesktopObjectPositionAxis.START - "center" -> DesktopObjectPositionAxis.CENTER - "bottom" -> DesktopObjectPositionAxis.END - else -> null - } -} diff --git a/shared/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/LocalBookCoverImage.desktop.kt b/shared/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/LocalBookCoverImage.desktop.kt deleted file mode 100644 index 70d3145..0000000 --- a/shared/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/LocalBookCoverImage.desktop.kt +++ /dev/null @@ -1,42 +0,0 @@ -package org.dueattendant149.bookreader.shared.ui - -import androidx.compose.foundation.Image -import androidx.compose.runtime.LaunchedEffect -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.Modifier -import androidx.compose.ui.layout.ContentScale -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -@Composable -internal actual fun LocalBookCoverImage( - path: String, - contentDescription: String?, - modifier: Modifier -) { - var bitmap by remember(path) { - mutableStateOf(DesktopBookCoverImageCache.peek(path)) - } - - LaunchedEffect(path) { - val loaded = withContext(Dispatchers.IO) { - DesktopBookCoverImageCache.load(path) - } - if (loaded != null && loaded != bitmap) { - bitmap = loaded - } - } - - if (bitmap != null) { - Image( - bitmap = bitmap!!, - contentDescription = contentDescription, - modifier = modifier, - contentScale = ContentScale.Crop - ) - } -} diff --git a/shared/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalLayer.desktop.kt b/shared/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalLayer.desktop.kt deleted file mode 100644 index 46e69a6..0000000 --- a/shared/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalLayer.desktop.kt +++ /dev/null @@ -1,499 +0,0 @@ -package org.dueattendant149.bookreader.shared.ui - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.compositionLocalOf -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.platform.LocalDensity -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.DpSize -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.WindowPosition -import androidx.compose.ui.window.Window as ComposeWindow -import androidx.compose.ui.window.rememberWindowState -import kotlinx.coroutines.delay -import java.awt.EventQueue -import java.awt.KeyboardFocusManager -import java.awt.Point -import java.awt.event.WindowAdapter -import java.awt.event.WindowEvent -import java.awt.Window as AwtWindow -import java.util.Collections -import java.util.WeakHashMap -import javax.swing.RootPaneContainer - -private val LocalSharedReaderModalOwnerWindow = compositionLocalOf { null } -private val SharedReaderModalOwnerByWindow: MutableMap = - Collections.synchronizedMap(WeakHashMap()) - -@Composable -actual fun SharedReaderModalOwnerWindowProvider( - ownerWindow: Any?, - content: @Composable () -> Unit -) { - CompositionLocalProvider( - LocalSharedReaderModalOwnerWindow provides (ownerWindow as? AwtWindow), - content = content - ) -} - -@Composable -internal actual fun SharedReaderModalLayer( - onDismiss: () -> Unit, - level: SharedReaderModalLevel, - content: @Composable () -> Unit -) { - val anchor = LocalSharedReaderModalAnchorBounds.current - val density = LocalDensity.current - val focusableOverride = LocalSharedReaderModalFocusableOverride.current - val explicitOwnerWindow = LocalSharedReaderModalOwnerWindow.current - val fallbackOwnerWindow = remember { currentNonModalOwnerWindow() } - val ownerWindow = explicitOwnerWindow ?: fallbackOwnerWindow - val modalWindowFocusable = sharedReaderModalLayerWindowFocusable( - level = level, - focusableOverride = focusableOverride - ) - val dialogSize = with(density) { - anchor?.let { - when { - level.isChromeLayer() -> { - DpSize( - width = it.widthPx.toDp().coerceAtLeast(360.dp), - height = level.chromeLayerHeight().coerceAtMost(it.heightPx.toDp().coerceAtLeast(1.dp)) - ) - } - level.isEdgePanelLayer() -> { - DpSize( - width = sharedReaderModalEdgePanelLayerWidth( - level = level, - anchorWidth = it.widthPx.toDp() - ), - height = it.heightPx.toDp().coerceAtLeast(360.dp) - ) - } - else -> { - DpSize( - width = it.widthPx.toDp().coerceAtLeast(360.dp), - height = it.heightPx.toDp().coerceAtLeast(360.dp) - ) - } - } - } ?: DpSize(720.dp, 620.dp) - } - val dialogPosition = sharedReaderModalLayerPosition( - anchor = anchor, - ownerWindow = ownerWindow, - dialogSize = dialogSize, - level = level, - density = density - ) - val state = rememberWindowState(position = dialogPosition, size = dialogSize) - val windowTitle = when (level) { - SharedReaderModalLevel.Panel -> "Reader Panel" - SharedReaderModalLevel.PanelLeft -> "Reader Navigation" - SharedReaderModalLevel.PanelRight -> "Reader Tools" - SharedReaderModalLevel.Popup -> "Reader Popup" - SharedReaderModalLevel.ChromeTop -> "Reader Chrome Top" - SharedReaderModalLevel.ChromeBottom -> "Reader Chrome Bottom" - } - var modalVisible by remember(ownerWindow, explicitOwnerWindow, level) { - mutableStateOf(sharedReaderModalLayerVisible(ownerWindow, explicitOwnerWindow, null)) - } - - LaunchedEffect(dialogPosition, dialogSize) { - if (state.position != dialogPosition) { - state.position = dialogPosition - } - if (state.size != dialogSize) { - state.size = dialogSize - } - } - if (!level.isChromeLayer()) { - DisposableEffect(ownerWindow) { - onDispose { - ownerWindow?.restoreFocusAfterSharedReaderModal() - } - } - } - DisposableEffect(ownerWindow, explicitOwnerWindow, level) { - if (ownerWindow == null || explicitOwnerWindow == null) { - modalVisible = true - onDispose {} - } else { - var disposed = false - fun hideImmediately(ownerClosing: Boolean) { - if (disposed) return - if ( - sharedReaderModalLayerShouldHideImmediately( - ownerShowing = ownerWindow.isShowing, - ownerDisplayable = ownerWindow.isDisplayable, - ownerClosing = ownerClosing - ) - ) { - modalVisible = false - } - } - fun syncVisibility(oppositeWindow: AwtWindow?) { - if (disposed) return - if ( - sharedReaderModalLayerShouldHideImmediately( - ownerShowing = ownerWindow.isShowing, - ownerDisplayable = ownerWindow.isDisplayable, - ownerClosing = false - ) - ) { - hideImmediately(ownerClosing = false) - return - } - val nextVisible = sharedReaderModalLayerVisible(ownerWindow, explicitOwnerWindow, oppositeWindow) - if (nextVisible) { - modalVisible = true - } else { - EventQueue.invokeLater { - if (!disposed) { - modalVisible = sharedReaderModalLayerVisible(ownerWindow, explicitOwnerWindow, null) - } - } - } - } - val listener = object : WindowAdapter() { - override fun windowClosing(e: WindowEvent?) = hideImmediately(ownerClosing = true) - override fun windowClosed(e: WindowEvent?) = hideImmediately(ownerClosing = true) - override fun windowActivated(e: WindowEvent?) = syncVisibility(e?.oppositeWindow) - override fun windowDeactivated(e: WindowEvent?) = syncVisibility(e?.oppositeWindow) - override fun windowGainedFocus(e: WindowEvent?) = syncVisibility(e?.oppositeWindow) - override fun windowLostFocus(e: WindowEvent?) = syncVisibility(e?.oppositeWindow) - override fun windowIconified(e: WindowEvent?) = syncVisibility(e?.oppositeWindow) - override fun windowDeiconified(e: WindowEvent?) = syncVisibility(e?.oppositeWindow) - } - ownerWindow.addWindowListener(listener) - ownerWindow.addWindowFocusListener(listener) - syncVisibility(null) - onDispose { - disposed = true - ownerWindow.removeWindowListener(listener) - ownerWindow.removeWindowFocusListener(listener) - } - } - } - - if (modalVisible) { - ComposeWindow( - onCloseRequest = onDismiss, - state = state, - title = windowTitle, - undecorated = true, - transparent = true, - resizable = false, - alwaysOnTop = true, - focusable = modalWindowFocusable - ) { - val modalWindow = window - DisposableEffect(modalWindow, ownerWindow, explicitOwnerWindow, level) { - modalWindow.name = SharedReaderModalWindowNamePrefix + level.name - if (ownerWindow != null && explicitOwnerWindow != null) { - synchronized(SharedReaderModalOwnerByWindow) { - SharedReaderModalOwnerByWindow[modalWindow] = ownerWindow - } - } - var disposed = false - fun syncVisibility(oppositeWindow: AwtWindow?) { - if (disposed) return - if (ownerWindow != null && explicitOwnerWindow != null) { - if ( - sharedReaderModalLayerShouldHideImmediately( - ownerShowing = ownerWindow.isShowing, - ownerDisplayable = ownerWindow.isDisplayable, - ownerClosing = false - ) - ) { - modalVisible = false - return - } - val nextVisible = sharedReaderModalLayerVisible(ownerWindow, explicitOwnerWindow, oppositeWindow) - if (nextVisible) { - modalVisible = true - } else { - EventQueue.invokeLater { - if (!disposed) { - modalVisible = sharedReaderModalLayerVisible(ownerWindow, explicitOwnerWindow, null) - } - } - } - } - } - val listener = object : WindowAdapter() { - override fun windowActivated(e: WindowEvent?) = syncVisibility(e?.oppositeWindow) - override fun windowDeactivated(e: WindowEvent?) = syncVisibility(e?.oppositeWindow) - override fun windowGainedFocus(e: WindowEvent?) = syncVisibility(e?.oppositeWindow) - override fun windowLostFocus(e: WindowEvent?) = syncVisibility(e?.oppositeWindow) - override fun windowIconified(e: WindowEvent?) = syncVisibility(e?.oppositeWindow) - override fun windowClosed(e: WindowEvent?) = syncVisibility(e?.oppositeWindow) - } - modalWindow.addWindowListener(listener) - modalWindow.addWindowFocusListener(listener) - onDispose { - disposed = true - modalWindow.removeWindowListener(listener) - modalWindow.removeWindowFocusListener(listener) - synchronized(SharedReaderModalOwnerByWindow) { - SharedReaderModalOwnerByWindow.remove(modalWindow) - } - } - } - LaunchedEffect(modalWindow, level, modalWindowFocusable) { - modalWindow.name = SharedReaderModalWindowNamePrefix + level.name - modalWindow.isAlwaysOnTop = true - modalWindow.setFocusableWindowState(modalWindowFocusable) - val frontAttempts = when (level) { - SharedReaderModalLevel.Popup -> 4 - SharedReaderModalLevel.Panel, - SharedReaderModalLevel.PanelLeft, - SharedReaderModalLevel.PanelRight -> 3 - SharedReaderModalLevel.ChromeTop, - SharedReaderModalLevel.ChromeBottom -> 1 - } - repeat(frontAttempts) { attempt -> - delay(if (attempt == 0) 30L else 80L) - if (!modalWindow.isDisplayable) return@repeat - runCatching { - modalWindow.isAlwaysOnTop = true - modalWindow.toFront() - if (modalWindowFocusable && !level.isEdgePanelLayer()) { - modalWindow.requestFocus() - modalWindow.requestFocusInWindow() - } - } - } - } - content() - } - } -} - -internal fun sharedReaderModalLayerWindowFocusable( - level: SharedReaderModalLevel, - focusableOverride: Boolean? -): Boolean { - return focusableOverride ?: !level.isChromeLayer() -} - -internal actual fun sharedReaderModalLayerUsesSizedEdgeWindow(level: SharedReaderModalLevel): Boolean { - return level.isEdgePanelLayer() -} - -private const val SharedReaderModalWindowNamePrefix = "shared-reader-modal:" -private val SharedReaderChromeTopLayerHeight = 104.dp -private val SharedReaderChromeBottomLayerHeight = 164.dp -private val SharedReaderChromeBottomLayerOverlap = 8.dp -private val SharedReaderLeftPanelWidth = 340.dp -private val SharedReaderRightPanelWidth = 380.dp -private val SharedReaderLeftNarrowPanelMaxWidth = 320.dp -private val SharedReaderRightNarrowPanelMaxWidth = 360.dp -private val SharedReaderNarrowPanelFraction = 0.92f -private val SharedReaderWidePanelBreakpoint = 1120.dp - -private fun sharedReaderModalLayerVisible( - ownerWindow: AwtWindow?, - explicitOwnerWindow: AwtWindow?, - oppositeWindow: AwtWindow? -): Boolean { - if (explicitOwnerWindow == null) return true - return ownerWindow?.sharedReaderChromeLayerVisible(oppositeWindow) == true -} - -internal fun sharedReaderModalChromeLayerVisible( - ownerShowing: Boolean, - ownerDisplayable: Boolean, - ownerMinimized: Boolean, - ownerActive: Boolean, - ownerFocused: Boolean, - ownerModalActive: Boolean -): Boolean { - return ownerShowing && - ownerDisplayable && - !ownerMinimized && - (ownerActive || ownerFocused || ownerModalActive) -} - -internal fun sharedReaderModalLayerShouldHideImmediately( - ownerShowing: Boolean, - ownerDisplayable: Boolean, - ownerClosing: Boolean -): Boolean { - return ownerClosing || !ownerShowing || !ownerDisplayable -} - -private fun AwtWindow.sharedReaderChromeLayerVisible(oppositeWindow: AwtWindow?): Boolean { - return sharedReaderModalChromeLayerVisible( - ownerShowing = isShowing, - ownerDisplayable = isDisplayable, - ownerMinimized = (this as? java.awt.Frame)?.let { frame -> - frame.extendedState and java.awt.Frame.ICONIFIED != 0 - } == true, - ownerActive = isActive, - ownerFocused = isFocused, - ownerModalActive = oppositeWindow.isSharedReaderModalWindowForOwner(this) || - sharedReaderModalWindowActiveForOwner(this) - ) -} - -private fun sharedReaderModalLayerPosition( - anchor: SharedReaderModalAnchorBounds?, - ownerWindow: AwtWindow?, - dialogSize: DpSize, - level: SharedReaderModalLevel, - density: Density -): WindowPosition { - return with(density) { - val ownerLocation = ownerWindow?.let { window -> - runCatching { window.sharedReaderModalContentLocationOnScreen() }.getOrNull() - } - if (anchor != null && ownerLocation != null) { - val topPx = when (level) { - SharedReaderModalLevel.ChromeBottom -> sharedReaderModalChromeBottomLayerTopPx( - anchorTopPx = anchor.topPx, - anchorHeightPx = anchor.heightPx, - dialogHeightPx = dialogSize.height.toPx(), - overlapPx = SharedReaderChromeBottomLayerOverlap.toPx() - ) - else -> anchor.topPx - } - val leftPx = when (level) { - SharedReaderModalLevel.PanelRight -> anchor.leftPx + anchor.widthPx - dialogSize.width.toPx() - else -> anchor.leftPx - } - WindowPosition( - (ownerLocation.x + leftPx).toDp(), - (ownerLocation.y + topPx).toDp() - ) - } else { - WindowPosition(Alignment.Center) - } - } -} - -internal fun sharedReaderModalChromeBottomLayerTopPx( - anchorTopPx: Float, - anchorHeightPx: Float, - dialogHeightPx: Float, - overlapPx: Float -): Float { - return anchorTopPx + anchorHeightPx - dialogHeightPx + overlapPx -} - -private fun AwtWindow.sharedReaderModalContentLocationOnScreen(): Point { - val contentPane = (this as? RootPaneContainer)?.contentPane - if (contentPane != null && contentPane.isShowing) { - return contentPane.locationOnScreen - } - return locationOnScreen -} - -private fun SharedReaderModalLevel.isChromeLayer(): Boolean { - return this == SharedReaderModalLevel.ChromeTop || this == SharedReaderModalLevel.ChromeBottom -} - -private fun SharedReaderModalLevel.isEdgePanelLayer(): Boolean { - return this == SharedReaderModalLevel.PanelLeft || this == SharedReaderModalLevel.PanelRight -} - -private fun SharedReaderModalLevel.chromeLayerHeight() = when (this) { - SharedReaderModalLevel.ChromeTop -> SharedReaderChromeTopLayerHeight - SharedReaderModalLevel.ChromeBottom -> SharedReaderChromeBottomLayerHeight - else -> 0.dp -} - -internal fun sharedReaderModalEdgePanelLayerWidth( - level: SharedReaderModalLevel, - anchorWidth: Dp -): Dp { - val preferredWideWidth = when (level) { - SharedReaderModalLevel.PanelLeft -> SharedReaderLeftPanelWidth - else -> SharedReaderRightPanelWidth - } - val preferredNarrowWidth = when (level) { - SharedReaderModalLevel.PanelLeft -> minOf(SharedReaderLeftNarrowPanelMaxWidth, anchorWidth * SharedReaderNarrowPanelFraction) - else -> minOf(SharedReaderRightNarrowPanelMaxWidth, anchorWidth * SharedReaderNarrowPanelFraction) - } - return if (anchorWidth >= SharedReaderWidePanelBreakpoint) { - preferredWideWidth.coerceAtMost(anchorWidth) - } else { - preferredNarrowWidth.coerceAtMost(anchorWidth) - }.coerceAtLeast(1.dp) -} - -private fun AwtWindow.restoreFocusAfterSharedReaderModal() { - EventQueue.invokeLater { - if (!isDisplayable || !isShowing) return@invokeLater - if (this is java.awt.Frame && extendedState and java.awt.Frame.ICONIFIED != 0) { - extendedState = extendedState and java.awt.Frame.ICONIFIED.inv() - } - toFront() - requestFocus() - requestFocusInWindow() - focusOwner?.requestFocus() - } -} - -private fun currentNonModalOwnerWindow(): AwtWindow? { - val activeWindow = KeyboardFocusManager.getCurrentKeyboardFocusManager().activeWindow - if (activeWindow != null && !activeWindow.isSharedReaderModalWindow()) { - return activeWindow - } - return AwtWindow.getWindows() - .filter { window -> window.isShowing && window.isDisplayable && !window.isSharedReaderModalWindow() } - .maxByOrNull { window -> - when { - window.isFocused -> 3 - window.isActive -> 2 - window.isVisible -> 1 - else -> 0 - } - } -} - -private fun AwtWindow.isSharedReaderModalWindow(): Boolean { - val windowTitle = when (this) { - is java.awt.Dialog -> title - is java.awt.Frame -> title - else -> "" - } - return name?.startsWith(SharedReaderModalWindowNamePrefix) == true || - windowTitle.startsWith("Reader Panel") || - windowTitle.startsWith("Reader Popup") || - windowTitle.startsWith("Reader Chrome") -} - -private fun AwtWindow?.isSharedReaderModalWindowForOwner(ownerWindow: AwtWindow): Boolean { - val window = this ?: return false - return window.sharedReaderModalOwnerInWindowChain() == ownerWindow -} - -private fun sharedReaderModalWindowActiveForOwner(ownerWindow: AwtWindow): Boolean { - return AwtWindow.getWindows().any { window -> - window.isShowing && - window.isDisplayable && - window.isSharedReaderModalWindowForOwner(ownerWindow) && - (window.isActive || window.isFocused) - } -} - -private fun AwtWindow.sharedReaderModalOwnerInWindowChain(): AwtWindow? { - var current: AwtWindow? = this - while (current != null) { - val window = current - synchronized(SharedReaderModalOwnerByWindow) { - SharedReaderModalOwnerByWindow[window] - }?.let { owner -> return owner } - current = window.owner - } - return null -} diff --git a/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/HtmlParserLinkTest.kt b/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/HtmlParserLinkTest.kt deleted file mode 100644 index 5a99006..0000000 --- a/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/HtmlParserLinkTest.kt +++ /dev/null @@ -1,151 +0,0 @@ -package org.dueattendant149.bookreader.paginatedreader - -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.unit.Constraints -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.sp -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -class HtmlParserLinkTest { - @Test - fun `block anchor propagates href to paragraph text`() { - val blocks = parse( - """ - - -

Continue reading

- - - """.trimIndent() - ) - - val paragraph = blocks.single() as SemanticParagraph - val linkSpan = paragraph.spans.single { it.linkHref == "chapter2.xhtml#start" } - - assertEquals("Continue reading", paragraph.text) - assertEquals(0, linkSpan.start) - assertEquals(paragraph.text.length, linkSpan.end) - } - - @Test - fun `block anchor propagates href to heading text`() { - val blocks = parse( - """ - - -

Details

- - - """.trimIndent() - ) - - val heading = blocks.single() as SemanticHeader - - assertEquals("Details", heading.text) - assertTrue(heading.spans.any { span -> - span.linkHref == "#details" && - span.start == 0 && - span.end == heading.text.length - }) - } - - @Test - fun `nested inline spans inherit anchor href`() { - val blocks = parse( - """ - - -

note

- - - """.trimIndent() - ) - - val paragraph = blocks.single() as SemanticParagraph - - assertEquals("note", paragraph.text) - assertTrue(paragraph.spans.any { span -> - span.tag == "span" && - span.linkHref == "notes.xhtml#n1" && - span.start == 0 && - span.end == paragraph.text.length - }) - } - - @Test - fun `namespaced anchor href is treated as link`() { - val blocks = parse( - """ - - -

Appendix

- - - """.trimIndent() - ) - - val paragraph = blocks.single() as SemanticParagraph - - assertEquals("Appendix", paragraph.text) - assertTrue(paragraph.spans.any { span -> - span.linkHref == "appendix.xhtml#more" && - span.start == 0 && - span.end == paragraph.text.length - }) - } - - @Test - fun `css font family resolves onto block and inline span styles`() { - val cssRules = CssParser.parse( - cssContent = """ - p { font-family: "BodyFace"; } - i { font-style: italic; } - """.trimIndent(), - cssPath = null, - baseFontSizeSp = 16f, - density = 1f, - constraints = Constraints(maxWidth = 400, maxHeight = 800), - isDarkTheme = false - ).rules - - val blocks = parse( - html = """ - - -

plain italic

- - - """.trimIndent(), - cssRules = cssRules, - fontFamilyMap = mapOf("bodyface" to FontFamily.Serif) - ) - - val paragraph = blocks.single() as SemanticParagraph - val italicSpan = paragraph.spans.single { it.tag == "i" } - - assertEquals(FontFamily.Serif, paragraph.style.spanStyle.fontFamily) - assertEquals(FontFamily.Serif, italicSpan.style.spanStyle.fontFamily) - assertEquals(FontStyle.Italic, italicSpan.style.spanStyle.fontStyle) - } - - private fun parse( - html: String, - cssRules: OptimizedCssRules = OptimizedCssRules(), - fontFamilyMap: Map = emptyMap() - ): List { - return htmlToSemanticBlocks( - html = html, - cssRules = cssRules, - textStyle = TextStyle(fontSize = 16.sp), - chapterAbsPath = "OEBPS/chapter1.xhtml", - extractionBasePath = "", - density = Density(1f), - fontFamilyMap = fontFamilyMap, - constraints = Constraints(maxWidth = 400, maxHeight = 800) - ) - } -} diff --git a/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderTtsFileCacheManagerTest.kt b/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderTtsFileCacheManagerTest.kt deleted file mode 100644 index 40f1527..0000000 --- a/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderTtsFileCacheManagerTest.kt +++ /dev/null @@ -1,48 +0,0 @@ -package org.dueattendant149.bookreader.shared - -import java.nio.file.Files -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/dueattendant149/bookreader/reader/shared/opds/SharedOpdsParserTest.kt b/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsParserTest.kt deleted file mode 100644 index 56281ce..0000000 --- a/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsParserTest.kt +++ /dev/null @@ -1,264 +0,0 @@ -package org.dueattendant149.bookreader.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 cover links from opds cover relations and image typed links`() { - val xmlFeed = SharedOpdsParser().parse( - bodyString = """ - - - XML Catalog - - xml-cover - XML Cover Book - - - - - """.trimIndent(), - baseUrl = "https://grimmory.example/api/v1/opds/catalog" - ) - - assertEquals("https://grimmory.example/api/v1/opds/42/cover", xmlFeed.entries.single().coverUrl) - - val jsonFeed = SharedOpdsParser().parse( - bodyString = """ - { - "publications": [ - { - "metadata": {"identifier": "json-cover", "title": "JSON Cover Book"}, - "links": [ - {"rel": "cover", "href": "/api/v1/opds/77/cover", "type": "image/jpeg"}, - {"rel": "http://opds-spec.org/acquisition", "href": "/api/v1/opds/77/download", "type": "application/pdf"} - ] - } - ] - } - """.trimIndent(), - baseUrl = "https://grimmory.example/api/v1/opds/catalog" - ) - - assertEquals("https://grimmory.example/api/v1/opds/77/cover", jsonFeed.entries.single().coverUrl) - } - - @Test - fun `extract OpenSearch template prefers OPDS acquisition feeds over generic Atom feeds`() { - val template = SharedOpdsParser().extractOpenSearchTemplate( - bodyString = """ - - - - - - """.trimIndent(), - openSearchUrl = "https://example.org/opensearch" - ) - - assertEquals( - "https://example.org/feeds/opds/all?query={searchTerms}&per-page={count}&page={startPage}", - template - ) - } - - @Test - fun `parse ebook enclosure links as acquisitions`() { - val feed = SharedOpdsParser().parse( - bodyString = """ - - - Atom Search - - atom-book - Atom Book - - - - - """.trimIndent(), - baseUrl = "https://example.org/feeds/atom/all" - ) - - assertEquals( - OpdsAcquisition("https://example.org/feeds/atom/downloads/book.epub", "application/epub+zip"), - feed.entries.single().acquisitions.single() - ) - } - - @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/dueattendant149/bookreader/reader/shared/reader/SharedEpubMetadataEditorTest.kt b/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedEpubMetadataEditorTest.kt deleted file mode 100644 index 36b0f0b..0000000 --- a/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedEpubMetadataEditorTest.kt +++ /dev/null @@ -1,166 +0,0 @@ -package org.dueattendant149.bookreader.shared.reader - -import java.io.File -import java.util.zip.CRC32 -import java.util.zip.ZipEntry -import java.util.zip.ZipFile -import java.util.zip.ZipInputStream -import java.util.zip.ZipOutputStream -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertNotNull -import kotlin.test.assertTrue - -class SharedEpubMetadataEditorTest { - @Test - fun `rewrite updates existing OPF metadata`() = withTempDir { dir -> - val source = File(dir, "source.epub") - val output = File(dir, "output.epub") - writeEpub(source, metadata = """ - - Old - Old Author - Old summary - - - - """.trimIndent()) - - val result = SharedEpubMetadataEditor.rewrite( - source = source, - destination = output, - update = update() - ) - - assertEquals("New Title", result.title) - assertEquals("New Author", result.author) - assertEquals("New summary", result.description) - assertEquals("New Series", result.seriesName) - assertEquals(2.5, result.seriesIndex) - assertEquals(result, SharedEpubMetadataEditor.readMetadata(output)) - } - - @Test - fun `rewrite creates missing metadata elements`() = withTempDir { dir -> - val source = File(dir, "source.epub") - val output = File(dir, "output.epub") - writeEpub(source, metadata = "") - - val result = SharedEpubMetadataEditor.rewrite(source, output, update()) - - assertEquals("New Title", result.title) - assertEquals("New Author", result.author) - assertEquals("New summary", result.description) - assertEquals("New Series", result.seriesName) - assertEquals(2.5, result.seriesIndex) - } - - @Test - fun `rewrite preserves non OPF zip entries`() = withTempDir { dir -> - val source = File(dir, "source.epub") - val output = File(dir, "output.epub") - writeEpub(source) - - SharedEpubMetadataEditor.rewrite(source, output, update()) - - ZipFile(output).use { zip -> - assertEquals("chapter", zip.getInputStream(assertNotNull(zip.getEntry("OEBPS/chapter.xhtml"))).reader().readText()) - } - } - - @Test - fun `rewrite keeps mimetype first and stored`() = withTempDir { dir -> - val source = File(dir, "source.epub") - val output = File(dir, "output.epub") - writeEpub(source) - - SharedEpubMetadataEditor.rewrite(source, output, update()) - - ZipInputStream(output.inputStream()).use { zip -> - val first = assertNotNull(zip.nextEntry) - assertEquals("mimetype", first.name) - assertEquals(ZipEntry.STORED, first.method) - } - } - - @Test - fun `rewrite in place rejects invalid epub without replacing source`() = withTempDir { dir -> - val source = File(dir, "broken.epub").apply { writeText("not an epub") } - val backup = File(dir, "backup.epub") - - assertFailsWith { - SharedEpubMetadataEditor.rewriteInPlace(source, backup, update()) - } - - assertEquals("not an epub", source.readText()) - assertTrue(!backup.exists()) - } - - private fun update(): SharedEpubMetadataUpdate { - return SharedEpubMetadataUpdate( - title = "New Title", - author = "New Author", - description = "New summary", - seriesName = "New Series", - seriesIndex = 2.5 - ) - } - - private fun writeEpub( - target: File, - metadata: String = """ - - Old - - """.trimIndent() - ) { - ZipOutputStream(target.outputStream()).use { zip -> - zip.putStoredText("mimetype", "application/epub+zip") - zip.putText( - "META-INF/container.xml", - """""" - ) - zip.putText( - "OEBPS/content.opf", - """ - - $metadata - - - - """.trimIndent() - ) - zip.putText("OEBPS/chapter.xhtml", "chapter") - } - } - - private fun ZipOutputStream.putText(name: String, text: String) { - putNextEntry(ZipEntry(name)) - write(text.toByteArray()) - closeEntry() - } - - private fun ZipOutputStream.putStoredText(name: String, text: String) { - val bytes = text.toByteArray() - val crc = CRC32().apply { update(bytes) }.value - val entry = ZipEntry(name).apply { - method = ZipEntry.STORED - size = bytes.size.toLong() - compressedSize = bytes.size.toLong() - this.crc = crc - } - putNextEntry(entry) - write(bytes) - closeEntry() - } - - private inline fun withTempDir(block: (File) -> Unit) { - val dir = createTempDir(prefix = "epub-metadata-test") - try { - block(dir) - } finally { - dir.deleteRecursively() - } - } -} diff --git a/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedEpubPaginationCacheTest.kt b/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedEpubPaginationCacheTest.kt deleted file mode 100644 index dab48e6..0000000 --- a/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedEpubPaginationCacheTest.kt +++ /dev/null @@ -1,280 +0,0 @@ -package org.dueattendant149.bookreader.shared.reader - -import androidx.compose.ui.unit.sp -import org.dueattendant149.bookreader.paginatedreader.CssStyle -import org.dueattendant149.bookreader.paginatedreader.SemanticParagraph -import kotlinx.coroutines.runBlocking -import java.nio.file.Files -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertNull - -class SharedEpubPaginationCacheTest { - - @Test - fun `page cache round trips measured pages`() = runBlocking { - val root = Files.createTempDirectory("reader-page-cache").toFile() - try { - val cache = SharedEpubPaginationCache(root) - val book = cacheBook() - val settings = ReaderSettings(fontSize = 19, lineSpacing = 1.5f) - val viewport = ReaderViewportSpec(widthPx = 960, heightPx = 720) - val pages = listOf( - ReaderPage( - pageIndex = 12, - chapterIndex = 0, - chapterTitle = "One", - text = "Cached page", - startOffset = 4, - endOffset = 15 - ) - ) - - cache.save(book, settings, viewport, pages) - val loaded = cache.load(book, settings, viewport) - - assertNotNull(loaded) - assertEquals(1, loaded.size) - assertEquals(0, loaded.first().pageIndex) - assertEquals("Cached page", loaded.first().text) - assertEquals(4, loaded.first().startOffset) - assertEquals(15, loaded.first().endOffset) - } finally { - root.deleteRecursively() - } - } - - @Test - fun `page cache misses when viewport or chapter content changes`() = runBlocking { - val root = Files.createTempDirectory("reader-page-cache").toFile() - try { - val cache = SharedEpubPaginationCache(root) - val book = cacheBook() - val settings = ReaderSettings() - val viewport = ReaderViewportSpec(widthPx = 900, heightPx = 700) - val pages = listOf( - ReaderPage( - pageIndex = 0, - chapterIndex = 0, - chapterTitle = "One", - text = "Cached page", - startOffset = 0, - endOffset = 11 - ) - ) - - cache.save(book, settings, viewport, pages) - - assertNull(cache.load(book, settings, viewport.copy(widthPx = 901))) - assertNull( - cache.load( - book.copy( - chapters = book.chapters.map { chapter -> - chapter.copy(plainText = chapter.plainText + " Changed.") - } - ), - settings, - viewport - ) - ) - } finally { - root.deleteRecursively() - } - } - - @Test - fun `chapter page cache round trips one measured chapter`() = runBlocking { - val root = Files.createTempDirectory("reader-page-cache").toFile() - try { - val cache = SharedEpubPaginationCache(root) - val book = cacheBook().copy( - chapters = listOf( - cacheBook().chapters.first(), - cacheBook().chapters.first().copy(id = "chapter-2", title = "Two", plainText = "Second chapter.") - ) - ) - val settings = ReaderSettings() - val viewport = ReaderViewportSpec(widthPx = 960, heightPx = 720) - val pages = listOf( - ReaderPage( - pageIndex = 0, - chapterIndex = 0, - chapterTitle = "One", - text = "First cached page", - startOffset = 0, - endOffset = 17 - ), - ReaderPage( - pageIndex = 1, - chapterIndex = 1, - chapterTitle = "Two", - text = "Second cached page", - startOffset = 0, - endOffset = 18 - ) - ) - - cache.save(book, settings, viewport, pages) - val loadedChapter = cache.loadChapter(book, settings, viewport, chapterIndex = 1) - - assertNotNull(loadedChapter) - assertEquals(1, loadedChapter.size) - assertEquals(1, loadedChapter.first().pageIndex) - assertEquals(1, loadedChapter.first().chapterIndex) - assertEquals("Second cached page", loadedChapter.first().text) - } finally { - root.deleteRecursively() - } - } - - @Test - fun `pagination cache key changes for spread mode`() { - val root = Files.createTempDirectory("reader-page-cache").toFile() - try { - val cache = SharedEpubPaginationCache(root) - val book = cacheBook() - val viewport = ReaderViewportSpec(widthPx = 960, heightPx = 720) - val single = cache.keyFor(book, ReaderSettings(pageSpreadMode = ReaderPageSpreadMode.SINGLE), viewport) - val spread = cache.keyFor(book, ReaderSettings(pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE), viewport) - - assertFalse(single.configHash == spread.configHash) - } finally { - root.deleteRecursively() - } - } - - @Test - fun `page cache ignores semanticless pages for semantic books`() = runBlocking { - val root = Files.createTempDirectory("reader-page-cache").toFile() - try { - val cache = SharedEpubPaginationCache(root) - val book = cacheBook().copy( - chapters = listOf( - cacheBook().chapters.first().copy( - semanticBlocks = listOf( - SemanticParagraph( - text = "Cached page content.", - spans = emptyList(), - style = CssStyle(fontSize = 22.sp), - elementId = null, - cfi = null, - startCharOffsetInSource = 0, - blockIndex = 0 - ) - ) - ) - ) - ) - val settings = ReaderSettings() - val viewport = ReaderViewportSpec(widthPx = 960, heightPx = 720) - val pages = listOf( - ReaderPage( - pageIndex = 0, - chapterIndex = 0, - chapterTitle = "One", - text = "Cached page", - startOffset = 0, - endOffset = 11 - ) - ) - - cache.save(book, settings, viewport, pages) - - assertNull(cache.load(book, settings, viewport)) - } finally { - root.deleteRecursively() - } - } - - @Test - fun `clear all removes persisted and memory pagination pages`() = runBlocking { - val root = Files.createTempDirectory("reader-page-cache").toFile() - try { - val cache = SharedEpubPaginationCache(root) - val book = cacheBook() - val settings = ReaderSettings() - val viewport = ReaderViewportSpec(widthPx = 960, heightPx = 720) - val pages = listOf( - ReaderPage( - pageIndex = 0, - chapterIndex = 0, - chapterTitle = "One", - text = "Cached page", - startOffset = 0, - endOffset = 11 - ) - ) - - cache.save(book, settings, viewport, pages) - assertNotNull(cache.load(book, settings, viewport)) - - cache.clearAll() - - assertNull(cache.load(book, settings, viewport)) - } finally { - root.deleteRecursively() - } - } - - @Test - fun `saving more than three configurations removes oldest page cache`() = runBlocking { - val root = Files.createTempDirectory("reader-page-cache").toFile() - try { - val book = cacheBook() - val settings = ReaderSettings() - val pages = listOf( - ReaderPage( - pageIndex = 0, - chapterIndex = 0, - chapterTitle = "One", - text = "Cached page", - startOffset = 0, - endOffset = 11 - ) - ) - val viewports = listOf( - ReaderViewportSpec(widthPx = 900, heightPx = 700), - ReaderViewportSpec(widthPx = 901, heightPx = 700), - ReaderViewportSpec(widthPx = 902, heightPx = 700), - ReaderViewportSpec(widthPx = 903, heightPx = 700) - ) - val writer = SharedEpubPaginationCache(root) - - viewports.take(3).forEachIndexed { index, viewport -> - writer.save(book, settings, viewport, pages) - val key = writer.keyFor(book, settings, viewport) - val file = root - .resolve(key.bookHash) - .resolve("${key.configHash.toUInt().toString(16)}.pages.pb") - file.setLastModified((index + 1) * 1_000L) - } - writer.save(book, settings, viewports.last(), pages) - val reader = SharedEpubPaginationCache(root) - - assertNull(reader.load(book, settings, viewports.first())) - assertNotNull(reader.load(book, settings, viewports.last())) - } finally { - root.deleteRecursively() - } - } - - private fun cacheBook(): SharedEpubBook { - return SharedEpubBook( - id = "book-id", - fileName = "book.epub", - title = "Book", - author = "Author", - chapters = listOf( - SharedEpubChapter( - id = "chapter-1", - title = "One", - plainText = "Cached page content.", - htmlContent = "

Cached page content.

", - baseHref = "one.xhtml" - ) - ) - ) - } -} diff --git a/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmBookLoadCacheTest.kt b/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmBookLoadCacheTest.kt deleted file mode 100644 index 48a3116..0000000 --- a/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmBookLoadCacheTest.kt +++ /dev/null @@ -1,126 +0,0 @@ -package org.dueattendant149.bookreader.shared.reader - -import androidx.compose.ui.unit.sp -import org.dueattendant149.bookreader.paginatedreader.CssStyle -import org.dueattendant149.bookreader.paginatedreader.SemanticParagraph -import org.dueattendant149.bookreader.shared.FileType -import java.nio.file.Files -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull - -class SharedJvmBookLoadCacheTest { - - @Test - fun `book load cache round trips parsed shared book`() { - val root = Files.createTempDirectory("reader-book-load-cache").toFile() - try { - val cache = SharedJvmBookLoadCache(root) - val key = SharedJvmBookLoadCacheKey( - canonicalPath = "C:/Books/book.epub", - type = FileType.EPUB, - length = 1234L, - lastModified = 5678L - ) - val book = SharedEpubBook( - id = "C:/Books/book.epub", - fileName = "book.epub", - title = "Cached Book", - author = "Author", - css = mapOf("style.css" to "p { margin: 0; }"), - chapters = listOf( - SharedEpubChapter( - id = "one", - title = "One", - plainText = "Hello cache.", - htmlContent = "

Hello cache.

", - semanticBlocks = listOf( - SemanticParagraph( - text = "Hello cache.", - spans = emptyList(), - style = CssStyle(fontSize = 18.sp), - elementId = null, - cfi = null, - startCharOffsetInSource = 0, - blockIndex = 0 - ) - ), - baseHref = "one.xhtml" - ) - ) - ) - - cache.save(key, book) - val loaded = cache.load(key) - - assertNotNull(loaded) - assertEquals(book.title, loaded.title) - assertEquals(book.css, loaded.css) - assertEquals("Hello cache.", loaded.chapters.single().plainText) - } finally { - root.deleteRecursively() - } - } - - @Test - fun `book load cache rejects styled reader books without semantic blocks`() { - val root = Files.createTempDirectory("reader-book-load-cache").toFile() - try { - val cache = SharedJvmBookLoadCache(root) - val key = SharedJvmBookLoadCacheKey( - canonicalPath = "C:/Books/book.epub", - type = FileType.EPUB, - length = 1234L, - lastModified = 5678L - ) - val book = SharedEpubBook( - id = "C:/Books/book.epub", - fileName = "book.epub", - title = "Cached Book", - chapters = listOf( - SharedEpubChapter( - id = "one", - title = "One", - plainText = "Hello cache.", - htmlContent = "

Hello cache.

", - baseHref = "one.xhtml" - ) - ) - ) - - cache.save(key, book) - - assertNull(cache.load(key)) - } finally { - root.deleteRecursively() - } - } - - @Test - fun `book load cache misses when source fingerprint changes`() { - val root = Files.createTempDirectory("reader-book-load-cache").toFile() - try { - val cache = SharedJvmBookLoadCache(root) - val key = SharedJvmBookLoadCacheKey( - canonicalPath = "C:/Books/book.epub", - type = FileType.EPUB, - length = 1234L, - lastModified = 5678L - ) - val book = SharedEpubBook( - id = "C:/Books/book.epub", - fileName = "book.epub", - title = "Cached Book", - chapters = listOf(SharedEpubChapter("one", "One", "Hello cache.")) - ) - - cache.save(key, book) - - assertNull(cache.load(key.copy(lastModified = 5679L))) - assertNull(cache.load(key.copy(length = 1235L))) - } finally { - root.deleteRecursively() - } - } -} diff --git a/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmBookLoaderTest.kt b/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmBookLoaderTest.kt deleted file mode 100644 index 1b72d9a..0000000 --- a/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmBookLoaderTest.kt +++ /dev/null @@ -1,476 +0,0 @@ -package org.dueattendant149.bookreader.shared.reader - -import org.dueattendant149.bookreader.paginatedreader.SemanticImage -import org.dueattendant149.bookreader.shared.FileType -import java.io.File -import java.nio.file.Files -import java.util.Base64 -import java.util.zip.ZipEntry -import java.util.zip.ZipOutputStream -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -class 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) - } - - @Test - fun `html loader splits generated pdf reflow page breaks`() = withTempDir { dir -> - val file = File(dir, "source_reflow.html").apply { - writeText( - """ - - - Source (Reflow) - -

-- Page 1 --

First page text.

- -

-- Page 2 --

Second page text.

- - - """.trimIndent() - ) - } - - val book = SharedJvmBookLoader.load(file, FileType.HTML) - - assertEquals("Source (Reflow)", book.title) - assertEquals(2, book.chapters.size) - assertTrue(book.chapters[0].plainText.contains("First page text.")) - assertEquals("Page 2", book.chapters[1].title) - assertTrue(book.chapters[1].plainText.contains("Second page text.")) - } - - @Test - fun `epub loader keeps embedded images in semantic pagination blocks`() = withTempDir { dir -> - val file = File(dir, "image-book.epub") - writeImageEpub(file) - - val book = SharedJvmBookLoader.loadEpub(file) - val image = book.chapters.single().semanticBlocks.filterIsInstance().single() - - assertTrue(image.path.startsWith("data:image/png;base64,")) - assertEquals("Pixel", image.altText) - } - - @Test - fun `epub loader can skip semantic blocks for vertical fast path`() = withTempDir { dir -> - val file = File(dir, "image-book.epub") - writeImageEpub(file) - - val book = SharedJvmBookLoader.loadEpub(file, parseSemanticBlocks = false) - val chapter = book.chapters.single() - - assertTrue(chapter.semanticBlocks.isEmpty()) - assertTrue(chapter.htmlContent.contains("data:image/png;base64,")) - assertTrue(chapter.plainText.contains("Before")) - assertTrue(chapter.plainText.contains("After")) - } - - @Test - fun `epub loader can prepare html for selected vertical chapters only`() = withTempDir { dir -> - val file = File(dir, "two-chapters.epub") - writeTwoChapterEpub(file) - - val book = SharedJvmBookLoader.loadEpub( - file = file, - parseSemanticBlocks = false, - preparedHtmlChapterRange = 1..1 - ) - - assertEquals(2, book.chapters.size) - assertTrue(book.chapters[0].plainText.contains("First chapter text")) - assertTrue(book.chapters[0].htmlContent.isBlank()) - assertTrue(book.chapters[1].plainText.contains("Second chapter text")) - assertTrue(book.chapters[1].htmlContent.contains("Second chapter text")) - } - - @Test - fun `epub loader does not inline stylesheet font resources`() = withTempDir { dir -> - val file = File(dir, "font-book.epub") - writeImageEpub(file) - - val css = SharedJvmBookLoader.loadEpub(file).css.values.single() - - assertTrue(css.contains("fonts/reader.woff2")) - assertTrue(!css.contains("data:font/woff2")) - } - - @Test - fun `epub loader uses spine toc id when manifest contains volume ncx files first`() = withTempDir { dir -> - val file = File(dir, "merged-volumes.epub") - writeMergedVolumeTocEpub(file) - - val book = SharedJvmBookLoader.loadEpub(file) - - assertEquals( - listOf("Volume 1", "Chapter 1", "Volume 2", "Chapter 2"), - book.tableOfContents.map { it.label } - ) - assertEquals(listOf(0, 1, 0, 1), book.tableOfContents.map { it.depth }) - assertEquals("2/title.xhtml", book.tableOfContents[2].href) - assertEquals(4, book.chapters.size) - } - - 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 writeImageEpub(file: File) { - writeZip(file) { - text( - "META-INF/container.xml", - """ - - - - - - """.trimIndent() - ) - text( - "OPS/content.opf", - """ - - - Image Book - - - - - - - - - - - - """.trimIndent() - ) - text( - "OPS/chapter.xhtml", - """ - - -

One

-

Before

- Pixel -

After

- - - """.trimIndent() - ) - text( - "OPS/styles/book.css", - """ - @font-face { - font-family: "Fixture Serif"; - src: url("fonts/reader.woff2") format("woff2"); - } - body { font-family: "Fixture Serif"; } - """.trimIndent() - ) - bytes("OPS/images/pixel.png", onePixelPng) - bytes("OPS/styles/fonts/reader.woff2", byteArrayOf(0, 1, 2, 3)) - } - } - - private fun writeMergedVolumeTocEpub(file: File) { - writeZip(file) { - text( - "META-INF/container.xml", - """ - - - - - - """.trimIndent() - ) - text( - "content.opf", - """ - - - Merged Volumes - - - - - - - - - - - - - - - - - """.trimIndent() - ) - text( - "1/toc.ncx", - """ - - Volume 1 - - """.trimIndent() - ) - text( - "toc.ncx", - """ - - Volume 1 - Chapter 1 - - Volume 2 - Chapter 2 - - - """.trimIndent() - ) - text("1/title.xhtml", "

Volume 1

Volume one.

") - text("1/chapter1.xhtml", "

Chapter 1

Chapter one.

") - text("2/title.xhtml", "

Volume 2

Volume two.

") - text("2/chapter1.xhtml", "

Chapter 2

Chapter two.

") - } - } - - private fun writeTwoChapterEpub(file: File) { - writeZip(file) { - text( - "META-INF/container.xml", - """ - - - - - - """.trimIndent() - ) - text( - "OPS/content.opf", - """ - - - Two Chapters - - - - - - - - - - - """.trimIndent() - ) - text( - "OPS/first.xhtml", - """ -

First

First chapter text.

- """.trimIndent() - ) - text( - "OPS/second.xhtml", - """ -

Second

Second chapter text.

- """.trimIndent() - ) - } - } - - 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) { - bytes(path, value.toByteArray(Charsets.UTF_8)) - } - - fun bytes(path: String, value: ByteArray) { - zip.putNextEntry(ZipEntry(path)) - zip.write(value) - zip.closeEntry() - } - } - - private val onePixelPng: ByteArray = - Base64.getDecoder().decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=") -} diff --git a/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmLruMemoryCacheTest.kt b/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmLruMemoryCacheTest.kt deleted file mode 100644 index 3cb4d20..0000000 --- a/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmLruMemoryCacheTest.kt +++ /dev/null @@ -1,22 +0,0 @@ -package org.dueattendant149.bookreader.shared.reader - -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull - -class SharedJvmLruMemoryCacheTest { - @Test - fun `cache evicts least recently used entry`() { - val cache = SharedJvmLruMemoryCache(maxEntries = 2) - - cache["one"] = 1 - cache["two"] = 2 - assertEquals(1, cache["one"]) - - cache["three"] = 3 - - assertEquals(1, cache["one"]) - assertNull(cache["two"]) - assertEquals(3, cache["three"]) - } -} diff --git a/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmUserDirectoriesTest.kt b/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmUserDirectoriesTest.kt deleted file mode 100644 index 9ff502e..0000000 --- a/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmUserDirectoriesTest.kt +++ /dev/null @@ -1,32 +0,0 @@ -package org.dueattendant149.bookreader.shared.reader - -import kotlin.test.Test -import kotlin.test.assertEquals - -class SharedJvmUserDirectoriesTest { - @Test - fun `shared jvm cache root uses xdg cache on linux`() { - val root = sharedJvmEpistemeCacheRoot( - env = mapOf("XDG_CACHE_HOME" to "/tmp/reader-cache")::get, - userHome = "/home/reader", - osName = "Linux" - ) - - assertEquals("/tmp/reader-cache/episteme", root.portablePath()) - } - - @Test - fun `shared jvm cache root preserves windows appdata location`() { - val root = sharedJvmEpistemeCacheRoot( - env = mapOf("APPDATA" to "C:/Users/reader/AppData/Roaming")::get, - userHome = "C:/Users/reader", - osName = "Windows 11" - ) - - assertEquals("C:/Users/reader/AppData/Roaming/Episteme", root.portablePath()) - } -} - -private fun java.io.File.portablePath(): String { - return path.replace('\\', '/') -} diff --git a/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedMeasuredEpubPaginatorTest.kt b/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedMeasuredEpubPaginatorTest.kt deleted file mode 100644 index b6f7766..0000000 --- a/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedMeasuredEpubPaginatorTest.kt +++ /dev/null @@ -1,211 +0,0 @@ -package org.dueattendant149.bookreader.shared.reader - -import androidx.compose.ui.text.ParagraphStyle -import androidx.compose.ui.text.style.Hyphens -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextIndent -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import org.dueattendant149.bookreader.paginatedreader.BlockStyle -import org.dueattendant149.bookreader.paginatedreader.BoxBorders -import org.dueattendant149.bookreader.paginatedreader.CssStyle -import org.dueattendant149.bookreader.paginatedreader.SemanticParagraph -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull - -class SharedMeasuredEpubPaginatorTest { - - @Test - fun `two page geometry caps each page to rendered page width on wide viewports`() { - val geometry = measuredPageGeometryFor( - settings = ReaderSettings( - pageWidth = 760, - margin = 48, - readingMode = ReaderReadingMode.PAGINATED, - pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE - ), - viewport = ReaderViewportSpec(widthPx = 2_400, heightPx = 1_200) - ) - - assertEquals(760, geometry.pageWidthPx) - assertEquals(1_104, geometry.pageHeightPx) - } - - @Test - fun `two page geometry subtracts margins inside each rendered page on constrained viewports`() { - val geometry = measuredPageGeometryFor( - settings = ReaderSettings( - pageWidth = 760, - horizontalMargin = 80, - verticalMargin = 40, - readingMode = ReaderReadingMode.PAGINATED, - pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE - ), - viewport = ReaderViewportSpec(widthPx = 1_300, heightPx = 900) - ) - - assertEquals(476, geometry.pageWidthPx) - assertEquals(820, geometry.pageHeightPx) - } - - @Test - fun `paginated single page geometry matches one rendered page in a spread`() { - val singlePageGeometry = measuredPageGeometryFor( - settings = ReaderSettings( - pageWidth = 760, - horizontalMargin = 80, - verticalMargin = 40, - readingMode = ReaderReadingMode.PAGINATED, - pageSpreadMode = ReaderPageSpreadMode.SINGLE - ), - viewport = ReaderViewportSpec(widthPx = 1_300, heightPx = 900) - ) - val twoPageGeometry = measuredPageGeometryFor( - settings = ReaderSettings( - pageWidth = 760, - horizontalMargin = 80, - verticalMargin = 40, - readingMode = ReaderReadingMode.PAGINATED, - pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE - ), - viewport = ReaderViewportSpec(widthPx = 1_300, heightPx = 900) - ) - - assertEquals(twoPageGeometry, singlePageGeometry) - assertEquals(476, singlePageGeometry.pageWidthPx) - assertEquals(820, singlePageGeometry.pageHeightPx) - } - - @Test - fun `geometry does not invent minimum page space beyond the rendered viewport`() { - val geometry = measuredPageGeometryFor( - settings = ReaderSettings( - pageWidth = 760, - horizontalMargin = 80, - verticalMargin = 120 - ), - viewport = ReaderViewportSpec(widthPx = 300, heightPx = 220) - ) - - assertEquals(140, geometry.pageWidthPx) - assertEquals(1, geometry.pageHeightPx) - } - - @Test - fun `geometry scales css-sized page settings to measured desktop pixels`() { - val geometry = measuredPageGeometryFor( - settings = ReaderSettings( - pageWidth = 760, - horizontalMargin = 0, - verticalMargin = 0 - ), - viewport = ReaderViewportSpec(widthPx = 1_900, heightPx = 860), - densityScale = 1.25f - ) - - assertEquals(950, geometry.pageWidthPx) - assertEquals(860, geometry.pageHeightPx) - } - - @Test - fun `paragraph split trims whitespace and prepares continuation styling`() { - val paragraph = SemanticParagraph( - text = "Alpha beta gamma delta", - spans = emptyList(), - style = CssStyle( - paragraphStyle = ParagraphStyle( - textIndent = TextIndent(firstLine = 24.sp, restLine = 8.sp) - ), - blockStyle = BlockStyle( - margin = BoxBorders(top = 12.dp) - ) - ), - elementId = null, - cfi = null, - startCharOffsetInSource = 100, - blockIndex = 7 - ) - - val split = assertNotNull(splitSemanticTextBlockAtOffsetForPagination(paragraph, 11)) - - assertEquals("Alpha beta", split.first.text) - assertEquals(100, split.first.startCharOffsetInSource) - assertEquals("gamma delta", split.second.text) - assertEquals(112, split.second.startCharOffsetInSource) - assertEquals( - TextIndent(firstLine = 0.sp, restLine = 8.sp), - split.second.style.paragraphStyle.textIndent - ) - assertEquals(0.dp, split.second.style.blockStyle.margin.top) - } - - @Test - fun `measurement paragraph style keeps css indent hyphenation and android justify rule`() { - val paragraph = SemanticParagraph( - text = "Indented paragraph", - spans = emptyList(), - style = CssStyle( - paragraphStyle = ParagraphStyle( - textAlign = TextAlign.Justify, - textIndent = TextIndent(firstLine = 20.sp, restLine = 4.sp) - ), - hyphens = "auto" - ), - elementId = null, - cfi = null, - startCharOffsetInSource = 0, - blockIndex = 1 - ) - - val defaultAlignStyle = paragraph.toMeasurementParagraphStyleForPagination(TextAlign.Start) - val forcedJustifyStyle = paragraph.toMeasurementParagraphStyleForPagination(TextAlign.Justify) - - assertEquals(TextAlign.Left, defaultAlignStyle.textAlign) - assertEquals(TextAlign.Justify, forcedJustifyStyle.textAlign) - assertEquals(TextIndent(firstLine = 20.sp, restLine = 4.sp), defaultAlignStyle.textIndent) - assertEquals(Hyphens.Auto, defaultAlignStyle.hyphens) - } - - @Test - fun `pagination stack collapses adjacent margins and can ignore trailing bottom margin`() { - val items = listOf( - PaginationStackItem(contentHeightPx = 100, marginTopPx = 18, marginBottomPx = 18), - PaginationStackItem(contentHeightPx = 80, marginTopPx = 18, marginBottomPx = 18) - ) - - assertEquals( - 216, - collapsedPaginationStackHeight(items, includeTrailingBottomMargin = false) - ) - assertEquals( - 234, - collapsedPaginationStackHeight(items, includeTrailingBottomMargin = true) - ) - } - - @Test - fun `pagination stack prefix fitting includes trailing bottom margin`() { - val items = listOf( - PaginationStackItem(contentHeightPx = 100, marginTopPx = 10, marginBottomPx = 30), - PaginationStackItem(contentHeightPx = 80, marginTopPx = 10, marginBottomPx = 30) - ) - - assertEquals( - 1, - paginationStackPrefixCountThatFits( - items = items, - availableHeightPx = 220, - includeTrailingBottomMargin = true - ) - ) - assertEquals( - 2, - paginationStackPrefixCountThatFits( - items = items, - availableHeightPx = 220, - includeTrailingBottomMargin = false - ) - ) - } -} diff --git a/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/DesktopBookCoverImageCacheTest.kt b/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/DesktopBookCoverImageCacheTest.kt deleted file mode 100644 index b8af7a8..0000000 --- a/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/DesktopBookCoverImageCacheTest.kt +++ /dev/null @@ -1,69 +0,0 @@ -package org.dueattendant149.bookreader.shared.ui - -import java.awt.Color -import java.awt.image.BufferedImage -import java.nio.file.Files -import javax.imageio.ImageIO -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertTrue - -class DesktopBookCoverImageCacheTest { - - @Test - fun `cover cache reloads when source fingerprint changes`() { - val root = Files.createTempDirectory("reader-cover-cache").toFile() - try { - DesktopBookCoverImageCache.clearForTests() - val cover = root.resolve("cover.png") - writeImage(cover.absolutePath, width = 64, height = 64) - - val first = DesktopBookCoverImageCache.load(cover.absolutePath) - assertNotNull(first) - assertEquals(64, first.width) - - writeImage(cover.absolutePath, width = 96, height = 48) - cover.setLastModified(cover.lastModified() + 2_000L) - - val second = DesktopBookCoverImageCache.load(cover.absolutePath) - assertNotNull(second) - assertEquals(96, second.width) - assertEquals(48, second.height) - } finally { - DesktopBookCoverImageCache.clearForTests() - root.deleteRecursively() - } - } - - @Test - fun `large covers are cached at thumbnail size`() { - val root = Files.createTempDirectory("reader-cover-cache").toFile() - try { - DesktopBookCoverImageCache.clearForTests() - val cover = root.resolve("large-cover.png") - writeImage(cover.absolutePath, width = 1_200, height = 800) - - val bitmap = DesktopBookCoverImageCache.load(cover.absolutePath) - - assertNotNull(bitmap) - assertTrue(bitmap.width <= 512) - assertTrue(bitmap.height <= 512) - } finally { - DesktopBookCoverImageCache.clearForTests() - root.deleteRecursively() - } - } - - private fun writeImage(path: String, width: Int, height: Int) { - val image = BufferedImage(width, height, BufferedImage.TYPE_INT_RGB) - val graphics = image.createGraphics() - try { - graphics.color = Color(0x2A, 0x5C, 0x88) - graphics.fillRect(0, 0, width, height) - } finally { - graphics.dispose() - } - ImageIO.write(image, "png", java.io.File(path)) - } -} diff --git a/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/DesktopEpubNativeImageTest.kt b/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/DesktopEpubNativeImageTest.kt deleted file mode 100644 index 4829313..0000000 --- a/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/DesktopEpubNativeImageTest.kt +++ /dev/null @@ -1,16 +0,0 @@ -package org.dueattendant149.bookreader.shared.ui - -import androidx.compose.ui.Alignment -import kotlin.test.Test -import kotlin.test.assertEquals - -class DesktopEpubNativeImageTest { - @Test - fun `desktop native epub image maps css object position to compose alignment`() { - assertEquals(Alignment.TopStart, desktopEpubImageContentAlignment("left top")) - assertEquals(Alignment.TopStart, desktopEpubImageContentAlignment("top left")) - assertEquals(Alignment.BottomStart, desktopEpubImageContentAlignment("0% 100%")) - assertEquals(Alignment.CenterEnd, desktopEpubImageContentAlignment("right center")) - assertEquals(Alignment.Center, desktopEpubImageContentAlignment(null)) - } -} diff --git a/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalLayerDesktopTest.kt b/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalLayerDesktopTest.kt deleted file mode 100644 index 3808608..0000000 --- a/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalLayerDesktopTest.kt +++ /dev/null @@ -1,179 +0,0 @@ -package org.dueattendant149.bookreader.shared.ui - -import androidx.compose.ui.unit.dp -import kotlin.test.Test -import kotlin.test.assertFalse -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -class SharedReaderModalLayerDesktopTest { - - @Test - fun `left reader panel uses extension width instead of blocking full reader`() { - assertEquals( - 340.dp, - sharedReaderModalEdgePanelLayerWidth( - level = SharedReaderModalLevel.PanelLeft, - anchorWidth = 1280.dp - ) - ) - } - - @Test - fun `right reader panel remains an inspector width`() { - assertEquals( - 380.dp, - sharedReaderModalEdgePanelLayerWidth( - level = SharedReaderModalLevel.PanelRight, - anchorWidth = 1280.dp - ) - ) - } - - @Test - fun `bottom chrome layer overlaps reader edge to avoid desktop rounding gap`() { - assertEquals( - 948f, - sharedReaderModalChromeBottomLayerTopPx( - anchorTopPx = 100f, - anchorHeightPx = 1000f, - dialogHeightPx = 160f, - overlapPx = 8f - ) - ) - } - - @Test - fun `chrome top layer can opt into focus for search input`() { - assertFalse( - sharedReaderModalLayerWindowFocusable( - level = SharedReaderModalLevel.ChromeTop, - focusableOverride = null - ) - ) - assertTrue( - sharedReaderModalLayerWindowFocusable( - level = SharedReaderModalLevel.ChromeTop, - focusableOverride = true - ) - ) - } - - @Test - fun `chrome bottom layer stays non focusable by default`() { - assertFalse( - sharedReaderModalLayerWindowFocusable( - level = SharedReaderModalLevel.ChromeBottom, - focusableOverride = null - ) - ) - } - - @Test - fun `chrome layer remains visible when owner window is focused`() { - assertTrue( - sharedReaderModalChromeLayerVisible( - ownerShowing = true, - ownerDisplayable = true, - ownerMinimized = false, - ownerActive = false, - ownerFocused = true, - ownerModalActive = false - ) - ) - } - - @Test - fun `chrome layer remains visible while its own reader modal is active`() { - assertTrue( - sharedReaderModalChromeLayerVisible( - ownerShowing = true, - ownerDisplayable = true, - ownerMinimized = false, - ownerActive = false, - ownerFocused = false, - ownerModalActive = true - ) - ) - } - - @Test - fun `chrome layer hides when owner loses focus to another app`() { - assertFalse( - sharedReaderModalChromeLayerVisible( - ownerShowing = true, - ownerDisplayable = true, - ownerMinimized = false, - ownerActive = false, - ownerFocused = false, - ownerModalActive = false - ) - ) - } - - @Test - fun `chrome layer hides when owner window is unavailable`() { - assertFalse( - sharedReaderModalChromeLayerVisible( - ownerShowing = false, - ownerDisplayable = true, - ownerMinimized = false, - ownerActive = true, - ownerFocused = true, - ownerModalActive = false - ) - ) - assertFalse( - sharedReaderModalChromeLayerVisible( - ownerShowing = true, - ownerDisplayable = false, - ownerMinimized = false, - ownerActive = true, - ownerFocused = true, - ownerModalActive = false - ) - ) - assertFalse( - sharedReaderModalChromeLayerVisible( - ownerShowing = true, - ownerDisplayable = true, - ownerMinimized = true, - ownerActive = true, - ownerFocused = true, - ownerModalActive = false - ) - ) - } - - @Test - fun `modal layer hides immediately while owner window is closing`() { - assertTrue( - sharedReaderModalLayerShouldHideImmediately( - ownerShowing = true, - ownerDisplayable = true, - ownerClosing = true - ) - ) - assertTrue( - sharedReaderModalLayerShouldHideImmediately( - ownerShowing = false, - ownerDisplayable = true, - ownerClosing = false - ) - ) - assertTrue( - sharedReaderModalLayerShouldHideImmediately( - ownerShowing = true, - ownerDisplayable = false, - ownerClosing = false - ) - ) - assertFalse( - sharedReaderModalLayerShouldHideImmediately( - ownerShowing = true, - ownerDisplayable = true, - ownerClosing = false - ) - ) - } -} diff --git a/shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmBookLoader.kt b/shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmBookLoader.kt deleted file mode 100644 index f91efed..0000000 --- a/shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmBookLoader.kt +++ /dev/null @@ -1,1827 +0,0 @@ -package org.dueattendant149.bookreader.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 org.dueattendant149.bookreader.paginatedreader.CssParser -import org.dueattendant149.bookreader.paginatedreader.HtmlResourceResolver -import org.dueattendant149.bookreader.paginatedreader.OptimizedCssRules -import org.dueattendant149.bookreader.paginatedreader.SemanticBlock -import org.dueattendant149.bookreader.paginatedreader.SemanticFlexContainer -import org.dueattendant149.bookreader.paginatedreader.SemanticImage -import org.dueattendant149.bookreader.paginatedreader.SemanticList -import org.dueattendant149.bookreader.paginatedreader.SemanticMath -import org.dueattendant149.bookreader.paginatedreader.SemanticSpacer -import org.dueattendant149.bookreader.paginatedreader.SemanticTable -import org.dueattendant149.bookreader.paginatedreader.SemanticTextBlock -import org.dueattendant149.bookreader.paginatedreader.SemanticWrappingBlock -import org.dueattendant149.bookreader.paginatedreader.UserAgentStylesheet -import org.dueattendant149.bookreader.paginatedreader.htmlToSemanticBlocks -import org.dueattendant149.bookreader.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.net.URI -import java.net.URLDecoder -import java.nio.charset.Charset -import java.util.Base64 -import java.util.UUID -import java.util.zip.ZipFile -import javax.imageio.ImageIO - -private const val JvmBookOpenTraceTag = "EpistemeDesktopOpenTrace" - -private fun logJvmBookOpenTrace(message: () -> String) { - logSharedReaderDiagnostic(JvmBookOpenTraceTag, message) -} - -private fun Long.jvmBookOpenTraceElapsedMs(nowNanos: Long = System.nanoTime()): Long { - return ((nowNanos - this).coerceAtLeast(0L)) / 1_000_000L -} - -private fun String.jvmBookOpenTracePreview(maxLength: Int = 96): String { - return replace(Regex("\\s+"), " ") - .trim() - .let { if (it.length <= maxLength) it else it.take(maxLength) + "..." } - .replace("\"", "\\\"") -} - -private fun SharedEpubBook.jvmBookOpenTraceSummary(): String { - return "title=\"${title.jvmBookOpenTracePreview(120)}\" chapters=${chapters.size} " + - "textChars=${chapters.sumOf { it.plainText.length }} " + - "htmlChars=${chapters.sumOf { it.htmlContent.length }} " + - "semanticBlocks=${chapters.sumOf { it.semanticBlocks.size }} " + - "cssFiles=${css.size} cssChars=${css.values.sumOf { it.length }} toc=${tableOfContents.size}" -} - -private fun IntRange.toOpenTraceRangeKey(): String { - return "$first..$last" -} - -object SharedJvmBookLoader { - private val persistentBookCache = SharedJvmBookLoadCache() - private val loadedBookCache = SharedJvmLruMemoryCache(maxEntries = 12) - private val htmlPageBreakRegex = Regex("(?is)]*>(?:\\s*)?") - - fun load( - file: File, - type: FileType, - titleOverride: String? = null, - authorOverride: String? = null, - semanticMode: SharedJvmBookLoadSemanticMode = SharedJvmBookLoadSemanticMode.FULL, - preparedHtmlChapterRange: IntRange? = null - ): SharedEpubBook { - val loadStartedAt = System.nanoTime() - require(file.isFile) { "Missing reader file: ${file.absolutePath}" } - val preparedHtmlChapterRangeKey = preparedHtmlChapterRange?.toOpenTraceRangeKey() - val key = SharedJvmBookLoadCacheKey( - canonicalPath = file.canonicalPath, - type = type, - length = file.length(), - lastModified = file.lastModified(), - semanticMode = semanticMode, - htmlChapterRange = preparedHtmlChapterRangeKey - ) - logJvmBookOpenTrace { - "event=shared_load_start type=${type.name} file=\"${file.name.jvmBookOpenTracePreview(120)}\" " + - "semanticMode=${semanticMode.name} preparedHtmlChapters=${preparedHtmlChapterRangeKey ?: "all"} " + - "bytes=${file.length()} lastModified=${file.lastModified()} cacheId=${key.cacheId} " + - "path=\"${file.absolutePath.jvmBookOpenTracePreview(220)}\"" - } - synchronized(loadedBookCache) { - loadedBookCache[key]?.let { cached -> - logJvmBookOpenTrace { - "event=shared_load_memory_hit cacheId=${key.cacheId} " + - "elapsedMs=${loadStartedAt.jvmBookOpenTraceElapsedMs()} ${cached.jvmBookOpenTraceSummary()}" - } - return cached.withOverrides(titleOverride = titleOverride, authorOverride = authorOverride) - } - } - - val diskCacheStartedAt = System.nanoTime() - val diskCached = persistentBookCache.load(key) - logJvmBookOpenTrace { - "event=shared_load_disk_cache_lookup result=${if (diskCached != null) "hit" else "miss"} " + - "cacheId=${key.cacheId} durationMs=${diskCacheStartedAt.jvmBookOpenTraceElapsedMs()}" - } - val source: String - val loaded = if (diskCached != null) { - source = "disk_cache" - diskCached - } else { - val parseStartedAt = System.nanoTime() - logJvmBookOpenTrace { - "event=shared_parse_start type=${type.name} semanticMode=${semanticMode.name} cacheId=${key.cacheId} " + - "file=\"${file.name.jvmBookOpenTracePreview(120)}\"" - } - val parsed = when (type) { - FileType.EPUB -> loadEpub( - file = file, - parseSemanticBlocks = semanticMode == SharedJvmBookLoadSemanticMode.FULL, - preparedHtmlChapterRange = preparedHtmlChapterRange - ) - 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.") - } - logJvmBookOpenTrace { - "event=shared_parse_done type=${type.name} semanticMode=${semanticMode.name} cacheId=${key.cacheId} " + - "durationMs=${parseStartedAt.jvmBookOpenTraceElapsedMs()} ${parsed.jvmBookOpenTraceSummary()}" - } - val saveStartedAt = System.nanoTime() - persistentBookCache.save(key, parsed) - logJvmBookOpenTrace { - "event=shared_load_disk_cache_save cacheId=${key.cacheId} " + - "durationMs=${saveStartedAt.jvmBookOpenTraceElapsedMs()}" - } - source = "parsed" - parsed - } - - synchronized(loadedBookCache) { - loadedBookCache[key] = loaded - } - logJvmBookOpenTrace { - "event=shared_load_done source=$source cacheId=${key.cacheId} " + - "elapsedMs=${loadStartedAt.jvmBookOpenTraceElapsedMs()} ${loaded.jvmBookOpenTraceSummary()}" - } - return loaded.withOverrides(titleOverride = titleOverride, authorOverride = authorOverride) - } - - fun clearCache() { - persistentBookCache.clear() - synchronized(loadedBookCache) { - loadedBookCache.clear() - } - } - - fun loadEpub( - file: File, - parseSemanticBlocks: Boolean = true, - preparedHtmlChapterRange: IntRange? = null - ): SharedEpubBook { - val parseStartedAt = System.nanoTime() - val preparedHtmlChapterRangeKey = preparedHtmlChapterRange?.toOpenTraceRangeKey() - logJvmBookOpenTrace { - "event=epub_parse_start file=\"${file.name.jvmBookOpenTracePreview(120)}\" " + - "parseSemanticBlocks=$parseSemanticBlocks preparedHtmlChapters=${preparedHtmlChapterRangeKey ?: "all"} " + - "bytes=${file.length()} " + - "path=\"${file.absolutePath.jvmBookOpenTracePreview(220)}\"" - } - val zipStartedAt = System.nanoTime() - ZipFile(file).use { zip -> - logJvmBookOpenTrace { - "event=epub_zip_opened file=\"${file.name.jvmBookOpenTracePreview(120)}\" " + - "durationMs=${zipStartedAt.jvmBookOpenTraceElapsedMs()} entries=${zip.size()}" - } - val opfStartedAt = System.nanoTime() - 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) - logJvmBookOpenTrace { - "event=epub_opf_ready file=\"${file.name.jvmBookOpenTracePreview(120)}\" " + - "durationMs=${opfStartedAt.jvmBookOpenTraceElapsedMs()} opfPath=\"${opfPath.jvmBookOpenTracePreview(160)}\" " + - "basePath=\"${basePath.jvmBookOpenTracePreview(120)}\" manifestItems=${manifest.size} opfChars=${opf.length} " + - "title=\"${title.jvmBookOpenTracePreview(120)}\"" - } - val cssStartedAt = System.nanoTime() - val cssByPath = loadEpubCss(zip, manifest, basePath) - logJvmBookOpenTrace { - "event=epub_css_loaded file=\"${file.name.jvmBookOpenTracePreview(120)}\" " + - "durationMs=${cssStartedAt.jvmBookOpenTraceElapsedMs()} cssFiles=${cssByPath.size} " + - "cssChars=${cssByPath.values.sumOf { it.length }}" - } - val cssRulesStartedAt = System.nanoTime() - val cssRules = if (parseSemanticBlocks) parseCssRules(cssByPath) else OptimizedCssRules() - logJvmBookOpenTrace { - "event=epub_css_rules_parsed file=\"${file.name.jvmBookOpenTracePreview(120)}\" " + - "durationMs=${cssRulesStartedAt.jvmBookOpenTraceElapsedMs()} cssFiles=${cssByPath.size} " + - "skipped=${!parseSemanticBlocks}" - } - val tocStartedAt = System.nanoTime() - val tableOfContents = parseEpubTableOfContents(zip, opf, manifest, basePath) - logJvmBookOpenTrace { - "event=epub_toc_loaded file=\"${file.name.jvmBookOpenTracePreview(120)}\" " + - "durationMs=${tocStartedAt.jvmBookOpenTraceElapsedMs()} entries=${tableOfContents.size}" - } - val spineStartedAt = System.nanoTime() - 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) } - } - logJvmBookOpenTrace { - "event=epub_spine_ready file=\"${file.name.jvmBookOpenTracePreview(120)}\" " + - "durationMs=${spineStartedAt.jvmBookOpenTraceElapsedMs()} spineItems=${spine.size} " + - "chapterCandidates=${chapterPaths.size}" - } - - val chaptersStartedAt = System.nanoTime() - val chapters = chapterPaths.mapIndexedNotNull { index, href -> - val chapterStartedAt = System.nanoTime() - val path = normalizeZipPath(basePath + href) - val html = zip.readTextOrNull(path) - if (html == null) { - logJvmBookOpenTrace { - "event=epub_chapter_missing file=\"${file.name.jvmBookOpenTracePreview(120)}\" " + - "index=$index path=\"${path.jvmBookOpenTracePreview(160)}\"" - } - return@mapIndexedNotNull null - } - val shouldPrepareHtml = parseSemanticBlocks || - preparedHtmlChapterRange == null || - index in preparedHtmlChapterRange - val resourcesStartedAt = System.nanoTime() - val resourceReadyHtml = if (shouldPrepareHtml) { - html.sanitizeReaderHtml().withEmbeddedResources(zip, path) - } else { - "" - } - val resourceDurationMs = resourcesStartedAt.jvmBookOpenTraceElapsedMs() - val textStartedAt = System.nanoTime() - val text = if (parseSemanticBlocks) html.htmlToText() else html.fastHtmlToText() - val textDurationMs = textStartedAt.jvmBookOpenTraceElapsedMs() - val chapter = 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, - parseSemanticBlocks = parseSemanticBlocks - ) - val accepted = text.isNotBlank() || chapter.semanticBlocks.isNotEmpty() || resourceReadyHtml.hasVisualHtmlContent() - val chapterDurationMs = chapterStartedAt.jvmBookOpenTraceElapsedMs() - if (parseSemanticBlocks || shouldPrepareHtml || chapterDurationMs >= 50) { - logJvmBookOpenTrace { - "event=epub_chapter_loaded file=\"${file.name.jvmBookOpenTracePreview(120)}\" " + - "index=$index accepted=$accepted durationMs=$chapterDurationMs " + - "htmlPrepared=$shouldPrepareHtml resourceMs=$resourceDurationMs textMs=$textDurationMs " + - "path=\"${path.jvmBookOpenTracePreview(160)}\" title=\"${chapter.title.jvmBookOpenTracePreview(120)}\" " + - "htmlChars=${html.length} embeddedHtmlChars=${resourceReadyHtml.length} textChars=${text.length} " + - "semanticBlocks=${chapter.semanticBlocks.size}" - } - } - chapter.takeIf { accepted } - } - logJvmBookOpenTrace { - "event=epub_chapters_loaded file=\"${file.name.jvmBookOpenTracePreview(120)}\" " + - "durationMs=${chaptersStartedAt.jvmBookOpenTraceElapsedMs()} chapters=${chapters.size} " + - "candidates=${chapterPaths.size}" - } - - val book = SharedEpubBook( - id = file.absolutePath, - fileName = file.name, - title = title, - author = author, - css = cssByPath, - tableOfContents = tableOfContents, - 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." - ) - ) - } - ) - logJvmBookOpenTrace { - "event=epub_parse_done file=\"${file.name.jvmBookOpenTracePreview(120)}\" " + - "durationMs=${parseStartedAt.jvmBookOpenTraceElapsedMs()} ${book.jvmBookOpenTraceSummary()}" - } - return book - } - } - - 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 } - val pageChapters = sanitized.splitHtmlPageBreakChapters(title) - if (pageChapters.size > 1) { - return ParsedDocument( - title = title, - chapters = pageChapters - ).toBook(file, parseCssRules(emptyMap())) - } - 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, - parseSemanticBlocks: Boolean = true - ): SharedEpubChapter { - val semanticStartedAt = System.nanoTime() - var semanticError: Throwable? = null - val semanticBlocks = if (parseSemanticBlocks) { - 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), - resourceResolver = SharedJvmHtmlResourceResolver - ) - }.onFailure { error -> - semanticError = error - }.getOrDefault(emptyList()) - } else { - emptyList() - } - if (parseSemanticBlocks) { - logJvmBookOpenTrace { - "event=semantic_blocks_built title=\"${title.jvmBookOpenTracePreview(120)}\" skipped=false " + - "baseHref=\"${baseHref.orEmpty().jvmBookOpenTracePreview(160)}\" " + - "durationMs=${semanticStartedAt.jvmBookOpenTraceElapsedMs()} htmlChars=${html.length} " + - "plainTextChars=${plainText.length} blocks=${semanticBlocks.size} " + - "error=\"${semanticError?.message.orEmpty().jvmBookOpenTracePreview(160)}\"" - } - } - return SharedEpubChapter( - id = id, - title = title, - plainText = plainText.takeUnlessBlank() ?: semanticBlocks.semanticFallbackText().ifBlank { title }, - 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 String.splitHtmlPageBreakChapters(fallbackTitle: String): List { - if (!contains(" - ParsedChapter( - title = if (index == 0) { - chapterHtml.tagText("h1") - .ifBlank { chapterHtml.tagText("h2") } - .ifBlank { fallbackTitle } - } else { - "Page ${index + 1}" - }, - 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 parseEpubTableOfContents( - zip: ZipFile, - opf: String, - manifest: Map, - basePath: String - ): List { - val manifestNcxHref = resolveEpubNcxHref(opf, manifest) - val ncxPath = manifestNcxHref - ?.let { normalizeZipPath(basePath + it) } - ?: zip.entries().asSequence() - .map { it.name } - .firstOrNull { it.endsWith(".ncx", ignoreCase = true) } - ?: return emptyList() - val document = zip.readBytesOrNull(ncxPath)?.let(::xmlDocument) ?: return emptyList() - val navMap = document.allElementsByLocalTag("navmap").firstOrNull() ?: return emptyList() - val ncxBasePath = ncxPath.substringBeforeLast('/', missingDelimiterValue = "") - val entries = mutableListOf() - - fun visit(parent: Element, depth: Int) { - parent.childrenByLocalTag("navpoint").forEach { navPoint -> - val label = navPoint.childrenByLocalTag("navlabel") - .firstOrNull() - ?.allElementsByLocalTag("text") - ?.firstOrNull() - ?.text() - ?.normalizeReaderWhitespace() - .takeUnlessBlank() - ?: "Section ${entries.size + 1}" - val src = navPoint.childrenByLocalTag("content") - .firstOrNull() - ?.xmlAttr("src") - .orEmpty() - .trim() - val href = src.substringBefore('#').substringBefore('?').percentDecodedOrSelf() - val fragmentId = src.substringAfter('#', missingDelimiterValue = "") - .substringBefore('?') - .takeUnlessBlank() - ?.percentDecodedOrSelf() - if (href.isNotBlank()) { - val absoluteHref = normalizeZipPath( - if (ncxBasePath.isBlank()) href else "$ncxBasePath/$href" - ) - entries += SharedEpubTocEntry( - label = label, - href = absoluteHref, - fragmentId = fragmentId, - depth = depth.coerceAtLeast(0) - ) - } - visit(navPoint, depth + 1) - } - } - - visit(navMap, depth = 0) - return entries - } - - private fun resolveEpubNcxHref(opf: String, manifest: Map): String? { - Regex("<(?:[^:>]+:)?spine\\b[^>]*>", RegexOption.IGNORE_CASE) - .find(opf) - ?.value - ?.attr("toc") - ?.takeIf { it.isNotBlank() } - ?.let { tocId -> manifest[tocId] } - ?.let { return it } - - return manifest.values.firstOrNull { it.endsWith(".ncx", ignoreCase = true) } - } - - 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.percentDecodedOrSelf(): String { - return runCatching { URLDecoder.decode(this, Charsets.UTF_8.name()) }.getOrDefault(this) - } - - 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() - if (raw.isFontResourceReference()) return@replace match.value - val dataUri = zip.toDataUri(raw, cssPath) - if (dataUri != null) "url('$dataUri')" else match.value - } - } - - private fun String.isFontResourceReference(): Boolean { - return substringBefore('#') - .substringBefore('?') - .substringAfterLast('.', "") - .lowercase() in setOf("ttf", "otf", "woff", "woff2") - } - - private fun String.hasVisualHtmlContent(): Boolean { - return contains(Regex("""<\s*(img|svg|math|video|audio|object|canvas)\b""", RegexOption.IGNORE_CASE)) - } - - private fun ZipFile.toZipResourcePath(rawRef: String, ownerPath: String): String? { - val ref = rawRef.substringBefore('#').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 decodedRef = ref.percentDecodedOrSelf() - return normalizeZipPath(if (base.isBlank()) decodedRef else "$base/$decodedRef") - } - - private fun ZipFile.toDataUri(rawRef: String, ownerPath: String): String? { - val path = toZipResourcePath(rawRef, ownerPath) ?: return null - 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.fastHtmlToText(): String { - return Parser.unescapeEntities( - extractBodyOrSelf() - .replace(Regex("(?is)"), " ") - .replace(Regex("(?is)"), " ") - .replace(Regex("(?i)<\\s*br\\s*/?\\s*>"), "\n") - .replace(Regex("(?i)"), "\n") - .replace(Regex("(?is)<[^>]+>"), " "), - false - ).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 object SharedJvmHtmlResourceResolver : HtmlResourceResolver { - override fun resolvePath(chapterAbsPath: String, extractionBasePath: String, src: String): String? { - val raw = src.trim().takeIf { it.isNotBlank() } ?: return null - if (raw.startsWith("data:", ignoreCase = true)) return raw - if (raw.startsWith("http://", ignoreCase = true) || raw.startsWith("https://", ignoreCase = true)) return raw - if (raw.startsWith("file:", ignoreCase = true)) return null - - val clean = raw.substringBefore('#').substringBefore('?').takeIf { it.isNotBlank() } ?: return null - val decoded = runCatching { URLDecoder.decode(clean, Charsets.UTF_8.name()) }.getOrDefault(clean) - val chapterFile = chapterAbsPath.trim().takeIf { it.isNotBlank() }?.let { path -> - val file = File(path) - if (file.isAbsolute) file else null - } - val extractionRoot = extractionBasePath - .trim() - .takeIf { it.isNotBlank() } - ?.let { runCatching { File(it).canonicalFile }.getOrNull() } - ?: chapterFile - ?.parentFile - ?.let { runCatching { it.canonicalFile }.getOrNull() } - ?: return null - - val resolvedChapterFile = chapterFile ?: File(extractionRoot, chapterAbsPath) - val chapterRelative = resolvedChapterFile.parentFile?.let { File(it, decoded) } - fileInsideRootOrNull(extractionRoot, chapterRelative)?.let { return it.absolutePath } - - fileInsideRootOrNull(extractionRoot, File(extractionRoot, decoded))?.let { return it.absolutePath } - - return null - } - - override fun readText(path: String): String? { - dataUriBytes(path)?.let { bytes -> return bytes.toString(Charsets.UTF_8) } - return path.toFileOrNull()?.takeIf { it.isFile }?.readText() - } - - override fun imageDimensions(path: String): Pair? { - val image = runCatching { - dataUriBytes(path)?.let { bytes -> - ImageIO.read(ByteArrayInputStream(bytes)) - } ?: path.toFileOrNull()?.takeIf { it.isFile }?.let { file -> ImageIO.read(file) } - }.getOrNull() ?: return null - return image.width.toFloat() to image.height.toFloat() - } - - private fun dataUriBytes(value: String): ByteArray? { - if (!value.startsWith("data:", ignoreCase = true)) return null - val commaIndex = value.indexOf(',') - if (commaIndex < 0) return null - val metadata = value.substring(0, commaIndex) - val payload = value.substring(commaIndex + 1) - return if (";base64" in metadata.lowercase()) { - runCatching { Base64.getDecoder().decode(payload) }.getOrNull() - } else { - runCatching { URLDecoder.decode(payload, Charsets.UTF_8.name()).toByteArray(Charsets.UTF_8) }.getOrNull() - } - } - - private fun String.toFileOrNull(): File? { - return when { - startsWith("file:", ignoreCase = true) -> runCatching { File(URI(this)) }.getOrNull() - else -> File(this) - } - } - - private fun fileInsideRootOrNull(root: File, candidate: File?): File? { - val file = candidate ?: return null - val canonical = runCatching { file.canonicalFile }.getOrNull() ?: return null - val rootPath = root.path - val targetPath = canonical.path - val insideRoot = targetPath == rootPath || targetPath.startsWith(rootPath + File.separator) - return canonical.takeIf { insideRoot && it.isFile } - } - } - - private fun List.semanticFallbackText(): String { - return flatMap { it.semanticTextParts() } - .filter { it.isNotBlank() } - .joinToString("\n\n") - } - - private fun SemanticBlock.semanticTextParts(): List { - return when (this) { - is SemanticTextBlock -> listOf(text) - is SemanticList -> items.flatMap { it.semanticTextParts() } - is SemanticTable -> rows.flatMap { row -> row.flatMap { cell -> cell.content.flatMap { it.semanticTextParts() } } } - is SemanticFlexContainer -> children.flatMap { it.semanticTextParts() } - is SemanticWrappingBlock -> floatedImage.semanticTextParts() + paragraphsToWrap.flatMap { it.semanticTextParts() } - is SemanticImage -> listOf(altText.orEmpty()) - is SemanticMath -> listOf(altText.orEmpty()) - is SemanticSpacer -> emptyList() - } - } - - 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 -}