From 063a493b18e924abe5538db8e92dd463536a8b27 Mon Sep 17 00:00:00 2001 From: shen <> Date: Sat, 11 Jul 2026 08:16:56 +0800 Subject: [PATCH] fix epub reader playback and trial navigation --- ReadViewDemo/Podfile | 2 +- ReadViewDemo/Podfile.lock | 4 +- .../RDEpubReaderView.podspec.json | 6 +- ReadViewDemo/Pods/Manifest.lock | 4 +- .../Pods/Pods.xcodeproj/project.pbxproj | 36 +- .../ReadViewDemo.xcodeproj/project.pbxproj | 16 +- .../RDEPUBNavigatorLayoutContext.swift | 2 +- .../EPUBCore/RDEPUBSafeArea.swift | 4 + .../RDEPUBWebView+Configuration.swift | 119 ++ .../RDEPUBWebView+JavaScriptBridge.swift | 1 + .../EPUBCore/RDEPUBWebView.swift | 3 + .../EPUBCore/Resources/epub-fixed-layout.html | 4 +- .../RDEPUBReaderChapterListController.swift | 14 +- .../RDEPUBReaderController+DataSource.swift | 17 +- ...RDEPUBReaderController+RenderSupport.swift | 7 +- ...EPUBReaderController+TableOfContents.swift | 4 +- .../EPUBUI/RDEPUBReaderController+Trial.swift | 22 +- .../EPUBUI/RDEPUBReaderController.swift | 14 +- .../RDEPUBReaderTableOfContentsItem.swift | 13 +- .../EPUBUI/RDEPUBWebContentView.swift | 6 +- .../RDEPUBReaderContext.swift | 9 +- .../RDEPUBReaderEnvironment.swift | 81 +- .../RDEPUBReaderSearchCoordinator.swift | 5 +- .../TextPage/RDEPUBTextContentView.swift | 8 +- .../RDEpubReaderPreloadController.swift | 26 +- .../RDEpubReaderView+CollectionView.swift | 5 +- .../ReaderView/RDEpubReaderView.swift | 73 +- .../RDEpubReaderViewProtocols.swift | 4 + .../EPUBCore/CFI/RDEPUBCFI.swift | 52 - .../EPUBCore/CFI/RDEPUBCFICompatibility.swift | 29 - .../CFI/RDEPUBCFIDOMPathBuilder.swift | 575 -------- .../EPUBCore/CFI/RDEPUBCFIError.swift | 8 - .../EPUBCore/CFI/RDEPUBCFIGenerator.swift | 87 -- .../EPUBCore/CFI/RDEPUBCFIMap.swift | 182 --- .../EPUBCore/CFI/RDEPUBCFIParser.swift | 271 ---- .../EPUBCore/CFI/RDEPUBCFIPath.swift | 39 - .../EPUBCore/CFI/RDEPUBCFIRange.swift | 34 - .../CFI/RDEPUBCFIRecoveryEngine.swift | 380 ------ .../EPUBCore/CFI/RDEPUBCFIResolver.swift | 51 - .../EPUBCore/CFI/RDEPUBCFISerializer.swift | 109 -- .../EPUBCore/CFI/RDEPUBCFITextAssertion.swift | 22 - .../EPUBCore/CFI/RDEPUBCFIUtilities.swift | 8 - .../Models/RDEPUBAnnotationModels.swift | 333 ----- .../Models/RDEPUBPaginationModels.swift | 206 --- .../Models/RDEPUBReadingLocationModels.swift | 130 -- .../EPUBCore/Notes/RDEPUBNoteDetector.swift | 63 - .../EPUBCore/Notes/RDEPUBNoteModels.swift | 78 -- .../EPUBCore/Notes/RDEPUBNoteResolver.swift | 168 --- .../EPUBCore/RDEPUBAssetRepository.swift | 74 -- .../EPUBCore/RDEPUBFixedLayoutTemplate.swift | 53 - .../EPUBCore/RDEPUBJavaScriptBridge.swift | 188 --- .../EPUBCore/RDEPUBModels.swift | 214 --- .../RDEPUBNavigatorLayoutContext.swift | 59 - .../EPUBCore/RDEPUBPaginator.swift | 379 ------ .../EPUBCore/RDEPUBParser+Archive.swift | 198 --- .../EPUBCore/RDEPUBParser+Package.swift | 407 ------ .../RDEPUBParser+ReadingProfile.swift | 46 - .../EPUBCore/RDEPUBParser+Resources.swift | 101 -- .../EPUBCore/RDEPUBParser+TOC.swift | 303 ----- .../EPUBCore/RDEPUBParser.swift | 210 --- .../EPUBCore/RDEPUBPreferences.swift | 107 -- .../EPUBCore/RDEPUBPublication.swift | 71 - .../EPUBCore/RDEPUBReadingSession.swift | 322 ----- .../EPUBCore/RDEPUBRenderRequest.swift | 159 --- .../EPUBCore/RDEPUBResourceDataProvider.swift | 63 - .../EPUBCore/RDEPUBResourceResolver.swift | 175 --- .../RDEPUBResourceURLSchemeHandler.swift | 349 ----- .../EPUBCore/RDEPUBSafeArea.swift | 67 - .../EPUBCore/RDEPUBSearchEngine.swift | 120 -- .../EPUBCore/RDEPUBSearchModels.swift | 127 -- .../EPUBCore/RDEPUBStyleSheetBuilder.swift | 215 --- .../EPUBCore/RDEPUBTextAnchor.swift | 76 -- .../RDEPUBWebView+Configuration.swift | 109 -- .../EPUBCore/RDEPUBWebView+FixedLayout.swift | 122 -- .../RDEPUBWebView+JavaScriptBridge.swift | 149 --- .../EPUBCore/RDEPUBWebView+Reflowable.swift | 161 --- .../EPUBCore/RDEPUBWebView+Search.swift | 142 -- .../EPUBCore/RDEPUBWebView.swift | 244 ---- .../EPUBCore/RDEPUBWebViewDebug.swift | 106 -- .../EPUBCore/Resources/WeReadApi.js | 67 - .../EPUBCore/Resources/cssInjector.js | 45 - .../EPUBCore/Resources/epub-bridge.js | 550 -------- .../EPUBCore/Resources/epub-fixed-layout.html | 266 ---- .../EPUBCore/Resources/rangy-core.js | 13 - .../EPUBCore/Resources/rangy-serializer.js | 93 -- .../EPUBCore/Resources/wxread-dark.css | 56 - .../EPUBCore/Resources/wxread-default.css | 221 ---- .../Resources/wxread-replace-latin.css | 113 -- .../EPUBCore/Resources/wxread-replace.css | 171 --- .../RDEPUBBuildDiagnosticsReporter.swift | 63 - .../RDEPUBChapterTailNormalizer.swift | 197 --- .../RDEPUBPaginationCacheCoordinator.swift | 48 - .../BuildPipeline/RDEPUBTextBookBuilder.swift | 420 ------ .../BuildPipeline/RDEPUBTextBookCache.swift | 205 --- .../BuildPipeline/RDEPUBTextBookModels.swift | 193 --- .../RDEPUBTextBuildPipelineInterfaces.swift | 48 - .../RDEPUBTextPerformanceSampler.swift | 64 - .../Pagination/RDEPUBChapterPageCounter.swift | 271 ---- .../RDEPUBCoreTextPageFrameFactory.swift | 499 ------- .../Pagination/RDEPUBPageBreakPolicy.swift | 217 --- .../Pagination/RDEPUBTextLayoutFrame.swift | 38 - .../Pagination/RDEPUBTextLayouter.swift | 19 - .../RDEPUBTextPaginationInterfaces.swift | 32 - .../RDEPUBTextPaginationSupport.swift | 19 - .../EPUBTextRendering/RDEPUBChapterData.swift | 380 ------ .../RDEPUBDTCoreTextRenderer.swift | 135 -- .../RDEPUBTextIndexTable.swift | 542 -------- .../RDEPUBTextPositionConverter.swift | 70 - .../RDEPUBTextRenderer.swift | 337 ----- .../RDEPUBTextSearchEngine.swift | 128 -- .../RDEpubPlainTextBookBuilder.swift | 158 --- .../RDEPUBCSSCompatibilityLayer.swift | 193 --- .../RDEPUBFontFallbackResolver.swift | 21 - .../RDEPUBStyleCompatibilityModels.swift | 101 -- .../RDEPUBAttachmentNormalizer.swift | 257 ---- .../Typesetter/RDEPUBCFIMarkerInjector.swift | 59 - .../RDEPUBDecryptingImageAttachment.swift | 66 - .../Typesetter/RDEPUBFontNormalizer.swift | 160 --- .../RDEPUBFragmentMarkerInjector.swift | 68 - .../Typesetter/RDEPUBHTMLNormalizer.swift | 270 ---- .../RDEPUBRenderDiagnosticsCollector.swift | 191 --- .../RDEPUBSemanticMarkerInjector.swift | 336 ----- .../Typesetter/RDEPUBStyleSheetComposer.swift | 289 ---- .../RDEPUBTextRendererSupport.swift | 185 --- .../RDEPUBTypesettingPipeline.swift | 117 -- .../Notes/RDEPUBNotePopupCoordinator.swift | 24 - .../Notes/RDEPUBNotePopupViewController.swift | 145 -- .../RDEPUBAttachmentTooltipOverlayView.swift | 36 - .../EPUBUI/RDEPUBAttachmentTooltipView.swift | 188 --- .../EPUBUI/RDEPUBImageViewController.swift | 241 ---- .../EPUBUI/RDEPUBImageViewerCoordinator.swift | 61 - .../EPUBUI/RDEPUBReaderBottomToolView.swift | 146 -- .../RDEPUBReaderChapterListController.swift | 66 - ...UBReaderController+AttachmentTooltip.swift | 79 -- ...PUBReaderController+ContentDelegates.swift | 185 --- .../RDEPUBReaderController+DataSource.swift | 464 ------- ...RDEPUBReaderController+ExternalLinks.swift | 35 - ...BReaderController+LocationResolution.swift | 319 ----- .../RDEPUBReaderController+Orientation.swift | 48 - .../RDEPUBReaderController+PublicAPI.swift | 168 --- ...RDEPUBReaderController+RenderSupport.swift | 86 -- ...RDEPUBReaderController+RuntimeBridge.swift | 241 ---- ...EPUBReaderController+TableOfContents.swift | 109 -- .../EPUBUI/RDEPUBReaderController+Trial.swift | 93 -- .../EPUBUI/RDEPUBReaderController.swift | 503 ------- .../EPUBUI/RDEPUBReaderDelegate.swift | 56 - ...RDEPUBReaderHighlightsViewController.swift | 351 ----- .../EPUBUI/RDEPUBReaderPersistence.swift | 180 --- .../EPUBUI/RDEPUBReaderSearchBarView.swift | 634 --------- .../RDEPUBReaderTableOfContentsItem.swift | 19 - .../EPUBUI/RDEPUBReaderToolView.swift | 35 - .../RDEPUBReaderToolViewProtocols.swift | 69 - .../EPUBUI/RDEPUBReaderTopToolView.swift | 136 -- .../EPUBUI/RDEPUBSettingsFlipAutomation.swift | 84 -- .../EPUBUI/RDEPUBViewportTypes.swift | 34 - .../EPUBUI/RDEPUBWebContentView.swift | 135 -- .../RDEPUBWebDecorationOverlayView.swift | 48 - .../EPUBUI/RDEpubURLReaderController.swift | 516 -------- .../RDEPUBBackgroundTrace.swift | 10 - .../ChapterRuntime/RDEPUBBookPageMap.swift | 112 -- .../RDEPUBChapterCacheKey.swift | 12 - .../RDEPUBChapterDataCache.swift | 75 -- .../ChapterRuntime/RDEPUBChapterLoader.swift | 838 ------------ .../RDEPUBChapterLocation.swift | 30 - .../RDEPUBChapterOffsetMap.swift | 69 - .../RDEPUBChapterRuntimeStore.swift | 203 --- .../RDEPUBChapterSummaryDiskCache.swift | 228 ---- .../RDEPUBChapterWarmupOrchestrator.swift | 679 ---------- .../ChapterRuntime/RDEPUBPageCountCache.swift | 33 - .../ChapterRuntime/RDEPUBPageResolver.swift | 40 - .../ChapterRuntime/RDEPUBRuntimeChapter.swift | 52 - .../RDEPUBRuntimePageCount.swift | 14 - .../RDEPUBBackgroundCoverageStore.swift | 252 ---- .../RDEPUBBackgroundPriorityPolicy.swift | 178 --- .../ReaderController/RDEPUBJumpSession.swift | 191 --- .../ReaderController/RDEPUBMemoryProbe.swift | 48 - ...BMetadataParseCancellationController.swift | 44 - .../RDEPUBMetadataParseWorker.swift | 604 --------- ...EPUBPageMapReconciliationCoordinator.swift | 230 ---- .../RDEPUBPresentationRuntime.swift | 471 ------- .../RDEPUBReaderAnnotationCoordinator.swift | 578 -------- .../RDEPUBReaderAssemblyCoordinator.swift | 81 -- .../RDEPUBReaderChromeCoordinator.swift | 235 ---- .../RDEPUBReaderContext.swift | 420 ------ .../RDEPUBReaderDependencies.swift | 97 -- .../RDEPUBReaderEnvironment.swift | 98 -- .../RDEPUBReaderLoadCoordinator.swift | 86 -- .../RDEPUBReaderLocationCoordinator.swift | 125 -- .../RDEPUBReaderPaginationCoordinator.swift | 387 ------ .../RDEPUBReaderRuntime.swift | 689 ---------- .../RDEPUBReaderSearchCoordinator.swift | 419 ------ .../RDEPUBReaderServices.swift | 54 - .../ReaderController/RDEPUBReaderState.swift | 75 -- .../RDEPUBReaderUIState.swift | 34 - .../RDEPUBReaderViewportMonitor.swift | 129 -- .../RDEPUBSelectionState.swift | 31 - .../Settings/RDEPUBReaderConfiguration.swift | 215 --- .../Settings/RDEPUBReaderSettings.swift | 160 --- .../RDEPUBReaderSettingsViewController.swift | 368 ------ .../EPUBUI/Settings/RDEPUBReaderTheme.swift | 103 -- .../EPUBUI/String+RDEPUB.swift | 17 - .../RDEPUBChapterDisplayContentCache.swift | 114 -- .../TextPage/RDEPUBDarkImageAdjuster.swift | 107 -- .../RDEPUBPageInteractionController.swift | 215 --- .../TextPage/RDEPUBPageLayoutSnapshot.swift | 224 ---- .../TextPage/RDEPUBSelectionLoupeView.swift | 78 -- .../TextPage/RDEPUBSelectionOverlayView.swift | 177 --- .../RDEPUBTextAnnotationOverlay.swift | 135 -- ...PUBTextContentInteractionCoordinator.swift | 248 ---- .../TextPage/RDEPUBTextContentView.swift | 1171 ----------------- .../RDEPUBTextPageBoundaryValidator.swift | 223 ---- .../RDEPUBTextPageDecorationView.swift | 3 - .../TextPage/RDEPUBTextPageRenderView.swift | 213 --- .../RDEPUBTextPageVerticalJustifier.swift | 57 - .../RDEPUBTextSelectionController.swift | 388 ------ .../EPUBUI/UIColor+RDEPUB.swift | 31 - .../EPUBUI/UIColor+RDEPUBHex.swift | 16 - .../Paging/RDEpubReaderPagingController.swift | 45 - .../RDEpubReaderPreloadController.swift | 342 ----- .../Paging/RDEpubReaderSpreadResolver.swift | 74 -- .../Paging/RDEpubReaderTapRegionHandler.swift | 29 - .../ReaderView/RDEpubReaderContentCell.swift | 35 - .../ReaderView/RDEpubReaderFlowLayout.swift | 305 ----- .../RDEpubReaderGestureController.swift | 25 - .../RDEpubReaderPageChildViewController.swift | 50 - .../RDEpubReaderView+CollectionView.swift | 42 - .../RDEpubReaderView+ContentAccess.swift | 68 - .../RDEpubReaderView+PageCurl.swift | 123 -- .../RDEpubReaderView+ToolView.swift | 136 -- .../ReaderView/RDEpubReaderView.swift | 939 ------------- .../RDEpubReaderViewProtocols.swift | 93 -- 231 files changed, 435 insertions(+), 36060 deletions(-) delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFI.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFICompatibility.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIDOMPathBuilder.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIError.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIGenerator.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIMap.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIParser.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIPath.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIRange.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIRecoveryEngine.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIResolver.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFISerializer.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFITextAssertion.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIUtilities.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Models/RDEPUBAnnotationModels.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Models/RDEPUBPaginationModels.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Models/RDEPUBReadingLocationModels.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Notes/RDEPUBNoteDetector.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Notes/RDEPUBNoteModels.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Notes/RDEPUBNoteResolver.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBAssetRepository.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBFixedLayoutTemplate.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBJavaScriptBridge.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBModels.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBNavigatorLayoutContext.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBPaginator.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+Archive.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+Package.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+ReadingProfile.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+Resources.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+TOC.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBParser.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBPreferences.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBPublication.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBReadingSession.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBRenderRequest.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBResourceDataProvider.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBResourceResolver.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBResourceURLSchemeHandler.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBSafeArea.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBSearchEngine.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBSearchModels.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBStyleSheetBuilder.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBTextAnchor.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+Configuration.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+FixedLayout.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+JavaScriptBridge.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+Reflowable.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+Search.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebViewDebug.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/WeReadApi.js delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/cssInjector.js delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/epub-bridge.js delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/epub-fixed-layout.html delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/rangy-core.js delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/rangy-serializer.js delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/wxread-dark.css delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/wxread-default.css delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/wxread-replace-latin.css delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/wxread-replace.css delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBBuildDiagnosticsReporter.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBChapterTailNormalizer.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBPaginationCacheCoordinator.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookBuilder.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookCache.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookModels.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBuildPipelineInterfaces.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextPerformanceSampler.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBChapterPageCounter.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBCoreTextPageFrameFactory.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBPageBreakPolicy.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBTextLayoutFrame.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBTextLayouter.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBTextPaginationInterfaces.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBTextPaginationSupport.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBChapterData.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBDTCoreTextRenderer.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBTextIndexTable.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBTextPositionConverter.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBTextSearchEngine.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEpubPlainTextBookBuilder.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBCSSCompatibilityLayer.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBFontFallbackResolver.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBStyleCompatibilityModels.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBAttachmentNormalizer.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBCFIMarkerInjector.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBDecryptingImageAttachment.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBFontNormalizer.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBFragmentMarkerInjector.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBHTMLNormalizer.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBRenderDiagnosticsCollector.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBSemanticMarkerInjector.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBStyleSheetComposer.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBTextRendererSupport.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBTypesettingPipeline.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Notes/RDEPUBNotePopupCoordinator.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Notes/RDEPUBNotePopupViewController.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBAttachmentTooltipOverlayView.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBAttachmentTooltipView.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBImageViewController.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBImageViewerCoordinator.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderBottomToolView.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderChapterListController.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+AttachmentTooltip.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+DataSource.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+ExternalLinks.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+LocationResolution.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+Orientation.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+PublicAPI.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+RenderSupport.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+RuntimeBridge.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+TableOfContents.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+Trial.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderDelegate.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderHighlightsViewController.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderPersistence.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderSearchBarView.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderTableOfContentsItem.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderToolView.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderToolViewProtocols.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderTopToolView.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBSettingsFlipAutomation.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBViewportTypes.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBWebContentView.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBWebDecorationOverlayView.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEpubURLReaderController.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBBackgroundTrace.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBBookPageMap.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterCacheKey.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterDataCache.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLoader.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLocation.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterOffsetMap.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterRuntimeStore.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterSummaryDiskCache.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterWarmupOrchestrator.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBPageCountCache.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBPageResolver.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBRuntimeChapter.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBRuntimePageCount.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBBackgroundCoverageStore.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBBackgroundPriorityPolicy.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBJumpSession.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBMemoryProbe.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBMetadataParseCancellationController.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBMetadataParseWorker.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBPageMapReconciliationCoordinator.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBPresentationRuntime.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderAnnotationCoordinator.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderAssemblyCoordinator.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderChromeCoordinator.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderContext.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderDependencies.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderEnvironment.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderLoadCoordinator.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderLocationCoordinator.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderRuntime.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderSearchCoordinator.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderServices.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderState.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderUIState.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderViewportMonitor.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBSelectionState.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderConfiguration.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderSettings.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderSettingsViewController.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderTheme.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/String+RDEPUB.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBChapterDisplayContentCache.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBDarkImageAdjuster.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBPageInteractionController.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBPageLayoutSnapshot.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBSelectionLoupeView.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBSelectionOverlayView.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextAnnotationOverlay.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextContentInteractionCoordinator.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextPageBoundaryValidator.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextPageDecorationView.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextPageRenderView.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextPageVerticalJustifier.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextSelectionController.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/UIColor+RDEPUB.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/UIColor+RDEPUBHex.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/Paging/RDEpubReaderPagingController.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/Paging/RDEpubReaderPreloadController.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/Paging/RDEpubReaderSpreadResolver.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/Paging/RDEpubReaderTapRegionHandler.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderContentCell.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderFlowLayout.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderGestureController.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderPageChildViewController.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView+CollectionView.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView+ContentAccess.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView+PageCurl.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView+ToolView.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView.swift delete mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderViewProtocols.swift diff --git a/ReadViewDemo/Podfile b/ReadViewDemo/Podfile index 1887048..c3171a7 100644 --- a/ReadViewDemo/Podfile +++ b/ReadViewDemo/Podfile @@ -10,7 +10,7 @@ end post_install do |installer| apply_settings = lambda do |config| config.build_settings['ENABLE_USER_SCRIPT_SANDBOXING'] = 'NO' - config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.6' + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0' end # Pods project (Pods.xcodeproj) diff --git a/ReadViewDemo/Podfile.lock b/ReadViewDemo/Podfile.lock index 163d73e..d745b64 100644 --- a/ReadViewDemo/Podfile.lock +++ b/ReadViewDemo/Podfile.lock @@ -37,9 +37,9 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0 - RDEpubReaderView: 603761e6d74a34230472bf53138942168e350d41 + RDEpubReaderView: 5ff99b803f5a46e5ff4ebef3024dd217284c69ed ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351 -PODFILE CHECKSUM: 966c18a0a8d86d2f90e367ab41338d4e476d39bb +PODFILE CHECKSUM: adda84af3e8577b2a99d5b2cecf381511352f0bd COCOAPODS: 1.16.2 diff --git a/ReadViewDemo/Pods/Local Podspecs/RDEpubReaderView.podspec.json b/ReadViewDemo/Pods/Local Podspecs/RDEpubReaderView.podspec.json index 485cf9b..3d6d187 100644 --- a/ReadViewDemo/Pods/Local Podspecs/RDEpubReaderView.podspec.json +++ b/ReadViewDemo/Pods/Local Podspecs/RDEpubReaderView.podspec.json @@ -9,12 +9,12 @@ "swift_versions": [ "5.10" ], - "homepage": "https://github.com/namesubai/RDEpubReaderView.git", + "homepage": "http://192.168.21.200:8418/4v5u09Z5a4Yuc/ReadViewSDK.git", "authors": { - "subai": "804663401@qq.com" + "shenlei": "shenlei@touchread.com" }, "source": { - "git": "https://github.com/namesubai/RDEpubReaderView.git", + "git": "http://192.168.21.200:8418/4v5u09Z5a4Yuc/ReadViewSDK.git", "tag": "0.0.1" }, "license": "MIT", diff --git a/ReadViewDemo/Pods/Manifest.lock b/ReadViewDemo/Pods/Manifest.lock index 163d73e..d745b64 100644 --- a/ReadViewDemo/Pods/Manifest.lock +++ b/ReadViewDemo/Pods/Manifest.lock @@ -37,9 +37,9 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0 - RDEpubReaderView: 603761e6d74a34230472bf53138942168e350d41 + RDEpubReaderView: 5ff99b803f5a46e5ff4ebef3024dd217284c69ed ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351 -PODFILE CHECKSUM: 966c18a0a8d86d2f90e367ab41338d4e476d39bb +PODFILE CHECKSUM: adda84af3e8577b2a99d5b2cecf381511352f0bd COCOAPODS: 1.16.2 diff --git a/ReadViewDemo/Pods/Pods.xcodeproj/project.pbxproj b/ReadViewDemo/Pods/Pods.xcodeproj/project.pbxproj index d963c6e..2646709 100644 --- a/ReadViewDemo/Pods/Pods.xcodeproj/project.pbxproj +++ b/ReadViewDemo/Pods/Pods.xcodeproj/project.pbxproj @@ -2742,7 +2742,7 @@ ENABLE_USER_SCRIPT_SANDBOXING = NO; IBSC_MODULE = DTCoreText; INFOPLIST_FILE = "Target Support Files/DTCoreText/ResourceBundle-Resources-DTCoreText-Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; PRODUCT_NAME = Resources; SDKROOT = iphoneos; SKIP_INSTALL = YES; @@ -2770,7 +2770,7 @@ GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = "Target Support Files/ZIPFoundation/ZIPFoundation-Info.plist"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2810,7 +2810,7 @@ GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = "Target Support Files/DTFoundation/DTFoundation-Info.plist"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2881,7 +2881,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -2902,7 +2902,7 @@ ENABLE_USER_SCRIPT_SANDBOXING = NO; IBSC_MODULE = ZIPFoundation; INFOPLIST_FILE = "Target Support Files/ZIPFoundation/ResourceBundle-ZIPFoundation_Privacy-ZIPFoundation-Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; PRODUCT_NAME = ZIPFoundation_Privacy; SDKROOT = iphoneos; SKIP_INSTALL = YES; @@ -2929,7 +2929,7 @@ ENABLE_USER_SCRIPT_SANDBOXING = NO; INFOPLIST_FILE = "Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-Info.plist"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2969,7 +2969,7 @@ GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = "Target Support Files/RDEpubReaderView/RDEpubReaderView-Info.plist"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -3008,7 +3008,7 @@ GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = "Target Support Files/DTFoundation/DTFoundation-Info.plist"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -3047,7 +3047,7 @@ ENABLE_USER_SCRIPT_SANDBOXING = NO; INFOPLIST_FILE = "Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-Info.plist"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -3123,7 +3123,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -3155,7 +3155,7 @@ GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = "Target Support Files/DTCoreText/DTCoreText-Info.plist"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -3195,7 +3195,7 @@ GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = "Target Support Files/DTCoreText/DTCoreText-Info.plist"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -3224,7 +3224,7 @@ ENABLE_USER_SCRIPT_SANDBOXING = NO; IBSC_MODULE = DTCoreText; INFOPLIST_FILE = "Target Support Files/DTCoreText/ResourceBundle-Resources-DTCoreText-Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; PRODUCT_NAME = Resources; SDKROOT = iphoneos; SKIP_INSTALL = YES; @@ -3242,7 +3242,7 @@ ENABLE_USER_SCRIPT_SANDBOXING = NO; IBSC_MODULE = ZIPFoundation; INFOPLIST_FILE = "Target Support Files/ZIPFoundation/ResourceBundle-ZIPFoundation_Privacy-ZIPFoundation-Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; PRODUCT_NAME = ZIPFoundation_Privacy; SDKROOT = iphoneos; SKIP_INSTALL = YES; @@ -3260,7 +3260,7 @@ ENABLE_USER_SCRIPT_SANDBOXING = NO; IBSC_MODULE = RDEpubReaderView; INFOPLIST_FILE = "Target Support Files/RDEpubReaderView/ResourceBundle-RDEpubReaderViewAssets-RDEpubReaderView-Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; PRODUCT_NAME = RDEpubReaderViewAssets; SDKROOT = iphoneos; SKIP_INSTALL = YES; @@ -3278,7 +3278,7 @@ ENABLE_USER_SCRIPT_SANDBOXING = NO; IBSC_MODULE = RDEpubReaderView; INFOPLIST_FILE = "Target Support Files/RDEpubReaderView/ResourceBundle-RDEpubReaderViewAssets-RDEpubReaderView-Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; PRODUCT_NAME = RDEpubReaderViewAssets; SDKROOT = iphoneos; SKIP_INSTALL = YES; @@ -3306,7 +3306,7 @@ GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = "Target Support Files/ZIPFoundation/ZIPFoundation-Info.plist"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -3345,7 +3345,7 @@ GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = "Target Support Files/RDEpubReaderView/RDEpubReaderView-Info.plist"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/ReadViewDemo/ReadViewDemo.xcodeproj/project.pbxproj b/ReadViewDemo/ReadViewDemo.xcodeproj/project.pbxproj index 4546788..8bc3d23 100644 --- a/ReadViewDemo/ReadViewDemo.xcodeproj/project.pbxproj +++ b/ReadViewDemo/ReadViewDemo.xcodeproj/project.pbxproj @@ -329,14 +329,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks.sh\"\n"; @@ -402,7 +398,7 @@ "DEVELOPMENT_TEAM[sdk=iphoneos*]" = MG4Z7FU83W; ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; PRODUCT_BUNDLE_IDENTIFIER = cn.shen.ReadViewDemoUITests; PRODUCT_MODULE_NAME = ReadViewDemoUITests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -427,7 +423,7 @@ "DEVELOPMENT_TEAM[sdk=iphoneos*]" = MG4Z7FU83W; ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; PRODUCT_BUNDLE_IDENTIFIER = cn.shen.ReadViewDemoUITests; PRODUCT_MODULE_NAME = ReadViewDemoUITests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -460,7 +456,7 @@ INFOPLIST_KEY_UIMainStoryboardFile = Main; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -499,7 +495,7 @@ INFOPLIST_KEY_UIMainStoryboardFile = Main; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -571,7 +567,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; @@ -628,7 +624,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; diff --git a/Sources/RDEpubReaderView/EPUBCore/RDEPUBNavigatorLayoutContext.swift b/Sources/RDEpubReaderView/EPUBCore/RDEPUBNavigatorLayoutContext.swift index 085a37f..e0f4e4e 100644 --- a/Sources/RDEpubReaderView/EPUBCore/RDEPUBNavigatorLayoutContext.swift +++ b/Sources/RDEpubReaderView/EPUBCore/RDEPUBNavigatorLayoutContext.swift @@ -56,4 +56,4 @@ public struct RDEPUBNavigatorLayoutContext: Equatable { insets.right = horizontalInsets return insets } -} \ No newline at end of file +} diff --git a/Sources/RDEpubReaderView/EPUBCore/RDEPUBSafeArea.swift b/Sources/RDEpubReaderView/EPUBCore/RDEPUBSafeArea.swift index 1a20c05..5b2c1dd 100644 --- a/Sources/RDEpubReaderView/EPUBCore/RDEPUBSafeArea.swift +++ b/Sources/RDEpubReaderView/EPUBCore/RDEPUBSafeArea.swift @@ -11,6 +11,10 @@ public enum RDEPUBSafeArea { /// paddings, not approximations of the safe area itself. public static let minimumVerticalTextMargin: CGFloat = 20 + /// Compact top/bottom padding used by a landscape two-page spread. This + /// applies only beyond the actual safe area, which is always preserved. + public static let landscapeVerticalTextMargin: CGFloat = 12 + public static let minimumHorizontalTextMargin: CGFloat = 16 public static func keyWindow() -> UIWindow? { diff --git a/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+Configuration.swift b/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+Configuration.swift index b8bbe85..4b69a9c 100644 --- a/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+Configuration.swift +++ b/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+Configuration.swift @@ -25,10 +25,22 @@ extension RDEPUBWebView { forMainFrameOnly: true ) ) + userContentController.addUserScript( + WKUserScript( + source: Self.readerPageMediaLifecycleUserScript, + injectionTime: .atDocumentStart, + forMainFrameOnly: false + ) + ) let configuration = WKWebViewConfiguration() configuration.websiteDataStore = .nonPersistent() configuration.userContentController = userContentController + // Interactive fixed-layout EPUBs commonly start narration from their + // document-load timeline. Allow those page-owned audio elements to play + // without requiring an extra tap, and keep playback inside the reader. + configuration.allowsInlineMediaPlayback = true + configuration.mediaTypesRequiringUserActionForPlayback = [] let schemeHandler = RDEPUBResourceURLSchemeHandler(parser: parser) configuration.setURLSchemeHandler(schemeHandler, forURLScheme: RDEPUBResourceURLSchemeHandler.scheme) @@ -130,3 +142,110 @@ private final class RDEPUBWeakScriptMessageHandler: NSObject, WKScriptMessageHan target?.userContentController(userContentController, didReceive: message) } } + +extension RDEPUBWebView { + + /// Installed in every frame before EPUB scripts run. Hidden pages start in + /// a suspended state, including audio created with `new Audio()` that is + /// not attached to the DOM (as used by Tumult Hype publications). + static let readerPageMediaLifecycleUserScript = #""" + (() => { + if (window.__rdReaderMediaLifecycleInstalled) return; + window.__rdReaderMediaLifecycleInstalled = true; + + let isActive = false; + const trackedMedia = new Set(); + const command = "rd-reader-page-visibility"; + const mediaPrototype = window.HTMLMediaElement && window.HTMLMediaElement.prototype; + const nativePlay = mediaPrototype && mediaPrototype.play; + + const register = media => { + if (media) trackedMedia.add(media); + return media; + }; + + const collectDocumentMedia = () => { + if (!document.querySelectorAll) return; + document.querySelectorAll("audio,video").forEach(register); + }; + + const setActive = active => { + isActive = !!active; + collectDocumentMedia(); + trackedMedia.forEach(media => { + if (isActive) { + if (media.__rdReaderReplayWhenVisible) { + media.__rdReaderReplayWhenVisible = false; + try { + media.currentTime = 0; + const result = nativePlay && nativePlay.call(media); + if (result && result.catch) result.catch(() => {}); + } catch (_) {} + } + } else { + if (media.__rdReaderWasPlayed || media.autoplay || (!media.paused && !media.ended)) { + media.__rdReaderReplayWhenVisible = true; + } + if (!media.paused) { + try { media.pause(); } catch (_) {} + } + } + }); + }; + + if (mediaPrototype && nativePlay) { + mediaPrototype.play = function() { + register(this); + this.__rdReaderWasPlayed = true; + if (!isActive) { + this.__rdReaderReplayWhenVisible = true; + try { this.pause(); } catch (_) {} + return Promise.resolve(); + } + this.__rdReaderReplayWhenVisible = false; + return nativePlay.apply(this, arguments); + }; + } + + const NativeAudio = window.Audio; + if (typeof NativeAudio === "function") { + const ReaderAudio = function(source) { + const media = arguments.length > 0 ? new NativeAudio(source) : new NativeAudio(); + return register(media); + }; + ReaderAudio.prototype = NativeAudio.prototype; + try { Object.setPrototypeOf(ReaderAudio, NativeAudio); } catch (_) {} + window.Audio = ReaderAudio; + } + + window.addEventListener("message", event => { + const message = event.data; + if (!message || message.type !== command) return; + setActive(message.active); + for (let index = 0; index < window.frames.length; index += 1) { + try { window.frames[index].postMessage(message, "*"); } catch (_) {} + } + }); + + document.addEventListener("DOMContentLoaded", () => setActive(isActive)); + if (window.MutationObserver) { + new MutationObserver(() => setActive(isActive)).observe(document, { + childList: true, + subtree: true + }); + } + })(); + """# + + func setReaderPageVisible(_ isVisible: Bool) { + isReaderPageVisible = isVisible + applyReaderPageMediaVisibility() + } + + func applyReaderPageMediaVisibility() { + guard let webView else { return } + let active = isReaderPageVisible ? "true" : "false" + let script = "window.postMessage({type: 'rd-reader-page-visibility', active: \(active)}, '*');" + webView.evaluateJavaScript(script, completionHandler: nil) + } +} diff --git a/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+JavaScriptBridge.swift b/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+JavaScriptBridge.swift index 428f5a3..0e3a197 100644 --- a/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+JavaScriptBridge.swift +++ b/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+JavaScriptBridge.swift @@ -31,6 +31,7 @@ extension RDEPUBWebView: WKNavigationDelegate { public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFinish", url: webView.url) + applyReaderPageMediaVisibility() applyPresentation() } diff --git a/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView.swift b/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView.swift index 9563b24..0f99e51 100644 --- a/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView.swift +++ b/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView.swift @@ -136,6 +136,8 @@ public final class RDEPUBWebView: UIView { var isFixedLayout = false + var isReaderPageVisible = true + var pendingProgressionRequest = false var fixedLayoutReadyWorkItem: DispatchWorkItem? @@ -186,6 +188,7 @@ public final class RDEPUBWebView: UIView { pendingHighlights = [] fixedSpread = nil isFixedLayout = false + isReaderPageVisible = true pendingProgressionRequest = false didRenderCurrentRequest = false teardownWebView() diff --git a/Sources/RDEpubReaderView/EPUBCore/Resources/epub-fixed-layout.html b/Sources/RDEpubReaderView/EPUBCore/Resources/epub-fixed-layout.html index 8855696..8e7cd24 100644 --- a/Sources/RDEpubReaderView/EPUBCore/Resources/epub-fixed-layout.html +++ b/Sources/RDEpubReaderView/EPUBCore/Resources/epub-fixed-layout.html @@ -55,7 +55,6 @@ "#, with: "", options: .regularExpression) - .replacingOccurrences(of: #""#, with: "", options: .regularExpression) - let withoutTags = withoutScripts.replacingOccurrences(of: #"<[^>]+>"#, with: " ", options: .regularExpression) - return withoutTags - .replacingOccurrences(of: " ", with: " ") - .replacingOccurrences(of: "<", with: "<") - .replacingOccurrences(of: ">", with: ">") - .replacingOccurrences(of: "&", with: "&") - .components(separatedBy: .whitespacesAndNewlines) - .filter { !$0.isEmpty } - .joined(separator: " ") - } - - private func title(for reference: RDEPUBNoteReference) -> String { - switch reference.kind { - case .footnote: - return "脚注" - case .endnote: - return "尾注" - case .rearnote: - return "后注" - case .unknown: - return "注释" - } - } - - private func matchingEndTagRange( - tagName: String, - in html: String, - after startIndex: String.Index - ) -> Range? { - let escapedTagName = NSRegularExpression.escapedPattern(for: tagName) - let pattern = #"]*>"# - guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { - return nil - } - - let searchRange = NSRange(startIndex..") { - depth += 1 - } - if depth == 0 { - return range - } - } - return nil - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBAssetRepository.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBAssetRepository.swift deleted file mode 100644 index db54d4d..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBAssetRepository.swift +++ /dev/null @@ -1,74 +0,0 @@ - -import Foundation - -enum RDEPUBAsset: String { - - case rangyCoreScript = "rangy-core" - - case rangySerializerScript = "rangy-serializer" - - case cssInjectorScript = "cssInjector" - - case weReadAPIScript = "WeReadApi" - - case bridgeScript = "epub-bridge" - - case fixedLayoutTemplate = "epub-fixed-layout" - - case wxReadDefaultCSS = "wxread-default" - - case wxReadReplaceCSS = "wxread-replace" - - case wxReadDarkCSS = "wxread-dark" - - case wxReadLatinReplaceCSS = "wxread-replace-latin" - - var fileExtension: String { - switch self { - case .rangyCoreScript, .rangySerializerScript, .cssInjectorScript, .weReadAPIScript, .bridgeScript: - return "js" - case .fixedLayoutTemplate: - return "html" - case .wxReadDefaultCSS, .wxReadReplaceCSS, .wxReadDarkCSS, .wxReadLatinReplaceCSS: - return "css" - } - } -} - -enum RDEPUBAssetRepository { - - static func string(for asset: RDEPUBAsset, replacements: [String: String] = [:]) -> String { - guard let url = resourceBundle.url(forResource: asset.rawValue, withExtension: asset.fileExtension), - var content = try? String(contentsOf: url, encoding: .utf8) else { - assertionFailure("Missing EPUB asset: \(asset.rawValue).\(asset.fileExtension)") - return "" - } - - for (token, value) in replacements { - content = content.replacingOccurrences(of: "{{\(token)}}", with: value) - } - return content - } - - private static var resourceBundle: Bundle { - if let bundle = resolvedBundle { - return bundle - } - return Bundle(for: RDEPUBAssetBundleToken.self) - } - - private static var resolvedBundle: Bundle? = { - let hostBundles = [Bundle(for: RDEPUBAssetBundleToken.self), Bundle.main] + Bundle.allFrameworks + Bundle.allBundles - for hostBundle in hostBundles { - for bundleName in ["RDEpubReaderViewAssets", "RDReaderViewAssets"] { - if let url = hostBundle.url(forResource: bundleName, withExtension: "bundle"), - let bundle = Bundle(url: url) { - return bundle - } - } - } - return nil - }() -} - -private final class RDEPUBAssetBundleToken {} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBFixedLayoutTemplate.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBFixedLayoutTemplate.swift deleted file mode 100644 index 5d84523..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBFixedLayoutTemplate.swift +++ /dev/null @@ -1,53 +0,0 @@ - -import Foundation - -enum RDEPUBFixedLayoutTemplate { - - static func html(for request: RDEPUBFixedRenderRequest, publication: RDEPUBPublication) -> String { - - let panes = request.spread.resources.enumerated().compactMap { index, resource -> String? in - guard let url = publication.resourceResolver.resourceURL(forRelativePath: resource.href)?.absoluteString else { - return nil - } - - let pageType: String - if request.spread.resources.count == 1 { - pageType = "single" - } else if request.spread.resources.count == 2 { - pageType = index == 0 ? "left" : "right" - } else { - pageType = "center" - } - - return """ -
- -
- """ - }.joined() - - let background = request.backgroundColorCSS ?? "#FFFFFF" - let viewportWidth = max(1, Int((request.viewportSize.width - request.contentInset.left - request.contentInset.right).rounded(.down))) - let viewportHeight = max(1, Int((request.viewportSize.height - request.contentInset.top - request.contentInset.bottom).rounded(.down))) - let insetTop = Int(request.contentInset.top.rounded(.down)) - let insetRight = Int(request.contentInset.right.rounded(.down)) - let insetBottom = Int(request.contentInset.bottom.rounded(.down)) - let insetLeft = Int(request.contentInset.left.rounded(.down)) - - return RDEPUBAssetRepository.string( - for: .fixedLayoutTemplate, - replacements: [ - "BACKGROUND": background, - "INSET_TOP": String(insetTop), - "INSET_RIGHT": String(insetRight), - "INSET_BOTTOM": String(insetBottom), - "INSET_LEFT": String(insetLeft), - "VIEWPORT_WIDTH": String(viewportWidth), - "VIEWPORT_HEIGHT": String(viewportHeight), - "PANES": panes, - "FIT_MODE": request.fit.rawValue, - "FIXED_LAYOUT_READY_MESSAGE": RDEPUBJavaScriptBridgeMessage.fixedLayoutReady.rawValue - ] - ) - } -} \ No newline at end of file diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBJavaScriptBridge.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBJavaScriptBridge.swift deleted file mode 100644 index 906e586..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBJavaScriptBridge.swift +++ /dev/null @@ -1,188 +0,0 @@ - -import Foundation - -enum RDEPUBJavaScriptBridgeMessage: String, CaseIterable { - - case progressionChanged = "ssReaderProgressionChanged" - - case selectionChanged = "ssReaderSelectionChanged" - - case internalLink = "ssReaderInternalLink" - - case externalLink = "ssReaderExternalLink" - - case javaScriptError = "ssReaderJSError" - - case fixedLayoutReady = "ssReaderFixedLayoutReady" - - case imageDidTap = "ssReaderImageDidTap" - - case footnoteDidTap = "ssReaderFootnoteDidTap" -} - -enum RDEPUBJavaScriptBridge { - - static var messageNames: [String] { - RDEPUBJavaScriptBridgeMessage.allCases.map(\.rawValue) - } - - static var userScript: String { - [ - RDEPUBAssetRepository.string(for: .rangyCoreScript), - RDEPUBAssetRepository.string(for: .rangySerializerScript), - RDEPUBAssetRepository.string(for: .cssInjectorScript), - RDEPUBAssetRepository.string(for: .weReadAPIScript), - RDEPUBAssetRepository.string( - for: .bridgeScript, - replacements: [ - "PROGRESSION_CHANGED_MESSAGE": RDEPUBJavaScriptBridgeMessage.progressionChanged.rawValue, - "SELECTION_CHANGED_MESSAGE": RDEPUBJavaScriptBridgeMessage.selectionChanged.rawValue, - "INTERNAL_LINK_MESSAGE": RDEPUBJavaScriptBridgeMessage.internalLink.rawValue, - "EXTERNAL_LINK_MESSAGE": RDEPUBJavaScriptBridgeMessage.externalLink.rawValue, - "JAVASCRIPT_ERROR_MESSAGE": RDEPUBJavaScriptBridgeMessage.javaScriptError.rawValue, - "FIXED_LAYOUT_READY_MESSAGE": RDEPUBJavaScriptBridgeMessage.fixedLayoutReady.rawValue, - "IMAGE_DID_TAP_MESSAGE": RDEPUBJavaScriptBridgeMessage.imageDidTap.rawValue, - "FOOTNOTE_DID_TAP_MESSAGE": RDEPUBJavaScriptBridgeMessage.footnoteDidTap.rawValue - ] - ) - ].joined(separator: "\n\n") - } - - static func applyPresentationScript(for request: RDEPUBReflowableRenderRequest) -> String { - let style = escapedJavaScriptTemplateLiteral(RDEPUBStyleSheetBuilder.renderCSS(for: request.presentation)) - let pageStride = max(1, request.presentation.viewportSize.width) - let searchScript = applySearchScript(for: request.searchPresentation) - - return """ - (function() { - if (!window.WeReadApi) { return false; } - window.WeReadApi.applySharedTheme({ - backgroundColor: \(javaScriptStringLiteral(request.presentation.themeBackgroundColor)), - textColor: \(javaScriptStringLiteral(request.presentation.themeTextColor)), - includeFrames: false - }); - window.WeReadApi.applyPagination(`\(style)`); - window.WeReadApi.setPageMetrics(\(request.totalPagesInChapter), \(pageStride)); - window.WeReadApi.clearHighlights(); - window.WeReadApi.setHighlights(\(jsonString(from: highlightsPayload(request.highlights), fallback: "[]"))); - if (\(jsonString(from: targetLocationPayload(request.targetLocation), fallback: "null")) !== null) { - window.WeReadApi.scrollToLocation( - \(jsonString(from: targetLocationPayload(request.targetLocation), fallback: "null")), - \(request.pageIndex), - \(javaScriptStringLiteral(request.targetHighlightRangeInfo)) - ); - } else { - window.WeReadApi.scrollToPage(\(request.pageIndex)); - } - window.WeReadApi.reportProgression(); - return true; - })(); - \(searchScript) - """ - } - - static func applyFixedPresentationScript(for request: RDEPUBFixedRenderRequest) -> String { - """ - (function() { - if (!window.WeReadApi) { return false; } - window.WeReadApi.applySharedTheme({ - backgroundColor: \(javaScriptStringLiteral(request.backgroundColorCSS)), - textColor: null, - includeFrames: true - }); - return true; - })(); - """ - } - - static func applySearchScript(for presentation: RDEPUBSearchPresentation?) -> String { - let payload = jsonString(from: searchPayload(presentation), fallback: "null") - return """ - (function() { - if (!window.WeReadApi) { return false; } - window.WeReadApi.setSearchPresentation(\(payload)); - return true; - })(); - """ - } - - static func resolveDecorationsScript() -> String { - """ - (function() { - if (!window.WeReadApi) { return { highlights: [], search: [] }; } - return window.WeReadApi.resolveDecorations(); - })(); - """ - } - - private static func escapedJavaScriptTemplateLiteral(_ string: String) -> String { - string - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "`", with: "\\`") - } - - private static func highlightsPayload(_ highlights: [RDEPUBHighlight]) -> [[String: String]] { - highlights.compactMap { highlight in - guard let rangeInfo = highlight.rangeInfo, !rangeInfo.isEmpty else { return nil } - return [ - "id": highlight.id, - "rangeInfo": rangeInfo, - "color": highlight.color, - "style": highlight.style.rawValue - ] - } - } - - private static func targetLocationPayload(_ location: RDEPUBLocation?) -> [String: Any]? { - guard let location else { return nil } - return [ - "progression": location.progression, - "lastProgression": location.lastProgression ?? location.progression, - "fragment": location.fragment as Any - ] - } - - private static func searchPayload(_ presentation: RDEPUBSearchPresentation?) -> [String: Any]? { - guard let presentation else { return nil } - return [ - "keyword": presentation.keyword, - "resources": presentation.resources.map { resource in - [ - "href": resource.href, - "matchCount": resource.matchCount, - "activeLocalMatchIndex": resource.activeLocalMatchIndex as Any - ] - } - ] - } - - private static func jsonString(from object: Any?, fallback: String) -> String { - guard let object else { return fallback } - guard JSONSerialization.isValidJSONObject(object), - let data = try? JSONSerialization.data(withJSONObject: object, options: []), - let string = String(data: data, encoding: .utf8) else { - return fallback - } - return string - } - - /// Encodes a string value as a safe JavaScript string literal. - /// - /// Uses JSON serialization to handle escaping of quotes, backslashes, control - /// characters, and Unicode, then strips the array wrapper to produce a standalone - /// JS string literal (including surrounding quotes). - /// - /// - Parameter value: The string to encode, or `nil` which produces the JS keyword `null`. - /// - Returns: A JavaScript string literal safe for inline embedding in JS source. - private static func javaScriptStringLiteral(_ value: String?) -> String { - guard let value else { return "null" } - guard JSONSerialization.isValidJSONObject([value]), - let data = try? JSONSerialization.data(withJSONObject: [value], options: []), - let arrayString = String(data: data, encoding: .utf8) else { - return "null" - } - // JSON encodes ["value"] as `["value"]`. Drop the leading `[` and trailing `]` - // to yield `"value"` — a valid JS string literal with all special characters escaped. - return String(arrayString.dropFirst().dropLast()) - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBModels.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBModels.swift deleted file mode 100644 index 7f4fe0e..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBModels.swift +++ /dev/null @@ -1,214 +0,0 @@ - -import Foundation - -public enum RDEPUBLayout: String, Codable { - case reflowable - case fixed -} - -public enum RDEPUBReadingProfile: String, Codable { - case webInteractive - case webFixedLayout - case textReflowable -} - -public enum RDEPUBReadingProgression: String, Codable { - case ltr - case rtl - case auto -} - -public enum RDEPUBPageSpread: String, Codable { - case left - case right - case center -} - -/// EPUB 渲染朝向(rendition:orientation / iBooks display-options)。 -public enum RDEPUBOrientation: String, Codable { - case portrait - case landscape - case auto -} - -public struct RDEPUBMetadata: Codable, Equatable { - - public var identifier: String? - - public var title: String - - public var author: String? - - public var language: String? - - public var version: String? - - public var layout: RDEPUBLayout - - public var spread: String? - - public var readingProgression: RDEPUBReadingProgression - - /// 渲染朝向;nil = 未声明(不锁定,跟随系统)。 - /// 来源优先级:OPF rendition:orientation > META-INF/com.apple.ibooks.display-options.xml。 - public var orientation: RDEPUBOrientation? - - public init( - identifier: String? = nil, - title: String = "", - author: String? = nil, - language: String? = nil, - version: String? = nil, - layout: RDEPUBLayout = .reflowable, - spread: String? = nil, - readingProgression: RDEPUBReadingProgression = .auto, - orientation: RDEPUBOrientation? = nil - ) { - self.identifier = identifier - self.title = title - self.author = author - self.language = language - self.version = version - self.layout = layout - self.spread = spread - self.readingProgression = readingProgression - self.orientation = orientation - } -} - -public struct RDEPUBManifestItem: Codable, Equatable { - - public var id: String - - public var href: String - - public var mediaType: String - - public var properties: [String] - - public var fallback: String? - - public var mediaOverlay: String? - - public var title: String? - - public init( - id: String, - href: String, - mediaType: String, - properties: [String] = [], - fallback: String? = nil, - mediaOverlay: String? = nil, - title: String? = nil - ) { - self.id = id - self.href = href - self.mediaType = mediaType - self.properties = properties - self.fallback = fallback - self.mediaOverlay = mediaOverlay - self.title = title - } - - public var isNavigationDocument: Bool { - properties.contains("nav") - } - - public var isNCX: Bool { - mediaType == "application/x-dtbncx+xml" - } -} - -public struct RDEPUBSpineItem: Codable, Equatable { - - public var idref: String - - public var href: String - - public var mediaType: String - - public var title: String - - public var linear: Bool - - public var properties: [String] - - public var pageSpread: RDEPUBPageSpread? - - public var layout: RDEPUBLayout? - - public init( - idref: String, - href: String, - mediaType: String, - title: String, - linear: Bool = true, - properties: [String] = [], - pageSpread: RDEPUBPageSpread? = nil, - layout: RDEPUBLayout? = nil - ) { - self.idref = idref - self.href = href - self.mediaType = mediaType - self.title = title - self.linear = linear - self.properties = properties - self.pageSpread = pageSpread - self.layout = layout - } -} - -public struct EPUBTableOfContentsItem: Codable, Equatable { - - public var title: String - - public var href: String - - public var children: [EPUBTableOfContentsItem] - - public init(title: String, href: String, children: [EPUBTableOfContentsItem] = []) { - self.title = title - self.href = href - self.children = children - } -} - -public enum RDEPUBParserError: LocalizedError { - - case archiveOpenFailed(URL) - - case missingContainerXML - - case missingRootFile - - case invalidRootFilePath(String) - - case invalidXML(URL) - - case missingManifestItem(idref: String) - - case emptySpine - - case invalidArchiveEntryPath(String) - - public var errorDescription: String? { - switch self { - case .archiveOpenFailed(let url): - return "无法打开 EPUB 压缩包: \(url.lastPathComponent)" - case .missingContainerXML: - return "EPUB 缺少 META-INF/container.xml" - case .missingRootFile: - return "container.xml 中未找到 rootfile" - case .invalidRootFilePath(let path): - return "OPF 路径无效: \(path)" - case .invalidXML(let url): - return "XML 解析失败: \(url.lastPathComponent)" - case .missingManifestItem(let idref): - return "spine itemref 找不到对应 manifest 项: \(idref)" - case .emptySpine: - return "OPF spine 为空" - case .invalidArchiveEntryPath(let path): - return "归档条目路径非法: \(path)" - } - } -} \ No newline at end of file diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBNavigatorLayoutContext.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBNavigatorLayoutContext.swift deleted file mode 100644 index 085a37f..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBNavigatorLayoutContext.swift +++ /dev/null @@ -1,59 +0,0 @@ - -import UIKit - -public struct RDEPUBNavigatorLayoutContext: Equatable { - - public var containerSize: CGSize - - public var pagesPerScreen: Int - - public var safeAreaInsets: UIEdgeInsets - - public var userInterfaceIdiom: UIUserInterfaceIdiom - - public var reflowableContentInsets: UIEdgeInsets - - public init( - containerSize: CGSize, - pagesPerScreen: Int = 1, - safeAreaInsets: UIEdgeInsets = .zero, - userInterfaceIdiom: UIUserInterfaceIdiom = .phone, - reflowableContentInsets: UIEdgeInsets = RDEPUBSafeArea.defaultReflowableContentInsets() - ) { - self.containerSize = containerSize - self.pagesPerScreen = max(1, pagesPerScreen) - self.safeAreaInsets = safeAreaInsets - self.userInterfaceIdiom = userInterfaceIdiom - self.reflowableContentInsets = reflowableContentInsets - } - - public var viewportSize: CGSize { - let width: CGFloat - if pagesPerScreen > 1 { - width = containerSize.width / CGFloat(pagesPerScreen) - } else { - width = containerSize.width - } - return CGSize(width: width, height: containerSize.height) - } - - /// Content insets that account for safe area (Dynamic Island / notch) on iPhone. - /// Uses the larger of safeAreaInsets and reflowableContentInsets for top/bottom - /// to ensure content is never hidden under the Dynamic Island or home indicator. - public var safeReflowableContentInsets: UIEdgeInsets { - let top = max(safeAreaInsets.top, reflowableContentInsets.top) - let bottom = max(safeAreaInsets.bottom, reflowableContentInsets.bottom) - let left = max(safeAreaInsets.left, reflowableContentInsets.left) - let right = max(safeAreaInsets.right, reflowableContentInsets.right) - return UIEdgeInsets(top: top, left: left, bottom: bottom, right: right) - } - - /// Fixed layout content insets that respect safe areas on all devices including iPhone. - public var fixedContentInset: UIEdgeInsets { - var insets = safeAreaInsets - let horizontalInsets = max(insets.left, insets.right) - insets.left = horizontalInsets - insets.right = horizontalInsets - return insets - } -} \ No newline at end of file diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBPaginator.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBPaginator.swift deleted file mode 100644 index 0e844b9..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBPaginator.swift +++ /dev/null @@ -1,379 +0,0 @@ - -import UIKit -import WebKit - -public final class RDEPUBPaginator: NSObject { - - private var parser: RDEPUBParser? - - private weak var hostingView: UIView? - - private var presentation = RDEPUBPresentationStyle( - viewportSize: .zero, - contentInsets: .zero, - fontSize: 16, - lineHeightMultiple: 1.5 - ) - - private var completion: (([Int]) -> Void)? - - private var singlePageCountCompletion: ((Int) -> Void)? - - private var pageCounts: [Int] = [] - - private var measurementIndices: [Int] = [] - - private var currentMeasurementOffset = 0 - - private var pendingMeasurementValue = 1 - - private var measurementPass = 0 - - private var activeSessionID = UUID() - - private let debugScope = "PaginatorWebView" - - private lazy var webView: WKWebView = { - let configuration = WKWebViewConfiguration() - configuration.websiteDataStore = .nonPersistent() - let webView = WKWebView(frame: .zero, configuration: configuration) - webView.navigationDelegate = self - webView.isOpaque = false - webView.backgroundColor = .clear - webView.scrollView.isScrollEnabled = false - webView.scrollView.bounces = false - webView.scrollView.showsHorizontalScrollIndicator = false - webView.scrollView.showsVerticalScrollIndicator = false - webView.isUserInteractionEnabled = false - if #available(iOS 16.4, *) { - webView.isInspectable = RDEPUBWebViewDebug.isInspectableEnabled - } - RDEPUBWebViewDebug.log("PaginatorWebView", message: "configured webView=\(RDEPUBWebViewDebug.webViewID(webView))") - return webView - }() - - public override init() { - super.init() - } - - deinit { - cleanupMeasurementState() - webView.navigationDelegate = nil - webView.stopLoading() - webView.removeFromSuperview() - } - - public func calculate( - parser: RDEPUBParser, - hostingView: UIView, - presentation: RDEPUBPresentationStyle, - completion: @escaping ([Int]) -> Void - ) { - self.parser = parser - self.hostingView = hostingView - self.presentation = presentation - self.completion = completion - self.singlePageCountCompletion = nil - self.pageCounts = Array(repeating: 1, count: parser.spine.count) - self.measurementIndices = Array(parser.spine.indices) - self.currentMeasurementOffset = 0 - self.activeSessionID = UUID() - RDEPUBWebViewDebug.log(debugScope, message: "calculate session=\(activeSessionID) spineCount=\(parser.spine.count)") - - guard parser.metadata.layout != .fixed else { - completion(self.pageCounts) - return - } - - webView.navigationDelegate = self - if webView.superview == nil { - hostingView.addSubview(webView) - } - webView.frame = CGRect(origin: .zero, size: presentation.viewportSize) - measureNextSpineItem() - } - - public func calculate( - parser: RDEPUBParser, - hostingView: UIView, - viewportSize: CGSize, - padding: UIEdgeInsets, - fontSize: CGFloat, - lineHeightMultiple: CGFloat, - completion: @escaping ([Int]) -> Void - ) { - calculate( - parser: parser, - hostingView: hostingView, - presentation: RDEPUBPresentationStyle( - viewportSize: viewportSize, - contentInsets: padding, - fontSize: fontSize, - lineHeightMultiple: lineHeightMultiple - ), - completion: completion - ) - } - - public func calculateSingleSpinePageCount( - parser: RDEPUBParser, - spineIndex: Int, - hostingView: UIView, - presentation: RDEPUBPresentationStyle, - completion: @escaping (Int) -> Void - ) { - self.parser = parser - self.hostingView = hostingView - self.presentation = presentation - self.completion = nil - self.singlePageCountCompletion = completion - self.pageCounts = Array(repeating: 1, count: parser.spine.count) - self.measurementIndices = parser.spine.indices.contains(spineIndex) ? [spineIndex] : [] - self.currentMeasurementOffset = 0 - self.activeSessionID = UUID() - RDEPUBWebViewDebug.log(debugScope, message: "calculateSingle session=\(activeSessionID) spineIndex=\(spineIndex)") - - guard parser.metadata.layout != .fixed, !measurementIndices.isEmpty else { - completion(1) - return - } - - webView.navigationDelegate = self - if webView.superview == nil { - hostingView.addSubview(webView) - } - webView.frame = CGRect(origin: .zero, size: presentation.viewportSize) - measureNextSpineItem() - } - - public func calculateSingleSpinePageCount( - parser: RDEPUBParser, - spineIndex: Int, - hostingView: UIView, - viewportSize: CGSize, - padding: UIEdgeInsets, - fontSize: CGFloat, - lineHeightMultiple: CGFloat, - completion: @escaping (Int) -> Void - ) { - calculateSingleSpinePageCount( - parser: parser, - spineIndex: spineIndex, - hostingView: hostingView, - presentation: RDEPUBPresentationStyle( - viewportSize: viewportSize, - contentInsets: padding, - fontSize: fontSize, - lineHeightMultiple: lineHeightMultiple - ), - completion: completion - ) - } - - public static func injectPaginationCSS( - into html: String, - viewportSize: CGSize, - padding: UIEdgeInsets, - fontSize: CGFloat, - lineHeightMultiple: CGFloat, - themeBackgroundColor: String? = nil, - themeTextColor: String? = nil - ) -> String { - RDEPUBStyleSheetBuilder.injectPaginationCSS( - into: html, - presentation: RDEPUBPresentationStyle( - viewportSize: viewportSize, - contentInsets: padding, - fontSize: fontSize, - lineHeightMultiple: lineHeightMultiple, - themeBackgroundColor: themeBackgroundColor, - themeTextColor: themeTextColor - ) - ) - } - - private func measureNextSpineItem() { - guard let parser else { - finishIfNeeded() - return - } - - guard currentMeasurementOffset < measurementIndices.count else { - finishIfNeeded() - return - } - - let currentSpineIndex = measurementIndices[currentMeasurementOffset] - - let item = parser.spine[currentSpineIndex] - guard isRenderablePage(item: item), - let opfDirectoryURL = parser.opfDirectoryURL else { - pageCounts[currentSpineIndex] = 1 - currentMeasurementOffset += 1 - measureNextSpineItem() - return - } - - let fileURL = opfDirectoryURL.appendingPathComponent(item.href) - guard FileManager.default.fileExists(atPath: fileURL.path) else { - pageCounts[currentSpineIndex] = 1 - currentMeasurementOffset += 1 - measureNextSpineItem() - return - } - - pendingMeasurementValue = 1 - measurementPass = 0 - let readAccessURL = parser.extractionRootURL ?? opfDirectoryURL - RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "measure-load", url: fileURL) - webView.loadFileURL(fileURL, allowingReadAccessTo: readAccessURL) - } - - private func currentSpineIndexForMeasurement() -> Int? { - measurementIndices[safe: currentMeasurementOffset] - } - - private func finishIfNeeded() { - if let singlePageCountCompletion { - let measuredSpineIndex = measurementIndices.first ?? 0 - singlePageCountCompletion(max(1, pageCounts[safe: measuredSpineIndex] ?? 1)) - self.singlePageCountCompletion = nil - cleanupMeasurementState() - return - } - guard let completion else { return } - completion(pageCounts) - self.completion = nil - cleanupMeasurementState() - } - - private func cleanupMeasurementState() { - parser = nil - hostingView = nil - completion = nil - singlePageCountCompletion = nil - measurementIndices = [] - currentMeasurementOffset = 0 - pendingMeasurementValue = 1 - measurementPass = 0 - activeSessionID = UUID() - webView.stopLoading() - webView.navigationDelegate = nil - webView.removeFromSuperview() - } - - private func isRenderablePage(item: RDEPUBSpineItem) -> Bool { - let mediaType = item.mediaType.lowercased() - return mediaType.contains("html") || mediaType.contains("xhtml") || mediaType.contains("xml") - } - - private var measurementPassValues: [Int] = [] - - private func scheduleMeasurementPass() { - let sessionID = activeSessionID - let delays: [TimeInterval] = [0.0, 0.08, 0.18] - guard measurementPass < delays.count else { - guard sessionID == activeSessionID, - let currentSpineIndex = currentSpineIndexForMeasurement() else { - return - } - let finalValue = max(1, pendingMeasurementValue) - - if measurementPassValues.count >= 2 { - let minVal = measurementPassValues.min() ?? 1 - let maxVal = measurementPassValues.max() ?? 1 - if minVal > 0 && Double(maxVal - minVal) / Double(minVal) > 0.2 { - RDEPUBWebViewDebug.log(debugScope, message: "measurement instability: passes=\(measurementPassValues) spine=\(currentSpineIndex)") - } - } - pageCounts[currentSpineIndex] = finalValue - currentMeasurementOffset += 1 - measurementPassValues = [] - measureNextSpineItem() - return - } - - let delay = delays[measurementPass] - measurementPass += 1 - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in - guard let self, self.activeSessionID == sessionID else { return } - self.measureCurrentDocument(sessionID: sessionID) - } - } - - private func measureCurrentDocument(sessionID: UUID) { - let script = RDEPUBStyleSheetBuilder.measurementScript(for: presentation) - RDEPUBWebViewDebug.logJavaScript(debugScope, webView: webView, action: "measure", details: "session=\(sessionID) pass=\(measurementPass)") - - webView.evaluateJavaScript(script) { [weak self] value, _ in - guard let self else { return } - guard self.activeSessionID == sessionID, - self.currentSpineIndexForMeasurement() != nil else { - return - } - if let number = value as? NSNumber { - self.pendingMeasurementValue = max(self.pendingMeasurementValue, number.intValue) - self.measurementPassValues.append(number.intValue) - } else if let intValue = value as? Int { - self.pendingMeasurementValue = max(self.pendingMeasurementValue, intValue) - self.measurementPassValues.append(intValue) - } - RDEPUBWebViewDebug.log(self.debugScope, message: "measurement value=\(self.pendingMeasurementValue) session=\(sessionID)") - self.scheduleMeasurementPass() - } - } - -} - -extension RDEPUBPaginator: WKNavigationDelegate { - - public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { - guard let url = navigationAction.request.url else { - decisionHandler(.cancel) - return - } - let scheme = url.scheme?.lowercased() ?? "" - if scheme == "file" || scheme == RDEPUBResourceURLSchemeHandler.scheme { - decisionHandler(.allow) - } else { - RDEPUBWebViewDebug.log(debugScope, message: "blocked navigation to non-local scheme: \(scheme)") - decisionHandler(.cancel) - } - } - - public func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { - RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didStart", url: webView.url) - } - - public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFinish", url: webView.url) - guard currentSpineIndexForMeasurement() != nil else { return } - scheduleMeasurementPass() - } - - public func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { - RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFail", url: webView.url, error: error) - let currentSpineIndex = measurementIndices[safe: currentMeasurementOffset] ?? 0 - pageCounts[currentSpineIndex] = 1 - currentMeasurementOffset += 1 - measureNextSpineItem() - } - - public func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { - RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFailProvisional", url: webView.url, error: error) - let currentSpineIndex = measurementIndices[safe: currentMeasurementOffset] ?? 0 - pageCounts[currentSpineIndex] = 1 - currentMeasurementOffset += 1 - measureNextSpineItem() - } - - public func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { - RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "processTerminated", url: webView.url) - } -} - -private extension Array { - subscript(safe index: Int) -> Element? { - indices.contains(index) ? self[index] : nil - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+Archive.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+Archive.swift deleted file mode 100644 index fd76ea5..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+Archive.swift +++ /dev/null @@ -1,198 +0,0 @@ - -import Foundation -import ZIPFoundation - -extension RDEPUBParser { - - func parseContainerRootFile(at containerURL: URL) throws -> String { - guard let parser = XMLParser(contentsOf: containerURL) else { - throw RDEPUBParserError.invalidXML(containerURL) - } - - let delegate = ContainerXMLParserDelegate() - parser.shouldProcessNamespaces = false - parser.delegate = delegate - - guard parser.parse() else { - throw parser.parserError ?? RDEPUBParserError.invalidXML(containerURL) - } - - guard let rootFilePath = delegate.rootFilePath, !rootFilePath.isEmpty else { - throw RDEPUBParserError.missingRootFile - } - - return rootFilePath - } - - func extractArchiveIfNeeded(epubURL: URL) throws -> URL { - let fileManager = FileManager.default - let extractionURL = temporaryExtractionDirectory(for: epubURL) - - if fileManager.fileExists(atPath: extractionURL.path) { - let containerURL = extractionURL.appendingPathComponent("META-INF/container.xml") - if fileManager.fileExists(atPath: containerURL.path) { - return extractionURL - } - try? fileManager.removeItem(at: extractionURL) - } - - guard let archive = Archive(url: epubURL, accessMode: .read) else { - throw RDEPUBParserError.archiveOpenFailed(epubURL) - } - - try fileManager.createDirectory(at: extractionURL, withIntermediateDirectories: true) - - do { - for entry in archive { - if shouldSkipEntry(entry.path) { continue } - - guard let destinationURL = validatedExtractionDestination(for: entry.path, extractionRoot: extractionURL) else { - if isCriticalEntry(entry.path) { - throw RDEPUBParserError.invalidArchiveEntryPath(entry.path) - } - continue - } - switch entry.type { - case .directory: - try fileManager.createDirectory(at: destinationURL, withIntermediateDirectories: true) - case .file: - try fileManager.createDirectory(at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true) - _ = try archive.extract(entry, to: destinationURL) - case .symlink: - continue - } - } - } catch { - try? fileManager.removeItem(at: extractionURL) - throw error - } - - return extractionURL - } - - private func isCriticalEntry(_ entryPath: String) -> Bool { - let lowercased = entryPath.lowercased() - return lowercased == "mimetype" - || lowercased.hasPrefix("meta-inf/") - || lowercased.hasSuffix(".opf") - } - - private func shouldSkipEntry(_ entryPath: String) -> Bool { - let lowercased = entryPath.lowercased() - if lowercased.hasPrefix("__macosx/") { return true } - if lowercased.hasPrefix(".ds_store") { return true } - if lowercased.contains("/.ds_store") { return true } - if lowercased.hasSuffix("/thumbs.db") { return true } - return false - } - - private func validatedExtractionDestination(for entryPath: String, extractionRoot: URL) -> URL? { - - if entryPath.hasPrefix("/") { - return nil - } - - let components = entryPath.split(separator: "/", omittingEmptySubsequences: true) - if components.contains(where: { $0 == ".." }) { - return nil - } - let destinationURL = extractionRoot.appendingPathComponent(entryPath) - let standardizedDest = destinationURL.standardizedFileURL.path - let standardizedRoot = extractionRoot.standardizedFileURL.path - - guard standardizedDest.hasPrefix(standardizedRoot) else { - return nil - } - return destinationURL - } - - func temporaryExtractionDirectory(for epubURL: URL) -> URL { - let baseURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first? - .appendingPathComponent("ssreaderview-epub", isDirectory: true) - ?? FileManager.default.temporaryDirectory.appendingPathComponent("ssreaderview-epub", isDirectory: true) - let fileAttributes = try? FileManager.default.attributesOfItem(atPath: epubURL.path) - let fileSize = (fileAttributes?[.size] as? NSNumber)?.stringValue ?? "0" - let modifiedAt = (fileAttributes?[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0 - let slug = epubURL.deletingPathExtension().lastPathComponent - .replacingOccurrences(of: " ", with: "-") - let signature = String(format: "%.0f", modifiedAt) - return baseURL.appendingPathComponent("\(slug)-\(fileSize)-\(signature)", isDirectory: true) - } - - func reset() { - extractionRootURL = nil - opfURL = nil - resetPublicationState() - } - - func resetPublicationState() { - metadata = RDEPUBMetadata() - manifest = [:] - spine = [] - tableOfContents = [] - } -} - -private final class ContainerXMLParserDelegate: NSObject, XMLParserDelegate { - - private(set) var rootFilePath: String? - - func parser( - _ parser: XMLParser, - didStartElement elementName: String, - namespaceURI: String?, - qualifiedName qName: String?, - attributes attributeDict: [String: String] = [:] - ) { - let name = XMLName.localName(from: qName ?? elementName) - guard name == "rootfile", rootFilePath == nil else { - return - } - rootFilePath = attributeDict["full-path"]?.trimmingCharacters(in: .whitespacesAndNewlines) - } -} - -/// 解析 com.apple.ibooks.display-options.xml,提取 orientation-lock 选项值。 -final class IBooksDisplayOptionsParserDelegate: NSObject, XMLParserDelegate { - - private(set) var orientationLock: String? - - private var isCapturingOrientationOption = false - private var buffer = "" - - func parser( - _ parser: XMLParser, - didStartElement elementName: String, - namespaceURI: String?, - qualifiedName qName: String?, - attributes attributeDict: [String: String] = [:] - ) { - let name = XMLName.localName(from: qName ?? elementName) - guard name == "option" else { return } - let optionName = (attributeDict["name"] ?? "").lowercased() - if optionName == "orientation-lock" { - isCapturingOrientationOption = true - buffer = "" - } - } - - func parser(_ parser: XMLParser, foundCharacters string: String) { - guard isCapturingOrientationOption else { return } - buffer += string - } - - func parser( - _ parser: XMLParser, - didEndElement elementName: String, - namespaceURI: String?, - qualifiedName qName: String? - ) { - let name = XMLName.localName(from: qName ?? elementName) - guard name == "option", isCapturingOrientationOption else { return } - isCapturingOrientationOption = false - let value = buffer.trimmingCharacters(in: .whitespacesAndNewlines) - if !value.isEmpty, orientationLock == nil { - orientationLock = value - } - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+Package.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+Package.swift deleted file mode 100644 index d734956..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+Package.swift +++ /dev/null @@ -1,407 +0,0 @@ - -import Foundation - -extension RDEPUBParser { - - func buildSpine(from packageDocument: OPFPackageDocument, opfURL: URL) throws -> [RDEPUBSpineItem] { - let opfDirectoryURL = opfURL.deletingLastPathComponent() - let publicationLayout = packageDocument.metadata.layout - - let spineItems = try packageDocument.spineReferences.map { reference in - guard let manifestItem = packageDocument.manifestByID[reference.idref] else { - throw RDEPUBParserError.missingManifestItem(idref: reference.idref) - } - - let normalizedHref = normalize(href: manifestItem.href, relativeTo: opfDirectoryURL) - let title = manifestItem.title?.trimmingCharacters(in: .whitespacesAndNewlines) - let fallbackTitle = normalizedHref - .split(separator: "/") - .last - .map(String.init) - .map { $0.replacingOccurrences(of: ".xhtml", with: "") } - .map { $0.replacingOccurrences(of: ".html", with: "") } - .flatMap { $0.isEmpty ? nil : $0 } - ?? reference.idref - - let effectiveLayout: RDEPUBLayout? - if reference.properties.contains("rendition:layout-pre-paginated") || manifestItem.properties.contains("rendition:layout-pre-paginated") { - effectiveLayout = .fixed - } else if reference.properties.contains("rendition:layout-reflowable") || manifestItem.properties.contains("rendition:layout-reflowable") { - effectiveLayout = .reflowable - } else { - effectiveLayout = publicationLayout == .fixed ? .fixed : nil - } - - return RDEPUBSpineItem( - idref: reference.idref, - href: normalizedHref, - mediaType: manifestItem.mediaType, - title: title?.isEmpty == false ? title! : fallbackTitle, - linear: reference.linear, - properties: reference.properties, - pageSpread: reference.pageSpread, - layout: effectiveLayout - ) - } - - guard !spineItems.isEmpty else { - throw RDEPUBParserError.emptySpine - } - - return spineItems - } -} - -struct OPFPackageDocument { - var metadata: RDEPUBMetadata - var manifest: [RDEPUBManifestItem] - var manifestByID: [String: RDEPUBManifestItem] - var spineReferences: [OPFSpineReference] - - var ncxItem: RDEPUBManifestItem? - - var navigationItem: RDEPUBManifestItem? -} - -struct OPFSpineReference { - - var idref: String - - var linear: Bool - - var properties: [String] - - var pageSpread: RDEPUBPageSpread? -} - -final class OPFPackageParserDelegate: NSObject, XMLParserDelegate { - - private var metadata = RDEPUBMetadata() - - private var manifestItems: [RDEPUBManifestItem] = [] - - private var spineReferences: [OPFSpineReference] = [] - - private let xmlContext = XMLParserContext() - - private var uniqueIdentifierID: String? - - private var currentIdentifierElementID: String? - - private var currentMetaProperty: String? - - private var identifierByID: [String: String] = [:] - - private var manifestTitleByID: [String: String] = [:] - - private var currentMetaRefinesID: String? - - private var ncxID: String? - - func packageDocument() -> OPFPackageDocument { - if metadata.identifier == nil, let fallbackIdentifier = identifierByID.values.first { - metadata.identifier = fallbackIdentifier - } - metadata.title = metadata.title.trimmingCharacters(in: .whitespacesAndNewlines) - metadata.author = metadata.author?.trimmingCharacters(in: .whitespacesAndNewlines) - metadata.language = metadata.language?.trimmingCharacters(in: .whitespacesAndNewlines) - - let manifest = manifestItems.map { item in - var updatedItem = item - updatedItem.title = manifestTitleByID[item.id] - return updatedItem - } - // 部分历史 EPUB 的 OPF manifest 含重复 id。`uniqueKeysWithValues` 会在 - // 此处触发运行时断言,导致整本书无法打开;spine 按 OPF 顺序解析,故保留 - // 第一个声明即可维持其原始引用语义。 - let manifestByID = manifest.reduce(into: [String: RDEPUBManifestItem]()) { result, item in - guard !item.id.isEmpty, result[item.id] == nil else { return } - result[item.id] = item - } - - return OPFPackageDocument( - metadata: metadata, - manifest: manifest, - manifestByID: manifestByID, - spineReferences: spineReferences, - ncxItem: manifest.first(where: { $0.id == ncxID || $0.isNCX }), - navigationItem: manifest.first(where: { $0.isNavigationDocument }) - ) - } - - func parser( - _ parser: XMLParser, - didStartElement elementName: String, - namespaceURI: String?, - qualifiedName qName: String?, - attributes attributeDict: [String: String] = [:] - ) { - let name = xmlContext.startElement(named: qName ?? elementName) - - switch name { - case "package": - metadata.version = attributeDict["version"] - uniqueIdentifierID = attributeDict["unique-identifier"] - case "identifier": - if currentSection == .metadata { - currentIdentifierElementID = attributeDict["id"] - } - case "item": - guard currentSection == .manifest, - let id = attributeDict["id"], - let href = attributeDict["href"], - let mediaType = attributeDict["media-type"] else { - return - } - manifestItems.append( - RDEPUBManifestItem( - id: id, - href: href, - mediaType: mediaType, - properties: XMLName.tokenize(attributeDict["properties"]), - fallback: attributeDict["fallback"], - mediaOverlay: attributeDict["media-overlay"] - ) - ) - case "itemref": - guard currentSection == .spine, let idref = attributeDict["idref"] else { - return - } - spineReferences.append( - OPFSpineReference( - idref: idref, - linear: (attributeDict["linear"]?.lowercased() ?? "yes") != "no", - properties: XMLName.tokenize(attributeDict["properties"]), - pageSpread: pageSpread(from: attributeDict) - ) - ) - case "meta": - currentMetaProperty = attributeDict["property"] ?? attributeDict["name"] - currentMetaRefinesID = attributeDict["refines"].flatMap(XMLName.refinedID(from:)) - if let property = currentMetaProperty?.lowercased(), - property == "rendition:layout", - let value = attributeDict["content"] { - metadata.layout = layout(from: value) - } - if let property = currentMetaProperty?.lowercased(), - property == "rendition:spread" { - metadata.spread = attributeDict["content"] - } - if let property = currentMetaProperty?.lowercased(), - property == "rendition:orientation", - let value = attributeDict["content"] { - metadata.orientation = orientation(from: value) - } - if let property = currentMetaProperty?.lowercased(), - property == "title", - let refinesID = currentMetaRefinesID, - let value = attributeDict["content"], - !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - manifestTitleByID[refinesID] = value.trimmingCharacters(in: .whitespacesAndNewlines) - } - default: - break - } - - if name == "spine" { - if let direction = attributeDict["page-progression-direction"]?.lowercased() { - metadata.readingProgression = direction == "ltr" ? .ltr : (direction == "rtl" ? .rtl : .auto) - } - ncxID = attributeDict["toc"] - } - } - - func parser(_ parser: XMLParser, foundCharacters string: String) { - xmlContext.appendCharacters(string) - } - - func parser( - _ parser: XMLParser, - didEndElement elementName: String, - namespaceURI: String?, - qualifiedName qName: String? - ) { - let name = XMLName.localName(from: qName ?? elementName) - let text = xmlContext.trimmedCharacters - - switch (currentSection, name) { - case (.metadata, "identifier"): - if let identifierID = currentIdentifierElementID, !text.isEmpty { - identifierByID[identifierID] = text - if identifierID == uniqueIdentifierID { - metadata.identifier = text - } - } else if metadata.identifier == nil, !text.isEmpty { - metadata.identifier = text - } - currentIdentifierElementID = nil - case (.metadata, "title"): - if metadata.title.isEmpty, !text.isEmpty { - metadata.title = text - } - case (.metadata, "creator"): - if metadata.author == nil, !text.isEmpty { - metadata.author = text - } - case (.metadata, "language"): - if metadata.language == nil, !text.isEmpty { - metadata.language = text - } - case (.metadata, "meta"): - applyMetaValue(text) - currentMetaProperty = nil - currentMetaRefinesID = nil - default: - break - } - - xmlContext.endElement() - } - - private var currentSection: XMLSection { - if xmlContext.containsElement(named: "manifest") { - return .manifest - } - if xmlContext.containsElement(named: "spine") { - return .spine - } - if xmlContext.containsElement(named: "metadata") { - return .metadata - } - return .other - } - - private func applyMetaValue(_ value: String) { - let normalizedValue = value.trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalizedValue.isEmpty, let property = currentMetaProperty?.lowercased() else { - return - } - - switch property { - case "rendition:layout": - metadata.layout = layout(from: normalizedValue) - case "rendition:spread": - metadata.spread = normalizedValue - case "rendition:orientation": - metadata.orientation = orientation(from: normalizedValue) - case "dcterms:identifier", "identifier": - if metadata.identifier == nil { - metadata.identifier = normalizedValue - } - case "title": - if let refinesID = currentMetaRefinesID { - manifestTitleByID[refinesID] = normalizedValue - } - default: - break - } - } - - private func pageSpread(from attributes: [String: String]) -> RDEPUBPageSpread? { - if let properties = attributes["properties"]?.lowercased() { - if properties.contains("page-spread-left") { - return .left - } - if properties.contains("page-spread-right") { - return .right - } - if properties.contains("page-spread-center") { - return .center - } - } - - if let legacySpread = attributes["page-spread"]?.lowercased() { - switch legacySpread { - case "left": - return .left - case "right": - return .right - case "center": - return .center - default: - return nil - } - } - - return nil - } - - private func layout(from rawValue: String) -> RDEPUBLayout { - rawValue.lowercased().contains("pre-paginated") ? .fixed : .reflowable - } - - private func orientation(from rawValue: String) -> RDEPUBOrientation? { - switch rawValue.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) { - case "portrait": - return .portrait - case "landscape": - return .landscape - case "auto": - return .auto - default: - return nil - } - } -} - -enum XMLSection { - case metadata - case manifest - case spine - case other -} - -enum XMLName { - - static func localName(from rawName: String) -> String { - rawName.split(separator: ":").last.map(String.init) ?? rawName - } - - static func tokenize(_ rawValue: String?) -> [String] { - guard let rawValue else { return [] } - return rawValue - .split(whereSeparator: { $0 == " " || $0 == "\n" || $0 == "\t" || $0 == "\r" }) - .map(String.init) - } - - static func refinedID(from rawValue: String) -> String? { - let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmed.hasPrefix("#") else { - return nil - } - return String(trimmed.dropFirst()) - } -} - -final class XMLParserContext { - - private var elementStack: [String] = [] - - private var currentCharacters = "" - - var trimmedCharacters: String { - currentCharacters.trimmingCharacters(in: .whitespacesAndNewlines) - } - - func startElement(named rawName: String) -> String { - let name = XMLName.localName(from: rawName) - elementStack.append(name) - currentCharacters = "" - return name - } - - func appendCharacters(_ string: String) { - currentCharacters += string - } - - func endElement() { - if !elementStack.isEmpty { - _ = elementStack.removeLast() - } - currentCharacters = "" - } - - func containsElement(named name: String) -> Bool { - elementStack.contains(name) - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+ReadingProfile.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+ReadingProfile.swift deleted file mode 100644 index b58219c..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+ReadingProfile.swift +++ /dev/null @@ -1,46 +0,0 @@ - -import Foundation - -extension RDEPUBParser { - - public func readingProfile() -> RDEPUBReadingProfile { - if metadata.layout == .fixed { - return .webFixedLayout - } - return hasInteractiveContent() ? .webInteractive : .textReflowable - } - - public func hasInteractiveContent() -> Bool { - let interactiveMediaTypes = [ - "application/javascript", - "text/javascript", - "application/ecmascript" - ] - if manifest.values.contains(where: { item in - interactiveMediaTypes.contains(item.mediaType.lowercased()) || item.properties.contains("scripted") - }) { - return true - } - - for item in spine where item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml")) { - guard let html = htmlString(forRelativePath: item.href) else { - continue - } - if containsInteractiveMarkup(html) { - return true - } - } - - return false - } - - private func containsInteractiveMarkup(_ html: String) -> Bool { - let interactivePattern = #"<(script|iframe|video|audio|canvas|object|embed)\b|\bon(load|click|touchstart|touchend|mouseover|submit|change|input)=|hype_generated_script|swiper|webview"# - if html.range(of: interactivePattern, options: [.regularExpression, .caseInsensitive]) != nil { - return true - } - - let dynamicSVGPattern = #"]*>[\s\S]*?(<(script|foreignObject|animate|animateMotion|animateTransform|set)\b|\bon(load|click|touchstart|touchend|mouseover)=)"# - return html.range(of: dynamicSVGPattern, options: [.regularExpression, .caseInsensitive]) != nil - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+Resources.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+Resources.swift deleted file mode 100644 index 24bc728..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+Resources.swift +++ /dev/null @@ -1,101 +0,0 @@ - -import Foundation -import UIKit - -extension RDEPUBParser { - - public func manifestItem(for id: String) -> RDEPUBManifestItem? { - manifest[id] - } - - public func manifestItem(forHref href: String) -> RDEPUBManifestItem? { - let target = href.components(separatedBy: "#").first ?? href - return manifest.values.first { item in - normalize(href: item.href, relativeTo: opfDirectoryURL ?? URL(fileURLWithPath: "/")) == target - } - } - - public func href(forSpineIndex index: Int) -> String? { - guard spine.indices.contains(index) else { return nil } - return spine[index].href - } - - public func fileURL(forRelativePath relativePath: String) -> URL? { - guard let opfDirectoryURL else { return nil } - let path = relativePath.components(separatedBy: "#").first ?? relativePath - guard !path.isEmpty else { return nil } - let resolvedURL = URL(fileURLWithPath: path, relativeTo: opfDirectoryURL).standardizedFileURL - guard let rootURL = extractionRootURL?.standardizedFileURL else { - return resolvedURL - } - guard resolvedURL.path.hasPrefix(rootURL.path) else { - return nil - } - return resolvedURL - } - - public func resourceURL(forRelativePath relativePath: String) -> URL? { - let normalizedPath = relativePath.trimmingCharacters(in: CharacterSet(charactersIn: "/")) - guard !normalizedPath.isEmpty else { return nil } - guard let encodedPath = normalizedPath.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { - return nil - } - var components = URLComponents() - components.scheme = RDEPUBResourceURLSchemeHandler.scheme - components.host = RDEPUBResourceURLSchemeHandler.host - components.percentEncodedPath = "/" + encodedPath - return components.url - } - - public func fileURL(forResourceURL resourceURL: URL) -> URL? { - guard resourceURL.scheme == RDEPUBResourceURLSchemeHandler.scheme, - resourceURL.host == RDEPUBResourceURLSchemeHandler.host else { - return nil - } - let relativePath = resourceURL.path.removingPercentEncoding?.trimmingCharacters(in: CharacterSet(charactersIn: "/")) ?? "" - return fileURL(forRelativePath: relativePath) - } - - public func coverImage() -> UIImage? { - let coverCandidates = manifest.values.filter { item in - item.properties.contains("cover-image") || item.id.lowercased().contains("cover") - } - for item in coverCandidates { - if let fileURL = fileURL(forRelativePath: item.href), - let data = resourceData(at: fileURL), - let image = UIImage(data: data) { - return image - } - } - return nil - } - - public func htmlString(forSpineIndex index: Int) -> String? { - guard spine.indices.contains(index) else { - return nil - } - return htmlString(forRelativePath: spine[index].href) - } - - public func htmlString(forRelativePath relativePath: String) -> String? { - guard let fileURL = fileURL(forRelativePath: relativePath) else { - return nil - } - if let provided = providedResourceData(at: fileURL) { - return String(data: provided, encoding: .utf8) - ?? String(data: provided, encoding: .utf16) - } - return try? String(contentsOf: fileURL) - } - - func normalize(href: String, relativeTo directoryURL: URL) -> String { - guard let resolvedURL = URL(string: href, relativeTo: directoryURL)?.standardizedFileURL else { - return href - } - let prefix = directoryURL.standardizedFileURL.path + "/" - if resolvedURL.path.hasPrefix(prefix) { - return String(resolvedURL.path.dropFirst(prefix.count)) - } - return href - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+TOC.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+TOC.swift deleted file mode 100644 index 10e2449..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+TOC.swift +++ /dev/null @@ -1,303 +0,0 @@ - -import Foundation - -extension RDEPUBParser { - - func parseTOC(from packageDocument: OPFPackageDocument, opfURL: URL) -> [EPUBTableOfContentsItem] { - let opfDirectoryURL = opfURL.deletingLastPathComponent() - - if let ncxItem = packageDocument.ncxItem ?? packageDocument.manifest.first(where: { $0.isNCX }) { - let ncxURL = opfDirectoryURL.appendingPathComponent(ncxItem.href) - let items = parseNCXDocumentItems(at: ncxURL) - if !items.isEmpty { - return items - } - } - - if let navItem = packageDocument.navigationItem { - let navURL = opfDirectoryURL.appendingPathComponent(navItem.href) - let items = parseNavDocumentItems(at: navURL, baseURL: navURL.deletingLastPathComponent()) - if !items.isEmpty { - return items - } - } - - return spine.map { EPUBTableOfContentsItem(title: $0.title, href: $0.href) } - } - - func parseNCXDocumentItems(at ncxURL: URL) -> [EPUBTableOfContentsItem] { - guard FileManager.default.fileExists(atPath: ncxURL.path), - let parser = XMLParser(contentsOf: ncxURL) else { - return [] - } - - let delegate = NCXParserDelegate(baseURL: ncxURL.deletingLastPathComponent()) { [weak self] href, baseURL in - self?.normalizeTOCHref(href, relativeTo: baseURL) - } - parser.shouldProcessNamespaces = false - parser.delegate = delegate - guard parser.parse() else { - return [] - } - return delegate.items - } - - func parseNavDocumentItems(at navURL: URL, baseURL: URL) -> [EPUBTableOfContentsItem] { - guard FileManager.default.fileExists(atPath: navURL.path), - let parser = XMLParser(contentsOf: navURL) else { - return [] - } - - let delegate = NavDocumentParserDelegate(baseURL: baseURL) { [weak self] href, currentBaseURL in - self?.normalizeTOCHref(href, relativeTo: currentBaseURL) - } - parser.shouldProcessNamespaces = false - parser.delegate = delegate - guard parser.parse() else { - return [] - } - return delegate.items - } - - func normalizeTOCHref(_ href: String, relativeTo baseURL: URL) -> String? { - let components = href.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false) - let pathPart = components.first.map(String.init) ?? href - let fragment = components.count > 1 ? String(components[1]) : nil - - let normalizedPath: String - if pathPart.isEmpty { - normalizedPath = "" - } else { - normalizedPath = normalize(href: pathPart, relativeTo: baseURL) - } - - if let fragment, !fragment.isEmpty { - if normalizedPath.isEmpty { - return "#\(fragment)" - } - return "\(normalizedPath)#\(fragment)" - } - - return normalizedPath.isEmpty ? nil : normalizedPath - } -} - -private final class TOCNode { - var title = "" - var href: String? - var children: [TOCNode] = [] - - func asItem() -> EPUBTableOfContentsItem? { - let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) - guard let href, !href.isEmpty else { - return nil - } - return EPUBTableOfContentsItem( - title: trimmedTitle.isEmpty ? href : trimmedTitle, - href: href, - children: children.compactMap { $0.asItem() } - ) - } -} - -private final class TOCTreeBuilder { - - private var nodeStack: [TOCNode] = [] - - private var rootNodes: [TOCNode] = [] - - var items: [EPUBTableOfContentsItem] { - rootNodes.compactMap { $0.asItem() } - } - - func beginNode() { - nodeStack.append(TOCNode()) - } - - func updateCurrentHref(_ href: String?) { - nodeStack.last?.href = href - } - - func appendCurrentTitle(_ text: String) { - nodeStack.last?.title += text - } - - func endNode() { - guard let node = nodeStack.popLast() else { - return - } - if let parent = nodeStack.last { - parent.children.append(node) - } else { - rootNodes.append(node) - } - } - - func reset() { - nodeStack.removeAll() - } -} - -private final class NCXParserDelegate: NSObject, XMLParserDelegate { - private let baseURL: URL - private let hrefNormalizer: (String, URL) -> String? - private let treeBuilder = TOCTreeBuilder() - private var readingLabelText = false - - init(baseURL: URL, hrefNormalizer: @escaping (String, URL) -> String?) { - self.baseURL = baseURL - self.hrefNormalizer = hrefNormalizer - } - - var items: [EPUBTableOfContentsItem] { - treeBuilder.items - } - - func parser( - _ parser: XMLParser, - didStartElement elementName: String, - namespaceURI: String?, - qualifiedName qName: String?, - attributes attributeDict: [String: String] = [:] - ) { - let name = XMLName.localName(from: qName ?? elementName) - - switch name { - case "navPoint": - treeBuilder.beginNode() - case "content": - guard let src = attributeDict["src"] else { - return - } - treeBuilder.updateCurrentHref(hrefNormalizer(src, baseURL)) - case "text": - readingLabelText = true - default: - break - } - } - - func parser(_ parser: XMLParser, foundCharacters string: String) { - if readingLabelText { - treeBuilder.appendCurrentTitle(string) - } - } - - func parser( - _ parser: XMLParser, - didEndElement elementName: String, - namespaceURI: String?, - qualifiedName qName: String? - ) { - let name = XMLName.localName(from: qName ?? elementName) - - switch name { - case "text": - readingLabelText = false - case "navPoint": - treeBuilder.endNode() - default: - break - } - } -} - -private final class NavDocumentParserDelegate: NSObject, XMLParserDelegate { - private let baseURL: URL - private let hrefNormalizer: (String, URL) -> String? - private var insideTOCNav = false - private var listDepth = 0 - private let treeBuilder = TOCTreeBuilder() - private var collectingAnchorText = false - - init(baseURL: URL, hrefNormalizer: @escaping (String, URL) -> String?) { - self.baseURL = baseURL - self.hrefNormalizer = hrefNormalizer - } - - var items: [EPUBTableOfContentsItem] { - treeBuilder.items - } - - func parser( - _ parser: XMLParser, - didStartElement elementName: String, - namespaceURI: String?, - qualifiedName qName: String?, - attributes attributeDict: [String: String] = [:] - ) { - let name = XMLName.localName(from: qName ?? elementName) - - switch name { - case "nav": - insideTOCNav = isTOCNav(attributes: attributeDict) - case "ol": - if insideTOCNav { - listDepth += 1 - } - case "li": - if insideTOCNav, listDepth > 0 { - treeBuilder.beginNode() - } - case "a": - guard insideTOCNav else { - return - } - collectingAnchorText = true - if let href = attributeDict["href"] { - treeBuilder.updateCurrentHref(hrefNormalizer(href, baseURL)) - } - default: - break - } - } - - func parser(_ parser: XMLParser, foundCharacters string: String) { - if collectingAnchorText { - treeBuilder.appendCurrentTitle(string) - } - } - - func parser( - _ parser: XMLParser, - didEndElement elementName: String, - namespaceURI: String?, - qualifiedName qName: String? - ) { - let name = XMLName.localName(from: qName ?? elementName) - - switch name { - case "a": - collectingAnchorText = false - case "li": - guard insideTOCNav else { - break - } - treeBuilder.endNode() - case "ol": - if insideTOCNav { - listDepth = max(0, listDepth - 1) - } - case "nav": - insideTOCNav = false - listDepth = 0 - treeBuilder.reset() - collectingAnchorText = false - default: - break - } - } - - private func isTOCNav(attributes: [String: String]) -> Bool { - if let epubType = attributes["epub:type"]?.lowercased(), epubType.contains("toc") { - return true - } - if let type = attributes["type"]?.lowercased(), type.contains("toc") { - return true - } - if let role = attributes["role"]?.lowercased(), role.contains("doc-toc") { - return true - } - return false - } -} \ No newline at end of file diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBParser.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBParser.swift deleted file mode 100644 index a6c220e..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBParser.swift +++ /dev/null @@ -1,210 +0,0 @@ - -import Foundation - -public final class RDEPUBParser { - - public internal(set) var metadata = RDEPUBMetadata() - - public internal(set) var manifest: [String: RDEPUBManifestItem] = [:] - - public internal(set) var spine: [RDEPUBSpineItem] = [] - - public internal(set) var tableOfContents: [EPUBTableOfContentsItem] = [] - - public internal(set) var extractionRootURL: URL? - - public internal(set) var opfURL: URL? - - public var opfDirectoryURL: URL? { - opfURL?.deletingLastPathComponent() - } - - /// 加密资源数据提供者;nil 时所有资源按明文直读。 - /// 必须在 `parse(epubURL:)` 之前设置(推荐通过 `RDEPUBReaderDependencies.live(resourceDataProvider:)` 注入)。 - public var resourceDataProvider: RDEPUBResourceDataProvider? { - didSet { - guard resourceDataProvider != nil else { return } - RDEPUBResourceAccessRegistry.register(self) - } - } - - /// 仅经 provider 获取解密数据;无 provider 或 provider 判定无需解密时返回 nil - public func providedResourceData(at fileURL: URL) -> Data? { - resourceDataProvider?.resourceData(at: fileURL) - } - - /// 统一资源读取入口:优先 provider 解密,其次磁盘明文 - public func resourceData(at fileURL: URL) -> Data? { - if let provided = providedResourceData(at: fileURL) { - return provided - } - return try? Data(contentsOf: fileURL) - } - - public init() {} - - // MARK: - Cache Management - - /// The base directory used for EPUB extraction caches. - public static var extractionCacheBaseDirectory: URL { - FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first? - .appendingPathComponent("ssreaderview-epub", isDirectory: true) - ?? FileManager.default.temporaryDirectory.appendingPathComponent("ssreaderview-epub", isDirectory: true) - } - - /// Removes all EPUB extraction caches from disk. - /// Call this when the host app wants to free disk space, e.g. on didReceiveMemoryWarning - /// or during cleanup when the reader is no longer needed. - public static func cleanExtractionCache() throws { - let fileManager = FileManager.default - let baseURL = extractionCacheBaseDirectory - guard fileManager.fileExists(atPath: baseURL.path) else { return } - try fileManager.removeItem(at: baseURL) - } - - /// Evicts extraction caches until total disk usage is below the given threshold, - /// removing least-recently-accessed directories first. - /// - Parameter maxBytes: Maximum total bytes for all extraction caches. Defaults to 500 MB. - public static func evictExtractionCache(maxBytes: UInt64 = 500 * 1024 * 1024) throws { - let fileManager = FileManager.default - let baseURL = extractionCacheBaseDirectory - guard fileManager.fileExists(atPath: baseURL.path) else { return } - - let contents = try fileManager.contentsOfDirectory( - at: baseURL, - includingPropertiesForKeys: [.contentAccessDateKey, .isDirectoryKey], - options: .skipsHiddenFiles - ) - - var entries: [(url: URL, accessDate: Date, size: UInt64)] = [] - var totalSize: UInt64 = 0 - - for directoryURL in contents { - guard let resourceValues = try? directoryURL.resourceValues(forKeys: [.isDirectoryKey, .contentAccessDateKey]), - resourceValues.isDirectory == true else { - continue - } - let directorySize = directoryURL.directorySize - let accessDate = resourceValues.contentAccessDate ?? Date.distantPast - entries.append((url: directoryURL, accessDate: accessDate, size: directorySize)) - totalSize += directorySize - } - - guard totalSize > maxBytes else { return } - - entries.sort { $0.accessDate < $1.accessDate } - - for entry in entries { - guard totalSize > maxBytes else { break } - try? fileManager.removeItem(at: entry.url) - totalSize -= entry.size - } - } - - public func makePublication() -> RDEPUBPublication { - RDEPUBPublication(parser: self) - } - - public func parse(epubURL: URL) throws { - reset() - - let extractionURL = try extractArchiveIfNeeded(epubURL: epubURL) - extractionRootURL = extractionURL - - let containerURL = extractionURL.appendingPathComponent("META-INF/container.xml") - guard FileManager.default.fileExists(atPath: containerURL.path) else { - throw RDEPUBParserError.missingContainerXML - } - - let rootFilePath = try parseContainerRootFile(at: containerURL) - let packageURL = extractionURL.appendingPathComponent(rootFilePath) - guard FileManager.default.fileExists(atPath: packageURL.path) else { - throw RDEPUBParserError.invalidRootFilePath(rootFilePath) - } - - try parseOPF(at: packageURL) - - // 兜底:OPF 未声明朝向时,回退读取 iBooks display-options - if metadata.orientation == nil { - let displayOptionsURL = extractionURL - .appendingPathComponent("META-INF/com.apple.ibooks.display-options.xml") - if let orientation = parseIBooksOrientation(at: displayOptionsURL) { - metadata.orientation = orientation - } - } - } - - /// 解析 `com.apple.ibooks.display-options.xml` 中的 orientation-lock。 - /// 结构:platform[@name] / option[@name="orientation-lock"] 文本值。 - private func parseIBooksOrientation(at url: URL) -> RDEPUBOrientation? { - guard FileManager.default.fileExists(atPath: url.path), - let parser = XMLParser(contentsOf: url) else { - return nil - } - let delegate = IBooksDisplayOptionsParserDelegate() - parser.delegate = delegate - guard parser.parse() else { return nil } - switch delegate.orientationLock?.lowercased() { - case "portrait-orientation", "portrait": - return .portrait - case "landscape-orientation", "landscape": - return .landscape - case "none": - return .auto - default: - return nil - } - } - - public func parseOPF(at opfURL: URL) throws { - resetPublicationState() - - guard let parser = XMLParser(contentsOf: opfURL) else { - throw RDEPUBParserError.invalidXML(opfURL) - } - - let delegate = OPFPackageParserDelegate() - parser.shouldProcessNamespaces = false - parser.delegate = delegate - - guard parser.parse() else { - throw parser.parserError ?? RDEPUBParserError.invalidXML(opfURL) - } - - let packageDocument = delegate.packageDocument() - self.opfURL = opfURL - self.metadata = packageDocument.metadata - // 与 packageDocument 使用同一份已去重索引,避免重复 manifest id 再次触发断言。 - self.manifest = packageDocument.manifestByID - self.spine = try buildSpine(from: packageDocument, opfURL: opfURL) - self.tableOfContents = parseTOC(from: packageDocument, opfURL: opfURL) - } - - public func parseTOC() -> [EPUBTableOfContentsItem] { - tableOfContents - } - - public func parseNavDocument(_ navURL: URL, baseURL: URL? = nil) -> [EPUBTableOfContentsItem] { - parseNavDocumentItems(at: navURL, baseURL: baseURL ?? navURL.deletingLastPathComponent()) - } -} - -// MARK: - URL Directory Size Extension - -private extension URL { - var directorySize: UInt64 { - let fileManager = FileManager.default - guard let enumerator = fileManager.enumerator(at: self, includingPropertiesForKeys: [.fileSizeKey], options: [.skipsHiddenFiles], errorHandler: nil) else { - return 0 - } - var totalSize: UInt64 = 0 - for case let fileURL as URL in enumerator { - guard let resourceValues = try? fileURL.resourceValues(forKeys: [.fileSizeKey]), - let fileSize = resourceValues.fileSize else { - continue - } - totalSize += UInt64(fileSize) - } - return totalSize - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBPreferences.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBPreferences.swift deleted file mode 100644 index d0a4c8e..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBPreferences.swift +++ /dev/null @@ -1,107 +0,0 @@ - -import UIKit - -public struct RDEPUBPreferences: Equatable { - - public var fontSize: CGFloat - - public var lineHeightMultiple: CGFloat - - public var reflowableContentInsets: UIEdgeInsets - - public var fixedContentInset: UIEdgeInsets - - public var themeBackgroundColor: String? - - public var themeTextColor: String? - - public var fixedBackgroundColor: String? - - public var fixedLayoutFit: RDEPUBFixedLayoutFit - - public var fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode - - public var numberOfColumns: Int - - public var columnGap: CGFloat - - public init( - fontSize: CGFloat, - lineHeightMultiple: CGFloat, - reflowableContentInsets: UIEdgeInsets, - fixedContentInset: UIEdgeInsets, - numberOfColumns: Int = 1, - columnGap: CGFloat = 20, - themeBackgroundColor: String? = nil, - themeTextColor: String? = nil, - fixedBackgroundColor: String? = nil, - fixedLayoutFit: RDEPUBFixedLayoutFit = .page, - fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode = .automatic - ) { - self.fontSize = fontSize - self.lineHeightMultiple = lineHeightMultiple - self.reflowableContentInsets = reflowableContentInsets - self.fixedContentInset = fixedContentInset - self.numberOfColumns = max(1, numberOfColumns) - self.columnGap = max(0, columnGap) - self.themeBackgroundColor = themeBackgroundColor - self.themeTextColor = themeTextColor - self.fixedBackgroundColor = fixedBackgroundColor - self.fixedLayoutFit = fixedLayoutFit - self.fixedLayoutSpreadMode = fixedLayoutSpreadMode - } - - public func presentationStyle(viewportSize: CGSize) -> RDEPUBPresentationStyle { - RDEPUBPresentationStyle( - viewportSize: viewportSize, - contentInsets: reflowableContentInsets, - fontSize: fontSize, - lineHeightMultiple: lineHeightMultiple, - numberOfColumns: numberOfColumns, - columnGap: columnGap, - themeBackgroundColor: themeBackgroundColor, - themeTextColor: themeTextColor - ) - } - - public func renderRequest( - for page: EPUBPage, - publication: RDEPUBPublication, - viewportSize: CGSize, - targetLocation: RDEPUBLocation? = nil, - targetHighlightRangeInfo: String? = nil, - highlights: [RDEPUBHighlight] = [], - searchPresentation: RDEPUBSearchPresentation? = nil - ) -> RDEPUBRenderRequest? { - if publication.layout == .fixed, let fixedSpread = page.fixedSpread { - return .fixed( - RDEPUBFixedRenderRequest( - spread: fixedSpread, - viewportSize: viewportSize, - contentInset: fixedContentInset, - backgroundColorCSS: fixedBackgroundColor, - fit: fixedLayoutFit, - searchPresentation: searchPresentation - ) - ) - } - - guard publication.spine.indices.contains(page.spineIndex) else { - return nil - } - - return .reflowable( - RDEPUBReflowableRenderRequest( - spineIndex: page.spineIndex, - href: publication.spine[page.spineIndex].href, - pageIndex: page.pageIndexInChapter, - totalPagesInChapter: page.totalPagesInChapter, - presentation: presentationStyle(viewportSize: viewportSize), - targetLocation: targetLocation, - targetHighlightRangeInfo: targetHighlightRangeInfo, - highlights: highlights, - searchPresentation: searchPresentation - ) - ) - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBPublication.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBPublication.swift deleted file mode 100644 index 4577176..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBPublication.swift +++ /dev/null @@ -1,71 +0,0 @@ - -import Foundation - -public final class RDEPUBPublication { - - public let parser: RDEPUBParser - - public let resourceResolver: RDEPUBResourceResolver - - public init(parser: RDEPUBParser) { - self.parser = parser - self.resourceResolver = RDEPUBResourceResolver(parser: parser) - } - - public var metadata: RDEPUBMetadata { - parser.metadata - } - - public var manifest: [String: RDEPUBManifestItem] { - parser.manifest - } - - public var spine: [RDEPUBSpineItem] { - parser.spine - } - - public var tableOfContents: [EPUBTableOfContentsItem] { - parser.tableOfContents - } - - public var layout: RDEPUBLayout { - metadata.layout - } - - public var readingProfile: RDEPUBReadingProfile { - parser.readingProfile() - } - - public var readingProgression: RDEPUBReadingProgression { - metadata.readingProgression - } - - public var bookIdentifier: String? { - metadata.identifier - } - - public func fixedLayoutSpreadEnabled(for preferences: RDEPUBPreferences, viewportSize: CGSize) -> Bool { - guard layout == .fixed else { - return false - } - guard metadata.spread?.lowercased() != "none" else { - return false - } - - switch preferences.fixedLayoutSpreadMode { - case .never: - return false - case .always: - return spine.count > 1 - case .automatic: - return viewportSize.width > viewportSize.height - } - } - - public func makeFixedSpreads(preferences: RDEPUBPreferences, viewportSize: CGSize) -> [EPUBFixedSpread] { - EPUBFixedSpread.makeSpreads( - spine: spine, - spreadEnabled: fixedLayoutSpreadEnabled(for: preferences, viewportSize: viewportSize) - ) - } -} \ No newline at end of file diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBReadingSession.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBReadingSession.swift deleted file mode 100644 index ebd52ac..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBReadingSession.swift +++ /dev/null @@ -1,322 +0,0 @@ - -import Foundation - -public final class RDEPUBReadingSession { - - public typealias PaginationSnapshot = (pages: [EPUBPage], chapters: [EPUBChapterInfo]) - - public let publication: RDEPUBPublication - - public private(set) var activePages: [EPUBPage] = [] - - public private(set) var activeChapters: [EPUBChapterInfo] = [] - - public private(set) var pendingNavigationLocation: RDEPUBLocation? - - public private(set) var pendingNavigationPageNum: Int? - - public private(set) var pendingNavigationHighlightRangeInfo: String? - - public private(set) var currentViewport: RDEPUBViewport? - - public private(set) var currentReadingContext: RDEPUBReadingContext? - - public init(publication: RDEPUBPublication) { - self.publication = publication - } - - public var resourceResolver: RDEPUBResourceResolver { - publication.resourceResolver - } - - public func resetRuntimeState() { - activePages = [] - activeChapters = [] - clearPendingNavigation() - currentViewport = nil - currentReadingContext = nil - } - - public func setActiveSnapshot(_ snapshot: PaginationSnapshot) { - activePages = snapshot.pages - activeChapters = snapshot.chapters - } - - public func clearPendingNavigation() { - pendingNavigationLocation = nil - pendingNavigationPageNum = nil - pendingNavigationHighlightRangeInfo = nil - } - - public func pendingLocation(forPageNumber pageNumber: Int, spineIndex: Int?) -> RDEPUBLocation? { - guard pendingNavigationPageNum == pageNumber, - let pendingNavigationLocation else { - return nil - } - guard let spineIndex else { - return pendingNavigationLocation - } - let pageHref = resourceResolver.href(forSpineIndex: spineIndex) - guard resourceResolver.normalizedHref(pageHref ?? "") == resourceResolver.normalizedHref(pendingNavigationLocation.href) else { - return nil - } - return pendingNavigationLocation - } - - public func pendingHighlightRangeInfo(forPageNumber pageNumber: Int, spineIndex: Int?) -> String? { - guard pendingNavigationPageNum == pageNumber, - let pendingNavigationHighlightRangeInfo, - !pendingNavigationHighlightRangeInfo.isEmpty else { - return nil - } - guard let pendingNavigationLocation else { - return pendingNavigationHighlightRangeInfo - } - guard let spineIndex else { - return pendingNavigationHighlightRangeInfo - } - let pageHref = resourceResolver.href(forSpineIndex: spineIndex) - guard resourceResolver.normalizedHref(pageHref ?? "") == resourceResolver.normalizedHref(pendingNavigationLocation.href) else { - return nil - } - return pendingNavigationHighlightRangeInfo - } - - public func pageContains(spineIndex: Int, in page: EPUBPage) -> Bool { - if let fixedSpread = page.fixedSpread { - return fixedSpread.resources.contains(where: { $0.spineIndex == spineIndex }) - } - return page.spineIndex == spineIndex - } - - public func fallbackLocation(for page: EPUBPage, bookIdentifier: String?) -> RDEPUBLocation? { - if let fixedSpread = page.fixedSpread { - return RDEPUBLocation( - bookIdentifier: bookIdentifier, - href: fixedSpread.primaryResource.href, - progression: 0, - lastProgression: 1 - ) - } - - guard let href = resourceResolver.href(forSpineIndex: page.spineIndex) else { - return nil - } - - let progression: Double - if page.totalPagesInChapter <= 1 { - progression = 0 - } else { - progression = Double(page.pageIndexInChapter) / Double(page.totalPagesInChapter - 1) - } - - return RDEPUBLocation( - bookIdentifier: bookIdentifier, - href: href, - progression: progression, - lastProgression: progression - ) - } - - public func currentVisibleLocation(currentPageNumber: Int, bookIdentifier: String?) -> RDEPUBLocation? { - guard currentPageNumber > 0, - activePages.indices.contains(currentPageNumber - 1) else { - return nil - } - return fallbackLocation(for: activePages[currentPageNumber - 1], bookIdentifier: bookIdentifier) - } - - public func initialSpineIndex(for location: RDEPUBLocation?) -> Int { - guard let location, - let normalizedHref = resourceResolver.normalizedHref(location.href), - let spineIndex = publication.spine.firstIndex(where: { resourceResolver.normalizedHref($0.href) == normalizedHref }) else { - return 0 - } - return spineIndex - } - - public func pageIndex(for location: RDEPUBLocation, bookIdentifier: String?) -> Int? { - guard location.bookIdentifier == nil || location.bookIdentifier == bookIdentifier else { - return nil - } - guard let targetHref = resourceResolver.normalizedHref(location.href) else { - return nil - } - - if publication.layout == .fixed { - return activePages.firstIndex(where: { page in - if let fixedSpread = page.fixedSpread { - return fixedSpread.contains(normalizedHref: targetHref, normalizer: { self.resourceResolver.normalizedHref($0) }) - } - guard let href = self.resourceResolver.href(forSpineIndex: page.spineIndex) else { - return false - } - return self.resourceResolver.normalizedHref(href) == targetHref - }) - } - - guard let spineIndex = publication.spine.firstIndex(where: { resourceResolver.normalizedHref($0.href) == targetHref }) else { - return nil - } - - let candidates = activePages.enumerated().filter { $0.element.spineIndex == spineIndex } - guard !candidates.isEmpty else { - return nil - } - if candidates.count == 1 { - return candidates[0].offset - } - - let navigationProgression = location.navigationProgression - let localIndex = min( - candidates.count - 1, - max(0, Int(round(navigationProgression * Double(candidates.count - 1)))) - ) - return candidates[localIndex].offset - } - - public func queueNavigation( - to location: RDEPUBLocation, - relativeToSpineIndex spineIndex: Int? = nil, - bookIdentifier: String?, - targetHighlightRangeInfo: String? = nil - ) -> Int? { - guard let normalizedLocation = resourceResolver.normalizedLocation( - location, - relativeToSpineIndex: spineIndex, - bookIdentifier: bookIdentifier - ), let pageIndex = pageIndex(for: normalizedLocation, bookIdentifier: bookIdentifier) else { - return nil - } - - let hasTargetHighlightRangeInfo = targetHighlightRangeInfo?.isEmpty == false - let shouldKeepPendingNavigation = - normalizedLocation.fragment != nil || - normalizedLocation.rangeAnchor != nil || - hasTargetHighlightRangeInfo - pendingNavigationLocation = shouldKeepPendingNavigation ? normalizedLocation : nil - pendingNavigationPageNum = shouldKeepPendingNavigation ? pageIndex + 1 : nil - pendingNavigationHighlightRangeInfo = hasTargetHighlightRangeInfo ? targetHighlightRangeInfo : nil - return pageIndex + 1 - } - - public func updateReadingContext( - pageNumber: Int, - location: RDEPUBLocation, - spineIndex: Int, - chapterIndex: Int?, - bookIdentifier: String? - ) { - let normalizedLocation = resourceResolver.normalizedLocation( - location, - relativeToSpineIndex: spineIndex, - bookIdentifier: bookIdentifier - ) ?? RDEPUBLocation( - bookIdentifier: bookIdentifier, - href: location.href, - progression: location.progression, - lastProgression: location.lastProgression, - fragment: location.fragment, - rangeAnchor: location.rangeAnchor, - cfi: location.cfi, - lastCFI: location.lastCFI, - rangeCFI: location.rangeCFI - ) - - let resource = RDEPUBViewportResource( - href: normalizedLocation.href, - spineIndex: spineIndex, - progression: normalizedLocation.progression, - lastProgression: normalizedLocation.lastProgression, - fragment: normalizedLocation.fragment - ) - let viewport = RDEPUBViewport( - resources: [resource], - visiblePageNumber: pageNumber, - chapterIndex: chapterIndex, - isFixedLayout: publication.layout == .fixed - ) - currentViewport = viewport - currentReadingContext = RDEPUBReadingContext( - location: normalizedLocation, - viewport: viewport, - pageNumber: pageNumber, - chapterIndex: chapterIndex - ) - - if pendingNavigationPageNum == pageNumber { - clearPendingNavigation() - } - } - - public func currentReadingLocation(bookIdentifier: String?) -> RDEPUBLocation? { - currentReadingContext?.location ?? currentVisibleLocation(currentPageNumber: currentViewport?.visiblePageNumber ?? 0, bookIdentifier: bookIdentifier) - } - - public func makePaginationSnapshot( - pageCounts: [Int], - preferences: RDEPUBPreferences, - layoutContext: RDEPUBNavigatorLayoutContext - ) -> PaginationSnapshot { - if publication.layout == .fixed { - let spreads = publication.makeFixedSpreads( - preferences: preferences, - viewportSize: layoutContext.viewportSize - ) - - let chapters = spreads.enumerated().map { spreadIndex, spread in - EPUBChapterInfo( - spineIndex: spread.primaryResource.spineIndex, - title: spread.primaryResource.title, - pageCount: 1 - ) - } - - let pages = spreads.enumerated().map { spreadIndex, spread in - EPUBPage( - spineIndex: spread.primaryResource.spineIndex, - chapterIndex: spreadIndex, - pageIndexInChapter: 0, - totalPagesInChapter: 1, - chapterTitle: spread.primaryResource.title, - fixedSpread: spread - ) - } - - return (pages, chapters) - } - - var pages: [EPUBPage] = [] - var chapters: [EPUBChapterInfo] = [] - - for (spineIndex, pageCount) in pageCounts.enumerated() { - guard pageCount > 0, spineIndex < publication.spine.count else { continue } - - let chapterIndex = chapters.count - let title = publication.spine[spineIndex].title - - chapters.append( - EPUBChapterInfo( - spineIndex: spineIndex, - title: title, - pageCount: pageCount - ) - ) - - for pageIndex in 0.. Data? -} - -/// 加密资源访问登记表。 -/// -/// DTCoreText 在解析 `` 时由自己内部加载图片文件,拿不到 parser 实例, -/// 因此这里以弱引用登记所有启用了 provider 的 parser, -/// 供 `RDEPUBDecryptingImageAttachment` 按文件路径反查所属书籍的 provider。 -enum RDEPUBResourceAccessRegistry { - - private struct WeakParserBox { - weak var parser: RDEPUBParser? - } - - private static let lock = NSLock() - - private static var boxes: [WeakParserBox] = [] - - /// 登记启用了 provider 的 parser(重复登记会被去重,弱引用失效项顺带清理) - static func register(_ parser: RDEPUBParser) { - lock.lock() - defer { lock.unlock() } - boxes.removeAll { $0.parser == nil || $0.parser === parser } - boxes.append(WeakParserBox(parser: parser)) -#if canImport(DTCoreText) - RDEPUBDecryptingImageAttachment.registerTagClassIfNeeded() -#endif - } - - /// 按文件路径反查 provider 并返回解密数据; - /// 仅当文件位于某个已登记 parser 的解压目录内才会命中 - static func providedResourceData(at fileURL: URL) -> Data? { - let targetPath = fileURL.resolvingSymlinksInPath().standardizedFileURL.path - lock.lock() - let parsers = boxes.compactMap(\.parser) - lock.unlock() - for parser in parsers { - guard let rootURL = parser.extractionRootURL else { continue } - let rootPath = rootURL.resolvingSymlinksInPath().standardizedFileURL.path - guard targetPath.hasPrefix(rootPath + "/") else { continue } - return parser.providedResourceData(at: fileURL) - } - return nil - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBResourceResolver.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBResourceResolver.swift deleted file mode 100644 index 6caae2a..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBResourceResolver.swift +++ /dev/null @@ -1,175 +0,0 @@ - -import Foundation - -public final class RDEPUBResourceResolver { - - private let parser: RDEPUBParser - - public init(parser: RDEPUBParser) { - self.parser = parser - } - - public var opfDirectoryURL: URL? { - parser.opfDirectoryURL - } - - public func fileURL(forRelativePath relativePath: String) -> URL? { - parser.fileURL(forRelativePath: relativePath) - } - - public func resourceURL(forRelativePath relativePath: String) -> URL? { - parser.resourceURL(forRelativePath: relativePath) - } - - public func fileURL(forResourceURL resourceURL: URL) -> URL? { - parser.fileURL(forResourceURL: resourceURL) - } - - /// 是否配置了加密资源数据提供者 - public var hasResourceDataProvider: Bool { - parser.resourceDataProvider != nil - } - - /// 仅经 provider 获取解密数据;无 provider 或 provider 判定无需解密时返回 nil - public func providedResourceData(at fileURL: URL) -> Data? { - parser.providedResourceData(at: fileURL) - } - - /// 统一资源读取入口:优先 provider 解密,其次磁盘明文 - public func resourceData(at fileURL: URL) -> Data? { - parser.resourceData(at: fileURL) - } - - public func normalizedHref(_ href: String, relativeToSpineIndex spineIndex: Int? = nil) -> String? { - guard let opfDirectoryURL else { - return href.components(separatedBy: "#").first - } - - let pathPart = href.components(separatedBy: "#").first ?? href - if pathPart.isEmpty { - guard let spineIndex, parser.spine.indices.contains(spineIndex) else { - return nil - } - return parser.spine[spineIndex].href.components(separatedBy: "#").first - } - - let baseURL: URL - if let spineIndex, parser.spine.indices.contains(spineIndex) { - baseURL = opfDirectoryURL - .appendingPathComponent(parser.spine[spineIndex].href) - .deletingLastPathComponent() - } else { - baseURL = opfDirectoryURL - } - - guard let resolvedURL = URL(string: pathPart, relativeTo: baseURL)?.standardizedFileURL else { - return pathPart - } - - let opfPath = opfDirectoryURL.standardizedFileURL.path + "/" - let resolvedPath = resolvedURL.path - if resolvedPath.hasPrefix(opfPath) { - return String(resolvedPath.dropFirst(opfPath.count)) - } - return pathPart - } - - public func normalizedHref(_ href: String, relativeToHref baseHref: String) -> String? { - guard let opfDirectoryURL else { - return href.components(separatedBy: "#").first - } - - let pathPart = href.components(separatedBy: "#").first ?? href - let basePath = baseHref.components(separatedBy: "#").first ?? baseHref - let baseURL = opfDirectoryURL - .appendingPathComponent(basePath) - .deletingLastPathComponent() - - guard let resolvedURL = URL(string: pathPart, relativeTo: baseURL)?.standardizedFileURL else { - return pathPart - } - - let opfPath = opfDirectoryURL.standardizedFileURL.path + "/" - if resolvedURL.path.hasPrefix(opfPath) { - return String(resolvedURL.path.dropFirst(opfPath.count)) - } - return pathPart - } - - public func fileURL(forReference href: String, relativeToHref baseHref: String) -> URL? { - guard let normalizedHref = normalizedHref(href, relativeToHref: baseHref) else { - return nil - } - return fileURL(forRelativePath: normalizedHref) - } - - public func normalizedLocation( - _ location: RDEPUBLocation, - relativeToSpineIndex spineIndex: Int? = nil, - bookIdentifier: String? - ) -> RDEPUBLocation? { - let rawHref = location.href - let hrefParts = rawHref.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false) - let pathPart = hrefParts.first.map(String.init) ?? rawHref - let fragment = location.fragment ?? (hrefParts.count > 1 ? String(hrefParts[1]) : nil) - - let normalizedTargetHref: String - if pathPart.isEmpty { - guard let spineIndex, parser.spine.indices.contains(spineIndex) else { - return nil - } - normalizedTargetHref = parser.spine[spineIndex].href.components(separatedBy: "#").first ?? parser.spine[spineIndex].href - } else { - guard let href = normalizedHref(pathPart, relativeToSpineIndex: spineIndex) else { - return nil - } - normalizedTargetHref = href - } - - return RDEPUBLocation( - bookIdentifier: bookIdentifier, - href: normalizedTargetHref, - progression: location.progression, - lastProgression: location.lastProgression, - fragment: fragment, - rangeAnchor: location.rangeAnchor, - cfi: location.cfi, - lastCFI: location.lastCFI, - rangeCFI: location.rangeCFI - ) - } - - public func spineIndex(forNormalizedHref normalizedHref: String) -> Int? { - parser.spine.firstIndex { item in - self.normalizedHref(item.href) == normalizedHref - } - } - - public func spineIndex(for location: RDEPUBLocation) -> Int? { - guard let normalizedHref = normalizedHref(location.href) else { - return nil - } - return spineIndex(forNormalizedHref: normalizedHref) - } - - public func href(forSpineIndex spineIndex: Int) -> String? { - guard parser.spine.indices.contains(spineIndex) else { - return nil - } - return parser.spine[spineIndex].href - } - - public func title(forSpineIndex spineIndex: Int) -> String? { - guard parser.spine.indices.contains(spineIndex) else { - return nil - } - return parser.spine[spineIndex].title - } - - public func manifestItem(forHref href: String) -> RDEPUBManifestItem? { - let targetHref = normalizedHref(href) ?? href.components(separatedBy: "#").first ?? href - return parser.manifest.values.first { - normalizedHref($0.href) == targetHref - } - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBResourceURLSchemeHandler.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBResourceURLSchemeHandler.swift deleted file mode 100644 index 7f6163e..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBResourceURLSchemeHandler.swift +++ /dev/null @@ -1,349 +0,0 @@ -import Foundation -import WebKit - -public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler { - struct DebugMetrics { - let streamedResponses: Int - let inMemoryResponses: Int - let failures: Int - } - - public static let scheme = "ss-reader" - - public static let host = "book" - - private weak var parser: RDEPUBParser? - - private let fileManager = FileManager.default - - private let syncQueue = DispatchQueue(label: "com.ssreaderview.epub.scheme-handler") - - private let ioQueue = DispatchQueue(label: "com.ssreaderview.epub.scheme-handler.io", qos: .utility) - - private var activeTasks: [ObjectIdentifier: Bool] = [:] - private static let debugMetricsQueue = DispatchQueue(label: "com.ssreaderview.epub.scheme-handler.metrics") - private static var streamedResponseCount = 0 - private static var inMemoryResponseCount = 0 - private static var failureCount = 0 - - public init(parser: RDEPUBParser) { - self.parser = parser - super.init() - } - - static func resetDebugMetrics() { - debugMetricsQueue.sync { - streamedResponseCount = 0 - inMemoryResponseCount = 0 - failureCount = 0 - } - } - - static func debugMetricsSnapshot() -> DebugMetrics { - debugMetricsQueue.sync { - DebugMetrics( - streamedResponses: streamedResponseCount, - inMemoryResponses: inMemoryResponseCount, - failures: failureCount - ) - } - } - - public func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) { - let taskID = ObjectIdentifier(urlSchemeTask as AnyObject) - syncQueue.sync { - activeTasks[taskID] = true - } - - let requestURL = urlSchemeTask.request.url - RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, event: "start") - - guard let parser, - let requestURL, - let fileURL = parser.fileURL(forResourceURL: requestURL), - fileManager.fileExists(atPath: fileURL.path) else { - if let requestURL, Self.isOptionalResource(requestURL) { - RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, event: "missing-optional-resource") - let response = URLResponse( - url: requestURL, - mimeType: Self.mimeType(for: requestURL.pathExtension), - expectedContentLength: 0, - textEncodingName: Self.textEncodingName(for: requestURL.pathExtension) - ) - // H-08 fix: These calls happen synchronously in webView(_:start:) which - // runs on the same queue WebKit calls start on, so no thread violation here. - urlSchemeTask.didReceive(response) - urlSchemeTask.didReceive(Data()) - urlSchemeTask.didFinish() - } else { - RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, event: "missing-file") - Self.recordFailure() - urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorFileDoesNotExist)) - } - clearTask(taskID) - return - } - - RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "resolved") - - let fileSize = resourceSize(for: fileURL) - // 配置了加密资源 provider 时不能走流式分支:密文必须整体解密后再响应, - // 统一改走内存分支(内部会先询问 provider,明文文件仍按磁盘直读) - if let fileSize, fileSize > 524_288, parser.resourceDataProvider == nil { - respondWithStreaming(fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask, fileSize: fileSize) - } else { - respondWithInMemoryData(fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask) - } - } - - public func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask) { - RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: urlSchemeTask.request.url, event: "stop") - clearTask(ObjectIdentifier(urlSchemeTask as AnyObject)) - } - - private func clearTask(_ id: ObjectIdentifier) { - syncQueue.sync { - activeTasks[id] = nil - } - } - - private func isTaskActive(_ id: ObjectIdentifier) -> Bool { - syncQueue.sync { - activeTasks[id] == true - } - } - - private func resourceSize(for url: URL) -> UInt64? { - guard let attrs = try? fileManager.attributesOfItem(atPath: url.path), - let size = attrs[.size] as? UInt64 else { - return nil - } - return size - } - - /// H-08 fix: Dispatch all WKURLSchemeTask completion callbacks to the main queue, - /// ensuring they run on the same serial queue that WebKit calls start() on. - /// Also: cancelled tasks now call didFailWithError(NSURLErrorCancelled) instead - /// of silently returning, satisfying the protocol requirement that every started - /// task must receive a completion callback. - private func respondWithInMemoryData(fileURL: URL, requestURL: URL, taskID: ObjectIdentifier, urlSchemeTask: any WKURLSchemeTask) { - ioQueue.async { [weak self] in - guard let self else { - DispatchQueue.main.async { - urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled)) - } - return - } - do { - // 优先向加密资源 provider 索取解密数据,provider 放行的明文文件仍直读磁盘 - let data: Data - if let provided = self.parser?.providedResourceData(at: fileURL) { - data = provided - } else { - data = try Data(contentsOf: fileURL) - } - guard self.isTaskActive(taskID) else { - DispatchQueue.main.async { - urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled)) - } - return - } - let response = URLResponse( - url: requestURL, - mimeType: Self.mimeType(for: fileURL.pathExtension), - expectedContentLength: data.count, - textEncodingName: Self.textEncodingName(for: fileURL.pathExtension) - ) - self.dispatchTaskSuccessCallback( - taskID: taskID, - urlSchemeTask: urlSchemeTask - ) { - urlSchemeTask.didReceive(response) - urlSchemeTask.didReceive(data) - urlSchemeTask.didFinish() - } - Self.recordInMemoryResponse() - RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "finished") - return - } catch { - guard self.isTaskActive(taskID) else { - DispatchQueue.main.async { - urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled)) - } - return - } - Self.recordFailure() - RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "failed", error: error) - DispatchQueue.main.async { - urlSchemeTask.didFailWithError(error) - } - } - self.clearTask(taskID) - } - } - - private func respondWithStreaming(fileURL: URL, requestURL: URL, taskID: ObjectIdentifier, urlSchemeTask: any WKURLSchemeTask, fileSize: UInt64) { - ioQueue.async { [weak self] in - guard let self else { - DispatchQueue.main.async { - urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled)) - } - return - } - let response = URLResponse( - url: requestURL, - mimeType: Self.mimeType(for: fileURL.pathExtension), - expectedContentLength: Int(fileSize), - textEncodingName: Self.textEncodingName(for: fileURL.pathExtension) - ) - self.dispatchTaskCallbackIfActive(taskID: taskID) { - urlSchemeTask.didReceive(response) - } - - guard let fileHandle = try? FileHandle(forReadingFrom: fileURL) else { - guard self.isTaskActive(taskID) else { - DispatchQueue.main.async { - urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled)) - } - return - } - Self.recordFailure() - DispatchQueue.main.async { - urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorFileDoesNotExist)) - } - self.clearTask(taskID) - return - } - - let chunkSize = 65_536 - defer { - fileHandle.closeFile() - } - while true { - guard self.isTaskActive(taskID) else { - // H-08 fix: Task was cancelled. Send didFailWithError on main queue. - DispatchQueue.main.async { - urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled)) - } - self.clearTask(taskID) - return - } - let data = fileHandle.readData(ofLength: chunkSize) - if data.isEmpty { break } - self.dispatchTaskCallbackIfActive(taskID: taskID) { - urlSchemeTask.didReceive(data) - } - } - self.dispatchTaskSuccessCallback( - taskID: taskID, - urlSchemeTask: urlSchemeTask - ) { - urlSchemeTask.didFinish() - } - Self.recordStreamedResponse() - RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "finished-streaming") - } - } - - private func dispatchTaskCallbackIfActive(taskID: ObjectIdentifier, _ callback: @escaping () -> Void) { - DispatchQueue.main.async { [weak self] in - guard let self, self.isTaskActive(taskID) else { return } - callback() - } - } - - private func dispatchTaskSuccessCallback( - taskID: ObjectIdentifier, - urlSchemeTask: any WKURLSchemeTask, - _ callback: @escaping () -> Void - ) { - DispatchQueue.main.async { [weak self] in - guard let self else { - urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled)) - return - } - guard self.isTaskActive(taskID) else { - urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled)) - self.clearTask(taskID) - return - } - callback() - self.clearTask(taskID) - } - } - - private static func recordStreamedResponse() { - debugMetricsQueue.sync { - streamedResponseCount += 1 - } - } - - private static func recordInMemoryResponse() { - debugMetricsQueue.sync { - inMemoryResponseCount += 1 - } - } - - private static func recordFailure() { - debugMetricsQueue.sync { - failureCount += 1 - } - } - - private static func mimeType(for pathExtension: String) -> String { - switch pathExtension.lowercased() { - case "html", "htm": - return "text/html" - case "xhtml": - return "application/xhtml+xml" - case "css": - return "text/css" - case "js": - return "application/javascript" - case "xml", "opf", "ncx": - return "application/xml" - case "svg": - return "image/svg+xml" - case "jpg", "jpeg": - return "image/jpeg" - case "png": - return "image/png" - case "gif": - return "image/gif" - case "webp": - return "image/webp" - case "ttf": - return "font/ttf" - case "otf": - return "font/otf" - case "woff": - return "font/woff" - case "woff2": - return "font/woff2" - case "mp3": - return "audio/mpeg" - case "mp4": - return "video/mp4" - default: - return "application/octet-stream" - } - } - - private static func textEncodingName(for pathExtension: String) -> String? { - switch pathExtension.lowercased() { - case "html", "htm", "xhtml", "css", "js", "xml", "opf", "ncx", "txt": - return "utf-8" - default: - return nil - } - } - - private static func isOptionalResource(_ url: URL) -> Bool { - switch url.pathExtension.lowercased() { - case "ttf", "otf", "woff", "woff2", "css", "js", "jpg", "jpeg", "png", "gif", "webp", "svg": - return true - default: - return false - } - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBSafeArea.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBSafeArea.swift deleted file mode 100644 index 1a20c05..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBSafeArea.swift +++ /dev/null @@ -1,67 +0,0 @@ -import UIKit - -/// Device-level safe area lookup modeled after GKNavigationBarSwift. -/// Reads the real safe area from the key window so correct values are -/// available even before a view has been laid out in the hierarchy. -/// Must be called on the main thread. -public enum RDEPUBSafeArea { - - /// Minimum text margins applied when the device safe area on an edge is - /// smaller (e.g. no notch / no home indicator). These are aesthetic - /// paddings, not approximations of the safe area itself. - public static let minimumVerticalTextMargin: CGFloat = 20 - - public static let minimumHorizontalTextMargin: CGFloat = 16 - - public static func keyWindow() -> UIWindow? { - let scenes = UIApplication.shared.connectedScenes - .compactMap { $0 as? UIWindowScene } - if let window = scenes - .filter({ $0.activationState == .foregroundActive }) - .flatMap({ $0.windows }) - .first(where: { $0.isKeyWindow }) { - return window - } - if let window = scenes - .flatMap({ $0.windows }) - .first(where: { $0.isKeyWindow }) { - return window - } - return UIApplication.shared.delegate?.window ?? nil - } - - public static func insets() -> UIEdgeInsets { - if let window = keyWindow() { - return window.safeAreaInsets - } - // No key window yet (early launch): create a detached window to read - // the device safe area, same fallback as GKNavigationBarSwift. - let window = UIWindow(frame: UIScreen.main.bounds) - if window.safeAreaInsets.bottom <= 0 { - window.rootViewController = UIViewController() - } - return window.safeAreaInsets - } - - /// Prefers insets measured from a view already installed in the hierarchy; - /// falls back to the key-window insets when the view is not laid out yet - /// and reports .zero. - public static func resolve(_ measuredInsets: UIEdgeInsets?) -> UIEdgeInsets { - if let measuredInsets, measuredInsets != .zero { - return measuredInsets - } - return insets() - } - - /// Default reflowable content insets derived from the live device safe - /// area instead of hard-coded heights. - public static func defaultReflowableContentInsets() -> UIEdgeInsets { - let safe = insets() - return UIEdgeInsets( - top: max(safe.top, minimumVerticalTextMargin), - left: max(safe.left, minimumHorizontalTextMargin), - bottom: max(safe.bottom, minimumVerticalTextMargin), - right: max(safe.right, minimumHorizontalTextMargin) - ) - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBSearchEngine.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBSearchEngine.swift deleted file mode 100644 index 3240ca8..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBSearchEngine.swift +++ /dev/null @@ -1,120 +0,0 @@ - -import Foundation -import UIKit - -protocol RDEPUBSearchEngine { - - func search(keyword: String) -> [RDEPUBSearchMatch] -} - -final class RDEPUBHTMLSearchEngine: RDEPUBSearchEngine { - - private let parser: RDEPUBParser - - private let publication: RDEPUBPublication - - init(parser: RDEPUBParser, publication: RDEPUBPublication) { - self.parser = parser - self.publication = publication - } - - func search(keyword: String) -> [RDEPUBSearchMatch] { - let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalizedKeyword.isEmpty else { - return [] - } - - var searchMatches: [RDEPUBSearchMatch] = [] - for item in publication.spine where item.linear { - guard item.mediaType.contains("html") || item.mediaType.contains("xhtml"), - let html = parser.htmlString(forRelativePath: item.href) else { - continue - } - - let plainText = plainText(fromHTML: html, baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent()) - let normalizedHref = publication.resourceResolver.normalizedHref(item.href) ?? item.href - searchMatches.append(contentsOf: matches(in: plainText, href: normalizedHref, keyword: normalizedKeyword)) - } - - return searchMatches - } - - private func plainText(fromHTML html: String, baseURL: URL?) -> String { - guard let data = html.data(using: .utf8) else { - return fallbackPlainText(fromHTML: html) - } - - var options: [NSAttributedString.DocumentReadingOptionKey: Any] = [ - .documentType: NSAttributedString.DocumentType.html, - .characterEncoding: String.Encoding.utf8.rawValue - ] - if let baseURL { - options[NSAttributedString.DocumentReadingOptionKey(rawValue: "NSBaseURLDocumentOption")] = baseURL - } - - if let attributed = try? NSAttributedString(data: data, options: options, documentAttributes: nil) { - return attributed.string - } - return fallbackPlainText(fromHTML: html) - } - - private func fallbackPlainText(fromHTML html: String) -> String { - let stripped = html.replacingOccurrences(of: "<[^>]+>", with: " ", options: .regularExpression) - return stripped - .replacingOccurrences(of: " ", with: " ") - .replacingOccurrences(of: "&", with: "&") - .replacingOccurrences(of: "<", with: "<") - .replacingOccurrences(of: ">", with: ">") - .replacingOccurrences(of: "'", with: "'") - .replacingOccurrences(of: """, with: "\"") - } - - private func matches(in text: String, href: String, keyword: String) -> [RDEPUBSearchMatch] { - let source = text as NSString - let fullLength = source.length - guard fullLength > 0 else { - return [] - } - - var results: [RDEPUBSearchMatch] = [] - var localMatchIndex = 0 - var searchRange = NSRange(location: 0, length: fullLength) - - while searchRange.length > 0 { - let foundRange = source.range(of: keyword, options: [.caseInsensitive], range: searchRange) - guard foundRange.location != NSNotFound else { - break - } - - let progressionDenominator = max(fullLength - 1, 1) - let progression = Double(foundRange.location) / Double(progressionDenominator) - results.append( - RDEPUBSearchMatch( - href: href, - progression: progression, - previewText: previewText(in: source, matchRange: foundRange), - localMatchIndex: localMatchIndex, - rangeLocation: foundRange.location, - rangeLength: foundRange.length - ) - ) - - localMatchIndex += 1 - let nextLocation = foundRange.location + max(foundRange.length, 1) - if nextLocation >= fullLength { - break - } - searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation) - } - - return results - } - - private func previewText(in text: NSString, matchRange: NSRange) -> String { - let previewRadius = 12 - let start = max(matchRange.location - previewRadius, 0) - let end = min(matchRange.location + matchRange.length + previewRadius, text.length) - let range = NSRange(location: start, length: max(end - start, 0)) - return text.substring(with: range).trimmingCharacters(in: .whitespacesAndNewlines) - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBSearchModels.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBSearchModels.swift deleted file mode 100644 index c1838c0..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBSearchModels.swift +++ /dev/null @@ -1,127 +0,0 @@ - -import Foundation - -public struct RDEPUBSearchMatch: Codable, Equatable { - - public var href: String - - public var progression: Double - - public var previewText: String - - public var localMatchIndex: Int - - public var rangeLocation: Int? - - public var rangeLength: Int - - public var rangeAnchor: RDEPUBTextRangeAnchor? - - public var cfi: String? - - public var rangeCFI: String? - - public init( - href: String, - progression: Double, - previewText: String, - localMatchIndex: Int, - rangeLocation: Int? = nil, - rangeLength: Int, - rangeAnchor: RDEPUBTextRangeAnchor? = nil, - cfi: String? = nil, - rangeCFI: String? = nil - ) { - self.href = href - self.progression = progression - self.previewText = previewText - self.localMatchIndex = localMatchIndex - self.rangeLocation = rangeLocation - self.rangeLength = rangeLength - self.rangeAnchor = rangeAnchor - self.cfi = cfi - self.rangeCFI = rangeCFI - } -} - -public struct RDEPUBSearchResult: Codable, Equatable { - - public var keyword: String - - public var totalMatchCount: Int - - public var currentMatchIndex: Int? - - public var currentMatch: RDEPUBSearchMatch? - - public init( - keyword: String, - totalMatchCount: Int, - currentMatchIndex: Int?, - currentMatch: RDEPUBSearchMatch? - ) { - self.keyword = keyword - self.totalMatchCount = totalMatchCount - self.currentMatchIndex = currentMatchIndex - self.currentMatch = currentMatch - } -} - -public struct RDEPUBSearchState: Codable, Equatable { - - public var keyword: String - - public var matches: [RDEPUBSearchMatch] - - public var currentMatchIndex: Int? - - public init(keyword: String, matches: [RDEPUBSearchMatch], currentMatchIndex: Int? = nil) { - self.keyword = keyword - self.matches = matches - self.currentMatchIndex = currentMatchIndex - } - - public var currentMatch: RDEPUBSearchMatch? { - guard let currentMatchIndex, - matches.indices.contains(currentMatchIndex) else { - return nil - } - return matches[currentMatchIndex] - } - - public var result: RDEPUBSearchResult { - RDEPUBSearchResult( - keyword: keyword, - totalMatchCount: matches.count, - currentMatchIndex: currentMatchIndex.map { $0 + 1 }, - currentMatch: currentMatch - ) - } -} - -public struct RDEPUBSearchPresentationResource: Codable, Equatable { - - public var href: String - - public var matchCount: Int - - public var activeLocalMatchIndex: Int? - - public init(href: String, matchCount: Int, activeLocalMatchIndex: Int? = nil) { - self.href = href - self.matchCount = matchCount - self.activeLocalMatchIndex = activeLocalMatchIndex - } -} - -public struct RDEPUBSearchPresentation: Codable, Equatable { - - public var keyword: String - - public var resources: [RDEPUBSearchPresentationResource] - - public init(keyword: String, resources: [RDEPUBSearchPresentationResource]) { - self.keyword = keyword - self.resources = resources - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBStyleSheetBuilder.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBStyleSheetBuilder.swift deleted file mode 100644 index bb4b823..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBStyleSheetBuilder.swift +++ /dev/null @@ -1,215 +0,0 @@ - -import Foundation - -public enum RDEPUBStyleSheetBuilder { - - public static func injectPaginationCSS( - into html: String, - presentation: RDEPUBPresentationStyle - ) -> String { - let styleTag = "" - if html.range(of: "", options: .caseInsensitive) != nil { - return html.replacingOccurrences(of: "", with: styleTag + "", options: .caseInsensitive) - } - if html.range(of: " String { - let values = cssValues(for: presentation) - let backgroundCSS = presentation.themeBackgroundColor.map { "background: \($0) !important;" } ?? "" - let textCSS = presentation.themeTextColor.map { "color: \($0) !important;" } ?? "" - let columnCountCSS = values.numberOfColumns > 1 ? "-webkit-column-count: \(values.numberOfColumns) !important; column-count: \(values.numberOfColumns) !important;" : "" - - return """ - html { - margin: 0 !important; - padding: 0 !important; - width: \(values.viewportWidth)px !important; - height: \(values.viewportHeight)px !important; - min-height: \(values.viewportHeight)px !important; - overflow-x: hidden !important; - overflow-y: hidden !important; - } - body { - margin: 0 !important; - padding: 0 !important; - box-sizing: border-box !important; - position: relative !important; - width: \(values.viewportWidth)px !important; - height: \(values.viewportHeight)px !important; - min-height: \(values.viewportHeight)px !important; - overflow: hidden !important; - background: transparent !important; - } - #ss-reader-viewport { - position: relative !important; - width: \(values.viewportWidth)px !important; - height: \(values.viewportHeight)px !important; - min-height: \(values.viewportHeight)px !important; - overflow: hidden !important; - background: transparent !important; - } - #ss-reader-content { - position: relative !important; - box-sizing: border-box !important; - width: \(values.viewportWidth)px !important; - height: \(values.viewportHeight)px !important; - min-height: \(values.viewportHeight)px !important; - overflow: visible !important; - padding: \(values.paddingTop)px \(values.paddingRight)px \(values.paddingBottom)px \(values.paddingLeft)px !important; - font-size: \(values.fontSize)px !important; - line-height: \(values.lineHeight) !important; - -webkit-column-width: \(values.contentWidth)px !important; - column-width: \(values.contentWidth)px !important; - \(columnCountCSS) - -webkit-column-gap: \(values.columnGap)px !important; - column-gap: \(values.columnGap)px !important; - -webkit-column-fill: auto !important; - column-fill: auto !important; - transform-origin: top left !important; - will-change: transform !important; - \(backgroundCSS) - \(textCSS) - } - #ss-reader-content img, #ss-reader-content svg, #ss-reader-content video, #ss-reader-content canvas, #ss-reader-content iframe { - max-width: 100% !important; - height: auto !important; - break-inside: avoid-column !important; - -webkit-column-break-inside: avoid !important; - } - #ss-reader-content mark.ss-reader-highlight { - padding: 0; - border-radius: 2px; - color: inherit !important; - } - #ss-reader-content mark.ss-reader-highlight-highlight { - text-decoration: none !important; - color: inherit !important; - } - #ss-reader-content mark.ss-reader-highlight-underline { - background: transparent !important; - color: inherit !important; - text-decoration-line: underline !important; - text-decoration-thickness: 2px !important; - text-decoration-skip-ink: auto !important; - } - """ - } - - public static func measurementCSS(for presentation: RDEPUBPresentationStyle) -> String { - let values = cssValues(for: presentation) - let backgroundCSS = presentation.themeBackgroundColor.map { "background: \($0) !important;" } ?? "" - let textCSS = presentation.themeTextColor.map { "color: \($0) !important;" } ?? "" - let columnCountCSS = values.numberOfColumns > 1 ? "-webkit-column-count: \(values.numberOfColumns) !important; column-count: \(values.numberOfColumns) !important;" : "" - - return """ - html { - margin: 0 !important; - padding: 0 !important; - width: \(values.viewportWidth)px !important; - height: \(values.viewportHeight)px !important; - min-height: \(values.viewportHeight)px !important; - overflow: hidden !important; - } - body { - margin: 0 !important; - box-sizing: border-box !important; - position: relative !important; - width: \(values.viewportWidth)px !important; - height: \(values.viewportHeight)px !important; - min-height: \(values.viewportHeight)px !important; - overflow: visible !important; - padding: \(values.paddingTop)px \(values.paddingRight)px \(values.paddingBottom)px \(values.paddingLeft)px !important; - font-size: \(values.fontSize)px !important; - line-height: \(values.lineHeight) !important; - -webkit-column-width: \(values.contentWidth)px !important; - column-width: \(values.contentWidth)px !important; - \(columnCountCSS) - -webkit-column-gap: \(values.columnGap)px !important; - column-gap: \(values.columnGap)px !important; - -webkit-column-fill: auto !important; - column-fill: auto !important; - transform-origin: top left !important; - \(backgroundCSS) - \(textCSS) - } - img, svg, video, canvas, iframe { - max-width: 100% !important; - height: auto !important; - break-inside: avoid-column !important; - -webkit-column-break-inside: avoid !important; - } - """ - } - - public static func measurementScript(for presentation: RDEPUBPresentationStyle) -> String { - let style = measurementCSS(for: presentation) - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "`", with: "\\`") - - let viewportWidth = Double(max(1, presentation.viewportSize.width)) - let viewportWidthJS = String(format: "%.3f", viewportWidth) - - return """ - (function() { - var style = document.getElementById('ss-reader-pagination'); - if (!style) { - style = document.createElement('style'); - style.id = 'ss-reader-pagination'; - document.head.appendChild(style); - } - style.textContent = `\(style)`; - var scrollingElement = document.scrollingElement || document.documentElement || document.body; - var totalWidth = Math.max( - scrollingElement ? scrollingElement.scrollWidth : 0, - scrollingElement ? scrollingElement.offsetWidth : 0, - document.body ? document.body.scrollWidth : 0, - document.body ? document.body.offsetWidth : 0, - document.documentElement ? document.documentElement.scrollWidth : 0, - document.documentElement ? document.documentElement.offsetWidth : 0 - ); - return Math.max(1, Math.ceil(totalWidth / \(viewportWidthJS))); - })(); - """ - } - - private static func cssValues(for presentation: RDEPUBPresentationStyle) -> ( - viewportWidth: String, - viewportHeight: String, - contentWidth: String, - columnGap: String, - numberOfColumns: Int, - paddingTop: String, - paddingRight: String, - paddingBottom: String, - paddingLeft: String, - fontSize: String, - lineHeight: String - ) { - let viewportWidth = max(1, presentation.viewportSize.width) - let viewportHeight = max(1, presentation.viewportSize.height) - let contentInsets = presentation.contentInsets - let numberOfColumns = max(1, presentation.numberOfColumns) - let availableWidth = max(1, viewportWidth - contentInsets.left - contentInsets.right) - let totalGap = CGFloat(numberOfColumns - 1) * max(0, presentation.columnGap) - let contentWidth = max(1, (availableWidth - totalGap) / CGFloat(numberOfColumns)) - let columnGap = max(0, presentation.columnGap) - - return ( - viewportWidth: String(format: "%.3f", viewportWidth), - viewportHeight: String(format: "%.3f", viewportHeight), - contentWidth: String(format: "%.3f", contentWidth), - columnGap: String(format: "%.3f", columnGap), - numberOfColumns: numberOfColumns, - paddingTop: String(format: "%.3f", contentInsets.top), - paddingRight: String(format: "%.3f", contentInsets.right), - paddingBottom: String(format: "%.3f", contentInsets.bottom), - paddingLeft: String(format: "%.3f", contentInsets.left), - fontSize: String(format: "%.3f", presentation.fontSize), - lineHeight: String(format: "%.3f", max(1, presentation.lineHeightMultiple)) - ) - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBTextAnchor.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBTextAnchor.swift deleted file mode 100644 index 10417ba..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBTextAnchor.swift +++ /dev/null @@ -1,76 +0,0 @@ - -import Foundation - -public struct RDEPUBTextAnchor: Codable, Equatable { - - public let fileIndex: Int - - public let row: Int - - public let column: Int - - public let chapterOffset: Int - - public let fragmentID: String? - - public var spineIndex: Int { fileIndex } - - public init( - fileIndex: Int, - row: Int, - column: Int, - chapterOffset: Int, - fragmentID: String? = nil - ) { - self.fileIndex = fileIndex - self.row = row - self.column = column - self.chapterOffset = chapterOffset - self.fragmentID = fragmentID - } - - enum CodingKeys: String, CodingKey { - case fileIndex - case spineIndex - case row - case column - case chapterOffset - case fragmentID - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let decodedFileIndex = try container.decodeIfPresent(Int.self, forKey: .fileIndex) - ?? container.decode(Int.self, forKey: .spineIndex) - self.fileIndex = decodedFileIndex - self.row = try container.decodeIfPresent(Int.self, forKey: .row) ?? 0 - self.column = try container.decodeIfPresent(Int.self, forKey: .column) ?? 0 - self.chapterOffset = try container.decode(Int.self, forKey: .chapterOffset) - self.fragmentID = try container.decodeIfPresent(String.self, forKey: .fragmentID) - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(fileIndex, forKey: .fileIndex) - try container.encode(row, forKey: .row) - try container.encode(column, forKey: .column) - try container.encode(chapterOffset, forKey: .chapterOffset) - try container.encodeIfPresent(fragmentID, forKey: .fragmentID) - } -} - -public struct RDEPUBTextRangeAnchor: Codable, Equatable { - - public let start: RDEPUBTextAnchor - - public let end: RDEPUBTextAnchor - - public init(start: RDEPUBTextAnchor, end: RDEPUBTextAnchor) { - self.start = start - self.end = end - } - - public var nsRange: NSRange { - NSRange(location: start.chapterOffset, length: max(end.chapterOffset - start.chapterOffset, 0)) - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+Configuration.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+Configuration.swift deleted file mode 100644 index 7ca2286..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+Configuration.swift +++ /dev/null @@ -1,109 +0,0 @@ - -import UIKit -import WebKit - -extension RDEPUBWebView { - - func configureWebViewIfNeeded(publication: RDEPUBPublication) { - let parser = publication.parser - let publicationKey = parser.opfURL?.path ?? parser.extractionRootURL?.path ?? UUID().uuidString - if publicationKey == configuredPublicationKey, webView != nil { - return - } - - teardownWebView() - - let userContentController = WKUserContentController() - RDEPUBJavaScriptBridge.messageNames.forEach { - userContentController.add(self, name: $0) - } - userContentController.addUserScript( - WKUserScript( - source: RDEPUBJavaScriptBridge.userScript, - injectionTime: .atDocumentEnd, - forMainFrameOnly: true - ) - ) - - let configuration = WKWebViewConfiguration() - configuration.websiteDataStore = .nonPersistent() - configuration.userContentController = userContentController - let schemeHandler = RDEPUBResourceURLSchemeHandler(parser: parser) - configuration.setURLSchemeHandler(schemeHandler, forURLScheme: RDEPUBResourceURLSchemeHandler.scheme) - - let webView = RDEPUBAnnotationWebView(frame: bounds, configuration: configuration) - webView.onSelectionAction = { [weak self] action in - guard let self else { return } - self.handleSelectionMenuAction(action) - } - // M-15: Use UIEditMenuInteraction on iOS 16+ to replace deprecated UIMenuController - if #available(iOS 16.0, *) { - let interaction = UIEditMenuInteraction(delegate: webView) - webView.addInteraction(interaction) - webView._editMenuInteraction = interaction - } else { - UIMenuController.shared.menuItems = [ - UIMenuItem(title: "拷贝", action: #selector(RDEPUBAnnotationWebView.rd_copy(_:))), - UIMenuItem(title: "高亮", action: #selector(RDEPUBAnnotationWebView.rd_highlight(_:))), - UIMenuItem(title: "批注", action: #selector(RDEPUBAnnotationWebView.rd_annotate(_:))) - ] - } - webView.navigationDelegate = self - webView.scrollView.isScrollEnabled = false - webView.scrollView.showsHorizontalScrollIndicator = false - webView.scrollView.showsVerticalScrollIndicator = false - webView.scrollView.bounces = false - webView.scrollView.alwaysBounceHorizontal = false - webView.scrollView.alwaysBounceVertical = false - webView.scrollView.clipsToBounds = true - webView.scrollView.panGestureRecognizer.isEnabled = false - webView.scrollView.pinchGestureRecognizer?.isEnabled = false - webView.isOpaque = false - webView.backgroundColor = .clear - webView.clipsToBounds = true - if #available(iOS 16.4, *) { - webView.isInspectable = RDEPUBWebViewDebug.isInspectableEnabled - } - - addSubview(webView) - webView.frame = bounds - webView.autoresizingMask = [.flexibleWidth, .flexibleHeight] - - self.schemeHandler = schemeHandler - self.webView = webView - self.configuredPublicationKey = publicationKey - RDEPUBWebViewDebug.log(debugScope, message: "configured webView=\(RDEPUBWebViewDebug.webViewID(webView)) publicationKey=\(publicationKey)") - } - - func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction) { - delegate?.epubWebView(self, didRequestSelectionAction: action) - clearWebSelection() - } - - func clearWebSelection() { - webView?.evaluateJavaScript("window.getSelection && window.getSelection().removeAllRanges();") - } - - func teardownWebView() { - cancelFixedLayoutReadyFallback() - if let webView { - RDEPUBWebViewDebug.log(debugScope, message: "teardown webView=\(RDEPUBWebViewDebug.webViewID(webView))") - } - // M-15: Clean up UIEditMenuInteraction on iOS 16+ - if #available(iOS 16.0, *), let interaction = (webView as? RDEPUBAnnotationWebView)?._editMenuInteraction as? UIEditMenuInteraction { - webView?.removeInteraction(interaction) - } - webView?.navigationDelegate = nil - if let userContentController = webView?.configuration.userContentController { - RDEPUBJavaScriptBridge.messageNames.forEach { - userContentController.removeScriptMessageHandler(forName: $0) - } - } - (webView as? RDEPUBAnnotationWebView)?.onSelectionAction = nil - webView?.removeFromSuperview() - webView = nil - schemeHandler = nil - configuredPublicationKey = nil - currentLoadSignature = nil - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+FixedLayout.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+FixedLayout.swift deleted file mode 100644 index 1458105..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+FixedLayout.swift +++ /dev/null @@ -1,122 +0,0 @@ - -import UIKit -import WebKit - -extension RDEPUBWebView { - - public func loadFixedSpread( - parser: RDEPUBParser, - spread: EPUBFixedSpread, - viewportSize: CGSize, - contentInset: UIEdgeInsets, - backgroundColor: UIColor? - ) { - let request = RDEPUBRenderRequest.fixed( - RDEPUBFixedRenderRequest( - spread: spread, - viewportSize: viewportSize, - contentInset: contentInset, - backgroundColorCSS: backgroundColor?.rd_hexString, - fit: .page, - searchPresentation: nil - ) - ) - load(publication: parser.makePublication(), request: request) - } - - func handleFixedLayoutLoad( - publication: RDEPUBPublication, - request: RDEPUBFixedRenderRequest, - loadSignature: String, - in webView: WKWebView - ) { - currentPageIndex = 0 - currentTotalPagesInChapter = 1 - viewportSize = request.viewportSize - currentPadding = request.contentInset - currentFontSize = 16 - currentLineHeightMultiple = 1.5 - currentThemeBackgroundColor = request.backgroundColorCSS - currentThemeTextColor = nil - targetLocation = nil - pendingHighlights = [] - fixedSpread = request.spread - isFixedLayout = true - pendingProgressionRequest = false - currentLoadSignature = loadSignature - scheduleFixedLayoutReadyFallback() - - let html = RDEPUBFixedLayoutTemplate.html(for: request, publication: publication) - RDEPUBWebViewDebug.log(debugScope, message: "load fixed spread resources=\(request.spread.resources.map(\.href).joined(separator: ",")) fit=\(request.fit.rawValue)") - webView.loadHTMLString( - html, - baseURL: URL(string: "\(RDEPUBResourceURLSchemeHandler.scheme)://\(RDEPUBResourceURLSchemeHandler.host)/") - ) - } - - func fixedLayoutLoadSignature(publicationKey: String, request: RDEPUBFixedRenderRequest) -> String { - let searchSignature = [ - request.searchPresentation?.keyword ?? "", - request.searchPresentation?.resources.map { - [ - $0.href, - String($0.matchCount), - String($0.activeLocalMatchIndex ?? -1) - ].joined(separator: "|") - }.joined(separator: ",") ?? "" - ].joined(separator: "#") - return [ - publicationKey, - "fixed", - request.spread.resources.map(\.href).joined(separator: "|"), - String(format: "%.2f", request.viewportSize.width), - String(format: "%.2f", request.viewportSize.height), - String(format: "%.2f", request.contentInset.top), - String(format: "%.2f", request.contentInset.left), - String(format: "%.2f", request.contentInset.bottom), - String(format: "%.2f", request.contentInset.right), - request.backgroundColorCSS ?? "", - request.fit.rawValue, - searchSignature - ].joined(separator: "#") - } - - func scheduleFixedLayoutReadyFallback() { - let workItem = DispatchWorkItem { [weak self] in - RDEPUBWebViewDebug.log(self?.debugScope ?? "ReaderWebView", message: "fixed ready fallback fired") - self?.applyFixedPresentationIfNeeded { - self?.refreshNativeDecorationsIfNeeded { - self?.rendered() - } - } - } - fixedLayoutReadyWorkItem = workItem - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: workItem) - } - - func cancelFixedLayoutReadyFallback() { - fixedLayoutReadyWorkItem?.cancel() - fixedLayoutReadyWorkItem = nil - } - - func applyFixedPresentationIfNeeded(completion: (() -> Void)? = nil) { - guard let webView, - let currentRenderRequest, - case .fixed(let request) = currentRenderRequest else { - completion?() - return - } - - let script = RDEPUBJavaScriptBridge.applyFixedPresentationScript(for: request) - webView.evaluateJavaScript(script) { [weak self] _, error in - guard let self else { - completion?() - return - } - if let error { - self.delegate?.epubWebView(self, didLogJavaScriptError: error.localizedDescription) - } - self.applySearchDecorationsIfNeeded(completion: completion) - } - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+JavaScriptBridge.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+JavaScriptBridge.swift deleted file mode 100644 index 428f5a3..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+JavaScriptBridge.swift +++ /dev/null @@ -1,149 +0,0 @@ - -import Foundation -import WebKit - -extension RDEPUBWebView { - - func internalLocation(from url: URL) -> RDEPUBLocation? { - let href = url.path.removingPercentEncoding?.trimmingCharacters(in: CharacterSet(charactersIn: "/")) ?? "" - let fragment = url.fragment - guard !href.isEmpty || fragment != nil else { return nil } - return RDEPUBLocation(href: href, progression: 0, fragment: fragment) - } - - func currentLocation(from body: [String: Any]) -> RDEPUBLocation { - let progression = (body["progression"] as? NSNumber)?.doubleValue ?? 0 - let lastProgression = (body["lastProgression"] as? NSNumber)?.doubleValue - let fragment = body["fragment"] as? String - let href = body["href"] as? String ?? currentHref - return RDEPUBLocation(href: href, progression: progression, lastProgression: lastProgression, fragment: fragment) - } -} - -extension RDEPUBWebView: WKNavigationDelegate { - public func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { - RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didStart", url: webView.url) - } - - public func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) { - RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didCommit", url: webView.url) - } - - public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFinish", url: webView.url) - applyPresentation() - } - - public func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { - RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "processTerminated", url: webView.url) - } - - public func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { - RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFail", url: webView.url, error: error) - } - - public func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { - RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFailProvisional", url: webView.url, error: error) - } - - public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { - RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "decidePolicy", url: navigationAction.request.url) - guard navigationAction.navigationType == .linkActivated, - let url = navigationAction.request.url else { - decisionHandler(.allow) - return - } - - if url.scheme == RDEPUBResourceURLSchemeHandler.scheme, - let location = internalLocation(from: url) { - delegate?.epubWebView(self, didActivateInternalLink: location, fromSpineIndex: currentSpineIndex) - decisionHandler(.cancel) - return - } - - if let scheme = url.scheme?.lowercased(), ["http", "https", "mailto", "tel"].contains(scheme) { - delegate?.epubWebView(self, didActivateExternalLink: url) - decisionHandler(.cancel) - return - } - - decisionHandler(.allow) - } -} - -extension RDEPUBWebView: WKScriptMessageHandler { - - public func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { - RDEPUBWebViewDebug.logMessage(debugScope, webView: message.webView, name: message.name, body: message.body) - switch message.name { - case RDEPUBJavaScriptBridgeMessage.progressionChanged.rawValue: - guard let body = message.body as? [String: Any] else { return } - delegate?.epubWebView(self, didUpdateLocation: currentLocation(from: body), spineIndex: currentSpineIndex) - case RDEPUBJavaScriptBridgeMessage.selectionChanged.rawValue: - if let body = message.body as? [String: Any], - let text = body["text"] as? String, - let rangeInfo = body["rangeInfo"] as? String { - let location = currentLocation(from: body) - let selection = RDEPUBSelection(location: location, text: text, rangeInfo: rangeInfo) - delegate?.epubWebView(self, didChangeSelection: selection, spineIndex: currentSpineIndex) - } else { - delegate?.epubWebView(self, didChangeSelection: nil, spineIndex: currentSpineIndex) - } - case RDEPUBJavaScriptBridgeMessage.internalLink.rawValue: - guard let body = message.body as? [String: Any], - let href = body["href"] as? String else { return } - let location = RDEPUBLocation(href: href, progression: 0) - delegate?.epubWebView(self, didActivateInternalLink: location, fromSpineIndex: currentSpineIndex) - case RDEPUBJavaScriptBridgeMessage.externalLink.rawValue: - guard let body = message.body as? [String: Any], - let urlString = body["url"] as? String, - let url = URL(string: urlString) else { return } - delegate?.epubWebView(self, didActivateExternalLink: url) - case RDEPUBJavaScriptBridgeMessage.javaScriptError.rawValue: - delegate?.epubWebView(self, didLogJavaScriptError: "\(message.body)") - case RDEPUBJavaScriptBridgeMessage.fixedLayoutReady.rawValue: - applyFixedPresentationIfNeeded { - self.refreshNativeDecorationsIfNeeded { - self.rendered() - } - } - case RDEPUBJavaScriptBridgeMessage.imageDidTap.rawValue: - guard let body = message.body as? [String: Any], - let src = body["src"] as? String else { return } - let sourceRect = sourceRect(from: body["rect"]) - delegate?.epubWebView(self, didTapImageWithSource: src, sourceRect: sourceRect) - case RDEPUBJavaScriptBridgeMessage.footnoteDidTap.rawValue: - guard let body = message.body as? [String: Any], - let altText = body["alt"] as? String else { return } - let sourceRect = sourceRect(from: body["rect"]) - delegate?.epubWebView(self, didTapFootnoteWithAltText: altText, sourceRect: sourceRect) - default: - break - } - } - - private func sourceRect(from value: Any?) -> CGRect? { - guard let rectDict = value as? [String: Any] else { return nil } - return CGRect( - x: cgFloatValue(rectDict["x"]), - y: cgFloatValue(rectDict["y"]), - width: cgFloatValue(rectDict["width"]), - height: cgFloatValue(rectDict["height"]) - ) - } - - private func cgFloatValue(_ value: Any?) -> CGFloat { - switch value { - case let number as NSNumber: - return CGFloat(truncating: number) - case let double as Double: - return CGFloat(double) - case let int as Int: - return CGFloat(int) - case let string as String: - return CGFloat(Double(string) ?? 0) - default: - return 0 - } - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+Reflowable.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+Reflowable.swift deleted file mode 100644 index 472304a..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+Reflowable.swift +++ /dev/null @@ -1,161 +0,0 @@ - -import UIKit -import WebKit - -extension RDEPUBWebView { - - public func loadPage( - parser: RDEPUBParser, - spineIndex: Int, - pageIndex: Int, - totalPagesInChapter: Int, - viewportSize: CGSize, - padding: UIEdgeInsets, - fontSize: CGFloat, - lineHeightMultiple: CGFloat, - themeBackgroundColor: String?, - themeTextColor: String?, - targetLocation: RDEPUBLocation?, - highlights: [RDEPUBHighlight] - ) { - let href = parser.spine.indices.contains(spineIndex) ? parser.spine[spineIndex].href : "" - let request = RDEPUBRenderRequest.reflowable( - RDEPUBReflowableRenderRequest( - spineIndex: spineIndex, - href: href, - pageIndex: pageIndex, - totalPagesInChapter: totalPagesInChapter, - presentation: RDEPUBPresentationStyle( - viewportSize: viewportSize, - contentInsets: padding, - fontSize: fontSize, - lineHeightMultiple: lineHeightMultiple, - themeBackgroundColor: themeBackgroundColor, - themeTextColor: themeTextColor - ), - targetLocation: targetLocation, - highlights: highlights, - searchPresentation: nil - ) - ) - load(publication: parser.makePublication(), request: request) - } - - func handleReflowableLoad( - publication: RDEPUBPublication, - request: RDEPUBReflowableRenderRequest, - loadSignature: String, - in webView: WKWebView - ) { - guard publication.spine.indices.contains(request.spineIndex), - let requestURL = publication.resourceResolver.resourceURL(forRelativePath: request.href) else { - return - } - - currentPageIndex = request.pageIndex - currentTotalPagesInChapter = max(1, request.totalPagesInChapter) - viewportSize = request.presentation.viewportSize - currentPadding = request.presentation.contentInsets - currentFontSize = request.presentation.fontSize - currentLineHeightMultiple = request.presentation.lineHeightMultiple - currentThemeBackgroundColor = request.presentation.themeBackgroundColor - currentThemeTextColor = request.presentation.themeTextColor - targetLocation = request.targetLocation - pendingHighlights = request.highlights - fixedSpread = nil - isFixedLayout = false - pendingProgressionRequest = true - - if currentLoadSignature == loadSignature { - if didRenderCurrentRequest || webView.isLoading { - let reason = didRenderCurrentRequest ? "already-rendered" : "still-loading" - RDEPUBWebViewDebug.log(debugScope, message: "skip duplicate request href=\(request.href) page=\(request.pageIndex) reason=\(reason)") - return - } - - if webView.url == requestURL { - RDEPUBWebViewDebug.log(debugScope, message: "reuse existing document href=\(request.href) page=\(request.pageIndex)") - applyPresentation() - return - } - } - - currentLoadSignature = loadSignature - RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "load-request", url: requestURL) - webView.load(URLRequest(url: requestURL)) - } - - func reflowableLoadSignature(publicationKey: String, request: RDEPUBReflowableRenderRequest) -> String { - let targetSignature = [ - request.targetLocation?.href ?? "", - String(request.targetLocation?.progression ?? 0), - String(request.targetLocation?.lastProgression ?? 0), - request.targetLocation?.fragment ?? "", - request.targetHighlightRangeInfo ?? "" - ].joined(separator: "|") - let highlightSignature = request.highlights - .map { [$0.id, $0.rangeInfo ?? "", $0.color, $0.style.rawValue].joined(separator: "|") } - .joined(separator: ",") - let searchSignature = [ - request.searchPresentation?.keyword ?? "", - request.searchPresentation?.resources.map { - [ - $0.href, - String($0.matchCount), - String($0.activeLocalMatchIndex ?? -1) - ].joined(separator: "|") - }.joined(separator: ",") ?? "" - ].joined(separator: "#") - let presentation = request.presentation - return [ - publicationKey, - "reflowable", - String(request.spineIndex), - request.href, - String(request.pageIndex), - String(request.totalPagesInChapter), - String(format: "%.2f", presentation.viewportSize.width), - String(format: "%.2f", presentation.viewportSize.height), - String(format: "%.2f", presentation.contentInsets.top), - String(format: "%.2f", presentation.contentInsets.left), - String(format: "%.2f", presentation.contentInsets.bottom), - String(format: "%.2f", presentation.contentInsets.right), - String(format: "%.2f", presentation.fontSize), - String(format: "%.2f", presentation.lineHeightMultiple), - String(presentation.numberOfColumns), - String(format: "%.2f", presentation.columnGap), - presentation.themeBackgroundColor ?? "", - presentation.themeTextColor ?? "", - targetSignature, - highlightSignature, - searchSignature - ].joined(separator: "#") - } - - func applyPresentation() { - guard let webView, let currentRenderRequest else { return } - guard case .reflowable(let request) = currentRenderRequest else { - return - } - - let script = RDEPUBJavaScriptBridge.applyPresentationScript(for: request) - RDEPUBWebViewDebug.logJavaScript( - debugScope, - webView: webView, - action: "applyPresentation", - details: "spine=\(request.spineIndex) page=\(request.pageIndex) total=\(request.totalPagesInChapter)" - ) - - webView.evaluateJavaScript(script) { [weak self] _, error in - guard let self else { return } - if let error { - RDEPUBWebViewDebug.logJavaScript(self.debugScope, webView: webView, action: "applyPresentationFailed", details: error.localizedDescription) - self.delegate?.epubWebView(self, didLogJavaScriptError: error.localizedDescription) - return - } - self.applySearchDecorationsIfNeeded { - self.rendered() - } - } - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+Search.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+Search.swift deleted file mode 100644 index d1d2c05..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+Search.swift +++ /dev/null @@ -1,142 +0,0 @@ - -import UIKit -import WebKit - -extension RDEPUBWebView { - private var normalSearchOverlayColor: UIColor { - UIColor(red: 0.21, green: 0.48, blue: 0.95, alpha: 0.16) - } - - private var activeSearchOverlayColor: UIColor { - UIColor(red: 0.14, green: 0.42, blue: 0.95, alpha: 0.34) - } - - func applySearchDecorationsIfNeeded(completion: (() -> Void)? = nil) { - guard let webView else { - completion?() - return - } - - let presentation: RDEPUBSearchPresentation? - switch currentRenderRequest { - case .reflowable(let request): - presentation = request.searchPresentation - case .fixed(let request): - presentation = request.searchPresentation - case .none: - presentation = nil - } - - let script = RDEPUBJavaScriptBridge.applySearchScript(for: presentation) - webView.evaluateJavaScript(script) { [weak self] _, error in - guard let self else { - completion?() - return - } - if let error { - self.delegate?.epubWebView(self, didLogJavaScriptError: error.localizedDescription) - } - self.refreshNativeDecorationsIfNeeded(completion: completion) - } - } - - func refreshNativeDecorationsIfNeeded(completion: (() -> Void)? = nil) { - guard let webView else { - onDecorationsResolved?([]) - completion?() - return - } - - webView.evaluateJavaScript(RDEPUBJavaScriptBridge.resolveDecorationsScript()) { [weak self] result, error in - guard let self else { - completion?() - return - } - if let error { - self.delegate?.epubWebView(self, didLogJavaScriptError: error.localizedDescription) - self.onDecorationsResolved?([]) - completion?() - return - } - - let decorations = self.nativeDecorations(from: result) - self.onDecorationsResolved?(decorations) - completion?() - } - } - - private func nativeDecorations(from result: Any?) -> [RDEPUBTextOverlayDecoration] { - guard let payload = result as? [String: Any] else { return [] } - - let highlightDecorations = decorationList( - from: payload["highlights"] as? [[String: Any]], - defaultColor: overlayColor(hex: "#F8E16C", alpha: 0.45) ?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.45) - ) - let searchDecorations = decorationList( - from: payload["search"] as? [[String: Any]], - defaultColor: normalSearchOverlayColor - ) - return highlightDecorations + searchDecorations - } - - private func decorationList( - from items: [[String: Any]]?, - defaultColor: UIColor - ) -> [RDEPUBTextOverlayDecoration] { - guard let items else { return [] } - - return items.enumerated().compactMap { index, item -> RDEPUBTextOverlayDecoration? in - guard let kindString = item["kind"] as? String, - let kind = RDEPUBTextOverlayDecoration.Kind(rawValue: kindString), - let rectPayloads = item["rects"] as? [[String: Any]] else { - return nil - } - - let rects = rectPayloads.compactMap { rectPayload -> CGRect? in - guard let x = (rectPayload["x"] as? NSNumber)?.doubleValue, - let y = (rectPayload["y"] as? NSNumber)?.doubleValue, - let width = (rectPayload["width"] as? NSNumber)?.doubleValue, - let height = (rectPayload["height"] as? NSNumber)?.doubleValue else { - return nil - } - return CGRect(x: x, y: y, width: width, height: height) - } - guard !rects.isEmpty else { return nil } - - let color: UIColor - switch kind { - case .underline: - let hex = item["color"] as? String ?? "#F8E16C" - color = overlayColor(hex: hex, alpha: 1) ?? defaultColor - case .activeSearch: - color = activeSearchOverlayColor - case .search: - color = normalSearchOverlayColor - case .highlight: - let hex = item["color"] as? String ?? "#F8E16C" - color = overlayColor(hex: hex, alpha: 0.45) ?? defaultColor - case .selection, .locate: - color = defaultColor - } - - return RDEPUBTextOverlayDecoration( - kind: kind, - absoluteRange: NSRange(location: index, length: 1), - rects: rects, - color: color - ) - } - } - - private func overlayColor(hex: String, alpha: CGFloat) -> UIColor? { - var value = hex.trimmingCharacters(in: .whitespacesAndNewlines) - value = value.replacingOccurrences(of: "#", with: "") - guard value.count == 6, let hexValue = Int(value, radix: 16) else { return nil } - return UIColor( - red: CGFloat((hexValue >> 16) & 0xFF) / 255, - green: CGFloat((hexValue >> 8) & 0xFF) / 255, - blue: CGFloat(hexValue & 0xFF) / 255, - alpha: alpha - ) - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView.swift deleted file mode 100644 index ae28125..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView.swift +++ /dev/null @@ -1,244 +0,0 @@ - -import UIKit -import WebKit - -public protocol RDEPUBWebViewDelegate: AnyObject { - - func epubWebView(_ webView: RDEPUBWebView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int) - - func epubWebView(_ webView: RDEPUBWebView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int) - - func epubWebView(_ webView: RDEPUBWebView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) - - func epubWebView(_ webView: RDEPUBWebView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int) - - func epubWebView(_ webView: RDEPUBWebView, didActivateExternalLink url: URL) - - func epubWebView(_ webView: RDEPUBWebView, didLogJavaScriptError message: String) - - func epubWebViewDidFinishRendering(_ webView: RDEPUBWebView) - - func epubWebView(_ webView: RDEPUBWebView, didTapImageWithSource src: String, sourceRect: CGRect?) - - func epubWebView(_ webView: RDEPUBWebView, didTapFootnoteWithAltText altText: String, sourceRect: CGRect?) -} - -public extension RDEPUBWebViewDelegate { - func epubWebView(_ webView: RDEPUBWebView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) {} - func epubWebView(_ webView: RDEPUBWebView, didTapImageWithSource src: String, sourceRect: CGRect?) {} - func epubWebView(_ webView: RDEPUBWebView, didTapFootnoteWithAltText altText: String, sourceRect: CGRect?) {} -} - -final class RDEPUBAnnotationWebView: WKWebView { - - var onSelectionAction: ((RDEPUBAnnotationMenuAction) -> Void)? - - // M-15: Backing store for UIEditMenuInteraction (iOS 16+). - // Stored as Any? to avoid @available on stored property restriction. - var _editMenuInteraction: Any? - - override var canBecomeFirstResponder: Bool { - true - } - - override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { - switch action { - case #selector(rd_copy(_:)), - #selector(rd_highlight(_:)), - #selector(rd_annotate(_:)): - return true - default: - return false - } - } - - @objc func rd_copy(_ sender: Any?) { - onSelectionAction?(.copy) - } - - @objc func rd_highlight(_ sender: Any?) { - onSelectionAction?(.highlight) - } - - @objc func rd_annotate(_ sender: Any?) { - onSelectionAction?(.annotate) - } -} - -// MARK: - UIEditMenuInteractionDelegate (iOS 16+) - -@available(iOS 16.0, *) -extension RDEPUBAnnotationWebView: UIEditMenuInteractionDelegate { - func editMenuInteraction( - _ interaction: UIEditMenuInteraction, - menuFor configuration: UIEditMenuConfiguration - ) -> UIMenu { - UIMenu(children: [ - UICommand(title: "拷贝", action: #selector(rd_copy(_:))), - UICommand(title: "高亮", action: #selector(rd_highlight(_:))), - UICommand(title: "批注", action: #selector(rd_annotate(_:))) - ]) - } - - func editMenuInteraction( - _ interaction: UIEditMenuInteraction, - targetRectFor configuration: UIEditMenuConfiguration - ) -> CGRect { - bounds - } -} - -public final class RDEPUBWebView: UIView { - - public weak var delegate: RDEPUBWebViewDelegate? - - public var onRendered: (() -> Void)? - - var onDecorationsResolved: (([RDEPUBTextOverlayDecoration]) -> Void)? - - var publication: RDEPUBPublication? - - var currentRenderRequest: RDEPUBRenderRequest? - - var webView: WKWebView? - - var schemeHandler: RDEPUBResourceURLSchemeHandler? - - var configuredPublicationKey: String? - - var currentLoadSignature: String? - - var currentSpineIndex = 0 - - var currentHref = "" - - var currentPageIndex = 0 - - var currentTotalPagesInChapter = 1 - - var viewportSize: CGSize = .zero - - var currentPadding: UIEdgeInsets = .zero - - var currentFontSize: CGFloat = 16 - - var currentLineHeightMultiple: CGFloat = 1.5 - - var currentThemeBackgroundColor: String? - - var currentThemeTextColor: String? - - var targetLocation: RDEPUBLocation? - - var pendingHighlights: [RDEPUBHighlight] = [] - - var fixedSpread: EPUBFixedSpread? - - var isFixedLayout = false - - var pendingProgressionRequest = false - - var fixedLayoutReadyWorkItem: DispatchWorkItem? - - var didRenderCurrentRequest = false - - let debugScope = "ReaderWebView" - - public override init(frame: CGRect) { - super.init(frame: frame) - backgroundColor = .clear - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // M-08: Retain cycle note — webView → configuration.userContentController → self (as WKScriptMessageHandler) - // forms a retain cycle. The cycle is broken by calling teardownWebView(), which removes - // the message handlers. teardownWebView() is called in reset() and deinit. IMPORTANT: - // deinit may never trigger if the cycle is not broken first. Every code path that disposes - // of this view MUST call reset() before releasing the last reference. - deinit { - teardownWebView() - } - - public override func layoutSubviews() { - super.layoutSubviews() - webView?.frame = bounds - } - - public func reset() { - delegate = nil - onRendered = nil - onDecorationsResolved = nil - publication = nil - currentRenderRequest = nil - currentSpineIndex = 0 - currentHref = "" - currentPageIndex = 0 - currentTotalPagesInChapter = 1 - viewportSize = .zero - currentPadding = .zero - currentFontSize = 16 - currentLineHeightMultiple = 1.5 - currentThemeBackgroundColor = nil - currentThemeTextColor = nil - targetLocation = nil - pendingHighlights = [] - fixedSpread = nil - isFixedLayout = false - pendingProgressionRequest = false - didRenderCurrentRequest = false - teardownWebView() - } - - public func load(publication: RDEPUBPublication, request: RDEPUBRenderRequest) { - configureWebViewIfNeeded(publication: publication) - guard let webView else { return } - - self.publication = publication - self.currentRenderRequest = request - self.currentSpineIndex = request.primarySpineIndex - self.currentHref = request.primaryHref - self.didRenderCurrentRequest = false - cancelFixedLayoutReadyFallback() - - let publicationKey = publication.parser.opfURL?.path ?? publication.parser.extractionRootURL?.path ?? "" - let loadSignature = pageLoadSignature(publicationKey: publicationKey, request: request) - RDEPUBWebViewDebug.log( - debugScope, - message: "load request=\(request.isFixedLayout ? "fixed" : "reflowable") signature=\(loadSignature) webView=\(RDEPUBWebViewDebug.webViewID(webView))" - ) - - switch request { - case .reflowable(let reflowableRequest): - handleReflowableLoad(publication: publication, request: reflowableRequest, loadSignature: loadSignature, in: webView) - case .fixed(let fixedRequest): - handleFixedLayoutLoad(publication: publication, request: fixedRequest, loadSignature: loadSignature, in: webView) - } - } - - func pageLoadSignature( - publicationKey: String, - request: RDEPUBRenderRequest - ) -> String { - switch request { - case .reflowable(let request): - return reflowableLoadSignature(publicationKey: publicationKey, request: request) - case .fixed(let request): - return fixedLayoutLoadSignature(publicationKey: publicationKey, request: request) - } - } - - func rendered() { - guard !didRenderCurrentRequest else { return } - didRenderCurrentRequest = true - cancelFixedLayoutReadyFallback() - RDEPUBWebViewDebug.log(debugScope, message: "rendered spine=\(currentSpineIndex) href=\(currentHref) fixed=\(isFixedLayout)") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in - guard let self else { return } - self.delegate?.epubWebViewDidFinishRendering(self) - self.onRendered?() - } - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebViewDebug.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebViewDebug.swift deleted file mode 100644 index 9486bdb..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebViewDebug.swift +++ /dev/null @@ -1,106 +0,0 @@ - -import Foundation -import WebKit - -enum RDEPUBWebViewDebug { - - static var isEnabled: Bool = { - if let configured = UserDefaults.standard.object(forKey: "RDEPUBWebViewDebugEnabled") as? Bool { - return configured - } -#if DEBUG - return true -#else - return false -#endif - }() - - static var isVerboseEnabled: Bool = { - if let configured = UserDefaults.standard.object(forKey: "RDEPUBWebViewVerboseEnabled") as? Bool { - return configured - } - return false - }() - - static var isInspectableEnabled: Bool = { - if let configured = UserDefaults.standard.object(forKey: "RDEPUBInspectableWebViewsEnabled") as? Bool { - return configured - } - return false - }() - - static func applyDebugPolicy(inspectableEnabled: Bool, verboseLoggingEnabled: Bool) { - isInspectableEnabled = inspectableEnabled - isVerboseEnabled = verboseLoggingEnabled - } - - static func webViewID(_ webView: WKWebView?) -> String { - guard let webView else { return "nil-webview" } - return String(ObjectIdentifier(webView).hashValue, radix: 16) - } - - static func log(_ scope: String, message: String) { - guard isEnabled else { return } - print("[RDEpubReaderWK][\(scope)] \(message)") - } - - static func logNavigationEvent(_ scope: String, webView: WKWebView?, event: String, url: URL? = nil, error: Error? = nil) { - guard isEnabled else { return } - let webViewToken = webViewID(webView) - let urlText = summarizedURL(url) - if let error { - log(scope, message: "webView=\(webViewToken) event=\(event) url=\(urlText) error=\(error.localizedDescription)") - } else { - log(scope, message: "webView=\(webViewToken) event=\(event) url=\(urlText)") - } - } - - static func logJavaScript(_ scope: String, webView: WKWebView?, action: String, details: String) { - guard isEnabled else { return } - log(scope, message: "webView=\(webViewID(webView)) js=\(action) \(details)") - } - - static func logMessage(_ scope: String, webView: WKWebView?, name: String, body: Any) { - guard isEnabled else { return } - if isVerboseEnabled { - log(scope, message: "webView=\(webViewID(webView)) message=\(name) body=\(String(describing: body))") - return - } - if let dict = body as? [String: Any] { - let keys = dict.keys.sorted().joined(separator: ",") - var meta = "keys=[\(keys)]" - if let text = dict["text"] as? String { - meta += " textLen=\(text.count)" - } - if let urlString = dict["url"] as? String ?? dict["href"] as? String { - meta += " url=\(summarizedURL(URL(string: urlString)))" - } - log(scope, message: "webView=\(webViewID(webView)) message=\(name) \(meta)") - } else { - log(scope, message: "webView=\(webViewID(webView)) message=\(name) type=\(Swift.type(of: body))") - } - } - - static func logSchemeTask(_ scope: String, requestURL: URL?, fileURL: URL? = nil, event: String, error: Error? = nil) { - guard isEnabled else { return } - let requestText = summarizedURL(requestURL) - let fileText = summarizedURL(fileURL) - if let error { - log(scope, message: "scheme=\(event) request=\(requestText) file=\(fileText) error=\(error.localizedDescription)") - } else { - log(scope, message: "scheme=\(event) request=\(requestText) file=\(fileText)") - } - } - - static func summarizedURL(_ url: URL?) -> String { - guard let url else { return "nil" } - if let scheme = url.scheme, scheme == RDEPUBResourceURLSchemeHandler.scheme { - return url.absoluteString - } - let path = url.path - if path.isEmpty { - return url.absoluteString - } - return url.lastPathComponent.isEmpty ? path : url.lastPathComponent - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/WeReadApi.js b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/WeReadApi.js deleted file mode 100644 index 521cf11..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/WeReadApi.js +++ /dev/null @@ -1,67 +0,0 @@ -(function() { - if (window.WeReadApi) { return; } - - function bridge() { - return window.RDEpubReaderBridge || null; - } - - function sharedThemeCSS(theme) { - if (!theme) { return ''; } - var backgroundColor = theme.backgroundColor || 'transparent'; - var textColor = theme.textColor || 'inherit'; - return [ - ':root {', - ' color-scheme: light;', - '}', - 'html, body {', - ' background: ' + backgroundColor + ' !important;', - ' color: ' + textColor + ' !important;', - '}', - 'a {', - ' -webkit-tap-highlight-color: transparent;', - '}' - ].join('\n'); - } - - window.WeReadApi = { - applySharedTheme: function(theme) { - var css = sharedThemeCSS(theme); - if (window.RDInjectedCSS) { - window.RDInjectedCSS.setStyle('ss-reader-theme', css, { - includeFrames: !!(theme && theme.includeFrames) - }); - } - return true; - }, - applyPagination: function(styleText) { - return !!(bridge() && bridge().applyPagination(styleText)); - }, - setPageMetrics: function(pageCount, pageStride) { - return !!(bridge() && bridge().setPageMetrics(pageCount, pageStride)); - }, - scrollToPage: function(pageIndex) { - return !!(bridge() && bridge().scrollToPage(pageIndex)); - }, - scrollToLocation: function(location, fallbackPageIndex) { - return !!(bridge() && bridge().scrollToLocation(location, fallbackPageIndex)); - }, - setHighlights: function(items) { - return !!(bridge() && bridge().setHighlights(items)); - }, - clearHighlights: function() { - return !!(bridge() && bridge().clearHighlights()); - }, - setSearchPresentation: function(payload) { - return !!(bridge() && bridge().setSearchPresentation(payload)); - }, - resolveDecorations: function() { - return bridge() ? bridge().resolveDecorations() : { highlights: [], search: [] }; - }, - reportProgression: function(fragment) { - return !!(bridge() && bridge().reportProgression(fragment)); - }, - selectionPayload: function() { - return bridge() ? bridge().selectionPayload() : null; - } - }; -})(); diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/cssInjector.js b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/cssInjector.js deleted file mode 100644 index 9517c26..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/cssInjector.js +++ /dev/null @@ -1,45 +0,0 @@ -(function() { - if (window.RDInjectedCSS) { return; } - - function applyStyle(doc, identifier, cssText) { - if (!doc || !doc.head) { return; } - var style = doc.getElementById(identifier); - if (!style) { - style = doc.createElement('style'); - style.id = identifier; - doc.head.appendChild(style); - } - style.textContent = cssText || ''; - } - - function walkDocuments(rootDoc, includeFrames, visitor) { - if (!rootDoc) { return; } - visitor(rootDoc); - if (!includeFrames || !rootDoc.querySelectorAll) { return; } - Array.prototype.forEach.call(rootDoc.querySelectorAll('iframe'), function(frame) { - try { - if (!frame.contentDocument) { return; } - walkDocuments(frame.contentDocument, includeFrames, visitor); - } catch (error) { - } - }); - } - - window.RDInjectedCSS = { - setStyle: function(identifier, cssText, options) { - var includeFrames = !!(options && options.includeFrames); - walkDocuments(document, includeFrames, function(doc) { - applyStyle(doc, identifier, cssText); - }); - }, - removeStyle: function(identifier, options) { - var includeFrames = !!(options && options.includeFrames); - walkDocuments(document, includeFrames, function(doc) { - var style = doc.getElementById(identifier); - if (style && style.parentNode) { - style.parentNode.removeChild(style); - } - }); - } - }; -})(); diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/epub-bridge.js b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/epub-bridge.js deleted file mode 100644 index f59c67e..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/epub-bridge.js +++ /dev/null @@ -1,550 +0,0 @@ - (function() { - if (window.RDEpubReaderBridge) { return; } - window.addEventListener('error', function(event) { - try { - var message = event && (event.message || (event.error && event.error.message)) || 'Unknown JavaScript error'; - window.webkit.messageHandlers.{{JAVASCRIPT_ERROR_MESSAGE}}.postMessage(message); - } catch (error) { - } - }); - function serializeRange(range) { - if (window.rangy && typeof window.rangy.serializeRange === 'function') { - return window.rangy.serializeRange(range, range && range.startContainer && range.startContainer.ownerDocument); - } - return null; - } - function rangeFromInfo(rangeInfo) { - if (window.rangy && typeof window.rangy.deserializeRange === 'function') { - return window.rangy.deserializeRange(rangeInfo, document); - } - return null; - } - var visiblePageIndex = 0; - var configuredPageCount = 1; - var configuredPageStride = 1; - var relayoutTimer = null; - var fixedLayoutDocument = false; - function isFixedLayoutDocument() { - if (fixedLayoutDocument) { return true; } - fixedLayoutDocument = !!document.getElementById('ss-fixed-root') || !!document.querySelector('.ss-fixed-page'); - return fixedLayoutDocument; - } - function ensurePaginationStructure() { - if (isFixedLayoutDocument()) { - return null; - } - var viewport = document.getElementById('ss-reader-viewport'); - var content = document.getElementById('ss-reader-content'); - if (viewport && content) { - return content; - } - - viewport = document.createElement('div'); - viewport.id = 'ss-reader-viewport'; - content = document.createElement('div'); - content.id = 'ss-reader-content'; - - while (document.body.firstChild) { - content.appendChild(document.body.firstChild); - } - - viewport.appendChild(content); - document.body.appendChild(viewport); - return content; - } - function paginationContent() { - if (isFixedLayoutDocument()) { - return null; - } - return ensurePaginationStructure(); - } - function effectivePageStride() { - return Math.max(1, configuredPageStride || 1); - } - function measuredPageCount() { - if (isFixedLayoutDocument()) { - return 1; - } - var content = paginationContent(); - var scrollingElement = document.scrollingElement || document.documentElement || document.body; - var scrollWidth = Math.max( - content ? content.scrollWidth : 0, - content ? content.offsetWidth : 0, - scrollingElement ? scrollingElement.scrollWidth : 0, - document.documentElement ? document.documentElement.scrollWidth : 0, - document.body ? document.body.scrollWidth : 0, - effectivePageStride() - ); - return Math.max(1, Math.ceil(scrollWidth / effectivePageStride())); - } - function totalPageCount() { - return Math.max(configuredPageCount, measuredPageCount()); - } - function currentPageOffset() { - return Math.round(visiblePageIndex * effectivePageStride()); - } - function reportProgression(fragment) { - var pageCount = totalPageCount(); - var progression = pageCount <= 1 ? 0 : visiblePageIndex / Math.max(pageCount - 1, 1); - var lastProgression = progression; - window.webkit.messageHandlers.{{PROGRESSION_CHANGED_MESSAGE}}.postMessage({ - progression: progression, - lastProgression: lastProgression, - fragment: fragment || null - }); - } - function setVisiblePageIndex(pageIndex) { - if (isFixedLayoutDocument()) { - visiblePageIndex = 0; - return; - } - var pageCount = totalPageCount(); - var maxPageIndex = Math.max(0, pageCount - 1); - var safePageIndex = Math.max(0, Math.min(maxPageIndex, Math.round(pageIndex || 0))); - visiblePageIndex = safePageIndex; - var content = paginationContent(); - if (content) { - content.style.transform = 'translate3d(' + (-currentPageOffset()) + 'px, 0, 0)'; - } - } - function scheduleRelayout() { - if (isFixedLayoutDocument()) { return; } - clearTimeout(relayoutTimer); - relayoutTimer = setTimeout(function() { - setVisiblePageIndex(visiblePageIndex); - reportProgression(null); - }, 60); - } - function pageIndexForElement(target) { - if (isFixedLayoutDocument()) { return 0; } - if (!target) { return 0; } - var rect = target.getBoundingClientRect(); - var absoluteLeft = Math.max(0, rect.left + currentPageOffset()); - return Math.max(0, Math.floor(absoluteLeft / effectivePageStride())); - } - function fragmentTarget(fragment) { - if (!fragment) { return null; } - var contexts = documentContexts(document, 0, 0); - for (var index = 0; index < contexts.length; index += 1) { - var context = contexts[index]; - var target = context.doc.getElementById(fragment) - || context.doc.querySelector('[name="' + fragment.replace(/"/g, '\\"') + '"]'); - if (target) { - return { - context: context, - target: target - }; - } - } - return null; - } - var storedHighlights = []; - var storedSearchPresentation = null; - function normalizeHref(rawHref) { - if (!rawHref) { return ''; } - var value = String(rawHref); - value = value.replace(/^.*?:\/\/[^/]+\//, ''); - value = value.split('#')[0]; - value = value.replace(/^\/+/, ''); - try { - value = decodeURIComponent(value); - } catch (error) { - } - return value; - } - function clearHighlights() { - storedHighlights = []; - } - function setHighlights(items) { - storedHighlights = Array.isArray(items) ? items.slice() : []; - } - function setSearchPresentation(payload) { - storedSearchPresentation = payload || null; - } - function textNodes(doc) { - if (!doc || !doc.body) { return []; } - var walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT, { - acceptNode: function(node) { - if (!node || !node.nodeValue || !node.nodeValue.trim()) { - return NodeFilter.FILTER_REJECT; - } - var parent = node.parentNode; - if (!parent || !parent.nodeName) { - return NodeFilter.FILTER_REJECT; - } - var name = parent.nodeName.toLowerCase(); - if (['script', 'style', 'noscript'].indexOf(name) >= 0) { - return NodeFilter.FILTER_REJECT; - } - return NodeFilter.FILTER_ACCEPT; - } - }); - var nodes = []; - while (walker.nextNode()) { - nodes.push(walker.currentNode); - } - return nodes; - } - function documentContexts(rootDoc, baseOffsetX, baseOffsetY) { - var items = []; - if (!rootDoc) { return items; } - var offsetX = baseOffsetX || 0; - var offsetY = baseOffsetY || 0; - items.push({ - doc: rootDoc, - href: normalizeHref(rootDoc.location && rootDoc.location.href), - offsetX: offsetX, - offsetY: offsetY - }); - var frames = rootDoc.querySelectorAll ? rootDoc.querySelectorAll('iframe') : []; - frames.forEach(function(frame) { - try { - if (!frame.contentDocument) { return; } - var frameRect = frame.getBoundingClientRect(); - documentContexts( - frame.contentDocument, - offsetX + frameRect.left, - offsetY + frameRect.top - ).forEach(function(item) { - items.push(item); - }); - } catch (error) { - } - }); - return items; - } - var hookedDocuments = new WeakSet(); - var hookedFrames = new WeakSet(); - function selectionForDocument(doc) { - if (!doc) { return null; } - try { - var view = doc.defaultView || window; - return view.getSelection ? view.getSelection() : null; - } catch (error) { - return null; - } - } - function currentSelectionContext() { - var contexts = documentContexts(document, 0, 0); - for (var index = 0; index < contexts.length; index += 1) { - var context = contexts[index]; - var selection = selectionForDocument(context.doc); - if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { - continue; - } - var text = selection.toString ? selection.toString() : ''; - if (!text || !text.trim()) { - continue; - } - return { - context: context, - selection: selection - }; - } - return null; - } - function rectPayloadForRange(range, offsetX, offsetY) { - if (!range || range.collapsed) { return []; } - var rects = []; - Array.prototype.forEach.call(range.getClientRects(), function(rect) { - if (!rect || rect.width < 0.5 || rect.height < 0.5) { return; } - rects.push({ - x: rect.left + (offsetX || 0), - y: rect.top + (offsetY || 0), - width: rect.width, - height: rect.height - }); - }); - return rects; - } - function collectHighlightDecorations() { - return storedHighlights.map(function(item, index) { - if (!item || !item.rangeInfo) { return null; } - var range = rangeFromInfo(item.rangeInfo); - var rects = rectPayloadForRange(range, 0, 0); - if (!rects.length) { return null; } - return { - key: item.id || ('highlight-' + index), - kind: item.style === 'underline' ? 'underline' : 'highlight', - color: item.color || '#F8E16C', - rangeInfo: item.rangeInfo, - rects: rects - }; - }).filter(Boolean); - } - function collectSearchDecorations() { - if (!storedSearchPresentation || !storedSearchPresentation.keyword) { - return []; - } - - var resources = new Map(); - if (Array.isArray(storedSearchPresentation.resources)) { - storedSearchPresentation.resources.forEach(function(resource) { - resources.set(normalizeHref(resource.href), resource); - }); - } - - var keyword = String(storedSearchPresentation.keyword); - var lowerKeyword = keyword.toLowerCase(); - var decorations = []; - - documentContexts(document, 0, 0).forEach(function(context) { - var resourceState = resources.get(context.href); - if (!resourceState || resourceState.matchCount <= 0) { - return; - } - - var activeIndex = typeof resourceState.activeLocalMatchIndex === 'number' ? resourceState.activeLocalMatchIndex : null; - var currentLocalIndex = 0; - var activeRect = null; - var contextStartIndex = decorations.length; - - textNodes(context.doc).forEach(function(node) { - var source = node.nodeValue || ''; - var lowerSource = source.toLowerCase(); - var cursor = 0; - - while (cursor < source.length) { - var foundIndex = lowerSource.indexOf(lowerKeyword, cursor); - if (foundIndex < 0) { - break; - } - - var range = context.doc.createRange(); - range.setStart(node, foundIndex); - range.setEnd(node, foundIndex + keyword.length); - var rects = rectPayloadForRange(range, context.offsetX, context.offsetY); - if (rects.length) { - var isActive = currentLocalIndex === activeIndex; - decorations.push({ - key: context.href + ':' + currentLocalIndex, - kind: isActive ? 'activeSearch' : 'search', - rects: rects - }); - if (isActive) { - activeRect = rects[0]; - } - } - - currentLocalIndex += 1; - cursor = foundIndex + keyword.length; - } - }); - - if (activeRect && !isFixedLayoutDocument()) { - var previousPageIndex = visiblePageIndex; - var activePageIndex = Math.max(0, Math.floor(Math.max(0, activeRect.x + currentPageOffset()) / effectivePageStride())); - setVisiblePageIndex(activePageIndex); - var deltaX = (activePageIndex - previousPageIndex) * effectivePageStride(); - if (deltaX !== 0) { - for (var index = contextStartIndex; index < decorations.length; index += 1) { - decorations[index].rects = decorations[index].rects.map(function(rect) { - return { - x: rect.x - deltaX, - y: rect.y, - width: rect.width, - height: rect.height - }; - }); - } - } - } - }); - return decorations; - } - function resolveDecorations() { - return { - highlights: collectHighlightDecorations(), - search: collectSearchDecorations() - }; - } - function selectionPayload() { - var payloadContext = currentSelectionContext(); - if (!payloadContext) { - return null; - } - var range = payloadContext.selection.getRangeAt(0); - var text = payloadContext.selection.toString(); - if (!text || !text.trim()) { return null; } - var pageCount = totalPageCount(); - var progression = pageCount <= 1 ? 0 : visiblePageIndex / Math.max(pageCount - 1, 1); - var lastProgression = progression; - return { - href: payloadContext.context.href || null, - text: text, - rangeInfo: serializeRange(range), - progression: progression, - lastProgression: lastProgression - }; - } - var selectionTimer = null; - function notifySelectionChange() { - clearTimeout(selectionTimer); - selectionTimer = setTimeout(function() { - var payload = selectionPayload(); - window.webkit.messageHandlers.{{SELECTION_CHANGED_MESSAGE}}.postMessage(payload); - }, 120); - } - function handleDocumentClick(event) { - // Check for image tap. Footnote images always show the note popup; regular - // images inside links are left to the link handler below. - var img = event.target; - while (img && img.tagName !== 'IMG') { - img = img.parentElement; - } - if (img && img.tagName === 'IMG') { - var src = img.getAttribute('src'); - var classes = (img.getAttribute('class') || '').toLowerCase(); - var alt = (img.getAttribute('alt') || '').trim(); - var isFootnote = classes.indexOf('qqreader-footnote') >= 0 || alt.length > 0; - if (isFootnote) { - event.preventDefault(); - event.stopPropagation(); - var footnoteRect = img.getBoundingClientRect(); - window.webkit.messageHandlers.{{FOOTNOTE_DID_TAP_MESSAGE}}.postMessage({ - alt: alt, - rect: { x: footnoteRect.x, y: footnoteRect.y, width: footnoteRect.width, height: footnoteRect.height } - }); - return; - } - - // If a regular image is inside a link, let the link handler deal with it. - var linkParent = img.closest ? img.closest('a[href]') : null; - if (!linkParent) { - if (src) { - event.preventDefault(); - event.stopPropagation(); - var rect = img.getBoundingClientRect(); - window.webkit.messageHandlers.{{IMAGE_DID_TAP_MESSAGE}}.postMessage({ - src: src, - rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height } - }); - return; - } - } - } - var anchor = event.target.closest ? event.target.closest('a[href]') : null; - if (!anchor) { return; } - var href = anchor.getAttribute('href'); - if (!href) { return; } - if (/^(https?:|mailto:|tel:)/i.test(href)) { - event.preventDefault(); - window.webkit.messageHandlers.{{EXTERNAL_LINK_MESSAGE}}.postMessage({ url: href }); - return; - } - event.preventDefault(); - window.webkit.messageHandlers.{{INTERNAL_LINK_MESSAGE}}.postMessage({ href: href }); - } - function installDocumentHooks(doc) { - if (!doc || hookedDocuments.has(doc)) { return; } - hookedDocuments.add(doc); - doc.addEventListener('selectionchange', notifySelectionChange); - doc.addEventListener('click', handleDocumentClick, true); - } - function installFrameHooks(rootDoc) { - if (!rootDoc || !rootDoc.querySelectorAll) { return; } - Array.prototype.forEach.call(rootDoc.querySelectorAll('iframe'), function(frame) { - if (hookedFrames.has(frame)) { return; } - hookedFrames.add(frame); - var hookFrameDocument = function() { - try { - if (!frame.contentDocument) { return; } - installDocumentHooks(frame.contentDocument); - installFrameHooks(frame.contentDocument); - } catch (error) { - } - }; - frame.addEventListener('load', function() { - hookFrameDocument(); - scheduleRelayout(); - }, { passive: true }); - hookFrameDocument(); - }); - } - installDocumentHooks(document); - installFrameHooks(document); - window.addEventListener('resize', function() { - if (isFixedLayoutDocument()) { return; } - setVisiblePageIndex(visiblePageIndex); - reportProgression(null); - }, { passive: true }); - window.addEventListener('load', scheduleRelayout, { passive: true }); - if (!isFixedLayoutDocument() && document.fonts && document.fonts.ready) { - document.fonts.ready.then(scheduleRelayout).catch(function() {}); - } - if (!isFixedLayoutDocument()) { - Array.prototype.forEach.call(document.querySelectorAll('img, iframe, video'), function(node) { - node.addEventListener('load', scheduleRelayout, { passive: true }); - node.addEventListener('error', scheduleRelayout, { passive: true }); - }); - } - window.RDEpubReaderBridge = { - applyPagination: function(styleText) { - if (isFixedLayoutDocument()) { return; } - if (window.RDInjectedCSS) { - window.RDInjectedCSS.setStyle('ss-reader-pagination', styleText, { includeFrames: false }); - } else { - var style = document.getElementById('ss-reader-pagination'); - if (!style) { - style = document.createElement('style'); - style.id = 'ss-reader-pagination'; - document.head.appendChild(style); - } - style.textContent = styleText; - } - ensurePaginationStructure(); - installFrameHooks(document); - }, - setPageMetrics: function(pageCount, pageStride) { - if (isFixedLayoutDocument()) { return; } - configuredPageCount = Math.max(1, Math.round(pageCount || 1)); - configuredPageStride = Math.max(1, Number(pageStride) || configuredPageStride || 1); - setVisiblePageIndex(visiblePageIndex); - scheduleRelayout(); - }, - scrollToPage: function(pageIndex) { - if (isFixedLayoutDocument()) { return; } - setVisiblePageIndex(pageIndex); - reportProgression(null); - }, - scrollToLocation: function(location, fallbackPageIndex, targetRangeInfo) { - if (targetRangeInfo) { - var highlightRange = rangeFromInfo(targetRangeInfo); - var highlightRects = rectPayloadForRange(highlightRange, 0, 0); - if (highlightRects.length) { - var highlightRect = highlightRects[0]; - var highlightAbsoluteLeft = Math.max(0, highlightRect.x + currentPageOffset()); - setVisiblePageIndex(Math.max(0, Math.floor(highlightAbsoluteLeft / effectivePageStride()))); - reportProgression(location && location.fragment ? location.fragment : null); - return; - } - } - if (location && location.fragment) { - var resolvedTarget = fragmentTarget(location.fragment); - if (resolvedTarget) { - if (isFixedLayoutDocument()) { - reportProgression(location.fragment); - return; - } - var rect = resolvedTarget.target.getBoundingClientRect(); - var absoluteLeft = Math.max(0, rect.left + resolvedTarget.context.offsetX + currentPageOffset()); - setVisiblePageIndex(Math.max(0, Math.floor(absoluteLeft / effectivePageStride()))); - reportProgression(location.fragment); - return; - } - } - var pageCount = totalPageCount(); - var progression = location && typeof location.progression === 'number' ? location.progression : 0; - var derivedPageIndex = progression > 0 && pageCount > 1 - ? Math.round(progression * Math.max(pageCount - 1, 0)) - : (fallbackPageIndex || 0); - this.scrollToPage(derivedPageIndex); - }, - setHighlights: setHighlights, - clearHighlights: clearHighlights, - setSearchPresentation: setSearchPresentation, - resolveDecorations: resolveDecorations, - reportProgression: reportProgression, - selectionPayload: selectionPayload - }; -})(); diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/epub-fixed-layout.html b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/epub-fixed-layout.html deleted file mode 100644 index ddcc026..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/epub-fixed-layout.html +++ /dev/null @@ -1,266 +0,0 @@ - - - - - - - -
{{PANES}}
- - - diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/rangy-core.js b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/rangy-core.js deleted file mode 100644 index d1f4a9f..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/rangy-core.js +++ /dev/null @@ -1,13 +0,0 @@ -(function() { - if (window.rangy) { return; } - - window.rangy = { - initialized: true, - init: function() { - return window.rangy; - }, - createRange: function(doc) { - return (doc || document).createRange(); - } - }; -})(); diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/rangy-serializer.js b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/rangy-serializer.js deleted file mode 100644 index ff74015..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/rangy-serializer.js +++ /dev/null @@ -1,93 +0,0 @@ -(function() { - if (!window.rangy) { - window.rangy = {}; - } - if (window.rangy.serializeRange && window.rangy.deserializeRange) { - return; - } - - function normalizeHref(rawHref) { - if (!rawHref) { return ''; } - var value = String(rawHref); - value = value.replace(/^.*?:\/\/[^/]+\//, ''); - value = value.split('#')[0]; - value = value.replace(/^\/+/, ''); - try { - value = decodeURIComponent(value); - } catch (error) { - } - return value; - } - - function nodePath(node) { - var path = []; - var current = node; - while (current && current !== current.ownerDocument) { - var parent = current.parentNode; - if (!parent) { break; } - var index = Array.prototype.indexOf.call(parent.childNodes, current); - path.unshift(index); - current = parent; - } - return path; - } - - function nodeFromPath(doc, path) { - var current = doc; - for (var index = 0; index < path.length; index += 1) { - if (!current || !current.childNodes || current.childNodes.length <= path[index]) { - return null; - } - current = current.childNodes[path[index]]; - } - return current; - } - - function documentContexts(rootDoc) { - var items = []; - if (!rootDoc) { return items; } - items.push(rootDoc); - var frames = rootDoc.querySelectorAll ? rootDoc.querySelectorAll('iframe') : []; - Array.prototype.forEach.call(frames, function(frame) { - try { - if (!frame.contentDocument) { return; } - documentContexts(frame.contentDocument).forEach(function(item) { - items.push(item); - }); - } catch (error) { - } - }); - return items; - } - - window.rangy.serializeRange = function(range, doc) { - var ownerDocument = doc || (range && range.startContainer && range.startContainer.ownerDocument) || document; - return JSON.stringify({ - kind: 'dom-range', - documentHref: normalizeHref(ownerDocument.location && ownerDocument.location.href), - startPath: nodePath(range.startContainer), - startOffset: range.startOffset, - endPath: nodePath(range.endContainer), - endOffset: range.endOffset - }); - }; - - window.rangy.deserializeRange = function(rangeInfo, rootDoc) { - try { - var payload = typeof rangeInfo === 'string' ? JSON.parse(rangeInfo) : rangeInfo; - var documentHref = normalizeHref(payload.documentHref); - var resolvedDocument = (documentContexts(rootDoc || document).find(function(candidate) { - return normalizeHref(candidate.location && candidate.location.href) === documentHref; - })) || (rootDoc || document); - var startNode = nodeFromPath(resolvedDocument, payload.startPath || []); - var endNode = nodeFromPath(resolvedDocument, payload.endPath || []); - if (!startNode || !endNode) { return null; } - var range = resolvedDocument.createRange(); - range.setStart(startNode, payload.startOffset || 0); - range.setEnd(endNode, payload.endOffset || 0); - return range; - } catch (error) { - return null; - } - }; -})(); diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/wxread-dark.css b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/wxread-dark.css deleted file mode 100644 index cb21780..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/wxread-dark.css +++ /dev/null @@ -1,56 +0,0 @@ -/* 适配搜狗百科 bar 里的 icon 被染色 */ -.header-home, .header-home:before, .header-logo, .loginBox, .header-search, .header-search:before, .header-more:before { - background-color: transparent !important; -} - -/* 适配搜狗百科 某些透明的 div 被染色导致挡住图片 */ -.abstract-img-none { - background: none !important; -} - -/* 适配百度搜索图片丢失 */ -.c-touchable-feedback-no-default * { - background-color: transparent !important; -} - -img, video { - z-index: 1 !important; -} - -/*背景纯黑*/ -*, *:before, *:after { - background-color: rgba(0, 0, 0, 1) !important; -} - -.wr-business-container, -.wr-business-container *, -.wr-business-container *:before, -.wr-business-container *:after { - background-color: rgb(28, 28, 29) !important; -} - -/*背景颜色和一般字体颜色*/ -div, h1, h2, h3, h4, h5, h6, p, body, em, html, link, textarea, form, select, input, span, button, em, menu, aside, table, tr, td, nav, dl, dt, dd, amp-iframe, main, section { - color: rgba(180, 180, 182, 1) !important; - border-color: #555555 !important; - text-shadow: 0 0 0 #000; -} - -/*超链接*/ -a { - color: rgba(84, 127, 176, 1) !important; -} - -blockquote { - color : rgb(218, 220, 224); - background-color : rgb(10, 14, 18) !important; - border-left: 1px solid rgba(116, 120, 124, 1); -} - -img { - border: none !important; -} - -ul li:before, ol li:before { - background-color: rgba(196, 200, 204, 1) !important; -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/wxread-default.css b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/wxread-default.css deleted file mode 100644 index 49323e0..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/wxread-default.css +++ /dev/null @@ -1,221 +0,0 @@ -/* this file is processed with xxd via a build rule and embedded in library */ - -/* these styles come from Safari */ - -/* note that comments are only permitted before selectors and before the styles */ - -/* DO NOT fiddle with this file if you want to have your own styles, - pass your own stylesheet via the option parameter to override these defaults */ - -head { - display:none; -} - -title { - display:none; -} - -style { - display:none; -} - -link { - display: none; -} - -meta { - display: none; -} - -script { - display: none; -} - -html { - display:block; - margin:0; - padding:0; -} - -body { - display:block; - font-size:16px; - margin:0; - padding:0; -} - -article,aside,footer,header,hgroup,nav,section { - display:block; -} - -p { - display:block; - margin:0; -} - -img { - margin:0; -} - -ul,ol,menu,dir { - display:block; - margin:1em 0 1em 0; - padding-left:24px; -} - -ul { - list-style-type:disc; -} - -ol { - list-style-type:decimal; -} - -li { - display:list-item; -} - -ul ul, ol ul { - list-style-type: circle; -} - -ol ol ul, ol ul ul, ul ol ul, ul ul ul { - list-style-type: square; -} - -code { - font-family:Courier; -} - -pre, xmp, plaintext, listing { - display: block; - font-family: monospace; - white-space: pre; - margin: 1em 0; -} - -a { - color:#2262A3; - text-decoration:underline; -} - -a:active { - color:#2262A3; -} - -center { - text-align:center; - display:block; -} - -strong,b { - font-weight:bold; -} - -i,em { - font-style:italic; -} - -u { - text-decoration:underline; -} - -big { - font-size:bigger; -} - -small { - font-size:smaller; -} - -sub { - font-size:smaller; - vertical-align:sub; -} - -sup { - font-size:smaller; - vertical-align:super; -} - -s,strike,del { - text-decoration:line-through; -} - -tt,code,kbd,samp { - font-family:monospace; -} - -pre,xmp,plaintext,listing { - display:block; - font-family:monospace; - white-space:pre; - margin-top:1em; - margin-right:0; - margin-bottom:1em; -} - -pre { - background-color:rgba(0,0,0,.05); - padding-top:1em; - padding-bottom:1em; - border-radius:0.3em; -} - -h1 { - display:block; - font-size:1.5em; - font-weight: normal; -} - -h2 { - display:block; - font-size:1.4em; - margin-top: 0.83em; -} - -h3 { - display:block; - font-size:1.3em; - margin-top: 1em; -} - -h4 { - display:block; - font-size:1.2em; - margin-top: 1.33em; -} - -h5 { - display:block; - font-size:1.1em; - margin-top: 1.67em; -} - -h6 { - display:block; - font-size:1em; - margin-top: 2.33em; -} - -div { - display: block; -} - -hr { - display: block; - margin:0.5em auto 0.5em auto; - border-style: inset; - border-width: 1px; -} - -table { - display: table; - border-collapse: separate; - border-spacing: 2px; - border-color: gray; -} - -blockquote { - display: block; -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/wxread-replace-latin.css b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/wxread-replace-latin.css deleted file mode 100644 index 318b912..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/wxread-replace-latin.css +++ /dev/null @@ -1,113 +0,0 @@ -/*
代码块,注意必须写font-weight使字体生效 */
-pre {
-    font-family: "Menlo";
-    font-weight: normal;
-    line-height: 1.5em;
-}
-
-/*版权信息*/
-.copyRightTitle {
-    color: black;
-    font-size: 1.5em;
-    font-family: "Source Han Serif CN";
-    font-weight: normal;
-}
-
-/*图片说明文字*/
-.eepub-single-image-title {
-    font-size: 0.75em;
-    text-align: center;
-    line-height: 1.4em;
-    color: rgba(0, 0, 0, 0.9);
-    margin: 0.2em 0.4em 1em 0.4em;
-    font-weight: normal;
-}
-
-/*标题*/
-.firstTitle, h1.firstTitle {
-    font-size: 1.5em;
-    font-weight: bold;
-    line-height: 1.25em;
-}
-.secondTitle, h2.secondTitle {
-    font-size: 1.4em;
-    font-weight: bold;
-    line-height: 1.35em;
-}
-.thirdTitle, h3.thirdTitle {
-    font-size: 1.3em;
-    font-weight: bold;
-    line-height: 1.5em;
-}
-.fourthTitle, h4.fourthTitle {
-    font-size: 1.2em;
-    font-weight: bold;
-    line-height: 1.65em;
-}
-.fifthTitle, h5.fifthTitle {
-    font-size: 1.1em;
-    font-weight: bold;
-    line-height: 1.85em;
-}
-.sixthTitle, h6.sixthTitle {
-    font-size: 1em;
-    font-weight: bold;
-    line-height: 2em;
-}
-
-/*首字加大*/
-/*浮动元素有默认的margin,所以这里会修复一下*/
-.ftext {
-    float: left;
-    margin: 0em;
-    font-size: 2.38em;
-    font-weight: normal;
-}
-
-/*引用内容*/
-.conQuot {
-    font-weight: normal;
-    margin: 0em 0em 0.2em 0em;
-}
-
-/*标题下来可能会有一行subHead*/
-.subHead{
-    text-indent: 2em;
-}
-
-pre, pre span, pre code {
-    font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace;
-    font-size:.7em!important;
-}
-
-.bodyPic {
-    wr-vertical-center-style: 2;
-}
-
-.qrbodyPic {
-    page-break-inside: avoid;
-    wr-vertical-center-style: 2;
-}
-
-/* 注标图 */
-.qqreader-footnote {
-    width: 1em;
-}
-
-/* 私有垂直居中类 */
-.wr-vertical-center {
-    wr-vertical-center-style: 1 !important;
-}
-
-/* 翻译 */
-.wr-translation {
-    line-height: 1.7em !important;
-}
-
-/* 章节结尾工具 */
-.book-chapter-tool {
-    display: block;
-    height: 78px;
-    width: 100%;
-    margin-top: 24px;
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/wxread-replace.css b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/wxread-replace.css
deleted file mode 100644
index 7c4909c..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/wxread-replace.css
+++ /dev/null
@@ -1,171 +0,0 @@
-/* 
代码块,注意必须写font-weight使字体生效 */
-pre {
-    font-family: "Menlo";
-    font-weight: normal;
-    line-height: 1.5em;
-}
-
-/*版权信息*/
-.copyRightTitle {
-    color: black;
-    font-size: 1.5em;
-    font-family: "Source Han Serif CN";
-    font-weight: normal;
-}
-
-/*图片说明文字*/
-.eepub-single-image-title {
-    font-size: 0.75em;
-    text-align: center;
-    line-height: 1.4em;
-    color: rgba(0, 0, 0, 0.9);
-    margin: 0.2em 0.4em 1em 0.4em;
-    font-family: "FZFSJW--GB1-0";
-    font-weight: normal;
-}
-
-/*标题*/
-.firstTitle, h1.firstTitle {
-    font-size: 1.5em;
-    font-family: "Source Han Serif CN";
-    font-weight: bold;
-    line-height: 1.25em;
-}
-.secondTitle, h2.secondTitle {
-    font-size: 1.4em;
-    font-family: "Source Han Serif CN";
-    font-weight: bold;
-    line-height: 1.35em;
-}
-.thirdTitle, h3.thirdTitle {
-    font-size: 1.3em;
-    font-family: "Source Han Serif CN";
-    font-weight: bold;
-    line-height: 1.5em;
-}
-.fourthTitle, h4.fourthTitle {
-    font-size: 1.2em;
-    font-family: "Source Han Serif CN";
-    font-weight: bold;
-    line-height: 1.65em;
-}
-.fifthTitle, h5.fifthTitle {
-    font-size: 1.1em;
-    font-family: "Source Han Serif CN";
-    font-weight: bold;
-    line-height: 1.85em;
-}
-.sixthTitle, h6.sixthTitle {
-    font-size: 1em;
-    font-family: "Source Han Serif CN";
-    font-weight: bold;
-    line-height: 2em;
-}
-
-/*首字加大*/
-/*浮动元素有默认的margin,所以这里会修复一下*/
-.ftext {
-    float: left;
-    margin: 0em;
-    font-size: 2.38em;
-    font-weight: normal;
-}
-
-/*引用内容*/
-.conQuot {
-    font-family: "FZFSJW--GB1-0";
-    font-weight: normal;
-    margin: 0em 0em 0.2em 0em;
-}
-
-/*标题下来可能会有一行subHead*/
-.subHead{
-    text-indent: 2em;
-}
-
-pre, pre span, pre code {
-    font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace;
-    font-size:.7em!important;
-}
-
-.bodyPic {
-    wr-vertical-center-style: 2;
-}
-
-.qrbodyPic {
-    page-break-inside: avoid;
-    wr-vertical-center-style: 2;
-}
-
-/* 注标图 */
-.qqreader-footnote {
-    width: 1em;
-}
-
-/* 翻译 */
-.wr-translation {
-    line-height: 1.7em !important;
-}
-
-/*-------------------- 小文章相关 ----------------------*/
-
-/*epub-custom-rule-6 如果 class="weread-page-relate" 在开头,需要把上一页末尾一行移到这一页*/
-.weread-page-relate {
-    weread-page-relate:true;
-}
-
-/*epub-custom-rule-29 小文章打赏的样式*/
-.chapter-reward {
-    display: block;
-    height: 120px;
-    width: 100%;
-    margin-top: 24px;
-}
-
-/*epub-custom-rule-7 小文章工具栏标签的样式*/
-.chapter-tool {
-    display: block;
-    height: 140px;
-    width: 100%;
-    margin-top: 24px;
-}
-
-/* 章节结尾工具 */
-.book-chapter-tool {
-    display: block;
-    height: 78px;
-    width: 100%;
-    margin-top: 24px;
-}
-
-/* 调试好样式后和安卓同步,交给后台随小文章 css 下发,并把下面的样式从replace.css 删除 */
-
-/* 安卓的re_bookItem是通过原生view留出上下空间的,但是iOS是通过css来控制 */
-.re_bookItem{
-    display: block;
-    margin: 18px 0 18px 0;
-}
-
-p {
-    text-align:justify;
-}
-
-img[data-image-size="large"] {
-    width: 100%;
-}
-
-/* 文集公众号文章不支持控件的样式 */
-.unsupported_iframe {
-    border-radius: 4px;
-    border: 1px solid #ffcdc0;
-    background: #fee;
-    padding: 12px 0px;
-    color: #ff8d8d;
-    text-align: center;
-    font-size: 14px;
-}
-
-/* 私有垂直居中类 */
-.wr-vertical-center {
-    wr-vertical-center-style: 1 !important;
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBBuildDiagnosticsReporter.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBBuildDiagnosticsReporter.swift
deleted file mode 100644
index e169d71..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBBuildDiagnosticsReporter.swift
+++ /dev/null
@@ -1,63 +0,0 @@
-import Foundation
-
-struct RDEPUBBuildDiagnosticsReporter {
-
-    func phase7SemanticSummary(
-        title: String?,
-        diagnostics: [RDEPUBTextChapterPaginationDiagnostic]
-    ) -> String? {
-
-        guard !diagnostics.isEmpty else { return nil }
-
-        let blockKinds = uniqueValues(from: diagnostics.flatMap(\.blockKinds))
-        let semanticHints = uniqueValues(from: diagnostics.flatMap(\.semanticHints))
-        let attachmentPlacements = uniqueValues(from: diagnostics.flatMap(\.attachmentPlacements))
-
-        let note = diagnostics
-            .flatMap(\.sampleNotes)
-            .first(where: { $0.contains("semantic") || $0.contains("attachment") || $0.contains("block kinds") })
-
-        var parts = [
-            title,
-            "章节 \(diagnostics.count)",
-            blockKinds.isEmpty ? nil : "block kinds [\(blockKinds.map(\.rawValue).joined(separator: ","))]",
-            semanticHints.isEmpty ? nil : "hints [\(semanticHints.map(\.rawValue).joined(separator: ","))]",
-            attachmentPlacements.isEmpty ? nil : "placements [\(attachmentPlacements.map(\.rawValue).joined(separator: ","))]"
-        ].compactMap { $0 }
-
-        if let note {
-            parts.append(note)
-        }
-        return parts.joined(separator: " · ")
-    }
-
-    func chapterDiagnostic(
-        href: String,
-        title: String,
-        pages: [RDEPUBTextPage]
-    ) -> RDEPUBTextChapterPaginationDiagnostic {
-        RDEPUBTextChapterPaginationDiagnostic(
-            href: href,
-            title: title,
-            pageCount: pages.count,
-            breakReasons: pages.map(\.metadata.breakReason),
-
-            attachmentPageCount: pages.filter { !$0.metadata.attachmentKinds.isEmpty }.count,
-
-            blockAdjustedPageCount: pages.filter { $0.metadata.breakReason == .blockBoundary || $0.metadata.breakReason == .attachmentBoundary }.count,
-            blockKinds: uniqueValues(from: pages.flatMap(\.metadata.blockKinds)),
-            semanticHints: uniqueValues(from: pages.flatMap(\.metadata.semanticHints)),
-            attachmentPlacements: uniqueValues(from: pages.flatMap(\.metadata.attachmentPlacements)),
-
-            sampleNotes: Array(pages.flatMap(\.metadata.diagnostics).prefix(4))
-        )
-    }
-
-    private func uniqueValues(from values: [T]) -> [T] {
-        values.reduce(into: [T]()) { result, value in
-            if !result.contains(value) {
-                result.append(value)
-            }
-        }
-    }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBChapterTailNormalizer.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBChapterTailNormalizer.swift
deleted file mode 100644
index 6a2b19f..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBChapterTailNormalizer.swift
+++ /dev/null
@@ -1,197 +0,0 @@
-import Foundation
-
-struct RDEPUBChapterTailNormalizer {
-
-    func normalize(
-        _ frames: [RDEPUBTextLayoutFrame],
-        content: NSAttributedString,
-        href: String
-    ) -> [RDEPUBTextLayoutFrame] {
-
-        guard frames.count > 1 else { return frames }
-
-        var normalized = frames
-
-        var compacted: [RDEPUBTextLayoutFrame] = []
-        compacted.reserveCapacity(normalized.count)
-        for frame in normalized {
-            if shouldDropWhitespaceOnlyFrame(frame, in: content) {
-                let note = "normalized: dropped whitespace-only intermediate page \(NSStringFromRange(frame.contentRange))"
-
-                if var previous = compacted.popLast() {
-                    previous.diagnostics.append(note)
-                    compacted.append(previous)
-                } else {
-                }
-                continue
-            }
-            compacted.append(frame)
-        }
-        normalized = compacted
-
-        while let lastFrame = normalized.last,
-              shouldDropWhitespaceOnlyFrame(lastFrame, in: content) {
-            normalized.removeLast()
-            let note = "normalized: dropped whitespace-only trailing page \(NSStringFromRange(lastFrame.contentRange))"
-            if var previousFrame = normalized.popLast() {
-                previousFrame.diagnostics.append(note)
-                normalized.append(previousFrame)
-            } else {
-            }
-        }
-
-        guard normalized.count > 1,
-              let lastFrame = normalized.last,
-              let previousFrame = normalized.dropLast().last,
-              shouldMergeShortTrailingFrame(lastFrame, previousFrame: previousFrame, in: content) else {
-            return normalized
-        }
-
-        let mergedFrame = mergeTrailingFrame(previousFrame, with: lastFrame)
-        normalized.removeLast(2)
-        normalized.append(mergedFrame)
-        return normalized
-    }
-
-    private func shouldDropWhitespaceOnlyFrame(
-        _ frame: RDEPUBTextLayoutFrame,
-        in content: NSAttributedString
-    ) -> Bool {
-        guard frame.contentRange.length > 0,
-              attachmentCount(in: content, range: frame.contentRange) == 0 else {
-            return false
-        }
-        return visibleCharacterCount(in: content, range: frame.contentRange) == 0
-    }
-
-    private func shouldMergeShortTrailingFrame(
-        _ trailingFrame: RDEPUBTextLayoutFrame,
-        previousFrame: RDEPUBTextLayoutFrame,
-        in content: NSAttributedString
-    ) -> Bool {
-
-        guard trailingFrame.contentRange.length > 0,
-              NSMaxRange(previousFrame.contentRange) == trailingFrame.contentRange.location else {
-            return false
-        }
-
-        let visibleCount = visibleCharacterCount(in: content, range: trailingFrame.contentRange)
-        let trailingAttachmentCount = attachmentCount(in: content, range: trailingFrame.contentRange)
-
-        guard visibleCount <= 2,
-              trailingFrame.contentRange.length <= 2,
-              visibleCount > 0 || trailingAttachmentCount > 0 else {
-            return false
-        }
-
-        guard !frameHasAvoidPageBreakInside(trailingFrame, in: content) else {
-            return false
-        }
-
-        let previousVisibleCount = visibleCharacterCount(in: content, range: previousFrame.contentRange)
-        return previousVisibleCount >= max(visibleCount * 8, 12)
-    }
-
-    private func frameHasAvoidPageBreakInside(
-        _ frame: RDEPUBTextLayoutFrame,
-        in content: NSAttributedString
-    ) -> Bool {
-        if frame.semanticHints.contains(.avoidPageBreakInside) {
-            return true
-        }
-
-        guard content.length > 0,
-              let safeRange = clampedRange(frame.contentRange, in: content),
-              safeRange.length > 0 else {
-            return false
-        }
-
-        var found = false
-        content.enumerateAttribute(.rdPageSemanticHints, in: safeRange) { value, _, stop in
-            guard let rawValue = value as? String else { return }
-            let hints = rawValue
-                .split(separator: ",")
-                .compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
-            if hints.contains(.avoidPageBreakInside) {
-                found = true
-                stop.pointee = true
-            }
-        }
-        return found
-    }
-
-    private func mergeTrailingFrame(
-        _ previousFrame: RDEPUBTextLayoutFrame,
-        with trailingFrame: RDEPUBTextLayoutFrame
-    ) -> RDEPUBTextLayoutFrame {
-
-        let mergedRange = NSRange(
-            location: previousFrame.contentRange.location,
-            length: NSMaxRange(trailingFrame.contentRange) - previousFrame.contentRange.location
-        )
-
-        return RDEPUBTextLayoutFrame(
-            contentRange: mergedRange,
-            breakReason: trailingFrame.breakReason,
-            blockRange: trailingFrame.blockRange ?? previousFrame.blockRange,
-            attachmentRanges: uniqueRanges(from: previousFrame.attachmentRanges + trailingFrame.attachmentRanges),
-            attachmentKinds: uniqueValues(from: previousFrame.attachmentKinds + trailingFrame.attachmentKinds),
-            blockKinds: uniqueValues(from: previousFrame.blockKinds + trailingFrame.blockKinds),
-            semanticHints: uniqueValues(from: previousFrame.semanticHints + trailingFrame.semanticHints),
-            attachmentPlacements: uniqueValues(from: previousFrame.attachmentPlacements + trailingFrame.attachmentPlacements),
-            trailingFragmentID: trailingFrame.trailingFragmentID ?? previousFrame.trailingFragmentID,
-
-            diagnostics: previousFrame.diagnostics
-                + trailingFrame.diagnostics
-                + ["normalized: merged short trailing page \(NSStringFromRange(trailingFrame.contentRange)) into previous page"]
-        )
-    }
-
-    private func visibleCharacterCount(
-        in content: NSAttributedString,
-        range: NSRange
-    ) -> Int {
-        guard range.length > 0 else { return 0 }
-        let string = content.attributedSubstring(from: range).string
-
-        let filteredScalars = string.unicodeScalars.filter { scalar in
-            !CharacterSet.whitespacesAndNewlines.contains(scalar)
-                && !CharacterSet.controlCharacters.contains(scalar)
-        }
-        return filteredScalars.count
-    }
-
-    private func attachmentCount(in content: NSAttributedString, range: NSRange) -> Int {
-        guard content.length > 0, range.length > 0 else { return 0 }
-        var count = 0
-        content.enumerateAttribute(.attachment, in: range) { value, _, _ in
-            if value != nil {
-                count += 1
-            }
-        }
-        return count
-    }
-
-    private func clampedRange(_ range: NSRange, in content: NSAttributedString) -> NSRange? {
-        guard range.location >= 0, range.length >= 0, range.location < content.length else {
-            return nil
-        }
-        return NSRange(location: range.location, length: min(range.length, content.length - range.location))
-    }
-
-    private func uniqueValues(from values: [T]) -> [T] {
-        values.reduce(into: [T]()) { result, value in
-            if !result.contains(value) {
-                result.append(value)
-            }
-        }
-    }
-
-    private func uniqueRanges(from ranges: [NSRange]) -> [NSRange] {
-        ranges.reduce(into: [NSRange]()) { result, value in
-            if !result.contains(value) {
-                result.append(value)
-            }
-        }
-    }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBPaginationCacheCoordinator.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBPaginationCacheCoordinator.swift
deleted file mode 100644
index d2a3cf8..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBPaginationCacheCoordinator.swift
+++ /dev/null
@@ -1,48 +0,0 @@
-import UIKit
-
-struct RDEPUBPaginationCacheCoordinator {
-
-    private let cache: RDEPUBTextBookCache?
-
-    private let layoutConfig: RDEPUBTextLayoutConfig
-
-    init(cache: RDEPUBTextBookCache?, layoutConfig: RDEPUBTextLayoutConfig) {
-        self.cache = cache
-        self.layoutConfig = layoutConfig
-    }
-
-    func cacheKey(
-        bookID: String,
-        pageSize: CGSize,
-        style: RDEPUBTextRenderStyle
-    ) -> String? {
-        guard let cache else { return nil }
-        return cache.cacheKey(
-            bookID: bookID,
-            fontSize: style.font.pointSize,
-            lineHeightMultiple: style.lineSpacing,
-            contentInsets: layoutConfig.edgeInsets,
-            pageSize: pageSize,
-            layoutConfigSignature: "\(layoutConfig.cacheSignature)|font=\(style.font.fontName)"
-        )
-    }
-
-    func load(key: String?) -> [String: RDEPUBTextChapterPaginationCache]? {
-        key.flatMap { cache?.load(key: $0) }
-    }
-
-    func save(chapters: [RDEPUBTextChapter], key: String?) {
-        guard let key else { return }
-
-        let paginationCache = chapters.map { chapter in
-            RDEPUBTextChapterPaginationCache(
-                href: chapter.href,
-                pageRanges: chapter.pages.map(\.contentRange),
-                breakReasons: chapter.pages.map(\.metadata.breakReason),
-
-                semanticHints: Array(Set(chapter.pages.flatMap(\.metadata.semanticHints)))
-            )
-        }
-        cache?.save(paginationCache, key: key)
-    }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookBuilder.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookBuilder.swift
deleted file mode 100644
index a57f381..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookBuilder.swift
+++ /dev/null
@@ -1,420 +0,0 @@
-import UIKit
-
-public final class RDEPUBTextBookBuilder {
-    private let renderer: RDEPUBTextRenderer
-    private let cache: RDEPUBTextBookCache?
-    private let layoutConfig: RDEPUBTextLayoutConfig
-    private let sampler: RDEPUBTextPerformanceSampler
-    private let renderPipeline: RDEPUBChapterRenderPipeline
-    private let paginationPipeline: RDEPUBChapterPaginationPipeline
-    private let tailNormalizer: RDEPUBChapterTailNormalizer
-    private let cacheCoordinator: RDEPUBPaginationCacheCoordinator
-    private let diagnosticsReporter: RDEPUBBuildDiagnosticsReporter
-
-    public private(set) var lastBuildResourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic] = []
-
-    public private(set) var lastBuildPaginationDiagnostics: [RDEPUBTextChapterPaginationDiagnostic] = []
-
-    public private(set) var lastBuildPerformanceSamples: [RDEPUBTextPerformanceSample] = []
-
-    public private(set) var lastBuildCacheStats: (hits: Int, misses: Int) = (0, 0)
-
-    public init(
-        renderer: RDEPUBTextRenderer,
-        cache: RDEPUBTextBookCache? = nil,
-        layoutConfig: RDEPUBTextLayoutConfig = .default
-    ) {
-        self.renderer = renderer
-        self.cache = cache
-        self.layoutConfig = layoutConfig
-        self.sampler = RDEPUBTextPerformanceSampler()
-        self.renderPipeline = RDEPUBChapterRenderPipeline(renderer: renderer)
-        self.paginationPipeline = RDEPUBChapterPaginationPipeline()
-        self.tailNormalizer = RDEPUBChapterTailNormalizer()
-        self.cacheCoordinator = RDEPUBPaginationCacheCoordinator(cache: cache, layoutConfig: layoutConfig)
-        self.diagnosticsReporter = RDEPUBBuildDiagnosticsReporter()
-    }
-
-    public convenience init() {
-        self.init(renderer: RDEPUBDTCoreTextRenderer())
-    }
-
-    private var isPaginationDebugEnabled: Bool {
-        ProcessInfo.processInfo.arguments.contains("--demo-pagination-debug")
-    }
-
-    public func phase7SemanticSummary(title: String? = nil) -> String? {
-        diagnosticsReporter.phase7SemanticSummary(
-            title: title,
-            diagnostics: lastBuildPaginationDiagnostics
-        )
-    }
-
-    public func build(
-        parser: RDEPUBParser,
-        publication: RDEPUBPublication,
-        pageSize: CGSize,
-        style: RDEPUBTextRenderStyle
-    ) throws -> RDEPUBTextBook {
-        if isPaginationDebugEnabled {
-            print("[PaginationDebug] build pageSize=\(NSCoder.string(for: pageSize)) layoutInsets=\(NSCoder.string(for: layoutConfig.edgeInsets))")
-        }
-        var chapters: [RDEPUBTextChapter] = []
-        var flatPages: [RDEPUBTextPage] = []
-        lastBuildResourceDiagnostics = []
-        lastBuildPaginationDiagnostics = []
-        lastBuildPerformanceSamples = []
-        lastBuildCacheStats = (0, 0)
-        sampler.reset()
-
-        let buildStart = CFAbsoluteTimeGetCurrent()
-
-        let bookID = publication.metadata.identifier ?? publication.metadata.title
-        let cacheKey = cacheCoordinator.cacheKey(bookID: bookID, pageSize: pageSize, style: style)
-        let cachedPagination = cacheCoordinator.load(key: cacheKey)
-
-        for (spineIndex, item) in publication.spine.enumerated() where item.linear {
-            guard let result = try buildChapter(
-                parser: parser,
-                publication: publication,
-                spineIndex: spineIndex,
-                pageSize: pageSize,
-                style: style,
-                chapterIndex: chapters.count,
-                absolutePageStartIndex: flatPages.count,
-                cachedPagination: cachedPagination
-            ) else { continue }
-
-            if isPaginationDebugEnabled,
-               item.href.contains("Chapter_3.xhtml") {
-                let pages = result.chapter.pages
-                print("[PaginationDebug] href=\(item.href) pages=\(pages.count)")
-                for page in pages {
-                    let preview = debugPreview(for: page.content, limit: 36)
-                    print("[PaginationDebug] absPage=\(page.absolutePageIndex + 1) localPage=\(page.pageIndexInChapter + 1) range=\(NSStringFromRange(page.contentRange)) break=\(page.metadata.breakReason.rawValue) preview=\(preview)")
-                    for note in page.metadata.diagnostics.prefix(4) {
-                        print("[PaginationDebug] note=\(note)")
-                    }
-                }
-            }
-
-            chapters.append(result.chapter)
-            flatPages.append(contentsOf: result.chapter.pages)
-            lastBuildResourceDiagnostics.append(contentsOf: result.resourceDiagnostics)
-            lastBuildPaginationDiagnostics.append(result.paginationDiagnostic)
-            sampler.record(result.performanceSample)
-            if result.cacheHit {
-                lastBuildCacheStats.hits += 1
-            } else {
-                lastBuildCacheStats.misses += 1
-            }
-        }
-
-        let book = RDEPUBTextBook(chapters: chapters, pages: flatPages)
-
-        sampler.totalBuildDuration = CFAbsoluteTimeGetCurrent() - buildStart
-
-        cacheCoordinator.save(chapters: chapters, key: cacheKey)
-
-        #if DEBUG
-        print(sampler.summary())
-        #endif
-        lastBuildPerformanceSamples = sampler.samples
-
-        return book
-    }
-
-    public func buildChapter(
-        parser: RDEPUBParser,
-        publication: RDEPUBPublication,
-        spineIndex: Int,
-        pageSize: CGSize,
-        style: RDEPUBTextRenderStyle,
-        chapterIndex: Int = 0,
-        absolutePageStartIndex: Int = 0
-    ) throws -> RDEPUBTextChapterBuildResult? {
-        let bookID = publication.metadata.identifier ?? publication.metadata.title
-        let cacheKey = cacheCoordinator.cacheKey(bookID: bookID, pageSize: pageSize, style: style)
-        let cachedPagination = cacheCoordinator.load(key: cacheKey)
-        return try buildChapter(
-            parser: parser,
-            publication: publication,
-            spineIndex: spineIndex,
-            pageSize: pageSize,
-            style: style,
-            chapterIndex: chapterIndex,
-            absolutePageStartIndex: absolutePageStartIndex,
-            cachedPagination: cachedPagination
-        )
-    }
-
-    private func buildChapter(
-        parser: RDEPUBParser,
-        publication: RDEPUBPublication,
-        spineIndex: Int,
-        pageSize: CGSize,
-        style: RDEPUBTextRenderStyle,
-        chapterIndex: Int,
-        absolutePageStartIndex: Int,
-        cachedPagination: [String: RDEPUBTextChapterPaginationCache]?
-    ) throws -> RDEPUBTextChapterBuildResult? {
-        guard publication.spine.indices.contains(spineIndex) else { return nil }
-        let item = publication.spine[spineIndex]
-        guard item.linear,
-              item.mediaType.contains("html") || item.mediaType.contains("xhtml"),
-              let rawHTML = parser.htmlString(forRelativePath: item.href) else {
-            return nil
-        }
-
-        let chapterTitle = resolvedChapterTitle(for: item, toc: publication.tableOfContents)
-        let request = RDEPUBTextTypesetterPipeline().makeRequest(
-            from: RDEPUBTypesettingInput(
-                href: item.href,
-                spineIndex: spineIndex,
-                title: chapterTitle,
-                rawHTML: rawHTML,
-                baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent(),
-                style: style,
-                resourceResolver: publication.resourceResolver,
-                contentLanguageCode: publication.metadata.language,
-                pageSize: pageSize,
-                layoutConfig: layoutConfig
-            )
-        ).request
-
-        let renderStart = CFAbsoluteTimeGetCurrent()
-        let rendered = try renderPipeline.render(request)
-        let renderDuration = CFAbsoluteTimeGetCurrent() - renderStart
-
-        let plainText = rendered.attributedString.string.trimmingCharacters(in: .whitespacesAndNewlines)
-        if shouldSkipChapter(item: item, content: rendered.attributedString, text: plainText) {
-            if item.href.lowercased().contains("cover") {
-            }
-            return nil
-        }
-
-        let content = NSMutableAttributedString(attributedString: rendered.attributedString)
-        let paginateStart = CFAbsoluteTimeGetCurrent()
-        let layoutFrames: [RDEPUBTextLayoutFrame]
-        let isCacheHit: Bool
-
-        if isAttachmentOnlyCoverChapter(item: item, content: content, plainText: plainText) {
-            layoutFrames = [
-                RDEPUBTextLayoutFrame(
-                    contentRange: NSRange(location: 0, length: content.length),
-                    breakReason: .chapterEnd,
-                    blockRange: nil,
-                    attachmentRanges: attachmentRanges(in: content),
-                    attachmentKinds: [],
-                    blockKinds: [],
-                    semanticHints: [],
-                    attachmentPlacements: [],
-                    trailingFragmentID: nil,
-                    diagnostics: [
-                        "page break: chapterEnd",
-                        "cover fallback: single attachment page",
-                        "page range: \(NSStringFromRange(NSRange(location: 0, length: content.length)))"
-                    ]
-                )
-            ]
-            isCacheHit = false
-        } else if let cached = cachedPagination?[item.href] {
-            layoutFrames = cached.pageRanges.enumerated().map { idx, range in
-                let breakReason = idx < cached.breakReasons.count ? cached.breakReasons[idx] : .frameLimit
-                return RDEPUBTextLayoutFrame(
-                    contentRange: range,
-                    breakReason: breakReason,
-                    blockRange: nil,
-                    attachmentRanges: [],
-                    attachmentKinds: [],
-                    blockKinds: [],
-                    semanticHints: cached.semanticHints,
-                    attachmentPlacements: [],
-                    trailingFragmentID: nil,
-                    diagnostics: [
-                        "page break: \(breakReason.rawValue)",
-                        "page range: \(NSStringFromRange(range))",
-                        "source: cache hit"
-                    ]
-                )
-            }
-            isCacheHit = true
-        } else {
-            layoutFrames = content.length > 0
-                ? paginationPipeline.frames(
-                    for: content,
-                    pageSize: pageSize,
-                    config: layoutConfig,
-                    fragmentOffsets: rendered.fragmentOffsets
-                )
-                : []
-            isCacheHit = false
-        }
-
-        let paginateDuration = CFAbsoluteTimeGetCurrent() - paginateStart
-
-        let normalizedFrames = tailNormalizer.normalize(
-            layoutFrames,
-            content: content,
-            href: item.href
-        )
-
-        let effectiveFrames = normalizedFrames.isEmpty && content.length > 0
-            ? [
-                RDEPUBTextLayoutFrame(
-                    contentRange: NSRange(location: 0, length: content.length),
-                    breakReason: .chapterEnd,
-                    blockRange: nil,
-                    attachmentRanges: [],
-                    attachmentKinds: [],
-                    blockKinds: [],
-                    semanticHints: [],
-                    attachmentPlacements: [],
-                    trailingFragmentID: nil,
-                    diagnostics: [
-                        "page break: chapterEnd",
-                        "page range: \(NSStringFromRange(NSRange(location: 0, length: content.length)))"
-                    ]
-                )
-            ]
-            : normalizedFrames
-
-        let chapterAttributedContent = content.copy() as! NSAttributedString
-        let pages = effectiveFrames.enumerated().map { localPageIndex, frame in
-            let range = frame.contentRange
-            return RDEPUBTextPage(
-                absolutePageIndex: absolutePageStartIndex + localPageIndex,
-                chapterIndex: chapterIndex,
-                spineIndex: spineIndex,
-                href: item.href,
-                chapterTitle: chapterTitle,
-                pageIndexInChapter: localPageIndex,
-                totalPagesInChapter: effectiveFrames.count,
-                chapterContent: chapterAttributedContent,
-                contentRange: range,
-                pageStartOffset: range.location,
-                pageEndOffset: range.location + max(range.length - 1, 0),
-                metadata: frame.metadata
-            )
-        }
-
-        let chapter = RDEPUBTextChapter(
-            chapterIndex: chapterIndex,
-            spineIndex: spineIndex,
-            href: item.href,
-            title: chapterTitle,
-            attributedContent: chapterAttributedContent,
-            fragmentOffsets: rendered.fragmentOffsets,
-            cfiMap: RDEPUBCFITextNodeMapBuilder.makeMap(
-                href: item.href,
-                rawHTML: rawHTML,
-                chapterText: chapterAttributedContent.string,
-                fragmentOffsets: rendered.fragmentOffsets
-            ),
-            pageBreakReasons: pages.map(\.metadata.breakReason),
-            pages: pages
-        )
-        let performanceSample = RDEPUBTextPerformanceSample(
-            chapterHref: item.href,
-            renderDuration: renderDuration,
-            paginateDuration: paginateDuration,
-            pageCount: effectiveFrames.count,
-            attributedStringLength: content.length,
-            cacheHit: isCacheHit
-        )
-        let diagnostic = diagnosticsReporter.chapterDiagnostic(
-            href: item.href,
-            title: chapterTitle,
-            pages: pages
-        )
-
-        return RDEPUBTextChapterBuildResult(
-            chapter: chapter,
-            resourceDiagnostics: rendered.resourceDiagnostics,
-            paginationDiagnostic: diagnostic,
-            performanceSample: performanceSample,
-            cacheHit: isCacheHit
-        )
-    }
-
-    private func resolvedChapterTitle(for item: RDEPUBSpineItem, toc: [EPUBTableOfContentsItem]) -> String {
-        if let title = flattenedTOCItems(from: toc).first(where: { tocItem in
-            tocItem.href.components(separatedBy: "#").first == item.href
-        })?.title.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
-            return title
-        }
-        let trimmedTitle = item.title.trimmingCharacters(in: .whitespacesAndNewlines)
-        return trimmedTitle.isEmpty ? item.href : trimmedTitle
-    }
-
-    private func flattenedTOCItems(from items: [EPUBTableOfContentsItem]) -> [EPUBTableOfContentsItem] {
-        items.flatMap { item in
-            [item] + flattenedTOCItems(from: item.children)
-        }
-    }
-
-    private func shouldSkipChapter(item: RDEPUBSpineItem, content: NSAttributedString, text: String) -> Bool {
-        let lowercasedHref = item.href.lowercased()
-        var hasAttachment = false
-        if content.length > 0 {
-            content.enumerateAttribute(.attachment, in: NSRange(location: 0, length: content.length)) { value, _, stop in
-                guard value != nil else { return }
-                hasAttachment = true
-                stop.pointee = true
-            }
-        }
-        if text.isEmpty && !hasAttachment && (lowercasedHref.contains("cover") || lowercasedHref.contains("title")) {
-            return true
-        }
-        return false
-    }
-
-    private func attachmentCount(in content: NSAttributedString) -> Int {
-        guard content.length > 0 else { return 0 }
-        var count = 0
-        content.enumerateAttribute(.attachment, in: NSRange(location: 0, length: content.length)) { value, _, _ in
-            if value != nil {
-                count += 1
-            }
-        }
-        return count
-    }
-
-    private func attachmentRanges(in content: NSAttributedString) -> [NSRange] {
-        guard content.length > 0 else { return [] }
-        var ranges: [NSRange] = []
-        content.enumerateAttribute(.attachment, in: NSRange(location: 0, length: content.length)) { value, range, _ in
-            if value != nil {
-                ranges.append(range)
-            }
-        }
-        return ranges
-    }
-
-    private func isAttachmentOnlyCoverChapter(
-        item: RDEPUBSpineItem,
-        content: NSAttributedString,
-        plainText: String
-    ) -> Bool {
-        let lowercasedHref = item.href.lowercased()
-        guard lowercasedHref.contains("cover") else { return false }
-        let trimmed = plainText.trimmingCharacters(in: .whitespacesAndNewlines)
-        return attachmentCount(in: content) > 0 && trimmed.count <= 1
-    }
-
-    private func debugPreview(for content: NSAttributedString, limit: Int) -> String {
-
-        let collapsed = content.string
-            .replacingOccurrences(of: "\n", with: " ")
-            .replacingOccurrences(of: "\r", with: " ")
-            .replacingOccurrences(of: "\t", with: " ")
-            .trimmingCharacters(in: .whitespacesAndNewlines)
-        guard !collapsed.isEmpty else { return "" }
-        if collapsed.count <= limit {
-            return collapsed
-        }
-        let head = collapsed.prefix(limit)
-        return "\(head)…"
-    }
-
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookCache.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookCache.swift
deleted file mode 100644
index 6728e52..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookCache.swift
+++ /dev/null
@@ -1,205 +0,0 @@
-import Foundation
-import CryptoKit
-
-public struct RDEPUBTextChapterPaginationCache: Equatable {
-
-    public var href: String
-
-    public var pageRanges: [NSRange]
-
-    public var breakReasons: [RDEPUBTextPageBreakReason]
-
-    public var semanticHints: [RDEPUBTextSemanticHint]
-
-    public init(
-        href: String,
-        pageRanges: [NSRange],
-        breakReasons: [RDEPUBTextPageBreakReason],
-        semanticHints: [RDEPUBTextSemanticHint]
-    ) {
-        self.href = href
-        self.pageRanges = pageRanges
-        self.breakReasons = breakReasons
-        self.semanticHints = semanticHints
-    }
-}
-
-final class PaginationCacheArchive: NSObject, NSSecureCoding {
-
-    static var supportsSecureCoding: Bool { true }
-
-    let chapters: [ChapterPaginationArchive]
-
-    init(chapters: [ChapterPaginationArchive]) {
-        self.chapters = chapters
-    }
-
-    func encode(with coder: NSCoder) {
-        coder.encode(chapters, forKey: "chapters")
-    }
-
-    required init?(coder: NSCoder) {
-        guard let chapters = coder.decodeObject(of: [NSArray.self, ChapterPaginationArchive.self], forKey: "chapters") as? [ChapterPaginationArchive] else { return nil }
-        self.chapters = chapters
-    }
-}
-
-final class ChapterPaginationArchive: NSObject, NSSecureCoding {
-
-    static var supportsSecureCoding: Bool { true }
-
-    let href: String
-
-    let rangeLocations: [NSNumber]
-
-    let rangeLengths: [NSNumber]
-
-    let breakReasons: [String]
-
-    let semanticHints: [String]
-
-    init(from cache: RDEPUBTextChapterPaginationCache) {
-        self.href = cache.href
-        self.rangeLocations = cache.pageRanges.map { NSNumber(value: $0.location) }
-        self.rangeLengths = cache.pageRanges.map { NSNumber(value: $0.length) }
-        self.breakReasons = cache.breakReasons.map(\.rawValue)
-        self.semanticHints = cache.semanticHints.map(\.rawValue)
-    }
-
-    func encode(with coder: NSCoder) {
-        coder.encode(href, forKey: "href")
-        coder.encode(rangeLocations, forKey: "rangeLocations")
-        coder.encode(rangeLengths, forKey: "rangeLengths")
-        coder.encode(breakReasons, forKey: "breakReasons")
-        coder.encode(semanticHints, forKey: "semanticHints")
-    }
-
-    required init?(coder: NSCoder) {
-        guard let href = coder.decodeObject(of: NSString.self, forKey: "href") as String?,
-              let rangeLocations = coder.decodeObject(of: [NSArray.self, NSNumber.self], forKey: "rangeLocations") as? [NSNumber],
-              let rangeLengths = coder.decodeObject(of: [NSArray.self, NSNumber.self], forKey: "rangeLengths") as? [NSNumber],
-              let breakReasons = coder.decodeObject(of: [NSArray.self, NSString.self], forKey: "breakReasons") as? [String],
-              let semanticHints = coder.decodeObject(of: [NSArray.self, NSString.self], forKey: "semanticHints") as? [String] else {
-            return nil
-        }
-        self.href = href
-        self.rangeLocations = rangeLocations
-        self.rangeLengths = rangeLengths
-        self.breakReasons = breakReasons
-        self.semanticHints = semanticHints
-    }
-
-    func toCache() -> RDEPUBTextChapterPaginationCache {
-        let pageRanges = zip(rangeLocations, rangeLengths).map { loc, len in
-            NSRange(location: loc.intValue, length: len.intValue)
-        }
-        return RDEPUBTextChapterPaginationCache(
-            href: href,
-            pageRanges: pageRanges,
-            breakReasons: breakReasons.compactMap(RDEPUBTextPageBreakReason.init(rawValue:)),
-            semanticHints: semanticHints.compactMap(RDEPUBTextSemanticHint.init(rawValue:))
-        )
-    }
-}
-
-public final class RDEPUBTextBookCache {
-
-    public var schemaVersion: Int = 14
-
-    private let queue = DispatchQueue(label: "com.RDEpubReader.textbookcache", qos: .utility)
-
-    private let cacheDirectory: URL
-
-    public init(subdirectory: String = "RDEPUBTextBookCache") {
-        let baseURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?
-            .appendingPathComponent(subdirectory, isDirectory: true)
-            ?? FileManager.default.temporaryDirectory.appendingPathComponent(subdirectory, isDirectory: true)
-        self.cacheDirectory = baseURL
-        try? FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
-    }
-
-    public func cacheKey(
-        bookID: String,
-        fontSize: CGFloat,
-        lineHeightMultiple: CGFloat,
-        contentInsets: UIEdgeInsets,
-        pageSize: CGSize,
-        layoutConfigSignature: String = RDEPUBTextLayoutConfig.default.cacheSignature
-    ) -> String {
-        let raw = "\(bookID)_\(fontSize)_\(lineHeightMultiple)_\(contentInsets.top)_\(contentInsets.left)_\(contentInsets.bottom)_\(contentInsets.right)_\(pageSize.width)_\(pageSize.height)_\(layoutConfigSignature)_v\(schemaVersion)"
-        let digest = SHA256.hash(data: Data(raw.utf8))
-        let hex = digest.map { String(format: "%02x", $0) }.joined()
-        return hex + ".cache"
-    }
-
-    public func load(key: String) -> [String: RDEPUBTextChapterPaginationCache]? {
-        queue.sync {
-            let fileURL = cacheDirectory.appendingPathComponent(key)
-            guard FileManager.default.fileExists(atPath: fileURL.path) else {
-                #if DEBUG
-//                print("[Cache] load MISS key=\(key)")
-                #endif
-                return nil
-            }
-            do {
-                let data = try Data(contentsOf: fileURL)
-                guard let archive = try NSKeyedUnarchiver.unarchivedObject(
-                    ofClass: PaginationCacheArchive.self,
-                    from: data
-                ) else {
-                    #if DEBUG
-//                    print("[Cache] load MISS key=\(key) (unarchive returned nil)")
-                    #endif
-                    return nil
-                }
-                var result: [String: RDEPUBTextChapterPaginationCache] = [:]
-                for chapter in archive.chapters {
-                    result[chapter.href] = chapter.toCache()
-                }
-                #if DEBUG
-//                print("[Cache] load HIT key=\(key) chapters=\(result.count)")
-                #endif
-                return result
-            } catch {
-                #if DEBUG
-//                print("[Cache] load MISS key=\(key) error=\(error)")
-                #endif
-                return nil
-            }
-        }
-    }
-
-    public func save(_ chapters: [RDEPUBTextChapterPaginationCache], key: String) {
-        queue.sync {
-            let fileURL = cacheDirectory.appendingPathComponent(key)
-            do {
-                let archives = chapters.map { ChapterPaginationArchive(from: $0) }
-                let bookArchive = PaginationCacheArchive(chapters: archives)
-                let data = try NSKeyedArchiver.archivedData(withRootObject: bookArchive, requiringSecureCoding: true)
-                try data.write(to: fileURL, options: .atomic)
-                #if DEBUG
-                print("[Cache] save key=\(key) chapters=\(chapters.count)")
-                #endif
-            } catch {
-                #if DEBUG
-                print("[Cache] save FAILED key=\(key) error=\(error)")
-                #endif
-            }
-        }
-    }
-
-    public func invalidateAll() {
-        queue.sync {
-            let fileManager = FileManager.default
-            guard let contents = try? fileManager.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: nil) else {
-                return
-            }
-            for file in contents {
-                try? fileManager.removeItem(at: file)
-            }
-            #if DEBUG
-            print("[Cache] invalidateAll")
-            #endif
-        }
-    }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookModels.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookModels.swift
deleted file mode 100644
index bf24607..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookModels.swift
+++ /dev/null
@@ -1,193 +0,0 @@
-import UIKit
-
-public struct RDEPUBTextChapterPaginationDiagnostic: Equatable {
-
-    public var href: String
-
-    public var title: String
-
-    public var pageCount: Int
-
-    public var breakReasons: [RDEPUBTextPageBreakReason]
-
-    public var attachmentPageCount: Int
-
-    public var blockAdjustedPageCount: Int
-
-    public var blockKinds: [RDEPUBTextBlockKind]
-
-    public var semanticHints: [RDEPUBTextSemanticHint]
-
-    public var attachmentPlacements: [RDEPUBTextAttachmentPlacement]
-
-    public var sampleNotes: [String]
-}
-
-public struct RDEPUBTextChapterBuildResult {
-
-    public var chapter: RDEPUBTextChapter
-
-    public var resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic]
-
-    public var paginationDiagnostic: RDEPUBTextChapterPaginationDiagnostic
-
-    public var performanceSample: RDEPUBTextPerformanceSample
-
-    public var cacheHit: Bool
-}
-
-public struct RDEPUBTextPage: Equatable {
-
-    public var absolutePageIndex: Int
-
-    public var chapterIndex: Int
-
-    public var spineIndex: Int
-
-    public var href: String
-
-    public var chapterTitle: String
-
-    public var pageIndexInChapter: Int
-
-    public var totalPagesInChapter: Int
-
-    public var chapterContent: NSAttributedString
-
-    /// Page substring derived on demand from `chapterContent` + `contentRange`.
-    /// Not stored: keeping a per-page substring alive roughly doubles the
-    /// chapter's resident text memory across the chapter cache window.
-    public var content: NSAttributedString {
-        let bounds = NSRange(location: 0, length: chapterContent.length)
-        let clamped = NSIntersectionRange(contentRange, bounds)
-        guard clamped.length > 0 else { return NSAttributedString() }
-        return chapterContent.attributedSubstring(from: clamped)
-    }
-
-    public var contentRange: NSRange
-
-    public var pageStartOffset: Int
-
-    public var pageEndOffset: Int
-
-    public var metadata: RDEPUBTextPageMetadata
-}
-
-public struct RDEPUBTextChapter: Equatable {
-
-    public var chapterIndex: Int
-
-    public var spineIndex: Int
-
-    public var href: String
-
-    public var title: String
-
-    public var attributedContent: NSAttributedString
-
-    public var fragmentOffsets: [String: Int]
-
-    public var cfiMap: RDEPUBCFIMap?
-
-    public var pageBreakReasons: [RDEPUBTextPageBreakReason]
-
-    public var pages: [RDEPUBTextPage]
-}
-
-public struct RDEPUBTextBook {
-
-    public var chapters: [RDEPUBTextChapter]
-
-    public var pages: [RDEPUBTextPage]
-
-    public let indexTable: RDEPUBTextIndexTable
-
-    public var positionConverter: RDEPUBTextPositionConverter {
-        RDEPUBTextPositionConverter(book: self)
-    }
-
-    public init(chapters: [RDEPUBTextChapter], pages: [RDEPUBTextPage]) {
-        self.chapters = chapters
-        self.pages = pages
-        self.indexTable = RDEPUBTextIndexTable(chapters: chapters)
-    }
-
-    public static func == (lhs: RDEPUBTextBook, rhs: RDEPUBTextBook) -> Bool {
-        lhs.chapters == rhs.chapters && lhs.pages == rhs.pages
-    }
-
-    public func chapterData(for href: String) -> RDEPUBChapterData? {
-        guard let chapter = chapters.first(where: { $0.href == href }) else { return nil }
-        return RDEPUBChapterData(chapter: chapter, indexTable: indexTable)
-    }
-
-    public func chapterData(forSpineIndex spineIndex: Int) -> RDEPUBChapterData? {
-        guard let chapter = chapters.first(where: { $0.spineIndex == spineIndex }) else { return nil }
-        return RDEPUBChapterData(chapter: chapter, indexTable: indexTable)
-    }
-
-    public func chapterData(atChapterIndex index: Int) -> RDEPUBChapterData? {
-        guard chapters.indices.contains(index) else { return nil }
-        return RDEPUBChapterData(chapter: chapters[index], indexTable: indexTable)
-    }
-
-    public func chapterData(forPageNumber pageNumber: Int) -> RDEPUBChapterData? {
-        guard let page = page(at: pageNumber) else { return nil }
-        return chapterData(forSpineIndex: page.spineIndex)
-    }
-
-    public func chapterData(
-        for location: RDEPUBLocation,
-        resolver: RDEPUBResourceResolver,
-        bookIdentifier: String?
-    ) -> RDEPUBChapterData? {
-        guard let normalizedLocation = resolver.normalizedLocation(location, bookIdentifier: bookIdentifier) else {
-            return nil
-        }
-        return chapterData(for: normalizedLocation.href)
-    }
-
-    public var chapterInfos: [EPUBChapterInfo] {
-        chapters.map { chapter in
-            EPUBChapterInfo(
-                spineIndex: chapter.spineIndex,
-                title: chapter.title,
-                pageCount: chapter.pages.count
-            )
-        }
-    }
-
-    public func page(at pageNumber: Int) -> RDEPUBTextPage? {
-        guard pageNumber > 0, pages.indices.contains(pageNumber - 1) else {
-            return nil
-        }
-        return pages[pageNumber - 1]
-    }
-
-    public func pageNumber(for location: RDEPUBLocation, resolver: RDEPUBResourceResolver, bookIdentifier: String?) -> Int? {
-        guard let normalizedLocation = resolver.normalizedLocation(location, bookIdentifier: bookIdentifier),
-              let chapterData = chapterData(for: normalizedLocation.href) else {
-            return nil
-        }
-
-        if let anchor = normalizedLocation.rangeAnchor?.start,
-           let page = positionConverter.pageNumber(for: anchor) {
-            return page
-        }
-
-        if let anchor = indexTable.anchor(for: normalizedLocation),
-           let page = positionConverter.pageNumber(for: anchor) {
-            return page
-        }
-
-        return chapterData.pageNumber(for: normalizedLocation)
-    }
-
-    public func location(forPageNumber pageNumber: Int, bookIdentifier: String?) -> RDEPUBLocation? {
-        guard let chapterData = chapterData(forPageNumber: pageNumber),
-              let page = page(at: pageNumber) else {
-            return nil
-        }
-        return chapterData.location(forPage: page, bookIdentifier: bookIdentifier)
-    }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBuildPipelineInterfaces.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBuildPipelineInterfaces.swift
deleted file mode 100644
index 19074f6..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBuildPipelineInterfaces.swift
+++ /dev/null
@@ -1,48 +0,0 @@
-
-import UIKit
-
-protocol RDEPUBTextBookBuilding {
-
-    func build(
-        parser: RDEPUBParser,
-        publication: RDEPUBPublication,
-        pageSize: CGSize,
-        style: RDEPUBTextRenderStyle
-    ) throws -> RDEPUBTextBook
-}
-
-struct RDEPUBChapterRenderPipeline {
-    private let renderer: RDEPUBTextRenderer
-
-    init(renderer: RDEPUBTextRenderer) {
-        self.renderer = renderer
-    }
-
-    func render(_ request: RDEPUBTextChapterRenderRequest) throws -> RDEPUBRenderedChapterContent {
-        try renderer.renderChapter(request: request)
-    }
-}
-
-struct RDEPUBChapterPaginationPipeline {
-    private let frameFactory: RDEPUBPageFrameBuilding
-
-    init(frameFactory: RDEPUBPageFrameBuilding = RDEPUBCoreTextPageFrameFactory()) {
-        self.frameFactory = frameFactory
-    }
-
-    func frames(
-        for content: NSAttributedString,
-        pageSize: CGSize,
-        config: RDEPUBTextLayoutConfig,
-        fragmentOffsets: [String: Int]
-    ) -> [RDEPUBTextLayoutFrame] {
-        frameFactory.makeFrames(
-            attributedString: content,
-            pageSize: pageSize,
-            config: config,
-            fragmentOffsets: fragmentOffsets
-        )
-    }
-}
-
-extension RDEPUBTextBookBuilder: RDEPUBTextBookBuilding {}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextPerformanceSampler.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextPerformanceSampler.swift
deleted file mode 100644
index ac974d5..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextPerformanceSampler.swift
+++ /dev/null
@@ -1,64 +0,0 @@
-import Foundation
-
-public struct RDEPUBTextPerformanceSample: Equatable {
-
-    public var chapterHref: String
-
-    public var renderDuration: TimeInterval
-
-    public var paginateDuration: TimeInterval
-
-    public var pageCount: Int
-
-    public var attributedStringLength: Int
-
-    public var cacheHit: Bool
-
-    public init(
-        chapterHref: String,
-        renderDuration: TimeInterval,
-        paginateDuration: TimeInterval,
-        pageCount: Int,
-        attributedStringLength: Int,
-        cacheHit: Bool
-    ) {
-        self.chapterHref = chapterHref
-        self.renderDuration = renderDuration
-        self.paginateDuration = paginateDuration
-        self.pageCount = pageCount
-        self.attributedStringLength = attributedStringLength
-        self.cacheHit = cacheHit
-    }
-}
-
-public final class RDEPUBTextPerformanceSampler {
-
-    public private(set) var samples: [RDEPUBTextPerformanceSample] = []
-
-    public var totalBuildDuration: TimeInterval = 0
-
-    public init() {}
-
-    public func record(_ sample: RDEPUBTextPerformanceSample) {
-        samples.append(sample)
-        #if DEBUG
-        print("[PERF] \(sample.chapterHref): render=\(formatMS(sample.renderDuration)) paginate=\(formatMS(sample.paginateDuration)) pages=\(sample.pageCount) cache=\(sample.cacheHit ? "HIT" : "MISS")")
-        #endif
-    }
-
-    public func summary() -> String {
-        let totalRender = samples.reduce(0) { $0 + $1.renderDuration }
-        let totalPaginate = samples.reduce(0) { $0 + $1.paginateDuration }
-        let hitCount = samples.filter(\.cacheHit).count
-        return "[PERF] chapters=\(samples.count) render=\(formatMS(totalRender)) paginate=\(formatMS(totalPaginate)) total=\(formatMS(totalBuildDuration)) cacheHits=\(hitCount)/\(samples.count)"
-    }
-
-    public func reset() {
-        samples.removeAll()
-        totalBuildDuration = 0
-    }
-
-    private func formatMS(_ duration: TimeInterval) -> String {
-        String(format: "%.0fms", duration * 1000)
-    }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBChapterPageCounter.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBChapterPageCounter.swift
deleted file mode 100644
index b1bdff4..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBChapterPageCounter.swift
+++ /dev/null
@@ -1,271 +0,0 @@
-import CoreText
-import UIKit
-
-#if canImport(DTCoreText)
-import DTCoreText
-#endif
-
-struct RDEPUBChapterPageCounter {
-
-    private let factory: RDEPUBCoreTextPageFrameFactory
-
-    private let attributedString: NSAttributedString
-
-    private let pageSize: CGSize
-
-    private let config: RDEPUBTextLayoutConfig
-
-    private let pageBreakPolicy: RDEPUBPageBreakPolicy
-
-    private let framesetter: CTFramesetter
-
-    private let dtLayoutRect: CGRect
-
-    init(factory: RDEPUBCoreTextPageFrameFactory) {
-        self.factory = factory
-        self.attributedString = factory.attributedString
-        self.pageSize = factory.pageSize
-        self.config = factory.config
-        self.pageBreakPolicy = RDEPUBPageBreakPolicy(attributedString: factory.attributedString)
-        self.framesetter = CTFramesetterCreateWithAttributedString(factory.attributedString)
-        self.dtLayoutRect = factory.config.contentRect(fallback: factory.pageSize)
-    }
-
-    func layoutFrames(fragmentOffsets: [String: Int] = [:]) -> [RDEPUBTextLayoutFrame] {
-        guard attributedString.length > 0, pageSize.width > 0, pageSize.height > 0 else {
-            return []
-        }
-
-#if canImport(DTCoreText)
-        return layoutFramesUsingDTCoreText(fragmentOffsets: fragmentOffsets)
-#else
-        return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets)
-#endif
-    }
-
-    private func layoutFramesUsingCoreText(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame] {
-        guard attributedString.length > 0, pageSize.width > 0, pageSize.height > 0 else {
-            return []
-        }
-
-        var frames: [RDEPUBTextLayoutFrame] = []
-        var location = 0
-        let resolvedSize = config.resolvedFrameSize(fallback: pageSize)
-        let contentRect = config.contentRect(fallback: resolvedSize)
-
-        guard contentRect.width > 0, contentRect.height > 0 else {
-            return []
-        }
-
-        while location < attributedString.length {
-            let framePath = RDEPUBCoreTextPageFrameFactory.makeLayoutPath(
-                pageSize: resolvedSize,
-                config: config
-            )
-            let frame = CTFramesetterCreateFrame(
-                framesetter,
-                CFRangeMake(location, 0),
-                framePath,
-                nil
-            )
-            let proposedRange = proposedVisibleRange(
-                from: frame,
-                start: location,
-                totalLength: attributedString.length
-            )
-            guard proposedRange.length > 0 else {
-                break
-            }
-
-            let avoidAdjusted = factory.trimmedRangeForAvoidPageBreakInside(from: frame, proposed: proposedRange)
-            let keepWithNextAdjusted = factory.trimmedRangeForKeepWithNext(from: frame, proposed: avoidAdjusted)
-            let lineRanges = RDEPUBCoreTextPageFrameFactory.lineRanges(from: frame)
-            let widowOrphanAdjusted = factory.trimmedRangeForWidowAndOrphanControl(
-                proposed: keepWithNextAdjusted,
-                lineRanges: lineRanges
-            )
-            let effectiveLineRanges = lineRangesWithinRange(lineRanges, range: widowOrphanAdjusted)
-
-            let adjusted = pageBreakPolicy.adjustedRange(
-                from: widowOrphanAdjusted,
-                totalLength: attributedString.length,
-                lineRanges: effectiveLineRanges,
-                factory: factory
-            )
-            let trailingFragmentID = factory.nearestTrailingFragmentID(
-                endingAt: adjusted.range.location + adjusted.range.length,
-                fragmentOffsets: fragmentOffsets
-            )
-            frames.append(
-                RDEPUBTextLayoutFrame(
-                    contentRange: adjusted.range,
-                    breakReason: adjusted.breakReason,
-                    blockRange: adjusted.blockRange,
-                    attachmentRanges: adjusted.attachmentRanges,
-                    attachmentKinds: adjusted.attachmentKinds,
-                    blockKinds: adjusted.blockKinds,
-                    semanticHints: adjusted.semanticHints,
-                    attachmentPlacements: adjusted.attachmentPlacements,
-                    trailingFragmentID: trailingFragmentID,
-                    diagnostics: adjusted.diagnostics
-                )
-            )
-
-            let nextLocation = adjusted.range.location + adjusted.range.length
-            guard nextLocation > location else {
-                location += max(proposedRange.length, 1)
-                continue
-            }
-            location = nextLocation
-        }
-
-        return frames
-    }
-
-#if canImport(DTCoreText)
-
-    private func layoutFramesUsingDTCoreText(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame] {
-        guard config.numberOfColumns == 1 else {
-            return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets)
-        }
-
-        guard let layouter = DTCoreTextLayouter(attributedString: attributedString) else {
-            return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets)
-        }
-
-        layouter.shouldCacheLayoutFrames = false
-
-        var frames: [RDEPUBTextLayoutFrame] = []
-        var location = 0
-        let pageRect = dtLayoutRect
-        let isDebug = ProcessInfo.processInfo.arguments.contains("--demo-pagination-debug")
-        if isDebug {
-            print("[PAGINATION-DEBUG] pageRect=\(pageRect) totalLength=\(attributedString.length)")
-        }
-
-        while location < attributedString.length {
-            guard let layoutFrame = layouter.layoutFrame(with: pageRect, range: NSRange(location: location, length: 0)) else {
-                break
-            }
-
-            let visibleRange = layoutFrame.visibleStringRange()
-            guard visibleRange.length > 0 else {
-                break
-            }
-
-            let proposedRange = NSRange(location: location, length: visibleRange.length)
-            let avoidAdjusted = factory.trimmedRangeForAvoidPageBreakInside(from: layoutFrame, proposed: proposedRange)
-            let lineAdjusted = factory.trimmedRangeForKeepWithNext(from: layoutFrame, proposed: avoidAdjusted)
-            let lineRanges = RDEPUBCoreTextPageFrameFactory.lineRanges(from: layoutFrame)
-            let widowOrphanAdjusted = factory.trimmedRangeForWidowAndOrphanControl(
-                proposed: lineAdjusted,
-                lineRanges: lineRanges
-            )
-
-            if isDebug {
-                let pageCount = frames.count + 1
-                let previewText = (attributedString.string as NSString).substring(with: NSRange(location: location, length: min(20, attributedString.length - location)))
-                let avoidRemoved = proposedRange.length - avoidAdjusted.length
-                let kwNextRemoved = avoidAdjusted.length - lineAdjusted.length
-                let widowRemoved = lineAdjusted.length - widowOrphanAdjusted.length
-                let totalRemoved = proposedRange.length - widowOrphanAdjusted.length
-                print("[PAGINATION-DEBUG] page#\(pageCount) loc=\(location) proposed=\(proposedRange.length) avoid(-\(avoidRemoved)) kwNext(-\(kwNextRemoved)) widowOrphan(-\(widowRemoved)) total(-\(totalRemoved)) lastLine=\"\(previewText)\"")
-            }
-            let effectiveLineRanges = lineRangesWithinRange(lineRanges, range: widowOrphanAdjusted)
-            let adjusted = pageBreakPolicy.adjustedRange(
-                from: widowOrphanAdjusted,
-                totalLength: attributedString.length,
-                lineRanges: effectiveLineRanges,
-                factory: factory
-            )
-            let verifiedRange: NSRange
-            if adjusted.breakReason == .attachmentBoundary {
-                verifiedRange = verifiedDisplayRange(for: adjusted.range)
-            } else {
-                verifiedRange = adjusted.range
-            }
-            let trailingFragmentID = factory.nearestTrailingFragmentID(
-                endingAt: verifiedRange.location + verifiedRange.length,
-                fragmentOffsets: fragmentOffsets
-            )
-            let diagnostics = verifiedRange == adjusted.range
-                ? adjusted.diagnostics
-                : adjusted.diagnostics + ["verified-display-range \(NSStringFromRange(adjusted.range)) -> \(NSStringFromRange(verifiedRange))"]
-
-            frames.append(
-                RDEPUBTextLayoutFrame(
-                    contentRange: verifiedRange,
-                    breakReason: adjusted.breakReason,
-                    blockRange: factory.blockRange(at: max(verifiedRange.location, verifiedRange.location + verifiedRange.length - 1)),
-                    attachmentRanges: factory.attachmentRanges(in: verifiedRange),
-                    attachmentKinds: factory.attachmentKinds(in: verifiedRange),
-                    blockKinds: factory.blockKinds(in: verifiedRange),
-                    semanticHints: factory.semanticHints(in: verifiedRange),
-                    attachmentPlacements: factory.attachmentPlacements(in: verifiedRange),
-                    trailingFragmentID: trailingFragmentID,
-                    diagnostics: diagnostics
-                )
-            )
-
-            let nextLocation = verifiedRange.location + verifiedRange.length
-            guard nextLocation > location else {
-                location += max(visibleRange.length, 1)
-                continue
-            }
-            location = nextLocation
-        }
-
-        return frames
-    }
-
-    private func verifiedDisplayRange(for range: NSRange) -> NSRange {
-        guard let clampedRange = factory.clampedRange(range),
-              clampedRange.length > 0,
-              !factory.attachmentRanges(in: clampedRange).isEmpty else {
-            return range
-        }
-
-        let pageContent = attributedString.attributedSubstring(from: clampedRange)
-        guard let layouter = DTCoreTextLayouter(attributedString: pageContent) else {
-            return clampedRange
-        }
-
-        layouter.shouldCacheLayoutFrames = false
-        guard let layoutFrame = layouter.layoutFrame(with: dtLayoutRect, range: NSRange(location: 0, length: 0)) else {
-            return clampedRange
-        }
-
-        let visibleRange = layoutFrame.visibleStringRange()
-        guard visibleRange.length > 0, visibleRange.length < clampedRange.length else {
-            return clampedRange
-        }
-
-        return NSRange(location: clampedRange.location, length: visibleRange.length)
-    }
-#endif
-
-    private func proposedVisibleRange(
-        from frame: CTFrame,
-        start location: Int,
-        totalLength: Int
-    ) -> NSRange {
-        let visibleRange = CTFrameGetVisibleStringRange(frame)
-        guard visibleRange.length > 0 else {
-            return NSRange(location: location, length: 0)
-        }
-
-        let resolvedLocation = max(location, visibleRange.location)
-        let visibleEnd = visibleRange.location + visibleRange.length
-        let length = min(max(visibleEnd - resolvedLocation, 0), totalLength - resolvedLocation)
-        guard length > 0 else {
-            return NSRange(location: resolvedLocation, length: 0)
-        }
-        return NSRange(location: resolvedLocation, length: length)
-    }
-
-    private func lineRangesWithinRange(_ lineRanges: [NSRange], range: NSRange) -> [NSRange] {
-        lineRanges.filter { lineRange in
-            lineRange.location >= range.location && NSMaxRange(lineRange) <= NSMaxRange(range)
-        }
-    }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBCoreTextPageFrameFactory.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBCoreTextPageFrameFactory.swift
deleted file mode 100644
index 893fcdc..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBCoreTextPageFrameFactory.swift
+++ /dev/null
@@ -1,499 +0,0 @@
-import CoreText
-import UIKit
-
-#if canImport(DTCoreText)
-import DTCoreText
-#endif
-
-struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
-
-    let attributedString: NSAttributedString
-
-    let pageSize: CGSize
-
-    let config: RDEPUBTextLayoutConfig
-
-    private let pageBreakPolicy: RDEPUBPageBreakPolicy
-
-    init(attributedString: NSAttributedString, pageSize: CGSize, config: RDEPUBTextLayoutConfig = .default) {
-        self.attributedString = attributedString
-        self.pageSize = pageSize
-        self.config = config
-        self.pageBreakPolicy = RDEPUBPageBreakPolicy(attributedString: attributedString)
-    }
-
-    init() {
-        self.init(attributedString: NSAttributedString(), pageSize: .zero, config: .default)
-    }
-
-    func makeFrames(
-        attributedString: NSAttributedString,
-        pageSize: CGSize,
-        config: RDEPUBTextLayoutConfig,
-        fragmentOffsets: [String: Int]
-    ) -> [RDEPUBTextLayoutFrame] {
-        let factory = RDEPUBCoreTextPageFrameFactory(attributedString: attributedString, pageSize: pageSize, config: config)
-        let counter = RDEPUBChapterPageCounter(factory: factory)
-        return counter.layoutFrames(fragmentOffsets: fragmentOffsets)
-    }
-
-    static func makeLayoutPath(pageSize: CGSize, config: RDEPUBTextLayoutConfig) -> CGPath {
-        let columnRects = config.columnRects(fallback: pageSize)
-        guard columnRects.count > 1 else {
-            return CGPath(rect: columnRects.first ?? CGRect(origin: .zero, size: pageSize), transform: nil)
-        }
-
-        let path = CGMutablePath()
-        for rect in columnRects {
-            path.addRect(rect)
-        }
-        return path
-    }
-
-    func trimmedRangeForAvoidPageBreakInside(
-        from frame: CTFrame,
-        proposed: NSRange
-    ) -> NSRange {
-        guard config.avoidPageBreakInsideEnabled else { return proposed }
-
-        let lines = CTFrameGetLines(frame) as! [CTLine]
-        guard !lines.isEmpty else { return proposed }
-
-        let lineRanges = lines.map {
-            let range = CTLineGetStringRange($0)
-            return NSRange(location: range.location, length: range.length)
-        }
-        return trimmedRangeForAvoidPageBreakInside(proposed: proposed, lineRanges: lineRanges)
-    }
-
-    func trimmedRangeForKeepWithNext(
-        from frame: CTFrame,
-        proposed: NSRange
-    ) -> NSRange {
-        let lines = CTFrameGetLines(frame) as! [CTLine]
-        let lineRanges = lines.map {
-            let range = CTLineGetStringRange($0)
-            return NSRange(location: range.location, length: range.length)
-        }
-        return trimmedRangeForKeepWithNext(proposed: proposed, lineRanges: lineRanges)
-    }
-
-#if canImport(DTCoreText)
-
-    func trimmedRangeForAvoidPageBreakInside(
-        from layoutFrame: DTCoreTextLayoutFrame,
-        proposed: NSRange
-    ) -> NSRange {
-        guard config.avoidPageBreakInsideEnabled else { return proposed }
-
-        guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine], !lines.isEmpty else {
-            return proposed
-        }
-        let lineRanges = lines.map { $0.stringRange() }
-        return trimmedRangeForAvoidPageBreakInside(proposed: proposed, lineRanges: lineRanges)
-    }
-
-    func trimmedRangeForKeepWithNext(
-        from layoutFrame: DTCoreTextLayoutFrame,
-        proposed: NSRange
-    ) -> NSRange {
-        guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine], !lines.isEmpty else {
-            return proposed
-        }
-        let lineRanges = lines.map { $0.stringRange() }
-        return trimmedRangeForKeepWithNext(proposed: proposed, lineRanges: lineRanges)
-    }
-#endif
-
-    func trimmedRangeForAvoidPageBreakInside(
-        proposed: NSRange,
-        lineRanges: [NSRange]
-    ) -> NSRange {
-        guard config.avoidPageBreakInsideEnabled, !lineRanges.isEmpty else { return proposed }
-
-        let kMaxLinesToRemove = 3
-        var linesToRemove = 0
-
-        for lineRange in lineRanges.reversed() {
-            if pageBreakPolicy.lineIsInAvoidPageBreakInsideBlock(lineRange) {
-                linesToRemove += 1
-                if linesToRemove >= kMaxLinesToRemove {
-                    linesToRemove = kMaxLinesToRemove
-                    break
-                }
-            } else {
-                break
-            }
-        }
-
-        guard linesToRemove > 0 else { return proposed }
-
-        let validLineCount = lineRanges.count - linesToRemove
-        guard validLineCount > 0 else { return proposed }
-
-        let lastValidLine = lineRanges[validLineCount - 1]
-        let endLocation = lastValidLine.location + lastValidLine.length
-        let adjustedLength = endLocation - proposed.location
-        guard adjustedLength > 0 else { return proposed }
-
-        if ProcessInfo.processInfo.arguments.contains("--demo-pagination-debug") {
-            let removedText = (attributedString.string as NSString).substring(with: NSRange(location: endLocation, length: min(proposed.length - adjustedLength, 40)))
-            print("[PAGINATION-DEBUG] avoidPageBreakInside removed \(linesToRemove) lines: \"\(removedText)\"")
-        }
-
-        return NSRange(location: proposed.location, length: adjustedLength)
-    }
-
-    func trimmedRangeForKeepWithNext(
-        proposed: NSRange,
-        lineRanges: [NSRange]
-    ) -> NSRange {
-        guard !lineRanges.isEmpty else { return proposed }
-
-        let kMaxLinesToRemove = 3
-        var linesToRemove = 0
-
-        for lineRange in lineRanges.reversed() {
-            if pageBreakPolicy.lineIsInKeepWithNextBlock(lineRange) {
-                linesToRemove += 1
-                if linesToRemove >= kMaxLinesToRemove {
-                    linesToRemove = kMaxLinesToRemove
-                    break
-                }
-            } else {
-                break
-            }
-        }
-
-        guard linesToRemove > 0 else { return proposed }
-
-        let validLineCount = lineRanges.count - linesToRemove
-        guard validLineCount > 0 else { return proposed }
-
-        let lastValidLine = lineRanges[validLineCount - 1]
-        let endLocation = lastValidLine.location + lastValidLine.length
-        let adjustedLength = endLocation - proposed.location
-        guard adjustedLength > 0 else { return proposed }
-
-        if ProcessInfo.processInfo.arguments.contains("--demo-pagination-debug") {
-            let removedText = (attributedString.string as NSString).substring(with: NSRange(location: endLocation, length: min(proposed.length - adjustedLength, 40)))
-            print("[PAGINATION-DEBUG] keepWithNext removed \(linesToRemove) lines: \"\(removedText)\"")
-        }
-
-        return NSRange(location: proposed.location, length: adjustedLength)
-    }
-
-    func trimmedRangeForWidowAndOrphanControl(
-        proposed: NSRange,
-        lineRanges: [NSRange]
-    ) -> NSRange {
-        guard !lineRanges.isEmpty else { return proposed }
-        guard config.avoidWidows || config.avoidOrphans else { return proposed }
-
-        var adjusted = proposed
-        var visibleLines = lineRanges
-
-        if config.avoidWidows,
-           let widowAdjusted = trimmedRangeAvoidingWidow(
-                proposed: adjusted,
-                lineRanges: visibleLines
-           ),
-           widowAdjusted != adjusted {
-            adjusted = widowAdjusted
-            visibleLines = Array(visibleLines.dropLast())
-        }
-
-        if config.avoidOrphans,
-           let orphanAdjusted = trimmedRangeAvoidingOrphan(
-                proposed: adjusted,
-                lineRanges: visibleLines
-           ) {
-            adjusted = orphanAdjusted
-        }
-
-        return adjusted
-    }
-
-    static func lineRanges(from frame: CTFrame) -> [NSRange] {
-        let lines = CTFrameGetLines(frame) as! [CTLine]
-        return lines.map {
-            let lineRange = CTLineGetStringRange($0)
-            return NSRange(location: lineRange.location, length: lineRange.length)
-        }
-    }
-
-#if canImport(DTCoreText)
-
-    static func lineRanges(from layoutFrame: DTCoreTextLayoutFrame) -> [NSRange] {
-        guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine] else {
-            return []
-        }
-        return lines.map { $0.stringRange() }
-    }
-#endif
-
-    func blockRange(at location: Int) -> NSRange? {
-        guard location >= 0, location < attributedString.length else { return nil }
-        let attributes = attributedString.attributes(at: location, effectiveRange: nil)
-        if let encodedRange = attributes[.rdPageBlockRange] as? String {
-            return NSRangeFromString(encodedRange)
-        }
-        return nil
-    }
-
-    func blockKind(at location: Int) -> RDEPUBTextBlockKind? {
-        guard location >= 0, location < attributedString.length else { return nil }
-        let attributes = attributedString.attributes(at: location, effectiveRange: nil)
-        guard let rawValue = attributes[.rdPageBlockKind] as? String else { return nil }
-        return RDEPUBTextBlockKind(rawValue: rawValue)
-    }
-
-    func attachmentPlacement(at location: Int) -> RDEPUBTextAttachmentPlacement? {
-        guard location >= 0, location < attributedString.length else { return nil }
-        let attributes = attributedString.attributes(at: location, effectiveRange: nil)
-        guard let rawValue = attributes[.rdPageAttachmentPlacement] as? String else { return nil }
-        return RDEPUBTextAttachmentPlacement(rawValue: rawValue)
-    }
-
-    func paragraphRange(containing location: Int) -> NSRange {
-        let source = attributedString.string as NSString
-        guard source.length > 0 else { return NSRange(location: 0, length: 0) }
-        let safeLocation = min(max(location, 0), max(source.length - 1, 0))
-        return source.paragraphRange(for: NSRange(location: safeLocation, length: 0))
-    }
-
-    func attachmentRanges(in range: NSRange) -> [NSRange] {
-        guard let safeRange = clampedRange(range), safeRange.length > 0 else {
-            return []
-        }
-        var results: [NSRange] = []
-        attributedString.enumerateAttribute(.rdPageAttachmentKind, in: safeRange) { value, attributeRange, _ in
-            guard value != nil else { return }
-            results.append(attributeRange)
-        }
-        return results
-    }
-
-    func semanticHints(at location: Int) -> [RDEPUBTextSemanticHint] {
-        guard location >= 0, location < attributedString.length else { return [] }
-        let attributes = attributedString.attributes(at: location, effectiveRange: nil)
-        guard let rawValue = attributes[.rdPageSemanticHints] as? String else { return [] }
-        return rawValue
-            .split(separator: ",")
-            .compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
-    }
-
-    private func trimmedRangeAvoidingWidow(
-        proposed: NSRange,
-        lineRanges: [NSRange]
-    ) -> NSRange? {
-        guard lineRanges.count >= 2,
-              let lastLine = lineRanges.last else {
-            return nil
-        }
-
-        let paragraphRange = paragraphRange(containing: lastLine.location)
-        let trailingLines = trailingLineCount(in: lineRanges, paragraphRange: paragraphRange)
-        let paragraphContinuesOnNextPage = NSMaxRange(proposed) < NSMaxRange(paragraphRange)
-
-        guard trailingLines == 1, paragraphContinuesOnNextPage else {
-            return nil
-        }
-
-        let keptLine = lineRanges[lineRanges.count - 2]
-        let adjustedLength = NSMaxRange(keptLine) - proposed.location
-        guard adjustedLength > 0 else { return nil }
-
-        if ProcessInfo.processInfo.arguments.contains("--demo-pagination-debug") {
-            let removedText = (attributedString.string as NSString).substring(with: NSRange(location: NSMaxRange(keptLine), length: min(proposed.length - adjustedLength, 40)))
-            print("[PAGINATION-DEBUG] widow control removed 1 line: \"\(removedText)\" paragraphRange=\(NSStringFromRange(paragraphRange))")
-        }
-
-        return NSRange(location: proposed.location, length: adjustedLength)
-    }
-
-    private func trimmedRangeAvoidingOrphan(
-        proposed: NSRange,
-        lineRanges: [NSRange]
-    ) -> NSRange? {
-        guard lineRanges.count >= 2,
-              let lastLine = lineRanges.last else {
-            return nil
-        }
-
-        let paragraphRange = paragraphRange(containing: lastLine.location)
-        let pageEnd = NSMaxRange(proposed)
-        let paragraphEnd = NSMaxRange(paragraphRange)
-        guard pageEnd < paragraphEnd else { return nil }
-
-        let remainingLineCount = estimatedRemainingLineCount(
-            in: paragraphRange,
-            startingAt: pageEnd
-        )
-        let trailingLines = trailingLineCount(in: lineRanges, paragraphRange: paragraphRange)
-        guard remainingLineCount == 1,
-              trailingLines >= 2 else {
-            return nil
-        }
-
-        let keptLine = lineRanges[lineRanges.count - 2]
-        let adjustedLength = NSMaxRange(keptLine) - proposed.location
-        guard adjustedLength > 0 else { return nil }
-        return NSRange(location: proposed.location, length: adjustedLength)
-    }
-
-    private func trailingLineCount(
-        in lineRanges: [NSRange],
-        paragraphRange: NSRange
-    ) -> Int {
-        var count = 0
-        for lineRange in lineRanges.reversed() {
-            if NSIntersectionRange(lineRange, paragraphRange).length > 0 {
-                count += 1
-            } else {
-                break
-            }
-        }
-        return count
-    }
-
-    private func estimatedRemainingLineCount(
-        in paragraphRange: NSRange,
-        startingAt location: Int
-    ) -> Int {
-        let paragraphEnd = NSMaxRange(paragraphRange)
-        var currentLocation = max(location, paragraphRange.location)
-        guard currentLocation < paragraphEnd else { return 0 }
-
-        let width = max(config.columnRects(fallback: pageSize).first?.width ?? config.contentRect(fallback: pageSize).width, 1)
-        let typesetter = CTTypesetterCreateWithAttributedString(attributedString)
-        var lineCount = 0
-
-        while currentLocation < paragraphEnd {
-            let suggestedCount = CTTypesetterSuggestLineBreak(typesetter, currentLocation, Double(width))
-            let lineLength = max(suggestedCount, 1)
-            currentLocation += lineLength
-            lineCount += 1
-            if lineCount > 2 {
-                break
-            }
-        }
-
-        return lineCount
-    }
-
-    func attachmentKinds(in range: NSRange) -> [RDEPUBTextAttachmentKind] {
-        guard let safeRange = clampedRange(range), safeRange.length > 0 else {
-            return []
-        }
-        var kinds: [RDEPUBTextAttachmentKind] = []
-        attributedString.enumerateAttribute(.rdPageAttachmentKind, in: safeRange) { value, _, _ in
-            guard let rawValue = value as? String,
-                  let kind = RDEPUBTextAttachmentKind(rawValue: rawValue),
-                  !kinds.contains(kind) else {
-                return
-            }
-            kinds.append(kind)
-        }
-        return kinds
-    }
-
-    func blockKinds(in range: NSRange) -> [RDEPUBTextBlockKind] {
-        guard let safeRange = clampedRange(range), safeRange.length > 0 else {
-            return []
-        }
-        var kinds: [RDEPUBTextBlockKind] = []
-        attributedString.enumerateAttribute(.rdPageBlockKind, in: safeRange) { value, _, _ in
-            guard let rawValue = value as? String,
-                  let kind = RDEPUBTextBlockKind(rawValue: rawValue),
-                  !kinds.contains(kind) else {
-                return
-            }
-            kinds.append(kind)
-        }
-        return kinds
-    }
-
-    func semanticHints(in range: NSRange) -> [RDEPUBTextSemanticHint] {
-        guard let safeRange = clampedRange(range), safeRange.length > 0 else {
-            return []
-        }
-        var hints: [RDEPUBTextSemanticHint] = []
-        attributedString.enumerateAttribute(.rdPageSemanticHints, in: safeRange) { value, _, _ in
-            guard let rawValue = value as? String else { return }
-            for hint in rawValue.split(separator: ",").compactMap({ RDEPUBTextSemanticHint(rawValue: String($0)) }) where !hints.contains(hint) {
-                hints.append(hint)
-            }
-        }
-        return hints
-    }
-
-    func attachmentPlacements(in range: NSRange) -> [RDEPUBTextAttachmentPlacement] {
-        guard let safeRange = clampedRange(range), safeRange.length > 0 else {
-            return []
-        }
-        var placements: [RDEPUBTextAttachmentPlacement] = []
-        attributedString.enumerateAttribute(.rdPageAttachmentPlacement, in: safeRange) { value, _, _ in
-            guard let rawValue = value as? String,
-                  let placement = RDEPUBTextAttachmentPlacement(rawValue: rawValue),
-                  !placements.contains(placement) else {
-                return
-            }
-            placements.append(placement)
-        }
-        return placements
-    }
-
-    func clampedRange(_ range: NSRange) -> NSRange? {
-        guard range.location >= 0, range.length >= 0 else { return nil }
-        guard attributedString.length > 0 else {
-            return range.location == 0 ? NSRange(location: 0, length: 0) : nil
-        }
-        guard range.location < attributedString.length else { return nil }
-
-        let maxLength = attributedString.length - range.location
-        return NSRange(location: range.location, length: min(range.length, maxLength))
-    }
-
-    func nearestTrailingFragmentID(
-        endingAt location: Int,
-        fragmentOffsets: [String: Int]
-    ) -> String? {
-        fragmentOffsets
-            .filter { $0.value <= location }
-            .max { lhs, rhs in lhs.value < rhs.value }?
-            .key
-    }
-
-    func diagnostics(
-        reason: RDEPUBTextPageBreakReason,
-        range: NSRange,
-        attachmentRanges: [NSRange],
-        blockRange: NSRange?,
-        blockKinds: [RDEPUBTextBlockKind],
-        semanticHints: [RDEPUBTextSemanticHint],
-        attachmentPlacements: [RDEPUBTextAttachmentPlacement],
-        trigger: String? = nil
-    ) -> [String] {
-        var items = ["page break: \(reason.rawValue)", "page range: \(NSStringFromRange(range))"]
-        if let blockRange {
-            items.append("block range: \(NSStringFromRange(blockRange))")
-        }
-        if !attachmentRanges.isEmpty {
-            items.append("attachment ranges: \(attachmentRanges.map(NSStringFromRange).joined(separator: ","))")
-        }
-        if !blockKinds.isEmpty {
-            items.append("block kinds: \(blockKinds.map(\.rawValue).joined(separator: ","))")
-        }
-        if !semanticHints.isEmpty {
-            items.append("semantic hints: \(semanticHints.map(\.rawValue).joined(separator: ","))")
-        }
-        if !attachmentPlacements.isEmpty {
-            items.append("attachment placements: \(attachmentPlacements.map(\.rawValue).joined(separator: ","))")
-        }
-        if let trigger, !trigger.isEmpty {
-            items.append("semantic trigger: \(trigger)")
-        }
-        return items
-    }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBPageBreakPolicy.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBPageBreakPolicy.swift
deleted file mode 100644
index bb3cf38..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBPageBreakPolicy.swift
+++ /dev/null
@@ -1,217 +0,0 @@
-import Foundation
-import UIKit
-
-struct RDEPUBPageBreakPolicy {
-
-    private let attributedString: NSAttributedString
-
-    init(attributedString: NSAttributedString) {
-        self.attributedString = attributedString
-    }
-
-    func lineIsInAvoidPageBreakInsideBlock(_ lineRange: NSRange) -> Bool {
-        guard let probeRange = clampedProbeRange(for: lineRange) else {
-            return false
-        }
-        var found = false
-        attributedString.enumerateAttributes(in: probeRange) { attributes, _, stop in
-            guard shouldTreatAvoidHintAsBlockProtection(attributes) else {
-                return
-            }
-            guard let rawValue = attributes[.rdPageSemanticHints] as? String else { return }
-            let hints = rawValue
-                .split(separator: ",")
-                .compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
-            if hints.contains(.avoidPageBreakInside) {
-                found = true
-                stop.pointee = true
-            }
-        }
-        return found
-    }
-
-    func lineIsInKeepWithNextBlock(_ lineRange: NSRange) -> Bool {
-        guard let probeRange = clampedProbeRange(for: lineRange) else {
-            return false
-        }
-        var found = false
-        attributedString.enumerateAttribute(.rdPageSemanticHints, in: probeRange) { value, _, stop in
-            guard let rawValue = value as? String else { return }
-            let hints = rawValue
-                .split(separator: ",")
-                .compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
-            if hints.contains(.keepWithNext) {
-                found = true
-                stop.pointee = true
-            }
-        }
-        return found
-    }
-
-    func adjustedRange(
-        from proposedRange: NSRange,
-        totalLength: Int,
-        lineRanges: [NSRange],
-        factory: RDEPUBCoreTextPageFrameFactory
-    ) -> (
-        range: NSRange,
-        breakReason: RDEPUBTextPageBreakReason,
-        blockRange: NSRange?,
-        attachmentRanges: [NSRange],
-        attachmentKinds: [RDEPUBTextAttachmentKind],
-        blockKinds: [RDEPUBTextBlockKind],
-        semanticHints: [RDEPUBTextSemanticHint],
-        attachmentPlacements: [RDEPUBTextAttachmentPlacement],
-        diagnostics: [String]
-    ) {
-        let pageEnd = proposedRange.location + proposedRange.length
-        let proposedBlockKinds = factory.blockKinds(in: proposedRange)
-        let proposedSemanticHints = factory.semanticHints(in: proposedRange)
-        let proposedAttachmentPlacements = factory.attachmentPlacements(in: proposedRange)
-        guard pageEnd < totalLength else {
-            return (
-                range: proposedRange,
-                breakReason: .chapterEnd,
-                blockRange: factory.blockRange(at: max(proposedRange.location, pageEnd - 1)),
-                attachmentRanges: factory.attachmentRanges(in: proposedRange),
-                attachmentKinds: factory.attachmentKinds(in: proposedRange),
-                blockKinds: proposedBlockKinds,
-                semanticHints: proposedSemanticHints,
-                attachmentPlacements: proposedAttachmentPlacements,
-                diagnostics: factory.diagnostics(
-                    reason: .chapterEnd,
-                    range: proposedRange,
-                    attachmentRanges: factory.attachmentRanges(in: proposedRange),
-                    blockRange: factory.blockRange(at: max(proposedRange.location, pageEnd - 1)),
-                    blockKinds: proposedBlockKinds,
-                    semanticHints: proposedSemanticHints,
-                    attachmentPlacements: proposedAttachmentPlacements
-                )
-            )
-        }
-
-        let currentBlockRange = factory.blockRange(at: max(proposedRange.location, pageEnd - 1))
-        let currentAttachmentRanges = factory.attachmentRanges(in: proposedRange)
-        let currentAttachmentKinds = factory.attachmentKinds(in: proposedRange)
-        let currentBlockKinds = proposedBlockKinds
-        let currentSemanticHints = proposedSemanticHints
-        let currentAttachmentPlacements = proposedAttachmentPlacements
-
-        if let pageRelateBoundary = preferredPageRelateBoundary(
-            after: proposedRange,
-            minimumEnd: proposedRange.location + 1,
-            lineRanges: lineRanges,
-            factory: factory
-        ) {
-            let adjustedRange = NSRange(location: proposedRange.location, length: pageRelateBoundary - proposedRange.location)
-            return (
-                range: adjustedRange,
-                breakReason: .semanticBoundary,
-                blockRange: currentBlockRange,
-                attachmentRanges: currentAttachmentRanges,
-                attachmentKinds: currentAttachmentKinds,
-                blockKinds: currentBlockKinds,
-                semanticHints: currentSemanticHints,
-                attachmentPlacements: currentAttachmentPlacements,
-                diagnostics: factory.diagnostics(
-                    reason: .semanticBoundary,
-                    range: adjustedRange,
-                    attachmentRanges: currentAttachmentRanges,
-                    blockRange: currentBlockRange,
-                    blockKinds: currentBlockKinds,
-                    semanticHints: currentSemanticHints,
-                    attachmentPlacements: currentAttachmentPlacements,
-                    trigger: RDEPUBTextSemanticHint.pageRelate.rawValue
-                )
-            )
-        }
-
-        return (
-            range: proposedRange,
-            breakReason: .frameLimit,
-            blockRange: currentBlockRange,
-            attachmentRanges: currentAttachmentRanges,
-            attachmentKinds: currentAttachmentKinds,
-            blockKinds: currentBlockKinds,
-            semanticHints: currentSemanticHints,
-            attachmentPlacements: currentAttachmentPlacements,
-            diagnostics: factory.diagnostics(
-                reason: .frameLimit,
-                range: proposedRange,
-                attachmentRanges: currentAttachmentRanges,
-                blockRange: currentBlockRange,
-                blockKinds: currentBlockKinds,
-                semanticHints: currentSemanticHints,
-                attachmentPlacements: currentAttachmentPlacements
-            )
-        )
-    }
-
-    func preferredPageRelateBoundary(
-        after range: NSRange,
-        minimumEnd: Int,
-        lineRanges: [NSRange],
-        factory: RDEPUBCoreTextPageFrameFactory
-    ) -> Int? {
-        let pageStartOfNext = range.location + range.length
-        guard pageStartOfNext > range.location,
-              pageStartOfNext < attributedString.length,
-              lineRanges.count >= 2,
-              factory.semanticHints(at: pageStartOfNext).contains(.pageRelate) else {
-            return nil
-        }
-
-        let boundaryBlockStart = factory.blockRange(at: pageStartOfNext)?.location
-            ?? factory.paragraphRange(containing: pageStartOfNext).location
-        guard boundaryBlockStart == pageStartOfNext else { return nil }
-
-        let lastLineStart = lineRanges[lineRanges.count - 1].location
-        guard lastLineStart > range.location, lastLineStart >= minimumEnd else {
-            return nil
-        }
-        return lastLineStart
-    }
-
-    private func shouldTreatAvoidHintAsBlockProtection(_ attributes: [NSAttributedString.Key: Any]) -> Bool {
-        guard let rawValue = attributes[.rdPageSemanticHints] as? String else {
-            return false
-        }
-        let hints = rawValue
-            .split(separator: ",")
-            .compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
-        guard hints.contains(.avoidPageBreakInside) else {
-            return false
-        }
-
-        let placement = (attributes[.rdPageAttachmentPlacement] as? String)
-            .flatMap(RDEPUBTextAttachmentPlacement.init(rawValue:))
-        let blockKind = (attributes[.rdPageBlockKind] as? String)
-            .flatMap(RDEPUBTextBlockKind.init(rawValue:))
-
-        // Inline attachments (footnote icons, rare-character images) flow with
-        // the surrounding text, so their avoid hint must not lock the line to
-        // the next page. Placement is checked besides blockKind because an
-        // enclosing paragraph's semantics overwrite blockKind on the
-        // attachment's range, while placement survives.
-        if blockKind == .attachment || placement != nil, placement != .centered {
-            return false
-        }
-
-        return true
-    }
-
-    private func clampedProbeRange(for lineRange: NSRange) -> NSRange? {
-        clampedRange(NSRange(location: lineRange.location, length: max(lineRange.length, 1)))
-    }
-
-    private func clampedRange(_ range: NSRange) -> NSRange? {
-        guard range.location >= 0, range.length >= 0 else { return nil }
-        guard attributedString.length > 0 else {
-            return range.location == 0 ? NSRange(location: 0, length: 0) : nil
-        }
-        guard range.location < attributedString.length else { return nil }
-
-        let maxLength = attributedString.length - range.location
-        return NSRange(location: range.location, length: min(range.length, maxLength))
-    }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBTextLayoutFrame.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBTextLayoutFrame.swift
deleted file mode 100644
index ef5c0d7..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBTextLayoutFrame.swift
+++ /dev/null
@@ -1,38 +0,0 @@
-import Foundation
-
-struct RDEPUBTextLayoutFrame: Equatable {
-
-    var contentRange: NSRange
-
-    var breakReason: RDEPUBTextPageBreakReason
-
-    var blockRange: NSRange?
-
-    var attachmentRanges: [NSRange]
-
-    var attachmentKinds: [RDEPUBTextAttachmentKind]
-
-    var blockKinds: [RDEPUBTextBlockKind]
-
-    var semanticHints: [RDEPUBTextSemanticHint]
-
-    var attachmentPlacements: [RDEPUBTextAttachmentPlacement]
-
-    var trailingFragmentID: String?
-
-    var diagnostics: [String]
-
-    var metadata: RDEPUBTextPageMetadata {
-        RDEPUBTextPageMetadata(
-            breakReason: breakReason,
-            blockRange: blockRange,
-            attachmentRanges: attachmentRanges,
-            attachmentKinds: attachmentKinds,
-            blockKinds: blockKinds,
-            semanticHints: semanticHints,
-            attachmentPlacements: attachmentPlacements,
-            trailingFragmentID: trailingFragmentID,
-            diagnostics: diagnostics
-        )
-    }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBTextLayouter.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBTextLayouter.swift
deleted file mode 100644
index d87f9ed..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBTextLayouter.swift
+++ /dev/null
@@ -1,19 +0,0 @@
-import CoreText
-import UIKit
-
-#if canImport(DTCoreText)
-import DTCoreText
-#endif
-
-struct RDEPUBTextLayouter {
-    private let counter: RDEPUBChapterPageCounter
-
-    init(attributedString: NSAttributedString, pageSize: CGSize, config: RDEPUBTextLayoutConfig = .default) {
-        let factory = RDEPUBCoreTextPageFrameFactory(attributedString: attributedString, pageSize: pageSize, config: config)
-        self.counter = RDEPUBChapterPageCounter(factory: factory)
-    }
-
-    func layoutFrames(fragmentOffsets: [String: Int] = [:]) -> [RDEPUBTextLayoutFrame] {
-        counter.layoutFrames(fragmentOffsets: fragmentOffsets)
-    }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBTextPaginationInterfaces.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBTextPaginationInterfaces.swift
deleted file mode 100644
index 257acbe..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBTextPaginationInterfaces.swift
+++ /dev/null
@@ -1,32 +0,0 @@
-
-import Foundation
-import UIKit
-
-struct RDEPUBPageBreakDecision {
-
-    var range: NSRange
-
-    var reason: RDEPUBTextPageBreakReason
-
-    var diagnostics: [String]
-}
-
-protocol RDEPUBChapterPageCounting {
-
-    func pageRanges(
-        for attributedString: NSAttributedString,
-        pageSize: CGSize,
-        config: RDEPUBTextLayoutConfig,
-        fragmentOffsets: [String: Int]
-    ) -> [RDEPUBPageBreakDecision]
-}
-
-protocol RDEPUBPageFrameBuilding {
-
-    func makeFrames(
-        attributedString: NSAttributedString,
-        pageSize: CGSize,
-        config: RDEPUBTextLayoutConfig,
-        fragmentOffsets: [String: Int]
-    ) -> [RDEPUBTextLayoutFrame]
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBTextPaginationSupport.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBTextPaginationSupport.swift
deleted file mode 100644
index a7ab73c..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBTextPaginationSupport.swift
+++ /dev/null
@@ -1,19 +0,0 @@
-import CoreText
-import UIKit
-
-extension NSAttributedString {
-
-    func rd_paginatedFrames(
-        size: CGSize,
-        fragmentOffsets: [String: Int] = [:],
-        config: RDEPUBTextLayoutConfig = .default
-    ) -> [RDEPUBTextLayoutFrame] {
-        let factory = RDEPUBCoreTextPageFrameFactory(attributedString: self, pageSize: size, config: config)
-        let counter = RDEPUBChapterPageCounter(factory: factory)
-        return counter.layoutFrames(fragmentOffsets: fragmentOffsets)
-    }
-
-    func ss_pageRanges(size: CGSize) -> [NSRange] {
-        rd_paginatedFrames(size: size).map(\.contentRange)
-    }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBChapterData.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBChapterData.swift
deleted file mode 100644
index cdd4d58..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBChapterData.swift
+++ /dev/null
@@ -1,380 +0,0 @@
-import UIKit
-
-public final class RDEPUBChapterData {
-
-    public let chapter: RDEPUBTextChapter
-
-    public let indexTable: RDEPUBTextIndexTable
-
-    public init(chapter: RDEPUBTextChapter, indexTable: RDEPUBTextIndexTable) {
-        self.chapter = chapter
-        self.indexTable = indexTable
-    }
-
-    public var chapterIndex: Int { chapter.chapterIndex }
-
-    public var spineIndex: Int { chapter.spineIndex }
-
-    public var href: String { chapter.href }
-
-    public var title: String { chapter.title }
-
-    public var attributedContent: NSAttributedString { chapter.attributedContent }
-
-    public var pages: [RDEPUBTextPage] { chapter.pages }
-
-    public var pageCount: Int { chapter.pages.count }
-
-    public var fragmentOffsets: [String: Int] { chapter.fragmentOffsets }
-
-    public var chapterInfo: EPUBChapterInfo {
-        EPUBChapterInfo(spineIndex: spineIndex, title: title, pageCount: pageCount)
-    }
-
-    public var absolutePageRange: ClosedRange? {
-        guard let firstPage = pages.first, let lastPage = pages.last else { return nil }
-        return firstPage.absolutePageIndex...lastPage.absolutePageIndex
-    }
-
-    public func page(containing absoluteOffset: Int) -> RDEPUBTextPage? {
-        chapter.pages.first { NSLocationInRange(absoluteOffset, $0.contentRange) }
-    }
-
-    public func pageNumber(containing absoluteOffset: Int) -> Int? {
-        page(containing: absoluteOffset)?.absolutePageIndex
-    }
-
-    public func page(atAbsolutePageIndex absolutePageIndex: Int) -> RDEPUBTextPage? {
-        chapter.pages.first { $0.absolutePageIndex == absolutePageIndex }
-    }
-
-    public func page(atPageNumber pageNumber: Int) -> RDEPUBTextPage? {
-        guard pageNumber > 0, pages.indices.contains(pageNumber - 1) else { return nil }
-        return pages[pageNumber - 1]
-    }
-
-    public func anchor(forAbsoluteIndex index: Int) -> RDEPUBTextAnchor {
-        indexTable.anchor(forAbsoluteIndex: index, in: chapter)
-    }
-
-    public func anchor(forGlobalIndex index: Int) -> RDEPUBTextAnchor? {
-        indexTable.anchor(forGlobalIndex: index)
-    }
-
-    public func rangeAnchor(for absoluteRange: NSRange) -> RDEPUBTextRangeAnchor {
-        let start = anchor(forAbsoluteIndex: absoluteRange.location)
-        let end = anchor(forAbsoluteIndex: absoluteRange.location + absoluteRange.length)
-        return RDEPUBTextRangeAnchor(start: start, end: end)
-    }
-
-    public func globalRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange {
-        indexTable.globalRange(for: rangeAnchor)
-    }
-
-    public func selection(from absoluteRange: NSRange, bookIdentifier: String?) -> RDEPUBSelection? {
-        guard page(containing: absoluteRange.location) != nil else { return nil }
-        let location = self.location(for: absoluteRange, bookIdentifier: bookIdentifier)
-        let text = chapter.attributedContent.attributedSubstring(from: absoluteRange).string
-        let rangeInfo = RDEPUBTextOffsetRangeInfo(
-            href: chapter.href,
-            start: absoluteRange.location,
-            end: absoluteRange.location + absoluteRange.length
-        ).jsonString()
-        return RDEPUBSelection(
-            bookIdentifier: bookIdentifier,
-            location: location,
-            text: text,
-            rangeInfo: rangeInfo
-        )
-    }
-
-    public func location(for absoluteRange: NSRange, bookIdentifier: String?) -> RDEPUBLocation {
-        indexTable.location(
-            for: rangeAnchor(for: absoluteRange),
-            in: chapter,
-            bookIdentifier: bookIdentifier
-        )
-    }
-
-    public func location(forPage page: RDEPUBTextPage, bookIdentifier: String?) -> RDEPUBLocation {
-        location(for: page.contentRange, bookIdentifier: bookIdentifier)
-    }
-
-    public func page(for location: RDEPUBLocation) -> RDEPUBTextPage? {
-        guard let range = absoluteRange(for: location) else { return nil }
-        return page(containing: range.location)
-    }
-
-    public func page(for searchMatch: RDEPUBSearchMatch) -> RDEPUBTextPage? {
-        guard let range = absoluteRange(for: searchMatch) else { return nil }
-        return page(containing: range.location)
-    }
-
-    public func absoluteRange(for location: RDEPUBLocation) -> NSRange? {
-        if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(location.rangeCFI),
-           let rangeAnchor = indexTable.rangeAnchor(for: cfiRange) {
-            return indexTable.chapterRange(for: rangeAnchor)
-        }
-
-        if let cfi = RDEPUBCFICompatibility.parseLossy(location.cfi),
-           let anchor = indexTable.anchor(for: cfi) {
-            return NSRange(location: indexTable.chapterOffset(for: anchor), length: 1)
-        }
-
-        if let rangeAnchor = location.rangeAnchor {
-            return indexTable.chapterRange(for: rangeAnchor)
-        }
-
-        if let fragment = location.fragment,
-           let offset = fragmentOffsets[fragment] {
-            return NSRange(location: offset, length: 1)
-        }
-
-        let lastOffset = max(attributedContent.length - 1, 0)
-        let offset = min(lastOffset, max(0, Int(round(Double(lastOffset) * location.navigationProgression))))
-        return NSRange(location: offset, length: 1)
-    }
-
-    public func absoluteRange(for highlight: RDEPUBHighlight) -> NSRange? {
-        if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(highlight.location.rangeCFI),
-           let rangeAnchor = indexTable.rangeAnchor(for: cfiRange) {
-            return indexTable.chapterRange(for: rangeAnchor)
-        }
-
-        if let endpointRange = chapterRange(fromLocationCFIEndpoints: highlight.location) {
-            return endpointRange
-        }
-
-        if let rangeAnchor = highlight.location.rangeAnchor {
-            return indexTable.chapterRange(for: rangeAnchor)
-        }
-        return RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange
-    }
-
-    public func absoluteRange(for searchMatch: RDEPUBSearchMatch) -> NSRange? {
-        if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(searchMatch.rangeCFI),
-           let rangeAnchor = indexTable.rangeAnchor(for: cfiRange) {
-            return indexTable.chapterRange(for: rangeAnchor)
-        }
-        if let cfi = RDEPUBCFICompatibility.parseLossy(searchMatch.cfi),
-           let anchor = indexTable.anchor(for: cfi) {
-            let location = indexTable.chapterOffset(for: anchor)
-            return NSRange(
-                location: location,
-                length: recoveredSearchRangeLength(for: searchMatch, cfi: cfi, startOffset: location)
-            )
-        }
-        if let location = searchMatch.rangeLocation {
-            return NSRange(location: location, length: max(searchMatch.rangeLength, 1))
-        }
-        if let rangeAnchor = searchMatch.rangeAnchor {
-            return indexTable.chapterRange(for: rangeAnchor)
-        }
-        return nil
-    }
-
-    public func globalRange(for location: RDEPUBLocation) -> NSRange? {
-        if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(location.rangeCFI),
-           let rangeAnchor = indexTable.rangeAnchor(for: cfiRange) {
-            return indexTable.globalRange(for: rangeAnchor)
-        }
-
-        if let cfi = RDEPUBCFICompatibility.parseLossy(location.cfi),
-           let anchor = indexTable.anchor(for: cfi) {
-            return NSRange(location: indexTable.globalIndex(for: anchor), length: 1)
-        }
-
-        if let rangeAnchor = location.rangeAnchor {
-            return indexTable.globalRange(for: rangeAnchor)
-        }
-        guard let chapterRange = absoluteRange(for: location),
-              let chapterStart = indexTable.chapterStartOffset(forFileIndex: spineIndex) else {
-            return nil
-        }
-        return NSRange(location: chapterStart + chapterRange.location, length: chapterRange.length)
-    }
-
-    public func globalRange(for highlight: RDEPUBHighlight) -> NSRange? {
-        if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(highlight.location.rangeCFI),
-           let rangeAnchor = indexTable.rangeAnchor(for: cfiRange) {
-            return indexTable.globalRange(for: rangeAnchor)
-        }
-
-        if let endpointRange = globalRange(fromLocationCFIEndpoints: highlight.location) {
-            return endpointRange
-        }
-
-        if let rangeAnchor = highlight.location.rangeAnchor {
-            return indexTable.globalRange(for: rangeAnchor)
-        }
-        guard let chapterRange = absoluteRange(for: highlight),
-              let chapterStart = indexTable.chapterStartOffset(forFileIndex: spineIndex) else {
-            return nil
-        }
-        return NSRange(location: chapterStart + chapterRange.location, length: chapterRange.length)
-    }
-
-    public func globalRange(for searchMatch: RDEPUBSearchMatch) -> NSRange? {
-        if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(searchMatch.rangeCFI),
-           let rangeAnchor = indexTable.rangeAnchor(for: cfiRange) {
-            return indexTable.globalRange(for: rangeAnchor)
-        }
-        if let cfi = RDEPUBCFICompatibility.parseLossy(searchMatch.cfi),
-           let anchor = indexTable.anchor(for: cfi) {
-            let chapterLocation = indexTable.chapterOffset(for: anchor)
-            return NSRange(
-                location: indexTable.globalIndex(for: anchor),
-                length: recoveredSearchRangeLength(for: searchMatch, cfi: cfi, startOffset: chapterLocation)
-            )
-        }
-        if let rangeAnchor = searchMatch.rangeAnchor {
-            return indexTable.globalRange(for: rangeAnchor)
-        }
-        guard let chapterRange = absoluteRange(for: searchMatch),
-              let chapterStart = indexTable.chapterStartOffset(forFileIndex: spineIndex) else {
-            return nil
-        }
-        return NSRange(location: chapterStart + chapterRange.location, length: chapterRange.length)
-    }
-
-    public func highlights(on page: RDEPUBTextPage, from allHighlights: [RDEPUBHighlight]) -> [RDEPUBHighlight] {
-        let pageRange = absoluteOffsetRange(for: page)
-        return allHighlights.filter { highlight in
-            guard highlight.location.href == chapter.href else { return false }
-            if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(highlight.location.rangeCFI),
-               let rangeAnchor = indexTable.rangeAnchor(for: cfiRange) {
-                return NSIntersectionRange(indexTable.chapterRange(for: rangeAnchor), page.contentRange).length > 0
-            }
-            if let anchor = highlight.location.rangeAnchor?.start {
-                return pageRange.contains(absoluteOffset(for: anchor))
-            }
-            guard let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else {
-                return false
-            }
-            return NSIntersectionRange(range, page.contentRange).length > 0
-        }
-    }
-
-    public func searchMatches(on page: RDEPUBTextPage, from matches: [RDEPUBSearchMatch]) -> [RDEPUBSearchMatch] {
-        return matches.filter { match in
-            guard match.href == chapter.href else { return false }
-            if let range = absoluteRange(for: match) {
-                return NSIntersectionRange(range, page.contentRange).length > 0
-            }
-            return false
-        }
-    }
-
-    public func searchResults(on page: RDEPUBTextPage, from matches: [RDEPUBSearchMatch]) -> [RDEPUBSearchMatch] {
-        searchMatches(on: page, from: matches)
-    }
-
-    public func pageNumber(for location: RDEPUBLocation) -> Int? {
-        guard let page = page(for: location) else { return nil }
-        return page.absolutePageIndex + 1
-    }
-
-    public func pageNumber(for searchMatch: RDEPUBSearchMatch) -> Int? {
-        guard let page = page(for: searchMatch) else { return nil }
-        return page.absolutePageIndex + 1
-    }
-
-    public func contains(
-        tableOfContentsItem item: EPUBTableOfContentsItem,
-        normalizer: (String) -> String?
-    ) -> Bool {
-        guard let chapterHref = normalizer(href),
-              let itemHref = normalizer(item.href.components(separatedBy: "#").first ?? item.href) else {
-            return false
-        }
-        return chapterHref == itemHref
-    }
-
-    public func tableOfContentsItems(
-        from items: [EPUBTableOfContentsItem],
-        normalizer: (String) -> String?
-    ) -> [EPUBTableOfContentsItem] {
-        items.flatMap { item -> [EPUBTableOfContentsItem] in
-            let descendants = tableOfContentsItems(from: item.children, normalizer: normalizer)
-            return contains(tableOfContentsItem: item, normalizer: normalizer) ? [item] + descendants : descendants
-        }
-    }
-
-    public func primaryTableOfContentsItem(
-        from items: [EPUBTableOfContentsItem],
-        normalizer: (String) -> String?
-    ) -> EPUBTableOfContentsItem? {
-        tableOfContentsItems(from: items, normalizer: normalizer).first
-    }
-
-    public func applyHighlights(
-        to content: NSMutableAttributedString,
-        page: RDEPUBTextPage,
-        highlights: [RDEPUBHighlight]
-    ) {
-        for highlight in highlights {
-            guard highlight.location.href == chapter.href else { continue }
-            guard let range = absoluteRange(for: highlight) else { continue }
-            let overlap = NSIntersectionRange(range, page.contentRange)
-            guard overlap.length > 0 else { continue }
-            let relativeRange = NSRange(location: overlap.location - page.pageStartOffset, length: overlap.length)
-            guard relativeRange.location >= 0,
-                  relativeRange.location + relativeRange.length <= content.length else { continue }
-
-            switch highlight.style {
-            case .highlight:
-                content.addAttribute(kRDEPUBHighlightAttributeName, value: highlight.uiColor, range: relativeRange)
-            case .underline:
-                content.addAttribute(kRDEPUBUnderlineAttributeName, value: highlight.uiColor, range: relativeRange)
-            }
-        }
-    }
-
-    private func absoluteOffsetRange(for page: RDEPUBTextPage) -> Range {
-        let lowerBound = page.pageStartOffset
-        let upperBound = page.pageEndOffset + 1
-        return lowerBound.. Int {
-        indexTable.chapterOffset(for: anchor)
-    }
-
-    private func chapterRange(fromLocationCFIEndpoints location: RDEPUBLocation) -> NSRange? {
-        guard let startCFI = RDEPUBCFICompatibility.parseLossy(location.cfi),
-              let endCFI = RDEPUBCFICompatibility.parseLossy(location.lastCFI ?? location.cfi),
-              let startAnchor = indexTable.anchor(for: startCFI),
-              let endAnchor = indexTable.anchor(for: endCFI),
-              startAnchor.fileIndex == endAnchor.fileIndex else {
-            return nil
-        }
-        let start = indexTable.chapterOffset(for: startAnchor)
-        let end = indexTable.chapterOffset(for: endAnchor)
-        return NSRange(location: min(start, end), length: max(abs(end - start), 1))
-    }
-
-    private func globalRange(fromLocationCFIEndpoints location: RDEPUBLocation) -> NSRange? {
-        guard let startCFI = RDEPUBCFICompatibility.parseLossy(location.cfi),
-              let endCFI = RDEPUBCFICompatibility.parseLossy(location.lastCFI ?? location.cfi),
-              let startAnchor = indexTable.anchor(for: startCFI),
-              let endAnchor = indexTable.anchor(for: endCFI),
-              startAnchor.fileIndex == endAnchor.fileIndex else {
-            return nil
-        }
-        let start = indexTable.globalIndex(for: startAnchor)
-        let end = indexTable.globalIndex(for: endAnchor)
-        return NSRange(location: min(start, end), length: max(abs(end - start), 1))
-    }
-
-    private func recoveredSearchRangeLength(
-        for searchMatch: RDEPUBSearchMatch,
-        cfi: RDEPUBCFI,
-        startOffset: Int
-    ) -> Int {
-        let exactLength = cfi.textAssertion?.exact?.utf16.count ?? 0
-        let fallbackLength = max(searchMatch.rangeLength, 1)
-        let candidateLength = exactLength > 0 ? exactLength : fallbackLength
-        let remainingLength = max(attributedContent.length - startOffset, 1)
-        return min(max(candidateLength, 1), remainingLength)
-    }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBDTCoreTextRenderer.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBDTCoreTextRenderer.swift
deleted file mode 100644
index 28c1e63..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBDTCoreTextRenderer.swift
+++ /dev/null
@@ -1,135 +0,0 @@
-import UIKit
-
-#if canImport(DTCoreText)
-import DTCoreText
-#endif
-
-public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
-    public init() {}
-
-    public static var isAvailable: Bool {
-#if canImport(DTCoreText)
-        return true
-#else
-        return false
-#endif
-    }
-
-    public func renderChapter(
-        request: RDEPUBTextChapterRenderRequest
-    ) throws -> RDEPUBRenderedChapterContent {
-#if canImport(DTCoreText)
-        let chapterContext = request.context
-        guard let data = chapterContext.html.data(using: .utf8) else {
-            throw RDEPUBTextRenderingError.htmlEncodingFailed
-        }
-
-        guard let rendered = makeAttributedString(from: data, request: request) else {
-            return fallbackRenderedContent(request: request)
-        }
-
-        let attributedString = NSMutableAttributedString(attributedString: rendered)
-        RDEPUBSemanticMarkerInjector.applyPaginationSemantics(in: attributedString)
-        let fragmentOffsets = RDEPUBFragmentMarkerInjector.extractFragmentOffsets(from: attributedString)
-        RDEPUBTextRendererSupport.normalizeReadingAttributes(
-            in: attributedString,
-            style: request.style,
-            layoutConfig: request.layoutConfig ?? .default
-        )
-        return RDEPUBRenderedChapterContent(
-            attributedString: attributedString,
-            fragmentOffsets: fragmentOffsets,
-            resourceDiagnostics: chapterContext.resourceDiagnostics
-        )
-#else
-        return fallbackRenderedContent(request: request)
-#endif
-    }
-
-    public func renderChapter(
-        html: String,
-        baseURL: URL?,
-        style: RDEPUBTextRenderStyle
-    ) throws -> RDEPUBRenderedChapterContent {
-        let request = RDEPUBTextTypesetterPipeline().makeRequest(
-            from: RDEPUBTypesettingInput(
-                href: "",
-                spineIndex: nil,
-                title: "",
-                rawHTML: html,
-                baseURL: baseURL,
-                style: style,
-                resourceResolver: nil
-            )
-        ).request
-        return try renderChapter(request: request)
-    }
-
-    private func fallbackRenderedContent(request: RDEPUBTextChapterRenderRequest) -> RDEPUBRenderedChapterContent {
-        let attributedString = RDEPUBTextRendererSupport.fallbackAttributedString(for: request.context.html, style: request.style)
-        RDEPUBSemanticMarkerInjector.applyPaginationSemantics(in: attributedString)
-        let fragmentOffsets = RDEPUBFragmentMarkerInjector.extractFragmentOffsets(from: attributedString)
-        RDEPUBTextRendererSupport.normalizeReadingAttributes(
-            in: attributedString,
-            style: request.style,
-            layoutConfig: request.layoutConfig ?? .default
-        )
-        return RDEPUBRenderedChapterContent(
-            attributedString: attributedString,
-            fragmentOffsets: fragmentOffsets,
-            resourceDiagnostics: request.context.resourceDiagnostics
-        )
-    }
-
-#if canImport(DTCoreText)
-
-    private func makeAttributedString(from data: Data, request: RDEPUBTextChapterRenderRequest) -> NSAttributedString? {
-        let builder = DTHTMLAttributedStringBuilder(
-            html: data,
-            options: dtOptions(request: request),
-            documentAttributes: nil
-        )
-        builder?.willFlushCallback = { element in
-            guard let element else { return }
-            RDEPUBAttachmentNormalizer.prepareHTMLElementForReaderRendering(
-                element,
-                style: request.style,
-                maxImageSize: resolvedMaxImageSize(for: request)
-            )
-        }
-        return builder?.generatedAttributedString()
-    }
-
-    private func dtOptions(request: RDEPUBTextChapterRenderRequest) -> [AnyHashable: Any] {
-        let style = request.style
-        let maxImageSize = resolvedMaxImageSize(for: request)
-        var options: [AnyHashable: Any] = [
-            NSTextSizeMultiplierDocumentOption: 1.0,
-            DTDefaultFontFamily: style.font.familyName,
-            DTDefaultFontName: style.font.fontName,
-            DTDefaultFontSize: style.font.pointSize,
-            DTDefaultLineHeightMultiplier: max((style.font.lineHeight + style.lineSpacing) / max(style.font.lineHeight, 1), 1),
-            DTUseiOS6Attributes: true,
-            DTMaxImageSize: NSValue(cgSize: maxImageSize)
-        ]
-
-        if let baseURL = request.context.baseURL {
-            options[NSBaseURLDocumentOption] = baseURL
-        }
-        if let textColor = style.textColor {
-            options[DTDefaultTextColor] = textColor
-        }
-
-        return options
-    }
-
-    private func resolvedMaxImageSize(for request: RDEPUBTextChapterRenderRequest) -> CGSize {
-        let layoutConfig = request.layoutConfig ?? .default
-        let fallbackPageSize = request.pageSize ?? layoutConfig.fallbackViewportSize
-        let contentRect = layoutConfig.contentRect(fallback: fallbackPageSize)
-        let maxWidth = max(round(contentRect.width), 1)
-        let maxHeight = max(round(contentRect.height * layoutConfig.imageMaxHeightRatio), 1)
-        return CGSize(width: maxWidth, height: maxHeight)
-    }
-#endif
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBTextIndexTable.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBTextIndexTable.swift
deleted file mode 100644
index 5d01418..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBTextIndexTable.swift
+++ /dev/null
@@ -1,542 +0,0 @@
-
-import Foundation
-
-public struct RDEPUBRowColumnIndex: Codable, Equatable {
-
-    public let row: Int
-
-    public let startOffset: Int
-
-    public let endOffset: Int
-
-    public init(row: Int, startOffset: Int, endOffset: Int) {
-        self.row = row
-        self.startOffset = startOffset
-        self.endOffset = endOffset
-    }
-
-    public func contains(_ offset: Int) -> Bool {
-        offset >= startOffset && offset <= endOffset
-    }
-}
-
-public struct RDEPUBTextIndexTable {
-
-    public let chapterStartOffsets: [Int]
-
-    public let chapterLengths: [Int]
-
-    public let hrefToChapterIndex: [String: Int]
-
-    public let hrefToFileIndex: [String: Int]
-
-    public let fragmentOffsetsByHref: [String: [String: Int]]
-
-    public let fileIndexToHref: [Int: String]
-
-    public let fileRowColumnMap: [Int: [RDEPUBRowColumnIndex]]
-
-    public let fileTextMap: [Int: String]
-
-    public let fileCFIMap: [Int: RDEPUBCFIMap]
-
-    public var totalCharacterCount: Int {
-        guard let lastIndex = chapterStartOffsets.indices.last,
-              chapterLengths.indices.contains(lastIndex) else {
-            return 0
-        }
-        return chapterStartOffsets[lastIndex] + chapterLengths[lastIndex]
-    }
-
-    public init(chapters: [RDEPUBTextChapter]) {
-        var offsets: [Int] = []
-        var lengths: [Int] = []
-        var hrefMap: [String: Int] = [:]
-        var hrefToFileMap: [String: Int] = [:]
-        var fragmentMap: [String: [String: Int]] = [:]
-        var fileIndexMap: [Int: String] = [:]
-        var rowColumnMap: [Int: [RDEPUBRowColumnIndex]] = [:]
-        var textMap: [Int: String] = [:]
-        var cfiMap: [Int: RDEPUBCFIMap] = [:]
-        var running = 0
-
-        for (index, chapter) in chapters.enumerated() {
-            hrefMap[chapter.href] = index
-            hrefToFileMap[chapter.href] = chapter.spineIndex
-            offsets.append(running)
-            lengths.append(chapter.attributedContent.length)
-            running += chapter.attributedContent.length
-            fragmentMap[chapter.href] = chapter.fragmentOffsets
-            fileIndexMap[chapter.spineIndex] = chapter.href
-            let chapterText = chapter.attributedContent.string
-            rowColumnMap[chapter.spineIndex] = Self.makeRowColumnIndices(for: chapterText)
-            textMap[chapter.spineIndex] = chapterText
-            if let chapterCFIMap = chapter.cfiMap {
-                cfiMap[chapter.spineIndex] = chapterCFIMap
-            }
-        }
-
-        self.chapterStartOffsets = offsets
-        self.chapterLengths = lengths
-        self.hrefToChapterIndex = hrefMap
-        self.hrefToFileIndex = hrefToFileMap
-        self.fragmentOffsetsByHref = fragmentMap
-        self.fileIndexToHref = fileIndexMap
-        self.fileRowColumnMap = rowColumnMap
-        self.fileTextMap = textMap
-        self.fileCFIMap = cfiMap
-    }
-
-    public func anchor(forAbsoluteIndex index: Int, in chapter: RDEPUBTextChapter) -> RDEPUBTextAnchor {
-        let normalizedIndex = clampedOffset(index, in: chapter)
-        let fragmentID = nearestFragmentID(beforeOrAt: normalizedIndex, in: chapter)
-        let row = row(forAbsoluteIndex: normalizedIndex, inFileIndex: chapter.spineIndex)
-        let column = column(forAbsoluteIndex: normalizedIndex, inFileIndex: chapter.spineIndex)
-        return RDEPUBTextAnchor(
-            fileIndex: chapter.spineIndex,
-            row: row,
-            column: column,
-            chapterOffset: normalizedIndex,
-            fragmentID: fragmentID
-        )
-    }
-
-    public func anchor(for location: RDEPUBLocation) -> RDEPUBTextAnchor? {
-        if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(location.rangeCFI),
-           let anchor = anchor(for: cfiRange.start) {
-            return anchor
-        }
-
-        if let anchor = location.rangeAnchor?.start {
-            return anchor
-        }
-
-        if let cfi = RDEPUBCFICompatibility.parseLossy(location.cfi),
-           let anchor = anchor(for: cfi) {
-            return anchor
-        }
-
-        guard let chapterIndex = hrefToChapterIndex[location.href],
-              let fileIndex = hrefToFileIndex[location.href] else { return nil }
-        let fragments = fragmentOffsetsByHref[location.href] ?? [:]
-        let chapterOffset: Int
-
-        if let fragment = location.fragment, let fragmentOffset = fragments[fragment] {
-            chapterOffset = fragmentOffset
-        } else {
-            let estimatedLength = max(chapterLengths.indices.contains(chapterIndex) ? chapterLengths[chapterIndex] : 0, 1)
-            let lastOffset = max(estimatedLength - 1, 0)
-            chapterOffset = min(lastOffset, max(0, Int(round(Double(lastOffset) * location.navigationProgression))))
-        }
-
-        return RDEPUBTextAnchor(
-            fileIndex: fileIndex,
-            row: row(forAbsoluteIndex: chapterOffset, inFileIndex: fileIndex),
-            column: column(forAbsoluteIndex: chapterOffset, inFileIndex: fileIndex),
-            chapterOffset: chapterOffset,
-            fragmentID: location.fragment
-        )
-    }
-
-    public func anchor(for cfi: RDEPUBCFI) -> RDEPUBTextAnchor? {
-        let resolved = RDEPUBCFIResolver.resolve(cfi)
-        let fileIndex: Int
-        if let resolvedFileIndex = resolved.fileIndex {
-            fileIndex = resolvedFileIndex
-        } else if let href = resolved.href, let mappedFileIndex = hrefToFileIndex[href] {
-            fileIndex = mappedFileIndex
-        } else {
-            return nil
-        }
-
-        guard let href = href(for: fileIndex) ?? resolved.href else { return nil }
-        let chapterLength = max(chapterLength(forFileIndex: fileIndex) ?? 0, 1)
-        let lastOffset = max(chapterLength - 1, 0)
-        let recovery = RDEPUBCFIRecoveryEngine.recover(
-            cfi: cfi,
-            cfiMap: fileCFIMap[fileIndex],
-            chapterText: fileTextMap[fileIndex],
-            fragmentOffsets: fragmentOffsetsByHref[href] ?? [:],
-            fallbackOffset: resolved.chapterOffset,
-            lastOffset: lastOffset
-        )
-        let chapterOffset = recovery?.chapterOffset ?? min(max(resolved.chapterOffset ?? 0, 0), lastOffset)
-        let fragmentID = resolved.fragmentID ?? nearestFragmentID(beforeOrAt: chapterOffset, inHref: href)
-
-        return RDEPUBTextAnchor(
-            fileIndex: fileIndex,
-            row: row(forAbsoluteIndex: chapterOffset, inFileIndex: fileIndex),
-            column: column(forAbsoluteIndex: chapterOffset, inFileIndex: fileIndex),
-            chapterOffset: chapterOffset,
-            fragmentID: fragmentID
-        )
-    }
-
-    public func rangeAnchor(for cfiRange: RDEPUBCFIRange) -> RDEPUBTextRangeAnchor? {
-        guard let start = anchor(for: cfiRange.start),
-              let end = anchor(for: cfiRange.end),
-              start.fileIndex == end.fileIndex else {
-            return nil
-        }
-        return RDEPUBTextRangeAnchor(start: start, end: end)
-    }
-
-    public func cfi(for anchor: RDEPUBTextAnchor) -> RDEPUBCFI? {
-        guard let href = href(for: anchor.fileIndex) else { return nil }
-        let chapterOffset = chapterOffset(for: anchor)
-        if let marker = preciseMarker(fileIndex: anchor.fileIndex, chapterOffset: chapterOffset) {
-            let localOffset = max(chapterOffset - (marker.chapterOffset ?? 0), 0)
-            return RDEPUBCFIGenerator.makeCFI(
-                href: href,
-                fileIndex: anchor.fileIndex,
-                contentPath: marker.cfiPath,
-                characterOffset: localOffset,
-                textAssertion: textAssertion(fileIndex: anchor.fileIndex, offset: chapterOffset)
-            )
-        }
-        return RDEPUBCFIGenerator.makeOffsetCFI(
-            href: href,
-            fileIndex: anchor.fileIndex,
-            chapterOffset: chapterOffset,
-            fragmentID: anchor.fragmentID,
-            textAssertion: textAssertion(fileIndex: anchor.fileIndex, offset: chapterOffset)
-        )
-    }
-
-    public func cfiRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> RDEPUBCFIRange? {
-        guard rangeAnchor.start.fileIndex == rangeAnchor.end.fileIndex,
-              let href = href(for: rangeAnchor.start.fileIndex) else {
-            return nil
-        }
-        let startOffset = chapterOffset(for: rangeAnchor.start)
-        let endOffset = chapterOffset(for: rangeAnchor.end)
-        let startCFI = cfi(for: rangeAnchor.start)
-        let endCFI = cfi(for: rangeAnchor.end)
-        if let startCFI, let endCFI {
-            let parent = RDEPUBCFI(
-                packagePath: startCFI.packagePath.commonPrefix(with: endCFI.packagePath),
-                contentPath: startCFI.contentPath.commonPrefix(with: endCFI.contentPath)
-            )
-            return RDEPUBCFIRange(parent: parent, start: startCFI, end: endCFI)
-        }
-        return RDEPUBCFIGenerator.makeOffsetRangeCFI(
-            href: href,
-            fileIndex: rangeAnchor.start.fileIndex,
-            startOffset: startOffset,
-            endOffset: endOffset,
-            fragmentID: rangeAnchor.start.fragmentID,
-            startTextAssertion: textAssertion(
-                fileIndex: rangeAnchor.start.fileIndex,
-                offset: startOffset
-            ),
-            endTextAssertion: textAssertion(
-                fileIndex: rangeAnchor.end.fileIndex,
-                offset: endOffset
-            )
-        )
-    }
-
-    public func anchor(forGlobalIndex index: Int) -> RDEPUBTextAnchor? {
-        guard let fileIndex = fileIndex(forCharacterPosition: index),
-              let href = href(for: fileIndex) else {
-            return nil
-        }
-
-        let chapterOffset = localOffsetInFile(at: fileIndex, forGlobalPosition: index) ?? 0
-        let fragmentID = nearestFragmentID(beforeOrAt: chapterOffset, inHref: href)
-        return RDEPUBTextAnchor(
-            fileIndex: fileIndex,
-            row: row(forAbsoluteIndex: chapterOffset, inFileIndex: fileIndex),
-            column: column(forAbsoluteIndex: chapterOffset, inFileIndex: fileIndex),
-            chapterOffset: chapterOffset,
-            fragmentID: fragmentID
-        )
-    }
-
-    public func pageNumber(for anchor: RDEPUBTextAnchor, in book: RDEPUBTextBook) -> Int? {
-        guard let chapter = book.chapters.first(where: { $0.spineIndex == anchor.fileIndex }) else { return nil }
-        let basePageIndex = chapter.pages.first?.absolutePageIndex ?? 0
-        let resolvedOffset = chapterOffset(for: anchor)
-        return chapter.pages.firstIndex { page in
-            NSLocationInRange(resolvedOffset, page.contentRange)
-        }.map { $0 + basePageIndex }
-    }
-
-    public func href(for fileIndex: Int) -> String? {
-        fileIndexToHref[fileIndex]
-    }
-
-    public func chapterIndex(for href: String) -> Int? {
-        hrefToChapterIndex[href]
-    }
-
-    public func chapterStartOffset(forFileIndex fileIndex: Int) -> Int? {
-        guard let href = href(for: fileIndex),
-              let chapterIndex = hrefToChapterIndex[href],
-              chapterStartOffsets.indices.contains(chapterIndex) else {
-            return nil
-        }
-        return chapterStartOffsets[chapterIndex]
-    }
-
-    public func chapterLength(forFileIndex fileIndex: Int) -> Int? {
-        guard let href = href(for: fileIndex),
-              let chapterIndex = hrefToChapterIndex[href],
-              chapterLengths.indices.contains(chapterIndex) else {
-            return nil
-        }
-        return chapterLengths[chapterIndex]
-    }
-
-    public func chapterOffset(for anchor: RDEPUBTextAnchor) -> Int {
-        anchor.chapterOffset
-    }
-
-    public func globalIndex(for anchor: RDEPUBTextAnchor) -> Int {
-        let chapterOffset = chapterOffset(for: anchor)
-        let baseOffset = chapterStartOffset(forFileIndex: anchor.fileIndex) ?? 0
-        let chapterLength = max(chapterLength(forFileIndex: anchor.fileIndex) ?? 0, 0)
-        let lastOffset = max(chapterLength - 1, 0)
-        return baseOffset + min(max(chapterOffset, 0), lastOffset)
-    }
-
-    public func absoluteIndex(for anchor: RDEPUBTextAnchor) -> Int {
-        globalIndex(for: anchor)
-    }
-
-    public func chapterRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange {
-        let start = chapterOffset(for: rangeAnchor.start)
-        let end = max(start, chapterOffset(for: rangeAnchor.end))
-        return NSRange(location: start, length: max(end - start, 0))
-    }
-
-    public func globalRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange {
-        let start = globalIndex(for: rangeAnchor.start)
-        let end = max(start, globalIndex(for: rangeAnchor.end))
-        return NSRange(location: start, length: max(end - start, 0))
-    }
-
-    public func absoluteRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange {
-        globalRange(for: rangeAnchor)
-    }
-
-    public func location(
-        for anchor: RDEPUBTextAnchor,
-        in chapter: RDEPUBTextChapter,
-        bookIdentifier: String?
-    ) -> RDEPUBLocation {
-        let chapterOffset = self.chapterOffset(for: anchor)
-        let totalLength = max(chapter.attributedContent.length - 1, 1)
-        let progression = Double(min(max(chapterOffset, 0), totalLength)) / Double(totalLength)
-        return RDEPUBLocation(
-            bookIdentifier: bookIdentifier,
-            href: chapter.href,
-            progression: progression,
-            lastProgression: progression,
-            fragment: anchor.fragmentID,
-            rangeAnchor: RDEPUBTextRangeAnchor(start: anchor, end: anchor),
-            cfi: cfi(for: anchor)?.rawValue,
-            lastCFI: cfi(for: anchor)?.rawValue
-        )
-    }
-
-    public func location(
-        for rangeAnchor: RDEPUBTextRangeAnchor,
-        in chapter: RDEPUBTextChapter,
-        bookIdentifier: String?
-    ) -> RDEPUBLocation {
-        let start = chapterOffset(for: rangeAnchor.start)
-        let end = max(start, chapterOffset(for: rangeAnchor.end))
-        let totalLength = max(chapter.attributedContent.length - 1, 1)
-        let clampedStart = min(max(start, 0), totalLength)
-        let clampedEnd = min(max(end, clampedStart), totalLength)
-        return RDEPUBLocation(
-            bookIdentifier: bookIdentifier,
-            href: chapter.href,
-            progression: Double(clampedStart) / Double(totalLength),
-            lastProgression: Double(clampedEnd) / Double(totalLength),
-            fragment: rangeAnchor.start.fragmentID,
-            rangeAnchor: rangeAnchor,
-            cfi: cfi(for: rangeAnchor.start)?.rawValue,
-            lastCFI: cfi(for: rangeAnchor.end)?.rawValue,
-            rangeCFI: cfiRange(for: rangeAnchor)?.rawValue
-        )
-    }
-
-    public func row(forAbsoluteIndex index: Int, inFileIndex fileIndex: Int) -> Int {
-        guard let rows = fileRowColumnMap[fileIndex], !rows.isEmpty else { return 0 }
-        if let rowIndex = rows.first(where: { $0.contains(index) })?.row {
-            return rowIndex
-        }
-        return rows.last?.row ?? 0
-    }
-
-    public func column(forAbsoluteIndex index: Int, inFileIndex fileIndex: Int) -> Int {
-        guard let rows = fileRowColumnMap[fileIndex], !rows.isEmpty else { return 0 }
-        if let rowEntry = rows.first(where: { $0.contains(index) }) {
-            return max(index - rowEntry.startOffset, 0)
-        }
-        guard let lastRow = rows.last else { return 0 }
-        return max(index - lastRow.startOffset, 0)
-    }
-
-    public func chapterOffset(fileIndex: Int, row: Int, column: Int) -> Int? {
-        guard let rows = fileRowColumnMap[fileIndex], !rows.isEmpty else { return nil }
-        let normalizedRow = min(max(row, 0), rows.count - 1)
-        let rowEntry = rows[normalizedRow]
-        let maxColumn = max(rowEntry.endOffset - rowEntry.startOffset, 0)
-        return rowEntry.startOffset + min(max(column, 0), maxColumn)
-    }
-
-    public func absoluteIndex(fileIndex: Int, row: Int, column: Int) -> Int? {
-        guard let chapterOffset = chapterOffset(fileIndex: fileIndex, row: row, column: column),
-              let baseOffset = chapterStartOffset(forFileIndex: fileIndex) else {
-            return nil
-        }
-        return baseOffset + chapterOffset
-    }
-
-    public func fileIndex(forCharacterPosition index: Int) -> Int? {
-        guard totalCharacterCount > 0 else { return nil }
-        let normalized = min(max(index, 0), max(totalCharacterCount - 1, 0))
-        for (href, chapterIndex) in hrefToChapterIndex {
-            guard chapterStartOffsets.indices.contains(chapterIndex),
-                  chapterLengths.indices.contains(chapterIndex),
-                  let fileIndex = hrefToFileIndex[href] else {
-                continue
-            }
-
-            let start = chapterStartOffsets[chapterIndex]
-            let length = chapterLengths[chapterIndex]
-            let endExclusive = start + max(length, 1)
-            if normalized >= start && normalized < endExclusive {
-                return fileIndex
-            }
-        }
-        return fileIndexToHref.keys.sorted().last
-    }
-
-    public func localOffsetInFile(at fileIndex: Int, forGlobalPosition index: Int) -> Int? {
-        guard let start = chapterStartOffset(forFileIndex: fileIndex),
-              let chapterLength = chapterLength(forFileIndex: fileIndex) else {
-            return nil
-        }
-        let normalized = min(max(index, start), start + max(chapterLength - 1, 0))
-        return normalized - start
-    }
-
-    private func clampedOffset(_ index: Int, in chapter: RDEPUBTextChapter) -> Int {
-        let lastOffset = max(chapter.attributedContent.length - 1, 0)
-        return min(max(index, 0), lastOffset)
-    }
-
-    private func nearestFragmentID(beforeOrAt offset: Int, in chapter: RDEPUBTextChapter) -> String? {
-        var bestID: String?
-        var bestOffset = -1
-
-        for (id, fragOffset) in chapter.fragmentOffsets {
-            if fragOffset <= offset && fragOffset > bestOffset {
-                bestOffset = fragOffset
-                bestID = id
-            }
-        }
-
-        return bestID
-    }
-
-    private func nearestFragmentID(beforeOrAt offset: Int, inHref href: String) -> String? {
-        guard let fragmentOffsets = fragmentOffsetsByHref[href] else {
-            return nil
-        }
-
-        var bestID: String?
-        var bestOffset = -1
-        for (id, fragmentOffset) in fragmentOffsets where fragmentOffset <= offset && fragmentOffset > bestOffset {
-            bestOffset = fragmentOffset
-            bestID = id
-        }
-        return bestID
-    }
-
-    private func preciseMarker(fileIndex: Int, chapterOffset: Int) -> RDEPUBCFIMarker? {
-        guard let markers = fileCFIMap[fileIndex]?.markers else { return nil }
-        return markers
-            .filter {
-                guard let start = $0.chapterOffset,
-                      let length = $0.textNodeLength,
-                      length > 0 else {
-                    return false
-                }
-                return chapterOffset >= start && chapterOffset < start + length
-            }
-            .max { lhs, rhs in
-                let leftStart = lhs.chapterOffset ?? 0
-                let rightStart = rhs.chapterOffset ?? 0
-                if leftStart != rightStart {
-                    return leftStart < rightStart
-                }
-                return (lhs.textNodeLength ?? 0) > (rhs.textNodeLength ?? 0)
-            }
-    }
-
-    private func textAssertion(fileIndex: Int, offset: Int) -> RDEPUBCFITextAssertion? {
-        guard let text = fileTextMap[fileIndex], !text.isEmpty else { return nil }
-        let nsText = text as NSString
-        let length = nsText.length
-        guard length > 0 else { return nil }
-
-        let location = min(max(offset, 0), max(length - 1, 0))
-        let exactLength = min(16, max(length - location, 0))
-        guard exactLength > 0 else { return nil }
-
-        let prefixStart = max(location - 8, 0)
-        let suffixStart = min(location + exactLength, length)
-        return RDEPUBCFITextAssertion(
-            prefix: prefixStart < location
-                ? nsText.substring(with: NSRange(location: prefixStart, length: location - prefixStart))
-                : nil,
-            exact: nsText.substring(with: NSRange(location: location, length: exactLength)),
-            suffix: suffixStart < length
-                ? nsText.substring(with: NSRange(location: suffixStart, length: min(8, length - suffixStart)))
-                : nil
-        )
-    }
-
-    private static func makeRowColumnIndices(for text: String) -> [RDEPUBRowColumnIndex] {
-        let nsText = text as NSString
-        let length = nsText.length
-        guard length > 0 else {
-            return [RDEPUBRowColumnIndex(row: 0, startOffset: 0, endOffset: 0)]
-        }
-
-        var rows: [RDEPUBRowColumnIndex] = []
-        var rowNumber = 0
-        var lineStart = 0
-
-        nsText.enumerateSubstrings(
-            in: NSRange(location: 0, length: length),
-            options: [.byLines, .substringNotRequired]
-        ) { _, substringRange, enclosingRange, _ in
-            let startOffset = enclosingRange.location
-            let lineLength = max(substringRange.length, 0)
-            let endOffset = max(startOffset + max(lineLength - 1, 0), startOffset)
-            rows.append(
-                RDEPUBRowColumnIndex(
-                    row: rowNumber,
-                    startOffset: startOffset,
-                    endOffset: min(endOffset, max(length - 1, 0))
-                )
-            )
-            rowNumber += 1
-            lineStart = enclosingRange.location + enclosingRange.length
-        }
-
-        if rows.isEmpty {
-            rows.append(RDEPUBRowColumnIndex(row: 0, startOffset: 0, endOffset: max(length - 1, 0)))
-        } else if lineStart == length, text.hasSuffix("\n") {
-            rows.append(RDEPUBRowColumnIndex(row: rowNumber, startOffset: length, endOffset: length))
-        }
-
-        return rows
-    }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBTextPositionConverter.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBTextPositionConverter.swift
deleted file mode 100644
index 14bcab9..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBTextPositionConverter.swift
+++ /dev/null
@@ -1,70 +0,0 @@
-
-import Foundation
-
-public struct RDEPUBTextPositionConverter {
-
-    public let book: RDEPUBTextBook
-
-    public init(book: RDEPUBTextBook) {
-        self.book = book
-    }
-
-    public var totalCharacterCount: Int {
-        book.indexTable.totalCharacterCount
-    }
-
-    public func globalIndex(for anchor: RDEPUBTextAnchor) -> Int {
-        book.indexTable.globalIndex(for: anchor)
-    }
-
-    public func globalRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange {
-        book.indexTable.globalRange(for: rangeAnchor)
-    }
-
-    public func fileIndex(forCharacterPosition index: Int) -> Int? {
-        book.indexTable.fileIndex(forCharacterPosition: index)
-    }
-
-    public func localOffsetInFile(at fileIndex: Int, forGlobalPosition index: Int) -> Int? {
-        book.indexTable.localOffsetInFile(at: fileIndex, forGlobalPosition: index)
-    }
-
-    public func anchor(forCharacterPosition index: Int) -> RDEPUBTextAnchor? {
-        book.indexTable.anchor(forGlobalIndex: index)
-    }
-
-    public func anchor(for location: RDEPUBLocation) -> RDEPUBTextAnchor? {
-        book.indexTable.anchor(for: location)
-    }
-
-    public func pageNumber(for anchor: RDEPUBTextAnchor) -> Int? {
-        book.indexTable.pageNumber(for: anchor, in: book).map { $0 + 1 }
-    }
-
-    public func pageNumber(forCharacterPosition index: Int) -> Int? {
-        guard let anchor = anchor(forCharacterPosition: index) else {
-            return nil
-        }
-        return pageNumber(for: anchor)
-    }
-
-    public func location(
-        for anchor: RDEPUBTextAnchor,
-        bookIdentifier: String?
-    ) -> RDEPUBLocation? {
-        guard let chapter = book.chapters.first(where: { $0.spineIndex == anchor.fileIndex }) else {
-            return nil
-        }
-        return book.indexTable.location(for: anchor, in: chapter, bookIdentifier: bookIdentifier)
-    }
-
-    public func location(
-        for rangeAnchor: RDEPUBTextRangeAnchor,
-        bookIdentifier: String?
-    ) -> RDEPUBLocation? {
-        guard let chapter = book.chapters.first(where: { $0.spineIndex == rangeAnchor.start.fileIndex }) else {
-            return nil
-        }
-        return book.indexTable.location(for: rangeAnchor, in: chapter, bookIdentifier: bookIdentifier)
-    }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift
deleted file mode 100644
index 7ae82d3..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift
+++ /dev/null
@@ -1,337 +0,0 @@
-import UIKit
-
-public extension NSAttributedString.Key {
-
-    static let rdPageBlockRange = NSAttributedString.Key("com.RDEpubReader.epub.pageBlockRange")
-
-    static let rdPageBlockIndex = NSAttributedString.Key("com.RDEpubReader.epub.pageBlockIndex")
-
-    static let rdPageFragmentID = NSAttributedString.Key("com.RDEpubReader.epub.pageFragmentID")
-
-    static let rdPageAttachmentKind = NSAttributedString.Key("com.RDEpubReader.epub.pageAttachmentKind")
-
-    static let rdPageBlockKind = NSAttributedString.Key("com.RDEpubReader.epub.pageBlockKind")
-
-    static let rdPageSemanticHints = NSAttributedString.Key("com.RDEpubReader.epub.pageSemanticHints")
-
-    static let rdPageAttachmentPlacement = NSAttributedString.Key("com.RDEpubReader.epub.pageAttachmentPlacement")
-}
-
-public enum RDEPUBTextBlockKind: String, Codable, Equatable, CaseIterable {
-    case paragraph   
-    case list        
-    case table       
-    case code        
-    case blockquote  
-    case attachment  
-    case generic     
-}
-
-public enum RDEPUBTextSemanticHint: String, Codable, Equatable, CaseIterable {
-    case avoidPageBreakInside  
-    case keepWithNext          
-    case pageBreakBefore       
-    case pageBreakAfter        
-    case pageRelate            
-}
-
-public enum RDEPUBTextAttachmentPlacement: String, Codable, Equatable {
-    case inline     
-    case baseline   
-    case centered   
-}
-
-public struct RDEPUBTextRenderStyle {
-
-    public var font: UIFont
-
-    public var lineSpacing: CGFloat
-
-    public var textColor: UIColor?
-
-    public var backgroundColor: UIColor?
-
-    public init(font: UIFont, lineSpacing: CGFloat, textColor: UIColor? = nil, backgroundColor: UIColor? = nil) {
-        self.font = font
-        self.lineSpacing = lineSpacing
-        self.textColor = textColor
-        self.backgroundColor = backgroundColor
-    }
-}
-
-public struct RDEPUBTextLayoutConfig: Equatable {
-
-    public var frameWidth: CGFloat
-
-    public var frameHeight: CGFloat
-
-    public var edgeInsets: UIEdgeInsets
-
-    public var numberOfColumns: Int
-
-    public var columnGap: CGFloat
-
-    public var avoidOrphans: Bool
-
-    public var avoidWidows: Bool
-
-    public var avoidPageBreakInsideEnabled: Bool
-
-    public var hyphenation: Bool
-
-    public var imageMaxHeightRatio: CGFloat
-
-    public var fallbackViewportSize: CGSize
-
-    public init(
-        frameWidth: CGFloat = 0,
-        frameHeight: CGFloat = 0,
-        edgeInsets: UIEdgeInsets = .zero,
-        numberOfColumns: Int = 1,
-        columnGap: CGFloat = 20,
-        avoidOrphans: Bool = true,
-        avoidWidows: Bool = true,
-        avoidPageBreakInsideEnabled: Bool = true,
-        hyphenation: Bool = true,
-        imageMaxHeightRatio: CGFloat = 0.85,
-        fallbackViewportSize: CGSize = CGSize(width: 375, height: 667)
-    ) {
-        self.frameWidth = frameWidth
-        self.frameHeight = frameHeight
-        self.edgeInsets = edgeInsets
-        self.numberOfColumns = max(1, numberOfColumns)
-        self.columnGap = max(0, columnGap)
-        self.avoidOrphans = avoidOrphans
-        self.avoidWidows = avoidWidows
-        self.avoidPageBreakInsideEnabled = avoidPageBreakInsideEnabled
-        self.hyphenation = hyphenation
-        self.imageMaxHeightRatio = imageMaxHeightRatio
-        self.fallbackViewportSize = fallbackViewportSize
-    }
-
-    public static let `default` = RDEPUBTextLayoutConfig()
-
-    public func resolvedFrameSize(fallback pageSize: CGSize) -> CGSize {
-        CGSize(
-            width: max(frameWidth > 0 ? frameWidth : pageSize.width, 1),
-            height: max(frameHeight > 0 ? frameHeight : pageSize.height, 1)
-        )
-    }
-
-    public func contentRect(fallback pageSize: CGSize) -> CGRect {
-        let size = resolvedFrameSize(fallback: pageSize)
-        return CGRect(origin: .zero, size: size).inset(by: edgeInsets)
-    }
-
-    public func columnRects(fallback pageSize: CGSize) -> [CGRect] {
-        let rect = contentRect(fallback: pageSize)
-        let columns = max(1, numberOfColumns)
-        guard columns > 1 else { return [rect] }
-
-        let totalGap = CGFloat(columns - 1) * columnGap
-        let columnWidth = max((rect.width - totalGap) / CGFloat(columns), 1)
-
-        return (0.. RDEPUBRenderedChapterContent
-
-    func renderChapter(
-        html: String,
-        baseURL: URL?,
-        style: RDEPUBTextRenderStyle
-    ) throws -> RDEPUBRenderedChapterContent
-}
-
-public extension RDEPUBTextRenderer {
-    func renderChapter(
-        html: String,
-        baseURL: URL?,
-        style: RDEPUBTextRenderStyle
-    ) throws -> RDEPUBRenderedChapterContent {
-        let context = RDEPUBTextChapterContext(
-            href: "",
-            title: "",
-            html: html,
-            baseURL: baseURL,
-            stylesheet: RDEPUBTextStyleSheetPackage(layers: []),
-            resourceDiagnostics: []
-        )
-        return try renderChapter(request: RDEPUBTextChapterRenderRequest(context: context, style: style))
-    }
-}
-
-public enum RDEPUBTextRenderingError: LocalizedError {
-    case htmlEncodingFailed  
-    case htmlImportFailed    
-
-    public var errorDescription: String? {
-        switch self {
-        case .htmlEncodingFailed:
-            return "HTML 编码失败"
-        case .htmlImportFailed:
-            return "HTML 富文本导入失败"
-        }
-    }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBTextSearchEngine.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBTextSearchEngine.swift
deleted file mode 100644
index 7af5b10..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBTextSearchEngine.swift
+++ /dev/null
@@ -1,128 +0,0 @@
-import Foundation
-
-final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
-    private let textBook: RDEPUBTextBook
-    private let publication: RDEPUBPublication
-
-    init(textBook: RDEPUBTextBook, publication: RDEPUBPublication) {
-        self.textBook = textBook
-        self.publication = publication
-    }
-
-    func search(keyword: String) -> [RDEPUBSearchMatch] {
-        let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
-        guard !normalizedKeyword.isEmpty else {
-            return []
-        }
-
-        var matches: [RDEPUBSearchMatch] = []
-        for chapter in textBook.chapters {
-            guard let chapterData = textBook.chapterData(for: chapter.href) else { continue }
-            let source = chapter.attributedContent.string as NSString
-            let fullLength = source.length
-            guard fullLength > 0 else {
-                continue
-            }
-
-            let normalizedHref = publication.resourceResolver.normalizedHref(chapter.href) ?? chapter.href
-            var localMatchIndex = 0
-            var searchRange = NSRange(location: 0, length: fullLength)
-
-            while searchRange.length > 0 {
-                let foundRange = source.range(of: normalizedKeyword, options: [.caseInsensitive], range: searchRange)
-                guard foundRange.location != NSNotFound else {
-                    break
-                }
-
-                let progressionDenominator = max(fullLength - 1, 1)
-                let progression = Double(foundRange.location) / Double(progressionDenominator)
-                let rangeAnchor = chapterData.rangeAnchor(for: foundRange)
-                matches.append(
-                    RDEPUBSearchMatch(
-                        href: normalizedHref,
-                        progression: progression,
-                        previewText: previewText(in: source, matchRange: foundRange),
-                        localMatchIndex: localMatchIndex,
-                        rangeLocation: foundRange.location,
-                        rangeLength: foundRange.length,
-                        rangeAnchor: rangeAnchor,
-                        cfi: chapterData.indexTable.cfi(for: rangeAnchor.start)?.rawValue,
-                        rangeCFI: chapterData.indexTable.cfiRange(for: rangeAnchor)?.rawValue
-                    )
-                )
-
-                localMatchIndex += 1
-                let nextLocation = foundRange.location + max(foundRange.length, 1)
-                if nextLocation >= fullLength {
-                    break
-                }
-                searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation)
-            }
-        }
-
-        return matches
-    }
-
-    private func previewText(in text: NSString, matchRange: NSRange) -> String {
-        let previewRadius = 12
-        let start = max(matchRange.location - previewRadius, 0)
-        let end = min(matchRange.location + matchRange.length + previewRadius, text.length)
-        let range = NSRange(location: start, length: max(end - start, 0))
-        return text.substring(with: range).trimmingCharacters(in: .whitespacesAndNewlines)
-    }
-
-    static func searchWithoutPublication(textBook: RDEPUBTextBook, keyword: String) -> [RDEPUBSearchMatch] {
-        let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
-        guard !normalizedKeyword.isEmpty else { return [] }
-
-        var matches: [RDEPUBSearchMatch] = []
-        for chapter in textBook.chapters {
-            let chapterData = RDEPUBChapterData(
-                chapter: chapter,
-                indexTable: RDEPUBTextIndexTable(chapters: [chapter])
-            )
-            let source = chapter.attributedContent.string as NSString
-            let fullLength = source.length
-            guard fullLength > 0 else { continue }
-
-            var localMatchIndex = 0
-            var searchRange = NSRange(location: 0, length: fullLength)
-
-            while searchRange.length > 0 {
-                let foundRange = source.range(of: normalizedKeyword, options: [.caseInsensitive], range: searchRange)
-                guard foundRange.location != NSNotFound else { break }
-
-                let progressionDenominator = max(fullLength - 1, 1)
-                let progression = Double(foundRange.location) / Double(progressionDenominator)
-                let rangeAnchor = chapterData.rangeAnchor(for: foundRange)
-                let cfi = chapterData.indexTable.cfi(for: rangeAnchor.start)
-                let cfiRange = chapterData.indexTable.cfiRange(for: rangeAnchor)
-                let previewRadius = 12
-                let previewStart = max(foundRange.location - previewRadius, 0)
-                let previewEnd = min(foundRange.location + foundRange.length + previewRadius, fullLength)
-                let previewRange = NSRange(location: previewStart, length: max(previewEnd - previewStart, 0))
-                let previewText = source.substring(with: previewRange).trimmingCharacters(in: .whitespacesAndNewlines)
-
-                matches.append(
-                    RDEPUBSearchMatch(
-                        href: chapter.href,
-                        progression: progression,
-                        previewText: previewText,
-                        localMatchIndex: localMatchIndex,
-                        rangeLocation: foundRange.location,
-                        rangeLength: foundRange.length,
-                        rangeAnchor: rangeAnchor,
-                        cfi: cfi?.rawValue ?? cfiRange?.start.rawValue,
-                        rangeCFI: cfiRange?.rawValue
-                    )
-                )
-
-                localMatchIndex += 1
-                let nextLocation = foundRange.location + max(foundRange.length, 1)
-                if nextLocation >= fullLength { break }
-                searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation)
-            }
-        }
-        return matches
-    }
-}
diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEpubPlainTextBookBuilder.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEpubPlainTextBookBuilder.swift
deleted file mode 100644
index e0cdc60..0000000
--- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEpubPlainTextBookBuilder.swift
+++ /dev/null
@@ -1,158 +0,0 @@
-import UIKit
-
-public final class RDEpubPlainTextBookBuilder {
-    private let renderer: RDEPUBTextRenderer
-    private let layoutConfig: RDEPUBTextLayoutConfig
-
-    public init(
-        renderer: RDEPUBTextRenderer = RDEPUBDTCoreTextRenderer(),
-        layoutConfig: RDEPUBTextLayoutConfig = .default
-    ) {
-        self.renderer = renderer
-        self.layoutConfig = layoutConfig
-    }
-
-    public func build(
-        textFileURL: URL,
-        pageSize: CGSize,
-        style: RDEPUBTextRenderStyle
-    ) throws -> RDEPUBTextBook {
-        let rawText = rd_decodeTextFile(url: textFileURL)
-        let chapterSpecs = splitChapters(from: rawText)
-
-        var chapters: [RDEPUBTextChapter] = []
-        var flatPages: [RDEPUBTextPage] = []
-
-        for (index, spec) in chapterSpecs.enumerated() {
-            let html = wrapTextAsHTML(spec.content)
-            let request = RDEPUBTextChapterRenderRequest(
-                context: RDEPUBTextChapterContext(
-                    href: "chapter_\(index).xhtml",
-                    title: spec.title ?? "第 \(index + 1) 章",
-                    html: html,
-                    baseURL: nil,
-                    stylesheet: RDEPUBTextStyleSheetPackage(layers: []),
-                    resourceDiagnostics: []
-                ),
-                style: style,
-                pageSize: pageSize,
-                layoutConfig: layoutConfig
-            )
-            let rendered = try renderer.renderChapter(request: request)
-
-            let content = NSMutableAttributedString(attributedString: rendered.attributedString)
-            let layoutFrames = content.length > 0 ? content.rd_paginatedFrames(size: pageSize, config: layoutConfig) : []
-            let effectiveFrames = layoutFrames.isEmpty && content.length > 0
-                ? [
-                    RDEPUBTextLayoutFrame(
-                        contentRange: NSRange(location: 0, length: content.length),
-                        breakReason: .chapterEnd,
-                        blockRange: nil,
-                        attachmentRanges: [],
-                        attachmentKinds: [],
-                        blockKinds: [],
-                        semanticHints: [],
-                        attachmentPlacements: [],
-                        trailingFragmentID: nil,
-                        diagnostics: [
-                            "page break: chapterEnd",
-                            "page range: \(NSStringFromRange(NSRange(location: 0, length: content.length)))"
-                        ]
-                    )
-                ]
-                : layoutFrames
-
-            let href = "chapter_\(index).xhtml"
-            let chapterAttributedContent = content.copy() as! NSAttributedString
-            let pages = effectiveFrames.enumerated().map { localPageIndex, frame in
-                let range = frame.contentRange
-                return RDEPUBTextPage(
-                    absolutePageIndex: flatPages.count + localPageIndex,
-                    chapterIndex: index,
-                    spineIndex: index,
-                    href: href,
-                    chapterTitle: spec.title ?? "第 \(index + 1) 章",
-                    pageIndexInChapter: localPageIndex,
-                    totalPagesInChapter: effectiveFrames.count,
-                    chapterContent: chapterAttributedContent,
-                    contentRange: range,
-                    pageStartOffset: range.location,
-                    pageEndOffset: range.location + max(range.length - 1, 0),
-                    metadata: frame.metadata
-                )
-            }
-
-            chapters.append(
-                RDEPUBTextChapter(
-                    chapterIndex: index,
-                    spineIndex: index,
-                    href: href,
-                    title: spec.title ?? "第 \(index + 1) 章",
-                    attributedContent: chapterAttributedContent,
-                    fragmentOffsets: [:],
-                    cfiMap: nil,
-                    pageBreakReasons: pages.map { $0.metadata.breakReason },
-                    pages: pages
-                )
-            )
-            flatPages.append(contentsOf: pages)
-        }
-
-        return RDEPUBTextBook(chapters: chapters, pages: flatPages)
-    }
-
-    private struct ChapterSpec {
-        let title: String?
-        let content: String
-    }
-
-    private func splitChapters(from text: String) -> [ChapterSpec] {
-        let pattern = #"^(第[零一二三四五六七八九十百千万\d]+[章节回卷].*)$"#
-        guard let regex = try? NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines]) else {
-            return [ChapterSpec(title: nil, content: text)]
-        }
-
-        let nsText = text as NSString
-        let matches = regex.matches(in: text, range: NSRange(location: 0, length: nsText.length))
-        guard !matches.isEmpty else {
-            return [ChapterSpec(title: nil, content: text)]
-        }
-
-        var specs: [ChapterSpec] = []
-        for (i, match) in matches.enumerated() {
-            let titleRange = match.range(at: 1)
-            let title = nsText.substring(with: titleRange).trimmingCharacters(in: .whitespacesAndNewlines)
-            let contentStart = titleRange.location + titleRange.length
-            let contentEnd = (i + 1 < matches.count) ? matches[i + 1].range.location : nsText.length
-            let contentRange = NSRange(location: contentStart, length: contentEnd - contentStart)
-            let content = nsText.substring(with: contentRange).trimmingCharacters(in: .whitespacesAndNewlines)
-            if !content.isEmpty {
-                specs.append(ChapterSpec(title: title, content: content))
-            }
-        }
-
-        if specs.isEmpty {
-            return [ChapterSpec(title: nil, content: text)]
-        }
-        return specs
-    }
-
-    private func wrapTextAsHTML(_ text: String) -> String {
-        let paragraphs = text.components(separatedBy: "\n").filter { !$0.isEmpty }
-        let body = paragraphs.map { "

\($0)

" }.joined(separator: "\n") - return "\(body)" - } - - private func rd_decodeTextFile(url: URL) -> String { - if let content = try? NSString(contentsOf: url, encoding: String.Encoding.utf8.rawValue) as String { - return content - } - if let content = try? NSString(contentsOf: url, encoding: 0x80000632) as String { - return content - } - if let content = try? NSString(contentsOf: url, encoding: 0x80000631) as String { - return content - } - return "" - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBCSSCompatibilityLayer.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBCSSCompatibilityLayer.swift deleted file mode 100644 index 66f0799..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBCSSCompatibilityLayer.swift +++ /dev/null @@ -1,193 +0,0 @@ -import Foundation - -struct RDEPUBCSSCompatibilityLayer { - - var policy = RDEPUBStyleCompatibilityPolicy() - - func sanitize(_ css: String) -> RDEPUBCSSCompatibilityResult { - var rewritten = css - var normalized: [String] = [] - var unsupported: [String] = [] - - if policy.normalizeLineHeight { - let result = clampNumericProperty( - in: rewritten, - property: "line-height", - minValue: 0.9, - maxValue: 2.4 - ) - rewritten = result.css - normalized += result.normalizedRules - } - - if policy.normalizeTextIndent { - let result = clampLengthProperty( - in: rewritten, - property: "text-indent", - maxAbsolutePX: 64 - ) - rewritten = result.css - normalized += result.normalizedRules - } - - let alignmentIndentResult = addTextIndentResetForAlignedBlocks(in: rewritten) - rewritten = alignmentIndentResult.css - normalized += alignmentIndentResult.normalizedRules - - if !policy.allowPublisherMargins { - let result = clampLengthProperty( - in: rewritten, - property: "margin", - maxAbsolutePX: 64 - ) - rewritten = result.css - normalized += result.normalizedRules - } - - if policy.fallbackUnsupportedWritingModes, - rewritten.range(of: "writing-mode", options: [.caseInsensitive]) != nil { - unsupported.append("writing-mode") - rewritten += "\n\nhtml, body { writing-mode: horizontal-tb !important; }" - normalized.append("writing-mode") - } - - if policy.clampImagesToViewport { - rewritten += """ - - img, svg, table, video, canvas { - max-width: 100% !important; - height: auto !important; - box-sizing: border-box !important; - } - table { - overflow-wrap: anywhere; - border-collapse: collapse; - } - """ - normalized.append("media-size-clamp") - } - - return RDEPUBCSSCompatibilityResult( - css: rewritten, - report: RDEPUBCSSCompatibilityReport( - unsupportedRules: unsupported, - normalizedRules: normalized, - fontFailures: [] - ) - ) - } - - private func clampNumericProperty( - in css: String, - property: String, - minValue: Double, - maxValue: Double - ) -> (css: String, normalizedRules: [String]) { - guard let regex = try? NSRegularExpression( - pattern: #"(?i)\b"# + NSRegularExpression.escapedPattern(for: property) + #"\s*:\s*([0-9]*\.?[0-9]+)\s*;"# - ) else { - return (css, []) - } - - let nsCSS = css as NSString - var rewritten = css - var normalized: [String] = [] - - for match in regex.matches(in: css, range: NSRange(location: 0, length: nsCSS.length)).reversed() { - guard match.numberOfRanges > 1 else { continue } - let rawValue = nsCSS.substring(with: match.range(at: 1)) - - guard let value = Double(rawValue), value < minValue || value > maxValue else { continue } - - let clamped = min(max(value, minValue), maxValue) - if let range = Range(match.range, in: rewritten) { - rewritten.replaceSubrange(range, with: "\(property): \(String(format: "%.3f", clamped));") - normalized.append(property) - } - } - return (rewritten, normalized) - } - - private func clampLengthProperty( - in css: String, - property: String, - maxAbsolutePX: Double - ) -> (css: String, normalizedRules: [String]) { - guard let regex = try? NSRegularExpression( - pattern: #"(?i)\b"# + NSRegularExpression.escapedPattern(for: property) + #"\s*:\s*(-?[0-9]*\.?[0-9]+)px\s*;"# - ) else { - return (css, []) - } - - let nsCSS = css as NSString - var rewritten = css - var normalized: [String] = [] - - for match in regex.matches(in: css, range: NSRange(location: 0, length: nsCSS.length)).reversed() { - guard match.numberOfRanges > 1 else { continue } - let rawValue = nsCSS.substring(with: match.range(at: 1)) - - guard let value = Double(rawValue), abs(value) > maxAbsolutePX else { continue } - - let clamped = value < 0 ? -maxAbsolutePX : maxAbsolutePX - if let range = Range(match.range, in: rewritten) { - rewritten.replaceSubrange(range, with: "\(property): \(String(format: "%.0f", clamped))px;") - normalized.append(property) - } - } - return (rewritten, normalized) - } - - private func addTextIndentResetForAlignedBlocks( - in css: String - ) -> (css: String, normalizedRules: [String]) { - guard let regex = try? NSRegularExpression( - pattern: #"(?is)([^{}]+)\{([^{}]*)\}"# - ) else { - return (css, []) - } - - let nsCSS = css as NSString - var rewritten = css - var normalized: [String] = [] - - for match in regex.matches(in: css, range: NSRange(location: 0, length: nsCSS.length)).reversed() { - guard match.numberOfRanges > 2 else { continue } - let selector = nsCSS.substring(with: match.range(at: 1)) - let declarations = nsCSS.substring(with: match.range(at: 2)) - - guard declaresRightOrCenterAlignment(declarations), - !declaresProperty("text-indent", in: declarations) else { - continue - } - - let replacement = "\(selector){\(declarations)\n text-indent: 0 !important;\n}" - if let range = Range(match.range, in: rewritten) { - rewritten.replaceSubrange(range, with: replacement) - normalized.append("aligned-text-indent-reset") - } - } - - return (rewritten, normalized) - } - - private func declaresRightOrCenterAlignment(_ declarations: String) -> Bool { - guard let regex = try? NSRegularExpression( - pattern: #"(?i)\btext-align\s*:\s*(right|center)\b"# - ) else { - return false - } - let range = NSRange(location: 0, length: (declarations as NSString).length) - return regex.firstMatch(in: declarations, range: range) != nil - } - - private func declaresProperty(_ property: String, in declarations: String) -> Bool { - guard let regex = try? NSRegularExpression( - pattern: #"(?i)\b"# + NSRegularExpression.escapedPattern(for: property) + #"\s*:"# - ) else { - return false - } - let range = NSRange(location: 0, length: (declarations as NSString).length) - return regex.firstMatch(in: declarations, range: range) != nil - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBFontFallbackResolver.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBFontFallbackResolver.swift deleted file mode 100644 index 6ee3e85..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBFontFallbackResolver.swift +++ /dev/null @@ -1,21 +0,0 @@ -import UIKit - -struct RDEPUBFontFallbackResolver { - - static func fallbackChain(requestedFamily: String?, embeddedFamily: String?) -> RDEPUBFontFallbackChain { - - let preferredCJK = ["PingFang SC", "Heiti SC", "Songti SC"] - - let preferredLatin = ["Times New Roman", "Georgia", "Helvetica Neue"] - return RDEPUBFontFallbackChain( - requestedFamily: requestedFamily, - embeddedFamily: embeddedFamily, - systemFallbacks: preferredCJK + preferredLatin, - finalFallback: UIFont.systemFont(ofSize: UIFont.systemFontSize).familyName - ) - } - - static func resolveFont(sourceFont: UIFont?, baseFont: UIFont) -> UIFont { - RDEPUBFontNormalizer.normalizedFont(from: sourceFont, baseFont: baseFont) - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBStyleCompatibilityModels.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBStyleCompatibilityModels.swift deleted file mode 100644 index a06967e..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBStyleCompatibilityModels.swift +++ /dev/null @@ -1,101 +0,0 @@ -import Foundation - -public struct RDEPUBStyleCompatibilityPolicy: Equatable { - - public var allowPublisherFonts: Bool - - public var allowPublisherMargins: Bool - - public var normalizeLineHeight: Bool - - public var normalizeTextIndent: Bool - - public var clampImagesToViewport: Bool - - public var fallbackUnsupportedWritingModes: Bool - - public init( - allowPublisherFonts: Bool = true, - allowPublisherMargins: Bool = true, - normalizeLineHeight: Bool = true, - normalizeTextIndent: Bool = true, - clampImagesToViewport: Bool = true, - fallbackUnsupportedWritingModes: Bool = true - ) { - self.allowPublisherFonts = allowPublisherFonts - self.allowPublisherMargins = allowPublisherMargins - self.normalizeLineHeight = normalizeLineHeight - self.normalizeTextIndent = normalizeTextIndent - self.clampImagesToViewport = clampImagesToViewport - self.fallbackUnsupportedWritingModes = fallbackUnsupportedWritingModes - } -} - -public struct RDEPUBFontFallbackChain: Codable, Equatable { - - public var requestedFamily: String? - - public var embeddedFamily: String? - - public var systemFallbacks: [String] - - public var finalFallback: String - - public init( - requestedFamily: String? = nil, - embeddedFamily: String? = nil, - systemFallbacks: [String] = ["PingFang SC", "Heiti SC", "Times New Roman"], - finalFallback: String = ".AppleSystemUIFont" - ) { - self.requestedFamily = requestedFamily - self.embeddedFamily = embeddedFamily - self.systemFallbacks = systemFallbacks - self.finalFallback = finalFallback - } -} - -public struct RDEPUBEmbeddedFontDescriptor: Codable, Equatable { - - public var family: String? - - public var href: String - - public var format: String? - - public var weight: Int? - - public var style: String? -} - -public struct RDEPUBFontRegistrationResult: Codable, Equatable { - - public var descriptor: RDEPUBEmbeddedFontDescriptor - - public var fileURL: URL? - - public var didRegister: Bool - - public var errorDescription: String? -} - -public struct RDEPUBCSSCompatibilityReport: Codable, Equatable { - - public var unsupportedRules: [String] - - public var normalizedRules: [String] - - public var fontFailures: [String] - - public init(unsupportedRules: [String] = [], normalizedRules: [String] = [], fontFailures: [String] = []) { - self.unsupportedRules = unsupportedRules - self.normalizedRules = normalizedRules - self.fontFailures = fontFailures - } -} - -struct RDEPUBCSSCompatibilityResult { - - var css: String - - var report: RDEPUBCSSCompatibilityReport -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBAttachmentNormalizer.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBAttachmentNormalizer.swift deleted file mode 100644 index ffae4bf..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBAttachmentNormalizer.swift +++ /dev/null @@ -1,257 +0,0 @@ -import UIKit - -#if canImport(DTCoreText) -import DTCoreText -#endif - -struct RDEPUBAttachmentNormalizer { - - private static var didLogFootnoteAttachment = false - - private static var didLogCoverAttachment = false - -#if canImport(DTCoreText) - - func normalize( - _ attachment: DTTextAttachment, - fontPointSize: CGFloat, - maxImageSize: CGSize - ) { - Self.normalizeAttachmentLayoutForWXRead( - attachment, - fontPointSize: fontPointSize, - maxImageSize: maxImageSize - ) - } -#endif - -#if canImport(DTCoreText) - - static func normalizeAttachmentLayoutForWXRead( - _ attachment: DTTextAttachment, - fontPointSize: CGFloat, - maxImageSize: CGSize? = nil - ) { - - let pointSize = max(fontPointSize, 1) - - let originalSize = attachment.originalSize - - if isFootnoteAttachment(attachment) { - - let targetWidth = max(round(pointSize), 1) - - let aspectRatio = originalSize.height > 0 ? originalSize.width / originalSize.height : 1 - - let targetHeight = max(round(targetWidth / max(aspectRatio, 0.1)), 1) - - attachment.displaySize = CGSize(width: targetWidth, height: targetHeight) - - attachment.verticalAlignment = .baseline - - if !didLogFootnoteAttachment { - didLogFootnoteAttachment = true - } - return - } - - if isCoverAttachment(attachment) { - - let maxSize = maxImageSize ?? defaultMaxImageSize(fontPointSize: pointSize) - if originalSize.width > 0, originalSize.height > 0 { - - let scale = min(maxSize.width / originalSize.width, maxSize.height / originalSize.height) - attachment.displaySize = CGSize( - width: round(originalSize.width * scale), - height: round(originalSize.height * scale) - ) - } else { - - attachment.displaySize = maxSize - } - - attachment.verticalAlignment = .baseline - - if !didLogCoverAttachment { - didLogCoverAttachment = true - } - return - } - - var resolvedSize = attachment.displaySize - - if resolvedSize.width <= 0 || resolvedSize.height <= 0 { - resolvedSize = originalSize - } - - if resolvedSize.width <= 0 || resolvedSize.height <= 0 { - resolvedSize = CGSize(width: pointSize, height: pointSize) - } - - if let maxImageSize, - resolvedSize.width > 0, - resolvedSize.height > 0, - (resolvedSize.width > maxImageSize.width || resolvedSize.height > maxImageSize.height) { - - let scale = min(maxImageSize.width / resolvedSize.width, maxImageSize.height / resolvedSize.height) - resolvedSize = CGSize( - width: round(resolvedSize.width * scale), - height: round(resolvedSize.height * scale) - ) - } - - attachment.displaySize = CGSize(width: round(resolvedSize.width), height: round(resolvedSize.height)) - - attachment.verticalAlignment = .center - } - - static func prepareHTMLElementForReaderRendering( - _ element: DTHTMLElement, - style: RDEPUBTextRenderStyle, - maxImageSize: CGSize? = nil - ) { - - guard let attachment = element.textAttachment else { return } - - let pointSize = max(element.fontDescriptor.pointSize, style.font.pointSize) - - let fallbackSize = CGSize( - width: defaultMaxImageSize(fontPointSize: pointSize).width, - height: defaultMaxImageSize(fontPointSize: pointSize).height - ) - - normalizeAttachmentLayoutForWXRead( - attachment, - fontPointSize: pointSize, - maxImageSize: maxImageSize ?? fallbackSize - ) - - if isFootnoteAttachment(attachment) { - element.displayStyle = .inline - } else if isCoverAttachment(attachment) { - element.displayStyle = .block - } - } - - private static func isFootnoteAttachment(_ attachment: DTTextAttachment) -> Bool { - - let lowercasedClasses = ((attachment.attributes["class"] as? String) ?? "").lowercased() - if lowercasedClasses.contains("qqreader-footnote") { - return true - } - let altText = attachment.attributes["alt"] as? String - return hasFootnoteAltText(altText) && isFootnoteSizedImage(attachment.originalSize) - } - - private static func isCoverAttachment(_ attachment: DTTextAttachment) -> Bool { - - let lowercasedClasses = ((attachment.attributes["class"] as? String) ?? "").lowercased() - - let lowercasedPath = attachment.contentURL?.lastPathComponent.lowercased() - ?? ((attachment.attributes["src"] as? String) ?? "").lowercased() - return lowercasedClasses.contains("rd-front-cover-image") || lowercasedPath == "cover.jpg" - } - - private static func defaultMaxImageSize(fontPointSize: CGFloat) -> CGSize { - - let referenceViewport = CGSize(width: 375, height: 667) - - let horizontalInset = max(round(fontPointSize), 16) - - let verticalInset = max(round(fontPointSize * 1.5), 28) - - return CGSize( - width: max(round(referenceViewport.width - horizontalInset * 2), 1), - height: max(round((referenceViewport.height - verticalInset * 2) * 0.85), 1) - ) - } -#endif - - static func normalizeAttachmentDisplayIfNeeded( - in attributes: inout [NSAttributedString.Key: Any], - font: UIFont - ) { - - guard let attachment = attributes[.attachment] else { return } - -#if canImport(DTCoreText) - - if let textAttachment = attachment as? DTTextAttachment { - normalizeAttachmentLayoutForWXRead(textAttachment, fontPointSize: font.pointSize) - attributes[.attachment] = textAttachment - return - } -#endif - - if let textAttachment = attachment as? NSTextAttachment, textAttachment.bounds.height <= 0 { - - let targetHeight = max(round(font.pointSize * 0.86), 1) - textAttachment.bounds = CGRect(x: 0, y: 0, width: targetHeight, height: targetHeight) - attributes[.attachment] = textAttachment - } - } - - static func attachmentKind(for attributes: [NSAttributedString.Key: Any]) -> RDEPUBTextAttachmentKind? { - // Check for footnote attachments first — they should show tooltip, not image viewer - if isFootnoteAttachmentInAttributes(attributes) { - return .footnote - } - - if let attachment = attributes[.attachment] as? NSTextAttachment { - - if attachment.image != nil || attachment.fileType?.lowercased().contains("image") == true { - return .image - } - return .generic - } - - for value in attributes.values { - let typeName = String(describing: type(of: value)).lowercased() - - if typeName.contains("attachment") { - return typeName.contains("image") ? .image : .generic - } - } - - return nil - } - - private static func isFootnoteAttachmentInAttributes(_ attributes: [NSAttributedString.Key: Any]) -> Bool { -#if canImport(DTCoreText) - if let textAttachment = attributes[.attachment] as? DTTextAttachment { - return isFootnoteAttachment(textAttachment) - } -#endif - guard let fileAttachment = attributes[.attachment] as? NSTextAttachment else { - return false - } - let label = fileAttachment.accessibilityLabel - let lowercasedLabel = (label ?? "").lowercased() - if lowercasedLabel.contains("qqreader-footnote") { - return true - } - let imageSize = fileAttachment.image?.size ?? fileAttachment.bounds.size - return hasFootnoteAltText(label) && isFootnoteSizedImage(imageSize) - } - - // Footnote images without the qqreader-footnote class are recognized by their - // alt text carrying the note body. Short alts ("logo", "图1") are ordinary - // accessibility descriptions, and note markers are small inline icons, so both - // conditions must hold before an image is shrunk to footnote size. - private static let minimumFootnoteAltTextLength = 8 - - private static let maximumFootnoteImageDimension: CGFloat = 50 - - private static func hasFootnoteAltText(_ text: String?) -> Bool { - guard let trimmed = text?.trimmingCharacters(in: .whitespacesAndNewlines) else { - return false - } - return trimmed.count >= minimumFootnoteAltTextLength - } - - private static func isFootnoteSizedImage(_ size: CGSize) -> Bool { - guard size.width > 0, size.height > 0 else { return false } - return size.width <= maximumFootnoteImageDimension - && size.height <= maximumFootnoteImageDimension - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBCFIMarkerInjector.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBCFIMarkerInjector.swift deleted file mode 100644 index cb75f0a..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBCFIMarkerInjector.swift +++ /dev/null @@ -1,59 +0,0 @@ -import Foundation - -struct RDEPUBCFIMarkerInjector: RDEPUBTypesettingStage { - - func process(_ html: String, context: RDEPUBTypesettingInput) -> String { - - guard let regex = try? NSRegularExpression( - pattern: #"<([A-Za-z][A-Za-z0-9:_-]*)([^>]*\s(?:id|xml:id)\s*=\s*['"]([^'"]+)['"][^>]*)>"#, - options: [.caseInsensitive] - ) else { - return html - } - - let nsHTML = html as NSString - - let matches = regex.matches(in: html, range: NSRange(location: 0, length: nsHTML.length)) - guard !matches.isEmpty else { return html } - - var rewritten = html - for match in matches.reversed() { - - guard match.numberOfRanges > 3, - let fullRange = Range(match.range(at: 0), in: rewritten), - let tagNameRange = Range(match.range(at: 1), in: html), - let attributesRange = Range(match.range(at: 2), in: html), - let fragmentRange = Range(match.range(at: 3), in: html) else { - continue - } - - let tagName = String(html[tagNameRange]) - let attributes = String(html[attributesRange]) - let fragmentID = String(html[fragmentRange]) - - guard attributes.range(of: "data-rd-cfi-marker", options: [.caseInsensitive]) == nil else { - continue - } - - let marker = RDEPUBCFIGenerator.makeOffsetCFI( - href: context.href, - fileIndex: context.spineIndex ?? 0, - chapterOffset: 0, - fragmentID: fragmentID - ).rawValue - - let replacement = "<\(tagName)\(attributes) data-rd-cfi-marker=\"\(Self.escapeAttribute(marker))\">" - rewritten.replaceSubrange(fullRange, with: replacement) - } - - return rewritten - } - - private static func escapeAttribute(_ value: String) -> String { - value - .replacingOccurrences(of: "&", with: "&") - .replacingOccurrences(of: "\"", with: """) - .replacingOccurrences(of: "<", with: "<") - .replacingOccurrences(of: ">", with: ">") - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBDecryptingImageAttachment.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBDecryptingImageAttachment.swift deleted file mode 100644 index 6869ca2..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBDecryptingImageAttachment.swift +++ /dev/null @@ -1,66 +0,0 @@ -import UIKit - -#if canImport(DTCoreText) -import DTCoreText - -/// 支持加密图片的 DTCoreText 图片附件。 -/// -/// DTCoreText 解析 `` 时会直接读磁盘文件来取图并推算尺寸; -/// 对加密图片该读取会解码失败,表现为 `image == nil` 且 `contentURL` 被保留。 -/// 本子类在 super 初始化完成后接管这种失败场景: -/// 经 `RDEPUBResourceAccessRegistry` 反查 provider 解密数据并调用 `setImage` -/// (setter 会自动补齐 originalSize / displaySize,后续 willFlush 阶段的 -/// 附件规范化与绘制均按明文图片的既有路径工作)。 -/// -/// 通过 `DTTextAttachment.registerClass(_:forTagName:)` 全局注册, -/// 未启用 provider 的书行为与父类完全一致。 -final class RDEPUBDecryptingImageAttachment: DTImageTextAttachment { - - private static var didRegisterTagClass = false - - private static let registerLock = NSLock() - - /// 将本类注册为 `` 标签的附件类(进程内一次性,线程安全) - static func registerTagClassIfNeeded() { - registerLock.lock() - defer { registerLock.unlock() } - guard !didRegisterTagClass else { return } - DTTextAttachment.registerClass(RDEPUBDecryptingImageAttachment.self, forTagName: "img") - didRegisterTagClass = true - } - - override init!(element: DTHTMLElement!, options: [AnyHashable: Any]! = [:]) { - super.init(element: element, options: options) - decryptImageIfNeeded() - } - - /// DTCoreText / UIKit 在部分 HTML 图片路径会改用 NSTextAttachment 的 - /// `init(data:ofType:)` 创建附件。若子类未实现,Foundation 会直接触发 - /// “Use of unimplemented initializer” 致命错误。 - override init(data contentData: Data?, ofType uti: String?) { - super.init(data: contentData, ofType: uti) - if image == nil, let contentData { - image = UIImage(data: contentData) - } - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - } - - private func decryptImageIfNeeded() { - // super 已成功取到图(明文图片 / data: 内联图)时不干预 - guard image == nil, - let contentURL, - contentURL.isFileURL, - let data = RDEPUBResourceAccessRegistry.providedResourceData(at: contentURL.standardizedFileURL), - let decrypted = UIImage(data: data) else { - return - } - image = decrypted - // 对齐 DTCoreText 本地图片加载成功的行为:置空 contentURL, - // 避免下游误当作待加载的远程 / 磁盘图片再次直读密文 - self.contentURL = nil - } -} -#endif diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBFontNormalizer.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBFontNormalizer.swift deleted file mode 100644 index be5f5c3..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBFontNormalizer.swift +++ /dev/null @@ -1,160 +0,0 @@ -import UIKit - -import CoreText - -struct RDEPUBFontNormalizer { - - private static var registeredFontPaths = Set() - - @discardableResult - func registerEmbeddedFonts( - html: String, - inlinedCSS: String, - input: RDEPUBTypesettingInput - ) -> [RDEPUBFontRegistrationResult] { - - Self.registerEmbeddedFonts( - in: inlinedCSS + "\n" + Self.inlineStyleCSS(in: html), - chapterHref: input.href, - resourceResolver: input.resourceResolver - ) - } - - @discardableResult - static func registerEmbeddedFonts( - in css: String, - chapterHref: String, - resourceResolver: RDEPUBResourceResolver? - ) -> [RDEPUBFontRegistrationResult] { - - guard let resourceResolver, - - let faceRegex = try? NSRegularExpression(pattern: #"@font-face\s*\{([\s\S]*?)\}"#, options: [.caseInsensitive]), - - let urlRegex = try? NSRegularExpression(pattern: #"url\(([^)]+)\)"#, options: [.caseInsensitive]) else { - return [] - } - - var results: [RDEPUBFontRegistrationResult] = [] - let nsCSS = css as NSString - - for faceMatch in faceRegex.matches(in: css, range: NSRange(location: 0, length: nsCSS.length)) { - guard faceMatch.numberOfRanges > 1 else { continue } - - let block = nsCSS.substring(with: faceMatch.range(at: 1)) - let nsBlock = block as NSString - - let family = declarationValue(named: "font-family", in: block)? - .trimmingCharacters(in: CharacterSet(charactersIn: "\"'")) - - let weight = declarationValue(named: "font-weight", in: block).flatMap(Int.init) - - let style = declarationValue(named: "font-style", in: block) - - for urlMatch in urlRegex.matches(in: block, range: NSRange(location: 0, length: nsBlock.length)) { - guard urlMatch.numberOfRanges > 1 else { continue } - - let rawReference = nsBlock.substring(with: urlMatch.range(at: 1)) - .trimmingCharacters(in: CharacterSet(charactersIn: "\"' \n\r\t")) - - let descriptor = RDEPUBEmbeddedFontDescriptor( - family: family, - href: rawReference, - format: nil, - weight: weight, - style: style - ) - - guard !rawReference.isEmpty, - !rawReference.hasPrefix("data:"), - !rawReference.hasPrefix("http:"), - !rawReference.hasPrefix("https:"), - - let fileURL = resourceResolver.fileURL(forReference: rawReference, relativeToHref: chapterHref) else { - - results.append( - RDEPUBFontRegistrationResult( - descriptor: descriptor, - fileURL: nil, - didRegister: false, - errorDescription: "Font URL could not be resolved" - ) - ) - continue - } - - let didRegister = registerFontIfNeeded(at: fileURL) - results.append( - RDEPUBFontRegistrationResult( - descriptor: descriptor, - fileURL: fileURL, - didRegister: didRegister, - errorDescription: didRegister ? nil : "Font was already registered or registration failed" - ) - ) - } - } - return results - } - - @discardableResult - static func registerFontIfNeeded(at fileURL: URL) -> Bool { - - let standardizedPath = fileURL.standardizedFileURL.path - - guard !registeredFontPaths.contains(standardizedPath) else { return true } - - let registered = CTFontManagerRegisterFontsForURL(fileURL as CFURL, .process, nil) - if registered { - - registeredFontPaths.insert(standardizedPath) - } - return registered - } - - static func normalizedFont(from sourceFont: UIFont?, baseFont: UIFont) -> UIFont { - guard let sourceFont else { - return baseFont - } - - let traits = sourceFont.fontDescriptor.symbolicTraits.intersection([.traitBold, .traitItalic]) - - if let descriptor = baseFont.fontDescriptor.withSymbolicTraits(traits) { - return UIFont(descriptor: descriptor, size: baseFont.pointSize) - } - - return baseFont - } - - static func inlineStyleCSS(in html: String) -> String { - - guard let regex = try? NSRegularExpression(pattern: #"]*>([\s\S]*?)"#, options: [.caseInsensitive]) else { - return "" - } - let nsHTML = html as NSString - - return regex.matches(in: html, range: NSRange(location: 0, length: nsHTML.length)) - .compactMap { match in - guard match.numberOfRanges > 1 else { return nil } - return nsHTML.substring(with: match.range(at: 1)) - } - .joined(separator: "\n") - } - - private static func declarationValue(named name: String, in block: String) -> String? { - - guard let regex = try? NSRegularExpression( - pattern: #"(?i)\b"# + NSRegularExpression.escapedPattern(for: name) + #"\s*:\s*([^;]+)"# - ) else { - return nil - } - let nsBlock = block as NSString - - guard let match = regex.firstMatch(in: block, range: NSRange(location: 0, length: nsBlock.length)), - match.numberOfRanges > 1 else { - return nil - } - - return nsBlock.substring(with: match.range(at: 1)).trimmingCharacters(in: .whitespacesAndNewlines) - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBFragmentMarkerInjector.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBFragmentMarkerInjector.swift deleted file mode 100644 index 45272af..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBFragmentMarkerInjector.swift +++ /dev/null @@ -1,68 +0,0 @@ - -import Foundation - -struct RDEPUBFragmentMarkerInjector: RDEPUBTypesettingStage { - - func process(_ html: String, context: RDEPUBTypesettingInput) -> String { - Self.injectFragmentMarkers(into: html) - } - - func extractOffsets(from attributedString: NSMutableAttributedString) -> [String: Int] { - Self.extractFragmentOffsets(from: attributedString) - } - - static func injectFragmentMarkers(into html: String) -> String { - guard let regex = try? NSRegularExpression(pattern: #"(<[^>]+\sid="([^"]+)"[^>]*>)"#, options: [.caseInsensitive]) else { - return html - } - return regex.stringByReplacingMatches( - in: html, - options: [], - range: NSRange(location: 0, length: html.utf16.count), - withTemplate: "${id=$2}$1" - ) - } - - - - - - static func extractFragmentOffsets(from attributedString: NSMutableAttributedString) -> [String: Int] { - - let markerPattern = #"\$\{id=([^}]+)\}"# - guard let regex = try? NSRegularExpression(pattern: markerPattern, options: []) else { - return [:] - } - - - let mutableString = NSMutableString(string: attributedString.string) - - var fragmentOffsets: [String: Int] = [:] - - var searchRange = NSRange(location: 0, length: mutableString.length) - - var offsetAdjustment = 0 - - - while let match = regex.firstMatch(in: mutableString as String, options: [], range: searchRange) { - - let fullMatch = mutableString.substring(with: match.range) as NSString - let fragmentID = fullMatch - .replacingOccurrences(of: #"\$\{id="#, with: "", options: .regularExpression, range: NSRange(location: 0, length: fullMatch.length)) - .replacingOccurrences(of: #"\}"#, with: "", options: .regularExpression) - - let adjustedLocation = max(0, match.range.location + offsetAdjustment) - - fragmentOffsets[fragmentID] = adjustedLocation - - attributedString.deleteCharacters(in: match.range) - mutableString.deleteCharacters(in: match.range) - - offsetAdjustment -= match.range.length - - searchRange = NSRange(location: match.range.location, length: mutableString.length - match.range.location) - } - - return fragmentOffsets - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBHTMLNormalizer.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBHTMLNormalizer.swift deleted file mode 100644 index 10aaa02..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBHTMLNormalizer.swift +++ /dev/null @@ -1,270 +0,0 @@ -import Foundation - -struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage { - - func process(_ html: String, context: RDEPUBTypesettingInput) -> String { - Self.normalizeHTML(html) - } - - static func normalizeHTML(_ html: String) -> String { - var cleanedHTML = html - - let replacements: [(pattern: String, template: String)] = [ - (#"分页符"#, ""), - (#"\r"#, "\n"), - (#"\n+"#, "\n") - ] - - for replacement in replacements { - if let regex = try? NSRegularExpression(pattern: replacement.pattern, options: [.caseInsensitive]) { - cleanedHTML = regex.stringByReplacingMatches( - in: cleanedHTML, - options: [], - range: NSRange(location: 0, length: cleanedHTML.utf16.count), - withTemplate: replacement.template - ) - } - } - - cleanedHTML = normalizeAttachmentHTMLMarkers(in: cleanedHTML) - cleanedHTML = normalizeBodyLeadingSpacing(in: cleanedHTML) - return cleanedHTML - } - - private static func normalizeAttachmentHTMLMarkers(in html: String) -> String { - var normalized = html - - if let bodyPicContainerRegex = try? NSRegularExpression( - pattern: #"]*class\s*=\s*["'][^"']*\b(?:qrbodyPic|bodyPic)\b[^"']*["'][^>]*)>([\s\S]*?)"#, - options: [.caseInsensitive] - ) { - normalized = replaceMatches( - using: bodyPicContainerRegex, - in: normalized - ) { tag in - - guard let imageTagRegex = try? NSRegularExpression(pattern: #"]*>"#, options: [.caseInsensitive]) else { - return tag - } - - - return replaceMatches( - using: imageTagRegex, - in: tag - ) { imageTag in - mergeHTMLAttributes( - into: imageTag, - requiredClass: "bodyPic", - styleFragments: [ - "wr-vertical-center-style:2", - "max-width:100%", - "height:auto", - "display:block", - "margin-left:auto", - "margin-right:auto" - ] - ) - } - } - } - - - if let footnoteRegex = try? NSRegularExpression( - pattern: #"]*class\s*=\s*["'][^"']*\bqqreader-footnote\b[^"']*["'][^>]*)>"#, - options: [.caseInsensitive] - ) { - normalized = replaceMatches( - using: footnoteRegex, - in: normalized - ) { tag in - - mergeHTMLAttributes( - into: tag, - requiredClass: nil, - styleFragments: [ - "width:1em", - "height:1em", - "vertical-align:middle", - "display:inline-block" - ] - ) - } - } - - - if let coverRegex = try? NSRegularExpression( - pattern: #"]*class\s*=\s*["'][^"']*\bfrontCover\b[^"']*["'][^>]*)>\s*(]*>)\s*"#, - options: [.caseInsensitive] - ) { - normalized = replaceMatches( - using: coverRegex, - in: normalized - ) { tag in - - guard let imageTagRegex = try? NSRegularExpression(pattern: #"]*>"#, options: [.caseInsensitive]), - let imageMatch = imageTagRegex.firstMatch( - in: tag, - options: [], - range: NSRange(location: 0, length: (tag as NSString).length) - ), - let imageRange = Range(imageMatch.range, in: tag) else { - return tag - } - - let imageTag = String(tag[imageRange]) - let normalizedImageTag = mergeHTMLAttributes( - into: imageTag, - requiredClass: "rd-front-cover-image", - styleFragments: [ - "display:block", - "width:100%", - "height:auto", - "margin-left:auto", - "margin-right:auto" - ] - ) - return tag.replacingCharacters(in: imageRange, with: normalizedImageTag) - } - } - - return normalized - } - - private static func normalizeBodyLeadingSpacing(in html: String) -> String { - var normalized = html - - if let bodyStartRegex = try? NSRegularExpression( - pattern: #"(]*>)\s+"#, - options: [.caseInsensitive] - ) { - normalized = bodyStartRegex.stringByReplacingMatches( - in: normalized, - options: [], - range: NSRange(location: 0, length: (normalized as NSString).length), - withTemplate: "$1" - ) - } - - if let bodyEndRegex = try? NSRegularExpression( - pattern: #"\s+()"#, - options: [.caseInsensitive] - ) { - normalized = bodyEndRegex.stringByReplacingMatches( - in: normalized, - options: [], - range: NSRange(location: 0, length: (normalized as NSString).length), - withTemplate: "$1" - ) - } - - guard let firstBlockRegex = try? NSRegularExpression( - pattern: #"(]*>)(\s*)(<(?h[1-6]|p|div|blockquote|section|article|ul|ol)\b[^>]*>)"#, - options: [.caseInsensitive] - ) else { - return normalized - } - - let nsNormalized = normalized as NSString - let fullRange = NSRange(location: 0, length: nsNormalized.length) - guard let match = firstBlockRegex.firstMatch(in: normalized, options: [], range: fullRange), - match.numberOfRanges >= 4, - let bodyRange = Range(match.range(at: 1), in: normalized), - let blockRange = Range(match.range(at: 3), in: normalized) else { - return normalized - } - - let prefix = String(normalized[.. String - ) -> String { - let nsSource = source as NSString - - let matches = regex.matches(in: source, options: [], range: NSRange(location: 0, length: nsSource.length)) - guard !matches.isEmpty else { return source } - - var rewritten = source - for match in matches.reversed() { - guard let range = Range(match.range, in: rewritten) else { continue } - let original = String(rewritten[range]) - rewritten.replaceSubrange(range, with: transform(original)) - } - return rewritten - } - - static func mergeHTMLAttributes( - into tag: String, - requiredClass: String?, - styleFragments: [String] - ) -> String { - var rewritten = tag - if let requiredClass { - if let classRegex = try? NSRegularExpression(pattern: #"class\s*=\s*["']([^"']*)["']"#, options: [.caseInsensitive]), - let match = classRegex.firstMatch(in: rewritten, options: [], range: NSRange(location: 0, length: (rewritten as NSString).length)), - match.numberOfRanges > 1 { - let existingClasses = (rewritten as NSString).substring(with: match.range(at: 1)) - if !existingClasses.localizedCaseInsensitiveContains(requiredClass) { - let replacement = #"class="\#(existingClasses) \#(requiredClass)""# - if let range = Range(match.range, in: rewritten) { - rewritten.replaceSubrange(range, with: replacement) - } - } - } else if let closing = rewritten.lastIndex(of: ">") { - rewritten.insert(contentsOf: #" class="\#(requiredClass)""#, at: closing) - } - } - - let styleValue = styleFragments.joined(separator: ";") + ";" - if let styleRegex = try? NSRegularExpression(pattern: #"style\s*=\s*["']([^"']*)["']"#, options: [.caseInsensitive]), - let match = styleRegex.firstMatch(in: rewritten, options: [], range: NSRange(location: 0, length: (rewritten as NSString).length)), - match.numberOfRanges > 1 { - let existing = (rewritten as NSString).substring(with: match.range(at: 1)).trimmingCharacters(in: .whitespacesAndNewlines) - let merged = existing.isEmpty ? styleValue : existing + (existing.hasSuffix(";") ? "" : ";") + styleValue - let replacement = #"style="\#(merged)""# - if let range = Range(match.range, in: rewritten) { - rewritten.replaceSubrange(range, with: replacement) - } - } else if let closing = rewritten.lastIndex(of: ">") { - rewritten.insert(contentsOf: #" style="\#(styleValue)""#, at: closing) - } - - return rewritten - } - - static func injectBaseHref(into html: String, baseURL: URL?) -> String { - guard let baseURL else { - return html - } - let baseTag = "" - if html.range(of: "", options: [.caseInsensitive]) != nil { - return html.replacingOccurrences(of: "", with: "\n\(baseTag)", options: [.caseInsensitive]) - } - if let htmlTagRange = html.range(of: "", range: htmlTagRange.lowerBound..\n\(baseTag)\n") - } - return "\n\(baseTag)\n\n" + html - } - - static func string(from size: CGSize) -> String { - "{\(Int(round(size.width))), \(Int(round(size.height)))}" - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBRenderDiagnosticsCollector.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBRenderDiagnosticsCollector.swift deleted file mode 100644 index 8bc00b1..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBRenderDiagnosticsCollector.swift +++ /dev/null @@ -1,191 +0,0 @@ -import Foundation - -struct RDEPUBRenderDiagnosticsCollector { - - private static let stylesheetLinkPattern = #"]*rel\s*=\s*["'][^"']*stylesheet[^"']*["'][^>]*href\s*=\s*["']([^"']+)["'][^>]*>"# - - private static let imageSourcePattern = #"]*src\s*=\s*["']([^"']+)["'][^>]*>"# - - - - - - - func collect( - in html: String, - input: RDEPUBTypesettingInput - ) -> [RDEPUBTextResourceReferenceDiagnostic] { - Self.collectImageDiagnostics( - in: html, - chapterHref: input.href, - baseURL: input.baseURL, - resourceResolver: input.resourceResolver - ) - } - - - - - static func collectImageDiagnostics( - in html: String, - chapterHref: String, - baseURL: URL?, - resourceResolver: RDEPUBResourceResolver? - ) -> [RDEPUBTextResourceReferenceDiagnostic] { - guard let regex = try? NSRegularExpression(pattern: imageSourcePattern, options: [.caseInsensitive]) else { - return [] - } - let nsHTML = html as NSString - return regex.matches(in: html, options: [], range: NSRange(location: 0, length: nsHTML.length)).compactMap { match in - guard match.numberOfRanges > 1 else { return nil } - let href = nsHTML.substring(with: match.range(at: 1)) - return resolveReference( - href, - kind: .image, - chapterHref: chapterHref, - baseURL: baseURL, - resourceResolver: resourceResolver - ).diagnostic - } - } - - - - - static func inlineLinkedStyleSheets( - in html: String, - chapterHref: String, - baseURL: URL?, - resourceResolver: RDEPUBResourceResolver? - ) -> (html: String, inlinedCSS: String, diagnostics: [RDEPUBTextResourceReferenceDiagnostic]) { - guard let regex = try? NSRegularExpression(pattern: stylesheetLinkPattern, options: [.caseInsensitive]) else { - return (html, "", []) - } - - let nsHTML = html as NSString - let matches = regex.matches(in: html, options: [], range: NSRange(location: 0, length: nsHTML.length)) - guard !matches.isEmpty else { - return (html, "", []) - } - - - var rewrittenHTML = html - - var inlinedCSSBlocks: [String] = [] - - var diagnostics: [RDEPUBTextResourceReferenceDiagnostic] = [] - - - for match in matches.reversed() { - guard match.numberOfRanges > 1 else { continue } - let href = nsHTML.substring(with: match.range(at: 1)) - let resolution = resolveReference( - href, - kind: .stylesheet, - chapterHref: chapterHref, - baseURL: baseURL, - resourceResolver: resourceResolver - ) - diagnostics.append(resolution.diagnostic) - - if let fileURL = resolution.resolvedFileURL, - let css = styleSheetContent(at: fileURL, resourceResolver: resourceResolver), - resolution.diagnostic.existsOnDisk { - let cssWithResolvedURLs = rewriteCSSResourceURLs( - in: css, - styleSheetFileURL: fileURL - ) - inlinedCSSBlocks.append(cssWithResolvedURLs) - } - - if let range = Range(match.range, in: rewrittenHTML) { - rewrittenHTML.replaceSubrange(range, with: "") - } - } - - return (rewrittenHTML, inlinedCSSBlocks.reversed().joined(separator: "\n\n"), diagnostics.reversed()) - } - - /// 读取样式表内容:优先经加密资源 provider 解密,明文文件保持原直读路径 - private static func styleSheetContent( - at fileURL: URL, - resourceResolver: RDEPUBResourceResolver? - ) -> String? { - if let provided = resourceResolver?.providedResourceData(at: fileURL) { - return String(data: provided, encoding: .utf8) - ?? String(data: provided, encoding: .utf16) - } - return try? String(contentsOf: fileURL) - } - - - static func rewriteCSSResourceURLs( - in css: String, - styleSheetFileURL: URL - ) -> String { - guard let regex = try? NSRegularExpression(pattern: #"url\(([^)]+)\)"#, options: [.caseInsensitive]) else { - return css - } - - let nsCSS = css as NSString - let matches = regex.matches(in: css, options: [], range: NSRange(location: 0, length: nsCSS.length)) - guard !matches.isEmpty else { - return css - } - - var rewrittenCSS = css - - for match in matches.reversed() { - guard match.numberOfRanges > 1 else { continue } - - let rawValue = nsCSS.substring(with: match.range(at: 1)) - .trimmingCharacters(in: .whitespacesAndNewlines) - .trimmingCharacters(in: CharacterSet(charactersIn: "\"'")) - guard !rawValue.isEmpty else { continue } - - if rawValue.hasPrefix("data:") - || rawValue.hasPrefix("http:") - || rawValue.hasPrefix("https:") { - continue - } - - guard let resolvedURL = URL(string: rawValue, relativeTo: styleSheetFileURL.deletingLastPathComponent())?.standardizedFileURL else { - continue - } - - let replacement = "url(\"\(resolvedURL.absoluteString)\")" - if let range = Range(match.range, in: rewrittenCSS) { - rewrittenCSS.replaceSubrange(range, with: replacement) - } - } - return rewrittenCSS - } - - static func resolveReference( - _ reference: String, - kind: RDEPUBTextResourceReferenceKind, - chapterHref: String, - baseURL: URL?, - resourceResolver: RDEPUBResourceResolver? - ) -> (normalizedHref: String?, resolvedFileURL: URL?, diagnostic: RDEPUBTextResourceReferenceDiagnostic) { - - let trimmedReference = reference.trimmingCharacters(in: .whitespacesAndNewlines) - - let normalizedHref = resourceResolver?.normalizedHref(trimmedReference, relativeToHref: chapterHref) - - let resolvedFileURL = resourceResolver?.fileURL(forReference: trimmedReference, relativeToHref: chapterHref) - ?? URL(string: trimmedReference, relativeTo: baseURL)?.standardizedFileURL - - let existsOnDisk = resolvedFileURL.map { FileManager.default.fileExists(atPath: $0.path) } ?? false - - let diagnostic = RDEPUBTextResourceReferenceDiagnostic( - kind: kind, - chapterHref: chapterHref, - originalReference: trimmedReference, - normalizedHref: normalizedHref, - resolvedFileURL: resolvedFileURL, - existsOnDisk: existsOnDisk - ) - return (normalizedHref, resolvedFileURL, diagnostic) - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBSemanticMarkerInjector.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBSemanticMarkerInjector.swift deleted file mode 100644 index cc941ac..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBSemanticMarkerInjector.swift +++ /dev/null @@ -1,336 +0,0 @@ - -import Foundation -import UIKit - -struct RDEPUBSemanticMarkerInjector: RDEPUBTypesettingStage { - - private static let semanticMarkerPattern = #"\$\{rd-sem-(start|end):([^}]+)\}"# - - func process(_ html: String, context: RDEPUBTypesettingInput) -> String { - Self.injectPaginationSemanticMarkers(into: html) - } - - func apply(to attributedString: NSMutableAttributedString) { - Self.applyPaginationSemantics(in: attributedString) - } - - static func injectPaginationSemanticMarkers(into html: String) -> String { - guard let regex = try? NSRegularExpression(pattern: #"<[^>]+>"#, options: [.caseInsensitive]) else { - return html - } - - let nsHTML = html as NSString - let matches = regex.matches(in: html, options: [], range: NSRange(location: 0, length: nsHTML.length)) - guard !matches.isEmpty else { - return html - } - - var output = "" - - var cursor = 0 - - var openTagStack: [(name: String, id: String)] = [] - - var nextMarkerID = 0 - - for match in matches { - let tagRange = match.range - guard tagRange.location >= cursor else { continue } - output += nsHTML.substring(with: NSRange(location: cursor, length: tagRange.location - cursor)) - - let tag = nsHTML.substring(with: tagRange) - let loweredTag = tag.lowercased() - - let tagName = htmlTagName(from: loweredTag) - - if loweredTag.hasPrefix("") { - - output += startMarker + tag + semanticEndMarker(id: markerID) - } else { - - openTagStack.append((name: tagName, id: markerID)) - output += tag + startMarker - } - } else { - - output += tag - } - - cursor = tagRange.location + tagRange.length - } - - output += nsHTML.substring(from: cursor) - return output - } - - static func applyPaginationSemantics(in attributedString: NSMutableAttributedString) { - guard let regex = try? NSRegularExpression(pattern: semanticMarkerPattern, options: []) else { - return - } - - let mutableString = NSMutableString(string: attributedString.string) - - var searchRange = NSRange(location: 0, length: mutableString.length) - - var openRanges: [String: (location: Int, semantics: RDPaginationSemantics)] = [:] - - while let match = regex.firstMatch(in: mutableString as String, options: [], range: searchRange) { - - let kind = mutableString.substring(with: match.range(at: 1)) - - let payload = mutableString.substring(with: match.range(at: 2)) - - let markerLocation = match.range.location - - attributedString.deleteCharacters(in: match.range) - mutableString.deleteCharacters(in: match.range) - - if kind == "start" { - - let semantics = parseSemanticMarkerPayload(payload) - openRanges[semantics.id] = (markerLocation, semantics) - } else { - - let markerID = parseSemanticEndID(payload) - if let markerID, let opened = openRanges.removeValue(forKey: markerID) { - let length = max(markerLocation - opened.location, 0) - if length > 0 { - apply(semantics: opened.semantics, to: NSRange(location: opened.location, length: length), in: attributedString) - } - } - } - - searchRange = NSRange(location: markerLocation, length: mutableString.length - markerLocation) - } - } - - static func htmlTagName(from loweredTag: String) -> String? { - let trimmed = loweredTag.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmed.hasPrefix("<") else { return nil } - let body = trimmed.dropFirst().drop(while: { $0 == "/" || $0 == "!" || $0 == "?" }) - let name = body.prefix { $0.isLetter || $0.isNumber } - return name.isEmpty ? nil : String(name) - } - - private static func paginationSemantics(forTagName tagName: String, rawTag: String) -> RDPaginationSemantics? { - let loweredTag = rawTag.lowercased() - let blockKind = inferredBlockKind(forTagName: tagName, rawTag: loweredTag) - let hints = inferredHints(forTagName: tagName, rawTag: loweredTag) - let placement = inferredAttachmentPlacement(forTagName: tagName, rawTag: loweredTag) - - guard blockKind != nil || !hints.isEmpty || placement != nil else { - return nil - } - - return RDPaginationSemantics( - id: "", - blockKind: blockKind, - hints: hints, - attachmentPlacement: placement - ) - } - - private static func inferredBlockKind(forTagName tagName: String, rawTag: String) -> RDEPUBTextBlockKind? { - if rawTag.contains("bodypic") || tagName == "img" || tagName == "figure" { - return .attachment - } - switch tagName { - case "h1", "h2", "h3", "h4", "h5", "h6": - return .generic - case "blockquote": - return .blockquote - case "ul", "ol", "li": - return .list - case "table", "thead", "tbody", "tfoot", "tr", "td", "th": - return .table - case "pre", "code": - return .code - case "p": - return .paragraph - case "div": - if rawTag.contains("code") || rawTag.contains("highlight") { - return .code - } - if rawTag.contains("quote") || rawTag.contains("blockquote") { - return .blockquote - } - if rawTag.contains("table") { - return .table - } - if rawTag.contains("list") { - return .list - } - return .generic - default: - return nil - } - } - - private static func inferredHints(forTagName tagName: String, rawTag: String) -> [RDEPUBTextSemanticHint] { - var hints: [RDEPUBTextSemanticHint] = [] - if rawTag.contains("avoidpagebreakinside") || - rawTag.contains("break-inside: avoid") || - rawTag.contains("page-break-inside: avoid") || - ["blockquote", "pre", "code", "table", "ul", "ol", "figure", "img"].contains(tagName) { - hints.append(.avoidPageBreakInside) - } - if ["h1", "h2", "h3", "h4", "h5", "h6"].contains(tagName) || - rawTag.contains("subhead") || - rawTag.contains("firsttitle") || - rawTag.contains("secondtitle") || - rawTag.contains("thirdtitle") || - rawTag.contains("fourthtitle") || - rawTag.contains("fifthtitle") || - rawTag.contains("sixthtitle") { - hints.append(.keepWithNext) - } - if rawTag.contains("pagebreakbefore") || - rawTag.contains("page-break-before: always") || - rawTag.contains("break-before: page") { - hints.append(.pageBreakBefore) - } - if rawTag.contains("pagebreakafter") || - rawTag.contains("page-break-after: always") || - rawTag.contains("break-after: page") { - hints.append(.pageBreakAfter) - } - if rawTag.contains("pageRelate".lowercased()) || rawTag.contains("weread-page-relate") { - hints.append(.pageRelate) - } - return hints - .reduce(into: [RDEPUBTextSemanticHint]()) { result, hint in - if !result.contains(hint) { - result.append(hint) - } - } - .sorted { $0.rawValue < $1.rawValue } - } - - private static func inferredAttachmentPlacement(forTagName tagName: String, rawTag: String) -> RDEPUBTextAttachmentPlacement? { - guard tagName == "img" || rawTag.contains("bodypic") || rawTag.contains("wr-vertical-center") else { - return nil - } - if rawTag.contains("wr-vertical-center-style: 2") || rawTag.contains("bodypic") { - return .centered - } - if rawTag.contains("wr-vertical-center-style: 1") || rawTag.contains("wr-vertical-center") { - return .baseline - } - return .inline - } - - private static func isVoidHTMLTag(_ tagName: String) -> Bool { - ["img", "br", "hr", "input", "meta", "link"].contains(tagName) - } - - private static func semanticStartMarker(id: String, semantics: RDPaginationSemantics) -> String { - var segments = ["id=\(id)"] - if let blockKind = semantics.blockKind { - segments.append("block=\(blockKind.rawValue)") - } - if !semantics.hints.isEmpty { - segments.append("hints=\(semantics.hints.map(\.rawValue).joined(separator: ","))") - } - if let placement = semantics.attachmentPlacement { - segments.append("placement=\(placement.rawValue)") - } - return "${rd-sem-start:\(segments.joined(separator: ";"))}" - } - - private static func semanticEndMarker(id: String) -> String { - "${rd-sem-end:id=\(id)}" - } - - private static func parseSemanticMarkerPayload(_ payload: String) -> RDPaginationSemantics { - var values: [String: String] = [:] - payload.split(separator: ";").forEach { entry in - let parts = entry.split(separator: "=", maxSplits: 1) - guard parts.count == 2 else { return } - values[String(parts[0])] = String(parts[1]) - } - let blockKind = values["block"].flatMap(RDEPUBTextBlockKind.init(rawValue:)) - let hints = values["hints"]? - .split(separator: ",") - .compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) } ?? [] - let placement = values["placement"].flatMap(RDEPUBTextAttachmentPlacement.init(rawValue:)) - return RDPaginationSemantics( - id: values["id"] ?? UUID().uuidString, - blockKind: blockKind, - hints: hints, - attachmentPlacement: placement - ) - } - - private static func parseSemanticEndID(_ payload: String) -> String? { - payload.split(separator: ";").first { $0.hasPrefix("id=") }.map { String($0.dropFirst(3)) } - } - - private static func apply( - semantics: RDPaginationSemantics, - to range: NSRange, - in attributedString: NSMutableAttributedString - ) { - var attributes: [NSAttributedString.Key: Any] = [:] - attributes[.rdPageBlockRange] = NSStringFromRange(range) - if let blockKind = semantics.blockKind { - attributes[.rdPageBlockKind] = blockKind.rawValue - } - if !semantics.hints.isEmpty { - attributes[.rdPageSemanticHints] = semantics.hints.map(\.rawValue).joined(separator: ",") - } - if let placement = semantics.attachmentPlacement { - attributes[.rdPageAttachmentPlacement] = placement.rawValue - } - guard !attributes.isEmpty else { return } - attributedString.addAttributes(attributes, range: range) - } - - static func normalizeBlockKind(for attributes: [NSAttributedString.Key: Any]) -> RDEPUBTextBlockKind? { - if let rawValue = attributes[.rdPageBlockKind] as? String, - let blockKind = RDEPUBTextBlockKind(rawValue: rawValue) { - return blockKind - } - return nil - } - - static func normalizeSemanticHints(for attributes: [NSAttributedString.Key: Any]) -> [RDEPUBTextSemanticHint]? { - if let rawValue = attributes[.rdPageSemanticHints] as? String { - let hints = rawValue - .split(separator: ",") - .compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) } - return hints.isEmpty ? nil : hints - } - return nil - } - - static func normalizeAttachmentPlacement(for attributes: [NSAttributedString.Key: Any]) -> RDEPUBTextAttachmentPlacement? { - if let rawValue = attributes[.rdPageAttachmentPlacement] as? String, - let placement = RDEPUBTextAttachmentPlacement(rawValue: rawValue) { - return placement - } - if let attachmentKind = RDEPUBAttachmentNormalizer.attachmentKind(for: attributes), attachmentKind == .image { - return .inline - } - return nil - } - - struct RDPaginationSemantics { - var id: String - var blockKind: RDEPUBTextBlockKind? - var hints: [RDEPUBTextSemanticHint] - var attachmentPlacement: RDEPUBTextAttachmentPlacement? - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBStyleSheetComposer.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBStyleSheetComposer.swift deleted file mode 100644 index 45015ac..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBStyleSheetComposer.swift +++ /dev/null @@ -1,289 +0,0 @@ -import UIKit - -struct RDEPUBStyleSheetComposition { - - var html: String - - var layers: [RDEPUBTextStyleSheetLayer] - - var inlinedCSS: String - - var diagnostics: [RDEPUBTextResourceReferenceDiagnostic] - - var compatibilityReport: RDEPUBCSSCompatibilityReport -} - -struct RDEPUBStyleSheetComposer { - - func compose(html: String, input: RDEPUBTypesettingInput) -> RDEPUBStyleSheetComposition { - let stylesheetHrefReplacements = RDEPUBRenderDiagnosticsCollector.inlineLinkedStyleSheets( - in: html, - chapterHref: input.href, - baseURL: input.baseURL, - resourceResolver: input.resourceResolver - ) - let compatibility = RDEPUBCSSCompatibilityLayer().sanitize(stylesheetHrefReplacements.inlinedCSS) - let layers = Self.makeStyleSheetLayers( - style: input.style, - epubCSS: compatibility.css, - contentLanguageCode: input.contentLanguageCode, - sourceHTML: input.rawHTML - ) - let htmlWithBase = RDEPUBHTMLNormalizer.injectBaseHref( - into: stylesheetHrefReplacements.html, - baseURL: input.baseURL - ) - let htmlWithDefaultLayers = Self.injectStyleTag( - into: htmlWithBase, - styleID: "rd-native-default-replace-dark", - css: layers - .filter { $0.kind != .user && $0.kind != .epub } - .map(\.css) - .joined(separator: "\n\n"), - position: .headStart - ) - let htmlWithEPUBLayer = Self.injectStyleTag( - into: htmlWithDefaultLayers, - styleID: "rd-native-epub", - css: layers.first(where: { $0.kind == .epub })?.css ?? "", - position: .headEnd - ) - let composedHTML = Self.injectStyleTag( - into: htmlWithEPUBLayer, - styleID: "rd-native-user", - css: layers.first(where: { $0.kind == .user })?.css ?? "", - position: .headEnd - ) - - return RDEPUBStyleSheetComposition( - html: composedHTML, - layers: layers, - inlinedCSS: compatibility.css, - diagnostics: stylesheetHrefReplacements.diagnostics, - compatibilityReport: compatibility.report - ) - } - - static func makeStyleSheetLayers( - style: RDEPUBTextRenderStyle, - epubCSS: String, - contentLanguageCode: String?, - sourceHTML: String - ) -> [RDEPUBTextStyleSheetLayer] { - let useLatinReplace = prefersLatinLanguageCSS( - languageCode: contentLanguageCode, - sourceHTML: sourceHTML - ) - var layers: [RDEPUBTextStyleSheetLayer] = [ - .init(kind: .default, css: defaultCSS()), - .init(kind: .replace, css: replaceCSS(useLatinVariant: useLatinReplace)) - ] - if isDarkTheme(style: style) { - layers.append(.init(kind: .dark, css: darkCSS(style: style))) - } - if !epubCSS.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - layers.append(.init(kind: .epub, css: epubCSS)) - } - layers.append(.init(kind: .user, css: userCSS(style: style))) - return layers - } - - enum StyleInjectionPosition { - - case headStart - - case headEnd - } - - static func injectStyleTag( - into html: String, - styleID: String, - css: String, - position: StyleInjectionPosition - ) -> String { - let trimmedCSS = css.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedCSS.isEmpty else { - return html - } - - let styleTag = "" - switch position { - case .headStart: - if html.range(of: "", options: [.caseInsensitive]) != nil { - return html.replacingOccurrences(of: "", with: "\n\(styleTag)", options: [.caseInsensitive]) - } - case .headEnd: - if html.range(of: "", options: [.caseInsensitive]) != nil { - return html.replacingOccurrences(of: "", with: "\(styleTag)\n", options: [.caseInsensitive]) - } - } - - if html.range(of: " String { - RDEPUBAssetRepository.string(for: .wxReadDefaultCSS) - } - - private static func replaceCSS(useLatinVariant: Bool) -> String { - let asset: RDEPUBAsset = useLatinVariant ? .wxReadLatinReplaceCSS : .wxReadReplaceCSS - return RDEPUBAssetRepository.string(for: asset) - } - - private static func darkCSS(style: RDEPUBTextRenderStyle) -> String { - let background = style.backgroundColor?.rd_cssString ?? "rgba(0, 0, 0, 1.000)" - let text = style.textColor?.rd_cssString ?? "rgba(255, 255, 255, 1.000)" - return RDEPUBAssetRepository.string(for: .wxReadDarkCSS) + "\n\n" + """ - html, body { - background: \(background) !important; - color: \(text) !important; - } - a { - color: \(text) !important; - } - """ - } - - private static func userCSS(style: RDEPUBTextRenderStyle) -> String { - let lineHeight = max((style.font.lineHeight + style.lineSpacing) / max(style.font.lineHeight, 1), 1) - let text = style.textColor.map { "color: \($0.rd_cssString) !important;" } ?? "" - let background = style.backgroundColor.map { "background: \($0.rd_cssString) !important;" } ?? "" - return """ - html, body { - font-family: "\(style.font.familyName)" !important; - font-size: \(String(format: "%.3f", style.font.pointSize))px !important; - line-height: \(String(format: "%.3f", lineHeight)) !important; - \(text) - \(background) - } - """ - } - - private static func isDarkTheme(style: RDEPUBTextRenderStyle) -> Bool { - guard let backgroundColor = style.backgroundColor else { - return false - } - var red: CGFloat = 0 - var green: CGFloat = 0 - var blue: CGFloat = 0 - var alpha: CGFloat = 0 - backgroundColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha) - let luminance = (0.299 * red) + (0.587 * green) + (0.114 * blue) - return luminance < 0.5 - } - - private static func prefersLatinLanguageCSS( - languageCode: String?, - sourceHTML: String - ) -> Bool { - let candidateCodes = inferredLanguageCodes( - explicitLanguageCode: languageCode, - sourceHTML: sourceHTML - ) - - if candidateCodes.contains(where: isExplicitLatinLanguageCode) { - return true - } - if candidateCodes.contains(where: isExplicitCJKLanguageCode) { - return false - } - - let textSample = plainTextSample(from: sourceHTML) - guard !textSample.isEmpty else { return false } - - var alphabeticCount = 0 - var latinCount = 0 - for scalar in textSample.unicodeScalars { - guard CharacterSet.letters.contains(scalar) else { continue } - alphabeticCount += 1 - if isLatinScalar(scalar) { - latinCount += 1 - } - } - - guard alphabeticCount >= 80 else { return false } - return (Double(latinCount) / Double(alphabeticCount)) >= 0.6 - } - - private static func inferredLanguageCodes( - explicitLanguageCode: String?, - sourceHTML: String - ) -> [String] { - var codes: [String] = [] - if let explicitLanguageCode { - let normalized = explicitLanguageCode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - if !normalized.isEmpty { - codes.append(normalized) - } - } - - if let regex = try? NSRegularExpression( - pattern: #"\b(?:xml:lang|lang)\s*=\s*["']([^"']+)["']"#, - options: [.caseInsensitive] - ) { - let nsHTML = sourceHTML as NSString - let range = NSRange(location: 0, length: min(nsHTML.length, 8_000)) - for match in regex.matches(in: sourceHTML, options: [], range: range) { - guard match.numberOfRanges > 1 else { continue } - let code = nsHTML.substring(with: match.range(at: 1)) - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - if !code.isEmpty { - codes.append(code) - } - } - } - - return Array(NSOrderedSet(array: codes)) as? [String] ?? codes - } - - private static func isExplicitLatinLanguageCode(_ code: String) -> Bool { - let normalized = code.lowercased() - if normalized.contains("latn") { - return true - } - - let prefix = normalized.split(separator: "-").first.map(String.init) ?? normalized - let latinPrefixes: Set = [ - "en", "fr", "de", "es", "it", "pt", "nl", "sv", "da", "no", "fi", - "is", "ga", "cy", "pl", "cs", "sk", "sl", "hr", "hu", "ro", "tr", - "vi", "id", "ms", "tl", "sw", "af", "sq", "et", "lv", "lt" - ] - return latinPrefixes.contains(prefix) - } - - private static func isExplicitCJKLanguageCode(_ code: String) -> Bool { - let prefix = code.lowercased().split(separator: "-").first.map(String.init) ?? code.lowercased() - return ["zh", "ja", "ko"].contains(prefix) - } - - private static func plainTextSample(from html: String) -> String { - let maxLength = min(html.count, 20_000) - let sample = String(html.prefix(maxLength)) - let withoutTags = sample.replacingOccurrences( - of: #"<[^>]+>"#, - with: " ", - options: .regularExpression - ) - return withoutTags.replacingOccurrences( - of: #"&[A-Za-z0-9#]+;"#, - with: " ", - options: .regularExpression - ) - } - - private static func isLatinScalar(_ scalar: UnicodeScalar) -> Bool { - switch scalar.value { - case 0x0041...0x007A, - 0x00C0...0x00FF, - 0x0100...0x024F, - 0x1E00...0x1EFF: - return true - default: - return false - } - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBTextRendererSupport.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBTextRendererSupport.swift deleted file mode 100644 index 04cd13f..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBTextRendererSupport.swift +++ /dev/null @@ -1,185 +0,0 @@ -import UIKit -import CoreText - -#if canImport(DTCoreText) -import DTCoreText -#endif - -enum RDEPUBTextRendererSupport { - - static func makeChapterRenderRequest( - href: String, - title: String, - rawHTML: String, - baseURL: URL?, - style: RDEPUBTextRenderStyle, - resourceResolver: RDEPUBResourceResolver?, - contentLanguageCode: String? = nil, - pageSize: CGSize? = nil, - layoutConfig: RDEPUBTextLayoutConfig? = nil - ) -> RDEPUBTextChapterRenderRequest { - - let normalizedHTML = RDEPUBSemanticMarkerInjector.injectPaginationSemanticMarkers( - into: RDEPUBHTMLNormalizer.normalizeHTML(rawHTML) - ) - - let stylesheetHrefReplacements = RDEPUBRenderDiagnosticsCollector.inlineLinkedStyleSheets( - in: normalizedHTML, - chapterHref: href, - baseURL: baseURL, - resourceResolver: resourceResolver - ) - let compatibility = RDEPUBCSSCompatibilityLayer().sanitize(stylesheetHrefReplacements.inlinedCSS) - - let layers = RDEPUBStyleSheetComposer.makeStyleSheetLayers( - style: style, - epubCSS: compatibility.css, - contentLanguageCode: contentLanguageCode, - sourceHTML: rawHTML - ) - - RDEPUBFontNormalizer.registerEmbeddedFonts( - in: compatibility.css + "\n" + RDEPUBFontNormalizer.inlineStyleCSS(in: normalizedHTML), - chapterHref: href, - resourceResolver: resourceResolver - ) - - let htmlWithBase = RDEPUBHTMLNormalizer.injectBaseHref(into: stylesheetHrefReplacements.html, baseURL: baseURL) - - let htmlWithDefaultLayers = RDEPUBStyleSheetComposer.injectStyleTag( - into: htmlWithBase, - styleID: "rd-native-default-replace-dark", - css: layers - .filter { $0.kind != .user && $0.kind != .epub } - .map(\.css) - .joined(separator: "\n\n"), - position: .headStart - ) - - let htmlWithEPUBLayer = RDEPUBStyleSheetComposer.injectStyleTag( - into: htmlWithDefaultLayers, - styleID: "rd-native-epub", - css: layers.first(where: { $0.kind == .epub })?.css ?? "", - position: .headEnd - ) - - let composedHTML = RDEPUBStyleSheetComposer.injectStyleTag( - into: htmlWithEPUBLayer, - styleID: "rd-native-user", - css: layers.first(where: { $0.kind == .user })?.css ?? "", - position: .headEnd - ) - - let markedHTML = RDEPUBFragmentMarkerInjector.injectFragmentMarkers(into: composedHTML) - - let resourceDiagnostics = stylesheetHrefReplacements.diagnostics + RDEPUBRenderDiagnosticsCollector.collectImageDiagnostics( - in: markedHTML, - chapterHref: href, - baseURL: baseURL, - resourceResolver: resourceResolver - ) - - let context = RDEPUBTextChapterContext( - href: href, - title: title, - html: markedHTML, - baseURL: baseURL, - stylesheet: RDEPUBTextStyleSheetPackage(layers: layers), - resourceDiagnostics: resourceDiagnostics, - styleCompatibilityReport: compatibility.report - ) - return RDEPUBTextChapterRenderRequest( - context: context, - style: style, - pageSize: pageSize, - layoutConfig: layoutConfig - ) - } - - static func normalizeReadingAttributes( - in attributedString: NSMutableAttributedString, - style: RDEPUBTextRenderStyle, - layoutConfig: RDEPUBTextLayoutConfig = .default - ) { - let fullRange = NSRange(location: 0, length: attributedString.length) - - var blockIndex = 0 - let sourceText = attributedString.string as NSString - - attributedString.enumerateAttributes(in: fullRange) { attributes, range, _ in - - let sourceFont = attributes[.font] as? UIFont - let normalizedFont = RDEPUBFontNormalizer.normalizedFont(from: sourceFont, baseFont: style.font) - - let paragraph = (attributes[.paragraphStyle] as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle ?? paragraphStyle(lineSpacing: style.lineSpacing) - paragraph.lineSpacing = style.lineSpacing - paragraph.paragraphSpacing = max(paragraph.paragraphSpacing, style.lineSpacing / 2) - normalizeAlignedParagraphIndent(paragraph) - - paragraph.hyphenationFactor = layoutConfig.hyphenation ? 1.0 : 0.0 - - var updatedAttributes = attributes - updatedAttributes[.font] = normalizedFont - updatedAttributes[.paragraphStyle] = paragraph - - if let textColor = style.textColor { - updatedAttributes[.foregroundColor] = textColor - } - - RDEPUBAttachmentNormalizer.normalizeAttachmentDisplayIfNeeded(in: &updatedAttributes, font: normalizedFont) - - let semanticBlockRange = (attributes[.rdPageBlockRange] as? String) - .flatMap(NSRangeFromString) - .flatMap { $0.length > 0 ? $0 : nil } - let paragraphRange = sourceText.length > 0 - ? sourceText.paragraphRange(for: NSRange(location: min(range.location, max(sourceText.length - 1, 0)), length: 0)) - : range - - updatedAttributes[.rdPageBlockRange] = NSStringFromRange(semanticBlockRange ?? paragraphRange) - updatedAttributes[.rdPageBlockIndex] = blockIndex - - if let attachmentKind = RDEPUBAttachmentNormalizer.attachmentKind(for: attributes) { - updatedAttributes[.rdPageAttachmentKind] = attachmentKind.rawValue - } - - if let placement = RDEPUBSemanticMarkerInjector.normalizeAttachmentPlacement(for: attributes) { - updatedAttributes[.rdPageAttachmentPlacement] = placement.rawValue - } - - if let blockKind = RDEPUBSemanticMarkerInjector.normalizeBlockKind(for: attributes) { - updatedAttributes[.rdPageBlockKind] = blockKind.rawValue - } - - if let hints = RDEPUBSemanticMarkerInjector.normalizeSemanticHints(for: attributes), !hints.isEmpty { - updatedAttributes[.rdPageSemanticHints] = hints.map(\.rawValue).joined(separator: ",") - } - - attributedString.setAttributes(updatedAttributes, range: range) - blockIndex += 1 - } - } - - static func fallbackAttributedString(for html: String, style: RDEPUBTextRenderStyle) -> NSMutableAttributedString { - - let fallbackAttributes: [NSAttributedString.Key: Any] = [ - .font: style.font, - .paragraphStyle: paragraphStyle(lineSpacing: style.lineSpacing), - .foregroundColor: style.textColor ?? UIColor.black - ] - return NSMutableAttributedString(string: html, attributes: fallbackAttributes) - } - - static func paragraphStyle(lineSpacing: CGFloat) -> NSMutableParagraphStyle { - let style = NSMutableParagraphStyle() - style.lineSpacing = lineSpacing - style.paragraphSpacing = max(6, lineSpacing / 2) - return style - } - - private static func normalizeAlignedParagraphIndent(_ paragraph: NSMutableParagraphStyle) { - guard paragraph.alignment == .right || paragraph.alignment == .center else { - return - } - paragraph.firstLineHeadIndent = 0 - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBTypesettingPipeline.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBTypesettingPipeline.swift deleted file mode 100644 index 1fe50e7..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBTypesettingPipeline.swift +++ /dev/null @@ -1,117 +0,0 @@ -import UIKit - -struct RDEPUBTypesettingInput { - - var href: String - - var spineIndex: Int? - - var title: String - - var rawHTML: String - - var baseURL: URL? - - var style: RDEPUBTextRenderStyle - - var resourceResolver: RDEPUBResourceResolver? - - var contentLanguageCode: String? - - var pageSize: CGSize? - - var layoutConfig: RDEPUBTextLayoutConfig? -} - -struct RDEPUBTypesettingOutput { - - var request: RDEPUBTextChapterRenderRequest - - var diagnostics: [RDEPUBTextResourceReferenceDiagnostic] - - var styleCompatibilityReport: RDEPUBCSSCompatibilityReport -} - -protocol RDEPUBTypesettingStage { - - func process(_ html: String, context: RDEPUBTypesettingInput) -> String -} - -struct RDEPUBTextTypesetterPipeline { - - func makeRequest(from input: RDEPUBTypesettingInput) -> RDEPUBTypesettingOutput { - - let htmlNormalizer = RDEPUBHTMLNormalizer() - let semanticMarkerInjector = RDEPUBSemanticMarkerInjector() - let cfiMarkerInjector = RDEPUBCFIMarkerInjector() - let styleSheetComposer = RDEPUBStyleSheetComposer() - let fontNormalizer = RDEPUBFontNormalizer() - let fragmentMarkerInjector = RDEPUBFragmentMarkerInjector() - let diagnosticsCollector = RDEPUBRenderDiagnosticsCollector() - - let normalizedHTML = cfiMarkerInjector.process( - semanticMarkerInjector.process( - htmlNormalizer.process(input.rawHTML, context: input), - context: input - ), - context: input - ) - - let styleSheetComposition = styleSheetComposer.compose(html: normalizedHTML, input: input) - - let fontResults = fontNormalizer.registerEmbeddedFonts( - html: normalizedHTML, - inlinedCSS: styleSheetComposition.inlinedCSS, - input: input - ) - - let compatibilityReport = Self.mergedCompatibilityReport( - styleSheetComposition.compatibilityReport, - fontResults: fontResults - ) - - let markedHTML = fragmentMarkerInjector.process(styleSheetComposition.html, context: input) - - let diagnostics = styleSheetComposition.diagnostics + diagnosticsCollector.collect(in: markedHTML, input: input) - - let context = RDEPUBTextChapterContext( - href: input.href, - title: input.title, - html: markedHTML, - baseURL: input.baseURL, - stylesheet: RDEPUBTextStyleSheetPackage(layers: styleSheetComposition.layers), - resourceDiagnostics: diagnostics, - styleCompatibilityReport: compatibilityReport - ) - let request = RDEPUBTextChapterRenderRequest( - context: context, - style: input.style, - pageSize: input.pageSize, - layoutConfig: input.layoutConfig - ) - return RDEPUBTypesettingOutput( - request: request, - diagnostics: diagnostics, - styleCompatibilityReport: compatibilityReport - ) - } - - private static func mergedCompatibilityReport( - _ report: RDEPUBCSSCompatibilityReport, - fontResults: [RDEPUBFontRegistrationResult] - ) -> RDEPUBCSSCompatibilityReport { - - let fontFailures = fontResults.compactMap { result -> String? in - guard result.didRegister == false else { return nil } - let family = result.descriptor.family ?? "unknown-family" - let href = result.descriptor.href - let reason = result.errorDescription ?? "Font registration failed" - return "\(family) <\(href)>: \(reason)" - } - return RDEPUBCSSCompatibilityReport( - unsupportedRules: report.unsupportedRules, - normalizedRules: report.normalizedRules, - fontFailures: report.fontFailures + fontFailures - ) - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Notes/RDEPUBNotePopupCoordinator.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Notes/RDEPUBNotePopupCoordinator.swift deleted file mode 100644 index 5e8103d..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Notes/RDEPUBNotePopupCoordinator.swift +++ /dev/null @@ -1,24 +0,0 @@ -import UIKit - -final class RDEPUBNotePopupCoordinator { - - static func present( - _ note: RDEPUBResolvedNote, - from controller: UIViewController, - onReturnToSource: (() -> Void)? = nil, - onOpenNoteLocation: (() -> Void)? = nil - ) { - let popup = RDEPUBNotePopupViewController( - note: note, - onReturnToSource: onReturnToSource, - onOpenNoteLocation: onOpenNoteLocation - ) - let navigationController = UINavigationController(rootViewController: popup) - navigationController.modalPresentationStyle = .pageSheet - if let sheet = navigationController.sheetPresentationController { - sheet.detents = [.medium(), .large()] - sheet.prefersGrabberVisible = true - } - controller.present(navigationController, animated: true) - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Notes/RDEPUBNotePopupViewController.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Notes/RDEPUBNotePopupViewController.swift deleted file mode 100644 index 1f5df59..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Notes/RDEPUBNotePopupViewController.swift +++ /dev/null @@ -1,145 +0,0 @@ -import UIKit - -final class RDEPUBNotePopupViewController: UIViewController { - - private let note: RDEPUBResolvedNote - private let onReturnToSource: (() -> Void)? - private let onOpenNoteLocation: (() -> Void)? - - private let textView = UITextView() - private let actionsStackView = UIStackView() - - init( - note: RDEPUBResolvedNote, - onReturnToSource: (() -> Void)? = nil, - onOpenNoteLocation: (() -> Void)? = nil - ) { - self.note = note - self.onReturnToSource = onReturnToSource - self.onOpenNoteLocation = onOpenNoteLocation - super.init(nibName: nil, bundle: nil) - title = note.title ?? "注释" - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - view.backgroundColor = .systemBackground - navigationItem.rightBarButtonItem = UIBarButtonItem( - barButtonSystemItem: .done, - target: self, - action: #selector(close) - ) - - textView.translatesAutoresizingMaskIntoConstraints = false - textView.isEditable = false - textView.alwaysBounceVertical = true - textView.backgroundColor = .clear - textView.textContainerInset = UIEdgeInsets(top: 18, left: 18, bottom: 24, right: 18) - textView.attributedText = attributedContent() - textView.accessibilityIdentifier = "epub.reader.note.text" - actionsStackView.translatesAutoresizingMaskIntoConstraints = false - actionsStackView.axis = .horizontal - actionsStackView.spacing = 12 - actionsStackView.distribution = .fillEqually - actionsStackView.accessibilityIdentifier = "epub.reader.note.actions" - configureActionButtons() - - view.addSubview(actionsStackView) - view.addSubview(textView) - - NSLayoutConstraint.activate([ - actionsStackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 18), - actionsStackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -18), - actionsStackView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12), - actionsStackView.heightAnchor.constraint(equalToConstant: actionsStackView.arrangedSubviews.isEmpty ? 0 : 36), - textView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - textView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - textView.topAnchor.constraint(equalTo: actionsStackView.bottomAnchor, constant: actionsStackView.arrangedSubviews.isEmpty ? 0 : 8), - textView.bottomAnchor.constraint(equalTo: view.bottomAnchor) - ]) - } - - private func configureActionButtons() { - if note.sourceLocation != nil, onReturnToSource != nil { - actionsStackView.addArrangedSubview( - makeActionButton(title: "返回原文", action: #selector(returnToSource)) - ) - } - if onOpenNoteLocation != nil { - actionsStackView.addArrangedSubview( - makeActionButton(title: "打开注释原文", action: #selector(openNoteLocation)) - ) - } - actionsStackView.isHidden = actionsStackView.arrangedSubviews.isEmpty - } - - private func makeActionButton(title: String, action: Selector) -> UIButton { - let button = UIButton(type: .system) - var configuration = UIButton.Configuration.filled() - configuration.cornerStyle = .medium - configuration.title = title - configuration.baseBackgroundColor = .secondarySystemBackground - configuration.baseForegroundColor = .label - button.configuration = configuration - if title == "返回原文" { - button.accessibilityIdentifier = "epub.reader.note.return" - } else if title == "打开注释原文" { - button.accessibilityIdentifier = "epub.reader.note.open" - } - button.addTarget(self, action: action, for: .touchUpInside) - return button - } - - private func attributedContent() -> NSAttributedString { - let html = """ - - - - - - \(note.html) - - """ - guard let data = html.data(using: .utf8), - let attributed = try? NSMutableAttributedString( - data: data, - options: [ - .documentType: NSAttributedString.DocumentType.html, - .characterEncoding: String.Encoding.utf8.rawValue - ], - documentAttributes: nil - ) else { - return NSAttributedString(string: note.plainText) - } - return attributed - } - - @objc private func close() { - dismiss(animated: true) - } - - @objc private func returnToSource() { - dismiss(animated: true) { [onReturnToSource] in - onReturnToSource?() - } - } - - @objc private func openNoteLocation() { - dismiss(animated: true) { [onOpenNoteLocation] in - onOpenNoteLocation?() - } - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBAttachmentTooltipOverlayView.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBAttachmentTooltipOverlayView.swift deleted file mode 100644 index 73eed51..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBAttachmentTooltipOverlayView.swift +++ /dev/null @@ -1,36 +0,0 @@ - -import UIKit - -final class RDEPUBAttachmentTooltipOverlayView: UIView { - - var onBackgroundTap: (() -> Void)? - - var tooltipView: RDEPUBAttachmentTooltipView? { - subviews.compactMap { $0 as? RDEPUBAttachmentTooltipView }.first - } - - override init(frame: CGRect) { - super.init(frame: frame) - backgroundColor = .clear - - let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:))) - tapGesture.cancelsTouchesInView = false - addGestureRecognizer(tapGesture) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - @objc - private func handleTap(_ gesture: UITapGestureRecognizer) { - let point = gesture.location(in: self) - guard let tooltipView else { - onBackgroundTap?() - return - } - if !tooltipView.frame.contains(point) { - onBackgroundTap?() - } - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBAttachmentTooltipView.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBAttachmentTooltipView.swift deleted file mode 100644 index f82fd91..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBAttachmentTooltipView.swift +++ /dev/null @@ -1,188 +0,0 @@ - -import UIKit - -final class RDEPUBAttachmentTooltipView: UIView { - - enum ArrowPlacement { - - case top - - case bottom - } - - private let contentInsets = UIEdgeInsets(top: 18, left: 20, bottom: 24, right: 20) - - private let arrowSize = CGSize(width: 20, height: 10) - - private let cornerRadius: CGFloat = 18 - - private(set) var minimumArrowX: CGFloat = 28 - - private var arrowTipX: CGFloat? - - private var arrowPlacement: ArrowPlacement = .bottom - - private let textLabel: UILabel = { - let label = UILabel() - label.numberOfLines = 0 - label.textColor = .white - label.font = .systemFont(ofSize: 16, weight: .regular) - label.lineBreakMode = .byWordWrapping - return label - }() - - private let shapeLayer = CAShapeLayer() - - override init(frame: CGRect) { - super.init(frame: frame) - backgroundColor = .clear - isOpaque = false - layer.addSublayer(shapeLayer) - addSubview(textLabel) - accessibilityIdentifier = "epub.reader.attachment.tooltip" - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func layoutSubviews() { - super.layoutSubviews() - shapeLayer.frame = bounds - shapeLayer.path = bubblePath(in: bounds).cgPath - shapeLayer.fillColor = UIColor(white: 0.26, alpha: 0.96).cgColor - - let topInset = contentInsets.top + (arrowPlacement == .top ? arrowSize.height : 0) - let bottomInset = contentInsets.bottom + (arrowPlacement == .bottom ? arrowSize.height : 0) - let labelFrame = bounds.inset(by: UIEdgeInsets( - top: topInset, - left: contentInsets.left, - bottom: bottomInset, - right: contentInsets.right - )) - textLabel.frame = labelFrame - } - - func setArrowTipX(_ value: CGFloat, placement: ArrowPlacement) { - arrowTipX = value - arrowPlacement = placement - setNeedsLayout() - } - - func configure(text: String, maxWidth: CGFloat) { - textLabel.text = text - let labelMaxWidth = max(maxWidth - contentInsets.left - contentInsets.right, 120) - let labelSize = textLabel.sizeThatFits(CGSize(width: labelMaxWidth, height: .greatestFiniteMagnitude)) - frame.size = CGSize( - width: min(maxWidth, labelSize.width + contentInsets.left + contentInsets.right), - height: labelSize.height + contentInsets.top + contentInsets.bottom + arrowSize.height - ) - setNeedsLayout() - layoutIfNeeded() - } - - private func bubblePath(in rect: CGRect) -> UIBezierPath { - let bubbleRect: CGRect - switch arrowPlacement { - case .bottom: - bubbleRect = CGRect( - x: rect.minX, - y: rect.minY, - width: rect.width, - height: rect.height - arrowSize.height - ) - case .top: - bubbleRect = CGRect( - x: rect.minX, - y: rect.minY + arrowSize.height, - width: rect.width, - height: rect.height - arrowSize.height - ) - } - let arrowMidX = min( - max(arrowTipX ?? bubbleRect.midX, minimumArrowX), - bubbleRect.width - minimumArrowX - ) - let arrowHalfWidth = arrowSize.width / 2 - - let path = UIBezierPath() - switch arrowPlacement { - case .bottom: - path.move(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY)) - path.addLine(to: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY)) - path.addArc( - withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY + cornerRadius), - radius: cornerRadius, - startAngle: -.pi / 2, - endAngle: 0, - clockwise: true - ) - path.addLine(to: CGPoint(x: bubbleRect.maxX, y: bubbleRect.maxY - cornerRadius)) - path.addArc( - withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.maxY - cornerRadius), - radius: cornerRadius, - startAngle: 0, - endAngle: .pi / 2, - clockwise: true - ) - path.addLine(to: CGPoint(x: arrowMidX + arrowHalfWidth, y: bubbleRect.maxY)) - path.addLine(to: CGPoint(x: arrowMidX, y: bubbleRect.maxY + arrowSize.height)) - path.addLine(to: CGPoint(x: arrowMidX - arrowHalfWidth, y: bubbleRect.maxY)) - path.addLine(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY)) - path.addArc( - withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY - cornerRadius), - radius: cornerRadius, - startAngle: .pi / 2, - endAngle: .pi, - clockwise: true - ) - path.addLine(to: CGPoint(x: bubbleRect.minX, y: bubbleRect.minY + cornerRadius)) - path.addArc( - withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY + cornerRadius), - radius: cornerRadius, - startAngle: .pi, - endAngle: -.pi / 2, - clockwise: true - ) - case .top: - path.move(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY)) - path.addLine(to: CGPoint(x: arrowMidX - arrowHalfWidth, y: bubbleRect.minY)) - path.addLine(to: CGPoint(x: arrowMidX, y: bubbleRect.minY - arrowSize.height)) - path.addLine(to: CGPoint(x: arrowMidX + arrowHalfWidth, y: bubbleRect.minY)) - path.addLine(to: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY)) - path.addArc( - withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY + cornerRadius), - radius: cornerRadius, - startAngle: -.pi / 2, - endAngle: 0, - clockwise: true - ) - path.addLine(to: CGPoint(x: bubbleRect.maxX, y: bubbleRect.maxY - cornerRadius)) - path.addArc( - withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.maxY - cornerRadius), - radius: cornerRadius, - startAngle: 0, - endAngle: .pi / 2, - clockwise: true - ) - path.addLine(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY)) - path.addArc( - withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY - cornerRadius), - radius: cornerRadius, - startAngle: .pi / 2, - endAngle: .pi, - clockwise: true - ) - path.addLine(to: CGPoint(x: bubbleRect.minX, y: bubbleRect.minY + cornerRadius)) - path.addArc( - withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY + cornerRadius), - radius: cornerRadius, - startAngle: .pi, - endAngle: -.pi / 2, - clockwise: true - ) - } - path.close() - return path - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBImageViewController.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBImageViewController.swift deleted file mode 100644 index bb6ace2..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBImageViewController.swift +++ /dev/null @@ -1,241 +0,0 @@ -import UIKit - -final class RDEPUBImageViewController: UIViewController { - - struct Configuration { - let image: UIImage - let sourceRect: CGRect? - let altText: String? - let theme: RDEPUBReaderTheme - - init(image: UIImage, sourceRect: CGRect? = nil, altText: String? = nil, theme: RDEPUBReaderTheme) { - self.image = image - self.sourceRect = sourceRect - self.altText = altText - self.theme = theme - } - } - - // MARK: - Properties - - private let configuration: Configuration - private let scrollView = UIScrollView() - private let imageView = UIImageView() - private let closeButton = UIButton(type: .system) - private let backgroundView = UIView() - - private var isShowingChrome = true - - // MARK: - Init - - init(configuration: Configuration) { - self.configuration = configuration - super.init(nibName: nil, bundle: nil) - modalPresentationStyle = .fullScreen - modalTransitionStyle = .crossDissolve - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - Lifecycle - - override func viewDidLoad() { - super.viewDidLoad() - setupBackground() - setupScrollView() - setupImageView() - setupCloseButton() - } - - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - centerImage() - } - - override var prefersStatusBarHidden: Bool { true } - - // MARK: - Setup - - private func setupBackground() { - backgroundView.translatesAutoresizingMaskIntoConstraints = false - backgroundView.backgroundColor = configuration.theme.imageViewerBackgroundColor - view.addSubview(backgroundView) - NSLayoutConstraint.activate([ - backgroundView.topAnchor.constraint(equalTo: view.topAnchor), - backgroundView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - backgroundView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - backgroundView.trailingAnchor.constraint(equalTo: view.trailingAnchor) - ]) - - let tap = UITapGestureRecognizer(target: self, action: #selector(dismissViewer)) - backgroundView.addGestureRecognizer(tap) - } - - private func setupScrollView() { - scrollView.translatesAutoresizingMaskIntoConstraints = false - scrollView.delegate = self - scrollView.minimumZoomScale = 1.0 - scrollView.maximumZoomScale = 5.0 - scrollView.showsVerticalScrollIndicator = false - scrollView.showsHorizontalScrollIndicator = false - scrollView.alwaysBounceVertical = false - scrollView.alwaysBounceHorizontal = false - scrollView.decelerationRate = .fast - view.addSubview(scrollView) - NSLayoutConstraint.activate([ - scrollView.topAnchor.constraint(equalTo: view.topAnchor), - scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor) - ]) - - let swipeDown = UISwipeGestureRecognizer(target: self, action: #selector(dismissViewer)) - swipeDown.direction = .down - scrollView.addGestureRecognizer(swipeDown) - } - - private func setupImageView() { - imageView.image = configuration.image - imageView.contentMode = .scaleAspectFit - imageView.isUserInteractionEnabled = true - imageView.accessibilityLabel = configuration.altText - imageView.isAccessibilityElement = configuration.altText != nil - scrollView.addSubview(imageView) - - let doubleTap = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap(_:))) - doubleTap.numberOfTapsRequired = 2 - imageView.addGestureRecognizer(doubleTap) - - let singleTap = UITapGestureRecognizer(target: self, action: #selector(toggleChrome)) - singleTap.numberOfTapsRequired = 1 - singleTap.require(toFail: doubleTap) - imageView.addGestureRecognizer(singleTap) - } - - private func setupCloseButton() { - let config = UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold) - closeButton.setImage(UIImage(systemName: "xmark", withConfiguration: config), for: .normal) - closeButton.tintColor = .white - closeButton.backgroundColor = UIColor(white: 0.3, alpha: 0.6) - closeButton.layer.cornerRadius = 16 - closeButton.clipsToBounds = true - closeButton.addTarget(self, action: #selector(dismissViewer), for: .touchUpInside) - closeButton.accessibilityLabel = NSLocalizedString("Close image viewer", comment: "Accessibility label for close button in image viewer") - - view.addSubview(closeButton) - closeButton.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - closeButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12), - closeButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -16), - closeButton.widthAnchor.constraint(equalToConstant: 32), - closeButton.heightAnchor.constraint(equalToConstant: 32) - ]) - } - - // MARK: - Layout - - private func centerImage() { - guard let image = imageView.image else { return } - let boundsSize = scrollView.bounds.size - guard boundsSize.width > 0, boundsSize.height > 0 else { return } - - let imageSize = image.size - guard imageSize.width > 0, imageSize.height > 0 else { return } - - let widthRatio = boundsSize.width / imageSize.width - let heightRatio = boundsSize.height / imageSize.height - let fitScale = min(widthRatio, heightRatio) - - let fitWidth = imageSize.width * fitScale - let fitHeight = imageSize.height * fitScale - - imageView.frame = CGRect( - x: 0, - y: 0, - width: fitWidth, - height: fitHeight - ) - - scrollView.contentSize = imageView.frame.size - - let horizontalInset = max(0, (boundsSize.width - fitWidth) / 2) - let verticalInset = max(0, (boundsSize.height - fitHeight) / 2) - scrollView.contentInset = UIEdgeInsets( - top: verticalInset, - left: horizontalInset, - bottom: verticalInset, - right: horizontalInset - ) - } - - // MARK: - Actions - - @objc private func handleDoubleTap(_ gesture: UITapGestureRecognizer) { - if scrollView.zoomScale > scrollView.minimumZoomScale { - scrollView.setZoomScale(scrollView.minimumZoomScale, animated: true) - } else { - let point = gesture.location(in: imageView) - let zoomSize = CGSize(width: 100, height: 100) - let zoomRect = CGRect( - x: point.x - zoomSize.width / 2, - y: point.y - zoomSize.height / 2, - width: zoomSize.width, - height: zoomSize.height - ) - scrollView.zoom(to: zoomRect, animated: true) - } - } - - @objc private func toggleChrome() { - isShowingChrome.toggle() - UIView.animate(withDuration: 0.25) { - self.closeButton.alpha = self.isShowingChrome ? 1.0 : 0.0 - } - } - - @objc private func dismissViewer() { - dismiss(animated: true) - } -} - -// MARK: - UIScrollViewDelegate - -extension RDEPUBImageViewController: UIScrollViewDelegate { - - func viewForZooming(in scrollView: UIScrollView) -> UIView? { - imageView - } - - func scrollViewDidZoom(_ scrollView: UIScrollView) { - guard let image = imageView.image else { return } - let boundsSize = scrollView.bounds.size - let imageSize = image.size - guard imageSize.width > 0, imageSize.height > 0 else { return } - - let widthRatio = boundsSize.width / imageSize.width - let heightRatio = boundsSize.height / imageSize.height - let fitScale = min(widthRatio, heightRatio) - - let fitWidth = imageSize.width * fitScale * scrollView.zoomScale - let fitHeight = imageSize.height * fitScale * scrollView.zoomScale - - imageView.frame = CGRect( - x: 0, - y: 0, - width: fitWidth, - height: fitHeight - ) - - let horizontalInset = max(0, (boundsSize.width - fitWidth) / 2) - let verticalInset = max(0, (boundsSize.height - fitHeight) / 2) - scrollView.contentInset = UIEdgeInsets( - top: verticalInset, - left: horizontalInset, - bottom: verticalInset, - right: horizontalInset - ) - } -} \ No newline at end of file diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBImageViewerCoordinator.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBImageViewerCoordinator.swift deleted file mode 100644 index d0dcf09..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBImageViewerCoordinator.swift +++ /dev/null @@ -1,61 +0,0 @@ -import UIKit - -final class RDEPUBImageViewerCoordinator { - - private weak var presentingController: UIViewController? - private let theme: RDEPUBReaderTheme - - init(presentingController: UIViewController, theme: RDEPUBReaderTheme) { - self.presentingController = presentingController - self.theme = theme - } - - // MARK: - From Native Text Path (UIImage already resolved) - - func presentImage(_ image: UIImage, sourceRect: CGRect? = nil, altText: String? = nil) { - let config = RDEPUBImageViewController.Configuration( - image: image, - sourceRect: sourceRect, - altText: altText, - theme: theme - ) - let viewerVC = RDEPUBImageViewController(configuration: config) - presentingController?.present(viewerVC, animated: true) - } - - // MARK: - From WebView Path (need to resolve from src URL) - - func presentImageFromWebView( - src: String, - baseHref: String?, - resourceResolver: RDEPUBResourceResolver - ) { - guard let image = loadImage(src: src, baseHref: baseHref, resourceResolver: resourceResolver) else { - return - } - presentImage(image) - } - - // MARK: - Private - - private func loadImage( - src: String, - baseHref: String?, - resourceResolver: RDEPUBResourceResolver - ) -> UIImage? { - let pathPart = src.components(separatedBy: "#").first ?? src - guard !pathPart.isEmpty else { return nil } - - let fileURL: URL? - if let baseHref { - fileURL = resourceResolver.fileURL(forReference: pathPart, relativeToHref: baseHref) - } else { - let normalized = resourceResolver.normalizedHref(pathPart) - fileURL = normalized.flatMap { resourceResolver.fileURL(forRelativePath: $0) } - } - - // 经统一资源入口读取:加密图片由 provider 解密,明文图片直读磁盘 - guard let fileURL, let data = resourceResolver.resourceData(at: fileURL) else { return nil } - return UIImage(data: data) - } -} \ No newline at end of file diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderBottomToolView.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderBottomToolView.swift deleted file mode 100644 index c33ebf6..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderBottomToolView.swift +++ /dev/null @@ -1,146 +0,0 @@ -import UIKit - -final class RDEPUBReaderBottomToolView: RDEPUBReaderToolView, RDEPUBReaderBottomToolViewProtocol { - - var onShowTableOfContents: (() -> Void)? - - var onShowBookmarks: (() -> Void)? - - var onShowHighlights: (() -> Void)? - - var onAddHighlight: (() -> Void)? - - var onShowSettings: (() -> Void)? - - private let stackView: UIStackView = { - let view = UIStackView() - view.axis = .horizontal - view.distribution = .fillEqually - view.alignment = .fill - view.spacing = 16 - return view - }() - - private let chapterButton = RDEPUBReaderTintButton(type: .system) - - private let bookmarksButton = RDEPUBReaderTintButton(type: .system) - - private let highlightsButton = RDEPUBReaderTintButton(type: .system) - - private let addHighlightButton = RDEPUBReaderTintButton(type: .system) - - private let settingsButton = RDEPUBReaderTintButton(type: .system) - - override init(frame: CGRect) { - super.init(frame: frame) - accessibilityIdentifier = "epub.reader.bottomToolbar" - - addSubview(stackView) - stackView.translatesAutoresizingMaskIntoConstraints = false - stackView.addArrangedSubview(chapterButton) - stackView.addArrangedSubview(bookmarksButton) - stackView.addArrangedSubview(highlightsButton) - stackView.addArrangedSubview(addHighlightButton) - stackView.addArrangedSubview(settingsButton) - - NSLayoutConstraint.activate([ - stackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), - stackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16), - stackView.topAnchor.constraint(equalTo: topAnchor), - stackView.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor) - ]) - - configureButton(chapterButton, systemName: "list.bullet", fallbackTitle: "目录") - configureButton(bookmarksButton, systemName: "bookmark", fallbackTitle: "书签") - configureButton(highlightsButton, systemName: "note.text", fallbackTitle: "批注") - configureButton(addHighlightButton, systemName: "highlighter", fallbackTitle: "标注") - configureButton(settingsButton, systemName: "textformat.size", fallbackTitle: "设置") - chapterButton.accessibilityIdentifier = "epub.reader.toc" - bookmarksButton.accessibilityIdentifier = "epub.reader.bookmarks" - highlightsButton.accessibilityIdentifier = "epub.reader.highlights" - addHighlightButton.accessibilityIdentifier = "epub.reader.add-highlight" - settingsButton.accessibilityIdentifier = "epub.reader.settings" - - [chapterButton, bookmarksButton, highlightsButton, addHighlightButton, settingsButton].forEach { button in - button.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).isActive = true - } - - chapterButton.addTarget(self, action: #selector(chapterAction), for: .touchUpInside) - bookmarksButton.addTarget(self, action: #selector(bookmarksAction), for: .touchUpInside) - highlightsButton.addTarget(self, action: #selector(highlightsAction), for: .touchUpInside) - addHighlightButton.addTarget(self, action: #selector(highlightAction), for: .touchUpInside) - settingsButton.addTarget(self, action: #selector(settingsAction), for: .touchUpInside) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func lineFrame(in bounds: CGRect) -> CGRect { - CGRect(x: 0, y: 0, width: bounds.width, height: 0.5) - } - - override func apply(theme: RDEPUBReaderTheme) { - super.apply(theme: theme) - [chapterButton, bookmarksButton, highlightsButton, addHighlightButton, settingsButton].forEach { button in - button.tintColor = .black - button.setTitleColor(.black, for: .normal) - } - } - - func setAddHighlightEnabled(_ isEnabled: Bool) { - addHighlightButton.isEnabled = isEnabled - addHighlightButton.alpha = isEnabled ? 1 : 0.45 - } - - func setHighlightsEnabled(_ isEnabled: Bool) { - highlightsButton.isEnabled = isEnabled - highlightsButton.alpha = isEnabled ? 1 : 0.45 - } - - func setBookmarksEnabled(_ isEnabled: Bool) { - bookmarksButton.isEnabled = isEnabled - bookmarksButton.alpha = isEnabled ? 1 : 0.45 - } - - func updateVisibility( - showsTableOfContents: Bool, - allowsHighlights: Bool, - showsSettingsPanel: Bool - ) { - chapterButton.isHidden = !showsTableOfContents - bookmarksButton.isHidden = false - highlightsButton.isHidden = !allowsHighlights - addHighlightButton.isHidden = !allowsHighlights - settingsButton.isHidden = !showsSettingsPanel - } - - private func configureButton(_ button: UIButton, systemName: String, fallbackTitle: String) { - button.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .medium) - if #available(iOS 13.0, *), let image = UIImage(systemName: systemName) { - button.setImage(image.withRenderingMode(.alwaysTemplate), for: .normal) - } else { - button.setTitle(fallbackTitle, for: .normal) - } - } - - @objc private func chapterAction() { - onShowTableOfContents?() - } - - @objc private func bookmarksAction() { - onShowBookmarks?() - } - - @objc private func highlightsAction() { - onShowHighlights?() - } - - @objc private func highlightAction() { - onAddHighlight?() - } - - @objc private func settingsAction() { - onShowSettings?() - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderChapterListController.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderChapterListController.swift deleted file mode 100644 index 184128a..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderChapterListController.swift +++ /dev/null @@ -1,66 +0,0 @@ -import UIKit - -final class RDEPUBReaderChapterListController: UITableViewController { - - var onSelectItem: ((RDEPUBReaderTableOfContentsItem) -> Void)? - - private let items: [RDEPUBReaderTableOfContentsItem] - - private let currentItem: RDEPUBReaderTableOfContentsItem? - - private let theme: RDEPUBReaderTheme - - init( - items: [RDEPUBReaderTableOfContentsItem], - currentItem: RDEPUBReaderTableOfContentsItem?, - theme: RDEPUBReaderTheme - ) { - self.items = items - self.currentItem = currentItem - self.theme = theme - super.init(style: .plain) - title = "目录" - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - view.accessibilityIdentifier = "epub.reader.toc.panel" - tableView.accessibilityIdentifier = "epub.reader.toc.table" - tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") - tableView.tableFooterView = UIView(frame: .zero) - tableView.backgroundColor = theme.contentBackgroundColor - navigationController?.navigationBar.tintColor = theme.toolControlTextColor - navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor - navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor] - navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.toc.navbar" - } - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - items.count - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let item = items[indexPath.row] - let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) - cell.backgroundColor = theme.contentBackgroundColor - cell.textLabel?.numberOfLines = 2 - cell.textLabel?.text = item.title - cell.textLabel?.textColor = isCurrentItem(item) ? .systemBlue : theme.contentTextColor - cell.indentationLevel = item.depth - cell.indentationWidth = 18 - return cell - } - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - onSelectItem?(items[indexPath.row]) - } - - private func isCurrentItem(_ item: RDEPUBReaderTableOfContentsItem) -> Bool { - guard let currentItem else { return false } - return currentItem.href == item.href - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+AttachmentTooltip.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+AttachmentTooltip.swift deleted file mode 100644 index 8ba839b..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+AttachmentTooltip.swift +++ /dev/null @@ -1,79 +0,0 @@ - -import UIKit - -extension RDEPUBReaderController { - - func presentAttachmentTooltip(text: String, sourceView: UIView, sourceRect: CGRect, sourcePoint: CGPoint) { - hideAttachmentTooltipIfNeeded() - - let overlay = RDEPUBAttachmentTooltipOverlayView(frame: view.bounds) - overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight] - overlay.onBackgroundTap = { [weak self, weak overlay] in - guard let self, let overlay else { return } - self.dismissAttachmentTooltipOverlay(overlay) - } - - let tooltip = RDEPUBAttachmentTooltipView() - tooltip.alpha = 0 - let horizontalPadding = max(view.safeAreaInsets.left, view.safeAreaInsets.right) + 12 - tooltip.configure(text: text, maxWidth: min(view.bounds.width - horizontalPadding * 2, 320)) - - let anchorRect = sourceView.convert(sourceRect, to: view) - let rawAnchorPoint = sourceView.convert(sourcePoint, to: view) - let anchorPoint = CGPoint( - x: min(max(rawAnchorPoint.x, anchorRect.minX), anchorRect.maxX), - y: min(max(rawAnchorPoint.y, anchorRect.minY), anchorRect.maxY) - ) - let verticalSpacing: CGFloat = 6 - let tooltipSize = tooltip.frame.size - let idealX = anchorPoint.x - tooltipSize.width / 2 - let minX = horizontalPadding - let maxX = max(minX, view.bounds.width - horizontalPadding - tooltipSize.width) - let originX = min(max(idealX, minX), maxX) - let topSafeY = view.safeAreaInsets.top + 12 - let bottomSafeY = view.bounds.height - view.safeAreaInsets.bottom - 12 - let availableSpaceAbove = anchorRect.minY - topSafeY - let availableSpaceBelow = bottomSafeY - anchorRect.maxY - let prefersAbove = availableSpaceAbove >= tooltipSize.height + verticalSpacing || availableSpaceAbove >= availableSpaceBelow - let tooltipPlacement: RDEPUBAttachmentTooltipView.ArrowPlacement = prefersAbove ? .bottom : .top - let originY: CGFloat - switch tooltipPlacement { - case .bottom: - originY = max(topSafeY, anchorRect.minY - tooltipSize.height - verticalSpacing) - case .top: - originY = min(bottomSafeY - tooltipSize.height, anchorRect.maxY + verticalSpacing) - } - let arrowTipX = min( - max(anchorPoint.x - originX, tooltip.minimumArrowX), - tooltipSize.width - tooltip.minimumArrowX - ) - - tooltip.setArrowTipX(arrowTipX, placement: tooltipPlacement) - tooltip.frame.origin = CGPoint(x: originX, y: originY) - overlay.addSubview(tooltip) - view.addSubview(overlay) - - UIView.animate(withDuration: 0.2) { - tooltip.alpha = 1 - } - - DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) { [weak self, weak overlay] in - guard let self, let overlay else { return } - self.dismissAttachmentTooltipOverlay(overlay) - } - } - - private func hideAttachmentTooltipIfNeeded() { - view.subviews - .compactMap { $0 as? RDEPUBAttachmentTooltipOverlayView } - .forEach { $0.removeFromSuperview() } - } - - private func dismissAttachmentTooltipOverlay(_ overlay: RDEPUBAttachmentTooltipOverlayView) { - UIView.animate(withDuration: 0.18, animations: { - overlay.tooltipView?.alpha = 0 - }, completion: { _ in - overlay.removeFromSuperview() - }) - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift deleted file mode 100644 index 54bb779..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift +++ /dev/null @@ -1,185 +0,0 @@ - -import UIKit - -extension RDEPUBReaderController: RDEPUBWebContentViewDelegate { - - func epubWebContentView(_ contentView: RDEPUBWebContentView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int) { - guard readerView.currentPage >= 0, - activePages.indices.contains(readerView.currentPage) else { - return - } - - guard readerView.pageContentView(pageNum: readerView.currentPage) === contentView else { - return - } - - let currentPage = activePages[readerView.currentPage] - guard readingSession?.pageContains(spineIndex: spineIndex, in: currentPage) == true else { - return - } - - persist(location: location) - readingSession?.updateReadingContext( - pageNumber: readerView.currentPage + 1, - location: location, - spineIndex: spineIndex, - chapterIndex: currentPage.chapterIndex, - bookIdentifier: currentBookIdentifier - ) - } - - func epubWebContentView(_ contentView: RDEPUBWebContentView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int) { - if let selection { - updateCurrentSelection(scopedSelection(selection, relativeToSpineIndex: spineIndex)) - } else { - updateCurrentSelection(nil) - } - } - - func epubWebContentView(_ contentView: RDEPUBWebContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) { - handleSelectionMenuAction(action, selection: currentSelection) - } - - func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int) { - if presentNotePopupIfPossible(for: location, fromSpineIndex: fromSpineIndex) { - return - } - - guard let readingSession, - let pageNumber = readingSession.queueNavigation( - to: location, - relativeToSpineIndex: fromSpineIndex, - bookIdentifier: currentBookIdentifier - ) else { - return - } - readerView.transitionToPage(pageNum: max(pageNumber - 1, 0), animated: true) - } - - func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateExternalLink url: URL) { - delegate?.epubReader(self, didActivateExternalLink: url) - openExternalURLIfAllowed(url) - } - - func epubWebContentView(_ contentView: RDEPUBWebContentView, didLogJavaScriptError message: String) { - #if DEBUG - print("EPUB JS Error: \(message)") - #endif - } - - func epubWebContentView(_ contentView: RDEPUBWebContentView, didTapImageWithSource src: String, sourceRect: CGRect?) { - guard let publication else { return } - let baseHref = contentView.currentHref - let coordinator = RDEPUBImageViewerCoordinator(presentingController: self, theme: configuration.theme) - coordinator.presentImageFromWebView(src: src, baseHref: baseHref, resourceResolver: publication.resourceResolver) - } - - func epubWebContentView(_ contentView: RDEPUBWebContentView, didTapFootnoteWithAltText altText: String, sourceRect: CGRect?) { - guard !altText.isEmpty else { return } - let rect = sourceRect ?? .zero - let sourcePoint = rect.isNull ? CGPoint(x: contentView.bounds.midX, y: contentView.bounds.midY) : CGPoint(x: rect.midX, y: rect.midY) - presentAttachmentTooltip(text: altText, sourceView: contentView, sourceRect: rect, sourcePoint: sourcePoint) - } -} - -extension RDEPUBReaderController: RDEPUBTextContentViewDelegate { - - func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?) { - guard let selection else { - updateCurrentSelection(nil) - return - } - updateCurrentSelection(selection) - } - - func textContentView(_ contentView: RDEPUBTextContentView, didRequestReaderTapAt point: CGPoint) { - RDEpubReaderTapDebug.log( - "ReaderController.textContentTap", - "delegate received reader tap from contentView=\(RDEpubReaderTapDebug.describe(contentView)) point=\(RDEpubReaderTapDebug.describe(point)) currentPage=\(readerView.currentPage)" - ) - readerView.handleContentTap(at: point, in: contentView) - } - - func textContentView( - _ contentView: RDEPUBTextContentView, - didRequestSelectionAction action: RDEPUBAnnotationMenuAction, - selection: RDEPUBSelection? - ) { - handleSelectionMenuAction(action, selection: selection ?? currentSelection) - contentView.clearSelection() - } - - func textContentView( - _ contentView: RDEPUBTextContentView, - didActivateAttachmentText text: String, - sourceRect: CGRect, - sourcePoint: CGPoint - ) { - presentAttachmentTooltip(text: text, sourceView: contentView, sourceRect: sourceRect, sourcePoint: sourcePoint) - } - - func textContentView( - _ contentView: RDEPUBTextContentView, - didActivateImage image: UIImage, - sourceRect: CGRect, - altText: String? - ) { - let coordinator = RDEPUBImageViewerCoordinator(presentingController: self, theme: configuration.theme) - coordinator.presentImage(image, sourceRect: sourceRect, altText: altText) - } - - func textContentView( - _ contentView: RDEPUBTextContentView, - didRequestHighlightActions highlight: RDEPUBHighlight, - sourceRect: CGRect - ) { - runtime.presentHighlightActions(for: highlight, sourceView: contentView, sourceRect: sourceRect) - } - - private func presentNotePopupIfPossible(for location: RDEPUBLocation, fromSpineIndex: Int) -> Bool { - guard let publication else { return false } - let sourceHref = publication.resourceResolver.href(forSpineIndex: fromSpineIndex) ?? location.href - let sourceLocation = currentVisibleLocation() - let sourceCFI = sourceLocation?.cfi - let resolver = RDEPUBNoteResolver(resourceResolver: publication.resourceResolver) - guard let note = resolver.resolveInternalLink( - sourceHref: sourceHref, - sourceCFI: sourceCFI, - targetLocation: location, - sourceLocation: sourceLocation, - relativeToSpineIndex: fromSpineIndex - ) else { - return false - } - - RDEPUBNotePopupCoordinator.present( - note, - from: self, - onReturnToSource: { [weak self] in - guard let self, let sourceLocation = note.sourceLocation else { return } - self.navigateToLocation(sourceLocation, relativeToSpineIndex: nil, animated: true) - }, - onOpenNoteLocation: { [weak self] in - guard let self else { return } - self.navigateToLocation(note.targetLocation, relativeToSpineIndex: nil, animated: true) - } - ) - return true - } - - private func navigateToLocation( - _ location: RDEPUBLocation, - relativeToSpineIndex spineIndex: Int?, - animated: Bool - ) { - guard let pageNumber = readingSession?.queueNavigation( - to: location, - relativeToSpineIndex: spineIndex, - bookIdentifier: currentBookIdentifier - ) else { - return - } - readerView.transitionToPage(pageNum: max(pageNumber - 1, 0), animated: animated) - } - -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+DataSource.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+DataSource.swift deleted file mode 100644 index 862b259..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+DataSource.swift +++ /dev/null @@ -1,464 +0,0 @@ - -import UIKit - -extension RDEPUBReaderController: RDEpubReaderDataSource, RDEpubReaderPageProvider, RDEpubReaderDelegate { - - public func numberOfPages(in readerView: RDEpubReaderView) -> Int { - effectiveNumberOfPages - } - - public func readerView(_ readerView: RDEpubReaderView, viewForPageAt index: Int, reusableView: UIView?) -> UIView { - pageContentView(readerView: readerView, pageNum: index, containerView: reusableView) - } - - public func pageIdentifier(in readerView: RDEpubReaderView, index: Int) -> String? { - pageIdentifier(readerView: readerView, pageNum: index) - } - - public func readerView( - _ readerView: RDEpubReaderView, - shouldPreloadPageAt index: Int - ) -> Bool { - // 试读墙由宿主提供唯一 UIView,不允许预加载创建另一容器并抢走它。 - index != trialWallPageIndex - } - - public func readerViewTopChrome(_ readerView: RDEpubReaderView) -> UIView? { - topToolView(readerView: readerView) - } - - public func readerViewBottomChrome(_ readerView: RDEpubReaderView) -> UIView? { - bottomToolView(readerView: readerView) - } - - public func pageCountOfReaderView(readerView: RDEpubReaderView) -> Int { - readerContext.bookPageMap?.totalPages ?? textBook?.pages.count ?? activePages.count - } - - public func pageContentView(readerView: RDEpubReaderView, pageNum: Int, containerView: UIView?) -> UIView { - // 试读墙页:位于最后一个可读页之后,展示宿主提供的墙视图 - if let wallIndex = trialWallPageIndex, pageNum == wallIndex { - return makeTrialWallPageView(reusableView: containerView) - } - - if readerContext.bookPageMap != nil { - _ = runtime.prepareOnDemandChapter( - forAbsolutePageNumber: pageNum + 1, - allowSynchronousLoad: false - ) - var resolvedPage = runtime.pageResolver.resolvePage(absolutePageIndex: pageNum) - let shouldAllowSynchronousFallback = ( - readerView.currentPage < 0 || readerView.currentPage == pageNum - ) && !readerView.isPageCurlTransitioning - if resolvedPage == nil, shouldAllowSynchronousFallback { - _ = runtime.prepareOnDemandChapter( - forAbsolutePageNumber: pageNum + 1, - allowSynchronousLoad: true - ) - resolvedPage = runtime.pageResolver.resolvePage(absolutePageIndex: pageNum) - } - if let resolvedPage { - let contentView = (containerView as? RDEPUBTextContentView) ?? RDEPUBTextContentView() - contentView.delegate = self - readerView.registerSelectionGestureDependenciesIfNeeded(for: contentView) - contentView.selectionTapSuppressionDidChange = { [weak readerView, weak contentView] isSuppressed in - guard let readerView, let contentView else { return } - readerView.updateSelectionTapSuppression(for: contentView, isSuppressed: isSuppressed) - } - contentView.selectionPagingSuppressionDidChange = { [weak readerView, weak contentView] isSuppressed in - guard let readerView, let contentView else { return } - readerView.updateSelectionPagingSuppression(for: contentView, isSuppressed: isSuppressed) - } - contentView.configure( - page: resolvedPage.page, - pageNumber: pageNum + 1, - totalPages: pageCountOfReaderView(readerView: readerView), - configuration: configuration, - chapterCFIMap: resolvedPage.chapter.chapterOffsetMap.cfiMap, - chapterFragmentOffsets: resolvedPage.chapter.chapterOffsetMap.fragmentOffsets, - highlights: textHighlights(for: resolvedPage.page), - searchState: searchState(for: resolvedPage.page), - displayCache: textDisplayCache - ) - return contentView - } - - let contentView = (containerView as? RDEPUBTextContentView) ?? RDEPUBTextContentView() - contentView.delegate = self - readerView.registerSelectionGestureDependenciesIfNeeded(for: contentView) - contentView.selectionTapSuppressionDidChange = { [weak readerView, weak contentView] isSuppressed in - guard let readerView, let contentView else { return } - readerView.updateSelectionTapSuppression(for: contentView, isSuppressed: isSuppressed) - } - contentView.selectionPagingSuppressionDidChange = { [weak readerView, weak contentView] isSuppressed in - guard let readerView, let contentView else { return } - readerView.updateSelectionPagingSuppression(for: contentView, isSuppressed: isSuppressed) - } - contentView.configureLoading( - pageNumber: pageNum + 1, - totalPages: pageCountOfReaderView(readerView: readerView), - configuration: configuration - ) - return contentView - } - - if let textBook, let page = textBook.page(at: pageNum + 1) { - let contentView = (containerView as? RDEPUBTextContentView) ?? RDEPUBTextContentView() - contentView.delegate = self - readerView.registerSelectionGestureDependenciesIfNeeded(for: contentView) - contentView.selectionTapSuppressionDidChange = { [weak readerView, weak contentView] isSuppressed in - guard let readerView, let contentView else { return } - readerView.updateSelectionTapSuppression(for: contentView, isSuppressed: isSuppressed) - } - contentView.selectionPagingSuppressionDidChange = { [weak readerView, weak contentView] isSuppressed in - guard let readerView, let contentView else { return } - readerView.updateSelectionPagingSuppression(for: contentView, isSuppressed: isSuppressed) - } - contentView.configure( - page: page, - pageNumber: pageNum + 1, - totalPages: textBook.pages.count, - configuration: configuration, - chapterCFIMap: textBook.chapterData(for: page.href)?.chapter.cfiMap, - chapterFragmentOffsets: textBook.chapterData(for: page.href)?.chapter.fragmentOffsets ?? [:], - highlights: textHighlights(for: page), - searchState: searchState(for: page), - displayCache: textDisplayCache - ) - return contentView - } - - guard let publication, - let request = request(for: pageNum) else { - return containerView ?? UIView() - } - - let contentView = (containerView as? RDEPUBWebContentView) ?? RDEPUBWebContentView() - contentView.delegate = self - contentView.configure( - publication: publication, - request: request, - pageNumber: pageNum + 1, - totalPages: activePages.count, - theme: configuration.theme - ) - return contentView - } - - public func pageIdentifier(readerView: RDEpubReaderView, pageNum: Int) -> String? { - if let wallIndex = trialWallPageIndex, pageNum == wallIndex { - return NSStringFromClass(RDEPUBTrialWallContainerView.self) - } - return (textBook == nil && readerContext.bookPageMap == nil) - ? NSStringFromClass(RDEPUBWebContentView.self) - : NSStringFromClass(RDEPUBTextContentView.self) - } - - private func textHighlights(for page: RDEPUBTextPage) -> [RDEPUBHighlight] { - if let textBook, - let chapterData = textBook.chapterData(for: page.href) { - return chapterData.highlights(on: page, from: activeHighlights) - } - - guard let publication else { - return activeHighlights.filter { $0.location.href == page.href } - } - let pageHref = publication.resourceResolver.normalizedHref(page.href) ?? page.href - return activeHighlights.filter { - (publication.resourceResolver.normalizedHref($0.location.href) ?? $0.location.href) == pageHref - } - } - - private func searchState(for page: RDEPUBTextPage) -> RDEPUBSearchState? { - guard let globalSearchState = searchState else { return nil } - - let matches: [RDEPUBSearchMatch] - let currentMatchIndex: Int? - - if let chapterData = chapterData(for: page), - let resolvedState = resolvedSearchState( - for: page, - chapterData: chapterData, - globalSearchState: globalSearchState - ) { - matches = resolvedState.matches - currentMatchIndex = resolvedState.currentMatchIndex - } else { - matches = globalSearchState.matches.filter { searchMatch in - searchMatchBelongsToPage(searchMatch, page: page) - } - currentMatchIndex = globalSearchState.currentMatch.flatMap { currentMatch in - matches.firstIndex(of: currentMatch) - } - } - guard !matches.isEmpty || globalSearchState.currentMatch != nil else { - return globalSearchState.matches.isEmpty ? globalSearchState : nil - } - return RDEPUBSearchState( - keyword: globalSearchState.keyword, - matches: matches, - currentMatchIndex: currentMatchIndex - ) - } - - private func resolvedSearchState( - for page: RDEPUBTextPage, - chapterData: RDEPUBChapterData, - globalSearchState: RDEPUBSearchState - ) -> RDEPUBSearchState? { - let normalizedKeyword = globalSearchState.keyword.trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalizedKeyword.isEmpty else { - return globalSearchState.matches.isEmpty ? globalSearchState : nil - } - - let normalizedHref = normalizedPageHref(for: page) - let exactMatches = exactChapterSearchMatches( - in: chapterData, - keyword: normalizedKeyword, - normalizedHref: normalizedHref - ) - let pageMatches = exactMatches.filter { match in - guard let rangeLocation = match.rangeLocation else { return false } - let range = NSRange(location: rangeLocation, length: max(match.rangeLength, 1)) - return NSIntersectionRange(range, page.contentRange).length > 0 - } - - let currentLocalMatchIndex = globalSearchState.currentMatch?.localMatchIndex - let currentMatchIndex = currentLocalMatchIndex.flatMap { localMatchIndex in - pageMatches.firstIndex(where: { $0.localMatchIndex == localMatchIndex }) - } - - guard !pageMatches.isEmpty || currentMatchIndex != nil else { - return globalSearchState.matches.isEmpty ? globalSearchState : nil - } - - return RDEPUBSearchState( - keyword: globalSearchState.keyword, - matches: pageMatches, - currentMatchIndex: currentMatchIndex - ) - } - - private func exactChapterSearchMatches( - in chapterData: RDEPUBChapterData, - keyword: String, - normalizedHref: String - ) -> [RDEPUBSearchMatch] { - let source = chapterData.attributedContent.string as NSString - let fullLength = source.length - guard fullLength > 0 else { return [] } - - var matches: [RDEPUBSearchMatch] = [] - var localMatchIndex = 0 - var searchRange = NSRange(location: 0, length: fullLength) - - while searchRange.length > 0 { - let foundRange = source.range(of: keyword, options: [.caseInsensitive], range: searchRange) - guard foundRange.location != NSNotFound else { break } - - let progressionDenominator = max(fullLength - 1, 1) - let progression = Double(foundRange.location) / Double(progressionDenominator) - matches.append( - RDEPUBSearchMatch( - href: normalizedHref, - progression: progression, - previewText: previewText(in: source, matchRange: foundRange), - localMatchIndex: localMatchIndex, - rangeLocation: foundRange.location, - rangeLength: max(foundRange.length, 1), - rangeAnchor: chapterData.rangeAnchor(for: foundRange) - ) - ) - - localMatchIndex += 1 - let nextLocation = foundRange.location + max(foundRange.length, 1) - if nextLocation >= fullLength { - break - } - searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation) - } - - return matches - } - - private func previewText(in text: NSString, matchRange: NSRange) -> String { - let previewRadius = 12 - let start = max(matchRange.location - previewRadius, 0) - let end = min(matchRange.location + matchRange.length + previewRadius, text.length) - let range = NSRange(location: start, length: max(end - start, 0)) - return text.substring(with: range).trimmingCharacters(in: .whitespacesAndNewlines) - } - - private func chapterData(for page: RDEPUBTextPage) -> RDEPUBChapterData? { - if let textBook, - let chapterData = textBook.chapterData(for: page.href) { - return chapterData - } - - guard let runtimeChapter = runtime.chapterRuntimeStore.chapterData(for: page.spineIndex) else { - return nil - } - return makeChapterData(from: runtimeChapter, chapterIndex: page.chapterIndex) - } - - private func makeChapterData( - from runtimeChapter: RDEPUBRuntimeChapter, - chapterIndex: Int - ) -> RDEPUBChapterData { - let textChapter = RDEPUBTextChapter( - chapterIndex: chapterIndex, - spineIndex: runtimeChapter.spineIndex, - href: runtimeChapter.href, - title: runtimeChapter.title, - attributedContent: runtimeChapter.typesetAttributedString, - fragmentOffsets: runtimeChapter.chapterOffsetMap.fragmentOffsets, - cfiMap: runtimeChapter.chapterOffsetMap.cfiMap, - pageBreakReasons: runtimeChapter.pages.map(\.metadata.breakReason), - pages: runtimeChapter.pages - ) - return RDEPUBChapterData( - chapter: textChapter, - indexTable: RDEPUBTextIndexTable(chapters: [textChapter]) - ) - } - - private func searchMatchBelongsToPage(_ searchMatch: RDEPUBSearchMatch, page: RDEPUBTextPage) -> Bool { - if let textBook, - let chapterData = textBook.chapterData(for: page.href), - let range = chapterData.absoluteRange(for: searchMatch) { - return NSIntersectionRange(range, page.contentRange).length > 0 - } - - let pageHref = normalizedPageHref(for: page) - let matchHref = normalizedSearchHref(searchMatch.href) - guard pageHref == matchHref else { return false } - - guard let rangeLocation = searchMatch.rangeLocation else { - return false - } - let range = NSRange(location: rangeLocation, length: searchMatch.rangeLength) - return NSIntersectionRange(range, page.contentRange).length > 0 - } - - private func normalizedPageHref(for page: RDEPUBTextPage) -> String { - guard let publication else { return page.href } - return publication.resourceResolver.normalizedHref(page.href) ?? page.href - } - - private func normalizedSearchHref(_ href: String) -> String { - guard let publication else { return href } - return publication.resourceResolver.normalizedHref(href) ?? href - } - - func textChapterData(forNormalizedHref href: String) -> RDEPUBChapterData? { - readerContext.textChapterData(forNormalizedHref: href) - } - - public func topToolView(readerView: RDEpubReaderView) -> UIView? { - topToolView - } - - public func bottomToolView(readerView: RDEpubReaderView) -> UIView? { - bottomToolView - } - - public func pageNum(readerView: RDEpubReaderView, pageNum: Int) { - RDEPUBMemoryProbe.logPageTurn() - readerContext.markUserNavigationActivity() - updateCurrentSelection(nil) - reconcileTextPaginationSizeIfNeeded(for: pageNum) - - guard !isRepaginating else { return } - - let previousCurrentPage = readerView.currentPage - let wasPageCurlTransitioning = readerView.isPageCurlTransitioning - runtime.applyPendingFullPageMapIfNeeded() - if wasPageCurlTransitioning { - DispatchQueue.main.async { [weak self, weak readerView] in - guard let self, let readerView, !readerView.isPageCurlTransitioning else { return } - self.runtime.applyPendingFullPageMapIfNeeded() - } - } - let effectivePageNum = readerView.currentPage >= 0 ? readerView.currentPage : pageNum - if previousCurrentPage != readerView.currentPage, effectivePageNum != pageNum { - return - } - - if readerContext.bookPageMap != nil { - _ = runtime.prepareOnDemandChapter( - forAbsolutePageNumber: effectivePageNum + 1, - allowSynchronousLoad: false - ) - runtime.extendPartialBookPageMapIfNeeded(currentPageNumber: effectivePageNum + 1) - } - - // 翻到试读墙页:触发回调,跳过内容定位(墙页不属于书籍内容,无 location/CFI) - if let wallIndex = trialWallPageIndex, effectivePageNum == wallIndex { - delegate?.epubReaderDidReachTrialWall(self) - return - } - - let totalPages = pageCountOfReaderView(readerView: readerView) - if totalPages > 0, effectivePageNum == totalPages - 1 { - delegate?.epubReaderDidReachEnd(self) - } - - if (textBook != nil || readerContext.bookPageMap != nil), - let location = resolvedTextLocation(forPageNumber: effectivePageNum + 1) { - persist(location: location) - synchronizeTextReadingState(pageNumber: effectivePageNum + 1, location: location) - - runtime.locationCoordinator.recordPageChangeIfNeeded() - return - } - - if let location = fallbackLocation(for: effectivePageNum) { - persist(location: location) - } - - runtime.locationCoordinator.recordPageChangeIfNeeded() - } - - public func readerViewOrientationWillChange(readerView: RDEpubReaderView, isLandscape: Bool) { - _ = isLandscape - runtime.viewportMonitor.capturePendingPresentationRestoreLocation() - } - - private func reconcileTextPaginationSizeIfNeeded(for pageNum: Int) { - guard textBook != nil || readerContext.bookPageMap != nil, - !isRepaginating, - !isReconcilingTextPaginationSize, - pageNum >= 0, - let lastTextPaginationPageSize else { - return - } - - let resolvedSize = readerView.resolvedSinglePageSize(pageNum: pageNum) - guard resolvedSize.width > 0, - resolvedSize.height > 0 else { - return - } - - let sizeChanged = abs(resolvedSize.width - lastTextPaginationPageSize.width) > 0.5 - || abs(resolvedSize.height - lastTextPaginationPageSize.height) > 0.5 - guard sizeChanged else { - return - } - - isReconcilingTextPaginationSize = true - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in - guard let self else { return } - self.isReconcilingTextPaginationSize = false - guard (self.textBook != nil || self.readerContext.bookPageMap != nil), !self.isRepaginating else { return } - - let currentSize = self.readerView.resolvedSinglePageSize(pageNum: self.readerView.currentPage) - guard currentSize.width > 0, currentSize.height > 0 else { return } - guard let previousPageSize = self.lastTextPaginationPageSize else { return } - let stillChanged = abs(currentSize.width - previousPageSize.width) > 0.5 - || abs(currentSize.height - previousPageSize.height) > 0.5 - guard stillChanged else { return } - self.repaginatePreservingCurrentLocation() - } - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+ExternalLinks.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+ExternalLinks.swift deleted file mode 100644 index bdebaf4..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+ExternalLinks.swift +++ /dev/null @@ -1,35 +0,0 @@ - -import UIKit - -extension RDEPUBReaderController { - - func openExternalURLIfAllowed(_ url: URL) { - guard shouldAllowExternalURL(url) else { return } - if configuration.requiresExternalLinkConfirmation { - presentExternalLinkConfirmation(for: url) - } else { - UIApplication.shared.open(url, options: [:], completionHandler: nil) - } - } - - private func shouldAllowExternalURL(_ url: URL) -> Bool { - guard let scheme = url.scheme?.lowercased() else { return false } - if delegate?.epubReader(self, shouldOpenExternalURL: url) == false { - return false - } - return configuration.allowedExternalURLSchemes.contains(scheme) - } - - private func presentExternalLinkConfirmation(for url: URL) { - let alert = UIAlertController( - title: "打开外部链接", - message: url.absoluteString, - preferredStyle: .alert - ) - alert.addAction(UIAlertAction(title: "取消", style: .cancel)) - alert.addAction(UIAlertAction(title: "打开", style: .default) { _ in - UIApplication.shared.open(url, options: [:], completionHandler: nil) - }) - present(alert, animated: true) - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+LocationResolution.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+LocationResolution.swift deleted file mode 100644 index 7ff047e..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+LocationResolution.swift +++ /dev/null @@ -1,319 +0,0 @@ - -import UIKit - -extension RDEPUBReaderController { - - func pageNumber(for location: RDEPUBLocation, rangeInfo: String? = nil) -> Int? { - if let publication, - let bookPageMap = readerContext.bookPageMap, - let spineIndex = readerContext.normalizedSpineIndex(for: location), - let entry = bookPageMap.entry(forSpineIndex: spineIndex) { - let normalizedLocation = publication.resourceResolver.normalizedLocation( - location, - relativeToSpineIndex: nil, - bookIdentifier: currentBookIdentifier - ) ?? location - let localPageIndex = resolvedLocalPageIndex( - for: normalizedLocation, - spineIndex: spineIndex, - fallbackEntry: entry, - rangeInfo: rangeInfo - ) ?? fallbackLocalPageIndex(for: normalizedLocation, pageCount: entry.pageCount) - return bookPageMap.absolutePageIndex( - spineIndex: spineIndex, - localPageIndex: min(max(localPageIndex, 0), max(entry.pageCount - 1, 0)) - ).map { $0 + 1 } - } - - if let textBook { - let normalizedLocation = publication?.resourceResolver.normalizedLocation( - location, - relativeToSpineIndex: nil, - bookIdentifier: currentBookIdentifier - ) ?? location - - if let pageNumber = pageNumberFromRangeInfo(rangeInfo, in: textBook, matching: normalizedLocation) { - return pageNumber - } - - if let anchor = normalizedLocation.rangeAnchor?.start { - if let page = textBook.indexTable.pageNumber(for: anchor, in: textBook) { - return page + 1 - } - } - - if let publication { - return textBook.pageNumber( - for: normalizedLocation, - resolver: publication.resourceResolver, - bookIdentifier: currentBookIdentifier - ) - } - // External text books have no resolver; hrefs match verbatim. - return textBook.chapterData(for: normalizedLocation.href)? - .pageNumber(for: normalizedLocation) - } - - return readingSession?.queueNavigation( - to: location, - relativeToSpineIndex: nil, - bookIdentifier: currentBookIdentifier - ) - } - - func resolvedTextLocation(forPageNumber pageNumber: Int) -> RDEPUBLocation? { - if let resolvedPage = resolvedRuntimePage(forPageNumber: pageNumber) { - let startOffset = resolvedPage.page.pageStartOffset - let endOffset = max(startOffset, resolvedPage.page.pageEndOffset) - let chapterData = makeRuntimeChapterData(from: resolvedPage) - let location = chapterData.location( - for: NSRange(location: startOffset, length: max(endOffset - startOffset + 1, 1)), - bookIdentifier: currentBookIdentifier - ) - if let publication { - return publication.resourceResolver.normalizedLocation( - location, - relativeToSpineIndex: nil, - bookIdentifier: currentBookIdentifier - ) ?? location - } - return location - } - - guard let textBook, - let page = textBook.page(at: pageNumber) else { - return nil - } - - let location = textBook.chapterData(forPageNumber: pageNumber)?.location(forPage: page, bookIdentifier: currentBookIdentifier) - ?? textBook.location(forPageNumber: pageNumber, bookIdentifier: currentBookIdentifier) - guard let location else { return nil } - - if let publication { - return publication.resourceResolver.normalizedLocation( - location, - relativeToSpineIndex: nil, - bookIdentifier: currentBookIdentifier - ) ?? location - } - return location - } - - func currentVisibleRangeInfo() -> String? { - guard let pageNumber = currentPageNumber else { - return nil - } - - if let resolvedPage = resolvedRuntimePage(forPageNumber: pageNumber) { - let startOffset = resolvedPage.page.pageStartOffset - let endOffset = max(startOffset + 1, resolvedPage.page.pageEndOffset + 1) - return RDEPUBTextOffsetRangeInfo( - href: resolvedPage.chapter.href, - start: startOffset, - end: endOffset - ).jsonString() - } - - guard let textBook, - let page = textBook.page(at: pageNumber) else { - return nil - } - - let startOffset = page.pageStartOffset - let endOffset = max(startOffset + 1, page.pageEndOffset + 1) - return RDEPUBTextOffsetRangeInfo( - href: page.href, - start: startOffset, - end: endOffset - ).jsonString() - } - - func synchronizeTextReadingState(pageNumber: Int, location: RDEPUBLocation) { - if let resolvedPage = resolvedRuntimePage(forPageNumber: pageNumber) { - readingSession?.updateReadingContext( - pageNumber: pageNumber, - location: location, - spineIndex: resolvedPage.page.spineIndex, - chapterIndex: resolvedPage.chapterIndex, - bookIdentifier: currentBookIdentifier - ) - return - } - - guard let textBook, - let page = textBook.page(at: pageNumber) else { - return - } - - readingSession?.updateReadingContext( - pageNumber: pageNumber, - location: location, - spineIndex: page.spineIndex, - chapterIndex: page.chapterIndex, - bookIdentifier: currentBookIdentifier - ) - } - - func nativeTextSnapshot(from textBook: RDEPUBTextBook) -> RDEPUBNativeTextSnapshot { - let chapters = textBook.chapterInfos - let pages = textBook.pages.map { - EPUBPage( - spineIndex: $0.spineIndex, - chapterIndex: $0.chapterIndex, - pageIndexInChapter: $0.pageIndexInChapter, - totalPagesInChapter: $0.totalPagesInChapter, - chapterTitle: $0.chapterTitle, - fixedSpread: nil - ) - } - return (pages, chapters) - } - - func chapterOffset(for location: RDEPUBLocation, fallbackEntry: RDEPUBBookPageMapEntry, rangeInfo: String? = nil) -> Int { - if let decodedRangeInfo = decodedRangeInfo(from: rangeInfo, matching: location) { - return decodedRangeInfo.start - } - // Native text anchors carry the exact UTF-16 chapter offset and remain - // stable across font/layout changes. Prefer them over CFI recovery, - // which may temporarily resolve to the chapter's first marker while a - // new CFI map is still being prepared. - if let anchor = location.rangeAnchor?.start { - return anchor.chapterOffset - } - if let spineIndex = readerContext.normalizedSpineIndex(for: location), - let runtimeChapter = runtime.chapterRuntimeStore.chapterData(for: spineIndex), - let cfi = primaryLocationCFI(for: location), - let offset = runtimeChapter.chapterOffsetMap.chapterOffset(forCFI: cfi) { - return offset - } - if let rawCFI = primaryLocationCFI(for: location), - let cfi = RDEPUBCFICompatibility.parseLossy(rawCFI), - let cfiOffset = RDEPUBCFIResolver.resolve(cfi).chapterOffset { - return cfiOffset - } - if let fragment = location.fragment, - let offset = fallbackEntry.fragmentOffsets[fragment] { - return offset - } - return 0 - } - - func fallbackLocalPageIndex(for location: RDEPUBLocation, pageCount: Int) -> Int { - guard pageCount > 1 else { return 0 } - return min( - pageCount - 1, - max(0, Int(round(location.navigationProgression * Double(pageCount - 1)))) - ) - } - - private func resolvedRuntimePage(forPageNumber pageNumber: Int) -> RDEPUBResolvedPage? { - runtime.pageResolver.resolvePage(absolutePageIndex: pageNumber - 1) - } - - private func resolvedLocalPageIndex( - for location: RDEPUBLocation, - spineIndex: Int, - fallbackEntry: RDEPUBBookPageMapEntry, - rangeInfo: String? = nil - ) -> Int? { - let offset = chapterOffset(for: location, fallbackEntry: fallbackEntry, rangeInfo: rangeInfo) - - if let runtimeChapter = runtime.chapterRuntimeStore.chapterData(for: spineIndex), - let pageIndex = runtimeChapter.chapterOffsetMap.pageIndex(forChapterOffset: offset) { - return pageIndex - } - - if let summary = readerContext.chapterSummary(forSpineIndex: spineIndex) { - return summary.pageRanges.firstIndex { - let range = $0.nsRange - return offset >= range.location && offset <= max(range.location + range.length - 1, range.location) - } - } - - return nil - } - - private func primaryLocationCFI(for location: RDEPUBLocation) -> String? { - if let rangeCFI = RDEPUBCFICompatibility.parseRangeLossy(location.rangeCFI) { - return rangeCFI.start.rawValue - } - return location.cfi - } - - private func pageNumberFromRangeInfo( - _ rangeInfo: String?, - in textBook: RDEPUBTextBook, - matching location: RDEPUBLocation - ) -> Int? { - guard let decodedRangeInfo = decodedRangeInfo(from: rangeInfo, matching: location), - let chapterData = chapterData(in: textBook, for: decodedRangeInfo), - let page = chapterData.page(containing: decodedRangeInfo.start) else { - return nil - } - return page.absolutePageIndex + 1 - } - - private func decodedRangeInfo( - from rangeInfo: String?, - matching location: RDEPUBLocation - ) -> RDEPUBTextOffsetRangeInfo? { - guard let decodedRangeInfo = RDEPUBTextOffsetRangeInfo.decode(from: rangeInfo), - normalizedHref(decodedRangeInfo.href) == normalizedHref(location.href) else { - return nil - } - return decodedRangeInfo - } - - private func chapterData( - in textBook: RDEPUBTextBook, - for rangeInfo: RDEPUBTextOffsetRangeInfo - ) -> RDEPUBChapterData? { - if let chapterData = textBook.chapterData(for: rangeInfo.href) { - return chapterData - } - - let targetHref = normalizedHref(rangeInfo.href) - if let chapterData = textBook.chapterData(for: targetHref) { - return chapterData - } - - guard let chapter = textBook.chapters.first(where: { normalizedHref($0.href) == targetHref }) else { - return nil - } - return textBook.chapterData(for: chapter.href) - } - - private func normalizedHref(_ href: String) -> String { - publication?.resourceResolver.normalizedHref(href) - ?? href.components(separatedBy: "#").first - ?? href - } - - private func nearestFragmentID(beforeOrAt offset: Int, fragmentOffsets: [String: Int]) -> String? { - var bestID: String? - var bestOffset = Int.min - for (fragmentID, fragmentOffset) in fragmentOffsets where fragmentOffset <= offset && fragmentOffset > bestOffset { - bestOffset = fragmentOffset - bestID = fragmentID - } - return bestID - } - - private func makeRuntimeChapterData(from resolvedPage: RDEPUBResolvedPage) -> RDEPUBChapterData { - let textChapter = RDEPUBTextChapter( - chapterIndex: resolvedPage.chapterIndex, - spineIndex: resolvedPage.chapter.spineIndex, - href: resolvedPage.chapter.href, - title: resolvedPage.chapter.title, - attributedContent: resolvedPage.chapter.typesetAttributedString, - fragmentOffsets: resolvedPage.chapter.chapterOffsetMap.fragmentOffsets, - cfiMap: resolvedPage.chapter.chapterOffsetMap.cfiMap, - pageBreakReasons: resolvedPage.chapter.pages.map(\.metadata.breakReason), - pages: resolvedPage.chapter.pages - ) - return RDEPUBChapterData( - chapter: textChapter, - indexTable: RDEPUBTextIndexTable(chapters: [textChapter]) - ) - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+Orientation.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+Orientation.swift deleted file mode 100644 index 32dd893..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+Orientation.swift +++ /dev/null @@ -1,48 +0,0 @@ -import UIKit - -/// 朝向锁定支持。 -/// -/// 依据 `publication.metadata.orientation`(来源 OPF rendition:orientation, -/// 或兜底的 iBooks display-options)返回受限的支持朝向,并在书籍打开后主动请求转向。 -/// 未声明朝向(nil / .auto)时不干预,跟随系统与容器。 -/// -/// 注意:`supportedInterfaceOrientations` 只有当阅读器是当前决定朝向的控制器时才生效 -/// (通常需宿主保证它被 present 或位于导航栈顶;若被包在自定义容器里,容器需转发该值)。 -extension RDEPUBReaderController { - - /// 由 EPUB 元数据推导的受限支持朝向;nil = 不限制(跟随系统) - var epubSupportedOrientationMask: UIInterfaceOrientationMask? { - switch publication?.metadata.orientation { - case .portrait: - return .portrait - case .landscape: - return .landscape - case .auto, .none: - return nil - } - } - - public override var supportedInterfaceOrientations: UIInterfaceOrientationMask { - epubSupportedOrientationMask ?? super.supportedInterfaceOrientations - } - - public override var shouldAutorotate: Bool { - // 已锁定单一朝向时不自动旋转 - epubSupportedOrientationMask == nil - } - - /// 打开书籍后按元数据主动转向(iOS 16+ 用 requestGeometryUpdate,旧系统回退到刷新支持朝向)。 - /// 由加载完成流程调用。 - func applyOrientationLockIfNeeded() { - guard let mask = epubSupportedOrientationMask else { return } - if #available(iOS 16.0, *) { - setNeedsUpdateOfSupportedInterfaceOrientations() - guard let scene = view.window?.windowScene else { return } - scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask)) { _ in } - } else { - let target: UIInterfaceOrientation = (mask == .landscape) ? .landscapeRight : .portrait - UIDevice.current.setValue(target.rawValue, forKey: "orientation") - UIViewController.attemptRotationToDeviceOrientation() - } - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+PublicAPI.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+PublicAPI.swift deleted file mode 100644 index afe8d50..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+PublicAPI.swift +++ /dev/null @@ -1,168 +0,0 @@ - -import UIKit - -extension RDEPUBReaderController { - - public func reloadBook() { - runtime.reloadBook() - } - - public func go(to location: RDEPUBLocation) { - guard publication != nil else { return } - _ = runtime.go(to: location) - } - - @discardableResult - public func go(toPageNumber pageNumber: Int, animated: Bool = false) -> Bool { - runtime.go(toPageNumber: pageNumber, animated: animated) - } - - public func clearSelection() { - runtime.clearSelection() - } - - public func bookmark(withID id: String) -> RDEPUBBookmark? { - runtime.bookmark(withID: id) - } - - public func highlight(withID id: String) -> RDEPUBHighlight? { - runtime.highlight(withID: id) - } - - public func nativeTextSemanticSummary() -> String? { - let resolvedPage: RDEPUBTextPage? - if let textBook { - resolvedPage = textBook.page(at: max(readerView.currentPage + 1, 1)) ?? textBook.pages.first - } else if readerContext.bookPageMap != nil { - let absolutePageIndex = max(readerView.currentPage, 0) - _ = runtime.prepareOnDemandChapter(forAbsolutePageNumber: absolutePageIndex + 1) - resolvedPage = runtime.pageResolver.resolvePage(absolutePageIndex: absolutePageIndex)?.page - } else { - resolvedPage = nil - } - - guard let page = resolvedPage else { - return nil - } - - let metadata = page.metadata - var parts = [ - "page \(page.absolutePageIndex + 1)", - "break \(metadata.breakReason.rawValue)", - metadata.blockKinds.isEmpty ? nil : "block kinds [\(metadata.blockKinds.map(\.rawValue).joined(separator: ","))]", - metadata.semanticHints.isEmpty ? nil : "hints [\(metadata.semanticHints.map(\.rawValue).joined(separator: ","))]", - metadata.attachmentPlacements.isEmpty ? nil : "placements [\(metadata.attachmentPlacements.map(\.rawValue).joined(separator: ","))]" - ].compactMap { $0 } - if let firstDiagnostic = metadata.diagnostics.first { - parts.append(firstDiagnostic) - } - return parts.joined(separator: " · ") - } - - @discardableResult - public func addHighlight( - from selection: RDEPUBSelection? = nil, - color: String = "#F8E16C", - note: String? = nil - ) -> RDEPUBHighlight? { - runtime.addHighlight(from: selection, color: color, note: note) - } - - @discardableResult - public func addAnnotation( - from selection: RDEPUBSelection? = nil, - style: RDEPUBHighlightStyle, - color: String = "#F8E16C", - note: String? = nil - ) -> RDEPUBHighlight? { - runtime.addAnnotation(from: selection, style: style, color: color, note: note) - } - - @discardableResult - public func upsertHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight? { - runtime.upsertHighlight(highlight) - } - - @discardableResult - public func removeHighlight(id: String) -> RDEPUBHighlight? { - runtime.removeHighlight(id: id) - } - - @discardableResult - public func updateHighlightNote(id: String, note: String?) -> RDEPUBHighlight? { - runtime.updateHighlightNote(id: id, note: note) - } - - @discardableResult - public func go(toHighlightID id: String, animated: Bool = true) -> Bool { - runtime.go(toHighlightID: id, animated: animated) - } - - public func removeAllHighlights() { - runtime.removeAllHighlights() - } - - @discardableResult - public func go(toTableOfContentsItem item: EPUBTableOfContentsItem, animated: Bool = true) -> Bool { - go(toTableOfContentsHref: item.href, animated: animated) - } - - @discardableResult - public func go(toTableOfContentsItem item: RDEPUBReaderTableOfContentsItem, animated: Bool = true) -> Bool { - go(toTableOfContentsHref: item.href, animated: animated) - } - - @discardableResult - public func go(toTableOfContentsHref href: String, animated: Bool = true) -> Bool { - guard publication != nil else { return false } - - let components = href.components(separatedBy: "#") - let baseHref = components.first ?? href - let fragment = components.count > 1 ? components[1] : nil - let location = RDEPUBLocation( - bookIdentifier: currentBookIdentifier, - href: baseHref, - progression: 0, - fragment: fragment - ) - return restoreReadingLocation(location, animated: animated) - } - - @discardableResult - public func addBookmark(note: String? = nil) -> RDEPUBBookmark? { - runtime.addBookmark(note: note) - } - - @discardableResult - public func toggleBookmark(note: String? = nil) -> RDEPUBBookmark? { - runtime.toggleBookmark(note: note) - } - - @discardableResult - public func removeBookmark(id: String) -> RDEPUBBookmark? { - runtime.removeBookmark(id: id) - } - - @discardableResult - public func go(toBookmarkID id: String, animated: Bool = true) -> Bool { - runtime.go(toBookmarkID: id, animated: animated) - } - - public func search(keyword: String) { - runtime.search(keyword: keyword) - } - - @discardableResult - public func searchNext() -> Bool { - runtime.searchNext() - } - - @discardableResult - public func searchPrevious() -> Bool { - runtime.searchPrevious() - } - - public func clearSearch() { - runtime.clearSearch() - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+RenderSupport.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+RenderSupport.swift deleted file mode 100644 index a07576e..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+RenderSupport.swift +++ /dev/null @@ -1,86 +0,0 @@ - -import UIKit - -extension RDEPUBReaderController { - - func currentLayoutContext() -> RDEPUBNavigatorLayoutContext { - readerContext.currentLayoutContext() - } - - func currentPreferences() -> RDEPUBPreferences { - readerContext.currentPreferences() - } - - func currentTextPageSize() -> CGSize { - readerContext.currentTextPageSize() - } - - func currentTextRenderStyle() -> RDEPUBTextRenderStyle { - readerContext.currentTextRenderStyle() - } - - func currentTextLayoutConfig(pageSize: CGSize) -> RDEPUBTextLayoutConfig { - readerContext.currentTextLayoutConfig(pageSize: pageSize) - } - - func resolvedTextRenderer() -> RDEPUBTextRenderer { - readerContext.resolvedTextRenderer() - } - - func ensurePaginationHostView() -> UIView { - let viewportSize = currentLayoutContext().viewportSize - let hostFrame = CGRect(x: -viewportSize.width - 32, y: 0, width: viewportSize.width, height: viewportSize.height) - if paginationHostView.superview == nil { - view.addSubview(paginationHostView) - view.sendSubviewToBack(paginationHostView) - } - paginationHostView.frame = hostFrame - return paginationHostView - } - - func request(for pageIndex: Int) -> RDEPUBRenderRequest? { - guard let publication, activePages.indices.contains(pageIndex) else { - return nil - } - let page = activePages[pageIndex] - let pendingLocation = readingSession?.pendingLocation(forPageNumber: pageIndex + 1, spineIndex: page.spineIndex) - let pendingHighlightRangeInfo = readingSession?.pendingHighlightRangeInfo( - forPageNumber: pageIndex + 1, - spineIndex: page.spineIndex - ) - return currentPreferences().renderRequest( - for: page, - publication: publication, - viewportSize: currentLayoutContext().viewportSize, - targetLocation: pendingLocation, - targetHighlightRangeInfo: pendingHighlightRangeInfo, - highlights: highlights(for: page), - searchPresentation: searchPresentation(for: page) - ) - } - - func fallbackLocation(for pageIndex: Int) -> RDEPUBLocation? { - guard activePages.indices.contains(pageIndex) else { return nil } - return readingSession?.fallbackLocation(for: activePages[pageIndex], bookIdentifier: currentBookIdentifier) - } - - private func highlights(for page: EPUBPage) -> [RDEPUBHighlight] { - guard let publication else { return [] } - if let spread = page.fixedSpread { - let hrefs = Set(spread.resources.compactMap { publication.resourceResolver.normalizedHref($0.href) }) - return activeHighlights.filter { highlight in - guard let normalizedHref = publication.resourceResolver.normalizedHref(highlight.location.href) else { - return false - } - return hrefs.contains(normalizedHref) - } - } - - guard publication.spine.indices.contains(page.spineIndex) else { return [] } - let href = publication.spine[page.spineIndex].href - let normalizedHref = publication.resourceResolver.normalizedHref(href) - return activeHighlights.filter { highlight in - publication.resourceResolver.normalizedHref(highlight.location.href) == normalizedHref - } - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+RuntimeBridge.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+RuntimeBridge.swift deleted file mode 100644 index 2fa6d66..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+RuntimeBridge.swift +++ /dev/null @@ -1,241 +0,0 @@ - -import UIKit - -extension RDEPUBReaderController { - - func applyReaderViewConfiguration() { - let resolvedDirection = resolvedPageDirection() - let presentationDidChange = readerView.currentDisplayType != configuration.displayType - || readerView.landscapeDualPageEnabled != configuration.landscapeDualPageEnabled - || readerView.pageDirection != resolvedDirection - let preservedLocation = presentationDidChange - ? (runtime.viewportMonitor.consumePendingPresentationRestoreLocation() ?? currentVisibleLocation() ?? persistenceLocation()) - : nil - view.backgroundColor = configuration.theme.contentBackgroundColor - readerView.landscapeDualPageEnabled = configuration.landscapeDualPageEnabled - readerView.pageDirection = resolvedDirection - updateReaderChrome() - if presentationDidChange { - readerView.switchReaderDisplayType(configuration.displayType) - if let preservedLocation { - _ = restoreReadingLocation(preservedLocation) - } - } - } - - func startInitialLoadIfNeeded() { - runtime.startInitialLoadIfNeeded() - } - - func loadPublication() { - runtime.loadPublication() - } - - func applyParsedPublication( - parser: RDEPUBParser, - publication: RDEPUBPublication, - bookIdentifier: String, - restoreLocation: RDEPUBLocation?, - bookmarks: [RDEPUBBookmark], - highlights: [RDEPUBHighlight] - ) { - runtime.applyParsedPublication( - parser: parser, - publication: publication, - bookIdentifier: bookIdentifier, - restoreLocation: restoreLocation, - bookmarks: bookmarks, - highlights: highlights - ) - } - - func paginatePublication(restoreLocation: RDEPUBLocation?) { - runtime.paginatePublication(restoreLocation: restoreLocation) - } - - func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) { - runtime.applyTextBook(textBook, restoreLocation: restoreLocation) - } - - func applyPaginationSnapshot( - _ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]), - restoreLocation: RDEPUBLocation? - ) { - runtime.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation) - } - - func finishPagination(restoreLocation: RDEPUBLocation?) { - runtime.finishPagination(restoreLocation: restoreLocation) - } - - func repaginatePreservingCurrentLocation() { - runtime.repaginatePreservingCurrentLocation() - } - - @discardableResult - func restoreReadingLocation( - _ location: RDEPUBLocation, - animated: Bool = false, - targetHighlightRangeInfo: String? = nil - ) -> Bool { - runtime.restoreReadingLocation( - location, - animated: animated, - targetHighlightRangeInfo: targetHighlightRangeInfo - ) - } - - func currentVisibleLocation() -> RDEPUBLocation? { - runtime.currentVisibleLocation() - } - - func persistenceLocation() -> RDEPUBLocation? { - readerContext.persistenceLocation() - } - - func persist(location: RDEPUBLocation) { - readerContext.persist(location: location) - } - - func updateCurrentSelection(_ selection: RDEPUBSelection?) { - runtime.annotationCoordinator.updateCurrentSelection(selection) - } - - func scopedSelection( - _ selection: RDEPUBSelection, - relativeToSpineIndex spineIndex: Int? - ) -> RDEPUBSelection? { - runtime.annotationCoordinator.scopedSelection(selection, relativeToSpineIndex: spineIndex) - } - - func refreshVisibleContentPreservingLocation() { - runtime.refreshVisibleContentPreservingLocation() - } - - func rebuildExternalTextBook() { - runtime.rebuildExternalTextBook() - } - - func updateReaderChrome() { - runtime.updateReaderChrome() - } - - func presentBookmarksManager() { - runtime.presentBookmarksManager() - } - - func presentHighlightsManager() { - runtime.presentHighlightsManager() - } - - func presentAnnotationCreation() { - runtime.presentAnnotationCreation() - } - - func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) { - runtime.handleSelectionMenuAction(action, selection: selection) - } - - func presentSettings() { - runtime.presentSettings() - } - - func updateConfiguration(_ update: (inout RDEPUBReaderConfiguration) -> Void) { - var nextConfiguration = configuration - update(&nextConfiguration) - configuration = nextConfiguration - } - - func setScreenBrightness(_ brightness: CGFloat) { - currentBrightness = max(0, min(1, brightness)) - persistReaderSettingsIfNeeded() - } - - func persistReaderSettingsIfNeeded() { - let settings = RDEPUBReaderSettings.capture( - configuration: configuration, - brightness: currentBrightness - ) - persistence?.saveReaderSettings(settings) - } - - func requiresRepagination( - from oldConfiguration: RDEPUBReaderConfiguration, - to newConfiguration: RDEPUBReaderConfiguration - ) -> Bool { - oldConfiguration.fontSize != newConfiguration.fontSize || - oldConfiguration.fontChoice != newConfiguration.fontChoice || - oldConfiguration.lineHeightMultiple != newConfiguration.lineHeightMultiple || - oldConfiguration.numberOfColumns != newConfiguration.numberOfColumns || - oldConfiguration.columnGap != newConfiguration.columnGap || - oldConfiguration.reflowableContentInsets != newConfiguration.reflowableContentInsets || - oldConfiguration.fixedContentInset != newConfiguration.fixedContentInset || - oldConfiguration.fixedLayoutFit != newConfiguration.fixedLayoutFit || - oldConfiguration.fixedLayoutSpreadMode != newConfiguration.fixedLayoutSpreadMode || - oldConfiguration.textRenderingEngine != newConfiguration.textRenderingEngine - } - - func requiresVisibleRefresh( - from oldConfiguration: RDEPUBReaderConfiguration, - to newConfiguration: RDEPUBReaderConfiguration - ) -> Bool { - oldConfiguration.theme != newConfiguration.theme || - oldConfiguration.darkImageAdjustmentEnabled != newConfiguration.darkImageAdjustmentEnabled || - oldConfiguration.darkImageBlendRatio != newConfiguration.darkImageBlendRatio - } - - func presentTableOfContents() { - runtime.presentTableOfContents() - } - - func handleBackAction() { - runtime.handleBackAction() - } - - func handle(error: Error) { - isRepaginating = false - hideLoading() - readerContext.clearActiveSnapshot() - textBook = nil - readerContext.bookPageMap = nil - readerView.reloadData() - errorLabel.text = error.localizedDescription - errorLabel.isHidden = false - delegate?.epubReader(self, didFailWithError: error) - } - - func showLoading() { - errorLabel.isHidden = true - loadingIndicator.startAnimating() - } - - func hideLoading() { - loadingIndicator.stopAnimating() - } - - func currentViewportSignature() -> RDEPUBViewportSignature? { - runtime.currentViewportSignature() - } - - func handleViewportChangeIfNeeded( - reason: RDEPUBViewportChangeReason, - viewportSignature: RDEPUBViewportSignature? = nil - ) { - runtime.handleViewportChangeIfNeeded(reason: reason, viewportSignature: viewportSignature) - } - - func searchPresentation(for page: EPUBPage) -> RDEPUBSearchPresentation? { - runtime.searchPresentation(for: page) - } - - private func resolvedPageDirection() -> RDEpubReaderView.PageDirection { - publication?.readingProgression == .rtl ? .rightToLeft : .leftToRight - } -} - -extension RDEPUBReaderController: UIGestureRecognizerDelegate { - - public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { - true - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+TableOfContents.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+TableOfContents.swift deleted file mode 100644 index 8c38a63..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+TableOfContents.swift +++ /dev/null @@ -1,109 +0,0 @@ - -import Foundation - -extension RDEPUBReaderController { - - func resolvedCurrentTableOfContentsItem() -> RDEPUBReaderTableOfContentsItem? { - let items = flattenedTableOfContentsItems( - from: publication?.tableOfContents ?? [], - includePageNumbers: false - ) - guard !items.isEmpty else { return nil } - - guard let publication, - let currentLocation = currentVisibleLocation(), - let normalizedCurrentHref = publication.resourceResolver.normalizedHref(currentLocation.href) else { - return nil - } - - if let textBook, - let chapterData = textBook.chapterData( - for: currentLocation, - resolver: publication.resourceResolver, - bookIdentifier: currentBookIdentifier - ), - let tocItem = chapterData.primaryTableOfContentsItem( - from: publication.tableOfContents, - normalizer: { publication.resourceResolver.normalizedHref($0) } - ) { - return items.last { $0.href == tocItem.href } ?? items.last { - guard let normalizedItemHref = publication.resourceResolver.normalizedHref($0.href.components(separatedBy: "#").first ?? $0.href) else { - return false - } - return normalizedItemHref == normalizedCurrentHref - } - } - - return items.last { item in - guard let normalizedItemHref = publication.resourceResolver.normalizedHref(item.href.components(separatedBy: "#").first ?? item.href) else { - return false - } - return normalizedItemHref == normalizedCurrentHref - } - } - - func flattenedTableOfContentsItems( - from items: [EPUBTableOfContentsItem], - depth: Int = 0, - includePageNumbers: Bool = true - ) -> [RDEPUBReaderTableOfContentsItem] { - items.flatMap { item in - let location = RDEPUBLocation(bookIdentifier: currentBookIdentifier, href: item.href, progression: 0) - let pageNumber = includePageNumbers ? resolvedTableOfContentsPageNumber(for: location) : nil - - let current = RDEPUBReaderTableOfContentsItem( - title: item.title, - href: item.href, - depth: depth, - pageNumber: pageNumber - ) - return [current] + flattenedTableOfContentsItems( - from: item.children, - depth: depth + 1, - includePageNumbers: includePageNumbers - ) - } - } - - private func resolvedTableOfContentsPageNumber(for location: RDEPUBLocation) -> Int? { - if let publication, - let bookPageMap = readerContext.bookPageMap, - let spineIndex = readerContext.normalizedSpineIndex(for: location), - let entry = bookPageMap.entry(forSpineIndex: spineIndex) { - let normalizedLocation = publication.resourceResolver.normalizedLocation( - location, - bookIdentifier: currentBookIdentifier - ) ?? location - let localPageIndex: Int - if let summary = readerContext.chapterSummary(forSpineIndex: spineIndex) { - let offset = chapterOffset(for: normalizedLocation, fallbackEntry: entry) - localPageIndex = summary.pageRanges.firstIndex { - let range = $0.nsRange - return offset >= range.location && offset <= max(range.location + range.length - 1, range.location) - } ?? fallbackLocalPageIndex(for: normalizedLocation, pageCount: entry.pageCount) - } else { - localPageIndex = fallbackLocalPageIndex(for: normalizedLocation, pageCount: entry.pageCount) - } - return bookPageMap.absolutePageIndex( - spineIndex: spineIndex, - localPageIndex: min(max(localPageIndex, 0), max(entry.pageCount - 1, 0)) - ).map { $0 + 1 } - } - - if let textBook, let publication, - let chapterData = textBook.chapterData(for: location, resolver: publication.resourceResolver, bookIdentifier: currentBookIdentifier) { - let normalizedLocation = publication.resourceResolver.normalizedLocation( - location, - bookIdentifier: currentBookIdentifier - ) ?? location - return chapterData.pageNumber(for: normalizedLocation) - ?? textBook.pageNumber( - for: location, - resolver: publication.resourceResolver, - bookIdentifier: currentBookIdentifier - ) - } - - return readingSession?.pageIndex(for: location, bookIdentifier: currentBookIdentifier).map { $0 + 1 } - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+Trial.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+Trial.swift deleted file mode 100644 index 69c67e8..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+Trial.swift +++ /dev/null @@ -1,93 +0,0 @@ -import UIKit - -/// 试读墙支持。 -/// -/// 设计:`pageCountOfReaderView` 仍返回原始内容页数(内容页显示的总页数不受影响), -/// 仅 `numberOfPages(in:)` 在启用试读墙时 +1;试读墙页固定位于最后一个可读页之后。 -/// 两种形态统一覆盖: -/// - readoor 独立试读 epub(整本都是试读章节):readableChapterCount 传 nil,墙追加在全书末尾; -/// - 整本 epub + 按章限制:readableChapterCount 传 N,超出章节页不展示(仅 bookPageMap 路径)。 -extension RDEPUBReaderController { - - /// 试读墙视图(首次访问经 delegate 获取并缓存) - var trialWallView: UIView? { - if let cached = cachedTrialWallView { - return cached - } - guard configuration.trialPolicy != nil else { return nil } - let view = delegate?.epubReaderTrialWallView(self) - cachedTrialWallView = view - return view - } - - /// 是否启用试读墙:配置了试读策略且宿主提供了墙视图 - var isTrialWallEnabled: Bool { - configuration.trialPolicy != nil && trialWallView != nil - } - - /// 原始可读内容页数(不含试读墙页)。 - /// readableChapterCount 指定且存在 bookPageMap 时按章截断,否则为全部内容页。 - var readableContentPageCount: Int { - let rawCount = pageCountOfReaderView(readerView: readerView) - guard let policy = configuration.trialPolicy, - let readableChapterCount = policy.readableChapterCount, - readableChapterCount > 0, - let pageMap = readerContext.bookPageMap else { - return rawCount - } - // 第一个不可读章节(spineIndex >= readableChapterCount)的起始绝对页 = 可读页数 - let firstLockedStart = pageMap.entries - .first { $0.spineIndex >= readableChapterCount }? - .absolutePageStart - return firstLockedStart ?? rawCount - } - - /// 试读墙页的绝对索引(0-based);未启用时为 nil - var trialWallPageIndex: Int? { - guard isTrialWallEnabled else { return nil } - return readableContentPageCount - } - - /// 供 RDEpubReaderView 使用的有效页数:启用试读墙时为可读页数 + 1(墙页) - var effectiveNumberOfPages: Int { - guard isTrialWallEnabled else { - return pageCountOfReaderView(readerView: readerView) - } - return readableContentPageCount + 1 - } - - /// 构建试读墙页承载视图(铺满一页,居中放置宿主提供的墙视图) - func makeTrialWallPageView(reusableView: UIView?) -> UIView { - let container = (reusableView as? RDEPUBTrialWallContainerView) ?? RDEPUBTrialWallContainerView() - container.setWallView(trialWallView) - return container - } -} - -/// 试读墙页容器:让宿主墙视图铺满整页 -final class RDEPUBTrialWallContainerView: UIView, RDEpubReaderCachePolicyProviding { - - /// 宿主只提供一份试读墙 View。预加载会同时构建多个页容器,若缓存该容器会 - /// 把同一 View 从实际可见页移走,最终表现为横竖翻页的试读墙白屏。 - var shouldAvoidReaderPageCaching: Bool { true } - - private weak var wallView: UIView? - - func setWallView(_ view: UIView?) { - // 即使 View 对象相同,只要它已被其他复用容器接管,也必须重新挂回当前页。 - if wallView === view, view?.superview === self { - return - } - wallView?.removeFromSuperview() - guard let view else { return } - view.translatesAutoresizingMaskIntoConstraints = false - addSubview(view) - NSLayoutConstraint.activate([ - view.leadingAnchor.constraint(equalTo: leadingAnchor), - view.trailingAnchor.constraint(equalTo: trailingAnchor), - view.topAnchor.constraint(equalTo: topAnchor), - view.bottomAnchor.constraint(equalTo: bottomAnchor) - ]) - wallView = view - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController.swift deleted file mode 100644 index b508359..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController.swift +++ /dev/null @@ -1,503 +0,0 @@ -import UIKit - -public final class RDEPUBReaderController: UIViewController { - - public weak var delegate: RDEPUBReaderDelegate? - - public var configuration: RDEPUBReaderConfiguration { - didSet { - // M-09: Apply all configuration side effects even before view is loaded, - // but UI-dependent actions only after view is loaded. - readerContext.configuration = configuration - guard isViewLoaded else { return } - applyWebViewDebugPolicy() - persistReaderSettingsIfNeeded() - let oldConfiguration = oldValue - applyReaderViewConfiguration() - - if isExternalTextBook { - if requiresRepagination(from: oldConfiguration, to: configuration) { - rebuildExternalTextBook() - } else if requiresVisibleRefresh(from: oldConfiguration, to: configuration) { - refreshVisibleContentPreservingLocation() - } - return - } - - guard publication != nil else { return } - if requiresRepagination(from: oldConfiguration, to: configuration) { - repaginatePreservingCurrentLocation() - } else if requiresVisibleRefresh(from: oldConfiguration, to: configuration) { - refreshVisibleContentPreservingLocation() - } - } - } - - public var currentLocation: RDEPUBLocation? { - currentVisibleLocation() - } - - public var currentPageNumber: Int? { - guard readerView.currentPage >= 0 else { return nil } - return readerView.currentPage + 1 - } - - public internal(set) var currentSelection: RDEPUBSelection? { - get { readerContext.currentSelection } - set { readerContext.currentSelection = newValue } - } - - public var highlights: [RDEPUBHighlight] { - activeHighlights - } - - public var bookmarks: [RDEPUBBookmark] { - activeBookmarks - } - - public var annotations: [RDEPUBAnnotation] { - let merged = activeHighlights.map(\.annotation) + activeBookmarks.map(\.annotation) - return merged.sorted { $0.createdAt < $1.createdAt } - } - - public var tableOfContents: [EPUBTableOfContentsItem] { - publication?.tableOfContents ?? [] - } - - public var flattenedTableOfContents: [RDEPUBReaderTableOfContentsItem] { - guard let publication else { return [] } - return flattenedTableOfContentsItems(from: publication.tableOfContents) - } - - public var currentTableOfContentsItem: RDEPUBReaderTableOfContentsItem? { - resolvedCurrentTableOfContentsItem() - } - - let epubURL: URL - - let persistence: RDEPUBReaderPersistence? - - let dependencies: RDEPUBReaderDependencies - - let readerView = RDEpubReaderView() - - let loadingIndicator: UIActivityIndicatorView = { - UIActivityIndicatorView(style: .large) - }() - - let errorLabel: UILabel = { - let label = UILabel() - label.numberOfLines = 0 - label.textAlignment = .center - label.textColor = .darkGray - label.isHidden = true - return label - }() - - let paginationHostView = UIView() - - /// Chapter-level shared display content/layouter for text pages - /// (LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md P1-1/P1-3). - let textDisplayCache = RDEPUBChapterDisplayContentCache() - - lazy var readerContext = RDEPUBReaderContext(controller: self) - - var parser: RDEPUBParser? { - get { readerContext.parser } - set { readerContext.parser = newValue } - } - - var publication: RDEPUBPublication? { - get { readerContext.publication } - set { readerContext.publication = newValue } - } - - var readingSession: RDEPUBReadingSession? { - get { readerContext.readingSession } - set { readerContext.readingSession = newValue } - } - - var textBook: RDEPUBTextBook? { - get { readerContext.textBook } - set { readerContext.textBook = newValue } - } - - var activePages: [EPUBPage] { - readerContext.activePages - } - - var activeChapters: [EPUBChapterInfo] { - readerContext.activeChapters - } - - var activeBookmarks: [RDEPUBBookmark] { - get { readerContext.activeBookmarks } - set { readerContext.activeBookmarks = newValue } - } - - var activeHighlights: [RDEPUBHighlight] { - get { readerContext.activeHighlights } - set { readerContext.activeHighlights = newValue } - } - - lazy var topToolView = runtime.makeTopToolView() - - lazy var bottomToolView = runtime.makeBottomToolView() - - lazy var searchBarView = RDEPUBReaderSearchBarView() - - /// 试读墙视图缓存(经 delegate 获取一次后复用;见 RDEPUBReaderController+Trial) - var cachedTrialWallView: UIView? - - /// 阅读器进入前宿主导航栏的真实状态。详情页使用自定义导航栏时系统栏原本 - /// 就是隐藏的,退出阅读器必须恢复该状态,不能无条件显示系统导航栏。 - private var navigationBarHiddenBeforeReader: Bool? - - private(set) var isSearchBarVisible = false - - var currentBookIdentifier: String? { - get { readerContext.currentBookIdentifier } - set { readerContext.currentBookIdentifier = newValue } - } - - var textBookCache: RDEPUBTextBookCache { readerContext.textBookCache } - - var currentBrightness: CGFloat { - get { readerContext.currentBrightness } - set { readerContext.currentBrightness = newValue } - } - - var didStartInitialLoad: Bool { - get { readerContext.didStartInitialLoad } - set { readerContext.didStartInitialLoad = newValue } - } - - var isRepaginating: Bool { - get { readerContext.isRepaginating } - set { readerContext.isRepaginating = newValue } - } - - var lastTextPaginationPageSize: CGSize? { - get { readerContext.lastTextPaginationPageSize } - set { readerContext.lastTextPaginationPageSize = newValue } - } - - var isReconcilingTextPaginationSize = false - - var paginationToken: UUID { - get { readerContext.paginationToken } - set { readerContext.paginationToken = newValue } - } - - var searchState: RDEPUBSearchState? { - get { readerContext.searchState } - set { readerContext.searchState = newValue } - } - - var isExternalTextBook: Bool { - get { readerContext.isExternalTextBook } - set { readerContext.isExternalTextBook = newValue } - } - - var textFileURL: URL? { - get { readerContext.textFileURL } - set { readerContext.textFileURL = newValue } - } - - lazy var readerAssemblyCoordinator = RDEPUBReaderAssemblyCoordinator(context: readerContext) - - lazy var runtime = RDEPUBReaderRuntime(context: readerContext) - - public init( - epubURL: URL, - configuration: RDEPUBReaderConfiguration = .default, - persistence: RDEPUBReaderPersistence? = RDEPUBUserDefaultsPersistence(), - dependencies: RDEPUBReaderDependencies = .live - ) { - let persistedSettings = persistence?.loadReaderSettings() - self.epubURL = epubURL - self.configuration = persistedSettings?.applying(to: configuration) ?? configuration - self.persistence = persistence - self.dependencies = dependencies - let brightness = max(0, min(1, persistedSettings?.brightness ?? dependencies.environment.currentBrightness)) - super.init(nibName: nil, bundle: nil) - - readerContext.dependencies = dependencies - readerContext.configuration = self.configuration - readerContext.epubURL = epubURL - readerContext.persistence = persistence - self.currentBrightness = brightness - applyWebViewDebugPolicy() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func applyWebViewDebugPolicy() { - RDEPUBWebViewDebug.applyDebugPolicy( - inspectableEnabled: configuration.allowsInspectableWebViews, - verboseLoggingEnabled: configuration.enablesVerboseWebViewLogging - ) - } - - public convenience init( - textBook: RDEPUBTextBook, - bookIdentifier: String, - title: String, - textFileURL: URL? = nil, - configuration: RDEPUBReaderConfiguration = .default, - persistence: RDEPUBReaderPersistence? = RDEPUBUserDefaultsPersistence(), - dependencies: RDEPUBReaderDependencies = .live - ) { - let persistedSettings = persistence?.loadReaderSettings() - let resolvedConfig = persistedSettings?.applying(to: configuration) ?? configuration - - self.init( - epubURL: URL(string: "about:blank")!, - configuration: resolvedConfig, - persistence: persistence, - dependencies: dependencies - ) - self.isExternalTextBook = true - self.textBook = textBook - self.textFileURL = textFileURL - self.currentBookIdentifier = bookIdentifier - self.title = title - self.didStartInitialLoad = true - } - - public override func viewDidLoad() { - super.viewDidLoad() - currentBrightness = currentBrightness - readerAssemblyCoordinator.assembleInterface() - readerAssemblyCoordinator.finishExternalTextBookLaunchIfNeeded() - readerView.onToolViewVisibilityChanged = { [weak self] isVisible in - self?.handleToolViewVisibilityChanged(isVisible: isVisible) - } - } - - public override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - if navigationBarHiddenBeforeReader == nil { - navigationBarHiddenBeforeReader = navigationController?.isNavigationBarHidden - } - navigationController?.setNavigationBarHidden(true, animated: animated) - navigationController?.interactivePopGestureRecognizer?.delegate = self - navigationController?.interactivePopGestureRecognizer?.isEnabled = true - } - - public override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - if let navigationBarHiddenBeforeReader { - navigationController?.setNavigationBarHidden( - navigationBarHiddenBeforeReader, - animated: animated - ) - } - } - - public override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - startInitialLoadIfNeeded() - RDEPUBSettingsFlipAutomation.startIfNeeded(controller: self) - } - - public override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - runtime.viewportMonitor.viewDidLayoutSubviews() - } - - public override func didReceiveMemoryWarning() { - super.didReceiveMemoryWarning() - runtime.handleMemoryWarning() - textDisplayCache.removeAll() - } - - public override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { - super.viewWillTransition(to: size, with: coordinator) - runtime.viewportMonitor.viewWillTransition(with: coordinator) - coordinator.animate(alongsideTransition: nil) { _ in - RDEPUBMemoryProbe.log("orientationTransition size=\(Int(size.width))x\(Int(size.height))") - } - } - - func showSearchBar() { - guard !isSearchBarVisible else { return } - isSearchBarVisible = true - searchBarView.apply(theme: configuration.theme) - - searchBarView.onSearchSubmit = { [weak self] keyword in - self?.searchBarView.showSearching() - self?.runtime.search(keyword: keyword) - self?.updateSearchCount() - } - searchBarView.onSearchTextChanged = { [weak self] keyword in - guard let self else { return } - let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines) - if normalizedKeyword.isEmpty { - self.runtime.clearSearch() - } else { - self.searchBarView.showSearching() - self.runtime.search(keyword: normalizedKeyword) - } - self.updateSearchCount() - } - searchBarView.onSearchPrevious = { [weak self] in - _ = self?.runtime.searchPrevious() - self?.updateSearchCount() - } - searchBarView.onSearchNext = { [weak self] in - _ = self?.runtime.searchNext() - self?.updateSearchCount() - } - searchBarView.onSelectMatch = { [weak self] matchIndex in - guard let self else { return } - let didNavigate = self.runtime.selectSearchMatch(at: matchIndex) - self.updateSearchCount() - if didNavigate { - self.hideSearchBar(clearSearch: false) - } - } - searchBarView.onClose = { [weak self] in - self?.hideSearchBar(clearSearch: true) - } - - if readerView.isShowToolView { - installSearchBarView() - } - } - - private func installSearchBarView() { - guard searchBarView.superview == nil else { return } - searchBarView.apply(theme: configuration.theme) - - readerView.addSubview(searchBarView) - readerView.searchBarView = searchBarView - let bottomAnchor = bottomToolView.superview == nil - ? readerView.bottomAnchor - : bottomToolView.topAnchor - NSLayoutConstraint.activate([ - searchBarView.leadingAnchor.constraint(equalTo: readerView.leadingAnchor), - searchBarView.trailingAnchor.constraint(equalTo: readerView.trailingAnchor), - searchBarView.topAnchor.constraint(equalTo: readerView.topAnchor), - searchBarView.bottomAnchor.constraint(equalTo: bottomAnchor) - ]) - - searchBarView.alpha = 0 - searchBarView.presentedView.transform = CGAffineTransform(translationX: 0, y: 40) - UIView.animate(withDuration: 0.28) { - self.searchBarView.alpha = 1 - self.searchBarView.presentedView.transform = .identity - } - - if let keyword = searchState?.keyword, !keyword.isEmpty { - searchBarView.restoreKeyword(keyword) - updateSearchCount() - } else { - searchBarView.showNoResults() - } - - DispatchQueue.main.async { [weak self] in - self?.searchBarView.textField.becomeFirstResponder() - } - } - - func hideSearchBar(clearSearch: Bool = false) { - guard isSearchBarVisible else { return } - isSearchBarVisible = false - - searchBarView.textField.resignFirstResponder() - UIView.animate(withDuration: 0.25, animations: { - self.searchBarView.alpha = 0 - self.searchBarView.presentedView.transform = CGAffineTransform(translationX: 0, y: 40) - }) { _ in - self.searchBarView.removeFromSuperview() - self.searchBarView.alpha = 1 - self.searchBarView.presentedView.transform = .identity - self.readerView.searchBarView = nil - } - - if clearSearch { - runtime.clearSearch() - } - } - - func updateSearchCount() { - guard let searchState else { - searchBarView.showNoResults() - return - } - searchBarView.updateResults( - sections: searchResultSections(for: searchState), - keyword: searchState.keyword, - currentMatchIndex: searchState.currentMatchIndex - ) - } - - func handleToolViewVisibilityChanged(isVisible: Bool) { - if isVisible { - if isSearchBarVisible { - installSearchBarView() - } else if searchState != nil { - showSearchBar() - } - } else { - if isSearchBarVisible { - hideSearchBar(clearSearch: false) - } - } - } - -} - -private extension RDEPUBReaderController { - - func searchResultSections(for searchState: RDEPUBSearchState) -> [RDEPUBReaderSearchSection] { - let groupedMatches = Dictionary(grouping: Array(searchState.matches.enumerated()), by: { entry in - searchSectionTitle(for: entry.element) - }) - - let orderedTitles = searchState.matches.reduce(into: [String]()) { titles, match in - let title = searchSectionTitle(for: match) - if titles.last != title, titles.contains(title) == false { - titles.append(title) - } - } - - return orderedTitles.compactMap { title in - guard let matches = groupedMatches[title] else { return nil } - let items = matches.map { offset, match in - RDEPUBReaderSearchSection.Item( - matchIndex: offset, - previewText: match.previewText, - isCurrent: offset == searchState.currentMatchIndex - ) - } - return RDEPUBReaderSearchSection(title: title, items: items) - } - } - - func searchSectionTitle(for match: RDEPUBSearchMatch) -> String { - guard let publication else { - return match.href - } - - let normalizedMatchHref = publication.resourceResolver.normalizedHref(match.href) ?? match.href - let tocItems = flattenedTableOfContentsItems(from: publication.tableOfContents, includePageNumbers: false) - if let tocItem = tocItems.last(where: { - let rawHref = $0.href.components(separatedBy: "#").first ?? $0.href - let normalizedItemHref = publication.resourceResolver.normalizedHref(rawHref) ?? rawHref - return normalizedItemHref == normalizedMatchHref - }) { - return tocItem.title - } - - if let spineIndex = publication.resourceResolver.spineIndex(forNormalizedHref: normalizedMatchHref), - publication.spine.indices.contains(spineIndex) { - return publication.spine[spineIndex].title - } - - return normalizedMatchHref - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderDelegate.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderDelegate.swift deleted file mode 100644 index 30f23fb..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderDelegate.swift +++ /dev/null @@ -1,56 +0,0 @@ -import UIKit - -public protocol RDEPUBReaderDelegate: AnyObject { - - func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication) - - func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation) - - func epubReaderDidReachEnd(_ reader: UIViewController) - - func epubReader(_ reader: UIViewController, didChangeSelection selection: RDEPUBSelection?) - - func epubReader(_ reader: UIViewController, didUpdateHighlights highlights: [RDEPUBHighlight]) - - func epubReader(_ reader: UIViewController, didUpdateBookmarks bookmarks: [RDEPUBBookmark]) - - func epubReader(_ reader: UIViewController, didUpdateSearchResult result: RDEPUBSearchResult?) - - func epubReader(_ reader: UIViewController, didChangeCurrentSearchMatch match: RDEPUBSearchMatch?) - - func epubReader(_ reader: UIViewController, didUpdateCurrentTableOfContentsItem item: RDEPUBReaderTableOfContentsItem?) - - func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL) - - func epubReader(_ reader: UIViewController, shouldOpenExternalURL url: URL) -> Bool - - func epubReader(_ reader: UIViewController, didFailWithError error: Error) - - func epubReader(_ reader: UIViewController, configureTopToolView topToolView: RDEPUBReaderTopToolViewProviding) - - /// 提供试读墙视图(configuration.trialPolicy 非 nil 时生效)。 - /// 返回非 nil 时,阅读器在最后一个可读页之后追加一页展示该视图;返回 nil 则不追加试读墙。 - /// 视图的购买 / 登录按钮由宿主自行处理(可在点击后调用 reloadBook() 切换到完整 epub)。 - func epubReaderTrialWallView(_ reader: UIViewController) -> UIView? - - /// 用户翻到试读墙页时回调(宿主可在此触发购买引导 / 埋点等)。 - func epubReaderDidReachTrialWall(_ reader: UIViewController) -} - -public extension RDEPUBReaderDelegate { - func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication) {} - func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation) {} - func epubReaderDidReachEnd(_ reader: UIViewController) {} - func epubReader(_ reader: UIViewController, didChangeSelection selection: RDEPUBSelection?) {} - func epubReader(_ reader: UIViewController, didUpdateHighlights highlights: [RDEPUBHighlight]) {} - func epubReader(_ reader: UIViewController, didUpdateBookmarks bookmarks: [RDEPUBBookmark]) {} - func epubReader(_ reader: UIViewController, didUpdateSearchResult result: RDEPUBSearchResult?) {} - func epubReader(_ reader: UIViewController, didChangeCurrentSearchMatch match: RDEPUBSearchMatch?) {} - func epubReader(_ reader: UIViewController, didUpdateCurrentTableOfContentsItem item: RDEPUBReaderTableOfContentsItem?) {} - func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL) {} - func epubReader(_ reader: UIViewController, shouldOpenExternalURL url: URL) -> Bool { true } - func epubReader(_ reader: UIViewController, didFailWithError error: Error) {} - func epubReader(_ reader: UIViewController, configureTopToolView topToolView: RDEPUBReaderTopToolViewProviding) {} - func epubReaderTrialWallView(_ reader: UIViewController) -> UIView? { nil } - func epubReaderDidReachTrialWall(_ reader: UIViewController) {} -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderHighlightsViewController.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderHighlightsViewController.swift deleted file mode 100644 index c127a2c..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderHighlightsViewController.swift +++ /dev/null @@ -1,351 +0,0 @@ -import UIKit - -final class RDEPUBReaderHighlightsViewController: UITableViewController { - - var onSelectHighlight: ((RDEPUBHighlight) -> Void)? - - var onUpdateHighlight: ((RDEPUBHighlight) -> Void)? - - var onDeleteHighlight: ((RDEPUBHighlight) -> Void)? - - private var highlights: [RDEPUBHighlight] - - private let theme: RDEPUBReaderTheme - - private let sectionTitleProvider: (RDEPUBHighlight) -> String? - - private let filterControl = UISegmentedControl(items: ["全部", "批注", "高亮"]) - - private var filteredHighlights: [RDEPUBHighlight] { - switch filterControl.selectedSegmentIndex { - case 1: - return highlights.filter(\.hasNote) - case 2: - return highlights.filter { $0.style == .highlight } - default: - return highlights - } - } - - init( - highlights: [RDEPUBHighlight], - theme: RDEPUBReaderTheme, - sectionTitleProvider: @escaping (RDEPUBHighlight) -> String? - ) { - self.highlights = highlights.sorted { $0.createdAt > $1.createdAt } - self.theme = theme - self.sectionTitleProvider = sectionTitleProvider - super.init(style: .plain) - title = "标注" - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - view.accessibilityIdentifier = "epub.reader.highlights.panel" - tableView.accessibilityIdentifier = "epub.reader.highlights.table" - tableView.tableFooterView = UIView(frame: .zero) - tableView.separatorInset = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16) - configureFilterControl() - applyTheme() - updateEmptyState() - } - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - filteredHighlights.count - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cellIdentifier = "HighlightCell" - let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier) ?? UITableViewCell(style: .subtitle, reuseIdentifier: cellIdentifier) - let highlight = filteredHighlights[indexPath.row] - - cell.backgroundColor = theme.contentBackgroundColor - cell.textLabel?.textColor = theme.contentTextColor - cell.textLabel?.font = UIFont.systemFont(ofSize: 15, weight: .medium) - cell.textLabel?.numberOfLines = 2 - cell.textLabel?.text = titleText(for: highlight) - - cell.detailTextLabel?.textColor = theme.contentTextColor.withAlphaComponent(0.7) - cell.detailTextLabel?.font = UIFont.systemFont(ofSize: 12) - cell.detailTextLabel?.numberOfLines = 3 - cell.detailTextLabel?.text = detailText(for: highlight) - cell.accessoryType = .disclosureIndicator - return cell - } - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.deselectRow(at: indexPath, animated: true) - presentActions(for: filteredHighlights[indexPath.row], sourceIndexPath: indexPath) - } - - private func configureFilterControl() { - filterControl.selectedSegmentIndex = 0 - filterControl.accessibilityIdentifier = "epub.reader.highlights.filter" - filterControl.addTarget(self, action: #selector(filterChangedAction), for: .valueChanged) - navigationItem.titleView = filterControl - } - - private func applyTheme() { - tableView.backgroundColor = theme.contentBackgroundColor - navigationController?.navigationBar.tintColor = theme.toolControlTextColor - navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor - navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor] - navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.highlights.navbar" - } - - private func updateEmptyState() { - guard filteredHighlights.isEmpty else { - tableView.backgroundView = nil - return - } - - let label = UILabel() - label.text = emptyStateText() - label.textAlignment = .center - label.textColor = theme.contentTextColor.withAlphaComponent(0.7) - label.numberOfLines = 0 - label.accessibilityIdentifier = "epub.reader.highlights.empty" - tableView.backgroundView = label - } - - @objc private func filterChangedAction() { - tableView.reloadData() - updateEmptyState() - } - - private func emptyStateText() -> String { - switch filterControl.selectedSegmentIndex { - case 1: - return "暂无批注" - case 2: - return "暂无划线" - default: - return "暂无标注" - } - } - - private func detailText(for highlight: RDEPUBHighlight) -> String { - let style = styleDescription(for: highlight) - let chapter = sectionTitleProvider(highlight) - let note = normalizedNote(highlight.note) - let parts = [style, chapter, note].compactMap { $0 } - if !parts.isEmpty { - return parts.joined(separator: "\n") - } - return highlight.location.href - } - - private func titleText(for highlight: RDEPUBHighlight) -> String { - let text = highlight.text.trimmingCharacters(in: .whitespacesAndNewlines) - if highlight.hasNote { - return "批注: \(text)" - } - return text - } - - private func styleDescription(for highlight: RDEPUBHighlight) -> String { - switch highlight.style { - case .highlight: - return highlight.hasNote ? "高亮批注" : "高亮" - case .underline: - return highlight.hasNote ? "划线批注" : "划线" - } - } - - private func presentActions(for highlight: RDEPUBHighlight, sourceIndexPath: IndexPath) { - let alert = UIAlertController(title: "标注管理", message: nil, preferredStyle: .actionSheet) - alert.addAction(UIAlertAction(title: "跳转到位置", style: .default) { [weak self] _ in - self?.onSelectHighlight?(highlight) - }) - alert.addAction(UIAlertAction(title: "编辑批注", style: .default) { [weak self] _ in - self?.presentNoteEditor(for: highlight) - }) - alert.addAction(UIAlertAction(title: "删除标注", style: .destructive) { [weak self] _ in - self?.deleteHighlight(highlight) - }) - alert.addAction(UIAlertAction(title: "取消", style: .cancel)) - - if let popover = alert.popoverPresentationController, - let cell = tableView.cellForRow(at: sourceIndexPath) { - popover.sourceView = cell - popover.sourceRect = cell.bounds - } - - present(alert, animated: true) - } - - private func presentNoteEditor(for highlight: RDEPUBHighlight) { - let alert = UIAlertController(title: "编辑批注", message: nil, preferredStyle: .alert) - alert.addTextField { textField in - textField.placeholder = "输入批注内容" - textField.text = highlight.note - } - alert.addAction(UIAlertAction(title: "取消", style: .cancel)) - alert.addAction(UIAlertAction(title: "保存", style: .default) { [weak self, weak alert] _ in - guard let self, - let note = alert?.textFields?.first?.text, - let index = self.highlights.firstIndex(where: { $0.id == highlight.id }) else { - return - } - - var updated = highlight - updated.note = self.normalizedNote(note) - self.highlights[index] = updated - self.tableView.reloadData() - self.onUpdateHighlight?(updated) - self.updateEmptyState() - }) - present(alert, animated: true) - } - - private func deleteHighlight(_ highlight: RDEPUBHighlight) { - guard let index = highlights.firstIndex(where: { $0.id == highlight.id }) else { return } - highlights.remove(at: index) - tableView.reloadData() - onDeleteHighlight?(highlight) - updateEmptyState() - } - - private func normalizedNote(_ note: String?) -> String? { - let trimmed = note?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return trimmed.isEmpty ? nil : trimmed - } -} - -final class RDEPUBReaderBookmarksViewController: UITableViewController { - - var onSelectBookmark: ((RDEPUBBookmark) -> Void)? - - var onDeleteBookmark: ((RDEPUBBookmark) -> Void)? - - private var bookmarks: [RDEPUBBookmark] - - private let theme: RDEPUBReaderTheme - - private let dateFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd HH:mm" - return formatter - }() - - init(bookmarks: [RDEPUBBookmark], theme: RDEPUBReaderTheme) { - self.bookmarks = bookmarks.sorted { $0.createdAt > $1.createdAt } - self.theme = theme - super.init(style: .plain) - title = "书签" - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - view.accessibilityIdentifier = "epub.reader.bookmarks.panel" - tableView.accessibilityIdentifier = "epub.reader.bookmarks.table" - tableView.tableFooterView = UIView(frame: .zero) - tableView.separatorInset = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16) - applyTheme() - updateEmptyState() - } - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - bookmarks.count - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cellIdentifier = "BookmarkCell" - let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier) ?? UITableViewCell(style: .subtitle, reuseIdentifier: cellIdentifier) - let bookmark = bookmarks[indexPath.row] - - cell.backgroundColor = theme.contentBackgroundColor - cell.textLabel?.textColor = theme.contentTextColor - cell.textLabel?.font = UIFont.systemFont(ofSize: 15, weight: .medium) - cell.textLabel?.numberOfLines = 2 - cell.textLabel?.text = titleText(for: bookmark) - - cell.detailTextLabel?.textColor = theme.contentTextColor.withAlphaComponent(0.7) - cell.detailTextLabel?.font = UIFont.systemFont(ofSize: 12) - cell.detailTextLabel?.numberOfLines = 3 - cell.detailTextLabel?.text = detailText(for: bookmark) - cell.accessoryType = .disclosureIndicator - return cell - } - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.deselectRow(at: indexPath, animated: true) - presentActions(for: bookmarks[indexPath.row], sourceIndexPath: indexPath) - } - - private func applyTheme() { - tableView.backgroundColor = theme.contentBackgroundColor - navigationController?.navigationBar.tintColor = theme.toolControlTextColor - navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor - navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor] - navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.bookmarks.navbar" - } - - private func updateEmptyState() { - guard bookmarks.isEmpty == false else { - let label = UILabel() - label.text = "暂无书签" - label.textAlignment = .center - label.textColor = theme.contentTextColor.withAlphaComponent(0.7) - label.numberOfLines = 0 - label.accessibilityIdentifier = "epub.reader.bookmarks.empty" - tableView.backgroundView = label - return - } - tableView.backgroundView = nil - } - - private func titleText(for bookmark: RDEPUBBookmark) -> String { - if let chapterTitle = bookmark.chapterTitle, !chapterTitle.isEmpty { - return chapterTitle - } - return bookmark.location.href - } - - private func detailText(for bookmark: RDEPUBBookmark) -> String { - let parts = [ - dateFormatter.string(from: bookmark.createdAt), - bookmark.note?.trimmingCharacters(in: .whitespacesAndNewlines), - bookmark.location.href - ].compactMap { value -> String? in - guard let value, !value.isEmpty else { return nil } - return value - } - return parts.joined(separator: "\n") - } - - private func presentActions(for bookmark: RDEPUBBookmark, sourceIndexPath: IndexPath) { - let alert = UIAlertController(title: "书签管理", message: nil, preferredStyle: .actionSheet) - alert.addAction(UIAlertAction(title: "跳转到位置", style: .default) { [weak self] _ in - self?.onSelectBookmark?(bookmark) - }) - alert.addAction(UIAlertAction(title: "删除书签", style: .destructive) { [weak self] _ in - self?.deleteBookmark(bookmark) - }) - alert.addAction(UIAlertAction(title: "取消", style: .cancel)) - - if let popover = alert.popoverPresentationController, - let cell = tableView.cellForRow(at: sourceIndexPath) { - popover.sourceView = cell - popover.sourceRect = cell.bounds - } - - present(alert, animated: true) - } - - private func deleteBookmark(_ bookmark: RDEPUBBookmark) { - guard let index = bookmarks.firstIndex(where: { $0.id == bookmark.id }) else { return } - bookmarks.remove(at: index) - tableView.reloadData() - onDeleteBookmark?(bookmark) - updateEmptyState() - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderPersistence.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderPersistence.swift deleted file mode 100644 index c4e113d..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderPersistence.swift +++ /dev/null @@ -1,180 +0,0 @@ -import Foundation - -public protocol RDEPUBReaderPersistence: AnyObject { - - func loadLocation(for bookIdentifier: String) -> RDEPUBLocation? - - func saveLocation(_ location: RDEPUBLocation, for bookIdentifier: String) - - func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark] - - func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String) - - func loadHighlights(for bookIdentifier: String) -> [RDEPUBHighlight] - - func saveHighlights(_ highlights: [RDEPUBHighlight], for bookIdentifier: String) - - func loadReaderSettings() -> RDEPUBReaderSettings? - - func saveReaderSettings(_ settings: RDEPUBReaderSettings) -} - -public extension RDEPUBReaderPersistence { - func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark] { - #if DEBUG - print("[RDEPUBReaderPersistence] ⚠️ loadBookmarks called on default no-op implementation for: \(bookIdentifier)") - #endif - return [] - } - - func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String) { - #if DEBUG - print("[RDEPUBReaderPersistence] ⚠️ saveBookmarks(\(bookmarks.count) items) called on default no-op implementation for: \(bookIdentifier)") - #endif - } - - func loadReaderSettings() -> RDEPUBReaderSettings? { - #if DEBUG - print("[RDEPUBReaderPersistence] ⚠️ loadReaderSettings called on default no-op implementation") - #endif - return nil - } - - func saveReaderSettings(_ settings: RDEPUBReaderSettings) { - #if DEBUG - print("[RDEPUBReaderPersistence] ⚠️ saveReaderSettings called on default no-op implementation") - #endif - } -} - -public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence { - - private let defaults: UserDefaults - - private let locationPrefix: String - - private let bookmarksPrefix: String - - private let highlightsPrefix: String - - private let settingsKey: String - - public init( - defaults: UserDefaults = .standard, - locationPrefix: String = "ssreader.epub.location.", - bookmarksPrefix: String = "ssreader.epub.bookmarks.", - highlightsPrefix: String = "ssreader.epub.highlights.", - settingsKey: String = "ssreader.epub.settings" - ) { - self.defaults = defaults - self.locationPrefix = locationPrefix - self.bookmarksPrefix = bookmarksPrefix - self.highlightsPrefix = highlightsPrefix - self.settingsKey = settingsKey - } - - public func loadLocation(for bookIdentifier: String) -> RDEPUBLocation? { - guard let data = defaults.data(forKey: locationPrefix + bookIdentifier) else { - return nil - } - do { - return try JSONDecoder().decode(RDEPUBLocation.self, from: data) - } catch { - #if DEBUG - print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to decode location for '\(bookIdentifier)': \(error)") - #endif - return nil - } - } - - public func saveLocation(_ location: RDEPUBLocation, for bookIdentifier: String) { - do { - let data = try JSONEncoder().encode(location) - defaults.set(data, forKey: locationPrefix + bookIdentifier) - } catch { - #if DEBUG - print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to encode location for '\(bookIdentifier)': \(error)") - #endif - } - } - - public func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark] { - guard let data = defaults.data(forKey: bookmarksPrefix + bookIdentifier) else { - return [] - } - do { - return try JSONDecoder().decode([RDEPUBBookmark].self, from: data) - } catch { - #if DEBUG - print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to decode bookmarks for '\(bookIdentifier)': \(error)") - #endif - return [] - } - } - - public func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String) { - do { - let data = try JSONEncoder().encode(bookmarks) - defaults.set(data, forKey: bookmarksPrefix + bookIdentifier) - } catch { - #if DEBUG - print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to encode bookmarks for '\(bookIdentifier)': \(error)") - #endif - } - } - - public func loadHighlights(for bookIdentifier: String) -> [RDEPUBHighlight] { - guard let data = defaults.data(forKey: highlightsPrefix + bookIdentifier) else { - return [] - } - do { - return try JSONDecoder().decode([RDEPUBHighlight].self, from: data) - } catch { - #if DEBUG - print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to decode highlights for '\(bookIdentifier)': \(error)") - #endif - return [] - } - } - - public func saveHighlights(_ highlights: [RDEPUBHighlight], for bookIdentifier: String) { - do { - let data = try JSONEncoder().encode(highlights) - if data.count > 1_048_576 { - #if DEBUG - print("[RDEPUBUserDefaultsPersistence] ⚠️ saveHighlights data size (\(data.count) bytes) exceeds 1MB for: \(bookIdentifier)") - #endif - } - defaults.set(data, forKey: highlightsPrefix + bookIdentifier) - } catch { - #if DEBUG - print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to encode highlights for '\(bookIdentifier)': \(error)") - #endif - } - } - - public func loadReaderSettings() -> RDEPUBReaderSettings? { - guard let data = defaults.data(forKey: settingsKey) else { - return nil - } - do { - return try JSONDecoder().decode(RDEPUBReaderSettings.self, from: data) - } catch { - #if DEBUG - print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to decode reader settings: \(error)") - #endif - return nil - } - } - - public func saveReaderSettings(_ settings: RDEPUBReaderSettings) { - do { - let data = try JSONEncoder().encode(settings) - defaults.set(data, forKey: settingsKey) - } catch { - #if DEBUG - print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to encode reader settings: \(error)") - #endif - } - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderSearchBarView.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderSearchBarView.swift deleted file mode 100644 index 3ac1c21..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderSearchBarView.swift +++ /dev/null @@ -1,634 +0,0 @@ -import UIKit - -struct RDEPUBReaderSearchSection: Equatable { - - struct Item: Equatable { - - let matchIndex: Int - - let previewText: String - - let isCurrent: Bool - } - - let title: String - - let items: [Item] -} - -final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView { - - var onSearchSubmit: ((String) -> Void)? - - var onSearchTextChanged: ((String) -> Void)? - - var onSearchPrevious: (() -> Void)? - - var onSearchNext: (() -> Void)? - - var onSelectMatch: ((Int) -> Void)? - - var onClose: (() -> Void)? - - let textField: UITextField = { - let field = UITextField() - field.placeholder = "搜索" - field.font = UIFont.systemFont(ofSize: 18, weight: .medium) - field.returnKeyType = .search - field.autocorrectionType = .no - field.autocapitalizationType = .none - field.clearButtonMode = .whileEditing - field.isAccessibilityElement = true - if #available(iOS 13.0, *) { - field.accessibilityTraits = .searchField - } - return field - }() - - private let backgroundButton = UIButton(type: .custom) - - let panelView = UIView() - - private let grabberView = UIView() - - private let searchRowView = UIView() - - private let searchFieldContainer = UIView() - - private let searchIcon = UIImageView() - - private let searchFieldDivider = UIView() - - private let cancelButton = UIButton(type: .system) - - private let tableView = UITableView(frame: .zero, style: .plain) - - private let emptyStateLabel = UILabel() - - private let previousButton = RDEPUBReaderTintButton(type: .system) - - private let nextButton = RDEPUBReaderTintButton(type: .system) - - private let countLabel = UILabel() - - private var searchSections: [RDEPUBReaderSearchSection] = [] - - private var keyword = "" - - private var currentMatchIndex: Int? - - override init(frame: CGRect) { - super.init(frame: frame) - accessibilityIdentifier = "epub.reader.search.bar" - shouldGroupAccessibilityChildren = false - isAccessibilityElement = false - setupSubviews() - setupConstraints() - setupActions() - updateLegacyNavigationEnabled(false) - showInitialState() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { - // Only claim touches that fall within the panelView or the - // accessibility-only navigation buttons. Taps on the dimmed scrim - // area pass through to the reader content below, allowing chrome - // toggle taps to work. - let panelPoint = convert(point, to: panelView) - if panelView.point(inside: panelPoint, with: event) { - return super.hitTest(point, with: event) - } - // Also claim touches on the accessibility-only navigation buttons - if previousButton.frame.contains(point) - || nextButton.frame.contains(point) - || countLabel.frame.contains(point) { - return super.hitTest(point, with: event) - } - return nil - } - - override func lineFrame(in bounds: CGRect) -> CGRect { - .zero - } - - override func apply(theme: RDEPUBReaderTheme) { - super.apply(theme: theme) - let isDarkBackground = theme.contentBackgroundColor.rd_isDarkBackground - - let overlayColor = isDarkBackground - ? UIColor(white: 0.12, alpha: 0.92) - : UIColor(white: 0.08, alpha: 0.82) - let panelColor = isDarkBackground - ? UIColor(red: 0.18, green: 0.18, blue: 0.19, alpha: 1) - : UIColor(red: 0.15, green: 0.15, blue: 0.16, alpha: 1) - let rowColor = isDarkBackground - ? UIColor(white: 0.18, alpha: 1) - : UIColor(white: 0.14, alpha: 0.96) - let cardColor = isDarkBackground - ? UIColor(white: 0.12, alpha: 1) - : UIColor(white: 0.10, alpha: 0.98) - let activeCardColor = UIColor(red: 0.17, green: 0.28, blue: 0.38, alpha: 1) - let textColor = UIColor(white: 0.96, alpha: 1) - let secondaryTextColor = UIColor(white: 0.72, alpha: 1) - - backgroundColor = .clear - backgroundButton.backgroundColor = overlayColor - panelView.backgroundColor = panelColor - grabberView.backgroundColor = UIColor(white: 0.75, alpha: 0.7) - searchRowView.backgroundColor = rowColor - searchFieldContainer.backgroundColor = .clear - searchFieldDivider.backgroundColor = UIColor(white: 1, alpha: 0.12) - searchIcon.tintColor = secondaryTextColor - cancelButton.tintColor = textColor - cancelButton.setTitleColor(textColor, for: .normal) - textField.textColor = textColor - textField.tintColor = UIColor.systemBlue - textField.keyboardAppearance = isDarkBackground ? .dark : .default - textField.attributedPlaceholder = NSAttributedString( - string: "搜索", - attributes: [.foregroundColor: secondaryTextColor] - ) - - emptyStateLabel.textColor = secondaryTextColor - tableView.backgroundColor = .clear - tableView.separatorStyle = .none - - previousButton.tintColor = textColor - nextButton.tintColor = textColor - countLabel.textColor = textColor - countLabel.backgroundColor = .clear - - RDEPUBReaderSearchResultCell.cardBackgroundColor = cardColor - RDEPUBReaderSearchResultCell.activeCardBackgroundColor = activeCardColor - RDEPUBReaderSearchResultCell.primaryTextColor = textColor - RDEPUBReaderSearchResultCell.highlightTextColor = UIColor.systemBlue - RDEPUBReaderSearchResultCell.activeHighlightTextColor = UIColor(red: 0.40, green: 0.77, blue: 1, alpha: 1) - - tableView.reloadData() - } - - var presentedView: UIView { - panelView - } - - func updateMatchCount(current: Int, total: Int) { - countLabel.text = "\(current)/\(total)" - textField.accessibilityValue = "\(current)/\(total)" - updateLegacyNavigationEnabled(total > 0) - } - - func showNoResults() { - currentMatchIndex = nil - updateMatchCount(current: 0, total: 0) - tableView.isHidden = true - emptyStateLabel.isHidden = false - emptyStateLabel.text = keyword.isEmpty ? "输入关键词开始搜索" : "未找到相关内容" - } - - func showSearching() { - updateMatchCount(current: 0, total: 0) - tableView.isHidden = true - emptyStateLabel.isHidden = false - emptyStateLabel.text = "搜索中..." - } - - func restoreKeyword(_ keyword: String) { - textField.text = keyword - self.keyword = keyword - } - - func updateResults( - sections: [RDEPUBReaderSearchSection], - keyword: String, - currentMatchIndex: Int? - ) { - self.keyword = keyword - self.searchSections = sections - self.currentMatchIndex = currentMatchIndex - - let total = sections.reduce(0) { $0 + $1.items.count } - if let currentMatchIndex, total > 0 { - updateMatchCount(current: currentMatchIndex + 1, total: total) - } else { - updateMatchCount(current: 0, total: total) - } - - if total == 0 { - showNoResults() - return - } - - emptyStateLabel.isHidden = true - tableView.isHidden = false - tableView.reloadData() - scrollToCurrentMatchIfNeeded() - } - - private func setupSubviews() { - backgroundButton.translatesAutoresizingMaskIntoConstraints = false - panelView.translatesAutoresizingMaskIntoConstraints = false - grabberView.translatesAutoresizingMaskIntoConstraints = false - searchRowView.translatesAutoresizingMaskIntoConstraints = false - searchFieldContainer.translatesAutoresizingMaskIntoConstraints = false - searchIcon.translatesAutoresizingMaskIntoConstraints = false - textField.translatesAutoresizingMaskIntoConstraints = false - searchFieldDivider.translatesAutoresizingMaskIntoConstraints = false - cancelButton.translatesAutoresizingMaskIntoConstraints = false - tableView.translatesAutoresizingMaskIntoConstraints = false - emptyStateLabel.translatesAutoresizingMaskIntoConstraints = false - previousButton.translatesAutoresizingMaskIntoConstraints = false - nextButton.translatesAutoresizingMaskIntoConstraints = false - countLabel.translatesAutoresizingMaskIntoConstraints = false - - addSubview(backgroundButton) - addSubview(panelView) - - panelView.addSubview(grabberView) - panelView.addSubview(searchRowView) - panelView.addSubview(tableView) - panelView.addSubview(emptyStateLabel) - - searchRowView.addSubview(searchFieldContainer) - searchRowView.addSubview(searchFieldDivider) - searchRowView.addSubview(cancelButton) - searchFieldContainer.addSubview(searchIcon) - searchFieldContainer.addSubview(textField) - - addSubview(previousButton) - addSubview(nextButton) - addSubview(countLabel) - - if #available(iOS 13.0, *) { - searchIcon.image = UIImage(systemName: "magnifyingglass") - previousButton.setImage(UIImage(systemName: "chevron.up"), for: .normal) - nextButton.setImage(UIImage(systemName: "chevron.down"), for: .normal) - } else { - previousButton.setTitle("▲", for: .normal) - nextButton.setTitle("▼", for: .normal) - } - - searchIcon.contentMode = .scaleAspectFit - searchIcon.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 22, weight: .regular) - - panelView.layer.cornerRadius = 28 - panelView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] - panelView.clipsToBounds = true - - grabberView.layer.cornerRadius = 3 - searchRowView.layer.cornerRadius = 22 - searchFieldContainer.layer.cornerRadius = 22 - searchRowView.clipsToBounds = true - - cancelButton.setTitle("取消", for: .normal) - cancelButton.titleLabel?.font = UIFont.systemFont(ofSize: 18, weight: .medium) - cancelButton.accessibilityIdentifier = "epub.reader.search.close" - - emptyStateLabel.font = UIFont.systemFont(ofSize: 17, weight: .medium) - emptyStateLabel.textAlignment = .center - emptyStateLabel.numberOfLines = 0 - - previousButton.accessibilityIdentifier = "epub.reader.search.previous" - nextButton.accessibilityIdentifier = "epub.reader.search.next" - countLabel.accessibilityIdentifier = "epub.reader.search.count" - textField.accessibilityIdentifier = "epub.reader.search.field" - // 0.02 而非 0.01:CALayer.opacity 为 Float32,0.01 落盘后略小于 - // 0.01,会被 UIKit 命中测试按「透明视图」剔除,导致按钮永远点不中。 - previousButton.alpha = 0.02 - nextButton.alpha = 0.02 - countLabel.alpha = 0.02 - - tableView.register(RDEPUBReaderSearchResultCell.self, forCellReuseIdentifier: RDEPUBReaderSearchResultCell.reuseIdentifier) - tableView.dataSource = self - tableView.delegate = self - tableView.showsVerticalScrollIndicator = false - tableView.contentInset = UIEdgeInsets(top: 4, left: 0, bottom: 16, right: 0) - } - - private func setupConstraints() { - NSLayoutConstraint.activate([ - backgroundButton.leadingAnchor.constraint(equalTo: leadingAnchor), - backgroundButton.trailingAnchor.constraint(equalTo: trailingAnchor), - backgroundButton.topAnchor.constraint(equalTo: topAnchor), - backgroundButton.bottomAnchor.constraint(equalTo: bottomAnchor), - - panelView.leadingAnchor.constraint(equalTo: leadingAnchor), - panelView.trailingAnchor.constraint(equalTo: trailingAnchor), - panelView.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 8), - panelView.bottomAnchor.constraint(equalTo: bottomAnchor), - - grabberView.topAnchor.constraint(equalTo: panelView.topAnchor, constant: 10), - grabberView.centerXAnchor.constraint(equalTo: panelView.centerXAnchor), - grabberView.widthAnchor.constraint(equalToConstant: 92), - grabberView.heightAnchor.constraint(equalToConstant: 6), - - searchRowView.leadingAnchor.constraint(equalTo: panelView.leadingAnchor, constant: 20), - searchRowView.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: -20), - searchRowView.topAnchor.constraint(equalTo: grabberView.bottomAnchor, constant: 18), - searchRowView.heightAnchor.constraint(equalToConstant: 52), - - searchFieldContainer.leadingAnchor.constraint(equalTo: searchRowView.leadingAnchor, constant: 12), - searchFieldContainer.topAnchor.constraint(equalTo: searchRowView.topAnchor), - searchFieldContainer.bottomAnchor.constraint(equalTo: searchRowView.bottomAnchor), - - searchIcon.leadingAnchor.constraint(equalTo: searchFieldContainer.leadingAnchor, constant: 10), - searchIcon.centerYAnchor.constraint(equalTo: searchFieldContainer.centerYAnchor), - searchIcon.widthAnchor.constraint(equalToConstant: 24), - searchIcon.heightAnchor.constraint(equalToConstant: 24), - - textField.leadingAnchor.constraint(equalTo: searchIcon.trailingAnchor, constant: 10), - textField.trailingAnchor.constraint(equalTo: searchFieldContainer.trailingAnchor, constant: -10), - textField.topAnchor.constraint(equalTo: searchFieldContainer.topAnchor), - textField.bottomAnchor.constraint(equalTo: searchFieldContainer.bottomAnchor), - - searchFieldDivider.leadingAnchor.constraint(equalTo: searchFieldContainer.trailingAnchor, constant: 12), - searchFieldDivider.centerYAnchor.constraint(equalTo: searchRowView.centerYAnchor), - searchFieldDivider.widthAnchor.constraint(equalToConstant: 1), - searchFieldDivider.heightAnchor.constraint(equalToConstant: 28), - - cancelButton.leadingAnchor.constraint(equalTo: searchFieldDivider.trailingAnchor, constant: 18), - cancelButton.trailingAnchor.constraint(equalTo: searchRowView.trailingAnchor, constant: -18), - cancelButton.centerYAnchor.constraint(equalTo: searchRowView.centerYAnchor), - - tableView.leadingAnchor.constraint(equalTo: panelView.leadingAnchor, constant: 0), - tableView.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: 0), - tableView.topAnchor.constraint(equalTo: searchRowView.bottomAnchor, constant: 18), - tableView.bottomAnchor.constraint(equalTo: panelView.safeAreaLayoutGuide.bottomAnchor), - - emptyStateLabel.leadingAnchor.constraint(equalTo: panelView.leadingAnchor, constant: 32), - emptyStateLabel.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: -32), - emptyStateLabel.topAnchor.constraint(equalTo: searchRowView.bottomAnchor, constant: 56), - - // 无障碍/UI 测试专用的隐形控件:必须放在 panel 顶部而非视图 - // 左上角——视图左上角落在系统状态栏区域内,合成点击会被状态栏 - // 窗口拦截,永远到不了这些按钮。 - previousButton.topAnchor.constraint(equalTo: panelView.topAnchor), - previousButton.leadingAnchor.constraint(equalTo: leadingAnchor), - previousButton.widthAnchor.constraint(equalToConstant: 1), - previousButton.heightAnchor.constraint(equalToConstant: 1), - - nextButton.topAnchor.constraint(equalTo: panelView.topAnchor), - nextButton.leadingAnchor.constraint(equalTo: previousButton.trailingAnchor), - nextButton.widthAnchor.constraint(equalToConstant: 1), - nextButton.heightAnchor.constraint(equalToConstant: 1), - - countLabel.topAnchor.constraint(equalTo: panelView.topAnchor), - countLabel.leadingAnchor.constraint(equalTo: nextButton.trailingAnchor), - countLabel.widthAnchor.constraint(equalToConstant: 1), - countLabel.heightAnchor.constraint(equalToConstant: 1) - ]) - } - - private func setupActions() { - textField.delegate = self - textField.addTarget(self, action: #selector(textFieldDidReturn), for: .editingDidEndOnExit) - textField.addTarget(self, action: #selector(textFieldDidChange), for: .editingChanged) - previousButton.addTarget(self, action: #selector(previousAction), for: .touchUpInside) - nextButton.addTarget(self, action: #selector(nextAction), for: .touchUpInside) - cancelButton.addTarget(self, action: #selector(closeAction), for: .touchUpInside) - backgroundButton.addTarget(self, action: #selector(closeAction), for: .touchUpInside) - } - - private func updateLegacyNavigationEnabled(_ enabled: Bool) { - previousButton.isEnabled = enabled - nextButton.isEnabled = enabled - } - - private func showInitialState() { - tableView.isHidden = true - emptyStateLabel.isHidden = false - emptyStateLabel.text = "输入关键词开始搜索" - } - - private func scrollToCurrentMatchIfNeeded() { - guard let currentMatchIndex else { return } - for (sectionIndex, section) in searchSections.enumerated() { - if let rowIndex = section.items.firstIndex(where: { $0.matchIndex == currentMatchIndex }) { - let indexPath = IndexPath(row: rowIndex, section: sectionIndex) - DispatchQueue.main.async { [weak self] in - self?.tableView.scrollToRow(at: indexPath, at: .middle, animated: false) - } - return - } - } - } - - private func item(at indexPath: IndexPath) -> RDEPUBReaderSearchSection.Item { - searchSections[indexPath.section].items[indexPath.row] - } - - @objc private func textFieldDidReturn() { - let keyword = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !keyword.isEmpty else { return } - onSearchSubmit?(keyword) - textField.resignFirstResponder() - } - - @objc private func textFieldDidChange() { - guard textField.markedTextRange == nil else { return } - onSearchTextChanged?(textField.text ?? "") - } - - @objc private func previousAction() { - onSearchPrevious?() - } - - @objc private func nextAction() { - onSearchNext?() - } - - @objc private func closeAction() { - onClose?() - } -} - -extension RDEPUBReaderSearchBarView: UITableViewDataSource, UITableViewDelegate { - - func numberOfSections(in tableView: UITableView) -> Int { - searchSections.count - } - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - searchSections[section].items.count - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell( - withIdentifier: RDEPUBReaderSearchResultCell.reuseIdentifier, - for: indexPath - ) - - guard let cell = cell as? RDEPUBReaderSearchResultCell else { - return cell - } - - let item = item(at: indexPath) - cell.configure( - previewText: item.previewText, - keyword: keyword, - isCurrent: item.isCurrent - ) - cell.accessibilityIdentifier = "epub.reader.search.result.\(item.matchIndex)" - return cell - } - - func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - let container = UIView() - let label = UILabel() - label.translatesAutoresizingMaskIntoConstraints = false - label.font = UIFont.systemFont(ofSize: 19, weight: .bold) - label.textColor = UIColor(white: 0.96, alpha: 1) - label.text = searchSections[section].title - label.numberOfLines = 2 - container.addSubview(label) - - NSLayoutConstraint.activate([ - label.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 20), - label.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -20), - label.topAnchor.constraint(equalTo: container.topAnchor, constant: 4), - label.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -4) - ]) - return container - } - - func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - 40 - } - - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - UITableView.automaticDimension - } - - func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { - 116 - } - - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.deselectRow(at: indexPath, animated: true) - onSelectMatch?(item(at: indexPath).matchIndex) - } -} - -extension RDEPUBReaderSearchBarView: UITextFieldDelegate { - - func textFieldShouldClear(_ textField: UITextField) -> Bool { - DispatchQueue.main.async { [weak self] in - self?.onSearchTextChanged?("") - } - return true - } -} - -private final class RDEPUBReaderSearchResultCell: UITableViewCell { - - static let reuseIdentifier = "RDEPUBReaderSearchResultCell" - - static var cardBackgroundColor = UIColor(white: 0.12, alpha: 1) - - static var activeCardBackgroundColor = UIColor(red: 0.17, green: 0.28, blue: 0.38, alpha: 1) - - static var primaryTextColor = UIColor(white: 0.96, alpha: 1) - - static var highlightTextColor = UIColor.systemBlue - - static var activeHighlightTextColor = UIColor(red: 0.40, green: 0.77, blue: 1, alpha: 1) - - private let cardView = UIView() - - private let previewLabel = UILabel() - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - setupSubviews() - setupConstraints() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func prepareForReuse() { - super.prepareForReuse() - previewLabel.attributedText = nil - } - - func configure(previewText: String, keyword: String, isCurrent: Bool) { - selectionStyle = .none - backgroundColor = .clear - contentView.backgroundColor = .clear - cardView.backgroundColor = isCurrent ? Self.activeCardBackgroundColor : Self.cardBackgroundColor - previewLabel.attributedText = attributedPreviewText( - previewText, - keyword: keyword, - isCurrent: isCurrent - ) - } - - private func setupSubviews() { - cardView.translatesAutoresizingMaskIntoConstraints = false - previewLabel.translatesAutoresizingMaskIntoConstraints = false - - contentView.addSubview(cardView) - cardView.addSubview(previewLabel) - - cardView.layer.cornerRadius = 16 - cardView.clipsToBounds = true - - previewLabel.numberOfLines = 0 - previewLabel.font = UIFont.systemFont(ofSize: 18, weight: .regular) - } - - private func setupConstraints() { - NSLayoutConstraint.activate([ - cardView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), - cardView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), - cardView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10), - cardView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10), - - previewLabel.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 16), - previewLabel.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -16), - previewLabel.topAnchor.constraint(equalTo: cardView.topAnchor, constant: 16), - previewLabel.bottomAnchor.constraint(equalTo: cardView.bottomAnchor, constant: -16) - ]) - } - - private func attributedPreviewText(_ previewText: String, keyword: String, isCurrent: Bool) -> NSAttributedString { - let normalizedText = previewText - .replacingOccurrences(of: "\n", with: " ") - .replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) - .trimmingCharacters(in: .whitespacesAndNewlines) - - let paragraphStyle = NSMutableParagraphStyle() - paragraphStyle.lineSpacing = 8 - - let attributed = NSMutableAttributedString( - string: normalizedText, - attributes: [ - .font: UIFont.systemFont(ofSize: 18, weight: .regular), - .foregroundColor: Self.primaryTextColor, - .paragraphStyle: paragraphStyle - ] - ) - - let searchKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines) - guard !searchKeyword.isEmpty else { return attributed } - - let nsText = normalizedText as NSString - var searchRange = NSRange(location: 0, length: nsText.length) - let highlightColor = isCurrent ? Self.activeHighlightTextColor : Self.highlightTextColor - - while searchRange.length > 0 { - let foundRange = nsText.range(of: searchKeyword, options: [.caseInsensitive], range: searchRange) - guard foundRange.location != NSNotFound else { break } - attributed.addAttribute(.foregroundColor, value: highlightColor, range: foundRange) - let nextLocation = foundRange.location + max(foundRange.length, 1) - guard nextLocation < nsText.length else { break } - searchRange = NSRange(location: nextLocation, length: nsText.length - nextLocation) - } - - return attributed - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderTableOfContentsItem.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderTableOfContentsItem.swift deleted file mode 100644 index 7f22a2d..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderTableOfContentsItem.swift +++ /dev/null @@ -1,19 +0,0 @@ -import Foundation - -public struct RDEPUBReaderTableOfContentsItem: Equatable { - - public var title: String - - public var href: String - - public var depth: Int - - public var pageNumber: Int? - - public init(title: String, href: String, depth: Int, pageNumber: Int? = nil) { - self.title = title - self.href = href - self.depth = depth - self.pageNumber = pageNumber - } -} \ No newline at end of file diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderToolView.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderToolView.swift deleted file mode 100644 index 9189cbb..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderToolView.swift +++ /dev/null @@ -1,35 +0,0 @@ -import UIKit - -open class RDEPUBReaderToolView: UIView { - - private let lineView = UIView() - - private let lineHeight: CGFloat = 0.5 - - override init(frame: CGRect) { - super.init(frame: frame) - translatesAutoresizingMaskIntoConstraints = false - addSubview(lineView) - } - - public required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - open override func layoutSubviews() { - super.layoutSubviews() - lineView.frame = lineFrame(in: bounds) - } - - open func apply(theme: RDEPUBReaderTheme) { - backgroundColor = .white - lineView.backgroundColor = UIColor.lightGray.withAlphaComponent(0.5) - } - - open func lineFrame(in bounds: CGRect) -> CGRect { - CGRect(x: 0, y: 0, width: bounds.width, height: lineHeight) - } -} - -final class RDEPUBReaderTintButton: UIButton { -} \ No newline at end of file diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderToolViewProtocols.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderToolViewProtocols.swift deleted file mode 100644 index 25ba54a..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderToolViewProtocols.swift +++ /dev/null @@ -1,69 +0,0 @@ -import UIKit - -/// 顶部工具栏协议:宿主可提供自定义顶栏,只需实现以下回调与状态方法。 -/// 内置 `RDEPUBReaderTopToolView` 已实现本协议。 -/// 通过 `RDEPUBReaderDependencies.makeTopToolView` 工厂注入自定义实现。 -public protocol RDEPUBReaderTopToolViewProtocol: AnyObject { - - /// 返回 / 关闭 - var onBack: (() -> Void)? { get set } - - /// 搜索 - var onSearch: (() -> Void)? { get set } - - /// 切换书签 - var onToggleBookmark: (() -> Void)? { get set } - - /// 应用主题 - func apply(theme: RDEPUBReaderTheme) - - /// 设置标题 - func setTitle(_ title: String?) - - /// 书签按钮是否可用 - func setBookmarkEnabled(_ isEnabled: Bool) - - /// 书签按钮选中态(当前页是否已加书签) - func setBookmarkSelected(_ isSelected: Bool) -} - -/// 底部工具栏协议:宿主可提供自定义底栏。内置 `RDEPUBReaderBottomToolView` 已实现本协议。 -/// 通过 `RDEPUBReaderDependencies.makeBottomToolView` 工厂注入自定义实现。 -public protocol RDEPUBReaderBottomToolViewProtocol: AnyObject { - - /// 目录 - var onShowTableOfContents: (() -> Void)? { get set } - - /// 书签列表 - var onShowBookmarks: (() -> Void)? { get set } - - /// 高亮列表 - var onShowHighlights: (() -> Void)? { get set } - - /// 新增高亮 / 标注 - var onAddHighlight: (() -> Void)? { get set } - - /// 设置面板 - var onShowSettings: (() -> Void)? { get set } - - /// 应用主题 - func apply(theme: RDEPUBReaderTheme) - - /// 按配置更新各入口可见性 - func updateVisibility(showsTableOfContents: Bool, allowsHighlights: Bool, showsSettingsPanel: Bool) - - /// 书签列表按钮是否可用 - func setBookmarksEnabled(_ isEnabled: Bool) - - /// 新增高亮按钮是否可用(有选区时) - func setAddHighlightEnabled(_ isEnabled: Bool) - - /// 高亮列表按钮是否可用 - func setHighlightsEnabled(_ isEnabled: Bool) -} - -/// 顶部工具栏具体类型:UIView 且实现顶栏协议 -public typealias RDEPUBReaderTopToolViewProviding = UIView & RDEPUBReaderTopToolViewProtocol - -/// 底部工具栏具体类型:UIView 且实现底栏协议 -public typealias RDEPUBReaderBottomToolViewProviding = UIView & RDEPUBReaderBottomToolViewProtocol diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderTopToolView.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderTopToolView.swift deleted file mode 100644 index 28ddb84..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderTopToolView.swift +++ /dev/null @@ -1,136 +0,0 @@ -import UIKit - -public final class RDEPUBReaderTopToolView: RDEPUBReaderToolView, RDEPUBReaderTopToolViewProtocol { - - public var onBack: (() -> Void)? - - public var onToggleBookmark: (() -> Void)? - - public var onSearch: (() -> Void)? - - private let backButton = RDEPUBReaderTintButton(type: .system) - private let searchButton = RDEPUBReaderTintButton(type: .system) - private let bookmarkButton = RDEPUBReaderTintButton(type: .system) - private let titleLabel: UILabel = { - let label = UILabel() - label.textAlignment = .center - label.font = UIFont.systemFont(ofSize: 16, weight: .semibold) - label.numberOfLines = 1 - return label - }() - private var isBookmarked = false - - override init(frame: CGRect) { - super.init(frame: frame) - accessibilityIdentifier = "epub.reader.topToolbar" - self.backgroundColor = .white - addSubview(backButton) - addSubview(searchButton) - addSubview(bookmarkButton) - addSubview(titleLabel) - - backButton.translatesAutoresizingMaskIntoConstraints = false - searchButton.translatesAutoresizingMaskIntoConstraints = false - bookmarkButton.translatesAutoresizingMaskIntoConstraints = false - titleLabel.translatesAutoresizingMaskIntoConstraints = false - - backButton.addTarget(self, action: #selector(backAction), for: .touchUpInside) - searchButton.addTarget(self, action: #selector(searchAction), for: .touchUpInside) - bookmarkButton.addTarget(self, action: #selector(bookmarkAction), for: .touchUpInside) - - NSLayoutConstraint.activate([ - backButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8), - backButton.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 4), - backButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4), - backButton.widthAnchor.constraint(equalToConstant: 44), - backButton.heightAnchor.constraint(equalToConstant: 44), - - bookmarkButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), - bookmarkButton.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 4), - bookmarkButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4), - bookmarkButton.widthAnchor.constraint(equalToConstant: 44), - bookmarkButton.heightAnchor.constraint(equalToConstant: 44), - - searchButton.trailingAnchor.constraint(equalTo: bookmarkButton.leadingAnchor, constant: -4), - searchButton.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 4), - searchButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4), - searchButton.widthAnchor.constraint(equalToConstant: 44), - searchButton.heightAnchor.constraint(equalToConstant: 44), - - titleLabel.leadingAnchor.constraint(equalTo: backButton.trailingAnchor, constant: 8), - titleLabel.trailingAnchor.constraint(equalTo: searchButton.leadingAnchor, constant: -8), - titleLabel.centerYAnchor.constraint(equalTo: backButton.centerYAnchor) - ]) - - if #available(iOS 13.0, *) { - backButton.setImage(UIImage(systemName: "chevron.left")?.withRenderingMode(.alwaysTemplate), for: .normal) - searchButton.setImage(UIImage(systemName: "magnifyingglass")?.withRenderingMode(.alwaysTemplate), for: .normal) - } else { - backButton.setTitle("返回", for: .normal) - searchButton.setTitle("搜索", for: .normal) - } - backButton.accessibilityIdentifier = "epub.reader.back" - searchButton.accessibilityIdentifier = "epub.reader.search" - bookmarkButton.accessibilityIdentifier = "epub.reader.bookmark" - titleLabel.accessibilityIdentifier = "epub.reader.title" - updateBookmarkButtonAppearance() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override public func lineFrame(in bounds: CGRect) -> CGRect { - CGRect(x: 0, y: bounds.height - 0.5, width: bounds.width, height: 0.5) - } - - override public func apply(theme: RDEPUBReaderTheme) { - super.apply(theme: theme) - titleLabel.textColor = theme.toolControlTextColor - backButton.tintColor = theme.toolControlTextColor - searchButton.tintColor = theme.toolControlTextColor - bookmarkButton.tintColor = theme.toolControlTextColor - if #unavailable(iOS 13.0) { - backButton.setTitleColor(theme.toolControlTextColor, for: .normal) - searchButton.setTitleColor(theme.toolControlTextColor, for: .normal) - bookmarkButton.setTitleColor(theme.toolControlTextColor, for: .normal) - } - updateBookmarkButtonAppearance() - } - - public func setTitle(_ title: String?) { - titleLabel.text = title - } - - public func setBookmarkSelected(_ isSelected: Bool) { - isBookmarked = isSelected - updateBookmarkButtonAppearance() - } - - public func setBookmarkEnabled(_ isEnabled: Bool) { - bookmarkButton.isEnabled = isEnabled - bookmarkButton.alpha = isEnabled ? 1 : 0.45 - } - - @objc private func backAction() { - onBack?() - } - - @objc private func searchAction() { - onSearch?() - } - - @objc private func bookmarkAction() { - onToggleBookmark?() - } - - private func updateBookmarkButtonAppearance() { - if #available(iOS 13.0, *) { - let imageName = isBookmarked ? "bookmark.fill" : "bookmark" - bookmarkButton.setImage(UIImage(systemName: imageName)?.withRenderingMode(.alwaysTemplate), for: .normal) - } else { - bookmarkButton.setTitle(isBookmarked ? "已签" : "书签", for: .normal) - } - bookmarkButton.accessibilityValue = isBookmarked ? "selected" : "unselected" - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBSettingsFlipAutomation.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBSettingsFlipAutomation.swift deleted file mode 100644 index 8d72dff..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBSettingsFlipAutomation.swift +++ /dev/null @@ -1,84 +0,0 @@ -import UIKit - -/// Debug-only automation (`--demo-settings-flip`) that replays the user -/// gesture sequence suspected of producing stale page tables: open the -/// settings panel, change the line height, close the panel, flip pages — -/// with several timing variants aimed at the preview-repagination and -/// in-flight chapter build races. Combine with -/// `--demo-pagination-validate` to detect any resulting metric mismatch. -enum RDEPUBSettingsFlipAutomation { - - static let isEnabled = ProcessInfo.processInfo.arguments.contains("--demo-settings-flip") - - private(set) static var hasStarted = false - - static func startIfNeeded(controller: RDEPUBReaderController) { - guard isEnabled, !hasStarted else { return } - hasStarted = true - print("[SETTINGS-FLIP] scheduled") - - // Pass 1 starts while progressive pagination of the freshly opened - // book is still running, so prefetch builds are in flight. - run(after: 2.0) { [weak controller] in - guard let controller else { return } - pass(controller: controller, index: 1, lineHeight: 1.8, changeToCloseDelay: 1.2) { - pass(controller: controller, index: 2, lineHeight: 1.6, changeToCloseDelay: 0.05) { - pass(controller: controller, index: 3, lineHeight: 1.8, changeToCloseDelay: 0.3) { - print("[SETTINGS-FLIP] finished all passes") - } - } - } - } - } - - /// One panel round-trip: present → change line height → close after - /// `changeToCloseDelay` → flip pages forward and back. - private static func pass( - controller: RDEPUBReaderController, - index: Int, - lineHeight: CGFloat, - changeToCloseDelay: TimeInterval, - completion: @escaping () -> Void - ) { - print("[SETTINGS-FLIP] pass \(index) present panel") - controller.presentSettings() - - run(after: 0.7) { [weak controller] in - guard let controller else { return } - print("[SETTINGS-FLIP] pass \(index) set lineHeightMultiple=\(lineHeight)") - controller.updateConfiguration { $0.lineHeightMultiple = lineHeight } - - run(after: changeToCloseDelay) { [weak controller] in - guard let controller else { return } - print("[SETTINGS-FLIP] pass \(index) close panel") - controller.dismiss(animated: true) { [weak controller] in - controller?.runtime.settingsPanelDidDisappear() - run(after: 1.0) { [weak controller] in - guard let controller else { return } - flipPages(controller: controller, index: index, completion: completion) - } - } - } - } - } - - private static func flipPages( - controller: RDEPUBReaderController, - index: Int, - completion: @escaping () -> Void - ) { - let startPage = controller.readerView.currentPage + 1 - let offsets = [1, 2, 3, 2, 1, 0] - print("[SETTINGS-FLIP] pass \(index) flip pages from \(startPage)") - for (step, offset) in offsets.enumerated() { - run(after: 0.6 * Double(step + 1)) { [weak controller] in - _ = controller?.go(toPageNumber: startPage + offset, animated: false) - } - } - run(after: 0.6 * Double(offsets.count + 1) + 0.5, block: completion) - } - - private static func run(after delay: TimeInterval, block: @escaping () -> Void) { - DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: block) - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBViewportTypes.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBViewportTypes.swift deleted file mode 100644 index 2778689..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBViewportTypes.swift +++ /dev/null @@ -1,34 +0,0 @@ -import UIKit - -struct RDEPUBViewportSignature: Equatable { - - let width: CGFloat - - let height: CGFloat - - let safeTop: CGFloat - - let safeLeft: CGFloat - - let safeBottom: CGFloat - - let safeRight: CGFloat - - func differsSignificantly(from other: RDEPUBViewportSignature, threshold: CGFloat = 1) -> Bool { - abs(width - other.width) > threshold || - abs(height - other.height) > threshold || - abs(safeTop - other.safeTop) > threshold || - abs(safeLeft - other.safeLeft) > threshold || - abs(safeBottom - other.safeBottom) > threshold || - abs(safeRight - other.safeRight) > threshold - } -} - -enum RDEPUBViewportChangeReason { - - case viewLayout - - case orientationTransition -} - -typealias RDEPUBNativeTextSnapshot = (pages: [EPUBPage], chapters: [EPUBChapterInfo]) diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBWebContentView.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBWebContentView.swift deleted file mode 100644 index 241c514..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBWebContentView.swift +++ /dev/null @@ -1,135 +0,0 @@ -import UIKit - -protocol RDEPUBWebContentViewDelegate: AnyObject { - - func epubWebContentView(_ contentView: RDEPUBWebContentView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int) - - func epubWebContentView(_ contentView: RDEPUBWebContentView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int) - - func epubWebContentView(_ contentView: RDEPUBWebContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) - - func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int) - - func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateExternalLink url: URL) - - func epubWebContentView(_ contentView: RDEPUBWebContentView, didLogJavaScriptError message: String) - - func epubWebContentView(_ contentView: RDEPUBWebContentView, didTapImageWithSource src: String, sourceRect: CGRect?) - - func epubWebContentView(_ contentView: RDEPUBWebContentView, didTapFootnoteWithAltText altText: String, sourceRect: CGRect?) -} - -final class RDEPUBWebContentView: UIView { - weak var delegate: RDEPUBWebContentViewDelegate? - - /// The current href of the loaded chapter (exposed for image resolution). - var currentHref: String { - epubWebView.currentHref - } - - private let epubWebView = RDEPUBWebView() - private let decorationOverlayView = RDEPUBWebDecorationOverlayView() - private let pageNumberLabel: UILabel = { - let label = UILabel() - label.font = UIFont.systemFont(ofSize: 13) - return label - }() - - override init(frame: CGRect) { - super.init(frame: frame) - clipsToBounds = true - layer.masksToBounds = true - - addSubview(epubWebView) - addSubview(decorationOverlayView) - addSubview(pageNumberLabel) - epubWebView.delegate = self - epubWebView.onDecorationsResolved = { [weak self] decorations in - self?.decorationOverlayView.applyDecorations(decorations) - } - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func layoutSubviews() { - super.layoutSubviews() - epubWebView.frame = bounds - decorationOverlayView.frame = bounds - - let labelSize = pageNumberLabel.sizeThatFits(CGSize(width: bounds.width, height: 20)) - pageNumberLabel.frame = CGRect( - x: bounds.width - labelSize.width - 24, - y: bounds.height - labelSize.height - 20, - width: labelSize.width, - height: labelSize.height - ) - } - - func configure( - publication: RDEPUBPublication, - request: RDEPUBRenderRequest, - pageNumber: Int, - totalPages: Int, - theme: RDEPUBReaderTheme - ) { - backgroundColor = theme.contentBackgroundColor - pageNumberLabel.textColor = theme.contentTextColor - pageNumberLabel.text = "\(pageNumber) / \(totalPages)" - decorationOverlayView.applyDecorations([]) - epubWebView.load(publication: publication, request: request) - setNeedsLayout() - } - - func releaseResources() { - decorationOverlayView.applyDecorations([]) - epubWebView.reset() - epubWebView.delegate = self - epubWebView.onDecorationsResolved = { [weak self] decorations in - self?.decorationOverlayView.applyDecorations(decorations) - } - } -} - -extension RDEPUBWebContentView: RDEPUBWebViewDelegate { - func epubWebView(_ webView: RDEPUBWebView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int) { - delegate?.epubWebContentView(self, didUpdateLocation: location, spineIndex: spineIndex) - } - - func epubWebView(_ webView: RDEPUBWebView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int) { - delegate?.epubWebContentView(self, didChangeSelection: selection, spineIndex: spineIndex) - } - - func epubWebView(_ webView: RDEPUBWebView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) { - delegate?.epubWebContentView(self, didRequestSelectionAction: action) - } - - func epubWebView(_ webView: RDEPUBWebView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int) { - delegate?.epubWebContentView(self, didActivateInternalLink: location, fromSpineIndex: fromSpineIndex) - } - - func epubWebView(_ webView: RDEPUBWebView, didActivateExternalLink url: URL) { - delegate?.epubWebContentView(self, didActivateExternalLink: url) - } - - func epubWebView(_ webView: RDEPUBWebView, didLogJavaScriptError message: String) { - delegate?.epubWebContentView(self, didLogJavaScriptError: message) - } - func epubWebViewDidFinishRendering(_ webView: RDEPUBWebView) {} - func epubWebView(_ webView: RDEPUBWebView, didTapImageWithSource src: String, sourceRect: CGRect?) { - delegate?.epubWebContentView(self, didTapImageWithSource: src, sourceRect: sourceRect) - } - func epubWebView(_ webView: RDEPUBWebView, didTapFootnoteWithAltText altText: String, sourceRect: CGRect?) { - delegate?.epubWebContentView(self, didTapFootnoteWithAltText: altText, sourceRect: sourceRect) - } -} - -extension RDEPUBWebContentViewDelegate { - func epubWebContentView(_ contentView: RDEPUBWebContentView, didTapImageWithSource src: String, sourceRect: CGRect?) { - // Default: no-op. Implementors can present an image viewer. - } - func epubWebContentView(_ contentView: RDEPUBWebContentView, didTapFootnoteWithAltText altText: String, sourceRect: CGRect?) { - // Default: no-op. Implementors can present a footnote tooltip. - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBWebDecorationOverlayView.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBWebDecorationOverlayView.swift deleted file mode 100644 index 2448e3d..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBWebDecorationOverlayView.swift +++ /dev/null @@ -1,48 +0,0 @@ -import UIKit - -final class RDEPUBWebDecorationOverlayView: UIView { - private var decorations: [RDEPUBTextOverlayDecoration] = [] - private let verticalAdjustment: CGFloat = -1 - - override init(frame: CGRect) { - super.init(frame: frame) - backgroundColor = .clear - isOpaque = false - isUserInteractionEnabled = false - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func applyDecorations(_ decorations: [RDEPUBTextOverlayDecoration]) { - self.decorations = decorations.filter { !$0.rects.isEmpty } - setNeedsDisplay() - } - - override func draw(_ rect: CGRect) { - guard let context = UIGraphicsGetCurrentContext() else { return } - - for decoration in decorations { - switch decoration.kind { - case .underline: - context.setStrokeColor(decoration.color.cgColor) - context.setLineWidth(2) - for underlineRect in decoration.rects { - let y = underlineRect.maxY - 1 - context.move(to: CGPoint(x: underlineRect.minX, y: y)) - context.addLine(to: CGPoint(x: underlineRect.maxX, y: y)) - context.strokePath() - } - default: - context.setFillColor(decoration.color.cgColor) - for selectionRect in decoration.rects { - let adjustedRect = selectionRect.offsetBy(dx: 0, dy: verticalAdjustment) - let path = UIBezierPath(roundedRect: adjustedRect.insetBy(dx: -1, dy: -1), cornerRadius: 4) - context.addPath(path.cgPath) - context.fillPath() - } - } - } - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEpubURLReaderController.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEpubURLReaderController.swift deleted file mode 100644 index d18bf09..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEpubURLReaderController.swift +++ /dev/null @@ -1,516 +0,0 @@ - -import UIKit - -public final class RDEpubURLReaderController: UIViewController { - private struct PendingDemoPageRequest { - let pageNumber: Int - let animated: Bool - var attemptCount: Int - } - - private let bookURL: URL - - private let epubConfiguration: RDEPUBReaderConfiguration - - private var embeddedController: UIViewController? - private let demoStateLabel: UILabel = { - let label = UILabel() - label.accessibilityIdentifier = "demo.reader.state" - label.font = .systemFont(ofSize: 1) - label.textColor = .clear - label.alpha = 0.01 - label.isAccessibilityElement = true - label.text = "reader=opening" - return label - }() - private var pendingDemoPageRequest: PendingDemoPageRequest? - private var isRetryingPendingDemoPage = false - private let maxPendingDemoPageAttempts = 24 - private let pendingDemoPageRetryDelay: TimeInterval = 0.25 - private var demoStateTimer: Timer? - private var lastEmittedDemoState = "" - private var pendingSearchKeyword: String? - private var externalLinkActivationCount = 0 - private var lastActivatedExternalURL: URL? - private var lastReaderErrorDescription = "none" - - public init( - bookURL: URL, - epubConfiguration: RDEPUBReaderConfiguration = RDEPUBReaderConfiguration() - ) { - self.bookURL = bookURL - self.epubConfiguration = epubConfiguration - super.init(nibName: nil, bundle: nil) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - public override func viewDidLoad() { - super.viewDidLoad() - view.backgroundColor = .systemBackground - title = bookURL.deletingPathExtension().lastPathComponent - RDEPUBResourceURLSchemeHandler.resetDebugMetrics() - embedReaderController() - installDemoStateLabel() - } - - public override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - startDemoStateTimerIfNeeded() - refreshDemoState() - } - - public override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - stopDemoStateTimer() - } - - deinit { - stopDemoStateTimer() - } - - public func applyDemoDisplayType(_ displayType: RDEpubReaderView.DisplayType) { - readerController?.configuration.displayType = displayType - emitDemoState(prefix: "display=\(displayType.demoArgumentValue)") - } - - @discardableResult - public func goToDemoPage(_ pageNumber: Int, animated: Bool = false) -> Bool { - let moved = performDemoPageNavigation(pageNumber, animated: animated) - if moved { - pendingDemoPageRequest = nil - isRetryingPendingDemoPage = false - } else { - queuePendingDemoPageNavigation(pageNumber, animated: animated) - } - return moved - } - - public func runDemoDisplaySequence( - _ displayTypes: [RDEpubReaderView.DisplayType], - initialPageNumber: Int? = nil, - stepDelay: TimeInterval = 1.0 - ) { - let normalizedDelay = max(stepDelay, 0.1) - if let initialPageNumber { - DispatchQueue.main.asyncAfter(deadline: .now() + normalizedDelay) { [weak self] in - _ = self?.goToDemoPage(initialPageNumber) - } - } - - for (index, displayType) in displayTypes.enumerated() { - let delay = normalizedDelay * Double(index + 1 + (initialPageNumber == nil ? 0 : 1)) - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in - self?.applyDemoDisplayType(displayType) - } - } - } - - public func performDemoSearch(keyword: String) { - guard let readerController else { return } - readerController.showSearchBar() - if readerController.parser != nil || readerController.textBook != nil { - submitSearchAfterDelay(keyword: keyword, retries: 10) - } else { - pendingSearchKeyword = keyword - } - } - - private func submitSearchAfterDelay(keyword: String, retries: Int = 0) { - guard let readerController else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in - guard let self else { return } - readerController.search(keyword: keyword) - readerController.updateSearchCount() - // 按需加载的书打开初期搜索引擎可能尚未就绪(bookPageMap 未建立), - // 只要还没有命中就继续重试,交由搜索自身的 token 去重。 - if retries > 0, - readerController.searchState?.matches.isEmpty != false { - self.submitSearchAfterDelay(keyword: keyword, retries: retries - 1) - } - } - } - - private func executePendingSearchIfNeeded() { - guard let keyword = pendingSearchKeyword else { return } - guard let readerController, readerController.parser != nil || readerController.textBook != nil else { - return - } - pendingSearchKeyword = nil - submitSearchAfterDelay(keyword: keyword, retries: 10) - } - - private func embedReaderController() { - let controller: UIViewController - if bookURL.pathExtension.lowercased() == "epub" { - controller = RDEPUBReaderController( - epubURL: bookURL, - configuration: epubConfiguration - ) - } else { - let bookIdentifier = bookURL.lastPathComponent - let bookTitle = bookURL.deletingPathExtension().lastPathComponent - let pageSize = currentTextPageSize() - let renderStyle = currentTextRenderStyle() - let safeInsets = RDEPUBSafeArea.resolve(view.safeAreaInsets) - let edgeInsets = UIEdgeInsets( - top: max(epubConfiguration.reflowableContentInsets.top, safeInsets.top), - left: max(epubConfiguration.reflowableContentInsets.left, safeInsets.left), - bottom: max(epubConfiguration.reflowableContentInsets.bottom, safeInsets.bottom), - right: max(epubConfiguration.reflowableContentInsets.right, safeInsets.right) - ) - let builder = RDEpubPlainTextBookBuilder( - layoutConfig: RDEPUBTextLayoutConfig( - frameWidth: pageSize.width, - frameHeight: pageSize.height, - edgeInsets: edgeInsets, - numberOfColumns: 1, - columnGap: 20, - avoidOrphans: false, - avoidWidows: false, - avoidPageBreakInsideEnabled: true, - hyphenation: true, - imageMaxHeightRatio: 0.85 - ) - ) - if let textBook = try? builder.build(textFileURL: bookURL, pageSize: pageSize, style: renderStyle) { - controller = RDEPUBReaderController( - textBook: textBook, - bookIdentifier: bookIdentifier, - title: bookTitle, - textFileURL: bookURL, - configuration: epubConfiguration - ) - } else { - - let fallback = UIViewController() - let textView = UITextView() - textView.isEditable = false - textView.text = rd_decodeTextFile(url: bookURL) - fallback.view = textView - controller = fallback - } - } - embeddedController = controller - addChild(controller) - controller.view.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(controller.view) - NSLayoutConstraint.activate([ - controller.view.topAnchor.constraint(equalTo: view.topAnchor), - controller.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), - controller.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), - controller.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), - ]) - controller.didMove(toParent: self) - readerController?.delegate = self - emitDemoState() - } - - private var readerController: RDEPUBReaderController? { - embeddedController as? RDEPUBReaderController - } - - @discardableResult - private func performDemoPageNavigation(_ pageNumber: Int, animated: Bool) -> Bool { - guard let readerController else { return false } - guard pageNumber > 0 else { return false } - - let knownPages = readerController.readerContext.bookPageMap?.totalPages - ?? readerController.textBook?.pages.count - ?? readerController.activePages.count - let numPages = readerController.readerView.numberOfPages() - let moved = readerController.go(toPageNumber: pageNumber, animated: animated) - if moved { - emitDemoState(prefix: "page=\(pageNumber)") - } - return moved - } - - private func queuePendingDemoPageNavigation(_ pageNumber: Int, animated: Bool) { - if let pendingDemoPageRequest, - pendingDemoPageRequest.pageNumber == pageNumber, - pendingDemoPageRequest.animated == animated { - return - } - - pendingDemoPageRequest = PendingDemoPageRequest( - pageNumber: pageNumber, - animated: animated, - attemptCount: 0 - ) - retryPendingDemoPageIfNeeded() - } - - private func retryPendingDemoPageIfNeeded() { - guard !isRetryingPendingDemoPage else { return } - guard pendingDemoPageRequest != nil else { return } - - isRetryingPendingDemoPage = true - DispatchQueue.main.asyncAfter(deadline: .now() + pendingDemoPageRetryDelay) { [weak self] in - self?.attemptPendingDemoPageNavigation() - } - } - - private func attemptPendingDemoPageNavigation() { - guard var pendingDemoPageRequest else { - isRetryingPendingDemoPage = false - return - } - - if performDemoPageNavigation(pendingDemoPageRequest.pageNumber, animated: pendingDemoPageRequest.animated) { - self.pendingDemoPageRequest = nil - isRetryingPendingDemoPage = false - return - } - - pendingDemoPageRequest.attemptCount += 1 - if pendingDemoPageRequest.attemptCount >= maxPendingDemoPageAttempts { - self.pendingDemoPageRequest = nil - isRetryingPendingDemoPage = false - return - } - - self.pendingDemoPageRequest = pendingDemoPageRequest - DispatchQueue.main.asyncAfter(deadline: .now() + pendingDemoPageRetryDelay) { [weak self] in - self?.attemptPendingDemoPageNavigation() - } - } - - private func logDemoState(prefix: String) { - guard let readerController else { return } - let location = readerController.currentLocation - let href = location?.href ?? "nil" - let progression = location.map { String(format: "%.4f", $0.navigationProgression) } ?? "nil" - let page = readerController.currentPageNumber.map(String.init) ?? "nil" - } - - private func installDemoStateLabel() { - view.addSubview(demoStateLabel) - demoStateLabel.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - demoStateLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor), - demoStateLabel.topAnchor.constraint(equalTo: view.topAnchor), - demoStateLabel.widthAnchor.constraint(equalToConstant: 1), - demoStateLabel.heightAnchor.constraint(equalToConstant: 1) - ]) - } - - private func startDemoStateTimerIfNeeded() { - guard demoStateTimer == nil else { return } - demoStateTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in - self?.refreshDemoState(logIfChanged: false) - } - if let demoStateTimer { - RunLoop.main.add(demoStateTimer, forMode: .common) - } - } - - private func stopDemoStateTimer() { - demoStateTimer?.invalidate() - demoStateTimer = nil - } - - private func emitDemoState(prefix: String? = nil) { - refreshDemoState(logPrefix: prefix, logIfChanged: true) - } - - private func refreshDemoState(logPrefix: String? = nil, logIfChanged: Bool = true) { - let page = readerController?.currentPageNumber.map(String.init) ?? "nil" - let display = readerController?.configuration.displayType.demoArgumentValue ?? epubConfiguration.displayType.demoArgumentValue - let toolbar = readerController?.readerView.isShowToolView == true ? "visible" : "hidden" - let highlights = readerController?.highlights.count ?? 0 - let bookmarks = readerController?.activeBookmarks.count ?? 0 - let selection = readerController?.currentSelection == nil ? 0 : 1 - let location = readerController?.currentLocation - let href = encodedDemoLocationHref(location?.href) - let progression = location.map { String(format: "%.4f", $0.navigationProgression) } ?? "nil" - let cfi = encodedDemoField(location?.cfi) - let lastCFI = encodedDemoField(location?.lastCFI) - let rangeCFI = encodedDemoField(location?.rangeCFI) - let mapSnapshot = demoPaginationSnapshot() - let layoutConfig = readerController?.readerContext.currentTextLayoutConfig(pageSize: currentTextPageSize()) - let resourceMetrics = RDEPUBResourceURLSchemeHandler.debugMetricsSnapshot() - let cacheStats = readerController?.readerContext.makeChapterSummaryDiskCache().cacheStatistics - ?? (fileCount: 0, totalBytes: 0) - let inspectable = readerController?.configuration.allowsInspectableWebViews ?? epubConfiguration.allowsInspectableWebViews - let state = [ - "reader=opened", - "page=\(page)", - "display=\(display)", - "toolbar=\(toolbar)", - "highlights=\(highlights)", - "bookmarks=\(bookmarks)", - "selection=\(selection)", - "href=\(href)", - "progression=\(progression)", - "cfi=\(cfi)", - "lastCFI=\(lastCFI)", - "rangeCFI=\(rangeCFI)", - "mode=\(mapSnapshot.mode)", - "pagination=\(mapSnapshot.phase)", - "knownPages=\(mapSnapshot.knownPages)", - "knownChapters=\(mapSnapshot.knownChapters)", - "buildableChapters=\(mapSnapshot.buildableChapters)", - "avoidWidows=\(layoutConfig?.avoidWidows == true ? 1 : 0)", - "avoidOrphans=\(layoutConfig?.avoidOrphans == true ? 1 : 0)", - "windowSize=\(readerController?.configuration.onDemandChapterWindowSize ?? epubConfiguration.onDemandChapterWindowSize)", - "parseMs=\(readerController?.readerContext.lastMetadataParseWallClockMs ?? 0)", - "parseConcurrency=\(readerController?.readerContext.lastMetadataParseConcurrency ?? 0)", - "inspectable=\(inspectable ? 1 : 0)", - "streamedResources=\(resourceMetrics.streamedResponses)", - "inMemoryResources=\(resourceMetrics.inMemoryResponses)", - "resourceFailures=\(resourceMetrics.failures)", - "cacheFiles=\(cacheStats.fileCount)", - "cacheBytes=\(cacheStats.totalBytes)", - "externalLinks=\(externalLinkActivationCount)", - "lastExternalURL=\(encodedDemoLocationHref(lastActivatedExternalURL?.absoluteString))", - "lastError=\(encodedDemoField(lastReaderErrorDescription))", - "searchMatchText=\(encodedDemoField(currentSearchMatchText()))", - "footprintMB=\(String(format: "%.1f", RDEPUBMemoryProbe.footprintMB))" - ].joined(separator: " ") - demoStateLabel.text = state - if let logPrefix { - logDemoState(prefix: logPrefix) - } else if logIfChanged, state != lastEmittedDemoState { - } - lastEmittedDemoState = state - } - - private func encodedDemoLocationHref(_ href: String?) -> String { - guard let href, !href.isEmpty else { return "nil" } - return href.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? href.replacingOccurrences(of: " ", with: "%20") - } - - private func encodedDemoField(_ value: String?) -> String { - guard let value, !value.isEmpty else { return "nil" } - return value.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? value.replacingOccurrences(of: " ", with: "_") - } - - private func currentSearchMatchText() -> String { - guard let match = readerController?.searchState?.currentMatch else { - return "none" - } - // Primary path: extract the exact matched text from the chapter content - if let rangeLocation = match.rangeLocation, - let chapterData = readerController?.textChapterData(forNormalizedHref: match.href) { - let nsRange = NSRange(location: rangeLocation, length: match.rangeLength) - if nsRange.location >= 0, - nsRange.location + nsRange.length <= chapterData.attributedContent.length { - return chapterData.attributedContent.attributedSubstring(from: nsRange).string - } - } - // Fallback: chapter data may not be cached yet (on-demand loading). - // If the match length equals the keyword length, the keyword itself - // is the match text. Otherwise extract from previewText at the known offset. - if let keyword = readerController?.searchState?.keyword, - match.rangeLength == keyword.count { - return keyword - } - // Final fallback: return "none" to indicate data not yet available - return "none" - } - - private func demoPaginationSnapshot() -> (mode: String, phase: String, knownPages: Int, knownChapters: Int, buildableChapters: Int) { - guard let readerController else { - return ("unavailable", "none", 0, 0, 0) - } - - if let bookPageMap = readerController.readerContext.bookPageMap { - let buildableChapters = readerController.publication?.spine.filter { - $0.linear && ($0.mediaType.contains("html") || $0.mediaType.contains("xhtml")) - }.count ?? 0 - let phase = buildableChapters > 0 && bookPageMap.totalChapters >= buildableChapters ? "full" : "partial" - return ("bookPageMap", phase, bookPageMap.totalPages, bookPageMap.totalChapters, buildableChapters) - } - - if let textBook = readerController.textBook { - return ("textBook", "full", textBook.pages.count, textBook.chapters.count, textBook.chapters.count) - } - - let snapshotChapters = readerController.activeChapters.count - let snapshotPages = readerController.activePages.count - return ("snapshot", snapshotPages > 0 ? "full" : "none", snapshotPages, snapshotChapters, snapshotChapters) - } - - private func currentTextPageSize() -> CGSize { - UIScreen.main.bounds.size - } - - private func currentTextRenderStyle() -> RDEPUBTextRenderStyle { - let font = epubConfiguration.fontChoice.font(ofSize: epubConfiguration.fontSize) - let lineSpacing = max(font.lineHeight * (epubConfiguration.lineHeightMultiple - 1), 4) - return RDEPUBTextRenderStyle( - font: font, - lineSpacing: lineSpacing, - textColor: epubConfiguration.theme.contentTextColor, - backgroundColor: epubConfiguration.theme.contentBackgroundColor - ) - } - - private func rd_decodeTextFile(url: URL) -> String { - if let content = try? NSString(contentsOf: url, encoding: String.Encoding.utf8.rawValue) as String { - return content - } - if let content = try? NSString(contentsOf: url, encoding: 0x80000632) as String { - return content - } - if let content = try? NSString(contentsOf: url, encoding: 0x80000631) as String { - return content - } - return "" - } -} - -extension RDEpubURLReaderController: RDEPUBReaderDelegate { - public func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication) { - retryPendingDemoPageIfNeeded() - executePendingSearchIfNeeded() - emitDemoState() - } - - public func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation) { - retryPendingDemoPageIfNeeded() - executePendingSearchIfNeeded() - emitDemoState() - } - - public func epubReader(_ reader: UIViewController, didChangeSelection selection: RDEPUBSelection?) { - emitDemoState(prefix: "selection=\(selection == nil ? 0 : 1)") - } - - public func epubReader(_ reader: UIViewController, didUpdateHighlights highlights: [RDEPUBHighlight]) { - emitDemoState(prefix: "highlights=\(highlights.count)") - } - - public func epubReader(_ reader: UIViewController, didUpdateBookmarks bookmarks: [RDEPUBBookmark]) { - emitDemoState(prefix: "bookmarks=\(bookmarks.count)") - } - - public func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL) { - externalLinkActivationCount += 1 - lastActivatedExternalURL = url - emitDemoState(prefix: "externalLinks=\(externalLinkActivationCount)") - } - - public func epubReader(_ reader: UIViewController, didFailWithError error: Error) { - lastReaderErrorDescription = String(describing: error) - emitDemoState(prefix: "lastError=\(lastReaderErrorDescription)") - } -} - -private extension RDEpubReaderView.DisplayType { - - var demoArgumentValue: String { - switch self { - case .pageCurl: - return "pageCurl" - case .horizontalScroll: - return "horizontalScroll" - case .verticalScroll: - return "verticalScroll" - } - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBBackgroundTrace.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBBackgroundTrace.swift deleted file mode 100644 index 6d3dfa2..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBBackgroundTrace.swift +++ /dev/null @@ -1,10 +0,0 @@ -import Foundation - -enum RDEPUBBackgroundTrace { - - static func log(_ scope: String, _ message: String) { - let threadRole = Thread.isMainThread ? "main" : "bg" - let queueLabel = String(validatingUTF8: __dispatch_queue_get_label(nil)) ?? "unknown" - print("[EPUB][\(scope)][\(threadRole)][queue=\(queueLabel)] \(message)") - } -} \ No newline at end of file diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBBookPageMap.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBBookPageMap.swift deleted file mode 100644 index 79010ea..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBBookPageMap.swift +++ /dev/null @@ -1,112 +0,0 @@ -import Foundation - -struct RDEPUBBookPageMapEntry { - - let spineIndex: Int - - let href: String - - let title: String - - let pageCount: Int - - let absolutePageStart: Int - - let fragmentOffsets: [String: Int] -} - -struct RDEPUBBookPageMap { - - let entries: [RDEPUBBookPageMapEntry] - - private let indexBySpine: [Int: Int] - - let totalPages: Int - - init(entries: [RDEPUBBookPageMapEntry]) { - self.entries = entries - var mapping: [Int: Int] = [:] - for (i, entry) in entries.enumerated() { - mapping[entry.spineIndex] = i - } - self.indexBySpine = mapping - self.totalPages = entries.last.map { $0.absolutePageStart + $0.pageCount } ?? 0 - } - - static let empty = RDEPUBBookPageMap(entries: []) - - func absolutePageIndex(spineIndex: Int, localPageIndex: Int) -> Int? { - guard let idx = indexBySpine[spineIndex] else { return nil } - let entry = entries[idx] - guard localPageIndex >= 0, localPageIndex < entry.pageCount else { return nil } - return entry.absolutePageStart + localPageIndex - } - - func spineIndex(forAbsolutePage absolutePage: Int) -> Int? { - guard absolutePage >= 0, absolutePage < totalPages else { return nil } - - var lo = 0, hi = entries.count - while lo < hi { - let mid = lo + (hi - lo) / 2 - if entries[mid].absolutePageStart <= absolutePage { - lo = mid + 1 - } else { - hi = mid - } - } - guard lo > 0 else { return nil } - return entries[lo - 1].spineIndex - } - - func localPageIndex(forAbsolutePage absolutePage: Int) -> Int? { - guard let si = spineIndex(forAbsolutePage: absolutePage), - let idx = indexBySpine[si] else { return nil } - let entry = entries[idx] - let local = absolutePage - entry.absolutePageStart - guard local >= 0, local < entry.pageCount else { return nil } - return local - } - - func entry(forSpineIndex spineIndex: Int) -> RDEPUBBookPageMapEntry? { - guard let idx = indexBySpine[spineIndex] else { return nil } - return entries[idx] - } - - func chapterIndex(forSpineIndex spineIndex: Int) -> Int? { - indexBySpine[spineIndex] - } - - func pageCount(forSpineIndex spineIndex: Int) -> Int? { - entry(forSpineIndex: spineIndex)?.pageCount - } - - var totalChapters: Int { entries.count } - - struct Builder { - - private var items: [(spineIndex: Int, href: String, title: String, pageCount: Int, fragmentOffsets: [String: Int])] = [] - - mutating func add(spineIndex: Int, href: String, title: String, pageCount: Int, fragmentOffsets: [String: Int]) { - items.append((spineIndex, href, title, pageCount, fragmentOffsets)) - } - - func build() -> RDEPUBBookPageMap { - - let sorted = items.sorted { $0.spineIndex < $1.spineIndex } - var entries: [RDEPUBBookPageMapEntry] = [] - var absolutePageStart = 0 - for item in sorted { - entries.append(RDEPUBBookPageMapEntry( - spineIndex: item.spineIndex, - href: item.href, - title: item.title, - pageCount: item.pageCount, - absolutePageStart: absolutePageStart, - fragmentOffsets: item.fragmentOffsets - )) - absolutePageStart += item.pageCount - } - return RDEPUBBookPageMap(entries: entries) - } - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterCacheKey.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterCacheKey.swift deleted file mode 100644 index 3892060..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterCacheKey.swift +++ /dev/null @@ -1,12 +0,0 @@ -import Foundation - -struct RDEPUBChapterCacheKey: Hashable { - - let bookID: String - - let spineIndex: Int - - let renderSignature: String - - let chapterContentHash: String -} \ No newline at end of file diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterDataCache.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterDataCache.swift deleted file mode 100644 index 7f7fef3..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterDataCache.swift +++ /dev/null @@ -1,75 +0,0 @@ -import Foundation - -final class RDEPUBChapterDataCache { - - private var storage: [Int: RDEPUBRuntimeChapter] = [:] - private var accessOrder: [Int] = [] // H-05: LRU tracking for eviction - private let maxEntryCount: Int - - private let lock = NSLock() - - init(maxEntryCount: Int = 30) { - self.maxEntryCount = maxEntryCount - } - - subscript(_ spineIndex: Int) -> RDEPUBRuntimeChapter? { - get { - lock.lock() - defer { lock.unlock() } - guard let chapter = storage[spineIndex] else { - return nil - } - touchLocked(spineIndex) - return chapter - } - set { - lock.lock() - defer { lock.unlock() } - if let newValue { - storage[spineIndex] = newValue - touchLocked(spineIndex) - // Evict oldest entries if over limit - evictIfNeededLocked() - } else { - storage.removeValue(forKey: spineIndex) - accessOrder.removeAll { $0 == spineIndex } - } - } - } - - var storedSpineIndices: [Int] { - lock.lock() - defer { lock.unlock() } - return Array(storage.keys) - } - - func remove(spineIndex: Int) { - lock.lock() - defer { lock.unlock() } - storage.removeValue(forKey: spineIndex) - accessOrder.removeAll { $0 == spineIndex } - } - - func removeAll() { - lock.lock() - defer { lock.unlock() } - storage.removeAll() - accessOrder.removeAll() - } - - /// H-05: Evict least recently used entries when cache exceeds maxEntryCount. - /// Must be called while holding lock. - private func evictIfNeededLocked() { - while storage.count > maxEntryCount, let oldest = accessOrder.first { - storage.removeValue(forKey: oldest) - accessOrder.removeFirst() - } - } - - /// Marks an entry as most recently used. - /// Must be called while holding lock. - private func touchLocked(_ spineIndex: Int) { - accessOrder.removeAll { $0 == spineIndex } - accessOrder.append(spineIndex) - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLoader.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLoader.swift deleted file mode 100644 index a4428c7..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLoader.swift +++ /dev/null @@ -1,838 +0,0 @@ -import Foundation - -final class RDEPUBChapterLoader { - - private typealias LoadCompletion = (Result) -> Void - - private struct PendingLoad { - var priority: LoadPriority - var completions: [LoadCompletion] - } - - private weak var context: RDEPUBReaderContext? - - private var summaryDiskCache: RDEPUBChapterSummaryDiskCache? - - private let pendingLoadsLock = NSLock() - - private var pendingLoads: [Int: PendingLoad] = [:] - - var onDeferredCFIMapReady: ((Int) -> Void)? - - init(context: RDEPUBReaderContext) { - self.context = context - } - - func setSummaryDiskCache(_ cache: RDEPUBChapterSummaryDiskCache) { - summaryDiskCache = cache - } - - enum LoadPriority { - - case navigation - - case preview - - case prefetch - } - - private enum LoadRegistrationResult { - case created - case joined(existingPriority: LoadPriority, effectivePriority: LoadPriority) - } - - func loadChapter( - spineIndex: Int, - store: RDEPUBChapterRuntimeStore, - priority: LoadPriority = .navigation, - completion: @escaping (Result) -> Void - ) { - let layoutSnapshot = context?.makeLayoutSnapshot() - loadChapterWithSnapshot( - spineIndex: spineIndex, - store: store, - priority: priority, - layoutSnapshot: layoutSnapshot, - completion: completion - ) - } - - private func loadChapterWithSnapshot( - spineIndex: Int, - store: RDEPUBChapterRuntimeStore, - priority: LoadPriority, - layoutSnapshot: RDEPUBLayoutSnapshot?, - completion: @escaping (Result) -> Void - ) { - if let cached = store.chapterData(for: spineIndex) { - RDEPUBBackgroundTrace.log( - "ChapterLoad", - "memory HIT spine=\(spineIndex) pages=\(cached.pages.count) priority=\(priority)" - ) - if let context { - scheduleDeferredCFIMapBuildIfNeeded( - for: cached, - cacheKey: makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: layoutSnapshot), - store: store - ) - } - DispatchQueue.main.async { - completion(.success(cached)) - } - return - } - - let completionOnMain: LoadCompletion = { result in - DispatchQueue.main.async { - completion(result) - } - } - - let registration = registerPendingLoad( - spineIndex: spineIndex, - priority: priority, - completion: completionOnMain - ) - - switch registration { - case .joined(let existingPriority, let effectivePriority): - RDEPUBBackgroundTrace.log( - "ChapterLoad", - "join pendingLoad spine=\(spineIndex) existing=\(existingPriority) effective=\(effectivePriority)" - ) - return - case .created: - break - } - - store.markBuilding(true) - _ = store.beginPendingChapterLoad(for: spineIndex) - - store.chapterLoadQueue.async { [weak self] in - guard let self, let context = self.context else { - store.endPendingChapterLoad(for: spineIndex) - store.markBuilding(false) - self?.resolvePendingLoad(spineIndex: spineIndex, result: .failure(RDEPUBChapterLoadError.missingParser)) - return - } - let queuePriority = self.pendingPriority(for: spineIndex) ?? priority - let cacheKey = self.makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: layoutSnapshot) - - let precomputedPageRanges = store.pageCount(for: cacheKey)?.pageRanges - let diskSummary: RDEPUBChapterSummary? - if precomputedPageRanges == nil { - diskSummary = self.summaryDiskCache?.read(for: cacheKey) - } else { - diskSummary = nil - } - let diskPageRanges = diskSummary?.pageRanges.map { $0.nsRange } - let availablePageRanges = precomputedPageRanges ?? diskPageRanges - - let pageRangeSource: String - if precomputedPageRanges != nil { - pageRangeSource = "HIT(memoryPageCount)" - } else if diskPageRanges != nil { - pageRangeSource = "HIT(diskSummary)" - } else { - pageRangeSource = "MISS(fullRender)" - } - RDEPUBBackgroundTrace.log( - "ChapterLoad", - "build start spine=\(spineIndex) priority=\(queuePriority) pageRanges=\(pageRangeSource)" - ) - let buildStart = CFAbsoluteTimeGetCurrent() - - do { - - let chapter = try self.buildChapter( - spineIndex: spineIndex, - availablePageRanges: availablePageRanges, - diskSummary: diskSummary, - context: context, - layoutSnapshot: layoutSnapshot - ) - - RDEPUBBackgroundTrace.log( - "ChapterLoad", - "build done spine=\(spineIndex) pages=\(chapter.pages.count) elapsedMs=\(Int((CFAbsoluteTimeGetCurrent() - buildStart) * 1000)) pageRanges=\(pageRangeSource)" - ) - store.insertChapter(chapter) - let pc = RDEPUBRuntimePageCount( - cacheKey: cacheKey, - spineIndex: spineIndex, - pageRanges: chapter.pageRanges, - pageCount: chapter.pages.count, - renderSignature: cacheKey.renderSignature - ) - store.insertPageCount(pc, for: cacheKey) - self.scheduleDeferredCFIMapBuildIfNeeded( - for: chapter, - cacheKey: cacheKey, - store: store - ) - - let effectivePriority = self.pendingPriority(for: spineIndex) ?? queuePriority - store.endPendingChapterLoad(for: spineIndex) - switch effectivePriority { - case .navigation: - - let nextTarget = store.consumeNavigationTarget() - if let target = nextTarget, target != spineIndex { - - store.markBuilding(false) - self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter)) - self.loadChapterWithSnapshot(spineIndex: target, store: store, priority: .navigation, layoutSnapshot: layoutSnapshot, completion: { _ in }) - return - } - store.markBuilding(false) - self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter)) - - case .preview: - store.markBuilding(false) - self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter)) - - case .prefetch: - - store.removePrefetchTarget(spineIndex) - store.markBuilding(false) - self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter)) - } - } catch { - RDEPUBBackgroundTrace.log( - "ChapterLoad", - "build failed spine=\(spineIndex) pageRanges=\(pageRangeSource) error=\(String(describing: error))" - ) - store.endPendingChapterLoad(for: spineIndex) - store.markBuilding(false) - self.resolvePendingLoad(spineIndex: spineIndex, result: .failure(error)) - } - } - } - - func loadChapterSynchronouslyForMigration( - spineIndex: Int, - store: RDEPUBChapterRuntimeStore?, - layoutSnapshot: RDEPUBLayoutSnapshot? = nil - ) throws -> RDEPUBRuntimeChapter { - guard let context else { - throw RDEPUBChapterLoadError.missingParser - } - - if let cached = store?.chapterData(for: spineIndex) { - if let store { - scheduleDeferredCFIMapBuildIfNeeded( - for: cached, - cacheKey: makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: layoutSnapshot), - store: store - ) - } - return cached - } - - guard let store else { - throw RDEPUBChapterLoadError.missingParser - } - - if store.hasPendingChapterLoad(for: spineIndex) { - var result: Result? - let semaphore = DispatchSemaphore(value: 0) - let registration = registerPendingLoad( - spineIndex: spineIndex, - priority: .navigation - ) { pendingResult in - result = pendingResult - semaphore.signal() - } - if case .joined(let existingPriority, let effectivePriority) = registration { - semaphore.wait() - return try result!.get() - } - } - - store.assertNotOnChapterLoadQueue() - - let snapshot = layoutSnapshot ?? context.makeLayoutSnapshot() - var result: Result? - let semaphore = DispatchSemaphore(value: 0) - store.chapterLoadQueue.async { - do { - let chapter: RDEPUBRuntimeChapter = try autoreleasepool { - let cacheKey = self.makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: snapshot) - let precomputedPageRanges = store.pageCount(for: cacheKey)?.pageRanges - let diskSummary: RDEPUBChapterSummary? - if precomputedPageRanges == nil { - diskSummary = self.summaryDiskCache?.read(for: cacheKey) - } else { - diskSummary = nil - } - let chapter = try self.buildChapter( - spineIndex: spineIndex, - availablePageRanges: precomputedPageRanges ?? diskSummary?.pageRanges.map(\.nsRange), - diskSummary: diskSummary, - context: context, - layoutSnapshot: snapshot - ) - store.insertChapter(chapter) - let pageCount = RDEPUBRuntimePageCount( - cacheKey: cacheKey, - spineIndex: spineIndex, - pageRanges: chapter.pageRanges, - pageCount: chapter.pages.count, - renderSignature: cacheKey.renderSignature - ) - store.insertPageCount(pageCount, for: cacheKey) - self.scheduleDeferredCFIMapBuildIfNeeded( - for: chapter, - cacheKey: cacheKey, - store: store - ) - return chapter - } - result = .success(chapter) - } catch { - result = .failure(error) - } - semaphore.signal() - } - semaphore.wait() - return try result!.get() - } - - private func registerPendingLoad( - spineIndex: Int, - priority: LoadPriority, - completion: @escaping LoadCompletion - ) -> LoadRegistrationResult { - pendingLoadsLock.lock() - defer { pendingLoadsLock.unlock() } - - if var pending = pendingLoads[spineIndex] { - let existingPriority = pending.priority - pending.priority = LoadPriority.higherPriority(existingPriority, priority) - pending.completions.append(completion) - pendingLoads[spineIndex] = pending - return .joined(existingPriority: existingPriority, effectivePriority: pending.priority) - } - - pendingLoads[spineIndex] = PendingLoad(priority: priority, completions: [completion]) - return .created - } - - private func pendingPriority(for spineIndex: Int) -> LoadPriority? { - pendingLoadsLock.lock() - let priority = pendingLoads[spineIndex]?.priority - pendingLoadsLock.unlock() - return priority - } - - private func resolvePendingLoad(spineIndex: Int, result: Result) { - pendingLoadsLock.lock() - let completions = pendingLoads.removeValue(forKey: spineIndex)?.completions ?? [] - pendingLoadsLock.unlock() - - guard !completions.isEmpty else { return } - - completions.forEach { $0(result) } - } - - private func buildChapter( - spineIndex: Int, - availablePageRanges: [NSRange]?, - diskSummary: RDEPUBChapterSummary? = nil, - context: RDEPUBReaderContext, - layoutSnapshot: RDEPUBLayoutSnapshot? = nil - ) throws -> RDEPUBRuntimeChapter { - guard let parser = context.parser, - let publication = context.publication else { - throw RDEPUBChapterLoadError.missingParser - } - - let pageSize: CGSize - let style: RDEPUBTextRenderStyle - let layoutConfig: RDEPUBTextLayoutConfig - - if let snapshot = layoutSnapshot { - pageSize = snapshot.pageSize - style = snapshot.style - layoutConfig = snapshot.layoutConfig - } else { - assert(Thread.isMainThread, "buildChapter() requires a layoutSnapshot when called off the main thread. Capture a snapshot via makeLayoutSnapshot() before dispatching to a background queue.") - pageSize = context.currentTextPageSize() - style = context.currentTextRenderStyle() - layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize) - } - - if let pageRanges = availablePageRanges { - - return try buildChapterFromCachedPageRanges( - spineIndex: spineIndex, - pageRanges: pageRanges, - parser: parser, - publication: publication, - pageSize: pageSize, - style: style, - layoutConfig: layoutConfig, - diskSummary: diskSummary, - context: context - ) - } - - let builder = context.makeTextBookBuilder(layoutConfig: layoutConfig) - guard let result = try builder.buildChapter( - parser: parser, - publication: publication, - spineIndex: spineIndex, - pageSize: pageSize, - style: style - ) else { - throw RDEPUBChapterLoadError.emptyChapter(spineIndex: spineIndex) - } - - return try assembleRuntimeChapter( - from: result.chapter, - spineIndex: spineIndex, - pageSize: pageSize, - layoutConfig: layoutConfig, - context: context, - layoutSnapshot: layoutSnapshot - ) - } - - private func buildChapterFromCachedPageRanges( - spineIndex: Int, - pageRanges: [NSRange], - parser: RDEPUBParser, - publication: RDEPUBPublication, - pageSize: CGSize, - style: RDEPUBTextRenderStyle, - layoutConfig: RDEPUBTextLayoutConfig, - diskSummary: RDEPUBChapterSummary? = nil, - context: RDEPUBReaderContext - ) throws -> RDEPUBRuntimeChapter { - let spineItem = publication.spine[spineIndex] - let href = spineItem.href - let title = spineItem.title - let baseURL = parser.fileURL(forRelativePath: href)?.deletingLastPathComponent() - let rawHTML = try requireHTMLString(parser, href: href) - - let request = RDEPUBTextRendererSupport.makeChapterRenderRequest( - href: href, - title: title, - rawHTML: rawHTML, - baseURL: baseURL, - style: style, - resourceResolver: publication.resourceResolver, - pageSize: pageSize, - layoutConfig: layoutConfig - ) - let renderer = context.resolvedTextRenderer() - let rendered = try renderer.renderChapter(request: request) - - let typesetString = NSMutableAttributedString(attributedString: rendered.attributedString) - RDEPUBTextRendererSupport.normalizeReadingAttributes( - in: typesetString, style: style, layoutConfig: layoutConfig - ) - - let sanitizedCachedRanges = sanitizedPageRanges(pageRanges, contentLength: typesetString.length) - let effectivePageRanges: [NSRange] - let metadataSource: [RDEPUBChapterSummary.PageMetadataSummary]? - - if sanitizedCachedRanges.count == pageRanges.count { - effectivePageRanges = sanitizedCachedRanges - metadataSource = diskSummary?.pageMetadataList - } else { - effectivePageRanges = typesetString.rd_paginatedFrames(size: pageSize, config: layoutConfig).map(\.contentRange) - metadataSource = nil - } - - let pages = buildPagesFromRanges( - pageRanges: effectivePageRanges, - typesetString: typesetString, - spineIndex: spineIndex, - href: href, - title: title, - metadataSource: metadataSource - ) - - let layouter = RDEPUBTextLayouter( - attributedString: typesetString, - pageSize: pageSize, - config: layoutConfig - ) - - let offsetMap = RDEPUBChapterOffsetMap( - fragmentOffsets: rendered.fragmentOffsets, - pageStartOffsets: pages.map { $0.pageStartOffset }, - pageEndOffsets: pages.map { $0.pageEndOffset }, - cfiMap: diskSummary?.cfiMap, - chapterText: typesetString.string - ) - - return RDEPUBRuntimeChapter( - spineIndex: spineIndex, - href: href, - title: title, - sourceAttributedString: nil, - typesetAttributedString: typesetString, - layouter: layouter, - pageRanges: effectivePageRanges, - pages: pages, - chapterOffsetMap: offsetMap - ) - } - - private func buildPagesFromRanges( - pageRanges: [NSRange], - typesetString: NSAttributedString, - spineIndex: Int, - href: String, - title: String, - metadataSource: [RDEPUBChapterSummary.PageMetadataSummary]? = nil - ) -> [RDEPUBTextPage] { - let totalPageCount = pageRanges.count - return pageRanges.enumerated().map { (pageIndex, range) in - let metadata: RDEPUBTextPageMetadata - if let metaList = metadataSource, pageIndex < metaList.count { - - metadata = metaList[pageIndex].toPageMetadata() - } else { - - metadata = inferPageMetadata( - from: typesetString, - range: range, - isLastPage: pageIndex == totalPageCount - 1 - ) - } - return RDEPUBTextPage( - absolutePageIndex: -1, - chapterIndex: 0, - spineIndex: spineIndex, - href: href, - chapterTitle: title, - pageIndexInChapter: pageIndex, - totalPagesInChapter: totalPageCount, - chapterContent: typesetString, - contentRange: range, - pageStartOffset: range.location, - pageEndOffset: range.location + range.length - 1, - metadata: metadata - ) - } - } - - private func sanitizedPageRanges(_ pageRanges: [NSRange], contentLength: Int) -> [NSRange] { - guard contentLength > 0 else { return [] } - - return pageRanges.compactMap { range in - guard range.location >= 0, range.location < contentLength else { - return nil - } - let maxLength = contentLength - range.location - let clampedLength = min(max(range.length, 0), maxLength) - guard clampedLength > 0 else { - return nil - } - return NSRange(location: range.location, length: clampedLength) - } - } - - private func inferPageMetadata( - from string: NSAttributedString, - range: NSRange, - isLastPage: Bool - ) -> RDEPUBTextPageMetadata { - var attachmentRanges: [NSRange] = [] - var attachmentKinds: [RDEPUBTextAttachmentKind] = [] - var blockKinds: [RDEPUBTextBlockKind] = [] - var semanticHints: [RDEPUBTextSemanticHint] = [] - var attachmentPlacements: [RDEPUBTextAttachmentPlacement] = [] - var trailingFragmentID: String? = nil - - string.enumerateAttribute(.rdPageAttachmentKind, in: range, options: []) { value, attrRange, _ in - if let rawValue = value as? String, - let kind = RDEPUBTextAttachmentKind(rawValue: rawValue) { - attachmentRanges.append(attrRange) - attachmentKinds.append(kind) - } - } - string.enumerateAttribute(.rdPageBlockKind, in: range, options: []) { value, _, _ in - if let rawValue = value as? String, - let kind = RDEPUBTextBlockKind(rawValue: rawValue), - !blockKinds.contains(kind) { - blockKinds.append(kind) - } - } - string.enumerateAttribute(.rdPageSemanticHints, in: range, options: []) { value, _, _ in - if let rawValue = value as? String { - let hints = rawValue - .split(separator: ",") - .compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) } - for hint in hints where !semanticHints.contains(hint) { - semanticHints.append(hint) - } - } - } - string.enumerateAttribute(.rdPageAttachmentPlacement, in: range, options: []) { value, _, _ in - if let rawValue = value as? String, - let placement = RDEPUBTextAttachmentPlacement(rawValue: rawValue), - !attachmentPlacements.contains(placement) { - attachmentPlacements.append(placement) - } - } - string.enumerateAttribute(.rdPageFragmentID, in: range, options: [.reverse]) { value, _, stop in - if let fid = value as? String { - trailingFragmentID = fid - stop.pointee = true - } - } - - return RDEPUBTextPageMetadata( - breakReason: isLastPage ? .chapterEnd : .frameLimit, - blockRange: nil, - attachmentRanges: attachmentRanges, - attachmentKinds: attachmentKinds, - blockKinds: blockKinds, - semanticHints: semanticHints, - attachmentPlacements: attachmentPlacements, - trailingFragmentID: trailingFragmentID, - diagnostics: [] - ) - } - - private func assembleRuntimeChapter( - from chapter: RDEPUBTextChapter, - spineIndex: Int, - pageSize: CGSize, - layoutConfig: RDEPUBTextLayoutConfig, - context: RDEPUBReaderContext, - layoutSnapshot: RDEPUBLayoutSnapshot? = nil - ) throws -> RDEPUBRuntimeChapter { - let layouter = RDEPUBTextLayouter( - attributedString: chapter.attributedContent, - pageSize: pageSize, - config: layoutConfig - ) - - let offsetMap = RDEPUBChapterOffsetMap( - fragmentOffsets: chapter.fragmentOffsets, - pageStartOffsets: chapter.pages.map { $0.pageStartOffset }, - pageEndOffsets: chapter.pages.map { $0.pageEndOffset }, - cfiMap: chapter.cfiMap, - chapterText: chapter.attributedContent.string - ) - - let pageRanges = chapter.pages.map { $0.contentRange } - let cacheKey = makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: layoutSnapshot) - summaryDiskCache?.write(summary: makeSummary(for: chapter.pages, fragmentOffsets: chapter.fragmentOffsets, offsetMap: offsetMap, cacheKey: cacheKey), for: cacheKey) - - return RDEPUBRuntimeChapter( - spineIndex: spineIndex, - href: chapter.href, - title: chapter.title, - sourceAttributedString: nil, - typesetAttributedString: chapter.attributedContent, - layouter: layouter, - pageRanges: pageRanges, - pages: chapter.pages, - chapterOffsetMap: offsetMap - ) - } - - private func makeCacheKey(spineIndex: Int, context: RDEPUBReaderContext, layoutSnapshot: RDEPUBLayoutSnapshot? = nil) -> RDEPUBChapterCacheKey { - let style: RDEPUBTextRenderStyle - let layoutConfig: RDEPUBTextLayoutConfig - let lineHeightMultiple: CGFloat - - if let snapshot = layoutSnapshot { - style = snapshot.style - layoutConfig = snapshot.layoutConfig - lineHeightMultiple = context.configuration.lineHeightMultiple - } else { - assert(Thread.isMainThread, "makeCacheKey() requires a layoutSnapshot when called off the main thread. Capture a snapshot via makeLayoutSnapshot() before dispatching to a background queue.") - let pageSize = context.currentTextPageSize() - style = context.currentTextRenderStyle() - layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize) - lineHeightMultiple = context.configuration.lineHeightMultiple - } - - let renderSignature = [ - style.font.fontName, - "\(style.font.pointSize)", - "\(lineHeightMultiple)", - "\(style.lineSpacing)", - layoutConfig.cacheSignature, - "\(RDEPUBChapterSummary.currentSchemaVersion)" - ].joined(separator: "|") - - let contentHash = contentHashForSpineIndex(spineIndex, context: context) - - return RDEPUBChapterCacheKey( - bookID: context.currentBookIdentifier ?? "", - spineIndex: spineIndex, - renderSignature: renderSignature, - chapterContentHash: contentHash - ) - } - - private func scheduleDeferredCFIMapBuildIfNeeded( - for chapter: RDEPUBRuntimeChapter, - cacheKey: RDEPUBChapterCacheKey, - store: RDEPUBChapterRuntimeStore - ) { - guard chapter.chapterOffsetMap.cfiMap == nil, - store.beginBuildingCFIMap(for: chapter.spineIndex) else { - return - } - - let spineIndex = chapter.spineIndex - let href = chapter.href - let fragmentOffsets = chapter.chapterOffsetMap.fragmentOffsets - let chapterText = chapter.chapterOffsetMap.chapterText - - store.chapterLoadQueue.async { - defer { store.endBuildingCFIMap(for: spineIndex) } - guard let rawHTML = self.context?.parser?.htmlString(forRelativePath: href), - let chapterText else { - return - } - - let cfiMap = self.makeCFIMap( - href: href, - spineIndex: spineIndex, - fragmentOffsets: fragmentOffsets, - rawHTML: rawHTML, - chapterText: chapterText - ) - chapter.updateCFIMap(cfiMap) - self.summaryDiskCache?.write( - summary: self.makeSummary( - for: chapter.pages, - fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets, - offsetMap: chapter.chapterOffsetMap, - cacheKey: cacheKey - ), - for: cacheKey - ) - DispatchQueue.main.async { - self.onDeferredCFIMapReady?(spineIndex) - } - } - } - - private func makeSummary( - for pages: [RDEPUBTextPage], - fragmentOffsets: [String: Int], - offsetMap: RDEPUBChapterOffsetMap, - cacheKey: RDEPUBChapterCacheKey - ) -> RDEPUBChapterSummary { - RDEPUBChapterSummary( - pageRanges: pages.map { .init(location: $0.contentRange.location, length: $0.contentRange.length) }, - pageCount: pages.count, - fragmentOffsets: fragmentOffsets, - cfiMap: offsetMap.cfiMap, - renderSignature: cacheKey.renderSignature, - schemaVersion: RDEPUBChapterSummary.currentSchemaVersion, - chapterContentHash: cacheKey.chapterContentHash, - pageMetadataList: pages.map { .from($0.metadata) } - ) - } - - private func contentHashForSpineIndex(_ spineIndex: Int, context: RDEPUBReaderContext) -> String { - guard let parser = context.parser, - let publication = context.publication else { return "" } - let href = publication.spine[spineIndex].href - guard let html = parser.htmlString(forRelativePath: href) else { return "" } - return html.rd_sha256Hex - } - - private func requireHTMLString(_ parser: RDEPUBParser, href: String) throws -> String { - guard let html = parser.htmlString(forRelativePath: href) else { - throw RDEPUBChapterLoadError.emptyChapterHref(href) - } - return html - } - - private func makeCFIMap( - href: String, - spineIndex: Int, - fragmentOffsets: [String: Int], - rawHTML: String?, - chapterText: String - ) -> RDEPUBCFIMap { - if let rawHTML { - return RDEPUBCFITextNodeMapBuilder.makeMap( - href: href, - rawHTML: rawHTML, - chapterText: chapterText, - fragmentOffsets: fragmentOffsets - ) - } - - let domPaths: [String: RDEPUBCFIPath] = [:] - let markers = fragmentOffsets - .sorted { $0.value < $1.value } - .map { fragmentID, offset in - let cfi = RDEPUBCFIGenerator.makeOffsetCFI( - href: href, - fileIndex: spineIndex, - chapterOffset: offset, - fragmentID: fragmentID - ) - return RDEPUBCFIMarker( - cfiPath: domPaths[fragmentID] ?? cfi.contentPath, - chapterOffset: offset, - fragmentID: fragmentID - ) - } - return RDEPUBCFIMap( - href: href, - markers: markers, - recoveryMetadata: RDEPUBCFIRecoveryMetadata( - domFingerprint: "", - normalizedTextChecksum: RDEPUBCFITextNodeMapBuilder.normalizedText(from: chapterText).rd_sha256Hex, - fragmentPathMap: domPaths - ) - ) - } -} - -private extension RDEPUBChapterLoader.LoadPriority { - - static func higherPriority(_ lhs: Self, _ rhs: Self) -> Self { - if lhs.rank >= rhs.rank { - return lhs - } - return rhs - } - - var rank: Int { - switch self { - case .prefetch: - return 0 - case .preview: - return 1 - case .navigation: - return 2 - } - } -} - -enum RDEPUBChapterLoadError: LocalizedError { - - case missingParser - - case emptyChapter(spineIndex: Int) - - case emptyChapterHref(String) - - var errorDescription: String? { - switch self { - case .missingParser: - return "章节加载失败:缺少解析上下文。" - case .emptyChapter(let spineIndex): - return "章节加载失败:第 \(spineIndex) 章无法生成分页内容。" - case .emptyChapterHref(let href): - return "章节加载失败:未找到章节资源 \(href)。" - } - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLocation.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLocation.swift deleted file mode 100644 index 6a33766..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLocation.swift +++ /dev/null @@ -1,30 +0,0 @@ -import Foundation - -public struct RDEPUBChapterLocation: Codable, Equatable { - - public var spineIndex: Int - - public var chapterOffset: Int - - public var fragmentID: String? - - public var progressionInChapter: Double? - - public var schemaVersion: Int - - public init( - spineIndex: Int, - chapterOffset: Int, - fragmentID: String? = nil, - progressionInChapter: Double? = nil, - schemaVersion: Int = 2 - ) { - self.spineIndex = spineIndex - self.chapterOffset = chapterOffset - self.fragmentID = fragmentID.flatMap { $0.isEmpty ? nil : $0 } - self.progressionInChapter = progressionInChapter - self.schemaVersion = schemaVersion - } - - var isFallbackEstimate: Bool { schemaVersion == 1 } -} \ No newline at end of file diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterOffsetMap.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterOffsetMap.swift deleted file mode 100644 index af1323f..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterOffsetMap.swift +++ /dev/null @@ -1,69 +0,0 @@ -import Foundation - -final class RDEPUBChapterOffsetMap { - - let fragmentOffsets: [String: Int] - - let pageStartOffsets: [Int] - - let pageEndOffsets: [Int] - - private let cfiMapLock = NSLock() - - private var _cfiMap: RDEPUBCFIMap? - - var cfiMap: RDEPUBCFIMap? { - cfiMapLock.lock() - defer { cfiMapLock.unlock() } - return _cfiMap - } - - let chapterText: String? - - init( - fragmentOffsets: [String: Int], - pageStartOffsets: [Int], - pageEndOffsets: [Int], - cfiMap: RDEPUBCFIMap?, - chapterText: String? - ) { - self.fragmentOffsets = fragmentOffsets - self.pageStartOffsets = pageStartOffsets - self.pageEndOffsets = pageEndOffsets - self._cfiMap = cfiMap - self.chapterText = chapterText - } - - func updateCFIMap(_ cfiMap: RDEPUBCFIMap) { - cfiMapLock.lock() - _cfiMap = cfiMap - cfiMapLock.unlock() - } - - func chapterOffset(forFragmentID fragmentID: String) -> Int? { - return fragmentOffsets[fragmentID] - } - - func chapterOffset(forCFI rawCFI: String?) -> Int? { - guard let cfi = RDEPUBCFICompatibility.parseLossy(rawCFI) else { return nil } - let resolved = RDEPUBCFIResolver.resolve(cfi) - let lastOffset = max((pageEndOffsets.max() ?? 0), 0) - return RDEPUBCFIRecoveryEngine.recover( - cfi: cfi, - cfiMap: cfiMap, - chapterText: chapterText, - fragmentOffsets: fragmentOffsets, - fallbackOffset: resolved.chapterOffset, - lastOffset: lastOffset - )?.chapterOffset - } - - func pageIndex(forChapterOffset offset: Int) -> Int? { - for i in 0..= pageStartOffsets[i] && offset <= pageEndOffsets[i] { - return i - } - } - return nil - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterRuntimeStore.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterRuntimeStore.swift deleted file mode 100644 index 7b8efc7..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterRuntimeStore.swift +++ /dev/null @@ -1,203 +0,0 @@ -import UIKit - -final class RDEPUBChapterRuntimeStore { - - private let chapterDataCache = RDEPUBChapterDataCache() - - private let pageCountCache = RDEPUBPageCountCache() - - let imageCache = NSCache() - - let chapterLoadQueue = DispatchQueue(label: "com.RDEpubReader.chapterload", qos: .userInitiated) - - private let chapterLoadQueueKey = DispatchSpecificKey() - - // M-02: currentSpineIndex and windowSpineIndices are accessed only from the main thread - // (verified by audit of all 7 access points). They are not protected by locks unlike - // navigationLock/prefetchLock/buildingLock/cfiMapLock, but this is safe as long as - // access remains main-thread-only. Do NOT access from chapterLoadQueue. - private(set) var currentSpineIndex: Int? - - private(set) var windowSpineIndices: [Int] = [] - - private var pendingNavigationTarget: Int? - - private let navigationLock = NSLock() - - private var pendingPrefetchTargets: Set = [] - - private let prefetchLock = NSLock() - - private(set) var isBuilding: Bool = false - - private let buildingLock = NSLock() - - private var buildingCFIMapSpineIndices: Set = [] - - private let cfiMapLock = NSLock() - - private var pendingChapterLoadSpineIndices: Set = [] - - private let pendingChapterLoadLock = NSLock() - - init() { - - imageCache.countLimit = 50 - imageCache.totalCostLimit = 104_857_600 // 100 MB - - chapterLoadQueue.setSpecific(key: chapterLoadQueueKey, value: ()) - } - - func assertNotOnChapterLoadQueue() { - dispatchPrecondition(condition: .notOnQueue(chapterLoadQueue)) - } - - func chapterData(for spineIndex: Int) -> RDEPUBRuntimeChapter? { - return chapterDataCache[spineIndex] - } - - func pageCount(for key: RDEPUBChapterCacheKey) -> RDEPUBRuntimePageCount? { - return pageCountCache[key] - } - - func insertChapter(_ chapter: RDEPUBRuntimeChapter) { - chapterDataCache[chapter.spineIndex] = chapter - RDEPUBMemoryProbe.log("chapterLoaded spine=\(chapter.spineIndex) pages=\(chapter.pages.count)") - } - - func insertPageCount(_ pc: RDEPUBRuntimePageCount, for key: RDEPUBChapterCacheKey) { - pageCountCache[key] = pc - } - - func setCurrentChapter(spineIndex: Int, totalSpineCount: Int, windowRadius: Int = 1) { - currentSpineIndex = spineIndex - let radius = max(0, windowRadius) - let lowerBound = max(0, spineIndex - radius) - let upperBound = min(totalSpineCount - 1, spineIndex + radius) - guard lowerBound <= upperBound else { - windowSpineIndices = [spineIndex] - return - } - windowSpineIndices = Array(lowerBound...upperBound) - } - - func evictableSpineIndices() -> [Int] { - let windowSet = Set(windowSpineIndices) - return chapterDataCache.storedSpineIndices.filter { !windowSet.contains($0) } - } - - func evict(spineIndex: Int) { - chapterDataCache.remove(spineIndex: spineIndex) - pageCountCache.remove(forSpineIndex: spineIndex) - } - - func evictAllExceptCurrent() { - guard let current = currentSpineIndex else { - chapterDataCache.removeAll() - pageCountCache.removeAll() - return - } - let currentChapter = chapterDataCache[current] - chapterDataCache.removeAll() - if let ch = currentChapter { - chapterDataCache[current] = ch - } - - pageCountCache.removeAll() - } - - func handleMemoryWarning() { - evictAllExceptCurrent() - imageCache.removeAllObjects() - } - - func setNavigationTarget(spineIndex: Int) { - navigationLock.lock() - pendingNavigationTarget = spineIndex - navigationLock.unlock() - } - - func consumeNavigationTarget() -> Int? { - navigationLock.lock() - let target = pendingNavigationTarget - pendingNavigationTarget = nil - navigationLock.unlock() - return target - } - - func addPrefetchTarget(_ spineIndex: Int) { - prefetchLock.lock() - pendingPrefetchTargets.insert(spineIndex) - prefetchLock.unlock() - } - - func removePrefetchTarget(_ spineIndex: Int) { - prefetchLock.lock() - pendingPrefetchTargets.remove(spineIndex) - prefetchLock.unlock() - } - - func clearPrefetchTargets() { - prefetchLock.lock() - pendingPrefetchTargets.removeAll() - prefetchLock.unlock() - } - - func hasPrefetchTarget(_ spineIndex: Int) -> Bool { - prefetchLock.lock() - let has = pendingPrefetchTargets.contains(spineIndex) - prefetchLock.unlock() - return has - } - - func markBuilding(_ building: Bool) { - buildingLock.lock() - isBuilding = building - buildingLock.unlock() - } - - func beginPendingChapterLoad(for spineIndex: Int) -> Bool { - pendingChapterLoadLock.lock() - defer { pendingChapterLoadLock.unlock() } - return pendingChapterLoadSpineIndices.insert(spineIndex).inserted - } - - func endPendingChapterLoad(for spineIndex: Int) { - pendingChapterLoadLock.lock() - pendingChapterLoadSpineIndices.remove(spineIndex) - pendingChapterLoadLock.unlock() - } - - func hasPendingChapterLoad(for spineIndex: Int) -> Bool { - pendingChapterLoadLock.lock() - let hasPendingLoad = pendingChapterLoadSpineIndices.contains(spineIndex) - pendingChapterLoadLock.unlock() - return hasPendingLoad - } - - func beginBuildingCFIMap(for spineIndex: Int) -> Bool { - cfiMapLock.lock() - defer { cfiMapLock.unlock() } - let inserted = buildingCFIMapSpineIndices.insert(spineIndex).inserted - return inserted - } - - func endBuildingCFIMap(for spineIndex: Int) { - cfiMapLock.lock() - buildingCFIMapSpineIndices.remove(spineIndex) - cfiMapLock.unlock() - } - - func invalidateAllForSettingsChange() { - RDEPUBMemoryProbe.log("settingsChangeInvalidateAll") - chapterDataCache.removeAll() - pageCountCache.removeAll() - imageCache.removeAllObjects() - cfiMapLock.lock() - buildingCFIMapSpineIndices.removeAll() - cfiMapLock.unlock() - pendingChapterLoadLock.lock() - pendingChapterLoadSpineIndices.removeAll() - pendingChapterLoadLock.unlock() - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterSummaryDiskCache.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterSummaryDiskCache.swift deleted file mode 100644 index e0a5a79..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterSummaryDiskCache.swift +++ /dev/null @@ -1,228 +0,0 @@ -import Foundation - -final class RDEPUBChapterSummaryDiskCache { - - private let cacheDirectory: URL - - private let fileManager = FileManager.default - - private let queue = DispatchQueue(label: "com.RDEpubReader.summarydiskcache", qos: .utility) - - init(cacheDirectory: URL) { - self.cacheDirectory = cacheDirectory - try? fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true) - } - - func write(summary: RDEPUBChapterSummary, for key: RDEPUBChapterCacheKey) { - queue.async { - self.writeImmediately(summary: summary, for: key) - } - } - - func writeSynchronously(summary: RDEPUBChapterSummary, for key: RDEPUBChapterCacheKey) { - queue.sync { - self.writeImmediately(summary: summary, for: key) - } - } - - func flushPendingWrites() { - queue.sync { } - } - - func read(for key: RDEPUBChapterCacheKey) -> RDEPUBChapterSummary? { - let fileURL = self.fileURL(for: key) - let data: Data - do { - data = try Data(contentsOf: fileURL) - } catch { - let nsError = error as NSError - if nsError.domain == NSCocoaErrorDomain && nsError.code == NSFileReadNoSuchFileError { - - } else { - #if DEBUG - print("[RDEPUBChapterSummaryDiskCache] ⚠️ read IO error for \(fileURL.lastPathComponent): \(error.localizedDescription)") - #endif - } - return nil - } - do { - return try JSONDecoder().decode(RDEPUBChapterSummary.self, from: data) - } catch { - #if DEBUG - print("[RDEPUBChapterSummaryDiskCache] ⚠️ decode error for \(fileURL.lastPathComponent): \(error.localizedDescription)") - #endif - return nil - } - } - - func readAll(keys: [(key: RDEPUBChapterCacheKey, spineIndex: Int, href: String, title: String)]) -> ( - summaries: [Int: RDEPUBChapterSummary], - mapBuilder: RDEPUBBookPageMap.Builder - ) { - var summaries: [Int: RDEPUBChapterSummary] = [:] - var mapBuilder = RDEPUBBookPageMap.Builder() - - for item in keys { - if let summary = read(for: item.key) { - summaries[item.spineIndex] = summary - mapBuilder.add( - spineIndex: item.spineIndex, - href: item.href, - title: item.title, - pageCount: summary.pageCount, - fragmentOffsets: summary.fragmentOffsets - ) - } - } - return (summaries, mapBuilder) - } - - func isCacheComplete(keys: [RDEPUBChapterCacheKey]) -> Bool { - for key in keys { - if read(for: key) == nil { - return false - } - } - return true - } - - func removeAll() { - removeFiles(matching: { _ in true }) - } - - func removeAll(forBookID bookID: String) { - let bookPrefix = Self.cacheNamespacePrefix(for: bookID) - removeFiles { $0.hasPrefix(bookPrefix + "__") } - } - - func removeAll(forRenderSignature renderSignature: String) { - let renderPrefix = "__" + Self.cacheNamespacePrefix(for: renderSignature) + "__" - removeFiles { $0.contains(renderPrefix) } - } - - var cacheStatistics: (fileCount: Int, totalBytes: Int64) { - guard let files = try? fileManager.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: [.fileSizeKey]) else { - return (0, 0) - } - var count = 0 - var totalBytes: Int64 = 0 - for fileURL in files where fileURL.pathExtension == "json" { - count += 1 - if let size = try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize { - totalBytes += Int64(size) - } - } - return (count, totalBytes) - } - - private func fileURL(for key: RDEPUBChapterCacheKey) -> URL { - let bookPrefix = Self.cacheNamespacePrefix(for: key.bookID) - let renderPrefix = Self.cacheNamespacePrefix(for: key.renderSignature) - let rawKey = "\(key.bookID)_\(key.spineIndex)_\(key.renderSignature)_\(key.chapterContentHash)" - let digest = rawKey.rd_sha256Hex - return cacheDirectory.appendingPathComponent("\(bookPrefix)__\(renderPrefix)__\(digest).json") - } - - private func writeImmediately(summary: RDEPUBChapterSummary, for key: RDEPUBChapterCacheKey) { - let fileURL = self.fileURL(for: key) - let tmpURL = fileURL.appendingPathExtension("tmp") - do { - let data = try JSONEncoder().encode(summary) - try data.write(to: tmpURL) - if fileManager.fileExists(atPath: fileURL.path) { - _ = try fileManager.replaceItemAt(fileURL, withItemAt: tmpURL) - } else { - try fileManager.moveItem(at: tmpURL, to: fileURL) - } - } catch { - #if DEBUG - print("[RDEPUBChapterSummaryDiskCache] ⚠️ write error for \(fileURL.lastPathComponent): \(error.localizedDescription)") - #endif - try? fileManager.removeItem(at: tmpURL) - } - } - - private func removeFiles(matching predicate: (String) -> Bool) { - guard let files = try? fileManager.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: nil) else { return } - for fileURL in files where fileURL.pathExtension == "json" && predicate(fileURL.lastPathComponent) { - try? fileManager.removeItem(at: fileURL) - } - } - - private static func cacheNamespacePrefix(for rawValue: String) -> String { - rawValue.rd_sha256Hex.prefix(12).lowercased() - } -} - -struct RDEPUBChapterSummary: Codable { - - let pageRanges: [RangeData] - - let pageCount: Int - - let fragmentOffsets: [String: Int] - - let cfiMap: RDEPUBCFIMap? - - let renderSignature: String - - let schemaVersion: Int - - let chapterContentHash: String - - let pageMetadataList: [PageMetadataSummary] - - static let currentSchemaVersion = 17 - - struct RangeData: Codable { - - let location: Int - - let length: Int - - var nsRange: NSRange { NSRange(location: location, length: length) } - } - - struct PageMetadataSummary: Codable { - - let breakReason: String - - let attachmentRanges: [RangeData] - - let attachmentKinds: [String] - - let blockKinds: [String] - - let semanticHints: [String] - - let attachmentPlacements: [String] - - let trailingFragmentID: String? - - func toPageMetadata() -> RDEPUBTextPageMetadata { - RDEPUBTextPageMetadata( - breakReason: RDEPUBTextPageBreakReason(rawValue: breakReason) ?? .frameLimit, - blockRange: nil, - attachmentRanges: attachmentRanges.map { $0.nsRange }, - attachmentKinds: attachmentKinds.compactMap { RDEPUBTextAttachmentKind(rawValue: $0) }, - blockKinds: blockKinds.compactMap { RDEPUBTextBlockKind(rawValue: $0) }, - semanticHints: semanticHints.compactMap { RDEPUBTextSemanticHint(rawValue: $0) }, - attachmentPlacements: attachmentPlacements.compactMap { RDEPUBTextAttachmentPlacement(rawValue: $0) }, - trailingFragmentID: trailingFragmentID, - diagnostics: [] - ) - } - - static func from(_ metadata: RDEPUBTextPageMetadata) -> PageMetadataSummary { - PageMetadataSummary( - breakReason: metadata.breakReason.rawValue, - attachmentRanges: metadata.attachmentRanges.map { .init(location: $0.location, length: $0.length) }, - attachmentKinds: metadata.attachmentKinds.map { $0.rawValue }, - blockKinds: metadata.blockKinds.map { $0.rawValue }, - semanticHints: metadata.semanticHints.map { $0.rawValue }, - attachmentPlacements: metadata.attachmentPlacements.map { $0.rawValue }, - trailingFragmentID: metadata.trailingFragmentID - ) - } - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterWarmupOrchestrator.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterWarmupOrchestrator.swift deleted file mode 100644 index 7d23fab..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterWarmupOrchestrator.swift +++ /dev/null @@ -1,679 +0,0 @@ -import Foundation - -final class RDEPUBChapterWarmupOrchestrator { - - private unowned let context: RDEPUBReaderContext - - private unowned let store: RDEPUBChapterRuntimeStore - - private unowned let loader: RDEPUBChapterLoader - - private unowned let presentationRuntime: RDEPUBPresentationRuntime - - private unowned let locationCoordinator: RDEPUBReaderLocationCoordinator - - private unowned let backgroundPriorityManager: RDEPUBBackgroundPriorityManager - - private unowned let jumpSessionManager: RDEPUBJumpSessionManager - - private let refreshVisibleContentPreservingLocation: () -> Void - - private let asyncLoadStateLock = NSLock() - - private var asynchronouslyPreparingSpineIndices: Set = [] - - private var isExtendingPartialBookPageMap = false - - private let prepareRequestStateLock = NSLock() - - private var pendingPreparePageNumbers: Set = [] - - private var recentPrepareTimestamps: [Int: CFAbsoluteTime] = [:] - - private let prepareRequestDebounceInterval: CFTimeInterval = 0.15 - - private static let upcomingChapterLookaheadCount = 2 - - init( - context: RDEPUBReaderContext, - store: RDEPUBChapterRuntimeStore, - loader: RDEPUBChapterLoader, - presentationRuntime: RDEPUBPresentationRuntime, - locationCoordinator: RDEPUBReaderLocationCoordinator, - backgroundPriorityManager: RDEPUBBackgroundPriorityManager, - jumpSessionManager: RDEPUBJumpSessionManager, - refreshVisibleContentPreservingLocation: @escaping () -> Void - ) { - self.context = context - self.store = store - self.loader = loader - self.presentationRuntime = presentationRuntime - self.locationCoordinator = locationCoordinator - self.backgroundPriorityManager = backgroundPriorityManager - self.jumpSessionManager = jumpSessionManager - self.refreshVisibleContentPreservingLocation = refreshVisibleContentPreservingLocation - } - - @discardableResult - func prepareOnDemandChapter( - forAbsolutePageNumber pageNumber: Int, - allowSynchronousLoad: Bool = true, - completion: ((Bool) -> Void)? = nil - ) -> Bool { - guard let bookPageMap = context.bookPageMap, - let publication = context.publication else { - return false - } - let absolutePageIndex = pageNumber - 1 - guard let spineIndex = bookPageMap.spineIndex(forAbsolutePage: absolutePageIndex) else { - return false - } - let chapterReady = store.chapterData(for: spineIndex) != nil - - if let debouncedResult = debouncedPrepareResult( - pageNumber: pageNumber, - spineIndex: spineIndex, - chapterReady: chapterReady, - allowSynchronousLoad: allowSynchronousLoad - ) { - return debouncedResult - } - - store.setCurrentChapter( - spineIndex: spineIndex, - totalSpineCount: publication.spine.count, - windowRadius: context.configuration.chapterWindowRadius - ) - if !chapterReady { - guard allowSynchronousLoad else { - scheduleAsynchronousChapterPreparation( - spineIndex: spineIndex, - triggerPageNumber: pageNumber, - completion: completion - ) - return false - } - do { - _ = try loader.loadChapterSynchronouslyForMigration( - spineIndex: spineIndex, - store: store - ) - } catch { - clearPendingPreparePageNumber(pageNumber) - return false - } - } - - markPrepareResolved(pageNumber) - completion?(true) - maybePrefetchUpcomingChapters(aroundAbsolutePageNumber: pageNumber, in: bookPageMap) - scheduleAdjacentChapterPrefetches(for: spineIndex, totalSpineCount: publication.spine.count) - return true - } - - func extendPartialBookPageMapIfNeeded( - currentPageNumber: Int, - minimumTrailingPages: Int = 2, - batchChapterCount: Int = 3 - ) { - guard let publication = context.publication, - let currentMap = context.bookPageMap else { - return - } - - let buildableSpineIndices = buildableSpineIndices(in: publication) - guard currentMap.totalChapters < buildableSpineIndices.count else { - return - } - - let isNearEnd = currentMap.totalPages - currentPageNumber <= minimumTrailingPages - let isNearStart = currentPageNumber <= minimumTrailingPages - - var spineIndicesToAppend: [Int] = [] - if isNearEnd { - let lastKnownSpineIndex = currentMap.entries.last?.spineIndex ?? -1 - spineIndicesToAppend = Array(buildableSpineIndices.filter { $0 > lastKnownSpineIndex }.prefix(batchChapterCount)) - } else if isNearStart { - let firstKnownSpineIndex = currentMap.entries.first?.spineIndex ?? Int.max - let prependCandidates = buildableSpineIndices.filter { $0 < firstKnownSpineIndex } - spineIndicesToAppend = Array(prependCandidates.suffix(batchChapterCount)) - } else { - return - } - - guard !spineIndicesToAppend.isEmpty, beginPartialBookPageMapExtension() else { - return - } - - - let currentLocation = locationCoordinator.currentVisibleLocation() - let loadedChaptersLock = NSLock() - var loadedChapters: [Int: RDEPUBRuntimeChapter] = [:] - let group = DispatchGroup() - - for spineIndex in spineIndicesToAppend { - group.enter() - loader.loadChapter( - spineIndex: spineIndex, - store: store, - priority: .prefetch - ) { result in - defer { group.leave() } - switch result { - case .success(let chapter): - loadedChaptersLock.lock() - loadedChapters[spineIndex] = chapter - loadedChaptersLock.unlock() - case .failure: - break - } - } - } - - group.notify(queue: .main) { [weak self] in - guard let self else { return } - defer { self.endPartialBookPageMapExtension() } - // Use live position values rather than the stale captured values, - // since the user may have turned several pages since the extension began. - let livePageNumber = max(self.context.readerView?.currentPage ?? 0, 0) + 1 - let liveLocation = self.locationCoordinator.currentVisibleLocation() - self.applyAsyncPartialBookPageMapExtension( - currentPageNumber: livePageNumber, - currentLocation: liveLocation, - currentMap: currentMap, - loadedChapters: loadedChapters - ) - } - } - - func prefetchForwardChaptersAfterInitialOpen(anchorSpineIndex: Int, totalSpineCount: Int) { - guard context.publication != nil else { return } - - store.setCurrentChapter( - spineIndex: anchorSpineIndex, - totalSpineCount: totalSpineCount, - windowRadius: context.configuration.chapterWindowRadius - ) - - let forwardTargets = store.windowSpineIndices.filter { $0 > anchorSpineIndex } - guard !forwardTargets.isEmpty else { return } - - for spineIndex in forwardTargets { - if store.chapterData(for: spineIndex) != nil { - appendLoadedForwardChaptersToCurrentPageMapIfPossible() - continue - } - guard shouldSchedulePrefetch(for: spineIndex) else { continue } - - store.addPrefetchTarget(spineIndex) - loader.loadChapter(spineIndex: spineIndex, store: store, priority: .prefetch) { [weak self] result in - guard let self, case .success = result else { return } - self.appendLoadedForwardChaptersToCurrentPageMapIfPossible() - } - } - } - - @discardableResult - func ensureNavigationTargetAvailable(for location: RDEPUBLocation) -> Bool { - guard context.bookPageMap != nil, - let publication = context.publication, - let targetSpineIndex = context.normalizedSpineIndex(for: location) else { - return false - } - - let currentSpineIndex = locationCoordinator.currentVisibleLocation() - .flatMap { context.normalizedSpineIndex(for: $0) } - let isDistantJump = if let current = currentSpineIndex { - abs(current - targetSpineIndex) > context.configuration.jumpSessionPolicy.protectedNeighborRadius * 2 - } else { - false - } - - if context.bookPageMap?.entry(forSpineIndex: targetSpineIndex) != nil { - if isDistantJump { - jumpSessionManager.createSession( - anchorSpineIndex: targetSpineIndex, - reason: .tableOfContentsJump, - totalSpineCount: publication.spine.count - ) - } - return true - } - - if context.pendingPageMapUpdates.contains(where: { update in - update.pageMap.entry(forSpineIndex: targetSpineIndex) != nil - }) { - presentationRuntime.commitPendingPageMapUpdateIfNeeded() - if context.bookPageMap?.entry(forSpineIndex: targetSpineIndex) != nil { - if isDistantJump { - jumpSessionManager.createSession( - anchorSpineIndex: targetSpineIndex, - reason: .tableOfContentsJump, - totalSpineCount: publication.spine.count - ) - } - return true - } - } - - let buildableIndices = buildableSpineIndices(in: publication) - guard let anchorPosition = buildableIndices.firstIndex(of: targetSpineIndex) else { - return false - } - - let normalizedWindowSize = RDEPUBReaderConfiguration.normalizedChapterWindowSize( - context.configuration.onDemandChapterWindowSize - ) - let chapters = loadPartialWindowChapters( - around: anchorPosition, - in: buildableIndices, - targetSpineIndex: targetSpineIndex, - windowSize: normalizedWindowSize - ) - guard !chapters.isEmpty else { return false } - - store.setCurrentChapter( - spineIndex: targetSpineIndex, - totalSpineCount: publication.spine.count, - windowRadius: context.configuration.chapterWindowRadius - ) - let partialMap = makePartialPageMap(from: chapters) - context.bookPageMap = partialMap - context.replaceActiveSnapshot(presentationRuntime.makeSnapshot(from: partialMap)) - context.readerView?.reloadData() - - if isDistantJump { - jumpSessionManager.createSession( - anchorSpineIndex: targetSpineIndex, - reason: .tableOfContentsJump, - totalSpineCount: publication.spine.count - ) - backgroundPriorityManager.addWarmAnchor(spineIndex: targetSpineIndex) - } - - return partialMap.entry(forSpineIndex: targetSpineIndex) != nil - } - - func clear() { - asyncLoadStateLock.lock() - asynchronouslyPreparingSpineIndices.removeAll() - isExtendingPartialBookPageMap = false - asyncLoadStateLock.unlock() - prepareRequestStateLock.lock() - pendingPreparePageNumbers.removeAll() - recentPrepareTimestamps.removeAll() - prepareRequestStateLock.unlock() - } - - private func applyAsyncPartialBookPageMapExtension( - currentPageNumber: Int, - currentLocation: RDEPUBLocation?, - currentMap: RDEPUBBookPageMap, - loadedChapters: [Int: RDEPUBRuntimeChapter] - ) { - let appendedEntries = loadedChapters.keys.sorted().compactMap { spineIndex -> RDEPUBBookPageMapEntry? in - guard let chapter = loadedChapters[spineIndex] else { return nil } - return RDEPUBBookPageMapEntry( - spineIndex: chapter.spineIndex, - href: chapter.href, - title: chapter.title, - pageCount: chapter.pages.count, - absolutePageStart: 0, - fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets - ) - } - guard !appendedEntries.isEmpty else { - return - } - - let combinedEntries = (currentMap.entries.map { - RDEPUBBookPageMapEntry( - spineIndex: $0.spineIndex, - href: $0.href, - title: $0.title, - pageCount: $0.pageCount, - absolutePageStart: 0, - fragmentOffsets: $0.fragmentOffsets - ) - } + appendedEntries).sorted { $0.spineIndex < $1.spineIndex } - - var absolutePageStart = 0 - let normalizedEntries = combinedEntries.map { entry -> RDEPUBBookPageMapEntry in - let normalized = RDEPUBBookPageMapEntry( - spineIndex: entry.spineIndex, - href: entry.href, - title: entry.title, - pageCount: entry.pageCount, - absolutePageStart: absolutePageStart, - fragmentOffsets: entry.fragmentOffsets - ) - absolutePageStart += entry.pageCount - return normalized - } - - let newMap = RDEPUBBookPageMap(entries: normalizedEntries) - presentationRuntime.queueExtendedPartialPageMap( - newMap, - currentPageNumber: currentPageNumber, - currentLocation: currentLocation - ) - } - - private func scheduleAsynchronousChapterPreparation( - spineIndex: Int, - triggerPageNumber: Int, - completion: ((Bool) -> Void)? - ) { - guard beginAsynchronousChapterPreparation(for: spineIndex) else { - return - } - loader.loadChapter( - spineIndex: spineIndex, - store: store, - priority: .preview - ) { [weak self] result in - guard let self else { return } - self.endAsynchronousChapterPreparation(for: spineIndex) - switch result { - case .success: - self.markPrepareResolved(triggerPageNumber) - completion?(true) - self.appendLoadedForwardChaptersToCurrentPageMapIfPossible() - self.refreshVisibleContentIfNeeded(afterPreparing: spineIndex, triggerPageNumber: triggerPageNumber) - case .failure(let error): - self.clearPendingPreparePageNumber(triggerPageNumber) - completion?(false) - } - } - } - - private func scheduleAdjacentChapterPrefetches(for spineIndex: Int, totalSpineCount: Int) { - store.setCurrentChapter( - spineIndex: spineIndex, - totalSpineCount: totalSpineCount, - windowRadius: context.configuration.chapterWindowRadius - ) - - // Keep chapters that maybePrefetchUpcomingChapters is responsible for, - // otherwise the two policies evict/rebuild the same chapter in a loop. - let retainedLookaheadIndices = upcomingLookaheadSpineIndices(after: spineIndex) - for evictable in store.evictableSpineIndices() where !retainedLookaheadIndices.contains(evictable) { - store.evict(spineIndex: evictable) - } - - for adjacentSpineIndex in store.windowSpineIndices where adjacentSpineIndex != spineIndex { - guard shouldSchedulePrefetch(for: adjacentSpineIndex) else { continue } - store.addPrefetchTarget(adjacentSpineIndex) - loader.loadChapter( - spineIndex: adjacentSpineIndex, - store: store, - priority: .prefetch - ) { [weak self] result in - guard let self, case .success = result else { return } - self.appendLoadedForwardChaptersToCurrentPageMapIfPossible() - } - } - } - - private func upcomingLookaheadSpineIndices(after spineIndex: Int) -> Set { - guard let publication = context.publication else { return [] } - let buildableIndices = buildableSpineIndices(in: publication) - guard let currentPosition = buildableIndices.firstIndex(of: spineIndex) else { return [] } - return Set(buildableIndices.dropFirst(currentPosition + 1).prefix(Self.upcomingChapterLookaheadCount)) - } - - private func maybePrefetchUpcomingChapters( - aroundAbsolutePageNumber pageNumber: Int, - in bookPageMap: RDEPUBBookPageMap, - threshold: Int = 3, - lookaheadChapterCount: Int = RDEPUBChapterWarmupOrchestrator.upcomingChapterLookaheadCount - ) { - guard let publication = context.publication else { return } - let absolutePageIndex = pageNumber - 1 - guard let spineIndex = bookPageMap.spineIndex(forAbsolutePage: absolutePageIndex), - let localPageIndex = bookPageMap.localPageIndex(forAbsolutePage: absolutePageIndex), - let chapter = store.chapterData(for: spineIndex) else { - return - } - - let remainingPages = chapter.pages.count - localPageIndex - 1 - guard remainingPages <= threshold else { return } - - let buildableIndices = buildableSpineIndices(in: publication) - guard let currentPosition = buildableIndices.firstIndex(of: spineIndex) else { return } - - let targets = buildableIndices.dropFirst(currentPosition + 1).prefix(lookaheadChapterCount) - for targetSpineIndex in targets { - guard shouldSchedulePrefetch(for: targetSpineIndex) else { continue } - store.addPrefetchTarget(targetSpineIndex) - loader.loadChapter(spineIndex: targetSpineIndex, store: store, priority: .prefetch) { [weak self] result in - guard let self, case .success = result else { return } - self.appendLoadedForwardChaptersToCurrentPageMapIfPossible() - } - } - } - - private func appendLoadedForwardChaptersToCurrentPageMapIfPossible( - minimumTrailingPages: Int = 2 - ) { - guard let publication = context.publication, - let currentMap = context.bookPageMap, - let readerView = context.readerView, - let lastKnownSpineIndex = currentMap.entries.last?.spineIndex else { - return - } - - let currentPageNumber = max(readerView.currentPage + 1, 1) - let trailingPages = currentMap.totalPages - currentPageNumber - guard trailingPages <= minimumTrailingPages else { return } - - let buildableIndices = buildableSpineIndices(in: publication) - var appendedEntries: [RDEPUBBookPageMapEntry] = [] - var projectedTotalPages = currentMap.totalPages - for spineIndex in buildableIndices where spineIndex > lastKnownSpineIndex { - guard let chapter = store.chapterData(for: spineIndex) else { break } - appendedEntries.append( - RDEPUBBookPageMapEntry( - spineIndex: chapter.spineIndex, - href: chapter.href, - title: chapter.title, - pageCount: chapter.pages.count, - absolutePageStart: 0, - fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets - ) - ) - projectedTotalPages += chapter.pages.count - if projectedTotalPages - currentPageNumber > minimumTrailingPages { - break - } - } - - guard !appendedEntries.isEmpty else { return } - - let existingEntries = currentMap.entries.map { - RDEPUBBookPageMapEntry( - spineIndex: $0.spineIndex, - href: $0.href, - title: $0.title, - pageCount: $0.pageCount, - absolutePageStart: 0, - fragmentOffsets: $0.fragmentOffsets - ) - } - - var absolutePageStart = 0 - let newEntries = (existingEntries + appendedEntries).map { entry -> RDEPUBBookPageMapEntry in - let normalizedEntry = RDEPUBBookPageMapEntry( - spineIndex: entry.spineIndex, - href: entry.href, - title: entry.title, - pageCount: entry.pageCount, - absolutePageStart: absolutePageStart, - fragmentOffsets: entry.fragmentOffsets - ) - absolutePageStart += entry.pageCount - return normalizedEntry - } - - let newMap = RDEPUBBookPageMap(entries: newEntries) - guard newMap.totalPages > currentMap.totalPages else { return } - - - presentationRuntime.queueForwardAppendedPageMap(newMap) - } - - private func shouldSchedulePrefetch(for spineIndex: Int) -> Bool { - guard store.chapterData(for: spineIndex) == nil else { return false } - guard !store.hasPrefetchTarget(spineIndex) else { return false } - guard !store.hasPendingChapterLoad(for: spineIndex) else { return false } - return true - } - - private func debouncedPrepareResult( - pageNumber: Int, - spineIndex: Int, - chapterReady: Bool, - allowSynchronousLoad: Bool - ) -> Bool? { - prepareRequestStateLock.lock() - defer { prepareRequestStateLock.unlock() } - - let now = CFAbsoluteTimeGetCurrent() - recentPrepareTimestamps = recentPrepareTimestamps.filter { now - $0.value <= prepareRequestDebounceInterval } - - if !allowSynchronousLoad && !chapterReady { - let inserted = pendingPreparePageNumbers.insert(pageNumber).inserted - if !inserted { - return false - } - return nil - } - - pendingPreparePageNumbers.remove(pageNumber) - if let lastTimestamp = recentPrepareTimestamps[pageNumber], - now - lastTimestamp <= prepareRequestDebounceInterval { - return chapterReady - } - - recentPrepareTimestamps[pageNumber] = now - return nil - } - - private func markPrepareResolved(_ pageNumber: Int) { - prepareRequestStateLock.lock() - pendingPreparePageNumbers.remove(pageNumber) - recentPrepareTimestamps[pageNumber] = CFAbsoluteTimeGetCurrent() - prepareRequestStateLock.unlock() - } - - private func clearPendingPreparePageNumber(_ pageNumber: Int) { - prepareRequestStateLock.lock() - pendingPreparePageNumbers.remove(pageNumber) - prepareRequestStateLock.unlock() - } - - private func refreshVisibleContentIfNeeded(afterPreparing spineIndex: Int, triggerPageNumber: Int) { - guard let readerView = context.readerView, - let bookPageMap = context.bookPageMap else { - return - } - if readerView.isPageCurlTransitioning { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in - self?.refreshVisibleContentIfNeeded( - afterPreparing: spineIndex, - triggerPageNumber: triggerPageNumber - ) - } - return - } - let visiblePageNumber = readerView.currentPage + 1 - if visiblePageNumber == triggerPageNumber { - refreshVisibleContentPreservingLocation() - return - } - guard visiblePageNumber > 0, - let visibleSpineIndex = bookPageMap.spineIndex(forAbsolutePage: visiblePageNumber - 1), - visibleSpineIndex == spineIndex else { - return - } - refreshVisibleContentPreservingLocation() - } - - // Intentional synchronous path: called from TOC distant jumps where - // the user expects immediate navigation. The loading indicator is shown - // by the caller. Do NOT convert to async without UX consideration. - private func loadPartialWindowChapters( - around anchorPosition: Int, - in buildableSpineIndices: [Int], - targetSpineIndex: Int, - windowSize: Int - ) -> [RDEPUBRuntimeChapter] { - let lowerBound = max(anchorPosition - max(windowSize / 2, 0), 0) - let upperBound = min(lowerBound + max(windowSize, 1), buildableSpineIndices.count) - let startIndex = max(0, upperBound - max(windowSize, 1)) - let window = Array(buildableSpineIndices[startIndex.. RDEPUBBookPageMap { - var builder = RDEPUBBookPageMap.Builder() - for chapter in chapters { - builder.add( - spineIndex: chapter.spineIndex, - href: chapter.href, - title: chapter.title, - pageCount: chapter.pages.count, - fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets - ) - } - return builder.build() - } - - private func buildableSpineIndices(in publication: RDEPUBPublication) -> [Int] { - publication.spine.indices.filter { index in - let item = publication.spine[index] - return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml")) - } - } - - private func beginAsynchronousChapterPreparation(for spineIndex: Int) -> Bool { - asyncLoadStateLock.lock() - defer { asyncLoadStateLock.unlock() } - return asynchronouslyPreparingSpineIndices.insert(spineIndex).inserted - } - - private func endAsynchronousChapterPreparation(for spineIndex: Int) { - asyncLoadStateLock.lock() - asynchronouslyPreparingSpineIndices.remove(spineIndex) - asyncLoadStateLock.unlock() - } - - private func beginPartialBookPageMapExtension() -> Bool { - asyncLoadStateLock.lock() - defer { asyncLoadStateLock.unlock() } - guard !isExtendingPartialBookPageMap else { return false } - isExtendingPartialBookPageMap = true - return true - } - - private func endPartialBookPageMapExtension() { - asyncLoadStateLock.lock() - isExtendingPartialBookPageMap = false - asyncLoadStateLock.unlock() - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBPageCountCache.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBPageCountCache.swift deleted file mode 100644 index df58a84..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBPageCountCache.swift +++ /dev/null @@ -1,33 +0,0 @@ -import Foundation - -final class RDEPUBPageCountCache { - - private var storage: [RDEPUBChapterCacheKey: RDEPUBRuntimePageCount] = [:] - - private let lock = NSLock() - - subscript(key: RDEPUBChapterCacheKey) -> RDEPUBRuntimePageCount? { - get { - lock.lock() - defer { lock.unlock() } - return storage[key] - } - set { - lock.lock() - defer { lock.unlock() } - storage[key] = newValue - } - } - - func remove(forSpineIndex spineIndex: Int) { - lock.lock() - defer { lock.unlock() } - storage = storage.filter { $0.value.spineIndex != spineIndex } - } - - func removeAll() { - lock.lock() - defer { lock.unlock() } - storage.removeAll() - } -} \ No newline at end of file diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBPageResolver.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBPageResolver.swift deleted file mode 100644 index af9d86b..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBPageResolver.swift +++ /dev/null @@ -1,40 +0,0 @@ -import Foundation - -struct RDEPUBResolvedPage { - - let page: RDEPUBTextPage - - let chapter: RDEPUBRuntimeChapter - - let chapterIndex: Int -} - -final class RDEPUBPageResolver { - - private unowned let context: RDEPUBReaderContext - - private let store: RDEPUBChapterRuntimeStore - - init(context: RDEPUBReaderContext, store: RDEPUBChapterRuntimeStore) { - self.context = context - self.store = store - } - - func resolvePage(absolutePageIndex: Int) -> RDEPUBResolvedPage? { - guard let bookPageMap = context.bookPageMap, - let spineIndex = bookPageMap.spineIndex(forAbsolutePage: absolutePageIndex), - let localPageIndex = bookPageMap.localPageIndex(forAbsolutePage: absolutePageIndex), - let chapter = store.chapterData(for: spineIndex), - chapter.pages.indices.contains(localPageIndex), - let chapterIndex = bookPageMap.chapterIndex(forSpineIndex: spineIndex) else { - return nil - } - - var page = chapter.pages[localPageIndex] - page.absolutePageIndex = absolutePageIndex - page.chapterIndex = chapterIndex - page.pageIndexInChapter = localPageIndex - page.totalPagesInChapter = chapter.pages.count - return RDEPUBResolvedPage(page: page, chapter: chapter, chapterIndex: chapterIndex) - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBRuntimeChapter.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBRuntimeChapter.swift deleted file mode 100644 index 9cfddd3..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBRuntimeChapter.swift +++ /dev/null @@ -1,52 +0,0 @@ -import Foundation - -final class RDEPUBRuntimeChapter { - - let spineIndex: Int - - let href: String - - let title: String - - var sourceAttributedString: NSAttributedString? - - let typesetAttributedString: NSAttributedString - - let layouter: RDEPUBTextLayouter - - let pageRanges: [NSRange] - - let pages: [RDEPUBTextPage] - - let chapterOffsetMap: RDEPUBChapterOffsetMap - - init( - spineIndex: Int, - href: String, - title: String, - sourceAttributedString: NSAttributedString?, - typesetAttributedString: NSAttributedString, - layouter: RDEPUBTextLayouter, - pageRanges: [NSRange], - pages: [RDEPUBTextPage], - chapterOffsetMap: RDEPUBChapterOffsetMap - ) { - self.spineIndex = spineIndex - self.href = href - self.title = title - self.sourceAttributedString = sourceAttributedString - self.typesetAttributedString = typesetAttributedString - self.layouter = layouter - self.pageRanges = pageRanges - self.pages = pages - self.chapterOffsetMap = chapterOffsetMap - } - - func releaseSourceText() { - sourceAttributedString = nil - } - - func updateCFIMap(_ cfiMap: RDEPUBCFIMap) { - chapterOffsetMap.updateCFIMap(cfiMap) - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBRuntimePageCount.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBRuntimePageCount.swift deleted file mode 100644 index c62966f..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBRuntimePageCount.swift +++ /dev/null @@ -1,14 +0,0 @@ -import Foundation - -struct RDEPUBRuntimePageCount { - - let cacheKey: RDEPUBChapterCacheKey - - let spineIndex: Int - - let pageRanges: [NSRange] - - let pageCount: Int - - let renderSignature: String -} \ No newline at end of file diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBBackgroundCoverageStore.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBBackgroundCoverageStore.swift deleted file mode 100644 index 7572b04..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBBackgroundCoverageStore.swift +++ /dev/null @@ -1,252 +0,0 @@ -import Foundation - -struct RDEPUBBackgroundCoverageSegment { - - let lowerSpineIndex: Int - - let upperSpineIndex: Int - - let pageMap: RDEPUBBookPageMap - - let resolvedSpineIndices: Set - - let generatedAt: CFAbsoluteTime - - let renderSignature: String - - let estimatedMemoryBytes: Int - - func contains(spineIndex: Int) -> Bool { - spineIndex >= lowerSpineIndex && spineIndex <= upperSpineIndex - } - - func distance(to spineIndex: Int) -> Int { - if contains(spineIndex: spineIndex) { return 0 } - return min(abs(spineIndex - lowerSpineIndex), abs(spineIndex - upperSpineIndex)) - } -} - -struct RDEPUBBackgroundCoverageStorePolicy { - - let maxResidentSegments: Int - - let maxChaptersPerSegment: Int - - let memoryBudgetBytes: Int - - static let `default` = RDEPUBBackgroundCoverageStorePolicy( - maxResidentSegments: 8, - maxChaptersPerSegment: 256, - memoryBudgetBytes: 8 * 1024 * 1024 - ) -} - -final class RDEPUBBackgroundCoverageStore { - - private unowned let context: RDEPUBReaderContext - - private let policy: RDEPUBBackgroundCoverageStorePolicy - - private var segments: [RDEPUBBackgroundCoverageSegment] = [] - - private var currentMemoryBytes: Int = 0 - - private var lastAccessTime: [Int: CFAbsoluteTime] = [:] - - init(context: RDEPUBReaderContext, policy: RDEPUBBackgroundCoverageStorePolicy = .default) { - self.context = context - self.policy = policy - } - - func addSegment(_ segment: RDEPUBBackgroundCoverageSegment) { - - evictIfNeeded(forNewSegment: segment) - - var merged = false - for (index, existing) in segments.enumerated() { - if canMerge(existing, segment) { - if let mergedSegment = mergeSegments(existing, segment) { - segments[index] = mergedSegment - currentMemoryBytes = currentMemoryBytes - existing.estimatedMemoryBytes + mergedSegment.estimatedMemoryBytes - merged = true - break - } - } - } - - if !merged { - segments.append(segment) - currentMemoryBytes += segment.estimatedMemoryBytes - } - - lastAccessTime[segment.lowerSpineIndex] = CFAbsoluteTimeGetCurrent() - } - - func findSegment(containing spineIndex: Int) -> RDEPUBBackgroundCoverageSegment? { - let segment = segments.first { $0.contains(spineIndex: spineIndex) } - if let segment { - lastAccessTime[segment.lowerSpineIndex] = CFAbsoluteTimeGetCurrent() - } - return segment - } - - func findSegment(covering spineIndices: Set) -> RDEPUBBackgroundCoverageSegment? { - let segment = segments.first { segment in - spineIndices.allSatisfy { segment.contains(spineIndex: $0) } - } - if let segment { - lastAccessTime[segment.lowerSpineIndex] = CFAbsoluteTimeGetCurrent() - } - return segment - } - - func allSegments() -> [RDEPUBBackgroundCoverageSegment] { - segments - } - - func clearAll() { - segments.removeAll() - currentMemoryBytes = 0 - lastAccessTime.removeAll() - } - - func clearColdSegments( - activeWindowSpineIndices: Set, - protectedSpineIndices: Set - ) { - let keepIndices = activeWindowSpineIndices.union(protectedSpineIndices) - segments.removeAll { segment in - let isCold = !segment.resolvedSpineIndices.contains(where: { keepIndices.contains($0) }) - if isCold { - currentMemoryBytes -= segment.estimatedMemoryBytes - lastAccessTime.removeValue(forKey: segment.lowerSpineIndex) - } - return isCold - } - } - - func handleMemoryWarning( - activeWindowSpineIndices: Set, - protectedSpineIndices: Set - ) { - - clearColdSegments( - activeWindowSpineIndices: activeWindowSpineIndices, - protectedSpineIndices: protectedSpineIndices - ) - - if currentMemoryBytes > policy.memoryBudgetBytes { - - let sorted = segments.sorted { lhs, rhs in - let lhsDistance = lhs.resolvedSpineIndices.map { idx in - activeWindowSpineIndices.map { abs(idx - $0) }.min() ?? Int.max - }.min() ?? Int.max - let rhsDistance = rhs.resolvedSpineIndices.map { idx in - activeWindowSpineIndices.map { abs(idx - $0) }.min() ?? Int.max - }.min() ?? Int.max - return lhsDistance > rhsDistance - } - - for segment in sorted { - if currentMemoryBytes <= policy.memoryBudgetBytes { break } - currentMemoryBytes -= segment.estimatedMemoryBytes - lastAccessTime.removeValue(forKey: segment.lowerSpineIndex) - segments.removeAll { $0.lowerSpineIndex == segment.lowerSpineIndex } - } - } - } - - private func evictIfNeeded(forNewSegment newSegment: RDEPUBBackgroundCoverageSegment) { - - while segments.count >= policy.maxResidentSegments { - evictLeastRecentlyUsed() - } - - while currentMemoryBytes + newSegment.estimatedMemoryBytes > policy.memoryBudgetBytes { - evictLeastRecentlyUsed() - } - } - - private func evictLeastRecentlyUsed() { - guard !segments.isEmpty else { return } - - var oldestTime = CFAbsoluteTimeGetCurrent() - var oldestIndex = 0 - for (index, segment) in segments.enumerated() { - let accessTime = lastAccessTime[segment.lowerSpineIndex] ?? 0 - if accessTime < oldestTime { - oldestTime = accessTime - oldestIndex = index - } - } - - let evicted = segments.remove(at: oldestIndex) - currentMemoryBytes -= evicted.estimatedMemoryBytes - lastAccessTime.removeValue(forKey: evicted.lowerSpineIndex) - } - - private func canMerge(_ lhs: RDEPUBBackgroundCoverageSegment, _ rhs: RDEPUBBackgroundCoverageSegment) -> Bool { - - guard lhs.renderSignature == rhs.renderSignature else { return false } - - let overlap = lhs.upperSpineIndex >= rhs.lowerSpineIndex - 1 && - rhs.upperSpineIndex >= lhs.lowerSpineIndex - 1 - return overlap - } - - private func mergeSegments(_ lhs: RDEPUBBackgroundCoverageSegment, _ rhs: RDEPUBBackgroundCoverageSegment) -> RDEPUBBackgroundCoverageSegment? { - let newLower = min(lhs.lowerSpineIndex, rhs.lowerSpineIndex) - let newUpper = max(lhs.upperSpineIndex, rhs.upperSpineIndex) - let newChapterCount = newUpper - newLower + 1 - - if newChapterCount > policy.maxChaptersPerSegment { - - return nil - } - - let newResolved = lhs.resolvedSpineIndices.union(rhs.resolvedSpineIndices) - - let newerSegment = lhs.generatedAt <= rhs.generatedAt ? rhs : lhs - let olderSegment = lhs.generatedAt <= rhs.generatedAt ? lhs : rhs - let newPageMap = mergePageMaps(olderSegment.pageMap, newerSegment.pageMap) - - return RDEPUBBackgroundCoverageSegment( - lowerSpineIndex: newLower, - upperSpineIndex: newUpper, - pageMap: newPageMap, - resolvedSpineIndices: newResolved, - generatedAt: max(lhs.generatedAt, rhs.generatedAt), - renderSignature: lhs.renderSignature, - estimatedMemoryBytes: estimateMemoryBytes(pageMap: newPageMap, resolvedCount: newResolved.count) - ) - } - - private func mergePageMaps(_ older: RDEPUBBookPageMap, _ newer: RDEPUBBookPageMap) -> RDEPUBBookPageMap { - var builder = RDEPUBBookPageMap.Builder() - var entriesBySpineIndex: [Int: RDEPUBBookPageMapEntry] = [:] - - for entry in older.entries { - entriesBySpineIndex[entry.spineIndex] = entry - } - for entry in newer.entries { - entriesBySpineIndex[entry.spineIndex] = entry - } - - for spineIndex in entriesBySpineIndex.keys.sorted() { - guard let entry = entriesBySpineIndex[spineIndex] else { continue } - builder.add( - spineIndex: entry.spineIndex, - href: entry.href, - title: entry.title, - pageCount: entry.pageCount, - fragmentOffsets: entry.fragmentOffsets - ) - } - - return builder.build() - } - - private func estimateMemoryBytes(pageMap: RDEPUBBookPageMap, resolvedCount: Int) -> Int { - 256 + pageMap.entries.count * 96 + resolvedCount * 16 - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBBackgroundPriorityPolicy.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBBackgroundPriorityPolicy.swift deleted file mode 100644 index 0d5a652..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBBackgroundPriorityPolicy.swift +++ /dev/null @@ -1,178 +0,0 @@ -import Foundation - -struct RDEPUBBackgroundPriorityPolicy { - - let hotRadius: Int - - let warmRadius: Int - - let maxWarmJumpAnchors: Int - - let coldLaneShare: Double - - static let `default` = RDEPUBBackgroundPriorityPolicy( - hotRadius: 24, - warmRadius: 96, - maxWarmJumpAnchors: 2, - coldLaneShare: 0.15 - ) - - static func adaptive(totalBuildableChapters: Int) -> RDEPUBBackgroundPriorityPolicy { - let hotRadius = min(max(12, Int(sqrt(Double(totalBuildableChapters)))), 48) - let warmRadius = min(max(hotRadius * 3, 32), 192) - return RDEPUBBackgroundPriorityPolicy( - hotRadius: hotRadius, - warmRadius: warmRadius, - maxWarmJumpAnchors: 2, - coldLaneShare: 0.15 - ) - } -} - -enum RDEPUBPriorityBand: Int, Comparable { - case hot = 0 - case warmPrimary = 1 - case warmSecondary = 2 - case cold = 3 - - static func < (lhs: RDEPUBPriorityBand, rhs: RDEPUBPriorityBand) -> Bool { - lhs.rawValue < rhs.rawValue - } -} - -struct RDEPUBWarmJumpAnchor { - let spineIndex: Int - let timestamp: CFAbsoluteTime - let sequenceNumber: Int -} - -struct RDEPUBMetadataParseWorkItem { - let spineIndex: Int - let generation: Int - let priorityBand: RDEPUBPriorityBand - - var sortKey: (bandRank: Int, distanceToCurrent: Int, distanceToNewestJump: Int, spineIndex: Int) { - (priorityBand.rawValue, 0, 0, spineIndex) - } -} - -final class RDEPUBBackgroundPriorityManager { - - private unowned let context: RDEPUBReaderContext - - private(set) var policy: RDEPUBBackgroundPriorityPolicy - - private var warmAnchors: [RDEPUBWarmJumpAnchor] = [] - private let warmAnchorsLock = NSLock() - - private(set) var currentGeneration: Int = 0 - - private var coldCursor: Int = 0 - - init(context: RDEPUBReaderContext) { - self.context = context - self.policy = .default - } - - func updatePolicy(_ newPolicy: RDEPUBBackgroundPriorityPolicy) { - policy = newPolicy - } - - func addWarmAnchor(spineIndex: Int) { - let anchor = RDEPUBWarmJumpAnchor( - spineIndex: spineIndex, - timestamp: CFAbsoluteTimeGetCurrent(), - sequenceNumber: currentGeneration - ) - - warmAnchorsLock.lock() - warmAnchors.insert(anchor, at: 0) - - if warmAnchors.count > policy.maxWarmJumpAnchors { - warmAnchors = Array(warmAnchors.prefix(policy.maxWarmJumpAnchors)) - } - warmAnchorsLock.unlock() - - currentGeneration += 1 - coldCursor = 0 - - } - - func makeMetadataPriorityOrder( - allBuildableIndices: [Int], - currentSpineIndex: Int?, - cachedSpineIndices: Set - ) -> [Int] { - let uncachedIndices = allBuildableIndices.filter { !cachedSpineIndices.contains($0) } - guard !uncachedIndices.isEmpty else { return [] } - - // M-03: Snapshot warmAnchors under lock since it's written on main thread - // and read on background thread. - warmAnchorsLock.lock() - let warmAnchorsSnapshot = warmAnchors - warmAnchorsLock.unlock() - - let items = uncachedIndices.map { spineIndex -> (spineIndex: Int, band: RDEPUBPriorityBand) in - let band = classifySpineIndex( - spineIndex: spineIndex, - currentSpineIndex: currentSpineIndex, - warmAnchors: warmAnchorsSnapshot - ) - return (spineIndex, band) - } - - let sorted = items.sorted { lhs, rhs in - - if lhs.band != rhs.band { - return lhs.band < rhs.band - } - - let lhsDistanceToCurrent = currentSpineIndex.map { abs(lhs.spineIndex - $0) } ?? Int.max - let rhsDistanceToCurrent = currentSpineIndex.map { abs(rhs.spineIndex - $0) } ?? Int.max - if lhsDistanceToCurrent != rhsDistanceToCurrent { - return lhsDistanceToCurrent < rhsDistanceToCurrent - } - - return lhs.spineIndex < rhs.spineIndex - } - - return sorted.map { $0.spineIndex } - } - - private func classifySpineIndex( - spineIndex: Int, - currentSpineIndex: Int?, - warmAnchors: [RDEPUBWarmJumpAnchor] - ) -> RDEPUBPriorityBand { - - if let current = currentSpineIndex { - let distance = abs(spineIndex - current) - if distance <= policy.hotRadius { - return .hot - } - } - - for (index, anchor) in warmAnchors.enumerated() { - let distance = abs(spineIndex - anchor.spineIndex) - if distance <= policy.warmRadius { - return index == 0 ? .warmPrimary : .warmSecondary - } - } - - return .cold - } - - func currentWarmAnchors() -> [RDEPUBWarmJumpAnchor] { - warmAnchorsLock.lock() - defer { warmAnchorsLock.unlock() } - return warmAnchors - } - - func reset() { - warmAnchorsLock.lock() - warmAnchors.removeAll() - warmAnchorsLock.unlock() - currentGeneration = 0 - coldCursor = 0 - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBJumpSession.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBJumpSession.swift deleted file mode 100644 index 0c684dc..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBJumpSession.swift +++ /dev/null @@ -1,191 +0,0 @@ -import Foundation - -struct RDEPUBJumpSession { - - let anchorSpineIndex: Int - - let createdAt: CFAbsoluteTime - - let protectedSpineIndices: Set - - let sequenceNumber: Int - - let expiresAt: CFAbsoluteTime - - let reason: Reason - - enum Reason { - case tableOfContentsJump - case bookmarkJump - case searchJump - } - - enum EndReason { - - case coverageComplete - - case navigatedAway - - case timeout - - case superseded - } -} - -public struct RDEPUBJumpSessionPolicy: Equatable { - - public let exitPageThreshold: Int - - public let timeout: TimeInterval - - public let idleGracePeriod: TimeInterval - - public let protectedNeighborRadius: Int - - public static let `default` = RDEPUBJumpSessionPolicy( - exitPageThreshold: 6, - timeout: 20, - idleGracePeriod: 1.5, - protectedNeighborRadius: 1 - ) - - public init( - exitPageThreshold: Int = 6, - timeout: TimeInterval = 20, - idleGracePeriod: TimeInterval = 1.5, - protectedNeighborRadius: Int = 1 - ) { - self.exitPageThreshold = exitPageThreshold - self.timeout = timeout - self.idleGracePeriod = idleGracePeriod - self.protectedNeighborRadius = protectedNeighborRadius - } -} - -final class RDEPUBJumpSessionManager { - - private unowned let context: RDEPUBReaderContext - - private(set) var activeSession: RDEPUBJumpSession? - - private var nextSequenceNumber: Int = 0 - - private var consecutivePageCount: Int = 0 - - private var lastPageDirection: PageDirection? - - private var lastActivityTime: CFAbsoluteTime = 0 - - enum PageDirection { - case forward - case backward - } - - init(context: RDEPUBReaderContext) { - self.context = context - } - - @discardableResult - func createSession( - anchorSpineIndex: Int, - reason: RDEPUBJumpSession.Reason, - totalSpineCount: Int - ) -> RDEPUBJumpSession { - let policy = context.configuration.jumpSessionPolicy - let now = CFAbsoluteTimeGetCurrent() - - var protectedIndices: Set = [anchorSpineIndex] - for offset in 1...policy.protectedNeighborRadius { - let lower = anchorSpineIndex - offset - let upper = anchorSpineIndex + offset - if lower >= 0 { - protectedIndices.insert(lower) - } - if upper < totalSpineCount { - protectedIndices.insert(upper) - } - } - - nextSequenceNumber += 1 - let session = RDEPUBJumpSession( - anchorSpineIndex: anchorSpineIndex, - createdAt: now, - protectedSpineIndices: protectedIndices, - sequenceNumber: nextSequenceNumber, - expiresAt: now + policy.timeout, - reason: reason - ) - - activeSession = session - consecutivePageCount = 0 - lastPageDirection = nil - lastActivityTime = now - - - return session - } - - func recordPageChange(fromSpineIndex: Int, toSpineIndex: Int) { - guard activeSession != nil else { return } - - let direction: PageDirection = toSpineIndex >= fromSpineIndex ? .forward : .backward - lastActivityTime = CFAbsoluteTimeGetCurrent() - - if direction == lastPageDirection { - consecutivePageCount += 1 - } else { - consecutivePageCount = 1 - lastPageDirection = direction - } - } - - func shouldAllowPageMapTakeover(candidateSpineIndices: Set) -> Bool { - guard let session = activeSession else { - return true - } - - let protectedIndices = session.protectedSpineIndices - let coverageRatio = Double(protectedIndices.intersection(candidateSpineIndices).count) / - Double(protectedIndices.count) - - return coverageRatio >= 0.8 - } - - func checkSessionEnd(currentSpineIndex: Int, isIdle: Bool) -> RDEPUBJumpSession.EndReason? { - guard let session = activeSession else { return nil } - - let now = CFAbsoluteTimeGetCurrent() - let policy = context.configuration.jumpSessionPolicy - - if now >= session.expiresAt { - if isIdle || (now - lastActivityTime) >= policy.idleGracePeriod { - return .timeout - } - } - - if !session.protectedSpineIndices.contains(currentSpineIndex) { - if consecutivePageCount >= policy.exitPageThreshold { - return .navigatedAway - } - } else { - - consecutivePageCount = 0 - } - - return nil - } - - func endSession(_ reason: RDEPUBJumpSession.EndReason) { - guard let session = activeSession else { return } - activeSession = nil - consecutivePageCount = 0 - lastPageDirection = nil - } - - func clearSession() { - activeSession = nil - consecutivePageCount = 0 - lastPageDirection = nil - nextSequenceNumber = 0 - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBMemoryProbe.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBMemoryProbe.swift deleted file mode 100644 index 40067fa..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBMemoryProbe.swift +++ /dev/null @@ -1,48 +0,0 @@ -import Foundation - -/// Memory footprint probe for the long-chapter optimization work -/// (Doc/LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md, P0-2). Enabled with the -/// `--demo-memory-probe` launch argument; logs phys_footprint at chapter -/// load, every 20 page turns, settings invalidation, and rotation. -enum RDEPUBMemoryProbe { - - static let isEnabled = ProcessInfo.processInfo.arguments.contains("--demo-memory-probe") - - private static let pageTurnLogStride = 20 - - /// Main-thread only (page turns are delivered on main). - private static var pageTurnCount = 0 - - static func logPageTurn() { - guard isEnabled else { return } - pageTurnCount += 1 - guard pageTurnCount % pageTurnLogStride == 0 else { return } - log("pageTurn count=\(pageTurnCount)") - } - - static func log(_ event: String) { - guard isEnabled else { return } - print(String(format: "[EPUB][MemoryProbe] %@ footprint=%.1fMB", event, footprintMB)) - } - - /// Current phys_footprint in MB. Cheap enough (one task_info call) to - /// surface in the demo state snapshot for automated memory assertions. - static var footprintMB: Double { - Double(currentFootprint()) / 1_048_576 - } - - /// phys_footprint matches the value Xcode's memory gauge and Jetsam use. - private static func currentFootprint() -> UInt64 { - var info = task_vm_info_data_t() - var count = mach_msg_type_number_t( - MemoryLayout.size / MemoryLayout.size - ) - let result = withUnsafeMutablePointer(to: &info) { pointer in - pointer.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { - task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), $0, &count) - } - } - guard result == KERN_SUCCESS else { return 0 } - return info.phys_footprint - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBMetadataParseCancellationController.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBMetadataParseCancellationController.swift deleted file mode 100644 index 6408b3d..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBMetadataParseCancellationController.swift +++ /dev/null @@ -1,44 +0,0 @@ -import Foundation - -final class RDEPUBMetadataParseCancellationController { - - let token: UUID - - private let lock = NSLock() - - private weak var queue: OperationQueue? - - private var cancelled = false - - init(token: UUID) { - self.token = token - } - - func attach(queue: OperationQueue) { - let shouldCancelImmediately: Bool - lock.lock() - self.queue = queue - shouldCancelImmediately = cancelled - lock.unlock() - - if shouldCancelImmediately { - queue.cancelAllOperations() - } - } - - func cancel() { - let queueToCancel: OperationQueue? - lock.lock() - cancelled = true - queueToCancel = queue - lock.unlock() - queueToCancel?.cancelAllOperations() - } - - var isCancelled: Bool { - lock.lock() - let value = cancelled - lock.unlock() - return value - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBMetadataParseWorker.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBMetadataParseWorker.swift deleted file mode 100644 index d1bf3e5..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBMetadataParseWorker.swift +++ /dev/null @@ -1,604 +0,0 @@ -import Foundation - -final class RDEPUBMetadataParseWorker { - - private static let maxRetryCount = 3 - private static let retryDelays: [TimeInterval] = [0.5, 2.0, 8.0] - - private final class ParseState { - - var summariesBySpineIndex: [Int: RDEPUBChapterSummary] - - var totalResolvedCount: Int - - var lastAppliedCount: Int - - init( - summariesBySpineIndex: [Int: RDEPUBChapterSummary], - totalResolvedCount: Int, - lastAppliedCount: Int - ) { - self.summariesBySpineIndex = summariesBySpineIndex - self.totalResolvedCount = totalResolvedCount - self.lastAppliedCount = lastAppliedCount - } - } - - private let backgroundInteractionCooldown: CFAbsoluteTime = 0.8 - - weak var context: RDEPUBReaderContext? - - let cancellationController: RDEPUBMetadataParseCancellationController - - let pageMapRefreshInterval: Int - - private let token: UUID - - private let parser: RDEPUBParser - - private let publication: RDEPUBPublication - - private let pageSize: CGSize - - private let layoutConfig: RDEPUBTextLayoutConfig - - private let style: RDEPUBTextRenderStyle - - private let renderSignature: String - - private let allBuildableIndices: [Int] - - private let summaryDiskCache: RDEPUBChapterSummaryDiskCache? - - private let workerCount: Int - - private let contentHashBySpineIndex: [Int: String] - - private let catalog: [(key: RDEPUBChapterCacheKey, spineIndex: Int, href: String, title: String)] - - private let progressLogStride = 4 - - init( - context: RDEPUBReaderContext, - cancellationController: RDEPUBMetadataParseCancellationController, - token: UUID, - parser: RDEPUBParser, - publication: RDEPUBPublication - ) { - self.context = context - self.cancellationController = cancellationController - self.token = token - self.parser = parser - self.publication = publication - - let pageSize = context.currentTextPageSize() - self.pageSize = pageSize - self.layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize) - self.style = context.currentTextRenderStyle() - self.renderSignature = context.currentRenderSignature() - self.allBuildableIndices = publication.spine.indices.filter { index in - guard publication.spine.indices.contains(index) else { return false } - let item = publication.spine[index] - return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml")) - } - self.summaryDiskCache = context.runtime?.summaryDiskCache - self.workerCount = max(1, context.configuration.metadataParsingConcurrency) - self.pageMapRefreshInterval = RDEPUBReaderPaginationCoordinator.pageMapRefreshInterval - - var hashes: [Int: String] = [:] - for spineIndex in allBuildableIndices { - guard let href = publication.spine.indices.contains(spineIndex) - ? publication.spine[spineIndex].href : nil, - let html = parser.htmlString(forRelativePath: href) else { - hashes[spineIndex] = "" - continue - } - hashes[spineIndex] = html.rd_sha256Hex - } - self.contentHashBySpineIndex = hashes - - let ctx = context - let spine = publication.spine - let sig = renderSignature - self.catalog = allBuildableIndices.map { spineIndex in - let item = spine[spineIndex] - return ( - key: ctx.chapterCacheKey( - forSpineIndex: spineIndex, - precomputedContentHash: hashes[spineIndex] ?? "", - renderSignature: sig - ), - spineIndex: spineIndex, - href: item.href, - title: item.title - ) - } - } - - func start(token: UUID, restoreLocation: RDEPUBLocation?) { - let cancellationController = self.cancellationController - - DispatchQueue.global(qos: .utility).async { [self] in - RDEPUBBackgroundTrace.log( - "Metadata", - "workerDispatched token=\(token.uuidString)" - ) - let context = self.context - guard let context, - context.controller != nil, - !cancellationController.isCancelled, - context.paginationToken == token else { - RDEPUBBackgroundTrace.log( - "Metadata", - "workerAbortedBeforeStart reason=contextUnavailableOrTokenMismatch" - ) - return - } - defer { context.runtime?.paginationCoordinator.finishMetadataParseCancellationController(cancellationController) } - guard context.controller != nil, - !cancellationController.isCancelled, - context.paginationToken == token else { - RDEPUBBackgroundTrace.log( - "Metadata", - "workerAbortedAfterStart reason=contextUnavailableOrTokenMismatch" - ) - return - } - - if let restoredPageMap = self.restoreBookPageMapIfPossible() { - RDEPUBBackgroundTrace.log( - "Metadata", - "restoreBookPageMapIfPossible hit totalChapters=\(restoredPageMap.totalChapters) totalPages=\(restoredPageMap.totalPages)" - ) - DispatchQueue.main.async { - guard context.paginationToken == token, - context.controller != nil, - !cancellationController.isCancelled else { return } - context.runtime?.refreshBookPageMapInPlace(restoredPageMap) - } - return - } - - let prewarmStart = CFAbsoluteTimeGetCurrent() - let prewarmMs = Int((CFAbsoluteTimeGetCurrent() - prewarmStart) * 1000) - - let restored = self.summaryDiskCache?.readAll(keys: self.catalog) - let cachedSummaries = restored?.summaries ?? [:] - let cachedSpineIndices = Set(cachedSummaries.keys) - let resultLock = NSLock() - let parseState = ParseState( - summariesBySpineIndex: cachedSummaries, - totalResolvedCount: cachedSpineIndices.count, - lastAppliedCount: cachedSpineIndices.count - ) - - if !cachedSpineIndices.isEmpty { - let cachedMap = self.buildPageMap(summaries: parseState.summariesBySpineIndex) - DispatchQueue.main.async { - guard context.paginationToken == token, - context.controller != nil else { return } - context.runtime?.refreshBookPageMapInPlace(cachedMap) - } - } - - let prioritizedSpineIndices: [Int] - if let priorityManager = context.runtime?.backgroundPriorityManager { - let currentSpineIndex = context.runtime?.locationCoordinator.currentVisibleLocation() - .flatMap { context.normalizedSpineIndex(for: $0) } - prioritizedSpineIndices = priorityManager.makeMetadataPriorityOrder( - allBuildableIndices: self.allBuildableIndices, - currentSpineIndex: currentSpineIndex, - cachedSpineIndices: cachedSpineIndices - ) - } else { - prioritizedSpineIndices = self.allBuildableIndices.filter { !cachedSpineIndices.contains($0) } - } - - let uncachedSpineIndices = prioritizedSpineIndices - - RDEPUBBackgroundTrace.log( - "Metadata", - "waitingForInteractionCooldown elapsedSinceNavigation=\(String(format: "%.2f", context.secondsSinceLastUserNavigation())) uncached=\(uncachedSpineIndices.count)" - ) - self.waitForReadingInteractionToSettle(cancellationController: cancellationController) - guard !cancellationController.isCancelled, - context.controller != nil, - context.paginationToken == token else { - RDEPUBBackgroundTrace.log( - "Metadata", - "workerAbortedDuringCooldown" - ) - return - } - - RDEPUBBackgroundTrace.log( - "Metadata", - "start totalBuildable=\(self.allBuildableIndices.count) cached=\(cachedSpineIndices.count) uncached=\(uncachedSpineIndices.count) concurrency=\(self.workerCount) refreshInterval=\(self.pageMapRefreshInterval)" - ) - - let wallClockStart = CFAbsoluteTimeGetCurrent() - var totalRenderMs: Double = 0 - var totalWriteMs: Double = 0 - var totalMergeMs: Double = 0 - var completedChapters = 0 - var failedChapters = 0 - let timingLock = NSLock() - - let queue = OperationQueue() - queue.name = "com.RDEpubReader.metadata.parse" - queue.qualityOfService = .utility - queue.maxConcurrentOperationCount = self.workerCount - cancellationController.attach(queue: queue) - - let refreshInterval = self.pageMapRefreshInterval - - for (offset, spineIndex) in uncachedSpineIndices.enumerated() { - let operation = BlockOperation() - operation.addExecutionBlock { [weak operation] in - guard context.controller != nil, - context.paginationToken == token, - !cancellationController.isCancelled, - operation?.isCancelled != true else { - return - } - do { - - let renderResult: RDEPUBChapterSummary? = try autoreleasepool { () -> RDEPUBChapterSummary? in - guard context.controller != nil, - context.paginationToken == token, - !cancellationController.isCancelled, - operation?.isCancelled != true else { - return nil - } - let chapterBuilder = context.makeTextBookBuilder(layoutConfig: self.layoutConfig) - let renderStart = CFAbsoluteTimeGetCurrent() - guard let result = try chapterBuilder.buildChapter( - parser: self.parser, - publication: self.publication, - spineIndex: spineIndex, - pageSize: self.pageSize, - style: self.style - ) else { - return nil - } - guard context.controller != nil, - context.paginationToken == token, - !cancellationController.isCancelled, - operation?.isCancelled != true else { - return nil - } - let renderElapsed = (CFAbsoluteTimeGetCurrent() - renderStart) * 1000 - - let chapter = result.chapter - let precomputedHash = self.contentHashBySpineIndex[spineIndex] ?? "" - let cacheKey = context.chapterCacheKey( - forSpineIndex: spineIndex, - precomputedContentHash: precomputedHash, - renderSignature: self.renderSignature - ) - let summary = RDEPUBChapterSummary( - pageRanges: chapter.pages.map { .init(location: $0.contentRange.location, length: $0.contentRange.length) }, - pageCount: chapter.pages.count, - fragmentOffsets: chapter.fragmentOffsets, - cfiMap: chapter.cfiMap, - renderSignature: cacheKey.renderSignature, - schemaVersion: RDEPUBChapterSummary.currentSchemaVersion, - chapterContentHash: cacheKey.chapterContentHash, - pageMetadataList: chapter.pages.map { .from($0.metadata) } - ) - guard context.controller != nil, - context.paginationToken == token, - !cancellationController.isCancelled, - operation?.isCancelled != true else { - return nil - } - let writeStart = CFAbsoluteTimeGetCurrent() - self.summaryDiskCache?.write(summary: summary, for: cacheKey) - let writeElapsed = (CFAbsoluteTimeGetCurrent() - writeStart) * 1000 - - timingLock.lock() - totalRenderMs += renderElapsed - totalWriteMs += writeElapsed - completedChapters += 1 - timingLock.unlock() - - return summary - } - - guard let renderResult else { return } - guard context.controller != nil, - context.paginationToken == token, - !cancellationController.isCancelled, - operation?.isCancelled != true else { - return - } - - var snapshot: [Int: RDEPUBChapterSummary]? - resultLock.lock() - parseState.summariesBySpineIndex[spineIndex] = renderResult - parseState.totalResolvedCount += 1 - let resolvedCount = parseState.totalResolvedCount - let shouldLogProgress = resolvedCount == self.allBuildableIndices.count - || resolvedCount == cachedSpineIndices.count + 1 - || resolvedCount % self.progressLogStride == 0 - if parseState.totalResolvedCount - parseState.lastAppliedCount >= refreshInterval - || parseState.totalResolvedCount == self.allBuildableIndices.count { - parseState.lastAppliedCount = parseState.totalResolvedCount - snapshot = parseState.summariesBySpineIndex - } - resultLock.unlock() - - if shouldLogProgress { - let progressPercent = Self.progressPercent( - resolved: resolvedCount, - total: self.allBuildableIndices.count - ) - RDEPUBBackgroundTrace.log( - "Metadata", - "chapterReady spine=\(spineIndex) resolved=\(resolvedCount)/\(self.allBuildableIndices.count) progress=\(progressPercent)% pageCount=\(renderResult.pageCount)" - ) - } - - if let snapshot { - let mergeStart = CFAbsoluteTimeGetCurrent() - let partialMap = self.buildPageMap(summaries: snapshot) - let mergeElapsed = (CFAbsoluteTimeGetCurrent() - mergeStart) * 1000 - timingLock.lock() - totalMergeMs += mergeElapsed - timingLock.unlock() - RDEPUBBackgroundTrace.log( - "Metadata", - "partialMap resolvedChapters=\(snapshot.count) totalPages=\(partialMap.totalPages) mergeMs=\(Int(mergeElapsed))" - ) - DispatchQueue.main.async { - guard context.paginationToken == token, - context.controller != nil, - !cancellationController.isCancelled else { return } - context.runtime?.refreshBookPageMapInPlace(partialMap) - } - } - } catch { - guard !cancellationController.isCancelled, - context.paginationToken == token, - context.controller != nil, - operation?.isCancelled != true else { - return - } - timingLock.lock() - failedChapters += 1 - timingLock.unlock() - RDEPUBBackgroundTrace.log( - "Metadata", - "chapterFailed spine=\(spineIndex) retryScheduled=true error=\(String(describing: error))" - ) - - self.scheduleRetry( - spineIndex: spineIndex, - retryCount: 0, - resultLock: resultLock, - parseState: parseState, - cancellationController: cancellationController - ) - } - } - queue.addOperation(operation) - } - queue.waitUntilAllOperationsAreFinished() - if !cancellationController.isCancelled, - context.paginationToken == token, - context.controller != nil { - self.summaryDiskCache?.flushPendingWrites() - } - - let wallClockMs = Int((CFAbsoluteTimeGetCurrent() - wallClockStart) * 1000) - timingLock.lock() - let renderTotal = Int(totalRenderMs) - let writeTotal = Int(totalWriteMs) - let mergeTotal = Int(totalMergeMs) - let rendered = completedChapters - let failed = failedChapters - timingLock.unlock() - let avgRenderMs = rendered > 0 ? renderTotal / rendered : 0 - context.lastMetadataParseWallClockMs = wallClockMs - context.lastMetadataParseConcurrency = self.workerCount - - guard context.controller != nil, - context.paginationToken == token, - !cancellationController.isCancelled else { - return - } - - let finalMergeStart = CFAbsoluteTimeGetCurrent() - let pageMap = self.buildPageMap(summaries: parseState.summariesBySpineIndex) - let finalMergeMs = Int((CFAbsoluteTimeGetCurrent() - finalMergeStart) * 1000) - RDEPUBBackgroundTrace.log( - "Metadata", - "finish resolved=\(parseState.summariesBySpineIndex.count)/\(self.allBuildableIndices.count) totalPages=\(pageMap.totalPages) elapsedMs=\(wallClockMs) renderMs=\(renderTotal) writeMs=\(writeTotal) mergeMs=\(mergeTotal + finalMergeMs) failed=\(failed)" - ) - - if let coverageStore = context.runtime?.backgroundCoverageStore { - let resolvedSpineIndices = Set(parseState.summariesBySpineIndex.keys) - let lowerSpine = resolvedSpineIndices.min() ?? 0 - let upperSpine = resolvedSpineIndices.max() ?? 0 - let estimatedBytes = 256 + pageMap.entries.count * 96 + resolvedSpineIndices.count * 16 - - let segment = RDEPUBBackgroundCoverageSegment( - lowerSpineIndex: lowerSpine, - upperSpineIndex: upperSpine, - pageMap: pageMap, - resolvedSpineIndices: resolvedSpineIndices, - generatedAt: CFAbsoluteTimeGetCurrent(), - renderSignature: self.renderSignature, - estimatedMemoryBytes: estimatedBytes - ) - coverageStore.addSegment(segment) - } - - DispatchQueue.main.async { - guard context.paginationToken == token, - context.controller != nil else { return } - context.runtime?.refreshBookPageMapInPlace(pageMap) - } - } - } - - // MARK: - Private - - private func waitForReadingInteractionToSettle( - cancellationController: RDEPUBMetadataParseCancellationController? = nil - ) { - let semaphore = DispatchSemaphore(value: 0) - let maxWaitIterations = 100 // ~8 seconds max wait - var iteration = 0 - while context?.controller != nil, - cancellationController?.isCancelled != true, - iteration < maxWaitIterations { - let elapsed = context?.secondsSinceLastUserNavigation() ?? 0 - if elapsed >= backgroundInteractionCooldown { break } - semaphore.wait(timeout: .now() + 0.08) - iteration += 1 - } - } - - private func scheduleRetry( - spineIndex: Int, - retryCount: Int, - resultLock: NSLock, - parseState: ParseState, - cancellationController: RDEPUBMetadataParseCancellationController - ) { - guard retryCount < Self.maxRetryCount else { - RDEPUBBackgroundTrace.log( - "Metadata", - "retryAborted spine=\(spineIndex) retryCount=\(retryCount)" - ) - return - } - - let delay = Self.retryDelays[min(retryCount, Self.retryDelays.count - 1)] - RDEPUBBackgroundTrace.log( - "Metadata", - "retryScheduled spine=\(spineIndex) retryCount=\(retryCount + 1) delayMs=\(Int(delay * 1000))" - ) - - DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + delay) { [weak self] in - guard let self, let context = self.context else { return } - guard context.controller != nil, - context.paginationToken == self.token, - !cancellationController.isCancelled else { - return - } - - do { - let chapterBuilder = context.makeTextBookBuilder(layoutConfig: self.layoutConfig) - guard let result = try chapterBuilder.buildChapter( - parser: self.parser, - publication: self.publication, - spineIndex: spineIndex, - pageSize: self.pageSize, - style: self.style - ) else { - return - } - guard context.controller != nil, - context.paginationToken == self.token, - !cancellationController.isCancelled else { - return - } - - let chapter = result.chapter - let precomputedHash = self.contentHashBySpineIndex[spineIndex] ?? "" - let cacheKey = context.chapterCacheKey( - forSpineIndex: spineIndex, - precomputedContentHash: precomputedHash, - renderSignature: self.renderSignature - ) - let summary = RDEPUBChapterSummary( - pageRanges: chapter.pages.map { .init(location: $0.contentRange.location, length: $0.contentRange.length) }, - pageCount: chapter.pages.count, - fragmentOffsets: chapter.fragmentOffsets, - cfiMap: chapter.cfiMap, - renderSignature: cacheKey.renderSignature, - schemaVersion: RDEPUBChapterSummary.currentSchemaVersion, - chapterContentHash: cacheKey.chapterContentHash, - pageMetadataList: chapter.pages.map { .from($0.metadata) } - ) - guard context.controller != nil, - context.paginationToken == self.token, - !cancellationController.isCancelled else { - return - } - self.summaryDiskCache?.write(summary: summary, for: cacheKey) - - resultLock.lock() - parseState.summariesBySpineIndex[spineIndex] = summary - parseState.totalResolvedCount += 1 - let shouldRefresh = - parseState.totalResolvedCount - parseState.lastAppliedCount >= self.pageMapRefreshInterval - || parseState.totalResolvedCount == self.allBuildableIndices.count - if shouldRefresh { - parseState.lastAppliedCount = parseState.totalResolvedCount - } - resultLock.unlock() - - if shouldRefresh { - let partialMap = self.buildPageMap(summaries: parseState.summariesBySpineIndex) - RDEPUBBackgroundTrace.log( - "Metadata", - "retryPartialMap resolvedChapters=\(parseState.summariesBySpineIndex.count) totalPages=\(partialMap.totalPages)" - ) - DispatchQueue.main.async { - guard context.paginationToken == self.token, - context.controller != nil, - !cancellationController.isCancelled else { return } - context.runtime?.refreshBookPageMapInPlace(partialMap) - } - } - - } catch { - - self.scheduleRetry( - spineIndex: spineIndex, - retryCount: retryCount + 1, - resultLock: resultLock, - parseState: parseState, - cancellationController: cancellationController - ) - } - } - } - - private static func progressPercent(resolved: Int, total: Int) -> Int { - guard total > 0 else { return 0 } - return Int((Double(resolved) / Double(total) * 100.0).rounded()) - } - - private func restoreBookPageMapIfPossible() -> RDEPUBBookPageMap? { - guard let summaryDiskCache else { return nil } - guard summaryDiskCache.isCacheComplete(keys: catalog.map(\.key)) else { - return nil - } - let restored = summaryDiskCache.readAll(keys: catalog) - guard restored.summaries.count == catalog.count else { - return nil - } - return restored.mapBuilder.build() - } - - private func buildPageMap( - summaries: [Int: RDEPUBChapterSummary] - ) -> RDEPUBBookPageMap { - var builder = RDEPUBBookPageMap.Builder() - for item in catalog { - guard let summary = summaries[item.spineIndex] else { continue } - builder.add( - spineIndex: item.spineIndex, - href: item.href, - title: item.title, - pageCount: summary.pageCount, - fragmentOffsets: summary.fragmentOffsets - ) - } - return builder.build() - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBPageMapReconciliationCoordinator.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBPageMapReconciliationCoordinator.swift deleted file mode 100644 index c601bfe..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBPageMapReconciliationCoordinator.swift +++ /dev/null @@ -1,230 +0,0 @@ -import Foundation - -enum RDEPUBPageMapTakeoverDecision { - - case keepCurrentWindow - - case expandWindow(RDEPUBBackgroundCoverageSegment) - - case segmentReplace(RDEPUBBackgroundCoverageSegment) - - case fullReplace(RDEPUBBookPageMap) -} - -final class RDEPUBPageMapReconciliationCoordinator { - - private unowned let context: RDEPUBReaderContext - - init(context: RDEPUBReaderContext) { - self.context = context - } - - func evaluateTakeover( - candidatePageMap: RDEPUBBookPageMap?, - candidateSegment: RDEPUBBackgroundCoverageSegment?, - currentWindow: RDEPUBBookPageMap?, - jumpSession: RDEPUBJumpSession? - ) -> RDEPUBPageMapTakeoverDecision { - - guard let currentWindow else { - if let candidatePageMap { - return .fullReplace(candidatePageMap) - } - return .keepCurrentWindow - } - - let currentSpineIndex = context.runtime?.locationCoordinator.currentVisibleLocation() - .flatMap { context.normalizedSpineIndex(for: $0) } - - let lastBuildableSpineIndex = context.publication?.spine.indices - .reversed() - .first(where: { index in - guard let item = context.publication?.spine[index] else { return false } - return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml")) - }) ?? 0 - - if let jumpSession { - let protectedIndices = jumpSession.protectedSpineIndices - if let currentSpineIndex, protectedIndices.contains(currentSpineIndex) { - - if let candidateSegment { - let candidateIndices = candidateSegment.resolvedSpineIndices - let coverageRatio = Double(protectedIndices.intersection(candidateIndices).count) / - Double(protectedIndices.count) - if coverageRatio < 0.8 { - RDEPUBBackgroundTrace.log( - "Reconciliation", - "evaluateTakeover: keepCurrentWindow — jumpSession coverageRatio=\(String(format: "%.2f", coverageRatio)) protected=\(protectedIndices.count) candidateChapters=\(candidateIndices.count)" - ) - return .keepCurrentWindow - } - } - } - } - - if let currentSpineIndex { - let requiresAdjacentCoverage = currentSpineIndex > 0 && currentSpineIndex < lastBuildableSpineIndex - - if requiresAdjacentCoverage { - if let candidateSegment { - let hasPrev = candidateSegment.contains(spineIndex: currentSpineIndex - 1) - let hasNext = candidateSegment.contains(spineIndex: currentSpineIndex + 1) - if !hasPrev || !hasNext { - RDEPUBBackgroundTrace.log( - "Reconciliation", - "evaluateTakeover: keepCurrentWindow — adjacentCoverage missing hasPrev=\(hasPrev) hasNext=\(hasNext) currentSpine=\(currentSpineIndex)" - ) - return .keepCurrentWindow - } - } - } - } - - if let candidateSegment { - let currentRenderSignature = context.currentRenderSignature() - if candidateSegment.renderSignature != currentRenderSignature { - RDEPUBBackgroundTrace.log( - "Reconciliation", - "evaluateTakeover: keepCurrentWindow — renderSignature mismatch segment=\(candidateSegment.renderSignature) current=\(currentRenderSignature)" - ) - return .keepCurrentWindow - } - } - - if let candidateSegment { - return evaluateSegmentTakeover( - candidateSegment: candidateSegment, - currentWindow: currentWindow, - currentSpineIndex: currentSpineIndex, - lastBuildableSpineIndex: lastBuildableSpineIndex - ) - } - - if let candidatePageMap { - return evaluateFullPageMapTakeover( - candidatePageMap: candidatePageMap, - currentWindow: currentWindow, - currentSpineIndex: currentSpineIndex, - lastBuildableSpineIndex: lastBuildableSpineIndex - ) - } - - return .keepCurrentWindow - } - - private func evaluateSegmentTakeover( - candidateSegment: RDEPUBBackgroundCoverageSegment, - currentWindow: RDEPUBBookPageMap, - currentSpineIndex: Int?, - lastBuildableSpineIndex: Int - ) -> RDEPUBPageMapTakeoverDecision { - let currentIndices = Set(currentWindow.entries.map { $0.spineIndex }) - let candidateIndices = candidateSegment.resolvedSpineIndices - - if let currentSpineIndex { - if !candidateIndices.contains(currentSpineIndex) { - return .keepCurrentWindow - } - } - - if let currentSpineIndex { - let hasPrev = candidateIndices.contains(currentSpineIndex - 1) || currentSpineIndex == 0 - let hasNext = candidateIndices.contains(currentSpineIndex + 1) || - currentSpineIndex == lastBuildableSpineIndex - if !hasPrev || !hasNext { - return .keepCurrentWindow - } - } - - let isContinuous = currentIndices.contains(candidateSegment.lowerSpineIndex - 1) || - currentIndices.contains(candidateSegment.upperSpineIndex + 1) || - candidateIndices.contains(currentWindow.entries.first?.spineIndex ?? Int.max) || - candidateIndices.contains(currentWindow.entries.last?.spineIndex ?? Int.min) - - if isContinuous { - - return .expandWindow(candidateSegment) - } else { - - let overlap = currentIndices.intersection(candidateIndices) - let overlapRatio = Double(overlap.count) / Double(currentIndices.count) - if overlapRatio > 0.5 { - - return .segmentReplace(candidateSegment) - } - } - - return .keepCurrentWindow - } - - private func evaluateFullPageMapTakeover( - candidatePageMap: RDEPUBBookPageMap, - currentWindow: RDEPUBBookPageMap, - currentSpineIndex: Int?, - lastBuildableSpineIndex: Int - ) -> RDEPUBPageMapTakeoverDecision { - let candidateIndices = Set(candidatePageMap.entries.map { $0.spineIndex }) - let currentIndices = Set(currentWindow.entries.map { $0.spineIndex }) - let currentEntries = currentWindow.entries.count - - if let currentSpineIndex { - if !candidateIndices.contains(currentSpineIndex) { - RDEPUBBackgroundTrace.log( - "Reconciliation", - "evaluateFullPageMapTakeover: keepCurrentWindow — currentSpine=\(currentSpineIndex) not in candidate chapters=\(candidateIndices.count)" - ) - return .keepCurrentWindow - } - } - - if let currentSpineIndex { - let hasPrev = candidateIndices.contains(currentSpineIndex - 1) || currentSpineIndex == 0 - let hasNext = candidateIndices.contains(currentSpineIndex + 1) || - currentSpineIndex == lastBuildableSpineIndex - if !hasPrev || !hasNext { - RDEPUBBackgroundTrace.log( - "Reconciliation", - "evaluateFullPageMapTakeover: keepCurrentWindow — adjacentCoverage missing hasPrev=\(hasPrev) hasNext=\(hasNext) currentSpine=\(currentSpineIndex)" - ) - return .keepCurrentWindow - } - } - - let coversCurrentWindow = currentIndices.isSubset(of: candidateIndices) - if coversCurrentWindow { - RDEPUBBackgroundTrace.log( - "Reconciliation", - "evaluateFullPageMapTakeover: fullReplace — candidateChapters=\(candidateIndices.count) currentChapters=\(currentEntries) candidatePages=\(candidatePageMap.totalPages) currentPages=\(currentWindow.totalPages)" - ) - return .fullReplace(candidatePageMap) - } - - RDEPUBBackgroundTrace.log( - "Reconciliation", - "evaluateFullPageMapTakeover: keepCurrentWindow — candidate does not cover currentWindow candidateChapters=\(candidateIndices.count) currentChapters=\(currentEntries)" - ) - return .keepCurrentWindow - } - - func protectedSpineIndices( - currentSpineIndex: Int?, - jumpSession: RDEPUBJumpSession? - ) -> Set { - var indices: Set = [] - - if let currentSpineIndex { - indices.insert(currentSpineIndex) - - if currentSpineIndex > 0 { - indices.insert(currentSpineIndex - 1) - } - indices.insert(currentSpineIndex + 1) - } - - if let jumpSession { - indices.formUnion(jumpSession.protectedSpineIndices) - } - - return indices - } -} \ No newline at end of file diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBPresentationRuntime.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBPresentationRuntime.swift deleted file mode 100644 index 48d0bbe..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBPresentationRuntime.swift +++ /dev/null @@ -1,471 +0,0 @@ -import UIKit - -enum RDEPUBPendingPageMapUpdateKind { - case reconcileFullMap - case extendPartial(currentPageNumber: Int, currentLocation: RDEPUBLocation?) - case appendForward -} - -struct RDEPUBPendingPageMapUpdate { - let pageMap: RDEPUBBookPageMap - let kind: RDEPUBPendingPageMapUpdateKind -} - -final class RDEPUBPresentationRuntime { - - private unowned let context: RDEPUBReaderContext - - private unowned let locationCoordinator: RDEPUBReaderLocationCoordinator - - private unowned let jumpSessionManager: RDEPUBJumpSessionManager - - private unowned let reconciliationCoordinator: RDEPUBPageMapReconciliationCoordinator - - private var pendingCommitRetryWorkItem: DispatchWorkItem? - - init( - context: RDEPUBReaderContext, - locationCoordinator: RDEPUBReaderLocationCoordinator, - jumpSessionManager: RDEPUBJumpSessionManager, - reconciliationCoordinator: RDEPUBPageMapReconciliationCoordinator - ) { - self.context = context - self.locationCoordinator = locationCoordinator - self.jumpSessionManager = jumpSessionManager - self.reconciliationCoordinator = reconciliationCoordinator - } - - func applyBookPageMap( - _ bookPageMap: RDEPUBBookPageMap, - restoreLocation: RDEPUBLocation?, - finishPagination: (RDEPUBLocation?) -> Void - ) { - context.textBook = nil - context.bookPageMap = bookPageMap - context.pendingPageMapUpdates.removeAll() - context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap)) - finishPagination(restoreLocation) - } - - func refreshBookPageMapInPlace(_ bookPageMap: RDEPUBBookPageMap) { - RDEPUBBackgroundTrace.log( - "Reconciliation", - "refreshBookPageMapInPlace: enqueued reconcileFullMap chapters=\(bookPageMap.totalChapters) pages=\(bookPageMap.totalPages)" - ) - enqueuePendingPageMapUpdate( - RDEPUBPendingPageMapUpdate( - pageMap: bookPageMap, - kind: .reconcileFullMap - ) - ) - } - - func commitPendingPageMapUpdateIfNeeded() { - guard let readerView = context.readerView, - let controller = context.controller else { return } - - guard !context.pendingPageMapUpdates.isEmpty else { - cancelPendingCommitRetry() - return - } - - guard !controller.isRepaginating else { - schedulePendingCommitRetry() - return - } - guard !readerView.isPageCurlTransitioning else { - schedulePendingCommitRetry() - return - } - - cancelPendingCommitRetry() - - let rankedUpdates = rankedPendingPageMapUpdates() - for (index, update) in rankedUpdates { - if commitPendingPageMapUpdate( - update, - at: index, - readerView: readerView, - controller: controller - ) { - if !context.pendingPageMapUpdates.isEmpty { - schedulePendingCommitRetry() - } - return - } - } - - if !context.pendingPageMapUpdates.isEmpty { - schedulePendingCommitRetry() - } - } - - func queueExtendedPartialPageMap( - _ bookPageMap: RDEPUBBookPageMap, - currentPageNumber: Int, - currentLocation: RDEPUBLocation? - ) { - enqueuePendingPageMapUpdate( - RDEPUBPendingPageMapUpdate( - pageMap: bookPageMap, - kind: .extendPartial( - currentPageNumber: currentPageNumber, - currentLocation: currentLocation - ) - ) - ) - } - - func applySettingsPreviewPageMap(_ bookPageMap: RDEPUBBookPageMap) { - context.bookPageMap = bookPageMap - context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap)) - } - - func queueForwardAppendedPageMap(_ bookPageMap: RDEPUBBookPageMap) { - enqueuePendingPageMapUpdate( - RDEPUBPendingPageMapUpdate( - pageMap: bookPageMap, - kind: .appendForward - ) - ) - } - - func makeSnapshot(from bookPageMap: RDEPUBBookPageMap) -> RDEPUBReadingSession.PaginationSnapshot { - let pages = bookPageMap.entries.flatMap { entry in - (0.. full-book takeover). - let oldResolvedPage = currentLocation.flatMap { controller.pageNumber(for: $0) } - - context.textBook = nil - applyPageMapToLiveModel(newPageMap) - RDEPUBBackgroundTrace.log( - "Reconciliation", - "applyFullPageMapReplacement livePageBeforeApply=\(livePageBeforeApply) totalPages=\(newPageMap.totalPages) totalChapters=\(newPageMap.totalChapters)" - ) - - if let currentLocation { - let resolvedTargetPage = controller.pageNumber(for: currentLocation) - let shouldTrustResolvedLocation = shouldTrustFullReplaceResolvedPage( - resolvedTargetPage, - livePageBeforeApply: livePageBeforeApply, - locationMatchesLivePage: oldResolvedPage == livePageBeforeApply - ) - RDEPUBBackgroundTrace.log( - "Reconciliation", - "applyFullPageMapReplacement decision livePage=\(livePageBeforeApply) oldResolvedPage=\(oldResolvedPage ?? -1) resolvedTargetPage=\(resolvedTargetPage ?? -1) trustResolved=\(shouldTrustResolvedLocation) href=\(currentLocation.href)" - ) - - if shouldTrustResolvedLocation, - rebindVisibleLocation(currentLocation, readerView: readerView, controller: controller) { - return - } - - let fallbackPage = max(livePageBeforeApply, 1) - RDEPUBBackgroundTrace.log( - "Reconciliation", - "applyFullPageMapReplacement preserveLivePage fallbackPage=\(fallbackPage) currentReaderPage=\(readerView.currentPage + 1)" - ) - rebindVisiblePage( - to: fallbackPage - 1, - readerView: readerView - ) - } else { - readerView.reloadPageCountOnly() - } - - if let currentLocation, - context.normalizedSpineIndex(for: currentLocation) != nil, - let activeSession = jumpSessionManager.activeSession { - let candidateIndices = Set(newPageMap.entries.map { $0.spineIndex }) - let protectedIndices = activeSession.protectedSpineIndices - if protectedIndices.isSubset(of: candidateIndices) { - jumpSessionManager.endSession(.coverageComplete) - } - } - } - - private func enqueuePendingPageMapUpdate(_ update: RDEPUBPendingPageMapUpdate) { - var updates = context.pendingPageMapUpdates - if let existingIndex = updates.firstIndex(where: { - pendingPageMapUpdateKindMatches($0.kind, update.kind) - }) { - let existing = updates[existingIndex] - if shouldReplacePendingPageMapUpdate(existing, with: update) { - updates[existingIndex] = update - } - } else { - updates.append(update) - } - context.pendingPageMapUpdates = updates - commitPendingPageMapUpdateIfNeeded() - } - - private func rankedPendingPageMapUpdates() -> [(Int, RDEPUBPendingPageMapUpdate)] { - context.pendingPageMapUpdates.enumerated().sorted { lhs, rhs in - pendingPriority(for: lhs.element.kind) > pendingPriority(for: rhs.element.kind) - } - } - - private func commitPendingPageMapUpdate( - _ update: RDEPUBPendingPageMapUpdate, - at index: Int, - readerView: RDEpubReaderView, - controller: RDEPUBReaderController - ) -> Bool { - switch update.kind { - case .reconcileFullMap: - let decision = reconciliationCoordinator.evaluateTakeover( - candidatePageMap: update.pageMap, - candidateSegment: nil, - currentWindow: context.bookPageMap, - jumpSession: jumpSessionManager.activeSession - ) - - switch decision { - case .keepCurrentWindow: - RDEPUBBackgroundTrace.log( - "Reconciliation", - "commitPendingPageMapUpdate: keepCurrentWindow — removed pending update, totalPages=\(update.pageMap.totalPages)" - ) - removePendingPageMapUpdate(at: index) - return false - - case .fullReplace(let newPageMap): - removePendingPageMapUpdate(at: index) - applyFullPageMapReplacement(newPageMap, readerView: readerView, controller: controller) - return true - - case .expandWindow, .segmentReplace: - removePendingPageMapUpdate(at: index) - return false - } - - case .extendPartial(let capturedPageNumber, let currentLocation): - removePendingPageMapUpdate(at: index) - applyPageMapToLiveModel(update.pageMap) - let livePageNumber = max(readerView.currentPage, 0) + 1 - let shouldTrustCapturedLocation = livePageNumber == max(capturedPageNumber, 1) - RDEPUBBackgroundTrace.log( - "Reconciliation", - "extendPartial commit capturedPage=\(capturedPageNumber) livePage=\(livePageNumber) trustCaptured=\(shouldTrustCapturedLocation) totalPages=\(update.pageMap.totalPages) totalChapters=\(update.pageMap.totalChapters)" - ) - if shouldTrustCapturedLocation, - let currentLocation, - rebindVisibleLocation(currentLocation, readerView: readerView, controller: controller) { - return true - } - // Prefer the live readerView page when the user has moved since the - // extension request was created; otherwise a stale captured location can - // snap pageCurl back to the previous page after the turn completes. - let livePageIndex = max(readerView.currentPage, 0) - rebindVisiblePage( - to: livePageIndex, - readerView: readerView - ) - return true - - case .appendForward: - removePendingPageMapUpdate(at: index) - applyPageMapToLiveModel(update.pageMap) - readerView.reloadPageCountOnly() - return true - } - } - - private func rebindVisiblePage(to pageIndex: Int, readerView: RDEpubReaderView) { - if pageIndex == readerView.currentPage { - RDEPUBBackgroundTrace.log( - "Reconciliation", - "rebindVisiblePage pageUnchanged currentPage=\(readerView.currentPage + 1) displayType=\(readerView.currentDisplayType)" - ) - readerView.reloadPageCountOnly() - return - } - - RDEPUBBackgroundTrace.log( - "Reconciliation", - "rebindVisiblePage targetPage=\(pageIndex + 1) currentPage=\(readerView.currentPage + 1) displayType=\(readerView.currentDisplayType) isPageCurlTransitioning=\(readerView.isPageCurlTransitioning)" - ) - if readerView.currentDisplayType == .pageCurl { - if readerView.isPageCurlTransitioning { - // Defer the transition until the current page-curl animation completes, - // and re-read the live page at that time to avoid jumping to a stale position. - DispatchQueue.main.async { [weak readerView] in - guard let readerView, !readerView.isPageCurlTransitioning else { return } - let livePageIndex = max(readerView.currentPage, 0) - RDEPUBBackgroundTrace.log( - "Reconciliation", - "rebindVisiblePage deferredTransition livePage=\(livePageIndex + 1)" - ) - readerView.transitionToPage(pageNum: livePageIndex, animated: false) - } - } else { - readerView.transitionToPage(pageNum: pageIndex, animated: false) - } - } else { - readerView.reloadPageCountOnly() - if pageIndex != readerView.currentPage { - readerView.transitionToPage(pageNum: pageIndex, animated: false) - } - } - } - - private func rebindVisibleLocation( - _ location: RDEPUBLocation, - readerView: RDEpubReaderView, - controller: RDEPUBReaderController - ) -> Bool { - guard let targetPageNumber = controller.pageNumber(for: location) else { - RDEPUBBackgroundTrace.log( - "Reconciliation", - "rebindVisibleLocation failedToResolve locationHref=\(location.href) currentPage=\(readerView.currentPage + 1)" - ) - return false - } - - if context.bookPageMap != nil, - context.runtime?.prepareOnDemandChapter( - forAbsolutePageNumber: targetPageNumber, - allowSynchronousLoad: false - ) == false { - RDEPUBBackgroundTrace.log( - "Reconciliation", - "rebindVisibleLocation prepareOnDemandBlocked targetPage=\(targetPageNumber) currentPage=\(readerView.currentPage + 1)" - ) - return false - } - - RDEPUBBackgroundTrace.log( - "Reconciliation", - "rebindVisibleLocation targetPage=\(targetPageNumber) currentPage=\(readerView.currentPage + 1) href=\(location.href)" - ) - - rebindVisiblePage( - to: max(targetPageNumber - 1, 0), - readerView: readerView - ) - return true - } - - private func applyPageMapToLiveModel(_ pageMap: RDEPUBBookPageMap) { - context.bookPageMap = pageMap - context.replaceActiveSnapshot(makeSnapshot(from: pageMap)) - discardSupersededPendingPageMapUpdates(afterApplying: pageMap) - } - - private func removePendingPageMapUpdate(at index: Int) { - var updates = context.pendingPageMapUpdates - guard updates.indices.contains(index) else { return } - updates.remove(at: index) - context.pendingPageMapUpdates = updates - } - - private func discardSupersededPendingPageMapUpdates(afterApplying liveMap: RDEPUBBookPageMap) { - let updates = context.pendingPageMapUpdates.filter { update in - update.pageMap.totalChapters > liveMap.totalChapters - || ( - update.pageMap.totalChapters == liveMap.totalChapters - && update.pageMap.totalPages > liveMap.totalPages - ) - } - context.pendingPageMapUpdates = updates - } - - private func pendingPriority(for kind: RDEPUBPendingPageMapUpdateKind) -> Int { - switch kind { - case .extendPartial: - return 3 - case .appendForward: - return 2 - case .reconcileFullMap: - return 1 - } - } - - private func pendingPageMapUpdateKindMatches( - _ lhs: RDEPUBPendingPageMapUpdateKind, - _ rhs: RDEPUBPendingPageMapUpdateKind - ) -> Bool { - switch (lhs, rhs) { - case (.reconcileFullMap, .reconcileFullMap), - (.appendForward, .appendForward), - (.extendPartial, .extendPartial): - return true - default: - return false - } - } - - private func shouldReplacePendingPageMapUpdate( - _ existing: RDEPUBPendingPageMapUpdate, - with candidate: RDEPUBPendingPageMapUpdate - ) -> Bool { - candidate.pageMap.totalChapters > existing.pageMap.totalChapters - || ( - candidate.pageMap.totalChapters == existing.pageMap.totalChapters - && candidate.pageMap.totalPages >= existing.pageMap.totalPages - ) - } - - private func shouldTrustFullReplaceResolvedPage( - _ resolvedTargetPage: Int?, - livePageBeforeApply: Int, - locationMatchesLivePage: Bool - ) -> Bool { - guard let resolvedTargetPage else { return false } - if locationMatchesLivePage { - return true - } - // The location did not round-trip to the live page in the outgoing map - // (stale persisted location or mid-transition), so only follow it when it - // stays next to the page the user is actually looking at. - return abs(resolvedTargetPage - livePageBeforeApply) <= 1 - } - - private func schedulePendingCommitRetry() { - guard pendingCommitRetryWorkItem == nil else { return } - - let workItem = DispatchWorkItem { [weak self] in - guard let self else { return } - self.pendingCommitRetryWorkItem = nil - self.commitPendingPageMapUpdateIfNeeded() - } - pendingCommitRetryWorkItem = workItem - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: workItem) - } - - private func cancelPendingCommitRetry() { - pendingCommitRetryWorkItem?.cancel() - pendingCommitRetryWorkItem = nil - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderAnnotationCoordinator.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderAnnotationCoordinator.swift deleted file mode 100644 index 8354f60..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderAnnotationCoordinator.swift +++ /dev/null @@ -1,578 +0,0 @@ -import UIKit - -final class RDEPUBReaderAnnotationCoordinator { - - private unowned let context: RDEPUBReaderContext - - init(context: RDEPUBReaderContext) { - self.context = context - } - - private var controller: RDEPUBReaderController? { - context.controller - } - - func bookmark(withID id: String) -> RDEPUBBookmark? { - guard let controller else { return nil } - return controller.activeBookmarks.first { $0.id == id } - } - - func highlight(withID id: String) -> RDEPUBHighlight? { - guard let controller else { return nil } - return controller.activeHighlights.first { $0.id == id } - } - - func updateCurrentSelection(_ selection: RDEPUBSelection?) { - if let selection, !selection.isEmpty { - applySelectionState(.selected(selection)) - } else { - applySelectionState(.idle) - } - } - - func applySelectionState(_ state: RDEPUBSelectionState) { - guard let controller else { return } - context.selectionState = state - switch state { - case .idle: - controller.updateReaderChrome() - controller.delegate?.epubReader(controller, didChangeSelection: nil) - case .selecting: - break - case .selected(let selection): - controller.updateReaderChrome() - controller.delegate?.epubReader(controller, didChangeSelection: selection) - case .committingAction: - break - } - } - - @discardableResult - func addHighlight( - from selection: RDEPUBSelection? = nil, - color: String = "#F8E16C", - note: String? = nil - ) -> RDEPUBHighlight? { - addAnnotation(from: selection, style: .highlight, color: color, note: note) - } - - @discardableResult - func addAnnotation( - from selection: RDEPUBSelection? = nil, - style: RDEPUBHighlightStyle, - color: String = "#F8E16C", - note: String? = nil - ) -> RDEPUBHighlight? { - guard let controller else { return nil } - let sourceSelection = selection ?? controller.currentSelection - guard let sourceSelection, - let scopedSelection = scopedSelection(sourceSelection, relativeToSpineIndex: nil), - !scopedSelection.isEmpty else { - return nil - } - - let newHighlight = RDEPUBHighlight( - bookIdentifier: controller.currentBookIdentifier, - location: scopedSelection.location, - text: scopedSelection.text, - rangeInfo: scopedSelection.rangeInfo, - style: style, - color: color, - note: note - ) - - let isDuplicate = controller.activeHighlights.contains { highlight in - highlight.location.href == newHighlight.location.href && - highlight.location.fragment == newHighlight.location.fragment && - highlight.text == newHighlight.text && - highlight.rangeInfo == newHighlight.rangeInfo && - highlight.style == newHighlight.style - } - guard !isDuplicate else { - return nil - } - - controller.activeHighlights.append(newHighlight) - persistHighlightsAndRefreshContent() - updateCurrentSelection(nil) - return newHighlight - } - - @discardableResult - func upsertHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight? { - guard let controller else { return nil } - guard let scopedHighlight = scopedHighlight(highlight) else { - return nil - } - - if let index = controller.activeHighlights.firstIndex(where: { $0.id == scopedHighlight.id }) { - controller.activeHighlights[index] = scopedHighlight - } else { - controller.activeHighlights.append(scopedHighlight) - } - persistHighlightsAndRefreshContent() - return scopedHighlight - } - - @discardableResult - func removeHighlight(id: String) -> RDEPUBHighlight? { - guard let controller else { return nil } - guard let index = controller.activeHighlights.firstIndex(where: { $0.id == id }) else { - return nil - } - let removed = controller.activeHighlights.remove(at: index) - persistHighlightsAndRefreshContent() - return removed - } - - @discardableResult - func updateHighlightNote(id: String, note: String?) -> RDEPUBHighlight? { - guard let controller else { return nil } - guard let index = controller.activeHighlights.firstIndex(where: { $0.id == id }) else { - return nil - } - controller.activeHighlights[index].note = normalizedNote(note) - persistHighlightsAndRefreshContent() - return controller.activeHighlights[index] - } - - @discardableResult - func go(toHighlightID id: String, animated: Bool = true) -> Bool { - guard let highlight = highlight(withID: id) else { - return false - } - return navigate(to: highlight, animated: animated) - } - - func removeAllHighlights() { - guard let controller else { return } - guard !controller.activeHighlights.isEmpty else { return } - controller.activeHighlights.removeAll() - persistHighlightsAndRefreshContent() - } - - func scopedSelection( - _ selection: RDEPUBSelection, - relativeToSpineIndex spineIndex: Int? - ) -> RDEPUBSelection? { - guard let controller else { return nil } - guard let publication = controller.publication else { return nil } - let normalizedLocation = publication.resourceResolver.normalizedLocation( - selection.location, - relativeToSpineIndex: spineIndex, - bookIdentifier: controller.currentBookIdentifier - ) ?? RDEPUBLocation( - bookIdentifier: controller.currentBookIdentifier, - href: selection.location.href, - progression: selection.location.progression, - lastProgression: selection.location.lastProgression, - fragment: selection.location.fragment, - rangeAnchor: selection.location.rangeAnchor, - cfi: selection.location.cfi, - lastCFI: selection.location.lastCFI, - rangeCFI: selection.location.rangeCFI - ) - return RDEPUBSelection( - bookIdentifier: controller.currentBookIdentifier, - location: normalizedLocation, - text: selection.text, - rangeInfo: selection.rangeInfo, - createdAt: selection.createdAt - ) - } - - func presentHighlightsManager() { - guard let controller else { return } - guard controller.configuration.allowsHighlights else { return } - guard !controller.activeHighlights.isEmpty else { return } - - let highlightsController = RDEPUBReaderHighlightsViewController( - highlights: controller.activeHighlights, - theme: controller.configuration.theme, - sectionTitleProvider: { [weak self] highlight in - self?.titleForHighlight(highlight) - } - ) - highlightsController.onSelectHighlight = { [weak self, weak highlightsController] highlight in - highlightsController?.dismiss(animated: true) { - _ = self?.navigate(to: highlight, animated: true) - } - } - highlightsController.onUpdateHighlight = { [weak self] highlight in - _ = self?.controller?.updateHighlightNote(id: highlight.id, note: highlight.note) - } - highlightsController.onDeleteHighlight = { [weak self] highlight in - _ = self?.controller?.removeHighlight(id: highlight.id) - } - - let navigationController = UINavigationController(rootViewController: highlightsController) - navigationController.modalPresentationStyle = .pageSheet - controller.present(navigationController, animated: true) - } - - func presentAnnotationCreation() { - guard let controller else { return } - guard controller.configuration.allowsHighlights, - let currentSelection = controller.currentSelection else { - return - } - presentAnnotationActionSheet(for: currentSelection) - } - - func presentHighlightActions(for highlight: RDEPUBHighlight, sourceView: UIView, sourceRect: CGRect) { - guard let controller else { return } - let alert = UIAlertController(title: "标注操作", message: highlight.text, preferredStyle: .actionSheet) - alert.addAction(UIAlertAction(title: "删除高亮", style: .destructive) { [weak self] _ in - _ = self?.removeHighlight(id: highlight.id) - }) - if highlight.hasNote { - alert.addAction(UIAlertAction(title: "删除批注", style: .destructive) { [weak self] _ in - _ = self?.updateHighlightNote(id: highlight.id, note: nil) - }) - } - alert.addAction(UIAlertAction(title: "取消", style: .cancel)) - - if let popover = alert.popoverPresentationController { - popover.sourceView = sourceView - popover.sourceRect = sourceRect - } - - controller.present(alert, animated: true) - } - - func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) { - guard let selection else { return } - switch action { - case .copy: - UIPasteboard.general.string = selection.text - updateCurrentSelection(nil) - case .highlight: - createAnnotation(from: selection, style: .highlight) - case .annotate: - presentAnnotationNoteEditor(for: selection) - } - } - - @discardableResult - func addBookmark(note: String? = nil) -> RDEPUBBookmark? { - guard let controller else { return nil } - guard let location = scopedBookmarkLocation(controller.currentVisibleLocation()) else { - return nil - } - guard bookmark(matching: location) == nil else { - return nil - } - - let newBookmark = RDEPUBBookmark( - bookIdentifier: controller.currentBookIdentifier, - location: location, - rangeInfo: controller.currentVisibleRangeInfo(), - chapterTitle: titleForBookmarkLocation(location), - note: normalizedBookmarkNote(note) - ) - controller.activeBookmarks.append(newBookmark) - persistBookmarksAndRefreshChrome() - return newBookmark - } - - @discardableResult - func toggleBookmark(note: String? = nil) -> RDEPUBBookmark? { - guard let controller else { return nil } - guard let location = scopedBookmarkLocation(controller.currentVisibleLocation()) else { - return nil - } - - if let existingBookmark = bookmark(matching: location) { - _ = removeBookmark(id: existingBookmark.id) - return nil - } - - return addBookmark(note: note) - } - - @discardableResult - func removeBookmark(id: String) -> RDEPUBBookmark? { - guard let controller else { return nil } - guard let index = controller.activeBookmarks.firstIndex(where: { $0.id == id }) else { - return nil - } - let removed = controller.activeBookmarks.remove(at: index) - persistBookmarksAndRefreshChrome() - return removed - } - - @discardableResult - func go(toBookmarkID id: String, animated: Bool = true) -> Bool { - guard let controller else { return false } - guard let bookmark = bookmark(withID: id) else { - return false - } - return controller.restoreReadingLocation( - bookmark.location, - animated: animated, - targetHighlightRangeInfo: bookmark.rangeInfo - ) - } - - func presentBookmarksManager() { - guard let controller else { return } - guard !controller.activeBookmarks.isEmpty else { return } - - let bookmarksController = RDEPUBReaderBookmarksViewController( - bookmarks: controller.activeBookmarks, - theme: controller.configuration.theme - ) - bookmarksController.onSelectBookmark = { [weak self, weak bookmarksController] bookmark in - guard let controller = self?.controller else { return } - bookmarksController?.dismiss(animated: true) { - _ = controller.restoreReadingLocation( - bookmark.location, - animated: true, - targetHighlightRangeInfo: bookmark.rangeInfo - ) - } - } - bookmarksController.onDeleteBookmark = { [weak self] bookmark in - _ = self?.controller?.removeBookmark(id: bookmark.id) - } - - let navigationController = UINavigationController(rootViewController: bookmarksController) - navigationController.modalPresentationStyle = .pageSheet - controller.present(navigationController, animated: true) - } - - private func scopedHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight? { - guard let controller else { return nil } - guard let publication = controller.publication else { return nil } - let normalizedLocation = publication.resourceResolver.normalizedLocation( - highlight.location, - relativeToSpineIndex: nil, - bookIdentifier: controller.currentBookIdentifier - ) ?? RDEPUBLocation( - bookIdentifier: controller.currentBookIdentifier, - href: highlight.location.href, - progression: highlight.location.progression, - lastProgression: highlight.location.lastProgression, - fragment: highlight.location.fragment, - rangeAnchor: highlight.location.rangeAnchor, - cfi: highlight.location.cfi, - lastCFI: highlight.location.lastCFI, - rangeCFI: highlight.location.rangeCFI - ) - return RDEPUBHighlight( - id: highlight.id, - bookIdentifier: controller.currentBookIdentifier, - location: normalizedLocation, - text: highlight.text, - rangeInfo: highlight.rangeInfo, - style: highlight.style, - color: highlight.color, - note: highlight.note, - createdAt: highlight.createdAt - ) - } - - @discardableResult - private func navigate(to highlight: RDEPUBHighlight, animated: Bool) -> Bool { - guard let controller else { return false } - let navigationTarget = scopedHighlight(highlight) ?? highlight - return controller.restoreReadingLocation( - navigationTarget.location, - animated: animated, - targetHighlightRangeInfo: navigationTarget.rangeInfo - ) - } - - private func persistHighlightsAndRefreshContent() { - guard let controller else { return } - if let currentBookIdentifier = controller.currentBookIdentifier { - controller.persistence?.saveHighlights(controller.activeHighlights, for: currentBookIdentifier) - } - controller.delegate?.epubReader(controller, didUpdateHighlights: controller.activeHighlights) - controller.updateReaderChrome() - refreshVisibleContentPreservingCurrentPage() - } - - private func refreshVisibleContentPreservingCurrentPage() { - guard let controller else { return } - let currentPage = controller.readerView.currentPage - guard currentPage >= 0 else { - controller.refreshVisibleContentPreservingLocation() - return - } - - controller.readerView.reloadData() - if controller.readerView.currentPage != currentPage { - controller.readerView.transitionToPage(pageNum: currentPage, animated: false) - } - } - - private func presentAnnotationActionSheet(for selection: RDEPUBSelection) { - guard let controller else { return } - let alert = UIAlertController(title: "创建标注", message: nil, preferredStyle: .actionSheet) - alert.addAction(UIAlertAction(title: "高亮", style: .default) { [weak self] _ in - self?.createAnnotation(from: selection, style: .highlight) - }) - alert.addAction(UIAlertAction(title: "划线", style: .default) { [weak self] _ in - self?.createAnnotation(from: selection, style: .underline) - }) - alert.addAction(UIAlertAction(title: "批注", style: .default) { [weak self] _ in - self?.presentAnnotationNoteEditor(for: selection) - }) - alert.addAction(UIAlertAction(title: "取消", style: .cancel)) - - if let popover = alert.popoverPresentationController { - popover.sourceView = controller.bottomToolView - popover.sourceRect = controller.bottomToolView.bounds - } - - controller.present(alert, animated: true) - } - - private func createAnnotation(from selection: RDEPUBSelection, style: RDEPUBHighlightStyle, note: String? = nil) { - _ = addAnnotation(from: selection, style: style, note: note) - } - - private func presentAnnotationNoteEditor(for selection: RDEPUBSelection) { - guard let controller else { return } - let alert = UIAlertController(title: "添加批注", message: selection.text, preferredStyle: .alert) - alert.addTextField { textField in - textField.placeholder = "输入批注内容" - } - alert.addAction(UIAlertAction(title: "取消", style: .cancel)) - alert.addAction(UIAlertAction(title: "保存", style: .default) { [weak self, weak alert] _ in - self?.createAnnotation( - from: selection, - style: .highlight, - note: alert?.textFields?.first?.text - ) - }) - controller.present(alert, animated: true) - } - - private func titleForHighlight(_ highlight: RDEPUBHighlight) -> String? { - guard let controller else { return nil } - guard let publication = controller.publication, - let normalizedHighlightHref = publication.resourceResolver.normalizedHref(highlight.location.href) else { - return nil - } - - return controller.flattenedTableOfContents.first { item in - let rawHref = item.href.components(separatedBy: "#").first ?? item.href - return publication.resourceResolver.normalizedHref(rawHref) == normalizedHighlightHref - }?.title - } - - private func normalizedNote(_ note: String?) -> String? { - let trimmed = note?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return trimmed.isEmpty ? nil : trimmed - } - - private func titleForBookmarkLocation(_ location: RDEPUBLocation) -> String? { - guard let controller else { return nil } - if let currentLocation = controller.currentVisibleLocation(), - bookmarkHref(for: currentLocation) == bookmarkHref(for: location) { - return controller.currentTableOfContentsItem?.title - } - - return controller.flattenedTableOfContents.last { item in - bookmarkHref(forTableOfContentsHref: item.href) == bookmarkHref(for: location) - }?.title - } - - private func persistBookmarksAndRefreshChrome() { - guard let controller else { return } - guard let currentBookIdentifier = controller.currentBookIdentifier else { return } - controller.persistence?.saveBookmarks(controller.activeBookmarks, for: currentBookIdentifier) - controller.delegate?.epubReader(controller, didUpdateBookmarks: controller.activeBookmarks) - controller.updateReaderChrome() - } - - func currentBookmark() -> RDEPUBBookmark? { - guard let controller else { return nil } - guard let location = scopedBookmarkLocation(controller.currentVisibleLocation()) else { - return nil - } - return bookmark(matching: location) - } - - private func bookmark(matching location: RDEPUBLocation?) -> RDEPUBBookmark? { - guard let controller else { return nil } - guard let location else { return nil } - return controller.activeBookmarks.first { bookmarkMatchesLocation($0, location: location) } - } - - private func bookmarkMatchesLocation(_ bookmark: RDEPUBBookmark, location: RDEPUBLocation) -> Bool { - guard let controller else { return false } - guard bookmarkHref(for: bookmark.location) == bookmarkHref(for: location) else { - return false - } - - if let bookmarkCFI = bookmark.location.cfi, - let locationCFI = location.cfi { - return bookmarkCFI == locationCFI - } - - if let bookmarkAnchor = bookmark.location.rangeAnchor, - let locationAnchor = location.rangeAnchor { - return bookmarkAnchor == locationAnchor - } - - if let bookmarkFragment = bookmark.location.fragment, - let locationFragment = location.fragment { - return bookmarkFragment == locationFragment - } - - let progressionDelta = abs(bookmark.location.navigationProgression - location.navigationProgression) - let threshold: Double = controller.publication?.layout == .fixed ? 0.01 : 0.05 - return progressionDelta <= threshold - } - - private func bookmarkHref(for location: RDEPUBLocation) -> String { - controller?.publication?.resourceResolver.normalizedHref(location.href) ?? location.href - } - - private func bookmarkHref(forTableOfContentsHref href: String) -> String { - let rawHref = href.components(separatedBy: "#").first ?? href - return controller?.publication?.resourceResolver.normalizedHref(rawHref) ?? rawHref - } - - private func scopedBookmarkLocation(_ location: RDEPUBLocation?) -> RDEPUBLocation? { - guard let controller else { return nil } - guard let location else { return nil } - guard let publication = controller.publication else { - return RDEPUBLocation( - bookIdentifier: controller.currentBookIdentifier, - href: location.href, - progression: location.progression, - lastProgression: location.lastProgression, - fragment: location.fragment, - rangeAnchor: location.rangeAnchor, - cfi: location.cfi, - lastCFI: location.lastCFI, - rangeCFI: location.rangeCFI - ) - } - - return publication.resourceResolver.normalizedLocation( - location, - relativeToSpineIndex: nil, - bookIdentifier: controller.currentBookIdentifier - ) ?? RDEPUBLocation( - bookIdentifier: controller.currentBookIdentifier, - href: location.href, - progression: location.progression, - lastProgression: location.lastProgression, - fragment: location.fragment, - rangeAnchor: location.rangeAnchor, - cfi: location.cfi, - lastCFI: location.lastCFI, - rangeCFI: location.rangeCFI - ) - } - - private func normalizedBookmarkNote(_ note: String?) -> String? { - let trimmed = note?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return trimmed.isEmpty ? nil : trimmed - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderAssemblyCoordinator.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderAssemblyCoordinator.swift deleted file mode 100644 index 9179cbb..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderAssemblyCoordinator.swift +++ /dev/null @@ -1,81 +0,0 @@ -import UIKit - -final class RDEPUBReaderAssemblyCoordinator { - private unowned let context: RDEPUBReaderContext - - init(context: RDEPUBReaderContext) { - self.context = context - } - - func assembleInterface() { - guard let controller = context.controller, - let readerView = context.readerView else { return } - - controller.view.backgroundColor = context.configuration.theme.contentBackgroundColor - setupReaderView(readerView, in: controller.view) - setupLoadingIndicator(controller.loadingIndicator, in: controller.view) - setupErrorLabel(controller.errorLabel, in: controller.view) - controller.delegate?.epubReader(controller, configureTopToolView: controller.topToolView) - } - - func finishExternalTextBookLaunchIfNeeded() { - guard let runtime = context.runtime, - let controller = context.controller, - context.isExternalTextBook else { - return - } - - let restoreLocation = context.currentBookIdentifier.flatMap { context.persistence?.loadLocation(for: $0) } - if let id = context.currentBookIdentifier { - context.activeBookmarks = context.persistence?.loadBookmarks(for: id) ?? [] - context.activeHighlights = context.persistence?.loadHighlights(for: id) ?? [] - } - - if let textBook = controller.textBook { - runtime.applyTextBook(textBook, restoreLocation: restoreLocation) - } else { - runtime.finishPagination(restoreLocation: restoreLocation) - } - } - - private func setupReaderView(_ readerView: RDEpubReaderView, in containerView: UIView) { - readerView.pageProvider = context.controller - readerView.delegate = context.controller - readerView.translatesAutoresizingMaskIntoConstraints = false - containerView.addSubview(readerView) - NSLayoutConstraint.activate([ - readerView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), - readerView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), - readerView.topAnchor.constraint(equalTo: containerView.topAnchor), - readerView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor) - ]) - - readerView.register(contentView: RDEPUBTextContentView.self, contentViewWithReuseIdentifier: NSStringFromClass(RDEPUBTextContentView.self)) - readerView.register(contentView: RDEPUBWebContentView.self, contentViewWithReuseIdentifier: NSStringFromClass(RDEPUBWebContentView.self)) - readerView.register( - contentView: RDEPUBTrialWallContainerView.self, - contentViewWithReuseIdentifier: NSStringFromClass(RDEPUBTrialWallContainerView.self) - ) - context.controller?.applyReaderViewConfiguration() - } - - private func setupLoadingIndicator(_ loadingIndicator: UIActivityIndicatorView, in containerView: UIView) { - loadingIndicator.hidesWhenStopped = true - loadingIndicator.translatesAutoresizingMaskIntoConstraints = false - containerView.addSubview(loadingIndicator) - NSLayoutConstraint.activate([ - loadingIndicator.centerXAnchor.constraint(equalTo: containerView.centerXAnchor), - loadingIndicator.centerYAnchor.constraint(equalTo: containerView.centerYAnchor) - ]) - } - - private func setupErrorLabel(_ errorLabel: UILabel, in containerView: UIView) { - errorLabel.translatesAutoresizingMaskIntoConstraints = false - containerView.addSubview(errorLabel) - NSLayoutConstraint.activate([ - errorLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 24), - errorLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -24), - errorLabel.centerYAnchor.constraint(equalTo: containerView.centerYAnchor) - ]) - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderChromeCoordinator.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderChromeCoordinator.swift deleted file mode 100644 index b714576..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderChromeCoordinator.swift +++ /dev/null @@ -1,235 +0,0 @@ -import UIKit - -final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationControllerDelegate { - - private unowned let context: RDEPUBReaderContext - - init(context: RDEPUBReaderContext) { - self.context = context - } - - private var controller: RDEPUBReaderController? { - context.controller - } - - func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { - - context.runtime?.settingsPanelDidDisappear() - } - - func makeTopToolView() -> RDEPUBReaderTopToolViewProviding { - let toolView: RDEPUBReaderTopToolViewProviding = - context.controller?.dependencies.makeTopToolView?() ?? RDEPUBReaderTopToolView() - toolView.onBack = { [weak self] in - self?.handleBackAction() - } - toolView.onSearch = { [weak self] in - self?.toggleSearchBar() - } - toolView.onToggleBookmark = { [weak self] in - _ = self?.context.runtime?.toggleBookmark() - } - return toolView - } - - func makeBottomToolView() -> RDEPUBReaderBottomToolViewProviding { - let toolView: RDEPUBReaderBottomToolViewProviding = - context.controller?.dependencies.makeBottomToolView?() ?? RDEPUBReaderBottomToolView() - toolView.onShowTableOfContents = { [weak self] in - self?.presentTableOfContents() - } - toolView.onShowBookmarks = { [weak self] in - self?.context.runtime?.presentBookmarksManager() - } - toolView.onShowHighlights = { [weak self] in - self?.context.runtime?.presentHighlightsManager() - } - toolView.onAddHighlight = { [weak self] in - self?.context.runtime?.presentAnnotationCreation() - } - toolView.onShowSettings = { [weak self] in - self?.presentSettings() - } - return toolView - } - - func updateReaderChrome() { - guard let controller else { return } - let uiState = makeUIState() - applyUIState(uiState) - updateSearchBar() - } - - func makeUIState() -> RDEPUBReaderUIState { - guard let controller else { return .empty } - return RDEPUBReaderUIState( - canToggleBookmark: controller.currentBookIdentifier != nil, - hasBookmarkAtCurrentLocation: hasBookmarkAtCurrentLocation(), - canShowBookmarks: !controller.activeBookmarks.isEmpty, - canAddHighlight: controller.configuration.allowsHighlights && context.selectionState.hasSelection, - canShowHighlights: controller.configuration.allowsHighlights && !controller.activeHighlights.isEmpty, - showsTableOfContents: controller.configuration.showsTableOfContents, - allowsHighlights: controller.configuration.allowsHighlights, - showsSettingsPanel: controller.configuration.showsSettingsPanel - ) - } - - func applyUIState(_ state: RDEPUBReaderUIState) { - guard let controller else { return } - controller.topToolView.apply(theme: controller.configuration.theme) - controller.topToolView.setTitle( - controller.title - ?? controller.parser?.metadata.title - ?? controller.epubURL.deletingPathExtension().lastPathComponent - ) - controller.topToolView.setBookmarkEnabled(state.canToggleBookmark) - controller.topToolView.setBookmarkSelected(state.hasBookmarkAtCurrentLocation) - controller.bottomToolView.apply(theme: controller.configuration.theme) - controller.bottomToolView.updateVisibility( - showsTableOfContents: state.showsTableOfContents, - allowsHighlights: state.allowsHighlights, - showsSettingsPanel: state.showsSettingsPanel - ) - controller.bottomToolView.setBookmarksEnabled(state.canShowBookmarks) - controller.bottomToolView.setAddHighlightEnabled(state.canAddHighlight) - controller.bottomToolView.setHighlightsEnabled(state.canShowHighlights) - } - - private func hasBookmarkAtCurrentLocation() -> Bool { - guard let controller else { return false } - return context.runtime?.annotationCoordinator.currentBookmark() != nil - } - - func presentSettings() { - guard let controller else { return } - guard controller.configuration.showsSettingsPanel else { return } - - context.runtime?.settingsPanelWillAppear() - - let settingsController = RDEPUBReaderSettingsViewController( - configuration: controller.configuration, - brightness: controller.currentBrightness - ) - settingsController.onBrightnessChange = { [weak controller] brightness in - controller?.setScreenBrightness(brightness) - } - settingsController.onFontSizeChange = { [weak controller] fontSize in - controller?.updateConfiguration { $0.fontSize = fontSize } - } - settingsController.onFontChoiceChange = { [weak controller] fontChoice in - controller?.updateConfiguration { $0.fontChoice = fontChoice } - } - settingsController.onLineHeightChange = { [weak controller] lineHeightMultiple in - controller?.updateConfiguration { $0.lineHeightMultiple = lineHeightMultiple } - } - settingsController.onColumnCountChange = { [weak controller] numberOfColumns in - controller?.updateConfiguration { $0.numberOfColumns = numberOfColumns } - } - settingsController.onDisplayTypeChange = { [weak controller] displayType in - controller?.updateConfiguration { $0.displayType = displayType } - } - settingsController.onThemeChange = { [weak controller] theme in - controller?.updateConfiguration { $0.theme = theme } - } - settingsController.onDismiss = { [weak self] in - - self?.context.runtime?.settingsPanelDidDisappear() - } - - let navigationController = UINavigationController(rootViewController: settingsController) - navigationController.modalPresentationStyle = .pageSheet - navigationController.presentationController?.delegate = self - controller.present(navigationController, animated: true) - } - - func presentTableOfContents() { - guard let controller else { return } - guard controller.configuration.showsTableOfContents else { return } - let items = controller.flattenedTableOfContentsItems( - from: controller.publication?.tableOfContents ?? [], - includePageNumbers: false - ) - guard !items.isEmpty else { return } - - let chapterController = RDEPUBReaderChapterListController( - items: items, - currentItem: controller.currentTableOfContentsItem, - theme: controller.configuration.theme - ) - chapterController.onSelectItem = { [weak controller] item in - guard let controller else { return } - chapterController.dismiss(animated: true) { - _ = controller.go(toTableOfContentsItem: item, animated: true) - } - } - - let navigationController = UINavigationController(rootViewController: chapterController) - navigationController.modalPresentationStyle = .pageSheet - controller.present(navigationController, animated: true) - } - - func toggleSearchBar() { - guard let controller else { return } - if controller.isSearchBarVisible { - controller.hideSearchBar() - } else { - controller.showSearchBar() - } - } - - func updateSearchBar() { - guard let controller else { return } - controller.searchBarView.apply(theme: controller.configuration.theme) - if let searchState = controller.searchState { - if let index = searchState.currentMatchIndex { - controller.searchBarView.updateMatchCount(current: index + 1, total: searchState.matches.count) - } else if searchState.matches.isEmpty { - controller.searchBarView.showNoResults() - } - } - } - - func handleBackAction() { - guard let controller else { return } - close(controller) - } - - private func close(_ controller: UIViewController) { - let target = closestDismissTarget(from: controller) - if let navigationController = target.navigationController, - navigationController.viewControllers.first !== target { - navigationController.popViewController(animated: true) - return - } - - if let navigationController = target.navigationController, - navigationController.presentingViewController != nil { - navigationController.dismiss(animated: true) - return - } - - if target.presentingViewController != nil { - target.dismiss(animated: true) - return - } - - controller.dismiss(animated: true) - } - - private func closestDismissTarget(from controller: UIViewController) -> UIViewController { - var candidate: UIViewController = controller - var current = controller.parent - while let parent = current { - if let navigationController = parent.navigationController, - navigationController.viewControllers.contains(parent) { - return parent - } - if parent.presentingViewController != nil || parent.navigationController?.presentingViewController != nil { - return parent - } - candidate = parent - current = parent.parent - } - return candidate - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderContext.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderContext.swift deleted file mode 100644 index cb325a5..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderContext.swift +++ /dev/null @@ -1,420 +0,0 @@ -import UIKit - -/// Captures layout parameters on the main thread for safe use on background queues. -/// Create via `RDEPUBReaderContext.makeLayoutSnapshot()` before dispatching work off the main thread. -struct RDEPUBLayoutSnapshot { - let pageSize: CGSize - let style: RDEPUBTextRenderStyle - let layoutConfig: RDEPUBTextLayoutConfig - let renderSignature: String -} - -final class RDEPUBReaderContext { - - private let activityLock = NSLock() - - private var lastUserNavigationTimestamp: CFAbsoluteTime = 0 - - weak var controller: RDEPUBReaderController? - - weak var readerView: RDEpubReaderView? - - let state: RDEPUBReaderState - - let environment: RDEPUBReaderEnvironment - - let services: RDEPUBReaderServices - - var dependencies: RDEPUBReaderDependencies { - get { services.dependencies } - set { - services.dependencies = newValue - environment.displayEnvironment = newValue.environment - } - } - - var runtime: RDEPUBReaderRuntime? { - controller?.runtime - } - - var parser: RDEPUBParser? { - get { state.parser } - set { state.parser = newValue } - } - - var publication: RDEPUBPublication? { - get { state.publication } - set { state.publication = newValue } - } - - var readingSession: RDEPUBReadingSession? { - get { state.readingSession } - set { state.readingSession = newValue } - } - - var textBook: RDEPUBTextBook? { - get { state.textBook } - set { state.textBook = newValue } - } - - var bookPageMap: RDEPUBBookPageMap? { - get { state.bookPageMap } - set { state.bookPageMap = newValue } - } - - var activeBookmarks: [RDEPUBBookmark] { - get { state.activeBookmarks } - set { state.activeBookmarks = newValue } - } - - var activeHighlights: [RDEPUBHighlight] { - get { state.activeHighlights } - set { state.activeHighlights = newValue } - } - - var currentBookIdentifier: String? { - get { state.currentBookIdentifier } - set { state.currentBookIdentifier = newValue } - } - - var paginationToken: UUID { - get { state.paginationToken } - set { state.paginationToken = newValue } - } - - var searchState: RDEPUBSearchState? { - get { state.searchState } - set { state.searchState = newValue } - } - - var pendingPageMapUpdates: [RDEPUBPendingPageMapUpdate] { - get { state.pendingPageMapUpdates } - set { state.pendingPageMapUpdates = newValue } - } - - var lastTextPaginationPageSize: CGSize? { - get { state.lastTextPaginationPageSize } - set { state.lastTextPaginationPageSize = newValue } - } - - var lastMetadataParseWallClockMs: Int { - get { state.lastMetadataParseWallClockMs } - set { state.lastMetadataParseWallClockMs = newValue } - } - - var lastMetadataParseConcurrency: Int { - get { state.lastMetadataParseConcurrency } - set { state.lastMetadataParseConcurrency = newValue } - } - - var currentSelection: RDEPUBSelection? { - get { state.currentSelection } - set { state.currentSelection = newValue } - } - - var selectionState: RDEPUBSelectionState { - get { state.selectionState } - set { state.selectionState = newValue } - } - - var configuration: RDEPUBReaderConfiguration = .default - - var persistence: RDEPUBReaderPersistence? - - var epubURL: URL = URL(string: "about:blank")! - - var isRepaginating: Bool { - get { state.isRepaginating } - set { state.isRepaginating = newValue } - } - - var didStartInitialLoad: Bool { - get { state.didStartInitialLoad } - set { state.didStartInitialLoad = newValue } - } - - var isExternalTextBook: Bool { - get { state.isExternalTextBook } - set { state.isExternalTextBook = newValue } - } - - var textFileURL: URL? { - get { state.textFileURL } - set { state.textFileURL = newValue } - } - - var textBookCache: RDEPUBTextBookCache { state.textBookCache } - - init(controller: RDEPUBReaderController) { - self.controller = controller - self.readerView = controller.readerView - let state = RDEPUBReaderState() - self.state = state - self.environment = RDEPUBReaderEnvironment( - controller: controller, - readerView: controller.readerView, - displayEnvironment: RDEPUBUIScreenEnvironment() - ) - self.services = RDEPUBReaderServices(dependencies: .live) - } - - func currentLayoutContext() -> RDEPUBNavigatorLayoutContext { - environment.currentLayoutContext(configuration: configuration) - } - - func currentPreferences() -> RDEPUBPreferences { - let safeInsets = RDEPUBSafeArea.resolve(controller?.view.safeAreaInsets) - return configuration.makePreferences(safeAreaInsets: safeInsets) - } - - /// Captures all layout parameters needed for background chapter loading. - /// Must be called on the main thread. The returned snapshot is safe to use on any thread. - func makeLayoutSnapshot() -> RDEPUBLayoutSnapshot { - dispatchPrecondition(condition: .onQueue(.main)) - let pageSize = currentTextPageSize() - let style = currentTextRenderStyle() - let layoutConfig = currentTextLayoutConfig(pageSize: pageSize) - let renderSignature = renderSignature(style: style, pageSize: pageSize, layoutConfig: layoutConfig) - return RDEPUBLayoutSnapshot( - pageSize: pageSize, - style: style, - layoutConfig: layoutConfig, - renderSignature: renderSignature - ) - } - - func currentTextPageSize() -> CGSize { - // M-04: Use dispatchPrecondition instead of assert so it's enforced in Release builds too. - dispatchPrecondition(condition: .onQueue(.main)) - - let pageNum = (readerView?.currentPage ?? -1) >= 0 ? readerView?.currentPage : nil - if let readerView, let pageNum { - let resolvedSize = readerView.resolvedSinglePageSize(pageNum: pageNum) - if resolvedSize.width > 0, resolvedSize.height > 0 { - return resolvedSize - } - } - let viewportSize = currentLayoutContext().viewportSize - if viewportSize.width > 0, viewportSize.height > 0 { - return viewportSize - } - if let lastTextPaginationPageSize, - lastTextPaginationPageSize.width > 0, - lastTextPaginationPageSize.height > 0 { - return lastTextPaginationPageSize - } - return environment.fallbackViewportSize - } - - func currentTextRenderStyle() -> RDEPUBTextRenderStyle { - dispatchPrecondition(condition: .onQueue(.main)) - return environment.currentTextRenderStyle(configuration: configuration) - } - - func currentTextLayoutConfig(pageSize: CGSize) -> RDEPUBTextLayoutConfig { - dispatchPrecondition(condition: .onQueue(.main)) - return environment.currentTextLayoutConfig(configuration: configuration, pageSize: pageSize) - } - - func resolvedTextRenderer() -> RDEPUBTextRenderer { - services.resolvedTextRenderer(configuration: configuration) - } - - var activePages: [EPUBPage] { - state.activePages - } - - var activeChapters: [EPUBChapterInfo] { - state.activeChapters - } - - var currentBrightness: CGFloat { - get { environment.currentBrightness } - set { environment.currentBrightness = newValue } - } - - func replaceActiveSnapshot(_ snapshot: RDEPUBReadingSession.PaginationSnapshot) { - state.replaceActiveSnapshot(snapshot) - } - - func clearActiveSnapshot() { - state.clearActiveSnapshot() - } - - func makeParser() -> RDEPUBParser { - services.makeParser() - } - - func makePaginator() -> RDEPUBPaginator { - services.makePaginator() - } - - func makeTextBookBuilder(layoutConfig: RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder { - services.makeTextBookBuilder( - configuration: configuration, - cache: textBookCache, - layoutConfig: layoutConfig - ) - } - - func makeChapterSummaryDiskCache() -> RDEPUBChapterSummaryDiskCache { - services.makeChapterSummaryDiskCache(bookIdentifier: currentBookIdentifier) - } - - func chapterCacheKey(forSpineIndex spineIndex: Int) -> RDEPUBChapterCacheKey { - let contentHash: String - if let parser, - let publication, - publication.spine.indices.contains(spineIndex) { - let href = publication.spine[spineIndex].href - contentHash = parser.htmlString(forRelativePath: href)?.rd_sha256Hex ?? "" - } else { - contentHash = "" - } - return chapterCacheKey( - forSpineIndex: spineIndex, - precomputedContentHash: contentHash, - renderSignature: currentRenderSignature() - ) - } - - func chapterCacheKey(forSpineIndex spineIndex: Int, precomputedContentHash: String) -> RDEPUBChapterCacheKey { - chapterCacheKey( - forSpineIndex: spineIndex, - precomputedContentHash: precomputedContentHash, - renderSignature: currentRenderSignature() - ) - } - - func chapterCacheKey( - forSpineIndex spineIndex: Int, - precomputedContentHash: String, - renderSignature: String - ) -> RDEPUBChapterCacheKey { - RDEPUBChapterCacheKey( - bookID: currentBookIdentifier ?? "", - spineIndex: spineIndex, - renderSignature: renderSignature, - chapterContentHash: precomputedContentHash - ) - } - - func currentRenderSignature() -> String { - dispatchPrecondition(condition: .onQueue(.main)) - let style = currentTextRenderStyle() - let pageSize = currentTextPageSize() - let layoutConfig = currentTextLayoutConfig(pageSize: pageSize) - return renderSignature(style: style, pageSize: pageSize, layoutConfig: layoutConfig) - } - - private func renderSignature( - style: RDEPUBTextRenderStyle, - pageSize: CGSize, - layoutConfig: RDEPUBTextLayoutConfig - ) -> String { - [ - style.font.fontName, - "\(style.font.pointSize)", - "\(configuration.lineHeightMultiple)", - "\(style.lineSpacing)", - layoutConfig.cacheSignature, - "\(RDEPUBChapterSummary.currentSchemaVersion)" - ].joined(separator: "|") - } - - func chapterSummary(forSpineIndex spineIndex: Int) -> RDEPUBChapterSummary? { - runtime?.summaryDiskCache.read(for: chapterCacheKey(forSpineIndex: spineIndex)) - } - - func normalizedSpineIndex(for location: RDEPUBLocation) -> Int? { - guard let publication else { return nil } - let normalizedLocation = publication.resourceResolver.normalizedLocation( - location, - relativeToSpineIndex: nil, - bookIdentifier: currentBookIdentifier - ) ?? location - guard let normalizedHref = publication.resourceResolver.normalizedHref(normalizedLocation.href) else { - return nil - } - return publication.spine.firstIndex { - publication.resourceResolver.normalizedHref($0.href) == normalizedHref - } - } - - func makePlainTextBookBuilder(layoutConfig: RDEPUBTextLayoutConfig) -> RDEpubPlainTextBookBuilder { - services.makePlainTextBookBuilder( - configuration: configuration, - layoutConfig: layoutConfig - ) - } - - func currentVisibleLocation() -> RDEPUBLocation? { - controller?.currentVisibleLocation() - } - - func persistenceLocation() -> RDEPUBLocation? { - guard let currentBookIdentifier else { return nil } - return persistence?.loadLocation(for: currentBookIdentifier) - } - - func persist(location: RDEPUBLocation) { - guard let currentBookIdentifier else { return } - persistence?.saveLocation(location, for: currentBookIdentifier) - } - - func markUserNavigationActivity() { - activityLock.lock() - lastUserNavigationTimestamp = CFAbsoluteTimeGetCurrent() - activityLock.unlock() - } - - func secondsSinceLastUserNavigation() -> CFAbsoluteTime { - activityLock.lock() - let timestamp = lastUserNavigationTimestamp - activityLock.unlock() - guard timestamp > 0 else { return .greatestFiniteMagnitude } - return CFAbsoluteTimeGetCurrent() - timestamp - } - - func textChapterData(forNormalizedHref href: String) -> RDEPUBChapterData? { - guard let textBook else { return nil } - // External text books (e.g. plain .txt) have no publication; their - // chapter hrefs are matched verbatim. - let normalizedHref = publication?.resourceResolver.normalizedHref(href) ?? href - return textBook.chapters.lazy - .first(where: { (publication?.resourceResolver.normalizedHref($0.href) ?? $0.href) == normalizedHref }) - .flatMap { textBook.chapterData(for: $0.href) } - } - - func showLoading() { - controller?.showLoading() - } - - func hideLoading() { - controller?.hideLoading() - } - - func handle(error: Error) { - controller?.handle(error: error) - } - - func updateReaderChrome() { - controller?.updateReaderChrome() - } - - func refreshVisibleContentPreservingLocation() { - controller?.refreshVisibleContentPreservingLocation() - } - - func restoreReadingLocation(_ location: RDEPUBLocation, animated: Bool = false) -> Bool { - controller?.restoreReadingLocation(location, animated: animated) ?? false - } - - func repaginatePreservingCurrentLocation() { - controller?.repaginatePreservingCurrentLocation() - } - - func applyReaderViewConfiguration() { - controller?.applyReaderViewConfiguration() - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderDependencies.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderDependencies.swift deleted file mode 100644 index 17490f0..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderDependencies.swift +++ /dev/null @@ -1,97 +0,0 @@ - -import UIKit - -public protocol RDEPUBReaderDisplayEnvironment: AnyObject { - - var currentBrightness: CGFloat { get set } - - var fallbackViewportSize: CGSize { get } -} - -public final class RDEPUBUIScreenEnvironment: RDEPUBReaderDisplayEnvironment { - public init() {} - - public var currentBrightness: CGFloat { - get { CGFloat(UIScreen.main.brightness) } - set { UIScreen.main.brightness = newValue } - } - - public var fallbackViewportSize: CGSize { - UIScreen.main.bounds.size - } -} - -public struct RDEPUBReaderDependencies { - - public var environment: any RDEPUBReaderDisplayEnvironment - - public var makeParser: () -> RDEPUBParser - - public var makePaginator: () -> RDEPUBPaginator - - public var makeTextBookBuilder: (RDEPUBTextRenderer, RDEPUBTextBookCache?, RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder - - public var makePlainTextBookBuilder: (RDEPUBTextRenderer, RDEPUBTextLayoutConfig) -> RDEpubPlainTextBookBuilder - - public var makeTextRenderer: (RDEPUBTextRenderingEngine) -> RDEPUBTextRenderer - - /// 自定义顶部工具栏工厂;nil 时使用内置 `RDEPUBReaderTopToolView` - public var makeTopToolView: (() -> RDEPUBReaderTopToolViewProviding)? - - /// 自定义底部工具栏工厂;nil 时使用内置 `RDEPUBReaderBottomToolView` - public var makeBottomToolView: (() -> RDEPUBReaderBottomToolViewProviding)? - - public init( - environment: any RDEPUBReaderDisplayEnvironment, - makeParser: @escaping () -> RDEPUBParser, - makePaginator: @escaping () -> RDEPUBPaginator, - makeTextBookBuilder: @escaping (RDEPUBTextRenderer, RDEPUBTextBookCache?, RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder, - makePlainTextBookBuilder: @escaping (RDEPUBTextRenderer, RDEPUBTextLayoutConfig) -> RDEpubPlainTextBookBuilder, - makeTextRenderer: @escaping (RDEPUBTextRenderingEngine) -> RDEPUBTextRenderer, - makeTopToolView: (() -> RDEPUBReaderTopToolViewProviding)? = nil, - makeBottomToolView: (() -> RDEPUBReaderBottomToolViewProviding)? = nil - ) { - self.environment = environment - self.makeParser = makeParser - self.makePaginator = makePaginator - self.makeTextBookBuilder = makeTextBookBuilder - self.makePlainTextBookBuilder = makePlainTextBookBuilder - self.makeTextRenderer = makeTextRenderer - self.makeTopToolView = makeTopToolView - self.makeBottomToolView = makeBottomToolView - } - - /// 构建带加密资源 provider 的默认依赖: - /// 宿主实现 `RDEPUBResourceDataProvider` 后经此注入,即可打开单文件加密的 EPUB。 - /// - Parameter resourceDataProvider: 解密数据提供者;传 nil 等价于 `.live` - public static func live(resourceDataProvider: RDEPUBResourceDataProvider?) -> RDEPUBReaderDependencies { - var dependencies = RDEPUBReaderDependencies.live - guard let resourceDataProvider else { return dependencies } - dependencies.makeParser = { - let parser = RDEPUBParser() - parser.resourceDataProvider = resourceDataProvider - return parser - } - return dependencies - } - - public static var live: RDEPUBReaderDependencies { - RDEPUBReaderDependencies( - environment: RDEPUBUIScreenEnvironment(), - makeParser: { RDEPUBParser() }, - makePaginator: { RDEPUBPaginator() }, - makeTextBookBuilder: { renderer, cache, layoutConfig in - RDEPUBTextBookBuilder(renderer: renderer, cache: cache, layoutConfig: layoutConfig) - }, - makePlainTextBookBuilder: { renderer, layoutConfig in - RDEpubPlainTextBookBuilder(renderer: renderer, layoutConfig: layoutConfig) - }, - makeTextRenderer: { engine in - switch engine { - case .dtCoreText: - return RDEPUBDTCoreTextRenderer() - } - } - ) - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderEnvironment.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderEnvironment.swift deleted file mode 100644 index c15edae..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderEnvironment.swift +++ /dev/null @@ -1,98 +0,0 @@ -import UIKit - -enum RDEPUBTextPageLayoutMetrics { - - static let pageNumberTrailingPadding: CGFloat = 4 - - static let pageNumberFooterPadding: CGFloat = 8 - - static let pageNumberReservedHeight: CGFloat = ceil(UIFont.systemFont(ofSize: 13).lineHeight) + pageNumberFooterPadding - - static func contentInsets( - configuration: RDEPUBReaderConfiguration, - safeAreaInsets: UIEdgeInsets - ) -> UIEdgeInsets { - let configInsets = configuration.reflowableContentInsets - return UIEdgeInsets( - top: max(configInsets.top, safeAreaInsets.top), - left: max(configInsets.left, safeAreaInsets.left), - bottom: max(configInsets.bottom, safeAreaInsets.bottom) + pageNumberReservedHeight, - right: max(configInsets.right, safeAreaInsets.right) - ) - } -} - -final class RDEPUBReaderEnvironment { - - weak var controller: RDEPUBReaderController? - - weak var readerView: RDEpubReaderView? - - var displayEnvironment: any RDEPUBReaderDisplayEnvironment - - init( - controller: RDEPUBReaderController, - readerView: RDEpubReaderView, - displayEnvironment: any RDEPUBReaderDisplayEnvironment - ) { - self.controller = controller - self.readerView = readerView - self.displayEnvironment = displayEnvironment - } - - func currentLayoutContext(configuration: RDEPUBReaderConfiguration) -> RDEPUBNavigatorLayoutContext { - let containerSize = readerView?.bounds.size ?? .zero - let viewSize = controller?.view.bounds.size ?? containerSize - let resolvedSize = containerSize == .zero ? viewSize : containerSize - return RDEPUBNavigatorLayoutContext( - containerSize: resolvedSize, - pagesPerScreen: readerView?.pagesPerScreen ?? 1, - safeAreaInsets: RDEPUBSafeArea.resolve(controller?.view.safeAreaInsets), - userInterfaceIdiom: controller?.traitCollection.userInterfaceIdiom ?? .phone, - reflowableContentInsets: configuration.reflowableContentInsets - ) - } - - func currentTextRenderStyle(configuration: RDEPUBReaderConfiguration) -> RDEPUBTextRenderStyle { - let font = configuration.fontChoice.font(ofSize: configuration.fontSize) - let lineSpacing = max(font.lineHeight * (configuration.lineHeightMultiple - 1), 4) - return RDEPUBTextRenderStyle( - font: font, - lineSpacing: lineSpacing, - textColor: configuration.theme.contentTextColor, - backgroundColor: configuration.theme.contentBackgroundColor - ) - } - - func currentTextLayoutConfig( - configuration: RDEPUBReaderConfiguration, - pageSize: CGSize - ) -> RDEPUBTextLayoutConfig { - let safeAreaInsets = RDEPUBSafeArea.resolve(controller?.view.safeAreaInsets) - return RDEPUBTextLayoutConfig( - frameWidth: max(pageSize.width, 1), - frameHeight: max(pageSize.height, 1), - edgeInsets: RDEPUBTextPageLayoutMetrics.contentInsets( - configuration: configuration, - safeAreaInsets: safeAreaInsets - ), - numberOfColumns: configuration.numberOfColumns, - columnGap: configuration.columnGap, - avoidOrphans: false, - avoidWidows: false, - avoidPageBreakInsideEnabled: true, - hyphenation: true, - imageMaxHeightRatio: 0.85, - fallbackViewportSize: displayEnvironment.fallbackViewportSize - ) - } - - var currentBrightness: CGFloat { - get { displayEnvironment.currentBrightness } - set { displayEnvironment.currentBrightness = newValue } - } - - var fallbackViewportSize: CGSize { - displayEnvironment.fallbackViewportSize - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderLoadCoordinator.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderLoadCoordinator.swift deleted file mode 100644 index f98286f..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderLoadCoordinator.swift +++ /dev/null @@ -1,86 +0,0 @@ -import Foundation - -final class RDEPUBReaderLoadCoordinator { - private weak var context: RDEPUBReaderContext? - - init(context: RDEPUBReaderContext) { - self.context = context - } - - func startInitialLoadIfNeeded() { - guard let context, let controller = context.controller, - let readerView = context.readerView, - !controller.didStartInitialLoad, - readerView.bounds.width > 0, - readerView.bounds.height > 0 else { - return - } - controller.didStartInitialLoad = true - loadPublication() - } - - func loadPublication() { - guard let context, let controller = context.controller else { return } - context.showLoading() - let loadToken = UUID() - context.paginationToken = loadToken - - DispatchQueue.global(qos: .userInitiated).async { [weak self] in - guard let self, let context = self.context, let controller = context.controller else { return } - let parser = context.makeParser() - - do { - try parser.parse(epubURL: controller.epubURL) - let publication = parser.makePublication() - let bookIdentifier = parser.metadata.identifier ?? controller.epubURL.lastPathComponent - let restoreLocation = controller.persistence?.loadLocation(for: bookIdentifier) - let bookmarks = controller.persistence?.loadBookmarks(for: bookIdentifier) ?? [] - let highlights = controller.persistence?.loadHighlights(for: bookIdentifier) ?? [] - - DispatchQueue.main.async { [weak self] in - guard let self, let context = self.context else { return } - guard context.paginationToken == loadToken else { return } - context.runtime?.applyParsedPublication( - parser: parser, - publication: publication, - bookIdentifier: bookIdentifier, - restoreLocation: restoreLocation, - bookmarks: bookmarks, - highlights: highlights - ) - } - } catch { - DispatchQueue.main.async { [weak self] in - guard let self, let context = self.context else { return } - guard context.paginationToken == loadToken else { return } - context.handle(error: error) - } - } - } - } - - func applyParsedPublication( - parser: RDEPUBParser, - publication: RDEPUBPublication, - bookIdentifier: String, - restoreLocation: RDEPUBLocation?, - bookmarks: [RDEPUBBookmark], - highlights: [RDEPUBHighlight] - ) { - guard let context, let controller = context.controller else { return } - context.parser = parser - context.publication = publication - context.currentBookIdentifier = bookIdentifier - context.activeBookmarks = bookmarks - context.activeHighlights = highlights - context.readingSession = RDEPUBReadingSession(publication: publication) - controller.title = parser.metadata.title.isEmpty - ? controller.epubURL.deletingPathExtension().lastPathComponent - : parser.metadata.title - controller.applyReaderViewConfiguration() - context.updateReaderChrome() - controller.delegate?.epubReader(controller, didOpen: publication) - controller.applyOrientationLockIfNeeded() - context.runtime?.paginatePublication(restoreLocation: restoreLocation) - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderLocationCoordinator.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderLocationCoordinator.swift deleted file mode 100644 index 0c34441..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderLocationCoordinator.swift +++ /dev/null @@ -1,125 +0,0 @@ -import Foundation - -final class RDEPUBReaderLocationCoordinator { - private unowned let context: RDEPUBReaderContext - - private var lastPageChangeSpineIndex: Int? - - init(context: RDEPUBReaderContext) { - self.context = context - } - - @discardableResult - func restoreReadingLocation( - _ location: RDEPUBLocation, - animated: Bool = false, - targetHighlightRangeInfo: String? = nil - ) -> Bool { - guard let controller = context.controller, - let readerView = context.readerView else { return false } - if context.bookPageMap != nil { - _ = context.runtime?.ensureOnDemandNavigationTargetAvailable(for: location) - } - guard let targetPageNumber = controller.pageNumber(for: location, rangeInfo: targetHighlightRangeInfo) else { - readerView.transitionToPage(pageNum: 0) - return false - } - - if context.bookPageMap != nil { - guard context.runtime?.prepareOnDemandChapter(forAbsolutePageNumber: targetPageNumber) == true else { - return false - } - _ = context.readingSession?.queueNavigation( - to: location, - relativeToSpineIndex: nil, - bookIdentifier: context.currentBookIdentifier, - targetHighlightRangeInfo: targetHighlightRangeInfo - ) - } else if context.textBook == nil { - _ = context.readingSession?.queueNavigation( - to: location, - relativeToSpineIndex: nil, - bookIdentifier: context.currentBookIdentifier, - targetHighlightRangeInfo: targetHighlightRangeInfo - ) - } else { - } - readerView.transitionToPage(pageNum: max(targetPageNumber - 1, 0), animated: animated) - - recordPageChangeIfNeeded() - - return true - } - - func currentVisibleLocation() -> RDEPUBLocation? { - guard let controller = context.controller, - let readerView = context.readerView else { - return nil - } - let pageNumber = readerView.currentPage + 1 - if (context.textBook != nil || context.bookPageMap != nil), readerView.currentPage >= 0 { - if let location = controller.resolvedTextLocation(forPageNumber: pageNumber) { - return location - } - - if let readingSession = context.readingSession, - readingSession.activePages.indices.contains(readerView.currentPage) { - return readingSession.fallbackLocation( - for: readingSession.activePages[readerView.currentPage], - bookIdentifier: context.currentBookIdentifier - ) - } - } - return context.readingSession?.currentReadingLocation(bookIdentifier: context.currentBookIdentifier) - } - - func persistenceLocation() -> RDEPUBLocation? { - guard let controller = context.controller, - let currentBookIdentifier = context.currentBookIdentifier else { - return nil - } - return controller.persistence?.loadLocation(for: currentBookIdentifier) - } - - func persist(location: RDEPUBLocation) { - guard let controller = context.controller, - let currentBookIdentifier = context.currentBookIdentifier else { return } - controller.persistence?.saveLocation(location, for: currentBookIdentifier) - controller.delegate?.epubReader(controller, didUpdateLocation: location) - controller.delegate?.epubReader(controller, didUpdateCurrentTableOfContentsItem: controller.currentTableOfContentsItem) - controller.updateReaderChrome() - } - - func recordPageChangeIfNeeded() { - guard let runtime = context.runtime, - let bookPageMap = context.bookPageMap, - let readerView = context.readerView else { return } - - let currentPageNumber = readerView.currentPage + 1 - guard let currentSpineIndex = bookPageMap.spineIndex(forAbsolutePage: currentPageNumber - 1) else { - return - } - - if let lastSpineIndex = lastPageChangeSpineIndex, - lastSpineIndex != currentSpineIndex { - runtime.jumpSessionManager.recordPageChange( - fromSpineIndex: lastSpineIndex, - toSpineIndex: currentSpineIndex - ) - } - - lastPageChangeSpineIndex = currentSpineIndex - - let isIdle = context.secondsSinceLastUserNavigation() > 2.0 - if let endReason = runtime.jumpSessionManager.checkSessionEnd( - currentSpineIndex: currentSpineIndex, - isIdle: isIdle - ) { - runtime.jumpSessionManager.endSession(endReason) - } - } - - func resetPageChangeState() { - lastPageChangeSpineIndex = nil - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift deleted file mode 100644 index 6134c66..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift +++ /dev/null @@ -1,387 +0,0 @@ -import Foundation - -final class RDEPUBReaderPaginationCoordinator { - - static var pageMapRefreshInterval: Int = 32 - - private unowned let context: RDEPUBReaderContext - - private let metadataParseControlLock = NSLock() - - private var activeMetadataParseCancellationController: RDEPUBMetadataParseCancellationController? - - init(context: RDEPUBReaderContext) { - self.context = context - } - - func paginatePublication(restoreLocation: RDEPUBLocation?) { - guard let controller = context.controller, - let parser = context.parser, - let publication = context.publication, - let readingSession = context.readingSession else { - return - } - - controller.isRepaginating = true - controller.errorLabel.isHidden = true - controller.showLoading() - let token = UUID() - context.paginationToken = token - - if publication.readingProfile == .textReflowable { - paginateTextPublication( - parser: parser, - publication: publication, - readingSession: readingSession, - restoreLocation: restoreLocation, - token: token - ) - return - } - - if publication.layout == .fixed { - let snapshot = readingSession.makePaginationSnapshot( - pageCounts: Array(repeating: 1, count: publication.spine.count), - preferences: controller.currentPreferences(), - layoutContext: controller.currentLayoutContext() - ) - context.runtime?.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation) - return - } - - let paginator = context.makePaginator() - paginator.calculate( - parser: parser, - hostingView: controller.ensurePaginationHostView(), - presentation: controller.currentPreferences().presentationStyle(viewportSize: controller.currentLayoutContext().viewportSize) - ) { [weak controller] pageCounts in - guard let controller, self.context.paginationToken == token else { return } - let snapshot = readingSession.makePaginationSnapshot( - pageCounts: pageCounts, - preferences: controller.currentPreferences(), - layoutContext: controller.currentLayoutContext() - ) - self.context.runtime?.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation) - } - } - - func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) { - guard let controller = context.controller else { return } - context.textBook = textBook - context.bookPageMap = nil - context.pendingPageMapUpdates.removeAll() - let snapshot = controller.nativeTextSnapshot(from: textBook) - context.replaceActiveSnapshot(snapshot) - - guard !textBook.pages.isEmpty else { - context.handle(error: RDEPUBParserError.emptySpine) - return - } - - finishPagination(restoreLocation: restoreLocation) - } - - func applyPaginationSnapshot( - _ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]), - restoreLocation: RDEPUBLocation? - ) { - guard context.controller != nil else { return } - context.textBook = nil - context.bookPageMap = nil - context.pendingPageMapUpdates.removeAll() - context.replaceActiveSnapshot(snapshot) - - guard !snapshot.pages.isEmpty else { - context.handle(error: RDEPUBParserError.emptySpine) - return - } - - finishPagination(restoreLocation: restoreLocation) - } - - func finishPagination(restoreLocation: RDEPUBLocation?) { - guard let controller = context.controller, - let readerView = context.readerView else { return } - controller.isRepaginating = false - controller.hideLoading() - readerView.reloadData() - if let targetLocation = restoreLocation { - controller.restoreReadingLocation(targetLocation) - } else { - readerView.transitionToPage(pageNum: 0) - } - - context.runtime?.viewportMonitor.processPendingChangeAfterPagination() - } - - func repaginatePreservingCurrentLocation( - preferredRestoreLocation: RDEPUBLocation? = nil - ) { - guard context.publication != nil else { return } - let restoreLocation = preferredRestoreLocation - ?? context.runtime?.viewportMonitor.consumePendingPresentationRestoreLocation() - ?? context.currentVisibleLocation() - ?? context.persistenceLocation() - paginatePublication(restoreLocation: restoreLocation) - } - - func refreshVisibleContentPreservingLocation() { - guard let readerView = context.readerView else { return } - if readerView.isPageCurlTransitioning { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in - self?.refreshVisibleContentPreservingLocation() - } - return - } - let restoreLocation = context.currentVisibleLocation() ?? context.persistenceLocation() - if readerView.currentDisplayType == .pageCurl, readerView.currentPage >= 0 { - readerView.transitionToPage(pageNum: readerView.currentPage, animated: false) - } else { - readerView.reloadData() - } - if let restoreLocation { - _ = context.restoreReadingLocation(restoreLocation) - } - } - - func rebuildExternalTextBook() { - guard let controller = context.controller, - let textFileURL = controller.textFileURL else { return } - let restoreLocation = context.currentVisibleLocation() ?? context.persistenceLocation() - let pageSize = controller.currentTextPageSize() - let style = controller.currentTextRenderStyle() - let builder = context.makePlainTextBookBuilder(layoutConfig: controller.currentTextLayoutConfig(pageSize: pageSize)) - if let newBook = try? builder.build(textFileURL: textFileURL, pageSize: pageSize, style: style) { - context.runtime?.applyTextBook(newBook, restoreLocation: restoreLocation) - } - } - - private func paginateTextPublication( - parser: RDEPUBParser, - publication: RDEPUBPublication, - readingSession: RDEPUBReadingSession, - restoreLocation: RDEPUBLocation?, - token: UUID - ) { - guard let controller = context.controller else { return } - let context = self.context - - let pageSize = controller.currentTextPageSize() - context.lastTextPaginationPageSize = pageSize - let layoutSnapshot = context.makeLayoutSnapshot() - let runtime = context.runtime - - DispatchQueue.global(qos: .utility).async { [weak controller] in - guard controller != nil else { return } - guard let runtime else { return } - let prioritizedCandidates = self.prioritizedBuildableSpineIndices( - publication: publication, - readingSession: readingSession, - restoreLocation: restoreLocation - ) - guard prioritizedCandidates.first != nil else { - DispatchQueue.main.async { - guard context.paginationToken == token, - context.controller != nil else { return } - context.handle(error: RDEPUBParserError.emptySpine) - } - return - } - - do { - let runtimeChapter = try self.loadFirstRenderableRuntimeChapter( - prioritizedSpineIndices: prioritizedCandidates, - runtime: runtime, - layoutSnapshot: layoutSnapshot - ) - RDEPUBBackgroundTrace.log( - "Pagination", - "anchorChapterReady spine=\(runtimeChapter.spineIndex) pages=\(runtimeChapter.pages.count)" - ) - let initialChapters = self.loadInitialInteractiveRuntimeChapters( - anchorChapter: runtimeChapter, - publication: publication, - runtime: runtime, - layoutSnapshot: layoutSnapshot - ) - let initialPageCount = initialChapters.reduce(0) { $0 + $1.pages.count } - RDEPUBBackgroundTrace.log( - "Pagination", - "initialInteractiveChapters ready count=\(initialChapters.count) pages=\(initialPageCount) spines=\(initialChapters.map(\.spineIndex))" - ) - - DispatchQueue.main.async { - guard context.paginationToken == token, - context.controller != nil else { return } - runtime.chapterRuntimeStore.setCurrentChapter( - spineIndex: runtimeChapter.spineIndex, - totalSpineCount: publication.spine.count, - windowRadius: context.configuration.chapterWindowRadius - ) - let partialMap = self.makePartialPageMap(from: initialChapters) - runtime.applyBookPageMap(partialMap, restoreLocation: restoreLocation) - runtime.prefetchForwardChaptersAfterInitialOpen( - anchorSpineIndex: runtimeChapter.spineIndex, - totalSpineCount: publication.spine.count - ) - let cancellationController = self.beginMetadataParseCancellationController(for: token) - let worker = RDEPUBMetadataParseWorker( - context: context, - cancellationController: cancellationController, - token: token, - parser: parser, - publication: publication - ) - RDEPUBBackgroundTrace.log( - "Pagination", - "startingMetadataWorker token=\(token.uuidString) anchorSpine=\(runtimeChapter.spineIndex) partialPages=\(partialMap.totalPages) partialChapters=\(partialMap.totalChapters)" - ) - worker.start(token: token, restoreLocation: restoreLocation) - } - } catch { - RDEPUBBackgroundTrace.log( - "Pagination", - "initialPaginationFailed error=\(String(describing: error)) prioritizedCandidates=\(prioritizedCandidates)" - ) - DispatchQueue.main.async { - guard context.paginationToken == token, - context.controller != nil else { return } - context.handle(error: error) - } - } - } - } - - private func loadFirstRenderableRuntimeChapter( - prioritizedSpineIndices: [Int], - runtime: RDEPUBReaderRuntime, - layoutSnapshot: RDEPUBLayoutSnapshot - ) throws -> RDEPUBRuntimeChapter { - var lastError: Error? - for spineIndex in prioritizedSpineIndices { - do { - return try runtime.chapterLoader.loadChapterSynchronouslyForMigration( - spineIndex: spineIndex, - store: runtime.chapterRuntimeStore, - layoutSnapshot: layoutSnapshot - ) - } catch { - lastError = error - } - } - throw lastError ?? RDEPUBParserError.emptySpine - } - - private func loadInitialInteractiveRuntimeChapters( - anchorChapter: RDEPUBRuntimeChapter, - publication: RDEPUBPublication, - runtime: RDEPUBReaderRuntime, - layoutSnapshot: RDEPUBLayoutSnapshot - ) -> [RDEPUBRuntimeChapter] { - let minimumInteractivePageCount = 2 - let maximumAdditionalChapters = 1 - - guard anchorChapter.pages.count < minimumInteractivePageCount else { - return [anchorChapter] - } - - let buildableSpineIndices = publication.spine.indices.filter { isBuildableTextSpine(at: $0, in: publication) } - guard let anchorPosition = buildableSpineIndices.firstIndex(of: anchorChapter.spineIndex) else { - return [anchorChapter] - } - - var selectedChapters: [RDEPUBRuntimeChapter] = [anchorChapter] - - for offset in 1...maximumAdditionalChapters { - let candidatePositions = [anchorPosition + offset, anchorPosition - offset] - for candidatePosition in candidatePositions { - guard buildableSpineIndices.indices.contains(candidatePosition) else { continue } - let spineIndex = buildableSpineIndices[candidatePosition] - guard selectedChapters.contains(where: { $0.spineIndex == spineIndex }) == false else { continue } - - do { - let chapter = try runtime.chapterLoader.loadChapterSynchronouslyForMigration( - spineIndex: spineIndex, - store: runtime.chapterRuntimeStore, - layoutSnapshot: layoutSnapshot - ) - selectedChapters.append(chapter) - } catch { - #if DEBUG - print("[RDEPUBReaderPaginationCoordinator] ⚠️ Failed to load chapter at spineIndex \(spineIndex): \(error)") - #endif - } - } - - let loadedPageCount = selectedChapters.reduce(0) { $0 + $1.pages.count } - if loadedPageCount >= minimumInteractivePageCount { - break - } - } - - return selectedChapters - } - - private func makePartialPageMap(from chapters: [RDEPUBRuntimeChapter]) -> RDEPUBBookPageMap { - var builder = RDEPUBBookPageMap.Builder() - for chapter in chapters { - builder.add( - spineIndex: chapter.spineIndex, - href: chapter.href, - title: chapter.title, - pageCount: chapter.pages.count, - fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets - ) - } - return builder.build() - } - - private func prioritizedBuildableSpineIndices( - publication: RDEPUBPublication, - readingSession: RDEPUBReadingSession, - restoreLocation: RDEPUBLocation? - ) -> [Int] { - let preferred = readingSession.initialSpineIndex(for: restoreLocation) - return publication.spine.indices - .filter { isBuildableTextSpine(at: $0, in: publication) } - .sorted { lhs, rhs in - abs(lhs - preferred) < abs(rhs - preferred) - } - } - - private func allBuildableSpineIndices(in publication: RDEPUBPublication) -> [Int] { - publication.spine.indices.filter { isBuildableTextSpine(at: $0, in: publication) } - } - - private func isBuildableTextSpine(at index: Int, in publication: RDEPUBPublication) -> Bool { - guard publication.spine.indices.contains(index) else { return false } - let item = publication.spine[index] - return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml")) - } - - func cancelActiveMetadataParseWork() { - metadataParseControlLock.lock() - let controller = activeMetadataParseCancellationController - activeMetadataParseCancellationController = nil - metadataParseControlLock.unlock() - controller?.cancel() - } - - func finishMetadataParseCancellationController(_ controller: RDEPUBMetadataParseCancellationController) { - metadataParseControlLock.lock() - if activeMetadataParseCancellationController === controller { - activeMetadataParseCancellationController = nil - } - metadataParseControlLock.unlock() - } - - private func beginMetadataParseCancellationController(for token: UUID) -> RDEPUBMetadataParseCancellationController { - let controller = RDEPUBMetadataParseCancellationController(token: token) - metadataParseControlLock.lock() - let previous = activeMetadataParseCancellationController - activeMetadataParseCancellationController = controller - metadataParseControlLock.unlock() - previous?.cancel() - return controller - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderRuntime.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderRuntime.swift deleted file mode 100644 index 2b36c62..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderRuntime.swift +++ /dev/null @@ -1,689 +0,0 @@ -import UIKit - -final class RDEPUBReaderRuntime { - - private unowned let context: RDEPUBReaderContext - - lazy var chapterRuntimeStore = RDEPUBChapterRuntimeStore() - - lazy var summaryDiskCache = context.makeChapterSummaryDiskCache() - - lazy var chapterLoader: RDEPUBChapterLoader = { - let loader = RDEPUBChapterLoader(context: context) - loader.setSummaryDiskCache(summaryDiskCache) - loader.onDeferredCFIMapReady = { [weak self] spineIndex in - self?.handleDeferredCFIMapReady(for: spineIndex) - } - return loader - }() - - lazy var pageResolver = RDEPUBPageResolver(context: context, store: chapterRuntimeStore) - - lazy var loadCoordinator = RDEPUBReaderLoadCoordinator(context: context) - - lazy var paginationCoordinator = RDEPUBReaderPaginationCoordinator(context: context) - - lazy var locationCoordinator = RDEPUBReaderLocationCoordinator(context: context) - - lazy var searchCoordinator = RDEPUBReaderSearchCoordinator(context: context) - - lazy var chromeCoordinator = RDEPUBReaderChromeCoordinator(context: context) - - lazy var annotationCoordinator = RDEPUBReaderAnnotationCoordinator(context: context) - - lazy var viewportMonitor = RDEPUBReaderViewportMonitor(context: context) - - lazy var jumpSessionManager = RDEPUBJumpSessionManager(context: context) - - lazy var backgroundPriorityManager = RDEPUBBackgroundPriorityManager(context: context) - - lazy var backgroundCoverageStore = RDEPUBBackgroundCoverageStore(context: context) - - lazy var reconciliationCoordinator = RDEPUBPageMapReconciliationCoordinator(context: context) - - lazy var presentationRuntime = RDEPUBPresentationRuntime( - context: context, - locationCoordinator: locationCoordinator, - jumpSessionManager: jumpSessionManager, - reconciliationCoordinator: reconciliationCoordinator - ) - - lazy var chapterWarmupOrchestrator = RDEPUBChapterWarmupOrchestrator( - context: context, - store: chapterRuntimeStore, - loader: chapterLoader, - presentationRuntime: presentationRuntime, - locationCoordinator: locationCoordinator, - backgroundPriorityManager: backgroundPriorityManager, - jumpSessionManager: jumpSessionManager, - refreshVisibleContentPreservingLocation: { [weak self] in - self?.refreshVisibleContentPreservingLocation() - } - ) - - var isSettingsPanelOpen: Bool = false - - var needsFullRepaginationAfterSettingsClose: Bool = false - - private var settingsPreviewGeneration: Int = 0 - - private var pendingSettingsPreviewWorkItem: DispatchWorkItem? - - /// The location before the settings preview replaces the full book page map - /// with the current chapter's temporary map. The temporary map rebases that - /// chapter at page zero, so it must never be used as the final restore source. - private var settingsRestoreLocation: RDEPUBLocation? - - /// A settings session must keep one immutable text offset. Re-capturing the - /// start of each preview page makes repeated font-size changes drift backward. - private var settingsPreviewAnchor: SettingsPreviewAnchor? - - private let settingsPreviewDebounceDelay: TimeInterval = 0.2 - - private struct SettingsPreviewAnchor { - - let spineIndex: Int - - let href: String - - let offset: Int - } - - init(context: RDEPUBReaderContext) { - self.context = context - } - - func makeTopToolView() -> RDEPUBReaderTopToolViewProviding { - chromeCoordinator.makeTopToolView() - } - - func makeBottomToolView() -> RDEPUBReaderBottomToolViewProviding { - chromeCoordinator.makeBottomToolView() - } - - func startInitialLoadIfNeeded() { - loadCoordinator.startInitialLoadIfNeeded() - } - - func reloadBook() { - guard let readerView = context.readerView else { return } - context.didStartInitialLoad = false - context.parser = nil - context.publication = nil - context.clearActiveSnapshot() - context.readingSession = nil - context.textBook = nil - context.bookPageMap = nil - context.pendingPageMapUpdates.removeAll() - context.activeBookmarks = [] - context.activeHighlights = [] - context.searchState = nil - clearOnDemandPageModeState() - viewportMonitor.resetForReload() - annotationCoordinator.updateCurrentSelection(nil) - readerView.reloadData() - startInitialLoadIfNeeded() - } - - func go(to location: RDEPUBLocation, animated: Bool = false) -> Bool { - locationCoordinator.restoreReadingLocation(location, animated: animated) - } - - @discardableResult - func go(toPageNumber pageNumber: Int, animated: Bool = false) -> Bool { - guard context.controller != nil, - let readerView = context.readerView, - pageNumber > 0 else { - return false - } - - if let textBook = context.textBook { - guard textBook.page(at: pageNumber) != nil else { - return false - } - readerView.transitionToPage(pageNum: pageNumber - 1, animated: animated) - if let location = locationCoordinator.currentVisibleLocation() { - context.persist(location: location) - } - return true - } - - if context.bookPageMap != nil { - guard prepareOnDemandChapter(forAbsolutePageNumber: pageNumber) else { - return false - } - readerView.transitionToPage(pageNum: pageNumber - 1, animated: animated) - if let location = locationCoordinator.currentVisibleLocation() { - context.persist(location: location) - } - return true - } - - guard context.activePages.indices.contains(pageNumber - 1) else { - return false - } - readerView.transitionToPage(pageNum: pageNumber - 1, animated: animated) - if let location = locationCoordinator.currentVisibleLocation() { - context.persist(location: location) - } - return true - } - - func clearSelection() { - annotationCoordinator.updateCurrentSelection(nil) - } - - func bookmark(withID id: String) -> RDEPUBBookmark? { - annotationCoordinator.bookmark(withID: id) - } - - func highlight(withID id: String) -> RDEPUBHighlight? { - annotationCoordinator.highlight(withID: id) - } - - @discardableResult - func addHighlight( - from selection: RDEPUBSelection? = nil, - color: String = "#F8E16C", - note: String? = nil - ) -> RDEPUBHighlight? { - annotationCoordinator.addHighlight(from: selection, color: color, note: note) - } - - @discardableResult - func addAnnotation( - from selection: RDEPUBSelection? = nil, - style: RDEPUBHighlightStyle, - color: String = "#F8E16C", - note: String? = nil - ) -> RDEPUBHighlight? { - annotationCoordinator.addAnnotation(from: selection, style: style, color: color, note: note) - } - - @discardableResult - func upsertHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight? { - annotationCoordinator.upsertHighlight(highlight) - } - - @discardableResult - func removeHighlight(id: String) -> RDEPUBHighlight? { - annotationCoordinator.removeHighlight(id: id) - } - - @discardableResult - func updateHighlightNote(id: String, note: String?) -> RDEPUBHighlight? { - annotationCoordinator.updateHighlightNote(id: id, note: note) - } - - @discardableResult - func go(toHighlightID id: String, animated: Bool = true) -> Bool { - annotationCoordinator.go(toHighlightID: id, animated: animated) - } - - func removeAllHighlights() { - annotationCoordinator.removeAllHighlights() - } - - @discardableResult - func addBookmark(note: String? = nil) -> RDEPUBBookmark? { - annotationCoordinator.addBookmark(note: note) - } - - @discardableResult - func toggleBookmark(note: String? = nil) -> RDEPUBBookmark? { - annotationCoordinator.toggleBookmark(note: note) - } - - @discardableResult - func removeBookmark(id: String) -> RDEPUBBookmark? { - annotationCoordinator.removeBookmark(id: id) - } - - @discardableResult - func go(toBookmarkID id: String, animated: Bool = true) -> Bool { - annotationCoordinator.go(toBookmarkID: id, animated: animated) - } - - func presentBookmarksManager() { - annotationCoordinator.presentBookmarksManager() - } - - func presentHighlightsManager() { - annotationCoordinator.presentHighlightsManager() - } - - func presentAnnotationCreation() { - annotationCoordinator.presentAnnotationCreation() - } - - func presentHighlightActions(for highlight: RDEPUBHighlight, sourceView: UIView, sourceRect: CGRect) { - annotationCoordinator.presentHighlightActions(for: highlight, sourceView: sourceView, sourceRect: sourceRect) - } - - func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) { - annotationCoordinator.handleSelectionMenuAction(action, selection: selection) - } - - func search(keyword: String) { - searchCoordinator.search(keyword: keyword) - } - - @discardableResult - func searchNext() -> Bool { - searchCoordinator.searchNext() - } - - @discardableResult - func searchPrevious() -> Bool { - searchCoordinator.searchPrevious() - } - - @discardableResult - func selectSearchMatch(at index: Int) -> Bool { - searchCoordinator.selectSearchMatch(at: index) - } - - func clearSearch() { - searchCoordinator.clearSearch() - } - - func searchPresentation(for page: EPUBPage) -> RDEPUBSearchPresentation? { - searchCoordinator.searchPresentation(for: page) - } - - func updateReaderChrome() { - chromeCoordinator.updateReaderChrome() - } - - func presentSettings() { - chromeCoordinator.presentSettings() - } - - func presentTableOfContents() { - chromeCoordinator.presentTableOfContents() - } - - func handleBackAction() { - chromeCoordinator.handleBackAction() - } - - func loadPublication() { - loadCoordinator.loadPublication() - } - - func applyParsedPublication( - parser: RDEPUBParser, - publication: RDEPUBPublication, - bookIdentifier: String, - restoreLocation: RDEPUBLocation?, - bookmarks: [RDEPUBBookmark], - highlights: [RDEPUBHighlight] - ) { - loadCoordinator.applyParsedPublication( - parser: parser, - publication: publication, - bookIdentifier: bookIdentifier, - restoreLocation: restoreLocation, - bookmarks: bookmarks, - highlights: highlights - ) - } - - func paginatePublication(restoreLocation: RDEPUBLocation?) { - paginationCoordinator.paginatePublication(restoreLocation: restoreLocation) - } - - func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) { - paginationCoordinator.applyTextBook(textBook, restoreLocation: restoreLocation) - } - - func applyPaginationSnapshot( - _ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]), - restoreLocation: RDEPUBLocation? - ) { - paginationCoordinator.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation) - } - - func applyBookPageMap(_ bookPageMap: RDEPUBBookPageMap, restoreLocation: RDEPUBLocation?) { - presentationRuntime.applyBookPageMap( - bookPageMap, - restoreLocation: restoreLocation - ) { [weak self] restoreLocation in - self?.paginationCoordinator.finishPagination(restoreLocation: restoreLocation) - } - } - - func refreshBookPageMapInPlace(_ bookPageMap: RDEPUBBookPageMap) { - presentationRuntime.refreshBookPageMapInPlace(bookPageMap) - } - - func applyPendingFullPageMapIfNeeded() { - presentationRuntime.commitPendingPageMapUpdateIfNeeded() - } - - func finishPagination(restoreLocation: RDEPUBLocation?) { - paginationCoordinator.finishPagination(restoreLocation: restoreLocation) - } - - func repaginatePreservingCurrentLocation() { - - if isSettingsPanelOpen { - needsFullRepaginationAfterSettingsClose = true - paginationCoordinator.cancelActiveMetadataParseWork() - scheduleSettingsPreviewRepagination() - } else { - paginationCoordinator.repaginatePreservingCurrentLocation() - } - } - - private func scheduleSettingsPreviewRepagination() { - pendingSettingsPreviewWorkItem?.cancel() - settingsPreviewGeneration += 1 - let previewGeneration = settingsPreviewGeneration - - let workItem = DispatchWorkItem { [weak self] in - guard let self, - self.isSettingsPanelOpen, - previewGeneration == self.settingsPreviewGeneration else { - return - } - self.pendingSettingsPreviewWorkItem = nil - let previewAnchor = self.settingsPreviewAnchor - ?? self.captureSettingsPreviewAnchor() - self.chapterRuntimeStore.invalidateAllForSettingsChange() - self.repaginateCurrentChapterOnly( - previewGeneration: previewGeneration, - previewAnchor: previewAnchor - ) - } - pendingSettingsPreviewWorkItem = workItem - DispatchQueue.main.asyncAfter( - deadline: .now() + settingsPreviewDebounceDelay, - execute: workItem - ) - } - - private func captureSettingsPreviewAnchor() -> SettingsPreviewAnchor? { - guard let bookPageMap = context.bookPageMap, - let readerView = context.readerView else { return nil } - - let absolutePageIndex = readerView.currentPage - guard absolutePageIndex >= 0, - let spineIndex = bookPageMap.spineIndex(forAbsolutePage: absolutePageIndex), - let localPageIndex = bookPageMap.localPageIndex(forAbsolutePage: absolutePageIndex), - let chapter = chapterRuntimeStore.chapterData(for: spineIndex), - chapter.pages.indices.contains(localPageIndex) else { - return nil - } - - let page = chapter.pages[localPageIndex] - let offset = page.contentRange.length > 0 - ? page.contentRange.location - : page.pageStartOffset - return SettingsPreviewAnchor( - spineIndex: spineIndex, - href: chapter.href, - offset: offset - ) - } - - private func repaginateCurrentChapterOnly( - previewGeneration: Int, - previewAnchor: SettingsPreviewAnchor? - ) { - guard let bookPageMap = context.bookPageMap, - let readerView = context.readerView else { return } - - let currentPageNumber = readerView.currentPage + 1 - guard let currentSpineIndex = bookPageMap.spineIndex(forAbsolutePage: currentPageNumber - 1) else { - return - } - let previewLocation = locationCoordinator.currentVisibleLocation() ?? context.persistenceLocation() - - chapterLoader.loadChapter( - spineIndex: currentSpineIndex, - store: chapterRuntimeStore, - priority: .preview - ) { [weak self] result in - guard let self, - self.isSettingsPanelOpen, - previewGeneration == self.settingsPreviewGeneration, - let readerView = self.context.readerView else { - return - } - - switch result { - case .success(let chapter): - let partialMap = self.makePartialPageMap(from: [chapter]) - self.presentationRuntime.applySettingsPreviewPageMap(partialMap) - readerView.reloadData() - - if let targetPage = self.settingsPreviewTargetPage( - in: chapter, - for: previewAnchor - ) { - readerView.transitionToPage(pageNum: targetPage, animated: false) - return - } - - if let previewLocation, - self.locationCoordinator.restoreReadingLocation(previewLocation, animated: false) { - return - } - readerView.transitionToPage(pageNum: 0, animated: false) - - case .failure(let error): - break - } - } - } - - private func settingsPreviewTargetPage( - in chapter: RDEPUBRuntimeChapter, - for anchor: SettingsPreviewAnchor? - ) -> Int? { - guard let anchor, - anchor.spineIndex == chapter.spineIndex, - anchor.href == chapter.href, - !chapter.pages.isEmpty else { - return nil - } - - if let exactPage = chapter.pages.first(where: { page in - let lowerBound = page.contentRange.location - let upperBound = page.contentRange.location + page.contentRange.length - if page.contentRange.length == 0 { - return anchor.offset == lowerBound - } - return anchor.offset >= lowerBound && anchor.offset < upperBound - }) { - return exactPage.pageIndexInChapter - } - - if let nextPage = chapter.pages.first(where: { page in - page.contentRange.location > anchor.offset - }) { - return nextPage.pageIndexInChapter - } - - return max(chapter.pages.count - 1, 0) - } - - func settingsPanelWillAppear() { - pendingSettingsPreviewWorkItem?.cancel() - pendingSettingsPreviewWorkItem = nil - isSettingsPanelOpen = true - needsFullRepaginationAfterSettingsClose = false - let fallbackLocation = locationCoordinator.currentVisibleLocation() - ?? context.persistenceLocation() - settingsPreviewAnchor = captureSettingsPreviewAnchor() - settingsRestoreLocation = exactSettingsRestoreLocation( - anchor: settingsPreviewAnchor, - fallbackLocation: fallbackLocation - ) - settingsPreviewGeneration += 1 - } - - func settingsPanelDidDisappear() { - pendingSettingsPreviewWorkItem?.cancel() - pendingSettingsPreviewWorkItem = nil - isSettingsPanelOpen = false - settingsPreviewGeneration += 1 - - if needsFullRepaginationAfterSettingsClose { - needsFullRepaginationAfterSettingsClose = false - paginationCoordinator.repaginatePreservingCurrentLocation( - preferredRestoreLocation: settingsRestoreLocation - ) - } - settingsRestoreLocation = nil - settingsPreviewAnchor = nil - } - - private func exactSettingsRestoreLocation( - anchor: SettingsPreviewAnchor?, - fallbackLocation: RDEPUBLocation? - ) -> RDEPUBLocation? { - guard let anchor else { return fallbackLocation } - - let textAnchor = RDEPUBTextAnchor( - fileIndex: anchor.spineIndex, - row: 0, - column: 0, - chapterOffset: anchor.offset, - fragmentID: fallbackLocation?.fragment - ) - return RDEPUBLocation( - bookIdentifier: fallbackLocation?.bookIdentifier ?? context.currentBookIdentifier, - href: anchor.href, - progression: fallbackLocation?.progression ?? 0, - lastProgression: fallbackLocation?.lastProgression, - fragment: fallbackLocation?.fragment, - rangeAnchor: RDEPUBTextRangeAnchor(start: textAnchor, end: textAnchor) - ) - } - - func refreshVisibleContentPreservingLocation() { - paginationCoordinator.refreshVisibleContentPreservingLocation() - } - - func rebuildExternalTextBook() { - paginationCoordinator.rebuildExternalTextBook() - } - - @discardableResult - func restoreReadingLocation( - _ location: RDEPUBLocation, - animated: Bool = false, - targetHighlightRangeInfo: String? = nil - ) -> Bool { - locationCoordinator.restoreReadingLocation( - location, - animated: animated, - targetHighlightRangeInfo: targetHighlightRangeInfo - ) - } - - func currentVisibleLocation() -> RDEPUBLocation? { - locationCoordinator.currentVisibleLocation() - } - - func currentViewportSignature() -> RDEPUBViewportSignature? { - viewportMonitor.currentViewportSignature() - } - - func handleViewportChangeIfNeeded( - reason: RDEPUBViewportChangeReason, - viewportSignature: RDEPUBViewportSignature? = nil - ) { - viewportMonitor.handleViewportChangeIfNeeded(reason: reason, viewportSignature: viewportSignature) - } - - @discardableResult - func ensureOnDemandNavigationTargetAvailable(for location: RDEPUBLocation) -> Bool { - chapterWarmupOrchestrator.ensureNavigationTargetAvailable(for: location) - } - - @discardableResult - func prepareOnDemandChapter( - forAbsolutePageNumber pageNumber: Int, - allowSynchronousLoad: Bool = true, - completion: ((Bool) -> Void)? = nil - ) -> Bool { - chapterWarmupOrchestrator.prepareOnDemandChapter( - forAbsolutePageNumber: pageNumber, - allowSynchronousLoad: allowSynchronousLoad, - completion: completion - ) - } - - func extendPartialBookPageMapIfNeeded( - currentPageNumber: Int, - minimumTrailingPages: Int = 2, - batchChapterCount: Int = 3 - ) { - chapterWarmupOrchestrator.extendPartialBookPageMapIfNeeded( - currentPageNumber: currentPageNumber, - minimumTrailingPages: minimumTrailingPages, - batchChapterCount: batchChapterCount - ) - } - - func prefetchForwardChaptersAfterInitialOpen(anchorSpineIndex: Int, totalSpineCount: Int) { - chapterWarmupOrchestrator.prefetchForwardChaptersAfterInitialOpen( - anchorSpineIndex: anchorSpineIndex, - totalSpineCount: totalSpineCount - ) - } - - func clearOnDemandPageModeState() { - paginationCoordinator.cancelActiveMetadataParseWork() - chapterRuntimeStore.invalidateAllForSettingsChange() - context.bookPageMap = nil - context.pendingPageMapUpdates.removeAll() - jumpSessionManager.clearSession() - backgroundPriorityManager.reset() - backgroundCoverageStore.clearAll() - chapterWarmupOrchestrator.clear() - } - - func handleMemoryWarning() { - let currentSpineIndex = locationCoordinator.currentVisibleLocation() - .flatMap { context.normalizedSpineIndex(for: $0) } - let activeWindowIndices: Set = if let currentSpineIndex { - [currentSpineIndex, currentSpineIndex - 1, currentSpineIndex + 1] - } else { - [] - } - let protectedIndices = jumpSessionManager.activeSession?.protectedSpineIndices ?? [] - - backgroundCoverageStore.handleMemoryWarning( - activeWindowSpineIndices: activeWindowIndices, - protectedSpineIndices: protectedIndices - ) - chapterRuntimeStore.handleMemoryWarning() - } - - private func makePartialPageMap(from chapters: [RDEPUBRuntimeChapter]) -> RDEPUBBookPageMap { - var builder = RDEPUBBookPageMap.Builder() - for chapter in chapters { - builder.add( - spineIndex: chapter.spineIndex, - href: chapter.href, - title: chapter.title, - pageCount: chapter.pages.count, - fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets - ) - } - return builder.build() - } - - private func handleDeferredCFIMapReady(for spineIndex: Int) { - guard let currentLocation = locationCoordinator.currentVisibleLocation(), - let visibleSpineIndex = context.normalizedSpineIndex(for: currentLocation), - visibleSpineIndex == spineIndex else { - return - } - refreshVisibleContentPreservingLocation() - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderSearchCoordinator.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderSearchCoordinator.swift deleted file mode 100644 index 1c24f55..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderSearchCoordinator.swift +++ /dev/null @@ -1,419 +0,0 @@ -import Foundation - -final class RDEPUBReaderSearchCoordinator { - - private unowned let context: RDEPUBReaderContext - - private let searchQueue = DispatchQueue(label: "com.ssreaderview.epub.search", qos: .userInitiated) - - private let tokenLock = NSLock() - private var _currentSearchToken: UUID = UUID() - - private var currentSearchToken: UUID { - get { - tokenLock.lock() - defer { tokenLock.unlock() } - return _currentSearchToken - } - set { - tokenLock.lock() - _currentSearchToken = newValue - tokenLock.unlock() - } - } - - init(context: RDEPUBReaderContext) { - self.context = context - } - - private var controller: RDEPUBReaderController? { - context.controller - } - - func search(keyword: String) { - guard let controller else { return } - let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalizedKeyword.isEmpty else { - clearSearch() - return - } - - let token = UUID() - currentSearchToken = token - - let searchEngine = makeSearchEngine() - let layoutSnapshot = context.makeLayoutSnapshot() - - searchQueue.async { [weak self] in - guard let self, self.currentSearchToken == token else { return } - let matches = self.performSearch(using: searchEngine, keyword: normalizedKeyword, layoutSnapshot: layoutSnapshot) - - DispatchQueue.main.async { [weak self] in - guard let self, self.currentSearchToken == token else { return } - guard let controller = self.context.controller else { return } - controller.searchState = RDEPUBSearchState( - keyword: normalizedKeyword, - matches: matches, - currentMatchIndex: matches.isEmpty ? nil : 0 - ) - self.notifySearchStateChanged() - if matches.isEmpty { - controller.refreshVisibleContentPreservingLocation() - } else { - _ = self.navigateToCurrentSearchMatch(animated: false) - } - } - } - } - - @discardableResult - func searchNext() -> Bool { - advanceSearch(by: 1) - } - - @discardableResult - func searchPrevious() -> Bool { - advanceSearch(by: -1) - } - - @discardableResult - func selectSearchMatch(at index: Int) -> Bool { - guard let controller else { return false } - guard var searchState = controller.searchState, - searchState.matches.indices.contains(index) else { - return false - } - searchState.currentMatchIndex = index - controller.searchState = searchState - notifySearchStateChanged() - return navigateToCurrentSearchMatch(animated: true) - } - - func clearSearch() { - currentSearchToken = UUID() - guard let controller else { return } - controller.searchState = nil - notifySearchStateChanged() - controller.refreshVisibleContentPreservingLocation() - } - - func searchPresentation(for page: EPUBPage) -> RDEPUBSearchPresentation? { - guard let controller else { return nil } - guard let searchState = controller.searchState, - let publication = controller.publication else { - return nil - } - - let pageHrefs: [String] - if let fixedSpread = page.fixedSpread { - pageHrefs = fixedSpread.resources.map { publication.resourceResolver.normalizedHref($0.href) ?? $0.href } - } else if publication.spine.indices.contains(page.spineIndex) { - pageHrefs = [ - publication.resourceResolver.normalizedHref(publication.spine[page.spineIndex].href) - ?? publication.spine[page.spineIndex].href - ] - } else { - pageHrefs = [] - } - - guard !pageHrefs.isEmpty else { - return nil - } - - let currentMatch = searchState.currentMatch - let normalizedCurrentHref = currentMatch.map { publication.resourceResolver.normalizedHref($0.href) ?? $0.href } - let resources = pageHrefs.map { href in - let matchCount = searchState.matches.filter { - (publication.resourceResolver.normalizedHref($0.href) ?? $0.href) == href - }.count - let activeLocalMatchIndex = normalizedCurrentHref == href ? currentMatch?.localMatchIndex : nil - return RDEPUBSearchPresentationResource( - href: href, - matchCount: matchCount, - activeLocalMatchIndex: activeLocalMatchIndex - ) - } - return RDEPUBSearchPresentation(keyword: searchState.keyword, resources: resources) - } - - // MARK: - Private - - private func makeSearchEngine() -> SearchEngineSnapshot? { - guard let controller else { return nil } - if let textBook = controller.textBook, let publication = controller.publication { - return .textBook(textBook, publication) - } - // External text books (e.g. plain .txt) carry a textBook but no - // publication/parser/bookPageMap; search over the chapter text - // directly using hrefs from the textBook itself. - if let textBook = controller.textBook { - return .externalTextBook(textBook) - } - if controller.readerContext.bookPageMap != nil, let publication = controller.publication { - return .onDemand(publication) - } - if let parser = controller.parser, let publication = controller.publication { - return .html(parser, publication) - } - return nil - } - - private func performSearch(using engine: SearchEngineSnapshot?, keyword: String, layoutSnapshot: RDEPUBLayoutSnapshot?) -> [RDEPUBSearchMatch] { - guard let engine else { return [] } - switch engine { - case .textBook(let textBook, let publication): - return RDEPUBTextSearchEngine(textBook: textBook, publication: publication).search(keyword: keyword) - case .externalTextBook(let textBook): - return RDEPUBTextSearchEngine.searchWithoutPublication(textBook: textBook, keyword: keyword) - case .onDemand(let publication): - return resolvedOnDemandSearchMatches(for: keyword, publication: publication, layoutSnapshot: layoutSnapshot) - case .html(let parser, let publication): - return RDEPUBHTMLSearchEngine(parser: parser, publication: publication).search(keyword: keyword) - } - } - - private enum SearchEngineSnapshot { - case textBook(RDEPUBTextBook, RDEPUBPublication) - case externalTextBook(RDEPUBTextBook) - case onDemand(RDEPUBPublication) - case html(RDEPUBParser, RDEPUBPublication) - } - - // H-09: Each chapter iteration is wrapped in autoreleasepool to release - // the chapter's typesetAttributedString memory between iterations. - private func resolvedOnDemandSearchMatches(for keyword: String, publication: RDEPUBPublication, layoutSnapshot: RDEPUBLayoutSnapshot?) -> [RDEPUBSearchMatch] { - let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalizedKeyword.isEmpty else { - return [] - } - - let buildableSpineIndices = publication.spine.indices.filter { index in - let item = publication.spine[index] - return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml")) - } - - var matches: [RDEPUBSearchMatch] = [] - for spineIndex in buildableSpineIndices { - let chapterMatches: [RDEPUBSearchMatch] = autoreleasepool { - guard let chapter = try? context.runtime?.chapterLoader.loadChapterSynchronouslyForMigration( - spineIndex: spineIndex, - store: context.runtime?.chapterRuntimeStore ?? RDEPUBChapterRuntimeStore(), - layoutSnapshot: layoutSnapshot - ) else { - return [] - } - - let chapterData = makeChapterData(from: chapter, chapterIndex: chapter.pages.first?.chapterIndex ?? 0) - let source = chapter.typesetAttributedString.string as NSString - let fullLength = source.length - guard fullLength > 0 else { return [] } - - let normalizedHref = publication.resourceResolver.normalizedHref(chapter.href) ?? chapter.href - var localMatches: [RDEPUBSearchMatch] = [] - var localMatchIndex = 0 - var searchRange = NSRange(location: 0, length: fullLength) - - while searchRange.length > 0 { - let foundRange = source.range(of: normalizedKeyword, options: [.caseInsensitive], range: searchRange) - guard foundRange.location != NSNotFound else { - break - } - - let progressionDenominator = max(fullLength - 1, 1) - let progression = Double(foundRange.location) / Double(progressionDenominator) - let rangeAnchor = chapterData.rangeAnchor(for: foundRange) - localMatches.append( - RDEPUBSearchMatch( - href: normalizedHref, - progression: progression, - previewText: previewText(in: source, matchRange: foundRange), - localMatchIndex: localMatchIndex, - rangeLocation: foundRange.location, - rangeLength: max(foundRange.length, 1), - rangeAnchor: rangeAnchor, - cfi: chapterData.indexTable.cfi(for: rangeAnchor.start)?.rawValue, - rangeCFI: chapterData.indexTable.cfiRange(for: rangeAnchor)?.rawValue - ) - ) - - localMatchIndex += 1 - let nextLocation = foundRange.location + max(foundRange.length, 1) - if nextLocation >= fullLength { - break - } - searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation) - } - return localMatches - } // end autoreleasepool - matches.append(contentsOf: chapterMatches) - } - - return matches - } - - private func makeChapterData( - from runtimeChapter: RDEPUBRuntimeChapter, - chapterIndex: Int - ) -> RDEPUBChapterData { - let textChapter = RDEPUBTextChapter( - chapterIndex: chapterIndex, - spineIndex: runtimeChapter.spineIndex, - href: runtimeChapter.href, - title: runtimeChapter.title, - attributedContent: runtimeChapter.typesetAttributedString, - fragmentOffsets: runtimeChapter.chapterOffsetMap.fragmentOffsets, - cfiMap: runtimeChapter.chapterOffsetMap.cfiMap, - pageBreakReasons: runtimeChapter.pages.map(\.metadata.breakReason), - pages: runtimeChapter.pages - ) - return RDEPUBChapterData( - chapter: textChapter, - indexTable: RDEPUBTextIndexTable(chapters: [textChapter]) - ) - } - - private func previewText(in text: NSString, matchRange: NSRange) -> String { - let previewRadius = 12 - let start = max(matchRange.location - previewRadius, 0) - let end = min(matchRange.location + matchRange.length + previewRadius, text.length) - let range = NSRange(location: start, length: max(end - start, 0)) - return text.substring(with: range).trimmingCharacters(in: .whitespacesAndNewlines) - } - - private func advanceSearch(by delta: Int) -> Bool { - guard let controller else { return false } - guard var searchState = controller.searchState, !searchState.matches.isEmpty else { - return false - } - - let currentIndex = searchState.currentMatchIndex ?? 0 - let nextIndex = (currentIndex + delta + searchState.matches.count) % searchState.matches.count - searchState.currentMatchIndex = nextIndex - controller.searchState = searchState - notifySearchStateChanged() - return navigateToCurrentSearchMatch(animated: true) - } - - private func notifySearchStateChanged() { - guard let controller else { return } - let state = controller.searchState - // 搜索在后台队列执行,提交结果时必须同步刷新搜索面板。 - // 输入事件触发的 updateSearchCount() 发生在结果返回前,只会显示 - // “搜索中”或空状态;如果此处不刷新,正文虽已拿到 searchState - // 并显示高亮,结果列表仍会一直为空。 - controller.updateSearchCount() - controller.delegate?.epubReader(controller, didUpdateSearchResult: state?.result) - controller.delegate?.epubReader(controller, didChangeCurrentSearchMatch: state?.currentMatch) - } - - private func navigateToCurrentSearchMatch(animated: Bool) -> Bool { - guard let controller else { return false } - guard let searchMatch = controller.searchState?.currentMatch else { - controller.refreshVisibleContentPreservingLocation() - return false - } - - if let targetPageNumber = pageNumber(for: searchMatch), - controller.readerView.currentPage == targetPageNumber - 1 { - controller.refreshVisibleContentPreservingLocation() - return true - } - - let location = RDEPUBLocation( - bookIdentifier: controller.currentBookIdentifier, - href: searchMatch.href, - progression: searchMatch.progression, - lastProgression: searchMatch.progression, - fragment: nil, - rangeAnchor: searchMatch.rangeAnchor, - cfi: searchMatch.cfi, - rangeCFI: searchMatch.rangeCFI - ) - return controller.restoreReadingLocation(location, animated: animated) - } - - private func pageNumber(for searchMatch: RDEPUBSearchMatch) -> Int? { - guard let controller else { return nil } - if let chapterData = controller.textChapterData(forNormalizedHref: searchMatch.href) { - if let exactPageNumber = exactPageNumber( - for: searchMatch, - in: chapterData, - keyword: controller.searchState?.keyword - ) { - return exactPageNumber - } - - if let pageNumber = chapterData.pageNumber(for: searchMatch) { - return pageNumber - } - - if let rangeLocation = searchMatch.rangeLocation, - let page = chapterData.page(containing: rangeLocation) { - return page.absolutePageIndex + 1 - } - } - - let location = RDEPUBLocation( - bookIdentifier: controller.currentBookIdentifier, - href: searchMatch.href, - progression: searchMatch.progression, - lastProgression: searchMatch.progression, - fragment: nil, - rangeAnchor: searchMatch.rangeAnchor, - cfi: searchMatch.cfi, - rangeCFI: searchMatch.rangeCFI - ) - - if let textBook = controller.textBook { - if let publication = controller.publication { - return textBook.pageNumber( - for: location, - resolver: publication.resourceResolver, - bookIdentifier: controller.currentBookIdentifier - ) - } - // External text books have no resolver; hrefs match verbatim. - return textBook.chapterData(for: location.href)?.pageNumber(for: location) - } - - return controller.readingSession?.pageIndex( - for: location, - bookIdentifier: controller.currentBookIdentifier - ).map { $0 + 1 } - } - - private func exactPageNumber( - for searchMatch: RDEPUBSearchMatch, - in chapterData: RDEPUBChapterData, - keyword: String? - ) -> Int? { - let normalizedKeyword = keyword?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !normalizedKeyword.isEmpty else { return nil } - - let source = chapterData.attributedContent.string as NSString - let fullLength = source.length - guard fullLength > 0 else { return nil } - - var localMatchIndex = 0 - var searchRange = NSRange(location: 0, length: fullLength) - - while searchRange.length > 0 { - let foundRange = source.range(of: normalizedKeyword, options: [.caseInsensitive], range: searchRange) - guard foundRange.location != NSNotFound else { break } - - if localMatchIndex == searchMatch.localMatchIndex, - let page = chapterData.page(containing: foundRange.location) { - return page.absolutePageIndex + 1 - } - - localMatchIndex += 1 - let nextLocation = foundRange.location + max(foundRange.length, 1) - if nextLocation >= fullLength { - break - } - searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation) - } - - return nil - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderServices.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderServices.swift deleted file mode 100644 index 78eabca..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderServices.swift +++ /dev/null @@ -1,54 +0,0 @@ -import Foundation - -final class RDEPUBReaderServices { - - var dependencies: RDEPUBReaderDependencies - - init(dependencies: RDEPUBReaderDependencies) { - self.dependencies = dependencies - } - - func resolvedTextRenderer(configuration: RDEPUBReaderConfiguration) -> RDEPUBTextRenderer { - dependencies.makeTextRenderer(configuration.textRenderingEngine) - } - - func makeParser() -> RDEPUBParser { - dependencies.makeParser() - } - - func makePaginator() -> RDEPUBPaginator { - dependencies.makePaginator() - } - - func makeTextBookBuilder( - configuration: RDEPUBReaderConfiguration, - cache: RDEPUBTextBookCache?, - layoutConfig: RDEPUBTextLayoutConfig - ) -> RDEPUBTextBookBuilder { - dependencies.makeTextBookBuilder( - resolvedTextRenderer(configuration: configuration), - cache, - layoutConfig - ) - } - - func makePlainTextBookBuilder( - configuration: RDEPUBReaderConfiguration, - layoutConfig: RDEPUBTextLayoutConfig - ) -> RDEpubPlainTextBookBuilder { - dependencies.makePlainTextBookBuilder( - resolvedTextRenderer(configuration: configuration), - layoutConfig - ) - } - - func makeChapterSummaryDiskCache(bookIdentifier: String?) -> RDEPUBChapterSummaryDiskCache { - let cachesDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first - ?? FileManager.default.temporaryDirectory - let bookID = (bookIdentifier ?? "default").rd_sha256Hex - let directory = cachesDirectory - .appendingPathComponent("RDEPUBChapterSummaryCache", isDirectory: true) - .appendingPathComponent(bookID, isDirectory: true) - return RDEPUBChapterSummaryDiskCache(cacheDirectory: directory) - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderState.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderState.swift deleted file mode 100644 index eefa79e..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderState.swift +++ /dev/null @@ -1,75 +0,0 @@ -import UIKit - -/// M-01: All properties must be accessed exclusively from the main thread. -/// This is currently enforced by convention — all verified access paths are main-thread-only. -/// Adding @MainActor would formalize this but requires iOS 15+ and Swift concurrency throughout. -/// For now, rely on the audit-verified access patterns and consider @MainActor in a future refactor. -final class RDEPUBReaderState { - - var parser: RDEPUBParser? - - var publication: RDEPUBPublication? - - var readingSession: RDEPUBReadingSession? - - var textBook: RDEPUBTextBook? - - var bookPageMap: RDEPUBBookPageMap? - - var activeBookmarks: [RDEPUBBookmark] = [] - - var activeHighlights: [RDEPUBHighlight] = [] - - var currentBookIdentifier: String? - - var paginationToken = UUID() - - var searchState: RDEPUBSearchState? - - var pendingPageMapUpdates: [RDEPUBPendingPageMapUpdate] = [] - - var lastTextPaginationPageSize: CGSize? - - var lastMetadataParseWallClockMs: Int = 0 - - var lastMetadataParseConcurrency: Int = 0 - - var selectionState: RDEPUBSelectionState = .idle - - var isRepaginating: Bool = false - - var didStartInitialLoad: Bool = false - - var isExternalTextBook: Bool = false - - var textFileURL: URL? - - let textBookCache = RDEPUBTextBookCache() - - var currentSelection: RDEPUBSelection? { - get { selectionState.selection } - set { - if let newValue, !newValue.isEmpty { - selectionState = .selected(newValue) - } else { - selectionState = .idle - } - } - } - - var activePages: [EPUBPage] { - readingSession?.activePages ?? [] - } - - var activeChapters: [EPUBChapterInfo] { - readingSession?.activeChapters ?? [] - } - - func replaceActiveSnapshot(_ snapshot: RDEPUBReadingSession.PaginationSnapshot) { - readingSession?.setActiveSnapshot(snapshot) - } - - func clearActiveSnapshot() { - readingSession?.resetRuntimeState() - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderUIState.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderUIState.swift deleted file mode 100644 index 326941a..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderUIState.swift +++ /dev/null @@ -1,34 +0,0 @@ -import Foundation - -struct RDEPUBReaderUIState { - - let canToggleBookmark: Bool - - let hasBookmarkAtCurrentLocation: Bool - - let canShowBookmarks: Bool - - let canAddHighlight: Bool - - let canShowHighlights: Bool - - let showsTableOfContents: Bool - - let allowsHighlights: Bool - - let showsSettingsPanel: Bool -} - -extension RDEPUBReaderUIState { - - static let empty = RDEPUBReaderUIState( - canToggleBookmark: false, - hasBookmarkAtCurrentLocation: false, - canShowBookmarks: false, - canAddHighlight: false, - canShowHighlights: false, - showsTableOfContents: true, - allowsHighlights: true, - showsSettingsPanel: true - ) -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderViewportMonitor.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderViewportMonitor.swift deleted file mode 100644 index 1fb4948..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderViewportMonitor.swift +++ /dev/null @@ -1,129 +0,0 @@ -import UIKit - -final class RDEPUBReaderViewportMonitor { - - private unowned let context: RDEPUBReaderContext - - private var lastAppliedViewportSignature: RDEPUBViewportSignature? - - private var pendingViewportChangeReason: RDEPUBViewportChangeReason? - - private var pendingPresentationRestoreLocation: RDEPUBLocation? - - private var isWaitingForViewportTransitionCompletion = false - - init(context: RDEPUBReaderContext) { - self.context = context - } - - private var controller: RDEPUBReaderController? { - context.controller - } - - func viewDidLayoutSubviews() { - guard let controller else { return } - guard let viewportSignature = currentViewportSignature() else { return } - - if !controller.didStartInitialLoad { - lastAppliedViewportSignature = viewportSignature - controller.startInitialLoadIfNeeded() - return - } - - guard controller.publication != nil || controller.isExternalTextBook else { - lastAppliedViewportSignature = viewportSignature - return - } - - guard !isWaitingForViewportTransitionCompletion else { - return - } - - handleViewportChangeIfNeeded(reason: .viewLayout, viewportSignature: viewportSignature) - } - - func viewWillTransition(with coordinator: UIViewControllerTransitionCoordinator) { - guard let controller else { return } - guard controller.didStartInitialLoad else { return } - pendingPresentationRestoreLocation = controller.currentVisibleLocation() ?? controller.persistenceLocation() - isWaitingForViewportTransitionCompletion = true - - coordinator.animate(alongsideTransition: nil) { [weak self] _ in - guard let self, let controller = self.controller else { return } - self.isWaitingForViewportTransitionCompletion = false - controller.view.layoutIfNeeded() - self.handleViewportChangeIfNeeded(reason: .orientationTransition) - } - } - - func resetForReload() { - lastAppliedViewportSignature = currentViewportSignature() - pendingViewportChangeReason = nil - pendingPresentationRestoreLocation = nil - isWaitingForViewportTransitionCompletion = false - } - - func consumePendingPresentationRestoreLocation() -> RDEPUBLocation? { - defer { pendingPresentationRestoreLocation = nil } - return pendingPresentationRestoreLocation - } - - func capturePendingPresentationRestoreLocation() { - guard let controller else { return } - pendingPresentationRestoreLocation = controller.currentVisibleLocation() ?? controller.persistenceLocation() - } - - func processPendingChangeAfterPagination() { - guard let pendingReason = pendingViewportChangeReason else { return } - pendingViewportChangeReason = nil - DispatchQueue.main.async { [weak self] in - self?.handleViewportChangeIfNeeded(reason: pendingReason) - } - } - - func currentViewportSignature() -> RDEPUBViewportSignature? { - guard let controller else { return nil } - let containerSize = controller.readerView.bounds.size == .zero ? controller.view.bounds.size : controller.readerView.bounds.size - guard containerSize.width > 0, containerSize.height > 0 else { return nil } - let insets = controller.view.safeAreaInsets - return RDEPUBViewportSignature( - width: containerSize.width, - height: containerSize.height, - safeTop: insets.top, - safeLeft: insets.left, - safeBottom: insets.bottom, - safeRight: insets.right - ) - } - - func handleViewportChangeIfNeeded( - reason: RDEPUBViewportChangeReason, - viewportSignature: RDEPUBViewportSignature? = nil - ) { - guard let controller else { return } - guard controller.didStartInitialLoad, - let signature = viewportSignature ?? currentViewportSignature() else { - return - } - - if controller.isRepaginating { - pendingViewportChangeReason = reason - return - } - - if let lastAppliedViewportSignature, - !signature.differsSignificantly(from: lastAppliedViewportSignature) { - return - } - - lastAppliedViewportSignature = signature - - if controller.isExternalTextBook { - controller.rebuildExternalTextBook() - return - } - - guard controller.publication != nil else { return } - controller.repaginatePreservingCurrentLocation() - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBSelectionState.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBSelectionState.swift deleted file mode 100644 index 0949414..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBSelectionState.swift +++ /dev/null @@ -1,31 +0,0 @@ - -import Foundation - -enum RDEPUBSelectionState: Equatable { - - case idle - - case selecting(anchor: Int) - - case selected(RDEPUBSelection) - - case committingAction(RDEPUBSelection, action: RDEPUBAnnotationMenuAction) - - var hasSelection: Bool { - switch self { - case .idle: - return false - case .selecting, .selected, .committingAction: - return true - } - } - - var selection: RDEPUBSelection? { - switch self { - case .idle, .selecting: - return nil - case .selected(let selection), .committingAction(let selection, _): - return selection - } - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderConfiguration.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderConfiguration.swift deleted file mode 100644 index 25203fd..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderConfiguration.swift +++ /dev/null @@ -1,215 +0,0 @@ -import UIKit - -public enum RDEPUBTextRenderingEngine: Equatable { - - case dtCoreText -} - -public enum RDEPUBReaderFontChoice: String, Codable, CaseIterable, Equatable { - - case system - - case serif - - case rounded - - case monospaced - - public var displayName: String { - switch self { - case .system: - return "系统" - case .serif: - return "宋体" - case .rounded: - return "圆体" - case .monospaced: - return "等宽" - } - } - - public func font(ofSize size: CGFloat) -> UIFont { - switch self { - case .system: - return UIFont.systemFont(ofSize: size) - case .serif: - let descriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body) - .withDesign(.serif) ?? UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body) - return UIFont(descriptor: descriptor, size: size) - case .rounded: - let descriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body) - .withDesign(.rounded) ?? UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body) - return UIFont(descriptor: descriptor, size: size) - case .monospaced: - return UIFont.monospacedSystemFont(ofSize: size, weight: .regular) - } - } -} - -/// 试读策略:开启后,阅读器在最后一个可读页之后追加一页“试读墙”(UI 由宿主经 delegate 提供)。 -public struct RDEPUBReaderTrialPolicy: Equatable { - - /// 可读章节数(可读 spine 为 0.. - - public var requiresExternalLinkConfirmation: Bool - - public var allowsInspectableWebViews: Bool - - public var enablesVerboseWebViewLogging: Bool - - /// 试读策略;nil = 非试读(全书可读、无试读墙)。 - public var trialPolicy: RDEPUBReaderTrialPolicy? - - public init( - fontSize: CGFloat = 15, - lineHeightMultiple: CGFloat = 1.6, - fontChoice: RDEPUBReaderFontChoice = .system, - numberOfColumns: Int = 1, - columnGap: CGFloat = 20, - displayType: RDEpubReaderView.DisplayType = .pageCurl, - landscapeDualPageEnabled: Bool = true, - showsTableOfContents: Bool = true, - allowsHighlights: Bool = true, - showsSettingsPanel: Bool = true, - reflowableContentInsets: UIEdgeInsets = RDEPUBSafeArea.defaultReflowableContentInsets(), - fixedContentInset: UIEdgeInsets = .zero, - theme: RDEPUBReaderTheme = .light, - darkImageAdjustmentEnabled: Bool = true, - darkImageBlendRatio: CGFloat = 0.15, - fixedLayoutFit: RDEPUBFixedLayoutFit = .page, - fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode = .automatic, - textRenderingEngine: RDEPUBTextRenderingEngine = .dtCoreText, - onDemandChapterWindowSize: Int = 3, - metadataParsingConcurrency: Int = ProcessInfo.processInfo.activeProcessorCount, - jumpSessionPolicy: RDEPUBJumpSessionPolicy = .default, - allowedExternalURLSchemes: Set = ["https"], - requiresExternalLinkConfirmation: Bool = true, - allowsInspectableWebViews: Bool = false, - enablesVerboseWebViewLogging: Bool = false, - trialPolicy: RDEPUBReaderTrialPolicy? = nil - ) { - self.fontSize = fontSize - self.lineHeightMultiple = lineHeightMultiple - self.fontChoice = fontChoice - self.numberOfColumns = max(1, numberOfColumns) - self.columnGap = max(0, columnGap) - self.displayType = displayType - self.landscapeDualPageEnabled = landscapeDualPageEnabled - self.showsTableOfContents = showsTableOfContents - self.allowsHighlights = allowsHighlights - self.showsSettingsPanel = showsSettingsPanel - self.reflowableContentInsets = reflowableContentInsets - self.fixedContentInset = fixedContentInset - self.theme = theme - self.darkImageAdjustmentEnabled = darkImageAdjustmentEnabled - self.darkImageBlendRatio = max(0, min(0.35, darkImageBlendRatio)) - self.fixedLayoutFit = fixedLayoutFit - self.fixedLayoutSpreadMode = fixedLayoutSpreadMode - self.textRenderingEngine = textRenderingEngine - self.onDemandChapterWindowSize = Self.normalizedChapterWindowSize(onDemandChapterWindowSize) - self.metadataParsingConcurrency = max(1, metadataParsingConcurrency) - self.jumpSessionPolicy = jumpSessionPolicy - self.allowedExternalURLSchemes = allowedExternalURLSchemes - self.requiresExternalLinkConfirmation = requiresExternalLinkConfirmation - self.allowsInspectableWebViews = allowsInspectableWebViews - self.enablesVerboseWebViewLogging = enablesVerboseWebViewLogging - self.trialPolicy = trialPolicy - } - - public static let `default` = RDEPUBReaderConfiguration() -} - -extension RDEPUBReaderConfiguration { - - static func normalizedChapterWindowSize(_ size: Int) -> Int { - let clamped = max(3, min(15, size)) - return clamped % 2 == 0 ? clamped + 1 : clamped - } - - var chapterWindowRadius: Int { - onDemandChapterWindowSize / 2 - } -} - -extension RDEPUBReaderConfiguration { - - func makePreferences(safeAreaInsets: UIEdgeInsets = .zero) -> RDEPUBPreferences { - // Use the larger of reflowableContentInsets and safeAreaInsets for each edge - // to prevent content from being hidden under Dynamic Island / home indicator. - // Falls back to the key-window safe area when the caller has no laid-out view. - let resolvedSafeAreaInsets = RDEPUBSafeArea.resolve(safeAreaInsets) - let safeInsets = UIEdgeInsets( - top: max(reflowableContentInsets.top, resolvedSafeAreaInsets.top), - left: max(reflowableContentInsets.left, resolvedSafeAreaInsets.left), - bottom: max(reflowableContentInsets.bottom, resolvedSafeAreaInsets.bottom), - right: max(reflowableContentInsets.right, resolvedSafeAreaInsets.right) - ) - return RDEPUBPreferences( - fontSize: fontSize, - lineHeightMultiple: lineHeightMultiple, - reflowableContentInsets: safeInsets, - fixedContentInset: fixedContentInset, - numberOfColumns: numberOfColumns, - columnGap: columnGap, - themeBackgroundColor: theme.themeBackgroundColorCSS, - themeTextColor: theme.themeTextColorCSS, - fixedBackgroundColor: theme.themeBackgroundColorCSS, - fixedLayoutFit: fixedLayoutFit, - fixedLayoutSpreadMode: fixedLayoutSpreadMode - ) - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderSettings.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderSettings.swift deleted file mode 100644 index ef8ac29..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderSettings.swift +++ /dev/null @@ -1,160 +0,0 @@ -import UIKit - -public enum RDEPUBReaderDisplayMode: String, Codable, Equatable { - - case pageCurl - - case horizontalScroll - - case verticalScroll - - case horizontalCoverScroll - - init(displayType: RDEpubReaderView.DisplayType) { - switch displayType { - case .pageCurl: - self = .pageCurl - case .horizontalScroll: - self = .horizontalScroll - case .verticalScroll: - self = .verticalScroll - } - } - - var displayType: RDEpubReaderView.DisplayType { - switch self { - case .pageCurl: - return .pageCurl - case .horizontalScroll, .horizontalCoverScroll: - return .horizontalScroll - case .verticalScroll: - return .verticalScroll - } - } -} - -public enum RDEPUBReaderThemePreset: String, Codable, CaseIterable, Equatable { - - case light - - case yellow - - case green - - case pink - - case blue - - case dark - - public var theme: RDEPUBReaderTheme { - switch self { - case .light: - return .light - case .yellow: - return .yellow - case .green: - return .green - case .pink: - return .pink - case .blue: - return .blue - case .dark: - return .dark - } - } - - public init?(theme: RDEPUBReaderTheme) { - switch theme { - case .light: - self = .light - case .yellow: - self = .yellow - case .green: - self = .green - case .pink: - self = .pink - case .blue: - self = .blue - case .dark: - self = .dark - default: - return nil - } - } -} - -public struct RDEPUBReaderSettings: Codable, Equatable { - - public var brightness: CGFloat? - - public var fontSize: CGFloat? - - public var fontChoice: RDEPUBReaderFontChoice? - - public var lineHeightMultiple: CGFloat? - - public var numberOfColumns: Int? - - public var displayMode: RDEPUBReaderDisplayMode? - - public var themePreset: RDEPUBReaderThemePreset? - - public init( - brightness: CGFloat? = nil, - fontSize: CGFloat? = nil, - fontChoice: RDEPUBReaderFontChoice? = nil, - lineHeightMultiple: CGFloat? = nil, - numberOfColumns: Int? = nil, - displayMode: RDEPUBReaderDisplayMode? = nil, - themePreset: RDEPUBReaderThemePreset? = nil - ) { - self.brightness = brightness - self.fontSize = fontSize - self.fontChoice = fontChoice - self.lineHeightMultiple = lineHeightMultiple - self.numberOfColumns = numberOfColumns - self.displayMode = displayMode - self.themePreset = themePreset - } - - public func applying(to configuration: RDEPUBReaderConfiguration) -> RDEPUBReaderConfiguration { - var resolvedConfiguration = configuration - - if let fontSize { - resolvedConfiguration.fontSize = fontSize - } - if let fontChoice { - resolvedConfiguration.fontChoice = fontChoice - } - if let lineHeightMultiple { - resolvedConfiguration.lineHeightMultiple = lineHeightMultiple - } - if let numberOfColumns { - resolvedConfiguration.numberOfColumns = max(1, numberOfColumns) - } - if let displayMode { - resolvedConfiguration.displayType = displayMode.displayType - } - if let themePreset { - resolvedConfiguration.theme = themePreset.theme - } - - return resolvedConfiguration - } - - public static func capture( - configuration: RDEPUBReaderConfiguration, - brightness: CGFloat - ) -> RDEPUBReaderSettings { - RDEPUBReaderSettings( - brightness: max(0, min(1, brightness)), - fontSize: configuration.fontSize, - fontChoice: configuration.fontChoice, - lineHeightMultiple: configuration.lineHeightMultiple, - numberOfColumns: configuration.numberOfColumns, - displayMode: RDEPUBReaderDisplayMode(displayType: configuration.displayType), - themePreset: RDEPUBReaderThemePreset(theme: configuration.theme) - ) - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderSettingsViewController.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderSettingsViewController.swift deleted file mode 100644 index 871d11d..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderSettingsViewController.swift +++ /dev/null @@ -1,368 +0,0 @@ -import UIKit - -final class RDEPUBReaderSettingsViewController: UIViewController { - - var onBrightnessChange: ((CGFloat) -> Void)? - - var onFontSizeChange: ((CGFloat) -> Void)? - - var onFontChoiceChange: ((RDEPUBReaderFontChoice) -> Void)? - - var onLineHeightChange: ((CGFloat) -> Void)? - - var onColumnCountChange: ((Int) -> Void)? - - var onDisplayTypeChange: ((RDEpubReaderView.DisplayType) -> Void)? - - var onThemeChange: ((RDEPUBReaderTheme) -> Void)? - - var onDismiss: (() -> Void)? - - private enum ThemePreset: Int, CaseIterable { - case light - case yellow - case green - case pink - case blue - case dark - - var title: String { - switch self { - case .light: return "浅色" - case .yellow: return "米黄" - case .green: return "青绿" - case .pink: return "粉色" - case .blue: return "蓝灰" - case .dark: return "夜间" - } - } - - var theme: RDEPUBReaderTheme { - switch self { - case .light: return .light - case .yellow: return .yellow - case .green: return .green - case .pink: return .pink - case .blue: return .blue - case .dark: return .dark - } - } - } - - private let scrollView = UIScrollView() - - private let contentStack: UIStackView = { - let stackView = UIStackView() - stackView.axis = .vertical - stackView.spacing = 20 - return stackView - }() - - private let brightnessSlider = UISlider() - - private let fontValueLabel = UILabel() - - private let decreaseFontButton = UIButton(type: .system) - - private let increaseFontButton = UIButton(type: .system) - - private let fontChoiceControl = UISegmentedControl(items: RDEPUBReaderFontChoice.allCases.map(\.displayName)) - - private let lineHeightControl = UISegmentedControl(items: ["紧凑", "标准", "宽松"]) - - private let columnCountControl = UISegmentedControl(items: ["单栏", "双栏"]) - - private let displayTypeControl = UISegmentedControl(items: ["仿真", "横滑", "竖滑"]) - - private let themeStackView: UIStackView = { - let stackView = UIStackView() - stackView.axis = .horizontal - stackView.distribution = .fillEqually - stackView.spacing = 12 - return stackView - }() - - private var themeButtons: [UIButton] = [] - - private let lineHeightValues: [CGFloat] = [1.3, 1.6, 1.9] - - private var currentConfiguration: RDEPUBReaderConfiguration - - init(configuration: RDEPUBReaderConfiguration, brightness: CGFloat) { - self.currentConfiguration = configuration - super.init(nibName: nil, bundle: nil) - brightnessSlider.value = Float(brightness) - title = "样式设置" - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - setupNavigationItems() - setupViews() - syncControls() - applyTheme(currentConfiguration.theme) - } - - private func setupNavigationItems() { - let doneItem = UIBarButtonItem(title: "完成", style: .done, target: self, action: #selector(doneAction)) - doneItem.accessibilityIdentifier = "epub.reader.settings.done" - navigationItem.rightBarButtonItem = doneItem - } - - private func setupViews() { - view.addSubview(scrollView) - scrollView.accessibilityIdentifier = "epub.reader.settings.scroll" - scrollView.translatesAutoresizingMaskIntoConstraints = false - scrollView.addSubview(contentStack) - contentStack.translatesAutoresizingMaskIntoConstraints = false - - NSLayoutConstraint.activate([ - scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), - scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - - contentStack.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor, constant: 16), - contentStack.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor, constant: -16), - contentStack.topAnchor.constraint(equalTo: scrollView.topAnchor, constant: 20), - contentStack.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor, constant: -24), - contentStack.widthAnchor.constraint(equalTo: scrollView.widthAnchor, constant: -32) - ]) - - brightnessSlider.minimumValue = 0 - brightnessSlider.maximumValue = 1 - brightnessSlider.accessibilityIdentifier = "epub.reader.settings.brightness" - brightnessSlider.addTarget(self, action: #selector(brightnessChanged(_:)), for: .valueChanged) - - fontValueLabel.font = UIFont.monospacedDigitSystemFont(ofSize: 16, weight: .semibold) - fontValueLabel.textAlignment = .center - fontValueLabel.accessibilityIdentifier = "epub.reader.settings.font.value" - fontValueLabel.setContentHuggingPriority(.required, for: .horizontal) - - configureFontButton(decreaseFontButton, title: "A-") - configureFontButton(increaseFontButton, title: "A+") - decreaseFontButton.accessibilityIdentifier = "epub.reader.settings.font.decrease" - increaseFontButton.accessibilityIdentifier = "epub.reader.settings.font.increase" - decreaseFontButton.addTarget(self, action: #selector(decreaseFontAction), for: .touchUpInside) - increaseFontButton.addTarget(self, action: #selector(increaseFontAction), for: .touchUpInside) - - lineHeightControl.accessibilityIdentifier = "epub.reader.settings.lineHeight" - fontChoiceControl.accessibilityIdentifier = "epub.reader.settings.font.choice" - columnCountControl.accessibilityIdentifier = "epub.reader.settings.columns" - displayTypeControl.accessibilityIdentifier = "epub.reader.settings.displayType" - fontChoiceControl.addTarget(self, action: #selector(fontChoiceChanged(_:)), for: .valueChanged) - lineHeightControl.addTarget(self, action: #selector(lineHeightChanged(_:)), for: .valueChanged) - columnCountControl.addTarget(self, action: #selector(columnCountChanged(_:)), for: .valueChanged) - displayTypeControl.addTarget(self, action: #selector(displayTypeChanged(_:)), for: .valueChanged) - - ThemePreset.allCases.forEach { preset in - let button = UIButton(type: .system) - button.tag = preset.rawValue - button.layer.cornerRadius = 18 - button.layer.borderWidth = 1.5 - button.backgroundColor = preset.theme.contentBackgroundColor - button.accessibilityLabel = preset.title - button.accessibilityIdentifier = "epub.reader.settings.theme.\(preset.rawValue)" - button.addTarget(self, action: #selector(themeButtonAction(_:)), for: .touchUpInside) - themeButtons.append(button) - themeStackView.addArrangedSubview(button) - NSLayoutConstraint.activate([ - button.heightAnchor.constraint(equalToConstant: 36) - ]) - } - - contentStack.addArrangedSubview(makeSection(title: "亮度", content: brightnessSlider)) - contentStack.addArrangedSubview(makeSection(title: "字号", content: makeFontSizeRow())) - contentStack.addArrangedSubview(makeSection(title: "字体", content: fontChoiceControl)) - contentStack.addArrangedSubview(makeSection(title: "行距", content: lineHeightControl)) - contentStack.addArrangedSubview(makeSection(title: "分栏", content: columnCountControl)) - contentStack.addArrangedSubview(makeSection(title: "翻页方式", content: displayTypeControl)) - contentStack.addArrangedSubview(makeSection(title: "主题", content: themeStackView)) - } - - private func makeSection(title: String, content: UIView) -> UIView { - let container = UIStackView() - container.axis = .vertical - container.spacing = 10 - - let titleLabel = UILabel() - titleLabel.text = title - titleLabel.font = UIFont.systemFont(ofSize: 14, weight: .semibold) - - content.translatesAutoresizingMaskIntoConstraints = false - container.addArrangedSubview(titleLabel) - container.addArrangedSubview(content) - return container - } - - private func makeFontSizeRow() -> UIView { - let stackView = UIStackView(arrangedSubviews: [decreaseFontButton, fontValueLabel, increaseFontButton]) - stackView.axis = .horizontal - stackView.alignment = .center - stackView.distribution = .fill - stackView.spacing = 12 - - NSLayoutConstraint.activate([ - decreaseFontButton.widthAnchor.constraint(equalToConstant: 64), - increaseFontButton.widthAnchor.constraint(equalToConstant: 64), - decreaseFontButton.heightAnchor.constraint(equalToConstant: 36), - increaseFontButton.heightAnchor.constraint(equalToConstant: 36) - ]) - return stackView - } - - private func configureFontButton(_ button: UIButton, title: String) { - button.setTitle(title, for: .normal) - button.titleLabel?.font = UIFont.systemFont(ofSize: 16, weight: .semibold) - button.layer.cornerRadius = 18 - button.layer.borderWidth = 1 - } - - private func syncControls() { - fontValueLabel.text = String(Int(currentConfiguration.fontSize.rounded())) - fontChoiceControl.selectedSegmentIndex = RDEPUBReaderFontChoice.allCases.firstIndex(of: currentConfiguration.fontChoice) ?? 0 - - let lineHeightIndex = lineHeightValues.enumerated().min { abs($0.element - currentConfiguration.lineHeightMultiple) < abs($1.element - currentConfiguration.lineHeightMultiple) }?.offset ?? 1 - lineHeightControl.selectedSegmentIndex = lineHeightIndex - columnCountControl.selectedSegmentIndex = currentConfiguration.numberOfColumns > 1 ? 1 : 0 - - switch currentConfiguration.displayType { - case .pageCurl: - displayTypeControl.selectedSegmentIndex = 0 - case .horizontalScroll: - displayTypeControl.selectedSegmentIndex = 1 - case .verticalScroll: - displayTypeControl.selectedSegmentIndex = 2 - } - - let selectedPreset = ThemePreset.allCases.first(where: { $0.theme == currentConfiguration.theme }) ?? .light - updateThemeSelection(selectedPreset) - updateControlAccessibilityValues() - } - - private func applyTheme(_ theme: RDEPUBReaderTheme) { - view.backgroundColor = theme.contentBackgroundColor - scrollView.backgroundColor = theme.contentBackgroundColor - contentStack.arrangedSubviews - .compactMap { $0 as? UIStackView } - .flatMap { $0.arrangedSubviews } - .compactMap { $0 as? UILabel } - .forEach { $0.textColor = theme.contentTextColor } - - fontValueLabel.textColor = theme.contentTextColor - [decreaseFontButton, increaseFontButton].forEach { button in - button.setTitleColor(theme.toolControlTextColor, for: .normal) - button.layer.borderColor = theme.toolControlBorderUnselectColor.cgColor - button.backgroundColor = theme.toolBackgroundColor - } - - [fontChoiceControl, lineHeightControl, columnCountControl, displayTypeControl].forEach { control in - control.backgroundColor = theme.toolBackgroundColor - if #available(iOS 13.0, *) { - control.selectedSegmentTintColor = theme.toolControlTextColor.withAlphaComponent(0.14) - } else { - control.tintColor = theme.toolControlTextColor - } - control.setTitleTextAttributes([.foregroundColor: theme.contentTextColor], for: .normal) - control.setTitleTextAttributes([.foregroundColor: theme.toolControlTextColor], for: .selected) - } - - navigationController?.navigationBar.tintColor = theme.toolControlTextColor - navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor - navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor] - updateThemeSelection(ThemePreset.allCases.first(where: { $0.theme == theme }) ?? .light) - } - - private func updateThemeSelection(_ preset: ThemePreset) { - themeButtons.forEach { button in - let isSelected = button.tag == preset.rawValue - button.layer.borderWidth = isSelected ? 2 : 1 - button.layer.borderColor = isSelected ? currentConfiguration.theme.toolControlTextColor.cgColor : currentConfiguration.theme.toolControlBorderUnselectColor.cgColor - button.accessibilityValue = isSelected ? "selected" : "unselected" - } - } - - private func updateControlAccessibilityValues() { - fontChoiceControl.accessibilityValue = fontChoiceControl.titleForSegment(at: fontChoiceControl.selectedSegmentIndex) - lineHeightControl.accessibilityValue = lineHeightControl.titleForSegment(at: lineHeightControl.selectedSegmentIndex) - columnCountControl.accessibilityValue = columnCountControl.titleForSegment(at: columnCountControl.selectedSegmentIndex) - displayTypeControl.accessibilityValue = displayTypeControl.titleForSegment(at: displayTypeControl.selectedSegmentIndex) - } - - @objc private func doneAction() { - dismiss(animated: true) { [weak self] in - self?.onDismiss?() - } - } - - @objc private func brightnessChanged(_ slider: UISlider) { - onBrightnessChange?(CGFloat(slider.value)) - } - - @objc private func decreaseFontAction() { - let nextValue = max(12, currentConfiguration.fontSize - 1) - guard nextValue != currentConfiguration.fontSize else { return } - currentConfiguration.fontSize = nextValue - fontValueLabel.text = String(Int(nextValue.rounded())) - onFontSizeChange?(nextValue) - } - - @objc private func increaseFontAction() { - let nextValue = min(36, currentConfiguration.fontSize + 1) - guard nextValue != currentConfiguration.fontSize else { return } - currentConfiguration.fontSize = nextValue - fontValueLabel.text = String(Int(nextValue.rounded())) - onFontSizeChange?(nextValue) - } - - @objc private func fontChoiceChanged(_ control: UISegmentedControl) { - let choices = RDEPUBReaderFontChoice.allCases - let index = max(0, min(control.selectedSegmentIndex, choices.count - 1)) - let choice = choices[index] - guard choice != currentConfiguration.fontChoice else { return } - currentConfiguration.fontChoice = choice - updateControlAccessibilityValues() - onFontChoiceChange?(choice) - } - - @objc private func lineHeightChanged(_ control: UISegmentedControl) { - let index = max(0, min(control.selectedSegmentIndex, lineHeightValues.count - 1)) - let value = lineHeightValues[index] - currentConfiguration.lineHeightMultiple = value - updateControlAccessibilityValues() - onLineHeightChange?(value) - } - - @objc private func columnCountChanged(_ control: UISegmentedControl) { - let numberOfColumns = control.selectedSegmentIndex == 1 ? 2 : 1 - currentConfiguration.numberOfColumns = numberOfColumns - updateControlAccessibilityValues() - onColumnCountChange?(numberOfColumns) - } - - @objc private func displayTypeChanged(_ control: UISegmentedControl) { - let displayType: RDEpubReaderView.DisplayType - switch control.selectedSegmentIndex { - case 1: - displayType = .horizontalScroll - case 2: - displayType = .verticalScroll - default: - displayType = .pageCurl - } - currentConfiguration.displayType = displayType - updateControlAccessibilityValues() - onDisplayTypeChange?(displayType) - } - - @objc private func themeButtonAction(_ sender: UIButton) { - guard let preset = ThemePreset(rawValue: sender.tag) else { return } - currentConfiguration.theme = preset.theme - applyTheme(preset.theme) - onThemeChange?(preset.theme) - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderTheme.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderTheme.swift deleted file mode 100644 index 9a3a6d7..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderTheme.swift +++ /dev/null @@ -1,103 +0,0 @@ -import UIKit - -public struct RDEPUBReaderTheme: Equatable { - - public var contentBackgroundColor: UIColor - - public var contentTextColor: UIColor - - public var toolBackgroundColor: UIColor - - public var toolControlTextColor: UIColor - - public var toolControlBorderUnselectColor: UIColor - - public var toolLineColor: UIColor - - public init( - contentBackgroundColor: UIColor, - contentTextColor: UIColor, - toolBackgroundColor: UIColor, - toolControlTextColor: UIColor, - toolControlBorderUnselectColor: UIColor, - toolLineColor: UIColor - ) { - self.contentBackgroundColor = contentBackgroundColor - self.contentTextColor = contentTextColor - self.toolBackgroundColor = toolBackgroundColor - self.toolControlTextColor = toolControlTextColor - self.toolControlBorderUnselectColor = toolControlBorderUnselectColor - self.toolLineColor = toolLineColor - } - - public static let light = RDEPUBReaderTheme( - contentBackgroundColor: .white, - contentTextColor: .black, - toolBackgroundColor: .white, - toolControlTextColor: .black, - toolControlBorderUnselectColor: UIColor.lightGray.withAlphaComponent(0.5), - toolLineColor: UIColor.lightGray.withAlphaComponent(0.5) - ) - - public static let dark = RDEPUBReaderTheme( - contentBackgroundColor: .black, - contentTextColor: .white, - toolBackgroundColor: .black, - toolControlTextColor: .white, - toolControlBorderUnselectColor: UIColor.lightGray.withAlphaComponent(0.5), - toolLineColor: UIColor.lightGray.withAlphaComponent(0.5) - ) - - public static let yellow = RDEPUBReaderTheme( - contentBackgroundColor: UIColor(red: 0.89, green: 0.87, blue: 0.79, alpha: 1), - contentTextColor: .black, - toolBackgroundColor: UIColor(red: 0.89, green: 0.87, blue: 0.79, alpha: 1), - toolControlTextColor: .black, - toolControlBorderUnselectColor: UIColor.lightGray.withAlphaComponent(0.5), - toolLineColor: UIColor.lightGray.withAlphaComponent(0.5) - ) - - public static let green = RDEPUBReaderTheme( - contentBackgroundColor: UIColor(red: 0.87, green: 0.91, blue: 0.82, alpha: 1), - contentTextColor: .black, - toolBackgroundColor: UIColor(red: 0.87, green: 0.91, blue: 0.82, alpha: 1), - toolControlTextColor: .black, - toolControlBorderUnselectColor: UIColor.lightGray.withAlphaComponent(0.5), - toolLineColor: UIColor.lightGray.withAlphaComponent(0.5) - ) - - public static let pink = RDEPUBReaderTheme( - contentBackgroundColor: UIColor(red: 1, green: 0.89, blue: 0.91, alpha: 1), - contentTextColor: .black, - toolBackgroundColor: UIColor(red: 1, green: 0.89, blue: 0.91, alpha: 1), - toolControlTextColor: .black, - toolControlBorderUnselectColor: UIColor.lightGray.withAlphaComponent(0.5), - toolLineColor: UIColor.lightGray.withAlphaComponent(0.5) - ) - - public static let blue = RDEPUBReaderTheme( - contentBackgroundColor: UIColor(red: 0.8, green: 0.84, blue: 0.89, alpha: 1), - contentTextColor: .black, - toolBackgroundColor: UIColor(red: 0.8, green: 0.84, blue: 0.89, alpha: 1), - toolControlTextColor: .black, - toolControlBorderUnselectColor: UIColor.lightGray.withAlphaComponent(0.5), - toolLineColor: UIColor.lightGray.withAlphaComponent(0.5) - ) -} - -extension RDEPUBReaderTheme { - - /// Background color for the full-screen image viewer. - /// Always near-black regardless of theme, since the image is the focus. - var imageViewerBackgroundColor: UIColor { - UIColor(white: 0.0, alpha: 0.95) - } - - var themeBackgroundColorCSS: String { - contentBackgroundColor.rd_cssString - } - - var themeTextColorCSS: String { - contentTextColor.rd_cssString - } -} \ No newline at end of file diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/String+RDEPUB.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/String+RDEPUB.swift deleted file mode 100644 index fddc71a..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/String+RDEPUB.swift +++ /dev/null @@ -1,17 +0,0 @@ -import Foundation -import CryptoKit - -extension String { - - /// trim 后空字符串返回 nil - var rd_nilIfEmpty: String? { - let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - } - - /// SHA256 hex 摘要 - var rd_sha256Hex: String { - let digest = SHA256.hash(data: Data(self.utf8)) - return digest.map { String(format: "%02x", $0) }.joined() - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBChapterDisplayContentCache.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBChapterDisplayContentCache.swift deleted file mode 100644 index d4ba110..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBChapterDisplayContentCache.swift +++ /dev/null @@ -1,114 +0,0 @@ -import UIKit - -#if canImport(DTCoreText) -import DTCoreText -#endif - -/// Chapter-level shared display content and layouter -/// (LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md P1-1 / P1-3). -/// -/// One entry holds the chapter-length display string (theme text color and -/// dark-image adjustment injected once over the full chapter at build time) -/// plus the DTCoreTextLayouter wrapping its framesetter. Page views reference -/// the shared string and request per-page layout frames; they must never -/// mutate the shared string — the framesetter and any live layout frame hold -/// it. Page-level decoration (highlights, search, selection) is drawn by the -/// overlay views instead. -/// -/// Main-thread only. -final class RDEPUBChapterDisplayContentCache { - -#if canImport(DTCoreText) - - struct Entry { - let content: NSAttributedString - let layouter: DTCoreTextLayouter? - fileprivate let signature: Signature - } - - fileprivate struct Signature: Equatable { - let chapterContentID: ObjectIdentifier - let contentLength: Int - let textColor: UIColor - let backgroundColor: UIColor - let darkImageAdjustmentEnabled: Bool - let darkImageBlendRatio: CGFloat - - init(page: RDEPUBTextPage, configuration: RDEPUBReaderConfiguration) { - chapterContentID = ObjectIdentifier(page.chapterContent) - contentLength = page.chapterContent.length - textColor = configuration.theme.contentTextColor - backgroundColor = configuration.theme.contentBackgroundColor - darkImageAdjustmentEnabled = configuration.darkImageAdjustmentEnabled - darkImageBlendRatio = configuration.darkImageBlendRatio - } - } - - /// Current chapter plus one adjacent chapter during page-curl/scroll - /// transitions across a chapter boundary. - private static let capacity = 2 - - private var entries: [String: Entry] = [:] - - private var accessOrder: [String] = [] - - func entry(for page: RDEPUBTextPage, configuration: RDEPUBReaderConfiguration) -> Entry { - dispatchPrecondition(condition: .onQueue(.main)) - let signature = Signature(page: page, configuration: configuration) - if let cached = entries[page.href], cached.signature == signature { - touch(page.href) - return cached - } - let entry = Self.buildEntry(page: page, configuration: configuration, signature: signature) - entries[page.href] = entry - touch(page.href) - evictIfNeeded() - RDEPUBMemoryProbe.log("displayContentBuilt href=\(page.href) length=\(entry.content.length)") - return entry - } - - func removeAll() { - entries.removeAll() - accessOrder.removeAll() - } - - private func touch(_ href: String) { - accessOrder.removeAll { $0 == href } - accessOrder.append(href) - } - - private func evictIfNeeded() { - while accessOrder.count > Self.capacity { - let evicted = accessOrder.removeFirst() - entries.removeValue(forKey: evicted) - } - } - - private static func buildEntry( - page: RDEPUBTextPage, - configuration: RDEPUBReaderConfiguration, - signature: Signature - ) -> Entry { - let content = NSMutableAttributedString(attributedString: page.chapterContent) - let fullRange = NSRange(location: 0, length: content.length) - _ = RDEPUBDarkImageAdjuster.adjustIfNeeded( - content, - in: fullRange, - configuration: configuration - ) - content.addAttribute( - .foregroundColor, - value: configuration.theme.contentTextColor, - range: fullRange - ) - let layouter = DTCoreTextLayouter(attributedString: content) - layouter?.shouldCacheLayoutFrames = false - return Entry(content: content, layouter: layouter, signature: signature) - } - -#else - - func removeAll() {} - -#endif -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBDarkImageAdjuster.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBDarkImageAdjuster.swift deleted file mode 100644 index acc3534..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBDarkImageAdjuster.swift +++ /dev/null @@ -1,107 +0,0 @@ -import UIKit - -#if canImport(DTCoreText) -import DTCoreText -#endif - -enum RDEPUBDarkImageAdjuster { - - private static let imageCache: NSCache = { - let cache = NSCache() - cache.countLimit = 100 - cache.totalCostLimit = 52_428_800 // 50 MB - return cache - }() - - private static func imageCost(of image: UIImage) -> Int { - let scale = image.scale - let width = Int(image.size.width * scale) - let height = Int(image.size.height * scale) - // 4 bytes per pixel (RGBA) - return width * height * 4 - } - -#if canImport(DTCoreText) - - static func adjustIfNeeded( - _ content: NSMutableAttributedString, - in range: NSRange? = nil, - configuration: RDEPUBReaderConfiguration - ) -> NSMutableAttributedString { - guard configuration.darkImageAdjustmentEnabled, - configuration.darkImageBlendRatio > 0, - configuration.theme.contentBackgroundColor.rd_isDarkBackground else { - return content - } - - let fullRange = NSRange(location: 0, length: content.length) - let targetRange = range.map { NSIntersectionRange($0, fullRange) } ?? fullRange - content.enumerateAttribute(.attachment, in: targetRange) { value, range, _ in - guard let attachment = value as? DTImageTextAttachment, - !isCoverAttachment(attachment), - let image = attachment.image, - shouldAdjust(image) else { return } - - let adjustedAttachment = DTImageTextAttachment() - adjustedAttachment.image = adjustedImage( - image, - backgroundColor: configuration.theme.contentBackgroundColor, - blendRatio: configuration.darkImageBlendRatio, - cacheKey: cacheKey(for: attachment, image: image, configuration: configuration) - ) - adjustedAttachment.originalSize = attachment.originalSize - adjustedAttachment.displaySize = attachment.displaySize - adjustedAttachment.verticalAlignment = attachment.verticalAlignment - adjustedAttachment.contentURL = attachment.contentURL - adjustedAttachment.hyperLinkURL = attachment.hyperLinkURL - adjustedAttachment.hyperLinkGUID = attachment.hyperLinkGUID - adjustedAttachment.attributes = attachment.attributes - content.addAttribute(.attachment, value: adjustedAttachment, range: range) - } - return content - } - - private static func isCoverAttachment(_ attachment: DTTextAttachment) -> Bool { - let lowercasedClasses = ((attachment.attributes["class"] as? String) ?? "").lowercased() - let lowercasedPath = attachment.contentURL?.lastPathComponent.lowercased() - ?? ((attachment.attributes["src"] as? String) ?? "").lowercased() - return lowercasedClasses.contains("rd-front-cover-image") || lowercasedPath.contains("cover") - } - - private static func shouldAdjust(_ image: UIImage) -> Bool { - image.size.width >= 80 && image.size.height >= 80 - } - - private static func cacheKey( - for attachment: DTImageTextAttachment, - image: UIImage, - configuration: RDEPUBReaderConfiguration - ) -> NSString { - let source = attachment.contentURL?.absoluteString - ?? "\(Unmanaged.passUnretained(image).toOpaque())" - return "\(source)|\(image.size.width)x\(image.size.height)|\(configuration.theme.contentBackgroundColor.rd_cssString)|\(configuration.darkImageBlendRatio)" as NSString - } - - private static func adjustedImage( - _ image: UIImage, - backgroundColor: UIColor, - blendRatio: CGFloat, - cacheKey: NSString - ) -> UIImage { - if let cached = imageCache.object(forKey: cacheKey) { return cached } - let format = UIGraphicsImageRendererFormat() - format.scale = image.scale - format.opaque = false - let renderer = UIGraphicsImageRenderer(size: image.size, format: format) - let adjusted = renderer.image { context in - image.draw(in: CGRect(origin: .zero, size: image.size)) - backgroundColor.withAlphaComponent(max(0, min(0.35, blendRatio))).setFill() - context.cgContext.setBlendMode(.sourceAtop) - context.fill(CGRect(origin: .zero, size: image.size)) - } - imageCache.setObject(adjusted, forKey: cacheKey, cost: imageCost(of: adjusted)) - return adjusted - } - -#endif -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBPageInteractionController.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBPageInteractionController.swift deleted file mode 100644 index e0e3689..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBPageInteractionController.swift +++ /dev/null @@ -1,215 +0,0 @@ -import UIKit - -#if canImport(DTCoreText) -import DTCoreText -#endif - -final class RDEPUBPageInteractionController { - - var snapshot: RDEPUBPageLayoutSnapshot? - - private var dtLayoutFrame: DTCoreTextLayoutFrame? - -#if canImport(DTCoreText) - - func configure(layoutFrame: DTCoreTextLayoutFrame?, page: RDEPUBTextPage?) { - dtLayoutFrame = layoutFrame - if let layoutFrame, let page { - snapshot = RDEPUBPageLayoutSnapshot.build(from: layoutFrame, page: page) - } else { - snapshot = nil - } - } -#endif - - func characterIndexForViewPoint(at viewPoint: CGPoint, in view: UIView) -> Int? { - let localPoint = CGPoint(x: viewPoint.x, y: viewPoint.y) - return characterIndex(at: localPoint) - } - - func characterIndex(at point: CGPoint) -> Int? { - guard let snapshot else { return nil } - - for attachment in snapshot.attachments { - if attachment.frame.insetBy(dx: -6, dy: -6).contains(point) { - return attachment.stringRange.location - } - } - - guard let line = nearestLine(to: point, in: snapshot.lines) else { return nil } - -#if canImport(DTCoreText) - guard let dtLine = dtLineContaining(range: line.stringRange) else { return nil } - let relativePoint = CGPoint( - x: point.x - line.baselineOrigin.x, - y: point.y - line.baselineOrigin.y - ) - // The layout frame is built in chapter context, so DTCoreText string - // indices are chapter-absolute already. - let idx = dtLine.stringIndex(forPosition: relativePoint) - guard idx != NSNotFound, idx >= 0 else { return nil } - return normalizedIndex( - idx, - lineRange: line.stringRange, - pageRange: snapshot.pageContentRange - ) -#else - return nil -#endif - } - - func selectionRange(from startPoint: CGPoint, to endPoint: CGPoint) -> NSRange? { - guard let start = characterIndex(at: startPoint), - let end = characterIndex(at: endPoint) else { return nil } - let lower = min(start, end) - let upper = max(start, end) - return NSRange(location: lower, length: max(upper - lower, 1)) - } - - func selectionRects(for absoluteRange: NSRange) -> [CGRect] { - guard let snapshot else { return [] } - var rects: [CGRect] = [] - - for line in snapshot.lines { - let overlap = NSIntersectionRange(line.stringRange, absoluteRange) - guard overlap.length > 0 else { continue } - -#if canImport(DTCoreText) - guard let dtLine = dtLineContaining(range: line.stringRange) else { continue } - let startX = dtLine.offset(forStringIndex: overlap.location) - let endIdx = overlap.location + overlap.length - let endX = dtLine.offset(forStringIndex: endIdx) -#else - let startX: CGFloat = 0 - let endX: CGFloat = line.frame.width -#endif - - let rect = CGRect( - x: line.baselineOrigin.x + startX, - y: line.baselineOrigin.y - line.ascent, - width: max(endX - startX, 2), - height: line.ascent + line.descent - ) - rects.append(rect) - } - - return mergeAdjacentRects(rects) - } - - func firstRect(for absoluteRange: NSRange) -> CGRect? { - selectionRects(for: absoluteRange).first - } - - func lastRect(for absoluteRange: NSRange) -> CGRect? { - selectionRects(for: absoluteRange).last - } - - func boundingRect(for absoluteRange: NSRange) -> CGRect? { - let rects = selectionRects(for: absoluteRange) - guard var rect = rects.first else { return nil } - for next in rects.dropFirst() { - rect = rect.union(next) - } - return rect - } - - func menuAnchorRect(for absoluteRange: NSRange) -> CGRect? { - guard let first = firstRect(for: absoluteRange), - let last = lastRect(for: absoluteRange) else { - return boundingRect(for: absoluteRange) - } - - let minX = min(first.minX, last.minX) - let maxX = max(first.maxX, last.maxX) - let minY = min(first.minY, last.minY) - let maxY = max(first.maxY, last.maxY) - return CGRect(x: minX, y: minY, width: max(maxX - minX, 2), height: max(maxY - minY, 2)) - } - - func caretRect(at index: Int) -> CGRect? { - guard let snapshot else { return nil } - guard let line = snapshot.lines.first(where: { NSLocationInRange(index, $0.stringRange) }) else { - return nil - } - -#if canImport(DTCoreText) - guard let dtLine = dtLineContaining(range: line.stringRange) else { return nil } - let offsetX = dtLine.offset(forStringIndex: index) -#else - let offsetX: CGFloat = 0 -#endif - - return CGRect( - x: line.baselineOrigin.x + offsetX - 1, - y: line.baselineOrigin.y - line.ascent, - width: 2, - height: line.ascent + line.descent - ) - } - - private func nearestLine(to point: CGPoint, in lines: [RDEPUBPageLine]) -> RDEPUBPageLine? { - var bestLine: RDEPUBPageLine? - var bestDistance: CGFloat = .greatestFiniteMagnitude - - for line in lines { - let lineBottom = line.baselineOrigin.y + line.descent - let lineTop = line.baselineOrigin.y - line.ascent - if point.y >= lineTop && point.y <= lineBottom { - return line - } - let lineMidY = (lineTop + lineBottom) / 2 - let distance = abs(point.y - lineMidY) - - if distance < bestDistance { - bestDistance = distance - bestLine = line - } - } - - return bestLine - } - - private func normalizedIndex(_ idx: Int, lineRange: NSRange, pageRange: NSRange) -> Int { - var result = idx - if result < lineRange.location { - result = lineRange.location - } - let lineEnd = lineRange.location + lineRange.length - if result >= lineEnd { - result = max(lineEnd - 1, lineRange.location) - } - return result - } - -#if canImport(DTCoreText) - - private func dtLineContaining(range: NSRange) -> DTCoreTextLayoutLine? { - guard let dtLayoutFrame else { return nil } - return dtLayoutFrame.lineContaining(UInt(max(range.location, 0))) - } -#endif - - private func mergeAdjacentRects(_ rects: [CGRect]) -> [CGRect] { - guard rects.count > 1 else { return rects } - - let sorted = rects.sorted { a, b in - if abs(a.origin.y - b.origin.y) < 1 { - return a.origin.x < b.origin.x - } - return a.origin.y < b.origin.y - } - - var merged: [CGRect] = [sorted[0]] - for rect in sorted.dropFirst() { - let last = merged[merged.count - 1] - if abs(rect.origin.y - last.origin.y) < 1, - rect.origin.x <= last.maxX + 2 { - merged[merged.count - 1] = last.union(rect) - } else { - merged.append(rect) - } - } - - return merged - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBPageLayoutSnapshot.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBPageLayoutSnapshot.swift deleted file mode 100644 index 54be1e0..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBPageLayoutSnapshot.swift +++ /dev/null @@ -1,224 +0,0 @@ -import UIKit - -#if canImport(DTCoreText) -import DTCoreText -#endif - -struct RDEPUBPageLine { - - let stringRange: NSRange - - let frame: CGRect - - let baselineOrigin: CGPoint - - let ascent: CGFloat - - let descent: CGFloat - - let leading: CGFloat -} - -struct RDEPUBPageRun { - - let stringRange: NSRange - - let frame: CGRect - - let isAttachment: Bool -} - -struct RDEPUBPageAttachment { - - let stringRange: NSRange - - let frame: CGRect - - let displaySize: CGSize - - let placement: RDEPUBTextAttachmentPlacement? - - let kind: RDEPUBTextAttachmentKind? -} - -struct RDEPUBPageLayoutSnapshot { - - let page: RDEPUBTextPage - - let lines: [RDEPUBPageLine] - - let runs: [RDEPUBPageRun] - - let attachments: [RDEPUBPageAttachment] - - let pageContentRange: NSRange -#if canImport(DTCoreText) - - let layoutFrame: DTCoreTextLayoutFrame -#endif - - var contentBounds: CGRect { - let rects = lines.map(\.frame) + attachments.map(\.frame) - guard var bounds = rects.first else { return .zero } - for rect in rects.dropFirst() { - bounds = bounds.union(rect) - } - return bounds - } - - func line(containing absoluteIndex: Int) -> RDEPUBPageLine? { - lines.first { NSLocationInRange(absoluteIndex, $0.stringRange) } - } - - func run(containing absoluteIndex: Int) -> RDEPUBPageRun? { - runs.first { NSLocationInRange(absoluteIndex, $0.stringRange) } - } - - func runs(intersecting range: NSRange) -> [RDEPUBPageRun] { - runs.filter { NSIntersectionRange($0.stringRange, range).length > 0 } - } - - func attachment(at point: CGPoint, hitSlop: CGFloat = 6) -> RDEPUBPageAttachment? { - attachments.first { $0.frame.insetBy(dx: -hitSlop, dy: -hitSlop).contains(point) } - } - - func line(at point: CGPoint, hitSlop: CGFloat = 4) -> RDEPUBPageLine? { - lines.first { line in - let lineRect = CGRect( - x: line.frame.minX, - y: line.baselineOrigin.y - line.ascent, - width: max(line.frame.width, 1), - height: line.ascent + line.descent + line.leading - ) - return lineRect.insetBy(dx: -hitSlop, dy: -hitSlop).contains(point) - } - } - - func run(at point: CGPoint, hitSlop: CGFloat = 4) -> RDEPUBPageRun? { - runs.first { $0.frame.insetBy(dx: -hitSlop, dy: -hitSlop).contains(point) } - } - - func rects(containing point: CGPoint, in decorations: [RDEPUBTextOverlayDecoration]) -> [RDEPUBTextOverlayDecoration] { - decorations.filter { decoration in - decoration.rects.contains { $0.insetBy(dx: -4, dy: -4).contains(point) } - } - } - - func absoluteRange(at point: CGPoint, in decorations: [RDEPUBTextOverlayDecoration]) -> NSRange? { - if let attachment = attachment(at: point) { - return attachment.stringRange - } - if let run = run(at: point) { - return run.stringRange - } - let hitDecorations = rects(containing: point, in: decorations) - if let mostSpecific = hitDecorations.min(by: { lhs, rhs in - lhs.absoluteRange.length < rhs.absoluteRange.length - }) { - return mostSpecific.absoluteRange - } - return line(at: point)?.stringRange - } - -#if canImport(DTCoreText) - - static func build( - from layoutFrame: DTCoreTextLayoutFrame, - page: RDEPUBTextPage - ) -> RDEPUBPageLayoutSnapshot? { - guard let dtLines = layoutFrame.lines as? [DTCoreTextLayoutLine], !dtLines.isEmpty else { - return nil - } - - // The layout frame is produced in chapter context, so DTCoreText - // string ranges are already chapter-absolute. - var lines: [RDEPUBPageLine] = [] - var runs: [RDEPUBPageRun] = [] - var attachments: [RDEPUBPageAttachment] = [] - - for dtLine in dtLines { - let lineRange = dtLine.stringRange() - let line = RDEPUBPageLine( - stringRange: lineRange, - frame: dtLine.frame, - baselineOrigin: dtLine.baselineOrigin, - ascent: dtLine.ascent, - descent: dtLine.descent, - leading: dtLine.leading - ) - lines.append(line) - - if let glyphRuns = dtLine.glyphRuns as? [DTCoreTextGlyphRun] { - for run in glyphRuns { - let runRange = run.stringRange() - let isAttachment = run.attachment != nil - runs.append( - RDEPUBPageRun( - stringRange: runRange, - frame: run.frame, - isAttachment: isAttachment - ) - ) - - guard isAttachment else { continue } - let metadata = attachmentMetadata( - for: runRange, - on: page - ) - attachments.append( - RDEPUBPageAttachment( - stringRange: runRange, - frame: run.frame, - displaySize: run.frame.size, - placement: metadata.placement, - kind: metadata.kind - ) - ) - } - } - } - - let visibleRange = layoutFrame.visibleStringRange() - - return RDEPUBPageLayoutSnapshot( - page: page, - lines: lines, - runs: runs, - attachments: attachments, - pageContentRange: visibleRange, - layoutFrame: layoutFrame - ) - } - - private static func attachmentMetadata( - for range: NSRange, - on page: RDEPUBTextPage - ) -> (placement: RDEPUBTextAttachmentPlacement?, kind: RDEPUBTextAttachmentKind?) { - guard let attachmentIndex = page.metadata.attachmentRanges.firstIndex(where: { NSIntersectionRange($0, range).length > 0 }) else { - return (nil, nil) - } - - let placement = page.metadata.attachmentPlacements.indices.contains(attachmentIndex) - ? page.metadata.attachmentPlacements[attachmentIndex] - : nil - let metadataKind = page.metadata.attachmentKinds.indices.contains(attachmentIndex) - ? page.metadata.attachmentKinds[attachmentIndex] - : nil - let resolvedKind = attachmentKind(at: range, on: page) ?? metadataKind - return (placement, resolvedKind) - } - - private static func attachmentKind( - at range: NSRange, - on page: RDEPUBTextPage - ) -> RDEPUBTextAttachmentKind? { - guard range.location >= 0, - range.location < page.chapterContent.length else { - return nil - } - let attributes = page.chapterContent.attributes(at: range.location, effectiveRange: nil) - return RDEPUBAttachmentNormalizer.attachmentKind(for: attributes) - } - -#endif -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBSelectionLoupeView.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBSelectionLoupeView.swift deleted file mode 100644 index d2d9cde..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBSelectionLoupeView.swift +++ /dev/null @@ -1,78 +0,0 @@ -import UIKit - -final class RDEPUBSelectionLoupeView: UIView { - - private let imageView = UIImageView() - - private let magnification: CGFloat = 1.45 - - private let captureSize = CGSize(width: 84, height: 84) - - override init(frame: CGRect) { - super.init(frame: CGRect(origin: .zero, size: CGSize(width: 96, height: 96))) - isUserInteractionEnabled = false - backgroundColor = .clear - layer.shadowColor = UIColor.black.cgColor - layer.shadowOpacity = 0.18 - layer.shadowRadius = 10 - layer.shadowOffset = CGSize(width: 0, height: 5) - - imageView.frame = bounds - imageView.autoresizingMask = [.flexibleWidth, .flexibleHeight] - imageView.layer.cornerRadius = bounds.width / 2 - imageView.layer.cornerCurve = .continuous - imageView.layer.borderWidth = 1.5 - imageView.layer.borderColor = UIColor(white: 0.82, alpha: 0.95).cgColor - imageView.clipsToBounds = true - addSubview(imageView) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func present(sourceView: UIView, focusPoint: CGPoint, hostBounds: CGRect, targetPoint: CGPoint) { - imageView.image = snapshot(from: sourceView, focusPoint: focusPoint) - - let targetCenter = CGPoint( - x: min(max(targetPoint.x, hostBounds.minX + bounds.width / 2), hostBounds.maxX - bounds.width / 2), - y: min( - max(hostBounds.minY + bounds.height / 2, targetPoint.y - 74), - hostBounds.maxY - bounds.height / 2 - ) - ) - - center = targetCenter - if isHidden { - alpha = 0 - transform = CGAffineTransform(scaleX: 0.92, y: 0.92) - isHidden = false - UIView.animate(withDuration: 0.12) { - self.alpha = 1 - self.transform = .identity - } - } - } - - func dismiss() { - guard !isHidden else { return } - isHidden = true - alpha = 0 - imageView.image = nil - } - - private func snapshot(from sourceView: UIView, focusPoint: CGPoint) -> UIImage { - let renderer = UIGraphicsImageRenderer(size: captureSize) - return renderer.image { context in - let cgContext = context.cgContext - cgContext.setFillColor(UIColor.systemBackground.cgColor) - cgContext.fill(CGRect(origin: .zero, size: captureSize)) - cgContext.translateBy( - x: captureSize.width / 2 - focusPoint.x * magnification, - y: captureSize.height / 2 - focusPoint.y * magnification - ) - cgContext.scaleBy(x: magnification, y: magnification) - sourceView.layer.render(in: cgContext) - } - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBSelectionOverlayView.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBSelectionOverlayView.swift deleted file mode 100644 index afd7e35..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBSelectionOverlayView.swift +++ /dev/null @@ -1,177 +0,0 @@ -import UIKit - -struct RDEPUBTextOverlayDecoration { - - enum Kind: String { - - case selection - - case highlight - - case underline - - case search - - case activeSearch - - case locate - } - - var kind: Kind - - var absoluteRange: NSRange - - var rects: [CGRect] - - var color: UIColor -} - -class RDEPUBSelectionOverlayView: UIView { - - private(set) var page: RDEPUBTextPage? - - private var snapshot: RDEPUBPageLayoutSnapshot? - - private(set) var selectionRange: NSRange? - - private var selectionRects: [CGRect] = [] - - private var decorations: [RDEPUBTextOverlayDecoration] = [] - - private let selectionVerticalAdjustment: CGFloat = -1 - - var selectionColor: UIColor = UIColor(red: 70 / 255, green: 140 / 255, blue: 1, alpha: 0.24) { - didSet { - setNeedsDisplay() - } - } - - override init(frame: CGRect) { - super.init(frame: frame) - backgroundColor = .clear - isOpaque = false - isUserInteractionEnabled = false - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func configure(page: RDEPUBTextPage, selectionColor: UIColor, snapshot: RDEPUBPageLayoutSnapshot? = nil) { - self.page = page - self.snapshot = snapshot - self.selectionColor = selectionColor - selectionRange = nil - decorations = [] - setNeedsDisplay() - } - - func updateSnapshot(_ snapshot: RDEPUBPageLayoutSnapshot?) { - self.snapshot = snapshot - } - - func updateSelection(absoluteRange: NSRange?) { - selectionRange = absoluteRange - selectionRects = [] - setNeedsDisplay() - } - - func updateSelection(absoluteRange: NSRange?, rects: [CGRect]) { - selectionRange = absoluteRange - selectionRects = rects - setNeedsDisplay() - } - - func applyDecorations(_ decorations: [RDEPUBTextOverlayDecoration]) { - self.decorations = decorations - setNeedsDisplay() - } - - func clearSelection() { - updateSelection(absoluteRange: nil) - } - - func absoluteRange(at point: CGPoint) -> NSRange? { - if let selectionRange, - selectionRects.contains(where: { $0.insetBy(dx: -4, dy: -4).contains(point) }) { - return selectionRange - } - - if let snapshot, - let absoluteRange = snapshot.absoluteRange(at: point, in: resolvedDecorations) { - return absoluteRange - } - - for decoration in resolvedDecorations { - for rect in decoration.rects { - if rect.insetBy(dx: -4, dy: -4).contains(point) { - return decoration.absoluteRange - } - } - } - return nil - } - - func decorationSummary() -> String { - let counts = Dictionary(grouping: resolvedDecorations, by: \.kind).mapValues(\.count) - let selectionLabel = selectionRange.map(NSStringFromRange) ?? "none" - let highlightCount = counts[.highlight, default: 0] - let underlineCount = counts[.underline, default: 0] - let searchCount = counts[.search, default: 0] - let activeSearchCount = counts[.activeSearch, default: 0] - let locateLabel = resolvedDecorations.first(where: { $0.kind == .locate }).map { NSStringFromRange($0.absoluteRange) } ?? "none" - return [ - "selection \(selectionLabel)", - "highlight \(highlightCount)", - "underline \(underlineCount)", - "search \(searchCount)", - "activeSearch \(activeSearchCount)", - "locate \(locateLabel)" - ].joined(separator: " · ") - } - - override func draw(_ rect: CGRect) { - guard let context = UIGraphicsGetCurrentContext() else { return } - - for decoration in resolvedDecorations { - switch decoration.kind { - case .underline: - context.setStrokeColor(decoration.color.cgColor) - context.setLineWidth(2) - for underlineRect in decoration.rects { - let y = underlineRect.maxY - 1 - context.move(to: CGPoint(x: underlineRect.minX, y: y)) - context.addLine(to: CGPoint(x: underlineRect.maxX, y: y)) - context.strokePath() - } - default: - context.setFillColor(decoration.color.cgColor) - for selectionRect in decoration.rects { - let adjustedRect = selectionRect.offsetBy(dx: 0, dy: selectionVerticalAdjustment) - let path = UIBezierPath(roundedRect: adjustedRect.insetBy(dx: -1, dy: -1), cornerRadius: 4) - context.addPath(path.cgPath) - context.fillPath() - } - } - } - } - - private var resolvedDecorations: [RDEPUBTextOverlayDecoration] { - guard page != nil else { return [] } - - var result = decorations.filter { !$0.rects.isEmpty } - - if let selectionRange, !selectionRects.isEmpty { - result.append( - RDEPUBTextOverlayDecoration( - kind: .selection, - absoluteRange: selectionRange, - rects: selectionRects, - color: selectionColor - ) - ) - } - - return result - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextAnnotationOverlay.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextAnnotationOverlay.swift deleted file mode 100644 index 11a5caf..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextAnnotationOverlay.swift +++ /dev/null @@ -1,135 +0,0 @@ -import UIKit - -final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView { - - private let normalSearchColor = UIColor(red: 0.21, green: 0.48, blue: 0.95, alpha: 0.16) - - private let activeSearchColor = UIColor(red: 0.14, green: 0.42, blue: 0.95, alpha: 0.34) - - func applyHighlights( - _ highlights: [RDEPUBHighlight], - to content: NSMutableAttributedString, - page: RDEPUBTextPage, - contentBaseOffset: Int - ) { - let pageRange = absoluteOffsetRange(for: page) - let pageStart = pageRange.lowerBound - let pageEndExclusive = pageRange.upperBound - - for highlight in highlights where highlight.location.href == page.href { - guard let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else { continue } - let overlapStart = max(range.location, pageStart) - let overlapEnd = min(range.location + range.length, pageEndExclusive) - guard overlapStart < overlapEnd else { continue } - - let relativeRange = NSRange( - location: overlapStart - contentBaseOffset, - length: overlapEnd - overlapStart - ) - switch highlight.style { - case .highlight: - content.addAttribute( - .backgroundColor, - value: UIColor(rdHexString: highlight.color, alpha: 0.35) ?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.35), - range: relativeRange - ) - case .underline: - content.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: relativeRange) - if let color = UIColor(rdHexString: highlight.color, alpha: 1) { - content.addAttribute(.underlineColor, value: color, range: relativeRange) - } - } - } - } - - func applySearchHighlights( - to content: NSMutableAttributedString, - page: RDEPUBTextPage, - searchState: RDEPUBSearchState?, - contentBaseOffset: Int - ) { - guard let searchState else { return } - - let pageRange = absoluteOffsetRange(for: page) - let pageStart = pageRange.lowerBound - let pageEndExclusive = pageRange.upperBound - - for match in searchState.matches { - guard let matchStart = match.rangeLocation else { continue } - let matchEnd = matchStart + match.rangeLength - let overlapStart = max(matchStart, pageStart) - let overlapEnd = min(matchEnd, pageEndExclusive) - guard overlapStart < overlapEnd else { continue } - - let relativeRange = NSRange(location: Int(overlapStart - contentBaseOffset), length: Int(overlapEnd - overlapStart)) - let color = match == searchState.currentMatch ? activeSearchColor : normalSearchColor - content.addAttribute(.backgroundColor, value: color, range: relativeRange) - } - } - - func buildDecorations( - page: RDEPUBTextPage, - highlights: [RDEPUBHighlight], - searchState: RDEPUBSearchState?, - interactionController: RDEPUBPageInteractionController - ) -> (background: [RDEPUBTextOverlayDecoration], foreground: [RDEPUBTextOverlayDecoration]) { - var background: [RDEPUBTextOverlayDecoration] = [] - var foreground: [RDEPUBTextOverlayDecoration] = [] - let pageRange = absoluteOffsetRange(for: page) - let pageStart = pageRange.lowerBound - let pageEndExclusive = pageRange.upperBound - - if let searchState { - for match in searchState.matches { - guard let matchStart = match.rangeLocation else { continue } - let matchEnd = matchStart + match.rangeLength - let overlapStart = max(matchStart, pageStart) - let overlapEnd = min(matchEnd, pageEndExclusive) - guard overlapStart < overlapEnd else { continue } - - let absoluteRange = NSRange(location: overlapStart, length: overlapEnd - overlapStart) - let rects = interactionController.selectionRects(for: absoluteRange) - guard !rects.isEmpty else { continue } - - let isActive = match == searchState.currentMatch - let kind: RDEPUBTextOverlayDecoration.Kind = isActive ? .activeSearch : .search - let color = isActive ? activeSearchColor : normalSearchColor - background.append(RDEPUBTextOverlayDecoration(kind: kind, absoluteRange: absoluteRange, rects: rects, color: color)) - } - } - - for highlight in highlights where highlight.location.href == page.href { - guard let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else { continue } - let overlapStart = max(range.location, pageStart) - let overlapEnd = min(range.location + range.length, pageEndExclusive) - guard overlapStart < overlapEnd else { continue } - - let absoluteRange = NSRange(location: overlapStart, length: overlapEnd - overlapStart) - let rects = interactionController.selectionRects(for: absoluteRange) - guard !rects.isEmpty else { continue } - - let color = UIColor(rdHexString: highlight.color, alpha: 0.35) - ?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.35) - let decoration = RDEPUBTextOverlayDecoration( - kind: highlight.style == .underline ? .underline : .highlight, - absoluteRange: absoluteRange, - rects: rects, - color: color - ) - - if decoration.kind == .underline { - foreground.append(decoration) - } else { - background.append(decoration) - } - } - - return (background, foreground) - } - - private func absoluteOffsetRange(for page: RDEPUBTextPage) -> Range { - let lowerBound = page.pageStartOffset - let upperBound = page.pageEndOffset + 1 - return lowerBound.. Bool - let currentSelectionProvider: () -> RDEPUBSelection? - let isSelectionControllerSelecting: () -> Bool - let hasActiveSelection: () -> Bool - let selectionHandleAtPoint: (CGPoint) -> RDEPUBTextSelectionController.BoundaryHandle? - let selectionContainsPoint: (CGPoint) -> Bool - let renderPointForGesture: (UIGestureRecognizer) -> CGPoint - let renderPointForTouch: (UITouch) -> CGPoint - let performLongPressSelection: (UILongPressGestureRecognizer) -> Void - let performPanSelection: (UIPanGestureRecognizer) -> Void - let adjustSelection: (RDEPUBTextSelectionController.BoundaryHandle, CGPoint) -> Void - let presentLoupeAtPoint: (CGPoint) -> Void - let dismissLoupe: () -> Void - let showSelectionMenu: () -> Void - let hideSelectionMenu: () -> Void - let selectionTapSuppressionDidChange: (Bool) -> Void - let selectionPagingSuppressionDidChange: (Bool) -> Void - } - - private let dependencies: Dependencies - - private var activeSelectionHandle: RDEPUBTextSelectionController.BoundaryHandle? - - private(set) var interactionState: SelectionInteractionState = .idle - - var isInteractionInProgress: Bool { - interactionState != .idle - } - - init(dependencies: Dependencies) { - self.dependencies = dependencies - super.init() - } - - func handleLongPress(_ gesture: UILongPressGestureRecognizer) { - dependencies.performLongPressSelection(gesture) - - switch gesture.state { - case .began: - activeSelectionHandle = nil - updateSelectionInteractionState(.selecting) - dependencies.hideSelectionMenu() - dependencies.presentLoupeAtPoint(dependencies.renderPointForGesture(gesture)) - case .changed: - updateSelectionInteractionState(.selecting) - dependencies.presentLoupeAtPoint(dependencies.renderPointForGesture(gesture)) - case .ended: - activeSelectionHandle = nil - dependencies.dismissLoupe() - dependencies.showSelectionMenu() - case .cancelled, .failed: - activeSelectionHandle = nil - dependencies.dismissLoupe() - default: - break - } - } - - func handlePan(_ gesture: UIPanGestureRecognizer) { - let point = dependencies.renderPointForGesture(gesture) - - if gesture.state == .began, activeSelectionHandle == nil, - let handle = dependencies.selectionHandleAtPoint(point) { - activeSelectionHandle = handle - updateSelectionInteractionState(.adjustingHandle) - dependencies.hideSelectionMenu() - dependencies.presentLoupeAtPoint(point) - } - - if let activeSelectionHandle { - dependencies.adjustSelection(activeSelectionHandle, point) - dependencies.presentLoupeAtPoint(point) - } else { - dependencies.performPanSelection(gesture) - if dependencies.isSelectionControllerSelecting() { - dependencies.presentLoupeAtPoint(point) - } - } - - switch gesture.state { - case .ended: - activeSelectionHandle = nil - updateSelectionInteractionState( - dependencies.currentSelectionProvider() == nil ? .idle : .selectionActive - ) - dependencies.dismissLoupe() - dependencies.showSelectionMenu() - case .cancelled, .failed: - activeSelectionHandle = nil - updateSelectionInteractionState( - dependencies.currentSelectionProvider() == nil ? .idle : .selectionActive - ) - dependencies.dismissLoupe() - default: - break - } - } - - func selectionControllerStateDidChange(_ state: RDEPUBTextSelectionController.InteractionState) { - switch state { - case .idle: - if dependencies.currentSelectionProvider() == nil, activeSelectionHandle == nil { - updateSelectionInteractionState(.idle) - } - case .selecting: - updateSelectionInteractionState(.selecting) - case .selectionActive: - updateSelectionInteractionState( - dependencies.currentSelectionProvider() == nil ? .idle : .selectionActive - ) - case .adjustingHandle: - updateSelectionInteractionState(.adjustingHandle) - } - } - - func touchesBegan(_ touches: Set, with event: UIEvent?) { - guard interactionState == .idle, - activeSelectionHandle == nil, - dependencies.hasRenderableContent(), - let touch = touches.first else { - return - } - - let point = dependencies.renderPointForTouch(touch) - guard dependencies.selectionHandleAtPoint(point) == nil else { return } - updateSelectionInteractionState(.selectionPending) - } - - func touchesEnded(_ touches: Set, with event: UIEvent?) { - resetSelectionPendingIfNeeded() - } - - func touchesCancelled(_ touches: Set, with event: UIEvent?) { - resetSelectionPendingIfNeeded() - } - - func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { - if gestureRecognizer is UIPanGestureRecognizer { - if dependencies.isSelectionControllerSelecting() { - return true - } - let point = dependencies.renderPointForGesture(gestureRecognizer) - return dependencies.selectionHandleAtPoint(point) != nil - } - - if gestureRecognizer is UILongPressGestureRecognizer { - guard dependencies.hasRenderableContent() else { - return true - } - let point = dependencies.renderPointForGesture(gestureRecognizer) - if dependencies.selectionHandleAtPoint(point) != nil { - return false - } - if dependencies.hasActiveSelection(), dependencies.selectionContainsPoint(point) { - return false - } - return true - } - - return true - } - - func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { - guard dependencies.hasRenderableContent() else { - return true - } - - let point = dependencies.renderPointForTouch(touch) - if let handle = dependencies.selectionHandleAtPoint(point) { - if gestureRecognizer is UIPanGestureRecognizer { - activeSelectionHandle = handle - updateSelectionInteractionState(.adjustingHandle) - dependencies.hideSelectionMenu() - return true - } - if gestureRecognizer is UILongPressGestureRecognizer || gestureRecognizer is UITapGestureRecognizer { - return false - } - } - - return true - } - - func gestureRecognizer( - _ gestureRecognizer: UIGestureRecognizer, - shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer - ) -> Bool { - gestureRecognizer is UILongPressGestureRecognizer || gestureRecognizer is UIPanGestureRecognizer - } - - func reset() { - activeSelectionHandle = nil - updateSelectionInteractionState(.idle) - dependencies.dismissLoupe() - } - - private func resetSelectionPendingIfNeeded() { - guard interactionState == .selectionPending else { return } - if dependencies.currentSelectionProvider() != nil { - updateSelectionInteractionState(.selectionActive) - } else { - updateSelectionInteractionState(.idle) - } - } - - private func updateSelectionInteractionState(_ state: SelectionInteractionState) { - let previousTapSuppressed = interactionState != .idle - let previousPagingSuppressed = shouldSuppressPagingInteraction(for: interactionState) - let previousState = interactionState - interactionState = state - let currentTapSuppressed = interactionState != .idle - let currentPagingSuppressed = shouldSuppressPagingInteraction(for: interactionState) - if previousState != state { - RDEpubReaderTapDebug.log( - "TextContentInteraction.state", - "transition \(previousState) -> \(state) tapSuppressed=\(currentTapSuppressed) pagingSuppressed=\(currentPagingSuppressed)" - ) - } - if previousTapSuppressed != currentTapSuppressed { - dependencies.selectionTapSuppressionDidChange(currentTapSuppressed) - } - if previousPagingSuppressed != currentPagingSuppressed { - dependencies.selectionPagingSuppressionDidChange(currentPagingSuppressed) - } - } - - private func shouldSuppressPagingInteraction(for state: SelectionInteractionState) -> Bool { - switch state { - case .idle, .selectionPending, .selectionActive: - return false - case .selecting, .adjustingHandle: - return true - } - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift deleted file mode 100644 index 99c87e9..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift +++ /dev/null @@ -1,1171 +0,0 @@ -import UIKit -import Foundation - -#if canImport(DTCoreText) -import DTCoreText -#endif - -protocol RDEPUBTextContentViewDelegate: AnyObject { - func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?) - func textContentView(_ contentView: RDEPUBTextContentView, didRequestReaderTapAt point: CGPoint) - func textContentView( - _ contentView: RDEPUBTextContentView, - didRequestSelectionAction action: RDEPUBAnnotationMenuAction, - selection: RDEPUBSelection? - ) - func textContentView( - _ contentView: RDEPUBTextContentView, - didActivateAttachmentText text: String, - sourceRect: CGRect, - sourcePoint: CGPoint - ) - - func textContentView( - _ contentView: RDEPUBTextContentView, - didActivateImage image: UIImage, - sourceRect: CGRect, - altText: String? - ) - func textContentView( - _ contentView: RDEPUBTextContentView, - didRequestHighlightActions highlight: RDEPUBHighlight, - sourceRect: CGRect - ) -} - -extension RDEPUBTextContentViewDelegate { - func textContentView( - _ contentView: RDEPUBTextContentView, - didActivateImage image: UIImage, - sourceRect: CGRect, - altText: String? - ) { - // Default: no-op. Implementors can present an image viewer. - } -} - -final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDEpubReaderCachePolicyProviding { - - private static let pageNumberTrailingPadding: CGFloat = 4 - private static let pageNumberFooterPadding: CGFloat = 8 - private static let pageNumberReservedHeight = ceil(UIFont.systemFont(ofSize: 13).lineHeight) + pageNumberFooterPadding - - private var contentInsets: UIEdgeInsets = .zero - - private var currentPage: RDEPUBTextPage? - private var currentChapterCFIMap: RDEPUBCFIMap? - private var currentChapterFragmentOffsets: [String: Int] = [:] - - private var currentSelection: RDEPUBSelection? - - private var menuSelection: RDEPUBSelection? - - // M-15: Backing store for UIEditMenuInteraction (iOS 16+). - // Stored as Any? to avoid @available on stored property restriction. - private var _editMenuInteraction: Any? - - private let selectionLoupeView = RDEPUBSelectionLoupeView() - - private var currentHighlights: [RDEPUBHighlight] = [] - - private var currentSearchState: RDEPUBSearchState? - - weak var delegate: RDEPUBTextContentViewDelegate? - - var selectionTapSuppressionDidChange: ((Bool) -> Void)? - - var selectionPagingSuppressionDidChange: ((Bool) -> Void)? - -#if canImport(DTCoreText) - - private let coreTextContentView: RDEPUBTextPageRenderView = { - let view = RDEPUBTextPageRenderView() - view.backgroundColor = .clear - view.isOpaque = false - view.isAccessibilityElement = true - view.accessibilityTraits = .staticText - return view - }() - - /// Chapter-level shared display string owned by the display cache. - /// Read-only from the page's perspective: theme color and dark-image - /// adjustment are baked in at build time; page decoration goes through - /// the overlay views. - private var coreTextDisplayContent: NSAttributedString? - - private var coreTextDisplayRange: NSRange? - - /// Shared chapter-level layouter (owned by the display cache); layout - /// frames are recomputed per page range and bounds change. - private var coreTextLayouter: DTCoreTextLayouter? -#endif - - private let interactionController = RDEPUBPageInteractionController() - - private let selectionController = RDEPUBTextSelectionController() - - private let backgroundOverlayView: RDEPUBTextPageDecorationView = { - let view = RDEPUBTextPageDecorationView() - return view - }() - - private let overlayView: RDEPUBTextAnnotationOverlay = { - let view = RDEPUBTextAnnotationOverlay() - return view - }() - - private let coverImageView: UIImageView = { - let view = UIImageView() - view.contentMode = .scaleAspectFit - view.isHidden = true - return view - }() - - private let pageNumberLabel: UILabel = { - let label = UILabel() - label.font = UIFont.systemFont(ofSize: 13) - return label - }() - - private let loadingSpinner: UIActivityIndicatorView = { - let spinner = UIActivityIndicatorView(style: .medium) - spinner.hidesWhenStopped = true - spinner.accessibilityIdentifier = "epub.reader.loadingSpinner" - return spinner - }() - -#if DEBUG - /// Debug-only outline of the readable content area (bounds inset by contentInsets). - private let debugContentAreaBorderView: UIView = { - let view = UIView() - view.isUserInteractionEnabled = false - view.backgroundColor = .clear - view.layer.borderColor = UIColor.systemRed.withAlphaComponent(0.6).cgColor - view.layer.borderWidth = 1 - view.accessibilityIdentifier = "epub.reader.debug.contentAreaBorder" - return view - }() -#endif - - private lazy var longPressGestureRecognizer: UILongPressGestureRecognizer = { - let gesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:))) - gesture.minimumPressDuration = 0.5 - gesture.delegate = self - return gesture - }() - - private lazy var panGestureRecognizer: UIPanGestureRecognizer = { - let gesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:))) - gesture.isEnabled = false - gesture.delegate = self - return gesture - }() - - private lazy var tapGestureRecognizer: UITapGestureRecognizer = { - let gesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:))) - gesture.delegate = self - return gesture - }() - - private lazy var interactionCoordinator: RDEPUBTextContentInteractionCoordinator = { - RDEPUBTextContentInteractionCoordinator( - dependencies: .init( - hasRenderableContent: { [weak self] in - self?.hasInteractiveTextContent ?? false - }, - currentSelectionProvider: { [weak self] in self?.currentSelection }, - isSelectionControllerSelecting: { [weak self] in - self?.selectionController.isSelecting ?? false - }, - hasActiveSelection: { [weak self] in - self?.selectionController.hasActiveSelection ?? false - }, - selectionHandleAtPoint: { [weak self] point in - guard let renderView = self?.coreTextRenderView else { return nil } - let handle = renderView.selectionHandle(at: point) - switch handle { - case .start: - return .start - case .end: - return .end - case nil: - return nil - } - }, - selectionContainsPoint: { [weak self] point in - self?.coreTextRenderView?.selectionContains(point) ?? false - }, - renderPointForGesture: { [weak self] gesture in - guard let self else { return .zero } - return gesture.location(in: self.coreTextRenderView ?? self) - }, - renderPointForTouch: { [weak self] touch in - guard let self else { return .zero } - return touch.location(in: self.coreTextRenderView ?? self) - }, - performLongPressSelection: { [weak self] gesture in - guard let self else { return } - self.layoutIfNeeded() - self.selectionController.handleLongPress( - gesture, - renderView: self.coreTextRenderView, - interactionController: self.interactionController - ) - }, - performPanSelection: { [weak self] gesture in - guard let self else { return } - self.layoutIfNeeded() - self.selectionController.handlePan( - gesture, - renderView: self.coreTextRenderView, - interactionController: self.interactionController - ) - }, - adjustSelection: { [weak self] handle, point in - guard let self else { return } - self.selectionController.updateSelection( - byAdjusting: handle, - at: point, - renderView: self.coreTextRenderView, - interactionController: self.interactionController - ) - }, - presentLoupeAtPoint: { [weak self] point in - self?.updateSelectionLoupe(for: point) - }, - dismissLoupe: { [weak self] in - self?.selectionLoupeView.dismiss() - }, - showSelectionMenu: { [weak self] in - self?.showSelectionMenuIfNeeded() - }, - hideSelectionMenu: { [weak self] in - self?.hideSelectionMenu() - }, - selectionTapSuppressionDidChange: { [weak self] isSuppressed in - self?.selectionTapSuppressionDidChange?(isSuppressed) - }, - selectionPagingSuppressionDidChange: { [weak self] isSuppressed in - self?.selectionPagingSuppressionDidChange?(isSuppressed) - } - ) - ) - }() - - override init(frame: CGRect) { - super.init(frame: frame) - accessibilityIdentifier = "epub.reader.content.view" - addSubview(coverImageView) -#if canImport(DTCoreText) - addSubview(backgroundOverlayView) - addSubview(coreTextContentView) -#endif - addSubview(overlayView) - addSubview(pageNumberLabel) - addSubview(loadingSpinner) - addSubview(selectionLoupeView) -#if DEBUG - addSubview(debugContentAreaBorderView) -#endif - addGestureRecognizer(longPressGestureRecognizer) - addGestureRecognizer(panGestureRecognizer) - addGestureRecognizer(tapGestureRecognizer) - tapGestureRecognizer.require(toFail: longPressGestureRecognizer) - selectionLoupeView.isHidden = true - - // M-15: Use UIEditMenuInteraction on iOS 16+ to replace deprecated UIMenuController - if #available(iOS 16.0, *) { - let interaction = UIEditMenuInteraction(delegate: self) - addInteraction(interaction) - self._editMenuInteraction = interaction - } - - selectionController.onSelectionChanged = { [weak self] selection in - guard let self else { return } - self.currentSelection = selection - if let selection { - self.menuSelection = selection - } else { - self.hideSelectionMenu() - } - self.updateSelectionPanAvailability() - self.updateViewInteractionAvailability() - self.updateAccessibilityDecorationSummary() - self.delegate?.textContentView(self, didChangeSelection: selection) - } - selectionController.interactionStateDidChange = { [weak self] state in - guard let self else { return } - self.interactionCoordinator.selectionControllerStateDidChange(state) - self.updateSelectionPanAvailability() - self.updateViewInteractionAvailability() - } - selectionController.pageProvider = { [weak self] in self?.currentPage } - selectionController.chapterCFIMapProvider = { [weak self] in self?.currentChapterCFIMap } - selectionController.chapterFragmentOffsetsProvider = { [weak self] in - self?.currentChapterFragmentOffsets ?? [:] - } - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override var canBecomeFirstResponder: Bool { true } - - var selectionLongPressGestureRecognizer: UILongPressGestureRecognizer { - longPressGestureRecognizer - } - - var isSelectionInteractionInProgress: Bool { - interactionCoordinator.isInteractionInProgress - } - - var shouldAvoidReaderPageCaching: Bool { -#if canImport(DTCoreText) - // In-context text layout keeps a chapter-length attributed copy and - // layouter alive for the visible page. Avoid reader-level page - // caching so preloading does not retain several full-chapter - // display copies at once. - // Do NOT relax this until chapter-level shared display content - // lands (LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md P1-1): cached - // pages would each pin their own full-chapter copy again. - return currentPage == nil - || loadingSpinner.isAnimating - || coreTextDisplayContent != nil -#else - currentPage == nil - || loadingSpinner.isAnimating -#endif - } - - private var hasInteractiveTextContent: Bool { -#if canImport(DTCoreText) - currentPage != nil && coreTextRenderView?.isHidden == false -#else - currentPage != nil -#endif - } - - override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { - action == #selector(rd_copy(_:)) - || action == #selector(rd_highlight(_:)) - || action == #selector(rd_annotate(_:)) - } - - @objc func rd_copy(_ sender: Any?) { - performSelectionAction(.copy) - } - - @objc func rd_highlight(_ sender: Any?) { - performSelectionAction(.highlight) - } - - @objc func rd_annotate(_ sender: Any?) { - performSelectionAction(.annotate) - } - - override func layoutSubviews() { - super.layoutSubviews() - -#if canImport(DTCoreText) - backgroundOverlayView.frame = bounds.inset(by: contentInsets) - coreTextContentView.frame = bounds.inset(by: contentInsets) - updateCoreTextLayoutFrameIfNeeded() -#endif - overlayView.frame = bounds.inset(by: contentInsets) - coverImageView.frame = bounds.inset(by: contentInsets) - - let contentRect = bounds.inset(by: contentInsets) -#if DEBUG - debugContentAreaBorderView.frame = contentRect -#endif - let labelSize = pageNumberLabel.sizeThatFits( - CGSize(width: contentRect.width, height: Self.pageNumberReservedHeight) - ) - let footerOriginY = bounds.maxY - contentInsets.bottom - let footerVerticalInset = max((contentInsets.bottom - labelSize.height) / 2, 0) - pageNumberLabel.frame = CGRect( - x: bounds.maxX - contentInsets.right - labelSize.width - Self.pageNumberTrailingPadding, - y: footerOriginY + footerVerticalInset, - width: labelSize.width, - height: labelSize.height - ) - loadingSpinner.center = CGPoint(x: bounds.midX, y: bounds.midY) - } - - func configure( - page: RDEPUBTextPage, - pageNumber: Int, - totalPages: Int, - configuration: RDEPUBReaderConfiguration, - chapterCFIMap: RDEPUBCFIMap? = nil, - chapterFragmentOffsets: [String: Int] = [:], - highlights: [RDEPUBHighlight] = [], - searchState: RDEPUBSearchState? = nil, - displayCache: RDEPUBChapterDisplayContentCache? = nil - ) { - loadingSpinner.stopAnimating() - currentPage = page - currentChapterCFIMap = chapterCFIMap - currentChapterFragmentOffsets = chapterFragmentOffsets - currentSelection = nil - menuSelection = nil - interactionCoordinator.reset() - currentHighlights = highlights - currentSearchState = searchState - selectionController.clearSelection(renderView: coreTextRenderView) - contentInsets = Self.safeContentInsets( - configuration: configuration, - safeAreaInsets: RDEPUBSafeArea.resolve(safeAreaInsets) - ) - backgroundColor = configuration.theme.contentBackgroundColor - pageNumberLabel.textColor = configuration.theme.contentTextColor - pageNumberLabel.text = "\(pageNumber) / \(totalPages)" - - if configureCoverIfNeeded(for: page) { -#if canImport(DTCoreText) - coreTextContentView.isHidden = true - coreTextContentView.layoutFrame = nil - coreTextDisplayContent = nil - coreTextDisplayRange = nil - coreTextLayouter = nil -#endif - updateStaticGestureAvailability() - updateSelectionPanAvailability() - updateViewInteractionAvailability() - delegate?.textContentView(self, didChangeSelection: nil) - setNeedsLayout() - return - } - - coverImageView.isHidden = true - coverImageView.image = nil - -#if canImport(DTCoreText) - // In-context display: lay out the full chapter string and render only - // the page's range. CTTypesetter line breaks are context-sensitive, so - // re-wrapping a page substring can break ±1 character away from the - // paginator's line ends (lone characters spilling onto the last line); - // laying out the same chapter string the paginator used cannot. - // String indices in the layout frame are therefore chapter-absolute. - // The chapter string and layouter are shared per chapter via the - // display cache; this view must not mutate the shared string. - let display = (displayCache ?? RDEPUBChapterDisplayContentCache()) - .entry(for: page, configuration: configuration) - let pageRange = NSIntersectionRange( - page.contentRange, - NSRange(location: 0, length: display.content.length) - ) - coreTextContentView.isHidden = false - coreTextContentView.backgroundColor = .clear - coreTextContentView.accessibilityIdentifier = "epub.reader.selection.text" - coreTextDisplayContent = display.content - coreTextDisplayRange = pageRange - coreTextLayouter = display.layouter - updateCoreTextLayoutFrameIfNeeded() -#else - let selectionContent = normalizedPageContent(from: page) - let selectionRange = NSRange(location: 0, length: selectionContent.length) - selectionContent.addAttribute( - .foregroundColor, - value: configuration.theme.contentTextColor, - range: selectionRange - ) - overlayView.applyHighlights(highlights, to: selectionContent, page: page, contentBaseOffset: page.pageStartOffset) - overlayView.applySearchHighlights(to: selectionContent, page: page, searchState: searchState, contentBaseOffset: page.pageStartOffset) -#endif - - overlayView.configure(page: page, selectionColor: overlayView.selectionColor, snapshot: interactionController.snapshot) -#if canImport(DTCoreText) - backgroundOverlayView.configure(page: page, selectionColor: overlayView.selectionColor, snapshot: interactionController.snapshot) -#endif - updateStaticGestureAvailability() - updateSelectionPanAvailability() - updateViewInteractionAvailability() - updateAccessibilityDecorationSummary() - - delegate?.textContentView(self, didChangeSelection: nil) - setNeedsLayout() - } - - func configureLoading( - pageNumber: Int, - totalPages: Int, - configuration: RDEPUBReaderConfiguration - ) { - currentPage = nil - currentChapterCFIMap = nil - currentChapterFragmentOffsets = [:] - currentSelection = nil - menuSelection = nil - interactionCoordinator.reset() - currentHighlights = [] - currentSearchState = nil - selectionController.clearSelection(renderView: coreTextRenderView) - contentInsets = Self.safeContentInsets( - configuration: configuration, - safeAreaInsets: RDEPUBSafeArea.resolve(safeAreaInsets) - ) - backgroundColor = configuration.theme.contentBackgroundColor - pageNumberLabel.textColor = configuration.theme.contentTextColor - pageNumberLabel.text = "\(pageNumber) / \(totalPages)" - coverImageView.isHidden = true - coverImageView.image = nil - -#if canImport(DTCoreText) - coreTextContentView.isHidden = false - coreTextContentView.backgroundColor = .clear - coreTextContentView.layoutFrame = nil - coreTextDisplayContent = nil - coreTextDisplayRange = nil - coreTextLayouter = nil -#endif - - overlayView.clearSelection() - backgroundOverlayView.clearSelection() - updateStaticGestureAvailability() - updateSelectionPanAvailability() - updateViewInteractionAvailability() - delegate?.textContentView(self, didChangeSelection: nil) - loadingSpinner.startAnimating() - setNeedsLayout() - } - - func clearSelection() { - currentSelection = nil - menuSelection = nil - currentSearchState = nil - interactionCoordinator.reset() - selectionController.clearSelection(renderView: coreTextRenderView) - overlayView.clearSelection() - backgroundOverlayView.clearSelection() - updateSelectionPanAvailability() - updateViewInteractionAvailability() - updateAccessibilityDecorationSummary() - hideSelectionMenu() - } - - private func performSelectionAction(_ action: RDEPUBAnnotationMenuAction) { - let selection = currentSelection ?? menuSelection - delegate?.textContentView(self, didRequestSelectionAction: action, selection: selection) - menuSelection = nil - hideSelectionMenu() - } - - private func configureCoverIfNeeded(for page: RDEPUBTextPage) -> Bool { - guard page.pageIndexInChapter == 0, - page.href.lowercased().contains("cover"), - let image = coverImage(from: page.content) else { - return false - } - - coverImageView.image = image - coverImageView.isHidden = false -#if canImport(DTCoreText) - coreTextContentView.isHidden = true - coreTextContentView.layoutFrame = nil - coreTextDisplayContent = nil - coreTextDisplayRange = nil - coreTextLayouter = nil -#endif - return true - } - - private func coverImage(from content: NSAttributedString) -> UIImage? { - guard content.length > 0 else { return nil } - var resolvedImage: UIImage? - content.enumerateAttribute(.attachment, in: NSRange(location: 0, length: content.length)) { value, _, stop in - guard let image = image(from: value) else { return } - resolvedImage = image - stop.pointee = true - } - return resolvedImage - } - - private func image(from attachmentValue: Any?) -> UIImage? { -#if canImport(DTCoreText) - // 附件本体已持有图片时(含加密书解密后的图)优先使用,避免二次读盘或误读密文 - if let attachment = attachmentValue as? DTImageTextAttachment, - let image = attachment.image { - return image - } - if let attachment = attachmentValue as? DTTextAttachment, - let url = attachment.contentURL { - return UIImage(contentsOfFile: url.path) - } -#endif - if let attachment = attachmentValue as? NSTextAttachment { - if let image = attachment.image { return image } - if let data = attachment.contents { return UIImage(data: data) } - if let fileWrapper = attachment.fileWrapper, - let data = fileWrapper.regularFileContents { return UIImage(data: data) } - } - return nil - } - - private func imageFromPage(attachment: RDEPUBPageAttachment, page: RDEPUBTextPage) -> UIImage? { - guard page.chapterContent.length > attachment.stringRange.location else { return nil } - let attachmentValue = page.chapterContent.attribute( - .attachment, - at: attachment.stringRange.location, - effectiveRange: nil - ) - return image(from: attachmentValue) - } - - private func normalizedPageContent(from page: RDEPUBTextPage) -> NSMutableAttributedString { - let content = NSMutableAttributedString(attributedString: page.content) - guard content.length > 0 else { return content } - let text = content.string as NSString - let firstParagraphRange = text.paragraphRange(for: NSRange(location: 0, length: 0)) - guard firstParagraphRange.length > 0 else { return content } - - if shouldNormalizeLeadingParagraphSpacing(for: page) { - let leadingRange = firstNonWhitespaceParagraphRange(in: content) ?? firstParagraphRange - updateParagraphStyle(in: content, range: leadingRange) { style in - style.paragraphSpacingBefore = 0 - } - } - - guard shouldNormalizeContinuationParagraph(for: page), - !firstParagraphUsesNonLeadingAlignment(in: content, range: firstParagraphRange) else { - return content - } - - updateParagraphStyle(in: content, range: firstParagraphRange) { style in - style.firstLineHeadIndent = style.headIndent - style.paragraphSpacingBefore = 0 - } - return content - } - - private func shouldNormalizeLeadingParagraphSpacing(for page: RDEPUBTextPage) -> Bool { - page.pageStartOffset == 0 - } - - private func firstNonWhitespaceParagraphRange(in content: NSAttributedString) -> NSRange? { - let text = content.string as NSString - var index = 0 - while index < text.length { - guard let scalar = UnicodeScalar(text.character(at: index)) else { break } - if !CharacterSet.whitespacesAndNewlines.contains(scalar) { - return text.paragraphRange(for: NSRange(location: index, length: 0)) - } - index += 1 - } - return nil - } - - private func shouldNormalizeContinuationParagraph(for page: RDEPUBTextPage) -> Bool { - let pageStart = page.pageStartOffset - guard pageStart > 0, pageStart < page.chapterContent.length else { return false } - let chapterText = page.chapterContent.string as NSString - guard let previousScalar = UnicodeScalar(chapterText.character(at: pageStart - 1)) else { return false } - return !CharacterSet.newlines.contains(previousScalar) - } - - private func updateParagraphStyle( - in content: NSMutableAttributedString, - range: NSRange, - transform: (NSMutableParagraphStyle) -> Void - ) { - content.enumerateAttribute(.paragraphStyle, in: range) { value, attributeRange, _ in - guard let style = value as? NSParagraphStyle else { return } - let mutableStyle = (style.mutableCopy() as? NSMutableParagraphStyle) ?? NSMutableParagraphStyle() - transform(mutableStyle) - content.addAttribute(.paragraphStyle, value: mutableStyle.copy() as Any, range: attributeRange) - } - } - - - private func firstParagraphUsesNonLeadingAlignment( - in content: NSAttributedString, - range: NSRange - ) -> Bool { - var usesNonLeadingAlignment = false - content.enumerateAttribute(.paragraphStyle, in: range) { value, _, stop in - guard let style = value as? NSParagraphStyle else { return } - if style.alignment == .right || style.alignment == .center { - usesNonLeadingAlignment = true - stop.pointee = true - } - } - return usesNonLeadingAlignment - } - -#if canImport(DTCoreText) - - private func updateCoreTextLayoutFrameIfNeeded() { - guard !coreTextContentView.isHidden, - let displayContent = coreTextDisplayContent, - let displayRange = coreTextDisplayRange, - let page = currentPage, - coreTextContentView.bounds.width > 0, - coreTextContentView.bounds.height > 0 else { - interactionController.configure(layoutFrame: nil, page: currentPage) - return - } - - guard let layouter = coreTextLayouter else { - coreTextContentView.layoutFrame = nil - interactionController.configure(layoutFrame: nil, page: page) - return - } - let layoutFrame = layouter.layoutFrame(with: coreTextContentView.bounds, range: displayRange) - if let layoutFrame { - RDEPUBTextPageBoundaryValidator.validate( - page: page, - displayLayoutFrame: layoutFrame, - displayContent: displayContent, - displayBounds: coreTextContentView.bounds - ) - RDEPUBTextPageVerticalJustifier.justify( - layoutFrame, - contentHeight: coreTextContentView.bounds.height, - isChapterLastPage: page.pageIndexInChapter >= page.totalPagesInChapter - 1, - pixelScale: window?.screen.scale ?? UIScreen.main.scale - ) - } - coreTextContentView.layoutFrame = layoutFrame - interactionController.configure(layoutFrame: layoutFrame, page: page) - overlayView.updateSnapshot(interactionController.snapshot) - backgroundOverlayView.updateSnapshot(interactionController.snapshot) - - if let page = currentPage { - // Highlights/underlines and search are all drawn as overlay - // decorations; the shared chapter display string carries no - // page-level annotation attributes. - let (bgDecorations, fgDecorations) = overlayView.buildDecorations( - page: page, - highlights: currentHighlights, - searchState: currentSearchState, - interactionController: interactionController - ) - backgroundOverlayView.applyDecorations(bgDecorations) - overlayView.applyDecorations(fgDecorations) - } - } -#endif - - @objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) { - interactionCoordinator.handleLongPress(gesture) - } - - @objc private func handlePan(_ gesture: UIPanGestureRecognizer) { - interactionCoordinator.handlePan(gesture) - } - - private func updateSelectionPanAvailability() { - let shouldEnablePan = selectionController.isSelecting - || selectionController.hasActiveSelection - || interactionCoordinator.interactionState == .adjustingHandle - guard panGestureRecognizer.isEnabled != shouldEnablePan else { return } - panGestureRecognizer.isEnabled = shouldEnablePan - } - - private func updateViewInteractionAvailability() { - let shouldEnableInteraction = hasInteractiveTextContent - || selectionController.isSelecting - || selectionController.hasActiveSelection - || interactionCoordinator.interactionState != .idle - guard isUserInteractionEnabled != shouldEnableInteraction else { return } - isUserInteractionEnabled = shouldEnableInteraction - RDEpubReaderTapDebug.log( - "TextContentView.interactionAvailability", - "updated isUserInteractionEnabled=\(shouldEnableInteraction) currentPage=\(currentPage?.absolutePageIndex ?? -1) loading=\(loadingSpinner.isAnimating) selecting=\(selectionController.isSelecting) hasSelection=\(selectionController.hasActiveSelection) state=\(interactionCoordinator.interactionState)" - ) - } - - private func updateStaticGestureAvailability() { - let shouldEnableLongPress = hasInteractiveTextContent && loadingSpinner.isAnimating == false - let shouldEnableTap = hasInteractiveTextContent && loadingSpinner.isAnimating == false - let longPressChanged = longPressGestureRecognizer.isEnabled != shouldEnableLongPress - let tapChanged = tapGestureRecognizer.isEnabled != shouldEnableTap - guard longPressChanged || tapChanged else { return } - longPressGestureRecognizer.isEnabled = shouldEnableLongPress - tapGestureRecognizer.isEnabled = shouldEnableTap - RDEpubReaderTapDebug.log( - "TextContentView.gestureAvailability", - "updated longPressEnabled=\(shouldEnableLongPress) tapEnabled=\(shouldEnableTap) hasInteractiveTextContent=\(hasInteractiveTextContent) loading=\(loadingSpinner.isAnimating) currentPage=\(currentPage?.absolutePageIndex ?? -1)" - ) - } - - @objc private func handleTap(_ gesture: UITapGestureRecognizer) { - let point = gesture.location(in: overlayView) - RDEpubReaderTapDebug.log( - "TextContentView.handleTap", - "received point=\(RDEpubReaderTapDebug.describe(point)) currentPage=\(currentPage?.absolutePageIndex ?? -1) selectionState=\(interactionCoordinator.interactionState) hasSelection=\(currentSelection != nil) loading=\(loadingSpinner.isAnimating)" - ) - if let renderView = coreTextRenderView { - let renderPoint = gesture.location(in: renderView) - if renderView.selectionHandle(at: renderPoint) != nil { - RDEpubReaderTapDebug.log("TextContentView.handleTap", "ignored because tap hit selection handle") - return - } - if currentSelection != nil { - if renderView.selectionContains(renderPoint) { - RDEpubReaderTapDebug.log("TextContentView.handleTap", "selection exists and tap stayed inside selection; showing selection menu") - showSelectionMenuIfNeeded() - return - } - RDEpubReaderTapDebug.log("TextContentView.handleTap", "selection exists and tap moved outside selection; clearing selection") - clearSelection() - return - } - } else if currentSelection != nil { - RDEpubReaderTapDebug.log("TextContentView.handleTap", "selection exists without renderView; clearing selection") - clearSelection() - return - } - // PRIORITY: Check for attachment tap - if let page = currentPage, - let attachment = interactionController.snapshot?.attachment(at: point) { - let kind = attachment.kind - let sourceRect = attachmentSourceRect(at: point, fallbackPoint: point) ?? CGRect.zero - // Footnote attachments carry their note text in alt/accessibility metadata. - // Prefer that semantic text over image preview even if attachment kind metadata is incomplete. - if let footnoteText = attachmentText(at: point), kind == .footnote || !footnoteText.isEmpty { - RDEpubReaderTapDebug.log("TextContentView.handleTap", "resolved footnote attachment tap") - delegate?.textContentView( - self, - didActivateAttachmentText: footnoteText, - sourceRect: sourceRect, - sourcePoint: convert(point, from: overlayView) - ) - return - } - // Regular image attachments: present image viewer - if let image = imageFromPage(attachment: attachment, page: page) { - let altText = attachmentText(at: point) - RDEpubReaderTapDebug.log("TextContentView.handleTap", "resolved image attachment tap altTextPresent=\(altText?.isEmpty == false)") - delegate?.textContentView(self, didActivateImage: image, sourceRect: sourceRect, altText: altText) - return - } - } - if let attachmentText = attachmentText(at: point), - let sourceRect = attachmentSourceRect(at: point, fallbackPoint: point) { - RDEpubReaderTapDebug.log("TextContentView.handleTap", "resolved fallback attachment text tap") - delegate?.textContentView( - self, - didActivateAttachmentText: attachmentText, - sourceRect: sourceRect, - sourcePoint: convert(point, from: overlayView) - ) - return - } - guard let highlight = highlight(at: point), - let sourceRect = highlightSourceRect(for: highlight, fallbackPoint: point) else { - RDEpubReaderTapDebug.log("TextContentView.handleTap", "forwarding plain reader tap to delegate") - delegate?.textContentView(self, didRequestReaderTapAt: convert(point, from: overlayView)) - return - } - RDEpubReaderTapDebug.log("TextContentView.handleTap", "resolved highlight tap highlightId=\(highlight.id)") - delegate?.textContentView(self, didRequestHighlightActions: highlight, sourceRect: sourceRect) - } - - private func showSelectionMenuIfNeeded() { - guard currentSelection != nil, - let targetRect = selectionController.menuAnchorRect(interactionController: interactionController), - !targetRect.isEmpty else { - return - } - let resolvedTargetRect = resolvedSelectionMenuTargetRect(from: targetRect) - if #available(iOS 16.0, *), let interaction = _editMenuInteraction as? UIEditMenuInteraction { - becomeFirstResponder() - let anchor = CGPoint(x: resolvedTargetRect.midX, y: resolvedTargetRect.midY) - let config = UIEditMenuConfiguration(identifier: "SelectionMenu", sourcePoint: anchor) - DispatchQueue.main.async { [weak self, weak interaction] in - guard let self, self.currentSelection != nil else { return } - interaction?.presentEditMenu(with: config) - } - } else { - becomeFirstResponder() - let menuController = UIMenuController.shared - menuController.menuItems = [ - UIMenuItem(title: "拷贝", action: #selector(rd_copy(_:))), - UIMenuItem(title: "高亮", action: #selector(rd_highlight(_:))), - UIMenuItem(title: "批注", action: #selector(rd_annotate(_:))) - ] - menuController.setTargetRect(resolvedTargetRect, in: self) - menuController.setMenuVisible(true, animated: true) - } - } - - private func hideSelectionMenu() { - if #available(iOS 16.0, *), let interaction = _editMenuInteraction as? UIEditMenuInteraction { - interaction.dismissMenu() - } else { - UIMenuController.shared.setMenuVisible(false, animated: true) - } - } - - private func updateSelectionLoupe(for point: CGPoint) { -#if canImport(DTCoreText) - guard let renderView = coreTextRenderView else { return } - let localPoint = convert(point, from: renderView) - selectionLoupeView.present( - sourceView: renderView, - focusPoint: point, - hostBounds: bounds.inset(by: contentInsets), - targetPoint: localPoint - ) -#endif - } - - private var coreTextRenderView: RDEPUBTextPageRenderView? { -#if canImport(DTCoreText) - return coreTextContentView -#else - return nil -#endif - } - - private func resolvedSelectionMenuTargetRect(from rect: CGRect) -> CGRect { - guard let renderView = coreTextRenderView else { return rect } - return convert(rect, from: renderView) - } - - private func updateAccessibilityDecorationSummary() { -#if canImport(DTCoreText) - coreTextContentView.accessibilityValue = [ - backgroundOverlayView.decorationSummary(), - overlayView.decorationSummary() - ].joined(separator: " | ") -#endif - } - - private func highlight(at point: CGPoint) -> RDEPUBHighlight? { - guard let page = currentPage else { return nil } - let matches = currentHighlights.filter { highlight in - guard highlight.location.href == page.href, - let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else { - return false - } - let overlap = NSIntersectionRange(range, page.contentRange) - guard overlap.length > 0 else { return false } - return interactionController.selectionRects(for: overlap).contains { - $0.insetBy(dx: -4, dy: -4).contains(point) - } - } - return matches.sorted { lhs, rhs in - let lhsRange = RDEPUBTextOffsetRangeInfo.decode(from: lhs.rangeInfo)?.nsRange?.length ?? .max - let rhsRange = RDEPUBTextOffsetRangeInfo.decode(from: rhs.rangeInfo)?.nsRange?.length ?? .max - if lhs.hasNote != rhs.hasNote { - return lhs.hasNote && !rhs.hasNote - } - return lhsRange < rhsRange - }.first - } - - private func highlightSourceRect(for highlight: RDEPUBHighlight, fallbackPoint: CGPoint) -> CGRect? { - guard let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else { - let fallbackRect = CGRect(origin: fallbackPoint, size: CGSize(width: 1, height: 1)) - return overlayView.convert(fallbackRect, to: self) - } - let rects = interactionController.selectionRects(for: range) - if let first = rects.first { - var unionRect = first - for rect in rects.dropFirst() { - unionRect = unionRect.union(rect) - } - return overlayView.convert(unionRect, to: self) - } - let fallbackRect = CGRect(origin: fallbackPoint, size: CGSize(width: 1, height: 1)) - return overlayView.convert(fallbackRect, to: self) - } - - private func attachmentText(at point: CGPoint) -> String? { - guard let page = currentPage else { return nil } - guard let attachmentRange = interactionController.snapshot?.attachment(at: point)?.stringRange else { - return nil - } - guard page.chapterContent.length > attachmentRange.location else { - return nil - } - let attachment = page.chapterContent.attribute(.attachment, at: attachmentRange.location, effectiveRange: nil) - -#if canImport(DTCoreText) - if let textAttachment = attachment as? DTTextAttachment, - let altText = (textAttachment.attributes["alt"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines), - !altText.isEmpty { - return altText - } -#endif - - if let fileAttachment = attachment as? NSTextAttachment, - let altText = fileAttachment.accessibilityLabel?.trimmingCharacters(in: .whitespacesAndNewlines), - !altText.isEmpty { - return altText - } - return nil - } - - private func attachmentSourceRect(at point: CGPoint, fallbackPoint: CGPoint) -> CGRect? { - if let attachmentRect = interactionController.snapshot?.attachment(at: point)?.frame { - return overlayView.convert(attachmentRect, to: self) - } - let fallbackRect = CGRect(origin: fallbackPoint, size: CGSize(width: 1, height: 1)) - return overlayView.convert(fallbackRect, to: self) - } - - func shouldSuppressReaderTap(at point: CGPoint) -> Bool { -#if canImport(DTCoreText) - if interactionCoordinator.interactionState == .selectionPending { - RDEpubReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return true because interactionState is selectionPending") - return true - } - - guard let renderView = coreTextRenderView else { - RDEpubReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return false because renderView is nil") - return false - } - let renderPoint = convert(point, to: renderView) - if renderView.selectionHandle(at: renderPoint) != nil { - RDEpubReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return true because tap hit selection handle") - return true - } - if selectionController.hasActiveSelection, renderView.selectionContains(renderPoint) { - RDEpubReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return true because tap is inside active selection") - return true - } - switch interactionCoordinator.interactionState { - case .idle: - RDEpubReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return false because interactionState is idle") - return false - case .selectionPending, .selecting, .selectionActive, .adjustingHandle: - RDEpubReaderTapDebug.log( - "TextContentView.shouldSuppressReaderTap", - "return true because interactionState=\(interactionCoordinator.interactionState)" - ) - return true - } -#else - RDEpubReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return false because DTCoreText is unavailable") - return false -#endif - } - - override func touchesBegan(_ touches: Set, with event: UIEvent?) { - super.touchesBegan(touches, with: event) - interactionCoordinator.touchesBegan(touches, with: event) - } - - override func touchesEnded(_ touches: Set, with event: UIEvent?) { - super.touchesEnded(touches, with: event) - interactionCoordinator.touchesEnded(touches, with: event) - } - - override func touchesCancelled(_ touches: Set, with event: UIEvent?) { - super.touchesCancelled(touches, with: event) - interactionCoordinator.touchesCancelled(touches, with: event) - } - - override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { - if gestureRecognizer === tapGestureRecognizer { - guard let tapGestureRecognizer = gestureRecognizer as? UITapGestureRecognizer else { - return false - } - if selectionController.hasActiveSelection { - if let renderView = coreTextRenderView { - let point = tapGestureRecognizer.location(in: renderView) - if renderView.selectionHandle(at: point) != nil { - return false - } - } - return true - } - let point = tapGestureRecognizer.location(in: overlayView) - if attachmentText(at: point) != nil || highlight(at: point) != nil { - return true - } - return currentPage != nil - } - return interactionCoordinator.gestureRecognizerShouldBegin(gestureRecognizer) - } - - func gestureRecognizer( - _ gestureRecognizer: UIGestureRecognizer, - shouldReceive touch: UITouch - ) -> Bool { - if gestureRecognizer === tapGestureRecognizer { - guard let renderView = coreTextRenderView else { - RDEpubReaderTapDebug.log("TextContentView.gestureShouldReceive", "return true because renderView is nil") - return true - } - - let point = touch.location(in: renderView) - if renderView.selectionHandle(at: point) != nil { - RDEpubReaderTapDebug.log("TextContentView.gestureShouldReceive", "return false because tap hit selection handle") - return false - } - } - - let result = interactionCoordinator.gestureRecognizer(gestureRecognizer, shouldReceive: touch) - RDEpubReaderTapDebug.log( - "TextContentView.gestureShouldReceive", - "gesture=\(type(of: gestureRecognizer)) touchView=\(RDEpubReaderTapDebug.describe(touch.view)) result=\(result)" - ) - return result - } - - func gestureRecognizer( - _ gestureRecognizer: UIGestureRecognizer, - shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer - ) -> Bool { - gestureRecognizer === tapGestureRecognizer - || interactionCoordinator.gestureRecognizer( - gestureRecognizer, - shouldRecognizeSimultaneouslyWith: otherGestureRecognizer - ) - } -} - -// MARK: - UIEditMenuInteractionDelegate (iOS 16+) - -@available(iOS 16.0, *) -extension RDEPUBTextContentView: UIEditMenuInteractionDelegate { - func editMenuInteraction( - _ interaction: UIEditMenuInteraction, - menuFor configuration: UIEditMenuConfiguration, - suggestedActions: [UIMenuElement] - ) -> UIMenu? { - UIMenu(children: [ - UICommand(title: "拷贝", action: #selector(rd_copy(_:))), - UICommand(title: "高亮", action: #selector(rd_highlight(_:))), - UICommand(title: "批注", action: #selector(rd_annotate(_:))) - ]) - } - - func editMenuInteraction( - _ interaction: UIEditMenuInteraction, - targetRectFor configuration: UIEditMenuConfiguration - ) -> CGRect { - if let targetRect = selectionController.menuAnchorRect(interactionController: interactionController), - !targetRect.isEmpty { - return resolvedSelectionMenuTargetRect(from: targetRect) - } - return bounds - } -} - -// MARK: - Safe Content Insets - -extension RDEPUBTextContentView { - - /// Computes content insets that account for safe areas (Dynamic Island, home indicator). - /// Uses the larger of safeAreaInsets and reflowableContentInsets for each edge, - /// ensuring content is never hidden under the Dynamic Island or home indicator area. - private static func safeContentInsets( - configuration: RDEPUBReaderConfiguration, - safeAreaInsets: UIEdgeInsets - ) -> UIEdgeInsets { - let configInsets = configuration.reflowableContentInsets - return UIEdgeInsets( - top: max(configInsets.top, safeAreaInsets.top), - left: max(configInsets.left, safeAreaInsets.left), - bottom: max(configInsets.bottom, safeAreaInsets.bottom) + pageNumberReservedHeight, - right: max(configInsets.right, safeAreaInsets.right) - ) - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextPageBoundaryValidator.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextPageBoundaryValidator.swift deleted file mode 100644 index 323f771..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextPageBoundaryValidator.swift +++ /dev/null @@ -1,223 +0,0 @@ -import UIKit - -#if canImport(DTCoreText) -import DTCoreText - -/// Debug-only detector for pagination/display metric mismatches, enabled by -/// the `--demo-pagination-validate` launch argument. -/// -/// For every displayed text page it re-wraps the chapter text from the page -/// start at the display width (same CoreText engine the paginator used, in -/// chapter context) and compares the resulting line breaks against both the -/// page range and the lines actually drawn. Two failure classes: -/// -/// - `STALE-RANGE`: the page range does not end on a line boundary of the -/// current metrics — the range was produced under different metrics than -/// the ones on screen (stale page table). -/// - `DISPLAY-DIVERGE`: the range is fine, but the drawn lines break at -/// different offsets than the in-context wrap — the display-side content -/// transform (e.g. continuation-paragraph normalization) changed wrapping. -enum RDEPUBTextPageBoundaryValidator { - - static let isEnabled = ProcessInfo.processInfo.arguments.contains("--demo-pagination-validate") - - /// Extra characters wrapped past the page end so the probe can see the - /// line that a mid-line page boundary cuts through. - private static let probeTailLength = 400 - - static func validate( - page: RDEPUBTextPage, - displayLayoutFrame: DTCoreTextLayoutFrame, - displayContent: NSAttributedString?, - displayBounds: CGRect - ) { - guard isEnabled else { return } - let chapter = page.chapterContent - let pageStart = page.contentRange.location - let pageEnd = page.contentRange.location + page.contentRange.length - guard page.contentRange.length > 0, - pageStart >= 0, - pageEnd <= chapter.length, - displayBounds.width > 0 else { return } - - guard let layouter = DTCoreTextLayouter(attributedString: chapter) else { return } - layouter.shouldCacheLayoutFrames = false - let probeLength = min(chapter.length - pageStart, page.contentRange.length + probeTailLength) - let probeRect = CGRect(x: 0, y: 0, width: displayBounds.width, height: 4_000_000) - guard let probeFrame = layouter.layoutFrame( - with: probeRect, - range: NSRange(location: pageStart, length: probeLength) - ), let probeLines = probeFrame.lines as? [DTCoreTextLayoutLine] else { return } - - let probeRanges = probeLines.map { $0.stringRange() } - - // Class A: the page must end on a line boundary of the current wrap - // (unless it is the chapter's last page, which ends at chapter end). - let isChapterLastPage = pageEnd >= chapter.length - if !isChapterLastPage, - !probeRanges.contains(where: { NSMaxRange($0) == pageEnd }), - let cutLine = probeRanges.first(where: { NSLocationInRange(pageEnd - 1, $0) }) { - let text = chapter.string as NSString - let lineText = safeSubstring(text, cutLine) - print("[PAGINATION-VALIDATE] STALE-RANGE spine=\(page.spineIndex) page=\(page.pageIndexInChapter + 1)/\(page.totalPagesInChapter) pageEnd=\(pageEnd) cutLine=\(NSStringFromRange(cutLine)) width=\(displayBounds.width) line=\"\(lineText)\"") - } - - // Class B: the drawn lines must break at the same offsets as the - // in-context wrap. The display layout frame is built in chapter - // context, so its string ranges are chapter-absolute. - guard let displayLines = displayLayoutFrame.lines as? [DTCoreTextLayoutLine] else { return } - for (index, displayLine) in displayLines.enumerated() { - let displayRange = displayLine.stringRange() - let displayEndInChapter = NSMaxRange(displayRange) - guard displayEndInChapter < pageEnd else { break } - guard index < probeRanges.count else { break } - let probeEnd = NSMaxRange(probeRanges[index]) - if probeEnd != displayEndInChapter { - let text = chapter.string as NSString - let lineStartInChapter = displayRange.location - let displayLineRangeInChapter = displayRange - let isParagraphStart = lineStartInChapter == 0 - || text.character(at: lineStartInChapter - 1) == 0x0A - let chapterStyle = chapter.attribute( - .paragraphStyle, at: lineStartInChapter, effectiveRange: nil - ) as? NSParagraphStyle - let displayStyle = displayContent?.attribute( - .paragraphStyle, at: displayRange.location, effectiveRange: nil - ) as? NSParagraphStyle - let displayLineWidth = displayLine.frame.width - let probeLineWidth = index < probeLines.count ? probeLines[index].frame.width : -1 - print("[PAGINATION-VALIDATE] DISPLAY-DIVERGE spine=\(page.spineIndex) page=\(page.pageIndexInChapter + 1)/\(page.totalPagesInChapter) lineIndex=\(index) displayLineEnd=\(displayEndInChapter) probeLineEnd=\(probeEnd) width=\(displayBounds.width) paraStart=\(isParagraphStart) chapterIndents=(\(chapterStyle?.firstLineHeadIndent ?? -1),\(chapterStyle?.headIndent ?? -1),tail:\(chapterStyle?.tailIndent ?? -1)) displayIndents=(\(displayStyle?.firstLineHeadIndent ?? -1),\(displayStyle?.headIndent ?? -1),tail:\(displayStyle?.tailIndent ?? -1)) displayLineWidth=\(displayLineWidth) probeLineWidth=\(probeLineWidth) displayLine=\"\(safeSubstring(text, displayLineRangeInChapter))\" displayBreak=\"…\(safeSubstring(text, NSRange(location: max(displayEndInChapter - 2, 0), length: min(4, text.length - max(displayEndInChapter - 2, 0)))))\" probeBreak=\"…\(safeSubstring(text, NSRange(location: max(probeEnd - 2, 0), length: min(4, text.length - max(probeEnd - 2, 0)))))\"") - diagnoseDivergence( - page: page, - displayContent: displayContent, - lineIndex: index, - lineStartInChapter: lineStartInChapter, - displayEndInChapter: displayEndInChapter, - probeEnd: probeEnd, - width: displayBounds.width - ) - break - } - } - } - - /// Narrows a display/probe line-break divergence down to its cause by - /// re-wrapping controlled variants and diffing attributes over the line. - private static func diagnoseDivergence( - page: RDEPUBTextPage, - displayContent: NSAttributedString?, - lineIndex: Int, - lineStartInChapter: Int, - displayEndInChapter: Int, - probeEnd: Int, - width: CGFloat - ) { - let chapter = page.chapterContent - - // Variant 1: the raw page substring with no display normalization. - let rawSubstring = chapter.attributedSubstring(from: page.contentRange) - let rawEnd = lineEnd( - wrapping: rawSubstring, - lineIndex: lineIndex, - width: width - ).map { $0 + page.pageStartOffset } - - // Variant 2: wrap the chapter from the start of the paragraph that - // contains the diverging line (context = current paragraph only). - let text = chapter.string as NSString - let paragraphRange = text.paragraphRange( - for: NSRange(location: lineStartInChapter, length: 0) - ) - let paraString = chapter.attributedSubstring( - from: NSRange( - location: paragraphRange.location, - length: min(chapter.length - paragraphRange.location, paragraphRange.length + probeTailLength) - ) - ) - var paraEnd: Int? - if let layouter = DTCoreTextLayouter(attributedString: paraString) { - layouter.shouldCacheLayoutFrames = false - let frame = layouter.layoutFrame( - with: CGRect(x: 0, y: 0, width: width, height: 4_000_000), - range: NSRange(location: 0, length: paraString.length) - ) - if let lines = frame?.lines as? [DTCoreTextLayoutLine] { - let target = lineStartInChapter - paragraphRange.location - if let matched = lines.first(where: { $0.stringRange().location == target }) { - paraEnd = NSMaxRange(matched.stringRange()) + paragraphRange.location - } - } - } - - print("[PAGINATION-VALIDATE] DIAGNOSE lineStart=\(lineStartInChapter) display=\(displayEndInChapter) probeFullContext=\(probeEnd) rawSubstringWrap=\(rawEnd ?? -1) paragraphContextWrap=\(paraEnd ?? -1)") - - // Attribute diff between chapter text and display content over the - // diverging line (through the longer of the two ends). The display - // content is a chapter-length copy, so indices are shared. - guard let displayContent else { return } - let diffEnd = max(displayEndInChapter, probeEnd) - var position = lineStartInChapter - while position < diffEnd { - guard position >= 0, position < displayContent.length, position < chapter.length else { break } - var chapterRunRange = NSRange() - let chapterAttrs = chapter.attributes(at: position, effectiveRange: &chapterRunRange) - var displayRunRange = NSRange() - let displayAttrs = displayContent.attributes(at: position, effectiveRange: &displayRunRange) - - let keys = Set(chapterAttrs.keys).union(displayAttrs.keys) - for key in keys { - let lhs = chapterAttrs[key] as AnyObject? - let rhs = displayAttrs[key] as AnyObject? - if let lhs, let rhs, lhs.isEqual(rhs) { continue } - if lhs == nil, rhs == nil { continue } - print("[PAGINATION-VALIDATE] ATTR-DIFF pos=\(position) key=\(key.rawValue) chapter=\(describeAttr(lhs)) display=\(describeAttr(rhs))") - } - let nextPosition = min( - NSMaxRange(chapterRunRange), - NSMaxRange(displayRunRange) - ) - guard nextPosition > position else { break } - position = nextPosition - } - } - - private static func describeAttr(_ value: AnyObject?) -> String { - guard let value else { return "nil" } - if let font = value as? UIFont { - return "font(\(font.fontName),\(font.pointSize))" - } - if let style = value as? NSParagraphStyle { - return "para(fli:\(style.firstLineHeadIndent),hi:\(style.headIndent),ti:\(style.tailIndent),lbm:\(style.lineBreakMode.rawValue),align:\(style.alignment.rawValue),lhm:\(style.lineHeightMultiple),ls:\(style.lineSpacing),min:\(style.minimumLineHeight),max:\(style.maximumLineHeight))" - } - if let number = value as? NSNumber { - return "num(\(number))" - } - return String(describing: type(of: value)) - } - - /// Wraps `content` page-locally and returns the chapter-relative end of - /// line `lineIndex`, or nil if it cannot be produced. - private static func lineEnd( - wrapping content: NSAttributedString, - lineIndex: Int, - width: CGFloat - ) -> Int? { - guard content.length > 0, - let layouter = DTCoreTextLayouter(attributedString: content) else { return nil } - layouter.shouldCacheLayoutFrames = false - let frame = layouter.layoutFrame( - with: CGRect(x: 0, y: 0, width: width, height: 4_000_000), - range: NSRange(location: 0, length: content.length) - ) - guard let lines = frame?.lines as? [DTCoreTextLayoutLine], - lineIndex < lines.count else { return nil } - return NSMaxRange(lines[lineIndex].stringRange()) - } - - private static func safeSubstring(_ text: NSString, _ range: NSRange) -> String { - guard range.location >= 0, NSMaxRange(range) <= text.length else { return "" } - return text.substring(with: range) - .replacingOccurrences(of: "\n", with: "⏎") - } -} -#endif diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextPageDecorationView.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextPageDecorationView.swift deleted file mode 100644 index 3bb438e..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextPageDecorationView.swift +++ /dev/null @@ -1,3 +0,0 @@ -import UIKit - -final class RDEPUBTextPageDecorationView: RDEPUBSelectionOverlayView {} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextPageRenderView.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextPageRenderView.swift deleted file mode 100644 index b72036d..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextPageRenderView.swift +++ /dev/null @@ -1,213 +0,0 @@ -import UIKit - -#if canImport(DTCoreText) -import DTCoreText - -final class RDEPUBTextPageRenderView: UIView { - - enum SelectionHandle { - case start - case end - } - - var layoutFrame: DTCoreTextLayoutFrame? { - didSet { - invalidateStaticContent() - } - } - - var drawOptions: DTCoreTextLayoutFrameDrawingOptions = DTCoreTextLayoutFrameDrawingOptions(rawValue: 1)! { - didSet { - invalidateStaticContent() - } - } - - var selectionRects: [CGRect] = [] { - didSet { - setNeedsDisplay() - } - } - - var selectionColor: UIColor = UIColor(red: 70 / 255, green: 140 / 255, blue: 1, alpha: 0.24) - - private let selectionHandleColor = UIColor(red: 20 / 255, green: 122 / 255, blue: 1, alpha: 1) - - private let selectionHandleStemWidth: CGFloat = 2.5 - - private let selectionHandleKnobRadius: CGFloat = 7 - - private let selectionHandleHitSlop: CGFloat = 20 - - private var cachedStaticImage: UIImage? - - private var cachedStaticBoundsSize: CGSize = .zero - - private var needsStaticContentRedraw = true - - override init(frame: CGRect) { - super.init(frame: frame) - backgroundColor = .clear - isOpaque = false - contentMode = .redraw - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func draw(_ rect: CGRect) { - guard let context = UIGraphicsGetCurrentContext(), - let layoutFrame else { return } - - context.saveGState() - - if cachedStaticImage == nil - || cachedStaticBoundsSize != bounds.size - || needsStaticContentRedraw { - cachedStaticImage = renderStaticImage(layoutFrame: layoutFrame) - cachedStaticBoundsSize = bounds.size - needsStaticContentRedraw = false - } - - if let cachedStaticImage { - cachedStaticImage.draw(in: bounds) - } else { - layoutFrame.draw(in: context, options: drawOptions) - } - - drawSelection(in: context) - - context.restoreGState() - } - - override func layoutSubviews() { - super.layoutSubviews() - if cachedStaticBoundsSize != bounds.size { - invalidateStaticContent() - } - } - - private func drawSelection(in context: CGContext) { - guard !selectionRects.isEmpty else { return } - selectionColor.setFill() - for rect in selectionRects { - context.fill(rect) - } - drawSelectionHandles(in: context) - } - - func selectionHandle(at point: CGPoint) -> SelectionHandle? { - guard let handleGeometry = selectionHandleGeometry else { return nil } - - let startDistance = point.distance(to: handleGeometry.startKnobCenter) - let endDistance = point.distance(to: handleGeometry.endKnobCenter) - let maxDistance = selectionHandleKnobRadius + selectionHandleHitSlop - - let startMatched = startDistance <= maxDistance - let endMatched = endDistance <= maxDistance - - switch (startMatched, endMatched) { - case (true, true): - return startDistance <= endDistance ? .start : .end - case (true, false): - return .start - case (false, true): - return .end - default: - return nil - } - } - - func selectionContains(_ point: CGPoint) -> Bool { - selectionRects.contains { rect in - rect.insetBy(dx: -6, dy: -8).contains(point) - } - } - - private var selectionHandleGeometry: (startKnobCenter: CGPoint, endKnobCenter: CGPoint)? { - guard let firstRect = selectionRects.first, - let lastRect = selectionRects.last else { - return nil - } - - let startKnobCenter = CGPoint( - x: firstRect.minX, - y: firstRect.minY - selectionHandleKnobRadius - ) - let endKnobCenter = CGPoint( - x: lastRect.maxX, - y: lastRect.maxY + selectionHandleKnobRadius - ) - - return (startKnobCenter: startKnobCenter, endKnobCenter: endKnobCenter) - } - - private func drawSelectionHandles(in context: CGContext) { - guard let firstRect = selectionRects.first, - let lastRect = selectionRects.last else { - return - } - - context.saveGState() - context.setFillColor(selectionHandleColor.cgColor) - - let stemHalfWidth = selectionHandleStemWidth / 2 - - let startStem = CGRect( - x: firstRect.minX - stemHalfWidth, - y: firstRect.minY - selectionHandleKnobRadius * 2, - width: selectionHandleStemWidth, - height: firstRect.height + selectionHandleKnobRadius * 2 - ) - context.fill(startStem) - let startKnob = CGRect( - x: firstRect.minX - selectionHandleKnobRadius, - y: firstRect.minY - selectionHandleKnobRadius * 2, - width: selectionHandleKnobRadius * 2, - height: selectionHandleKnobRadius * 2 - ) - context.fillEllipse(in: startKnob) - - let endStem = CGRect( - x: lastRect.maxX - stemHalfWidth, - y: lastRect.minY, - width: selectionHandleStemWidth, - height: lastRect.height + selectionHandleKnobRadius * 2 - ) - context.fill(endStem) - let endKnob = CGRect( - x: lastRect.maxX - selectionHandleKnobRadius, - y: lastRect.maxY, - width: selectionHandleKnobRadius * 2, - height: selectionHandleKnobRadius * 2 - ) - context.fillEllipse(in: endKnob) - - context.restoreGState() - } - - private func invalidateStaticContent() { - cachedStaticImage = nil - needsStaticContentRedraw = true - setNeedsDisplay() - } - - private func renderStaticImage(layoutFrame: DTCoreTextLayoutFrame) -> UIImage? { - guard bounds.width > 0, bounds.height > 0 else { return nil } - let format = UIGraphicsImageRendererFormat.default() - format.opaque = false - let renderer = UIGraphicsImageRenderer(size: bounds.size, format: format) - return renderer.image { _ in - guard let staticContext = UIGraphicsGetCurrentContext() else { return } - layoutFrame.draw(in: staticContext, options: drawOptions) - } - } -} - -private extension CGPoint { - - func distance(to point: CGPoint) -> CGFloat { - hypot(x - point.x, y - point.y) - } -} -#endif diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextPageVerticalJustifier.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextPageVerticalJustifier.swift deleted file mode 100644 index 01aae72..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextPageVerticalJustifier.swift +++ /dev/null @@ -1,57 +0,0 @@ -import UIKit - -#if canImport(DTCoreText) -import DTCoreText - -/// Redistributes the leftover space at the bottom of a page into the gaps -/// between lines so the last line sits flush with the content bottom edge -/// (vertical justification), keeping page character ranges untouched. -/// -/// Mutating each line's `baselineOrigin` is sufficient: DTCoreText derives -/// line frames, glyph-run frames and attachment positions from it lazily, -/// so drawing, selection, highlights and hit-testing all stay consistent. -enum RDEPUBTextPageVerticalJustifier { - - /// Leftover larger than this many typical line advances is kept as - /// whitespace instead of being stretched: it usually comes from a whole - /// block (image, table) pushed to the next page, and stretching would - /// make the line spacing visibly sparse. - static let maxStretchLineAdvanceRatio: CGFloat = 1.5 - - static func justify( - _ layoutFrame: DTCoreTextLayoutFrame, - contentHeight: CGFloat, - isChapterLastPage: Bool, - pixelScale: CGFloat - ) { - guard !isChapterLastPage, - contentHeight > 0, - let lines = layoutFrame.lines as? [DTCoreTextLayoutLine], - lines.count >= 2, - let firstLine = lines.first, - let lastLine = lines.last else { - return - } - - let leftover = contentHeight - lastLine.frame.maxY - guard leftover > 0.5 else { return } - - let gapCount = CGFloat(lines.count - 1) - let typicalAdvance = (lastLine.baselineOrigin.y - firstLine.baselineOrigin.y) / gapCount - guard typicalAdvance > 0, - leftover <= typicalAdvance * maxStretchLineAdvanceRatio else { - return - } - - let scale = max(pixelScale, 1) - for (index, line) in lines.enumerated() where index > 0 { - // Round each cumulative shift down to the pixel grid so glyphs - // stay sharp and the last line never overshoots the bottom edge. - let shift = floor(leftover * CGFloat(index) / gapCount * scale) / scale - var origin = line.baselineOrigin - origin.y += shift - line.baselineOrigin = origin - } - } -} -#endif diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextSelectionController.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextSelectionController.swift deleted file mode 100644 index c732c3b..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextSelectionController.swift +++ /dev/null @@ -1,388 +0,0 @@ -import UIKit - -final class RDEPUBTextSelectionController: NSObject { - - enum BoundaryHandle { - case start - case end - } - - enum InteractionState: Equatable { - case idle - case selecting - case selectionActive - case adjustingHandle - } - - private enum SelectionGranularity { - case character - case word - } - - private(set) var isSelecting = false - - private var selectionStartIndex: Int = NSNotFound - - private var selectionEndIndex: Int = NSNotFound - - private var activeGranularity: SelectionGranularity = .character - - private var selectionAnchorIndex: Int = NSNotFound - - private(set) var interactionState: InteractionState = .idle { - didSet { - guard interactionState != oldValue else { return } - interactionStateDidChange?(interactionState) - } - } - - var onSelectionChanged: ((RDEPUBSelection?) -> Void)? - - var interactionStateDidChange: ((InteractionState) -> Void)? - - var pageProvider: (() -> RDEPUBTextPage?)? - - var chapterCFIMapProvider: (() -> RDEPUBCFIMap?)? - - var chapterFragmentOffsetsProvider: (() -> [String: Int])? - - var hasActiveSelection: Bool { - selectedAbsoluteRange != nil - } - - var selectedAbsoluteRange: NSRange? { - guard selectionStartIndex != NSNotFound, - selectionEndIndex != NSNotFound else { - return nil - } - let lower = min(selectionStartIndex, selectionEndIndex) - let upper = max(selectionStartIndex, selectionEndIndex) - return NSRange(location: lower, length: max(upper - lower + 1, 1)) - } - - func handleLongPress( - _ gesture: UILongPressGestureRecognizer, - renderView: RDEPUBTextPageRenderView?, - interactionController: RDEPUBPageInteractionController - ) { - guard let renderView else { return } - let point = gesture.location(in: renderView) - - switch gesture.state { - case .began: - setInteractionState(.selecting) - beginSelection(at: point, renderView: renderView, interactionController: interactionController) - case .changed: - setInteractionState(.selecting) - updateSelection(at: point, renderView: renderView, interactionController: interactionController) - case .ended: - isSelecting = false - setInteractionState(hasActiveSelection ? .selectionActive : .idle) - case .cancelled, .failed: - clearSelection(renderView: renderView) - default: - break - } - } - - func handlePan( - _ gesture: UIPanGestureRecognizer, - renderView: RDEPUBTextPageRenderView?, - interactionController: RDEPUBPageInteractionController - ) { - guard isSelecting, let renderView else { return } - let point = gesture.location(in: renderView) - - switch gesture.state { - case .began, .changed: - setInteractionState(.selecting) - updateSelection(at: point, renderView: renderView, interactionController: interactionController) - case .ended, .cancelled, .failed: - isSelecting = false - setInteractionState(hasActiveSelection ? .selectionActive : .idle) - default: - break - } - } - - func updateSelection( - byAdjusting handle: BoundaryHandle, - at point: CGPoint, - renderView: RDEPUBTextPageRenderView?, - interactionController: RDEPUBPageInteractionController - ) { - guard let renderView, - let index = interactionController.characterIndexForViewPoint(at: point, in: renderView), - selectedAbsoluteRange != nil else { - return - } - - setInteractionState(.adjustingHandle) - - switch handle { - case .start: - selectionStartIndex = snappedBoundaryIndex(for: index, handle: .start) - if selectionEndIndex != NSNotFound { - selectionStartIndex = min(selectionStartIndex, selectionEndIndex) - } - case .end: - selectionEndIndex = snappedBoundaryIndex(for: index, handle: .end) - if selectionStartIndex != NSNotFound { - selectionEndIndex = max(selectionEndIndex, selectionStartIndex) - } - } - - applySelection(renderView: renderView, interactionController: interactionController) - } - - func menuAnchorRect(interactionController: RDEPUBPageInteractionController) -> CGRect? { - guard let absoluteRange = selectedAbsoluteRange else { return nil } - return interactionController.menuAnchorRect(for: absoluteRange) - } - - func clearSelection(renderView: RDEPUBTextPageRenderView? = nil) { - isSelecting = false - selectionAnchorIndex = NSNotFound - activeGranularity = .character - selectionStartIndex = NSNotFound - selectionEndIndex = NSNotFound - setInteractionState(.idle) - renderView?.selectionRects = [] - onSelectionChanged?(nil) - } - - private func beginSelection( - at point: CGPoint, - renderView: RDEPUBTextPageRenderView, - interactionController: RDEPUBPageInteractionController - ) { - guard let index = interactionController.characterIndexForViewPoint(at: point, in: renderView) else { - if !isSelecting { - clearSelection(renderView: renderView) - } - return - } - selectionAnchorIndex = index - activeGranularity = .word - isSelecting = true - applySelection( - range: selectionRange(for: index, granularity: .word), - renderView: renderView, - interactionController: interactionController - ) - } - - private func selectionRange(for focusIndex: Int, granularity: SelectionGranularity) -> NSRange? { - guard let page = pageProvider?() else { return nil } - - switch granularity { - case .character: - return NSRange(location: focusIndex, length: 1) - case .word: - if let wordRange = wordRange(containing: focusIndex, in: page.chapterContent.string as NSString) { - return wordRange - } - return NSRange(location: focusIndex, length: 1) - } - } - - private func snappedBoundaryIndex(for index: Int, handle: BoundaryHandle) -> Int { - guard let page = pageProvider?() else { return index } - let text = page.chapterContent.string as NSString - guard let wordRange = wordRange(containing: index, in: text) else { - return index - } - - switch handle { - case .start: - return wordRange.location - case .end: - return max(wordRange.location + wordRange.length - 1, wordRange.location) - } - } - - private func wordRange(containing index: Int, in text: NSString) -> NSRange? { - guard text.length > 0 else { return nil } - let safeIndex = min(max(index, 0), max(text.length - 1, 0)) - if let scalar = UnicodeScalar(text.character(at: safeIndex)), - CharacterSet.whitespacesAndNewlines.contains(scalar) { - return nearestWordRange(to: safeIndex, in: text) - ?? text.rangeOfComposedCharacterSequence(at: safeIndex) - } - let characterRange = text.rangeOfComposedCharacterSequence(at: safeIndex) - let probeRange = NSRange(location: safeIndex, length: 1) - var matchedWordRange: NSRange? - - text.enumerateSubstrings( - in: NSRange(location: 0, length: text.length), - options: [.byWords, .substringNotRequired] - ) { _, substringRange, _, stop in - guard substringRange.length > 0 else { return } - if NSIntersectionRange(substringRange, probeRange).length > 0 - || NSLocationInRange(characterRange.location, substringRange) { - matchedWordRange = substringRange - stop.pointee = true - } - } - - if let matchedWordRange { - let trimmedRange = trimmed(range: matchedWordRange, in: text) - if trimmedRange.length > 0 { - return trimmedRange - } - } - - return characterRange - } - - private func nearestWordRange(to index: Int, in text: NSString) -> NSRange? { - var nearestRange: NSRange? - var nearestDistance = Int.max - - text.enumerateSubstrings( - in: NSRange(location: 0, length: text.length), - options: [.byWords, .substringNotRequired] - ) { _, substringRange, _, _ in - guard substringRange.length > 0 else { return } - let trimmedRange = self.trimmed(range: substringRange, in: text) - guard trimmedRange.length > 0 else { return } - - let distance: Int - if index < trimmedRange.location { - distance = trimmedRange.location - index - } else if index >= trimmedRange.location + trimmedRange.length { - distance = index - (trimmedRange.location + trimmedRange.length - 1) - } else { - distance = 0 - } - - if distance < nearestDistance { - nearestDistance = distance - nearestRange = trimmedRange - } - } - - return nearestRange - } - - private func trimmed(range: NSRange, in text: NSString) -> NSRange { - guard range.length > 0 else { return range } - - var lowerBound = range.location - var upperBound = range.location + range.length - - while lowerBound < upperBound, - let scalar = UnicodeScalar(text.character(at: lowerBound)), - CharacterSet.whitespacesAndNewlines.contains(scalar) { - lowerBound += 1 - } - - while upperBound > lowerBound, - let scalar = UnicodeScalar(text.character(at: upperBound - 1)), - CharacterSet.whitespacesAndNewlines.contains(scalar) { - upperBound -= 1 - } - - return NSRange(location: lowerBound, length: max(upperBound - lowerBound, 0)) - } - - private func applySelection( - range: NSRange?, - renderView: RDEPUBTextPageRenderView, - interactionController: RDEPUBPageInteractionController - ) { - guard let range, range.location != NSNotFound, range.length > 0 else { - clearSelection(renderView: renderView) - return - } - selectionStartIndex = range.location - selectionEndIndex = range.location + range.length - 1 - applySelection(renderView: renderView, interactionController: interactionController) - } - - private func updateSelection( - at point: CGPoint, - renderView: RDEPUBTextPageRenderView, - interactionController: RDEPUBPageInteractionController - ) { - guard selectionAnchorIndex != NSNotFound, - let index = interactionController.characterIndexForViewPoint(at: point, in: renderView) else { - return - } - - let clampedIndex: Int - switch activeGranularity { - case .character: - clampedIndex = index - case .word: - clampedIndex = snappedBoundaryIndex( - for: index, - handle: index >= selectionAnchorIndex ? .end : .start - ) - } - - selectionStartIndex = selectionAnchorIndex - selectionEndIndex = clampedIndex - applySelection(renderView: renderView, interactionController: interactionController) - } - - private func applySelection( - renderView: RDEPUBTextPageRenderView, - interactionController: RDEPUBPageInteractionController - ) { - guard let absoluteRange = selectedAbsoluteRange, - let page = pageProvider?() else { - clearSelection(renderView: renderView) - return - } - - renderView.selectionRects = interactionController.selectionRects(for: absoluteRange) - setInteractionState(isSelecting ? .selecting : .selectionActive) - onSelectionChanged?(makeSelection(from: absoluteRange, page: page)) - } - - private func setInteractionState(_ state: InteractionState) { - interactionState = state - } - - private func makeSelection(from absoluteRange: NSRange, page: RDEPUBTextPage) -> RDEPUBSelection? { - guard absoluteRange.location != NSNotFound, - absoluteRange.length > 0, - NSMaxRange(absoluteRange) <= page.chapterContent.length else { - return nil - } - - let selectedText = page.chapterContent.attributedSubstring(from: absoluteRange).string - guard !selectedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - return nil - } - - let globalStart = absoluteRange.location - let globalEnd = absoluteRange.location + absoluteRange.length - let chapterData = makeChapterData(for: page) - let location = chapterData.location(for: absoluteRange, bookIdentifier: nil) - return RDEPUBSelection( - location: location, - text: selectedText, - rangeInfo: RDEPUBTextOffsetRangeInfo(href: page.href, start: globalStart, end: globalEnd).jsonString() - ) - } - - private func makeChapterData(for page: RDEPUBTextPage) -> RDEPUBChapterData { - let textChapter = RDEPUBTextChapter( - chapterIndex: page.chapterIndex, - spineIndex: page.spineIndex, - href: page.href, - title: page.chapterTitle, - attributedContent: page.chapterContent, - fragmentOffsets: chapterFragmentOffsetsProvider?() ?? [:], - cfiMap: chapterCFIMapProvider?(), - pageBreakReasons: [], - pages: [page] - ) - return RDEPUBChapterData( - chapter: textChapter, - indexTable: RDEPUBTextIndexTable(chapters: [textChapter]) - ) - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/UIColor+RDEPUB.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/UIColor+RDEPUB.swift deleted file mode 100644 index afd465e..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/UIColor+RDEPUB.swift +++ /dev/null @@ -1,31 +0,0 @@ -import UIKit - -extension UIColor { - - /// rgba CSS 字符串,如 "rgba(255, 128, 0, 1.000)" - var rd_cssString: String { - var red: CGFloat = 0 - var green: CGFloat = 0 - var blue: CGFloat = 0 - var alpha: CGFloat = 0 - getRed(&red, green: &green, blue: &blue, alpha: &alpha) - return String(format: "rgba(%d, %d, %d, %.3f)", Int(red * 255), Int(green * 255), Int(blue * 255), alpha) - } - - /// #RRGGBB hex 字符串 - var rd_hexString: String { - var red: CGFloat = 0 - var green: CGFloat = 0 - var blue: CGFloat = 0 - var alpha: CGFloat = 0 - getRed(&red, green: &green, blue: &blue, alpha: &alpha) - return String(format: "#%02X%02X%02X", Int(red * 255), Int(green * 255), Int(blue * 255)) - } - - /// 判断背景是否为暗色(BT.709 亮度 < 0.4) - var rd_isDarkBackground: Bool { - var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0 - guard getRed(&red, green: &green, blue: &blue, alpha: &alpha) else { return false } - return (0.2126 * red + 0.7152 * green + 0.0722 * blue) < 0.4 - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/UIColor+RDEPUBHex.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/UIColor+RDEPUBHex.swift deleted file mode 100644 index 829a05f..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/UIColor+RDEPUBHex.swift +++ /dev/null @@ -1,16 +0,0 @@ -import UIKit - -extension UIColor { - - convenience init?(rdHexString: String, alpha: CGFloat) { - var value = rdHexString.trimmingCharacters(in: .whitespacesAndNewlines) - value = value.replacingOccurrences(of: "#", with: "") - guard value.count == 6, let hex = Int(value, radix: 16) else { return nil } - self.init( - red: CGFloat((hex >> 16) & 0xFF) / 255, - green: CGFloat((hex >> 8) & 0xFF) / 255, - blue: CGFloat(hex & 0xFF) / 255, - alpha: alpha - ) - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/Paging/RDEpubReaderPagingController.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/Paging/RDEpubReaderPagingController.swift deleted file mode 100644 index 54d1782..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/Paging/RDEpubReaderPagingController.swift +++ /dev/null @@ -1,45 +0,0 @@ -import UIKit - -struct RDEpubReaderPagingController { - - struct PageTransitionRequest: Equatable { - let pageNum: Int - let animated: Bool - } - - var pendingTransitionRequest: PageTransitionRequest? - - var isTransitioning: Bool = false - - var didBuildUI = false - - static func createPageViewController(isDualPage: Bool) -> UIPageViewController { - let options: [UIPageViewController.OptionsKey: Any]? - if isDualPage { - options = [.spineLocation: NSNumber(value: UIPageViewController.SpineLocation.mid.rawValue)] - } else { - options = nil - } - let pageVC = UIPageViewController(transitionStyle: .pageCurl, navigationOrientation: .horizontal, options: options) - pageVC.isDoubleSided = isDualPage - return pageVC - } - - mutating func shouldQueuePageTransition(_ request: PageTransitionRequest, currentDisplayType: RDEpubReaderView.DisplayType) -> Bool { - guard currentDisplayType == .pageCurl, isTransitioning else { return false } - pendingTransitionRequest = request - return true - } - - mutating func finishPageCurlTransition() -> PageTransitionRequest? { - isTransitioning = false - guard let pending = pendingTransitionRequest else { return nil } - pendingTransitionRequest = nil - return pending - } - - mutating func resetPendingState() { - isTransitioning = false - pendingTransitionRequest = nil - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/Paging/RDEpubReaderPreloadController.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/Paging/RDEpubReaderPreloadController.swift deleted file mode 100644 index 51fb53a..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/Paging/RDEpubReaderPreloadController.swift +++ /dev/null @@ -1,342 +0,0 @@ - -import UIKit - -protocol RDEpubReaderCachePolicyProviding { - var shouldAvoidReaderPageCaching: Bool { get } -} - -final class RDEpubReaderPreloadController { - - var radius: Int = 2 - - private let preloadHostView = UIView() - - private var preloadedPageViews: [Int: UIView] = [:] - - private var pageCurlCachedViews: [Int: UIView] = [:] - - private var cacheSignature: CacheSignature? - - struct Environment { - - let displayType: RDEpubReaderView.DisplayType - - let isLandscape: Bool - - let pagesPerScreen: Int - - let boundsSize: CGSize - - let landscapeDualPageEnabled: Bool - - let coverPageIndex: Int? - - let totalPages: Int - - let spreadResolver: RDEpubReaderSpreadResolver - } - - private struct CacheSignature: Equatable { - - let displayType: RDEpubReaderView.DisplayType - - let isLandscape: Bool - - let pagesPerScreen: Int - - let boundsSize: CGSize - } - - func setHostFrame(_ frame: CGRect) { - preloadHostView.frame = frame - } - - func ensureHostView(in parentView: UIView) { - guard preloadHostView.superview == nil else { return } - preloadHostView.isHidden = true - preloadHostView.isUserInteractionEnabled = false - preloadHostView.clipsToBounds = true - preloadHostView.frame = parentView.bounds - preloadHostView.autoresizingMask = [.flexibleWidth, .flexibleHeight] - parentView.insertSubview(preloadHostView, at: 0) - } - - func initializeSignature(_ environment: Environment) { - cacheSignature = currentCacheSignature(environment) - } - - func invalidate(environment: Environment) { - RDEpubReaderTapDebug.log( - "PreloadController.invalidate", - "clearing pageCurlCached=\(pageCurlCachedViews.keys.sorted()) preloaded=\(preloadedPageViews.keys.sorted())" - ) - pageCurlCachedViews.values.forEach { $0.removeFromSuperview() } - preloadedPageViews.values.forEach { $0.removeFromSuperview() } - pageCurlCachedViews.removeAll() - preloadedPageViews.removeAll() - cacheSignature = currentCacheSignature(environment) - } - - func pageViewForDisplay( - pageNum: Int, - environment: Environment, - contentViewProvider: (Int, UIView?) -> UIView? - ) -> UIView { - let view: UIView - if let reusableView = detachedReusablePageView(for: pageNum) { - view = reusableView - RDEpubReaderTapDebug.log( - "PreloadController.pageViewForDisplay", - "cache HIT page=\(pageNum) view=\(RDEpubReaderTapDebug.describe(reusableView))" - ) - } else { - view = contentViewProvider(pageNum, nil) ?? UIView() - RDEpubReaderTapDebug.log( - "PreloadController.pageViewForDisplay", - "cache MISS page=\(pageNum) created=\(RDEpubReaderTapDebug.describe(view))" - ) - } - if shouldCache(view: view, for: pageNum, environment: environment) { - pageCurlCachedViews[pageNum] = view - } else { - pageCurlCachedViews.removeValue(forKey: pageNum) - } - return view - } - - func takePreloadedView(for pageNum: Int) -> UIView? { - let preloaded = preloadedPageViews.removeValue(forKey: pageNum) - preloaded?.removeFromSuperview() - RDEpubReaderTapDebug.log( - "PreloadController.takePreloadedView", - "cache \(preloaded == nil ? "MISS" : "HIT") page=\(pageNum) view=\(RDEpubReaderTapDebug.describe(preloaded))" - ) - return preloaded - } - - func prime( - around pageNum: Int, - preferredForward: Bool? = nil, - parentView: UIView, - environment: Environment, - contentViewProvider: (Int, UIView?) -> UIView? - ) { - refreshCacheSignatureIfNeeded(environment) - let targets = forecastTargets(around: pageNum, preferredForward: preferredForward, environment: environment) - let keepSet = Set(targets).union(visiblePageNumbers(for: pageNum, environment: environment)) - trimCachedPageViews(keeping: keepSet) - guard !targets.isEmpty else { return } - - ensureHostView(in: parentView) - preloadHostView.frame = parentView.bounds - - for targetPage in targets { - let contentView: UIView - if let existing = preloadedPageViews[targetPage], existing.superview === preloadHostView { - contentView = existing - RDEpubReaderTapDebug.log( - "PreloadController.prime", - "cache HIT(preloaded) page=\(targetPage) view=\(RDEpubReaderTapDebug.describe(existing))" - ) - } else if let cached = pageCurlCachedViews.removeValue(forKey: targetPage), cached.superview == nil { - contentView = cached - RDEpubReaderTapDebug.log( - "PreloadController.prime", - "cache HIT(pageCurl) page=\(targetPage) view=\(RDEpubReaderTapDebug.describe(cached))" - ) - } else { - guard let providedView = contentViewProvider(targetPage, nil) else { - // 页面提供者明确禁止预加载时不能塞入空白占位缓存,否则实际展示 - // 会直接命中该白页,而不再请求真实内容。 - preloadedPageViews.removeValue(forKey: targetPage) - pageCurlCachedViews.removeValue(forKey: targetPage) - continue - } - contentView = providedView - RDEpubReaderTapDebug.log( - "PreloadController.prime", - "cache MISS page=\(targetPage) created=\(RDEpubReaderTapDebug.describe(contentView))" - ) - } - let shouldCacheContentView = shouldCache(view: contentView, for: targetPage, environment: environment) - if shouldCacheContentView { - preloadedPageViews[targetPage] = contentView - } else { - preloadedPageViews.removeValue(forKey: targetPage) - } - if contentView.superview !== preloadHostView { - contentView.removeFromSuperview() - preloadHostView.addSubview(contentView) - } - contentView.frame = preloadHostView.bounds - if !shouldCacheContentView { - contentView.removeFromSuperview() - } - } - } - - private func currentCacheSignature(_ environment: Environment) -> CacheSignature { - CacheSignature( - displayType: environment.displayType, - isLandscape: environment.isLandscape, - pagesPerScreen: environment.pagesPerScreen, - boundsSize: environment.boundsSize - ) - } - - private func refreshCacheSignatureIfNeeded(_ environment: Environment) { - let signature = currentCacheSignature(environment) - if cacheSignature != signature { - invalidate(environment: environment) - } else if cacheSignature == nil { - cacheSignature = signature - } - } - - private func shouldCachePage(_ pageNum: Int, environment: Environment) -> Bool { - pageNum >= 0 - && pageNum != RDEpubReaderView.blankPageNum - && pageNum != RDEpubReaderView.blankEndPageNum - && pageNum < environment.totalPages - } - - private func shouldCache(view: UIView, for pageNum: Int, environment: Environment) -> Bool { - guard shouldCachePage(pageNum, environment: environment) else { return false } - if let policyProvider = view as? RDEpubReaderCachePolicyProviding { - return !policyProvider.shouldAvoidReaderPageCaching - } - return true - } - - private func isDualPage(environment: Environment) -> Bool { - environment.landscapeDualPageEnabled - && environment.isLandscape - && environment.displayType != .verticalScroll - } - - private func spreadStart(for pageNum: Int, environment: Environment) -> Int? { - guard shouldCachePage(pageNum, environment: environment) else { return nil } - guard isDualPage(environment: environment) else { return pageNum } - return environment.spreadResolver.dualPagePair( - for: pageNum, - totalPages: environment.totalPages, - coverPageIndex: environment.coverPageIndex - ).left - } - - private func spreadPageNumbers(startingAt pageNum: Int, environment: Environment) -> Set { - guard shouldCachePage(pageNum, environment: environment) else { return [] } - guard isDualPage(environment: environment) else { return [pageNum] } - let pair = environment.spreadResolver.dualPagePair( - for: pageNum, - totalPages: environment.totalPages, - coverPageIndex: environment.coverPageIndex - ) - var pages: Set = [pair.left] - if let right = pair.right, shouldCachePage(right, environment: environment) { - pages.insert(right) - } - return pages - } - - private func adjacentSpreadStart(from pageNum: Int, forward: Bool, environment: Environment) -> Int? { - guard environment.totalPages > 0, - let spreadStart = spreadStart(for: pageNum, environment: environment) else { - return nil - } - - guard isDualPage(environment: environment) else { - let target = forward ? spreadStart + 1 : spreadStart - 1 - return shouldCachePage(target, environment: environment) ? target : nil - } - - if forward { - return environment.spreadResolver.adjacentDualPage( - from: spreadStart, - totalPages: environment.totalPages, - coverPageIndex: environment.coverPageIndex, - forward: true - ) - } - - let pair = environment.spreadResolver.dualPagePair( - for: spreadStart, - totalPages: environment.totalPages, - coverPageIndex: environment.coverPageIndex - ) - let prevEnd = pair.left - 1 - guard prevEnd >= 0 else { return nil } - return environment.spreadResolver.dualPagePair( - for: prevEnd, - totalPages: environment.totalPages, - coverPageIndex: environment.coverPageIndex - ).left - } - - private func visiblePageNumbers(for anchorPage: Int, environment: Environment) -> Set { - spreadPageNumbers(startingAt: anchorPage, environment: environment) - } - - private func detachedReusablePageView(for pageNum: Int) -> UIView? { - if let preloaded = preloadedPageViews.removeValue(forKey: pageNum) { - preloaded.removeFromSuperview() - return preloaded - } - - guard let cached = pageCurlCachedViews[pageNum], cached.superview == nil else { - return nil - } - - pageCurlCachedViews.removeValue(forKey: pageNum) - return cached - } - - private func trimCachedPageViews(keeping pageNumbers: Set) { - pageCurlCachedViews = pageCurlCachedViews.filter { key, value in - let keep = pageNumbers.contains(key) - if !keep { - value.removeFromSuperview() - } - return keep - } - - preloadedPageViews = preloadedPageViews.filter { key, value in - let keep = pageNumbers.contains(key) - if !keep { - value.removeFromSuperview() - } - return keep - } - } - - private func forecastTargets(around pageNum: Int, preferredForward: Bool?, environment: Environment) -> [Int] { - guard environment.totalPages > 0, - shouldCachePage(pageNum, environment: environment) else { - return [] - } - - var targets = Set() - - var previousAnchor = pageNum - for _ in 0.. Bool { - guard landscapeDualPageEnabled, isLandscape, let coverIndex = coverPageIndex else { return false } - return pageNum == coverIndex - } - - func dualPagePair( - for pageNum: Int, - totalPages: Int, - coverPageIndex: Int? - ) -> (left: Int, right: Int?) { - if let coverIndex = coverPageIndex { - if pageNum == coverIndex { - return (coverIndex, nil) - } - let adjustedIndex = pageNum - (coverIndex + 1) - let pairStart = coverIndex + 1 + (adjustedIndex / 2) * 2 - let left = pairStart - let right = pairStart + 1 < totalPages ? pairStart + 1 : nil - return (left, right) - } - - let left = (pageNum / 2) * 2 - let right = left + 1 < totalPages ? left + 1 : nil - return (left, right) - } - - func adjacentDualPage( - from pageNum: Int, - totalPages: Int, - coverPageIndex: Int?, - forward: Bool - ) -> Int? { - let pair = dualPagePair(for: pageNum, totalPages: totalPages, coverPageIndex: coverPageIndex) - if forward { - let nextStart = (pair.right ?? pair.left) + 1 - return nextStart < totalPages ? nextStart : nil - } - - let prevEnd = pair.left - 1 - guard prevEnd >= 0 else { return nil } - return dualPagePair(for: prevEnd, totalPages: totalPages, coverPageIndex: coverPageIndex).left - } - - func nextPage( - from currentPage: Int, - totalPages: Int, - pagesPerScreen: Int, - coverPageIndex: Int?, - forward: Bool - ) -> Int? { - if pagesPerScreen > 1 { - return adjacentDualPage( - from: currentPage, - totalPages: totalPages, - coverPageIndex: coverPageIndex, - forward: forward - ) - } - - let target = currentPage + (forward ? 1 : -1) - guard target >= 0, target < totalPages else { return nil } - return target - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/Paging/RDEpubReaderTapRegionHandler.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/Paging/RDEpubReaderTapRegionHandler.swift deleted file mode 100644 index 01769ca..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/Paging/RDEpubReaderTapRegionHandler.swift +++ /dev/null @@ -1,29 +0,0 @@ - -import CoreGraphics - -struct RDEpubReaderTapRegionHandler { - - func resolveTapEvent( - point: CGPoint, - viewFrame: CGRect, - isToolViewVisible: Bool - ) -> RDEpubReaderView.TapEvent { - let leftFrame = CGRect(x: 0, y: 0, width: viewFrame.width / 3, height: viewFrame.height) - let centerFrame = CGRect(x: viewFrame.width / 3, y: 0, width: viewFrame.width / 3, height: viewFrame.height) - let rightFrame = CGRect(x: viewFrame.width * 2 / 3, y: 0, width: viewFrame.width / 3, height: viewFrame.height) - - if leftFrame.contains(point) { - return isToolViewVisible ? .center : .left - } - - if centerFrame.contains(point) { - return .center - } - - if rightFrame.contains(point) { - return isToolViewVisible ? .center : .right - } - - return .none - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderContentCell.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderContentCell.swift deleted file mode 100644 index 3d81284..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderContentCell.swift +++ /dev/null @@ -1,35 +0,0 @@ - -import UIKit - -class RDEpubReaderContentCell: UICollectionViewCell { - - private var _containerView: UIView? = nil - - var containerView: UIView? { - set { - guard newValue !== _containerView else { return } - _containerView?.removeFromSuperview() - _containerView = newValue - if let containerView = _containerView { - contentView.addSubview(containerView) - setNeedsLayout() - } - } - get { - return _containerView - } - } - - override func layoutSubviews() { - super.layoutSubviews() - _containerView?.frame = CGRect(x: 0, y: 0, width: contentView.frame.width, height: contentView.frame.height) - } - - override init(frame: CGRect) { - super.init(frame: frame) - accessibilityIdentifier = "epub.reader.content.cell" - } - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderFlowLayout.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderFlowLayout.swift deleted file mode 100644 index 544c8b7..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderFlowLayout.swift +++ /dev/null @@ -1,305 +0,0 @@ - -import UIKit - -public protocol RDEpubReaderFlowLayoutDataSoure: NSObjectProtocol { - - func heigtOfVerticalScrollPage(flowLayout: RDEpubReaderFlowLayout, pageIndex: Int) -> CGFloat? -} - -@objc public protocol RDEpubReaderFlowLayoutDelegate: NSObjectProtocol { - - func pageNum(flowLayout: RDEpubReaderFlowLayout, pageIndex: Int) -} - -public class RDEpubReaderFlowLayout: UICollectionViewFlowLayout { - - var displayType: RDEpubReaderView.DisplayType = .horizontalScroll { - didSet { - invalidateLayout() - } - } - - var isLandscapeDualPage: Bool = false { - didSet { - if oldValue != isLandscapeDualPage { - invalidateLayout() - } - } - } - - var coverPageIndex: Int? = nil { - didSet { - if oldValue != coverPageIndex { - invalidateLayout() - } - } - } - - private var lastPreparedBoundsSize: CGSize = .zero - - var pagesPerScreen: Int { - guard isLandscapeDualPage else { return 1 } - switch displayType { - case .horizontalScroll: - if let cv = collectionView, cv.bounds.width > cv.bounds.height { - return 2 - } - return 1 - default: - return 1 - } - } - - weak var dataSource: RDEpubReaderFlowLayoutDataSoure? = nil - - weak var delegate: RDEpubReaderFlowLayoutDelegate? = nil - - private var hasCoverPageInDualMode: Bool { - return pagesPerScreen > 1 && coverPageIndex != nil - } - - private func coverAwareFrame(for index: Int, screenWidth: CGFloat, halfWidth: CGFloat, height: CGFloat) -> CGRect { - guard let coverIndex = coverPageIndex else { - let pairIdx = index / 2 - let side = index % 2 - let x = CGFloat(pairIdx) * screenWidth + CGFloat(side) * halfWidth - return CGRect(x: x, y: 0, width: halfWidth, height: height) - } - if index == coverIndex { - let screensBeforeCover = coverIndex / 2 - let x = CGFloat(screensBeforeCover) * screenWidth - return CGRect(x: x, y: 0, width: screenWidth, height: height) - } - let adjustedIndex = index - (coverIndex + 1) - let pairIdx = adjustedIndex / 2 - let side = adjustedIndex % 2 - let coverScreens = coverIndex / 2 + 1 - let x = CGFloat(coverScreens + pairIdx) * screenWidth + CGFloat(side) * halfWidth - return CGRect(x: x, y: 0, width: halfWidth, height: height) - } - - private func coverAwareStartIndex(for offset: CGFloat, screenWidth: CGFloat) -> Int { - let pps = pagesPerScreen - guard let coverIdx = coverPageIndex, hasCoverPageInDualMode else { - let screenIndex = Int((offset / screenWidth).rounded(.down)) - return screenIndex * pps - } - let coverScreens = coverIdx / 2 + 1 - let coverEnd = CGFloat(coverScreens) * screenWidth - if offset < coverEnd { - let screenIdx = Int((offset / screenWidth).rounded(.down)) - return screenIdx >= coverIdx / 2 ? coverIdx : screenIdx * pps - } else { - let pairIdx = Int(((offset - coverEnd) / screenWidth).rounded(.down)) - return coverIdx + 1 + pairIdx * 2 - } - } - - private var currentPage: Int = 0 { - - didSet { - if let delegate = delegate, delegate.responds(to: #selector(RDEpubReaderFlowLayoutDelegate.pageNum(flowLayout:pageIndex:))), currentPage != oldValue, currentPage >= 0 { - delegate.pageNum(flowLayout: self, pageIndex: currentPage) - } - } - } - - init(displayType: RDEpubReaderView.DisplayType) { - self.displayType = displayType - super.init() - - } - - public override func prepare() { - super.prepare() - guard let collectionView = self.collectionView else { - return - } - guard collectionView.frame.width > 0, collectionView.frame.height > 0 else { - return - } - - lastPreparedBoundsSize = collectionView.bounds.size - - if #available(iOS 11.0, *) { - collectionView.contentInsetAdjustmentBehavior = .never - } else { - collectionView.ss_superViewController?.automaticallyAdjustsScrollViewInsets = false - } - collectionView.showsVerticalScrollIndicator = false - collectionView.showsHorizontalScrollIndicator = false - minimumLineSpacing = 0 - minimumInteritemSpacing = 0 - switch self.displayType { - case .horizontalScroll: - scrollDirection = .horizontal - let columns = CGFloat(pagesPerScreen) - itemSize = CGSize(width: collectionView.frame.width / columns, height: collectionView.frame.height) - collectionView.isPagingEnabled = true - case .verticalScroll: - itemSize = CGSize(width: collectionView.frame.width, height: collectionView.frame.height) - scrollDirection = .vertical - collectionView.isPagingEnabled = false - collectionView.showsVerticalScrollIndicator = true - - default: break - } - - } - - public override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { - if newBounds.size != lastPreparedBoundsSize { - return true - } - switch displayType { - case .verticalScroll: - return true - case .horizontalScroll: - return true - default: - return false - } - } - - public override var collectionViewContentSize: CGSize { - var size = super.collectionViewContentSize - guard let collectionView = collectionView else { - return size - } - if displayType == .verticalScroll { - let totalHeight = [Int](0.. [UICollectionViewLayoutAttributes]? { - var attributes = super.layoutAttributesForElements(in: rect) - guard let collectionView = collectionView else { - return attributes - } - - if self.displayType == .verticalScroll { - let rowCount = collectionView.numberOfItems(inSection: 0) - var totalHeight: CGFloat = 0 - for index in 0.. collectionView.contentOffset.y { - self.currentPage = index - break - } - } - (attributes ?? []).forEach({ attr in - let indexPath = attr.indexPath - let lastRow = max(0, indexPath.row - 1) - let height = dataSource?.heigtOfVerticalScrollPage(flowLayout: self, pageIndex: indexPath.row) ?? collectionView.frame.height - var lastTotalHeight: CGFloat = 0 - if indexPath.row > 0 { - lastTotalHeight = [Int](0...lastRow).reduce(0.0) { result, row in - result + (dataSource?.heigtOfVerticalScrollPage(flowLayout: self, pageIndex: row) ?? collectionView.frame.height) - } - } - if let cell = collectionView.cellForItem(at: indexPath) { - cell.layer.shadowOpacity = 0 - } - attr.frame = CGRect(x: 0, y: lastTotalHeight, width: collectionView.frame.width, height: height) - }) - } - - if self.displayType == .horizontalScroll { - let pps = pagesPerScreen - - if hasCoverPageInDualMode { - let screenWidth = collectionView.frame.width - let halfWidth = screenWidth / 2.0 - let rows = collectionView.numberOfItems(inSection: 0) - - self.currentPage = coverAwareStartIndex(for: collectionView.contentOffset.x, screenWidth: screenWidth) - - var attrs = [UICollectionViewLayoutAttributes]() - for index in 0.. 1 { - let currentPage = Int((collectionView.contentOffset.x / collectionView.frame.width).rounded(.down)) * pps - self.currentPage = currentPage - (attributes ?? []).forEach({ attr in - if let cell = collectionView.cellForItem(at: attr.indexPath) { - cell.layer.shadowOpacity = 0 - } - }) - } else { - let currentPage = Int((collectionView.contentOffset.x / collectionView.frame.width).rounded(.down)) - self.currentPage = currentPage - (attributes ?? []).forEach({ attr in - if let cell = collectionView.cellForItem(at: attr.indexPath) { - cell.layer.shadowOpacity = 0 - } - }) - } - } - - return attributes - } - - func currentContentOffset(count: Int) -> CGPoint { - guard let collectionView = collectionView else { - return .zero - } - guard collectionView.frame.width > 0, collectionView.frame.height > 0 else { - return .zero - } - let safeCount = max(0, count) - switch displayType { - case .verticalScroll: - guard safeCount > 0 else { return .zero } - let totalHeight = [Int](0...(safeCount - 1)).reduce(0.0) { result, pageNum in - result + (dataSource?.heigtOfVerticalScrollPage(flowLayout: self, pageIndex: pageNum) ?? collectionView.frame.height) - } - return CGPoint(x: 0, y: totalHeight) - default: - let pps = pagesPerScreen - let screenWidth = collectionView.frame.width - if hasCoverPageInDualMode, let coverIdx = coverPageIndex { - if safeCount == coverIdx { - let screensBeforeCover = coverIdx / 2 - return CGPoint(x: CGFloat(screensBeforeCover) * screenWidth, y: 0) - } - if safeCount < coverIdx { - return CGPoint(x: CGFloat(safeCount / 2) * screenWidth, y: 0) - } - let coverScreens = coverIdx / 2 + 1 - let adjustedIndex = safeCount - (coverIdx + 1) - let pairIndex = adjustedIndex / 2 - return CGPoint(x: CGFloat(coverScreens + pairIndex) * screenWidth, y: 0) - } else if pps > 1 { - let screenIndex = safeCount / pps - return CGPoint(x: CGFloat(screenIndex) * screenWidth, y: 0) - } else { - return CGPoint(x: CGFloat(safeCount) * itemSize.width, y: 0) - } - } - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderGestureController.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderGestureController.swift deleted file mode 100644 index cb5ce0e..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderGestureController.swift +++ /dev/null @@ -1,25 +0,0 @@ - -import UIKit - -class RDEpubReaderGestureController: UIViewController { - - var topToolView: UIView? - - var bottomToolView: UIView? - - init(topToolView: UIView?, bottomToolView: UIView?) { - self.topToolView = topToolView - self.bottomToolView = bottomToolView - super.init(nibName: nil, bundle: nil) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - - } - -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderPageChildViewController.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderPageChildViewController.swift deleted file mode 100644 index 015256a..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderPageChildViewController.swift +++ /dev/null @@ -1,50 +0,0 @@ - -import UIKit - -class RDEpubReaderPageChildViewController: UIViewController { - - private let contentContainerView = UIView() - - var contentView: UIView? { - didSet { - guard isViewLoaded else { return } - installContentView() - } - } - - var pageNum: Int = 0 - - init(contentView: UIView?, pageNum: Int = 0) { - self.contentView = contentView - self.pageNum = pageNum - super.init(nibName: nil, bundle: nil) - } - - override func loadView() { - view = UIView() - view.backgroundColor = .clear - contentContainerView.frame = view.bounds - contentContainerView.autoresizingMask = [.flexibleWidth, .flexibleHeight] - contentContainerView.backgroundColor = .clear - view.addSubview(contentContainerView) - installContentView() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - override func viewDidLoad() { - super.viewDidLoad() - - } - - private func installContentView() { - contentContainerView.subviews.forEach { $0.removeFromSuperview() } - guard let contentView else { return } - contentView.removeFromSuperview() - contentView.frame = contentContainerView.bounds - contentView.autoresizingMask = [.flexibleWidth, .flexibleHeight] - contentContainerView.addSubview(contentView) - } - -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView+CollectionView.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView+CollectionView.swift deleted file mode 100644 index f9ff05c..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView+CollectionView.swift +++ /dev/null @@ -1,42 +0,0 @@ - -import UIKit - -extension RDEpubReaderView: UICollectionViewDataSource, RDEpubReaderFlowLayoutDelegate, RDEpubReaderFlowLayoutDataSoure { - - public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - - if let identifer = pageReuseIdentifier(for: indexPath.row) { - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: identifer, for: IndexPath(item: indexPath.row, section: 0)) as! RDEpubReaderContentCell - let preloadedView = preloadController.takePreloadedView(for: indexPath.row) - let reusableView = cell.containerView ?? preloadedView - cell.containerView = contentViewForPage(indexPath.row, reusableView: reusableView) - - if pageDirection == .rightToLeft && currentDisplayType != .verticalScroll { - cell.contentView.transform = CGAffineTransform(scaleX: -1, y: 1) - } else { - cell.contentView.transform = .identity - } - return cell - } - - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: NSStringFromClass(UICollectionViewCell.self), for: indexPath) - - return cell - } - - public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - return numberOfPages() - } - - public func pageNum(flowLayout: RDEpubReaderFlowLayout, pageIndex: Int) { - if currentPage >= 0, currentPage != pageIndex { - predictedPageDirection = pageIndex >= currentPage - } - currentPage = pageIndex - primePageCache(around: pageIndex, preferredForward: predictedPageDirection) - } - - public func heigtOfVerticalScrollPage(flowLayout: RDEpubReaderFlowLayout, pageIndex: Int) -> CGFloat? { - return nil - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView+ContentAccess.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView+ContentAccess.swift deleted file mode 100644 index a84e621..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView+ContentAccess.swift +++ /dev/null @@ -1,68 +0,0 @@ - -import UIKit - -extension RDEpubReaderView { - - public func register(contentView: UIView.Type, contentViewWithReuseIdentifier identifier: String) { - contentViews[identifier] = contentView - collectionView.register(RDEpubReaderContentCell.self, forCellWithReuseIdentifier: identifier) - } - - public func dequeueReusableContentView(withReuseIdentifier identifier: String, for pageNum: Int) -> UIView { - if self.currentDisplayType != .pageCurl, let cell = self.collectionView.cellForItem(at: IndexPath(row: pageNum, section: 0)) as? RDEpubReaderContentCell, let containerView = cell.containerView { - return containerView - } - let contentViewClass = contentViews[identifier] - assert(contentViewClass != nil, "请调用register(contentView:contentViewWithReuseIdentifier:)") - let contentView = contentViewClass!.init() - return contentView - } - - public func pageContentView(pageNum: Int) -> UIView? { - if currentDisplayType == .pageCurl { - return (self.pageViewController.viewControllers?.first as? RDEpubReaderPageChildViewController)?.contentView - } else { - let cell = collectionView.cellForItem(at: IndexPath(item: pageNum, section: 0)) as? RDEpubReaderContentCell - return cell?.containerView - } - } - - public func resolvedSinglePageSize(pageNum: Int? = nil) -> CGSize { - let targetPage = pageNum ?? (currentPage >= 0 ? currentPage : nil) - if let targetPage, - let contentView = pageContentView(pageNum: targetPage), - contentView.bounds.width > 0, - contentView.bounds.height > 0 { - return contentView.bounds.size - } - - if currentDisplayType == .pageCurl { - if let childViewController = pageViewController.viewControllers?.first as? RDEpubReaderPageChildViewController, - childViewController.view.bounds.width > 0, - childViewController.view.bounds.height > 0 { - return childViewController.view.bounds.size - } - if pageViewController.view.bounds.width > 0, pageViewController.view.bounds.height > 0 { - return pageViewController.view.bounds.size - } - return bounds.size - } - - let containerBounds = collectionView.bounds.size == .zero ? bounds.size : collectionView.bounds.size - guard containerBounds.width > 0, containerBounds.height > 0 else { - return bounds.size - } - - switch currentDisplayType { - case .horizontalScroll: - return CGSize( - width: containerBounds.width / CGFloat(max(pagesPerScreen, 1)), - height: containerBounds.height - ) - case .verticalScroll: - return containerBounds - case .pageCurl: - return bounds.size - } - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView+PageCurl.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView+PageCurl.swift deleted file mode 100644 index ae910c8..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView+PageCurl.swift +++ /dev/null @@ -1,123 +0,0 @@ - -import UIKit - -extension RDEpubReaderView: UIPageViewControllerDataSource, UIPageViewControllerDelegate { - - private func makeSinglePageChildVC(for pageNum: Int) -> RDEpubReaderPageChildViewController { - if pageNum == RDEpubReaderView.blankPageNum || pageNum == RDEpubReaderView.blankEndPageNum { - return RDEpubReaderPageChildViewController(contentView: UIView(), pageNum: pageNum) - } - let contentView = pageViewForDisplay(pageNum: pageNum) - return RDEpubReaderPageChildViewController(contentView: contentView, pageNum: pageNum) - } - - private func nextPageNum(after pageNum: Int, isDualPage: Bool) -> Int? { - let totalPages = numberOfPages() - - if pageNum == RDEpubReaderView.blankEndPageNum { - return nil - } - - if isDualPage, let coverIndex = coverPageIndex { - if pageNum == coverIndex { - return RDEpubReaderView.blankPageNum - } - if pageNum == RDEpubReaderView.blankPageNum { - let firstContent = coverIndex + 1 - return firstContent < totalPages ? firstContent : nil - } - } - - let next = pageNum + 1 - if next < totalPages { - return next - } - - if isDualPage { - if let coverIndex = coverPageIndex { - let adjustedIndex = pageNum - (coverIndex + 1) - if adjustedIndex >= 0 && adjustedIndex % 2 == 0 { - return RDEpubReaderView.blankEndPageNum - } - } else { - if pageNum % 2 == 0 { - return RDEpubReaderView.blankEndPageNum - } - } - } - return nil - } - - private func prevPageNum(before pageNum: Int, isDualPage: Bool) -> Int? { - if pageNum == RDEpubReaderView.blankEndPageNum { - let totalPages = numberOfPages() - return totalPages > 0 ? totalPages - 1 : nil - } - if isDualPage, let coverIndex = coverPageIndex { - if pageNum == RDEpubReaderView.blankPageNum { - return coverIndex - } - if pageNum == coverIndex + 1 { - return RDEpubReaderView.blankPageNum - } - } - let prev = pageNum - 1 - return prev >= 0 ? prev : nil - } - - public func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? { - guard let vc = viewController as? RDEpubReaderPageChildViewController else { return nil } - let isDualPage = landscapeDualPageEnabled && isLandscape - let isRTL = pageDirection == .rightToLeft - - let targetNum = isRTL - ? nextPageNum(after: vc.pageNum, isDualPage: isDualPage) - : prevPageNum(before: vc.pageNum, isDualPage: isDualPage) - - guard let num = targetNum else { return nil } - let targetVC = makeSinglePageChildVC(for: num) - willPreviousTransitionToViewController = targetVC - return targetVC - } - - public func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? { - guard let vc = viewController as? RDEpubReaderPageChildViewController else { return nil } - let isDualPage = landscapeDualPageEnabled && isLandscape - let isRTL = pageDirection == .rightToLeft - - let targetNum = isRTL - ? prevPageNum(before: vc.pageNum, isDualPage: isDualPage) - : nextPageNum(after: vc.pageNum, isDualPage: isDualPage) - - guard let num = targetNum else { return nil } - let targetVC = makeSinglePageChildVC(for: num) - willNextTransitionToViewController = targetVC - return targetVC - } - - public func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) { - let resolvedPage = (pageViewController.viewControllers?.first as? RDEpubReaderPageChildViewController)?.pageNum ?? -1 - if completed, let firstVC = pageViewController.viewControllers?.first as? RDEpubReaderPageChildViewController { - let pn = firstVC.pageNum - if pn != RDEpubReaderView.blankPageNum && pn != RDEpubReaderView.blankEndPageNum { - currentPage = pn - primePageCache(around: pn, preferredForward: predictedPageDirection) - } - } - - predictedPageDirection = nil - finishPageCurlTransition() - } - - public func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) { - pagingController.isTransitioning = true - willTransitionToViewController = pendingViewControllers.first - if let target = pendingViewControllers.first as? RDEpubReaderPageChildViewController, - target.pageNum != RDEpubReaderView.blankPageNum, - target.pageNum != RDEpubReaderView.blankEndPageNum { - predictedPageDirection = target.pageNum >= currentPage - primePageCache(around: target.pageNum, preferredForward: predictedPageDirection) - } else { - } - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView+ToolView.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView+ToolView.swift deleted file mode 100644 index 881c61c..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView+ToolView.swift +++ /dev/null @@ -1,136 +0,0 @@ - -import UIKit - -extension RDEpubReaderView { - - func tapCenter() { - refreshToolViewsFromProviderIfNeeded() - isShowToolView = !isShowToolView - RDEpubReaderTapDebug.log( - "ReaderView.tapCenter", - "toggle toolView visible=\(isShowToolView) top=\(RDEpubReaderTapDebug.describe(topToolView)) bottom=\(RDEpubReaderTapDebug.describe(bottomToolView))" - ) - if isShowToolView { - if let topToolView = topToolView { - installToolViewIfNeeded(topToolView, position: .top) - layoutIfNeeded() - topToolView.transform = CGAffineTransform(translationX: 0, y: -topToolView.bounds.height) - UIView.animate(withDuration: toolViewAnimationDuration) { - topToolView.transform = .identity - } - } - - if let bottomToolView = bottomToolView { - installToolViewIfNeeded(bottomToolView, position: .bottom) - layoutIfNeeded() - bottomToolView.transform = CGAffineTransform(translationX: 0, y: bottomToolView.bounds.height) - UIView.animate(withDuration: toolViewAnimationDuration) { - bottomToolView.transform = .identity - } - } - - collectionView.isUserInteractionEnabled = false - pageViewController.view.isUserInteractionEnabled = false - RDEpubReaderTapDebug.log("ReaderView.tapCenter", "disabled collectionView/pageViewController interaction while toolView is visible") - } else { - if let topToolView = topToolView { - UIView.animate(withDuration: toolViewAnimationDuration, animations: { - topToolView.transform = CGAffineTransform(translationX: 0, y: -topToolView.bounds.height) - }) { _ in - topToolView.removeFromSuperview() - } - } - - if let bottomToolView = bottomToolView { - UIView.animate(withDuration: toolViewAnimationDuration, animations: { - bottomToolView.transform = CGAffineTransform(translationX: 0, y: bottomToolView.bounds.height) - }) { _ in - bottomToolView.removeFromSuperview() - } - } - - collectionView.isUserInteractionEnabled = true - pageViewController.view.isUserInteractionEnabled = true - RDEpubReaderTapDebug.log("ReaderView.tapCenter", "re-enabled collectionView/pageViewController interaction after hiding toolView") - } - onToolViewVisibilityChanged?(isShowToolView) - } - - func isHitView(_ hitView: UIView?, inside toolView: UIView, point: CGPoint) -> Bool { - if let hitView, hitView === toolView || hitView.isDescendant(of: toolView) { - return true - } - - if toolView.frame.contains(point) { - // For search bar views, only consider the hit inside the visible - // panel area. Taps on the dimmed scrim should pass through to - // the reader content to allow chrome toggling. Touches that - // resolved to a search bar subview (e.g. the accessibility-only - // navigation buttons outside the panel) are already claimed by - // the descendant check above. - if let searchBarView = toolView as? RDEPUBReaderSearchBarView { - let panelPoint = convert(point, to: searchBarView.panelView) - if !searchBarView.panelView.point(inside: panelPoint, with: nil) { - return false - } - } - return true - } - - return false - } - - enum ToolViewPosition { - - case top - - case bottom - } - - func installToolViewIfNeeded(_ toolView: UIView, position: ToolViewPosition) { - guard toolView.superview !== self else { return } - - toolView.removeFromSuperview() - addSubview(toolView) - toolView.translatesAutoresizingMaskIntoConstraints = false - - let heightConstraint: NSLayoutConstraint - switch position { - case .top: - topToolViewHeightConstraint?.isActive = false - heightConstraint = toolView.heightAnchor.constraint(equalToConstant: resolvedToolViewHeight(for: .top)) - topToolViewHeightConstraint = heightConstraint - NSLayoutConstraint.activate([ - toolView.leadingAnchor.constraint(equalTo: leadingAnchor), - toolView.trailingAnchor.constraint(equalTo: trailingAnchor), - toolView.topAnchor.constraint(equalTo: topAnchor), - heightConstraint - ]) - case .bottom: - bottomToolViewHeightConstraint?.isActive = false - heightConstraint = toolView.heightAnchor.constraint(equalToConstant: resolvedToolViewHeight(for: .bottom)) - bottomToolViewHeightConstraint = heightConstraint - NSLayoutConstraint.activate([ - toolView.leadingAnchor.constraint(equalTo: leadingAnchor), - toolView.trailingAnchor.constraint(equalTo: trailingAnchor), - toolView.bottomAnchor.constraint(equalTo: bottomAnchor), - heightConstraint - ]) - } - } - - func updateToolViewHeightConstraintsIfNeeded() { - topToolViewHeightConstraint?.constant = resolvedToolViewHeight(for: .top) - bottomToolViewHeightConstraint?.constant = resolvedToolViewHeight(for: .bottom) - } - - private func resolvedToolViewHeight(for position: ToolViewPosition) -> CGFloat { - let contentHeight: CGFloat = 52 - switch position { - case .top: - return safeAreaInsets.top + contentHeight - case .bottom: - return safeAreaInsets.bottom + contentHeight - } - } -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView.swift deleted file mode 100644 index 6949089..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView.swift +++ /dev/null @@ -1,939 +0,0 @@ - -import UIKit - -enum RDEpubReaderTapDebug { - - private static let enabledArguments: Set = [ - "--demo-tap-debug", - "--demo-reader-interaction-debug" - ] - - static var isEnabled: Bool { -#if DEBUG - return true -#else - let arguments = ProcessInfo.processInfo.arguments - if arguments.contains(where: { enabledArguments.contains($0) }) { - return true - } - - let environment = ProcessInfo.processInfo.environment - return environment["RDEpubReader_TAP_DEBUG"] == "1" -#endif - } - - static func log(_ scope: String, _ message: String) { - guard isEnabled else { return } - let threadRole = Thread.isMainThread ? "main" : "bg" - let queueLabel = String(validatingUTF8: __dispatch_queue_get_label(nil)) ?? "unknown" - print("[RDEpubReaderTap][\(scope)][\(threadRole)][queue=\(queueLabel)] \(message)") - } - - static func describe(_ point: CGPoint) -> String { - "(\(format(point.x)), \(format(point.y)))" - } - - static func describe(_ rect: CGRect) -> String { - "(x:\(format(rect.origin.x)), y:\(format(rect.origin.y)), w:\(format(rect.size.width)), h:\(format(rect.size.height)))" - } - - static func describe(_ view: UIView?) -> String { - guard let view else { return "nil" } - let identifier = view.accessibilityIdentifier ?? "nil" - return "\(type(of: view))(addr=\(Unmanaged.passUnretained(view).toOpaque()), id=\(identifier))" - } - - private static func format(_ value: CGFloat) -> String { - String(format: "%.1f", value) - } -} - -public class RDEpubReaderView: UIView { - - enum TapEvent { - - case none - - case left - - case center - - case right - } - - lazy var pageViewController: UIPageViewController = { - let pageVC = UIPageViewController(transitionStyle: .pageCurl, navigationOrientation: .horizontal, options: nil) - pageVC.delegate = self - pageVC.dataSource = self - return pageVC - }() - - lazy var layout: RDEpubReaderFlowLayout = { - let layout = RDEpubReaderFlowLayout(displayType: .horizontalScroll) - layout.dataSource = self - layout.delegate = self - return layout - }() - - lazy var collectionView: UICollectionView = { - - let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) - collectionView.backgroundColor = UIColor.clear - collectionView.accessibilityIdentifier = "epub.reader.paging" - return collectionView - }() - - private let spreadResolver = RDEpubReaderSpreadResolver() - - private let tapRegionHandler = RDEpubReaderTapRegionHandler() - - let preloadController = RDEpubReaderPreloadController() - - var pagingController = RDEpubReaderPagingController() - - public var currentPage: Int = -1 { - didSet { - if let delegate = delegate, currentPage != oldValue, delegate.responds(to: #selector(RDEpubReaderDelegate.pageNum(readerView:pageNum:))) { - delegate.pageNum(readerView: self, pageNum: currentPage) - } - } - } - - private(set) lazy var tapGestureRecognizer: UITapGestureRecognizer = { - let tap = UITapGestureRecognizer(target: self, action: #selector(tapAction(tap:))) - tap.delegate = self - return tap - }() - - private var tapEvent: TapEvent = .none { - didSet { - let isRTL = pageDirection == .rightToLeft - switch tapEvent { - case .left: - if currentDisplayType != .pageCurl { - if isRTL { goNextPage() } else { goPreviousPage() } - } - case .right: - if currentDisplayType != .pageCurl { - if isRTL { goPreviousPage() } else { goNextPage() } - } - case .center: - tapCenter() - default: - break - } - } - } - - private func goNextPage() { - let totalPages = numberOfPages() - if let target = spreadResolver.nextPage( - from: currentPage, - totalPages: totalPages, - pagesPerScreen: pagesPerScreen, - coverPageIndex: coverPageIndex, - forward: true - ) { - transitionToPage(pageNum: target, animated: true) - } - } - - private func goPreviousPage() { - let totalPages = numberOfPages() - if let target = spreadResolver.nextPage( - from: currentPage, - totalPages: totalPages, - pagesPerScreen: pagesPerScreen, - coverPageIndex: coverPageIndex, - forward: false - ) { - transitionToPage(pageNum: target, animated: true) - } - } - - private let legacyDataSourceAdapter = RDEpubReaderLegacyDataSourceAdapter() - - public weak var dataSource: RDEpubReaderDataSource? { - didSet { - legacyDataSourceAdapter.dataSource = dataSource - } - } - - public weak var pageProvider: RDEpubReaderPageProvider? - - public weak var delegate: RDEpubReaderDelegate? = nil - - public var currentDisplayType: RDEpubReaderView.DisplayType = .pageCurl - - public var toolViewAnimationDuration: TimeInterval = 0.3 - - var onToolViewVisibilityChanged: ((Bool) -> Void)? - - var searchBarView: UIView? - - public var landscapeDualPageEnabled: Bool = false - - public var pageDirection: RDEpubReaderView.PageDirection = .leftToRight - - public var coverPageIndex: Int? = nil - - private var resolvedPageProvider: RDEpubReaderPageProvider? { - pageProvider ?? legacyDataSourceAdapter - } - - func numberOfPages() -> Int { - resolvedPageProvider?.numberOfPages(in: self) ?? 0 - } - - func pageReuseIdentifier(for pageNum: Int) -> String? { - resolvedPageProvider?.pageIdentifier?(in: self, index: pageNum) - } - - func contentViewForPage(_ pageNum: Int, reusableView: UIView?) -> UIView { - resolvedPageProvider?.readerView(self, viewForPageAt: pageNum, reusableView: reusableView) ?? UIView() - } - - private func resolvedTopChromeView() -> UIView? { - resolvedPageProvider?.readerViewTopChrome?(self) - } - - private func resolvedBottomChromeView() -> UIView? { - resolvedPageProvider?.readerViewBottomChrome?(self) - } - - func refreshToolViewsFromProviderIfNeeded() { - if topToolView == nil { - topToolView = resolvedTopChromeView() - } - if bottomToolView == nil { - bottomToolView = resolvedBottomChromeView() - } - } - - private var hasCoverPage: Bool { - return coverPageIndex != nil - } - - var isLandscape: Bool { - return bounds.width > bounds.height - } - - public var pagesPerScreen: Int { - if !landscapeDualPageEnabled { return 1 } - if currentDisplayType == .verticalScroll { return 1 } - return isLandscape ? 2 : 1 - } - - public func isFullScreenPage(_ pageNum: Int) -> Bool { - spreadResolver.isFullScreenPage( - pageNum, - landscapeDualPageEnabled: landscapeDualPageEnabled, - isLandscape: isLandscape, - coverPageIndex: coverPageIndex - ) - } - - private func dualPagePair(for pageNum: Int) -> (left: Int, right: Int?) { - let totalPages = numberOfPages() - return spreadResolver.dualPagePair( - for: pageNum, - totalPages: totalPages, - coverPageIndex: coverPageIndex - ) - } - - private func adjacentDualPage(from pageNum: Int, forward: Bool) -> Int? { - let totalPages = numberOfPages() - return spreadResolver.adjacentDualPage( - from: pageNum, - totalPages: totalPages, - coverPageIndex: coverPageIndex, - forward: forward - ) - } - - private func clampedPageNumber(_ pageNum: Int) -> Int? { - let totalPages = numberOfPages() - guard totalPages > 0 else { return nil } - return min(max(pageNum, 0), totalPages - 1) - } - - private var previousIsLandscape: Bool? - - var contentViews = [String : UIView.Type]() - - var willPreviousTransitionToViewController: UIViewController? = nil - - var willNextTransitionToViewController: UIViewController? = nil - - var willTransitionToViewController: UIViewController? = nil - - var topToolView: UIView? - - var bottomToolView: UIView? - - var topToolViewHeightConstraint: NSLayoutConstraint? - - var bottomToolViewHeightConstraint: NSLayoutConstraint? - - var isShowToolView: Bool = false - - private var isTransitioning: Bool { - get { pagingController.isTransitioning } - set { pagingController.isTransitioning = newValue } - } - - private var didBuildUI: Bool { - get { pagingController.didBuildUI } - set { pagingController.didBuildUI = newValue } - } - - var predictedPageDirection: Bool? - - public var preloadRadius: Int { - get { preloadController.radius } - set { preloadController.radius = newValue } - } - - static let blankPageNum = Int.max - - static let blankEndPageNum = Int.max - 1 - - private typealias PageTransitionRequest = RDEpubReaderPagingController.PageTransitionRequest - - private let registeredSelectionLongPressRecognizers = NSHashTable.weakObjects() - - private let selectionTapSuppressedContentViews = NSHashTable.weakObjects() - - private let selectionPagingSuppressedContentViews = NSHashTable.weakObjects() - - private var isPagingInteractionSuppressed = false - - private var pendingTransitionRequest: PageTransitionRequest? { - get { pagingController.pendingTransitionRequest } - set { pagingController.pendingTransitionRequest = newValue } - } - - var isPageCurlTransitioning: Bool { - currentDisplayType == .pageCurl && pagingController.isTransitioning - } - - override init(frame: CGRect) { - super.init(frame: frame) - accessibilityIdentifier = "epub.reader.content" - isAccessibilityElement = false - } - - public override func layoutSubviews() { - super.layoutSubviews() - guard bounds.width > 0, bounds.height > 0 else { return } - preloadController.setHostFrame(bounds) - updateToolViewHeightConstraintsIfNeeded() - let nowLandscape = isLandscape - if let prev = previousIsLandscape, prev != nowLandscape { - previousIsLandscape = nowLandscape - - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - self.orientationChanged(isNowLandscape: nowLandscape) - } - } else if previousIsLandscape == nil { - previousIsLandscape = nowLandscape - layout.isLandscapeDualPage = landscapeDualPageEnabled && nowLandscape - } - } - - private func orientationChanged(isNowLandscape: Bool) { - let savedPage = max(0, currentPage) - - delegate?.readerViewOrientationWillChange?(readerView: self, isLandscape: isNowLandscape) - - layout.isLandscapeDualPage = landscapeDualPageEnabled && isNowLandscape - layout.coverPageIndex = coverPageIndex - - switch currentDisplayType { - case .pageCurl: - - rebuildPageViewController() - transitionToPage(pageNum: savedPage) - primePageCache(around: savedPage, preferredForward: predictedPageDirection) - default: - - UIView.performWithoutAnimation { - - collectionView.collectionViewLayout.invalidateLayout() - collectionView.reloadData() - collectionView.layoutIfNeeded() - - let totalPages = numberOfPages() - let safePage = min(savedPage, max(0, totalPages - 1)) - let targetOffset = layout.currentContentOffset(count: safePage) - collectionView.setContentOffset(targetOffset, animated: false) - primePageCache(around: safePage, preferredForward: predictedPageDirection) - } - } - } - - private func createPageViewController(isDualPage: Bool) -> UIPageViewController { - let options: [UIPageViewController.OptionsKey: Any]? - if isDualPage { - options = [.spineLocation: NSNumber(value: UIPageViewController.SpineLocation.mid.rawValue)] - } else { - options = nil - } - let pageVC = UIPageViewController(transitionStyle: .pageCurl, navigationOrientation: .horizontal, options: options) - pageVC.delegate = self - pageVC.dataSource = self - pageVC.isDoubleSided = isDualPage - return pageVC - } - - private func rebuildPageViewController() { - detachPageViewControllerIfNeeded() - - let isDualPage = landscapeDualPageEnabled && isLandscape - let pageVC = createPageViewController(isDualPage: isDualPage) - pageViewController = pageVC - attachPageViewControllerIfNeeded() - } - - private func detectPageViewControllerFault(_ pageVC: UIPageViewController) -> Bool { - guard currentDisplayType == .pageCurl else { return false } - let expectedCount = (landscapeDualPageEnabled && isLandscape) ? 2 : 1 - guard let viewControllers = pageVC.viewControllers, - viewControllers.count == expectedCount else { - return true - } - - let childViewControllers = viewControllers.compactMap { $0 as? RDEpubReaderPageChildViewController } - guard childViewControllers.count == expectedCount else { - return true - } - - if expectedCount == 1 { - return childViewControllers.first?.pageNum != currentPage - } - - let expectedPair = dualPagePair(for: currentPage) - let expectedRightPage = expectedPair.right - ?? (isFullScreenPage(expectedPair.left) ? RDEpubReaderView.blankPageNum : RDEpubReaderView.blankEndPageNum) - return childViewControllers[0].pageNum != expectedPair.left - || childViewControllers[1].pageNum != expectedRightPage - } - - private func patchPageViewControllerFault() { - guard currentPage >= 0 else { return } - DispatchQueue.main.async { [weak self] in - guard let self else { return } - self.pagingController.resetPendingState() - self.willPreviousTransitionToViewController = nil - self.willNextTransitionToViewController = nil - self.willTransitionToViewController = nil - self.invalidatePageCaches() - self.rebuildPageViewController() - self.transitionToPage(pageNum: self.currentPage, animated: false) - } - } - - private func shouldQueuePageTransition(_ request: PageTransitionRequest) -> Bool { - pagingController.shouldQueuePageTransition(request, currentDisplayType: currentDisplayType) - } - - func finishPageCurlTransition(repairFaultIfNeeded: Bool = true) { - willPreviousTransitionToViewController = nil - willNextTransitionToViewController = nil - willTransitionToViewController = nil - - if repairFaultIfNeeded, detectPageViewControllerFault(pageViewController) { - patchPageViewControllerFault() - return - } - - if let pending = pagingController.finishPageCurlTransition() { - transitionToPage(pageNum: pending.pageNum, animated: pending.animated) - } - - updatePagingInteractionSuppression() - } - - private var preloadEnvironment: RDEpubReaderPreloadController.Environment { - RDEpubReaderPreloadController.Environment( - displayType: currentDisplayType, - isLandscape: isLandscape, - pagesPerScreen: pagesPerScreen, - boundsSize: bounds.size, - landscapeDualPageEnabled: landscapeDualPageEnabled, - coverPageIndex: coverPageIndex, - totalPages: numberOfPages(), - spreadResolver: spreadResolver - ) - } - - private func invalidatePageCaches() { - preloadController.invalidate(environment: preloadEnvironment) - } - - func pageViewForDisplay(pageNum: Int) -> UIView { - preloadController.pageViewForDisplay( - pageNum: pageNum, - environment: preloadEnvironment, - contentViewProvider: pageContentViewForDisplay(pageNum:reusableView:) - ) - } - - func primePageCache(around pageNum: Int, preferredForward: Bool? = nil) { - preloadController.prime( - around: pageNum, - preferredForward: preferredForward, - parentView: self, - environment: preloadEnvironment, - contentViewProvider: pageContentViewForPreload(pageNum:reusableView:) - ) - } - - private func pageContentViewForDisplay(pageNum: Int, reusableView: UIView?) -> UIView? { - contentViewForPage(pageNum, reusableView: reusableView) - } - - private func pageContentViewForPreload(pageNum: Int, reusableView: UIView?) -> UIView? { - let allowsPreloading = resolvedPageProvider? - .readerView?(self, shouldPreloadPageAt: pageNum) ?? true - guard allowsPreloading else { return nil } - return contentViewForPage(pageNum, reusableView: reusableView) - } - - public override func didMoveToSuperview() { - super.didMoveToSuperview() - - if superview != nil { - makeUI() - } - } - - private func attachPageViewControllerIfNeeded() { - guard let parentViewController = self.ss_superViewController else { return } - - var didAddToParent = false - if pageViewController.parent !== parentViewController { - if pageViewController.parent != nil { - pageViewController.willMove(toParent: nil) - pageViewController.view.removeFromSuperview() - pageViewController.removeFromParent() - } - parentViewController.addChild(pageViewController) - didAddToParent = true - } - - pageViewController.view.frame = CGRect(x: 0, y: 0, width: frame.width, height: frame.height) - pageViewController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] - if pageViewController.view.superview !== self { - insertSubview(pageViewController.view, at: 0) - } - - if didAddToParent { - pageViewController.didMove(toParent: parentViewController) - } - - if currentDisplayType == .pageCurl, !isTransitioning, pendingTransitionRequest != nil { - finishPageCurlTransition(repairFaultIfNeeded: false) - } - } - - private func detachPageViewControllerIfNeeded() { - if pageViewController.parent != nil { - pageViewController.willMove(toParent: nil) - } - pageViewController.view.removeFromSuperview() - if pageViewController.parent != nil { - pageViewController.removeFromParent() - } - } - - private func makeUI() { - guard !didBuildUI else { - if currentDisplayType == .pageCurl { - attachPageViewControllerIfNeeded() - } - return - } - didBuildUI = true - - collectionView.dataSource = self - collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: NSStringFromClass(UICollectionViewCell.self)) - collectionView.frame = CGRect(x: 0, y: 0, width: frame.width, height: frame.height) - collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight] - - if currentDisplayType == .pageCurl { - attachPageViewControllerIfNeeded() - } - preloadController.ensureHostView(in: self) - preloadController.initializeSignature(preloadEnvironment) - - addGestureRecognizer(tapGestureRecognizer) - - tapGestureRecognizer.cancelsTouchesInView = false - - } - - @objc private func tapAction(tap: UITapGestureRecognizer) { - let point = tap.location(in: tap.view) - let hitView = hitTest(point, with: nil) - RDEpubReaderTapDebug.log( - "ReaderView.tapAction", - "received point=\(RDEpubReaderTapDebug.describe(point)) hitView=\(RDEpubReaderTapDebug.describe(hitView)) display=\(currentDisplayType) currentPage=\(currentPage) toolVisible=\(isShowToolView) transitioning=\(isPageCurlTransitioning)" - ) - if containsTextContentView(in: hitView) { - RDEpubReaderTapDebug.log("ReaderView.tapAction", "ignored because hitView is inside text content view") - return - } - if shouldSuppressChromeToggle(for: hitView, point: point) { - RDEpubReaderTapDebug.log("ReaderView.tapAction", "ignored because chrome toggle is suppressed") - return - } - handleResolvedTap(at: point, hitView: hitView, in: tap.view) - } - - private func shouldSuppressChromeToggle(for hitView: UIView?, point: CGPoint) -> Bool { - if selectionTapSuppressedContentViews.allObjects.isEmpty == false { - RDEpubReaderTapDebug.log( - "ReaderView.suppressChromeToggle", - "suppressed by active selectionTapSuppressedContentViews count=\(selectionTapSuppressedContentViews.allObjects.count)" - ) - return true - } - var currentView = hitView - while let view = currentView { - if let textContentView = view as? RDEPUBTextContentView { - let localPoint = convert(point, to: textContentView) - let shouldSuppress = textContentView.shouldSuppressReaderTap(at: localPoint) - if shouldSuppress { - RDEpubReaderTapDebug.log( - "ReaderView.suppressChromeToggle", - "suppressed by text content view=\(RDEpubReaderTapDebug.describe(textContentView)) localPoint=\(RDEpubReaderTapDebug.describe(localPoint))" - ) - } - return shouldSuppress - } - currentView = view.superview - } - return false - } - - func registerSelectionGestureDependenciesIfNeeded(for contentView: UIView) { - guard let textContentView = contentView as? RDEPUBTextContentView else { return } - let longPressGesture = textContentView.selectionLongPressGestureRecognizer - if registeredSelectionLongPressRecognizers.allObjects.contains(where: { $0 === longPressGesture }) { - return - } - tapGestureRecognizer.require(toFail: longPressGesture) - registeredSelectionLongPressRecognizers.add(longPressGesture) - } - - func updateSelectionTapSuppression(for contentView: UIView, isSuppressed: Bool) { - if isSuppressed { - if selectionTapSuppressedContentViews.allObjects.contains(where: { $0 === contentView }) == false { - selectionTapSuppressedContentViews.add(contentView) - } - } else { - selectionTapSuppressedContentViews.remove(contentView) - } - RDEpubReaderTapDebug.log( - "ReaderView.selectionTapSuppression", - "contentView=\(RDEpubReaderTapDebug.describe(contentView)) suppressed=\(isSuppressed) activeCount=\(selectionTapSuppressedContentViews.allObjects.count)" - ) - } - - func updateSelectionPagingSuppression(for contentView: UIView, isSuppressed: Bool) { - if isSuppressed { - if selectionPagingSuppressedContentViews.allObjects.contains(where: { $0 === contentView }) == false { - selectionPagingSuppressedContentViews.add(contentView) - } - } else { - selectionPagingSuppressedContentViews.remove(contentView) - } - RDEpubReaderTapDebug.log( - "ReaderView.selectionPagingSuppression", - "contentView=\(RDEpubReaderTapDebug.describe(contentView)) suppressed=\(isSuppressed) activeCount=\(selectionPagingSuppressedContentViews.allObjects.count)" - ) - updatePagingInteractionSuppression() - } - - func handleContentTap(at point: CGPoint, in sourceView: UIView) { - let localPoint = sourceView.convert(point, to: self) - RDEpubReaderTapDebug.log( - "ReaderView.handleContentTap", - "forwarded from sourceView=\(RDEpubReaderTapDebug.describe(sourceView)) sourcePoint=\(RDEpubReaderTapDebug.describe(point)) localPoint=\(RDEpubReaderTapDebug.describe(localPoint))" - ) - handleResolvedTap(at: localPoint, hitView: nil, in: self) - } - - private func handleResolvedTap(at point: CGPoint, hitView: UIView?, in tapView: UIView?) { - if isShowToolView { - if let top = topToolView, isHitView(hitView, inside: top, point: point) { - RDEpubReaderTapDebug.log("ReaderView.handleResolvedTap", "ignored because point hits top tool view") - return - } - if let bottom = bottomToolView, isHitView(hitView, inside: bottom, point: point) { - RDEpubReaderTapDebug.log("ReaderView.handleResolvedTap", "ignored because point hits bottom tool view") - return - } - } - guard let viewFrame = tapView?.frame else { return } - tapEvent = tapRegionHandler.resolveTapEvent( - point: point, - viewFrame: viewFrame, - isToolViewVisible: isShowToolView - ) - RDEpubReaderTapDebug.log( - "ReaderView.handleResolvedTap", - "resolved tapEvent=\(tapEvent) point=\(RDEpubReaderTapDebug.describe(point)) viewFrame=\(RDEpubReaderTapDebug.describe(viewFrame))" - ) - } - - private func containsTextContentView(in hitView: UIView?) -> Bool { - var currentView = hitView - while let view = currentView { - if view is RDEPUBTextContentView { - return true - } - currentView = view.superview - } - return false - } - - private func updatePagingInteractionSuppression() { - let shouldSuppress = activePagingSuppressionContentViews().isEmpty == false - guard shouldSuppress != isPagingInteractionSuppressed else { return } - isPagingInteractionSuppressed = shouldSuppress - RDEpubReaderTapDebug.log( - "ReaderView.pagingSuppression", - "updated shouldSuppress=\(shouldSuppress) activeContentViews=\(activePagingSuppressionContentViews().count)" - ) - setPagingInteractionEnabled(!shouldSuppress) - } - - private func activePagingSuppressionContentViews() -> [UIView] { - selectionPagingSuppressedContentViews.allObjects.filter { contentView in - guard contentView.window != nil, - contentView.isDescendant(of: self), - contentView.bounds.isEmpty == false else { - return false - } - let frameInReader = contentView.convert(contentView.bounds, to: self) - return frameInReader.intersects(bounds) - } - } - - private func setPagingInteractionEnabled(_ isEnabled: Bool) { - collectionView.isScrollEnabled = isEnabled - pageViewController.gestureRecognizers.forEach { $0.isEnabled = isEnabled } - } - - public func switchReaderDisplayType(_ displayType: RDEpubReaderView.DisplayType) { - let previousDisplayType = currentDisplayType - self.currentDisplayType = displayType - if currentPage == -1 { - currentPage = 0 - } - if previousDisplayType != displayType { - invalidatePageCaches() - } - - layout.isLandscapeDualPage = landscapeDualPageEnabled && isLandscape - layout.coverPageIndex = coverPageIndex - switch displayType { - case .pageCurl: - self.collectionView.removeFromSuperview() - self.collectionView.transform = .identity - attachPageViewControllerIfNeeded() - rebuildPageViewController() - transitionToPage(pageNum: currentPage) - primePageCache(around: currentPage, preferredForward: predictedPageDirection) - default: - detachPageViewControllerIfNeeded() - - if pageDirection == .rightToLeft && displayType != .verticalScroll { - collectionView.transform = CGAffineTransform(scaleX: -1, y: 1) - } else { - collectionView.transform = .identity - } - - collectionView.frame = CGRect(x: 0, y: 0, width: frame.width, height: frame.height) - insertSubview(self.collectionView, at: 0) - layout.displayType = displayType - transitionToPage(pageNum: currentPage) - primePageCache(around: currentPage, preferredForward: predictedPageDirection) - } - } - - public func transitionToPage(pageNum: Int, animated: Bool = false) { - guard let safePageNum = clampedPageNumber(pageNum) else { return } - switch currentDisplayType { - case .pageCurl: - if !animated, safePageNum == currentPage, !detectPageViewControllerFault(pageViewController) { - RDEpubReaderTapDebug.log( - "ReaderView.transitionToPage", - "skip same page page=\(safePageNum) animated=\(animated)" - ) - return - } - - let request = PageTransitionRequest(pageNum: safePageNum, animated: animated) - if shouldQueuePageTransition(request) { - return - } - - let isDualPage = landscapeDualPageEnabled && isLandscape - - let direction: UIPageViewController.NavigationDirection - if pageDirection == .rightToLeft { - direction = safePageNum > currentPage ? .reverse : .forward - } else { - direction = safePageNum > currentPage ? .forward : .reverse - } - predictedPageDirection = currentPage >= 0 ? safePageNum >= currentPage : nil - attachPageViewControllerIfNeeded() - if isDualPage { - let pair = dualPagePair(for: safePageNum) - let leftContent = pageViewForDisplay(pageNum: pair.left) - let leftVC = RDEpubReaderPageChildViewController(contentView: leftContent, pageNum: pair.left) - isTransitioning = animated - if let rightPage = pair.right { - let rightContent = pageViewForDisplay(pageNum: rightPage) - let rightVC = RDEpubReaderPageChildViewController(contentView: rightContent, pageNum: rightPage) - pageViewController.setViewControllers([leftVC, rightVC], direction: animated ? direction : .forward, animated: animated) { [weak self] _ in - guard let self else { return } - self.finishPageCurlTransition() - } - } else { - - let blankNum = isFullScreenPage(pair.left) ? RDEpubReaderView.blankPageNum : RDEpubReaderView.blankEndPageNum - let emptyVC = RDEpubReaderPageChildViewController(contentView: UIView(), pageNum: blankNum) - pageViewController.setViewControllers([leftVC, emptyVC], direction: animated ? direction : .forward, animated: animated) { [weak self] _ in - guard let self else { return } - self.finishPageCurlTransition() - } - } - currentPage = pair.left - primePageCache(around: pair.left, preferredForward: predictedPageDirection) - } else { - let contentView = pageViewForDisplay(pageNum: safePageNum) - let vc = RDEpubReaderPageChildViewController(contentView: contentView, pageNum: safePageNum) - isTransitioning = animated - pageViewController.setViewControllers([vc], direction: animated ? direction : .forward, animated: animated) { [weak self] _ in - guard let self else { return } - self.finishPageCurlTransition() - } - currentPage = safePageNum - primePageCache(around: safePageNum, preferredForward: predictedPageDirection) - } - default: - collectionView.reloadData() - collectionView.layoutIfNeeded() - predictedPageDirection = currentPage >= 0 ? safePageNum >= currentPage : nil - collectionView.setContentOffset(layout.currentContentOffset(count: safePageNum), animated: animated) - currentPage = safePageNum - primePageCache(around: safePageNum, preferredForward: predictedPageDirection) - } - } - - public func reloadData() { - invalidatePageCaches() - switchReaderDisplayType(currentDisplayType) - topToolView = resolvedTopChromeView() - bottomToolView = resolvedBottomChromeView() - } - - public func reloadPageCountOnly() { - if currentDisplayType == .pageCurl { - let totalPages = numberOfPages() - if !isPageCurlTransitioning, currentPage >= totalPages, totalPages > 0 { - transitionToPage(pageNum: totalPages - 1, animated: false) - } - } else { - collectionView.reloadData() - } - topToolView = resolvedTopChromeView() - bottomToolView = resolvedBottomChromeView() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - -} - -extension RDEpubReaderView: RDEpubReaderPageNavigating { - public func reloadPages() { - reloadData() - } - - public func transition(to page: Int, animated: Bool) { - transitionToPage(pageNum: page, animated: animated) - } -} - -extension RDEpubReaderView: UIGestureRecognizerDelegate { - public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { - guard gestureRecognizer === tapGestureRecognizer else { return true } - - if selectionTapSuppressedContentViews.allObjects.isEmpty == false { - RDEpubReaderTapDebug.log( - "ReaderView.gestureShouldReceive", - "return false because selectionTapSuppressedContentViews count=\(selectionTapSuppressedContentViews.allObjects.count)" - ) - return false - } - - let point = touch.location(in: self) - if containsTextContentView(in: touch.view) { - RDEpubReaderTapDebug.log( - "ReaderView.gestureShouldReceive", - "return false because touch.view is inside text content view point=\(RDEpubReaderTapDebug.describe(point)) touchView=\(RDEpubReaderTapDebug.describe(touch.view))" - ) - return false - } - if let topToolView, isHitView(touch.view, inside: topToolView, point: point) { - RDEpubReaderTapDebug.log("ReaderView.gestureShouldReceive", "return false because touch hit topToolView") - return false - } - if let bottomToolView, isHitView(touch.view, inside: bottomToolView, point: point) { - RDEpubReaderTapDebug.log("ReaderView.gestureShouldReceive", "return false because touch hit bottomToolView") - return false - } - if let searchBarView, isHitView(touch.view, inside: searchBarView, point: point) { - RDEpubReaderTapDebug.log("ReaderView.gestureShouldReceive", "return false because touch hit searchBarView") - return false - } - RDEpubReaderTapDebug.log( - "ReaderView.gestureShouldReceive", - "return true point=\(RDEpubReaderTapDebug.describe(point)) touchView=\(RDEpubReaderTapDebug.describe(touch.view))" - ) - return true - } - - public func gestureRecognizer( - _ gestureRecognizer: UIGestureRecognizer, - shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer - ) -> Bool { - gestureRecognizer === tapGestureRecognizer - } -} - -private var cellViewKey: Int8 = 0 - -extension UIView { - - var ss_superViewController: UIViewController? { - var next = self.next - while next != nil { - if next is UIViewController { - return next as? UIViewController - } else { - next = next!.next - } - } - return nil - } - -} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderViewProtocols.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderViewProtocols.swift deleted file mode 100644 index de313e5..0000000 --- a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderViewProtocols.swift +++ /dev/null @@ -1,93 +0,0 @@ - -import UIKit - -@available(*, deprecated, message: "Use RDEpubReaderPageProvider instead.") -@objc public protocol RDEpubReaderDataSource: NSObjectProtocol { - - func pageCountOfReaderView(readerView: RDEpubReaderView) -> Int - - func pageContentView(readerView: RDEpubReaderView, pageNum: Int, containerView: UIView?) -> UIView - - func pageIdentifier(readerView: RDEpubReaderView, pageNum: Int) -> String? - - @objc optional func topToolView(readerView: RDEpubReaderView) -> UIView? - - @objc optional func bottomToolView(readerView: RDEpubReaderView) -> UIView? -} - -@objc public protocol RDEpubReaderPageProvider: NSObjectProtocol { - - func numberOfPages(in readerView: RDEpubReaderView) -> Int - - func readerView(_ readerView: RDEpubReaderView, viewForPageAt index: Int, reusableView: UIView?) -> UIView - - @objc optional func pageIdentifier(in readerView: RDEpubReaderView, index: Int) -> String? - - /// 指定页面是否允许进入阅读器预加载。默认允许。 - /// 包含不可复制宿主 View 的页面应返回 false,避免预加载抢占可见页面内容。 - @objc optional func readerView(_ readerView: RDEpubReaderView, shouldPreloadPageAt index: Int) -> Bool - - @objc optional func readerViewTopChrome(_ readerView: RDEpubReaderView) -> UIView? - - @objc optional func readerViewBottomChrome(_ readerView: RDEpubReaderView) -> UIView? -} - -@objc public protocol RDEpubReaderDelegate: NSObjectProtocol { - - func pageNum(readerView: RDEpubReaderView, pageNum: Int) - - @objc optional func readerViewOrientationWillChange(readerView: RDEpubReaderView, isLandscape: Bool) -} - -public protocol RDEpubReaderPageNavigating: AnyObject { - - var currentPage: Int { get } - - func reloadPages() - - func transition(to page: Int, animated: Bool) -} - -extension RDEpubReaderView { - - public enum DisplayType { - - case pageCurl - - case horizontalScroll - - case verticalScroll - } - - public enum PageDirection { - - case leftToRight - - case rightToLeft - } -} - -final class RDEpubReaderLegacyDataSourceAdapter: NSObject, RDEpubReaderPageProvider { - - weak var dataSource: RDEpubReaderDataSource? - - func numberOfPages(in readerView: RDEpubReaderView) -> Int { - dataSource?.pageCountOfReaderView(readerView: readerView) ?? 0 - } - - func readerView(_ readerView: RDEpubReaderView, viewForPageAt index: Int, reusableView: UIView?) -> UIView { - dataSource?.pageContentView(readerView: readerView, pageNum: index, containerView: reusableView) ?? UIView() - } - - func pageIdentifier(in readerView: RDEpubReaderView, index: Int) -> String? { - dataSource?.pageIdentifier(readerView: readerView, pageNum: index) - } - - func readerViewTopChrome(_ readerView: RDEpubReaderView) -> UIView? { - dataSource?.topToolView?(readerView: readerView) - } - - func readerViewBottomChrome(_ readerView: RDEpubReaderView) -> UIView? { - dataSource?.bottomToolView?(readerView: readerView) - } -}