feat: EPUB阅读器搜索、注释、CFI模块及大书远距跳转优化
- 实现EPUB阅读器搜索功能及选中注释功能 - 优化CFI模块,修复代码审查发现的11个问题 - 实现大书远距目录跳转与后台补全优化方案 - 优化设置面板与章节运行时联动 - 重构及大量改进优化
This commit is contained in:
@@ -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<String>` | `["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?
|
||||
}
|
||||
```
|
||||
@@ -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), // <body> 的第 2 个元素子节点
|
||||
RDEPUBCFIStep(index: 2, idAssertion: fragmentID) // 第 1 个文本节点
|
||||
])
|
||||
```
|
||||
|
||||
**问题:** `makeOffsetCFI` 硬编码 content path 为 `/4/2`,即假设目标文本在 `<body>` 的第 2 个元素子节点的第 1 个文本子节点中。以下情况会出错:
|
||||
|
||||
| 场景 | 预期 content path | 实际生成 |
|
||||
|------|-------------------|----------|
|
||||
| 文本直接在 `<body>` 下 | `/4/N`(N 为文本节点奇数索引) | `/4/2` ❌ |
|
||||
| 文本在 3 层嵌套中 | `/4/2/2/2` | `/4/2` ❌ |
|
||||
| `<body>` 前有注释节点 | 索引需要偏移 | `/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: #"</?\s*([A-Za-z][A-Za-z0-9:_-]*)([^>]*)>"#,
|
||||
options: [.caseInsensitive]
|
||||
) else { return [:] }
|
||||
```
|
||||
|
||||
**边界情况清单:**
|
||||
|
||||
| 输入 | 后果 |
|
||||
|------|------|
|
||||
| `<!-- <div> -->` | 正则匹配注释内的 `<div>`,导致错误的子节点计数 |
|
||||
| `<![CDATA[ <tag> ]]>` | 同上 |
|
||||
| `<div data-value="a>b">` | 属性值中的 `>` 导致正则截断 |
|
||||
| `<input checked>` | 无值属性解析正常,但 `idAttribute` 正则要求引号 |
|
||||
| `<script>if (a < b) {}</script>` | `< b` 可能被匹配为标签 |
|
||||
| `<br/>` vs `<br />` | `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)..<close])
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**问题:** EPUB CFI 规范中,text assertion 内的 `[` 和 `]` 可以用反斜杠转义(`\[` 和 `\]`)。当前实现用 `firstIndex(of: "[")` 和 `lastIndex(of: "]")` 找括号,不处理转义。
|
||||
|
||||
**示例:** CFI `epubcfi(/6/4[chap01]!/4/2/1:3[前缀,文\[本\],后缀])` 中,精确文本是 `文[本]`,但当前实现会错误地在 `\]` 处截断。
|
||||
|
||||
---
|
||||
|
||||
### 7. Recovery Engine 的 token 匹配过于宽泛
|
||||
|
||||
**文件:** `RDEPUBCFIRecoveryEngine.swift:136-139`
|
||||
|
||||
```swift
|
||||
let candidateAnchors = cfiMap.recoveryMetadata.tokenIndex.filter { anchor in
|
||||
exact.contains(anchor.token) || anchor.token.contains(exact)
|
||||
}
|
||||
```
|
||||
|
||||
**问题:** 双向 `contains` 匹配会导致大量误匹配:
|
||||
|
||||
- `exact = "的"` 会匹配所有包含 "的" 的 token(中文中极其常见)
|
||||
- `anchor.token = "这是一个很长的句子"` 如果 `exact = "是"`,也会匹配
|
||||
- 英文中 `exact = "the"` 同样会匹配大量 token
|
||||
|
||||
**建议:**
|
||||
1. 设置最小 token 长度阈值(如 ≥ 4 字符)
|
||||
2. 改为前缀/后缀匹配而非任意包含
|
||||
3. 要求 token 与 exact 的长度比在合理范围内(如 0.5 ~ 2.0)
|
||||
|
||||
---
|
||||
|
||||
### 8. `makeOffsetRangeCFI` 的 end offset 边界
|
||||
|
||||
**文件:** `RDEPUBCFIGenerator.swift:78-93`
|
||||
|
||||
```swift
|
||||
let start = makeOffsetCFI(
|
||||
href: href, fileIndex: fileIndex,
|
||||
chapterOffset: startOffset, // startOffset 可能为负
|
||||
sideBias: .before, ...
|
||||
)
|
||||
let end = makeOffsetCFI(
|
||||
href: href, fileIndex: fileIndex,
|
||||
chapterOffset: max(startOffset, endOffset), // 基于原始 startOffset
|
||||
sideBias: .after, ...
|
||||
)
|
||||
```
|
||||
|
||||
**问题:** 如果 `startOffset` 为负数:
|
||||
1. `start` 的 `chapterOffset` 被 `max(chapterOffset, 0)` clamp 到 0
|
||||
2. `end` 的 `chapterOffset` 是 `max(startOffset, endOffset)`,如果 `endOffset` 也为负,结果为负数,再被 clamp 到 0
|
||||
3. 最终 start 和 end 都是 0,range 退化为点
|
||||
|
||||
但如果 `startOffset = -5`,`endOffset = 10`:
|
||||
- `start.chapterOffset` = `max(-5, 0)` = 0
|
||||
- `end.chapterOffset` = `max(-5, 10)` = 10
|
||||
- 这个结果是正确的
|
||||
|
||||
然而如果 `startOffset = 10`,`endOffset = 5`(反向 range):
|
||||
- `start.chapterOffset` = 10
|
||||
- `end.chapterOffset` = `max(10, 5)` = 10
|
||||
- range 退化为点,丢失了反向信息
|
||||
|
||||
**建议:** 在入口处验证 `startOffset <= endOffset`,或支持反向 range。
|
||||
|
||||
---
|
||||
|
||||
## 低优先级
|
||||
|
||||
### 9. 重复的 `nilIfEmpty` 扩展
|
||||
|
||||
**文件:** 4 个文件各自定义了 `private extension String { var nilIfEmpty }`:
|
||||
|
||||
| 文件 | 行号 |
|
||||
|------|------|
|
||||
| `RDEPUBCFIPath.swift` | 41-46 |
|
||||
| `RDEPUBCFIParser.swift` | 172-177 |
|
||||
| `RDEPUBCFIDOMPathBuilder.swift` | 90-95 |
|
||||
| `RDEPUBCFITextAssertion.swift` | 18-24 |
|
||||
|
||||
**建议:** 提取为一个共享的 `internal` 扩展,放在单独文件或 `RDEPUBCFI.swift` 中。
|
||||
|
||||
---
|
||||
|
||||
### 10. `RDEPUBCFIMap.marker(matching:)` 线性扫描
|
||||
|
||||
**文件:** `RDEPUBCFIMap.swift:30-32`
|
||||
|
||||
```swift
|
||||
public func marker(matching path: RDEPUBCFIPath) -> 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 静默失败 → 统一错误处理
|
||||
```
|
||||
@@ -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` | 错误类型定义 |
|
||||
@@ -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<NSString, UIImage> (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<NSString, UIImage>()
|
||||
|
||||
// 章节加载队列(串行,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<RDEPUBRuntimeChapter, Error>) -> 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 版本 |
|
||||
@@ -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` 管理导航状态 |
|
||||
@@ -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:移除 `<script>`、修复未闭合标签、规范化编码 |
|
||||
| `RDEPUBSemanticMarkerInjector` | `RDEPUBSemanticMarkerInjector.swift` | 为块级元素注入 `data-rd-block-kind` 属性 |
|
||||
| `RDEPUBCFIMarkerInjector` | `RDEPUBCFIMarkerInjector.swift` | 为文本节点注入 `data-rd-cfi` 属性 |
|
||||
| `RDEPUBStyleSheetComposer` | `RDEPUBStyleSheetComposer.swift` | 合并 EPUB 原始 CSS + SDK 样式 + 兼容性补丁 |
|
||||
| `RDEPUBFontNormalizer` | `RDEPUBFontNormalizer.swift` | 检测并注册 `@font-face` 嵌入字体 |
|
||||
| `RDEPUBFragmentMarkerInjector` | `RDEPUBFragmentMarkerInjector.swift` | 为 `id` 属性注入片段偏移标记 |
|
||||
| `RDEPUBAttachmentNormalizer` | `RDEPUBAttachmentNormalizer.swift` | 规范化 `<img>`、`<svg>` 等附件元素 |
|
||||
| `RDEPUBRenderDiagnosticsCollector` | `RDEPUBRenderDiagnosticsCollector.swift` | 收集渲染诊断信息(缺失资源、不支持的特性) |
|
||||
|
||||
### 2.4 CSS 兼容性层
|
||||
|
||||
**文件:** `Typesetter/Compatibility/`
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `RDEPUBCSSCompatibilityLayer.swift` | CSS 兼容性补丁(webkit 前缀、不支持属性替换) |
|
||||
| `RDEPUBFontFallbackResolver.swift` | 字体回退链解析 |
|
||||
| `RDEPUBStyleCompatibilityModels.swift` | 兼容性报告模型 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 文本渲染器
|
||||
|
||||
### 3.1 RDEPUBTextRenderer(协议)
|
||||
|
||||
**文件:** `RDEPUBTextRenderer.swift`
|
||||
|
||||
```swift
|
||||
public protocol RDEPUBTextRenderer {
|
||||
/// 将 HTML 渲染为 NSAttributedString
|
||||
func render(html: String, baseURL: URL?, style: RDEPUBTextRenderStyle) -> NSAttributedString
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 RDEPUBDTCoreTextRenderer
|
||||
|
||||
**文件:** `RDEPUBDTCoreTextRenderer.swift`
|
||||
|
||||
基于 DTCoreText 的渲染器实现,将 HTML 转换为 `NSAttributedString`。
|
||||
|
||||
```swift
|
||||
public final class RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
|
||||
public init()
|
||||
public func render(html: String, baseURL: URL?, style: RDEPUBTextRenderStyle) -> NSAttributedString
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 渲染样式
|
||||
|
||||
```swift
|
||||
public struct RDEPUBTextRenderStyle {
|
||||
public var font: UIFont // 字体
|
||||
public var lineSpacing: CGFloat // 行间距
|
||||
public var textColor: UIColor? // 文字颜色
|
||||
public var backgroundColor: UIColor? // 背景颜色
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 NSAttributedString 扩展键
|
||||
|
||||
```swift
|
||||
extension NSAttributedString.Key {
|
||||
static let rdPageBlockRange // 块级元素范围
|
||||
static let rdPageBlockIndex // 块索引
|
||||
static let rdPageFragmentID // 片段 ID
|
||||
static let rdPageAttachmentKind // 附件类型
|
||||
static let rdPageBlockKind // 块类型(paragraph/list/table/code/blockquote/attachment/generic)
|
||||
static let rdPageSemanticHints // 语义提示(avoidPageBreakInside/keepWithNext/pageBreakBefore/After)
|
||||
static let rdPageAttachmentPlacement // 附件放置方式(inline/baseline/centered)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 分页引擎
|
||||
|
||||
### 4.1 RDEPUBTextLayouter
|
||||
|
||||
**文件:** `Pagination/RDEPUBTextLayouter.swift`
|
||||
|
||||
分页的核心入口,组合 `RDEPUBCoreTextPageFrameFactory` 和 `RDEPUBChapterPageCounter`。
|
||||
|
||||
```swift
|
||||
struct RDEPUBTextLayouter {
|
||||
init(attributedString: NSAttributedString, pageSize: CGSize, config: RDEPUBTextLayoutConfig = .default)
|
||||
func layoutFrames(fragmentOffsets: [String: Int] = [:]) -> [RDEPUBTextLayoutFrame]
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 RDEPUBTextLayoutConfig
|
||||
|
||||
```swift
|
||||
public struct RDEPUBTextLayoutConfig: Equatable {
|
||||
public var frameWidth: CGFloat // 帧宽度
|
||||
public var frameHeight: CGFloat // 帧高度
|
||||
public var edgeInsets: UIEdgeInsets // 内边距
|
||||
public var numberOfColumns: Int // 列数
|
||||
public var columnGap: CGFloat // 列间距
|
||||
public var avoidOrphans: Bool // 避免孤行
|
||||
public var avoidWidows: Bool // 避免寡行
|
||||
public var avoidPageBreakInsideEnabled: Bool // 避免块内分页
|
||||
public var hyphenation: Bool // 连字符断词
|
||||
|
||||
public static let `default`: RDEPUBTextLayoutConfig
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 RDEPUBCoreTextPageFrameFactory
|
||||
|
||||
**文件:** `Pagination/RDEPUBCoreTextPageFrameFactory.swift`
|
||||
|
||||
使用 CoreText 排版引擎,将 `NSAttributedString` 排列到指定尺寸的帧中。
|
||||
|
||||
```swift
|
||||
struct RDEPUBCoreTextPageFrameFactory {
|
||||
init(attributedString: NSAttributedString, pageSize: CGSize, config: RDEPUBTextLayoutConfig)
|
||||
func makeFrames(fragmentOffsets: [String: Int] = [:]) -> [RDEPUBTextLayoutFrame]
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 RDEPUBChapterPageCounter
|
||||
|
||||
**文件:** `Pagination/RDEPUBChapterPageCounter.swift`
|
||||
|
||||
基于 `RDEPUBCoreTextPageFrameFactory` 计算页范围。
|
||||
|
||||
```swift
|
||||
struct RDEPUBChapterPageCounter {
|
||||
init(factory: RDEPUBCoreTextPageFrameFactory)
|
||||
func layoutFrames(fragmentOffsets: [String: Int] = [:]) -> [RDEPUBTextLayoutFrame]
|
||||
}
|
||||
```
|
||||
|
||||
### 4.5 RDEPUBTextLayoutFrame
|
||||
|
||||
**文件:** `Pagination/RDEPUBTextLayoutFrame.swift`
|
||||
|
||||
表示一个排版帧(一页的内容范围)。
|
||||
|
||||
```swift
|
||||
public struct RDEPUBTextLayoutFrame {
|
||||
public let range: NSRange // 字符范围
|
||||
public let frameIndex: Int // 帧索引
|
||||
public let diagnostics: [String] // 诊断信息
|
||||
}
|
||||
```
|
||||
|
||||
### 4.6 RDEPUBPageBreakPolicy
|
||||
|
||||
**文件:** `Pagination/RDEPUBPageBreakPolicy.swift`
|
||||
|
||||
分页策略,决定在何处断页。
|
||||
|
||||
```swift
|
||||
struct RDEPUBPageBreakPolicy {
|
||||
/// 检查指定位置是否允许分页
|
||||
func canBreak(at offset: Int, in attributedString: NSAttributedString) -> Bool
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 构建管线
|
||||
|
||||
### 5.1 RDEPUBTextBookBuilder
|
||||
|
||||
**文件:** `BuildPipeline/RDEPUBTextBookBuilder.swift`
|
||||
|
||||
全书构建的核心类,串联渲染、分页、缓存和诊断。
|
||||
|
||||
```swift
|
||||
public final class RDEPUBTextBookBuilder {
|
||||
public init(renderer: RDEPUBTextRenderer, cache: RDEPUBTextBookCache?, layoutConfig: RDEPUBTextLayoutConfig)
|
||||
|
||||
/// 构建全书文本模型
|
||||
public func build(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
pageSize: CGSize,
|
||||
style: RDEPUBTextRenderStyle
|
||||
) throws -> RDEPUBTextBook
|
||||
|
||||
// 诊断信息
|
||||
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)
|
||||
}
|
||||
```
|
||||
|
||||
**构建流程:**
|
||||
|
||||
```
|
||||
RDEPUBParser + RDEPUBPublication
|
||||
│
|
||||
▼ 遍历 spine(linear 章节)
|
||||
│
|
||||
├── 检查缓存(RDEPUBPaginationCacheCoordinator)
|
||||
│ ├── 命中 → 直接使用缓存的分页结果
|
||||
│ └── 未命中 → 执行渲染 + 分页
|
||||
│
|
||||
├── RDEPUBChapterRenderPipeline(渲染单章)
|
||||
│ └── RDEPUBTextTypesetterPipeline → RDEPUBTextRenderer
|
||||
│
|
||||
├── RDEPUBChapterPaginationPipeline(分页单章)
|
||||
│ └── RDEPUBTextLayouter → [RDEPUBTextLayoutFrame]
|
||||
│
|
||||
├── RDEPUBChapterTailNormalizer(章节尾部规范化)
|
||||
│
|
||||
└── 组装 RDEPUBTextChapter + RDEPUBTextPage
|
||||
│
|
||||
▼
|
||||
RDEPUBTextBook(全书模型)
|
||||
```
|
||||
|
||||
### 5.2 辅助组件
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `RDEPUBChapterRenderPipeline.swift` | 单章渲染管线 |
|
||||
| `RDEPUBChapterPaginationPipeline.swift` | 单章分页管线 |
|
||||
| `RDEPUBChapterTailNormalizer.swift` | 章节尾部空白处理 |
|
||||
| `RDEPUBPaginationCacheCoordinator.swift` | 分页缓存协调 |
|
||||
| `RDEPUBTextBookCache.swift` | 分页缓存存储 |
|
||||
| `RDEPUBTextPerformanceSampler.swift` | 性能采样 |
|
||||
| `RDEPUBBuildDiagnosticsReporter.swift` | 构建诊断报告 |
|
||||
| `RDEPUBTextBuildPipelineInterfaces.swift` | 管线接口定义 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 全书模型
|
||||
|
||||
### 6.1 RDEPUBTextBook
|
||||
|
||||
**文件:** `BuildPipeline/RDEPUBTextBookModels.swift`
|
||||
|
||||
全书文本模型,包含所有章节和页面。
|
||||
|
||||
```swift
|
||||
public struct RDEPUBTextBook {
|
||||
public var chapters: [RDEPUBTextChapter] // 所有章节
|
||||
public var pages: [RDEPUBTextPage] // 所有页面(扁平化)
|
||||
public let indexTable: RDEPUBTextIndexTable // 索引表
|
||||
public var positionConverter: RDEPUBTextPositionConverter
|
||||
|
||||
// 章节查询
|
||||
public func chapterData(for href: String) -> RDEPUBChapterData?
|
||||
public func chapterData(forSpineIndex spineIndex: Int) -> RDEPUBChapterData?
|
||||
public func chapterData(atChapterIndex index: Int) -> RDEPUBChapterData?
|
||||
public func chapterData(forPageNumber pageNumber: Int) -> RDEPUBChapterData?
|
||||
|
||||
// 页码查询
|
||||
public func page(at pageNumber: Int) -> RDEPUBTextPage?
|
||||
public func pageNumber(for location: RDEPUBLocation, resolver: RDEPUBResourceResolver, bookIdentifier: String?) -> Int?
|
||||
public func location(forPageNumber pageNumber: Int, bookIdentifier: String?) -> RDEPUBLocation?
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 RDEPUBTextChapter
|
||||
|
||||
```swift
|
||||
public struct RDEPUBTextChapter: Equatable {
|
||||
public var chapterIndex: Int // 章节索引
|
||||
public var spineIndex: Int // spine 索引
|
||||
public var href: String // 章节 href
|
||||
public var title: String // 章节标题
|
||||
public var attributedContent: NSAttributedString // 章节完整富文本
|
||||
public var fragmentOffsets: [String: Int] // 片段 ID → 字符偏移
|
||||
public var cfiMap: RDEPUBCFIMap? // CFI 映射
|
||||
public var pageBreakReasons: [RDEPUBTextPageBreakReason] // 分页原因
|
||||
public var pages: [RDEPUBTextPage] // 章节内页面
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 RDEPUBTextPage
|
||||
|
||||
```swift
|
||||
public struct RDEPUBTextPage: Equatable {
|
||||
public var absolutePageIndex: Int // 全书绝对页码
|
||||
public var chapterIndex: Int // 章节索引
|
||||
public var spineIndex: Int // spine 索引
|
||||
public var href: String // 章节 href
|
||||
public var chapterTitle: String // 章节标题
|
||||
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 // 页元数据
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 索引与位置转换
|
||||
|
||||
### 7.1 RDEPUBTextIndexTable
|
||||
|
||||
**文件:** `RDEPUBTextIndexTable.swift`
|
||||
|
||||
全书字符偏移索引,支持快速查找。
|
||||
|
||||
```swift
|
||||
public struct RDEPUBTextIndexTable {
|
||||
public let chapterStartOffsets: [Int] // 各章节起始偏移
|
||||
public let chapterLengths: [Int] // 各章节长度
|
||||
public let hrefToChapterIndex: [String: Int] // href → 章节索引
|
||||
public let hrefToFileIndex: [String: Int] // href → 文件索引
|
||||
public let fragmentOffsetsByHref: [String: [String: Int]] // 片段偏移
|
||||
public let fileRowColumnMap: [Int: [RDEPUBRowColumnIndex]] // 行列映射
|
||||
public let fileCFIMap: [Int: RDEPUBCFIMap] // CFI 映射
|
||||
public var totalCharacterCount: Int // 总字符数
|
||||
|
||||
public init(chapters: [RDEPUBTextChapter])
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 RDEPUBTextPositionConverter
|
||||
|
||||
**文件:** `RDEPUBTextPositionConverter.swift`
|
||||
|
||||
字符偏移 ↔ 页码双向转换。
|
||||
|
||||
```swift
|
||||
public struct RDEPUBTextPositionConverter {
|
||||
init(book: RDEPUBTextBook)
|
||||
|
||||
/// 字符偏移 → 页码
|
||||
public func pageNumber(for offset: Int) -> Int?
|
||||
|
||||
/// 页码 → 字符偏移范围
|
||||
public func offsetRange(forPageNumber pageNumber: Int) -> NSRange?
|
||||
}
|
||||
```
|
||||
|
||||
### 7.3 RDEPUBChapterData
|
||||
|
||||
**文件:** `RDEPUBChapterData.swift`
|
||||
|
||||
章节级数据访问封装。
|
||||
|
||||
```swift
|
||||
public struct RDEPUBChapterData {
|
||||
public let chapter: RDEPUBTextChapter
|
||||
public let indexTable: RDEPUBTextIndexTable
|
||||
|
||||
/// 位置 → 页码
|
||||
public func pageNumber(for location: RDEPUBLocation) -> Int?
|
||||
|
||||
/// 页面 → 位置
|
||||
public func location(for page: RDEPUBTextPage, bookIdentifier: String?) -> RDEPUBLocation?
|
||||
|
||||
/// 字符范围 → 范围锚点
|
||||
public func rangeAnchor(for range: NSRange) -> RDEPUBRangeAnchor
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 搜索引擎
|
||||
|
||||
**文件:** `RDEPUBTextSearchEngine.swift`
|
||||
|
||||
基于原生 `NSAttributedString` 的搜索引擎,比 HTML 搜索更高效。
|
||||
|
||||
```swift
|
||||
final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
|
||||
init(textBook: RDEPUBTextBook, publication: RDEPUBPublication)
|
||||
func search(keyword: String) -> [RDEPUBSearchMatch]
|
||||
}
|
||||
```
|
||||
|
||||
搜索结果包含:href、progression、previewText、rangeAnchor、cfi 等定位信息。
|
||||
|
||||
---
|
||||
|
||||
## 9. 纯文本构建器
|
||||
|
||||
**文件:** `RDPlainTextBookBuilder.swift`
|
||||
|
||||
从纯文本(.txt)文件构建 `RDEPUBTextBook`,用于支持 TXT 格式阅读。
|
||||
|
||||
---
|
||||
|
||||
## 10. 设计模式总结
|
||||
|
||||
| 模式 | 应用 |
|
||||
|------|------|
|
||||
| **管线模式** | `RDEPUBTextTypesettingPipeline` 多阶段 HTML 处理 |
|
||||
| **策略模式** | `RDEPUBTextRenderer` 协议,`RDEPUBDTCoreTextRenderer` 实现 |
|
||||
| **Builder 模式** | `RDEPUBTextBookBuilder` 全书构建 |
|
||||
| **缓存协调** | `RDEPUBPaginationCacheCoordinator` 分页结果缓存 |
|
||||
| **索引表** | `RDEPUBTextIndexTable` 全书字符偏移快速查找 |
|
||||
| **关注点分离** | 渲染/分页/缓存/诊断各司其职 |
|
||||
|
||||
---
|
||||
|
||||
## 11. 数据流图
|
||||
|
||||
```
|
||||
EPUB HTML 文件
|
||||
│
|
||||
▼
|
||||
RDEPUBTypesettingPipeline
|
||||
│ HTMLNormalizer → SemanticMarker → CFI → StyleSheet → Font → Fragment
|
||||
▼
|
||||
标记化 HTML + CSS
|
||||
│
|
||||
▼
|
||||
RDEPUBDTCoreTextRenderer
|
||||
│ DTCoreText HTML → NSAttributedString
|
||||
▼
|
||||
NSAttributedString(带语义属性)
|
||||
│
|
||||
▼
|
||||
RDEPUBTextLayouter
|
||||
│ CoreText 排版 → 帧计算 → 分页策略
|
||||
▼
|
||||
[RDEPUBTextLayoutFrame](页面范围列表)
|
||||
│
|
||||
▼
|
||||
RDEPUBTextChapter + RDEPUBTextPage
|
||||
│
|
||||
▼
|
||||
RDEPUBTextBook(全书模型 + 索引表)
|
||||
```
|
||||
@@ -0,0 +1,727 @@
|
||||
# EPUBUI 模块代码级参考文档
|
||||
|
||||
> 最后更新:2026-06-18
|
||||
|
||||
---
|
||||
|
||||
## 1. 模块概述
|
||||
|
||||
`EPUBUI` 是 ReadViewSDK 的**用户界面层**,位于架构最顶层。它包含阅读器控制器、协调器模式、章节运行时系统、设置面板、文本页面渲染和标注管理等子系统。
|
||||
|
||||
**文件清单(~60 个 Swift 文件):**
|
||||
|
||||
| 子系统 | 目录 | 核心文件 | 职责 |
|
||||
|--------|------|----------|------|
|
||||
| **阅读器控制器** | `EPUBUI/` | `RDEPUBReaderController*.swift` | 主控制器及其扩展 |
|
||||
| **协调器** | `ReaderController/` | `RDEPUBReader*Coordinator.swift` | 各功能域协调器 |
|
||||
| **运行时** | `ReaderController/` | `RDEPUBReaderRuntime.swift`, `RDEPUBReaderContext.swift` | 运行时状态与依赖 |
|
||||
| **章节运行时** | `ReaderController/ChapterRuntime/` | `RDEPUBChapter*.swift` | 按需加载、缓存、页图 |
|
||||
| **设置** | `Settings/` | `RDEPUBReaderSettings*.swift` | 配置、主题、设置面板 |
|
||||
| **文本页面** | `TextPage/` | `RDEPUBTextContentView*.swift` | 原生文本渲染页面 |
|
||||
| **UI 组件** | `EPUBUI/` | `RDEPUBReader*ToolView.swift` | 工具栏、搜索栏、目录 |
|
||||
| **笔记弹层** | `Notes/` | `RDEPUBNotePopup*.swift` | 脚注弹层 |
|
||||
| **WebView** | `EPUBUI/` | `RDEPUBWebContentView.swift` | WebView 内容页面 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 阅读器控制器
|
||||
|
||||
### 2.1 RDEPUBReaderController
|
||||
|
||||
**文件:** `RDEPUBReaderController.swift` + 扩展文件
|
||||
|
||||
主控制器,`UIViewController` 子类,是整个阅读器的入口。
|
||||
|
||||
```swift
|
||||
public final class RDEPUBReaderController: UIViewController {
|
||||
public weak var delegate: RDEPUBReaderDelegate?
|
||||
public var configuration: RDEPUBReaderConfiguration // 配置(变化时自动重新分页/刷新)
|
||||
public var currentLocation: RDEPUBLocation? // 当前阅读位置
|
||||
public var currentPageNumber: Int? // 当前页码(1-based)
|
||||
public var currentSelection: RDEPUBSelection? // 当前选中文本
|
||||
public var highlights: [RDEPUBHighlight] // 高亮列表
|
||||
public var bookmarks: [RDEPUBBookmark] // 书签列表
|
||||
public var annotations: [RDEPUBAnnotation] // 标注列表(高亮+书签,按时间排序)
|
||||
public var tableOfContents: [EPUBTableOfContentsItem] // 目录树
|
||||
public var flattenedTableOfContents: [RDEPUBReaderTableOfContentsItem] // 扁平化目录
|
||||
|
||||
let epubURL: URL // EPUB 文件路径
|
||||
let persistence: RDEPUBReaderPersistence? // 持久化代理
|
||||
let dependencies: RDEPUBReaderDependencies // 依赖注入
|
||||
let readerView = RDReaderView() // 翻页容器
|
||||
}
|
||||
```
|
||||
|
||||
**扩展文件职责:**
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `RDEPUBReaderController+PublicAPI.swift` | 公开 API(跳转、搜索、标注、书签) |
|
||||
| `RDEPUBReaderController+DataSource.swift` | `RDReaderPageProvider` 实现 |
|
||||
| `RDEPUBReaderController+ContentDelegates.swift` | WebView/TextContentView 代理 |
|
||||
| `RDEPUBReaderController+RenderSupport.swift` | 渲染辅助(WebView/TextContent 创建) |
|
||||
| `RDEPUBReaderController+RuntimeBridge.swift` | Runtime 桥接 |
|
||||
| `RDEPUBReaderController+TableOfContents.swift` | 目录处理 |
|
||||
|
||||
### 2.2 RDEPUBReaderDelegate
|
||||
|
||||
**文件:** `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)
|
||||
func epubReader(_ reader: UIViewController, didChangeSelection selection: RDEPUBSelection?)
|
||||
func epubReader(_ reader: UIViewController, didUpdateHighlights highlights: [RDEPUBHighlight])
|
||||
func epubReader(_ reader: UIViewController, didUpdateBookmarks bookmarks: [RDEPUBBookmark])
|
||||
func epubReader(_ reader: UIViewController, didUpdateSearchResult result: RDEPUBSearchResult?)
|
||||
func epubReader(_ reader: UIViewController, didChangeCurrentSearchMatch match: RDEPUBSearchMatch?)
|
||||
func epubReader(_ reader: UIViewController, didUpdateCurrentTableOfContentsItem item: RDEPUBReaderTableOfContentsItem?)
|
||||
func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL)
|
||||
func epubReader(_ reader: UIViewController, shouldOpenExternalURL url: URL) -> Bool
|
||||
func epubReader(_ reader: UIViewController, didFailWithError error: Error)
|
||||
func epubReader(_ reader: UIViewController, configureTopToolView topToolView: RDEPUBReaderTopToolView)
|
||||
}
|
||||
```
|
||||
|
||||
所有方法均有默认空实现。
|
||||
|
||||
### 2.3 RDEPUBReaderPersistence
|
||||
|
||||
**文件:** `RDEPUBReaderPersistence.swift`
|
||||
|
||||
持久化协议,由宿主 App 实现。
|
||||
|
||||
```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)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 运行时与上下文
|
||||
|
||||
### 3.1 RDEPUBReaderContext
|
||||
|
||||
**文件:** `ReaderController/RDEPUBReaderContext.swift`
|
||||
|
||||
阅读器的共享上下文,持有所有运行时状态。协调器通过 `unowned` 引用访问。
|
||||
|
||||
```swift
|
||||
final class RDEPUBReaderContext {
|
||||
weak var controller: RDEPUBReaderController?
|
||||
weak var readerView: RDReaderView?
|
||||
var dependencies: RDEPUBReaderDependencies
|
||||
var runtime: RDEPUBReaderRuntime?
|
||||
|
||||
// 解析状态
|
||||
var parser: RDEPUBParser?
|
||||
var publication: RDEPUBPublication?
|
||||
var readingSession: RDEPUBReadingSession?
|
||||
var textBook: RDEPUBTextBook?
|
||||
var bookPageMap: RDEPUBBookPageMap?
|
||||
|
||||
// 标注状态
|
||||
var activeBookmarks: [RDEPUBBookmark]
|
||||
var activeHighlights: [RDEPUBHighlight]
|
||||
var currentBookIdentifier: String?
|
||||
var selectionState: RDEPUBSelectionState
|
||||
var currentSelection: RDEPUBSelection?
|
||||
|
||||
// 搜索状态
|
||||
var searchState: RDEPUBSearchState?
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 RDEPUBReaderDependencies
|
||||
|
||||
**文件:** `ReaderController/RDEPUBReaderDependencies.swift`
|
||||
|
||||
依赖注入容器,支持测试替身。
|
||||
|
||||
```swift
|
||||
public struct RDEPUBReaderDependencies {
|
||||
public var environment: any RDEPUBReaderDisplayEnvironment
|
||||
public var makeParser: () -> RDEPUBParser
|
||||
public var makePaginator: () -> RDEPUBPaginator
|
||||
public var makeTextBookBuilder: (RDEPUBTextRenderer, RDEPUBTextBookCache?, RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder
|
||||
public var makePlainTextBookBuilder: (RDEPUBTextRenderer, RDEPUBTextLayoutConfig) -> RDPlainTextBookBuilder
|
||||
public var makeTextRenderer: (RDEPUBTextRenderingEngine) -> RDEPUBTextRenderer
|
||||
|
||||
public static var live: RDEPUBReaderDependencies // 默认实现
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 RDEPUBReaderRuntime
|
||||
|
||||
**文件:** `ReaderController/RDEPUBReaderRuntime.swift`
|
||||
|
||||
运行时管理器,持有所有协调器和子系统。
|
||||
|
||||
```swift
|
||||
final class RDEPUBReaderRuntime {
|
||||
lazy var chapterRuntimeStore = RDEPUBChapterRuntimeStore()
|
||||
lazy var summaryDiskCache: RDEPUBChapterSummaryDiskCache
|
||||
lazy var chapterLoader: RDEPUBChapterLoader
|
||||
lazy var pageResolver: RDEPUBPageResolver
|
||||
lazy var loadCoordinator: RDEPUBReaderLoadCoordinator
|
||||
lazy var paginationCoordinator: RDEPUBReaderPaginationCoordinator
|
||||
lazy var locationCoordinator: RDEPUBReaderLocationCoordinator
|
||||
lazy var searchCoordinator: RDEPUBReaderSearchCoordinator
|
||||
lazy var chromeCoordinator: RDEPUBReaderChromeCoordinator
|
||||
lazy var annotationCoordinator: RDEPUBReaderAnnotationCoordinator
|
||||
lazy var viewportMonitor: RDEPUBReaderViewportMonitor
|
||||
lazy var jumpSessionManager: RDEPUBJumpSessionManager
|
||||
lazy var backgroundPriorityManager: RDEPUBBackgroundPriorityManager
|
||||
lazy var backgroundCoverageStore: RDEPUBBackgroundCoverageStore
|
||||
lazy var reconciliationCoordinator: RDEPUBPageMapReconciliationCoordinator
|
||||
|
||||
var isSettingsPanelOpen: Bool
|
||||
var needsFullRepaginationAfterSettingsClose: Bool
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 协调器子系统
|
||||
|
||||
采用 **Coordinator 模式**,每个功能域由独立的协调器管理。
|
||||
|
||||
### 4.1 RDEPUBReaderLoadCoordinator
|
||||
|
||||
**文件:** `ReaderController/RDEPUBReaderLoadCoordinator.swift`
|
||||
|
||||
负责初始加载流程:解析 EPUB → 创建 Publication → 恢复阅读位置。
|
||||
|
||||
```swift
|
||||
final class RDEPUBReaderLoadCoordinator {
|
||||
func startInitialLoadIfNeeded() // 开始初始加载
|
||||
func loadPublication() // 解析 EPUB 文件
|
||||
func applyParsedPublication(...) // 应用解析结果
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 RDEPUBReaderPaginationCoordinator
|
||||
|
||||
**文件:** `ReaderController/RDEPUBReaderPaginationCoordinator.swift`
|
||||
|
||||
负责分页调度:WebView 分页 → 文本构建 → 页图生成。
|
||||
|
||||
```swift
|
||||
final class RDEPUBReaderPaginationCoordinator {
|
||||
func startPagination(...) // 开始分页
|
||||
func applyPageCounts(...) // 应用页数结果
|
||||
func repaginatePreservingCurrentLocation() // 重新分页(保持位置)
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 RDEPUBReaderLocationCoordinator
|
||||
|
||||
**文件:** `ReaderController/RDEPUBReaderLocationCoordinator.swift`
|
||||
|
||||
负责位置管理:恢复位置、记录位置变化、目录跳转。
|
||||
|
||||
```swift
|
||||
final class RDEPUBReaderLocationCoordinator {
|
||||
func restoreReadingLocation(_ location: RDEPUBLocation, animated: Bool, ...) -> Bool
|
||||
func currentVisibleLocation() -> RDEPUBLocation?
|
||||
func recordPageChangeIfNeeded()
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 RDEPUBReaderSearchCoordinator
|
||||
|
||||
**文件:** `ReaderController/RDEPUBReaderSearchCoordinator.swift`
|
||||
|
||||
负责搜索管理:执行搜索、导航到匹配项、清除搜索。
|
||||
|
||||
```swift
|
||||
final class RDEPUBReaderSearchCoordinator {
|
||||
func search(keyword: String)
|
||||
func searchNext() -> Bool
|
||||
func searchPrevious() -> Bool
|
||||
func selectSearchMatch(at index: Int) -> Bool
|
||||
func clearSearch()
|
||||
}
|
||||
```
|
||||
|
||||
### 4.5 RDEPUBReaderChromeCoordinator
|
||||
|
||||
**文件:** `ReaderController/RDEPUBReaderChromeCoordinator.swift`
|
||||
|
||||
负责 UI Chrome(工具栏、搜索栏)的创建和状态更新。
|
||||
|
||||
```swift
|
||||
final class RDEPUBReaderChromeCoordinator {
|
||||
func makeTopToolView() -> RDEPUBReaderTopToolView
|
||||
func makeBottomToolView() -> RDEPUBReaderBottomToolView
|
||||
func updateReaderChrome()
|
||||
func toggleSearchBar()
|
||||
func presentTableOfContents()
|
||||
func presentSettings()
|
||||
}
|
||||
```
|
||||
|
||||
### 4.6 RDEPUBReaderAnnotationCoordinator
|
||||
|
||||
**文件:** `ReaderController/RDEPUBReaderAnnotationCoordinator.swift`
|
||||
|
||||
负责标注管理:高亮、书签的增删改查。
|
||||
|
||||
```swift
|
||||
final class RDEPUBReaderAnnotationCoordinator {
|
||||
func addHighlight(from selection: RDEPUBSelection?, color: String, note: String?) -> RDEPUBHighlight?
|
||||
func removeHighlight(withID id: String)
|
||||
func addBookmark(for location: RDEPUBLocation, ...) -> RDEPUBBookmark?
|
||||
func removeBookmark(withID id: String)
|
||||
func toggleBookmark() -> Bool
|
||||
func updateCurrentSelection(_ selection: RDEPUBSelection?)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 章节运行时子系统
|
||||
|
||||
**目录:** `ReaderController/ChapterRuntime/`
|
||||
|
||||
按需加载章节、管理章节缓存、维护全书页图。
|
||||
|
||||
### 5.1 RDEPUBChapterRuntimeStore
|
||||
|
||||
**文件:** `ChapterRuntime/RDEPUBChapterRuntimeStore.swift`
|
||||
|
||||
章节数据的核心存储,管理内存缓存和窗口。
|
||||
|
||||
```swift
|
||||
final class RDEPUBChapterRuntimeStore {
|
||||
let chapterLoadQueue: DispatchQueue // 后台加载队列
|
||||
private(set) var currentSpineIndex: Int?
|
||||
private(set) var windowSpineIndices: [Int] // 当前窗口内的 spine 索引
|
||||
|
||||
func chapterData(for spineIndex: Int) -> RDEPUBRuntimeChapter?
|
||||
func insertChapter(_ chapter: RDEPUBRuntimeChapter)
|
||||
func setCurrentChapter(spineIndex: Int, totalSpineCount: Int, windowRadius: Int)
|
||||
func evictableSpineIndices() -> [Int]
|
||||
func evict(spineIndex: Int)
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 RDEPUBChapterLoader
|
||||
|
||||
**文件:** `ChapterRuntime/RDEPUBChapterLoader.swift`
|
||||
|
||||
章节按需加载器,支持优先级和磁盘摘要缓存。
|
||||
|
||||
```swift
|
||||
final class RDEPUBChapterLoader {
|
||||
enum LoadPriority {
|
||||
case navigation // 导航(最高优先级)
|
||||
case preview // 预览
|
||||
case prefetch // 预取(最低优先级)
|
||||
}
|
||||
|
||||
func loadChapter(
|
||||
spineIndex: Int,
|
||||
store: RDEPUBChapterRuntimeStore,
|
||||
priority: LoadPriority,
|
||||
completion: @escaping (Result<RDEPUBRuntimeChapter, Error>) -> Void
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**加载流程:**
|
||||
1. 检查内存缓存(`RDEPUBChapterDataCache`)
|
||||
2. 检查页数缓存(`RDEPUBPageCountCache`)
|
||||
3. 检查磁盘摘要缓存(`RDEPUBChapterSummaryDiskCache`)
|
||||
4. 构建章节(排版 + 分页)
|
||||
5. 写入缓存
|
||||
|
||||
### 5.3 RDEPUBChapterWindowCoordinator
|
||||
|
||||
**文件:** `ChapterRuntime/RDEPUBChapterWindowCoordinator.swift`
|
||||
|
||||
管理章节窗口(当前章 + 前后各 N 章),协调加载和驱逐。
|
||||
|
||||
```swift
|
||||
final class RDEPUBChapterWindowCoordinator {
|
||||
private(set) var currentSnapshot: RDEPUBChapterWindowSnapshot?
|
||||
var onSnapshotChanged: ((RDEPUBChapterWindowSnapshot) -> Void)?
|
||||
|
||||
func openBook(at targetSpineIndex: Int, restoreChapterOffset: Int?)
|
||||
func navigateToChapter(at spineIndex: Int)
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 RDEPUBBookPageMap
|
||||
|
||||
**文件:** `ChapterRuntime/RDEPUBBookPageMap.swift`
|
||||
|
||||
全书页图,映射绝对页码 ↔ spine 索引 + 本地页码。
|
||||
|
||||
```swift
|
||||
struct RDEPUBBookPageMap {
|
||||
let entries: [RDEPUBBookPageMapEntry]
|
||||
let totalPages: Int
|
||||
|
||||
func absolutePageIndex(spineIndex: Int, localPageIndex: Int) -> Int?
|
||||
func spineIndex(forAbsolutePage absolutePage: Int) -> Int?
|
||||
func localPageIndex(forAbsolutePage absolutePage: Int) -> Int?
|
||||
func chapterIndex(forSpineIndex spineIndex: Int) -> Int?
|
||||
}
|
||||
```
|
||||
|
||||
### 5.5 RDEPUBPageResolver
|
||||
|
||||
**文件:** `ChapterRuntime/RDEPUBPageResolver.swift`
|
||||
|
||||
将绝对页码解析为具体的页面数据。
|
||||
|
||||
```swift
|
||||
final class RDEPUBPageResolver {
|
||||
func resolvePage(absolutePageIndex: Int) -> RDEPUBResolvedPage?
|
||||
}
|
||||
|
||||
struct RDEPUBResolvedPage {
|
||||
let page: RDEPUBTextPage
|
||||
let chapter: RDEPUBRuntimeChapter
|
||||
let chapterIndex: Int
|
||||
}
|
||||
```
|
||||
|
||||
### 5.6 其他 ChapterRuntime 组件
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `RDEPUBRuntimeChapter.swift` | 运行时章节模型(包含分页后的页面列表) |
|
||||
| `RDEPUBRuntimePageCount.swift` | 页数缓存模型 |
|
||||
| `RDEPUBChapterDataCache.swift` | 章节数据内存缓存(NSCache) |
|
||||
| `RDEPUBPageCountCache.swift` | 页数缓存 |
|
||||
| `RDEPUBChapterCacheKey.swift` | 缓存键(pageSize + fontSize + lineHeight) |
|
||||
| `RDEPUBChapterSummaryDiskCache.swift` | 章节摘要磁盘缓存 |
|
||||
| `RDEPUBChapterWindowSnapshot.swift` | 窗口快照(当前可见章节集合) |
|
||||
| `RDEPUBChapterLocation.swift` | 章节位置模型 |
|
||||
| `RDEPUBChapterOffsetMap.swift` | 章节偏移映射 |
|
||||
| `RDEPUBBackgroundTrace.swift` | 后台任务追踪日志 |
|
||||
| `String+SHA256.swift` | SHA256 哈希扩展 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 设置子系统
|
||||
|
||||
**目录:** `Settings/`
|
||||
|
||||
### 6.1 RDEPUBReaderConfiguration
|
||||
|
||||
**文件:** `Settings/RDEPUBReaderConfiguration.swift`
|
||||
|
||||
阅读器配置模型。
|
||||
|
||||
```swift
|
||||
public struct RDEPUBReaderConfiguration: Equatable {
|
||||
public var fontSize: CGFloat
|
||||
public var lineHeightMultiple: CGFloat
|
||||
public var fontChoice: RDEPUBReaderFontChoice
|
||||
public var numberOfColumns: Int
|
||||
public var columnGap: CGFloat
|
||||
// ... 更多配置项
|
||||
}
|
||||
|
||||
public enum RDEPUBReaderFontChoice: String, Codable, CaseIterable {
|
||||
case system // 系统字体
|
||||
case serif // 宋体
|
||||
case rounded // 圆体
|
||||
case monospaced // 等宽
|
||||
}
|
||||
|
||||
public enum RDEPUBTextRenderingEngine: Equatable {
|
||||
case dtCoreText
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 RDEPUBReaderSettings
|
||||
|
||||
**文件:** `Settings/RDEPUBReaderSettings.swift`
|
||||
|
||||
设置状态管理。
|
||||
|
||||
### 6.3 RDEPUBReaderSettingsViewController
|
||||
|
||||
**文件:** `Settings/RDEPUBReaderSettingsViewController.swift`
|
||||
|
||||
设置面板 VC(字体大小、行高、字体选择、主题等)。
|
||||
|
||||
### 6.4 RDEPUBReaderTheme
|
||||
|
||||
**文件:** `Settings/RDEPUBReaderTheme.swift`
|
||||
|
||||
主题定义(日间/夜间/护眼等)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 文本页面子系统
|
||||
|
||||
**目录:** `TextPage/`
|
||||
|
||||
原生文本渲染模式(`textReflowable`)的页面视图。
|
||||
|
||||
### 7.1 RDEPUBTextContentView
|
||||
|
||||
**文件:** `TextPage/RDEPUBTextContentView.swift`
|
||||
|
||||
文本内容视图,使用 CoreText 渲染 `NSAttributedString`。
|
||||
|
||||
```swift
|
||||
final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
weak var delegate: RDEPUBTextContentViewDelegate?
|
||||
|
||||
func configure(page: RDEPUBTextPage, ...)
|
||||
func applyHighlights(_ highlights: [RDEPUBHighlight])
|
||||
func applySearchState(_ state: RDEPUBSearchState?)
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 RDEPUBTextPageRenderView
|
||||
|
||||
**文件:** `TextPage/RDEPUBTextPageRenderView.swift`
|
||||
|
||||
CoreText 渲染视图,绘制 `NSAttributedString` 到屏幕上。
|
||||
|
||||
### 7.3 RDEPUBTextAnnotationOverlay
|
||||
|
||||
**文件:** `TextPage/RDEPUBTextAnnotationOverlay.swift`
|
||||
|
||||
标注叠加层,绘制高亮和下划线。
|
||||
|
||||
### 7.4 RDEPUBSelectionOverlayView
|
||||
|
||||
**文件:** `TextPage/RDEPUBSelectionOverlayView.swift`
|
||||
|
||||
文本选择叠加层(选择手柄)。
|
||||
|
||||
### 7.5 RDEPUBTextSelectionController
|
||||
|
||||
**文件:** `TextPage/RDEPUBTextSelectionController.swift`
|
||||
|
||||
文本选择手势控制器。
|
||||
|
||||
### 7.6 RDEPUBPageInteractionController
|
||||
|
||||
**文件:** `TextPage/RDEPUBPageInteractionController.swift`
|
||||
|
||||
页面交互控制器(长按选择、点击标注等)。
|
||||
|
||||
### 7.7 RDEPUBPageLayoutSnapshot
|
||||
|
||||
**文件:** `TextPage/RDEPUBPageLayoutSnapshot.swift`
|
||||
|
||||
页面布局快照(用于选择定位)。
|
||||
|
||||
### 7.8 RDEPUBTextPageDecorationView
|
||||
|
||||
**文件:** `TextPage/RDEPUBTextPageDecorationView.swift`
|
||||
|
||||
页面装饰视图(页码、页眉等)。
|
||||
|
||||
---
|
||||
|
||||
## 8. UI 组件
|
||||
|
||||
### 8.1 工具栏
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `RDEPUBReaderTopToolView.swift` | 顶部工具栏(返回、搜索、书签) |
|
||||
| `RDEPUBReaderBottomToolView.swift` | 底部工具栏(目录、书签、高亮、设置) |
|
||||
| `RDEPUBReaderToolView.swift` | 工具栏基类 |
|
||||
|
||||
### 8.2 搜索栏
|
||||
|
||||
**文件:** `RDEPUBReaderSearchBarView.swift`
|
||||
|
||||
搜索输入栏(输入框 + 上一个/下一个 + 关闭)。
|
||||
|
||||
### 8.3 目录
|
||||
|
||||
**文件:** `RDEPUBReaderChapterListController.swift`
|
||||
|
||||
目录列表 VC,支持多级目录展开。
|
||||
|
||||
**文件:** `RDEPUBReaderTableOfContentsItem.swift`
|
||||
|
||||
目录项模型。
|
||||
|
||||
### 8.4 高亮管理
|
||||
|
||||
**文件:** `RDEPUBReaderHighlightsViewController.swift`
|
||||
|
||||
高亮列表 VC。
|
||||
|
||||
### 8.5 笔记弹层
|
||||
|
||||
**目录:** `Notes/`
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `RDEPUBNotePopupCoordinator.swift` | 脚注弹层协调器 |
|
||||
| `RDEPUBNotePopupViewController.swift` | 脚注弹层 VC |
|
||||
|
||||
### 8.6 WebView 内容视图
|
||||
|
||||
**文件:** `RDEPUBWebContentView.swift`
|
||||
|
||||
WebView 模式的内容页面视图。
|
||||
|
||||
### 8.7 装饰叠加层
|
||||
|
||||
**文件:** `RDEPUBWebDecorationOverlayView.swift`
|
||||
|
||||
WebView 模式的装饰叠加层(高亮、搜索高亮)。
|
||||
|
||||
---
|
||||
|
||||
## 9. 状态模型
|
||||
|
||||
### 9.1 RDEPUBSelectionState
|
||||
|
||||
```swift
|
||||
enum RDEPUBSelectionState: Equatable {
|
||||
case idle // 无选择
|
||||
case selecting(anchor: Int) // 选择中
|
||||
case selected(RDEPUBSelection) // 已选择
|
||||
case committingAction(RDEPUBSelection, action: RDEPUBAnnotationMenuAction) // 执行操作中
|
||||
}
|
||||
```
|
||||
|
||||
### 9.2 RDEPUBReaderUIState
|
||||
|
||||
```swift
|
||||
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
|
||||
}
|
||||
```
|
||||
|
||||
### 9.3 RDEPUBViewportTypes
|
||||
|
||||
**文件:** `RDEPUBViewportTypes.swift`
|
||||
|
||||
视口相关模型。
|
||||
|
||||
---
|
||||
|
||||
## 10. 其他组件
|
||||
|
||||
### 10.1 RDEPUBReaderViewportMonitor
|
||||
|
||||
**文件:** `ReaderController/RDEPUBReaderViewportMonitor.swift`
|
||||
|
||||
视口变化监听器(旋转、尺寸变化)。
|
||||
|
||||
### 10.2 RDEPUBJumpSession
|
||||
|
||||
**文件:** `ReaderController/RDEPUBJumpSession.swift`
|
||||
|
||||
跳转会话管理(远距跳转时的加载状态)。
|
||||
|
||||
### 10.3 RDEPUBBackgroundPriorityPolicy
|
||||
|
||||
**文件:** `ReaderController/RDEPUBBackgroundPriorityPolicy.swift`
|
||||
|
||||
后台加载优先级策略。
|
||||
|
||||
### 10.4 RDEPUBBackgroundCoverageStore
|
||||
|
||||
**文件:** `ReaderController/RDEPUBBackgroundCoverageStore.swift`
|
||||
|
||||
后台加载覆盖范围追踪。
|
||||
|
||||
### 10.5 RDEPUBPageMapReconciliationCoordinator
|
||||
|
||||
**文件:** `ReaderController/RDEPUBPageMapReconciliationCoordinator.swift`
|
||||
|
||||
页图协调器(按需分页与全量分页的结果合并)。
|
||||
|
||||
### 10.6 RDURLReaderController
|
||||
|
||||
**文件:** `RDURLReaderController.swift`
|
||||
|
||||
URL 阅读器控制器(用于打开单个 URL)。
|
||||
|
||||
### 10.7 UIColor+RDEPUBHex
|
||||
|
||||
**文件:** `UIColor+RDEPUBHex.swift`
|
||||
|
||||
UIColor 十六进制扩展。
|
||||
|
||||
---
|
||||
|
||||
## 11. 设计模式总结
|
||||
|
||||
| 模式 | 应用 |
|
||||
|------|------|
|
||||
| **Coordinator 模式** | 7 个独立协调器分管各功能域 |
|
||||
| **Context 模式** | `RDEPUBReaderContext` 共享上下文,避免循环依赖 |
|
||||
| **依赖注入** | `RDEPUBReaderDependencies` 工厂方法注入 |
|
||||
| **按需加载** | `RDEPUBChapterLoader` 章节级按需加载 |
|
||||
| **窗口管理** | `RDEPUBChapterWindowCoordinator` 滑动窗口驱逐 |
|
||||
| **快照管理** | `RDEPUBBookPageMap` 全书页图快照 |
|
||||
| **状态机** | `RDEPUBSelectionState` 选择状态管理 |
|
||||
| **磁盘缓存** | `RDEPUBChapterSummaryDiskCache` 章节摘要持久化 |
|
||||
|
||||
---
|
||||
|
||||
## 12. 数据流图
|
||||
|
||||
```
|
||||
用户打开 EPUB
|
||||
│
|
||||
▼
|
||||
RDEPUBReaderController.init(epubURL:)
|
||||
│
|
||||
▼ viewDidLoad()
|
||||
RDEPUBReaderLoadCoordinator.startInitialLoadIfNeeded()
|
||||
│
|
||||
├── RDEPUBParser.parse(epubURL:) → 解析 EPUB
|
||||
├── RDEPUBPublication 创建
|
||||
├── 恢复阅读位置(persistence.loadLocation)
|
||||
│
|
||||
▼
|
||||
RDEPUBReaderRuntime.applyParsedPublication()
|
||||
│
|
||||
├── 根据 readingProfile 选择渲染模式
|
||||
│ ├── webInteractive → WebView 分页 → RDEPUBPaginator
|
||||
│ └── textReflowable → 文本构建 → RDEPUBTextBookBuilder
|
||||
│
|
||||
├── RDEPUBChapterWindowCoordinator.openBook()
|
||||
│ └── RDEPUBChapterLoader.loadChapter() → 按需加载
|
||||
│
|
||||
├── RDEPUBBookPageMap 生成
|
||||
│
|
||||
└── RDReaderView.transitionToPage() → 显示页面
|
||||
|
||||
用户翻页
|
||||
│
|
||||
▼
|
||||
RDReaderView.currentPage 变化
|
||||
│
|
||||
├── RDEPUBReaderLocationCoordinator.recordPageChangeIfNeeded()
|
||||
│ └── persistence.saveLocation()
|
||||
│
|
||||
├── RDEPUBChapterWindowCoordinator 检查是否需要加载新章节
|
||||
│
|
||||
└── RDEPUBReaderChromeCoordinator.updateReaderChrome()
|
||||
```
|
||||
@@ -0,0 +1,455 @@
|
||||
# ReaderView 模块代码级参考文档
|
||||
|
||||
> 最后更新:2026-06-18
|
||||
|
||||
---
|
||||
|
||||
## 1. 模块概述
|
||||
|
||||
`ReaderView` 是 ReadViewSDK 的**通用翻页容器层**,位于 EPUBUI 层之下。它提供与 EPUB 内容无关的页面展示、翻页动画、手势识别、页面预加载和双页布局能力。上层通过 `RDReaderPageProvider` 协议提供页面内容视图,ReaderView 负责容器管理和翻页调度。
|
||||
|
||||
**文件清单(14 个 Swift 文件):**
|
||||
|
||||
| 文件 | 核心类型 | 职责 |
|
||||
|------|----------|------|
|
||||
| `RDReaderView.swift` | `RDReaderView` | 主容器视图,协调所有子组件 |
|
||||
| `RDReaderViewProtocols.swift` | 协议 + 枚举 | 数据源、代理、导航协议定义 |
|
||||
| `RDReaderFlowLayout.swift` | `RDReaderFlowLayout` | UICollectionView 自定义布局 |
|
||||
| `RDReaderGestureController.swift` | `RDReaderGestureController` | 手势控制器(预留) |
|
||||
| `RDReaderContentCell.swift` | `RDReaderContentCell` | CollectionView 内容 Cell |
|
||||
| `RDReaderPageChildViewController.swift` | `RDReaderPageChildViewController` | PageCurl 模式子 VC |
|
||||
| `RDReaderView+PageCurl.swift` | Extension | UIPageViewController 数据源/代理 |
|
||||
| `RDReaderView+CollectionView.swift` | Extension | UICollectionView 数据源/布局代理 |
|
||||
| `RDReaderView+ContentAccess.swift` | Extension | 内容视图访问与复用 |
|
||||
| `RDReaderView+ToolView.swift` | Extension | 工具栏安装与动画 |
|
||||
| `Paging/RDReaderPagingController.swift` | `RDReaderPagingController` | 翻页状态机 |
|
||||
| `Paging/RDReaderPreloadController.swift` | `RDReaderPreloadController` | 页面预加载与缓存 |
|
||||
| `Paging/RDReaderSpreadResolver.swift` | `RDReaderSpreadResolver` | 双页展开计算 |
|
||||
| `Paging/RDReaderTapRegionHandler.swift` | `RDReaderTapRegionHandler` | 点击区域判定 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 协议定义(RDReaderViewProtocols.swift)
|
||||
|
||||
### 2.1 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?
|
||||
}
|
||||
```
|
||||
|
||||
> 向后兼容保留,新代码应使用 `RDReaderPageProvider`。
|
||||
|
||||
### 2.2 RDReaderPageProvider(推荐数据源)
|
||||
|
||||
```swift
|
||||
@objc public protocol RDReaderPageProvider: NSObjectProtocol {
|
||||
func numberOfPages(in readerView: RDReaderView) -> Int
|
||||
func readerView(_ readerView: RDReaderView, viewForPageAt index: Int, reusableView: UIView?) -> UIView
|
||||
@objc optional func pageIdentifier(in readerView: RDReaderView, index: Int) -> String?
|
||||
@objc optional func readerViewTopChrome(_ readerView: RDReaderView) -> UIView?
|
||||
@objc optional func readerViewBottomChrome(_ readerView: RDReaderView) -> UIView?
|
||||
}
|
||||
```
|
||||
|
||||
| 方法 | 说明 |
|
||||
|------|------|
|
||||
| `numberOfPages(in:)` | 返回总页数 |
|
||||
| `readerView(_:viewForPageAt:reusableView:)` | 为指定页码提供内容视图,`reusableView` 可复用 |
|
||||
| `pageIdentifier(in:index:)` | 返回页视图复用标识符,用于 CollectionView 注册 |
|
||||
| `readerViewTopChrome(_:)` | 返回顶部工具栏视图 |
|
||||
| `readerViewBottomChrome(_:)` | 返回底部工具栏视图 |
|
||||
|
||||
### 2.3 RDReaderDelegate
|
||||
|
||||
```swift
|
||||
@objc public protocol RDReaderDelegate: NSObjectProtocol {
|
||||
func pageNum(readerView: RDReaderView, pageNum: Int)
|
||||
@objc optional func readerViewOrientationWillChange(readerView: RDReaderView, isLandscape: Bool)
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 RDReaderPageNavigating
|
||||
|
||||
```swift
|
||||
public protocol RDReaderPageNavigating: AnyObject {
|
||||
var currentPage: Int { get }
|
||||
func reloadPages()
|
||||
func transition(to page: Int, animated: Bool)
|
||||
}
|
||||
```
|
||||
|
||||
`RDReaderView` 遵循此协议,提供统一的页面导航接口。
|
||||
|
||||
### 2.5 枚举类型
|
||||
|
||||
```swift
|
||||
extension RDReaderView {
|
||||
public enum DisplayType {
|
||||
case pageCurl // 仿真翻页(UIPageViewController)
|
||||
case horizontalScroll // 水平滑动(UICollectionView)
|
||||
case verticalScroll // 垂直滚动(UICollectionView)
|
||||
}
|
||||
|
||||
public enum PageDirection {
|
||||
case leftToRight // LTR
|
||||
case rightToLeft // RTL
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 核心类:RDReaderView
|
||||
|
||||
**文件:** `RDReaderView.swift`
|
||||
|
||||
`RDReaderView` 是一个 `UIView` 子类,作为翻页容器的主入口。内部管理两种翻页引擎:
|
||||
- **PageCurl 模式**:使用 `UIPageViewController` 实现仿真翻页
|
||||
- **Scroll 模式**:使用 `UICollectionView` + 自定义 `RDReaderFlowLayout` 实现滑动翻页
|
||||
|
||||
### 3.1 关键属性
|
||||
|
||||
| 属性 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `currentPage` | `Int` | 当前页码,变化时通知 delegate |
|
||||
| `currentDisplayType` | `DisplayType` | 当前显示模式 |
|
||||
| `pageDirection` | `PageDirection` | 页面方向(LTR/RTL) |
|
||||
| `landscapeDualPageEnabled` | `Bool` | 是否启用横屏双页 |
|
||||
| `coverPageIndex` | `Int?` | 封面页索引(独占一屏) |
|
||||
| `pagesPerScreen` | `Int` | 每屏页数(横屏双页时为 2) |
|
||||
| `preloadRadius` | `Int` | 预加载半径(默认 1) |
|
||||
| `dataSource` | `RDReaderDataSource?` | 旧版数据源 |
|
||||
| `pageProvider` | `RDReaderPageProvider?` | 推荐数据源 |
|
||||
| `delegate` | `RDReaderDelegate?` | 事件代理 |
|
||||
| `toolViewAnimationDuration` | `TimeInterval` | 工具栏动画时长(0.3s) |
|
||||
|
||||
### 3.2 关键方法
|
||||
|
||||
```swift
|
||||
/// 切换显示模式(pageCurl / horizontalScroll / verticalScroll)
|
||||
public func switchReaderDisplayType(_ displayType: RDReaderView.DisplayType)
|
||||
|
||||
/// 跳转到指定页
|
||||
public func transitionToPage(pageNum: Int, animated: Bool = false)
|
||||
|
||||
/// 重新加载所有页面
|
||||
public func reloadData()
|
||||
|
||||
/// 仅重载页数(不重建内容)
|
||||
public func reloadPageCountOnly()
|
||||
|
||||
/// 判断指定页是否为全屏页(封面页独占一屏)
|
||||
public func isFullScreenPage(_ pageNum: Int) -> Bool
|
||||
```
|
||||
|
||||
### 3.3 内部子组件
|
||||
|
||||
| 组件 | 类型 | 职责 |
|
||||
|------|------|------|
|
||||
| `pageViewController` | `UIPageViewController` | PageCurl 翻页引擎 |
|
||||
| `collectionView` | `UICollectionView` | Scroll 翻页引擎 |
|
||||
| `layout` | `RDReaderFlowLayout` | CollectionView 自定义布局 |
|
||||
| `spreadResolver` | `RDReaderSpreadResolver` | 双页配对计算 |
|
||||
| `tapRegionHandler` | `RDReaderTapRegionHandler` | 点击区域判定 |
|
||||
| `preloadController` | `RDReaderPreloadController` | 页面预加载与缓存 |
|
||||
| `pagingController` | `RDReaderPagingController` | 翻页状态管理 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 翻页状态机:RDReaderPagingController
|
||||
|
||||
**文件:** `Paging/RDReaderPagingController.swift`
|
||||
|
||||
管理 PageCurl 模式下的翻页请求队列,防止动画冲突。
|
||||
|
||||
```swift
|
||||
struct RDReaderPagingController {
|
||||
struct PageTransitionRequest: Equatable {
|
||||
let pageNum: Int
|
||||
let animated: Bool
|
||||
}
|
||||
|
||||
var pendingTransitionRequest: PageTransitionRequest? // 待处理请求
|
||||
var isTransitioning: Bool // 是否正在翻页动画中
|
||||
var didBuildUI: Bool // UI 是否已构建
|
||||
|
||||
/// 判断是否应排队请求(PageCurl 模式下动画中返回 true)
|
||||
mutating func shouldQueuePageTransition(_ request: PageTransitionRequest, currentDisplayType: RDReaderView.DisplayType) -> Bool
|
||||
|
||||
/// 完成翻页动画,返回待处理的请求
|
||||
mutating func finishPageCurlTransition() -> PageTransitionRequest?
|
||||
|
||||
/// 重置状态(用于故障恢复)
|
||||
mutating func resetPendingState()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 页面预加载:RDReaderPreloadController
|
||||
|
||||
**文件:** `Paging/RDReaderPreloadController.swift`
|
||||
|
||||
负责在当前页周围预渲染页面视图,减少翻页时的白屏时间。
|
||||
|
||||
### 5.1 核心机制
|
||||
|
||||
- **缓存签名(CacheSignature)**:基于 `displayType + isLandscape + pagesPerScreen + boundsSize` 生成签名,签名变化时清空缓存
|
||||
- **双缓存池**:`preloadedPageViews`(预加载池)和 `pageCurlCachedViews`(PageCurl 缓存池)
|
||||
- **预测性预加载**:根据 `preferredForward` 方向多预加载一页
|
||||
|
||||
### 5.2 关键方法
|
||||
|
||||
```swift
|
||||
/// 为指定页获取视图(优先从缓存取)
|
||||
func pageViewForDisplay(pageNum: Int, environment: Environment, contentViewProvider: (Int, UIView?) -> UIView?) -> UIView
|
||||
|
||||
/// 在指定页周围预加载
|
||||
func prime(around pageNum: Int, preferredForward: Bool?, parentView: UIView, environment: Environment, contentViewProvider: (Int, UIView?) -> UIView?)
|
||||
|
||||
/// 取走预加载的视图(用于 CollectionView 复用)
|
||||
func takePreloadedView(for pageNum: Int) -> UIView?
|
||||
|
||||
/// 使缓存失效
|
||||
func invalidate(environment: Environment)
|
||||
```
|
||||
|
||||
### 5.3 Environment 结构体
|
||||
|
||||
```swift
|
||||
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
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 双页展开计算:RDReaderSpreadResolver
|
||||
|
||||
**文件:** `Paging/RDReaderSpreadResolver.swift`
|
||||
|
||||
纯函数式结构体,负责双页模式下的页面配对和导航计算。
|
||||
|
||||
```swift
|
||||
struct RDReaderSpreadResolver {
|
||||
/// 判断是否为全屏页(封面页独占一屏)
|
||||
func isFullScreenPage(_ pageNum: Int, landscapeDualPageEnabled: Bool, isLandscape: Bool, coverPageIndex: Int?) -> Bool
|
||||
|
||||
/// 计算双页配对(left, right?),right 为 nil 表示独占一屏
|
||||
func dualPagePair(for pageNum: Int, totalPages: Int, coverPageIndex: Int?) -> (left: Int, right: Int?)
|
||||
|
||||
/// 计算相邻双页的起始页码
|
||||
func adjacentDualPage(from pageNum: Int, totalPages: Int, coverPageIndex: Int?, forward: Bool) -> Int?
|
||||
|
||||
/// 计算下一页(支持单页和双页模式)
|
||||
func nextPage(from currentPage: Int, totalPages: Int, pagesPerScreen: Int, coverPageIndex: Int?, forward: Bool) -> Int?
|
||||
}
|
||||
```
|
||||
|
||||
**封面页逻辑:**
|
||||
- 封面页(`coverPageIndex`)独占一屏,不与其他页配对
|
||||
- 封面后的页面从 `coverIndex + 1` 开始两两配对
|
||||
|
||||
---
|
||||
|
||||
## 7. 点击区域判定:RDReaderTapRegionHandler
|
||||
|
||||
**文件:** `Paging/RDReaderTapRegionHandler.swift`
|
||||
|
||||
将屏幕三等分,判定点击属于左/中/右区域。
|
||||
|
||||
```swift
|
||||
struct RDReaderTapRegionHandler {
|
||||
func resolveTapEvent(point: CGPoint, viewFrame: CGRect, isToolViewVisible: Bool) -> RDReaderView.TapEvent
|
||||
}
|
||||
```
|
||||
|
||||
**逻辑:**
|
||||
- 左 1/3 → `.left`(上一页),工具栏可见时改为 `.center`
|
||||
- 中 1/3 → `.center`(切换工具栏)
|
||||
- 右 1/3 → `.right`(下一页),工具栏可见时改为 `.center`
|
||||
|
||||
---
|
||||
|
||||
## 8. 流式布局:RDReaderFlowLayout
|
||||
|
||||
**文件:** `RDReaderFlowLayout.swift`
|
||||
|
||||
`UICollectionViewFlowLayout` 子类,支持水平滚动和垂直滚动两种模式。
|
||||
|
||||
### 8.1 关键属性
|
||||
|
||||
| 属性 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `displayType` | `RDReaderView.DisplayType` | 布局模式 |
|
||||
| `isLandscapeDualPage` | `Bool` | 是否横屏双页 |
|
||||
| `coverPageIndex` | `Int?` | 封面页索引 |
|
||||
| `pagesPerScreen` | `Int` | 每屏页数 |
|
||||
|
||||
### 8.2 协议
|
||||
|
||||
```swift
|
||||
public protocol RDReaderFlowLayoutDataSoure: NSObjectProtocol {
|
||||
func heigtOfVerticalScrollPage(flowLayout: RDReaderFlowLayout, pageIndex: Int) -> CGFloat?
|
||||
}
|
||||
|
||||
@objc public protocol RDReaderFlowLayoutDelegate: NSObjectProtocol {
|
||||
func pageNum(flowLayout: RDReaderFlowLayout, pageIndex: Int)
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3 关键方法
|
||||
|
||||
```swift
|
||||
/// 计算指定页码的 contentOffset
|
||||
func currentContentOffset(count: Int) -> CGPoint
|
||||
```
|
||||
|
||||
### 8.4 封面页布局逻辑
|
||||
|
||||
双页模式下,封面页占满整屏宽度,后续页面两两配对占半屏宽度。`coverAwareFrame(for:screenWidth:halfWidth:height:)` 方法根据页码计算对应的 frame。
|
||||
|
||||
---
|
||||
|
||||
## 9. 内容 Cell:RDReaderContentCell
|
||||
|
||||
**文件:** `RDReaderContentCell.swift`
|
||||
|
||||
`UICollectionViewCell` 子类,用于 Scroll 模式下承载页面内容视图。
|
||||
|
||||
```swift
|
||||
class RDReaderContentCell: UICollectionViewCell {
|
||||
var containerView: UIView? // 设置时自动添加到 contentView,移除旧视图
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. PageCurl 子控制器:RDReaderPageChildViewController
|
||||
|
||||
**文件:** `RDReaderPageChildViewController.swift`
|
||||
|
||||
`UIViewController` 子类,作为 `UIPageViewController` 的页面 VC。
|
||||
|
||||
```swift
|
||||
class RDReaderPageChildViewController: UIViewController {
|
||||
var contentView: UIView? // 内容视图,设置时自动安装到容器
|
||||
var pageNum: Int // 对应页码
|
||||
|
||||
init(contentView: UIView?, pageNum: Int = 0)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Extension 汇总
|
||||
|
||||
### 11.1 RDReaderView+PageCurl
|
||||
|
||||
实现 `UIPageViewControllerDataSource` 和 `UIPageViewControllerDelegate`:
|
||||
|
||||
- `pageViewController(_:viewControllerBefore:)` — 提供前一页 VC(RTL 时逻辑反转)
|
||||
- `pageViewController(_:viewControllerAfter:)` — 提供后一页 VC
|
||||
- `pageViewController(_:didFinishAnimating:...)` — 完成动画后更新 currentPage
|
||||
- `pageViewController(_:willTransitionTo:)` — 即将翻页时预加载
|
||||
|
||||
**特殊页码:**
|
||||
- `RDReaderView.blankPageNum`(`Int.max`)— 双页模式下的空白页
|
||||
- `RDReaderView.blankEndPageNum`(`Int.max - 1`)— 末尾空白页
|
||||
|
||||
### 11.2 RDReaderView+CollectionView
|
||||
|
||||
实现 `UICollectionViewDataSource`、`RDReaderFlowLayoutDelegate`、`RDReaderFlowLayoutDataSoure`:
|
||||
|
||||
- `collectionView(_:cellForItemAt:)` — 复用预加载视图或创建新 Cell
|
||||
- `collectionView(_:numberOfItemsInSection:)` — 返回总页数
|
||||
- `pageNum(flowLayout:pageIndex:)` — 滚动时更新当前页码
|
||||
|
||||
### 11.3 RDReaderView+ContentAccess
|
||||
|
||||
提供内容视图的注册、复用和查询:
|
||||
|
||||
```swift
|
||||
/// 注册内容视图类型(类似 UICollectionView 的 register)
|
||||
public func register(contentView: UIView.Type, contentViewWithReuseIdentifier identifier: String)
|
||||
|
||||
/// 获取可复用的内容视图
|
||||
public func dequeueReusableContentView(withReuseIdentifier identifier: String, for pageNum: Int) -> UIView
|
||||
|
||||
/// 获取指定页的内容视图
|
||||
public func pageContentView(pageNum: Int) -> UIView?
|
||||
|
||||
/// 计算单页尺寸(考虑双页模式)
|
||||
public func resolvedSinglePageSize(pageNum: Int? = nil) -> CGSize
|
||||
```
|
||||
|
||||
### 11.4 RDReaderView+ToolView
|
||||
|
||||
管理顶部/底部工具栏的安装、显示/隐藏动画:
|
||||
|
||||
```swift
|
||||
/// 切换工具栏显示状态(点击中心区域触发)
|
||||
func tapCenter()
|
||||
|
||||
/// 安装工具栏视图到指定位置
|
||||
func installToolViewIfNeeded(_ toolView: UIView, position: ToolViewPosition)
|
||||
|
||||
/// 更新工具栏高度约束
|
||||
func updateToolViewHeightConstraintsIfNeeded()
|
||||
```
|
||||
|
||||
**动画效果:** 顶部工具栏从上方滑入,底部工具栏从下方滑入。
|
||||
|
||||
---
|
||||
|
||||
## 12. 设计模式总结
|
||||
|
||||
| 模式 | 应用 |
|
||||
|------|------|
|
||||
| **策略模式** | `DisplayType` 切换 PageCurl / Scroll 两种翻页策略 |
|
||||
| **适配器模式** | `RDReaderLegacyDataSourceAdapter` 将旧 `RDReaderDataSource` 适配为 `RDReaderPageProvider` |
|
||||
| **命令队列** | `RDReaderPagingController` 管理翻页请求队列 |
|
||||
| **缓存签名** | `RDReaderPreloadController.CacheSignature` 检测环境变化自动失效 |
|
||||
| **关注点分离** | Extension 将不同功能拆分到独立文件 |
|
||||
| **纯函数** | `RDReaderSpreadResolver` 和 `RDReaderTapRegionHandler` 无状态计算 |
|
||||
|
||||
---
|
||||
|
||||
## 13. 数据流图
|
||||
|
||||
```
|
||||
用户点击屏幕
|
||||
│
|
||||
▼
|
||||
RDReaderTapRegionHandler.resolveTapEvent()
|
||||
│
|
||||
├── .left → goPreviousPage() ──→ spreadResolver.nextPage(forward: false)
|
||||
├── .right → goNextPage() ──→ spreadResolver.nextPage(forward: true)
|
||||
└── .center → tapCenter() ──→ 显示/隐藏工具栏
|
||||
|
||||
transitionToPage(pageNum:)
|
||||
│
|
||||
├── PageCurl 模式
|
||||
│ ├── pagingController.shouldQueuePageTransition() → 排队或执行
|
||||
│ ├── pageViewForDisplay() → preloadController 取缓存视图
|
||||
│ ├── pageViewController.setViewControllers()
|
||||
│ └── primePageCache() → 预加载周围页面
|
||||
│
|
||||
└── Scroll 模式
|
||||
├── collectionView.reloadData()
|
||||
├── collectionView.setContentOffset()
|
||||
└── primePageCache() → 预加载周围页面
|
||||
```
|
||||
@@ -0,0 +1,349 @@
|
||||
# 排版管线详解
|
||||
|
||||
> 最后更新:2026-06-18
|
||||
|
||||
本文档详细描述 ReadViewSDK 文本排版管线(Typesetter Pipeline)的 8 个处理阶段、核心算法和数据流。
|
||||
|
||||
---
|
||||
|
||||
## 1. 概述
|
||||
|
||||
排版管线是 EPUBTextRendering 层的核心组件,负责将 EPUB 原始 HTML 转换为可分页的 NSAttributedString。它是 textReflowable 渲染路径(本 SDK 核心路径)的关键环节。
|
||||
|
||||
**入口**:`RDEPUBTextTypesetterPipeline.makeRequest(from:)`
|
||||
|
||||
**输入**:`RDEPUBTypesettingInput`(原始 HTML、样式、布局配置)
|
||||
|
||||
**输出**:`RDEPUBTypesettingOutput`(渲染请求、诊断信息、兼容性报告)
|
||||
|
||||
**关键文件**:`Sources/RDReaderView/EPUBTextRendering/Typesetter/`
|
||||
|
||||
---
|
||||
|
||||
## 2. 管线总览
|
||||
|
||||
```
|
||||
Raw HTML (从 EPUB 解压目录读取)
|
||||
│
|
||||
▼ [阶段 1] RDEPUBHTMLNormalizer
|
||||
│ 去 CR、合并空行、规范化附件 HTML
|
||||
│
|
||||
▼ [阶段 2] RDEPUBSemanticMarkerInjector
|
||||
│ 注入分页语义标记 ${rd-sem-start/end}
|
||||
│
|
||||
▼ [阶段 3] RDEPUBCFIMarkerInjector
|
||||
│ 注入 CFI 路径标记
|
||||
│
|
||||
▼ [阶段 4] RDEPUBStyleSheetComposer
|
||||
│ 内联 <link stylesheet>、构建 5 层 CSS、注入 <base href>
|
||||
│
|
||||
▼ [阶段 5] RDEPUBFontNormalizer
|
||||
│ 解析 @font-face、注册嵌入字体
|
||||
│
|
||||
▼ [阶段 6] RDEPUBFragmentMarkerInjector
|
||||
│ 注入 fragment 锚点标记 ${id=xxx}
|
||||
│
|
||||
▼ [阶段 7] RDEPUBRenderDiagnosticsCollector
|
||||
│ 收集图片诊断、资源引用检查
|
||||
│
|
||||
▼ 构建 RDEPUBTextChapterRenderRequest
|
||||
│
|
||||
▼ [阶段 8] RDEPUBDTCoreTextRenderer
|
||||
HTML → NSAttributedString → 后处理 → 分页
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 阶段 1:HTML 规范化(RDEPUBHTMLNormalizer)
|
||||
|
||||
**文件**:`RDEPUBHTMLNormalizer.swift`
|
||||
|
||||
### 3.1 处理内容
|
||||
|
||||
| 规则 | 说明 |
|
||||
|------|------|
|
||||
| CR → LF | 统一换行符 |
|
||||
| 合并连续空行 | 多个空行合并为一个 |
|
||||
| 删除分页标记 | `<hr lang="zh-CN">分页符</hr>` |
|
||||
| 附件 HTML 规范化 | 见下表 |
|
||||
|
||||
### 3.2 附件 HTML 规范化规则
|
||||
|
||||
| 原始 HTML | 规范化结果 |
|
||||
|-----------|-----------|
|
||||
| `div.qrbodyPic / div.bodyPic` | 合并样式到 `<img>` |
|
||||
| `img.qqreader-footnote` | 行内 1em×1em |
|
||||
| `h1.frontCover > img` | 封面图片 100% 宽度 |
|
||||
|
||||
### 3.3 Base URL 注入
|
||||
|
||||
在 `<head>` 开头注入 `<base href="...">` 用于相对路径解析:
|
||||
|
||||
```swift
|
||||
static func injectBaseHref(into html: String, baseURL: URL?) -> String
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 阶段 2:语义标记注入(RDEPUBSemanticMarkerInjector)
|
||||
|
||||
**文件**:`RDEPUBSemanticMarkerInjector.swift`
|
||||
|
||||
### 4.1 标记语法
|
||||
|
||||
```html
|
||||
${rd-sem-start:id=X;block=...;hints=...;placement=...}
|
||||
...内容...
|
||||
${rd-sem-end:id=X}
|
||||
```
|
||||
|
||||
### 4.2 注入规则
|
||||
|
||||
- 遍历所有 HTML 标签,维护开标签栈
|
||||
- 为有分页语义的标签注入标记:
|
||||
- `block`:块类型(paragraph、heading、list 等)
|
||||
- `hints`:分页提示(avoidPageBreakInside、keepWithNext 等)
|
||||
- `placement`:附件位置(inline、block)
|
||||
- 空标签(img, br, hr)同时注入开始和结束标记
|
||||
|
||||
### 4.3 语义提示类型
|
||||
|
||||
| 提示 | 说明 |
|
||||
|------|------|
|
||||
| `avoidPageBreakInside` | 避免在块内分页 |
|
||||
| `keepWithNext` | 与下一个块保持同页 |
|
||||
| `attachmentBlock` | 块级附件(图片等) |
|
||||
|
||||
---
|
||||
|
||||
## 5. 阶段 3:CFI 标记注入(RDEPUBCFIMarkerInjector)
|
||||
|
||||
**文件**:`RDEPUBCFIMarkerInjector.swift`
|
||||
|
||||
注入 CFI 路径标记,用于后续 CFI 映射构建。在每个文本节点前注入 CFI 路径信息,使渲染后的 NSAttributedString 能够建立 CFI 路径到文本偏移量的映射。
|
||||
|
||||
---
|
||||
|
||||
## 6. 阶段 4:CSS 层合成(RDEPUBStyleSheetComposer)
|
||||
|
||||
**文件**:`RDEPUBStyleSheetComposer.swift`
|
||||
|
||||
### 6.1 五层 CSS 架构
|
||||
|
||||
按优先级从低到高:
|
||||
|
||||
| 层 | Kind | 说明 | 注入位置 |
|
||||
|----|------|------|----------|
|
||||
| 1 | `.default` | 基础阅读器样式(隐藏 head/title/style,默认字体大小) | `<head>` 开头 |
|
||||
| 2 | `.replace` | 格式化样式(代码块、标题、引用、列表) | `<head>` 开头 |
|
||||
| 3 | `.dark` | 暗色模式覆盖(仅暗色主题时注入) | `<head>` 开头 |
|
||||
| 4 | `.epub` | EPUB 自带样式(内联后的 `<link stylesheet>`) | `<head>` 末尾 |
|
||||
| 5 | `.user` | 用户设置(字号、行距、颜色,!important) | `<head>` 末尾 |
|
||||
|
||||
### 6.2 语言检测
|
||||
|
||||
```swift
|
||||
static func prefersLatinLanguageCSS(
|
||||
languageCode: String?,
|
||||
sourceHTML: String
|
||||
) -> Bool
|
||||
```
|
||||
|
||||
检测逻辑:
|
||||
1. 检查 `lang` 属性(如 `lang="en"`)
|
||||
2. 对 HTML 文本采样,统计拉丁字符比例
|
||||
3. 拉丁语言使用专用 CSS(`wxread-replace-latin.css`)
|
||||
|
||||
### 6.3 样式表内联
|
||||
|
||||
`RDEPUBRenderDiagnosticsCollector.inlineLinkedStyleSheets()` 负责:
|
||||
1. 查找 `<link rel=stylesheet href=...>`
|
||||
2. 读取 CSS 文件内容
|
||||
3. 重写 CSS 中的相对 `url()` 引用
|
||||
4. 内联到 HTML 的 `<head>` 中
|
||||
|
||||
---
|
||||
|
||||
## 7. 阶段 5:字体注册(RDEPUBFontNormalizer)
|
||||
|
||||
**文件**:`RDEPUBFontNormalizer.swift`
|
||||
|
||||
### 7.1 处理流程
|
||||
|
||||
1. 解析 `@font-face { url(...) }` 块
|
||||
2. 提取字体文件路径和 family 名称
|
||||
3. 通过 `CTFontManagerRegisterFontsForURL(.process)` 注册嵌入字体
|
||||
4. 已注册字体路径缓存在 `registeredFontPaths` 集合中,避免重复注册
|
||||
|
||||
### 7.2 注册结果
|
||||
|
||||
```swift
|
||||
struct RDEPUBFontRegistrationResult {
|
||||
let descriptor: RDEPUBFontDescriptor
|
||||
let didRegister: Bool
|
||||
let errorDescription: String?
|
||||
}
|
||||
```
|
||||
|
||||
注册失败的字体不会阻断管线,但会记录到兼容性报告中。
|
||||
|
||||
---
|
||||
|
||||
## 8. 阶段 6:Fragment 标记注入(RDEPUBFragmentMarkerInjector)
|
||||
|
||||
**文件**:`RDEPUBFragmentMarkerInjector.swift`
|
||||
|
||||
扫描 HTML 中的 `id` 属性,在其前面注入 `${id=xxx}` 标记:
|
||||
|
||||
```html
|
||||
<h2 id="section1">标题</h2>
|
||||
→
|
||||
${id=section1}<h2 id="section1">标题</h2>
|
||||
```
|
||||
|
||||
渲染后这些标记会被转换为 NSAttributedString 属性,用于 fragment 偏移量提取。
|
||||
|
||||
---
|
||||
|
||||
## 9. 阶段 7:诊断收集(RDEPUBRenderDiagnosticsCollector)
|
||||
|
||||
**文件**:`RDEPUBRenderDiagnosticsCollector.swift`
|
||||
|
||||
### 9.1 图片诊断
|
||||
|
||||
- 扫描 `<img src="...">` 标签
|
||||
- 解析引用路径,检查文件是否存在
|
||||
- 收集诊断信息用于调试
|
||||
|
||||
### 9.2 资源引用检查
|
||||
|
||||
- 检查 CSS 中的 `url()` 引用
|
||||
- 验证字体文件是否存在
|
||||
- 记录缺失资源的诊断信息
|
||||
|
||||
---
|
||||
|
||||
## 10. 阶段 8:渲染与后处理(RDEPUBDTCoreTextRenderer)
|
||||
|
||||
**文件**:`RDEPUBDTCoreTextRenderer.swift`
|
||||
|
||||
### 10.1 HTML → NSAttributedString
|
||||
|
||||
```swift
|
||||
func renderChapter(request: RDEPUBTextChapterRenderRequest) throws -> RDEPUBRenderedChapterContent
|
||||
```
|
||||
|
||||
1. 将 HTML 编码为 `Data`
|
||||
2. 使用 `DTHTMLAttributedStringBuilder` 构建 `NSAttributedString`
|
||||
3. 在 `willFlushCallback` 中对每个 DOM 元素调用 `RDEPUBAttachmentNormalizer.prepareHTMLElementForReaderRendering()`
|
||||
|
||||
### 10.2 后处理
|
||||
|
||||
**applyPaginationSemantics()**:
|
||||
- 将 `${rd-sem-start/end}` 标记转为 NSAttributedString 属性
|
||||
- 属性键:`.rdPageSemanticHints`、`.rdPageBlockKind`、`.rdPageAttachmentPlacement`
|
||||
|
||||
**extractFragmentOffsets()**:
|
||||
- 提取 `${id=xxx}` 标记
|
||||
- 生成 fragment ID → 字符偏移量映射
|
||||
- 删除标记文本
|
||||
|
||||
**normalizeReadingAttributes()**:
|
||||
- 规范化字体(应用用户选择的字体)
|
||||
- 调整行距(应用 lineHeightMultiple)
|
||||
- 设置文字颜色(应用主题颜色)
|
||||
- 处理附件(图片缩放、对齐)
|
||||
|
||||
---
|
||||
|
||||
## 11. 分页计算
|
||||
|
||||
### 11.1 RDEPUBChapterPageCounter
|
||||
|
||||
**文件**:`Pagination/RDEPUBChapterPageCounter.swift`
|
||||
|
||||
使用 CoreText 迭代分页:
|
||||
|
||||
```swift
|
||||
func layoutFrames(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame]
|
||||
```
|
||||
|
||||
**分页循环**:
|
||||
|
||||
```
|
||||
location = 0
|
||||
while location < totalLength:
|
||||
1. 创建 CTFrame(通过 CTFramesetter)
|
||||
2. 获取可见范围 (CTFrameGetVisibleStringRange)
|
||||
3. 应用 avoidPageBreakInside 规则(最多移除 3 行尾部)
|
||||
4. 应用 keepWithNext 规则(最多移除 3 行尾部)
|
||||
5. 应用 widow/orphan 控制
|
||||
6. 应用 pageBreakPolicy 调整
|
||||
7. 记录页面范围
|
||||
8. location = 调整后的范围末尾
|
||||
```
|
||||
|
||||
### 11.2 RDEPUBPageBreakPolicy
|
||||
|
||||
**文件**:`Pagination/RDEPUBPageBreakPolicy.swift`
|
||||
|
||||
分页规则优先级:
|
||||
|
||||
| 规则 | 说明 | 最大调整行数 |
|
||||
|------|------|-------------|
|
||||
| `avoidPageBreakInside` | 块内不分页(标题、图片等) | 3 行 |
|
||||
| `keepWithNext` | 标题与正文不分离 | 3 行 |
|
||||
| widow control | 段落最后一行不留到下一页 | 1 行 |
|
||||
| orphan control | 段落第一行不单独在上一页 | 1 行 |
|
||||
| attachment boundary | 块级图片前后分页 | 0(精确切分) |
|
||||
|
||||
**判断方法**:
|
||||
|
||||
```swift
|
||||
func lineIsInAvoidPageBreakInsideBlock(_ lineRange: NSRange) -> Bool
|
||||
func lineIsInKeepWithNextBlock(_ lineRange: NSRange) -> Bool
|
||||
func adjustedRange(from:totalLength:lineRanges:factory:) -> (range, breakReason, ...)
|
||||
```
|
||||
|
||||
### 11.3 RDEPUBChapterTailNormalizer
|
||||
|
||||
**文件**:`BuildPipeline/RDEPUBChapterTailNormalizer.swift`
|
||||
|
||||
三遍处理:
|
||||
|
||||
1. **删除空白中间帧**:无可见字符且无附件的帧
|
||||
2. **删除空白尾部帧**:从末尾开始删除同类空白帧
|
||||
3. **合并短尾帧**:如果最后一帧 ≤2 个可见字符且前一帧 ≥8 倍长,合并
|
||||
|
||||
---
|
||||
|
||||
## 12. 诊断与调试
|
||||
|
||||
### 12.1 兼容性报告
|
||||
|
||||
```swift
|
||||
struct RDEPUBCSSCompatibilityReport {
|
||||
let unsupportedRules: [String] // 不支持的 CSS 规则
|
||||
let normalizedRules: [String] // 已规范化的规则
|
||||
let fontFailures: [String] // 字体注册失败
|
||||
}
|
||||
```
|
||||
|
||||
### 12.2 资源诊断
|
||||
|
||||
```swift
|
||||
struct RDEPUBTextResourceReferenceDiagnostic {
|
||||
let href: String // 引用路径
|
||||
let exists: Bool // 文件是否存在
|
||||
let type: String // 资源类型(image/font/stylesheet)
|
||||
}
|
||||
```
|
||||
|
||||
### 12.3 语义摘要
|
||||
|
||||
`RDEPUBReaderController.nativeTextSemanticSummary()` 返回当前页面的语义摘要,包含:
|
||||
- 页码
|
||||
- 分页原因(breakReason)
|
||||
- 块类型(blockKinds)
|
||||
- 语义提示(semanticHints)
|
||||
- 附件位置(attachmentPlacements)
|
||||
+17
-2
@@ -1,6 +1,6 @@
|
||||
# ReadViewSDK 文档索引
|
||||
|
||||
> 最后更新:2026-06-15
|
||||
> 最后更新:2026-06-18
|
||||
|
||||
---
|
||||
|
||||
@@ -12,6 +12,15 @@
|
||||
| [UML_CLASS_DIAGRAMS.md](UML_CLASS_DIAGRAMS.md) | UML 类图(Mermaid 格式):模块关系、核心类图、并发模型、缓存键设计 |
|
||||
| [BUSINESS_LOGIC.md](BUSINESS_LOGIC.md) | 业务逻辑详解:EPUB 解析、文本渲染管线、后台解析优化、翻页容器、标注系统、搜索、设置 |
|
||||
|
||||
## 代码级参考文档
|
||||
|
||||
| 文档 | 说明 |
|
||||
|------|------|
|
||||
| [EPUBCore_CODE_REFERENCE.md](EPUBCore_CODE_REFERENCE.md) | EPUBCore 模块代码级文档:解析器、Publication、WebView、JS 桥接、CFI、搜索、资源解析(~50 文件) |
|
||||
| [EPUBTextRendering_CODE_REFERENCE.md](EPUBTextRendering_CODE_REFERENCE.md) | EPUBTextRendering 模块代码级文档:排版管线、文本渲染、分页引擎、构建管线、索引表(~30 文件) |
|
||||
| [EPUBUI_CODE_REFERENCE.md](EPUBUI_CODE_REFERENCE.md) | EPUBUI 模块代码级文档:阅读器控制器、协调器、章节运行时、设置、文本页面、标注(~60 文件) |
|
||||
| [ReaderView_CODE_REFERENCE.md](ReaderView_CODE_REFERENCE.md) | ReaderView 模块代码级文档:翻页容器、预加载、双页布局、手势、流式布局(14 文件) |
|
||||
|
||||
## 工程规范与风险
|
||||
|
||||
| 文档 | 说明 |
|
||||
@@ -25,14 +34,20 @@
|
||||
|
||||
| 文档 | 说明 |
|
||||
|------|------|
|
||||
| [TYPESetter_PIPELINE.md](TYPESetter_PIPELINE.md) | Typesetter 排版管线详解:HTML 规范化、语义标记注入、CFI 标记、样式合成为、字体规范化、片段标记 |
|
||||
| [CHAPTER_RUNTIME.md](CHAPTER_RUNTIME.md) | 章节运行时详解:按需加载、章节窗口协调、页图管理、磁盘缓存、后台补全 |
|
||||
| [CFI_SUBSYSTEM.md](CFI_SUBSYSTEM.md) | CFI 子系统详解:EPUB CFI 解析、生成、序列化、范围、恢复引擎 |
|
||||
| [CFI_ISSUES_REVIEW.md](CFI_ISSUES_REVIEW.md) | CFI 实现问题分析报告:11 个问题(3 高 / 5 中 / 3 低),含修复优先级建议 |
|
||||
| [API_REFERENCE.md](API_REFERENCE.md) | 公开 API 参考:RDEPUBReaderController 公开接口、委托协议、配置模型 |
|
||||
| [当前阅读器问题修复开发清单.md](当前阅读器问题修复开发清单.md) | 基于当前代码实现整理的逐任务开发计划:10 个任务、修改位置、实施步骤、风险点与验收标准 |
|
||||
| [大书远距目录跳转与后台补全优化方案.md](大书远距目录跳转与后台补全优化方案.md) | 面向大书按需分页模式的完整优化方案:远距目录跳转、后台补全优先级、页图接管协议与分阶段实施路径 |
|
||||
| [标准级EPUB定位与兼容能力开发蓝图.md](标准级EPUB定位与兼容能力开发蓝图.md) | 面向商业级 EPUB 阅读器的可执行蓝图:完整版 CFI、无 id 节点双向恢复、脚注弹层、字体回退与 CSS 兼容层 |
|
||||
| [大书后台解析优化实施清单_30秒目标.md](大书后台解析优化实施清单_30秒目标.md) | 《凡人修仙传》后台解析性能优化方案,目标从 70s 压缩到 30-50s |
|
||||
|
||||
## 项目信息
|
||||
|
||||
- **模块总数:** 4 个(EPUBCore、EPUBTextRendering、RDReaderView、EPUBUI)
|
||||
- **Swift 文件数:** 139 个(SDK Sources)
|
||||
- **Swift 文件数:** 166 个(SDK Sources)
|
||||
- **测试用例数:** 23 个测试类,约 99 个测试方法(UI 测试)
|
||||
- **最低 iOS 版本:** 15.6
|
||||
- **构建方式:** CocoaPods(本地 pod)
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
# readoor vs ReadViewSDK 功能差距分析
|
||||
|
||||
> 最后更新:2026-06-18
|
||||
|
||||
---
|
||||
|
||||
## 概述
|
||||
|
||||
本文档对比 readoor(`BookView/EPUB/`)EPUB 阅读器与 ReadViewSDK 的功能差异,列出 readoor 已实现但 ReadViewSDK **尚未实现**的功能,以及实现方式存在差异的功能。
|
||||
|
||||
---
|
||||
|
||||
## 未实现的功能
|
||||
|
||||
### 1. 字体下载
|
||||
|
||||
**readoor 状态:** 已实现。`RBCoreEpubFontDownloadViewController` + `RBCoreEpubFontDownloadHandler` 提供字体下载管理,支持用户从服务端下载自定义字体并通过 `@font-face` 注入阅读器。
|
||||
|
||||
**ReadViewSDK 状态:** 未实现。`RDEPUBFontNormalizer` 仅负责注册 EPUB 内嵌字体(`CTFontManagerRegisterFontsForURL`)和系统字体回退,无字体下载/商店功能。
|
||||
|
||||
**影响:** 用户只能使用系统字体和 EPUB 自带字体,无法扩展字体选择。
|
||||
|
||||
---
|
||||
|
||||
### 2. 服务器同步笔记/书签
|
||||
|
||||
**readoor 状态:** 已实现。`pullEpubNoteInfo`/`pushEpubNoteInfo` 通过 API 同步笔记到云端,支持多设备数据一致性。
|
||||
|
||||
**ReadViewSDK 状态:** 未实现。`RDEPUBReaderDelegate` 提供 `didUpdateHighlights`、`didUpdateBookmarks` 等回调,但仅为本地通知,无网络请求或远程同步逻辑。笔记/书签数据完全在本地管理。
|
||||
|
||||
**影响:** 用户换设备后阅读数据(书签、高亮、笔记)会丢失。
|
||||
|
||||
---
|
||||
|
||||
### 3. 加密 EPUB 解密
|
||||
|
||||
**readoor 状态:** 已实现。`STSRDFileManagerStorage readToFileDeCrypt:password:` 基于 bookID 拼接密码,对所有 EPUB 资源(HTML、CSS、图片)进行解密加载。
|
||||
|
||||
**ReadViewSDK 状态:** 未实现。`RDEPUBParser` 直接解析标准 EPUB 归档,无加解密层。
|
||||
|
||||
**影响:** 无法打开加密的 EPUB 文件。
|
||||
|
||||
---
|
||||
|
||||
### 4. 试读/购买权限控制
|
||||
|
||||
**readoor 状态:** 已实现。支持三种权限模式:免费试读、登录后免费、购买后阅读。通过 API 返回的权限字段控制章节可读性。
|
||||
|
||||
**ReadViewSDK 状态:** 未实现。无试读限制、付费墙或章节权限控制逻辑。
|
||||
|
||||
**影响:** SDK 无商业化能力,无法限制用户只阅读已购买的章节。
|
||||
|
||||
---
|
||||
|
||||
### 5. 阅读统计上报
|
||||
|
||||
**readoor 状态:** 已实现。`RDStatisticsManager` 上报阅读进度(sectionNo + 页面内进度),集成埋点系统。
|
||||
|
||||
**ReadViewSDK 状态:** 未实现。JS 端 `reportProgression()` 仅将进度通过 `webkit.messageHandlers` 传递给原生端,属于本地 bridge 通信,无远程上报。
|
||||
|
||||
**影响:** 无法收集用户阅读行为数据用于运营分析。
|
||||
|
||||
---
|
||||
|
||||
### 6. 图片点击放大
|
||||
|
||||
**readoor 状态:** 已实现。点击 EPUB 内图片发送通知,弹出全屏图片查看(`showImageView`)。
|
||||
|
||||
**ReadViewSDK 状态:** 未实现。JS 端 `handleDocumentClick`(`epub-bridge.js:388-400`)仅处理链接点击,不处理图片点击事件。
|
||||
|
||||
**影响:** 用户无法放大查看 EPUB 中的小图片或细节图。
|
||||
|
||||
---
|
||||
|
||||
## 实现方式存在差异的功能
|
||||
|
||||
### 7. 亮度调节
|
||||
|
||||
**readoor 方式:** 通过黑色半透明 CALayer overlay 暗化屏幕,不改变系统亮度。
|
||||
|
||||
**ReadViewSDK 方式:** 直接设置 `UIScreen.main.brightness`(系统屏幕亮度 API)。已实现,但会影响系统全局亮度。
|
||||
|
||||
---
|
||||
|
||||
### 8. 搜索结果上下文
|
||||
|
||||
**readoor 方式:** 搜索结果显示匹配文本前后各 30 字符上下文。
|
||||
|
||||
**ReadViewSDK 方式:** `previewRadius` 为 12 字符(`RDEPUBReaderController+DataSource.swift:196-201`),上下文较短。
|
||||
|
||||
---
|
||||
|
||||
### 9. CSS 主题切换机制
|
||||
|
||||
**readoor 方式:** 通过 JS `classList.toggle("mycss")` 切换 CSS class 实现主题切换。
|
||||
|
||||
**ReadViewSDK 方式:** 通过 `cssInjector.js` 的 `window.RDInjectedCSS.setStyle(identifier, cssText, options)` 直接操作 style 标签内容。功能等价,但机制不同。
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
| # | 功能 | 优先级 | 复杂度 |
|
||||
|---|------|--------|--------|
|
||||
| 1 | 字体下载 | 中 | 中(需后端接口 + 下载管理) |
|
||||
| 2 | 服务器同步 | 高 | 高(需 API 设计 + 冲突解决) |
|
||||
| 3 | 加密 EPUB | 高 | 中(需对接加密方案) |
|
||||
| 4 | 试读/购买权限 | 高 | 低(SDK 层暴露权限接口即可) |
|
||||
| 5 | 阅读统计上报 | 中 | 低(delegate 暴露事件即可) |
|
||||
| 6 | 图片点击放大 | 中 | 低(JS 端监听图片点击 + 原生展示) |
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,744 @@
|
||||
# 标准级 EPUB 定位与兼容能力开发蓝图
|
||||
|
||||
> 最后更新:2026-06-18
|
||||
> 适用范围:`EPUBCore/CFI`、`EPUBCore/Notes`、`EPUBTextRendering`、`EPUBUI`
|
||||
> 目标:把当前阅读器从“主流文本书可用”推进到“商业级 EPUB 阅读器必须具备的定位与兼容能力”。
|
||||
|
||||
---
|
||||
|
||||
## 1. 文档目标
|
||||
|
||||
本文不是概念方案,而是可直接拆任务、排期、开发和回归的实施蓝图,覆盖三项“必须做”的能力:
|
||||
|
||||
1. `EPUB CFI` 标准级定位与持久化
|
||||
2. 脚注 / 尾注弹层阅读
|
||||
3. 嵌入字体回退 + CSS 兼容层
|
||||
|
||||
其中 `EPUB CFI` 按完整版路线执行,核心要求是:
|
||||
|
||||
1. 不依赖 `id` 也能做章节内精确 DOM 定位
|
||||
2. 支持“阅读位置 -> CFI”和“CFI -> 阅读位置”的双向恢复
|
||||
3. 在换字号、换字体、换行距、重新分页后仍能稳定恢复
|
||||
4. 为高亮、书签、搜索命中、脚注锚点、未来跨设备同步提供统一锚点
|
||||
|
||||
---
|
||||
|
||||
## 2. 当前基线
|
||||
|
||||
当前仓库已经有一部分基础设施,不是从零开始:
|
||||
|
||||
### 2.1 已有能力
|
||||
|
||||
1. 已有 `RDEPUBLocation.cfi / lastCFI / rangeCFI`
|
||||
2. 已有 `RDEPUBCFIParser / Serializer / Resolver / Generator`
|
||||
3. 已有 `RDEPUBCFIMap` 和章节级 `marker`
|
||||
4. 已有基于文本节点路径的初版 `cfiMap` 构建
|
||||
5. 已有脚注检测、解析和弹层 UI 的第一版入口
|
||||
6. 已有 CSS 兼容层与字体 fallback resolver 的第一版模型
|
||||
|
||||
### 2.2 当前仍然不够的地方
|
||||
|
||||
当前实现更像“标准级路线的 Phase 0.5”,还差以下闭环:
|
||||
|
||||
1. 无 `id` 节点的 DOM 路径恢复还只是“文本对齐驱动”,未达到标准级双向校准
|
||||
2. `CFI -> 章内偏移` 仍主要依赖单个 marker 命中,缺少区间级、断言级回退链
|
||||
3. 还没有持久化“DOM 恢复所需的结构指纹”,章节缓存命中后无法做更强恢复
|
||||
4. 脚注弹层还没有完整纳入 CFI 锚点、回跳和高亮链路
|
||||
5. CSS 兼容层仍偏“样式修正函数”,还不是完整的兼容策略包
|
||||
6. 缺少样书库、诊断报告和标准级回归基线
|
||||
|
||||
---
|
||||
|
||||
## 3. 为什么要做“标准级无 id 节点双向恢复”
|
||||
|
||||
### 3.1 要解决的真实问题
|
||||
|
||||
如果只用 `href + progression` 或 `href + chapterOffset`:
|
||||
|
||||
1. 用户改字号、字体、行距、分栏后,位置会漂
|
||||
2. 高亮恢复会落到错误字词
|
||||
3. 搜索命中重开书后会跳偏
|
||||
4. 脚注回跳在长章节里不稳定
|
||||
5. 将来做跨设备同步时,同步点不可复用
|
||||
|
||||
### 3.2 做到标准级后的收益
|
||||
|
||||
1. 阅读位置恢复从“章节大致位置”升级到“字符级稳定锚点”
|
||||
2. 高亮、批注、书签、搜索、脚注全部共用同一套定位协议
|
||||
3. 章节重排后仍能尽可能落到同一语义位置
|
||||
4. 对不规范 EPUB 的容错能力显著提升
|
||||
5. 后续可以继续扩展到 WebView 路径、CFI 导出、跨端同步
|
||||
|
||||
---
|
||||
|
||||
## 4. 目标能力定义
|
||||
|
||||
## 4.1 CFI 能力分级
|
||||
|
||||
### L1 基础可用
|
||||
|
||||
1. 能解析 / 序列化单点 CFI 与 Range CFI
|
||||
2. 能持久化阅读位置和高亮范围
|
||||
3. 对带 `id` 的节点定位稳定
|
||||
|
||||
### L2 当前已接近
|
||||
|
||||
1. 能为文本节点生成路径
|
||||
2. 能在章节内做初步文本节点映射
|
||||
3. 能在部分重排场景下恢复位置
|
||||
|
||||
### L3 本文目标:标准级
|
||||
|
||||
1. 无 `id` 节点可精确恢复
|
||||
2. 位置恢复有多级回退链
|
||||
3. 断言、结构指纹、上下文窗口共同参与校准
|
||||
4. 章节缓存可直接携带恢复元数据
|
||||
5. 所有消费方统一走 `CFI` 主链路
|
||||
|
||||
---
|
||||
|
||||
## 5. 总体架构
|
||||
|
||||
建议把定位系统拆成四层:
|
||||
|
||||
1. `CFI Syntax Layer`
|
||||
2. `DOM Anchor Extraction Layer`
|
||||
3. `Recovery & Calibration Layer`
|
||||
4. `Consumer Integration Layer`
|
||||
|
||||
### 5.1 CFI Syntax Layer
|
||||
|
||||
职责:
|
||||
|
||||
1. 负责解析、序列化、生成、Range 拼装
|
||||
2. 不关心具体渲染结果
|
||||
|
||||
对应目录:
|
||||
|
||||
- `Sources/RDReaderView/EPUBCore/CFI/`
|
||||
|
||||
### 5.2 DOM Anchor Extraction Layer
|
||||
|
||||
职责:
|
||||
|
||||
1. 从原始 HTML 建立 DOM 路径
|
||||
2. 为文本节点、fragment、note anchor 建立索引
|
||||
3. 产出章节级恢复元数据
|
||||
|
||||
对应目录:
|
||||
|
||||
- `Sources/RDReaderView/EPUBCore/CFI/`
|
||||
- `Sources/RDReaderView/EPUBTextRendering/BuildPipeline/`
|
||||
|
||||
### 5.3 Recovery & Calibration Layer
|
||||
|
||||
职责:
|
||||
|
||||
1. 输入 `CFI`,输出稳定的章节字符偏移
|
||||
2. 输入字符偏移,输出稳定 `CFI`
|
||||
3. 在 DOM、文本、分页变动时完成校准与回退
|
||||
|
||||
对应目录:
|
||||
|
||||
- `Sources/RDReaderView/EPUBTextRendering/`
|
||||
- `Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/`
|
||||
|
||||
### 5.4 Consumer Integration Layer
|
||||
|
||||
职责:
|
||||
|
||||
1. 阅读位置恢复
|
||||
2. 高亮 / 批注 / 书签
|
||||
3. 搜索命中
|
||||
4. 脚注弹层与回跳
|
||||
|
||||
对应目录:
|
||||
|
||||
- `Sources/RDReaderView/EPUBUI/`
|
||||
|
||||
---
|
||||
|
||||
## 6. 核心数据结构
|
||||
|
||||
## 6.1 在现有模型上补强,不推翻
|
||||
|
||||
### `RDEPUBCFIMap`
|
||||
|
||||
现有结构:
|
||||
|
||||
```swift
|
||||
public struct RDEPUBCFIMap: Codable, Equatable {
|
||||
public var href: String
|
||||
public var markers: [RDEPUBCFIMarker]
|
||||
public var textAssertions: [String: RDEPUBCFITextAssertion]
|
||||
}
|
||||
```
|
||||
|
||||
建议扩展为:
|
||||
|
||||
```swift
|
||||
public struct RDEPUBCFIMap: Codable, Equatable {
|
||||
public var href: String
|
||||
public var renderVersion: Int
|
||||
public var domVersion: Int
|
||||
public var markers: [RDEPUBCFIMarker]
|
||||
public var textAssertions: [String: RDEPUBCFITextAssertion]
|
||||
public var pathRanges: [RDEPUBCFIPathRange]
|
||||
public var recoveryMetadata: RDEPUBCFIRecoveryMetadata
|
||||
}
|
||||
```
|
||||
|
||||
新增原因:
|
||||
|
||||
1. `markers` 适合单点命中,但不足以做标准级回退
|
||||
2. 需要显式记录“某个 DOM 路径覆盖哪段文本”
|
||||
3. 需要缓存 DOM 恢复辅助信息,避免每次重扫 HTML
|
||||
|
||||
### `RDEPUBCFIMarker`
|
||||
|
||||
建议扩展字段:
|
||||
|
||||
```swift
|
||||
public struct RDEPUBCFIMarker: Codable, Equatable {
|
||||
public var cfiPath: RDEPUBCFIPath
|
||||
public var chapterOffset: Int?
|
||||
public var fragmentID: String?
|
||||
public var textNodeLength: Int?
|
||||
public var textNodeChecksum: UInt64?
|
||||
public var normalizedTextPreview: String?
|
||||
public var domSiblingSignature: String?
|
||||
}
|
||||
```
|
||||
|
||||
用途:
|
||||
|
||||
1. `textNodeChecksum` 用于节点文本快速比对
|
||||
2. `normalizedTextPreview` 用于短窗口断言
|
||||
3. `domSiblingSignature` 用于路径偏移时的邻接恢复
|
||||
|
||||
### 新增 `RDEPUBCFIPathRange`
|
||||
|
||||
```swift
|
||||
public struct RDEPUBCFIPathRange: Codable, Equatable {
|
||||
public var cfiPath: RDEPUBCFIPath
|
||||
public var startOffset: Int
|
||||
public var endOffset: Int
|
||||
public var textNodeLength: Int
|
||||
}
|
||||
```
|
||||
|
||||
用途:
|
||||
|
||||
1. 表示某个文本节点在章节纯文本中的覆盖区间
|
||||
2. 支撑“按区间查找最近路径”
|
||||
3. 支撑 `chapterOffset -> nearest CFI path`
|
||||
|
||||
### 新增 `RDEPUBCFIRecoveryMetadata`
|
||||
|
||||
```swift
|
||||
public struct RDEPUBCFIRecoveryMetadata: Codable, Equatable {
|
||||
public var domFingerprint: String
|
||||
public var normalizedTextChecksum: String
|
||||
public var tokenIndex: [RDEPUBCFITokenAnchor]
|
||||
public var fragmentPathMap: [String: RDEPUBCFIPath]
|
||||
}
|
||||
```
|
||||
|
||||
用途:
|
||||
|
||||
1. `domFingerprint` 判断原始 HTML 是否变化
|
||||
2. `normalizedTextChecksum` 判断章节标准化文本是否变化
|
||||
3. `tokenIndex` 为无 `id` 恢复提供次级定位锚
|
||||
4. `fragmentPathMap` 保留锚点和脚注入口
|
||||
|
||||
### 新增 `RDEPUBCFITokenAnchor`
|
||||
|
||||
```swift
|
||||
public struct RDEPUBCFITokenAnchor: Codable, Equatable {
|
||||
public var token: String
|
||||
public var occurrence: Int
|
||||
public var chapterOffset: Int
|
||||
public var cfiPath: RDEPUBCFIPath
|
||||
}
|
||||
```
|
||||
|
||||
用途:
|
||||
|
||||
1. 当文本节点路径不再精确命中时,用稀疏 token 锚做恢复
|
||||
2. 对长章节做局部二次定位
|
||||
|
||||
---
|
||||
|
||||
## 7. 标准级无 id 节点双向恢复算法
|
||||
|
||||
## 7.1 正向:阅读位置 -> CFI
|
||||
|
||||
输入:
|
||||
|
||||
1. `fileIndex`
|
||||
2. `chapterOffset`
|
||||
3. `RDEPUBCFIMap`
|
||||
|
||||
输出:
|
||||
|
||||
1. 标准 `CFI`
|
||||
2. 如有需要,附带 text assertion
|
||||
|
||||
算法顺序:
|
||||
|
||||
1. 在 `pathRanges` 中查找覆盖 `chapterOffset` 的文本节点
|
||||
2. 计算该节点内的 `localOffset`
|
||||
3. 生成 `contentPath + characterOffset`
|
||||
4. 从前后文提取 assertion
|
||||
5. 若该节点不存在,则回退到最近 `fragment`
|
||||
6. 若 `fragment` 也缺失,则退化为 offset-backed CFI
|
||||
|
||||
### 关键要求
|
||||
|
||||
1. `chapterOffset` 不能直接依赖页码
|
||||
2. assertion 必须来自标准化文本,而不是原始 HTML 片段
|
||||
3. 对选区范围,`startCFI / endCFI` 必须分别独立生成,不能只存一个起点
|
||||
|
||||
## 7.2 逆向:CFI -> 阅读位置
|
||||
|
||||
输入:
|
||||
|
||||
1. `RDEPUBCFI`
|
||||
2. `RDEPUBCFIMap`
|
||||
3. `chapterText`
|
||||
|
||||
输出:
|
||||
|
||||
1. `chapterOffset`
|
||||
2. 必要时输出 `confidence`
|
||||
|
||||
建议新增恢复结果:
|
||||
|
||||
```swift
|
||||
public struct RDEPUBCFIRecoveryResult: Equatable {
|
||||
public enum Confidence: Int {
|
||||
case exactPath
|
||||
case assertionCalibrated
|
||||
case siblingRecovered
|
||||
case tokenRecovered
|
||||
case fragmentFallback
|
||||
case offsetFallback
|
||||
}
|
||||
|
||||
public var chapterOffset: Int
|
||||
public var confidence: Confidence
|
||||
}
|
||||
```
|
||||
|
||||
恢复链路必须固定为:
|
||||
|
||||
1. `exact-path`
|
||||
- `cfiPath` 直接命中 marker 或 pathRange
|
||||
2. `assertion-calibrated`
|
||||
- 路径命中后,用 text assertion 微调偏移
|
||||
3. `sibling-recovered`
|
||||
- 路径不命中时,用父路径 + 兄弟签名查找邻近文本节点
|
||||
4. `token-recovered`
|
||||
- 使用 token anchor 在局部窗口重定位
|
||||
5. `fragment-fallback`
|
||||
- 回退到最近 fragment 起点
|
||||
6. `offset-fallback`
|
||||
- 使用旧式 chapterOffset 或 progression 兜底
|
||||
|
||||
这里的关键不是“某一步一定成功”,而是恢复链必须稳定、可解释、可诊断。
|
||||
|
||||
---
|
||||
|
||||
## 8. 为什么要加“结构指纹 + token 锚”
|
||||
|
||||
只做文本节点路径有两个问题:
|
||||
|
||||
1. 某些 EPUB 会在无 `id` 场景下插入大量包装标签,导致路径整体漂移
|
||||
2. 某些章节文本重复度高,单靠短 assertion 可能落到错误位置
|
||||
|
||||
所以标准级方案需要两层辅助:
|
||||
|
||||
### 8.1 结构指纹
|
||||
|
||||
针对每个文本节点记录:
|
||||
|
||||
1. 父路径
|
||||
2. 左右兄弟摘要
|
||||
3. 标签名序列
|
||||
4. 局部文本 checksum
|
||||
|
||||
这样即便 `cfiPath` 因中间插入一个 wrapper 节点失效,也能在兄弟集合里恢复出最可能目标。
|
||||
|
||||
### 8.2 token 锚
|
||||
|
||||
在章节标准化文本中抽样稀疏 token,例如:
|
||||
|
||||
1. 每 `N` 个词或每 `M` 个中文字符窗口抽一个 token
|
||||
2. 每个 token 记录其 occurrence、offset 和所属 `cfiPath`
|
||||
|
||||
当路径和断言都不稳时:
|
||||
|
||||
1. 先用 token 锁定大致位置
|
||||
2. 再在局部窗口里做文本节点回溯
|
||||
|
||||
这就是“无 id 节点也能做标准级恢复”的核心。
|
||||
|
||||
---
|
||||
|
||||
## 9. 分阶段实施
|
||||
|
||||
## Phase 1:结构补强与缓存升级
|
||||
|
||||
### 目标
|
||||
|
||||
让章节缓存携带足够的 DOM 恢复元数据,为后续标准级恢复打底。
|
||||
|
||||
### 需要改的模块
|
||||
|
||||
1. `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIMap.swift`
|
||||
2. `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIDOMPathBuilder.swift`
|
||||
3. `Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookModels.swift`
|
||||
4. `Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookBuilder.swift`
|
||||
5. `Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterSummaryDiskCache.swift`
|
||||
6. `Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLoader.swift`
|
||||
|
||||
### 具体任务
|
||||
|
||||
1. 扩展 `RDEPUBCFIMap`、`RDEPUBCFIMarker`
|
||||
2. 新增 `RDEPUBCFIPathRange`
|
||||
3. 新增 `RDEPUBCFIRecoveryMetadata`
|
||||
4. 在章节构建阶段产出 `pathRanges / tokenIndex / domFingerprint`
|
||||
5. 升级 chapter summary schema version
|
||||
6. 为缓存加版本兼容分支,旧缓存 miss 后自动重建
|
||||
|
||||
### 验收
|
||||
|
||||
1. 新章节缓存可落盘完整 `cfiMap`
|
||||
2. 旧缓存不会引发崩溃
|
||||
3. 二次打开时不需要重新扫描整章 HTML 才能恢复定位
|
||||
|
||||
## Phase 2:标准级 `CFI -> offset` 恢复链
|
||||
|
||||
### 目标
|
||||
|
||||
把当前“marker 命中 + chapterOffset 回退”的恢复方式升级为多级恢复链。
|
||||
|
||||
### 需要改的模块
|
||||
|
||||
1. `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextIndexTable.swift`
|
||||
2. `Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterOffsetMap.swift`
|
||||
3. `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIResolver.swift`
|
||||
4. 建议新增:
|
||||
- `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIRecoveryEngine.swift`
|
||||
|
||||
### 具体任务
|
||||
|
||||
1. 抽出 `RDEPUBCFIRecoveryEngine`
|
||||
2. 输出 `RDEPUBCFIRecoveryResult`
|
||||
3. 实现六级恢复链
|
||||
4. 为每次恢复产出 `confidence`
|
||||
5. 为 diagnostics 埋点:
|
||||
- exact-path 命中率
|
||||
- assertion 校准命中率
|
||||
- token 恢复命中率
|
||||
- offset 回退比例
|
||||
|
||||
### 验收
|
||||
|
||||
1. 无 `id` 文本节点可稳定恢复
|
||||
2. 换字号 / 字体 / 行距后,阅读位置恢复成功率显著提升
|
||||
3. 高亮与搜索命中恢复不再大面积偏移
|
||||
|
||||
## Phase 3:标准级 `offset -> CFI` 生成链
|
||||
|
||||
### 目标
|
||||
|
||||
让所有阅读位置、选区、高亮和搜索结果都优先生成标准文本节点 CFI。
|
||||
|
||||
### 需要改的模块
|
||||
|
||||
1. `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIGenerator.swift`
|
||||
2. `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextIndexTable.swift`
|
||||
3. `Sources/RDReaderView/EPUBTextRendering/RDEPUBChapterData.swift`
|
||||
4. `Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextSelectionController.swift`
|
||||
5. `Sources/RDReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift`
|
||||
6. `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextSearchEngine.swift`
|
||||
|
||||
### 具体任务
|
||||
|
||||
1. `chapterOffset -> pathRange` 精确映射
|
||||
2. 统一单点 CFI 与范围 CFI 生成
|
||||
3. 所有消费方优先写入 `cfi/rangeCFI`
|
||||
4. 保留 `fragment/progression` 作为兜底兼容字段
|
||||
|
||||
### 验收
|
||||
|
||||
1. 书签、新建高亮、搜索命中都能生成标准 CFI
|
||||
2. 相同文本范围在重新分页后仍能正确恢复
|
||||
|
||||
## Phase 4:脚注 / 尾注弹层闭环
|
||||
|
||||
### 目标
|
||||
|
||||
把脚注从“跳出当前阅读流”改为“就地预览 + 可回跳”。
|
||||
|
||||
### 需要改的模块
|
||||
|
||||
1. `Sources/RDReaderView/EPUBCore/Notes/RDEPUBNoteDetector.swift`
|
||||
2. `Sources/RDReaderView/EPUBCore/Notes/RDEPUBNoteResolver.swift`
|
||||
3. `Sources/RDReaderView/EPUBCore/Notes/RDEPUBNoteModels.swift`
|
||||
4. `Sources/RDReaderView/EPUBUI/Notes/RDEPUBNotePopupCoordinator.swift`
|
||||
5. `Sources/RDReaderView/EPUBUI/Notes/RDEPUBNotePopupViewController.swift`
|
||||
6. `Sources/RDReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift`
|
||||
|
||||
### 具体任务
|
||||
|
||||
1. 完善脚注链接识别:
|
||||
- `epub:type=noteref`
|
||||
- `role=doc-noteref`
|
||||
- `href=#footnote-*`
|
||||
- 章节内 / 跨章节 note link
|
||||
2. note target 解析后落为 `CFI`
|
||||
3. 弹层展示注释正文,而不是强制整章跳转
|
||||
4. 弹层内支持:
|
||||
- 查看上下文
|
||||
- 跳到原文
|
||||
- 跳到注释原位置
|
||||
5. 建立“入口位置 CFI -> 弹层 -> 回跳位置 CFI”的闭环
|
||||
|
||||
### 验收
|
||||
|
||||
1. 点击脚注不打断当前阅读主流程
|
||||
2. 长注释可滚动
|
||||
3. 关闭弹层后能回到点击时的位置
|
||||
4. 跨章节尾注也能稳定打开
|
||||
|
||||
## Phase 5:样式兼容层与字体回退闭环
|
||||
|
||||
### 目标
|
||||
|
||||
减少“某些 EPUB 打开排版异常”的商业级缺陷。
|
||||
|
||||
### 需要改的模块
|
||||
|
||||
1. `Sources/RDReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBStyleCompatibilityModels.swift`
|
||||
2. `Sources/RDReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBCSSCompatibilityLayer.swift`
|
||||
3. `Sources/RDReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBFontFallbackResolver.swift`
|
||||
4. `Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBStyleSheetComposer.swift`
|
||||
5. `Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBHTMLNormalizer.swift`
|
||||
6. `Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBRenderDiagnosticsCollector.swift`
|
||||
|
||||
### 具体任务
|
||||
|
||||
1. 建立 CSS 兼容策略包:
|
||||
- 非法 `font-family` 归一化
|
||||
- 极端 `line-height` 修正
|
||||
- 过大 `margin/padding` 收口
|
||||
- 表格 / 图片 / SVG 溢出保护
|
||||
- 嵌套 `white-space` 异常归一化
|
||||
2. 建立字体 fallback 链:
|
||||
- 书内嵌入字体
|
||||
- 用户选中字體
|
||||
- 语言 fallback
|
||||
- 系统兜底字体
|
||||
3. diagnostics 输出:
|
||||
- 缺失字体
|
||||
- 被修正的 CSS 规则
|
||||
- 可能导致排版异常的资源
|
||||
|
||||
### 验收
|
||||
|
||||
1. 缺字库 EPUB 不崩溃、不出现大面积 tofu
|
||||
2. 非法 CSS 不导致正文不可读
|
||||
3. diagnostics 可定位问题书源
|
||||
|
||||
---
|
||||
|
||||
## 10. 关键实现细节
|
||||
|
||||
## 10.1 DOM 路径构建规则
|
||||
|
||||
1. 元素节点使用偶数 step
|
||||
2. 文本节点使用奇数 step
|
||||
3. 忽略注释节点、doctype、处理指令
|
||||
4. `script/style` 默认不参与文本定位
|
||||
5. `ruby`、`rt`、`rp` 需要单独定义标准化策略,避免正文偏移
|
||||
|
||||
## 10.2 标准化文本规则必须固定
|
||||
|
||||
以下规则必须全链路共用同一份实现,否则 CFI 会漂:
|
||||
|
||||
1. HTML entity 解码
|
||||
2. 连续空白压缩
|
||||
3. 换行归一化
|
||||
4. 零宽字符处理
|
||||
5. `nbsp` 处理
|
||||
6. 附件占位字符策略
|
||||
7. 中文全角 / 半角是否归一
|
||||
|
||||
建议把规则集中到一个入口,避免 `Builder`、`Search`、`Selection` 各写一套。
|
||||
|
||||
## 10.3 `renderSignature` 与 `domFingerprint` 分工
|
||||
|
||||
1. `renderSignature`
|
||||
- 描述“分页语义”
|
||||
- 用于判定页图、chapter summary、layout cache 是否可复用
|
||||
2. `domFingerprint`
|
||||
- 描述“章节 DOM 结构”
|
||||
- 用于判定 `cfiMap` 是否可复用
|
||||
|
||||
这两个概念不能混用。
|
||||
|
||||
## 10.4 本地缓存策略
|
||||
|
||||
建议缓存分三层:
|
||||
|
||||
1. `raw parse cache`
|
||||
- OPF、manifest、spine、resource bytes
|
||||
2. `chapter summary cache`
|
||||
- 章节文本、fragmentOffsets、cfiMap、pathRanges、tokenIndex
|
||||
3. `page map / runtime cache`
|
||||
- 与具体排版参数绑定
|
||||
|
||||
原则:
|
||||
|
||||
1. 字号、字体、行距变化时,第三层必须失效
|
||||
2. 第二层尽量复用
|
||||
3. `cfiMap` 属于第二层,不应因单纯重排被清空
|
||||
|
||||
---
|
||||
|
||||
## 11. 文件改动清单
|
||||
|
||||
## 11.1 必改文件
|
||||
|
||||
### CFI 核心
|
||||
|
||||
1. `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIMap.swift`
|
||||
2. `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIDOMPathBuilder.swift`
|
||||
3. `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIGenerator.swift`
|
||||
4. `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIResolver.swift`
|
||||
5. 新增 `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIRecoveryEngine.swift`
|
||||
|
||||
### 构建与缓存
|
||||
|
||||
1. `Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookModels.swift`
|
||||
2. `Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookBuilder.swift`
|
||||
3. `Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLoader.swift`
|
||||
4. `Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterSummaryDiskCache.swift`
|
||||
|
||||
### 定位消费方
|
||||
|
||||
1. `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextIndexTable.swift`
|
||||
2. `Sources/RDReaderView/EPUBTextRendering/RDEPUBChapterData.swift`
|
||||
3. `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextSearchEngine.swift`
|
||||
4. `Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextSelectionController.swift`
|
||||
5. `Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift`
|
||||
6. `Sources/RDReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift`
|
||||
7. `Sources/RDReaderView/EPUBUI/RDEPUBReaderController+DataSource.swift`
|
||||
8. `Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterOffsetMap.swift`
|
||||
9. `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderLocationCoordinator.swift`
|
||||
|
||||
### 脚注
|
||||
|
||||
1. `Sources/RDReaderView/EPUBCore/Notes/RDEPUBNoteDetector.swift`
|
||||
2. `Sources/RDReaderView/EPUBCore/Notes/RDEPUBNoteResolver.swift`
|
||||
3. `Sources/RDReaderView/EPUBUI/Notes/RDEPUBNotePopupCoordinator.swift`
|
||||
4. `Sources/RDReaderView/EPUBUI/Notes/RDEPUBNotePopupViewController.swift`
|
||||
|
||||
### 样式兼容
|
||||
|
||||
1. `Sources/RDReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBStyleCompatibilityModels.swift`
|
||||
2. `Sources/RDReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBCSSCompatibilityLayer.swift`
|
||||
3. `Sources/RDReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBFontFallbackResolver.swift`
|
||||
4. `Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBStyleSheetComposer.swift`
|
||||
|
||||
## 11.2 建议新增测试
|
||||
|
||||
1. `ReadViewDemo/ReadViewDemoTests/CFI/RDEPUBCFIRecoveryEngineTests.swift`
|
||||
2. `ReadViewDemo/ReadViewDemoTests/CFI/RDEPUBCFITextNodeMapBuilderTests.swift`
|
||||
3. `ReadViewDemo/ReadViewDemoTests/Notes/RDEPUBNoteResolverTests.swift`
|
||||
4. `ReadViewDemo/ReadViewDemoTests/Compatibility/RDEPUBCSSCompatibilityLayerTests.swift`
|
||||
5. `ReadViewDemo/ReadViewDemoUITests/ReaderUITests/LocationPersistenceTests.swift`
|
||||
6. `ReadViewDemo/ReadViewDemoUITests/ReaderUITests/FootnotePopupTests.swift`
|
||||
|
||||
---
|
||||
|
||||
## 12. 回归样书与验收用例
|
||||
|
||||
至少准备以下样书:
|
||||
|
||||
1. 标准文本 EPUB,节点 `id` 完整
|
||||
2. 无 `id` 文本 EPUB
|
||||
3. 重复文本很多的长章节 EPUB
|
||||
4. 脚注密集型 EPUB
|
||||
5. 嵌入字体缺失或损坏 EPUB
|
||||
6. CSS 激进覆盖型 EPUB
|
||||
7. 跨章节尾注 EPUB
|
||||
|
||||
关键验收:
|
||||
|
||||
1. 阅读到某章第 5 页,改字号后仍落在同一阅读语义位置
|
||||
2. 高亮一段正文,改字体后还能精确回到同一段
|
||||
3. 搜索命中结果重开书后可稳定定位
|
||||
4. 点击脚注弹层展示,不强制整章跳转
|
||||
5. 关闭脚注后回到点击前位置
|
||||
6. 不规范 CSS 的书籍仍可阅读
|
||||
7. 缺失嵌入字体时能稳定 fallback
|
||||
|
||||
---
|
||||
|
||||
## 13. 性能与风险要求
|
||||
|
||||
### 性能目标
|
||||
|
||||
1. 单章节 `cfiMap` 构建增量耗时 P50 `< 12ms`,P95 `< 35ms`
|
||||
2. `CFI -> offset` 恢复耗时 P50 `< 2ms`,P95 `< 8ms`
|
||||
3. 二次打开时不因 CFI 恢复而重新解析整本书
|
||||
|
||||
### 主要风险
|
||||
|
||||
1. 章节标准化文本规则不统一,导致生成和恢复不一致
|
||||
2. token 锚过密,导致缓存膨胀
|
||||
3. 断言窗口过短,在重复文本章节误命中
|
||||
4. 兼容层修正规则过激,伤及正常书籍排版
|
||||
|
||||
对应策略:
|
||||
|
||||
1. 文本标准化统一收口
|
||||
2. token 锚做稀疏抽样并设章节上限
|
||||
3. diagnostics 输出恢复置信度
|
||||
4. CSS 修正规则全部可开关并支持样书回归
|
||||
|
||||
---
|
||||
|
||||
## 14. 推荐排期
|
||||
|
||||
建议按 4 个迭代执行:
|
||||
|
||||
1. 第 1 迭代
|
||||
- Phase 1
|
||||
- Phase 2
|
||||
2. 第 2 迭代
|
||||
- Phase 3
|
||||
- 核心位置恢复 UI 回归
|
||||
3. 第 3 迭代
|
||||
- Phase 4
|
||||
- 脚注体验打磨
|
||||
4. 第 4 迭代
|
||||
- Phase 5
|
||||
- 样书库、diagnostics、稳定性回归
|
||||
|
||||
---
|
||||
|
||||
## 15. 最终交付定义
|
||||
|
||||
完成本蓝图后,阅读器至少应达到以下标准:
|
||||
|
||||
1. 阅读位置、高亮、书签、搜索命中全部优先基于标准 CFI
|
||||
2. 无 `id` 节点的 EPUB 仍能做精确字符级恢复
|
||||
3. 脚注 / 尾注不再破坏主阅读流
|
||||
4. 字体缺失和异常 CSS 不再轻易把正文排坏
|
||||
5. 章节缓存、页图缓存、定位缓存职责清晰,二次打开可复用
|
||||
|
||||
这时阅读器才算从“功能可用”进入“商业级可发布”的基线。
|
||||
Reference in New Issue
Block a user