From d7fcda345d83a880d189f695ea71cd32170d3b5b Mon Sep 17 00:00:00 2001 From: shenlei Date: Fri, 10 Jul 2026 19:44:53 +0900 Subject: [PATCH] refactor: rename RDReaderView -> RDEpubReaderView, update pod config and docs - Rename source module from RDReaderView to RDEpubReaderView - Move all source files from Sources/RDReaderView/ to Sources/RDEpubReaderView/ - Update podspec: RDReaderView.podspec -> RDEpubReaderView.podspec - Update Podfile, demo project, and CocoaPods config for new pod name - Delete old RDReaderView pod support files from ReadViewDemo/Pods - Add new RDEpubReaderView pod support files - Update documentation (API ref, architecture, UML, conventions, etc.) - Add FixedLayoutRotationTests - Update .gitignore: exclude .DS_Store, manual unpack backups, _ssoft-output --- .gitignore | 18 + CODE_REVIEW.md | 4 +- Doc/API_REFERENCE.md | 54 +- Doc/ARCHITECTURE.md | 50 +- Doc/AUDIT_FINAL.md | 6 +- Doc/BUSINESS_LOGIC.md | 8 +- Doc/CFI_SUBSYSTEM.md | 26 +- Doc/CHAPTER_RUNTIME.md | 4 +- Doc/CONCERNS.md | 18 +- Doc/CONVENTIONS.md | 42 +- Doc/EPUBTextRendering_CODE_REFERENCE.md | 2 +- Doc/EPUBUI_CODE_REFERENCE.md | 16 +- Doc/LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md | 12 +- Doc/ReaderView_CODE_REFERENCE.md | 184 +- Doc/TESTING.md | 2 +- Doc/TYPESetter_PIPELINE.md | 2 +- Doc/UML_CLASS_DIAGRAMS.md | 56 +- Doc/index.md | 2 +- Podfile | 2 +- RDEpubReaderView.podspec | 19 + RDReaderView.podspec | 21 - ReadViewDemo/Podfile | 2 +- ReadViewDemo/Podfile.lock | 10 +- .../RDEpubReaderView.podspec.json | 37 + .../Local Podspecs/RDReaderView.podspec.json | 36 - ReadViewDemo/Pods/Manifest.lock | 10 +- .../Pods/Pods.xcodeproj/project.pbxproj | 2948 ++++++++--------- ...mo-frameworks-Debug-input-files.xcfilelist | 2 +- ...o-frameworks-Debug-output-files.xcfilelist | 2 +- ...-frameworks-Release-input-files.xcfilelist | 2 +- ...frameworks-Release-output-files.xcfilelist | 2 +- .../Pods-ReadViewDemo-frameworks.sh | 4 +- .../Pods-ReadViewDemo.debug.xcconfig | 8 +- .../Pods-ReadViewDemo.release.xcconfig | 8 +- .../RDEpubReaderView-Info.plist} | 0 .../RDEpubReaderView/RDEpubReaderView-dummy.m | 5 + .../RDEpubReaderView-prefix.pch} | 0 .../RDEpubReaderView-umbrella.h} | 4 +- .../RDEpubReaderView.debug.xcconfig} | 2 +- .../RDEpubReaderView.modulemap | 6 + .../RDEpubReaderView.release.xcconfig} | 2 +- ...derViewAssets-RDEpubReaderView-Info.plist} | 0 .../RDReaderView/RDReaderView-dummy.m | 5 - .../RDReaderView/RDReaderView.modulemap | 6 - .../ReadViewDemo.xcodeproj/project.pbxproj | 8 + .../ReadViewDemo/ViewController.swift | 12 +- .../ReadViewDemo/book/回归验证样本.txt | 2 +- .../AsyncChapterLoadingTests.swift | 4 + .../FixedLayoutRotationTests.swift | 33 + ReadViewDemo/ViewController.swift | 14 +- .../EPUBCore/CFI/RDEPUBCFI.swift | 0 .../EPUBCore/CFI/RDEPUBCFICompatibility.swift | 0 .../CFI/RDEPUBCFIDOMPathBuilder.swift | 0 .../EPUBCore/CFI/RDEPUBCFIError.swift | 0 .../EPUBCore/CFI/RDEPUBCFIGenerator.swift | 0 .../EPUBCore/CFI/RDEPUBCFIMap.swift | 0 .../EPUBCore/CFI/RDEPUBCFIParser.swift | 0 .../EPUBCore/CFI/RDEPUBCFIPath.swift | 0 .../EPUBCore/CFI/RDEPUBCFIRange.swift | 0 .../CFI/RDEPUBCFIRecoveryEngine.swift | 0 .../EPUBCore/CFI/RDEPUBCFIResolver.swift | 0 .../EPUBCore/CFI/RDEPUBCFISerializer.swift | 0 .../EPUBCore/CFI/RDEPUBCFITextAssertion.swift | 0 .../EPUBCore/CFI/RDEPUBCFIUtilities.swift | 0 .../Models/RDEPUBAnnotationModels.swift | 4 +- .../Models/RDEPUBPaginationModels.swift | 0 .../Models/RDEPUBReadingLocationModels.swift | 0 .../EPUBCore/Notes/RDEPUBNoteDetector.swift | 0 .../EPUBCore/Notes/RDEPUBNoteModels.swift | 0 .../EPUBCore/Notes/RDEPUBNoteResolver.swift | 0 .../EPUBCore/RDEPUBAssetRepository.swift | 8 +- .../EPUBCore/RDEPUBFixedLayoutTemplate.swift | 0 .../EPUBCore/RDEPUBJavaScriptBridge.swift | 0 .../EPUBCore/RDEPUBModels.swift | 0 .../RDEPUBNavigatorLayoutContext.swift | 0 .../EPUBCore/RDEPUBPaginator.swift | 379 +++ .../EPUBCore/RDEPUBParser+Archive.swift | 0 .../EPUBCore/RDEPUBParser+Package.swift | 9 +- .../RDEPUBParser+ReadingProfile.swift | 0 .../EPUBCore/RDEPUBParser+Resources.swift | 0 .../EPUBCore/RDEPUBParser+TOC.swift | 0 .../EPUBCore/RDEPUBParser.swift | 5 +- .../EPUBCore/RDEPUBPreferences.swift | 0 .../EPUBCore/RDEPUBPublication.swift | 0 .../EPUBCore/RDEPUBReadingSession.swift | 0 .../EPUBCore/RDEPUBRenderRequest.swift | 0 .../EPUBCore/RDEPUBResourceDataProvider.swift | 0 .../EPUBCore/RDEPUBResourceResolver.swift | 0 .../RDEPUBResourceURLSchemeHandler.swift | 0 .../EPUBCore/RDEPUBSafeArea.swift | 0 .../EPUBCore/RDEPUBSearchEngine.swift | 0 .../EPUBCore/RDEPUBSearchModels.swift | 0 .../EPUBCore/RDEPUBStyleSheetBuilder.swift | 0 .../EPUBCore/RDEPUBTextAnchor.swift | 0 .../RDEPUBWebView+Configuration.swift | 132 + .../EPUBCore/RDEPUBWebView+FixedLayout.swift | 0 .../RDEPUBWebView+JavaScriptBridge.swift | 0 .../EPUBCore/RDEPUBWebView+Reflowable.swift | 0 .../EPUBCore/RDEPUBWebView+Search.swift | 0 .../EPUBCore/RDEPUBWebView.swift | 243 ++ .../EPUBCore/RDEPUBWebViewDebug.swift | 2 +- .../EPUBCore/Resources/WeReadApi.js | 2 +- .../EPUBCore/Resources/cssInjector.js | 0 .../EPUBCore/Resources/epub-bridge.js | 4 +- .../EPUBCore/Resources/epub-fixed-layout.html | 266 ++ .../EPUBCore/Resources/rangy-core.js | 0 .../EPUBCore/Resources/rangy-serializer.js | 0 .../EPUBCore/Resources/wxread-dark.css | 0 .../EPUBCore/Resources/wxread-default.css | 0 .../Resources/wxread-replace-latin.css | 0 .../EPUBCore/Resources/wxread-replace.css | 0 .../RDEPUBBuildDiagnosticsReporter.swift | 0 .../RDEPUBChapterTailNormalizer.swift | 0 .../RDEPUBPaginationCacheCoordinator.swift | 0 .../BuildPipeline/RDEPUBTextBookBuilder.swift | 0 .../BuildPipeline/RDEPUBTextBookCache.swift | 2 +- .../BuildPipeline/RDEPUBTextBookModels.swift | 0 .../RDEPUBTextBuildPipelineInterfaces.swift | 0 .../RDEPUBTextPerformanceSampler.swift | 0 .../Pagination/RDEPUBChapterPageCounter.swift | 0 .../RDEPUBCoreTextPageFrameFactory.swift | 0 .../Pagination/RDEPUBPageBreakPolicy.swift | 0 .../Pagination/RDEPUBTextLayoutFrame.swift | 0 .../Pagination/RDEPUBTextLayouter.swift | 0 .../RDEPUBTextPaginationInterfaces.swift | 0 .../RDEPUBTextPaginationSupport.swift | 0 .../EPUBTextRendering/RDEPUBChapterData.swift | 0 .../RDEPUBDTCoreTextRenderer.swift | 0 .../RDEPUBTextIndexTable.swift | 0 .../RDEPUBTextPositionConverter.swift | 0 .../RDEPUBTextRenderer.swift | 14 +- .../RDEPUBTextSearchEngine.swift | 0 .../RDEpubPlainTextBookBuilder.swift} | 2 +- .../RDEPUBCSSCompatibilityLayer.swift | 0 .../RDEPUBFontFallbackResolver.swift | 0 .../RDEPUBStyleCompatibilityModels.swift | 0 .../RDEPUBAttachmentNormalizer.swift | 0 .../Typesetter/RDEPUBCFIMarkerInjector.swift | 0 .../RDEPUBDecryptingImageAttachment.swift | 10 + .../Typesetter/RDEPUBFontNormalizer.swift | 0 .../RDEPUBFragmentMarkerInjector.swift | 0 .../Typesetter/RDEPUBHTMLNormalizer.swift | 0 .../RDEPUBRenderDiagnosticsCollector.swift | 0 .../RDEPUBSemanticMarkerInjector.swift | 0 .../Typesetter/RDEPUBStyleSheetComposer.swift | 0 .../RDEPUBTextRendererSupport.swift | 0 .../RDEPUBTypesettingPipeline.swift | 0 .../Notes/RDEPUBNotePopupCoordinator.swift | 0 .../Notes/RDEPUBNotePopupViewController.swift | 0 .../RDEPUBAttachmentTooltipOverlayView.swift | 0 .../EPUBUI/RDEPUBAttachmentTooltipView.swift | 0 .../EPUBUI/RDEPUBImageViewController.swift | 0 .../EPUBUI/RDEPUBImageViewerCoordinator.swift | 0 .../EPUBUI/RDEPUBReaderBottomToolView.swift | 0 .../RDEPUBReaderChapterListController.swift | 0 ...UBReaderController+AttachmentTooltip.swift | 0 ...PUBReaderController+ContentDelegates.swift | 4 +- .../RDEPUBReaderController+DataSource.swift | 26 +- ...RDEPUBReaderController+ExternalLinks.swift | 0 ...BReaderController+LocationResolution.swift | 0 .../RDEPUBReaderController+Orientation.swift | 0 .../RDEPUBReaderController+PublicAPI.swift | 0 ...RDEPUBReaderController+RenderSupport.swift | 92 + ...RDEPUBReaderController+RuntimeBridge.swift | 2 +- ...EPUBReaderController+TableOfContents.swift | 0 .../EPUBUI/RDEPUBReaderController+Trial.swift | 2 +- .../EPUBUI/RDEPUBReaderController.swift | 2 +- .../EPUBUI/RDEPUBReaderDelegate.swift | 0 ...RDEPUBReaderHighlightsViewController.swift | 0 .../EPUBUI/RDEPUBReaderPersistence.swift | 0 .../EPUBUI/RDEPUBReaderSearchBarView.swift | 0 .../RDEPUBReaderTableOfContentsItem.swift | 0 .../EPUBUI/RDEPUBReaderToolView.swift | 0 .../RDEPUBReaderToolViewProtocols.swift | 0 .../EPUBUI/RDEPUBReaderTopToolView.swift | 0 .../EPUBUI/RDEPUBSettingsFlipAutomation.swift | 0 .../EPUBUI/RDEPUBViewportTypes.swift | 0 .../EPUBUI/RDEPUBWebContentView.swift | 135 + .../RDEPUBWebDecorationOverlayView.swift | 0 .../EPUBUI/RDEpubURLReaderController.swift} | 12 +- .../RDEPUBBackgroundTrace.swift | 0 .../ChapterRuntime/RDEPUBBookPageMap.swift | 0 .../RDEPUBChapterCacheKey.swift | 0 .../RDEPUBChapterDataCache.swift | 0 .../ChapterRuntime/RDEPUBChapterLoader.swift | 0 .../RDEPUBChapterLocation.swift | 0 .../RDEPUBChapterOffsetMap.swift | 0 .../RDEPUBChapterRuntimeStore.swift | 203 ++ .../RDEPUBChapterSummaryDiskCache.swift | 2 +- .../RDEPUBChapterWarmupOrchestrator.swift | 0 .../ChapterRuntime/RDEPUBPageCountCache.swift | 0 .../ChapterRuntime/RDEPUBPageResolver.swift | 0 .../ChapterRuntime/RDEPUBRuntimeChapter.swift | 0 .../RDEPUBRuntimePageCount.swift | 0 .../RDEPUBBackgroundCoverageStore.swift | 0 .../RDEPUBBackgroundPriorityPolicy.swift | 0 .../ReaderController/RDEPUBJumpSession.swift | 0 .../ReaderController/RDEPUBMemoryProbe.swift | 0 ...BMetadataParseCancellationController.swift | 0 .../RDEPUBMetadataParseWorker.swift | 2 +- ...EPUBPageMapReconciliationCoordinator.swift | 0 .../RDEPUBPresentationRuntime.swift | 8 +- .../RDEPUBReaderAnnotationCoordinator.swift | 0 .../RDEPUBReaderAssemblyCoordinator.swift | 6 +- .../RDEPUBReaderChromeCoordinator.swift | 0 .../RDEPUBReaderContext.swift | 4 +- .../RDEPUBReaderDependencies.swift | 6 +- .../RDEPUBReaderEnvironment.swift | 4 +- .../RDEPUBReaderLoadCoordinator.swift | 0 .../RDEPUBReaderLocationCoordinator.swift | 0 .../RDEPUBReaderPaginationCoordinator.swift | 392 +++ .../RDEPUBReaderRuntime.swift | 703 ++++ .../RDEPUBReaderSearchCoordinator.swift | 0 .../RDEPUBReaderServices.swift | 2 +- .../ReaderController/RDEPUBReaderState.swift | 0 .../RDEPUBReaderUIState.swift | 0 .../RDEPUBReaderViewportMonitor.swift | 0 .../RDEPUBSelectionState.swift | 0 .../Settings/RDEPUBReaderConfiguration.swift | 4 +- .../Settings/RDEPUBReaderSettings.swift | 4 +- .../RDEPUBReaderSettingsViewController.swift | 4 +- .../EPUBUI/Settings/RDEPUBReaderTheme.swift | 0 .../EPUBUI/String+RDEPUB.swift | 0 .../RDEPUBChapterDisplayContentCache.swift | 0 .../TextPage/RDEPUBDarkImageAdjuster.swift | 0 .../RDEPUBPageInteractionController.swift | 0 .../TextPage/RDEPUBPageLayoutSnapshot.swift | 0 .../TextPage/RDEPUBSelectionLoupeView.swift | 0 .../TextPage/RDEPUBSelectionOverlayView.swift | 0 .../RDEPUBTextAnnotationOverlay.swift | 0 ...PUBTextContentInteractionCoordinator.swift | 2 +- .../TextPage/RDEPUBTextContentView.swift | 50 +- .../RDEPUBTextPageBoundaryValidator.swift | 0 .../RDEPUBTextPageDecorationView.swift | 0 .../TextPage/RDEPUBTextPageRenderView.swift | 0 .../RDEPUBTextPageVerticalJustifier.swift | 0 .../RDEPUBTextSelectionController.swift | 0 .../EPUBUI/UIColor+RDEPUB.swift | 0 .../EPUBUI/UIColor+RDEPUBHex.swift | 0 .../RDEpubReaderView/RDEpubReaderView.podspec | 19 + Sources/RDEpubReaderView/README.md | 11 + .../RDEpubReaderPagingController.swift} | 4 +- .../RDEpubReaderPreloadController.swift | 366 ++ .../Paging/RDEpubReaderSpreadResolver.swift} | 2 +- .../RDEpubReaderTapRegionHandler.swift} | 4 +- .../ReaderView/RDEpubReaderContentCell.swift} | 2 +- .../ReaderView/RDEpubReaderFlowLayout.swift} | 20 +- .../RDEpubReaderGestureController.swift} | 2 +- ...RDEpubReaderPageChildViewController.swift} | 2 +- .../RDEpubReaderView+CollectionView.swift} | 10 +- .../RDEpubReaderView+ContentAccess.swift} | 12 +- .../RDEpubReaderView+PageCurl.swift} | 42 +- .../RDEpubReaderView+ToolView.swift} | 10 +- .../ReaderView/RDEpubReaderView.swift | 961 ++++++ .../RDEpubReaderViewProtocols.swift | 89 + .../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 | 0 .../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 | 0 .../EPUBCore/RDEPUBWebView+FixedLayout.swift | 122 + .../RDEPUBWebView+JavaScriptBridge.swift | 149 + .../EPUBCore/RDEPUBWebView+Reflowable.swift | 161 + .../EPUBCore/RDEPUBWebView+Search.swift | 142 + .../EPUBCore/RDEPUBWebView.swift | 0 .../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 | 0 .../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 | 0 ...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 | 0 .../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 | 2 +- .../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 | 0 .../RDEPUBReaderRuntime.swift | 0 .../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} | 51 +- .../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} | 127 +- .../RDEpubReaderViewProtocols.swift | 93 + .../ReaderView/RDReaderViewProtocols.swift | 89 - .../spec-cfi-11-issues-fix.md | 151 - 460 files changed, 38358 insertions(+), 2300 deletions(-) create mode 100644 RDEpubReaderView.podspec delete mode 100644 RDReaderView.podspec create mode 100644 ReadViewDemo/Pods/Local Podspecs/RDEpubReaderView.podspec.json delete mode 100644 ReadViewDemo/Pods/Local Podspecs/RDReaderView.podspec.json rename ReadViewDemo/Pods/Target Support Files/{RDReaderView/RDReaderView-Info.plist => RDEpubReaderView/RDEpubReaderView-Info.plist} (100%) create mode 100644 ReadViewDemo/Pods/Target Support Files/RDEpubReaderView/RDEpubReaderView-dummy.m rename ReadViewDemo/Pods/Target Support Files/{RDReaderView/RDReaderView-prefix.pch => RDEpubReaderView/RDEpubReaderView-prefix.pch} (100%) rename ReadViewDemo/Pods/Target Support Files/{RDReaderView/RDReaderView-umbrella.h => RDEpubReaderView/RDEpubReaderView-umbrella.h} (60%) rename ReadViewDemo/Pods/Target Support Files/{RDReaderView/RDReaderView.debug.xcconfig => RDEpubReaderView/RDEpubReaderView.debug.xcconfig} (93%) create mode 100644 ReadViewDemo/Pods/Target Support Files/RDEpubReaderView/RDEpubReaderView.modulemap rename ReadViewDemo/Pods/Target Support Files/{RDReaderView/RDReaderView.release.xcconfig => RDEpubReaderView/RDEpubReaderView.release.xcconfig} (93%) rename ReadViewDemo/Pods/Target Support Files/{RDReaderView/ResourceBundle-RDReaderViewAssets-RDReaderView-Info.plist => RDEpubReaderView/ResourceBundle-RDEpubReaderViewAssets-RDEpubReaderView-Info.plist} (100%) delete mode 100644 ReadViewDemo/Pods/Target Support Files/RDReaderView/RDReaderView-dummy.m delete mode 100644 ReadViewDemo/Pods/Target Support Files/RDReaderView/RDReaderView.modulemap create mode 100644 ReadViewDemo/ReadViewDemoUITests/ReaderUITests/FixedLayoutRotationTests.swift rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/CFI/RDEPUBCFI.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/CFI/RDEPUBCFICompatibility.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/CFI/RDEPUBCFIDOMPathBuilder.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/CFI/RDEPUBCFIError.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/CFI/RDEPUBCFIGenerator.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/CFI/RDEPUBCFIMap.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/CFI/RDEPUBCFIParser.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/CFI/RDEPUBCFIPath.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/CFI/RDEPUBCFIRange.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/CFI/RDEPUBCFIRecoveryEngine.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/CFI/RDEPUBCFIResolver.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/CFI/RDEPUBCFISerializer.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/CFI/RDEPUBCFITextAssertion.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/CFI/RDEPUBCFIUtilities.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/Models/RDEPUBAnnotationModels.swift (99%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/Models/RDEPUBPaginationModels.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/Models/RDEPUBReadingLocationModels.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/Notes/RDEPUBNoteDetector.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/Notes/RDEPUBNoteModels.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/Notes/RDEPUBNoteResolver.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/RDEPUBAssetRepository.swift (87%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/RDEPUBFixedLayoutTemplate.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/RDEPUBJavaScriptBridge.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/RDEPUBModels.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/RDEPUBNavigatorLayoutContext.swift (100%) create mode 100644 Sources/RDEpubReaderView/EPUBCore/RDEPUBPaginator.swift rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/RDEPUBParser+Archive.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/RDEPUBParser+Package.swift (96%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/RDEPUBParser+ReadingProfile.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/RDEPUBParser+Resources.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/RDEPUBParser+TOC.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/RDEPUBParser.swift (97%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/RDEPUBPreferences.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/RDEPUBPublication.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/RDEPUBReadingSession.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/RDEPUBRenderRequest.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/RDEPUBResourceDataProvider.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/RDEPUBResourceResolver.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/RDEPUBResourceURLSchemeHandler.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/RDEPUBSafeArea.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/RDEPUBSearchEngine.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/RDEPUBSearchModels.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/RDEPUBStyleSheetBuilder.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/RDEPUBTextAnchor.swift (100%) create mode 100644 Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+Configuration.swift rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/RDEPUBWebView+FixedLayout.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/RDEPUBWebView+JavaScriptBridge.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/RDEPUBWebView+Reflowable.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/RDEPUBWebView+Search.swift (100%) create mode 100644 Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView.swift rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/RDEPUBWebViewDebug.swift (98%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/Resources/WeReadApi.js (97%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/Resources/cssInjector.js (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/Resources/epub-bridge.js (99%) create mode 100644 Sources/RDEpubReaderView/EPUBCore/Resources/epub-fixed-layout.html rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/Resources/rangy-core.js (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/Resources/rangy-serializer.js (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/Resources/wxread-dark.css (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/Resources/wxread-default.css (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/Resources/wxread-replace-latin.css (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBCore/Resources/wxread-replace.css (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBTextRendering/BuildPipeline/RDEPUBBuildDiagnosticsReporter.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBTextRendering/BuildPipeline/RDEPUBChapterTailNormalizer.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBTextRendering/BuildPipeline/RDEPUBPaginationCacheCoordinator.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBTextRendering/BuildPipeline/RDEPUBTextBookBuilder.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBTextRendering/BuildPipeline/RDEPUBTextBookCache.swift (98%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBTextRendering/BuildPipeline/RDEPUBTextBookModels.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBTextRendering/BuildPipeline/RDEPUBTextBuildPipelineInterfaces.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBTextRendering/BuildPipeline/RDEPUBTextPerformanceSampler.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBTextRendering/Pagination/RDEPUBChapterPageCounter.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBTextRendering/Pagination/RDEPUBCoreTextPageFrameFactory.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBTextRendering/Pagination/RDEPUBPageBreakPolicy.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBTextRendering/Pagination/RDEPUBTextLayoutFrame.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBTextRendering/Pagination/RDEPUBTextLayouter.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBTextRendering/Pagination/RDEPUBTextPaginationInterfaces.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBTextRendering/Pagination/RDEPUBTextPaginationSupport.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBTextRendering/RDEPUBChapterData.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBTextRendering/RDEPUBDTCoreTextRenderer.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBTextRendering/RDEPUBTextIndexTable.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBTextRendering/RDEPUBTextPositionConverter.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBTextRendering/RDEPUBTextRenderer.swift (95%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBTextRendering/RDEPUBTextSearchEngine.swift (100%) rename Sources/{RDReaderView/EPUBTextRendering/RDPlainTextBookBuilder.swift => RDEpubReaderView/EPUBTextRendering/RDEpubPlainTextBookBuilder.swift} (99%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBTextRendering/Typesetter/Compatibility/RDEPUBCSSCompatibilityLayer.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBTextRendering/Typesetter/Compatibility/RDEPUBFontFallbackResolver.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBTextRendering/Typesetter/Compatibility/RDEPUBStyleCompatibilityModels.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBTextRendering/Typesetter/RDEPUBAttachmentNormalizer.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBTextRendering/Typesetter/RDEPUBCFIMarkerInjector.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBTextRendering/Typesetter/RDEPUBDecryptingImageAttachment.swift (82%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBTextRendering/Typesetter/RDEPUBFontNormalizer.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBTextRendering/Typesetter/RDEPUBFragmentMarkerInjector.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBTextRendering/Typesetter/RDEPUBHTMLNormalizer.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBTextRendering/Typesetter/RDEPUBRenderDiagnosticsCollector.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBTextRendering/Typesetter/RDEPUBSemanticMarkerInjector.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBTextRendering/Typesetter/RDEPUBStyleSheetComposer.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBTextRendering/Typesetter/RDEPUBTextRendererSupport.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBTextRendering/Typesetter/RDEPUBTypesettingPipeline.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/Notes/RDEPUBNotePopupCoordinator.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/Notes/RDEPUBNotePopupViewController.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/RDEPUBAttachmentTooltipOverlayView.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/RDEPUBAttachmentTooltipView.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/RDEPUBImageViewController.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/RDEPUBImageViewerCoordinator.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/RDEPUBReaderBottomToolView.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/RDEPUBReaderChapterListController.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/RDEPUBReaderController+AttachmentTooltip.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/RDEPUBReaderController+ContentDelegates.swift (96%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/RDEPUBReaderController+DataSource.swift (94%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/RDEPUBReaderController+ExternalLinks.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/RDEPUBReaderController+LocationResolution.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/RDEPUBReaderController+Orientation.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/RDEPUBReaderController+PublicAPI.swift (100%) create mode 100644 Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+RenderSupport.swift rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/RDEPUBReaderController+RuntimeBridge.swift (99%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/RDEPUBReaderController+TableOfContents.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/RDEPUBReaderController+Trial.swift (97%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/RDEPUBReaderController.swift (99%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/RDEPUBReaderDelegate.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/RDEPUBReaderHighlightsViewController.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/RDEPUBReaderPersistence.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/RDEPUBReaderSearchBarView.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/RDEPUBReaderTableOfContentsItem.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/RDEPUBReaderToolView.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/RDEPUBReaderToolViewProtocols.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/RDEPUBReaderTopToolView.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/RDEPUBSettingsFlipAutomation.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/RDEPUBViewportTypes.swift (100%) create mode 100644 Sources/RDEpubReaderView/EPUBUI/RDEPUBWebContentView.swift rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/RDEPUBWebDecorationOverlayView.swift (100%) rename Sources/{RDReaderView/EPUBUI/RDURLReaderController.swift => RDEpubReaderView/EPUBUI/RDEpubURLReaderController.swift} (98%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/ReaderController/ChapterRuntime/RDEPUBBackgroundTrace.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/ReaderController/ChapterRuntime/RDEPUBBookPageMap.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterCacheKey.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterDataCache.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLoader.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLocation.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterOffsetMap.swift (100%) create mode 100644 Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterRuntimeStore.swift rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterSummaryDiskCache.swift (98%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterWarmupOrchestrator.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/ReaderController/ChapterRuntime/RDEPUBPageCountCache.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/ReaderController/ChapterRuntime/RDEPUBPageResolver.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/ReaderController/ChapterRuntime/RDEPUBRuntimeChapter.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/ReaderController/ChapterRuntime/RDEPUBRuntimePageCount.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/ReaderController/RDEPUBBackgroundCoverageStore.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/ReaderController/RDEPUBBackgroundPriorityPolicy.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/ReaderController/RDEPUBJumpSession.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/ReaderController/RDEPUBMemoryProbe.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/ReaderController/RDEPUBMetadataParseCancellationController.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/ReaderController/RDEPUBMetadataParseWorker.swift (99%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/ReaderController/RDEPUBPageMapReconciliationCoordinator.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/ReaderController/RDEPUBPresentationRuntime.swift (99%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/ReaderController/RDEPUBReaderAnnotationCoordinator.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/ReaderController/RDEPUBReaderAssemblyCoordinator.swift (92%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/ReaderController/RDEPUBReaderChromeCoordinator.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/ReaderController/RDEPUBReaderContext.swift (99%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/ReaderController/RDEPUBReaderDependencies.swift (94%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/ReaderController/RDEPUBReaderEnvironment.swift (97%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/ReaderController/RDEPUBReaderLoadCoordinator.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/ReaderController/RDEPUBReaderLocationCoordinator.swift (100%) create mode 100644 Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift create mode 100644 Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderRuntime.swift rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/ReaderController/RDEPUBReaderSearchCoordinator.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/ReaderController/RDEPUBReaderServices.swift (97%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/ReaderController/RDEPUBReaderState.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/ReaderController/RDEPUBReaderUIState.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/ReaderController/RDEPUBReaderViewportMonitor.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/ReaderController/RDEPUBSelectionState.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/Settings/RDEPUBReaderConfiguration.swift (98%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/Settings/RDEPUBReaderSettings.swift (97%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/Settings/RDEPUBReaderSettingsViewController.swift (99%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/Settings/RDEPUBReaderTheme.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/String+RDEPUB.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/TextPage/RDEPUBChapterDisplayContentCache.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/TextPage/RDEPUBDarkImageAdjuster.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/TextPage/RDEPUBPageInteractionController.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/TextPage/RDEPUBPageLayoutSnapshot.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/TextPage/RDEPUBSelectionLoupeView.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/TextPage/RDEPUBSelectionOverlayView.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/TextPage/RDEPUBTextAnnotationOverlay.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/TextPage/RDEPUBTextContentInteractionCoordinator.swift (99%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/TextPage/RDEPUBTextContentView.swift (94%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/TextPage/RDEPUBTextPageBoundaryValidator.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/TextPage/RDEPUBTextPageDecorationView.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/TextPage/RDEPUBTextPageRenderView.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/TextPage/RDEPUBTextPageVerticalJustifier.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/TextPage/RDEPUBTextSelectionController.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/UIColor+RDEPUB.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView}/EPUBUI/UIColor+RDEPUBHex.swift (100%) create mode 100644 Sources/RDEpubReaderView/RDEpubReaderView.podspec create mode 100644 Sources/RDEpubReaderView/README.md rename Sources/{RDReaderView/ReaderView/Paging/RDReaderPagingController.swift => RDEpubReaderView/ReaderView/Paging/RDEpubReaderPagingController.swift} (92%) create mode 100644 Sources/RDEpubReaderView/ReaderView/Paging/RDEpubReaderPreloadController.swift rename Sources/{RDReaderView/ReaderView/Paging/RDReaderSpreadResolver.swift => RDEpubReaderView/ReaderView/Paging/RDEpubReaderSpreadResolver.swift} (98%) rename Sources/{RDReaderView/ReaderView/Paging/RDReaderTapRegionHandler.swift => RDEpubReaderView/ReaderView/Paging/RDEpubReaderTapRegionHandler.swift} (91%) rename Sources/{RDReaderView/ReaderView/RDReaderContentCell.swift => RDEpubReaderView/ReaderView/RDEpubReaderContentCell.swift} (94%) rename Sources/{RDReaderView/ReaderView/RDReaderFlowLayout.swift => RDEpubReaderView/ReaderView/RDEpubReaderFlowLayout.swift} (93%) rename Sources/{RDReaderView/ReaderView/RDReaderGestureController.swift => RDEpubReaderView/ReaderView/RDEpubReaderGestureController.swift} (88%) rename Sources/{RDReaderView/ReaderView/RDReaderPageChildViewController.swift => RDEpubReaderView/ReaderView/RDEpubReaderPageChildViewController.swift} (95%) rename Sources/{RDReaderView/ReaderView/RDReaderView+CollectionView.swift => RDEpubReaderView/ReaderView/RDEpubReaderView+CollectionView.swift} (80%) rename Sources/{RDReaderView/ReaderView/RDReaderView+ContentAccess.swift => RDEpubReaderView/ReaderView/RDEpubReaderView+ContentAccess.swift} (86%) rename Sources/{RDReaderView/ReaderView/RDReaderView+PageCurl.swift => RDEpubReaderView/ReaderView/RDEpubReaderView+PageCurl.swift} (70%) rename Sources/{RDReaderView/ReaderView/RDReaderView+ToolView.swift => RDEpubReaderView/ReaderView/RDEpubReaderView+ToolView.swift} (91%) create mode 100644 Sources/RDEpubReaderView/ReaderView/RDEpubReaderView.swift create mode 100644 Sources/RDEpubReaderView/ReaderView/RDEpubReaderViewProtocols.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFI.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFICompatibility.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIDOMPathBuilder.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIError.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIGenerator.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIMap.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIParser.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIPath.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIRange.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIRecoveryEngine.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIResolver.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFISerializer.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFITextAssertion.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIUtilities.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Models/RDEPUBAnnotationModels.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Models/RDEPUBPaginationModels.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Models/RDEPUBReadingLocationModels.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Notes/RDEPUBNoteDetector.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Notes/RDEPUBNoteModels.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Notes/RDEPUBNoteResolver.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBAssetRepository.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBFixedLayoutTemplate.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBJavaScriptBridge.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBModels.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBNavigatorLayoutContext.swift rename Sources/{RDReaderView => RDEpubReaderView/Sources/RDEpubReaderView}/EPUBCore/RDEPUBPaginator.swift (100%) create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+Archive.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+Package.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+ReadingProfile.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+Resources.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+TOC.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBParser.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBPreferences.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBPublication.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBReadingSession.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBRenderRequest.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBResourceDataProvider.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBResourceResolver.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBResourceURLSchemeHandler.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBSafeArea.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBSearchEngine.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBSearchModels.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBStyleSheetBuilder.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBTextAnchor.swift rename Sources/{RDReaderView => RDEpubReaderView/Sources/RDEpubReaderView}/EPUBCore/RDEPUBWebView+Configuration.swift (100%) create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+FixedLayout.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+JavaScriptBridge.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+Reflowable.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+Search.swift rename Sources/{RDReaderView => RDEpubReaderView/Sources/RDEpubReaderView}/EPUBCore/RDEPUBWebView.swift (100%) create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebViewDebug.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/WeReadApi.js create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/cssInjector.js create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/epub-bridge.js rename Sources/{RDReaderView => RDEpubReaderView/Sources/RDEpubReaderView}/EPUBCore/Resources/epub-fixed-layout.html (100%) create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/rangy-core.js create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/rangy-serializer.js create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/wxread-dark.css create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/wxread-default.css create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/wxread-replace-latin.css create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/wxread-replace.css create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBBuildDiagnosticsReporter.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBChapterTailNormalizer.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBPaginationCacheCoordinator.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookBuilder.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookCache.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookModels.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBuildPipelineInterfaces.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextPerformanceSampler.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBChapterPageCounter.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBCoreTextPageFrameFactory.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBPageBreakPolicy.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBTextLayoutFrame.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBTextLayouter.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBTextPaginationInterfaces.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBTextPaginationSupport.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBChapterData.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBDTCoreTextRenderer.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBTextIndexTable.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBTextPositionConverter.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBTextSearchEngine.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEpubPlainTextBookBuilder.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBCSSCompatibilityLayer.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBFontFallbackResolver.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBStyleCompatibilityModels.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBAttachmentNormalizer.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBCFIMarkerInjector.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBDecryptingImageAttachment.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBFontNormalizer.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBFragmentMarkerInjector.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBHTMLNormalizer.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBRenderDiagnosticsCollector.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBSemanticMarkerInjector.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBStyleSheetComposer.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBTextRendererSupport.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBTypesettingPipeline.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Notes/RDEPUBNotePopupCoordinator.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Notes/RDEPUBNotePopupViewController.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBAttachmentTooltipOverlayView.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBAttachmentTooltipView.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBImageViewController.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBImageViewerCoordinator.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderBottomToolView.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderChapterListController.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+AttachmentTooltip.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+DataSource.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+ExternalLinks.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+LocationResolution.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+Orientation.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+PublicAPI.swift rename Sources/{RDReaderView => RDEpubReaderView/Sources/RDEpubReaderView}/EPUBUI/RDEPUBReaderController+RenderSupport.swift (100%) create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+RuntimeBridge.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+TableOfContents.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+Trial.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderDelegate.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderHighlightsViewController.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderPersistence.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderSearchBarView.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderTableOfContentsItem.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderToolView.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderToolViewProtocols.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderTopToolView.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBSettingsFlipAutomation.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBViewportTypes.swift rename Sources/{RDReaderView => RDEpubReaderView/Sources/RDEpubReaderView}/EPUBUI/RDEPUBWebContentView.swift (100%) create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBWebDecorationOverlayView.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEpubURLReaderController.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBBackgroundTrace.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBBookPageMap.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterCacheKey.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterDataCache.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLoader.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLocation.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterOffsetMap.swift rename Sources/{RDReaderView => RDEpubReaderView/Sources/RDEpubReaderView}/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterRuntimeStore.swift (98%) create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterSummaryDiskCache.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterWarmupOrchestrator.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBPageCountCache.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBPageResolver.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBRuntimeChapter.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBRuntimePageCount.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBBackgroundCoverageStore.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBBackgroundPriorityPolicy.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBJumpSession.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBMemoryProbe.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBMetadataParseCancellationController.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBMetadataParseWorker.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBPageMapReconciliationCoordinator.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBPresentationRuntime.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderAnnotationCoordinator.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderAssemblyCoordinator.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderChromeCoordinator.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderContext.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderDependencies.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderEnvironment.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderLoadCoordinator.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderLocationCoordinator.swift rename Sources/{RDReaderView => RDEpubReaderView/Sources/RDEpubReaderView}/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift (100%) rename Sources/{RDReaderView => RDEpubReaderView/Sources/RDEpubReaderView}/EPUBUI/ReaderController/RDEPUBReaderRuntime.swift (100%) create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderSearchCoordinator.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderServices.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderState.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderUIState.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderViewportMonitor.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBSelectionState.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderConfiguration.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderSettings.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderSettingsViewController.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderTheme.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/String+RDEPUB.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBChapterDisplayContentCache.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBDarkImageAdjuster.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBPageInteractionController.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBPageLayoutSnapshot.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBSelectionLoupeView.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBSelectionOverlayView.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextAnnotationOverlay.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextContentInteractionCoordinator.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextPageBoundaryValidator.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextPageDecorationView.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextPageRenderView.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextPageVerticalJustifier.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextSelectionController.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/UIColor+RDEPUB.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/UIColor+RDEPUBHex.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/Paging/RDEpubReaderPagingController.swift rename Sources/{RDReaderView/ReaderView/Paging/RDReaderPreloadController.swift => RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/Paging/RDEpubReaderPreloadController.swift} (87%) create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/Paging/RDEpubReaderSpreadResolver.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/Paging/RDEpubReaderTapRegionHandler.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderContentCell.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderFlowLayout.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderGestureController.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderPageChildViewController.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView+CollectionView.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView+ContentAccess.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView+PageCurl.swift create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView+ToolView.swift rename Sources/{RDReaderView/ReaderView/RDReaderView.swift => RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView.swift} (85%) create mode 100644 Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderViewProtocols.swift delete mode 100644 Sources/RDReaderView/ReaderView/RDReaderViewProtocols.swift delete mode 100644 _ssoft-output/implementation-artifacts/spec-cfi-11-issues-fix.md diff --git a/.gitignore b/.gitignore index 9dba7dd..14b3bf9 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,21 @@ xcuserdata/ .artifacts/ .deriveddata/ .swift-module-cache/ + +# This repository checks in the Demo Pods project. Keep generated files for the +# local RDEpubReaderView pod visible even when a global gitignore excludes Pods. +!ReadViewDemo/Pods/ +!ReadViewDemo/Pods/Local Podspecs/ +!ReadViewDemo/Pods/Local Podspecs/RDEpubReaderView.podspec.json +!ReadViewDemo/Pods/Target Support Files/ +!ReadViewDemo/Pods/Target Support Files/RDEpubReaderView/ +!ReadViewDemo/Pods/Target Support Files/RDEpubReaderView/** + +# macOS system files +.DS_Store + +# Local backup directories +*手动解包备份* + +# AI tool output +_ssoft-output/ diff --git a/CODE_REVIEW.md b/CODE_REVIEW.md index b273585..5f8a0da 100644 --- a/CODE_REVIEW.md +++ b/CODE_REVIEW.md @@ -1,7 +1,7 @@ # ReadViewSDK 代码审查报告 > 审查日期:2026-06-26 -> 审查范围:Sources/RDReaderView 全部源码 +> 审查范围:Sources/RDEpubReaderView 全部源码 > 审查方法:逐文件阅读 + 交叉验证 + 线程模型分析 --- @@ -148,4 +148,4 @@ func currentTextPageSize() -> CGSize { | 🟡 P2 | P2-2 | javaScriptStringLiteral 实现依赖隐式假设 | 可维护性 | | 🔵 P3 | P3-1 | 后台线程同步 hop 主线程获取布局信息 | 线程模型 | -**整体评价**: SDK 架构设计良好,模块分层清晰,引用管理(unowned/weak)使用正确,分页取消机制有 `paginationToken` + `cancellationController` 兜底。主要值得修复的是三个性能类 P1 问题(主线程阻塞搜索、资源加载阻塞、缓存无清理),两个 P2 鲁棒性/可维护性问题,以及一个 P3 线程模型优化建议。 \ No newline at end of file +**整体评价**: SDK 架构设计良好,模块分层清晰,引用管理(unowned/weak)使用正确,分页取消机制有 `paginationToken` + `cancellationController` 兜底。主要值得修复的是三个性能类 P1 问题(主线程阻塞搜索、资源加载阻塞、缓存无清理),两个 P2 鲁棒性/可维护性问题,以及一个 P3 线程模型优化建议。 diff --git a/Doc/API_REFERENCE.md b/Doc/API_REFERENCE.md index de34fb6..b3cfcaa 100644 --- a/Doc/API_REFERENCE.md +++ b/Doc/API_REFERENCE.md @@ -8,7 +8,7 @@ ## 1. RDEPUBReaderController — 入口控制器 -**文件**:`Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift` 及扩展 +**文件**:`Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController.swift` 及扩展 ### 1.1 初始化 @@ -180,7 +180,7 @@ func nativeTextSemanticSummary() -> String? ## 2. RDEPUBReaderDelegate — 委托协议 -**文件**:`Sources/RDReaderView/EPUBUI/RDEPUBReaderDelegate.swift` +**文件**:`Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderDelegate.swift` 所有方法均有默认空实现,可按需实现。 @@ -232,7 +232,7 @@ public protocol RDEPUBReaderDelegate: AnyObject { ## 3. RDEPUBReaderPersistence — 持久化协议 -**文件**:`Sources/RDReaderView/EPUBUI/RDEPUBReaderPersistence.swift` +**文件**:`Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderPersistence.swift` ```swift public protocol RDEPUBReaderPersistence: AnyObject { @@ -264,7 +264,7 @@ public protocol RDEPUBReaderPersistence: AnyObject { ## 4. RDEPUBReaderConfiguration — 配置模型 -**文件**:`Sources/RDReaderView/EPUBUI/Settings/RDEPUBReaderConfiguration.swift` +**文件**:`Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderConfiguration.swift` | 属性 | 类型 | 默认值 | 说明 | |------|------|--------|------| @@ -273,7 +273,7 @@ public protocol RDEPUBReaderPersistence: AnyObject { | `fontChoice` | `RDEPUBReaderFontChoice` | `.system` | 字体选择(system/serif/rounded/monospaced) | | `numberOfColumns` | `Int` | `1` | 每页列数(1 或 2) | | `columnGap` | `CGFloat` | `20` | 列间距 | -| `displayType` | `RDReaderView.DisplayType` | `.pageCurl` | 翻页模式 | +| `displayType` | `RDEpubReaderView.DisplayType` | `.pageCurl` | 翻页模式 | | `landscapeDualPageEnabled` | `Bool` | `true` | 横屏双页 | | `showsTableOfContents` | `Bool` | `true` | 显示目录 | | `allowsHighlights` | `Bool` | `true` | 允许高亮 | @@ -301,50 +301,50 @@ public protocol RDEPUBReaderPersistence: AnyObject { ## 5. 翻页容器协议 -**文件**:`Sources/RDReaderView/ReaderView/RDReaderViewProtocols.swift` +**文件**:`Sources/RDEpubReaderView/ReaderView/RDEpubReaderViewProtocols.swift` -### 5.1 RDReaderPageProvider(推荐) +### 5.1 RDEpubReaderPageProvider(推荐) ```swift -@objc public protocol RDReaderPageProvider: NSObjectProtocol { +@objc public protocol RDEpubReaderPageProvider: NSObjectProtocol { /// 总页数 - func numberOfPages(in readerView: RDReaderView) -> Int + func numberOfPages(in readerView: RDEpubReaderView) -> Int /// 返回指定页的视图 /// - Parameters: /// - index: 页码索引(0-based) /// - reusableView: 可复用的旧视图(可能为 nil) - func readerView(_ readerView: RDReaderView, viewForPageAt index: Int, reusableView: UIView?) -> UIView + func readerView(_ readerView: RDEpubReaderView, viewForPageAt index: Int, reusableView: UIView?) -> UIView /// 页面标识符(用于缓存去重) - @objc optional func pageIdentifier(in readerView: RDReaderView, index: Int) -> String? + @objc optional func pageIdentifier(in readerView: RDEpubReaderView, index: Int) -> String? /// 顶部工具栏视图 - @objc optional func readerViewTopChrome(_ readerView: RDReaderView) -> UIView? + @objc optional func readerViewTopChrome(_ readerView: RDEpubReaderView) -> UIView? /// 底部工具栏视图 - @objc optional func readerViewBottomChrome(_ readerView: RDReaderView) -> UIView? + @objc optional func readerViewBottomChrome(_ readerView: RDEpubReaderView) -> UIView? } ``` -### 5.2 RDReaderDelegate +### 5.2 RDEpubReaderDelegate ```swift -@objc public protocol RDReaderDelegate: NSObjectProtocol { +@objc public protocol RDEpubReaderDelegate: NSObjectProtocol { /// 页面变化回调 - func pageNum(readerView: RDReaderView, pageNum: Int) + func pageNum(readerView: RDEpubReaderView, pageNum: Int) /// 屏幕方向即将变化 - @objc optional func readerViewOrientationWillChange(readerView: RDReaderView, isLandscape: Bool) + @objc optional func readerViewOrientationWillChange(readerView: RDEpubReaderView, isLandscape: Bool) } ``` -### 5.3 RDReaderPageNavigating +### 5.3 RDEpubReaderPageNavigating ```swift -public protocol RDReaderPageNavigating: AnyObject { +public protocol RDEpubReaderPageNavigating: AnyObject { /// 当前页码 var currentPage: Int { get } @@ -357,19 +357,19 @@ public protocol RDReaderPageNavigating: AnyObject { } ``` -### 5.4 RDReaderDataSource(遗留) +### 5.4 RDEpubReaderDataSource(遗留) ```swift -@objc public protocol RDReaderDataSource: NSObjectProtocol { - func pageCountOfReaderView(readerView: RDReaderView) -> Int - func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView - func pageIdentifier(readerView: RDReaderView, pageNum: Int) -> String? - @objc optional func topToolView(readerView: RDReaderView) -> UIView? - @objc optional func bottomToolView(readerView: RDReaderView) -> UIView? +@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? } ``` -通过 `RDReaderLegacyDataSourceAdapter` 自动适配到 `RDReaderPageProvider`。 +通过 `RDEpubReaderLegacyDataSourceAdapter` 自动适配到 `RDEpubReaderPageProvider`。 --- diff --git a/Doc/ARCHITECTURE.md b/Doc/ARCHITECTURE.md index 776cca8..25d0514 100644 --- a/Doc/ARCHITECTURE.md +++ b/Doc/ARCHITECTURE.md @@ -22,7 +22,7 @@ ReadViewSDK 是一个 iOS EPUB 阅读器 SDK,支持文本重排(Reflowable │ ReadViewDemo (Demo App) │ │ ViewController · LaunchAutomationPlan · UITests │ └──────────────────────────────┬──────────────────────────────┘ - │ imports RDReaderView + │ imports RDEpubReaderView ┌──────────────────────────────▼──────────────────────────────┐ │ EPUBUI 层 │ │ RDEPUBReaderController · Coordinators · Settings · TextPage │ @@ -30,8 +30,8 @@ ReadViewSDK 是一个 iOS EPUB 阅读器 SDK,支持文本重排(Reflowable └──────────────────────────────┬──────────────────────────────┘ │ ┌──────────────────────────────▼──────────────────────────────┐ -│ RDReaderView 层 │ -│ RDReaderView · FlowLayout · PreloadController │ +│ RDEpubReaderView 层 │ +│ RDEpubReaderView · FlowLayout · PreloadController │ │ SpreadResolver · TapRegionHandler · PagingController │ └──────────────────────────────┬──────────────────────────────┘ │ @@ -54,7 +54,7 @@ ReadViewSDK 是一个 iOS EPUB 阅读器 SDK,支持文本重排(Reflowable |---|---|---| | **EPUBCore** | EPUB 文件解析、资源管理、WebView 渲染、JS 桥接 | `RDEPUBParser`, `RDEPUBPublication`, `RDEPUBWebView` | | **EPUBTextRendering** | HTML→NSAttributedString 转换、排版、分页、全书构建 | `RDEPUBTextBookBuilder`, `RDEPUBCoreTextPageFrameFactory` | -| **RDReaderView** | 通用翻页容器、手势识别、页面预加载、双页布局 | `RDReaderView`, `RDReaderFlowLayout`, `RDReaderPreloadController` | +| **RDEpubReaderView** | 通用翻页容器、手势识别、页面预加载、双页布局 | `RDEpubReaderView`, `RDEpubReaderFlowLayout`, `RDEpubReaderPreloadController` | | **EPUBUI** | 阅读器控制器、协调器模式、设置面板、按需加载、磁盘缓存 | `RDEPUBReaderController`, `RDEPUBReaderPaginationCoordinator` | --- @@ -310,10 +310,10 @@ RDEPUBWebView (UIView) --- -## 7. 翻页容器架构(RDReaderView) +## 7. 翻页容器架构(RDEpubReaderView) ``` -RDReaderView (UIView) +RDEpubReaderView (UIView) │ ├─ 三种翻页模式: │ ├─ .pageCurl → UIPageViewController (翻页动画) @@ -321,14 +321,14 @@ RDReaderView (UIView) │ └─ .verticalScroll → UICollectionView (垂直滚动) │ ├─ 组合对象: - │ ├─ RDReaderPagingController // 翻页状态管理、请求队列 - │ ├─ RDReaderPreloadController // 页面预加载、缓存管理 - │ ├─ RDReaderSpreadResolver // 双页展开计算 - │ └─ RDReaderTapRegionHandler // 点击区域分类(左/中/右) + │ ├─ RDEpubReaderPagingController // 翻页状态管理、请求队列 + │ ├─ RDEpubReaderPreloadController // 页面预加载、缓存管理 + │ ├─ RDEpubReaderSpreadResolver // 双页展开计算 + │ └─ RDEpubReaderTapRegionHandler // 点击区域分类(左/中/右) │ ├─ 数据源协议: - │ ├─ RDReaderPageProvider (新) // 格式无关,优先级高 - │ └─ RDReaderDataSource (旧) // 遗留兼容,通过 Adapter 适配 + │ ├─ RDEpubReaderPageProvider (新) // 格式无关,优先级高 + │ └─ RDEpubReaderDataSource (旧) // 遗留兼容,通过 Adapter 适配 │ └─ 手势流: 点击 → TapRegionHandler → 分类(左/中/右) @@ -351,7 +351,7 @@ struct RDEPUBReaderConfiguration { var numberOfColumns: Int // 1 或 2 var columnGap: CGFloat // 默认 20 var theme: ReaderTheme // 6 种主题 - var displayType: RDReaderView.DisplayType // pageCurl/horizontal/vertical + var displayType: RDEpubReaderView.DisplayType // pageCurl/horizontal/vertical var onDemandChapterWindowSize: Int // 默认 3(奇数,最小 3,最大 15) var metadataParsingConcurrency: Int // 默认 CPU 核心数 var chapterWindowRadius: Int // 内存缓存窗口半径 @@ -374,7 +374,7 @@ struct RDEPUBReaderConfiguration { ## 9. 目录结构 ``` -Sources/RDReaderView/ +Sources/RDEpubReaderView/ ├── EPUBCore/ # EPUB 解析与 WebView 渲染 │ ├── Models/ # 数据模型 │ │ ├── RDEPUBAnnotationModels.swift @@ -428,19 +428,19 @@ Sources/RDReaderView/ │ ├── RDEPUBDTCoreTextRenderer.swift # DTCoreText 渲染器实现 │ ├── RDEPUBChapterData.swift # 章节查询门面 │ ├── RDEPUBTextIndexTable.swift # 全书索引表 -│ └── RDPlainTextBookBuilder.swift # 纯文本 (.txt) 构建器 +│ └── RDEpubPlainTextBookBuilder.swift # 纯文本 (.txt) 构建器 │ ├── ReaderView/ # 通用翻页容器 │ ├── Paging/ # 翻页子系统 -│ │ ├── RDReaderPagingController.swift -│ │ ├── RDReaderPreloadController.swift -│ │ ├── RDReaderSpreadResolver.swift -│ │ └── RDReaderTapRegionHandler.swift -│ ├── RDReaderView.swift # 主容器视图 -│ ├── RDReaderView+*.swift # 扩展(CollectionView/PageCurl/ToolView/ContentAccess) -│ ├── RDReaderFlowLayout.swift # 自定义 CollectionView 布局 -│ ├── RDReaderContentCell.swift # 滚动模式 Cell -│ └── RDReaderPageChildViewController.swift // 翻页模式子 VC +│ │ ├── RDEpubReaderPagingController.swift +│ │ ├── RDEpubReaderPreloadController.swift +│ │ ├── RDEpubReaderSpreadResolver.swift +│ │ └── RDEpubReaderTapRegionHandler.swift +│ ├── RDEpubReaderView.swift # 主容器视图 +│ ├── RDEpubReaderView+*.swift # 扩展(CollectionView/PageCurl/ToolView/ContentAccess) +│ ├── RDEpubReaderFlowLayout.swift # 自定义 CollectionView 布局 +│ ├── RDEpubReaderContentCell.swift # 滚动模式 Cell +│ └── RDEpubReaderPageChildViewController.swift // 翻页模式子 VC │ └── EPUBUI/ # 阅读器 UI ├── ReaderController/ # 控制器与协调器 @@ -494,7 +494,7 @@ Sources/RDReaderView/ | **Builder** | `RDEPUBBookPageMap.Builder` 增量构建页码映射 | | **Strategy** | `RDEPUBTextRenderer` 协议,可替换渲染器实现 | | **Pipeline** | `RDEPUBTextTypesetterPipeline` 排版管线(8 个逻辑阶段,封装为 5-6 个顶层调用) | -| **Adapter** | `RDReaderLegacyDataSourceAdapter` 适配旧数据源协议 | +| **Adapter** | `RDEpubReaderLegacyDataSourceAdapter` 适配旧数据源协议 | | **三级缓存** | 内存 → 磁盘摘要 → 全书分页,逐级降级 | | **Token 取消** | `paginationToken` 确保过期异步任务不干扰新任务 | | **Frozen Parameters** | 后台任务冻结 `renderSignature`,避免运行中参数漂移 | diff --git a/Doc/AUDIT_FINAL.md b/Doc/AUDIT_FINAL.md index 5f338a7..73f60a9 100644 --- a/Doc/AUDIT_FINAL.md +++ b/Doc/AUDIT_FINAL.md @@ -1,6 +1,6 @@ # ReadViewSDK 代码审查报告 -> 审查范围:`Sources/RDReaderView/` 全部 184 个 Swift 文件(约 32,300 行) +> 审查范围:`Sources/RDEpubReaderView/` 全部 184 个 Swift 文件(约 32,300 行) > 审查维度:并发安全、内存管理、API 正确性、Swift/iOS 平台特定、架构与依赖 > 审查日期:2026-06-26 @@ -134,7 +134,7 @@ let stillChanged = abs(currentSize.width - self.lastTextPaginationPageSize!.widt ### H-10 · 依赖版本未锁定 -`RDReaderView.podspec` 中 DTCoreText、SnapKit、SSAlertSwift 无版本约束: +`RDEpubReaderView.podspec` 中 DTCoreText、SnapKit、SSAlertSwift 无版本约束: ```ruby s.dependency 'ZIPFoundation', '~> 0.9' # ✅ 已锁定 @@ -143,7 +143,7 @@ s.dependency 'SnapKit' # ❌ 无约束 s.dependency 'SSAlertSwift' # ❌ 无约束 ``` -**位置:** `RDReaderView.podspec:17-20` +**位置:** `RDEpubReaderView.podspec:17-20` --- diff --git a/Doc/BUSINESS_LOGIC.md b/Doc/BUSINESS_LOGIC.md index 63f98fd..ed734fc 100644 --- a/Doc/BUSINESS_LOGIC.md +++ b/Doc/BUSINESS_LOGIC.md @@ -298,14 +298,14 @@ return builder.build() **触发条件:** `landscapeDualPageEnabled && isLandscape && !verticalScroll` -**页面配对逻辑(RDReaderSpreadResolver):** +**页面配对逻辑(RDEpubReaderSpreadResolver):** - 有封面页:封面独占一屏,后续页面两两配对 - 配对规则:`(coverIndex, nil)`, `(1, 2)`, `(3, 4)`, ... - 无封面页:标准偶奇配对 - 配对规则:`(0, 1)`, `(2, 3)`, `(4, 5)`, ... -**封面感知布局(RDReaderFlowLayout):** +**封面感知布局(RDEpubReaderFlowLayout):** ``` 封面页: [====全屏宽度====] @@ -314,7 +314,7 @@ return builder.build() ### 5.3 页面预加载 -`RDReaderPreloadController` 管理两个缓存: +`RDEpubReaderPreloadController` 管理两个缓存: - `preloadedPageViews` - 预渲染的页面视图 - `pageCurlCachedViews` - 当前显示的页面视图 @@ -580,7 +580,7 @@ protocol RDEPUBReaderPersistence { ## 9. 纯文本 (.txt) 支持 -`RDPlainTextBookBuilder` 复用 EPUB 渲染管线处理 .txt 文件: +`RDEpubPlainTextBookBuilder` 复用 EPUB 渲染管线处理 .txt 文件: 1. **解码:** 依次尝试 UTF-8 → GB18030 → GBK 2. **分章:** 正则匹配 `^(第[零一二三四五六七八九十百千万\d]+[章节回卷].*)$` diff --git a/Doc/CFI_SUBSYSTEM.md b/Doc/CFI_SUBSYSTEM.md index cc7f43a..e5b6365 100644 --- a/Doc/CFI_SUBSYSTEM.md +++ b/Doc/CFI_SUBSYSTEM.md @@ -34,7 +34,7 @@ epubcfi(/6/4!ch01.xhtml/4/2/1:3) 顶层 CFI 模型,对应一个完整的 `epubcfi(...)` 字符串。 -**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFI.swift` +**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFI.swift` ```swift public struct RDEPUBCFI: Codable, Equatable, Hashable { @@ -66,7 +66,7 @@ public enum RDEPUBCFISideBias: String, Codable { 路径模型,由一组 `RDEPUBCFIStep` 组成,描述从根节点到目标节点的遍历序列。 -**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIPath.swift` +**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIPath.swift` ```swift public struct RDEPUBCFIPath: Codable, Equatable, Hashable { @@ -95,7 +95,7 @@ public struct RDEPUBCFIStep: Codable, Equatable, Hashable { 范围模型,表示文档中的一个连续区域,由父级 CFI 和起止 CFI 组成。 -**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIRange.swift` +**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIRange.swift` ```swift public struct RDEPUBCFIRange: Codable, Equatable, Hashable { @@ -116,7 +116,7 @@ public struct RDEPUBCFIRange: Codable, Equatable, Hashable { 将 CFI 字符串解析为结构化模型。 -**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIParser.swift` +**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIParser.swift` ```swift public enum RDEPUBCFIParser { @@ -154,7 +154,7 @@ print(cfi.textAssertion?.exact) // Optional("目标文本") 将 CFI 模型序列化为标准字符串。 -**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFISerializer.swift` +**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFISerializer.swift` ```swift public enum RDEPUBCFISerializer { @@ -192,7 +192,7 @@ let serialized = RDEPUBCFISerializer.serialize(cfi) 将 CFI 解析为可直接用于资源定位的结果。 -**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIResolver.swift` +**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIResolver.swift` ```swift public struct RDEPUBCFIResolverResult: Equatable { @@ -228,7 +228,7 @@ print(result.chapterOffset) // Optional(80) 从已知的章节信息生成 CFI。 -**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIGenerator.swift` +**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIGenerator.swift` ```swift public enum RDEPUBCFIGenerator { @@ -274,7 +274,7 @@ public enum RDEPUBCFIGenerator { 从 HTML 源码中提取带有 `id` 属性的元素路径映射。 -**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIDOMPathBuilder.swift` +**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIDOMPathBuilder.swift` ```swift public enum RDEPUBCFIDOMPathBuilder { @@ -296,7 +296,7 @@ public enum RDEPUBCFIDOMPathBuilder { 当 CFI 精确定位失败时(如 DOM 结构变更),恢复引擎通过多级降级策略尝试找到最佳匹配位置。 -**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIRecoveryEngine.swift` +**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIRecoveryEngine.swift` ### 5.1 恢复置信度 @@ -361,7 +361,7 @@ let result = RDEPUBCFIRecoveryEngine.recover( 文本断言用于在 CFI 定位后验证所指位置的文本内容是否符合预期,增强定位鲁棒性。 -**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFITextAssertion.swift` +**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFITextAssertion.swift` ```swift public struct RDEPUBCFITextAssertion: Codable, Equatable, Hashable { @@ -393,7 +393,7 @@ print(assertion?.suffix) // Optional("后续内容") 提供宽松解析接口,兼容非标准 CFI 格式。 -**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFICompatibility.swift` +**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFICompatibility.swift` ```swift public enum RDEPUBCFICompatibility { @@ -418,7 +418,7 @@ public enum RDEPUBCFICompatibility { CFI 映射是章节级别的索引结构,将 CFI 路径映射到章节文本偏移量,是容错恢复引擎的核心数据源。 -**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIMap.swift` +**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIMap.swift` ### 8.1 RDEPUBCFIMap @@ -506,7 +506,7 @@ public struct RDEPUBCFITokenAnchor: Codable, Equatable { ## 9. 错误类型(RDEPUBCFIError) -**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIError.swift` +**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIError.swift` ```swift public enum RDEPUBCFIError: Error, Equatable { diff --git a/Doc/CHAPTER_RUNTIME.md b/Doc/CHAPTER_RUNTIME.md index 7720a4b..9a1501f 100644 --- a/Doc/CHAPTER_RUNTIME.md +++ b/Doc/CHAPTER_RUNTIME.md @@ -16,8 +16,8 @@ - 渐进补全:后台逐步补全所有章节的页码信息 **关键文件**: -- `Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/` 目录 -- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift` +- `Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/` 目录 +- `Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift` --- diff --git a/Doc/CONCERNS.md b/Doc/CONCERNS.md index df33a24..3c58755 100644 --- a/Doc/CONCERNS.md +++ b/Doc/CONCERNS.md @@ -105,15 +105,15 @@ ## Evidence(仓库路径) - Build flags:`Podfile` -- Pod metadata/toolchain:`RDReaderView.podspec` -- Archive extraction:`Sources/RDReaderView/EPUBCore/RDEPUBParser+Archive.swift` -- Resource path validation(post-extraction):`Sources/RDReaderView/EPUBCore/RDEPUBParser+Resources.swift` -- Scheme handler reads full file bytes:`Sources/RDReaderView/EPUBCore/RDEPUBResourceURLSchemeHandler.swift` -- WebView bridge + message handling:`Sources/RDReaderView/EPUBCore/RDEPUBWebView+JavaScriptBridge.swift` -- External link opening:`Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift` -- UserDefaults persistence implementation:`Sources/RDReaderView/EPUBUI/RDEPUBReaderPersistence.swift` -- Hidden paginator web view:`Sources/RDReaderView/EPUBCore/RDEPUBPaginator.swift` -- Debug logging:`Sources/RDReaderView/EPUBCore/RDEPUBWebViewDebug.swift` +- Pod metadata/toolchain:`RDEpubReaderView.podspec` +- Archive extraction:`Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+Archive.swift` +- Resource path validation(post-extraction):`Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+Resources.swift` +- Scheme handler reads full file bytes:`Sources/RDEpubReaderView/EPUBCore/RDEPUBResourceURLSchemeHandler.swift` +- WebView bridge + message handling:`Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+JavaScriptBridge.swift` +- External link opening:`Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController.swift` +- UserDefaults persistence implementation:`Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderPersistence.swift` +- Hidden paginator web view:`Sources/RDEpubReaderView/EPUBCore/RDEPUBPaginator.swift` +- Debug logging:`Sources/RDEpubReaderView/EPUBCore/RDEPUBWebViewDebug.swift` - Vendored dependencies:`Pods/`、`ReadViewDemo/Pods/` --- diff --git a/Doc/CONVENTIONS.md b/Doc/CONVENTIONS.md index 924336e..df801b0 100644 --- a/Doc/CONVENTIONS.md +++ b/Doc/CONVENTIONS.md @@ -6,43 +6,43 @@ ## 语言与工程约束 -- **主要语言**:Swift(Podspec 声明 `s.swift_versions = ["5.10"]`,见 `RDReaderView.podspec`) -- **最低系统版本**:Podspec `iOS 15.0`(`RDReaderView.podspec`),示例工程 Podfile/构建设置里常见为 `iOS 15.6`(`Podfile`) +- **主要语言**:Swift(Podspec 声明 `s.swift_versions = ["5.10"]`,见 `RDEpubReaderView.podspec`) +- **最低系统版本**:Podspec `iOS 15.0`(`RDEpubReaderView.podspec`),示例工程 Podfile/构建设置里常见为 `iOS 15.6`(`Podfile`) - **依赖管理**:CocoaPods(`Podfile`、`ReadViewDemo/Podfile`、`Podfile.lock`) ## 命名约定 **文件/类型命名(Swift):** -- 以类型名为文件名的单文件组织较常见:`Sources/RDReaderView/EPUBCore/RDEPUBParser.swift` +- 以类型名为文件名的单文件组织较常见:`Sources/RDEpubReaderView/EPUBCore/RDEPUBParser.swift` - 大量使用前缀区分模块域: - - `RD...`:阅读器 UI/控制器相关(如 `Sources/RDReaderView/RDReaderView.swift`、`Sources/RDReaderView/RDURLReaderController.swift`) - - `RDEPUB...`:EPUB Core/UI/渲染相关(如 `Sources/RDReaderView/EPUBCore/RDEPUBModels.swift`) -- Extension 文件使用 `+` 命名:`Sources/RDReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift` + - `RD...`:阅读器 UI/控制器相关(如 `Sources/RDEpubReaderView/RDEpubReaderView.swift`、`Sources/RDEpubReaderView/RDEpubURLReaderController.swift`) + - `RDEPUB...`:EPUB Core/UI/渲染相关(如 `Sources/RDEpubReaderView/EPUBCore/RDEPUBModels.swift`) +- Extension 文件使用 `+` 命名:`Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift` **变量/函数命名:** -- 基本遵循 Swift lowerCamelCase:`parse(epubURL:)`(`Sources/RDReaderView/EPUBCore/RDEPUBParser.swift`) -- 常量多用 `static let`:`kRDEPUBHighlightAttributeName`(`Sources/RDReaderView/EPUBCore/Models/RDEPUBAnnotationModels.swift`) +- 基本遵循 Swift lowerCamelCase:`parse(epubURL:)`(`Sources/RDEpubReaderView/EPUBCore/RDEPUBParser.swift`) +- 常量多用 `static let`:`kRDEPUBHighlightAttributeName`(`Sources/RDEpubReaderView/EPUBCore/Models/RDEPUBAnnotationModels.swift`) ## 代码风格与排版(从现有代码归纳) **缩进与换行:** -- 多数文件使用 4 空格缩进(示例:`ReadViewDemo/ReadViewDemo/ViewController.swift`、`Sources/RDReaderView/EPUBCore/RDEPUBModels.swift`) +- 多数文件使用 4 空格缩进(示例:`ReadViewDemo/ReadViewDemo/ViewController.swift`、`Sources/RDEpubReaderView/EPUBCore/RDEPUBModels.swift`) - 历史代码中更常见”强制换行/多行括号”风格 **空行与分组:** - UI 相关文件常用空行分隔属性/初始化/布局段落 -- `// MARK:` 用于分区组织(示例:`Sources/RDReaderView/RDReaderGestureController.swift`、`Sources/RDReaderView/EPUBTextRendering/RDPlainTextBookBuilder.swift`) +- `// MARK:` 用于分区组织(示例:`Sources/RDEpubReaderView/RDEpubReaderGestureController.swift`、`Sources/RDEpubReaderView/EPUBTextRendering/RDEpubPlainTextBookBuilder.swift`) **类型组织:** - 偏好用 `extension` 拆分职责/协议实现(示例:`ReadViewDemo/ReadViewDemo/ViewController.swift` 的 `UITableViewDataSource/Delegate`) -- API 暴露处使用 `public`、`public final class`、`public enum/struct`(示例:`Sources/RDReaderView/EPUBCore/RDEPUBModels.swift`) -- “对外只读、内部可写”常用 `public internal(set)`(示例:`Sources/RDReaderView/EPUBCore/RDEPUBParser.swift`) +- API 暴露处使用 `public`、`public final class`、`public enum/struct`(示例:`Sources/RDEpubReaderView/EPUBCore/RDEPUBModels.swift`) +- “对外只读、内部可写”常用 `public internal(set)`(示例:`Sources/RDEpubReaderView/EPUBCore/RDEPUBParser.swift`) ## 导入与依赖使用 **import:** - UIKit/UI 文件:`import UIKit`(大量文件) -- Core/模型文件:`import Foundation`(如 `Sources/RDReaderView/EPUBCore/RDEPUBModels.swift`) +- Core/模型文件:`import Foundation`(如 `Sources/RDEpubReaderView/EPUBCore/RDEPUBModels.swift`) - 三方依赖按需引入: - `SnapKit`:布局 - `SSAlertSwift`:弹窗/提示 @@ -52,15 +52,15 @@ ## 错误处理与日志 -- Core 解析层倾向用 `throws` + 自定义 `Error`(示例:`Sources/RDReaderView/EPUBCore/RDEPUBParser.swift`、`Sources/RDReaderView/EPUBCore/RDEPUBModels.swift` 的 `RDEPUBParserError`) +- Core 解析层倾向用 `throws` + 自定义 `Error`(示例:`Sources/RDEpubReaderView/EPUBCore/RDEPUBParser.swift`、`Sources/RDEpubReaderView/EPUBCore/RDEPUBModels.swift` 的 `RDEPUBParserError`) - UI/控制器层常见 `guard` 早返回(示例:`ReadViewDemo/ReadViewDemo/ViewController.swift`) - 未检测到统一日志框架(未发现专用 logging package/config);出现时以系统 API/局部输出为主(需按具体文件核对)。 ## 注释与文档 - **项目规则(强约束)**:代码标识符保持英文,但**代码注释/文档/提交信息使用中文**(见 `CONTEXT.md`)。 -- 历史文件常带 Xcode 头部注释块(示例:`Sources/RDReaderView/ReaderView/RDReaderView.swift`)。 -- 公共 API 处存在少量三斜线文档注释(示例:`Sources/RDReaderView/RDReaderView.swift` 的中文说明)。 +- 历史文件常带 Xcode 头部注释块(示例:`Sources/RDEpubReaderView/ReaderView/RDEpubReaderView.swift`)。 +- 公共 API 处存在少量三斜线文档注释(示例:`Sources/RDEpubReaderView/RDEpubReaderView.swift` 的中文说明)。 ## Lint / Formatter / 静态检查 @@ -77,9 +77,9 @@ - `CONTEXT.md` - `Podfile` -- `RDReaderView.podspec` +- `RDEpubReaderView.podspec` - `ReadViewDemo/ReadViewDemo/ViewController.swift` -- `Sources/RDReaderView/ReaderView/RDReaderView.swift` -- `Sources/RDReaderView/EPUBCore/RDEPUBParser.swift` -- `Sources/RDReaderView/EPUBCore/RDEPUBModels.swift` -- `Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift` +- `Sources/RDEpubReaderView/ReaderView/RDEpubReaderView.swift` +- `Sources/RDEpubReaderView/EPUBCore/RDEPUBParser.swift` +- `Sources/RDEpubReaderView/EPUBCore/RDEPUBModels.swift` +- `Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController.swift` diff --git a/Doc/EPUBTextRendering_CODE_REFERENCE.md b/Doc/EPUBTextRendering_CODE_REFERENCE.md index 45ea516..e17d24e 100644 --- a/Doc/EPUBTextRendering_CODE_REFERENCE.md +++ b/Doc/EPUBTextRendering_CODE_REFERENCE.md @@ -471,7 +471,7 @@ final class RDEPUBTextSearchEngine: RDEPUBSearchEngine { ## 9. 纯文本构建器 -**文件:** `RDPlainTextBookBuilder.swift` +**文件:** `RDEpubPlainTextBookBuilder.swift` 从纯文本(.txt)文件构建 `RDEPUBTextBook`,用于支持 TXT 格式阅读。 diff --git a/Doc/EPUBUI_CODE_REFERENCE.md b/Doc/EPUBUI_CODE_REFERENCE.md index c337c5e..7945447 100644 --- a/Doc/EPUBUI_CODE_REFERENCE.md +++ b/Doc/EPUBUI_CODE_REFERENCE.md @@ -48,7 +48,7 @@ public final class RDEPUBReaderController: UIViewController { let epubURL: URL // EPUB 文件路径 let persistence: RDEPUBReaderPersistence? // 持久化代理 let dependencies: RDEPUBReaderDependencies // 依赖注入 - let readerView = RDReaderView() // 翻页容器 + let readerView = RDEpubReaderView() // 翻页容器 } ``` @@ -57,7 +57,7 @@ public final class RDEPUBReaderController: UIViewController { | 文件 | 职责 | |------|------| | `RDEPUBReaderController+PublicAPI.swift` | 公开 API(跳转、搜索、标注、书签) | -| `RDEPUBReaderController+DataSource.swift` | `RDReaderPageProvider` 实现 | +| `RDEPUBReaderController+DataSource.swift` | `RDEpubReaderPageProvider` 实现 | | `RDEPUBReaderController+ContentDelegates.swift` | WebView/TextContentView 代理路由 | | `RDEPUBReaderController+LocationResolution.swift` | 页码/位置解析、阅读状态同步 | | `RDEPUBReaderController+ExternalLinks.swift` | 外部链接处理(白名单、确认弹窗) | @@ -120,7 +120,7 @@ public protocol RDEPUBReaderPersistence: AnyObject { ```swift final class RDEPUBReaderContext { weak var controller: RDEPUBReaderController? - weak var readerView: RDReaderView? + weak var readerView: RDEpubReaderView? var dependencies: RDEPUBReaderDependencies var runtime: RDEPUBReaderRuntime? @@ -155,7 +155,7 @@ public struct RDEPUBReaderDependencies { public var makeParser: () -> RDEPUBParser public var makePaginator: () -> RDEPUBPaginator public var makeTextBookBuilder: (RDEPUBTextRenderer, RDEPUBTextBookCache?, RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder - public var makePlainTextBookBuilder: (RDEPUBTextRenderer, RDEPUBTextLayoutConfig) -> RDPlainTextBookBuilder + public var makePlainTextBookBuilder: (RDEPUBTextRenderer, RDEPUBTextLayoutConfig) -> RDEpubPlainTextBookBuilder public var makeTextRenderer: (RDEPUBTextRenderingEngine) -> RDEPUBTextRenderer public static var live: RDEPUBReaderDependencies // 默认实现 @@ -659,9 +659,9 @@ struct RDEPUBReaderUIState { 页图协调器(按需分页与全量分页的结果合并)。 -### 10.6 RDURLReaderController +### 10.6 RDEpubURLReaderController -**文件:** `RDURLReaderController.swift` +**文件:** `RDEpubURLReaderController.swift` URL 阅读器控制器(用于打开单个 URL)。 @@ -715,12 +715,12 @@ RDEPUBReaderRuntime.applyParsedPublication() │ ├── RDEPUBBookPageMap 生成 │ - └── RDReaderView.transitionToPage() → 显示页面 + └── RDEpubReaderView.transitionToPage() → 显示页面 用户翻页 │ ▼ -RDReaderView.currentPage 变化 +RDEpubReaderView.currentPage 变化 │ ├── RDEPUBReaderLocationCoordinator.recordPageChangeIfNeeded() │ └── persistence.saveLocation() diff --git a/Doc/LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md b/Doc/LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md index b534515..31d529e 100644 --- a/Doc/LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md +++ b/Doc/LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md @@ -14,9 +14,9 @@ 生成点: -- `Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLoader.swift:494`(`buildPagesFromRanges`) -- `Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookBuilder.swift:294` -- `Sources/RDReaderView/EPUBTextRendering/RDPlainTextBookBuilder.swift:78` +- `Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLoader.swift:494`(`buildPagesFromRanges`) +- `Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookBuilder.swift:294` +- `Sources/RDEpubReaderView/EPUBTextRendering/RDEpubPlainTextBookBuilder.swift:78` 现存全部消费点(已盘点确认,仅 3 处): @@ -27,7 +27,7 @@ **任务**: - [x] 将 `RDEPUBTextPage.content` 改为按需构造:已改为基于 `chapterContent` + `contentRange` 的计算属性(带范围钳制),公开读取 API 不变 -- [x] 三个构造点(`RDEPUBChapterLoader` / `RDEPUBTextBookBuilder` / `RDPlainTextBookBuilder`)不再生成每页子串;消费点经计算属性透明按需构造 +- [x] 三个构造点(`RDEPUBChapterLoader` / `RDEPUBTextBookBuilder` / `RDEpubPlainTextBookBuilder`)不再生成每页子串;消费点经计算属性透明按需构造 - [x] `Equatable` 确认:`content` 为派生值,由 `contentRange` + `chapterContent` 判等覆盖,语义不变 **API 兼容性注意**: @@ -47,7 +47,7 @@ ### P0-3 保持现有缓存限制,防止错误复用 - [x] `shouldAvoidReaderPageCaching`(`RDEPUBTextContentView.swift:319`)保持现状,已补注释说明放开的前置条件是 P1-1 落地 -- [x] 本阶段未改 `RDReaderPreloadController` 行为 +- [x] 本阶段未改 `RDEpubReaderPreloadController` 行为 --- @@ -101,7 +101,7 @@ 前置:P1-1~P1-3 全部落地后,页面视图不再持有整章副本。 -- [ ] 调整 `shouldAvoidReaderPageCaching` 判定,允许轻量化后的文本页进入 `RDReaderPreloadController` 缓存 +- [ ] 调整 `shouldAvoidReaderPageCaching` 判定,允许轻量化后的文本页进入 `RDEpubReaderPreloadController` 缓存 - [ ] 对比放开前后的翻页流畅度与峰值内存,数据不佳则回退此项 --- diff --git a/Doc/ReaderView_CODE_REFERENCE.md b/Doc/ReaderView_CODE_REFERENCE.md index d8f8065..40e9cb7 100644 --- a/Doc/ReaderView_CODE_REFERENCE.md +++ b/Doc/ReaderView_CODE_REFERENCE.md @@ -6,54 +6,54 @@ ## 1. 模块概述 -`ReaderView` 是 ReadViewSDK 的**通用翻页容器层**,位于 EPUBUI 层之下。它提供与 EPUB 内容无关的页面展示、翻页动画、手势识别、页面预加载和双页布局能力。上层通过 `RDReaderPageProvider` 协议提供页面内容视图,ReaderView 负责容器管理和翻页调度。 +`ReaderView` 是 ReadViewSDK 的**通用翻页容器层**,位于 EPUBUI 层之下。它提供与 EPUB 内容无关的页面展示、翻页动画、手势识别、页面预加载和双页布局能力。上层通过 `RDEpubReaderPageProvider` 协议提供页面内容视图,ReaderView 负责容器管理和翻页调度。 **文件清单(14 个 Swift 文件):** | 文件 | 核心类型 | 职责 | |------|----------|------| -| `RDReaderView.swift` | `RDReaderView` | 主容器视图,协调所有子组件 | -| `RDReaderViewProtocols.swift` | 协议 + 枚举 | 数据源、代理、导航协议定义 | -| `RDReaderFlowLayout.swift` | `RDReaderFlowLayout` | UICollectionView 自定义布局 | -| `RDReaderGestureController.swift` | `RDReaderGestureController` | 手势控制器(预留) | -| `RDReaderContentCell.swift` | `RDReaderContentCell` | CollectionView 内容 Cell | -| `RDReaderPageChildViewController.swift` | `RDReaderPageChildViewController` | PageCurl 模式子 VC | -| `RDReaderView+PageCurl.swift` | Extension | UIPageViewController 数据源/代理 | -| `RDReaderView+CollectionView.swift` | Extension | UICollectionView 数据源/布局代理 | -| `RDReaderView+ContentAccess.swift` | Extension | 内容视图访问与复用 | -| `RDReaderView+ToolView.swift` | Extension | 工具栏安装与动画 | -| `Paging/RDReaderPagingController.swift` | `RDReaderPagingController` | 翻页状态机 | -| `Paging/RDReaderPreloadController.swift` | `RDReaderPreloadController` | 页面预加载与缓存 | -| `Paging/RDReaderSpreadResolver.swift` | `RDReaderSpreadResolver` | 双页展开计算 | -| `Paging/RDReaderTapRegionHandler.swift` | `RDReaderTapRegionHandler` | 点击区域判定 | +| `RDEpubReaderView.swift` | `RDEpubReaderView` | 主容器视图,协调所有子组件 | +| `RDEpubReaderViewProtocols.swift` | 协议 + 枚举 | 数据源、代理、导航协议定义 | +| `RDEpubReaderFlowLayout.swift` | `RDEpubReaderFlowLayout` | UICollectionView 自定义布局 | +| `RDEpubReaderGestureController.swift` | `RDEpubReaderGestureController` | 手势控制器(预留) | +| `RDEpubReaderContentCell.swift` | `RDEpubReaderContentCell` | CollectionView 内容 Cell | +| `RDEpubReaderPageChildViewController.swift` | `RDEpubReaderPageChildViewController` | PageCurl 模式子 VC | +| `RDEpubReaderView+PageCurl.swift` | Extension | UIPageViewController 数据源/代理 | +| `RDEpubReaderView+CollectionView.swift` | Extension | UICollectionView 数据源/布局代理 | +| `RDEpubReaderView+ContentAccess.swift` | Extension | 内容视图访问与复用 | +| `RDEpubReaderView+ToolView.swift` | Extension | 工具栏安装与动画 | +| `Paging/RDEpubReaderPagingController.swift` | `RDEpubReaderPagingController` | 翻页状态机 | +| `Paging/RDEpubReaderPreloadController.swift` | `RDEpubReaderPreloadController` | 页面预加载与缓存 | +| `Paging/RDEpubReaderSpreadResolver.swift` | `RDEpubReaderSpreadResolver` | 双页展开计算 | +| `Paging/RDEpubReaderTapRegionHandler.swift` | `RDEpubReaderTapRegionHandler` | 点击区域判定 | --- -## 2. 协议定义(RDReaderViewProtocols.swift) +## 2. 协议定义(RDEpubReaderViewProtocols.swift) -### 2.1 RDReaderDataSource(旧版数据源,已废弃) +### 2.1 RDEpubReaderDataSource(旧版数据源,已废弃) ```swift -@objc public protocol RDReaderDataSource: NSObjectProtocol { - func pageCountOfReaderView(readerView: RDReaderView) -> Int - func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView - func pageIdentifier(readerView: RDReaderView, pageNum: Int) -> String? - @objc optional func topToolView(readerView: RDReaderView) -> UIView? - @objc optional func bottomToolView(readerView: RDReaderView) -> UIView? +@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? } ``` -> 向后兼容保留,新代码应使用 `RDReaderPageProvider`。 +> 向后兼容保留,新代码应使用 `RDEpubReaderPageProvider`。 -### 2.2 RDReaderPageProvider(推荐数据源) +### 2.2 RDEpubReaderPageProvider(推荐数据源) ```swift -@objc public protocol RDReaderPageProvider: NSObjectProtocol { - func numberOfPages(in readerView: RDReaderView) -> Int - func readerView(_ readerView: RDReaderView, viewForPageAt index: Int, reusableView: UIView?) -> UIView - @objc optional func pageIdentifier(in readerView: RDReaderView, index: Int) -> String? - @objc optional func readerViewTopChrome(_ readerView: RDReaderView) -> UIView? - @objc optional func readerViewBottomChrome(_ readerView: RDReaderView) -> 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? + @objc optional func readerViewTopChrome(_ readerView: RDEpubReaderView) -> UIView? + @objc optional func readerViewBottomChrome(_ readerView: RDEpubReaderView) -> UIView? } ``` @@ -65,31 +65,31 @@ | `readerViewTopChrome(_:)` | 返回顶部工具栏视图 | | `readerViewBottomChrome(_:)` | 返回底部工具栏视图 | -### 2.3 RDReaderDelegate +### 2.3 RDEpubReaderDelegate ```swift -@objc public protocol RDReaderDelegate: NSObjectProtocol { - func pageNum(readerView: RDReaderView, pageNum: Int) - @objc optional func readerViewOrientationWillChange(readerView: RDReaderView, isLandscape: Bool) +@objc public protocol RDEpubReaderDelegate: NSObjectProtocol { + func pageNum(readerView: RDEpubReaderView, pageNum: Int) + @objc optional func readerViewOrientationWillChange(readerView: RDEpubReaderView, isLandscape: Bool) } ``` -### 2.4 RDReaderPageNavigating +### 2.4 RDEpubReaderPageNavigating ```swift -public protocol RDReaderPageNavigating: AnyObject { +public protocol RDEpubReaderPageNavigating: AnyObject { var currentPage: Int { get } func reloadPages() func transition(to page: Int, animated: Bool) } ``` -`RDReaderView` 遵循此协议,提供统一的页面导航接口。 +`RDEpubReaderView` 遵循此协议,提供统一的页面导航接口。 ### 2.5 枚举类型 ```swift -extension RDReaderView { +extension RDEpubReaderView { public enum DisplayType { case pageCurl // 仿真翻页(UIPageViewController) case horizontalScroll // 水平滑动(UICollectionView) @@ -105,13 +105,13 @@ extension RDReaderView { --- -## 3. 核心类:RDReaderView +## 3. 核心类:RDEpubReaderView -**文件:** `RDReaderView.swift` +**文件:** `RDEpubReaderView.swift` -`RDReaderView` 是一个 `UIView` 子类,作为翻页容器的主入口。内部管理两种翻页引擎: +`RDEpubReaderView` 是一个 `UIView` 子类,作为翻页容器的主入口。内部管理两种翻页引擎: - **PageCurl 模式**:使用 `UIPageViewController` 实现仿真翻页 -- **Scroll 模式**:使用 `UICollectionView` + 自定义 `RDReaderFlowLayout` 实现滑动翻页 +- **Scroll 模式**:使用 `UICollectionView` + 自定义 `RDEpubReaderFlowLayout` 实现滑动翻页 ### 3.1 关键属性 @@ -124,16 +124,16 @@ extension RDReaderView { | `coverPageIndex` | `Int?` | 封面页索引(独占一屏) | | `pagesPerScreen` | `Int` | 每屏页数(横屏双页时为 2) | | `preloadRadius` | `Int` | 预加载半径(默认 1) | -| `dataSource` | `RDReaderDataSource?` | 旧版数据源 | -| `pageProvider` | `RDReaderPageProvider?` | 推荐数据源 | -| `delegate` | `RDReaderDelegate?` | 事件代理 | +| `dataSource` | `RDEpubReaderDataSource?` | 旧版数据源 | +| `pageProvider` | `RDEpubReaderPageProvider?` | 推荐数据源 | +| `delegate` | `RDEpubReaderDelegate?` | 事件代理 | | `toolViewAnimationDuration` | `TimeInterval` | 工具栏动画时长(0.3s) | ### 3.2 关键方法 ```swift /// 切换显示模式(pageCurl / horizontalScroll / verticalScroll) -public func switchReaderDisplayType(_ displayType: RDReaderView.DisplayType) +public func switchReaderDisplayType(_ displayType: RDEpubReaderView.DisplayType) /// 跳转到指定页 public func transitionToPage(pageNum: Int, animated: Bool = false) @@ -154,22 +154,22 @@ public func isFullScreenPage(_ pageNum: Int) -> Bool |------|------|------| | `pageViewController` | `UIPageViewController` | PageCurl 翻页引擎 | | `collectionView` | `UICollectionView` | Scroll 翻页引擎 | -| `layout` | `RDReaderFlowLayout` | CollectionView 自定义布局 | -| `spreadResolver` | `RDReaderSpreadResolver` | 双页配对计算 | -| `tapRegionHandler` | `RDReaderTapRegionHandler` | 点击区域判定 | -| `preloadController` | `RDReaderPreloadController` | 页面预加载与缓存 | -| `pagingController` | `RDReaderPagingController` | 翻页状态管理 | +| `layout` | `RDEpubReaderFlowLayout` | CollectionView 自定义布局 | +| `spreadResolver` | `RDEpubReaderSpreadResolver` | 双页配对计算 | +| `tapRegionHandler` | `RDEpubReaderTapRegionHandler` | 点击区域判定 | +| `preloadController` | `RDEpubReaderPreloadController` | 页面预加载与缓存 | +| `pagingController` | `RDEpubReaderPagingController` | 翻页状态管理 | --- -## 4. 翻页状态机:RDReaderPagingController +## 4. 翻页状态机:RDEpubReaderPagingController -**文件:** `Paging/RDReaderPagingController.swift` +**文件:** `Paging/RDEpubReaderPagingController.swift` 管理 PageCurl 模式下的翻页请求队列,防止动画冲突。 ```swift -struct RDReaderPagingController { +struct RDEpubReaderPagingController { struct PageTransitionRequest: Equatable { let pageNum: Int let animated: Bool @@ -180,7 +180,7 @@ struct RDReaderPagingController { var didBuildUI: Bool // UI 是否已构建 /// 判断是否应排队请求(PageCurl 模式下动画中返回 true) - mutating func shouldQueuePageTransition(_ request: PageTransitionRequest, currentDisplayType: RDReaderView.DisplayType) -> Bool + mutating func shouldQueuePageTransition(_ request: PageTransitionRequest, currentDisplayType: RDEpubReaderView.DisplayType) -> Bool /// 完成翻页动画,返回待处理的请求 mutating func finishPageCurlTransition() -> PageTransitionRequest? @@ -192,9 +192,9 @@ struct RDReaderPagingController { --- -## 5. 页面预加载:RDReaderPreloadController +## 5. 页面预加载:RDEpubReaderPreloadController -**文件:** `Paging/RDReaderPreloadController.swift` +**文件:** `Paging/RDEpubReaderPreloadController.swift` 负责在当前页周围预渲染页面视图,减少翻页时的白屏时间。 @@ -224,27 +224,27 @@ func invalidate(environment: Environment) ```swift struct Environment { - let displayType: RDReaderView.DisplayType + let displayType: RDEpubReaderView.DisplayType let isLandscape: Bool let pagesPerScreen: Int let boundsSize: CGSize let landscapeDualPageEnabled: Bool let coverPageIndex: Int? let totalPages: Int - let spreadResolver: RDReaderSpreadResolver + let spreadResolver: RDEpubReaderSpreadResolver } ``` --- -## 6. 双页展开计算:RDReaderSpreadResolver +## 6. 双页展开计算:RDEpubReaderSpreadResolver -**文件:** `Paging/RDReaderSpreadResolver.swift` +**文件:** `Paging/RDEpubReaderSpreadResolver.swift` 纯函数式结构体,负责双页模式下的页面配对和导航计算。 ```swift -struct RDReaderSpreadResolver { +struct RDEpubReaderSpreadResolver { /// 判断是否为全屏页(封面页独占一屏) func isFullScreenPage(_ pageNum: Int, landscapeDualPageEnabled: Bool, isLandscape: Bool, coverPageIndex: Int?) -> Bool @@ -265,15 +265,15 @@ struct RDReaderSpreadResolver { --- -## 7. 点击区域判定:RDReaderTapRegionHandler +## 7. 点击区域判定:RDEpubReaderTapRegionHandler -**文件:** `Paging/RDReaderTapRegionHandler.swift` +**文件:** `Paging/RDEpubReaderTapRegionHandler.swift` 将屏幕三等分,判定点击属于左/中/右区域。 ```swift -struct RDReaderTapRegionHandler { - func resolveTapEvent(point: CGPoint, viewFrame: CGRect, isToolViewVisible: Bool) -> RDReaderView.TapEvent +struct RDEpubReaderTapRegionHandler { + func resolveTapEvent(point: CGPoint, viewFrame: CGRect, isToolViewVisible: Bool) -> RDEpubReaderView.TapEvent } ``` @@ -284,9 +284,9 @@ struct RDReaderTapRegionHandler { --- -## 8. 流式布局:RDReaderFlowLayout +## 8. 流式布局:RDEpubReaderFlowLayout -**文件:** `RDReaderFlowLayout.swift` +**文件:** `RDEpubReaderFlowLayout.swift` `UICollectionViewFlowLayout` 子类,支持水平滚动和垂直滚动两种模式。 @@ -294,7 +294,7 @@ struct RDReaderTapRegionHandler { | 属性 | 类型 | 说明 | |------|------|------| -| `displayType` | `RDReaderView.DisplayType` | 布局模式 | +| `displayType` | `RDEpubReaderView.DisplayType` | 布局模式 | | `isLandscapeDualPage` | `Bool` | 是否横屏双页 | | `coverPageIndex` | `Int?` | 封面页索引 | | `pagesPerScreen` | `Int` | 每屏页数 | @@ -302,12 +302,12 @@ struct RDReaderTapRegionHandler { ### 8.2 协议 ```swift -public protocol RDReaderFlowLayoutDataSoure: NSObjectProtocol { - func heigtOfVerticalScrollPage(flowLayout: RDReaderFlowLayout, pageIndex: Int) -> CGFloat? +public protocol RDEpubReaderFlowLayoutDataSoure: NSObjectProtocol { + func heigtOfVerticalScrollPage(flowLayout: RDEpubReaderFlowLayout, pageIndex: Int) -> CGFloat? } -@objc public protocol RDReaderFlowLayoutDelegate: NSObjectProtocol { - func pageNum(flowLayout: RDReaderFlowLayout, pageIndex: Int) +@objc public protocol RDEpubReaderFlowLayoutDelegate: NSObjectProtocol { + func pageNum(flowLayout: RDEpubReaderFlowLayout, pageIndex: Int) } ``` @@ -324,28 +324,28 @@ func currentContentOffset(count: Int) -> CGPoint --- -## 9. 内容 Cell:RDReaderContentCell +## 9. 内容 Cell:RDEpubReaderContentCell -**文件:** `RDReaderContentCell.swift` +**文件:** `RDEpubReaderContentCell.swift` `UICollectionViewCell` 子类,用于 Scroll 模式下承载页面内容视图。 ```swift -class RDReaderContentCell: UICollectionViewCell { +class RDEpubReaderContentCell: UICollectionViewCell { var containerView: UIView? // 设置时自动添加到 contentView,移除旧视图 } ``` --- -## 10. PageCurl 子控制器:RDReaderPageChildViewController +## 10. PageCurl 子控制器:RDEpubReaderPageChildViewController -**文件:** `RDReaderPageChildViewController.swift` +**文件:** `RDEpubReaderPageChildViewController.swift` `UIViewController` 子类,作为 `UIPageViewController` 的页面 VC。 ```swift -class RDReaderPageChildViewController: UIViewController { +class RDEpubReaderPageChildViewController: UIViewController { var contentView: UIView? // 内容视图,设置时自动安装到容器 var pageNum: Int // 对应页码 @@ -357,7 +357,7 @@ class RDReaderPageChildViewController: UIViewController { ## 11. Extension 汇总 -### 11.1 RDReaderView+PageCurl +### 11.1 RDEpubReaderView+PageCurl 实现 `UIPageViewControllerDataSource` 和 `UIPageViewControllerDelegate`: @@ -367,18 +367,18 @@ class RDReaderPageChildViewController: UIViewController { - `pageViewController(_:willTransitionTo:)` — 即将翻页时预加载 **特殊页码:** -- `RDReaderView.blankPageNum`(`Int.max`)— 双页模式下的空白页 -- `RDReaderView.blankEndPageNum`(`Int.max - 1`)— 末尾空白页 +- `RDEpubReaderView.blankPageNum`(`Int.max`)— 双页模式下的空白页 +- `RDEpubReaderView.blankEndPageNum`(`Int.max - 1`)— 末尾空白页 -### 11.2 RDReaderView+CollectionView +### 11.2 RDEpubReaderView+CollectionView -实现 `UICollectionViewDataSource`、`RDReaderFlowLayoutDelegate`、`RDReaderFlowLayoutDataSoure`: +实现 `UICollectionViewDataSource`、`RDEpubReaderFlowLayoutDelegate`、`RDEpubReaderFlowLayoutDataSoure`: - `collectionView(_:cellForItemAt:)` — 复用预加载视图或创建新 Cell - `collectionView(_:numberOfItemsInSection:)` — 返回总页数 - `pageNum(flowLayout:pageIndex:)` — 滚动时更新当前页码 -### 11.3 RDReaderView+ContentAccess +### 11.3 RDEpubReaderView+ContentAccess 提供内容视图的注册、复用和查询: @@ -396,7 +396,7 @@ public func pageContentView(pageNum: Int) -> UIView? public func resolvedSinglePageSize(pageNum: Int? = nil) -> CGSize ``` -### 11.4 RDReaderView+ToolView +### 11.4 RDEpubReaderView+ToolView 管理顶部/底部工具栏的安装、显示/隐藏动画: @@ -420,11 +420,11 @@ func updateToolViewHeightConstraintsIfNeeded() | 模式 | 应用 | |------|------| | **策略模式** | `DisplayType` 切换 PageCurl / Scroll 两种翻页策略 | -| **适配器模式** | `RDReaderLegacyDataSourceAdapter` 将旧 `RDReaderDataSource` 适配为 `RDReaderPageProvider` | -| **命令队列** | `RDReaderPagingController` 管理翻页请求队列 | -| **缓存签名** | `RDReaderPreloadController.CacheSignature` 检测环境变化自动失效 | +| **适配器模式** | `RDEpubReaderLegacyDataSourceAdapter` 将旧 `RDEpubReaderDataSource` 适配为 `RDEpubReaderPageProvider` | +| **命令队列** | `RDEpubReaderPagingController` 管理翻页请求队列 | +| **缓存签名** | `RDEpubReaderPreloadController.CacheSignature` 检测环境变化自动失效 | | **关注点分离** | Extension 将不同功能拆分到独立文件 | -| **纯函数** | `RDReaderSpreadResolver` 和 `RDReaderTapRegionHandler` 无状态计算 | +| **纯函数** | `RDEpubReaderSpreadResolver` 和 `RDEpubReaderTapRegionHandler` 无状态计算 | --- @@ -434,7 +434,7 @@ func updateToolViewHeightConstraintsIfNeeded() 用户点击屏幕 │ ▼ -RDReaderTapRegionHandler.resolveTapEvent() +RDEpubReaderTapRegionHandler.resolveTapEvent() │ ├── .left → goPreviousPage() ──→ spreadResolver.nextPage(forward: false) ├── .right → goNextPage() ──→ spreadResolver.nextPage(forward: true) diff --git a/Doc/TESTING.md b/Doc/TESTING.md index 3629121..95431ad 100644 --- a/Doc/TESTING.md +++ b/Doc/TESTING.md @@ -116,7 +116,7 @@ xcodebuild test \ 建议同时记录以下观测项: - `prepareOnDemandChapter` 主线程 wall clock -- `RDReaderPreloadController` 预加载命中率 +- `RDEpubReaderPreloadController` 预加载命中率 - `bookPageMap` partial extension / full replacement 次数 - 页面静态底图缓存命中率 - CFI 延迟构建完成次数与耗时 diff --git a/Doc/TYPESetter_PIPELINE.md b/Doc/TYPESetter_PIPELINE.md index 831358b..4f6f958 100644 --- a/Doc/TYPESetter_PIPELINE.md +++ b/Doc/TYPESetter_PIPELINE.md @@ -16,7 +16,7 @@ **输出**:`RDEPUBTypesettingOutput`(渲染请求、诊断信息、兼容性报告) -**关键文件**:`Sources/RDReaderView/EPUBTextRendering/Typesetter/` +**关键文件**:`Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/` --- diff --git a/Doc/UML_CLASS_DIAGRAMS.md b/Doc/UML_CLASS_DIAGRAMS.md index b26113c..b208f73 100644 --- a/Doc/UML_CLASS_DIAGRAMS.md +++ b/Doc/UML_CLASS_DIAGRAMS.md @@ -31,12 +31,12 @@ graph TB IndexTable[RDEPUBTextIndexTable] end - subgraph RDReaderView - ReaderView[RDReaderView] - FlowLayout[RDReaderFlowLayout] - Preload[RDReaderPreloadController] - Spread[RDReaderSpreadResolver] - TapRegion[RDReaderTapRegionHandler] + subgraph RDEpubReaderView + ReaderView[RDEpubReaderView] + FlowLayout[RDEpubReaderFlowLayout] + Preload[RDEpubReaderPreloadController] + Spread[RDEpubReaderSpreadResolver] + TapRegion[RDEpubReaderTapRegionHandler] end subgraph EPUBUI @@ -297,11 +297,11 @@ classDiagram ```mermaid classDiagram - class RDReaderView { + class RDEpubReaderView { +currentPage: Int +currentDisplayType: DisplayType - +pageProvider: RDReaderPageProvider? - +delegate: RDReaderDelegate? + +pageProvider: RDEpubReaderPageProvider? + +delegate: RDEpubReaderDelegate? +landscapeDualPageEnabled: Bool +pageDirection: PageDirection +coverPageIndex: Int? @@ -318,14 +318,14 @@ classDiagram verticalScroll } - class RDReaderPageProvider { + class RDEpubReaderPageProvider { <> +numberOfPages(in:) Int +readerView(_:viewForPageAt:reusableView:) UIView +pageIdentifier(in:index:) String? } - class RDReaderFlowLayout { + class RDEpubReaderFlowLayout { +displayType: DisplayType +isLandscapeDualPage: Bool +coverPageIndex: Int? @@ -333,7 +333,7 @@ classDiagram +currentPage: Int } - class RDReaderPreloadController { + class RDEpubReaderPreloadController { +radius: Int +pageViewForDisplay(pageNum:environment:contentViewProvider:) UIView? +takePreloadedView(for:) UIView? @@ -341,13 +341,13 @@ classDiagram +invalidate(environment:) } - class RDReaderSpreadResolver { + class RDEpubReaderSpreadResolver { +isFullScreenPage(...) Bool +dualPagePair(for:totalPages:coverPageIndex:) (Int, Int?) +nextPage(from:totalPages:pagesPerScreen:coverPageIndex:forward:) Int? } - class RDReaderTapRegionHandler { + class RDEpubReaderTapRegionHandler { +resolveTapEvent(point:viewFrame:isToolViewVisible:) TapEvent } @@ -359,7 +359,7 @@ classDiagram right } - class RDReaderPagingController { + class RDEpubReaderPagingController { +isTransitioning: Bool +didBuildUI: Bool +pendingTransitionRequest: PageTransitionRequest? @@ -367,25 +367,25 @@ classDiagram +finishPageCurlTransition() PageTransitionRequest? } - class RDReaderContentCell { + class RDEpubReaderContentCell { +containerView: UIView? } - class RDReaderPageChildViewController { + class RDEpubReaderPageChildViewController { +contentView: UIView? +pageNum: Int } - RDReaderView --> DisplayType : uses - RDReaderView --> RDReaderPageProvider : delegates - RDReaderView --> RDReaderFlowLayout : owns - RDReaderView --> RDReaderPreloadController : owns - RDReaderView --> RDReaderSpreadResolver : owns - RDReaderView --> RDReaderTapRegionHandler : owns - RDReaderView --> RDReaderPagingController : owns - RDReaderView --> RDReaderContentCell : creates - RDReaderView --> RDReaderPageChildViewController : creates - RDReaderTapRegionHandler --> TapEvent : produces + RDEpubReaderView --> DisplayType : uses + RDEpubReaderView --> RDEpubReaderPageProvider : delegates + RDEpubReaderView --> RDEpubReaderFlowLayout : owns + RDEpubReaderView --> RDEpubReaderPreloadController : owns + RDEpubReaderView --> RDEpubReaderSpreadResolver : owns + RDEpubReaderView --> RDEpubReaderTapRegionHandler : owns + RDEpubReaderView --> RDEpubReaderPagingController : owns + RDEpubReaderView --> RDEpubReaderContentCell : creates + RDEpubReaderView --> RDEpubReaderPageChildViewController : creates + RDEpubReaderTapRegionHandler --> TapEvent : produces ``` --- @@ -403,7 +403,7 @@ classDiagram +highlights: [RDEPUBHighlight] +bookmarks: [RDEPUBBookmark] +tableOfContents: [EPUBTableOfContentsItem] - +readerView: RDReaderView + +readerView: RDEpubReaderView +readerContext: RDEPUBReaderContext +runtime: RDEPUBReaderRuntime? } diff --git a/Doc/index.md b/Doc/index.md index 154bc52..11e30be 100644 --- a/Doc/index.md +++ b/Doc/index.md @@ -47,7 +47,7 @@ ## 项目信息 -- **模块总数:** 4 个(EPUBCore、EPUBTextRendering、RDReaderView、EPUBUI) +- **模块总数:** 4 个(EPUBCore、EPUBTextRendering、RDEpubReaderView、EPUBUI) - **Swift 文件数:** 142 个(SDK Sources) - **测试用例数:** 23 个测试类,约 99 个测试方法(UI 测试) - **最低 iOS 版本:** 15.6 diff --git a/Podfile b/Podfile index c3c3aa2..25027ab 100644 --- a/Podfile +++ b/Podfile @@ -5,7 +5,7 @@ workspace 'ReadViewSDK.xcworkspace' project 'ReadViewSDK.xcodeproj' target 'ReadViewSDK' do - pod 'RDReaderView', :path => '..' + pod 'RDEpubReaderView', :path => '..' end post_install do |installer| diff --git a/RDEpubReaderView.podspec b/RDEpubReaderView.podspec new file mode 100644 index 0000000..347cbc4 --- /dev/null +++ b/RDEpubReaderView.podspec @@ -0,0 +1,19 @@ +Pod::Spec.new do |s| + s.name = "RDEpubReaderView" + s.module_name = "RDEpubReaderView" + s.version = "0.0.1" + s.summary = "A reader view for EPUB and plain-text books" + s.platform = :ios, "15.0" + s.swift_versions = ["5.10"] + s.homepage = "http://192.168.21.200:8418/4v5u09Z5a4Yuc/ReadViewSDK.git" + s.author = { "shenlei" => "shenlei@touchread.com" } + s.source = { :git => "http://192.168.21.200:8418/4v5u09Z5a4Yuc/ReadViewSDK.git", :tag => "#{s.version}" } + s.license = "MIT" + s.source_files = "Sources/RDEpubReaderView/**/*.{swift}" + s.resource_bundles = { + "RDEpubReaderViewAssets" => ["Sources/RDEpubReaderView/EPUBCore/Resources/**/*"] + } + s.dependency "ZIPFoundation", "~> 0.9" + s.dependency "DTCoreText", "~> 1.6" + s.requires_arc = true +end diff --git a/RDReaderView.podspec b/RDReaderView.podspec deleted file mode 100644 index b778e8f..0000000 --- a/RDReaderView.podspec +++ /dev/null @@ -1,21 +0,0 @@ - - -Pod::Spec.new do |s| - s.name = "RDReaderView" - s.version = "0.0.1" - s.summary = "A reader view for novel" - s.platform = :ios, "15.6" - s.swift_versions = ["5.10"] - s.homepage = "https://github.com/namesubai/RDReaderView.git" - s.author = { "subai" => "804663401@qq.com" } - s.source = { :git => "https://github.com/namesubai/RDReaderView.git", :tag => "#{s.version}"} - s.license = "MIT" - s.source_files = 'Sources/RDReaderView/**/*.{swift}' - s.resource_bundles = { - 'RDReaderViewAssets' => ['Sources/RDReaderView/EPUBCore/Resources/**/*'] - } - s.dependency 'ZIPFoundation', '~> 0.9' - s.dependency 'DTCoreText', '~> 1.6' - s.requires_arc = true - -end diff --git a/ReadViewDemo/Podfile b/ReadViewDemo/Podfile index 72ef2c7..1887048 100644 --- a/ReadViewDemo/Podfile +++ b/ReadViewDemo/Podfile @@ -3,7 +3,7 @@ platform :ios, '15.6' target 'ReadViewDemo' do # Comment the next line if you don't want to use dynamic frameworks use_frameworks! - pod 'RDReaderView', :path => '..' + pod 'RDEpubReaderView', :path => '..' end diff --git a/ReadViewDemo/Podfile.lock b/ReadViewDemo/Podfile.lock index 3806923..163d73e 100644 --- a/ReadViewDemo/Podfile.lock +++ b/ReadViewDemo/Podfile.lock @@ -16,13 +16,13 @@ PODS: - DTFoundation/Core - DTFoundation/UIKit (1.7.19): - DTFoundation/Core - - RDReaderView (0.0.1): + - RDEpubReaderView (0.0.1): - DTCoreText (~> 1.6) - ZIPFoundation (~> 0.9) - ZIPFoundation (0.9.20) DEPENDENCIES: - - RDReaderView (from `..`) + - RDEpubReaderView (from `..`) SPEC REPOS: trunk: @@ -31,15 +31,15 @@ SPEC REPOS: - ZIPFoundation EXTERNAL SOURCES: - RDReaderView: + RDEpubReaderView: :path: ".." SPEC CHECKSUMS: DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0 - RDReaderView: 2e0eeeff4bcfe8bbc09642344d326440af280ed5 + RDEpubReaderView: 603761e6d74a34230472bf53138942168e350d41 ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351 -PODFILE CHECKSUM: 775f5c8c488024e24d494aad6331e9fef8f9e2e5 +PODFILE CHECKSUM: 966c18a0a8d86d2f90e367ab41338d4e476d39bb COCOAPODS: 1.16.2 diff --git a/ReadViewDemo/Pods/Local Podspecs/RDEpubReaderView.podspec.json b/ReadViewDemo/Pods/Local Podspecs/RDEpubReaderView.podspec.json new file mode 100644 index 0000000..485cf9b --- /dev/null +++ b/ReadViewDemo/Pods/Local Podspecs/RDEpubReaderView.podspec.json @@ -0,0 +1,37 @@ +{ + "name": "RDEpubReaderView", + "module_name": "RDEpubReaderView", + "version": "0.0.1", + "summary": "A reader view for EPUB and plain-text books", + "platforms": { + "ios": "15.0" + }, + "swift_versions": [ + "5.10" + ], + "homepage": "https://github.com/namesubai/RDEpubReaderView.git", + "authors": { + "subai": "804663401@qq.com" + }, + "source": { + "git": "https://github.com/namesubai/RDEpubReaderView.git", + "tag": "0.0.1" + }, + "license": "MIT", + "source_files": "Sources/RDEpubReaderView/**/*.{swift}", + "resource_bundles": { + "RDEpubReaderViewAssets": [ + "Sources/RDEpubReaderView/EPUBCore/Resources/**/*" + ] + }, + "dependencies": { + "ZIPFoundation": [ + "~> 0.9" + ], + "DTCoreText": [ + "~> 1.6" + ] + }, + "requires_arc": true, + "swift_version": "5.10" +} diff --git a/ReadViewDemo/Pods/Local Podspecs/RDReaderView.podspec.json b/ReadViewDemo/Pods/Local Podspecs/RDReaderView.podspec.json deleted file mode 100644 index dae5975..0000000 --- a/ReadViewDemo/Pods/Local Podspecs/RDReaderView.podspec.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "name": "RDReaderView", - "version": "0.0.1", - "summary": "A reader view for novel", - "platforms": { - "ios": "15.6" - }, - "swift_versions": [ - "5.10" - ], - "homepage": "https://github.com/namesubai/RDReaderView.git", - "authors": { - "subai": "804663401@qq.com" - }, - "source": { - "git": "https://github.com/namesubai/RDReaderView.git", - "tag": "0.0.1" - }, - "license": "MIT", - "source_files": "Sources/RDReaderView/**/*.{swift}", - "resource_bundles": { - "RDReaderViewAssets": [ - "Sources/RDReaderView/EPUBCore/Resources/**/*" - ] - }, - "dependencies": { - "ZIPFoundation": [ - "~> 0.9" - ], - "DTCoreText": [ - "~> 1.6" - ] - }, - "requires_arc": true, - "swift_version": "5.10" -} diff --git a/ReadViewDemo/Pods/Manifest.lock b/ReadViewDemo/Pods/Manifest.lock index 3806923..163d73e 100644 --- a/ReadViewDemo/Pods/Manifest.lock +++ b/ReadViewDemo/Pods/Manifest.lock @@ -16,13 +16,13 @@ PODS: - DTFoundation/Core - DTFoundation/UIKit (1.7.19): - DTFoundation/Core - - RDReaderView (0.0.1): + - RDEpubReaderView (0.0.1): - DTCoreText (~> 1.6) - ZIPFoundation (~> 0.9) - ZIPFoundation (0.9.20) DEPENDENCIES: - - RDReaderView (from `..`) + - RDEpubReaderView (from `..`) SPEC REPOS: trunk: @@ -31,15 +31,15 @@ SPEC REPOS: - ZIPFoundation EXTERNAL SOURCES: - RDReaderView: + RDEpubReaderView: :path: ".." SPEC CHECKSUMS: DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0 - RDReaderView: 2e0eeeff4bcfe8bbc09642344d326440af280ed5 + RDEpubReaderView: 603761e6d74a34230472bf53138942168e350d41 ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351 -PODFILE CHECKSUM: 775f5c8c488024e24d494aad6331e9fef8f9e2e5 +PODFILE CHECKSUM: 966c18a0a8d86d2f90e367ab41338d4e476d39bb COCOAPODS: 1.16.2 diff --git a/ReadViewDemo/Pods/Pods.xcodeproj/project.pbxproj b/ReadViewDemo/Pods/Pods.xcodeproj/project.pbxproj index 931c150..d963c6e 100644 --- a/ReadViewDemo/Pods/Pods.xcodeproj/project.pbxproj +++ b/ReadViewDemo/Pods/Pods.xcodeproj/project.pbxproj @@ -8,449 +8,456 @@ /* Begin PBXBuildFile section */ 002685407B9C4D548571DA24AFD6E8F1 /* NSString+Paragraphs.m in Sources */ = {isa = PBXBuildFile; fileRef = B4A7F7402FAEE3A277A8716DDA115C95 /* NSString+Paragraphs.m */; }; - 00AD5F1F9E2E94C1D359676457840515 /* Entry+Serialization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76C1831768CE55090EADD902B29B6CD1 /* Entry+Serialization.swift */; }; + 00ED46EE485D06CEF016D5A721540F25 /* wxread-replace.css in Resources */ = {isa = PBXBuildFile; fileRef = 59CDD214151212AC68223A2BCC81D128 /* wxread-replace.css */; }; 014449AE560FFF3C628FAE639D043004 /* DTAnchorHTMLElement.m in Sources */ = {isa = PBXBuildFile; fileRef = 148B993AB228816D19FCB7CD13874C83 /* DTAnchorHTMLElement.m */; }; - 02DD8E465CE2BF1FA3954E939C26404C /* RDEPUBSemanticMarkerInjector.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3A9A32F96EBBCFD15780B8A62E38996 /* RDEPUBSemanticMarkerInjector.swift */; }; + 0178B405FAF3D265372305943BDB91AB /* RDEPUBReaderSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56A856C80CDD6B8D9CFEE9C551D5735F /* RDEPUBReaderSettings.swift */; }; 03D90C831397E8636A33CE557024A136 /* NSMutableAttributedString+HTML.h in Headers */ = {isa = PBXBuildFile; fileRef = 044BEA679671E16EA9BA9AD627750742 /* NSMutableAttributedString+HTML.h */; settings = {ATTRIBUTES = (Public, ); }; }; - 0415046466A4505448FA097120A58F3D /* FileManager+ZIPDeprecated.swift in Sources */ = {isa = PBXBuildFile; fileRef = F49FC82A04EE9B3B49CBA6CB88ACBC67 /* FileManager+ZIPDeprecated.swift */; }; - 04C2B9E1EA2066F182077D33A4A9F64C /* RDEPUBFontFallbackResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1898AE1C59D386BC2A565897777C7C9 /* RDEPUBFontFallbackResolver.swift */; }; - 05050DE075D5D9ACE72CE730BCEC53E7 /* RDEPUBReaderController+ContentDelegates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 785EFB63370FAE06B2485B755B42E2B5 /* RDEPUBReaderController+ContentDelegates.swift */; }; + 03D9B5FDBF0F61F0D2D9B28782BFB235 /* RDEPUBChapterDisplayContentCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAFF9E2F6436861E176B5E55A1180672 /* RDEPUBChapterDisplayContentCache.swift */; }; + 04703B1388DCD2CA10215F648B50F3CB /* RDEPUBRuntimePageCount.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBF2E942B6DF40838CD0EA5D5AFA0A49 /* RDEPUBRuntimePageCount.swift */; }; 0561DF6793C8516F469D9813F5CE3211 /* DTCoreTextLayouter.m in Sources */ = {isa = PBXBuildFile; fileRef = D80A72D8B7941D30246858D840EE1EF5 /* DTCoreTextLayouter.m */; }; - 0772C0CB588AA1183A682D00DEA0BDEF /* RDEPUBReaderAnnotationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BCE11DBF7C58BA594172E1D980C1626 /* RDEPUBReaderAnnotationCoordinator.swift */; }; - 07E74EEFF36512B9F4A805C13FBCD65C /* RDEPUBParser+Package.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CDD9C89E7009567107E9C16B4BE05C6 /* RDEPUBParser+Package.swift */; }; - 096DE56B035B002868AAE20C1D404B15 /* RDEPUBBackgroundTrace.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B0931BA1423E80768E11D26C280D4AF /* RDEPUBBackgroundTrace.swift */; }; + 058D3A9B6ACC980BAAFD1BA24CCA2AE7 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 006D002D86B43AA52E0AFD2E18459107 /* PrivacyInfo.xcprivacy */; }; + 06DF0E39B884DFADA5D3A32E03935DFF /* RDEPUBWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9868FA712FFD17D0195649510C1A68A6 /* RDEPUBWebView.swift */; }; + 06FB9E46B498A1601FFF195078E5A156 /* RDEPUBSelectionLoupeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F360D7798705EF33FED7578F42ABE588 /* RDEPUBSelectionLoupeView.swift */; }; + 07ADD08673F557736DFE92F970D7C6CB /* RDEPUBBackgroundCoverageStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E79069E6008AAA4FD339D2819CDF4675 /* RDEPUBBackgroundCoverageStore.swift */; }; 09819232AC1E42F72943F2B9EBC3EFDB /* DTSmartPagingScrollView.m in Sources */ = {isa = PBXBuildFile; fileRef = A20F6F0463A998DC3BD53DE9E63B63DE /* DTSmartPagingScrollView.m */; }; - 09BFA25E757EB9FAED6BE9D99EB1CEBD /* wxread-replace.css in Resources */ = {isa = PBXBuildFile; fileRef = 546293C95A2E9D6898E6E35EBAD67E3C /* wxread-replace.css */; }; - 0A7395089A78DF9BE4CDA924B947A584 /* RDEPUBReaderController+AttachmentTooltip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9726056B845AD5027986E5391D52D33D /* RDEPUBReaderController+AttachmentTooltip.swift */; }; 0AAA5BB1DBF584102B15CF8738F1815E /* NSScanner+HTML.m in Sources */ = {isa = PBXBuildFile; fileRef = 057DBFBFC99448A0B77A3C0DBB13B4A9 /* NSScanner+HTML.m */; }; 0ABC4D62588EABAA636441FD9A82C940 /* CTLineUtils.h in Headers */ = {isa = PBXBuildFile; fileRef = 9C6CE93FAA68605C6B90FF844A98E440 /* CTLineUtils.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 0B13ED712E176459DC75767C81356049 /* RDEPUBReaderHighlightsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 339103E5D1A1C62BF7B414EE689069F8 /* RDEPUBReaderHighlightsViewController.swift */; }; 0B6FA1F22150D7F8B1865F41FF70C922 /* DTImage+HTML.h in Headers */ = {isa = PBXBuildFile; fileRef = E3CAAC46883B74B071843E5D38B5BF85 /* DTImage+HTML.h */; settings = {ATTRIBUTES = (Public, ); }; }; - 0B7F8836E65428B8F1D3D72C40D25382 /* RDEPUBFixedLayoutTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 703ED5D8DDE3AEF39CCBA1F75ED53574 /* RDEPUBFixedLayoutTemplate.swift */; }; - 0C2FE44A6F901C5E9E1494DC1CD6F2B8 /* RDReaderView+PageCurl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D66B85E6DC6F2833A9D9561D6235DCA /* RDReaderView+PageCurl.swift */; }; - 0CAF515592F47FAB5A21B4D9E44A577A /* RDEPUBMetadataParseCancellationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B91825A1F365A62C53FC349F17C7038 /* RDEPUBMetadataParseCancellationController.swift */; }; - 0CD4054BDD450C9D1D74E4E1DACD5461 /* RDEPUBCFIRecoveryEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34D52809A0CFBA9664082FEDE2EBF420 /* RDEPUBCFIRecoveryEngine.swift */; }; - 0D3D1B4ACC6218895C3C62156C7A0913 /* RDEPUBTextPaginationInterfaces.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F3DBD229B4D724EEC81A05AEF68C96B /* RDEPUBTextPaginationInterfaces.swift */; }; - 0DB4BC648855B5320E45859AE469FFDA /* Archive+ZIP64.swift in Sources */ = {isa = PBXBuildFile; fileRef = D18519BCA8FAA9B01A704A0F0B9FFC78 /* Archive+ZIP64.swift */; }; - 0E0447CF04F1414B216B6F18B5AA2290 /* RDEPUBReaderSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94E4C09CA9FBF73F01D7DA481E085E0B /* RDEPUBReaderSettingsViewController.swift */; }; + 0BE1CDB7574C32928159748427234D17 /* Archive+BackingConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 765A94769106D14615FFB30EE1BD5E65 /* Archive+BackingConfiguration.swift */; }; + 0E80911EBF3F620A1B8DBCE516E2CC05 /* Entry+Serialization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76C1831768CE55090EADD902B29B6CD1 /* Entry+Serialization.swift */; }; 0FCBC8C64D8862F2A0E8794EDADDECBD /* UIFont+DTCoreText.h in Headers */ = {isa = PBXBuildFile; fileRef = 398DC798680E2CA64D46B9E19B380356 /* UIFont+DTCoreText.h */; settings = {ATTRIBUTES = (Public, ); }; }; 100BFFBD24F234C095EDB001B8088DB1 /* DTCoreTextLayoutFrameAccessibilityElementGenerator.m in Sources */ = {isa = PBXBuildFile; fileRef = 4476B58B93EE7476A4762FE0E26E8178 /* DTCoreTextLayoutFrameAccessibilityElementGenerator.m */; }; - 106C982FD5D5C71CA843649664127C78 /* Archive.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3E162E0261A5B0850158D3403191E62 /* Archive.swift */; }; + 10849A91BA39B64B632457A2818B8C63 /* RDEPUBTextContentInteractionCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3875D495797D0A7441AE5FDC6606E31 /* RDEPUBTextContentInteractionCoordinator.swift */; }; 109458F9D48FD4F381F0E40E2DB3D91C /* DTLazyImageView.h in Headers */ = {isa = PBXBuildFile; fileRef = D5DD4142DF33E1BE0B5A057101E851C5 /* DTLazyImageView.h */; settings = {ATTRIBUTES = (Public, ); }; }; - 10C5CE4C315EC9E77373B48B383E3372 /* RDEPUBCFIResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = F44654531A4D3A5E63F117378BDBA15E /* RDEPUBCFIResolver.swift */; }; + 10D59A237D78A0523310743DE96A47B0 /* Archive+Writing.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0218A7B243387C79E099CA947CF243D /* Archive+Writing.swift */; }; 11AB1902A68385851FB81E870991F9A0 /* DTImageTextAttachment.m in Sources */ = {isa = PBXBuildFile; fileRef = 57642B518C047A428FE1266A09E0513F /* DTImageTextAttachment.m */; }; - 11E5CACB61560ACC063C64C9801CB351 /* RDEPUBMemoryProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CEB851D42C524A554A2D26249EB6440 /* RDEPUBMemoryProbe.swift */; }; - 12374B35A5274F9053D51A1C41A246BA /* RDEPUBStyleSheetBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA427D1A69C32689C6C30A3C1CCE31A9 /* RDEPUBStyleSheetBuilder.swift */; }; + 11DEE40FA231D4F0E3E04F691930E413 /* RDEPUBWebViewDebug.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F02728AA8EB960A35AE0DD7736C90A /* RDEPUBWebViewDebug.swift */; }; 12B4A35B8E40C112E18A7C5511F023E9 /* CoreText.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4472755F83C121DE5E1E505D145F6DBE /* CoreText.framework */; }; 12C8BE5FE485F623F695BACC11DDF5B1 /* DTTiledLayerWithoutFade.m in Sources */ = {isa = PBXBuildFile; fileRef = 23006DBAC610AEB0A4B3EAFEA4FB0567 /* DTTiledLayerWithoutFade.m */; }; 12E4DE7F5308A77C2548C7FC376065A0 /* NSString+DTFormatNumbers.h in Headers */ = {isa = PBXBuildFile; fileRef = A3BD3ED7913130D32E14D5572CBCC921 /* NSString+DTFormatNumbers.h */; settings = {ATTRIBUTES = (Public, ); }; }; 12FD06AD8AA51C9E615E9094621D68CC /* NSURL+DTUnshorten.m in Sources */ = {isa = PBXBuildFile; fileRef = 3A6A858B976D032A3C61540AFF19DEE4 /* NSURL+DTUnshorten.m */; }; 130C3A85D9F9F445222C2E31EC245B0A /* NSScanner+HTML.h in Headers */ = {isa = PBXBuildFile; fileRef = DD0A5FF70CDBE87597264762EC20EF43 /* NSScanner+HTML.h */; settings = {ATTRIBUTES = (Public, ); }; }; - 1339E60ACC3CAA55D2A14496CA2C7F24 /* epub-bridge.js in Resources */ = {isa = PBXBuildFile; fileRef = 1AA0F27424B80E5D07E45D8DA86C9C26 /* epub-bridge.js */; }; - 142A627BB777C6927910C13A5AE04C22 /* RDEPUBPageBreakPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 875B4A5662D9C4B043C7C03DABF75E4B /* RDEPUBPageBreakPolicy.swift */; }; - 142F2CE735FD185C4CB812AFC1CC25A1 /* RDEPUBWebView+JavaScriptBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAA4CD4C6398D41D3E73B895B41A6C2B /* RDEPUBWebView+JavaScriptBridge.swift */; }; + 1384D17D1E1C6DE381FA63DAB5DB421B /* RDEPUBWebDecorationOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F630BB624DD56B3C0BD9131D8ACEC21D /* RDEPUBWebDecorationOverlayView.swift */; }; + 13A68713486786ECBD2F6D019D7F3A36 /* Archive+ReadingDeprecated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5193541848D2D1BF115A45D8A730611C /* Archive+ReadingDeprecated.swift */; }; 14BDD9FB7A7AC5C8ABFF8D24C381F3DA /* NSAttributedString+DTCoreText.m in Sources */ = {isa = PBXBuildFile; fileRef = 978449466D53EA6F5D40FCF0F353DDCA /* NSAttributedString+DTCoreText.m */; }; - 14CDD234BFB11FFA243EAB16C1BC620F /* RDEPUBReaderChapterListController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 413360D0D62A7D3B12EAB6FD542C8B29 /* RDEPUBReaderChapterListController.swift */; }; - 14DEAFC2EAB11EDC3546281C050C226E /* RDEPUBBackgroundCoverageStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B60943E11B87C06EB15D70467A9DAF42 /* RDEPUBBackgroundCoverageStore.swift */; }; + 150212A6C0D866FB32891A7C787D2AD9 /* epub-fixed-layout.html in Resources */ = {isa = PBXBuildFile; fileRef = 8A99D1BD5B4CEEFCCBE92DF58132E614 /* epub-fixed-layout.html */; }; + 151713856AAB9F06659A8D5DDF2087C2 /* RDEPUBPageMapReconciliationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46F84AE2351143E6F632A3FEE684A62C /* RDEPUBPageMapReconciliationCoordinator.swift */; }; + 153D368F1FB8A6C37F09FCA900308262 /* RDEPUBPublication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 634A51FA1417DC27E687D3FF80AD42CB /* RDEPUBPublication.swift */; }; 15A7ED15DA03DD3B9F26A017D270D1C2 /* DTCSSListStyle.h in Headers */ = {isa = PBXBuildFile; fileRef = 0C24F592C4F38F15DC8C46BE0442B59C /* DTCSSListStyle.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 15C867066FED697DF7379382C99E6B30 /* RDEPUBReaderController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 499D25E7902D605A5EC2940A714AABE0 /* RDEPUBReaderController.swift */; }; + 164B404F4970465BA732CB6C61F24099 /* RDEpubReaderViewProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = 986039F986495905BA1EB9D9F426B782 /* RDEpubReaderViewProtocols.swift */; }; + 169C2AB341395A974734908A6E630E5E /* RDEpubReaderContentCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4E8A28FA2AC4F578A1098D8285D3FFE /* RDEpubReaderContentCell.swift */; }; + 16C3B174F120D3C049977A091BFA34A0 /* RDEPUBTextPositionConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8EBCB744C294E197C28F3B81349FCF9 /* RDEPUBTextPositionConverter.swift */; }; + 18309C9443FFD52914CC7A183360DE88 /* RDEPUBCFIPath.swift in Sources */ = {isa = PBXBuildFile; fileRef = 643A984CBF55EC9D17CB2D82E7E68B9E /* RDEPUBCFIPath.swift */; }; 18FD31EA041A589A98B214078C77DE11 /* NSURL+DTComparing.h in Headers */ = {isa = PBXBuildFile; fileRef = 1F1F7BFB63DE15D627EB8F64829B8AAD /* NSURL+DTComparing.h */; settings = {ATTRIBUTES = (Public, ); }; }; 191824B2B1E1B0A7DD3B6298100B4B8A /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE4821B98DDE6C71411EC139EC6D2762 /* CoreGraphics.framework */; }; 19649ED312D92A6F5D2D6AF4E540CE75 /* DTHorizontalRuleHTMLElement.h in Headers */ = {isa = PBXBuildFile; fileRef = 89268DC8F1FBDE4F3167647B6A4F9216 /* DTHorizontalRuleHTMLElement.h */; settings = {ATTRIBUTES = (Public, ); }; }; - 19B2498971C455730EC1B664350A780A /* RDEPUBNoteDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA9CF4A5EFC9FEEA82D723074F256E82 /* RDEPUBNoteDetector.swift */; }; - 1C68F8BB0A8D65266501F52F000F3276 /* Data+Serialization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C3615C94C147AEA92F022E119810E9F /* Data+Serialization.swift */; }; + 199E93DDEF9B6092AF0C6CA8271FBF43 /* RDEPUBCFIDOMPathBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86F6704259B87ED54EF047EF455B168E /* RDEPUBCFIDOMPathBuilder.swift */; }; + 1A423024852D474564AD29A9E234D19D /* RDEPUBReaderSearchBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCD4B82D09BE72980015175674B04B22 /* RDEPUBReaderSearchBarView.swift */; }; + 1A5D4A937139068A751CE07AEC4FACDC /* RDEPUBChapterLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03B34F9D700619AE17BE5522BBABC08E /* RDEPUBChapterLoader.swift */; }; 1C76A33A29D56E6009775DD05B32BD2B /* DTWeakSupport.h in Headers */ = {isa = PBXBuildFile; fileRef = E4C5EB3F8F005C65005C101A9DBE488A /* DTWeakSupport.h */; settings = {ATTRIBUTES = (Public, ); }; }; 1D460603DCA4A4E5ECFE8FF1F1CACC10 /* DTColorFunctions.m in Sources */ = {isa = PBXBuildFile; fileRef = 4544C4444DCA90E700611D1281969883 /* DTColorFunctions.m */; }; - 1E8126EDD960AD9D2F7A7C3FC5073749 /* RDEPUBCFIPath.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35AAB84F53D47E997BE5822C68A820E1 /* RDEPUBCFIPath.swift */; }; - 1F5436D85D7121B1B9383A1EE70EE9B6 /* RDEPUBCFIDOMPathBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABEB2DB799322EF9048487013D0E7DBC /* RDEPUBCFIDOMPathBuilder.swift */; }; + 1D9D77BE1E9BF208AFEF407E669B2BDD /* wxread-replace-latin.css in Resources */ = {isa = PBXBuildFile; fileRef = 58BF341C0AB71F05D2FAED75A8A835A2 /* wxread-replace-latin.css */; }; + 1FD0505F5E605B1699CAF1616CC06662 /* RDEPUBNavigatorLayoutContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = B60AD68DA33BB20A0FE6D0D98EBE3D2B /* RDEPUBNavigatorLayoutContext.swift */; }; 1FD2290EC3449416DEBA4BDD5D7D7E22 /* DTLog.h in Headers */ = {isa = PBXBuildFile; fileRef = 207CC0EEB74CECEB43A870FF80560E0F /* DTLog.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 1FFD8E423E8DC4E04096041CFD00411A /* RDEPUBPageLayoutSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = F77D2A383DC7C1F05BD961646DB1D61E /* RDEPUBPageLayoutSnapshot.swift */; }; 20BDDBE5CE1A90B6CA1AC55C037357E0 /* DTHTMLParser.m in Sources */ = {isa = PBXBuildFile; fileRef = 71BBE6384315D7F00F5ECFFF67A85AF9 /* DTHTMLParser.m */; }; - 218A26D957029BC85CE43998835273D0 /* RDEPUBCFIRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65D988AEE2A2A568F2F08472880C56D0 /* RDEPUBCFIRange.swift */; }; - 21FF2314438A1D9C326CFB7090FEDEFD /* Archive+Writing.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0218A7B243387C79E099CA947CF243D /* Archive+Writing.swift */; }; + 2169042D85F5BBD58CA42E4893ABCF5F /* RDEPUBRenderDiagnosticsCollector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 327CD220C81409236F700C96851DB0E0 /* RDEPUBRenderDiagnosticsCollector.swift */; }; + 2201CC7B0A221ADAF05C3C4287FA3518 /* RDEPUBWebView+FixedLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E6420F3C34E33BDB146AE066B20ED88 /* RDEPUBWebView+FixedLayout.swift */; }; 2203356EEA94A9172BC7902923E43CBE /* DTCoreTextFontCollection.h in Headers */ = {isa = PBXBuildFile; fileRef = 25DDD76CA572F0C7AB5A8178CF80169D /* DTCoreTextFontCollection.h */; settings = {ATTRIBUTES = (Public, ); }; }; 22EEC92067603F597E6A32D1C77D77A0 /* NSDictionary+DTError.h in Headers */ = {isa = PBXBuildFile; fileRef = 05838D65DEC339C51AADB17346B7E287 /* NSDictionary+DTError.h */; settings = {ATTRIBUTES = (Public, ); }; }; - 2317574A8F30CCDC88503F5F3A6BAC20 /* RDEPUBReaderSearchCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16735EDC0457A01293AE4660C1097B43 /* RDEPUBReaderSearchCoordinator.swift */; }; - 235AA99103BBEA2892393E721374924D /* RDEPUBBuildDiagnosticsReporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74254B4DE3AD0907F43AB96CEED7408A /* RDEPUBBuildDiagnosticsReporter.swift */; }; - 237DE485DDB0995A62BF94834229A08F /* RDEPUBImageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87031C18DFBCC980112E5A9F3BDE49C3 /* RDEPUBImageViewController.swift */; }; + 22F7CE3E9E7DB5F51EF4562A2E8571BA /* RDEPUBSafeArea.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3415CCD5F8080BB63D594BDCFEA0EB86 /* RDEPUBSafeArea.swift */; }; + 2341854012EA656B8F32863213F7012A /* RDEPUBReaderEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96D1D1F318551BFCF4B28021E708564D /* RDEPUBReaderEnvironment.swift */; }; 24395BEB3C360D9DA5180BFB955B04A0 /* DTAttributedLabel.m in Sources */ = {isa = PBXBuildFile; fileRef = 3EFAE8DFDDEA1E6047289C86D66381C0 /* DTAttributedLabel.m */; }; 2445709667CB3820E51CD4AA915E6943 /* DTHTMLParserTextNode.h in Headers */ = {isa = PBXBuildFile; fileRef = 558FCD3F7E481B782EBFA127486ED15D /* DTHTMLParserTextNode.h */; settings = {ATTRIBUTES = (Public, ); }; }; - 2492C7282FC390E00AD92625292B948B /* RDEPUBReaderController+TableOfContents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96352099AD90E6B08B5178B06E6CD7CA /* RDEPUBReaderController+TableOfContents.swift */; }; 24C5B52FD2AA9C49731DC6859ABA7CDE /* NSDictionary+DTCoreText.m in Sources */ = {isa = PBXBuildFile; fileRef = 78887827FF15DE20865DF3A00660993B /* NSDictionary+DTCoreText.m */; }; 24D662573BF450D8125295A73043FC6D /* DTCustomColoredAccessory.h in Headers */ = {isa = PBXBuildFile; fileRef = BAE45673EE2875DB23FA7BDD61FDE1EF /* DTCustomColoredAccessory.h */; settings = {ATTRIBUTES = (Public, ); }; }; - 24FD03B3A8C72A13747F946300D8264F /* RDEPUBWebView+FixedLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC3AB2ABB72B4569AE8FF72F31261641 /* RDEPUBWebView+FixedLayout.swift */; }; - 2500FB14F7728CA537DDBA9CCC7E242D /* RDEPUBChapterRuntimeStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EAC09034BA70013D5EB674B407F2C63 /* RDEPUBChapterRuntimeStore.swift */; }; - 25599BEBB0B997F6742E00F7124C40D7 /* RDURLReaderController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 475D625CDF9C41734A2045F2DF2DA3A5 /* RDURLReaderController.swift */; }; 262F8039CD300F06804EF72ADE21296F /* DTFoundation-dummy.m in Sources */ = {isa = PBXBuildFile; fileRef = FF1C41D226C0957F715EFF3F23893386 /* DTFoundation-dummy.m */; }; + 263816EA7A16F559987B895002D43484 /* RDEPUBParser+Package.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A18237B2AA906DE9B54F3FBE756BD95 /* RDEPUBParser+Package.swift */; }; + 267ED64847468F47CE49E72E39AC04A3 /* RDEPUBReaderTopToolView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FFD7DFEB52262536F9A2C19B111C229 /* RDEPUBReaderTopToolView.swift */; }; + 26CCE192A25CD7B025904E0AC1260A10 /* Archive+Deprecated.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3C9CA544EBF55130625F6C3AD2ABFB4 /* Archive+Deprecated.swift */; }; + 26D029D2A69EB67AF0947292B6BB7FD8 /* ZIPFoundation-umbrella.h in Headers */ = {isa = PBXBuildFile; fileRef = B41F7D27C2336F89BB0185ACB0B19EA9 /* ZIPFoundation-umbrella.h */; settings = {ATTRIBUTES = (Public, ); }; }; 270C5C10028F0BC5A62548326CCB379B /* DTColor+Compatibility.m in Sources */ = {isa = PBXBuildFile; fileRef = 8FAAF640165FC1B4B47C546F80D98B1E /* DTColor+Compatibility.m */; }; - 28B11155E99C2BC1AEC3A7ACD8E84A19 /* Data+Compression.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22B69C9433F2FBE5CE8409BD4266DFE8 /* Data+Compression.swift */; }; + 295918B689386A62654E2CE1AB071A69 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B50104DE2E35D820ECA22CDB3E6F96EA /* Foundation.framework */; }; + 299808D938C97BA948DB3935EFD82B74 /* RDEPUBTextPaginationInterfaces.swift in Sources */ = {isa = PBXBuildFile; fileRef = 576B635BD8D0994E47FD4CF7E23FEA79 /* RDEPUBTextPaginationInterfaces.swift */; }; + 29AA7851B584FD07672063C7213179FA /* URL+ZIP.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6430501430329643C8280B3985C48711 /* URL+ZIP.swift */; }; 29D9B32DFFC59A157EDEF005DB92A5AC /* DTCoreTextLayoutLine.m in Sources */ = {isa = PBXBuildFile; fileRef = EB850927957E35DB8CE0F109188C99A9 /* DTCoreTextLayoutLine.m */; }; 2AAF9361720FC2D8FFCA93B5FAE42019 /* NSMutableString+HTML.m in Sources */ = {isa = PBXBuildFile; fileRef = 126E6ACAC7C0427806B58DA8E10BDD78 /* NSMutableString+HTML.m */; }; - 2B2FE2C2FA428E1532ACA1C76FB88DA1 /* RDEPUBReaderSearchBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03381B40A2B1AD4E41BCC3BE2C31B626 /* RDEPUBReaderSearchBarView.swift */; }; - 2B697D2B7F73C6C0C963C58B53C8A2B2 /* RDEPUBParser+ReadingProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EDE2594DAA2AA3DEE0DC6FBB8021FB4 /* RDEPUBParser+ReadingProfile.swift */; }; - 2C4FC496451BE7EB9804103EF5B5922F /* RDEPUBChapterSummaryDiskCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335C0A7F69EC3B4D9CB309D814D08733 /* RDEPUBChapterSummaryDiskCache.swift */; }; - 2C6EC9E937F7C828263E9B23CCE6F46D /* RDEPUBCFISerializer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00E9FEEAFFD8790F7439A91AD283F5F3 /* RDEPUBCFISerializer.swift */; }; - 2DB041FA1AAE39A99CD1F0E74124671E /* RDEPUBModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3840A5C7212D01AADA285E04510E8020 /* RDEPUBModels.swift */; }; - 2DC6A079155266B323EC5D93D7E3C1A1 /* RDEPUBReaderContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1300C3BDE866D4C29C0792C55713758D /* RDEPUBReaderContext.swift */; }; - 2DDC7535CA3807D6711EE0FFAB21B6EA /* RDEPUBParser+TOC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60C982580B17056D1FD0076743AF2A20 /* RDEPUBParser+TOC.swift */; }; + 2B65F3DCEBB005DA04C98F60A4D1AE77 /* RDEPUBReaderController+Trial.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AF6AEEC13967258B9E5E8BF691ADDC8 /* RDEPUBReaderController+Trial.swift */; }; 2E65B1871A829C3BD96472B1F86D34DF /* NSURL+DTAppLinks.m in Sources */ = {isa = PBXBuildFile; fileRef = 3453CFB9BDBF6F9D0937EDF5FB2BD07B /* NSURL+DTAppLinks.m */; }; + 2F9272A25DA4AD52EE88793A86DF9180 /* RDEPUBCSSCompatibilityLayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97C6D812897020B77EF9C826643A0418 /* RDEPUBCSSCompatibilityLayer.swift */; }; 2FD7360250C2255A80583D4CF518FBFA /* DTTextAttachment.m in Sources */ = {isa = PBXBuildFile; fileRef = 4944FE0BE3205F2EC91A6A0550D30B75 /* DTTextAttachment.m */; }; - 30398DEF7CAD19171A536D29ABFE4C66 /* RDReaderView-umbrella.h in Headers */ = {isa = PBXBuildFile; fileRef = A3BC0875FF335D55BEBFD59BBC6BF914 /* RDReaderView-umbrella.h */; settings = {ATTRIBUTES = (Public, ); }; }; - 30A2DE91620758A740D295AB29580245 /* RDEPUBAttachmentTooltipOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B812E0491FA6CD09EEDF1B3B674C86A3 /* RDEPUBAttachmentTooltipOverlayView.swift */; }; + 3026BC61FA36878E1CC1CBA07F69C648 /* RDEPUBChapterDataCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27176E77CA7151E15AC5ED7E22E7AD11 /* RDEPUBChapterDataCache.swift */; }; + 308462B8065C4552E6F2DCA653BA4A49 /* RDEPUBTextPaginationSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0020851315497D536AE8CB195B6BCA5B /* RDEPUBTextPaginationSupport.swift */; }; 30C01FE491C26314F776AD8B2AF136FA /* NSAttributedStringRunDelegates.m in Sources */ = {isa = PBXBuildFile; fileRef = C69C7A531B5E021499D8FBF1DBE0E3BB /* NSAttributedStringRunDelegates.m */; }; - 30C834B23033B4F571143C336E72510A /* RDEPUBTextLayouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9B958811F45DE07F349083133787061 /* RDEPUBTextLayouter.swift */; }; - 314AF8C74F2F44FC844A3D405A93F87B /* RDEPUBCFICompatibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E93EDC68FEB87F429AEE9DA66B1F0C /* RDEPUBCFICompatibility.swift */; }; - 31B065D428020BED69F0BB82C3C6CB6B /* RDEPUBWebContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62A03DB083C11C5FDA1D858BC447E3AD /* RDEPUBWebContentView.swift */; }; + 30CC1E6A5D43EC97866FA6D7087D696C /* RDEPUBReaderPaginationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAD24FFA4B4B8E33C45ED26C2FD4B37 /* RDEPUBReaderPaginationCoordinator.swift */; }; + 3144D716E7A554230FE1522F621FB024 /* RDEPUBChapterSummaryDiskCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF282AB822D775C2628A7A3D042A588D /* RDEPUBChapterSummaryDiskCache.swift */; }; 31B805C50E0E187E6242B0C878C4D30D /* DTCoreTextMacros.h in Headers */ = {isa = PBXBuildFile; fileRef = C495C74844982FAA1B846A2A89023D12 /* DTCoreTextMacros.h */; settings = {ATTRIBUTES = (Public, ); }; }; - 32C88ACF7CE5CD2C7C808D6192F449C9 /* RDEPUBChapterCacheKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = A567490FF7183A9FAAD5EEEFCF9E286F /* RDEPUBChapterCacheKey.swift */; }; 33A0F5C950CB119A009D62682928CC68 /* DTPieProgressIndicator.m in Sources */ = {isa = PBXBuildFile; fileRef = 8BA617294E6371DD7FAC32B61E0873F2 /* DTPieProgressIndicator.m */; }; - 33D110E213D1F9F69A3EA84F20BADB11 /* RDEPUBImageViewerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6054D59EA001FB57A86513538FA8BB2D /* RDEPUBImageViewerCoordinator.swift */; }; + 33B28214F1A3949A14C93CECA00B8173 /* RDEPUBReaderChapterListController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F265D797C3E4FD97ED35CB9BF70F121 /* RDEPUBReaderChapterListController.swift */; }; 343E794F88AA503F5139C2244DC1A2B1 /* DTTextAttachmentHTMLElement.h in Headers */ = {isa = PBXBuildFile; fileRef = 8B8FDAF9E8370366F7FA8524CB0D80B3 /* DTTextAttachmentHTMLElement.h */; settings = {ATTRIBUTES = (Public, ); }; }; 3526C298B08D60279E5378EDA84C1A96 /* DTActivityTitleView.m in Sources */ = {isa = PBXBuildFile; fileRef = 662D30C1E74D1D5122921475F4024F73 /* DTActivityTitleView.m */; }; - 35A655E299DC1E3EE7B316A18F2CE89D /* Archive+Deprecated.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3C9CA544EBF55130625F6C3AD2ABFB4 /* Archive+Deprecated.swift */; }; + 35B64675B8DC54F46D7D7AC046ACFD23 /* RDEPUBStyleSheetComposer.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB2DDE835F718828F22B7C06D9AF02FB /* RDEPUBStyleSheetComposer.swift */; }; 35C487D1A21FC5ADE3DF56FDB6040C0C /* NSURL+DTComparing.m in Sources */ = {isa = PBXBuildFile; fileRef = 467CC59CEC3B3BC7ACDCA776FDCBDC1D /* NSURL+DTComparing.m */; }; 35C5BE59CA712CCDF93D6E7010D93883 /* DTAttributedTextContentView.h in Headers */ = {isa = PBXBuildFile; fileRef = E96C0EEB2032D3CC18F4B37A7C34C84C /* DTAttributedTextContentView.h */; settings = {ATTRIBUTES = (Public, ); }; }; - 35CC934AA9BDDA78F314C5F7B6BC6992 /* RDEPUBPageMapReconciliationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9074F378D3B707959729C0DB2C2B8AB /* RDEPUBPageMapReconciliationCoordinator.swift */; }; + 35D82C01D957EA4B99A7BE2D88DBAB9C /* RDEPUBReadingSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DFD71BAF612FE4B521B30E7E24C4E1A /* RDEPUBReadingSession.swift */; }; + 36F6BC3C4651DB0B0122DB1300A566A6 /* RDEPUBNoteResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B1FC7245885C5EE717A583C35D1EC08 /* RDEPUBNoteResolver.swift */; }; 372E907DF08263D0E1AF904F1FCD66EB /* DTTextAttachment.h in Headers */ = {isa = PBXBuildFile; fileRef = E853AAB3B09E0662567752431CEF1D33 /* DTTextAttachment.h */; settings = {ATTRIBUTES = (Public, ); }; }; - 3797E49E7BA8E8431B3366064E190D1F /* RDEPUBResourceResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44113776B1D8777D7989AA4A28E7AD89 /* RDEPUBResourceResolver.swift */; }; + 37A6DEDE9455172CA9A261A3BC2ED02E /* Data+CompressionDeprecated.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6B46EF6F055A721AF370D705D026E9E /* Data+CompressionDeprecated.swift */; }; + 37E8FA54000350B466C257801E6614CE /* RDEPUBDarkImageAdjuster.swift in Sources */ = {isa = PBXBuildFile; fileRef = A08CDC243F29B29A95253DCA44335BFE /* RDEPUBDarkImageAdjuster.swift */; }; 389F7C8400F9223C019F2D7825CD2369 /* NSCharacterSet+HTML.m in Sources */ = {isa = PBXBuildFile; fileRef = 2F242FC1357DD90FA4FD67E1DB90A837 /* NSCharacterSet+HTML.m */; }; - 3A653E4DA613026DC1C1C22CF6166DAE /* RDEPUBTextLayoutFrame.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65C7DA34232B39F5D61DAD1C8D64BA55 /* RDEPUBTextLayoutFrame.swift */; }; - 3A79D34DFEF27437F551C74BF83B44CE /* RDReaderView-RDReaderViewAssets in Resources */ = {isa = PBXBuildFile; fileRef = 83410CC9CF2ABE63B90A92F2F988BF65 /* RDReaderView-RDReaderViewAssets */; }; - 3A84B52F3FC5DA28DB261B56F234DEC0 /* RDEPUBPaginationCacheCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5874C0AE2C0D64722A34980EB20368B4 /* RDEPUBPaginationCacheCoordinator.swift */; }; - 3AFA079E082223A8681E645F9995847F /* RDEPUBViewportTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13A624085502DB7A43EAEF62D179E1E /* RDEPUBViewportTypes.swift */; }; - 3BCE613C1A99CE80B7CBCF682022E6CC /* RDEPUBPaginator.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFF03BFDA015FD626A53E9FDBC95985A /* RDEPUBPaginator.swift */; }; - 3C4ACDF8DA2D70C2B21014421406B818 /* RDEPUBTextContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A92C7A06935B41D87D6CB878FDAD9867 /* RDEPUBTextContentView.swift */; }; + 3970AAD7089A8009D72C2A70F1D73EE5 /* RDEPUBJavaScriptBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = D36009F925C400C4981D456059CC16BA /* RDEPUBJavaScriptBridge.swift */; }; + 39E30D6C1E40038B0C1083926237A2E6 /* RDEPUBTextLayoutFrame.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEBCA7121897869990DF9876F2240A4A /* RDEPUBTextLayoutFrame.swift */; }; + 3AD036D9E12E1F5B13E649BD538CAE41 /* RDEPUBAnnotationModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8250EF1AA33B1FBC82A438CB75C77E7D /* RDEPUBAnnotationModels.swift */; }; + 3BE4D575119E0A75B45C32489B090137 /* RDEPUBResourceResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC0D5B57FDFCE021F4354EA651E68507 /* RDEPUBResourceResolver.swift */; }; + 3BEB5C7059EB7C4E62C280146D66CAF7 /* RDEPUBReaderTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 677A2034D4184FC4E2836F8A5063AD50 /* RDEPUBReaderTheme.swift */; }; + 3C1B919FDD4F85CF4E6D3E8EC4C7E025 /* RDEPUBReaderController+LocationResolution.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DC9A66DE304F1176D36FD9294F0E536 /* RDEPUBReaderController+LocationResolution.swift */; }; + 3CCEC42C335B6DCC04C3058CF078DAC7 /* RDEPUBNotePopupViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE074C3401A1CAF095691391F2028E61 /* RDEPUBNotePopupViewController.swift */; }; 3D8DF1B2E9B3BC529D727FBBE40D46AF /* DTFoundation-umbrella.h in Headers */ = {isa = PBXBuildFile; fileRef = 23588B63FC4026A371F5C683744A664C /* DTFoundation-umbrella.h */; settings = {ATTRIBUTES = (Public, ); }; }; 3DC8F9474FC6A0BB7E81A703CE958966 /* NSAttributedString+DTDebug.h in Headers */ = {isa = PBXBuildFile; fileRef = A8A086525BF509F4581693115438B82B /* NSAttributedString+DTDebug.h */; settings = {ATTRIBUTES = (Public, ); }; }; 3DF9D7FC3E7B18DE4FBB4935D747E57A /* NSString+DTUtilities.m in Sources */ = {isa = PBXBuildFile; fileRef = F3E93C4AC73A09DE1EAE805B0ADE3306 /* NSString+DTUtilities.m */; }; - 3E52F1617157FA5312268F8E8D77E2DF /* Archive+ReadingDeprecated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5193541848D2D1BF115A45D8A730611C /* Archive+ReadingDeprecated.swift */; }; - 40138948768EA675E92C18C3CB1D887A /* RDEPUBTextPositionConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D64719937C574F41EE90673FE70F681 /* RDEPUBTextPositionConverter.swift */; }; - 41873DE0BEAA19DCBA7C7375228752D8 /* RDEPUBResourceDataProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F64125A1A7278D5D5DBEE29D0A23289 /* RDEPUBResourceDataProvider.swift */; }; - 4251130F5ADB92686A2D713F37292FEA /* RDEPUBReaderViewportMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E28782DB94C6BAF80766A255BB55167 /* RDEPUBReaderViewportMonitor.swift */; }; - 43EA6AE86DAD9FF8AA228FFD06495F1D /* RDReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B503F169F6A42A61C4A4BBE13459E2B /* RDReaderView.swift */; }; + 3F77E380C25DEB0DE88525B9727D8EF4 /* RDEPUBCFIGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D915BB57012D656EE211B3BC41EBAA93 /* RDEPUBCFIGenerator.swift */; }; + 3FA774DD0C65367606773764044C2868 /* RDEpubReaderView-RDEpubReaderViewAssets in Resources */ = {isa = PBXBuildFile; fileRef = C8A6E90AB5E26049710E2C2404C28B9A /* RDEpubReaderView-RDEpubReaderViewAssets */; }; + 4046F20BC299071A92E803F8AEE93AA5 /* RDEPUBCFIMarkerInjector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6596F63DB6DA0616A8EB8FBA7BEC20F6 /* RDEPUBCFIMarkerInjector.swift */; }; + 40DBDF8655647908ED29A31535D267FB /* RDEPUBDTCoreTextRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 157F831C6FDD7EC227517F29E2CB8590 /* RDEPUBDTCoreTextRenderer.swift */; }; + 40ED28D3EDCD7FD6FDD14E01F5A9B447 /* RDEPUBJumpSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B098FF2F53DED347EE39859D3CC11BE /* RDEPUBJumpSession.swift */; }; + 411620ED94CA27F80F5BA1C54A8C0C68 /* RDEPUBTextSearchEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABA2040236556B3E47062333B414AF5 /* RDEPUBTextSearchEngine.swift */; }; + 41DFB140311D7C9600D2E428FF15656D /* Archive+Reading.swift in Sources */ = {isa = PBXBuildFile; fileRef = E865600EE9117A9153716299FFA7912D /* Archive+Reading.swift */; }; + 43EF10B03978E0CDA6E9F049A0D1A2A3 /* RDEPUBChapterPageCounter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00CDDB6857017E4C7EFF619210E70807 /* RDEPUBChapterPageCounter.swift */; }; 44226BF89798AB351A5DD128C6BCBB34 /* DTBlockFunctions.h in Headers */ = {isa = PBXBuildFile; fileRef = 35417EC94F2D58CB80C991F875CE15C2 /* DTBlockFunctions.h */; settings = {ATTRIBUTES = (Public, ); }; }; - 4440862E007C4B31A86BAAA653FD3F98 /* RDEPUBTextBookCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB350DF3FC839EBAC2D622C87CE4C8AD /* RDEPUBTextBookCache.swift */; }; - 448798E8C31C4EE68A050057A4A7DA5E /* RDEPUBTextBookModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = C559731DDB39006B286108CFA2DDA230 /* RDEPUBTextBookModels.swift */; }; 44B1FBF893C06F314F8C9BD1405897D8 /* DTCoreTextGlyphRun.m in Sources */ = {isa = PBXBuildFile; fileRef = ED049C791713944233EE10C7A111C691 /* DTCoreTextGlyphRun.m */; }; + 450D55646D08FE89E88D2F08698A6D32 /* RDEpubReaderFlowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = A858EFE14B7184E73CC8B040C0FBAF9F /* RDEpubReaderFlowLayout.swift */; }; 45133E47CC5FBCF78BFCD38981BA027F /* DTDictationPlaceholderView.h in Headers */ = {isa = PBXBuildFile; fileRef = 9C12A4A5BB42D0F68DABBD3BDF103BC5 /* DTDictationPlaceholderView.h */; settings = {ATTRIBUTES = (Public, ); }; }; - 457B13114B2568C713898BEFEE2536A7 /* RDEPUBSelectionLoupeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 975BFAACE86185AB89AAD8FABBD64FD5 /* RDEPUBSelectionLoupeView.swift */; }; + 45D3AC9978D8A395113A015A0AE53623 /* RDEPUBReaderDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07EF7DFA2327CD5059A2A0B693813195 /* RDEPUBReaderDependencies.swift */; }; + 45E0CD0CF8B9FA30F5B906B1B1374154 /* RDEpubReaderView-umbrella.h in Headers */ = {isa = PBXBuildFile; fileRef = 8A204FA398A28E3D0424A74DD407BB03 /* RDEpubReaderView-umbrella.h */; settings = {ATTRIBUTES = (Public, ); }; }; 45F90B98546FDBA03AB112DFE859A185 /* DTCSSStylesheet.m in Sources */ = {isa = PBXBuildFile; fileRef = B37118B8D16847B0F82E06A5B33898AB /* DTCSSStylesheet.m */; }; - 45FDB99F49312864726B866395136E29 /* String+RDEPUB.swift in Sources */ = {isa = PBXBuildFile; fileRef = B700F9E8C03D3F9C537B14755D984564 /* String+RDEPUB.swift */; }; 46EF32D586FF590CE2C60C66364B9357 /* ImageIO.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FEB14A8F8170777DB7CCC3F27C8EA57A /* ImageIO.framework */; }; + 4766DE4358C52541EA0EF277171EAD9A /* RDEPUBPaginationCacheCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A28433723E70648483D360F3B74FA46 /* RDEPUBPaginationCacheCoordinator.swift */; }; + 48878AB11627CEF8C7DC40088DA7AED7 /* RDEPUBPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 229118D72FEDE65A83CE684333A3E6C0 /* RDEPUBPreferences.swift */; }; 49047276A52DD55FA5A59AD240C6F3CF /* NSString+DTPaths.m in Sources */ = {isa = PBXBuildFile; fileRef = D30DBDF1EDB20CF83690AC6619AA0C48 /* NSString+DTPaths.m */; }; - 49837D2F0809E91BC1D12475C7B10621 /* RDEPUBParser+Archive.swift in Sources */ = {isa = PBXBuildFile; fileRef = 668E98AD14CBB11B4F60DC21B9B6EC4C /* RDEPUBParser+Archive.swift */; }; 4A13B4E5EA8616A8F09D90BC6EAEEA64 /* CTLineUtils.m in Sources */ = {isa = PBXBuildFile; fileRef = 4FF4A2A7F2C7CFB5DD757BE1A6E59C7B /* CTLineUtils.m */; }; - 4BEE03FBD65DFE217B496877CC2E7726 /* RDEPUBCFIGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF8D9CFA61CC68C8A808C2B39CE0C7F8 /* RDEPUBCFIGenerator.swift */; }; - 4D005BE2E0DC7CB926776C8406EE7A2A /* RDEPUBWebView+Reflowable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D7CCE90374184A7112FA1618B41674F /* RDEPUBWebView+Reflowable.swift */; }; + 4A307E593B912B2F30D64559300599FE /* Archive.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3E162E0261A5B0850158D3403191E62 /* Archive.swift */; }; + 4A5B6DC44DD0C372C4D760844E7468AF /* rangy-core.js in Resources */ = {isa = PBXBuildFile; fileRef = 33CD1120A32A59966196C179B158D57C /* rangy-core.js */; }; + 4A782A595503D2EB93424CF55B45C583 /* RDEPUBFixedLayoutTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD6168EB90D5F2AE16988D0B4BC4C56E /* RDEPUBFixedLayoutTemplate.swift */; }; + 4B79E51CBDA4A396EAEB033F203F2475 /* RDEPUBCFIRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 820C93ABAAF16D708F0A6322AC62801F /* RDEPUBCFIRange.swift */; }; + 4BB8DFFFE26E48DFA8B364CFF1EC8040 /* RDEPUBPageResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 752B1535C51D57BB88FB239513E0810A /* RDEPUBPageResolver.swift */; }; + 4BBADAEFFC3275B9ACFEA06354F413E1 /* RDEPUBWebView+Search.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19169CBEAD412B0E1C17F517F5B30269 /* RDEPUBWebView+Search.swift */; }; + 4C7BAC91C1E948D9592957C3A92C3147 /* RDEPUBPresentationRuntime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 961206494549A9AE3C6EB03DDAD21C1C /* RDEPUBPresentationRuntime.swift */; }; 4D23E316A728EAD4C30968F3D0C69E38 /* DTPieProgressIndicator.h in Headers */ = {isa = PBXBuildFile; fileRef = 173194D38C897D8E38AD8A194E47DB5C /* DTPieProgressIndicator.h */; settings = {ATTRIBUTES = (Public, ); }; }; - 4D24904AD441B9BDD1194349EF8EFE6B /* RDEPUBReaderController+PublicAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = D368B75B200BA85AA29995C39AA63371 /* RDEPUBReaderController+PublicAPI.swift */; }; + 4D2640B41A756FA16BCBAC667460FD10 /* RDEpubReaderSpreadResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABD447CD3A40D2D3C8E80590B8361B7 /* RDEpubReaderSpreadResolver.swift */; }; + 4D45731159EA1D951C642A46AB0A3B38 /* RDEPUBNotePopupCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5D6E84305CAE9A6583309CA93366E15 /* RDEPUBNotePopupCoordinator.swift */; }; + 4D58FC9031CA9144B783CCE85047089A /* epub-bridge.js in Resources */ = {isa = PBXBuildFile; fileRef = 4E3C6089F68358EFF3598A4990F5C545 /* epub-bridge.js */; }; 4DA6EC46F8968265B96C6D7B2260C4E5 /* DTTextBlock.m in Sources */ = {isa = PBXBuildFile; fileRef = C508E49F6BF9F50B05918C230B033069 /* DTTextBlock.m */; }; 4E4939F6592B06D2CA764DCACFB47871 /* NSString+Paragraphs.h in Headers */ = {isa = PBXBuildFile; fileRef = 0B8321AB80CFE0B94537AEA63E9A79F5 /* NSString+Paragraphs.h */; settings = {ATTRIBUTES = (Public, ); }; }; - 4EA1FC83A98FEBFB463619BD56F7723D /* RDEPUBAssetRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EAF25AD2B9FEE0FE357E3A433E3D92C /* RDEPUBAssetRepository.swift */; }; - 4F27DAE4E00D96562BD5EA6F4594E7F6 /* UIColor+RDEPUB.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5501A46F5D094488C50D841B21AAE96 /* UIColor+RDEPUB.swift */; }; + 4E6E2981D78BED42BFB236A570E76176 /* RDEpubReaderPagingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7180AF39B62FE6A6B43E7BE45E45C31 /* RDEpubReaderPagingController.swift */; }; 4F50C703427747B88405080B221349A1 /* DTLinkButton.h in Headers */ = {isa = PBXBuildFile; fileRef = B12CE4962CDC324FB47FB92D6706D149 /* DTLinkButton.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 4F9B2DB33A8FE255DDAF17E97D68227B /* RDEPUBSettingsFlipAutomation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 774F11C36F2A16D57002B67C22552875 /* RDEPUBSettingsFlipAutomation.swift */; }; 5065D99E1588E8CC60677E74916A5C03 /* DTAttributedTextCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 814315319ED50AF916C3C8A68C0A47A0 /* DTAttributedTextCell.m */; }; 50887D3ADF90BFE2720EBA9B6FABBAA2 /* NSString+DTURLEncoding.h in Headers */ = {isa = PBXBuildFile; fileRef = CEEC8715398E89B42F9B04E290E8E4FE /* NSString+DTURLEncoding.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 509C0C64CF80609A3A9ECBCE6D3C38AA /* RDEpubReaderTapRegionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EA2C6CFA053A4B06F2AAB2EA0D17A4B /* RDEpubReaderTapRegionHandler.swift */; }; + 51098D0F512DA347D755590FD7586D57 /* RDEPUBCoreTextPageFrameFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A6313EEF4AEDADB4793D6CAEC32B0A /* RDEPUBCoreTextPageFrameFactory.swift */; }; 51A1B5800D23C355B1712E8D1103780C /* DTTextBlock.h in Headers */ = {isa = PBXBuildFile; fileRef = EA2BF1D7535B036E1356E8DF42A04954 /* DTTextBlock.h */; settings = {ATTRIBUTES = (Public, ); }; }; - 51A9091769AF0695E474F919A031FC2F /* cssInjector.js in Resources */ = {isa = PBXBuildFile; fileRef = 3C9F522B5818D3E8F6E4B10B22A9F3C9 /* cssInjector.js */; }; - 520E924195F1CDF43C04873E7D2BCC3A /* RDEPUBReaderTopToolView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FA75FCB2F9051FFC2C7EC7FD801B3B6 /* RDEPUBReaderTopToolView.swift */; }; 528DBAC7FCEFC7D6575C1F6CB4399AEA /* DTStylesheetHTMLElement.h in Headers */ = {isa = PBXBuildFile; fileRef = 1A88AA04D7B29A217B432C19AC48AB6B /* DTStylesheetHTMLElement.h */; settings = {ATTRIBUTES = (Public, ); }; }; - 5375A13AE599ADE49B10C7934E1004B9 /* RDPlainTextBookBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7099DFE326BE72991030E977265222E8 /* RDPlainTextBookBuilder.swift */; }; - 5403D6294D176296755971502638A17F /* RDEPUBReaderState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 745DE64E801F69497754257504DF420A /* RDEPUBReaderState.swift */; }; - 544667F79E06E6ED8FE3CE0312D9A176 /* RDEPUBJavaScriptBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E031BA35A851ED17BB97887FA36FC83 /* RDEPUBJavaScriptBridge.swift */; }; - 54F9E6B88ADCBEAE21A7FEE193A78133 /* RDEPUBTextPageRenderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CB104ADA375D292371993C1CB6AAB87 /* RDEPUBTextPageRenderView.swift */; }; - 554BFC9D469A47AED35DD43458B1F6B6 /* RDEPUBNavigatorLayoutContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF8F93382E48EBF445FDA4A10C34EE23 /* RDEPUBNavigatorLayoutContext.swift */; }; + 542013DD6C510E7A820D2CB071B1219E /* cssInjector.js in Resources */ = {isa = PBXBuildFile; fileRef = B26A41A391468A542EFC7C0F2D1A94AC /* cssInjector.js */; }; + 5517E8BF85383E87597737B7C063403C /* RDEPUBRenderRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00C39185B11404EA80AFE34E589E7496 /* RDEPUBRenderRequest.swift */; }; 5582735073EBB9F5078E6E8AEEFB646A /* DTCoreTextLayoutLine.h in Headers */ = {isa = PBXBuildFile; fileRef = 37327141DC73DE2D5742BE6F989094CE /* DTCoreTextLayoutLine.h */; settings = {ATTRIBUTES = (Public, ); }; }; - 55F126D4401C2424C486390F1F5F52EB /* RDEPUBJumpSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 274FAE68017F54F1F939837B96E2826D /* RDEPUBJumpSession.swift */; }; 56188173A91EF51B7010905C6FCB22D2 /* UIScreen+DTFoundation.h in Headers */ = {isa = PBXBuildFile; fileRef = 9C5E29D728B8CD4EABC0B3DD9CD9DE99 /* UIScreen+DTFoundation.h */; settings = {ATTRIBUTES = (Public, ); }; }; - 564A0E8D7EA1FD563D30ED1C269CF110 /* RDEPUBSafeArea.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B257B0E6084F9E4D8FE7DC39AF9B477 /* RDEPUBSafeArea.swift */; }; - 565478E359CF62028188FB8E8EEA2141 /* RDEPUBCFIParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0879F59F605F8E1512ADB3D29CB7912E /* RDEPUBCFIParser.swift */; }; - 56D733115E4EFD396C2A011ECA018C49 /* RDEPUBParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54626FDB51F5A24F21ED95B65F732EA6 /* RDEPUBParser.swift */; }; - 5705832CA2DB13EDF020F92EA8DB8AAF /* RDEPUBCFIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE8EE378CAD4622D735C39CD1B0D4FEB /* RDEPUBCFIError.swift */; }; + 5719913D1BAA2CB6B405F41F827580DE /* RDEpubPlainTextBookBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CBCA6E5C76DE2C9362183A2B3763C08 /* RDEpubPlainTextBookBuilder.swift */; }; 57AC6E68683D2ABB49A4B67E02411534 /* DTExtendedFileAttributes.m in Sources */ = {isa = PBXBuildFile; fileRef = 56E8939081946D090FD77A04F879754C /* DTExtendedFileAttributes.m */; }; 57D5228C8A85628772D4D78E7FBEAF3C /* DTDictationPlaceholderTextAttachment.m in Sources */ = {isa = PBXBuildFile; fileRef = 8DA0F94C0870C1F0DFADE5E9045E5E6E /* DTDictationPlaceholderTextAttachment.m */; }; 584975F754B7B0F6773141E5788D9002 /* DTCoreGraphicsUtils.h in Headers */ = {isa = PBXBuildFile; fileRef = 2C1A7359A45BB880877A672C57F522FE /* DTCoreGraphicsUtils.h */; settings = {ATTRIBUTES = (Public, ); }; }; - 58AAD72FB743C681166B895694907776 /* RDEPUBReaderController+DataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 449E7EAE1D47E608A23C4994EA5F9D4C /* RDEPUBReaderController+DataSource.swift */; }; - 592ED452831CE7352E921389136EA40C /* ZIPFoundation-umbrella.h in Headers */ = {isa = PBXBuildFile; fileRef = B41F7D27C2336F89BB0185ACB0B19EA9 /* ZIPFoundation-umbrella.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 58CB40846060096DCEBFF452787552E9 /* RDEPUBChapterCacheKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DE016AAB61669B1F22DA37005C9CCC7 /* RDEPUBChapterCacheKey.swift */; }; 597DE4CDF5C674E269F03F6AA0AFFCC1 /* NSAttributedString+HTML.m in Sources */ = {isa = PBXBuildFile; fileRef = 71EE0B4D4D1B3D0A55E903E7E0CA44E7 /* NSAttributedString+HTML.m */; }; - 59E0947D80CB9A9384CF0CF0473828BF /* RDEPUBReaderServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B4326BC8EF36B8BB0A32D8E047921AD /* RDEPUBReaderServices.swift */; }; - 5A1492BFEF8BB10D35DE16AFED75ED1C /* FileManager+ZIP.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C3F8DF48C6EB28BFD05F5BB49793DDA /* FileManager+ZIP.swift */; }; - 5B649FCED26B4794B5AAAF837A3E9DBC /* wxread-replace-latin.css in Resources */ = {isa = PBXBuildFile; fileRef = C11CE525D0B420BD1FB8E289158F411D /* wxread-replace-latin.css */; }; - 5B8F73FF4F5FAEFD4DAD9A83F974DF47 /* RDEPUBPaginationModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = C86BF6C6A415B964A3F3B66B76DE9F0E /* RDEPUBPaginationModels.swift */; }; + 5A1F91D233524561D955A9A8C469C597 /* RDEPUBFontFallbackResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 344F132625D1DB107D6F88F810131154 /* RDEPUBFontFallbackResolver.swift */; }; + 5AB3B36FCE78B63E84BE2044872A9AD5 /* RDEPUBRuntimeChapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A05983FAA61E2029A29085B98DEDC010 /* RDEPUBRuntimeChapter.swift */; }; 5CAF56EF5E99F0788C26CB4C52182EDD /* NSString+DTPaths.h in Headers */ = {isa = PBXBuildFile; fileRef = B76B1E836C34CB755AEE1FBF7DC26BDD /* NSString+DTPaths.h */; settings = {ATTRIBUTES = (Public, ); }; }; - 5CDE2B7D11CFF775C358BD68412E0F45 /* RDReaderGestureController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C859102801709085785434DA17448C /* RDReaderGestureController.swift */; }; + 5CEAFB1EE890C110409F5D157012C0E7 /* RDEPUBTextPageDecorationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1251AEBAF40F77167C2B57D0A466DAA4 /* RDEPUBTextPageDecorationView.swift */; }; 5D03A5B9DC067875CFEC9D4EA7B059E8 /* DTCoreTextLayoutFrameAccessibilityElementGenerator.h in Headers */ = {isa = PBXBuildFile; fileRef = 671C2429406FDB36B677F4B6EC85B52F /* DTCoreTextLayoutFrameAccessibilityElementGenerator.h */; settings = {ATTRIBUTES = (Public, ); }; }; - 5D684BD54AB74B7C825E7BCB4834B3BA /* RDEPUBReaderTableOfContentsItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B2E80F85DC4DFBB0320EB582FCBD933 /* RDEPUBReaderTableOfContentsItem.swift */; }; 5D8D65121E87E419D8D2F23F8AF43673 /* DTColor+Compatibility.h in Headers */ = {isa = PBXBuildFile; fileRef = 68D81DF1BB2A14F9C186F75BBA5CF50D /* DTColor+Compatibility.h */; settings = {ATTRIBUTES = (Public, ); }; }; 5DBD89A108305B3DAE1D415BE9CBFEC2 /* NSData+DTCrypto.h in Headers */ = {isa = PBXBuildFile; fileRef = 94705DADD1F09F4162F7D733318238F4 /* NSData+DTCrypto.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 5E24441D23EA52049F2E955644FF13D7 /* RDEPUBCFI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 442D5E8B0118B18E067F09D23CB652DF /* RDEPUBCFI.swift */; }; 5E90EBD876ED4E50AB890FB56C4BFA57 /* DTHTMLParserNode.h in Headers */ = {isa = PBXBuildFile; fileRef = D28A3E79D83EA4B807E2D3A5EE086B8B /* DTHTMLParserNode.h */; settings = {ATTRIBUTES = (Public, ); }; }; - 5EFC094F491AADC10F5093D9836796AD /* RDEPUBBookPageMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28F849C864C21B91AE83565813ECE513 /* RDEPUBBookPageMap.swift */; }; 5FA1ED066966420F71E6B562C351B5DE /* DTBlockFunctions.m in Sources */ = {isa = PBXBuildFile; fileRef = 6D2316F422A6C97F95F9CF43E6F8E94F /* DTBlockFunctions.m */; }; 60532A2E06CCFF814933D2FAE6793AE9 /* DTCoreTextLayouter.h in Headers */ = {isa = PBXBuildFile; fileRef = 72225FB74594FCF18DA8064E7DB3947E /* DTCoreTextLayouter.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 60721E7D396AE3937C7133AB576FD883 /* String+RDEPUB.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0CF886110ECBE6539FAF53C10B1498A /* String+RDEPUB.swift */; }; 61BE3607EABD2657BC71AF8ACB1C25D4 /* DTHTMLAttributedStringBuilder.h in Headers */ = {isa = PBXBuildFile; fileRef = 4E1066A8FE63AD2C818E26DDCE62DF58 /* DTHTMLAttributedStringBuilder.h */; settings = {ATTRIBUTES = (Public, ); }; }; - 63C12C69A76CBC3CABBE2399D103B537 /* RDEPUBChapterWarmupOrchestrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D246168B830810582866BFF9209DBFD3 /* RDEPUBChapterWarmupOrchestrator.swift */; }; - 6421A89E3EF3FC54EE02EF965D4C0C1B /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B50104DE2E35D820ECA22CDB3E6F96EA /* Foundation.framework */; }; - 64273BE1153320103A7990C6742E9A11 /* RDEPUBSearchModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C6F4C9D48FC61243E5E405457EB1AB3 /* RDEPUBSearchModels.swift */; }; + 6277845A6CCAAA5F160D86F6AA458341 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B50104DE2E35D820ECA22CDB3E6F96EA /* Foundation.framework */; }; + 63A5CC78A647FBD1141999BBB2BDF21B /* RDEPUBReaderController+RuntimeBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A48F69748B05758B4CB62757E8CF6464 /* RDEPUBReaderController+RuntimeBridge.swift */; }; + 64AD1C8FACAD892C74600ECB3814FB7E /* RDEPUBBookPageMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = E935E1C0DB2E3250D48D6F7B1F1785EB /* RDEPUBBookPageMap.swift */; }; + 64E474FDB28F0C3CDAC624A41775468A /* RDEpubReaderView+ToolView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE76CB44588C6BA77E5E95CFF23BD68C /* RDEpubReaderView+ToolView.swift */; }; + 65207A50AA57064D307EC65F01B1FED2 /* RDEpubReaderView+ContentAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8FEC8336CD4C54BF17C89ED0AEFC376 /* RDEpubReaderView+ContentAccess.swift */; }; + 65826528B7322AC659BE2731BF56C4D7 /* RDEPUBCFIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AF5C2B69E3DDD7DB21E7A3498D81D3B /* RDEPUBCFIError.swift */; }; + 65F348EA65F8B9228DDCB4CE85A41CE4 /* RDEPUBReaderPersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1939CE18FB1D0E9342A3963CB1F5C84 /* RDEPUBReaderPersistence.swift */; }; 6634D568E49F5610242E1454680E5885 /* NSAttributedString+SmallCaps.h in Headers */ = {isa = PBXBuildFile; fileRef = CACF20969583778CAC1BC92BB96F27FD /* NSAttributedString+SmallCaps.h */; settings = {ATTRIBUTES = (Public, ); }; }; 6654C4AD722864C4EE65934D55682253 /* NSArray+DTError.h in Headers */ = {isa = PBXBuildFile; fileRef = C7E09A6C247D465CA0EF71B939B2A040 /* NSArray+DTError.h */; settings = {ATTRIBUTES = (Public, ); }; }; 67AE2AC8D9B961D45871077774E11FD3 /* DTObjectTextAttachment.h in Headers */ = {isa = PBXBuildFile; fileRef = 13597E7190F2C08C5906D7D128E43E99 /* DTObjectTextAttachment.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 683ED28788D2B1078B5D3E5EA8B247DC /* Pods-ReadViewDemo-dummy.m in Sources */ = {isa = PBXBuildFile; fileRef = AA0811310099A4BCB3D52683B70DB3D1 /* Pods-ReadViewDemo-dummy.m */; }; 684BDD6CF6B83CB31C2C96E7172241FD /* DTFolderMonitor.h in Headers */ = {isa = PBXBuildFile; fileRef = A8CC38EFEF171FFEE008C32A6319BE6A /* DTFolderMonitor.h */; settings = {ATTRIBUTES = (Public, ); }; }; - 686B52ECD457E564858A3DEAA7829290 /* RDEPUBReaderToolView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFAE45005F7BB7D9114003D6F4AA87A /* RDEPUBReaderToolView.swift */; }; + 685918559B0E72379697A83A0CAB97BB /* RDEPUBChapterTailNormalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F5DA3828400FEDE014E6CEF4A66E4F8 /* RDEPUBChapterTailNormalizer.swift */; }; 69674FEA70899F07BE6098AAEC849978 /* DTAccessibilityViewProxy.m in Sources */ = {isa = PBXBuildFile; fileRef = 6AC1E707308CDBC9FFC09CECE3117A9A /* DTAccessibilityViewProxy.m */; }; - 6974A64C25319EC60A3982F864FC01B2 /* Archive+WritingDeprecated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2373DCA0FC45287BFC063BEA661B8367 /* Archive+WritingDeprecated.swift */; }; - 69E7F1738637501925C258F4134F96A4 /* RDEPUBReaderDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9265A063637BD77FC50A93888BBAB11E /* RDEPUBReaderDependencies.swift */; }; - 6B497C1FA166F5DF043609FAC62C7C4B /* RDEPUBTextBookBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6F1C6F5695FAAA4BA13936F58275B89 /* RDEPUBTextBookBuilder.swift */; }; - 6BB57211BF6B933B65B0ED6CC81D2337 /* UIColor+RDEPUBHex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 165E51AE35F6043DF9684A794A48F348 /* UIColor+RDEPUBHex.swift */; }; + 6A9D9A375EC9BC31FA823F34FFFE4B46 /* RDEPUBReaderAnnotationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376805E5E5095E2902BB687236336C0F /* RDEPUBReaderAnnotationCoordinator.swift */; }; + 6C1327C994F11FDB4EDBDA5CE598CF11 /* Data+Serialization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C3615C94C147AEA92F022E119810E9F /* Data+Serialization.swift */; }; 6C2BF3E69D66D8866F24DB24494E7A3C /* DTFoundationConstants.m in Sources */ = {isa = PBXBuildFile; fileRef = 0F80AC45FB1DBF50E0E3AA4F26BFA676 /* DTFoundationConstants.m */; }; + 6C4C16CD6AFC19E5AE7CB35224BA5C92 /* RDEPUBTextLayouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D7FE9BC0B7335F2AB850FC2EC727ACD /* RDEPUBTextLayouter.swift */; }; 6E3D0FDD78ECC20F4B3697ED21D64224 /* MediaPlayer.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4C2B218B5EAA5831A036BBF7E1EFC1B9 /* MediaPlayer.framework */; }; - 6EA06E7FDDBB0E120C746B85A3F75151 /* RDEPUBRenderRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49FF2BFED217D10DD3A12046FF81C511 /* RDEPUBRenderRequest.swift */; }; - 6EC7A64049EF9D2841493E494652993D /* epub-fixed-layout.html in Resources */ = {isa = PBXBuildFile; fileRef = F91BABC77F3FC64BDACE8400F2A1808B /* epub-fixed-layout.html */; }; - 6F3F15A3FBEF30BE7E78EA76BB1F8927 /* RDEPUBReaderRuntime.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6220F23ADB2582C577A0C44BA52657D /* RDEPUBReaderRuntime.swift */; }; + 6F6E1D10B2D36F1BF24AA22D3EF2053D /* RDEPUBViewportTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3B3807F1E6648C733C6F915288C1E45 /* RDEPUBViewportTypes.swift */; }; 6FA0125F16DDEDAD341DB75DEB084F4B /* DTHTMLWriter.m in Sources */ = {isa = PBXBuildFile; fileRef = B23C5F038DAC52B75213C0760C431F2B /* DTHTMLWriter.m */; }; 7027EF8D77D4082C1BB9B80FF61FCADC /* DTCoreTextLayoutFrame+Cursor.h in Headers */ = {isa = PBXBuildFile; fileRef = 214F0FC87DE99D3FCE69D98B357F3FFC /* DTCoreTextLayoutFrame+Cursor.h */; settings = {ATTRIBUTES = (Public, ); }; }; - 714A34F8A6CF051D194F7EB767F30ED7 /* ZIPFoundation-ZIPFoundation_Privacy in Resources */ = {isa = PBXBuildFile; fileRef = CA4A114775B98CEA4A566C18123B145F /* ZIPFoundation-ZIPFoundation_Privacy */; }; - 7166B6127AC8ED73A5FF3A2351CC80B3 /* RDEPUBSelectionOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A18AC71FCC0D1D4F1FF1E6EAB3D229F0 /* RDEPUBSelectionOverlayView.swift */; }; + 70F233D5255EDA1579EEC9530945F1B5 /* RDEPUBPageInteractionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00385D2EEBD20524770BBB6A89C4DB3D /* RDEPUBPageInteractionController.swift */; }; 730ACB99580C5982DC08D50FC5084D41 /* DTAttributedTextView.h in Headers */ = {isa = PBXBuildFile; fileRef = B4D20A2B33F9F4DD8499FB462DE2731F /* DTAttributedTextView.h */; settings = {ATTRIBUTES = (Public, ); }; }; - 733DA50F18319A91EDBECA4E02C0F7A2 /* RDEPUBReaderController+Orientation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87FAC4BEFBDC1FD8E9824724A68800F1 /* RDEPUBReaderController+Orientation.swift */; }; - 73BA62E3AE9D75624A414EA841AD187C /* RDEPUBReaderAssemblyCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10956FC20C49EDDBFC8BABCC6EB098A /* RDEPUBReaderAssemblyCoordinator.swift */; }; - 7408D293C24C3BD9CBBCBB530B50938B /* Pods-ReadViewDemo-dummy.m in Sources */ = {isa = PBXBuildFile; fileRef = AA0811310099A4BCB3D52683B70DB3D1 /* Pods-ReadViewDemo-dummy.m */; }; 7459A1656EDC29D685073B83561B783E /* NSNumber+RomanNumerals.h in Headers */ = {isa = PBXBuildFile; fileRef = 7CFFB65C5D340A4B5C627B3EF02CD127 /* NSNumber+RomanNumerals.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 745A241FBA65FD761B09197560A4888C /* RDEPUBReaderController+ContentDelegates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E81140A7F083D951F3E912FC03A1A3B /* RDEPUBReaderController+ContentDelegates.swift */; }; + 751C87AD55AC0B89BD55CB0A84659855 /* RDEPUBFragmentMarkerInjector.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C8585AECE0BC205EE95CC484EC6561 /* RDEPUBFragmentMarkerInjector.swift */; }; + 7561605CCF229D3B47E4F7A4214E2495 /* RDEPUBReaderBottomToolView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A49A27A2F307647B468A9652F7A49006 /* RDEPUBReaderBottomToolView.swift */; }; + 75752230AC2DC5B19D73E7F5719C4AE6 /* RDEPUBReaderToolView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA4EC97EE53FBDB24B5FB92C7FE4C7C4 /* RDEPUBReaderToolView.swift */; }; + 75918C499D6B1483FBEBE1B0BC06AD53 /* RDEPUBParser+TOC.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1E86313FAB2369F42BBAC7FE6CAF40B /* RDEPUBParser+TOC.swift */; }; + 75AD0B7A20FF5E8B71750D60477625D3 /* RDEpubReaderPreloadController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E293D93795FE3F3AA09E249FBBAF2169 /* RDEpubReaderPreloadController.swift */; }; + 75C0B88152862378FC69366D5556A4F0 /* RDEPUBChapterRuntimeStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63C303ED9417890ADDAC0BED62294CB3 /* RDEPUBChapterRuntimeStore.swift */; }; 76811D5B18D00F172575D6717BF9DA63 /* NSData+DTCrypto.m in Sources */ = {isa = PBXBuildFile; fileRef = F63EBF2BF3000B3391F6E693581EA129 /* NSData+DTCrypto.m */; }; - 769008E585DCB34E7440CE7E9C3E93CC /* RDEPUBTextAnchor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AB8D9D0E868BF4DBDE6255D7F86E252 /* RDEPUBTextAnchor.swift */; }; - 76F2A74233A552DDC5AEDFC4C1D291F9 /* RDEPUBReaderConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27A8E420B22CCB314B97FF093673D7A7 /* RDEPUBReaderConfiguration.swift */; }; + 76EC3A1185B131D3A341FBB84A7AB936 /* rangy-serializer.js in Resources */ = {isa = PBXBuildFile; fileRef = 8D43BF19A7479296AA8324BFCE1BE87C /* rangy-serializer.js */; }; + 7787B4AA97C24C63E120606C94DA300E /* Pods-ReadViewDemo-umbrella.h in Headers */ = {isa = PBXBuildFile; fileRef = EBA0272559B0F7FAD8AC670AC7431188 /* Pods-ReadViewDemo-umbrella.h */; settings = {ATTRIBUTES = (Public, ); }; }; 7821105EF320619993B3BF7FB90C7ADA /* DTCSSStylesheet.h in Headers */ = {isa = PBXBuildFile; fileRef = 796EF27FF6AA4B62367B6BD9CEBD85A8 /* DTCSSStylesheet.h */; settings = {ATTRIBUTES = (Public, ); }; }; - 7828DA98DD0559035F8A02686A44A281 /* RDReaderSpreadResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D513DFE7299EE03B0CB5A16A4798152 /* RDReaderSpreadResolver.swift */; }; 7882FB8FD1A57CAEA4E4FDEF4952C621 /* UIView+DTFoundation.h in Headers */ = {isa = PBXBuildFile; fileRef = 29DEA4D201BB179BB6E228E9F688CD4C /* UIView+DTFoundation.h */; settings = {ATTRIBUTES = (Public, ); }; }; 78D24D82D98D77E744FF73C9040618A8 /* DTHTMLParserNode.m in Sources */ = {isa = PBXBuildFile; fileRef = 5001C74FB9506AEC71E458047698A4AB /* DTHTMLParserNode.m */; }; - 78FF4557AC434319860AD771F26E2697 /* URL+ZIP.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6430501430329643C8280B3985C48711 /* URL+ZIP.swift */; }; - 7953853E6BE17879EBFCECF9551634A5 /* RDEPUBTextPageDecorationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12B21DE048B2D9B7F8A8075D627B518F /* RDEPUBTextPageDecorationView.swift */; }; - 7999290DC707639D8F4D2090D3F0878F /* ZIPFoundation-dummy.m in Sources */ = {isa = PBXBuildFile; fileRef = E9D8AB18960B56A997408E3235751624 /* ZIPFoundation-dummy.m */; }; + 7928AE9F98DF003568813C9DC077236D /* RDEPUBWebView+Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CD0CE49F7A82C0DDA84464EA00115A3 /* RDEPUBWebView+Configuration.swift */; }; 799E070BA323793383FA81BA733C4DA5 /* NSMutableArray+DTMoving.h in Headers */ = {isa = PBXBuildFile; fileRef = 3046397381A83C2F6BAF344B86AD176E /* NSMutableArray+DTMoving.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 7A0AD5DDF58A23FABE861D91A5ADC52A /* RDEPUBReaderState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A58A47E8A2EEF76C3ED73CB54A086C0 /* RDEPUBReaderState.swift */; }; 7A48CDFB40202EFD09A2A9AB0EE0770D /* DTCoreTextFontDescriptor.h in Headers */ = {isa = PBXBuildFile; fileRef = 1EF19AD0EF4889156D46061FB43E0F5D /* DTCoreTextFontDescriptor.h */; settings = {ATTRIBUTES = (Public, ); }; }; - 7A5AC3C3A3C1BF6B0DA2090F55970D2D /* RDEPUBPageCountCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CD96965442A5F874F9CE6864437A3F7 /* RDEPUBPageCountCache.swift */; }; - 7B09D184EAF3D5886E4B91AE9966FB48 /* RDEPUBCoreTextPageFrameFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = F34C3AB5B2B7CFAED280AB097BF51C21 /* RDEPUBCoreTextPageFrameFactory.swift */; }; - 7B3A17CC46FA908545BBB3CA206702FF /* RDEPUBReaderController+RuntimeBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCADA127F117763F7DC1E99EC687D9F1 /* RDEPUBReaderController+RuntimeBridge.swift */; }; + 7BBB76FF5902D531FC2DEE7596B75DDA /* RDEPUBReaderController+TableOfContents.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED2AB892BC893DB186CD0AFA5CA63F61 /* RDEPUBReaderController+TableOfContents.swift */; }; 7C05F56B62B15EFF344C5CDE4A52B8FB /* DTHTMLElement.m in Sources */ = {isa = PBXBuildFile; fileRef = 7A4E9E54E556A72BC885CCC08B3791C1 /* DTHTMLElement.m */; }; - 7C39856468D7910473771D4D1BAC42B0 /* RDEPUBMetadataParseWorker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C49C3D57B022D024E68EDB3EF68D60F /* RDEPUBMetadataParseWorker.swift */; }; - 7D527AEF8B4001ADDEE64E24958BA9E6 /* RDEPUBStyleSheetComposer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 485EAD6F9C3A2212CF6CE024BA2FA1A6 /* RDEPUBStyleSheetComposer.swift */; }; - 7EA5C0BFEA6EFF3AFBA0543F64526A3A /* RDEPUBChapterDisplayContentCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B6CD2AB0DCDA662CD642765E8A5BE01 /* RDEPUBChapterDisplayContentCache.swift */; }; + 7D456A8374BD4A23821CFFB07DDCF583 /* RDEPUBReaderSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66CF2D711A2AC7E60FFD77AD510F8C2E /* RDEPUBReaderSettingsViewController.swift */; }; + 7E42B74D6ADBBF89D80AC2B85BEDC30D /* RDEPUBImageViewerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F57646CB4F9349BC9776772BFDC56B49 /* RDEPUBImageViewerCoordinator.swift */; }; + 7F30613BF7FFC301FA627D3A4FB1F0C5 /* RDEPUBMetadataParseCancellationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65144E0A92FF19D145E244C61896601B /* RDEPUBMetadataParseCancellationController.swift */; }; + 7F4FB7F34D880ECD442F847FA71AD492 /* RDEPUBCFIParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB1327FE6CC30C4AC4A5124BEB747510 /* RDEPUBCFIParser.swift */; }; 7F9CC306B17D409DB903DF850FEDA64C /* DTListItemHTMLElement.m in Sources */ = {isa = PBXBuildFile; fileRef = 61B18088896D291E0CE7718DFE82FAC1 /* DTListItemHTMLElement.m */; }; - 804E70CA6C95A89DD7997DDD48370800 /* RDEPUBChapterLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07EC70D349899B89F4546FFE4DE46EEC /* RDEPUBChapterLoader.swift */; }; 8099F1E613A15275EF5FC59ED3C73579 /* NSAttributedString+HTML.h in Headers */ = {isa = PBXBuildFile; fileRef = 7805DFC2CB71F5B6BBF7891876360EFB /* NSAttributedString+HTML.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 80FC00E351AC0D6D2F46E4394E929CF6 /* RDEPUBCFIUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E7113A77F93EA57B195F8A039935FF3 /* RDEPUBCFIUtilities.swift */; }; 811D7CBCD710AE45FF80A4B05BFE444C /* UIFont+DTCoreText.m in Sources */ = {isa = PBXBuildFile; fileRef = CEBA01360577AAD3FD089B6B17C24260 /* UIFont+DTCoreText.m */; }; 814D0D63E5FD4AB7231E63064FE9EB5C /* NSAttributedString+DTDebug.m in Sources */ = {isa = PBXBuildFile; fileRef = 5096458D9BD2818F786BC3B61B91EAC7 /* NSAttributedString+DTDebug.m */; }; - 815AD7AF46039FC8352B50AF272F102B /* RDEPUBWebView+Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EAA04F0E10B06A24072628253625784 /* RDEPUBWebView+Configuration.swift */; }; 81676AD4528CF268CB913CF3F27E00A2 /* DTExtendedFileAttributes.h in Headers */ = {isa = PBXBuildFile; fileRef = 2F75437CE9F9324D315E417014797C1D /* DTExtendedFileAttributes.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 8179F8F50EDBD602A3A05A57D2738C4F /* RDEPUBPaginationModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC0B33BDB6F7F3F950944CB3E69CDA68 /* RDEPUBPaginationModels.swift */; }; 81905A7717925A17E6B98E934A75B8BA /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0834E84632886CDD9911CAB76183F1CE /* QuartzCore.framework */; }; + 81F6BC0B7D2391F2FE65A174AFCBCBCB /* RDEPUBCFIResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C12125CE2086E4553539302D7B12BB3 /* RDEPUBCFIResolver.swift */; }; 82119A0DC4981CC2AF389C7C4CC08186 /* DTListItemHTMLElement.h in Headers */ = {isa = PBXBuildFile; fileRef = 5DB480D0B78911DBF96986E64A714907 /* DTListItemHTMLElement.h */; settings = {ATTRIBUTES = (Public, ); }; }; - 82B1DCCA45467E31302FFFD7EB78EF08 /* RDEPUBTextContentInteractionCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0634E78287117CD08963FC5011711674 /* RDEPUBTextContentInteractionCoordinator.swift */; }; 83A4BC9FE4765C9B848DD4925D99F362 /* DTAccessibilityElement.h in Headers */ = {isa = PBXBuildFile; fileRef = 5D146C3F4B7878B5CE8EFF38765580C2 /* DTAccessibilityElement.h */; settings = {ATTRIBUTES = (Public, ); }; }; - 87CE9E7A1DFE5341BF42FD146FC7CC83 /* Date+ZIP.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18D23D8D10719754D93032F66EB98536 /* Date+ZIP.swift */; }; - 8820CABB87F28D4AA4F13165DEA40BF2 /* RDEPUBChapterTailNormalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3124D3BC58A0A9244ACC822A8BF0860E /* RDEPUBChapterTailNormalizer.swift */; }; - 886CBFD7FC5AD3F76DDA53D178447786 /* RDReaderView+CollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE12B04784ACE77BDF6F920F30699A70 /* RDReaderView+CollectionView.swift */; }; + 876E49146880469D4CE3D5232EE6FC90 /* RDEPUBNoteModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 272CA6E32EEAE8E81504F1CD3000E925 /* RDEPUBNoteModels.swift */; }; + 879859E12B2187F26613F5158A9AF2EC /* RDEPUBChapterWarmupOrchestrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C746B6D8EF063C2B6C54882895756D6 /* RDEPUBChapterWarmupOrchestrator.swift */; }; + 87A23E9C163C19613C2643A27F192AA3 /* RDEPUBReaderController+DataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38887F7296CA064DFC5B3ABBE16EE16C /* RDEPUBReaderController+DataSource.swift */; }; 88F2D3A8852C62750F27E9DDBACC5E80 /* DTLog.m in Sources */ = {isa = PBXBuildFile; fileRef = 199E2ECC77BDAC03F644C20040F042D4 /* DTLog.m */; }; - 899844C021BD16F2361E471087949E10 /* RDEPUBReaderTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE17B2F690F6065BB982FA37EB9DF24C /* RDEPUBReaderTheme.swift */; }; + 890E2633C30FA49F9947BF7956F24175 /* RDEPUBTextPageRenderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 698DD735E4FB33C89CEA4216C18CD31E /* RDEPUBTextPageRenderView.swift */; }; 89B3A1D41C047AB90325994EEBFFC141 /* NSMutableArray+DTMoving.m in Sources */ = {isa = PBXBuildFile; fileRef = ADD5B2F66FCCFB58393314E7B9FE4384 /* NSMutableArray+DTMoving.m */; }; 8A0A0FB23DB0021ADC9AD0D90631883A /* NSMutableString+HTML.h in Headers */ = {isa = PBXBuildFile; fileRef = 52053ABC9CD3D4A72CD8A22173428AEB /* NSMutableString+HTML.h */; settings = {ATTRIBUTES = (Public, ); }; }; 8A35CC67F0AA6B26397C10D756EA34E8 /* DTTextHTMLElement.h in Headers */ = {isa = PBXBuildFile; fileRef = ED9A1EBDA0FD72671C7F6FE15F37B906 /* DTTextHTMLElement.h */; settings = {ATTRIBUTES = (Public, ); }; }; - 8A99D64B1FFAA4754D40D044A9996A81 /* wxread-default.css in Resources */ = {isa = PBXBuildFile; fileRef = E169B071EF0569FFFECA74A68DA4ACB4 /* wxread-default.css */; }; 8AC90941E71199090BCED76DE89EFB60 /* DTAttributedTextView.m in Sources */ = {isa = PBXBuildFile; fileRef = F46010C3A0C82AE5ABEC814D7EC6351B /* DTAttributedTextView.m */; }; - 8ACD0FAB855BF0922BAA860243DEDEDD /* RDEPUBWebDecorationOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7540C99450185F3D8B99D75A4C065F4 /* RDEPUBWebDecorationOverlayView.swift */; }; 8B405CE13E7D718A9340A82B52766AB2 /* NSCharacterSet+HTML.h in Headers */ = {isa = PBXBuildFile; fileRef = A7E167717A18E0C858C6FD95804249E4 /* NSCharacterSet+HTML.h */; settings = {ATTRIBUTES = (Public, ); }; }; - 8BA9A995A14E1CBFB04D036C6722B01B /* RDEPUBAttachmentNormalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1429432AAB58A7FBEEA14E2716F7D97E /* RDEPUBAttachmentNormalizer.swift */; }; - 8C18DD5EEE9BE69DA1780604BFA096AD /* RDEPUBAttachmentTooltipView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89A5207DD90D0FB2512C9B23FEEB1A02 /* RDEPUBAttachmentTooltipView.swift */; }; - 8CD67A11ACBEDE5F66DB9508B021BBF9 /* RDReaderView+ToolView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89A9008EF0A17C27F6EA9F37EF6DCFD0 /* RDReaderView+ToolView.swift */; }; + 8BD146DDAFEE91B40B1946154580BE95 /* RDEPUBPaginator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F88EF2BFEFAC452A2D6082C661041929 /* RDEPUBPaginator.swift */; }; + 8BF9CE9063E06BB318FE214E669391AD /* RDEPUBSelectionOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D94BA24C3E1653B2ADF0961B2DFD09F /* RDEPUBSelectionOverlayView.swift */; }; 8E71545C934B03B3131BD995D43D18BD /* DTCompatibility.h in Headers */ = {isa = PBXBuildFile; fileRef = 8F653EFBBEDAAC96A34BC93B4211E792 /* DTCompatibility.h */; settings = {ATTRIBUTES = (Public, ); }; }; 8E8726381CBCFB1EDB350183456D4EF7 /* DTHTMLParserTextNode.m in Sources */ = {isa = PBXBuildFile; fileRef = E5377786E80195587BF3E8A7589E5925 /* DTHTMLParserTextNode.m */; }; 8E909D0FE7B6AB696AA03294AC9DEA23 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B50104DE2E35D820ECA22CDB3E6F96EA /* Foundation.framework */; }; - 8EB24D1CF95A90D98DBD5C1F41443429 /* RDEPUBTextBuildPipelineInterfaces.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D0D9087DAE27DCDC6CB950E20E3608 /* RDEPUBTextBuildPipelineInterfaces.swift */; }; - 8F2886944A775846FDCC6C64D78E6414 /* RDEPUBChapterPageCounter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22887CE9C9E366BD11055E6B101C9A00 /* RDEPUBChapterPageCounter.swift */; }; - 91B95F181020FC644E5508803BAC5BEC /* RDReaderPagingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9634274EF4914F6FCC9A1546501F2843 /* RDReaderPagingController.swift */; }; + 8EB2C64782A5B3E28B464EC7DC62E941 /* RDEPUBWebView+Reflowable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B06436C03160A6968FEE44D6C916F2E /* RDEPUBWebView+Reflowable.swift */; }; + 8F082DD6458E2C5F76062E7D7FAF121F /* RDEPUBReadingLocationModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98D44DA052097D1223C075A582A6E1C7 /* RDEPUBReadingLocationModels.swift */; }; + 91052B4F93F977458CC1A5EA78DF1DEE /* FileManager+ZIP.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C3F8DF48C6EB28BFD05F5BB49793DDA /* FileManager+ZIP.swift */; }; 91FDA47810CE5CE2C48AED9205AE89B5 /* DTStylesheetHTMLElement.m in Sources */ = {isa = PBXBuildFile; fileRef = 354C1C3A74DB75C8B8608D538212A963 /* DTStylesheetHTMLElement.m */; }; + 920A2A7EF5F2915D271D229AA0E895DD /* Date+ZIP.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18D23D8D10719754D93032F66EB98536 /* Date+ZIP.swift */; }; + 929443ED1ADB72355635234DDF0923C0 /* RDEPUBReaderController+Orientation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A1B3AAEE7814FA72D7C528E10D49C3C /* RDEPUBReaderController+Orientation.swift */; }; 92C59F96D443525E8833676479FA60C5 /* DTVideoTextAttachment.h in Headers */ = {isa = PBXBuildFile; fileRef = 50C2868064310B16D2B5FD9901C846DC /* DTVideoTextAttachment.h */; settings = {ATTRIBUTES = (Public, ); }; }; - 92D1AACEA741834540D7E848D1ECBCB0 /* RDEPUBReaderController+ExternalLinks.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3BC3FFA41C912BB401CBEFA2A9D9964 /* RDEPUBReaderController+ExternalLinks.swift */; }; 9317D936D1624DAFFC0665186084B9A7 /* DTBase64Coding.h in Headers */ = {isa = PBXBuildFile; fileRef = 519087D9CD0F5E440E6D2C3B1AA1DFA2 /* DTBase64Coding.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 934D9A4D7F0455ABD8C4CEC056AD1F53 /* RDEPUBTextRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8612BCF374E10CB4503D16E19A381417 /* RDEPUBTextRenderer.swift */; }; + 93FF39CA95B10B233EA1419CF95ED174 /* RDEPUBReaderSearchCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC9324EF18C7802D6401A32D6B5C2CC /* RDEPUBReaderSearchCoordinator.swift */; }; 94A660CFA88E3A99BA840BC7C081FAF5 /* DTFoundationConstants.h in Headers */ = {isa = PBXBuildFile; fileRef = FF63F5E8902D6F3F5E845A924EBBC6D3 /* DTFoundationConstants.h */; settings = {ATTRIBUTES = (Public, ); }; }; - 94C9A09C6EF77DEDD1BD7E9DB8858F40 /* RDEPUBParser+Resources.swift in Sources */ = {isa = PBXBuildFile; fileRef = 104CAC07B59B05601D706C051CFB2EB3 /* RDEPUBParser+Resources.swift */; }; 94F5846979DBC90E2B646E975F5ECBA8 /* DTHTMLWriter.h in Headers */ = {isa = PBXBuildFile; fileRef = 14E63A0D35D4A07BD0123021EA161C99 /* DTHTMLWriter.h */; settings = {ATTRIBUTES = (Public, ); }; }; - 952F81C78437BA95611EBDD505BFF38B /* RDEPUBReaderController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 169DD70FDD7715783E8DD88ADB49E35E /* RDEPUBReaderController.swift */; }; - 972B52AF13D8A6B21127218321F0FD01 /* RDEPUBChapterDataCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54AF51CFB19FCEF5B3306518C6B311A0 /* RDEPUBChapterDataCache.swift */; }; + 9502E9B316CAD4D98520953DA4915B21 /* RDEPUBReaderController+RenderSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = F344665C57510633A0980B1F2F023A2F /* RDEPUBReaderController+RenderSupport.swift */; }; + 96822D638C109FD3B0248756D3C49054 /* RDEPUBCFICompatibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 915069D65E023E6D8140D847BBCAEF3A /* RDEPUBCFICompatibility.swift */; }; + 968B20B8DFA56F0E43056D8B437D22F8 /* RDEPUBReaderContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA19A96B26E75A87DFA7F1E213474E48 /* RDEPUBReaderContext.swift */; }; + 9760E98CC0951B6B8C058FB45926203A /* Archive+MemoryFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFF51E56E09E095C92CB5B9F21384027 /* Archive+MemoryFile.swift */; }; 97FF7E96537BE785FC7D2BE9D3A6F48D /* NSString+DTFormatNumbers.m in Sources */ = {isa = PBXBuildFile; fileRef = C5005A9E8F746647585CCAB26853E922 /* NSString+DTFormatNumbers.m */; }; 9854DC3F1763B6272C2EA3FC6EC40B95 /* NSAttributedStringRunDelegates.h in Headers */ = {isa = PBXBuildFile; fileRef = 30EB4CF4AD4114DE53786C318621EB19 /* NSAttributedStringRunDelegates.h */; settings = {ATTRIBUTES = (Public, ); }; }; 987538B07E11741899878EA8BC947F51 /* NSCoder+DTCompatibility.h in Headers */ = {isa = PBXBuildFile; fileRef = A592A34CA2EA792DDFAFAEB516FE8A10 /* NSCoder+DTCompatibility.h */; settings = {ATTRIBUTES = (Public, ); }; }; - 989CA2B28ED197B0B797FE317BFAC14B /* RDEPUBReadingLocationModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C2C269173C1D497D6810C8BF62E7338 /* RDEPUBReadingLocationModels.swift */; }; - 98AEEF2F4FFB0DB64232732D150F2D69 /* RDEPUBReaderUIState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C5BF48946F992DC481016853F217CE2 /* RDEPUBReaderUIState.swift */; }; - 98C2A1EEBEFCC4EBD5C75BF45C4E76A5 /* RDEPUBPresentationRuntime.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6F13727884F1901CB2C0D4FFABB1763 /* RDEPUBPresentationRuntime.swift */; }; - 98D38E0AAD5F1C91FF9EF1757BDDBF1F /* RDEPUBCFITextAssertion.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9D122DED12EE5B85539805AB7010B32 /* RDEPUBCFITextAssertion.swift */; }; 9917E9B804CD33AF3BE2A992A3510C97 /* NSNumber+RomanNumerals.m in Sources */ = {isa = PBXBuildFile; fileRef = 6E21B10EE150F00F245D297EAB7F2BA2 /* NSNumber+RomanNumerals.m */; }; 992B7C8C824B5EA190EF1DB096064DB7 /* DTDictationPlaceholderTextAttachment.h in Headers */ = {isa = PBXBuildFile; fileRef = D0856C3894240A673B5AB7C2704E9144 /* DTDictationPlaceholderTextAttachment.h */; settings = {ATTRIBUTES = (Public, ); }; }; - 99983AF5666F6E31BF186B2171765B6B /* Entry+ZIP64.swift in Sources */ = {isa = PBXBuildFile; fileRef = 288589D0B52F73505B43C743D9837578 /* Entry+ZIP64.swift */; }; + 99E46C19748D231F3D1D030942B6E1B1 /* RDEPUBNoteDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2023C1B0BC6999F982585F30454EF30 /* RDEPUBNoteDetector.swift */; }; + 99E9D689A47495662954CEE4075C05AD /* UIColor+RDEPUBHex.swift in Sources */ = {isa = PBXBuildFile; fileRef = A33FA1A570B5BA2C5DCAB078F31DE5BF /* UIColor+RDEPUBHex.swift */; }; 9A250709DAD9354AF5EC1E2DB111096B /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0834E84632886CDD9911CAB76183F1CE /* QuartzCore.framework */; }; 9ADFD4C5CB7848BF8C877C8F0ECCF627 /* default.css in Resources */ = {isa = PBXBuildFile; fileRef = 47DC174612C98B2CFB4E7D158169139E /* default.css */; }; 9B199D71D2F48B2055A7FDF9BD7051CC /* NSString+CSS.m in Sources */ = {isa = PBXBuildFile; fileRef = 8718F2DB8F0DC085CB012D9996596F3F /* NSString+CSS.m */; }; 9BD146F72B29C703798001F138B069C2 /* NSCoder+DTCompatibility.m in Sources */ = {isa = PBXBuildFile; fileRef = 753994141ACB981C898B36B761321C23 /* NSCoder+DTCompatibility.m */; }; - 9C1D23CBBBF2403B55EF491818672155 /* RDEPUBReaderController+RenderSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CD0B4D61E7169153CE0968E2D6A923C /* RDEPUBReaderController+RenderSupport.swift */; }; 9C4C10EBC3E762CE02A9616E6E64514C /* ImageIO.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FEB14A8F8170777DB7CCC3F27C8EA57A /* ImageIO.framework */; }; - 9DAAA92B75D5A98D770C8379D93C31CD /* RDEPUBReaderController+LocationResolution.swift in Sources */ = {isa = PBXBuildFile; fileRef = 960055C2836F036B9C45375852678BCE /* RDEPUBReaderController+LocationResolution.swift */; }; 9DE04C83A68C7512C0A860FFF5CEC113 /* DTCoreText.h in Headers */ = {isa = PBXBuildFile; fileRef = 8CF7CEB31BED0554F3EF3D3442A89B47 /* DTCoreText.h */; settings = {ATTRIBUTES = (Public, ); }; }; 9E4AB71DE8B3E7218B53B2C8EF6B843A /* DTAttributedTextContentView.m in Sources */ = {isa = PBXBuildFile; fileRef = 7203B4D9D997037E0F9D154A3B049738 /* DTAttributedTextContentView.m */; }; - 9EB0CAE0E721CF799B22772CD72DC159 /* RDEPUBDecryptingImageAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35F49784F5C7572343EA12BD542EC199 /* RDEPUBDecryptingImageAttachment.swift */; }; - 9ECF30F640D739DCEE8C5AD56C2BB4B4 /* Archive+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B9309F0BDDDE2C3EE13C97BF2C0F1E /* Archive+Helpers.swift */; }; 9F77C5130284AF5B3A8E3CC488CCB537 /* DTCustomColoredAccessory.m in Sources */ = {isa = PBXBuildFile; fileRef = 9542AFE3A82C8AE56576254CE196E71A /* DTCustomColoredAccessory.m */; }; - A0077D5C2CBF0E00081C059EFCEC6373 /* RDEPUBSelectionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AB1D76489DD745A028573570A736E59 /* RDEPUBSelectionState.swift */; }; - A05B5109498BFFDAE88FDF254CA1BC5B /* RDEPUBPageResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A188018454C7E42DF794BA1BA829E6FB /* RDEPUBPageResolver.swift */; }; - A0F88E79F3568E7BF0FAB6D00845B559 /* RDEPUBRuntimeChapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96B2BF79370F179010B6481C20FA58C1 /* RDEPUBRuntimeChapter.swift */; }; + A002B3B82F572D8F62C398093EFFC356 /* Entry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D93C2002AAECD29B3ED5A14026D3DFE /* Entry.swift */; }; A132A0C9EB13B65FB886B11AA2E6304C /* NSDictionary+DTCoreText.h in Headers */ = {isa = PBXBuildFile; fileRef = 5C891B983F0CC956CAE3978A15875819 /* NSDictionary+DTCoreText.h */; settings = {ATTRIBUTES = (Public, ); }; }; - A29B6C78057AF83CE0FA325BE0AE95ED /* RDEPUBTypesettingPipeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5FFE0C7676BB044875877B91485384 /* RDEPUBTypesettingPipeline.swift */; }; A29CC045590581A0435ED48791DAB677 /* NSDictionary+DTError.m in Sources */ = {isa = PBXBuildFile; fileRef = 45A67761829BBB3974CE24FE50E459EA /* NSDictionary+DTError.m */; }; A2CA9C27EB2B77637A12723F8CFE7AAB /* NSString+HTML.m in Sources */ = {isa = PBXBuildFile; fileRef = FA107BE47514D01B94B7A2670A9F83E5 /* NSString+HTML.m */; }; + A2FA3F0E9BDA38929CD43EF55BA1F30F /* RDEpubReaderView+CollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 448445FD208B513040C97CF27CA48852 /* RDEpubReaderView+CollectionView.swift */; }; A30D5C1E413A3D319A83D3410A8A549F /* DTCoreTextParagraphStyle.m in Sources */ = {isa = PBXBuildFile; fileRef = B7C4233DF4FA80BF4A9E6FA5082D49E5 /* DTCoreTextParagraphStyle.m */; }; - A4B4E6D986FC4F3E5A37A2667E0EFF66 /* RDEPUBNoteResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3850371BB41E7533AF689FAA5ED33F9B /* RDEPUBNoteResolver.swift */; }; - A4DB0F60CE6C88CA903892606BB2D666 /* RDEPUBReaderLocationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCCAE17086995415A5984D248EA420D6 /* RDEPUBReaderLocationCoordinator.swift */; }; - A5E13B16B918357268ABB9C6105F4379 /* RDEPUBTextRendererSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53A23BC49C365732A2CD9FCC08DEE23C /* RDEPUBTextRendererSupport.swift */; }; - A6455538113656F10265CBA70E89498C /* RDEPUBTextSelectionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F150BC8AD9B0E615F730AEC4CC9098 /* RDEPUBTextSelectionController.swift */; }; + A408F09A616AB7B4107965F03AFE340F /* RDEPUBTypesettingPipeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85EA0348053B11994DBA92C7B6D7E996 /* RDEPUBTypesettingPipeline.swift */; }; + A58BA01C2B741ED36C7B579D82DF9299 /* RDEPUBCFITextAssertion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 423B499E8F7CDADBC49F611A0A4EE4CB /* RDEPUBCFITextAssertion.swift */; }; A72E9F983E5BBFA7485EFB482B80E89E /* DTWeakSupport.h in Headers */ = {isa = PBXBuildFile; fileRef = E687628EF2FE92D093E3005D21B578E5 /* DTWeakSupport.h */; settings = {ATTRIBUTES = (Public, ); }; }; A7D665BBDADBE3D64023FF52E218FCFA /* DTCoreTextConstants.m in Sources */ = {isa = PBXBuildFile; fileRef = E89F199ECDE796B15CAF8E0C1775FAA6 /* DTCoreTextConstants.m */; }; - A8A98F9B70972C0AC1C7FA4C0F42D8CE /* RDEPUBReaderLoadCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E1C8E681EAA73E10C3BE26F8951E5D6 /* RDEPUBReaderLoadCoordinator.swift */; }; - A8F69E3E478FFE8D6AC8DAD05787660C /* RDEPUBChapterLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 750B16C9E2C4617CC27FCF04222EEAEF /* RDEPUBChapterLocation.swift */; }; + A866F128FF261777F443C1CDD8223687 /* RDEPUBTextAnnotationOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC96DD4FDA029CD6D6E810916DEBD563 /* RDEPUBTextAnnotationOverlay.swift */; }; A99834FF2DD87F43190B51C700D3CEA5 /* UIImage+DTFoundation.m in Sources */ = {isa = PBXBuildFile; fileRef = 499E135F1FEB31C26EB0EF793A90086C /* UIImage+DTFoundation.m */; }; - AA3118D4EEB37AEE0A1F82C042A21092 /* RDEPUBCFIMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18CD683F1DDEBEF3EDA621F9D3E95178 /* RDEPUBCFIMap.swift */; }; + AA230AF52990B03AB21C893D18B73E09 /* RDEPUBFontNormalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97E58B2D3994B2247E96226094D6EA5F /* RDEPUBFontNormalizer.swift */; }; + AA33782864FDA1F1F75C7538038F006D /* RDEPUBWebView+JavaScriptBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1633419ACBF4D75136CB54A9595F07AB /* RDEPUBWebView+JavaScriptBridge.swift */; }; AA3722CC8B4992EDA8A9F5E9116835B7 /* DTCoreText-dummy.m in Sources */ = {isa = PBXBuildFile; fileRef = B6CA8CC4525EF64AA5CEE4A722737F1C /* DTCoreText-dummy.m */; }; - AAC5E6A45E892B280F2F507EAA68A2C4 /* RDEPUBTextRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70AAB379D168256A54BC162D9CD200B7 /* RDEPUBTextRenderer.swift */; }; + AB4E2FB516007251FB7794E5BE62552D /* RDEPUBBackgroundTrace.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9F15AEACE475EF232CB992A4CDCC6E3 /* RDEPUBBackgroundTrace.swift */; }; + AC084BF5FB00B8C95649991E97BED6E5 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B50104DE2E35D820ECA22CDB3E6F96EA /* Foundation.framework */; }; AC0E244868EE1BB06DA2FC31EE7FEFF0 /* DTHTMLParser.h in Headers */ = {isa = PBXBuildFile; fileRef = CB2BD1F8CFECB82D427DF046439E68D6 /* DTHTMLParser.h */; settings = {ATTRIBUTES = (Public, ); }; }; AC24EE11636D8101C9678987A7D7E56B /* DTSmartPagingScrollView.h in Headers */ = {isa = PBXBuildFile; fileRef = 21CCCCAAF832BE9C859BEDDC79B4E9E2 /* DTSmartPagingScrollView.h */; settings = {ATTRIBUTES = (Public, ); }; }; AC7000465258822B466855B12F1F97A3 /* DTCoreTextGlyphRun.h in Headers */ = {isa = PBXBuildFile; fileRef = 771E0D5CBCBC013BF5780FEE5BBA81FF /* DTCoreTextGlyphRun.h */; settings = {ATTRIBUTES = (Public, ); }; }; - AD1C9A89324683F04573E4DE479DCDE5 /* RDEPUBReaderBottomToolView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 680ADAD990534743F965D6B6E96D7E9B /* RDEPUBReaderBottomToolView.swift */; }; - AE90B64916BAAB2132F880C33316EFCB /* WeReadApi.js in Resources */ = {isa = PBXBuildFile; fileRef = CA02E7C359334DEF7C203F369B5D6AAD /* WeReadApi.js */; }; - AED1E11539E157D1123D4AFB20BB3BFC /* RDEPUBTextPerformanceSampler.swift in Sources */ = {isa = PBXBuildFile; fileRef = B54F008AC6146B84C53BC2ECB8725B94 /* RDEPUBTextPerformanceSampler.swift */; }; + ADFBBB7F7EA8D54F062F6FE9A0EA1FA3 /* RDEPUBResourceURLSchemeHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 117C8D3804CD684A0BCFB28E527FE704 /* RDEPUBResourceURLSchemeHandler.swift */; }; + AEA69A182B7FD8571C50C8D6D0BA457E /* RDEPUBAttachmentTooltipView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08AA95F9E01837CBD5833F9D03F5FC8A /* RDEPUBAttachmentTooltipView.swift */; }; + AF71226D0D6B283BC04F8560E9B9C0A7 /* RDEPUBCFISerializer.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB2D03387C98C80186C40EAA590D89BD /* RDEPUBCFISerializer.swift */; }; AF7A1DC953992A149CB0815C90769529 /* DTIframeTextAttachment.h in Headers */ = {isa = PBXBuildFile; fileRef = D4C731AA2088D7E873D8E3D21B95D215 /* DTIframeTextAttachment.h */; settings = {ATTRIBUTES = (Public, ); }; }; - B059193A5A552F53D38C1B38E65AADAC /* RDEPUBBackgroundPriorityPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0ED3B8B105513BA029A8DAC14677F712 /* RDEPUBBackgroundPriorityPolicy.swift */; }; B0C819B1181CB7AC0ED2AE0F785C05E2 /* DTBase64Coding.m in Sources */ = {isa = PBXBuildFile; fileRef = E5D4D6E12779C54F17AE98A1E6C9B699 /* DTBase64Coding.m */; }; - B1D4240422AFD323C59B17BE24A1E1B7 /* RDEPUBSettingsFlipAutomation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C2BB1CB63F11F03F2A9AF85735EA076 /* RDEPUBSettingsFlipAutomation.swift */; }; + B102ABA5BAF500C91E7FEB842E860FD4 /* RDEpubReaderView+PageCurl.swift in Sources */ = {isa = PBXBuildFile; fileRef = B50734FC0871066B8BF8848B60EA53CF /* RDEpubReaderView+PageCurl.swift */; }; + B14BA93598C691CE6094E7B9D8AA2011 /* Data+Compression.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22B69C9433F2FBE5CE8409BD4266DFE8 /* Data+Compression.swift */; }; + B24C4825CE0519A7FB2CF193659F7236 /* RDEPUBReaderConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFE26DA6A1180E7B617EE341C73F7A50 /* RDEPUBReaderConfiguration.swift */; }; B24FAD34A7915FC4B90FE9038E814FF2 /* DTLazyImageView.m in Sources */ = {isa = PBXBuildFile; fileRef = CA5630DC0E663F38A9EC713323697EE1 /* DTLazyImageView.m */; }; B250A5E09EA67FE88C2CED128EFC70CE /* UIApplication+DTNetworkActivity.m in Sources */ = {isa = PBXBuildFile; fileRef = 730B56BC97DFF2B1E04DD2608055D9E0 /* UIApplication+DTNetworkActivity.m */; }; - B3F712BE87B6441DE9CE079F98A65CEF /* Data+CompressionDeprecated.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6B46EF6F055A721AF370D705D026E9E /* Data+CompressionDeprecated.swift */; }; - B47F908F2572E4D7FB9B8B02CF406A75 /* RDEPUBNotePopupViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18D7EB46EC2C4DD8B46B6FADEFD16BB6 /* RDEPUBNotePopupViewController.swift */; }; - B511D4D936A2B51C2E64C456B160E661 /* RDEPUBCFIMarkerInjector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE5DDE420852CFC895FEF2572577505 /* RDEPUBCFIMarkerInjector.swift */; }; - B52D7D4008111E537A88A33E349A337C /* RDEPUBReaderController+Trial.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6707BF75590797EB88552A6829CBAF19 /* RDEPUBReaderController+Trial.swift */; }; + B2B97CAF20B478A68B805D4BF213ECBD /* RDEPUBBackgroundPriorityPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44D49963D6EAE25EA025A41D91CEAD23 /* RDEPUBBackgroundPriorityPolicy.swift */; }; + B2BB216A76E7F3B995A0F3D5D099E1BC /* RDEPUBParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = A875D59C9ADB7D356F34F31D11DCC720 /* RDEPUBParser.swift */; }; + B462364CFF89AEFBE3F6B0FDD710F5CD /* RDEPUBStyleCompatibilityModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D2D23A34A39E1D251E493C14A46A852 /* RDEPUBStyleCompatibilityModels.swift */; }; + B4F523484916B5B5BF93998DC855D056 /* RDEPUBSearchEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6C77B875320EA1C7172B5E8F8A4460A /* RDEPUBSearchEngine.swift */; }; + B4F9C816E5248DBE5D0F5CD82D25A4B4 /* RDEPUBReaderToolViewProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = 447C8160AAD2A2E1FE48C8420778A342 /* RDEPUBReaderToolViewProtocols.swift */; }; B5386D5AFA1F546EFEE6E310BFF4D707 /* NSString+DTUtilities.h in Headers */ = {isa = PBXBuildFile; fileRef = 7C80941DE172AF972D2247659684D93A /* NSString+DTUtilities.h */; settings = {ATTRIBUTES = (Public, ); }; }; - B6835AAD31E05403BE3372062CDB785F /* RDEPUBChapterData.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2B2A40829A4A7C9D9B922A44179609E /* RDEPUBChapterData.swift */; }; + B55D0715C6E8F6847DE6D817EEBF9F2C /* Entry+ZIP64.swift in Sources */ = {isa = PBXBuildFile; fileRef = 288589D0B52F73505B43C743D9837578 /* Entry+ZIP64.swift */; }; + B5A9BB79C1D4D5081CC626C1C75ACA58 /* RDEPUBReaderDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63731E76CD377576124BFA6BC5764F08 /* RDEPUBReaderDelegate.swift */; }; + B61114677CF259E6270EFD80A28250D6 /* RDEPUBAssetRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BB49FBD6621286BA60757349FCC6883 /* RDEPUBAssetRepository.swift */; }; + B624BE4D149D61A910396D883766C7B1 /* RDEpubReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFC57105C58F0D30043B13D949A57D8E /* RDEpubReaderView.swift */; }; + B637F61E19FA6EBC9889C2BEDF31BE61 /* RDEPUBHTMLNormalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93D30E909C16D18D790A184AB3DDF67D /* RDEPUBHTMLNormalizer.swift */; }; B7BDD79FC06A1E4E0E17592860844108 /* NSAttributedString+DTCoreText.h in Headers */ = {isa = PBXBuildFile; fileRef = 3985FED7BD8EC49191663BEF73FB2C79 /* NSAttributedString+DTCoreText.h */; settings = {ATTRIBUTES = (Public, ); }; }; + B803A1C571EE32A1E68BDC1ECA3358ED /* RDEPUBTextContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D5A551AA691377650E64BF614B5315E /* RDEPUBTextContentView.swift */; }; B8332F36582F7A001FE85A8D54B27C9E /* DTCoreTextParagraphStyle.h in Headers */ = {isa = PBXBuildFile; fileRef = BD65B936E2A6FEB74718EA86D9AC1899 /* DTCoreTextParagraphStyle.h */; settings = {ATTRIBUTES = (Public, ); }; }; B86493B996A888D1DA7814ECB6028E22 /* DTHorizontalRuleHTMLElement.m in Sources */ = {isa = PBXBuildFile; fileRef = DD7A2DE5BDF72ABC066484CE554BDEF0 /* DTHorizontalRuleHTMLElement.m */; }; B94CD41727DFCA8791EED8FB2CA397DD /* DTCompatibility.h in Headers */ = {isa = PBXBuildFile; fileRef = 5C3D91FFA65414A2787FEB95576E0097 /* DTCompatibility.h */; settings = {ATTRIBUTES = (Public, ); }; }; + BB04C385085B062AD326250F566827A3 /* ZIPFoundation-ZIPFoundation_Privacy in Resources */ = {isa = PBXBuildFile; fileRef = CA4A114775B98CEA4A566C18123B145F /* ZIPFoundation-ZIPFoundation_Privacy */; }; + BB487A86B1365E8F883930A0BFB80668 /* RDEPUBReaderRuntime.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7C6D0CBF46040C1E92580C7CB26A47C /* RDEPUBReaderRuntime.swift */; }; BB72EACBFD0C77D61A9AA9142C100B10 /* NSFileWrapper+DTCopying.m in Sources */ = {isa = PBXBuildFile; fileRef = D9A812C3E577A3ADC36BAE3FF7AA4D9C /* NSFileWrapper+DTCopying.m */; }; BC017F711B983AD8E6EE8775B3AB3602 /* UIImage+DTFoundation.h in Headers */ = {isa = PBXBuildFile; fileRef = 1300CC5794E609DB8FBA6FFF90A09408 /* UIImage+DTFoundation.h */; settings = {ATTRIBUTES = (Public, ); }; }; + BC8D2215508131850AC76A34FF268694 /* UIColor+RDEPUB.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17B38E01CD7C86BDC366392058A1D719 /* UIColor+RDEPUB.swift */; }; + BCBDC16EE17A5C45D99597929DA76453 /* RDEPUBResourceDataProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA70CE662A3BBAFDEFA4F1AD91006DE /* RDEPUBResourceDataProvider.swift */; }; BE12CD112F82AD9050D7FB29395F27F9 /* DTVideoTextAttachment.m in Sources */ = {isa = PBXBuildFile; fileRef = B83D7A73677A2C3141EDEF56B9CE6584 /* DTVideoTextAttachment.m */; }; BED12B4CA012BB2D1EC92E136D6457F0 /* DTCoreGraphicsUtils.m in Sources */ = {isa = PBXBuildFile; fileRef = 0372FD1824BC43F7BD6B337430201420 /* DTCoreGraphicsUtils.m */; }; - BF0E4C081EC75F17692C703A2B9BE71B /* RDEPUBTextPaginationSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95C55ACB797975D1F898B13F069C609C /* RDEPUBTextPaginationSupport.swift */; }; - C049CDA9C12E413E2B63AD404564CD9E /* RDEPUBChapterOffsetMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BFA10454111B92A9838334E4C3BDD21 /* RDEPUBChapterOffsetMap.swift */; }; - C26DC956E5E05FE85127A36EAE538876 /* RDEPUBPageLayoutSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = C23F9EE5E57C3681A4372C8884901664 /* RDEPUBPageLayoutSnapshot.swift */; }; - C28649DD763E6E5EFC7D60F47B865966 /* RDEPUBReaderPersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54C212FF935AF7337E5CDE1C37D5785A /* RDEPUBReaderPersistence.swift */; }; - C407660D1DD8D9EBEE6B70B0BB18043D /* RDEPUBTextSearchEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44D834B66794C6899B84C5782F3624C6 /* RDEPUBTextSearchEngine.swift */; }; + BF411F0538517FC40CDC0FD0A8C7A420 /* RDEPUBModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 847740E4EA7EBFB8F53710A0CF448BAE /* RDEPUBModels.swift */; }; + BFF82B26C8F0A924864101BE93DBEA1F /* RDEPUBReaderLoadCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29F6775325522C48C45B7023AF9B83EB /* RDEPUBReaderLoadCoordinator.swift */; }; + C20B3A5FE8D80ACCF7281E7CED0826B3 /* RDEPUBReaderLocationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98C6026F789783895FC83556EF6F289C /* RDEPUBReaderLocationCoordinator.swift */; }; + C2E430A825AC41C57B06D2E35E3A853A /* RDEPUBParser+ReadingProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = F72A99A09ECD213552D649EF0E7BD081 /* RDEPUBParser+ReadingProfile.swift */; }; + C2E855E2813B0E3231243EBCADD7759E /* RDEPUBReaderAssemblyCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66497586176C305ADC5F3B447F17FA90 /* RDEPUBReaderAssemblyCoordinator.swift */; }; C557F129B5958D29FA4015DD182ED2CC /* UIApplication+DTNetworkActivity.h in Headers */ = {isa = PBXBuildFile; fileRef = 55423549E54A01F039B5F379B264C73F /* UIApplication+DTNetworkActivity.h */; settings = {ATTRIBUTES = (Public, ); }; }; + C5DD9E01BE94C43A42088BF48FC4A0C7 /* RDEPUBTextBookCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 612542D38DEA4A8BBD84517629D3482B /* RDEPUBTextBookCache.swift */; }; C5EE8E7373FBD24E66C678356393D871 /* DTCoreTextFontCollection.m in Sources */ = {isa = PBXBuildFile; fileRef = 722AF38F5106ADDE6F98D282F886A769 /* DTCoreTextFontCollection.m */; }; + C629CF9C1701AB409A00EC5D460DB964 /* RDEPUBMetadataParseWorker.swift in Sources */ = {isa = PBXBuildFile; fileRef = F486744FB2C3E4E55A9FED93371C7272 /* RDEPUBMetadataParseWorker.swift */; }; + C68E01289F5AF6B9245379348D08820F /* RDEPUBSemanticMarkerInjector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77F0FB3238D157802C13B7A0495C909B /* RDEPUBSemanticMarkerInjector.swift */; }; C6B65C0EA759DF426FBBBB113BA3C3E8 /* DTBreakHTMLElement.m in Sources */ = {isa = PBXBuildFile; fileRef = 7D8AE4BEBD1998839BA83AD05CA6CAC5 /* DTBreakHTMLElement.m */; }; C72CE4B974DFAE2C3037D425EB559314 /* DTCoreTextLayoutFrame.h in Headers */ = {isa = PBXBuildFile; fileRef = C46FCF109DBCB37595D2A3366CF1D765 /* DTCoreTextLayoutFrame.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C759F2FD36CAF63381C0AB0B99D7BAE3 /* Archive+Progress.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE83356F8FBC1801D29CA93C1A190D81 /* Archive+Progress.swift */; }; - C7CD4D8F66564DF81E180FFDB30F572B /* RDEPUBReaderHighlightsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93F324654B96751DA93FFC59A3679ED6 /* RDEPUBReaderHighlightsViewController.swift */; }; - C85F3E3ED00BE6DBA524F1BE53E008DC /* RDEPUBTextIndexTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 674CA023806B055868320A59D18FD977 /* RDEPUBTextIndexTable.swift */; }; + C72F5280B5B83EA11ACDF74895E5758F /* RDEPUBReaderServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19A9DF3B54605BC418E5EC2AC9C285F9 /* RDEPUBReaderServices.swift */; }; + C7E253517877BD88DD8B0DF7E23D1E89 /* RDEPUBAttachmentTooltipOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5CC55716AF7BC27FD9268D256ABA314 /* RDEPUBAttachmentTooltipOverlayView.swift */; }; + C8C71942D3A141E7CD5032E8F1016F29 /* RDEPUBParser+Archive.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FE0F0004B3436CA416F69193354B1AD /* RDEPUBParser+Archive.swift */; }; C8F753B56A6C4D2A938B6EA68CF522F8 /* DTColorFunctions.h in Headers */ = {isa = PBXBuildFile; fileRef = 720396ED306E768BAD28007D5E96B757 /* DTColorFunctions.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C964A7EFF40B8EB307EEA1E9EFE07FEE /* RDEPUBWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CD9D3B5B4DC5B0B3B51CC6B8F9AAD86 /* RDEPUBWebView.swift */; }; + C9C8410D0C0FF213B707830293E79235 /* RDEPUBStyleSheetBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB7F916B5DAC02317D14747841AF489C /* RDEPUBStyleSheetBuilder.swift */; }; C9DD05EF2D27FFEBFA7BF6F437168501 /* DTCoreTextConstants.h in Headers */ = {isa = PBXBuildFile; fileRef = A61C8F1BDD76EA59082F65269083BD08 /* DTCoreTextConstants.h */; settings = {ATTRIBUTES = (Public, ); }; }; CA1F1B6F7C24CCC6F7F8A75E8CD73B5C /* NSFileWrapper+DTCopying.h in Headers */ = {isa = PBXBuildFile; fileRef = B43D7603C0D66635268842EC0A2655AC /* NSFileWrapper+DTCopying.h */; settings = {ATTRIBUTES = (Public, ); }; }; - CBF966E59BA90842D96F8AC0F63506D5 /* RDEPUBResourceURLSchemeHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EA7439F32DE1A2349B94E9F3EA60EFB /* RDEPUBResourceURLSchemeHandler.swift */; }; + CA8985779F800BFDE009345B28F346C2 /* RDEPUBChapterData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92D5B64BD3D5489A837054C68FC54D65 /* RDEPUBChapterData.swift */; }; + CBE0B92DBDAB8DF59168B3BDAEF223E0 /* WeReadApi.js in Resources */ = {isa = PBXBuildFile; fileRef = 0437B4D43E3BC006FB3D6082F6DF0C43 /* WeReadApi.js */; }; + CC4DFC98C197D1FE60BF52809CC1B17E /* RDEpubReaderPageChildViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6106ADA2D16E2AB749BC690E59E199B5 /* RDEpubReaderPageChildViewController.swift */; }; CC8B8F3D758E98B51F69EDFAA69663A4 /* NSString+HTML.h in Headers */ = {isa = PBXBuildFile; fileRef = DC7D3AB85C954DA41E1500936C166C0E /* NSString+HTML.h */; settings = {ATTRIBUTES = (Public, ); }; }; - CE8512459F54E41A09977E6EF2DD9933 /* RDEPUBTextPageVerticalJustifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A53E7C06029DCDA8A67E02513AB726E /* RDEPUBTextPageVerticalJustifier.swift */; }; CEBD70C204E9891A9D5CF70CCF4C9D29 /* UIView+DTFoundation.m in Sources */ = {isa = PBXBuildFile; fileRef = 67DA49B61A249C2B8241D8FE166608C9 /* UIView+DTFoundation.m */; }; CF5E634F602E6E8723852FE93912CCBA /* NSArray+DTError.m in Sources */ = {isa = PBXBuildFile; fileRef = 8E5297E7F5769C61C1838962BA76D4F5 /* NSArray+DTError.m */; }; + CF7B02001C36BA5BE16362D5C4EBDB07 /* RDEPUBAttachmentNormalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B545FD055DB6F5E62A4B3477F7514D97 /* RDEPUBAttachmentNormalizer.swift */; }; + CF851879EEFBFA3EEB9EEC6F74510052 /* RDEpubURLReaderController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C985C2E56D867C385610E439E61BFBFE /* RDEpubURLReaderController.swift */; }; CFC49467FE75BFABC22A1CE2FFA26570 /* DTImage+HTML.m in Sources */ = {isa = PBXBuildFile; fileRef = CF1CED3ED65443999913BC0D96ACA151 /* DTImage+HTML.m */; }; - D063317214AFF5143D9C958E5FAFBB38 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B50104DE2E35D820ECA22CDB3E6F96EA /* Foundation.framework */; }; - D11000ABBDC9F4AA8C2D2D81A640F380 /* RDEPUBSearchEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DD41010C7FC84E0AD4545E0B751822C /* RDEPUBSearchEngine.swift */; }; D19AB62540BEEDAD415D616509B08445 /* DTAnimatedGIF.m in Sources */ = {isa = PBXBuildFile; fileRef = A3DE08AC23FE36A6C3F33A04700FDE57 /* DTAnimatedGIF.m */; }; D1A93B2D7F1B312755C48790338D7712 /* DTIframeTextAttachment.m in Sources */ = {isa = PBXBuildFile; fileRef = BA8A527A4CCB2AF1C1E26744B301F6B8 /* DTIframeTextAttachment.m */; }; - D2345E70DE161E0570492E70BD9CD20E /* RDEPUBTextAnnotationOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = D994416DB1DCB53E8D1F4A20D10A3D58 /* RDEPUBTextAnnotationOverlay.swift */; }; - D2D38EFF7A6086D40028DA14F8DAFB9C /* RDEPUBStyleCompatibilityModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41DE1F810FCEEA86135A8828CE7E0B2E /* RDEPUBStyleCompatibilityModels.swift */; }; - D2EFBE6B2968BD4AA79349D2D1558280 /* RDEPUBFontNormalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42C462DBD1028C2B2C741726F0C4F7D6 /* RDEPUBFontNormalizer.swift */; }; + D20A9C2E855617ADAE9ACCDC4BA2147A /* wxread-dark.css in Resources */ = {isa = PBXBuildFile; fileRef = C8687094F5EB5A27E0D5A0070B19A4C2 /* wxread-dark.css */; }; + D2D0EFD20BB05F026318070DB36D04BD /* FileManager+ZIPDeprecated.swift in Sources */ = {isa = PBXBuildFile; fileRef = F49FC82A04EE9B3B49CBA6CB88ACBC67 /* FileManager+ZIPDeprecated.swift */; }; + D2F7A0CE2F73B4EDCAD52379F2233A6B /* RDEPUBMemoryProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C46A096335AF772EB451C71B476A0C8 /* RDEPUBMemoryProbe.swift */; }; + D32DE4EA99BAA034D0C43502E93E7739 /* RDEPUBParser+Resources.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648B55749AEAFAEC440B7259DA5F18AA /* RDEPUBParser+Resources.swift */; }; + D38DC76225F46051EB5D9476485B4007 /* RDEPUBCFIRecoveryEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = D46B1E71516E5EC8C9D039B9D836D290 /* RDEPUBCFIRecoveryEngine.swift */; }; D526E03C098DA6CF8F01C1BC126C9BBA /* DTCoreText-umbrella.h in Headers */ = {isa = PBXBuildFile; fileRef = 13F8FC8B51AD434F5D4B0CF5150D7563 /* DTCoreText-umbrella.h */; settings = {ATTRIBUTES = (Public, ); }; }; D5D9BDF382C943902AC2A3368DCD932C /* NSURL+DTAppLinks.h in Headers */ = {isa = PBXBuildFile; fileRef = A3F7613E99CAA29881A015D0ED969B86 /* NSURL+DTAppLinks.h */; settings = {ATTRIBUTES = (Public, ); }; }; - D60AA4D9F0BBEF49EE1473A2F0A1CFB5 /* RDEPUBReadingSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFC5E77754171DA25D9A8A76A0985CCF /* RDEPUBReadingSession.swift */; }; - D69EB259E24B2E1077A03FE945FCB5FC /* rangy-serializer.js in Resources */ = {isa = PBXBuildFile; fileRef = 86DD8A5C0624A7C2C0C328A40118C3E8 /* rangy-serializer.js */; }; - D6DF49537490F8634E2BC8778FFDF0F3 /* RDReaderViewProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3D7F9A13B102C659531CCAAB50035F /* RDReaderViewProtocols.swift */; }; - D796C3C8958054CF0A67EC2BA3E87206 /* RDEPUBNotePopupCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 893B4227196A12B86EF2F32C46FCA05E /* RDEPUBNotePopupCoordinator.swift */; }; - D7D76E53192009455FC0FEE7D341EAB9 /* Archive+MemoryFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFF51E56E09E095C92CB5B9F21384027 /* Archive+MemoryFile.swift */; }; - D81B82D5420A636836F8771B438ED8D3 /* Entry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D93C2002AAECD29B3ED5A14026D3DFE /* Entry.swift */; }; + D795202CC870F4CEE10864C01005F6D3 /* Archive+WritingDeprecated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2373DCA0FC45287BFC063BEA661B8367 /* Archive+WritingDeprecated.swift */; }; + D79FE63B494282934EDE065131B29C98 /* RDEPUBTextBuildPipelineInterfaces.swift in Sources */ = {isa = PBXBuildFile; fileRef = A98B084E630DFB6A2A5DB15DFB214FD0 /* RDEPUBTextBuildPipelineInterfaces.swift */; }; + D82A46AA1A5B2F3BB3B6E200544810E6 /* RDEPUBTextRendererSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D260C6E64F57CE89343A271FB4804745 /* RDEPUBTextRendererSupport.swift */; }; D82DDA4C1E5C4D5378A9E5EA3F8E9DFE /* DTBreakHTMLElement.h in Headers */ = {isa = PBXBuildFile; fileRef = 14283024FA4A0231B32CA22AA907FEFA /* DTBreakHTMLElement.h */; settings = {ATTRIBUTES = (Public, ); }; }; - D862245B5224688D82612E8C376A8761 /* RDEPUBNoteModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AC44A856D45E8522194A048ABEAAF86 /* RDEPUBNoteModels.swift */; }; - D894E108A7402D9ACB0E7BB65D938AC2 /* RDReaderPageChildViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F1CF1FACBA97E3B87ED0ECBBA7271E2 /* RDReaderPageChildViewController.swift */; }; - D92F1EB12DB274D47F77C097BDBA2866 /* RDReaderFlowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFB982B05AC5435F003EC90F8A93EDA0 /* RDReaderFlowLayout.swift */; }; + D89AFCF84CC572910C3447D1C5B88EBE /* RDEPUBChapterOffsetMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = F84F78EBE95C9D7F7DC66344EB40BE9F /* RDEPUBChapterOffsetMap.swift */; }; + D949F02438BC5DE45F525F899D6E1027 /* RDEPUBBuildDiagnosticsReporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D76AF74F7C0A5657241E97D1CAD4A833 /* RDEPUBBuildDiagnosticsReporter.swift */; }; DA5B51E5D80C161D2902B6FC60DA5EC8 /* DTImageTextAttachment.h in Headers */ = {isa = PBXBuildFile; fileRef = 27372A23AA4B051867E7D2C749D1DE81 /* DTImageTextAttachment.h */; settings = {ATTRIBUTES = (Public, ); }; }; + DA678828B54CE961E1C2929A1410A31D /* RDEpubReaderGestureController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A36571C8CAE6B5E67048AF9818B631C6 /* RDEpubReaderGestureController.swift */; }; DAD16D3E6EBB32FF96C63BFE6037065F /* DTLinkButton.m in Sources */ = {isa = PBXBuildFile; fileRef = CE761A55BD57C0BFD6336CD073F85C31 /* DTLinkButton.m */; }; - DAD96B682717D81AD3D3D22BD899A7EA /* RDEPUBCFI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D1978442C3B21E292AEF661B6F98568 /* RDEPUBCFI.swift */; }; - DBA57C2841C8BCB36412B8CEAAA6AFDC /* RDEPUBWebView+Search.swift in Sources */ = {isa = PBXBuildFile; fileRef = F98C79337772FFEE3E718C3A251E5B9D /* RDEPUBWebView+Search.swift */; }; - DBB66507EB07AB5F08E6D305AA35B8FB /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B50104DE2E35D820ECA22CDB3E6F96EA /* Foundation.framework */; }; + DADD404DA23EBFC7F0101727EBE88832 /* Archive+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B9309F0BDDDE2C3EE13C97BF2C0F1E /* Archive+Helpers.swift */; }; DBCA42F7E1697F4EC82E0E7C9657851D /* DTAttributedTextCell.h in Headers */ = {isa = PBXBuildFile; fileRef = 068885F1B54CFD5DBBE6E2DC253F7819 /* DTAttributedTextCell.h */; settings = {ATTRIBUTES = (Public, ); }; }; + DBF354555B4B0353E32231529CB43F0A /* RDEPUBTextBookModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B99659A42728CF06CA00A18D502850AF /* RDEPUBTextBookModels.swift */; }; DC3E2932A82AD5D88D8B1EFBF004BEC8 /* DTCSSListStyle.m in Sources */ = {isa = PBXBuildFile; fileRef = 40FD7C9E2E05FB4A73F4C4C70A3AFAF0 /* DTCSSListStyle.m */; }; DC976BFEBC952457D8C728BA9C43DC55 /* NSURL+DTUnshorten.h in Headers */ = {isa = PBXBuildFile; fileRef = ACA6D26B26D2653D6E1C871A482BD6DF /* NSURL+DTUnshorten.h */; settings = {ATTRIBUTES = (Public, ); }; }; DCA2EB6A9C43728C911B1084BABADB13 /* NSString+DTURLEncoding.m in Sources */ = {isa = PBXBuildFile; fileRef = 131C1D8C96DF2D44A2D4F50C5AEFFF11 /* NSString+DTURLEncoding.m */; }; + DCD6C3ED3B13C549F589F57C95ED6BBF /* ZIPFoundation-dummy.m in Sources */ = {isa = PBXBuildFile; fileRef = E9D8AB18960B56A997408E3235751624 /* ZIPFoundation-dummy.m */; }; + DD5CB019978B6208D0E7ABD017DC7D94 /* RDEPUBTextPageBoundaryValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8C567DF41CC58FE2BA5C88C6B4A71F6 /* RDEPUBTextPageBoundaryValidator.swift */; }; DD73CD0E5148961D1ABD80E050122314 /* DTCoreTextFunctions.h in Headers */ = {isa = PBXBuildFile; fileRef = DA2ED17D0DDF5F1C5606BFFD9A512DE4 /* DTCoreTextFunctions.h */; settings = {ATTRIBUTES = (Public, ); }; }; + DE1DF6C20E410592A6F6A1262EA189FD /* wxread-default.css in Resources */ = {isa = PBXBuildFile; fileRef = 635A4B503A7681407E2D344472AAEFF8 /* wxread-default.css */; }; DE68AD2F426FD462BC70F968E5AF5553 /* DTTextAttachmentHTMLElement.m in Sources */ = {isa = PBXBuildFile; fileRef = AC0A4ED7E936923A29A5EEB1D95D6B55 /* DTTextAttachmentHTMLElement.m */; }; - DF711B907615C160BB94C6A1E42D8A4D /* RDReaderPreloadController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C69FCCE0841CEE98E2EBE9D173C0622C /* RDReaderPreloadController.swift */; }; DFB5A1C64C7908FD05BEFF7C7B571353 /* DTAttributedLabel.h in Headers */ = {isa = PBXBuildFile; fileRef = 09FF858DC29B73E9B67B32B03CFF4084 /* DTAttributedLabel.h */; settings = {ATTRIBUTES = (Public, ); }; }; - E014AE1BA45C5E083D1FFB337ECB3222 /* RDEPUBReaderToolViewProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3429C49A65BE1911C634E1C95A0579BA /* RDEPUBReaderToolViewProtocols.swift */; }; - E02468E0045376BD3A3A0E804151674F /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 006D002D86B43AA52E0AFD2E18459107 /* PrivacyInfo.xcprivacy */; }; + E0FC42C3CD2D1F7E374708F600536D26 /* RDEPUBReaderTableOfContentsItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66637609CA61FBB1E9F137BD7C0D5A2E /* RDEPUBReaderTableOfContentsItem.swift */; }; + E12C988C3974467F470A512B0E5C54F7 /* RDEPUBPageCountCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC69A9D6FE337C36229B5CE0CA4206FF /* RDEPUBPageCountCache.swift */; }; E1DA58105A72D6C0B77A8A9F7822BD68 /* DTCoreTextLayoutFrame.m in Sources */ = {isa = PBXBuildFile; fileRef = 4641C3C93CE32054665ACF80457A7A94 /* DTCoreTextLayoutFrame.m */; }; - E28DE61EC40C0EB8EE1467CA5996673B /* RDEPUBWebViewDebug.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF774474543DB1EEE0F2F8BCC54382AE /* RDEPUBWebViewDebug.swift */; }; - E35D15D74F94257E29D10826BFB666FC /* RDEPUBCFIUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53BFEB15CE78B6B97488049218D34E0C /* RDEPUBCFIUtilities.swift */; }; + E277306E0085BDD4F3A8885EE5351CD2 /* RDEPUBTextBookBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07FC18458844B9ADFDC71FB8C1A085DE /* RDEPUBTextBookBuilder.swift */; }; E37E3251D86E8B673CB16F60CA1151C1 /* UIScreen+DTFoundation.m in Sources */ = {isa = PBXBuildFile; fileRef = AF436E76C893E223A4F0BE86C1276054 /* UIScreen+DTFoundation.m */; }; E38FC7829B5BDD1588FA487E0FBAC208 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B50104DE2E35D820ECA22CDB3E6F96EA /* Foundation.framework */; }; - E3F85B1C4C7D7FEEFCD2035465E062A8 /* RDEPUBRuntimePageCount.swift in Sources */ = {isa = PBXBuildFile; fileRef = F518D64E810A583A82D13ED1FEF8ACF8 /* RDEPUBRuntimePageCount.swift */; }; E414D1C3659D3390E1429E8E450972D1 /* DTActivityTitleView.h in Headers */ = {isa = PBXBuildFile; fileRef = AF1516035C269F7A41EE064AAB14D562 /* DTActivityTitleView.h */; settings = {ATTRIBUTES = (Public, ); }; }; - E7581E6A941BC4B761D6FB321E311B4E /* RDEPUBRenderDiagnosticsCollector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3011B53A527F51AB7C6ABF0151CC130 /* RDEPUBRenderDiagnosticsCollector.swift */; }; + E586800F63A73B4721C0B3F03E49E942 /* RDEPUBTextAnchor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B2EA51F3AF54545A450E32FACC3B8C5 /* RDEPUBTextAnchor.swift */; }; + E5AB494FB43314E9ECF733806ED02B1E /* RDEPUBImageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06A86745BE9CD1C490EC229FA2F09BBD /* RDEPUBImageViewController.swift */; }; + E6701B19F1F17E88D687A13FCBA87277 /* RDEPUBReaderController+AttachmentTooltip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D5EB1FE3AA42EEE041C5BAF4391E8B3 /* RDEPUBReaderController+AttachmentTooltip.swift */; }; E8AB5CE26A5224EF09E9E363D70C7775 /* DTDictationPlaceholderView.m in Sources */ = {isa = PBXBuildFile; fileRef = 1FA88D00B7B2B2F0BA98BB92DF035906 /* DTDictationPlaceholderView.m */; }; - E8E13704514FAF7EF892BBBB71FB9C0E /* RDReaderView+ContentAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B84F7CB373F124B5ADA362B7A204376 /* RDReaderView+ContentAccess.swift */; }; - E944A14E0C8C3352C2DF3AF4586F0B7B /* RDEPUBReaderSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67B9C855C41D2A2276BB04970B73D00B /* RDEPUBReaderSettings.swift */; }; + E9871753E44C96C4AA1F48A81CA35361 /* RDEPUBTextSelectionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8170A64FBDE3CC44D5EFA56020DC7978 /* RDEPUBTextSelectionController.swift */; }; EA0040823CCF8E97363DB5891F5DA6D0 /* DTCoreTextFunctions.m in Sources */ = {isa = PBXBuildFile; fileRef = 4917E08E6E59460594AC443EDDAB7E77 /* DTCoreTextFunctions.m */; }; - EA5614D5101DCABD4056EED65EB1A393 /* RDReaderTapRegionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = B023EBCF5E4ECC879A57A325F2015CAA /* RDReaderTapRegionHandler.swift */; }; - EA9A53D3DDE22E73A6D8DE79D54547C1 /* Archive+BackingConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 765A94769106D14615FFB30EE1BD5E65 /* Archive+BackingConfiguration.swift */; }; - EB2E030B0EF64230A77CAE0CF3CED75B /* RDReaderView-dummy.m in Sources */ = {isa = PBXBuildFile; fileRef = 085DA39E20C3A758783B115AB62B1E72 /* RDReaderView-dummy.m */; }; + EA13E34B7BF4563DF1D5D2622C7DFF8F /* RDEPUBReaderViewportMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89BD34E68BFF47D27D5F91863AF1BBF1 /* RDEPUBReaderViewportMonitor.swift */; }; + EAB62A5D1D3D572AD18C7566F15279C0 /* RDEpubReaderView-dummy.m in Sources */ = {isa = PBXBuildFile; fileRef = 3108DBF4D182FE04D56AB715DE874117 /* RDEpubReaderView-dummy.m */; }; + EB0A86053AC9D8A89B4D6E792AD7B7A8 /* RDEPUBTextIndexTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025D419F9C7DF8D9768FF73AA996BB88 /* RDEPUBTextIndexTable.swift */; }; EBB7E4D4DB253B5F1267CF9B4DDB4E93 /* DTCoreTextFontDescriptor.m in Sources */ = {isa = PBXBuildFile; fileRef = 88464D2E3579F1A5B25DCCA128528938 /* DTCoreTextFontDescriptor.m */; }; - ECEA44771F14DE0FC56B152EE4B3DB79 /* RDEPUBPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9191031A62C049190D2BAAF26FCB4894 /* RDEPUBPreferences.swift */; }; - ED77BE7CC7893313B521DBD76A6046F6 /* RDEPUBPublication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F16DD7BE31C1407239419FAB208F7E9 /* RDEPUBPublication.swift */; }; + EBF356418C3C6D53BE635CFA4EAA2CEC /* RDEPUBReaderUIState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4830D8C6A94E0C97E0F0675276BFD674 /* RDEPUBReaderUIState.swift */; }; + ED44A43182DD2E248D122AB1AEE76C2E /* RDEPUBTextPageVerticalJustifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F922CBFACAAD201E732987D727209EE /* RDEPUBTextPageVerticalJustifier.swift */; }; EDA269B595D70EC2B9D81DC890157D63 /* DTAccessibilityViewProxy.h in Headers */ = {isa = PBXBuildFile; fileRef = 399AED6BF151EC39BE4450784795A832 /* DTAccessibilityViewProxy.h */; settings = {ATTRIBUTES = (Public, ); }; }; - EDA8BF97142B946BABB6CB8FFF83AD77 /* wxread-dark.css in Resources */ = {isa = PBXBuildFile; fileRef = B52A7884E951C3C2064E5610DD417DA5 /* wxread-dark.css */; }; - EDEFE5CF02BBC892F19412E18C611C41 /* RDEPUBFragmentMarkerInjector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1310063EDD27977AEF26DC0B83BA0811 /* RDEPUBFragmentMarkerInjector.swift */; }; EE4F0992A0F597320FF7BDE4691D0FD4 /* NSString+CSS.h in Headers */ = {isa = PBXBuildFile; fileRef = 3578B97A85BCDC60888FDF87B8F2600C /* NSString+CSS.h */; settings = {ATTRIBUTES = (Public, ); }; }; - EE6F2222A52155B6C4295C6E85E6E43F /* RDEPUBAnnotationModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17E520FDB2483CFB8A76E13BAB2BFFD8 /* RDEPUBAnnotationModels.swift */; }; + EE9B77D43F3023C4088D913B0012E493 /* RDEPUBChapterLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 834CDE067D219C544FC721901E8E70B5 /* RDEPUBChapterLocation.swift */; }; EEE698A9AA88E431076CF2F530BD82A9 /* DTFolderMonitor.m in Sources */ = {isa = PBXBuildFile; fileRef = BEA46E51E3616ECEAF0ACCEA9B8C67EF /* DTFolderMonitor.m */; }; + EF9B5A80BF3D877F80BAEC7508948F22 /* RDEPUBWebContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE31789BAB1A8C59EDFC3C728B350E90 /* RDEPUBWebContentView.swift */; }; + EFE4AC989091964E6030D916FCA02EA7 /* RDEPUBDecryptingImageAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2B413E5361DB33B983E9237109AD610 /* RDEPUBDecryptingImageAttachment.swift */; }; F006F73F17F2E3CC0E7852E9357BA946 /* DTAnchorHTMLElement.h in Headers */ = {isa = PBXBuildFile; fileRef = 599172147A32B6978EB5AAE1BE55FF7A /* DTAnchorHTMLElement.h */; settings = {ATTRIBUTES = (Public, ); }; }; F0F631056BADA32253528247DF493272 /* DTHTMLElement.h in Headers */ = {isa = PBXBuildFile; fileRef = F7C508BE899A1397560A767CAEADB7B9 /* DTHTMLElement.h */; settings = {ATTRIBUTES = (Public, ); }; }; - F1B14CF589E4B3B29B40C70508CB3466 /* RDEPUBTextPageBoundaryValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 713A7931BFA76291ACE4C095423CA7C2 /* RDEPUBTextPageBoundaryValidator.swift */; }; - F1EAB3BF7B71E2FE5F88E8544F95E69A /* rangy-core.js in Resources */ = {isa = PBXBuildFile; fileRef = 4568EB88B6EDC55FAC77D565DA1A2750 /* rangy-core.js */; }; - F282B0A9FAE1B412D35B15D948B35386 /* RDEPUBCSSCompatibilityLayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A48622CB59921725556CF92DE20EC3C0 /* RDEPUBCSSCompatibilityLayer.swift */; }; - F49D68621947F34F8F87BAC589B249E6 /* RDEPUBDTCoreTextRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 635FB53F5F49CC7F40F7C5E700A3A3EE /* RDEPUBDTCoreTextRenderer.swift */; }; + F193C9E5DE98B216A99EB20B7B3C01C7 /* RDEPUBCFIMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = E39C7BDD4236B494D4C00CBEC40F4371 /* RDEPUBCFIMap.swift */; }; + F439FC02429CC1C9DD1F6A5E161F61D6 /* RDEPUBSearchModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B502241DFB110D155ACB255B7597984 /* RDEPUBSearchModels.swift */; }; + F45B5C3A0499EAFD676758C4A361CFC4 /* Archive+Progress.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE83356F8FBC1801D29CA93C1A190D81 /* Archive+Progress.swift */; }; F4A2E3C4237B8931F61B72FF0BDD6D69 /* DTObjectTextAttachment.m in Sources */ = {isa = PBXBuildFile; fileRef = 8EDA1618562CC224690CF6FC03AFED38 /* DTObjectTextAttachment.m */; }; F4D1800D694F5268E261595E762CA5DD /* DTVersion.m in Sources */ = {isa = PBXBuildFile; fileRef = CCC633BF76C79E0B4A6750337D5DCDB8 /* DTVersion.m */; }; - F4DA7A510251FD67DE5B9A05D542FA21 /* RDEPUBReaderEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = B34DC8A160DF20B18E04242D92D54346 /* RDEPUBReaderEnvironment.swift */; }; F58ABA106C6200175DF574DF05A75BA8 /* DTAccessibilityElement.m in Sources */ = {isa = PBXBuildFile; fileRef = 18F842408AB76A580B80255453F2FDF6 /* DTAccessibilityElement.m */; }; F5A424E67F41C4F7B830CE2F7E71570D /* DTCoreText-Resources in Resources */ = {isa = PBXBuildFile; fileRef = F4E2B1AC73977F10254628C0B7EBE38F /* DTCoreText-Resources */; }; - F5DD147A6DA2FB3DE8E6EC4770E0F03F /* RDEPUBReaderPaginationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A33BBD0D82DD401D9A3FF02B10B320B6 /* RDEPUBReaderPaginationCoordinator.swift */; }; F6036BBDFE7484F5A453F6876C97C098 /* NSAttributedString+SmallCaps.m in Sources */ = {isa = PBXBuildFile; fileRef = 98CF9C5D504838741E399811F4D5A0B7 /* NSAttributedString+SmallCaps.m */; }; F61102C9BF3D6296CB6C6B754ABF98C5 /* DTTextHTMLElement.m in Sources */ = {isa = PBXBuildFile; fileRef = 69076FC9D4201719C319547346DA3E21 /* DTTextHTMLElement.m */; }; - F6888700DC52E960BF105470D3CB15F3 /* Archive+Reading.swift in Sources */ = {isa = PBXBuildFile; fileRef = E865600EE9117A9153716299FFA7912D /* Archive+Reading.swift */; }; - F7AA4D5FCE8E17C48A14FB08E61781C2 /* RDEPUBPageInteractionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1156A086D0660F13701595F2CA185B73 /* RDEPUBPageInteractionController.swift */; }; - F881F0F632ED90609AF3055471A3E5B2 /* RDEPUBHTMLNormalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C8C8F9EC45583D9AC337EE967208872 /* RDEPUBHTMLNormalizer.swift */; }; - F978F917AEE877B3955CC71F69AEDA3F /* RDEPUBReaderChromeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15C30720702E12C08A23F03FF9F06A46 /* RDEPUBReaderChromeCoordinator.swift */; }; + F71ED6002B31FF204556F29CA0265143 /* RDEPUBReaderController+ExternalLinks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3492A7E1375C07EAACE09CF11D14212D /* RDEPUBReaderController+ExternalLinks.swift */; }; + F7591EEF257622C45FB4686F28808841 /* RDEPUBTextPerformanceSampler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3723CC3DB0E91BE41710A956910CA87F /* RDEPUBTextPerformanceSampler.swift */; }; + F7C33530A1B3E5BFFDC06C08D888DD5D /* RDEPUBSelectionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82BB9B078A1C0E0BDB1BFBB93A670A2A /* RDEPUBSelectionState.swift */; }; + FA5E7F24DE52E5452B0EC9091D352602 /* RDEPUBPageBreakPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD12C1ABCF0800609F8579BC62C4997F /* RDEPUBPageBreakPolicy.swift */; }; FAC3DC7E5DE280771DD4A21A7436E52F /* DTTiledLayerWithoutFade.h in Headers */ = {isa = PBXBuildFile; fileRef = 0764D155F69503AACFA7AD976825E986 /* DTTiledLayerWithoutFade.h */; settings = {ATTRIBUTES = (Public, ); }; }; - FB6229275E860304DE410C3ABACA8368 /* RDReaderContentCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5CA72BC08E08AF5467ECB9D87611149 /* RDReaderContentCell.swift */; }; - FBB3C772E5087E1951F211ED447FD8C2 /* RDEPUBDarkImageAdjuster.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E6187139F503118F5A562E9A48344D4 /* RDEPUBDarkImageAdjuster.swift */; }; FC5F52919451A15639442B46B38E4C67 /* DTCoreTextLayoutFrame+Cursor.m in Sources */ = {isa = PBXBuildFile; fileRef = EF625CF6A790938F5CE0F0C7DC36E340 /* DTCoreTextLayoutFrame+Cursor.m */; }; FC9DC8D4C96D819E9BE20F05441E0459 /* DTVersion.h in Headers */ = {isa = PBXBuildFile; fileRef = 8C9575F05D72C239E7E6F079BACDF4C7 /* DTVersion.h */; settings = {ATTRIBUTES = (Public, ); }; }; + FCE6A5A737B87FA6CDEF85A5AB54ACD0 /* Archive+ZIP64.swift in Sources */ = {isa = PBXBuildFile; fileRef = D18519BCA8FAA9B01A704A0F0B9FFC78 /* Archive+ZIP64.swift */; }; + FD088FA26275687CF53057621ADB903C /* RDEPUBReaderController+PublicAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBF4C131ADF1BC0D64D695B49AAC8370 /* RDEPUBReaderController+PublicAPI.swift */; }; + FD597D941EC356BFB833A672BABCA2D0 /* RDEPUBReaderChromeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DDA63DD315651FD20B81BE242ADFC72 /* RDEPUBReaderChromeCoordinator.swift */; }; FD5D68887297CFA94F543C49DC68A156 /* DTHTMLAttributedStringBuilder.m in Sources */ = {isa = PBXBuildFile; fileRef = E0192469A486DA7CECFECEA37518B6B9 /* DTHTMLAttributedStringBuilder.m */; }; FDFFFDA2B22F1AF0F845CB42C63CFDFB /* DTAnimatedGIF.h in Headers */ = {isa = PBXBuildFile; fileRef = F9D9BEF72311AF9E3239F9B92A9D2842 /* DTAnimatedGIF.h */; settings = {ATTRIBUTES = (Public, ); }; }; FEB00C614DCB6A8A7BDF8DEE11260984 /* NSMutableAttributedString+HTML.m in Sources */ = {isa = PBXBuildFile; fileRef = 7CA72FA9A4F2F42A75C58325738A44AC /* NSMutableAttributedString+HTML.m */; }; - FEBA341DB483809E5E6867783DDA6A11 /* RDEPUBReaderDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4EA2355F64BEA505537D228D101D967 /* RDEPUBReaderDelegate.swift */; }; - FFEB74D61E54DC46EFE0DBE2E76EFAA1 /* Pods-ReadViewDemo-umbrella.h in Headers */ = {isa = PBXBuildFile; fileRef = EBA0272559B0F7FAD8AC670AC7431188 /* Pods-ReadViewDemo-umbrella.h */; settings = {ATTRIBUTES = (Public, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 0A9B46C05BF3C3A70BDDF3F755A55301 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = BFDFE7DC352907FC980B868725387E98 /* Project object */; + proxyType = 1; + remoteGlobalIDString = E07DDD766DCBF90486BCE6C2EA671E5E; + remoteInfo = "RDEpubReaderView-RDEpubReaderViewAssets"; + }; 14DAE0FAD0D1B19388CB9AA5D13DC5A4 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = BFDFE7DC352907FC980B868725387E98 /* Project object */; @@ -458,62 +465,55 @@ remoteGlobalIDString = 0C24CB0E87A728A11AA1124CB360D6A1; remoteInfo = "DTCoreText-Resources"; }; - 34C38B7572F96194A86992F982080DE3 /* PBXContainerItemProxy */ = { + 184572994656765806610539FDE97D20 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = BFDFE7DC352907FC980B868725387E98 /* Project object */; proxyType = 1; - remoteGlobalIDString = B88F4EA0695B6B3165C64594850D72C7; - remoteInfo = DTCoreText; + remoteGlobalIDString = FBCB57A3AEAAA3449B3F365930BC0977; + remoteInfo = RDEpubReaderView; }; - 351888AD0675DE4DF54F06BC38FB2999 /* PBXContainerItemProxy */ = { + 291617C41AB3A88D1C83DC7062E96890 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = BFDFE7DC352907FC980B868725387E98 /* Project object */; proxyType = 1; remoteGlobalIDString = C7A8D82E407CD3FDC3BA55CEE519B252; remoteInfo = "ZIPFoundation-ZIPFoundation_Privacy"; }; - 7677F420C9062853DC411E1D6CD67450 /* PBXContainerItemProxy */ = { + 5B1CF3E8F84553527E617670496C2759 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = BFDFE7DC352907FC980B868725387E98 /* Project object */; proxyType = 1; - remoteGlobalIDString = AE7F393FB7805DE2664AB4111873F907; - remoteInfo = "RDReaderView-RDReaderViewAssets"; + remoteGlobalIDString = AA15C8469D67684160CC2A7098EB841C; + remoteInfo = ZIPFoundation; }; - 84ADF252286F6B6A90FDCAF49CFD1CF7 /* PBXContainerItemProxy */ = { + 89EEF9DF71A6BD020500EED6F3B6F74F /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = BFDFE7DC352907FC980B868725387E98 /* Project object */; + proxyType = 1; + remoteGlobalIDString = B88F4EA0695B6B3165C64594850D72C7; + remoteInfo = DTCoreText; + }; + 9CABD6887800C32918AF4DF87BFA0A2E /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = BFDFE7DC352907FC980B868725387E98 /* Project object */; + proxyType = 1; + remoteGlobalIDString = B88F4EA0695B6B3165C64594850D72C7; + remoteInfo = DTCoreText; + }; + C3309B36153779AA44D0A11AA303CD30 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = BFDFE7DC352907FC980B868725387E98 /* Project object */; proxyType = 1; remoteGlobalIDString = 8F6E5A5BF72D62CDFD25F91A7CFA3309; remoteInfo = DTFoundation; }; - 909CE499960E68FFEEAC3F07CCED816D /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = BFDFE7DC352907FC980B868725387E98 /* Project object */; - proxyType = 1; - remoteGlobalIDString = AA2E57587AA8EECA63C4BE08EA3CB6D2; - remoteInfo = RDReaderView; - }; - DA8CE4540395617C72E05FAD18A9E528 /* PBXContainerItemProxy */ = { + F3A9CFD4766AB58EAD7C800B495663A5 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = BFDFE7DC352907FC980B868725387E98 /* Project object */; proxyType = 1; remoteGlobalIDString = AA15C8469D67684160CC2A7098EB841C; remoteInfo = ZIPFoundation; }; - E02D487CC5FCE17A09C6D7F992E61FA5 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = BFDFE7DC352907FC980B868725387E98 /* Project object */; - proxyType = 1; - remoteGlobalIDString = AA15C8469D67684160CC2A7098EB841C; - remoteInfo = ZIPFoundation; - }; - E6196200916399BA1FC57E48F75BEA93 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = BFDFE7DC352907FC980B868725387E98 /* Project object */; - proxyType = 1; - remoteGlobalIDString = B88F4EA0695B6B3165C64594850D72C7; - remoteInfo = DTCoreText; - }; FC2A40799EA5B233DDF797D1E36600CE /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = BFDFE7DC352907FC980B868725387E98 /* Project object */; @@ -524,152 +524,149 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 0020851315497D536AE8CB195B6BCA5B /* RDEPUBTextPaginationSupport.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBTextPaginationSupport.swift; sourceTree = ""; }; + 00385D2EEBD20524770BBB6A89C4DB3D /* RDEPUBPageInteractionController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBPageInteractionController.swift; sourceTree = ""; }; 006D002D86B43AA52E0AFD2E18459107 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = Sources/ZIPFoundation/Resources/PrivacyInfo.xcprivacy; sourceTree = ""; }; - 00E9FEEAFFD8790F7439A91AD283F5F3 /* RDEPUBCFISerializer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBCFISerializer.swift; sourceTree = ""; }; - 03381B40A2B1AD4E41BCC3BE2C31B626 /* RDEPUBReaderSearchBarView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderSearchBarView.swift; sourceTree = ""; }; + 00C39185B11404EA80AFE34E589E7496 /* RDEPUBRenderRequest.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBRenderRequest.swift; sourceTree = ""; }; + 00CDDB6857017E4C7EFF619210E70807 /* RDEPUBChapterPageCounter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBChapterPageCounter.swift; sourceTree = ""; }; + 01DCD1B42D40839F1C8DBBDB11EF09BA /* RDEpubReaderView */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; name = RDEpubReaderView; path = RDEpubReaderView.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 025D419F9C7DF8D9768FF73AA996BB88 /* RDEPUBTextIndexTable.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBTextIndexTable.swift; sourceTree = ""; }; 0372FD1824BC43F7BD6B337430201420 /* DTCoreGraphicsUtils.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTCoreGraphicsUtils.m; path = Core/Source/DTCoreGraphicsUtils.m; sourceTree = ""; }; + 03B34F9D700619AE17BE5522BBABC08E /* RDEPUBChapterLoader.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBChapterLoader.swift; sourceTree = ""; }; + 0437B4D43E3BC006FB3D6082F6DF0C43 /* WeReadApi.js */ = {isa = PBXFileReference; includeInIndex = 1; name = WeReadApi.js; path = Sources/RDEpubReaderView/EPUBCore/Resources/WeReadApi.js; sourceTree = ""; }; 044BEA679671E16EA9BA9AD627750742 /* NSMutableAttributedString+HTML.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = "NSMutableAttributedString+HTML.h"; path = "Core/Source/NSMutableAttributedString+HTML.h"; sourceTree = ""; }; 045ADBF2087ADD914A9CA0B62EBF2A9F /* Pods-ReadViewDemo.modulemap */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.module; path = "Pods-ReadViewDemo.modulemap"; sourceTree = ""; }; 057DBFBFC99448A0B77A3C0DBB13B4A9 /* NSScanner+HTML.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = "NSScanner+HTML.m"; path = "Core/Source/NSScanner+HTML.m"; sourceTree = ""; }; 05838D65DEC339C51AADB17346B7E287 /* NSDictionary+DTError.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = "NSDictionary+DTError.h"; path = "Core/Source/NSDictionary+DTError.h"; sourceTree = ""; }; - 0634E78287117CD08963FC5011711674 /* RDEPUBTextContentInteractionCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBTextContentInteractionCoordinator.swift; sourceTree = ""; }; 068885F1B54CFD5DBBE6E2DC253F7819 /* DTAttributedTextCell.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTAttributedTextCell.h; path = Core/Source/DTAttributedTextCell.h; sourceTree = ""; }; + 06A86745BE9CD1C490EC229FA2F09BBD /* RDEPUBImageViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBImageViewController.swift; sourceTree = ""; }; 0764D155F69503AACFA7AD976825E986 /* DTTiledLayerWithoutFade.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTTiledLayerWithoutFade.h; path = Core/Source/iOS/DTTiledLayerWithoutFade.h; sourceTree = ""; }; - 07EC70D349899B89F4546FFE4DE46EEC /* RDEPUBChapterLoader.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBChapterLoader.swift; sourceTree = ""; }; + 07EF7DFA2327CD5059A2A0B693813195 /* RDEPUBReaderDependencies.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderDependencies.swift; sourceTree = ""; }; + 07FC18458844B9ADFDC71FB8C1A085DE /* RDEPUBTextBookBuilder.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBTextBookBuilder.swift; sourceTree = ""; }; 0834E84632886CDD9911CAB76183F1CE /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/QuartzCore.framework; sourceTree = DEVELOPER_DIR; }; - 085DA39E20C3A758783B115AB62B1E72 /* RDReaderView-dummy.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; path = "RDReaderView-dummy.m"; sourceTree = ""; }; - 0879F59F605F8E1512ADB3D29CB7912E /* RDEPUBCFIParser.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBCFIParser.swift; sourceTree = ""; }; + 08AA95F9E01837CBD5833F9D03F5FC8A /* RDEPUBAttachmentTooltipView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBAttachmentTooltipView.swift; sourceTree = ""; }; 08F54C3D9B4AECA08A47309615DC2715 /* DTFoundation-prefix.pch */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "DTFoundation-prefix.pch"; sourceTree = ""; }; 09FF858DC29B73E9B67B32B03CFF4084 /* DTAttributedLabel.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTAttributedLabel.h; path = Core/Source/DTAttributedLabel.h; sourceTree = ""; }; + 0B1FC7245885C5EE717A583C35D1EC08 /* RDEPUBNoteResolver.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBNoteResolver.swift; sourceTree = ""; }; 0B8321AB80CFE0B94537AEA63E9A79F5 /* NSString+Paragraphs.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = "NSString+Paragraphs.h"; path = "Core/Source/NSString+Paragraphs.h"; sourceTree = ""; }; - 0BFA10454111B92A9838334E4C3BDD21 /* RDEPUBChapterOffsetMap.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBChapterOffsetMap.swift; sourceTree = ""; }; 0C24F592C4F38F15DC8C46BE0442B59C /* DTCSSListStyle.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTCSSListStyle.h; path = Core/Source/DTCSSListStyle.h; sourceTree = ""; }; - 0C6F4C9D48FC61243E5E405457EB1AB3 /* RDEPUBSearchModels.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBSearchModels.swift; sourceTree = ""; }; - 0CE5DDE420852CFC895FEF2572577505 /* RDEPUBCFIMarkerInjector.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBCFIMarkerInjector.swift; sourceTree = ""; }; 0D93C2002AAECD29B3ED5A14026D3DFE /* Entry.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Entry.swift; path = Sources/ZIPFoundation/Entry.swift; sourceTree = ""; }; - 0EA7439F32DE1A2349B94E9F3EA60EFB /* RDEPUBResourceURLSchemeHandler.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBResourceURLSchemeHandler.swift; sourceTree = ""; }; - 0ED3B8B105513BA029A8DAC14677F712 /* RDEPUBBackgroundPriorityPolicy.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBBackgroundPriorityPolicy.swift; sourceTree = ""; }; + 0D94BA24C3E1653B2ADF0961B2DFD09F /* RDEPUBSelectionOverlayView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBSelectionOverlayView.swift; sourceTree = ""; }; + 0E7113A77F93EA57B195F8A039935FF3 /* RDEPUBCFIUtilities.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBCFIUtilities.swift; sourceTree = ""; }; 0F80AC45FB1DBF50E0E3AA4F26BFA676 /* DTFoundationConstants.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTFoundationConstants.m; path = Core/Source/DTFoundationConstants.m; sourceTree = ""; }; - 104CAC07B59B05601D706C051CFB2EB3 /* RDEPUBParser+Resources.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "RDEPUBParser+Resources.swift"; sourceTree = ""; }; - 1156A086D0660F13701595F2CA185B73 /* RDEPUBPageInteractionController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBPageInteractionController.swift; sourceTree = ""; }; + 0FFD7DFEB52262536F9A2C19B111C229 /* RDEPUBReaderTopToolView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderTopToolView.swift; sourceTree = ""; }; + 117C8D3804CD684A0BCFB28E527FE704 /* RDEPUBResourceURLSchemeHandler.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBResourceURLSchemeHandler.swift; sourceTree = ""; }; + 1251AEBAF40F77167C2B57D0A466DAA4 /* RDEPUBTextPageDecorationView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBTextPageDecorationView.swift; sourceTree = ""; }; 126E6ACAC7C0427806B58DA8E10BDD78 /* NSMutableString+HTML.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = "NSMutableString+HTML.m"; path = "Core/Source/NSMutableString+HTML.m"; sourceTree = ""; }; - 12B21DE048B2D9B7F8A8075D627B518F /* RDEPUBTextPageDecorationView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBTextPageDecorationView.swift; sourceTree = ""; }; - 1300C3BDE866D4C29C0792C55713758D /* RDEPUBReaderContext.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderContext.swift; sourceTree = ""; }; 1300CC5794E609DB8FBA6FFF90A09408 /* UIImage+DTFoundation.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = "UIImage+DTFoundation.h"; path = "Core/Source/iOS/UIImage+DTFoundation.h"; sourceTree = ""; }; - 1310063EDD27977AEF26DC0B83BA0811 /* RDEPUBFragmentMarkerInjector.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBFragmentMarkerInjector.swift; sourceTree = ""; }; 131C1D8C96DF2D44A2D4F50C5AEFFF11 /* NSString+DTURLEncoding.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = "NSString+DTURLEncoding.m"; path = "Core/Source/NSString+DTURLEncoding.m"; sourceTree = ""; }; 13597E7190F2C08C5906D7D128E43E99 /* DTObjectTextAttachment.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTObjectTextAttachment.h; path = Core/Source/DTObjectTextAttachment.h; sourceTree = ""; }; 13F8FC8B51AD434F5D4B0CF5150D7563 /* DTCoreText-umbrella.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "DTCoreText-umbrella.h"; sourceTree = ""; }; 14283024FA4A0231B32CA22AA907FEFA /* DTBreakHTMLElement.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTBreakHTMLElement.h; path = Core/Source/DTBreakHTMLElement.h; sourceTree = ""; }; - 1429432AAB58A7FBEEA14E2716F7D97E /* RDEPUBAttachmentNormalizer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBAttachmentNormalizer.swift; sourceTree = ""; }; 148B993AB228816D19FCB7CD13874C83 /* DTAnchorHTMLElement.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTAnchorHTMLElement.m; path = Core/Source/DTAnchorHTMLElement.m; sourceTree = ""; }; 14E63A0D35D4A07BD0123021EA161C99 /* DTHTMLWriter.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTHTMLWriter.h; path = Core/Source/DTHTMLWriter.h; sourceTree = ""; }; - 15C30720702E12C08A23F03FF9F06A46 /* RDEPUBReaderChromeCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderChromeCoordinator.swift; sourceTree = ""; }; - 165E51AE35F6043DF9684A794A48F348 /* UIColor+RDEPUBHex.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "UIColor+RDEPUBHex.swift"; sourceTree = ""; }; - 16735EDC0457A01293AE4660C1097B43 /* RDEPUBReaderSearchCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderSearchCoordinator.swift; sourceTree = ""; }; - 169DD70FDD7715783E8DD88ADB49E35E /* RDEPUBReaderController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderController.swift; sourceTree = ""; }; + 157F831C6FDD7EC227517F29E2CB8590 /* RDEPUBDTCoreTextRenderer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBDTCoreTextRenderer.swift; sourceTree = ""; }; + 1633419ACBF4D75136CB54A9595F07AB /* RDEPUBWebView+JavaScriptBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "RDEPUBWebView+JavaScriptBridge.swift"; sourceTree = ""; }; 173194D38C897D8E38AD8A194E47DB5C /* DTPieProgressIndicator.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTPieProgressIndicator.h; path = Core/Source/iOS/DTPieProgressIndicator.h; sourceTree = ""; }; - 17E520FDB2483CFB8A76E13BAB2BFFD8 /* RDEPUBAnnotationModels.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBAnnotationModels.swift; sourceTree = ""; }; - 18CD683F1DDEBEF3EDA621F9D3E95178 /* RDEPUBCFIMap.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBCFIMap.swift; sourceTree = ""; }; + 17B38E01CD7C86BDC366392058A1D719 /* UIColor+RDEPUB.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "UIColor+RDEPUB.swift"; sourceTree = ""; }; 18D23D8D10719754D93032F66EB98536 /* Date+ZIP.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "Date+ZIP.swift"; path = "Sources/ZIPFoundation/Date+ZIP.swift"; sourceTree = ""; }; - 18D7EB46EC2C4DD8B46B6FADEFD16BB6 /* RDEPUBNotePopupViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBNotePopupViewController.swift; sourceTree = ""; }; 18F842408AB76A580B80255453F2FDF6 /* DTAccessibilityElement.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTAccessibilityElement.m; path = Core/Source/DTAccessibilityElement.m; sourceTree = ""; }; + 19169CBEAD412B0E1C17F517F5B30269 /* RDEPUBWebView+Search.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "RDEPUBWebView+Search.swift"; sourceTree = ""; }; 199E2ECC77BDAC03F644C20040F042D4 /* DTLog.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTLog.m; path = Core/Source/DTLog.m; sourceTree = ""; }; + 19A9DF3B54605BC418E5EC2AC9C285F9 /* RDEPUBReaderServices.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderServices.swift; sourceTree = ""; }; + 1A18237B2AA906DE9B54F3FBE756BD95 /* RDEPUBParser+Package.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "RDEPUBParser+Package.swift"; sourceTree = ""; }; 1A88AA04D7B29A217B432C19AC48AB6B /* DTStylesheetHTMLElement.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTStylesheetHTMLElement.h; path = Core/Source/DTStylesheetHTMLElement.h; sourceTree = ""; }; - 1AA0F27424B80E5D07E45D8DA86C9C26 /* epub-bridge.js */ = {isa = PBXFileReference; includeInIndex = 1; name = "epub-bridge.js"; path = "Sources/RDReaderView/EPUBCore/Resources/epub-bridge.js"; sourceTree = ""; }; 1AD502A1FA6EB1E38200B5283F979504 /* Pods-ReadViewDemo.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = "Pods-ReadViewDemo.release.xcconfig"; sourceTree = ""; }; - 1EAC09034BA70013D5EB674B407F2C63 /* RDEPUBChapterRuntimeStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBChapterRuntimeStore.swift; sourceTree = ""; }; + 1BB49FBD6621286BA60757349FCC6883 /* RDEPUBAssetRepository.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBAssetRepository.swift; sourceTree = ""; }; + 1C12125CE2086E4553539302D7B12BB3 /* RDEPUBCFIResolver.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBCFIResolver.swift; sourceTree = ""; }; + 1C746B6D8EF063C2B6C54882895756D6 /* RDEPUBChapterWarmupOrchestrator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBChapterWarmupOrchestrator.swift; sourceTree = ""; }; + 1DFD71BAF612FE4B521B30E7E24C4E1A /* RDEPUBReadingSession.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReadingSession.swift; sourceTree = ""; }; 1EF19AD0EF4889156D46061FB43E0F5D /* DTCoreTextFontDescriptor.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTCoreTextFontDescriptor.h; path = Core/Source/DTCoreTextFontDescriptor.h; sourceTree = ""; }; 1F1F7BFB63DE15D627EB8F64829B8AAD /* NSURL+DTComparing.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = "NSURL+DTComparing.h"; path = "Core/Source/NSURL+DTComparing.h"; sourceTree = ""; }; - 1FA75FCB2F9051FFC2C7EC7FD801B3B6 /* RDEPUBReaderTopToolView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderTopToolView.swift; sourceTree = ""; }; + 1F922CBFACAAD201E732987D727209EE /* RDEPUBTextPageVerticalJustifier.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBTextPageVerticalJustifier.swift; sourceTree = ""; }; 1FA88D00B7B2B2F0BA98BB92DF035906 /* DTDictationPlaceholderView.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTDictationPlaceholderView.m; path = Core/Source/DTDictationPlaceholderView.m; sourceTree = ""; }; 2047430DF5C84E0A8FC8874A2B915FC3 /* Pods-ReadViewDemo-acknowledgements.markdown */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text; path = "Pods-ReadViewDemo-acknowledgements.markdown"; sourceTree = ""; }; 207CC0EEB74CECEB43A870FF80560E0F /* DTLog.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTLog.h; path = Core/Source/DTLog.h; sourceTree = ""; }; 214F0FC87DE99D3FCE69D98B357F3FFC /* DTCoreTextLayoutFrame+Cursor.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = "DTCoreTextLayoutFrame+Cursor.h"; path = "Core/Source/DTCoreTextLayoutFrame+Cursor.h"; sourceTree = ""; }; 21CCCCAAF832BE9C859BEDDC79B4E9E2 /* DTSmartPagingScrollView.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTSmartPagingScrollView.h; path = Core/Source/iOS/DTSmartPagingScrollView.h; sourceTree = ""; }; - 22887CE9C9E366BD11055E6B101C9A00 /* RDEPUBChapterPageCounter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBChapterPageCounter.swift; sourceTree = ""; }; + 229118D72FEDE65A83CE684333A3E6C0 /* RDEPUBPreferences.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBPreferences.swift; sourceTree = ""; }; 22B69C9433F2FBE5CE8409BD4266DFE8 /* Data+Compression.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "Data+Compression.swift"; path = "Sources/ZIPFoundation/Data+Compression.swift"; sourceTree = ""; }; 23006DBAC610AEB0A4B3EAFEA4FB0567 /* DTTiledLayerWithoutFade.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTTiledLayerWithoutFade.m; path = Core/Source/iOS/DTTiledLayerWithoutFade.m; sourceTree = ""; }; 23588B63FC4026A371F5C683744A664C /* DTFoundation-umbrella.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "DTFoundation-umbrella.h"; sourceTree = ""; }; 2361BC151481D2717ED43C5659725945 /* ZIPFoundation.modulemap */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.module; path = ZIPFoundation.modulemap; sourceTree = ""; }; 2373DCA0FC45287BFC063BEA661B8367 /* Archive+WritingDeprecated.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "Archive+WritingDeprecated.swift"; path = "Sources/ZIPFoundation/Archive+WritingDeprecated.swift"; sourceTree = ""; }; - 238893C9CBD95CB9BDF069EB5A303E17 /* RDReaderView */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; name = RDReaderView; path = RDReaderView.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 25DDD76CA572F0C7AB5A8178CF80169D /* DTCoreTextFontCollection.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTCoreTextFontCollection.h; path = Core/Source/DTCoreTextFontCollection.h; sourceTree = ""; }; + 27176E77CA7151E15AC5ED7E22E7AD11 /* RDEPUBChapterDataCache.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBChapterDataCache.swift; sourceTree = ""; }; + 272CA6E32EEAE8E81504F1CD3000E925 /* RDEPUBNoteModels.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBNoteModels.swift; sourceTree = ""; }; 27372A23AA4B051867E7D2C749D1DE81 /* DTImageTextAttachment.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTImageTextAttachment.h; path = Core/Source/DTImageTextAttachment.h; sourceTree = ""; }; - 274FAE68017F54F1F939837B96E2826D /* RDEPUBJumpSession.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBJumpSession.swift; sourceTree = ""; }; - 27A8E420B22CCB314B97FF093673D7A7 /* RDEPUBReaderConfiguration.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderConfiguration.swift; sourceTree = ""; }; + 27F9CACA6F2EE61A3431B82175020F04 /* RDEpubReaderView-prefix.pch */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "RDEpubReaderView-prefix.pch"; sourceTree = ""; }; 288589D0B52F73505B43C743D9837578 /* Entry+ZIP64.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "Entry+ZIP64.swift"; path = "Sources/ZIPFoundation/Entry+ZIP64.swift"; sourceTree = ""; }; - 28F849C864C21B91AE83565813ECE513 /* RDEPUBBookPageMap.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBBookPageMap.swift; sourceTree = ""; }; 29DEA4D201BB179BB6E228E9F688CD4C /* UIView+DTFoundation.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = "UIView+DTFoundation.h"; path = "Core/Source/iOS/UIView+DTFoundation.h"; sourceTree = ""; }; - 2B0931BA1423E80768E11D26C280D4AF /* RDEPUBBackgroundTrace.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBBackgroundTrace.swift; sourceTree = ""; }; + 29F6775325522C48C45B7023AF9B83EB /* RDEPUBReaderLoadCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderLoadCoordinator.swift; sourceTree = ""; }; + 2AF6AEEC13967258B9E5E8BF691ADDC8 /* RDEPUBReaderController+Trial.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "RDEPUBReaderController+Trial.swift"; sourceTree = ""; }; + 2B502241DFB110D155ACB255B7597984 /* RDEPUBSearchModels.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBSearchModels.swift; sourceTree = ""; }; 2C1A7359A45BB880877A672C57F522FE /* DTCoreGraphicsUtils.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTCoreGraphicsUtils.h; path = Core/Source/DTCoreGraphicsUtils.h; sourceTree = ""; }; - 2C5BF48946F992DC481016853F217CE2 /* RDEPUBReaderUIState.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderUIState.swift; sourceTree = ""; }; - 2CB104ADA375D292371993C1CB6AAB87 /* RDEPUBTextPageRenderView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBTextPageRenderView.swift; sourceTree = ""; }; - 2F1CF1FACBA97E3B87ED0ECBBA7271E2 /* RDReaderPageChildViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDReaderPageChildViewController.swift; sourceTree = ""; }; + 2DC9A66DE304F1176D36FD9294F0E536 /* RDEPUBReaderController+LocationResolution.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "RDEPUBReaderController+LocationResolution.swift"; sourceTree = ""; }; 2F242FC1357DD90FA4FD67E1DB90A837 /* NSCharacterSet+HTML.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = "NSCharacterSet+HTML.m"; path = "Core/Source/NSCharacterSet+HTML.m"; sourceTree = ""; }; 2F75437CE9F9324D315E417014797C1D /* DTExtendedFileAttributes.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTExtendedFileAttributes.h; path = Core/Source/DTExtendedFileAttributes.h; sourceTree = ""; }; 3046397381A83C2F6BAF344B86AD176E /* NSMutableArray+DTMoving.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = "NSMutableArray+DTMoving.h"; path = "Core/Source/NSMutableArray+DTMoving.h"; sourceTree = ""; }; 306477706BBB1E784F55BCF8EB1AD737 /* Pods-ReadViewDemo.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = "Pods-ReadViewDemo.debug.xcconfig"; sourceTree = ""; }; 30EB4CF4AD4114DE53786C318621EB19 /* NSAttributedStringRunDelegates.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = NSAttributedStringRunDelegates.h; path = Core/Source/NSAttributedStringRunDelegates.h; sourceTree = ""; }; - 3124D3BC58A0A9244ACC822A8BF0860E /* RDEPUBChapterTailNormalizer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBChapterTailNormalizer.swift; sourceTree = ""; }; - 335C0A7F69EC3B4D9CB309D814D08733 /* RDEPUBChapterSummaryDiskCache.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBChapterSummaryDiskCache.swift; sourceTree = ""; }; - 3429C49A65BE1911C634E1C95A0579BA /* RDEPUBReaderToolViewProtocols.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderToolViewProtocols.swift; sourceTree = ""; }; + 3108DBF4D182FE04D56AB715DE874117 /* RDEpubReaderView-dummy.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; path = "RDEpubReaderView-dummy.m"; sourceTree = ""; }; + 327CD220C81409236F700C96851DB0E0 /* RDEPUBRenderDiagnosticsCollector.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBRenderDiagnosticsCollector.swift; sourceTree = ""; }; + 339103E5D1A1C62BF7B414EE689069F8 /* RDEPUBReaderHighlightsViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderHighlightsViewController.swift; sourceTree = ""; }; + 33CD1120A32A59966196C179B158D57C /* rangy-core.js */ = {isa = PBXFileReference; includeInIndex = 1; name = "rangy-core.js"; path = "Sources/RDEpubReaderView/EPUBCore/Resources/rangy-core.js"; sourceTree = ""; }; + 3415CCD5F8080BB63D594BDCFEA0EB86 /* RDEPUBSafeArea.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBSafeArea.swift; sourceTree = ""; }; + 344F132625D1DB107D6F88F810131154 /* RDEPUBFontFallbackResolver.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBFontFallbackResolver.swift; sourceTree = ""; }; 3453CFB9BDBF6F9D0937EDF5FB2BD07B /* NSURL+DTAppLinks.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = "NSURL+DTAppLinks.m"; path = "Core/Source/iOS/NSURL+DTAppLinks.m"; sourceTree = ""; }; - 34D52809A0CFBA9664082FEDE2EBF420 /* RDEPUBCFIRecoveryEngine.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBCFIRecoveryEngine.swift; sourceTree = ""; }; + 3492A7E1375C07EAACE09CF11D14212D /* RDEPUBReaderController+ExternalLinks.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "RDEPUBReaderController+ExternalLinks.swift"; sourceTree = ""; }; 35417EC94F2D58CB80C991F875CE15C2 /* DTBlockFunctions.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTBlockFunctions.h; path = Core/Source/DTBlockFunctions.h; sourceTree = ""; }; 354C1C3A74DB75C8B8608D538212A963 /* DTStylesheetHTMLElement.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTStylesheetHTMLElement.m; path = Core/Source/DTStylesheetHTMLElement.m; sourceTree = ""; }; 3578B97A85BCDC60888FDF87B8F2600C /* NSString+CSS.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = "NSString+CSS.h"; path = "Core/Source/NSString+CSS.h"; sourceTree = ""; }; - 35AAB84F53D47E997BE5822C68A820E1 /* RDEPUBCFIPath.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBCFIPath.swift; sourceTree = ""; }; - 35F49784F5C7572343EA12BD542EC199 /* RDEPUBDecryptingImageAttachment.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBDecryptingImageAttachment.swift; sourceTree = ""; }; + 3723CC3DB0E91BE41710A956910CA87F /* RDEPUBTextPerformanceSampler.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBTextPerformanceSampler.swift; sourceTree = ""; }; 37327141DC73DE2D5742BE6F989094CE /* DTCoreTextLayoutLine.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTCoreTextLayoutLine.h; path = Core/Source/DTCoreTextLayoutLine.h; sourceTree = ""; }; - 3840A5C7212D01AADA285E04510E8020 /* RDEPUBModels.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBModels.swift; sourceTree = ""; }; - 3850371BB41E7533AF689FAA5ED33F9B /* RDEPUBNoteResolver.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBNoteResolver.swift; sourceTree = ""; }; - 38C859102801709085785434DA17448C /* RDReaderGestureController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDReaderGestureController.swift; sourceTree = ""; }; + 376805E5E5095E2902BB687236336C0F /* RDEPUBReaderAnnotationCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderAnnotationCoordinator.swift; sourceTree = ""; }; + 38887F7296CA064DFC5B3ABBE16EE16C /* RDEPUBReaderController+DataSource.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "RDEPUBReaderController+DataSource.swift"; sourceTree = ""; }; 3985FED7BD8EC49191663BEF73FB2C79 /* NSAttributedString+DTCoreText.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = "NSAttributedString+DTCoreText.h"; path = "Core/Source/NSAttributedString+DTCoreText.h"; sourceTree = ""; }; 398DC798680E2CA64D46B9E19B380356 /* UIFont+DTCoreText.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = "UIFont+DTCoreText.h"; path = "Core/Source/UIFont+DTCoreText.h"; sourceTree = ""; }; 399AED6BF151EC39BE4450784795A832 /* DTAccessibilityViewProxy.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTAccessibilityViewProxy.h; path = Core/Source/DTAccessibilityViewProxy.h; sourceTree = ""; }; 3A6A858B976D032A3C61540AFF19DEE4 /* NSURL+DTUnshorten.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = "NSURL+DTUnshorten.m"; path = "Core/Source/NSURL+DTUnshorten.m"; sourceTree = ""; }; - 3B2E80F85DC4DFBB0320EB582FCBD933 /* RDEPUBReaderTableOfContentsItem.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderTableOfContentsItem.swift; sourceTree = ""; }; - 3B4326BC8EF36B8BB0A32D8E047921AD /* RDEPUBReaderServices.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderServices.swift; sourceTree = ""; }; - 3C9F522B5818D3E8F6E4B10B22A9F3C9 /* cssInjector.js */ = {isa = PBXFileReference; includeInIndex = 1; name = cssInjector.js; path = Sources/RDReaderView/EPUBCore/Resources/cssInjector.js; sourceTree = ""; }; - 3CEB851D42C524A554A2D26249EB6440 /* RDEPUBMemoryProbe.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBMemoryProbe.swift; sourceTree = ""; }; + 3AF5C2B69E3DDD7DB21E7A3498D81D3B /* RDEPUBCFIError.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBCFIError.swift; sourceTree = ""; }; + 3B06436C03160A6968FEE44D6C916F2E /* RDEPUBWebView+Reflowable.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "RDEPUBWebView+Reflowable.swift"; sourceTree = ""; }; + 3D7FE9BC0B7335F2AB850FC2EC727ACD /* RDEPUBTextLayouter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBTextLayouter.swift; sourceTree = ""; }; 3DAC9D3802A9F165B2F6B09C2DCF0EB7 /* ResourceBundle-ZIPFoundation_Privacy-ZIPFoundation-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = "ResourceBundle-ZIPFoundation_Privacy-ZIPFoundation-Info.plist"; sourceTree = ""; }; - 3EDE2594DAA2AA3DEE0DC6FBB8021FB4 /* RDEPUBParser+ReadingProfile.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "RDEPUBParser+ReadingProfile.swift"; sourceTree = ""; }; + 3E6420F3C34E33BDB146AE066B20ED88 /* RDEPUBWebView+FixedLayout.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "RDEPUBWebView+FixedLayout.swift"; sourceTree = ""; }; 3EFAE8DFDDEA1E6047289C86D66381C0 /* DTAttributedLabel.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTAttributedLabel.m; path = Core/Source/DTAttributedLabel.m; sourceTree = ""; }; - 3F64125A1A7278D5D5DBEE29D0A23289 /* RDEPUBResourceDataProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBResourceDataProvider.swift; sourceTree = ""; }; 40FD7C9E2E05FB4A73F4C4C70A3AFAF0 /* DTCSSListStyle.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTCSSListStyle.m; path = Core/Source/DTCSSListStyle.m; sourceTree = ""; }; - 413360D0D62A7D3B12EAB6FD542C8B29 /* RDEPUBReaderChapterListController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderChapterListController.swift; sourceTree = ""; }; - 41DE1F810FCEEA86135A8828CE7E0B2E /* RDEPUBStyleCompatibilityModels.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBStyleCompatibilityModels.swift; sourceTree = ""; }; - 42C462DBD1028C2B2C741726F0C4F7D6 /* RDEPUBFontNormalizer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBFontNormalizer.swift; sourceTree = ""; }; - 44113776B1D8777D7989AA4A28E7AD89 /* RDEPUBResourceResolver.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBResourceResolver.swift; sourceTree = ""; }; + 4186A080B9749A79809873298A193880 /* RDEpubReaderView-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = "RDEpubReaderView-Info.plist"; sourceTree = ""; }; + 423B499E8F7CDADBC49F611A0A4EE4CB /* RDEPUBCFITextAssertion.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBCFITextAssertion.swift; sourceTree = ""; }; 44133205D7FD87B481BF89EA607CA7A4 /* ZIPFoundation-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = "ZIPFoundation-Info.plist"; sourceTree = ""; }; + 442D5E8B0118B18E067F09D23CB652DF /* RDEPUBCFI.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBCFI.swift; sourceTree = ""; }; 4472755F83C121DE5E1E505D145F6DBE /* CoreText.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreText.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/CoreText.framework; sourceTree = DEVELOPER_DIR; }; 4476B58B93EE7476A4762FE0E26E8178 /* DTCoreTextLayoutFrameAccessibilityElementGenerator.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTCoreTextLayoutFrameAccessibilityElementGenerator.m; path = Core/Source/DTCoreTextLayoutFrameAccessibilityElementGenerator.m; sourceTree = ""; }; - 449E7EAE1D47E608A23C4994EA5F9D4C /* RDEPUBReaderController+DataSource.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "RDEPUBReaderController+DataSource.swift"; sourceTree = ""; }; - 44D834B66794C6899B84C5782F3624C6 /* RDEPUBTextSearchEngine.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBTextSearchEngine.swift; sourceTree = ""; }; + 447C8160AAD2A2E1FE48C8420778A342 /* RDEPUBReaderToolViewProtocols.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderToolViewProtocols.swift; sourceTree = ""; }; + 448445FD208B513040C97CF27CA48852 /* RDEpubReaderView+CollectionView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "RDEpubReaderView+CollectionView.swift"; sourceTree = ""; }; + 44D49963D6EAE25EA025A41D91CEAD23 /* RDEPUBBackgroundPriorityPolicy.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBBackgroundPriorityPolicy.swift; sourceTree = ""; }; 4544C4444DCA90E700611D1281969883 /* DTColorFunctions.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTColorFunctions.m; path = Core/Source/DTColorFunctions.m; sourceTree = ""; }; - 4568EB88B6EDC55FAC77D565DA1A2750 /* rangy-core.js */ = {isa = PBXFileReference; includeInIndex = 1; name = "rangy-core.js"; path = "Sources/RDReaderView/EPUBCore/Resources/rangy-core.js"; sourceTree = ""; }; 45A67761829BBB3974CE24FE50E459EA /* NSDictionary+DTError.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = "NSDictionary+DTError.m"; path = "Core/Source/NSDictionary+DTError.m"; sourceTree = ""; }; 4641C3C93CE32054665ACF80457A7A94 /* DTCoreTextLayoutFrame.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTCoreTextLayoutFrame.m; path = Core/Source/DTCoreTextLayoutFrame.m; sourceTree = ""; }; 467CC59CEC3B3BC7ACDCA776FDCBDC1D /* NSURL+DTComparing.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = "NSURL+DTComparing.m"; path = "Core/Source/NSURL+DTComparing.m"; sourceTree = ""; }; - 475D625CDF9C41734A2045F2DF2DA3A5 /* RDURLReaderController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDURLReaderController.swift; sourceTree = ""; }; + 46F84AE2351143E6F632A3FEE684A62C /* RDEPUBPageMapReconciliationCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBPageMapReconciliationCoordinator.swift; sourceTree = ""; }; 47DC174612C98B2CFB4E7D158169139E /* default.css */ = {isa = PBXFileReference; includeInIndex = 1; name = default.css; path = Core/Source/default.css; sourceTree = ""; }; - 485EAD6F9C3A2212CF6CE024BA2FA1A6 /* RDEPUBStyleSheetComposer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBStyleSheetComposer.swift; sourceTree = ""; }; + 4830D8C6A94E0C97E0F0675276BFD674 /* RDEPUBReaderUIState.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderUIState.swift; sourceTree = ""; }; 48A4A6EDC08C8AC933D0DCD507E9D173 /* ZIPFoundation */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; name = ZIPFoundation; path = ZIPFoundation.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 4917E08E6E59460594AC443EDDAB7E77 /* DTCoreTextFunctions.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTCoreTextFunctions.m; path = Core/Source/DTCoreTextFunctions.m; sourceTree = ""; }; 4944FE0BE3205F2EC91A6A0550D30B75 /* DTTextAttachment.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTTextAttachment.m; path = Core/Source/DTTextAttachment.m; sourceTree = ""; }; + 499D25E7902D605A5EC2940A714AABE0 /* RDEPUBReaderController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderController.swift; sourceTree = ""; }; 499E135F1FEB31C26EB0EF793A90086C /* UIImage+DTFoundation.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = "UIImage+DTFoundation.m"; path = "Core/Source/iOS/UIImage+DTFoundation.m"; sourceTree = ""; }; - 49FF2BFED217D10DD3A12046FF81C511 /* RDEPUBRenderRequest.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBRenderRequest.swift; sourceTree = ""; }; - 4A53E7C06029DCDA8A67E02513AB726E /* RDEPUBTextPageVerticalJustifier.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBTextPageVerticalJustifier.swift; sourceTree = ""; }; - 4AB1D76489DD745A028573570A736E59 /* RDEPUBSelectionState.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBSelectionState.swift; sourceTree = ""; }; - 4B257B0E6084F9E4D8FE7DC39AF9B477 /* RDEPUBSafeArea.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBSafeArea.swift; sourceTree = ""; }; 4C2B218B5EAA5831A036BBF7E1EFC1B9 /* MediaPlayer.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MediaPlayer.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/MediaPlayer.framework; sourceTree = DEVELOPER_DIR; }; - 4C2C269173C1D497D6810C8BF62E7338 /* RDEPUBReadingLocationModels.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReadingLocationModels.swift; sourceTree = ""; }; - 4D64719937C574F41EE90673FE70F681 /* RDEPUBTextPositionConverter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBTextPositionConverter.swift; sourceTree = ""; }; - 4D66B85E6DC6F2833A9D9561D6235DCA /* RDReaderView+PageCurl.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "RDReaderView+PageCurl.swift"; sourceTree = ""; }; - 4E031BA35A851ED17BB97887FA36FC83 /* RDEPUBJavaScriptBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBJavaScriptBridge.swift; sourceTree = ""; }; + 4CA70CE662A3BBAFDEFA4F1AD91006DE /* RDEPUBResourceDataProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBResourceDataProvider.swift; sourceTree = ""; }; + 4CD0CE49F7A82C0DDA84464EA00115A3 /* RDEPUBWebView+Configuration.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "RDEPUBWebView+Configuration.swift"; sourceTree = ""; }; + 4D5A551AA691377650E64BF614B5315E /* RDEPUBTextContentView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBTextContentView.swift; sourceTree = ""; }; + 4DDA63DD315651FD20B81BE242ADFC72 /* RDEPUBReaderChromeCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderChromeCoordinator.swift; sourceTree = ""; }; 4E1066A8FE63AD2C818E26DDCE62DF58 /* DTHTMLAttributedStringBuilder.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTHTMLAttributedStringBuilder.h; path = Core/Source/DTHTMLAttributedStringBuilder.h; sourceTree = ""; }; - 4E1C8E681EAA73E10C3BE26F8951E5D6 /* RDEPUBReaderLoadCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderLoadCoordinator.swift; sourceTree = ""; }; + 4E3C6089F68358EFF3598A4990F5C545 /* epub-bridge.js */ = {isa = PBXFileReference; includeInIndex = 1; name = "epub-bridge.js"; path = "Sources/RDEpubReaderView/EPUBCore/Resources/epub-bridge.js"; sourceTree = ""; }; + 4EC9324EF18C7802D6401A32D6B5C2CC /* RDEPUBReaderSearchCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderSearchCoordinator.swift; sourceTree = ""; }; 4FF4A2A7F2C7CFB5DD757BE1A6E59C7B /* CTLineUtils.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = CTLineUtils.m; path = Core/Source/CTLineUtils.m; sourceTree = ""; }; 5001C74FB9506AEC71E458047698A4AB /* DTHTMLParserNode.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTHTMLParserNode.m; path = Core/Source/DTHTMLParserNode.m; sourceTree = ""; }; 5096458D9BD2818F786BC3B61B91EAC7 /* NSAttributedString+DTDebug.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = "NSAttributedString+DTDebug.m"; path = "Core/Source/NSAttributedString+DTDebug.m"; sourceTree = ""; }; @@ -677,60 +674,58 @@ 519087D9CD0F5E440E6D2C3B1AA1DFA2 /* DTBase64Coding.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTBase64Coding.h; path = Core/Source/DTBase64Coding.h; sourceTree = ""; }; 5193541848D2D1BF115A45D8A730611C /* Archive+ReadingDeprecated.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "Archive+ReadingDeprecated.swift"; path = "Sources/ZIPFoundation/Archive+ReadingDeprecated.swift"; sourceTree = ""; }; 52053ABC9CD3D4A72CD8A22173428AEB /* NSMutableString+HTML.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = "NSMutableString+HTML.h"; path = "Core/Source/NSMutableString+HTML.h"; sourceTree = ""; }; - 53A23BC49C365732A2CD9FCC08DEE23C /* RDEPUBTextRendererSupport.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBTextRendererSupport.swift; sourceTree = ""; }; - 53BFEB15CE78B6B97488049218D34E0C /* RDEPUBCFIUtilities.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBCFIUtilities.swift; sourceTree = ""; }; - 54626FDB51F5A24F21ED95B65F732EA6 /* RDEPUBParser.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBParser.swift; sourceTree = ""; }; - 546293C95A2E9D6898E6E35EBAD67E3C /* wxread-replace.css */ = {isa = PBXFileReference; includeInIndex = 1; name = "wxread-replace.css"; path = "Sources/RDReaderView/EPUBCore/Resources/wxread-replace.css"; sourceTree = ""; }; - 54AF51CFB19FCEF5B3306518C6B311A0 /* RDEPUBChapterDataCache.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBChapterDataCache.swift; sourceTree = ""; }; - 54C212FF935AF7337E5CDE1C37D5785A /* RDEPUBReaderPersistence.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderPersistence.swift; sourceTree = ""; }; 55423549E54A01F039B5F379B264C73F /* UIApplication+DTNetworkActivity.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = "UIApplication+DTNetworkActivity.h"; path = "Core/Source/iOS/UIApplication+DTNetworkActivity.h"; sourceTree = ""; }; 558FCD3F7E481B782EBFA127486ED15D /* DTHTMLParserTextNode.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTHTMLParserTextNode.h; path = Core/Source/DTHTMLParserTextNode.h; sourceTree = ""; }; 55B9309F0BDDDE2C3EE13C97BF2C0F1E /* Archive+Helpers.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "Archive+Helpers.swift"; path = "Sources/ZIPFoundation/Archive+Helpers.swift"; sourceTree = ""; }; + 56A856C80CDD6B8D9CFEE9C551D5735F /* RDEPUBReaderSettings.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderSettings.swift; sourceTree = ""; }; 56E8939081946D090FD77A04F879754C /* DTExtendedFileAttributes.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTExtendedFileAttributes.m; path = Core/Source/DTExtendedFileAttributes.m; sourceTree = ""; }; 57642B518C047A428FE1266A09E0513F /* DTImageTextAttachment.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTImageTextAttachment.m; path = Core/Source/DTImageTextAttachment.m; sourceTree = ""; }; - 5874C0AE2C0D64722A34980EB20368B4 /* RDEPUBPaginationCacheCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBPaginationCacheCoordinator.swift; sourceTree = ""; }; + 576B635BD8D0994E47FD4CF7E23FEA79 /* RDEPUBTextPaginationInterfaces.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBTextPaginationInterfaces.swift; sourceTree = ""; }; + 57F3CF4EBE3598B7B7C01D2C19483B86 /* RDEpubReaderView.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = RDEpubReaderView.release.xcconfig; sourceTree = ""; }; + 58BF341C0AB71F05D2FAED75A8A835A2 /* wxread-replace-latin.css */ = {isa = PBXFileReference; includeInIndex = 1; name = "wxread-replace-latin.css"; path = "Sources/RDEpubReaderView/EPUBCore/Resources/wxread-replace-latin.css"; sourceTree = ""; }; 5977986EDFE76D5EF96A6D95483B12F7 /* Pods-ReadViewDemo-frameworks.sh */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.script.sh; path = "Pods-ReadViewDemo-frameworks.sh"; sourceTree = ""; }; 599172147A32B6978EB5AAE1BE55FF7A /* DTAnchorHTMLElement.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTAnchorHTMLElement.h; path = Core/Source/DTAnchorHTMLElement.h; sourceTree = ""; }; - 5B6CD2AB0DCDA662CD642765E8A5BE01 /* RDEPUBChapterDisplayContentCache.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBChapterDisplayContentCache.swift; sourceTree = ""; }; + 59CDD214151212AC68223A2BCC81D128 /* wxread-replace.css */ = {isa = PBXFileReference; includeInIndex = 1; name = "wxread-replace.css"; path = "Sources/RDEpubReaderView/EPUBCore/Resources/wxread-replace.css"; sourceTree = ""; }; 5C3D91FFA65414A2787FEB95576E0097 /* DTCompatibility.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTCompatibility.h; path = Core/Source/DTCompatibility.h; sourceTree = ""; }; 5C891B983F0CC956CAE3978A15875819 /* NSDictionary+DTCoreText.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = "NSDictionary+DTCoreText.h"; path = "Core/Source/NSDictionary+DTCoreText.h"; sourceTree = ""; }; - 5CD9D3B5B4DC5B0B3B51CC6B8F9AAD86 /* RDEPUBWebView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBWebView.swift; sourceTree = ""; }; + 5CBCA6E5C76DE2C9362183A2B3763C08 /* RDEpubPlainTextBookBuilder.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEpubPlainTextBookBuilder.swift; sourceTree = ""; }; 5D146C3F4B7878B5CE8EFF38765580C2 /* DTAccessibilityElement.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTAccessibilityElement.h; path = Core/Source/DTAccessibilityElement.h; sourceTree = ""; }; 5DB480D0B78911DBF96986E64A714907 /* DTListItemHTMLElement.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTListItemHTMLElement.h; path = Core/Source/DTListItemHTMLElement.h; sourceTree = ""; }; - 5F346BE7A196241EDCEEF3795731C9D7 /* RDReaderView.modulemap */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.module; path = RDReaderView.modulemap; sourceTree = ""; }; - 6054D59EA001FB57A86513538FA8BB2D /* RDEPUBImageViewerCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBImageViewerCoordinator.swift; sourceTree = ""; }; - 60C982580B17056D1FD0076743AF2A20 /* RDEPUBParser+TOC.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "RDEPUBParser+TOC.swift"; sourceTree = ""; }; + 5E81140A7F083D951F3E912FC03A1A3B /* RDEPUBReaderController+ContentDelegates.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "RDEPUBReaderController+ContentDelegates.swift"; sourceTree = ""; }; + 6106ADA2D16E2AB749BC690E59E199B5 /* RDEpubReaderPageChildViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEpubReaderPageChildViewController.swift; sourceTree = ""; }; + 611D8F816F2E1F33D89B52CA0A04A6F4 /* RDEpubReaderView.podspec */ = {isa = PBXFileReference; explicitFileType = text.script.ruby; includeInIndex = 1; indentWidth = 2; lastKnownFileType = text; path = RDEpubReaderView.podspec; sourceTree = ""; tabWidth = 2; xcLanguageSpecificationIdentifier = xcode.lang.ruby; }; + 612542D38DEA4A8BBD84517629D3482B /* RDEPUBTextBookCache.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBTextBookCache.swift; sourceTree = ""; }; 61B18088896D291E0CE7718DFE82FAC1 /* DTListItemHTMLElement.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTListItemHTMLElement.m; path = Core/Source/DTListItemHTMLElement.m; sourceTree = ""; }; - 62A03DB083C11C5FDA1D858BC447E3AD /* RDEPUBWebContentView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBWebContentView.swift; sourceTree = ""; }; - 635FB53F5F49CC7F40F7C5E700A3A3EE /* RDEPUBDTCoreTextRenderer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBDTCoreTextRenderer.swift; sourceTree = ""; }; - 63DA2C409280DA03F0E29C87EC858664 /* RDReaderView-prefix.pch */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "RDReaderView-prefix.pch"; sourceTree = ""; }; + 634A51FA1417DC27E687D3FF80AD42CB /* RDEPUBPublication.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBPublication.swift; sourceTree = ""; }; + 635A4B503A7681407E2D344472AAEFF8 /* wxread-default.css */ = {isa = PBXFileReference; includeInIndex = 1; name = "wxread-default.css"; path = "Sources/RDEpubReaderView/EPUBCore/Resources/wxread-default.css"; sourceTree = ""; }; + 63731E76CD377576124BFA6BC5764F08 /* RDEPUBReaderDelegate.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderDelegate.swift; sourceTree = ""; }; + 63C303ED9417890ADDAC0BED62294CB3 /* RDEPUBChapterRuntimeStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBChapterRuntimeStore.swift; sourceTree = ""; }; 6430501430329643C8280B3985C48711 /* URL+ZIP.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "URL+ZIP.swift"; path = "Sources/ZIPFoundation/URL+ZIP.swift"; sourceTree = ""; }; - 65C7DA34232B39F5D61DAD1C8D64BA55 /* RDEPUBTextLayoutFrame.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBTextLayoutFrame.swift; sourceTree = ""; }; - 65D988AEE2A2A568F2F08472880C56D0 /* RDEPUBCFIRange.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBCFIRange.swift; sourceTree = ""; }; + 643A984CBF55EC9D17CB2D82E7E68B9E /* RDEPUBCFIPath.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBCFIPath.swift; sourceTree = ""; }; + 648B55749AEAFAEC440B7259DA5F18AA /* RDEPUBParser+Resources.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "RDEPUBParser+Resources.swift"; sourceTree = ""; }; + 65144E0A92FF19D145E244C61896601B /* RDEPUBMetadataParseCancellationController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBMetadataParseCancellationController.swift; sourceTree = ""; }; + 6596F63DB6DA0616A8EB8FBA7BEC20F6 /* RDEPUBCFIMarkerInjector.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBCFIMarkerInjector.swift; sourceTree = ""; }; 662D30C1E74D1D5122921475F4024F73 /* DTActivityTitleView.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTActivityTitleView.m; path = Core/Source/iOS/DTActivityTitleView.m; sourceTree = ""; }; - 668E98AD14CBB11B4F60DC21B9B6EC4C /* RDEPUBParser+Archive.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "RDEPUBParser+Archive.swift"; sourceTree = ""; }; - 6707BF75590797EB88552A6829CBAF19 /* RDEPUBReaderController+Trial.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "RDEPUBReaderController+Trial.swift"; sourceTree = ""; }; + 66497586176C305ADC5F3B447F17FA90 /* RDEPUBReaderAssemblyCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderAssemblyCoordinator.swift; sourceTree = ""; }; + 66637609CA61FBB1E9F137BD7C0D5A2E /* RDEPUBReaderTableOfContentsItem.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderTableOfContentsItem.swift; sourceTree = ""; }; + 66CF2D711A2AC7E60FFD77AD510F8C2E /* RDEPUBReaderSettingsViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderSettingsViewController.swift; sourceTree = ""; }; 671C2429406FDB36B677F4B6EC85B52F /* DTCoreTextLayoutFrameAccessibilityElementGenerator.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTCoreTextLayoutFrameAccessibilityElementGenerator.h; path = Core/Source/DTCoreTextLayoutFrameAccessibilityElementGenerator.h; sourceTree = ""; }; - 674CA023806B055868320A59D18FD977 /* RDEPUBTextIndexTable.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBTextIndexTable.swift; sourceTree = ""; }; - 67B9C855C41D2A2276BB04970B73D00B /* RDEPUBReaderSettings.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderSettings.swift; sourceTree = ""; }; + 677A2034D4184FC4E2836F8A5063AD50 /* RDEPUBReaderTheme.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderTheme.swift; sourceTree = ""; }; 67DA49B61A249C2B8241D8FE166608C9 /* UIView+DTFoundation.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = "UIView+DTFoundation.m"; path = "Core/Source/iOS/UIView+DTFoundation.m"; sourceTree = ""; }; - 680ADAD990534743F965D6B6E96D7E9B /* RDEPUBReaderBottomToolView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderBottomToolView.swift; sourceTree = ""; }; 68D81DF1BB2A14F9C186F75BBA5CF50D /* DTColor+Compatibility.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = "DTColor+Compatibility.h"; path = "Core/Source/DTColor+Compatibility.h"; sourceTree = ""; }; 69076FC9D4201719C319547346DA3E21 /* DTTextHTMLElement.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTTextHTMLElement.m; path = Core/Source/DTTextHTMLElement.m; sourceTree = ""; }; - 69D0D9087DAE27DCDC6CB950E20E3608 /* RDEPUBTextBuildPipelineInterfaces.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBTextBuildPipelineInterfaces.swift; sourceTree = ""; }; + 698DD735E4FB33C89CEA4216C18CD31E /* RDEPUBTextPageRenderView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBTextPageRenderView.swift; sourceTree = ""; }; + 6A28433723E70648483D360F3B74FA46 /* RDEPUBPaginationCacheCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBPaginationCacheCoordinator.swift; sourceTree = ""; }; + 6A58A47E8A2EEF76C3ED73CB54A086C0 /* RDEPUBReaderState.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderState.swift; sourceTree = ""; }; 6AC1E707308CDBC9FFC09CECE3117A9A /* DTAccessibilityViewProxy.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTAccessibilityViewProxy.m; path = Core/Source/DTAccessibilityViewProxy.m; sourceTree = ""; }; - 6C2BB1CB63F11F03F2A9AF85735EA076 /* RDEPUBSettingsFlipAutomation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBSettingsFlipAutomation.swift; sourceTree = ""; }; 6C3F8DF48C6EB28BFD05F5BB49793DDA /* FileManager+ZIP.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "FileManager+ZIP.swift"; path = "Sources/ZIPFoundation/FileManager+ZIP.swift"; sourceTree = ""; }; - 6C49C3D57B022D024E68EDB3EF68D60F /* RDEPUBMetadataParseWorker.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBMetadataParseWorker.swift; sourceTree = ""; }; - 6CDD9C89E7009567107E9C16B4BE05C6 /* RDEPUBParser+Package.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "RDEPUBParser+Package.swift"; sourceTree = ""; }; - 6D1978442C3B21E292AEF661B6F98568 /* RDEPUBCFI.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBCFI.swift; sourceTree = ""; }; 6D2316F422A6C97F95F9CF43E6F8E94F /* DTBlockFunctions.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTBlockFunctions.m; path = Core/Source/DTBlockFunctions.m; sourceTree = ""; }; + 6D2D23A34A39E1D251E493C14A46A852 /* RDEPUBStyleCompatibilityModels.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBStyleCompatibilityModels.swift; sourceTree = ""; }; + 6D5EB1FE3AA42EEE041C5BAF4391E8B3 /* RDEPUBReaderController+AttachmentTooltip.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "RDEPUBReaderController+AttachmentTooltip.swift"; sourceTree = ""; }; + 6DE016AAB61669B1F22DA37005C9CCC7 /* RDEPUBChapterCacheKey.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBChapterCacheKey.swift; sourceTree = ""; }; 6E21B10EE150F00F245D297EAB7F2BA2 /* NSNumber+RomanNumerals.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = "NSNumber+RomanNumerals.m"; path = "Core/Source/NSNumber+RomanNumerals.m"; sourceTree = ""; }; - 6F3DBD229B4D724EEC81A05AEF68C96B /* RDEPUBTextPaginationInterfaces.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBTextPaginationInterfaces.swift; sourceTree = ""; }; - 703ED5D8DDE3AEF39CCBA1F75ED53574 /* RDEPUBFixedLayoutTemplate.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBFixedLayoutTemplate.swift; sourceTree = ""; }; - 7099DFE326BE72991030E977265222E8 /* RDPlainTextBookBuilder.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDPlainTextBookBuilder.swift; sourceTree = ""; }; - 70AAB379D168256A54BC162D9CD200B7 /* RDEPUBTextRenderer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBTextRenderer.swift; sourceTree = ""; }; - 713A7931BFA76291ACE4C095423CA7C2 /* RDEPUBTextPageBoundaryValidator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBTextPageBoundaryValidator.swift; sourceTree = ""; }; + 6F265D797C3E4FD97ED35CB9BF70F121 /* RDEPUBReaderChapterListController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderChapterListController.swift; sourceTree = ""; }; + 6F5DA3828400FEDE014E6CEF4A66E4F8 /* RDEPUBChapterTailNormalizer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBChapterTailNormalizer.swift; sourceTree = ""; }; 71BBE6384315D7F00F5ECFFF67A85AF9 /* DTHTMLParser.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTHTMLParser.m; path = Core/Source/DTHTMLParser/DTHTMLParser.m; sourceTree = ""; }; 71EE0B4D4D1B3D0A55E903E7E0CA44E7 /* NSAttributedString+HTML.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = "NSAttributedString+HTML.m"; path = "Core/Source/NSAttributedString+HTML.m"; sourceTree = ""; }; 720396ED306E768BAD28007D5E96B757 /* DTColorFunctions.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTColorFunctions.h; path = Core/Source/DTColorFunctions.h; sourceTree = ""; }; @@ -739,294 +734,282 @@ 722AF38F5106ADDE6F98D282F886A769 /* DTCoreTextFontCollection.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTCoreTextFontCollection.m; path = Core/Source/DTCoreTextFontCollection.m; sourceTree = ""; }; 730B56BC97DFF2B1E04DD2608055D9E0 /* UIApplication+DTNetworkActivity.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = "UIApplication+DTNetworkActivity.m"; path = "Core/Source/iOS/UIApplication+DTNetworkActivity.m"; sourceTree = ""; }; 741D74BC489AF6715191F2A88E50B016 /* DTCoreText.modulemap */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.module; path = DTCoreText.modulemap; sourceTree = ""; }; - 74254B4DE3AD0907F43AB96CEED7408A /* RDEPUBBuildDiagnosticsReporter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBBuildDiagnosticsReporter.swift; sourceTree = ""; }; - 745DE64E801F69497754257504DF420A /* RDEPUBReaderState.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderState.swift; sourceTree = ""; }; 7503926237A4785CCB864B2BCD1F4C2B /* DTFoundation.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = DTFoundation.release.xcconfig; sourceTree = ""; }; - 750B16C9E2C4617CC27FCF04222EEAEF /* RDEPUBChapterLocation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBChapterLocation.swift; sourceTree = ""; }; + 752B1535C51D57BB88FB239513E0810A /* RDEPUBPageResolver.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBPageResolver.swift; sourceTree = ""; }; 753994141ACB981C898B36B761321C23 /* NSCoder+DTCompatibility.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = "NSCoder+DTCompatibility.m"; path = "Core/Source/NSCoder+DTCompatibility.m"; sourceTree = ""; }; 765A94769106D14615FFB30EE1BD5E65 /* Archive+BackingConfiguration.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "Archive+BackingConfiguration.swift"; path = "Sources/ZIPFoundation/Archive+BackingConfiguration.swift"; sourceTree = ""; }; 766227355308600EFC048F797274454A /* ZIPFoundation.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = ZIPFoundation.release.xcconfig; sourceTree = ""; }; 76C1831768CE55090EADD902B29B6CD1 /* Entry+Serialization.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "Entry+Serialization.swift"; path = "Sources/ZIPFoundation/Entry+Serialization.swift"; sourceTree = ""; }; 771E0D5CBCBC013BF5780FEE5BBA81FF /* DTCoreTextGlyphRun.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTCoreTextGlyphRun.h; path = Core/Source/DTCoreTextGlyphRun.h; sourceTree = ""; }; + 774F11C36F2A16D57002B67C22552875 /* RDEPUBSettingsFlipAutomation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBSettingsFlipAutomation.swift; sourceTree = ""; }; + 77F0FB3238D157802C13B7A0495C909B /* RDEPUBSemanticMarkerInjector.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBSemanticMarkerInjector.swift; sourceTree = ""; }; 7805DFC2CB71F5B6BBF7891876360EFB /* NSAttributedString+HTML.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = "NSAttributedString+HTML.h"; path = "Core/Source/NSAttributedString+HTML.h"; sourceTree = ""; }; - 785EFB63370FAE06B2485B755B42E2B5 /* RDEPUBReaderController+ContentDelegates.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "RDEPUBReaderController+ContentDelegates.swift"; sourceTree = ""; }; 78887827FF15DE20865DF3A00660993B /* NSDictionary+DTCoreText.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = "NSDictionary+DTCoreText.m"; path = "Core/Source/NSDictionary+DTCoreText.m"; sourceTree = ""; }; 78EA0DF405F8465A86BFD3DDFD2EC295 /* Pods-ReadViewDemo */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; name = "Pods-ReadViewDemo"; path = Pods_ReadViewDemo.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 796EF27FF6AA4B62367B6BD9CEBD85A8 /* DTCSSStylesheet.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTCSSStylesheet.h; path = Core/Source/DTCSSStylesheet.h; sourceTree = ""; }; 79D13938A04579CD1E9E042A39CC4D86 /* ZIPFoundation.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = ZIPFoundation.debug.xcconfig; sourceTree = ""; }; 7A13F9221A300A89F9F8D0A2AB1794BF /* DTCoreText.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = DTCoreText.release.xcconfig; sourceTree = ""; }; 7A4E9E54E556A72BC885CCC08B3791C1 /* DTHTMLElement.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTHTMLElement.m; path = Core/Source/DTHTMLElement.m; sourceTree = ""; }; - 7B84F7CB373F124B5ADA362B7A204376 /* RDReaderView+ContentAccess.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "RDReaderView+ContentAccess.swift"; sourceTree = ""; }; + 7B098FF2F53DED347EE39859D3CC11BE /* RDEPUBJumpSession.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBJumpSession.swift; sourceTree = ""; }; 7C80941DE172AF972D2247659684D93A /* NSString+DTUtilities.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = "NSString+DTUtilities.h"; path = "Core/Source/NSString+DTUtilities.h"; sourceTree = ""; }; 7CA72FA9A4F2F42A75C58325738A44AC /* NSMutableAttributedString+HTML.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = "NSMutableAttributedString+HTML.m"; path = "Core/Source/NSMutableAttributedString+HTML.m"; sourceTree = ""; }; 7CFFB65C5D340A4B5C627B3EF02CD127 /* NSNumber+RomanNumerals.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = "NSNumber+RomanNumerals.h"; path = "Core/Source/NSNumber+RomanNumerals.h"; sourceTree = ""; }; 7D4062C36BA5FFBAE9122CAD0DCEC6B6 /* DTCoreText-prefix.pch */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "DTCoreText-prefix.pch"; sourceTree = ""; }; - 7D513DFE7299EE03B0CB5A16A4798152 /* RDReaderSpreadResolver.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDReaderSpreadResolver.swift; sourceTree = ""; }; 7D8AE4BEBD1998839BA83AD05CA6CAC5 /* DTBreakHTMLElement.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTBreakHTMLElement.m; path = Core/Source/DTBreakHTMLElement.m; sourceTree = ""; }; - 7E28782DB94C6BAF80766A255BB55167 /* RDEPUBReaderViewportMonitor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderViewportMonitor.swift; sourceTree = ""; }; + 7FE0F0004B3436CA416F69193354B1AD /* RDEPUBParser+Archive.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "RDEPUBParser+Archive.swift"; sourceTree = ""; }; 814315319ED50AF916C3C8A68C0A47A0 /* DTAttributedTextCell.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTAttributedTextCell.m; path = Core/Source/DTAttributedTextCell.m; sourceTree = ""; }; - 83410CC9CF2ABE63B90A92F2F988BF65 /* RDReaderView-RDReaderViewAssets */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; name = "RDReaderView-RDReaderViewAssets"; path = RDReaderViewAssets.bundle; sourceTree = BUILT_PRODUCTS_DIR; }; - 83610D7D676D5E06082204EDE1A1431B /* RDReaderView-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = "RDReaderView-Info.plist"; sourceTree = ""; }; + 8170A64FBDE3CC44D5EFA56020DC7978 /* RDEPUBTextSelectionController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBTextSelectionController.swift; sourceTree = ""; }; + 820C93ABAAF16D708F0A6322AC62801F /* RDEPUBCFIRange.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBCFIRange.swift; sourceTree = ""; }; + 8250EF1AA33B1FBC82A438CB75C77E7D /* RDEPUBAnnotationModels.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBAnnotationModels.swift; sourceTree = ""; }; + 82BB9B078A1C0E0BDB1BFBB93A670A2A /* RDEPUBSelectionState.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBSelectionState.swift; sourceTree = ""; }; + 834CDE067D219C544FC721901E8E70B5 /* RDEPUBChapterLocation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBChapterLocation.swift; sourceTree = ""; }; + 847740E4EA7EBFB8F53710A0CF448BAE /* RDEPUBModels.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBModels.swift; sourceTree = ""; }; 85E8DA9DE1F796F12171763D20CBAF27 /* DTFoundation.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = DTFoundation.debug.xcconfig; sourceTree = ""; }; - 86DD8A5C0624A7C2C0C328A40118C3E8 /* rangy-serializer.js */ = {isa = PBXFileReference; includeInIndex = 1; name = "rangy-serializer.js"; path = "Sources/RDReaderView/EPUBCore/Resources/rangy-serializer.js"; sourceTree = ""; }; - 87031C18DFBCC980112E5A9F3BDE49C3 /* RDEPUBImageViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBImageViewController.swift; sourceTree = ""; }; + 85EA0348053B11994DBA92C7B6D7E996 /* RDEPUBTypesettingPipeline.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBTypesettingPipeline.swift; sourceTree = ""; }; + 8612BCF374E10CB4503D16E19A381417 /* RDEPUBTextRenderer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBTextRenderer.swift; sourceTree = ""; }; + 86F6704259B87ED54EF047EF455B168E /* RDEPUBCFIDOMPathBuilder.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBCFIDOMPathBuilder.swift; sourceTree = ""; }; 8718F2DB8F0DC085CB012D9996596F3F /* NSString+CSS.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = "NSString+CSS.m"; path = "Core/Source/NSString+CSS.m"; sourceTree = ""; }; - 875B4A5662D9C4B043C7C03DABF75E4B /* RDEPUBPageBreakPolicy.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBPageBreakPolicy.swift; sourceTree = ""; }; - 87FAC4BEFBDC1FD8E9824724A68800F1 /* RDEPUBReaderController+Orientation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "RDEPUBReaderController+Orientation.swift"; sourceTree = ""; }; 88464D2E3579F1A5B25DCCA128528938 /* DTCoreTextFontDescriptor.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTCoreTextFontDescriptor.m; path = Core/Source/DTCoreTextFontDescriptor.m; sourceTree = ""; }; 89268DC8F1FBDE4F3167647B6A4F9216 /* DTHorizontalRuleHTMLElement.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTHorizontalRuleHTMLElement.h; path = Core/Source/DTHorizontalRuleHTMLElement.h; sourceTree = ""; }; - 893B4227196A12B86EF2F32C46FCA05E /* RDEPUBNotePopupCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBNotePopupCoordinator.swift; sourceTree = ""; }; - 89A5207DD90D0FB2512C9B23FEEB1A02 /* RDEPUBAttachmentTooltipView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBAttachmentTooltipView.swift; sourceTree = ""; }; - 89A9008EF0A17C27F6EA9F37EF6DCFD0 /* RDReaderView+ToolView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "RDReaderView+ToolView.swift"; sourceTree = ""; }; + 89BD34E68BFF47D27D5F91863AF1BBF1 /* RDEPUBReaderViewportMonitor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderViewportMonitor.swift; sourceTree = ""; }; + 8A1B3AAEE7814FA72D7C528E10D49C3C /* RDEPUBReaderController+Orientation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "RDEPUBReaderController+Orientation.swift"; sourceTree = ""; }; + 8A204FA398A28E3D0424A74DD407BB03 /* RDEpubReaderView-umbrella.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "RDEpubReaderView-umbrella.h"; sourceTree = ""; }; + 8A99D1BD5B4CEEFCCBE92DF58132E614 /* epub-fixed-layout.html */ = {isa = PBXFileReference; includeInIndex = 1; name = "epub-fixed-layout.html"; path = "Sources/RDEpubReaderView/EPUBCore/Resources/epub-fixed-layout.html"; sourceTree = ""; }; 8B35D0622F339A5B8A864FAA0C9EAE47 /* Pods-ReadViewDemo-acknowledgements.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = "Pods-ReadViewDemo-acknowledgements.plist"; sourceTree = ""; }; 8B8FDAF9E8370366F7FA8524CB0D80B3 /* DTTextAttachmentHTMLElement.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTTextAttachmentHTMLElement.h; path = Core/Source/DTTextAttachmentHTMLElement.h; sourceTree = ""; }; - 8B91825A1F365A62C53FC349F17C7038 /* RDEPUBMetadataParseCancellationController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBMetadataParseCancellationController.swift; sourceTree = ""; }; 8BA617294E6371DD7FAC32B61E0873F2 /* DTPieProgressIndicator.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTPieProgressIndicator.m; path = Core/Source/iOS/DTPieProgressIndicator.m; sourceTree = ""; }; - 8BCE11DBF7C58BA594172E1D980C1626 /* RDEPUBReaderAnnotationCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderAnnotationCoordinator.swift; sourceTree = ""; }; + 8C46A096335AF772EB451C71B476A0C8 /* RDEPUBMemoryProbe.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBMemoryProbe.swift; sourceTree = ""; }; 8C9575F05D72C239E7E6F079BACDF4C7 /* DTVersion.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTVersion.h; path = Core/Source/DTVersion.h; sourceTree = ""; }; - 8CD96965442A5F874F9CE6864437A3F7 /* RDEPUBPageCountCache.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBPageCountCache.swift; sourceTree = ""; }; 8CF7CEB31BED0554F3EF3D3442A89B47 /* DTCoreText.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTCoreText.h; path = Core/Source/DTCoreText.h; sourceTree = ""; }; - 8D7CCE90374184A7112FA1618B41674F /* RDEPUBWebView+Reflowable.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "RDEPUBWebView+Reflowable.swift"; sourceTree = ""; }; + 8D43BF19A7479296AA8324BFCE1BE87C /* rangy-serializer.js */ = {isa = PBXFileReference; includeInIndex = 1; name = "rangy-serializer.js"; path = "Sources/RDEpubReaderView/EPUBCore/Resources/rangy-serializer.js"; sourceTree = ""; }; 8DA0F94C0870C1F0DFADE5E9045E5E6E /* DTDictationPlaceholderTextAttachment.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTDictationPlaceholderTextAttachment.m; path = Core/Source/DTDictationPlaceholderTextAttachment.m; sourceTree = ""; }; - 8DD41010C7FC84E0AD4545E0B751822C /* RDEPUBSearchEngine.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBSearchEngine.swift; sourceTree = ""; }; 8E5297E7F5769C61C1838962BA76D4F5 /* NSArray+DTError.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = "NSArray+DTError.m"; path = "Core/Source/NSArray+DTError.m"; sourceTree = ""; }; - 8E6187139F503118F5A562E9A48344D4 /* RDEPUBDarkImageAdjuster.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBDarkImageAdjuster.swift; sourceTree = ""; }; - 8EAF25AD2B9FEE0FE357E3A433E3D92C /* RDEPUBAssetRepository.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBAssetRepository.swift; sourceTree = ""; }; + 8EA2C6CFA053A4B06F2AAB2EA0D17A4B /* RDEpubReaderTapRegionHandler.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEpubReaderTapRegionHandler.swift; sourceTree = ""; }; 8EDA1618562CC224690CF6FC03AFED38 /* DTObjectTextAttachment.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTObjectTextAttachment.m; path = Core/Source/DTObjectTextAttachment.m; sourceTree = ""; }; - 8F16DD7BE31C1407239419FAB208F7E9 /* RDEPUBPublication.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBPublication.swift; sourceTree = ""; }; 8F653EFBBEDAAC96A34BC93B4211E792 /* DTCompatibility.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTCompatibility.h; path = Core/Source/DTCompatibility.h; sourceTree = ""; }; 8FAAF640165FC1B4B47C546F80D98B1E /* DTColor+Compatibility.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = "DTColor+Compatibility.m"; path = "Core/Source/DTColor+Compatibility.m"; sourceTree = ""; }; - 9191031A62C049190D2BAAF26FCB4894 /* RDEPUBPreferences.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBPreferences.swift; sourceTree = ""; }; - 9265A063637BD77FC50A93888BBAB11E /* RDEPUBReaderDependencies.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderDependencies.swift; sourceTree = ""; }; - 93F324654B96751DA93FFC59A3679ED6 /* RDEPUBReaderHighlightsViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderHighlightsViewController.swift; sourceTree = ""; }; + 915069D65E023E6D8140D847BBCAEF3A /* RDEPUBCFICompatibility.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBCFICompatibility.swift; sourceTree = ""; }; + 92D5B64BD3D5489A837054C68FC54D65 /* RDEPUBChapterData.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBChapterData.swift; sourceTree = ""; }; + 93D30E909C16D18D790A184AB3DDF67D /* RDEPUBHTMLNormalizer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBHTMLNormalizer.swift; sourceTree = ""; }; 94705DADD1F09F4162F7D733318238F4 /* NSData+DTCrypto.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = "NSData+DTCrypto.h"; path = "Core/Source/NSData+DTCrypto.h"; sourceTree = ""; }; - 94E4C09CA9FBF73F01D7DA481E085E0B /* RDEPUBReaderSettingsViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderSettingsViewController.swift; sourceTree = ""; }; 9542AFE3A82C8AE56576254CE196E71A /* DTCustomColoredAccessory.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTCustomColoredAccessory.m; path = Core/Source/iOS/DTCustomColoredAccessory.m; sourceTree = ""; }; - 95C55ACB797975D1F898B13F069C609C /* RDEPUBTextPaginationSupport.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBTextPaginationSupport.swift; sourceTree = ""; }; - 960055C2836F036B9C45375852678BCE /* RDEPUBReaderController+LocationResolution.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "RDEPUBReaderController+LocationResolution.swift"; sourceTree = ""; }; - 9634274EF4914F6FCC9A1546501F2843 /* RDReaderPagingController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDReaderPagingController.swift; sourceTree = ""; }; - 96352099AD90E6B08B5178B06E6CD7CA /* RDEPUBReaderController+TableOfContents.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "RDEPUBReaderController+TableOfContents.swift"; sourceTree = ""; }; - 96B2BF79370F179010B6481C20FA58C1 /* RDEPUBRuntimeChapter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBRuntimeChapter.swift; sourceTree = ""; }; - 9726056B845AD5027986E5391D52D33D /* RDEPUBReaderController+AttachmentTooltip.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "RDEPUBReaderController+AttachmentTooltip.swift"; sourceTree = ""; }; - 975BFAACE86185AB89AAD8FABBD64FD5 /* RDEPUBSelectionLoupeView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBSelectionLoupeView.swift; sourceTree = ""; }; - 976C896B7BFA4C535F93A1CFF5F63077 /* ResourceBundle-RDReaderViewAssets-RDReaderView-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = "ResourceBundle-RDReaderViewAssets-RDReaderView-Info.plist"; sourceTree = ""; }; + 961206494549A9AE3C6EB03DDAD21C1C /* RDEPUBPresentationRuntime.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBPresentationRuntime.swift; sourceTree = ""; }; + 96D1D1F318551BFCF4B28021E708564D /* RDEPUBReaderEnvironment.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderEnvironment.swift; sourceTree = ""; }; 978449466D53EA6F5D40FCF0F353DDCA /* NSAttributedString+DTCoreText.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = "NSAttributedString+DTCoreText.m"; path = "Core/Source/NSAttributedString+DTCoreText.m"; sourceTree = ""; }; + 97C6D812897020B77EF9C826643A0418 /* RDEPUBCSSCompatibilityLayer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBCSSCompatibilityLayer.swift; sourceTree = ""; }; + 97E58B2D3994B2247E96226094D6EA5F /* RDEPUBFontNormalizer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBFontNormalizer.swift; sourceTree = ""; }; + 986039F986495905BA1EB9D9F426B782 /* RDEpubReaderViewProtocols.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEpubReaderViewProtocols.swift; sourceTree = ""; }; + 9868FA712FFD17D0195649510C1A68A6 /* RDEPUBWebView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBWebView.swift; sourceTree = ""; }; + 98C6026F789783895FC83556EF6F289C /* RDEPUBReaderLocationCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderLocationCoordinator.swift; sourceTree = ""; }; 98CF9C5D504838741E399811F4D5A0B7 /* NSAttributedString+SmallCaps.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = "NSAttributedString+SmallCaps.m"; path = "Core/Source/NSAttributedString+SmallCaps.m"; sourceTree = ""; }; - 9AB8D9D0E868BF4DBDE6255D7F86E252 /* RDEPUBTextAnchor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBTextAnchor.swift; sourceTree = ""; }; - 9AC44A856D45E8522194A048ABEAAF86 /* RDEPUBNoteModels.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBNoteModels.swift; sourceTree = ""; }; - 9B503F169F6A42A61C4A4BBE13459E2B /* RDReaderView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDReaderView.swift; sourceTree = ""; }; + 98D44DA052097D1223C075A582A6E1C7 /* RDEPUBReadingLocationModels.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReadingLocationModels.swift; sourceTree = ""; }; + 9B2EA51F3AF54545A450E32FACC3B8C5 /* RDEPUBTextAnchor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBTextAnchor.swift; sourceTree = ""; }; 9C12A4A5BB42D0F68DABBD3BDF103BC5 /* DTDictationPlaceholderView.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTDictationPlaceholderView.h; path = Core/Source/DTDictationPlaceholderView.h; sourceTree = ""; }; 9C3615C94C147AEA92F022E119810E9F /* Data+Serialization.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "Data+Serialization.swift"; path = "Sources/ZIPFoundation/Data+Serialization.swift"; sourceTree = ""; }; 9C5E29D728B8CD4EABC0B3DD9CD9DE99 /* UIScreen+DTFoundation.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = "UIScreen+DTFoundation.h"; path = "Core/Source/iOS/UIScreen+DTFoundation.h"; sourceTree = ""; }; 9C6CE93FAA68605C6B90FF844A98E440 /* CTLineUtils.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = CTLineUtils.h; path = Core/Source/CTLineUtils.h; sourceTree = ""; }; - 9C8C8F9EC45583D9AC337EE967208872 /* RDEPUBHTMLNormalizer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBHTMLNormalizer.swift; sourceTree = ""; }; - 9CD0B4D61E7169153CE0968E2D6A923C /* RDEPUBReaderController+RenderSupport.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "RDEPUBReaderController+RenderSupport.swift"; sourceTree = ""; }; 9D940727FF8FB9C785EB98E56350EF41 /* Podfile */ = {isa = PBXFileReference; explicitFileType = text.script.ruby; includeInIndex = 1; indentWidth = 2; lastKnownFileType = text; name = Podfile; path = ../Podfile; sourceTree = SOURCE_ROOT; tabWidth = 2; xcLanguageSpecificationIdentifier = xcode.lang.ruby; }; - 9EAA04F0E10B06A24072628253625784 /* RDEPUBWebView+Configuration.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "RDEPUBWebView+Configuration.swift"; sourceTree = ""; }; - A10956FC20C49EDDBFC8BABCC6EB098A /* RDEPUBReaderAssemblyCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderAssemblyCoordinator.swift; sourceTree = ""; }; - A188018454C7E42DF794BA1BA829E6FB /* RDEPUBPageResolver.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBPageResolver.swift; sourceTree = ""; }; - A1898AE1C59D386BC2A565897777C7C9 /* RDEPUBFontFallbackResolver.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBFontFallbackResolver.swift; sourceTree = ""; }; - A18AC71FCC0D1D4F1FF1E6EAB3D229F0 /* RDEPUBSelectionOverlayView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBSelectionOverlayView.swift; sourceTree = ""; }; + A05983FAA61E2029A29085B98DEDC010 /* RDEPUBRuntimeChapter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBRuntimeChapter.swift; sourceTree = ""; }; + A08CDC243F29B29A95253DCA44335BFE /* RDEPUBDarkImageAdjuster.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBDarkImageAdjuster.swift; sourceTree = ""; }; A20F6F0463A998DC3BD53DE9E63B63DE /* DTSmartPagingScrollView.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTSmartPagingScrollView.m; path = Core/Source/iOS/DTSmartPagingScrollView.m; sourceTree = ""; }; - A33BBD0D82DD401D9A3FF02B10B320B6 /* RDEPUBReaderPaginationCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderPaginationCoordinator.swift; sourceTree = ""; }; - A3BC0875FF335D55BEBFD59BBC6BF914 /* RDReaderView-umbrella.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "RDReaderView-umbrella.h"; sourceTree = ""; }; - A3BC3FFA41C912BB401CBEFA2A9D9964 /* RDEPUBReaderController+ExternalLinks.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "RDEPUBReaderController+ExternalLinks.swift"; sourceTree = ""; }; + A33FA1A570B5BA2C5DCAB078F31DE5BF /* UIColor+RDEPUBHex.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "UIColor+RDEPUBHex.swift"; sourceTree = ""; }; + A36571C8CAE6B5E67048AF9818B631C6 /* RDEpubReaderGestureController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEpubReaderGestureController.swift; sourceTree = ""; }; A3BD3ED7913130D32E14D5572CBCC921 /* NSString+DTFormatNumbers.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = "NSString+DTFormatNumbers.h"; path = "Core/Source/NSString+DTFormatNumbers.h"; sourceTree = ""; }; A3DE08AC23FE36A6C3F33A04700FDE57 /* DTAnimatedGIF.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTAnimatedGIF.m; path = Core/Source/iOS/DTAnimatedGIF/DTAnimatedGIF.m; sourceTree = ""; }; A3F7613E99CAA29881A015D0ED969B86 /* NSURL+DTAppLinks.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = "NSURL+DTAppLinks.h"; path = "Core/Source/iOS/NSURL+DTAppLinks.h"; sourceTree = ""; }; - A48622CB59921725556CF92DE20EC3C0 /* RDEPUBCSSCompatibilityLayer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBCSSCompatibilityLayer.swift; sourceTree = ""; }; - A4EA2355F64BEA505537D228D101D967 /* RDEPUBReaderDelegate.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderDelegate.swift; sourceTree = ""; }; - A567490FF7183A9FAAD5EEEFCF9E286F /* RDEPUBChapterCacheKey.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBChapterCacheKey.swift; sourceTree = ""; }; + A48F69748B05758B4CB62757E8CF6464 /* RDEPUBReaderController+RuntimeBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "RDEPUBReaderController+RuntimeBridge.swift"; sourceTree = ""; }; + A49A27A2F307647B468A9652F7A49006 /* RDEPUBReaderBottomToolView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderBottomToolView.swift; sourceTree = ""; }; A592A34CA2EA792DDFAFAEB516FE8A10 /* NSCoder+DTCompatibility.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = "NSCoder+DTCompatibility.h"; path = "Core/Source/NSCoder+DTCompatibility.h"; sourceTree = ""; }; A5D1FFCB042340FB917DAAE076DD1CCF /* DTFoundation.modulemap */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.module; path = DTFoundation.modulemap; sourceTree = ""; }; - A5E93EDC68FEB87F429AEE9DA66B1F0C /* RDEPUBCFICompatibility.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBCFICompatibility.swift; sourceTree = ""; }; A61C8F1BDD76EA59082F65269083BD08 /* DTCoreTextConstants.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTCoreTextConstants.h; path = Core/Source/DTCoreTextConstants.h; sourceTree = ""; }; + A7C6D0CBF46040C1E92580C7CB26A47C /* RDEPUBReaderRuntime.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderRuntime.swift; sourceTree = ""; }; A7E167717A18E0C858C6FD95804249E4 /* NSCharacterSet+HTML.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = "NSCharacterSet+HTML.h"; path = "Core/Source/NSCharacterSet+HTML.h"; sourceTree = ""; }; + A858EFE14B7184E73CC8B040C0FBAF9F /* RDEpubReaderFlowLayout.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEpubReaderFlowLayout.swift; sourceTree = ""; }; + A875D59C9ADB7D356F34F31D11DCC720 /* RDEPUBParser.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBParser.swift; sourceTree = ""; }; A8A086525BF509F4581693115438B82B /* NSAttributedString+DTDebug.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = "NSAttributedString+DTDebug.h"; path = "Core/Source/NSAttributedString+DTDebug.h"; sourceTree = ""; }; + A8C567DF41CC58FE2BA5C88C6B4A71F6 /* RDEPUBTextPageBoundaryValidator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBTextPageBoundaryValidator.swift; sourceTree = ""; }; A8CC38EFEF171FFEE008C32A6319BE6A /* DTFolderMonitor.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTFolderMonitor.h; path = Core/Source/DTFolderMonitor.h; sourceTree = ""; }; - A92C7A06935B41D87D6CB878FDAD9867 /* RDEPUBTextContentView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBTextContentView.swift; sourceTree = ""; }; - A9B958811F45DE07F349083133787061 /* RDEPUBTextLayouter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBTextLayouter.swift; sourceTree = ""; }; + A8FEC8336CD4C54BF17C89ED0AEFC376 /* RDEpubReaderView+ContentAccess.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "RDEpubReaderView+ContentAccess.swift"; sourceTree = ""; }; + A98B084E630DFB6A2A5DB15DFB214FD0 /* RDEPUBTextBuildPipelineInterfaces.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBTextBuildPipelineInterfaces.swift; sourceTree = ""; }; AA0811310099A4BCB3D52683B70DB3D1 /* Pods-ReadViewDemo-dummy.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; path = "Pods-ReadViewDemo-dummy.m"; sourceTree = ""; }; - ABEB2DB799322EF9048487013D0E7DBC /* RDEPUBCFIDOMPathBuilder.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBCFIDOMPathBuilder.swift; sourceTree = ""; }; + AAAD24FFA4B4B8E33C45ED26C2FD4B37 /* RDEPUBReaderPaginationCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderPaginationCoordinator.swift; sourceTree = ""; }; + AAFF9E2F6436861E176B5E55A1180672 /* RDEPUBChapterDisplayContentCache.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBChapterDisplayContentCache.swift; sourceTree = ""; }; AC0A4ED7E936923A29A5EEB1D95D6B55 /* DTTextAttachmentHTMLElement.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTTextAttachmentHTMLElement.m; path = Core/Source/DTTextAttachmentHTMLElement.m; sourceTree = ""; }; - AC3AB2ABB72B4569AE8FF72F31261641 /* RDEPUBWebView+FixedLayout.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "RDEPUBWebView+FixedLayout.swift"; sourceTree = ""; }; ACA6D26B26D2653D6E1C871A482BD6DF /* NSURL+DTUnshorten.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = "NSURL+DTUnshorten.h"; path = "Core/Source/NSURL+DTUnshorten.h"; sourceTree = ""; }; ACD2A4FF41C5A223C2D4B74E0EDA9B2C /* DTCoreText-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = "DTCoreText-Info.plist"; sourceTree = ""; }; ADD5B2F66FCCFB58393314E7B9FE4384 /* NSMutableArray+DTMoving.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = "NSMutableArray+DTMoving.m"; path = "Core/Source/NSMutableArray+DTMoving.m"; sourceTree = ""; }; - AE8EE378CAD4622D735C39CD1B0D4FEB /* RDEPUBCFIError.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBCFIError.swift; sourceTree = ""; }; + AEBCA7121897869990DF9876F2240A4A /* RDEPUBTextLayoutFrame.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBTextLayoutFrame.swift; sourceTree = ""; }; AF1516035C269F7A41EE064AAB14D562 /* DTActivityTitleView.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTActivityTitleView.h; path = Core/Source/iOS/DTActivityTitleView.h; sourceTree = ""; }; AF436E76C893E223A4F0BE86C1276054 /* UIScreen+DTFoundation.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = "UIScreen+DTFoundation.m"; path = "Core/Source/iOS/UIScreen+DTFoundation.m"; sourceTree = ""; }; - AF774474543DB1EEE0F2F8BCC54382AE /* RDEPUBWebViewDebug.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBWebViewDebug.swift; sourceTree = ""; }; + AFC57105C58F0D30043B13D949A57D8E /* RDEpubReaderView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEpubReaderView.swift; sourceTree = ""; }; B0218A7B243387C79E099CA947CF243D /* Archive+Writing.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "Archive+Writing.swift"; path = "Sources/ZIPFoundation/Archive+Writing.swift"; sourceTree = ""; }; - B023EBCF5E4ECC879A57A325F2015CAA /* RDReaderTapRegionHandler.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDReaderTapRegionHandler.swift; sourceTree = ""; }; + B03885DA855AC3C09FF73925C82186C8 /* ResourceBundle-RDEpubReaderViewAssets-RDEpubReaderView-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = "ResourceBundle-RDEpubReaderViewAssets-RDEpubReaderView-Info.plist"; sourceTree = ""; }; B12CE4962CDC324FB47FB92D6706D149 /* DTLinkButton.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTLinkButton.h; path = Core/Source/DTLinkButton.h; sourceTree = ""; }; B23C5F038DAC52B75213C0760C431F2B /* DTHTMLWriter.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTHTMLWriter.m; path = Core/Source/DTHTMLWriter.m; sourceTree = ""; }; - B34DC8A160DF20B18E04242D92D54346 /* RDEPUBReaderEnvironment.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderEnvironment.swift; sourceTree = ""; }; + B26A41A391468A542EFC7C0F2D1A94AC /* cssInjector.js */ = {isa = PBXFileReference; includeInIndex = 1; name = cssInjector.js; path = Sources/RDEpubReaderView/EPUBCore/Resources/cssInjector.js; sourceTree = ""; }; B37118B8D16847B0F82E06A5B33898AB /* DTCSSStylesheet.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTCSSStylesheet.m; path = Core/Source/DTCSSStylesheet.m; sourceTree = ""; }; + B3875D495797D0A7441AE5FDC6606E31 /* RDEPUBTextContentInteractionCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBTextContentInteractionCoordinator.swift; sourceTree = ""; }; B41F7D27C2336F89BB0185ACB0B19EA9 /* ZIPFoundation-umbrella.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "ZIPFoundation-umbrella.h"; sourceTree = ""; }; B43D7603C0D66635268842EC0A2655AC /* NSFileWrapper+DTCopying.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = "NSFileWrapper+DTCopying.h"; path = "Core/Source/NSFileWrapper+DTCopying.h"; sourceTree = ""; }; B4A7F7402FAEE3A277A8716DDA115C95 /* NSString+Paragraphs.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = "NSString+Paragraphs.m"; path = "Core/Source/NSString+Paragraphs.m"; sourceTree = ""; }; B4D20A2B33F9F4DD8499FB462DE2731F /* DTAttributedTextView.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTAttributedTextView.h; path = Core/Source/DTAttributedTextView.h; sourceTree = ""; }; B50104DE2E35D820ECA22CDB3E6F96EA /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; - B52A7884E951C3C2064E5610DD417DA5 /* wxread-dark.css */ = {isa = PBXFileReference; includeInIndex = 1; name = "wxread-dark.css"; path = "Sources/RDReaderView/EPUBCore/Resources/wxread-dark.css"; sourceTree = ""; }; - B54F008AC6146B84C53BC2ECB8725B94 /* RDEPUBTextPerformanceSampler.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBTextPerformanceSampler.swift; sourceTree = ""; }; - B5CA72BC08E08AF5467ECB9D87611149 /* RDReaderContentCell.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDReaderContentCell.swift; sourceTree = ""; }; - B60943E11B87C06EB15D70467A9DAF42 /* RDEPUBBackgroundCoverageStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBBackgroundCoverageStore.swift; sourceTree = ""; }; + B50734FC0871066B8BF8848B60EA53CF /* RDEpubReaderView+PageCurl.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "RDEpubReaderView+PageCurl.swift"; sourceTree = ""; }; + B545FD055DB6F5E62A4B3477F7514D97 /* RDEPUBAttachmentNormalizer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBAttachmentNormalizer.swift; sourceTree = ""; }; + B60AD68DA33BB20A0FE6D0D98EBE3D2B /* RDEPUBNavigatorLayoutContext.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBNavigatorLayoutContext.swift; sourceTree = ""; }; B6CA8CC4525EF64AA5CEE4A722737F1C /* DTCoreText-dummy.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; path = "DTCoreText-dummy.m"; sourceTree = ""; }; - B6F13727884F1901CB2C0D4FFABB1763 /* RDEPUBPresentationRuntime.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBPresentationRuntime.swift; sourceTree = ""; }; - B700F9E8C03D3F9C537B14755D984564 /* String+RDEPUB.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "String+RDEPUB.swift"; sourceTree = ""; }; + B7180AF39B62FE6A6B43E7BE45E45C31 /* RDEpubReaderPagingController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEpubReaderPagingController.swift; sourceTree = ""; }; B76B1E836C34CB755AEE1FBF7DC26BDD /* NSString+DTPaths.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = "NSString+DTPaths.h"; path = "Core/Source/NSString+DTPaths.h"; sourceTree = ""; }; B7C4233DF4FA80BF4A9E6FA5082D49E5 /* DTCoreTextParagraphStyle.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTCoreTextParagraphStyle.m; path = Core/Source/DTCoreTextParagraphStyle.m; sourceTree = ""; }; - B812E0491FA6CD09EEDF1B3B674C86A3 /* RDEPUBAttachmentTooltipOverlayView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBAttachmentTooltipOverlayView.swift; sourceTree = ""; }; B83D7A73677A2C3141EDEF56B9CE6584 /* DTVideoTextAttachment.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTVideoTextAttachment.m; path = Core/Source/DTVideoTextAttachment.m; sourceTree = ""; }; B8717A7A7BC5A1529A0F80FB3B22675D /* DTFoundation-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = "DTFoundation-Info.plist"; sourceTree = ""; }; - B8F150BC8AD9B0E615F730AEC4CC9098 /* RDEPUBTextSelectionController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBTextSelectionController.swift; sourceTree = ""; }; - BA427D1A69C32689C6C30A3C1CCE31A9 /* RDEPUBStyleSheetBuilder.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBStyleSheetBuilder.swift; sourceTree = ""; }; + B8EBCB744C294E197C28F3B81349FCF9 /* RDEPUBTextPositionConverter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBTextPositionConverter.swift; sourceTree = ""; }; + B99659A42728CF06CA00A18D502850AF /* RDEPUBTextBookModels.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBTextBookModels.swift; sourceTree = ""; }; BA8A527A4CCB2AF1C1E26744B301F6B8 /* DTIframeTextAttachment.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTIframeTextAttachment.m; path = Core/Source/DTIframeTextAttachment.m; sourceTree = ""; }; BAE45673EE2875DB23FA7BDD61FDE1EF /* DTCustomColoredAccessory.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTCustomColoredAccessory.h; path = Core/Source/iOS/DTCustomColoredAccessory.h; sourceTree = ""; }; + BB2DDE835F718828F22B7C06D9AF02FB /* RDEPUBStyleSheetComposer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBStyleSheetComposer.swift; sourceTree = ""; }; + BBF2E942B6DF40838CD0EA5D5AFA0A49 /* RDEPUBRuntimePageCount.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBRuntimePageCount.swift; sourceTree = ""; }; BC59BBF6628D93AAC30CBFE0FF0151F2 /* ZIPFoundation-prefix.pch */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "ZIPFoundation-prefix.pch"; sourceTree = ""; }; + BC69A9D6FE337C36229B5CE0CA4206FF /* RDEPUBPageCountCache.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBPageCountCache.swift; sourceTree = ""; }; BD65B936E2A6FEB74718EA86D9AC1899 /* DTCoreTextParagraphStyle.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTCoreTextParagraphStyle.h; path = Core/Source/DTCoreTextParagraphStyle.h; sourceTree = ""; }; BEA46E51E3616ECEAF0ACCEA9B8C67EF /* DTFolderMonitor.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTFolderMonitor.m; path = Core/Source/DTFolderMonitor.m; sourceTree = ""; }; - BFF03BFDA015FD626A53E9FDBC95985A /* RDEPUBPaginator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBPaginator.swift; sourceTree = ""; }; + BF282AB822D775C2628A7A3D042A588D /* RDEPUBChapterSummaryDiskCache.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBChapterSummaryDiskCache.swift; sourceTree = ""; }; BFF51E56E09E095C92CB5B9F21384027 /* Archive+MemoryFile.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "Archive+MemoryFile.swift"; path = "Sources/ZIPFoundation/Archive+MemoryFile.swift"; sourceTree = ""; }; + C0CF886110ECBE6539FAF53C10B1498A /* String+RDEPUB.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "String+RDEPUB.swift"; sourceTree = ""; }; C0DCB00F0B62D7DBE628D2B5FF006E93 /* DTCoreText.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = DTCoreText.debug.xcconfig; sourceTree = ""; }; - C11CE525D0B420BD1FB8E289158F411D /* wxread-replace-latin.css */ = {isa = PBXFileReference; includeInIndex = 1; name = "wxread-replace-latin.css"; path = "Sources/RDReaderView/EPUBCore/Resources/wxread-replace-latin.css"; sourceTree = ""; }; - C13A624085502DB7A43EAEF62D179E1E /* RDEPUBViewportTypes.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBViewportTypes.swift; sourceTree = ""; }; - C23F9EE5E57C3681A4372C8884901664 /* RDEPUBPageLayoutSnapshot.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBPageLayoutSnapshot.swift; sourceTree = ""; }; - C39473D9AF5F23B7886FFC138E4E7644 /* RDReaderView.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = RDReaderView.debug.xcconfig; sourceTree = ""; }; - C3A9A32F96EBBCFD15780B8A62E38996 /* RDEPUBSemanticMarkerInjector.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBSemanticMarkerInjector.swift; sourceTree = ""; }; C3E162E0261A5B0850158D3403191E62 /* Archive.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Archive.swift; path = Sources/ZIPFoundation/Archive.swift; sourceTree = ""; }; C46FCF109DBCB37595D2A3366CF1D765 /* DTCoreTextLayoutFrame.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTCoreTextLayoutFrame.h; path = Core/Source/DTCoreTextLayoutFrame.h; sourceTree = ""; }; C495C74844982FAA1B846A2A89023D12 /* DTCoreTextMacros.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTCoreTextMacros.h; path = Core/Source/DTCoreTextMacros.h; sourceTree = ""; }; + C4A6313EEF4AEDADB4793D6CAEC32B0A /* RDEPUBCoreTextPageFrameFactory.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBCoreTextPageFrameFactory.swift; sourceTree = ""; }; C5005A9E8F746647585CCAB26853E922 /* NSString+DTFormatNumbers.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = "NSString+DTFormatNumbers.m"; path = "Core/Source/NSString+DTFormatNumbers.m"; sourceTree = ""; }; C508E49F6BF9F50B05918C230B033069 /* DTTextBlock.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTTextBlock.m; path = Core/Source/DTTextBlock.m; sourceTree = ""; }; - C559731DDB39006B286108CFA2DDA230 /* RDEPUBTextBookModels.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBTextBookModels.swift; sourceTree = ""; }; C69C7A531B5E021499D8FBF1DBE0E3BB /* NSAttributedStringRunDelegates.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = NSAttributedStringRunDelegates.m; path = Core/Source/NSAttributedStringRunDelegates.m; sourceTree = ""; }; - C69FCCE0841CEE98E2EBE9D173C0622C /* RDReaderPreloadController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDReaderPreloadController.swift; sourceTree = ""; }; - C6F1C6F5695FAAA4BA13936F58275B89 /* RDEPUBTextBookBuilder.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBTextBookBuilder.swift; sourceTree = ""; }; C7E09A6C247D465CA0EF71B939B2A040 /* NSArray+DTError.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = "NSArray+DTError.h"; path = "Core/Source/NSArray+DTError.h"; sourceTree = ""; }; - C86BF6C6A415B964A3F3B66B76DE9F0E /* RDEPUBPaginationModels.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBPaginationModels.swift; sourceTree = ""; }; - C9D122DED12EE5B85539805AB7010B32 /* RDEPUBCFITextAssertion.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBCFITextAssertion.swift; sourceTree = ""; }; - CA02E7C359334DEF7C203F369B5D6AAD /* WeReadApi.js */ = {isa = PBXFileReference; includeInIndex = 1; name = WeReadApi.js; path = Sources/RDReaderView/EPUBCore/Resources/WeReadApi.js; sourceTree = ""; }; + C8687094F5EB5A27E0D5A0070B19A4C2 /* wxread-dark.css */ = {isa = PBXFileReference; includeInIndex = 1; name = "wxread-dark.css"; path = "Sources/RDEpubReaderView/EPUBCore/Resources/wxread-dark.css"; sourceTree = ""; }; + C8A6E90AB5E26049710E2C2404C28B9A /* RDEpubReaderView-RDEpubReaderViewAssets */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; name = "RDEpubReaderView-RDEpubReaderViewAssets"; path = RDEpubReaderViewAssets.bundle; sourceTree = BUILT_PRODUCTS_DIR; }; + C985C2E56D867C385610E439E61BFBFE /* RDEpubURLReaderController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEpubURLReaderController.swift; sourceTree = ""; }; CA4A114775B98CEA4A566C18123B145F /* ZIPFoundation-ZIPFoundation_Privacy */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; name = "ZIPFoundation-ZIPFoundation_Privacy"; path = ZIPFoundation_Privacy.bundle; sourceTree = BUILT_PRODUCTS_DIR; }; CA5630DC0E663F38A9EC713323697EE1 /* DTLazyImageView.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTLazyImageView.m; path = Core/Source/DTLazyImageView.m; sourceTree = ""; }; + CABD447CD3A40D2D3C8E80590B8361B7 /* RDEpubReaderSpreadResolver.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEpubReaderSpreadResolver.swift; sourceTree = ""; }; CACF20969583778CAC1BC92BB96F27FD /* NSAttributedString+SmallCaps.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = "NSAttributedString+SmallCaps.h"; path = "Core/Source/NSAttributedString+SmallCaps.h"; sourceTree = ""; }; CB2BD1F8CFECB82D427DF046439E68D6 /* DTHTMLParser.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTHTMLParser.h; path = Core/Source/DTHTMLParser/DTHTMLParser.h; sourceTree = ""; }; + CB2D03387C98C80186C40EAA590D89BD /* RDEPUBCFISerializer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBCFISerializer.swift; sourceTree = ""; }; CCC633BF76C79E0B4A6750337D5DCDB8 /* DTVersion.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTVersion.m; path = Core/Source/DTVersion.m; sourceTree = ""; }; - CCCAE17086995415A5984D248EA420D6 /* RDEPUBReaderLocationCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderLocationCoordinator.swift; sourceTree = ""; }; - CE12B04784ACE77BDF6F920F30699A70 /* RDReaderView+CollectionView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "RDReaderView+CollectionView.swift"; sourceTree = ""; }; + CCD4B82D09BE72980015175674B04B22 /* RDEPUBReaderSearchBarView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderSearchBarView.swift; sourceTree = ""; }; + CD6168EB90D5F2AE16988D0B4BC4C56E /* RDEPUBFixedLayoutTemplate.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBFixedLayoutTemplate.swift; sourceTree = ""; }; + CE074C3401A1CAF095691391F2028E61 /* RDEPUBNotePopupViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBNotePopupViewController.swift; sourceTree = ""; }; CE761A55BD57C0BFD6336CD073F85C31 /* DTLinkButton.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTLinkButton.m; path = Core/Source/DTLinkButton.m; sourceTree = ""; }; CEBA01360577AAD3FD089B6B17C24260 /* UIFont+DTCoreText.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = "UIFont+DTCoreText.m"; path = "Core/Source/UIFont+DTCoreText.m"; sourceTree = ""; }; CEEC8715398E89B42F9B04E290E8E4FE /* NSString+DTURLEncoding.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = "NSString+DTURLEncoding.h"; path = "Core/Source/NSString+DTURLEncoding.h"; sourceTree = ""; }; CF1CED3ED65443999913BC0D96ACA151 /* DTImage+HTML.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = "DTImage+HTML.m"; path = "Core/Source/DTImage+HTML.m"; sourceTree = ""; }; + CF8FD338F89FBB4FEDDF24E590E86AFE /* RDEpubReaderView.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = RDEpubReaderView.debug.xcconfig; sourceTree = ""; }; D0856C3894240A673B5AB7C2704E9144 /* DTDictationPlaceholderTextAttachment.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTDictationPlaceholderTextAttachment.h; path = Core/Source/DTDictationPlaceholderTextAttachment.h; sourceTree = ""; }; D18519BCA8FAA9B01A704A0F0B9FFC78 /* Archive+ZIP64.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "Archive+ZIP64.swift"; path = "Sources/ZIPFoundation/Archive+ZIP64.swift"; sourceTree = ""; }; + D1939CE18FB1D0E9342A3963CB1F5C84 /* RDEPUBReaderPersistence.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderPersistence.swift; sourceTree = ""; }; D1E89A31867FF1FB44B4B999988F1B82 /* DTFoundation */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; name = DTFoundation; path = DTFoundation.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - D246168B830810582866BFF9209DBFD3 /* RDEPUBChapterWarmupOrchestrator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBChapterWarmupOrchestrator.swift; sourceTree = ""; }; + D260C6E64F57CE89343A271FB4804745 /* RDEPUBTextRendererSupport.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBTextRendererSupport.swift; sourceTree = ""; }; D28A3E79D83EA4B807E2D3A5EE086B8B /* DTHTMLParserNode.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTHTMLParserNode.h; path = Core/Source/DTHTMLParserNode.h; sourceTree = ""; }; D30DBDF1EDB20CF83690AC6619AA0C48 /* NSString+DTPaths.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = "NSString+DTPaths.m"; path = "Core/Source/NSString+DTPaths.m"; sourceTree = ""; }; - D368B75B200BA85AA29995C39AA63371 /* RDEPUBReaderController+PublicAPI.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "RDEPUBReaderController+PublicAPI.swift"; sourceTree = ""; }; + D36009F925C400C4981D456059CC16BA /* RDEPUBJavaScriptBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBJavaScriptBridge.swift; sourceTree = ""; }; + D46B1E71516E5EC8C9D039B9D836D290 /* RDEPUBCFIRecoveryEngine.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBCFIRecoveryEngine.swift; sourceTree = ""; }; D4C731AA2088D7E873D8E3D21B95D215 /* DTIframeTextAttachment.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTIframeTextAttachment.h; path = Core/Source/DTIframeTextAttachment.h; sourceTree = ""; }; - D5501A46F5D094488C50D841B21AAE96 /* UIColor+RDEPUB.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "UIColor+RDEPUB.swift"; sourceTree = ""; }; + D5CC55716AF7BC27FD9268D256ABA314 /* RDEPUBAttachmentTooltipOverlayView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBAttachmentTooltipOverlayView.swift; sourceTree = ""; }; + D5D6E84305CAE9A6583309CA93366E15 /* RDEPUBNotePopupCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBNotePopupCoordinator.swift; sourceTree = ""; }; D5DD4142DF33E1BE0B5A057101E851C5 /* DTLazyImageView.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTLazyImageView.h; path = Core/Source/DTLazyImageView.h; sourceTree = ""; }; - D5E4801FDC3D9637688B1ADC282597D0 /* RDReaderView.podspec */ = {isa = PBXFileReference; explicitFileType = text.script.ruby; includeInIndex = 1; indentWidth = 2; lastKnownFileType = text; path = RDReaderView.podspec; sourceTree = ""; tabWidth = 2; xcLanguageSpecificationIdentifier = xcode.lang.ruby; }; - D6220F23ADB2582C577A0C44BA52657D /* RDEPUBReaderRuntime.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderRuntime.swift; sourceTree = ""; }; - D7540C99450185F3D8B99D75A4C065F4 /* RDEPUBWebDecorationOverlayView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBWebDecorationOverlayView.swift; sourceTree = ""; }; + D6C8585AECE0BC205EE95CC484EC6561 /* RDEPUBFragmentMarkerInjector.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBFragmentMarkerInjector.swift; sourceTree = ""; }; + D71B377949D433438F307CC6B120D372 /* RDEpubReaderView.modulemap */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.module; path = RDEpubReaderView.modulemap; sourceTree = ""; }; + D76AF74F7C0A5657241E97D1CAD4A833 /* RDEPUBBuildDiagnosticsReporter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBBuildDiagnosticsReporter.swift; sourceTree = ""; }; D80A72D8B7941D30246858D840EE1EF5 /* DTCoreTextLayouter.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTCoreTextLayouter.m; path = Core/Source/DTCoreTextLayouter.m; sourceTree = ""; }; - D994416DB1DCB53E8D1F4A20D10A3D58 /* RDEPUBTextAnnotationOverlay.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBTextAnnotationOverlay.swift; sourceTree = ""; }; + D915BB57012D656EE211B3BC41EBAA93 /* RDEPUBCFIGenerator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBCFIGenerator.swift; sourceTree = ""; }; D9A812C3E577A3ADC36BAE3FF7AA4D9C /* NSFileWrapper+DTCopying.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = "NSFileWrapper+DTCopying.m"; path = "Core/Source/NSFileWrapper+DTCopying.m"; sourceTree = ""; }; DA2ED17D0DDF5F1C5606BFFD9A512DE4 /* DTCoreTextFunctions.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTCoreTextFunctions.h; path = Core/Source/DTCoreTextFunctions.h; sourceTree = ""; }; - DB350DF3FC839EBAC2D622C87CE4C8AD /* RDEPUBTextBookCache.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBTextBookCache.swift; sourceTree = ""; }; - DB3D7F9A13B102C659531CCAAB50035F /* RDReaderViewProtocols.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDReaderViewProtocols.swift; sourceTree = ""; }; + DABA2040236556B3E47062333B414AF5 /* RDEPUBTextSearchEngine.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBTextSearchEngine.swift; sourceTree = ""; }; + DB7F916B5DAC02317D14747841AF489C /* RDEPUBStyleSheetBuilder.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBStyleSheetBuilder.swift; sourceTree = ""; }; + DC0D5B57FDFCE021F4354EA651E68507 /* RDEPUBResourceResolver.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBResourceResolver.swift; sourceTree = ""; }; DC7D3AB85C954DA41E1500936C166C0E /* NSString+HTML.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = "NSString+HTML.h"; path = "Core/Source/NSString+HTML.h"; sourceTree = ""; }; - DCADA127F117763F7DC1E99EC687D9F1 /* RDEPUBReaderController+RuntimeBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "RDEPUBReaderController+RuntimeBridge.swift"; sourceTree = ""; }; + DC96DD4FDA029CD6D6E810916DEBD563 /* RDEPUBTextAnnotationOverlay.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBTextAnnotationOverlay.swift; sourceTree = ""; }; DD0A5FF70CDBE87597264762EC20EF43 /* NSScanner+HTML.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = "NSScanner+HTML.h"; path = "Core/Source/NSScanner+HTML.h"; sourceTree = ""; }; DD7A2DE5BDF72ABC066484CE554BDEF0 /* DTHorizontalRuleHTMLElement.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTHorizontalRuleHTMLElement.m; path = Core/Source/DTHorizontalRuleHTMLElement.m; sourceTree = ""; }; - DDFAE45005F7BB7D9114003D6F4AA87A /* RDEPUBReaderToolView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderToolView.swift; sourceTree = ""; }; - DE0E862DE5230ECE5406E1D3EB3ED840 /* RDReaderView.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = RDReaderView.release.xcconfig; sourceTree = ""; }; DE4821B98DDE6C71411EC139EC6D2762 /* CoreGraphics.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreGraphics.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/CoreGraphics.framework; sourceTree = DEVELOPER_DIR; }; DE83356F8FBC1801D29CA93C1A190D81 /* Archive+Progress.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "Archive+Progress.swift"; path = "Sources/ZIPFoundation/Archive+Progress.swift"; sourceTree = ""; }; - DF8F93382E48EBF445FDA4A10C34EE23 /* RDEPUBNavigatorLayoutContext.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBNavigatorLayoutContext.swift; sourceTree = ""; }; + DFE26DA6A1180E7B617EE341C73F7A50 /* RDEPUBReaderConfiguration.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderConfiguration.swift; sourceTree = ""; }; E0192469A486DA7CECFECEA37518B6B9 /* DTHTMLAttributedStringBuilder.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTHTMLAttributedStringBuilder.m; path = Core/Source/DTHTMLAttributedStringBuilder.m; sourceTree = ""; }; - E169B071EF0569FFFECA74A68DA4ACB4 /* wxread-default.css */ = {isa = PBXFileReference; includeInIndex = 1; name = "wxread-default.css"; path = "Sources/RDReaderView/EPUBCore/Resources/wxread-default.css"; sourceTree = ""; }; - E2B2A40829A4A7C9D9B922A44179609E /* RDEPUBChapterData.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBChapterData.swift; sourceTree = ""; }; - E3011B53A527F51AB7C6ABF0151CC130 /* RDEPUBRenderDiagnosticsCollector.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBRenderDiagnosticsCollector.swift; sourceTree = ""; }; + E2023C1B0BC6999F982585F30454EF30 /* RDEPUBNoteDetector.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBNoteDetector.swift; sourceTree = ""; }; + E293D93795FE3F3AA09E249FBBAF2169 /* RDEpubReaderPreloadController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEpubReaderPreloadController.swift; sourceTree = ""; }; + E2B413E5361DB33B983E9237109AD610 /* RDEPUBDecryptingImageAttachment.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBDecryptingImageAttachment.swift; sourceTree = ""; }; + E2F02728AA8EB960A35AE0DD7736C90A /* RDEPUBWebViewDebug.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBWebViewDebug.swift; sourceTree = ""; }; + E39C7BDD4236B494D4C00CBEC40F4371 /* RDEPUBCFIMap.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBCFIMap.swift; sourceTree = ""; }; + E3B3807F1E6648C733C6F915288C1E45 /* RDEPUBViewportTypes.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBViewportTypes.swift; sourceTree = ""; }; E3CAAC46883B74B071843E5D38B5BF85 /* DTImage+HTML.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = "DTImage+HTML.h"; path = "Core/Source/DTImage+HTML.h"; sourceTree = ""; }; E4C5EB3F8F005C65005C101A9DBE488A /* DTWeakSupport.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTWeakSupport.h; path = Core/Source/DTWeakSupport.h; sourceTree = ""; }; E5377786E80195587BF3E8A7589E5925 /* DTHTMLParserTextNode.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTHTMLParserTextNode.m; path = Core/Source/DTHTMLParserTextNode.m; sourceTree = ""; }; E5D4D6E12779C54F17AE98A1E6C9B699 /* DTBase64Coding.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTBase64Coding.m; path = Core/Source/DTBase64Coding.m; sourceTree = ""; }; E687628EF2FE92D093E3005D21B578E5 /* DTWeakSupport.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTWeakSupport.h; path = Core/Source/DTWeakSupport.h; sourceTree = ""; }; E6B46EF6F055A721AF370D705D026E9E /* Data+CompressionDeprecated.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "Data+CompressionDeprecated.swift"; path = "Sources/ZIPFoundation/Data+CompressionDeprecated.swift"; sourceTree = ""; }; + E6C77B875320EA1C7172B5E8F8A4460A /* RDEPUBSearchEngine.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBSearchEngine.swift; sourceTree = ""; }; E74132875CC4A311B230B68C085D8CE6 /* ResourceBundle-Resources-DTCoreText-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = "ResourceBundle-Resources-DTCoreText-Info.plist"; sourceTree = ""; }; + E79069E6008AAA4FD339D2819CDF4675 /* RDEPUBBackgroundCoverageStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBBackgroundCoverageStore.swift; sourceTree = ""; }; E853AAB3B09E0662567752431CEF1D33 /* DTTextAttachment.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTTextAttachment.h; path = Core/Source/DTTextAttachment.h; sourceTree = ""; }; E865600EE9117A9153716299FFA7912D /* Archive+Reading.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "Archive+Reading.swift"; path = "Sources/ZIPFoundation/Archive+Reading.swift"; sourceTree = ""; }; E89F199ECDE796B15CAF8E0C1775FAA6 /* DTCoreTextConstants.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTCoreTextConstants.m; path = Core/Source/DTCoreTextConstants.m; sourceTree = ""; }; - E9074F378D3B707959729C0DB2C2B8AB /* RDEPUBPageMapReconciliationCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBPageMapReconciliationCoordinator.swift; sourceTree = ""; }; + E935E1C0DB2E3250D48D6F7B1F1785EB /* RDEPUBBookPageMap.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBBookPageMap.swift; sourceTree = ""; }; E96C0EEB2032D3CC18F4B37A7C34C84C /* DTAttributedTextContentView.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTAttributedTextContentView.h; path = Core/Source/DTAttributedTextContentView.h; sourceTree = ""; }; E9D8AB18960B56A997408E3235751624 /* ZIPFoundation-dummy.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; path = "ZIPFoundation-dummy.m"; sourceTree = ""; }; + E9F15AEACE475EF232CB992A4CDCC6E3 /* RDEPUBBackgroundTrace.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBBackgroundTrace.swift; sourceTree = ""; }; EA2BF1D7535B036E1356E8DF42A04954 /* DTTextBlock.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTTextBlock.h; path = Core/Source/DTTextBlock.h; sourceTree = ""; }; EB850927957E35DB8CE0F109188C99A9 /* DTCoreTextLayoutLine.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTCoreTextLayoutLine.m; path = Core/Source/DTCoreTextLayoutLine.m; sourceTree = ""; }; EBA0272559B0F7FAD8AC670AC7431188 /* Pods-ReadViewDemo-umbrella.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "Pods-ReadViewDemo-umbrella.h"; sourceTree = ""; }; ED049C791713944233EE10C7A111C691 /* DTCoreTextGlyphRun.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTCoreTextGlyphRun.m; path = Core/Source/DTCoreTextGlyphRun.m; sourceTree = ""; }; + ED2AB892BC893DB186CD0AFA5CA63F61 /* RDEPUBReaderController+TableOfContents.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "RDEPUBReaderController+TableOfContents.swift"; sourceTree = ""; }; ED9A1EBDA0FD72671C7F6FE15F37B906 /* DTTextHTMLElement.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTTextHTMLElement.h; path = Core/Source/DTTextHTMLElement.h; sourceTree = ""; }; EDA6D5D7D0A04878EE78FAAE94F65068 /* DTCoreText */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; name = DTCoreText; path = DTCoreText.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + EE31789BAB1A8C59EDFC3C728B350E90 /* RDEPUBWebContentView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBWebContentView.swift; sourceTree = ""; }; EF625CF6A790938F5CE0F0C7DC36E340 /* DTCoreTextLayoutFrame+Cursor.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = "DTCoreTextLayoutFrame+Cursor.m"; path = "Core/Source/DTCoreTextLayoutFrame+Cursor.m"; sourceTree = ""; }; - EFB982B05AC5435F003EC90F8A93EDA0 /* RDReaderFlowLayout.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDReaderFlowLayout.swift; sourceTree = ""; }; - EFC5E77754171DA25D9A8A76A0985CCF /* RDEPUBReadingSession.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReadingSession.swift; sourceTree = ""; }; - F34C3AB5B2B7CFAED280AB097BF51C21 /* RDEPUBCoreTextPageFrameFactory.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBCoreTextPageFrameFactory.swift; sourceTree = ""; }; + F1E86313FAB2369F42BBAC7FE6CAF40B /* RDEPUBParser+TOC.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "RDEPUBParser+TOC.swift"; sourceTree = ""; }; + F344665C57510633A0980B1F2F023A2F /* RDEPUBReaderController+RenderSupport.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "RDEPUBReaderController+RenderSupport.swift"; sourceTree = ""; }; + F360D7798705EF33FED7578F42ABE588 /* RDEPUBSelectionLoupeView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBSelectionLoupeView.swift; sourceTree = ""; }; F3C9CA544EBF55130625F6C3AD2ABFB4 /* Archive+Deprecated.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "Archive+Deprecated.swift"; path = "Sources/ZIPFoundation/Archive+Deprecated.swift"; sourceTree = ""; }; F3E93C4AC73A09DE1EAE805B0ADE3306 /* NSString+DTUtilities.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = "NSString+DTUtilities.m"; path = "Core/Source/NSString+DTUtilities.m"; sourceTree = ""; }; - F44654531A4D3A5E63F117378BDBA15E /* RDEPUBCFIResolver.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBCFIResolver.swift; sourceTree = ""; }; F46010C3A0C82AE5ABEC814D7EC6351B /* DTAttributedTextView.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTAttributedTextView.m; path = Core/Source/DTAttributedTextView.m; sourceTree = ""; }; + F486744FB2C3E4E55A9FED93371C7272 /* RDEPUBMetadataParseWorker.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBMetadataParseWorker.swift; sourceTree = ""; }; F49FC82A04EE9B3B49CBA6CB88ACBC67 /* FileManager+ZIPDeprecated.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "FileManager+ZIPDeprecated.swift"; path = "Sources/ZIPFoundation/FileManager+ZIPDeprecated.swift"; sourceTree = ""; }; F4E2B1AC73977F10254628C0B7EBE38F /* DTCoreText-Resources */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; name = "DTCoreText-Resources"; path = Resources.bundle; sourceTree = BUILT_PRODUCTS_DIR; }; - F518D64E810A583A82D13ED1FEF8ACF8 /* RDEPUBRuntimePageCount.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBRuntimePageCount.swift; sourceTree = ""; }; + F4E8A28FA2AC4F578A1098D8285D3FFE /* RDEpubReaderContentCell.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEpubReaderContentCell.swift; sourceTree = ""; }; + F57646CB4F9349BC9776772BFDC56B49 /* RDEPUBImageViewerCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBImageViewerCoordinator.swift; sourceTree = ""; }; + F630BB624DD56B3C0BD9131D8ACEC21D /* RDEPUBWebDecorationOverlayView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBWebDecorationOverlayView.swift; sourceTree = ""; }; F63EBF2BF3000B3391F6E693581EA129 /* NSData+DTCrypto.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = "NSData+DTCrypto.m"; path = "Core/Source/NSData+DTCrypto.m"; sourceTree = ""; }; + F72A99A09ECD213552D649EF0E7BD081 /* RDEPUBParser+ReadingProfile.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "RDEPUBParser+ReadingProfile.swift"; sourceTree = ""; }; + F77D2A383DC7C1F05BD961646DB1D61E /* RDEPUBPageLayoutSnapshot.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBPageLayoutSnapshot.swift; sourceTree = ""; }; F7C508BE899A1397560A767CAEADB7B9 /* DTHTMLElement.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTHTMLElement.h; path = Core/Source/DTHTMLElement.h; sourceTree = ""; }; - F91BABC77F3FC64BDACE8400F2A1808B /* epub-fixed-layout.html */ = {isa = PBXFileReference; includeInIndex = 1; name = "epub-fixed-layout.html"; path = "Sources/RDReaderView/EPUBCore/Resources/epub-fixed-layout.html"; sourceTree = ""; }; - F98C79337772FFEE3E718C3A251E5B9D /* RDEPUBWebView+Search.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "RDEPUBWebView+Search.swift"; sourceTree = ""; }; + F84F78EBE95C9D7F7DC66344EB40BE9F /* RDEPUBChapterOffsetMap.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBChapterOffsetMap.swift; sourceTree = ""; }; + F88EF2BFEFAC452A2D6082C661041929 /* RDEPUBPaginator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBPaginator.swift; sourceTree = ""; }; F9D9BEF72311AF9E3239F9B92A9D2842 /* DTAnimatedGIF.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTAnimatedGIF.h; path = Core/Source/iOS/DTAnimatedGIF/DTAnimatedGIF.h; sourceTree = ""; }; FA107BE47514D01B94B7A2670A9F83E5 /* NSString+HTML.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = "NSString+HTML.m"; path = "Core/Source/NSString+HTML.m"; sourceTree = ""; }; - FA9CF4A5EFC9FEEA82D723074F256E82 /* RDEPUBNoteDetector.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBNoteDetector.swift; sourceTree = ""; }; - FAA4CD4C6398D41D3E73B895B41A6C2B /* RDEPUBWebView+JavaScriptBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "RDEPUBWebView+JavaScriptBridge.swift"; sourceTree = ""; }; - FD5FFE0C7676BB044875877B91485384 /* RDEPUBTypesettingPipeline.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBTypesettingPipeline.swift; sourceTree = ""; }; - FE17B2F690F6065BB982FA37EB9DF24C /* RDEPUBReaderTheme.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderTheme.swift; sourceTree = ""; }; + FA19A96B26E75A87DFA7F1E213474E48 /* RDEPUBReaderContext.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderContext.swift; sourceTree = ""; }; + FA4EC97EE53FBDB24B5FB92C7FE4C7C4 /* RDEPUBReaderToolView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderToolView.swift; sourceTree = ""; }; + FB1327FE6CC30C4AC4A5124BEB747510 /* RDEPUBCFIParser.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBCFIParser.swift; sourceTree = ""; }; + FBF4C131ADF1BC0D64D695B49AAC8370 /* RDEPUBReaderController+PublicAPI.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "RDEPUBReaderController+PublicAPI.swift"; sourceTree = ""; }; + FC0B33BDB6F7F3F950944CB3E69CDA68 /* RDEPUBPaginationModels.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBPaginationModels.swift; sourceTree = ""; }; + FD12C1ABCF0800609F8579BC62C4997F /* RDEPUBPageBreakPolicy.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBPageBreakPolicy.swift; sourceTree = ""; }; + FE76CB44588C6BA77E5E95CFF23BD68C /* RDEpubReaderView+ToolView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "RDEpubReaderView+ToolView.swift"; sourceTree = ""; }; FEA9F91758013B23B0EEA71DA22E7475 /* Pods-ReadViewDemo-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = "Pods-ReadViewDemo-Info.plist"; sourceTree = ""; }; FEB14A8F8170777DB7CCC3F27C8EA57A /* ImageIO.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ImageIO.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/ImageIO.framework; sourceTree = DEVELOPER_DIR; }; FF1C41D226C0957F715EFF3F23893386 /* DTFoundation-dummy.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; path = "DTFoundation-dummy.m"; sourceTree = ""; }; FF63F5E8902D6F3F5E845A924EBBC6D3 /* DTFoundationConstants.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTFoundationConstants.h; path = Core/Source/DTFoundationConstants.h; sourceTree = ""; }; - FF8D9CFA61CC68C8A808C2B39CE0C7F8 /* RDEPUBCFIGenerator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBCFIGenerator.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ - 002B78A4F01BC47F7C7339F0DD6A71BD /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - DBB66507EB07AB5F08E6D305AA35B8FB /* Foundation.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 0E04762C7E5292C5A68246FB22B73AA6 /* Frameworks */ = { + 019CACBDE98B6E7CD6E2323C2BE18D6F /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; - 241C62665F0673AB5044556DB7A47F1F /* Frameworks */ = { + 1DC6173FB956454B07EFE7D70CFAA752 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - D063317214AFF5143D9C958E5FAFBB38 /* Foundation.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 2E80BB883280157F55CC1A24FD8FDD42 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 6421A89E3EF3FC54EE02EF965D4C0C1B /* Foundation.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1040,6 +1023,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 67452C77F8E886622577C996F3462FE6 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 295918B689386A62654E2CE1AB071A69 /* Foundation.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 6F1DC7DF5E89C820D04E068D56B1D0EF /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -1053,10 +1044,19 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - B39787E0F2B7900159C968B18DC80D2D /* Frameworks */ = { + 902FF6DD4EB5A15E1C04D090758D9DA8 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + AC084BF5FB00B8C95649991E97BED6E5 /* Foundation.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + AA17F25A105235455D6DBA0379F37BC1 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 6277845A6CCAAA5F160D86F6AA458341 /* Foundation.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1070,22 +1070,15 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 01B04F0BE9DE512C31BD8B4DE265A3D1 /* EPUBTextRendering */ = { + 03DC298EABDEB36C8A8F9F3BD445F015 /* Models */ = { isa = PBXGroup; children = ( - E2B2A40829A4A7C9D9B922A44179609E /* RDEPUBChapterData.swift */, - 635FB53F5F49CC7F40F7C5E700A3A3EE /* RDEPUBDTCoreTextRenderer.swift */, - 674CA023806B055868320A59D18FD977 /* RDEPUBTextIndexTable.swift */, - 4D64719937C574F41EE90673FE70F681 /* RDEPUBTextPositionConverter.swift */, - 70AAB379D168256A54BC162D9CD200B7 /* RDEPUBTextRenderer.swift */, - 44D834B66794C6899B84C5782F3624C6 /* RDEPUBTextSearchEngine.swift */, - 7099DFE326BE72991030E977265222E8 /* RDPlainTextBookBuilder.swift */, - 84242A364B178500A166441993CF3C9D /* BuildPipeline */, - BFC039C2D19544126D270E60A9A1BD89 /* Pagination */, - D6DFD3B4A9DBC501F19BD5F07EBC6341 /* Typesetter */, + 8250EF1AA33B1FBC82A438CB75C77E7D /* RDEPUBAnnotationModels.swift */, + FC0B33BDB6F7F3F950944CB3E69CDA68 /* RDEPUBPaginationModels.swift */, + 98D44DA052097D1223C075A582A6E1C7 /* RDEPUBReadingLocationModels.swift */, ); - name = EPUBTextRendering; - path = Sources/RDReaderView/EPUBTextRendering; + name = Models; + path = Models; sourceTree = ""; }; 06C22A27383EA079F8FF2367C9567BC0 /* DTCoreText */ = { @@ -1242,6 +1235,28 @@ path = "Target Support Files/Pods-ReadViewDemo"; sourceTree = ""; }; + 0E2A238FDA759C340D26856ED84A003D /* TextPage */ = { + isa = PBXGroup; + children = ( + AAFF9E2F6436861E176B5E55A1180672 /* RDEPUBChapterDisplayContentCache.swift */, + A08CDC243F29B29A95253DCA44335BFE /* RDEPUBDarkImageAdjuster.swift */, + 00385D2EEBD20524770BBB6A89C4DB3D /* RDEPUBPageInteractionController.swift */, + F77D2A383DC7C1F05BD961646DB1D61E /* RDEPUBPageLayoutSnapshot.swift */, + F360D7798705EF33FED7578F42ABE588 /* RDEPUBSelectionLoupeView.swift */, + 0D94BA24C3E1653B2ADF0961B2DFD09F /* RDEPUBSelectionOverlayView.swift */, + DC96DD4FDA029CD6D6E810916DEBD563 /* RDEPUBTextAnnotationOverlay.swift */, + B3875D495797D0A7441AE5FDC6606E31 /* RDEPUBTextContentInteractionCoordinator.swift */, + 4D5A551AA691377650E64BF614B5315E /* RDEPUBTextContentView.swift */, + A8C567DF41CC58FE2BA5C88C6B4A71F6 /* RDEPUBTextPageBoundaryValidator.swift */, + 1251AEBAF40F77167C2B57D0A466DAA4 /* RDEPUBTextPageDecorationView.swift */, + 698DD735E4FB33C89CEA4216C18CD31E /* RDEPUBTextPageRenderView.swift */, + 1F922CBFACAAD201E732987D727209EE /* RDEPUBTextPageVerticalJustifier.swift */, + 8170A64FBDE3CC44D5EFA56020DC7978 /* RDEPUBTextSelectionController.swift */, + ); + name = TextPage; + path = TextPage; + sourceTree = ""; + }; 1A823333EC1496E37E1F99813FE335DD /* DTAnimatedGIF */ = { isa = PBXGroup; children = ( @@ -1251,40 +1266,45 @@ name = DTAnimatedGIF; sourceTree = ""; }; - 1AC1EBE8B740242D8ED52884A9881E9B /* Settings */ = { + 1CA1B2C980301DD7B83B48382F78ED08 /* Development Pods */ = { isa = PBXGroup; children = ( - 27A8E420B22CCB314B97FF093673D7A7 /* RDEPUBReaderConfiguration.swift */, - 67B9C855C41D2A2276BB04970B73D00B /* RDEPUBReaderSettings.swift */, - 94E4C09CA9FBF73F01D7DA481E085E0B /* RDEPUBReaderSettingsViewController.swift */, - FE17B2F690F6065BB982FA37EB9DF24C /* RDEPUBReaderTheme.swift */, + BF9E008141B88625C14F2648FC9D1226 /* RDEpubReaderView */, ); - name = Settings; - path = Settings; + name = "Development Pods"; sourceTree = ""; }; - 1C5AC06F32CDDE751C9C6BA935BEB1E3 /* RDReaderView */ = { + 2F5C5558D370957F656D4C77FDD78A55 /* ReaderController */ = { isa = PBXGroup; children = ( - 3C9F522B5818D3E8F6E4B10B22A9F3C9 /* cssInjector.js */, - 1AA0F27424B80E5D07E45D8DA86C9C26 /* epub-bridge.js */, - F91BABC77F3FC64BDACE8400F2A1808B /* epub-fixed-layout.html */, - 4568EB88B6EDC55FAC77D565DA1A2750 /* rangy-core.js */, - 86DD8A5C0624A7C2C0C328A40118C3E8 /* rangy-serializer.js */, - CA02E7C359334DEF7C203F369B5D6AAD /* WeReadApi.js */, - B52A7884E951C3C2064E5610DD417DA5 /* wxread-dark.css */, - E169B071EF0569FFFECA74A68DA4ACB4 /* wxread-default.css */, - 546293C95A2E9D6898E6E35EBAD67E3C /* wxread-replace.css */, - C11CE525D0B420BD1FB8E289158F411D /* wxread-replace-latin.css */, - 3F336D21B18772F72103036E6462A145 /* EPUBCore */, - 01B04F0BE9DE512C31BD8B4DE265A3D1 /* EPUBTextRendering */, - 88A30917BF0A7F49821B57F828197F51 /* EPUBUI */, - 913AE0D51BBCBADFBCA8DEFE33FA874C /* Pod */, - A55FE0149CE18136E8CDCA6B7F3D6AD4 /* ReaderView */, - BA377FB14D6835880328094AAF737A9C /* Support Files */, + E79069E6008AAA4FD339D2819CDF4675 /* RDEPUBBackgroundCoverageStore.swift */, + 44D49963D6EAE25EA025A41D91CEAD23 /* RDEPUBBackgroundPriorityPolicy.swift */, + 7B098FF2F53DED347EE39859D3CC11BE /* RDEPUBJumpSession.swift */, + 8C46A096335AF772EB451C71B476A0C8 /* RDEPUBMemoryProbe.swift */, + 65144E0A92FF19D145E244C61896601B /* RDEPUBMetadataParseCancellationController.swift */, + F486744FB2C3E4E55A9FED93371C7272 /* RDEPUBMetadataParseWorker.swift */, + 46F84AE2351143E6F632A3FEE684A62C /* RDEPUBPageMapReconciliationCoordinator.swift */, + 961206494549A9AE3C6EB03DDAD21C1C /* RDEPUBPresentationRuntime.swift */, + 376805E5E5095E2902BB687236336C0F /* RDEPUBReaderAnnotationCoordinator.swift */, + 66497586176C305ADC5F3B447F17FA90 /* RDEPUBReaderAssemblyCoordinator.swift */, + 4DDA63DD315651FD20B81BE242ADFC72 /* RDEPUBReaderChromeCoordinator.swift */, + FA19A96B26E75A87DFA7F1E213474E48 /* RDEPUBReaderContext.swift */, + 07EF7DFA2327CD5059A2A0B693813195 /* RDEPUBReaderDependencies.swift */, + 96D1D1F318551BFCF4B28021E708564D /* RDEPUBReaderEnvironment.swift */, + 29F6775325522C48C45B7023AF9B83EB /* RDEPUBReaderLoadCoordinator.swift */, + 98C6026F789783895FC83556EF6F289C /* RDEPUBReaderLocationCoordinator.swift */, + AAAD24FFA4B4B8E33C45ED26C2FD4B37 /* RDEPUBReaderPaginationCoordinator.swift */, + A7C6D0CBF46040C1E92580C7CB26A47C /* RDEPUBReaderRuntime.swift */, + 4EC9324EF18C7802D6401A32D6B5C2CC /* RDEPUBReaderSearchCoordinator.swift */, + 19A9DF3B54605BC418E5EC2AC9C285F9 /* RDEPUBReaderServices.swift */, + 6A58A47E8A2EEF76C3ED73CB54A086C0 /* RDEPUBReaderState.swift */, + 4830D8C6A94E0C97E0F0675276BFD674 /* RDEPUBReaderUIState.swift */, + 89BD34E68BFF47D27D5F91863AF1BBF1 /* RDEPUBReaderViewportMonitor.swift */, + 82BB9B078A1C0E0BDB1BFBB93A670A2A /* RDEPUBSelectionState.swift */, + D49A996EA5DB539C5B945FA3BB68E029 /* ChapterRuntime */, ); - name = RDReaderView; - path = ../..; + name = ReaderController; + path = ReaderController; sourceTree = ""; }; 30694C306AB98E50937E9ECB722C31A4 /* Core */ = { @@ -1294,6 +1314,52 @@ name = Core; sourceTree = ""; }; + 3217B140E7CD936FAB4FE45BED42A4E4 /* EPUBUI */ = { + isa = PBXGroup; + children = ( + D5CC55716AF7BC27FD9268D256ABA314 /* RDEPUBAttachmentTooltipOverlayView.swift */, + 08AA95F9E01837CBD5833F9D03F5FC8A /* RDEPUBAttachmentTooltipView.swift */, + 06A86745BE9CD1C490EC229FA2F09BBD /* RDEPUBImageViewController.swift */, + F57646CB4F9349BC9776772BFDC56B49 /* RDEPUBImageViewerCoordinator.swift */, + A49A27A2F307647B468A9652F7A49006 /* RDEPUBReaderBottomToolView.swift */, + 6F265D797C3E4FD97ED35CB9BF70F121 /* RDEPUBReaderChapterListController.swift */, + 499D25E7902D605A5EC2940A714AABE0 /* RDEPUBReaderController.swift */, + 6D5EB1FE3AA42EEE041C5BAF4391E8B3 /* RDEPUBReaderController+AttachmentTooltip.swift */, + 5E81140A7F083D951F3E912FC03A1A3B /* RDEPUBReaderController+ContentDelegates.swift */, + 38887F7296CA064DFC5B3ABBE16EE16C /* RDEPUBReaderController+DataSource.swift */, + 3492A7E1375C07EAACE09CF11D14212D /* RDEPUBReaderController+ExternalLinks.swift */, + 2DC9A66DE304F1176D36FD9294F0E536 /* RDEPUBReaderController+LocationResolution.swift */, + 8A1B3AAEE7814FA72D7C528E10D49C3C /* RDEPUBReaderController+Orientation.swift */, + FBF4C131ADF1BC0D64D695B49AAC8370 /* RDEPUBReaderController+PublicAPI.swift */, + F344665C57510633A0980B1F2F023A2F /* RDEPUBReaderController+RenderSupport.swift */, + A48F69748B05758B4CB62757E8CF6464 /* RDEPUBReaderController+RuntimeBridge.swift */, + ED2AB892BC893DB186CD0AFA5CA63F61 /* RDEPUBReaderController+TableOfContents.swift */, + 2AF6AEEC13967258B9E5E8BF691ADDC8 /* RDEPUBReaderController+Trial.swift */, + 63731E76CD377576124BFA6BC5764F08 /* RDEPUBReaderDelegate.swift */, + 339103E5D1A1C62BF7B414EE689069F8 /* RDEPUBReaderHighlightsViewController.swift */, + D1939CE18FB1D0E9342A3963CB1F5C84 /* RDEPUBReaderPersistence.swift */, + CCD4B82D09BE72980015175674B04B22 /* RDEPUBReaderSearchBarView.swift */, + 66637609CA61FBB1E9F137BD7C0D5A2E /* RDEPUBReaderTableOfContentsItem.swift */, + FA4EC97EE53FBDB24B5FB92C7FE4C7C4 /* RDEPUBReaderToolView.swift */, + 447C8160AAD2A2E1FE48C8420778A342 /* RDEPUBReaderToolViewProtocols.swift */, + 0FFD7DFEB52262536F9A2C19B111C229 /* RDEPUBReaderTopToolView.swift */, + 774F11C36F2A16D57002B67C22552875 /* RDEPUBSettingsFlipAutomation.swift */, + C985C2E56D867C385610E439E61BFBFE /* RDEpubURLReaderController.swift */, + E3B3807F1E6648C733C6F915288C1E45 /* RDEPUBViewportTypes.swift */, + EE31789BAB1A8C59EDFC3C728B350E90 /* RDEPUBWebContentView.swift */, + F630BB624DD56B3C0BD9131D8ACEC21D /* RDEPUBWebDecorationOverlayView.swift */, + C0CF886110ECBE6539FAF53C10B1498A /* String+RDEPUB.swift */, + 17B38E01CD7C86BDC366392058A1D719 /* UIColor+RDEPUB.swift */, + A33FA1A570B5BA2C5DCAB078F31DE5BF /* UIColor+RDEPUBHex.swift */, + 6C187420AB739F30652FFBD133782A8B /* Notes */, + 2F5C5558D370957F656D4C77FDD78A55 /* ReaderController */, + 9BCDE073FD44A6E872B7420D21E3EA84 /* Settings */, + 0E2A238FDA759C340D26856ED84A003D /* TextPage */, + ); + name = EPUBUI; + path = Sources/RDEpubReaderView/EPUBUI; + sourceTree = ""; + }; 387832C7C64B3B352215F0FD83B927AC /* Support Files */ = { isa = PBXGroup; children = ( @@ -1310,48 +1376,6 @@ path = "../Target Support Files/DTCoreText"; sourceTree = ""; }; - 3F336D21B18772F72103036E6462A145 /* EPUBCore */ = { - isa = PBXGroup; - children = ( - 8EAF25AD2B9FEE0FE357E3A433E3D92C /* RDEPUBAssetRepository.swift */, - 703ED5D8DDE3AEF39CCBA1F75ED53574 /* RDEPUBFixedLayoutTemplate.swift */, - 4E031BA35A851ED17BB97887FA36FC83 /* RDEPUBJavaScriptBridge.swift */, - 3840A5C7212D01AADA285E04510E8020 /* RDEPUBModels.swift */, - DF8F93382E48EBF445FDA4A10C34EE23 /* RDEPUBNavigatorLayoutContext.swift */, - BFF03BFDA015FD626A53E9FDBC95985A /* RDEPUBPaginator.swift */, - 54626FDB51F5A24F21ED95B65F732EA6 /* RDEPUBParser.swift */, - 668E98AD14CBB11B4F60DC21B9B6EC4C /* RDEPUBParser+Archive.swift */, - 6CDD9C89E7009567107E9C16B4BE05C6 /* RDEPUBParser+Package.swift */, - 3EDE2594DAA2AA3DEE0DC6FBB8021FB4 /* RDEPUBParser+ReadingProfile.swift */, - 104CAC07B59B05601D706C051CFB2EB3 /* RDEPUBParser+Resources.swift */, - 60C982580B17056D1FD0076743AF2A20 /* RDEPUBParser+TOC.swift */, - 9191031A62C049190D2BAAF26FCB4894 /* RDEPUBPreferences.swift */, - 8F16DD7BE31C1407239419FAB208F7E9 /* RDEPUBPublication.swift */, - EFC5E77754171DA25D9A8A76A0985CCF /* RDEPUBReadingSession.swift */, - 49FF2BFED217D10DD3A12046FF81C511 /* RDEPUBRenderRequest.swift */, - 3F64125A1A7278D5D5DBEE29D0A23289 /* RDEPUBResourceDataProvider.swift */, - 44113776B1D8777D7989AA4A28E7AD89 /* RDEPUBResourceResolver.swift */, - 0EA7439F32DE1A2349B94E9F3EA60EFB /* RDEPUBResourceURLSchemeHandler.swift */, - 4B257B0E6084F9E4D8FE7DC39AF9B477 /* RDEPUBSafeArea.swift */, - 8DD41010C7FC84E0AD4545E0B751822C /* RDEPUBSearchEngine.swift */, - 0C6F4C9D48FC61243E5E405457EB1AB3 /* RDEPUBSearchModels.swift */, - BA427D1A69C32689C6C30A3C1CCE31A9 /* RDEPUBStyleSheetBuilder.swift */, - 9AB8D9D0E868BF4DBDE6255D7F86E252 /* RDEPUBTextAnchor.swift */, - 5CD9D3B5B4DC5B0B3B51CC6B8F9AAD86 /* RDEPUBWebView.swift */, - 9EAA04F0E10B06A24072628253625784 /* RDEPUBWebView+Configuration.swift */, - AC3AB2ABB72B4569AE8FF72F31261641 /* RDEPUBWebView+FixedLayout.swift */, - FAA4CD4C6398D41D3E73B895B41A6C2B /* RDEPUBWebView+JavaScriptBridge.swift */, - 8D7CCE90374184A7112FA1618B41674F /* RDEPUBWebView+Reflowable.swift */, - F98C79337772FFEE3E718C3A251E5B9D /* RDEPUBWebView+Search.swift */, - AF774474543DB1EEE0F2F8BCC54382AE /* RDEPUBWebViewDebug.swift */, - 9062C25885FC0EE21CF8F8921F75F063 /* CFI */, - B07D95F4F241310D6092EFC5D7B05BB0 /* Models */, - 5FC978C96AD989841519C7504A1065A3 /* Notes */, - ); - name = EPUBCore; - path = Sources/RDReaderView/EPUBCore; - sourceTree = ""; - }; 426395460B992FC2D2EF86AF65197AD4 /* Pods */ = { isa = PBXGroup; children = ( @@ -1362,17 +1386,6 @@ name = Pods; sourceTree = ""; }; - 49DDC7DDC84496BE70FB8D02D1AE579D /* Compatibility */ = { - isa = PBXGroup; - children = ( - A48622CB59921725556CF92DE20EC3C0 /* RDEPUBCSSCompatibilityLayer.swift */, - A1898AE1C59D386BC2A565897777C7C9 /* RDEPUBFontFallbackResolver.swift */, - 41DE1F810FCEEA86135A8828CE7E0B2E /* RDEPUBStyleCompatibilityModels.swift */, - ); - name = Compatibility; - path = Compatibility; - sourceTree = ""; - }; 4B9763CFE13CC8B0D8AA60EE28156E6D /* Support Files */ = { isa = PBXGroup; children = ( @@ -1389,6 +1402,22 @@ path = "../Target Support Files/ZIPFoundation"; sourceTree = ""; }; + 4BF62D97D37BD92F438C7139535636AB /* Support Files */ = { + isa = PBXGroup; + children = ( + D71B377949D433438F307CC6B120D372 /* RDEpubReaderView.modulemap */, + 3108DBF4D182FE04D56AB715DE874117 /* RDEpubReaderView-dummy.m */, + 4186A080B9749A79809873298A193880 /* RDEpubReaderView-Info.plist */, + 27F9CACA6F2EE61A3431B82175020F04 /* RDEpubReaderView-prefix.pch */, + 8A204FA398A28E3D0424A74DD407BB03 /* RDEpubReaderView-umbrella.h */, + CF8FD338F89FBB4FEDDF24E590E86AFE /* RDEpubReaderView.debug.xcconfig */, + 57F3CF4EBE3598B7B7C01D2C19483B86 /* RDEpubReaderView.release.xcconfig */, + B03885DA855AC3C09FF73925C82186C8 /* ResourceBundle-RDEpubReaderViewAssets-RDEpubReaderView-Info.plist */, + ); + name = "Support Files"; + path = "ReadViewDemo/Pods/Target Support Files/RDEpubReaderView"; + sourceTree = ""; + }; 4DABD1D106D01055A2AB87F3DA58A3CC /* iOS */ = { isa = PBXGroup; children = ( @@ -1411,123 +1440,63 @@ name = DTHTMLParser; sourceTree = ""; }; - 55545D8C5ECD57C236F1C7E4B5CEB6F9 /* ChapterRuntime */ = { + 6C187420AB739F30652FFBD133782A8B /* Notes */ = { isa = PBXGroup; children = ( - 2B0931BA1423E80768E11D26C280D4AF /* RDEPUBBackgroundTrace.swift */, - 28F849C864C21B91AE83565813ECE513 /* RDEPUBBookPageMap.swift */, - A567490FF7183A9FAAD5EEEFCF9E286F /* RDEPUBChapterCacheKey.swift */, - 54AF51CFB19FCEF5B3306518C6B311A0 /* RDEPUBChapterDataCache.swift */, - 07EC70D349899B89F4546FFE4DE46EEC /* RDEPUBChapterLoader.swift */, - 750B16C9E2C4617CC27FCF04222EEAEF /* RDEPUBChapterLocation.swift */, - 0BFA10454111B92A9838334E4C3BDD21 /* RDEPUBChapterOffsetMap.swift */, - 1EAC09034BA70013D5EB674B407F2C63 /* RDEPUBChapterRuntimeStore.swift */, - 335C0A7F69EC3B4D9CB309D814D08733 /* RDEPUBChapterSummaryDiskCache.swift */, - D246168B830810582866BFF9209DBFD3 /* RDEPUBChapterWarmupOrchestrator.swift */, - 8CD96965442A5F874F9CE6864437A3F7 /* RDEPUBPageCountCache.swift */, - A188018454C7E42DF794BA1BA829E6FB /* RDEPUBPageResolver.swift */, - 96B2BF79370F179010B6481C20FA58C1 /* RDEPUBRuntimeChapter.swift */, - F518D64E810A583A82D13ED1FEF8ACF8 /* RDEPUBRuntimePageCount.swift */, - ); - name = ChapterRuntime; - path = ChapterRuntime; - sourceTree = ""; - }; - 5FC978C96AD989841519C7504A1065A3 /* Notes */ = { - isa = PBXGroup; - children = ( - FA9CF4A5EFC9FEEA82D723074F256E82 /* RDEPUBNoteDetector.swift */, - 9AC44A856D45E8522194A048ABEAAF86 /* RDEPUBNoteModels.swift */, - 3850371BB41E7533AF689FAA5ED33F9B /* RDEPUBNoteResolver.swift */, + D5D6E84305CAE9A6583309CA93366E15 /* RDEPUBNotePopupCoordinator.swift */, + CE074C3401A1CAF095691391F2028E61 /* RDEPUBNotePopupViewController.swift */, ); name = Notes; path = Notes; sourceTree = ""; }; - 702EB560B7AA81818A62FC2BC089122F /* TextPage */ = { + 77F501B572D52526D103EA3148D4C343 /* Typesetter */ = { isa = PBXGroup; children = ( - 5B6CD2AB0DCDA662CD642765E8A5BE01 /* RDEPUBChapterDisplayContentCache.swift */, - 8E6187139F503118F5A562E9A48344D4 /* RDEPUBDarkImageAdjuster.swift */, - 1156A086D0660F13701595F2CA185B73 /* RDEPUBPageInteractionController.swift */, - C23F9EE5E57C3681A4372C8884901664 /* RDEPUBPageLayoutSnapshot.swift */, - 975BFAACE86185AB89AAD8FABBD64FD5 /* RDEPUBSelectionLoupeView.swift */, - A18AC71FCC0D1D4F1FF1E6EAB3D229F0 /* RDEPUBSelectionOverlayView.swift */, - D994416DB1DCB53E8D1F4A20D10A3D58 /* RDEPUBTextAnnotationOverlay.swift */, - 0634E78287117CD08963FC5011711674 /* RDEPUBTextContentInteractionCoordinator.swift */, - A92C7A06935B41D87D6CB878FDAD9867 /* RDEPUBTextContentView.swift */, - 713A7931BFA76291ACE4C095423CA7C2 /* RDEPUBTextPageBoundaryValidator.swift */, - 12B21DE048B2D9B7F8A8075D627B518F /* RDEPUBTextPageDecorationView.swift */, - 2CB104ADA375D292371993C1CB6AAB87 /* RDEPUBTextPageRenderView.swift */, - 4A53E7C06029DCDA8A67E02513AB726E /* RDEPUBTextPageVerticalJustifier.swift */, - B8F150BC8AD9B0E615F730AEC4CC9098 /* RDEPUBTextSelectionController.swift */, + B545FD055DB6F5E62A4B3477F7514D97 /* RDEPUBAttachmentNormalizer.swift */, + 6596F63DB6DA0616A8EB8FBA7BEC20F6 /* RDEPUBCFIMarkerInjector.swift */, + E2B413E5361DB33B983E9237109AD610 /* RDEPUBDecryptingImageAttachment.swift */, + 97E58B2D3994B2247E96226094D6EA5F /* RDEPUBFontNormalizer.swift */, + D6C8585AECE0BC205EE95CC484EC6561 /* RDEPUBFragmentMarkerInjector.swift */, + 93D30E909C16D18D790A184AB3DDF67D /* RDEPUBHTMLNormalizer.swift */, + 327CD220C81409236F700C96851DB0E0 /* RDEPUBRenderDiagnosticsCollector.swift */, + 77F0FB3238D157802C13B7A0495C909B /* RDEPUBSemanticMarkerInjector.swift */, + BB2DDE835F718828F22B7C06D9AF02FB /* RDEPUBStyleSheetComposer.swift */, + D260C6E64F57CE89343A271FB4804745 /* RDEPUBTextRendererSupport.swift */, + 85EA0348053B11994DBA92C7B6D7E996 /* RDEPUBTypesettingPipeline.swift */, + BA71FE38DFE3AB5568B6E88280F44F62 /* Compatibility */, ); - name = TextPage; - path = TextPage; + name = Typesetter; + path = Typesetter; sourceTree = ""; }; - 84242A364B178500A166441993CF3C9D /* BuildPipeline */ = { + 808F439A37B8E287DEBC64CB967C582A /* Notes */ = { isa = PBXGroup; children = ( - 74254B4DE3AD0907F43AB96CEED7408A /* RDEPUBBuildDiagnosticsReporter.swift */, - 3124D3BC58A0A9244ACC822A8BF0860E /* RDEPUBChapterTailNormalizer.swift */, - 5874C0AE2C0D64722A34980EB20368B4 /* RDEPUBPaginationCacheCoordinator.swift */, - C6F1C6F5695FAAA4BA13936F58275B89 /* RDEPUBTextBookBuilder.swift */, - DB350DF3FC839EBAC2D622C87CE4C8AD /* RDEPUBTextBookCache.swift */, - C559731DDB39006B286108CFA2DDA230 /* RDEPUBTextBookModels.swift */, - 69D0D9087DAE27DCDC6CB950E20E3608 /* RDEPUBTextBuildPipelineInterfaces.swift */, - B54F008AC6146B84C53BC2ECB8725B94 /* RDEPUBTextPerformanceSampler.swift */, + E2023C1B0BC6999F982585F30454EF30 /* RDEPUBNoteDetector.swift */, + 272CA6E32EEAE8E81504F1CD3000E925 /* RDEPUBNoteModels.swift */, + 0B1FC7245885C5EE717A583C35D1EC08 /* RDEPUBNoteResolver.swift */, + ); + name = Notes; + path = Notes; + sourceTree = ""; + }; + 8815B4F7BEEA43535969D51A0EDD89BF /* BuildPipeline */ = { + isa = PBXGroup; + children = ( + D76AF74F7C0A5657241E97D1CAD4A833 /* RDEPUBBuildDiagnosticsReporter.swift */, + 6F5DA3828400FEDE014E6CEF4A66E4F8 /* RDEPUBChapterTailNormalizer.swift */, + 6A28433723E70648483D360F3B74FA46 /* RDEPUBPaginationCacheCoordinator.swift */, + 07FC18458844B9ADFDC71FB8C1A085DE /* RDEPUBTextBookBuilder.swift */, + 612542D38DEA4A8BBD84517629D3482B /* RDEPUBTextBookCache.swift */, + B99659A42728CF06CA00A18D502850AF /* RDEPUBTextBookModels.swift */, + A98B084E630DFB6A2A5DB15DFB214FD0 /* RDEPUBTextBuildPipelineInterfaces.swift */, + 3723CC3DB0E91BE41710A956910CA87F /* RDEPUBTextPerformanceSampler.swift */, ); name = BuildPipeline; path = BuildPipeline; sourceTree = ""; }; - 88A30917BF0A7F49821B57F828197F51 /* EPUBUI */ = { - isa = PBXGroup; - children = ( - B812E0491FA6CD09EEDF1B3B674C86A3 /* RDEPUBAttachmentTooltipOverlayView.swift */, - 89A5207DD90D0FB2512C9B23FEEB1A02 /* RDEPUBAttachmentTooltipView.swift */, - 87031C18DFBCC980112E5A9F3BDE49C3 /* RDEPUBImageViewController.swift */, - 6054D59EA001FB57A86513538FA8BB2D /* RDEPUBImageViewerCoordinator.swift */, - 680ADAD990534743F965D6B6E96D7E9B /* RDEPUBReaderBottomToolView.swift */, - 413360D0D62A7D3B12EAB6FD542C8B29 /* RDEPUBReaderChapterListController.swift */, - 169DD70FDD7715783E8DD88ADB49E35E /* RDEPUBReaderController.swift */, - 9726056B845AD5027986E5391D52D33D /* RDEPUBReaderController+AttachmentTooltip.swift */, - 785EFB63370FAE06B2485B755B42E2B5 /* RDEPUBReaderController+ContentDelegates.swift */, - 449E7EAE1D47E608A23C4994EA5F9D4C /* RDEPUBReaderController+DataSource.swift */, - A3BC3FFA41C912BB401CBEFA2A9D9964 /* RDEPUBReaderController+ExternalLinks.swift */, - 960055C2836F036B9C45375852678BCE /* RDEPUBReaderController+LocationResolution.swift */, - 87FAC4BEFBDC1FD8E9824724A68800F1 /* RDEPUBReaderController+Orientation.swift */, - D368B75B200BA85AA29995C39AA63371 /* RDEPUBReaderController+PublicAPI.swift */, - 9CD0B4D61E7169153CE0968E2D6A923C /* RDEPUBReaderController+RenderSupport.swift */, - DCADA127F117763F7DC1E99EC687D9F1 /* RDEPUBReaderController+RuntimeBridge.swift */, - 96352099AD90E6B08B5178B06E6CD7CA /* RDEPUBReaderController+TableOfContents.swift */, - 6707BF75590797EB88552A6829CBAF19 /* RDEPUBReaderController+Trial.swift */, - A4EA2355F64BEA505537D228D101D967 /* RDEPUBReaderDelegate.swift */, - 93F324654B96751DA93FFC59A3679ED6 /* RDEPUBReaderHighlightsViewController.swift */, - 54C212FF935AF7337E5CDE1C37D5785A /* RDEPUBReaderPersistence.swift */, - 03381B40A2B1AD4E41BCC3BE2C31B626 /* RDEPUBReaderSearchBarView.swift */, - 3B2E80F85DC4DFBB0320EB582FCBD933 /* RDEPUBReaderTableOfContentsItem.swift */, - DDFAE45005F7BB7D9114003D6F4AA87A /* RDEPUBReaderToolView.swift */, - 3429C49A65BE1911C634E1C95A0579BA /* RDEPUBReaderToolViewProtocols.swift */, - 1FA75FCB2F9051FFC2C7EC7FD801B3B6 /* RDEPUBReaderTopToolView.swift */, - 6C2BB1CB63F11F03F2A9AF85735EA076 /* RDEPUBSettingsFlipAutomation.swift */, - C13A624085502DB7A43EAEF62D179E1E /* RDEPUBViewportTypes.swift */, - 62A03DB083C11C5FDA1D858BC447E3AD /* RDEPUBWebContentView.swift */, - D7540C99450185F3D8B99D75A4C065F4 /* RDEPUBWebDecorationOverlayView.swift */, - 475D625CDF9C41734A2045F2DF2DA3A5 /* RDURLReaderController.swift */, - B700F9E8C03D3F9C537B14755D984564 /* String+RDEPUB.swift */, - D5501A46F5D094488C50D841B21AAE96 /* UIColor+RDEPUB.swift */, - 165E51AE35F6043DF9684A794A48F348 /* UIColor+RDEPUBHex.swift */, - 9F512A0F0A7184A87C378200CF177064 /* Notes */, - A963665562EFFC19E5A34047CDF1BB12 /* ReaderController */, - 1AC1EBE8B740242D8ED52884A9881E9B /* Settings */, - 702EB560B7AA81818A62FC2BC089122F /* TextPage */, - ); - name = EPUBUI; - path = Sources/RDReaderView/EPUBUI; - sourceTree = ""; - }; 8AE80DCE6937729AEA82EC094AC470AE /* Resources */ = { isa = PBXGroup; children = ( @@ -1536,36 +1505,6 @@ name = Resources; sourceTree = ""; }; - 9062C25885FC0EE21CF8F8921F75F063 /* CFI */ = { - isa = PBXGroup; - children = ( - 6D1978442C3B21E292AEF661B6F98568 /* RDEPUBCFI.swift */, - A5E93EDC68FEB87F429AEE9DA66B1F0C /* RDEPUBCFICompatibility.swift */, - ABEB2DB799322EF9048487013D0E7DBC /* RDEPUBCFIDOMPathBuilder.swift */, - AE8EE378CAD4622D735C39CD1B0D4FEB /* RDEPUBCFIError.swift */, - FF8D9CFA61CC68C8A808C2B39CE0C7F8 /* RDEPUBCFIGenerator.swift */, - 18CD683F1DDEBEF3EDA621F9D3E95178 /* RDEPUBCFIMap.swift */, - 0879F59F605F8E1512ADB3D29CB7912E /* RDEPUBCFIParser.swift */, - 35AAB84F53D47E997BE5822C68A820E1 /* RDEPUBCFIPath.swift */, - 65D988AEE2A2A568F2F08472880C56D0 /* RDEPUBCFIRange.swift */, - 34D52809A0CFBA9664082FEDE2EBF420 /* RDEPUBCFIRecoveryEngine.swift */, - F44654531A4D3A5E63F117378BDBA15E /* RDEPUBCFIResolver.swift */, - 00E9FEEAFFD8790F7439A91AD283F5F3 /* RDEPUBCFISerializer.swift */, - C9D122DED12EE5B85539805AB7010B32 /* RDEPUBCFITextAssertion.swift */, - 53BFEB15CE78B6B97488049218D34E0C /* RDEPUBCFIUtilities.swift */, - ); - name = CFI; - path = CFI; - sourceTree = ""; - }; - 913AE0D51BBCBADFBCA8DEFE33FA874C /* Pod */ = { - isa = PBXGroup; - children = ( - D5E4801FDC3D9637688B1ADC282597D0 /* RDReaderView.podspec */, - ); - name = Pod; - sourceTree = ""; - }; 994C39A644C0722C7A9E97AB46DB8965 /* Targets Support Files */ = { isa = PBXGroup; children = ( @@ -1574,6 +1513,18 @@ name = "Targets Support Files"; sourceTree = ""; }; + 9BCDE073FD44A6E872B7420D21E3EA84 /* Settings */ = { + isa = PBXGroup; + children = ( + DFE26DA6A1180E7B617EE341C73F7A50 /* RDEPUBReaderConfiguration.swift */, + 56A856C80CDD6B8D9CFEE9C551D5735F /* RDEPUBReaderSettings.swift */, + 66CF2D711A2AC7E60FFD77AD510F8C2E /* RDEPUBReaderSettingsViewController.swift */, + 677A2034D4184FC4E2836F8A5063AD50 /* RDEPUBReaderTheme.swift */, + ); + name = Settings; + path = Settings; + sourceTree = ""; + }; 9C7F7E7109B9004B64578ADED4DB8E0D /* Core */ = { isa = PBXGroup; children = ( @@ -1621,89 +1572,64 @@ name = Core; sourceTree = ""; }; - 9F512A0F0A7184A87C378200CF177064 /* Notes */ = { + 9CC032A6AD1EFA768C774E703F44FE4F /* EPUBCore */ = { isa = PBXGroup; children = ( - 893B4227196A12B86EF2F32C46FCA05E /* RDEPUBNotePopupCoordinator.swift */, - 18D7EB46EC2C4DD8B46B6FADEFD16BB6 /* RDEPUBNotePopupViewController.swift */, + 1BB49FBD6621286BA60757349FCC6883 /* RDEPUBAssetRepository.swift */, + CD6168EB90D5F2AE16988D0B4BC4C56E /* RDEPUBFixedLayoutTemplate.swift */, + D36009F925C400C4981D456059CC16BA /* RDEPUBJavaScriptBridge.swift */, + 847740E4EA7EBFB8F53710A0CF448BAE /* RDEPUBModels.swift */, + B60AD68DA33BB20A0FE6D0D98EBE3D2B /* RDEPUBNavigatorLayoutContext.swift */, + F88EF2BFEFAC452A2D6082C661041929 /* RDEPUBPaginator.swift */, + A875D59C9ADB7D356F34F31D11DCC720 /* RDEPUBParser.swift */, + 7FE0F0004B3436CA416F69193354B1AD /* RDEPUBParser+Archive.swift */, + 1A18237B2AA906DE9B54F3FBE756BD95 /* RDEPUBParser+Package.swift */, + F72A99A09ECD213552D649EF0E7BD081 /* RDEPUBParser+ReadingProfile.swift */, + 648B55749AEAFAEC440B7259DA5F18AA /* RDEPUBParser+Resources.swift */, + F1E86313FAB2369F42BBAC7FE6CAF40B /* RDEPUBParser+TOC.swift */, + 229118D72FEDE65A83CE684333A3E6C0 /* RDEPUBPreferences.swift */, + 634A51FA1417DC27E687D3FF80AD42CB /* RDEPUBPublication.swift */, + 1DFD71BAF612FE4B521B30E7E24C4E1A /* RDEPUBReadingSession.swift */, + 00C39185B11404EA80AFE34E589E7496 /* RDEPUBRenderRequest.swift */, + 4CA70CE662A3BBAFDEFA4F1AD91006DE /* RDEPUBResourceDataProvider.swift */, + DC0D5B57FDFCE021F4354EA651E68507 /* RDEPUBResourceResolver.swift */, + 117C8D3804CD684A0BCFB28E527FE704 /* RDEPUBResourceURLSchemeHandler.swift */, + 3415CCD5F8080BB63D594BDCFEA0EB86 /* RDEPUBSafeArea.swift */, + E6C77B875320EA1C7172B5E8F8A4460A /* RDEPUBSearchEngine.swift */, + 2B502241DFB110D155ACB255B7597984 /* RDEPUBSearchModels.swift */, + DB7F916B5DAC02317D14747841AF489C /* RDEPUBStyleSheetBuilder.swift */, + 9B2EA51F3AF54545A450E32FACC3B8C5 /* RDEPUBTextAnchor.swift */, + 9868FA712FFD17D0195649510C1A68A6 /* RDEPUBWebView.swift */, + 4CD0CE49F7A82C0DDA84464EA00115A3 /* RDEPUBWebView+Configuration.swift */, + 3E6420F3C34E33BDB146AE066B20ED88 /* RDEPUBWebView+FixedLayout.swift */, + 1633419ACBF4D75136CB54A9595F07AB /* RDEPUBWebView+JavaScriptBridge.swift */, + 3B06436C03160A6968FEE44D6C916F2E /* RDEPUBWebView+Reflowable.swift */, + 19169CBEAD412B0E1C17F517F5B30269 /* RDEPUBWebView+Search.swift */, + E2F02728AA8EB960A35AE0DD7736C90A /* RDEPUBWebViewDebug.swift */, + C3A8956F36E7C6EDB7FB9A8070A885F9 /* CFI */, + 03DC298EABDEB36C8A8F9F3BD445F015 /* Models */, + 808F439A37B8E287DEBC64CB967C582A /* Notes */, ); - name = Notes; - path = Notes; + name = EPUBCore; + path = Sources/RDEpubReaderView/EPUBCore; sourceTree = ""; }; - A3A292F57EFDA9C7CE6BEB56A6109C3B /* Paging */ = { + A07A5363CF915A6E953BE45979E238C2 /* EPUBTextRendering */ = { isa = PBXGroup; children = ( - 9634274EF4914F6FCC9A1546501F2843 /* RDReaderPagingController.swift */, - C69FCCE0841CEE98E2EBE9D173C0622C /* RDReaderPreloadController.swift */, - 7D513DFE7299EE03B0CB5A16A4798152 /* RDReaderSpreadResolver.swift */, - B023EBCF5E4ECC879A57A325F2015CAA /* RDReaderTapRegionHandler.swift */, + 92D5B64BD3D5489A837054C68FC54D65 /* RDEPUBChapterData.swift */, + 157F831C6FDD7EC227517F29E2CB8590 /* RDEPUBDTCoreTextRenderer.swift */, + 5CBCA6E5C76DE2C9362183A2B3763C08 /* RDEpubPlainTextBookBuilder.swift */, + 025D419F9C7DF8D9768FF73AA996BB88 /* RDEPUBTextIndexTable.swift */, + B8EBCB744C294E197C28F3B81349FCF9 /* RDEPUBTextPositionConverter.swift */, + 8612BCF374E10CB4503D16E19A381417 /* RDEPUBTextRenderer.swift */, + DABA2040236556B3E47062333B414AF5 /* RDEPUBTextSearchEngine.swift */, + 8815B4F7BEEA43535969D51A0EDD89BF /* BuildPipeline */, + CE280496DFC1576D3C8D290405C21082 /* Pagination */, + 77F501B572D52526D103EA3148D4C343 /* Typesetter */, ); - name = Paging; - path = Paging; - sourceTree = ""; - }; - A55FE0149CE18136E8CDCA6B7F3D6AD4 /* ReaderView */ = { - isa = PBXGroup; - children = ( - B5CA72BC08E08AF5467ECB9D87611149 /* RDReaderContentCell.swift */, - EFB982B05AC5435F003EC90F8A93EDA0 /* RDReaderFlowLayout.swift */, - 38C859102801709085785434DA17448C /* RDReaderGestureController.swift */, - 2F1CF1FACBA97E3B87ED0ECBBA7271E2 /* RDReaderPageChildViewController.swift */, - 9B503F169F6A42A61C4A4BBE13459E2B /* RDReaderView.swift */, - CE12B04784ACE77BDF6F920F30699A70 /* RDReaderView+CollectionView.swift */, - 7B84F7CB373F124B5ADA362B7A204376 /* RDReaderView+ContentAccess.swift */, - 4D66B85E6DC6F2833A9D9561D6235DCA /* RDReaderView+PageCurl.swift */, - 89A9008EF0A17C27F6EA9F37EF6DCFD0 /* RDReaderView+ToolView.swift */, - DB3D7F9A13B102C659531CCAAB50035F /* RDReaderViewProtocols.swift */, - A3A292F57EFDA9C7CE6BEB56A6109C3B /* Paging */, - ); - name = ReaderView; - path = Sources/RDReaderView/ReaderView; - sourceTree = ""; - }; - A963665562EFFC19E5A34047CDF1BB12 /* ReaderController */ = { - isa = PBXGroup; - children = ( - B60943E11B87C06EB15D70467A9DAF42 /* RDEPUBBackgroundCoverageStore.swift */, - 0ED3B8B105513BA029A8DAC14677F712 /* RDEPUBBackgroundPriorityPolicy.swift */, - 274FAE68017F54F1F939837B96E2826D /* RDEPUBJumpSession.swift */, - 3CEB851D42C524A554A2D26249EB6440 /* RDEPUBMemoryProbe.swift */, - 8B91825A1F365A62C53FC349F17C7038 /* RDEPUBMetadataParseCancellationController.swift */, - 6C49C3D57B022D024E68EDB3EF68D60F /* RDEPUBMetadataParseWorker.swift */, - E9074F378D3B707959729C0DB2C2B8AB /* RDEPUBPageMapReconciliationCoordinator.swift */, - B6F13727884F1901CB2C0D4FFABB1763 /* RDEPUBPresentationRuntime.swift */, - 8BCE11DBF7C58BA594172E1D980C1626 /* RDEPUBReaderAnnotationCoordinator.swift */, - A10956FC20C49EDDBFC8BABCC6EB098A /* RDEPUBReaderAssemblyCoordinator.swift */, - 15C30720702E12C08A23F03FF9F06A46 /* RDEPUBReaderChromeCoordinator.swift */, - 1300C3BDE866D4C29C0792C55713758D /* RDEPUBReaderContext.swift */, - 9265A063637BD77FC50A93888BBAB11E /* RDEPUBReaderDependencies.swift */, - B34DC8A160DF20B18E04242D92D54346 /* RDEPUBReaderEnvironment.swift */, - 4E1C8E681EAA73E10C3BE26F8951E5D6 /* RDEPUBReaderLoadCoordinator.swift */, - CCCAE17086995415A5984D248EA420D6 /* RDEPUBReaderLocationCoordinator.swift */, - A33BBD0D82DD401D9A3FF02B10B320B6 /* RDEPUBReaderPaginationCoordinator.swift */, - D6220F23ADB2582C577A0C44BA52657D /* RDEPUBReaderRuntime.swift */, - 16735EDC0457A01293AE4660C1097B43 /* RDEPUBReaderSearchCoordinator.swift */, - 3B4326BC8EF36B8BB0A32D8E047921AD /* RDEPUBReaderServices.swift */, - 745DE64E801F69497754257504DF420A /* RDEPUBReaderState.swift */, - 2C5BF48946F992DC481016853F217CE2 /* RDEPUBReaderUIState.swift */, - 7E28782DB94C6BAF80766A255BB55167 /* RDEPUBReaderViewportMonitor.swift */, - 4AB1D76489DD745A028573570A736E59 /* RDEPUBSelectionState.swift */, - 55545D8C5ECD57C236F1C7E4B5CEB6F9 /* ChapterRuntime */, - ); - name = ReaderController; - path = ReaderController; - sourceTree = ""; - }; - B07D95F4F241310D6092EFC5D7B05BB0 /* Models */ = { - isa = PBXGroup; - children = ( - 17E520FDB2483CFB8A76E13BAB2BFFD8 /* RDEPUBAnnotationModels.swift */, - C86BF6C6A415B964A3F3B66B76DE9F0E /* RDEPUBPaginationModels.swift */, - 4C2C269173C1D497D6810C8BF62E7338 /* RDEPUBReadingLocationModels.swift */, - ); - name = Models; - path = Models; + name = EPUBTextRendering; + path = Sources/RDEpubReaderView/EPUBTextRendering; sourceTree = ""; }; B45E17EA805660F2389832287623284D /* ZIPFoundation */ = { @@ -1737,43 +1663,61 @@ path = ZIPFoundation; sourceTree = ""; }; - B9CF9F63ECCD4435AA8F5FEDC37E6258 /* Development Pods */ = { + BA71FE38DFE3AB5568B6E88280F44F62 /* Compatibility */ = { isa = PBXGroup; children = ( - 1C5AC06F32CDDE751C9C6BA935BEB1E3 /* RDReaderView */, + 97C6D812897020B77EF9C826643A0418 /* RDEPUBCSSCompatibilityLayer.swift */, + 344F132625D1DB107D6F88F810131154 /* RDEPUBFontFallbackResolver.swift */, + 6D2D23A34A39E1D251E493C14A46A852 /* RDEPUBStyleCompatibilityModels.swift */, ); - name = "Development Pods"; + name = Compatibility; + path = Compatibility; sourceTree = ""; }; - BA377FB14D6835880328094AAF737A9C /* Support Files */ = { + BF9E008141B88625C14F2648FC9D1226 /* RDEpubReaderView */ = { isa = PBXGroup; children = ( - 5F346BE7A196241EDCEEF3795731C9D7 /* RDReaderView.modulemap */, - 085DA39E20C3A758783B115AB62B1E72 /* RDReaderView-dummy.m */, - 83610D7D676D5E06082204EDE1A1431B /* RDReaderView-Info.plist */, - 63DA2C409280DA03F0E29C87EC858664 /* RDReaderView-prefix.pch */, - A3BC0875FF335D55BEBFD59BBC6BF914 /* RDReaderView-umbrella.h */, - C39473D9AF5F23B7886FFC138E4E7644 /* RDReaderView.debug.xcconfig */, - DE0E862DE5230ECE5406E1D3EB3ED840 /* RDReaderView.release.xcconfig */, - 976C896B7BFA4C535F93A1CFF5F63077 /* ResourceBundle-RDReaderViewAssets-RDReaderView-Info.plist */, + B26A41A391468A542EFC7C0F2D1A94AC /* cssInjector.js */, + 4E3C6089F68358EFF3598A4990F5C545 /* epub-bridge.js */, + 8A99D1BD5B4CEEFCCBE92DF58132E614 /* epub-fixed-layout.html */, + 33CD1120A32A59966196C179B158D57C /* rangy-core.js */, + 8D43BF19A7479296AA8324BFCE1BE87C /* rangy-serializer.js */, + 0437B4D43E3BC006FB3D6082F6DF0C43 /* WeReadApi.js */, + C8687094F5EB5A27E0D5A0070B19A4C2 /* wxread-dark.css */, + 635A4B503A7681407E2D344472AAEFF8 /* wxread-default.css */, + 59CDD214151212AC68223A2BCC81D128 /* wxread-replace.css */, + 58BF341C0AB71F05D2FAED75A8A835A2 /* wxread-replace-latin.css */, + 9CC032A6AD1EFA768C774E703F44FE4F /* EPUBCore */, + A07A5363CF915A6E953BE45979E238C2 /* EPUBTextRendering */, + 3217B140E7CD936FAB4FE45BED42A4E4 /* EPUBUI */, + DD551E78FF4CE67A1600E23812E537B3 /* Pod */, + FBD7A5A2D5463D61F8B41FCE33453BBA /* ReaderView */, + 4BF62D97D37BD92F438C7139535636AB /* Support Files */, ); - name = "Support Files"; - path = "ReadViewDemo/Pods/Target Support Files/RDReaderView"; + name = RDEpubReaderView; + path = ../..; sourceTree = ""; }; - BFC039C2D19544126D270E60A9A1BD89 /* Pagination */ = { + C3A8956F36E7C6EDB7FB9A8070A885F9 /* CFI */ = { isa = PBXGroup; children = ( - 22887CE9C9E366BD11055E6B101C9A00 /* RDEPUBChapterPageCounter.swift */, - F34C3AB5B2B7CFAED280AB097BF51C21 /* RDEPUBCoreTextPageFrameFactory.swift */, - 875B4A5662D9C4B043C7C03DABF75E4B /* RDEPUBPageBreakPolicy.swift */, - A9B958811F45DE07F349083133787061 /* RDEPUBTextLayouter.swift */, - 65C7DA34232B39F5D61DAD1C8D64BA55 /* RDEPUBTextLayoutFrame.swift */, - 6F3DBD229B4D724EEC81A05AEF68C96B /* RDEPUBTextPaginationInterfaces.swift */, - 95C55ACB797975D1F898B13F069C609C /* RDEPUBTextPaginationSupport.swift */, + 442D5E8B0118B18E067F09D23CB652DF /* RDEPUBCFI.swift */, + 915069D65E023E6D8140D847BBCAEF3A /* RDEPUBCFICompatibility.swift */, + 86F6704259B87ED54EF047EF455B168E /* RDEPUBCFIDOMPathBuilder.swift */, + 3AF5C2B69E3DDD7DB21E7A3498D81D3B /* RDEPUBCFIError.swift */, + D915BB57012D656EE211B3BC41EBAA93 /* RDEPUBCFIGenerator.swift */, + E39C7BDD4236B494D4C00CBEC40F4371 /* RDEPUBCFIMap.swift */, + FB1327FE6CC30C4AC4A5124BEB747510 /* RDEPUBCFIParser.swift */, + 643A984CBF55EC9D17CB2D82E7E68B9E /* RDEPUBCFIPath.swift */, + 820C93ABAAF16D708F0A6322AC62801F /* RDEPUBCFIRange.swift */, + D46B1E71516E5EC8C9D039B9D836D290 /* RDEPUBCFIRecoveryEngine.swift */, + 1C12125CE2086E4553539302D7B12BB3 /* RDEPUBCFIResolver.swift */, + CB2D03387C98C80186C40EAA590D89BD /* RDEPUBCFISerializer.swift */, + 423B499E8F7CDADBC49F611A0A4EE4CB /* RDEPUBCFITextAssertion.swift */, + 0E7113A77F93EA57B195F8A039935FF3 /* RDEPUBCFIUtilities.swift */, ); - name = Pagination; - path = Pagination; + name = CFI; + path = CFI; sourceTree = ""; }; CB07546106F457AC4196E207FEBAB4ED /* UIKit */ = { @@ -1803,18 +1747,67 @@ name = UIKit; sourceTree = ""; }; + CE280496DFC1576D3C8D290405C21082 /* Pagination */ = { + isa = PBXGroup; + children = ( + 00CDDB6857017E4C7EFF619210E70807 /* RDEPUBChapterPageCounter.swift */, + C4A6313EEF4AEDADB4793D6CAEC32B0A /* RDEPUBCoreTextPageFrameFactory.swift */, + FD12C1ABCF0800609F8579BC62C4997F /* RDEPUBPageBreakPolicy.swift */, + 3D7FE9BC0B7335F2AB850FC2EC727ACD /* RDEPUBTextLayouter.swift */, + AEBCA7121897869990DF9876F2240A4A /* RDEPUBTextLayoutFrame.swift */, + 576B635BD8D0994E47FD4CF7E23FEA79 /* RDEPUBTextPaginationInterfaces.swift */, + 0020851315497D536AE8CB195B6BCA5B /* RDEPUBTextPaginationSupport.swift */, + ); + name = Pagination; + path = Pagination; + sourceTree = ""; + }; CF1408CF629C7361332E53B88F7BD30C = { isa = PBXGroup; children = ( 9D940727FF8FB9C785EB98E56350EF41 /* Podfile */, - B9CF9F63ECCD4435AA8F5FEDC37E6258 /* Development Pods */, + 1CA1B2C980301DD7B83B48382F78ED08 /* Development Pods */, D68CA58901FBF589D75F5E40F1EAF5BA /* Frameworks */, 426395460B992FC2D2EF86AF65197AD4 /* Pods */, - F8A2B0DD84CEFC61D2C703C767D33C43 /* Products */, + F95A6BE3981EB2EEBFE9B209199BFC43 /* Products */, 994C39A644C0722C7A9E97AB46DB8965 /* Targets Support Files */, ); sourceTree = ""; }; + D1C9FB9108DF2F93DED0BF06EA0FD75E /* Paging */ = { + isa = PBXGroup; + children = ( + B7180AF39B62FE6A6B43E7BE45E45C31 /* RDEpubReaderPagingController.swift */, + E293D93795FE3F3AA09E249FBBAF2169 /* RDEpubReaderPreloadController.swift */, + CABD447CD3A40D2D3C8E80590B8361B7 /* RDEpubReaderSpreadResolver.swift */, + 8EA2C6CFA053A4B06F2AAB2EA0D17A4B /* RDEpubReaderTapRegionHandler.swift */, + ); + name = Paging; + path = Paging; + sourceTree = ""; + }; + D49A996EA5DB539C5B945FA3BB68E029 /* ChapterRuntime */ = { + isa = PBXGroup; + children = ( + E9F15AEACE475EF232CB992A4CDCC6E3 /* RDEPUBBackgroundTrace.swift */, + E935E1C0DB2E3250D48D6F7B1F1785EB /* RDEPUBBookPageMap.swift */, + 6DE016AAB61669B1F22DA37005C9CCC7 /* RDEPUBChapterCacheKey.swift */, + 27176E77CA7151E15AC5ED7E22E7AD11 /* RDEPUBChapterDataCache.swift */, + 03B34F9D700619AE17BE5522BBABC08E /* RDEPUBChapterLoader.swift */, + 834CDE067D219C544FC721901E8E70B5 /* RDEPUBChapterLocation.swift */, + F84F78EBE95C9D7F7DC66344EB40BE9F /* RDEPUBChapterOffsetMap.swift */, + 63C303ED9417890ADDAC0BED62294CB3 /* RDEPUBChapterRuntimeStore.swift */, + BF282AB822D775C2628A7A3D042A588D /* RDEPUBChapterSummaryDiskCache.swift */, + 1C746B6D8EF063C2B6C54882895756D6 /* RDEPUBChapterWarmupOrchestrator.swift */, + BC69A9D6FE337C36229B5CE0CA4206FF /* RDEPUBPageCountCache.swift */, + 752B1535C51D57BB88FB239513E0810A /* RDEPUBPageResolver.swift */, + A05983FAA61E2029A29085B98DEDC010 /* RDEPUBRuntimeChapter.swift */, + BBF2E942B6DF40838CD0EA5D5AFA0A49 /* RDEPUBRuntimePageCount.swift */, + ); + name = ChapterRuntime; + path = ChapterRuntime; + sourceTree = ""; + }; D4D1573132E3E8DD3B99E449658562B8 /* Resources */ = { isa = PBXGroup; children = ( @@ -1831,26 +1824,6 @@ name = Frameworks; sourceTree = ""; }; - D6DFD3B4A9DBC501F19BD5F07EBC6341 /* Typesetter */ = { - isa = PBXGroup; - children = ( - 1429432AAB58A7FBEEA14E2716F7D97E /* RDEPUBAttachmentNormalizer.swift */, - 0CE5DDE420852CFC895FEF2572577505 /* RDEPUBCFIMarkerInjector.swift */, - 35F49784F5C7572343EA12BD542EC199 /* RDEPUBDecryptingImageAttachment.swift */, - 42C462DBD1028C2B2C741726F0C4F7D6 /* RDEPUBFontNormalizer.swift */, - 1310063EDD27977AEF26DC0B83BA0811 /* RDEPUBFragmentMarkerInjector.swift */, - 9C8C8F9EC45583D9AC337EE967208872 /* RDEPUBHTMLNormalizer.swift */, - E3011B53A527F51AB7C6ABF0151CC130 /* RDEPUBRenderDiagnosticsCollector.swift */, - C3A9A32F96EBBCFD15780B8A62E38996 /* RDEPUBSemanticMarkerInjector.swift */, - 485EAD6F9C3A2212CF6CE024BA2FA1A6 /* RDEPUBStyleSheetComposer.swift */, - 53A23BC49C365732A2CD9FCC08DEE23C /* RDEPUBTextRendererSupport.swift */, - FD5FFE0C7676BB044875877B91485384 /* RDEPUBTypesettingPipeline.swift */, - 49DDC7DDC84496BE70FB8D02D1AE579D /* Compatibility */, - ); - name = Typesetter; - path = Typesetter; - sourceTree = ""; - }; DA2E3A6B928E59EEA59E8967EC93C73E /* DTFoundation */ = { isa = PBXGroup; children = ( @@ -1864,6 +1837,14 @@ path = DTFoundation; sourceTree = ""; }; + DD551E78FF4CE67A1600E23812E537B3 /* Pod */ = { + isa = PBXGroup; + children = ( + 611D8F816F2E1F33D89B52CA0A04A6F4 /* RDEpubReaderView.podspec */, + ); + name = Pod; + sourceTree = ""; + }; E21916B80D2B8A09E76AB2CFCB1E1232 /* Support Files */ = { isa = PBXGroup; children = ( @@ -1879,24 +1860,51 @@ path = "../Target Support Files/DTFoundation"; sourceTree = ""; }; - F8A2B0DD84CEFC61D2C703C767D33C43 /* Products */ = { + F95A6BE3981EB2EEBFE9B209199BFC43 /* Products */ = { isa = PBXGroup; children = ( EDA6D5D7D0A04878EE78FAAE94F65068 /* DTCoreText */, F4E2B1AC73977F10254628C0B7EBE38F /* DTCoreText-Resources */, D1E89A31867FF1FB44B4B999988F1B82 /* DTFoundation */, 78EA0DF405F8465A86BFD3DDFD2EC295 /* Pods-ReadViewDemo */, - 238893C9CBD95CB9BDF069EB5A303E17 /* RDReaderView */, - 83410CC9CF2ABE63B90A92F2F988BF65 /* RDReaderView-RDReaderViewAssets */, + 01DCD1B42D40839F1C8DBBDB11EF09BA /* RDEpubReaderView */, + C8A6E90AB5E26049710E2C2404C28B9A /* RDEpubReaderView-RDEpubReaderViewAssets */, 48A4A6EDC08C8AC933D0DCD507E9D173 /* ZIPFoundation */, CA4A114775B98CEA4A566C18123B145F /* ZIPFoundation-ZIPFoundation_Privacy */, ); name = Products; sourceTree = ""; }; + FBD7A5A2D5463D61F8B41FCE33453BBA /* ReaderView */ = { + isa = PBXGroup; + children = ( + F4E8A28FA2AC4F578A1098D8285D3FFE /* RDEpubReaderContentCell.swift */, + A858EFE14B7184E73CC8B040C0FBAF9F /* RDEpubReaderFlowLayout.swift */, + A36571C8CAE6B5E67048AF9818B631C6 /* RDEpubReaderGestureController.swift */, + 6106ADA2D16E2AB749BC690E59E199B5 /* RDEpubReaderPageChildViewController.swift */, + AFC57105C58F0D30043B13D949A57D8E /* RDEpubReaderView.swift */, + 448445FD208B513040C97CF27CA48852 /* RDEpubReaderView+CollectionView.swift */, + A8FEC8336CD4C54BF17C89ED0AEFC376 /* RDEpubReaderView+ContentAccess.swift */, + B50734FC0871066B8BF8848B60EA53CF /* RDEpubReaderView+PageCurl.swift */, + FE76CB44588C6BA77E5E95CFF23BD68C /* RDEpubReaderView+ToolView.swift */, + 986039F986495905BA1EB9D9F426B782 /* RDEpubReaderViewProtocols.swift */, + D1C9FB9108DF2F93DED0BF06EA0FD75E /* Paging */, + ); + name = ReaderView; + path = Sources/RDEpubReaderView/ReaderView; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ + 070795592978432D8994FB58DEBF3E56 /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + 7787B4AA97C24C63E120606C94DA300E /* Pods-ReadViewDemo-umbrella.h in Headers */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 29CB77F5EC62298B8E19FEFFD791AEAD /* Headers */ = { isa = PBXHeadersBuildPhase; buildActionMask = 2147483647; @@ -1970,27 +1978,11 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 33B6840BE83FE6C6AE3C935E73FAB404 /* Headers */ = { + 36DB6228E6B596067D284904E68C9CD1 /* Headers */ = { isa = PBXHeadersBuildPhase; buildActionMask = 2147483647; files = ( - FFEB74D61E54DC46EFE0DBE2E76EFAA1 /* Pods-ReadViewDemo-umbrella.h in Headers */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 3BFDEF898E3D10A958893D2D0B92B00B /* Headers */ = { - isa = PBXHeadersBuildPhase; - buildActionMask = 2147483647; - files = ( - 592ED452831CE7352E921389136EA40C /* ZIPFoundation-umbrella.h in Headers */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 4847A210BF9226B4A6E136CA9791D750 /* Headers */ = { - isa = PBXHeadersBuildPhase; - buildActionMask = 2147483647; - files = ( - 30398DEF7CAD19171A536D29ABFE4C66 /* RDReaderView-umbrella.h in Headers */, + 26D029D2A69EB67AF0947292B6BB7FD8 /* ZIPFoundation-umbrella.h in Headers */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2035,6 +2027,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + AA1F32B6F2921FB1A5BC3897DE76C490 /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + 45E0CD0CF8B9FA30F5B906B1B1374154 /* RDEpubReaderView-umbrella.h in Headers */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXHeadersBuildPhase section */ /* Begin PBXNativeTarget section */ @@ -2057,20 +2057,20 @@ }; 87E2B8A29E8A66B9205D8A41F66E4927 /* Pods-ReadViewDemo */ = { isa = PBXNativeTarget; - buildConfigurationList = 969FEC0B2FA67E4DE3E10F5E98CF39C6 /* Build configuration list for PBXNativeTarget "Pods-ReadViewDemo" */; + buildConfigurationList = D01F817655AEC389E227C183D399F270 /* Build configuration list for PBXNativeTarget "Pods-ReadViewDemo" */; buildPhases = ( - 33B6840BE83FE6C6AE3C935E73FAB404 /* Headers */, - AF09D5A5B57E781D29EB3CD22A1DE186 /* Sources */, - 241C62665F0673AB5044556DB7A47F1F /* Frameworks */, - ABB244EF18715E7F2232890224DD8993 /* Resources */, + 070795592978432D8994FB58DEBF3E56 /* Headers */, + 8F0597C224D28EFC47DAB52A1C4B18D9 /* Sources */, + 902FF6DD4EB5A15E1C04D090758D9DA8 /* Frameworks */, + A9B06F335596ADC7618AA0C00109C537 /* Resources */, ); buildRules = ( ); dependencies = ( - DFA128FE3AA2D6360EBE3901CA609FEC /* PBXTargetDependency */, - C36FE402BBAD13F1322853208B3F26EE /* PBXTargetDependency */, - 1A81973F692B2B1BA74FB6E5A9B7C526 /* PBXTargetDependency */, - 86F72F6F95E48B50E90565CCC2617B60 /* PBXTargetDependency */, + 1551DF4D30E2A88F1C572E3102D161E0 /* PBXTargetDependency */, + F65A4523B74023B13D4117D61E694844 /* PBXTargetDependency */, + 455E64EEF1F8ABA5151E7A74C7F65E30 /* PBXTargetDependency */, + 669D8327694A81D8D157F39CDB3E1539 /* PBXTargetDependency */, ); name = "Pods-ReadViewDemo"; productName = Pods_ReadViewDemo; @@ -2097,61 +2097,23 @@ }; AA15C8469D67684160CC2A7098EB841C /* ZIPFoundation */ = { isa = PBXNativeTarget; - buildConfigurationList = 2CC157438E43F8FADB5A73A6D96D87CD /* Build configuration list for PBXNativeTarget "ZIPFoundation" */; + buildConfigurationList = F34DEB3C2C0CDC5F97BBEAE4D6F7D219 /* Build configuration list for PBXNativeTarget "ZIPFoundation" */; buildPhases = ( - 3BFDEF898E3D10A958893D2D0B92B00B /* Headers */, - B93FAED387C4F9F71AB33C5E2AAE246D /* Sources */, - 2E80BB883280157F55CC1A24FD8FDD42 /* Frameworks */, - 7A567339AAB92574CD921640506F8738 /* Resources */, + 36DB6228E6B596067D284904E68C9CD1 /* Headers */, + 9C3B5FCA1D5038170D3D6E7DEC230802 /* Sources */, + 67452C77F8E886622577C996F3462FE6 /* Frameworks */, + 7E9F0004695B48153DE4788E6CB86C68 /* Resources */, ); buildRules = ( ); dependencies = ( - C91E1D59F6BC9C8277B2CED2D15F0AFA /* PBXTargetDependency */, + 00C6361C8719FBA4E395DD7972756069 /* PBXTargetDependency */, ); name = ZIPFoundation; productName = ZIPFoundation; productReference = 48A4A6EDC08C8AC933D0DCD507E9D173 /* ZIPFoundation */; productType = "com.apple.product-type.framework"; }; - AA2E57587AA8EECA63C4BE08EA3CB6D2 /* RDReaderView */ = { - isa = PBXNativeTarget; - buildConfigurationList = 88A749A76163B4114B76F1489777EFB2 /* Build configuration list for PBXNativeTarget "RDReaderView" */; - buildPhases = ( - 4847A210BF9226B4A6E136CA9791D750 /* Headers */, - 0743027E3F2D9A52681FC4071768E436 /* Sources */, - 002B78A4F01BC47F7C7339F0DD6A71BD /* Frameworks */, - 7FE68E2127B13073088B2BAC0CB9A30E /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - 8FAA51D0E55C0CC6C11C56E8B5117428 /* PBXTargetDependency */, - 7BA66A0536D199195F48545FC6C74888 /* PBXTargetDependency */, - 0DC04BA41A0DC1E304A94063EAEF8EB1 /* PBXTargetDependency */, - ); - name = RDReaderView; - productName = RDReaderView; - productReference = 238893C9CBD95CB9BDF069EB5A303E17 /* RDReaderView */; - productType = "com.apple.product-type.framework"; - }; - AE7F393FB7805DE2664AB4111873F907 /* RDReaderView-RDReaderViewAssets */ = { - isa = PBXNativeTarget; - buildConfigurationList = 35127788D4B8EA3B2A33A086A96F6692 /* Build configuration list for PBXNativeTarget "RDReaderView-RDReaderViewAssets" */; - buildPhases = ( - 364142EF580BEBCFFA43C8B98013B6F8 /* Sources */, - 0E04762C7E5292C5A68246FB22B73AA6 /* Frameworks */, - 9D6D7F76DEC23545B41BBC74877C00F5 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = "RDReaderView-RDReaderViewAssets"; - productName = RDReaderViewAssets; - productReference = 83410CC9CF2ABE63B90A92F2F988BF65 /* RDReaderView-RDReaderViewAssets */; - productType = "com.apple.product-type.bundle"; - }; B88F4EA0695B6B3165C64594850D72C7 /* DTCoreText */ = { isa = PBXNativeTarget; buildConfigurationList = 0C6B1944226D4396EB72162F792F7CD2 /* Build configuration list for PBXNativeTarget "DTCoreText" */; @@ -2174,11 +2136,11 @@ }; C7A8D82E407CD3FDC3BA55CEE519B252 /* ZIPFoundation-ZIPFoundation_Privacy */ = { isa = PBXNativeTarget; - buildConfigurationList = CBA94F1763131C44221ECC4537E2EC32 /* Build configuration list for PBXNativeTarget "ZIPFoundation-ZIPFoundation_Privacy" */; + buildConfigurationList = A4FC6BA1B119BFD8297B387109E266D3 /* Build configuration list for PBXNativeTarget "ZIPFoundation-ZIPFoundation_Privacy" */; buildPhases = ( - 41A31B1132D292A790ACC09C7248B14D /* Sources */, - B39787E0F2B7900159C968B18DC80D2D /* Frameworks */, - 1B6FDB2DB6F38C14405348521A129398 /* Resources */, + E66DF131B1BAFD7B618B44E7892A790A /* Sources */, + 019CACBDE98B6E7CD6E2323C2BE18D6F /* Frameworks */, + 744051303ADB9EE20F09231D50B520AA /* Resources */, ); buildRules = ( ); @@ -2189,6 +2151,44 @@ productReference = CA4A114775B98CEA4A566C18123B145F /* ZIPFoundation-ZIPFoundation_Privacy */; productType = "com.apple.product-type.bundle"; }; + E07DDD766DCBF90486BCE6C2EA671E5E /* RDEpubReaderView-RDEpubReaderViewAssets */ = { + isa = PBXNativeTarget; + buildConfigurationList = 4E41342AEB9C410C736C34DCA7B0F78F /* Build configuration list for PBXNativeTarget "RDEpubReaderView-RDEpubReaderViewAssets" */; + buildPhases = ( + 13DCE5F3F9EEA363E6338EA762B8C539 /* Sources */, + 1DC6173FB956454B07EFE7D70CFAA752 /* Frameworks */, + DFE3E3891764184F381F081235D5AC22 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = "RDEpubReaderView-RDEpubReaderViewAssets"; + productName = RDEpubReaderViewAssets; + productReference = C8A6E90AB5E26049710E2C2404C28B9A /* RDEpubReaderView-RDEpubReaderViewAssets */; + productType = "com.apple.product-type.bundle"; + }; + FBCB57A3AEAAA3449B3F365930BC0977 /* RDEpubReaderView */ = { + isa = PBXNativeTarget; + buildConfigurationList = B302A2B9135E6E0BE1A48FEB8624A91C /* Build configuration list for PBXNativeTarget "RDEpubReaderView" */; + buildPhases = ( + AA1F32B6F2921FB1A5BC3897DE76C490 /* Headers */, + D29E47752E90C593DF598BE626679F85 /* Sources */, + AA17F25A105235455D6DBA0379F37BC1 /* Frameworks */, + 2733D37A31C74211F54BFDE63AC6DBCB /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + E73110092DD196B4E565269EBF56DC68 /* PBXTargetDependency */, + 0B12ED877A504A3EDF328B8B5AB55A37 /* PBXTargetDependency */, + 982CD0D79765BF90F3C8143F8254BA5A /* PBXTargetDependency */, + ); + name = RDEpubReaderView; + productName = RDEpubReaderView; + productReference = 01DCD1B42D40839F1C8DBBDB11EF09BA /* RDEpubReaderView */; + productType = "com.apple.product-type.framework"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -2209,7 +2209,7 @@ mainGroup = CF1408CF629C7361332E53B88F7BD30C; minimizedProjectReferenceProxies = 0; preferredProjectObjectVersion = 77; - productRefGroup = F8A2B0DD84CEFC61D2C703C767D33C43 /* Products */; + productRefGroup = F95A6BE3981EB2EEBFE9B209199BFC43 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( @@ -2217,8 +2217,8 @@ 0C24CB0E87A728A11AA1124CB360D6A1 /* DTCoreText-Resources */, 8F6E5A5BF72D62CDFD25F91A7CFA3309 /* DTFoundation */, 87E2B8A29E8A66B9205D8A41F66E4927 /* Pods-ReadViewDemo */, - AA2E57587AA8EECA63C4BE08EA3CB6D2 /* RDReaderView */, - AE7F393FB7805DE2664AB4111873F907 /* RDReaderView-RDReaderViewAssets */, + FBCB57A3AEAAA3449B3F365930BC0977 /* RDEpubReaderView */, + E07DDD766DCBF90486BCE6C2EA671E5E /* RDEpubReaderView-RDEpubReaderViewAssets */, AA15C8469D67684160CC2A7098EB841C /* ZIPFoundation */, C7A8D82E407CD3FDC3BA55CEE519B252 /* ZIPFoundation-ZIPFoundation_Privacy */, ); @@ -2226,14 +2226,6 @@ /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ - 1B6FDB2DB6F38C14405348521A129398 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - E02468E0045376BD3A3A0E804151674F /* PrivacyInfo.xcprivacy in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; 229D057473C710AD9E925A4DF80E5C56 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -2242,6 +2234,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 2733D37A31C74211F54BFDE63AC6DBCB /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 3FA774DD0C65367606773764044C2868 /* RDEpubReaderView-RDEpubReaderViewAssets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 378BF991D3B318AACBF6D78F57218233 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -2250,19 +2250,19 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 7A567339AAB92574CD921640506F8738 /* Resources */ = { + 744051303ADB9EE20F09231D50B520AA /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 714A34F8A6CF051D194F7EB767F30ED7 /* ZIPFoundation-ZIPFoundation_Privacy in Resources */, + 058D3A9B6ACC980BAAFD1BA24CCA2AE7 /* PrivacyInfo.xcprivacy in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; - 7FE68E2127B13073088B2BAC0CB9A30E /* Resources */ = { + 7E9F0004695B48153DE4788E6CB86C68 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 3A79D34DFEF27437F551C74BF83B44CE /* RDReaderView-RDReaderViewAssets in Resources */, + BB04C385085B062AD326250F566827A3 /* ZIPFoundation-ZIPFoundation_Privacy in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2273,282 +2273,74 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 9D6D7F76DEC23545B41BBC74877C00F5 /* Resources */ = { + A9B06F335596ADC7618AA0C00109C537 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 51A9091769AF0695E474F919A031FC2F /* cssInjector.js in Resources */, - 1339E60ACC3CAA55D2A14496CA2C7F24 /* epub-bridge.js in Resources */, - 6EC7A64049EF9D2841493E494652993D /* epub-fixed-layout.html in Resources */, - F1EAB3BF7B71E2FE5F88E8544F95E69A /* rangy-core.js in Resources */, - D69EB259E24B2E1077A03FE945FCB5FC /* rangy-serializer.js in Resources */, - AE90B64916BAAB2132F880C33316EFCB /* WeReadApi.js in Resources */, - EDA8BF97142B946BABB6CB8FFF83AD77 /* wxread-dark.css in Resources */, - 8A99D64B1FFAA4754D40D044A9996A81 /* wxread-default.css in Resources */, - 09BFA25E757EB9FAED6BE9D99EB1CEBD /* wxread-replace.css in Resources */, - 5B649FCED26B4794B5AAAF837A3E9DBC /* wxread-replace-latin.css in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; - ABB244EF18715E7F2232890224DD8993 /* Resources */ = { + DFE3E3891764184F381F081235D5AC22 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 542013DD6C510E7A820D2CB071B1219E /* cssInjector.js in Resources */, + 4D58FC9031CA9144B783CCE85047089A /* epub-bridge.js in Resources */, + 150212A6C0D866FB32891A7C787D2AD9 /* epub-fixed-layout.html in Resources */, + 4A5B6DC44DD0C372C4D760844E7468AF /* rangy-core.js in Resources */, + 76EC3A1185B131D3A341FBB84A7AB936 /* rangy-serializer.js in Resources */, + CBE0B92DBDAB8DF59168B3BDAEF223E0 /* WeReadApi.js in Resources */, + D20A9C2E855617ADAE9ACCDC4BA2147A /* wxread-dark.css in Resources */, + DE1DF6C20E410592A6F6A1262EA189FD /* wxread-default.css in Resources */, + 00ED46EE485D06CEF016D5A721540F25 /* wxread-replace.css in Resources */, + 1D9D77BE1E9BF208AFEF407E669B2BDD /* wxread-replace-latin.css in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ - 0743027E3F2D9A52681FC4071768E436 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - EE6F2222A52155B6C4295C6E85E6E43F /* RDEPUBAnnotationModels.swift in Sources */, - 4EA1FC83A98FEBFB463619BD56F7723D /* RDEPUBAssetRepository.swift in Sources */, - 8BA9A995A14E1CBFB04D036C6722B01B /* RDEPUBAttachmentNormalizer.swift in Sources */, - 30A2DE91620758A740D295AB29580245 /* RDEPUBAttachmentTooltipOverlayView.swift in Sources */, - 8C18DD5EEE9BE69DA1780604BFA096AD /* RDEPUBAttachmentTooltipView.swift in Sources */, - 14DEAFC2EAB11EDC3546281C050C226E /* RDEPUBBackgroundCoverageStore.swift in Sources */, - B059193A5A552F53D38C1B38E65AADAC /* RDEPUBBackgroundPriorityPolicy.swift in Sources */, - 096DE56B035B002868AAE20C1D404B15 /* RDEPUBBackgroundTrace.swift in Sources */, - 5EFC094F491AADC10F5093D9836796AD /* RDEPUBBookPageMap.swift in Sources */, - 235AA99103BBEA2892393E721374924D /* RDEPUBBuildDiagnosticsReporter.swift in Sources */, - DAD96B682717D81AD3D3D22BD899A7EA /* RDEPUBCFI.swift in Sources */, - 314AF8C74F2F44FC844A3D405A93F87B /* RDEPUBCFICompatibility.swift in Sources */, - 1F5436D85D7121B1B9383A1EE70EE9B6 /* RDEPUBCFIDOMPathBuilder.swift in Sources */, - 5705832CA2DB13EDF020F92EA8DB8AAF /* RDEPUBCFIError.swift in Sources */, - 4BEE03FBD65DFE217B496877CC2E7726 /* RDEPUBCFIGenerator.swift in Sources */, - AA3118D4EEB37AEE0A1F82C042A21092 /* RDEPUBCFIMap.swift in Sources */, - B511D4D936A2B51C2E64C456B160E661 /* RDEPUBCFIMarkerInjector.swift in Sources */, - 565478E359CF62028188FB8E8EEA2141 /* RDEPUBCFIParser.swift in Sources */, - 1E8126EDD960AD9D2F7A7C3FC5073749 /* RDEPUBCFIPath.swift in Sources */, - 218A26D957029BC85CE43998835273D0 /* RDEPUBCFIRange.swift in Sources */, - 0CD4054BDD450C9D1D74E4E1DACD5461 /* RDEPUBCFIRecoveryEngine.swift in Sources */, - 10C5CE4C315EC9E77373B48B383E3372 /* RDEPUBCFIResolver.swift in Sources */, - 2C6EC9E937F7C828263E9B23CCE6F46D /* RDEPUBCFISerializer.swift in Sources */, - 98D38E0AAD5F1C91FF9EF1757BDDBF1F /* RDEPUBCFITextAssertion.swift in Sources */, - E35D15D74F94257E29D10826BFB666FC /* RDEPUBCFIUtilities.swift in Sources */, - 32C88ACF7CE5CD2C7C808D6192F449C9 /* RDEPUBChapterCacheKey.swift in Sources */, - B6835AAD31E05403BE3372062CDB785F /* RDEPUBChapterData.swift in Sources */, - 972B52AF13D8A6B21127218321F0FD01 /* RDEPUBChapterDataCache.swift in Sources */, - 7EA5C0BFEA6EFF3AFBA0543F64526A3A /* RDEPUBChapterDisplayContentCache.swift in Sources */, - 804E70CA6C95A89DD7997DDD48370800 /* RDEPUBChapterLoader.swift in Sources */, - A8F69E3E478FFE8D6AC8DAD05787660C /* RDEPUBChapterLocation.swift in Sources */, - C049CDA9C12E413E2B63AD404564CD9E /* RDEPUBChapterOffsetMap.swift in Sources */, - 8F2886944A775846FDCC6C64D78E6414 /* RDEPUBChapterPageCounter.swift in Sources */, - 2500FB14F7728CA537DDBA9CCC7E242D /* RDEPUBChapterRuntimeStore.swift in Sources */, - 2C4FC496451BE7EB9804103EF5B5922F /* RDEPUBChapterSummaryDiskCache.swift in Sources */, - 8820CABB87F28D4AA4F13165DEA40BF2 /* RDEPUBChapterTailNormalizer.swift in Sources */, - 63C12C69A76CBC3CABBE2399D103B537 /* RDEPUBChapterWarmupOrchestrator.swift in Sources */, - 7B09D184EAF3D5886E4B91AE9966FB48 /* RDEPUBCoreTextPageFrameFactory.swift in Sources */, - F282B0A9FAE1B412D35B15D948B35386 /* RDEPUBCSSCompatibilityLayer.swift in Sources */, - FBB3C772E5087E1951F211ED447FD8C2 /* RDEPUBDarkImageAdjuster.swift in Sources */, - 9EB0CAE0E721CF799B22772CD72DC159 /* RDEPUBDecryptingImageAttachment.swift in Sources */, - F49D68621947F34F8F87BAC589B249E6 /* RDEPUBDTCoreTextRenderer.swift in Sources */, - 0B7F8836E65428B8F1D3D72C40D25382 /* RDEPUBFixedLayoutTemplate.swift in Sources */, - 04C2B9E1EA2066F182077D33A4A9F64C /* RDEPUBFontFallbackResolver.swift in Sources */, - D2EFBE6B2968BD4AA79349D2D1558280 /* RDEPUBFontNormalizer.swift in Sources */, - EDEFE5CF02BBC892F19412E18C611C41 /* RDEPUBFragmentMarkerInjector.swift in Sources */, - F881F0F632ED90609AF3055471A3E5B2 /* RDEPUBHTMLNormalizer.swift in Sources */, - 237DE485DDB0995A62BF94834229A08F /* RDEPUBImageViewController.swift in Sources */, - 33D110E213D1F9F69A3EA84F20BADB11 /* RDEPUBImageViewerCoordinator.swift in Sources */, - 544667F79E06E6ED8FE3CE0312D9A176 /* RDEPUBJavaScriptBridge.swift in Sources */, - 55F126D4401C2424C486390F1F5F52EB /* RDEPUBJumpSession.swift in Sources */, - 11E5CACB61560ACC063C64C9801CB351 /* RDEPUBMemoryProbe.swift in Sources */, - 0CAF515592F47FAB5A21B4D9E44A577A /* RDEPUBMetadataParseCancellationController.swift in Sources */, - 7C39856468D7910473771D4D1BAC42B0 /* RDEPUBMetadataParseWorker.swift in Sources */, - 2DB041FA1AAE39A99CD1F0E74124671E /* RDEPUBModels.swift in Sources */, - 554BFC9D469A47AED35DD43458B1F6B6 /* RDEPUBNavigatorLayoutContext.swift in Sources */, - 19B2498971C455730EC1B664350A780A /* RDEPUBNoteDetector.swift in Sources */, - D862245B5224688D82612E8C376A8761 /* RDEPUBNoteModels.swift in Sources */, - D796C3C8958054CF0A67EC2BA3E87206 /* RDEPUBNotePopupCoordinator.swift in Sources */, - B47F908F2572E4D7FB9B8B02CF406A75 /* RDEPUBNotePopupViewController.swift in Sources */, - A4B4E6D986FC4F3E5A37A2667E0EFF66 /* RDEPUBNoteResolver.swift in Sources */, - 142A627BB777C6927910C13A5AE04C22 /* RDEPUBPageBreakPolicy.swift in Sources */, - 7A5AC3C3A3C1BF6B0DA2090F55970D2D /* RDEPUBPageCountCache.swift in Sources */, - F7AA4D5FCE8E17C48A14FB08E61781C2 /* RDEPUBPageInteractionController.swift in Sources */, - C26DC956E5E05FE85127A36EAE538876 /* RDEPUBPageLayoutSnapshot.swift in Sources */, - 35CC934AA9BDDA78F314C5F7B6BC6992 /* RDEPUBPageMapReconciliationCoordinator.swift in Sources */, - A05B5109498BFFDAE88FDF254CA1BC5B /* RDEPUBPageResolver.swift in Sources */, - 3A84B52F3FC5DA28DB261B56F234DEC0 /* RDEPUBPaginationCacheCoordinator.swift in Sources */, - 5B8F73FF4F5FAEFD4DAD9A83F974DF47 /* RDEPUBPaginationModels.swift in Sources */, - 3BCE613C1A99CE80B7CBCF682022E6CC /* RDEPUBPaginator.swift in Sources */, - 56D733115E4EFD396C2A011ECA018C49 /* RDEPUBParser.swift in Sources */, - 49837D2F0809E91BC1D12475C7B10621 /* RDEPUBParser+Archive.swift in Sources */, - 07E74EEFF36512B9F4A805C13FBCD65C /* RDEPUBParser+Package.swift in Sources */, - 2B697D2B7F73C6C0C963C58B53C8A2B2 /* RDEPUBParser+ReadingProfile.swift in Sources */, - 94C9A09C6EF77DEDD1BD7E9DB8858F40 /* RDEPUBParser+Resources.swift in Sources */, - 2DDC7535CA3807D6711EE0FFAB21B6EA /* RDEPUBParser+TOC.swift in Sources */, - ECEA44771F14DE0FC56B152EE4B3DB79 /* RDEPUBPreferences.swift in Sources */, - 98C2A1EEBEFCC4EBD5C75BF45C4E76A5 /* RDEPUBPresentationRuntime.swift in Sources */, - ED77BE7CC7893313B521DBD76A6046F6 /* RDEPUBPublication.swift in Sources */, - 0772C0CB588AA1183A682D00DEA0BDEF /* RDEPUBReaderAnnotationCoordinator.swift in Sources */, - 73BA62E3AE9D75624A414EA841AD187C /* RDEPUBReaderAssemblyCoordinator.swift in Sources */, - AD1C9A89324683F04573E4DE479DCDE5 /* RDEPUBReaderBottomToolView.swift in Sources */, - 14CDD234BFB11FFA243EAB16C1BC620F /* RDEPUBReaderChapterListController.swift in Sources */, - F978F917AEE877B3955CC71F69AEDA3F /* RDEPUBReaderChromeCoordinator.swift in Sources */, - 76F2A74233A552DDC5AEDFC4C1D291F9 /* RDEPUBReaderConfiguration.swift in Sources */, - 2DC6A079155266B323EC5D93D7E3C1A1 /* RDEPUBReaderContext.swift in Sources */, - 952F81C78437BA95611EBDD505BFF38B /* RDEPUBReaderController.swift in Sources */, - 0A7395089A78DF9BE4CDA924B947A584 /* RDEPUBReaderController+AttachmentTooltip.swift in Sources */, - 05050DE075D5D9ACE72CE730BCEC53E7 /* RDEPUBReaderController+ContentDelegates.swift in Sources */, - 58AAD72FB743C681166B895694907776 /* RDEPUBReaderController+DataSource.swift in Sources */, - 92D1AACEA741834540D7E848D1ECBCB0 /* RDEPUBReaderController+ExternalLinks.swift in Sources */, - 9DAAA92B75D5A98D770C8379D93C31CD /* RDEPUBReaderController+LocationResolution.swift in Sources */, - 733DA50F18319A91EDBECA4E02C0F7A2 /* RDEPUBReaderController+Orientation.swift in Sources */, - 4D24904AD441B9BDD1194349EF8EFE6B /* RDEPUBReaderController+PublicAPI.swift in Sources */, - 9C1D23CBBBF2403B55EF491818672155 /* RDEPUBReaderController+RenderSupport.swift in Sources */, - 7B3A17CC46FA908545BBB3CA206702FF /* RDEPUBReaderController+RuntimeBridge.swift in Sources */, - 2492C7282FC390E00AD92625292B948B /* RDEPUBReaderController+TableOfContents.swift in Sources */, - B52D7D4008111E537A88A33E349A337C /* RDEPUBReaderController+Trial.swift in Sources */, - FEBA341DB483809E5E6867783DDA6A11 /* RDEPUBReaderDelegate.swift in Sources */, - 69E7F1738637501925C258F4134F96A4 /* RDEPUBReaderDependencies.swift in Sources */, - F4DA7A510251FD67DE5B9A05D542FA21 /* RDEPUBReaderEnvironment.swift in Sources */, - C7CD4D8F66564DF81E180FFDB30F572B /* RDEPUBReaderHighlightsViewController.swift in Sources */, - A8A98F9B70972C0AC1C7FA4C0F42D8CE /* RDEPUBReaderLoadCoordinator.swift in Sources */, - A4DB0F60CE6C88CA903892606BB2D666 /* RDEPUBReaderLocationCoordinator.swift in Sources */, - F5DD147A6DA2FB3DE8E6EC4770E0F03F /* RDEPUBReaderPaginationCoordinator.swift in Sources */, - C28649DD763E6E5EFC7D60F47B865966 /* RDEPUBReaderPersistence.swift in Sources */, - 6F3F15A3FBEF30BE7E78EA76BB1F8927 /* RDEPUBReaderRuntime.swift in Sources */, - 2B2FE2C2FA428E1532ACA1C76FB88DA1 /* RDEPUBReaderSearchBarView.swift in Sources */, - 2317574A8F30CCDC88503F5F3A6BAC20 /* RDEPUBReaderSearchCoordinator.swift in Sources */, - 59E0947D80CB9A9384CF0CF0473828BF /* RDEPUBReaderServices.swift in Sources */, - E944A14E0C8C3352C2DF3AF4586F0B7B /* RDEPUBReaderSettings.swift in Sources */, - 0E0447CF04F1414B216B6F18B5AA2290 /* RDEPUBReaderSettingsViewController.swift in Sources */, - 5403D6294D176296755971502638A17F /* RDEPUBReaderState.swift in Sources */, - 5D684BD54AB74B7C825E7BCB4834B3BA /* RDEPUBReaderTableOfContentsItem.swift in Sources */, - 899844C021BD16F2361E471087949E10 /* RDEPUBReaderTheme.swift in Sources */, - 686B52ECD457E564858A3DEAA7829290 /* RDEPUBReaderToolView.swift in Sources */, - E014AE1BA45C5E083D1FFB337ECB3222 /* RDEPUBReaderToolViewProtocols.swift in Sources */, - 520E924195F1CDF43C04873E7D2BCC3A /* RDEPUBReaderTopToolView.swift in Sources */, - 98AEEF2F4FFB0DB64232732D150F2D69 /* RDEPUBReaderUIState.swift in Sources */, - 4251130F5ADB92686A2D713F37292FEA /* RDEPUBReaderViewportMonitor.swift in Sources */, - 989CA2B28ED197B0B797FE317BFAC14B /* RDEPUBReadingLocationModels.swift in Sources */, - D60AA4D9F0BBEF49EE1473A2F0A1CFB5 /* RDEPUBReadingSession.swift in Sources */, - E7581E6A941BC4B761D6FB321E311B4E /* RDEPUBRenderDiagnosticsCollector.swift in Sources */, - 6EA06E7FDDBB0E120C746B85A3F75151 /* RDEPUBRenderRequest.swift in Sources */, - 41873DE0BEAA19DCBA7C7375228752D8 /* RDEPUBResourceDataProvider.swift in Sources */, - 3797E49E7BA8E8431B3366064E190D1F /* RDEPUBResourceResolver.swift in Sources */, - CBF966E59BA90842D96F8AC0F63506D5 /* RDEPUBResourceURLSchemeHandler.swift in Sources */, - A0F88E79F3568E7BF0FAB6D00845B559 /* RDEPUBRuntimeChapter.swift in Sources */, - E3F85B1C4C7D7FEEFCD2035465E062A8 /* RDEPUBRuntimePageCount.swift in Sources */, - 564A0E8D7EA1FD563D30ED1C269CF110 /* RDEPUBSafeArea.swift in Sources */, - D11000ABBDC9F4AA8C2D2D81A640F380 /* RDEPUBSearchEngine.swift in Sources */, - 64273BE1153320103A7990C6742E9A11 /* RDEPUBSearchModels.swift in Sources */, - 457B13114B2568C713898BEFEE2536A7 /* RDEPUBSelectionLoupeView.swift in Sources */, - 7166B6127AC8ED73A5FF3A2351CC80B3 /* RDEPUBSelectionOverlayView.swift in Sources */, - A0077D5C2CBF0E00081C059EFCEC6373 /* RDEPUBSelectionState.swift in Sources */, - 02DD8E465CE2BF1FA3954E939C26404C /* RDEPUBSemanticMarkerInjector.swift in Sources */, - B1D4240422AFD323C59B17BE24A1E1B7 /* RDEPUBSettingsFlipAutomation.swift in Sources */, - D2D38EFF7A6086D40028DA14F8DAFB9C /* RDEPUBStyleCompatibilityModels.swift in Sources */, - 12374B35A5274F9053D51A1C41A246BA /* RDEPUBStyleSheetBuilder.swift in Sources */, - 7D527AEF8B4001ADDEE64E24958BA9E6 /* RDEPUBStyleSheetComposer.swift in Sources */, - 769008E585DCB34E7440CE7E9C3E93CC /* RDEPUBTextAnchor.swift in Sources */, - D2345E70DE161E0570492E70BD9CD20E /* RDEPUBTextAnnotationOverlay.swift in Sources */, - 6B497C1FA166F5DF043609FAC62C7C4B /* RDEPUBTextBookBuilder.swift in Sources */, - 4440862E007C4B31A86BAAA653FD3F98 /* RDEPUBTextBookCache.swift in Sources */, - 448798E8C31C4EE68A050057A4A7DA5E /* RDEPUBTextBookModels.swift in Sources */, - 8EB24D1CF95A90D98DBD5C1F41443429 /* RDEPUBTextBuildPipelineInterfaces.swift in Sources */, - 82B1DCCA45467E31302FFFD7EB78EF08 /* RDEPUBTextContentInteractionCoordinator.swift in Sources */, - 3C4ACDF8DA2D70C2B21014421406B818 /* RDEPUBTextContentView.swift in Sources */, - C85F3E3ED00BE6DBA524F1BE53E008DC /* RDEPUBTextIndexTable.swift in Sources */, - 30C834B23033B4F571143C336E72510A /* RDEPUBTextLayouter.swift in Sources */, - 3A653E4DA613026DC1C1C22CF6166DAE /* RDEPUBTextLayoutFrame.swift in Sources */, - F1B14CF589E4B3B29B40C70508CB3466 /* RDEPUBTextPageBoundaryValidator.swift in Sources */, - 7953853E6BE17879EBFCECF9551634A5 /* RDEPUBTextPageDecorationView.swift in Sources */, - 54F9E6B88ADCBEAE21A7FEE193A78133 /* RDEPUBTextPageRenderView.swift in Sources */, - CE8512459F54E41A09977E6EF2DD9933 /* RDEPUBTextPageVerticalJustifier.swift in Sources */, - 0D3D1B4ACC6218895C3C62156C7A0913 /* RDEPUBTextPaginationInterfaces.swift in Sources */, - BF0E4C081EC75F17692C703A2B9BE71B /* RDEPUBTextPaginationSupport.swift in Sources */, - AED1E11539E157D1123D4AFB20BB3BFC /* RDEPUBTextPerformanceSampler.swift in Sources */, - 40138948768EA675E92C18C3CB1D887A /* RDEPUBTextPositionConverter.swift in Sources */, - AAC5E6A45E892B280F2F507EAA68A2C4 /* RDEPUBTextRenderer.swift in Sources */, - A5E13B16B918357268ABB9C6105F4379 /* RDEPUBTextRendererSupport.swift in Sources */, - C407660D1DD8D9EBEE6B70B0BB18043D /* RDEPUBTextSearchEngine.swift in Sources */, - A6455538113656F10265CBA70E89498C /* RDEPUBTextSelectionController.swift in Sources */, - A29B6C78057AF83CE0FA325BE0AE95ED /* RDEPUBTypesettingPipeline.swift in Sources */, - 3AFA079E082223A8681E645F9995847F /* RDEPUBViewportTypes.swift in Sources */, - 31B065D428020BED69F0BB82C3C6CB6B /* RDEPUBWebContentView.swift in Sources */, - 8ACD0FAB855BF0922BAA860243DEDEDD /* RDEPUBWebDecorationOverlayView.swift in Sources */, - C964A7EFF40B8EB307EEA1E9EFE07FEE /* RDEPUBWebView.swift in Sources */, - 815AD7AF46039FC8352B50AF272F102B /* RDEPUBWebView+Configuration.swift in Sources */, - 24FD03B3A8C72A13747F946300D8264F /* RDEPUBWebView+FixedLayout.swift in Sources */, - 142F2CE735FD185C4CB812AFC1CC25A1 /* RDEPUBWebView+JavaScriptBridge.swift in Sources */, - 4D005BE2E0DC7CB926776C8406EE7A2A /* RDEPUBWebView+Reflowable.swift in Sources */, - DBA57C2841C8BCB36412B8CEAAA6AFDC /* RDEPUBWebView+Search.swift in Sources */, - E28DE61EC40C0EB8EE1467CA5996673B /* RDEPUBWebViewDebug.swift in Sources */, - 5375A13AE599ADE49B10C7934E1004B9 /* RDPlainTextBookBuilder.swift in Sources */, - FB6229275E860304DE410C3ABACA8368 /* RDReaderContentCell.swift in Sources */, - D92F1EB12DB274D47F77C097BDBA2866 /* RDReaderFlowLayout.swift in Sources */, - 5CDE2B7D11CFF775C358BD68412E0F45 /* RDReaderGestureController.swift in Sources */, - D894E108A7402D9ACB0E7BB65D938AC2 /* RDReaderPageChildViewController.swift in Sources */, - 91B95F181020FC644E5508803BAC5BEC /* RDReaderPagingController.swift in Sources */, - DF711B907615C160BB94C6A1E42D8A4D /* RDReaderPreloadController.swift in Sources */, - 7828DA98DD0559035F8A02686A44A281 /* RDReaderSpreadResolver.swift in Sources */, - EA5614D5101DCABD4056EED65EB1A393 /* RDReaderTapRegionHandler.swift in Sources */, - 43EA6AE86DAD9FF8AA228FFD06495F1D /* RDReaderView.swift in Sources */, - 886CBFD7FC5AD3F76DDA53D178447786 /* RDReaderView+CollectionView.swift in Sources */, - E8E13704514FAF7EF892BBBB71FB9C0E /* RDReaderView+ContentAccess.swift in Sources */, - 0C2FE44A6F901C5E9E1494DC1CD6F2B8 /* RDReaderView+PageCurl.swift in Sources */, - 8CD67A11ACBEDE5F66DB9508B021BBF9 /* RDReaderView+ToolView.swift in Sources */, - EB2E030B0EF64230A77CAE0CF3CED75B /* RDReaderView-dummy.m in Sources */, - D6DF49537490F8634E2BC8778FFDF0F3 /* RDReaderViewProtocols.swift in Sources */, - 25599BEBB0B997F6742E00F7124C40D7 /* RDURLReaderController.swift in Sources */, - 45FDB99F49312864726B866395136E29 /* String+RDEPUB.swift in Sources */, - 4F27DAE4E00D96562BD5EA6F4594E7F6 /* UIColor+RDEPUB.swift in Sources */, - 6BB57211BF6B933B65B0ED6CC81D2337 /* UIColor+RDEPUBHex.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 364142EF580BEBCFFA43C8B98013B6F8 /* Sources */ = { + 13DCE5F3F9EEA363E6338EA762B8C539 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; - 41A31B1132D292A790ACC09C7248B14D /* Sources */ = { + 8F0597C224D28EFC47DAB52A1C4B18D9 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 683ED28788D2B1078B5D3E5EA8B247DC /* Pods-ReadViewDemo-dummy.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; - AF09D5A5B57E781D29EB3CD22A1DE186 /* Sources */ = { + 9C3B5FCA1D5038170D3D6E7DEC230802 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 7408D293C24C3BD9CBBCBB530B50938B /* Pods-ReadViewDemo-dummy.m in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - B93FAED387C4F9F71AB33C5E2AAE246D /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 106C982FD5D5C71CA843649664127C78 /* Archive.swift in Sources */, - EA9A53D3DDE22E73A6D8DE79D54547C1 /* Archive+BackingConfiguration.swift in Sources */, - 35A655E299DC1E3EE7B316A18F2CE89D /* Archive+Deprecated.swift in Sources */, - 9ECF30F640D739DCEE8C5AD56C2BB4B4 /* Archive+Helpers.swift in Sources */, - D7D76E53192009455FC0FEE7D341EAB9 /* Archive+MemoryFile.swift in Sources */, - C759F2FD36CAF63381C0AB0B99D7BAE3 /* Archive+Progress.swift in Sources */, - F6888700DC52E960BF105470D3CB15F3 /* Archive+Reading.swift in Sources */, - 3E52F1617157FA5312268F8E8D77E2DF /* Archive+ReadingDeprecated.swift in Sources */, - 21FF2314438A1D9C326CFB7090FEDEFD /* Archive+Writing.swift in Sources */, - 6974A64C25319EC60A3982F864FC01B2 /* Archive+WritingDeprecated.swift in Sources */, - 0DB4BC648855B5320E45859AE469FFDA /* Archive+ZIP64.swift in Sources */, - 28B11155E99C2BC1AEC3A7ACD8E84A19 /* Data+Compression.swift in Sources */, - B3F712BE87B6441DE9CE079F98A65CEF /* Data+CompressionDeprecated.swift in Sources */, - 1C68F8BB0A8D65266501F52F000F3276 /* Data+Serialization.swift in Sources */, - 87CE9E7A1DFE5341BF42FD146FC7CC83 /* Date+ZIP.swift in Sources */, - D81B82D5420A636836F8771B438ED8D3 /* Entry.swift in Sources */, - 00AD5F1F9E2E94C1D359676457840515 /* Entry+Serialization.swift in Sources */, - 99983AF5666F6E31BF186B2171765B6B /* Entry+ZIP64.swift in Sources */, - 5A1492BFEF8BB10D35DE16AFED75ED1C /* FileManager+ZIP.swift in Sources */, - 0415046466A4505448FA097120A58F3D /* FileManager+ZIPDeprecated.swift in Sources */, - 78FF4557AC434319860AD771F26E2697 /* URL+ZIP.swift in Sources */, - 7999290DC707639D8F4D2090D3F0878F /* ZIPFoundation-dummy.m in Sources */, + 4A307E593B912B2F30D64559300599FE /* Archive.swift in Sources */, + 0BE1CDB7574C32928159748427234D17 /* Archive+BackingConfiguration.swift in Sources */, + 26CCE192A25CD7B025904E0AC1260A10 /* Archive+Deprecated.swift in Sources */, + DADD404DA23EBFC7F0101727EBE88832 /* Archive+Helpers.swift in Sources */, + 9760E98CC0951B6B8C058FB45926203A /* Archive+MemoryFile.swift in Sources */, + F45B5C3A0499EAFD676758C4A361CFC4 /* Archive+Progress.swift in Sources */, + 41DFB140311D7C9600D2E428FF15656D /* Archive+Reading.swift in Sources */, + 13A68713486786ECBD2F6D019D7F3A36 /* Archive+ReadingDeprecated.swift in Sources */, + 10D59A237D78A0523310743DE96A47B0 /* Archive+Writing.swift in Sources */, + D795202CC870F4CEE10864C01005F6D3 /* Archive+WritingDeprecated.swift in Sources */, + FCE6A5A737B87FA6CDEF85A5AB54ACD0 /* Archive+ZIP64.swift in Sources */, + B14BA93598C691CE6094E7B9D8AA2011 /* Data+Compression.swift in Sources */, + 37A6DEDE9455172CA9A261A3BC2ED02E /* Data+CompressionDeprecated.swift in Sources */, + 6C1327C994F11FDB4EDBDA5CE598CF11 /* Data+Serialization.swift in Sources */, + 920A2A7EF5F2915D271D229AA0E895DD /* Date+ZIP.swift in Sources */, + A002B3B82F572D8F62C398093EFFC356 /* Entry.swift in Sources */, + 0E80911EBF3F620A1B8DBCE516E2CC05 /* Entry+Serialization.swift in Sources */, + B55D0715C6E8F6847DE6D817EEBF9F2C /* Entry+ZIP64.swift in Sources */, + 91052B4F93F977458CC1A5EA78DF1DEE /* FileManager+ZIP.swift in Sources */, + D2D0EFD20BB05F026318070DB36D04BD /* FileManager+ZIPDeprecated.swift in Sources */, + 29AA7851B584FD07672063C7213179FA /* URL+ZIP.swift in Sources */, + DCD6C3ED3B13C549F589F57C95ED6BBF /* ZIPFoundation-dummy.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2591,6 +2383,214 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D29E47752E90C593DF598BE626679F85 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 3AD036D9E12E1F5B13E649BD538CAE41 /* RDEPUBAnnotationModels.swift in Sources */, + B61114677CF259E6270EFD80A28250D6 /* RDEPUBAssetRepository.swift in Sources */, + CF7B02001C36BA5BE16362D5C4EBDB07 /* RDEPUBAttachmentNormalizer.swift in Sources */, + C7E253517877BD88DD8B0DF7E23D1E89 /* RDEPUBAttachmentTooltipOverlayView.swift in Sources */, + AEA69A182B7FD8571C50C8D6D0BA457E /* RDEPUBAttachmentTooltipView.swift in Sources */, + 07ADD08673F557736DFE92F970D7C6CB /* RDEPUBBackgroundCoverageStore.swift in Sources */, + B2B97CAF20B478A68B805D4BF213ECBD /* RDEPUBBackgroundPriorityPolicy.swift in Sources */, + AB4E2FB516007251FB7794E5BE62552D /* RDEPUBBackgroundTrace.swift in Sources */, + 64AD1C8FACAD892C74600ECB3814FB7E /* RDEPUBBookPageMap.swift in Sources */, + D949F02438BC5DE45F525F899D6E1027 /* RDEPUBBuildDiagnosticsReporter.swift in Sources */, + 5E24441D23EA52049F2E955644FF13D7 /* RDEPUBCFI.swift in Sources */, + 96822D638C109FD3B0248756D3C49054 /* RDEPUBCFICompatibility.swift in Sources */, + 199E93DDEF9B6092AF0C6CA8271FBF43 /* RDEPUBCFIDOMPathBuilder.swift in Sources */, + 65826528B7322AC659BE2731BF56C4D7 /* RDEPUBCFIError.swift in Sources */, + 3F77E380C25DEB0DE88525B9727D8EF4 /* RDEPUBCFIGenerator.swift in Sources */, + F193C9E5DE98B216A99EB20B7B3C01C7 /* RDEPUBCFIMap.swift in Sources */, + 4046F20BC299071A92E803F8AEE93AA5 /* RDEPUBCFIMarkerInjector.swift in Sources */, + 7F4FB7F34D880ECD442F847FA71AD492 /* RDEPUBCFIParser.swift in Sources */, + 18309C9443FFD52914CC7A183360DE88 /* RDEPUBCFIPath.swift in Sources */, + 4B79E51CBDA4A396EAEB033F203F2475 /* RDEPUBCFIRange.swift in Sources */, + D38DC76225F46051EB5D9476485B4007 /* RDEPUBCFIRecoveryEngine.swift in Sources */, + 81F6BC0B7D2391F2FE65A174AFCBCBCB /* RDEPUBCFIResolver.swift in Sources */, + AF71226D0D6B283BC04F8560E9B9C0A7 /* RDEPUBCFISerializer.swift in Sources */, + A58BA01C2B741ED36C7B579D82DF9299 /* RDEPUBCFITextAssertion.swift in Sources */, + 80FC00E351AC0D6D2F46E4394E929CF6 /* RDEPUBCFIUtilities.swift in Sources */, + 58CB40846060096DCEBFF452787552E9 /* RDEPUBChapterCacheKey.swift in Sources */, + CA8985779F800BFDE009345B28F346C2 /* RDEPUBChapterData.swift in Sources */, + 3026BC61FA36878E1CC1CBA07F69C648 /* RDEPUBChapterDataCache.swift in Sources */, + 03D9B5FDBF0F61F0D2D9B28782BFB235 /* RDEPUBChapterDisplayContentCache.swift in Sources */, + 1A5D4A937139068A751CE07AEC4FACDC /* RDEPUBChapterLoader.swift in Sources */, + EE9B77D43F3023C4088D913B0012E493 /* RDEPUBChapterLocation.swift in Sources */, + D89AFCF84CC572910C3447D1C5B88EBE /* RDEPUBChapterOffsetMap.swift in Sources */, + 43EF10B03978E0CDA6E9F049A0D1A2A3 /* RDEPUBChapterPageCounter.swift in Sources */, + 75C0B88152862378FC69366D5556A4F0 /* RDEPUBChapterRuntimeStore.swift in Sources */, + 3144D716E7A554230FE1522F621FB024 /* RDEPUBChapterSummaryDiskCache.swift in Sources */, + 685918559B0E72379697A83A0CAB97BB /* RDEPUBChapterTailNormalizer.swift in Sources */, + 879859E12B2187F26613F5158A9AF2EC /* RDEPUBChapterWarmupOrchestrator.swift in Sources */, + 51098D0F512DA347D755590FD7586D57 /* RDEPUBCoreTextPageFrameFactory.swift in Sources */, + 2F9272A25DA4AD52EE88793A86DF9180 /* RDEPUBCSSCompatibilityLayer.swift in Sources */, + 37E8FA54000350B466C257801E6614CE /* RDEPUBDarkImageAdjuster.swift in Sources */, + EFE4AC989091964E6030D916FCA02EA7 /* RDEPUBDecryptingImageAttachment.swift in Sources */, + 40DBDF8655647908ED29A31535D267FB /* RDEPUBDTCoreTextRenderer.swift in Sources */, + 4A782A595503D2EB93424CF55B45C583 /* RDEPUBFixedLayoutTemplate.swift in Sources */, + 5A1F91D233524561D955A9A8C469C597 /* RDEPUBFontFallbackResolver.swift in Sources */, + AA230AF52990B03AB21C893D18B73E09 /* RDEPUBFontNormalizer.swift in Sources */, + 751C87AD55AC0B89BD55CB0A84659855 /* RDEPUBFragmentMarkerInjector.swift in Sources */, + B637F61E19FA6EBC9889C2BEDF31BE61 /* RDEPUBHTMLNormalizer.swift in Sources */, + E5AB494FB43314E9ECF733806ED02B1E /* RDEPUBImageViewController.swift in Sources */, + 7E42B74D6ADBBF89D80AC2B85BEDC30D /* RDEPUBImageViewerCoordinator.swift in Sources */, + 3970AAD7089A8009D72C2A70F1D73EE5 /* RDEPUBJavaScriptBridge.swift in Sources */, + 40ED28D3EDCD7FD6FDD14E01F5A9B447 /* RDEPUBJumpSession.swift in Sources */, + D2F7A0CE2F73B4EDCAD52379F2233A6B /* RDEPUBMemoryProbe.swift in Sources */, + 7F30613BF7FFC301FA627D3A4FB1F0C5 /* RDEPUBMetadataParseCancellationController.swift in Sources */, + C629CF9C1701AB409A00EC5D460DB964 /* RDEPUBMetadataParseWorker.swift in Sources */, + BF411F0538517FC40CDC0FD0A8C7A420 /* RDEPUBModels.swift in Sources */, + 1FD0505F5E605B1699CAF1616CC06662 /* RDEPUBNavigatorLayoutContext.swift in Sources */, + 99E46C19748D231F3D1D030942B6E1B1 /* RDEPUBNoteDetector.swift in Sources */, + 876E49146880469D4CE3D5232EE6FC90 /* RDEPUBNoteModels.swift in Sources */, + 4D45731159EA1D951C642A46AB0A3B38 /* RDEPUBNotePopupCoordinator.swift in Sources */, + 3CCEC42C335B6DCC04C3058CF078DAC7 /* RDEPUBNotePopupViewController.swift in Sources */, + 36F6BC3C4651DB0B0122DB1300A566A6 /* RDEPUBNoteResolver.swift in Sources */, + FA5E7F24DE52E5452B0EC9091D352602 /* RDEPUBPageBreakPolicy.swift in Sources */, + E12C988C3974467F470A512B0E5C54F7 /* RDEPUBPageCountCache.swift in Sources */, + 70F233D5255EDA1579EEC9530945F1B5 /* RDEPUBPageInteractionController.swift in Sources */, + 1FFD8E423E8DC4E04096041CFD00411A /* RDEPUBPageLayoutSnapshot.swift in Sources */, + 151713856AAB9F06659A8D5DDF2087C2 /* RDEPUBPageMapReconciliationCoordinator.swift in Sources */, + 4BB8DFFFE26E48DFA8B364CFF1EC8040 /* RDEPUBPageResolver.swift in Sources */, + 4766DE4358C52541EA0EF277171EAD9A /* RDEPUBPaginationCacheCoordinator.swift in Sources */, + 8179F8F50EDBD602A3A05A57D2738C4F /* RDEPUBPaginationModels.swift in Sources */, + 8BD146DDAFEE91B40B1946154580BE95 /* RDEPUBPaginator.swift in Sources */, + B2BB216A76E7F3B995A0F3D5D099E1BC /* RDEPUBParser.swift in Sources */, + C8C71942D3A141E7CD5032E8F1016F29 /* RDEPUBParser+Archive.swift in Sources */, + 263816EA7A16F559987B895002D43484 /* RDEPUBParser+Package.swift in Sources */, + C2E430A825AC41C57B06D2E35E3A853A /* RDEPUBParser+ReadingProfile.swift in Sources */, + D32DE4EA99BAA034D0C43502E93E7739 /* RDEPUBParser+Resources.swift in Sources */, + 75918C499D6B1483FBEBE1B0BC06AD53 /* RDEPUBParser+TOC.swift in Sources */, + 5719913D1BAA2CB6B405F41F827580DE /* RDEpubPlainTextBookBuilder.swift in Sources */, + 48878AB11627CEF8C7DC40088DA7AED7 /* RDEPUBPreferences.swift in Sources */, + 4C7BAC91C1E948D9592957C3A92C3147 /* RDEPUBPresentationRuntime.swift in Sources */, + 153D368F1FB8A6C37F09FCA900308262 /* RDEPUBPublication.swift in Sources */, + 6A9D9A375EC9BC31FA823F34FFFE4B46 /* RDEPUBReaderAnnotationCoordinator.swift in Sources */, + C2E855E2813B0E3231243EBCADD7759E /* RDEPUBReaderAssemblyCoordinator.swift in Sources */, + 7561605CCF229D3B47E4F7A4214E2495 /* RDEPUBReaderBottomToolView.swift in Sources */, + 33B28214F1A3949A14C93CECA00B8173 /* RDEPUBReaderChapterListController.swift in Sources */, + FD597D941EC356BFB833A672BABCA2D0 /* RDEPUBReaderChromeCoordinator.swift in Sources */, + B24C4825CE0519A7FB2CF193659F7236 /* RDEPUBReaderConfiguration.swift in Sources */, + 169C2AB341395A974734908A6E630E5E /* RDEpubReaderContentCell.swift in Sources */, + 968B20B8DFA56F0E43056D8B437D22F8 /* RDEPUBReaderContext.swift in Sources */, + 15C867066FED697DF7379382C99E6B30 /* RDEPUBReaderController.swift in Sources */, + E6701B19F1F17E88D687A13FCBA87277 /* RDEPUBReaderController+AttachmentTooltip.swift in Sources */, + 745A241FBA65FD761B09197560A4888C /* RDEPUBReaderController+ContentDelegates.swift in Sources */, + 87A23E9C163C19613C2643A27F192AA3 /* RDEPUBReaderController+DataSource.swift in Sources */, + F71ED6002B31FF204556F29CA0265143 /* RDEPUBReaderController+ExternalLinks.swift in Sources */, + 3C1B919FDD4F85CF4E6D3E8EC4C7E025 /* RDEPUBReaderController+LocationResolution.swift in Sources */, + 929443ED1ADB72355635234DDF0923C0 /* RDEPUBReaderController+Orientation.swift in Sources */, + FD088FA26275687CF53057621ADB903C /* RDEPUBReaderController+PublicAPI.swift in Sources */, + 9502E9B316CAD4D98520953DA4915B21 /* RDEPUBReaderController+RenderSupport.swift in Sources */, + 63A5CC78A647FBD1141999BBB2BDF21B /* RDEPUBReaderController+RuntimeBridge.swift in Sources */, + 7BBB76FF5902D531FC2DEE7596B75DDA /* RDEPUBReaderController+TableOfContents.swift in Sources */, + 2B65F3DCEBB005DA04C98F60A4D1AE77 /* RDEPUBReaderController+Trial.swift in Sources */, + B5A9BB79C1D4D5081CC626C1C75ACA58 /* RDEPUBReaderDelegate.swift in Sources */, + 45D3AC9978D8A395113A015A0AE53623 /* RDEPUBReaderDependencies.swift in Sources */, + 2341854012EA656B8F32863213F7012A /* RDEPUBReaderEnvironment.swift in Sources */, + 450D55646D08FE89E88D2F08698A6D32 /* RDEpubReaderFlowLayout.swift in Sources */, + DA678828B54CE961E1C2929A1410A31D /* RDEpubReaderGestureController.swift in Sources */, + 0B13ED712E176459DC75767C81356049 /* RDEPUBReaderHighlightsViewController.swift in Sources */, + BFF82B26C8F0A924864101BE93DBEA1F /* RDEPUBReaderLoadCoordinator.swift in Sources */, + C20B3A5FE8D80ACCF7281E7CED0826B3 /* RDEPUBReaderLocationCoordinator.swift in Sources */, + CC4DFC98C197D1FE60BF52809CC1B17E /* RDEpubReaderPageChildViewController.swift in Sources */, + 30CC1E6A5D43EC97866FA6D7087D696C /* RDEPUBReaderPaginationCoordinator.swift in Sources */, + 4E6E2981D78BED42BFB236A570E76176 /* RDEpubReaderPagingController.swift in Sources */, + 65F348EA65F8B9228DDCB4CE85A41CE4 /* RDEPUBReaderPersistence.swift in Sources */, + 75AD0B7A20FF5E8B71750D60477625D3 /* RDEpubReaderPreloadController.swift in Sources */, + BB487A86B1365E8F883930A0BFB80668 /* RDEPUBReaderRuntime.swift in Sources */, + 1A423024852D474564AD29A9E234D19D /* RDEPUBReaderSearchBarView.swift in Sources */, + 93FF39CA95B10B233EA1419CF95ED174 /* RDEPUBReaderSearchCoordinator.swift in Sources */, + C72F5280B5B83EA11ACDF74895E5758F /* RDEPUBReaderServices.swift in Sources */, + 0178B405FAF3D265372305943BDB91AB /* RDEPUBReaderSettings.swift in Sources */, + 7D456A8374BD4A23821CFFB07DDCF583 /* RDEPUBReaderSettingsViewController.swift in Sources */, + 4D2640B41A756FA16BCBAC667460FD10 /* RDEpubReaderSpreadResolver.swift in Sources */, + 7A0AD5DDF58A23FABE861D91A5ADC52A /* RDEPUBReaderState.swift in Sources */, + E0FC42C3CD2D1F7E374708F600536D26 /* RDEPUBReaderTableOfContentsItem.swift in Sources */, + 509C0C64CF80609A3A9ECBCE6D3C38AA /* RDEpubReaderTapRegionHandler.swift in Sources */, + 3BEB5C7059EB7C4E62C280146D66CAF7 /* RDEPUBReaderTheme.swift in Sources */, + 75752230AC2DC5B19D73E7F5719C4AE6 /* RDEPUBReaderToolView.swift in Sources */, + B4F9C816E5248DBE5D0F5CD82D25A4B4 /* RDEPUBReaderToolViewProtocols.swift in Sources */, + 267ED64847468F47CE49E72E39AC04A3 /* RDEPUBReaderTopToolView.swift in Sources */, + EBF356418C3C6D53BE635CFA4EAA2CEC /* RDEPUBReaderUIState.swift in Sources */, + B624BE4D149D61A910396D883766C7B1 /* RDEpubReaderView.swift in Sources */, + A2FA3F0E9BDA38929CD43EF55BA1F30F /* RDEpubReaderView+CollectionView.swift in Sources */, + 65207A50AA57064D307EC65F01B1FED2 /* RDEpubReaderView+ContentAccess.swift in Sources */, + B102ABA5BAF500C91E7FEB842E860FD4 /* RDEpubReaderView+PageCurl.swift in Sources */, + 64E474FDB28F0C3CDAC624A41775468A /* RDEpubReaderView+ToolView.swift in Sources */, + EAB62A5D1D3D572AD18C7566F15279C0 /* RDEpubReaderView-dummy.m in Sources */, + EA13E34B7BF4563DF1D5D2622C7DFF8F /* RDEPUBReaderViewportMonitor.swift in Sources */, + 164B404F4970465BA732CB6C61F24099 /* RDEpubReaderViewProtocols.swift in Sources */, + 8F082DD6458E2C5F76062E7D7FAF121F /* RDEPUBReadingLocationModels.swift in Sources */, + 35D82C01D957EA4B99A7BE2D88DBAB9C /* RDEPUBReadingSession.swift in Sources */, + 2169042D85F5BBD58CA42E4893ABCF5F /* RDEPUBRenderDiagnosticsCollector.swift in Sources */, + 5517E8BF85383E87597737B7C063403C /* RDEPUBRenderRequest.swift in Sources */, + BCBDC16EE17A5C45D99597929DA76453 /* RDEPUBResourceDataProvider.swift in Sources */, + 3BE4D575119E0A75B45C32489B090137 /* RDEPUBResourceResolver.swift in Sources */, + ADFBBB7F7EA8D54F062F6FE9A0EA1FA3 /* RDEPUBResourceURLSchemeHandler.swift in Sources */, + 5AB3B36FCE78B63E84BE2044872A9AD5 /* RDEPUBRuntimeChapter.swift in Sources */, + 04703B1388DCD2CA10215F648B50F3CB /* RDEPUBRuntimePageCount.swift in Sources */, + 22F7CE3E9E7DB5F51EF4562A2E8571BA /* RDEPUBSafeArea.swift in Sources */, + B4F523484916B5B5BF93998DC855D056 /* RDEPUBSearchEngine.swift in Sources */, + F439FC02429CC1C9DD1F6A5E161F61D6 /* RDEPUBSearchModels.swift in Sources */, + 06FB9E46B498A1601FFF195078E5A156 /* RDEPUBSelectionLoupeView.swift in Sources */, + 8BF9CE9063E06BB318FE214E669391AD /* RDEPUBSelectionOverlayView.swift in Sources */, + F7C33530A1B3E5BFFDC06C08D888DD5D /* RDEPUBSelectionState.swift in Sources */, + C68E01289F5AF6B9245379348D08820F /* RDEPUBSemanticMarkerInjector.swift in Sources */, + 4F9B2DB33A8FE255DDAF17E97D68227B /* RDEPUBSettingsFlipAutomation.swift in Sources */, + B462364CFF89AEFBE3F6B0FDD710F5CD /* RDEPUBStyleCompatibilityModels.swift in Sources */, + C9C8410D0C0FF213B707830293E79235 /* RDEPUBStyleSheetBuilder.swift in Sources */, + 35B64675B8DC54F46D7D7AC046ACFD23 /* RDEPUBStyleSheetComposer.swift in Sources */, + E586800F63A73B4721C0B3F03E49E942 /* RDEPUBTextAnchor.swift in Sources */, + A866F128FF261777F443C1CDD8223687 /* RDEPUBTextAnnotationOverlay.swift in Sources */, + E277306E0085BDD4F3A8885EE5351CD2 /* RDEPUBTextBookBuilder.swift in Sources */, + C5DD9E01BE94C43A42088BF48FC4A0C7 /* RDEPUBTextBookCache.swift in Sources */, + DBF354555B4B0353E32231529CB43F0A /* RDEPUBTextBookModels.swift in Sources */, + D79FE63B494282934EDE065131B29C98 /* RDEPUBTextBuildPipelineInterfaces.swift in Sources */, + 10849A91BA39B64B632457A2818B8C63 /* RDEPUBTextContentInteractionCoordinator.swift in Sources */, + B803A1C571EE32A1E68BDC1ECA3358ED /* RDEPUBTextContentView.swift in Sources */, + EB0A86053AC9D8A89B4D6E792AD7B7A8 /* RDEPUBTextIndexTable.swift in Sources */, + 6C4C16CD6AFC19E5AE7CB35224BA5C92 /* RDEPUBTextLayouter.swift in Sources */, + 39E30D6C1E40038B0C1083926237A2E6 /* RDEPUBTextLayoutFrame.swift in Sources */, + DD5CB019978B6208D0E7ABD017DC7D94 /* RDEPUBTextPageBoundaryValidator.swift in Sources */, + 5CEAFB1EE890C110409F5D157012C0E7 /* RDEPUBTextPageDecorationView.swift in Sources */, + 890E2633C30FA49F9947BF7956F24175 /* RDEPUBTextPageRenderView.swift in Sources */, + ED44A43182DD2E248D122AB1AEE76C2E /* RDEPUBTextPageVerticalJustifier.swift in Sources */, + 299808D938C97BA948DB3935EFD82B74 /* RDEPUBTextPaginationInterfaces.swift in Sources */, + 308462B8065C4552E6F2DCA653BA4A49 /* RDEPUBTextPaginationSupport.swift in Sources */, + F7591EEF257622C45FB4686F28808841 /* RDEPUBTextPerformanceSampler.swift in Sources */, + 16C3B174F120D3C049977A091BFA34A0 /* RDEPUBTextPositionConverter.swift in Sources */, + 934D9A4D7F0455ABD8C4CEC056AD1F53 /* RDEPUBTextRenderer.swift in Sources */, + D82A46AA1A5B2F3BB3B6E200544810E6 /* RDEPUBTextRendererSupport.swift in Sources */, + 411620ED94CA27F80F5BA1C54A8C0C68 /* RDEPUBTextSearchEngine.swift in Sources */, + E9871753E44C96C4AA1F48A81CA35361 /* RDEPUBTextSelectionController.swift in Sources */, + A408F09A616AB7B4107965F03AFE340F /* RDEPUBTypesettingPipeline.swift in Sources */, + CF851879EEFBFA3EEB9EEC6F74510052 /* RDEpubURLReaderController.swift in Sources */, + 6F6E1D10B2D36F1BF24AA22D3EF2053D /* RDEPUBViewportTypes.swift in Sources */, + EF9B5A80BF3D877F80BAEC7508948F22 /* RDEPUBWebContentView.swift in Sources */, + 1384D17D1E1C6DE381FA63DAB5DB421B /* RDEPUBWebDecorationOverlayView.swift in Sources */, + 06DF0E39B884DFADA5D3A32E03935DFF /* RDEPUBWebView.swift in Sources */, + 7928AE9F98DF003568813C9DC077236D /* RDEPUBWebView+Configuration.swift in Sources */, + 2201CC7B0A221ADAF05C3C4287FA3518 /* RDEPUBWebView+FixedLayout.swift in Sources */, + AA33782864FDA1F1F75C7538038F006D /* RDEPUBWebView+JavaScriptBridge.swift in Sources */, + 8EB2C64782A5B3E28B464EC7DC62E941 /* RDEPUBWebView+Reflowable.swift in Sources */, + 4BBADAEFFC3275B9ACFEA06354F413E1 /* RDEPUBWebView+Search.swift in Sources */, + 11DEE40FA231D4F0E3E04F691930E413 /* RDEPUBWebViewDebug.swift in Sources */, + 60721E7D396AE3937C7133AB576FD883 /* String+RDEPUB.swift in Sources */, + BC8D2215508131850AC76A34FF268694 /* UIColor+RDEPUB.swift in Sources */, + 99E9D689A47495662954CEE4075C05AD /* UIColor+RDEPUBHex.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E66DF131B1BAFD7B618B44E7892A790A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; E67568179BECE9060450A68A54A2E0FA /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -2670,29 +2670,35 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ - 0DC04BA41A0DC1E304A94063EAEF8EB1 /* PBXTargetDependency */ = { + 00C6361C8719FBA4E395DD7972756069 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = "ZIPFoundation-ZIPFoundation_Privacy"; + target = C7A8D82E407CD3FDC3BA55CEE519B252 /* ZIPFoundation-ZIPFoundation_Privacy */; + targetProxy = 291617C41AB3A88D1C83DC7062E96890 /* PBXContainerItemProxy */; + }; + 0B12ED877A504A3EDF328B8B5AB55A37 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = "RDEpubReaderView-RDEpubReaderViewAssets"; + target = E07DDD766DCBF90486BCE6C2EA671E5E /* RDEpubReaderView-RDEpubReaderViewAssets */; + targetProxy = 0A9B46C05BF3C3A70BDDF3F755A55301 /* PBXContainerItemProxy */; + }; + 1551DF4D30E2A88F1C572E3102D161E0 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = DTCoreText; + target = B88F4EA0695B6B3165C64594850D72C7 /* DTCoreText */; + targetProxy = 89EEF9DF71A6BD020500EED6F3B6F74F /* PBXContainerItemProxy */; + }; + 455E64EEF1F8ABA5151E7A74C7F65E30 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = RDEpubReaderView; + target = FBCB57A3AEAAA3449B3F365930BC0977 /* RDEpubReaderView */; + targetProxy = 184572994656765806610539FDE97D20 /* PBXContainerItemProxy */; + }; + 669D8327694A81D8D157F39CDB3E1539 /* PBXTargetDependency */ = { isa = PBXTargetDependency; name = ZIPFoundation; target = AA15C8469D67684160CC2A7098EB841C /* ZIPFoundation */; - targetProxy = DA8CE4540395617C72E05FAD18A9E528 /* PBXContainerItemProxy */; - }; - 1A81973F692B2B1BA74FB6E5A9B7C526 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - name = RDReaderView; - target = AA2E57587AA8EECA63C4BE08EA3CB6D2 /* RDReaderView */; - targetProxy = 909CE499960E68FFEEAC3F07CCED816D /* PBXContainerItemProxy */; - }; - 7BA66A0536D199195F48545FC6C74888 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - name = "RDReaderView-RDReaderViewAssets"; - target = AE7F393FB7805DE2664AB4111873F907 /* RDReaderView-RDReaderViewAssets */; - targetProxy = 7677F420C9062853DC411E1D6CD67450 /* PBXContainerItemProxy */; - }; - 86F72F6F95E48B50E90565CCC2617B60 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - name = ZIPFoundation; - target = AA15C8469D67684160CC2A7098EB841C /* ZIPFoundation */; - targetProxy = E02D487CC5FCE17A09C6D7F992E61FA5 /* PBXContainerItemProxy */; + targetProxy = F3A9CFD4766AB58EAD7C800B495663A5 /* PBXContainerItemProxy */; }; 8A57936B99831660CED2C2AE2656E553 /* PBXTargetDependency */ = { isa = PBXTargetDependency; @@ -2700,29 +2706,23 @@ target = 8F6E5A5BF72D62CDFD25F91A7CFA3309 /* DTFoundation */; targetProxy = FC2A40799EA5B233DDF797D1E36600CE /* PBXContainerItemProxy */; }; - 8FAA51D0E55C0CC6C11C56E8B5117428 /* PBXTargetDependency */ = { + 982CD0D79765BF90F3C8143F8254BA5A /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = ZIPFoundation; + target = AA15C8469D67684160CC2A7098EB841C /* ZIPFoundation */; + targetProxy = 5B1CF3E8F84553527E617670496C2759 /* PBXContainerItemProxy */; + }; + E73110092DD196B4E565269EBF56DC68 /* PBXTargetDependency */ = { isa = PBXTargetDependency; name = DTCoreText; target = B88F4EA0695B6B3165C64594850D72C7 /* DTCoreText */; - targetProxy = E6196200916399BA1FC57E48F75BEA93 /* PBXContainerItemProxy */; + targetProxy = 9CABD6887800C32918AF4DF87BFA0A2E /* PBXContainerItemProxy */; }; - C36FE402BBAD13F1322853208B3F26EE /* PBXTargetDependency */ = { + F65A4523B74023B13D4117D61E694844 /* PBXTargetDependency */ = { isa = PBXTargetDependency; name = DTFoundation; target = 8F6E5A5BF72D62CDFD25F91A7CFA3309 /* DTFoundation */; - targetProxy = 84ADF252286F6B6A90FDCAF49CFD1CF7 /* PBXContainerItemProxy */; - }; - C91E1D59F6BC9C8277B2CED2D15F0AFA /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - name = "ZIPFoundation-ZIPFoundation_Privacy"; - target = C7A8D82E407CD3FDC3BA55CEE519B252 /* ZIPFoundation-ZIPFoundation_Privacy */; - targetProxy = 351888AD0675DE4DF54F06BC38FB2999 /* PBXContainerItemProxy */; - }; - DFA128FE3AA2D6360EBE3901CA609FEC /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - name = DTCoreText; - target = B88F4EA0695B6B3165C64594850D72C7 /* DTCoreText */; - targetProxy = 34C38B7572F96194A86992F982080DE3 /* PBXContainerItemProxy */; + targetProxy = C3309B36153779AA44D0A11AA303CD30 /* PBXContainerItemProxy */; }; F89BE326BA8B06B1007631FA78D53A6A /* PBXTargetDependency */ = { isa = PBXTargetDependency; @@ -2733,81 +2733,6 @@ /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ - 02D9064198969F75468B7756D2A0CA5E /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = DE0E862DE5230ECE5406E1D3EB3ED840 /* RDReaderView.release.xcconfig */; - buildSettings = { - CODE_SIGNING_ALLOWED = NO; - CONFIGURATION_BUILD_DIR = "$(BUILD_DIR)/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)/RDReaderView"; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - IBSC_MODULE = RDReaderView; - INFOPLIST_FILE = "Target Support Files/RDReaderView/ResourceBundle-RDReaderViewAssets-RDReaderView-Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; - PRODUCT_NAME = RDReaderViewAssets; - SDKROOT = iphoneos; - SKIP_INSTALL = YES; - TARGETED_DEVICE_FAMILY = "1,2"; - WRAPPER_EXTENSION = bundle; - }; - name = Release; - }; - 09AE6F08A8CECD592D907C0A8AEF4FB0 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 766227355308600EFC048F797274454A /* ZIPFoundation.release.xcconfig */; - buildSettings = { - CODE_SIGNING_ALLOWED = NO; - CONFIGURATION_BUILD_DIR = "$(BUILD_DIR)/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)/ZIPFoundation"; - 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; - PRODUCT_NAME = ZIPFoundation_Privacy; - SDKROOT = iphoneos; - SKIP_INSTALL = YES; - TARGETED_DEVICE_FAMILY = "1,2"; - WRAPPER_EXTENSION = bundle; - }; - name = Release; - }; - 1DBA69CFBAB5965CF41704A7A4699A8A /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 306477706BBB1E784F55BCF8EB1AD737 /* Pods-ReadViewDemo.debug.xcconfig */; - buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; - CLANG_ENABLE_OBJC_WEAK = NO; - "CODE_SIGN_IDENTITY[sdk=appletvos*]" = ""; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; - "CODE_SIGN_IDENTITY[sdk=watchos*]" = ""; - CURRENT_PROJECT_VERSION = 1; - DEFINES_MODULE = YES; - DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 1; - DYLIB_INSTALL_NAME_BASE = "@rpath"; - ENABLE_MODULE_VERIFIER = NO; - 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; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@loader_path/Frameworks", - ); - MACH_O_TYPE = staticlib; - MODULEMAP_FILE = "Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo.modulemap"; - OTHER_LDFLAGS = ""; - OTHER_LIBTOOLFLAGS = ""; - PODS_ROOT = "$(SRCROOT)"; - PRODUCT_BUNDLE_IDENTIFIER = "org.cocoapods.${PRODUCT_NAME:rfc1034identifier}"; - PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; - SDKROOT = iphoneos; - SKIP_INSTALL = YES; - TARGETED_DEVICE_FAMILY = "1,2"; - VERSIONING_SYSTEM = "apple-generic"; - VERSION_INFO_PREFIX = ""; - }; - name = Debug; - }; 23DEE767AD9A95A36965BBF0E1B95EE0 /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = C0DCB00F0B62D7DBE628D2B5FF006E93 /* DTCoreText.debug.xcconfig */; @@ -2826,6 +2751,46 @@ }; name = Debug; }; + 29B18116A3CD49E3DF1838D1231A6962 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 766227355308600EFC048F797274454A /* ZIPFoundation.release.xcconfig */; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = NO; + "CODE_SIGN_IDENTITY[sdk=appletvos*]" = ""; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; + "CODE_SIGN_IDENTITY[sdk=watchos*]" = ""; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = NO; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_PREFIX_HEADER = "Target Support Files/ZIPFoundation/ZIPFoundation-prefix.pch"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = "Target Support Files/ZIPFoundation/ZIPFoundation-Info.plist"; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MODULEMAP_FILE = "Target Support Files/ZIPFoundation/ZIPFoundation.modulemap"; + PRODUCT_MODULE_NAME = ZIPFoundation; + PRODUCT_NAME = ZIPFoundation; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) "; + SWIFT_INSTALL_OBJC_HEADER = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Release; + }; 3E0792C6BCBD78FE928FEDF7FE083B8B /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = 85E8DA9DE1F796F12171763D20CBAF27 /* DTFoundation.debug.xcconfig */; @@ -2865,24 +2830,6 @@ }; name = Debug; }; - 44897BD47178DC27ABD838521D880722 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 79D13938A04579CD1E9E042A39CC4D86 /* ZIPFoundation.debug.xcconfig */; - buildSettings = { - CODE_SIGNING_ALLOWED = NO; - CONFIGURATION_BUILD_DIR = "$(BUILD_DIR)/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)/ZIPFoundation"; - 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; - PRODUCT_NAME = ZIPFoundation_Privacy; - SDKROOT = iphoneos; - SKIP_INSTALL = YES; - TARGETED_DEVICE_FAMILY = "1,2"; - WRAPPER_EXTENSION = bundle; - }; - name = Debug; - }; 44A4B2FB6211B90C1949A44A381F6D1F /* Release */ = { isa = XCBuildConfiguration; buildSettings = { @@ -2946,9 +2893,66 @@ }; name = Release; }; - 7E9B342A72B3CA971528D14086E8FA77 /* Release */ = { + 6324561B3A31DED50394D20E6F6C48F5 /* Release */ = { isa = XCBuildConfiguration; baseConfigurationReference = 766227355308600EFC048F797274454A /* ZIPFoundation.release.xcconfig */; + buildSettings = { + CODE_SIGNING_ALLOWED = NO; + CONFIGURATION_BUILD_DIR = "$(BUILD_DIR)/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)/ZIPFoundation"; + 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; + PRODUCT_NAME = ZIPFoundation_Privacy; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + WRAPPER_EXTENSION = bundle; + }; + name = Release; + }; + 77AF13D67B09359B5F6184B93496E376 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 306477706BBB1E784F55BCF8EB1AD737 /* Pods-ReadViewDemo.debug.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; + CLANG_ENABLE_OBJC_WEAK = NO; + "CODE_SIGN_IDENTITY[sdk=appletvos*]" = ""; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; + "CODE_SIGN_IDENTITY[sdk=watchos*]" = ""; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = NO; + 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; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MACH_O_TYPE = staticlib; + MODULEMAP_FILE = "Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo.modulemap"; + OTHER_LDFLAGS = ""; + OTHER_LIBTOOLFLAGS = ""; + PODS_ROOT = "$(SRCROOT)"; + PRODUCT_BUNDLE_IDENTIFIER = "org.cocoapods.${PRODUCT_NAME:rfc1034identifier}"; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Debug; + }; + 86D2911B3845A1300778528B808CC582 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = CF8FD338F89FBB4FEDDF24E590E86AFE /* RDEpubReaderView.debug.xcconfig */; buildSettings = { CLANG_ENABLE_OBJC_WEAK = NO; "CODE_SIGN_IDENTITY[sdk=appletvos*]" = ""; @@ -2961,9 +2965,9 @@ DYLIB_INSTALL_NAME_BASE = "@rpath"; ENABLE_MODULE_VERIFIER = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_PREFIX_HEADER = "Target Support Files/ZIPFoundation/ZIPFoundation-prefix.pch"; + GCC_PREFIX_HEADER = "Target Support Files/RDEpubReaderView/RDEpubReaderView-prefix.pch"; GENERATE_INFOPLIST_FILE = NO; - INFOPLIST_FILE = "Target Support Files/ZIPFoundation/ZIPFoundation-Info.plist"; + INFOPLIST_FILE = "Target Support Files/RDEpubReaderView/RDEpubReaderView-Info.plist"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = ( @@ -2971,20 +2975,19 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MODULEMAP_FILE = "Target Support Files/ZIPFoundation/ZIPFoundation.modulemap"; - PRODUCT_MODULE_NAME = ZIPFoundation; - PRODUCT_NAME = ZIPFoundation; + MODULEMAP_FILE = "Target Support Files/RDEpubReaderView/RDEpubReaderView.modulemap"; + PRODUCT_MODULE_NAME = RDEpubReaderView; + PRODUCT_NAME = RDEpubReaderView; SDKROOT = iphoneos; SKIP_INSTALL = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) "; SWIFT_INSTALL_OBJC_HEADER = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 5.10; TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; }; - name = Release; + name = Debug; }; 878C92DA590BE3C7155CD32C9C84FBA8 /* Release */ = { isa = XCBuildConfiguration; @@ -3026,6 +3029,46 @@ }; name = Release; }; + 971178F13FAD5F4C0B9D9714B6345C9F /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 1AD502A1FA6EB1E38200B5283F979504 /* Pods-ReadViewDemo.release.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; + CLANG_ENABLE_OBJC_WEAK = NO; + "CODE_SIGN_IDENTITY[sdk=appletvos*]" = ""; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; + "CODE_SIGN_IDENTITY[sdk=watchos*]" = ""; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = NO; + 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; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MACH_O_TYPE = staticlib; + MODULEMAP_FILE = "Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo.modulemap"; + OTHER_LDFLAGS = ""; + OTHER_LIBTOOLFLAGS = ""; + PODS_ROOT = "$(SRCROOT)"; + PRODUCT_BUNDLE_IDENTIFIER = "org.cocoapods.${PRODUCT_NAME:rfc1034identifier}"; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Release; + }; A0DC1C2C814704734F83EAB35BB20B21 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -3172,125 +3215,6 @@ }; name = Debug; }; - B29B08EB754E81F21963B11AE2FA57EF /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = DE0E862DE5230ECE5406E1D3EB3ED840 /* RDReaderView.release.xcconfig */; - buildSettings = { - CLANG_ENABLE_OBJC_WEAK = NO; - "CODE_SIGN_IDENTITY[sdk=appletvos*]" = ""; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; - "CODE_SIGN_IDENTITY[sdk=watchos*]" = ""; - CURRENT_PROJECT_VERSION = 1; - DEFINES_MODULE = YES; - DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 1; - DYLIB_INSTALL_NAME_BASE = "@rpath"; - ENABLE_MODULE_VERIFIER = NO; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_PREFIX_HEADER = "Target Support Files/RDReaderView/RDReaderView-prefix.pch"; - GENERATE_INFOPLIST_FILE = NO; - INFOPLIST_FILE = "Target Support Files/RDReaderView/RDReaderView-Info.plist"; - INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@loader_path/Frameworks", - ); - MODULEMAP_FILE = "Target Support Files/RDReaderView/RDReaderView.modulemap"; - PRODUCT_MODULE_NAME = RDReaderView; - PRODUCT_NAME = RDReaderView; - SDKROOT = iphoneos; - SKIP_INSTALL = YES; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) "; - SWIFT_INSTALL_OBJC_HEADER = YES; - SWIFT_VERSION = 5.10; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - VERSIONING_SYSTEM = "apple-generic"; - VERSION_INFO_PREFIX = ""; - }; - name = Release; - }; - B57CEDA4B87E320F9FE64C2C83050112 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = C39473D9AF5F23B7886FFC138E4E7644 /* RDReaderView.debug.xcconfig */; - buildSettings = { - CLANG_ENABLE_OBJC_WEAK = NO; - "CODE_SIGN_IDENTITY[sdk=appletvos*]" = ""; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; - "CODE_SIGN_IDENTITY[sdk=watchos*]" = ""; - CURRENT_PROJECT_VERSION = 1; - DEFINES_MODULE = YES; - DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 1; - DYLIB_INSTALL_NAME_BASE = "@rpath"; - ENABLE_MODULE_VERIFIER = NO; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_PREFIX_HEADER = "Target Support Files/RDReaderView/RDReaderView-prefix.pch"; - GENERATE_INFOPLIST_FILE = NO; - INFOPLIST_FILE = "Target Support Files/RDReaderView/RDReaderView-Info.plist"; - INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@loader_path/Frameworks", - ); - MODULEMAP_FILE = "Target Support Files/RDReaderView/RDReaderView.modulemap"; - PRODUCT_MODULE_NAME = RDReaderView; - PRODUCT_NAME = RDReaderView; - SDKROOT = iphoneos; - SKIP_INSTALL = YES; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) "; - SWIFT_INSTALL_OBJC_HEADER = YES; - SWIFT_VERSION = 5.10; - TARGETED_DEVICE_FAMILY = "1,2"; - VERSIONING_SYSTEM = "apple-generic"; - VERSION_INFO_PREFIX = ""; - }; - name = Debug; - }; - C9A3C41671C64664CEDC2482C0D7591C /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 1AD502A1FA6EB1E38200B5283F979504 /* Pods-ReadViewDemo.release.xcconfig */; - buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; - CLANG_ENABLE_OBJC_WEAK = NO; - "CODE_SIGN_IDENTITY[sdk=appletvos*]" = ""; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; - "CODE_SIGN_IDENTITY[sdk=watchos*]" = ""; - CURRENT_PROJECT_VERSION = 1; - DEFINES_MODULE = YES; - DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 1; - DYLIB_INSTALL_NAME_BASE = "@rpath"; - ENABLE_MODULE_VERIFIER = NO; - 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; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@loader_path/Frameworks", - ); - MACH_O_TYPE = staticlib; - MODULEMAP_FILE = "Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo.modulemap"; - OTHER_LDFLAGS = ""; - OTHER_LIBTOOLFLAGS = ""; - PODS_ROOT = "$(SRCROOT)"; - PRODUCT_BUNDLE_IDENTIFIER = "org.cocoapods.${PRODUCT_NAME:rfc1034identifier}"; - PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; - SDKROOT = iphoneos; - SKIP_INSTALL = YES; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - VERSIONING_SYSTEM = "apple-generic"; - VERSION_INFO_PREFIX = ""; - }; - name = Release; - }; CF3B3A6EE9088299CBAF7500D2133580 /* Release */ = { isa = XCBuildConfiguration; baseConfigurationReference = 7A13F9221A300A89F9F8D0A2AB1794BF /* DTCoreText.release.xcconfig */; @@ -3309,7 +3233,61 @@ }; name = Release; }; - D2692243E03689CAACAC289155F37112 /* Debug */ = { + D21E6AF554E5ED9D11192200394F2642 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 79D13938A04579CD1E9E042A39CC4D86 /* ZIPFoundation.debug.xcconfig */; + buildSettings = { + CODE_SIGNING_ALLOWED = NO; + CONFIGURATION_BUILD_DIR = "$(BUILD_DIR)/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)/ZIPFoundation"; + 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; + PRODUCT_NAME = ZIPFoundation_Privacy; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + WRAPPER_EXTENSION = bundle; + }; + name = Debug; + }; + DD5459E58C63C444714EAAF61A16B4F8 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = CF8FD338F89FBB4FEDDF24E590E86AFE /* RDEpubReaderView.debug.xcconfig */; + buildSettings = { + CODE_SIGNING_ALLOWED = NO; + CONFIGURATION_BUILD_DIR = "$(BUILD_DIR)/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)/RDEpubReaderView"; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + IBSC_MODULE = RDEpubReaderView; + INFOPLIST_FILE = "Target Support Files/RDEpubReaderView/ResourceBundle-RDEpubReaderViewAssets-RDEpubReaderView-Info.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; + PRODUCT_NAME = RDEpubReaderViewAssets; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + WRAPPER_EXTENSION = bundle; + }; + name = Debug; + }; + E1D4E894DCB57F557E71F4DC86EB0013 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 57F3CF4EBE3598B7B7C01D2C19483B86 /* RDEpubReaderView.release.xcconfig */; + buildSettings = { + CODE_SIGNING_ALLOWED = NO; + CONFIGURATION_BUILD_DIR = "$(BUILD_DIR)/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)/RDEpubReaderView"; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + IBSC_MODULE = RDEpubReaderView; + INFOPLIST_FILE = "Target Support Files/RDEpubReaderView/ResourceBundle-RDEpubReaderViewAssets-RDEpubReaderView-Info.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; + PRODUCT_NAME = RDEpubReaderViewAssets; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + WRAPPER_EXTENSION = bundle; + }; + name = Release; + }; + E781143F031B88689D083E61995FCF43 /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = 79D13938A04579CD1E9E042A39CC4D86 /* ZIPFoundation.debug.xcconfig */; buildSettings = { @@ -3348,23 +3326,45 @@ }; name = Debug; }; - DF2BF92C9A97D2A5DA2D1C61D1F8D00C /* Debug */ = { + EE7F9483CF6EE6F6809A98D197239013 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = C39473D9AF5F23B7886FFC138E4E7644 /* RDReaderView.debug.xcconfig */; + baseConfigurationReference = 57F3CF4EBE3598B7B7C01D2C19483B86 /* RDEpubReaderView.release.xcconfig */; buildSettings = { - CODE_SIGNING_ALLOWED = NO; - CONFIGURATION_BUILD_DIR = "$(BUILD_DIR)/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)/RDReaderView"; + CLANG_ENABLE_OBJC_WEAK = NO; + "CODE_SIGN_IDENTITY[sdk=appletvos*]" = ""; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; + "CODE_SIGN_IDENTITY[sdk=watchos*]" = ""; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO; - IBSC_MODULE = RDReaderView; - INFOPLIST_FILE = "Target Support Files/RDReaderView/ResourceBundle-RDReaderViewAssets-RDReaderView-Info.plist"; + GCC_PREFIX_HEADER = "Target Support Files/RDEpubReaderView/RDEpubReaderView-prefix.pch"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = "Target Support Files/RDEpubReaderView/RDEpubReaderView-Info.plist"; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 15.6; - PRODUCT_NAME = RDReaderViewAssets; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MODULEMAP_FILE = "Target Support Files/RDEpubReaderView/RDEpubReaderView.modulemap"; + PRODUCT_MODULE_NAME = RDEpubReaderView; + PRODUCT_NAME = RDEpubReaderView; SDKROOT = iphoneos; SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) "; + SWIFT_INSTALL_OBJC_HEADER = YES; + SWIFT_VERSION = 5.10; TARGETED_DEVICE_FAMILY = "1,2"; - WRAPPER_EXTENSION = bundle; + VALIDATE_PRODUCT = YES; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; }; - name = Debug; + name = Release; }; /* End XCBuildConfiguration section */ @@ -3378,24 +3378,6 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 2CC157438E43F8FADB5A73A6D96D87CD /* Build configuration list for PBXNativeTarget "ZIPFoundation" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - D2692243E03689CAACAC289155F37112 /* Debug */, - 7E9B342A72B3CA971528D14086E8FA77 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 35127788D4B8EA3B2A33A086A96F6692 /* Build configuration list for PBXNativeTarget "RDReaderView-RDReaderViewAssets" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - DF2BF92C9A97D2A5DA2D1C61D1F8D00C /* Debug */, - 02D9064198969F75468B7756D2A0CA5E /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; 4821239608C13582E20E6DA73FD5F1F9 /* Build configuration list for PBXProject "Pods" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -3405,6 +3387,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 4E41342AEB9C410C736C34DCA7B0F78F /* Build configuration list for PBXNativeTarget "RDEpubReaderView-RDEpubReaderViewAssets" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DD5459E58C63C444714EAAF61A16B4F8 /* Debug */, + E1D4E894DCB57F557E71F4DC86EB0013 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 6D4878B1090649FAD56F25B2145334DE /* Build configuration list for PBXNativeTarget "DTCoreText-Resources" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -3423,29 +3414,38 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 88A749A76163B4114B76F1489777EFB2 /* Build configuration list for PBXNativeTarget "RDReaderView" */ = { + A4FC6BA1B119BFD8297B387109E266D3 /* Build configuration list for PBXNativeTarget "ZIPFoundation-ZIPFoundation_Privacy" */ = { isa = XCConfigurationList; buildConfigurations = ( - B57CEDA4B87E320F9FE64C2C83050112 /* Debug */, - B29B08EB754E81F21963B11AE2FA57EF /* Release */, + D21E6AF554E5ED9D11192200394F2642 /* Debug */, + 6324561B3A31DED50394D20E6F6C48F5 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 969FEC0B2FA67E4DE3E10F5E98CF39C6 /* Build configuration list for PBXNativeTarget "Pods-ReadViewDemo" */ = { + B302A2B9135E6E0BE1A48FEB8624A91C /* Build configuration list for PBXNativeTarget "RDEpubReaderView" */ = { isa = XCConfigurationList; buildConfigurations = ( - 1DBA69CFBAB5965CF41704A7A4699A8A /* Debug */, - C9A3C41671C64664CEDC2482C0D7591C /* Release */, + 86D2911B3845A1300778528B808CC582 /* Debug */, + EE7F9483CF6EE6F6809A98D197239013 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - CBA94F1763131C44221ECC4537E2EC32 /* Build configuration list for PBXNativeTarget "ZIPFoundation-ZIPFoundation_Privacy" */ = { + D01F817655AEC389E227C183D399F270 /* Build configuration list for PBXNativeTarget "Pods-ReadViewDemo" */ = { isa = XCConfigurationList; buildConfigurations = ( - 44897BD47178DC27ABD838521D880722 /* Debug */, - 09AE6F08A8CECD592D907C0A8AEF4FB0 /* Release */, + 77AF13D67B09359B5F6184B93496E376 /* Debug */, + 971178F13FAD5F4C0B9D9714B6345C9F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + F34DEB3C2C0CDC5F97BBEAE4D6F7D219 /* Build configuration list for PBXNativeTarget "ZIPFoundation" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E781143F031B88689D083E61995FCF43 /* Debug */, + 29B18116A3CD49E3DF1838D1231A6962 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; diff --git a/ReadViewDemo/Pods/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-Debug-input-files.xcfilelist b/ReadViewDemo/Pods/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-Debug-input-files.xcfilelist index 18a9ff0..40b30e6 100644 --- a/ReadViewDemo/Pods/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-Debug-input-files.xcfilelist +++ b/ReadViewDemo/Pods/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-Debug-input-files.xcfilelist @@ -1,5 +1,5 @@ ${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks.sh ${BUILT_PRODUCTS_DIR}/DTCoreText/DTCoreText.framework ${BUILT_PRODUCTS_DIR}/DTFoundation/DTFoundation.framework -${BUILT_PRODUCTS_DIR}/RDReaderView/RDReaderView.framework +${BUILT_PRODUCTS_DIR}/RDEpubReaderView/RDEpubReaderView.framework ${BUILT_PRODUCTS_DIR}/ZIPFoundation/ZIPFoundation.framework \ No newline at end of file diff --git a/ReadViewDemo/Pods/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-Debug-output-files.xcfilelist b/ReadViewDemo/Pods/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-Debug-output-files.xcfilelist index 410fd64..8916018 100644 --- a/ReadViewDemo/Pods/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-Debug-output-files.xcfilelist +++ b/ReadViewDemo/Pods/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-Debug-output-files.xcfilelist @@ -1,4 +1,4 @@ ${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/DTCoreText.framework ${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/DTFoundation.framework -${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RDReaderView.framework +${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RDEpubReaderView.framework ${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ZIPFoundation.framework \ No newline at end of file diff --git a/ReadViewDemo/Pods/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-Release-input-files.xcfilelist b/ReadViewDemo/Pods/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-Release-input-files.xcfilelist index 18a9ff0..40b30e6 100644 --- a/ReadViewDemo/Pods/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-Release-input-files.xcfilelist +++ b/ReadViewDemo/Pods/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-Release-input-files.xcfilelist @@ -1,5 +1,5 @@ ${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks.sh ${BUILT_PRODUCTS_DIR}/DTCoreText/DTCoreText.framework ${BUILT_PRODUCTS_DIR}/DTFoundation/DTFoundation.framework -${BUILT_PRODUCTS_DIR}/RDReaderView/RDReaderView.framework +${BUILT_PRODUCTS_DIR}/RDEpubReaderView/RDEpubReaderView.framework ${BUILT_PRODUCTS_DIR}/ZIPFoundation/ZIPFoundation.framework \ No newline at end of file diff --git a/ReadViewDemo/Pods/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-Release-output-files.xcfilelist b/ReadViewDemo/Pods/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-Release-output-files.xcfilelist index 410fd64..8916018 100644 --- a/ReadViewDemo/Pods/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-Release-output-files.xcfilelist +++ b/ReadViewDemo/Pods/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-Release-output-files.xcfilelist @@ -1,4 +1,4 @@ ${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/DTCoreText.framework ${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/DTFoundation.framework -${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RDReaderView.framework +${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RDEpubReaderView.framework ${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ZIPFoundation.framework \ No newline at end of file diff --git a/ReadViewDemo/Pods/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks.sh b/ReadViewDemo/Pods/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks.sh index 28d1c76..a8b8ff0 100755 --- a/ReadViewDemo/Pods/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks.sh +++ b/ReadViewDemo/Pods/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks.sh @@ -178,13 +178,13 @@ code_sign_if_enabled() { if [[ "$CONFIGURATION" == "Debug" ]]; then install_framework "${BUILT_PRODUCTS_DIR}/DTCoreText/DTCoreText.framework" install_framework "${BUILT_PRODUCTS_DIR}/DTFoundation/DTFoundation.framework" - install_framework "${BUILT_PRODUCTS_DIR}/RDReaderView/RDReaderView.framework" + install_framework "${BUILT_PRODUCTS_DIR}/RDEpubReaderView/RDEpubReaderView.framework" install_framework "${BUILT_PRODUCTS_DIR}/ZIPFoundation/ZIPFoundation.framework" fi if [[ "$CONFIGURATION" == "Release" ]]; then install_framework "${BUILT_PRODUCTS_DIR}/DTCoreText/DTCoreText.framework" install_framework "${BUILT_PRODUCTS_DIR}/DTFoundation/DTFoundation.framework" - install_framework "${BUILT_PRODUCTS_DIR}/RDReaderView/RDReaderView.framework" + install_framework "${BUILT_PRODUCTS_DIR}/RDEpubReaderView/RDEpubReaderView.framework" install_framework "${BUILT_PRODUCTS_DIR}/ZIPFoundation/ZIPFoundation.framework" fi if [ "${COCOAPODS_PARALLEL_CODE_SIGN}" == "true" ]; then diff --git a/ReadViewDemo/Pods/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo.debug.xcconfig b/ReadViewDemo/Pods/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo.debug.xcconfig index de3b625..e6dde3b 100644 --- a/ReadViewDemo/Pods/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo.debug.xcconfig +++ b/ReadViewDemo/Pods/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo.debug.xcconfig @@ -1,12 +1,12 @@ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO -FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "${PODS_CONFIGURATION_BUILD_DIR}/RDReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation" +FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation" GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1 -HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText/DTCoreText.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation/DTFoundation.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDReaderView/RDReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation/ZIPFoundation.framework/Headers" "$(SDKROOT)/usr/include/libxml2" +HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText/DTCoreText.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation/DTFoundation.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView/RDEpubReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation/ZIPFoundation.framework/Headers" "$(SDKROOT)/usr/include/libxml2" LD_RUNPATH_SEARCH_PATHS = $(inherited) /usr/lib/swift '@executable_path/Frameworks' '@loader_path/Frameworks' LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift -OTHER_LDFLAGS = $(inherited) -l"xml2" -framework "CoreGraphics" -framework "CoreText" -framework "DTCoreText" -framework "DTFoundation" -framework "ImageIO" -framework "MediaPlayer" -framework "QuartzCore" -framework "RDReaderView" -framework "ZIPFoundation" -OTHER_MODULE_VERIFIER_FLAGS = $(inherited) "-F${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "-F${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation" +OTHER_LDFLAGS = $(inherited) -l"xml2" -framework "CoreGraphics" -framework "CoreText" -framework "DTCoreText" -framework "DTFoundation" -framework "ImageIO" -framework "MediaPlayer" -framework "QuartzCore" -framework "RDEpubReaderView" -framework "ZIPFoundation" +OTHER_MODULE_VERIFIER_FLAGS = $(inherited) "-F${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "-F${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation" OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS PODS_BUILD_DIR = ${BUILD_DIR} PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME) diff --git a/ReadViewDemo/Pods/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo.release.xcconfig b/ReadViewDemo/Pods/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo.release.xcconfig index de3b625..e6dde3b 100644 --- a/ReadViewDemo/Pods/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo.release.xcconfig +++ b/ReadViewDemo/Pods/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo.release.xcconfig @@ -1,12 +1,12 @@ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO -FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "${PODS_CONFIGURATION_BUILD_DIR}/RDReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation" +FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation" GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1 -HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText/DTCoreText.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation/DTFoundation.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDReaderView/RDReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation/ZIPFoundation.framework/Headers" "$(SDKROOT)/usr/include/libxml2" +HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText/DTCoreText.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation/DTFoundation.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView/RDEpubReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation/ZIPFoundation.framework/Headers" "$(SDKROOT)/usr/include/libxml2" LD_RUNPATH_SEARCH_PATHS = $(inherited) /usr/lib/swift '@executable_path/Frameworks' '@loader_path/Frameworks' LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift -OTHER_LDFLAGS = $(inherited) -l"xml2" -framework "CoreGraphics" -framework "CoreText" -framework "DTCoreText" -framework "DTFoundation" -framework "ImageIO" -framework "MediaPlayer" -framework "QuartzCore" -framework "RDReaderView" -framework "ZIPFoundation" -OTHER_MODULE_VERIFIER_FLAGS = $(inherited) "-F${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "-F${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation" +OTHER_LDFLAGS = $(inherited) -l"xml2" -framework "CoreGraphics" -framework "CoreText" -framework "DTCoreText" -framework "DTFoundation" -framework "ImageIO" -framework "MediaPlayer" -framework "QuartzCore" -framework "RDEpubReaderView" -framework "ZIPFoundation" +OTHER_MODULE_VERIFIER_FLAGS = $(inherited) "-F${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "-F${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation" OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS PODS_BUILD_DIR = ${BUILD_DIR} PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME) diff --git a/ReadViewDemo/Pods/Target Support Files/RDReaderView/RDReaderView-Info.plist b/ReadViewDemo/Pods/Target Support Files/RDEpubReaderView/RDEpubReaderView-Info.plist similarity index 100% rename from ReadViewDemo/Pods/Target Support Files/RDReaderView/RDReaderView-Info.plist rename to ReadViewDemo/Pods/Target Support Files/RDEpubReaderView/RDEpubReaderView-Info.plist diff --git a/ReadViewDemo/Pods/Target Support Files/RDEpubReaderView/RDEpubReaderView-dummy.m b/ReadViewDemo/Pods/Target Support Files/RDEpubReaderView/RDEpubReaderView-dummy.m new file mode 100644 index 0000000..ea51046 --- /dev/null +++ b/ReadViewDemo/Pods/Target Support Files/RDEpubReaderView/RDEpubReaderView-dummy.m @@ -0,0 +1,5 @@ +#import +@interface PodsDummy_RDEpubReaderView : NSObject +@end +@implementation PodsDummy_RDEpubReaderView +@end diff --git a/ReadViewDemo/Pods/Target Support Files/RDReaderView/RDReaderView-prefix.pch b/ReadViewDemo/Pods/Target Support Files/RDEpubReaderView/RDEpubReaderView-prefix.pch similarity index 100% rename from ReadViewDemo/Pods/Target Support Files/RDReaderView/RDReaderView-prefix.pch rename to ReadViewDemo/Pods/Target Support Files/RDEpubReaderView/RDEpubReaderView-prefix.pch diff --git a/ReadViewDemo/Pods/Target Support Files/RDReaderView/RDReaderView-umbrella.h b/ReadViewDemo/Pods/Target Support Files/RDEpubReaderView/RDEpubReaderView-umbrella.h similarity index 60% rename from ReadViewDemo/Pods/Target Support Files/RDReaderView/RDReaderView-umbrella.h rename to ReadViewDemo/Pods/Target Support Files/RDEpubReaderView/RDEpubReaderView-umbrella.h index b5bb735..f94b53e 100644 --- a/ReadViewDemo/Pods/Target Support Files/RDReaderView/RDReaderView-umbrella.h +++ b/ReadViewDemo/Pods/Target Support Files/RDEpubReaderView/RDEpubReaderView-umbrella.h @@ -11,6 +11,6 @@ #endif -FOUNDATION_EXPORT double RDReaderViewVersionNumber; -FOUNDATION_EXPORT const unsigned char RDReaderViewVersionString[]; +FOUNDATION_EXPORT double RDEpubReaderViewVersionNumber; +FOUNDATION_EXPORT const unsigned char RDEpubReaderViewVersionString[]; diff --git a/ReadViewDemo/Pods/Target Support Files/RDReaderView/RDReaderView.debug.xcconfig b/ReadViewDemo/Pods/Target Support Files/RDEpubReaderView/RDEpubReaderView.debug.xcconfig similarity index 93% rename from ReadViewDemo/Pods/Target Support Files/RDReaderView/RDReaderView.debug.xcconfig rename to ReadViewDemo/Pods/Target Support Files/RDEpubReaderView/RDEpubReaderView.debug.xcconfig index dfe5382..8bc5cb6 100644 --- a/ReadViewDemo/Pods/Target Support Files/RDReaderView/RDReaderView.debug.xcconfig +++ b/ReadViewDemo/Pods/Target Support Files/RDEpubReaderView/RDEpubReaderView.debug.xcconfig @@ -1,5 +1,5 @@ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO -CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RDReaderView +CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation" GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1 LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift diff --git a/ReadViewDemo/Pods/Target Support Files/RDEpubReaderView/RDEpubReaderView.modulemap b/ReadViewDemo/Pods/Target Support Files/RDEpubReaderView/RDEpubReaderView.modulemap new file mode 100644 index 0000000..8f70124 --- /dev/null +++ b/ReadViewDemo/Pods/Target Support Files/RDEpubReaderView/RDEpubReaderView.modulemap @@ -0,0 +1,6 @@ +framework module RDEpubReaderView { + umbrella header "RDEpubReaderView-umbrella.h" + + export * + module * { export * } +} diff --git a/ReadViewDemo/Pods/Target Support Files/RDReaderView/RDReaderView.release.xcconfig b/ReadViewDemo/Pods/Target Support Files/RDEpubReaderView/RDEpubReaderView.release.xcconfig similarity index 93% rename from ReadViewDemo/Pods/Target Support Files/RDReaderView/RDReaderView.release.xcconfig rename to ReadViewDemo/Pods/Target Support Files/RDEpubReaderView/RDEpubReaderView.release.xcconfig index dfe5382..8bc5cb6 100644 --- a/ReadViewDemo/Pods/Target Support Files/RDReaderView/RDReaderView.release.xcconfig +++ b/ReadViewDemo/Pods/Target Support Files/RDEpubReaderView/RDEpubReaderView.release.xcconfig @@ -1,5 +1,5 @@ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO -CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RDReaderView +CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation" GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1 LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift diff --git a/ReadViewDemo/Pods/Target Support Files/RDReaderView/ResourceBundle-RDReaderViewAssets-RDReaderView-Info.plist b/ReadViewDemo/Pods/Target Support Files/RDEpubReaderView/ResourceBundle-RDEpubReaderViewAssets-RDEpubReaderView-Info.plist similarity index 100% rename from ReadViewDemo/Pods/Target Support Files/RDReaderView/ResourceBundle-RDReaderViewAssets-RDReaderView-Info.plist rename to ReadViewDemo/Pods/Target Support Files/RDEpubReaderView/ResourceBundle-RDEpubReaderViewAssets-RDEpubReaderView-Info.plist diff --git a/ReadViewDemo/Pods/Target Support Files/RDReaderView/RDReaderView-dummy.m b/ReadViewDemo/Pods/Target Support Files/RDReaderView/RDReaderView-dummy.m deleted file mode 100644 index 9e84fe7..0000000 --- a/ReadViewDemo/Pods/Target Support Files/RDReaderView/RDReaderView-dummy.m +++ /dev/null @@ -1,5 +0,0 @@ -#import -@interface PodsDummy_RDReaderView : NSObject -@end -@implementation PodsDummy_RDReaderView -@end diff --git a/ReadViewDemo/Pods/Target Support Files/RDReaderView/RDReaderView.modulemap b/ReadViewDemo/Pods/Target Support Files/RDReaderView/RDReaderView.modulemap deleted file mode 100644 index bc144f4..0000000 --- a/ReadViewDemo/Pods/Target Support Files/RDReaderView/RDReaderView.modulemap +++ /dev/null @@ -1,6 +0,0 @@ -framework module RDReaderView { - umbrella header "RDReaderView-umbrella.h" - - export * - module * { export * } -} diff --git a/ReadViewDemo/ReadViewDemo.xcodeproj/project.pbxproj b/ReadViewDemo/ReadViewDemo.xcodeproj/project.pbxproj index b166ea4..4546788 100644 --- a/ReadViewDemo/ReadViewDemo.xcodeproj/project.pbxproj +++ b/ReadViewDemo/ReadViewDemo.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ 1A2B3C4D00000014AABBCC01 /* FanrenParseTimeTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000013AABBCC01 /* FanrenParseTimeTest.swift */; }; 1A2B3C4D00000015AABBCC01 /* AsyncChapterLoadingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000016AABBCC01 /* AsyncChapterLoadingTests.swift */; }; 1A2B3C4D00000021AABBCC01 /* MemoryFootprintTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000020AABBCC01 /* MemoryFootprintTests.swift */; }; + 1A2B3C4D00000030AABBCC01 /* FixedLayoutRotationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000031AABBCC01 /* FixedLayoutRotationTests.swift */; }; 23BB1155EA379786DAA10A89 /* DisplayTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */; }; 369C9658D870DCAFC17EB7F7 /* SearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFE01DBDCB8D790832A4DE3F /* SearchTests.swift */; }; 3BC5C96D7A0ACF35F2192CC7 /* XCUIApplication+Launch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74B4C44287820D68ED6570F8 /* XCUIApplication+Launch.swift */; }; @@ -55,6 +56,7 @@ 1A2B3C4D00000013AABBCC01 /* FanrenParseTimeTest.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FanrenParseTimeTest.swift; sourceTree = ""; }; 1A2B3C4D00000016AABBCC01 /* AsyncChapterLoadingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AsyncChapterLoadingTests.swift; sourceTree = ""; }; 1A2B3C4D00000020AABBCC01 /* MemoryFootprintTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MemoryFootprintTests.swift; sourceTree = ""; }; + 1A2B3C4D00000031AABBCC01 /* FixedLayoutRotationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FixedLayoutRotationTests.swift; sourceTree = ""; }; 201C2B482287866487EFAE66 /* SettingsPanelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SettingsPanelTests.swift; sourceTree = ""; }; 20BD15E5D8F04E7E9A239E14 /* ReaderAnnotationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderAnnotationTests.swift; sourceTree = ""; }; 3A43AED288BFCA3ADBA97DD7 /* Pods-ReadViewDemo.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ReadViewDemo.release.xcconfig"; path = "Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo.release.xcconfig"; sourceTree = ""; }; @@ -196,6 +198,7 @@ CFE01DBDCB8D790832A4DE3F /* SearchTests.swift */, 1A2B3C4D00000020AABBCC01 /* MemoryFootprintTests.swift */, 1A2B3C4D00000016AABBCC01 /* AsyncChapterLoadingTests.swift */, + 1A2B3C4D00000031AABBCC01 /* FixedLayoutRotationTests.swift */, ); path = ReaderUITests; sourceTree = ""; @@ -326,10 +329,14 @@ 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"; @@ -362,6 +369,7 @@ 369C9658D870DCAFC17EB7F7 /* SearchTests.swift in Sources */, 1A2B3C4D00000021AABBCC01 /* MemoryFootprintTests.swift in Sources */, 1A2B3C4D00000015AABBCC01 /* AsyncChapterLoadingTests.swift in Sources */, + 1A2B3C4D00000030AABBCC01 /* FixedLayoutRotationTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ReadViewDemo/ReadViewDemo/ViewController.swift b/ReadViewDemo/ReadViewDemo/ViewController.swift index f667288..1bd27ff 100644 --- a/ReadViewDemo/ReadViewDemo/ViewController.swift +++ b/ReadViewDemo/ReadViewDemo/ViewController.swift @@ -1,5 +1,5 @@ import UIKit -import RDReaderView +import RDEpubReaderView final class ViewController: UIViewController { private struct DemoBook: Equatable { @@ -10,10 +10,10 @@ final class ViewController: UIViewController { private struct LaunchAutomationPlan { let bookTitleQuery: String - let displayType: RDReaderView.DisplayType? + let displayType: RDEpubReaderView.DisplayType? let pageNumber: Int? let resetsReaderState: Bool - let displaySequence: [RDReaderView.DisplayType] + let displaySequence: [RDEpubReaderView.DisplayType] let stepDelay: TimeInterval let windowSize: Int? let concurrency: Int? @@ -23,7 +23,7 @@ final class ViewController: UIViewController { let enablesVerboseWebLogs: Bool let searchKeyword: String? - nonisolated private static func parseDisplayType(_ rawValue: String) -> RDReaderView.DisplayType? { + nonisolated private static func parseDisplayType(_ rawValue: String) -> RDEpubReaderView.DisplayType? { switch rawValue.lowercased() { case "pagecurl", "curl": return .pageCurl @@ -213,8 +213,8 @@ final class ViewController: UIViewController { _ book: DemoBook, configuration: RDEPUBReaderConfiguration = .default, automationPlan: LaunchAutomationPlan? = nil - ) -> RDURLReaderController { - let controller = RDURLReaderController(bookURL: book.fileURL, epubConfiguration: configuration) + ) -> RDEpubURLReaderController { + let controller = RDEpubURLReaderController(bookURL: book.fileURL, epubConfiguration: configuration) controller.title = book.title if let navigationController { diff --git a/ReadViewDemo/ReadViewDemo/book/回归验证样本.txt b/ReadViewDemo/ReadViewDemo/book/回归验证样本.txt index 69d697e..dba3281 100644 --- a/ReadViewDemo/ReadViewDemo/book/回归验证样本.txt +++ b/ReadViewDemo/ReadViewDemo/book/回归验证样本.txt @@ -2,7 +2,7 @@ 这是 ReadViewSDK 的 Phase 5 纯文本验证样本。 -它用于覆盖 RDURLReaderController 与 RDPlainTextBookBuilder 的 TXT 路径, +它用于覆盖 RDEpubURLReaderController 与 RDEpubPlainTextBookBuilder 的 TXT 路径, 确认纯文本分页、打开主流程,以及后续 reader 壳层回归不会失效。 为了让 UI 自动化可以稳定验证翻页、长按选区、书签和目录行为, diff --git a/ReadViewDemo/ReadViewDemoUITests/ReaderUITests/AsyncChapterLoadingTests.swift b/ReadViewDemo/ReadViewDemoUITests/ReaderUITests/AsyncChapterLoadingTests.swift index 81fd61d..06897d7 100644 --- a/ReadViewDemo/ReadViewDemoUITests/ReaderUITests/AsyncChapterLoadingTests.swift +++ b/ReadViewDemo/ReadViewDemoUITests/ReaderUITests/AsyncChapterLoadingTests.swift @@ -394,6 +394,10 @@ final class AsyncChapterLoadingTests: XCTestCase { for _ in 0..<10 { paging.swipeLeft() RunLoop.current.run(until: Date().addingTimeInterval(0.4)) + XCTAssertTrue( + app.activityIndicators[IDs.readerLoadingSpinner].waitForNonExistence(timeout: 10), + "横屏翻页后的页面不应一直停留在 loading" + ) } } diff --git a/ReadViewDemo/ReadViewDemoUITests/ReaderUITests/FixedLayoutRotationTests.swift b/ReadViewDemo/ReadViewDemoUITests/ReaderUITests/FixedLayoutRotationTests.swift new file mode 100644 index 0000000..363c36f --- /dev/null +++ b/ReadViewDemo/ReadViewDemoUITests/ReaderUITests/FixedLayoutRotationTests.swift @@ -0,0 +1,33 @@ +import XCTest + +/// 固定布局(pre-paginated)书籍的旋转回归: +/// 每次旋转会使预加载缓存签名失效并批量丢弃 WKWebView 页面, +/// 历史上曾因 script message handler 保留环导致 WebView 泄漏累积, +/// 多次旋转后在 CA commit 阶段 abort(CA::Render::Encoder::grow)。 +final class FixedLayoutRotationTests: XCTestCase { + private let app = XCUIApplication() + + override func setUpWithError() throws { + continueAfterFailure = false + XCUIDevice.shared.orientation = .portrait + } + + override func tearDownWithError() throws { + XCUIDevice.shared.orientation = .portrait + } + + func testFixedLayoutBookSurvivesRepeatedRotation() { + app.launchAndOpenSampleBook(bookTitleQuery: "熊爷爷") + app.waitForReader() + + for _ in 0..<3 { + XCUIDevice.shared.orientation = .landscapeLeft + RunLoop.current.run(until: Date().addingTimeInterval(2)) + XCUIDevice.shared.orientation = .portrait + RunLoop.current.run(until: Date().addingTimeInterval(2)) + } + + XCTAssertEqual(app.state, .runningForeground, "反复旋转后 app 已退出(疑似崩溃)") + app.waitForReader() + } +} diff --git a/ReadViewDemo/ViewController.swift b/ReadViewDemo/ViewController.swift index 2483987..b867dc6 100644 --- a/ReadViewDemo/ViewController.swift +++ b/ReadViewDemo/ViewController.swift @@ -1,5 +1,5 @@ import UIKit -import RDReaderView +import RDEpubReaderView final class ViewController: UIViewController { private struct DemoBook: Equatable { @@ -10,12 +10,12 @@ final class ViewController: UIViewController { private struct LaunchAutomationPlan { let bookTitleQuery: String - let displayType: RDReaderView.DisplayType? + let displayType: RDEpubReaderView.DisplayType? let pageNumber: Int? - let displaySequence: [RDReaderView.DisplayType] + let displaySequence: [RDEpubReaderView.DisplayType] let stepDelay: TimeInterval - nonisolated private static func parseDisplayType(_ rawValue: String) -> RDReaderView.DisplayType? { + nonisolated private static func parseDisplayType(_ rawValue: String) -> RDEpubReaderView.DisplayType? { switch rawValue.lowercased() { case "pagecurl", "curl": return .pageCurl @@ -328,7 +328,7 @@ final class ViewController: UIViewController { ) -> (report: BookValidationReport, diagnostics: [String]) { let fileExtension = book.fileURL.pathExtension.lowercased() if fileExtension == "txt" { - let builder = RDPlainTextBookBuilder() + let builder = RDEpubPlainTextBookBuilder() do { let textBook = try builder.build(textFileURL: book.fileURL, pageSize: pageSize, style: style) let passed = !textBook.pages.isEmpty && !textBook.chapters.isEmpty @@ -646,8 +646,8 @@ final class ViewController: UIViewController { _ book: DemoBook, configuration: RDEPUBReaderConfiguration = .default, automationPlan: LaunchAutomationPlan? = nil - ) -> RDURLReaderController { - let controller = RDURLReaderController(bookURL: book.fileURL, epubConfiguration: configuration) + ) -> RDEpubURLReaderController { + let controller = RDEpubURLReaderController(bookURL: book.fileURL, epubConfiguration: configuration) controller.title = book.title if let navigationController { diff --git a/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFI.swift b/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFI.swift similarity index 100% rename from Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFI.swift rename to Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFI.swift diff --git a/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFICompatibility.swift b/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFICompatibility.swift similarity index 100% rename from Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFICompatibility.swift rename to Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFICompatibility.swift diff --git a/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIDOMPathBuilder.swift b/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIDOMPathBuilder.swift similarity index 100% rename from Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIDOMPathBuilder.swift rename to Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIDOMPathBuilder.swift diff --git a/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIError.swift b/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIError.swift similarity index 100% rename from Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIError.swift rename to Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIError.swift diff --git a/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIGenerator.swift b/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIGenerator.swift similarity index 100% rename from Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIGenerator.swift rename to Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIGenerator.swift diff --git a/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIMap.swift b/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIMap.swift similarity index 100% rename from Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIMap.swift rename to Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIMap.swift diff --git a/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIParser.swift b/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIParser.swift similarity index 100% rename from Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIParser.swift rename to Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIParser.swift diff --git a/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIPath.swift b/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIPath.swift similarity index 100% rename from Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIPath.swift rename to Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIPath.swift diff --git a/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIRange.swift b/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIRange.swift similarity index 100% rename from Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIRange.swift rename to Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIRange.swift diff --git a/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIRecoveryEngine.swift b/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIRecoveryEngine.swift similarity index 100% rename from Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIRecoveryEngine.swift rename to Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIRecoveryEngine.swift diff --git a/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIResolver.swift b/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIResolver.swift similarity index 100% rename from Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIResolver.swift rename to Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIResolver.swift diff --git a/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFISerializer.swift b/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFISerializer.swift similarity index 100% rename from Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFISerializer.swift rename to Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFISerializer.swift diff --git a/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFITextAssertion.swift b/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFITextAssertion.swift similarity index 100% rename from Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFITextAssertion.swift rename to Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFITextAssertion.swift diff --git a/Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIUtilities.swift b/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIUtilities.swift similarity index 100% rename from Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIUtilities.swift rename to Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIUtilities.swift diff --git a/Sources/RDReaderView/EPUBCore/Models/RDEPUBAnnotationModels.swift b/Sources/RDEpubReaderView/EPUBCore/Models/RDEPUBAnnotationModels.swift similarity index 99% rename from Sources/RDReaderView/EPUBCore/Models/RDEPUBAnnotationModels.swift rename to Sources/RDEpubReaderView/EPUBCore/Models/RDEPUBAnnotationModels.swift index d04c7dc..9d57aee 100644 --- a/Sources/RDReaderView/EPUBCore/Models/RDEPUBAnnotationModels.swift +++ b/Sources/RDEpubReaderView/EPUBCore/Models/RDEPUBAnnotationModels.swift @@ -1,9 +1,9 @@ import Foundation import UIKit -public let kRDEPUBHighlightAttributeName = NSAttributedString.Key("com.rdreader.highlight") +public let kRDEPUBHighlightAttributeName = NSAttributedString.Key("com.RDEpubReader.highlight") -public let kRDEPUBUnderlineAttributeName = NSAttributedString.Key("com.rdreader.underline") +public let kRDEPUBUnderlineAttributeName = NSAttributedString.Key("com.RDEpubReader.underline") public struct RDEPUBSelection: Codable, Equatable { diff --git a/Sources/RDReaderView/EPUBCore/Models/RDEPUBPaginationModels.swift b/Sources/RDEpubReaderView/EPUBCore/Models/RDEPUBPaginationModels.swift similarity index 100% rename from Sources/RDReaderView/EPUBCore/Models/RDEPUBPaginationModels.swift rename to Sources/RDEpubReaderView/EPUBCore/Models/RDEPUBPaginationModels.swift diff --git a/Sources/RDReaderView/EPUBCore/Models/RDEPUBReadingLocationModels.swift b/Sources/RDEpubReaderView/EPUBCore/Models/RDEPUBReadingLocationModels.swift similarity index 100% rename from Sources/RDReaderView/EPUBCore/Models/RDEPUBReadingLocationModels.swift rename to Sources/RDEpubReaderView/EPUBCore/Models/RDEPUBReadingLocationModels.swift diff --git a/Sources/RDReaderView/EPUBCore/Notes/RDEPUBNoteDetector.swift b/Sources/RDEpubReaderView/EPUBCore/Notes/RDEPUBNoteDetector.swift similarity index 100% rename from Sources/RDReaderView/EPUBCore/Notes/RDEPUBNoteDetector.swift rename to Sources/RDEpubReaderView/EPUBCore/Notes/RDEPUBNoteDetector.swift diff --git a/Sources/RDReaderView/EPUBCore/Notes/RDEPUBNoteModels.swift b/Sources/RDEpubReaderView/EPUBCore/Notes/RDEPUBNoteModels.swift similarity index 100% rename from Sources/RDReaderView/EPUBCore/Notes/RDEPUBNoteModels.swift rename to Sources/RDEpubReaderView/EPUBCore/Notes/RDEPUBNoteModels.swift diff --git a/Sources/RDReaderView/EPUBCore/Notes/RDEPUBNoteResolver.swift b/Sources/RDEpubReaderView/EPUBCore/Notes/RDEPUBNoteResolver.swift similarity index 100% rename from Sources/RDReaderView/EPUBCore/Notes/RDEPUBNoteResolver.swift rename to Sources/RDEpubReaderView/EPUBCore/Notes/RDEPUBNoteResolver.swift diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBAssetRepository.swift b/Sources/RDEpubReaderView/EPUBCore/RDEPUBAssetRepository.swift similarity index 87% rename from Sources/RDReaderView/EPUBCore/RDEPUBAssetRepository.swift rename to Sources/RDEpubReaderView/EPUBCore/RDEPUBAssetRepository.swift index a0304c1..db54d4d 100644 --- a/Sources/RDReaderView/EPUBCore/RDEPUBAssetRepository.swift +++ b/Sources/RDEpubReaderView/EPUBCore/RDEPUBAssetRepository.swift @@ -60,9 +60,11 @@ enum RDEPUBAssetRepository { private static var resolvedBundle: Bundle? = { let hostBundles = [Bundle(for: RDEPUBAssetBundleToken.self), Bundle.main] + Bundle.allFrameworks + Bundle.allBundles for hostBundle in hostBundles { - if let url = hostBundle.url(forResource: "RDReaderViewAssets", withExtension: "bundle"), - let bundle = Bundle(url: url) { - return bundle + for bundleName in ["RDEpubReaderViewAssets", "RDReaderViewAssets"] { + if let url = hostBundle.url(forResource: bundleName, withExtension: "bundle"), + let bundle = Bundle(url: url) { + return bundle + } } } return nil diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBFixedLayoutTemplate.swift b/Sources/RDEpubReaderView/EPUBCore/RDEPUBFixedLayoutTemplate.swift similarity index 100% rename from Sources/RDReaderView/EPUBCore/RDEPUBFixedLayoutTemplate.swift rename to Sources/RDEpubReaderView/EPUBCore/RDEPUBFixedLayoutTemplate.swift diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBJavaScriptBridge.swift b/Sources/RDEpubReaderView/EPUBCore/RDEPUBJavaScriptBridge.swift similarity index 100% rename from Sources/RDReaderView/EPUBCore/RDEPUBJavaScriptBridge.swift rename to Sources/RDEpubReaderView/EPUBCore/RDEPUBJavaScriptBridge.swift diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBModels.swift b/Sources/RDEpubReaderView/EPUBCore/RDEPUBModels.swift similarity index 100% rename from Sources/RDReaderView/EPUBCore/RDEPUBModels.swift rename to Sources/RDEpubReaderView/EPUBCore/RDEPUBModels.swift diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBNavigatorLayoutContext.swift b/Sources/RDEpubReaderView/EPUBCore/RDEPUBNavigatorLayoutContext.swift similarity index 100% rename from Sources/RDReaderView/EPUBCore/RDEPUBNavigatorLayoutContext.swift rename to Sources/RDEpubReaderView/EPUBCore/RDEPUBNavigatorLayoutContext.swift diff --git a/Sources/RDEpubReaderView/EPUBCore/RDEPUBPaginator.swift b/Sources/RDEpubReaderView/EPUBCore/RDEPUBPaginator.swift new file mode 100644 index 0000000..63e662e --- /dev/null +++ b/Sources/RDEpubReaderView/EPUBCore/RDEPUBPaginator.swift @@ -0,0 +1,379 @@ + +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/RDReaderView/EPUBCore/RDEPUBParser+Archive.swift b/Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+Archive.swift similarity index 100% rename from Sources/RDReaderView/EPUBCore/RDEPUBParser+Archive.swift rename to Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+Archive.swift diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBParser+Package.swift b/Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+Package.swift similarity index 96% rename from Sources/RDReaderView/EPUBCore/RDEPUBParser+Package.swift rename to Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+Package.swift index cfe4892..d734956 100644 --- a/Sources/RDReaderView/EPUBCore/RDEPUBParser+Package.swift +++ b/Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+Package.swift @@ -111,11 +111,18 @@ final class OPFPackageParserDelegate: NSObject, XMLParserDelegate { 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: Dictionary(uniqueKeysWithValues: manifest.map { ($0.id, $0) }), + manifestByID: manifestByID, spineReferences: spineReferences, ncxItem: manifest.first(where: { $0.id == ncxID || $0.isNCX }), navigationItem: manifest.first(where: { $0.isNavigationDocument }) diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBParser+ReadingProfile.swift b/Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+ReadingProfile.swift similarity index 100% rename from Sources/RDReaderView/EPUBCore/RDEPUBParser+ReadingProfile.swift rename to Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+ReadingProfile.swift diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBParser+Resources.swift b/Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+Resources.swift similarity index 100% rename from Sources/RDReaderView/EPUBCore/RDEPUBParser+Resources.swift rename to Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+Resources.swift diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBParser+TOC.swift b/Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+TOC.swift similarity index 100% rename from Sources/RDReaderView/EPUBCore/RDEPUBParser+TOC.swift rename to Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+TOC.swift diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBParser.swift b/Sources/RDEpubReaderView/EPUBCore/RDEPUBParser.swift similarity index 97% rename from Sources/RDReaderView/EPUBCore/RDEPUBParser.swift rename to Sources/RDEpubReaderView/EPUBCore/RDEPUBParser.swift index 1126233..a6c220e 100644 --- a/Sources/RDReaderView/EPUBCore/RDEPUBParser.swift +++ b/Sources/RDEpubReaderView/EPUBCore/RDEPUBParser.swift @@ -174,7 +174,8 @@ public final class RDEPUBParser { let packageDocument = delegate.packageDocument() self.opfURL = opfURL self.metadata = packageDocument.metadata - self.manifest = Dictionary(uniqueKeysWithValues: packageDocument.manifest.map { ($0.id, $0) }) + // 与 packageDocument 使用同一份已去重索引,避免重复 manifest id 再次触发断言。 + self.manifest = packageDocument.manifestByID self.spine = try buildSpine(from: packageDocument, opfURL: opfURL) self.tableOfContents = parseTOC(from: packageDocument, opfURL: opfURL) } @@ -206,4 +207,4 @@ private extension URL { } return totalSize } -} \ No newline at end of file +} diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBPreferences.swift b/Sources/RDEpubReaderView/EPUBCore/RDEPUBPreferences.swift similarity index 100% rename from Sources/RDReaderView/EPUBCore/RDEPUBPreferences.swift rename to Sources/RDEpubReaderView/EPUBCore/RDEPUBPreferences.swift diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBPublication.swift b/Sources/RDEpubReaderView/EPUBCore/RDEPUBPublication.swift similarity index 100% rename from Sources/RDReaderView/EPUBCore/RDEPUBPublication.swift rename to Sources/RDEpubReaderView/EPUBCore/RDEPUBPublication.swift diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBReadingSession.swift b/Sources/RDEpubReaderView/EPUBCore/RDEPUBReadingSession.swift similarity index 100% rename from Sources/RDReaderView/EPUBCore/RDEPUBReadingSession.swift rename to Sources/RDEpubReaderView/EPUBCore/RDEPUBReadingSession.swift diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBRenderRequest.swift b/Sources/RDEpubReaderView/EPUBCore/RDEPUBRenderRequest.swift similarity index 100% rename from Sources/RDReaderView/EPUBCore/RDEPUBRenderRequest.swift rename to Sources/RDEpubReaderView/EPUBCore/RDEPUBRenderRequest.swift diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBResourceDataProvider.swift b/Sources/RDEpubReaderView/EPUBCore/RDEPUBResourceDataProvider.swift similarity index 100% rename from Sources/RDReaderView/EPUBCore/RDEPUBResourceDataProvider.swift rename to Sources/RDEpubReaderView/EPUBCore/RDEPUBResourceDataProvider.swift diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBResourceResolver.swift b/Sources/RDEpubReaderView/EPUBCore/RDEPUBResourceResolver.swift similarity index 100% rename from Sources/RDReaderView/EPUBCore/RDEPUBResourceResolver.swift rename to Sources/RDEpubReaderView/EPUBCore/RDEPUBResourceResolver.swift diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBResourceURLSchemeHandler.swift b/Sources/RDEpubReaderView/EPUBCore/RDEPUBResourceURLSchemeHandler.swift similarity index 100% rename from Sources/RDReaderView/EPUBCore/RDEPUBResourceURLSchemeHandler.swift rename to Sources/RDEpubReaderView/EPUBCore/RDEPUBResourceURLSchemeHandler.swift diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBSafeArea.swift b/Sources/RDEpubReaderView/EPUBCore/RDEPUBSafeArea.swift similarity index 100% rename from Sources/RDReaderView/EPUBCore/RDEPUBSafeArea.swift rename to Sources/RDEpubReaderView/EPUBCore/RDEPUBSafeArea.swift diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBSearchEngine.swift b/Sources/RDEpubReaderView/EPUBCore/RDEPUBSearchEngine.swift similarity index 100% rename from Sources/RDReaderView/EPUBCore/RDEPUBSearchEngine.swift rename to Sources/RDEpubReaderView/EPUBCore/RDEPUBSearchEngine.swift diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBSearchModels.swift b/Sources/RDEpubReaderView/EPUBCore/RDEPUBSearchModels.swift similarity index 100% rename from Sources/RDReaderView/EPUBCore/RDEPUBSearchModels.swift rename to Sources/RDEpubReaderView/EPUBCore/RDEPUBSearchModels.swift diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBStyleSheetBuilder.swift b/Sources/RDEpubReaderView/EPUBCore/RDEPUBStyleSheetBuilder.swift similarity index 100% rename from Sources/RDReaderView/EPUBCore/RDEPUBStyleSheetBuilder.swift rename to Sources/RDEpubReaderView/EPUBCore/RDEPUBStyleSheetBuilder.swift diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBTextAnchor.swift b/Sources/RDEpubReaderView/EPUBCore/RDEPUBTextAnchor.swift similarity index 100% rename from Sources/RDReaderView/EPUBCore/RDEPUBTextAnchor.swift rename to Sources/RDEpubReaderView/EPUBCore/RDEPUBTextAnchor.swift diff --git a/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+Configuration.swift b/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+Configuration.swift new file mode 100644 index 0000000..b8bbe85 --- /dev/null +++ b/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+Configuration.swift @@ -0,0 +1,132 @@ + +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() + let messageHandlerProxy = RDEPUBWeakScriptMessageHandler(target: self) + RDEPUBJavaScriptBridge.messageNames.forEach { + userContentController.add(messageHandlerProxy, 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 + // 安全区自适应会把 Web 布局视口缩小(横屏左右各 59pt、底部 21pt), + // 固定布局页面随之按缩小后的视口适配,产生大边距/中缝。 + // 阅读区域的安全区避让统一由 fixedContentInset / 排版 CSS 负责。 + webView.scrollView.contentInsetAdjustmentBehavior = .never + 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 + } +} + +/// M-08: WKUserContentController 强持有注册的 script message handler。 +/// 直接注册 RDEPUBWebView 自身会形成保留环 +/// (webView → userContentController → RDEPUBWebView → webView), +/// 被丢弃的页面 WebView 将永远无法释放。本代理弱持有真实 handler, +/// 保留环从一开始就不成立,RDEPUBWebView 可随最后一个持有者正常 deinit。 +private final class RDEPUBWeakScriptMessageHandler: NSObject, WKScriptMessageHandler { + + private weak var target: WKScriptMessageHandler? + + init(target: WKScriptMessageHandler) { + self.target = target + } + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + target?.userContentController(userContentController, didReceive: message) + } +} diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBWebView+FixedLayout.swift b/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+FixedLayout.swift similarity index 100% rename from Sources/RDReaderView/EPUBCore/RDEPUBWebView+FixedLayout.swift rename to Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+FixedLayout.swift diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBWebView+JavaScriptBridge.swift b/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+JavaScriptBridge.swift similarity index 100% rename from Sources/RDReaderView/EPUBCore/RDEPUBWebView+JavaScriptBridge.swift rename to Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+JavaScriptBridge.swift diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBWebView+Reflowable.swift b/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+Reflowable.swift similarity index 100% rename from Sources/RDReaderView/EPUBCore/RDEPUBWebView+Reflowable.swift rename to Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+Reflowable.swift diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBWebView+Search.swift b/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+Search.swift similarity index 100% rename from Sources/RDReaderView/EPUBCore/RDEPUBWebView+Search.swift rename to Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+Search.swift diff --git a/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView.swift b/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView.swift new file mode 100644 index 0000000..9563b24 --- /dev/null +++ b/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView.swift @@ -0,0 +1,243 @@ + +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: script message handler 经 RDEPUBWeakScriptMessageHandler 弱代理注册 + // (见 configureWebViewIfNeeded),webView → userContentController → self + // 的保留环不再成立,deinit 会随最后一个持有者释放而正常触发。 + // reset() 仍可用于在丢弃路径上提前拆除 WKWebView(不必等 dealloc)。 + 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/RDReaderView/EPUBCore/RDEPUBWebViewDebug.swift b/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebViewDebug.swift similarity index 98% rename from Sources/RDReaderView/EPUBCore/RDEPUBWebViewDebug.swift rename to Sources/RDEpubReaderView/EPUBCore/RDEPUBWebViewDebug.swift index 410a21e..9486bdb 100644 --- a/Sources/RDReaderView/EPUBCore/RDEPUBWebViewDebug.swift +++ b/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebViewDebug.swift @@ -41,7 +41,7 @@ enum RDEPUBWebViewDebug { static func log(_ scope: String, message: String) { guard isEnabled else { return } - print("[RDReaderWK][\(scope)] \(message)") + print("[RDEpubReaderWK][\(scope)] \(message)") } static func logNavigationEvent(_ scope: String, webView: WKWebView?, event: String, url: URL? = nil, error: Error? = nil) { diff --git a/Sources/RDReaderView/EPUBCore/Resources/WeReadApi.js b/Sources/RDEpubReaderView/EPUBCore/Resources/WeReadApi.js similarity index 97% rename from Sources/RDReaderView/EPUBCore/Resources/WeReadApi.js rename to Sources/RDEpubReaderView/EPUBCore/Resources/WeReadApi.js index df154ac..521cf11 100644 --- a/Sources/RDReaderView/EPUBCore/Resources/WeReadApi.js +++ b/Sources/RDEpubReaderView/EPUBCore/Resources/WeReadApi.js @@ -2,7 +2,7 @@ if (window.WeReadApi) { return; } function bridge() { - return window.RDReaderBridge || null; + return window.RDEpubReaderBridge || null; } function sharedThemeCSS(theme) { diff --git a/Sources/RDReaderView/EPUBCore/Resources/cssInjector.js b/Sources/RDEpubReaderView/EPUBCore/Resources/cssInjector.js similarity index 100% rename from Sources/RDReaderView/EPUBCore/Resources/cssInjector.js rename to Sources/RDEpubReaderView/EPUBCore/Resources/cssInjector.js diff --git a/Sources/RDReaderView/EPUBCore/Resources/epub-bridge.js b/Sources/RDEpubReaderView/EPUBCore/Resources/epub-bridge.js similarity index 99% rename from Sources/RDReaderView/EPUBCore/Resources/epub-bridge.js rename to Sources/RDEpubReaderView/EPUBCore/Resources/epub-bridge.js index 3592717..f59c67e 100644 --- a/Sources/RDReaderView/EPUBCore/Resources/epub-bridge.js +++ b/Sources/RDEpubReaderView/EPUBCore/Resources/epub-bridge.js @@ -1,5 +1,5 @@ (function() { - if (window.RDReaderBridge) { return; } + if (window.RDEpubReaderBridge) { return; } window.addEventListener('error', function(event) { try { var message = event && (event.message || (event.error && event.error.message)) || 'Unknown JavaScript error'; @@ -478,7 +478,7 @@ node.addEventListener('error', scheduleRelayout, { passive: true }); }); } - window.RDReaderBridge = { + window.RDEpubReaderBridge = { applyPagination: function(styleText) { if (isFixedLayoutDocument()) { return; } if (window.RDInjectedCSS) { diff --git a/Sources/RDEpubReaderView/EPUBCore/Resources/epub-fixed-layout.html b/Sources/RDEpubReaderView/EPUBCore/Resources/epub-fixed-layout.html new file mode 100644 index 0000000..8855696 --- /dev/null +++ b/Sources/RDEpubReaderView/EPUBCore/Resources/epub-fixed-layout.html @@ -0,0 +1,266 @@ + + + + + + + +
{{PANES}}
+ + + diff --git a/Sources/RDReaderView/EPUBCore/Resources/rangy-core.js b/Sources/RDEpubReaderView/EPUBCore/Resources/rangy-core.js similarity index 100% rename from Sources/RDReaderView/EPUBCore/Resources/rangy-core.js rename to Sources/RDEpubReaderView/EPUBCore/Resources/rangy-core.js diff --git a/Sources/RDReaderView/EPUBCore/Resources/rangy-serializer.js b/Sources/RDEpubReaderView/EPUBCore/Resources/rangy-serializer.js similarity index 100% rename from Sources/RDReaderView/EPUBCore/Resources/rangy-serializer.js rename to Sources/RDEpubReaderView/EPUBCore/Resources/rangy-serializer.js diff --git a/Sources/RDReaderView/EPUBCore/Resources/wxread-dark.css b/Sources/RDEpubReaderView/EPUBCore/Resources/wxread-dark.css similarity index 100% rename from Sources/RDReaderView/EPUBCore/Resources/wxread-dark.css rename to Sources/RDEpubReaderView/EPUBCore/Resources/wxread-dark.css diff --git a/Sources/RDReaderView/EPUBCore/Resources/wxread-default.css b/Sources/RDEpubReaderView/EPUBCore/Resources/wxread-default.css similarity index 100% rename from Sources/RDReaderView/EPUBCore/Resources/wxread-default.css rename to Sources/RDEpubReaderView/EPUBCore/Resources/wxread-default.css diff --git a/Sources/RDReaderView/EPUBCore/Resources/wxread-replace-latin.css b/Sources/RDEpubReaderView/EPUBCore/Resources/wxread-replace-latin.css similarity index 100% rename from Sources/RDReaderView/EPUBCore/Resources/wxread-replace-latin.css rename to Sources/RDEpubReaderView/EPUBCore/Resources/wxread-replace-latin.css diff --git a/Sources/RDReaderView/EPUBCore/Resources/wxread-replace.css b/Sources/RDEpubReaderView/EPUBCore/Resources/wxread-replace.css similarity index 100% rename from Sources/RDReaderView/EPUBCore/Resources/wxread-replace.css rename to Sources/RDEpubReaderView/EPUBCore/Resources/wxread-replace.css diff --git a/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBBuildDiagnosticsReporter.swift b/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBBuildDiagnosticsReporter.swift similarity index 100% rename from Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBBuildDiagnosticsReporter.swift rename to Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBBuildDiagnosticsReporter.swift diff --git a/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBChapterTailNormalizer.swift b/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBChapterTailNormalizer.swift similarity index 100% rename from Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBChapterTailNormalizer.swift rename to Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBChapterTailNormalizer.swift diff --git a/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBPaginationCacheCoordinator.swift b/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBPaginationCacheCoordinator.swift similarity index 100% rename from Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBPaginationCacheCoordinator.swift rename to Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBPaginationCacheCoordinator.swift diff --git a/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookBuilder.swift b/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookBuilder.swift similarity index 100% rename from Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookBuilder.swift rename to Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookBuilder.swift diff --git a/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookCache.swift b/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookCache.swift similarity index 98% rename from Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookCache.swift rename to Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookCache.swift index 7f746c2..6728e52 100644 --- a/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookCache.swift +++ b/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookCache.swift @@ -106,7 +106,7 @@ public final class RDEPUBTextBookCache { public var schemaVersion: Int = 14 - private let queue = DispatchQueue(label: "com.rdreader.textbookcache", qos: .utility) + private let queue = DispatchQueue(label: "com.RDEpubReader.textbookcache", qos: .utility) private let cacheDirectory: URL diff --git a/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookModels.swift b/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookModels.swift similarity index 100% rename from Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookModels.swift rename to Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookModels.swift diff --git a/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBuildPipelineInterfaces.swift b/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBuildPipelineInterfaces.swift similarity index 100% rename from Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBuildPipelineInterfaces.swift rename to Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBuildPipelineInterfaces.swift diff --git a/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextPerformanceSampler.swift b/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextPerformanceSampler.swift similarity index 100% rename from Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextPerformanceSampler.swift rename to Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextPerformanceSampler.swift diff --git a/Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBChapterPageCounter.swift b/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBChapterPageCounter.swift similarity index 100% rename from Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBChapterPageCounter.swift rename to Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBChapterPageCounter.swift diff --git a/Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBCoreTextPageFrameFactory.swift b/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBCoreTextPageFrameFactory.swift similarity index 100% rename from Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBCoreTextPageFrameFactory.swift rename to Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBCoreTextPageFrameFactory.swift diff --git a/Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBPageBreakPolicy.swift b/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBPageBreakPolicy.swift similarity index 100% rename from Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBPageBreakPolicy.swift rename to Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBPageBreakPolicy.swift diff --git a/Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBTextLayoutFrame.swift b/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBTextLayoutFrame.swift similarity index 100% rename from Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBTextLayoutFrame.swift rename to Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBTextLayoutFrame.swift diff --git a/Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBTextLayouter.swift b/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBTextLayouter.swift similarity index 100% rename from Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBTextLayouter.swift rename to Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBTextLayouter.swift diff --git a/Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBTextPaginationInterfaces.swift b/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBTextPaginationInterfaces.swift similarity index 100% rename from Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBTextPaginationInterfaces.swift rename to Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBTextPaginationInterfaces.swift diff --git a/Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBTextPaginationSupport.swift b/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBTextPaginationSupport.swift similarity index 100% rename from Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBTextPaginationSupport.swift rename to Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBTextPaginationSupport.swift diff --git a/Sources/RDReaderView/EPUBTextRendering/RDEPUBChapterData.swift b/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBChapterData.swift similarity index 100% rename from Sources/RDReaderView/EPUBTextRendering/RDEPUBChapterData.swift rename to Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBChapterData.swift diff --git a/Sources/RDReaderView/EPUBTextRendering/RDEPUBDTCoreTextRenderer.swift b/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBDTCoreTextRenderer.swift similarity index 100% rename from Sources/RDReaderView/EPUBTextRendering/RDEPUBDTCoreTextRenderer.swift rename to Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBDTCoreTextRenderer.swift diff --git a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextIndexTable.swift b/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBTextIndexTable.swift similarity index 100% rename from Sources/RDReaderView/EPUBTextRendering/RDEPUBTextIndexTable.swift rename to Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBTextIndexTable.swift diff --git a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPositionConverter.swift b/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBTextPositionConverter.swift similarity index 100% rename from Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPositionConverter.swift rename to Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBTextPositionConverter.swift diff --git a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift b/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift similarity index 95% rename from Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift rename to Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift index 95367c7..7ae82d3 100644 --- a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift +++ b/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift @@ -2,19 +2,19 @@ import UIKit public extension NSAttributedString.Key { - static let rdPageBlockRange = NSAttributedString.Key("com.rdreader.epub.pageBlockRange") + static let rdPageBlockRange = NSAttributedString.Key("com.RDEpubReader.epub.pageBlockRange") - static let rdPageBlockIndex = NSAttributedString.Key("com.rdreader.epub.pageBlockIndex") + static let rdPageBlockIndex = NSAttributedString.Key("com.RDEpubReader.epub.pageBlockIndex") - static let rdPageFragmentID = NSAttributedString.Key("com.rdreader.epub.pageFragmentID") + static let rdPageFragmentID = NSAttributedString.Key("com.RDEpubReader.epub.pageFragmentID") - static let rdPageAttachmentKind = NSAttributedString.Key("com.rdreader.epub.pageAttachmentKind") + static let rdPageAttachmentKind = NSAttributedString.Key("com.RDEpubReader.epub.pageAttachmentKind") - static let rdPageBlockKind = NSAttributedString.Key("com.rdreader.epub.pageBlockKind") + static let rdPageBlockKind = NSAttributedString.Key("com.RDEpubReader.epub.pageBlockKind") - static let rdPageSemanticHints = NSAttributedString.Key("com.rdreader.epub.pageSemanticHints") + static let rdPageSemanticHints = NSAttributedString.Key("com.RDEpubReader.epub.pageSemanticHints") - static let rdPageAttachmentPlacement = NSAttributedString.Key("com.rdreader.epub.pageAttachmentPlacement") + static let rdPageAttachmentPlacement = NSAttributedString.Key("com.RDEpubReader.epub.pageAttachmentPlacement") } public enum RDEPUBTextBlockKind: String, Codable, Equatable, CaseIterable { diff --git a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextSearchEngine.swift b/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBTextSearchEngine.swift similarity index 100% rename from Sources/RDReaderView/EPUBTextRendering/RDEPUBTextSearchEngine.swift rename to Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBTextSearchEngine.swift diff --git a/Sources/RDReaderView/EPUBTextRendering/RDPlainTextBookBuilder.swift b/Sources/RDEpubReaderView/EPUBTextRendering/RDEpubPlainTextBookBuilder.swift similarity index 99% rename from Sources/RDReaderView/EPUBTextRendering/RDPlainTextBookBuilder.swift rename to Sources/RDEpubReaderView/EPUBTextRendering/RDEpubPlainTextBookBuilder.swift index 9de6591..e0cdc60 100644 --- a/Sources/RDReaderView/EPUBTextRendering/RDPlainTextBookBuilder.swift +++ b/Sources/RDEpubReaderView/EPUBTextRendering/RDEpubPlainTextBookBuilder.swift @@ -1,6 +1,6 @@ import UIKit -public final class RDPlainTextBookBuilder { +public final class RDEpubPlainTextBookBuilder { private let renderer: RDEPUBTextRenderer private let layoutConfig: RDEPUBTextLayoutConfig diff --git a/Sources/RDReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBCSSCompatibilityLayer.swift b/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBCSSCompatibilityLayer.swift similarity index 100% rename from Sources/RDReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBCSSCompatibilityLayer.swift rename to Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBCSSCompatibilityLayer.swift diff --git a/Sources/RDReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBFontFallbackResolver.swift b/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBFontFallbackResolver.swift similarity index 100% rename from Sources/RDReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBFontFallbackResolver.swift rename to Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBFontFallbackResolver.swift diff --git a/Sources/RDReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBStyleCompatibilityModels.swift b/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBStyleCompatibilityModels.swift similarity index 100% rename from Sources/RDReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBStyleCompatibilityModels.swift rename to Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBStyleCompatibilityModels.swift diff --git a/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBAttachmentNormalizer.swift b/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBAttachmentNormalizer.swift similarity index 100% rename from Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBAttachmentNormalizer.swift rename to Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBAttachmentNormalizer.swift diff --git a/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBCFIMarkerInjector.swift b/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBCFIMarkerInjector.swift similarity index 100% rename from Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBCFIMarkerInjector.swift rename to Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBCFIMarkerInjector.swift diff --git a/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBDecryptingImageAttachment.swift b/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBDecryptingImageAttachment.swift similarity index 82% rename from Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBDecryptingImageAttachment.swift rename to Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBDecryptingImageAttachment.swift index 0fe84c8..6869ca2 100644 --- a/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBDecryptingImageAttachment.swift +++ b/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBDecryptingImageAttachment.swift @@ -34,6 +34,16 @@ final class RDEPUBDecryptingImageAttachment: DTImageTextAttachment { 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) } diff --git a/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBFontNormalizer.swift b/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBFontNormalizer.swift similarity index 100% rename from Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBFontNormalizer.swift rename to Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBFontNormalizer.swift diff --git a/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBFragmentMarkerInjector.swift b/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBFragmentMarkerInjector.swift similarity index 100% rename from Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBFragmentMarkerInjector.swift rename to Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBFragmentMarkerInjector.swift diff --git a/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBHTMLNormalizer.swift b/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBHTMLNormalizer.swift similarity index 100% rename from Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBHTMLNormalizer.swift rename to Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBHTMLNormalizer.swift diff --git a/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBRenderDiagnosticsCollector.swift b/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBRenderDiagnosticsCollector.swift similarity index 100% rename from Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBRenderDiagnosticsCollector.swift rename to Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBRenderDiagnosticsCollector.swift diff --git a/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBSemanticMarkerInjector.swift b/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBSemanticMarkerInjector.swift similarity index 100% rename from Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBSemanticMarkerInjector.swift rename to Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBSemanticMarkerInjector.swift diff --git a/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBStyleSheetComposer.swift b/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBStyleSheetComposer.swift similarity index 100% rename from Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBStyleSheetComposer.swift rename to Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBStyleSheetComposer.swift diff --git a/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBTextRendererSupport.swift b/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBTextRendererSupport.swift similarity index 100% rename from Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBTextRendererSupport.swift rename to Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBTextRendererSupport.swift diff --git a/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBTypesettingPipeline.swift b/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBTypesettingPipeline.swift similarity index 100% rename from Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBTypesettingPipeline.swift rename to Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBTypesettingPipeline.swift diff --git a/Sources/RDReaderView/EPUBUI/Notes/RDEPUBNotePopupCoordinator.swift b/Sources/RDEpubReaderView/EPUBUI/Notes/RDEPUBNotePopupCoordinator.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/Notes/RDEPUBNotePopupCoordinator.swift rename to Sources/RDEpubReaderView/EPUBUI/Notes/RDEPUBNotePopupCoordinator.swift diff --git a/Sources/RDReaderView/EPUBUI/Notes/RDEPUBNotePopupViewController.swift b/Sources/RDEpubReaderView/EPUBUI/Notes/RDEPUBNotePopupViewController.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/Notes/RDEPUBNotePopupViewController.swift rename to Sources/RDEpubReaderView/EPUBUI/Notes/RDEPUBNotePopupViewController.swift diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBAttachmentTooltipOverlayView.swift b/Sources/RDEpubReaderView/EPUBUI/RDEPUBAttachmentTooltipOverlayView.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/RDEPUBAttachmentTooltipOverlayView.swift rename to Sources/RDEpubReaderView/EPUBUI/RDEPUBAttachmentTooltipOverlayView.swift diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBAttachmentTooltipView.swift b/Sources/RDEpubReaderView/EPUBUI/RDEPUBAttachmentTooltipView.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/RDEPUBAttachmentTooltipView.swift rename to Sources/RDEpubReaderView/EPUBUI/RDEPUBAttachmentTooltipView.swift diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBImageViewController.swift b/Sources/RDEpubReaderView/EPUBUI/RDEPUBImageViewController.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/RDEPUBImageViewController.swift rename to Sources/RDEpubReaderView/EPUBUI/RDEPUBImageViewController.swift diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBImageViewerCoordinator.swift b/Sources/RDEpubReaderView/EPUBUI/RDEPUBImageViewerCoordinator.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/RDEPUBImageViewerCoordinator.swift rename to Sources/RDEpubReaderView/EPUBUI/RDEPUBImageViewerCoordinator.swift diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderBottomToolView.swift b/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderBottomToolView.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/RDEPUBReaderBottomToolView.swift rename to Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderBottomToolView.swift diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderChapterListController.swift b/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderChapterListController.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/RDEPUBReaderChapterListController.swift rename to Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderChapterListController.swift diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+AttachmentTooltip.swift b/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+AttachmentTooltip.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/RDEPUBReaderController+AttachmentTooltip.swift rename to Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+AttachmentTooltip.swift diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift b/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift similarity index 96% rename from Sources/RDReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift rename to Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift index d49632d..54bb779 100644 --- a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift +++ b/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift @@ -93,9 +93,9 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate { } func textContentView(_ contentView: RDEPUBTextContentView, didRequestReaderTapAt point: CGPoint) { - RDReaderTapDebug.log( + RDEpubReaderTapDebug.log( "ReaderController.textContentTap", - "delegate received reader tap from contentView=\(RDReaderTapDebug.describe(contentView)) point=\(RDReaderTapDebug.describe(point)) currentPage=\(readerView.currentPage)" + "delegate received reader tap from contentView=\(RDEpubReaderTapDebug.describe(contentView)) point=\(RDEpubReaderTapDebug.describe(point)) currentPage=\(readerView.currentPage)" ) readerView.handleContentTap(at: point, in: contentView) } diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+DataSource.swift b/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+DataSource.swift similarity index 94% rename from Sources/RDReaderView/EPUBUI/RDEPUBReaderController+DataSource.swift rename to Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+DataSource.swift index 138033d..d9da404 100644 --- a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+DataSource.swift +++ b/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+DataSource.swift @@ -1,33 +1,33 @@ import UIKit -extension RDEPUBReaderController: RDReaderDataSource, RDReaderPageProvider, RDReaderDelegate { +extension RDEPUBReaderController: RDEpubReaderDataSource, RDEpubReaderPageProvider, RDEpubReaderDelegate { - public func numberOfPages(in readerView: RDReaderView) -> Int { + public func numberOfPages(in readerView: RDEpubReaderView) -> Int { effectiveNumberOfPages } - public func readerView(_ readerView: RDReaderView, viewForPageAt index: Int, reusableView: UIView?) -> UIView { + public func readerView(_ readerView: RDEpubReaderView, viewForPageAt index: Int, reusableView: UIView?) -> UIView { pageContentView(readerView: readerView, pageNum: index, containerView: reusableView) } - public func pageIdentifier(in readerView: RDReaderView, index: Int) -> String? { + public func pageIdentifier(in readerView: RDEpubReaderView, index: Int) -> String? { pageIdentifier(readerView: readerView, pageNum: index) } - public func readerViewTopChrome(_ readerView: RDReaderView) -> UIView? { + public func readerViewTopChrome(_ readerView: RDEpubReaderView) -> UIView? { topToolView(readerView: readerView) } - public func readerViewBottomChrome(_ readerView: RDReaderView) -> UIView? { + public func readerViewBottomChrome(_ readerView: RDEpubReaderView) -> UIView? { bottomToolView(readerView: readerView) } - public func pageCountOfReaderView(readerView: RDReaderView) -> Int { + public func pageCountOfReaderView(readerView: RDEpubReaderView) -> Int { readerContext.bookPageMap?.totalPages ?? textBook?.pages.count ?? activePages.count } - public func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView { + public func pageContentView(readerView: RDEpubReaderView, pageNum: Int, containerView: UIView?) -> UIView { // 试读墙页:位于最后一个可读页之后,展示宿主提供的墙视图 if let wallIndex = trialWallPageIndex, pageNum == wallIndex { return makeTrialWallPageView(reusableView: containerView) @@ -137,7 +137,7 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderPageProvider, RDRe return contentView } - public func pageIdentifier(readerView: RDReaderView, pageNum: Int) -> String? { + public func pageIdentifier(readerView: RDEpubReaderView, pageNum: Int) -> String? { if let wallIndex = trialWallPageIndex, pageNum == wallIndex { return NSStringFromClass(RDEPUBTrialWallContainerView.self) } @@ -346,15 +346,15 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderPageProvider, RDRe readerContext.textChapterData(forNormalizedHref: href) } - public func topToolView(readerView: RDReaderView) -> UIView? { + public func topToolView(readerView: RDEpubReaderView) -> UIView? { topToolView } - public func bottomToolView(readerView: RDReaderView) -> UIView? { + public func bottomToolView(readerView: RDEpubReaderView) -> UIView? { bottomToolView } - public func pageNum(readerView: RDReaderView, pageNum: Int) { + public func pageNum(readerView: RDEpubReaderView, pageNum: Int) { RDEPUBMemoryProbe.logPageTurn() readerContext.markUserNavigationActivity() updateCurrentSelection(nil) @@ -411,7 +411,7 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderPageProvider, RDRe runtime.locationCoordinator.recordPageChangeIfNeeded() } - public func readerViewOrientationWillChange(readerView: RDReaderView, isLandscape: Bool) { + public func readerViewOrientationWillChange(readerView: RDEpubReaderView, isLandscape: Bool) { _ = isLandscape runtime.viewportMonitor.capturePendingPresentationRestoreLocation() } diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+ExternalLinks.swift b/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+ExternalLinks.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/RDEPUBReaderController+ExternalLinks.swift rename to Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+ExternalLinks.swift diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+LocationResolution.swift b/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+LocationResolution.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/RDEPUBReaderController+LocationResolution.swift rename to Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+LocationResolution.swift diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+Orientation.swift b/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+Orientation.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/RDEPUBReaderController+Orientation.swift rename to Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+Orientation.swift diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+PublicAPI.swift b/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+PublicAPI.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/RDEPUBReaderController+PublicAPI.swift rename to Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+PublicAPI.swift diff --git a/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+RenderSupport.swift b/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+RenderSupport.swift new file mode 100644 index 0000000..91a9c44 --- /dev/null +++ b/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+RenderSupport.swift @@ -0,0 +1,92 @@ + +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 + ) + var preferences = currentPreferences() + if publication.layout == .fixed, readerView.pagesPerScreen == 2 { + // 横屏双页:左右页各让出 5pt,拼出 10pt 的书脊间距 + preferences.fixedContentInset.left += 5 + preferences.fixedContentInset.right += 5 + } + return preferences.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/RDReaderView/EPUBUI/RDEPUBReaderController+RuntimeBridge.swift b/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+RuntimeBridge.swift similarity index 99% rename from Sources/RDReaderView/EPUBUI/RDEPUBReaderController+RuntimeBridge.swift rename to Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+RuntimeBridge.swift index 62af09b..2fa6d66 100644 --- a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+RuntimeBridge.swift +++ b/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+RuntimeBridge.swift @@ -228,7 +228,7 @@ extension RDEPUBReaderController { runtime.searchPresentation(for: page) } - private func resolvedPageDirection() -> RDReaderView.PageDirection { + private func resolvedPageDirection() -> RDEpubReaderView.PageDirection { publication?.readingProgression == .rtl ? .rightToLeft : .leftToRight } } diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+TableOfContents.swift b/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+TableOfContents.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/RDEPUBReaderController+TableOfContents.swift rename to Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+TableOfContents.swift diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+Trial.swift b/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+Trial.swift similarity index 97% rename from Sources/RDReaderView/EPUBUI/RDEPUBReaderController+Trial.swift rename to Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+Trial.swift index 9d75f09..83af551 100644 --- a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+Trial.swift +++ b/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+Trial.swift @@ -48,7 +48,7 @@ extension RDEPUBReaderController { return readableContentPageCount } - /// 供 RDReaderView 使用的有效页数:启用试读墙时为可读页数 + 1(墙页) + /// 供 RDEpubReaderView 使用的有效页数:启用试读墙时为可读页数 + 1(墙页) var effectiveNumberOfPages: Int { guard isTrialWallEnabled else { return pageCountOfReaderView(readerView: readerView) diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift b/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController.swift similarity index 99% rename from Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift rename to Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController.swift index 55232b3..1587366 100644 --- a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift +++ b/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController.swift @@ -79,7 +79,7 @@ public final class RDEPUBReaderController: UIViewController { let dependencies: RDEPUBReaderDependencies - let readerView = RDReaderView() + let readerView = RDEpubReaderView() let loadingIndicator: UIActivityIndicatorView = { UIActivityIndicatorView(style: .large) diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderDelegate.swift b/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderDelegate.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/RDEPUBReaderDelegate.swift rename to Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderDelegate.swift diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderHighlightsViewController.swift b/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderHighlightsViewController.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/RDEPUBReaderHighlightsViewController.swift rename to Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderHighlightsViewController.swift diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderPersistence.swift b/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderPersistence.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/RDEPUBReaderPersistence.swift rename to Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderPersistence.swift diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderSearchBarView.swift b/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderSearchBarView.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/RDEPUBReaderSearchBarView.swift rename to Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderSearchBarView.swift diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderTableOfContentsItem.swift b/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderTableOfContentsItem.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/RDEPUBReaderTableOfContentsItem.swift rename to Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderTableOfContentsItem.swift diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderToolView.swift b/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderToolView.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/RDEPUBReaderToolView.swift rename to Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderToolView.swift diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderToolViewProtocols.swift b/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderToolViewProtocols.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/RDEPUBReaderToolViewProtocols.swift rename to Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderToolViewProtocols.swift diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderTopToolView.swift b/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderTopToolView.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/RDEPUBReaderTopToolView.swift rename to Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderTopToolView.swift diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBSettingsFlipAutomation.swift b/Sources/RDEpubReaderView/EPUBUI/RDEPUBSettingsFlipAutomation.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/RDEPUBSettingsFlipAutomation.swift rename to Sources/RDEpubReaderView/EPUBUI/RDEPUBSettingsFlipAutomation.swift diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBViewportTypes.swift b/Sources/RDEpubReaderView/EPUBUI/RDEPUBViewportTypes.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/RDEPUBViewportTypes.swift rename to Sources/RDEpubReaderView/EPUBUI/RDEPUBViewportTypes.swift diff --git a/Sources/RDEpubReaderView/EPUBUI/RDEPUBWebContentView.swift b/Sources/RDEpubReaderView/EPUBUI/RDEPUBWebContentView.swift new file mode 100644 index 0000000..794c287 --- /dev/null +++ b/Sources/RDEpubReaderView/EPUBUI/RDEPUBWebContentView.swift @@ -0,0 +1,135 @@ +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, RDEpubReaderPageResourceReleasing { + 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/RDReaderView/EPUBUI/RDEPUBWebDecorationOverlayView.swift b/Sources/RDEpubReaderView/EPUBUI/RDEPUBWebDecorationOverlayView.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/RDEPUBWebDecorationOverlayView.swift rename to Sources/RDEpubReaderView/EPUBUI/RDEPUBWebDecorationOverlayView.swift diff --git a/Sources/RDReaderView/EPUBUI/RDURLReaderController.swift b/Sources/RDEpubReaderView/EPUBUI/RDEpubURLReaderController.swift similarity index 98% rename from Sources/RDReaderView/EPUBUI/RDURLReaderController.swift rename to Sources/RDEpubReaderView/EPUBUI/RDEpubURLReaderController.swift index 4537ca8..d18bf09 100644 --- a/Sources/RDReaderView/EPUBUI/RDURLReaderController.swift +++ b/Sources/RDEpubReaderView/EPUBUI/RDEpubURLReaderController.swift @@ -1,7 +1,7 @@ import UIKit -public final class RDURLReaderController: UIViewController { +public final class RDEpubURLReaderController: UIViewController { private struct PendingDemoPageRequest { let pageNumber: Int let animated: Bool @@ -72,7 +72,7 @@ public final class RDURLReaderController: UIViewController { stopDemoStateTimer() } - public func applyDemoDisplayType(_ displayType: RDReaderView.DisplayType) { + public func applyDemoDisplayType(_ displayType: RDEpubReaderView.DisplayType) { readerController?.configuration.displayType = displayType emitDemoState(prefix: "display=\(displayType.demoArgumentValue)") } @@ -90,7 +90,7 @@ public final class RDURLReaderController: UIViewController { } public func runDemoDisplaySequence( - _ displayTypes: [RDReaderView.DisplayType], + _ displayTypes: [RDEpubReaderView.DisplayType], initialPageNumber: Int? = nil, stepDelay: TimeInterval = 1.0 ) { @@ -162,7 +162,7 @@ public final class RDURLReaderController: UIViewController { bottom: max(epubConfiguration.reflowableContentInsets.bottom, safeInsets.bottom), right: max(epubConfiguration.reflowableContentInsets.right, safeInsets.right) ) - let builder = RDPlainTextBookBuilder( + let builder = RDEpubPlainTextBookBuilder( layoutConfig: RDEPUBTextLayoutConfig( frameWidth: pageSize.width, frameHeight: pageSize.height, @@ -464,7 +464,7 @@ public final class RDURLReaderController: UIViewController { } } -extension RDURLReaderController: RDEPUBReaderDelegate { +extension RDEpubURLReaderController: RDEPUBReaderDelegate { public func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication) { retryPendingDemoPageIfNeeded() executePendingSearchIfNeeded() @@ -501,7 +501,7 @@ extension RDURLReaderController: RDEPUBReaderDelegate { } } -private extension RDReaderView.DisplayType { +private extension RDEpubReaderView.DisplayType { var demoArgumentValue: String { switch self { diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBBackgroundTrace.swift b/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBBackgroundTrace.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBBackgroundTrace.swift rename to Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBBackgroundTrace.swift diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBBookPageMap.swift b/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBBookPageMap.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBBookPageMap.swift rename to Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBBookPageMap.swift diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterCacheKey.swift b/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterCacheKey.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterCacheKey.swift rename to Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterCacheKey.swift diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterDataCache.swift b/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterDataCache.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterDataCache.swift rename to Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterDataCache.swift diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLoader.swift b/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLoader.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLoader.swift rename to Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLoader.swift diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLocation.swift b/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLocation.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLocation.swift rename to Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLocation.swift diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterOffsetMap.swift b/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterOffsetMap.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterOffsetMap.swift rename to Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterOffsetMap.swift diff --git a/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterRuntimeStore.swift b/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterRuntimeStore.swift new file mode 100644 index 0000000..3370f45 --- /dev/null +++ b/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterRuntimeStore.swift @@ -0,0 +1,203 @@ +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 invalidateAllLayoutDependentContent() { + RDEPUBMemoryProbe.log("layoutDependentContentInvalidateAll") + chapterDataCache.removeAll() + pageCountCache.removeAll() + imageCache.removeAllObjects() + cfiMapLock.lock() + buildingCFIMapSpineIndices.removeAll() + cfiMapLock.unlock() + pendingChapterLoadLock.lock() + pendingChapterLoadSpineIndices.removeAll() + pendingChapterLoadLock.unlock() + } +} diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterSummaryDiskCache.swift b/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterSummaryDiskCache.swift similarity index 98% rename from Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterSummaryDiskCache.swift rename to Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterSummaryDiskCache.swift index 75f6705..e0a5a79 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterSummaryDiskCache.swift +++ b/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterSummaryDiskCache.swift @@ -6,7 +6,7 @@ final class RDEPUBChapterSummaryDiskCache { private let fileManager = FileManager.default - private let queue = DispatchQueue(label: "com.rdreader.summarydiskcache", qos: .utility) + private let queue = DispatchQueue(label: "com.RDEpubReader.summarydiskcache", qos: .utility) init(cacheDirectory: URL) { self.cacheDirectory = cacheDirectory diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterWarmupOrchestrator.swift b/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterWarmupOrchestrator.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterWarmupOrchestrator.swift rename to Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterWarmupOrchestrator.swift diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBPageCountCache.swift b/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBPageCountCache.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBPageCountCache.swift rename to Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBPageCountCache.swift diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBPageResolver.swift b/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBPageResolver.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBPageResolver.swift rename to Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBPageResolver.swift diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBRuntimeChapter.swift b/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBRuntimeChapter.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBRuntimeChapter.swift rename to Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBRuntimeChapter.swift diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBRuntimePageCount.swift b/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBRuntimePageCount.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBRuntimePageCount.swift rename to Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBRuntimePageCount.swift diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBBackgroundCoverageStore.swift b/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBBackgroundCoverageStore.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBBackgroundCoverageStore.swift rename to Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBBackgroundCoverageStore.swift diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBBackgroundPriorityPolicy.swift b/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBBackgroundPriorityPolicy.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBBackgroundPriorityPolicy.swift rename to Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBBackgroundPriorityPolicy.swift diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBJumpSession.swift b/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBJumpSession.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBJumpSession.swift rename to Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBJumpSession.swift diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBMemoryProbe.swift b/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBMemoryProbe.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBMemoryProbe.swift rename to Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBMemoryProbe.swift diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBMetadataParseCancellationController.swift b/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBMetadataParseCancellationController.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBMetadataParseCancellationController.swift rename to Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBMetadataParseCancellationController.swift diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBMetadataParseWorker.swift b/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBMetadataParseWorker.swift similarity index 99% rename from Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBMetadataParseWorker.swift rename to Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBMetadataParseWorker.swift index 654746f..d1bf3e5 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBMetadataParseWorker.swift +++ b/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBMetadataParseWorker.swift @@ -225,7 +225,7 @@ final class RDEPUBMetadataParseWorker { let timingLock = NSLock() let queue = OperationQueue() - queue.name = "com.rdreader.metadata.parse" + queue.name = "com.RDEpubReader.metadata.parse" queue.qualityOfService = .utility queue.maxConcurrentOperationCount = self.workerCount cancellationController.attach(queue: queue) diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBPageMapReconciliationCoordinator.swift b/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBPageMapReconciliationCoordinator.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBPageMapReconciliationCoordinator.swift rename to Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBPageMapReconciliationCoordinator.swift diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBPresentationRuntime.swift b/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBPresentationRuntime.swift similarity index 99% rename from Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBPresentationRuntime.swift rename to Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBPresentationRuntime.swift index e35593d..48d0bbe 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBPresentationRuntime.swift +++ b/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBPresentationRuntime.swift @@ -155,7 +155,7 @@ final class RDEPUBPresentationRuntime { private func applyFullPageMapReplacement( _ newPageMap: RDEPUBBookPageMap, - readerView: RDReaderView, + readerView: RDEpubReaderView, controller: RDEPUBReaderController ) { let currentLocation = locationCoordinator.currentVisibleLocation() @@ -239,7 +239,7 @@ final class RDEPUBPresentationRuntime { private func commitPendingPageMapUpdate( _ update: RDEPUBPendingPageMapUpdate, at index: Int, - readerView: RDReaderView, + readerView: RDEpubReaderView, controller: RDEPUBReaderController ) -> Bool { switch update.kind { @@ -302,7 +302,7 @@ final class RDEPUBPresentationRuntime { } } - private func rebindVisiblePage(to pageIndex: Int, readerView: RDReaderView) { + private func rebindVisiblePage(to pageIndex: Int, readerView: RDEpubReaderView) { if pageIndex == readerView.currentPage { RDEPUBBackgroundTrace.log( "Reconciliation", @@ -342,7 +342,7 @@ final class RDEPUBPresentationRuntime { private func rebindVisibleLocation( _ location: RDEPUBLocation, - readerView: RDReaderView, + readerView: RDEpubReaderView, controller: RDEPUBReaderController ) -> Bool { guard let targetPageNumber = controller.pageNumber(for: location) else { diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderAnnotationCoordinator.swift b/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderAnnotationCoordinator.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderAnnotationCoordinator.swift rename to Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderAnnotationCoordinator.swift diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderAssemblyCoordinator.swift b/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderAssemblyCoordinator.swift similarity index 92% rename from Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderAssemblyCoordinator.swift rename to Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderAssemblyCoordinator.swift index f56f055..9179cbb 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderAssemblyCoordinator.swift +++ b/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderAssemblyCoordinator.swift @@ -38,7 +38,7 @@ final class RDEPUBReaderAssemblyCoordinator { } } - private func setupReaderView(_ readerView: RDReaderView, in containerView: UIView) { + private func setupReaderView(_ readerView: RDEpubReaderView, in containerView: UIView) { readerView.pageProvider = context.controller readerView.delegate = context.controller readerView.translatesAutoresizingMaskIntoConstraints = false @@ -52,6 +52,10 @@ final class RDEPUBReaderAssemblyCoordinator { 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() } diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderChromeCoordinator.swift b/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderChromeCoordinator.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderChromeCoordinator.swift rename to Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderChromeCoordinator.swift diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderContext.swift b/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderContext.swift similarity index 99% rename from Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderContext.swift rename to Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderContext.swift index ff0e489..cb325a5 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderContext.swift +++ b/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderContext.swift @@ -17,7 +17,7 @@ final class RDEPUBReaderContext { weak var controller: RDEPUBReaderController? - weak var readerView: RDReaderView? + weak var readerView: RDEpubReaderView? let state: RDEPUBReaderState @@ -341,7 +341,7 @@ final class RDEPUBReaderContext { } } - func makePlainTextBookBuilder(layoutConfig: RDEPUBTextLayoutConfig) -> RDPlainTextBookBuilder { + func makePlainTextBookBuilder(layoutConfig: RDEPUBTextLayoutConfig) -> RDEpubPlainTextBookBuilder { services.makePlainTextBookBuilder( configuration: configuration, layoutConfig: layoutConfig diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderDependencies.swift b/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderDependencies.swift similarity index 94% rename from Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderDependencies.swift rename to Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderDependencies.swift index b2849cc..17490f0 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderDependencies.swift +++ b/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderDependencies.swift @@ -31,7 +31,7 @@ public struct RDEPUBReaderDependencies { public var makeTextBookBuilder: (RDEPUBTextRenderer, RDEPUBTextBookCache?, RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder - public var makePlainTextBookBuilder: (RDEPUBTextRenderer, RDEPUBTextLayoutConfig) -> RDPlainTextBookBuilder + public var makePlainTextBookBuilder: (RDEPUBTextRenderer, RDEPUBTextLayoutConfig) -> RDEpubPlainTextBookBuilder public var makeTextRenderer: (RDEPUBTextRenderingEngine) -> RDEPUBTextRenderer @@ -46,7 +46,7 @@ public struct RDEPUBReaderDependencies { makeParser: @escaping () -> RDEPUBParser, makePaginator: @escaping () -> RDEPUBPaginator, makeTextBookBuilder: @escaping (RDEPUBTextRenderer, RDEPUBTextBookCache?, RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder, - makePlainTextBookBuilder: @escaping (RDEPUBTextRenderer, RDEPUBTextLayoutConfig) -> RDPlainTextBookBuilder, + makePlainTextBookBuilder: @escaping (RDEPUBTextRenderer, RDEPUBTextLayoutConfig) -> RDEpubPlainTextBookBuilder, makeTextRenderer: @escaping (RDEPUBTextRenderingEngine) -> RDEPUBTextRenderer, makeTopToolView: (() -> RDEPUBReaderTopToolViewProviding)? = nil, makeBottomToolView: (() -> RDEPUBReaderBottomToolViewProviding)? = nil @@ -84,7 +84,7 @@ public struct RDEPUBReaderDependencies { RDEPUBTextBookBuilder(renderer: renderer, cache: cache, layoutConfig: layoutConfig) }, makePlainTextBookBuilder: { renderer, layoutConfig in - RDPlainTextBookBuilder(renderer: renderer, layoutConfig: layoutConfig) + RDEpubPlainTextBookBuilder(renderer: renderer, layoutConfig: layoutConfig) }, makeTextRenderer: { engine in switch engine { diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderEnvironment.swift b/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderEnvironment.swift similarity index 97% rename from Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderEnvironment.swift rename to Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderEnvironment.swift index 11090df..c15edae 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderEnvironment.swift +++ b/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderEnvironment.swift @@ -26,13 +26,13 @@ final class RDEPUBReaderEnvironment { weak var controller: RDEPUBReaderController? - weak var readerView: RDReaderView? + weak var readerView: RDEpubReaderView? var displayEnvironment: any RDEPUBReaderDisplayEnvironment init( controller: RDEPUBReaderController, - readerView: RDReaderView, + readerView: RDEpubReaderView, displayEnvironment: any RDEPUBReaderDisplayEnvironment ) { self.controller = controller diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderLoadCoordinator.swift b/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderLoadCoordinator.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderLoadCoordinator.swift rename to Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderLoadCoordinator.swift diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderLocationCoordinator.swift b/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderLocationCoordinator.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderLocationCoordinator.swift rename to Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderLocationCoordinator.swift diff --git a/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift b/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift new file mode 100644 index 0000000..523005e --- /dev/null +++ b/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift @@ -0,0 +1,392 @@ +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() + // Chapter pages, page counts, partial maps, and background coverage all + // depend on the viewport. Keeping them across a rotation can pair the + // new page map with chapters paginated for the old size, leaving an + // on-demand page in its loading state forever. + context.runtime?.prepareForFullRepagination() + 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/EPUBUI/ReaderController/RDEPUBReaderRuntime.swift b/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderRuntime.swift new file mode 100644 index 0000000..360048c --- /dev/null +++ b/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderRuntime.swift @@ -0,0 +1,703 @@ +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.invalidateAllLayoutDependentContent() + 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.invalidateAllLayoutDependentContent() + context.bookPageMap = nil + context.pendingPageMapUpdates.removeAll() + jumpSessionManager.clearSession() + backgroundPriorityManager.reset() + backgroundCoverageStore.clearAll() + chapterWarmupOrchestrator.clear() + } + + /// Clears every runtime value derived from the current viewport before a + /// full repagination. The caller must capture the visible location first, + /// because chapter/page resolution is intentionally invalid after this. + func prepareForFullRepagination() { + paginationCoordinator.cancelActiveMetadataParseWork() + chapterRuntimeStore.invalidateAllLayoutDependentContent() + context.pendingPageMapUpdates.removeAll() + jumpSessionManager.clearSession() + backgroundPriorityManager.reset() + backgroundCoverageStore.clearAll() + chapterWarmupOrchestrator.clear() + context.controller?.textDisplayCache.removeAll() + } + + 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/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderSearchCoordinator.swift b/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderSearchCoordinator.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderSearchCoordinator.swift rename to Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderSearchCoordinator.swift diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderServices.swift b/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderServices.swift similarity index 97% rename from Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderServices.swift rename to Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderServices.swift index e49ab2d..78eabca 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderServices.swift +++ b/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderServices.swift @@ -35,7 +35,7 @@ final class RDEPUBReaderServices { func makePlainTextBookBuilder( configuration: RDEPUBReaderConfiguration, layoutConfig: RDEPUBTextLayoutConfig - ) -> RDPlainTextBookBuilder { + ) -> RDEpubPlainTextBookBuilder { dependencies.makePlainTextBookBuilder( resolvedTextRenderer(configuration: configuration), layoutConfig diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderState.swift b/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderState.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderState.swift rename to Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderState.swift diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderUIState.swift b/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderUIState.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderUIState.swift rename to Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderUIState.swift diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderViewportMonitor.swift b/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderViewportMonitor.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderViewportMonitor.swift rename to Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderViewportMonitor.swift diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBSelectionState.swift b/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBSelectionState.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBSelectionState.swift rename to Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBSelectionState.swift diff --git a/Sources/RDReaderView/EPUBUI/Settings/RDEPUBReaderConfiguration.swift b/Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderConfiguration.swift similarity index 98% rename from Sources/RDReaderView/EPUBUI/Settings/RDEPUBReaderConfiguration.swift rename to Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderConfiguration.swift index ee96946..25203fd 100644 --- a/Sources/RDReaderView/EPUBUI/Settings/RDEPUBReaderConfiguration.swift +++ b/Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderConfiguration.swift @@ -71,7 +71,7 @@ public struct RDEPUBReaderConfiguration: Equatable { public var columnGap: CGFloat - public var displayType: RDReaderView.DisplayType + public var displayType: RDEpubReaderView.DisplayType public var landscapeDualPageEnabled: Bool @@ -120,7 +120,7 @@ public struct RDEPUBReaderConfiguration: Equatable { fontChoice: RDEPUBReaderFontChoice = .system, numberOfColumns: Int = 1, columnGap: CGFloat = 20, - displayType: RDReaderView.DisplayType = .pageCurl, + displayType: RDEpubReaderView.DisplayType = .pageCurl, landscapeDualPageEnabled: Bool = true, showsTableOfContents: Bool = true, allowsHighlights: Bool = true, diff --git a/Sources/RDReaderView/EPUBUI/Settings/RDEPUBReaderSettings.swift b/Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderSettings.swift similarity index 97% rename from Sources/RDReaderView/EPUBUI/Settings/RDEPUBReaderSettings.swift rename to Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderSettings.swift index 21596e1..ef8ac29 100644 --- a/Sources/RDReaderView/EPUBUI/Settings/RDEPUBReaderSettings.swift +++ b/Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderSettings.swift @@ -10,7 +10,7 @@ public enum RDEPUBReaderDisplayMode: String, Codable, Equatable { case horizontalCoverScroll - init(displayType: RDReaderView.DisplayType) { + init(displayType: RDEpubReaderView.DisplayType) { switch displayType { case .pageCurl: self = .pageCurl @@ -21,7 +21,7 @@ public enum RDEPUBReaderDisplayMode: String, Codable, Equatable { } } - var displayType: RDReaderView.DisplayType { + var displayType: RDEpubReaderView.DisplayType { switch self { case .pageCurl: return .pageCurl diff --git a/Sources/RDReaderView/EPUBUI/Settings/RDEPUBReaderSettingsViewController.swift b/Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderSettingsViewController.swift similarity index 99% rename from Sources/RDReaderView/EPUBUI/Settings/RDEPUBReaderSettingsViewController.swift rename to Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderSettingsViewController.swift index 46b8be0..871d11d 100644 --- a/Sources/RDReaderView/EPUBUI/Settings/RDEPUBReaderSettingsViewController.swift +++ b/Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderSettingsViewController.swift @@ -12,7 +12,7 @@ final class RDEPUBReaderSettingsViewController: UIViewController { var onColumnCountChange: ((Int) -> Void)? - var onDisplayTypeChange: ((RDReaderView.DisplayType) -> Void)? + var onDisplayTypeChange: ((RDEpubReaderView.DisplayType) -> Void)? var onThemeChange: ((RDEPUBReaderTheme) -> Void)? @@ -345,7 +345,7 @@ final class RDEPUBReaderSettingsViewController: UIViewController { } @objc private func displayTypeChanged(_ control: UISegmentedControl) { - let displayType: RDReaderView.DisplayType + let displayType: RDEpubReaderView.DisplayType switch control.selectedSegmentIndex { case 1: displayType = .horizontalScroll diff --git a/Sources/RDReaderView/EPUBUI/Settings/RDEPUBReaderTheme.swift b/Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderTheme.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/Settings/RDEPUBReaderTheme.swift rename to Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderTheme.swift diff --git a/Sources/RDReaderView/EPUBUI/String+RDEPUB.swift b/Sources/RDEpubReaderView/EPUBUI/String+RDEPUB.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/String+RDEPUB.swift rename to Sources/RDEpubReaderView/EPUBUI/String+RDEPUB.swift diff --git a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBChapterDisplayContentCache.swift b/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBChapterDisplayContentCache.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/TextPage/RDEPUBChapterDisplayContentCache.swift rename to Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBChapterDisplayContentCache.swift diff --git a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBDarkImageAdjuster.swift b/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBDarkImageAdjuster.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/TextPage/RDEPUBDarkImageAdjuster.swift rename to Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBDarkImageAdjuster.swift diff --git a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBPageInteractionController.swift b/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBPageInteractionController.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/TextPage/RDEPUBPageInteractionController.swift rename to Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBPageInteractionController.swift diff --git a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBPageLayoutSnapshot.swift b/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBPageLayoutSnapshot.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/TextPage/RDEPUBPageLayoutSnapshot.swift rename to Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBPageLayoutSnapshot.swift diff --git a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBSelectionLoupeView.swift b/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBSelectionLoupeView.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/TextPage/RDEPUBSelectionLoupeView.swift rename to Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBSelectionLoupeView.swift diff --git a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBSelectionOverlayView.swift b/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBSelectionOverlayView.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/TextPage/RDEPUBSelectionOverlayView.swift rename to Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBSelectionOverlayView.swift diff --git a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextAnnotationOverlay.swift b/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextAnnotationOverlay.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextAnnotationOverlay.swift rename to Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextAnnotationOverlay.swift diff --git a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentInteractionCoordinator.swift b/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextContentInteractionCoordinator.swift similarity index 99% rename from Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentInteractionCoordinator.swift rename to Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextContentInteractionCoordinator.swift index d6714bb..fe40936 100644 --- a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentInteractionCoordinator.swift +++ b/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextContentInteractionCoordinator.swift @@ -224,7 +224,7 @@ final class RDEPUBTextContentInteractionCoordinator: NSObject { let currentTapSuppressed = interactionState != .idle let currentPagingSuppressed = shouldSuppressPagingInteraction(for: interactionState) if previousState != state { - RDReaderTapDebug.log( + RDEpubReaderTapDebug.log( "TextContentInteraction.state", "transition \(previousState) -> \(state) tapSuppressed=\(currentTapSuppressed) pagingSuppressed=\(currentPagingSuppressed)" ) diff --git a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift b/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift similarity index 94% rename from Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift rename to Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift index 9e902cd..99c87e9 100644 --- a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift +++ b/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift @@ -44,7 +44,7 @@ extension RDEPUBTextContentViewDelegate { } } -final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReaderCachePolicyProviding { +final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDEpubReaderCachePolicyProviding { private static let pageNumberTrailingPadding: CGFloat = 4 private static let pageNumberFooterPadding: CGFloat = 8 @@ -770,7 +770,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader || interactionCoordinator.interactionState != .idle guard isUserInteractionEnabled != shouldEnableInteraction else { return } isUserInteractionEnabled = shouldEnableInteraction - RDReaderTapDebug.log( + RDEpubReaderTapDebug.log( "TextContentView.interactionAvailability", "updated isUserInteractionEnabled=\(shouldEnableInteraction) currentPage=\(currentPage?.absolutePageIndex ?? -1) loading=\(loadingSpinner.isAnimating) selecting=\(selectionController.isSelecting) hasSelection=\(selectionController.hasActiveSelection) state=\(interactionCoordinator.interactionState)" ) @@ -784,7 +784,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader guard longPressChanged || tapChanged else { return } longPressGestureRecognizer.isEnabled = shouldEnableLongPress tapGestureRecognizer.isEnabled = shouldEnableTap - RDReaderTapDebug.log( + RDEpubReaderTapDebug.log( "TextContentView.gestureAvailability", "updated longPressEnabled=\(shouldEnableLongPress) tapEnabled=\(shouldEnableTap) hasInteractiveTextContent=\(hasInteractiveTextContent) loading=\(loadingSpinner.isAnimating) currentPage=\(currentPage?.absolutePageIndex ?? -1)" ) @@ -792,28 +792,28 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader @objc private func handleTap(_ gesture: UITapGestureRecognizer) { let point = gesture.location(in: overlayView) - RDReaderTapDebug.log( + RDEpubReaderTapDebug.log( "TextContentView.handleTap", - "received point=\(RDReaderTapDebug.describe(point)) currentPage=\(currentPage?.absolutePageIndex ?? -1) selectionState=\(interactionCoordinator.interactionState) hasSelection=\(currentSelection != nil) loading=\(loadingSpinner.isAnimating)" + "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 { - RDReaderTapDebug.log("TextContentView.handleTap", "ignored because tap hit selection handle") + RDEpubReaderTapDebug.log("TextContentView.handleTap", "ignored because tap hit selection handle") return } if currentSelection != nil { if renderView.selectionContains(renderPoint) { - RDReaderTapDebug.log("TextContentView.handleTap", "selection exists and tap stayed inside selection; showing selection menu") + RDEpubReaderTapDebug.log("TextContentView.handleTap", "selection exists and tap stayed inside selection; showing selection menu") showSelectionMenuIfNeeded() return } - RDReaderTapDebug.log("TextContentView.handleTap", "selection exists and tap moved outside selection; clearing selection") + RDEpubReaderTapDebug.log("TextContentView.handleTap", "selection exists and tap moved outside selection; clearing selection") clearSelection() return } } else if currentSelection != nil { - RDReaderTapDebug.log("TextContentView.handleTap", "selection exists without renderView; clearing selection") + RDEpubReaderTapDebug.log("TextContentView.handleTap", "selection exists without renderView; clearing selection") clearSelection() return } @@ -825,7 +825,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader // 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 { - RDReaderTapDebug.log("TextContentView.handleTap", "resolved footnote attachment tap") + RDEpubReaderTapDebug.log("TextContentView.handleTap", "resolved footnote attachment tap") delegate?.textContentView( self, didActivateAttachmentText: footnoteText, @@ -837,14 +837,14 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader // Regular image attachments: present image viewer if let image = imageFromPage(attachment: attachment, page: page) { let altText = attachmentText(at: point) - RDReaderTapDebug.log("TextContentView.handleTap", "resolved image attachment tap altTextPresent=\(altText?.isEmpty == false)") + 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) { - RDReaderTapDebug.log("TextContentView.handleTap", "resolved fallback attachment text tap") + RDEpubReaderTapDebug.log("TextContentView.handleTap", "resolved fallback attachment text tap") delegate?.textContentView( self, didActivateAttachmentText: attachmentText, @@ -855,11 +855,11 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader } guard let highlight = highlight(at: point), let sourceRect = highlightSourceRect(for: highlight, fallbackPoint: point) else { - RDReaderTapDebug.log("TextContentView.handleTap", "forwarding plain reader tap to delegate") + RDEpubReaderTapDebug.log("TextContentView.handleTap", "forwarding plain reader tap to delegate") delegate?.textContentView(self, didRequestReaderTapAt: convert(point, from: overlayView)) return } - RDReaderTapDebug.log("TextContentView.handleTap", "resolved highlight tap highlightId=\(highlight.id)") + RDEpubReaderTapDebug.log("TextContentView.handleTap", "resolved highlight tap highlightId=\(highlight.id)") delegate?.textContentView(self, didRequestHighlightActions: highlight, sourceRect: sourceRect) } @@ -1012,36 +1012,36 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader func shouldSuppressReaderTap(at point: CGPoint) -> Bool { #if canImport(DTCoreText) if interactionCoordinator.interactionState == .selectionPending { - RDReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return true because interactionState is selectionPending") + RDEpubReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return true because interactionState is selectionPending") return true } guard let renderView = coreTextRenderView else { - RDReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return false because renderView is nil") + RDEpubReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return false because renderView is nil") return false } let renderPoint = convert(point, to: renderView) if renderView.selectionHandle(at: renderPoint) != nil { - RDReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return true because tap hit selection handle") + RDEpubReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return true because tap hit selection handle") return true } if selectionController.hasActiveSelection, renderView.selectionContains(renderPoint) { - RDReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return true because tap is inside active selection") + RDEpubReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return true because tap is inside active selection") return true } switch interactionCoordinator.interactionState { case .idle: - RDReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return false because interactionState is idle") + RDEpubReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return false because interactionState is idle") return false case .selectionPending, .selecting, .selectionActive, .adjustingHandle: - RDReaderTapDebug.log( + RDEpubReaderTapDebug.log( "TextContentView.shouldSuppressReaderTap", "return true because interactionState=\(interactionCoordinator.interactionState)" ) return true } #else - RDReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return false because DTCoreText is unavailable") + RDEpubReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return false because DTCoreText is unavailable") return false #endif } @@ -1090,21 +1090,21 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader ) -> Bool { if gestureRecognizer === tapGestureRecognizer { guard let renderView = coreTextRenderView else { - RDReaderTapDebug.log("TextContentView.gestureShouldReceive", "return true because renderView is nil") + RDEpubReaderTapDebug.log("TextContentView.gestureShouldReceive", "return true because renderView is nil") return true } let point = touch.location(in: renderView) if renderView.selectionHandle(at: point) != nil { - RDReaderTapDebug.log("TextContentView.gestureShouldReceive", "return false because tap hit selection handle") + RDEpubReaderTapDebug.log("TextContentView.gestureShouldReceive", "return false because tap hit selection handle") return false } } let result = interactionCoordinator.gestureRecognizer(gestureRecognizer, shouldReceive: touch) - RDReaderTapDebug.log( + RDEpubReaderTapDebug.log( "TextContentView.gestureShouldReceive", - "gesture=\(type(of: gestureRecognizer)) touchView=\(RDReaderTapDebug.describe(touch.view)) result=\(result)" + "gesture=\(type(of: gestureRecognizer)) touchView=\(RDEpubReaderTapDebug.describe(touch.view)) result=\(result)" ) return result } diff --git a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextPageBoundaryValidator.swift b/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextPageBoundaryValidator.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextPageBoundaryValidator.swift rename to Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextPageBoundaryValidator.swift diff --git a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextPageDecorationView.swift b/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextPageDecorationView.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextPageDecorationView.swift rename to Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextPageDecorationView.swift diff --git a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextPageRenderView.swift b/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextPageRenderView.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextPageRenderView.swift rename to Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextPageRenderView.swift diff --git a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextPageVerticalJustifier.swift b/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextPageVerticalJustifier.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextPageVerticalJustifier.swift rename to Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextPageVerticalJustifier.swift diff --git a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextSelectionController.swift b/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextSelectionController.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextSelectionController.swift rename to Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextSelectionController.swift diff --git a/Sources/RDReaderView/EPUBUI/UIColor+RDEPUB.swift b/Sources/RDEpubReaderView/EPUBUI/UIColor+RDEPUB.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/UIColor+RDEPUB.swift rename to Sources/RDEpubReaderView/EPUBUI/UIColor+RDEPUB.swift diff --git a/Sources/RDReaderView/EPUBUI/UIColor+RDEPUBHex.swift b/Sources/RDEpubReaderView/EPUBUI/UIColor+RDEPUBHex.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/UIColor+RDEPUBHex.swift rename to Sources/RDEpubReaderView/EPUBUI/UIColor+RDEPUBHex.swift diff --git a/Sources/RDEpubReaderView/RDEpubReaderView.podspec b/Sources/RDEpubReaderView/RDEpubReaderView.podspec new file mode 100644 index 0000000..347cbc4 --- /dev/null +++ b/Sources/RDEpubReaderView/RDEpubReaderView.podspec @@ -0,0 +1,19 @@ +Pod::Spec.new do |s| + s.name = "RDEpubReaderView" + s.module_name = "RDEpubReaderView" + s.version = "0.0.1" + s.summary = "A reader view for EPUB and plain-text books" + s.platform = :ios, "15.0" + s.swift_versions = ["5.10"] + s.homepage = "http://192.168.21.200:8418/4v5u09Z5a4Yuc/ReadViewSDK.git" + s.author = { "shenlei" => "shenlei@touchread.com" } + s.source = { :git => "http://192.168.21.200:8418/4v5u09Z5a4Yuc/ReadViewSDK.git", :tag => "#{s.version}" } + s.license = "MIT" + s.source_files = "Sources/RDEpubReaderView/**/*.{swift}" + s.resource_bundles = { + "RDEpubReaderViewAssets" => ["Sources/RDEpubReaderView/EPUBCore/Resources/**/*"] + } + s.dependency "ZIPFoundation", "~> 0.9" + s.dependency "DTCoreText", "~> 1.6" + s.requires_arc = true +end diff --git a/Sources/RDEpubReaderView/README.md b/Sources/RDEpubReaderView/README.md new file mode 100644 index 0000000..7d96c76 --- /dev/null +++ b/Sources/RDEpubReaderView/README.md @@ -0,0 +1,11 @@ +# RDEpubReaderView + +项目内置的 EPUB 阅读器 CocoaPods 源码。 + +`Podfile` 通过 `:path => 'Vendor/RDEpubReaderView'` 引用本目录;自动复制工程到其他目录打包时,不再依赖仓库外的 `ReadViewSDK` 路径。 + +更新 SDK 时请同步更新本目录中的 `Sources/` 与 `RDEpubReaderView.podspec`,然后执行: + +```sh +pod install --no-repo-update +``` diff --git a/Sources/RDReaderView/ReaderView/Paging/RDReaderPagingController.swift b/Sources/RDEpubReaderView/ReaderView/Paging/RDEpubReaderPagingController.swift similarity index 92% rename from Sources/RDReaderView/ReaderView/Paging/RDReaderPagingController.swift rename to Sources/RDEpubReaderView/ReaderView/Paging/RDEpubReaderPagingController.swift index cbf6354..54d1782 100644 --- a/Sources/RDReaderView/ReaderView/Paging/RDReaderPagingController.swift +++ b/Sources/RDEpubReaderView/ReaderView/Paging/RDEpubReaderPagingController.swift @@ -1,6 +1,6 @@ import UIKit -struct RDReaderPagingController { +struct RDEpubReaderPagingController { struct PageTransitionRequest: Equatable { let pageNum: Int @@ -25,7 +25,7 @@ struct RDReaderPagingController { return pageVC } - mutating func shouldQueuePageTransition(_ request: PageTransitionRequest, currentDisplayType: RDReaderView.DisplayType) -> Bool { + mutating func shouldQueuePageTransition(_ request: PageTransitionRequest, currentDisplayType: RDEpubReaderView.DisplayType) -> Bool { guard currentDisplayType == .pageCurl, isTransitioning else { return false } pendingTransitionRequest = request return true diff --git a/Sources/RDEpubReaderView/ReaderView/Paging/RDEpubReaderPreloadController.swift b/Sources/RDEpubReaderView/ReaderView/Paging/RDEpubReaderPreloadController.swift new file mode 100644 index 0000000..fcff081 --- /dev/null +++ b/Sources/RDEpubReaderView/ReaderView/Paging/RDEpubReaderPreloadController.swift @@ -0,0 +1,366 @@ + +import UIKit + +protocol RDEpubReaderCachePolicyProviding { + var shouldAvoidReaderPageCaching: Bool { get } +} + +/// 页面视图被缓存彻底丢弃(旋转失效、窗口裁剪)时的即时清理钩子, +/// 让持有重资源的页面(如活 WKWebView)不必等 dealloc 就能拆除内容。 +/// 实现方在之后再次被 configure 时必须能恢复可用。 +protocol RDEpubReaderPageResourceReleasing { + func releaseResources() +} + +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 { discard($0) } + preloadedPageViews.values.forEach { discard($0) } + 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 { + if let liveCached = pageCurlCachedViews[targetPage], + liveCached.superview != nil, + liveCached.superview !== preloadHostView { + // 该页视图仍挂在翻页控制器的相邻页缓存上,内容已就绪。 + // 以前这里会误删缓存条目并创建一个从零加载的副本顶替, + // 重内容页翻过去时拿到的是仍在加载的副本,表现为翻页后闪烁重置。 + RDEpubReaderTapDebug.log( + "PreloadController.prime", + "skip live page=\(targetPage) view=\(RDEpubReaderTapDebug.describe(liveCached))" + ) + continue + } + 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 { + contentView = contentViewProvider(targetPage, nil) ?? UIView() + 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 { + discard(value) + } + return keep + } + + preloadedPageViews = preloadedPageViews.filter { key, value in + let keep = pageNumbers.contains(key) + if !keep { + discard(value) + } + return keep + } + } + + private func discard(_ view: UIView) { + // 只有闲置视图(未挂载或还在预加载宿主里)才能立即拆除内容; + // 挂在子 VC / cell 上的视图可能正在展示或会被 UIPageViewController + // 的缓存重新呈现,提前拆除会让翻页露出空白页。这类视图交由持有方 + // 释放后经 deinit 自然回收(弱代理已保证无保留环)。 + let isHeldOutsidePreloadHost = view.superview != nil && view.superview !== preloadHostView + view.removeFromSuperview() + if !isHeldOutsidePreloadHost { + (view as? RDEpubReaderPageResourceReleasing)?.releaseResources() + } + } + + 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.. RDReaderView.TapEvent { + ) -> 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) diff --git a/Sources/RDReaderView/ReaderView/RDReaderContentCell.swift b/Sources/RDEpubReaderView/ReaderView/RDEpubReaderContentCell.swift similarity index 94% rename from Sources/RDReaderView/ReaderView/RDReaderContentCell.swift rename to Sources/RDEpubReaderView/ReaderView/RDEpubReaderContentCell.swift index 18ec111..3d81284 100644 --- a/Sources/RDReaderView/ReaderView/RDReaderContentCell.swift +++ b/Sources/RDEpubReaderView/ReaderView/RDEpubReaderContentCell.swift @@ -1,7 +1,7 @@ import UIKit -class RDReaderContentCell: UICollectionViewCell { +class RDEpubReaderContentCell: UICollectionViewCell { private var _containerView: UIView? = nil diff --git a/Sources/RDReaderView/ReaderView/RDReaderFlowLayout.swift b/Sources/RDEpubReaderView/ReaderView/RDEpubReaderFlowLayout.swift similarity index 93% rename from Sources/RDReaderView/ReaderView/RDReaderFlowLayout.swift rename to Sources/RDEpubReaderView/ReaderView/RDEpubReaderFlowLayout.swift index 68e47a5..544c8b7 100644 --- a/Sources/RDReaderView/ReaderView/RDReaderFlowLayout.swift +++ b/Sources/RDEpubReaderView/ReaderView/RDEpubReaderFlowLayout.swift @@ -1,19 +1,19 @@ import UIKit -public protocol RDReaderFlowLayoutDataSoure: NSObjectProtocol { +public protocol RDEpubReaderFlowLayoutDataSoure: NSObjectProtocol { - func heigtOfVerticalScrollPage(flowLayout: RDReaderFlowLayout, pageIndex: Int) -> CGFloat? + func heigtOfVerticalScrollPage(flowLayout: RDEpubReaderFlowLayout, pageIndex: Int) -> CGFloat? } -@objc public protocol RDReaderFlowLayoutDelegate: NSObjectProtocol { +@objc public protocol RDEpubReaderFlowLayoutDelegate: NSObjectProtocol { - func pageNum(flowLayout: RDReaderFlowLayout, pageIndex: Int) + func pageNum(flowLayout: RDEpubReaderFlowLayout, pageIndex: Int) } -public class RDReaderFlowLayout: UICollectionViewFlowLayout { +public class RDEpubReaderFlowLayout: UICollectionViewFlowLayout { - var displayType: RDReaderView.DisplayType = .horizontalScroll { + var displayType: RDEpubReaderView.DisplayType = .horizontalScroll { didSet { invalidateLayout() } @@ -50,9 +50,9 @@ public class RDReaderFlowLayout: UICollectionViewFlowLayout { } } - weak var dataSource: RDReaderFlowLayoutDataSoure? = nil + weak var dataSource: RDEpubReaderFlowLayoutDataSoure? = nil - weak var delegate: RDReaderFlowLayoutDelegate? = nil + weak var delegate: RDEpubReaderFlowLayoutDelegate? = nil private var hasCoverPageInDualMode: Bool { return pagesPerScreen > 1 && coverPageIndex != nil @@ -98,13 +98,13 @@ public class RDReaderFlowLayout: UICollectionViewFlowLayout { private var currentPage: Int = 0 { didSet { - if let delegate = delegate, delegate.responds(to: #selector(RDReaderFlowLayoutDelegate.pageNum(flowLayout:pageIndex:))), currentPage != oldValue, currentPage >= 0 { + if let delegate = delegate, delegate.responds(to: #selector(RDEpubReaderFlowLayoutDelegate.pageNum(flowLayout:pageIndex:))), currentPage != oldValue, currentPage >= 0 { delegate.pageNum(flowLayout: self, pageIndex: currentPage) } } } - init(displayType: RDReaderView.DisplayType) { + init(displayType: RDEpubReaderView.DisplayType) { self.displayType = displayType super.init() diff --git a/Sources/RDReaderView/ReaderView/RDReaderGestureController.swift b/Sources/RDEpubReaderView/ReaderView/RDEpubReaderGestureController.swift similarity index 88% rename from Sources/RDReaderView/ReaderView/RDReaderGestureController.swift rename to Sources/RDEpubReaderView/ReaderView/RDEpubReaderGestureController.swift index 1aabe30..cb5ce0e 100644 --- a/Sources/RDReaderView/ReaderView/RDReaderGestureController.swift +++ b/Sources/RDEpubReaderView/ReaderView/RDEpubReaderGestureController.swift @@ -1,7 +1,7 @@ import UIKit -class RDReaderGestureController: UIViewController { +class RDEpubReaderGestureController: UIViewController { var topToolView: UIView? diff --git a/Sources/RDReaderView/ReaderView/RDReaderPageChildViewController.swift b/Sources/RDEpubReaderView/ReaderView/RDEpubReaderPageChildViewController.swift similarity index 95% rename from Sources/RDReaderView/ReaderView/RDReaderPageChildViewController.swift rename to Sources/RDEpubReaderView/ReaderView/RDEpubReaderPageChildViewController.swift index 7e21f24..015256a 100644 --- a/Sources/RDReaderView/ReaderView/RDReaderPageChildViewController.swift +++ b/Sources/RDEpubReaderView/ReaderView/RDEpubReaderPageChildViewController.swift @@ -1,7 +1,7 @@ import UIKit -class RDReaderPageChildViewController: UIViewController { +class RDEpubReaderPageChildViewController: UIViewController { private let contentContainerView = UIView() diff --git a/Sources/RDReaderView/ReaderView/RDReaderView+CollectionView.swift b/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView+CollectionView.swift similarity index 80% rename from Sources/RDReaderView/ReaderView/RDReaderView+CollectionView.swift rename to Sources/RDEpubReaderView/ReaderView/RDEpubReaderView+CollectionView.swift index d1a5800..f9ff05c 100644 --- a/Sources/RDReaderView/ReaderView/RDReaderView+CollectionView.swift +++ b/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView+CollectionView.swift @@ -1,16 +1,16 @@ import UIKit -extension RDReaderView: UICollectionViewDataSource, RDReaderFlowLayoutDelegate, RDReaderFlowLayoutDataSoure { +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! RDReaderContentCell + 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 { @@ -28,7 +28,7 @@ extension RDReaderView: UICollectionViewDataSource, RDReaderFlowLayoutDelegate, return numberOfPages() } - public func pageNum(flowLayout: RDReaderFlowLayout, pageIndex: Int) { + public func pageNum(flowLayout: RDEpubReaderFlowLayout, pageIndex: Int) { if currentPage >= 0, currentPage != pageIndex { predictedPageDirection = pageIndex >= currentPage } @@ -36,7 +36,7 @@ extension RDReaderView: UICollectionViewDataSource, RDReaderFlowLayoutDelegate, primePageCache(around: pageIndex, preferredForward: predictedPageDirection) } - public func heigtOfVerticalScrollPage(flowLayout: RDReaderFlowLayout, pageIndex: Int) -> CGFloat? { + public func heigtOfVerticalScrollPage(flowLayout: RDEpubReaderFlowLayout, pageIndex: Int) -> CGFloat? { return nil } } diff --git a/Sources/RDReaderView/ReaderView/RDReaderView+ContentAccess.swift b/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView+ContentAccess.swift similarity index 86% rename from Sources/RDReaderView/ReaderView/RDReaderView+ContentAccess.swift rename to Sources/RDEpubReaderView/ReaderView/RDEpubReaderView+ContentAccess.swift index 89f3ab8..a84e621 100644 --- a/Sources/RDReaderView/ReaderView/RDReaderView+ContentAccess.swift +++ b/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView+ContentAccess.swift @@ -1,15 +1,15 @@ import UIKit -extension RDReaderView { +extension RDEpubReaderView { public func register(contentView: UIView.Type, contentViewWithReuseIdentifier identifier: String) { contentViews[identifier] = contentView - collectionView.register(RDReaderContentCell.self, forCellWithReuseIdentifier: identifier) + 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? RDReaderContentCell, let containerView = cell.containerView { + 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] @@ -20,9 +20,9 @@ extension RDReaderView { public func pageContentView(pageNum: Int) -> UIView? { if currentDisplayType == .pageCurl { - return (self.pageViewController.viewControllers?.first as? RDReaderPageChildViewController)?.contentView + return (self.pageViewController.viewControllers?.first as? RDEpubReaderPageChildViewController)?.contentView } else { - let cell = collectionView.cellForItem(at: IndexPath(item: pageNum, section: 0)) as? RDReaderContentCell + let cell = collectionView.cellForItem(at: IndexPath(item: pageNum, section: 0)) as? RDEpubReaderContentCell return cell?.containerView } } @@ -37,7 +37,7 @@ extension RDReaderView { } if currentDisplayType == .pageCurl { - if let childViewController = pageViewController.viewControllers?.first as? RDReaderPageChildViewController, + if let childViewController = pageViewController.viewControllers?.first as? RDEpubReaderPageChildViewController, childViewController.view.bounds.width > 0, childViewController.view.bounds.height > 0 { return childViewController.view.bounds.size diff --git a/Sources/RDReaderView/ReaderView/RDReaderView+PageCurl.swift b/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView+PageCurl.swift similarity index 70% rename from Sources/RDReaderView/ReaderView/RDReaderView+PageCurl.swift rename to Sources/RDEpubReaderView/ReaderView/RDEpubReaderView+PageCurl.swift index c2b9be9..ae910c8 100644 --- a/Sources/RDReaderView/ReaderView/RDReaderView+PageCurl.swift +++ b/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView+PageCurl.swift @@ -1,28 +1,28 @@ import UIKit -extension RDReaderView: UIPageViewControllerDataSource, UIPageViewControllerDelegate { +extension RDEpubReaderView: UIPageViewControllerDataSource, UIPageViewControllerDelegate { - private func makeSinglePageChildVC(for pageNum: Int) -> RDReaderPageChildViewController { - if pageNum == RDReaderView.blankPageNum || pageNum == RDReaderView.blankEndPageNum { - return RDReaderPageChildViewController(contentView: UIView(), pageNum: pageNum) + 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 RDReaderPageChildViewController(contentView: contentView, pageNum: pageNum) + return RDEpubReaderPageChildViewController(contentView: contentView, pageNum: pageNum) } private func nextPageNum(after pageNum: Int, isDualPage: Bool) -> Int? { let totalPages = numberOfPages() - if pageNum == RDReaderView.blankEndPageNum { + if pageNum == RDEpubReaderView.blankEndPageNum { return nil } if isDualPage, let coverIndex = coverPageIndex { if pageNum == coverIndex { - return RDReaderView.blankPageNum + return RDEpubReaderView.blankPageNum } - if pageNum == RDReaderView.blankPageNum { + if pageNum == RDEpubReaderView.blankPageNum { let firstContent = coverIndex + 1 return firstContent < totalPages ? firstContent : nil } @@ -37,11 +37,11 @@ extension RDReaderView: UIPageViewControllerDataSource, UIPageViewControllerDele if let coverIndex = coverPageIndex { let adjustedIndex = pageNum - (coverIndex + 1) if adjustedIndex >= 0 && adjustedIndex % 2 == 0 { - return RDReaderView.blankEndPageNum + return RDEpubReaderView.blankEndPageNum } } else { if pageNum % 2 == 0 { - return RDReaderView.blankEndPageNum + return RDEpubReaderView.blankEndPageNum } } } @@ -49,16 +49,16 @@ extension RDReaderView: UIPageViewControllerDataSource, UIPageViewControllerDele } private func prevPageNum(before pageNum: Int, isDualPage: Bool) -> Int? { - if pageNum == RDReaderView.blankEndPageNum { + if pageNum == RDEpubReaderView.blankEndPageNum { let totalPages = numberOfPages() return totalPages > 0 ? totalPages - 1 : nil } if isDualPage, let coverIndex = coverPageIndex { - if pageNum == RDReaderView.blankPageNum { + if pageNum == RDEpubReaderView.blankPageNum { return coverIndex } if pageNum == coverIndex + 1 { - return RDReaderView.blankPageNum + return RDEpubReaderView.blankPageNum } } let prev = pageNum - 1 @@ -66,7 +66,7 @@ extension RDReaderView: UIPageViewControllerDataSource, UIPageViewControllerDele } public func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? { - guard let vc = viewController as? RDReaderPageChildViewController else { return nil } + guard let vc = viewController as? RDEpubReaderPageChildViewController else { return nil } let isDualPage = landscapeDualPageEnabled && isLandscape let isRTL = pageDirection == .rightToLeft @@ -81,7 +81,7 @@ extension RDReaderView: UIPageViewControllerDataSource, UIPageViewControllerDele } public func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? { - guard let vc = viewController as? RDReaderPageChildViewController else { return nil } + guard let vc = viewController as? RDEpubReaderPageChildViewController else { return nil } let isDualPage = landscapeDualPageEnabled && isLandscape let isRTL = pageDirection == .rightToLeft @@ -96,10 +96,10 @@ extension RDReaderView: UIPageViewControllerDataSource, UIPageViewControllerDele } public func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) { - let resolvedPage = (pageViewController.viewControllers?.first as? RDReaderPageChildViewController)?.pageNum ?? -1 - if completed, let firstVC = pageViewController.viewControllers?.first as? RDReaderPageChildViewController { + let resolvedPage = (pageViewController.viewControllers?.first as? RDEpubReaderPageChildViewController)?.pageNum ?? -1 + if completed, let firstVC = pageViewController.viewControllers?.first as? RDEpubReaderPageChildViewController { let pn = firstVC.pageNum - if pn != RDReaderView.blankPageNum && pn != RDReaderView.blankEndPageNum { + if pn != RDEpubReaderView.blankPageNum && pn != RDEpubReaderView.blankEndPageNum { currentPage = pn primePageCache(around: pn, preferredForward: predictedPageDirection) } @@ -112,9 +112,9 @@ extension RDReaderView: UIPageViewControllerDataSource, UIPageViewControllerDele public func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) { pagingController.isTransitioning = true willTransitionToViewController = pendingViewControllers.first - if let target = pendingViewControllers.first as? RDReaderPageChildViewController, - target.pageNum != RDReaderView.blankPageNum, - target.pageNum != RDReaderView.blankEndPageNum { + 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/RDReaderView/ReaderView/RDReaderView+ToolView.swift b/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView+ToolView.swift similarity index 91% rename from Sources/RDReaderView/ReaderView/RDReaderView+ToolView.swift rename to Sources/RDEpubReaderView/ReaderView/RDEpubReaderView+ToolView.swift index c12d0d8..881c61c 100644 --- a/Sources/RDReaderView/ReaderView/RDReaderView+ToolView.swift +++ b/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView+ToolView.swift @@ -1,14 +1,14 @@ import UIKit -extension RDReaderView { +extension RDEpubReaderView { func tapCenter() { refreshToolViewsFromProviderIfNeeded() isShowToolView = !isShowToolView - RDReaderTapDebug.log( + RDEpubReaderTapDebug.log( "ReaderView.tapCenter", - "toggle toolView visible=\(isShowToolView) top=\(RDReaderTapDebug.describe(topToolView)) bottom=\(RDReaderTapDebug.describe(bottomToolView))" + "toggle toolView visible=\(isShowToolView) top=\(RDEpubReaderTapDebug.describe(topToolView)) bottom=\(RDEpubReaderTapDebug.describe(bottomToolView))" ) if isShowToolView { if let topToolView = topToolView { @@ -31,7 +31,7 @@ extension RDReaderView { collectionView.isUserInteractionEnabled = false pageViewController.view.isUserInteractionEnabled = false - RDReaderTapDebug.log("ReaderView.tapCenter", "disabled collectionView/pageViewController interaction while toolView is visible") + RDEpubReaderTapDebug.log("ReaderView.tapCenter", "disabled collectionView/pageViewController interaction while toolView is visible") } else { if let topToolView = topToolView { UIView.animate(withDuration: toolViewAnimationDuration, animations: { @@ -51,7 +51,7 @@ extension RDReaderView { collectionView.isUserInteractionEnabled = true pageViewController.view.isUserInteractionEnabled = true - RDReaderTapDebug.log("ReaderView.tapCenter", "re-enabled collectionView/pageViewController interaction after hiding toolView") + RDEpubReaderTapDebug.log("ReaderView.tapCenter", "re-enabled collectionView/pageViewController interaction after hiding toolView") } onToolViewVisibilityChanged?(isShowToolView) } diff --git a/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView.swift b/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView.swift new file mode 100644 index 0000000..9c82532 --- /dev/null +++ b/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView.swift @@ -0,0 +1,961 @@ + +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? + + private var pendingIsLandscape: Bool? + + private var isWaitingForOrientationTransitionCompletion = false + + 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 + scheduleOrientationChange(isNowLandscape: nowLandscape) + } else if previousIsLandscape == nil { + previousIsLandscape = nowLandscape + layout.isLandscapeDualPage = landscapeDualPageEnabled && nowLandscape + } + } + + private func scheduleOrientationChange(isNowLandscape: Bool) { + pendingIsLandscape = isNowLandscape + + // UIPageViewController asserts if it is detached/rebuilt while UIKit is + // still executing its own rotation callbacks. Rebuild the single/dual + // page controller only after the enclosing transition has completed. + if let coordinator = ss_superViewController?.transitionCoordinator, + coordinator.isAnimated { + guard !isWaitingForOrientationTransitionCompletion else { return } + isWaitingForOrientationTransitionCompletion = true + coordinator.animate(alongsideTransition: nil) { [weak self] _ in + guard let self else { return } + self.isWaitingForOrientationTransitionCompletion = false + self.applyPendingOrientationChange() + } + return + } + + DispatchQueue.main.async { [weak self] in + self?.applyPendingOrientationChange() + } + } + + private func applyPendingOrientationChange() { + guard let isNowLandscape = pendingIsLandscape else { return } + pendingIsLandscape = nil + orientationChanged(isNowLandscape: isNowLandscape) + } + + 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: pageContentViewForPreload(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 pageContentViewForPreload(pageNum: Int, reusableView: UIView?) -> UIView? { + 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/ReaderView/RDEpubReaderViewProtocols.swift b/Sources/RDEpubReaderView/ReaderView/RDEpubReaderViewProtocols.swift new file mode 100644 index 0000000..c579d01 --- /dev/null +++ b/Sources/RDEpubReaderView/ReaderView/RDEpubReaderViewProtocols.swift @@ -0,0 +1,89 @@ + +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? + + @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) + } +} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFI.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFI.swift new file mode 100644 index 0000000..f884ac1 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFI.swift @@ -0,0 +1,52 @@ +import Foundation + +public struct RDEPUBCFI: Codable, Equatable, Hashable { + public var packagePath: RDEPUBCFIPath + public var contentPath: RDEPUBCFIPath + public var characterOffset: Int? + public var sideBias: RDEPUBCFISideBias? + public var textAssertion: RDEPUBCFITextAssertion? + + /// Always computed from components — no stale cached value risk. + public var rawValue: String { + RDEPUBCFISerializer.serialize(self) + } + + /// Initialize from components. The `rawValue` parameter is ignored — rawValue is always + /// computed from the components. To parse a CFI string, use `RDEPUBCFIParser.parse(_:)`. + public init( + rawValue: String = "", + packagePath: RDEPUBCFIPath = RDEPUBCFIPath(), + contentPath: RDEPUBCFIPath = RDEPUBCFIPath(), + characterOffset: Int? = nil, + sideBias: RDEPUBCFISideBias? = nil, + textAssertion: RDEPUBCFITextAssertion? = nil + ) { + self.packagePath = packagePath + self.contentPath = contentPath + self.characterOffset = characterOffset + self.sideBias = sideBias + self.textAssertion = textAssertion + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(String.self) + let parsed = try RDEPUBCFIParser.parse(rawValue) + self.packagePath = parsed.packagePath + self.contentPath = parsed.contentPath + self.characterOffset = parsed.characterOffset + self.sideBias = parsed.sideBias + self.textAssertion = parsed.textAssertion + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} + +public enum RDEPUBCFISideBias: String, Codable, Equatable, Hashable { + case before = "b" + case after = "a" +} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFICompatibility.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFICompatibility.swift new file mode 100644 index 0000000..20abcdd --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFICompatibility.swift @@ -0,0 +1,29 @@ +import Foundation + +public enum RDEPUBCFICompatibility { + public static func parseLossy(_ rawValue: String?) -> RDEPUBCFI? { + try? RDEPUBCFIParser.parse(rawValue) + } + + public static func parseRangeLossy(_ rawValue: String?) -> RDEPUBCFIRange? { + if let parsed = try? RDEPUBCFIParser.parseRange(rawValue) { + return parsed + } + + guard let rawValue, + !rawValue.isEmpty else { + return nil + } + + let separators = ["..", "-"] + let separator = separators.first(where: { rawValue.contains($0) }) + guard let separator else { return nil } + let parts = rawValue.components(separatedBy: separator) + guard parts.count == 2, + let start = parseLossy(parts[0]), + let end = parseLossy(parts[1]) else { + return nil + } + return RDEPUBCFIRange(parent: nil, start: start, end: end) + } +} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIDOMPathBuilder.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIDOMPathBuilder.swift new file mode 100644 index 0000000..671823d --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIDOMPathBuilder.swift @@ -0,0 +1,575 @@ +import Foundation + +public enum RDEPUBCFIDOMPathBuilder { + public static func fragmentPaths(in html: String) -> [String: RDEPUBCFIPath] { + // Pre-strip HTML comments and CDATA to avoid false tag matches inside them + let cleaned = stripCommentsAndCDATA(html) + + guard let regex = try? NSRegularExpression( + pattern: #"]*)>"#, + options: [.caseInsensitive] + ) else { + return [:] + } + + let nsHTML = cleaned as NSString + var stack: [RDEPUBCFIStep] = [] + var childCountsByDepth: [Int: Int] = [:] + var paths: [String: RDEPUBCFIPath] = [:] + + for match in regex.matches(in: cleaned, range: NSRange(location: 0, length: nsHTML.length)) { + let rawTag = nsHTML.substring(with: match.range) + guard match.numberOfRanges > 2 else { continue } + let tagName = nsHTML.substring(with: match.range(at: 1)).lowercased() + guard !isIgnorableTag(tagName) else { continue } + + if rawTag.hasPrefix(""), !isVoidTag(tagName) { + stack.append(step) + childCountsByDepth[stack.count] = nil + } + } + + return paths + } + + static func idAttribute(in attributes: String) -> String? { + guard let regex = try? NSRegularExpression( + pattern: #"(?:^|\s)(?:id|xml:id)\s*=\s*(['"])(.*?)\1"#, + options: [.caseInsensitive] + ) else { + return nil + } + let nsAttributes = attributes as NSString + guard let match = regex.firstMatch( + in: attributes, + range: NSRange(location: 0, length: nsAttributes.length) + ), + match.numberOfRanges > 2 else { + return nil + } + return nsAttributes.substring(with: match.range(at: 2)).rd_nilIfEmpty + } + + static func isIgnorableTag(_ tagName: String) -> Bool { + tagName == "!doctype" || tagName.hasPrefix("?") + } + + /// Strip HTML comments `` and CDATA sections `` from HTML + /// to prevent the tag regex from matching false tags inside them. + static func stripCommentsAndCDATA(_ html: String) -> String { + var result = html + // Strip comments: + if let commentRegex = try? NSRegularExpression(pattern: #""#, options: []) { + let nsResult = result as NSString + let matches = commentRegex.matches(in: result, range: NSRange(location: 0, length: nsResult.length)) + for match in matches.reversed() { + result = nsResult.replacingCharacters(in: match.range, with: "") + } + } + // Strip CDATA: + if let cdataRegex = try? NSRegularExpression(pattern: #""#, options: []) { + let nsResult = result as NSString + let matches = cdataRegex.matches(in: result, range: NSRange(location: 0, length: nsResult.length)) + for match in matches.reversed() { + result = nsResult.replacingCharacters(in: match.range, with: "") + } + } + return result + } + + static func isVoidTag(_ tagName: String) -> Bool { + [ + "area", "base", "br", "col", "embed", "hr", "img", "input", + "link", "meta", "param", "source", "track", "wbr" + ].contains(tagName) + } +} + +enum RDEPUBCFITextNodeMapBuilder { + static func normalizedText(from source: String) -> String { + RDEPUBNormalizedTextIndex.normalize(source) + } + + static func makeMap( + href: String, + rawHTML: String, + chapterText: String, + fragmentOffsets: [String: Int] + ) -> RDEPUBCFIMap { + let normalizedChapter = RDEPUBNormalizedTextIndex(source: chapterText) + let fragmentPaths = RDEPUBCFIDOMPathBuilder.fragmentPaths(in: rawHTML) + let markers = makeMarkers( + href: href, + rawHTML: rawHTML, + chapterText: chapterText, + fragmentOffsets: fragmentOffsets + ) + let pathRanges = makePathRanges(from: markers) + return RDEPUBCFIMap( + href: href, + markers: markers, + pathRanges: pathRanges, + recoveryMetadata: RDEPUBCFIRecoveryMetadata( + domFingerprint: rawHTML.rd_sha256Hex, + normalizedTextChecksum: normalizedChapter.normalizedText.rd_sha256Hex, + tokenIndex: makeTokenAnchors( + normalizedChapter: normalizedChapter, + markers: markers, + pathRanges: pathRanges + ), + fragmentPathMap: fragmentPaths + ) + ) + } + + static func makeMarkers( + href: String, + rawHTML: String, + chapterText: String, + fragmentOffsets: [String: Int] + ) -> [RDEPUBCFIMarker] { + let normalizedChapter = RDEPUBNormalizedTextIndex(source: chapterText) + guard !normalizedChapter.normalizedText.isEmpty else { + return fragmentMarkers(fragmentOffsets: fragmentOffsets) + } + + // Use comment/CDATA-stripped HTML for both path building and tag matching + let cleanedHTML = RDEPUBCFIDOMPathBuilder.stripCommentsAndCDATA(rawHTML) + + let fragments = RDEPUBCFIDOMPathBuilder.fragmentPaths(in: rawHTML) + var markers: [RDEPUBCFIMarker] = fragmentMarkers(fragmentOffsets: fragmentOffsets, paths: fragments) + var searchLocation = 0 + var stack: [RDEPUBCFIStep] = [] + var childCountsByDepth: [Int: Int] = [:] + var lastTagEnd = cleanedHTML.startIndex + + for tagMatch in RDEPUBHTMLTagMatch.matches(in: cleanedHTML) { + let textChunk = String(cleanedHTML[lastTagEnd.. [RDEPUBCFIMarker] { + fragmentOffsets + .sorted { $0.value < $1.value } + .map { fragmentID, offset in + RDEPUBCFIMarker( + cfiPath: paths[fragmentID] ?? RDEPUBCFIPath(), + chapterOffset: offset, + fragmentID: fragmentID + ) + } + } + + private static func textMarker( + for rawText: String, + stack: [RDEPUBCFIStep], + childCountsByDepth: inout [Int: Int], + normalizedChapter: RDEPUBNormalizedTextIndex, + searchLocation: inout Int + ) -> RDEPUBCFIMarker? { + let normalizedNodeText = RDEPUBNormalizedTextIndex.normalize(rawText) + guard !normalizedNodeText.isEmpty else { return nil } + + let depth = stack.count + let nextChildIndex = (childCountsByDepth[depth] ?? 0) + 1 + childCountsByDepth[depth] = nextChildIndex + let textStep = RDEPUBCFIStep(index: nextChildIndex * 2 - 1) + let path = RDEPUBCFIPath(steps: stack + [textStep]) + + // Search using UTF-16 offsets to match normalizedToChapterOffsets indices + let nsNormalized = normalizedChapter.normalizedText as NSString + let searchRange = NSRange(location: searchLocation, length: nsNormalized.length - searchLocation) + guard let foundRange = nsNormalized.range(of: normalizedNodeText, options: [], range: searchRange).toRange(), + foundRange.lowerBound >= searchLocation else { + return nil + } + + searchLocation = foundRange.upperBound + let normalizedOffset = foundRange.lowerBound + guard let chapterOffset = normalizedChapter.chapterOffset(forNormalizedOffset: normalizedOffset) else { + return nil + } + + return RDEPUBCFIMarker( + cfiPath: path, + chapterOffset: chapterOffset, + textNodeLength: normalizedNodeText.utf16.count, + textNodeChecksum: normalizedNodeText.fnv1a64, + normalizedTextPreview: previewText(for: normalizedNodeText), + domSiblingSignature: siblingSignature(stack: stack, childIndex: nextChildIndex) + ) + } + + private static func processTag( + _ match: RDEPUBHTMLTagMatch, + stack: inout [RDEPUBCFIStep], + childCountsByDepth: inout [Int: Int] + ) { + let tagName = match.name.lowercased() + guard !RDEPUBCFIDOMPathBuilder.isIgnorableTag(tagName) else { return } + + if match.isClosing { + if tagName == "html" { + return + } + if !stack.isEmpty { + stack.removeLast() + childCountsByDepth[stack.count + 1] = nil + } + return + } + + if tagName == "html" { + childCountsByDepth[0] = nil + return + } + + let depth = stack.count + let nextChildIndex = (childCountsByDepth[depth] ?? 0) + 1 + childCountsByDepth[depth] = nextChildIndex + + let fragmentID = RDEPUBCFIDOMPathBuilder.idAttribute(in: match.attributes) + let step = RDEPUBCFIStep(index: nextChildIndex * 2, idAssertion: fragmentID) + if !match.isSelfClosing && !RDEPUBCFIDOMPathBuilder.isVoidTag(tagName) { + stack.append(step) + childCountsByDepth[stack.count] = nil + } + } + + private static func makePathRanges(from markers: [RDEPUBCFIMarker]) -> [RDEPUBCFIPathRange] { + markers.compactMap { marker in + guard let startOffset = marker.chapterOffset, + let textNodeLength = marker.textNodeLength, + textNodeLength > 0 else { + return nil + } + return RDEPUBCFIPathRange( + cfiPath: marker.cfiPath, + startOffset: startOffset, + endOffset: startOffset + max(textNodeLength - 1, 0), + textNodeLength: textNodeLength + ) + } + } + + private static func makeTokenAnchors( + normalizedChapter: RDEPUBNormalizedTextIndex, + markers: [RDEPUBCFIMarker], + pathRanges: [RDEPUBCFIPathRange] + ) -> [RDEPUBCFITokenAnchor] { + guard !normalizedChapter.normalizedText.isEmpty else { return [] } + + let sortedRanges = pathRanges.sorted { $0.startOffset < $1.startOffset } + var occurrenceByToken: [String: Int] = [:] + var anchors: [RDEPUBCFITokenAnchor] = [] + let samples = normalizedChapter.tokenSamples(maxSamples: 64, stride: 96, window: 12) + + for sample in samples { + guard let chapterOffset = normalizedChapter.chapterOffset(forNormalizedOffset: sample.offset), + let path = path(for: chapterOffset, ranges: sortedRanges, markers: markers) else { + continue + } + let occurrence = (occurrenceByToken[sample.token] ?? 0) + 1 + occurrenceByToken[sample.token] = occurrence + anchors.append( + RDEPUBCFITokenAnchor( + token: sample.token, + occurrence: occurrence, + chapterOffset: chapterOffset, + cfiPath: path + ) + ) + } + + return anchors + } + + private static func path( + for chapterOffset: Int, + ranges: [RDEPUBCFIPathRange], + markers: [RDEPUBCFIMarker] + ) -> RDEPUBCFIPath? { + if let range = ranges.first(where: { chapterOffset >= $0.startOffset && chapterOffset <= $0.endOffset }) { + return range.cfiPath + } + return markers + .filter { $0.chapterOffset != nil } + .min(by: { + abs(($0.chapterOffset ?? 0) - chapterOffset) < abs(($1.chapterOffset ?? 0) - chapterOffset) + })? + .cfiPath + } + + private static func previewText(for normalizedText: String, limit: Int = 24) -> String { + String(normalizedText.prefix(limit)) + } + + private static func siblingSignature(stack: [RDEPUBCFIStep], childIndex: Int) -> String { + let parent = stack.map { String($0.index) }.joined(separator: "/") + return "\(parent)#\(childIndex)" + } +} + +private struct RDEPUBHTMLTagMatch { + let range: Range + let name: String + let attributes: String + let isClosing: Bool + let isSelfClosing: Bool + + static func matches(in html: String) -> [RDEPUBHTMLTagMatch] { + let cleaned = RDEPUBCFIDOMPathBuilder.stripCommentsAndCDATA(html) + + guard let regex = try? NSRegularExpression( + pattern: #"]*)>"#, + options: [.caseInsensitive] + ) else { + return [] + } + + let nsHTML = cleaned as NSString + return regex.matches(in: cleaned, range: NSRange(location: 0, length: nsHTML.length)).compactMap { match in + guard match.numberOfRanges > 2, + let range = Range(match.range, in: cleaned), + let nameRange = Range(match.range(at: 1), in: cleaned), + let attributesRange = Range(match.range(at: 2), in: cleaned) else { + return nil + } + + let rawTag = String(cleaned[range]) + return RDEPUBHTMLTagMatch( + range: range, + name: String(cleaned[nameRange]), + attributes: String(cleaned[attributesRange]), + isClosing: rawTag.hasPrefix("") + ) + } + } +} + +private struct RDEPUBNormalizedTextIndex { + let normalizedText: String + private let normalizedToChapterOffsets: [Int] + + init(source: String) { + var text = "" + var offsets: [Int] = [] + let nsSource = source as NSString + var lastWasWhitespace = false + + for index in 0.. Int? { + guard normalizedToChapterOffsets.indices.contains(offset) else { return nil } + return normalizedToChapterOffsets[offset] + } + + func tokenSamples(maxSamples: Int, stride: Int, window: Int) -> [(token: String, offset: Int)] { + guard !normalizedText.isEmpty, stride > 0, window > 0 else { return [] } + // Use NSString-based (UTF-16) iteration to match normalizedToChapterOffsets indices + let nsText = normalizedText as NSString + let length = nsText.length + var samples: [(token: String, offset: Int)] = [] + var index = 0 + + while index < length, samples.count < maxSamples { + let end = min(index + window, length) + let token = nsText.substring(with: NSRange(location: index, length: end - index)) + .trimmingCharacters(in: .whitespacesAndNewlines) + if token.utf16.count >= min(window, 4) { + samples.append((token, index)) + } + index += stride + } + + if samples.isEmpty { + let token = nsText.substring(with: NSRange(location: 0, length: min(window, length))) + .trimmingCharacters(in: .whitespacesAndNewlines) + if !token.isEmpty { + samples.append((token, 0)) + } + } + + return samples + } + + static func normalize(_ source: String) -> String { + var result = "" + var lastWasWhitespace = false + let decodedSource = RDEPUBHTMLEntityDecoder.decodeEntities(in: source) + let nsSource = decodedSource as NSString + + for index in 0.. Character { + if scalar == "\u{00A0}" { + return " " + } + if scalar.unicodeScalars.allSatisfy(\.properties.isWhitespace) { + return " " + } + return scalar.first ?? " " + } +} + +private extension String { + var fnv1a64: UInt64 { + let offsetBasis: UInt64 = 14695981039346656037 + let prime: UInt64 = 1099511628211 + return utf8.reduce(offsetBasis) { partialResult, byte in + (partialResult ^ UInt64(byte)) &* prime + } + } +} + +private enum RDEPUBHTMLEntityDecoder { + private static let namedEntities: [String: String] = [ + " ": " ", + "&": "&", + "<": "<", + ">": ">", + """: "\"", + "'": "'" + ] + + static func decode(_ value: String) -> String { + if let mapped = namedEntities[value] { + return mapped + } + guard value.hasPrefix("&"), value.hasSuffix(";") else { + return value + } + let body = String(value.dropFirst().dropLast()) + if body.hasPrefix("#x") || body.hasPrefix("#X"), + let scalar = UInt32(body.dropFirst(2), radix: 16).flatMap(UnicodeScalar.init) { + return String(Character(scalar)) + } + if body.hasPrefix("#"), + let scalar = UInt32(body.dropFirst(), radix: 10).flatMap(UnicodeScalar.init) { + return String(Character(scalar)) + } + return value + } + + static func decodeEntities(in value: String) -> String { + guard value.contains("&"), + let regex = try? NSRegularExpression(pattern: #"&(?:[A-Za-z]+|#\d+|#x[0-9A-Fa-f]+);"#) else { + return value + } + + let nsValue = value as NSString + var decoded = value + for match in regex.matches(in: value, range: NSRange(location: 0, length: nsValue.length)).reversed() { + let entity = nsValue.substring(with: match.range) + let replacement = decode(entity) + if let range = Range(match.range, in: decoded) { + decoded.replaceSubrange(range, with: replacement) + } + } + return decoded + } +} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIError.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIError.swift new file mode 100644 index 0000000..84f68b6 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIError.swift @@ -0,0 +1,8 @@ +import Foundation + +public enum RDEPUBCFIError: Error, Equatable { + case empty + case invalidWrapper(String) + case invalidPath(String) + case unsupportedRange(String) +} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIGenerator.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIGenerator.swift new file mode 100644 index 0000000..feb750e --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIGenerator.swift @@ -0,0 +1,87 @@ +import Foundation + +public enum RDEPUBCFIGenerator { + + public static func makeOffsetCFI( + href: String, + fileIndex: Int, + chapterOffset: Int, + fragmentID: String? = nil, + sideBias: RDEPUBCFISideBias? = nil, + textAssertion: RDEPUBCFITextAssertion? = nil, + contentPath: RDEPUBCFIPath? = nil + ) -> RDEPUBCFI { + let packagePath = RDEPUBCFIPath(steps: [ + RDEPUBCFIStep(index: 6), + RDEPUBCFIStep(index: max((fileIndex + 1) * 2, 2), idAssertion: href) + ]) + let resolvedContentPath = contentPath ?? RDEPUBCFIPath(steps: [ + RDEPUBCFIStep(index: 4), + RDEPUBCFIStep(index: 2, idAssertion: fragmentID) + ]) + return RDEPUBCFI( + packagePath: packagePath, + contentPath: resolvedContentPath, + characterOffset: max(chapterOffset, 0), + sideBias: sideBias, + textAssertion: textAssertion + ) + } + + public static func makeCFI( + href: String, + fileIndex: Int, + contentPath: RDEPUBCFIPath, + characterOffset: Int, + sideBias: RDEPUBCFISideBias? = nil, + textAssertion: RDEPUBCFITextAssertion? = nil + ) -> RDEPUBCFI { + let packagePath = RDEPUBCFIPath(steps: [ + RDEPUBCFIStep(index: 6), + RDEPUBCFIStep(index: max((fileIndex + 1) * 2, 2), idAssertion: href) + ]) + return RDEPUBCFI( + packagePath: packagePath, + contentPath: contentPath, + characterOffset: max(characterOffset, 0), + sideBias: sideBias, + textAssertion: textAssertion + ) + } + + public static func makeOffsetRangeCFI( + href: String, + fileIndex: Int, + startOffset: Int, + endOffset: Int, + fragmentID: String? = nil, + startTextAssertion: RDEPUBCFITextAssertion? = nil, + endTextAssertion: RDEPUBCFITextAssertion? = nil, + contentPath: RDEPUBCFIPath? = nil + ) -> RDEPUBCFIRange { + precondition(startOffset <= endOffset, "startOffset (\(startOffset)) must be <= endOffset (\(endOffset))") + let start = makeOffsetCFI( + href: href, + fileIndex: fileIndex, + chapterOffset: startOffset, + fragmentID: fragmentID, + sideBias: .before, + textAssertion: startTextAssertion, + contentPath: contentPath + ) + let end = makeOffsetCFI( + href: href, + fileIndex: fileIndex, + chapterOffset: endOffset, + fragmentID: fragmentID, + sideBias: .after, + textAssertion: endTextAssertion, + contentPath: contentPath + ) + let parent = RDEPUBCFI( + packagePath: start.packagePath.commonPrefix(with: end.packagePath), + contentPath: start.contentPath.commonPrefix(with: end.contentPath) + ) + return RDEPUBCFIRange(parent: parent, start: start, end: end) + } +} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIMap.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIMap.swift new file mode 100644 index 0000000..8b22365 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIMap.swift @@ -0,0 +1,182 @@ +import Foundation + +public struct RDEPUBCFIMap: Codable, Equatable { + public var href: String + public var renderVersion: Int + public var domVersion: Int + public var markers: [RDEPUBCFIMarker] + public var textAssertions: [String: RDEPUBCFITextAssertion] + public var pathRanges: [RDEPUBCFIPathRange] + public var recoveryMetadata: RDEPUBCFIRecoveryMetadata + + /// Lazy O(1) lookup index for markers by cfiPath + private var markerByPath: [RDEPUBCFIPath: Int]? + + public init( + href: String, + renderVersion: Int = 1, + domVersion: Int = 1, + markers: [RDEPUBCFIMarker] = [], + textAssertions: [String: RDEPUBCFITextAssertion] = [:], + pathRanges: [RDEPUBCFIPathRange] = [], + recoveryMetadata: RDEPUBCFIRecoveryMetadata = .empty + ) { + self.href = href + self.renderVersion = renderVersion + self.domVersion = domVersion + self.markers = markers + self.textAssertions = textAssertions + self.pathRanges = pathRanges + self.recoveryMetadata = recoveryMetadata + } + + public func marker(matching path: RDEPUBCFIPath) -> RDEPUBCFIMarker? { + if let index = markerByPath?[path] { + return markers[index] + } + return markers.first { $0.cfiPath == path } + } + + /// Build or rebuild the marker lookup index. Call after mutating `markers`. + public mutating func rebuildMarkerIndex() { + markerByPath = Dictionary( + markers.enumerated().map { ($0.element.cfiPath, $0.offset) }, + uniquingKeysWith: { _, last in last } + ) + } + + enum CodingKeys: String, CodingKey { + case href + case renderVersion + case domVersion + case markers + case textAssertions + case pathRanges + case recoveryMetadata + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + href = try container.decode(String.self, forKey: .href) + renderVersion = try container.decodeIfPresent(Int.self, forKey: .renderVersion) ?? 1 + domVersion = try container.decodeIfPresent(Int.self, forKey: .domVersion) ?? 1 + markers = try container.decodeIfPresent([RDEPUBCFIMarker].self, forKey: .markers) ?? [] + textAssertions = try container.decodeIfPresent([String: RDEPUBCFITextAssertion].self, forKey: .textAssertions) ?? [:] + pathRanges = try container.decodeIfPresent([RDEPUBCFIPathRange].self, forKey: .pathRanges) ?? [] + recoveryMetadata = try container.decodeIfPresent(RDEPUBCFIRecoveryMetadata.self, forKey: .recoveryMetadata) ?? .empty + markerByPath = nil + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(href, forKey: .href) + try container.encode(renderVersion, forKey: .renderVersion) + try container.encode(domVersion, forKey: .domVersion) + try container.encode(markers, forKey: .markers) + try container.encode(textAssertions, forKey: .textAssertions) + try container.encode(pathRanges, forKey: .pathRanges) + try container.encode(recoveryMetadata, forKey: .recoveryMetadata) + } + + public static func == (lhs: RDEPUBCFIMap, rhs: RDEPUBCFIMap) -> Bool { + lhs.href == rhs.href + && lhs.renderVersion == rhs.renderVersion + && lhs.domVersion == rhs.domVersion + && lhs.markers == rhs.markers + && lhs.textAssertions == rhs.textAssertions + && lhs.pathRanges == rhs.pathRanges + && lhs.recoveryMetadata == rhs.recoveryMetadata + } +} + +public struct RDEPUBCFIMarker: Codable, Equatable { + public var cfiPath: RDEPUBCFIPath + public var chapterOffset: Int? + public var fragmentID: String? + public var textNodeLength: Int? + public var textNodeChecksum: UInt64? + public var normalizedTextPreview: String? + public var domSiblingSignature: String? + + public init( + cfiPath: RDEPUBCFIPath, + chapterOffset: Int? = nil, + fragmentID: String? = nil, + textNodeLength: Int? = nil, + textNodeChecksum: UInt64? = nil, + normalizedTextPreview: String? = nil, + domSiblingSignature: String? = nil + ) { + self.cfiPath = cfiPath + self.chapterOffset = chapterOffset + self.fragmentID = fragmentID + self.textNodeLength = textNodeLength + self.textNodeChecksum = textNodeChecksum + self.normalizedTextPreview = normalizedTextPreview + self.domSiblingSignature = domSiblingSignature + } +} + +public struct RDEPUBCFIPathRange: Codable, Equatable { + public var cfiPath: RDEPUBCFIPath + public var startOffset: Int + public var endOffset: Int + public var textNodeLength: Int + + public init( + cfiPath: RDEPUBCFIPath, + startOffset: Int, + endOffset: Int, + textNodeLength: Int + ) { + self.cfiPath = cfiPath + self.startOffset = startOffset + self.endOffset = endOffset + self.textNodeLength = textNodeLength + } +} + +public struct RDEPUBCFIRecoveryMetadata: Codable, Equatable { + public var domFingerprint: String + public var normalizedTextChecksum: String + public var tokenIndex: [RDEPUBCFITokenAnchor] + public var fragmentPathMap: [String: RDEPUBCFIPath] + + public init( + domFingerprint: String, + normalizedTextChecksum: String, + tokenIndex: [RDEPUBCFITokenAnchor] = [], + fragmentPathMap: [String: RDEPUBCFIPath] = [:] + ) { + self.domFingerprint = domFingerprint + self.normalizedTextChecksum = normalizedTextChecksum + self.tokenIndex = tokenIndex + self.fragmentPathMap = fragmentPathMap + } + + public static let empty = RDEPUBCFIRecoveryMetadata( + domFingerprint: "", + normalizedTextChecksum: "", + tokenIndex: [], + fragmentPathMap: [:] + ) +} + +public struct RDEPUBCFITokenAnchor: Codable, Equatable { + public var token: String + public var occurrence: Int + public var chapterOffset: Int + public var cfiPath: RDEPUBCFIPath + + public init( + token: String, + occurrence: Int, + chapterOffset: Int, + cfiPath: RDEPUBCFIPath + ) { + self.token = token + self.occurrence = occurrence + self.chapterOffset = chapterOffset + self.cfiPath = cfiPath + } +} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIParser.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIParser.swift new file mode 100644 index 0000000..c0982bd --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIParser.swift @@ -0,0 +1,271 @@ +import Foundation + +public enum RDEPUBCFIParser { + + public static func parse(_ rawValue: String?) throws -> RDEPUBCFI { + + guard let rawValue = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines), + !rawValue.isEmpty else { + throw RDEPUBCFIError.empty + } + + let body = try unwrap(rawValue) + + let parts = body.split(separator: "!", maxSplits: 1, omittingEmptySubsequences: false) + + let packagePath = try parsePath(String(parts.first ?? "")) + + let contentBody = parts.count > 1 ? String(parts[1]) : "" + + let parsedContent = try parsePathWithOffset(contentBody) + + return RDEPUBCFI( + packagePath: packagePath, + contentPath: parsedContent.path, + characterOffset: parsedContent.characterOffset, + sideBias: parsedContent.sideBias, + textAssertion: parsedContent.textAssertion + ) + } + + public static func parseRange(_ rawValue: String?) throws -> RDEPUBCFIRange { + + guard let rawValue = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines), + !rawValue.isEmpty else { + throw RDEPUBCFIError.empty + } + + let body = try unwrap(rawValue) + + let rangeParts = body.split(separator: ",", omittingEmptySubsequences: false) + guard rangeParts.count == 3 else { + throw RDEPUBCFIError.unsupportedRange(rawValue) + } + + let parentRaw = "epubcfi(\(rangeParts[0]))" + + let startRaw = "epubcfi(\(rangeParts[0])\(rangeParts[1]))" + + let endRaw = "epubcfi(\(rangeParts[0])\(rangeParts[2]))" + return RDEPUBCFIRange( + parent: try parse(parentRaw), + start: try parse(startRaw), + end: try parse(endRaw) + ) + } + + private static func unwrap(_ rawValue: String) throws -> String { + guard rawValue.hasPrefix("epubcfi("), rawValue.hasSuffix(")") else { + throw RDEPUBCFIError.invalidWrapper(rawValue) + } + return String(rawValue.dropFirst("epubcfi(".count).dropLast()) + } + + private static func parsePathWithOffset(_ body: String) throws -> ( + path: RDEPUBCFIPath, + characterOffset: Int?, + sideBias: RDEPUBCFISideBias?, + textAssertion: RDEPUBCFITextAssertion? + ) { + + guard let offsetSeparator = firstIndexOutsideBrackets(of: ":", in: body) else { + + return (try parsePath(body), nil, nil, nil) + } + + let pathPart = String(body[.. RDEPUBCFIPath { + + guard path.isEmpty || path.hasPrefix("/") else { + throw RDEPUBCFIError.invalidPath(path) + } + + let steps = path + .split(separator: "/", omittingEmptySubsequences: true) + .compactMap { parseStep(String($0)) } + return RDEPUBCFIPath(steps: steps) + } + + private static func parseStep(_ rawStep: String) -> RDEPUBCFIStep? { + + let indexPart = rawStep.split(separator: "[", maxSplits: 1, omittingEmptySubsequences: false).first.map(String.init) ?? rawStep + guard let index = Int(indexPart) else { return nil } + + let idAssertion: String? + if let open = rawStep.firstIndex(of: "["), + let close = rawStep.lastIndex(of: "]"), + open < close { + idAssertion = String(rawStep[rawStep.index(after: open).. RDEPUBCFISideBias? { + + guard let markerRange = body.range(of: ";s=") else { return nil } + let value = body[markerRange.upperBound...].prefix(1) + return RDEPUBCFISideBias(rawValue: String(value)) + } + + private static func parseTextAssertion(from body: String) -> RDEPUBCFITextAssertion? { + + // Scan for the opening bracket, respecting backslash escapes + guard let open = firstUnescapedIndex(of: "[", in: body) else { return nil } + + // Find the matching closing bracket, respecting backslash escapes and nesting + var depth = 1 + var current = body.index(after: open) + while current < body.endIndex && depth > 0 { + if body[current] == "\\" { + // Skip escaped character + current = body.index(after: current) + if current < body.endIndex { + current = body.index(after: current) + } + continue + } + if body[current] == "[" { + depth += 1 + } else if body[current] == "]" { + depth -= 1 + } + if depth > 0 { + current = body.index(after: current) + } + } + + guard depth == 0, current <= body.endIndex else { return nil } + let close = current + + // Extract content, processing escape sequences left-to-right + let raw = String(body[body.index(after: open).. 1 else { + return RDEPUBCFITextAssertion(exact: unescaped) + } + return RDEPUBCFITextAssertion( + prefix: components.first, + exact: components.count > 1 ? components[1] : nil, + suffix: components.count > 2 ? components[2] : nil + ) + } + + /// Find the first occurrence of a character not preceded by a backslash + private static func firstUnescapedIndex(of character: Character, in string: String) -> String.Index? { + var index = string.startIndex + while index < string.endIndex { + if string[index] == "\\" { + // Skip escaped character + index = string.index(after: index) + if index < string.endIndex { + index = string.index(after: index) + } + continue + } + if string[index] == character { + return index + } + index = string.index(after: index) + } + return nil + } + + /// Split assertion text by commas, but only those at the top level (not inside brackets) + private static func splitAssertionComponents(_ text: String) -> [String] { + var components: [String] = [] + var current = "" + var depth = 0 + var index = text.startIndex + while index < text.endIndex { + let char = text[index] + if char == "\\" { + current.append(char) + index = text.index(after: index) + if index < text.endIndex { + current.append(text[index]) + index = text.index(after: index) + } + continue + } + if char == "[" { + depth += 1 + } else if char == "]" { + depth = max(depth - 1, 0) + } else if char == "," && depth == 0 { + components.append(current) + current = "" + index = text.index(after: index) + continue + } + current.append(char) + index = text.index(after: index) + } + components.append(current) + return components + } + + /// Unescape CFI text assertion content: \[ → [, \] → ], \\ → \ + private static func unescapeCFIText(_ text: String) -> String { + var result = "" + var index = text.startIndex + while index < text.endIndex { + if text[index] == "\\" { + let nextIndex = text.index(after: index) + if nextIndex < text.endIndex { + let nextChar = text[nextIndex] + if nextChar == "[" || nextChar == "]" || nextChar == "\\" { + result.append(nextChar) + index = text.index(after: nextIndex) + continue + } + } + // Not a recognized escape — keep the backslash + result.append(text[index]) + } else { + result.append(text[index]) + } + index = text.index(after: index) + } + return result + } + + private static func firstIndexOutsideBrackets(of character: Character, in body: String) -> String.Index? { + + var bracketDepth = 0 + for index in body.indices { + let current = body[index] + if current == "[" { + bracketDepth += 1 + } else if current == "]" { + bracketDepth = max(bracketDepth - 1, 0) + } else if current == character, bracketDepth == 0 { + return index + } + } + return nil + } +} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIPath.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIPath.swift new file mode 100644 index 0000000..0e4e01d --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIPath.swift @@ -0,0 +1,39 @@ +import Foundation + +public struct RDEPUBCFIPath: Codable, Equatable, Hashable { + + public var steps: [RDEPUBCFIStep] + + public init(steps: [RDEPUBCFIStep] = []) { + self.steps = steps + } + + public func commonPrefix(with other: RDEPUBCFIPath) -> RDEPUBCFIPath { + var prefix: [RDEPUBCFIStep] = [] + let upperBound = min(steps.count, other.steps.count) + for index in 0.. RDEPUBCFIPath { + guard prefix.steps.count <= steps.count else { return self } + let candidate = Array(steps.prefix(prefix.steps.count)) + guard candidate == prefix.steps else { return self } + return RDEPUBCFIPath(steps: Array(steps.dropFirst(prefix.steps.count))) + } +} + +public struct RDEPUBCFIStep: Codable, Equatable, Hashable { + + public var index: Int + + public var idAssertion: String? + + public init(index: Int, idAssertion: String? = nil) { + self.index = index + self.idAssertion = idAssertion?.rd_nilIfEmpty + } +} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIRange.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIRange.swift new file mode 100644 index 0000000..3735876 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIRange.swift @@ -0,0 +1,34 @@ +import Foundation + +public struct RDEPUBCFIRange: Codable, Equatable, Hashable { + public var parent: RDEPUBCFI? + public var start: RDEPUBCFI + public var end: RDEPUBCFI + + /// Always computed from components — no stale cached value risk. + public var rawValue: String { + RDEPUBCFISerializer.serializeRange(self) + } + + /// Initialize from components. The `rawValue` parameter is ignored — rawValue is always + /// computed from the components. To parse a CFI range string, use `RDEPUBCFIParser.parseRange(_:)`. + public init(rawValue: String = "", parent: RDEPUBCFI? = nil, start: RDEPUBCFI, end: RDEPUBCFI) { + self.parent = parent + self.start = start + self.end = end + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(String.self) + let parsed = try RDEPUBCFIParser.parseRange(rawValue) + self.parent = parsed.parent + self.start = parsed.start + self.end = parsed.end + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} \ No newline at end of file diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIRecoveryEngine.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIRecoveryEngine.swift new file mode 100644 index 0000000..38b5272 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIRecoveryEngine.swift @@ -0,0 +1,380 @@ +import Foundation + +public struct RDEPUBCFIRecoveryResult: Equatable { + public enum Confidence: Int, Codable { + case exactPath + case assertionCalibrated + case siblingRecovered + case tokenRecovered + case fragmentFallback + case offsetFallback + } + + public var chapterOffset: Int + public var confidence: Confidence + + public init(chapterOffset: Int, confidence: Confidence) { + self.chapterOffset = chapterOffset + self.confidence = confidence + } +} + +enum RDEPUBCFIRecoveryEngine { + static func recover( + cfi: RDEPUBCFI, + cfiMap: RDEPUBCFIMap?, + chapterText: String?, + fragmentOffsets: [String: Int], + fallbackOffset: Int?, + lastOffset: Int + ) -> RDEPUBCFIRecoveryResult? { + let resolved = RDEPUBCFIResolver.resolve(cfi) + + if let exact = exactPathResult(cfi: cfi, cfiMap: cfiMap, lastOffset: lastOffset) { + return calibrateIfNeeded( + exact, + cfi: cfi, + chapterText: chapterText, + lastOffset: lastOffset + ) + } + + if let sibling = siblingRecoveredResult(cfi: cfi, cfiMap: cfiMap, lastOffset: lastOffset) { + return calibrateIfNeeded( + sibling, + cfi: cfi, + chapterText: chapterText, + lastOffset: lastOffset + ) + } + + if let token = tokenRecoveredResult( + cfi: cfi, + cfiMap: cfiMap, + chapterText: chapterText, + fallbackOffset: resolved.chapterOffset, + lastOffset: lastOffset + ) { + return token + } + + if let fragmentID = resolved.fragmentID, + let fragmentOffset = fragmentOffsets[fragmentID] + ?? cfiMap?.markers.first(where: { $0.fragmentID == fragmentID })?.chapterOffset { + return RDEPUBCFIRecoveryResult( + chapterOffset: clamp(fragmentOffset, lastOffset: lastOffset), + confidence: .fragmentFallback + ) + } + + if let fallbackOffset { + return RDEPUBCFIRecoveryResult( + chapterOffset: clamp(fallbackOffset, lastOffset: lastOffset), + confidence: .offsetFallback + ) + } + + return nil + } + + private static func exactPathResult( + cfi: RDEPUBCFI, + cfiMap: RDEPUBCFIMap?, + lastOffset: Int + ) -> RDEPUBCFIRecoveryResult? { + guard let cfiMap else { return nil } + if let marker = cfiMap.marker(matching: cfi.contentPath), + let chapterOffset = chapterOffset(for: cfi, marker: marker, lastOffset: lastOffset) { + return RDEPUBCFIRecoveryResult(chapterOffset: chapterOffset, confidence: .exactPath) + } + if let pathRange = cfiMap.pathRanges.first(where: { $0.cfiPath == cfi.contentPath }) { + let localOffset = max(cfi.characterOffset ?? 0, 0) + let chapterOffset = clamp(pathRange.startOffset + min(localOffset, max(pathRange.textNodeLength - 1, 0)), lastOffset: lastOffset) + return RDEPUBCFIRecoveryResult(chapterOffset: chapterOffset, confidence: .exactPath) + } + return nil + } + + private static func siblingRecoveredResult( + cfi: RDEPUBCFI, + cfiMap: RDEPUBCFIMap?, + lastOffset: Int + ) -> RDEPUBCFIRecoveryResult? { + guard let cfiMap, + let targetStep = cfi.contentPath.steps.last else { return nil } + + let targetParent = cfi.contentPath.steps.dropLast() + let candidates = cfiMap.markers.filter { marker in + marker.cfiPath.steps.count == cfi.contentPath.steps.count + && Array(marker.cfiPath.steps.dropLast()) == Array(targetParent) + && marker.chapterOffset != nil + } + guard !candidates.isEmpty else { return nil } + + let siblingSignature = siblingSignature(for: cfi.contentPath) + let best = candidates.min { lhs, rhs in + siblingScore(for: lhs, targetStep: targetStep, siblingSignature: siblingSignature) + < siblingScore(for: rhs, targetStep: targetStep, siblingSignature: siblingSignature) + } + + guard let best, + let chapterOffset = chapterOffset(for: cfi, marker: best, lastOffset: lastOffset) else { + return nil + } + + return RDEPUBCFIRecoveryResult(chapterOffset: chapterOffset, confidence: .siblingRecovered) + } + + private static func tokenRecoveredResult( + cfi: RDEPUBCFI, + cfiMap: RDEPUBCFIMap?, + chapterText: String?, + fallbackOffset: Int?, + lastOffset: Int + ) -> RDEPUBCFIRecoveryResult? { + guard let cfiMap, + let chapterText, + !chapterText.isEmpty, + let exact = cfi.textAssertion?.exact, + !exact.isEmpty else { + return nil + } + + let minLength = 2 + let exactCount = Double(max(exact.count, 1)) + let candidateAnchors = cfiMap.recoveryMetadata.tokenIndex.filter { anchor in + let tokenCount = anchor.token.count + guard tokenCount >= minLength else { return false } + let ratio = Double(tokenCount) / exactCount + guard ratio >= 0.3 && ratio <= 3.0 else { return false } + let hasPrefixMatch = anchor.token.hasPrefix(exact) || exact.hasPrefix(anchor.token) + let hasSuffixMatch = anchor.token.hasSuffix(exact) || exact.hasSuffix(anchor.token) + return hasPrefixMatch || hasSuffixMatch + } + guard !candidateAnchors.isEmpty else { return nil } + + let normalizedText = RDEPUBCFITextNodeMapBuilder.normalizedText(from: chapterText) + guard !normalizedText.isEmpty else { return nil } + + let preferredOffset = fallbackOffset.map { clamp($0, lastOffset: lastOffset) } + var bestOffset: Int? + var bestScore = Int.max + + for anchor in candidateAnchors { + let approximateOffset = clamp(anchor.chapterOffset, lastOffset: lastOffset) + let calibrated = calibratedOffset( + approximateOffset, + assertion: cfi.textAssertion, + text: chapterText, + lastOffset: lastOffset, + windowRadius: 768 + ) + let score = tokenRecoveryScore( + for: anchor, + targetPath: cfi.contentPath, + approximateOffset: approximateOffset, + calibratedOffset: calibrated, + preferredOffset: preferredOffset, + allAnchors: cfiMap.recoveryMetadata.tokenIndex + ) + if score < bestScore { + bestScore = score + bestOffset = calibrated + } + } + + guard let bestOffset else { return nil } + return RDEPUBCFIRecoveryResult(chapterOffset: bestOffset, confidence: .tokenRecovered) + } + + private static func calibrateIfNeeded( + _ result: RDEPUBCFIRecoveryResult, + cfi: RDEPUBCFI, + chapterText: String?, + lastOffset: Int + ) -> RDEPUBCFIRecoveryResult { + guard let chapterText, + let assertion = cfi.textAssertion, + assertion.exact?.isEmpty == false else { + return result + } + let calibrated = calibratedOffset( + result.chapterOffset, + assertion: assertion, + text: chapterText, + lastOffset: lastOffset + ) + if calibrated != result.chapterOffset { + return RDEPUBCFIRecoveryResult(chapterOffset: calibrated, confidence: .assertionCalibrated) + } + return result + } + + private static func chapterOffset( + for cfi: RDEPUBCFI, + marker: RDEPUBCFIMarker, + lastOffset: Int + ) -> Int? { + guard let baseOffset = marker.chapterOffset else { return nil } + let localOffset = max(cfi.characterOffset ?? 0, 0) + if let textNodeLength = marker.textNodeLength, textNodeLength > 0 { + return clamp(baseOffset + min(localOffset, max(textNodeLength - 1, 0)), lastOffset: lastOffset) + } + return clamp(baseOffset, lastOffset: lastOffset) + } + + private static func siblingSignature(for path: RDEPUBCFIPath) -> String { + guard let last = path.steps.last else { return "" } + let parent = path.steps.dropLast().map { String($0.index) }.joined(separator: "/") + let childIndex = max(last.index / 2, 0) + return "\(parent)#\(childIndex)" + } + + private static func siblingScore( + for marker: RDEPUBCFIMarker, + targetStep: RDEPUBCFIStep, + siblingSignature: String + ) -> Int { + guard let candidateStep = marker.cfiPath.steps.last else { return Int.max } + var score = abs(candidateStep.index - targetStep.index) + if marker.domSiblingSignature != siblingSignature { + score += 1_000 + } + if marker.fragmentID != nil { + score += 100 + } + return score + } + + private static func calibratedOffset( + _ offset: Int, + assertion: RDEPUBCFITextAssertion?, + text: String, + lastOffset: Int, + windowRadius: Int = 512 + ) -> Int { + let clampedOffset = clamp(offset, lastOffset: lastOffset) + guard let assertion, + let exact = assertion.exact, + !exact.isEmpty else { + return clampedOffset + } + + let nsText = text as NSString + let length = nsText.length + guard length > 0 else { return clampedOffset } + + let searchStart = max(clampedOffset - windowRadius, 0) + let searchEnd = min(clampedOffset + windowRadius + exact.utf16.count, length) + guard searchEnd > searchStart else { return clampedOffset } + + let searchRange = NSRange(location: searchStart, length: searchEnd - searchStart) + var candidateRange = nsText.range(of: exact, options: [], range: searchRange) + var bestOffset: Int? + var bestScore = Int.max + + while candidateRange.location != NSNotFound { + let score = assertionScore( + assertion, + candidateLocation: candidateRange.location, + exactLength: candidateRange.length, + in: nsText, + preferredOffset: clampedOffset + ) + if score < bestScore { + bestScore = score + bestOffset = candidateRange.location + } + + let nextLocation = candidateRange.location + max(candidateRange.length, 1) + let upperBound = searchRange.location + searchRange.length + guard nextLocation < upperBound else { break } + candidateRange = nsText.range( + of: exact, + options: [], + range: NSRange(location: nextLocation, length: upperBound - nextLocation) + ) + } + + guard let bestOffset, bestScore < Int.max else { + return clampedOffset + } + return clamp(bestOffset, lastOffset: lastOffset) + } + + private static func assertionScore( + _ assertion: RDEPUBCFITextAssertion, + candidateLocation: Int, + exactLength: Int, + in text: NSString, + preferredOffset: Int + ) -> Int { + var score = abs(candidateLocation - preferredOffset) + if let prefix = assertion.prefix, !prefix.isEmpty { + let prefixStart = max(candidateLocation - prefix.utf16.count, 0) + let availableLength = candidateLocation - prefixStart + let candidatePrefix = availableLength > 0 + ? text.substring(with: NSRange(location: prefixStart, length: availableLength)) + : "" + score += candidatePrefix.hasSuffix(prefix) ? 0 : 10_000 + } + if let suffix = assertion.suffix, !suffix.isEmpty { + let suffixStart = min(candidateLocation + exactLength, text.length) + let suffixLength = min(suffix.utf16.count, text.length - suffixStart) + let candidateSuffix = suffixLength > 0 + ? text.substring(with: NSRange(location: suffixStart, length: suffixLength)) + : "" + score += candidateSuffix == suffix ? 0 : 10_000 + } + return score + } + + private static func clamp(_ offset: Int, lastOffset: Int) -> Int { + min(max(offset, 0), max(lastOffset, 0)) + } + + private static func tokenRecoveryScore( + for anchor: RDEPUBCFITokenAnchor, + targetPath: RDEPUBCFIPath, + approximateOffset: Int, + calibratedOffset: Int, + preferredOffset: Int?, + allAnchors: [RDEPUBCFITokenAnchor] + ) -> Int { + var score = abs(calibratedOffset - approximateOffset) + score += pathDistance(anchor.cfiPath, targetPath) * 1_024 + + if let preferredOffset { + score += abs(approximateOffset - preferredOffset) + let expectedOccurrence = nearestOccurrence( + forToken: anchor.token, + preferredOffset: preferredOffset, + anchors: allAnchors + ) + score += abs(anchor.occurrence - expectedOccurrence) * 256 + } + + return score + } + + private static func nearestOccurrence( + forToken token: String, + preferredOffset: Int, + anchors: [RDEPUBCFITokenAnchor] + ) -> Int { + anchors + .filter { $0.token == token } + .min(by: { abs($0.chapterOffset - preferredOffset) < abs($1.chapterOffset - preferredOffset) })? + .occurrence ?? 0 + } + + private static func pathDistance(_ lhs: RDEPUBCFIPath, _ rhs: RDEPUBCFIPath) -> Int { + let prefix = lhs.commonPrefix(with: rhs).steps.count + let remainingLHS = lhs.steps.dropFirst(prefix) + let remainingRHS = rhs.steps.dropFirst(prefix) + let stepDistance = zip(remainingLHS, remainingRHS).reduce(0) { partial, pair in + partial + abs(pair.0.index - pair.1.index) + } + return stepDistance + abs(lhs.steps.count - rhs.steps.count) * 2 + } +} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIResolver.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIResolver.swift new file mode 100644 index 0000000..8b6367a --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIResolver.swift @@ -0,0 +1,51 @@ +import Foundation + +public struct RDEPUBCFIResolverResult: Equatable { + + public var href: String? + + public var fileIndex: Int? + + public var chapterOffset: Int? + + public var fragmentID: String? + + public init(href: String? = nil, fileIndex: Int? = nil, chapterOffset: Int? = nil, fragmentID: String? = nil) { + self.href = href + self.fileIndex = fileIndex + self.chapterOffset = chapterOffset + self.fragmentID = fragmentID + } +} + +public enum RDEPUBCFIResolver { + + public static func resolve(_ cfi: RDEPUBCFI) -> RDEPUBCFIResolverResult { + + // EPUB CFI spec: package path structure is /6/2[spine]/2n[manifest-id] + // Manifest step is always the 3rd step (index 2) if present, or the last step with an id assertion. + let manifestStep: RDEPUBCFIStep? + if cfi.packagePath.steps.count >= 3 { + manifestStep = cfi.packagePath.steps[2] + } else { + manifestStep = cfi.packagePath.steps.last(where: { $0.idAssertion?.rd_nilIfEmpty != nil }) + } + let href = manifestStep?.idAssertion + + // Spine step is the 2nd step (index 1) in the package path + let fileIndex: Int? + if cfi.packagePath.steps.count >= 2 { + fileIndex = max((cfi.packagePath.steps[1].index / 2) - 1, 0) + } else { + fileIndex = nil + } + + let fragmentID = cfi.contentPath.steps.last(where: { $0.idAssertion?.rd_nilIfEmpty != nil })?.idAssertion + return RDEPUBCFIResolverResult( + href: href, + fileIndex: fileIndex, + chapterOffset: cfi.characterOffset, + fragmentID: fragmentID + ) + } +} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFISerializer.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFISerializer.swift new file mode 100644 index 0000000..7630418 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFISerializer.swift @@ -0,0 +1,109 @@ +import Foundation + +public enum RDEPUBCFISerializer { + + public static func serialize(_ cfi: RDEPUBCFI) -> String { + + var body = serializePath(cfi.packagePath) + + let content = serializePath(cfi.contentPath) + + if !content.isEmpty || cfi.characterOffset != nil { + body += "!" + content + } + + if let characterOffset = cfi.characterOffset { + body += ":\(max(characterOffset, 0))" + } + + if let sideBias = cfi.sideBias { + body += ";s=\(sideBias.rawValue)" + } + + if let assertion = cfi.textAssertion { + body += "[\(assertion.prefix ?? ""),\(assertion.exact ?? ""),\(assertion.suffix ?? "")]" + } + return "epubcfi(\(body))" + } + + public static func serializeRange(_ range: RDEPUBCFIRange) -> String { + let canonical = canonicalRangeComponents(for: range) + let parentBody = serializeRangeParent(canonical.parent) + let startBody = serializeRangeTerminal( + canonical.start, + relativeToPackagePath: canonical.parent.packagePath, + contentPrefix: canonical.parent.contentPath + ) + let endBody = serializeRangeTerminal( + canonical.end, + relativeToPackagePath: canonical.parent.packagePath, + contentPrefix: canonical.parent.contentPath + ) + return "epubcfi(\(parentBody),\(startBody),\(endBody))" + } + + public static func serializePath(_ path: RDEPUBCFIPath) -> String { + guard !path.steps.isEmpty else { return "" } + return path.steps.map { step in + if let idAssertion = step.idAssertion { + return "/\(step.index)[\(idAssertion)]" + } + return "/\(step.index)" + }.joined() + } + + private static func canonicalRangeComponents(for range: RDEPUBCFIRange) -> ( + parent: RDEPUBCFI, + start: RDEPUBCFI, + end: RDEPUBCFI + ) { + if let parent = range.parent { + return (parent, range.start, range.end) + } + + let sharedPackage = range.start.packagePath.commonPrefix(with: range.end.packagePath) + let sharedContent = range.start.contentPath.commonPrefix(with: range.end.contentPath) + let parent = RDEPUBCFI( + packagePath: sharedPackage, + contentPath: sharedContent, + characterOffset: nil, + sideBias: nil, + textAssertion: nil + ) + return (parent, range.start, range.end) + } + + private static func serializeRangeParent(_ parent: RDEPUBCFI) -> String { + var body = serializePath(parent.packagePath) + let content = serializePath(parent.contentPath) + if !content.isEmpty { + body += "!" + content + } + return body + } + + private static func serializeRangeTerminal( + _ cfi: RDEPUBCFI, + relativeToPackagePath parentPackagePath: RDEPUBCFIPath, + contentPrefix: RDEPUBCFIPath + ) -> String { + let relativePackage = cfi.packagePath.droppingPrefix(parentPackagePath) + let relativeContent = cfi.contentPath.droppingPrefix(contentPrefix) + + var body = serializePath(relativePackage) + let content = serializePath(relativeContent) + if !content.isEmpty || cfi.characterOffset != nil || !relativePackage.steps.isEmpty { + body += content + } + if let characterOffset = cfi.characterOffset { + body += ":\(max(characterOffset, 0))" + } + if let sideBias = cfi.sideBias { + body += ";s=\(sideBias.rawValue)" + } + if let assertion = cfi.textAssertion { + body += "[\(assertion.prefix ?? ""),\(assertion.exact ?? ""),\(assertion.suffix ?? "")]" + } + return body + } +} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFITextAssertion.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFITextAssertion.swift new file mode 100644 index 0000000..5ba80f7 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFITextAssertion.swift @@ -0,0 +1,22 @@ +import Foundation + +public struct RDEPUBCFITextAssertion: Codable, Equatable, Hashable { + + public var prefix: String? + + public var exact: String? + + public var suffix: String? + + public init(prefix: String? = nil, exact: String? = nil, suffix: String? = nil) { + self.prefix = Self.trimmedOrNil(prefix) + self.exact = Self.trimmedOrNil(exact) + self.suffix = Self.trimmedOrNil(suffix) + } + + private static func trimmedOrNil(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIUtilities.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIUtilities.swift new file mode 100644 index 0000000..050c0c4 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIUtilities.swift @@ -0,0 +1,8 @@ +import Foundation + +internal extension String { + var nilIfEmpty: String? { + let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} \ No newline at end of file diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Models/RDEPUBAnnotationModels.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Models/RDEPUBAnnotationModels.swift new file mode 100644 index 0000000..9d57aee --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Models/RDEPUBAnnotationModels.swift @@ -0,0 +1,333 @@ +import Foundation +import UIKit + +public let kRDEPUBHighlightAttributeName = NSAttributedString.Key("com.RDEpubReader.highlight") + +public let kRDEPUBUnderlineAttributeName = NSAttributedString.Key("com.RDEpubReader.underline") + +public struct RDEPUBSelection: Codable, Equatable { + + public var bookIdentifier: String? + + public var location: RDEPUBLocation + + public var text: String + + public var rangeInfo: String? + + public var createdAt: Date + + public init( + bookIdentifier: String? = nil, + location: RDEPUBLocation, + text: String, + rangeInfo: String? = nil, + createdAt: Date = Date() + ) { + self.bookIdentifier = bookIdentifier + self.location = location + self.text = text + self.rangeInfo = rangeInfo?.nilIfEmpty + self.createdAt = createdAt + } + + public var isEmpty: Bool { + text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } +} + +public enum RDEPUBHighlightStyle: String, Codable { + case highlight + case underline +} + +public enum RDEPUBAnnotationKind: String, Codable, Equatable { + case bookmark + case highlight + case underline +} + +public enum RDEPUBAnnotationMenuAction: Equatable { + case copy + case highlight + case annotate +} + +public struct RDEPUBTextOffsetRangeInfo: Codable, Equatable { + + public var kind: String + + public var href: String + + public var start: Int + + public var end: Int + + public init(href: String, start: Int, end: Int) { + self.kind = "text-offset" + self.href = href + self.start = start + self.end = end + } + + public var nsRange: NSRange? { + guard end > start else { return nil } + return NSRange(location: start, length: end - start) + } + + public func jsonString() -> String? { + guard let data = try? JSONEncoder().encode(self) else { return nil } + return String(data: data, encoding: .utf8) + } + + public static func decode(from string: String?) -> RDEPUBTextOffsetRangeInfo? { + guard let string, + let data = string.data(using: .utf8), + let rangeInfo = try? JSONDecoder().decode(RDEPUBTextOffsetRangeInfo.self, from: data), + rangeInfo.kind == "text-offset", + rangeInfo.end > rangeInfo.start else { + return nil + } + return rangeInfo + } +} + +public struct RDEPUBHighlight: Codable, Equatable { + + public var id: String + + public var bookIdentifier: String? + + public var location: RDEPUBLocation + + public var text: String + + public var rangeInfo: String? + + public var style: RDEPUBHighlightStyle + + public var color: String + + public var note: String? + + public var createdAt: Date + + public init( + id: String = UUID().uuidString, + bookIdentifier: String? = nil, + location: RDEPUBLocation, + text: String, + rangeInfo: String? = nil, + style: RDEPUBHighlightStyle = .highlight, + color: String = "#F8E16C", + note: String? = nil, + createdAt: Date = Date() + ) { + self.id = id + self.bookIdentifier = bookIdentifier + self.location = location + self.text = text + self.rangeInfo = rangeInfo?.nilIfEmpty + self.style = style + self.color = color + self.note = note?.nilIfEmpty + self.createdAt = createdAt + } + + public var hasNote: Bool { + note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + } + + public var uiColor: UIColor { + UIColor(rdHexString: color, alpha: 0.35) + ?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.35) + } + + private enum CodingKeys: String, CodingKey { + case id + case bookIdentifier + case location + case text + case rangeInfo + case style + case color + case note + case createdAt + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + bookIdentifier = try container.decodeIfPresent(String.self, forKey: .bookIdentifier) + location = try container.decode(RDEPUBLocation.self, forKey: .location) + text = try container.decode(String.self, forKey: .text) + rangeInfo = try container.decodeIfPresent(String.self, forKey: .rangeInfo)?.nilIfEmpty + if let rawStyle = try container.decodeIfPresent(String.self, forKey: .style), + let decodedStyle = RDEPUBHighlightStyle(rawValue: rawStyle) { + style = decodedStyle + } else { + style = .highlight + } + color = try container.decodeIfPresent(String.self, forKey: .color) ?? "#F8E16C" + note = try container.decodeIfPresent(String.self, forKey: .note)?.nilIfEmpty + createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date() + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encodeIfPresent(bookIdentifier, forKey: .bookIdentifier) + try container.encode(location, forKey: .location) + try container.encode(text, forKey: .text) + try container.encodeIfPresent(rangeInfo, forKey: .rangeInfo) + try container.encode(style, forKey: .style) + try container.encode(color, forKey: .color) + try container.encodeIfPresent(note, forKey: .note) + try container.encode(createdAt, forKey: .createdAt) + } + + public var annotation: RDEPUBAnnotation { + RDEPUBAnnotation(highlight: self) + } +} + +public struct RDEPUBBookmark: Codable, Equatable { + + public var id: String + + public var bookIdentifier: String? + + public var location: RDEPUBLocation + + public var rangeInfo: String? + + public var chapterTitle: String? + + public var note: String? + + public var createdAt: Date + + public init( + id: String = UUID().uuidString, + bookIdentifier: String? = nil, + location: RDEPUBLocation, + rangeInfo: String? = nil, + chapterTitle: String? = nil, + note: String? = nil, + createdAt: Date = Date() + ) { + self.id = id + self.bookIdentifier = bookIdentifier + self.location = location + self.rangeInfo = rangeInfo?.nilIfEmpty + self.chapterTitle = chapterTitle?.nilIfEmpty + self.note = note?.nilIfEmpty + self.createdAt = createdAt + } + + public var hasNote: Bool { + note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + } + + public var annotation: RDEPUBAnnotation { + RDEPUBAnnotation(bookmark: self) + } +} + +public struct RDEPUBAnnotation: Codable, Equatable { + public var id: String + public var bookIdentifier: String? + public var kind: RDEPUBAnnotationKind + public var location: RDEPUBLocation + public var text: String? + public var rangeInfo: String? + public var color: String? + public var chapterTitle: String? + public var note: String? + public var createdAt: Date + + public init( + id: String = UUID().uuidString, + bookIdentifier: String? = nil, + kind: RDEPUBAnnotationKind, + location: RDEPUBLocation, + text: String? = nil, + rangeInfo: String? = nil, + color: String? = nil, + chapterTitle: String? = nil, + note: String? = nil, + createdAt: Date = Date() + ) { + self.id = id + self.bookIdentifier = bookIdentifier + self.kind = kind + self.location = location + self.text = text?.nilIfEmpty + self.rangeInfo = rangeInfo?.nilIfEmpty + self.color = color?.nilIfEmpty + self.chapterTitle = chapterTitle?.nilIfEmpty + self.note = note?.nilIfEmpty + self.createdAt = createdAt + } + + public init(highlight: RDEPUBHighlight) { + self.init( + id: highlight.id, + bookIdentifier: highlight.bookIdentifier, + kind: highlight.style == .underline ? .underline : .highlight, + location: highlight.location, + text: highlight.text, + rangeInfo: highlight.rangeInfo, + color: highlight.color, + chapterTitle: nil, + note: highlight.note, + createdAt: highlight.createdAt + ) + } + + public init(bookmark: RDEPUBBookmark) { + self.init( + id: bookmark.id, + bookIdentifier: bookmark.bookIdentifier, + kind: .bookmark, + location: bookmark.location, + text: nil, + rangeInfo: bookmark.rangeInfo, + color: nil, + chapterTitle: bookmark.chapterTitle, + note: bookmark.note, + createdAt: bookmark.createdAt + ) + } + + public var bookmark: RDEPUBBookmark? { + guard kind == .bookmark else { return nil } + return RDEPUBBookmark( + id: id, + bookIdentifier: bookIdentifier, + location: location, + rangeInfo: rangeInfo, + chapterTitle: chapterTitle, + note: note, + createdAt: createdAt + ) + } + + public var highlight: RDEPUBHighlight? { + guard kind == .highlight || kind == .underline, + let text else { + return nil + } + return RDEPUBHighlight( + id: id, + bookIdentifier: bookIdentifier, + location: location, + text: text, + rangeInfo: rangeInfo, + style: kind == .underline ? .underline : .highlight, + color: color ?? "#F8E16C", + note: note, + createdAt: createdAt + ) + } +} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Models/RDEPUBPaginationModels.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Models/RDEPUBPaginationModels.swift new file mode 100644 index 0000000..8caed35 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Models/RDEPUBPaginationModels.swift @@ -0,0 +1,206 @@ +import Foundation + +public enum RDEPUBTextPageBreakReason: String, Codable, Equatable { + + case chapterEnd + + case frameLimit + + case blockBoundary + + case attachmentBoundary + + case semanticBoundary +} + +public enum RDEPUBTextAttachmentKind: String, Codable, Equatable { + + case image + + case generic + + case footnote +} + +public struct RDEPUBTextPageMetadata: Codable, Equatable { + + public var breakReason: RDEPUBTextPageBreakReason + + public var blockRange: NSRange? + + public var attachmentRanges: [NSRange] + + public var attachmentKinds: [RDEPUBTextAttachmentKind] + + public var blockKinds: [RDEPUBTextBlockKind] + + public var semanticHints: [RDEPUBTextSemanticHint] + + public var attachmentPlacements: [RDEPUBTextAttachmentPlacement] + + public var trailingFragmentID: String? + + public var diagnostics: [String] + + public init( + breakReason: RDEPUBTextPageBreakReason, + blockRange: NSRange? = nil, + attachmentRanges: [NSRange] = [], + attachmentKinds: [RDEPUBTextAttachmentKind] = [], + blockKinds: [RDEPUBTextBlockKind] = [], + semanticHints: [RDEPUBTextSemanticHint] = [], + attachmentPlacements: [RDEPUBTextAttachmentPlacement] = [], + trailingFragmentID: String? = nil, + diagnostics: [String] = [] + ) { + self.breakReason = breakReason + self.blockRange = blockRange + self.attachmentRanges = attachmentRanges + self.attachmentKinds = attachmentKinds + self.blockKinds = blockKinds + self.semanticHints = semanticHints + self.attachmentPlacements = attachmentPlacements + self.trailingFragmentID = trailingFragmentID + self.diagnostics = diagnostics + } +} + +public struct EPUBChapterInfo: Codable, Equatable { + + public var spineIndex: Int + + public var title: String + + public var pageCount: Int + + public init(spineIndex: Int, title: String, pageCount: Int) { + self.spineIndex = spineIndex + self.title = title + self.pageCount = pageCount + } +} + +public struct EPUBPage: Codable, Equatable { + + public var spineIndex: Int + + public var chapterIndex: Int + + public var pageIndexInChapter: Int + + public var totalPagesInChapter: Int + + public var chapterTitle: String + + public var fixedSpread: EPUBFixedSpread? + + public init( + spineIndex: Int, + chapterIndex: Int, + pageIndexInChapter: Int, + totalPagesInChapter: Int, + chapterTitle: String, + fixedSpread: EPUBFixedSpread? + ) { + self.spineIndex = spineIndex + self.chapterIndex = chapterIndex + self.pageIndexInChapter = pageIndexInChapter + self.totalPagesInChapter = totalPagesInChapter + self.chapterTitle = chapterTitle + self.fixedSpread = fixedSpread + } +} + +public struct EPUBFixedSpreadResource: Codable, Equatable { + + public var spineIndex: Int + + public var href: String + + public var title: String + + public var pageSpread: RDEPUBPageSpread? + + public init(spineIndex: Int, href: String, title: String, pageSpread: RDEPUBPageSpread? = nil) { + self.spineIndex = spineIndex + self.href = href + self.title = title + self.pageSpread = pageSpread + } +} + +public struct EPUBFixedSpread: Codable, Equatable { + + public var resources: [EPUBFixedSpreadResource] + + public init(resources: [EPUBFixedSpreadResource]) { + self.resources = resources + } + + public var primaryResource: EPUBFixedSpreadResource { + resources.first ?? EPUBFixedSpreadResource(spineIndex: 0, href: "", title: "") + } + + public func contains(normalizedHref: String, normalizer: (String) -> String?) -> Bool { + resources.contains { resource in + normalizer(resource.href) == normalizedHref + } + } + + public static func makeSpreads(spine: [RDEPUBSpineItem], spreadEnabled: Bool) -> [EPUBFixedSpread] { + let resources = spine.enumerated().map { index, item in + EPUBFixedSpreadResource( + spineIndex: index, + href: item.href, + title: item.title, + pageSpread: item.pageSpread + ) + } + + guard spreadEnabled, resources.count > 1 else { + return resources.map { EPUBFixedSpread(resources: [$0]) } + } + + var spreads: [EPUBFixedSpread] = [] + var cursor = 0 + + while cursor < resources.count { + let current = resources[cursor] + + guard cursor + 1 < resources.count else { + spreads.append(EPUBFixedSpread(resources: [current])) + break + } + + let next = resources[cursor + 1] + if shouldPair(current: current, next: next) { + spreads.append(EPUBFixedSpread(resources: [current, next])) + cursor += 2 + } else { + spreads.append(EPUBFixedSpread(resources: [current])) + cursor += 1 + } + } + + return spreads + } + + public static func makeSpreads(parser: RDEPUBParser, spreadEnabled: Bool) -> [EPUBFixedSpread] { + makeSpreads(spine: parser.spine, spreadEnabled: spreadEnabled) + } + + private static func shouldPair(current: EPUBFixedSpreadResource, next: EPUBFixedSpreadResource) -> Bool { + if current.pageSpread == .center || next.pageSpread == .center { + return false + } + + switch (current.pageSpread, next.pageSpread) { + case (.left, .right), (.right, .left): + return true + case (.left, .left), (.right, .right): + return false + default: + return true + } + } +} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Models/RDEPUBReadingLocationModels.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Models/RDEPUBReadingLocationModels.swift new file mode 100644 index 0000000..4f7d1bd --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Models/RDEPUBReadingLocationModels.swift @@ -0,0 +1,130 @@ +import Foundation + +public struct RDEPUBLocation: Codable, Equatable { + + public var bookIdentifier: String? + + public var href: String + + public var progression: Double + + public var lastProgression: Double? + + public var fragment: String? + + public var rangeAnchor: RDEPUBTextRangeAnchor? + + public var cfi: String? + + public var lastCFI: String? + + public var rangeCFI: String? + + public init( + bookIdentifier: String? = nil, + href: String, + progression: Double, + lastProgression: Double? = nil, + fragment: String? = nil, + rangeAnchor: RDEPUBTextRangeAnchor? = nil, + cfi: String? = nil, + lastCFI: String? = nil, + rangeCFI: String? = nil + ) { + self.bookIdentifier = bookIdentifier + self.href = href + self.progression = Self.clamp(progression) + self.lastProgression = lastProgression.map(Self.clamp) + self.fragment = fragment?.nilIfEmpty + self.rangeAnchor = rangeAnchor + self.cfi = cfi?.nilIfEmpty + self.lastCFI = lastCFI?.nilIfEmpty + self.rangeCFI = rangeCFI?.nilIfEmpty + } + + public var navigationProgression: Double { + let end = lastProgression ?? progression + if end.isNaN || end.isInfinite { + return progression + } + return Self.clamp((progression + end) / 2.0) + } + + private static func clamp(_ value: Double) -> Double { + guard value.isFinite else { return 0 } + return min(1, max(0, value)) + } +} + +public struct RDEPUBViewportResource: Codable, Equatable { + + public var href: String + + public var spineIndex: Int + + public var progression: Double + + public var lastProgression: Double? + + public var fragment: String? + + public init( + href: String, + spineIndex: Int, + progression: Double, + lastProgression: Double? = nil, + fragment: String? = nil + ) { + self.href = href + self.spineIndex = spineIndex + self.progression = progression + self.lastProgression = lastProgression + self.fragment = fragment + } +} + +public struct RDEPUBViewport: Codable, Equatable { + + public var resources: [RDEPUBViewportResource] + + public var visiblePageNumber: Int + + public var chapterIndex: Int? + + public var isFixedLayout: Bool + + public init( + resources: [RDEPUBViewportResource], + visiblePageNumber: Int, + chapterIndex: Int? = nil, + isFixedLayout: Bool + ) { + self.resources = resources + self.visiblePageNumber = visiblePageNumber + self.chapterIndex = chapterIndex + self.isFixedLayout = isFixedLayout + } +} + +public struct RDEPUBReadingContext: Codable, Equatable { + + public var location: RDEPUBLocation + + public var viewport: RDEPUBViewport + + public var pageNumber: Int + + public var chapterIndex: Int? + + public init( + location: RDEPUBLocation, + viewport: RDEPUBViewport, + pageNumber: Int, + chapterIndex: Int? = nil + ) { + self.location = location + self.viewport = viewport + self.pageNumber = pageNumber + self.chapterIndex = chapterIndex + } +} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Notes/RDEPUBNoteDetector.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Notes/RDEPUBNoteDetector.swift new file mode 100644 index 0000000..cc2f331 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Notes/RDEPUBNoteDetector.swift @@ -0,0 +1,63 @@ +import Foundation + +public enum RDEPUBNoteDetector { + + private static let noteTokens = [ + "noteref", + "footnote", + "endnote", + "rearnote", + "fnref", + "fn-", + "fn_", + "note", + "annotation" + ] + + public static func makeReferenceIfLikelyNote( + sourceHref: String, + sourceCFI: String?, + targetHref: String, + targetFragment: String?, + targetCFI: String?, + targetElementHTML: String? + ) -> RDEPUBNoteReference? { + + let haystack = [ + targetHref, + targetFragment ?? "", + targetElementHTML ?? "" + ].joined(separator: " ").lowercased() + + guard noteTokens.contains(where: { haystack.contains($0) }) || + hasEPUBType("footnote", in: targetElementHTML) || + hasEPUBType("endnote", in: targetElementHTML) || + hasEPUBType("rearnote", in: targetElementHTML) else { + return nil + } + + return RDEPUBNoteReference( + sourceHref: sourceHref, + sourceCFI: sourceCFI, + targetHref: targetHref, + targetFragment: targetFragment, + targetCFI: targetCFI, + label: targetFragment, + kind: kind(from: haystack) + ) + } + + public static func kind(from text: String) -> RDEPUBNoteKind { + let lowered = text.lowercased() + if lowered.contains("endnote") { return .endnote } + if lowered.contains("rearnote") { return .rearnote } + if lowered.contains("footnote") || lowered.contains("fn") { return .footnote } + return .unknown + } + + private static func hasEPUBType(_ type: String, in html: String?) -> Bool { + guard let html else { return false } + let pattern = #"epub:type\s*=\s*['"][^'"]*\b"# + NSRegularExpression.escapedPattern(for: type) + #"\b[^'"]*['"]"# + return html.range(of: pattern, options: [.regularExpression, .caseInsensitive]) != nil + } +} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Notes/RDEPUBNoteModels.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Notes/RDEPUBNoteModels.swift new file mode 100644 index 0000000..be75e57 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Notes/RDEPUBNoteModels.swift @@ -0,0 +1,78 @@ +import Foundation + +public enum RDEPUBNoteKind: String, Codable, Equatable { + + case footnote + + case endnote + + case rearnote + + case unknown +} + +public struct RDEPUBNoteReference: Codable, Equatable { + + public var sourceHref: String + + public var sourceCFI: String? + + public var targetHref: String + + public var targetFragment: String? + + public var targetCFI: String? + + public var label: String? + + public var kind: RDEPUBNoteKind + + public init( + sourceHref: String, + sourceCFI: String? = nil, + targetHref: String, + targetFragment: String? = nil, + targetCFI: String? = nil, + label: String? = nil, + kind: RDEPUBNoteKind = .unknown + ) { + self.sourceHref = sourceHref + self.sourceCFI = sourceCFI + self.targetHref = targetHref + self.targetFragment = targetFragment + self.targetCFI = targetCFI + self.label = label?.rd_nilIfEmpty + self.kind = kind + } +} + +public struct RDEPUBResolvedNote: Equatable { + + public var reference: RDEPUBNoteReference + + public var sourceLocation: RDEPUBLocation? + + public var targetLocation: RDEPUBLocation + + public var title: String? + + public var html: String + + public var plainText: String + + public init( + reference: RDEPUBNoteReference, + sourceLocation: RDEPUBLocation? = nil, + targetLocation: RDEPUBLocation, + title: String? = nil, + html: String, + plainText: String + ) { + self.reference = reference + self.sourceLocation = sourceLocation + self.targetLocation = targetLocation + self.title = title?.rd_nilIfEmpty + self.html = html + self.plainText = plainText + } +} diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Notes/RDEPUBNoteResolver.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Notes/RDEPUBNoteResolver.swift new file mode 100644 index 0000000..357fb32 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Notes/RDEPUBNoteResolver.swift @@ -0,0 +1,168 @@ +import Foundation + +public final class RDEPUBNoteResolver { + + private let resourceResolver: RDEPUBResourceResolver + + public init(resourceResolver: RDEPUBResourceResolver) { + self.resourceResolver = resourceResolver + } + + public func resolveInternalLink( + sourceHref: String, + sourceCFI: String?, + targetLocation: RDEPUBLocation, + sourceLocation: RDEPUBLocation? = nil, + relativeToSpineIndex spineIndex: Int? + ) -> RDEPUBResolvedNote? { + guard let normalizedTarget = resourceResolver.normalizedLocation( + targetLocation, + relativeToSpineIndex: spineIndex, + bookIdentifier: targetLocation.bookIdentifier + ) else { + return nil + } + + let fragment = normalizedTarget.fragment + guard let elementHTML = extractElementHTML(href: normalizedTarget.href, fragment: fragment) else { + return nil + } + + guard let reference = RDEPUBNoteDetector.makeReferenceIfLikelyNote( + sourceHref: sourceHref, + sourceCFI: sourceCFI, + targetHref: normalizedTarget.href, + targetFragment: fragment, + targetCFI: normalizedTarget.cfi, + targetElementHTML: elementHTML + ) else { + return nil + } + + let sanitizedHTML = stripBacklinks(from: elementHTML) + let plainText = plainText(from: sanitizedHTML) + guard !plainText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return nil + } + + return RDEPUBResolvedNote( + reference: reference, + sourceLocation: sourceLocation, + targetLocation: normalizedTarget, + title: title(for: reference), + html: sanitizedHTML, + plainText: plainText + ) + } + + public func extractElementHTML(href: String, fragment: String?) -> String? { + guard let fragment, + !fragment.isEmpty, + let fileURL = resourceResolver.fileURL(forRelativePath: href), + let html = chapterHTML(at: fileURL) else { + return nil + } + + return elementHTML(withID: fragment, in: html) + } + + /// 读取章节 HTML:优先经加密资源 provider 解密,明文文件保持原直读路径 + private func chapterHTML(at fileURL: URL) -> String? { + if let provided = resourceResolver.providedResourceData(at: fileURL) { + return String(data: provided, encoding: .utf8) + ?? String(data: provided, encoding: .utf16) + } + return try? String(contentsOf: fileURL, encoding: .utf8) + } + + private func elementHTML(withID id: String, in html: String) -> String? { + let escapedID = NSRegularExpression.escapedPattern(for: id) + let pattern = #"<([A-Za-z][A-Za-z0-9:_-]*)(?=[^>]*(?:id|xml:id)\s*=\s*(['"])"# + escapedID + #"\2)[^>]*>"# + guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { + return nil + } + let nsRange = NSRange(html.startIndex..") else { + return String(html[startRange]) + } + guard let endRange = matchingEndTagRange( + tagName: tagName, + in: html, + after: startRange.upperBound + ) else { + return String(html[startRange]) + } + return String(html[startRange.lowerBound.. String { + let backlinkPattern = #"]*(?:rev\s*=\s*['"]footnote['"]|epub:type\s*=\s*['"][^'"]*(?:backlink|return)[^'"]*['"]|class\s*=\s*['"][^'"]*(?:backlink|return)[^'"]*['"])[^>]*>[\s\S]*?"# + guard let regex = try? NSRegularExpression(pattern: backlinkPattern, options: [.caseInsensitive]) else { + return html + } + let nsRange = NSRange(html.startIndex.. String { + let withoutScripts = html + .replacingOccurrences(of: #""#, 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 new file mode 100644 index 0000000..db54d4d --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBAssetRepository.swift @@ -0,0 +1,74 @@ + +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 new file mode 100644 index 0000000..5d84523 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBFixedLayoutTemplate.swift @@ -0,0 +1,53 @@ + +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 new file mode 100644 index 0000000..906e586 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBJavaScriptBridge.swift @@ -0,0 +1,188 @@ + +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 new file mode 100644 index 0000000..7f4fe0e --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBModels.swift @@ -0,0 +1,214 @@ + +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 new file mode 100644 index 0000000..085a37f --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBNavigatorLayoutContext.swift @@ -0,0 +1,59 @@ + +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/RDReaderView/EPUBCore/RDEPUBPaginator.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBPaginator.swift similarity index 100% rename from Sources/RDReaderView/EPUBCore/RDEPUBPaginator.swift rename to Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBPaginator.swift diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+Archive.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+Archive.swift new file mode 100644 index 0000000..fd76ea5 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+Archive.swift @@ -0,0 +1,198 @@ + +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 new file mode 100644 index 0000000..d734956 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+Package.swift @@ -0,0 +1,407 @@ + +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 new file mode 100644 index 0000000..b58219c --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+ReadingProfile.swift @@ -0,0 +1,46 @@ + +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 new file mode 100644 index 0000000..24bc728 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+Resources.swift @@ -0,0 +1,101 @@ + +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 new file mode 100644 index 0000000..10e2449 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+TOC.swift @@ -0,0 +1,303 @@ + +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 new file mode 100644 index 0000000..a6c220e --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBParser.swift @@ -0,0 +1,210 @@ + +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 new file mode 100644 index 0000000..d0a4c8e --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBPreferences.swift @@ -0,0 +1,107 @@ + +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 new file mode 100644 index 0000000..4577176 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBPublication.swift @@ -0,0 +1,71 @@ + +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 new file mode 100644 index 0000000..ebd52ac --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBReadingSession.swift @@ -0,0 +1,322 @@ + +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 new file mode 100644 index 0000000..6caae2a --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBResourceResolver.swift @@ -0,0 +1,175 @@ + +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 new file mode 100644 index 0000000..7f6163e --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBResourceURLSchemeHandler.swift @@ -0,0 +1,349 @@ +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 new file mode 100644 index 0000000..1a20c05 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBSafeArea.swift @@ -0,0 +1,67 @@ +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 new file mode 100644 index 0000000..3240ca8 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBSearchEngine.swift @@ -0,0 +1,120 @@ + +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 new file mode 100644 index 0000000..c1838c0 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBSearchModels.swift @@ -0,0 +1,127 @@ + +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 new file mode 100644 index 0000000..bb4b823 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBStyleSheetBuilder.swift @@ -0,0 +1,215 @@ + +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 new file mode 100644 index 0000000..10417ba --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBTextAnchor.swift @@ -0,0 +1,76 @@ + +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/RDReaderView/EPUBCore/RDEPUBWebView+Configuration.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+Configuration.swift similarity index 100% rename from Sources/RDReaderView/EPUBCore/RDEPUBWebView+Configuration.swift rename to Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+Configuration.swift diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+FixedLayout.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+FixedLayout.swift new file mode 100644 index 0000000..1458105 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+FixedLayout.swift @@ -0,0 +1,122 @@ + +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 new file mode 100644 index 0000000..428f5a3 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+JavaScriptBridge.swift @@ -0,0 +1,149 @@ + +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 new file mode 100644 index 0000000..472304a --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+Reflowable.swift @@ -0,0 +1,161 @@ + +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 new file mode 100644 index 0000000..d1d2c05 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+Search.swift @@ -0,0 +1,142 @@ + +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/RDReaderView/EPUBCore/RDEPUBWebView.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView.swift similarity index 100% rename from Sources/RDReaderView/EPUBCore/RDEPUBWebView.swift rename to Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView.swift diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebViewDebug.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebViewDebug.swift new file mode 100644 index 0000000..9486bdb --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebViewDebug.swift @@ -0,0 +1,106 @@ + +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 new file mode 100644 index 0000000..521cf11 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/WeReadApi.js @@ -0,0 +1,67 @@ +(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 new file mode 100644 index 0000000..9517c26 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/cssInjector.js @@ -0,0 +1,45 @@ +(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 new file mode 100644 index 0000000..f59c67e --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/epub-bridge.js @@ -0,0 +1,550 @@ + (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/RDReaderView/EPUBCore/Resources/epub-fixed-layout.html b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/epub-fixed-layout.html similarity index 100% rename from Sources/RDReaderView/EPUBCore/Resources/epub-fixed-layout.html rename to Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/epub-fixed-layout.html diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/rangy-core.js b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/rangy-core.js new file mode 100644 index 0000000..d1f4a9f --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/rangy-core.js @@ -0,0 +1,13 @@ +(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 new file mode 100644 index 0000000..ff74015 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/rangy-serializer.js @@ -0,0 +1,93 @@ +(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 new file mode 100644 index 0000000..cb21780 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/wxread-dark.css @@ -0,0 +1,56 @@ +/* 适配搜狗百科 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 new file mode 100644 index 0000000..49323e0 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/wxread-default.css @@ -0,0 +1,221 @@ +/* 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 new file mode 100644 index 0000000..318b912 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/wxread-replace-latin.css @@ -0,0 +1,113 @@ +/*
代码块,注意必须写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
new file mode 100644
index 0000000..7c4909c
--- /dev/null
+++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBCore/Resources/wxread-replace.css
@@ -0,0 +1,171 @@
+/* 
代码块,注意必须写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
new file mode 100644
index 0000000..e169d71
--- /dev/null
+++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBBuildDiagnosticsReporter.swift
@@ -0,0 +1,63 @@
+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
new file mode 100644
index 0000000..6a2b19f
--- /dev/null
+++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBChapterTailNormalizer.swift
@@ -0,0 +1,197 @@
+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
new file mode 100644
index 0000000..d2a3cf8
--- /dev/null
+++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBPaginationCacheCoordinator.swift
@@ -0,0 +1,48 @@
+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
new file mode 100644
index 0000000..a57f381
--- /dev/null
+++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookBuilder.swift
@@ -0,0 +1,420 @@
+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
new file mode 100644
index 0000000..6728e52
--- /dev/null
+++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookCache.swift
@@ -0,0 +1,205 @@
+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
new file mode 100644
index 0000000..bf24607
--- /dev/null
+++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookModels.swift
@@ -0,0 +1,193 @@
+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
new file mode 100644
index 0000000..19074f6
--- /dev/null
+++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBuildPipelineInterfaces.swift
@@ -0,0 +1,48 @@
+
+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
new file mode 100644
index 0000000..ac974d5
--- /dev/null
+++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextPerformanceSampler.swift
@@ -0,0 +1,64 @@
+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
new file mode 100644
index 0000000..b1bdff4
--- /dev/null
+++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBChapterPageCounter.swift
@@ -0,0 +1,271 @@
+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
new file mode 100644
index 0000000..893fcdc
--- /dev/null
+++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBCoreTextPageFrameFactory.swift
@@ -0,0 +1,499 @@
+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
new file mode 100644
index 0000000..bb3cf38
--- /dev/null
+++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBPageBreakPolicy.swift
@@ -0,0 +1,217 @@
+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
new file mode 100644
index 0000000..ef5c0d7
--- /dev/null
+++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBTextLayoutFrame.swift
@@ -0,0 +1,38 @@
+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
new file mode 100644
index 0000000..d87f9ed
--- /dev/null
+++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBTextLayouter.swift
@@ -0,0 +1,19 @@
+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
new file mode 100644
index 0000000..257acbe
--- /dev/null
+++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBTextPaginationInterfaces.swift
@@ -0,0 +1,32 @@
+
+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
new file mode 100644
index 0000000..a7ab73c
--- /dev/null
+++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Pagination/RDEPUBTextPaginationSupport.swift
@@ -0,0 +1,19 @@
+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
new file mode 100644
index 0000000..cdd4d58
--- /dev/null
+++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBChapterData.swift
@@ -0,0 +1,380 @@
+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
new file mode 100644
index 0000000..28c1e63
--- /dev/null
+++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBDTCoreTextRenderer.swift
@@ -0,0 +1,135 @@
+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
new file mode 100644
index 0000000..5d01418
--- /dev/null
+++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBTextIndexTable.swift
@@ -0,0 +1,542 @@
+
+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
new file mode 100644
index 0000000..14bcab9
--- /dev/null
+++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBTextPositionConverter.swift
@@ -0,0 +1,70 @@
+
+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
new file mode 100644
index 0000000..7ae82d3
--- /dev/null
+++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift
@@ -0,0 +1,337 @@
+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
new file mode 100644
index 0000000..7af5b10
--- /dev/null
+++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEPUBTextSearchEngine.swift
@@ -0,0 +1,128 @@
+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
new file mode 100644
index 0000000..e0cdc60
--- /dev/null
+++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/RDEpubPlainTextBookBuilder.swift
@@ -0,0 +1,158 @@
+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 new file mode 100644 index 0000000..66f0799 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBCSSCompatibilityLayer.swift @@ -0,0 +1,193 @@ +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 new file mode 100644 index 0000000..6ee3e85 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBFontFallbackResolver.swift @@ -0,0 +1,21 @@ +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 new file mode 100644 index 0000000..a06967e --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBStyleCompatibilityModels.swift @@ -0,0 +1,101 @@ +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 new file mode 100644 index 0000000..ffae4bf --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBAttachmentNormalizer.swift @@ -0,0 +1,257 @@ +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 new file mode 100644 index 0000000..cb75f0a --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBCFIMarkerInjector.swift @@ -0,0 +1,59 @@ +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 new file mode 100644 index 0000000..6869ca2 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBDecryptingImageAttachment.swift @@ -0,0 +1,66 @@ +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 new file mode 100644 index 0000000..be5f5c3 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBFontNormalizer.swift @@ -0,0 +1,160 @@ +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 new file mode 100644 index 0000000..45272af --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBFragmentMarkerInjector.swift @@ -0,0 +1,68 @@ + +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 new file mode 100644 index 0000000..10aaa02 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBHTMLNormalizer.swift @@ -0,0 +1,270 @@ +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 new file mode 100644 index 0000000..8bc00b1 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBRenderDiagnosticsCollector.swift @@ -0,0 +1,191 @@ +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 new file mode 100644 index 0000000..cc941ac --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBSemanticMarkerInjector.swift @@ -0,0 +1,336 @@ + +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 new file mode 100644 index 0000000..45015ac --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBStyleSheetComposer.swift @@ -0,0 +1,289 @@ +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 new file mode 100644 index 0000000..04cd13f --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBTextRendererSupport.swift @@ -0,0 +1,185 @@ +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 new file mode 100644 index 0000000..1fe50e7 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/RDEPUBTypesettingPipeline.swift @@ -0,0 +1,117 @@ +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 new file mode 100644 index 0000000..5e8103d --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Notes/RDEPUBNotePopupCoordinator.swift @@ -0,0 +1,24 @@ +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 new file mode 100644 index 0000000..1f5df59 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Notes/RDEPUBNotePopupViewController.swift @@ -0,0 +1,145 @@ +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 new file mode 100644 index 0000000..73eed51 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBAttachmentTooltipOverlayView.swift @@ -0,0 +1,36 @@ + +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 new file mode 100644 index 0000000..f82fd91 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBAttachmentTooltipView.swift @@ -0,0 +1,188 @@ + +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 new file mode 100644 index 0000000..bb6ace2 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBImageViewController.swift @@ -0,0 +1,241 @@ +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 new file mode 100644 index 0000000..d0dcf09 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBImageViewerCoordinator.swift @@ -0,0 +1,61 @@ +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 new file mode 100644 index 0000000..c33ebf6 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderBottomToolView.swift @@ -0,0 +1,146 @@ +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 new file mode 100644 index 0000000..184128a --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderChapterListController.swift @@ -0,0 +1,66 @@ +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 new file mode 100644 index 0000000..8ba839b --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+AttachmentTooltip.swift @@ -0,0 +1,79 @@ + +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 new file mode 100644 index 0000000..54bb779 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift @@ -0,0 +1,185 @@ + +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 new file mode 100644 index 0000000..862b259 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+DataSource.swift @@ -0,0 +1,464 @@ + +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 new file mode 100644 index 0000000..bdebaf4 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+ExternalLinks.swift @@ -0,0 +1,35 @@ + +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 new file mode 100644 index 0000000..7ff047e --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+LocationResolution.swift @@ -0,0 +1,319 @@ + +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 new file mode 100644 index 0000000..32dd893 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+Orientation.swift @@ -0,0 +1,48 @@ +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 new file mode 100644 index 0000000..afe8d50 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+PublicAPI.swift @@ -0,0 +1,168 @@ + +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/RDReaderView/EPUBUI/RDEPUBReaderController+RenderSupport.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+RenderSupport.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/RDEPUBReaderController+RenderSupport.swift rename to Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+RenderSupport.swift diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+RuntimeBridge.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+RuntimeBridge.swift new file mode 100644 index 0000000..2fa6d66 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+RuntimeBridge.swift @@ -0,0 +1,241 @@ + +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 new file mode 100644 index 0000000..8c38a63 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+TableOfContents.swift @@ -0,0 +1,109 @@ + +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 new file mode 100644 index 0000000..69c67e8 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+Trial.swift @@ -0,0 +1,93 @@ +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 new file mode 100644 index 0000000..b508359 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController.swift @@ -0,0 +1,503 @@ +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 new file mode 100644 index 0000000..30f23fb --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderDelegate.swift @@ -0,0 +1,56 @@ +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 new file mode 100644 index 0000000..c127a2c --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderHighlightsViewController.swift @@ -0,0 +1,351 @@ +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 new file mode 100644 index 0000000..c4e113d --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderPersistence.swift @@ -0,0 +1,180 @@ +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 new file mode 100644 index 0000000..3ac1c21 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderSearchBarView.swift @@ -0,0 +1,634 @@ +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 new file mode 100644 index 0000000..7f22a2d --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderTableOfContentsItem.swift @@ -0,0 +1,19 @@ +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 new file mode 100644 index 0000000..9189cbb --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderToolView.swift @@ -0,0 +1,35 @@ +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 new file mode 100644 index 0000000..25ba54a --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderToolViewProtocols.swift @@ -0,0 +1,69 @@ +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 new file mode 100644 index 0000000..28ddb84 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderTopToolView.swift @@ -0,0 +1,136 @@ +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 new file mode 100644 index 0000000..8d72dff --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBSettingsFlipAutomation.swift @@ -0,0 +1,84 @@ +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 new file mode 100644 index 0000000..2778689 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBViewportTypes.swift @@ -0,0 +1,34 @@ +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/RDReaderView/EPUBUI/RDEPUBWebContentView.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBWebContentView.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/RDEPUBWebContentView.swift rename to Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBWebContentView.swift diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBWebDecorationOverlayView.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBWebDecorationOverlayView.swift new file mode 100644 index 0000000..2448e3d --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEPUBWebDecorationOverlayView.swift @@ -0,0 +1,48 @@ +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 new file mode 100644 index 0000000..d18bf09 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/RDEpubURLReaderController.swift @@ -0,0 +1,516 @@ + +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 new file mode 100644 index 0000000..6d3dfa2 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBBackgroundTrace.swift @@ -0,0 +1,10 @@ +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 new file mode 100644 index 0000000..79010ea --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBBookPageMap.swift @@ -0,0 +1,112 @@ +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 new file mode 100644 index 0000000..3892060 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterCacheKey.swift @@ -0,0 +1,12 @@ +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 new file mode 100644 index 0000000..7f7fef3 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterDataCache.swift @@ -0,0 +1,75 @@ +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 new file mode 100644 index 0000000..a4428c7 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLoader.swift @@ -0,0 +1,838 @@ +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 new file mode 100644 index 0000000..6a33766 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLocation.swift @@ -0,0 +1,30 @@ +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 new file mode 100644 index 0000000..af1323f --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterOffsetMap.swift @@ -0,0 +1,69 @@ +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/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterRuntimeStore.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterRuntimeStore.swift similarity index 98% rename from Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterRuntimeStore.swift rename to Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterRuntimeStore.swift index e4166d1..7b8efc7 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterRuntimeStore.swift +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterRuntimeStore.swift @@ -8,7 +8,7 @@ final class RDEPUBChapterRuntimeStore { let imageCache = NSCache() - let chapterLoadQueue = DispatchQueue(label: "com.rdreader.chapterload", qos: .userInitiated) + let chapterLoadQueue = DispatchQueue(label: "com.RDEpubReader.chapterload", qos: .userInitiated) private let chapterLoadQueueKey = DispatchSpecificKey() diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterSummaryDiskCache.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterSummaryDiskCache.swift new file mode 100644 index 0000000..e0a5a79 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterSummaryDiskCache.swift @@ -0,0 +1,228 @@ +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 new file mode 100644 index 0000000..7d23fab --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterWarmupOrchestrator.swift @@ -0,0 +1,679 @@ +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 new file mode 100644 index 0000000..df58a84 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBPageCountCache.swift @@ -0,0 +1,33 @@ +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 new file mode 100644 index 0000000..af9d86b --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBPageResolver.swift @@ -0,0 +1,40 @@ +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 new file mode 100644 index 0000000..9cfddd3 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBRuntimeChapter.swift @@ -0,0 +1,52 @@ +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 new file mode 100644 index 0000000..c62966f --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBRuntimePageCount.swift @@ -0,0 +1,14 @@ +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 new file mode 100644 index 0000000..7572b04 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBBackgroundCoverageStore.swift @@ -0,0 +1,252 @@ +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 new file mode 100644 index 0000000..0d5a652 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBBackgroundPriorityPolicy.swift @@ -0,0 +1,178 @@ +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 new file mode 100644 index 0000000..0c684dc --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBJumpSession.swift @@ -0,0 +1,191 @@ +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 new file mode 100644 index 0000000..40067fa --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBMemoryProbe.swift @@ -0,0 +1,48 @@ +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 new file mode 100644 index 0000000..6408b3d --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBMetadataParseCancellationController.swift @@ -0,0 +1,44 @@ +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 new file mode 100644 index 0000000..d1bf3e5 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBMetadataParseWorker.swift @@ -0,0 +1,604 @@ +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 new file mode 100644 index 0000000..c601bfe --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBPageMapReconciliationCoordinator.swift @@ -0,0 +1,230 @@ +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 new file mode 100644 index 0000000..48d0bbe --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBPresentationRuntime.swift @@ -0,0 +1,471 @@ +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 new file mode 100644 index 0000000..8354f60 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderAnnotationCoordinator.swift @@ -0,0 +1,578 @@ +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 new file mode 100644 index 0000000..9179cbb --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderAssemblyCoordinator.swift @@ -0,0 +1,81 @@ +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 new file mode 100644 index 0000000..b714576 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderChromeCoordinator.swift @@ -0,0 +1,235 @@ +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 new file mode 100644 index 0000000..cb325a5 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderContext.swift @@ -0,0 +1,420 @@ +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 new file mode 100644 index 0000000..17490f0 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderDependencies.swift @@ -0,0 +1,97 @@ + +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 new file mode 100644 index 0000000..c15edae --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderEnvironment.swift @@ -0,0 +1,98 @@ +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 new file mode 100644 index 0000000..f98286f --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderLoadCoordinator.swift @@ -0,0 +1,86 @@ +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 new file mode 100644 index 0000000..0c34441 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderLocationCoordinator.swift @@ -0,0 +1,125 @@ +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/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift rename to Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderRuntime.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderRuntime.swift similarity index 100% rename from Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderRuntime.swift rename to Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderRuntime.swift diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderSearchCoordinator.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderSearchCoordinator.swift new file mode 100644 index 0000000..1c24f55 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderSearchCoordinator.swift @@ -0,0 +1,419 @@ +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 new file mode 100644 index 0000000..78eabca --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderServices.swift @@ -0,0 +1,54 @@ +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 new file mode 100644 index 0000000..eefa79e --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderState.swift @@ -0,0 +1,75 @@ +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 new file mode 100644 index 0000000..326941a --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderUIState.swift @@ -0,0 +1,34 @@ +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 new file mode 100644 index 0000000..1fb4948 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderViewportMonitor.swift @@ -0,0 +1,129 @@ +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 new file mode 100644 index 0000000..0949414 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBSelectionState.swift @@ -0,0 +1,31 @@ + +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 new file mode 100644 index 0000000..25203fd --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderConfiguration.swift @@ -0,0 +1,215 @@ +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 new file mode 100644 index 0000000..ef8ac29 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderSettings.swift @@ -0,0 +1,160 @@ +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 new file mode 100644 index 0000000..871d11d --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderSettingsViewController.swift @@ -0,0 +1,368 @@ +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 new file mode 100644 index 0000000..9a3a6d7 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderTheme.swift @@ -0,0 +1,103 @@ +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 new file mode 100644 index 0000000..fddc71a --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/String+RDEPUB.swift @@ -0,0 +1,17 @@ +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 new file mode 100644 index 0000000..d4ba110 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBChapterDisplayContentCache.swift @@ -0,0 +1,114 @@ +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 new file mode 100644 index 0000000..acc3534 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBDarkImageAdjuster.swift @@ -0,0 +1,107 @@ +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 new file mode 100644 index 0000000..e0e3689 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBPageInteractionController.swift @@ -0,0 +1,215 @@ +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 new file mode 100644 index 0000000..54be1e0 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBPageLayoutSnapshot.swift @@ -0,0 +1,224 @@ +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 new file mode 100644 index 0000000..d2d9cde --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBSelectionLoupeView.swift @@ -0,0 +1,78 @@ +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 new file mode 100644 index 0000000..afd7e35 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBSelectionOverlayView.swift @@ -0,0 +1,177 @@ +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 new file mode 100644 index 0000000..11a5caf --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextAnnotationOverlay.swift @@ -0,0 +1,135 @@ +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 new file mode 100644 index 0000000..99c87e9 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift @@ -0,0 +1,1171 @@ +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 new file mode 100644 index 0000000..323f771 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextPageBoundaryValidator.swift @@ -0,0 +1,223 @@ +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 new file mode 100644 index 0000000..3bb438e --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextPageDecorationView.swift @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..b72036d --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextPageRenderView.swift @@ -0,0 +1,213 @@ +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 new file mode 100644 index 0000000..01aae72 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextPageVerticalJustifier.swift @@ -0,0 +1,57 @@ +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 new file mode 100644 index 0000000..c732c3b --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextSelectionController.swift @@ -0,0 +1,388 @@ +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 new file mode 100644 index 0000000..afd465e --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/UIColor+RDEPUB.swift @@ -0,0 +1,31 @@ +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 new file mode 100644 index 0000000..829a05f --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/EPUBUI/UIColor+RDEPUBHex.swift @@ -0,0 +1,16 @@ +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 new file mode 100644 index 0000000..54d1782 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/Paging/RDEpubReaderPagingController.swift @@ -0,0 +1,45 @@ +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/RDReaderView/ReaderView/Paging/RDReaderPreloadController.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/Paging/RDEpubReaderPreloadController.swift similarity index 87% rename from Sources/RDReaderView/ReaderView/Paging/RDReaderPreloadController.swift rename to Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/Paging/RDEpubReaderPreloadController.swift index 293b4b3..51fb53a 100644 --- a/Sources/RDReaderView/ReaderView/Paging/RDReaderPreloadController.swift +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/Paging/RDEpubReaderPreloadController.swift @@ -1,11 +1,11 @@ import UIKit -protocol RDReaderCachePolicyProviding { +protocol RDEpubReaderCachePolicyProviding { var shouldAvoidReaderPageCaching: Bool { get } } -final class RDReaderPreloadController { +final class RDEpubReaderPreloadController { var radius: Int = 2 @@ -19,7 +19,7 @@ final class RDReaderPreloadController { struct Environment { - let displayType: RDReaderView.DisplayType + let displayType: RDEpubReaderView.DisplayType let isLandscape: Bool @@ -33,12 +33,12 @@ final class RDReaderPreloadController { let totalPages: Int - let spreadResolver: RDReaderSpreadResolver + let spreadResolver: RDEpubReaderSpreadResolver } private struct CacheSignature: Equatable { - let displayType: RDReaderView.DisplayType + let displayType: RDEpubReaderView.DisplayType let isLandscape: Bool @@ -66,7 +66,7 @@ final class RDReaderPreloadController { } func invalidate(environment: Environment) { - RDReaderTapDebug.log( + RDEpubReaderTapDebug.log( "PreloadController.invalidate", "clearing pageCurlCached=\(pageCurlCachedViews.keys.sorted()) preloaded=\(preloadedPageViews.keys.sorted())" ) @@ -85,15 +85,15 @@ final class RDReaderPreloadController { let view: UIView if let reusableView = detachedReusablePageView(for: pageNum) { view = reusableView - RDReaderTapDebug.log( + RDEpubReaderTapDebug.log( "PreloadController.pageViewForDisplay", - "cache HIT page=\(pageNum) view=\(RDReaderTapDebug.describe(reusableView))" + "cache HIT page=\(pageNum) view=\(RDEpubReaderTapDebug.describe(reusableView))" ) } else { view = contentViewProvider(pageNum, nil) ?? UIView() - RDReaderTapDebug.log( + RDEpubReaderTapDebug.log( "PreloadController.pageViewForDisplay", - "cache MISS page=\(pageNum) created=\(RDReaderTapDebug.describe(view))" + "cache MISS page=\(pageNum) created=\(RDEpubReaderTapDebug.describe(view))" ) } if shouldCache(view: view, for: pageNum, environment: environment) { @@ -107,9 +107,9 @@ final class RDReaderPreloadController { func takePreloadedView(for pageNum: Int) -> UIView? { let preloaded = preloadedPageViews.removeValue(forKey: pageNum) preloaded?.removeFromSuperview() - RDReaderTapDebug.log( + RDEpubReaderTapDebug.log( "PreloadController.takePreloadedView", - "cache \(preloaded == nil ? "MISS" : "HIT") page=\(pageNum) view=\(RDReaderTapDebug.describe(preloaded))" + "cache \(preloaded == nil ? "MISS" : "HIT") page=\(pageNum) view=\(RDEpubReaderTapDebug.describe(preloaded))" ) return preloaded } @@ -134,21 +134,28 @@ final class RDReaderPreloadController { let contentView: UIView if let existing = preloadedPageViews[targetPage], existing.superview === preloadHostView { contentView = existing - RDReaderTapDebug.log( + RDEpubReaderTapDebug.log( "PreloadController.prime", - "cache HIT(preloaded) page=\(targetPage) view=\(RDReaderTapDebug.describe(existing))" + "cache HIT(preloaded) page=\(targetPage) view=\(RDEpubReaderTapDebug.describe(existing))" ) } else if let cached = pageCurlCachedViews.removeValue(forKey: targetPage), cached.superview == nil { contentView = cached - RDReaderTapDebug.log( + RDEpubReaderTapDebug.log( "PreloadController.prime", - "cache HIT(pageCurl) page=\(targetPage) view=\(RDReaderTapDebug.describe(cached))" + "cache HIT(pageCurl) page=\(targetPage) view=\(RDEpubReaderTapDebug.describe(cached))" ) } else { - contentView = contentViewProvider(targetPage, nil) ?? UIView() - RDReaderTapDebug.log( + 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=\(RDReaderTapDebug.describe(contentView))" + "cache MISS page=\(targetPage) created=\(RDEpubReaderTapDebug.describe(contentView))" ) } let shouldCacheContentView = shouldCache(view: contentView, for: targetPage, environment: environment) @@ -188,14 +195,14 @@ final class RDReaderPreloadController { private func shouldCachePage(_ pageNum: Int, environment: Environment) -> Bool { pageNum >= 0 - && pageNum != RDReaderView.blankPageNum - && pageNum != RDReaderView.blankEndPageNum + && 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? RDReaderCachePolicyProviding { + if let policyProvider = view as? RDEpubReaderCachePolicyProviding { return !policyProvider.shouldAvoidReaderPageCaching } return true diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/Paging/RDEpubReaderSpreadResolver.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/Paging/RDEpubReaderSpreadResolver.swift new file mode 100644 index 0000000..0f849bc --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/Paging/RDEpubReaderSpreadResolver.swift @@ -0,0 +1,74 @@ + +import Foundation + +struct RDEpubReaderSpreadResolver { + + func isFullScreenPage( + _ pageNum: Int, + landscapeDualPageEnabled: Bool, + isLandscape: Bool, + coverPageIndex: Int? + ) -> 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 new file mode 100644 index 0000000..01769ca --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/Paging/RDEpubReaderTapRegionHandler.swift @@ -0,0 +1,29 @@ + +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 new file mode 100644 index 0000000..3d81284 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderContentCell.swift @@ -0,0 +1,35 @@ + +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 new file mode 100644 index 0000000..544c8b7 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderFlowLayout.swift @@ -0,0 +1,305 @@ + +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 new file mode 100644 index 0000000..cb5ce0e --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderGestureController.swift @@ -0,0 +1,25 @@ + +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 new file mode 100644 index 0000000..015256a --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderPageChildViewController.swift @@ -0,0 +1,50 @@ + +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 new file mode 100644 index 0000000..f9ff05c --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView+CollectionView.swift @@ -0,0 +1,42 @@ + +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 new file mode 100644 index 0000000..a84e621 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView+ContentAccess.swift @@ -0,0 +1,68 @@ + +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 new file mode 100644 index 0000000..ae910c8 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView+PageCurl.swift @@ -0,0 +1,123 @@ + +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 new file mode 100644 index 0000000..881c61c --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView+ToolView.swift @@ -0,0 +1,136 @@ + +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/RDReaderView/ReaderView/RDReaderView.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView.swift similarity index 85% rename from Sources/RDReaderView/ReaderView/RDReaderView.swift rename to Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView.swift index d2a7be9..6949089 100644 --- a/Sources/RDReaderView/ReaderView/RDReaderView.swift +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView.swift @@ -1,7 +1,7 @@ import UIKit -enum RDReaderTapDebug { +enum RDEpubReaderTapDebug { private static let enabledArguments: Set = [ "--demo-tap-debug", @@ -18,7 +18,7 @@ enum RDReaderTapDebug { } let environment = ProcessInfo.processInfo.environment - return environment["RDREADER_TAP_DEBUG"] == "1" + return environment["RDEpubReader_TAP_DEBUG"] == "1" #endif } @@ -26,7 +26,7 @@ enum RDReaderTapDebug { guard isEnabled else { return } let threadRole = Thread.isMainThread ? "main" : "bg" let queueLabel = String(validatingUTF8: __dispatch_queue_get_label(nil)) ?? "unknown" - print("[RDReaderTap][\(scope)][\(threadRole)][queue=\(queueLabel)] \(message)") + print("[RDEpubReaderTap][\(scope)][\(threadRole)][queue=\(queueLabel)] \(message)") } static func describe(_ point: CGPoint) -> String { @@ -48,7 +48,7 @@ enum RDReaderTapDebug { } } -public class RDReaderView: UIView { +public class RDEpubReaderView: UIView { enum TapEvent { @@ -68,8 +68,8 @@ public class RDReaderView: UIView { return pageVC }() - lazy var layout: RDReaderFlowLayout = { - let layout = RDReaderFlowLayout(displayType: .horizontalScroll) + lazy var layout: RDEpubReaderFlowLayout = { + let layout = RDEpubReaderFlowLayout(displayType: .horizontalScroll) layout.dataSource = self layout.delegate = self return layout @@ -83,17 +83,17 @@ public class RDReaderView: UIView { return collectionView }() - private let spreadResolver = RDReaderSpreadResolver() + private let spreadResolver = RDEpubReaderSpreadResolver() - private let tapRegionHandler = RDReaderTapRegionHandler() + private let tapRegionHandler = RDEpubReaderTapRegionHandler() - let preloadController = RDReaderPreloadController() + let preloadController = RDEpubReaderPreloadController() - var pagingController = RDReaderPagingController() + var pagingController = RDEpubReaderPagingController() public var currentPage: Int = -1 { didSet { - if let delegate = delegate, currentPage != oldValue, delegate.responds(to: #selector(RDReaderDelegate.pageNum(readerView:pageNum:))) { + if let delegate = delegate, currentPage != oldValue, delegate.responds(to: #selector(RDEpubReaderDelegate.pageNum(readerView:pageNum:))) { delegate.pageNum(readerView: self, pageNum: currentPage) } } @@ -151,19 +151,19 @@ public class RDReaderView: UIView { } } - private let legacyDataSourceAdapter = RDReaderLegacyDataSourceAdapter() + private let legacyDataSourceAdapter = RDEpubReaderLegacyDataSourceAdapter() - public weak var dataSource: RDReaderDataSource? { + public weak var dataSource: RDEpubReaderDataSource? { didSet { legacyDataSourceAdapter.dataSource = dataSource } } - public weak var pageProvider: RDReaderPageProvider? + public weak var pageProvider: RDEpubReaderPageProvider? - public weak var delegate: RDReaderDelegate? = nil + public weak var delegate: RDEpubReaderDelegate? = nil - public var currentDisplayType: RDReaderView.DisplayType = .pageCurl + public var currentDisplayType: RDEpubReaderView.DisplayType = .pageCurl public var toolViewAnimationDuration: TimeInterval = 0.3 @@ -173,11 +173,11 @@ public class RDReaderView: UIView { public var landscapeDualPageEnabled: Bool = false - public var pageDirection: RDReaderView.PageDirection = .leftToRight + public var pageDirection: RDEpubReaderView.PageDirection = .leftToRight public var coverPageIndex: Int? = nil - private var resolvedPageProvider: RDReaderPageProvider? { + private var resolvedPageProvider: RDEpubReaderPageProvider? { pageProvider ?? legacyDataSourceAdapter } @@ -299,7 +299,7 @@ public class RDReaderView: UIView { static let blankEndPageNum = Int.max - 1 - private typealias PageTransitionRequest = RDReaderPagingController.PageTransitionRequest + private typealias PageTransitionRequest = RDEpubReaderPagingController.PageTransitionRequest private let registeredSelectionLongPressRecognizers = NSHashTable.weakObjects() @@ -405,7 +405,7 @@ public class RDReaderView: UIView { return true } - let childViewControllers = viewControllers.compactMap { $0 as? RDReaderPageChildViewController } + let childViewControllers = viewControllers.compactMap { $0 as? RDEpubReaderPageChildViewController } guard childViewControllers.count == expectedCount else { return true } @@ -416,7 +416,7 @@ public class RDReaderView: UIView { let expectedPair = dualPagePair(for: currentPage) let expectedRightPage = expectedPair.right - ?? (isFullScreenPage(expectedPair.left) ? RDReaderView.blankPageNum : RDReaderView.blankEndPageNum) + ?? (isFullScreenPage(expectedPair.left) ? RDEpubReaderView.blankPageNum : RDEpubReaderView.blankEndPageNum) return childViewControllers[0].pageNum != expectedPair.left || childViewControllers[1].pageNum != expectedRightPage } @@ -456,8 +456,8 @@ public class RDReaderView: UIView { updatePagingInteractionSuppression() } - private var preloadEnvironment: RDReaderPreloadController.Environment { - RDReaderPreloadController.Environment( + private var preloadEnvironment: RDEpubReaderPreloadController.Environment { + RDEpubReaderPreloadController.Environment( displayType: currentDisplayType, isLandscape: isLandscape, pagesPerScreen: pagesPerScreen, @@ -477,7 +477,7 @@ public class RDReaderView: UIView { preloadController.pageViewForDisplay( pageNum: pageNum, environment: preloadEnvironment, - contentViewProvider: pageContentViewForPreload(pageNum:reusableView:) + contentViewProvider: pageContentViewForDisplay(pageNum:reusableView:) ) } @@ -491,10 +491,17 @@ public class RDReaderView: UIView { ) } - private func pageContentViewForPreload(pageNum: Int, reusableView: UIView?) -> UIView? { + 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() @@ -571,16 +578,16 @@ public class RDReaderView: UIView { @objc private func tapAction(tap: UITapGestureRecognizer) { let point = tap.location(in: tap.view) let hitView = hitTest(point, with: nil) - RDReaderTapDebug.log( + RDEpubReaderTapDebug.log( "ReaderView.tapAction", - "received point=\(RDReaderTapDebug.describe(point)) hitView=\(RDReaderTapDebug.describe(hitView)) display=\(currentDisplayType) currentPage=\(currentPage) toolVisible=\(isShowToolView) transitioning=\(isPageCurlTransitioning)" + "received point=\(RDEpubReaderTapDebug.describe(point)) hitView=\(RDEpubReaderTapDebug.describe(hitView)) display=\(currentDisplayType) currentPage=\(currentPage) toolVisible=\(isShowToolView) transitioning=\(isPageCurlTransitioning)" ) if containsTextContentView(in: hitView) { - RDReaderTapDebug.log("ReaderView.tapAction", "ignored because hitView is inside text content view") + RDEpubReaderTapDebug.log("ReaderView.tapAction", "ignored because hitView is inside text content view") return } if shouldSuppressChromeToggle(for: hitView, point: point) { - RDReaderTapDebug.log("ReaderView.tapAction", "ignored because chrome toggle is suppressed") + RDEpubReaderTapDebug.log("ReaderView.tapAction", "ignored because chrome toggle is suppressed") return } handleResolvedTap(at: point, hitView: hitView, in: tap.view) @@ -588,7 +595,7 @@ public class RDReaderView: UIView { private func shouldSuppressChromeToggle(for hitView: UIView?, point: CGPoint) -> Bool { if selectionTapSuppressedContentViews.allObjects.isEmpty == false { - RDReaderTapDebug.log( + RDEpubReaderTapDebug.log( "ReaderView.suppressChromeToggle", "suppressed by active selectionTapSuppressedContentViews count=\(selectionTapSuppressedContentViews.allObjects.count)" ) @@ -600,9 +607,9 @@ public class RDReaderView: UIView { let localPoint = convert(point, to: textContentView) let shouldSuppress = textContentView.shouldSuppressReaderTap(at: localPoint) if shouldSuppress { - RDReaderTapDebug.log( + RDEpubReaderTapDebug.log( "ReaderView.suppressChromeToggle", - "suppressed by text content view=\(RDReaderTapDebug.describe(textContentView)) localPoint=\(RDReaderTapDebug.describe(localPoint))" + "suppressed by text content view=\(RDEpubReaderTapDebug.describe(textContentView)) localPoint=\(RDEpubReaderTapDebug.describe(localPoint))" ) } return shouldSuppress @@ -630,9 +637,9 @@ public class RDReaderView: UIView { } else { selectionTapSuppressedContentViews.remove(contentView) } - RDReaderTapDebug.log( + RDEpubReaderTapDebug.log( "ReaderView.selectionTapSuppression", - "contentView=\(RDReaderTapDebug.describe(contentView)) suppressed=\(isSuppressed) activeCount=\(selectionTapSuppressedContentViews.allObjects.count)" + "contentView=\(RDEpubReaderTapDebug.describe(contentView)) suppressed=\(isSuppressed) activeCount=\(selectionTapSuppressedContentViews.allObjects.count)" ) } @@ -644,18 +651,18 @@ public class RDReaderView: UIView { } else { selectionPagingSuppressedContentViews.remove(contentView) } - RDReaderTapDebug.log( + RDEpubReaderTapDebug.log( "ReaderView.selectionPagingSuppression", - "contentView=\(RDReaderTapDebug.describe(contentView)) suppressed=\(isSuppressed) activeCount=\(selectionPagingSuppressedContentViews.allObjects.count)" + "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) - RDReaderTapDebug.log( + RDEpubReaderTapDebug.log( "ReaderView.handleContentTap", - "forwarded from sourceView=\(RDReaderTapDebug.describe(sourceView)) sourcePoint=\(RDReaderTapDebug.describe(point)) localPoint=\(RDReaderTapDebug.describe(localPoint))" + "forwarded from sourceView=\(RDEpubReaderTapDebug.describe(sourceView)) sourcePoint=\(RDEpubReaderTapDebug.describe(point)) localPoint=\(RDEpubReaderTapDebug.describe(localPoint))" ) handleResolvedTap(at: localPoint, hitView: nil, in: self) } @@ -663,11 +670,11 @@ public class RDReaderView: UIView { private func handleResolvedTap(at point: CGPoint, hitView: UIView?, in tapView: UIView?) { if isShowToolView { if let top = topToolView, isHitView(hitView, inside: top, point: point) { - RDReaderTapDebug.log("ReaderView.handleResolvedTap", "ignored because point hits top tool view") + RDEpubReaderTapDebug.log("ReaderView.handleResolvedTap", "ignored because point hits top tool view") return } if let bottom = bottomToolView, isHitView(hitView, inside: bottom, point: point) { - RDReaderTapDebug.log("ReaderView.handleResolvedTap", "ignored because point hits bottom tool view") + RDEpubReaderTapDebug.log("ReaderView.handleResolvedTap", "ignored because point hits bottom tool view") return } } @@ -677,9 +684,9 @@ public class RDReaderView: UIView { viewFrame: viewFrame, isToolViewVisible: isShowToolView ) - RDReaderTapDebug.log( + RDEpubReaderTapDebug.log( "ReaderView.handleResolvedTap", - "resolved tapEvent=\(tapEvent) point=\(RDReaderTapDebug.describe(point)) viewFrame=\(RDReaderTapDebug.describe(viewFrame))" + "resolved tapEvent=\(tapEvent) point=\(RDEpubReaderTapDebug.describe(point)) viewFrame=\(RDEpubReaderTapDebug.describe(viewFrame))" ) } @@ -698,7 +705,7 @@ public class RDReaderView: UIView { let shouldSuppress = activePagingSuppressionContentViews().isEmpty == false guard shouldSuppress != isPagingInteractionSuppressed else { return } isPagingInteractionSuppressed = shouldSuppress - RDReaderTapDebug.log( + RDEpubReaderTapDebug.log( "ReaderView.pagingSuppression", "updated shouldSuppress=\(shouldSuppress) activeContentViews=\(activePagingSuppressionContentViews().count)" ) @@ -722,7 +729,7 @@ public class RDReaderView: UIView { pageViewController.gestureRecognizers.forEach { $0.isEnabled = isEnabled } } - public func switchReaderDisplayType(_ displayType: RDReaderView.DisplayType) { + public func switchReaderDisplayType(_ displayType: RDEpubReaderView.DisplayType) { let previousDisplayType = currentDisplayType self.currentDisplayType = displayType if currentPage == -1 { @@ -764,7 +771,7 @@ public class RDReaderView: UIView { switch currentDisplayType { case .pageCurl: if !animated, safePageNum == currentPage, !detectPageViewControllerFault(pageViewController) { - RDReaderTapDebug.log( + RDEpubReaderTapDebug.log( "ReaderView.transitionToPage", "skip same page page=\(safePageNum) animated=\(animated)" ) @@ -789,19 +796,19 @@ public class RDReaderView: UIView { if isDualPage { let pair = dualPagePair(for: safePageNum) let leftContent = pageViewForDisplay(pageNum: pair.left) - let leftVC = RDReaderPageChildViewController(contentView: leftContent, 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 = RDReaderPageChildViewController(contentView: rightContent, 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) ? RDReaderView.blankPageNum : RDReaderView.blankEndPageNum - let emptyVC = RDReaderPageChildViewController(contentView: UIView(), pageNum: blankNum) + 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() @@ -811,7 +818,7 @@ public class RDReaderView: UIView { primePageCache(around: pair.left, preferredForward: predictedPageDirection) } else { let contentView = pageViewForDisplay(pageNum: safePageNum) - let vc = RDReaderPageChildViewController(contentView: contentView, 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 } @@ -856,7 +863,7 @@ public class RDReaderView: UIView { } -extension RDReaderView: RDReaderPageNavigating { +extension RDEpubReaderView: RDEpubReaderPageNavigating { public func reloadPages() { reloadData() } @@ -866,12 +873,12 @@ extension RDReaderView: RDReaderPageNavigating { } } -extension RDReaderView: UIGestureRecognizerDelegate { +extension RDEpubReaderView: UIGestureRecognizerDelegate { public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { guard gestureRecognizer === tapGestureRecognizer else { return true } if selectionTapSuppressedContentViews.allObjects.isEmpty == false { - RDReaderTapDebug.log( + RDEpubReaderTapDebug.log( "ReaderView.gestureShouldReceive", "return false because selectionTapSuppressedContentViews count=\(selectionTapSuppressedContentViews.allObjects.count)" ) @@ -880,27 +887,27 @@ extension RDReaderView: UIGestureRecognizerDelegate { let point = touch.location(in: self) if containsTextContentView(in: touch.view) { - RDReaderTapDebug.log( + RDEpubReaderTapDebug.log( "ReaderView.gestureShouldReceive", - "return false because touch.view is inside text content view point=\(RDReaderTapDebug.describe(point)) touchView=\(RDReaderTapDebug.describe(touch.view))" + "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) { - RDReaderTapDebug.log("ReaderView.gestureShouldReceive", "return false because touch hit topToolView") + RDEpubReaderTapDebug.log("ReaderView.gestureShouldReceive", "return false because touch hit topToolView") return false } if let bottomToolView, isHitView(touch.view, inside: bottomToolView, point: point) { - RDReaderTapDebug.log("ReaderView.gestureShouldReceive", "return false because touch hit bottomToolView") + RDEpubReaderTapDebug.log("ReaderView.gestureShouldReceive", "return false because touch hit bottomToolView") return false } if let searchBarView, isHitView(touch.view, inside: searchBarView, point: point) { - RDReaderTapDebug.log("ReaderView.gestureShouldReceive", "return false because touch hit searchBarView") + RDEpubReaderTapDebug.log("ReaderView.gestureShouldReceive", "return false because touch hit searchBarView") return false } - RDReaderTapDebug.log( + RDEpubReaderTapDebug.log( "ReaderView.gestureShouldReceive", - "return true point=\(RDReaderTapDebug.describe(point)) touchView=\(RDReaderTapDebug.describe(touch.view))" + "return true point=\(RDEpubReaderTapDebug.describe(point)) touchView=\(RDEpubReaderTapDebug.describe(touch.view))" ) return true } diff --git a/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderViewProtocols.swift b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderViewProtocols.swift new file mode 100644 index 0000000..de313e5 --- /dev/null +++ b/Sources/RDEpubReaderView/Sources/RDEpubReaderView/ReaderView/RDEpubReaderViewProtocols.swift @@ -0,0 +1,93 @@ + +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) + } +} diff --git a/Sources/RDReaderView/ReaderView/RDReaderViewProtocols.swift b/Sources/RDReaderView/ReaderView/RDReaderViewProtocols.swift deleted file mode 100644 index cf5d3c0..0000000 --- a/Sources/RDReaderView/ReaderView/RDReaderViewProtocols.swift +++ /dev/null @@ -1,89 +0,0 @@ - -import UIKit - -@available(*, deprecated, message: "Use RDReaderPageProvider instead.") -@objc public protocol RDReaderDataSource: NSObjectProtocol { - - func pageCountOfReaderView(readerView: RDReaderView) -> Int - - func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView - - func pageIdentifier(readerView: RDReaderView, pageNum: Int) -> String? - - @objc optional func topToolView(readerView: RDReaderView) -> UIView? - - @objc optional func bottomToolView(readerView: RDReaderView) -> UIView? -} - -@objc public protocol RDReaderPageProvider: NSObjectProtocol { - - func numberOfPages(in readerView: RDReaderView) -> Int - - func readerView(_ readerView: RDReaderView, viewForPageAt index: Int, reusableView: UIView?) -> UIView - - @objc optional func pageIdentifier(in readerView: RDReaderView, index: Int) -> String? - - @objc optional func readerViewTopChrome(_ readerView: RDReaderView) -> UIView? - - @objc optional func readerViewBottomChrome(_ readerView: RDReaderView) -> UIView? -} - -@objc public protocol RDReaderDelegate: NSObjectProtocol { - - func pageNum(readerView: RDReaderView, pageNum: Int) - - @objc optional func readerViewOrientationWillChange(readerView: RDReaderView, isLandscape: Bool) -} - -public protocol RDReaderPageNavigating: AnyObject { - - var currentPage: Int { get } - - func reloadPages() - - func transition(to page: Int, animated: Bool) -} - -extension RDReaderView { - - public enum DisplayType { - - case pageCurl - - case horizontalScroll - - case verticalScroll - } - - public enum PageDirection { - - case leftToRight - - case rightToLeft - } -} - -final class RDReaderLegacyDataSourceAdapter: NSObject, RDReaderPageProvider { - - weak var dataSource: RDReaderDataSource? - - func numberOfPages(in readerView: RDReaderView) -> Int { - dataSource?.pageCountOfReaderView(readerView: readerView) ?? 0 - } - - func readerView(_ readerView: RDReaderView, viewForPageAt index: Int, reusableView: UIView?) -> UIView { - dataSource?.pageContentView(readerView: readerView, pageNum: index, containerView: reusableView) ?? UIView() - } - - func pageIdentifier(in readerView: RDReaderView, index: Int) -> String? { - dataSource?.pageIdentifier(readerView: readerView, pageNum: index) - } - - func readerViewTopChrome(_ readerView: RDReaderView) -> UIView? { - dataSource?.topToolView?(readerView: readerView) - } - - func readerViewBottomChrome(_ readerView: RDReaderView) -> UIView? { - dataSource?.bottomToolView?(readerView: readerView) - } -} diff --git a/_ssoft-output/implementation-artifacts/spec-cfi-11-issues-fix.md b/_ssoft-output/implementation-artifacts/spec-cfi-11-issues-fix.md deleted file mode 100644 index ed315b9..0000000 --- a/_ssoft-output/implementation-artifacts/spec-cfi-11-issues-fix.md +++ /dev/null @@ -1,151 +0,0 @@ ---- -title: 'CFI 模块 11 项问题修复' -type: 'bugfix' -created: '2026-06-18' -baseline_commit: '6ed3fcb' -status: 'done' -context: - - 'Doc/CFI_ISSUES_REVIEW.md' ---- - - - -## 意图 - -**问题:** CFI 模块存在 11 个已验证的代码问题,涵盖数据一致性风险(rawValue 短路)、偏移计算错误(UTF-16/Character 混用)、健壮性缺陷(正则解析 HTML、token 宽泛匹配、转义缺失)和代码质量问题(重复扩展、线性扫描、混合错误处理)。 - -**方法:** 按 Phase 1→2→3 顺序修复全部 11 个问题,保持 API 向后兼容。高优先级问题从根源修正数据模型,中优先级加固边界处理,低优先级提取共享代码和优化性能。 - -## 边界与约束 - -**始终:** -- 保持 `public` API 签名不变,仅修改内部实现和 `internal`/`private` 代码 -- 所有偏移计算统一使用 UTF-16 基准(与 NSRange/NSString 一致) -- 修复必须保持现有正确行为的测试不回归 - -**先询问:** -- 是否为 #3(content path 硬编码)引入新的 `contentPath` 参数到 `makeOffsetCFI` 公开 API -- 是否为 #10(线性扫描)将 `markers` 从 `[RDEPUBCFIMarker]` 改为含索引字典的结构 - -**永不:** -- 不引入新的外部依赖(如 libxml2、DTCoreText) -- 不改变 CFI 字符串的序列化格式 -- 不修改 `RDEPUBCFIMarker`、`RDEPUBCFITokenAnchor` 等 Codable 结构的字段名(保持序列化兼容) - - - -## 代码映射 - -- `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFI.swift` -- #1 rawValue 存储模型 -- `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFISerializer.swift` -- #1 rawValue 短路序列化;#9 nilIfEmpty -- `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIGenerator.swift` -- #3 content path 硬编码;#8 end offset 边界 -- `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIDOMPathBuilder.swift` -- #2 偏移混用;#5 HTML 正则;#9 nilIfEmpty -- `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIRecoveryEngine.swift` -- #2 calibratedOffset;#7 token 匹配 -- `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIResolver.swift` -- #4 结构假设 -- `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIParser.swift` -- #6 text assertion 转义;#11 parent 静默失败;#9 nilIfEmpty -- `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIMap.swift` -- #10 线性扫描 -- `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIPath.swift` -- #9 nilIfEmpty -- `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFITextAssertion.swift` -- #9 nilIfEmpty - -## 任务与验收 - -**Phase 1 — 高优先级(影响正确性):** - -- [x] `RDEPUBCFI.swift` + `RDEPUBCFISerializer.swift` -- 将 `rawValue` 从存储属性改为 computed property(基于组件实时序列化),移除 Generator 中的 rawValue="" workaround -- #1 rawValue 短路导致序列化返回过期值 -- [x] `RDEPUBCFIDOMPathBuilder.swift` -- 将 `tokenSamples()` 的 offset 从 Character 索引改为 UTF-16 索引,确保与 `normalizedToChapterOffsets` 数组下标对齐;同步修正 `calibratedOffset` 中 NSString 搜索与 Character 偏移的一致性 -- #2 UTF-16/Character 偏移混用导致 emoji/CJK 扩展字符定位错误 -- [x] `RDEPUBCFIGenerator.swift` -- 为 `makeOffsetCFI` 添加可选 `contentPath` 参数(默认仍为 `/4/2`),`makeOffsetRangeCFI` 透传该参数 -- #3 content path 硬编码 `/4/2` 在非标准 DOM 结构下定位失败 - -**Phase 2 — 中优先级(影响健壮性):** - -- [x] `RDEPUBCFIResolver.swift` -- 用 `steps.count >= 3 ? steps[2] : steps.last` 替换 `last(where:)` 查找 manifest 步骤;用 `steps.count >= 2 ? steps[1] : nil` 替换 `dropFirst().last`;将 `idAssertion?.isEmpty == false` 改为 `idAssertion?.nilIfEmpty != nil` -- #4 Resolver 假设不健壮 -- [x] `RDEPUBCFIDOMPathBuilder.swift` -- 在正则匹配前先移除 HTML 注释 `` 和 CDATA `` 内容;对属性值中的 `>` 增加 `hasSuffix("/>")` 对自闭合标签的精确判断 -- #5 HTML 正则脆弱 -- [x] `RDEPUBCFIParser.swift` -- 在 `parseTextAssertion` 中实现反斜杠转义处理:用扫描替代 `firstIndex/lastIndex`,遇到 `\` 时跳过下一个字符 -- #6 text assertion 不处理转义 -- [x] `RDEPUBCFIRecoveryEngine.swift` -- 将双向 `contains` 匹配改为前缀+后缀匹配,设置最小 token 长度阈值 ≥ 4,要求 token 与 exact 长度比在 0.3~3.0 范围内 -- #7 token 匹配过于宽泛 -- [x] `RDEPUBCFIGenerator.swift` -- 在 `makeOffsetRangeCFI` 入口添加 `precondition(startOffset <= endOffset)` 或交换两者确保 start ≤ end -- #8 反向 range 退化为点 - -**Phase 3 — 低优先级(代码质量):** - -- [x] 新建 `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIUtilities.swift` -- 提取共享 `internal extension String { var nilIfEmpty }` -- #9 四处重复的 private nilIfEmpty -- [x] `RDEPUBCFIMap.swift` -- 添加 `lazy var markerByPath: [RDEPUBCFIPath: Int]` 索引字典,将 `marker(matching:)` 查找改为 O(1) -- #10 线性扫描性能瓶颈 -- [x] `RDEPUBCFIParser.swift` -- 将 `parent: try? parse(parentRaw)` 改为 `parent: try parse(parentRaw)`,统一抛出错误;或三者均改为 `try?` 并在 `RDEPUBCFIRange` 初始化中加注释说明容错策略 -- #11 parent 静默失败混合错误处理 - -**验收标准:** -- 含 emoji(如 😀)的文本在 CFI 恢复时定位偏移正确 -- 修改 rawValue 后序列化返回新值而非旧值 -- `makeOffsetCFI` 接受自定义 contentPath 时能正确定位嵌套文本节点 -- `parseTextAssertion` 正确解析含 `\[` `\]` 转义的 CFI -- `makeOffsetRangeCFI(startOffset: 10, endOffset: 5)` 不会静默产生退化 range -- `marker(matching:)` 在 500+ markers 场景下性能优于 O(n) - -## 规范变更日志 - -## 设计说明 - -**#1 rawValue 改为 computed property:** 当前 `RDEPUBCFI` 同时存储 `rawValue` 和组件,序列化时优先返回 rawValue 导致数据不一致。改为 computed property 后,`rawValue` 始终由 `RDEPUBCFISerializer.serialize(self)` 计算得出,消除缓存过期风险。由于 `RDEPUBCFI` 是 struct 且 `rawValue` 原本就参与 `Codable`,需同步调整 `Codable` 实现。 - -**#2 偏移统一:** `tokenSamples()` 当前用 `Array(normalizedText)` 生成 Character 索引,但 `normalizedToChapterOffsets` 数组按 UTF-16 code unit 索引构建。修正方式:tokenSamples 改为基于 UTF-16 偏移生成 token,使用 `nsSource.length` 迭代而非 Swift Character 迭代。 - -**#5 HTML 预处理:** 不引入外部 HTML 解析器,而是在正则匹配前用简单的字符串操作移除注释和 CDATA:`` 和 ``。这是最小侵入的修复,覆盖最常见的边界情况。 - -## 验证 - -**命令:** -- `swift build` -- 预期:编译成功,无错误无警告 -- `swift test` -- 预期:所有现有测试通过(如有 CFI 相关测试,需覆盖 emoji 和转义场景) - -**手动检查:** -- 确认 `RDEPUBCFIUtilities.swift` 中只有一个 `internal` nilIfEmpty 扩展 -- 确认 `RDEPUBCFI.rawValue` 在 struct 定义中为 computed property 而非 stored property -- 确认 `tokenSamples` 返回的 offset 类型与 `normalizedToChapterOffsets` 索引一致(均为 UTF-16 基准) - -## Suggested Review Order - -**数据模型核心(rawValue → computed property)** - -- rawValue 改为 computed property,移除短路逻辑,Codable 自定义实现 - [`RDEPUBCFI.swift:11`](../../Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFI.swift#L11) - -- rawValue 改为 computed property,Codable 自定义实现,init(rawValue:) 文档说明 - [`RDEPUBCFIRange.swift:9`](../../Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIRange.swift#L9) - -- 序列化移除 rawValue 短路缓存,直接从组件构建 - [`RDEPUBCFISerializer.swift:5`](../../Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFISerializer.swift#L5) - -- Generator 移除 rawValue="" workaround,添加 contentPath 参数,precondition 校验 - [`RDEPUBCFIGenerator.swift:5`](../../Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIGenerator.swift#L5) - -**偏移计算统一(UTF-16/Character 修复)** - -- tokenSamples 改用 NSString UTF-16 索引替代 Character 索引 - [`RDEPUBCFIDOMPathBuilder.swift:435`](../../Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIDOMPathBuilder.swift#L435) - -- textMarker 改用 NSRange 搜索,searchLocation 从 String.Index 改为 Int - [`RDEPUBCFIDOMPathBuilder.swift:212`](../../Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIDOMPathBuilder.swift#L212) - -- HTML 预处理:stripCommentsAndCDATA 方法 - [`RDEPUBCFIDOMPathBuilder.swift:81`](../../Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIDOMPathBuilder.swift#L81) - -**解析与健壮性修复** - -- Resolver 明确取固定索引步骤,改善可读性 - [`RDEPUBCFIResolver.swift:23`](../../Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIResolver.swift#L23) - -- text assertion 转义处理:firstUnescapedIndex + 括号深度 + unescapeCFIText - [`RDEPUBCFIParser.swift:131`](../../Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIParser.swift#L131) - -- parent 统一为 try 抛出错误 - [`RDEPUBCFIParser.swift:50`](../../Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIParser.swift#L50) - -- token 匹配改为前缀+后缀匹配,最小长度阈值 2,长度比 0.3~3.0 - [`RDEPUBCFIRecoveryEngine.swift:136`](../../Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIRecoveryEngine.swift#L136) - -**代码质量与性能** - -- 共享 internal nilIfEmpty 扩展 - [`RDEPUBCFIUtilities.swift:1`](../../Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIUtilities.swift#L1) - -- RDEPUBCFIMap 索引字典 + 自定义 Equatable/Codable - [`RDEPUBCFIMap.swift:11`](../../Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIMap.swift#L11) - -- 外部调用方移除 rawValue workaround - [`RDEPUBTextIndexTable.swift:211`](../../Sources/RDReaderView/EPUBTextRendering/RDEPUBTextIndexTable.swift#L211) \ No newline at end of file