From c65c190b711f32064a2711acb08d938481c9a9fe Mon Sep 17 00:00:00 2001 From: shenlei Date: Mon, 22 Jun 2026 20:26:34 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20EPUB=E9=98=85=E8=AF=BB=E5=99=A8?= =?UTF-8?q?=E6=90=9C=E7=B4=A2=E3=80=81=E6=B3=A8=E9=87=8A=E3=80=81CFI?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E5=8F=8A=E5=A4=A7=E4=B9=A6=E8=BF=9C=E8=B7=9D?= =?UTF-8?q?=E8=B7=B3=E8=BD=AC=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现EPUB阅读器搜索功能及选中注释功能 - 优化CFI模块,修复代码审查发现的11个问题 - 实现大书远距目录跳转与后台补全优化方案 - 优化设置面板与章节运行时联动 - 重构及大量改进优化 --- Doc/API_REFERENCE.md | 483 +++ Doc/CFI_ISSUES_REVIEW.md | 314 ++ Doc/CFI_SUBSYSTEM.md | 597 +++ Doc/CHAPTER_RUNTIME.md | 425 ++ Doc/EPUBCore_CODE_REFERENCE.md | 642 +++ Doc/EPUBTextRendering_CODE_REFERENCE.md | 521 +++ Doc/EPUBUI_CODE_REFERENCE.md | 727 ++++ Doc/ReaderView_CODE_REFERENCE.md | 455 +++ Doc/TYPESetter_PIPELINE.md | 349 ++ Doc/index.md | 19 +- Doc/readoor-vs-readview-feature-gap.md | 110 + Doc/大书远距目录跳转与后台补全优化方案.md | 1017 ----- Doc/标准级EPUB定位与兼容能力开发蓝图.md | 744 ++++ .../Pods/Pods.xcodeproj/project.pbxproj | 3572 +++++++++-------- .../Helpers/AccessibilityIdentifiers.swift | 11 + .../Helpers/DemoReaderState.swift | 16 +- .../LocationPersistenceTests.swift | 101 + .../ReaderUITests/SearchTests.swift | 24 +- .../CFI/RDEPUBCFIDOMPathBuilder.swift | 12 +- .../EPUBCore/CFI/RDEPUBCFIParser.swift | 2 +- .../EPUBCore/CFI/RDEPUBCFIPath.swift | 2 +- .../CFI/RDEPUBCFIRecoveryEngine.swift | 64 +- .../EPUBCore/CFI/RDEPUBCFIResolver.swift | 6 +- .../EPUBCore/CFI/RDEPUBCFITextAssertion.swift | 12 +- .../Models/RDEPUBAnnotationModels.swift | 80 +- .../Models/RDEPUBPaginationModels.swift | 85 +- .../Models/RDEPUBReadingLocationModels.swift | 62 +- .../EPUBCore/Notes/RDEPUBNoteDetector.swift | 63 + .../EPUBCore/Notes/RDEPUBNoteModels.swift | 85 + .../EPUBCore/Notes/RDEPUBNoteResolver.swift | 159 + .../EPUBCore/RDEPUBAssetRepository.swift | 36 +- .../EPUBCore/RDEPUBFixedLayoutTemplate.swift | 17 +- .../EPUBCore/RDEPUBJavaScriptBridge.swift | 45 +- .../RDReaderView/EPUBCore/RDEPUBModels.swift | 105 +- .../RDEPUBNavigatorLayoutContext.swift | 18 +- .../EPUBCore/RDEPUBNavigatorState.swift | 25 +- .../EPUBCore/RDEPUBPaginator.swift | 59 +- .../EPUBCore/RDEPUBParser+Archive.swift | 30 +- .../EPUBCore/RDEPUBParser+Package.swift | 57 +- .../RDEPUBParser+ReadingProfile.swift | 12 +- .../EPUBCore/RDEPUBParser+Resources.swift | 15 +- .../EPUBCore/RDEPUBParser+TOC.swift | 22 +- .../RDReaderView/EPUBCore/RDEPUBParser.swift | 27 +- .../EPUBCore/RDEPUBPreferences.swift | 29 +- .../EPUBCore/RDEPUBPublication.swift | 23 +- .../EPUBCore/RDEPUBReadingSession.swift | 52 +- .../EPUBCore/RDEPUBRenderRequest.swift | 76 +- .../EPUBCore/RDEPUBResourceResolver.swift | 26 +- .../RDEPUBResourceURLSchemeHandler.swift | 23 +- .../EPUBCore/RDEPUBSearchEngine.swift | 24 +- .../EPUBCore/RDEPUBSearchModels.swift | 59 +- .../EPUBCore/RDEPUBStyleSheetBuilder.swift | 12 +- .../EPUBCore/RDEPUBTextAnchor.swift | 24 +- .../RDEPUBWebView+Configuration.swift | 12 +- .../EPUBCore/RDEPUBWebView+FixedLayout.swift | 12 +- .../RDEPUBWebView+JavaScriptBridge.swift | 11 +- .../EPUBCore/RDEPUBWebView+Reflowable.swift | 10 +- .../EPUBCore/RDEPUBWebView+Search.swift | 4 - .../RDReaderView/EPUBCore/RDEPUBWebView.swift | 84 +- .../EPUBCore/RDEPUBWebViewDebug.swift | 20 +- .../EPUBCore/Resources/epub-fixed-layout.html | 61 +- .../RDEPUBBuildDiagnosticsReporter.swift | 8 +- .../RDEPUBChapterTailNormalizer.swift | 10 +- .../RDEPUBPaginationCacheCoordinator.swift | 5 +- .../BuildPipeline/RDEPUBTextBookBuilder.swift | 52 +- .../BuildPipeline/RDEPUBTextBookCache.swift | 62 +- .../BuildPipeline/RDEPUBTextBookModels.swift | 92 +- .../RDEPUBTextBuildPipelineInterfaces.swift | 20 +- .../RDEPUBTextPerformanceSampler.swift | 29 +- .../Pagination/RDEPUBChapterPageCounter.swift | 25 +- .../RDEPUBCoreTextPageFrameFactory.swift | 47 +- .../Pagination/RDEPUBPageBreakPolicy.swift | 24 +- .../Pagination/RDEPUBTextLayoutFrame.swift | 26 +- .../Pagination/RDEPUBTextLayouter.swift | 7 - .../RDEPUBTextPaginationInterfaces.swift | 29 +- .../RDEPUBTextPaginationSupport.swift | 18 +- .../EPUBTextRendering/RDEPUBChapterData.swift | 190 +- .../RDEPUBDTCoreTextRenderer.swift | 16 +- .../RDEPUBTextPositionConverter.swift | 50 +- .../RDEPUBTextRenderer.swift | 159 +- .../RDEPUBTextSearchEngine.swift | 31 +- .../RDPlainTextBookBuilder.swift | 31 +- .../RDEPUBCSSCompatibilityLayer.swift | 136 + .../RDEPUBFontFallbackResolver.swift | 21 + .../RDEPUBStyleCompatibilityModels.swift | 101 + .../RDEPUBAttachmentNormalizer.swift | 49 +- .../Typesetter/RDEPUBCFIMarkerInjector.swift | 59 + .../Typesetter/RDEPUBFontNormalizer.swift | 110 +- .../RDEPUBFragmentMarkerInjector.swift | 31 +- .../Typesetter/RDEPUBHTMLNormalizer.swift | 33 +- .../RDEPUBRenderDiagnosticsCollector.swift | 47 +- .../RDEPUBSemanticMarkerInjector.swift | 41 +- .../Typesetter/RDEPUBStyleSheetComposer.swift | 41 +- .../RDEPUBTextRendererSupport.swift | 47 +- .../RDEPUBTypesettingPipeline.swift | 85 +- .../Notes/RDEPUBNotePopupCoordinator.swift | 24 + .../Notes/RDEPUBNotePopupViewController.swift | 145 + .../EPUBUI/RDEPUBReaderBottomToolView.swift | 32 +- .../RDEPUBReaderChapterListController.swift | 8 +- ...PUBReaderController+ContentDelegates.swift | 155 +- .../RDEPUBReaderController+DataSource.swift | 57 +- .../RDEPUBReaderController+PublicAPI.swift | 88 +- ...RDEPUBReaderController+RenderSupport.swift | 20 +- ...RDEPUBReaderController+RuntimeBridge.swift | 51 +- ...EPUBReaderController+TableOfContents.swift | 12 +- .../EPUBUI/RDEPUBReaderController.swift | 97 +- .../EPUBUI/RDEPUBReaderDelegate.swift | 61 +- ...RDEPUBReaderHighlightsViewController.swift | 33 +- .../EPUBUI/RDEPUBReaderPersistence.swift | 45 +- .../EPUBUI/RDEPUBReaderSearchBarView.swift | 39 +- .../RDEPUBReaderTableOfContentsItem.swift | 12 +- .../EPUBUI/RDEPUBReaderToolView.swift | 17 +- .../EPUBUI/RDEPUBReaderTopToolView.swift | 11 +- .../EPUBUI/RDEPUBViewportTypes.swift | 20 +- .../EPUBUI/RDEPUBWebContentView.swift | 21 +- .../RDEPUBWebDecorationOverlayView.swift | 5 - .../EPUBUI/RDURLReaderController.swift | 70 +- .../RDEPUBBackgroundTrace.swift | 11 + .../ChapterRuntime/RDEPUBBookPageMap.swift | 40 +- .../RDEPUBChapterCacheKey.swift | 4 + .../RDEPUBChapterDataCache.swift | 2 + .../ChapterRuntime/RDEPUBChapterLoader.swift | 179 +- .../RDEPUBChapterLocation.swift | 13 +- .../RDEPUBChapterOffsetMap.swift | 25 +- .../RDEPUBChapterRuntimeStore.swift | 54 +- .../RDEPUBChapterSummaryDiskCache.swift | 45 +- .../RDEPUBChapterWindowCoordinator.swift | 59 +- .../RDEPUBChapterWindowSnapshot.swift | 17 +- .../ChapterRuntime/RDEPUBPageCountCache.swift | 2 + .../ChapterRuntime/RDEPUBPageResolver.swift | 5 + .../ChapterRuntime/RDEPUBRuntimeChapter.swift | 10 +- .../RDEPUBRuntimePageCount.swift | 5 + .../ChapterRuntime/String+SHA256.swift | 3 + .../RDEPUBBackgroundCoverageStore.swift | 66 +- .../RDEPUBBackgroundPriorityPolicy.swift | 42 +- .../ReaderController/RDEPUBJumpSession.swift | 60 +- ...EPUBPageMapReconciliationCoordinator.swift | 38 +- .../RDEPUBReaderAnnotationCoordinator.swift | 75 +- .../RDEPUBReaderAssemblyCoordinator.swift | 8 - .../RDEPUBReaderChromeCoordinator.swift | 27 +- .../RDEPUBReaderContext.swift | 125 +- .../RDEPUBReaderDependencies.swift | 30 +- .../RDEPUBReaderLoadCoordinator.swift | 9 - .../RDEPUBReaderLocationCoordinator.swift | 19 +- .../RDEPUBReaderPaginationCoordinator.swift | 144 +- .../RDEPUBReaderRuntime.swift | 293 +- .../RDEPUBReaderSearchCoordinator.swift | 27 +- .../RDEPUBReaderUIState.swift | 22 +- .../RDEPUBReaderViewportMonitor.swift | 15 +- .../RDEPUBSelectionState.swift | 16 +- .../Settings/RDEPUBReaderConfiguration.swift | 113 +- .../Settings/RDEPUBReaderSettings.swift | 64 +- .../RDEPUBReaderSettingsViewController.swift | 38 +- .../EPUBUI/Settings/RDEPUBReaderTheme.swift | 39 +- .../RDEPUBPageInteractionController.swift | 20 +- .../TextPage/RDEPUBPageLayoutSnapshot.swift | 48 +- .../TextPage/RDEPUBSelectionOverlayView.swift | 36 +- .../RDEPUBTextAnnotationOverlay.swift | 22 +- .../TextPage/RDEPUBTextContentView.swift | 412 +- .../RDEPUBTextPageDecorationView.swift | 2 - .../TextPage/RDEPUBTextPageRenderView.swift | 132 +- .../RDEPUBTextSelectionController.swift | 278 +- .../EPUBUI/UIColor+RDEPUBHex.swift | 6 +- .../Paging/RDReaderPagingController.swift | 16 +- .../Paging/RDReaderPreloadController.swift | 34 +- .../Paging/RDReaderSpreadResolver.swift | 15 +- .../Paging/RDReaderTapRegionHandler.swift | 14 +- .../ReaderView/RDReaderContentCell.swift | 23 +- .../ReaderView/RDReaderFlowLayout.swift | 76 +- .../RDReaderGestureController.swift | 37 +- .../RDReaderPageChildViewController.swift | 41 +- .../RDReaderView+CollectionView.swift | 18 +- .../RDReaderView+ContentAccess.swift | 16 +- .../ReaderView/RDReaderView+PageCurl.swift | 24 - .../ReaderView/RDReaderView+ToolView.swift | 16 +- .../ReaderView/RDReaderView.swift | 317 +- .../ReaderView/RDReaderViewProtocols.swift | 66 +- .../spec-cfi-11-issues-fix.md | 151 + 178 files changed, 11380 insertions(+), 6728 deletions(-) create mode 100644 Doc/API_REFERENCE.md create mode 100644 Doc/CFI_ISSUES_REVIEW.md create mode 100644 Doc/CFI_SUBSYSTEM.md create mode 100644 Doc/CHAPTER_RUNTIME.md create mode 100644 Doc/EPUBCore_CODE_REFERENCE.md create mode 100644 Doc/EPUBTextRendering_CODE_REFERENCE.md create mode 100644 Doc/EPUBUI_CODE_REFERENCE.md create mode 100644 Doc/ReaderView_CODE_REFERENCE.md create mode 100644 Doc/TYPESetter_PIPELINE.md create mode 100644 Doc/readoor-vs-readview-feature-gap.md delete mode 100644 Doc/大书远距目录跳转与后台补全优化方案.md create mode 100644 Doc/标准级EPUB定位与兼容能力开发蓝图.md create mode 100644 Sources/RDReaderView/EPUBCore/Notes/RDEPUBNoteDetector.swift create mode 100644 Sources/RDReaderView/EPUBCore/Notes/RDEPUBNoteModels.swift create mode 100644 Sources/RDReaderView/EPUBCore/Notes/RDEPUBNoteResolver.swift create mode 100644 Sources/RDReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBCSSCompatibilityLayer.swift create mode 100644 Sources/RDReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBFontFallbackResolver.swift create mode 100644 Sources/RDReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBStyleCompatibilityModels.swift create mode 100644 Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBCFIMarkerInjector.swift create mode 100644 Sources/RDReaderView/EPUBUI/Notes/RDEPUBNotePopupCoordinator.swift create mode 100644 Sources/RDReaderView/EPUBUI/Notes/RDEPUBNotePopupViewController.swift create mode 100644 _ssoft-output/implementation-artifacts/spec-cfi-11-issues-fix.md diff --git a/Doc/API_REFERENCE.md b/Doc/API_REFERENCE.md new file mode 100644 index 0000000..de34fb6 --- /dev/null +++ b/Doc/API_REFERENCE.md @@ -0,0 +1,483 @@ +# 公开 API 参考手册 + +> 最后更新:2026-06-18 + +本文档列出 ReadViewSDK 所有公开 API 的完整签名、参数说明和使用方法。 + +--- + +## 1. RDEPUBReaderController — 入口控制器 + +**文件**:`Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift` 及扩展 + +### 1.1 初始化 + +```swift +// EPUB 文件初始化 +init( + epubURL: URL, // EPUB 文件路径 + configuration: RDEPUBReaderConfiguration = .default, // 阅读配置 + persistence: RDEPUBReaderPersistence? = nil, // 持久化实现 + dependencies: RDEPUBReaderDependencies? = nil // 依赖注入 +) + +// 预构建 TextBook 初始化(如 .txt 文件) +init( + textBook: RDEPUBTextBook, // 预构建的文本书籍 + bookIdentifier: String, // 书籍唯一标识 + title: String, // 书籍标题 + textFileURL: URL? = nil, // 原始文件路径 + configuration: RDEPUBReaderConfiguration = .default, + persistence: RDEPUBReaderPersistence? = nil, + dependencies: RDEPUBReaderDependencies? = nil +) +``` + +### 1.2 公开属性 + +| 属性 | 类型 | 读写 | 说明 | +|------|------|------|------| +| `delegate` | `RDEPUBReaderDelegate?` | 读写 | 委托代理 | +| `configuration` | `RDEPUBReaderConfiguration` | 读写 | 阅读配置,设置后触发重新分页 | +| `currentLocation` | `RDEPUBLocation?` | 只读 | 当前阅读位置 | +| `currentPageNumber` | `Int?` | 只读 | 当前页码(1-based) | +| `currentSelection` | `RDEPUBSelection?` | 只读 | 当前文本选区 | +| `highlights` | `[RDEPUBHighlight]` | 只读 | 所有高亮标注 | +| `bookmarks` | `[RDEPUBBookmark]` | 只读 | 所有书签 | +| `annotations` | `[RDEPUBAnnotation]` | 只读 | 合并排序后的统一标注列表 | +| `tableOfContents` | `[EPUBTableOfContentsItem]` | 只读 | 目录树(嵌套结构) | +| `flattenedTableOfContents` | `[RDEPUBReaderTableOfContentsItem]` | 只读 | 扁平化目录列表 | +| `currentTableOfContentsItem` | `RDEPUBReaderTableOfContentsItem?` | 只读 | 当前所在目录项 | + +### 1.3 导航方法 + +```swift +/// 重新加载书籍 +func reloadBook() + +/// 跳转到指定位置 +func go(to location: RDEPUBLocation) + +/// 跳转到指定页码(1-based) +/// - Returns: 是否跳转成功 +@discardableResult +func go(toPageNumber pageNumber: Int, animated: Bool = false) -> Bool + +/// 跳转到目录项(EPUBTableOfContentsItem) +@discardableResult +func go(toTableOfContentsItem item: EPUBTableOfContentsItem, animated: Bool = true) -> Bool + +/// 跳转到目录项(RDEPUBReaderTableOfContentsItem) +@discardableResult +func go(toTableOfContentsItem item: RDEPUBReaderTableOfContentsItem, animated: Bool = true) -> Bool + +/// 跳转到目录 href(支持 fragment,如 "chapter1.xhtml#section2") +@discardableResult +func go(toTableOfContentsHref href: String, animated: Bool = true) -> Bool + +/// 跳转到指定高亮位置 +@discardableResult +func go(toHighlightID id: String, animated: Bool = true) -> Bool + +/// 跳转到指定书签位置 +@discardableResult +func go(toBookmarkID id: String, animated: Bool = true) -> Bool +``` + +### 1.4 高亮方法 + +```swift +/// 添加高亮(使用当前选区或指定选区) +/// - Parameters: +/// - selection: 选区信息,nil 时使用 currentSelection +/// - color: 高亮颜色(十六进制),默认 "#F8E16C" +/// - note: 可选备注 +/// - Returns: 创建的高亮对象,失败返回 nil +@discardableResult +func addHighlight( + from selection: RDEPUBSelection? = nil, + color: String = "#F8E16C", + note: String? = nil +) -> RDEPUBHighlight? + +/// 添加标注(支持高亮和下划线样式) +@discardableResult +func addAnnotation( + from selection: RDEPUBSelection? = nil, + style: RDEPUBHighlightStyle, + color: String = "#F8E16C", + note: String? = nil +) -> RDEPUBHighlight? + +/// 更新或插入高亮 +@discardableResult +func upsertHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight? + +/// 删除高亮 +@discardableResult +func removeHighlight(id: String) -> RDEPUBHighlight? + +/// 更新高亮备注 +@discardableResult +func updateHighlightNote(id: String, note: String?) -> RDEPUBHighlight? + +/// 删除所有高亮 +func removeAllHighlights() + +/// 根据 ID 查找高亮 +func highlight(withID id: String) -> RDEPUBHighlight? +``` + +### 1.5 书签方法 + +```swift +/// 添加书签(使用当前位置) +@discardableResult +func addBookmark(note: String? = nil) -> RDEPUBBookmark? + +/// 切换书签状态(已存在则删除,不存在则添加) +@discardableResult +func toggleBookmark(note: String? = nil) -> RDEPUBBookmark? + +/// 删除书签 +@discardableResult +func removeBookmark(id: String) -> RDEPUBBookmark? + +/// 根据 ID 查找书签 +func bookmark(withID id: String) -> RDEPUBBookmark? +``` + +### 1.6 搜索方法 + +```swift +/// 开始搜索 +func search(keyword: String) + +/// 跳转到下一个匹配 +/// - Returns: 是否有下一个匹配 +@discardableResult +func searchNext() -> Bool + +/// 跳转到上一个匹配 +@discardableResult +func searchPrevious() -> Bool + +/// 清除搜索 +func clearSearch() +``` + +### 1.7 其他方法 + +```swift +/// 清除当前文本选区 +func clearSelection() + +/// 获取当前页面的语义摘要(调试用) +func nativeTextSemanticSummary() -> String? +``` + +--- + +## 2. RDEPUBReaderDelegate — 委托协议 + +**文件**:`Sources/RDReaderView/EPUBUI/RDEPUBReaderDelegate.swift` + +所有方法均有默认空实现,可按需实现。 + +```swift +public protocol RDEPUBReaderDelegate: AnyObject { + + /// 书籍打开完成 + func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication) + + /// 阅读位置变化 + func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation) + + /// 到达书籍末尾 + func epubReaderDidReachEnd(_ reader: UIViewController) + + /// 文本选区变化(nil 表示取消选中) + 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) + + /// 是否允许打开外部链接(返回 false 拦截) + func epubReader(_ reader: UIViewController, shouldOpenExternalURL url: URL) -> Bool + + /// 错误回调 + func epubReader(_ reader: UIViewController, didFailWithError error: Error) + + /// 配置顶部工具栏(自定义按钮等) + func epubReader(_ reader: UIViewController, configureTopToolView topToolView: RDEPUBReaderTopToolView) +} +``` + +--- + +## 3. RDEPUBReaderPersistence — 持久化协议 + +**文件**:`Sources/RDReaderView/EPUBUI/RDEPUBReaderPersistence.swift` + +```swift +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) +} +``` + +默认实现 `RDEPUBUserDefaultsPersistence` 使用 UserDefaults 存储: + +| 数据 | Key 格式 | +|------|----------| +| 阅读位置 | `ssreader.epub.location.{bookID}` | +| 书签 | `ssreader.epub.bookmarks.{bookID}` | +| 高亮 | `ssreader.epub.highlights.{bookID}` | +| 全局设置 | `ssreader.epub.settings` | + +--- + +## 4. RDEPUBReaderConfiguration — 配置模型 + +**文件**:`Sources/RDReaderView/EPUBUI/Settings/RDEPUBReaderConfiguration.swift` + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `fontSize` | `CGFloat` | `15` | 字体大小(pt) | +| `lineHeightMultiple` | `CGFloat` | `1.6` | 行距倍数 | +| `fontChoice` | `RDEPUBReaderFontChoice` | `.system` | 字体选择(system/serif/rounded/monospaced) | +| `numberOfColumns` | `Int` | `1` | 每页列数(1 或 2) | +| `columnGap` | `CGFloat` | `20` | 列间距 | +| `displayType` | `RDReaderView.DisplayType` | `.pageCurl` | 翻页模式 | +| `landscapeDualPageEnabled` | `Bool` | `true` | 横屏双页 | +| `showsTableOfContents` | `Bool` | `true` | 显示目录 | +| `allowsHighlights` | `Bool` | `true` | 允许高亮 | +| `showsSettingsPanel` | `Bool` | `true` | 显示设置面板 | +| `reflowableContentInsets` | `UIEdgeInsets` | `(40,16,40,16)` | 重排内容内边距 | +| `fixedContentInset` | `UIEdgeInsets` | `.zero` | 固定布局内边距 | +| `theme` | `RDEPUBReaderTheme` | `.light` | 阅读主题 | +| `darkImageAdjustmentEnabled` | `Bool` | `true` | 暗色模式图片调整 | +| `darkImageBlendRatio` | `CGFloat` | `0.15` | 暗色图片混合比例(0-0.35) | +| `fixedLayoutFit` | `RDEPUBFixedLayoutFit` | `.page` | 固定布局适配方式 | +| `fixedLayoutSpreadMode` | `RDEPUBFixedLayoutSpreadMode` | `.automatic` | 固定布局跨页模式 | +| `textRenderingEngine` | `RDEPUBTextRenderingEngine` | `.dtCoreText` | 文本渲染引擎 | +| `onDemandChapterWindowSize` | `Int` | `3` | 按需加载窗口大小(奇数,3-15) | +| `metadataParsingConcurrency` | `Int` | CPU 核心数 | 后台解析并发数 | +| `jumpSessionPolicy` | `RDEPUBJumpSessionPolicy` | `.default` | 远距跳转策略 | +| `allowedExternalURLSchemes` | `Set` | `["https"]` | 允许的外部 URL 协议 | +| `requiresExternalLinkConfirmation` | `Bool` | `true` | 外部链接需确认 | +| `allowsInspectableWebViews` | `Bool` | `false` | WebView 可调试 | +| `enablesVerboseWebViewLogging` | `Bool` | `false` | WebView 详细日志 | + +**计算属性**: +- `chapterWindowRadius: Int` — 内存缓存窗口半径(`onDemandChapterWindowSize / 2`) + +--- + +## 5. 翻页容器协议 + +**文件**:`Sources/RDReaderView/ReaderView/RDReaderViewProtocols.swift` + +### 5.1 RDReaderPageProvider(推荐) + +```swift +@objc public protocol RDReaderPageProvider: NSObjectProtocol { + + /// 总页数 + func numberOfPages(in readerView: RDReaderView) -> Int + + /// 返回指定页的视图 + /// - Parameters: + /// - index: 页码索引(0-based) + /// - reusableView: 可复用的旧视图(可能为 nil) + 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? +} +``` + +### 5.2 RDReaderDelegate + +```swift +@objc public protocol RDReaderDelegate: NSObjectProtocol { + + /// 页面变化回调 + func pageNum(readerView: RDReaderView, pageNum: Int) + + /// 屏幕方向即将变化 + @objc optional func readerViewOrientationWillChange(readerView: RDReaderView, isLandscape: Bool) +} +``` + +### 5.3 RDReaderPageNavigating + +```swift +public protocol RDReaderPageNavigating: AnyObject { + + /// 当前页码 + var currentPage: Int { get } + + /// 重新加载所有页面 + func reloadPages() + + /// 跳转到指定页 + func transition(to page: Int, animated: Bool) +} +``` + +### 5.4 RDReaderDataSource(遗留) + +```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? +} +``` + +通过 `RDReaderLegacyDataSourceAdapter` 自动适配到 `RDReaderPageProvider`。 + +--- + +## 6. 数据模型速查 + +### 6.1 RDEPUBLocation + +```swift +public struct RDEPUBLocation: Codable, Equatable { + public var bookIdentifier: String? // 书籍标识 + public var href: String // 章节文件路径 + public var progression: Double // 章节内进度(0.0-1.0) + public var lastProgression: Double? // 上次进度(用于恢复方向) + public var fragment: String? // 片段 ID(如 #section1) + public var rangeAnchor: RDEPUBTextRangeAnchor? // 文本范围锚点 +} +``` + +### 6.2 RDEPUBBookmark + +```swift +public struct RDEPUBBookmark: Codable, Equatable, Identifiable { + public let id: String // 唯一标识 + public let bookIdentifier: String? + public let location: RDEPUBLocation + public let chapterTitle: String? + public let note: String? + public let createdAt: Date +} +``` + +### 6.3 RDEPUBHighlight + +```swift +public struct RDEPUBHighlight: Codable, Equatable, Identifiable { + public let id: String + public let bookIdentifier: String? + public let location: RDEPUBLocation + public let text: String // 高亮文本内容 + public let style: RDEPUBHighlightStyle // .highlight / .underline + public let color: String // 十六进制颜色 + public let note: String? + public let rangeInfo: String? // CoreText 选区范围信息 + public let createdAt: Date + public var uiColor: UIColor { ... } // 计算属性 +} +``` + +### 6.4 RDEPUBAnnotation + +```swift +public struct RDEPUBAnnotation: Codable, Equatable, Identifiable { + public let id: String + public let bookIdentifier: String? + public let kind: RDEPUBAnnotationKind // .bookmark / .highlight / .underline + public let location: RDEPUBLocation + public let text: String? + public let rangeInfo: String? + public let chapterTitle: String? + public let createdAt: Date + public var bookmark: RDEPUBBookmark? { ... } // kind == .bookmark 时有值 + public var highlight: RDEPUBHighlight? { ... } // kind == .highlight/.underline 时有值 +} +``` + +### 6.5 RDEPUBSelection + +```swift +public struct RDEPUBSelection: Equatable { + public let bookIdentifier: String? + public let location: RDEPUBLocation + public let text: String // 选中文本 + public let rangeInfo: String? // CoreText 选区范围 + public let createdAt: Date +} +``` + +### 6.6 EPUBPage / EPUBChapterInfo + +```swift +public struct EPUBPage: Equatable { + public let spineIndex: Int + public let chapterIndex: Int + public let pageIndexInChapter: Int + public let totalPagesInChapter: Int + public let chapterTitle: String + public let fixedSpread: EPUBFixedSpread? +} + +public struct EPUBChapterInfo: Equatable { + public let spineIndex: Int + public let title: String + public let pageCount: Int +} +``` + +### 6.7 RDEPUBSearchResult / RDEPUBSearchMatch + +```swift +public struct RDEPUBSearchResult: Equatable { + public let keyword: String + public let matches: [RDEPUBSearchMatch] +} + +public struct RDEPUBSearchMatch: Equatable { + public let spineIndex: Int + public let progression: Double + public let previewText: String + public let rangeAnchor: RDEPUBTextRangeAnchor? +} +``` diff --git a/Doc/CFI_ISSUES_REVIEW.md b/Doc/CFI_ISSUES_REVIEW.md new file mode 100644 index 0000000..8bc86a1 --- /dev/null +++ b/Doc/CFI_ISSUES_REVIEW.md @@ -0,0 +1,314 @@ +# CFI 实现问题分析报告 + +> 最后更新:2026-06-18 + +--- + +## 概述 + +本报告覆盖 `EPUBCore/CFI/` 目录下全部 13 个 Swift 文件的代码审查结果。共发现 **11 个问题**,按严重程度分为高/中/低三级。 + +| 严重程度 | 数量 | 说明 | +|----------|------|------| +| **高** | 3 | rawValue 短路、UTF-16 混用、content path 硬编码 | +| **中** | 5 | Resolver 假设不健壮、HTML 正则解析、text assertion 转义、token 匹配宽泛、end offset 边界 | +| **低** | 3 | 重复 nilIfEmpty、线性扫描、parent 静默失败 | + +--- + +## 高优先级 + +### 1. Serializer 的 rawValue 短路逻辑 — 数据一致性风险 + +**文件:** `RDEPUBCFISerializer.swift:5-9`、`33-36` + +```swift +public static func serialize(_ cfi: RDEPUBCFI) -> String { + if !cfi.rawValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return cfi.rawValue // 直接返回原始值,不检查组件是否变化 + } + // ... 从组件重新构建 +} +``` + +**问题:** `RDEPUBCFI` 同时存储了 `rawValue`(原始字符串)和解析后的组件(`packagePath`、`contentPath`、`characterOffset` 等)。如果有人修改了组件但没有清空 `rawValue`,序列化会返回**过期的旧值**。 + +`RDEPUBCFIGenerator` 中已经出现了这个 workaround — 先创建 `rawValue: ""` 的 CFI,序列化后再创建一个新 CFI 填入 `rawValue`。这说明开发者意识到了问题,但没有从根源修复。 + +**建议:** 要么移除 `rawValue` 缓存,每次都从组件重新构建;要么让 `rawValue` 成为 `computed property`,在 `packagePath`/`contentPath` 等变化时自动失效。 + +--- + +### 2. UTF-16 vs Character Offset 混用 + +**文件:** `RDEPUBCFIDOMPathBuilder.swift:435-458`(`RDEPUBNormalizedTextIndex.tokenSamples`)与 `RDEPUBCFIDOMPathBuilder.swift:405-428`(`RDEPUBNormalizedTextIndex.init`) + +**问题:** `RDEPUBNormalizedTextIndex` 内部同时维护了两种偏移体系,但没有统一: + +1. **`normalizedToChapterOffsets` 数组**(`init` 中构建):通过遍历 `nsSource.length`(UTF-16 code unit 数)逐个构建,每个 UTF-16 code unit 对应一个数组条目。因此数组下标是 **UTF-16 索引**。 + +2. **`tokenSamples()` 返回的 offset**:使用 `Array(normalizedText)` 生成 token,`Array` 按 Swift Character 拆分,返回的 offset 是 `characters` 数组的下标——即 **Character 索引**。 + +3. **`chapterOffset(forNormalizedOffset:)`**:用传入的 offset 直接查表 `normalizedToChapterOffsets[offset]`,期望接收 UTF-16 索引。 + +当 `normalizedText` 含多 code unit 字符时,`normalizedText.count`(Character 数)< `nsSource.length`(UTF-16 code unit 数),导致 `tokenSamples` 返回的 Character 索引在查 UTF-16 索引表时越界或错位。 + +此外,`calibratedOffset`(`RDEPUBCFIRecoveryEngine.swift:240-279`)全程使用 `NSString`/`NSRange`(UTF-16 偏移)进行搜索和打分,这与 `tokenSamples` 的 Character 偏移体系不一致。 + +**影响场景:** +- 含 emoji 的书籍(如 😀,UTF-16 surrogate pair 占 2 code unit,但 Character 计为 1) +- 含 CJK 扩展 B 区汉字的古籍 +- 含数学符号的教材(如 𝕏,占 2 code unit) + +**建议:** 统一偏移基准。推荐全部使用 UTF-16 偏移(与 NSRange/NSAttributedString 一致),将 `tokenSamples` 改为基于 UTF-16 索引生成 token,或在 `chapterOffset(forNormalizedOffset:)` 入口处显式转换。 + +--- + +### 3. Generator 的 content path 硬编码 + +**文件:** `RDEPUBCFIGenerator.swift:17-19` + +```swift +let contentPath = RDEPUBCFIPath(steps: [ + RDEPUBCFIStep(index: 4), // 的第 2 个元素子节点 + RDEPUBCFIStep(index: 2, idAssertion: fragmentID) // 第 1 个文本节点 +]) +``` + +**问题:** `makeOffsetCFI` 硬编码 content path 为 `/4/2`,即假设目标文本在 `` 的第 2 个元素子节点的第 1 个文本子节点中。以下情况会出错: + +| 场景 | 预期 content path | 实际生成 | +|------|-------------------|----------| +| 文本直接在 `` 下 | `/4/N`(N 为文本节点奇数索引) | `/4/2` ❌ | +| 文本在 3 层嵌套中 | `/4/2/2/2` | `/4/2` ❌ | +| `` 前有注释节点 | 索引需要偏移 | `/4/2` ❌ | + +`makeCFI` 方法允许传入自定义 `contentPath`,但 `makeOffsetCFI` 没有这个灵活性,而它是 `makeOffsetRangeCFI` 的内部依赖。 + +**建议:** `makeOffsetCFI` 应接受可选的 `contentPath` 参数,或从 `RDEPUBCFIMap` 中查找正确的路径。 + +--- + +## 中优先级 + +### 4. Resolver 的 CFI 结构假设不健壮 + +**文件:** `RDEPUBCFIResolver.swift:23-40` + +```swift +public static func resolve(_ cfi: RDEPUBCFI) -> RDEPUBCFIResolverResult { + let manifestStep = cfi.packagePath.steps.last(where: { $0.idAssertion?.isEmpty == false }) + let href = manifestStep?.idAssertion + + let fileIndex = cfi.packagePath.steps + .dropFirst() + .last + .map { max(($0.index / 2) - 1, 0) } + // ... +} +``` + +**问题 1 — manifest 步骤查找不可靠:** 用 `last(where:)` 搜索带 id assertion 的步骤。EPUB CFI 规范中 package path 结构固定:`/6`(package)→ `/2[n]`(spine)→ `/2n[manifest-id]`。应明确取第三个步骤,而非搜索。 + +**问题 2 — fileIndex 计算错误:** `dropFirst().last` 取的是**最后一个**步骤,不是第二个。如果 package path 有 3 个步骤(如 Fixed Layout spread),取到的是 manifest 步骤而非 spine 步骤。正确做法是 `steps.count >= 2 ? steps[1] : nil`。 + +**问题 3 — 可读性差:** `idAssertion?.isEmpty == false` 逻辑取反应,应该是 `idAssertion?.nilIfEmpty != nil`。 + +--- + +### 5. DOMPathBuilder 用正则解析 HTML — 脆弱且有边界情况 + +**文件:** `RDEPUBCFIDOMPathBuilder.swift:5-8` + +```swift +guard let regex = try? NSRegularExpression( + pattern: #"]*)>"#, + options: [.caseInsensitive] +) else { return [:] } +``` + +**边界情况清单:** + +| 输入 | 后果 | +|------|------| +| `` | 正则匹配注释内的 `
`,导致错误的子节点计数 | +| ` ]]>` | 同上 | +| `
` | 属性值中的 `>` 导致正则截断 | +| `` | 无值属性解析正常,但 `idAttribute` 正则要求引号 | +| `` | `< b` 可能被匹配为标签 | +| `
` vs `
` | `hasSuffix("/>")` 可能在属性值以 `/` 结尾时误判 | + +**建议:** 考虑使用 `DTCoreText` 或 `libxml2` 进行真正的 HTML 解析。如果必须用正则,至少先移除注释和 CDATA。 + +--- + +### 6. Text Assertion 解析不处理转义 + +**文件:** `RDEPUBCFIParser.swift:133-153` + +```swift +private static func parseTextAssertion(from body: String) -> RDEPUBCFITextAssertion? { + guard let open = body.firstIndex(of: "["), + let close = body.lastIndex(of: "]"), + open < close else { return nil } + let raw = String(body[body.index(after: open).. RDEPUBCFIMarker? { + markers.first { $0.cfiPath == path } +} +``` + +**问题:** 对于大章节(几百个文本节点),每次查找都做 O(n) 线性扫描。在恢复引擎中可能被多次调用,性能成为瓶颈。 + +**建议:** 在 `RDEPUBCFIMap` 初始化时构建 `[RDEPUBCFIPath: Int]` 索引字典(path → marker 数组下标),查找降为 O(1)。 + +--- + +### 11. Range 解析的 parent 静默失败 + +**文件:** `RDEPUBCFIParser.swift:51` + +```swift +return RDEPUBCFIRange( + rawValue: rawValue, + parent: try? parse(parentRaw), // 静默忽略解析失败 + start: try parse(startRaw), + end: try parse(endRaw) +) +``` + +**问题:** `parent` 用 `try?` 静默忽略解析失败,但 `start` 和 `end` 用 `try` 抛出。如果 parent 解析失败,`parent` 为 `nil`,后续 `canonicalRangeComponents` 会从 start/end 推导 parent,推导逻辑可能与原始 parent 不一致。 + +**建议:** 统一错误处理策略 — 要么全部抛出,要么全部容错。当前的混合策略会让调试困难。 + +--- + +## 附录:受影响文件清单 + +| 文件 | 涉及问题 | +|------|----------| +| `RDEPUBCFI.swift` | #1(rawValue 存储) | +| `RDEPUBCFIParser.swift` | #6(text assertion 转义)、#11(parent 静默失败)、#9(nilIfEmpty) | +| `RDEPUBCFISerializer.swift` | #1(rawValue 短路) | +| `RDEPUBCFIPath.swift` | #9(nilIfEmpty) | +| `RDEPUBCFIResolver.swift` | #4(结构假设) | +| `RDEPUBCFIGenerator.swift` | #3(content path 硬编码)、#8(end offset 边界) | +| `RDEPUBCFIMap.swift` | #10(线性扫描) | +| `RDEPUBCFIDOMPathBuilder.swift` | #2(UTF-16/Character 偏移混用)、#5(HTML 正则解析)、#9(nilIfEmpty) | +| `RDEPUBCFIRecoveryEngine.swift` | #2(calibratedOffset 使用 UTF-16 偏移)、#7(token 匹配) | +| `RDEPUBCFITextAssertion.swift` | #9(nilIfEmpty) | +| `RDEPUBCFICompatibility.swift` | 无直接问题 | +| `RDEPUBCFIError.swift` | 无直接问题 | + +--- + +## 修复优先级建议 + +``` +Phase 1(高风险,影响正确性) +├── #1 rawValue 短路 → 改为 computed property 或移除缓存 +├── #2 UTF-16/Character 偏移混用 → tokenSamples 用 Character 索引查 UTF-16 索引表,统一为 UTF-16 偏移 +└── #3 content path 硬编码 → 从 CFIMap 查找或接受参数 + +Phase 2(中风险,影响健壮性) +├── #4 Resolver 假设 → 明确取固定索引步骤 +├── #5 HTML 正则 → 先清理注释/CDATA,或换用 DOM 解析器 +├── #6 text assertion 转义 → 实现反斜杠转义处理 +├── #7 token 匹配 → 加最小长度阈值和长度比约束 +└── #8 end offset → 入口验证或支持反向 range + +Phase 3(低风险,代码质量) +├── #9 nilIfEmpty → 提取共享扩展 +├── #10 线性扫描 → 构建索引字典 +└── #11 parent 静默失败 → 统一错误处理 +``` diff --git a/Doc/CFI_SUBSYSTEM.md b/Doc/CFI_SUBSYSTEM.md new file mode 100644 index 0000000..cc7f43a --- /dev/null +++ b/Doc/CFI_SUBSYSTEM.md @@ -0,0 +1,597 @@ +# CFI 子系统文档 + +> 最后更新:2026-06-18 + +本文档详细描述 ReadViewSDK 中 EPUB CFI(Canonical Fragment Identifier)子系统的架构、数据模型、解析流程与容错机制。 + +--- + +## 1. CFI 规范简介 + +EPUB CFI 是 EPUB 3 规范定义的标准化片段标识符,用于精确定位 EPUB 内容中的任意位置。其语法形式为: + +``` +epubcfi(/6/4!ch01.xhtml/4/2/1:3) + ├──────┘ ├────────┘ ├──────┘ └─┘ + │ │ │ └─ 字符偏移(characterOffset) + │ │ └─ 内容路径(contentPath):定位 DOM 节点 + │ └─ 包路径(packagePath):定位 OPF manifest 中的资源 + └─ epubcfi() 包装器 +``` + +关键规则: +- **步进(step)**:以 `/` 分隔,偶数索引表示元素节点,奇数索引表示文本节点 +- **id 断言**:`[id]` 形式,如 `/4[ch01.xhtml]`,用于增强定位鲁棒性 +- **范围 CFI**:`epubcfi(/parent,/start,/end)` 三段逗号分隔,表示起止范围 +- **侧偏**:`;s=b`(before)或 `;s=a`(after),指示锚点偏向 +- **文本断言**:`[prefix,exact,suffix]`,用于断言定位处的文本内容 + +--- + +## 2. 核心数据模型 + +### 2.1 RDEPUBCFI + +顶层 CFI 模型,对应一个完整的 `epubcfi(...)` 字符串。 + +**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFI.swift` + +```swift +public struct RDEPUBCFI: Codable, Equatable, Hashable { + public var rawValue: String // 原始 CFI 字符串 + public var packagePath: RDEPUBCFIPath // 包路径(定位 XHTML 文件) + public var contentPath: RDEPUBCFIPath // 内容路径(定位 DOM 节点) + public var characterOffset: Int? // 文本节点内的字符偏移 + public var sideBias: RDEPUBCFISideBias? // 侧偏方向 + public var textAssertion: RDEPUBCFITextAssertion? // 文本断言 +} +``` + +**侧偏枚举**: + +```swift +public enum RDEPUBCFISideBias: String, Codable { + case before = "b" // 锚点偏向起始侧 + case after = "a" // 锚点偏向结束侧 +} +``` + +**示例**:解析 `epubcfi(/6/4[ch01.xhtml]!/4/2/1:100;s=b)` 后: +- `packagePath.steps` = `[Step(index: 6), Step(index: 4, idAssertion: "ch01.xhtml")]` +- `contentPath.steps` = `[Step(index: 4), Step(index: 2), Step(index: 1)]` +- `characterOffset` = 100 +- `sideBias` = `.before` + +### 2.2 RDEPUBCFIPath + +路径模型,由一组 `RDEPUBCFIStep` 组成,描述从根节点到目标节点的遍历序列。 + +**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIPath.swift` + +```swift +public struct RDEPUBCFIPath: Codable, Equatable, Hashable { + public var steps: [RDEPUBCFIStep] + + // 计算两条路径的公共前缀 + public func commonPrefix(with other: RDEPUBCFIPath) -> RDEPUBCFIPath + + // 去除指定前缀后的剩余路径 + public func droppingPrefix(_ prefix: RDEPUBCFIPath) -> RDEPUBCFIPath +} +``` + +**RDEPUBCFIStep**: + +```swift +public struct RDEPUBCFIStep: Codable, Equatable, Hashable { + public var index: Int // 节点索引(偶数=元素,奇数=文本) + public var idAssertion: String? // 可选的 id 断言,如 "ch01.xhtml" +} +``` + +`commonPrefix` 方法在范围 CFI 序列化时用于提取起止点的公共父路径;`droppingPrefix` 用于生成相对于父级的路径片段。 + +### 2.3 RDEPUBCFIRange + +范围模型,表示文档中的一个连续区域,由父级 CFI 和起止 CFI 组成。 + +**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIRange.swift` + +```swift +public struct RDEPUBCFIRange: Codable, Equatable, Hashable { + public var rawValue: String // 原始范围 CFI 字符串 + public var parent: RDEPUBCFI? // 公共父级(起止共享的前缀路径) + public var start: RDEPUBCFI // 起始位置 + public var end: RDEPUBCFI // 结束位置 +} +``` + +序列化时输出格式:`epubcfi(/parent_path,/start_terminal,/end_terminal)`,其中起止路径相对于父级路径输出。 + +--- + +## 3. 解析与序列化 + +### 3.1 RDEPUBCFIParser + +将 CFI 字符串解析为结构化模型。 + +**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIParser.swift` + +```swift +public enum RDEPUBCFIParser { + // 解析单点 CFI + public static func parse(_ rawValue: String?) throws -> RDEPUBCFI + + // 解析范围 CFI + public static func parseRange(_ rawValue: String?) throws -> RDEPUBCFIRange +} +``` + +**解析流程**(`parse` 方法): + +1. **去包装**:剥离 `epubcfi(...)` 外层,提取 body +2. **拆分包路径与内容路径**:以第一个 `!` 为分隔符 +3. **解析路径**:按 `/` 分割为 step,每个 step 可含 `[idAssertion]` +4. **解析偏移与限定符**:从内容路径中提取 `:offset`、`;s=b/a`、`[textAssertion]` + +**关键细节**: +- `firstIndexOutsideBrackets` 方法确保 `:` 分隔符在方括号外才被识别,避免与文本断言中的内容混淆 +- `parseRange` 要求恰好 3 个逗号分隔部分(parent, start, end),否则抛出 `unsupportedRange` 错误 + +**使用示例**: + +```swift +let cfi = try RDEPUBCFIParser.parse( + "epubcfi(/6/4[ch01.xhtml]!/4/2/1:50;s=a[前缀,目标文本,后缀])" +) +print(cfi.characterOffset) // Optional(50) +print(cfi.sideBias) // Optional(.after) +print(cfi.textAssertion?.exact) // Optional("目标文本") +``` + +### 3.2 RDEPUBCFISerializer + +将 CFI 模型序列化为标准字符串。 + +**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFISerializer.swift` + +```swift +public enum RDEPUBCFISerializer { + // 序列化单点 CFI + public static func serialize(_ cfi: RDEPUBCFI) -> String + + // 序列化范围 CFI + public static func serializeRange(_ range: RDEPUBCFIRange) -> String + + // 序列化路径为 step 字符串 + public static func serializePath(_ path: RDEPUBCFIPath) -> String +} +``` + +**序列化逻辑**: +- 若 `rawValue` 非空,直接返回(避免重复序列化) +- 范围序列化先通过 `canonicalRangeComponents` 提取或推导公共父级,再分别序列化起止终端路径(相对于父级) +- 终端路径使用 `droppingPrefix` 去除与父级共享的部分 + +**示例**: + +```swift +let cfi = RDEPUBCFIGenerator.makeOffsetCFI( + href: "chapter1.xhtml", fileIndex: 0, chapterOffset: 120 +) +let serialized = RDEPUBCFISerializer.serialize(cfi) +// => "epubcfi(/6/2[chapter1.xhtml]!/4/2:120)" +``` + +--- + +## 4. DOM 解析与生成 + +### 4.1 RDEPUBCFIResolver + +将 CFI 解析为可直接用于资源定位的结果。 + +**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIResolver.swift` + +```swift +public struct RDEPUBCFIResolverResult: Equatable { + public var href: String? // XHTML 文件路径(来自 idAssertion) + public var fileIndex: Int? // 文件索引 + public var chapterOffset: Int? // 章节内字符偏移 + public var fragmentID: String? // 片段 ID(如 #section1) +} + +public enum RDEPUBCFIResolver { + public static func resolve(_ cfi: RDEPUBCFI) -> RDEPUBCFIResolverResult +} +``` + +**解析规则**: +- `href`:从 `packagePath` 中最后一个含 `idAssertion` 的 step 提取 +- `fileIndex`:`packagePath` 中倒数第二个 step 的 `(index / 2) - 1` +- `fragmentID`:从 `contentPath` 中最后一个含 `idAssertion` 的 step 提取 +- `chapterOffset`:直接取 `cfi.characterOffset` + +**示例**: + +```swift +let cfi = try RDEPUBCFIParser.parse("epubcfi(/6/4[ch01.xhtml]!/4/2[chap1]:80)") +let result = RDEPUBCFIResolver.resolve(cfi) +print(result.href) // Optional("ch01.xhtml") +print(result.fileIndex) // Optional(0) +print(result.fragmentID) // Optional("chap1") +print(result.chapterOffset) // Optional(80) +``` + +### 4.2 RDEPUBCFIGenerator + +从已知的章节信息生成 CFI。 + +**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIGenerator.swift` + +```swift +public enum RDEPUBCFIGenerator { + // 根据偏移量生成单点 CFI + public static func makeOffsetCFI( + href: String, + fileIndex: Int, + chapterOffset: Int, + fragmentID: String? = nil, + sideBias: RDEPUBCFISideBias? = nil, + textAssertion: RDEPUBCFITextAssertion? = nil + ) -> RDEPUBCFI + + // 根据自定义内容路径生成 CFI + public static func makeCFI( + href: String, + fileIndex: Int, + contentPath: RDEPUBCFIPath, + characterOffset: Int, + sideBias: RDEPUBCFISideBias? = nil, + textAssertion: RDEPUBCFITextAssertion? = nil + ) -> RDEPUBCFI + + // 生成范围 CFI(起止偏移量) + public static func makeOffsetRangeCFI( + href: String, + fileIndex: Int, + startOffset: Int, + endOffset: Int, + fragmentID: String? = nil, + startTextAssertion: RDEPUBCFITextAssertion? = nil, + endTextAssertion: RDEPUBCFITextAssertion? = nil + ) -> RDEPUBCFIRange +} +``` + +**路径构造规则**: +- `packagePath` 固定为 `[Step(index: 6), Step(index: (fileIndex+1)*2, idAssertion: href)]` +- `contentPath` 默认为 `[Step(index: 4), Step(index: 2, idAssertion: fragmentID)]` +- 范围 CFI 的起始点自动附加 `sideBias: .before`,结束点附加 `sideBias: .after` + +### 4.3 RDEPUBCFIDOMPathBuilder + +从 HTML 源码中提取带有 `id` 属性的元素路径映射。 + +**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIDOMPathBuilder.swift` + +```swift +public enum RDEPUBCFIDOMPathBuilder { + // 从 HTML 中提取 fragmentID -> CFIPath 映射 + public static func fragmentPaths(in html: String) -> [String: RDEPUBCFIPath] +} +``` + +**实现细节**: +- 使用正则表达式匹配 HTML 标签,维护一个模拟 DOM 栈 +- 每遇到开标签,计算子节点索引(`childIndex * 2`),生成 `RDEPUBCFIStep` +- 从标签属性中提取 `id` 或 `xml:id` 作为 `idAssertion` +- 识别并跳过 void 元素(`br`、`img`、`input` 等)和可忽略标签(`!doctype`) +- 闭标签时弹出栈顶,重置该深度的子节点计数 + +--- + +## 5. 容错恢复引擎(RDEPUBCFIRecoveryEngine) + +当 CFI 精确定位失败时(如 DOM 结构变更),恢复引擎通过多级降级策略尝试找到最佳匹配位置。 + +**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIRecoveryEngine.swift` + +### 5.1 恢复置信度 + +```swift +public struct RDEPUBCFIRecoveryResult: Equatable { + public enum Confidence: Int, Codable { + case exactPath // 精确路径匹配 + case assertionCalibrated // 路径匹配 + 文本断言校准 + case siblingRecovered // 兄弟节点恢复 + case tokenRecovered // 词元索引恢复 + case fragmentFallback // 片段 ID 降级 + case offsetFallback // 偏移量兜底 + } + public var chapterOffset: Int + public var confidence: Confidence +} +``` + +### 5.2 恢复策略(按优先级) + +| 优先级 | 策略 | 说明 | +|--------|------|------| +| 1 | `exactPath` | 在 CFIMap 中查找精确匹配的 marker 或 pathRange | +| 2 | `assertionCalibrated` | 精确路径匹配后,使用文本断言在窗口内校准偏移 | +| 3 | `siblingRecovered` | 查找同父级的兄弟节点,通过 `siblingScore` 选择最近匹配 | +| 4 | `tokenRecovered` | 利用词元索引(token index)在章节文本中搜索匹配 | +| 5 | `fragmentFallback` | 回退到 fragment ID 对应的已知偏移量 | +| 6 | `offsetFallback` | 使用兜底偏移量 | + +### 5.3 核心算法 + +**校准机制**(`calibratedOffset`): +- 在给定偏移量附近 ±512 字符窗口内搜索 `textAssertion.exact` 文本 +- 对每个候选位置计算 `assertionScore`:距离越近分数越低,prefix/suffix 匹配则大幅加分(不匹配 +10000) +- 选取得分最低的候选位置 + +**兄弟恢复**(`siblingRecovered`): +- 查找与目标路径同父级、同深度的已知 marker +- 通过 `siblingSignature`(`parent_path#childIndex`)和索引距离计算相似度分数 + +**词元恢复**(`tokenRecovered`): +- 在预构建的 token index 中查找包含 `textAssertion.exact` 的词元 +- 对每个候选锚点执行校准,选择校准距离最小的结果 + +**调用入口**: + +```swift +let result = RDEPUBCFIRecoveryEngine.recover( + cfi: someCFI, + cfiMap: chapterCFIMap, + chapterText: "章节纯文本...", + fragmentOffsets: ["section1": 150, "section2": 800], + fallbackOffset: 0, + lastOffset: 5000 +) +// result?.confidence 反映恢复质量 +``` + +--- + +## 6. 文本断言验证(RDEPUBCFITextAssertion) + +文本断言用于在 CFI 定位后验证所指位置的文本内容是否符合预期,增强定位鲁棒性。 + +**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFITextAssertion.swift` + +```swift +public struct RDEPUBCFITextAssertion: Codable, Equatable, Hashable { + public var prefix: String? // 目标文本之前的上下文 + public var exact: String? // 精确匹配的目标文本 + public var suffix: String? // 目标文本之后的上下文 +} +``` + +**在 CFI 中的表示**:`[prefix,exact,suffix]`,位于偏移量和侧偏之后。 + +**示例**: + +```swift +// CFI: epubcfi(/6/4[ch01.xhtml]!/4/2/1:100;s=a[这是一段,目标文本,后续内容]) +let assertion = cfi.textAssertion +print(assertion?.prefix) // Optional("这是一段") +print(assertion?.exact) // Optional("目标文本") +print(assertion?.suffix) // Optional("后续内容") +``` + +**容错引擎中的应用**: +- `exact` 用于在窗口内搜索实际文本位置 +- `prefix` 和 `suffix` 用于对候选位置评分,优先选择上下文都匹配的位置 + +--- + +## 7. 兼容性处理(RDEPUBCFICompatibility) + +提供宽松解析接口,兼容非标准 CFI 格式。 + +**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFICompatibility.swift` + +```swift +public enum RDEPUBCFICompatibility { + // 宽松解析单点 CFI(解析失败返回 nil 而非抛异常) + public static func parseLossy(_ rawValue: String?) -> RDEPUBCFI? + + // 宽松解析范围 CFI + public static func parseRangeLossy(_ rawValue: String?) -> RDEPUBCFIRange? +} +``` + +**范围 CFI 兼容策略**: +- 优先使用标准 `parseRange`(逗号三分段格式) +- 若失败,尝试以 `..` 或 `-` 作为分隔符拆分为两个独立 CFI 分别解析 +- 支持 `epubcfi(...)-epubcfi(...)` 或 `epubcfi(...)..epubcfi(...)` 格式 + +**使用场景**:处理第三方生成器或旧版本导出的非标准范围标记。 + +--- + +## 8. CFI 映射(RDEPUBCFIMap) + +CFI 映射是章节级别的索引结构,将 CFI 路径映射到章节文本偏移量,是容错恢复引擎的核心数据源。 + +**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIMap.swift` + +### 8.1 RDEPUBCFIMap + +```swift +public struct RDEPUBCFIMap: Codable, Equatable { + public var href: String // 章节文件路径 + public var renderVersion: Int // 渲染版本号 + public var domVersion: Int // DOM 版本号 + public var markers: [RDEPUBCFIMarker] // CFI 路径到偏移量的标记列表 + public var textAssertions: [String: RDEPUBCFITextAssertion] // 文本断言缓存 + public var pathRanges: [RDEPUBCFIPathRange] // 路径范围列表 + public var recoveryMetadata: RDEPUBCFIRecoveryMetadata // 恢复元数据 + + // 精确匹配 marker + public func marker(matching path: RDEPUBCFIPath) -> RDEPUBCFIMarker? +} +``` + +### 8.2 RDEPUBCFIMarker + +每个 marker 记录一个 CFI 路径对应的章节信息: + +```swift +public struct RDEPUBCFIMarker: Codable, Equatable { + public var cfiPath: RDEPUBCFIPath // CFI 路径 + public var chapterOffset: Int? // 章节内字符偏移 + public var fragmentID: String? // 片段 ID + public var textNodeLength: Int? // 文本节点长度 + public var textNodeChecksum: UInt64? // 文本节点校验和(FNV-1a 64) + public var normalizedTextPreview: String? // 归一化文本预览(前 24 字符) + public var domSiblingSignature: String? // DOM 兄弟签名 +} +``` + +### 8.3 RDEPUBCFIPathRange + +描述一个 CFI 路径对应的文本偏移范围: + +```swift +public struct RDEPUBCFIPathRange: Codable, Equatable { + public var cfiPath: RDEPUBCFIPath // CFI 路径 + public var startOffset: Int // 范围起始偏移 + public var endOffset: Int // 范围结束偏移 + public var textNodeLength: Int // 文本节点长度 +} +``` + +### 8.4 RDEPUBCFIRecoveryMetadata + +恢复元数据,为容错引擎提供多维度恢复依据: + +```swift +public struct RDEPUBCFIRecoveryMetadata: Codable, Equatable { + public var domFingerprint: String // DOM 指纹(SHA-256) + public var normalizedTextChecksum: String // 归一化文本校验和 + public var tokenIndex: [RDEPUBCFITokenAnchor] // 词元锚点索引 + public var fragmentPathMap: [String: RDEPUBCFIPath] // fragment -> 路径映射 +} +``` + +### 8.5 RDEPUBCFITokenAnchor + +词元锚点,用于基于内容的恢复: + +```swift +public struct RDEPUBCFITokenAnchor: Codable, Equatable { + public var token: String // 采样词元(12 字符窗口,96 步长,最多 64 个) + public var occurrence: Int // 该词元的出现次数 + public var chapterOffset: Int // 对应的章节偏移 + public var cfiPath: RDEPUBCFIPath // 对应的 CFI 路径 +} +``` + +### 8.6 映射构建流程 + +`RDEPUBCFITextNodeMapBuilder.makeMap` 方法负责构建完整映射: + +1. **归一化文本**:解码 HTML 实体、合并连续空白、统一空白字符 +2. **提取 fragment 路径**:通过 `RDEPUBCFIDOMPathBuilder.fragmentPaths` 获取 id -> path 映射 +3. **构建 markers**:逐标签遍历 HTML,维护 DOM 栈,为每个文本节点创建 marker +4. **构建 pathRanges**:将 markers 转换为偏移范围列表 +5. **构建恢复元数据**:生成 DOM 指纹、文本校验和、词元锚点索引 + +--- + +## 9. 错误类型(RDEPUBCFIError) + +**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIError.swift` + +```swift +public enum RDEPUBCFIError: Error, Equatable { + case empty // 输入为空或空白 + case invalidWrapper(String) // 缺少 epubcfi() 包装 + case invalidPath(String) // 路径格式无效(不以 / 开头) + case unsupportedRange(String) // 范围格式不正确(非三段逗号分隔) +} +``` + +| 错误 | 触发条件 | +|------|----------| +| `empty` | 输入为 `nil`、空字符串或纯空白 | +| `invalidWrapper` | 未以 `epubcfi(` 开头或未以 `)` 结尾 | +| `invalidPath` | 路径部分非空且不以 `/` 开头 | +| `unsupportedRange` | 范围 CFI 的逗号分隔部分不等于 3 | + +--- + +## 10. 使用场景与调用链 + +### 10.1 保存阅读位置 + +``` +用户翻页 + → RDEPUBCFIGenerator.makeOffsetCFI(href, fileIndex, chapterOffset) + → RDEPUBCFISerializer.serialize(cfi) + → 存储 epubcfi(...) 字符串 +``` + +### 10.2 恢复阅读位置 + +``` +加载 epubcfi(...) 字符串 + → RDEPUBCFIParser.parse(rawValue) + → RDEPUBCFIResolver.resolve(cfi) + → 获取 href, fileIndex, chapterOffset + → 若偏移量无效,进入恢复引擎 + → RDEPUBCFIRecoveryEngine.recover(cfi, cfiMap, chapterText, ...) + → 依次尝试 exactPath → sibling → token → fragment → offset +``` + +### 10.3 高亮选中文本 + +``` +用户选择文本范围 + → 获取 start/end DOM 位置 + → RDEPUBCFIGenerator.makeOffsetRangeCFI(href, fileIndex, startOffset, endOffset) + → RDEPUBCFISerializer.serializeRange(range) + → 存储范围 CFI +``` + +### 10.4 章节加载时构建索引 + +``` +章节 HTML 加载完成 + → RDEPUBCFITextNodeMapBuilder.makeMap(href, rawHTML, chapterText, fragmentOffsets) + → 生成 RDEPUBCFIMap(markers + pathRanges + recoveryMetadata) + → 缓存供后续恢复使用 +``` + +### 10.5 兼容性解析 + +``` +外部导入非标准 CFI + → RDEPUBCFICompatibility.parseLossy(rawValue) // 宽松解析 + → RDEPUBCFICompatibility.parseRangeLossy(rawValue) // 支持 .. / - 分隔符 +``` + +--- + +## 文件清单 + +| 文件 | 职责 | +|------|------| +| `RDEPUBCFI.swift` | 顶层 CFI 模型与侧偏枚举 | +| `RDEPUBCFIPath.swift` | 路径模型与 step 定义 | +| `RDEPUBCFIRange.swift` | 范围模型 | +| `RDEPUBCFIParser.swift` | 字符串 → 模型解析 | +| `RDEPUBCFISerializer.swift` | 模型 → 字符串序列化 | +| `RDEPUBCFIResolver.swift` | CFI → 资源定位结果 | +| `RDEPUBCFIGenerator.swift` | 章节信息 → CFI 生成 | +| `RDEPUBCFIDOMPathBuilder.swift` | HTML → fragment 路径映射 + 文本节点映射构建 | +| `RDEPUBCFIRecoveryEngine.swift` | 多级容错恢复引擎 | +| `RDEPUBCFITextAssertion.swift` | 文本断言模型 | +| `RDEPUBCFICompatibility.swift` | 非标准格式兼容解析 | +| `RDEPUBCFIMap.swift` | CFI 映射、marker、恢复元数据 | +| `RDEPUBCFIError.swift` | 错误类型定义 | diff --git a/Doc/CHAPTER_RUNTIME.md b/Doc/CHAPTER_RUNTIME.md new file mode 100644 index 0000000..2b216f0 --- /dev/null +++ b/Doc/CHAPTER_RUNTIME.md @@ -0,0 +1,425 @@ +# 章节运行时详解 + +> 最后更新:2026-06-18 + +本文档详细描述 ReadViewSDK 章节运行时子系统的架构、缓存策略、加载流程和优化机制。 + +--- + +## 1. 概述 + +章节运行时是 EPUBUI 层的核心子系统,负责章节的按需加载、缓存管理和页码映射。它位于 `RDEPUBReaderContext` → `RDEPUBReaderRuntime` 架构中,是实现大书(如 1000+ 章的网络小说)流畅阅读的关键。 + +**核心设计目标**: +- 快速打开:用户点击书籍后 1-2 秒内可开始阅读 +- 按需加载:只加载当前窗口内的章节,内存占用可控 +- 渐进补全:后台逐步补全所有章节的页码信息 + +**关键文件**: +- `Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/` 目录 +- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift` + +--- + +## 2. 三级缓存架构 + +``` +┌─────────────────────────────────────────────────────────┐ +│ Tier 1: 内存缓存 (RDEPUBChapterRuntimeStore) │ +│ ├─ chapterDataCache: [Int: RDEPUBRuntimeChapter] │ +│ ├─ pageCountCache: [CacheKey: RDEPUBRuntimePageCount] │ +│ ├─ imageCache: NSCache (50 上限) │ +│ └─ 窗口驱逐:只保留当前章节 ± windowRadius 的章节 │ +└──────────────────────────┬──────────────────────────────┘ + │ miss +┌──────────────────────────▼──────────────────────────────┐ +│ Tier 2: 磁盘摘要缓存 (RDEPUBChapterSummaryDiskCache) │ +│ ├─ 路径: ~/Caches/RDEPUBChapterSummaryCache/{bookID}/ │ +│ ├─ 文件名: SHA256(bookID_spineIdx_renderSig_contentHash)│ +│ ├─ 格式: JSON (RDEPUBChapterSummary) │ +│ ├─ 写入: 异步(serial DispatchQueue) │ +│ └─ 读取: 同步(readAll 批量读取) │ +└──────────────────────────┬──────────────────────────────┘ + │ miss +┌──────────────────────────▼──────────────────────────────┐ +│ Tier 3: 全书分页缓存 (RDEPUBTextBookCache) │ +│ ├─ 路径: ~/Caches/RDEPUBTextBookCache/ │ +│ ├─ 格式: NSSecureCoding archive │ +│ ├─ 内容: 每章的 pageRanges + breakReasons + semanticHints│ +│ └─ 失效: schemaVersion 变更时全部失效 │ +└─────────────────────────────────────────────────────────┘ +``` + +### 2.1 Tier 1: RDEPUBChapterRuntimeStore + +**文件**:`RDEPUBChapterRuntimeStore.swift` + +内存缓存,存储当前窗口内的章节数据。 + +```swift +final class RDEPUBChapterRuntimeStore { + // 章节数据缓存(完整 RuntimeChapter) + private let chapterDataCache = RDEPUBChapterDataCache() + + // 页数缓存(仅页范围,不含 NSAttributedString) + private let pageCountCache = RDEPUBPageCountCache() + + // 图片缓存(NSCache,上限 50 张) + let imageCache = NSCache() + + // 章节加载队列(串行,userInitiated QoS) + let chapterLoadQueue = DispatchQueue(label: "com.rdreader.chapterload", qos: .userInitiated) +} +``` + +**窗口驱逐策略**: + +```swift +func setCurrentChapter(spineIndex: Int, totalSpineCount: Int, windowRadius: Int = 1) { + currentSpineIndex = spineIndex + let lowerBound = max(0, spineIndex - radius) + let upperBound = min(totalSpineCount - 1, spineIndex + radius) + windowSpineIndices = Array(lowerBound...upperBound) +} +``` + +- 保留范围:`[spineIndex - windowRadius, spineIndex + windowRadius]` +- `evictableSpineIndices()` 返回窗口外的已缓存章节索引 +- 章节切换时调用 `setCurrentChapter` 更新窗口,然后驱逐窗口外章节 + +**内存警告处理**: + +```swift +func handleMemoryWarning() { + evictAllExceptCurrent() // 驱逐除当前章节外的所有缓存 + imageCache.removeAllObjects() // 清空图片缓存 +} +``` + +**导航优先级抢占**: + +当用户快速翻页时,新的导航请求可以抢占正在构建的章节: + +```swift +func setNavigationTarget(spineIndex: Int) // 设置导航目标 +func consumeNavigationTarget() -> Int? // 消费导航目标(构建完成后检查) +``` + +`RDEPUBChapterLoader` 在完成当前章节构建后,会检查是否有新的导航目标,如有则立即切换到新目标。 + +### 2.2 Tier 2: RDEPUBChapterSummaryDiskCache + +**文件**:`RDEPUBChapterSummaryDiskCache.swift` + +磁盘摘要缓存,存储章节的分页元数据(不含完整 NSAttributedString)。 + +**存储路径**:`~/Library/Caches/RDEPUBChapterSummaryCache/{bookID}/` + +**文件命名**:`SHA256(bookID_spineIndex_renderSignature_contentHash).json` + +**缓存内容**(RDEPUBChapterSummary): +- `pageRanges: [NSRange]` — 每页的文本范围 +- `pageCount: Int` — 总页数 +- `fragmentOffsets: [String: Int]` — fragment ID 到偏移量的映射 +- `cfiMap: RDEPUBCFIMap?` — CFI 映射 +- `renderSignature: String` — 渲染参数签名 +- `chapterContentHash: String` — 章节 HTML 的 SHA-256 +- `pageMetadataList: [PageMetadataSummary]` — 每页的语义元数据 + +**写入策略**: +- 异步写入(serial DispatchQueue) +- 原子写入(先写临时文件,再 rename) + +**批量读取**: + +```swift +func readAll(keys: [RDEPUBChapterCacheKey]) -> ( + summaries: [Int: RDEPUBChapterSummary], + partialBuilder: RDEPUBBookPageMap.Builder +) +``` + +一次读取所有章节的摘要,同时构建 `BookPageMap.Builder`,避免重复遍历。 + +### 2.3 Tier 3: RDEPUBTextBookCache + +全书分页缓存,使用 `NSSecureCoding` 归档。包含每章的完整分页信息(pageRanges、breakReasons、semanticHints)。当 `schemaVersion` 变更时全部失效。 + +--- + +## 3. RDEPUBChapterCacheKey — 缓存键设计 + +**文件**:`RDEPUBChapterCacheKey.swift` + +```swift +struct RDEPUBChapterCacheKey: Hashable { + let bookID: String // 书籍唯一标识 + let spineIndex: Int // 章节索引 + let renderSignature: String // 渲染参数签名 + let chapterContentHash: String // 章节 HTML 的 SHA-256 +} +``` + +**renderSignature 组成**: + +```swift +let renderSignature = [ + style.font.fontName, // 字体名称 + "\(style.font.pointSize)", // 字号 + "\(lineHeightMultiple)", // 行距倍数 + "\(style.lineSpacing)", // 行间距 + layoutConfig.cacheSignature, // 布局参数签名 + "\(schemaVersion)" // 缓存 schema 版本 +].joined(separator: "|") +``` + +**缓存失效语义**: +- 换字体/字号 → renderSignature 变化 → 缓存失效 +- EPUB 内容更新 → contentHash 变化 → 缓存失效 +- 不同书籍 → bookID 不同 → 互不干扰 +- schemaVersion 变更 → 全部失效 + +--- + +## 4. RDEPUBChapterLoader — 章节加载器 + +**文件**:`RDEPUBChapterLoader.swift` + +### 4.1 加载流程 + +```swift +func loadChapter( + spineIndex: Int, + store: RDEPUBChapterRuntimeStore, + priority: LoadPriority = .navigation, + completion: @escaping (Result) -> Void +) +``` + +**三级缓存串联**: + +1. **Tier 1 命中**:`store.chapterData(for: spineIndex)` → 直接返回 +2. **Tier 1 页数缓存命中**:`store.pageCount(for: cacheKey)` → 轻量路径(跳过分页计算) +3. **Tier 2 命中**:`summaryDiskCache?.read(for: cacheKey)` → 轻量路径 +4. **全部未命中**:完整路径(渲染 + 分页 + 写缓存) + +### 4.2 轻量路径 vs 完整路径 + +**轻量路径**(有缓存页范围时): +1. 读取 HTML 并渲染为 NSAttributedString +2. 使用缓存的 pageRanges 直接构建页面 +3. 跳过分页计算(最耗时的步骤) + +**完整路径**(无缓存时): +1. 使用 `RDEPUBTextBookBuilder.buildChapter()` 完整构建 +2. 包含 HTML→NSAttributedString→分页→尾页规范化全流程 +3. 构建完成后写入磁盘摘要缓存 + +### 4.3 加载优先级 + +```swift +enum LoadPriority { + case navigation // 用户导航触发(最高优先级,可抢占) + case preview // 预览触发 + case prefetch // 预取触发(最低优先级) +} +``` + +**导航优先级抢占**:当 `priority == .navigation` 时,完成构建后检查 `consumeNavigationTarget()`,如有新目标则立即切换。 + +### 4.4 同步加载 + +```swift +func loadChapterSynchronouslyForMigration( + spineIndex: Int, + store: RDEPUBChapterRuntimeStore? +) throws -> RDEPUBRuntimeChapter +``` + +使用信号量阻塞当前线程,等待章节加载完成。用于快速打开路径中加载首个可渲染章节。 + +--- + +## 5. RDEPUBBookPageMap — 轻量页码映射 + +**文件**:`RDEPUBBookPageMap.swift` + +轻量级全书页码映射,不持有 NSAttributedString,内存占用约 100KB/1000 章。 + +### 5.1 数据结构 + +```swift +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] + let totalPages: Int + var totalChapters: Int { entries.count } +} +``` + +### 5.2 页码查询 + +```swift +// 绝对页码 → 章节索引(二分查找,O(log n)) +func spineIndex(forAbsolutePage absolutePage: Int) -> Int? + +// 绝对页码 → 章节内本地页码 +func localPageIndex(forAbsolutePage absolutePage: Int) -> Int? + +// (spineIndex, localPageIndex) → 绝对页码 +func absolutePageIndex(spineIndex: Int, localPageIndex: Int) -> Int? +``` + +### 5.3 Builder 增量构建 + +```swift +struct Builder { + mutating func add( + spineIndex: Int, + href: String, + title: String, + pageCount: Int, + fragmentOffsets: [String: Int] + ) + + func build() -> RDEPUBBookPageMap +} +``` + +Builder 模式支持增量添加章节信息,`build()` 时自动按 spineIndex 排序并计算绝对页码。 + +--- + +## 6. RDEPUBPageResolver — 页码解析器 + +**文件**:`RDEPUBPageResolver.swift` + +将绝对页码解析为章节和本地页码的组合。 + +```swift +struct RDEPUBResolvedPage { + let spineIndex: Int + let localPageIndex: Int + let page: RDEPUBTextPage? +} + +func resolvePage(absolutePageIndex: Int) -> RDEPUBResolvedPage? +``` + +解析流程: +1. 从 `BookPageMap` 查找 spineIndex 和 localPageIndex +2. 从 `ChapterRuntimeStore` 获取已加载的章节数据 +3. 如果章节未加载,触发按需加载 + +--- + +## 7. 后台元数据解析优化 + +**文件**:`RDEPUBReaderPaginationCoordinator.swift` + +### 7.1 整体流程 + +``` +paginateMetadataOnly(token) + │ + ├─ 预计算所有章节 contentHash(串行) + ├─ readAll(keys:) 批量读取磁盘缓存 + ├─ refreshBookPageMapInPlace(缓存部分) + ├─ waitForReadingInteractionToSettle(0.8s 冷却) + │ + ├─ OperationQueue (并发 N=CPU核心数): + │ ├─ buildChapter(spineIndex) // 渲染+分页 + │ ├─ chapterCacheKey(spineIndex) // 复用预计算 hash + │ ├─ summary.write() // 异步写盘 + │ └─ 每 32 章刷新 BookPageMap + │ + └─ 最终 refreshBookPageMapInPlace +``` + +### 7.2 预计算 contentHash + +**问题**:每个章节在构建缓存键时需要读取 HTML 并计算 SHA-256,重复 I/O 开销大。 + +**优化**:在后台解析开始时,串行预计算所有章节的 contentHash: + +```swift +var contentHashBySpineIndex: [Int: String] = [:] +for spineIndex in allBuildableIndices { + let html = parser.htmlString(forRelativePath: href) + contentHashBySpineIndex[spineIndex] = html?.sha256Hex ?? "" +} +``` + +后续所有 `chapterCacheKey` 调用都使用预计算值。 + +### 7.3 冻结 renderSignature + +**问题**:用户在后台解析进行中更改字号/行距,会导致部分章节用旧签名、部分用新签名写入缓存。 + +**解决**:在 `paginateMetadataOnly` 开始时冻结签名: + +```swift +let renderSignature = context.currentRenderSignature() +// 后续所有 chapterCacheKey 调用使用此固定值 +``` + +token 机制确保设置变更会触发新的解析任务(新 token),旧任务自动废弃。 + +### 7.4 锁区瘦身 + +**原始实现**:`resultLock` 内调用 `buildPageMap()`,遍历全量 catalog 和 summaries。 + +**优化**:锁内只做写入和计数,快照数据后锁外构建 pageMap: + +```swift +var snapshot: [Int: RDEPUBChapterSummary]? +resultLock.lock() +summariesBySpineIndex[spineIndex] = renderResult +totalResolvedCount += 1 +if shouldRefresh { + snapshot = summariesBySpineIndex // 快照 +} +resultLock.unlock() + +if let snapshot { + let partialMap = buildPageMap(from: catalog, summaries: snapshot) // 锁外 +} +``` + +### 7.5 可配置刷新间隔 + +```swift +static var pageMapRefreshInterval: Int = 32 // 默认 32 章 +``` + +可实测调优:32 / 48 / 64。值越大,UI 刷新频率越低,后台解析吞吐越高。 + +### 7.6 用户交互冷却 + +等待用户操作冷却 0.8 秒后再开始后台解析,避免与用户翻页操作竞争资源: + +```swift +try await Task.sleep(nanoseconds: 800_000_000) +``` + +--- + +## 8. 关键配置参数 + +| 参数 | 位置 | 默认值 | 说明 | +|------|------|--------|------| +| `onDemandChapterWindowSize` | `RDEPUBReaderConfiguration` | `3` | 按需加载窗口大小(奇数,3-15) | +| `chapterWindowRadius` | 计算属性 | `1` | 内存缓存窗口半径(windowSize / 2) | +| `metadataParsingConcurrency` | `RDEPUBReaderConfiguration` | CPU 核心数 | 后台解析并发数 | +| `pageMapRefreshInterval` | 静态变量 | `32` | 每 N 章刷新一次 UI | +| `imageCache.countLimit` | `RDEPUBChapterRuntimeStore` | `50` | 图片缓存上限 | +| 冷却时间 | 硬编码 | `0.8s` | 用户交互冷却时间 | +| `schemaVersion` | `RDEPUBChapterSummary` | - | 缓存 schema 版本 | diff --git a/Doc/EPUBCore_CODE_REFERENCE.md b/Doc/EPUBCore_CODE_REFERENCE.md new file mode 100644 index 0000000..b9714b6 --- /dev/null +++ b/Doc/EPUBCore_CODE_REFERENCE.md @@ -0,0 +1,642 @@ +# EPUBCore 模块代码级参考文档 + +> 最后更新:2026-06-18 + +--- + +## 1. 模块概述 + +`EPUBCore` 是 ReadViewSDK 的**基础层**,负责 EPUB 文件解析、资源管理、WebView 渲染、JavaScript 桥接、CFI 定位、搜索和分页计算。它是上层 EPUBTextRendering 和 EPUBUI 的数据提供者。 + +**文件清单(~50 个 Swift 文件):** + +| 子系统 | 核心文件 | 职责 | +|--------|----------|------| +| **解析器** | `RDEPUBParser*.swift` | EPUB 解压、OPF 解析、TOC 解析、资源提取 | +| **数据模型** | `RDEPUBModels.swift` | Metadata、ManifestItem、SpineItem、TOC 等基础模型 | +| **Publication** | `RDEPUBPublication.swift` | 出版物抽象,聚合 parser 和 resourceResolver | +| **阅读会话** | `RDEPUBReadingSession.swift` | 管理分页快照、阅读位置、待导航状态 | +| **WebView** | `RDEPUBWebView*.swift` | WKWebView 封装,支持 Reflowable 和 Fixed Layout | +| **JS 桥接** | `RDEPUBJavaScriptBridge.swift` | JavaScript 消息协议和脚本注入 | +| **资源解析** | `RDEPUBResourceResolver.swift` | href 归一化、文件路径解析 | +| **URL 方案** | `RDEPUBResourceURLSchemeHandler.swift` | 自定义 `ss-reader://` 协议处理 | +| **分页器** | `RDEPUBPaginator.swift` | WebView 分页计算(按章测量页数) | +| **CFI** | `CFI/RDEPUBCFI*.swift` | EPUB CFI 解析、生成、序列化、恢复 | +| **搜索** | `RDEPUBSearchEngine.swift` | HTML 全文搜索 | +| **样式** | `RDEPUBStyleSheetBuilder.swift` | CSS 注入与分页样式生成 | +| **偏好** | `RDEPUBPreferences.swift` | 阅读偏好设置模型 | +| **资源文件** | `RDEPUBAssetRepository.swift` | JS/CSS/HTML 资源文件加载 | +| **笔记** | `Notes/RDEPUBNote*.swift` | 脚注/尾注检测与解析 | + +--- + +## 2. 解析器子系统 + +### 2.1 RDEPUBParser + +**文件:** `RDEPUBParser.swift` + 扩展文件 + +EPUB 文件解析的核心类,负责从 .epub 文件中提取结构化数据。 + +```swift +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? // OPF 文件路径 + + public var opfDirectoryURL: URL? // OPF 所在目录 + + /// 解析 EPUB 文件(解压 + 解析 container.xml + 解析 OPF) + public func parse(epubURL: URL) throws + + /// 直接解析 OPF 文件 + public func parseOPF(at opfURL: URL) throws + + /// 解析 Navigation Document 或 NCX + public func parseTOC() -> [EPUBTableOfContentsItem] + + /// 从 parser 创建 Publication + public func makePublication() -> RDEPUBPublication +} +``` + +**扩展文件职责:** + +| 文件 | 职责 | +|------|------| +| `RDEPUBParser+Archive.swift` | ZIP 解压(ZIPFoundation),提取到 Caches 目录 | +| `RDEPUBParser+Package.swift` | OPF XML SAX 解析(metadata/manifest/spine) | +| `RDEPUBParser+TOC.swift` | Navigation Document 和 NCX 解析 | +| `RDEPUBParser+Resources.swift` | 资源文件读取(HTML 字符串、文件 URL) | +| `RDEPUBParser+ReadingProfile.swift` | 阅读配置文件检测 | + +### 2.2 RDEPUBParserError + +```swift +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) +} +``` + +--- + +## 3. 数据模型 + +### 3.1 RDEPUBMetadata + +**文件:** `RDEPUBModels.swift` + +```swift +public struct RDEPUBMetadata: Codable, Equatable { + public var identifier: String? // ISBN/UUID + public var title: String // 书名 + public var author: String? // 作者 + public var language: String? // 语言 + public var version: String? // EPUB 版本 + public var layout: RDEPUBLayout // reflowable / fixed + public var spread: String? // none / auto + public var readingProgression: RDEPUBReadingProgression // ltr / rtl / auto +} +``` + +### 3.2 RDEPUBManifestItem + +```swift +public struct RDEPUBManifestItem: Codable, Equatable { + public var id: String // 资源 ID + public var href: String // 相对路径 + public var mediaType: String // MIME 类型 + public var properties: [String] // 属性(nav, mathml, svg 等) + public var fallback: String? // 回退资源 ID + public var mediaOverlay: String? // 媒体叠加 ID + public var title: String? // 标题 + + public var isNavigationDocument: Bool // 是否为 Navigation Document + public var isNCX: Bool // 是否为 NCX 文件 +} +``` + +### 3.3 RDEPUBSpineItem + +```swift +public struct RDEPUBSpineItem: Codable, Equatable { + public var idref: String // 引用 manifest ID + public var href: String // 解析后的相对路径 + public var mediaType: String // MIME 类型 + public var title: String // 章节标题 + public var linear: Bool // 是否为线性内容 + public var properties: [String] // 属性(page-spread-left/right) + public var pageSpread: RDEPUBPageSpread? // 页面位置 + public var layout: RDEPUBLayout? // 章节级布局覆盖 +} +``` + +### 3.4 EPUBTableOfContentsItem + +```swift +public struct EPUBTableOfContentsItem: Codable, Equatable { + public var title: String // 目录标题 + public var href: String // 链接地址 + public var children: [EPUBTableOfContentsItem] // 子目录(支持多级) +} +``` + +### 3.5 枚举类型 + +```swift +public enum RDEPUBLayout: String, Codable { + case reflowable // 文本重排 + case fixed // 固定布局 +} + +public enum RDEPUBReadingProfile: String, Codable { + case webInteractive // WebView 交互模式(原 EPUB 渲染) + case webFixedLayout // WebView 固定布局 + case textReflowable // 原生文本渲染(DTCoreText) +} + +public enum RDEPUBReadingProgression: String, Codable { + case ltr, rtl, auto +} + +public enum RDEPUBPageSpread: String, Codable { + case left, right, center +} +``` + +--- + +## 4. Publication + +**文件:** `RDEPUBPublication.swift` + +聚合 `RDEPUBParser` 和 `RDEPUBResourceResolver`,提供统一的出版物访问接口。 + +```swift +public final class RDEPUBPublication { + public let parser: RDEPUBParser + public let resourceResolver: RDEPUBResourceResolver + + public var metadata: RDEPUBMetadata + public var manifest: [String: RDEPUBManifestItem] + public var spine: [RDEPUBSpineItem] + public var tableOfContents: [EPUBTableOfContentsItem] + public var layout: RDEPUBLayout + public var readingProfile: RDEPUBReadingProfile + public var readingProgression: RDEPUBReadingProgression + public var bookIdentifier: String? + + /// 判断 Fixed Layout 是否启用双页展开 + public func fixedLayoutSpreadEnabled(for preferences: RDEPUBPreferences, viewportSize: CGSize) -> Bool + + /// 生成 Fixed Layout 的 Spread 列表 + public func makeFixedSpreads(preferences: RDEPUBPreferences, viewportSize: CGSize) -> [EPUBFixedSpread] +} +``` + +--- + +## 5. 阅读会话 + +**文件:** `RDEPUBReadingSession.swift` + +管理阅读过程中的分页快照、阅读位置和待导航状态。 + +```swift +public final class RDEPUBReadingSession { + public typealias PaginationSnapshot = (pages: [EPUBPage], chapters: [EPUBChapterInfo]) + + public let publication: RDEPUBPublication + public private(set) var navigatorState: RDEPUBNavigatorState + public private(set) var activePages: [EPUBPage] // 当前活跃页列表 + public private(set) var activeChapters: [EPUBChapterInfo] // 当前活跃章节列表 + public private(set) var stagedPages: [EPUBPage]? // 暂存页列表(待应用) + public private(set) var stagedChapters: [EPUBChapterInfo]? + public private(set) var currentViewport: RDEPUBViewport? + public private(set) var currentReadingContext: RDEPUBReadingContext? + + // 状态管理 + public func transition(to state: RDEPUBNavigatorState) + public func resetRuntimeState() + + // 快照管理 + public func setActiveSnapshot(_ snapshot: PaginationSnapshot) + public func stageSnapshot(_ snapshot: PaginationSnapshot, restoreLocation: RDEPUBLocation?) + public func consumeStagedSnapshotIfAllowed() -> (snapshot: PaginationSnapshot, restoreLocation: RDEPUBLocation?)? + + // 导航 + public func queueNavigation(to location: RDEPUBLocation, ...) -> Int? + public func updateReadingContext(pageNumber:location:spineIndex:chapterIndex:bookIdentifier:) + public func currentReadingLocation(bookIdentifier: String?) -> RDEPUBLocation? + + // 分页快照生成 + public func makePaginationSnapshot(pageCounts:preferences:layoutContext:) -> PaginationSnapshot +} +``` + +### 5.1 RDEPUBNavigatorState + +```swift +public enum RDEPUBNavigatorState: String, Codable { + case initializing // 初始化中 + case loading // 加载中 + case idle // 空闲(可应用快照) + case jumping // 跳转中 + case moving // 翻页中 + case repaginating // 重新分页中 + + public var isStableForSnapshotApplication: Bool // 仅 idle 状态为 true +} +``` + +--- + +## 6. WebView 子系统 + +### 6.1 RDEPUBWebView + +**文件:** `RDEPUBWebView.swift` + 扩展文件 + +`UIView` 子类,内部封装 `WKWebView`,支持 Reflowable 和 Fixed Layout 两种渲染模式。 + +```swift +public final class RDEPUBWebView: UIView { + public weak var delegate: RDEPUBWebViewDelegate? + public var onRendered: (() -> Void)? + + var publication: RDEPUBPublication? + var currentRenderRequest: RDEPUBRenderRequest? + var webView: WKWebView? + var schemeHandler: RDEPUBResourceURLSchemeHandler? + var currentSpineIndex: Int + var currentHref: String + var currentPageIndex: Int + var currentTotalPagesInChapter: Int + var viewportSize: CGSize + var currentFontSize: CGFloat + var currentLineHeightMultiple: CGFloat + var targetLocation: RDEPUBLocation? + var pendingHighlights: [RDEPUBHighlight] +} +``` + +**扩展文件:** + +| 文件 | 职责 | +|------|------| +| `RDEPUBWebView+Configuration.swift` | WKWebView 配置(scheme handler、user script) | +| `RDEPUBWebView+Reflowable.swift` | Reflowable 模式渲染逻辑 | +| `RDEPUBWebView+FixedLayout.swift` | Fixed Layout 模式渲染逻辑 | +| `RDEPUBWebView+JavaScriptBridge.swift` | JS 消息接收与处理 | +| `RDEPUBWebView+Search.swift` | 搜索高亮渲染 | + +### 6.2 RDEPUBWebViewDelegate + +```swift +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) +} +``` + +--- + +## 7. JavaScript 桥接 + +**文件:** `RDEPUBJavaScriptBridge.swift` + +定义 Native ↔ WebView 的消息协议和脚本注入。 + +### 7.1 消息类型 + +```swift +enum RDEPUBJavaScriptBridgeMessage: String, CaseIterable { + case progressionChanged = "ssReaderProgressionChanged" // 阅读进度变化 + case selectionChanged = "ssReaderSelectionChanged" // 文本选择变化 + case internalLink = "ssReaderInternalLink" // 内部链接点击 + case externalLink = "ssReaderExternalLink" // 外部链接点击 + case javaScriptError = "ssReaderJSError" // JS 错误 + case fixedLayoutReady = "ssReaderFixedLayoutReady" // Fixed Layout 就绪 +} +``` + +### 7.2 注入的 JS 资源 + +- `rangy-core.js` — 范围与选择 API +- `rangy-serializer.js` — 选择序列化 +- `cssInjector.js` — CSS 注入 +- `WeReadApi.js` — 阅读器 API(分页、跳转、高亮) +- `epub-bridge.js` — 消息桥接 + +### 7.3 关键方法 + +```swift +enum RDEPUBJavaScriptBridge { + static var messageNames: [String] // 所有消息名称 + static var userScript: String // 注入的 User Script + + /// 生成 Reflowable 渲染脚本 + static func applyPresentationScript(for request: RDEPUBReflowableRenderRequest) -> String + + /// 生成 Fixed Layout 渲染脚本 + static func applyFixedPresentationScript(for request: RDEPUBFixedRenderRequest) -> String +} +``` + +--- + +## 8. 资源解析 + +**文件:** `RDEPUBResourceResolver.swift` + +负责 href 归一化、文件路径解析和 spine 索引查找。 + +```swift +public final class RDEPUBResourceResolver { + public var opfDirectoryURL: URL? + + /// 将相对路径转为文件 URL + public func fileURL(forRelativePath relativePath: String) -> URL? + + /// 将相对路径转为 ss-reader:// 资源 URL + public func resourceURL(forRelativePath relativePath: String) -> URL? + + /// href 归一化(去除 fragment,解析相对路径) + public func normalizedHref(_ href: String, relativeToSpineIndex spineIndex: Int? = nil) -> String? + public func normalizedHref(_ href: String, relativeToHref baseHref: String) -> String? + + /// 位置归一化(合并 href + fragment + progression) + public func normalizedLocation(_ location: RDEPUBLocation, relativeToSpineIndex spineIndex: Int?, bookIdentifier: String?) -> RDEPUBLocation? + + /// 根据 href 查找 spine 索引 + public func spineIndex(forNormalizedHref normalizedHref: String) -> Int? + public func spineIndex(for location: RDEPUBLocation) -> Int? + + /// 获取 spine 项的 href 和 title + public func href(forSpineIndex spineIndex: Int) -> String? + public func title(forSpineIndex spineIndex: Int) -> String? +} +``` + +--- + +## 9. URL 方案处理器 + +**文件:** `RDEPUBResourceURLSchemeHandler.swift` + +实现 `WKURLSchemeHandler`,拦截 `ss-reader://book/` 请求,从 EPUB 解压目录流式读取资源文件。 + +```swift +public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler { + public static let scheme = "ss-reader" + public static let host = "book" + + public init(parser: RDEPUBParser) + + // WKURLSchemeHandler + public func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) + public func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask) +} +``` + +--- + +## 10. 分页器 + +**文件:** `RDEPUBPaginator.swift` + +使用隐藏的 WKWebView 逐章加载 HTML,通过 JS 测量每章的页数。 + +```swift +public final class RDEPUBPaginator: NSObject { + /// 计算所有章节的页数 + public func calculate( + parser: RDEPUBParser, + hostingView: UIView, + presentation: RDEPUBPresentationStyle, + completion: @escaping ([Int]) -> Void + ) + + /// 计算单个章节的页数 + public func calculateSingleChapter( + parser: RDEPUBParser, + spineIndex: Int, + hostingView: UIView, + presentation: RDEPUBPresentationStyle, + completion: @escaping (Int) -> Void + ) +} +``` + +--- + +## 11. 搜索引擎 + +**文件:** `RDEPUBSearchEngine.swift` + +```swift +protocol RDEPUBSearchEngine { + func search(keyword: String) -> [RDEPUBSearchMatch] +} +``` + +### RDEPUBHTMLSearchEngine + +基于 HTML 的搜索引擎,逐章加载 HTML → 转为纯文本 → 关键词匹配。 + +```swift +final class RDEPUBHTMLSearchEngine: RDEPUBSearchEngine { + init(parser: RDEPUBParser, publication: RDEPUBPublication) + func search(keyword: String) -> [RDEPUBSearchMatch] +} +``` + +### RDEPUBTextSearchEngine(EPUBTextRendering 模块) + +基于原生文本的搜索引擎,在 `NSAttributedString` 上直接搜索,性能更好。 + +```swift +final class RDEPUBTextSearchEngine: RDEPUBSearchEngine { + init(textBook: RDEPUBTextBook, publication: RDEPUBPublication) + func search(keyword: String) -> [RDEPUBSearchMatch] +} +``` + +--- + +## 12. CFI 子系统 + +**文件:** `CFI/RDEPUBCFI*.swift`(13 个文件) + +实现 EPUB CFI(Canonical Fragment Identifier)标准,用于精确定位 EPUB 内容。 + +| 文件 | 职责 | +|------|------| +| `RDEPUBCFI.swift` | CFI 值类型,核心解析入口 | +| `RDEPUBCFIParser.swift` | CFI 字符串解析器 | +| `RDEPUBCFISerializer.swift` | CFI 序列化为字符串 | +| `RDEPUBCFIPath.swift` | CFI 路径节点 | +| `RDEPUBCFIRange.swift` | CFI 范围(起止点) | +| `RDEPUBCFIResolver.swift` | CFI → DOM 位置解析 | +| `RDEPUBCFIGenerator.swift` | DOM 位置 → CFI 生成 | +| `RDEPUBCFIMap.swift` | 章节级 CFI 映射表 | +| `RDEPUBCFIDOMPathBuilder.swift` | DOM 路径构建 | +| `RDEPUBCFITextAssertion.swift` | 文本断言(偏移校验) | +| `RDEPUBCFIRecoveryEngine.swift` | CFI 恢复引擎(节点变化后重新定位) | +| `RDEPUBCFICompatibility.swift` | CFI 兼容性处理 | +| `RDEPUBCFIError.swift` | 错误类型 | + +--- + +## 13. 样式构建 + +**文件:** `RDEPUBStyleSheetBuilder.swift` + +```swift +public enum RDEPUBStyleSheetBuilder { + /// 向 HTML 注入分页 CSS(用于 Paginator 测量) + public static func injectPaginationCSS(into html: String, presentation: RDEPUBPresentationStyle) -> String + + /// 生成渲染用 CSS(viewport 尺寸、字体、行高、主题色) + public static func renderCSS(for presentation: RDEPUBPresentationStyle) -> String +} +``` + +--- + +## 14. 偏好设置 + +**文件:** `RDEPUBPreferences.swift` + +```swift +public struct RDEPUBPreferences: Equatable { + public var fontSize: CGFloat // 字体大小 + public var lineHeightMultiple: CGFloat // 行高倍数 + public var reflowableContentInsets: UIEdgeInsets // Reflowable 内容边距 + public var fixedContentInset: UIEdgeInsets // Fixed Layout 内容边距 + public var themeBackgroundColor: String? // 主题背景色(CSS) + public var themeTextColor: String? // 主题文字色(CSS) + public var fixedBackgroundColor: String? // Fixed 背景色 + public var fixedLayoutFit: RDEPUBFixedLayoutFit // Fixed 适配模式 + public var fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode // 双页模式 + public var numberOfColumns: Int // 列数 + public var columnGap: CGFloat // 列间距 + + /// 生成 PresentationStyle + public func presentationStyle(viewportSize: CGSize) -> RDEPUBPresentationStyle + + /// 生成渲染请求 + public func renderRequest(for page: EPUBPage, publication: RDEPUBPublication, viewportSize: CGSize, ...) -> RDEPUBRenderRequest? +} +``` + +--- + +## 15. 渲染请求模型 + +**文件:** `RDEPUBRenderRequest.swift` + +```swift +public enum RDEPUBRenderRequest: Equatable { + case reflowable(RDEPUBReflowableRenderRequest) + case fixed(RDEPUBFixedRenderRequest) + + public var isFixedLayout: Bool + public var primarySpineIndex: Int + public var primaryHref: String +} + +public struct RDEPUBReflowableRenderRequest: Equatable { + public var spineIndex: Int + public var href: String + public var pageIndex: Int + public var totalPagesInChapter: Int + public var presentation: RDEPUBPresentationStyle + public var targetLocation: RDEPUBLocation? + public var highlights: [RDEPUBHighlight] + public var searchPresentation: RDEPUBSearchPresentation? +} + +public struct RDEPUBFixedRenderRequest: Equatable { + public var spread: EPUBFixedSpread + public var viewportSize: CGSize + public var contentInset: UIEdgeInsets + public var fit: RDEPUBFixedLayoutFit +} +``` + +### RDEPUBPresentationStyle + +```swift +public struct RDEPUBPresentationStyle: Equatable { + public var viewportSize: CGSize + public var contentInsets: UIEdgeInsets + public var fontSize: CGFloat + public var lineHeightMultiple: CGFloat + public var numberOfColumns: Int + public var columnGap: CGFloat + public var themeBackgroundColor: String? + public var themeTextColor: String? +} +``` + +--- + +## 16. 资源仓库 + +**文件:** `RDEPUBAssetRepository.swift` + +管理 SDK 内置的 JS/CSS/HTML 资源文件加载。 + +```swift +enum RDEPUBAsset: String { + case rangyCoreScript, rangySerializerScript, cssInjectorScript + case weReadAPIScript, bridgeScript, fixedLayoutTemplate + case wxReadDefaultCSS, wxReadReplaceCSS, wxReadDarkCSS, wxReadLatinReplaceCSS +} + +enum RDEPUBAssetRepository { + /// 加载资源文件内容,支持模板替换 + static func string(for asset: RDEPUBAsset, replacements: [String: String] = [:]) -> String +} +``` + +--- + +## 17. 笔记子系统 + +**文件:** `Notes/RDEPUBNote*.swift` + +| 文件 | 职责 | +|------|------| +| `RDEPUBNoteModels.swift` | 脚注/尾注数据模型 | +| `RDEPUBNoteDetector.swift` | 从 HTML 中检测脚注标记 | +| `RDEPUBNoteResolver.swift` | 解析脚注内容 | + +--- + +## 18. 设计模式总结 + +| 模式 | 应用 | +|------|------| +| **Builder 模式** | `RDEPUBParser` → `RDEPUBPublication` 构建链 | +| **Strategy 模式** | `RDEPUBSearchEngine` 协议,HTML 和 Text 两种实现 | +| **URL Scheme 拦截** | `RDEPUBResourceURLSchemeHandler` 自定义协议 | +| **消息桥接** | `RDEPUBJavaScriptBridge` Native ↔ JS 双向通信 | +| **快照管理** | `RDEPUBReadingSession` staged/active 双快照 | +| **状态机** | `RDEPUBNavigatorState` 管理导航状态 | diff --git a/Doc/EPUBTextRendering_CODE_REFERENCE.md b/Doc/EPUBTextRendering_CODE_REFERENCE.md new file mode 100644 index 0000000..45ea516 --- /dev/null +++ b/Doc/EPUBTextRendering_CODE_REFERENCE.md @@ -0,0 +1,521 @@ +# EPUBTextRendering 模块代码级参考文档 + +> 最后更新:2026-06-18 + +--- + +## 1. 模块概述 + +`EPUBTextRendering` 是 ReadViewSDK 的**文本渲染层**,负责将 EPUB HTML 内容转换为原生 `NSAttributedString`,执行排版分页,并构建全书文本模型。它位于 EPUBCore 之上、EPUBUI 之下,是 `textReflowable` 阅读配置文件的核心引擎。 + +**文件清单(~30 个 Swift 文件):** + +| 子系统 | 核心文件 | 职责 | +|--------|----------|------| +| **排版管线** | `Typesetter/RDEPUBTypesettingPipeline.swift` | HTML → 标记化 HTML 的多阶段管线 | +| **文本渲染** | `RDEPUBTextRenderer.swift`, `RDEPUBDTCoreTextRenderer.swift` | HTML → NSAttributedString 转换 | +| **分页引擎** | `Pagination/*.swift` | CoreText 排版、页帧计算、分页策略 | +| **构建管线** | `BuildPipeline/*.swift` | 全书构建(渲染 → 分页 → 缓存) | +| **搜索** | `RDEPUBTextSearchEngine.swift` | 基于原生文本的全文搜索 | +| **索引** | `RDEPUBTextIndexTable.swift` | 全书字符偏移索引、CFI 映射 | +| **位置转换** | `RDEPUBTextPositionConverter.swift` | 字符偏移 ↔ 页码转换 | +| **章节数据** | `RDEPUBChapterData.swift` | 章节级数据访问封装 | + +--- + +## 2. 排版管线(Typesetter) + +### 2.1 RDEPUBTextTypesettingPipeline + +**文件:** `Typesetter/RDEPUBTypesettingPipeline.swift` + +多阶段 HTML 处理管线,将原始 HTML 转换为可渲染的标记化 HTML。 + +```swift +struct RDEPUBTextTypesetterPipeline { + func makeRequest(from input: RDEPUBTypesettingInput) -> RDEPUBTypesettingOutput +} +``` + +**管线阶段:** + +``` +原始 HTML + │ + ▼ +RDEPUBHTMLNormalizer → 清理 HTML(移除脚本、修复标签) + │ + ▼ +RDEPUBSemanticMarkerInjector → 注入语义标记(段落、列表、表格等) + │ + ▼ +RDEPUBCFIMarkerInjector → 注入 CFI 定位标记 + │ + ▼ +RDEPUBStyleSheetComposer → 合并样式表(内联 CSS + 兼容性处理) + │ + ▼ +RDEPUBFontNormalizer → 注册嵌入字体、字体回退 + │ + ▼ +RDEPUBFragmentMarkerInjector → 注入片段标记(用于锚点定位) + │ + ▼ +标记化 HTML + 诊断报告 +``` + +### 2.2 管线输入/输出 + +```swift +struct RDEPUBTypesettingInput { + var href: String // 章节 href + var spineIndex: Int? + var title: String // 章节标题 + var rawHTML: String // 原始 HTML + var baseURL: URL? // 资源基础 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 // CSS 兼容性报告 +} +``` + +### 2.3 各阶段处理器 + +| 处理器 | 文件 | 职责 | +|--------|------|------| +| `RDEPUBHTMLNormalizer` | `RDEPUBHTMLNormalizer.swift` | 清理 HTML:移除 `"#, 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/RDReaderView/EPUBCore/RDEPUBAssetRepository.swift b/Sources/RDReaderView/EPUBCore/RDEPUBAssetRepository.swift index 812e75d..a0304c1 100644 --- a/Sources/RDReaderView/EPUBCore/RDEPUBAssetRepository.swift +++ b/Sources/RDReaderView/EPUBCore/RDEPUBAssetRepository.swift @@ -1,34 +1,28 @@ -// RDEPUBAssetRepository.swift -// EPUBCore 静态资源仓库 -// 负责从资源包中加载 EPUB 渲染所需的 JS 桥接脚本和固定版式 HTML 模板, -// 支持模板变量替换({{token}} 占位符)。 import Foundation -/// EPUB 内置资源枚举,定义桥接脚本和固定版式模板的文件名及扩展名 enum RDEPUBAsset: String { - /// Rangy core 等价桥接脚本,负责提供 Web range/selection 基础能力 + case rangyCoreScript = "rangy-core" - /// Rangy serializer 等价桥接脚本,负责 DOM range 序列化/反序列化 + case rangySerializerScript = "rangy-serializer" - /// WXRead 对齐的动态 CSS 注入脚本 + case cssInjectorScript = "cssInjector" - /// WXRead 对齐的 WeReadApi JS-Native 桥接脚本 + case weReadAPIScript = "WeReadApi" - /// JS 桥接脚本(epub-bridge.js),注入到 WebView 中实现原生-JS 通信 + case bridgeScript = "epub-bridge" - /// 固定版式 HTML 模板(epub-fixed-layout.html),用于渲染 pre-paginated 类型的 EPUB + case fixedLayoutTemplate = "epub-fixed-layout" - /// WXRead 对齐的 default.css + case wxReadDefaultCSS = "wxread-default" - /// WXRead 对齐的 replace.css + case wxReadReplaceCSS = "wxread-replace" - /// WXRead 对齐的 dark.css + case wxReadDarkCSS = "wxread-dark" - /// WXRead 对齐的 replaceForLatinLanguageBook.css + case wxReadLatinReplaceCSS = "wxread-replace-latin" - /// 对应的文件扩展名 var fileExtension: String { switch self { case .rangyCoreScript, .rangySerializerScript, .cssInjectorScript, .weReadAPIScript, .bridgeScript: @@ -41,13 +35,8 @@ enum RDEPUBAsset: String { } } -/// 静态资源加载器,负责从资源包中读取模板文件并执行变量替换 enum RDEPUBAssetRepository { - /// 加载指定资源文件的内容,并将 {{key}} 占位符替换为对应的值 - /// - Parameters: - /// - asset: 要加载的资源类型 - /// - replacements: 模板变量字典,key 为占位符名称,value 为替换值 - /// - Returns: 替换后的文件内容字符串 + 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 { @@ -61,7 +50,6 @@ enum RDEPUBAssetRepository { return content } - /// 获取资源所在的 Bundle(优先使用已解析的子 bundle,兜底到宿主 bundle) private static var resourceBundle: Bundle { if let bundle = resolvedBundle { return bundle @@ -69,7 +57,6 @@ enum RDEPUBAssetRepository { return Bundle(for: RDEPUBAssetBundleToken.self) } - /// 延迟解析 RDReaderViewAssets.bundle 的位置,在宿主 bundle 和所有 framework 中查找 private static var resolvedBundle: Bundle? = { let hostBundles = [Bundle(for: RDEPUBAssetBundleToken.self), Bundle.main] + Bundle.allFrameworks + Bundle.allBundles for hostBundle in hostBundles { @@ -82,5 +69,4 @@ enum RDEPUBAssetRepository { }() } -/// 用于定位当前模块 Bundle 的标记类 private final class RDEPUBAssetBundleToken {} diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBFixedLayoutTemplate.swift b/Sources/RDReaderView/EPUBCore/RDEPUBFixedLayoutTemplate.swift index 5478e00..5d84523 100644 --- a/Sources/RDReaderView/EPUBCore/RDEPUBFixedLayoutTemplate.swift +++ b/Sources/RDReaderView/EPUBCore/RDEPUBFixedLayoutTemplate.swift @@ -1,25 +1,15 @@ -// RDEPUBFixedLayoutTemplate.swift -// 固定版式(Fixed Layout)HTML 模板生成器 -// 根据渲染请求和 Publication 信息,将 iframe 拼装到 HTML 模板中, -// 生成适配视口尺寸和内边距的固定版式页面 HTML。 import Foundation -/// 固定版式模板生成器,负责将 spine 资源渲染为 iframe 嵌套的 HTML 页面 enum RDEPUBFixedLayoutTemplate { - /// 根据渲染请求和 Publication 生成固定版式页面的完整 HTML - /// - Parameters: - /// - request: 固定版式渲染请求(含 spread、视口尺寸、内边距、适配模式等) - /// - publication: EPUB 出版物,用于解析资源 URL - /// - Returns: 完整的 HTML 字符串 + static func html(for request: RDEPUBFixedRenderRequest, publication: RDEPUBPublication) -> String { - // 遍历 spread 中的每个资源,生成对应的 iframe pane HTML + let panes = request.spread.resources.enumerated().compactMap { index, resource -> String? in guard let url = publication.resourceResolver.resourceURL(forRelativePath: resource.href)?.absoluteString else { return nil } - // 根据 spread 中的资源数量判断页面类型:单页/左页/右页/居中 let pageType: String if request.spread.resources.count == 1 { pageType = "single" @@ -36,7 +26,6 @@ enum RDEPUBFixedLayoutTemplate { """ }.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))) @@ -44,7 +33,7 @@ enum RDEPUBFixedLayoutTemplate { let insetRight = Int(request.contentInset.right.rounded(.down)) let insetBottom = Int(request.contentInset.bottom.rounded(.down)) let insetLeft = Int(request.contentInset.left.rounded(.down)) - // 从资源仓库加载 HTML 模板并替换占位符 + return RDEPUBAssetRepository.string( for: .fixedLayoutTemplate, replacements: [ diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBJavaScriptBridge.swift b/Sources/RDReaderView/EPUBCore/RDEPUBJavaScriptBridge.swift index 6fbd06d..3462eab 100644 --- a/Sources/RDReaderView/EPUBCore/RDEPUBJavaScriptBridge.swift +++ b/Sources/RDReaderView/EPUBCore/RDEPUBJavaScriptBridge.swift @@ -1,35 +1,27 @@ -// RDEPUBJavaScriptBridge.swift -// 原生与 WebView 之间的 JavaScript 桥接层 -// 定义所有 JS 消息类型(progression/selection/link/error 等), -// 生成注入到 WebView 的用户脚本,以及分页渲染、搜索高亮等执行脚本。 import Foundation -/// JS 桥接消息类型枚举,每种消息对应 WebView 向原生发送的一种事件 enum RDEPUBJavaScriptBridgeMessage: String, CaseIterable { - /// 阅读进度变化事件 + case progressionChanged = "ssReaderProgressionChanged" - /// 文本选择变化事件 + case selectionChanged = "ssReaderSelectionChanged" - /// 内部链接点击事件(ss-reader:// 协议) + case internalLink = "ssReaderInternalLink" - /// 外部链接点击事件(http/https/mailto/tel) + case externalLink = "ssReaderExternalLink" - /// JavaScript 运行时错误事件 + case javaScriptError = "ssReaderJSError" - /// 固定版式渲染就绪事件 + case fixedLayoutReady = "ssReaderFixedLayoutReady" } -/// JavaScript 桥接工具集,负责生成注入脚本和执行脚本 enum RDEPUBJavaScriptBridge { - /// 所有需要注册的 JS 消息名称列表,用于 WKUserContentController 注册 + static var messageNames: [String] { RDEPUBJavaScriptBridgeMessage.allCases.map(\.rawValue) } - /// 获取注入到 WebView 的桥接用户脚本(在 document end 注入) - /// 将消息类型占位符替换为实际值,使 JS 端能通过 window.webkit.messageHandlers 发送消息 static var userScript: String { [ RDEPUBAssetRepository.string(for: .rangyCoreScript), @@ -50,10 +42,6 @@ enum RDEPUBJavaScriptBridge { ].joined(separator: "\n\n") } - /// 生成重排模式下的分页渲染脚本 - /// 注入 CSS 分页样式、设置页码参数、高亮和目标位置,最后触发进度汇报 - /// - Parameter request: 重排渲染请求,包含展示样式、高亮、目标位置等信息 - /// - Returns: 可直接通过 evaluateJavaScript 执行的脚本字符串 static func applyPresentationScript(for request: RDEPUBReflowableRenderRequest) -> String { let style = escapedJavaScriptTemplateLiteral(RDEPUBStyleSheetBuilder.renderCSS(for: request.presentation)) let pageStride = max(1, request.presentation.viewportSize.width) @@ -87,10 +75,6 @@ enum RDEPUBJavaScriptBridge { """ } - /// 生成固定版式模式下的主题应用脚本 - /// 仅应用共享主题(背景色),不涉及分页逻辑(固定版式由 HTML 自身布局决定) - /// - Parameter request: 固定版式渲染请求,包含背景色等信息 - /// - Returns: 可直接通过 evaluateJavaScript 执行的脚本字符串 static func applyFixedPresentationScript(for request: RDEPUBFixedRenderRequest) -> String { """ (function() { @@ -105,11 +89,6 @@ enum RDEPUBJavaScriptBridge { """ } - /// 生成搜索高亮脚本 - /// 在当前文档和所有 iframe 中查找关键词并用 span 标记高亮, - /// 活跃匹配项使用特殊样式并自动滚动到视图中央 - /// - Parameter presentation: 搜索展示信息(关键词、各资源的匹配数量等) - /// - Returns: 搜索高亮脚本字符串 static func applySearchScript(for presentation: RDEPUBSearchPresentation?) -> String { let payload = jsonString(from: searchPayload(presentation), fallback: "null") return """ @@ -121,9 +100,6 @@ enum RDEPUBJavaScriptBridge { """ } - /// 生成获取当前装饰数据的脚本 - /// 从 WebView 中读取高亮和搜索高亮的 DOM 装饰信息,用于同步原生标注状态 - /// - Returns: 可直接通过 evaluateJavaScript 执行的脚本字符串,返回包含 highlights 和 search 的对象 static func resolveDecorationsScript() -> String { """ (function() { @@ -133,14 +109,12 @@ enum RDEPUBJavaScriptBridge { """ } - /// 转义字符串中的反斜杠和反引号,避免 JS 模板字面量语法错误 private static func escapedJavaScriptTemplateLiteral(_ string: String) -> String { string .replacingOccurrences(of: "\\", with: "\\\\") .replacingOccurrences(of: "`", with: "\\`") } - /// 将高亮数组转换为 JS 可用的字典数组 private static func highlightsPayload(_ highlights: [RDEPUBHighlight]) -> [[String: String]] { highlights.compactMap { highlight in guard let rangeInfo = highlight.rangeInfo, !rangeInfo.isEmpty else { return nil } @@ -153,7 +127,6 @@ enum RDEPUBJavaScriptBridge { } } - /// 将目标位置转换为 JS 可用的字典(含 progression、fragment) private static func targetLocationPayload(_ location: RDEPUBLocation?) -> [String: Any]? { guard let location else { return nil } return [ @@ -163,7 +136,6 @@ enum RDEPUBJavaScriptBridge { ] } - /// 将搜索展示信息转换为 JS 可用的字典 private static func searchPayload(_ presentation: RDEPUBSearchPresentation?) -> [String: Any]? { guard let presentation else { return nil } return [ @@ -178,7 +150,6 @@ enum RDEPUBJavaScriptBridge { ] } - /// 将任意对象序列化为 JSON 字符串,失败时返回 fallback private static func jsonString(from object: Any?, fallback: String) -> String { guard let object else { return fallback } guard JSONSerialization.isValidJSONObject(object), @@ -189,8 +160,6 @@ enum RDEPUBJavaScriptBridge { return string } - /// 将 Swift 字符串转换为 JavaScript 字符串字面量 - /// nil 值转为 "null",非空值序列化为 JSON 字符串后去除外层方括号 private static func javaScriptStringLiteral(_ value: String?) -> String { guard let value else { return "null" } return jsonString(from: [value], fallback: "[null]") diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBModels.swift b/Sources/RDReaderView/EPUBCore/RDEPUBModels.swift index dcfd66e..58e76ed 100644 --- a/Sources/RDReaderView/EPUBCore/RDEPUBModels.swift +++ b/Sources/RDReaderView/EPUBCore/RDEPUBModels.swift @@ -1,71 +1,45 @@ -// -// RDEPUBModels.swift -// RDReaderView -// -// EPUBCore 层核心数据模型定义文件。 -// 包含 EPUB 解析过程中所需的全部基础枚举和数据结构: -// 布局类型、阅读方向、页面分布、元数据、清单项、Spine 项、目录项及解析错误类型。 -// 这些模型由 RDEPUBParser 解析后填充,并由 RDEPUBPublication 聚合对外暴露。 -// import Foundation -/// EPUB 版面布局类型 -/// - reflowable: 可重排布局,内容根据屏幕尺寸和字号自动分页(小说类 EPUB) -/// - fixed: 固定版式布局,页面尺寸固定不变(漫画、绘本类 EPUB) public enum RDEPUBLayout: String, Codable { case reflowable case fixed } -/// 阅读配置文件类型,决定使用哪种渲染路径 -/// - webInteractive: 可重排 + 含交互脚本,使用 WKWebView 渲染 -/// - webFixedLayout: 固定版式 EPUB(漫画、绘本),使用 WKWebView 渲染 -/// - textReflowable: 纯文本可重排 EPUB,使用 DTCoreText 渲染(性能更优) public enum RDEPUBReadingProfile: String, Codable { case webInteractive case webFixedLayout case textReflowable } -/// 阅读方向(文字排版方向) -/// - ltr: 从左到右(英文、中文横排) -/// - rtl: 从右到左(日文竖排、阿拉伯文) -/// - auto: 根据语言自动判断 public enum RDEPUBReadingProgression: String, Codable { case ltr case rtl case auto } -/// 固定版式页面在 spread(双页展开)中的位置 -/// - left: 左页 -/// - right: 右页 -/// - center: 居中(跨页或单页居中) public enum RDEPUBPageSpread: String, Codable { case left case right case center } -/// EPUB 出版物元数据,对应 OPF 文档中 节点的内容 -/// 包含书籍的基本信息:标识符、标题、作者、语言、版本、布局类型等 public struct RDEPUBMetadata: Codable, Equatable { - /// 书籍唯一标识符(对应 ) + public var identifier: String? - /// 书名(对应 ) + public var title: String - /// 作者(对应 ) + public var author: String? - /// 语言代码(对应 ,如 "zh"、"en") + public var language: String? - /// EPUB 版本号(如 "2.0"、"3.0") + public var version: String? - /// 版面布局类型(reflowable 或 fixed) + public var layout: RDEPUBLayout - /// spread 模式设置(对应 ) + public var spread: String? - /// 阅读方向(ltr / rtl / auto) + public var readingProgression: RDEPUBReadingProgression public init( @@ -89,22 +63,20 @@ public struct RDEPUBMetadata: Codable, Equatable { } } -/// EPUB 清单项,对应 OPF 文档 中的每个 -/// 描述一个资源文件的 ID、路径、MIME 类型和附加属性 public struct RDEPUBManifestItem: Codable, Equatable { - /// 清单项的唯一标识符(对应 ) + public var id: String - /// 资源文件路径,相对于 OPF 文件所在目录 + public var href: String - /// MIME 类型(如 "application/xhtml+xml"、"image/jpeg"、"text/css") + public var mediaType: String - /// 属性列表(如 "nav" 表示导航文档、"scripted" 表示含脚本、"cover-image" 表示封面) + public var properties: [String] - /// 回退项 ID(对应 ) + public var fallback: String? - /// 媒体叠加文件 ID(EPUB 3 有声书功能) + public var mediaOverlay: String? - /// 资源标题(用于搜索结果展示等辅助用途) + public var title: String? public init( @@ -125,37 +97,31 @@ public struct RDEPUBManifestItem: Codable, Equatable { self.title = title } - /// 是否为 EPUB 3 导航文档(Nav Document) - /// 通过检查 properties 中是否包含 "nav" 标记判断 public var isNavigationDocument: Bool { properties.contains("nav") } - /// 是否为 NCX 目录文件(EPUB 2 格式) - /// 通过 MIME 类型 "application/x-dtbncx+xml" 判断 public var isNCX: Bool { mediaType == "application/x-dtbncx+xml" } } -/// EPUB Spine 项,对应 OPF 文档 中的每个 -/// Spine 定义了阅读器中章节内容的线性阅读顺序 public struct RDEPUBSpineItem: Codable, Equatable { - /// 对应 manifest 中的 item id(用于关联资源) + public var idref: String - /// 标准化后的资源路径(相对于 OPF 文件所在目录) + public var href: String - /// MIME 类型 + public var mediaType: String - /// 章节标题(从目录或 manifest 推断) + public var title: String - /// 是否为线性内容("no" 表示补充材料,不影响主线阅读进度) + public var linear: Bool - /// 属性列表(如 rendition:layout-* 用于单页布局覆盖) + public var properties: [String] - /// 页面在 spread 中的位置偏好(左页/右页/居中) + public var pageSpread: RDEPUBPageSpread? - /// 单页布局覆盖(显式属性可覆盖出版级别的 layout 设置) + public var layout: RDEPUBLayout? public init( @@ -179,14 +145,12 @@ public struct RDEPUBSpineItem: Codable, Equatable { } } -/// EPUB 目录项,支持树形层级结构 -/// 来源于 NCX 文件(EPUB 2)或 Nav Document(EPUB 3)的解析结果 public struct EPUBTableOfContentsItem: Codable, Equatable { - /// 目录项标题(显示在目录面板中) + public var title: String - /// 目标资源路径(含可能的 fragment 锚点) + public var href: String - /// 子目录项列表(递归结构,支持多级目录) + public var children: [EPUBTableOfContentsItem] public init(title: String, href: String, children: [EPUBTableOfContentsItem] = []) { @@ -196,23 +160,22 @@ public struct EPUBTableOfContentsItem: Codable, Equatable { } } -/// EPUB 解析器错误类型,涵盖从 ZIP 解压到 OPF 解析全链路的异常情况 public enum RDEPUBParserError: LocalizedError { - /// ZIP 文件无法打开(文件损坏或格式不支持) + case archiveOpenFailed(URL) - /// 缺少 META-INF/container.xml 文件(EPUB 格式不合规) + case missingContainerXML - /// container.xml 中未找到 元素 + case missingRootFile - /// OPF 根文件路径无效(文件不存在) + case invalidRootFilePath(String) - /// XML 解析失败(格式错误或编码问题) + case invalidXML(URL) - /// spine 中引用的 manifest 项不存在(idref 不匹配) + case missingManifestItem(idref: String) - /// 构建完成的 spine 为空(无可阅读的内容) + case emptySpine - /// 归档条目路径包含路径穿越或非法字符 + case invalidArchiveEntryPath(String) public var errorDescription: String? { diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBNavigatorLayoutContext.swift b/Sources/RDReaderView/EPUBCore/RDEPUBNavigatorLayoutContext.swift index 480ab30..bfd669b 100644 --- a/Sources/RDReaderView/EPUBCore/RDEPUBNavigatorLayoutContext.swift +++ b/Sources/RDReaderView/EPUBCore/RDEPUBNavigatorLayoutContext.swift @@ -1,22 +1,16 @@ -// RDEPUBNavigatorLayoutContext.swift -// 导航器布局上下文 -// 封装阅读器容器的布局参数:容器尺寸、每屏页数、安全区域、设备类型、 -// 重排内容内边距等,供 Paginator 和渲染层使用。 import UIKit -/// 导航器布局上下文,描述阅读器容器的布局参数 -/// 在 Paginator 计算分页和 WebView 渲染时作为核心输入 public struct RDEPUBNavigatorLayoutContext: Equatable { - /// 容器视图的总尺寸 + public var containerSize: CGSize - /// 每屏显示的页数(横屏双页模式为 2) + public var pagesPerScreen: Int - /// 安全区域内边距(刘海屏、Home Indicator 等) + public var safeAreaInsets: UIEdgeInsets - /// 用户界面设备类型(phone/pad) + public var userInterfaceIdiom: UIUserInterfaceIdiom - /// 重排模式下的内容内边距(上下左右留白) + public var reflowableContentInsets: UIEdgeInsets public init( @@ -33,7 +27,6 @@ public struct RDEPUBNavigatorLayoutContext: Equatable { self.reflowableContentInsets = reflowableContentInsets } - /// 计算单页视口尺寸(双页模式下宽度除以 2) public var viewportSize: CGSize { let width: CGFloat if pagesPerScreen > 1 { @@ -44,7 +37,6 @@ public struct RDEPUBNavigatorLayoutContext: Equatable { return CGSize(width: width, height: containerSize.height) } - /// 固定版式模式下的内容内边距(仅手机端使用安全区域,Pad 端归零;左右取较大值对称) public var fixedContentInset: UIEdgeInsets { var insets = safeAreaInsets if userInterfaceIdiom != .phone { diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBNavigatorState.swift b/Sources/RDReaderView/EPUBCore/RDEPUBNavigatorState.swift index 7fd982e..d07937c 100644 --- a/Sources/RDReaderView/EPUBCore/RDEPUBNavigatorState.swift +++ b/Sources/RDReaderView/EPUBCore/RDEPUBNavigatorState.swift @@ -1,33 +1,20 @@ -// -// RDEPUBNavigatorState.swift -// RDReaderView -// -// EPUBCore 层导航状态机定义文件。 -// 定义了阅读器导航器的状态枚举,状态流转遵循: -// initializing → loading → idle ↔ jumping/moving/repaginating -// 由 RDEPUBReadingSession 管理状态跃迁,控制分页快照的应用时机。 -// import Foundation -/// 导航状态枚举,描述阅读器的当前操作状态 -/// 状态机流转:initializing → loading → idle ↔ jumping/moving/repaginating public enum RDEPUBNavigatorState: String, Codable { - /// 初始化状态,刚打开书籍,等待初始恢复位置 + case initializing - /// 加载状态,正在生成首轮分页模型 + case loading - /// 空闲状态,显示稳定,可接受用户操作和快照应用 + case idle - /// 跳转状态,正在执行目录/内部链接跳转 + case jumping - /// 翻页状态,用户正在翻页操作中 + case moving - /// 重排状态,正在重新分页(字号/横竖屏变化触发) + case repaginating - /// 是否处于可应用分页快照的稳定状态 - /// 只有 idle 状态才允许消费 staged snapshot public var isStableForSnapshotApplication: Bool { self == .idle } diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBPaginator.swift b/Sources/RDReaderView/EPUBCore/RDEPUBPaginator.swift index 684205a..0e844b9 100644 --- a/Sources/RDReaderView/EPUBCore/RDEPUBPaginator.swift +++ b/Sources/RDReaderView/EPUBCore/RDEPUBPaginator.swift @@ -1,45 +1,38 @@ -// RDEPUBPaginator.swift -// EPUB 离屏分页计算器 -// 使用隐藏的 WKWebView 加载每个 spine 项,通过 JS 测量 scrollWidth -// 计算每章的页数。采用多轮延迟测量(0ms/80ms/180ms)确保布局稳定。 -// 固定版式(Fixed Layout)直接返回 1 页。 import UIKit import WebKit -/// EPUB 分页计算器,通过离屏 WebView 测量每个 spine 项的分页数 public final class RDEPUBPaginator: NSObject { - /// 当前解析器引用 + private var parser: RDEPUBParser? - /// 承载 WebView 的父视图(弱引用) + private weak var hostingView: UIView? - /// 当前展示样式(视口尺寸、内边距、字号、行高) + private var presentation = RDEPUBPresentationStyle( viewportSize: .zero, contentInsets: .zero, fontSize: 16, lineHeightMultiple: 1.5 ) - /// 全量分页完成回调(返回每个 spine 项的页数数组) + private var completion: (([Int]) -> Void)? - /// 单个 spine 项分页完成回调 + private var singlePageCountCompletion: ((Int) -> Void)? - /// 各 spine 项的页数结果 + private var pageCounts: [Int] = [] - /// 待测量的 spine 项索引列表 + private var measurementIndices: [Int] = [] - /// 当前测量偏移量(在 measurementIndices 中的位置) + private var currentMeasurementOffset = 0 - /// 当前 spine 项的待定页数(多轮测量取最大值) + private var pendingMeasurementValue = 1 - /// 当前测量轮次(0/1/2 对应延迟 0ms/80ms/180ms) + private var measurementPass = 0 - /// 当前测量会话 ID,用于防并发和取消过期任务 + private var activeSessionID = UUID() - /// 调试日志作用域标识 + private let debugScope = "PaginatorWebView" - /// 用于分页测量的隐藏 WebView(非持久化数据存储,禁止用户交互) private lazy var webView: WKWebView = { let configuration = WKWebViewConfiguration() configuration.websiteDataStore = .nonPersistent() @@ -70,12 +63,6 @@ public final class RDEPUBPaginator: NSObject { webView.removeFromSuperview() } - /// 计算所有 spine 项的分页数 - /// - Parameters: - /// - parser: EPUB 解析器 - /// - hostingView: 承载 WebView 的父视图 - /// - presentation: 展示样式(视口、内边距、字号、行高) - /// - completion: 完成回调,返回每个 spine 项的页数数组 public func calculate( parser: RDEPUBParser, hostingView: UIView, @@ -106,7 +93,6 @@ public final class RDEPUBPaginator: NSObject { measureNextSpineItem() } - /// 便捷方法:使用独立参数计算所有 spine 项的分页数 public func calculate( parser: RDEPUBParser, hostingView: UIView, @@ -129,13 +115,6 @@ public final class RDEPUBPaginator: NSObject { ) } - /// 仅计算单个 spine 项的分页数 - /// - Parameters: - /// - parser: EPUB 解析器 - /// - spineIndex: 要测量的 spine 项索引 - /// - hostingView: 承载 WebView 的父视图 - /// - presentation: 展示样式 - /// - completion: 完成回调,返回该 spine 项的页数 public func calculateSingleSpinePageCount( parser: RDEPUBParser, spineIndex: Int, @@ -167,7 +146,6 @@ public final class RDEPUBPaginator: NSObject { measureNextSpineItem() } - /// 便捷方法:使用独立参数计算单个 spine 项的分页数 public func calculateSingleSpinePageCount( parser: RDEPUBParser, spineIndex: Int, @@ -192,7 +170,6 @@ public final class RDEPUBPaginator: NSObject { ) } - /// 静态方法:将分页 CSS 注入到 HTML 字符串中(用于预渲染场景) public static func injectPaginationCSS( into html: String, viewportSize: CGSize, @@ -215,7 +192,6 @@ public final class RDEPUBPaginator: NSObject { ) } - /// 测量下一个 spine 项:加载文件到 WebView,跳过不可渲染或文件缺失的项 private func measureNextSpineItem() { guard let parser else { finishIfNeeded() @@ -253,12 +229,10 @@ public final class RDEPUBPaginator: NSObject { webView.loadFileURL(fileURL, allowingReadAccessTo: readAccessURL) } - /// 获取当前正在测量的 spine 项索引 private func currentSpineIndexForMeasurement() -> Int? { measurementIndices[safe: currentMeasurementOffset] } - /// 检查是否所有 spine 项都已测量完毕,触发对应的完成回调 private func finishIfNeeded() { if let singlePageCountCompletion { let measuredSpineIndex = measurementIndices.first ?? 0 @@ -273,7 +247,6 @@ public final class RDEPUBPaginator: NSObject { cleanupMeasurementState() } - /// 清理测量状态,释放 WebView 和所有引用 private func cleanupMeasurementState() { parser = nil hostingView = nil @@ -289,16 +262,13 @@ public final class RDEPUBPaginator: NSObject { webView.removeFromSuperview() } - /// 判断 spine 项是否可渲染(仅 HTML/XHTML/XML 类型可分页) 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] = [] - /// 调度多轮测量:0ms/80ms/180ms 三轮延迟,取最大值以应对布局抖动 private func scheduleMeasurementPass() { let sessionID = activeSessionID let delays: [TimeInterval] = [0.0, 0.08, 0.18] @@ -308,7 +278,7 @@ public final class RDEPUBPaginator: NSObject { return } let finalValue = max(1, pendingMeasurementValue) - // 测量稳定性日志:三轮差异过大时告警 + if measurementPassValues.count >= 2 { let minVal = measurementPassValues.min() ?? 1 let maxVal = measurementPassValues.max() ?? 1 @@ -331,7 +301,6 @@ public final class RDEPUBPaginator: NSObject { } } - /// 执行当前文档的测量:通过 JS 获取 scrollWidth 并计算页数 private func measureCurrentDocument(sessionID: UUID) { let script = RDEPUBStyleSheetBuilder.measurementScript(for: presentation) RDEPUBWebViewDebug.logJavaScript(debugScope, webView: webView, action: "measure", details: "session=\(sessionID) pass=\(measurementPass)") @@ -357,6 +326,7 @@ public final class RDEPUBPaginator: NSObject { } extension RDEPUBPaginator: WKNavigationDelegate { + public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { guard let url = navigationAction.request.url else { decisionHandler(.cancel) @@ -402,7 +372,6 @@ extension RDEPUBPaginator: WKNavigationDelegate { } } -/// Array 安全下标扩展,越界时返回 nil 而非崩溃 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/RDReaderView/EPUBCore/RDEPUBParser+Archive.swift index 61381d9..1b6a19b 100644 --- a/Sources/RDReaderView/EPUBCore/RDEPUBParser+Archive.swift +++ b/Sources/RDReaderView/EPUBCore/RDEPUBParser+Archive.swift @@ -1,15 +1,9 @@ -// RDEPUBParser+Archive.swift -// EPUB 归档解压与 container.xml 解析 -// 负责将 EPUB ZIP 文件解压到缓存目录(~/Library/Caches/ssreaderview-epub/), -// 并解析 META-INF/container.xml 获取 OPF 文件路径。 import Foundation import ZIPFoundation extension RDEPUBParser { - /// 解析 container.xml,提取 OPF(Package Document)的相对路径 - /// - Parameter containerURL: container.xml 的文件路径 - /// - Returns: OPF 文件的相对路径(如 "OEBPS/content.opf") + func parseContainerRootFile(at containerURL: URL) throws -> String { guard let parser = XMLParser(contentsOf: containerURL) else { throw RDEPUBParserError.invalidXML(containerURL) @@ -30,10 +24,6 @@ extension RDEPUBParser { return rootFilePath } - /// 解压 EPUB ZIP 文件到缓存目录(幂等:已存在则跳过) - /// 目录名由 slug + 文件大小 + 修改时间戳组成,避免冲突 - /// - Parameter epubURL: EPUB 文件路径 - /// - Returns: 解压目标目录 URL func extractArchiveIfNeeded(epubURL: URL) throws -> URL { let fileManager = FileManager.default let extractionURL = temporaryExtractionDirectory(for: epubURL) @@ -66,17 +56,12 @@ extension RDEPUBParser { return extractionURL } - /// 校验归档条目路径,防止路径穿越攻击 - /// - Parameters: - /// - entryPath: 归档中的原始条目路径 - /// - extractionRoot: 解压根目录 - /// - Returns: 校验通过的目标 URL,非法路径返回 nil 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 @@ -84,15 +69,13 @@ extension RDEPUBParser { let destinationURL = extractionRoot.appendingPathComponent(entryPath) let standardizedDest = destinationURL.standardizedFileURL.path let standardizedRoot = extractionRoot.standardizedFileURL.path - // 确保最终路径位于解压根目录下 + guard standardizedDest.hasPrefix(standardizedRoot) else { return nil } return destinationURL } - /// 计算 EPUB 的临时解压目录路径 - /// 路径格式:~/Library/Caches/ssreaderview-epub/{slug}-{fileSize}-{modifiedTimestamp}/ func temporaryExtractionDirectory(for epubURL: URL) -> URL { let baseURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first? .appendingPathComponent("ssreaderview-epub", isDirectory: true) @@ -106,14 +89,12 @@ extension RDEPUBParser { return baseURL.appendingPathComponent("\(slug)-\(fileSize)-\(signature)", isDirectory: true) } - /// 重置解析器状态(清除解压路径和出版物数据) func reset() { extractionRootURL = nil opfURL = nil resetPublicationState() } - /// 重置出版物相关状态(metadata、manifest、spine、目录) func resetPublicationState() { metadata = RDEPUBMetadata() manifest = [:] @@ -122,9 +103,8 @@ extension RDEPUBParser { } } -/// container.xml 的 SAX 解析代理,提取 元素的 full-path 属性 private final class ContainerXMLParserDelegate: NSObject, XMLParserDelegate { - /// 解析得到的 OPF 文件相对路径 + private(set) var rootFilePath: String? func parser( diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBParser+Package.swift b/Sources/RDReaderView/EPUBCore/RDEPUBParser+Package.swift index 204c31f..f8625fc 100644 --- a/Sources/RDReaderView/EPUBCore/RDEPUBParser+Package.swift +++ b/Sources/RDReaderView/EPUBCore/RDEPUBParser+Package.swift @@ -1,18 +1,8 @@ -// RDEPUBParser+Package.swift -// OPF(Package Document)解析与 Spine 构建 -// 包含 OPFPackageParserDelegate(SAX 解析 metadata/manifest/spine), -// 从解析结果构建 RDEPUBSpineItem 数组,处理 rendition:layout、page-spread 等属性。 -// 同时定义 XMLName、XMLSection、XMLParserContext 等 XML 解析辅助工具。 import Foundation extension RDEPUBParser { - /// 从 OPF 解析结果构建 spine 阅读顺序数组 - /// 逐项匹配 manifest,解析 href、布局模式(fixed/reflowable)、page-spread 等属性 - /// - Parameters: - /// - packageDocument: OPF 解析结果(含 metadata、manifest、spineReferences) - /// - opfURL: OPF 文件路径 - /// - Returns: spine 项数组 + func buildSpine(from packageDocument: OPFPackageDocument, opfURL: URL) throws -> [RDEPUBSpineItem] { let opfDirectoryURL = opfURL.deletingLastPathComponent() let publicationLayout = packageDocument.metadata.layout @@ -62,47 +52,52 @@ extension RDEPUBParser { } } -/// OPF 包文档的解析结果,包含 metadata、manifest、spine 引用及 NCX/nav 项 struct OPFPackageDocument { var metadata: RDEPUBMetadata var manifest: [RDEPUBManifestItem] var manifestByID: [String: RDEPUBManifestItem] var spineReferences: [OPFSpineReference] - /// NCX 目录文件的 manifest 项(EPUB 2) + var ncxItem: RDEPUBManifestItem? - /// Navigation Document 的 manifest 项(EPUB 3) + var navigationItem: RDEPUBManifestItem? } -/// OPF 中 spine/itemref 元素的解析结果 struct OPFSpineReference { - /// 对应 manifest 中的 item id + var idref: String - /// 是否为线性阅读项("no" 表示辅助内容) + var linear: Bool - /// 属性列表(如 rendition:layout-pre-paginated、page-spread-left 等) + var properties: [String] - /// 页面展开方向(左/右/居中) + var pageSpread: RDEPUBPageSpread? } -/// OPF 文档的 SAX 解析代理 -/// 解析 metadata(identifier/title/creator/language/rendition:layout 等)、 -/// manifest(item 元素)、spine(itemref 元素)三个主要区域 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? - /// 将解析中间结果组装为 OPFPackageDocument 结构体 func packageDocument() -> OPFPackageDocument { if metadata.identifier == nil, let fallbackIdentifier = identifierByID.values.first { metadata.identifier = fallbackIdentifier @@ -251,7 +246,6 @@ final class OPFPackageParserDelegate: NSObject, XMLParserDelegate { xmlContext.endElement() } - /// 当前解析所在的 OPF 区域(metadata/manifest/spine/other) private var currentSection: XMLSection { if xmlContext.containsElement(named: "manifest") { return .manifest @@ -265,7 +259,6 @@ final class OPFPackageParserDelegate: NSObject, XMLParserDelegate { return .other } - /// 处理 元素的文本内容,提取 rendition:layout、rendition:spread、identifier 等 private func applyMetaValue(_ value: String) { let normalizedValue = value.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalizedValue.isEmpty, let property = currentMetaProperty?.lowercased() else { @@ -290,7 +283,6 @@ final class OPFPackageParserDelegate: NSObject, XMLParserDelegate { } } - /// 从属性字典中提取 page-spread 信息(支持 properties 和 page-spread 两种写法) private func pageSpread(from attributes: [String: String]) -> RDEPUBPageSpread? { if let properties = attributes["properties"]?.lowercased() { if properties.contains("page-spread-left") { @@ -320,13 +312,11 @@ final class OPFPackageParserDelegate: NSObject, XMLParserDelegate { return nil } - /// 将 rendition:layout 原始值转换为布局枚举 private func layout(from rawValue: String) -> RDEPUBLayout { rawValue.lowercased().contains("pre-paginated") ? .fixed : .reflowable } } -/// OPF 文档的区域枚举,用于区分当前解析位置 enum XMLSection { case metadata case manifest @@ -334,14 +324,12 @@ enum XMLSection { case other } -/// XML 名称解析工具:提取本地名、分词、提取 refines ID enum XMLName { - /// 从带命名空间前缀的名称中提取本地名(如 "dc:title" -> "title") + static func localName(from rawName: String) -> String { rawName.split(separator: ":").last.map(String.init) ?? rawName } - /// 将空格分隔的属性值拆分为 token 数组 static func tokenize(_ rawValue: String?) -> [String] { guard let rawValue else { return [] } return rawValue @@ -349,7 +337,6 @@ enum XMLName { .map(String.init) } - /// 从 refines 属性值中提取 ID(去掉 "#" 前缀) static func refinedID(from rawValue: String) -> String? { let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) guard trimmed.hasPrefix("#") else { @@ -359,14 +346,12 @@ enum XMLName { } } -/// XML 解析上下文,维护元素栈和当前文本内容 final class XMLParserContext { - /// 元素名称栈,用于追踪嵌套层级 + private var elementStack: [String] = [] - /// 当前元素累积的文本内容 + private var currentCharacters = "" - /// 去除空白后的当前文本 var trimmedCharacters: String { currentCharacters.trimmingCharacters(in: .whitespacesAndNewlines) } diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBParser+ReadingProfile.swift b/Sources/RDReaderView/EPUBCore/RDEPUBParser+ReadingProfile.swift index 03e0e34..b58219c 100644 --- a/Sources/RDReaderView/EPUBCore/RDEPUBParser+ReadingProfile.swift +++ b/Sources/RDReaderView/EPUBCore/RDEPUBParser+ReadingProfile.swift @@ -1,13 +1,8 @@ -// RDEPUBParser+ReadingProfile.swift -// EPUB 阅读配置文件判断 -// 根据 metadata 的 layout 属性和 manifest/spine 内容, -// 将出版物分类为 webFixedLayout(固定版式)、webInteractive(含脚本/多媒体)或 textReflowable(纯文本重排)。 import Foundation extension RDEPUBParser { - /// 判断出版物的阅读配置文件 - /// 优先判断固定版式,其次检测交互内容(脚本/iframe/视频等),默认为文本重排 + public func readingProfile() -> RDEPUBReadingProfile { if metadata.layout == .fixed { return .webFixedLayout @@ -15,9 +10,6 @@ extension RDEPUBParser { return hasInteractiveContent() ? .webInteractive : .textReflowable } - /// 检测出版物是否包含交互内容 - /// 通过 manifest 中的脚本媒体类型和 "scripted" 属性, - /// 以及扫描 HTML 中的 script/iframe/video/audio/canvas/svg/form 等标签 public func hasInteractiveContent() -> Bool { let interactiveMediaTypes = [ "application/javascript", @@ -42,8 +34,6 @@ extension RDEPUBParser { return false } - /// 检测 HTML 是否包含需要走 Web 交互渲染的特征。 - /// 注意:普通小说 EPUB 常用静态 SVG 包封面图,不能仅因 `` 就误判为 interactive。 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 { diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBParser+Resources.swift b/Sources/RDReaderView/EPUBCore/RDEPUBParser+Resources.swift index b33ceac..624f236 100644 --- a/Sources/RDReaderView/EPUBCore/RDEPUBParser+Resources.swift +++ b/Sources/RDReaderView/EPUBCore/RDEPUBParser+Resources.swift @@ -1,18 +1,13 @@ -// RDEPUBParser+Resources.swift -// EPUB 资源查询与 URL 解析 -// 提供按 ID、href 查询 manifest 项,spine 索引与 href 互转, -// 文件路径与 ss-reader:// 协议 URL 互转,封面图提取,HTML 内容读取等能力。 import Foundation import UIKit extension RDEPUBParser { - /// 按 ID 查询 manifest 项 + public func manifestItem(for id: String) -> RDEPUBManifestItem? { manifest[id] } - /// 按 href 查询 manifest 项(忽略 fragment) public func manifestItem(forHref href: String) -> RDEPUBManifestItem? { let target = href.components(separatedBy: "#").first ?? href return manifest.values.first { item in @@ -20,13 +15,11 @@ extension RDEPUBParser { } } - /// 通过 spine 索引获取 href public func href(forSpineIndex index: Int) -> String? { guard spine.indices.contains(index) else { return nil } return spine[index].href } - /// 将相对路径转换为本地文件 URL,并校验是否在解压根目录内(防路径遍历攻击) public func fileURL(forRelativePath relativePath: String) -> URL? { guard let opfDirectoryURL else { return nil } let path = relativePath.components(separatedBy: "#").first ?? relativePath @@ -41,7 +34,6 @@ extension RDEPUBParser { return resolvedURL } - /// 将相对路径转换为 ss-reader://book/ 协议的 URL(用于 WebView 加载资源) public func resourceURL(forRelativePath relativePath: String) -> URL? { let normalizedPath = relativePath.trimmingCharacters(in: CharacterSet(charactersIn: "/")) guard !normalizedPath.isEmpty else { return nil } @@ -55,7 +47,6 @@ extension RDEPUBParser { return components.url } - /// 将 ss-reader://book/ 协议的 URL 还原为本地文件 URL public func fileURL(forResourceURL resourceURL: URL) -> URL? { guard resourceURL.scheme == RDEPUBResourceURLSchemeHandler.scheme, resourceURL.host == RDEPUBResourceURLSchemeHandler.host else { @@ -65,7 +56,6 @@ extension RDEPUBParser { return fileURL(forRelativePath: relativePath) } - /// 提取封面图片(优先查找含 "cover-image" 属性或 ID 含 "cover" 的 manifest 项) public func coverImage() -> UIImage? { let coverCandidates = manifest.values.filter { item in item.properties.contains("cover-image") || item.id.lowercased().contains("cover") @@ -79,7 +69,6 @@ extension RDEPUBParser { return nil } - /// 通过 spine 索引读取 HTML 内容 public func htmlString(forSpineIndex index: Int) -> String? { guard spine.indices.contains(index) else { return nil @@ -87,7 +76,6 @@ extension RDEPUBParser { return htmlString(forRelativePath: spine[index].href) } - /// 通过相对路径读取 HTML 内容 public func htmlString(forRelativePath relativePath: String) -> String? { guard let fileURL = fileURL(forRelativePath: relativePath) else { return nil @@ -95,7 +83,6 @@ extension RDEPUBParser { return try? String(contentsOf: fileURL) } - /// 将 href 相对于目录 URL 解析为标准化的相对路径 func normalize(href: String, relativeTo directoryURL: URL) -> String { guard let resolvedURL = URL(string: href, relativeTo: directoryURL)?.standardizedFileURL else { return href diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBParser+TOC.swift b/Sources/RDReaderView/EPUBCore/RDEPUBParser+TOC.swift index 3bb5ab9..10e2449 100644 --- a/Sources/RDReaderView/EPUBCore/RDEPUBParser+TOC.swift +++ b/Sources/RDReaderView/EPUBCore/RDEPUBParser+TOC.swift @@ -1,14 +1,8 @@ -// RDEPUBParser+TOC.swift -// EPUB 目录解析 -// 支持两种目录格式:NCX(EPUB 2)和 Navigation Document(EPUB 3), -// 优先使用 NCX,其次 Nav Document,最终回退到 spine 列表。 -// 内部使用 TOCTreeBuilder 构建树状目录结构。 import Foundation extension RDEPUBParser { - /// 从 OPF 解析结果中解析目录 - /// 优先尝试 NCX,再尝试 Nav Document,两者都失败则回退到 spine 列表 + func parseTOC(from packageDocument: OPFPackageDocument, opfURL: URL) -> [EPUBTableOfContentsItem] { let opfDirectoryURL = opfURL.deletingLastPathComponent() @@ -31,7 +25,6 @@ extension RDEPUBParser { return spine.map { EPUBTableOfContentsItem(title: $0.title, href: $0.href) } } - /// 解析 NCX(EPUB 2)目录文件 func parseNCXDocumentItems(at ncxURL: URL) -> [EPUBTableOfContentsItem] { guard FileManager.default.fileExists(atPath: ncxURL.path), let parser = XMLParser(contentsOf: ncxURL) else { @@ -49,7 +42,6 @@ extension RDEPUBParser { return delegate.items } - /// 解析 Navigation Document(EPUB 3)目录文件 func parseNavDocumentItems(at navURL: URL, baseURL: URL) -> [EPUBTableOfContentsItem] { guard FileManager.default.fileExists(atPath: navURL.path), let parser = XMLParser(contentsOf: navURL) else { @@ -67,7 +59,6 @@ extension RDEPUBParser { return delegate.items } - /// 标准化目录 href(拆分路径和 fragment,对路径部分做相对路径解析) 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 @@ -91,13 +82,11 @@ extension RDEPUBParser { } } -/// 目录树节点,存储标题、href 和子节点,最终转换为 EPUBTableOfContentsItem 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 { @@ -111,14 +100,12 @@ private final class TOCNode { } } -/// 目录树构建器:通过 beginNode/endNode 操作维护节点栈,构建嵌套目录结构 private final class TOCTreeBuilder { - /// 当前嵌套路径上的节点栈 + private var nodeStack: [TOCNode] = [] - /// 顶层根节点列表 + private var rootNodes: [TOCNode] = [] - /// 获取构建完成的目录项列表 var items: [EPUBTableOfContentsItem] { rootNodes.compactMap { $0.asItem() } } @@ -151,7 +138,6 @@ private final class TOCTreeBuilder { } } -/// NCX 文档的 SAX 解析代理,解析 元素 private final class NCXParserDelegate: NSObject, XMLParserDelegate { private let baseURL: URL private let hrefNormalizer: (String, URL) -> String? @@ -216,7 +202,6 @@ private final class NCXParserDelegate: NSObject, XMLParserDelegate { } } -/// Navigation Document 的 SAX 解析代理,解析含 epub:type="toc" 的 String { let values = cssValues(for: presentation) let backgroundCSS = presentation.themeBackgroundColor.map { "background: \($0) !important;" } ?? "" @@ -106,7 +99,6 @@ public enum RDEPUBStyleSheetBuilder { """ } - /// 生成分页测量用的 CSS(与渲染 CSS 类似但不含 transform/will-change 等优化属性) public static func measurementCSS(for presentation: RDEPUBPresentationStyle) -> String { let values = cssValues(for: presentation) let backgroundCSS = presentation.themeBackgroundColor.map { "background: \($0) !important;" } ?? "" @@ -153,7 +145,6 @@ public enum RDEPUBStyleSheetBuilder { """ } - /// 生成分页测量脚本(JS):注入 CSS 后测量 scrollWidth,计算页数 public static func measurementScript(for presentation: RDEPUBPresentationStyle) -> String { let style = measurementCSS(for: presentation) .replacingOccurrences(of: "\\", with: "\\\\") @@ -185,7 +176,6 @@ public enum RDEPUBStyleSheetBuilder { """ } - /// 从展示样式计算所有 CSS 变量值(视口尺寸、内边距、字号、行高等) private static func cssValues(for presentation: RDEPUBPresentationStyle) -> ( viewportWidth: String, viewportHeight: String, diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBTextAnchor.swift b/Sources/RDReaderView/EPUBCore/RDEPUBTextAnchor.swift index 5210a52..10417ba 100644 --- a/Sources/RDReaderView/EPUBCore/RDEPUBTextAnchor.swift +++ b/Sources/RDReaderView/EPUBCore/RDEPUBTextAnchor.swift @@ -1,26 +1,18 @@ -// RDEPUBTextAnchor.swift -// EPUB 文本锚点和范围锚点 -// 定义精确的文本位置锚点(RDEPUBTextAnchor,含 fileIndex/row/column/chapterOffset/fragmentID), -// 以及文本范围锚点(RDEPUBTextRangeAnchor,起止锚点对)。 -// 支持 Codable 序列化(兼容 spineIndex/fileIndex 两种 key)。 import Foundation -/// 文本锚点,精确定位到 EPUB 中的某个字符位置 -/// 包含文件索引、行号、列号、章节内偏移和可选的 fragment ID public struct RDEPUBTextAnchor: Codable, Equatable { - /// 文件索引(等同于 spineIndex) + public let fileIndex: Int - /// 行号 + public let row: Int - /// 列号(行内偏移) + public let column: Int - /// 章节内的绝对字符偏移量 + public let chapterOffset: Int - /// 最近的 fragment ID(用于 URL 锚点定位) + public let fragmentID: String? - /// spine 索引的别名(与 fileIndex 等价) public var spineIndex: Int { fileIndex } public init( @@ -67,11 +59,10 @@ public struct RDEPUBTextAnchor: Codable, Equatable { } } -/// 文本范围锚点,由起止锚点组成的区间 public struct RDEPUBTextRangeAnchor: Codable, Equatable { - /// 起始锚点 + public let start: RDEPUBTextAnchor - /// 结束锚点 + public let end: RDEPUBTextAnchor public init(start: RDEPUBTextAnchor, end: RDEPUBTextAnchor) { @@ -79,7 +70,6 @@ public struct RDEPUBTextRangeAnchor: Codable, Equatable { self.end = end } - /// 转换为 NSRange(基于 chapterOffset) 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/RDReaderView/EPUBCore/RDEPUBWebView+Configuration.swift index 236a2c5..7be3c6e 100644 --- a/Sources/RDReaderView/EPUBCore/RDEPUBWebView+Configuration.swift +++ b/Sources/RDReaderView/EPUBCore/RDEPUBWebView+Configuration.swift @@ -1,14 +1,9 @@ -// RDEPUBWebView+Configuration.swift -// RDEPUBWebView 的 WKWebView 配置扩展 -// 负责按需创建和配置 WKWebView 实例:注册 JS 桥接消息处理器、 -// 注入用户脚本、设置自定义 URL Scheme 处理器、配置滚动和外观属性。 -// 同时处理选择菜单(拷贝/高亮/批注)和 WebView 销毁清理。 import UIKit import WebKit extension RDEPUBWebView { - /// 按需配置 WKWebView(仅在 Publication 变更或 WebView 不存在时重建) + func configureWebViewIfNeeded(publication: RDEPUBPublication) { let parser = publication.parser let publicationKey = parser.opfURL?.path ?? parser.extractionRootURL?.path ?? UUID().uuidString @@ -73,18 +68,15 @@ extension RDEPUBWebView { RDEPUBWebViewDebug.log(debugScope, message: "configured webView=\(RDEPUBWebViewDebug.webViewID(webView)) publicationKey=\(publicationKey)") } - /// 处理选择菜单动作(拷贝/高亮/批注),通知委托并清除选择 func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction) { delegate?.epubWebView(self, didRequestSelectionAction: action) clearWebSelection() } - /// 清除 WebView 中的文本选择 func clearWebSelection() { webView?.evaluateJavaScript("window.getSelection && window.getSelection().removeAllRanges();") } - /// 销毁 WebView,移除所有消息处理器和引用 func teardownWebView() { cancelFixedLayoutReadyFallback() if let webView { @@ -106,7 +98,7 @@ extension RDEPUBWebView { } extension UIColor { - /// 将 UIColor 转换为 CSS 十六进制颜色字符串(如 "#FF0000") + var ss_hexString: String { var red: CGFloat = 0 var green: CGFloat = 0 diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBWebView+FixedLayout.swift b/Sources/RDReaderView/EPUBCore/RDEPUBWebView+FixedLayout.swift index 1847752..d9c866e 100644 --- a/Sources/RDReaderView/EPUBCore/RDEPUBWebView+FixedLayout.swift +++ b/Sources/RDReaderView/EPUBCore/RDEPUBWebView+FixedLayout.swift @@ -1,13 +1,9 @@ -// RDEPUBWebView+FixedLayout.swift -// RDEPUBWebView 的固定版式渲染扩展 -// 处理固定版式(Fixed Layout)的加载流程:生成 HTML 模板、 -// 计算加载签名(去重)、设置固定版式就绪回退定时器等。 import UIKit import WebKit extension RDEPUBWebView { - /// 便捷方法:加载固定版式的 spread 页面 + public func loadFixedSpread( parser: RDEPUBParser, spread: EPUBFixedSpread, @@ -28,7 +24,6 @@ extension RDEPUBWebView { load(publication: parser.makePublication(), request: request) } - /// 处理固定版式加载:设置状态、生成 HTML、加载到 WebView func handleFixedLayoutLoad( publication: RDEPUBPublication, request: RDEPUBFixedRenderRequest, @@ -55,11 +50,10 @@ extension RDEPUBWebView { 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)/") + baseURL: URL(string: "\(RDEPUBResourceURLSchemeHandler.scheme):///") ) } - /// 生成固定版式加载签名(用于去重相同请求) func fixedLayoutLoadSignature(publicationKey: String, request: RDEPUBFixedRenderRequest) -> String { let searchSignature = [ request.searchPresentation?.keyword ?? "", @@ -87,7 +81,6 @@ extension RDEPUBWebView { ].joined(separator: "#") } - /// 调度固定版式就绪回退定时器(1 秒后如果未收到 ready 消息则手动触发渲染完成) func scheduleFixedLayoutReadyFallback() { let workItem = DispatchWorkItem { [weak self] in RDEPUBWebViewDebug.log(self?.debugScope ?? "ReaderWebView", message: "fixed ready fallback fired") @@ -101,7 +94,6 @@ extension RDEPUBWebView { DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: workItem) } - /// 取消固定版式就绪回退定时器 func cancelFixedLayoutReadyFallback() { fixedLayoutReadyWorkItem?.cancel() fixedLayoutReadyWorkItem = nil diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBWebView+JavaScriptBridge.swift b/Sources/RDReaderView/EPUBCore/RDEPUBWebView+JavaScriptBridge.swift index 236943e..07df618 100644 --- a/Sources/RDReaderView/EPUBCore/RDEPUBWebView+JavaScriptBridge.swift +++ b/Sources/RDReaderView/EPUBCore/RDEPUBWebView+JavaScriptBridge.swift @@ -1,14 +1,9 @@ -// RDEPUBWebView+JavaScriptBridge.swift -// RDEPUBWebView 的 JS 桥接和导航委托扩展 -// 实现 WKNavigationDelegate 处理页面加载生命周期, -// 实现 WKScriptMessageHandler 接收 JS 消息(进度/选择/链接/错误/固定版式就绪), -// 并将内部链接和外部链接路由到委托方法。 import Foundation import WebKit extension RDEPUBWebView { - /// 从 URL 解析内部位置(ss-reader:// 协议的链接) + func internalLocation(from url: URL) -> RDEPUBLocation? { let href = url.path.removingPercentEncoding?.trimmingCharacters(in: CharacterSet(charactersIn: "/")) ?? "" let fragment = url.fragment @@ -16,7 +11,6 @@ extension RDEPUBWebView { return RDEPUBLocation(href: href, progression: 0, fragment: fragment) } - /// 从 JS 消息体中构建当前位置对象 func currentLocation(from body: [String: Any]) -> RDEPUBLocation { let progression = (body["progression"] as? NSNumber)?.doubleValue ?? 0 let lastProgression = (body["lastProgression"] as? NSNumber)?.doubleValue @@ -52,7 +46,6 @@ extension RDEPUBWebView: WKNavigationDelegate { RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFailProvisional", url: webView.url, error: error) } - /// 导航策略决策:区分内部链接(ss-reader://)、外部链接(http/https/mailto/tel)和其他导航 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, @@ -79,7 +72,7 @@ extension RDEPUBWebView: WKNavigationDelegate { } extension RDEPUBWebView: WKScriptMessageHandler { - /// 接收 JS 桥接消息,根据消息类型路由到对应的委托方法 + public func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { RDEPUBWebViewDebug.logMessage(debugScope, webView: message.webView, name: message.name, body: message.body) switch message.name { diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBWebView+Reflowable.swift b/Sources/RDReaderView/EPUBCore/RDEPUBWebView+Reflowable.swift index 264d414..472304a 100644 --- a/Sources/RDReaderView/EPUBCore/RDEPUBWebView+Reflowable.swift +++ b/Sources/RDReaderView/EPUBCore/RDEPUBWebView+Reflowable.swift @@ -1,14 +1,9 @@ -// RDEPUBWebView+Reflowable.swift -// RDEPUBWebView 的重排模式渲染扩展 -// 处理重排(Reflowable)模式的加载流程:构建渲染请求、 -// 生成加载签名(去重)、通过 JS 桥接应用分页样式和高亮、 -// 处理重复请求跳过和已有文档复用等优化逻辑。 import UIKit import WebKit extension RDEPUBWebView { - /// 便捷方法:加载重排模式的指定页面 + public func loadPage( parser: RDEPUBParser, spineIndex: Int, @@ -46,7 +41,6 @@ extension RDEPUBWebView { load(publication: parser.makePublication(), request: request) } - /// 处理重排模式加载:设置状态、去重检查、加载资源或直接应用样式 func handleReflowableLoad( publication: RDEPUBPublication, request: RDEPUBReflowableRenderRequest, @@ -91,7 +85,6 @@ extension RDEPUBWebView { webView.load(URLRequest(url: requestURL)) } - /// 生成重排模式加载签名(包含所有渲染参数的哈希,用于去重) func reflowableLoadSignature(publicationKey: String, request: RDEPUBReflowableRenderRequest) -> String { let targetSignature = [ request.targetLocation?.href ?? "", @@ -139,7 +132,6 @@ extension RDEPUBWebView { ].joined(separator: "#") } - /// 应用分页展示样式:通过 JS 桥接注入 CSS、设置分页参数和高亮,然后触发渲染完成 func applyPresentation() { guard let webView, let currentRenderRequest else { return } guard case .reflowable(let request) = currentRenderRequest else { diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBWebView+Search.swift b/Sources/RDReaderView/EPUBCore/RDEPUBWebView+Search.swift index a75b14e..d1d2c05 100644 --- a/Sources/RDReaderView/EPUBCore/RDEPUBWebView+Search.swift +++ b/Sources/RDReaderView/EPUBCore/RDEPUBWebView+Search.swift @@ -1,6 +1,3 @@ -// RDEPUBWebView+Search.swift -// RDEPUBWebView 的搜索高亮扩展 -// 从当前渲染请求中提取搜索展示信息,生成搜索高亮脚本并注入到 WebView 中执行。 import UIKit import WebKit @@ -14,7 +11,6 @@ extension RDEPUBWebView { UIColor(red: 0.14, green: 0.42, blue: 0.95, alpha: 0.34) } - /// 如果当前渲染请求包含搜索信息,注入搜索高亮脚本到 WebView func applySearchDecorationsIfNeeded(completion: (() -> Void)? = nil) { guard let webView else { completion?() diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBWebView.swift b/Sources/RDReaderView/EPUBCore/RDEPUBWebView.swift index 1523cb2..1f0f4ab 100644 --- a/Sources/RDReaderView/EPUBCore/RDEPUBWebView.swift +++ b/Sources/RDReaderView/EPUBCore/RDEPUBWebView.swift @@ -1,39 +1,30 @@ -// RDEPUBWebView.swift -// EPUB WebView 主类 -// 封装 WKWebView 的 EPUB 渲染能力,是 Resource View 层的核心组件。 -// 统一管理固定版式和重排模式的加载流程,持有当前渲染状态(spineIndex、pageIndex、 -// 主题色、高亮等),并通过委托模式通知上层页面渲染和交互事件。 -// 具体逻辑分散在 +Configuration、+FixedLayout、+Reflowable、+JavaScriptBridge、+Search 扩展中。 import UIKit import WebKit -/// RDEPUBWebView 委托协议,定义 WebView 向上层通知的事件 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) - /// JavaScript 错误事件 + func epubWebView(_ webView: RDEPUBWebView, didLogJavaScriptError message: String) - /// 页面渲染完成事件 + func epubWebViewDidFinishRendering(_ webView: RDEPUBWebView) } -/// 委托协议的默认实现(选择菜单动作为可选) public extension RDEPUBWebViewDelegate { func epubWebView(_ webView: RDEPUBWebView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) {} } -/// 自定义 WKWebView 子类,支持拷贝/高亮/批注选择菜单 final class RDEPUBAnnotationWebView: WKWebView { - /// 选择菜单动作回调 + var onSelectionAction: ((RDEPUBAnnotationMenuAction) -> Void)? override var canBecomeFirstResponder: Bool { @@ -64,63 +55,60 @@ final class RDEPUBAnnotationWebView: WKWebView { } } -/// EPUB WebView 主类,封装 WKWebView 的 EPUB 渲染能力 -/// Resource View 层核心组件,统一管理固定版式和重排模式的渲染 public final class RDEPUBWebView: UIView { - /// 委托对象,接收渲染和交互事件 + public weak var delegate: RDEPUBWebViewDelegate? - /// 渲染完成回调(与 delegate 并行使用) + public var onRendered: (() -> Void)? - /// 内部原生装饰解析回调 + var onDecorationsResolved: (([RDEPUBTextOverlayDecoration]) -> Void)? - /// 当前关联的出版物 var publication: RDEPUBPublication? - /// 当前渲染请求 + var currentRenderRequest: RDEPUBRenderRequest? - /// 内部 WKWebView 实例 + var webView: WKWebView? - /// URL Scheme 处理器 + var schemeHandler: RDEPUBResourceURLSchemeHandler? - /// 已配置的出版物标识(用于判断是否需要重建 WebView) + var configuredPublicationKey: String? - /// 当前加载签名(用于去重相同请求) + var currentLoadSignature: String? - /// 当前 spine 索引 + var currentSpineIndex = 0 - /// 当前资源 href + var currentHref = "" - /// 当前页码(从 0 开始) + 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] = [] - /// 当前固定版式 spread(固定版式时非空) + var fixedSpread: EPUBFixedSpread? - /// 是否为固定版式模式 + var isFixedLayout = false - /// 是否有待处理的进度汇报请求 + var pendingProgressionRequest = false - /// 固定版式就绪回退定时器 + var fixedLayoutReadyWorkItem: DispatchWorkItem? - /// 当前请求是否已完成渲染 + var didRenderCurrentRequest = false - /// 调试日志作用域 + let debugScope = "ReaderWebView" public override init(frame: CGRect) { @@ -141,7 +129,6 @@ public final class RDEPUBWebView: UIView { webView?.frame = bounds } - /// 重置所有状态并销毁 WebView public func reset() { delegate = nil onRendered = nil @@ -167,7 +154,6 @@ public final class RDEPUBWebView: UIView { teardownWebView() } - /// 加载渲染请求:配置 WebView、生成签名、分发到固定版式或重排处理 public func load(publication: RDEPUBPublication, request: RDEPUBRenderRequest) { configureWebViewIfNeeded(publication: publication) guard let webView else { return } @@ -194,7 +180,6 @@ public final class RDEPUBWebView: UIView { } } - /// 生成页面加载签名 func pageLoadSignature( publicationKey: String, request: RDEPUBRenderRequest @@ -207,7 +192,6 @@ public final class RDEPUBWebView: UIView { } } - /// 标记渲染完成:通知委托和回调,延迟 100ms 确保布局稳定 func rendered() { guard !didRenderCurrentRequest else { return } didRenderCurrentRequest = true diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBWebViewDebug.swift b/Sources/RDReaderView/EPUBCore/RDEPUBWebViewDebug.swift index 23e560f..410a21e 100644 --- a/Sources/RDReaderView/EPUBCore/RDEPUBWebViewDebug.swift +++ b/Sources/RDReaderView/EPUBCore/RDEPUBWebViewDebug.swift @@ -1,15 +1,9 @@ -// RDEPUBWebViewDebug.swift -// WebView 调试日志工具 -// 提供统一的调试日志输出接口,支持导航事件、JS 执行、 -// 消息接收、URL Scheme 任务等场景的日志记录。 -// DEBUG 模式默认开启,Release 模式默认关闭,可通过 UserDefaults 覆盖。 import Foundation import WebKit -/// WebView 调试日志工具集 enum RDEPUBWebViewDebug { - /// 调试日志是否启用(DEBUG 默认开启,可通过 UserDefaults "RDEPUBWebViewDebugEnabled" 覆盖) + static var isEnabled: Bool = { if let configured = UserDefaults.standard.object(forKey: "RDEPUBWebViewDebugEnabled") as? Bool { return configured @@ -21,8 +15,6 @@ enum RDEPUBWebViewDebug { #endif }() - /// 详细日志模式是否启用(默认关闭,可通过 UserDefaults "RDEPUBWebViewVerboseEnabled" 开启) - /// 开启后 logMessage 将输出完整消息体,关闭时仅输出消息名、字段名和文本长度 static var isVerboseEnabled: Bool = { if let configured = UserDefaults.standard.object(forKey: "RDEPUBWebViewVerboseEnabled") as? Bool { return configured @@ -30,8 +22,6 @@ enum RDEPUBWebViewDebug { return false }() - /// 是否允许开启 inspectable。 - /// 默认关闭,仅在阅读器配置显式允许或 UserDefaults 覆盖时开启。 static var isInspectableEnabled: Bool = { if let configured = UserDefaults.standard.object(forKey: "RDEPUBInspectableWebViewsEnabled") as? Bool { return configured @@ -39,25 +29,21 @@ enum RDEPUBWebViewDebug { return false }() - /// 将阅读器配置同步到调试策略,保证默认安全策略由配置显式控制。 static func applyDebugPolicy(inspectableEnabled: Bool, verboseLoggingEnabled: Bool) { isInspectableEnabled = inspectableEnabled isVerboseEnabled = verboseLoggingEnabled } - /// 获取 WebView 的十六进制标识符(用于日志区分多个 WebView 实例) 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("[RDReaderWK][\(scope)] \(message)") } - /// 输出导航事件日志(含 WebView ID、事件类型、URL、错误信息) static func logNavigationEvent(_ scope: String, webView: WKWebView?, event: String, url: URL? = nil, error: Error? = nil) { guard isEnabled else { return } let webViewToken = webViewID(webView) @@ -69,13 +55,11 @@ enum RDEPUBWebViewDebug { } } - /// 输出 JavaScript 执行日志 static func logJavaScript(_ scope: String, webView: WKWebView?, action: String, details: String) { guard isEnabled else { return } log(scope, message: "webView=\(webViewID(webView)) js=\(action) \(details)") } - /// 输出 JS 消息接收日志(默认仅输出字段名和文本长度,verbose 模式输出完整消息体) static func logMessage(_ scope: String, webView: WKWebView?, name: String, body: Any) { guard isEnabled else { return } if isVerboseEnabled { @@ -97,7 +81,6 @@ enum RDEPUBWebViewDebug { } } - /// 输出 URL Scheme 任务日志(含请求 URL、文件 URL、事件类型、错误信息) static func logSchemeTask(_ scope: String, requestURL: URL?, fileURL: URL? = nil, event: String, error: Error? = nil) { guard isEnabled else { return } let requestText = summarizedURL(requestURL) @@ -109,7 +92,6 @@ enum RDEPUBWebViewDebug { } } - /// 截取 URL 的摘要信息(ss-reader:// 协议显示完整 URL,其他显示最后路径组件) static func summarizedURL(_ url: URL?) -> String { guard let url else { return "nil" } if let scheme = url.scheme, scheme == RDEPUBResourceURLSchemeHandler.scheme { diff --git a/Sources/RDReaderView/EPUBCore/Resources/epub-fixed-layout.html b/Sources/RDReaderView/EPUBCore/Resources/epub-fixed-layout.html index 96b6315..ddcc026 100644 --- a/Sources/RDReaderView/EPUBCore/Resources/epub-fixed-layout.html +++ b/Sources/RDReaderView/EPUBCore/Resources/epub-fixed-layout.html @@ -89,6 +89,60 @@ } } + function parseCssPixelValue(value) { + if (!value) { return 0; } + var match = String(value).match(/([0-9]+(?:\.[0-9]+)?)px?/); + return match ? Number.parseFloat(match[1]) : 0; + } + + function sizeFromElement(element) { + if (!element) { return null; } + var width = parseCssPixelValue(element.style && element.style.width); + var height = parseCssPixelValue(element.style && element.style.height); + if (!width || !height) { + width = parseCssPixelValue(element.getAttribute && element.getAttribute('width')); + height = parseCssPixelValue(element.getAttribute && element.getAttribute('height')); + } + if (!width || !height) { + var rect = element.getBoundingClientRect ? element.getBoundingClientRect() : null; + if (rect) { + width = width || rect.width; + height = height || rect.height; + } + } + if (width > 1 && height > 1) { + return { width: width, height: height }; + } + return null; + } + + function parsePageSizeFromDocumentBox(iframe) { + try { + var doc = iframe.contentWindow.document; + return sizeFromElement(doc.body) || + sizeFromElement(doc.documentElement) || + sizeFromElement(doc.querySelector('[id$="_hype_container"]')) || + sizeFromElement(doc.querySelector('iframe')) || + null; + } catch (error) { + return null; + } + } + + function parsePageSizeFromNestedFrame(iframe) { + try { + var nestedFrame = iframe.contentWindow.document.querySelector('iframe'); + if (!nestedFrame || !nestedFrame.contentWindow || !nestedFrame.contentWindow.document) { + return null; + } + return parsePageSizeFromViewportMetaTag(nestedFrame) || + parsePageSizeFromDocumentBox(nestedFrame) || + parsePageSizeFromEmbeddedImage(nestedFrame); + } catch (error) { + return null; + } + } + function parsePageSizeFromEmbeddedImage(iframe) { try { var img = iframe.contentWindow.document.querySelector('img'); @@ -176,9 +230,14 @@ function onLoad() { iframe.__ssPageSize = parsePageSizeFromViewportMetaTag(iframe) || + parsePageSizeFromDocumentBox(iframe) || + parsePageSizeFromNestedFrame(iframe) || parsePageSizeFromEmbeddedImage(iframe) || pageViewportSize(iframe); layoutPage(iframe); + window.setTimeout(function() { + layoutPage(iframe); + }, 250); finalizeLoad(); } @@ -204,4 +263,4 @@ })(); - \ No newline at end of file + diff --git a/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBBuildDiagnosticsReporter.swift b/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBBuildDiagnosticsReporter.swift index 6bbb0e4..e169d71 100644 --- a/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBBuildDiagnosticsReporter.swift +++ b/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBBuildDiagnosticsReporter.swift @@ -1,16 +1,18 @@ import Foundation -/// Builds diagnostics and human-readable summaries for a text book build. 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") }) @@ -22,6 +24,7 @@ struct RDEPUBBuildDiagnosticsReporter { 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) } @@ -38,11 +41,14 @@ struct RDEPUBBuildDiagnosticsReporter { 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)) ) } diff --git a/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBChapterTailNormalizer.swift b/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBChapterTailNormalizer.swift index 0a1fa24..7a792a1 100644 --- a/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBChapterTailNormalizer.swift +++ b/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBChapterTailNormalizer.swift @@ -1,12 +1,13 @@ import Foundation -/// Normalizes suspicious trailing or whitespace-only page frames after pagination. struct RDEPUBChapterTailNormalizer { + func normalize( _ frames: [RDEPUBTextLayoutFrame], content: NSAttributedString, href: String ) -> [RDEPUBTextLayoutFrame] { + guard frames.count > 1 else { return frames } var normalized = frames @@ -16,10 +17,12 @@ struct RDEPUBChapterTailNormalizer { 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 { + #if DEBUG print("[EPUB][Pagination] href=\(href) dropped leading/intermediate whitespace frame \(NSStringFromRange(frame.contentRange))") #endif @@ -73,6 +76,7 @@ struct RDEPUBChapterTailNormalizer { previousFrame: RDEPUBTextLayoutFrame, in content: NSAttributedString ) -> Bool { + guard trailingFrame.contentRange.length > 0, NSMaxRange(previousFrame.contentRange) == trailingFrame.contentRange.location else { return false @@ -80,6 +84,7 @@ struct RDEPUBChapterTailNormalizer { 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 { @@ -94,6 +99,7 @@ struct RDEPUBChapterTailNormalizer { _ previousFrame: RDEPUBTextLayoutFrame, with trailingFrame: RDEPUBTextLayoutFrame ) -> RDEPUBTextLayoutFrame { + let mergedRange = NSRange( location: previousFrame.contentRange.location, length: NSMaxRange(trailingFrame.contentRange) - previousFrame.contentRange.location @@ -109,6 +115,7 @@ struct RDEPUBChapterTailNormalizer { 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"] @@ -121,6 +128,7 @@ struct RDEPUBChapterTailNormalizer { ) -> 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) diff --git a/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBPaginationCacheCoordinator.swift b/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBPaginationCacheCoordinator.swift index 50893e0..d2a3cf8 100644 --- a/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBPaginationCacheCoordinator.swift +++ b/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBPaginationCacheCoordinator.swift @@ -1,8 +1,9 @@ import UIKit -/// Keeps pagination cache key generation and cache IO in one BuildPipeline role. struct RDEPUBPaginationCacheCoordinator { + private let cache: RDEPUBTextBookCache? + private let layoutConfig: RDEPUBTextLayoutConfig init(cache: RDEPUBTextBookCache?, layoutConfig: RDEPUBTextLayoutConfig) { @@ -32,11 +33,13 @@ struct RDEPUBPaginationCacheCoordinator { 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))) ) } diff --git a/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookBuilder.swift b/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookBuilder.swift index 7497c8c..f9e22ea 100644 --- a/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookBuilder.swift +++ b/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookBuilder.swift @@ -1,6 +1,5 @@ import UIKit -/// EPUB 文本书籍构建器,负责将 EPUB publication 渲染、分页并组装为 `RDEPUBTextBook`。 public final class RDEPUBTextBookBuilder { private let renderer: RDEPUBTextRenderer private let cache: RDEPUBTextBookCache? @@ -12,20 +11,14 @@ public final class RDEPUBTextBookBuilder { 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) - /// 创建书籍构建器。 - /// - Parameters: - /// - renderer: 文本渲染器 - /// - cache: 分页缓存(可选) - /// - layoutConfig: 页面布局配置 public init( renderer: RDEPUBTextRenderer, cache: RDEPUBTextBookCache? = nil, @@ -42,7 +35,6 @@ public final class RDEPUBTextBookBuilder { self.diagnosticsReporter = RDEPUBBuildDiagnosticsReporter() } - /// 默认构造器,使用 DTCoreText 渲染器 public convenience init() { self.init(renderer: RDEPUBDTCoreTextRenderer()) } @@ -51,7 +43,6 @@ public final class RDEPUBTextBookBuilder { ProcessInfo.processInfo.arguments.contains("--demo-pagination-debug") } - /// 生成最近一次构建的语义摘要,用于 Phase 7 质量检测日志 public func phase7SemanticSummary(title: String? = nil) -> String? { diagnosticsReporter.phase7SemanticSummary( title: title, @@ -59,16 +50,6 @@ public final class RDEPUBTextBookBuilder { ) } - /// 核心构建方法:从 EPUB publication 构建分页书籍。 - /// - /// 流程: - /// 1. 生成缓存键,尝试加载分页缓存 - /// 2. 遍历 spine 中的线性 HTML 章节 - /// 3. 渲染每章 HTML → NSAttributedString - /// 4. 跳过空白的封面/扉页章节 - /// 5. 分页:缓存命中则使用缓存的页范围,否则调用 CoreText 分页引擎 - /// 6. 尾页规范化:丢弃纯空白尾页、合并过短尾页 - /// 7. 构建 RDEPUBTextBook 并保存分页缓存 public func build( parser: RDEPUBParser, publication: RDEPUBPublication, @@ -88,7 +69,6 @@ public final class RDEPUBTextBookBuilder { let buildStart = CFAbsoluteTimeGetCurrent() - // 缓存查询 — WXRead 模式:只加载每章的页范围,不缓存富文本 let bookID = publication.metadata.identifier ?? publication.metadata.title let cacheKey = cacheCoordinator.cacheKey(bookID: bookID, pageSize: pageSize, style: style) let cachedPagination = cacheCoordinator.load(key: cacheKey) @@ -134,7 +114,6 @@ public final class RDEPUBTextBookBuilder { sampler.totalBuildDuration = CFAbsoluteTimeGetCurrent() - buildStart - // 保存分页缓存(只缓存页范围和分页原因,不缓存富文本) cacheCoordinator.save(chapters: chapters, key: cacheKey) #if DEBUG @@ -145,7 +124,6 @@ public final class RDEPUBTextBookBuilder { return book } - /// 构建指定 spine 章节,用于大书快速首屏和后台增量补齐。 public func buildChapter( parser: RDEPUBParser, publication: RDEPUBPublication, @@ -192,6 +170,7 @@ public final class RDEPUBTextBookBuilder { let request = RDEPUBTextTypesetterPipeline().makeRequest( from: RDEPUBTypesettingInput( href: item.href, + spineIndex: spineIndex, title: chapterTitle, rawHTML: rawHTML, baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent(), @@ -281,11 +260,13 @@ public final class RDEPUBTextBookBuilder { } let paginateDuration = CFAbsoluteTimeGetCurrent() - paginateStart + let normalizedFrames = tailNormalizer.normalize( layoutFrames, content: content, href: item.href ) + let effectiveFrames = normalizedFrames.isEmpty && content.length > 0 ? [ RDEPUBTextLayoutFrame( @@ -339,6 +320,12 @@ public final class RDEPUBTextBookBuilder { 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 ) @@ -365,9 +352,6 @@ public final class RDEPUBTextBookBuilder { ) } - // MARK: - 章节标题解析 - - /// 从目录表中查找章节标题,找不到则回退到 spine item 的 title 或 href 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 @@ -378,16 +362,12 @@ public final class RDEPUBTextBookBuilder { return trimmedTitle.isEmpty ? item.href : trimmedTitle } - /// 递归展开嵌套目录为扁平列表 private func flattenedTOCItems(from items: [EPUBTableOfContentsItem]) -> [EPUBTableOfContentsItem] { items.flatMap { item in [item] + flattenedTOCItems(from: item.children) } } - // MARK: - 章节过滤 - - /// 判断是否应跳过该章节(空白的封面/扉页,无文本且无附件) private func shouldSkipChapter(item: RDEPUBSpineItem, content: NSAttributedString, text: String) -> Bool { let lowercasedHref = item.href.lowercased() var hasAttachment = false @@ -404,9 +384,6 @@ public final class RDEPUBTextBookBuilder { return false } - // MARK: - 附件统计 - - /// 统计富文本中的附件数量 private func attachmentCount(in content: NSAttributedString) -> Int { guard content.length > 0 else { return 0 } var count = 0 @@ -418,7 +395,6 @@ public final class RDEPUBTextBookBuilder { return count } - /// 获取富文本中所有附件的 NSRange 列表 private func attachmentRanges(in content: NSAttributedString) -> [NSRange] { guard content.length > 0 else { return [] } var ranges: [NSRange] = [] @@ -430,9 +406,6 @@ public final class RDEPUBTextBookBuilder { return ranges } - // MARK: - 封面章节检测 - - /// 判断是否为纯图片封面章节(href 包含 cover 且有附件但几乎无文本) private func isAttachmentOnlyCoverChapter( item: RDEPUBSpineItem, content: NSAttributedString, @@ -445,6 +418,7 @@ public final class RDEPUBTextBookBuilder { } private func debugPreview(for content: NSAttributedString, limit: Int) -> String { + let collapsed = content.string .replacingOccurrences(of: "\n", with: " ") .replacingOccurrences(of: "\r", with: " ") diff --git a/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookCache.swift b/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookCache.swift index 3760776..1f007cd 100644 --- a/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookCache.swift +++ b/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookCache.swift @@ -1,19 +1,14 @@ import Foundation import CryptoKit -// MARK: - 分页缓存数据模型(WXRead 模式:只缓存页范围,不缓存富文本) - -/// 单个章节的分页元数据缓存,用于磁盘持久化。 -/// -/// 对标 WXRead 的 WRChapterPageCount 缓存策略:只存储每页的 NSRange + 分页原因, -/// 不缓存完整的 NSAttributedString。缓存命中时重新渲染 HTML,但跳过 CoreText 分页步骤。 public struct RDEPUBTextChapterPaginationCache: Equatable { - /// 章节文件相对路径 + public var href: String - /// 每页在章节富文本中的字符范围 + public var pageRanges: [NSRange] - /// 每页的分页原因(语义边界、帧限制等) + public var breakReasons: [RDEPUBTextPageBreakReason] + public var semanticHints: [RDEPUBTextSemanticHint] public init( @@ -29,11 +24,8 @@ public struct RDEPUBTextChapterPaginationCache: Equatable { } } -// MARK: - NSCoding 归档层(只使用字符串和整数类型) - -/// 整本书的分页缓存归档,用于 NSKeyedArchiver 序列化。 -/// 包含所有章节的分页归档数据。 final class PaginationCacheArchive: NSObject, NSSecureCoding { + static var supportsSecureCoding: Bool { true } let chapters: [ChapterPaginationArchive] @@ -52,20 +44,20 @@ final class PaginationCacheArchive: NSObject, NSSecureCoding { } } -/// 单个章节的分页归档,将 NSRange 拆分为 location/length 数组以便 NSSecureCoding 编码。 final class ChapterPaginationArchive: NSObject, NSSecureCoding { + static var supportsSecureCoding: Bool { true } + let href: String - /// 页范围的起始位置数组(与 rangeLengths 一一对应) + 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) } @@ -97,7 +89,6 @@ final class ChapterPaginationArchive: NSObject, NSSecureCoding { self.semanticHints = semanticHints } - /// 将归档数据转换回缓存模型 func toCache() -> RDEPUBTextChapterPaginationCache { let pageRanges = zip(rangeLocations, rangeLengths).map { loc, len in NSRange(location: loc.intValue, length: len.intValue) @@ -111,28 +102,14 @@ final class ChapterPaginationArchive: NSObject, NSSecureCoding { } } -// MARK: - 分页缓存管理器 - -/// 磁盘持久化的分页缓存层,对标 WXRead 的 WRChapterPageCount 缓存模式。 -/// -/// 缓存策略: -/// - 缓存键 = SHA256(书籍ID + 字号 + 行距 + 内边距 + 页面尺寸 + schema版本) -/// - 缓存值 = 每章的页 NSRange 列表 + 分页原因(NSKeyedArchiver 序列化) -/// - 富文本不缓存:缓存命中时重新渲染 HTML,但跳过 CoreText 分页步骤 -/// - 线程安全:所有读写操作通过 serial DispatchQueue 串行执行 public final class RDEPUBTextBookCache { - /// 缓存模式版本号,变更时旧缓存自动失效 - // 分页算法调整后需要提升版本,避免继续复用旧页范围缓存。 public var schemaVersion: Int = 6 - /// 串行队列,保证缓存读写的线程安全 private let queue = DispatchQueue(label: "com.rdreader.textbookcache", qos: .utility) - /// 缓存文件目录 + private let cacheDirectory: URL - /// 初始化缓存管理器,自动创建缓存目录 - /// - Parameter subdirectory: Caches 目录下的子目录名 public init(subdirectory: String = "RDEPUBTextBookCache") { let baseURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first? .appendingPathComponent(subdirectory, isDirectory: true) @@ -141,13 +118,6 @@ public final class RDEPUBTextBookCache { try? FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true) } - // MARK: - 缓存键生成 - // 对标 WRChapterPageCount.currentCacheKeyWithBookId: - // 编码 bookID + fontSize + lineHeightMultiple + contentInsets + pageSize - - /// 生成缓存文件名(SHA256 哈希 + ".cache" 后缀)。 - /// - /// 任意布局参数变更都会导致缓存键不同,从而自动失效。 public func cacheKey( bookID: String, fontSize: CGFloat, @@ -162,10 +132,6 @@ public final class RDEPUBTextBookCache { return hex + ".cache" } - // MARK: - 加载/保存(只缓存分页元数据) - - /// 从磁盘加载缓存的分页元数据,返回以章节 href 为键的字典。 - /// 缓存未命中或反序列化失败时返回 nil。 public func load(key: String) -> [String: RDEPUBTextChapterPaginationCache]? { queue.sync { let fileURL = cacheDirectory.appendingPathComponent(key) @@ -203,7 +169,6 @@ public final class RDEPUBTextBookCache { } } - /// 将分页元数据保存到磁盘(原子写入,防止损坏) public func save(_ chapters: [RDEPUBTextChapterPaginationCache], key: String) { queue.sync { let fileURL = cacheDirectory.appendingPathComponent(key) @@ -223,9 +188,6 @@ public final class RDEPUBTextBookCache { } } - // MARK: - 缓存失效 - - /// 清除所有缓存文件 public func invalidateAll() { queue.sync { let fileManager = FileManager.default diff --git a/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookModels.swift b/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookModels.swift index d70e724..3f7f0e6 100644 --- a/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookModels.swift +++ b/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookModels.swift @@ -1,93 +1,99 @@ import UIKit -// MARK: - 章节分页诊断数据 - -/// 单个章节的分页诊断信息,用于调试和质量检测。 -/// 记录页数、分页原因、附件/块级元素统计等。 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] - /// 采样诊断日志(最多 4 条) + public var sampleNotes: [String] } -// MARK: - 章节构建结果 - -/// 单章构建结果,用于大书快速进入阅读器和后台增量补齐。 public struct RDEPUBTextChapterBuildResult { + public var chapter: RDEPUBTextChapter + public var resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic] + public var paginationDiagnostic: RDEPUBTextChapterPaginationDiagnostic + public var performanceSample: RDEPUBTextPerformanceSample + public var cacheHit: Bool } -// MARK: - 页面数据模型 - -/// 分页后的单页数据,包含全书绝对页码、所属章节、内容范围等。 -/// -/// 每个 `RDEPUBTextPage` 由 `RDEPUBTextLayoutFrame` 生成, -/// `contentRange` 标记了该页在章节富文本中的字符范围。 public struct RDEPUBTextPage: Equatable { - /// 全书绝对页码(从 0 开始) + public var absolutePageIndex: Int + public var chapterIndex: Int + public var spineIndex: Int + public var href: String + public var chapterTitle: String - /// 该页在所属章节中的相对页码(从 0 开始) + public var pageIndexInChapter: Int + public var totalPagesInChapter: Int - /// 所属章节的完整富文本内容(用于跨页查询) + public var chapterContent: NSAttributedString - /// 该页的富文本片段 + public var content: NSAttributedString - /// 该页在章节富文本中的范围 + public var contentRange: NSRange + public var pageStartOffset: Int + public var pageEndOffset: Int - /// 页的元数据(分页原因、附件、语义标记等) + public var metadata: RDEPUBTextPageMetadata } -// MARK: - 章节数据模型 - -/// 渲染并分页后的单个章节,包含完整富文本和分页结果。 public struct RDEPUBTextChapter: Equatable { + public var chapterIndex: Int + public var spineIndex: Int + public var href: String + public var title: String - /// 章节完整富文本内容 + public var attributedContent: NSAttributedString - /// fragment ID → 字符偏移量映射(用于锚点定位) + public var fragmentOffsets: [String: Int] + + public var cfiMap: RDEPUBCFIMap? + public var pageBreakReasons: [RDEPUBTextPageBreakReason] + public var pages: [RDEPUBTextPage] } -// MARK: - 分页书籍模型 - -/// 整本 EPUB 的分页书籍模型,包含所有章节、页面和全局索引表。 -/// -/// 这是 EPUBTextRendering 层的最终产物,由 `RDEPUBTextBookBuilder.build()` 生成。 -/// 通过 `chapterData(for:)` 或 `chapterData(atChapterIndex:)` 获取 `RDEPUBChapterData` 进行查询。 public struct RDEPUBTextBook { + public var chapters: [RDEPUBTextChapter] + public var pages: [RDEPUBTextPage] - /// 全局索引表:fileIndex/row/column → 绝对字符偏移量 + public let indexTable: RDEPUBTextIndexTable - /// 对标 WXRead 的位置转换器(文件位置 <-> 全书字符位置 <-> 页码) + public var positionConverter: RDEPUBTextPositionConverter { RDEPUBTextPositionConverter(book: self) } @@ -102,31 +108,26 @@ public struct RDEPUBTextBook { lhs.chapters == rhs.chapters && lhs.pages == rhs.pages } - /// 按 href 获取章节的数据访问层(包含索引表) 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) } - /// 按 spine 索引获取章节的数据访问层。 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) } - /// 按绝对页码(从 1 开始)获取章节的数据访问层。 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, @@ -138,7 +139,6 @@ public struct RDEPUBTextBook { return chapterData(for: normalizedLocation.href) } - /// 全书章节信息快照;对标 WXRead 由章节模型直接提供章节元数据。 public var chapterInfos: [EPUBChapterInfo] { chapters.map { chapter in EPUBChapterInfo( @@ -149,7 +149,6 @@ public struct RDEPUBTextBook { } } - /// 按页码(从 1 开始)获取对应页面 public func page(at pageNumber: Int) -> RDEPUBTextPage? { guard pageNumber > 0, pages.indices.contains(pageNumber - 1) else { return nil @@ -157,12 +156,6 @@ public struct RDEPUBTextBook { return pages[pageNumber - 1] } - /// 根据持久化位置计算对应页码(从 1 开始)。 - /// - /// 解析优先级: - /// 1. rangeAnchor 锚点定位 - /// 2. fragment 片段 ID - /// 3. navigationProgression 进度百分比回退 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 { @@ -182,7 +175,6 @@ public struct RDEPUBTextBook { return chapterData.pageNumber(for: normalizedLocation) } - /// 根据页码生成持久化位置(RDEPUBLocation),包含起止锚点 public func location(forPageNumber pageNumber: Int, bookIdentifier: String?) -> RDEPUBLocation? { guard let chapterData = chapterData(forPageNumber: pageNumber), let page = page(at: pageNumber) else { diff --git a/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBuildPipelineInterfaces.swift b/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBuildPipelineInterfaces.swift index 82d31ad..19074f6 100644 --- a/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBuildPipelineInterfaces.swift +++ b/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBuildPipelineInterfaces.swift @@ -1,10 +1,8 @@ -// RDEPUBTextBuildPipelineInterfaces.swift -// EPUB 文本构建管线接口定义,包括全书构建、章节渲染与分页管线。 import UIKit -/// EPUB 全书文本构建接口,将 EPUB 出版物解析为可分页的文本书籍。 protocol RDEPUBTextBookBuilding { + func build( parser: RDEPUBParser, publication: RDEPUBPublication, @@ -13,41 +11,25 @@ protocol RDEPUBTextBookBuilding { ) throws -> RDEPUBTextBook } -/// 章节渲染管线,将章节渲染请求委托给底层文本渲染器。 struct RDEPUBChapterRenderPipeline { private let renderer: RDEPUBTextRenderer - /// 初始化渲染管线。 - /// - Parameter renderer: 文本渲染器实例 init(renderer: RDEPUBTextRenderer) { self.renderer = renderer } - /// 渲染单个章节,返回渲染后的富文本内容。 - /// - Parameter request: 章节渲染请求 - /// - Returns: 渲染完成的章节内容 func render(_ request: RDEPUBTextChapterRenderRequest) throws -> RDEPUBRenderedChapterContent { try renderer.renderChapter(request: request) } } -/// 章节分页管线,将渲染后的富文本按页面尺寸拆分为文本帧数组。 struct RDEPUBChapterPaginationPipeline { private let frameFactory: RDEPUBPageFrameBuilding - /// 初始化分页管线。 - /// - Parameter frameFactory: 页面帧工厂,默认使用 CoreText 实现 init(frameFactory: RDEPUBPageFrameBuilding = RDEPUBCoreTextPageFrameFactory()) { self.frameFactory = frameFactory } - /// 将富文本内容分页,返回页面帧数组。 - /// - Parameters: - /// - content: 待分页的富文本 - /// - pageSize: 页面尺寸 - /// - config: 排版配置 - /// - fragmentOffsets: fragment 锚点偏移映射 - /// - Returns: 页面帧数组 func frames( for content: NSAttributedString, pageSize: CGSize, diff --git a/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextPerformanceSampler.swift b/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextPerformanceSampler.swift index fd4fba8..ac974d5 100644 --- a/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextPerformanceSampler.swift +++ b/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextPerformanceSampler.swift @@ -1,20 +1,17 @@ import Foundation -// MARK: - 性能采样数据模型 - -/// 单个章节的性能采样数据,记录渲染和分页的耗时。 public struct RDEPUBTextPerformanceSample: Equatable { - /// 章节文件路径 + public var chapterHref: String - /// HTML 渲染耗时(秒) + public var renderDuration: TimeInterval - /// CoreText 分页耗时(秒) + public var paginateDuration: TimeInterval - /// 分页后的页数 + public var pageCount: Int - /// 富文本字符长度 + public var attributedStringLength: Int - /// 是否命中分页缓存 + public var cacheHit: Bool public init( @@ -34,21 +31,14 @@ public struct RDEPUBTextPerformanceSample: Equatable { } } -// MARK: - 性能采样器 - -/// 书籍构建过程的性能采样器,用于监控每章的渲染和分页耗时。 -/// -/// 由 `RDEPUBTextBookBuilder` 在构建过程中使用,每章记录一个采样点, -/// 构建完成后输出汇总报告。 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 @@ -56,7 +46,6 @@ public final class RDEPUBTextPerformanceSampler { #endif } - /// 生成性能汇总报告,包含总渲染/分页耗时和缓存命中率 public func summary() -> String { let totalRender = samples.reduce(0) { $0 + $1.renderDuration } let totalPaginate = samples.reduce(0) { $0 + $1.paginateDuration } @@ -64,13 +53,11 @@ public final class RDEPUBTextPerformanceSampler { 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/RDReaderView/EPUBTextRendering/Pagination/RDEPUBChapterPageCounter.swift b/Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBChapterPageCounter.swift index f1bafe3..b1bdff4 100644 --- a/Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBChapterPageCounter.swift +++ b/Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBChapterPageCounter.swift @@ -5,24 +5,20 @@ import UIKit import DTCoreText #endif -/// 章节分页计数器:顶层编排循环,将富文本按页面尺寸拆分为多帧。 -/// -/// 分页策略优先级(从高到低): -/// 1. avoidPageBreakInside — 不在保护块内分页(WXRead 行级回退扫描) -/// 2. keepWithNext — 标题等元素需与下一段同页 -/// 3. 语义边界 — pageBreakBefore/After 等显式分页标记 -/// 4. pageRelate — 微信读书式的跨页关联元素 -/// 5. 附件边界 — 块级附件应整体移到下一页 -/// 6. 帧限制 — 默认按 CoreText 可视范围分页 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 - /// DTCoreText 路径可直接消费的单矩形布局区域 private let dtLayoutRect: CGRect init(factory: RDEPUBCoreTextPageFrameFactory) { @@ -35,7 +31,6 @@ struct RDEPUBChapterPageCounter { 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 [] @@ -48,8 +43,6 @@ struct RDEPUBChapterPageCounter { #endif } - // MARK: - CoreText 分页路径(回退方案) - private func layoutFramesUsingCoreText(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame] { guard attributedString.length > 0, pageSize.width > 0, pageSize.height > 0 else { return [] @@ -129,9 +122,8 @@ struct RDEPUBChapterPageCounter { return frames } - // MARK: - DTCoreText 分页路径(首选方案) - #if canImport(DTCoreText) + private func layoutFramesUsingDTCoreText(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame] { guard config.numberOfColumns == 1 else { return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets) @@ -252,9 +244,6 @@ struct RDEPUBChapterPageCounter { } #endif - // MARK: - WXRead 分页对齐 - - /// 读取 CoreText frame 当前可见的字符范围;多栏 path 下也能返回整页可见区。 private func proposedVisibleRange( from frame: CTFrame, start location: Int, diff --git a/Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBCoreTextPageFrameFactory.swift b/Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBCoreTextPageFrameFactory.swift index d605b0f..893fcdc 100644 --- a/Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBCoreTextPageFrameFactory.swift +++ b/Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBCoreTextPageFrameFactory.swift @@ -5,13 +5,14 @@ import UIKit import DTCoreText #endif -/// CoreText 帧工厂:负责帧创建、行级裁剪、属性查询和诊断构建。 -/// -/// 叶节点组件,被 RDEPUBChapterPageCounter 和 RDEPUBPageBreakPolicy 调用。 struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding { + let attributedString: NSAttributedString + let pageSize: CGSize + let config: RDEPUBTextLayoutConfig + private let pageBreakPolicy: RDEPUBPageBreakPolicy init(attributedString: NSAttributedString, pageSize: CGSize, config: RDEPUBTextLayoutConfig = .default) { @@ -21,13 +22,10 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding { self.pageBreakPolicy = RDEPUBPageBreakPolicy(attributedString: attributedString) } - /// 协议要求的便捷初始化(使用默认配置) init() { self.init(attributedString: NSAttributedString(), pageSize: .zero, config: .default) } - // MARK: - RDEPUBPageFrameBuilding 协议 - func makeFrames( attributedString: NSAttributedString, pageSize: CGSize, @@ -39,9 +37,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding { return counter.layoutFrames(fragmentOffsets: fragmentOffsets) } - // MARK: - 帧构建 - - /// 从列矩形构建 CGPath(用于 CTFramesetterCreateFrame)。 static func makeLayoutPath(pageSize: CGSize, config: RDEPUBTextLayoutConfig) -> CGPath { let columnRects = config.columnRects(fallback: pageSize) guard columnRects.count > 1 else { @@ -55,9 +50,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding { return path } - // MARK: - 行级裁剪 - - /// 从 CTFrame 最后一行向前扫描,移除落在 avoidPageBreakInside 保护块内的尾部行。 func trimmedRangeForAvoidPageBreakInside( from frame: CTFrame, proposed: NSRange @@ -74,7 +66,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding { return trimmedRangeForAvoidPageBreakInside(proposed: proposed, lineRanges: lineRanges) } - /// CoreText 路径的 keepWithNext 处理:从最后行向前扫描 func trimmedRangeForKeepWithNext( from frame: CTFrame, proposed: NSRange @@ -88,7 +79,7 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding { } #if canImport(DTCoreText) - /// DTCoreText 路径的 avoidPageBreakInside 处理 + func trimmedRangeForAvoidPageBreakInside( from layoutFrame: DTCoreTextLayoutFrame, proposed: NSRange @@ -102,7 +93,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding { return trimmedRangeForAvoidPageBreakInside(proposed: proposed, lineRanges: lineRanges) } - /// DTCoreText 路径的 keepWithNext 处理 func trimmedRangeForKeepWithNext( from layoutFrame: DTCoreTextLayoutFrame, proposed: NSRange @@ -115,7 +105,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding { } #endif - /// 从最后行向前扫描,移除落在 avoidPageBreakInside 保护块内的尾部行(通用路径)。 func trimmedRangeForAvoidPageBreakInside( proposed: NSRange, lineRanges: [NSRange] @@ -155,7 +144,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding { return NSRange(location: proposed.location, length: adjustedLength) } - /// 从最后行向前扫描,移除落在 keepWithNext 保护块内的尾部行。 func trimmedRangeForKeepWithNext( proposed: NSRange, lineRanges: [NSRange] @@ -195,7 +183,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding { return NSRange(location: proposed.location, length: adjustedLength) } - /// 根据 avoidWidows / avoidOrphans 修正页尾断点,避免段首孤悬页尾或段末独悬下一页。 func trimmedRangeForWidowAndOrphanControl( proposed: NSRange, lineRanges: [NSRange] @@ -227,9 +214,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding { return adjusted } - // MARK: - 行信息提取 - - /// 获取 CTFrame 中所有行的字符范围 static func lineRanges(from frame: CTFrame) -> [NSRange] { let lines = CTFrameGetLines(frame) as! [CTLine] return lines.map { @@ -239,7 +223,7 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding { } #if canImport(DTCoreText) - /// 获取 DTCoreTextLayoutFrame 中所有行的字符范围 + static func lineRanges(from layoutFrame: DTCoreTextLayoutFrame) -> [NSRange] { guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine] else { return [] @@ -248,9 +232,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding { } #endif - // MARK: - 属性查询 - - /// 获取指定位置的块级元素范围 func blockRange(at location: Int) -> NSRange? { guard location >= 0, location < attributedString.length else { return nil } let attributes = attributedString.attributes(at: location, effectiveRange: nil) @@ -260,7 +241,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding { 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) @@ -268,7 +248,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding { 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) @@ -276,7 +255,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding { 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) } @@ -284,7 +262,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding { 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 [] @@ -297,7 +274,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding { return results } - /// 获取指定位置的语义提示列表 func semanticHints(at location: Int) -> [RDEPUBTextSemanticHint] { guard location >= 0, location < attributedString.length else { return [] } let attributes = attributedString.attributes(at: location, effectiveRange: nil) @@ -307,8 +283,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding { .compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) } } - // MARK: - Widow / Orphan 控制 - private func trimmedRangeAvoidingWidow( proposed: NSRange, lineRanges: [NSRange] @@ -408,7 +382,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding { return lineCount } - /// 获取指定范围内的附件类型列表(去重) func attachmentKinds(in range: NSRange) -> [RDEPUBTextAttachmentKind] { guard let safeRange = clampedRange(range), safeRange.length > 0 else { return [] @@ -425,7 +398,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding { return kinds } - /// 获取指定范围内的块级元素类型列表(去重) func blockKinds(in range: NSRange) -> [RDEPUBTextBlockKind] { guard let safeRange = clampedRange(range), safeRange.length > 0 else { return [] @@ -442,7 +414,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding { return kinds } - /// 获取指定范围内的语义提示列表(去重) func semanticHints(in range: NSRange) -> [RDEPUBTextSemanticHint] { guard let safeRange = clampedRange(range), safeRange.length > 0 else { return [] @@ -457,7 +428,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding { return hints } - /// 获取指定范围内的附件布局方式列表(去重) func attachmentPlacements(in range: NSRange) -> [RDEPUBTextAttachmentPlacement] { guard let safeRange = clampedRange(range), safeRange.length > 0 else { return [] @@ -474,7 +444,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding { return placements } - /// 将范围裁剪到 attributedString 的合法边界内。 func clampedRange(_ range: NSRange) -> NSRange? { guard range.location >= 0, range.length >= 0 else { return nil } guard attributedString.length > 0 else { @@ -486,7 +455,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding { return NSRange(location: range.location, length: min(range.length, maxLength)) } - /// 查找指定位置之前最近的 fragment ID(用于阅读位置恢复) func nearestTrailingFragmentID( endingAt location: Int, fragmentOffsets: [String: Int] @@ -497,9 +465,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding { .key } - // MARK: - 诊断日志 - - /// 生成分页诊断日志 func diagnostics( reason: RDEPUBTextPageBreakReason, range: NSRange, diff --git a/Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBPageBreakPolicy.swift b/Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBPageBreakPolicy.swift index 6fd371f..4e268be 100644 --- a/Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBPageBreakPolicy.swift +++ b/Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBPageBreakPolicy.swift @@ -1,19 +1,14 @@ import Foundation import UIKit -/// 分页规则策略:语义边界搜索、行级保护检查、范围调整调度器。 -/// -/// 不构建 CoreText frame,只基于 attributed string 属性做规则判定。 struct RDEPUBPageBreakPolicy { + private let attributedString: NSAttributedString init(attributedString: NSAttributedString) { self.attributedString = attributedString } - // MARK: - 行级保护检查 - - /// 检查指定行范围是否落在 avoidPageBreakInside 保护块内。 func lineIsInAvoidPageBreakInsideBlock(_ lineRange: NSRange) -> Bool { guard let probeRange = clampedProbeRange(for: lineRange) else { return false @@ -35,7 +30,6 @@ struct RDEPUBPageBreakPolicy { return found } - /// 检查指定行范围是否落在 keepWithNext 保护块内。 func lineIsInKeepWithNextBlock(_ lineRange: NSRange) -> Bool { guard let probeRange = clampedProbeRange(for: lineRange) else { return false @@ -54,14 +48,6 @@ struct RDEPUBPageBreakPolicy { return found } - // MARK: - 范围调整调度器 - - /// 对 CoreText/DTCoreText 提出的分页范围进行语义边界调整。 - /// - /// 调整优先级: - /// 1. 若已达章节末尾,直接返回 chapterEnd - /// 2. pageRelate 跨页关联边界 - /// 3. 以上都不满足时,使用原始帧限制分页 func adjustedRange( from proposedRange: NSRange, totalLength: Int, @@ -111,8 +97,6 @@ struct RDEPUBPageBreakPolicy { let currentSemanticHints = proposedSemanticHints let currentAttachmentPlacements = proposedAttachmentPlacements - // 对齐 WXRead:默认按 CTFrame 已经容纳的行数分页,仅保留 pageRelate 这种 - // 微信读书特有的跨页关联规则。 if let pageRelateBoundary = preferredPageRelateBoundary( after: proposedRange, minimumEnd: proposedRange.location + 1, @@ -142,7 +126,6 @@ struct RDEPUBPageBreakPolicy { ) } - // 帧限制(默认分页) return ( range: proposedRange, breakReason: .frameLimit, @@ -164,9 +147,6 @@ struct RDEPUBPageBreakPolicy { ) } - // MARK: - 语义边界查找 - - /// 查找 pageRelate 跨页关联边界。 func preferredPageRelateBoundary( after range: NSRange, minimumEnd: Int, @@ -192,8 +172,6 @@ struct RDEPUBPageBreakPolicy { return lastLineStart } - // MARK: - 内部工具 - private func shouldTreatAvoidHintAsBlockProtection(_ attributes: [NSAttributedString.Key: Any]) -> Bool { guard let rawValue = attributes[.rdPageSemanticHints] as? String else { return false diff --git a/Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBTextLayoutFrame.swift b/Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBTextLayoutFrame.swift index 0759049..ef5c0d7 100644 --- a/Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBTextLayoutFrame.swift +++ b/Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBTextLayoutFrame.swift @@ -1,33 +1,27 @@ import Foundation -/// CoreText 分页引擎产生的单帧(单页)布局数据。 -/// -/// 由 `RDEPUBTextLayouter` 在分页过程中生成,记录了该页的内容范围、分页原因、 -/// 附件信息和语义标记。`RDEPUBTextBookBuilder` 会将 `RDEPUBTextLayoutFrame` 转换为 -/// `RDEPUBTextPage`,并纳入最终的 `RDEPUBTextBook` 模型。 struct RDEPUBTextLayoutFrame: Equatable { - /// 该帧在章节富文本中的字符范围 + var contentRange: NSRange - /// 分页原因(语义边界、附件边界、帧限制等) + var breakReason: RDEPUBTextPageBreakReason - /// 所属 HTML 块级元素的范围(用于调试) + var blockRange: NSRange? - /// 帧内附件的字符范围列表 + var attachmentRanges: [NSRange] - /// 帧内附件类型列表(图片、通用附件等) + var attachmentKinds: [RDEPUBTextAttachmentKind] - /// 帧内包含的 HTML 块级元素类型列表(段落、列表、表格等) + var blockKinds: [RDEPUBTextBlockKind] - /// 帧内语义提示列表(避免分页、保持与下一段同页等) + var semanticHints: [RDEPUBTextSemanticHint] - /// 帧内附件的布局方式(行内、基线、居中) + var attachmentPlacements: [RDEPUBTextAttachmentPlacement] - /// 帧尾部最近的 fragment ID(用于恢复阅读位置) + var trailingFragmentID: String? - /// 诊断日志列表(分页原因详情、范围信息等) + var diagnostics: [String] - /// 将布局帧数据转换为页元数据模型 var metadata: RDEPUBTextPageMetadata { RDEPUBTextPageMetadata( breakReason: breakReason, diff --git a/Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBTextLayouter.swift b/Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBTextLayouter.swift index bdce5a2..d87f9ed 100644 --- a/Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBTextLayouter.swift +++ b/Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBTextLayouter.swift @@ -5,12 +5,6 @@ import UIKit import DTCoreText #endif -/// CoreText 分页引擎 Facade:将富文本按页面尺寸拆分为多帧(每帧对应一页)。 -/// -/// 内部委托给三个组件: -/// - RDEPUBCoreTextPageFrameFactory — 帧创建、属性查询、诊断 -/// - RDEPUBPageBreakPolicy — 分页规则(语义保护、keepWithNext) -/// - RDEPUBChapterPageCounter — 顶层分页循环编排 struct RDEPUBTextLayouter { private let counter: RDEPUBChapterPageCounter @@ -19,7 +13,6 @@ struct RDEPUBTextLayouter { self.counter = RDEPUBChapterPageCounter(factory: factory) } - /// 执行分页,返回布局帧列表(每帧对应一页)。 func layoutFrames(fragmentOffsets: [String: Int] = [:]) -> [RDEPUBTextLayoutFrame] { counter.layoutFrames(fragmentOffsets: fragmentOffsets) } diff --git a/Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBTextPaginationInterfaces.swift b/Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBTextPaginationInterfaces.swift index 2aab692..257acbe 100644 --- a/Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBTextPaginationInterfaces.swift +++ b/Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBTextPaginationInterfaces.swift @@ -1,30 +1,18 @@ -// RDEPUBTextPaginationInterfaces.swift -// EPUB 分页接口定义,包括断页决策、章节数页和页面帧构建协议。 import Foundation import UIKit -// MARK: - 分页接口定义 - -/// 单次断页决策,记录断页位置、原因及诊断信息。 struct RDEPUBPageBreakDecision { - /// 本页在富文本中的字符范围 + var range: NSRange - /// 断页原因 + var reason: RDEPUBTextPageBreakReason - /// 分页过程中的诊断日志 + var diagnostics: [String] } -/// 章节页数计算接口,将富文本按页面尺寸拆分为断页决策序列。 protocol RDEPUBChapterPageCounting { - /// 计算章节中每页的断页位置。 - /// - Parameters: - /// - attributedString: 章节富文本 - /// - pageSize: 页面尺寸 - /// - config: 排版配置 - /// - fragmentOffsets: fragment 锚点偏移映射 - /// - Returns: 断页决策数组,每个元素对应一页 + func pageRanges( for attributedString: NSAttributedString, pageSize: CGSize, @@ -33,15 +21,8 @@ protocol RDEPUBChapterPageCounting { ) -> [RDEPUBPageBreakDecision] } -/// 页面帧构建接口,将富文本转换为可渲染的页面帧数组。 protocol RDEPUBPageFrameBuilding { - /// 将富文本构建为页面帧数组。 - /// - Parameters: - /// - attributedString: 待分页的富文本 - /// - pageSize: 页面尺寸 - /// - config: 排版配置 - /// - fragmentOffsets: fragment 锚点偏移映射 - /// - Returns: 页面帧数组 + func makeFrames( attributedString: NSAttributedString, pageSize: CGSize, diff --git a/Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBTextPaginationSupport.swift b/Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBTextPaginationSupport.swift index 39c7d20..ecb3a59 100644 --- a/Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBTextPaginationSupport.swift +++ b/Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBTextPaginationSupport.swift @@ -1,18 +1,8 @@ import CoreText import UIKit -// MARK: - NSAttributedString 分页扩展 - -/// 为 NSAttributedString 提供便捷的分页方法,是渲染链路中 CoreText 分页的入口点。 extension NSAttributedString { - /// 将富文本按指定页面尺寸分页,返回布局帧列表(每帧对应一页)。 - /// - /// 这是 `RDEPUBTextBookBuilder` 和 `RDPlainTextBookBuilder` 调用的核心分页方法。 - /// - Parameters: - /// - size: 页面尺寸 - /// - fragmentOffsets: fragment ID → 字符偏移量映射(用于阅读位置恢复) - /// - config: 布局配置(孤行控制、avoidPageBreakInside 等) - /// - Returns: 分页后的布局帧列表 + func rd_paginatedFrames( size: CGSize, fragmentOffsets: [String: Int] = [:], @@ -23,17 +13,13 @@ extension NSAttributedString { return counter.layoutFrames(fragmentOffsets: fragmentOffsets) } - /// 简化版分页:只返回每页的 NSRange 列表(不含语义元数据) func ss_pageRanges(size: CGSize) -> [NSRange] { rd_paginatedFrames(size: size).map(\.contentRange) } } -// MARK: - UIColor CSS 转换扩展 - -/// 为 UIColor 提供 CSS 颜色字符串转换,用于动态生成暗色模式 CSS。 extension UIColor { - /// 将 UIColor 转换为 CSS rgba() 字符串格式 + var ss_cssString: String { var red: CGFloat = 0 var green: CGFloat = 0 diff --git a/Sources/RDReaderView/EPUBTextRendering/RDEPUBChapterData.swift b/Sources/RDReaderView/EPUBTextRendering/RDEPUBChapterData.swift index b91d77a..cdd4d58 100644 --- a/Sources/RDReaderView/EPUBTextRendering/RDEPUBChapterData.swift +++ b/Sources/RDReaderView/EPUBTextRendering/RDEPUBChapterData.swift @@ -1,14 +1,9 @@ import UIKit -/// 章节数据访问层:为已分页章节提供便捷的查询接口。 -/// -/// 本类是 `RDEPUBTextChapter` 的轻量级封装,持有 `indexTable`(全局索引表), -/// 将分页数据、fragment 锚点、高亮、搜索结果等统一到同一套查询 API 中。 -/// 由 `RDEPUBTextBook.chapterData(for:)` 或 `chapterData(atChapterIndex:)` 创建。 public final class RDEPUBChapterData { - /// 底层章节模型(只读) + public let chapter: RDEPUBTextChapter - /// 全局索引表,用于 fileIndex/row/column 到绝对偏移量的映射 + public let indexTable: RDEPUBTextIndexTable public init(chapter: RDEPUBTextChapter, indexTable: RDEPUBTextIndexTable) { @@ -16,88 +11,66 @@ public final class RDEPUBChapterData { self.indexTable = indexTable } - // MARK: - 便捷属性(转发自 chapter) - - /// 章节在全书中的序号 public var chapterIndex: Int { chapter.chapterIndex } - /// spine 顺序索引 + public var spineIndex: Int { chapter.spineIndex } - /// 章节文件相对路径(如 "OEBPS/chapter1.xhtml") + 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 } - /// fragment ID → 字符偏移量的映射表(用于锚点定位) + 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 } - // MARK: - 页面查询 - - /// 查找包含指定绝对字符偏移量的页面 - /// - Parameter absoluteOffset: 全书绝对字符偏移量 - /// - Returns: 包含该偏移量的页面,未找到返回 nil public func page(containing absoluteOffset: Int) -> RDEPUBTextPage? { chapter.pages.first { NSLocationInRange(absoluteOffset, $0.contentRange) } } - /// 获取指定绝对偏移量所在页的绝对页码(从 0 开始) public func pageNumber(containing absoluteOffset: Int) -> Int? { page(containing: absoluteOffset)?.absolutePageIndex } - /// 按绝对页码查找页面 public func page(atAbsolutePageIndex absolutePageIndex: Int) -> RDEPUBTextPage? { chapter.pages.first { $0.absolutePageIndex == absolutePageIndex } } - /// 按章节内页码查找页面(从 1 开始) public func page(atPageNumber pageNumber: Int) -> RDEPUBTextPage? { guard pageNumber > 0, pages.indices.contains(pageNumber - 1) else { return nil } return pages[pageNumber - 1] } - // MARK: - 锚点与位置映射 - - /// 将章节内字符索引转换为语义锚点(fileIndex/row/column 三元组) 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) } - /// 从绝对字符范围构建选区对象(用于复制/高亮分享) - /// - Parameters: - /// - absoluteRange: 选区在全书中的字符范围 - /// - bookIdentifier: 书籍标识符 - /// - Returns: 选区对象,若起始偏移量不在任何页面内则返回 nil 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) @@ -115,7 +88,6 @@ public final class RDEPUBChapterData { ) } - /// 将绝对字符范围转换为持久化位置对象(RDEPUBLocation) public func location(for absoluteRange: NSRange, bookIdentifier: String?) -> RDEPUBLocation { indexTable.location( for: rangeAnchor(for: absoluteRange), @@ -124,32 +96,31 @@ public final class RDEPUBChapterData { ) } - /// 将页面转换为对应的 RDEPUBLocation 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) } - // MARK: - 位置反向解析(Location → 绝对偏移量) - - /// 将持久化位置还原为章节内的字符范围。 - /// - /// 解析优先级: - /// 1. rangeAnchor(锚点定位,最精确) - /// 2. fragment(片段 ID 定位) - /// 3. navigationProgression(进度百分比回退) 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) } @@ -164,16 +135,35 @@ public final class RDEPUBChapterData { 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)) } @@ -183,8 +173,17 @@ public final class RDEPUBChapterData { 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) } @@ -195,8 +194,16 @@ public final class RDEPUBChapterData { 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) } @@ -207,8 +214,19 @@ public final class RDEPUBChapterData { 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) } @@ -219,13 +237,14 @@ public final class RDEPUBChapterData { return NSRange(location: chapterStart + chapterRange.location, length: chapterRange.length) } - // MARK: - 高亮/搜索结果与页面的交叉查询 - - /// 获取指定页面上出现的所有高亮 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)) } @@ -236,7 +255,6 @@ public final class RDEPUBChapterData { } } - /// 获取指定页面上出现的所有搜索匹配结果 public func searchMatches(on page: RDEPUBTextPage, from matches: [RDEPUBSearchMatch]) -> [RDEPUBSearchMatch] { return matches.filter { match in guard match.href == chapter.href else { return false } @@ -247,26 +265,20 @@ public final class RDEPUBChapterData { } } - /// `searchMatches(on:from:)` 的别名;保持和文档/WXRead 语义一致。 public func searchResults(on page: RDEPUBTextPage, from matches: [RDEPUBSearchMatch]) -> [RDEPUBSearchMatch] { searchMatches(on: page, from: matches) } - /// 根据持久化位置获取对应页码(从 1 开始),用于阅读进度跳转 public func pageNumber(for location: RDEPUBLocation) -> Int? { guard let page = page(for: location) else { return nil } return page.absolutePageIndex + 1 } - /// 根据搜索结果获取对应页码(从 1 开始)。 public func pageNumber(for searchMatch: RDEPUBSearchMatch) -> Int? { guard let page = page(for: searchMatch) else { return nil } return page.absolutePageIndex + 1 } - // MARK: - 目录与章节语义 - - /// 判断某个 TOC 条目是否属于当前章节。 public func contains( tableOfContentsItem item: EPUBTableOfContentsItem, normalizer: (String) -> String? @@ -278,7 +290,6 @@ public final class RDEPUBChapterData { return chapterHref == itemHref } - /// 过滤出当前章节命中的目录条目。 public func tableOfContentsItems( from items: [EPUBTableOfContentsItem], normalizer: (String) -> String? @@ -289,7 +300,6 @@ public final class RDEPUBChapterData { } } - /// 当前章节最合适的目录条目(通常取命中的首个最近条目)。 public func primaryTableOfContentsItem( from items: [EPUBTableOfContentsItem], normalizer: (String) -> String? @@ -297,10 +307,6 @@ public final class RDEPUBChapterData { tableOfContentsItems(from: items, normalizer: normalizer).first } - // MARK: - 高亮属性注入(对齐 WXRead 的 WRChapterData.addHighlightInRange:key:itemId:color:) - - /// 将高亮/下划线作为自定义属性注入 NSAttributedString, - /// 供 CoreText 渲染时读取绘制(对齐 WXRead 的 com.weread.highlight / com.weread.underline) public func applyHighlights( to content: NSMutableAttributedString, page: RDEPUBTextPage, @@ -324,17 +330,51 @@ public final class RDEPUBChapterData { } } - // MARK: - 私有工具方法 - - /// 将页面的起止偏移量转换为半开区间 [start, end+1) 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/RDReaderView/EPUBTextRendering/RDEPUBDTCoreTextRenderer.swift b/Sources/RDReaderView/EPUBTextRendering/RDEPUBDTCoreTextRenderer.swift index 420ea0a..28c1e63 100644 --- a/Sources/RDReaderView/EPUBTextRendering/RDEPUBDTCoreTextRenderer.swift +++ b/Sources/RDReaderView/EPUBTextRendering/RDEPUBDTCoreTextRenderer.swift @@ -4,18 +4,9 @@ import UIKit import DTCoreText #endif -/// DTCoreText 渲染器实现:将 EPUB 章节 HTML 转换为富文本(NSAttributedString)。 -/// -/// 这是 `RDEPUBTextRenderer` 协议的默认实现,位于渲染链路的第二步: -/// 章节 HTML → DTCoreText 渲染 → NSAttributedString → 提取 fragment 偏移量 → 统一字体/行距 -/// -/// DTCoreText 库负责将 HTML 解析为带有排版属性的富文本, -/// 渲染过程中会通过 `willFlushCallback` 回调对每个 DOM 元素做布局规范化(图片尺寸等)。 -/// 当 DTCoreText 不可用时(条件编译失败),回退到纯文本渲染。 public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer { public init() {} - /// DTCoreText 库是否在当前编译环境中可用 public static var isAvailable: Bool { #if canImport(DTCoreText) return true @@ -24,7 +15,6 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer { #endif } - /// 渲染单个章节:HTML → NSAttributedString,同时提取 fragment 和语义标记。 public func renderChapter( request: RDEPUBTextChapterRenderRequest ) throws -> RDEPUBRenderedChapterContent { @@ -56,7 +46,6 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer { #endif } - /// 便捷方法:直接传入 HTML 字符串进行渲染(不含上下文信息) public func renderChapter( html: String, baseURL: URL?, @@ -65,6 +54,7 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer { let request = RDEPUBTextTypesetterPipeline().makeRequest( from: RDEPUBTypesettingInput( href: "", + spineIndex: nil, title: "", rawHTML: html, baseURL: baseURL, @@ -75,7 +65,6 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer { return try renderChapter(request: request) } - /// 回退渲染:当 DTCoreText 不可用时,将 HTML 源码当作纯文本处理 private func fallbackRenderedContent(request: RDEPUBTextChapterRenderRequest) -> RDEPUBRenderedChapterContent { let attributedString = RDEPUBTextRendererSupport.fallbackAttributedString(for: request.context.html, style: request.style) RDEPUBSemanticMarkerInjector.applyPaginationSemantics(in: attributedString) @@ -93,7 +82,7 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer { } #if canImport(DTCoreText) - /// 使用 DTCoreText 将 HTML Data 解析为富文本。 + private func makeAttributedString(from data: Data, request: RDEPUBTextChapterRenderRequest) -> NSAttributedString? { let builder = DTHTMLAttributedStringBuilder( html: data, @@ -111,7 +100,6 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer { return builder?.generatedAttributedString() } - /// 构建 DTCoreText 的解析选项字典,包括字体、行高、图片尺寸限制等。 private func dtOptions(request: RDEPUBTextChapterRenderRequest) -> [AnyHashable: Any] { let style = request.style let maxImageSize = resolvedMaxImageSize(for: request) diff --git a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPositionConverter.swift b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPositionConverter.swift index 219560a..14bcab9 100644 --- a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPositionConverter.swift +++ b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPositionConverter.swift @@ -1,84 +1,46 @@ -// RDEPUBTextPositionConverter.swift -// EPUB 全书文本位置转换器,在多种坐标体系之间进行双向映射。 import Foundation -/// 对标 WXRead `WREpubPositionConverter` 的全书位置转换器。 -/// -/// 负责在四套坐标之间做双向转换: -/// - `(fileIndex, row, column)` 文件级语义锚点 -/// - 章节内字符偏移 -/// - 全书字符偏移 -/// - 页码 / `RDEPUBLocation` public struct RDEPUBTextPositionConverter { - /// 当前转换器所关联的 EPUB 文本对象。 + public let book: RDEPUBTextBook - /// 创建指定 EPUB 文本对象的位置转换器。 - /// - Parameter book: 要关联的 EPUB 文本对象。 public init(book: RDEPUBTextBook) { self.book = book } - /// 全书字符总数(跨所有文件累计)。 public var totalCharacterCount: Int { book.indexTable.totalCharacterCount } - /// 将语义锚点转换为全书字符偏移。 - /// - Parameter anchor: 由文件索引、行号、列号组成的语义锚点。 - /// - Returns: 对应的全书字符偏移量。 public func globalIndex(for anchor: RDEPUBTextAnchor) -> Int { book.indexTable.globalIndex(for: anchor) } - /// 将语义范围锚点转换为全书字符范围。 - /// - Parameter rangeAnchor: 由起止锚点组成的语义范围。 - /// - Returns: 对应的全书字符范围(`NSRange`)。 public func globalRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange { book.indexTable.globalRange(for: rangeAnchor) } - /// 根据全书字符偏移查找所属文件的索引。 - /// - Parameter index: 全书字符偏移量。 - /// - Returns: 对应的文件索引,超出范围时返回 `nil`。 public func fileIndex(forCharacterPosition index: Int) -> Int? { book.indexTable.fileIndex(forCharacterPosition: index) } - /// 将全书字符偏移转换为指定文件内的本地偏移。 - /// - Parameters: - /// - fileIndex: 目标文件索引。 - /// - index: 全书字符偏移量。 - /// - Returns: 文件内的本地字符偏移,越界时返回 `nil`。 public func localOffsetInFile(at fileIndex: Int, forGlobalPosition index: Int) -> Int? { book.indexTable.localOffsetInFile(at: fileIndex, forGlobalPosition: index) } - /// 将全书字符偏移转换为语义锚点。 - /// - Parameter index: 全书字符偏移量。 - /// - Returns: 对应的语义锚点,无效位置时返回 `nil`。 public func anchor(forCharacterPosition index: Int) -> RDEPUBTextAnchor? { book.indexTable.anchor(forGlobalIndex: index) } - /// 将 `RDEPUBLocation` 转换为语义锚点。 - /// - Parameter location: EPUB 位置对象。 - /// - Returns: 对应的语义锚点,无法映射时返回 `nil`。 public func anchor(for location: RDEPUBLocation) -> RDEPUBTextAnchor? { book.indexTable.anchor(for: location) } - /// 根据语义锚点获取页码(从 1 开始)。 - /// - Parameter anchor: 由文件索引、行号、列号组成的语义锚点。 - /// - Returns: 对应的页码,无法定位时返回 `nil`。 public func pageNumber(for anchor: RDEPUBTextAnchor) -> Int? { book.indexTable.pageNumber(for: anchor, in: book).map { $0 + 1 } } - /// 根据全书字符偏移获取页码(从 1 开始)。 - /// - Parameter index: 全书字符偏移量。 - /// - Returns: 对应的页码,无效位置时返回 `nil`。 public func pageNumber(forCharacterPosition index: Int) -> Int? { guard let anchor = anchor(forCharacterPosition: index) else { return nil @@ -86,11 +48,6 @@ public struct RDEPUBTextPositionConverter { return pageNumber(for: anchor) } - /// 将语义锚点转换为可序列化的 `RDEPUBLocation`。 - /// - Parameters: - /// - anchor: 由文件索引、行号、列号组成的语义锚点。 - /// - bookIdentifier: 书籍标识符,会写入 location 的 `bookId` 字段。 - /// - Returns: 对应的 `RDEPUBLocation`,无法映射时返回 `nil`。 public func location( for anchor: RDEPUBTextAnchor, bookIdentifier: String? @@ -101,11 +58,6 @@ public struct RDEPUBTextPositionConverter { return book.indexTable.location(for: anchor, in: chapter, bookIdentifier: bookIdentifier) } - /// 将语义范围锚点转换为可序列化的 `RDEPUBLocation`。 - /// - Parameters: - /// - rangeAnchor: 由起止锚点组成的语义范围。 - /// - bookIdentifier: 书籍标识符,会写入 location 的 `bookId` 字段。 - /// - Returns: 对应的 `RDEPUBLocation`,无法映射时返回 `nil`。 public func location( for rangeAnchor: RDEPUBTextRangeAnchor, bookIdentifier: String? diff --git a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift index 3887e27..95367c7 100644 --- a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift +++ b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift @@ -1,71 +1,54 @@ import UIKit -// MARK: - 自定义富文本属性键 - -/// EPUBTextRendering 层使用的自定义 NSAttributedString 属性键。 -/// 这些属性由 `RDEPUBTextRendererSupport` 在渲染阶段注入, -/// 供 `RDEPUBTextLayouter` 分页时读取语义信息。 public extension NSAttributedString.Key { - /// 块级元素在富文本中的字符范围(NSString 编码的 NSRange) + static let rdPageBlockRange = NSAttributedString.Key("com.rdreader.epub.pageBlockRange") - /// 块级元素在章节中的序号 + static let rdPageBlockIndex = NSAttributedString.Key("com.rdreader.epub.pageBlockIndex") - /// fragment 锚点 ID + static let rdPageFragmentID = NSAttributedString.Key("com.rdreader.epub.pageFragmentID") - /// 附件类型(图片/通用附件) + static let rdPageAttachmentKind = NSAttributedString.Key("com.rdreader.epub.pageAttachmentKind") - /// 块级元素类型(段落/列表/表格/代码等) + static let rdPageBlockKind = NSAttributedString.Key("com.rdreader.epub.pageBlockKind") - /// 语义提示标记(避免分页、保持与下一段同页等,逗号分隔) + static let rdPageSemanticHints = NSAttributedString.Key("com.rdreader.epub.pageSemanticHints") - /// 附件布局方式(行内/基线/居中) + static let rdPageAttachmentPlacement = NSAttributedString.Key("com.rdreader.epub.pageAttachmentPlacement") } -// MARK: - 块级元素类型枚举 - -/// HTML 块级元素的类型分类,用于分页时判断语义边界。 public enum RDEPUBTextBlockKind: String, Codable, Equatable, CaseIterable { - case paragraph //

- case list //

    ,
      ,
    1. - case table // 系列 - case code //
      , 
      -    case blockquote  // 
      - case attachment // 附件块(图片、figure 等) - case generic // 通用块(
      ,

      -

      等) + case paragraph + case list + case table + case code + case blockquote + case attachment + case generic } -// MARK: - 语义提示枚举 - -/// 分页语义提示,指导分页引擎在何处/何处不进行分页。 public enum RDEPUBTextSemanticHint: String, Codable, Equatable, CaseIterable { - case avoidPageBreakInside // 禁止在块内分页(如代码块、表格) - case keepWithNext // 与下一段保持同页(如标题) - case pageBreakBefore // 在此元素前强制分页 - case pageBreakAfter // 在此元素后强制分页 - case pageRelate // 微信读书式跨页关联元素 + case avoidPageBreakInside + case keepWithNext + case pageBreakBefore + case pageBreakAfter + case pageRelate } -// MARK: - 附件布局方式枚举 - -/// 富文本附件(图片等)的垂直布局方式。 public enum RDEPUBTextAttachmentPlacement: String, Codable, Equatable { - case inline // 行内布局 - case baseline // 基线对齐 - case centered // 垂直居中(块级) + case inline + case baseline + case centered } -// MARK: - 渲染样式 - -/// 阅读器渲染样式配置,控制字体、行距、颜色等。 public struct RDEPUBTextRenderStyle { - /// 基础字体(大小和字族将覆盖 EPUB 原始样式) + public var font: UIFont - /// 行间距(pt) + public var lineSpacing: CGFloat - /// 文本颜色(nil 则使用 EPUB 原始颜色) + public var textColor: UIColor? - /// 背景颜色(nil 则透明,用于暗色模式判断) + public var backgroundColor: UIColor? public init(font: UIFont, lineSpacing: CGFloat, textColor: UIColor? = nil, backgroundColor: UIColor? = nil) { @@ -76,31 +59,28 @@ public struct RDEPUBTextRenderStyle { } } -// MARK: - 布局配置 - -/// 分页引擎的布局控制参数。 public struct RDEPUBTextLayoutConfig: Equatable { - /// 页面帧宽度;传 0 时回退为分页入口传入的 pageSize.width + public var frameWidth: CGFloat - /// 页面帧高度;传 0 时回退为分页入口传入的 pageSize.height + public var frameHeight: CGFloat - /// 页面内容内边距,对标 WXRead 的 WRCoreTextLayoutConfig.edgeInsets + public var edgeInsets: UIEdgeInsets - /// 栏数,对标 WXRead 的 numberOfColumns + public var numberOfColumns: Int - /// 栏间距,对标 WXRead 的 columnGap + public var columnGap: CGFloat - /// 是否避免孤行(段落最后一行单独在下一页顶部) + public var avoidOrphans: Bool - /// 是否避免寡行(段落第一行单独在上一页底部) + public var avoidWidows: Bool - /// 是否启用 avoidPageBreakInside 保护(对标 WXRead 的行级回退扫描) + public var avoidPageBreakInsideEnabled: Bool - /// 是否启用连字符断字,对标 WXRead 的 hyphenation + public var hyphenation: Bool - /// 图片最大高度占页面高度的比例 + public var imageMaxHeightRatio: CGFloat - /// 当调用方暂时拿不到 pageSize 时,用于估算附件尺寸的兜底 viewport。 + public var fallbackViewportSize: CGSize public init( @@ -129,10 +109,8 @@ public struct RDEPUBTextLayoutConfig: Equatable { self.fallbackViewportSize = fallbackViewportSize } - /// 默认配置 public static let `default` = RDEPUBTextLayoutConfig() - /// 结合调用方 pageSize 解析后的实际页面尺寸。 public func resolvedFrameSize(fallback pageSize: CGSize) -> CGSize { CGSize( width: max(frameWidth > 0 ? frameWidth : pageSize.width, 1), @@ -140,13 +118,11 @@ public struct RDEPUBTextLayoutConfig: Equatable { ) } - /// 实际内容区域;对标 WXRead 的 frame + edgeInsets 组合。 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) @@ -161,7 +137,6 @@ public struct RDEPUBTextLayoutConfig: Equatable { } } - /// 持久化/缓存键使用的稳定签名。 public var cacheSignature: String { [ String(format: "%.3f", frameWidth), @@ -183,18 +158,14 @@ public struct RDEPUBTextLayoutConfig: Equatable { } } -// MARK: - CSS 样式表层级 - -/// 样式表层级类型,用于 CSS 层叠优先级管理。 public enum RDEPUBTextStyleSheetLayerKind: String, CaseIterable, Equatable { - case `default` // 基础重置样式(margin、padding 等) - case replace // 元素替换样式(图片居中、标题分页等) - case dark // 暗色模式覆盖 - case epub // EPUB 原始样式表 - case user // 用户自定义样式(字号、行距、颜色等) + case `default` + case replace + case dark + case epub + case user } -/// 单个 CSS 样式表层,包含层级类型和 CSS 内容。 public struct RDEPUBTextStyleSheetLayer: Equatable { public var kind: RDEPUBTextStyleSheetLayerKind public var css: String @@ -205,9 +176,6 @@ public struct RDEPUBTextStyleSheetLayer: Equatable { } } -/// CSS 样式表包,管理多层 CSS 的合并与注入顺序。 -/// -/// CSS 层叠顺序(从低到高):default → replace → dark → epub → user public struct RDEPUBTextStyleSheetPackage: Equatable { public var layers: [RDEPUBTextStyleSheetLayer] @@ -215,7 +183,6 @@ public struct RDEPUBTextStyleSheetPackage: Equatable { self.layers = layers } - /// 合并所有非空层的 CSS,每层添加注释头标记 public var combinedCSS: String { layers .filter { !$0.css.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } @@ -226,15 +193,11 @@ public struct RDEPUBTextStyleSheetPackage: Equatable { } } -// MARK: - 资源引用诊断 - -/// 资源引用类型(样式表或图片) public enum RDEPUBTextResourceReferenceKind: String, Equatable { case stylesheet case image } -/// 单个资源引用的诊断信息,用于检测 EPUB 中的资源是否正确引用。 public struct RDEPUBTextResourceReferenceDiagnostic: Equatable { public var kind: RDEPUBTextResourceReferenceKind public var chapterHref: String @@ -260,9 +223,6 @@ public struct RDEPUBTextResourceReferenceDiagnostic: Equatable { } } -// MARK: - 章节渲染上下文与请求 - -/// 章节渲染上下文:包含 HTML 源码、样式表和资源诊断信息。 public struct RDEPUBTextChapterContext: Equatable { public var href: String public var title: String @@ -270,6 +230,7 @@ public struct RDEPUBTextChapterContext: Equatable { public var baseURL: URL? public var stylesheet: RDEPUBTextStyleSheetPackage public var resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic] + public var styleCompatibilityReport: RDEPUBCSSCompatibilityReport public init( href: String, @@ -277,7 +238,8 @@ public struct RDEPUBTextChapterContext: Equatable { html: String, baseURL: URL?, stylesheet: RDEPUBTextStyleSheetPackage, - resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic] + resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic], + styleCompatibilityReport: RDEPUBCSSCompatibilityReport = RDEPUBCSSCompatibilityReport() ) { self.href = href self.title = title @@ -285,16 +247,16 @@ public struct RDEPUBTextChapterContext: Equatable { self.baseURL = baseURL self.stylesheet = stylesheet self.resourceDiagnostics = resourceDiagnostics + self.styleCompatibilityReport = styleCompatibilityReport } } -/// 章节渲染请求:打包上下文和渲染样式,传递给 `RDEPUBTextRenderer`。 public struct RDEPUBTextChapterRenderRequest { public var context: RDEPUBTextChapterContext public var style: RDEPUBTextRenderStyle - /// 当前章节将被分页到的页面尺寸;用于让附件缩放贴近真实 page size。 + public var pageSize: CGSize? - /// 当前分页布局配置;用于生成与 WXRead 更接近的内容区尺寸。 + public var layoutConfig: RDEPUBTextLayoutConfig? public init( @@ -310,15 +272,12 @@ public struct RDEPUBTextChapterRenderRequest { } } -// MARK: - 渲染结果 - -/// 章节渲染的输出结果,包含富文本、fragment 偏移量和资源诊断。 public struct RDEPUBRenderedChapterContent { - /// 渲染后的富文本 + public var attributedString: NSAttributedString - /// fragment ID → 字符偏移量映射 + public var fragmentOffsets: [String: Int] - /// 资源引用诊断列表 + public var resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic] public init( @@ -332,18 +291,12 @@ public struct RDEPUBRenderedChapterContent { } } -// MARK: - 渲染器协议 - -/// EPUB 文本渲染器协议,定义 HTML → NSAttributedString 的转换接口。 -/// -/// 默认实现:`RDEPUBDTCoreTextRenderer`(基于 DTCoreText 库) public protocol RDEPUBTextRenderer { - /// 渲染单个章节 + func renderChapter( request: RDEPUBTextChapterRenderRequest ) throws -> RDEPUBRenderedChapterContent - /// 便捷方法:直接渲染 HTML 字符串 func renderChapter( html: String, baseURL: URL?, @@ -351,7 +304,6 @@ public protocol RDEPUBTextRenderer { ) throws -> RDEPUBRenderedChapterContent } -/// 协议默认实现:将便捷方法委托给完整方法 public extension RDEPUBTextRenderer { func renderChapter( html: String, @@ -370,12 +322,9 @@ public extension RDEPUBTextRenderer { } } -// MARK: - 渲染错误 - -/// EPUB 文本渲染过程中可能出现的错误类型。 public enum RDEPUBTextRenderingError: LocalizedError { - case htmlEncodingFailed // HTML 字符串编码为 Data 失败 - case htmlImportFailed // HTML 富文本解析失败 + case htmlEncodingFailed + case htmlImportFailed public var errorDescription: String? { switch self { diff --git a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextSearchEngine.swift b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextSearchEngine.swift index 8b93f76..7af5b10 100644 --- a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextSearchEngine.swift +++ b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextSearchEngine.swift @@ -1,10 +1,5 @@ import Foundation -/// EPUB 文本搜索引擎:在分页书籍中执行全文搜索。 -/// -/// 实现 `RDEPUBSearchEngine` 协议,遍历所有章节的富文本内容, -/// 使用大小写不敏感匹配查找关键词,返回搜索结果列表。 -/// 每个搜索结果包含进度位置、预览文本和语义锚点。 final class RDEPUBTextSearchEngine: RDEPUBSearchEngine { private let textBook: RDEPUBTextBook private let publication: RDEPUBPublication @@ -14,14 +9,6 @@ final class RDEPUBTextSearchEngine: RDEPUBSearchEngine { self.publication = publication } - /// 在全书中搜索关键词,返回所有匹配结果。 - /// - /// 搜索流程: - /// 1. 遍历所有章节 - /// 2. 在每章的富文本字符串中执行大小写不敏感搜索 - /// 3. 计算匹配位置的阅读进度(progression) - /// 4. 生成预览文本(匹配位置前后各 12 个字符) - /// 5. 生成语义锚点(用于跨设备/跨字体精确定位) func search(keyword: String) -> [RDEPUBSearchMatch] { let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalizedKeyword.isEmpty else { @@ -49,6 +36,7 @@ final class RDEPUBTextSearchEngine: RDEPUBSearchEngine { let progressionDenominator = max(fullLength - 1, 1) let progression = Double(foundRange.location) / Double(progressionDenominator) + let rangeAnchor = chapterData.rangeAnchor(for: foundRange) matches.append( RDEPUBSearchMatch( href: normalizedHref, @@ -57,7 +45,9 @@ final class RDEPUBTextSearchEngine: RDEPUBSearchEngine { localMatchIndex: localMatchIndex, rangeLocation: foundRange.location, rangeLength: foundRange.length, - rangeAnchor: chapterData.rangeAnchor(for: foundRange) + rangeAnchor: rangeAnchor, + cfi: chapterData.indexTable.cfi(for: rangeAnchor.start)?.rawValue, + rangeCFI: chapterData.indexTable.cfiRange(for: rangeAnchor)?.rawValue ) ) @@ -73,7 +63,6 @@ final class RDEPUBTextSearchEngine: RDEPUBSearchEngine { return matches } - /// 生成搜索结果的预览文本:匹配位置前后各取 12 个字符 private func previewText(in text: NSString, matchRange: NSRange) -> String { let previewRadius = 12 let start = max(matchRange.location - previewRadius, 0) @@ -82,13 +71,16 @@ final class RDEPUBTextSearchEngine: RDEPUBSearchEngine { return text.substring(with: range).trimmingCharacters(in: .whitespacesAndNewlines) } - /// 无 publication 时的搜索(用于 txt 等外部文本文件) 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 } @@ -102,6 +94,9 @@ final class RDEPUBTextSearchEngine: RDEPUBSearchEngine { 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) @@ -116,7 +111,9 @@ final class RDEPUBTextSearchEngine: RDEPUBSearchEngine { localMatchIndex: localMatchIndex, rangeLocation: foundRange.location, rangeLength: foundRange.length, - rangeAnchor: nil + rangeAnchor: rangeAnchor, + cfi: cfi?.rawValue ?? cfiRange?.start.rawValue, + rangeCFI: cfiRange?.rawValue ) ) diff --git a/Sources/RDReaderView/EPUBTextRendering/RDPlainTextBookBuilder.swift b/Sources/RDReaderView/EPUBTextRendering/RDPlainTextBookBuilder.swift index a750868..ad59ea0 100644 --- a/Sources/RDReaderView/EPUBTextRendering/RDPlainTextBookBuilder.swift +++ b/Sources/RDReaderView/EPUBTextRendering/RDPlainTextBookBuilder.swift @@ -1,16 +1,5 @@ import UIKit -/// 纯文本书籍构建器:将 TXT 文件构建为 RDEPUBTextBook,复用 EPUB 文本渲染和分页管线。 -/// -/// 本类将纯文本文件(.txt)转换为与 EPUB 相同的分页书籍模型, -/// 使得纯文本阅读器可以复用 EPUBReader 的分页、导航、搜索等全部功能。 -/// -/// 处理流程: -/// 1. 解码文本文件(UTF-8 → GB18030 → GBK 兼容编码) -/// 2. 按中文章节正则拆分为多个章节 -/// 3. 将纯文本包装为 HTML 段落 -/// 4. 通过渲染器(DTCoreText)渲染为富文本 -/// 5. CoreText 分页 → RDEPUBTextBook public final class RDPlainTextBookBuilder { private let renderer: RDEPUBTextRenderer private let layoutConfig: RDEPUBTextLayoutConfig @@ -23,13 +12,6 @@ public final class RDPlainTextBookBuilder { self.layoutConfig = layoutConfig } - /// 从纯文本文件构建分页书籍。 - /// - /// - Parameters: - /// - textFileURL: 纯文本文件的本地 URL - /// - pageSize: 页面尺寸 - /// - style: 渲染样式(字体、行距、颜色) - /// - Returns: 分页后的书籍模型 public func build( textFileURL: URL, pageSize: CGSize, @@ -109,6 +91,7 @@ public final class RDPlainTextBookBuilder { title: spec.title ?? "第 \(index + 1) 章", attributedContent: chapterAttributedContent, fragmentOffsets: [:], + cfiMap: nil, pageBreakReasons: pages.map { $0.metadata.breakReason }, pages: pages ) @@ -119,16 +102,11 @@ public final class RDPlainTextBookBuilder { return RDEPUBTextBook(chapters: chapters, pages: flatPages) } - // MARK: - 章节拆分 - - /// 章节规格:标题 + 内容 private struct ChapterSpec { let title: String? let content: String } - /// 按中文章节正则拆分文本。匹配 "第X章/节/回/卷" 格式, - /// 无匹配时将整篇文本作为一章处理。 private func splitChapters(from text: String) -> [ChapterSpec] { let pattern = #"^(第[零一二三四五六七八九十百千万\d]+[章节回卷].*)$"# guard let regex = try? NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines]) else { @@ -154,25 +132,18 @@ public final class RDPlainTextBookBuilder { } } - // 拆分结果为空(所有章节内容为空),整体作为一章 if specs.isEmpty { return [ChapterSpec(title: nil, content: text)] } return specs } - // MARK: - HTML 包装 - - /// 将纯文本包装为 HTML 段落:每行一个 `

      ` 标签 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)" } - // MARK: - 文本解码 - - /// 解码文本文件,按优先级尝试 UTF-8 → GB18030 → GBK 编码 private func rd_decodeTextFile(url: URL) -> String { if let content = try? NSString(contentsOf: url, encoding: String.Encoding.utf8.rawValue) as String { return content diff --git a/Sources/RDReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBCSSCompatibilityLayer.swift b/Sources/RDReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBCSSCompatibilityLayer.swift new file mode 100644 index 0000000..e1f3e72 --- /dev/null +++ b/Sources/RDReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBCSSCompatibilityLayer.swift @@ -0,0 +1,136 @@ +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 + } + + 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) + } +} diff --git a/Sources/RDReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBFontFallbackResolver.swift b/Sources/RDReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBFontFallbackResolver.swift new file mode 100644 index 0000000..6ee3e85 --- /dev/null +++ b/Sources/RDReaderView/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/RDReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBStyleCompatibilityModels.swift b/Sources/RDReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBStyleCompatibilityModels.swift new file mode 100644 index 0000000..a06967e --- /dev/null +++ b/Sources/RDReaderView/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/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBAttachmentNormalizer.swift b/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBAttachmentNormalizer.swift index e3053af..d25948d 100644 --- a/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBAttachmentNormalizer.swift +++ b/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBAttachmentNormalizer.swift @@ -4,14 +4,14 @@ import UIKit import DTCoreText #endif -/// 附件布局规范化器,负责缩放脚注、封面和通用图片的显示尺寸。 struct RDEPUBAttachmentNormalizer { - /// 脚注附件日志已输出标记(避免重复日志) + private static var didLogFootnoteAttachment = false - /// 封面附件日志已输出标记(避免重复日志) + private static var didLogCoverAttachment = false #if canImport(DTCoreText) + func normalize( _ attachment: DTTextAttachment, fontPointSize: CGFloat, @@ -25,23 +25,28 @@ struct RDEPUBAttachmentNormalizer { } #endif - // MARK: - DTCoreText 附件布局规范化 - #if canImport(DTCoreText) - /// 对 DTTextAttachment 做尺寸规范化:脚注缩放、封面缩放、通用图片缩放。 + 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 { @@ -54,16 +59,20 @@ struct RDEPUBAttachmentNormalizer { } 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 { @@ -76,9 +85,11 @@ struct RDEPUBAttachmentNormalizer { } 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) } @@ -87,6 +98,7 @@ struct RDEPUBAttachmentNormalizer { 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), @@ -95,26 +107,31 @@ struct RDEPUBAttachmentNormalizer { } attachment.displaySize = CGSize(width: round(resolvedSize.width), height: round(resolvedSize.height)) + attachment.verticalAlignment = .center } - /// DTCoreText 元素配置:在 willFlushCallback 中调用。 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) { @@ -123,23 +140,31 @@ struct RDEPUBAttachmentNormalizer { } private static func isFootnoteAttachment(_ 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("qqreader-footnote") || lowercasedPath == "note.png" } 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) @@ -147,16 +172,15 @@ struct RDEPUBAttachmentNormalizer { } #endif - // MARK: - 通用附件规范化 - - /// 规范化 NSAttributedString 中附件的显示尺寸。 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 @@ -165,15 +189,17 @@ struct RDEPUBAttachmentNormalizer { #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? { + if let attachment = attributes[.attachment] as? NSTextAttachment { + if attachment.image != nil || attachment.fileType?.lowercased().contains("image") == true { return .image } @@ -182,6 +208,7 @@ struct RDEPUBAttachmentNormalizer { for value in attributes.values { let typeName = String(describing: type(of: value)).lowercased() + if typeName.contains("attachment") { return typeName.contains("image") ? .image : .generic } diff --git a/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBCFIMarkerInjector.swift b/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBCFIMarkerInjector.swift new file mode 100644 index 0000000..cb75f0a --- /dev/null +++ b/Sources/RDReaderView/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/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBFontNormalizer.swift b/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBFontNormalizer.swift index 0b09cbd..be5f5c3 100644 --- a/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBFontNormalizer.swift +++ b/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBFontNormalizer.swift @@ -1,16 +1,18 @@ import UIKit + import CoreText -/// 字体规范化器,负责注册 EPUB 嵌入字体并映射到用户设置字体。 struct RDEPUBFontNormalizer { - /// 已注册字体资源,避免重复调用 CTFontManager。 + 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, @@ -18,70 +20,119 @@ struct RDEPUBFontNormalizer { ) } - // MARK: - 字体注册 - - /// 从 CSS 中解析 @font-face 规则,注册嵌入字体。 + @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 + 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://"), + !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 } - registerFontIfNeeded(at: fileURL) + + 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 } - /// 若字体尚未注册,则通过 CTFontManager 注册。 - /// - Parameter fileURL: 字体文件 URL - static func registerFontIfNeeded(at fileURL: URL) { + @discardableResult + static func registerFontIfNeeded(at fileURL: URL) -> Bool { + let standardizedPath = fileURL.standardizedFileURL.path - guard !registeredFontPaths.contains(standardizedPath) else { return } - CTFontManagerRegisterFontsForURL(fileURL as CFURL, .process, nil) - registeredFontPaths.insert(standardizedPath) + + guard !registeredFontPaths.contains(standardizedPath) else { return true } + + let registered = CTFontManagerRegisterFontsForURL(fileURL as CFURL, .process, nil) + if registered { + + registeredFontPaths.insert(standardizedPath) + } + return registered } - // MARK: - 字体标准化 - - /// 将 EPUB 原始字体映射到用户设置字体,保留粗体/斜体特征。 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 } - /// 提取 HTML 中内联 "#, 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 } @@ -89,4 +140,21 @@ struct RDEPUBFontNormalizer { } .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/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBFragmentMarkerInjector.swift b/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBFragmentMarkerInjector.swift index 1382faf..45272af 100644 --- a/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBFragmentMarkerInjector.swift +++ b/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBFragmentMarkerInjector.swift @@ -1,26 +1,16 @@ -/// RDEPUBFragmentMarkerInjector - Fragment 标记注入与偏移量提取 + import Foundation -/// Fragment 标记注入器,负责在 HTML 中注入 fragment 锚点标记并从渲染结果中提取偏移量。 -/// -/// 工作流程: -/// 1. `process` 阶段:将 HTML 中的 `id` 属性元素转为 `${id=xxx}` 文本标记 -/// 2. `extractOffsets` 阶段:从 NSAttributedString 中扫描标记,记录偏移量后删除标记 struct RDEPUBFragmentMarkerInjector: RDEPUBTypesettingStage { - /// 将 HTML 中的 id 元素注入 fragment 文本标记,供后续偏移量提取使用。 + func process(_ html: String, context: RDEPUBTypesettingInput) -> String { Self.injectFragmentMarkers(into: html) } - /// 从渲染后的富文本中扫描 `${id=xxx}` 标记,提取 fragment ID 到字符偏移量的映射。 func extractOffsets(from attributedString: NSMutableAttributedString) -> [String: Int] { Self.extractFragmentOffsets(from: attributedString) } - // MARK: - Fragment 标记注入 - - /// 将 HTML 中的 id 属性元素注入 fragment 标记。 - /// `` → `${id=xxx}` static func injectFragmentMarkers(into html: String) -> String { guard let regex = try? NSRegularExpression(pattern: #"(<[^>]+\sid="([^"]+)"[^>]*>)"#, options: [.caseInsensitive]) else { return html @@ -33,32 +23,43 @@ struct RDEPUBFragmentMarkerInjector: RDEPUBTypesettingStage { ) } - // MARK: - Fragment 偏移量提取 + - /// 从渲染后的富文本中提取 fragment 偏移量映射。 - /// 扫描 `${id=xxx}` 标记,记录偏移量,然后删除标记文本。 + + 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) } diff --git a/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBHTMLNormalizer.swift b/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBHTMLNormalizer.swift index bd1c7f2..61c50f2 100644 --- a/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBHTMLNormalizer.swift +++ b/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBHTMLNormalizer.swift @@ -1,25 +1,18 @@ import Foundation -/// HTML 规范化器,清理冗余字符并规范化附件 HTML 标记。 struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage { - /// 执行 HTML 规范化处理。 - /// - Parameters: - /// - html: 原始 HTML - /// - context: 排版上下文 - /// - Returns: 规范化后的 HTML + func process(_ html: String, context: RDEPUBTypesettingInput) -> String { Self.normalizeHTML(html) } - // MARK: - HTML 规范化入口 - - /// 清理冗余字符(CR、多余换行),规范化附件 HTML 标记。 static func normalizeHTML(_ html: String) -> String { var cleanedHTML = html + let replacements: [(pattern: String, template: String)] = [ - (#"分页符"#, ""), - (#"\r"#, "\n"), - (#"\n+"#, "\n") + (#"分页符"#, ""), + (#"\r"#, "\n"), + (#"\n+"#, "\n") ] for replacement in replacements { @@ -37,9 +30,6 @@ struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage { return cleanedHTML } - // MARK: - 附件 HTML 标记规范化 - - /// 处理 bodyPic div、脚注 img、封面 h1+img 等特殊 HTML 结构。 private static func normalizeAttachmentHTMLMarkers(in html: String) -> String { var normalized = html @@ -51,10 +41,12 @@ struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage { using: bodyPicContainerRegex, in: normalized ) { tag in + guard let imageTagRegex = try? NSRegularExpression(pattern: #"]*>"#, options: [.caseInsensitive]) else { return tag } + return replaceMatches( using: imageTagRegex, in: tag @@ -75,6 +67,7 @@ struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage { } } + if let footnoteRegex = try? NSRegularExpression( pattern: #"]*class\s*=\s*["'][^"']*\bqqreader-footnote\b[^"']*["'][^>]*)>"#, options: [.caseInsensitive] @@ -83,6 +76,7 @@ struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage { using: footnoteRegex, in: normalized ) { tag in + mergeHTMLAttributes( into: tag, requiredClass: nil, @@ -96,6 +90,7 @@ struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage { } } + if let coverRegex = try? NSRegularExpression( pattern: #"]*class\s*=\s*["'][^"']*\bfrontCover\b[^"']*["'][^>]*)>\s*(]*>)\s*
      "#, options: [.caseInsensitive] @@ -104,6 +99,7 @@ struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage { using: coverRegex, in: normalized ) { tag in + guard let imageTagRegex = try? NSRegularExpression(pattern: #"]*>"#, options: [.caseInsensitive]), let imageMatch = imageTagRegex.firstMatch( in: tag, @@ -133,15 +129,13 @@ struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage { return normalized } - // MARK: - HTML 工具方法 - - /// 通用反向正则替换:遍历所有匹配,对每个匹配的原始文本调用 transform。 static func replaceMatches( using regex: NSRegularExpression, in source: String, transform: (String) -> 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 } @@ -154,7 +148,6 @@ struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage { return rewritten } - /// 合并 HTML 标签的 class 和 style 属性。 static func mergeHTMLAttributes( into tag: String, requiredClass: String?, @@ -194,7 +187,6 @@ struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage { return rewritten } - /// 注入 `` 标签以解析相对路径。 static func injectBaseHref(into html: String, baseURL: URL?) -> String { guard let baseURL else { return html @@ -213,7 +205,6 @@ struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage { return "\n\(baseTag)\n\n" + html } - /// 工具方法:从字符串范围构建 CGSize 描述。 static func string(from size: CGSize) -> String { "{\(Int(round(size.width))), \(Int(round(size.height)))}" } diff --git a/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBRenderDiagnosticsCollector.swift b/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBRenderDiagnosticsCollector.swift index e3b37b2..c4d0043 100644 --- a/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBRenderDiagnosticsCollector.swift +++ b/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBRenderDiagnosticsCollector.swift @@ -1,17 +1,16 @@ import Foundation -/// 渲染诊断收集器,扫描 HTML 中的资源引用并生成诊断信息。 struct RDEPUBRenderDiagnosticsCollector { - /// 外部样式表链接正则 + private static let stylesheetLinkPattern = #"]*rel\s*=\s*["'][^"']*stylesheet[^"']*["'][^>]*href\s*=\s*["']([^"']+)["'][^>]*>"# - /// 图片源地址正则 + private static let imageSourcePattern = #"]*src\s*=\s*["']([^"']+)["'][^>]*>"# - /// 收集 HTML 中图片资源的引用诊断。 - /// - Parameters: - /// - html: 待扫描的 HTML - /// - input: 排版输入上下文 - /// - Returns: 资源引用诊断列表 + + + + + func collect( in html: String, input: RDEPUBTypesettingInput @@ -24,9 +23,9 @@ struct RDEPUBRenderDiagnosticsCollector { ) } - // MARK: - 图片诊断 + - /// 扫描 HTML 中的 标签,构建资源引用诊断。 + static func collectImageDiagnostics( in html: String, chapterHref: String, @@ -50,9 +49,9 @@ struct RDEPUBRenderDiagnosticsCollector { } } - // MARK: - 外部样式表内联 + - /// 查找 `` 标签,内联 CSS 内容。 + static func inlineLinkedStyleSheets( in html: String, chapterHref: String, @@ -69,10 +68,14 @@ struct RDEPUBRenderDiagnosticsCollector { 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)) @@ -103,9 +106,9 @@ struct RDEPUBRenderDiagnosticsCollector { return (rewrittenHTML, inlinedCSSBlocks.reversed().joined(separator: "\n\n"), diagnostics.reversed()) } - // MARK: - CSS 资源 URL 重写 + - /// 重写 CSS 中的相对 url() 引用,解析为绝对文件路径。 + static func rewriteCSSResourceURLs( in css: String, styleSheetFileURL: URL @@ -121,18 +124,25 @@ struct RDEPUBRenderDiagnosticsCollector { } 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://") || rawValue.hasPrefix("file://") || rawValue.hasPrefix("#") { + + 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) @@ -141,8 +151,6 @@ struct RDEPUBRenderDiagnosticsCollector { return rewrittenCSS } - // MARK: - 资源引用解析 - static func resolveReference( _ reference: String, kind: RDEPUBTextResourceReferenceKind, @@ -150,11 +158,16 @@ struct RDEPUBRenderDiagnosticsCollector { 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, diff --git a/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBSemanticMarkerInjector.swift b/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBSemanticMarkerInjector.swift index b06a047..cc941ac 100644 --- a/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBSemanticMarkerInjector.swift +++ b/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBSemanticMarkerInjector.swift @@ -1,29 +1,19 @@ -/// RDEPUBSemanticMarkerInjector - 分页语义标记注入与应用 + import Foundation import UIKit -/// 语义标记注入器,为 HTML 标签注入分页语义信息(块级类型、分页提示、附件放置方式等)。 -/// -/// 分两阶段工作: -/// 1. HTML 阶段(`process`):解析标签并注入 `${rd-sem-start/end}` 语义标记 -/// 2. 渲染后阶段(`apply`):将标记解析为 NSAttributedString 属性 struct RDEPUBSemanticMarkerInjector: RDEPUBTypesettingStage { - /// 语义标记正则(${rd-sem-start:...} / ${rd-sem-end:...}) + private static let semanticMarkerPattern = #"\$\{rd-sem-(start|end):([^}]+)\}"# - /// 为 HTML 标签注入 `${rd-sem-start/end}` 语义标记,标记块级元素类型和分页提示。 func process(_ html: String, context: RDEPUBTypesettingInput) -> String { Self.injectPaginationSemanticMarkers(into: html) } - /// 解析渲染后富文本中的语义标记,将其转为 NSAttributedString 属性并删除标记文本。 func apply(to attributedString: NSMutableAttributedString) { Self.applyPaginationSemantics(in: attributedString) } - // MARK: - 语义标记注入(HTML 阶段) - - /// 为 HTML 标签注入 ${rd-sem-start/end} 语义标记。 static func injectPaginationSemanticMarkers(into html: String) -> String { guard let regex = try? NSRegularExpression(pattern: #"<[^>]+>"#, options: [.caseInsensitive]) else { return html @@ -36,8 +26,11 @@ struct RDEPUBSemanticMarkerInjector: RDEPUBTypesettingStage { } var output = "" + var cursor = 0 + var openTagStack: [(name: String, id: String)] = [] + var nextMarkerID = 0 for match in matches { @@ -47,9 +40,11 @@ struct RDEPUBSemanticMarkerInjector: RDEPUBTypesettingStage { 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 } @@ -77,30 +76,34 @@ struct RDEPUBSemanticMarkerInjector: RDEPUBTypesettingStage { return output } - // MARK: - 语义标记应用(渲染后阶段) - - /// 将 HTML 中注入的语义标记解析后写入 NSAttributedString 属性。 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) @@ -114,8 +117,6 @@ struct RDEPUBSemanticMarkerInjector: RDEPUBTypesettingStage { } } - // MARK: - 语义推断 - static func htmlTagName(from loweredTag: String) -> String? { let trimmed = loweredTag.trimmingCharacters(in: .whitespacesAndNewlines) guard trimmed.hasPrefix("<") else { return nil } @@ -297,7 +298,6 @@ struct RDEPUBSemanticMarkerInjector: RDEPUBTypesettingStage { 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) { @@ -306,7 +306,6 @@ struct RDEPUBSemanticMarkerInjector: RDEPUBTypesettingStage { return nil } - /// 推断语义提示列表。 static func normalizeSemanticHints(for attributes: [NSAttributedString.Key: Any]) -> [RDEPUBTextSemanticHint]? { if let rawValue = attributes[.rdPageSemanticHints] as? String { let hints = rawValue @@ -317,7 +316,6 @@ struct RDEPUBSemanticMarkerInjector: RDEPUBTypesettingStage { return nil } - /// 推断附件放置方式。 static func normalizeAttachmentPlacement(for attributes: [NSAttributedString.Key: Any]) -> RDEPUBTextAttachmentPlacement? { if let rawValue = attributes[.rdPageAttachmentPlacement] as? String, let placement = RDEPUBTextAttachmentPlacement(rawValue: rawValue) { @@ -329,9 +327,6 @@ struct RDEPUBSemanticMarkerInjector: RDEPUBTypesettingStage { return nil } - // MARK: - 内部类型 - - /// 单个 HTML 元素的分页语义信息,由标签名和属性推断而来。 struct RDPaginationSemantics { var id: String var blockKind: RDEPUBTextBlockKind? diff --git a/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBStyleSheetComposer.swift b/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBStyleSheetComposer.swift index a173887..722eb70 100644 --- a/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBStyleSheetComposer.swift +++ b/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBStyleSheetComposer.swift @@ -1,24 +1,20 @@ import UIKit -/// 样式表组合结果,包含合成后的 HTML、CSS 层列表及诊断信息。 struct RDEPUBStyleSheetComposition { - /// 合成后的 HTML 文本 + var html: String - /// 五层 CSS 样式层(default/replace/dark/epub/user) + var layers: [RDEPUBTextStyleSheetLayer] - /// 内联后的 CSS 内容 + var inlinedCSS: String - /// 资源引用诊断列表 + var diagnostics: [RDEPUBTextResourceReferenceDiagnostic] + + var compatibilityReport: RDEPUBCSSCompatibilityReport } -/// EPUB 样式表合成器,负责组装五层 CSS 并注入 HTML。 struct RDEPUBStyleSheetComposer { - /// 将 CSS 层注入 HTML,返回合成结果。 - /// - Parameters: - /// - html: 原始 HTML - /// - input: 排版输入上下文 - /// - Returns: 包含合成 HTML、CSS 层和诊断信息的结果 + func compose(html: String, input: RDEPUBTypesettingInput) -> RDEPUBStyleSheetComposition { let stylesheetHrefReplacements = RDEPUBRenderDiagnosticsCollector.inlineLinkedStyleSheets( in: html, @@ -26,9 +22,10 @@ struct RDEPUBStyleSheetComposer { baseURL: input.baseURL, resourceResolver: input.resourceResolver ) + let compatibility = RDEPUBCSSCompatibilityLayer().sanitize(stylesheetHrefReplacements.inlinedCSS) let layers = Self.makeStyleSheetLayers( style: input.style, - epubCSS: stylesheetHrefReplacements.inlinedCSS, + epubCSS: compatibility.css, contentLanguageCode: input.contentLanguageCode, sourceHTML: input.rawHTML ) @@ -61,14 +58,12 @@ struct RDEPUBStyleSheetComposer { return RDEPUBStyleSheetComposition( html: composedHTML, layers: layers, - inlinedCSS: stylesheetHrefReplacements.inlinedCSS, - diagnostics: stylesheetHrefReplacements.diagnostics + inlinedCSS: compatibility.css, + diagnostics: stylesheetHrefReplacements.diagnostics, + compatibilityReport: compatibility.report ) } - // MARK: - CSS 层组装 - - /// 构建五层 CSS 数组(default/replace/dark/epub/user)。 static func makeStyleSheetLayers( style: RDEPUBTextRenderStyle, epubCSS: String, @@ -93,17 +88,13 @@ struct RDEPUBStyleSheetComposer { return layers } - // MARK: - Style 注入 - - /// ` + + \(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/RDReaderView/EPUBUI/RDEPUBReaderBottomToolView.swift b/Sources/RDReaderView/EPUBUI/RDEPUBReaderBottomToolView.swift index f72e004..21f57dd 100644 --- a/Sources/RDReaderView/EPUBUI/RDEPUBReaderBottomToolView.swift +++ b/Sources/RDReaderView/EPUBUI/RDEPUBReaderBottomToolView.swift @@ -1,27 +1,17 @@ import UIKit -// MARK: - 底部工具栏 - -/// 阅读器底部工具栏 -/// 提供目录、书签、高亮管理、新建标注和设置五个功能入口 -/// 按钮可见性可根据 configuration 动态调整 final class RDEPUBReaderBottomToolView: RDEPUBReaderToolView { - // MARK: 回调闭包 - /// 点击目录按钮回调 var onShowTableOfContents: (() -> Void)? - /// 点击书签列表按钮回调 + var onShowBookmarks: (() -> Void)? - /// 点击高亮列表按钮回调 + var onShowHighlights: (() -> Void)? - /// 点击新建标注按钮回调 + var onAddHighlight: (() -> Void)? - /// 点击设置按钮回调 + var onShowSettings: (() -> Void)? - // MARK: UI 组件 - - /// 水平等分布局的按钮容器 private let stackView: UIStackView = { let view = UIStackView() view.axis = .horizontal @@ -31,21 +21,20 @@ final class RDEPUBReaderBottomToolView: RDEPUBReaderToolView { 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) @@ -99,9 +88,6 @@ final class RDEPUBReaderBottomToolView: RDEPUBReaderToolView { } } - // MARK: 按钮状态控制 - - /// 设置新建标注按钮是否可用(有选中文本时可用) func setAddHighlightEnabled(_ isEnabled: Bool) { addHighlightButton.isEnabled = isEnabled addHighlightButton.alpha = isEnabled ? 1 : 0.45 diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderChapterListController.swift b/Sources/RDReaderView/EPUBUI/RDEPUBReaderChapterListController.swift index f0eceb7..184128a 100644 --- a/Sources/RDReaderView/EPUBUI/RDEPUBReaderChapterListController.swift +++ b/Sources/RDReaderView/EPUBUI/RDEPUBReaderChapterListController.swift @@ -1,15 +1,13 @@ import UIKit -// MARK: - 目录面板 - -/// EPUB 阅读器的目录面板控制器 -/// 以列表形式展示书籍目录,支持多级缩进,当前所在章节高亮显示 final class RDEPUBReaderChapterListController: UITableViewController { - /// 用户选择目录项时的回调 + var onSelectItem: ((RDEPUBReaderTableOfContentsItem) -> Void)? private let items: [RDEPUBReaderTableOfContentsItem] + private let currentItem: RDEPUBReaderTableOfContentsItem? + private let theme: RDEPUBReaderTheme init( diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift b/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift index 2408b47..2a24446 100644 --- a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift +++ b/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift @@ -1,19 +1,8 @@ -// RDEPUBReaderController+ContentDelegates.swift -// EPUB 阅读器内容视图代理实现 -// 处理 Web 渲染路径(RDEPUBWebContentViewDelegate)和 Native Text 渲染路径 -// (RDEPUBTextContentViewDelegate)的位置更新、选区变化、链接跳转、错误日志等事件回调。 import UIKit -/// RDEPUBReaderController 内容代理扩展 -/// -/// 本文件实现 EPUB 阅读器的内容视图代理,处理 Web 渲染路径和 Native Text 渲染路径的 -/// 位置更新、选区变化、链接跳转、错误日志等事件回调。 - -// MARK: - Web 内容视图代理(EPUB 固定布局/Web 渲染路径) - extension RDEPUBReaderController: RDEPUBWebContentViewDelegate { - /// Web 内容视图位置更新回调,同步阅读上下文与持久化位置 + func epubWebContentView(_ contentView: RDEPUBWebContentView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int) { guard readerView.currentPage >= 0, activePages.indices.contains(readerView.currentPage) else { @@ -35,7 +24,6 @@ extension RDEPUBReaderController: RDEPUBWebContentViewDelegate { ) } - /// Web 内容视图选区变化回调,转换为统一选区模型 func epubWebContentView(_ contentView: RDEPUBWebContentView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int) { if let selection { updateCurrentSelection(scopedSelection(selection, relativeToSpineIndex: spineIndex)) @@ -44,13 +32,15 @@ extension RDEPUBReaderController: RDEPUBWebContentViewDelegate { } } - /// Web 内容视图选区菜单操作回调 func epubWebContentView(_ contentView: RDEPUBWebContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) { handleSelectionMenuAction(action, selection: currentSelection) } - /// Web 内容视图内部链接点击回调,执行页内跳转 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, @@ -62,13 +52,11 @@ extension RDEPUBReaderController: RDEPUBWebContentViewDelegate { readerView.transitionToPage(pageNum: max(pageNumber - 1, 0), animated: true) } - /// Web 内容视图外部链接点击回调,根据配置策略决定是否打开 func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateExternalLink url: URL) { delegate?.epubReader(self, didActivateExternalLink: url) openExternalURLIfAllowed(url) } - /// Web 内容视图 JavaScript 错误日志回调 func epubWebContentView(_ contentView: RDEPUBWebContentView, didLogJavaScriptError message: String) { #if DEBUG print("EPUB JS Error: \(message)") @@ -76,10 +64,8 @@ extension RDEPUBReaderController: RDEPUBWebContentViewDelegate { } } -// MARK: - 文本内容视图代理(Native Text 渲染路径) - extension RDEPUBReaderController: RDEPUBTextContentViewDelegate { - /// 文本内容视图选区变化回调,标准化后更新当前选区 + func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?) { guard let selection else { updateCurrentSelection(nil) @@ -88,7 +74,10 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate { updateCurrentSelection(selection) } - /// 文本内容视图选区菜单操作回调 + func textContentView(_ contentView: RDEPUBTextContentView, didRequestReaderTapAt point: CGPoint) { + readerView.handleContentTap(at: point, in: contentView) + } + func textContentView( _ contentView: RDEPUBTextContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction, @@ -115,8 +104,6 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate { runtime.presentHighlightActions(for: highlight, sourceView: contentView, sourceRect: sourceRect) } - - /// 根据 EPUB 位置计算对应的页码 func pageNumber(for location: RDEPUBLocation) -> Int? { if let publication, let bookPageMap = readerContext.bookPageMap, @@ -169,38 +156,14 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate { ) } - /// 根据页码解析对应的文本位置 func resolvedTextLocation(forPageNumber pageNumber: Int) -> RDEPUBLocation? { if let resolvedPage = resolvedRuntimePage(forPageNumber: pageNumber) { - let chapterLength = max(resolvedPage.chapter.typesetAttributedString.length, 1) let startOffset = resolvedPage.page.pageStartOffset let endOffset = max(startOffset, resolvedPage.page.pageEndOffset) - let fragmentID = nearestFragmentID( - beforeOrAt: startOffset, - fragmentOffsets: resolvedPage.chapter.chapterOffsetMap.fragmentOffsets - ) - let location = RDEPUBLocation( - bookIdentifier: currentBookIdentifier, - href: resolvedPage.page.href, - progression: Double(startOffset) / Double(chapterLength), - lastProgression: Double(endOffset) / Double(chapterLength), - fragment: fragmentID, - rangeAnchor: RDEPUBTextRangeAnchor( - start: RDEPUBTextAnchor( - fileIndex: resolvedPage.page.spineIndex, - row: 0, - column: 0, - chapterOffset: startOffset, - fragmentID: fragmentID - ), - end: RDEPUBTextAnchor( - fileIndex: resolvedPage.page.spineIndex, - row: 0, - column: 0, - chapterOffset: endOffset, - fragmentID: fragmentID - ) - ) + 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( @@ -231,7 +194,6 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate { return location } - /// 同步文本阅读状态到阅读会话(页码、位置、spine、章节等) func synchronizeTextReadingState(pageNumber: Int, location: RDEPUBLocation) { if let resolvedPage = resolvedRuntimePage(forPageNumber: pageNumber) { readingSession?.updateReadingContext( @@ -259,7 +221,6 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate { ) } - /// 从文本书籍生成原生文本快照(页面列表 + 章节信息) func nativeTextSnapshot(from textBook: RDEPUBTextBook) -> RDEPUBNativeTextSnapshot { let chapters = textBook.chapterInfos let pages = textBook.pages.map { @@ -280,6 +241,15 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate { } func chapterOffset(for location: RDEPUBLocation, fallbackEntry: RDEPUBBookPageMapEntry) -> Int { + if let spineIndex = readerContext.normalizedSpineIndex(for: location), + let runtimeChapter = runtime.chapterRuntimeStore.chapterData(for: spineIndex), + let offset = runtimeChapter.chapterOffsetMap.chapterOffset(forCFI: location.cfi) { + return offset + } + if let cfi = RDEPUBCFICompatibility.parseLossy(location.cfi), + let cfiOffset = RDEPUBCFIResolver.resolve(cfi).chapterOffset { + return cfiOffset + } if let anchor = location.rangeAnchor?.start { return anchor.chapterOffset } @@ -308,8 +278,6 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate { return bestID } - // MARK: - 外部链接策略 - private func shouldAllowExternalURL(_ url: URL) -> Bool { guard let scheme = url.scheme?.lowercased() else { return false } if delegate?.epubReader(self, shouldOpenExternalURL: url) == false { @@ -340,6 +308,52 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate { present(alert, animated: true) } + 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) + } + private func presentAttachmentTooltip(text: String, sourceView: UIView, sourceRect: CGRect, sourcePoint: CGPoint) { hideAttachmentTooltipIfNeeded() @@ -415,7 +429,28 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate { } } +private extension RDEPUBReaderController { + 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]) + ) + } +} + private final class RDEPUBAttachmentTooltipOverlayView: UIView { + var onBackgroundTap: (() -> Void)? var tooltipView: RDEPUBAttachmentTooltipView? { @@ -449,17 +484,26 @@ private final class RDEPUBAttachmentTooltipOverlayView: UIView { } private 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 @@ -468,6 +512,7 @@ private final class RDEPUBAttachmentTooltipView: UIView { label.lineBreakMode = .byWordWrapping return label }() + private let shapeLayer = CAShapeLayer() override init(frame: CGRect) { diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+DataSource.swift b/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+DataSource.swift index 03c0cdb..e3df2d4 100644 --- a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+DataSource.swift +++ b/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+DataSource.swift @@ -1,35 +1,34 @@ -// RDEPUBReaderController+DataSource.swift -// EPUB 阅读器数据源与代理实现 -// 实现 RDReaderDataSource 和 RDReaderDelegate 协议,为阅读器视图提供页面数量、 -// 页面内容视图、页码变化通知、屏幕方向变化处理等核心数据与事件支持。 import UIKit -/// RDEPUBReaderController 数据源与代理扩展 -/// -/// 本文件实现 RDReaderDataSource 和 RDReaderDelegate 协议,为阅读器视图提供页面数量、 -/// 页面内容视图、页码变化通知、屏幕方向变化处理等核心数据与事件支持。 - -// MARK: - RDReaderView 数据源与代理 - extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate { - /// 返回阅读器总页数 + public func pageCountOfReaderView(readerView: RDReaderView) -> Int { readerContext.bookPageMap?.totalPages ?? textBook?.pages.count ?? activePages.count } - /// 为指定页码创建或复用内容视图(优先文本渲染,回退 Web 渲染) public func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView { if readerContext.bookPageMap != nil { _ = runtime.prepareOnDemandChapter(forAbsolutePageNumber: pageNum + 1) if let resolvedPage = runtime.pageResolver.resolvePage(absolutePageIndex: pageNum) { 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) ) @@ -40,11 +39,22 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate { 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) ) @@ -68,14 +78,12 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate { return contentView } - /// 返回页面内容视图的重用标识符(区分文本与 Web 渲染) public func pageIdentifier(readerView: RDReaderView, pageNum: Int) -> String? { (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) { @@ -91,9 +99,6 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate { } } - /// 返回当前文本页需要展示的搜索状态。 - /// 这里会先按页过滤命中结果,并把 currentMatchIndex 重映射到当前页内的局部索引, - /// 这样渲染层不需要再关心 href 规范化差异。 private func searchState(for page: RDEPUBTextPage) -> RDEPUBSearchState? { guard let globalSearchState = searchState else { return nil } @@ -237,6 +242,7 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate { title: runtimeChapter.title, attributedContent: runtimeChapter.typesetAttributedString, fragmentOffsets: runtimeChapter.chapterOffsetMap.fragmentOffsets, + cfiMap: runtimeChapter.chapterOffsetMap.cfiMap, pageBreakReasons: runtimeChapter.pages.map(\.metadata.breakReason), pages: runtimeChapter.pages ) @@ -274,31 +280,25 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate { return publication.resourceResolver.normalizedHref(href) ?? href } - /// 根据规范化 href 获取文本章节数据 func textChapterData(forNormalizedHref href: String) -> RDEPUBChapterData? { readerContext.textChapterData(forNormalizedHref: href) } - /// 返回阅读器顶部工具视图 public func topToolView(readerView: RDReaderView) -> UIView? { topToolView } - /// 返回阅读器底部工具视图 public func bottomToolView(readerView: RDReaderView) -> UIView? { bottomToolView } - /// 页码变化回调:清除选区、同步阅读状态、检测到达末尾 public func pageNum(readerView: RDReaderView, pageNum: Int) { readerContext.markUserNavigationActivity() updateCurrentSelection(nil) reconcileTextPaginationSizeIfNeeded(for: pageNum) - // 如果正在重新分页,跳过后续操作,避免状态冲突 guard !isRepaginating else { return } - // 用户开始导航时,应用后台解析完成的完整 map let previousCurrentPage = readerView.currentPage runtime.applyPendingFullPageMapIfNeeded() let effectivePageNum = readerView.currentPage >= 0 ? readerView.currentPage : pageNum @@ -320,7 +320,7 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate { let location = resolvedTextLocation(forPageNumber: effectivePageNum + 1) { persist(location: location) synchronizeTextReadingState(pageNumber: effectivePageNum + 1, location: location) - // 记录翻页到 JumpSession + runtime.locationCoordinator.recordPageChangeIfNeeded() return } @@ -332,17 +332,14 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate { readingSession?.transition(to: .idle) } - // 记录翻页到 JumpSession runtime.locationCoordinator.recordPageChangeIfNeeded() } - /// 屏幕方向即将变化回调,捕获待恢复的展示位置 public func readerViewOrientationWillChange(readerView: RDReaderView, isLandscape: Bool) { _ = isLandscape runtime.viewportMonitor.capturePendingPresentationRestoreLocation() } - /// 检测页面尺寸变化并触发重新分页(避免布局错乱) private func reconcileTextPaginationSizeIfNeeded(for pageNum: Int) { guard textBook != nil || readerContext.bookPageMap != nil, !isRepaginating, @@ -365,12 +362,12 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate { } 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 } let stillChanged = abs(currentSize.width - self.lastTextPaginationPageSize!.width) > 0.5 diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+PublicAPI.swift b/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+PublicAPI.swift index 1c33a8e..afe8d50 100644 --- a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+PublicAPI.swift +++ b/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+PublicAPI.swift @@ -1,54 +1,34 @@ -// RDEPUBReaderController+PublicAPI.swift -// RDEPUBReaderController 的公开 API 扩展,提供书签、高亮、目录跳转、搜索等操作 import UIKit -// MARK: - Public Reader Commands - extension RDEPUBReaderController { - /// 重新加载书籍,重置所有状态并重新解析 EPUB + public func reloadBook() { runtime.reloadBook() } - /// 跳转到指定阅读位置 - /// - Parameter location: 目标阅读位置 public func go(to location: RDEPUBLocation) { guard publication != nil else { return } _ = runtime.go(to: location) } - /// 跳转到指定页码 - /// - Parameters: - /// - pageNumber: 目标页码(从 1 开始) - /// - animated: 是否动画过渡 - /// - Returns: 是否跳转成功 @discardableResult public func go(toPageNumber pageNumber: Int, animated: Bool = false) -> Bool { runtime.go(toPageNumber: pageNumber, animated: animated) } - /// 清除当前文本选中状态 public func clearSelection() { runtime.clearSelection() } - /// 根据唯一标识获取书签 - /// - Parameter id: 书签 ID - /// - Returns: 匹配的书签对象,不存在时返回 nil public func bookmark(withID id: String) -> RDEPUBBookmark? { runtime.bookmark(withID: id) } - /// 根据唯一标识获取高亮标注 - /// - Parameter id: 高亮 ID - /// - Returns: 匹配的高亮对象,不存在时返回 nil public func highlight(withID id: String) -> RDEPUBHighlight? { runtime.highlight(withID: id) } - /// 获取当前页的原生文本语义摘要,用于调试和可访问性 - /// - Returns: 包含页码、断行原因、块类型等信息的摘要字符串,无内容时返回 nil public func nativeTextSemanticSummary() -> String? { let resolvedPage: RDEPUBTextPage? if let textBook { @@ -79,12 +59,6 @@ extension RDEPUBReaderController { return parts.joined(separator: " · ") } - /// 添加高亮标注,从当前选中文本或指定选区创建 - /// - Parameters: - /// - selection: 文本选区,默认使用 currentSelection - /// - color: 高亮颜色(CSS 格式),默认黄色 - /// - note: 可选批注文字 - /// - Returns: 创建的高亮对象,重复时返回 nil @discardableResult public func addHighlight( from selection: RDEPUBSelection? = nil, @@ -94,13 +68,6 @@ extension RDEPUBReaderController { runtime.addHighlight(from: selection, color: color, note: note) } - /// 添加指定样式的标注,从当前选中文本或指定选区创建 - /// - Parameters: - /// - selection: 文本选区,默认使用 currentSelection - /// - style: 标注样式(下划线、高亮等) - /// - color: 标注颜色(CSS 格式),默认黄色 - /// - note: 可选批注文字 - /// - Returns: 创建的高亮对象,重复时返回 nil @discardableResult public func addAnnotation( from selection: RDEPUBSelection? = nil, @@ -111,72 +78,40 @@ extension RDEPUBReaderController { runtime.addAnnotation(from: selection, style: style, color: color, note: note) } - /// 插入或更新高亮标注(存在则更新,不存在则插入) - /// - Parameter highlight: 要写入的高亮对象 - /// - Returns: 写入后的高亮对象 @discardableResult public func upsertHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight? { runtime.upsertHighlight(highlight) } - /// 移除指定高亮标注 - /// - Parameter id: 高亮 ID - /// - Returns: 被移除的高亮对象,不存在时返回 nil @discardableResult public func removeHighlight(id: String) -> RDEPUBHighlight? { runtime.removeHighlight(id: id) } - /// 更新高亮标注的批注内容 - /// - Parameters: - /// - id: 高亮 ID - /// - note: 新的批注文字,传 nil 清除批注 - /// - Returns: 更新后的高亮对象,不存在时返回 nil @discardableResult public func updateHighlightNote(id: String, note: String?) -> RDEPUBHighlight? { runtime.updateHighlightNote(id: id, note: note) } - /// 跳转到指定高亮标注所在位置 - /// - Parameters: - /// - id: 高亮 ID - /// - animated: 是否动画过渡 - /// - Returns: 是否跳转成功 @discardableResult public func go(toHighlightID id: String, animated: Bool = true) -> Bool { runtime.go(toHighlightID: id, animated: animated) } - /// 移除当前书籍的所有高亮标注 public func removeAllHighlights() { runtime.removeAllHighlights() } - /// 跳转到目录项对应的阅读位置 - /// - Parameters: - /// - item: EPUB 原生目录项 - /// - animated: 是否动画过渡 - /// - Returns: 是否跳转成功 @discardableResult public func go(toTableOfContentsItem item: EPUBTableOfContentsItem, animated: Bool = true) -> Bool { go(toTableOfContentsHref: item.href, animated: animated) } - /// 跳转到目录项对应的阅读位置 - /// - Parameters: - /// - item: RDReader 目录项 - /// - animated: 是否动画过渡 - /// - Returns: 是否跳转成功 @discardableResult public func go(toTableOfContentsItem item: RDEPUBReaderTableOfContentsItem, animated: Bool = true) -> Bool { go(toTableOfContentsHref: item.href, animated: animated) } - /// 通过目录 href 跳转到对应阅读位置,支持带锚点的 href - /// - Parameters: - /// - href: 目录资源路径(可含 #fragment) - /// - animated: 是否动画过渡 - /// - Returns: 是否跳转成功 @discardableResult public func go(toTableOfContentsHref href: String, animated: Bool = true) -> Bool { guard publication != nil else { return false } @@ -193,61 +128,40 @@ extension RDEPUBReaderController { return restoreReadingLocation(location, animated: animated) } - /// 在当前位置添加书签 - /// - Parameter note: 可选批注文字 - /// - Returns: 创建的书签对象,位置已存在书签时返回 nil @discardableResult public func addBookmark(note: String? = nil) -> RDEPUBBookmark? { runtime.addBookmark(note: note) } - /// 切换当前位置的书签状态(有则移除,无则添加) - /// - Parameter note: 添加时可附带的批注文字 - /// - Returns: 添加时返回新书签,移除时返回被移除的书签 @discardableResult public func toggleBookmark(note: String? = nil) -> RDEPUBBookmark? { runtime.toggleBookmark(note: note) } - /// 移除指定书签 - /// - Parameter id: 书签 ID - /// - Returns: 被移除的书签对象,不存在时返回 nil @discardableResult public func removeBookmark(id: String) -> RDEPUBBookmark? { runtime.removeBookmark(id: id) } - /// 跳转到指定书签所在位置 - /// - Parameters: - /// - id: 书签 ID - /// - animated: 是否动画过渡 - /// - Returns: 是否跳转成功 @discardableResult public func go(toBookmarkID id: String, animated: Bool = true) -> Bool { runtime.go(toBookmarkID: id, animated: animated) } - /// 执行全文搜索,匹配结果非空时自动跳转到第一个匹配项,无匹配时清除搜索状态 - /// - Parameter keyword: 搜索关键词,空字符串会清除搜索 public func search(keyword: String) { runtime.search(keyword: keyword) } - /// 跳转到下一个搜索匹配项 - /// - Returns: 是否存在下一个匹配项并跳转成功 @discardableResult public func searchNext() -> Bool { runtime.searchNext() } - /// 跳转到上一个搜索匹配项 - /// - Returns: 是否存在上一个匹配项并跳转成功 @discardableResult public func searchPrevious() -> Bool { runtime.searchPrevious() } - /// 清除搜索状态和高亮 public func clearSearch() { runtime.clearSearch() } diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+RenderSupport.swift b/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+RenderSupport.swift index 797e26f..a07576e 100644 --- a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+RenderSupport.swift +++ b/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+RenderSupport.swift @@ -1,47 +1,32 @@ -// RDEPUBReaderController+RenderSupport.swift -// EPUB 阅读器渲染支持辅助方法 -// 提供获取当前布局上下文、偏好设置、页面尺寸、渲染样式、文本布局配置, -// 以及构建渲染请求、管理分页宿主视图和回退位置查询等功能。 import UIKit -/// RDEPUBReaderController 渲染支持扩展 -/// -/// 本文件提供阅读器渲染所需的辅助方法,包括获取当前布局上下文、偏好设置、页面尺寸、 -/// 渲染样式、文本布局配置,以及构建渲染请求和回退位置查询等功能。 - 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) @@ -53,7 +38,6 @@ extension RDEPUBReaderController { return paginationHostView } - /// 为指定页面索引构建渲染请求(含位置、高亮、搜索状态) func request(for pageIndex: Int) -> RDEPUBRenderRequest? { guard let publication, activePages.indices.contains(pageIndex) else { return nil @@ -75,13 +59,11 @@ extension RDEPUBReaderController { ) } - /// 获取指定页面索引的回退位置(当无法确定精确位置时使用) 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 { diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+RuntimeBridge.swift b/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+RuntimeBridge.swift index 3bffba9..62af09b 100644 --- a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+RuntimeBridge.swift +++ b/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+RuntimeBridge.swift @@ -1,17 +1,8 @@ -// RDEPUBReaderController+RuntimeBridge.swift -// EPUB 阅读器运行时桥接层 -// 将阅读器控制器的公开行为委托给 runtime 对象执行,涵盖配置应用、 -// 出版物加载与分页、阅读位置恢复、加载状态管理、错误处理等生命周期操作。 import UIKit -/// RDEPUBReaderController 运行时桥接扩展 -/// -/// 本文件将阅读器控制器的公开行为委托给 runtime 对象执行,涵盖配置应用、 -/// 出版物加载与分页、阅读位置恢复、加载状态管理、错误处理等生命周期操作。 - extension RDEPUBReaderController { - /// 应用阅读器视图配置,处理显示类型/翻页方向/横屏双页等变更并恢复位置 + func applyReaderViewConfiguration() { let resolvedDirection = resolvedPageDirection() let presentationDidChange = readerView.currentDisplayType != configuration.displayType @@ -32,17 +23,14 @@ extension RDEPUBReaderController { } } - /// 启动初始加载流程(如果尚未加载) func startInitialLoadIfNeeded() { runtime.startInitialLoadIfNeeded() } - /// 加载 EPUB 出版物 func loadPublication() { runtime.loadPublication() } - /// 应用已解析的出版物数据(解析器、出版物模型、书签、高亮等) func applyParsedPublication( parser: RDEPUBParser, publication: RDEPUBPublication, @@ -61,17 +49,14 @@ extension RDEPUBReaderController { ) } - /// 对出版物执行分页计算 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? @@ -79,17 +64,14 @@ extension RDEPUBReaderController { runtime.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation) } - /// 完成分页流程并恢复阅读位置 func finishPagination(restoreLocation: RDEPUBLocation?) { runtime.finishPagination(restoreLocation: restoreLocation) } - /// 保留当前位置重新执行分页 func repaginatePreservingCurrentLocation() { runtime.repaginatePreservingCurrentLocation() } - /// 恢复到指定阅读位置,返回是否成功 @discardableResult func restoreReadingLocation( _ location: RDEPUBLocation, @@ -103,27 +85,22 @@ extension RDEPUBReaderController { ) } - /// 获取当前可见页面的位置 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) } - /// 将选区限定到指定 spine 范围内 func scopedSelection( _ selection: RDEPUBSelection, relativeToSpineIndex spineIndex: Int? @@ -131,60 +108,49 @@ extension RDEPUBReaderController { runtime.annotationCoordinator.scopedSelection(selection, relativeToSpineIndex: spineIndex) } - /// 刷新可见内容并保留当前位置 func refreshVisibleContentPreservingLocation() { runtime.refreshVisibleContentPreservingLocation() } - /// 重建外部文本书籍 func rebuildExternalTextBook() { runtime.rebuildExternalTextBook() } - /// 更新阅读器 UI 装饰层(导航栏、工具栏等) 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, @@ -193,7 +159,6 @@ extension RDEPUBReaderController { persistence?.saveReaderSettings(settings) } - /// 判断配置变更是否需要重新分页 func requiresRepagination( from oldConfiguration: RDEPUBReaderConfiguration, to newConfiguration: RDEPUBReaderConfiguration @@ -210,7 +175,6 @@ extension RDEPUBReaderController { oldConfiguration.textRenderingEngine != newConfiguration.textRenderingEngine } - /// 判断配置变更是否需要刷新可见内容 func requiresVisibleRefresh( from oldConfiguration: RDEPUBReaderConfiguration, to newConfiguration: RDEPUBReaderConfiguration @@ -220,17 +184,14 @@ extension RDEPUBReaderController { oldConfiguration.darkImageBlendRatio != newConfiguration.darkImageBlendRatio } - /// 展示目录界面 func presentTableOfContents() { runtime.presentTableOfContents() } - /// 处理返回操作 func handleBackAction() { runtime.handleBackAction() } - /// 处理错误:停止分页、隐藏加载指示器、显示错误信息并通知代理 func handle(error: Error) { isRepaginating = false hideLoading() @@ -243,23 +204,19 @@ extension RDEPUBReaderController { 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 @@ -267,21 +224,17 @@ extension RDEPUBReaderController { runtime.handleViewportChangeIfNeeded(reason: reason, viewportSignature: viewportSignature) } - /// 获取指定页面的搜索结果展示状态 func searchPresentation(for page: EPUBPage) -> RDEPUBSearchPresentation? { runtime.searchPresentation(for: page) } - /// 根据出版物阅读方向解析页面翻页方向 private func resolvedPageDirection() -> RDReaderView.PageDirection { publication?.readingProgression == .rtl ? .rightToLeft : .leftToRight } } -// MARK: - 手势识别器代理 - extension RDEPUBReaderController: UIGestureRecognizerDelegate { - /// 手势识别器代理,始终返回 true 允许手势触发 + public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { true } diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+TableOfContents.swift b/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+TableOfContents.swift index cdb8ff4..8c38a63 100644 --- a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+TableOfContents.swift +++ b/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+TableOfContents.swift @@ -1,17 +1,8 @@ -// RDEPUBReaderController+TableOfContents.swift -// EPUB 阅读器目录功能 -// 提供目录(Table of Contents)相关功能,包括解析当前页面对应的目录项 -// 以及将嵌套目录树结构扁平化为线性列表。 import Foundation -/// RDEPUBReaderController 目录扩展 -/// -/// 本文件提供目录(Table of Contents)相关功能,包括解析当前页面对应的目录项 -/// 以及将嵌套目录树结构扁平化为线性列表。 - extension RDEPUBReaderController { - /// 解析当前阅读位置对应的目录项,按页码或 href 匹配 + func resolvedCurrentTableOfContentsItem() -> RDEPUBReaderTableOfContentsItem? { let items = flattenedTableOfContentsItems( from: publication?.tableOfContents ?? [], @@ -51,7 +42,6 @@ extension RDEPUBReaderController { } } - /// 将嵌套目录树递归扁平化为线性列表,并计算每项目标页码 func flattenedTableOfContentsItems( from items: [EPUBTableOfContentsItem], depth: Int = 0, diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift b/Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift index af5fbc9..c3880a1 100644 --- a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift +++ b/Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift @@ -1,32 +1,9 @@ import UIKit -// MARK: - 文件说明 - -/// EPUB 阅读器主控制器 -/// EPUBUI 层的核心入口,提供开箱即用的完整阅读器体验 -/// 调用方只需传入 EPUB 文件 URL 或已构建的 TextBook,即可呈现完整的阅读界面 -/// -/// **架构位置:** EPUBUI(最顶层)→ Core → Parser → Foundation -/// -/// **核心职责:** -/// - EPUB 解析与分页(支持流式排版和固定布局) -/// - 阅读位置的持久化与恢复 -/// - 高亮、书签的增删改查 -/// - 工具栏、目录、设置等 UI 面板的协调管理 -/// - 搜索功能(全文搜索、前后跳转) -/// -/// **打开流程:** init(epubURL:) → viewDidLoad → RDEPUBParser.parse → paginatePublication → readerView.reloadData → restoreLocation - -// MARK: - 阅读器主控制器 - public final class RDEPUBReaderController: UIViewController { - // MARK: - 公开属性 - - /// 阅读器委托,用于接收阅读状态变更通知 public weak var delegate: RDEPUBReaderDelegate? - /// 阅读器配置,修改后自动判断是否需要重新分页或刷新内容 public var configuration: RDEPUBReaderConfiguration { didSet { readerContext.configuration = configuration @@ -54,65 +31,58 @@ public final class RDEPUBReaderController: UIViewController { } } - /// 当前阅读位置 public var currentLocation: RDEPUBLocation? { currentVisibleLocation() } - /// 当前页码(从 1 开始),nil 表示尚未加载 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 } - /// 统一标注语义视图,对标 WXRead 的 WRBookmark 主模型。 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() } - // MARK: - 私有属性 - - /// EPUB 文件 URL let epubURL: URL + let persistence: RDEPUBReaderPersistence? + let dependencies: RDEPUBReaderDependencies + let readerView = RDReaderView() + let loadingIndicator: UIActivityIndicatorView = { UIActivityIndicatorView(style: .large) }() + let errorLabel: UILabel = { let label = UILabel() label.numberOfLines = 0 @@ -121,97 +91,115 @@ public final class RDEPUBReaderController: UIViewController { label.isHidden = true return label }() + let paginationHostView = UIView() + 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() - /// 搜索栏是否当前可见 + 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 paginator: RDEPUBPaginator? { get { readerContext.paginator } set { readerContext.paginator = 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) - // MARK: - 初始化 - - /// 使用 EPUB 文件 URL 初始化阅读器 - /// 会自动从持久化存储中恢复用户的阅读偏好设置 - /// - Parameters: - /// - epubURL: EPUB 文件的本地 URL - /// - configuration: 阅读器配置,默认使用 .default - /// - persistence: 持久化策略,默认使用 UserDefaults public init( epubURL: URL, configuration: RDEPUBReaderConfiguration = .default, @@ -225,7 +213,7 @@ public final class RDEPUBReaderController: UIViewController { self.dependencies = dependencies let brightness = max(0, min(1, persistedSettings?.brightness ?? dependencies.environment.currentBrightness)) super.init(nibName: nil, bundle: nil) - // Sync context after super.init + readerContext.dependencies = dependencies readerContext.configuration = self.configuration readerContext.epubURL = epubURL @@ -245,15 +233,6 @@ public final class RDEPUBReaderController: UIViewController { ) } - /// 使用已构建的 TextBook 初始化,跳过 EPUB 解析流程 - /// 适用于 TXT 等纯文本文件,调用方需自行构建 TextBook - /// - Parameters: - /// - textBook: 已分页的 TextBook 实例 - /// - bookIdentifier: 书籍唯一标识符,用于持久化 - /// - title: 书籍标题 - /// - textFileURL: 原始文本文件 URL,用于重建 TextBook - /// - configuration: 阅读器配置 - /// - persistence: 持久化策略 public convenience init( textBook: RDEPUBTextBook, bookIdentifier: String, @@ -317,10 +296,6 @@ public final class RDEPUBReaderController: UIViewController { runtime.viewportMonitor.viewWillTransition(with: coordinator) } - // MARK: - 搜索栏管理 - - /// 显示搜索栏。工具栏可见时立即添加到视图层级并滑入动画;否则仅标记状态, - /// 等待 `handleToolViewVisibilityChanged` 在工具栏显示时再安装。 func showSearchBar() { guard !isSearchBarVisible else { return } isSearchBarVisible = true @@ -367,7 +342,6 @@ public final class RDEPUBReaderController: UIViewController { } } - /// 将搜索栏添加到 readerView 并执行滑入动画 private func installSearchBarView() { guard searchBarView.superview == nil else { return } searchBarView.apply(theme: configuration.theme) @@ -403,8 +377,6 @@ public final class RDEPUBReaderController: UIViewController { } } - /// 隐藏搜索栏,带动画滑出 - /// - Parameter clearSearch: 是否同时清除搜索状态 func hideSearchBar(clearSearch: Bool = false) { guard isSearchBarVisible else { return } isSearchBarVisible = false @@ -425,7 +397,6 @@ public final class RDEPUBReaderController: UIViewController { } } - /// 同步搜索栏匹配计数 func updateSearchCount() { guard let searchState else { searchBarView.showNoResults() @@ -438,7 +409,6 @@ public final class RDEPUBReaderController: UIViewController { ) } - /// 当工具栏可见性变化时同步搜索栏(由 RDReaderView 回调调用) func handleToolViewVisibilityChanged(isVisible: Bool) { if isVisible { if isSearchBarVisible { @@ -456,6 +426,7 @@ public final class RDEPUBReaderController: UIViewController { } private extension RDEPUBReaderController { + func searchResultSections(for searchState: RDEPUBSearchState) -> [RDEPUBReaderSearchSection] { let groupedMatches = Dictionary(grouping: Array(searchState.matches.enumerated()), by: { entry in searchSectionTitle(for: entry.element) diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderDelegate.swift b/Sources/RDReaderView/EPUBUI/RDEPUBReaderDelegate.swift index d81ff3b..305dd7d 100644 --- a/Sources/RDReaderView/EPUBUI/RDEPUBReaderDelegate.swift +++ b/Sources/RDReaderView/EPUBUI/RDEPUBReaderDelegate.swift @@ -1,93 +1,34 @@ import UIKit -// MARK: - 阅读器委托协议 - -/// EPUB 阅读器的委托协议 -/// 用于向调用方通知阅读器的各种状态变更事件 -/// 所有方法均提供默认空实现,调用方可选择性实现感兴趣的方法 public protocol RDEPUBReaderDelegate: AnyObject { - /// 阅读器成功打开书籍时调用 - /// - Parameters: - /// - reader: 阅读器控制器 - /// - publication: 解析后的出版物对象 + func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication) - /// 阅读位置更新时调用(翻页、跳转等) - /// - Parameters: - /// - reader: 阅读器控制器 - /// - location: 新的阅读位置 func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation) - /// 阅读器翻到书籍末尾时调用 - /// - Parameter reader: 阅读器控制器 func epubReaderDidReachEnd(_ reader: UIViewController) - /// 用户选中文本发生变化时调用 - /// - Parameters: - /// - reader: 阅读器控制器 - /// - selection: 当前选中文本,nil 表示取消选中 func epubReader(_ reader: UIViewController, didChangeSelection selection: RDEPUBSelection?) - /// 高亮标注列表更新时调用 - /// - Parameters: - /// - reader: 阅读器控制器 - /// - highlights: 当前所有高亮 func epubReader(_ reader: UIViewController, didUpdateHighlights highlights: [RDEPUBHighlight]) - /// 书签列表更新时调用 - /// - Parameters: - /// - reader: 阅读器控制器 - /// - bookmarks: 当前所有书签 func epubReader(_ reader: UIViewController, didUpdateBookmarks bookmarks: [RDEPUBBookmark]) - /// 搜索结果更新时调用 - /// - Parameters: - /// - reader: 阅读器控制器 - /// - result: 搜索结果摘要,nil 表示无搜索 func epubReader(_ reader: UIViewController, didUpdateSearchResult result: RDEPUBSearchResult?) - /// 当前搜索匹配项变化时调用 - /// - Parameters: - /// - reader: 阅读器控制器 - /// - match: 当前匹配项,nil 表示无匹配 func epubReader(_ reader: UIViewController, didChangeCurrentSearchMatch match: RDEPUBSearchMatch?) - /// 当前所在目录项更新时调用 - /// - Parameters: - /// - reader: 阅读器控制器 - /// - item: 当前目录项 func epubReader(_ reader: UIViewController, didUpdateCurrentTableOfContentsItem item: RDEPUBReaderTableOfContentsItem?) - /// 用户点击外部链接时调用 - /// - Parameters: - /// - reader: 阅读器控制器 - /// - url: 外部链接 URL func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL) - /// 外部链接打开前的拦截钩子,返回 false 可阻止打开 - /// - Parameters: - /// - reader: 阅读器控制器 - /// - url: 外部链接 URL - /// - Returns: 是否允许打开该链接 func epubReader(_ reader: UIViewController, shouldOpenExternalURL url: URL) -> Bool - /// 阅读器发生错误时调用 - /// - Parameters: - /// - reader: 阅读器控制器 - /// - error: 错误对象 func epubReader(_ reader: UIViewController, didFailWithError error: Error) - /// 允许调用方自定义顶部工具栏 - /// 在 viewDidLoad 时调用,可在工具栏上添加自定义按钮 - /// - Parameters: - /// - reader: 阅读器控制器 - /// - topToolView: 顶部工具栏视图 func epubReader(_ reader: UIViewController, configureTopToolView topToolView: RDEPUBReaderTopToolView) } -// MARK: - 默认空实现 - -/// 所有委托方法的默认空实现,调用方可只实现需要的方法 public extension RDEPUBReaderDelegate { func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication) {} func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation) {} diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderHighlightsViewController.swift b/Sources/RDReaderView/EPUBUI/RDEPUBReaderHighlightsViewController.swift index 9d1a0cf..c127a2c 100644 --- a/Sources/RDReaderView/EPUBUI/RDEPUBReaderHighlightsViewController.swift +++ b/Sources/RDReaderView/EPUBUI/RDEPUBReaderHighlightsViewController.swift @@ -1,24 +1,19 @@ import UIKit -// MARK: - 高亮管理面板 - -/// EPUB 阅读器的高亮标注管理控制器 -/// 以列表形式展示所有高亮和批注,支持筛选(全部/批注/高亮)、跳转到位置、编辑批注、删除 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] { @@ -50,6 +45,8 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController { 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() @@ -87,6 +84,7 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController { private func configureFilterControl() { filterControl.selectedSegmentIndex = 0 + filterControl.accessibilityIdentifier = "epub.reader.highlights.filter" filterControl.addTarget(self, action: #selector(filterChangedAction), for: .valueChanged) navigationItem.titleView = filterControl } @@ -96,6 +94,7 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController { 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() { @@ -109,6 +108,7 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController { label.textAlignment = .center label.textColor = theme.contentTextColor.withAlphaComponent(0.7) label.numberOfLines = 0 + label.accessibilityIdentifier = "epub.reader.highlights.empty" tableView.backgroundView = label } @@ -216,18 +216,16 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController { } } -// MARK: - 书签管理面板 - -/// EPUB 阅读器的书签管理控制器 -/// 以列表形式展示所有书签,支持跳转到书签位置和删除书签 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" @@ -298,6 +296,7 @@ final class RDEPUBReaderBookmarksViewController: UITableViewController { label.textAlignment = .center label.textColor = theme.contentTextColor.withAlphaComponent(0.7) label.numberOfLines = 0 + label.accessibilityIdentifier = "epub.reader.bookmarks.empty" tableView.backgroundView = label return } diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderPersistence.swift b/Sources/RDReaderView/EPUBUI/RDEPUBReaderPersistence.swift index 3cfc85d..5ff87af 100644 --- a/Sources/RDReaderView/EPUBUI/RDEPUBReaderPersistence.swift +++ b/Sources/RDReaderView/EPUBUI/RDEPUBReaderPersistence.swift @@ -1,45 +1,24 @@ import Foundation -// MARK: - 持久化协议 - -/// 阅读器持久化协议 -/// 定义了阅读位置、书签、高亮和用户设置的存储接口 -/// 调用方可实现此协议以对接自定义存储(如数据库、云端同步等) public protocol RDEPUBReaderPersistence: AnyObject { - /// 加载指定书籍的上次阅读位置 - /// - Parameter bookIdentifier: 书籍唯一标识符 - /// - Returns: 保存的阅读位置,nil 表示首次打开 + func loadLocation(for bookIdentifier: String) -> RDEPUBLocation? - /// 保存指定书籍的当前阅读位置 - /// - Parameters: - /// - location: 阅读位置 - /// - bookIdentifier: 书籍唯一标识符 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) } -// MARK: - 默认实现 - -/// 协议的默认空实现,书签/高亮/设置为可选功能 -/// DEBUG 模式下会对 no-op 行为输出警告,便于发现未对接持久化层的误用 public extension RDEPUBReaderPersistence { func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark] { #if DEBUG @@ -68,30 +47,18 @@ public extension RDEPUBReaderPersistence { } } -// MARK: - UserDefaults 持久化实现 - -/// 基于 UserDefaults 的持久化实现 -/// 数据以 JSON 格式存储,按书籍标识符分隔存储位置 -/// 适合轻量级场景,大量数据建议改用 SQLite 或文件系统 public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence { - /// UserDefaults 实例 + private let defaults: UserDefaults - /// 阅读位置的 key 前缀 + private let locationPrefix: String - /// 书签列表的 key 前缀 + private let bookmarksPrefix: String - /// 高亮列表的 key 前缀 + private let highlightsPrefix: String - /// 用户设置的 key + private let settingsKey: String - /// 初始化 UserDefaults 持久化策略 - /// - Parameters: - /// - defaults: UserDefaults 实例,默认 .standard - /// - locationPrefix: 阅读位置 key 前缀 - /// - bookmarksPrefix: 书签 key 前缀 - /// - highlightsPrefix: 高亮 key 前缀 - /// - settingsKey: 用户设置 key public init( defaults: UserDefaults = .standard, locationPrefix: String = "ssreader.epub.location.", diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderSearchBarView.swift b/Sources/RDReaderView/EPUBUI/RDEPUBReaderSearchBarView.swift index 220837a..fc68b6f 100644 --- a/Sources/RDReaderView/EPUBUI/RDEPUBReaderSearchBarView.swift +++ b/Sources/RDReaderView/EPUBUI/RDEPUBReaderSearchBarView.swift @@ -1,26 +1,33 @@ import UIKit struct RDEPUBReaderSearchSection: Equatable { + struct Item: Equatable { + let matchIndex: Int + let previewText: String + let isCurrent: Bool } let title: String + let items: [Item] } -// MARK: - 搜索面板 - -/// 阅读器搜索面板 -/// 提供底部抽屉式搜索输入、分组结果列表和匹配跳转能力。 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 = { @@ -39,23 +46,35 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView { }() private let backgroundButton = UIButton(type: .custom) + private 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() - // Preserve legacy accessibility hooks used by existing tests and demo logic. 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) { @@ -221,7 +240,6 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView { searchFieldContainer.addSubview(searchIcon) searchFieldContainer.addSubview(textField) - // Legacy shims addSubview(previousButton) addSubview(nextButton) addSubview(countLabel) @@ -405,6 +423,7 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView { } private extension UIColor { + var rd_searchIsDarkBackground: Bool { var red: CGFloat = 0 var green: CGFloat = 0 @@ -419,6 +438,7 @@ private extension UIColor { } extension RDEPUBReaderSearchBarView: UITableViewDataSource, UITableViewDelegate { + func numberOfSections(in tableView: UITableView) -> Int { searchSections.count } @@ -485,6 +505,7 @@ extension RDEPUBReaderSearchBarView: UITableViewDataSource, UITableViewDelegate } extension RDEPUBReaderSearchBarView: UITextFieldDelegate { + func textFieldShouldClear(_ textField: UITextField) -> Bool { DispatchQueue.main.async { [weak self] in self?.onSearchTextChanged?("") @@ -494,15 +515,21 @@ extension RDEPUBReaderSearchBarView: UITextFieldDelegate { } 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?) { diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderTableOfContentsItem.swift b/Sources/RDReaderView/EPUBUI/RDEPUBReaderTableOfContentsItem.swift index 7d789f8..7f22a2d 100644 --- a/Sources/RDReaderView/EPUBUI/RDEPUBReaderTableOfContentsItem.swift +++ b/Sources/RDReaderView/EPUBUI/RDEPUBReaderTableOfContentsItem.swift @@ -1,17 +1,13 @@ import Foundation -// MARK: - 目录条目 - -/// 展平后的目录条目结构体 -/// 用于目录面板展示,将树形目录展平为一维列表,通过 depth 字段保留层级关系 public struct RDEPUBReaderTableOfContentsItem: Equatable { - /// 章节标题 + public var title: String - /// 章节的 href(相对于 EPUB 容器根目录) + public var href: String - /// 目录层级深度(0 为顶级章节),用于列表缩进显示 + public var depth: Int - /// 该章节所在页码(从 1 开始),nil 表示无法确定 + public var pageNumber: Int? public init(title: String, href: String, depth: Int, pageNumber: Int? = nil) { diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderToolView.swift b/Sources/RDReaderView/EPUBUI/RDEPUBReaderToolView.swift index e178b10..9189cbb 100644 --- a/Sources/RDReaderView/EPUBUI/RDEPUBReaderToolView.swift +++ b/Sources/RDReaderView/EPUBUI/RDEPUBReaderToolView.swift @@ -1,13 +1,9 @@ import UIKit -// MARK: - 工具栏基类 - -/// 阅读器工具栏的基类 -/// 提供分隔线和主题适配的通用逻辑,子类只需关注具体功能按钮 open class RDEPUBReaderToolView: UIView { - /// 分隔线视图 + private let lineView = UIView() - /// 分隔线高度 + private let lineHeight: CGFloat = 0.5 override init(frame: CGRect) { @@ -25,24 +21,15 @@ open class RDEPUBReaderToolView: UIView { lineView.frame = lineFrame(in: bounds) } - /// 应用主题配色到工具栏 - /// - Parameter theme: 阅读器主题配置 open func apply(theme: RDEPUBReaderTheme) { backgroundColor = .white lineView.backgroundColor = UIColor.lightGray.withAlphaComponent(0.5) } - /// 计算分隔线的位置和尺寸,子类可重写以调整分隔线位置(如顶部或底部) - /// - Parameter bounds: 工具栏的 bounds - /// - Returns: 分隔线的 frame open func lineFrame(in bounds: CGRect) -> CGRect { CGRect(x: 0, y: 0, width: bounds.width, height: lineHeight) } } -// MARK: - 工具栏按钮 - -/// 阅读器工具栏专用按钮 -/// 继承自 UIButton,用于工具栏中的功能按钮 final class RDEPUBReaderTintButton: UIButton { } \ No newline at end of file diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderTopToolView.swift b/Sources/RDReaderView/EPUBUI/RDEPUBReaderTopToolView.swift index e2f42f8..3cb665f 100644 --- a/Sources/RDReaderView/EPUBUI/RDEPUBReaderTopToolView.swift +++ b/Sources/RDReaderView/EPUBUI/RDEPUBReaderTopToolView.swift @@ -1,16 +1,11 @@ import UIKit -// MARK: - 顶部工具栏 - -/// 阅读器顶部工具栏 -/// 显示书名、返回按钮和书签按钮 -/// 位于阅读内容上方,支持 iOS 13+ SF Symbols 和低版本文字回退 public final class RDEPUBReaderTopToolView: RDEPUBReaderToolView { - /// 返回按钮点击回调 + var onBack: (() -> Void)? - /// 书签按钮点击回调 + var onToggleBookmark: (() -> Void)? - /// 搜索按钮点击回调 + var onSearch: (() -> Void)? private let backButton = RDEPUBReaderTintButton(type: .system) diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBViewportTypes.swift b/Sources/RDReaderView/EPUBUI/RDEPUBViewportTypes.swift index 56f1a52..2778689 100644 --- a/Sources/RDReaderView/EPUBUI/RDEPUBViewportTypes.swift +++ b/Sources/RDReaderView/EPUBUI/RDEPUBViewportTypes.swift @@ -1,19 +1,17 @@ import UIKit -/// 视口签名结构体,用于检测视口尺寸或安全区是否发生显著变化 -/// 当变化超过阈值时触发重新分页 struct RDEPUBViewportSignature: Equatable { - /// 视口宽度(pt) + let width: CGFloat - /// 视口高度(pt) + let height: CGFloat - /// 顶部安全区域高度 + let safeTop: CGFloat - /// 左侧安全区域宽度 + let safeLeft: CGFloat - /// 底部安全区域高度 + let safeBottom: CGFloat - /// 右侧安全区域宽度 + let safeRight: CGFloat func differsSignificantly(from other: RDEPUBViewportSignature, threshold: CGFloat = 1) -> Bool { @@ -26,13 +24,11 @@ struct RDEPUBViewportSignature: Equatable { } } -/// 视口变化的原因枚举,用于决定延迟处理的策略 enum RDEPUBViewportChangeReason { - /// 视图布局变化(如 Safe Area 更新、分屏调整等) + case viewLayout - /// 屏幕方向旋转过渡 + case orientationTransition } -/// 原生文本渲染路径的分页快照,包含所有页面和章节信息 typealias RDEPUBNativeTextSnapshot = (pages: [EPUBPage], chapters: [EPUBChapterInfo]) diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBWebContentView.swift b/Sources/RDReaderView/EPUBUI/RDEPUBWebContentView.swift index 6d626df..49aa67c 100644 --- a/Sources/RDReaderView/EPUBUI/RDEPUBWebContentView.swift +++ b/Sources/RDReaderView/EPUBUI/RDEPUBWebContentView.swift @@ -1,29 +1,20 @@ import UIKit -// MARK: - Web 内容视图代理 - -/// Web 内容视图的代理协议 -/// 将 WebView 中的事件(位置更新、文本选择、链接点击等)转发给控制器 protocol RDEPUBWebContentViewDelegate: AnyObject { - /// WebView 中阅读位置更新时调用 + func epubWebContentView(_ contentView: RDEPUBWebContentView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int) - /// WebView 中文本选择变化时调用 + 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) - /// WebView 中 JavaScript 执行出错 + func epubWebContentView(_ contentView: RDEPUBWebContentView, didLogJavaScriptError message: String) } -// MARK: - Web 内容视图 - -/// EPUB 固定布局和 Web 渲染路径的内容视图 -/// 包装 RDEPUBWebView,提供页码标签和加载/释放资源的控制 -/// 适用于固定布局 EPUB 和不支持 Native Text 的场景 final class RDEPUBWebContentView: UIView { weak var delegate: RDEPUBWebContentViewDelegate? diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBWebDecorationOverlayView.swift b/Sources/RDReaderView/EPUBUI/RDEPUBWebDecorationOverlayView.swift index f0a7a0d..2448e3d 100644 --- a/Sources/RDReaderView/EPUBUI/RDEPUBWebDecorationOverlayView.swift +++ b/Sources/RDReaderView/EPUBUI/RDEPUBWebDecorationOverlayView.swift @@ -1,8 +1,5 @@ import UIKit -/// EPUB WebView 路径的原生装饰覆盖层。 -/// 用于在 WebView 上方叠加高亮、下划线等视觉装饰。 -/// 对标 WXRead 的页面级装饰绘制思路:JS 只负责提供 rect,实际视觉绘制由原生 CGContext 完成。 final class RDEPUBWebDecorationOverlayView: UIView { private var decorations: [RDEPUBTextOverlayDecoration] = [] private let verticalAdjustment: CGFloat = -1 @@ -18,8 +15,6 @@ final class RDEPUBWebDecorationOverlayView: UIView { fatalError("init(coder:) has not been implemented") } - /// 应用装饰数据并触发重绘 - /// - Parameter decorations: 装饰项数组(高亮、下划线等) func applyDecorations(_ decorations: [RDEPUBTextOverlayDecoration]) { self.decorations = decorations.filter { !$0.rects.isEmpty } setNeedsDisplay() diff --git a/Sources/RDReaderView/EPUBUI/RDURLReaderController.swift b/Sources/RDReaderView/EPUBUI/RDURLReaderController.swift index 22fb315..b049c12 100644 --- a/Sources/RDReaderView/EPUBUI/RDURLReaderController.swift +++ b/Sources/RDReaderView/EPUBUI/RDURLReaderController.swift @@ -1,29 +1,6 @@ -// -// RDURLReaderController.swift -// RDReaderDemo -// -// 文件职责:URL 阅读器入口控制器,根据文件类型自动选择阅读器。 -// 该文件是 ReadViewSDK 的最简使用入口: -// - .epub 文件:直接创建 RDEPUBReaderController -// - 其他文本文件:通过 RDPlainTextBookBuilder 分页后创建 RDEPUBReaderController -// - 分页失败时回退到纯文本 UITextView 展示 -// -// 架构位置:RDReaderView 四层架构中第四层(读者 UI)的入口控制器。 -// import UIKit -/// URL 阅读器入口控制器 -/// 接收一个书籍文件 URL,根据文件类型自动创建对应的阅读器控制器: -/// - EPUB 文件:直接使用 RDEPUBReaderController -/// - 纯文本文件:先通过 RDPlainTextBookBuilder 分页,再使用 RDEPUBReaderController -/// - 分页失败:回退到纯文本 UITextView 展示 -/// -/// 使用方式: -/// ```swift -/// let controller = RDURLReaderController(bookURL: fileURL) -/// navigationController?.pushViewController(controller, animated: true) -/// ``` public final class RDURLReaderController: UIViewController { private struct PendingDemoPageRequest { let pageNumber: Int @@ -31,11 +8,10 @@ public final class RDURLReaderController: UIViewController { var attemptCount: Int } - /// 书籍文件 URL private let bookURL: URL - /// EPUB 阅读器配置(字体、行距、主题等) + private let epubConfiguration: RDEPUBReaderConfiguration - /// 内嵌的阅读器控制器 + private var embeddedController: UIViewController? private let demoStateLabel: UILabel = { let label = UILabel() @@ -58,10 +34,6 @@ public final class RDURLReaderController: UIViewController { private var lastActivatedExternalURL: URL? private var lastReaderErrorDescription = "none" - /// 初始化方法 - /// - Parameters: - /// - bookURL: 书籍文件的本地 URL - /// - epubConfiguration: EPUB 阅读器配置,默认使用标准配置 public init( bookURL: URL, epubConfiguration: RDEPUBReaderConfiguration = RDEPUBReaderConfiguration() @@ -76,7 +48,6 @@ public final class RDURLReaderController: UIViewController { fatalError("init(coder:) has not been implemented") } - /// 视图加载完成后,设置背景色、标题并嵌入阅读器控制器 public override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .systemBackground @@ -101,18 +72,11 @@ public final class RDURLReaderController: UIViewController { stopDemoStateTimer() } - /// 切换阅读器的翻页模式(Demo 用) - /// - Parameter displayType: 目标翻页模式 public func applyDemoDisplayType(_ displayType: RDReaderView.DisplayType) { readerController?.configuration.displayType = displayType emitDemoState(prefix: "display=\(displayType.demoArgumentValue)") } - /// 跳转到指定页码(Demo 用) - /// - Parameters: - /// - pageNumber: 目标页码 - /// - animated: 是否动画过渡 - /// - Returns: 是否成功跳转 @discardableResult public func goToDemoPage(_ pageNumber: Int, animated: Bool = false) -> Bool { let moved = performDemoPageNavigation(pageNumber, animated: animated) @@ -125,12 +89,6 @@ public final class RDURLReaderController: UIViewController { return moved } - /// 执行翻页模式切换序列(Demo 用) - /// 按照指定顺序和延迟依次切换翻页模式 - /// - Parameters: - /// - displayTypes: 翻页模式数组 - /// - initialPageNumber: 可选的初始跳转页码 - /// - stepDelay: 每步之间的延迟时间(秒) public func runDemoDisplaySequence( _ displayTypes: [RDReaderView.DisplayType], initialPageNumber: Int? = nil, @@ -151,9 +109,6 @@ public final class RDURLReaderController: UIViewController { } } - /// 执行搜索(Demo 用) - /// 显示搜索栏并提交关键词。若阅读器尚未完成加载则延迟到 `didUpdateLocation` 后执行。 - /// - Parameter keyword: 搜索关键词 public func performDemoSearch(keyword: String) { guard let readerController else { return } readerController.showSearchBar() @@ -187,8 +142,6 @@ public final class RDURLReaderController: UIViewController { submitSearchAfterDelay(keyword: keyword, retries: 5) } - /// 嵌入阅读器控制器到当前视图层级 - /// 根据文件类型选择合适的阅读器控制器,并通过 Child View Controller 方式嵌入 private func embedReaderController() { let controller: UIViewController if bookURL.pathExtension.lowercased() == "epub" { @@ -224,7 +177,7 @@ public final class RDURLReaderController: UIViewController { configuration: epubConfiguration ) } else { - // 分页失败时回退到纯文本展示 + let fallback = UIViewController() let textView = UITextView() textView.isEditable = false @@ -248,7 +201,6 @@ public final class RDURLReaderController: UIViewController { emitDemoState() } - /// 获取内嵌的 EPUB 阅读器控制器(如果存在) private var readerController: RDEPUBReaderController? { embeddedController as? RDEPUBReaderController } @@ -322,7 +274,6 @@ public final class RDURLReaderController: UIViewController { } } - /// 输出当前阅读器状态日志(Demo 调试用) private func logDemoState(prefix: String) { guard let readerController else { return } let location = readerController.currentLocation @@ -372,6 +323,9 @@ public final class RDURLReaderController: UIViewController { 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() @@ -388,6 +342,9 @@ public final class RDURLReaderController: UIViewController { "selection=\(selection)", "href=\(href)", "progression=\(progression)", + "cfi=\(cfi)", + "lastCFI=\(lastCFI)", + "rangeCFI=\(rangeCFI)", "mode=\(mapSnapshot.mode)", "pagination=\(mapSnapshot.phase)", "knownPages=\(mapSnapshot.knownPages)", @@ -464,14 +421,10 @@ public final class RDURLReaderController: UIViewController { return ("snapshot", snapshotPages > 0 ? "full" : "none", snapshotPages, snapshotChapters, snapshotChapters) } - /// 计算文本分页的页面尺寸。 - /// 分页器使用完整 viewport,真实可绘制区域由 layoutConfig.edgeInsets 控制, - /// 以保持与页面展示时的内容几何一致。 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) @@ -483,8 +436,6 @@ public final class RDURLReaderController: UIViewController { ) } - /// 解码文本文件内容 - /// 尝试 UTF-8 编码,失败则尝试 GBK 和 GB2312 编码 private func rd_decodeTextFile(url: URL) -> String { if let content = try? NSString(contentsOf: url, encoding: String.Encoding.utf8.rawValue) as String { return content @@ -536,9 +487,8 @@ extension RDURLReaderController: RDEPUBReaderDelegate { } } -/// 翻页模式扩展:提供 Demo 命令行参数值 private extension RDReaderView.DisplayType { - /// 返回翻页模式对应的 Demo 命令行参数字符串 + var demoArgumentValue: String { switch self { case .pageCurl: diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBBackgroundTrace.swift b/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBBackgroundTrace.swift index 97c8f87..de2d41d 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBBackgroundTrace.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBBackgroundTrace.swift @@ -1,22 +1,30 @@ import Foundation enum RDEPUBBackgroundTrace { + static func log(_ scope: String, _ message: String) { + let threadRole = Thread.isMainThread ? "main" : "bg" + let threadName = resolvedThreadName() + let queueLabel = resolvedQueueLabel() print("[EPUB][\(scope)][\(threadRole)][queue=\(queueLabel)][thread=\(threadName)] \(message)") } static func measure(_ scope: String, _ message: String, work: () throws -> T) rethrows -> T { + let startedAt = CFAbsoluteTimeGetCurrent() log(scope, "START \(message)") do { + let result = try work() + let elapsedMs = Int((CFAbsoluteTimeGetCurrent() - startedAt) * 1000) log(scope, "END \(message) elapsedMs=\(elapsedMs)") return result } catch { + let elapsedMs = Int((CFAbsoluteTimeGetCurrent() - startedAt) * 1000) log(scope, "FAIL \(message) elapsedMs=\(elapsedMs) error=\(error)") throw error @@ -24,12 +32,15 @@ enum RDEPUBBackgroundTrace { } private static func resolvedThreadName() -> String { + if let name = Thread.current.name, !name.isEmpty { return name } + if Thread.isMainThread { return "main" } + return String(describing: Unmanaged.passUnretained(Thread.current).toOpaque()) } diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBBookPageMap.swift b/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBBookPageMap.swift index f6b1b44..79010ea 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBBookPageMap.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBBookPageMap.swift @@ -1,29 +1,26 @@ import Foundation -/// BookPageMap 中的单个条目,记录一个章节的轻量元数据。 -/// 不持有 NSAttributedString,每条约 100 字节。 struct RDEPUBBookPageMapEntry { + let spineIndex: Int + let href: String + let title: String - /// 该章节的页数 + let pageCount: Int - /// 该章节在全书中的绝对起始页码(从 0 开始) + let absolutePageStart: Int - /// fragment ID → 字符偏移量映射 + let fragmentOffsets: [String: Int] } -/// 全书轻量页码映射:仅存储每章的页数和起始位置, -/// 内存成本约 100 字节/章,1000 章 ≈ 100KB。 -/// -/// 提供 spineIndex ↔ 绝对页码的双向查询, -/// 用于进度条、目录跳转、位置恢复等不依赖内容的场景。 struct RDEPUBBookPageMap { + let entries: [RDEPUBBookPageMapEntry] - /// 按 spineIndex 索引的查找表 - private let indexBySpine: [Int: Int] // spineIndex -> entries 数组下标 - /// 全书总页数 + + private let indexBySpine: [Int: Int] + let totalPages: Int init(entries: [RDEPUBBookPageMapEntry]) { @@ -38,9 +35,6 @@ struct RDEPUBBookPageMap { static let empty = RDEPUBBookPageMap(entries: []) - // MARK: - 查询 - - /// spineIndex + 本地页码 → 全书绝对页码 func absolutePageIndex(spineIndex: Int, localPageIndex: Int) -> Int? { guard let idx = indexBySpine[spineIndex] else { return nil } let entry = entries[idx] @@ -48,10 +42,9 @@ struct RDEPUBBookPageMap { return entry.absolutePageStart + localPageIndex } - /// 全书绝对页码 → spineIndex func spineIndex(forAbsolutePage absolutePage: Int) -> Int? { guard absolutePage >= 0, absolutePage < totalPages else { return nil } - // 二分查找:entries 按 absolutePageStart 有序 + var lo = 0, hi = entries.count while lo < hi { let mid = lo + (hi - lo) / 2 @@ -65,7 +58,6 @@ struct RDEPUBBookPageMap { return entries[lo - 1].spineIndex } - /// 全书绝对页码 → 本地页码(章节内偏移) func localPageIndex(forAbsolutePage absolutePage: Int) -> Int? { guard let si = spineIndex(forAbsolutePage: absolutePage), let idx = indexBySpine[si] else { return nil } @@ -75,29 +67,23 @@ struct RDEPUBBookPageMap { return local } - /// 获取指定 spineIndex 的条目 func entry(forSpineIndex spineIndex: Int) -> RDEPUBBookPageMapEntry? { guard let idx = indexBySpine[spineIndex] else { return nil } return entries[idx] } - /// 获取指定 spineIndex 在 entries 中的章节序号。 func chapterIndex(forSpineIndex spineIndex: Int) -> Int? { indexBySpine[spineIndex] } - /// 获取指定 spineIndex 的页数 func pageCount(forSpineIndex spineIndex: Int) -> Int? { entry(forSpineIndex: spineIndex)?.pageCount } - /// 全书总章节数 var totalChapters: Int { entries.count } - // MARK: - 构建 - - /// Builder:从各章的 pageCount 逐步构建 BookPageMap 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]) { @@ -105,7 +91,7 @@ struct RDEPUBBookPageMap { } func build() -> RDEPUBBookPageMap { - // 按 spineIndex 排序 + let sorted = items.sorted { $0.spineIndex < $1.spineIndex } var entries: [RDEPUBBookPageMapEntry] = [] var absolutePageStart = 0 diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterCacheKey.swift b/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterCacheKey.swift index 487a1fe..3892060 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterCacheKey.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterCacheKey.swift @@ -1,8 +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/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterDataCache.swift b/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterDataCache.swift index 043e2c0..526e1e6 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterDataCache.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterDataCache.swift @@ -1,7 +1,9 @@ import Foundation final class RDEPUBChapterDataCache { + private var storage: [Int: RDEPUBRuntimeChapter] = [:] + private let lock = NSLock() subscript(_ spineIndex: Int) -> RDEPUBRuntimeChapter? { diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLoader.swift b/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLoader.swift index bd9fa99..333f4fa 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLoader.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLoader.swift @@ -1,7 +1,9 @@ import Foundation final class RDEPUBChapterLoader { + private unowned let context: RDEPUBReaderContext + private var summaryDiskCache: RDEPUBChapterSummaryDiskCache? init(context: RDEPUBReaderContext) { @@ -12,24 +14,22 @@ final class RDEPUBChapterLoader { summaryDiskCache = cache } - // MARK: - 请求优先级 - enum LoadPriority { - case navigation // 前台导航:用户主动跳章,完成后检查导航目标队列 - case preview // 设置预览:仅构建当前章并回调,不参与导航目标消费 - case prefetch // 后台预取:±1 相邻章,完成后仅回填缓存 + 刷新快照 + + case navigation + + case preview + + case prefetch } - // MARK: - 主入口:加载单个章节 - - /// 在 chapterLoadQueue 上构建单章,完成后回调到主线程 func loadChapter( spineIndex: Int, store: RDEPUBChapterRuntimeStore, priority: LoadPriority = .navigation, completion: @escaping (Result) -> Void ) { - // 1. 查内存缓存(统一回主线程,保证 completion 线程语义一致) + if let cached = store.chapterData(for: spineIndex) { RDEPUBBackgroundTrace.log("ChapterLoader", "cache hit spine=\(spineIndex) priority=\(priority)") DispatchQueue.main.async { @@ -38,14 +38,12 @@ final class RDEPUBChapterLoader { return } - // 2-3. 构建缓存键 + 查内存级 pageCountCache 统一移到串行队列执行, - // 避免 contentHashForSpineIndex 的 SHA256 + 磁盘 I/O 阻塞主线程 store.markBuilding(true) + store.chapterLoadQueue.async { RDEPUBBackgroundTrace.log("ChapterLoader", "queue start spine=\(spineIndex) priority=\(priority)") let cacheKey = self.makeCacheKey(spineIndex: spineIndex) - // 仅当内存级 pageCountCache 未命中时才查磁盘摘要 let precomputedPageRanges = store.pageCount(for: cacheKey)?.pageRanges let diskSummary: RDEPUBChapterSummary? if precomputedPageRanges == nil { @@ -63,6 +61,7 @@ final class RDEPUBChapterLoader { let availablePageRanges = precomputedPageRanges ?? diskPageRanges do { + let chapter = try RDEPUBBackgroundTrace.measure( "ChapterLoader", "buildChapter spine=\(spineIndex) priority=\(priority) cachedRanges=\(availablePageRanges?.count ?? 0)" @@ -75,7 +74,6 @@ final class RDEPUBChapterLoader { } RDEPUBBackgroundTrace.log("ChapterLoader", "buildChapter OK: spine=\(spineIndex) pages=\(chapter.pages.count)") - // 5. 回填缓存 store.insertChapter(chapter) let pc = RDEPUBRuntimePageCount( cacheKey: cacheKey, @@ -86,13 +84,12 @@ final class RDEPUBChapterLoader { ) store.insertPageCount(pc, for: cacheKey) - // 6. 按优先级处理完成逻辑 switch priority { case .navigation: - // 前台导航:检查是否有更新的导航目标(§14.4 取消语义) + let nextTarget = store.consumeNavigationTarget() if let target = nextTarget, target != spineIndex { - // 当前结果不再是用户目标,丢弃,转而加载新目标 + store.markBuilding(false) self.loadChapter(spineIndex: target, store: store, priority: .navigation, completion: completion) return @@ -109,8 +106,7 @@ final class RDEPUBChapterLoader { } case .prefetch: - // 后台预取:仅回填缓存,标记预取目标完成 - // 不触发跳章,不检查导航目标队列 + store.removePrefetchTarget(spineIndex) store.markBuilding(false) DispatchQueue.main.async { @@ -118,6 +114,7 @@ final class RDEPUBChapterLoader { } } } catch { + RDEPUBBackgroundTrace.log("ChapterLoader", "buildChapter FAILED: spine=\(spineIndex) error=\(error)") store.markBuilding(false) DispatchQueue.main.async { @@ -127,14 +124,6 @@ final class RDEPUBChapterLoader { } } - // MARK: - 同步加载入口(仅供 legacy 位置迁移使用) - - /// 约束: - /// - 必须复用同一个 chapterLoadQueue,保持 WXRead 的单章串行语义 - /// - 不允许恢复整书 RDEPUBTextBook - /// - 只允许从主线程或明确的非 chapterLoadQueue 上下文调用 - /// - 调用前必须执行 store.assertNotOnChapterLoadQueue() - /// - 只在首次迁移且目标章未命中缓存时使用 func loadChapterSynchronouslyForMigration( spineIndex: Int, store: RDEPUBChapterRuntimeStore? @@ -193,8 +182,6 @@ final class RDEPUBChapterLoader { return try result!.get() } - // MARK: - 单章构建(支持轻量缓存命中后跳过分页) - private func buildChapter( spineIndex: Int, availablePageRanges: [NSRange]?, @@ -210,7 +197,7 @@ final class RDEPUBChapterLoader { let layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize) if let pageRanges = availablePageRanges { - // ---- 轻量路径:pageCountCache 或 chapterSummaryDiskCache 命中 ---- + RDEPUBBackgroundTrace.log("ChapterLoader", "轻量路径 spine=\(spineIndex) 缓存页数=\(pageRanges.count)") return try buildChapterFromCachedPageRanges( spineIndex: spineIndex, @@ -224,7 +211,6 @@ final class RDEPUBChapterLoader { ) } - // ---- 完整路径:无缓存,走全量渲染 + 分页 ---- RDEPUBBackgroundTrace.log("ChapterLoader", "完整路径 spine=\(spineIndex)") let builder = context.makeTextBookBuilder(layoutConfig: layoutConfig) guard let result = try builder.buildChapter( @@ -245,8 +231,6 @@ final class RDEPUBChapterLoader { ) } - // MARK: - 轻量路径:复用已有 pageRanges,跳过完整分页 - private func buildChapterFromCachedPageRanges( spineIndex: Int, pageRanges: [NSRange], @@ -259,14 +243,14 @@ final class RDEPUBChapterLoader { ) throws -> RDEPUBRuntimeChapter { let spineItem = publication.spine[spineIndex] let href = spineItem.href - let title = spineItem.title ?? "" + let title = spineItem.title let baseURL = parser.fileURL(forRelativePath: href)?.deletingLastPathComponent() + let rawHTML = try requireHTMLString(parser, href: href) - // 1. 只做 HTML → NSAttributedString 渲染,不做分页 let request = RDEPUBTextRendererSupport.makeChapterRenderRequest( href: href, title: title, - rawHTML: try requireHTMLString(parser, href: href), + rawHTML: rawHTML, baseURL: baseURL, style: style, resourceResolver: publication.resourceResolver, @@ -281,14 +265,24 @@ final class RDEPUBChapterLoader { in: typesetString, style: style, layoutConfig: layoutConfig ) - // 2. metadata 来源策略: - // - diskSummary 非空(磁盘路径命中):从摘要恢复完整 metadata - // - diskSummary 为空(pageCountCache 命中但没走磁盘):从 attributedString 属性推断 - let metadataSource = diskSummary?.pageMetadataList + 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 { + RDEPUBBackgroundTrace.log( + "ChapterLoader", + "缓存页范围失效,回退重分页 spine=\(spineIndex) cached=\(pageRanges.count) valid=\(sanitizedCachedRanges.count) textLength=\(typesetString.length)" + ) + effectivePageRanges = typesetString.rd_paginatedFrames(size: pageSize, config: layoutConfig).map(\.contentRange) + metadataSource = nil + } - // 3. 直接用缓存的 pageRanges 构建 pages(跳过 CoreText 分页) let pages = buildPagesFromRanges( - pageRanges: pageRanges, + pageRanges: effectivePageRanges, typesetString: typesetString, spineIndex: spineIndex, href: href, @@ -296,35 +290,39 @@ final class RDEPUBChapterLoader { metadataSource: metadataSource ) - // 4. 构建 layouter(用于后续可能的重新分页场景) let layouter = RDEPUBTextLayouter( attributedString: typesetString, pageSize: pageSize, config: layoutConfig ) - // 5. 构建 chapterOffsetMap let offsetMap = RDEPUBChapterOffsetMap( fragmentOffsets: rendered.fragmentOffsets, pageStartOffsets: pages.map { $0.pageStartOffset }, - pageEndOffsets: pages.map { $0.pageEndOffset } + pageEndOffsets: pages.map { $0.pageEndOffset }, + cfiMap: diskSummary?.cfiMap ?? makeCFIMap( + href: href, + spineIndex: spineIndex, + fragmentOffsets: rendered.fragmentOffsets, + rawHTML: rawHTML, + chapterText: typesetString.string + ), + chapterText: typesetString.string ) return RDEPUBRuntimeChapter( spineIndex: spineIndex, href: href, title: title, - sourceAttributedString: nil, // 轻量路径不保留原始 source,降低内存 + sourceAttributedString: nil, typesetAttributedString: typesetString, layouter: layouter, - pageRanges: pageRanges, + pageRanges: effectivePageRanges, pages: pages, chapterOffsetMap: offsetMap ) } - - // MARK: - 从缓存的 pageRanges 直接构建 RDEPUBTextPage 数组 - + private func buildPagesFromRanges( pageRanges: [NSRange], typesetString: NSAttributedString, @@ -338,10 +336,10 @@ final class RDEPUBChapterLoader { let pageContent = typesetString.attributedSubstring(from: range) let metadata: RDEPUBTextPageMetadata if let metaList = metadataSource, pageIndex < metaList.count { - // 从摘要缓存恢复完整 metadata + metadata = metaList[pageIndex].toPageMetadata() } else { - // 无缓存 metadata,从 attributedString 属性推断 + metadata = inferPageMetadata( from: typesetString, range: range, @@ -366,7 +364,21 @@ final class RDEPUBChapterLoader { } } - // MARK: - 从 attributedString 的自定义属性推断页 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, @@ -431,8 +443,6 @@ final class RDEPUBChapterLoader { ) } - // MARK: - 从完整构建结果组装 RDEPUBRuntimeChapter - private func assembleRuntimeChapter( from chapter: RDEPUBTextChapter, spineIndex: Int, @@ -448,17 +458,25 @@ final class RDEPUBChapterLoader { let offsetMap = RDEPUBChapterOffsetMap( fragmentOffsets: chapter.fragmentOffsets, pageStartOffsets: chapter.pages.map { $0.pageStartOffset }, - pageEndOffsets: chapter.pages.map { $0.pageEndOffset } + pageEndOffsets: chapter.pages.map { $0.pageEndOffset }, + cfiMap: chapter.cfiMap ?? makeCFIMap( + href: chapter.href, + spineIndex: spineIndex, + fragmentOffsets: chapter.fragmentOffsets, + rawHTML: context.parser?.htmlString(forRelativePath: chapter.href), + chapterText: chapter.attributedContent.string + ), + chapterText: chapter.attributedContent.string ) let pageRanges = chapter.pages.map { $0.contentRange } - // 回填磁盘摘要(P2 阶段生效) let cacheKey = makeCacheKey(spineIndex: spineIndex) let summary = RDEPUBChapterSummary( pageRanges: pageRanges.map { .init(location: $0.location, length: $0.length) }, pageCount: chapter.pages.count, fragmentOffsets: chapter.fragmentOffsets, + cfiMap: offsetMap.cfiMap, renderSignature: cacheKey.renderSignature, schemaVersion: RDEPUBChapterSummary.currentSchemaVersion, chapterContentHash: cacheKey.chapterContentHash, @@ -479,13 +497,10 @@ final class RDEPUBChapterLoader { ) } - // MARK: - 缓存键 - private func makeCacheKey(spineIndex: Int) -> RDEPUBChapterCacheKey { let style = context.currentTextRenderStyle() let layoutConfig = context.currentTextLayoutConfig(pageSize: context.currentTextPageSize()) - // renderSignature 必须覆盖 §8.2 定义的全部参数 let lineHeightMultiple = context.configuration.lineHeightMultiple let renderSignature = [ @@ -521,11 +536,57 @@ final class RDEPUBChapterLoader { } 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).sha256Hex, + fragmentPathMap: domPaths + ) + ) + } } enum RDEPUBChapterLoadError: LocalizedError { + case missingParser + case emptyChapter(spineIndex: Int) + case emptyChapterHref(String) var errorDescription: String? { diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLocation.swift b/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLocation.swift index d08a9bb..6a33766 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLocation.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLocation.swift @@ -1,17 +1,15 @@ import Foundation -/// 章节级位置模型——单章内的精确定位 -/// 取代旧版 RDEPUBLocation 的全局 progression 方式 public struct RDEPUBChapterLocation: Codable, Equatable { - /// 章节在 spine 中的索引 + public var spineIndex: Int - /// 章内字符偏移(从章首算起,0-based) + public var chapterOffset: Int - /// HTML fragment ID(如章节内锚点 #section1) + public var fragmentID: String? - /// 章内 progression(可选,fragmentID 优先时为 nil) + public var progressionInChapter: Double? - /// schema 版本:1=粗估降级, 2=精确值 + public var schemaVersion: Int public init( @@ -28,6 +26,5 @@ public struct RDEPUBChapterLocation: Codable, Equatable { self.schemaVersion = schemaVersion } - /// 粗估结果标记:schemaVersion == 1 表示 chapterOffset 由 progression 粗估得来 var isFallbackEstimate: Bool { schemaVersion == 1 } } \ No newline at end of file diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterOffsetMap.swift b/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterOffsetMap.swift index a202adc..ce172e1 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterOffsetMap.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterOffsetMap.swift @@ -1,16 +1,35 @@ import Foundation struct RDEPUBChapterOffsetMap { + let fragmentOffsets: [String: Int] + let pageStartOffsets: [Int] + let pageEndOffsets: [Int] - /// fragmentID -> 章内字符偏移 + let cfiMap: RDEPUBCFIMap? + + let chapterText: String? + func chapterOffset(forFragmentID fragmentID: String) -> Int? { return fragmentOffsets[fragmentID] } - /// 章内字符偏移 -> 章内页码(从 0 开始) + 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] { @@ -19,4 +38,4 @@ struct RDEPUBChapterOffsetMap { } return nil } -} \ No newline at end of file +} diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterRuntimeStore.swift b/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterRuntimeStore.swift index 5f0e065..ccd8cc2 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterRuntimeStore.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterRuntimeStore.swift @@ -2,50 +2,36 @@ import UIKit final class RDEPUBChapterRuntimeStore { - // MARK: - 子缓存 - - /// 章节运行时主缓存(等价 WXRead chapterDataCache) private let chapterDataCache = RDEPUBChapterDataCache() - /// 轻量分页结构缓存(等价 WXRead pageCountCache) private let pageCountCache = RDEPUBPageCountCache() - /// 图片缓存(独立 NSCache,等价 WXRead imageCache) let imageCache = NSCache() - /// 串行加载队列(等价 WXRead com.weread.chapterload) - /// QoS .userInitiated:用户主动跳章/开书属于前台交互,需尽快完成 let chapterLoadQueue = DispatchQueue(label: "com.rdreader.chapterload", qos: .userInitiated) + private let chapterLoadQueueKey = DispatchSpecificKey() - // MARK: - 窗口状态 - - /// 当前章 spineIndex private(set) var currentSpineIndex: Int? - /// 当前窗口内的 spineIndex 集合(以当前章为中心,按配置半径展开) private(set) var windowSpineIndices: [Int] = [] - // MARK: - 请求通道(前台导航 vs 后台预取,语义独立,互不抢占) - - /// 前台导航目标(用户主动跳章:目录/书签/搜索/翻章) - /// 仅保留最后一次目标,旧的排队请求可被取消 private var pendingNavigationTarget: Int? + private let navigationLock = NSLock() - /// 后台预取目标集合(±1 相邻章预取) - /// 预取不抢占前台导航通道,预取完成后仅刷新快照,不触发跳章 private var pendingPrefetchTargets: Set = [] + private let prefetchLock = NSLock() - /// 是否有章节正在构建中 private(set) var isBuilding: Bool = false + private let buildingLock = NSLock() - // MARK: - 初始化 - init() { + imageCache.countLimit = 50 + chapterLoadQueue.setSpecific(key: chapterLoadQueueKey, value: ()) } @@ -53,8 +39,6 @@ final class RDEPUBChapterRuntimeStore { dispatchPrecondition(condition: .notOnQueue(chapterLoadQueue)) } - // MARK: - 缓存查询(线程安全,通过 cache wrapper 的 lock 保护) - func chapterData(for spineIndex: Int) -> RDEPUBRuntimeChapter? { return chapterDataCache[spineIndex] } @@ -63,8 +47,6 @@ final class RDEPUBChapterRuntimeStore { return pageCountCache[key] } - // MARK: - 缓存插入 - func insertChapter(_ chapter: RDEPUBRuntimeChapter) { chapterDataCache[chapter.spineIndex] = chapter } @@ -73,9 +55,6 @@ final class RDEPUBChapterRuntimeStore { pageCountCache[key] = pc } - // MARK: - 窗口管理 - - /// 设定当前章,自动计算按半径展开的窗口 func setCurrentChapter(spineIndex: Int, totalSpineCount: Int, windowRadius: Int = 1) { currentSpineIndex = spineIndex let radius = max(0, windowRadius) @@ -88,14 +67,11 @@ final class RDEPUBChapterRuntimeStore { windowSpineIndices = Array(lowerBound...upperBound) } - /// 返回窗口外、应该淘汰的 spineIndex func evictableSpineIndices() -> [Int] { let windowSet = Set(windowSpineIndices) return chapterDataCache.storedSpineIndices.filter { !windowSet.contains($0) } } - // MARK: - 淘汰 - func evict(spineIndex: Int) { chapterDataCache.remove(spineIndex: spineIndex) pageCountCache.remove(forSpineIndex: spineIndex) @@ -112,28 +88,21 @@ final class RDEPUBChapterRuntimeStore { if let ch = currentChapter { chapterDataCache[current] = ch } - // WXRead 语义:内存警告时 pageCountCache 全量清空 + pageCountCache.removeAll() } - // MARK: - 内存警告 - func handleMemoryWarning() { evictAllExceptCurrent() imageCache.removeAllObjects() } - // MARK: - 前台导航请求管理(§14.4 取消语义) - - /// 注册前台导航目标(用户主动跳章时调用) - /// 仅保留最后一次目标,旧的排队请求可被取消 func setNavigationTarget(spineIndex: Int) { navigationLock.lock() pendingNavigationTarget = spineIndex navigationLock.unlock() } - /// 消费前台导航目标(章节构建完成后调用,检查是否有更新的目标) func consumeNavigationTarget() -> Int? { navigationLock.lock() let target = pendingNavigationTarget @@ -142,31 +111,24 @@ final class RDEPUBChapterRuntimeStore { return target } - // MARK: - 后台预取请求管理 - - /// 注册后台预取目标(±1 相邻章预取时调用) - /// 预取不抢占前台导航通道 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) @@ -180,8 +142,6 @@ final class RDEPUBChapterRuntimeStore { buildingLock.unlock() } - // MARK: - P1: 排版参数变化后整体失效(§8.5) - func invalidateAllForSettingsChange() { chapterDataCache.removeAll() pageCountCache.removeAll() diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterSummaryDiskCache.swift b/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterSummaryDiskCache.swift index 2e110bc..4cb2bb0 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterSummaryDiskCache.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterSummaryDiskCache.swift @@ -1,8 +1,11 @@ import Foundation final class RDEPUBChapterSummaryDiskCache { + private let cacheDirectory: URL + private let fileManager = FileManager.default + private let queue = DispatchQueue(label: "com.rdreader.summarydiskcache", qos: .utility) init(cacheDirectory: URL) { @@ -10,28 +13,22 @@ final class RDEPUBChapterSummaryDiskCache { try? fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true) } - // MARK: - 写入(异步) - 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 { } } - // MARK: - 读取(同步,因为 loadChapter 已在串行队列上) - func read(for key: RDEPUBChapterCacheKey) -> RDEPUBChapterSummary? { let fileURL = self.fileURL(for: key) let data: Data @@ -40,7 +37,7 @@ final class RDEPUBChapterSummaryDiskCache { } 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)") @@ -58,11 +55,6 @@ final class RDEPUBChapterSummaryDiskCache { } } - // MARK: - 批量读取:二次打开时直接从磁盘构建 BookPageMap - - /// 批量读取指定缓存键列表的摘要,返回 spineIndex → summary 映射。 - /// 同步方法,应在后台线程调用。 - /// 由调用方负责构建完整的缓存键列表(含正确的 contentHash)。 func readAll(keys: [(key: RDEPUBChapterCacheKey, spineIndex: Int, href: String, title: String)]) -> ( summaries: [Int: RDEPUBChapterSummary], mapBuilder: RDEPUBBookPageMap.Builder @@ -85,7 +77,6 @@ final class RDEPUBChapterSummaryDiskCache { return (summaries, mapBuilder) } - /// 检查指定缓存键列表是否全部有对应的磁盘摘要。 func isCacheComplete(keys: [RDEPUBChapterCacheKey]) -> Bool { for key in keys { if read(for: key) == nil { @@ -95,24 +86,20 @@ final class RDEPUBChapterSummaryDiskCache { 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) @@ -128,9 +115,6 @@ final class RDEPUBChapterSummaryDiskCache { return (count, totalBytes) } - // MARK: - key -> 文件路径 - - /// 使用确定性字符串拼接生成文件名,不依赖 Hashable.hashValue private func fileURL(for key: RDEPUBChapterCacheKey) -> URL { let bookPrefix = Self.cacheNamespacePrefix(for: key.bookID) let renderPrefix = Self.cacheNamespacePrefix(for: key.renderSignature) @@ -171,29 +155,48 @@ final class RDEPUBChapterSummaryDiskCache { } 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 = 6 + static let currentSchemaVersion = 9 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 { diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterWindowCoordinator.swift b/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterWindowCoordinator.swift index a8aacdc..4ae8b80 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterWindowCoordinator.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterWindowCoordinator.swift @@ -1,14 +1,15 @@ import Foundation final class RDEPUBChapterWindowCoordinator { + private unowned let context: RDEPUBReaderContext + private let store: RDEPUBChapterRuntimeStore + private let loader: RDEPUBChapterLoader - /// 当前窗口快照 private(set) var currentSnapshot: RDEPUBChapterWindowSnapshot? - /// 窗口切换回调 var onSnapshotChanged: ((RDEPUBChapterWindowSnapshot) -> Void)? init(context: RDEPUBReaderContext, store: RDEPUBChapterRuntimeStore, loader: RDEPUBChapterLoader) { @@ -17,11 +18,8 @@ final class RDEPUBChapterWindowCoordinator { self.loader = loader } - /// 章节加载完成后恢复位置用 private var restoreChapterOffset: Int? - // MARK: - 打开书籍 - func openBook(at targetSpineIndex: Int, restoreChapterOffset: Int? = nil) { let totalSpineCount = context.publication?.spine.count ?? 0 store.setCurrentChapter( @@ -31,19 +29,15 @@ final class RDEPUBChapterWindowCoordinator { ) self.restoreChapterOffset = restoreChapterOffset - // 标记切章进行中 isSwitchingChapter = true - // 注册前台导航目标 store.setNavigationTarget(spineIndex: targetSpineIndex) - // 清空旧预取目标 + store.clearPrefetchTargets() - // 加载目标章(前台导航优先级) loadChapterWithFallback(initialSpineIndex: targetSpineIndex, totalSpineCount: totalSpineCount) } - /// 加载章节,如果当前章节失败则自动尝试下一个可渲染的章节 private func loadChapterWithFallback(initialSpineIndex: Int, totalSpineCount: Int) { loader.loadChapter(spineIndex: initialSpineIndex, store: store, priority: .navigation) { [weak self] result in guard let self = self else { return } @@ -55,7 +49,7 @@ final class RDEPUBChapterWindowCoordinator { #if DEBUG print("[EPUB][WindowCoord] loadChapter failed at spine=\(initialSpineIndex): \(error), trying next") #endif - // 自动跳过不可渲染的章节(封面/版权页等 linear=false 的 spine 项) + let nextIndex = initialSpineIndex + 1 if nextIndex < totalSpineCount { self.store.setCurrentChapter( @@ -66,7 +60,7 @@ final class RDEPUBChapterWindowCoordinator { self.store.setNavigationTarget(spineIndex: nextIndex) self.loadChapterWithFallback(initialSpineIndex: nextIndex, totalSpineCount: totalSpineCount) } else { - // 所有章节都不可渲染 + self.isSwitchingChapter = false self.handle(error: error) } @@ -74,8 +68,6 @@ final class RDEPUBChapterWindowCoordinator { } } - // MARK: - 构建窗口快照 - private func buildSnapshotAroundCurrent(chapter: RDEPUBRuntimeChapter) { guard let current = store.currentSpineIndex else { #if DEBUG @@ -98,26 +90,20 @@ final class RDEPUBChapterWindowCoordinator { onSnapshotChanged?(snapshot) isApplyingSnapshot = false - // 首次打开时恢复到指定 chapterOffset if let offset = restoreChapterOffset, let chapter = snapshot.chapterForPage(flattenedPageIndex: snapshot.anchorPageOffset), let pageIndex = chapter.chapterOffsetMap.pageIndex(forChapterOffset: offset) { let targetPage = snapshot.anchorPageOffset + pageIndex context.readerView?.transitionToPage(pageNum: targetPage, animated: false) } else if snapshot.pageCount > 0 { - // 首次打开且没有恢复位置时,必须显式落到当前章首屏。 - // reloadData() 内部 switchReaderDisplayType 已将 currentPage 从 -1 置为 0, - // 但 0 不一定是目标章的起始页(anchorPageOffset),仍需显式 transition。 + context.readerView?.transitionToPage(pageNum: snapshot.anchorPageOffset, animated: false) } restoreChapterOffset = nil - // 预取窗口内尚未加载的章节 prefetchAdjacent(current: current) } - // MARK: - 预取(后台优先级,不抢占前台导航通道) - private func prefetchAdjacent(current: Int) { for spineIndex in store.windowSpineIndices where spineIndex != current { guard store.chapterData(for: spineIndex) == nil else { continue } @@ -129,9 +115,6 @@ final class RDEPUBChapterWindowCoordinator { } } - // MARK: - 翻章 - - /// 到达章末,翻到下一章 func flipToNextChapter(completion: @escaping (Result) -> Void) { guard let current = store.currentSpineIndex else { return } let next = current + 1 @@ -141,27 +124,23 @@ final class RDEPUBChapterWindowCoordinator { flipToChapter(spineIndex: next, completion: completion) } - /// 到达章首,翻到上一章 func flipToPreviousChapter(completion: @escaping (Result) -> Void) { guard let current = store.currentSpineIndex, current > 0 else { return } flipToChapter(spineIndex: current - 1, completion: completion) } - /// 跳转到指定章节(目录/书签/搜索) func flipToChapter( spineIndex: Int, completion: @escaping (Result) -> Void ) { let totalSpineCount = context.publication?.spine.count ?? 0 - // 注册前台导航目标 store.setNavigationTarget(spineIndex: spineIndex) - // 清空后台预取目标 + store.clearPrefetchTargets() - // 标记切章进行中 + isSwitchingChapter = true - // 先淘汰旧窗口外章节 store.setCurrentChapter( spineIndex: spineIndex, totalSpineCount: totalSpineCount, @@ -172,7 +151,6 @@ final class RDEPUBChapterWindowCoordinator { store.evict(spineIndex: idx) } - // 如果目标章已在缓存中,直接构建快照 if let cached = store.chapterData(for: spineIndex) { buildSnapshotAroundCurrent(chapter: cached) isSwitchingChapter = false @@ -182,7 +160,6 @@ final class RDEPUBChapterWindowCoordinator { return } - // 未命中缓存,走加载链路(前台导航优先级) loader.loadChapter(spineIndex: spineIndex, store: store, priority: .navigation) { [weak self] result in guard let self = self else { return } self.isSwitchingChapter = false @@ -198,13 +175,10 @@ final class RDEPUBChapterWindowCoordinator { } } - // MARK: - 刷新快照(预取完成后调用) - func refreshSnapshot() { guard let current = store.currentSpineIndex, let currentChapter = store.chapterData(for: current) else { return } - // 空闲门槛检查 guard isReaderIdle() else { DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in self?.refreshSnapshot() @@ -223,13 +197,9 @@ final class RDEPUBChapterWindowCoordinator { } } - // MARK: - P1: 翻章后维护窗口 - - /// 当前章常驻,预取新的相邻章 func maintainWindow(afterMovingTo spineIndex: Int) { let totalSpineCount = context.publication?.spine.count ?? 0 - // 淘汰窗口外章节 store.setCurrentChapter( spineIndex: spineIndex, totalSpineCount: totalSpineCount, @@ -242,18 +212,16 @@ final class RDEPUBChapterWindowCoordinator { prefetchAdjacent(current: spineIndex) } - // MARK: - 内部辅助 - private func handle(error: Error) { - // 日志记录,不中断当前阅读状态 + #if DEBUG print("[RDEPUBChapterWindowCoordinator] chapter load error: \(error)") #endif - // 确保 loading 指示器在加载失败时也被隐藏(避免永久白屏) + DispatchQueue.main.async { [weak self] in guard let self else { return } self.context.hideLoading() - // 如果有快照但没内容显示,显示错误提示 + if self.currentSnapshot == nil { #if DEBUG print("[RDEPUBChapterWindowCoordinator] No snapshot after error, page will be blank") @@ -293,8 +261,7 @@ final class RDEPUBChapterWindowCoordinator { return true } - /// 切章进行中标记 private var isSwitchingChapter: Bool = false - /// 应用快照进行中标记 + private var isApplyingSnapshot: Bool = false } diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterWindowSnapshot.swift b/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterWindowSnapshot.swift index 81f6736..561d838 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterWindowSnapshot.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterWindowSnapshot.swift @@ -1,26 +1,17 @@ import Foundation struct RDEPUBChapterWindowSnapshot { - /// 窗口中的章节(有序) + let chapters: [RDEPUBRuntimeChapter] - /// 展平后的连续页数组(供 RDReaderView 消费) - /// 窗口内页码不写回 RDEPUBTextPage 模型; - /// flattenedPages 的数组下标就是窗口内连续页码(从 0 开始)。 let flattenedPages: [RDEPUBTextPage] - /// 当前章在 chapters 数组中的索引 let anchorChapterIndex: Int - /// 当前章在 flattenedPages 中的起始页码(从 0 开始,窗口内编号) let anchorPageOffset: Int - /// 当前窗口首章的 spineIndex,用于调试日志和跨窗口映射 let windowStartSpineIndex: Int - // MARK: - 构建 - - /// 从章节窗口构建快照 static func from( chapters: [RDEPUBRuntimeChapter], anchorSpineIndex: Int @@ -29,7 +20,6 @@ struct RDEPUBChapterWindowSnapshot { let anchorIndex = sortedChapters.firstIndex { $0.spineIndex == anchorSpineIndex } ?? 0 let pageOffset = sortedChapters.prefix(anchorIndex).reduce(0) { $0 + $1.pages.count } - // 展平页数组 var allPages: [RDEPUBTextPage] = [] for (chIdx, ch) in sortedChapters.enumerated() { for var page in ch.pages { @@ -49,9 +39,6 @@ struct RDEPUBChapterWindowSnapshot { ) } - // MARK: - 查询 - - /// 窗口内页码(即 flattenedPages 下标)-> 所属章节 func chapterForPage(flattenedPageIndex: Int) -> RDEPUBRuntimeChapter? { var offset = 0 for ch in chapters { @@ -63,11 +50,9 @@ struct RDEPUBChapterWindowSnapshot { return nil } - /// 窗口内页码(即 flattenedPages 下标)-> 所属章节的 spineIndex func spineIndexForPage(flattenedPageIndex: Int) -> Int? { return chapterForPage(flattenedPageIndex: flattenedPageIndex)?.spineIndex } - /// 总页数(窗口内) var pageCount: Int { flattenedPages.count } } diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBPageCountCache.swift b/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBPageCountCache.swift index bbb6cb9..df58a84 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBPageCountCache.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBPageCountCache.swift @@ -1,7 +1,9 @@ import Foundation final class RDEPUBPageCountCache { + private var storage: [RDEPUBChapterCacheKey: RDEPUBRuntimePageCount] = [:] + private let lock = NSLock() subscript(key: RDEPUBChapterCacheKey) -> RDEPUBRuntimePageCount? { diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBPageResolver.swift b/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBPageResolver.swift index 6036b0a..af9d86b 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBPageResolver.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBPageResolver.swift @@ -1,13 +1,18 @@ 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) { diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBRuntimeChapter.swift b/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBRuntimeChapter.swift index 61cc2a7..fbc5045 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBRuntimeChapter.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBRuntimeChapter.swift @@ -1,26 +1,23 @@ 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( @@ -45,7 +42,6 @@ final class RDEPUBRuntimeChapter { self.chapterOffsetMap = chapterOffsetMap } - /// 释放 sourceAttributedString 以降低内存 func releaseSourceText() { sourceAttributedString = nil } diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBRuntimePageCount.swift b/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBRuntimePageCount.swift index 294007a..c62966f 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBRuntimePageCount.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBRuntimePageCount.swift @@ -1,9 +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/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/String+SHA256.swift b/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/String+SHA256.swift index fed980f..be051d3 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/String+SHA256.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/String+SHA256.swift @@ -1,8 +1,11 @@ import CryptoKit extension String { + var sha256Hex: String { + let digest = SHA256.hash(data: Data(self.utf8)) + return digest.map { String(format: "%02x", $0) }.joined() } } \ No newline at end of file diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBBackgroundCoverageStore.swift b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBBackgroundCoverageStore.swift index 3413825..c4a1be6 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBBackgroundCoverageStore.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBBackgroundCoverageStore.swift @@ -1,65 +1,56 @@ import Foundation -/// 后台覆盖段:表示某一段章节范围的稳定页码覆盖 struct RDEPUBBackgroundCoverageSegment { - /// 段的下界 spineIndex + let lowerSpineIndex: Int - /// 段的上界 spineIndex + let upperSpineIndex: Int - /// 页图数据 + let pageMap: RDEPUBBookPageMap - /// 已解析的 spineIndex 集合 + let resolvedSpineIndices: Set - /// 创建时间 + let generatedAt: CFAbsoluteTime - /// 渲染签名 + let renderSignature: String - /// 预估内存占用(字节) + let estimatedMemoryBytes: Int - /// 是否包含指定 spineIndex func contains(spineIndex: Int) -> Bool { spineIndex >= lowerSpineIndex && spineIndex <= upperSpineIndex } - /// 与指定 spineIndex 的距离 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 // 8MB + 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 - /// 最后访问时间(用于 LRU 淘汰) private var lastAccessTime: [Int: CFAbsoluteTime] = [:] init(context: RDEPUBReaderContext, policy: RDEPUBBackgroundCoverageStorePolicy = .default) { @@ -67,12 +58,10 @@ final class RDEPUBBackgroundCoverageStore { self.policy = policy } - /// 添加段 func addSegment(_ segment: RDEPUBBackgroundCoverageSegment) { - // 检查是否需要淘汰 + evictIfNeeded(forNewSegment: segment) - // 检查是否与现有段重叠,合并或替换 var merged = false for (index, existing) in segments.enumerated() { if canMerge(existing, segment) { @@ -94,7 +83,6 @@ final class RDEPUBBackgroundCoverageStore { currentMemoryBytes += segment.estimatedMemoryBytes } - // 更新访问时间 lastAccessTime[segment.lowerSpineIndex] = CFAbsoluteTimeGetCurrent() RDEPUBBackgroundTrace.log( @@ -103,7 +91,6 @@ final class RDEPUBBackgroundCoverageStore { ) } - /// 查找覆盖指定 spineIndex 的段 func findSegment(containing spineIndex: Int) -> RDEPUBBackgroundCoverageSegment? { let segment = segments.first { $0.contains(spineIndex: spineIndex) } if let segment { @@ -112,7 +99,6 @@ final class RDEPUBBackgroundCoverageStore { return segment } - /// 查找覆盖指定 spineIndex 集合的段 func findSegment(covering spineIndices: Set) -> RDEPUBBackgroundCoverageSegment? { let segment = segments.first { segment in spineIndices.allSatisfy { segment.contains(spineIndex: $0) } @@ -123,19 +109,16 @@ final class RDEPUBBackgroundCoverageStore { return segment } - /// 获取所有段 func allSegments() -> [RDEPUBBackgroundCoverageSegment] { segments } - /// 清除所有段 func clearAll() { segments.removeAll() currentMemoryBytes = 0 lastAccessTime.removeAll() } - /// 清除冷区段(不覆盖当前阅读位置和保护区的段) func clearColdSegments( activeWindowSpineIndices: Set, protectedSpineIndices: Set @@ -151,7 +134,6 @@ final class RDEPUBBackgroundCoverageStore { } } - /// 处理内存警告 func handleMemoryWarning( activeWindowSpineIndices: Set, protectedSpineIndices: Set @@ -161,15 +143,13 @@ final class RDEPUBBackgroundCoverageStore { "memory warning: clearing cold segments, current=\(currentMemoryBytes)B" ) - // 第一步:清除冷区段 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 @@ -194,24 +174,20 @@ final class RDEPUBBackgroundCoverageStore { ) } - /// 检查是否需要淘汰 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() { @@ -232,33 +208,27 @@ final class RDEPUBBackgroundCoverageStore { ) } - /// 检查两个段是否可以合并 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 } - // 合并 resolvedSpineIndices 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) @@ -274,7 +244,6 @@ final class RDEPUBBackgroundCoverageStore { ) } - /// 合并两个页图 private func mergePageMaps(_ older: RDEPUBBookPageMap, _ newer: RDEPUBBookPageMap) -> RDEPUBBookPageMap { var builder = RDEPUBBookPageMap.Builder() var entriesBySpineIndex: [Int: RDEPUBBookPageMapEntry] = [:] @@ -300,7 +269,6 @@ final class RDEPUBBackgroundCoverageStore { return builder.build() } - /// 估算内存占用 private func estimateMemoryBytes(pageMap: RDEPUBBookPageMap, resolvedCount: Int) -> Int { 256 + pageMap.entries.count * 96 + resolvedCount * 16 } diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBBackgroundPriorityPolicy.swift b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBBackgroundPriorityPolicy.swift index 208d74b..0fc3cfd 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBBackgroundPriorityPolicy.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBBackgroundPriorityPolicy.swift @@ -1,19 +1,15 @@ import Foundation -/// 后台补全优先级策略 -/// -/// 控制后台元数据解析的优先级排序,确保当前阅读区段优先补全 struct RDEPUBBackgroundPriorityPolicy { - /// 热区半径:当前章节附近的范围 + let hotRadius: Int - /// 温区半径:远距跳转锚点附近的范围 + let warmRadius: Int - /// 最大温区跳转锚点数量 + let maxWarmJumpAnchors: Int - /// 冷区份额:每轮补全中冷区任务的比例 + let coldLaneShare: Double - /// 默认策略 static let `default` = RDEPUBBackgroundPriorityPolicy( hotRadius: 24, warmRadius: 96, @@ -21,7 +17,6 @@ struct RDEPUBBackgroundPriorityPolicy { 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) @@ -34,7 +29,6 @@ struct RDEPUBBackgroundPriorityPolicy { } } -/// 优先级带 enum RDEPUBPriorityBand: Int, Comparable { case hot = 0 case warmPrimary = 1 @@ -46,39 +40,32 @@ enum RDEPUBPriorityBand: Int, Comparable { } } -/// 温区跳转锚点 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] = [] - /// 当前 generation private(set) var currentGeneration: Int = 0 - /// 冷区游标 private var coldCursor: Int = 0 init(context: RDEPUBReaderContext) { @@ -86,12 +73,10 @@ final class RDEPUBBackgroundPriorityManager { self.policy = .default } - /// 更新策略 func updatePolicy(_ newPolicy: RDEPUBBackgroundPriorityPolicy) { policy = newPolicy } - /// 添加温区跳转锚点 func addWarmAnchor(spineIndex: Int) { let anchor = RDEPUBWarmJumpAnchor( spineIndex: spineIndex, @@ -101,13 +86,12 @@ final class RDEPUBBackgroundPriorityManager { warmAnchors.insert(anchor, at: 0) - // 保留最近 N 个锚点 if warmAnchors.count > policy.maxWarmJumpAnchors { warmAnchors = Array(warmAnchors.prefix(policy.maxWarmJumpAnchors)) } currentGeneration += 1 - coldCursor = 0 // 重置冷区游标 + coldCursor = 0 RDEPUBBackgroundTrace.log( "PriorityManager", @@ -115,7 +99,6 @@ final class RDEPUBBackgroundPriorityManager { ) } - /// 生成优先级排序的 spineIndex 列表 func makeMetadataPriorityOrder( allBuildableIndices: [Int], currentSpineIndex: Int?, @@ -124,7 +107,6 @@ final class RDEPUBBackgroundPriorityManager { let uncachedIndices = allBuildableIndices.filter { !cachedSpineIndices.contains($0) } guard !uncachedIndices.isEmpty else { return [] } - // 为每个 spineIndex 计算优先级带 let items = uncachedIndices.map { spineIndex -> (spineIndex: Int, band: RDEPUBPriorityBand) in let band = classifySpineIndex( spineIndex: spineIndex, @@ -133,33 +115,29 @@ final class RDEPUBBackgroundPriorityManager { 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 } - // 距离相同时按 spineIndex 排序 return lhs.spineIndex < rhs.spineIndex } return sorted.map { $0.spineIndex } } - /// 分类 spineIndex 到优先级带 private func classifySpineIndex( spineIndex: Int, currentSpineIndex: Int? ) -> RDEPUBPriorityBand { - // 检查是否在热区 + if let current = currentSpineIndex { let distance = abs(spineIndex - current) if distance <= policy.hotRadius { @@ -167,7 +145,6 @@ final class RDEPUBBackgroundPriorityManager { } } - // 检查是否在温区 for (index, anchor) in warmAnchors.enumerated() { let distance = abs(spineIndex - anchor.spineIndex) if distance <= policy.warmRadius { @@ -175,16 +152,13 @@ final class RDEPUBBackgroundPriorityManager { } } - // 冷区 return .cold } - /// 获取当前温区锚点(用于后台任务调度) func currentWarmAnchors() -> [RDEPUBWarmJumpAnchor] { warmAnchors } - /// 重置状态 func reset() { warmAnchors.removeAll() currentGeneration = 0 diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBJumpSession.swift b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBJumpSession.swift index 0f347ab..dbd1238 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBJumpSession.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBJumpSession.swift @@ -1,56 +1,47 @@ import Foundation -/// 远距跳转会话:保护前台阅读窗口不被后台页图覆盖。 -/// -/// 当用户执行远距跳转(目录、书签、搜索)后创建,在保护期内: -/// - 不允许任何不覆盖保护区的后台页图接管前台 -/// - 后台补全围绕当前阅读区段优先 struct RDEPUBJumpSession { - /// 跳转目标的 spineIndex + let anchorSpineIndex: Int - /// 创建时间 + let createdAt: CFAbsoluteTime - /// 受保护的 spineIndex 集合 + 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 } } -/// JumpSession 策略配置 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, @@ -71,26 +62,20 @@ public struct RDEPUBJumpSessionPolicy: Equatable { } } -/// JumpSession 管理器 final class RDEPUBJumpSessionManager { + private unowned let context: RDEPUBReaderContext - /// 当前活跃的 JumpSession 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 @@ -100,7 +85,6 @@ final class RDEPUBJumpSessionManager { self.context = context } - /// 创建新的 JumpSession @discardableResult func createSession( anchorSpineIndex: Int, @@ -110,7 +94,6 @@ final class RDEPUBJumpSessionManager { let policy = context.configuration.jumpSessionPolicy let now = CFAbsoluteTimeGetCurrent() - // 计算保护区 var protectedIndices: Set = [anchorSpineIndex] for offset in 1...policy.protectedNeighborRadius { let lower = anchorSpineIndex - offset @@ -146,7 +129,6 @@ final class RDEPUBJumpSessionManager { return session } - /// 记录用户翻页 func recordPageChange(fromSpineIndex: Int, toSpineIndex: Int) { guard activeSession != nil else { return } @@ -161,29 +143,24 @@ final class RDEPUBJumpSessionManager { } } - /// 检查是否允许页图接管 func shouldAllowPageMapTakeover(candidateSpineIndices: Set) -> Bool { guard let session = activeSession else { - return true // 没有活跃 Session,允许接管 + return true } - // 检查候选页图是否覆盖保护区 let protectedIndices = session.protectedSpineIndices let coverageRatio = Double(protectedIndices.intersection(candidateSpineIndices).count) / Double(protectedIndices.count) - // 必须覆盖至少 80% 的保护区 return coverageRatio >= 0.8 } - /// 检查是否应该结束 Session func checkSessionEnd(currentSpineIndex: Int, isIdle: Bool) -> RDEPUBJumpSession.EndReason? { guard let session = activeSession else { return nil } let now = CFAbsoluteTimeGetCurrent() let policy = context.configuration.jumpSessionPolicy - // 1. 检查超时 if now >= session.expiresAt { if isIdle || (now - lastActivityTime) >= policy.idleGracePeriod { RDEPUBBackgroundTrace.log( @@ -194,7 +171,6 @@ final class RDEPUBJumpSessionManager { } } - // 2. 检查是否离开保护区 if !session.protectedSpineIndices.contains(currentSpineIndex) { if consecutivePageCount >= policy.exitPageThreshold { RDEPUBBackgroundTrace.log( @@ -204,14 +180,13 @@ final class RDEPUBJumpSessionManager { return .navigatedAway } } else { - // 在保护区内,重置连续翻页计数 + consecutivePageCount = 0 } return nil } - /// 结束当前 Session func endSession(_ reason: RDEPUBJumpSession.EndReason) { guard let session = activeSession else { return } RDEPUBBackgroundTrace.log( @@ -223,7 +198,6 @@ final class RDEPUBJumpSessionManager { lastPageDirection = nil } - /// 清除 Session(用于重新加载等场景) func clearSession() { activeSession = nil consecutivePageCount = 0 diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBPageMapReconciliationCoordinator.swift b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBPageMapReconciliationCoordinator.swift index c77f0ff..fc57fc6 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBPageMapReconciliationCoordinator.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBPageMapReconciliationCoordinator.swift @@ -1,33 +1,31 @@ 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) @@ -35,7 +33,6 @@ final class RDEPUBPageMapReconciliationCoordinator { return .keepCurrentWindow } - // 获取当前阅读位置 let currentSpineIndex = context.runtime?.locationCoordinator.currentVisibleLocation() .flatMap { context.normalizedSpineIndex(for: $0) } @@ -46,11 +43,10 @@ final class RDEPUBPageMapReconciliationCoordinator { return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml")) }) ?? 0 - // 检查 JumpSession 保护 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) / @@ -66,7 +62,6 @@ final class RDEPUBPageMapReconciliationCoordinator { } } - // 检查边界章节覆盖 if let currentSpineIndex { let requiresAdjacentCoverage = currentSpineIndex > 0 && currentSpineIndex < lastBuildableSpineIndex @@ -85,7 +80,6 @@ final class RDEPUBPageMapReconciliationCoordinator { } } - // 检查渲染签名一致性 if let candidateSegment { let currentRenderSignature = context.currentRenderSignature() if candidateSegment.renderSignature != currentRenderSignature { @@ -97,7 +91,6 @@ final class RDEPUBPageMapReconciliationCoordinator { } } - // 评估接管类型 if let candidateSegment { return evaluateSegmentTakeover( candidateSegment: candidateSegment, @@ -119,7 +112,6 @@ final class RDEPUBPageMapReconciliationCoordinator { return .keepCurrentWindow } - /// 评估分段接管 private func evaluateSegmentTakeover( candidateSegment: RDEPUBBackgroundCoverageSegment, currentWindow: RDEPUBBookPageMap, @@ -129,14 +121,12 @@ final class RDEPUBPageMapReconciliationCoordinator { 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) || @@ -146,21 +136,20 @@ final class RDEPUBPageMapReconciliationCoordinator { } } - // 检查是否与当前窗口连续 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) } } @@ -168,7 +157,6 @@ final class RDEPUBPageMapReconciliationCoordinator { return .keepCurrentWindow } - /// 评估全量页图接管 private func evaluateFullPageMapTakeover( candidatePageMap: RDEPUBBookPageMap, currentWindow: RDEPUBBookPageMap, @@ -177,14 +165,12 @@ final class RDEPUBPageMapReconciliationCoordinator { ) -> RDEPUBPageMapTakeoverDecision { let candidateIndices = Set(candidatePageMap.entries.map { $0.spineIndex }) - // 检查是否覆盖当前阅读位置 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) || @@ -194,7 +180,6 @@ final class RDEPUBPageMapReconciliationCoordinator { } } - // 检查是否完整覆盖 let isComplete = candidateIndices.count >= currentWindow.entries.count if isComplete { return .fullReplace(candidatePageMap) @@ -203,7 +188,6 @@ final class RDEPUBPageMapReconciliationCoordinator { return .keepCurrentWindow } - /// 生成保护区域的 spineIndex 集合 func protectedSpineIndices( currentSpineIndex: Int?, jumpSession: RDEPUBJumpSession? @@ -212,7 +196,7 @@ final class RDEPUBPageMapReconciliationCoordinator { if let currentSpineIndex { indices.insert(currentSpineIndex) - // 添加相邻章节 + if currentSpineIndex > 0 { indices.insert(currentSpineIndex - 1) } diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderAnnotationCoordinator.swift b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderAnnotationCoordinator.swift index 42015d6..e961cd3 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderAnnotationCoordinator.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderAnnotationCoordinator.swift @@ -1,13 +1,7 @@ import UIKit -/// EPUB 阅读器标注协调器:负责高亮、批注和书签的增删改查操作。 -/// -/// 职责: -/// - 管理高亮(highlight)与批注(annotation)的创建、更新和删除 -/// - 管理书签(bookmark)的添加、切换和删除 -/// - 处理文本选中后的菜单操作(复制、高亮、批注) -/// - 弹出高亮管理器和书签管理器界面 final class RDEPUBReaderAnnotationCoordinator { + private unowned let context: RDEPUBReaderContext init(context: RDEPUBReaderContext) { @@ -18,19 +12,16 @@ final class RDEPUBReaderAnnotationCoordinator { context.controller } - /// 根据 ID 查找书签。 func bookmark(withID id: String) -> RDEPUBBookmark? { guard let controller else { return nil } return controller.activeBookmarks.first { $0.id == 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)) @@ -39,9 +30,6 @@ final class RDEPUBReaderAnnotationCoordinator { } } - /// 统一选区状态变更入口。 - /// 在 `.selected` 时:更新 context 选区、显示工具栏、刷新 chrome、通知 delegate。 - /// 在 `.idle` 时:清空选区、刷新 chrome、通知 delegate。 func applySelectionState(_ state: RDEPUBSelectionState) { guard let controller else { return } context.selectionState = state @@ -52,9 +40,6 @@ final class RDEPUBReaderAnnotationCoordinator { case .selecting: break case .selected(let selection): - if controller.readerView.isShowToolView == false { - controller.readerView.tapCenter() - } controller.updateReaderChrome() controller.delegate?.epubReader(controller, didChangeSelection: selection) case .committingAction: @@ -62,7 +47,6 @@ final class RDEPUBReaderAnnotationCoordinator { } } - /// 基于当前选区添加高亮标记,自动去重并持久化。 @discardableResult func addHighlight( from selection: RDEPUBSelection? = nil, @@ -72,7 +56,6 @@ final class RDEPUBReaderAnnotationCoordinator { addAnnotation(from: selection, style: .highlight, color: color, note: note) } - /// 基于选区创建标注(高亮/划线/批注),支持指定样式、颜色和备注。 @discardableResult func addAnnotation( from selection: RDEPUBSelection? = nil, @@ -115,7 +98,6 @@ final class RDEPUBReaderAnnotationCoordinator { return newHighlight } - /// 插入或更新高亮(upsert),按 ID 匹配已有记录。 @discardableResult func upsertHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight? { guard let controller else { return nil } @@ -132,7 +114,6 @@ final class RDEPUBReaderAnnotationCoordinator { return scopedHighlight } - /// 根据 ID 删除高亮并持久化。 @discardableResult func removeHighlight(id: String) -> RDEPUBHighlight? { guard let controller else { return nil } @@ -144,7 +125,6 @@ final class RDEPUBReaderAnnotationCoordinator { return removed } - /// 更新指定高亮的批注备注内容。 @discardableResult func updateHighlightNote(id: String, note: String?) -> RDEPUBHighlight? { guard let controller else { return nil } @@ -156,7 +136,6 @@ final class RDEPUBReaderAnnotationCoordinator { return controller.activeHighlights[index] } - /// 跳转到指定高亮所在位置。 @discardableResult func go(toHighlightID id: String, animated: Bool = true) -> Bool { guard let highlight = highlight(withID: id) else { @@ -165,7 +144,6 @@ final class RDEPUBReaderAnnotationCoordinator { return navigate(to: highlight, animated: animated) } - /// 清除所有高亮标记。 func removeAllHighlights() { guard let controller else { return } guard !controller.activeHighlights.isEmpty else { return } @@ -173,7 +151,6 @@ final class RDEPUBReaderAnnotationCoordinator { persistHighlightsAndRefreshContent() } - /// 将选区位置相对于指定 spine 索引进行规范化。 func scopedSelection( _ selection: RDEPUBSelection, relativeToSpineIndex spineIndex: Int? @@ -190,7 +167,10 @@ final class RDEPUBReaderAnnotationCoordinator { progression: selection.location.progression, lastProgression: selection.location.lastProgression, fragment: selection.location.fragment, - rangeAnchor: selection.location.rangeAnchor + rangeAnchor: selection.location.rangeAnchor, + cfi: selection.location.cfi, + lastCFI: selection.location.lastCFI, + rangeCFI: selection.location.rangeCFI ) return RDEPUBSelection( bookIdentifier: controller.currentBookIdentifier, @@ -201,7 +181,6 @@ final class RDEPUBReaderAnnotationCoordinator { ) } - /// 弹出高亮管理器,支持查看、编辑备注和删除高亮。 func presentHighlightsManager() { guard let controller else { return } guard controller.configuration.allowsHighlights else { return } @@ -231,7 +210,6 @@ final class RDEPUBReaderAnnotationCoordinator { controller.present(navigationController, animated: true) } - /// 弹出标注创建面板(高亮/划线/批注选择)。 func presentAnnotationCreation() { guard let controller else { return } guard controller.configuration.allowsHighlights, @@ -241,7 +219,6 @@ final class RDEPUBReaderAnnotationCoordinator { 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) @@ -263,7 +240,6 @@ final class RDEPUBReaderAnnotationCoordinator { controller.present(alert, animated: true) } - /// 处理文本选中后的菜单操作:复制、高亮、批注。 func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) { guard let selection else { return } switch action { @@ -277,7 +253,6 @@ final class RDEPUBReaderAnnotationCoordinator { } } - /// 在当前位置添加书签,自动去重。 @discardableResult func addBookmark(note: String? = nil) -> RDEPUBBookmark? { guard let controller else { return nil } @@ -299,7 +274,6 @@ final class RDEPUBReaderAnnotationCoordinator { return newBookmark } - /// 切换当前位置的书签状态:已存在则移除,不存在则添加。 @discardableResult func toggleBookmark(note: String? = nil) -> RDEPUBBookmark? { guard let controller else { return nil } @@ -315,7 +289,6 @@ final class RDEPUBReaderAnnotationCoordinator { return addBookmark(note: note) } - /// 根据 ID 删除书签并持久化。 @discardableResult func removeBookmark(id: String) -> RDEPUBBookmark? { guard let controller else { return nil } @@ -327,7 +300,6 @@ final class RDEPUBReaderAnnotationCoordinator { return removed } - /// 跳转到指定书签所在位置。 @discardableResult func go(toBookmarkID id: String, animated: Bool = true) -> Bool { guard let controller else { return false } @@ -337,7 +309,6 @@ final class RDEPUBReaderAnnotationCoordinator { return controller.restoreReadingLocation(bookmark.location, animated: animated) } - /// 弹出书签管理器,支持查看、跳转和删除书签。 func presentBookmarksManager() { guard let controller else { return } guard !controller.activeBookmarks.isEmpty else { return } @@ -374,7 +345,10 @@ final class RDEPUBReaderAnnotationCoordinator { progression: highlight.location.progression, lastProgression: highlight.location.lastProgression, fragment: highlight.location.fragment, - rangeAnchor: highlight.location.rangeAnchor + rangeAnchor: highlight.location.rangeAnchor, + cfi: highlight.location.cfi, + lastCFI: highlight.location.lastCFI, + rangeCFI: highlight.location.rangeCFI ) return RDEPUBHighlight( id: highlight.id, @@ -407,7 +381,21 @@ final class RDEPUBReaderAnnotationCoordinator { } controller.delegate?.epubReader(controller, didUpdateHighlights: controller.activeHighlights) controller.updateReaderChrome() - controller.refreshVisibleContentPreservingLocation() + 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) { @@ -511,6 +499,11 @@ final class RDEPUBReaderAnnotationCoordinator { 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 @@ -545,7 +538,10 @@ final class RDEPUBReaderAnnotationCoordinator { progression: location.progression, lastProgression: location.lastProgression, fragment: location.fragment, - rangeAnchor: location.rangeAnchor + rangeAnchor: location.rangeAnchor, + cfi: location.cfi, + lastCFI: location.lastCFI, + rangeCFI: location.rangeCFI ) } @@ -559,7 +555,10 @@ final class RDEPUBReaderAnnotationCoordinator { progression: location.progression, lastProgression: location.lastProgression, fragment: location.fragment, - rangeAnchor: location.rangeAnchor + rangeAnchor: location.rangeAnchor, + cfi: location.cfi, + lastCFI: location.lastCFI, + rangeCFI: location.rangeCFI ) } diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderAssemblyCoordinator.swift b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderAssemblyCoordinator.swift index de865a9..f4cf7d4 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderAssemblyCoordinator.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderAssemblyCoordinator.swift @@ -1,11 +1,5 @@ import UIKit -/// EPUB 阅读器界面组装协调器:负责阅读器初次启动时的 UI 搭建。 -/// -/// 职责: -/// - 组装阅读器视图层次结构(readerView、loadingIndicator、errorLabel) -/// - 注册内容视图类型 -/// - 处理外部纯文本图书的启动收尾逻辑 final class RDEPUBReaderAssemblyCoordinator { private unowned let context: RDEPUBReaderContext @@ -13,7 +7,6 @@ final class RDEPUBReaderAssemblyCoordinator { self.context = context } - /// 组装阅读器界面:添加 readerView、loadingIndicator、errorLabel 到控制器视图,并配置顶部工具栏。 func assembleInterface() { guard let controller = context.controller, let readerView = context.readerView else { return } @@ -28,7 +21,6 @@ final class RDEPUBReaderAssemblyCoordinator { #endif } - /// 外部纯文本图书启动时,加载已保存的书签、高亮和阅读位置,完成分页收尾。 func finishExternalTextBookLaunchIfNeeded() { guard let runtime = context.runtime, let controller = context.controller, diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderChromeCoordinator.swift b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderChromeCoordinator.swift index 2990ad2..a5b936f 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderChromeCoordinator.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderChromeCoordinator.swift @@ -1,14 +1,7 @@ import UIKit -/// EPUB 阅读器 Chrome 协调器:负责顶部/底部工具栏的创建、更新和交互处理。 -/// -/// 职责: -/// - 创建并配置顶部工具栏(返回、书签按钮) -/// - 创建并配置底部工具栏(目录、书签、高亮、设置按钮) -/// - 同步工具栏的主题和状态 -/// - 弹出设置面板和目录面板 -/// - 处理返回按钮的关闭逻辑 final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationControllerDelegate { + private unowned let context: RDEPUBReaderContext init(context: RDEPUBReaderContext) { @@ -19,14 +12,11 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr context.controller } - // MARK: - UIAdaptivePresentationControllerDelegate - func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { - // 设置页面被用户下滑关闭时 + context.runtime?.settingsPanelDidDisappear() } - /// 创建顶部工具栏视图,绑定返回、搜索和书签切换回调。 func makeTopToolView() -> RDEPUBReaderTopToolView { let toolView = RDEPUBReaderTopToolView() toolView.onBack = { [weak self] in @@ -41,7 +31,6 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr return toolView } - /// 创建底部工具栏视图,绑定目录、书签、高亮、设置等回调。 func makeBottomToolView() -> RDEPUBReaderBottomToolView { let toolView = RDEPUBReaderBottomToolView() toolView.onShowTableOfContents = { [weak self] in @@ -62,7 +51,6 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr return toolView } - /// 同步更新顶部和底部工具栏的主题、标题、按钮可用性等状态。 func updateReaderChrome() { guard let controller else { return } let uiState = makeUIState() @@ -70,7 +58,6 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr updateSearchBar() } - /// 构建当前 UI 状态快照 func makeUIState() -> RDEPUBReaderUIState { guard let controller else { return .empty } return RDEPUBReaderUIState( @@ -85,7 +72,6 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr ) } - /// 将 UI 状态应用到顶部和底部工具栏 func applyUIState(_ state: RDEPUBReaderUIState) { guard let controller else { return } controller.topToolView.apply(theme: controller.configuration.theme) @@ -107,18 +93,15 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr 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 } - // 通知 Runtime 设置页面即将打开 context.runtime?.settingsPanelWillAppear() let settingsController = RDEPUBReaderSettingsViewController( @@ -147,7 +130,7 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr controller?.updateConfiguration { $0.theme = theme } } settingsController.onDismiss = { [weak self] in - // 通知 Runtime 设置页面已关闭 + self?.context.runtime?.settingsPanelDidDisappear() } @@ -157,7 +140,6 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr controller.present(navigationController, animated: true) } - /// 弹出目录列表面板,支持点击跳转到指定章节。 func presentTableOfContents() { guard let controller else { return } guard controller.configuration.showsTableOfContents else { return } @@ -184,7 +166,6 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr controller.present(navigationController, animated: true) } - /// 切换搜索栏的显示/隐藏状态。 func toggleSearchBar() { guard let controller else { return } if controller.isSearchBarVisible { @@ -194,7 +175,6 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr } } - /// 同步搜索栏的主题和匹配计数。 func updateSearchBar() { guard let controller else { return } controller.searchBarView.apply(theme: controller.configuration.theme) @@ -207,7 +187,6 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr } } - /// 处理返回按钮点击,自动判断 pop 或 dismiss 方式关闭阅读器。 func handleBackAction() { guard let controller else { return } close(controller) diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderContext.swift b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderContext.swift index e290eb7..063ab70 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderContext.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderContext.swift @@ -1,65 +1,51 @@ import UIKit -/// 阅读器共享状态中心:所有 coordinator 通过 context 访问业务状态和便捷方法。 -/// -/// context 持有: -/// - 业务状态(parser、publication、textBook、pages 等) -/// - UI 配置(configuration、brightness) -/// - 持久化策略(persistence) -/// - 便捷方法(renderStyle、layoutConfig 等) -/// - 弱引用 controller(仅用于 UIKit 呈现操作) final class RDEPUBReaderContext { + private let activityLock = NSLock() + private var lastUserNavigationTimestamp: CFAbsoluteTime = 0 - // MARK: - 引用 - - /// 弱引用阅读器控制器,用于 UIKit 呈现操作。 weak var controller: RDEPUBReaderController? - /// 弱引用阅读器视图,用于布局和页面状态查询。 + weak var readerView: RDReaderView? - /// 依赖注入容器,提供解析器、分页器等工厂方法。 + var dependencies: RDEPUBReaderDependencies = .live - /// 便捷访问当前控制器的运行时协调器集合。 + var runtime: RDEPUBReaderRuntime? { controller?.runtime } - // MARK: - 业务状态 - - /// EPUB 解析器实例。 var parser: RDEPUBParser? - /// 解析后的出版物模型。 + var publication: RDEPUBPublication? - /// 当前阅读会话,管理页面和章节状态。 + var readingSession: RDEPUBReadingSession? - /// 原生文本排版生成的图书模型(仅文本重排模式)。 - /// 章节模式下为 nil,内容通过 ChapterRuntimeStore 访问。 + var textBook: RDEPUBTextBook? - /// 全书轻量页码映射(章节模式)。约 100KB/1000章,不持有 NSAttributedString。 + var bookPageMap: RDEPUBBookPageMap? - /// 当前书籍的所有书签。 + var activeBookmarks: [RDEPUBBookmark] = [] - /// 当前书籍的所有高亮标注。 + var activeHighlights: [RDEPUBHighlight] = [] - /// 当前打开书籍的唯一标识。 + var currentBookIdentifier: String? - /// 分页操作令牌,用于取消过期的异步分页任务。 + var paginationToken = UUID() - /// Web 内容分页计算器。 + var paginator: RDEPUBPaginator? - /// 全文搜索状态。 + var searchState: RDEPUBSearchState? - /// 后台解析完成的完整 BookPageMap,等待用户下次导航时应用。 - /// 避免后台解析完成时直接替换 map 导致当前阅读位置跳转。 + var pendingFullPageMap: RDEPUBBookPageMap? - /// 上次文本分页时的页面尺寸,用于检测是否需要重新分页。 + var lastTextPaginationPageSize: CGSize? - /// 后台元数据解析耗时(毫秒),仅包含 OperationQueue 并行阶段。 + var lastMetadataParseWallClockMs: Int = 0 - /// 后台元数据解析使用的并发数。 + var lastMetadataParseConcurrency: Int = 0 - /// 当前用户文本选区(对外只读语义,底层由 selectionState 推导)。 + var currentSelection: RDEPUBSelection? { get { selectionState.selection } set { @@ -70,38 +56,30 @@ final class RDEPUBReaderContext { } } } - /// 统一选区状态模型,收口所有选区相关状态变更。 + var selectionState: RDEPUBSelectionState = .idle - // MARK: - 控制器状态(从 controller 下沉) - - /// 阅读器配置(字号、字体、主题等)。 var configuration: RDEPUBReaderConfiguration = .default - /// 持久化策略,负责书签、高亮、阅读位置的存取。 - var persistence: RDEPUBReaderPersistence? - /// 当前打开的 EPUB 文件 URL。 - var epubURL: URL = URL(string: "about:blank")! - /// 是否正在重新分页。 - var isRepaginating: Bool = false - /// 是否已完成首次加载。 - var didStartInitialLoad: Bool = false - /// 是否为外部传入的纯文本图书。 - var isExternalTextBook: Bool = false - /// 外部纯文本文件的 URL。 - var textFileURL: URL? - /// 文本图书缓存,避免重复排版。 - var textBookCache = RDEPUBTextBookCache() - // MARK: - 初始化 + var persistence: RDEPUBReaderPersistence? + + var epubURL: URL = URL(string: "about:blank")! + + var isRepaginating: Bool = false + + var didStartInitialLoad: Bool = false + + var isExternalTextBook: Bool = false + + var textFileURL: URL? + + var textBookCache = RDEPUBTextBookCache() init(controller: RDEPUBReaderController) { self.controller = controller self.readerView = controller.readerView } - // MARK: - 便捷方法 - - /// 根据当前 readerView 和控制器尺寸构建布局上下文。 func currentLayoutContext() -> RDEPUBNavigatorLayoutContext { let containerSize = readerView?.bounds.size ?? .zero let viewSize = controller?.view.bounds.size ?? containerSize @@ -115,12 +93,10 @@ final class RDEPUBReaderContext { ) } - /// 根据当前配置生成阅读偏好设置。 func currentPreferences() -> RDEPUBPreferences { configuration.makePreferences() } - /// 获取当前文本排版的单页尺寸,优先从 readerView 解析,兜底用布局上下文。 func currentTextPageSize() -> CGSize { if Thread.isMainThread { let pageNum = (readerView?.currentPage ?? -1) >= 0 ? readerView?.currentPage : nil @@ -149,7 +125,6 @@ final class RDEPUBReaderContext { return dependencies.environment.fallbackViewportSize } - /// 根据当前配置生成文本渲染样式(字体、行距、颜色)。 func currentTextRenderStyle() -> RDEPUBTextRenderStyle { let font = configuration.fontChoice.font(ofSize: configuration.fontSize) let lineSpacing = max(font.lineHeight * (configuration.lineHeightMultiple - 1), 4) @@ -161,7 +136,6 @@ final class RDEPUBReaderContext { ) } - /// 根据页面尺寸和配置生成文本排版参数。 func currentTextLayoutConfig(pageSize: CGSize) -> RDEPUBTextLayoutConfig { return RDEPUBTextLayoutConfig( frameWidth: max(pageSize.width, 1), @@ -169,7 +143,7 @@ final class RDEPUBReaderContext { edgeInsets: configuration.reflowableContentInsets, numberOfColumns: configuration.numberOfColumns, columnGap: configuration.columnGap, - // 小说正文更看重尽量铺满页面,避免页尾出现明显留白。 + avoidOrphans: false, avoidWidows: false, avoidPageBreakInsideEnabled: true, @@ -179,48 +153,39 @@ final class RDEPUBReaderContext { ) } - /// 获取当前配置对应的文本渲染器实例。 func resolvedTextRenderer() -> RDEPUBTextRenderer { dependencies.makeTextRenderer(configuration.textRenderingEngine) } - /// 当前活跃的页面列表。 var activePages: [EPUBPage] { readingSession?.activePages ?? [] } - /// 当前活跃的章节信息列表。 var activeChapters: [EPUBChapterInfo] { readingSession?.activeChapters ?? [] } - /// 屏幕亮度代理属性,读写均转发给系统环境。 var currentBrightness: CGFloat { get { dependencies.environment.currentBrightness } set { dependencies.environment.currentBrightness = newValue } } - /// 用新快照替换当前活跃的分页快照。 func replaceActiveSnapshot(_ snapshot: RDEPUBReadingSession.PaginationSnapshot) { readingSession?.setActiveSnapshot(snapshot) } - /// 清除当前活跃快照,重置运行时状态。 func clearActiveSnapshot() { readingSession?.resetRuntimeState() } - /// 工厂方法:创建 EPUB 解析器。 func makeParser() -> RDEPUBParser { dependencies.makeParser() } - /// 工厂方法:创建 Web 内容分页计算器。 func makePaginator() -> RDEPUBPaginator { dependencies.makePaginator() } - /// 工厂方法:创建 EPUB 文本图书构建器。 func makeTextBookBuilder(layoutConfig: RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder { dependencies.makeTextBookBuilder(resolvedTextRenderer(), textBookCache, layoutConfig) } @@ -252,8 +217,6 @@ final class RDEPUBReaderContext { ) } - /// 使用预计算的 contentHash 构建缓存键,避免重复读取 HTML 和计算 SHA-256。 - /// 后台批量解析必须走此版本。 func chapterCacheKey(forSpineIndex spineIndex: Int, precomputedContentHash: String) -> RDEPUBChapterCacheKey { chapterCacheKey( forSpineIndex: spineIndex, @@ -262,8 +225,6 @@ final class RDEPUBReaderContext { ) } - /// 使用固定的渲染签名与预计算 contentHash 构建缓存键。 - /// 适合后台任务在启动时冻结分页参数后复用,避免 live context 漂移。 func chapterCacheKey( forSpineIndex spineIndex: Int, precomputedContentHash: String, @@ -277,7 +238,6 @@ final class RDEPUBReaderContext { ) } - /// 当前渲染参数签名,所有章节共享同一值。 func currentRenderSignature() -> String { let style = currentTextRenderStyle() let pageSize = currentTextPageSize() @@ -311,36 +271,30 @@ final class RDEPUBReaderContext { } } - /// 工厂方法:创建纯文本图书构建器。 func makePlainTextBookBuilder(layoutConfig: RDEPUBTextLayoutConfig) -> RDPlainTextBookBuilder { dependencies.makePlainTextBookBuilder(resolvedTextRenderer(), 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 @@ -349,7 +303,6 @@ final class RDEPUBReaderContext { return CFAbsoluteTimeGetCurrent() - timestamp } - /// 根据规范化 href 获取文本章节数据。 func textChapterData(forNormalizedHref href: String) -> RDEPUBChapterData? { guard let textBook, let publication else { return nil } let normalizedHref = publication.resourceResolver.normalizedHref(href) ?? href @@ -358,42 +311,34 @@ final class RDEPUBReaderContext { .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/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderDependencies.swift b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderDependencies.swift index cc486d4..386a9d1 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderDependencies.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderDependencies.swift @@ -1,17 +1,13 @@ -// RDEPUBReaderDependencies.swift -// EPUB 阅读器依赖注入配置,定义显示环境协议与核心组件工厂 import UIKit -/// 阅读器显示环境协议,抽象屏幕亮度和视口尺寸以支持测试和多平台适配 public protocol RDEPUBReaderDisplayEnvironment: AnyObject { - /// 当前屏幕亮度,取值范围 0.0 ~ 1.0 + var currentBrightness: CGFloat { get set } - /// 后备视口尺寸,用于无法获取实际视图尺寸时的布局计算 + var fallbackViewportSize: CGSize { get } } -/// 基于 UIScreen 的默认显示环境实现,直接读写系统屏幕亮度 public final class RDEPUBUIScreenEnvironment: RDEPUBReaderDisplayEnvironment { public init() {} @@ -25,29 +21,20 @@ public final class RDEPUBUIScreenEnvironment: RDEPUBReaderDisplayEnvironment { } } -/// EPUB 阅读器核心依赖容器,通过工厂闭包注入各组件以便替换和测试 public struct RDEPUBReaderDependencies { - /// 显示环境实例 + public var environment: any RDEPUBReaderDisplayEnvironment - /// EPUB 解析器工厂 + public var makeParser: () -> RDEPUBParser - /// 分页器工厂 + public var makePaginator: () -> RDEPUBPaginator - /// 富文本书籍构建器工厂 + public var makeTextBookBuilder: (RDEPUBTextRenderer, RDEPUBTextBookCache?, RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder - /// 纯文本书籍构建器工厂 + public var makePlainTextBookBuilder: (RDEPUBTextRenderer, RDEPUBTextLayoutConfig) -> RDPlainTextBookBuilder - /// 文本渲染器工厂 + public var makeTextRenderer: (RDEPUBTextRenderingEngine) -> RDEPUBTextRenderer - /// 初始化依赖容器 - /// - Parameters: - /// - environment: 显示环境实例 - /// - makeParser: EPUB 解析器工厂闭包 - /// - makePaginator: 分页器工厂闭包 - /// - makeTextBookBuilder: 富文本书籍构建器工厂闭包 - /// - makePlainTextBookBuilder: 纯文本书籍构建器工厂闭包 - /// - makeTextRenderer: 文本渲染器工厂闭包 public init( environment: any RDEPUBReaderDisplayEnvironment, makeParser: @escaping () -> RDEPUBParser, @@ -64,7 +51,6 @@ public struct RDEPUBReaderDependencies { self.makeTextRenderer = makeTextRenderer } - /// 默认生产环境依赖,使用系统屏幕环境和标准组件实现 public static var live: RDEPUBReaderDependencies { RDEPUBReaderDependencies( environment: RDEPUBUIScreenEnvironment(), diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderLoadCoordinator.swift b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderLoadCoordinator.swift index 33244cd..c29bf15 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderLoadCoordinator.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderLoadCoordinator.swift @@ -1,11 +1,5 @@ import Foundation -/// EPUB 阅读器加载协调器:负责 EPUB 文件的解析和出版物初始化。 -/// -/// 职责: -/// - 判断是否需要执行首次加载 -/// - 后台解析 EPUB 文件并构建 Publication 模型 -/// - 将解析结果应用到阅读器上下文并触发分页 final class RDEPUBReaderLoadCoordinator { private unowned let context: RDEPUBReaderContext @@ -13,7 +7,6 @@ final class RDEPUBReaderLoadCoordinator { self.context = context } - /// 检查条件后启动首次加载,确保只执行一次且视图已布局。 func startInitialLoadIfNeeded() { guard let controller = context.controller, let readerView = context.readerView, @@ -26,7 +19,6 @@ final class RDEPUBReaderLoadCoordinator { loadPublication() } - /// 后台解析 EPUB 文件,加载书签、高亮和阅读位置,完成后回调主线程。 func loadPublication() { guard let controller = context.controller else { return } context.showLoading() @@ -65,7 +57,6 @@ final class RDEPUBReaderLoadCoordinator { } } - /// 将解析完成的出版物应用到上下文,设置书签/高亮/会话,并触发分页。 func applyParsedPublication( parser: RDEPUBParser, publication: RDEPUBPublication, diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderLocationCoordinator.swift b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderLocationCoordinator.swift index 1fc95d6..6c8d12f 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderLocationCoordinator.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderLocationCoordinator.swift @@ -1,23 +1,14 @@ import Foundation -/// EPUB 阅读器位置协调器:负责阅读位置的恢复、查询和持久化。 -/// -/// 职责: -/// - 根据保存的位置恢复阅读进度 -/// - 获取当前可见页面的阅读位置 -/// - 从持久化存储加载已保存位置 -/// - 持久化当前位置并通知委托 final class RDEPUBReaderLocationCoordinator { private unowned let context: RDEPUBReaderContext - /// 上次翻页时的 spineIndex,用于检测跨章翻页 private var lastPageChangeSpineIndex: Int? init(context: RDEPUBReaderContext) { self.context = context } - /// 恢复到指定阅读位置,返回是否成功跳转。 @discardableResult func restoreReadingLocation( _ location: RDEPUBLocation, @@ -57,13 +48,11 @@ final class RDEPUBReaderLocationCoordinator { } readerView.transitionToPage(pageNum: max(targetPageNumber - 1, 0), animated: animated) - // 记录翻页到 JumpSession recordPageChangeIfNeeded() return true } - /// 获取当前可见页面对应的阅读位置。 func currentVisibleLocation() -> RDEPUBLocation? { guard let controller = context.controller, let readerView = context.readerView else { @@ -74,8 +63,7 @@ final class RDEPUBReaderLocationCoordinator { if let location = controller.resolvedTextLocation(forPageNumber: pageNumber) { return location } - // resolvedTextLocation 可能因章节数据未加载而返回 nil, - // 通过 readingSession 的 activePages 构建回退位置 + if let readingSession = context.readingSession, readingSession.activePages.indices.contains(readerView.currentPage) { return readingSession.fallbackLocation( @@ -87,7 +75,6 @@ final class RDEPUBReaderLocationCoordinator { return context.readingSession?.currentReadingLocation(bookIdentifier: context.currentBookIdentifier) } - /// 从持久化存储加载上次保存的阅读位置。 func persistenceLocation() -> RDEPUBLocation? { guard let controller = context.controller, let currentBookIdentifier = context.currentBookIdentifier else { @@ -96,7 +83,6 @@ final class RDEPUBReaderLocationCoordinator { return controller.persistence?.loadLocation(for: currentBookIdentifier) } - /// 持久化阅读位置,并通知委托更新目录项和书签状态。 func persist(location: RDEPUBLocation) { guard let controller = context.controller, let currentBookIdentifier = context.currentBookIdentifier else { return } @@ -106,7 +92,6 @@ final class RDEPUBReaderLocationCoordinator { controller.updateReaderChrome() } - /// 记录翻页到 JumpSession func recordPageChangeIfNeeded() { guard let runtime = context.runtime, let bookPageMap = context.bookPageMap, @@ -127,7 +112,6 @@ final class RDEPUBReaderLocationCoordinator { lastPageChangeSpineIndex = currentSpineIndex - // 检查是否应该结束 JumpSession let isIdle = context.secondsSinceLastUserNavigation() > 2.0 if let endReason = runtime.jumpSessionManager.checkSessionEnd( currentSpineIndex: currentSpineIndex, @@ -137,7 +121,6 @@ final class RDEPUBReaderLocationCoordinator { } } - /// 重置翻页状态(用于重新加载等场景) func resetPageChangeState() { lastPageChangeSpineIndex = nil } diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift index 495a9ed..f495523 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift @@ -1,17 +1,13 @@ import Foundation -/// EPUB 阅读器分页协调器:负责出版物的分页计算和页面数据更新。 -/// -/// 职责: -/// - 根据出版物类型(文本重排/Fixed Layout/Web 内容)选择分页策略 -/// - 文本大书优先恢复分页摘要并切换到按需加载 -/// - 重新分页时保持当前阅读位置 -/// - 刷新可见内容并保持位置 -/// - 重建外部纯文本图书 final class RDEPUBReaderPaginationCoordinator { + private final class MetadataParseState { + var summariesBySpineIndex: [Int: RDEPUBChapterSummary] + var totalResolvedCount: Int + var lastAppliedCount: Int init( @@ -26,10 +22,13 @@ final class RDEPUBReaderPaginationCoordinator { } private final class MetadataParseCancellationController { + let token: UUID private let lock = NSLock() + private weak var queue: OperationQueue? + private var cancelled = false init(token: UUID) { @@ -66,18 +65,19 @@ final class RDEPUBReaderPaginationCoordinator { } private let backgroundInteractionCooldown: CFAbsoluteTime = 0.8 - /// 每 N 章刷新一次 pageMap,可通过修改此值实测调优。 + static var pageMapRefreshInterval: Int = 32 private unowned let context: RDEPUBReaderContext + private let metadataParseControlLock = NSLock() + private var activeMetadataParseCancellationController: MetadataParseCancellationController? init(context: RDEPUBReaderContext) { self.context = context } - /// 对出版物执行分页:文本重排优先走摘要恢复/按需加载,Fixed Layout 直接生成快照,Web 内容走 Paginator。 func paginatePublication(restoreLocation: RDEPUBLocation?) { guard let controller = context.controller, let parser = context.parser, @@ -143,7 +143,6 @@ final class RDEPUBReaderPaginationCoordinator { } } - /// 应用文本图书模型:生成分页快照并完成分页流程。 func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) { guard let controller = context.controller else { return } context.textBook = textBook @@ -160,7 +159,6 @@ final class RDEPUBReaderPaginationCoordinator { finishPagination(restoreLocation: restoreLocation) } - /// 应用分页快照(Fixed Layout 或 Web 内容),并完成分页流程。 func applyPaginationSnapshot( _ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]), restoreLocation: RDEPUBLocation? @@ -179,7 +177,6 @@ final class RDEPUBReaderPaginationCoordinator { finishPagination(restoreLocation: restoreLocation) } - /// 分页完成后的收尾:刷新视图、恢复阅读位置、处理待定视口变更。 func finishPagination(restoreLocation: RDEPUBLocation?) { guard let controller = context.controller, let readerView = context.readerView else { return } @@ -197,7 +194,6 @@ final class RDEPUBReaderPaginationCoordinator { context.runtime?.viewportMonitor.processPendingChangeAfterPagination() } - /// 重新分页并保持当前阅读位置(优先使用待恢复位置,其次当前位置,最后持久化位置)。 func repaginatePreservingCurrentLocation() { guard context.publication != nil else { return } let restoreLocation = context.runtime?.viewportMonitor.consumePendingPresentationRestoreLocation() @@ -206,7 +202,6 @@ final class RDEPUBReaderPaginationCoordinator { paginatePublication(restoreLocation: restoreLocation) } - /// 刷新可见内容并保持当前阅读位置不变。 func refreshVisibleContentPreservingLocation() { guard let readerView = context.readerView else { return } let restoreLocation = context.currentVisibleLocation() ?? context.persistenceLocation() @@ -216,7 +211,6 @@ final class RDEPUBReaderPaginationCoordinator { } } - /// 重建外部纯文本图书(布局变更后重新排版)。 func rebuildExternalTextBook() { guard let controller = context.controller, let textFileURL = controller.textFileURL else { return } @@ -245,15 +239,6 @@ final class RDEPUBReaderPaginationCoordinator { DispatchQueue.global(qos: .utility).async { [weak controller] in guard controller != nil else { return } guard context.controller != nil else { return } - if let restoredPageMap = self.restoreBookPageMapIfPossible(publication: publication) { - DispatchQueue.main.async { - guard context.paginationToken == token, - context.controller != nil else { return } - context.runtime?.applyBookPageMap(restoredPageMap, restoreLocation: restoreLocation) - } - return - } - let prioritizedCandidates = self.prioritizedBuildableSpineIndices( publication: publication, readingSession: readingSession, @@ -284,19 +269,9 @@ final class RDEPUBReaderPaginationCoordinator { runtime: runtime ) } - let quickWindowChapters = try RDEPUBBackgroundTrace.measure( - "QuickOpen", - "loadInitialRuntimeChapters anchorSpine=\(runtimeChapter.spineIndex)" - ) { - try self.loadInitialRuntimeChapters( - anchorSpineIndex: runtimeChapter.spineIndex, - publication: publication, - runtime: runtime - ) - } RDEPUBBackgroundTrace.log( "QuickOpen", - "ready anchorSpine=\(runtimeChapter.spineIndex) quickWindow=\(quickWindowChapters.map { $0.spineIndex }) pages=\(quickWindowChapters.reduce(0) { $0 + $1.pages.count })" + "ready anchorSpine=\(runtimeChapter.spineIndex) pages=\(runtimeChapter.pages.count)" ) DispatchQueue.main.async { @@ -307,8 +282,12 @@ final class RDEPUBReaderPaginationCoordinator { totalSpineCount: publication.spine.count, windowRadius: context.configuration.chapterWindowRadius ) - let partialMap = self.makePartialPageMap(from: quickWindowChapters) + let partialMap = self.makePartialPageMap(from: [runtimeChapter]) runtime.applyBookPageMap(partialMap, restoreLocation: restoreLocation) + runtime.prefetchForwardChaptersAfterInitialOpen( + anchorSpineIndex: runtimeChapter.spineIndex, + totalSpineCount: publication.spine.count + ) self.paginateMetadataOnly(token: token, restoreLocation: restoreLocation) } } catch { @@ -340,68 +319,6 @@ final class RDEPUBReaderPaginationCoordinator { throw lastError ?? RDEPUBParserError.emptySpine } - private func loadInitialRuntimeChapters( - anchorSpineIndex: Int, - publication: RDEPUBPublication, - runtime: RDEPUBReaderRuntime - ) throws -> [RDEPUBRuntimeChapter] { - let windowSpineIndices = initialWindowSpineIndices( - around: anchorSpineIndex, - in: publication, - maxChapterCount: context.configuration.onDemandChapterWindowSize - ) - var chapters: [RDEPUBRuntimeChapter] = [] - for spineIndex in windowSpineIndices { - do { - let chapter = try runtime.chapterLoader.loadChapterSynchronouslyForMigration( - spineIndex: spineIndex, - store: runtime.chapterRuntimeStore - ) - chapters.append(chapter) - } catch { - if spineIndex == anchorSpineIndex { - throw error - } - RDEPUBBackgroundTrace.log("QuickOpen", "skip adjacent spine=\(spineIndex) reason=\(error)") - } - } - return chapters - } - - private func initialWindowSpineIndices( - around anchorSpineIndex: Int, - in publication: RDEPUBPublication, - maxChapterCount: Int = 3 - ) -> [Int] { - let normalizedMaxChapterCount = RDEPUBReaderConfiguration.normalizedChapterWindowSize(maxChapterCount) - let buildableIndices = allBuildableSpineIndices(in: publication) - guard let anchorPosition = buildableIndices.firstIndex(of: anchorSpineIndex) else { - return [anchorSpineIndex] - } - - var selected = [anchorSpineIndex] - var nextPosition = anchorPosition + 1 - var previousPosition = anchorPosition - 1 - - while selected.count < normalizedMaxChapterCount, - nextPosition < buildableIndices.count || previousPosition >= 0 { - if nextPosition < buildableIndices.count { - selected.append(buildableIndices[nextPosition]) - nextPosition += 1 - if selected.count == normalizedMaxChapterCount { - break - } - } - - if previousPosition >= 0 { - selected.insert(buildableIndices[previousPosition], at: 0) - previousPosition -= 1 - } - } - - return selected - } - private func makePartialPageMap(from chapters: [RDEPUBRuntimeChapter]) -> RDEPUBBookPageMap { var builder = RDEPUBBookPageMap.Builder() for chapter in chapters { @@ -450,14 +367,9 @@ final class RDEPUBReaderPaginationCoordinator { return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml")) } - // MARK: - 元数据专用解析(Phase 0) - - /// 失败重试配置 private static let maxRetryCount = 3 private static let retryDelays: [TimeInterval] = [0.5, 2.0, 8.0] - /// 后台遍历所有章节,只提取轻量元数据(pageCount、pageRanges、fragmentOffsets), - /// 写入磁盘摘要缓存,不累积 RDEPUBTextBook。 func paginateMetadataOnly(token: UUID, restoreLocation: RDEPUBLocation?) { let context = self.context guard let parser = context.parser, @@ -481,7 +393,20 @@ final class RDEPUBReaderPaginationCoordinator { !cancellationController.isCancelled, context.paginationToken == token else { return } - // 预计算所有章节的 contentHash,避免后续重复读盘 + SHA-256 + if let restoredPageMap = self.restoreBookPageMapIfPossible(publication: publication) { + RDEPUBBackgroundTrace.log( + "MetadataParse", + "full cache restore hit chapters=\(restoredPageMap.totalChapters) pages=\(restoredPageMap.totalPages)" + ) + DispatchQueue.main.async { + guard context.paginationToken == token, + context.controller != nil, + !cancellationController.isCancelled else { return } + context.runtime?.refreshBookPageMapInPlace(restoredPageMap) + } + return + } + let prewarmStart = CFAbsoluteTimeGetCurrent() var contentHashBySpineIndex: [Int: String] = [:] for spineIndex in allBuildableIndices { @@ -542,7 +467,6 @@ final class RDEPUBReaderPaginationCoordinator { } } - // 使用优先级排序获取未缓存的 spineIndex let prioritizedSpineIndices: [Int] if let priorityManager = context.runtime?.backgroundPriorityManager { let currentSpineIndex = context.runtime?.locationCoordinator.currentVisibleLocation() @@ -639,6 +563,7 @@ final class RDEPUBReaderPaginationCoordinator { 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, @@ -676,7 +601,6 @@ final class RDEPUBReaderPaginationCoordinator { return } - // 锁内只做写入和计数,快照数据后锁外构建 pageMap var snapshot: [Int: RDEPUBChapterSummary]? resultLock.lock() parseState.summariesBySpineIndex[spineIndex] = renderResult @@ -714,7 +638,6 @@ final class RDEPUBReaderPaginationCoordinator { timingLock.unlock() RDEPUBBackgroundTrace.log("MetadataParse", "buildChapter FAILED: spine=\(spineIndex) error=\(error)") - // 添加到重试队列(带退避延迟) self.scheduleRetry( spineIndex: spineIndex, retryCount: 0, @@ -779,7 +702,6 @@ final class RDEPUBReaderPaginationCoordinator { "complete chapters=\(pageMap.totalChapters) pages=\(pageMap.totalPages) finalMergeMs=\(finalMergeMs)" ) - // 存储到 BackgroundCoverageStore if let coverageStore = context.runtime?.backgroundCoverageStore { let resolvedSpineIndices = Set(parseState.summariesBySpineIndex.keys) let lowerSpine = resolvedSpineIndices.min() ?? 0 @@ -806,7 +728,6 @@ final class RDEPUBReaderPaginationCoordinator { } } - /// 调度失败章节的重试 private func scheduleRetry( spineIndex: Int, retryCount: Int, @@ -878,6 +799,7 @@ final class RDEPUBReaderPaginationCoordinator { 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, @@ -921,7 +843,7 @@ final class RDEPUBReaderPaginationCoordinator { "MetadataParse", "retry failed for spine=\(spineIndex) attempt=\(retryCount + 1) error=\(error)" ) - // 继续重试 + self.scheduleRetry( spineIndex: spineIndex, retryCount: retryCount + 1, diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderRuntime.swift b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderRuntime.swift index 00d5fbf..367fc11 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderRuntime.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderRuntime.swift @@ -1,65 +1,78 @@ import UIKit -/// EPUB 阅读器运行时总协调器。 -/// 统一持有并分发给加载、分页、定位、搜索、工具栏、批注、视口监测等子协调器, -/// 作为阅读器控制器的门面(Facade),简化外部调用。 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) 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) - /// 设置页面是否打开 var isSettingsPanelOpen: Bool = false - /// 标记需要在设置页面关闭后触发完整补全 var needsFullRepaginationAfterSettingsClose: Bool = false - /// 设置页内当前章预览的代次,用于丢弃过期结果 private var settingsPreviewGeneration: Int = 0 - /// 设置页内 preview 防抖任务,只保留最后一次请求。 + private var pendingSettingsPreviewWorkItem: DispatchWorkItem? - /// 设置页内 preview 防抖延迟,合并连续点击。 + 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() -> RDEPUBReaderTopToolView { chromeCoordinator.makeTopToolView() } - /// 创建底部工具栏视图 func makeBottomToolView() -> RDEPUBReaderBottomToolView { chromeCoordinator.makeBottomToolView() } - /// 若尚未加载则启动首次加载流程 func startInitialLoadIfNeeded() { loadCoordinator.startInitialLoadIfNeeded() } - /// 重新加载当前书籍,清空解析器、分页、批注等状态后从头初始化 func reloadBook() { guard let readerView = context.readerView else { return } context.didStartInitialLoad = false @@ -80,20 +93,10 @@ final class RDEPUBReaderRuntime { startInitialLoadIfNeeded() } - /// 跳转到指定阅读位置 - /// - Parameters: - /// - location: 目标位置 - /// - animated: 是否动画过渡 - /// - Returns: 跳转是否成功 func go(to location: RDEPUBLocation, animated: Bool = false) -> Bool { locationCoordinator.restoreReadingLocation(location, animated: animated) } - /// 跳转到指定页码 - /// - Parameters: - /// - pageNumber: 目标页码(从 1 开始) - /// - animated: 是否动画过渡 - /// - Returns: 跳转是否成功 @discardableResult func go(toPageNumber pageNumber: Int, animated: Bool = false) -> Bool { guard let controller = context.controller, @@ -134,7 +137,6 @@ final class RDEPUBReaderRuntime { return true } - /// 清除当前选区 func clearSelection() { annotationCoordinator.updateCurrentSelection(nil) } @@ -230,30 +232,25 @@ final class RDEPUBReaderRuntime { 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() } @@ -262,32 +259,26 @@ final class RDEPUBReaderRuntime { searchCoordinator.searchPresentation(for: page) } - /// 更新阅读器工具栏显示状态 func updateReaderChrome() { chromeCoordinator.updateReaderChrome() } - /// 弹出阅读设置面板 func presentSettings() { chromeCoordinator.presentSettings() } - /// 弹出目录面板 func presentTableOfContents() { chromeCoordinator.presentTableOfContents() } - /// 处理返回操作 func handleBackAction() { chromeCoordinator.handleBackAction() } - /// 启动 Publication 加载流程 func loadPublication() { loadCoordinator.loadPublication() } - /// 将已解析的 Publication 应用到控制器 func applyParsedPublication( parser: RDEPUBParser, publication: RDEPUBPublication, @@ -306,17 +297,14 @@ final class RDEPUBReaderRuntime { ) } - /// 对 Publication 执行分页计算 func paginatePublication(restoreLocation: RDEPUBLocation?) { paginationCoordinator.paginatePublication(restoreLocation: restoreLocation) } - /// 应用外部 TextBook 并恢复阅读位置 func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) { paginationCoordinator.applyTextBook(textBook, restoreLocation: restoreLocation) } - /// 应用分页快照并恢复阅读位置 func applyPaginationSnapshot( _ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]), restoreLocation: RDEPUBLocation? @@ -332,21 +320,26 @@ final class RDEPUBReaderRuntime { } func refreshBookPageMapInPlace(_ bookPageMap: RDEPUBBookPageMap) { - // 暂存完整 map,等用户下次导航时再应用,避免当前阅读位置跳转 - // 此时保留旧 map,用户看到的内容和页码完全不变 + if let pendingMap = context.pendingFullPageMap { + let shouldKeepExisting = + pendingMap.totalChapters > bookPageMap.totalChapters || + (pendingMap.totalChapters == bookPageMap.totalChapters && + pendingMap.totalPages >= bookPageMap.totalPages) + if shouldKeepExisting { + return + } + } + context.pendingFullPageMap = bookPageMap } - /// 用户导航时检查并应用待处理的完整 BookPageMap func applyPendingFullPageMapIfNeeded() { guard let pendingMap = context.pendingFullPageMap, let readerView = context.readerView, let controller = context.controller else { return } - // 如果正在重新分页,跳过本次检查,避免状态冲突 guard !controller.isRepaginating else { return } - // 使用协调器判断是否允许接管 let decision = reconciliationCoordinator.evaluateTakeover( candidatePageMap: pendingMap, candidateSegment: nil, @@ -364,13 +357,12 @@ final class RDEPUBReaderRuntime { applyFullPageMapReplacement(newPageMap, readerView: readerView, controller: controller) case .expandWindow, .segmentReplace: - // 这些情况在当前实现中不会发生,因为我们传入的是 candidatePageMap + RDEPUBBackgroundTrace.log("Reconciliation", "decision: unexpected segment decision") break } } - /// 应用全量页图替换 private func applyFullPageMapReplacement( _ newPageMap: RDEPUBBookPageMap, readerView: RDReaderView, @@ -380,12 +372,10 @@ final class RDEPUBReaderRuntime { context.pendingFullPageMap = nil - // 替换 map 和快照 context.textBook = nil context.bookPageMap = newPageMap context.replaceActiveSnapshot(makeSnapshot(from: newPageMap)) - // 用位置在新 map 中重新解析正确的页码 if let currentLocation { let newPageNumber = controller.pageNumber(for: currentLocation) ?? (readerView.currentPage + 1) let newPage = max(0, newPageNumber - 1) @@ -397,7 +387,6 @@ final class RDEPUBReaderRuntime { readerView.reloadPageCountOnly() } - // 检查是否应该结束 JumpSession(coverage-complete) if let currentLocation, let currentSpineIndex = context.normalizedSpineIndex(for: currentLocation), let activeSession = jumpSessionManager.activeSession { @@ -410,14 +399,12 @@ final class RDEPUBReaderRuntime { } } - /// 完成分页流程并恢复阅读位置 func finishPagination(restoreLocation: RDEPUBLocation?) { paginationCoordinator.finishPagination(restoreLocation: restoreLocation) } - /// 重新分页并保持当前阅读位置不变 func repaginatePreservingCurrentLocation() { - // 如果设置页面打开,只计算当前章节(热区) + if isSettingsPanelOpen { needsFullRepaginationAfterSettingsClose = true paginationCoordinator.cancelActiveMetadataParseWork() @@ -427,7 +414,6 @@ final class RDEPUBReaderRuntime { } } - /// 防抖调度设置页内的当前章 preview,只保留最后一次请求。 private func scheduleSettingsPreviewRepagination() { pendingSettingsPreviewWorkItem?.cancel() settingsPreviewGeneration += 1 @@ -440,8 +426,12 @@ final class RDEPUBReaderRuntime { return } self.pendingSettingsPreviewWorkItem = nil + let previewAnchor = self.captureSettingsPreviewAnchor() self.chapterRuntimeStore.invalidateAllForSettingsChange() - self.repaginateCurrentChapterOnly(previewGeneration: previewGeneration) + self.repaginateCurrentChapterOnly( + previewGeneration: previewGeneration, + previewAnchor: previewAnchor + ) } pendingSettingsPreviewWorkItem = workItem DispatchQueue.main.asyncAfter( @@ -450,8 +440,34 @@ final class RDEPUBReaderRuntime { ) } - /// 只重新计算当前章节(设置页面打开时使用) - private func repaginateCurrentChapterOnly(previewGeneration: Int) { + 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 } @@ -485,11 +501,18 @@ final class RDEPUBReaderRuntime { self.context.replaceActiveSnapshot(self.makeSnapshot(from: 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): @@ -501,7 +524,37 @@ final class RDEPUBReaderRuntime { } } - /// 设置页面即将打开 + 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 @@ -510,13 +563,12 @@ final class RDEPUBReaderRuntime { settingsPreviewGeneration += 1 } - /// 设置页面已关闭 func settingsPanelDidDisappear() { pendingSettingsPreviewWorkItem?.cancel() pendingSettingsPreviewWorkItem = nil isSettingsPanelOpen = false settingsPreviewGeneration += 1 - // 如果在设置页面期间有配置变化,触发完整补全 + if needsFullRepaginationAfterSettingsClose { needsFullRepaginationAfterSettingsClose = false RDEPUBBackgroundTrace.log("Runtime", "settingsPanelDidDisappear: triggering full repagination") @@ -524,17 +576,14 @@ final class RDEPUBReaderRuntime { } } - /// 刷新当前可见内容,保持阅读位置不变 func refreshVisibleContentPreservingLocation() { paginationCoordinator.refreshVisibleContentPreservingLocation() } - /// 重建外部 TextBook 数据 func rebuildExternalTextBook() { paginationCoordinator.rebuildExternalTextBook() } - /// 恢复到指定阅读位置 @discardableResult func restoreReadingLocation( _ location: RDEPUBLocation, @@ -548,17 +597,14 @@ final class RDEPUBReaderRuntime { ) } - /// 获取当前可见页面的阅读位置 func currentVisibleLocation() -> RDEPUBLocation? { locationCoordinator.currentVisibleLocation() } - /// 获取当前视口签名快照 func currentViewportSignature() -> RDEPUBViewportSignature? { viewportMonitor.currentViewportSignature() } - /// 视口变化时检查是否需要重新分页 func handleViewportChangeIfNeeded( reason: RDEPUBViewportChangeReason, viewportSignature: RDEPUBViewportSignature? = nil @@ -574,7 +620,6 @@ final class RDEPUBReaderRuntime { return false } - // 检查是否是远距跳转 let currentSpineIndex = locationCoordinator.currentVisibleLocation() .flatMap { context.normalizedSpineIndex(for: $0) } let isDistantJump = if let current = currentSpineIndex { @@ -584,7 +629,7 @@ final class RDEPUBReaderRuntime { } if context.bookPageMap?.entry(forSpineIndex: targetSpineIndex) != nil { - // 如果是远距跳转且目标已在当前窗口中,创建 JumpSession + if isDistantJump { jumpSessionManager.createSession( anchorSpineIndex: targetSpineIndex, @@ -641,14 +686,13 @@ final class RDEPUBReaderRuntime { context.replaceActiveSnapshot(makeSnapshot(from: partialMap)) context.readerView?.reloadData() - // 远距跳转成功后创建 JumpSession if isDistantJump { jumpSessionManager.createSession( anchorSpineIndex: targetSpineIndex, reason: .tableOfContentsJump, totalSpineCount: publication.spine.count ) - // 添加温区锚点 + backgroundPriorityManager.addWarmAnchor(spineIndex: targetSpineIndex) } @@ -723,25 +767,23 @@ final class RDEPUBReaderRuntime { return } - // 确定当前阅读方向,优先向当前方向扩展 let currentSpineIndex = locationCoordinator.currentVisibleLocation() .flatMap { context.normalizedSpineIndex(for: $0) } 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 } @@ -816,6 +858,40 @@ final class RDEPUBReaderRuntime { readerView.transitionToPage(pageNum: max(currentPageNumber - 1, 0), animated: false) } + func prefetchForwardChaptersAfterInitialOpen(anchorSpineIndex: Int, totalSpineCount: Int) { + guard context.publication != nil else { return } + + chapterRuntimeStore.setCurrentChapter( + spineIndex: anchorSpineIndex, + totalSpineCount: totalSpineCount, + windowRadius: context.configuration.chapterWindowRadius + ) + + let forwardTargets = chapterRuntimeStore.windowSpineIndices.filter { $0 > anchorSpineIndex } + guard !forwardTargets.isEmpty else { return } + + for spineIndex in forwardTargets { + if chapterRuntimeStore.chapterData(for: spineIndex) != nil { + appendLoadedForwardChaptersToCurrentPageMapIfPossible() + continue + } + + chapterRuntimeStore.addPrefetchTarget(spineIndex) + RDEPUBBackgroundTrace.log( + "Runtime", + "initial open prefetch forward spine=\(spineIndex)" + ) + chapterLoader.loadChapter( + spineIndex: spineIndex, + store: chapterRuntimeStore, + priority: .prefetch + ) { [weak self] result in + guard let self, case .success = result else { return } + self.appendLoadedForwardChaptersToCurrentPageMapIfPossible() + } + } + } + func clearOnDemandPageModeState() { paginationCoordinator.cancelActiveMetadataParseWork() chapterRuntimeStore.invalidateAllForSettingsChange() @@ -826,7 +902,6 @@ final class RDEPUBReaderRuntime { backgroundCoverageStore.clearAll() } - /// 处理内存警告 func handleMemoryWarning() { let currentSpineIndex = locationCoordinator.currentVisibleLocation() .flatMap { context.normalizedSpineIndex(for: $0) } @@ -893,6 +968,76 @@ final class RDEPUBReaderRuntime { return builder.build() } + private func appendLoadedForwardChaptersToCurrentPageMapIfPossible() { + guard let publication = context.publication, + let currentMap = context.bookPageMap, + let readerView = context.readerView, + let lastKnownSpineIndex = currentMap.entries.last?.spineIndex else { + return + } + + let buildableSpineIndices = publication.spine.indices.filter { + let item = publication.spine[$0] + return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml")) + } + + var appendedEntries: [RDEPUBBookPageMapEntry] = [] + for spineIndex in buildableSpineIndices where spineIndex > lastKnownSpineIndex { + guard let chapter = chapterRuntimeStore.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 + ) + ) + } + + 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 } + + RDEPUBBackgroundTrace.log( + "Runtime", + "appendLoadedForwardChapters chapters=\(newMap.totalChapters) pages=\(newMap.totalPages)" + ) + + context.bookPageMap = newMap + context.replaceActiveSnapshot(makeSnapshot(from: newMap)) + readerView.reloadPageCountOnly() + } + private func makeSnapshot(from bookPageMap: RDEPUBBookPageMap) -> RDEPUBReadingSession.PaginationSnapshot { let pages = bookPageMap.entries.flatMap { entry in (0.. Bool { advanceSearch(by: 1) } - /// 跳转到上一个搜索匹配项 - /// - Returns: 是否成功跳转 @discardableResult func searchPrevious() -> Bool { advanceSearch(by: -1) } - /// 跳转到指定索引的搜索匹配项 @discardableResult func selectSearchMatch(at index: Int) -> Bool { guard let controller else { return false } @@ -66,7 +59,6 @@ final class RDEPUBReaderSearchCoordinator { return navigateToCurrentSearchMatch(animated: true) } - /// 清除搜索状态并刷新当前可见内容 func clearSearch() { guard let controller else { return } controller.searchState = nil @@ -74,9 +66,6 @@ final class RDEPUBReaderSearchCoordinator { controller.refreshVisibleContentPreservingLocation() } - /// 构建指定页面的搜索结果展示信息,用于高亮渲染 - /// - Parameter page: 目标页面 - /// - Returns: 搜索展示数据,若无搜索状态则返回 nil func searchPresentation(for page: EPUBPage) -> RDEPUBSearchPresentation? { guard let controller else { return nil } guard let searchState = controller.searchState, @@ -175,6 +164,7 @@ final class RDEPUBReaderSearchCoordinator { let progressionDenominator = max(fullLength - 1, 1) let progression = Double(foundRange.location) / Double(progressionDenominator) + let rangeAnchor = chapterData.rangeAnchor(for: foundRange) matches.append( RDEPUBSearchMatch( href: normalizedHref, @@ -183,7 +173,9 @@ final class RDEPUBReaderSearchCoordinator { localMatchIndex: localMatchIndex, rangeLocation: foundRange.location, rangeLength: foundRange.length, - rangeAnchor: chapterData.rangeAnchor(for: foundRange) + rangeAnchor: rangeAnchor, + cfi: chapterData.indexTable.cfi(for: rangeAnchor.start)?.rawValue, + rangeCFI: chapterData.indexTable.cfiRange(for: rangeAnchor)?.rawValue ) ) @@ -210,6 +202,7 @@ final class RDEPUBReaderSearchCoordinator { title: runtimeChapter.title, attributedContent: runtimeChapter.typesetAttributedString, fragmentOffsets: runtimeChapter.chapterOffsetMap.fragmentOffsets, + cfiMap: runtimeChapter.chapterOffsetMap.cfiMap, pageBreakReasons: runtimeChapter.pages.map(\.metadata.breakReason), pages: runtimeChapter.pages ) @@ -266,7 +259,9 @@ final class RDEPUBReaderSearchCoordinator { progression: searchMatch.progression, lastProgression: searchMatch.progression, fragment: nil, - rangeAnchor: searchMatch.rangeAnchor + rangeAnchor: searchMatch.rangeAnchor, + cfi: searchMatch.cfi, + rangeCFI: searchMatch.rangeCFI ) return controller.restoreReadingLocation(location, animated: animated) } @@ -298,7 +293,9 @@ final class RDEPUBReaderSearchCoordinator { progression: searchMatch.progression, lastProgression: searchMatch.progression, fragment: nil, - rangeAnchor: searchMatch.rangeAnchor + rangeAnchor: searchMatch.rangeAnchor, + cfi: searchMatch.cfi, + rangeCFI: searchMatch.rangeCFI ) if let textBook = controller.textBook, let publication = controller.publication { diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderUIState.swift b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderUIState.swift index 82bea10..326941a 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderUIState.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderUIState.swift @@ -1,30 +1,26 @@ import Foundation -/// 阅读器 UI 状态模型:统一管理顶部/底部工具栏按钮的可用性和显示状态。 -/// -/// 解决问题:之前书签、高亮等按钮的状态分散在 ChromeCoordinator 和 AnnotationCoordinator 中, -/// 导致状态更新入口不一致,容易出现不同步的情况。 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, diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderViewportMonitor.swift b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderViewportMonitor.swift index 4f66af9..1fb4948 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderViewportMonitor.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderViewportMonitor.swift @@ -1,13 +1,15 @@ 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) { @@ -18,7 +20,6 @@ final class RDEPUBReaderViewportMonitor { context.controller } - /// 视图布局完成后检查视口是否发生变化,首次布局时触发初始加载 func viewDidLayoutSubviews() { guard let controller else { return } guard let viewportSignature = currentViewportSignature() else { return } @@ -41,8 +42,6 @@ final class RDEPUBReaderViewportMonitor { handleViewportChangeIfNeeded(reason: .viewLayout, viewportSignature: viewportSignature) } - /// 屏幕旋转前捕获当前阅读位置,旋转完成后检测视口变化并处理 - /// - Parameter coordinator: 转场协调器 func viewWillTransition(with coordinator: UIViewControllerTransitionCoordinator) { guard let controller else { return } guard controller.didStartInitialLoad else { return } @@ -57,7 +56,6 @@ final class RDEPUBReaderViewportMonitor { } } - /// 重置所有视口状态,用于重新加载书籍 func resetForReload() { lastAppliedViewportSignature = currentViewportSignature() pendingViewportChangeReason = nil @@ -65,19 +63,16 @@ final class RDEPUBReaderViewportMonitor { 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 @@ -86,7 +81,6 @@ final class RDEPUBReaderViewportMonitor { } } - /// 获取当前视口签名,包含容器尺寸和安全区域信息 func currentViewportSignature() -> RDEPUBViewportSignature? { guard let controller else { return nil } let containerSize = controller.readerView.bounds.size == .zero ? controller.view.bounds.size : controller.readerView.bounds.size @@ -102,7 +96,6 @@ final class RDEPUBReaderViewportMonitor { ) } - /// 检测视口签名是否发生显著变化,若变化则触发重新分页或重建外部 TextBook func handleViewportChangeIfNeeded( reason: RDEPUBViewportChangeReason, viewportSignature: RDEPUBViewportSignature? = nil diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBSelectionState.swift b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBSelectionState.swift index 4aa79d1..0949414 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBSelectionState.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBSelectionState.swift @@ -1,23 +1,16 @@ -// RDEPUBSelectionState.swift -// 统一选区状态模型 -// 收口选区相关状态的冗余表达,降低 view 层与 controller 层各持有一份选区状态 -// 所带来的维护成本和时序问题。 import Foundation -/// 统一选区状态枚举 -/// 替代原先散落在 view/controller/coordinator 的 `currentSelection != nil` 判断 enum RDEPUBSelectionState: Equatable { - /// 无选区 + case idle - /// 用户正在拖拽选区(长按手势已开始,尚未松手) + case selecting(anchor: Int) - /// 选区已完成(用户松手,有有效文本) + case selected(RDEPUBSelection) - /// 正在执行选区菜单动作(拷贝/高亮/批注),动作完成后回到 idle + case committingAction(RDEPUBSelection, action: RDEPUBAnnotationMenuAction) - /// 当前是否有有效选区(selecting / selected / committingAction 均视为有选区) var hasSelection: Bool { switch self { case .idle: @@ -27,7 +20,6 @@ enum RDEPUBSelectionState: Equatable { } } - /// 当前选区数据(如有) var selection: RDEPUBSelection? { switch self { case .idle, .selecting: diff --git a/Sources/RDReaderView/EPUBUI/Settings/RDEPUBReaderConfiguration.swift b/Sources/RDReaderView/EPUBUI/Settings/RDEPUBReaderConfiguration.swift index 74c6cb4..6e97753 100644 --- a/Sources/RDReaderView/EPUBUI/Settings/RDEPUBReaderConfiguration.swift +++ b/Sources/RDReaderView/EPUBUI/Settings/RDEPUBReaderConfiguration.swift @@ -1,26 +1,18 @@ import UIKit -// MARK: - 文本渲染引擎 - -/// EPUB 文本渲染引擎枚举 -/// 定义了当前支持的文本渲染方式,未来可扩展为多引擎选择 public enum RDEPUBTextRenderingEngine: Equatable { - /// 基于 DTCoreText 的渲染引擎,将 HTML/CSS 转换为 NSAttributedString + case dtCoreText } -// MARK: - 阅读字体 - -/// 阅读器内置字体选项。 -/// 第一版优先使用系统授权字体,后续可扩展为 bundle 内置字体注册。 public enum RDEPUBReaderFontChoice: String, Codable, CaseIterable, Equatable { - /// 系统默认无衬线字体 + case system - /// 系统衬线字体 + case serif - /// 系统圆体字体 + case rounded - /// 系统等宽字体 + case monospaced public var displayName: String { @@ -54,108 +46,58 @@ public enum RDEPUBReaderFontChoice: String, Codable, CaseIterable, Equatable { } } -// MARK: - 阅读器配置 - -/// EPUB 阅读器的完整配置结构体 -/// 作为 EPUBUI 层的入口配置,控制阅读器的外观、行为和功能开关 -/// 调用方在创建 RDEPUBReaderController 前可自定义此配置 public struct RDEPUBReaderConfiguration: Equatable { - // MARK: 阅读排版参数 - /// 正文字号(单位:pt),默认 15 public var fontSize: CGFloat - /// 行距倍数,默认 1.6(即行距为字号的 1.6 倍) + public var lineHeightMultiple: CGFloat - /// 正文字体,默认系统字体 + public var fontChoice: RDEPUBReaderFontChoice - /// 栏数,默认单栏 + public var numberOfColumns: Int - /// 栏间距,默认 20pt + public var columnGap: CGFloat - /// 翻页显示模式:.pageCurl(仿真翻页)或 .scroll(滚动阅读) + public var displayType: RDReaderView.DisplayType - /// 横屏时是否启用双页显示,默认开启 + public var landscapeDualPageEnabled: Bool - // MARK: UI 功能开关 - - /// 是否显示目录入口,默认开启 public var showsTableOfContents: Bool - /// 是否允许文本高亮功能,默认开启 + public var allowsHighlights: Bool - /// 是否显示设置面板入口,默认开启 + public var showsSettingsPanel: Bool - // MARK: 内容内边距 - - /// 流式排版(reflowable)内容的内边距,上下 40pt、左右 16pt public var reflowableContentInsets: UIEdgeInsets - /// 固定布局(fixed layout)内容的内边距,默认为零 + public var fixedContentInset: UIEdgeInsets - // MARK: 主题与布局 - - /// 当前阅读主题(浅色/深色/护眼等),默认 .light public var theme: RDEPUBReaderTheme - /// 暗色主题下是否柔化正文图片,降低夜间阅读刺眼感 + public var darkImageAdjustmentEnabled: Bool - /// 暗色主题图片柔化混合比例,0 表示不处理,推荐 0.12 ~ 0.2 + public var darkImageBlendRatio: CGFloat - /// 固定布局的适配模式:按页适配或按宽度适配 + public var fixedLayoutFit: RDEPUBFixedLayoutFit - /// 固定布局的跨页模式:自动/单页/双页 + public var fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode - /// 文本渲染引擎,默认使用 DTCoreText + public var textRenderingEngine: RDEPUBTextRenderingEngine - /// 章节按需加载窗口大小(总章节数,包含当前章),默认 3,范围 3...15,偶数自动向上取奇 + public var onDemandChapterWindowSize: Int - /// 后台元数据解析并发数,默认为 CPU 核心数。 - /// 若 profiling 显示 renderTotalMs ≈ wallClockMs(渲染受限),维持核心数即可; - /// 若 writeTotalMs 占比显著(I/O 等待),可试探 cpuCount * 1.25~1.5 以填充 I/O 等待间隙。 + public var metadataParsingConcurrency: Int - // MARK: JumpSession 策略 - - /// JumpSession 策略配置 public var jumpSessionPolicy: RDEPUBJumpSessionPolicy - // MARK: 安全策略 - - /// 允许直接打开的外部 URL scheme 集合,默认仅允许 https public var allowedExternalURLSchemes: Set - /// 打开外部链接前是否需要用户确认,默认 true + public var requiresExternalLinkConfirmation: Bool - /// 是否允许正文和离屏分页 WebView 开启 inspectable,默认 false + public var allowsInspectableWebViews: Bool - /// 是否允许输出完整 WebView 消息体日志,默认 false + public var enablesVerboseWebViewLogging: Bool - // MARK: 初始化 - - /// 创建阅读器配置,所有参数均提供合理的默认值 - /// - Parameters: - /// - fontSize: 正文字号,默认 15pt - /// - lineHeightMultiple: 行距倍数,默认 1.6 - /// - fontChoice: 正文字体,默认系统字体 - /// - displayType: 翻页模式,默认 .pageCurl - /// - landscapeDualPageEnabled: 横屏双页,默认 true - /// - showsTableOfContents: 显示目录入口,默认 true - /// - allowsHighlights: 允许高亮,默认 true - /// - showsSettingsPanel: 显示设置面板,默认 true - /// - reflowableContentInsets: 流式排版内边距 - /// - fixedContentInset: 固定布局内边距 - /// - theme: 阅读主题,默认 .light - /// - darkImageAdjustmentEnabled: 暗色主题下是否柔化正文图片 - /// - darkImageBlendRatio: 暗色主题图片柔化混合比例 - /// - fixedLayoutFit: 固定布局适配模式 - /// - fixedLayoutSpreadMode: 固定布局跨页模式 - /// - textRenderingEngine: 文本渲染引擎 - /// - onDemandChapterWindowSize: 章节按需加载窗口大小(总章节数 3...15,偶数自动向上取奇) - /// - metadataParsingConcurrency: 后台元数据解析并发数,默认 CPU 核心数 - /// - allowedExternalURLSchemes: 允许直接打开的外部 URL scheme 集合,默认仅 https - /// - requiresExternalLinkConfirmation: 打开外部链接前是否需要确认,默认 true - /// - allowsInspectableWebViews: 是否允许开启 inspectable,默认 false - /// - enablesVerboseWebViewLogging: 是否允许输出完整 WebView 消息体日志,默认 false public init( fontSize: CGFloat = 15, lineHeightMultiple: CGFloat = 1.6, @@ -210,11 +152,11 @@ public struct RDEPUBReaderConfiguration: Equatable { self.enablesVerboseWebViewLogging = enablesVerboseWebViewLogging } - /// 默认配置实例,使用所有参数的默认值 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 @@ -225,11 +167,8 @@ extension RDEPUBReaderConfiguration { } } -// MARK: - 配置转换 - extension RDEPUBReaderConfiguration { - /// 将用户可见的配置转换为底层排版引擎所需的 RDEPUBPreferences - /// - Returns: 排版偏好设置实例,供 Core 层的渲染管道使用 + func makePreferences() -> RDEPUBPreferences { RDEPUBPreferences( fontSize: fontSize, diff --git a/Sources/RDReaderView/EPUBUI/Settings/RDEPUBReaderSettings.swift b/Sources/RDReaderView/EPUBUI/Settings/RDEPUBReaderSettings.swift index 20273f8..21596e1 100644 --- a/Sources/RDReaderView/EPUBUI/Settings/RDEPUBReaderSettings.swift +++ b/Sources/RDReaderView/EPUBUI/Settings/RDEPUBReaderSettings.swift @@ -1,20 +1,15 @@ import UIKit -// MARK: - 翻页显示模式 - -/// 阅读器翻页显示模式枚举 -/// 用于持久化存储用户的翻页偏好设置,与 RDReaderView.DisplayType 相互转换 public enum RDEPUBReaderDisplayMode: String, Codable, Equatable { - /// 仿真翻页模式(纸张翻转效果) + case pageCurl - /// 水平滚动模式 + case horizontalScroll - /// 垂直滚动模式 + case verticalScroll - /// 水平覆盖式滚动模式(类似电子杂志翻页效果) + case horizontalCoverScroll - /// 从底层 DisplayType 转换为 UI 层的 DisplayMode init(displayType: RDReaderView.DisplayType) { switch displayType { case .pageCurl: @@ -26,8 +21,6 @@ public enum RDEPUBReaderDisplayMode: String, Codable, Equatable { } } - /// 转换为底层 RDReaderView 所需的 DisplayType - /// 注意:horizontalCoverScroll 会被映射为 horizontalScroll var displayType: RDReaderView.DisplayType { switch self { case .pageCurl: @@ -40,25 +33,20 @@ public enum RDEPUBReaderDisplayMode: String, Codable, Equatable { } } -// MARK: - 主题预设 - -/// 阅读器主题预设枚举 -/// 将 RDEPUBReaderTheme 映射为可序列化的枚举值,用于持久化存储和设置面板展示 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: @@ -76,8 +64,6 @@ public enum RDEPUBReaderThemePreset: String, Codable, CaseIterable, Equatable { } } - /// 从主题配置实例反向查找对应的预设枚举 - /// 自定义主题(非内置预设)会返回 nil public init?(theme: RDEPUBReaderTheme) { switch theme { case .light: @@ -98,30 +84,22 @@ public enum RDEPUBReaderThemePreset: String, Codable, CaseIterable, Equatable { } } -// MARK: - 阅读器用户设置 - -/// 阅读器的可持久化用户设置 -/// 用于保存用户在设置面板中调整的阅读偏好(字号、行距、翻页模式、主题等) -/// 所有属性均为可选类型,只覆盖用户实际修改过的配置项 public struct RDEPUBReaderSettings: Codable, Equatable { - // MARK: 可调参数 - /// 屏幕亮度(0.0 ~ 1.0),nil 表示使用系统亮度 public var brightness: CGFloat? - /// 用户选择的正文字号(pt),nil 表示使用默认值 + public var fontSize: CGFloat? - /// 用户选择的正文字体,nil 表示使用默认值 + public var fontChoice: RDEPUBReaderFontChoice? - /// 用户选择的行距倍数,nil 表示使用默认值 + public var lineHeightMultiple: CGFloat? - /// 用户选择的栏数,nil 表示使用默认值 + public var numberOfColumns: Int? - /// 用户选择的翻页模式,nil 表示使用默认值 + public var displayMode: RDEPUBReaderDisplayMode? - /// 用户选择的主题预设,nil 表示使用默认值 + public var themePreset: RDEPUBReaderThemePreset? - /// 初始化用户设置,所有参数默认为 nil public init( brightness: CGFloat? = nil, fontSize: CGFloat? = nil, @@ -140,10 +118,6 @@ public struct RDEPUBReaderSettings: Codable, Equatable { self.themePreset = themePreset } - /// 将用户设置覆盖到基础配置上,得到最终生效的阅读器配置 - /// nil 属性不会覆盖基础配置的对应值 - /// - Parameter configuration: 基础配置(通常来自 RDEPUBReaderConfiguration.default) - /// - Returns: 合并后的完整配置 public func applying(to configuration: RDEPUBReaderConfiguration) -> RDEPUBReaderConfiguration { var resolvedConfiguration = configuration @@ -169,12 +143,6 @@ public struct RDEPUBReaderSettings: Codable, Equatable { return resolvedConfiguration } - /// 从当前配置和亮度值捕获一份完整的用户设置快照 - /// 亮度值会被限制在 0.0 ~ 1.0 范围内 - /// - Parameters: - /// - configuration: 当前生效的阅读器配置 - /// - brightness: 当前屏幕亮度 - /// - Returns: 完整的用户设置实例 public static func capture( configuration: RDEPUBReaderConfiguration, brightness: CGFloat diff --git a/Sources/RDReaderView/EPUBUI/Settings/RDEPUBReaderSettingsViewController.swift b/Sources/RDReaderView/EPUBUI/Settings/RDEPUBReaderSettingsViewController.swift index ab872f3..46b8be0 100644 --- a/Sources/RDReaderView/EPUBUI/Settings/RDEPUBReaderSettingsViewController.swift +++ b/Sources/RDReaderView/EPUBUI/Settings/RDEPUBReaderSettingsViewController.swift @@ -1,33 +1,23 @@ import UIKit -// MARK: - 设置面板 - -/// EPUB 阅读器的设置面板控制器 -/// 提供亮度、字号、行距、翻页方式、主题的调节功能 -/// 支持 iOS 13+ SF Symbols 和低版本文字回退 final class RDEPUBReaderSettingsViewController: UIViewController { - // MARK: 回调闭包 - /// 亮度变化回调(0.0 ~ 1.0) var onBrightnessChange: ((CGFloat) -> Void)? - /// 字号变化回调(pt 值) + var onFontSizeChange: ((CGFloat) -> Void)? - /// 字体变化回调 + var onFontChoiceChange: ((RDEPUBReaderFontChoice) -> Void)? - /// 行距倍数变化回调 + var onLineHeightChange: ((CGFloat) -> Void)? - /// 栏数变化回调 + var onColumnCountChange: ((Int) -> Void)? - /// 翻页模式变化回调 + var onDisplayTypeChange: ((RDReaderView.DisplayType) -> Void)? - /// 主题变化回调 + var onThemeChange: ((RDEPUBReaderTheme) -> Void)? - /// 设置页面关闭回调 + var onDismiss: (() -> Void)? - // MARK: 主题预设枚举 - - /// 设置面板中的主题预设枚举,用于按钮显示和主题切换 private enum ThemePreset: Int, CaseIterable { case light case yellow @@ -60,20 +50,30 @@ final class RDEPUBReaderSettingsViewController: UIViewController { } 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 @@ -81,11 +81,11 @@ final class RDEPUBReaderSettingsViewController: UIViewController { stackView.spacing = 12 return stackView }() + private var themeButtons: [UIButton] = [] - /// 行距选项对应的倍数值(紧凑 1.3、标准 1.6、宽松 1.9) private let lineHeightValues: [CGFloat] = [1.3, 1.6, 1.9] - /// 当前配置的本地副本,修改后通过回调通知控制器 + private var currentConfiguration: RDEPUBReaderConfiguration init(configuration: RDEPUBReaderConfiguration, brightness: CGFloat) { diff --git a/Sources/RDReaderView/EPUBUI/Settings/RDEPUBReaderTheme.swift b/Sources/RDReaderView/EPUBUI/Settings/RDEPUBReaderTheme.swift index b809a50..f1bd971 100644 --- a/Sources/RDReaderView/EPUBUI/Settings/RDEPUBReaderTheme.swift +++ b/Sources/RDReaderView/EPUBUI/Settings/RDEPUBReaderTheme.swift @@ -1,37 +1,19 @@ import UIKit -// MARK: - 阅读器主题 - -/// EPUB 阅读器主题配置结构体 -/// 定义阅读器内容区域和工具栏的颜色方案 -/// 内置浅色、深色、黄色、绿色、粉色、蓝色六套预设主题 public struct RDEPUBReaderTheme: Equatable { - // MARK: 内容区颜色 - /// 内容区域的背景色 public var contentBackgroundColor: UIColor - /// 内容区域的正文文字颜色 + public var contentTextColor: UIColor - // MARK: 工具栏颜色 - - /// 工具栏背景色 public var toolBackgroundColor: UIColor - /// 工具栏控件文字/图标颜色 + public var toolControlTextColor: UIColor - /// 工具栏控件未选中状态的边框颜色 + public var toolControlBorderUnselectColor: UIColor - /// 工具栏中分隔线的颜色 + public var toolLineColor: UIColor - /// 初始化主题配置 - /// - Parameters: - /// - contentBackgroundColor: 内容区背景色 - /// - contentTextColor: 内容区文字颜色 - /// - toolBackgroundColor: 工具栏背景色 - /// - toolControlTextColor: 工具栏控件颜色 - /// - toolControlBorderUnselectColor: 控件未选中边框色 - /// - toolLineColor: 分隔线颜色 public init( contentBackgroundColor: UIColor, contentTextColor: UIColor, @@ -48,9 +30,6 @@ public struct RDEPUBReaderTheme: Equatable { self.toolLineColor = toolLineColor } - // MARK: 内置预设主题 - - /// 浅色主题:白底黑字,适合日间阅读 public static let light = RDEPUBReaderTheme( contentBackgroundColor: .white, contentTextColor: .black, @@ -60,7 +39,6 @@ public struct RDEPUBReaderTheme: Equatable { toolLineColor: UIColor.lightGray.withAlphaComponent(0.5) ) - /// 深色主题:黑底白字,适合夜间阅读 public static let dark = RDEPUBReaderTheme( contentBackgroundColor: .black, contentTextColor: .white, @@ -70,7 +48,6 @@ public struct RDEPUBReaderTheme: Equatable { 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, @@ -80,7 +57,6 @@ public struct RDEPUBReaderTheme: Equatable { 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, @@ -90,7 +66,6 @@ public struct RDEPUBReaderTheme: Equatable { 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, @@ -100,7 +75,6 @@ public struct RDEPUBReaderTheme: Equatable { 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, @@ -111,15 +85,12 @@ public struct RDEPUBReaderTheme: Equatable { ) } -// MARK: - CSS 颜色转换 - extension RDEPUBReaderTheme { - /// 将内容区背景色转换为 CSS 颜色字符串,注入 EPUB 的 HTML 中 + var themeBackgroundColorCSS: String { contentBackgroundColor.ss_cssString } - /// 将内容区文字颜色转换为 CSS 颜色字符串,注入 EPUB 的 HTML 中 var themeTextColorCSS: String { contentTextColor.ss_cssString } diff --git a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBPageInteractionController.swift b/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBPageInteractionController.swift index 4008522..37d4285 100644 --- a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBPageInteractionController.swift +++ b/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBPageInteractionController.swift @@ -4,17 +4,14 @@ import UIKit import DTCoreText #endif -// MARK: - 页面交互控制器 - -/// 页面交互控制器,负责文本层面的坐标映射与选择逻辑 -/// 在 DTCoreText 排版结果和用户交互之间充当桥梁 -/// 提供字符索引查找、选择范围计算、选择矩形计算等功能 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 { @@ -25,9 +22,6 @@ final class RDEPUBPageInteractionController { } #endif - // MARK: - Hit Testing - - /// 将视图坐标系的触摸点转为字符索引(对齐 WXRead 的 stringIndexForPoint:) func characterIndexForViewPoint(at viewPoint: CGPoint, in view: UIView) -> Int? { let localPoint = CGPoint(x: viewPoint.x, y: viewPoint.y) return characterIndex(at: localPoint) @@ -36,7 +30,6 @@ final class RDEPUBPageInteractionController { func characterIndex(at point: CGPoint) -> Int? { guard let snapshot else { return nil } - // Attachment rect priority (6pt inset for easier tapping) for attachment in snapshot.attachments { if attachment.frame.insetBy(dx: -6, dy: -6).contains(point) { return attachment.stringRange.location @@ -63,8 +56,6 @@ final class RDEPUBPageInteractionController { #endif } - // MARK: - Selection Range - func selectionRange(from startPoint: CGPoint, to endPoint: CGPoint) -> NSRange? { guard let start = characterIndex(at: startPoint), let end = characterIndex(at: endPoint) else { return nil } @@ -73,8 +64,6 @@ final class RDEPUBPageInteractionController { return NSRange(location: lower, length: max(upper - lower, 1)) } - // MARK: - Selection Rects - func selectionRects(for absoluteRange: NSRange) -> [CGRect] { guard let snapshot else { return [] } var rects: [CGRect] = [] @@ -136,8 +125,6 @@ final class RDEPUBPageInteractionController { return CGRect(x: minX, y: minY, width: max(maxX - minX, 2), height: max(maxY - minY, 2)) } - // MARK: - Caret Rect - func caretRect(at index: Int) -> CGRect? { guard let snapshot else { return nil } guard let line = snapshot.lines.first(where: { NSLocationInRange(index, $0.stringRange) }) else { @@ -159,8 +146,6 @@ final class RDEPUBPageInteractionController { ) } - // MARK: - Private Helpers - private func nearestLine(to point: CGPoint, in lines: [RDEPUBPageLine]) -> RDEPUBPageLine? { var bestLine: RDEPUBPageLine? var bestDistance: CGFloat = .greatestFiniteMagnitude @@ -196,6 +181,7 @@ final class RDEPUBPageInteractionController { } #if canImport(DTCoreText) + private func dtLineContaining(range: NSRange) -> DTCoreTextLayoutLine? { guard let dtLayoutFrame, let snapshot else { return nil } let localLocation = max(range.location - pageOffset(for: snapshot), 0) diff --git a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBPageLayoutSnapshot.swift b/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBPageLayoutSnapshot.swift index c78808e..b31e2e4 100644 --- a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBPageLayoutSnapshot.swift +++ b/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBPageLayoutSnapshot.swift @@ -4,63 +4,56 @@ import UIKit import DTCoreText #endif -// MARK: - 页面排版快照数据结构 - -/// 文本行信息 -/// 描述 DTCoreText 排版后的一行文本的位置、范围和基线信息 struct RDEPUBPageLine { - /// 行内文本在 chapterContent 中的绝对字符范围 + let stringRange: NSRange - /// 行的框架(相对于排版区域) + let frame: CGRect - /// 行的基线原点 + let baselineOrigin: CGPoint - /// 基线以上的高度(升部) + let ascent: CGFloat - /// 基线以下的高度(降部) + let descent: CGFloat - /// 行间距 + let leading: CGFloat } -/// 文本 Run 信息 -/// 描述一行内某个连续绘制单元(如普通文字或附件) struct RDEPUBPageRun { - /// Run 内文本的绝对字符范围 + let stringRange: NSRange - /// Run 的绘制框架 + let frame: CGRect - /// 是否为附件(图片等) + let isAttachment: Bool } -/// 附件信息 -/// 描述嵌入在文本中的图片或其他附件 struct RDEPUBPageAttachment { - /// 附件在文本中的绝对字符范围 + let stringRange: NSRange - /// 附件的显示框架 + let frame: CGRect - /// 附件的建议显示尺寸 + let displaySize: CGSize - /// 附件的布局方式(行内/浮动等) + let placement: RDEPUBTextAttachmentPlacement? - /// 附件类型(封面图/普通图片等) + let kind: RDEPUBTextAttachmentKind? } -// MARK: - 页面排版快照 - -/// 页面排版快照 -/// 封装 DTCoreText 的排版结果,提供高效的文本位置查询能力 -/// 用于支持文本选择、高亮渲染和搜索结果定位 struct RDEPUBPageLayoutSnapshot { + let page: RDEPUBTextPage + let lines: [RDEPUBPageLine] + let runs: [RDEPUBPageRun] + let attachments: [RDEPUBPageAttachment] + let pageContentRange: NSRange #if canImport(DTCoreText) + let layoutFrame: DTCoreTextLayoutFrame #endif @@ -128,6 +121,7 @@ struct RDEPUBPageLayoutSnapshot { } #if canImport(DTCoreText) + static func build( from layoutFrame: DTCoreTextLayoutFrame, page: RDEPUBTextPage diff --git a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBSelectionOverlayView.swift b/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBSelectionOverlayView.swift index 5adfe85..afd7e35 100644 --- a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBSelectionOverlayView.swift +++ b/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBSelectionOverlayView.swift @@ -1,47 +1,43 @@ import UIKit -// MARK: - 覆盖层装饰类型 - -/// 覆盖层装饰的数据结构 -/// 表示一个需要绘制在文本上方或下方的视觉装饰(高亮、搜索结果、选区等) 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 } -// MARK: - 选择覆盖层视图 - -/// 文本选择和装饰的覆盖层绘制视图 -/// 位于文本内容上方,负责绘制选区、高亮、搜索结果等视觉效果 -/// 使用 Core Graphics 直接绘制,支持填充矩形和下划线两种绘制模式 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) { diff --git a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextAnnotationOverlay.swift b/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextAnnotationOverlay.swift index 1ca3a29..11a5caf 100644 --- a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextAnnotationOverlay.swift +++ b/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextAnnotationOverlay.swift @@ -1,16 +1,11 @@ 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) - /// 将用户高亮批注以富文本属性形式应用到页面内容上 - /// - Parameters: - /// - highlights: 高亮批注数组 - /// - content: 待修改的富文本 - /// - page: 目标文本页 - /// - contentBaseOffset: 内容在全局偏移中的起始位置 func applyHighlights( _ highlights: [RDEPUBHighlight], to content: NSMutableAttributedString, @@ -47,12 +42,6 @@ final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView { } } - /// 将搜索命中高亮以富文本背景色形式应用到页面内容上 - /// - Parameters: - /// - content: 待修改的富文本 - /// - page: 目标文本页 - /// - searchState: 当前搜索状态 - /// - contentBaseOffset: 内容在全局偏移中的起始位置 func applySearchHighlights( to content: NSMutableAttributedString, page: RDEPUBTextPage, @@ -78,13 +67,6 @@ final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView { } } - /// 构建页面的背景和前景装饰数组(搜索高亮为背景,下划线批注为前景) - /// - Parameters: - /// - page: 目标文本页 - /// - highlights: 高亮批注数组 - /// - searchState: 当前搜索状态 - /// - interactionController: 用于计算选区矩形的交互控制器 - /// - Returns: 分离的背景和前景装饰数组 func buildDecorations( page: RDEPUBTextPage, highlights: [RDEPUBHighlight], diff --git a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift b/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift index 234531e..e2aa2aa 100644 --- a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift +++ b/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift @@ -5,10 +5,9 @@ import Foundation import DTCoreText #endif -// MARK: - 文本内容视图代理 - protocol RDEPUBTextContentViewDelegate: AnyObject { func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?) + func textContentView(_ contentView: RDEPUBTextContentView, didRequestReaderTapAt point: CGPoint) func textContentView( _ contentView: RDEPUBTextContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction, @@ -27,22 +26,46 @@ protocol RDEPUBTextContentViewDelegate: AnyObject { ) } -// MARK: - 文本内容视图 - -/// EPUB 流式排版的文本内容视图 -/// 对齐 WXRead 架构:选区和高亮共享 CoreText 命中测试与 drawRect 绘制结果。 final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate { + + enum SelectionInteractionState: Equatable { + case idle + case selectionPending + case selecting + case selectionActive + case adjustingHandle + } + private static let darkAdjustedImageCache = NSCache() 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? + + private var activeSelectionHandle: RDEPUBTextSelectionController.BoundaryHandle? + + private let selectionLoupeView = RDEPUBSelectionLoupeView() + + private var selectionInteractionState: SelectionInteractionState = .idle + 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 @@ -53,10 +76,12 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate { }() private var coreTextDisplayContent: NSAttributedString? + private var coreTextDisplayRange: NSRange? #endif private let interactionController = RDEPUBPageInteractionController() + private let selectionController = RDEPUBTextSelectionController() private let backgroundOverlayView: RDEPUBTextPageDecorationView = { @@ -91,7 +116,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate { private lazy var panGestureRecognizer: UIPanGestureRecognizer = { let gesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:))) - gesture.isEnabled = false gesture.delegate = self return gesture }() @@ -102,8 +126,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate { return gesture }() - // MARK: - Init - override init(frame: CGRect) { super.init(frame: frame) accessibilityIdentifier = "epub.reader.content.view" @@ -114,10 +136,12 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate { #endif addSubview(overlayView) addSubview(pageNumberLabel) + addSubview(selectionLoupeView) addGestureRecognizer(longPressGestureRecognizer) addGestureRecognizer(panGestureRecognizer) addGestureRecognizer(tapGestureRecognizer) tapGestureRecognizer.require(toFail: longPressGestureRecognizer) + selectionLoupeView.isHidden = true selectionController.onSelectionChanged = { [weak self] selection in guard let self else { return } @@ -130,17 +154,30 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate { self.updateAccessibilityDecorationSummary() self.delegate?.textContentView(self, didChangeSelection: selection) } + selectionController.interactionStateDidChange = { [weak self] state in + self?.handleSelectionControllerStateChange(state) + } 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") } - // MARK: - First Responder - override var canBecomeFirstResponder: Bool { true } + var selectionLongPressGestureRecognizer: UILongPressGestureRecognizer { + longPressGestureRecognizer + } + + var isSelectionInteractionInProgress: Bool { + selectionInteractionState != .idle + } + override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { action == #selector(rd_copy(_:)) || action == #selector(rd_highlight(_:)) @@ -159,8 +196,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate { performSelectionAction(.annotate) } - // MARK: - Layout - override func layoutSubviews() { super.layoutSubviews() @@ -181,19 +216,22 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate { ) } - // MARK: - Configure - func configure( page: RDEPUBTextPage, pageNumber: Int, totalPages: Int, configuration: RDEPUBReaderConfiguration, + chapterCFIMap: RDEPUBCFIMap? = nil, + chapterFragmentOffsets: [String: Int] = [:], highlights: [RDEPUBHighlight] = [], searchState: RDEPUBSearchState? = nil ) { currentPage = page + currentChapterCFIMap = chapterCFIMap + currentChapterFragmentOffsets = chapterFragmentOffsets currentSelection = nil menuSelection = nil + updateSelectionInteractionState(.idle) currentHighlights = highlights currentSearchState = searchState selectionController.clearSelection(renderView: coreTextRenderView) @@ -228,7 +266,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate { value: configuration.theme.contentTextColor, range: fullRange ) - // 注入高亮/下划线自定义属性(对齐 WXRead 的 WRChapterData.addHighlightInRange:) + applyHighlightsToContent(displayContent, highlights: highlights, page: page) coreTextContentView.isHidden = false coreTextContentView.backgroundColor = .clear @@ -263,7 +301,9 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate { currentSelection = nil menuSelection = nil currentSearchState = nil - panGestureRecognizer.isEnabled = false + activeSelectionHandle = nil + updateSelectionInteractionState(.idle) + selectionLoupeView.dismiss() selectionController.clearSelection(renderView: coreTextRenderView) overlayView.clearSelection() backgroundOverlayView.clearSelection() @@ -271,8 +311,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate { UIMenuController.shared.setMenuVisible(false, animated: true) } - // MARK: - 选择操作 - private func performSelectionAction(_ action: RDEPUBAnnotationMenuAction) { let selection = currentSelection ?? menuSelection delegate?.textContentView(self, didRequestSelectionAction: action, selection: selection) @@ -280,8 +318,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate { hideSelectionMenu() } - // MARK: - Cover Image - private func configureCoverIfNeeded(for page: RDEPUBTextPage) -> Bool { guard page.pageIndexInChapter == 0, page.href.lowercased().contains("cover"), @@ -327,9 +363,8 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate { return nil } - // MARK: - Dark Image Adjustment - #if canImport(DTCoreText) + private func darkImageAdjustedContentIfNeeded( _ content: NSMutableAttributedString, configuration: RDEPUBReaderConfiguration @@ -409,8 +444,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate { } #endif - // MARK: - 高亮属性注入(对齐 WXRead 的 WRChapterData.addHighlightInRange:) - private func applyHighlightsToContent( _ content: NSMutableAttributedString, highlights: [RDEPUBHighlight], @@ -435,8 +468,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate { } } - // MARK: - Content Normalization - private func normalizedPageContent(from page: RDEPUBTextPage) -> NSMutableAttributedString { let content = NSMutableAttributedString(attributedString: page.content) guard shouldNormalizeContinuationParagraph(for: page) else { return content } @@ -463,9 +494,8 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate { return !CharacterSet.newlines.contains(previousScalar) } - // MARK: - CoreText Layout - #if canImport(DTCoreText) + private func updateCoreTextLayoutFrameIfNeeded() { guard !coreTextContentView.isHidden, let displayContent = coreTextDisplayContent, @@ -502,8 +532,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate { } #endif - // MARK: - Gestures - @objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) { #if canImport(DTCoreText) layoutIfNeeded() @@ -514,12 +542,19 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate { ) switch gesture.state { case .began: - panGestureRecognizer.isEnabled = true + activeSelectionHandle = nil + updateSelectionInteractionState(.selecting) + hideSelectionMenu() + updateSelectionLoupe(for: gesture.location(in: coreTextRenderView ?? self)) + case .changed: + updateSelectionInteractionState(.selecting) + updateSelectionLoupe(for: gesture.location(in: coreTextRenderView ?? self)) case .ended: - panGestureRecognizer.isEnabled = false + selectionLoupeView.dismiss() showSelectionMenuIfNeeded() case .cancelled, .failed: - panGestureRecognizer.isEnabled = false + activeSelectionHandle = nil + selectionLoupeView.dismiss() default: break } @@ -528,17 +563,46 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate { @objc private func handlePan(_ gesture: UIPanGestureRecognizer) { #if canImport(DTCoreText) - selectionController.handlePan( - gesture, - renderView: coreTextRenderView, - interactionController: interactionController - ) + layoutIfNeeded() + let point = gesture.location(in: coreTextRenderView ?? self) + if gesture.state == .began, activeSelectionHandle == nil { + if let renderView = coreTextRenderView, + let handle = renderView.selectionHandle(at: point) { + activeSelectionHandle = handle == .start ? .start : .end + updateSelectionInteractionState(.adjustingHandle) + hideSelectionMenu() + updateSelectionLoupe(for: point) + } + } + + if let activeSelectionHandle, let renderView = coreTextRenderView { + selectionController.updateSelection( + byAdjusting: activeSelectionHandle, + at: point, + renderView: renderView, + interactionController: interactionController + ) + updateSelectionLoupe(for: point) + } else { + selectionController.handlePan( + gesture, + renderView: coreTextRenderView, + interactionController: interactionController + ) + if selectionController.isSelecting { + updateSelectionLoupe(for: point) + } + } switch gesture.state { case .ended: - panGestureRecognizer.isEnabled = false + activeSelectionHandle = nil + updateSelectionInteractionState(currentSelection == nil ? .idle : .selectionActive) + selectionLoupeView.dismiss() showSelectionMenuIfNeeded() case .cancelled, .failed: - panGestureRecognizer.isEnabled = false + activeSelectionHandle = nil + updateSelectionInteractionState(currentSelection == nil ? .idle : .selectionActive) + selectionLoupeView.dismiss() default: break } @@ -547,7 +611,20 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate { @objc private func handleTap(_ gesture: UITapGestureRecognizer) { let point = gesture.location(in: overlayView) - if currentSelection != nil { + if let renderView = coreTextRenderView { + let renderPoint = gesture.location(in: renderView) + if renderView.selectionHandle(at: renderPoint) != nil { + return + } + if currentSelection != nil { + if renderView.selectionContains(renderPoint) { + showSelectionMenuIfNeeded() + return + } + clearSelection() + return + } + } else if currentSelection != nil { clearSelection() return } @@ -563,6 +640,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate { } guard let highlight = highlight(at: point), let sourceRect = highlightSourceRect(for: highlight, fallbackPoint: point) else { + delegate?.textContentView(self, didRequestReaderTapAt: convert(point, from: overlayView)) return } delegate?.textContentView(self, didRequestHighlightActions: highlight, sourceRect: sourceRect) @@ -589,6 +667,57 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate { 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 func handleSelectionControllerStateChange(_ state: RDEPUBTextSelectionController.InteractionState) { + switch state { + case .idle: + if currentSelection == nil, activeSelectionHandle == nil { + updateSelectionInteractionState(.idle) + } + case .selecting: + updateSelectionInteractionState(.selecting) + case .selectionActive: + updateSelectionInteractionState(currentSelection == nil ? .idle : .selectionActive) + case .adjustingHandle: + updateSelectionInteractionState(.adjustingHandle) + } + } + + private func updateSelectionInteractionState(_ state: SelectionInteractionState) { + let previousTapSuppressed = selectionInteractionState != .idle + let previousPagingSuppressed = shouldSuppressPagingInteraction(for: selectionInteractionState) + selectionInteractionState = state + let currentTapSuppressed = selectionInteractionState != .idle + let currentPagingSuppressed = shouldSuppressPagingInteraction(for: selectionInteractionState) + if previousTapSuppressed != currentTapSuppressed { + selectionTapSuppressionDidChange?(currentTapSuppressed) + } + if previousPagingSuppressed != currentPagingSuppressed { + selectionPagingSuppressionDidChange?(currentPagingSuppressed) + } + } + + private func shouldSuppressPagingInteraction(for state: SelectionInteractionState) -> Bool { + switch state { + case .idle, .selectionPending, .selectionActive: + return false + case .selecting, .adjustingHandle: + return true + } + } + private var coreTextRenderView: RDEPUBTextPageRenderView? { #if canImport(DTCoreText) return coreTextContentView @@ -678,23 +807,140 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate { return overlayView.convert(fallbackRect, to: self) } + func shouldSuppressReaderTap(at point: CGPoint) -> Bool { +#if canImport(DTCoreText) + if selectionInteractionState == .selectionPending { + return true + } + + guard let renderView = coreTextRenderView else { + return false + } + let renderPoint = convert(point, to: renderView) + if renderView.selectionHandle(at: renderPoint) != nil { + return true + } + if selectionController.hasActiveSelection, renderView.selectionContains(renderPoint) { + return true + } + switch selectionInteractionState { + case .idle: + return false + case .selectionPending, .selecting, .selectionActive, .adjustingHandle: + return true + } +#else + return false +#endif + } + + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + super.touchesBegan(touches, with: event) +#if canImport(DTCoreText) + guard selectionInteractionState == .idle, + activeSelectionHandle == nil, + currentPage != nil, + let touch = touches.first, + let renderView = coreTextRenderView else { + return + } + let point = touch.location(in: renderView) + guard renderView.selectionHandle(at: point) == nil else { return } + updateSelectionInteractionState(.selectionPending) +#endif + } + + override func touchesEnded(_ touches: Set, with event: UIEvent?) { + super.touchesEnded(touches, with: event) + resetSelectionPendingIfNeeded() + } + + override func touchesCancelled(_ touches: Set, with event: UIEvent?) { + super.touchesCancelled(touches, with: event) + resetSelectionPendingIfNeeded() + } + + private func resetSelectionPendingIfNeeded() { + guard selectionInteractionState == .selectionPending else { return } + if currentSelection != nil { + updateSelectionInteractionState(.selectionActive) + } else { + updateSelectionInteractionState(.idle) + } + } + override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { if gestureRecognizer === panGestureRecognizer { - return selectionController.isSelecting + if selectionController.isSelecting { + return true + } + guard let pan = gestureRecognizer as? UIPanGestureRecognizer, + let renderView = coreTextRenderView else { + return false + } + let point = pan.location(in: renderView) + return renderView.selectionHandle(at: point) != nil + } + if gestureRecognizer === longPressGestureRecognizer { + guard let longPress = gestureRecognizer as? UILongPressGestureRecognizer, + let renderView = coreTextRenderView else { + return true + } + let point = longPress.location(in: renderView) + if renderView.selectionHandle(at: point) != nil { + return false + } + if selectionController.hasActiveSelection, renderView.selectionContains(point) { + return false + } + return true } 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) - return attachmentText(at: point) != nil || highlight(at: point) != nil + if attachmentText(at: point) != nil || highlight(at: point) != nil { + return true + } + return currentPage != nil } return true } + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldReceive touch: UITouch + ) -> Bool { + guard let renderView = coreTextRenderView else { + return true + } + + let point = touch.location(in: renderView) + if let handle = renderView.selectionHandle(at: point) { + if gestureRecognizer === panGestureRecognizer { + activeSelectionHandle = handle == .start ? .start : .end + updateSelectionInteractionState(.adjustingHandle) + hideSelectionMenu() + return true + } + if gestureRecognizer === longPressGestureRecognizer || gestureRecognizer === tapGestureRecognizer { + return false + } + } + + return true + } + func gestureRecognizer( _ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer @@ -703,9 +949,85 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate { } } -// MARK: - UIColor Extension +private 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) + } + } +} private extension UIColor { + var rd_isDarkReaderBackground: 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 } diff --git a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextPageDecorationView.swift b/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextPageDecorationView.swift index c3c8e09..3bb438e 100644 --- a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextPageDecorationView.swift +++ b/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextPageDecorationView.swift @@ -1,5 +1,3 @@ import UIKit -/// 原生文本渲染路径的页级背景覆盖层。 -/// 继承自 RDEPUBSelectionOverlayView,负责绘制位于正文下方的页内装饰(如搜索高亮背景、批注背景等)。 final class RDEPUBTextPageDecorationView: RDEPUBSelectionOverlayView {} diff --git a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextPageRenderView.swift b/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextPageRenderView.swift index 9516993..c99151b 100644 --- a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextPageRenderView.swift +++ b/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextPageRenderView.swift @@ -3,10 +3,13 @@ import UIKit #if canImport(DTCoreText) import DTCoreText -/// 基于 DTCoreText 的 Core Text 直接绘制视图 -/// 将 DTCoreText 的排版结果直接绘制到 UIView 上,跳过 UITextView 的间接渲染。 -/// 对齐 WXRead 的 WRPageView:在同一 drawRect 中绘制高亮背景、文字和选区。 final class RDEPUBTextPageRenderView: UIView { + + enum SelectionHandle { + case start + case end + } + var layoutFrame: DTCoreTextLayoutFrame? { didSet { setNeedsDisplay() @@ -19,26 +22,27 @@ final class RDEPUBTextPageRenderView: UIView { } } - /// 用于高亮绘制的 attributed string(包含 com.rdreader.highlight 等自定义属性) var attributedDisplayContent: NSAttributedString? { didSet { setNeedsDisplay() } } - // MARK: - 选区绘制(对齐 WXRead 的 _drawSelectionInContext:) - - /// 选区矩形数组,由 RDEPUBTextSelectionController 设置 var selectionRects: [CGRect] = [] { didSet { setNeedsDisplay() } } - /// 选区颜色(蓝色半透明,对齐 WXRead) var selectionColor: UIColor = UIColor(red: 70 / 255, green: 140 / 255, blue: 1, alpha: 0.24) - // MARK: - Init + 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 override init(frame: CGRect) { super.init(frame: frame) @@ -51,30 +55,23 @@ final class RDEPUBTextPageRenderView: UIView { fatalError("init(coder:) has not been implemented") } - // MARK: - Draw - override func draw(_ rect: CGRect) { guard let context = UIGraphicsGetCurrentContext(), let layoutFrame else { return } context.saveGState() - // 1. 绘制高亮背景(在文字下方,对齐 WXRead 的 drawHighlightsInContext:) if let attributedDisplayContent { drawHighlights(in: context, attributedString: attributedDisplayContent, layoutFrame: layoutFrame) } - // 2. 绘制文字 layoutFrame.draw(in: context, options: drawOptions) - // 3. 绘制选区(在文字上方) drawSelection(in: context) context.restoreGState() } - // MARK: - 高亮绘制(对齐 WXRead 的 WRCoreTextLayoutFrame.drawHighlightsInContext:) - private func drawHighlights( in context: CGContext, attributedString: NSAttributedString, @@ -82,7 +79,6 @@ final class RDEPUBTextPageRenderView: UIView { ) { let fullRange = NSRange(location: 0, length: attributedString.length) - // 绘制高亮背景 attributedString.enumerateAttribute(kRDEPUBHighlightAttributeName, in: fullRange) { value, range, _ in guard let color = value as? UIColor else { return } let rects = computeHighlightRects(for: range, layoutFrame: layoutFrame) @@ -92,7 +88,6 @@ final class RDEPUBTextPageRenderView: UIView { } } - // 绘制下划线 attributedString.enumerateAttribute(kRDEPUBUnderlineAttributeName, in: fullRange) { value, range, _ in guard let color = value as? UIColor else { return } let rects = computeHighlightRects(for: range, layoutFrame: layoutFrame) @@ -107,7 +102,6 @@ final class RDEPUBTextPageRenderView: UIView { } } - /// 计算指定字符范围的视觉矩形(对齐 WXRead 的 rectsForRange: 算法) private func computeHighlightRects(for range: NSRange, layoutFrame: DTCoreTextLayoutFrame) -> [CGRect] { var rects: [CGRect] = [] guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine] else { return rects } @@ -131,14 +125,110 @@ final class RDEPUBTextPageRenderView: UIView { return rects } - // MARK: - 选区绘制 - 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 extension CGPoint { + + func distance(to point: CGPoint) -> CGFloat { + hypot(x - point.x, y - point.y) } } #endif diff --git a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextSelectionController.swift b/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextSelectionController.swift index e438216..973bf31 100644 --- a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextSelectionController.swift +++ b/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextSelectionController.swift @@ -1,18 +1,51 @@ import UIKit -/// 负责管理文本选区和 CoreText 命中测试。 -/// 对齐 WXRead 的选择模型:触摸命中和最终高亮共用同一套字符范围。 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 - /// 选区变化回调,通知视图层更新 UI + 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 } @@ -37,11 +70,14 @@ final class RDEPUBTextSelectionController: NSObject { 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: @@ -59,14 +95,46 @@ final class RDEPUBTextSelectionController: NSObject { 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) @@ -74,8 +142,11 @@ final class RDEPUBTextSelectionController: NSObject { func clearSelection(renderView: RDEPUBTextPageRenderView? = nil) { isSelecting = false + selectionAnchorIndex = NSNotFound + activeGranularity = .character selectionStartIndex = NSNotFound selectionEndIndex = NSNotFound + setInteractionState(.idle) renderView?.selectionRects = [] UIMenuController.shared.setMenuVisible(false, animated: true) onSelectionChanged?(nil) @@ -92,9 +163,141 @@ final class RDEPUBTextSelectionController: NSObject { } return } - selectionStartIndex = index - selectionEndIndex = index + 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) } @@ -103,11 +306,24 @@ final class RDEPUBTextSelectionController: NSObject { renderView: RDEPUBTextPageRenderView, interactionController: RDEPUBPageInteractionController ) { - guard selectionStartIndex != NSNotFound, + guard selectionAnchorIndex != NSNotFound, let index = interactionController.characterIndexForViewPoint(at: point, in: renderView) else { return } - selectionEndIndex = index + + 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) } @@ -122,9 +338,14 @@ final class RDEPUBTextSelectionController: NSObject { } 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, @@ -137,31 +358,32 @@ final class RDEPUBTextSelectionController: NSObject { return nil } - let totalLength = max(page.chapterContent.length - 1, 1) let globalStart = absoluteRange.location let globalEnd = absoluteRange.location + absoluteRange.length - let startAnchor = RDEPUBTextAnchor( - fileIndex: page.spineIndex, - row: 0, - column: 0, - chapterOffset: globalStart - ) - let endAnchor = RDEPUBTextAnchor( - fileIndex: page.spineIndex, - row: 0, - column: 0, - chapterOffset: globalEnd - ) + let chapterData = makeChapterData(for: page) + let location = chapterData.location(for: absoluteRange, bookIdentifier: nil) return RDEPUBSelection( - location: RDEPUBLocation( - href: page.href, - progression: Double(globalStart) / Double(totalLength), - lastProgression: Double(max(globalEnd - 1, globalStart)) / Double(totalLength), - fragment: nil, - rangeAnchor: RDEPUBTextRangeAnchor(start: startAnchor, end: endAnchor) - ), + 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/RDReaderView/EPUBUI/UIColor+RDEPUBHex.swift b/Sources/RDReaderView/EPUBUI/UIColor+RDEPUBHex.swift index c131f54..829a05f 100644 --- a/Sources/RDReaderView/EPUBUI/UIColor+RDEPUBHex.swift +++ b/Sources/RDReaderView/EPUBUI/UIColor+RDEPUBHex.swift @@ -1,11 +1,7 @@ import UIKit -/// UIColor 十六进制颜色扩展,提供从 HEX 字符串创建颜色的便捷方法。 extension UIColor { - /// 从十六进制字符串创建颜色 - /// - Parameters: - /// - rdHexString: 颜色 HEX 字符串,支持带或不带 "#" 前缀(如 "#FF5733" 或 "FF5733"),必须为 6 位 - /// - alpha: 透明度(0.0 ~ 1.0) + convenience init?(rdHexString: String, alpha: CGFloat) { var value = rdHexString.trimmingCharacters(in: .whitespacesAndNewlines) value = value.replacingOccurrences(of: "#", with: "") diff --git a/Sources/RDReaderView/ReaderView/Paging/RDReaderPagingController.swift b/Sources/RDReaderView/ReaderView/Paging/RDReaderPagingController.swift index c088901..cbf6354 100644 --- a/Sources/RDReaderView/ReaderView/Paging/RDReaderPagingController.swift +++ b/Sources/RDReaderView/ReaderView/Paging/RDReaderPagingController.swift @@ -1,27 +1,18 @@ import UIKit -/// 翻页控制器:管理 UIPageViewController 的创建、切换、故障修复和翻页请求队列。 -/// -/// RDReaderView 持有此控制器,将 pageCurl 模式的容器管理职责下沉。 struct RDReaderPagingController { - /// 当前 pageCurl 翻页请求模型 + struct PageTransitionRequest: Equatable { let pageNum: Int let animated: Bool } - /// 排队中的翻页请求(pageCurl 动画过程中收到新跳转时排队) var pendingTransitionRequest: PageTransitionRequest? - /// 是否正在执行页面转场动画 var isTransitioning: Bool = false - /// UI 是否已经构建完成(避免重复构建) var didBuildUI = false - // MARK: - PageViewController 工厂 - - /// 创建带指定 spine 位置的 UIPageViewController static func createPageViewController(isDualPage: Bool) -> UIPageViewController { let options: [UIPageViewController.OptionsKey: Any]? if isDualPage { @@ -34,16 +25,12 @@ struct RDReaderPagingController { return pageVC } - // MARK: - 翻页请求队列管理 - - /// pageCurl 动画过程中如果收到新的跳转请求,则排队等待当前动画结束。 mutating func shouldQueuePageTransition(_ request: PageTransitionRequest, currentDisplayType: RDReaderView.DisplayType) -> Bool { guard currentDisplayType == .pageCurl, isTransitioning else { return false } pendingTransitionRequest = request return true } - /// 收尾 pageCurl 转场,返回排队中的后续请求(如果有的话)。 mutating func finishPageCurlTransition() -> PageTransitionRequest? { isTransitioning = false guard let pending = pendingTransitionRequest else { return nil } @@ -51,7 +38,6 @@ struct RDReaderPagingController { return pending } - /// 重置所有排队状态(用于故障修复)。 mutating func resetPendingState() { isTransitioning = false pendingTransitionRequest = nil diff --git a/Sources/RDReaderView/ReaderView/Paging/RDReaderPreloadController.swift b/Sources/RDReaderView/ReaderView/Paging/RDReaderPreloadController.swift index 4d2899b..0d29fae 100644 --- a/Sources/RDReaderView/ReaderView/Paging/RDReaderPreloadController.swift +++ b/Sources/RDReaderView/ReaderView/Paging/RDReaderPreloadController.swift @@ -1,49 +1,52 @@ -// -// RDReaderPreloadController.swift -// ReadViewSDK -// -// 文件职责:页面预加载控制器,负责提前渲染即将显示的页面视图以减少翻页卡顿。 -// import UIKit -/// 页面预加载控制器 -/// 负责管理页面视图的预加载和缓存,在当前页面基础上提前渲染前后若干页, -/// 以减少翻页时的等待时间。支持仿真翻页和滚动模式下的不同复用策略。 final class RDReaderPreloadController { - /// 预加载半径,表示在当前页前后各预加载的页数,默认为 1 + var radius: Int = 1 private let preloadHostView = UIView() + private var preloadedPageViews: [Int: UIView] = [:] + private var pageCurlCachedViews: [Int: UIView] = [:] + private var cacheSignature: CacheSignature? - /// 预加载环境参数,封装翻页模式、屏幕方向、页面尺寸等上下文信息 struct Environment { + let displayType: RDReaderView.DisplayType + let isLandscape: Bool + let pagesPerScreen: Int + let boundsSize: CGSize + let landscapeDualPageEnabled: Bool + let coverPageIndex: Int? + let totalPages: Int + let spreadResolver: RDReaderSpreadResolver } private struct CacheSignature: Equatable { + let displayType: RDReaderView.DisplayType + let isLandscape: Bool + let pagesPerScreen: Int + let boundsSize: CGSize } - /// 设置预加载宿主视图的 frame func setHostFrame(_ frame: CGRect) { preloadHostView.frame = frame } - /// 将预加载宿主视图添加到父视图中(如果尚未添加),置于最底层且不可见 func ensureHostView(in parentView: UIView) { guard preloadHostView.superview == nil else { return } preloadHostView.isHidden = true @@ -54,12 +57,10 @@ final class RDReaderPreloadController { parentView.insertSubview(preloadHostView, at: 0) } - /// 根据当前环境初始化缓存签名,用于后续检测环境是否发生变化 func initializeSignature(_ environment: Environment) { cacheSignature = currentCacheSignature(environment) } - /// 清除所有已缓存的页面视图并更新缓存签名 func invalidate(environment: Environment) { pageCurlCachedViews.values.forEach { $0.removeFromSuperview() } preloadedPageViews.values.forEach { $0.removeFromSuperview() } @@ -68,7 +69,6 @@ final class RDReaderPreloadController { cacheSignature = currentCacheSignature(environment) } - /// 获取指定页码的页面视图用于显示,优先复用已缓存的视图 func pageViewForDisplay( pageNum: Int, environment: Environment, @@ -80,14 +80,12 @@ final class RDReaderPreloadController { return view } - /// 取出指定页码的预加载视图并从缓存中移除,供外部复用 func takePreloadedView(for pageNum: Int) -> UIView? { let preloaded = preloadedPageViews.removeValue(forKey: pageNum) preloaded?.removeFromSuperview() return preloaded } - /// 预加载当前页周围指定半径内的页面,清除不再需要的缓存视图 func prime( around pageNum: Int, preferredForward: Bool? = nil, diff --git a/Sources/RDReaderView/ReaderView/Paging/RDReaderSpreadResolver.swift b/Sources/RDReaderView/ReaderView/Paging/RDReaderSpreadResolver.swift index baf1251..d36728e 100644 --- a/Sources/RDReaderView/ReaderView/Paging/RDReaderSpreadResolver.swift +++ b/Sources/RDReaderView/ReaderView/Paging/RDReaderSpreadResolver.swift @@ -1,17 +1,8 @@ -// -// RDReaderSpreadResolver.swift -// ReadViewSDK -// -// 文件职责:页面展开(spread)解析器,负责计算双页模式下的页面配对和翻页目标。 -// import Foundation -/// 页面展开解析器 -/// 处理横屏双页模式下的页码配对逻辑,包括封面页独占、双页对齐、 -/// 以及相邻页面跳转等计算。 struct RDReaderSpreadResolver { - /// 判断指定页码是否为全屏独占页面(如封面页在双页模式下独占一屏) + func isFullScreenPage( _ pageNum: Int, landscapeDualPageEnabled: Bool, @@ -22,8 +13,6 @@ struct RDReaderSpreadResolver { return pageNum == coverIndex } - /// 计算指定页码在双页模式下的左右页配对,返回 (左页, 右页?)。 - /// 封面页独占时单独处理,后续页面按两页一组配对。 func dualPagePair( for pageNum: Int, totalPages: Int, @@ -45,7 +34,6 @@ struct RDReaderSpreadResolver { return (left, right) } - /// 计算从指定页码出发的下一个(或上一个)双页展开的起始页码 func adjacentDualPage( from pageNum: Int, totalPages: Int, @@ -63,7 +51,6 @@ struct RDReaderSpreadResolver { return dualPagePair(for: prevEnd, totalPages: totalPages, coverPageIndex: coverPageIndex).left } - /// 计算下一页(或上一页)的页码,自动根据单页/双页模式选择合适的算法 func nextPage( from currentPage: Int, totalPages: Int, diff --git a/Sources/RDReaderView/ReaderView/Paging/RDReaderTapRegionHandler.swift b/Sources/RDReaderView/ReaderView/Paging/RDReaderTapRegionHandler.swift index 9a87380..c86041a 100644 --- a/Sources/RDReaderView/ReaderView/Paging/RDReaderTapRegionHandler.swift +++ b/Sources/RDReaderView/ReaderView/Paging/RDReaderTapRegionHandler.swift @@ -1,20 +1,8 @@ -// -// RDReaderTapRegionHandler.swift -// ReadViewSDK -// -// 文件职责:点击区域判定处理器,将屏幕点击坐标映射为翻页或工具栏操作事件。 -// import CoreGraphics -/// 点击区域判定处理器 -/// 将屏幕三等分为左、中、右三个区域,根据点击位置和工具栏可见状态 -/// 决定触发上一页、下一页还是切换工具栏。 struct RDReaderTapRegionHandler { - /// 根据点击坐标和视图尺寸,解析点击事件类型 - /// - 左1/3区域:上一页(工具栏可见时转为 center) - /// - 中1/3区域:切换工具栏 - /// - 右1/3区域:下一页(工具栏可见时转为 center) + func resolveTapEvent( point: CGPoint, viewFrame: CGRect, diff --git a/Sources/RDReaderView/ReaderView/RDReaderContentCell.swift b/Sources/RDReaderView/ReaderView/RDReaderContentCell.swift index 9f6c7a8..18ec111 100644 --- a/Sources/RDReaderView/ReaderView/RDReaderContentCell.swift +++ b/Sources/RDReaderView/ReaderView/RDReaderContentCell.swift @@ -1,29 +1,10 @@ -// -// RDReaderContentCell.swift -// RDReaderDemo -// -// Created by yangsq on 2021/8/7. -// -// 文件职责:UICollectionViewCell 薄壳宿主,作为滚动模式下页面内容的容器。 -// 该 cell 本身不包含业务逻辑,仅持有 containerView 并通过自动布局将其填满。 -// 真正的内容视图由 RDReaderDataSource.pageContentView() 提供并赋值给 containerView。 -// -// 架构位置:RDReaderView 四层架构中第三层的 cell 组件,被 UICollectionView 使用。 -// import UIKit -/// 内容 cell 薄壳宿主 -/// 作为 UICollectionView 滚动模式下的页面容器。 -/// 通过 ``containerView`` 属性持有实际内容视图,并在 layoutSubviews 中 -/// 将内容视图的 frame 设置为与 contentView 等大,实现自动填满。 class RDReaderContentCell: UICollectionViewCell { - /// 内部持有的内容视图引用 + private var _containerView: UIView? = nil - /// 内容视图属性 - /// 设置时会自动将旧视图移除、新视图添加到 contentView 中 - /// 读取时返回当前持有的内容视图 var containerView: UIView? { set { guard newValue !== _containerView else { return } @@ -39,8 +20,6 @@ class RDReaderContentCell: UICollectionViewCell { } } - /// 布局子视图时调用 - /// 将 containerView 的 frame 设置为与 contentView 等大,实现自动填满 override func layoutSubviews() { super.layoutSubviews() _containerView?.frame = CGRect(x: 0, y: 0, width: contentView.frame.width, height: contentView.frame.height) diff --git a/Sources/RDReaderView/ReaderView/RDReaderFlowLayout.swift b/Sources/RDReaderView/ReaderView/RDReaderFlowLayout.swift index 810cde3..68e47a5 100644 --- a/Sources/RDReaderView/ReaderView/RDReaderFlowLayout.swift +++ b/Sources/RDReaderView/ReaderView/RDReaderFlowLayout.swift @@ -1,56 +1,24 @@ -// -// RDReaderFlowLayout.swift -// RDReaderDemo -// -// Created by yangsq on 2021/8/6. -// -// 文件职责:自定义 UICollectionViewFlowLayout,支持阅读器的三种滚动布局模式。 -// 该文件实现了水平滚动(全屏宽 item + 分页)、垂直滚动(可变高度)和 -// 封面感知的双页布局(含封面页独占整屏逻辑)。 -// -// 架构位置:RDReaderView 四层架构中第三层的布局组件,被 RDReaderView 持有和配置。 -// import UIKit -/// 流式布局数据源协议 -/// 提供垂直滚动模式下各页面的高度信息 public protocol RDReaderFlowLayoutDataSoure: NSObjectProtocol { - /// 返回垂直滚动模式下指定页的高度 - /// - Parameters: - /// - flowLayout: 发起请求的布局对象 - /// - pageIndex: 页码索引 - /// - Returns: 该页的高度,nil 表示使用默认高度(collectionView.frame.height) + func heigtOfVerticalScrollPage(flowLayout: RDReaderFlowLayout, pageIndex: Int) -> CGFloat? } -/// 流式布局代理协议 -/// 当当前可见页码变化时通知外部 @objc public protocol RDReaderFlowLayoutDelegate: NSObjectProtocol { - /// 页码变化回调 - /// - Parameters: - /// - flowLayout: 发起回调的布局对象 - /// - pageIndex: 当前页码 + func pageNum(flowLayout: RDReaderFlowLayout, pageIndex: Int) } -/// 自定义流式布局,支持阅读器的三种滚动布局模式: -/// - horizontalScroll:水平滚动,每屏1项(竖屏)或2项(横屏双页),支持分页 -/// - verticalScroll:垂直滚动,全宽项目,支持可变高度 -/// -/// 通过 ``displayType`` 属性切换布局模式。 -/// 通过 ``isLandscapeDualPage`` 和 ``coverPageIndex`` 控制横屏双页和封面页逻辑。 public class RDReaderFlowLayout: UICollectionViewFlowLayout { - /// 布局模式,切换时自动使布局失效并重新计算 var displayType: RDReaderView.DisplayType = .horizontalScroll { didSet { invalidateLayout() } } - /// 是否启用横屏双页模式 - /// 启用后水平滚动模式下每屏显示两项(各占半屏宽度) var isLandscapeDualPage: Bool = false { didSet { if oldValue != isLandscapeDualPage { @@ -59,8 +27,6 @@ public class RDReaderFlowLayout: UICollectionViewFlowLayout { } } - /// 封面页索引 - /// 设置后封面页独占整屏,后续页面两两配对各占半屏 var coverPageIndex: Int? = nil { didSet { if oldValue != coverPageIndex { @@ -69,11 +35,8 @@ public class RDReaderFlowLayout: UICollectionViewFlowLayout { } } - /// 上一次 prepare 时的 bounds 尺寸,用于检测尺寸变化 private var lastPreparedBoundsSize: CGSize = .zero - /// 每屏显示的页数 - /// 仅在水平滚动+横屏双页模式下返回2,其他情况返回1 var pagesPerScreen: Int { guard isLandscapeDualPage else { return 1 } switch displayType { @@ -87,24 +50,14 @@ public class RDReaderFlowLayout: UICollectionViewFlowLayout { } } - /// 流式布局数据源,提供垂直滚动模式的页面高度 weak var dataSource: RDReaderFlowLayoutDataSoure? = nil - /// 流式布局代理,接收页码变化通知 + weak var delegate: RDReaderFlowLayoutDelegate? = nil - /// 是否在双页模式下有封面页独占 private var hasCoverPageInDualMode: Bool { return pagesPerScreen > 1 && coverPageIndex != nil } - /// 封面感知的帧计算:根据索引计算 item 的 frame - /// 封面页独占整屏,后续页面两两配对各占半屏 - /// - Parameters: - /// - index: item 索引 - /// - screenWidth: 屏幕宽度 - /// - halfWidth: 半屏宽度 - /// - height: 屏幕高度 - /// - Returns: 该 item 的 frame private func coverAwareFrame(for index: Int, screenWidth: CGFloat, halfWidth: CGFloat, height: CGFloat) -> CGRect { guard let coverIndex = coverPageIndex else { let pairIdx = index / 2 @@ -125,11 +78,6 @@ public class RDReaderFlowLayout: UICollectionViewFlowLayout { return CGRect(x: x, y: 0, width: halfWidth, height: height) } - /// 封面感知的屏幕起始索引计算:根据滚动偏移量计算当前屏幕的第一个 item 索引 - /// - Parameters: - /// - offset: 当前滚动偏移量 - /// - screenWidth: 屏幕宽度 - /// - Returns: 当前屏幕第一个 item 的索引 private func coverAwareStartIndex(for offset: CGFloat, screenWidth: CGFloat) -> Int { let pps = pagesPerScreen guard let coverIdx = coverPageIndex, hasCoverPageInDualMode else { @@ -147,7 +95,6 @@ public class RDReaderFlowLayout: UICollectionViewFlowLayout { } } - /// 当前可见的页码,值变化时通过 delegate 通知外部 private var currentPage: Int = 0 { didSet { @@ -157,15 +104,12 @@ public class RDReaderFlowLayout: UICollectionViewFlowLayout { } } - /// 初始化方法 - /// - Parameter displayType: 初始布局模式 init(displayType: RDReaderView.DisplayType) { self.displayType = displayType super.init() } - /// 布局准备阶段,配置 item 尺寸、滚动方向和分页行为 public override func prepare() { super.prepare() guard let collectionView = self.collectionView else { @@ -203,8 +147,6 @@ public class RDReaderFlowLayout: UICollectionViewFlowLayout { } - /// 判断 bounds 变化是否需要使布局失效 - /// 尺寸变化时总是返回 true,布局模式变化也返回 true public override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { if newBounds.size != lastPreparedBoundsSize { return true @@ -219,9 +161,6 @@ public class RDReaderFlowLayout: UICollectionViewFlowLayout { } } - /// 计算 collectionView 的总内容尺寸 - /// 垂直滚动模式:累加所有页面高度 - /// 封面双页模式:封面占一屏 + 后续页面按两两配对计算屏幕数 public override var collectionViewContentSize: CGSize { var size = super.collectionViewContentSize guard let collectionView = collectionView else { @@ -243,9 +182,6 @@ public class RDReaderFlowLayout: UICollectionViewFlowLayout { return size } - /// 计算指定矩形区域内的布局属性 - /// 垂直滚动模式:根据可变高度定位每个 item - /// 水平滚动模式:根据当前偏移量计算当前页码,清除 cell 阴影 public override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { var attributes = super.layoutAttributesForElements(in: rect) guard let collectionView = collectionView else { @@ -279,7 +215,6 @@ public class RDReaderFlowLayout: UICollectionViewFlowLayout { }) } - if self.displayType == .horizontalScroll { let pps = pagesPerScreen @@ -325,10 +260,6 @@ public class RDReaderFlowLayout: UICollectionViewFlowLayout { return attributes } - /// 根据页码计算对应的 contentOffset - /// 用于跳转到指定页面时设置滚动位置 - /// - Parameter count: 目标页码 - /// - Returns: 对应的 contentOffset 坐标 func currentContentOffset(count: Int) -> CGPoint { guard let collectionView = collectionView else { return .zero @@ -368,7 +299,6 @@ public class RDReaderFlowLayout: UICollectionViewFlowLayout { } } - required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } diff --git a/Sources/RDReaderView/ReaderView/RDReaderGestureController.swift b/Sources/RDReaderView/ReaderView/RDReaderGestureController.swift index 3861cf7..1aabe30 100644 --- a/Sources/RDReaderView/ReaderView/RDReaderGestureController.swift +++ b/Sources/RDReaderView/ReaderView/RDReaderGestureController.swift @@ -1,33 +1,12 @@ -// -// RDReaderGestureController.swift -// RDReaderDemo -// -// Created by yangsq on 2021/8/10. -// -// 文件职责:手势控制器(当前为占位组件)。 -// 该控制器持有顶部和底部工具栏视图,预留了手势管理的扩展点。 -// 目前仅作为数据容器,未实现具体的手势识别逻辑。 -// 实际的手势识别由 RDReaderView 中的 tapGestureRecognizer 处理。 -// -// 架构位置:RDReaderView 四层架构中第三层的辅助组件。 -// import UIKit - -/// 手势控制器(占位组件) -/// 持有顶部和底部工具栏视图,预留手势管理扩展。 -/// 当前功能由 RDReaderView 直接处理,该类未被实际使用。 class RDReaderGestureController: UIViewController { - /// 顶部工具栏视图 + var topToolView: UIView? - /// 底部工具栏视图 + var bottomToolView: UIView? - /// 初始化方法 - /// - Parameters: - /// - topToolView: 顶部工具栏视图 - /// - bottomToolView: 底部工具栏视图 init(topToolView: UIView?, bottomToolView: UIView?) { self.topToolView = topToolView self.bottomToolView = bottomToolView @@ -41,18 +20,6 @@ class RDReaderGestureController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - // Do any additional setup after loading the view. } - - /* - // MARK: - Navigation - - // In a storyboard-based application, you will often want to do a little preparation before navigation - override func prepare(for segue: UIStoryboardSegue, sender: Any?) { - // Get the new view controller using segue.destination. - // Pass the selected object to the new view controller. - } - */ - } diff --git a/Sources/RDReaderView/ReaderView/RDReaderPageChildViewController.swift b/Sources/RDReaderView/ReaderView/RDReaderPageChildViewController.swift index 22c8e0e..7e21f24 100644 --- a/Sources/RDReaderView/ReaderView/RDReaderPageChildViewController.swift +++ b/Sources/RDReaderView/ReaderView/RDReaderPageChildViewController.swift @@ -1,28 +1,10 @@ -// -// RDReaderPageChildViewController.swift -// RDReaderDemo -// -// Created by yangsq on 2021/8/9. -// -// 文件职责:UIPageViewController 的子控制器,持有单页的内容视图和页码。 -// 在 pageCurl 仿真翻页模式下,每个页面由一个 RDReaderPageChildViewController 管理, -// 通过 contentContainerView 承载实际内容视图,并保持页码引用用于翻页状态追踪。 -// -// 架构位置:RDReaderView 四层架构中第三层的子控制器组件,被 UIPageViewController 使用。 -// import UIKit -/// 仿真翻页模式下的页面子控制器 -/// 作为 UIPageViewController 的子控制器,每个实例管理一页内容。 -/// 持有 ``contentView``(实际内容视图)和 ``pageNum``(页码标识), -/// 通过 contentContainerView 将内容视图填满控制器视图。 class RDReaderPageChildViewController: UIViewController { - /// 内容容器视图,作为 contentView 的父视图 + private let contentContainerView = UIView() - /// 实际内容视图 - /// 设置时如果视图已加载,会自动重新安装到 contentContainerView 中 var contentView: UIView? { didSet { guard isViewLoaded else { return } @@ -30,21 +12,14 @@ class RDReaderPageChildViewController: UIViewController { } } - /// 该控制器管理的页码标识 var pageNum: Int = 0 - /// 初始化方法 - /// - Parameters: - /// - contentView: 要显示的内容视图 - /// - pageNum: 该页的页码(默认0) init(contentView: UIView?, pageNum: Int = 0) { self.contentView = contentView self.pageNum = pageNum super.init(nibName: nil, bundle: nil) } - /// 加载视图时调用 - /// 创建透明背景的根视图,添加 contentContainerView,并安装内容视图 override func loadView() { view = UIView() view.backgroundColor = .clear @@ -61,11 +36,8 @@ class RDReaderPageChildViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - // Do any additional setup after loading the view. } - /// 安装内容视图到 contentContainerView 中 - /// 先移除 contentContainerView 中的所有子视图,再将 contentView 添加并填满 private func installContentView() { contentContainerView.subviews.forEach { $0.removeFromSuperview() } guard let contentView else { return } @@ -75,15 +47,4 @@ class RDReaderPageChildViewController: UIViewController { contentContainerView.addSubview(contentView) } - - /* - // MARK: - Navigation - - // In a storyboard-based application, you will often want to do a little preparation before navigation - override func prepare(for segue: UIStoryboardSegue, sender: Any?) { - // Get the new view controller using segue.destination. - // Pass the selected object to the new view controller. - } - */ - } diff --git a/Sources/RDReaderView/ReaderView/RDReaderView+CollectionView.swift b/Sources/RDReaderView/ReaderView/RDReaderView+CollectionView.swift index 7eff342..d1a5800 100644 --- a/Sources/RDReaderView/ReaderView/RDReaderView+CollectionView.swift +++ b/Sources/RDReaderView/ReaderView/RDReaderView+CollectionView.swift @@ -1,18 +1,8 @@ -// -// RDReaderView+CollectionView.swift -// ReadViewSDK -// -// 文件职责:UICollectionView 数据源和自定义布局代理实现,处理水平滚动和垂直滚动两种翻页模式。 -// import UIKit -/// UICollectionView 数据源和自定义布局代理实现 -/// 处理水平滚动和垂直滚动两种翻页模式 extension RDReaderView: UICollectionViewDataSource, RDReaderFlowLayoutDelegate, RDReaderFlowLayoutDataSoure { - /// 配置 UICollectionViewCell - /// 通过 DataSource 获取页面内容视图,支持 cell 复用和预加载视图复用 - /// RTL 水平模式下翻转 cell 内容使文字方向正常 + public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { if let identifer = pageReuseIdentifier(for: indexPath.row) { @@ -20,7 +10,7 @@ extension RDReaderView: UICollectionViewDataSource, RDReaderFlowLayoutDelegate, let preloadedView = preloadController.takePreloadedView(for: indexPath.row) let reusableView = cell.containerView ?? preloadedView cell.containerView = contentViewForPage(indexPath.row, reusableView: reusableView) - // RTL 水平模式:翻转 cell 内容使文字方向正常(collectionView 已整体翻转) + if pageDirection == .rightToLeft && currentDisplayType != .verticalScroll { cell.contentView.transform = CGAffineTransform(scaleX: -1, y: 1) } else { @@ -34,12 +24,10 @@ extension RDReaderView: UICollectionViewDataSource, RDReaderFlowLayoutDelegate, return cell } - /// 返回每组的 item 数量,即总页数 public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return numberOfPages() } - /// 布局代理回调:当前可见页码变化时通知 public func pageNum(flowLayout: RDReaderFlowLayout, pageIndex: Int) { if currentPage >= 0, currentPage != pageIndex { predictedPageDirection = pageIndex >= currentPage @@ -48,8 +36,6 @@ extension RDReaderView: UICollectionViewDataSource, RDReaderFlowLayoutDelegate, primePageCache(around: pageIndex, preferredForward: predictedPageDirection) } - /// 布局数据源回调:返回垂直滚动模式下指定页的高度 - /// 当前返回 nil 使用默认高度 public func heigtOfVerticalScrollPage(flowLayout: RDReaderFlowLayout, pageIndex: Int) -> CGFloat? { return nil } diff --git a/Sources/RDReaderView/ReaderView/RDReaderView+ContentAccess.swift b/Sources/RDReaderView/ReaderView/RDReaderView+ContentAccess.swift index 4e3f321..89f3ab8 100644 --- a/Sources/RDReaderView/ReaderView/RDReaderView+ContentAccess.swift +++ b/Sources/RDReaderView/ReaderView/RDReaderView+ContentAccess.swift @@ -1,23 +1,13 @@ -// -// RDReaderView+ContentAccess.swift -// ReadViewSDK -// -// 文件职责:内容视图注册、复用和页面内容访问,提供类似 UITableView 的 register/dequeueReusable 机制。 -// import UIKit -/// 内容视图注册和复用扩展 -/// 提供类似 UITableView 的 register/dequeueReusable 机制 extension RDReaderView { - /// 注册内容视图类型 + public func register(contentView: UIView.Type, contentViewWithReuseIdentifier identifier: String) { contentViews[identifier] = contentView collectionView.register(RDReaderContentCell.self, forCellWithReuseIdentifier: identifier) } - /// 获取可复用的内容视图 - /// 优先从当前显示的 cell 中获取,其次创建新实例 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 { return containerView @@ -28,8 +18,6 @@ extension RDReaderView { return contentView } - /// 获取指定页码的内容视图 - /// pageCurl 模式从 PageViewController 获取,滚动模式从 CollectionView cell 获取 public func pageContentView(pageNum: Int) -> UIView? { if currentDisplayType == .pageCurl { return (self.pageViewController.viewControllers?.first as? RDReaderPageChildViewController)?.contentView @@ -39,8 +27,6 @@ extension RDReaderView { } } - /// 返回当前阅读模式下单页内容容器的实际尺寸。 - /// 优先使用已经显示出来的内容视图尺寸;若页面尚未装载,则回退到当前模式下的理论单页尺寸。 public func resolvedSinglePageSize(pageNum: Int? = nil) -> CGSize { let targetPage = pageNum ?? (currentPage >= 0 ? currentPage : nil) if let targetPage, diff --git a/Sources/RDReaderView/ReaderView/RDReaderView+PageCurl.swift b/Sources/RDReaderView/ReaderView/RDReaderView+PageCurl.swift index a4aa296..1f22e88 100644 --- a/Sources/RDReaderView/ReaderView/RDReaderView+PageCurl.swift +++ b/Sources/RDReaderView/ReaderView/RDReaderView+PageCurl.swift @@ -1,18 +1,8 @@ -// -// RDReaderView+PageCurl.swift -// ReadViewSDK -// -// 文件职责:UIPageViewController 数据源和代理实现,处理仿真翻页模式下的页面数据供给和翻页事件。 -// import UIKit -/// UIPageViewController 数据源和代理实现 -/// 处理仿真翻页模式下的页面数据供给和翻页事件 extension RDReaderView: UIPageViewControllerDataSource, UIPageViewControllerDelegate { - /// 创建指定页码的子控制器 - /// 空白页返回空视图,正常页从缓存或数据源获取内容 private func makeSinglePageChildVC(for pageNum: Int) -> RDReaderPageChildViewController { if pageNum == RDReaderView.blankPageNum || pageNum == RDReaderView.blankEndPageNum { return RDReaderPageChildViewController(contentView: UIView(), pageNum: pageNum) @@ -21,12 +11,9 @@ extension RDReaderView: UIPageViewControllerDataSource, UIPageViewControllerDele return RDReaderPageChildViewController(contentView: contentView, pageNum: pageNum) } - /// 计算某页的"下一页"页码 - /// 考虑封面页独占和末尾空白页配对的情况 private func nextPageNum(after pageNum: Int, isDualPage: Bool) -> Int? { let totalPages = numberOfPages() - // 末尾空白页之后没有更多页 if pageNum == RDReaderView.blankEndPageNum { return nil } @@ -46,7 +33,6 @@ extension RDReaderView: UIPageViewControllerDataSource, UIPageViewControllerDele return next } - // pageNum 是最后一页,检查在双页模式下是否需要空白页配对 if isDualPage { if let coverIndex = coverPageIndex { let adjustedIndex = pageNum - (coverIndex + 1) @@ -62,8 +48,6 @@ extension RDReaderView: UIPageViewControllerDataSource, UIPageViewControllerDele return nil } - /// 计算某页的"上一页"页码 - /// 考虑封面页独占和末尾空白页配对的情况 private func prevPageNum(before pageNum: Int, isDualPage: Bool) -> Int? { if pageNum == RDReaderView.blankEndPageNum { let totalPages = numberOfPages() @@ -81,8 +65,6 @@ extension RDReaderView: UIPageViewControllerDataSource, UIPageViewControllerDele return prev >= 0 ? prev : nil } - /// UIPageViewController 数据源:返回当前页之前(左/上)的页面控制器 - /// RTL 模式下 before/after 语义互换:before(向右翻)= 下一页 public func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? { guard let vc = viewController as? RDReaderPageChildViewController else { return nil } let isDualPage = landscapeDualPageEnabled && isLandscape @@ -98,8 +80,6 @@ extension RDReaderView: UIPageViewControllerDataSource, UIPageViewControllerDele return targetVC } - /// UIPageViewController 数据源:返回当前页之后(右/下)的页面控制器 - /// RTL 模式下 before/after 语义互换 public func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? { guard let vc = viewController as? RDReaderPageChildViewController else { return nil } let isDualPage = landscapeDualPageEnabled && isLandscape @@ -115,8 +95,6 @@ extension RDReaderView: UIPageViewControllerDataSource, UIPageViewControllerDele return targetVC } - /// UIPageViewController 代理:翻页动画完成回调 - /// 更新当前页码,触发预加载,并检测 PageViewController 异常状态 public func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) { if completed, let firstVC = pageViewController.viewControllers?.first as? RDReaderPageChildViewController { let pn = firstVC.pageNum @@ -130,8 +108,6 @@ extension RDReaderView: UIPageViewControllerDataSource, UIPageViewControllerDele finishPageCurlTransition() } - /// UIPageViewController 代理:即将开始转场动画 - /// 记录预测的翻页方向,用于优化预加载策略 public func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) { pagingController.isTransitioning = true willTransitionToViewController = pendingViewControllers.first diff --git a/Sources/RDReaderView/ReaderView/RDReaderView+ToolView.swift b/Sources/RDReaderView/ReaderView/RDReaderView+ToolView.swift index dba0986..d56951f 100644 --- a/Sources/RDReaderView/ReaderView/RDReaderView+ToolView.swift +++ b/Sources/RDReaderView/ReaderView/RDReaderView+ToolView.swift @@ -1,16 +1,8 @@ -// -// RDReaderView+ToolView.swift -// ReadViewSDK -// -// 文件职责:工具栏管理扩展,处理工具栏的显示/隐藏动画、安装和布局约束。 -// import UIKit -/// 工具栏管理扩展:处理工具栏的显示/隐藏动画、安装、布局 extension RDReaderView { - /// 点击屏幕中央区域,切换工具栏的显示/隐藏,带动画效果 func tapCenter() { refreshToolViewsFromProviderIfNeeded() isShowToolView = !isShowToolView @@ -58,7 +50,6 @@ extension RDReaderView { onToolViewVisibilityChanged?(isShowToolView) } - /// 判断点击命中的视图是否在指定工具栏内,用于决定是否拦截点击事件 func isHitView(_ hitView: UIView?, inside toolView: UIView, point: CGPoint) -> Bool { if toolView.frame.contains(point) { return true @@ -68,15 +59,13 @@ extension RDReaderView { return hitView === toolView || hitView.isDescendant(of: toolView) } - /// 工具栏位置枚举 enum ToolViewPosition { - /// 顶部工具栏(如标题栏、导航栏) + case top - /// 底部工具栏(如进度条、操作按钮) + case bottom } - /// 将工具栏安装到指定位置并设置布局约束,已安装则跳过 func installToolViewIfNeeded(_ toolView: UIView, position: ToolViewPosition) { guard toolView.superview !== self else { return } @@ -109,7 +98,6 @@ extension RDReaderView { } } - /// 更新顶部和底部工具栏的高度约束,通常在 safeAreaInsets 变化时调用 func updateToolViewHeightConstraintsIfNeeded() { topToolViewHeightConstraint?.constant = resolvedToolViewHeight(for: .top) bottomToolViewHeightConstraint?.constant = resolvedToolViewHeight(for: .bottom) diff --git a/Sources/RDReaderView/ReaderView/RDReaderView.swift b/Sources/RDReaderView/ReaderView/RDReaderView.swift index 8ae1f6d..d59c5f3 100644 --- a/Sources/RDReaderView/ReaderView/RDReaderView.swift +++ b/Sources/RDReaderView/ReaderView/RDReaderView.swift @@ -1,58 +1,19 @@ -// -// RDReaderView.swift -// RDReaderDemo -// -// Created by yangsq on 2021/8/6. -// -// 文件职责:核心翻页容器视图,RDReaderView 模块的入口和中枢。 -// 该文件定义了阅读器的核心 UIView 子类 RDReaderView,负责: -// 1. 管理三种翻页模式(仿真翻页、水平滚动、垂直滚动) -// 2. 实现手势分区逻辑(左1/3上一页、中1/3工具栏、右1/3下一页) -// 3. 支持横屏双页显示(含封面页独占和页码配对算法) -// 4. 支持从左往右(LTR)和从右往左(RTL)两种翻页方向 -// 5. 提供 DataSource/Delegate 协议供上层控制器实现数据供给和事件回调 -// 6. 管理页面预加载和缓存机制 -// -// 架构位置:RDReaderView 四层架构中的第三层(翻页容器层) -// EPUBCore(解析引擎)→ EPUBTextRendering(文本渲染)→ RDReaderView(翻页容器)→ EPUBUI(读者 UI) -// import UIKit - -/// 核心翻页容器视图 -/// RDReaderView 模块的核心类,负责管理三种翻页模式的切换、手势识别、 -/// 横屏双页显示、页面缓存预加载等核心功能。 -/// -/// 使用方式: -/// 1. 设置 ``dataSource`` 提供页面数据 -/// 2. 设置 ``delegate`` 接收翻页事件 -/// 3. 调用 ``switchReaderDisplayType(_:)`` 切换翻页模式 -/// 4. 调用 ``reloadData()`` 刷新数据 -/// -/// 支持的功能: -/// - 三种翻页模式:pageCurl / horizontalScroll / verticalScroll -/// - 横屏双页显示(含封面页独占逻辑) -/// - RTL 翻页方向支持 -/// - 手势分区(左翻/工具栏/右翻) -/// - 页面预加载和缓存 public class RDReaderView: UIView { - /// 屏幕点击区域枚举,用于手势分区逻辑 - /// 将屏幕水平三等分,分别响应不同的手势事件 enum TapEvent { - /// 无操作 + case none - /// 左1/3区域:上一页(RTL 时为下一页) + case left - /// 中1/3区域:切换工具栏显示/隐藏 + case center - /// 右1/3区域:下一页(RTL 时为上一页) + case right } - /// 仿真翻页模式使用的 UIPageViewController - /// 通过 pageCurl 转场样式实现仿真翻书效果 lazy var pageViewController: UIPageViewController = { let pageVC = UIPageViewController(transitionStyle: .pageCurl, navigationOrientation: .horizontal, options: nil) pageVC.delegate = self @@ -60,7 +21,6 @@ public class RDReaderView: UIView { return pageVC }() - /// 自定义流式布局,支持水平滚动、垂直滚动和水平覆盖滚动三种布局模式 lazy var layout: RDReaderFlowLayout = { let layout = RDReaderFlowLayout(displayType: .horizontalScroll) layout.dataSource = self @@ -68,8 +28,6 @@ public class RDReaderView: UIView { return layout }() - /// 滚动模式使用的 UICollectionView - /// 用于 horizontalScroll 和 verticalScroll 两种翻页模式 lazy var collectionView: UICollectionView = { let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) @@ -77,14 +35,15 @@ public class RDReaderView: UIView { collectionView.accessibilityIdentifier = "epub.reader.paging" return collectionView }() + private let spreadResolver = RDReaderSpreadResolver() + private let tapRegionHandler = RDReaderTapRegionHandler() + let preloadController = RDReaderPreloadController() + var pagingController = RDReaderPagingController() - /// 当前显示的页码 - /// 值变化时会通过 delegate 回调通知上层控制器 - /// 初始值为 -1 表示尚未加载任何页面 public var currentPage: Int = -1 { didSet { if let delegate = delegate, currentPage != oldValue, delegate.responds(to: #selector(RDReaderDelegate.pageNum(readerView:pageNum:))) { @@ -93,16 +52,12 @@ public class RDReaderView: UIView { } } - /// 全屏点击手势识别器 - /// 用于检测用户点击屏幕的区域,触发翻页或工具栏切换 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 @@ -123,8 +78,6 @@ public class RDReaderView: UIView { } } - /// 翻到下一页 - /// 在双页模式下使用 adjacentDualPage 计算目标页码,单页模式直接 +1 private func goNextPage() { let totalPages = numberOfPages() if let target = spreadResolver.nextPage( @@ -138,8 +91,6 @@ public class RDReaderView: UIView { } } - /// 翻到上一页 - /// 在双页模式下使用 adjacentDualPage 计算目标页码,单页模式直接 -1 private func goPreviousPage() { let totalPages = numberOfPages() if let target = spreadResolver.nextPage( @@ -154,30 +105,29 @@ public class RDReaderView: UIView { } private let legacyDataSourceAdapter = RDReaderLegacyDataSourceAdapter() - /// 数据源代理,提供页面数量、内容视图和工具栏 + public weak var dataSource: RDReaderDataSource? { didSet { legacyDataSourceAdapter.dataSource = dataSource } } - /// 新的通用分页内容提供者,优先级高于 ``dataSource``。 + public weak var pageProvider: RDReaderPageProvider? - /// 代理,接收翻页和横竖屏切换事件 + public weak var delegate: RDReaderDelegate? = nil - /// 当前翻页模式,默认为仿真翻页 + public var currentDisplayType: RDReaderView.DisplayType = .pageCurl - /// 工具栏显示/隐藏动画时长,默认 0.3 秒 + public var toolViewAnimationDuration: TimeInterval = 0.3 - /// 工具栏可见性变化回调,参数为是否可见 + var onToolViewVisibilityChanged: ((Bool) -> Void)? - /// 搜索栏视图,点击时不触发 tapCenter + var searchBarView: UIView? - /// 是否启用横屏双页显示 + public var landscapeDualPageEnabled: Bool = false - /// 翻页方向,默认从左往右(适用于中文/英文书籍) + public var pageDirection: RDReaderView.PageDirection = .leftToRight - /// 横屏双页模式下,封面页的索引。设置后该页在横屏时独占一屏,后续页面两两配对。 - /// 设为 nil 表示没有封面页(所有页面正常两两配对:0+1, 2+3, 4+5...)。 + public var coverPageIndex: Int? = nil private var resolvedPageProvider: RDReaderPageProvider? { @@ -213,27 +163,20 @@ public class RDReaderView: UIView { } } - /// 是否启用了封面页独占 private var hasCoverPage: Bool { return coverPageIndex != nil } - /// 当前是否横屏(宽度 > 高度) var isLandscape: Bool { return bounds.width > bounds.height } - /// 每屏显示的页数 - /// 未启用双页、垂直滚动模式或竖屏时返回1,横屏双页模式返回2 public var pagesPerScreen: Int { if !landscapeDualPageEnabled { return 1 } if currentDisplayType == .verticalScroll { return 1 } return isLandscape ? 2 : 1 } - /// 判断某页在横屏双页模式下是否独占一屏(封面页) - /// - Parameter pageNum: 页码 - /// - Returns: 是否独占一屏 public func isFullScreenPage(_ pageNum: Int) -> Bool { spreadResolver.isFullScreenPage( pageNum, @@ -243,10 +186,6 @@ public class RDReaderView: UIView { ) } - /// 根据逻辑页码计算横屏双页模式下的配对信息 - /// 封面页独占一屏,后续页面两两配对 - /// - Parameter pageNum: 逻辑页码 - /// - Returns: (左页码, 右页码(可选,nil表示右页为空白)) private func dualPagePair(for pageNum: Int) -> (left: Int, right: Int?) { let totalPages = numberOfPages() return spreadResolver.dualPagePair( @@ -256,11 +195,6 @@ public class RDReaderView: UIView { ) } - /// 计算横屏双页下,某页往前/后翻一屏后的起始页码 - /// - Parameters: - /// - pageNum: 当前页码 - /// - forward: 是否向后翻(true=下一页,false=上一页) - /// - Returns: 目标页码,nil 表示到头了 private func adjacentDualPage(from pageNum: Int, forward: Bool) -> Int? { let totalPages = numberOfPages() return spreadResolver.adjacentDualPage( @@ -271,60 +205,63 @@ public class RDReaderView: UIView { ) } - /// 将页码钳制到当前书籍的合法范围内 private func clampedPageNumber(_ pageNum: Int) -> Int? { let totalPages = numberOfPages() guard totalPages > 0 else { return nil } return min(max(pageNum, 0), totalPages - 1) } - /// 上一次检测到的横竖屏状态,用于在 layoutSubviews 中检测方向变化 private var previousIsLandscape: Bool? - /// 已注册的内容视图类型字典,key 为重用标识符 var contentViews = [String : UIView.Type]() - /// pageCurl 模式下,即将向前翻转到的子控制器 + var willPreviousTransitionToViewController: UIViewController? = nil - /// pageCurl 模式下,即将向后翻转到的子控制器 + var willNextTransitionToViewController: UIViewController? = nil - /// pageCurl 模式下,正在转场的目标控制器 + 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 } } - /// UI 是否已经构建完成(避免重复构建) + private var didBuildUI: Bool { get { pagingController.didBuildUI } set { pagingController.didBuildUI = newValue } } - /// 预测的翻页方向(true=向前,false=向后),用于优化预加载方向 + var predictedPageDirection: Bool? - /// 预加载半径:当前页前后各预加载几屏,默认 1 屏 + public var preloadRadius: Int { get { preloadController.radius } set { preloadController.radius = newValue } } - /// 用于 pageCurl 双页模式下封面页旁边的空白页标识 static let blankPageNum = Int.max - /// 用于 pageCurl 双页模式下末尾不成对页旁边的空白页标识 + static let blankEndPageNum = Int.max - 1 - /// 当 pageCurl 正在转场时,后续跳转请求会先排队,待当前动画稳定后再执行。 private typealias PageTransitionRequest = RDReaderPagingController.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 } @@ -336,8 +273,6 @@ public class RDReaderView: UIView { isAccessibilityElement = false } - /// 布局子视图时调用 - /// 检测横竖屏方向变化,触发相应的布局更新和页面重建 public override func layoutSubviews() { super.layoutSubviews() guard bounds.width > 0, bounds.height > 0 else { return } @@ -346,8 +281,7 @@ public class RDReaderView: UIView { let nowLandscape = isLandscape if let prev = previousIsLandscape, prev != nowLandscape { previousIsLandscape = nowLandscape - // 延迟到下一个 RunLoop 执行,避免在 layoutSubviews 中嵌套触发 reloadData/layoutIfNeeded - // 造成 collectionView 中间态尺寸不一致的问题 + DispatchQueue.main.async { [weak self] in guard let self = self else { return } self.orientationChanged(isNowLandscape: nowLandscape) @@ -358,33 +292,28 @@ public class RDReaderView: UIView { } } - /// 横竖屏切换处理 - /// 根据当前翻页模式执行不同的重建策略: - /// - pageCurl:重建 UIPageViewController(因为 spineLocation 只能在初始化时设置) - /// - 滚动模式:禁用动画,重新加载数据并恢复滚动位置 - /// - Parameter isNowLandscape: 当前是否为横屏 private func orientationChanged(isNowLandscape: Bool) { let savedPage = max(0, currentPage) - // 1. 通知代理方向变化。正文级重分页由上层控制器统一接管,这里只保留容器级刷新钩子。 + delegate?.readerViewOrientationWillChange?(readerView: self, isLandscape: isNowLandscape) - // 2. 更新布局的横屏双页标记 + layout.isLandscapeDualPage = landscapeDualPageEnabled && isNowLandscape layout.coverPageIndex = coverPageIndex switch currentDisplayType { case .pageCurl: - // 仿真翻页:通过 spineLocation 原生支持双页,需要重建 PageViewController + rebuildPageViewController() transitionToPage(pageNum: savedPage) primePageCache(around: savedPage, preferredForward: predictedPageDirection) default: - // 滚动模式:禁用动画防止旋转过渡中出现尺寸抖动 + UIView.performWithoutAnimation { - // 强制使布局完全失效并重新计算 + collectionView.collectionViewLayout.invalidateLayout() collectionView.reloadData() collectionView.layoutIfNeeded() - // 3. 根据保存的页码恢复滚动位置 + let totalPages = numberOfPages() let safePage = min(savedPage, max(0, totalPages - 1)) let targetOffset = layout.currentContentOffset(count: safePage) @@ -394,9 +323,6 @@ public class RDReaderView: UIView { } } - /// 创建带指定 spine 位置的 UIPageViewController - /// - Parameter isDualPage: 是否双页模式(横屏时书脊在中间:.mid) - /// - Returns: 配置好的 UIPageViewController 实例 private func createPageViewController(isDualPage: Bool) -> UIPageViewController { let options: [UIPageViewController.OptionsKey: Any]? if isDualPage { @@ -411,9 +337,6 @@ public class RDReaderView: UIView { return pageVC } - /// 重建 UIPageViewController - /// 横竖屏切换时需要重建,因为 spineLocation 只能在初始化时设置 - /// 先移除旧的 PageViewController,再创建新的并添加到父控制器 private func rebuildPageViewController() { detachPageViewControllerIfNeeded() @@ -423,12 +346,6 @@ public class RDReaderView: UIView { attachPageViewControllerIfNeeded() } - // MARK: - UIPageViewController Fault Detection - - /// 检测 UIPageViewController 是否处于异常状态 - /// 异常状态包括:子控制器数量不对、页码不匹配等 - /// - Parameter pageVC: 要检测的 UIPageViewController - /// - Returns: true 表示存在异常,需要修复 private func detectPageViewControllerFault(_ pageVC: UIPageViewController) -> Bool { guard currentDisplayType == .pageCurl else { return false } let expectedCount = (landscapeDualPageEnabled && isLandscape) ? 2 : 1 @@ -453,8 +370,6 @@ public class RDReaderView: UIView { || childViewControllers[1].pageNum != expectedRightPage } - /// 修复 UIPageViewController 异常状态 - /// 清空缓存、重建 PageViewController 并重新定位到当前页 private func patchPageViewControllerFault() { guard currentPage >= 0 else { return } DispatchQueue.main.async { [weak self] in @@ -469,12 +384,10 @@ public class RDReaderView: UIView { } } - /// pageCurl 动画过程中如果收到新的跳转请求,则排队等待当前动画结束。 private func shouldQueuePageTransition(_ request: PageTransitionRequest) -> Bool { pagingController.shouldQueuePageTransition(request, currentDisplayType: currentDisplayType) } - /// 收尾 pageCurl 转场,并尝试执行排队中的后续跳转。 func finishPageCurlTransition(repairFaultIfNeeded: Bool = true) { willPreviousTransitionToViewController = nil willNextTransitionToViewController = nil @@ -490,8 +403,6 @@ public class RDReaderView: UIView { } } - // MARK: - Preload / Forecast - private var preloadEnvironment: RDReaderPreloadController.Environment { RDReaderPreloadController.Environment( displayType: currentDisplayType, @@ -531,7 +442,6 @@ public class RDReaderView: UIView { contentViewForPage(pageNum, reusableView: reusableView) } - /// 视图被添加到父视图时调用,触发 UI 构建 public override func didMoveToSuperview() { super.didMoveToSuperview() @@ -540,8 +450,6 @@ public class RDReaderView: UIView { } } - /// 将 pageViewController 添加到父控制器和视图层级 - /// 如果已在父控制器中则跳过,确保生命周期方法正确调用 private func attachPageViewControllerIfNeeded() { guard let parentViewController = self.ss_superViewController else { return } @@ -571,8 +479,6 @@ public class RDReaderView: UIView { } } - /// 从父控制器和视图层级中移除 pageViewController - /// 确保 willMove/didMove 生命周期方法正确调用 private func detachPageViewControllerIfNeeded() { if pageViewController.parent != nil { pageViewController.willMove(toParent: nil) @@ -583,8 +489,6 @@ public class RDReaderView: UIView { } } - /// 构建 UI 界面 - /// 首次调用时注册 cell、添加手势识别器、根据当前翻页模式添加对应视图 private func makeUI() { guard !didBuildUI else { if currentDisplayType == .pageCurl { @@ -606,21 +510,80 @@ public class RDReaderView: UIView { preloadController.initializeSignature(preloadEnvironment) addGestureRecognizer(tapGestureRecognizer) - // 不取消底层触摸事件,确保工具栏按钮(返回等)的 touchUpInside 能正常触发 + tapGestureRecognizer.cancelsTouchesInView = false } - /// 点击手势响应方法 - /// 根据点击位置将屏幕三等分,判断点击区域并设置 tapEvent @objc private func tapAction(tap: UITapGestureRecognizer) { let point = tap.location(in: tap.view) + let hitView = hitTest(point, with: nil) + if containsTextContentView(in: hitView) { + return + } + if shouldSuppressChromeToggle(for: hitView, point: point) { + return + } + handleResolvedTap(at: point, hitView: hitView, in: tap.view) + } + + private func shouldSuppressChromeToggle(for hitView: UIView?, point: CGPoint) -> Bool { + if selectionTapSuppressedContentViews.allObjects.isEmpty == false { + return true + } + var currentView = hitView + while let view = currentView { + if let textContentView = view as? RDEPUBTextContentView { + let localPoint = convert(point, to: textContentView) + return textContentView.shouldSuppressReaderTap(at: localPoint) + } + 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) + } + } + + func updateSelectionPagingSuppression(for contentView: UIView, isSuppressed: Bool) { + if isSuppressed { + if selectionPagingSuppressedContentViews.allObjects.contains(where: { $0 === contentView }) == false { + selectionPagingSuppressedContentViews.add(contentView) + } + } else { + selectionPagingSuppressedContentViews.remove(contentView) + } + updatePagingInteractionSuppression() + } + + func handleContentTap(at point: CGPoint, in sourceView: UIView) { + let localPoint = sourceView.convert(point, to: self) + handleResolvedTap(at: localPoint, hitView: nil, in: self) + } + + private func handleResolvedTap(at point: CGPoint, hitView: UIView?, in tapView: UIView?) { if isShowToolView { - let hitView = hitTest(point, with: nil) if let top = topToolView, isHitView(hitView, inside: top, point: point) { return } if let bottom = bottomToolView, isHitView(hitView, inside: bottom, point: point) { return } } - guard let viewFrame = tap.view?.frame else { return } + guard let viewFrame = tapView?.frame else { return } tapEvent = tapRegionHandler.resolveTapEvent( point: point, viewFrame: viewFrame, @@ -628,9 +591,29 @@ public class RDReaderView: UIView { ) } - /// 切换翻页模式(仿真/水平滚动/上下滚动) - /// 会重建底层视图(PageViewController 或 CollectionView),并恢复到当前页 - /// - Parameter displayType: 目标翻页模式 + 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 = selectionPagingSuppressedContentViews.allObjects.isEmpty == false + guard shouldSuppress != isPagingInteractionSuppressed else { return } + isPagingInteractionSuppressed = shouldSuppress + setPagingInteractionEnabled(!shouldSuppress) + } + + private func setPagingInteractionEnabled(_ isEnabled: Bool) { + collectionView.isScrollEnabled = isEnabled + pageViewController.gestureRecognizers.forEach { $0.isEnabled = isEnabled } + } + public func switchReaderDisplayType(_ displayType: RDReaderView.DisplayType) { let previousDisplayType = currentDisplayType self.currentDisplayType = displayType @@ -640,7 +623,7 @@ public class RDReaderView: UIView { if previousDisplayType != displayType { invalidatePageCaches() } - // 同步横屏双页标记到布局 + layout.isLandscapeDualPage = landscapeDualPageEnabled && isLandscape layout.coverPageIndex = coverPageIndex switch displayType { @@ -653,13 +636,13 @@ public class RDReaderView: UIView { primePageCache(around: currentPage, preferredForward: predictedPageDirection) default: detachPageViewControllerIfNeeded() - // RTL 水平模式翻转 collectionView + if pageDirection == .rightToLeft && displayType != .verticalScroll { collectionView.transform = CGAffineTransform(scaleX: -1, y: 1) } else { collectionView.transform = .identity } - // 确保 collectionView frame 正确后再触发布局计算 + collectionView.frame = CGRect(x: 0, y: 0, width: frame.width, height: frame.height) insertSubview(self.collectionView, at: 0) layout.displayType = displayType @@ -668,13 +651,6 @@ public class RDReaderView: UIView { } } - /// 跳转到指定页码 - /// 支持所有翻页模式: - /// - pageCurl:通过 UIPageViewController.setViewControllers 实现 - /// - 滚动模式:通过 setContentOffset 实现 - /// - Parameters: - /// - pageNum: 目标页码(item 索引) - /// - animated: 是否动画过渡 public func transitionToPage(pageNum: Int, animated: Bool = false) { guard let safePageNum = clampedPageNumber(pageNum) else { return } switch currentDisplayType { @@ -685,7 +661,7 @@ public class RDReaderView: UIView { } let isDualPage = landscapeDualPageEnabled && isLandscape - // RTL 时动画方向需要反转 + let direction: UIPageViewController.NavigationDirection if pageDirection == .rightToLeft { direction = safePageNum > currentPage ? .reverse : .forward @@ -707,7 +683,7 @@ public class RDReaderView: UIView { self.finishPageCurlTransition() } } else { - // 封面页独占或奇数最后一页:右侧放空白页 + let blankNum = isFullScreenPage(pair.left) ? RDReaderView.blankPageNum : RDReaderView.blankEndPageNum let emptyVC = RDReaderPageChildViewController(contentView: UIView(), pageNum: blankNum) pageViewController.setViewControllers([leftVC, emptyVC], direction: animated ? direction : .forward, animated: animated) { [weak self] _ in @@ -738,22 +714,18 @@ public class RDReaderView: UIView { } } - /// 重新加载数据 - /// 重新切换到当前翻页模式,并刷新工具栏 public func reloadData() { switchReaderDisplayType(currentDisplayType) topToolView = resolvedTopChromeView() bottomToolView = resolvedBottomChromeView() } - /// 仅刷新总页数,不重建页面内容(用于后台元数据解析期间避免刷新掉用户选区) public func reloadPageCountOnly() { collectionView.reloadData() topToolView = resolvedTopChromeView() bottomToolView = resolvedBottomChromeView() } - required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -774,7 +746,14 @@ extension RDReaderView: UIGestureRecognizerDelegate { public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { guard gestureRecognizer === tapGestureRecognizer else { return true } + if selectionTapSuppressedContentViews.allObjects.isEmpty == false { + return false + } + let point = touch.location(in: self) + if containsTextContentView(in: touch.view) { + return false + } if let topToolView, isHitView(touch.view, inside: topToolView, point: point) { return false } @@ -795,14 +774,10 @@ extension RDReaderView: UIGestureRecognizerDelegate { } } - - private var cellViewKey: Int8 = 0 -/// UIView 扩展:通过响应链查找最近的父 UIViewController extension UIView { - /// 沿响应链向上查找最近的 UIViewController - /// 用于在视图中获取其所在控制器的引用 + var ss_superViewController: UIViewController? { var next = self.next while next != nil { diff --git a/Sources/RDReaderView/ReaderView/RDReaderViewProtocols.swift b/Sources/RDReaderView/ReaderView/RDReaderViewProtocols.swift index 46f8847..611e69b 100644 --- a/Sources/RDReaderView/ReaderView/RDReaderViewProtocols.swift +++ b/Sources/RDReaderView/ReaderView/RDReaderViewProtocols.swift @@ -1,91 +1,69 @@ -// -// RDReaderViewProtocols.swift -// ReadViewSDK -// -// 文件职责:阅读器核心协议定义,包括数据源、代理、页面提供者、页面导航等接口。 -// import UIKit -/// 阅读器数据源协议 -/// 上层控制器通过实现此协议,向 RDReaderView 提供页面数量、内容视图和工具栏。 -/// 所有方法由 RDReaderView 在需要渲染页面时回调。 @objc public protocol RDReaderDataSource: NSObjectProtocol { - /// 返回阅读器的总页数 + func pageCountOfReaderView(readerView: RDReaderView) -> Int - /// 返回指定页码的内容视图 + func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView - /// 返回指定页码的唯一标识符,用于 UICollectionViewCell 复用 + func pageIdentifier(readerView: RDReaderView, pageNum: Int) -> String? - /// 返回顶部工具栏视图(可选),点击屏幕中央时会显示/隐藏 + @objc optional func topToolView(readerView: RDReaderView) -> UIView? - /// 返回底部工具栏视图(可选),点击屏幕中央时会显示/隐藏 + @objc optional func bottomToolView(readerView: RDReaderView) -> UIView? } -/// 与内容格式无关的统一分页提供者协议。 -/// 逐步替代面向 EPUB 命名的 ``RDReaderDataSource``,方便后续复用到 PDF 等其他阅读场景。 @objc public protocol RDReaderPageProvider: NSObjectProtocol { - /// 返回阅读器的总页数 + func numberOfPages(in readerView: RDReaderView) -> Int - /// 返回指定页码的内容视图,支持通过 reusableView 复用已有视图 + func readerView(_ readerView: RDReaderView, viewForPageAt index: Int, reusableView: UIView?) -> UIView - /// 返回指定页码的唯一标识符,用于 UICollectionViewCell 复用 + @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) } -// MARK: - 枚举 - extension RDReaderView { - /// 翻页模式枚举,决定 RDReaderView 使用哪种底层视图来展示内容 + public enum DisplayType { - /// 仿真翻页:使用 UIPageViewController,模拟真实翻书效果 + case pageCurl - /// 水平滚动:使用 UICollectionView + 自定义布局,每屏一项,水平分页 + case horizontalScroll - /// 垂直滚动:使用 UICollectionView,全宽项目,垂直连续滚动 + case verticalScroll } - /// 翻页方向枚举 public enum PageDirection { - /// 从左往右翻页(默认,适用于中文/英文书籍) + case leftToRight - /// 从右往左翻页(适用于日文漫画等) + case rightToLeft } } -// MARK: - Legacy 适配器 - -/// 旧版数据源适配器 -/// 将 ``RDReaderDataSource`` 协议适配为 ``RDReaderPageProvider``,实现渐进式迁移。 final class RDReaderLegacyDataSourceAdapter: NSObject, RDReaderPageProvider { - /// 被适配的旧版数据源 + weak var dataSource: RDReaderDataSource? func numberOfPages(in readerView: RDReaderView) -> Int { diff --git a/_ssoft-output/implementation-artifacts/spec-cfi-11-issues-fix.md b/_ssoft-output/implementation-artifacts/spec-cfi-11-issues-fix.md new file mode 100644 index 0000000..ed315b9 --- /dev/null +++ b/_ssoft-output/implementation-artifacts/spec-cfi-11-issues-fix.md @@ -0,0 +1,151 @@ +--- +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