feat: EPUB阅读器搜索、注释、CFI模块及大书远距跳转优化

- 实现EPUB阅读器搜索功能及选中注释功能
- 优化CFI模块,修复代码审查发现的11个问题
- 实现大书远距目录跳转与后台补全优化方案
- 优化设置面板与章节运行时联动
- 重构及大量改进优化
This commit is contained in:
shenlei
2026-06-22 20:26:34 +08:00
parent f50495ad91
commit c65c190b71
178 changed files with 11380 additions and 6728 deletions
+483
View File
@@ -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?
}
```
+314
View File
@@ -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 静默失败 → 统一错误处理
```
+597
View File
@@ -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` | 错误类型定义 |
+425
View File
@@ -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 版本 |
+642
View File
@@ -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` 管理导航状态 |
+521
View File
@@ -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(全书模型 + 索引表)
```
+727
View File
@@ -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()
```
+455
View File
@@ -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() → 预加载周围页面
```
+349
View File
@@ -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
View File
@@ -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)
+110
View File
@@ -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. 章节缓存、页图缓存、定位缓存职责清晰,二次打开可复用
这时阅读器才算从“功能可用”进入“商业级可发布”的基线。
File diff suppressed because it is too large Load Diff
@@ -26,6 +26,17 @@ enum IDs {
static let readerBookmarksNavBar = "epub.reader.bookmarks.navbar"
static let readerSelectionText = "epub.reader.selection.text"
static let readerSelectionHighlight = "epub.reader.selection.高亮"
static let readerContentView = "epub.reader.content.view"
static let readerHighlightsPanel = "epub.reader.highlights.panel"
static let readerHighlightsTable = "epub.reader.highlights.table"
static let readerHighlightsFilter = "epub.reader.highlights.filter"
static let readerHighlightsEmptyLabel = "epub.reader.highlights.empty"
static let readerHighlightsNavBar = "epub.reader.highlights.navbar"
static let readerBookmarksEmptyLabel = "epub.reader.bookmarks.empty"
static let readerNoteText = "epub.reader.note.text"
static let readerNoteActions = "epub.reader.note.actions"
static let readerNoteReturn = "epub.reader.note.return"
static let readerNoteOpen = "epub.reader.note.open"
static let readerSearch = "epub.reader.search"
static let searchBar = "epub.reader.search.bar"
@@ -21,6 +21,11 @@ struct DemoReaderState {
fields[key]
}
private func decodedField(_ key: String) -> String? {
guard let rawValue = fields[key], rawValue != "nil" else { return nil }
return rawValue.removingPercentEncoding ?? rawValue
}
var isOpened: Bool { fields["reader"] == "opened" }
var page: Int? { fields["page"].flatMap(Int.init) }
var display: String? { fields["display"] }
@@ -28,8 +33,11 @@ struct DemoReaderState {
var highlights: Int? { fields["highlights"].flatMap(Int.init) }
var bookmarks: Int? { fields["bookmarks"].flatMap(Int.init) }
var selection: Int? { fields["selection"].flatMap(Int.init) }
var href: String? { fields["href"] }
var href: String? { decodedField("href") }
var progression: Double? { fields["progression"].flatMap(Double.init) }
var cfi: String? { decodedField("cfi") }
var lastCFI: String? { decodedField("lastCFI") }
var rangeCFI: String? { decodedField("rangeCFI") }
var mode: String? { fields["mode"] }
var pagination: String? { fields["pagination"] }
var knownPages: Int? { fields["knownPages"].flatMap(Int.init) }
@@ -47,9 +55,9 @@ struct DemoReaderState {
var cacheFiles: Int? { fields["cacheFiles"].flatMap(Int.init) }
var cacheBytes: Int? { fields["cacheBytes"].flatMap(Int.init) }
var externalLinks: Int? { fields["externalLinks"].flatMap(Int.init) }
var lastExternalURL: String? { fields["lastExternalURL"] }
var lastError: String? { fields["lastError"] }
var searchMatchText: String? { fields["searchMatchText"] }
var lastExternalURL: String? { decodedField("lastExternalURL") }
var lastError: String? { decodedField("lastError") }
var searchMatchText: String? { decodedField("searchMatchText") }
}
extension XCUIApplication {
@@ -73,6 +73,54 @@ final class LocationPersistenceTests: XCTestCase {
app.waitForDemoReaderState(timeout: 8, description: "reader=opened") { $0.isOpened }
}
func testPositionSurvivesFontChoiceAndLineHeightChange() throws {
app.launchAndOpenSampleBook(resetsReaderState: true)
app.waitForReader(timeout: 12)
let paging = app.collectionViews[IDs.readerPaging]
if paging.waitForExistence(timeout: 5) {
paging.swipeLeft()
paging.swipeLeft()
}
let stateBefore = app.waitForDemoReaderState(timeout: 8, description: "翻页后定位稳定") { state in
(state.page ?? 0) >= 2 && state.cfi?.isEmpty == false
}
let pageBefore = stateBefore.page ?? 0
let hrefBefore = stateBefore.href
let cfiBefore = stateBefore.cfi
XCTAssertGreaterThanOrEqual(pageBefore, 2, "测试前应先进入第 2 页或之后")
XCTAssertNotNil(hrefBefore, "重排前应存在当前位置 href")
XCTAssertNotNil(cfiBefore, "重排前应存在当前位置 CFI")
app.showReaderChromeIfNeeded()
XCTAssertTrue(app.buttons[IDs.readerSettings].waitForExistence(timeout: 3))
app.buttons[IDs.readerSettings].tap()
let fontChoiceControl = app.segmentedControls[IDs.settingsFontChoice]
XCTAssertTrue(fontChoiceControl.waitForExistence(timeout: 5), "字体选择控件应出现")
if fontChoiceControl.buttons.count > 1 {
fontChoiceControl.buttons.element(boundBy: 1).tap()
}
let lineHeightControl = app.segmentedControls[IDs.settingsLineHeight]
XCTAssertTrue(lineHeightControl.waitForExistence(timeout: 5), "行距控件应出现")
if lineHeightControl.buttons.count > 1 {
lineHeightControl.buttons.element(boundBy: min(1, lineHeightControl.buttons.count - 1)).tap()
}
XCTAssertTrue(app.buttons[IDs.settingsDone].waitForExistence(timeout: 3))
app.buttons[IDs.settingsDone].tap()
let stateAfter = app.waitForDemoReaderState(timeout: 10, description: "改字体与行距后页码保持") { state in
state.page != nil && state.cfi?.isEmpty == false
}
let pageAfter = stateAfter.page ?? 0
XCTAssertEqual(pageAfter, pageBefore, "字体与行距变化后,应尽量保留当前阅读页")
XCTAssertEqual(stateAfter.href, hrefBefore, "重排后应仍定位在同一章节")
XCTAssertEqual(stateAfter.cfi, cfiBefore, "重排后应保持当前阅读锚点的 CFI 不变")
}
func testBookmarkAndHighlightPersistAcrossReopen() throws {
app.launchAndOpenSampleBook(resetsReaderState: true)
app.waitForReader(timeout: 12)
@@ -103,4 +151,57 @@ final class LocationPersistenceTests: XCTestCase {
XCTAssertEqual(state.bookmarks, 1)
XCTAssertEqual(state.highlights, 1)
}
func testHighlightNavigationSurvivesFontChoiceChange() throws {
app.launchAndOpenSampleBook(resetsReaderState: true, pageNumber: 3)
app.waitForReader(timeout: 12)
let initialState = app.waitForDemoReaderState(timeout: 8, description: "初始位于第 3 页") { state in
state.page == 3
}
XCTAssertEqual(initialState.page, 3)
app.hideReaderChromeIfNeeded()
let selectionText = try app.requireSelectionText(expectedPage: 3)
let start = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.25, dy: 0.5))
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
start.press(forDuration: 0.8, thenDragTo: end)
let highlightItem = app.menuItems["高亮"]
XCTAssertTrue(highlightItem.waitForExistence(timeout: 3), "应出现高亮菜单")
highlightItem.tap()
app.waitForDemoReaderState(timeout: 8, description: "highlights=1") { $0.highlights == 1 }
app.showReaderChromeIfNeeded()
app.buttons[IDs.readerSettings].tap()
let fontChoiceControl = app.segmentedControls[IDs.settingsFontChoice]
XCTAssertTrue(fontChoiceControl.waitForExistence(timeout: 5), "字体选择控件应出现")
if fontChoiceControl.buttons.count > 1 {
fontChoiceControl.buttons.element(boundBy: 1).tap()
}
app.buttons[IDs.settingsDone].tap()
let content = app.otherElements[IDs.readerContent].firstMatch
if content.waitForExistence(timeout: 3) {
content.swipeLeft()
content.swipeLeft()
Thread.sleep(forTimeInterval: 0.5)
}
app.showReaderChromeIfNeeded()
let highlightsButton = app.buttons[IDs.readerHighlights]
XCTAssertTrue(highlightsButton.waitForExistence(timeout: 3), "高亮面板按钮应存在")
highlightsButton.tap()
let highlightsTable = app.tables[IDs.readerHighlightsTable]
XCTAssertTrue(highlightsTable.waitForExistence(timeout: 5), "高亮列表应出现")
let firstCell = highlightsTable.cells.firstMatch
XCTAssertTrue(firstCell.waitForExistence(timeout: 3), "高亮列表应至少有一个条目")
firstCell.tap()
let restoredState = app.waitForDemoReaderState(timeout: 10, description: "字体变化后高亮跳回原页") { state in
state.page == 3
}
XCTAssertEqual(restoredState.page, 3, "字体变化后,通过高亮面板跳转仍应回到原始高亮页")
}
}
@@ -264,10 +264,11 @@ final class SearchTests: XCTestCase {
// 验证当前匹配的文本就是搜索关键字
let state = app.waitForDemoReaderState(timeout: 5, description: "searchMatchText exists") {
$0.searchMatchText != nil && $0.searchMatchText != "none"
$0.searchMatchText != nil && $0.searchMatchText != "none" && $0.rangeCFI?.isEmpty == false
}
XCTAssertEqual(state.searchMatchText, keyword,
"当前搜索匹配文本应为 '\(keyword)',实际:\(state.searchMatchText ?? "nil")")
XCTAssertNotNil(state.rangeCFI, "搜索命中应暴露 rangeCFI 以支持重排恢复")
}
/// 重排后搜索匹配文本仍应与关键字一致
@@ -280,6 +281,10 @@ final class SearchTests: XCTestCase {
// 等待搜索结果
let countLabel = app.staticTexts[IDs.searchCount]
XCTAssertTrue(countLabel.waitForExistence(timeout: 10), "搜索计数应出现")
let stateBefore = app.waitForDemoReaderState(timeout: 5, description: "search rangeCFI before repagination") {
$0.rangeCFI?.isEmpty == false
}
let rangeCFIBefore = stateBefore.rangeCFI
// 改变字体触发重排
app.showReaderChromeIfNeeded()
@@ -314,10 +319,11 @@ final class SearchTests: XCTestCase {
// 验证重排后匹配文本仍正确
let state = app.waitForDemoReaderState(timeout: 5, description: "searchMatchText after repagination") {
$0.searchMatchText != nil && $0.searchMatchText != "none"
$0.searchMatchText != nil && $0.searchMatchText != "none" && $0.rangeCFI?.isEmpty == false
}
XCTAssertEqual(state.searchMatchText, keyword,
"重排后搜索匹配文本应为 '\(keyword)',实际:\(state.searchMatchText ?? "nil")")
XCTAssertEqual(state.rangeCFI, rangeCFIBefore, "重排后当前搜索命中的 rangeCFI 应保持一致")
}
// MARK: - 重排后搜索跳转正确性
@@ -371,9 +377,10 @@ final class SearchTests: XCTestCase {
nextButton.tap()
Thread.sleep(forTimeInterval: 0.5)
let stateMatch2 = app.waitForDemoReaderState(timeout: 5, description: "match 2 page") {
($0.page ?? 0) > 0
($0.page ?? 0) > 0 && $0.rangeCFI?.isEmpty == false
}
let pageAtMatch2 = stateMatch2.page ?? 1
let rangeCFIAtMatch2 = stateMatch2.rangeCFI
// 翻到其他页
let content = app.otherElements[IDs.readerContent].firstMatch
@@ -389,9 +396,10 @@ final class SearchTests: XCTestCase {
nextButton.tap()
Thread.sleep(forTimeInterval: 0.5)
let stateMatch3 = app.waitForDemoReaderState(timeout: 5, description: "match 3 page") {
($0.page ?? 0) > 0
($0.page ?? 0) > 0 && $0.rangeCFI?.isEmpty == false
}
let pageAtMatch3 = stateMatch3.page ?? 1
let rangeCFIAtMatch3 = stateMatch3.rangeCFI
// 再翻到其他页
if content.waitForExistence(timeout: 3) {
@@ -406,10 +414,12 @@ final class SearchTests: XCTestCase {
prevButton.tap()
Thread.sleep(forTimeInterval: 0.5)
let stateBack2 = app.waitForDemoReaderState(timeout: 5, description: "back to match 2") {
$0.page == pageAtMatch2
$0.page == pageAtMatch2 && $0.rangeCFI == rangeCFIAtMatch2
}
XCTAssertEqual(stateBack2.page, pageAtMatch2,
"重排后搜索跳回第 2 个匹配应回到第 \(pageAtMatch2) 页,实际:\(stateBack2.page ?? -1)")
XCTAssertEqual(stateBack2.rangeCFI, rangeCFIAtMatch2,
"重排后搜索跳回第 2 个匹配应命中同一段文本")
// 再翻到其他页
if content.waitForExistence(timeout: 3) {
@@ -424,10 +434,12 @@ final class SearchTests: XCTestCase {
nextButton.tap()
Thread.sleep(forTimeInterval: 0.5)
let stateBack3 = app.waitForDemoReaderState(timeout: 5, description: "back to match 3") {
$0.page == pageAtMatch3
$0.page == pageAtMatch3 && $0.rangeCFI == rangeCFIAtMatch3
}
XCTAssertEqual(stateBack3.page, pageAtMatch3,
"重排后搜索跳到第 3 个匹配应回到第 \(pageAtMatch3) 页,实际:\(stateBack3.page ?? -1)")
XCTAssertEqual(stateBack3.rangeCFI, rangeCFIAtMatch3,
"重排后搜索跳到第 3 个匹配应命中同一段文本")
}
// MARK: - 辅助方法
@@ -75,7 +75,7 @@ public enum RDEPUBCFIDOMPathBuilder {
match.numberOfRanges > 2 else {
return nil
}
return nsAttributes.substring(with: match.range(at: 2)).nilIfEmpty
return nsAttributes.substring(with: match.range(at: 2)).rd_cfiNilIfEmpty
}
static func isIgnorableTag(_ tagName: String) -> Bool {
@@ -162,7 +162,7 @@ enum RDEPUBCFITextNodeMapBuilder {
}
// Use comment/CDATA-stripped HTML for both path building and tag matching
let cleanedHTML = stripCommentsAndCDATA(rawHTML)
let cleanedHTML = RDEPUBCFIDOMPathBuilder.stripCommentsAndCDATA(rawHTML)
let fragments = RDEPUBCFIDOMPathBuilder.fragmentPaths(in: rawHTML)
var markers: [RDEPUBCFIMarker] = fragmentMarkers(fragmentOffsets: fragmentOffsets, paths: fragments)
@@ -516,6 +516,14 @@ private struct RDEPUBNormalizedTextIndex {
}
}
extension String {
var rd_cfiNilIfEmpty: String? {
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}
private extension String {
var fnv1a64: UInt64 {
let offsetBasis: UInt64 = 14695981039346656037
@@ -114,7 +114,7 @@ public enum RDEPUBCFIParser {
if let open = rawStep.firstIndex(of: "["),
let close = rawStep.lastIndex(of: "]"),
open < close {
idAssertion = String(rawStep[rawStep.index(after: open)..<close]).nilIfEmpty
idAssertion = String(rawStep[rawStep.index(after: open)..<close]).rd_cfiNilIfEmpty
} else {
idAssertion = nil
}
@@ -34,6 +34,6 @@ public struct RDEPUBCFIStep: Codable, Equatable, Hashable {
public init(index: Int, idAssertion: String? = nil) {
self.index = index
self.idAssertion = idAssertion?.nilIfEmpty
self.idAssertion = idAssertion?.rd_cfiNilIfEmpty
}
}
@@ -48,7 +48,13 @@ enum RDEPUBCFIRecoveryEngine {
)
}
if let token = tokenRecoveredResult(cfi: cfi, cfiMap: cfiMap, chapterText: chapterText, lastOffset: lastOffset) {
if let token = tokenRecoveredResult(
cfi: cfi,
cfiMap: cfiMap,
chapterText: chapterText,
fallbackOffset: resolved.chapterOffset,
lastOffset: lastOffset
) {
return token
}
@@ -123,6 +129,7 @@ enum RDEPUBCFIRecoveryEngine {
cfi: RDEPUBCFI,
cfiMap: RDEPUBCFIMap?,
chapterText: String?,
fallbackOffset: Int?,
lastOffset: Int
) -> RDEPUBCFIRecoveryResult? {
guard let cfiMap,
@@ -149,6 +156,7 @@ enum RDEPUBCFIRecoveryEngine {
let normalizedText = RDEPUBCFITextNodeMapBuilder.normalizedText(from: chapterText)
guard !normalizedText.isEmpty else { return nil }
let preferredOffset = fallbackOffset.map { clamp($0, lastOffset: lastOffset) }
var bestOffset: Int?
var bestScore = Int.max
@@ -161,7 +169,14 @@ enum RDEPUBCFIRecoveryEngine {
lastOffset: lastOffset,
windowRadius: 768
)
let score = abs(calibrated - approximateOffset)
let score = tokenRecoveryScore(
for: anchor,
targetPath: cfi.contentPath,
approximateOffset: approximateOffset,
calibratedOffset: calibrated,
preferredOffset: preferredOffset,
allAnchors: cfiMap.recoveryMetadata.tokenIndex
)
if score < bestScore {
bestScore = score
bestOffset = calibrated
@@ -317,4 +332,49 @@ enum RDEPUBCFIRecoveryEngine {
private static func clamp(_ offset: Int, lastOffset: Int) -> Int {
min(max(offset, 0), max(lastOffset, 0))
}
private static func tokenRecoveryScore(
for anchor: RDEPUBCFITokenAnchor,
targetPath: RDEPUBCFIPath,
approximateOffset: Int,
calibratedOffset: Int,
preferredOffset: Int?,
allAnchors: [RDEPUBCFITokenAnchor]
) -> Int {
var score = abs(calibratedOffset - approximateOffset)
score += pathDistance(anchor.cfiPath, targetPath) * 1_024
if let preferredOffset {
score += abs(approximateOffset - preferredOffset)
let expectedOccurrence = nearestOccurrence(
forToken: anchor.token,
preferredOffset: preferredOffset,
anchors: allAnchors
)
score += abs(anchor.occurrence - expectedOccurrence) * 256
}
return score
}
private static func nearestOccurrence(
forToken token: String,
preferredOffset: Int,
anchors: [RDEPUBCFITokenAnchor]
) -> Int {
anchors
.filter { $0.token == token }
.min(by: { abs($0.chapterOffset - preferredOffset) < abs($1.chapterOffset - preferredOffset) })?
.occurrence ?? 0
}
private static func pathDistance(_ lhs: RDEPUBCFIPath, _ rhs: RDEPUBCFIPath) -> Int {
let prefix = lhs.commonPrefix(with: rhs).steps.count
let remainingLHS = lhs.steps.dropFirst(prefix)
let remainingRHS = rhs.steps.dropFirst(prefix)
let stepDistance = zip(remainingLHS, remainingRHS).reduce(0) { partial, pair in
partial + abs(pair.0.index - pair.1.index)
}
return stepDistance + abs(lhs.steps.count - rhs.steps.count) * 2
}
}
@@ -28,7 +28,7 @@ public enum RDEPUBCFIResolver {
if cfi.packagePath.steps.count >= 3 {
manifestStep = cfi.packagePath.steps[2]
} else {
manifestStep = cfi.packagePath.steps.last(where: { $0.idAssertion?.nilIfEmpty != nil })
manifestStep = cfi.packagePath.steps.last(where: { $0.idAssertion?.rd_cfiNilIfEmpty != nil })
}
let href = manifestStep?.idAssertion
@@ -40,7 +40,7 @@ public enum RDEPUBCFIResolver {
fileIndex = nil
}
let fragmentID = cfi.contentPath.steps.last(where: { $0.idAssertion?.nilIfEmpty != nil })?.idAssertion
let fragmentID = cfi.contentPath.steps.last(where: { $0.idAssertion?.rd_cfiNilIfEmpty != nil })?.idAssertion
return RDEPUBCFIResolverResult(
href: href,
fileIndex: fileIndex,
@@ -48,4 +48,4 @@ public enum RDEPUBCFIResolver {
fragmentID: fragmentID
)
}
}
}
@@ -9,8 +9,14 @@ public struct RDEPUBCFITextAssertion: Codable, Equatable, Hashable {
public var suffix: String?
public init(prefix: String? = nil, exact: String? = nil, suffix: String? = nil) {
self.prefix = prefix?.nilIfEmpty
self.exact = exact?.nilIfEmpty
self.suffix = suffix?.nilIfEmpty
self.prefix = Self.trimmedOrNil(prefix)
self.exact = Self.trimmedOrNil(exact)
self.suffix = Self.trimmedOrNil(suffix)
}
private static func trimmedOrNil(_ value: String?) -> String? {
guard let value else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}
@@ -1,26 +1,20 @@
import Foundation
import UIKit
// MARK: - 自定义 NSAttributedString 属性(对齐 WXRead 的 com.weread.highlight / com.weread.underline)
/// 高亮自定义属性名,注入到 NSAttributedString 中供 CoreText 绘制时读取
/// 对齐 WXRead 的 kWRHighlightAttributeName = "com.weread.highlight"
public let kRDEPUBHighlightAttributeName = NSAttributedString.Key("com.rdreader.highlight")
/// 下划线自定义属性名
/// 对齐 WXRead 的 kWRUnderlineAttributeName = "com.weread.underline"
public let kRDEPUBUnderlineAttributeName = NSAttributedString.Key("com.rdreader.underline")
public struct RDEPUBSelection: Codable, Equatable {
/// 书籍标识符
public var bookIdentifier: String?
/// 选择发生的位置
public var location: RDEPUBLocation
/// 选中的文本内容
public var text: String
/// DOM Range 信息(JSON 序列化字符串,用于精确重建选区)
public var rangeInfo: String?
/// 选择创建时间
public var createdAt: Date
public init(
@@ -37,51 +31,38 @@ public struct RDEPUBSelection: Codable, Equatable {
self.createdAt = createdAt
}
/// 判断是否为空选择(无文本内容)
public var isEmpty: Bool {
text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
}
/// 高亮样式类型
/// - highlight: 背景色高亮
/// - underline: 下划线标记
public enum RDEPUBHighlightStyle: String, Codable {
case highlight
case underline
}
/// 统一标注类型,对标 WXRead 的 WRBookmark 语义分层。
public enum RDEPUBAnnotationKind: String, Codable, Equatable {
case bookmark
case highlight
case underline
}
/// 标注菜单操作类型,用于用户长按选中文本后的操作选项
public enum RDEPUBAnnotationMenuAction: Equatable {
case copy
case highlight
case annotate
}
/// 文本偏移范围信息,用于文本 EPUB 的高亮精确定位
/// 使用字符偏移量而非 DOM Range,适用于 DTCoreText 渲染的纯文本内容
public struct RDEPUBTextOffsetRangeInfo: Codable, Equatable {
/// 类型标识符,固定为 "text-offset"
public var kind: String
/// 资源路径
public var href: String
/// 起始字符偏移量
public var start: Int
/// 结束字符偏移量
public var end: Int
/// 初始化文本偏移范围
/// - Parameters:
/// - href: 资源路径
/// - start: 起始字符偏移量
/// - end: 结束字符偏移量
public init(href: String, start: Int, end: Int) {
self.kind = "text-offset"
self.href = href
@@ -89,19 +70,16 @@ public struct RDEPUBTextOffsetRangeInfo: Codable, Equatable {
self.end = end
}
/// 转换为 NSRange(用于 NSAttributedString 操作)
public var nsRange: NSRange? {
guard end > start else { return nil }
return NSRange(location: start, length: end - start)
}
/// 序列化为 JSON 字符串
public func jsonString() -> String? {
guard let data = try? JSONEncoder().encode(self) else { return nil }
return String(data: data, encoding: .utf8)
}
/// 从 JSON 字符串反序列化,校验 kind 和范围有效性
public static func decode(from string: String?) -> RDEPUBTextOffsetRangeInfo? {
guard let string,
let data = string.data(using: .utf8),
@@ -114,25 +92,24 @@ public struct RDEPUBTextOffsetRangeInfo: Codable, Equatable {
}
}
/// 文本分页截断原因,描述为什么在当前位置分页
public struct RDEPUBHighlight: Codable, Equatable {
/// 高亮唯一标识符
public var id: String
/// 书籍标识符
public var bookIdentifier: String?
/// 高亮发生的位置
public var location: RDEPUBLocation
/// 高亮的文本内容
public var text: String
/// 范围信息(Web EPUB 为 DOM Range JSON,文本 EPUB 为字符偏移 JSON)
public var rangeInfo: String?
/// 高亮样式(背景色高亮或下划线)
public var style: RDEPUBHighlightStyle
/// 高亮颜色(CSS 颜色值,如 "#F8E16C")
public var color: String
/// 用户备注
public var note: String?
/// 创建时间
public var createdAt: Date
public init(
@@ -157,18 +134,15 @@ public struct RDEPUBHighlight: Codable, Equatable {
self.createdAt = createdAt
}
/// 是否包含用户备注
public var hasNote: Bool {
note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
}
/// 将 CSS hex 颜色转为 UIColor(对齐 WXRead 的 UIColorForHighlightColor,35% alpha)
public var uiColor: UIColor {
UIColor(rdHexString: color, alpha: 0.35)
?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.35)
}
/// 编码键,用于 Codable 序列化/反序列化
private enum CodingKeys: String, CodingKey {
case id
case bookIdentifier
@@ -181,7 +155,6 @@ public struct RDEPUBHighlight: Codable, Equatable {
case createdAt
}
/// 自定义解码器,兼容旧版本数据格式(缺失字段使用默认值)
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
@@ -200,7 +173,6 @@ public struct RDEPUBHighlight: Codable, Equatable {
createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date()
}
/// 自定义编码器
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
@@ -219,19 +191,18 @@ public struct RDEPUBHighlight: Codable, Equatable {
}
}
/// 书签模型,记录用户标记的阅读位置
public struct RDEPUBBookmark: Codable, Equatable {
/// 书签唯一标识符
public var id: String
/// 书籍标识符
public var bookIdentifier: String?
/// 书签位置
public var location: RDEPUBLocation
/// 章节标题(用于书签列表展示)
public var chapterTitle: String?
/// 用户备注
public var note: String?
/// 创建时间
public var createdAt: Date
public init(
@@ -250,7 +221,6 @@ public struct RDEPUBBookmark: Codable, Equatable {
self.createdAt = createdAt
}
/// 是否包含用户备注
public var hasNote: Bool {
note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
}
@@ -260,7 +230,6 @@ public struct RDEPUBBookmark: Codable, Equatable {
}
}
/// 统一标注模型,对标 WXRead 的 WRBookmark。
public struct RDEPUBAnnotation: Codable, Equatable {
public var id: String
public var bookIdentifier: String?
@@ -358,7 +327,6 @@ public struct RDEPUBAnnotation: Codable, Equatable {
}
}
private extension String {
var nilIfEmpty: String? {
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
@@ -1,46 +1,43 @@
import Foundation
public enum RDEPUBTextPageBreakReason: String, Codable, Equatable {
/// 章节自然结束
case chapterEnd
/// 达到帧容量限制
case frameLimit
/// 块级元素边界(如段落、标题)
case blockBoundary
/// 附件(图片等)边界
case attachmentBoundary
/// 语义边界(如列表、引用块)
case semanticBoundary
}
/// 文本附件类型
public enum RDEPUBTextAttachmentKind: String, Codable, Equatable {
/// 图片附件
case image
/// 通用附件
case generic
}
/// 文本页元数据,记录分页时的上下文信息
/// 用于调试分页问题和优化分页质量
public struct RDEPUBTextPageMetadata: Codable, Equatable {
/// 分页截断原因
public var breakReason: RDEPUBTextPageBreakReason
/// 本页覆盖的块级元素范围
public var blockRange: NSRange?
/// 本页包含的附件范围列表
public var attachmentRanges: [NSRange]
/// 附件类型列表
public var attachmentKinds: [RDEPUBTextAttachmentKind]
/// 块级元素类型列表
public var blockKinds: [RDEPUBTextBlockKind]
/// 语义提示信息
public var semanticHints: [RDEPUBTextSemanticHint]
/// 附件布局位置信息
public var attachmentPlacements: [RDEPUBTextAttachmentPlacement]
/// 本页末尾的 fragment ID(用于恢复位置)
public var trailingFragmentID: String?
/// 诊断信息列表(调试用)
public var diagnostics: [String]
public init(
@@ -66,13 +63,12 @@ public struct RDEPUBTextPageMetadata: Codable, Equatable {
}
}
/// 高亮模型,表示用户在阅读中标记的文本片段
public struct EPUBChapterInfo: Codable, Equatable {
/// 对应的 spine 索引
public var spineIndex: Int
/// 章节标题
public var title: String
/// 该章节的总页数
public var pageCount: Int
public init(spineIndex: Int, title: String, pageCount: Int) {
@@ -82,20 +78,18 @@ public struct EPUBChapterInfo: Codable, Equatable {
}
}
/// 页面模型,描述单页的完整信息
/// 由 RDEPUBReadingSession 根据分页结果构建,是翻页容器层的数据单元
public struct EPUBPage: Codable, Equatable {
/// 对应的 spine 索引
public var spineIndex: Int
/// 章节索引(从 0 开始的章节序号)
public var chapterIndex: Int
/// 页在章节中的索引(从 0 开始)
public var pageIndexInChapter: Int
/// 该章节的总页数
public var totalPagesInChapter: Int
/// 章节标题
public var chapterTitle: String
/// 固定版式 Spread 数据(仅 fixed layout 书籍有值)
public var fixedSpread: EPUBFixedSpread?
public init(
@@ -115,15 +109,14 @@ public struct EPUBPage: Codable, Equatable {
}
}
/// 固定版式 Spread 中的单个资源
public struct EPUBFixedSpreadResource: Codable, Equatable {
/// 对应的 spine 索引
public var spineIndex: Int
/// 资源路径
public var href: String
/// 资源标题
public var title: String
/// 在 spread 中的位置偏好
public var pageSpread: RDEPUBPageSpread?
public init(spineIndex: Int, href: String, title: String, pageSpread: RDEPUBPageSpread? = nil) {
@@ -134,38 +127,24 @@ public struct EPUBFixedSpreadResource: Codable, Equatable {
}
}
/// 固定版式 Spread(双页展开)模型
/// 将 1-2 个 spine 资源组合为一个显示单元
public struct EPUBFixedSpread: Codable, Equatable {
/// 该 spread 包含的资源列表(通常 1-2 个)
public var resources: [EPUBFixedSpreadResource]
public init(resources: [EPUBFixedSpreadResource]) {
self.resources = resources
}
/// 主资源(列表中的第一个),用于标识和定位
public var primaryResource: EPUBFixedSpreadResource {
resources.first ?? EPUBFixedSpreadResource(spineIndex: 0, href: "", title: "")
}
/// 判断指定 href 的资源是否属于此 spread
/// - Parameters:
/// - normalizedHref: 标准化后的 href
/// - normalizer: href 标准化函数
/// - Returns: 是否包含该资源
public func contains(normalizedHref: String, normalizer: (String) -> String?) -> Bool {
resources.contains { resource in
normalizer(resource.href) == normalizedHref
}
}
/// 根据 spine 列表和 spread 设置生成 Spread 数组
/// 根据 pageSpread 属性(left/right/center)决定资源配对
/// - Parameters:
/// - spine: spine 项列表
/// - spreadEnabled: 是否启用 spread 模式
/// - Returns: spread 数组
public static func makeSpreads(spine: [RDEPUBSpineItem], spreadEnabled: Bool) -> [EPUBFixedSpread] {
let resources = spine.enumerated().map { index, item in
EPUBFixedSpreadResource(
@@ -204,13 +183,10 @@ public struct EPUBFixedSpread: Codable, Equatable {
return spreads
}
/// 便捷方法:从 parser 构建 spread
public static func makeSpreads(parser: RDEPUBParser, spreadEnabled: Bool) -> [EPUBFixedSpread] {
makeSpreads(spine: parser.spine, spreadEnabled: spreadEnabled)
}
/// 判断两个资源是否应该配对为一个 spread
/// center 类型不参与配对,left+right 或 right+left 配对
private static func shouldPair(current: EPUBFixedSpreadResource, next: EPUBFixedSpreadResource) -> Bool {
if current.pageSpread == .center || next.pageSpread == .center {
return false
@@ -226,4 +202,3 @@ public struct EPUBFixedSpread: Codable, Equatable {
}
}
}
@@ -1,26 +1,35 @@
import Foundation
public struct RDEPUBLocation: Codable, Equatable {
/// 书籍唯一标识符,用于隔离不同书的阅读进度
public var bookIdentifier: String?
/// OPF 相对路径,对应 spine 中的资源文件
public var href: String
/// 视口起始位置在章节中的相对进度 [0, 1]
public var progression: Double
/// 视口末尾位置在章节中的相对进度 [0, 1](可选)
public var lastProgression: Double?
/// 锚点标识符(如 #section1),用于精确跳转
public var fragment: String?
/// 文本范围锚点(用于文本 EPUB 的高亮定位)
public var rangeAnchor: RDEPUBTextRangeAnchor?
public var cfi: String?
public var lastCFI: String?
public var rangeCFI: String?
public init(
bookIdentifier: String? = nil,
href: String,
progression: Double,
lastProgression: Double? = nil,
fragment: String? = nil,
rangeAnchor: RDEPUBTextRangeAnchor? = nil
rangeAnchor: RDEPUBTextRangeAnchor? = nil,
cfi: String? = nil,
lastCFI: String? = nil,
rangeCFI: String? = nil
) {
self.bookIdentifier = bookIdentifier
self.href = href
@@ -28,10 +37,11 @@ public struct RDEPUBLocation: Codable, Equatable {
self.lastProgression = lastProgression.map(Self.clamp)
self.fragment = fragment?.nilIfEmpty
self.rangeAnchor = rangeAnchor
self.cfi = cfi?.nilIfEmpty
self.lastCFI = lastCFI?.nilIfEmpty
self.rangeCFI = rangeCFI?.nilIfEmpty
}
/// 导航用的中间进度值,取 progression 和 lastProgression 的中点
/// 用于在字号/横竖屏变化后估算最接近的页号
public var navigationProgression: Double {
let end = lastProgression ?? progression
if end.isNaN || end.isInfinite {
@@ -40,24 +50,22 @@ public struct RDEPUBLocation: Codable, Equatable {
return Self.clamp((progression + end) / 2.0)
}
/// 将进度值限制在 [0, 1] 范围内,防止越界
private static func clamp(_ value: Double) -> Double {
guard value.isFinite else { return 0 }
return min(1, max(0, value))
}
}
/// 视口中的单个资源信息,表示当前屏幕可见区域覆盖的某个 spine 资源
public struct RDEPUBViewportResource: Codable, Equatable {
/// 资源路径(相对于 OPF 目录)
public var href: String
/// 对应的 spine 索引
public var spineIndex: Int
/// 该资源在视口中的起始进度 [0, 1]
public var progression: Double
/// 该资源在视口中的结束进度 [0, 1]
public var lastProgression: Double?
/// 锚点标识符
public var fragment: String?
public init(
@@ -75,16 +83,14 @@ public struct RDEPUBViewportResource: Codable, Equatable {
}
}
/// 视口模型,描述当前屏幕可见区域的完整状态
/// 可能跨越多个 spine 资源(如分栏布局时)
public struct RDEPUBViewport: Codable, Equatable {
/// 当前视口覆盖的资源列表(可能有多个,如分栏或跨页场景)
public var resources: [RDEPUBViewportResource]
/// 当前可见的页码
public var visiblePageNumber: Int
/// 当前章节索引
public var chapterIndex: Int?
/// 是否为固定版式内容
public var isFixedLayout: Bool
public init(
@@ -100,16 +106,14 @@ public struct RDEPUBViewport: Codable, Equatable {
}
}
/// 阅读上下文,聚合了位置、视口和页码等完整阅读状态
/// 由 RDEPUBReadingSession 维护,是翻页容器层获取当前阅读状态的主要数据源
public struct RDEPUBReadingContext: Codable, Equatable {
/// 当前阅读位置(href + progression)
public var location: RDEPUBLocation
/// 当前视口状态
public var viewport: RDEPUBViewport
/// 当前扁平页码(跨章节累计)
public var pageNumber: Int
/// 当前章节索引
public var chapterIndex: Int?
public init(
@@ -125,8 +129,6 @@ public struct RDEPUBReadingContext: Codable, Equatable {
}
}
/// 文本选择模型,由 JS 桥接 ssReaderSelectionChanged 消息触发创建
private extension String {
var nilIfEmpty: String? {
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
@@ -0,0 +1,63 @@
import Foundation
public enum RDEPUBNoteDetector {
private static let noteTokens = [
"noteref",
"footnote",
"endnote",
"rearnote",
"fnref",
"fn-",
"fn_",
"note",
"annotation"
]
public static func makeReferenceIfLikelyNote(
sourceHref: String,
sourceCFI: String?,
targetHref: String,
targetFragment: String?,
targetCFI: String?,
targetElementHTML: String?
) -> RDEPUBNoteReference? {
let haystack = [
targetHref,
targetFragment ?? "",
targetElementHTML ?? ""
].joined(separator: " ").lowercased()
guard noteTokens.contains(where: { haystack.contains($0) }) ||
hasEPUBType("footnote", in: targetElementHTML) ||
hasEPUBType("endnote", in: targetElementHTML) ||
hasEPUBType("rearnote", in: targetElementHTML) else {
return nil
}
return RDEPUBNoteReference(
sourceHref: sourceHref,
sourceCFI: sourceCFI,
targetHref: targetHref,
targetFragment: targetFragment,
targetCFI: targetCFI,
label: targetFragment,
kind: kind(from: haystack)
)
}
public static func kind(from text: String) -> RDEPUBNoteKind {
let lowered = text.lowercased()
if lowered.contains("endnote") { return .endnote }
if lowered.contains("rearnote") { return .rearnote }
if lowered.contains("footnote") || lowered.contains("fn") { return .footnote }
return .unknown
}
private static func hasEPUBType(_ type: String, in html: String?) -> Bool {
guard let html else { return false }
let pattern = #"epub:type\s*=\s*['"][^'"]*\b"# + NSRegularExpression.escapedPattern(for: type) + #"\b[^'"]*['"]"#
return html.range(of: pattern, options: [.regularExpression, .caseInsensitive]) != nil
}
}
@@ -0,0 +1,85 @@
import Foundation
public enum RDEPUBNoteKind: String, Codable, Equatable {
case footnote
case endnote
case rearnote
case unknown
}
public struct RDEPUBNoteReference: Codable, Equatable {
public var sourceHref: String
public var sourceCFI: String?
public var targetHref: String
public var targetFragment: String?
public var targetCFI: String?
public var label: String?
public var kind: RDEPUBNoteKind
public init(
sourceHref: String,
sourceCFI: String? = nil,
targetHref: String,
targetFragment: String? = nil,
targetCFI: String? = nil,
label: String? = nil,
kind: RDEPUBNoteKind = .unknown
) {
self.sourceHref = sourceHref
self.sourceCFI = sourceCFI
self.targetHref = targetHref
self.targetFragment = targetFragment
self.targetCFI = targetCFI
self.label = label?.nilIfEmpty
self.kind = kind
}
}
public struct RDEPUBResolvedNote: Equatable {
public var reference: RDEPUBNoteReference
public var sourceLocation: RDEPUBLocation?
public var targetLocation: RDEPUBLocation
public var title: String?
public var html: String
public var plainText: String
public init(
reference: RDEPUBNoteReference,
sourceLocation: RDEPUBLocation? = nil,
targetLocation: RDEPUBLocation,
title: String? = nil,
html: String,
plainText: String
) {
self.reference = reference
self.sourceLocation = sourceLocation
self.targetLocation = targetLocation
self.title = title?.nilIfEmpty
self.html = html
self.plainText = plainText
}
}
private extension String {
var nilIfEmpty: String? {
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}
@@ -0,0 +1,159 @@
import Foundation
public final class RDEPUBNoteResolver {
private let resourceResolver: RDEPUBResourceResolver
public init(resourceResolver: RDEPUBResourceResolver) {
self.resourceResolver = resourceResolver
}
public func resolveInternalLink(
sourceHref: String,
sourceCFI: String?,
targetLocation: RDEPUBLocation,
sourceLocation: RDEPUBLocation? = nil,
relativeToSpineIndex spineIndex: Int?
) -> RDEPUBResolvedNote? {
guard let normalizedTarget = resourceResolver.normalizedLocation(
targetLocation,
relativeToSpineIndex: spineIndex,
bookIdentifier: targetLocation.bookIdentifier
) else {
return nil
}
let fragment = normalizedTarget.fragment
guard let elementHTML = extractElementHTML(href: normalizedTarget.href, fragment: fragment) else {
return nil
}
guard let reference = RDEPUBNoteDetector.makeReferenceIfLikelyNote(
sourceHref: sourceHref,
sourceCFI: sourceCFI,
targetHref: normalizedTarget.href,
targetFragment: fragment,
targetCFI: normalizedTarget.cfi,
targetElementHTML: elementHTML
) else {
return nil
}
let sanitizedHTML = stripBacklinks(from: elementHTML)
let plainText = plainText(from: sanitizedHTML)
guard !plainText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return nil
}
return RDEPUBResolvedNote(
reference: reference,
sourceLocation: sourceLocation,
targetLocation: normalizedTarget,
title: title(for: reference),
html: sanitizedHTML,
plainText: plainText
)
}
public func extractElementHTML(href: String, fragment: String?) -> String? {
guard let fragment,
!fragment.isEmpty,
let fileURL = resourceResolver.fileURL(forRelativePath: href),
let html = try? String(contentsOf: fileURL, encoding: .utf8) else {
return nil
}
return elementHTML(withID: fragment, in: html)
}
private func elementHTML(withID id: String, in html: String) -> String? {
let escapedID = NSRegularExpression.escapedPattern(for: id)
let pattern = #"<([A-Za-z][A-Za-z0-9:_-]*)(?=[^>]*(?:id|xml:id)\s*=\s*(['"])"# + escapedID + #"\2)[^>]*>"#
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
return nil
}
let nsRange = NSRange(html.startIndex..<html.endIndex, in: html)
guard let match = regex.firstMatch(in: html, options: [], range: nsRange),
let startRange = Range(match.range, in: html),
let tagRange = Range(match.range(at: 1), in: html) else {
return nil
}
let tagName = String(html[tagRange])
guard !String(html[startRange]).hasSuffix("/>") else {
return String(html[startRange])
}
guard let endRange = matchingEndTagRange(
tagName: tagName,
in: html,
after: startRange.upperBound
) else {
return String(html[startRange])
}
return String(html[startRange.lowerBound..<endRange.upperBound])
}
private func stripBacklinks(from html: String) -> String {
let backlinkPattern = #"<a\b[^>]*(?:rev\s*=\s*['"]footnote['"]|epub:type\s*=\s*['"][^'"]*(?:backlink|return)[^'"]*['"]|class\s*=\s*['"][^'"]*(?:backlink|return)[^'"]*['"])[^>]*>[\s\S]*?</a>"#
guard let regex = try? NSRegularExpression(pattern: backlinkPattern, options: [.caseInsensitive]) else {
return html
}
let nsRange = NSRange(html.startIndex..<html.endIndex, in: html)
return regex.stringByReplacingMatches(in: html, options: [], range: nsRange, withTemplate: "")
}
private func plainText(from html: String) -> String {
let withoutScripts = html
.replacingOccurrences(of: #"<script[\s\S]*?</script>"#, with: "", options: .regularExpression)
.replacingOccurrences(of: #"<style[\s\S]*?</style>"#, with: "", options: .regularExpression)
let withoutTags = withoutScripts.replacingOccurrences(of: #"<[^>]+>"#, with: " ", options: .regularExpression)
return withoutTags
.replacingOccurrences(of: "&nbsp;", with: " ")
.replacingOccurrences(of: "&lt;", with: "<")
.replacingOccurrences(of: "&gt;", with: ">")
.replacingOccurrences(of: "&amp;", with: "&")
.components(separatedBy: .whitespacesAndNewlines)
.filter { !$0.isEmpty }
.joined(separator: " ")
}
private func title(for reference: RDEPUBNoteReference) -> String {
switch reference.kind {
case .footnote:
return "脚注"
case .endnote:
return "尾注"
case .rearnote:
return "后注"
case .unknown:
return "注释"
}
}
private func matchingEndTagRange(
tagName: String,
in html: String,
after startIndex: String.Index
) -> Range<String.Index>? {
let escapedTagName = NSRegularExpression.escapedPattern(for: tagName)
let pattern = #"</?\s*"# + escapedTagName + #"\b[^>]*>"#
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
return nil
}
let searchRange = NSRange(startIndex..<html.endIndex, in: html)
var depth = 1
for match in regex.matches(in: html, options: [], range: searchRange) {
guard let range = Range(match.range, in: html) else { continue }
let tag = String(html[range])
if tag.hasPrefix("</") {
depth -= 1
} else if !tag.hasSuffix("/>") {
depth += 1
}
if depth == 0 {
return range
}
}
return nil
}
}
@@ -1,34 +1,28 @@
// RDEPUBAssetRepository.swift
// EPUBCore 静态资源仓库
// 负责从资源包中加载 EPUB 渲染所需的 JS 桥接脚本和固定版式 HTML 模板,
// 支持模板变量替换({{token}} 占位符)。
import Foundation
/// EPUB 内置资源枚举,定义桥接脚本和固定版式模板的文件名及扩展名
enum RDEPUBAsset: String {
/// Rangy core 等价桥接脚本,负责提供 Web range/selection 基础能力
case rangyCoreScript = "rangy-core"
/// Rangy serializer 等价桥接脚本,负责 DOM range 序列化/反序列化
case rangySerializerScript = "rangy-serializer"
/// WXRead 对齐的动态 CSS 注入脚本
case cssInjectorScript = "cssInjector"
/// WXRead 对齐的 WeReadApi JS-Native 桥接脚本
case weReadAPIScript = "WeReadApi"
/// JS 桥接脚本(epub-bridge.js),注入到 WebView 中实现原生-JS 通信
case bridgeScript = "epub-bridge"
/// 固定版式 HTML 模板(epub-fixed-layout.html),用于渲染 pre-paginated 类型的 EPUB
case fixedLayoutTemplate = "epub-fixed-layout"
/// WXRead 对齐的 default.css
case wxReadDefaultCSS = "wxread-default"
/// WXRead 对齐的 replace.css
case wxReadReplaceCSS = "wxread-replace"
/// WXRead 对齐的 dark.css
case wxReadDarkCSS = "wxread-dark"
/// WXRead 对齐的 replaceForLatinLanguageBook.css
case wxReadLatinReplaceCSS = "wxread-replace-latin"
/// 对应的文件扩展名
var fileExtension: String {
switch self {
case .rangyCoreScript, .rangySerializerScript, .cssInjectorScript, .weReadAPIScript, .bridgeScript:
@@ -41,13 +35,8 @@ enum RDEPUBAsset: String {
}
}
/// 静态资源加载器,负责从资源包中读取模板文件并执行变量替换
enum RDEPUBAssetRepository {
/// 加载指定资源文件的内容,并将 {{key}} 占位符替换为对应的值
/// - Parameters:
/// - asset: 要加载的资源类型
/// - replacements: 模板变量字典,key 为占位符名称,value 为替换值
/// - Returns: 替换后的文件内容字符串
static func string(for asset: RDEPUBAsset, replacements: [String: String] = [:]) -> String {
guard let url = resourceBundle.url(forResource: asset.rawValue, withExtension: asset.fileExtension),
var content = try? String(contentsOf: url, encoding: .utf8) else {
@@ -61,7 +50,6 @@ enum RDEPUBAssetRepository {
return content
}
/// 获取资源所在的 Bundle(优先使用已解析的子 bundle,兜底到宿主 bundle)
private static var resourceBundle: Bundle {
if let bundle = resolvedBundle {
return bundle
@@ -69,7 +57,6 @@ enum RDEPUBAssetRepository {
return Bundle(for: RDEPUBAssetBundleToken.self)
}
/// 延迟解析 RDReaderViewAssets.bundle 的位置,在宿主 bundle 和所有 framework 中查找
private static var resolvedBundle: Bundle? = {
let hostBundles = [Bundle(for: RDEPUBAssetBundleToken.self), Bundle.main] + Bundle.allFrameworks + Bundle.allBundles
for hostBundle in hostBundles {
@@ -82,5 +69,4 @@ enum RDEPUBAssetRepository {
}()
}
/// 用于定位当前模块 Bundle 的标记类
private final class RDEPUBAssetBundleToken {}
@@ -1,25 +1,15 @@
// RDEPUBFixedLayoutTemplate.swift
// 固定版式(Fixed Layout)HTML 模板生成器
// 根据渲染请求和 Publication 信息,将 iframe 拼装到 HTML 模板中,
// 生成适配视口尺寸和内边距的固定版式页面 HTML。
import Foundation
/// 固定版式模板生成器,负责将 spine 资源渲染为 iframe 嵌套的 HTML 页面
enum RDEPUBFixedLayoutTemplate {
/// 根据渲染请求和 Publication 生成固定版式页面的完整 HTML
/// - Parameters:
/// - request: 固定版式渲染请求(含 spread、视口尺寸、内边距、适配模式等)
/// - publication: EPUB 出版物,用于解析资源 URL
/// - Returns: 完整的 HTML 字符串
static func html(for request: RDEPUBFixedRenderRequest, publication: RDEPUBPublication) -> String {
// 遍历 spread 中的每个资源,生成对应的 iframe pane HTML
let panes = request.spread.resources.enumerated().compactMap { index, resource -> String? in
guard let url = publication.resourceResolver.resourceURL(forRelativePath: resource.href)?.absoluteString else {
return nil
}
// 根据 spread 中的资源数量判断页面类型:单页/左页/右页/居中
let pageType: String
if request.spread.resources.count == 1 {
pageType = "single"
@@ -36,7 +26,6 @@ enum RDEPUBFixedLayoutTemplate {
"""
}.joined()
// 计算视口区域(去除内边距后的可用区域)
let background = request.backgroundColorCSS ?? "#FFFFFF"
let viewportWidth = max(1, Int((request.viewportSize.width - request.contentInset.left - request.contentInset.right).rounded(.down)))
let viewportHeight = max(1, Int((request.viewportSize.height - request.contentInset.top - request.contentInset.bottom).rounded(.down)))
@@ -44,7 +33,7 @@ enum RDEPUBFixedLayoutTemplate {
let insetRight = Int(request.contentInset.right.rounded(.down))
let insetBottom = Int(request.contentInset.bottom.rounded(.down))
let insetLeft = Int(request.contentInset.left.rounded(.down))
// 从资源仓库加载 HTML 模板并替换占位符
return RDEPUBAssetRepository.string(
for: .fixedLayoutTemplate,
replacements: [
@@ -1,35 +1,27 @@
// RDEPUBJavaScriptBridge.swift
// 原生与 WebView 之间的 JavaScript 桥接层
// 定义所有 JS 消息类型(progression/selection/link/error 等),
// 生成注入到 WebView 的用户脚本,以及分页渲染、搜索高亮等执行脚本。
import Foundation
/// JS 桥接消息类型枚举,每种消息对应 WebView 向原生发送的一种事件
enum RDEPUBJavaScriptBridgeMessage: String, CaseIterable {
/// 阅读进度变化事件
case progressionChanged = "ssReaderProgressionChanged"
/// 文本选择变化事件
case selectionChanged = "ssReaderSelectionChanged"
/// 内部链接点击事件(ss-reader:// 协议)
case internalLink = "ssReaderInternalLink"
/// 外部链接点击事件(http/https/mailto/tel)
case externalLink = "ssReaderExternalLink"
/// JavaScript 运行时错误事件
case javaScriptError = "ssReaderJSError"
/// 固定版式渲染就绪事件
case fixedLayoutReady = "ssReaderFixedLayoutReady"
}
/// JavaScript 桥接工具集,负责生成注入脚本和执行脚本
enum RDEPUBJavaScriptBridge {
/// 所有需要注册的 JS 消息名称列表,用于 WKUserContentController 注册
static var messageNames: [String] {
RDEPUBJavaScriptBridgeMessage.allCases.map(\.rawValue)
}
/// 获取注入到 WebView 的桥接用户脚本(在 document end 注入)
/// 将消息类型占位符替换为实际值,使 JS 端能通过 window.webkit.messageHandlers 发送消息
static var userScript: String {
[
RDEPUBAssetRepository.string(for: .rangyCoreScript),
@@ -50,10 +42,6 @@ enum RDEPUBJavaScriptBridge {
].joined(separator: "\n\n")
}
/// 生成重排模式下的分页渲染脚本
/// 注入 CSS 分页样式、设置页码参数、高亮和目标位置,最后触发进度汇报
/// - Parameter request: 重排渲染请求,包含展示样式、高亮、目标位置等信息
/// - Returns: 可直接通过 evaluateJavaScript 执行的脚本字符串
static func applyPresentationScript(for request: RDEPUBReflowableRenderRequest) -> String {
let style = escapedJavaScriptTemplateLiteral(RDEPUBStyleSheetBuilder.renderCSS(for: request.presentation))
let pageStride = max(1, request.presentation.viewportSize.width)
@@ -87,10 +75,6 @@ enum RDEPUBJavaScriptBridge {
"""
}
/// 生成固定版式模式下的主题应用脚本
/// 仅应用共享主题(背景色),不涉及分页逻辑(固定版式由 HTML 自身布局决定)
/// - Parameter request: 固定版式渲染请求,包含背景色等信息
/// - Returns: 可直接通过 evaluateJavaScript 执行的脚本字符串
static func applyFixedPresentationScript(for request: RDEPUBFixedRenderRequest) -> String {
"""
(function() {
@@ -105,11 +89,6 @@ enum RDEPUBJavaScriptBridge {
"""
}
/// 生成搜索高亮脚本
/// 在当前文档和所有 iframe 中查找关键词并用 span 标记高亮,
/// 活跃匹配项使用特殊样式并自动滚动到视图中央
/// - Parameter presentation: 搜索展示信息(关键词、各资源的匹配数量等)
/// - Returns: 搜索高亮脚本字符串
static func applySearchScript(for presentation: RDEPUBSearchPresentation?) -> String {
let payload = jsonString(from: searchPayload(presentation), fallback: "null")
return """
@@ -121,9 +100,6 @@ enum RDEPUBJavaScriptBridge {
"""
}
/// 生成获取当前装饰数据的脚本
/// 从 WebView 中读取高亮和搜索高亮的 DOM 装饰信息,用于同步原生标注状态
/// - Returns: 可直接通过 evaluateJavaScript 执行的脚本字符串,返回包含 highlights 和 search 的对象
static func resolveDecorationsScript() -> String {
"""
(function() {
@@ -133,14 +109,12 @@ enum RDEPUBJavaScriptBridge {
"""
}
/// 转义字符串中的反斜杠和反引号,避免 JS 模板字面量语法错误
private static func escapedJavaScriptTemplateLiteral(_ string: String) -> String {
string
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "`", with: "\\`")
}
/// 将高亮数组转换为 JS 可用的字典数组
private static func highlightsPayload(_ highlights: [RDEPUBHighlight]) -> [[String: String]] {
highlights.compactMap { highlight in
guard let rangeInfo = highlight.rangeInfo, !rangeInfo.isEmpty else { return nil }
@@ -153,7 +127,6 @@ enum RDEPUBJavaScriptBridge {
}
}
/// 将目标位置转换为 JS 可用的字典(含 progression、fragment)
private static func targetLocationPayload(_ location: RDEPUBLocation?) -> [String: Any]? {
guard let location else { return nil }
return [
@@ -163,7 +136,6 @@ enum RDEPUBJavaScriptBridge {
]
}
/// 将搜索展示信息转换为 JS 可用的字典
private static func searchPayload(_ presentation: RDEPUBSearchPresentation?) -> [String: Any]? {
guard let presentation else { return nil }
return [
@@ -178,7 +150,6 @@ enum RDEPUBJavaScriptBridge {
]
}
/// 将任意对象序列化为 JSON 字符串,失败时返回 fallback
private static func jsonString(from object: Any?, fallback: String) -> String {
guard let object else { return fallback }
guard JSONSerialization.isValidJSONObject(object),
@@ -189,8 +160,6 @@ enum RDEPUBJavaScriptBridge {
return string
}
/// 将 Swift 字符串转换为 JavaScript 字符串字面量
/// nil 值转为 "null",非空值序列化为 JSON 字符串后去除外层方括号
private static func javaScriptStringLiteral(_ value: String?) -> String {
guard let value else { return "null" }
return jsonString(from: [value], fallback: "[null]")
@@ -1,71 +1,45 @@
//
// RDEPUBModels.swift
// RDReaderView
//
// EPUBCore 层核心数据模型定义文件。
// 包含 EPUB 解析过程中所需的全部基础枚举和数据结构:
// 布局类型、阅读方向、页面分布、元数据、清单项、Spine 项、目录项及解析错误类型。
// 这些模型由 RDEPUBParser 解析后填充,并由 RDEPUBPublication 聚合对外暴露。
//
import Foundation
/// EPUB 版面布局类型
/// - reflowable: 可重排布局,内容根据屏幕尺寸和字号自动分页(小说类 EPUB)
/// - fixed: 固定版式布局,页面尺寸固定不变(漫画、绘本类 EPUB)
public enum RDEPUBLayout: String, Codable {
case reflowable
case fixed
}
/// 阅读配置文件类型,决定使用哪种渲染路径
/// - webInteractive: 可重排 + 含交互脚本,使用 WKWebView 渲染
/// - webFixedLayout: 固定版式 EPUB(漫画、绘本),使用 WKWebView 渲染
/// - textReflowable: 纯文本可重排 EPUB,使用 DTCoreText 渲染(性能更优)
public enum RDEPUBReadingProfile: String, Codable {
case webInteractive
case webFixedLayout
case textReflowable
}
/// 阅读方向(文字排版方向)
/// - ltr: 从左到右(英文、中文横排)
/// - rtl: 从右到左(日文竖排、阿拉伯文)
/// - auto: 根据语言自动判断
public enum RDEPUBReadingProgression: String, Codable {
case ltr
case rtl
case auto
}
/// 固定版式页面在 spread(双页展开)中的位置
/// - left: 左页
/// - right: 右页
/// - center: 居中(跨页或单页居中)
public enum RDEPUBPageSpread: String, Codable {
case left
case right
case center
}
/// EPUB 出版物元数据,对应 OPF 文档中 <metadata> 节点的内容
/// 包含书籍的基本信息:标识符、标题、作者、语言、版本、布局类型等
public struct RDEPUBMetadata: Codable, Equatable {
/// 书籍唯一标识符(对应 <dc:identifier>)
public var identifier: String?
/// 书名(对应 <dc:title>)
public var title: String
/// 作者(对应 <dc:creator>)
public var author: String?
/// 语言代码(对应 <dc:language>,如 "zh"、"en")
public var language: String?
/// EPUB 版本号(如 "2.0"、"3.0")
public var version: String?
/// 版面布局类型(reflowable 或 fixed)
public var layout: RDEPUBLayout
/// spread 模式设置(对应 <meta property="rendition:spread">)
public var spread: String?
/// 阅读方向(ltr / rtl / auto)
public var readingProgression: RDEPUBReadingProgression
public init(
@@ -89,22 +63,20 @@ public struct RDEPUBMetadata: Codable, Equatable {
}
}
/// EPUB 清单项,对应 OPF 文档 <manifest> 中的每个 <item>
/// 描述一个资源文件的 ID、路径、MIME 类型和附加属性
public struct RDEPUBManifestItem: Codable, Equatable {
/// 清单项的唯一标识符(对应 <item id="...">)
public var id: String
/// 资源文件路径,相对于 OPF 文件所在目录
public var href: String
/// MIME 类型(如 "application/xhtml+xml"、"image/jpeg"、"text/css")
public var mediaType: String
/// 属性列表(如 "nav" 表示导航文档、"scripted" 表示含脚本、"cover-image" 表示封面)
public var properties: [String]
/// 回退项 ID(对应 <item fallback="...">)
public var fallback: String?
/// 媒体叠加文件 ID(EPUB 3 有声书功能)
public var mediaOverlay: String?
/// 资源标题(用于搜索结果展示等辅助用途)
public var title: String?
public init(
@@ -125,37 +97,31 @@ public struct RDEPUBManifestItem: Codable, Equatable {
self.title = title
}
/// 是否为 EPUB 3 导航文档(Nav Document)
/// 通过检查 properties 中是否包含 "nav" 标记判断
public var isNavigationDocument: Bool {
properties.contains("nav")
}
/// 是否为 NCX 目录文件(EPUB 2 格式)
/// 通过 MIME 类型 "application/x-dtbncx+xml" 判断
public var isNCX: Bool {
mediaType == "application/x-dtbncx+xml"
}
}
/// EPUB Spine 项,对应 OPF 文档 <spine> 中的每个 <itemref>
/// Spine 定义了阅读器中章节内容的线性阅读顺序
public struct RDEPUBSpineItem: Codable, Equatable {
/// 对应 manifest 中的 item id(用于关联资源)
public var idref: String
/// 标准化后的资源路径(相对于 OPF 文件所在目录)
public var href: String
/// MIME 类型
public var mediaType: String
/// 章节标题(从目录或 manifest 推断)
public var title: String
/// 是否为线性内容("no" 表示补充材料,不影响主线阅读进度)
public var linear: Bool
/// 属性列表(如 rendition:layout-* 用于单页布局覆盖)
public var properties: [String]
/// 页面在 spread 中的位置偏好(左页/右页/居中)
public var pageSpread: RDEPUBPageSpread?
/// 单页布局覆盖(显式属性可覆盖出版级别的 layout 设置)
public var layout: RDEPUBLayout?
public init(
@@ -179,14 +145,12 @@ public struct RDEPUBSpineItem: Codable, Equatable {
}
}
/// EPUB 目录项,支持树形层级结构
/// 来源于 NCX 文件(EPUB 2)或 Nav Document(EPUB 3)的解析结果
public struct EPUBTableOfContentsItem: Codable, Equatable {
/// 目录项标题(显示在目录面板中)
public var title: String
/// 目标资源路径(含可能的 fragment 锚点)
public var href: String
/// 子目录项列表(递归结构,支持多级目录)
public var children: [EPUBTableOfContentsItem]
public init(title: String, href: String, children: [EPUBTableOfContentsItem] = []) {
@@ -196,23 +160,22 @@ public struct EPUBTableOfContentsItem: Codable, Equatable {
}
}
/// EPUB 解析器错误类型,涵盖从 ZIP 解压到 OPF 解析全链路的异常情况
public enum RDEPUBParserError: LocalizedError {
/// ZIP 文件无法打开(文件损坏或格式不支持)
case archiveOpenFailed(URL)
/// 缺少 META-INF/container.xml 文件(EPUB 格式不合规)
case missingContainerXML
/// container.xml 中未找到 <rootfile> 元素
case missingRootFile
/// OPF 根文件路径无效(文件不存在)
case invalidRootFilePath(String)
/// XML 解析失败(格式错误或编码问题)
case invalidXML(URL)
/// spine 中引用的 manifest 项不存在(idref 不匹配)
case missingManifestItem(idref: String)
/// 构建完成的 spine 为空(无可阅读的内容)
case emptySpine
/// 归档条目路径包含路径穿越或非法字符
case invalidArchiveEntryPath(String)
public var errorDescription: String? {
@@ -1,22 +1,16 @@
// RDEPUBNavigatorLayoutContext.swift
// 导航器布局上下文
// 封装阅读器容器的布局参数:容器尺寸、每屏页数、安全区域、设备类型、
// 重排内容内边距等,供 Paginator 和渲染层使用。
import UIKit
/// 导航器布局上下文,描述阅读器容器的布局参数
/// 在 Paginator 计算分页和 WebView 渲染时作为核心输入
public struct RDEPUBNavigatorLayoutContext: Equatable {
/// 容器视图的总尺寸
public var containerSize: CGSize
/// 每屏显示的页数(横屏双页模式为 2)
public var pagesPerScreen: Int
/// 安全区域内边距(刘海屏、Home Indicator 等)
public var safeAreaInsets: UIEdgeInsets
/// 用户界面设备类型(phone/pad)
public var userInterfaceIdiom: UIUserInterfaceIdiom
/// 重排模式下的内容内边距(上下左右留白)
public var reflowableContentInsets: UIEdgeInsets
public init(
@@ -33,7 +27,6 @@ public struct RDEPUBNavigatorLayoutContext: Equatable {
self.reflowableContentInsets = reflowableContentInsets
}
/// 计算单页视口尺寸(双页模式下宽度除以 2)
public var viewportSize: CGSize {
let width: CGFloat
if pagesPerScreen > 1 {
@@ -44,7 +37,6 @@ public struct RDEPUBNavigatorLayoutContext: Equatable {
return CGSize(width: width, height: containerSize.height)
}
/// 固定版式模式下的内容内边距(仅手机端使用安全区域,Pad 端归零;左右取较大值对称)
public var fixedContentInset: UIEdgeInsets {
var insets = safeAreaInsets
if userInterfaceIdiom != .phone {
@@ -1,33 +1,20 @@
//
// RDEPUBNavigatorState.swift
// RDReaderView
//
// EPUBCore 层导航状态机定义文件。
// 定义了阅读器导航器的状态枚举,状态流转遵循:
// initializing → loading → idle ↔ jumping/moving/repaginating
// 由 RDEPUBReadingSession 管理状态跃迁,控制分页快照的应用时机。
//
import Foundation
/// 导航状态枚举,描述阅读器的当前操作状态
/// 状态机流转:initializing → loading → idle ↔ jumping/moving/repaginating
public enum RDEPUBNavigatorState: String, Codable {
/// 初始化状态,刚打开书籍,等待初始恢复位置
case initializing
/// 加载状态,正在生成首轮分页模型
case loading
/// 空闲状态,显示稳定,可接受用户操作和快照应用
case idle
/// 跳转状态,正在执行目录/内部链接跳转
case jumping
/// 翻页状态,用户正在翻页操作中
case moving
/// 重排状态,正在重新分页(字号/横竖屏变化触发)
case repaginating
/// 是否处于可应用分页快照的稳定状态
/// 只有 idle 状态才允许消费 staged snapshot
public var isStableForSnapshotApplication: Bool {
self == .idle
}
@@ -1,45 +1,38 @@
// RDEPUBPaginator.swift
// EPUB 离屏分页计算器
// 使用隐藏的 WKWebView 加载每个 spine 项,通过 JS 测量 scrollWidth
// 计算每章的页数。采用多轮延迟测量(0ms/80ms/180ms)确保布局稳定。
// 固定版式(Fixed Layout)直接返回 1 页。
import UIKit
import WebKit
/// EPUB 分页计算器,通过离屏 WebView 测量每个 spine 项的分页数
public final class RDEPUBPaginator: NSObject {
/// 当前解析器引用
private var parser: RDEPUBParser?
/// 承载 WebView 的父视图(弱引用)
private weak var hostingView: UIView?
/// 当前展示样式(视口尺寸、内边距、字号、行高)
private var presentation = RDEPUBPresentationStyle(
viewportSize: .zero,
contentInsets: .zero,
fontSize: 16,
lineHeightMultiple: 1.5
)
/// 全量分页完成回调(返回每个 spine 项的页数数组)
private var completion: (([Int]) -> Void)?
/// 单个 spine 项分页完成回调
private var singlePageCountCompletion: ((Int) -> Void)?
/// 各 spine 项的页数结果
private var pageCounts: [Int] = []
/// 待测量的 spine 项索引列表
private var measurementIndices: [Int] = []
/// 当前测量偏移量(在 measurementIndices 中的位置)
private var currentMeasurementOffset = 0
/// 当前 spine 项的待定页数(多轮测量取最大值)
private var pendingMeasurementValue = 1
/// 当前测量轮次(0/1/2 对应延迟 0ms/80ms/180ms)
private var measurementPass = 0
/// 当前测量会话 ID,用于防并发和取消过期任务
private var activeSessionID = UUID()
/// 调试日志作用域标识
private let debugScope = "PaginatorWebView"
/// 用于分页测量的隐藏 WebView(非持久化数据存储,禁止用户交互)
private lazy var webView: WKWebView = {
let configuration = WKWebViewConfiguration()
configuration.websiteDataStore = .nonPersistent()
@@ -70,12 +63,6 @@ public final class RDEPUBPaginator: NSObject {
webView.removeFromSuperview()
}
/// 计算所有 spine 项的分页数
/// - Parameters:
/// - parser: EPUB 解析器
/// - hostingView: 承载 WebView 的父视图
/// - presentation: 展示样式(视口、内边距、字号、行高)
/// - completion: 完成回调,返回每个 spine 项的页数数组
public func calculate(
parser: RDEPUBParser,
hostingView: UIView,
@@ -106,7 +93,6 @@ public final class RDEPUBPaginator: NSObject {
measureNextSpineItem()
}
/// 便捷方法:使用独立参数计算所有 spine 项的分页数
public func calculate(
parser: RDEPUBParser,
hostingView: UIView,
@@ -129,13 +115,6 @@ public final class RDEPUBPaginator: NSObject {
)
}
/// 仅计算单个 spine 项的分页数
/// - Parameters:
/// - parser: EPUB 解析器
/// - spineIndex: 要测量的 spine 项索引
/// - hostingView: 承载 WebView 的父视图
/// - presentation: 展示样式
/// - completion: 完成回调,返回该 spine 项的页数
public func calculateSingleSpinePageCount(
parser: RDEPUBParser,
spineIndex: Int,
@@ -167,7 +146,6 @@ public final class RDEPUBPaginator: NSObject {
measureNextSpineItem()
}
/// 便捷方法:使用独立参数计算单个 spine 项的分页数
public func calculateSingleSpinePageCount(
parser: RDEPUBParser,
spineIndex: Int,
@@ -192,7 +170,6 @@ public final class RDEPUBPaginator: NSObject {
)
}
/// 静态方法:将分页 CSS 注入到 HTML 字符串中(用于预渲染场景)
public static func injectPaginationCSS(
into html: String,
viewportSize: CGSize,
@@ -215,7 +192,6 @@ public final class RDEPUBPaginator: NSObject {
)
}
/// 测量下一个 spine 项:加载文件到 WebView,跳过不可渲染或文件缺失的项
private func measureNextSpineItem() {
guard let parser else {
finishIfNeeded()
@@ -253,12 +229,10 @@ public final class RDEPUBPaginator: NSObject {
webView.loadFileURL(fileURL, allowingReadAccessTo: readAccessURL)
}
/// 获取当前正在测量的 spine 项索引
private func currentSpineIndexForMeasurement() -> Int? {
measurementIndices[safe: currentMeasurementOffset]
}
/// 检查是否所有 spine 项都已测量完毕,触发对应的完成回调
private func finishIfNeeded() {
if let singlePageCountCompletion {
let measuredSpineIndex = measurementIndices.first ?? 0
@@ -273,7 +247,6 @@ public final class RDEPUBPaginator: NSObject {
cleanupMeasurementState()
}
/// 清理测量状态,释放 WebView 和所有引用
private func cleanupMeasurementState() {
parser = nil
hostingView = nil
@@ -289,16 +262,13 @@ public final class RDEPUBPaginator: NSObject {
webView.removeFromSuperview()
}
/// 判断 spine 项是否可渲染(仅 HTML/XHTML/XML 类型可分页)
private func isRenderablePage(item: RDEPUBSpineItem) -> Bool {
let mediaType = item.mediaType.lowercased()
return mediaType.contains("html") || mediaType.contains("xhtml") || mediaType.contains("xml")
}
/// 各轮测量记录值(用于稳定性日志)
private var measurementPassValues: [Int] = []
/// 调度多轮测量:0ms/80ms/180ms 三轮延迟,取最大值以应对布局抖动
private func scheduleMeasurementPass() {
let sessionID = activeSessionID
let delays: [TimeInterval] = [0.0, 0.08, 0.18]
@@ -308,7 +278,7 @@ public final class RDEPUBPaginator: NSObject {
return
}
let finalValue = max(1, pendingMeasurementValue)
// 测量稳定性日志:三轮差异过大时告警
if measurementPassValues.count >= 2 {
let minVal = measurementPassValues.min() ?? 1
let maxVal = measurementPassValues.max() ?? 1
@@ -331,7 +301,6 @@ public final class RDEPUBPaginator: NSObject {
}
}
/// 执行当前文档的测量:通过 JS 获取 scrollWidth 并计算页数
private func measureCurrentDocument(sessionID: UUID) {
let script = RDEPUBStyleSheetBuilder.measurementScript(for: presentation)
RDEPUBWebViewDebug.logJavaScript(debugScope, webView: webView, action: "measure", details: "session=\(sessionID) pass=\(measurementPass)")
@@ -357,6 +326,7 @@ public final class RDEPUBPaginator: NSObject {
}
extension RDEPUBPaginator: WKNavigationDelegate {
public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
guard let url = navigationAction.request.url else {
decisionHandler(.cancel)
@@ -402,7 +372,6 @@ extension RDEPUBPaginator: WKNavigationDelegate {
}
}
/// Array 安全下标扩展,越界时返回 nil 而非崩溃
private extension Array {
subscript(safe index: Int) -> Element? {
indices.contains(index) ? self[index] : nil
@@ -1,15 +1,9 @@
// RDEPUBParser+Archive.swift
// EPUB 归档解压与 container.xml 解析
// 负责将 EPUB ZIP 文件解压到缓存目录(~/Library/Caches/ssreaderview-epub/),
// 并解析 META-INF/container.xml 获取 OPF 文件路径。
import Foundation
import ZIPFoundation
extension RDEPUBParser {
/// 解析 container.xml,提取 OPF(Package Document)的相对路径
/// - Parameter containerURL: container.xml 的文件路径
/// - Returns: OPF 文件的相对路径(如 "OEBPS/content.opf")
func parseContainerRootFile(at containerURL: URL) throws -> String {
guard let parser = XMLParser(contentsOf: containerURL) else {
throw RDEPUBParserError.invalidXML(containerURL)
@@ -30,10 +24,6 @@ extension RDEPUBParser {
return rootFilePath
}
/// 解压 EPUB ZIP 文件到缓存目录(幂等:已存在则跳过)
/// 目录名由 slug + 文件大小 + 修改时间戳组成,避免冲突
/// - Parameter epubURL: EPUB 文件路径
/// - Returns: 解压目标目录 URL
func extractArchiveIfNeeded(epubURL: URL) throws -> URL {
let fileManager = FileManager.default
let extractionURL = temporaryExtractionDirectory(for: epubURL)
@@ -66,17 +56,12 @@ extension RDEPUBParser {
return extractionURL
}
/// 校验归档条目路径,防止路径穿越攻击
/// - Parameters:
/// - entryPath: 归档中的原始条目路径
/// - extractionRoot: 解压根目录
/// - Returns: 校验通过的目标 URL,非法路径返回 nil
private func validatedExtractionDestination(for entryPath: String, extractionRoot: URL) -> URL? {
// 拒绝绝对路径
if entryPath.hasPrefix("/") {
return nil
}
// 拒绝包含 .. 的路径段
let components = entryPath.split(separator: "/", omittingEmptySubsequences: true)
if components.contains(where: { $0 == ".." }) {
return nil
@@ -84,15 +69,13 @@ extension RDEPUBParser {
let destinationURL = extractionRoot.appendingPathComponent(entryPath)
let standardizedDest = destinationURL.standardizedFileURL.path
let standardizedRoot = extractionRoot.standardizedFileURL.path
// 确保最终路径位于解压根目录下
guard standardizedDest.hasPrefix(standardizedRoot) else {
return nil
}
return destinationURL
}
/// 计算 EPUB 的临时解压目录路径
/// 路径格式:~/Library/Caches/ssreaderview-epub/{slug}-{fileSize}-{modifiedTimestamp}/
func temporaryExtractionDirectory(for epubURL: URL) -> URL {
let baseURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?
.appendingPathComponent("ssreaderview-epub", isDirectory: true)
@@ -106,14 +89,12 @@ extension RDEPUBParser {
return baseURL.appendingPathComponent("\(slug)-\(fileSize)-\(signature)", isDirectory: true)
}
/// 重置解析器状态(清除解压路径和出版物数据)
func reset() {
extractionRootURL = nil
opfURL = nil
resetPublicationState()
}
/// 重置出版物相关状态(metadata、manifest、spine、目录)
func resetPublicationState() {
metadata = RDEPUBMetadata()
manifest = [:]
@@ -122,9 +103,8 @@ extension RDEPUBParser {
}
}
/// container.xml 的 SAX 解析代理,提取 <rootfile> 元素的 full-path 属性
private final class ContainerXMLParserDelegate: NSObject, XMLParserDelegate {
/// 解析得到的 OPF 文件相对路径
private(set) var rootFilePath: String?
func parser(
@@ -1,18 +1,8 @@
// RDEPUBParser+Package.swift
// OPF(Package Document)解析与 Spine 构建
// 包含 OPFPackageParserDelegate(SAX 解析 metadata/manifest/spine),
// 从解析结果构建 RDEPUBSpineItem 数组,处理 rendition:layout、page-spread 等属性。
// 同时定义 XMLName、XMLSection、XMLParserContext 等 XML 解析辅助工具。
import Foundation
extension RDEPUBParser {
/// 从 OPF 解析结果构建 spine 阅读顺序数组
/// 逐项匹配 manifest,解析 href、布局模式(fixed/reflowable)、page-spread 等属性
/// - Parameters:
/// - packageDocument: OPF 解析结果(含 metadata、manifest、spineReferences)
/// - opfURL: OPF 文件路径
/// - Returns: spine 项数组
func buildSpine(from packageDocument: OPFPackageDocument, opfURL: URL) throws -> [RDEPUBSpineItem] {
let opfDirectoryURL = opfURL.deletingLastPathComponent()
let publicationLayout = packageDocument.metadata.layout
@@ -62,47 +52,52 @@ extension RDEPUBParser {
}
}
/// OPF 包文档的解析结果,包含 metadata、manifest、spine 引用及 NCX/nav 项
struct OPFPackageDocument {
var metadata: RDEPUBMetadata
var manifest: [RDEPUBManifestItem]
var manifestByID: [String: RDEPUBManifestItem]
var spineReferences: [OPFSpineReference]
/// NCX 目录文件的 manifest 项(EPUB 2)
var ncxItem: RDEPUBManifestItem?
/// Navigation Document 的 manifest 项(EPUB 3)
var navigationItem: RDEPUBManifestItem?
}
/// OPF 中 spine/itemref 元素的解析结果
struct OPFSpineReference {
/// 对应 manifest 中的 item id
var idref: String
/// 是否为线性阅读项("no" 表示辅助内容)
var linear: Bool
/// 属性列表(如 rendition:layout-pre-paginated、page-spread-left 等)
var properties: [String]
/// 页面展开方向(左/右/居中)
var pageSpread: RDEPUBPageSpread?
}
/// OPF 文档的 SAX 解析代理
/// 解析 metadata(identifier/title/creator/language/rendition:layout 等)、
/// manifest(item 元素)、spine(itemref 元素)三个主要区域
final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
private var metadata = RDEPUBMetadata()
private var manifestItems: [RDEPUBManifestItem] = []
private var spineReferences: [OPFSpineReference] = []
private let xmlContext = XMLParserContext()
private var uniqueIdentifierID: String?
private var currentIdentifierElementID: String?
private var currentMetaProperty: String?
private var identifierByID: [String: String] = [:]
private var manifestTitleByID: [String: String] = [:]
private var currentMetaRefinesID: String?
private var ncxID: String?
/// 将解析中间结果组装为 OPFPackageDocument 结构体
func packageDocument() -> OPFPackageDocument {
if metadata.identifier == nil, let fallbackIdentifier = identifierByID.values.first {
metadata.identifier = fallbackIdentifier
@@ -251,7 +246,6 @@ final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
xmlContext.endElement()
}
/// 当前解析所在的 OPF 区域(metadata/manifest/spine/other)
private var currentSection: XMLSection {
if xmlContext.containsElement(named: "manifest") {
return .manifest
@@ -265,7 +259,6 @@ final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
return .other
}
/// 处理 <meta> 元素的文本内容,提取 rendition:layout、rendition:spread、identifier 等
private func applyMetaValue(_ value: String) {
let normalizedValue = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedValue.isEmpty, let property = currentMetaProperty?.lowercased() else {
@@ -290,7 +283,6 @@ final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
}
}
/// 从属性字典中提取 page-spread 信息(支持 properties 和 page-spread 两种写法)
private func pageSpread(from attributes: [String: String]) -> RDEPUBPageSpread? {
if let properties = attributes["properties"]?.lowercased() {
if properties.contains("page-spread-left") {
@@ -320,13 +312,11 @@ final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
return nil
}
/// 将 rendition:layout 原始值转换为布局枚举
private func layout(from rawValue: String) -> RDEPUBLayout {
rawValue.lowercased().contains("pre-paginated") ? .fixed : .reflowable
}
}
/// OPF 文档的区域枚举,用于区分当前解析位置
enum XMLSection {
case metadata
case manifest
@@ -334,14 +324,12 @@ enum XMLSection {
case other
}
/// XML 名称解析工具:提取本地名、分词、提取 refines ID
enum XMLName {
/// 从带命名空间前缀的名称中提取本地名(如 "dc:title" -> "title")
static func localName(from rawName: String) -> String {
rawName.split(separator: ":").last.map(String.init) ?? rawName
}
/// 将空格分隔的属性值拆分为 token 数组
static func tokenize(_ rawValue: String?) -> [String] {
guard let rawValue else { return [] }
return rawValue
@@ -349,7 +337,6 @@ enum XMLName {
.map(String.init)
}
/// 从 refines 属性值中提取 ID(去掉 "#" 前缀)
static func refinedID(from rawValue: String) -> String? {
let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.hasPrefix("#") else {
@@ -359,14 +346,12 @@ enum XMLName {
}
}
/// XML 解析上下文,维护元素栈和当前文本内容
final class XMLParserContext {
/// 元素名称栈,用于追踪嵌套层级
private var elementStack: [String] = []
/// 当前元素累积的文本内容
private var currentCharacters = ""
/// 去除空白后的当前文本
var trimmedCharacters: String {
currentCharacters.trimmingCharacters(in: .whitespacesAndNewlines)
}
@@ -1,13 +1,8 @@
// RDEPUBParser+ReadingProfile.swift
// EPUB 阅读配置文件判断
// 根据 metadata 的 layout 属性和 manifest/spine 内容,
// 将出版物分类为 webFixedLayout(固定版式)、webInteractive(含脚本/多媒体)或 textReflowable(纯文本重排)。
import Foundation
extension RDEPUBParser {
/// 判断出版物的阅读配置文件
/// 优先判断固定版式,其次检测交互内容(脚本/iframe/视频等),默认为文本重排
public func readingProfile() -> RDEPUBReadingProfile {
if metadata.layout == .fixed {
return .webFixedLayout
@@ -15,9 +10,6 @@ extension RDEPUBParser {
return hasInteractiveContent() ? .webInteractive : .textReflowable
}
/// 检测出版物是否包含交互内容
/// 通过 manifest 中的脚本媒体类型和 "scripted" 属性,
/// 以及扫描 HTML 中的 script/iframe/video/audio/canvas/svg/form 等标签
public func hasInteractiveContent() -> Bool {
let interactiveMediaTypes = [
"application/javascript",
@@ -42,8 +34,6 @@ extension RDEPUBParser {
return false
}
/// 检测 HTML 是否包含需要走 Web 交互渲染的特征。
/// 注意:普通小说 EPUB 常用静态 SVG 包封面图,不能仅因 `<svg>` 就误判为 interactive。
private func containsInteractiveMarkup(_ html: String) -> Bool {
let interactivePattern = #"<(script|iframe|video|audio|canvas|object|embed)\b|\bon(load|click|touchstart|touchend|mouseover|submit|change|input)=|hype_generated_script|swiper|webview"#
if html.range(of: interactivePattern, options: [.regularExpression, .caseInsensitive]) != nil {
@@ -1,18 +1,13 @@
// RDEPUBParser+Resources.swift
// EPUB 资源查询与 URL 解析
// 提供按 ID、href 查询 manifest 项,spine 索引与 href 互转,
// 文件路径与 ss-reader:// 协议 URL 互转,封面图提取,HTML 内容读取等能力。
import Foundation
import UIKit
extension RDEPUBParser {
/// 按 ID 查询 manifest 项
public func manifestItem(for id: String) -> RDEPUBManifestItem? {
manifest[id]
}
/// 按 href 查询 manifest 项(忽略 fragment)
public func manifestItem(forHref href: String) -> RDEPUBManifestItem? {
let target = href.components(separatedBy: "#").first ?? href
return manifest.values.first { item in
@@ -20,13 +15,11 @@ extension RDEPUBParser {
}
}
/// 通过 spine 索引获取 href
public func href(forSpineIndex index: Int) -> String? {
guard spine.indices.contains(index) else { return nil }
return spine[index].href
}
/// 将相对路径转换为本地文件 URL,并校验是否在解压根目录内(防路径遍历攻击)
public func fileURL(forRelativePath relativePath: String) -> URL? {
guard let opfDirectoryURL else { return nil }
let path = relativePath.components(separatedBy: "#").first ?? relativePath
@@ -41,7 +34,6 @@ extension RDEPUBParser {
return resolvedURL
}
/// 将相对路径转换为 ss-reader://book/ 协议的 URL(用于 WebView 加载资源)
public func resourceURL(forRelativePath relativePath: String) -> URL? {
let normalizedPath = relativePath.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
guard !normalizedPath.isEmpty else { return nil }
@@ -55,7 +47,6 @@ extension RDEPUBParser {
return components.url
}
/// 将 ss-reader://book/ 协议的 URL 还原为本地文件 URL
public func fileURL(forResourceURL resourceURL: URL) -> URL? {
guard resourceURL.scheme == RDEPUBResourceURLSchemeHandler.scheme,
resourceURL.host == RDEPUBResourceURLSchemeHandler.host else {
@@ -65,7 +56,6 @@ extension RDEPUBParser {
return fileURL(forRelativePath: relativePath)
}
/// 提取封面图片(优先查找含 "cover-image" 属性或 ID 含 "cover" 的 manifest 项)
public func coverImage() -> UIImage? {
let coverCandidates = manifest.values.filter { item in
item.properties.contains("cover-image") || item.id.lowercased().contains("cover")
@@ -79,7 +69,6 @@ extension RDEPUBParser {
return nil
}
/// 通过 spine 索引读取 HTML 内容
public func htmlString(forSpineIndex index: Int) -> String? {
guard spine.indices.contains(index) else {
return nil
@@ -87,7 +76,6 @@ extension RDEPUBParser {
return htmlString(forRelativePath: spine[index].href)
}
/// 通过相对路径读取 HTML 内容
public func htmlString(forRelativePath relativePath: String) -> String? {
guard let fileURL = fileURL(forRelativePath: relativePath) else {
return nil
@@ -95,7 +83,6 @@ extension RDEPUBParser {
return try? String(contentsOf: fileURL)
}
/// 将 href 相对于目录 URL 解析为标准化的相对路径
func normalize(href: String, relativeTo directoryURL: URL) -> String {
guard let resolvedURL = URL(string: href, relativeTo: directoryURL)?.standardizedFileURL else {
return href
@@ -1,14 +1,8 @@
// RDEPUBParser+TOC.swift
// EPUB 目录解析
// 支持两种目录格式:NCX(EPUB 2)和 Navigation Document(EPUB 3),
// 优先使用 NCX,其次 Nav Document,最终回退到 spine 列表。
// 内部使用 TOCTreeBuilder 构建树状目录结构。
import Foundation
extension RDEPUBParser {
/// 从 OPF 解析结果中解析目录
/// 优先尝试 NCX,再尝试 Nav Document,两者都失败则回退到 spine 列表
func parseTOC(from packageDocument: OPFPackageDocument, opfURL: URL) -> [EPUBTableOfContentsItem] {
let opfDirectoryURL = opfURL.deletingLastPathComponent()
@@ -31,7 +25,6 @@ extension RDEPUBParser {
return spine.map { EPUBTableOfContentsItem(title: $0.title, href: $0.href) }
}
/// 解析 NCX(EPUB 2)目录文件
func parseNCXDocumentItems(at ncxURL: URL) -> [EPUBTableOfContentsItem] {
guard FileManager.default.fileExists(atPath: ncxURL.path),
let parser = XMLParser(contentsOf: ncxURL) else {
@@ -49,7 +42,6 @@ extension RDEPUBParser {
return delegate.items
}
/// 解析 Navigation Document(EPUB 3)目录文件
func parseNavDocumentItems(at navURL: URL, baseURL: URL) -> [EPUBTableOfContentsItem] {
guard FileManager.default.fileExists(atPath: navURL.path),
let parser = XMLParser(contentsOf: navURL) else {
@@ -67,7 +59,6 @@ extension RDEPUBParser {
return delegate.items
}
/// 标准化目录 href(拆分路径和 fragment,对路径部分做相对路径解析)
func normalizeTOCHref(_ href: String, relativeTo baseURL: URL) -> String? {
let components = href.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false)
let pathPart = components.first.map(String.init) ?? href
@@ -91,13 +82,11 @@ extension RDEPUBParser {
}
}
/// 目录树节点,存储标题、href 和子节点,最终转换为 EPUBTableOfContentsItem
private final class TOCNode {
var title = ""
var href: String?
var children: [TOCNode] = []
/// 将节点及其子节点递归转换为目录项
func asItem() -> EPUBTableOfContentsItem? {
let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
guard let href, !href.isEmpty else {
@@ -111,14 +100,12 @@ private final class TOCNode {
}
}
/// 目录树构建器:通过 beginNode/endNode 操作维护节点栈,构建嵌套目录结构
private final class TOCTreeBuilder {
/// 当前嵌套路径上的节点栈
private var nodeStack: [TOCNode] = []
/// 顶层根节点列表
private var rootNodes: [TOCNode] = []
/// 获取构建完成的目录项列表
var items: [EPUBTableOfContentsItem] {
rootNodes.compactMap { $0.asItem() }
}
@@ -151,7 +138,6 @@ private final class TOCTreeBuilder {
}
}
/// NCX 文档的 SAX 解析代理,解析 <navPoint>、<content>、<text> 元素
private final class NCXParserDelegate: NSObject, XMLParserDelegate {
private let baseURL: URL
private let hrefNormalizer: (String, URL) -> String?
@@ -216,7 +202,6 @@ private final class NCXParserDelegate: NSObject, XMLParserDelegate {
}
}
/// Navigation Document 的 SAX 解析代理,解析含 epub:type="toc" 的 <nav> 元素
private final class NavDocumentParserDelegate: NSObject, XMLParserDelegate {
private let baseURL: URL
private let hrefNormalizer: (String, URL) -> String?
@@ -303,7 +288,6 @@ private final class NavDocumentParserDelegate: NSObject, XMLParserDelegate {
}
}
/// 判断 <nav> 元素是否为 TOC 导航(检查 epub:type、type、role 属性)
private func isTOCNav(attributes: [String: String]) -> Bool {
if let epubType = attributes["epub:type"]?.lowercased(), epubType.contains("toc") {
return true
@@ -1,41 +1,30 @@
// RDEPUBParser.swift
// EPUB 解析器入口
// 解析链路:epubURL → ZIP 解压 → container.xml → OPF → spine/TOC
// 是 EPUBCore Publication 层的核心,解析完成后通过 makePublication() 生成 RDEPUBPublication。
// 具体解析逻辑分散在 +Archive、+Package、+TOC、+Resources、+ReadingProfile 扩展中。
import Foundation
/// EPUB 解析器,执行从 epub 文件到出版物数据的完整解析流程
/// 主链路:epubURL → extractArchiveIfNeeded → container.xml → OPF → metadata/manifest/spine/TOC
public final class RDEPUBParser {
/// 出版物元数据(标题、作者、语言、布局模式等)
public internal(set) var metadata = RDEPUBMetadata()
/// 资源清单字典(key 为 manifest item ID)
public internal(set) var manifest: [String: RDEPUBManifestItem] = [:]
/// 阅读顺序列表(按 spine 排列的资源项)
public internal(set) var spine: [RDEPUBSpineItem] = []
/// 目录树(从 NCX 或 Nav Document 解析得到)
public internal(set) var tableOfContents: [EPUBTableOfContentsItem] = []
/// EPUB 解压后的根目录路径
public internal(set) var extractionRootURL: URL?
/// OPF 文件的完整路径
public internal(set) var opfURL: URL?
/// OPF 文件所在目录的 URL(用于解析相对路径)
public var opfDirectoryURL: URL? {
opfURL?.deletingLastPathComponent()
}
public init() {}
/// 基于当前解析结果创建 RDEPUBPublication(门面对象)
public func makePublication() -> RDEPUBPublication {
RDEPUBPublication(parser: self)
}
/// 完整解析 EPUB 文件:解压 → container.xml → OPF
/// - Parameter epubURL: EPUB 文件的本地路径
public func parse(epubURL: URL) throws {
reset()
@@ -56,8 +45,6 @@ public final class RDEPUBParser {
try parseOPF(at: packageURL)
}
/// 解析 OPF 文件:metadata/manifest/spine/TOC
/// - Parameter opfURL: OPF 文件路径
public func parseOPF(at opfURL: URL) throws {
resetPublicationState()
@@ -81,12 +68,10 @@ public final class RDEPUBParser {
self.tableOfContents = parseTOC(from: packageDocument, opfURL: opfURL)
}
/// 获取已解析的目录
public func parseTOC() -> [EPUBTableOfContentsItem] {
tableOfContents
}
/// 解析指定的 Navigation Document 文件
public func parseNavDocument(_ navURL: URL, baseURL: URL? = nil) -> [EPUBTableOfContentsItem] {
parseNavDocumentItems(at: navURL, baseURL: baseURL ?? navURL.deletingLastPathComponent())
}
@@ -1,33 +1,28 @@
// RDEPUBPreferences.swift
// EPUB 阅读偏好设置
// 封装字号、行高、内容内边距、主题颜色、固定版式适配模式等展示参数,
// 并提供从偏好设置生成渲染请求和展示样式的便捷方法。
import UIKit
/// EPUB 阅读偏好设置,包含所有影响渲染效果的展示参数
public struct RDEPUBPreferences: Equatable {
/// 字体大小(pt)
public var fontSize: CGFloat
/// 行高倍数(如 1.5 表示 1.5 倍行高)
public var lineHeightMultiple: CGFloat
/// 重排模式下的内容内边距
public var reflowableContentInsets: UIEdgeInsets
/// 固定版式下的内容内边距
public var fixedContentInset: UIEdgeInsets
/// 主题背景色(CSS 颜色值,如 "#FFFFFF")
public var themeBackgroundColor: String?
/// 主题文字色(CSS 颜色值)
public var themeTextColor: String?
/// 固定版式背景色
public var fixedBackgroundColor: String?
/// 固定版式适配模式(page/width/height/contain/cover)
public var fixedLayoutFit: RDEPUBFixedLayoutFit
/// 固定版式双页展开模式(never/always/automatic)
public var fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode
/// 多栏排版栏数
public var numberOfColumns: Int
/// 多栏排版栏间距
public var columnGap: CGFloat
public init(
@@ -56,7 +51,6 @@ public struct RDEPUBPreferences: Equatable {
self.fixedLayoutSpreadMode = fixedLayoutSpreadMode
}
/// 从偏好设置生成重排模式的展示样式
public func presentationStyle(viewportSize: CGSize) -> RDEPUBPresentationStyle {
RDEPUBPresentationStyle(
viewportSize: viewportSize,
@@ -70,7 +64,6 @@ public struct RDEPUBPreferences: Equatable {
)
}
/// 根据页面和偏好设置生成对应的渲染请求(自动区分固定版式和重排)
public func renderRequest(
for page: EPUBPage,
publication: RDEPUBPublication,
@@ -1,67 +1,49 @@
// RDEPUBPublication.swift
// EPUB 出版物门面(Facade)
// 对 RDEPUBParser 的高层封装,提供 metadata、spine、TOC 等只读访问,
// 以及固定版式双页展开判断和 spread 生成等业务逻辑。
// 是 EPUBCore Publication 层对外暴露的主要接口。
import Foundation
/// EPUB 出版物门面对象,封装解析结果并提供高层访问接口
public final class RDEPUBPublication {
/// 底层解析器(持有解析数据)
public let parser: RDEPUBParser
/// 资源解析器(URL 转换和路径规范化)
public let resourceResolver: RDEPUBResourceResolver
/// 基于解析器创建出版物门面对象
/// - Parameter parser: 已完成解析的 EPUB 解析器
public init(parser: RDEPUBParser) {
self.parser = parser
self.resourceResolver = RDEPUBResourceResolver(parser: parser)
}
/// 元数据代理
public var metadata: RDEPUBMetadata {
parser.metadata
}
/// 资源清单代理
public var manifest: [String: RDEPUBManifestItem] {
parser.manifest
}
/// 阅读顺序代理
public var spine: [RDEPUBSpineItem] {
parser.spine
}
/// 目录代理
public var tableOfContents: [EPUBTableOfContentsItem] {
parser.tableOfContents
}
/// 布局模式(fixed/reflowable)
public var layout: RDEPUBLayout {
metadata.layout
}
/// 阅读配置文件(webFixedLayout/webInteractive/textReflowable)
public var readingProfile: RDEPUBReadingProfile {
parser.readingProfile()
}
/// 阅读方向(ltr/rtl/auto)
public var readingProgression: RDEPUBReadingProgression {
metadata.readingProgression
}
/// 书籍唯一标识符
public var bookIdentifier: String? {
metadata.identifier
}
/// 判断固定版式是否启用双页展开模式
/// 综合考虑 metadata.spread、偏好设置和视口方向
public func fixedLayoutSpreadEnabled(for preferences: RDEPUBPreferences, viewportSize: CGSize) -> Bool {
guard layout == .fixed else {
return false
@@ -80,7 +62,6 @@ public final class RDEPUBPublication {
}
}
/// 根据偏好和视口生成固定版式的 spread 数组
public func makeFixedSpreads(preferences: RDEPUBPreferences, viewportSize: CGSize) -> [EPUBFixedSpread] {
EPUBFixedSpread.makeSpreads(
spine: spine,
@@ -1,58 +1,46 @@
// RDEPUBReadingSession.swift
// EPUB 阅读会话(状态机)
// 管理阅读器的运行时状态:导航状态(initializing/loading/idle/jumping/moving/repaginating)、
// 活跃页面列表、待应用的分页快照、挂起的导航请求、当前视口和阅读上下文。
// 提供分页快照生成、位置计算、导航队列、阅读上下文更新等核心业务方法。
import Foundation
/// EPUB 阅读会话,管理导航状态、页面列表和阅读进度
/// 状态机:initializing → loading → idle ↔ jumping/moving/repaginating
public final class RDEPUBReadingSession {
/// 分页快照类型别名(页面数组 + 章节信息数组)
public typealias PaginationSnapshot = (pages: [EPUBPage], chapters: [EPUBChapterInfo])
/// 关联的出版物
public let publication: RDEPUBPublication
/// 当前导航状态
public private(set) var navigatorState: RDEPUBNavigatorState = .initializing
/// 当前生效的页面列表
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 stagedRestoreLocation: RDEPUBLocation?
/// 挂起的导航目标位置(用于 fragment 精确定位)
public private(set) var pendingNavigationLocation: RDEPUBLocation?
/// 挂起的导航目标页码
public private(set) var pendingNavigationPageNum: Int?
/// 挂起的高亮 Range 信息(仅 Web 渲染路径使用)
public private(set) var pendingNavigationHighlightRangeInfo: String?
/// 当前视口信息
public private(set) var currentViewport: RDEPUBViewport?
/// 当前阅读上下文(位置 + 视口 + 页码 + 章节)
public private(set) var currentReadingContext: RDEPUBReadingContext?
public init(publication: RDEPUBPublication) {
self.publication = publication
}
/// 资源解析器快捷访问
public var resourceResolver: RDEPUBResourceResolver {
publication.resourceResolver
}
/// 状态机转换
public func transition(to state: RDEPUBNavigatorState) {
navigatorState = state
}
/// 重置所有运行时状态(回到 initializing)
public func resetRuntimeState() {
navigatorState = .initializing
activePages = []
@@ -63,20 +51,17 @@ public final class RDEPUBReadingSession {
currentReadingContext = nil
}
/// 设置当前生效的分页快照
public func setActiveSnapshot(_ snapshot: PaginationSnapshot) {
activePages = snapshot.pages
activeChapters = snapshot.chapters
}
/// 暂存分页快照(待状态允许时才生效)
public func stageSnapshot(_ snapshot: PaginationSnapshot, restoreLocation: RDEPUBLocation?) {
stagedPages = snapshot.pages
stagedChapters = snapshot.chapters
stagedRestoreLocation = restoreLocation
}
/// 获取暂存的分页快照
public func stagedSnapshot() -> PaginationSnapshot? {
guard let stagedPages, let stagedChapters else {
return nil
@@ -84,7 +69,6 @@ public final class RDEPUBReadingSession {
return (stagedPages, stagedChapters)
}
/// 如果状态稳定且有暂存快照,则消费并返回(原子操作)
public func consumeStagedSnapshotIfAllowed() -> (snapshot: PaginationSnapshot, restoreLocation: RDEPUBLocation?)? {
guard navigatorState.isStableForSnapshotApplication,
let stagedPages,
@@ -96,21 +80,18 @@ public final class RDEPUBReadingSession {
return ((stagedPages, stagedChapters), restoreLocation)
}
/// 清除暂存快照
public func clearStagedSnapshot() {
stagedPages = nil
stagedChapters = nil
stagedRestoreLocation = nil
}
/// 清除挂起的导航请求
public func clearPendingNavigation() {
pendingNavigationLocation = nil
pendingNavigationPageNum = nil
pendingNavigationHighlightRangeInfo = nil
}
/// 检查指定页码和 spine 索引是否有挂起的导航位置
public func pendingLocation(forPageNumber pageNumber: Int, spineIndex: Int?) -> RDEPUBLocation? {
guard pendingNavigationPageNum == pageNumber,
let pendingNavigationLocation else {
@@ -126,7 +107,6 @@ public final class RDEPUBReadingSession {
return pendingNavigationLocation
}
/// 查询指定页是否存在待消费的高亮 Range 信息。
public func pendingHighlightRangeInfo(forPageNumber pageNumber: Int, spineIndex: Int?) -> String? {
guard pendingNavigationPageNum == pageNumber,
let pendingNavigationHighlightRangeInfo,
@@ -146,7 +126,6 @@ public final class RDEPUBReadingSession {
return pendingNavigationHighlightRangeInfo
}
/// 判断指定页面是否包含给定的 spine 索引(固定版式需检查 spread 内所有资源)
public func pageContains(spineIndex: Int, in page: EPUBPage) -> Bool {
if let fixedSpread = page.fixedSpread {
return fixedSpread.resources.contains(where: { $0.spineIndex == spineIndex })
@@ -154,7 +133,6 @@ public final class RDEPUBReadingSession {
return page.spineIndex == spineIndex
}
/// 根据页面信息生成回退位置(用于无精确位置时的兜底)
public func fallbackLocation(for page: EPUBPage, bookIdentifier: String?) -> RDEPUBLocation? {
if let fixedSpread = page.fixedSpread {
return RDEPUBLocation(
@@ -184,7 +162,6 @@ public final class RDEPUBReadingSession {
)
}
/// 获取当前可见页面对应的位置
public func currentVisibleLocation(currentPageNumber: Int, bookIdentifier: String?) -> RDEPUBLocation? {
guard currentPageNumber > 0,
activePages.indices.contains(currentPageNumber - 1) else {
@@ -193,7 +170,6 @@ public final class RDEPUBReadingSession {
return fallbackLocation(for: activePages[currentPageNumber - 1], bookIdentifier: bookIdentifier)
}
/// 根据位置获取初始 spine 索引
public func initialSpineIndex(for location: RDEPUBLocation?) -> Int {
guard let location,
let normalizedHref = resourceResolver.normalizedHref(location.href),
@@ -203,7 +179,6 @@ public final class RDEPUBReadingSession {
return spineIndex
}
/// 根据位置计算对应的页面索引(支持固定版式和重排模式)
public func pageIndex(for location: RDEPUBLocation, bookIdentifier: String?) -> Int? {
guard location.bookIdentifier == nil || location.bookIdentifier == bookIdentifier else {
return nil
@@ -244,7 +219,6 @@ public final class RDEPUBReadingSession {
return candidates[localIndex].offset
}
/// 排队导航到指定位置:规范化位置、查找页面索引、设置挂起导航、切换到 jumping 状态
public func queueNavigation(
to location: RDEPUBLocation,
relativeToSpineIndex spineIndex: Int? = nil,
@@ -271,7 +245,6 @@ public final class RDEPUBReadingSession {
return pageIndex + 1
}
/// 更新阅读上下文:规范化位置、设置视口和阅读上下文、清除挂起导航、回到 idle 状态
public func updateReadingContext(
pageNumber: Int,
location: RDEPUBLocation,
@@ -320,13 +293,10 @@ public final class RDEPUBReadingSession {
}
}
/// 获取当前阅读位置(优先从阅读上下文获取,回退到可见位置)
public func currentReadingLocation(bookIdentifier: String?) -> RDEPUBLocation? {
currentReadingContext?.location ?? currentVisibleLocation(currentPageNumber: currentViewport?.visiblePageNumber ?? 0, bookIdentifier: bookIdentifier)
}
/// 根据分页计数和偏好设置生成分页快照
/// 固定版式按 spread 分页,重排模式按 spine 索引逐章分页
public func makePaginationSnapshot(
pageCounts: [Int],
preferences: RDEPUBPreferences,
@@ -1,53 +1,34 @@
//
// RDEPUBRenderRequest.swift
// RDReaderView
//
// EPUBCore 层渲染请求模型定义文件。
// 包含渲染所需的展示样式(PresentationStyle)、可重排/固定版式渲染请求(RenderRequest)、
// 固定版式适配模式(Fit)和 Spread 模式(SpreadMode)。
// 由 RDEPUBPreferences 构建,传递给 RDEPUBWebView 执行渲染。
//
import UIKit
/// 固定版式页面适配模式
/// - auto: 自动选择最佳适配策略
/// - page: 适配整页(缩放至屏幕大小)
/// - width: 适配宽度(按宽度等比缩放)
public enum RDEPUBFixedLayoutFit: String, Codable {
case auto
case page
case width
}
/// 固定版式 Spread(双页)显示模式
/// - automatic: 根据设备方向和屏幕尺寸自动判断
/// - always: 始终启用双页显示
/// - never: 始终单页显示
public enum RDEPUBFixedLayoutSpreadMode: String, Codable {
case automatic
case always
case never
}
/// 渲染展示样式,WebView 和 Paginator 共用的视觉参数
/// 由 RDEPUBPreferences 根据用户设置构建
public struct RDEPUBPresentationStyle: Equatable {
/// 视口尺寸(容器的可见区域大小)
public var viewportSize: CGSize
/// 内容内边距(上下左右的安全区域偏移)
public var contentInsets: UIEdgeInsets
/// 字号(pt)
public var fontSize: CGFloat
/// 行距倍数(1.0 为默认行距)
public var lineHeightMultiple: CGFloat
/// 栏数,对标 WXRead 的多栏分页配置
public var numberOfColumns: Int
/// 栏间距,对标 WXRead 的 columnGap
public var columnGap: CGFloat
/// 主题背景色(CSS 颜色值,如 "#FFFFFF")
public var themeBackgroundColor: String?
/// 主题文字色(CSS 颜色值,如 "#000000")
public var themeTextColor: String?
public init(
@@ -71,25 +52,24 @@ public struct RDEPUBPresentationStyle: Equatable {
}
}
/// 可重排内容渲染请求,包含加载和渲染一个 reflowable spine 项所需的全部参数
public struct RDEPUBReflowableRenderRequest: Equatable {
/// spine 索引
public var spineIndex: Int
/// 资源路径
public var href: String
/// 目标页在章节中的索引
public var pageIndex: Int
/// 该章节的总页数
public var totalPagesInChapter: Int
/// 展示样式
public var presentation: RDEPUBPresentationStyle
/// 目标跳转位置(用于恢复阅读位置或锚点跳转)
public var targetLocation: RDEPUBLocation?
/// 目标高亮的 DOM Range 信息,用于 Web 渲染路径下的精确跳转
public var targetHighlightRangeInfo: String?
/// 高亮列表
public var highlights: [RDEPUBHighlight]
/// 搜索结果展示信息
public var searchPresentation: RDEPUBSearchPresentation?
public init(
@@ -115,19 +95,18 @@ public struct RDEPUBReflowableRenderRequest: Equatable {
}
}
/// 固定版式渲染请求,包含加载和渲染一个 fixed spread 所需的全部参数
public struct RDEPUBFixedRenderRequest: Equatable {
/// 要渲染的 spread(包含 1-2 个资源)
public var spread: EPUBFixedSpread
/// 视口尺寸
public var viewportSize: CGSize
/// 内容内边距
public var contentInset: UIEdgeInsets
/// 背景色 CSS 值
public var backgroundColorCSS: String?
/// 页面适配模式(auto/page/width)
public var fit: RDEPUBFixedLayoutFit
/// 搜索结果展示信息
public var searchPresentation: RDEPUBSearchPresentation?
public init(
@@ -147,15 +126,12 @@ public struct RDEPUBFixedRenderRequest: Equatable {
}
}
/// 渲染请求枚举,统一可重排和固定版式两种渲染模式
/// RDEPUBWebView 根据此请求类型选择对应的加载和渲染路径
public enum RDEPUBRenderRequest: Equatable {
/// 可重排内容渲染请求
case reflowable(RDEPUBReflowableRenderRequest)
/// 固定版式渲染请求
case fixed(RDEPUBFixedRenderRequest)
/// 是否为固定版式渲染
public var isFixedLayout: Bool {
if case .fixed = self {
return true
@@ -163,7 +139,6 @@ public enum RDEPUBRenderRequest: Equatable {
return false
}
/// 主资源的 spine 索引
public var primarySpineIndex: Int {
switch self {
case .reflowable(let request):
@@ -173,7 +148,6 @@ public enum RDEPUBRenderRequest: Equatable {
}
}
/// 主资源的路径
public var primaryHref: String {
switch self {
case .reflowable(let request):
@@ -1,41 +1,30 @@
// RDEPUBResourceResolver.swift
// EPUB 资源解析器
// 封装 RDEPUBParser 的资源查询能力,提供 href 规范化、
// spine 索引查找、位置规范化等高层接口。是 Services 层的核心组件,
// 被 RDEPUBPublication 和 RDEPUBReadingSession 广泛使用。
import Foundation
/// EPUB 资源解析器,负责 URL 转换、href 规范化和位置解析
public final class RDEPUBResourceResolver {
/// 底层解析器引用
private let parser: RDEPUBParser
public init(parser: RDEPUBParser) {
self.parser = parser
}
/// OPF 文件所在目录的 URL
public var opfDirectoryURL: URL? {
parser.opfDirectoryURL
}
/// 将相对路径转换为本地文件 URL
public func fileURL(forRelativePath relativePath: String) -> URL? {
parser.fileURL(forRelativePath: relativePath)
}
/// 将相对路径转换为 ss-reader://book/ 协议 URL
public func resourceURL(forRelativePath relativePath: String) -> URL? {
parser.resourceURL(forRelativePath: relativePath)
}
/// 将 ss-reader://book/ 协议 URL 还原为本地文件 URL
public func fileURL(forResourceURL resourceURL: URL) -> URL? {
parser.fileURL(forResourceURL: resourceURL)
}
/// 规范化 href:基于 OPF 目录和可选的 spine 索引,将相对路径解析为标准化的 OPF 内路径
public func normalizedHref(_ href: String, relativeToSpineIndex spineIndex: Int? = nil) -> String? {
guard let opfDirectoryURL else {
return href.components(separatedBy: "#").first
@@ -70,7 +59,6 @@ public final class RDEPUBResourceResolver {
return pathPart
}
/// 规范化 href:基于另一个 href 作为基准路径
public func normalizedHref(_ href: String, relativeToHref baseHref: String) -> String? {
guard let opfDirectoryURL else {
return href.components(separatedBy: "#").first
@@ -93,7 +81,6 @@ public final class RDEPUBResourceResolver {
return pathPart
}
/// 将相对于另一个 href 的引用转换为本地文件 URL
public func fileURL(forReference href: String, relativeToHref baseHref: String) -> URL? {
guard let normalizedHref = normalizedHref(href, relativeToHref: baseHref) else {
return nil
@@ -101,7 +88,6 @@ public final class RDEPUBResourceResolver {
return fileURL(forRelativePath: normalizedHref)
}
/// 规范化位置对象:解析 href、保留 progression 和 fragment
public func normalizedLocation(
_ location: RDEPUBLocation,
relativeToSpineIndex spineIndex: Int? = nil,
@@ -131,18 +117,19 @@ public final class RDEPUBResourceResolver {
progression: location.progression,
lastProgression: location.lastProgression,
fragment: fragment,
rangeAnchor: location.rangeAnchor
rangeAnchor: location.rangeAnchor,
cfi: location.cfi,
lastCFI: location.lastCFI,
rangeCFI: location.rangeCFI
)
}
/// 通过规范化 href 查找对应的 spine 索引
public func spineIndex(forNormalizedHref normalizedHref: String) -> Int? {
parser.spine.firstIndex { item in
self.normalizedHref(item.href) == normalizedHref
}
}
/// 通过位置查找对应的 spine 索引
public func spineIndex(for location: RDEPUBLocation) -> Int? {
guard let normalizedHref = normalizedHref(location.href) else {
return nil
@@ -150,7 +137,6 @@ public final class RDEPUBResourceResolver {
return spineIndex(forNormalizedHref: normalizedHref)
}
/// 通过 spine 索引获取 href
public func href(forSpineIndex spineIndex: Int) -> String? {
guard parser.spine.indices.contains(spineIndex) else {
return nil
@@ -158,7 +144,6 @@ public final class RDEPUBResourceResolver {
return parser.spine[spineIndex].href
}
/// 通过 spine 索引获取标题
public func title(forSpineIndex spineIndex: Int) -> String? {
guard parser.spine.indices.contains(spineIndex) else {
return nil
@@ -166,7 +151,6 @@ public final class RDEPUBResourceResolver {
return parser.spine[spineIndex].title
}
/// 通过 href 查询 manifest 项
public func manifestItem(forHref href: String) -> RDEPUBManifestItem? {
let targetHref = normalizedHref(href) ?? href.components(separatedBy: "#").first ?? href
return parser.manifest.values.first {
@@ -1,12 +1,7 @@
// RDEPUBResourceURLSchemeHandler.swift
// ss-reader:// 自定义 URL 协议处理器
// 实现 WKURLSchemeHandler 协议,拦截 ss-reader://book/<path> 请求,
// 将其映射到本地解压的 EPUB 资源文件。支持 MIME 类型推断和可选资源的空响应处理。
import Foundation
import WebKit
/// 自定义 URL 协议处理器,将 ss-reader://book/ 请求映射到本地 EPUB 资源文件
public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler {
struct DebugMetrics {
let streamedResponses: Int
@@ -14,18 +9,16 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
let failures: Int
}
/// 自定义协议名
public static let scheme = "ss-reader"
/// 自定义主机名
public static let host = "book"
/// 底层解析器引用(弱引用,避免循环持有)
private weak var parser: RDEPUBParser?
/// 文件管理器实例
private let fileManager = FileManager.default
/// 串行队列,用于同步访问活跃任务字典
private let syncQueue = DispatchQueue(label: "com.ssreaderview.epub.scheme-handler")
/// 活跃的 URL Scheme 任务集合(用于任务取消检测)
private var activeTasks: [ObjectIdentifier: Bool] = [:]
private static let debugMetricsQueue = DispatchQueue(label: "com.ssreaderview.epub.scheme-handler.metrics")
private static var streamedResponseCount = 0
@@ -55,8 +48,6 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
}
}
/// 处理 URL Scheme 请求:解析文件路径、读取数据、返回响应
/// 对缺失的可选资源(字体/图片/CSS/JS)返回空响应,其他返回 404 错误
public func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) {
let taskID = ObjectIdentifier(urlSchemeTask as AnyObject)
syncQueue.sync {
@@ -100,20 +91,17 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
}
}
/// 取消 URL Scheme 任务
public func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask) {
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: urlSchemeTask.request.url, event: "stop")
clearTask(ObjectIdentifier(urlSchemeTask as AnyObject))
}
/// 从活跃任务集合中移除已完成的任务
private func clearTask(_ id: ObjectIdentifier) {
syncQueue.sync {
activeTasks[id] = nil
}
}
/// 检查任务是否仍然活跃(防止取消后继续发送数据)
private func isTaskActive(_ id: ObjectIdentifier) -> Bool {
syncQueue.sync {
activeTasks[id] == true
@@ -203,7 +191,6 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
}
}
/// 根据文件扩展名推断 MIME 类型
private static func mimeType(for pathExtension: String) -> String {
switch pathExtension.lowercased() {
case "html", "htm":
@@ -243,7 +230,6 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
}
}
/// 根据文件扩展名推断文本编码(文本类文件返回 UTF-8)
private static func textEncodingName(for pathExtension: String) -> String? {
switch pathExtension.lowercased() {
case "html", "htm", "xhtml", "css", "js", "xml", "opf", "ncx", "txt":
@@ -253,7 +239,6 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
}
}
/// 判断是否为可选资源(字体/图片/CSS/JS 等缺失时不报错,返回空响应)
private static func isOptionalResource(_ url: URL) -> Bool {
switch url.pathExtension.lowercased() {
case "ttf", "otf", "woff", "woff2", "css", "js", "jpg", "jpeg", "png", "gif", "webp", "svg":
@@ -1,37 +1,23 @@
// RDEPUBSearchEngine.swift
// EPUB 全文搜索引擎
// 遍历 spine 中的线性 HTML/XHTML 资源,提取纯文本后执行大小写不敏感的关键词搜索,
// 返回匹配项列表(含 href、progression、previewText 等)。
import Foundation
import UIKit
/// 搜索引擎协议,定义全文搜索接口
protocol RDEPUBSearchEngine {
/// 执行全文搜索
/// - Parameter keyword: 搜索关键词
/// - Returns: 匹配结果列表
func search(keyword: String) -> [RDEPUBSearchMatch]
}
/// HTML 全文搜索引擎实现
/// 通过解析 HTML 为纯文本,逐章执行关键词匹配
final class RDEPUBHTMLSearchEngine: RDEPUBSearchEngine {
/// EPUB 解析器(用于读取 HTML 内容)
private let parser: RDEPUBParser
/// EPUB 出版物(用于遍历 spine 和规范化 href)
private let publication: RDEPUBPublication
/// 创建 HTML 全文搜索引擎
/// - Parameters:
/// - parser: EPUB 解析器(用于读取 HTML 内容)
/// - publication: EPUB 出版物(用于遍历 spine 和规范化 href)
init(parser: RDEPUBParser, publication: RDEPUBPublication) {
self.parser = parser
self.publication = publication
}
/// 执行全文搜索:遍历所有线性 spine 项,提取纯文本后匹配关键词
func search(keyword: String) -> [RDEPUBSearchMatch] {
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedKeyword.isEmpty else {
@@ -53,7 +39,6 @@ final class RDEPUBHTMLSearchEngine: RDEPUBSearchEngine {
return searchMatches
}
/// 将 HTML 转换为纯文本(优先使用 NSAttributedString,失败时用正则兜底)
private func plainText(fromHTML html: String, baseURL: URL?) -> String {
guard let data = html.data(using: .utf8) else {
return fallbackPlainText(fromHTML: html)
@@ -73,7 +58,6 @@ final class RDEPUBHTMLSearchEngine: RDEPUBSearchEngine {
return fallbackPlainText(fromHTML: html)
}
/// 兜底方案:用正则去除 HTML 标签并解码 HTML 实体
private func fallbackPlainText(fromHTML html: String) -> String {
let stripped = html.replacingOccurrences(of: "<[^>]+>", with: " ", options: .regularExpression)
return stripped
@@ -85,7 +69,6 @@ final class RDEPUBHTMLSearchEngine: RDEPUBSearchEngine {
.replacingOccurrences(of: "&quot;", with: "\"")
}
/// 在纯文本中查找所有关键词匹配项(大小写不敏感)
private func matches(in text: String, href: String, keyword: String) -> [RDEPUBSearchMatch] {
let source = text as NSString
let fullLength = source.length
@@ -127,7 +110,6 @@ final class RDEPUBHTMLSearchEngine: RDEPUBSearchEngine {
return results
}
/// 截取匹配位置前后的文本作为预览(前后各 12 个字符)
private func previewText(in text: NSString, matchRange: NSRange) -> String {
let previewRadius = 12
let start = max(matchRange.location - previewRadius, 0)
@@ -1,27 +1,26 @@
// RDEPUBSearchModels.swift
// EPUB 搜索相关数据模型
// 定义搜索匹配项(RDEPUBSearchMatch)、搜索结果(RDEPUBSearchResult)、
// 搜索状态(RDEPUBSearchState)和搜索展示(RDEPUBSearchPresentation)等数据结构。
import Foundation
/// 搜索匹配项,包含匹配位置、进度、预览文本和范围信息
public struct RDEPUBSearchMatch: Codable, Equatable {
/// 匹配所在资源的规范化 href
public var href: String
/// 匹配位置在文本中的进度比例(0.0 ~ 1.0)
public var progression: Double
/// 匹配位置前后的预览文本
public var previewText: String
/// 在当前资源内的本地匹配索引
public var localMatchIndex: Int
/// 匹配范围的起始位置(字符偏移量)
public var rangeLocation: Int?
/// 匹配范围的长度
public var rangeLength: Int
/// 文本范围锚点(用于精确定位)
public var rangeAnchor: RDEPUBTextRangeAnchor?
public var cfi: String?
public var rangeCFI: String?
public init(
href: String,
progression: Double,
@@ -29,7 +28,9 @@ public struct RDEPUBSearchMatch: Codable, Equatable {
localMatchIndex: Int,
rangeLocation: Int? = nil,
rangeLength: Int,
rangeAnchor: RDEPUBTextRangeAnchor? = nil
rangeAnchor: RDEPUBTextRangeAnchor? = nil,
cfi: String? = nil,
rangeCFI: String? = nil
) {
self.href = href
self.progression = progression
@@ -38,18 +39,19 @@ public struct RDEPUBSearchMatch: Codable, Equatable {
self.rangeLocation = rangeLocation
self.rangeLength = rangeLength
self.rangeAnchor = rangeAnchor
self.cfi = cfi
self.rangeCFI = rangeCFI
}
}
/// 搜索结果摘要,包含关键词、总匹配数和当前匹配项
public struct RDEPUBSearchResult: Codable, Equatable {
/// 搜索关键词
public var keyword: String
/// 总匹配数量
public var totalMatchCount: Int
/// 当前匹配项的索引(从 1 开始)
public var currentMatchIndex: Int?
/// 当前高亮的匹配项
public var currentMatch: RDEPUBSearchMatch?
public init(
@@ -65,13 +67,12 @@ public struct RDEPUBSearchResult: Codable, Equatable {
}
}
/// 搜索状态,管理关键词、匹配列表和当前索引
public struct RDEPUBSearchState: Codable, Equatable {
/// 搜索关键词
public var keyword: String
/// 所有匹配项列表
public var matches: [RDEPUBSearchMatch]
/// 当前匹配项索引(从 0 开始)
public var currentMatchIndex: Int?
public init(keyword: String, matches: [RDEPUBSearchMatch], currentMatchIndex: Int? = nil) {
@@ -80,7 +81,6 @@ public struct RDEPUBSearchState: Codable, Equatable {
self.currentMatchIndex = currentMatchIndex
}
/// 获取当前匹配项
public var currentMatch: RDEPUBSearchMatch? {
guard let currentMatchIndex,
matches.indices.contains(currentMatchIndex) else {
@@ -89,7 +89,6 @@ public struct RDEPUBSearchState: Codable, Equatable {
return matches[currentMatchIndex]
}
/// 转换为搜索结果摘要
public var result: RDEPUBSearchResult {
RDEPUBSearchResult(
keyword: keyword,
@@ -100,13 +99,12 @@ public struct RDEPUBSearchState: Codable, Equatable {
}
}
/// 搜索展示的单个资源信息
public struct RDEPUBSearchPresentationResource: Codable, Equatable {
/// 资源的规范化 href
public var href: String
/// 该资源内的匹配数量
public var matchCount: Int
/// 当前活跃的本地匹配索引
public var activeLocalMatchIndex: Int?
public init(href: String, matchCount: Int, activeLocalMatchIndex: Int? = nil) {
@@ -116,11 +114,10 @@ public struct RDEPUBSearchPresentationResource: Codable, Equatable {
}
}
/// 搜索展示信息,传递给 JS 桥接脚本用于渲染搜索高亮
public struct RDEPUBSearchPresentation: Codable, Equatable {
/// 搜索关键词
public var keyword: String
/// 各资源的搜索展示信息列表
public var resources: [RDEPUBSearchPresentationResource]
public init(keyword: String, resources: [RDEPUBSearchPresentationResource]) {
@@ -1,13 +1,8 @@
// RDEPUBStyleSheetBuilder.swift
// EPUB CSS 样式构建器
// 负责生成重排模式下的分页 CSS(多列布局)和测量 CSS,
// 以及将分页样式注入到 HTML 中。还提供测量脚本(JS)用于计算页数。
import Foundation
/// CSS 样式构建工具集,生成分页、渲染和测量所需的 CSS/JS
public enum RDEPUBStyleSheetBuilder {
/// 将分页 CSS 注入到 HTML 字符串中(插入 </head> 或 <body 之前)
public static func injectPaginationCSS(
into html: String,
presentation: RDEPUBPresentationStyle
@@ -22,8 +17,6 @@ public enum RDEPUBStyleSheetBuilder {
return styleTag + html
}
/// 生成重排模式的渲染 CSS(视口、body、#ss-reader-viewport、#ss-reader-content 等)
/// 包含多列布局、字号、行高、主题色、高亮样式等
public static func renderCSS(for presentation: RDEPUBPresentationStyle) -> String {
let values = cssValues(for: presentation)
let backgroundCSS = presentation.themeBackgroundColor.map { "background: \($0) !important;" } ?? ""
@@ -106,7 +99,6 @@ public enum RDEPUBStyleSheetBuilder {
"""
}
/// 生成分页测量用的 CSS(与渲染 CSS 类似但不含 transform/will-change 等优化属性)
public static func measurementCSS(for presentation: RDEPUBPresentationStyle) -> String {
let values = cssValues(for: presentation)
let backgroundCSS = presentation.themeBackgroundColor.map { "background: \($0) !important;" } ?? ""
@@ -153,7 +145,6 @@ public enum RDEPUBStyleSheetBuilder {
"""
}
/// 生成分页测量脚本(JS):注入 CSS 后测量 scrollWidth,计算页数
public static func measurementScript(for presentation: RDEPUBPresentationStyle) -> String {
let style = measurementCSS(for: presentation)
.replacingOccurrences(of: "\\", with: "\\\\")
@@ -185,7 +176,6 @@ public enum RDEPUBStyleSheetBuilder {
"""
}
/// 从展示样式计算所有 CSS 变量值(视口尺寸、内边距、字号、行高等)
private static func cssValues(for presentation: RDEPUBPresentationStyle) -> (
viewportWidth: String,
viewportHeight: String,
@@ -1,26 +1,18 @@
// RDEPUBTextAnchor.swift
// EPUB 文本锚点和范围锚点
// 定义精确的文本位置锚点(RDEPUBTextAnchor,含 fileIndex/row/column/chapterOffset/fragmentID),
// 以及文本范围锚点(RDEPUBTextRangeAnchor,起止锚点对)。
// 支持 Codable 序列化(兼容 spineIndex/fileIndex 两种 key)。
import Foundation
/// 文本锚点,精确定位到 EPUB 中的某个字符位置
/// 包含文件索引、行号、列号、章节内偏移和可选的 fragment ID
public struct RDEPUBTextAnchor: Codable, Equatable {
/// 文件索引(等同于 spineIndex)
public let fileIndex: Int
/// 行号
public let row: Int
/// 列号(行内偏移)
public let column: Int
/// 章节内的绝对字符偏移量
public let chapterOffset: Int
/// 最近的 fragment ID(用于 URL 锚点定位)
public let fragmentID: String?
/// spine 索引的别名(与 fileIndex 等价)
public var spineIndex: Int { fileIndex }
public init(
@@ -67,11 +59,10 @@ public struct RDEPUBTextAnchor: Codable, Equatable {
}
}
/// 文本范围锚点,由起止锚点组成的区间
public struct RDEPUBTextRangeAnchor: Codable, Equatable {
/// 起始锚点
public let start: RDEPUBTextAnchor
/// 结束锚点
public let end: RDEPUBTextAnchor
public init(start: RDEPUBTextAnchor, end: RDEPUBTextAnchor) {
@@ -79,7 +70,6 @@ public struct RDEPUBTextRangeAnchor: Codable, Equatable {
self.end = end
}
/// 转换为 NSRange(基于 chapterOffset)
public var nsRange: NSRange {
NSRange(location: start.chapterOffset, length: max(end.chapterOffset - start.chapterOffset, 0))
}
@@ -1,14 +1,9 @@
// RDEPUBWebView+Configuration.swift
// RDEPUBWebView 的 WKWebView 配置扩展
// 负责按需创建和配置 WKWebView 实例:注册 JS 桥接消息处理器、
// 注入用户脚本、设置自定义 URL Scheme 处理器、配置滚动和外观属性。
// 同时处理选择菜单(拷贝/高亮/批注)和 WebView 销毁清理。
import UIKit
import WebKit
extension RDEPUBWebView {
/// 按需配置 WKWebView(仅在 Publication 变更或 WebView 不存在时重建)
func configureWebViewIfNeeded(publication: RDEPUBPublication) {
let parser = publication.parser
let publicationKey = parser.opfURL?.path ?? parser.extractionRootURL?.path ?? UUID().uuidString
@@ -73,18 +68,15 @@ extension RDEPUBWebView {
RDEPUBWebViewDebug.log(debugScope, message: "configured webView=\(RDEPUBWebViewDebug.webViewID(webView)) publicationKey=\(publicationKey)")
}
/// 处理选择菜单动作(拷贝/高亮/批注),通知委托并清除选择
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction) {
delegate?.epubWebView(self, didRequestSelectionAction: action)
clearWebSelection()
}
/// 清除 WebView 中的文本选择
func clearWebSelection() {
webView?.evaluateJavaScript("window.getSelection && window.getSelection().removeAllRanges();")
}
/// 销毁 WebView,移除所有消息处理器和引用
func teardownWebView() {
cancelFixedLayoutReadyFallback()
if let webView {
@@ -106,7 +98,7 @@ extension RDEPUBWebView {
}
extension UIColor {
/// 将 UIColor 转换为 CSS 十六进制颜色字符串(如 "#FF0000")
var ss_hexString: String {
var red: CGFloat = 0
var green: CGFloat = 0
@@ -1,13 +1,9 @@
// RDEPUBWebView+FixedLayout.swift
// RDEPUBWebView 的固定版式渲染扩展
// 处理固定版式(Fixed Layout)的加载流程:生成 HTML 模板、
// 计算加载签名(去重)、设置固定版式就绪回退定时器等。
import UIKit
import WebKit
extension RDEPUBWebView {
/// 便捷方法:加载固定版式的 spread 页面
public func loadFixedSpread(
parser: RDEPUBParser,
spread: EPUBFixedSpread,
@@ -28,7 +24,6 @@ extension RDEPUBWebView {
load(publication: parser.makePublication(), request: request)
}
/// 处理固定版式加载:设置状态、生成 HTML、加载到 WebView
func handleFixedLayoutLoad(
publication: RDEPUBPublication,
request: RDEPUBFixedRenderRequest,
@@ -55,11 +50,10 @@ extension RDEPUBWebView {
RDEPUBWebViewDebug.log(debugScope, message: "load fixed spread resources=\(request.spread.resources.map(\.href).joined(separator: ",")) fit=\(request.fit.rawValue)")
webView.loadHTMLString(
html,
baseURL: URL(string: "\(RDEPUBResourceURLSchemeHandler.scheme)://\(RDEPUBResourceURLSchemeHandler.host)/")
baseURL: URL(string: "\(RDEPUBResourceURLSchemeHandler.scheme):///")
)
}
/// 生成固定版式加载签名(用于去重相同请求)
func fixedLayoutLoadSignature(publicationKey: String, request: RDEPUBFixedRenderRequest) -> String {
let searchSignature = [
request.searchPresentation?.keyword ?? "",
@@ -87,7 +81,6 @@ extension RDEPUBWebView {
].joined(separator: "#")
}
/// 调度固定版式就绪回退定时器(1 秒后如果未收到 ready 消息则手动触发渲染完成)
func scheduleFixedLayoutReadyFallback() {
let workItem = DispatchWorkItem { [weak self] in
RDEPUBWebViewDebug.log(self?.debugScope ?? "ReaderWebView", message: "fixed ready fallback fired")
@@ -101,7 +94,6 @@ extension RDEPUBWebView {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: workItem)
}
/// 取消固定版式就绪回退定时器
func cancelFixedLayoutReadyFallback() {
fixedLayoutReadyWorkItem?.cancel()
fixedLayoutReadyWorkItem = nil
@@ -1,14 +1,9 @@
// RDEPUBWebView+JavaScriptBridge.swift
// RDEPUBWebView 的 JS 桥接和导航委托扩展
// 实现 WKNavigationDelegate 处理页面加载生命周期,
// 实现 WKScriptMessageHandler 接收 JS 消息(进度/选择/链接/错误/固定版式就绪),
// 并将内部链接和外部链接路由到委托方法。
import Foundation
import WebKit
extension RDEPUBWebView {
/// 从 URL 解析内部位置(ss-reader:// 协议的链接)
func internalLocation(from url: URL) -> RDEPUBLocation? {
let href = url.path.removingPercentEncoding?.trimmingCharacters(in: CharacterSet(charactersIn: "/")) ?? ""
let fragment = url.fragment
@@ -16,7 +11,6 @@ extension RDEPUBWebView {
return RDEPUBLocation(href: href, progression: 0, fragment: fragment)
}
/// 从 JS 消息体中构建当前位置对象
func currentLocation(from body: [String: Any]) -> RDEPUBLocation {
let progression = (body["progression"] as? NSNumber)?.doubleValue ?? 0
let lastProgression = (body["lastProgression"] as? NSNumber)?.doubleValue
@@ -52,7 +46,6 @@ extension RDEPUBWebView: WKNavigationDelegate {
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFailProvisional", url: webView.url, error: error)
}
/// 导航策略决策:区分内部链接(ss-reader://)、外部链接(http/https/mailto/tel)和其他导航
public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "decidePolicy", url: navigationAction.request.url)
guard navigationAction.navigationType == .linkActivated,
@@ -79,7 +72,7 @@ extension RDEPUBWebView: WKNavigationDelegate {
}
extension RDEPUBWebView: WKScriptMessageHandler {
/// 接收 JS 桥接消息,根据消息类型路由到对应的委托方法
public func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
RDEPUBWebViewDebug.logMessage(debugScope, webView: message.webView, name: message.name, body: message.body)
switch message.name {
@@ -1,14 +1,9 @@
// RDEPUBWebView+Reflowable.swift
// RDEPUBWebView 的重排模式渲染扩展
// 处理重排(Reflowable)模式的加载流程:构建渲染请求、
// 生成加载签名(去重)、通过 JS 桥接应用分页样式和高亮、
// 处理重复请求跳过和已有文档复用等优化逻辑。
import UIKit
import WebKit
extension RDEPUBWebView {
/// 便捷方法:加载重排模式的指定页面
public func loadPage(
parser: RDEPUBParser,
spineIndex: Int,
@@ -46,7 +41,6 @@ extension RDEPUBWebView {
load(publication: parser.makePublication(), request: request)
}
/// 处理重排模式加载:设置状态、去重检查、加载资源或直接应用样式
func handleReflowableLoad(
publication: RDEPUBPublication,
request: RDEPUBReflowableRenderRequest,
@@ -91,7 +85,6 @@ extension RDEPUBWebView {
webView.load(URLRequest(url: requestURL))
}
/// 生成重排模式加载签名(包含所有渲染参数的哈希,用于去重)
func reflowableLoadSignature(publicationKey: String, request: RDEPUBReflowableRenderRequest) -> String {
let targetSignature = [
request.targetLocation?.href ?? "",
@@ -139,7 +132,6 @@ extension RDEPUBWebView {
].joined(separator: "#")
}
/// 应用分页展示样式:通过 JS 桥接注入 CSS、设置分页参数和高亮,然后触发渲染完成
func applyPresentation() {
guard let webView, let currentRenderRequest else { return }
guard case .reflowable(let request) = currentRenderRequest else {
@@ -1,6 +1,3 @@
// RDEPUBWebView+Search.swift
// RDEPUBWebView 的搜索高亮扩展
// 从当前渲染请求中提取搜索展示信息,生成搜索高亮脚本并注入到 WebView 中执行。
import UIKit
import WebKit
@@ -14,7 +11,6 @@ extension RDEPUBWebView {
UIColor(red: 0.14, green: 0.42, blue: 0.95, alpha: 0.34)
}
/// 如果当前渲染请求包含搜索信息,注入搜索高亮脚本到 WebView
func applySearchDecorationsIfNeeded(completion: (() -> Void)? = nil) {
guard let webView else {
completion?()
@@ -1,39 +1,30 @@
// RDEPUBWebView.swift
// EPUB WebView 主类
// 封装 WKWebView 的 EPUB 渲染能力,是 Resource View 层的核心组件。
// 统一管理固定版式和重排模式的加载流程,持有当前渲染状态(spineIndex、pageIndex、
// 主题色、高亮等),并通过委托模式通知上层页面渲染和交互事件。
// 具体逻辑分散在 +Configuration、+FixedLayout、+Reflowable、+JavaScriptBridge、+Search 扩展中。
import UIKit
import WebKit
/// RDEPUBWebView 委托协议,定义 WebView 向上层通知的事件
public protocol RDEPUBWebViewDelegate: AnyObject {
/// 阅读位置更新事件
func epubWebView(_ webView: RDEPUBWebView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int)
/// 文本选择变化事件
func epubWebView(_ webView: RDEPUBWebView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int)
/// 选择菜单动作请求事件
func epubWebView(_ webView: RDEPUBWebView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction)
/// 内部链接点击事件
func epubWebView(_ webView: RDEPUBWebView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int)
/// 外部链接点击事件
func epubWebView(_ webView: RDEPUBWebView, didActivateExternalLink url: URL)
/// JavaScript 错误事件
func epubWebView(_ webView: RDEPUBWebView, didLogJavaScriptError message: String)
/// 页面渲染完成事件
func epubWebViewDidFinishRendering(_ webView: RDEPUBWebView)
}
/// 委托协议的默认实现(选择菜单动作为可选)
public extension RDEPUBWebViewDelegate {
func epubWebView(_ webView: RDEPUBWebView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) {}
}
/// 自定义 WKWebView 子类,支持拷贝/高亮/批注选择菜单
final class RDEPUBAnnotationWebView: WKWebView {
/// 选择菜单动作回调
var onSelectionAction: ((RDEPUBAnnotationMenuAction) -> Void)?
override var canBecomeFirstResponder: Bool {
@@ -64,63 +55,60 @@ final class RDEPUBAnnotationWebView: WKWebView {
}
}
/// EPUB WebView 主类,封装 WKWebView 的 EPUB 渲染能力
/// Resource View 层核心组件,统一管理固定版式和重排模式的渲染
public final class RDEPUBWebView: UIView {
/// 委托对象,接收渲染和交互事件
public weak var delegate: RDEPUBWebViewDelegate?
/// 渲染完成回调(与 delegate 并行使用)
public var onRendered: (() -> Void)?
/// 内部原生装饰解析回调
var onDecorationsResolved: (([RDEPUBTextOverlayDecoration]) -> Void)?
/// 当前关联的出版物
var publication: RDEPUBPublication?
/// 当前渲染请求
var currentRenderRequest: RDEPUBRenderRequest?
/// 内部 WKWebView 实例
var webView: WKWebView?
/// URL Scheme 处理器
var schemeHandler: RDEPUBResourceURLSchemeHandler?
/// 已配置的出版物标识(用于判断是否需要重建 WebView)
var configuredPublicationKey: String?
/// 当前加载签名(用于去重相同请求)
var currentLoadSignature: String?
/// 当前 spine 索引
var currentSpineIndex = 0
/// 当前资源 href
var currentHref = ""
/// 当前页码(从 0 开始)
var currentPageIndex = 0
/// 当前章节总页数
var currentTotalPagesInChapter = 1
/// 当前视口尺寸
var viewportSize: CGSize = .zero
/// 当前内容内边距
var currentPadding: UIEdgeInsets = .zero
/// 当前字号
var currentFontSize: CGFloat = 16
/// 当前行高倍数
var currentLineHeightMultiple: CGFloat = 1.5
/// 当前主题背景色
var currentThemeBackgroundColor: String?
/// 当前主题文字色
var currentThemeTextColor: String?
/// 目标跳转位置
var targetLocation: RDEPUBLocation?
/// 待应用的高亮列表
var pendingHighlights: [RDEPUBHighlight] = []
/// 当前固定版式 spread(固定版式时非空)
var fixedSpread: EPUBFixedSpread?
/// 是否为固定版式模式
var isFixedLayout = false
/// 是否有待处理的进度汇报请求
var pendingProgressionRequest = false
/// 固定版式就绪回退定时器
var fixedLayoutReadyWorkItem: DispatchWorkItem?
/// 当前请求是否已完成渲染
var didRenderCurrentRequest = false
/// 调试日志作用域
let debugScope = "ReaderWebView"
public override init(frame: CGRect) {
@@ -141,7 +129,6 @@ public final class RDEPUBWebView: UIView {
webView?.frame = bounds
}
/// 重置所有状态并销毁 WebView
public func reset() {
delegate = nil
onRendered = nil
@@ -167,7 +154,6 @@ public final class RDEPUBWebView: UIView {
teardownWebView()
}
/// 加载渲染请求:配置 WebView、生成签名、分发到固定版式或重排处理
public func load(publication: RDEPUBPublication, request: RDEPUBRenderRequest) {
configureWebViewIfNeeded(publication: publication)
guard let webView else { return }
@@ -194,7 +180,6 @@ public final class RDEPUBWebView: UIView {
}
}
/// 生成页面加载签名
func pageLoadSignature(
publicationKey: String,
request: RDEPUBRenderRequest
@@ -207,7 +192,6 @@ public final class RDEPUBWebView: UIView {
}
}
/// 标记渲染完成:通知委托和回调,延迟 100ms 确保布局稳定
func rendered() {
guard !didRenderCurrentRequest else { return }
didRenderCurrentRequest = true
@@ -1,15 +1,9 @@
// RDEPUBWebViewDebug.swift
// WebView 调试日志工具
// 提供统一的调试日志输出接口,支持导航事件、JS 执行、
// 消息接收、URL Scheme 任务等场景的日志记录。
// DEBUG 模式默认开启,Release 模式默认关闭,可通过 UserDefaults 覆盖。
import Foundation
import WebKit
/// WebView 调试日志工具集
enum RDEPUBWebViewDebug {
/// 调试日志是否启用(DEBUG 默认开启,可通过 UserDefaults "RDEPUBWebViewDebugEnabled" 覆盖)
static var isEnabled: Bool = {
if let configured = UserDefaults.standard.object(forKey: "RDEPUBWebViewDebugEnabled") as? Bool {
return configured
@@ -21,8 +15,6 @@ enum RDEPUBWebViewDebug {
#endif
}()
/// 详细日志模式是否启用(默认关闭,可通过 UserDefaults "RDEPUBWebViewVerboseEnabled" 开启)
/// 开启后 logMessage 将输出完整消息体,关闭时仅输出消息名、字段名和文本长度
static var isVerboseEnabled: Bool = {
if let configured = UserDefaults.standard.object(forKey: "RDEPUBWebViewVerboseEnabled") as? Bool {
return configured
@@ -30,8 +22,6 @@ enum RDEPUBWebViewDebug {
return false
}()
/// 是否允许开启 inspectable。
/// 默认关闭,仅在阅读器配置显式允许或 UserDefaults 覆盖时开启。
static var isInspectableEnabled: Bool = {
if let configured = UserDefaults.standard.object(forKey: "RDEPUBInspectableWebViewsEnabled") as? Bool {
return configured
@@ -39,25 +29,21 @@ enum RDEPUBWebViewDebug {
return false
}()
/// 将阅读器配置同步到调试策略,保证默认安全策略由配置显式控制。
static func applyDebugPolicy(inspectableEnabled: Bool, verboseLoggingEnabled: Bool) {
isInspectableEnabled = inspectableEnabled
isVerboseEnabled = verboseLoggingEnabled
}
/// 获取 WebView 的十六进制标识符(用于日志区分多个 WebView 实例)
static func webViewID(_ webView: WKWebView?) -> String {
guard let webView else { return "nil-webview" }
return String(ObjectIdentifier(webView).hashValue, radix: 16)
}
/// 输出通用日志
static func log(_ scope: String, message: String) {
guard isEnabled else { return }
print("[RDReaderWK][\(scope)] \(message)")
}
/// 输出导航事件日志(含 WebView ID、事件类型、URL、错误信息)
static func logNavigationEvent(_ scope: String, webView: WKWebView?, event: String, url: URL? = nil, error: Error? = nil) {
guard isEnabled else { return }
let webViewToken = webViewID(webView)
@@ -69,13 +55,11 @@ enum RDEPUBWebViewDebug {
}
}
/// 输出 JavaScript 执行日志
static func logJavaScript(_ scope: String, webView: WKWebView?, action: String, details: String) {
guard isEnabled else { return }
log(scope, message: "webView=\(webViewID(webView)) js=\(action) \(details)")
}
/// 输出 JS 消息接收日志(默认仅输出字段名和文本长度,verbose 模式输出完整消息体)
static func logMessage(_ scope: String, webView: WKWebView?, name: String, body: Any) {
guard isEnabled else { return }
if isVerboseEnabled {
@@ -97,7 +81,6 @@ enum RDEPUBWebViewDebug {
}
}
/// 输出 URL Scheme 任务日志(含请求 URL、文件 URL、事件类型、错误信息)
static func logSchemeTask(_ scope: String, requestURL: URL?, fileURL: URL? = nil, event: String, error: Error? = nil) {
guard isEnabled else { return }
let requestText = summarizedURL(requestURL)
@@ -109,7 +92,6 @@ enum RDEPUBWebViewDebug {
}
}
/// 截取 URL 的摘要信息(ss-reader:// 协议显示完整 URL,其他显示最后路径组件)
static func summarizedURL(_ url: URL?) -> String {
guard let url else { return "nil" }
if let scheme = url.scheme, scheme == RDEPUBResourceURLSchemeHandler.scheme {
@@ -89,6 +89,60 @@
}
}
function parseCssPixelValue(value) {
if (!value) { return 0; }
var match = String(value).match(/([0-9]+(?:\.[0-9]+)?)px?/);
return match ? Number.parseFloat(match[1]) : 0;
}
function sizeFromElement(element) {
if (!element) { return null; }
var width = parseCssPixelValue(element.style && element.style.width);
var height = parseCssPixelValue(element.style && element.style.height);
if (!width || !height) {
width = parseCssPixelValue(element.getAttribute && element.getAttribute('width'));
height = parseCssPixelValue(element.getAttribute && element.getAttribute('height'));
}
if (!width || !height) {
var rect = element.getBoundingClientRect ? element.getBoundingClientRect() : null;
if (rect) {
width = width || rect.width;
height = height || rect.height;
}
}
if (width > 1 && height > 1) {
return { width: width, height: height };
}
return null;
}
function parsePageSizeFromDocumentBox(iframe) {
try {
var doc = iframe.contentWindow.document;
return sizeFromElement(doc.body) ||
sizeFromElement(doc.documentElement) ||
sizeFromElement(doc.querySelector('[id$="_hype_container"]')) ||
sizeFromElement(doc.querySelector('iframe')) ||
null;
} catch (error) {
return null;
}
}
function parsePageSizeFromNestedFrame(iframe) {
try {
var nestedFrame = iframe.contentWindow.document.querySelector('iframe');
if (!nestedFrame || !nestedFrame.contentWindow || !nestedFrame.contentWindow.document) {
return null;
}
return parsePageSizeFromViewportMetaTag(nestedFrame) ||
parsePageSizeFromDocumentBox(nestedFrame) ||
parsePageSizeFromEmbeddedImage(nestedFrame);
} catch (error) {
return null;
}
}
function parsePageSizeFromEmbeddedImage(iframe) {
try {
var img = iframe.contentWindow.document.querySelector('img');
@@ -176,9 +230,14 @@
function onLoad() {
iframe.__ssPageSize =
parsePageSizeFromViewportMetaTag(iframe) ||
parsePageSizeFromDocumentBox(iframe) ||
parsePageSizeFromNestedFrame(iframe) ||
parsePageSizeFromEmbeddedImage(iframe) ||
pageViewportSize(iframe);
layoutPage(iframe);
window.setTimeout(function() {
layoutPage(iframe);
}, 250);
finalizeLoad();
}
@@ -204,4 +263,4 @@
})();
</script>
</body>
</html>
</html>
@@ -1,16 +1,18 @@
import Foundation
/// Builds diagnostics and human-readable summaries for a text book build.
struct RDEPUBBuildDiagnosticsReporter {
func phase7SemanticSummary(
title: String?,
diagnostics: [RDEPUBTextChapterPaginationDiagnostic]
) -> String? {
guard !diagnostics.isEmpty else { return nil }
let blockKinds = uniqueValues(from: diagnostics.flatMap(\.blockKinds))
let semanticHints = uniqueValues(from: diagnostics.flatMap(\.semanticHints))
let attachmentPlacements = uniqueValues(from: diagnostics.flatMap(\.attachmentPlacements))
let note = diagnostics
.flatMap(\.sampleNotes)
.first(where: { $0.contains("semantic") || $0.contains("attachment") || $0.contains("block kinds") })
@@ -22,6 +24,7 @@ struct RDEPUBBuildDiagnosticsReporter {
semanticHints.isEmpty ? nil : "hints [\(semanticHints.map(\.rawValue).joined(separator: ","))]",
attachmentPlacements.isEmpty ? nil : "placements [\(attachmentPlacements.map(\.rawValue).joined(separator: ","))]"
].compactMap { $0 }
if let note {
parts.append(note)
}
@@ -38,11 +41,14 @@ struct RDEPUBBuildDiagnosticsReporter {
title: title,
pageCount: pages.count,
breakReasons: pages.map(\.metadata.breakReason),
attachmentPageCount: pages.filter { !$0.metadata.attachmentKinds.isEmpty }.count,
blockAdjustedPageCount: pages.filter { $0.metadata.breakReason == .blockBoundary || $0.metadata.breakReason == .attachmentBoundary }.count,
blockKinds: uniqueValues(from: pages.flatMap(\.metadata.blockKinds)),
semanticHints: uniqueValues(from: pages.flatMap(\.metadata.semanticHints)),
attachmentPlacements: uniqueValues(from: pages.flatMap(\.metadata.attachmentPlacements)),
sampleNotes: Array(pages.flatMap(\.metadata.diagnostics).prefix(4))
)
}
@@ -1,12 +1,13 @@
import Foundation
/// Normalizes suspicious trailing or whitespace-only page frames after pagination.
struct RDEPUBChapterTailNormalizer {
func normalize(
_ frames: [RDEPUBTextLayoutFrame],
content: NSAttributedString,
href: String
) -> [RDEPUBTextLayoutFrame] {
guard frames.count > 1 else { return frames }
var normalized = frames
@@ -16,10 +17,12 @@ struct RDEPUBChapterTailNormalizer {
for frame in normalized {
if shouldDropWhitespaceOnlyFrame(frame, in: content) {
let note = "normalized: dropped whitespace-only intermediate page \(NSStringFromRange(frame.contentRange))"
if var previous = compacted.popLast() {
previous.diagnostics.append(note)
compacted.append(previous)
} else {
#if DEBUG
print("[EPUB][Pagination] href=\(href) dropped leading/intermediate whitespace frame \(NSStringFromRange(frame.contentRange))")
#endif
@@ -73,6 +76,7 @@ struct RDEPUBChapterTailNormalizer {
previousFrame: RDEPUBTextLayoutFrame,
in content: NSAttributedString
) -> Bool {
guard trailingFrame.contentRange.length > 0,
NSMaxRange(previousFrame.contentRange) == trailingFrame.contentRange.location else {
return false
@@ -80,6 +84,7 @@ struct RDEPUBChapterTailNormalizer {
let visibleCount = visibleCharacterCount(in: content, range: trailingFrame.contentRange)
let trailingAttachmentCount = attachmentCount(in: content, range: trailingFrame.contentRange)
guard visibleCount <= 2,
trailingFrame.contentRange.length <= 2,
visibleCount > 0 || trailingAttachmentCount > 0 else {
@@ -94,6 +99,7 @@ struct RDEPUBChapterTailNormalizer {
_ previousFrame: RDEPUBTextLayoutFrame,
with trailingFrame: RDEPUBTextLayoutFrame
) -> RDEPUBTextLayoutFrame {
let mergedRange = NSRange(
location: previousFrame.contentRange.location,
length: NSMaxRange(trailingFrame.contentRange) - previousFrame.contentRange.location
@@ -109,6 +115,7 @@ struct RDEPUBChapterTailNormalizer {
semanticHints: uniqueValues(from: previousFrame.semanticHints + trailingFrame.semanticHints),
attachmentPlacements: uniqueValues(from: previousFrame.attachmentPlacements + trailingFrame.attachmentPlacements),
trailingFragmentID: trailingFrame.trailingFragmentID ?? previousFrame.trailingFragmentID,
diagnostics: previousFrame.diagnostics
+ trailingFrame.diagnostics
+ ["normalized: merged short trailing page \(NSStringFromRange(trailingFrame.contentRange)) into previous page"]
@@ -121,6 +128,7 @@ struct RDEPUBChapterTailNormalizer {
) -> Int {
guard range.length > 0 else { return 0 }
let string = content.attributedSubstring(from: range).string
let filteredScalars = string.unicodeScalars.filter { scalar in
!CharacterSet.whitespacesAndNewlines.contains(scalar)
&& !CharacterSet.controlCharacters.contains(scalar)
@@ -1,8 +1,9 @@
import UIKit
/// Keeps pagination cache key generation and cache IO in one BuildPipeline role.
struct RDEPUBPaginationCacheCoordinator {
private let cache: RDEPUBTextBookCache?
private let layoutConfig: RDEPUBTextLayoutConfig
init(cache: RDEPUBTextBookCache?, layoutConfig: RDEPUBTextLayoutConfig) {
@@ -32,11 +33,13 @@ struct RDEPUBPaginationCacheCoordinator {
func save(chapters: [RDEPUBTextChapter], key: String?) {
guard let key else { return }
let paginationCache = chapters.map { chapter in
RDEPUBTextChapterPaginationCache(
href: chapter.href,
pageRanges: chapter.pages.map(\.contentRange),
breakReasons: chapter.pages.map(\.metadata.breakReason),
semanticHints: Array(Set(chapter.pages.flatMap(\.metadata.semanticHints)))
)
}
@@ -1,6 +1,5 @@
import UIKit
/// EPUB 文本书籍构建器,负责将 EPUB publication 渲染、分页并组装为 `RDEPUBTextBook`。
public final class RDEPUBTextBookBuilder {
private let renderer: RDEPUBTextRenderer
private let cache: RDEPUBTextBookCache?
@@ -12,20 +11,14 @@ public final class RDEPUBTextBookBuilder {
private let cacheCoordinator: RDEPUBPaginationCacheCoordinator
private let diagnosticsReporter: RDEPUBBuildDiagnosticsReporter
/// 最后一次构建的资源引用诊断(样式表、图片等)
public private(set) var lastBuildResourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic] = []
/// 最后一次构建的分页诊断(每章一页的分页原因、附件统计等)
public private(set) var lastBuildPaginationDiagnostics: [RDEPUBTextChapterPaginationDiagnostic] = []
/// 最后一次构建的性能采样数据
public private(set) var lastBuildPerformanceSamples: [RDEPUBTextPerformanceSample] = []
/// 最后一次构建的缓存命中/未命中统计
public private(set) var lastBuildCacheStats: (hits: Int, misses: Int) = (0, 0)
/// 创建书籍构建器。
/// - Parameters:
/// - renderer: 文本渲染器
/// - cache: 分页缓存(可选)
/// - layoutConfig: 页面布局配置
public init(
renderer: RDEPUBTextRenderer,
cache: RDEPUBTextBookCache? = nil,
@@ -42,7 +35,6 @@ public final class RDEPUBTextBookBuilder {
self.diagnosticsReporter = RDEPUBBuildDiagnosticsReporter()
}
/// 默认构造器,使用 DTCoreText 渲染器
public convenience init() {
self.init(renderer: RDEPUBDTCoreTextRenderer())
}
@@ -51,7 +43,6 @@ public final class RDEPUBTextBookBuilder {
ProcessInfo.processInfo.arguments.contains("--demo-pagination-debug")
}
/// 生成最近一次构建的语义摘要,用于 Phase 7 质量检测日志
public func phase7SemanticSummary(title: String? = nil) -> String? {
diagnosticsReporter.phase7SemanticSummary(
title: title,
@@ -59,16 +50,6 @@ public final class RDEPUBTextBookBuilder {
)
}
/// 核心构建方法:从 EPUB publication 构建分页书籍。
///
/// 流程:
/// 1. 生成缓存键,尝试加载分页缓存
/// 2. 遍历 spine 中的线性 HTML 章节
/// 3. 渲染每章 HTML → NSAttributedString
/// 4. 跳过空白的封面/扉页章节
/// 5. 分页:缓存命中则使用缓存的页范围,否则调用 CoreText 分页引擎
/// 6. 尾页规范化:丢弃纯空白尾页、合并过短尾页
/// 7. 构建 RDEPUBTextBook 并保存分页缓存
public func build(
parser: RDEPUBParser,
publication: RDEPUBPublication,
@@ -88,7 +69,6 @@ public final class RDEPUBTextBookBuilder {
let buildStart = CFAbsoluteTimeGetCurrent()
// 缓存查询 — WXRead 模式:只加载每章的页范围,不缓存富文本
let bookID = publication.metadata.identifier ?? publication.metadata.title
let cacheKey = cacheCoordinator.cacheKey(bookID: bookID, pageSize: pageSize, style: style)
let cachedPagination = cacheCoordinator.load(key: cacheKey)
@@ -134,7 +114,6 @@ public final class RDEPUBTextBookBuilder {
sampler.totalBuildDuration = CFAbsoluteTimeGetCurrent() - buildStart
// 保存分页缓存(只缓存页范围和分页原因,不缓存富文本)
cacheCoordinator.save(chapters: chapters, key: cacheKey)
#if DEBUG
@@ -145,7 +124,6 @@ public final class RDEPUBTextBookBuilder {
return book
}
/// 构建指定 spine 章节,用于大书快速首屏和后台增量补齐。
public func buildChapter(
parser: RDEPUBParser,
publication: RDEPUBPublication,
@@ -192,6 +170,7 @@ public final class RDEPUBTextBookBuilder {
let request = RDEPUBTextTypesetterPipeline().makeRequest(
from: RDEPUBTypesettingInput(
href: item.href,
spineIndex: spineIndex,
title: chapterTitle,
rawHTML: rawHTML,
baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent(),
@@ -281,11 +260,13 @@ public final class RDEPUBTextBookBuilder {
}
let paginateDuration = CFAbsoluteTimeGetCurrent() - paginateStart
let normalizedFrames = tailNormalizer.normalize(
layoutFrames,
content: content,
href: item.href
)
let effectiveFrames = normalizedFrames.isEmpty && content.length > 0
? [
RDEPUBTextLayoutFrame(
@@ -339,6 +320,12 @@ public final class RDEPUBTextBookBuilder {
title: chapterTitle,
attributedContent: chapterAttributedContent,
fragmentOffsets: rendered.fragmentOffsets,
cfiMap: RDEPUBCFITextNodeMapBuilder.makeMap(
href: item.href,
rawHTML: rawHTML,
chapterText: chapterAttributedContent.string,
fragmentOffsets: rendered.fragmentOffsets
),
pageBreakReasons: pages.map(\.metadata.breakReason),
pages: pages
)
@@ -365,9 +352,6 @@ public final class RDEPUBTextBookBuilder {
)
}
// MARK: - 章节标题解析
/// 从目录表中查找章节标题,找不到则回退到 spine item 的 title 或 href
private func resolvedChapterTitle(for item: RDEPUBSpineItem, toc: [EPUBTableOfContentsItem]) -> String {
if let title = flattenedTOCItems(from: toc).first(where: { tocItem in
tocItem.href.components(separatedBy: "#").first == item.href
@@ -378,16 +362,12 @@ public final class RDEPUBTextBookBuilder {
return trimmedTitle.isEmpty ? item.href : trimmedTitle
}
/// 递归展开嵌套目录为扁平列表
private func flattenedTOCItems(from items: [EPUBTableOfContentsItem]) -> [EPUBTableOfContentsItem] {
items.flatMap { item in
[item] + flattenedTOCItems(from: item.children)
}
}
// MARK: - 章节过滤
/// 判断是否应跳过该章节(空白的封面/扉页,无文本且无附件)
private func shouldSkipChapter(item: RDEPUBSpineItem, content: NSAttributedString, text: String) -> Bool {
let lowercasedHref = item.href.lowercased()
var hasAttachment = false
@@ -404,9 +384,6 @@ public final class RDEPUBTextBookBuilder {
return false
}
// MARK: - 附件统计
/// 统计富文本中的附件数量
private func attachmentCount(in content: NSAttributedString) -> Int {
guard content.length > 0 else { return 0 }
var count = 0
@@ -418,7 +395,6 @@ public final class RDEPUBTextBookBuilder {
return count
}
/// 获取富文本中所有附件的 NSRange 列表
private func attachmentRanges(in content: NSAttributedString) -> [NSRange] {
guard content.length > 0 else { return [] }
var ranges: [NSRange] = []
@@ -430,9 +406,6 @@ public final class RDEPUBTextBookBuilder {
return ranges
}
// MARK: - 封面章节检测
/// 判断是否为纯图片封面章节(href 包含 cover 且有附件但几乎无文本)
private func isAttachmentOnlyCoverChapter(
item: RDEPUBSpineItem,
content: NSAttributedString,
@@ -445,6 +418,7 @@ public final class RDEPUBTextBookBuilder {
}
private func debugPreview(for content: NSAttributedString, limit: Int) -> String {
let collapsed = content.string
.replacingOccurrences(of: "\n", with: " ")
.replacingOccurrences(of: "\r", with: " ")
@@ -1,19 +1,14 @@
import Foundation
import CryptoKit
// MARK: - 分页缓存数据模型(WXRead 模式:只缓存页范围,不缓存富文本)
/// 单个章节的分页元数据缓存,用于磁盘持久化。
///
/// 对标 WXRead 的 WRChapterPageCount 缓存策略:只存储每页的 NSRange + 分页原因,
/// 不缓存完整的 NSAttributedString。缓存命中时重新渲染 HTML,但跳过 CoreText 分页步骤。
public struct RDEPUBTextChapterPaginationCache: Equatable {
/// 章节文件相对路径
public var href: String
/// 每页在章节富文本中的字符范围
public var pageRanges: [NSRange]
/// 每页的分页原因(语义边界、帧限制等)
public var breakReasons: [RDEPUBTextPageBreakReason]
public var semanticHints: [RDEPUBTextSemanticHint]
public init(
@@ -29,11 +24,8 @@ public struct RDEPUBTextChapterPaginationCache: Equatable {
}
}
// MARK: - NSCoding 归档层(只使用字符串和整数类型)
/// 整本书的分页缓存归档,用于 NSKeyedArchiver 序列化。
/// 包含所有章节的分页归档数据。
final class PaginationCacheArchive: NSObject, NSSecureCoding {
static var supportsSecureCoding: Bool { true }
let chapters: [ChapterPaginationArchive]
@@ -52,20 +44,20 @@ final class PaginationCacheArchive: NSObject, NSSecureCoding {
}
}
/// 单个章节的分页归档,将 NSRange 拆分为 location/length 数组以便 NSSecureCoding 编码。
final class ChapterPaginationArchive: NSObject, NSSecureCoding {
static var supportsSecureCoding: Bool { true }
let href: String
/// 页范围的起始位置数组(与 rangeLengths 一一对应)
let rangeLocations: [NSNumber]
/// 页范围的长度数组
let rangeLengths: [NSNumber]
/// 分页原因的原始字符串数组
let breakReasons: [String]
/// 语义提示的原始字符串数组
let semanticHints: [String]
/// 从缓存模型构建归档对象
init(from cache: RDEPUBTextChapterPaginationCache) {
self.href = cache.href
self.rangeLocations = cache.pageRanges.map { NSNumber(value: $0.location) }
@@ -97,7 +89,6 @@ final class ChapterPaginationArchive: NSObject, NSSecureCoding {
self.semanticHints = semanticHints
}
/// 将归档数据转换回缓存模型
func toCache() -> RDEPUBTextChapterPaginationCache {
let pageRanges = zip(rangeLocations, rangeLengths).map { loc, len in
NSRange(location: loc.intValue, length: len.intValue)
@@ -111,28 +102,14 @@ final class ChapterPaginationArchive: NSObject, NSSecureCoding {
}
}
// MARK: - 分页缓存管理器
/// 磁盘持久化的分页缓存层,对标 WXRead 的 WRChapterPageCount 缓存模式。
///
/// 缓存策略:
/// - 缓存键 = SHA256(书籍ID + 字号 + 行距 + 内边距 + 页面尺寸 + schema版本)
/// - 缓存值 = 每章的页 NSRange 列表 + 分页原因(NSKeyedArchiver 序列化)
/// - 富文本不缓存:缓存命中时重新渲染 HTML,但跳过 CoreText 分页步骤
/// - 线程安全:所有读写操作通过 serial DispatchQueue 串行执行
public final class RDEPUBTextBookCache {
/// 缓存模式版本号,变更时旧缓存自动失效
// 分页算法调整后需要提升版本,避免继续复用旧页范围缓存。
public var schemaVersion: Int = 6
/// 串行队列,保证缓存读写的线程安全
private let queue = DispatchQueue(label: "com.rdreader.textbookcache", qos: .utility)
/// 缓存文件目录
private let cacheDirectory: URL
/// 初始化缓存管理器,自动创建缓存目录
/// - Parameter subdirectory: Caches 目录下的子目录名
public init(subdirectory: String = "RDEPUBTextBookCache") {
let baseURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?
.appendingPathComponent(subdirectory, isDirectory: true)
@@ -141,13 +118,6 @@ public final class RDEPUBTextBookCache {
try? FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
}
// MARK: - 缓存键生成
// 对标 WRChapterPageCount.currentCacheKeyWithBookId:
// 编码 bookID + fontSize + lineHeightMultiple + contentInsets + pageSize
/// 生成缓存文件名(SHA256 哈希 + ".cache" 后缀)。
///
/// 任意布局参数变更都会导致缓存键不同,从而自动失效。
public func cacheKey(
bookID: String,
fontSize: CGFloat,
@@ -162,10 +132,6 @@ public final class RDEPUBTextBookCache {
return hex + ".cache"
}
// MARK: - 加载/保存(只缓存分页元数据)
/// 从磁盘加载缓存的分页元数据,返回以章节 href 为键的字典。
/// 缓存未命中或反序列化失败时返回 nil。
public func load(key: String) -> [String: RDEPUBTextChapterPaginationCache]? {
queue.sync {
let fileURL = cacheDirectory.appendingPathComponent(key)
@@ -203,7 +169,6 @@ public final class RDEPUBTextBookCache {
}
}
/// 将分页元数据保存到磁盘(原子写入,防止损坏)
public func save(_ chapters: [RDEPUBTextChapterPaginationCache], key: String) {
queue.sync {
let fileURL = cacheDirectory.appendingPathComponent(key)
@@ -223,9 +188,6 @@ public final class RDEPUBTextBookCache {
}
}
// MARK: - 缓存失效
/// 清除所有缓存文件
public func invalidateAll() {
queue.sync {
let fileManager = FileManager.default
@@ -1,93 +1,99 @@
import UIKit
// MARK: - 章节分页诊断数据
/// 单个章节的分页诊断信息,用于调试和质量检测。
/// 记录页数、分页原因、附件/块级元素统计等。
public struct RDEPUBTextChapterPaginationDiagnostic: Equatable {
public var href: String
public var title: String
public var pageCount: Int
/// 每页的分页原因列表
public var breakReasons: [RDEPUBTextPageBreakReason]
/// 包含附件的页数
public var attachmentPageCount: Int
/// 因块级/附件边界调整而分页的页数
public var blockAdjustedPageCount: Int
public var blockKinds: [RDEPUBTextBlockKind]
public var semanticHints: [RDEPUBTextSemanticHint]
public var attachmentPlacements: [RDEPUBTextAttachmentPlacement]
/// 采样诊断日志(最多 4 条)
public var sampleNotes: [String]
}
// MARK: - 章节构建结果
/// 单章构建结果,用于大书快速进入阅读器和后台增量补齐。
public struct RDEPUBTextChapterBuildResult {
public var chapter: RDEPUBTextChapter
public var resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic]
public var paginationDiagnostic: RDEPUBTextChapterPaginationDiagnostic
public var performanceSample: RDEPUBTextPerformanceSample
public var cacheHit: Bool
}
// MARK: - 页面数据模型
/// 分页后的单页数据,包含全书绝对页码、所属章节、内容范围等。
///
/// 每个 `RDEPUBTextPage` 由 `RDEPUBTextLayoutFrame` 生成,
/// `contentRange` 标记了该页在章节富文本中的字符范围。
public struct RDEPUBTextPage: Equatable {
/// 全书绝对页码(从 0 开始)
public var absolutePageIndex: Int
public var chapterIndex: Int
public var spineIndex: Int
public var href: String
public var chapterTitle: String
/// 该页在所属章节中的相对页码(从 0 开始)
public var pageIndexInChapter: Int
public var totalPagesInChapter: Int
/// 所属章节的完整富文本内容(用于跨页查询)
public var chapterContent: NSAttributedString
/// 该页的富文本片段
public var content: NSAttributedString
/// 该页在章节富文本中的范围
public var contentRange: NSRange
public var pageStartOffset: Int
public var pageEndOffset: Int
/// 页的元数据(分页原因、附件、语义标记等)
public var metadata: RDEPUBTextPageMetadata
}
// MARK: - 章节数据模型
/// 渲染并分页后的单个章节,包含完整富文本和分页结果。
public struct RDEPUBTextChapter: Equatable {
public var chapterIndex: Int
public var spineIndex: Int
public var href: String
public var title: String
/// 章节完整富文本内容
public var attributedContent: NSAttributedString
/// fragment ID → 字符偏移量映射(用于锚点定位)
public var fragmentOffsets: [String: Int]
public var cfiMap: RDEPUBCFIMap?
public var pageBreakReasons: [RDEPUBTextPageBreakReason]
public var pages: [RDEPUBTextPage]
}
// MARK: - 分页书籍模型
/// 整本 EPUB 的分页书籍模型,包含所有章节、页面和全局索引表。
///
/// 这是 EPUBTextRendering 层的最终产物,由 `RDEPUBTextBookBuilder.build()` 生成。
/// 通过 `chapterData(for:)` 或 `chapterData(atChapterIndex:)` 获取 `RDEPUBChapterData` 进行查询。
public struct RDEPUBTextBook {
public var chapters: [RDEPUBTextChapter]
public var pages: [RDEPUBTextPage]
/// 全局索引表:fileIndex/row/column → 绝对字符偏移量
public let indexTable: RDEPUBTextIndexTable
/// 对标 WXRead 的位置转换器(文件位置 <-> 全书字符位置 <-> 页码)
public var positionConverter: RDEPUBTextPositionConverter {
RDEPUBTextPositionConverter(book: self)
}
@@ -102,31 +108,26 @@ public struct RDEPUBTextBook {
lhs.chapters == rhs.chapters && lhs.pages == rhs.pages
}
/// 按 href 获取章节的数据访问层(包含索引表)
public func chapterData(for href: String) -> RDEPUBChapterData? {
guard let chapter = chapters.first(where: { $0.href == href }) else { return nil }
return RDEPUBChapterData(chapter: chapter, indexTable: indexTable)
}
/// 按 spine 索引获取章节的数据访问层。
public func chapterData(forSpineIndex spineIndex: Int) -> RDEPUBChapterData? {
guard let chapter = chapters.first(where: { $0.spineIndex == spineIndex }) else { return nil }
return RDEPUBChapterData(chapter: chapter, indexTable: indexTable)
}
/// 按章节序号获取章节的数据访问层
public func chapterData(atChapterIndex index: Int) -> RDEPUBChapterData? {
guard chapters.indices.contains(index) else { return nil }
return RDEPUBChapterData(chapter: chapters[index], indexTable: indexTable)
}
/// 按绝对页码(从 1 开始)获取章节的数据访问层。
public func chapterData(forPageNumber pageNumber: Int) -> RDEPUBChapterData? {
guard let page = page(at: pageNumber) else { return nil }
return chapterData(forSpineIndex: page.spineIndex)
}
/// 根据持久化位置解析所属章节的数据访问层。
public func chapterData(
for location: RDEPUBLocation,
resolver: RDEPUBResourceResolver,
@@ -138,7 +139,6 @@ public struct RDEPUBTextBook {
return chapterData(for: normalizedLocation.href)
}
/// 全书章节信息快照;对标 WXRead 由章节模型直接提供章节元数据。
public var chapterInfos: [EPUBChapterInfo] {
chapters.map { chapter in
EPUBChapterInfo(
@@ -149,7 +149,6 @@ public struct RDEPUBTextBook {
}
}
/// 按页码(从 1 开始)获取对应页面
public func page(at pageNumber: Int) -> RDEPUBTextPage? {
guard pageNumber > 0, pages.indices.contains(pageNumber - 1) else {
return nil
@@ -157,12 +156,6 @@ public struct RDEPUBTextBook {
return pages[pageNumber - 1]
}
/// 根据持久化位置计算对应页码(从 1 开始)。
///
/// 解析优先级:
/// 1. rangeAnchor 锚点定位
/// 2. fragment 片段 ID
/// 3. navigationProgression 进度百分比回退
public func pageNumber(for location: RDEPUBLocation, resolver: RDEPUBResourceResolver, bookIdentifier: String?) -> Int? {
guard let normalizedLocation = resolver.normalizedLocation(location, bookIdentifier: bookIdentifier),
let chapterData = chapterData(for: normalizedLocation.href) else {
@@ -182,7 +175,6 @@ public struct RDEPUBTextBook {
return chapterData.pageNumber(for: normalizedLocation)
}
/// 根据页码生成持久化位置(RDEPUBLocation),包含起止锚点
public func location(forPageNumber pageNumber: Int, bookIdentifier: String?) -> RDEPUBLocation? {
guard let chapterData = chapterData(forPageNumber: pageNumber),
let page = page(at: pageNumber) else {
@@ -1,10 +1,8 @@
// RDEPUBTextBuildPipelineInterfaces.swift
// EPUB 文本构建管线接口定义,包括全书构建、章节渲染与分页管线。
import UIKit
/// EPUB 全书文本构建接口,将 EPUB 出版物解析为可分页的文本书籍。
protocol RDEPUBTextBookBuilding {
func build(
parser: RDEPUBParser,
publication: RDEPUBPublication,
@@ -13,41 +11,25 @@ protocol RDEPUBTextBookBuilding {
) throws -> RDEPUBTextBook
}
/// 章节渲染管线,将章节渲染请求委托给底层文本渲染器。
struct RDEPUBChapterRenderPipeline {
private let renderer: RDEPUBTextRenderer
/// 初始化渲染管线。
/// - Parameter renderer: 文本渲染器实例
init(renderer: RDEPUBTextRenderer) {
self.renderer = renderer
}
/// 渲染单个章节,返回渲染后的富文本内容。
/// - Parameter request: 章节渲染请求
/// - Returns: 渲染完成的章节内容
func render(_ request: RDEPUBTextChapterRenderRequest) throws -> RDEPUBRenderedChapterContent {
try renderer.renderChapter(request: request)
}
}
/// 章节分页管线,将渲染后的富文本按页面尺寸拆分为文本帧数组。
struct RDEPUBChapterPaginationPipeline {
private let frameFactory: RDEPUBPageFrameBuilding
/// 初始化分页管线。
/// - Parameter frameFactory: 页面帧工厂,默认使用 CoreText 实现
init(frameFactory: RDEPUBPageFrameBuilding = RDEPUBCoreTextPageFrameFactory()) {
self.frameFactory = frameFactory
}
/// 将富文本内容分页,返回页面帧数组。
/// - Parameters:
/// - content: 待分页的富文本
/// - pageSize: 页面尺寸
/// - config: 排版配置
/// - fragmentOffsets: fragment 锚点偏移映射
/// - Returns: 页面帧数组
func frames(
for content: NSAttributedString,
pageSize: CGSize,
@@ -1,20 +1,17 @@
import Foundation
// MARK: - 性能采样数据模型
/// 单个章节的性能采样数据,记录渲染和分页的耗时。
public struct RDEPUBTextPerformanceSample: Equatable {
/// 章节文件路径
public var chapterHref: String
/// HTML 渲染耗时(秒)
public var renderDuration: TimeInterval
/// CoreText 分页耗时(秒)
public var paginateDuration: TimeInterval
/// 分页后的页数
public var pageCount: Int
/// 富文本字符长度
public var attributedStringLength: Int
/// 是否命中分页缓存
public var cacheHit: Bool
public init(
@@ -34,21 +31,14 @@ public struct RDEPUBTextPerformanceSample: Equatable {
}
}
// MARK: - 性能采样器
/// 书籍构建过程的性能采样器,用于监控每章的渲染和分页耗时。
///
/// 由 `RDEPUBTextBookBuilder` 在构建过程中使用,每章记录一个采样点,
/// 构建完成后输出汇总报告。
public final class RDEPUBTextPerformanceSampler {
/// 所有章节的采样数据列表
public private(set) var samples: [RDEPUBTextPerformanceSample] = []
/// 整本书构建的总耗时(秒)
public var totalBuildDuration: TimeInterval = 0
public init() {}
/// 记录单个章节的性能采样,并输出日志
public func record(_ sample: RDEPUBTextPerformanceSample) {
samples.append(sample)
#if DEBUG
@@ -56,7 +46,6 @@ public final class RDEPUBTextPerformanceSampler {
#endif
}
/// 生成性能汇总报告,包含总渲染/分页耗时和缓存命中率
public func summary() -> String {
let totalRender = samples.reduce(0) { $0 + $1.renderDuration }
let totalPaginate = samples.reduce(0) { $0 + $1.paginateDuration }
@@ -64,13 +53,11 @@ public final class RDEPUBTextPerformanceSampler {
return "[PERF] chapters=\(samples.count) render=\(formatMS(totalRender)) paginate=\(formatMS(totalPaginate)) total=\(formatMS(totalBuildDuration)) cacheHits=\(hitCount)/\(samples.count)"
}
/// 重置所有采样数据
public func reset() {
samples.removeAll()
totalBuildDuration = 0
}
/// 将秒转换为毫秒格式字符串
private func formatMS(_ duration: TimeInterval) -> String {
String(format: "%.0fms", duration * 1000)
}
@@ -5,24 +5,20 @@ import UIKit
import DTCoreText
#endif
/// 章节分页计数器:顶层编排循环,将富文本按页面尺寸拆分为多帧。
///
/// 分页策略优先级(从高到低):
/// 1. avoidPageBreakInside — 不在保护块内分页(WXRead 行级回退扫描)
/// 2. keepWithNext — 标题等元素需与下一段同页
/// 3. 语义边界 — pageBreakBefore/After 等显式分页标记
/// 4. pageRelate — 微信读书式的跨页关联元素
/// 5. 附件边界 — 块级附件应整体移到下一页
/// 6. 帧限制 — 默认按 CoreText 可视范围分页
struct RDEPUBChapterPageCounter {
private let factory: RDEPUBCoreTextPageFrameFactory
private let attributedString: NSAttributedString
private let pageSize: CGSize
private let config: RDEPUBTextLayoutConfig
private let pageBreakPolicy: RDEPUBPageBreakPolicy
private let framesetter: CTFramesetter
/// DTCoreText 路径可直接消费的单矩形布局区域
private let dtLayoutRect: CGRect
init(factory: RDEPUBCoreTextPageFrameFactory) {
@@ -35,7 +31,6 @@ struct RDEPUBChapterPageCounter {
self.dtLayoutRect = factory.config.contentRect(fallback: factory.pageSize)
}
/// 执行分页,返回布局帧列表(每帧对应一页)。
func layoutFrames(fragmentOffsets: [String: Int] = [:]) -> [RDEPUBTextLayoutFrame] {
guard attributedString.length > 0, pageSize.width > 0, pageSize.height > 0 else {
return []
@@ -48,8 +43,6 @@ struct RDEPUBChapterPageCounter {
#endif
}
// MARK: - CoreText 分页路径(回退方案)
private func layoutFramesUsingCoreText(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame] {
guard attributedString.length > 0, pageSize.width > 0, pageSize.height > 0 else {
return []
@@ -129,9 +122,8 @@ struct RDEPUBChapterPageCounter {
return frames
}
// MARK: - DTCoreText 分页路径(首选方案)
#if canImport(DTCoreText)
private func layoutFramesUsingDTCoreText(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame] {
guard config.numberOfColumns == 1 else {
return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets)
@@ -252,9 +244,6 @@ struct RDEPUBChapterPageCounter {
}
#endif
// MARK: - WXRead 分页对齐
/// 读取 CoreText frame 当前可见的字符范围;多栏 path 下也能返回整页可见区。
private func proposedVisibleRange(
from frame: CTFrame,
start location: Int,
@@ -5,13 +5,14 @@ import UIKit
import DTCoreText
#endif
/// CoreText 帧工厂:负责帧创建、行级裁剪、属性查询和诊断构建。
///
/// 叶节点组件,被 RDEPUBChapterPageCounter 和 RDEPUBPageBreakPolicy 调用。
struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
let attributedString: NSAttributedString
let pageSize: CGSize
let config: RDEPUBTextLayoutConfig
private let pageBreakPolicy: RDEPUBPageBreakPolicy
init(attributedString: NSAttributedString, pageSize: CGSize, config: RDEPUBTextLayoutConfig = .default) {
@@ -21,13 +22,10 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
self.pageBreakPolicy = RDEPUBPageBreakPolicy(attributedString: attributedString)
}
/// 协议要求的便捷初始化(使用默认配置)
init() {
self.init(attributedString: NSAttributedString(), pageSize: .zero, config: .default)
}
// MARK: - RDEPUBPageFrameBuilding 协议
func makeFrames(
attributedString: NSAttributedString,
pageSize: CGSize,
@@ -39,9 +37,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
return counter.layoutFrames(fragmentOffsets: fragmentOffsets)
}
// MARK: - 帧构建
/// 从列矩形构建 CGPath(用于 CTFramesetterCreateFrame)。
static func makeLayoutPath(pageSize: CGSize, config: RDEPUBTextLayoutConfig) -> CGPath {
let columnRects = config.columnRects(fallback: pageSize)
guard columnRects.count > 1 else {
@@ -55,9 +50,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
return path
}
// MARK: - 行级裁剪
/// 从 CTFrame 最后一行向前扫描,移除落在 avoidPageBreakInside 保护块内的尾部行。
func trimmedRangeForAvoidPageBreakInside(
from frame: CTFrame,
proposed: NSRange
@@ -74,7 +66,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
return trimmedRangeForAvoidPageBreakInside(proposed: proposed, lineRanges: lineRanges)
}
/// CoreText 路径的 keepWithNext 处理:从最后行向前扫描
func trimmedRangeForKeepWithNext(
from frame: CTFrame,
proposed: NSRange
@@ -88,7 +79,7 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
}
#if canImport(DTCoreText)
/// DTCoreText 路径的 avoidPageBreakInside 处理
func trimmedRangeForAvoidPageBreakInside(
from layoutFrame: DTCoreTextLayoutFrame,
proposed: NSRange
@@ -102,7 +93,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
return trimmedRangeForAvoidPageBreakInside(proposed: proposed, lineRanges: lineRanges)
}
/// DTCoreText 路径的 keepWithNext 处理
func trimmedRangeForKeepWithNext(
from layoutFrame: DTCoreTextLayoutFrame,
proposed: NSRange
@@ -115,7 +105,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
}
#endif
/// 从最后行向前扫描,移除落在 avoidPageBreakInside 保护块内的尾部行(通用路径)。
func trimmedRangeForAvoidPageBreakInside(
proposed: NSRange,
lineRanges: [NSRange]
@@ -155,7 +144,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
return NSRange(location: proposed.location, length: adjustedLength)
}
/// 从最后行向前扫描,移除落在 keepWithNext 保护块内的尾部行。
func trimmedRangeForKeepWithNext(
proposed: NSRange,
lineRanges: [NSRange]
@@ -195,7 +183,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
return NSRange(location: proposed.location, length: adjustedLength)
}
/// 根据 avoidWidows / avoidOrphans 修正页尾断点,避免段首孤悬页尾或段末独悬下一页。
func trimmedRangeForWidowAndOrphanControl(
proposed: NSRange,
lineRanges: [NSRange]
@@ -227,9 +214,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
return adjusted
}
// MARK: - 行信息提取
/// 获取 CTFrame 中所有行的字符范围
static func lineRanges(from frame: CTFrame) -> [NSRange] {
let lines = CTFrameGetLines(frame) as! [CTLine]
return lines.map {
@@ -239,7 +223,7 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
}
#if canImport(DTCoreText)
/// 获取 DTCoreTextLayoutFrame 中所有行的字符范围
static func lineRanges(from layoutFrame: DTCoreTextLayoutFrame) -> [NSRange] {
guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine] else {
return []
@@ -248,9 +232,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
}
#endif
// MARK: - 属性查询
/// 获取指定位置的块级元素范围
func blockRange(at location: Int) -> NSRange? {
guard location >= 0, location < attributedString.length else { return nil }
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
@@ -260,7 +241,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
return nil
}
/// 获取指定位置的块级元素类型
func blockKind(at location: Int) -> RDEPUBTextBlockKind? {
guard location >= 0, location < attributedString.length else { return nil }
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
@@ -268,7 +248,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
return RDEPUBTextBlockKind(rawValue: rawValue)
}
/// 获取指定位置的附件布局方式
func attachmentPlacement(at location: Int) -> RDEPUBTextAttachmentPlacement? {
guard location >= 0, location < attributedString.length else { return nil }
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
@@ -276,7 +255,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
return RDEPUBTextAttachmentPlacement(rawValue: rawValue)
}
/// 获取包含指定位置的段落范围
func paragraphRange(containing location: Int) -> NSRange {
let source = attributedString.string as NSString
guard source.length > 0 else { return NSRange(location: 0, length: 0) }
@@ -284,7 +262,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
return source.paragraphRange(for: NSRange(location: safeLocation, length: 0))
}
/// 获取指定范围内的所有附件字符范围
func attachmentRanges(in range: NSRange) -> [NSRange] {
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
return []
@@ -297,7 +274,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
return results
}
/// 获取指定位置的语义提示列表
func semanticHints(at location: Int) -> [RDEPUBTextSemanticHint] {
guard location >= 0, location < attributedString.length else { return [] }
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
@@ -307,8 +283,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
}
// MARK: - Widow / Orphan 控制
private func trimmedRangeAvoidingWidow(
proposed: NSRange,
lineRanges: [NSRange]
@@ -408,7 +382,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
return lineCount
}
/// 获取指定范围内的附件类型列表(去重)
func attachmentKinds(in range: NSRange) -> [RDEPUBTextAttachmentKind] {
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
return []
@@ -425,7 +398,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
return kinds
}
/// 获取指定范围内的块级元素类型列表(去重)
func blockKinds(in range: NSRange) -> [RDEPUBTextBlockKind] {
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
return []
@@ -442,7 +414,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
return kinds
}
/// 获取指定范围内的语义提示列表(去重)
func semanticHints(in range: NSRange) -> [RDEPUBTextSemanticHint] {
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
return []
@@ -457,7 +428,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
return hints
}
/// 获取指定范围内的附件布局方式列表(去重)
func attachmentPlacements(in range: NSRange) -> [RDEPUBTextAttachmentPlacement] {
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
return []
@@ -474,7 +444,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
return placements
}
/// 将范围裁剪到 attributedString 的合法边界内。
func clampedRange(_ range: NSRange) -> NSRange? {
guard range.location >= 0, range.length >= 0 else { return nil }
guard attributedString.length > 0 else {
@@ -486,7 +455,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
return NSRange(location: range.location, length: min(range.length, maxLength))
}
/// 查找指定位置之前最近的 fragment ID(用于阅读位置恢复)
func nearestTrailingFragmentID(
endingAt location: Int,
fragmentOffsets: [String: Int]
@@ -497,9 +465,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
.key
}
// MARK: - 诊断日志
/// 生成分页诊断日志
func diagnostics(
reason: RDEPUBTextPageBreakReason,
range: NSRange,
@@ -1,19 +1,14 @@
import Foundation
import UIKit
/// 分页规则策略:语义边界搜索、行级保护检查、范围调整调度器。
///
/// 不构建 CoreText frame,只基于 attributed string 属性做规则判定。
struct RDEPUBPageBreakPolicy {
private let attributedString: NSAttributedString
init(attributedString: NSAttributedString) {
self.attributedString = attributedString
}
// MARK: - 行级保护检查
/// 检查指定行范围是否落在 avoidPageBreakInside 保护块内。
func lineIsInAvoidPageBreakInsideBlock(_ lineRange: NSRange) -> Bool {
guard let probeRange = clampedProbeRange(for: lineRange) else {
return false
@@ -35,7 +30,6 @@ struct RDEPUBPageBreakPolicy {
return found
}
/// 检查指定行范围是否落在 keepWithNext 保护块内。
func lineIsInKeepWithNextBlock(_ lineRange: NSRange) -> Bool {
guard let probeRange = clampedProbeRange(for: lineRange) else {
return false
@@ -54,14 +48,6 @@ struct RDEPUBPageBreakPolicy {
return found
}
// MARK: - 范围调整调度器
/// 对 CoreText/DTCoreText 提出的分页范围进行语义边界调整。
///
/// 调整优先级:
/// 1. 若已达章节末尾,直接返回 chapterEnd
/// 2. pageRelate 跨页关联边界
/// 3. 以上都不满足时,使用原始帧限制分页
func adjustedRange(
from proposedRange: NSRange,
totalLength: Int,
@@ -111,8 +97,6 @@ struct RDEPUBPageBreakPolicy {
let currentSemanticHints = proposedSemanticHints
let currentAttachmentPlacements = proposedAttachmentPlacements
// 对齐 WXRead:默认按 CTFrame 已经容纳的行数分页,仅保留 pageRelate 这种
// 微信读书特有的跨页关联规则。
if let pageRelateBoundary = preferredPageRelateBoundary(
after: proposedRange,
minimumEnd: proposedRange.location + 1,
@@ -142,7 +126,6 @@ struct RDEPUBPageBreakPolicy {
)
}
// 帧限制(默认分页)
return (
range: proposedRange,
breakReason: .frameLimit,
@@ -164,9 +147,6 @@ struct RDEPUBPageBreakPolicy {
)
}
// MARK: - 语义边界查找
/// 查找 pageRelate 跨页关联边界。
func preferredPageRelateBoundary(
after range: NSRange,
minimumEnd: Int,
@@ -192,8 +172,6 @@ struct RDEPUBPageBreakPolicy {
return lastLineStart
}
// MARK: - 内部工具
private func shouldTreatAvoidHintAsBlockProtection(_ attributes: [NSAttributedString.Key: Any]) -> Bool {
guard let rawValue = attributes[.rdPageSemanticHints] as? String else {
return false
@@ -1,33 +1,27 @@
import Foundation
/// CoreText 分页引擎产生的单帧(单页)布局数据。
///
/// 由 `RDEPUBTextLayouter` 在分页过程中生成,记录了该页的内容范围、分页原因、
/// 附件信息和语义标记。`RDEPUBTextBookBuilder` 会将 `RDEPUBTextLayoutFrame` 转换为
/// `RDEPUBTextPage`,并纳入最终的 `RDEPUBTextBook` 模型。
struct RDEPUBTextLayoutFrame: Equatable {
/// 该帧在章节富文本中的字符范围
var contentRange: NSRange
/// 分页原因(语义边界、附件边界、帧限制等)
var breakReason: RDEPUBTextPageBreakReason
/// 所属 HTML 块级元素的范围(用于调试)
var blockRange: NSRange?
/// 帧内附件的字符范围列表
var attachmentRanges: [NSRange]
/// 帧内附件类型列表(图片、通用附件等)
var attachmentKinds: [RDEPUBTextAttachmentKind]
/// 帧内包含的 HTML 块级元素类型列表(段落、列表、表格等)
var blockKinds: [RDEPUBTextBlockKind]
/// 帧内语义提示列表(避免分页、保持与下一段同页等)
var semanticHints: [RDEPUBTextSemanticHint]
/// 帧内附件的布局方式(行内、基线、居中)
var attachmentPlacements: [RDEPUBTextAttachmentPlacement]
/// 帧尾部最近的 fragment ID(用于恢复阅读位置)
var trailingFragmentID: String?
/// 诊断日志列表(分页原因详情、范围信息等)
var diagnostics: [String]
/// 将布局帧数据转换为页元数据模型
var metadata: RDEPUBTextPageMetadata {
RDEPUBTextPageMetadata(
breakReason: breakReason,
@@ -5,12 +5,6 @@ import UIKit
import DTCoreText
#endif
/// CoreText 分页引擎 Facade:将富文本按页面尺寸拆分为多帧(每帧对应一页)。
///
/// 内部委托给三个组件:
/// - RDEPUBCoreTextPageFrameFactory — 帧创建、属性查询、诊断
/// - RDEPUBPageBreakPolicy — 分页规则(语义保护、keepWithNext)
/// - RDEPUBChapterPageCounter — 顶层分页循环编排
struct RDEPUBTextLayouter {
private let counter: RDEPUBChapterPageCounter
@@ -19,7 +13,6 @@ struct RDEPUBTextLayouter {
self.counter = RDEPUBChapterPageCounter(factory: factory)
}
/// 执行分页,返回布局帧列表(每帧对应一页)。
func layoutFrames(fragmentOffsets: [String: Int] = [:]) -> [RDEPUBTextLayoutFrame] {
counter.layoutFrames(fragmentOffsets: fragmentOffsets)
}
@@ -1,30 +1,18 @@
// RDEPUBTextPaginationInterfaces.swift
// EPUB 分页接口定义,包括断页决策、章节数页和页面帧构建协议。
import Foundation
import UIKit
// MARK: - 分页接口定义
/// 单次断页决策,记录断页位置、原因及诊断信息。
struct RDEPUBPageBreakDecision {
/// 本页在富文本中的字符范围
var range: NSRange
/// 断页原因
var reason: RDEPUBTextPageBreakReason
/// 分页过程中的诊断日志
var diagnostics: [String]
}
/// 章节页数计算接口,将富文本按页面尺寸拆分为断页决策序列。
protocol RDEPUBChapterPageCounting {
/// 计算章节中每页的断页位置。
/// - Parameters:
/// - attributedString: 章节富文本
/// - pageSize: 页面尺寸
/// - config: 排版配置
/// - fragmentOffsets: fragment 锚点偏移映射
/// - Returns: 断页决策数组,每个元素对应一页
func pageRanges(
for attributedString: NSAttributedString,
pageSize: CGSize,
@@ -33,15 +21,8 @@ protocol RDEPUBChapterPageCounting {
) -> [RDEPUBPageBreakDecision]
}
/// 页面帧构建接口,将富文本转换为可渲染的页面帧数组。
protocol RDEPUBPageFrameBuilding {
/// 将富文本构建为页面帧数组。
/// - Parameters:
/// - attributedString: 待分页的富文本
/// - pageSize: 页面尺寸
/// - config: 排版配置
/// - fragmentOffsets: fragment 锚点偏移映射
/// - Returns: 页面帧数组
func makeFrames(
attributedString: NSAttributedString,
pageSize: CGSize,
@@ -1,18 +1,8 @@
import CoreText
import UIKit
// MARK: - NSAttributedString 分页扩展
/// 为 NSAttributedString 提供便捷的分页方法,是渲染链路中 CoreText 分页的入口点。
extension NSAttributedString {
/// 将富文本按指定页面尺寸分页,返回布局帧列表(每帧对应一页)。
///
/// 这是 `RDEPUBTextBookBuilder` 和 `RDPlainTextBookBuilder` 调用的核心分页方法。
/// - Parameters:
/// - size: 页面尺寸
/// - fragmentOffsets: fragment ID → 字符偏移量映射(用于阅读位置恢复)
/// - config: 布局配置(孤行控制、avoidPageBreakInside 等)
/// - Returns: 分页后的布局帧列表
func rd_paginatedFrames(
size: CGSize,
fragmentOffsets: [String: Int] = [:],
@@ -23,17 +13,13 @@ extension NSAttributedString {
return counter.layoutFrames(fragmentOffsets: fragmentOffsets)
}
/// 简化版分页:只返回每页的 NSRange 列表(不含语义元数据)
func ss_pageRanges(size: CGSize) -> [NSRange] {
rd_paginatedFrames(size: size).map(\.contentRange)
}
}
// MARK: - UIColor CSS 转换扩展
/// 为 UIColor 提供 CSS 颜色字符串转换,用于动态生成暗色模式 CSS。
extension UIColor {
/// 将 UIColor 转换为 CSS rgba() 字符串格式
var ss_cssString: String {
var red: CGFloat = 0
var green: CGFloat = 0
@@ -1,14 +1,9 @@
import UIKit
/// 章节数据访问层:为已分页章节提供便捷的查询接口。
///
/// 本类是 `RDEPUBTextChapter` 的轻量级封装,持有 `indexTable`(全局索引表),
/// 将分页数据、fragment 锚点、高亮、搜索结果等统一到同一套查询 API 中。
/// 由 `RDEPUBTextBook.chapterData(for:)` 或 `chapterData(atChapterIndex:)` 创建。
public final class RDEPUBChapterData {
/// 底层章节模型(只读)
public let chapter: RDEPUBTextChapter
/// 全局索引表,用于 fileIndex/row/column 到绝对偏移量的映射
public let indexTable: RDEPUBTextIndexTable
public init(chapter: RDEPUBTextChapter, indexTable: RDEPUBTextIndexTable) {
@@ -16,88 +11,66 @@ public final class RDEPUBChapterData {
self.indexTable = indexTable
}
// MARK: - 便捷属性(转发自 chapter)
/// 章节在全书中的序号
public var chapterIndex: Int { chapter.chapterIndex }
/// spine 顺序索引
public var spineIndex: Int { chapter.spineIndex }
/// 章节文件相对路径(如 "OEBPS/chapter1.xhtml")
public var href: String { chapter.href }
/// 章节标题
public var title: String { chapter.title }
/// 章节完整的富文本内容
public var attributedContent: NSAttributedString { chapter.attributedContent }
/// 分页后的页面列表
public var pages: [RDEPUBTextPage] { chapter.pages }
/// 章节总页数
public var pageCount: Int { chapter.pages.count }
/// fragment ID → 字符偏移量的映射表(用于锚点定位)
public var fragmentOffsets: [String: Int] { chapter.fragmentOffsets }
/// 章节信息快照(供阅读会话/目录面板复用)
public var chapterInfo: EPUBChapterInfo {
EPUBChapterInfo(spineIndex: spineIndex, title: title, pageCount: pageCount)
}
/// 章节覆盖的绝对页码范围(闭区间)
public var absolutePageRange: ClosedRange<Int>? {
guard let firstPage = pages.first, let lastPage = pages.last else { return nil }
return firstPage.absolutePageIndex...lastPage.absolutePageIndex
}
// MARK: - 页面查询
/// 查找包含指定绝对字符偏移量的页面
/// - Parameter absoluteOffset: 全书绝对字符偏移量
/// - Returns: 包含该偏移量的页面,未找到返回 nil
public func page(containing absoluteOffset: Int) -> RDEPUBTextPage? {
chapter.pages.first { NSLocationInRange(absoluteOffset, $0.contentRange) }
}
/// 获取指定绝对偏移量所在页的绝对页码(从 0 开始)
public func pageNumber(containing absoluteOffset: Int) -> Int? {
page(containing: absoluteOffset)?.absolutePageIndex
}
/// 按绝对页码查找页面
public func page(atAbsolutePageIndex absolutePageIndex: Int) -> RDEPUBTextPage? {
chapter.pages.first { $0.absolutePageIndex == absolutePageIndex }
}
/// 按章节内页码查找页面(从 1 开始)
public func page(atPageNumber pageNumber: Int) -> RDEPUBTextPage? {
guard pageNumber > 0, pages.indices.contains(pageNumber - 1) else { return nil }
return pages[pageNumber - 1]
}
// MARK: - 锚点与位置映射
/// 将章节内字符索引转换为语义锚点(fileIndex/row/column 三元组)
public func anchor(forAbsoluteIndex index: Int) -> RDEPUBTextAnchor {
indexTable.anchor(forAbsoluteIndex: index, in: chapter)
}
/// 将全书字符索引转换为语义锚点。
public func anchor(forGlobalIndex index: Int) -> RDEPUBTextAnchor? {
indexTable.anchor(forGlobalIndex: index)
}
/// 将章节内字符范围转换为起止锚点对
public func rangeAnchor(for absoluteRange: NSRange) -> RDEPUBTextRangeAnchor {
let start = anchor(forAbsoluteIndex: absoluteRange.location)
let end = anchor(forAbsoluteIndex: absoluteRange.location + absoluteRange.length)
return RDEPUBTextRangeAnchor(start: start, end: end)
}
/// 将锚点范围转换为全书字符范围。
public func globalRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange {
indexTable.globalRange(for: rangeAnchor)
}
/// 从绝对字符范围构建选区对象(用于复制/高亮分享)
/// - Parameters:
/// - absoluteRange: 选区在全书中的字符范围
/// - bookIdentifier: 书籍标识符
/// - Returns: 选区对象,若起始偏移量不在任何页面内则返回 nil
public func selection(from absoluteRange: NSRange, bookIdentifier: String?) -> RDEPUBSelection? {
guard page(containing: absoluteRange.location) != nil else { return nil }
let location = self.location(for: absoluteRange, bookIdentifier: bookIdentifier)
@@ -115,7 +88,6 @@ public final class RDEPUBChapterData {
)
}
/// 将绝对字符范围转换为持久化位置对象(RDEPUBLocation)
public func location(for absoluteRange: NSRange, bookIdentifier: String?) -> RDEPUBLocation {
indexTable.location(
for: rangeAnchor(for: absoluteRange),
@@ -124,32 +96,31 @@ public final class RDEPUBChapterData {
)
}
/// 将页面转换为对应的 RDEPUBLocation
public func location(forPage page: RDEPUBTextPage, bookIdentifier: String?) -> RDEPUBLocation {
location(for: page.contentRange, bookIdentifier: bookIdentifier)
}
/// 根据持久化位置定位所属页面。
public func page(for location: RDEPUBLocation) -> RDEPUBTextPage? {
guard let range = absoluteRange(for: location) else { return nil }
return page(containing: range.location)
}
/// 根据搜索结果定位所属页面。
public func page(for searchMatch: RDEPUBSearchMatch) -> RDEPUBTextPage? {
guard let range = absoluteRange(for: searchMatch) else { return nil }
return page(containing: range.location)
}
// MARK: - 位置反向解析(Location → 绝对偏移量)
/// 将持久化位置还原为章节内的字符范围。
///
/// 解析优先级:
/// 1. rangeAnchor(锚点定位,最精确)
/// 2. fragment(片段 ID 定位)
/// 3. navigationProgression(进度百分比回退)
public func absoluteRange(for location: RDEPUBLocation) -> NSRange? {
if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(location.rangeCFI),
let rangeAnchor = indexTable.rangeAnchor(for: cfiRange) {
return indexTable.chapterRange(for: rangeAnchor)
}
if let cfi = RDEPUBCFICompatibility.parseLossy(location.cfi),
let anchor = indexTable.anchor(for: cfi) {
return NSRange(location: indexTable.chapterOffset(for: anchor), length: 1)
}
if let rangeAnchor = location.rangeAnchor {
return indexTable.chapterRange(for: rangeAnchor)
}
@@ -164,16 +135,35 @@ public final class RDEPUBChapterData {
return NSRange(location: offset, length: 1)
}
/// 从高亮的持久化位置还原绝对字符范围
public func absoluteRange(for highlight: RDEPUBHighlight) -> NSRange? {
if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(highlight.location.rangeCFI),
let rangeAnchor = indexTable.rangeAnchor(for: cfiRange) {
return indexTable.chapterRange(for: rangeAnchor)
}
if let endpointRange = chapterRange(fromLocationCFIEndpoints: highlight.location) {
return endpointRange
}
if let rangeAnchor = highlight.location.rangeAnchor {
return indexTable.chapterRange(for: rangeAnchor)
}
return RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange
}
/// 从搜索结果还原绝对字符范围
public func absoluteRange(for searchMatch: RDEPUBSearchMatch) -> NSRange? {
if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(searchMatch.rangeCFI),
let rangeAnchor = indexTable.rangeAnchor(for: cfiRange) {
return indexTable.chapterRange(for: rangeAnchor)
}
if let cfi = RDEPUBCFICompatibility.parseLossy(searchMatch.cfi),
let anchor = indexTable.anchor(for: cfi) {
let location = indexTable.chapterOffset(for: anchor)
return NSRange(
location: location,
length: recoveredSearchRangeLength(for: searchMatch, cfi: cfi, startOffset: location)
)
}
if let location = searchMatch.rangeLocation {
return NSRange(location: location, length: max(searchMatch.rangeLength, 1))
}
@@ -183,8 +173,17 @@ public final class RDEPUBChapterData {
return nil
}
/// 将持久化位置还原为全书字符范围。
public func globalRange(for location: RDEPUBLocation) -> NSRange? {
if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(location.rangeCFI),
let rangeAnchor = indexTable.rangeAnchor(for: cfiRange) {
return indexTable.globalRange(for: rangeAnchor)
}
if let cfi = RDEPUBCFICompatibility.parseLossy(location.cfi),
let anchor = indexTable.anchor(for: cfi) {
return NSRange(location: indexTable.globalIndex(for: anchor), length: 1)
}
if let rangeAnchor = location.rangeAnchor {
return indexTable.globalRange(for: rangeAnchor)
}
@@ -195,8 +194,16 @@ public final class RDEPUBChapterData {
return NSRange(location: chapterStart + chapterRange.location, length: chapterRange.length)
}
/// 从高亮的持久化位置还原全书字符范围。
public func globalRange(for highlight: RDEPUBHighlight) -> NSRange? {
if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(highlight.location.rangeCFI),
let rangeAnchor = indexTable.rangeAnchor(for: cfiRange) {
return indexTable.globalRange(for: rangeAnchor)
}
if let endpointRange = globalRange(fromLocationCFIEndpoints: highlight.location) {
return endpointRange
}
if let rangeAnchor = highlight.location.rangeAnchor {
return indexTable.globalRange(for: rangeAnchor)
}
@@ -207,8 +214,19 @@ public final class RDEPUBChapterData {
return NSRange(location: chapterStart + chapterRange.location, length: chapterRange.length)
}
/// 从搜索结果还原全书字符范围。
public func globalRange(for searchMatch: RDEPUBSearchMatch) -> NSRange? {
if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(searchMatch.rangeCFI),
let rangeAnchor = indexTable.rangeAnchor(for: cfiRange) {
return indexTable.globalRange(for: rangeAnchor)
}
if let cfi = RDEPUBCFICompatibility.parseLossy(searchMatch.cfi),
let anchor = indexTable.anchor(for: cfi) {
let chapterLocation = indexTable.chapterOffset(for: anchor)
return NSRange(
location: indexTable.globalIndex(for: anchor),
length: recoveredSearchRangeLength(for: searchMatch, cfi: cfi, startOffset: chapterLocation)
)
}
if let rangeAnchor = searchMatch.rangeAnchor {
return indexTable.globalRange(for: rangeAnchor)
}
@@ -219,13 +237,14 @@ public final class RDEPUBChapterData {
return NSRange(location: chapterStart + chapterRange.location, length: chapterRange.length)
}
// MARK: - 高亮/搜索结果与页面的交叉查询
/// 获取指定页面上出现的所有高亮
public func highlights(on page: RDEPUBTextPage, from allHighlights: [RDEPUBHighlight]) -> [RDEPUBHighlight] {
let pageRange = absoluteOffsetRange(for: page)
return allHighlights.filter { highlight in
guard highlight.location.href == chapter.href else { return false }
if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(highlight.location.rangeCFI),
let rangeAnchor = indexTable.rangeAnchor(for: cfiRange) {
return NSIntersectionRange(indexTable.chapterRange(for: rangeAnchor), page.contentRange).length > 0
}
if let anchor = highlight.location.rangeAnchor?.start {
return pageRange.contains(absoluteOffset(for: anchor))
}
@@ -236,7 +255,6 @@ public final class RDEPUBChapterData {
}
}
/// 获取指定页面上出现的所有搜索匹配结果
public func searchMatches(on page: RDEPUBTextPage, from matches: [RDEPUBSearchMatch]) -> [RDEPUBSearchMatch] {
return matches.filter { match in
guard match.href == chapter.href else { return false }
@@ -247,26 +265,20 @@ public final class RDEPUBChapterData {
}
}
/// `searchMatches(on:from:)` 的别名;保持和文档/WXRead 语义一致。
public func searchResults(on page: RDEPUBTextPage, from matches: [RDEPUBSearchMatch]) -> [RDEPUBSearchMatch] {
searchMatches(on: page, from: matches)
}
/// 根据持久化位置获取对应页码(从 1 开始),用于阅读进度跳转
public func pageNumber(for location: RDEPUBLocation) -> Int? {
guard let page = page(for: location) else { return nil }
return page.absolutePageIndex + 1
}
/// 根据搜索结果获取对应页码(从 1 开始)。
public func pageNumber(for searchMatch: RDEPUBSearchMatch) -> Int? {
guard let page = page(for: searchMatch) else { return nil }
return page.absolutePageIndex + 1
}
// MARK: - 目录与章节语义
/// 判断某个 TOC 条目是否属于当前章节。
public func contains(
tableOfContentsItem item: EPUBTableOfContentsItem,
normalizer: (String) -> String?
@@ -278,7 +290,6 @@ public final class RDEPUBChapterData {
return chapterHref == itemHref
}
/// 过滤出当前章节命中的目录条目。
public func tableOfContentsItems(
from items: [EPUBTableOfContentsItem],
normalizer: (String) -> String?
@@ -289,7 +300,6 @@ public final class RDEPUBChapterData {
}
}
/// 当前章节最合适的目录条目(通常取命中的首个最近条目)。
public func primaryTableOfContentsItem(
from items: [EPUBTableOfContentsItem],
normalizer: (String) -> String?
@@ -297,10 +307,6 @@ public final class RDEPUBChapterData {
tableOfContentsItems(from: items, normalizer: normalizer).first
}
// MARK: - 高亮属性注入(对齐 WXRead 的 WRChapterData.addHighlightInRange:key:itemId:color:)
/// 将高亮/下划线作为自定义属性注入 NSAttributedString,
/// 供 CoreText 渲染时读取绘制(对齐 WXRead 的 com.weread.highlight / com.weread.underline)
public func applyHighlights(
to content: NSMutableAttributedString,
page: RDEPUBTextPage,
@@ -324,17 +330,51 @@ public final class RDEPUBChapterData {
}
}
// MARK: - 私有工具方法
/// 将页面的起止偏移量转换为半开区间 [start, end+1)
private func absoluteOffsetRange(for page: RDEPUBTextPage) -> Range<Int> {
let lowerBound = page.pageStartOffset
let upperBound = page.pageEndOffset + 1
return lowerBound..<max(upperBound, lowerBound)
}
/// 将语义锚点转换为章节内字符偏移量,若索引表无法解析则回退到 chapterOffset
private func absoluteOffset(for anchor: RDEPUBTextAnchor) -> Int {
indexTable.chapterOffset(for: anchor)
}
private func chapterRange(fromLocationCFIEndpoints location: RDEPUBLocation) -> NSRange? {
guard let startCFI = RDEPUBCFICompatibility.parseLossy(location.cfi),
let endCFI = RDEPUBCFICompatibility.parseLossy(location.lastCFI ?? location.cfi),
let startAnchor = indexTable.anchor(for: startCFI),
let endAnchor = indexTable.anchor(for: endCFI),
startAnchor.fileIndex == endAnchor.fileIndex else {
return nil
}
let start = indexTable.chapterOffset(for: startAnchor)
let end = indexTable.chapterOffset(for: endAnchor)
return NSRange(location: min(start, end), length: max(abs(end - start), 1))
}
private func globalRange(fromLocationCFIEndpoints location: RDEPUBLocation) -> NSRange? {
guard let startCFI = RDEPUBCFICompatibility.parseLossy(location.cfi),
let endCFI = RDEPUBCFICompatibility.parseLossy(location.lastCFI ?? location.cfi),
let startAnchor = indexTable.anchor(for: startCFI),
let endAnchor = indexTable.anchor(for: endCFI),
startAnchor.fileIndex == endAnchor.fileIndex else {
return nil
}
let start = indexTable.globalIndex(for: startAnchor)
let end = indexTable.globalIndex(for: endAnchor)
return NSRange(location: min(start, end), length: max(abs(end - start), 1))
}
private func recoveredSearchRangeLength(
for searchMatch: RDEPUBSearchMatch,
cfi: RDEPUBCFI,
startOffset: Int
) -> Int {
let exactLength = cfi.textAssertion?.exact?.utf16.count ?? 0
let fallbackLength = max(searchMatch.rangeLength, 1)
let candidateLength = exactLength > 0 ? exactLength : fallbackLength
let remainingLength = max(attributedContent.length - startOffset, 1)
return min(max(candidateLength, 1), remainingLength)
}
}
@@ -4,18 +4,9 @@ import UIKit
import DTCoreText
#endif
/// DTCoreText 渲染器实现:将 EPUB 章节 HTML 转换为富文本(NSAttributedString)。
///
/// 这是 `RDEPUBTextRenderer` 协议的默认实现,位于渲染链路的第二步:
/// 章节 HTML → DTCoreText 渲染 → NSAttributedString → 提取 fragment 偏移量 → 统一字体/行距
///
/// DTCoreText 库负责将 HTML 解析为带有排版属性的富文本,
/// 渲染过程中会通过 `willFlushCallback` 回调对每个 DOM 元素做布局规范化(图片尺寸等)。
/// 当 DTCoreText 不可用时(条件编译失败),回退到纯文本渲染。
public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
public init() {}
/// DTCoreText 库是否在当前编译环境中可用
public static var isAvailable: Bool {
#if canImport(DTCoreText)
return true
@@ -24,7 +15,6 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
#endif
}
/// 渲染单个章节:HTML → NSAttributedString,同时提取 fragment 和语义标记。
public func renderChapter(
request: RDEPUBTextChapterRenderRequest
) throws -> RDEPUBRenderedChapterContent {
@@ -56,7 +46,6 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
#endif
}
/// 便捷方法:直接传入 HTML 字符串进行渲染(不含上下文信息)
public func renderChapter(
html: String,
baseURL: URL?,
@@ -65,6 +54,7 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
let request = RDEPUBTextTypesetterPipeline().makeRequest(
from: RDEPUBTypesettingInput(
href: "",
spineIndex: nil,
title: "",
rawHTML: html,
baseURL: baseURL,
@@ -75,7 +65,6 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
return try renderChapter(request: request)
}
/// 回退渲染:当 DTCoreText 不可用时,将 HTML 源码当作纯文本处理
private func fallbackRenderedContent(request: RDEPUBTextChapterRenderRequest) -> RDEPUBRenderedChapterContent {
let attributedString = RDEPUBTextRendererSupport.fallbackAttributedString(for: request.context.html, style: request.style)
RDEPUBSemanticMarkerInjector.applyPaginationSemantics(in: attributedString)
@@ -93,7 +82,7 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
}
#if canImport(DTCoreText)
/// 使用 DTCoreText 将 HTML Data 解析为富文本。
private func makeAttributedString(from data: Data, request: RDEPUBTextChapterRenderRequest) -> NSAttributedString? {
let builder = DTHTMLAttributedStringBuilder(
html: data,
@@ -111,7 +100,6 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
return builder?.generatedAttributedString()
}
/// 构建 DTCoreText 的解析选项字典,包括字体、行高、图片尺寸限制等。
private func dtOptions(request: RDEPUBTextChapterRenderRequest) -> [AnyHashable: Any] {
let style = request.style
let maxImageSize = resolvedMaxImageSize(for: request)
@@ -1,84 +1,46 @@
// RDEPUBTextPositionConverter.swift
// EPUB 全书文本位置转换器,在多种坐标体系之间进行双向映射。
import Foundation
/// 对标 WXRead `WREpubPositionConverter` 的全书位置转换器。
///
/// 负责在四套坐标之间做双向转换:
/// - `(fileIndex, row, column)` 文件级语义锚点
/// - 章节内字符偏移
/// - 全书字符偏移
/// - 页码 / `RDEPUBLocation`
public struct RDEPUBTextPositionConverter {
/// 当前转换器所关联的 EPUB 文本对象。
public let book: RDEPUBTextBook
/// 创建指定 EPUB 文本对象的位置转换器。
/// - Parameter book: 要关联的 EPUB 文本对象。
public init(book: RDEPUBTextBook) {
self.book = book
}
/// 全书字符总数(跨所有文件累计)。
public var totalCharacterCount: Int {
book.indexTable.totalCharacterCount
}
/// 将语义锚点转换为全书字符偏移。
/// - Parameter anchor: 由文件索引、行号、列号组成的语义锚点。
/// - Returns: 对应的全书字符偏移量。
public func globalIndex(for anchor: RDEPUBTextAnchor) -> Int {
book.indexTable.globalIndex(for: anchor)
}
/// 将语义范围锚点转换为全书字符范围。
/// - Parameter rangeAnchor: 由起止锚点组成的语义范围。
/// - Returns: 对应的全书字符范围(`NSRange`)。
public func globalRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange {
book.indexTable.globalRange(for: rangeAnchor)
}
/// 根据全书字符偏移查找所属文件的索引。
/// - Parameter index: 全书字符偏移量。
/// - Returns: 对应的文件索引,超出范围时返回 `nil`。
public func fileIndex(forCharacterPosition index: Int) -> Int? {
book.indexTable.fileIndex(forCharacterPosition: index)
}
/// 将全书字符偏移转换为指定文件内的本地偏移。
/// - Parameters:
/// - fileIndex: 目标文件索引。
/// - index: 全书字符偏移量。
/// - Returns: 文件内的本地字符偏移,越界时返回 `nil`。
public func localOffsetInFile(at fileIndex: Int, forGlobalPosition index: Int) -> Int? {
book.indexTable.localOffsetInFile(at: fileIndex, forGlobalPosition: index)
}
/// 将全书字符偏移转换为语义锚点。
/// - Parameter index: 全书字符偏移量。
/// - Returns: 对应的语义锚点,无效位置时返回 `nil`。
public func anchor(forCharacterPosition index: Int) -> RDEPUBTextAnchor? {
book.indexTable.anchor(forGlobalIndex: index)
}
/// 将 `RDEPUBLocation` 转换为语义锚点。
/// - Parameter location: EPUB 位置对象。
/// - Returns: 对应的语义锚点,无法映射时返回 `nil`。
public func anchor(for location: RDEPUBLocation) -> RDEPUBTextAnchor? {
book.indexTable.anchor(for: location)
}
/// 根据语义锚点获取页码(从 1 开始)。
/// - Parameter anchor: 由文件索引、行号、列号组成的语义锚点。
/// - Returns: 对应的页码,无法定位时返回 `nil`。
public func pageNumber(for anchor: RDEPUBTextAnchor) -> Int? {
book.indexTable.pageNumber(for: anchor, in: book).map { $0 + 1 }
}
/// 根据全书字符偏移获取页码(从 1 开始)。
/// - Parameter index: 全书字符偏移量。
/// - Returns: 对应的页码,无效位置时返回 `nil`。
public func pageNumber(forCharacterPosition index: Int) -> Int? {
guard let anchor = anchor(forCharacterPosition: index) else {
return nil
@@ -86,11 +48,6 @@ public struct RDEPUBTextPositionConverter {
return pageNumber(for: anchor)
}
/// 将语义锚点转换为可序列化的 `RDEPUBLocation`。
/// - Parameters:
/// - anchor: 由文件索引、行号、列号组成的语义锚点。
/// - bookIdentifier: 书籍标识符,会写入 location 的 `bookId` 字段。
/// - Returns: 对应的 `RDEPUBLocation`,无法映射时返回 `nil`。
public func location(
for anchor: RDEPUBTextAnchor,
bookIdentifier: String?
@@ -101,11 +58,6 @@ public struct RDEPUBTextPositionConverter {
return book.indexTable.location(for: anchor, in: chapter, bookIdentifier: bookIdentifier)
}
/// 将语义范围锚点转换为可序列化的 `RDEPUBLocation`。
/// - Parameters:
/// - rangeAnchor: 由起止锚点组成的语义范围。
/// - bookIdentifier: 书籍标识符,会写入 location 的 `bookId` 字段。
/// - Returns: 对应的 `RDEPUBLocation`,无法映射时返回 `nil`。
public func location(
for rangeAnchor: RDEPUBTextRangeAnchor,
bookIdentifier: String?
@@ -1,71 +1,54 @@
import UIKit
// MARK: - 自定义富文本属性键
/// EPUBTextRendering 层使用的自定义 NSAttributedString 属性键。
/// 这些属性由 `RDEPUBTextRendererSupport` 在渲染阶段注入,
/// 供 `RDEPUBTextLayouter` 分页时读取语义信息。
public extension NSAttributedString.Key {
/// 块级元素在富文本中的字符范围(NSString 编码的 NSRange)
static let rdPageBlockRange = NSAttributedString.Key("com.rdreader.epub.pageBlockRange")
/// 块级元素在章节中的序号
static let rdPageBlockIndex = NSAttributedString.Key("com.rdreader.epub.pageBlockIndex")
/// fragment 锚点 ID
static let rdPageFragmentID = NSAttributedString.Key("com.rdreader.epub.pageFragmentID")
/// 附件类型(图片/通用附件)
static let rdPageAttachmentKind = NSAttributedString.Key("com.rdreader.epub.pageAttachmentKind")
/// 块级元素类型(段落/列表/表格/代码等)
static let rdPageBlockKind = NSAttributedString.Key("com.rdreader.epub.pageBlockKind")
/// 语义提示标记(避免分页、保持与下一段同页等,逗号分隔)
static let rdPageSemanticHints = NSAttributedString.Key("com.rdreader.epub.pageSemanticHints")
/// 附件布局方式(行内/基线/居中)
static let rdPageAttachmentPlacement = NSAttributedString.Key("com.rdreader.epub.pageAttachmentPlacement")
}
// MARK: - 块级元素类型枚举
/// HTML 块级元素的类型分类,用于分页时判断语义边界。
public enum RDEPUBTextBlockKind: String, Codable, Equatable, CaseIterable {
case paragraph // <p>
case list // <ul>, <ol>, <li>
case table // <table> 系列
case code // <pre>, <code>
case blockquote // <blockquote>
case attachment // 附件块(图片、figure 等)
case generic // 通用块(<div>, <h1>-<h6> 等)
case paragraph
case list
case table
case code
case blockquote
case attachment
case generic
}
// MARK: - 语义提示枚举
/// 分页语义提示,指导分页引擎在何处/何处不进行分页。
public enum RDEPUBTextSemanticHint: String, Codable, Equatable, CaseIterable {
case avoidPageBreakInside // 禁止在块内分页(如代码块、表格)
case keepWithNext // 与下一段保持同页(如标题)
case pageBreakBefore // 在此元素前强制分页
case pageBreakAfter // 在此元素后强制分页
case pageRelate // 微信读书式跨页关联元素
case avoidPageBreakInside
case keepWithNext
case pageBreakBefore
case pageBreakAfter
case pageRelate
}
// MARK: - 附件布局方式枚举
/// 富文本附件(图片等)的垂直布局方式。
public enum RDEPUBTextAttachmentPlacement: String, Codable, Equatable {
case inline // 行内布局
case baseline // 基线对齐
case centered // 垂直居中(块级)
case inline
case baseline
case centered
}
// MARK: - 渲染样式
/// 阅读器渲染样式配置,控制字体、行距、颜色等。
public struct RDEPUBTextRenderStyle {
/// 基础字体(大小和字族将覆盖 EPUB 原始样式)
public var font: UIFont
/// 行间距(pt)
public var lineSpacing: CGFloat
/// 文本颜色(nil 则使用 EPUB 原始颜色)
public var textColor: UIColor?
/// 背景颜色(nil 则透明,用于暗色模式判断)
public var backgroundColor: UIColor?
public init(font: UIFont, lineSpacing: CGFloat, textColor: UIColor? = nil, backgroundColor: UIColor? = nil) {
@@ -76,31 +59,28 @@ public struct RDEPUBTextRenderStyle {
}
}
// MARK: - 布局配置
/// 分页引擎的布局控制参数。
public struct RDEPUBTextLayoutConfig: Equatable {
/// 页面帧宽度;传 0 时回退为分页入口传入的 pageSize.width
public var frameWidth: CGFloat
/// 页面帧高度;传 0 时回退为分页入口传入的 pageSize.height
public var frameHeight: CGFloat
/// 页面内容内边距,对标 WXRead 的 WRCoreTextLayoutConfig.edgeInsets
public var edgeInsets: UIEdgeInsets
/// 栏数,对标 WXRead 的 numberOfColumns
public var numberOfColumns: Int
/// 栏间距,对标 WXRead 的 columnGap
public var columnGap: CGFloat
/// 是否避免孤行(段落最后一行单独在下一页顶部)
public var avoidOrphans: Bool
/// 是否避免寡行(段落第一行单独在上一页底部)
public var avoidWidows: Bool
/// 是否启用 avoidPageBreakInside 保护(对标 WXRead 的行级回退扫描)
public var avoidPageBreakInsideEnabled: Bool
/// 是否启用连字符断字,对标 WXRead 的 hyphenation
public var hyphenation: Bool
/// 图片最大高度占页面高度的比例
public var imageMaxHeightRatio: CGFloat
/// 当调用方暂时拿不到 pageSize 时,用于估算附件尺寸的兜底 viewport。
public var fallbackViewportSize: CGSize
public init(
@@ -129,10 +109,8 @@ public struct RDEPUBTextLayoutConfig: Equatable {
self.fallbackViewportSize = fallbackViewportSize
}
/// 默认配置
public static let `default` = RDEPUBTextLayoutConfig()
/// 结合调用方 pageSize 解析后的实际页面尺寸。
public func resolvedFrameSize(fallback pageSize: CGSize) -> CGSize {
CGSize(
width: max(frameWidth > 0 ? frameWidth : pageSize.width, 1),
@@ -140,13 +118,11 @@ public struct RDEPUBTextLayoutConfig: Equatable {
)
}
/// 实际内容区域;对标 WXRead 的 frame + edgeInsets 组合。
public func contentRect(fallback pageSize: CGSize) -> CGRect {
let size = resolvedFrameSize(fallback: pageSize)
return CGRect(origin: .zero, size: size).inset(by: edgeInsets)
}
/// 多栏布局时的列矩形数组。
public func columnRects(fallback pageSize: CGSize) -> [CGRect] {
let rect = contentRect(fallback: pageSize)
let columns = max(1, numberOfColumns)
@@ -161,7 +137,6 @@ public struct RDEPUBTextLayoutConfig: Equatable {
}
}
/// 持久化/缓存键使用的稳定签名。
public var cacheSignature: String {
[
String(format: "%.3f", frameWidth),
@@ -183,18 +158,14 @@ public struct RDEPUBTextLayoutConfig: Equatable {
}
}
// MARK: - CSS 样式表层级
/// 样式表层级类型,用于 CSS 层叠优先级管理。
public enum RDEPUBTextStyleSheetLayerKind: String, CaseIterable, Equatable {
case `default` // 基础重置样式(margin、padding 等)
case replace // 元素替换样式(图片居中、标题分页等)
case dark // 暗色模式覆盖
case epub // EPUB 原始样式表
case user // 用户自定义样式(字号、行距、颜色等)
case `default`
case replace
case dark
case epub
case user
}
/// 单个 CSS 样式表层,包含层级类型和 CSS 内容。
public struct RDEPUBTextStyleSheetLayer: Equatable {
public var kind: RDEPUBTextStyleSheetLayerKind
public var css: String
@@ -205,9 +176,6 @@ public struct RDEPUBTextStyleSheetLayer: Equatable {
}
}
/// CSS 样式表包,管理多层 CSS 的合并与注入顺序。
///
/// CSS 层叠顺序(从低到高):default → replace → dark → epub → user
public struct RDEPUBTextStyleSheetPackage: Equatable {
public var layers: [RDEPUBTextStyleSheetLayer]
@@ -215,7 +183,6 @@ public struct RDEPUBTextStyleSheetPackage: Equatable {
self.layers = layers
}
/// 合并所有非空层的 CSS,每层添加注释头标记
public var combinedCSS: String {
layers
.filter { !$0.css.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
@@ -226,15 +193,11 @@ public struct RDEPUBTextStyleSheetPackage: Equatable {
}
}
// MARK: - 资源引用诊断
/// 资源引用类型(样式表或图片)
public enum RDEPUBTextResourceReferenceKind: String, Equatable {
case stylesheet
case image
}
/// 单个资源引用的诊断信息,用于检测 EPUB 中的资源是否正确引用。
public struct RDEPUBTextResourceReferenceDiagnostic: Equatable {
public var kind: RDEPUBTextResourceReferenceKind
public var chapterHref: String
@@ -260,9 +223,6 @@ public struct RDEPUBTextResourceReferenceDiagnostic: Equatable {
}
}
// MARK: - 章节渲染上下文与请求
/// 章节渲染上下文:包含 HTML 源码、样式表和资源诊断信息。
public struct RDEPUBTextChapterContext: Equatable {
public var href: String
public var title: String
@@ -270,6 +230,7 @@ public struct RDEPUBTextChapterContext: Equatable {
public var baseURL: URL?
public var stylesheet: RDEPUBTextStyleSheetPackage
public var resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic]
public var styleCompatibilityReport: RDEPUBCSSCompatibilityReport
public init(
href: String,
@@ -277,7 +238,8 @@ public struct RDEPUBTextChapterContext: Equatable {
html: String,
baseURL: URL?,
stylesheet: RDEPUBTextStyleSheetPackage,
resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic]
resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic],
styleCompatibilityReport: RDEPUBCSSCompatibilityReport = RDEPUBCSSCompatibilityReport()
) {
self.href = href
self.title = title
@@ -285,16 +247,16 @@ public struct RDEPUBTextChapterContext: Equatable {
self.baseURL = baseURL
self.stylesheet = stylesheet
self.resourceDiagnostics = resourceDiagnostics
self.styleCompatibilityReport = styleCompatibilityReport
}
}
/// 章节渲染请求:打包上下文和渲染样式,传递给 `RDEPUBTextRenderer`。
public struct RDEPUBTextChapterRenderRequest {
public var context: RDEPUBTextChapterContext
public var style: RDEPUBTextRenderStyle
/// 当前章节将被分页到的页面尺寸;用于让附件缩放贴近真实 page size。
public var pageSize: CGSize?
/// 当前分页布局配置;用于生成与 WXRead 更接近的内容区尺寸。
public var layoutConfig: RDEPUBTextLayoutConfig?
public init(
@@ -310,15 +272,12 @@ public struct RDEPUBTextChapterRenderRequest {
}
}
// MARK: - 渲染结果
/// 章节渲染的输出结果,包含富文本、fragment 偏移量和资源诊断。
public struct RDEPUBRenderedChapterContent {
/// 渲染后的富文本
public var attributedString: NSAttributedString
/// fragment ID → 字符偏移量映射
public var fragmentOffsets: [String: Int]
/// 资源引用诊断列表
public var resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic]
public init(
@@ -332,18 +291,12 @@ public struct RDEPUBRenderedChapterContent {
}
}
// MARK: - 渲染器协议
/// EPUB 文本渲染器协议,定义 HTML → NSAttributedString 的转换接口。
///
/// 默认实现:`RDEPUBDTCoreTextRenderer`(基于 DTCoreText 库)
public protocol RDEPUBTextRenderer {
/// 渲染单个章节
func renderChapter(
request: RDEPUBTextChapterRenderRequest
) throws -> RDEPUBRenderedChapterContent
/// 便捷方法:直接渲染 HTML 字符串
func renderChapter(
html: String,
baseURL: URL?,
@@ -351,7 +304,6 @@ public protocol RDEPUBTextRenderer {
) throws -> RDEPUBRenderedChapterContent
}
/// 协议默认实现:将便捷方法委托给完整方法
public extension RDEPUBTextRenderer {
func renderChapter(
html: String,
@@ -370,12 +322,9 @@ public extension RDEPUBTextRenderer {
}
}
// MARK: - 渲染错误
/// EPUB 文本渲染过程中可能出现的错误类型。
public enum RDEPUBTextRenderingError: LocalizedError {
case htmlEncodingFailed // HTML 字符串编码为 Data 失败
case htmlImportFailed // HTML 富文本解析失败
case htmlEncodingFailed
case htmlImportFailed
public var errorDescription: String? {
switch self {
@@ -1,10 +1,5 @@
import Foundation
/// EPUB 文本搜索引擎:在分页书籍中执行全文搜索。
///
/// 实现 `RDEPUBSearchEngine` 协议,遍历所有章节的富文本内容,
/// 使用大小写不敏感匹配查找关键词,返回搜索结果列表。
/// 每个搜索结果包含进度位置、预览文本和语义锚点。
final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
private let textBook: RDEPUBTextBook
private let publication: RDEPUBPublication
@@ -14,14 +9,6 @@ final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
self.publication = publication
}
/// 在全书中搜索关键词,返回所有匹配结果。
///
/// 搜索流程:
/// 1. 遍历所有章节
/// 2. 在每章的富文本字符串中执行大小写不敏感搜索
/// 3. 计算匹配位置的阅读进度(progression)
/// 4. 生成预览文本(匹配位置前后各 12 个字符)
/// 5. 生成语义锚点(用于跨设备/跨字体精确定位)
func search(keyword: String) -> [RDEPUBSearchMatch] {
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedKeyword.isEmpty else {
@@ -49,6 +36,7 @@ final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
let progressionDenominator = max(fullLength - 1, 1)
let progression = Double(foundRange.location) / Double(progressionDenominator)
let rangeAnchor = chapterData.rangeAnchor(for: foundRange)
matches.append(
RDEPUBSearchMatch(
href: normalizedHref,
@@ -57,7 +45,9 @@ final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
localMatchIndex: localMatchIndex,
rangeLocation: foundRange.location,
rangeLength: foundRange.length,
rangeAnchor: chapterData.rangeAnchor(for: foundRange)
rangeAnchor: rangeAnchor,
cfi: chapterData.indexTable.cfi(for: rangeAnchor.start)?.rawValue,
rangeCFI: chapterData.indexTable.cfiRange(for: rangeAnchor)?.rawValue
)
)
@@ -73,7 +63,6 @@ final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
return matches
}
/// 生成搜索结果的预览文本:匹配位置前后各取 12 个字符
private func previewText(in text: NSString, matchRange: NSRange) -> String {
let previewRadius = 12
let start = max(matchRange.location - previewRadius, 0)
@@ -82,13 +71,16 @@ final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
return text.substring(with: range).trimmingCharacters(in: .whitespacesAndNewlines)
}
/// 无 publication 时的搜索(用于 txt 等外部文本文件)
static func searchWithoutPublication(textBook: RDEPUBTextBook, keyword: String) -> [RDEPUBSearchMatch] {
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedKeyword.isEmpty else { return [] }
var matches: [RDEPUBSearchMatch] = []
for chapter in textBook.chapters {
let chapterData = RDEPUBChapterData(
chapter: chapter,
indexTable: RDEPUBTextIndexTable(chapters: [chapter])
)
let source = chapter.attributedContent.string as NSString
let fullLength = source.length
guard fullLength > 0 else { continue }
@@ -102,6 +94,9 @@ final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
let progressionDenominator = max(fullLength - 1, 1)
let progression = Double(foundRange.location) / Double(progressionDenominator)
let rangeAnchor = chapterData.rangeAnchor(for: foundRange)
let cfi = chapterData.indexTable.cfi(for: rangeAnchor.start)
let cfiRange = chapterData.indexTable.cfiRange(for: rangeAnchor)
let previewRadius = 12
let previewStart = max(foundRange.location - previewRadius, 0)
let previewEnd = min(foundRange.location + foundRange.length + previewRadius, fullLength)
@@ -116,7 +111,9 @@ final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
localMatchIndex: localMatchIndex,
rangeLocation: foundRange.location,
rangeLength: foundRange.length,
rangeAnchor: nil
rangeAnchor: rangeAnchor,
cfi: cfi?.rawValue ?? cfiRange?.start.rawValue,
rangeCFI: cfiRange?.rawValue
)
)
@@ -1,16 +1,5 @@
import UIKit
/// 纯文本书籍构建器:将 TXT 文件构建为 RDEPUBTextBook,复用 EPUB 文本渲染和分页管线。
///
/// 本类将纯文本文件(.txt)转换为与 EPUB 相同的分页书籍模型,
/// 使得纯文本阅读器可以复用 EPUBReader 的分页、导航、搜索等全部功能。
///
/// 处理流程:
/// 1. 解码文本文件(UTF-8 → GB18030 → GBK 兼容编码)
/// 2. 按中文章节正则拆分为多个章节
/// 3. 将纯文本包装为 HTML 段落
/// 4. 通过渲染器(DTCoreText)渲染为富文本
/// 5. CoreText 分页 → RDEPUBTextBook
public final class RDPlainTextBookBuilder {
private let renderer: RDEPUBTextRenderer
private let layoutConfig: RDEPUBTextLayoutConfig
@@ -23,13 +12,6 @@ public final class RDPlainTextBookBuilder {
self.layoutConfig = layoutConfig
}
/// 从纯文本文件构建分页书籍。
///
/// - Parameters:
/// - textFileURL: 纯文本文件的本地 URL
/// - pageSize: 页面尺寸
/// - style: 渲染样式(字体、行距、颜色)
/// - Returns: 分页后的书籍模型
public func build(
textFileURL: URL,
pageSize: CGSize,
@@ -109,6 +91,7 @@ public final class RDPlainTextBookBuilder {
title: spec.title ?? "第 \(index + 1) 章",
attributedContent: chapterAttributedContent,
fragmentOffsets: [:],
cfiMap: nil,
pageBreakReasons: pages.map { $0.metadata.breakReason },
pages: pages
)
@@ -119,16 +102,11 @@ public final class RDPlainTextBookBuilder {
return RDEPUBTextBook(chapters: chapters, pages: flatPages)
}
// MARK: - 章节拆分
/// 章节规格:标题 + 内容
private struct ChapterSpec {
let title: String?
let content: String
}
/// 按中文章节正则拆分文本。匹配 "第X章/节/回/卷" 格式,
/// 无匹配时将整篇文本作为一章处理。
private func splitChapters(from text: String) -> [ChapterSpec] {
let pattern = #"^(第[零一二三四五六七八九十百千万\d]+[章节回卷].*)$"#
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines]) else {
@@ -154,25 +132,18 @@ public final class RDPlainTextBookBuilder {
}
}
// 拆分结果为空(所有章节内容为空),整体作为一章
if specs.isEmpty {
return [ChapterSpec(title: nil, content: text)]
}
return specs
}
// MARK: - HTML 包装
/// 将纯文本包装为 HTML 段落:每行一个 `<p>` 标签
private func wrapTextAsHTML(_ text: String) -> String {
let paragraphs = text.components(separatedBy: "\n").filter { !$0.isEmpty }
let body = paragraphs.map { "<p>\($0)</p>" }.joined(separator: "\n")
return "<html><body>\(body)</body></html>"
}
// MARK: - 文本解码
/// 解码文本文件,按优先级尝试 UTF-8 → GB18030 → GBK 编码
private func rd_decodeTextFile(url: URL) -> String {
if let content = try? NSString(contentsOf: url, encoding: String.Encoding.utf8.rawValue) as String {
return content
@@ -0,0 +1,136 @@
import Foundation
struct RDEPUBCSSCompatibilityLayer {
var policy = RDEPUBStyleCompatibilityPolicy()
func sanitize(_ css: String) -> RDEPUBCSSCompatibilityResult {
var rewritten = css
var normalized: [String] = []
var unsupported: [String] = []
if policy.normalizeLineHeight {
let result = clampNumericProperty(
in: rewritten,
property: "line-height",
minValue: 0.9,
maxValue: 2.4
)
rewritten = result.css
normalized += result.normalizedRules
}
if policy.normalizeTextIndent {
let result = clampLengthProperty(
in: rewritten,
property: "text-indent",
maxAbsolutePX: 64
)
rewritten = result.css
normalized += result.normalizedRules
}
if !policy.allowPublisherMargins {
let result = clampLengthProperty(
in: rewritten,
property: "margin",
maxAbsolutePX: 64
)
rewritten = result.css
normalized += result.normalizedRules
}
if policy.fallbackUnsupportedWritingModes,
rewritten.range(of: "writing-mode", options: [.caseInsensitive]) != nil {
unsupported.append("writing-mode")
rewritten += "\n\nhtml, body { writing-mode: horizontal-tb !important; }"
normalized.append("writing-mode")
}
if policy.clampImagesToViewport {
rewritten += """
img, svg, table, video, canvas {
max-width: 100% !important;
height: auto !important;
box-sizing: border-box !important;
}
table {
overflow-wrap: anywhere;
border-collapse: collapse;
}
"""
normalized.append("media-size-clamp")
}
return RDEPUBCSSCompatibilityResult(
css: rewritten,
report: RDEPUBCSSCompatibilityReport(
unsupportedRules: unsupported,
normalizedRules: normalized,
fontFailures: []
)
)
}
private func clampNumericProperty(
in css: String,
property: String,
minValue: Double,
maxValue: Double
) -> (css: String, normalizedRules: [String]) {
guard let regex = try? NSRegularExpression(
pattern: #"(?i)\b"# + NSRegularExpression.escapedPattern(for: property) + #"\s*:\s*([0-9]*\.?[0-9]+)\s*;"#
) else {
return (css, [])
}
let nsCSS = css as NSString
var rewritten = css
var normalized: [String] = []
for match in regex.matches(in: css, range: NSRange(location: 0, length: nsCSS.length)).reversed() {
guard match.numberOfRanges > 1 else { continue }
let rawValue = nsCSS.substring(with: match.range(at: 1))
guard let value = Double(rawValue), value < minValue || value > maxValue else { continue }
let clamped = min(max(value, minValue), maxValue)
if let range = Range(match.range, in: rewritten) {
rewritten.replaceSubrange(range, with: "\(property): \(String(format: "%.3f", clamped));")
normalized.append(property)
}
}
return (rewritten, normalized)
}
private func clampLengthProperty(
in css: String,
property: String,
maxAbsolutePX: Double
) -> (css: String, normalizedRules: [String]) {
guard let regex = try? NSRegularExpression(
pattern: #"(?i)\b"# + NSRegularExpression.escapedPattern(for: property) + #"\s*:\s*(-?[0-9]*\.?[0-9]+)px\s*;"#
) else {
return (css, [])
}
let nsCSS = css as NSString
var rewritten = css
var normalized: [String] = []
for match in regex.matches(in: css, range: NSRange(location: 0, length: nsCSS.length)).reversed() {
guard match.numberOfRanges > 1 else { continue }
let rawValue = nsCSS.substring(with: match.range(at: 1))
guard let value = Double(rawValue), abs(value) > maxAbsolutePX else { continue }
let clamped = value < 0 ? -maxAbsolutePX : maxAbsolutePX
if let range = Range(match.range, in: rewritten) {
rewritten.replaceSubrange(range, with: "\(property): \(String(format: "%.0f", clamped))px;")
normalized.append(property)
}
}
return (rewritten, normalized)
}
}
@@ -0,0 +1,21 @@
import UIKit
struct RDEPUBFontFallbackResolver {
static func fallbackChain(requestedFamily: String?, embeddedFamily: String?) -> RDEPUBFontFallbackChain {
let preferredCJK = ["PingFang SC", "Heiti SC", "Songti SC"]
let preferredLatin = ["Times New Roman", "Georgia", "Helvetica Neue"]
return RDEPUBFontFallbackChain(
requestedFamily: requestedFamily,
embeddedFamily: embeddedFamily,
systemFallbacks: preferredCJK + preferredLatin,
finalFallback: UIFont.systemFont(ofSize: UIFont.systemFontSize).familyName
)
}
static func resolveFont(sourceFont: UIFont?, baseFont: UIFont) -> UIFont {
RDEPUBFontNormalizer.normalizedFont(from: sourceFont, baseFont: baseFont)
}
}
@@ -0,0 +1,101 @@
import Foundation
public struct RDEPUBStyleCompatibilityPolicy: Equatable {
public var allowPublisherFonts: Bool
public var allowPublisherMargins: Bool
public var normalizeLineHeight: Bool
public var normalizeTextIndent: Bool
public var clampImagesToViewport: Bool
public var fallbackUnsupportedWritingModes: Bool
public init(
allowPublisherFonts: Bool = true,
allowPublisherMargins: Bool = true,
normalizeLineHeight: Bool = true,
normalizeTextIndent: Bool = true,
clampImagesToViewport: Bool = true,
fallbackUnsupportedWritingModes: Bool = true
) {
self.allowPublisherFonts = allowPublisherFonts
self.allowPublisherMargins = allowPublisherMargins
self.normalizeLineHeight = normalizeLineHeight
self.normalizeTextIndent = normalizeTextIndent
self.clampImagesToViewport = clampImagesToViewport
self.fallbackUnsupportedWritingModes = fallbackUnsupportedWritingModes
}
}
public struct RDEPUBFontFallbackChain: Codable, Equatable {
public var requestedFamily: String?
public var embeddedFamily: String?
public var systemFallbacks: [String]
public var finalFallback: String
public init(
requestedFamily: String? = nil,
embeddedFamily: String? = nil,
systemFallbacks: [String] = ["PingFang SC", "Heiti SC", "Times New Roman"],
finalFallback: String = ".AppleSystemUIFont"
) {
self.requestedFamily = requestedFamily
self.embeddedFamily = embeddedFamily
self.systemFallbacks = systemFallbacks
self.finalFallback = finalFallback
}
}
public struct RDEPUBEmbeddedFontDescriptor: Codable, Equatable {
public var family: String?
public var href: String
public var format: String?
public var weight: Int?
public var style: String?
}
public struct RDEPUBFontRegistrationResult: Codable, Equatable {
public var descriptor: RDEPUBEmbeddedFontDescriptor
public var fileURL: URL?
public var didRegister: Bool
public var errorDescription: String?
}
public struct RDEPUBCSSCompatibilityReport: Codable, Equatable {
public var unsupportedRules: [String]
public var normalizedRules: [String]
public var fontFailures: [String]
public init(unsupportedRules: [String] = [], normalizedRules: [String] = [], fontFailures: [String] = []) {
self.unsupportedRules = unsupportedRules
self.normalizedRules = normalizedRules
self.fontFailures = fontFailures
}
}
struct RDEPUBCSSCompatibilityResult {
var css: String
var report: RDEPUBCSSCompatibilityReport
}
@@ -4,14 +4,14 @@ import UIKit
import DTCoreText
#endif
/// 附件布局规范化器,负责缩放脚注、封面和通用图片的显示尺寸。
struct RDEPUBAttachmentNormalizer {
/// 脚注附件日志已输出标记(避免重复日志)
private static var didLogFootnoteAttachment = false
/// 封面附件日志已输出标记(避免重复日志)
private static var didLogCoverAttachment = false
#if canImport(DTCoreText)
func normalize(
_ attachment: DTTextAttachment,
fontPointSize: CGFloat,
@@ -25,23 +25,28 @@ struct RDEPUBAttachmentNormalizer {
}
#endif
// MARK: - DTCoreText 附件布局规范化
#if canImport(DTCoreText)
/// 对 DTTextAttachment 做尺寸规范化:脚注缩放、封面缩放、通用图片缩放。
static func normalizeAttachmentLayoutForWXRead(
_ attachment: DTTextAttachment,
fontPointSize: CGFloat,
maxImageSize: CGSize? = nil
) {
let pointSize = max(fontPointSize, 1)
let originalSize = attachment.originalSize
if isFootnoteAttachment(attachment) {
let targetWidth = max(round(pointSize), 1)
let aspectRatio = originalSize.height > 0 ? originalSize.width / originalSize.height : 1
let targetHeight = max(round(targetWidth / max(aspectRatio, 0.1)), 1)
attachment.displaySize = CGSize(width: targetWidth, height: targetHeight)
attachment.verticalAlignment = .baseline
if !didLogFootnoteAttachment {
@@ -54,16 +59,20 @@ struct RDEPUBAttachmentNormalizer {
}
if isCoverAttachment(attachment) {
let maxSize = maxImageSize ?? defaultMaxImageSize(fontPointSize: pointSize)
if originalSize.width > 0, originalSize.height > 0 {
let scale = min(maxSize.width / originalSize.width, maxSize.height / originalSize.height)
attachment.displaySize = CGSize(
width: round(originalSize.width * scale),
height: round(originalSize.height * scale)
)
} else {
attachment.displaySize = maxSize
}
attachment.verticalAlignment = .baseline
if !didLogCoverAttachment {
@@ -76,9 +85,11 @@ struct RDEPUBAttachmentNormalizer {
}
var resolvedSize = attachment.displaySize
if resolvedSize.width <= 0 || resolvedSize.height <= 0 {
resolvedSize = originalSize
}
if resolvedSize.width <= 0 || resolvedSize.height <= 0 {
resolvedSize = CGSize(width: pointSize, height: pointSize)
}
@@ -87,6 +98,7 @@ struct RDEPUBAttachmentNormalizer {
resolvedSize.width > 0,
resolvedSize.height > 0,
(resolvedSize.width > maxImageSize.width || resolvedSize.height > maxImageSize.height) {
let scale = min(maxImageSize.width / resolvedSize.width, maxImageSize.height / resolvedSize.height)
resolvedSize = CGSize(
width: round(resolvedSize.width * scale),
@@ -95,26 +107,31 @@ struct RDEPUBAttachmentNormalizer {
}
attachment.displaySize = CGSize(width: round(resolvedSize.width), height: round(resolvedSize.height))
attachment.verticalAlignment = .center
}
/// DTCoreText 元素配置:在 willFlushCallback 中调用。
static func prepareHTMLElementForReaderRendering(
_ element: DTHTMLElement,
style: RDEPUBTextRenderStyle,
maxImageSize: CGSize? = nil
) {
guard let attachment = element.textAttachment else { return }
let pointSize = max(element.fontDescriptor.pointSize, style.font.pointSize)
let fallbackSize = CGSize(
width: defaultMaxImageSize(fontPointSize: pointSize).width,
height: defaultMaxImageSize(fontPointSize: pointSize).height
)
normalizeAttachmentLayoutForWXRead(
attachment,
fontPointSize: pointSize,
maxImageSize: maxImageSize ?? fallbackSize
)
if isFootnoteAttachment(attachment) {
element.displayStyle = .inline
} else if isCoverAttachment(attachment) {
@@ -123,23 +140,31 @@ struct RDEPUBAttachmentNormalizer {
}
private static func isFootnoteAttachment(_ attachment: DTTextAttachment) -> Bool {
let lowercasedClasses = ((attachment.attributes["class"] as? String) ?? "").lowercased()
let lowercasedPath = attachment.contentURL?.lastPathComponent.lowercased()
?? ((attachment.attributes["src"] as? String) ?? "").lowercased()
return lowercasedClasses.contains("qqreader-footnote") || lowercasedPath == "note.png"
}
private static func isCoverAttachment(_ attachment: DTTextAttachment) -> Bool {
let lowercasedClasses = ((attachment.attributes["class"] as? String) ?? "").lowercased()
let lowercasedPath = attachment.contentURL?.lastPathComponent.lowercased()
?? ((attachment.attributes["src"] as? String) ?? "").lowercased()
return lowercasedClasses.contains("rd-front-cover-image") || lowercasedPath == "cover.jpg"
}
private static func defaultMaxImageSize(fontPointSize: CGFloat) -> CGSize {
let referenceViewport = CGSize(width: 375, height: 667)
let horizontalInset = max(round(fontPointSize), 16)
let verticalInset = max(round(fontPointSize * 1.5), 28)
return CGSize(
width: max(round(referenceViewport.width - horizontalInset * 2), 1),
height: max(round((referenceViewport.height - verticalInset * 2) * 0.85), 1)
@@ -147,16 +172,15 @@ struct RDEPUBAttachmentNormalizer {
}
#endif
// MARK: - 通用附件规范化
/// 规范化 NSAttributedString 中附件的显示尺寸。
static func normalizeAttachmentDisplayIfNeeded(
in attributes: inout [NSAttributedString.Key: Any],
font: UIFont
) {
guard let attachment = attributes[.attachment] else { return }
#if canImport(DTCoreText)
if let textAttachment = attachment as? DTTextAttachment {
normalizeAttachmentLayoutForWXRead(textAttachment, fontPointSize: font.pointSize)
attributes[.attachment] = textAttachment
@@ -165,15 +189,17 @@ struct RDEPUBAttachmentNormalizer {
#endif
if let textAttachment = attachment as? NSTextAttachment, textAttachment.bounds.height <= 0 {
let targetHeight = max(round(font.pointSize * 0.86), 1)
textAttachment.bounds = CGRect(x: 0, y: 0, width: targetHeight, height: targetHeight)
attributes[.attachment] = textAttachment
}
}
/// 推断附件类型。
static func attachmentKind(for attributes: [NSAttributedString.Key: Any]) -> RDEPUBTextAttachmentKind? {
if let attachment = attributes[.attachment] as? NSTextAttachment {
if attachment.image != nil || attachment.fileType?.lowercased().contains("image") == true {
return .image
}
@@ -182,6 +208,7 @@ struct RDEPUBAttachmentNormalizer {
for value in attributes.values {
let typeName = String(describing: type(of: value)).lowercased()
if typeName.contains("attachment") {
return typeName.contains("image") ? .image : .generic
}
@@ -0,0 +1,59 @@
import Foundation
struct RDEPUBCFIMarkerInjector: RDEPUBTypesettingStage {
func process(_ html: String, context: RDEPUBTypesettingInput) -> String {
guard let regex = try? NSRegularExpression(
pattern: #"<([A-Za-z][A-Za-z0-9:_-]*)([^>]*\s(?:id|xml:id)\s*=\s*['"]([^'"]+)['"][^>]*)>"#,
options: [.caseInsensitive]
) else {
return html
}
let nsHTML = html as NSString
let matches = regex.matches(in: html, range: NSRange(location: 0, length: nsHTML.length))
guard !matches.isEmpty else { return html }
var rewritten = html
for match in matches.reversed() {
guard match.numberOfRanges > 3,
let fullRange = Range(match.range(at: 0), in: rewritten),
let tagNameRange = Range(match.range(at: 1), in: html),
let attributesRange = Range(match.range(at: 2), in: html),
let fragmentRange = Range(match.range(at: 3), in: html) else {
continue
}
let tagName = String(html[tagNameRange])
let attributes = String(html[attributesRange])
let fragmentID = String(html[fragmentRange])
guard attributes.range(of: "data-rd-cfi-marker", options: [.caseInsensitive]) == nil else {
continue
}
let marker = RDEPUBCFIGenerator.makeOffsetCFI(
href: context.href,
fileIndex: context.spineIndex ?? 0,
chapterOffset: 0,
fragmentID: fragmentID
).rawValue
let replacement = "<\(tagName)\(attributes) data-rd-cfi-marker=\"\(Self.escapeAttribute(marker))\">"
rewritten.replaceSubrange(fullRange, with: replacement)
}
return rewritten
}
private static func escapeAttribute(_ value: String) -> String {
value
.replacingOccurrences(of: "&", with: "&amp;")
.replacingOccurrences(of: "\"", with: "&quot;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
}
}
@@ -1,16 +1,18 @@
import UIKit
import CoreText
/// 字体规范化器,负责注册 EPUB 嵌入字体并映射到用户设置字体。
struct RDEPUBFontNormalizer {
/// 已注册字体资源,避免重复调用 CTFontManager。
private static var registeredFontPaths = Set<String>()
@discardableResult
func registerEmbeddedFonts(
html: String,
inlinedCSS: String,
input: RDEPUBTypesettingInput
) {
) -> [RDEPUBFontRegistrationResult] {
Self.registerEmbeddedFonts(
in: inlinedCSS + "\n" + Self.inlineStyleCSS(in: html),
chapterHref: input.href,
@@ -18,70 +20,119 @@ struct RDEPUBFontNormalizer {
)
}
// MARK: - 字体注册
/// 从 CSS 中解析 @font-face 规则,注册嵌入字体。
@discardableResult
static func registerEmbeddedFonts(
in css: String,
chapterHref: String,
resourceResolver: RDEPUBResourceResolver?
) {
) -> [RDEPUBFontRegistrationResult] {
guard let resourceResolver,
let faceRegex = try? NSRegularExpression(pattern: #"@font-face\s*\{([\s\S]*?)\}"#, options: [.caseInsensitive]),
let urlRegex = try? NSRegularExpression(pattern: #"url\(([^)]+)\)"#, options: [.caseInsensitive]) else {
return
return []
}
var results: [RDEPUBFontRegistrationResult] = []
let nsCSS = css as NSString
for faceMatch in faceRegex.matches(in: css, range: NSRange(location: 0, length: nsCSS.length)) {
guard faceMatch.numberOfRanges > 1 else { continue }
let block = nsCSS.substring(with: faceMatch.range(at: 1))
let nsBlock = block as NSString
let family = declarationValue(named: "font-family", in: block)?
.trimmingCharacters(in: CharacterSet(charactersIn: "\"'"))
let weight = declarationValue(named: "font-weight", in: block).flatMap(Int.init)
let style = declarationValue(named: "font-style", in: block)
for urlMatch in urlRegex.matches(in: block, range: NSRange(location: 0, length: nsBlock.length)) {
guard urlMatch.numberOfRanges > 1 else { continue }
let rawReference = nsBlock.substring(with: urlMatch.range(at: 1))
.trimmingCharacters(in: CharacterSet(charactersIn: "\"' \n\r\t"))
let descriptor = RDEPUBEmbeddedFontDescriptor(
family: family,
href: rawReference,
format: nil,
weight: weight,
style: style
)
guard !rawReference.isEmpty,
!rawReference.hasPrefix("data:"),
!rawReference.hasPrefix("http://"),
!rawReference.hasPrefix("https://"),
!rawReference.hasPrefix("http:"),
!rawReference.hasPrefix("https:"),
let fileURL = resourceResolver.fileURL(forReference: rawReference, relativeToHref: chapterHref) else {
results.append(
RDEPUBFontRegistrationResult(
descriptor: descriptor,
fileURL: nil,
didRegister: false,
errorDescription: "Font URL could not be resolved"
)
)
continue
}
registerFontIfNeeded(at: fileURL)
let didRegister = registerFontIfNeeded(at: fileURL)
results.append(
RDEPUBFontRegistrationResult(
descriptor: descriptor,
fileURL: fileURL,
didRegister: didRegister,
errorDescription: didRegister ? nil : "Font was already registered or registration failed"
)
)
}
}
return results
}
/// 若字体尚未注册,则通过 CTFontManager 注册。
/// - Parameter fileURL: 字体文件 URL
static func registerFontIfNeeded(at fileURL: URL) {
@discardableResult
static func registerFontIfNeeded(at fileURL: URL) -> Bool {
let standardizedPath = fileURL.standardizedFileURL.path
guard !registeredFontPaths.contains(standardizedPath) else { return }
CTFontManagerRegisterFontsForURL(fileURL as CFURL, .process, nil)
registeredFontPaths.insert(standardizedPath)
guard !registeredFontPaths.contains(standardizedPath) else { return true }
let registered = CTFontManagerRegisterFontsForURL(fileURL as CFURL, .process, nil)
if registered {
registeredFontPaths.insert(standardizedPath)
}
return registered
}
// MARK: - 字体标准化
/// 将 EPUB 原始字体映射到用户设置字体,保留粗体/斜体特征。
static func normalizedFont(from sourceFont: UIFont?, baseFont: UIFont) -> UIFont {
guard let sourceFont else {
return baseFont
}
let traits = sourceFont.fontDescriptor.symbolicTraits.intersection([.traitBold, .traitItalic])
if let descriptor = baseFont.fontDescriptor.withSymbolicTraits(traits) {
return UIFont(descriptor: descriptor, size: baseFont.pointSize)
}
return baseFont
}
/// 提取 HTML 中内联 <style> 块的 CSS 内容。
static func inlineStyleCSS(in html: String) -> String {
guard let regex = try? NSRegularExpression(pattern: #"<style\b[^>]*>([\s\S]*?)</style>"#, options: [.caseInsensitive]) else {
return ""
}
let nsHTML = html as NSString
return regex.matches(in: html, range: NSRange(location: 0, length: nsHTML.length))
.compactMap { match in
guard match.numberOfRanges > 1 else { return nil }
@@ -89,4 +140,21 @@ struct RDEPUBFontNormalizer {
}
.joined(separator: "\n")
}
private static func declarationValue(named name: String, in block: String) -> String? {
guard let regex = try? NSRegularExpression(
pattern: #"(?i)\b"# + NSRegularExpression.escapedPattern(for: name) + #"\s*:\s*([^;]+)"#
) else {
return nil
}
let nsBlock = block as NSString
guard let match = regex.firstMatch(in: block, range: NSRange(location: 0, length: nsBlock.length)),
match.numberOfRanges > 1 else {
return nil
}
return nsBlock.substring(with: match.range(at: 1)).trimmingCharacters(in: .whitespacesAndNewlines)
}
}
@@ -1,26 +1,16 @@
/// RDEPUBFragmentMarkerInjector - Fragment 标记注入与偏移量提取
import Foundation
/// Fragment 标记注入器,负责在 HTML 中注入 fragment 锚点标记并从渲染结果中提取偏移量。
///
/// 工作流程:
/// 1. `process` 阶段:将 HTML 中的 `id` 属性元素转为 `${id=xxx}` 文本标记
/// 2. `extractOffsets` 阶段:从 NSAttributedString 中扫描标记,记录偏移量后删除标记
struct RDEPUBFragmentMarkerInjector: RDEPUBTypesettingStage {
/// 将 HTML 中的 id 元素注入 fragment 文本标记,供后续偏移量提取使用。
func process(_ html: String, context: RDEPUBTypesettingInput) -> String {
Self.injectFragmentMarkers(into: html)
}
/// 从渲染后的富文本中扫描 `${id=xxx}` 标记,提取 fragment ID 到字符偏移量的映射。
func extractOffsets(from attributedString: NSMutableAttributedString) -> [String: Int] {
Self.extractFragmentOffsets(from: attributedString)
}
// MARK: - Fragment 标记注入
/// 将 HTML 中的 id 属性元素注入 fragment 标记。
/// `<tag id="xxx" ...>` → `${id=xxx}<tag id="xxx" ...>`
static func injectFragmentMarkers(into html: String) -> String {
guard let regex = try? NSRegularExpression(pattern: #"(<[^>]+\sid="([^"]+)"[^>]*>)"#, options: [.caseInsensitive]) else {
return html
@@ -33,32 +23,43 @@ struct RDEPUBFragmentMarkerInjector: RDEPUBTypesettingStage {
)
}
// MARK: - Fragment 偏移量提取
/// 从渲染后的富文本中提取 fragment 偏移量映射。
/// 扫描 `${id=xxx}` 标记,记录偏移量,然后删除标记文本。
static func extractFragmentOffsets(from attributedString: NSMutableAttributedString) -> [String: Int] {
let markerPattern = #"\$\{id=([^}]+)\}"#
guard let regex = try? NSRegularExpression(pattern: markerPattern, options: []) else {
return [:]
}
let mutableString = NSMutableString(string: attributedString.string)
var fragmentOffsets: [String: Int] = [:]
var searchRange = NSRange(location: 0, length: mutableString.length)
var offsetAdjustment = 0
while let match = regex.firstMatch(in: mutableString as String, options: [], range: searchRange) {
let fullMatch = mutableString.substring(with: match.range) as NSString
let fragmentID = fullMatch
.replacingOccurrences(of: #"\$\{id="#, with: "", options: .regularExpression, range: NSRange(location: 0, length: fullMatch.length))
.replacingOccurrences(of: #"\}"#, with: "", options: .regularExpression)
let adjustedLocation = max(0, match.range.location + offsetAdjustment)
fragmentOffsets[fragmentID] = adjustedLocation
attributedString.deleteCharacters(in: match.range)
mutableString.deleteCharacters(in: match.range)
offsetAdjustment -= match.range.length
searchRange = NSRange(location: match.range.location, length: mutableString.length - match.range.location)
}
@@ -1,25 +1,18 @@
import Foundation
/// HTML 规范化器,清理冗余字符并规范化附件 HTML 标记。
struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage {
/// 执行 HTML 规范化处理。
/// - Parameters:
/// - html: 原始 HTML
/// - context: 排版上下文
/// - Returns: 规范化后的 HTML
func process(_ html: String, context: RDEPUBTypesettingInput) -> String {
Self.normalizeHTML(html)
}
// MARK: - HTML 规范化入口
/// 清理冗余字符(CR、多余换行),规范化附件 HTML 标记。
static func normalizeHTML(_ html: String) -> String {
var cleanedHTML = html
let replacements: [(pattern: String, template: String)] = [
(#"<hr\s+lang="zh-CN">分页符</hr>"#, ""),
(#"\r"#, "\n"),
(#"\n+"#, "\n")
(#"<hr\s+lang="zh-CN">分页符</hr>"#, ""),
(#"\r"#, "\n"),
(#"\n+"#, "\n")
]
for replacement in replacements {
@@ -37,9 +30,6 @@ struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage {
return cleanedHTML
}
// MARK: - 附件 HTML 标记规范化
/// 处理 bodyPic div、脚注 img、封面 h1+img 等特殊 HTML 结构。
private static func normalizeAttachmentHTMLMarkers(in html: String) -> String {
var normalized = html
@@ -51,10 +41,12 @@ struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage {
using: bodyPicContainerRegex,
in: normalized
) { tag in
guard let imageTagRegex = try? NSRegularExpression(pattern: #"<img\b[^>]*>"#, options: [.caseInsensitive]) else {
return tag
}
return replaceMatches(
using: imageTagRegex,
in: tag
@@ -75,6 +67,7 @@ struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage {
}
}
if let footnoteRegex = try? NSRegularExpression(
pattern: #"<img\b([^>]*class\s*=\s*["'][^"']*\bqqreader-footnote\b[^"']*["'][^>]*)>"#,
options: [.caseInsensitive]
@@ -83,6 +76,7 @@ struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage {
using: footnoteRegex,
in: normalized
) { tag in
mergeHTMLAttributes(
into: tag,
requiredClass: nil,
@@ -96,6 +90,7 @@ struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage {
}
}
if let coverRegex = try? NSRegularExpression(
pattern: #"<h1\b([^>]*class\s*=\s*["'][^"']*\bfrontCover\b[^"']*["'][^>]*)>\s*(<img\b[^>]*>)\s*</h1>"#,
options: [.caseInsensitive]
@@ -104,6 +99,7 @@ struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage {
using: coverRegex,
in: normalized
) { tag in
guard let imageTagRegex = try? NSRegularExpression(pattern: #"<img\b[^>]*>"#, options: [.caseInsensitive]),
let imageMatch = imageTagRegex.firstMatch(
in: tag,
@@ -133,15 +129,13 @@ struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage {
return normalized
}
// MARK: - HTML 工具方法
/// 通用反向正则替换:遍历所有匹配,对每个匹配的原始文本调用 transform。
static func replaceMatches(
using regex: NSRegularExpression,
in source: String,
transform: (String) -> String
) -> String {
let nsSource = source as NSString
let matches = regex.matches(in: source, options: [], range: NSRange(location: 0, length: nsSource.length))
guard !matches.isEmpty else { return source }
@@ -154,7 +148,6 @@ struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage {
return rewritten
}
/// 合并 HTML 标签的 class 和 style 属性。
static func mergeHTMLAttributes(
into tag: String,
requiredClass: String?,
@@ -194,7 +187,6 @@ struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage {
return rewritten
}
/// 注入 `<base>` 标签以解析相对路径。
static func injectBaseHref(into html: String, baseURL: URL?) -> String {
guard let baseURL else {
return html
@@ -213,7 +205,6 @@ struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage {
return "<head>\n\(baseTag)\n</head>\n" + html
}
/// 工具方法:从字符串范围构建 CGSize 描述。
static func string(from size: CGSize) -> String {
"{\(Int(round(size.width))), \(Int(round(size.height)))}"
}
@@ -1,17 +1,16 @@
import Foundation
/// 渲染诊断收集器,扫描 HTML 中的资源引用并生成诊断信息。
struct RDEPUBRenderDiagnosticsCollector {
/// 外部样式表链接正则
private static let stylesheetLinkPattern = #"<link\b[^>]*rel\s*=\s*["'][^"']*stylesheet[^"']*["'][^>]*href\s*=\s*["']([^"']+)["'][^>]*>"#
/// 图片源地址正则
private static let imageSourcePattern = #"<img\b[^>]*src\s*=\s*["']([^"']+)["'][^>]*>"#
/// 收集 HTML 中图片资源的引用诊断。
/// - Parameters:
/// - html: 待扫描的 HTML
/// - input: 排版输入上下文
/// - Returns: 资源引用诊断列表
func collect(
in html: String,
input: RDEPUBTypesettingInput
@@ -24,9 +23,9 @@ struct RDEPUBRenderDiagnosticsCollector {
)
}
// MARK: - 图片诊断
/// 扫描 HTML 中的 <img> 标签,构建资源引用诊断。
static func collectImageDiagnostics(
in html: String,
chapterHref: String,
@@ -50,9 +49,9 @@ struct RDEPUBRenderDiagnosticsCollector {
}
}
// MARK: - 外部样式表内联
/// 查找 `<link rel=stylesheet>` 标签,内联 CSS 内容。
static func inlineLinkedStyleSheets(
in html: String,
chapterHref: String,
@@ -69,10 +68,14 @@ struct RDEPUBRenderDiagnosticsCollector {
return (html, "", [])
}
var rewrittenHTML = html
var inlinedCSSBlocks: [String] = []
var diagnostics: [RDEPUBTextResourceReferenceDiagnostic] = []
for match in matches.reversed() {
guard match.numberOfRanges > 1 else { continue }
let href = nsHTML.substring(with: match.range(at: 1))
@@ -103,9 +106,9 @@ struct RDEPUBRenderDiagnosticsCollector {
return (rewrittenHTML, inlinedCSSBlocks.reversed().joined(separator: "\n\n"), diagnostics.reversed())
}
// MARK: - CSS 资源 URL 重写
/// 重写 CSS 中的相对 url() 引用,解析为绝对文件路径。
static func rewriteCSSResourceURLs(
in css: String,
styleSheetFileURL: URL
@@ -121,18 +124,25 @@ struct RDEPUBRenderDiagnosticsCollector {
}
var rewrittenCSS = css
for match in matches.reversed() {
guard match.numberOfRanges > 1 else { continue }
let rawValue = nsCSS.substring(with: match.range(at: 1))
.trimmingCharacters(in: .whitespacesAndNewlines)
.trimmingCharacters(in: CharacterSet(charactersIn: "\"'"))
guard !rawValue.isEmpty else { continue }
if rawValue.hasPrefix("data:") || rawValue.hasPrefix("http://") || rawValue.hasPrefix("https://") || rawValue.hasPrefix("file://") || rawValue.hasPrefix("#") {
if rawValue.hasPrefix("data:")
|| rawValue.hasPrefix("http:")
|| rawValue.hasPrefix("https:") {
continue
}
guard let resolvedURL = URL(string: rawValue, relativeTo: styleSheetFileURL.deletingLastPathComponent())?.standardizedFileURL else {
continue
}
let replacement = "url(\"\(resolvedURL.absoluteString)\")"
if let range = Range(match.range, in: rewrittenCSS) {
rewrittenCSS.replaceSubrange(range, with: replacement)
@@ -141,8 +151,6 @@ struct RDEPUBRenderDiagnosticsCollector {
return rewrittenCSS
}
// MARK: - 资源引用解析
static func resolveReference(
_ reference: String,
kind: RDEPUBTextResourceReferenceKind,
@@ -150,11 +158,16 @@ struct RDEPUBRenderDiagnosticsCollector {
baseURL: URL?,
resourceResolver: RDEPUBResourceResolver?
) -> (normalizedHref: String?, resolvedFileURL: URL?, diagnostic: RDEPUBTextResourceReferenceDiagnostic) {
let trimmedReference = reference.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedHref = resourceResolver?.normalizedHref(trimmedReference, relativeToHref: chapterHref)
let resolvedFileURL = resourceResolver?.fileURL(forReference: trimmedReference, relativeToHref: chapterHref)
?? URL(string: trimmedReference, relativeTo: baseURL)?.standardizedFileURL
let existsOnDisk = resolvedFileURL.map { FileManager.default.fileExists(atPath: $0.path) } ?? false
let diagnostic = RDEPUBTextResourceReferenceDiagnostic(
kind: kind,
chapterHref: chapterHref,
@@ -1,29 +1,19 @@
/// RDEPUBSemanticMarkerInjector - 分页语义标记注入与应用
import Foundation
import UIKit
/// 语义标记注入器,为 HTML 标签注入分页语义信息(块级类型、分页提示、附件放置方式等)。
///
/// 分两阶段工作:
/// 1. HTML 阶段(`process`):解析标签并注入 `${rd-sem-start/end}` 语义标记
/// 2. 渲染后阶段(`apply`):将标记解析为 NSAttributedString 属性
struct RDEPUBSemanticMarkerInjector: RDEPUBTypesettingStage {
/// 语义标记正则(${rd-sem-start:...} / ${rd-sem-end:...})
private static let semanticMarkerPattern = #"\$\{rd-sem-(start|end):([^}]+)\}"#
/// 为 HTML 标签注入 `${rd-sem-start/end}` 语义标记,标记块级元素类型和分页提示。
func process(_ html: String, context: RDEPUBTypesettingInput) -> String {
Self.injectPaginationSemanticMarkers(into: html)
}
/// 解析渲染后富文本中的语义标记,将其转为 NSAttributedString 属性并删除标记文本。
func apply(to attributedString: NSMutableAttributedString) {
Self.applyPaginationSemantics(in: attributedString)
}
// MARK: - 语义标记注入(HTML 阶段)
/// 为 HTML 标签注入 ${rd-sem-start/end} 语义标记。
static func injectPaginationSemanticMarkers(into html: String) -> String {
guard let regex = try? NSRegularExpression(pattern: #"<[^>]+>"#, options: [.caseInsensitive]) else {
return html
@@ -36,8 +26,11 @@ struct RDEPUBSemanticMarkerInjector: RDEPUBTypesettingStage {
}
var output = ""
var cursor = 0
var openTagStack: [(name: String, id: String)] = []
var nextMarkerID = 0
for match in matches {
@@ -47,9 +40,11 @@ struct RDEPUBSemanticMarkerInjector: RDEPUBTypesettingStage {
let tag = nsHTML.substring(with: tagRange)
let loweredTag = tag.lowercased()
let tagName = htmlTagName(from: loweredTag)
if loweredTag.hasPrefix("</"), let tagName {
if let index = openTagStack.lastIndex(where: { $0.name == tagName }) {
let markerID = openTagStack.remove(at: index).id
output += semanticEndMarker(id: markerID)
@@ -57,16 +52,20 @@ struct RDEPUBSemanticMarkerInjector: RDEPUBTypesettingStage {
output += tag
} else if let tagName,
let semantics = paginationSemantics(forTagName: tagName, rawTag: tag) {
nextMarkerID += 1
let markerID = String(nextMarkerID)
let startMarker = semanticStartMarker(id: markerID, semantics: semantics)
if isVoidHTMLTag(tagName) || loweredTag.hasSuffix("/>") {
output += startMarker + tag + semanticEndMarker(id: markerID)
} else {
openTagStack.append((name: tagName, id: markerID))
output += tag + startMarker
}
} else {
output += tag
}
@@ -77,30 +76,34 @@ struct RDEPUBSemanticMarkerInjector: RDEPUBTypesettingStage {
return output
}
// MARK: - 语义标记应用(渲染后阶段)
/// 将 HTML 中注入的语义标记解析后写入 NSAttributedString 属性。
static func applyPaginationSemantics(in attributedString: NSMutableAttributedString) {
guard let regex = try? NSRegularExpression(pattern: semanticMarkerPattern, options: []) else {
return
}
let mutableString = NSMutableString(string: attributedString.string)
var searchRange = NSRange(location: 0, length: mutableString.length)
var openRanges: [String: (location: Int, semantics: RDPaginationSemantics)] = [:]
while let match = regex.firstMatch(in: mutableString as String, options: [], range: searchRange) {
let kind = mutableString.substring(with: match.range(at: 1))
let payload = mutableString.substring(with: match.range(at: 2))
let markerLocation = match.range.location
attributedString.deleteCharacters(in: match.range)
mutableString.deleteCharacters(in: match.range)
if kind == "start" {
let semantics = parseSemanticMarkerPayload(payload)
openRanges[semantics.id] = (markerLocation, semantics)
} else {
let markerID = parseSemanticEndID(payload)
if let markerID, let opened = openRanges.removeValue(forKey: markerID) {
let length = max(markerLocation - opened.location, 0)
@@ -114,8 +117,6 @@ struct RDEPUBSemanticMarkerInjector: RDEPUBTypesettingStage {
}
}
// MARK: - 语义推断
static func htmlTagName(from loweredTag: String) -> String? {
let trimmed = loweredTag.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.hasPrefix("<") else { return nil }
@@ -297,7 +298,6 @@ struct RDEPUBSemanticMarkerInjector: RDEPUBTypesettingStage {
attributedString.addAttributes(attributes, range: range)
}
/// 推断块级元素类型。
static func normalizeBlockKind(for attributes: [NSAttributedString.Key: Any]) -> RDEPUBTextBlockKind? {
if let rawValue = attributes[.rdPageBlockKind] as? String,
let blockKind = RDEPUBTextBlockKind(rawValue: rawValue) {
@@ -306,7 +306,6 @@ struct RDEPUBSemanticMarkerInjector: RDEPUBTypesettingStage {
return nil
}
/// 推断语义提示列表。
static func normalizeSemanticHints(for attributes: [NSAttributedString.Key: Any]) -> [RDEPUBTextSemanticHint]? {
if let rawValue = attributes[.rdPageSemanticHints] as? String {
let hints = rawValue
@@ -317,7 +316,6 @@ struct RDEPUBSemanticMarkerInjector: RDEPUBTypesettingStage {
return nil
}
/// 推断附件放置方式。
static func normalizeAttachmentPlacement(for attributes: [NSAttributedString.Key: Any]) -> RDEPUBTextAttachmentPlacement? {
if let rawValue = attributes[.rdPageAttachmentPlacement] as? String,
let placement = RDEPUBTextAttachmentPlacement(rawValue: rawValue) {
@@ -329,9 +327,6 @@ struct RDEPUBSemanticMarkerInjector: RDEPUBTypesettingStage {
return nil
}
// MARK: - 内部类型
/// 单个 HTML 元素的分页语义信息,由标签名和属性推断而来。
struct RDPaginationSemantics {
var id: String
var blockKind: RDEPUBTextBlockKind?
@@ -1,24 +1,20 @@
import UIKit
/// 样式表组合结果,包含合成后的 HTML、CSS 层列表及诊断信息。
struct RDEPUBStyleSheetComposition {
/// 合成后的 HTML 文本
var html: String
/// 五层 CSS 样式层(default/replace/dark/epub/user)
var layers: [RDEPUBTextStyleSheetLayer]
/// 内联后的 CSS 内容
var inlinedCSS: String
/// 资源引用诊断列表
var diagnostics: [RDEPUBTextResourceReferenceDiagnostic]
var compatibilityReport: RDEPUBCSSCompatibilityReport
}
/// EPUB 样式表合成器,负责组装五层 CSS 并注入 HTML。
struct RDEPUBStyleSheetComposer {
/// 将 CSS 层注入 HTML,返回合成结果。
/// - Parameters:
/// - html: 原始 HTML
/// - input: 排版输入上下文
/// - Returns: 包含合成 HTML、CSS 层和诊断信息的结果
func compose(html: String, input: RDEPUBTypesettingInput) -> RDEPUBStyleSheetComposition {
let stylesheetHrefReplacements = RDEPUBRenderDiagnosticsCollector.inlineLinkedStyleSheets(
in: html,
@@ -26,9 +22,10 @@ struct RDEPUBStyleSheetComposer {
baseURL: input.baseURL,
resourceResolver: input.resourceResolver
)
let compatibility = RDEPUBCSSCompatibilityLayer().sanitize(stylesheetHrefReplacements.inlinedCSS)
let layers = Self.makeStyleSheetLayers(
style: input.style,
epubCSS: stylesheetHrefReplacements.inlinedCSS,
epubCSS: compatibility.css,
contentLanguageCode: input.contentLanguageCode,
sourceHTML: input.rawHTML
)
@@ -61,14 +58,12 @@ struct RDEPUBStyleSheetComposer {
return RDEPUBStyleSheetComposition(
html: composedHTML,
layers: layers,
inlinedCSS: stylesheetHrefReplacements.inlinedCSS,
diagnostics: stylesheetHrefReplacements.diagnostics
inlinedCSS: compatibility.css,
diagnostics: stylesheetHrefReplacements.diagnostics,
compatibilityReport: compatibility.report
)
}
// MARK: - CSS 层组装
/// 构建五层 CSS 数组(default/replace/dark/epub/user)。
static func makeStyleSheetLayers(
style: RDEPUBTextRenderStyle,
epubCSS: String,
@@ -93,17 +88,13 @@ struct RDEPUBStyleSheetComposer {
return layers
}
// MARK: - Style 注入
/// `<style>` 标签的注入位置。
enum StyleInjectionPosition {
/// 注入到 `<head>` 标签之后
case headStart
/// 注入到 `</head>` 标签之前
case headEnd
}
/// 向 HTML 注入 <style> 标签。
static func injectStyleTag(
into html: String,
styleID: String,
@@ -133,8 +124,6 @@ struct RDEPUBStyleSheetComposer {
return styleTag + "\n" + html
}
// MARK: - 各层 CSS
private static func defaultCSS() -> String {
RDEPUBAssetRepository.string(for: .wxReadDefaultCSS)
}
@@ -186,8 +175,6 @@ struct RDEPUBStyleSheetComposer {
return luminance < 0.5
}
// MARK: - 语言检测
private static func prefersLatinLanguageCSS(
languageCode: String?,
sourceHTML: String
@@ -5,25 +5,8 @@ import CoreText
import DTCoreText
#endif
/// 渲染器支持 Facade:串联各 stage 完成 HTML 预处理,并保留后渲染归一化入口。
///
/// 预处理管线由各 stage 文件持有实现,本文件只作为公开入口协调调用。
/// `RDEPUBDTCoreTextRenderer` 直接调用各 stage 的静态方法。
enum RDEPUBTextRendererSupport {
// MARK: - 预处理管线入口
/// 核心预处理方法:将原始 HTML 转换为完整的章节渲染请求。
///
/// 内部流程:
/// 1. HTMLNormalizer.normalizeHTML
/// 2. SemanticMarkerInjector.injectPaginationSemanticMarkers
/// 3. DiagnosticsCollector.inlineLinkedStyleSheets
/// 4. StyleSheetComposer.makeStyleSheetLayers + injectStyleTag
/// 5. FontNormalizer.registerEmbeddedFonts
/// 6. HTMLNormalizer.injectBaseHref
/// 7. FragmentMarkerInjector.injectFragmentMarkers
/// 8. DiagnosticsCollector.collectImageDiagnostics
static func makeChapterRenderRequest(
href: String,
title: String,
@@ -35,27 +18,33 @@ enum RDEPUBTextRendererSupport {
pageSize: CGSize? = nil,
layoutConfig: RDEPUBTextLayoutConfig? = nil
) -> RDEPUBTextChapterRenderRequest {
let normalizedHTML = RDEPUBSemanticMarkerInjector.injectPaginationSemanticMarkers(
into: RDEPUBHTMLNormalizer.normalizeHTML(rawHTML)
)
let stylesheetHrefReplacements = RDEPUBRenderDiagnosticsCollector.inlineLinkedStyleSheets(
in: normalizedHTML,
chapterHref: href,
baseURL: baseURL,
resourceResolver: resourceResolver
)
let layers = RDEPUBStyleSheetComposer.makeStyleSheetLayers(
style: style,
epubCSS: stylesheetHrefReplacements.inlinedCSS,
contentLanguageCode: contentLanguageCode,
sourceHTML: rawHTML
)
RDEPUBFontNormalizer.registerEmbeddedFonts(
in: stylesheetHrefReplacements.inlinedCSS + "\n" + RDEPUBFontNormalizer.inlineStyleCSS(in: normalizedHTML),
chapterHref: href,
resourceResolver: resourceResolver
)
let htmlWithBase = RDEPUBHTMLNormalizer.injectBaseHref(into: stylesheetHrefReplacements.html, baseURL: baseURL)
let htmlWithDefaultLayers = RDEPUBStyleSheetComposer.injectStyleTag(
into: htmlWithBase,
styleID: "rd-native-default-replace-dark",
@@ -65,19 +54,23 @@ enum RDEPUBTextRendererSupport {
.joined(separator: "\n\n"),
position: .headStart
)
let htmlWithEPUBLayer = RDEPUBStyleSheetComposer.injectStyleTag(
into: htmlWithDefaultLayers,
styleID: "rd-native-epub",
css: layers.first(where: { $0.kind == .epub })?.css ?? "",
position: .headEnd
)
let composedHTML = RDEPUBStyleSheetComposer.injectStyleTag(
into: htmlWithEPUBLayer,
styleID: "rd-native-user",
css: layers.first(where: { $0.kind == .user })?.css ?? "",
position: .headEnd
)
let markedHTML = RDEPUBFragmentMarkerInjector.injectFragmentMarkers(into: composedHTML)
let resourceDiagnostics = stylesheetHrefReplacements.diagnostics + RDEPUBRenderDiagnosticsCollector.collectImageDiagnostics(
in: markedHTML,
chapterHref: href,
@@ -101,59 +94,70 @@ enum RDEPUBTextRendererSupport {
)
}
// MARK: - 后渲染归一化(由 RDEPUBDTCoreTextRenderer 调用)
/// 规范化阅读属性:统一字体、行距、颜色,并注入分页语义属性。
static func normalizeReadingAttributes(
in attributedString: NSMutableAttributedString,
style: RDEPUBTextRenderStyle,
layoutConfig: RDEPUBTextLayoutConfig = .default
) {
let fullRange = NSRange(location: 0, length: attributedString.length)
var blockIndex = 0
let sourceText = attributedString.string as NSString
attributedString.enumerateAttributes(in: fullRange) { attributes, range, _ in
let sourceFont = attributes[.font] as? UIFont
let normalizedFont = RDEPUBFontNormalizer.normalizedFont(from: sourceFont, baseFont: style.font)
let paragraph = (attributes[.paragraphStyle] as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle ?? paragraphStyle(lineSpacing: style.lineSpacing)
paragraph.lineSpacing = style.lineSpacing
paragraph.paragraphSpacing = max(paragraph.paragraphSpacing, style.lineSpacing / 2)
paragraph.hyphenationFactor = layoutConfig.hyphenation ? 1.0 : 0.0
var updatedAttributes = attributes
updatedAttributes[.font] = normalizedFont
updatedAttributes[.paragraphStyle] = paragraph
if let textColor = style.textColor {
updatedAttributes[.foregroundColor] = textColor
}
RDEPUBAttachmentNormalizer.normalizeAttachmentDisplayIfNeeded(in: &updatedAttributes, font: normalizedFont)
let semanticBlockRange = (attributes[.rdPageBlockRange] as? String)
.flatMap(NSRangeFromString)
.flatMap { $0.length > 0 ? $0 : nil }
let paragraphRange = sourceText.length > 0
? sourceText.paragraphRange(for: NSRange(location: min(range.location, max(sourceText.length - 1, 0)), length: 0))
: range
updatedAttributes[.rdPageBlockRange] = NSStringFromRange(semanticBlockRange ?? paragraphRange)
updatedAttributes[.rdPageBlockIndex] = blockIndex
if let attachmentKind = RDEPUBAttachmentNormalizer.attachmentKind(for: attributes) {
updatedAttributes[.rdPageAttachmentKind] = attachmentKind.rawValue
}
if let placement = RDEPUBSemanticMarkerInjector.normalizeAttachmentPlacement(for: attributes) {
updatedAttributes[.rdPageAttachmentPlacement] = placement.rawValue
}
if let blockKind = RDEPUBSemanticMarkerInjector.normalizeBlockKind(for: attributes) {
updatedAttributes[.rdPageBlockKind] = blockKind.rawValue
}
if let hints = RDEPUBSemanticMarkerInjector.normalizeSemanticHints(for: attributes), !hints.isEmpty {
updatedAttributes[.rdPageSemanticHints] = hints.map(\.rawValue).joined(separator: ",")
}
attributedString.setAttributes(updatedAttributes, range: range)
blockIndex += 1
}
}
/// 回退渲染:当 DTCoreText 不可用时,将 HTML 源码当作纯文本处理。
static func fallbackAttributedString(for html: String, style: RDEPUBTextRenderStyle) -> NSMutableAttributedString {
let fallbackAttributes: [NSAttributedString.Key: Any] = [
.font: style.font,
.paragraphStyle: paragraphStyle(lineSpacing: style.lineSpacing),
@@ -162,7 +166,6 @@ enum RDEPUBTextRendererSupport {
return NSMutableAttributedString(string: html, attributes: fallbackAttributes)
}
/// 构建共享段落样式。
static func paragraphStyle(lineSpacing: CGFloat) -> NSMutableParagraphStyle {
let style = NSMutableParagraphStyle()
style.lineSpacing = lineSpacing
@@ -1,69 +1,77 @@
import UIKit
/// 排版输入参数,包含章节 HTML、样式和布局配置。
struct RDEPUBTypesettingInput {
/// 章节相对路径
var href: String
/// 章节标题
var spineIndex: Int?
var title: String
/// 原始 HTML 内容
var rawHTML: String
/// HTML 基础 URL,用于解析相对路径
var baseURL: URL?
/// 渲染样式
var style: RDEPUBTextRenderStyle
/// 资源解析器
var resourceResolver: RDEPUBResourceResolver?
/// 内容语言代码(如 zh-CN)
var contentLanguageCode: String?
/// 页面尺寸
var pageSize: CGSize?
/// 页面布局配置
var layoutConfig: RDEPUBTextLayoutConfig?
}
/// 排版输出结果,包含渲染请求和诊断信息。
struct RDEPUBTypesettingOutput {
/// 章节渲染请求
var request: RDEPUBTextChapterRenderRequest
/// 资源引用诊断列表
var diagnostics: [RDEPUBTextResourceReferenceDiagnostic]
var styleCompatibilityReport: RDEPUBCSSCompatibilityReport
}
/// 排版阶段协议,定义 HTML 处理管线中的单个步骤。
protocol RDEPUBTypesettingStage {
/// 处理 HTML 并返回转换后的结果。
/// - Parameters:
/// - html: 输入 HTML
/// - context: 排版上下文
/// - Returns: 处理后的 HTML
func process(_ html: String, context: RDEPUBTypesettingInput) -> String
}
/// 排版管线,依次执行 HTML 规范化、语义标记、样式合成、字体注册和诊断收集。
struct RDEPUBTextTypesetterPipeline {
/// 从排版输入构建渲染请求。
/// - Parameter input: 排版输入参数
/// - Returns: 包含渲染请求和诊断信息的输出
func makeRequest(from input: RDEPUBTypesettingInput) -> RDEPUBTypesettingOutput {
let htmlNormalizer = RDEPUBHTMLNormalizer()
let semanticMarkerInjector = RDEPUBSemanticMarkerInjector()
let cfiMarkerInjector = RDEPUBCFIMarkerInjector()
let styleSheetComposer = RDEPUBStyleSheetComposer()
let fontNormalizer = RDEPUBFontNormalizer()
let fragmentMarkerInjector = RDEPUBFragmentMarkerInjector()
let diagnosticsCollector = RDEPUBRenderDiagnosticsCollector()
let normalizedHTML = semanticMarkerInjector.process(
htmlNormalizer.process(input.rawHTML, context: input),
let normalizedHTML = cfiMarkerInjector.process(
semanticMarkerInjector.process(
htmlNormalizer.process(input.rawHTML, context: input),
context: input
),
context: input
)
let styleSheetComposition = styleSheetComposer.compose(html: normalizedHTML, input: input)
fontNormalizer.registerEmbeddedFonts(
let fontResults = fontNormalizer.registerEmbeddedFonts(
html: normalizedHTML,
inlinedCSS: styleSheetComposition.inlinedCSS,
input: input
)
let compatibilityReport = Self.mergedCompatibilityReport(
styleSheetComposition.compatibilityReport,
fontResults: fontResults
)
let markedHTML = fragmentMarkerInjector.process(styleSheetComposition.html, context: input)
let diagnostics = styleSheetComposition.diagnostics + diagnosticsCollector.collect(in: markedHTML, input: input)
let context = RDEPUBTextChapterContext(
@@ -72,7 +80,8 @@ struct RDEPUBTextTypesetterPipeline {
html: markedHTML,
baseURL: input.baseURL,
stylesheet: RDEPUBTextStyleSheetPackage(layers: styleSheetComposition.layers),
resourceDiagnostics: diagnostics
resourceDiagnostics: diagnostics,
styleCompatibilityReport: compatibilityReport
)
let request = RDEPUBTextChapterRenderRequest(
context: context,
@@ -82,7 +91,27 @@ struct RDEPUBTextTypesetterPipeline {
)
return RDEPUBTypesettingOutput(
request: request,
diagnostics: diagnostics
diagnostics: diagnostics,
styleCompatibilityReport: compatibilityReport
)
}
private static func mergedCompatibilityReport(
_ report: RDEPUBCSSCompatibilityReport,
fontResults: [RDEPUBFontRegistrationResult]
) -> RDEPUBCSSCompatibilityReport {
let fontFailures = fontResults.compactMap { result -> String? in
guard result.didRegister == false else { return nil }
let family = result.descriptor.family ?? "unknown-family"
let href = result.descriptor.href
let reason = result.errorDescription ?? "Font registration failed"
return "\(family) <\(href)>: \(reason)"
}
return RDEPUBCSSCompatibilityReport(
unsupportedRules: report.unsupportedRules,
normalizedRules: report.normalizedRules,
fontFailures: report.fontFailures + fontFailures
)
}
}
@@ -0,0 +1,24 @@
import UIKit
final class RDEPUBNotePopupCoordinator {
static func present(
_ note: RDEPUBResolvedNote,
from controller: UIViewController,
onReturnToSource: (() -> Void)? = nil,
onOpenNoteLocation: (() -> Void)? = nil
) {
let popup = RDEPUBNotePopupViewController(
note: note,
onReturnToSource: onReturnToSource,
onOpenNoteLocation: onOpenNoteLocation
)
let navigationController = UINavigationController(rootViewController: popup)
navigationController.modalPresentationStyle = .pageSheet
if let sheet = navigationController.sheetPresentationController {
sheet.detents = [.medium(), .large()]
sheet.prefersGrabberVisible = true
}
controller.present(navigationController, animated: true)
}
}
@@ -0,0 +1,145 @@
import UIKit
final class RDEPUBNotePopupViewController: UIViewController {
private let note: RDEPUBResolvedNote
private let onReturnToSource: (() -> Void)?
private let onOpenNoteLocation: (() -> Void)?
private let textView = UITextView()
private let actionsStackView = UIStackView()
init(
note: RDEPUBResolvedNote,
onReturnToSource: (() -> Void)? = nil,
onOpenNoteLocation: (() -> Void)? = nil
) {
self.note = note
self.onReturnToSource = onReturnToSource
self.onOpenNoteLocation = onOpenNoteLocation
super.init(nibName: nil, bundle: nil)
title = note.title ?? "注释"
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
navigationItem.rightBarButtonItem = UIBarButtonItem(
barButtonSystemItem: .done,
target: self,
action: #selector(close)
)
textView.translatesAutoresizingMaskIntoConstraints = false
textView.isEditable = false
textView.alwaysBounceVertical = true
textView.backgroundColor = .clear
textView.textContainerInset = UIEdgeInsets(top: 18, left: 18, bottom: 24, right: 18)
textView.attributedText = attributedContent()
textView.accessibilityIdentifier = "epub.reader.note.text"
actionsStackView.translatesAutoresizingMaskIntoConstraints = false
actionsStackView.axis = .horizontal
actionsStackView.spacing = 12
actionsStackView.distribution = .fillEqually
actionsStackView.accessibilityIdentifier = "epub.reader.note.actions"
configureActionButtons()
view.addSubview(actionsStackView)
view.addSubview(textView)
NSLayoutConstraint.activate([
actionsStackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 18),
actionsStackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -18),
actionsStackView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12),
actionsStackView.heightAnchor.constraint(equalToConstant: actionsStackView.arrangedSubviews.isEmpty ? 0 : 36),
textView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
textView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
textView.topAnchor.constraint(equalTo: actionsStackView.bottomAnchor, constant: actionsStackView.arrangedSubviews.isEmpty ? 0 : 8),
textView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
}
private func configureActionButtons() {
if note.sourceLocation != nil, onReturnToSource != nil {
actionsStackView.addArrangedSubview(
makeActionButton(title: "返回原文", action: #selector(returnToSource))
)
}
if onOpenNoteLocation != nil {
actionsStackView.addArrangedSubview(
makeActionButton(title: "打开注释原文", action: #selector(openNoteLocation))
)
}
actionsStackView.isHidden = actionsStackView.arrangedSubviews.isEmpty
}
private func makeActionButton(title: String, action: Selector) -> UIButton {
let button = UIButton(type: .system)
var configuration = UIButton.Configuration.filled()
configuration.cornerStyle = .medium
configuration.title = title
configuration.baseBackgroundColor = .secondarySystemBackground
configuration.baseForegroundColor = .label
button.configuration = configuration
if title == "返回原文" {
button.accessibilityIdentifier = "epub.reader.note.return"
} else if title == "打开注释原文" {
button.accessibilityIdentifier = "epub.reader.note.open"
}
button.addTarget(self, action: action, for: .touchUpInside)
return button
}
private func attributedContent() -> NSAttributedString {
let html = """
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {
font: -apple-system-body;
color: #1f1f1f;
line-height: 1.55;
}
a { color: #3b6ea8; }
img, svg, table { max-width: 100%; height: auto; }
</style>
</head>
<body>\(note.html)</body>
</html>
"""
guard let data = html.data(using: .utf8),
let attributed = try? NSMutableAttributedString(
data: data,
options: [
.documentType: NSAttributedString.DocumentType.html,
.characterEncoding: String.Encoding.utf8.rawValue
],
documentAttributes: nil
) else {
return NSAttributedString(string: note.plainText)
}
return attributed
}
@objc private func close() {
dismiss(animated: true)
}
@objc private func returnToSource() {
dismiss(animated: true) { [onReturnToSource] in
onReturnToSource?()
}
}
@objc private func openNoteLocation() {
dismiss(animated: true) { [onOpenNoteLocation] in
onOpenNoteLocation?()
}
}
}
@@ -1,27 +1,17 @@
import UIKit
// MARK: - 底部工具栏
/// 阅读器底部工具栏
/// 提供目录、书签、高亮管理、新建标注和设置五个功能入口
/// 按钮可见性可根据 configuration 动态调整
final class RDEPUBReaderBottomToolView: RDEPUBReaderToolView {
// MARK: 回调闭包
/// 点击目录按钮回调
var onShowTableOfContents: (() -> Void)?
/// 点击书签列表按钮回调
var onShowBookmarks: (() -> Void)?
/// 点击高亮列表按钮回调
var onShowHighlights: (() -> Void)?
/// 点击新建标注按钮回调
var onAddHighlight: (() -> Void)?
/// 点击设置按钮回调
var onShowSettings: (() -> Void)?
// MARK: UI 组件
/// 水平等分布局的按钮容器
private let stackView: UIStackView = {
let view = UIStackView()
view.axis = .horizontal
@@ -31,21 +21,20 @@ final class RDEPUBReaderBottomToolView: RDEPUBReaderToolView {
return view
}()
/// 目录按钮
private let chapterButton = RDEPUBReaderTintButton(type: .system)
/// 书签列表按钮
private let bookmarksButton = RDEPUBReaderTintButton(type: .system)
/// 高亮列表按钮
private let highlightsButton = RDEPUBReaderTintButton(type: .system)
/// 新建标注按钮
private let addHighlightButton = RDEPUBReaderTintButton(type: .system)
/// 设置按钮
private let settingsButton = RDEPUBReaderTintButton(type: .system)
override init(frame: CGRect) {
super.init(frame: frame)
accessibilityIdentifier = "epub.reader.bottomToolbar"
addSubview(stackView)
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.addArrangedSubview(chapterButton)
@@ -99,9 +88,6 @@ final class RDEPUBReaderBottomToolView: RDEPUBReaderToolView {
}
}
// MARK: 按钮状态控制
/// 设置新建标注按钮是否可用(有选中文本时可用)
func setAddHighlightEnabled(_ isEnabled: Bool) {
addHighlightButton.isEnabled = isEnabled
addHighlightButton.alpha = isEnabled ? 1 : 0.45
@@ -1,15 +1,13 @@
import UIKit
// MARK: - 目录面板
/// EPUB 阅读器的目录面板控制器
/// 以列表形式展示书籍目录,支持多级缩进,当前所在章节高亮显示
final class RDEPUBReaderChapterListController: UITableViewController {
/// 用户选择目录项时的回调
var onSelectItem: ((RDEPUBReaderTableOfContentsItem) -> Void)?
private let items: [RDEPUBReaderTableOfContentsItem]
private let currentItem: RDEPUBReaderTableOfContentsItem?
private let theme: RDEPUBReaderTheme
init(
@@ -1,19 +1,8 @@
// RDEPUBReaderController+ContentDelegates.swift
// EPUB 阅读器内容视图代理实现
// 处理 Web 渲染路径(RDEPUBWebContentViewDelegate)和 Native Text 渲染路径
// (RDEPUBTextContentViewDelegate)的位置更新、选区变化、链接跳转、错误日志等事件回调。
import UIKit
/// RDEPUBReaderController 内容代理扩展
///
/// 本文件实现 EPUB 阅读器的内容视图代理,处理 Web 渲染路径和 Native Text 渲染路径的
/// 位置更新、选区变化、链接跳转、错误日志等事件回调。
// MARK: - Web 内容视图代理(EPUB 固定布局/Web 渲染路径)
extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
/// Web 内容视图位置更新回调,同步阅读上下文与持久化位置
func epubWebContentView(_ contentView: RDEPUBWebContentView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int) {
guard readerView.currentPage >= 0,
activePages.indices.contains(readerView.currentPage) else {
@@ -35,7 +24,6 @@ extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
)
}
/// Web 内容视图选区变化回调,转换为统一选区模型
func epubWebContentView(_ contentView: RDEPUBWebContentView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int) {
if let selection {
updateCurrentSelection(scopedSelection(selection, relativeToSpineIndex: spineIndex))
@@ -44,13 +32,15 @@ extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
}
}
/// Web 内容视图选区菜单操作回调
func epubWebContentView(_ contentView: RDEPUBWebContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) {
handleSelectionMenuAction(action, selection: currentSelection)
}
/// Web 内容视图内部链接点击回调,执行页内跳转
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int) {
if presentNotePopupIfPossible(for: location, fromSpineIndex: fromSpineIndex) {
return
}
guard let readingSession,
let pageNumber = readingSession.queueNavigation(
to: location,
@@ -62,13 +52,11 @@ extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
readerView.transitionToPage(pageNum: max(pageNumber - 1, 0), animated: true)
}
/// Web 内容视图外部链接点击回调,根据配置策略决定是否打开
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateExternalLink url: URL) {
delegate?.epubReader(self, didActivateExternalLink: url)
openExternalURLIfAllowed(url)
}
/// Web 内容视图 JavaScript 错误日志回调
func epubWebContentView(_ contentView: RDEPUBWebContentView, didLogJavaScriptError message: String) {
#if DEBUG
print("EPUB JS Error: \(message)")
@@ -76,10 +64,8 @@ extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
}
}
// MARK: - 文本内容视图代理(Native Text 渲染路径)
extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
/// 文本内容视图选区变化回调,标准化后更新当前选区
func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?) {
guard let selection else {
updateCurrentSelection(nil)
@@ -88,7 +74,10 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
updateCurrentSelection(selection)
}
/// 文本内容视图选区菜单操作回调
func textContentView(_ contentView: RDEPUBTextContentView, didRequestReaderTapAt point: CGPoint) {
readerView.handleContentTap(at: point, in: contentView)
}
func textContentView(
_ contentView: RDEPUBTextContentView,
didRequestSelectionAction action: RDEPUBAnnotationMenuAction,
@@ -115,8 +104,6 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
runtime.presentHighlightActions(for: highlight, sourceView: contentView, sourceRect: sourceRect)
}
/// 根据 EPUB 位置计算对应的页码
func pageNumber(for location: RDEPUBLocation) -> Int? {
if let publication,
let bookPageMap = readerContext.bookPageMap,
@@ -169,38 +156,14 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
)
}
/// 根据页码解析对应的文本位置
func resolvedTextLocation(forPageNumber pageNumber: Int) -> RDEPUBLocation? {
if let resolvedPage = resolvedRuntimePage(forPageNumber: pageNumber) {
let chapterLength = max(resolvedPage.chapter.typesetAttributedString.length, 1)
let startOffset = resolvedPage.page.pageStartOffset
let endOffset = max(startOffset, resolvedPage.page.pageEndOffset)
let fragmentID = nearestFragmentID(
beforeOrAt: startOffset,
fragmentOffsets: resolvedPage.chapter.chapterOffsetMap.fragmentOffsets
)
let location = RDEPUBLocation(
bookIdentifier: currentBookIdentifier,
href: resolvedPage.page.href,
progression: Double(startOffset) / Double(chapterLength),
lastProgression: Double(endOffset) / Double(chapterLength),
fragment: fragmentID,
rangeAnchor: RDEPUBTextRangeAnchor(
start: RDEPUBTextAnchor(
fileIndex: resolvedPage.page.spineIndex,
row: 0,
column: 0,
chapterOffset: startOffset,
fragmentID: fragmentID
),
end: RDEPUBTextAnchor(
fileIndex: resolvedPage.page.spineIndex,
row: 0,
column: 0,
chapterOffset: endOffset,
fragmentID: fragmentID
)
)
let chapterData = makeRuntimeChapterData(from: resolvedPage)
let location = chapterData.location(
for: NSRange(location: startOffset, length: max(endOffset - startOffset + 1, 1)),
bookIdentifier: currentBookIdentifier
)
if let publication {
return publication.resourceResolver.normalizedLocation(
@@ -231,7 +194,6 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
return location
}
/// 同步文本阅读状态到阅读会话(页码、位置、spine、章节等)
func synchronizeTextReadingState(pageNumber: Int, location: RDEPUBLocation) {
if let resolvedPage = resolvedRuntimePage(forPageNumber: pageNumber) {
readingSession?.updateReadingContext(
@@ -259,7 +221,6 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
)
}
/// 从文本书籍生成原生文本快照(页面列表 + 章节信息)
func nativeTextSnapshot(from textBook: RDEPUBTextBook) -> RDEPUBNativeTextSnapshot {
let chapters = textBook.chapterInfos
let pages = textBook.pages.map {
@@ -280,6 +241,15 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
}
func chapterOffset(for location: RDEPUBLocation, fallbackEntry: RDEPUBBookPageMapEntry) -> Int {
if let spineIndex = readerContext.normalizedSpineIndex(for: location),
let runtimeChapter = runtime.chapterRuntimeStore.chapterData(for: spineIndex),
let offset = runtimeChapter.chapterOffsetMap.chapterOffset(forCFI: location.cfi) {
return offset
}
if let cfi = RDEPUBCFICompatibility.parseLossy(location.cfi),
let cfiOffset = RDEPUBCFIResolver.resolve(cfi).chapterOffset {
return cfiOffset
}
if let anchor = location.rangeAnchor?.start {
return anchor.chapterOffset
}
@@ -308,8 +278,6 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
return bestID
}
// MARK: - 外部链接策略
private func shouldAllowExternalURL(_ url: URL) -> Bool {
guard let scheme = url.scheme?.lowercased() else { return false }
if delegate?.epubReader(self, shouldOpenExternalURL: url) == false {
@@ -340,6 +308,52 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
present(alert, animated: true)
}
private func presentNotePopupIfPossible(for location: RDEPUBLocation, fromSpineIndex: Int) -> Bool {
guard let publication else { return false }
let sourceHref = publication.resourceResolver.href(forSpineIndex: fromSpineIndex) ?? location.href
let sourceLocation = currentVisibleLocation()
let sourceCFI = sourceLocation?.cfi
let resolver = RDEPUBNoteResolver(resourceResolver: publication.resourceResolver)
guard let note = resolver.resolveInternalLink(
sourceHref: sourceHref,
sourceCFI: sourceCFI,
targetLocation: location,
sourceLocation: sourceLocation,
relativeToSpineIndex: fromSpineIndex
) else {
return false
}
RDEPUBNotePopupCoordinator.present(
note,
from: self,
onReturnToSource: { [weak self] in
guard let self, let sourceLocation = note.sourceLocation else { return }
self.navigateToLocation(sourceLocation, relativeToSpineIndex: nil, animated: true)
},
onOpenNoteLocation: { [weak self] in
guard let self else { return }
self.navigateToLocation(note.targetLocation, relativeToSpineIndex: nil, animated: true)
}
)
return true
}
private func navigateToLocation(
_ location: RDEPUBLocation,
relativeToSpineIndex spineIndex: Int?,
animated: Bool
) {
guard let pageNumber = readingSession?.queueNavigation(
to: location,
relativeToSpineIndex: spineIndex,
bookIdentifier: currentBookIdentifier
) else {
return
}
readerView.transitionToPage(pageNum: max(pageNumber - 1, 0), animated: animated)
}
private func presentAttachmentTooltip(text: String, sourceView: UIView, sourceRect: CGRect, sourcePoint: CGPoint) {
hideAttachmentTooltipIfNeeded()
@@ -415,7 +429,28 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
}
}
private extension RDEPUBReaderController {
func makeRuntimeChapterData(from resolvedPage: RDEPUBResolvedPage) -> RDEPUBChapterData {
let textChapter = RDEPUBTextChapter(
chapterIndex: resolvedPage.chapterIndex,
spineIndex: resolvedPage.chapter.spineIndex,
href: resolvedPage.chapter.href,
title: resolvedPage.chapter.title,
attributedContent: resolvedPage.chapter.typesetAttributedString,
fragmentOffsets: resolvedPage.chapter.chapterOffsetMap.fragmentOffsets,
cfiMap: resolvedPage.chapter.chapterOffsetMap.cfiMap,
pageBreakReasons: resolvedPage.chapter.pages.map(\.metadata.breakReason),
pages: resolvedPage.chapter.pages
)
return RDEPUBChapterData(
chapter: textChapter,
indexTable: RDEPUBTextIndexTable(chapters: [textChapter])
)
}
}
private final class RDEPUBAttachmentTooltipOverlayView: UIView {
var onBackgroundTap: (() -> Void)?
var tooltipView: RDEPUBAttachmentTooltipView? {
@@ -449,17 +484,26 @@ private final class RDEPUBAttachmentTooltipOverlayView: UIView {
}
private final class RDEPUBAttachmentTooltipView: UIView {
enum ArrowPlacement {
case top
case bottom
}
private let contentInsets = UIEdgeInsets(top: 18, left: 20, bottom: 24, right: 20)
private let arrowSize = CGSize(width: 20, height: 10)
private let cornerRadius: CGFloat = 18
private(set) var minimumArrowX: CGFloat = 28
private var arrowTipX: CGFloat?
private var arrowPlacement: ArrowPlacement = .bottom
private let textLabel: UILabel = {
let label = UILabel()
label.numberOfLines = 0
@@ -468,6 +512,7 @@ private final class RDEPUBAttachmentTooltipView: UIView {
label.lineBreakMode = .byWordWrapping
return label
}()
private let shapeLayer = CAShapeLayer()
override init(frame: CGRect) {

Some files were not shown because too many files have changed in this diff Show More