Compare commits
No commits in common. "8ccb7157b28a95097f148724b167e62a48208e6a" and "6f75b083f76404756a9dad217ea359201cc386dc" have entirely different histories.
8ccb7157b2
...
6f75b083f7
23
.gitignore
vendored
23
.gitignore
vendored
@ -1,26 +1,3 @@
|
||||
|
||||
# Xcode user data
|
||||
xcuserdata/
|
||||
|
||||
# Build artifacts
|
||||
.artifacts/
|
||||
.deriveddata/
|
||||
.swift-module-cache/
|
||||
|
||||
# This repository checks in the Demo Pods project. Keep generated files for the
|
||||
# local RDEpubReaderView pod visible even when a global gitignore excludes Pods.
|
||||
!ReadViewDemo/Pods/
|
||||
!ReadViewDemo/Pods/Local Podspecs/
|
||||
!ReadViewDemo/Pods/Local Podspecs/RDEpubReaderView.podspec.json
|
||||
!ReadViewDemo/Pods/Target Support Files/
|
||||
!ReadViewDemo/Pods/Target Support Files/RDEpubReaderView/
|
||||
!ReadViewDemo/Pods/Target Support Files/RDEpubReaderView/**
|
||||
|
||||
# macOS system files
|
||||
.DS_Store
|
||||
|
||||
# Local backup directories
|
||||
*手动解包备份*
|
||||
|
||||
# AI tool output
|
||||
_ssoft-output/
|
||||
|
||||
151
CODE_REVIEW.md
151
CODE_REVIEW.md
@ -1,151 +0,0 @@
|
||||
# ReadViewSDK 代码审查报告
|
||||
|
||||
> 审查日期:2026-06-26
|
||||
> 审查范围:Sources/RDEpubReaderView 全部源码
|
||||
> 审查方法:逐文件阅读 + 交叉验证 + 线程模型分析
|
||||
|
||||
---
|
||||
|
||||
## ✅ 确认成立的问题
|
||||
|
||||
### P1-1 WKURLSchemeHandler 同步文件 I/O 阻塞调用线程
|
||||
|
||||
**文件**: `EPUBCore/RDEPUBResourceURLSchemeHandler.swift:51-174`
|
||||
|
||||
**现象**: `webView(_:start:)` 回调中,所有文件读取均同步执行:
|
||||
|
||||
- `respondWithInMemoryData`(第119行)使用 `Data(contentsOf:)` 全量读入 ≤512KB 的文件
|
||||
- `respondWithStreaming`(第143行)在 `while true` 循环中同步读 64KB 块并逐块回调 `urlSchemeTask.didReceive(data)`
|
||||
|
||||
Apple 未保证 WKURLSchemeHandler 回调线程为主线程,实际在 WKWebView 内部队列上执行。同步阻塞该线程会:
|
||||
|
||||
- 若在主线程:直接造成 UI 卡顿/ANR
|
||||
- 若在 WKWebView 内部队列:阻塞资源加载管线,影响渲染时序
|
||||
|
||||
**建议**: 将文件读取移至后台队列,通过 `DispatchQueue.main.async` 或回调队列回传 `didReceive`/`didFinish`。流式场景可改为分批异步读取。
|
||||
|
||||
---
|
||||
|
||||
### P1-2 EPUB 解压缓存目录无清理策略
|
||||
|
||||
**文件**: `EPUBCore/RDEPUBParser+Archive.swift:79-90`
|
||||
|
||||
**现象**: `temporaryExtractionDirectory(for:)` 按 `(slug)-(fileSize)-(modificationTimestamp)` 生成缓存目录,存放在 `Caches/ssreaderview-epub/`。全局搜索确认不存在任何清理逻辑——无 LRU 淘汰、无总量上限、无 `removeItem` 调用指向该目录。
|
||||
|
||||
一本 50MB 的 EPUB 解压后约 100-200MB,用户打开多本书后缓存目录无限增长。
|
||||
|
||||
**建议**: 添加缓存清理策略:
|
||||
1. 提供 `clearCache()` 公开方法供宿主 App 在低存储时调用
|
||||
2. 每次打开新书时按访问时间淘汰超出上限的旧缓存
|
||||
3. 或采用 `FileManager.default.urls(for: .cachesDirectory:)` 依赖系统自动清理
|
||||
|
||||
---
|
||||
|
||||
### P1-3 搜索在主线程同步执行
|
||||
|
||||
**文件**: `EPUBUI/ReaderController/RDEPUBReaderSearchCoordinator.swift:15-36`
|
||||
|
||||
**现象**: `search(keyword:)` 直接调用 `resolvedSearchMatches(for:)`,该方法遍历整本书的所有 spine 条目执行 HTML→文本转换或章节同步加载 + NSRange 搜索,全程在主线程完成。
|
||||
|
||||
对于大型 EPUB(数百章):
|
||||
- UI 完全冻结直到搜索完成
|
||||
- 用户无法取消或看到进度
|
||||
- 可能触发 iOS 看门狗终止(0x8badf00d)
|
||||
|
||||
`resolvedOnDemandSearchMatches(for:)`(第125行)更严重:它同步加载每个章节(`loadChapterSynchronouslyForMigration`),整本书搜索意味着逐章同步加载。
|
||||
|
||||
**建议**: 将搜索逻辑移至后台队列,对大型书籍实施分批搜索 + 渐进结果回调,并在 UI 层添加搜索进度指示和取消能力。
|
||||
|
||||
---
|
||||
|
||||
### P2-1 ZIP 无效条目导致整本 EPUB 打开失败
|
||||
|
||||
**文件**: `EPUBCore/RDEPUBParser+Archive.swift:41-44`
|
||||
|
||||
**现象**: 解压循环中,若 `validatedExtractionDestination` 返回 `nil`(条目路径验证失败),直接 `throw` 终止整本书的解析。实际 EPUB 中常包含无害条目(macOS `__MACOSX/` 目录、`.DS_Store`、Thumbs.db 等),这些不应阻止打开。
|
||||
|
||||
```swift
|
||||
for entry in archive {
|
||||
guard let destinationURL = validatedExtractionDestination(for: entry.path, ...) else {
|
||||
throw RDEPUBParserError.invalidArchiveEntryPath(entry.path) // 应改为 continue
|
||||
}
|
||||
```
|
||||
|
||||
**建议**: 将验证失败的条目改为 `continue` + 日志警告,并增加对已知无害条目路径的跳过逻辑。
|
||||
|
||||
---
|
||||
|
||||
### P2-2 javaScriptStringLiteral 实现依赖隐式假设
|
||||
|
||||
**文件**: `EPUBCore/RDEPUBJavaScriptBridge.swift:169-174`
|
||||
|
||||
**现象**: `javaScriptStringLiteral` 将值包装进单元素数组做 JSON 编码,再暴力去掉 `[` 和 `]`:
|
||||
|
||||
```swift
|
||||
private static func javaScriptStringLiteral(_ value: String?) -> String {
|
||||
guard let value else { return "null" }
|
||||
return jsonString(from: [value], fallback: "[null]")
|
||||
.replacingOccurrences(of: "[", with: "")
|
||||
.replacingOccurrences(of: "]", with: "")
|
||||
}
|
||||
```
|
||||
|
||||
这不是 XSS/注入问题(`JSONSerialization` 已正确转义引号/反斜杠等危险字符),但该方法依赖"值不含 `[` 或 `]`"的隐式假设。当前调用场景传入的是 CSS 颜色值(如 `#FFFFFF`、`rgba(...)`),不含方括号,所以实际安全。但未来如果有人将包含方括号的值传入,输出会被静默破坏。
|
||||
|
||||
**建议**: 改为更直观的安全编码方式,如直接对单值做 `JSONSerialization` 后去掉两端引号,或添加文档注释明确标注此方法的约束前提。
|
||||
|
||||
---
|
||||
|
||||
### P3-1 后台线程同步 hop 主线程获取布局信息
|
||||
|
||||
**文件**: `EPUBUI/ReaderController/RDEPUBReaderContext.swift:160-186`
|
||||
|
||||
**现象**: `currentTextPageSize()` 在非主线程且缓存不可用时,通过 `DispatchQueue.main.sync` 回到主线程获取布局信息。调用方包括 `RDEPUBMetadataParseWorker`(后台初始化,第72行)和 `RDEPUBChapterLoader`(`chapterLoadQueue.async`,第298行)。
|
||||
|
||||
```swift
|
||||
func currentTextPageSize() -> CGSize {
|
||||
if Thread.isMainThread {
|
||||
// 主线程路径 — 安全
|
||||
...
|
||||
} else if let lastTextPaginationPageSize, ... {
|
||||
// 缓存路径 — 无需 hop
|
||||
return lastTextPaginationPageSize
|
||||
} else {
|
||||
let mainThreadSize = DispatchQueue.main.sync { [weak self] in
|
||||
self?.currentTextPageSize() ?? .zero
|
||||
}
|
||||
```
|
||||
|
||||
`Thread.isMainThread` 保护使得从主线程调用不会死锁。实际风险是**时序耦合**:后台任务阻塞等待主线程布局信息,如果主线程正在忙于 UI 操作(如翻页动画),后台线程会被卡住直到主线程空闲。这是一个线程模型/可维护性问题,而非可直接触发的死锁。
|
||||
|
||||
**建议**: 将布局参数作为初始化参数传入后台任务,而非在后台线程通过 `DispatchQueue.main.sync` 获取。这样后台线程完全自主,不依赖主线程时序。
|
||||
|
||||
---
|
||||
|
||||
## ✅ 已撤回的原始误判
|
||||
|
||||
以下条目经逐条代码验证后确认不成立或定级过高:
|
||||
|
||||
| 原始编号 | 原始结论 | 修正结论 |
|
||||
|---------|---------|---------|
|
||||
| ~~P0 引用循环~~ | Runtime/Coordinator 强引用 context 形成循环 | **不成立**。全量搜索确认所有 Coordinator/Runtime 均使用 `unowned let context` 或 `weak var context` |
|
||||
| ~~P0 XSS 注入~~ | `javaScriptStringLiteral` 的 `]` 替换导致注入 | **不成立**。`JSONSerialization` 编码已处理引号/反斜杠等危险字符。实际是健壮性问题,非安全问题(已调整为 P2-2) |
|
||||
| ~~P1 Publication 暴露 parser~~ | 外部可直接修改 parser 内部状态 | **不成立**。`RDEPUBParser` 关键属性均为 `public internal(set)`,外部模块无法修改 |
|
||||
| ~~P2 NSRange 越界~~ | UTF-16 和 Swift String 混用导致偏移 | **不成立**。搜索代码全程在 `NSString` + `NSRange` 域内操作,未混合 Swift String 索引 |
|
||||
| ~~P2 后台 deinit 崩溃~~ | teardownWebView 可能在后台线程执行 | **证据不足**。`RDEPUBWebView` 是 UIView 子类,正常在主线程释放,无证据表明会后台释放 |
|
||||
| ~~P0 ZIP 路径遍历绕过~~ | `%2e%2e` 可绕过路径校验 | **论证过头**。当前实现有组件级 `..` 检查 + `standardizedFileURL` 前缀校验,双层防护有效 |
|
||||
|
||||
---
|
||||
|
||||
## 📊 问题汇总
|
||||
|
||||
| 严重度 | 编号 | 问题 | 类型 |
|
||||
|--------|------|------|------|
|
||||
| 🟠 P1 | P1-1 | WKURLSchemeHandler 同步文件 I/O 阻塞调用线程 | 性能 |
|
||||
| 🟠 P1 | P1-2 | EPUB 解压缓存目录无清理策略 | 存储 |
|
||||
| 🟠 P1 | P1-3 | 搜索在主线程同步执行,大书可致 ANR | 性能 |
|
||||
| 🟡 P2 | P2-1 | ZIP 无效条目导致整本打开失败 | 鲁棒性 |
|
||||
| 🟡 P2 | P2-2 | javaScriptStringLiteral 实现依赖隐式假设 | 可维护性 |
|
||||
| 🔵 P3 | P3-1 | 后台线程同步 hop 主线程获取布局信息 | 线程模型 |
|
||||
|
||||
**整体评价**: SDK 架构设计良好,模块分层清晰,引用管理(unowned/weak)使用正确,分页取消机制有 `paginationToken` + `cancellationController` 兜底。主要值得修复的是三个性能类 P1 问题(主线程阻塞搜索、资源加载阻塞、缓存无清理),两个 P2 鲁棒性/可维护性问题,以及一个 P3 线程模型优化建议。
|
||||
@ -1,483 +0,0 @@
|
||||
# 公开 API 参考手册
|
||||
|
||||
> 最后更新:2026-06-18
|
||||
|
||||
本文档列出 ReadViewSDK 所有公开 API 的完整签名、参数说明和使用方法。
|
||||
|
||||
---
|
||||
|
||||
## 1. RDEPUBReaderController — 入口控制器
|
||||
|
||||
**文件**:`Sources/RDEpubReaderView/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/RDEpubReaderView/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/RDEpubReaderView/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/RDEpubReaderView/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` | `RDEpubReaderView.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/RDEpubReaderView/ReaderView/RDEpubReaderViewProtocols.swift`
|
||||
|
||||
### 5.1 RDEpubReaderPageProvider(推荐)
|
||||
|
||||
```swift
|
||||
@objc public protocol RDEpubReaderPageProvider: NSObjectProtocol {
|
||||
|
||||
/// 总页数
|
||||
func numberOfPages(in readerView: RDEpubReaderView) -> Int
|
||||
|
||||
/// 返回指定页的视图
|
||||
/// - Parameters:
|
||||
/// - index: 页码索引(0-based)
|
||||
/// - reusableView: 可复用的旧视图(可能为 nil)
|
||||
func readerView(_ readerView: RDEpubReaderView, viewForPageAt index: Int, reusableView: UIView?) -> UIView
|
||||
|
||||
/// 页面标识符(用于缓存去重)
|
||||
@objc optional func pageIdentifier(in readerView: RDEpubReaderView, index: Int) -> String?
|
||||
|
||||
/// 顶部工具栏视图
|
||||
@objc optional func readerViewTopChrome(_ readerView: RDEpubReaderView) -> UIView?
|
||||
|
||||
/// 底部工具栏视图
|
||||
@objc optional func readerViewBottomChrome(_ readerView: RDEpubReaderView) -> UIView?
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 RDEpubReaderDelegate
|
||||
|
||||
```swift
|
||||
@objc public protocol RDEpubReaderDelegate: NSObjectProtocol {
|
||||
|
||||
/// 页面变化回调
|
||||
func pageNum(readerView: RDEpubReaderView, pageNum: Int)
|
||||
|
||||
/// 屏幕方向即将变化
|
||||
@objc optional func readerViewOrientationWillChange(readerView: RDEpubReaderView, isLandscape: Bool)
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 RDEpubReaderPageNavigating
|
||||
|
||||
```swift
|
||||
public protocol RDEpubReaderPageNavigating: AnyObject {
|
||||
|
||||
/// 当前页码
|
||||
var currentPage: Int { get }
|
||||
|
||||
/// 重新加载所有页面
|
||||
func reloadPages()
|
||||
|
||||
/// 跳转到指定页
|
||||
func transition(to page: Int, animated: Bool)
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 RDEpubReaderDataSource(遗留)
|
||||
|
||||
```swift
|
||||
@objc public protocol RDEpubReaderDataSource: NSObjectProtocol {
|
||||
func pageCountOfReaderView(readerView: RDEpubReaderView) -> Int
|
||||
func pageContentView(readerView: RDEpubReaderView, pageNum: Int, containerView: UIView?) -> UIView
|
||||
func pageIdentifier(readerView: RDEpubReaderView, pageNum: Int) -> String?
|
||||
@objc optional func topToolView(readerView: RDEpubReaderView) -> UIView?
|
||||
@objc optional func bottomToolView(readerView: RDEpubReaderView) -> UIView?
|
||||
}
|
||||
```
|
||||
|
||||
通过 `RDEpubReaderLegacyDataSourceAdapter` 自动适配到 `RDEpubReaderPageProvider`。
|
||||
|
||||
---
|
||||
|
||||
## 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?
|
||||
}
|
||||
```
|
||||
115
Doc/ARCHITECTURE-CONTEXT.md
Normal file
115
Doc/ARCHITECTURE-CONTEXT.md
Normal file
@ -0,0 +1,115 @@
|
||||
# 架构分析:WXRead 参考 vs ReadViewSDK 当前
|
||||
|
||||
**分析日期:** 2026-05-23
|
||||
**分析基础:** `Doc/WXRead/decompiled-doc.md`、`Doc/WXRead/resources-doc.md`、`Doc/WXRead/读书EPUB阅读器实现架构.md`
|
||||
**状态:** Decisions captured
|
||||
|
||||
---
|
||||
|
||||
<domain>
|
||||
## 分析范围
|
||||
|
||||
对比读书 (WeRead v10.0.3) 逆向架构与当前 ReadViewSDK 项目的结构性差距,聚焦四大方向:分页引擎集成、CSS 处理、渲染架构、字体系统。
|
||||
|
||||
读书 EPUB 渲染的核心架构特征:
|
||||
- Path A (EPUB): 纯 CoreText 渲染 — `WRPageView.drawRect:` → `CTFrameDraw`,不用 UITextView/UILabel
|
||||
- 4 级语义断页:`WRCoreTextLayouter` + `WRCoreTextLayoutFrame` (语义边界 > 附件边界 > 块边界 > 帧限制)
|
||||
- 5 层 CSS 级联:`default.css < replace.css < dark.css < EPUB 内嵌 < 用户设置`
|
||||
- 字符级位置精度:`WREpubPositionConverter` (fileIndex, row, column) ↔ 全局字符偏移
|
||||
- 标注直接在 CTFrame 层叠加绘制,搜索高亮同理
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## 已决事项
|
||||
|
||||
### Area 1: 分页引擎集成(P0)
|
||||
|
||||
| 决策 | 说明 |
|
||||
|------|------|
|
||||
| 将 `RDEPUBTextLayouter` 集成到 `RDEPUBTextBookBuilder` | 内部调用 `rd_paginatedFrames(size:)` 替代旧的 `ss_pageRanges(size:)`,外部 API 不变 |
|
||||
| 分页元数据暴露到 `RDEPUBTextPage` | 新增 `metadata: RDEPUBTextPageMetadata` 字段(含 breakReason/blockKinds/semanticHints),对外暴露分页质量数据 |
|
||||
| `RDEPUBTextChapterPaginationDiagnostic` 增强 | 透传 RDEPUBTextLayoutFrame 的诊断信息 |
|
||||
| 分页缓存 | 按 `bookID + fontSize + lineHeightMultiple + contentInsets` 生成缓存 key,缓存完整 `RDEPUBTextBook` 到磁盘 |
|
||||
|
||||
### Area 2: CSS `<link>` 外部样式表处理(P0)
|
||||
|
||||
| 决策 | 说明 |
|
||||
|------|------|
|
||||
| 预处理内联 CSS | 渲染前扫描 HTML 中 `<link>` 标签,从 EPUB 解压目录读取 CSS 内容,注入 `<style>` 替换 `<link>` |
|
||||
| 实现 5 层 CSS 级联 | `RDEPUBTextStyleSheetBuilder` 实现 `default < replace < dark < epub-embedded < user` 五层合并,使用已定义的 `RDEPUBTextStyleSheetPackage`/`RDEPUBTextStyleSheetLayer` |
|
||||
|
||||
### Area 3: CoreText 直接绘制迁移(P1 — 大架构变更)
|
||||
|
||||
| 决策 | 说明 |
|
||||
|------|------|
|
||||
| 直接替换 UITextView | 新实现完全替代 `RDEPUBTextContentView`,不保留 UITextView 渐进迁移路径 |
|
||||
| CoreText 原生选区 | `CTLineGetStringIndexForPosition` 坐标 hit test + 自定义选区绘制,不依赖 UITextView 选区 |
|
||||
| 标注渲染:CGContext 装饰层 | drawRect 中 CTFrameDraw 绘制文本后,遍历当前页 RDEPUBHighlight,CGContext 绘制背景矩形(highlight)/ 下划线(underline) |
|
||||
| 搜索高亮:CGContext 叠加绘制 | 不修改底层 attributedString,在 drawRect 中根据匹配范围直接绘制高亮背景 |
|
||||
|
||||
### Area 4: 字体系统(P1 — 延迟到后续版本)
|
||||
|
||||
| 决策 | 说明 |
|
||||
|------|------|
|
||||
| 方案:内嵌固定字体集 | SDK bundle 内嵌常用中文字体,Settings 面板新增字体选择。不做 CDN 动态下载 |
|
||||
| 本版本范围:暂不做 | 字体切换功能延迟到后续迭代 |
|
||||
|
||||
</decisions>
|
||||
|
||||
<canonical_refs>
|
||||
## 关键参考文档
|
||||
|
||||
**WXRead 逆向参考(必须阅读):**
|
||||
- `Doc/WXRead/decompiled-doc.md` — 44 个逆向文件的职责说明
|
||||
- `Doc/WXRead/resources-doc.md` — CSS/JS 资源文件清单与职责
|
||||
- `Doc/WXRead/读书EPUB阅读器实现架构.md` — 双渲染引擎架构总览
|
||||
- `Doc/WXRead/decompiled/WRCoreTextLayoutFrame.m` — 跨页避让、装饰元素、搜索高亮实现
|
||||
- `Doc/WXRead/decompiled/WRCoreTextLayouter.m` — 4 级语义断页配置
|
||||
- `Doc/WXRead/decompiled/WRPageView.m` — CoreText 直接绘制参考
|
||||
- `Doc/WXRead/decompiled/WREpubTypesetter.m` — CSS 级联合并参考
|
||||
- `Doc/WXRead/resources/css/replace.css` — 5 层 CSS 参考
|
||||
|
||||
**当前项目代码(已有的基础设施):**
|
||||
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextLayouter.swift` — 已实现 4 级语义断页,待集成
|
||||
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextLayoutFrame.swift` — 帧模型,含 breakReason/semanticHints
|
||||
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift` — 已定义 `RDEPUBTextStyleSheetPackage`/`RDEPUBTextStyleSheetLayer`,未完全使用
|
||||
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift` — 当前可能仍在用旧的 `ss_pageRanges`
|
||||
- `Sources/RDReaderView/EPUBUI/RDEPUBTextContentView.swift` — UITextView 实现,待替换为 CoreText
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<roadmap_mapping>
|
||||
## 与现有 Roadmap 的映射
|
||||
|
||||
| 决策 | 对应 Phase | 说明 |
|
||||
|------|-----------|------|
|
||||
| RDEPUBTextLayouter 集成 | Phase 8 (08-02) | 分页质量改善的核心 |
|
||||
| 分页元数据暴露 | Phase 8 (08-03) | 分页诊断输出的一部分 |
|
||||
| 分页缓存 | Phase 8 (08-01) | 缓存键与失效策略 |
|
||||
| CSS `<link>` 内联 | Phase 7 范畴外 | 当前 Roadmap 未覆盖,需新增 phase 或合并到 Phase 8 |
|
||||
| 5 层 CSS 级联 | Phase 7 + Phase 8 | Phase 7 已完成属性闭环,级联是后续增强 |
|
||||
| **CoreText 直接绘制** | **v1.2 或 v2.0** | **超出 v1.1 Roadmap,需要独立 milestone** |
|
||||
| CoreText 原生选区 | 随 CoreText 迁移 | 同上 |
|
||||
| 标注 CGContext 绘制 | 随 CoreText 迁移 | 同上 |
|
||||
| 字体系统 | 未来版本 | 延迟 |
|
||||
|
||||
**关键发现:** CoreText 直接绘制迁移是最大的架构变更,超出 v1.1 的增量改进范围。建议作为 v1.2 独立 milestone 规划。
|
||||
|
||||
</roadmap_mapping>
|
||||
|
||||
<deferred>
|
||||
## 延迟事项
|
||||
|
||||
- **字体系统**:内嵌固定字体集方案已决,延迟到后续版本
|
||||
- **位置精度提升**(字符级锚点):P2,与 CoreText 迁移关联
|
||||
- **章节数据模型内聚**(WRChapterData 模式):P2,架构清晰度改进
|
||||
- **TTS / DRM / Pencil / 多栏排版**:P3,独立功能模块
|
||||
- **翻页控制器健壮性**(UIPageViewController crash patch):P2,当前未遇到相关崩溃
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*分析范围:WXRead 逆向文档 → ReadViewSDK 架构差距*
|
||||
*产出:4 个 Area、14 项决策、1 项延迟*
|
||||
@ -1,6 +1,6 @@
|
||||
# ReadViewSDK 系统架构文档
|
||||
|
||||
> 最后更新:2026-06-22
|
||||
> 最后更新:2026-06-09
|
||||
|
||||
---
|
||||
|
||||
@ -22,7 +22,7 @@ ReadViewSDK 是一个 iOS EPUB 阅读器 SDK,支持文本重排(Reflowable
|
||||
│ ReadViewDemo (Demo App) │
|
||||
│ ViewController · LaunchAutomationPlan · UITests │
|
||||
└──────────────────────────────┬──────────────────────────────┘
|
||||
│ imports RDEpubReaderView
|
||||
│ imports RDReaderView
|
||||
┌──────────────────────────────▼──────────────────────────────┐
|
||||
│ EPUBUI 层 │
|
||||
│ RDEPUBReaderController · Coordinators · Settings · TextPage │
|
||||
@ -30,8 +30,8 @@ ReadViewSDK 是一个 iOS EPUB 阅读器 SDK,支持文本重排(Reflowable
|
||||
└──────────────────────────────┬──────────────────────────────┘
|
||||
│
|
||||
┌──────────────────────────────▼──────────────────────────────┐
|
||||
│ RDEpubReaderView 层 │
|
||||
│ RDEpubReaderView · FlowLayout · PreloadController │
|
||||
│ RDReaderView 层 │
|
||||
│ RDReaderView · FlowLayout · PreloadController │
|
||||
│ SpreadResolver · TapRegionHandler · PagingController │
|
||||
└──────────────────────────────┬──────────────────────────────┘
|
||||
│
|
||||
@ -54,7 +54,7 @@ ReadViewSDK 是一个 iOS EPUB 阅读器 SDK,支持文本重排(Reflowable
|
||||
|---|---|---|
|
||||
| **EPUBCore** | EPUB 文件解析、资源管理、WebView 渲染、JS 桥接 | `RDEPUBParser`, `RDEPUBPublication`, `RDEPUBWebView` |
|
||||
| **EPUBTextRendering** | HTML→NSAttributedString 转换、排版、分页、全书构建 | `RDEPUBTextBookBuilder`, `RDEPUBCoreTextPageFrameFactory` |
|
||||
| **RDEpubReaderView** | 通用翻页容器、手势识别、页面预加载、双页布局 | `RDEpubReaderView`, `RDEpubReaderFlowLayout`, `RDEpubReaderPreloadController` |
|
||||
| **RDReaderView** | 通用翻页容器、手势识别、页面预加载、双页布局 | `RDReaderView`, `RDReaderFlowLayout`, `RDReaderPreloadController` |
|
||||
| **EPUBUI** | 阅读器控制器、协调器模式、设置面板、按需加载、磁盘缓存 | `RDEPUBReaderController`, `RDEPUBReaderPaginationCoordinator` |
|
||||
|
||||
---
|
||||
@ -105,7 +105,7 @@ paginateTextPublication()
|
||||
│ ├─ makePartialPageMap() // 构建局部 BookPageMap
|
||||
│ └─ applyBookPageMap() // 应用到 UI,用户可立即阅读
|
||||
│
|
||||
└─ RDEPUBMetadataParseWorker.start() // 后台元数据解析
|
||||
└─ paginateMetadataOnly() // 后台元数据解析
|
||||
│
|
||||
├─ 预计算 contentHash(串行) // 读取所有章节 HTML + SHA-256
|
||||
├─ readAll(keys:) 恢复已有缓存
|
||||
@ -201,81 +201,43 @@ struct RDEPUBChapterCacheKey: Hashable {
|
||||
|
||||
---
|
||||
|
||||
## 5. ReaderController 组合架构
|
||||
## 5. 章节按需加载架构
|
||||
|
||||
```
|
||||
RDEPUBReaderController
|
||||
│
|
||||
├─ RDEPUBReaderContext(过渡门面)
|
||||
│ ├─ RDEPUBReaderState
|
||||
│ │ ├─ parser / publication / readingSession
|
||||
│ │ ├─ textBook / bookPageMap / pendingFullPageMap
|
||||
│ │ ├─ activeBookmarks / activeHighlights / searchState
|
||||
│ │ └─ currentSelection / paginationToken / snapshot
|
||||
│ │
|
||||
│ ├─ RDEPUBReaderEnvironment
|
||||
│ │ ├─ viewport / safeArea / traitCollection 抽象
|
||||
│ │ ├─ brightness / fallbackViewportSize
|
||||
│ │ └─ renderStyle / layoutConfig 推导
|
||||
│ │
|
||||
│ └─ RDEPUBReaderServices
|
||||
│ ├─ parser / paginator / builder factory
|
||||
│ ├─ renderer factory
|
||||
│ └─ chapter summary disk cache factory
|
||||
├─ RDEPUBReaderContext // 共享状态中心
|
||||
│ ├─ parser, publication, readingSession
|
||||
│ ├─ configuration, persistence
|
||||
│ └─ 便捷方法 (renderStyle, layoutConfig, cacheKey)
|
||||
│
|
||||
└─ RDEPUBReaderRuntime(兼容门面)
|
||||
├─ load / pagination / location / search / chrome / annotation coordinator
|
||||
├─ RDEPUBPresentationRuntime
|
||||
├─ RDEPUBChapterWarmupOrchestrator
|
||||
└─ 旧 API 转发
|
||||
├─ RDEPUBReaderRuntime // 运行时协调器集合(Facade 模式)
|
||||
│ ├─ chapterLoader // 章节加载器
|
||||
│ ├─ chapterRuntimeStore // 内存缓存
|
||||
│ ├─ summaryDiskCache // 磁盘摘要缓存
|
||||
│ ├─ pageResolver // 页码解析器
|
||||
│ ├─ loadCoordinator // 加载协调器
|
||||
│ ├─ paginationCoordinator // 分页协调器
|
||||
│ ├─ locationCoordinator // 位置协调器
|
||||
│ ├─ searchCoordinator // 搜索协调器
|
||||
│ ├─ chromeCoordinator // 工具栏协调器
|
||||
│ ├─ annotationCoordinator // 标注协调器
|
||||
│ └─ viewportMonitor // 视口变化监控
|
||||
│
|
||||
├─ RDEPUBReaderPaginationCoordinator // 分页协调器
|
||||
│ ├─ paginatePublication() // 入口
|
||||
│ ├─ paginateMetadataOnly() // 后台元数据解析
|
||||
│ └─ restoreBookPageMapIfPossible() // 缓存恢复
|
||||
│
|
||||
├─ RDEPUBChapterLoader // 章节加载器
|
||||
│ ├─ loadChapter() // 异步加载(Tier1→Tier2→全量构建)
|
||||
│ └─ loadChapterSynchronouslyForMigration() // 同步加载(快速打开用)
|
||||
│
|
||||
└─ RDEPUBBookPageMap // 轻量页码映射
|
||||
├─ ~100KB/1000章,不持有 NSAttributedString
|
||||
└─ 支持增量刷新 (Builder pattern)
|
||||
```
|
||||
|
||||
当前 `RDEPUBReaderContext` 仍存在,但主要作用已经收缩为过渡门面:
|
||||
|
||||
- 对外维持兼容访问面
|
||||
- 对内把纯状态、环境推导、工厂依赖拆开
|
||||
- 避免新逻辑继续把 `Context` 当作全能对象扩散
|
||||
|
||||
---
|
||||
|
||||
## 6. 章节按需加载与分页窗口架构
|
||||
|
||||
```
|
||||
RDEPUBReaderController
|
||||
│
|
||||
├─ RDEPUBReaderPaginationCoordinator // 分页入口与后台元数据解析
|
||||
│ ├─ paginatePublication()
|
||||
│ ├─ RDEPUBMetadataParseWorker.start()
|
||||
│ └─ restoreBookPageMapIfPossible()
|
||||
│
|
||||
├─ RDEPUBPresentationRuntime // 分页状态与窗口替换
|
||||
│ ├─ applyBookPageMap()
|
||||
│ ├─ refreshBookPageMapInPlace()
|
||||
│ └─ applyPendingFullPageMapIfNeeded()
|
||||
│
|
||||
├─ RDEPUBChapterWarmupOrchestrator // 按需章节预热与边界预取
|
||||
│ ├─ prepareOnDemandChapter()
|
||||
│ ├─ extendPartialBookPageMapIfNeeded()
|
||||
│ ├─ prefetchForwardChaptersAfterInitialOpen()
|
||||
│ └─ ensureNavigationTargetAvailable()
|
||||
│
|
||||
├─ RDEPUBChapterLoader // 章节构建与缓存门面
|
||||
│ ├─ loadChapter() // 异步加载(主路径)
|
||||
│ └─ loadChapterSynchronouslyForMigration()
|
||||
│
|
||||
└─ RDEPUBBookPageMap / RDEPUBPaginationState
|
||||
├─ 当前激活窗口
|
||||
├─ 待接管完整 page map
|
||||
└─ 来源与切换状态
|
||||
```
|
||||
|
||||
这轮整改后,跨章节主路径的关键变化是:
|
||||
|
||||
- 普通翻页时不再要求 UI 主线程同步等待章节构建
|
||||
- 章节边界会前移预热相邻章节与 lookahead 章节
|
||||
- `bookPageMap` 的 partial extension 和 full replacement 统一经过 `RDEPUBPresentationRuntime`
|
||||
- `RDEPUBReaderRuntime` 不再内联维护整套预热/扩窗实现
|
||||
|
||||
---
|
||||
|
||||
## 6. WebView 渲染架构(Web 路径)
|
||||
@ -310,10 +272,10 @@ RDEPUBWebView (UIView)
|
||||
|
||||
---
|
||||
|
||||
## 7. 翻页容器架构(RDEpubReaderView)
|
||||
## 7. 翻页容器架构(RDReaderView)
|
||||
|
||||
```
|
||||
RDEpubReaderView (UIView)
|
||||
RDReaderView (UIView)
|
||||
│
|
||||
├─ 三种翻页模式:
|
||||
│ ├─ .pageCurl → UIPageViewController (翻页动画)
|
||||
@ -321,14 +283,14 @@ RDEpubReaderView (UIView)
|
||||
│ └─ .verticalScroll → UICollectionView (垂直滚动)
|
||||
│
|
||||
├─ 组合对象:
|
||||
│ ├─ RDEpubReaderPagingController // 翻页状态管理、请求队列
|
||||
│ ├─ RDEpubReaderPreloadController // 页面预加载、缓存管理
|
||||
│ ├─ RDEpubReaderSpreadResolver // 双页展开计算
|
||||
│ └─ RDEpubReaderTapRegionHandler // 点击区域分类(左/中/右)
|
||||
│ ├─ RDReaderPagingController // 翻页状态管理、请求队列
|
||||
│ ├─ RDReaderPreloadController // 页面预加载、缓存管理
|
||||
│ ├─ RDReaderSpreadResolver // 双页展开计算
|
||||
│ └─ RDReaderTapRegionHandler // 点击区域分类(左/中/右)
|
||||
│
|
||||
├─ 数据源协议:
|
||||
│ ├─ RDEpubReaderPageProvider (新) // 格式无关,优先级高
|
||||
│ └─ RDEpubReaderDataSource (旧) // 遗留兼容,通过 Adapter 适配
|
||||
│ ├─ RDReaderPageProvider (新) // 格式无关,优先级高
|
||||
│ └─ RDReaderDataSource (旧) // 遗留兼容,通过 Adapter 适配
|
||||
│
|
||||
└─ 手势流:
|
||||
点击 → TapRegionHandler → 分类(左/中/右)
|
||||
@ -351,7 +313,7 @@ struct RDEPUBReaderConfiguration {
|
||||
var numberOfColumns: Int // 1 或 2
|
||||
var columnGap: CGFloat // 默认 20
|
||||
var theme: ReaderTheme // 6 种主题
|
||||
var displayType: RDEpubReaderView.DisplayType // pageCurl/horizontal/vertical
|
||||
var displayType: RDReaderView.DisplayType // pageCurl/horizontal/vertical
|
||||
var onDemandChapterWindowSize: Int // 默认 3(奇数,最小 3,最大 15)
|
||||
var metadataParsingConcurrency: Int // 默认 CPU 核心数
|
||||
var chapterWindowRadius: Int // 内存缓存窗口半径
|
||||
@ -374,7 +336,7 @@ struct RDEPUBReaderConfiguration {
|
||||
## 9. 目录结构
|
||||
|
||||
```
|
||||
Sources/RDEpubReaderView/
|
||||
Sources/RDReaderView/
|
||||
├── EPUBCore/ # EPUB 解析与 WebView 渲染
|
||||
│ ├── Models/ # 数据模型
|
||||
│ │ ├── RDEPUBAnnotationModels.swift
|
||||
@ -428,19 +390,19 @@ Sources/RDEpubReaderView/
|
||||
│ ├── RDEPUBDTCoreTextRenderer.swift # DTCoreText 渲染器实现
|
||||
│ ├── RDEPUBChapterData.swift # 章节查询门面
|
||||
│ ├── RDEPUBTextIndexTable.swift # 全书索引表
|
||||
│ └── RDEpubPlainTextBookBuilder.swift # 纯文本 (.txt) 构建器
|
||||
│ └── RDPlainTextBookBuilder.swift # 纯文本 (.txt) 构建器
|
||||
│
|
||||
├── ReaderView/ # 通用翻页容器
|
||||
│ ├── Paging/ # 翻页子系统
|
||||
│ │ ├── RDEpubReaderPagingController.swift
|
||||
│ │ ├── RDEpubReaderPreloadController.swift
|
||||
│ │ ├── RDEpubReaderSpreadResolver.swift
|
||||
│ │ └── RDEpubReaderTapRegionHandler.swift
|
||||
│ ├── RDEpubReaderView.swift # 主容器视图
|
||||
│ ├── RDEpubReaderView+*.swift # 扩展(CollectionView/PageCurl/ToolView/ContentAccess)
|
||||
│ ├── RDEpubReaderFlowLayout.swift # 自定义 CollectionView 布局
|
||||
│ ├── RDEpubReaderContentCell.swift # 滚动模式 Cell
|
||||
│ └── RDEpubReaderPageChildViewController.swift // 翻页模式子 VC
|
||||
│ │ ├── RDReaderPagingController.swift
|
||||
│ │ ├── RDReaderPreloadController.swift
|
||||
│ │ ├── RDReaderSpreadResolver.swift
|
||||
│ │ └── RDReaderTapRegionHandler.swift
|
||||
│ ├── RDReaderView.swift # 主容器视图
|
||||
│ ├── RDReaderView+*.swift # 扩展(CollectionView/PageCurl/ToolView/ContentAccess)
|
||||
│ ├── RDReaderFlowLayout.swift # 自定义 CollectionView 布局
|
||||
│ ├── RDReaderContentCell.swift # 滚动模式 Cell
|
||||
│ └── RDReaderPageChildViewController.swift // 翻页模式子 VC
|
||||
│
|
||||
└── EPUBUI/ # 阅读器 UI
|
||||
├── ReaderController/ # 控制器与协调器
|
||||
@ -494,7 +456,8 @@ Sources/RDEpubReaderView/
|
||||
| **Builder** | `RDEPUBBookPageMap.Builder` 增量构建页码映射 |
|
||||
| **Strategy** | `RDEPUBTextRenderer` 协议,可替换渲染器实现 |
|
||||
| **Pipeline** | `RDEPUBTextTypesetterPipeline` 排版管线(8 个逻辑阶段,封装为 5-6 个顶层调用) |
|
||||
| **Adapter** | `RDEpubReaderLegacyDataSourceAdapter` 适配旧数据源协议 |
|
||||
| **State Machine** | `RDEPUBNavigatorState` 管理阅读器状态转换 |
|
||||
| **Adapter** | `RDReaderLegacyDataSourceAdapter` 适配旧数据源协议 |
|
||||
| **三级缓存** | 内存 → 磁盘摘要 → 全书分页,逐级降级 |
|
||||
| **Token 取消** | `paginationToken` 确保过期异步任务不干扰新任务 |
|
||||
| **Frozen Parameters** | 后台任务冻结 `renderSignature`,避免运行中参数漂移 |
|
||||
|
||||
@ -1,411 +0,0 @@
|
||||
# ReadViewSDK 代码审查报告
|
||||
|
||||
> 审查范围:`Sources/RDEpubReaderView/` 全部 184 个 Swift 文件(约 32,300 行)
|
||||
> 审查维度:并发安全、内存管理、API 正确性、Swift/iOS 平台特定、架构与依赖
|
||||
> 审查日期:2026-06-26
|
||||
|
||||
---
|
||||
|
||||
## 严重程度定义
|
||||
|
||||
| 标记 | 含义 |
|
||||
|------|------|
|
||||
| 🔴 高 | 有明确代码路径可导致崩溃、数据丢失或严重功能缺陷 |
|
||||
| 🟡 中 | 方向正确但风险程度取决于运行时条件;或当前安全但架构上脆弱 |
|
||||
| 🟢 低 | 代码异味、可维护性问题或未来风险 |
|
||||
|
||||
---
|
||||
|
||||
## 🔴 高优先级问题
|
||||
|
||||
### H-01 · 内存警告未连接章节缓存清理
|
||||
|
||||
`RDEPUBChapterRuntimeStore.handleMemoryWarning()` 已实现(清空除当前章节外的缓存 + 清除图片缓存),但从未被调用。
|
||||
|
||||
- `RDEPUBReaderRuntime.handleMemoryWarning()` 只清理了 `backgroundCoverageStore`,未调用 `chapterRuntimeStore.handleMemoryWarning()`
|
||||
- `RDEPUBReaderController` 未重写 `didReceiveMemoryWarning`
|
||||
|
||||
**影响:** 系统内存压力下,`RDEPUBChapterDataCache`(无上限字典)和 `imageCache`(仅 countLimit=50,无 totalCostLimit)不会主动释放内存。
|
||||
|
||||
**位置:**
|
||||
- `RDEPUBChapterRuntimeStore.swift:103` — `handleMemoryWarning()` 方法存在
|
||||
- `RDEPUBReaderRuntime.swift:606-620` — 只清理 backgroundCoverageStore
|
||||
|
||||
---
|
||||
|
||||
### H-02 · 主线程信号量等待
|
||||
|
||||
`RDEPUBChapterLoader.loadChapterSynchronouslyForMigration()` 使用 `DispatchSemaphore.wait()` 阻塞调用线程。当从 `RDEPUBPresentationRuntime.rebindVisibleLocation()` 以 `allowSynchronousLoad: true` 调用时,会在主线程等待。
|
||||
|
||||
**影响:** 如果章节数据未缓存,主线程被阻塞直到章节构建完成,可能导致 ANR 或死锁(如果构建过程需要主线程资源)。
|
||||
|
||||
**位置:**
|
||||
- `RDEPUBChapterLoader.swift:206-268` — `semaphore.wait()`
|
||||
- `RDEPUBPresentationRuntime.swift:282-286` — `allowSynchronousLoad: true`
|
||||
|
||||
---
|
||||
|
||||
### H-03 · asyncAfter 闭包中的强制解包
|
||||
|
||||
`RDEPUBReaderController+DataSource.swift:425-436` 在 0.3 秒延迟闭包中强制解包 `lastTextPaginationPageSize!`。方法入口的 `guard let lastTextPaginationPageSize` 不保护延迟闭包——0.3 秒后该值可能已变为 `nil`。
|
||||
|
||||
**位置:**
|
||||
```swift
|
||||
// line 432-433
|
||||
let stillChanged = abs(currentSize.width - self.lastTextPaginationPageSize!.width) > 0.5
|
||||
|| abs(currentSize.height - self.lastTextPaginationPageSize!.height) > 0.5
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### H-04 · `unowned context` 在后台操作中的崩溃路径
|
||||
|
||||
以下两个类在后台操作中访问 `unowned let context: RDEPUBReaderContext`,若 controller 在操作期间释放则会崩溃:
|
||||
|
||||
**RDEPUBMetadataParseWorker:**
|
||||
- Line 117: `let context = self.context` 在 `[weak self]` guard 之前将 unowned 引用捕获为局部变量
|
||||
- Lines 197-324: `BlockOperation` 闭包直接使用该局部变量(unowned),无 `[weak self]` 保护
|
||||
- Lines 296-301, 372-376: `DispatchQueue.main.async` 闭包捕获 unowned 局部变量
|
||||
|
||||
**RDEPUBReaderLoadCoordinator:**
|
||||
- Line 28: 闭包只捕获 `[weak controller]`,未捕获 `[weak self]`
|
||||
- Line 30: `self.context.makeParser()` 在后台队列访问 unowned 引用
|
||||
- Lines 41-42, 53-54: `DispatchQueue.main.async` 闭包直接访问 `self.context`
|
||||
|
||||
**影响:** 若 `RDEPUBReaderController` 在后台操作期间释放(如用户快速退出阅读器),`unowned` 引用悬挂导致 EXC_BAD_ACCESS。
|
||||
|
||||
**位置:**
|
||||
- `RDEPUBMetadataParseWorker.swift:117, 197-324, 296-301, 372-376`
|
||||
- `RDEPUBReaderLoadCoordinator.swift:28-57`
|
||||
|
||||
---
|
||||
|
||||
### H-05 · 章节数据缓存无容量限制
|
||||
|
||||
`RDEPUBChapterDataCache` 是无上限的 `[Int: RDEPUBRuntimeChapter]` 字典,仅通过显式 `remove(spineIndex:)` 或 `removeAll()` 逐出。每章持有完整的 `typesetAttributedString`(50-200KB+),50 章可达 5-10MB+。
|
||||
|
||||
**位置:** `RDEPUBChapterDataCache.swift:5` — `private var storage: [Int: RDEPUBRuntimeChapter] = [:]`
|
||||
|
||||
---
|
||||
|
||||
### H-06 · 暗色图片静态 NSCache 无 totalCostLimit
|
||||
|
||||
`RDEPUBDarkImageAdjuster.imageCache` 是 static `NSCache<NSString, UIImage>()`,无 `totalCostLimit`,跨越整个 App 生命周期累积调整后的图片。
|
||||
|
||||
**位置:** `RDEPUBDarkImageAdjuster.swift:9`
|
||||
|
||||
---
|
||||
|
||||
### H-07 · 持久化操作静默丢弃错误
|
||||
|
||||
`RDEPUBReaderPersistence` 中所有编解码操作均使用 `try?`,失败时:
|
||||
- **保存**:静默返回,数据丢失无任何反馈(lines 84, 98, 112, 131)
|
||||
- **加载**:返回 `nil` 或空数组,损坏/版本不匹配的数据无法恢复(lines 80, 94, 108, 127)
|
||||
|
||||
**影响:** 用户书签和阅读位置可能在无声中丢失。
|
||||
|
||||
**位置:** `RDEPUBReaderPersistence.swift:76-131`
|
||||
|
||||
---
|
||||
|
||||
### H-08 · WKURLSchemeHandler 线程违规与取消回调缺失
|
||||
|
||||
两个确定 bug:
|
||||
|
||||
**(a) 不同队列回调:** `webView(_:start:)` 在某个串行队列(由 WebKit 调度)上被调用,但 `respondWithInMemoryData` 和 `respondWithStreaming` 将 `didReceive`/`didFinish`/`didFailWithError` 显式切到了 `ioQueue`(line 121, 151)。这至少违反了"回调应与 `start` 使用同一串行队列"的要求。
|
||||
|
||||
**(b) 取消时不调用完成回调:** 任务被 `stop` 后,`isTaskActive` 返回 false 时:
|
||||
- streaming 路径(line 178):`guard self.isTaskActive(taskID) else { return }` — 仅 return,不调用 `didFinish` 或 `didFailWithError`
|
||||
- in-memory 路径(line 128, 141):同理
|
||||
|
||||
这违反了"每个 started task 必须收到完成回调"的协议要求。
|
||||
|
||||
**位置:** `RDEPUBResourceURLSchemeHandler.swift:121-187`
|
||||
|
||||
---
|
||||
|
||||
### H-09 · 搜索在后台线程同步加载所有章节
|
||||
|
||||
`RDEPUBReaderSearchCoordinator` 在 `searchQueue` 上遍历所有 buildable spine 索引,逐个同步加载章节。每章持有完整 `typesetAttributedString`,大型书籍搜索时内存峰值显著。
|
||||
|
||||
**位置:** `RDEPUBReaderSearchCoordinator.swift:47, 173-236`
|
||||
|
||||
---
|
||||
|
||||
### H-10 · 依赖版本未锁定
|
||||
|
||||
`RDEpubReaderView.podspec` 中 DTCoreText、SnapKit、SSAlertSwift 无版本约束:
|
||||
|
||||
```ruby
|
||||
s.dependency 'ZIPFoundation', '~> 0.9' # ✅ 已锁定
|
||||
s.dependency 'DTCoreText' # ❌ 无约束
|
||||
s.dependency 'SnapKit' # ❌ 无约束
|
||||
s.dependency 'SSAlertSwift' # ❌ 无约束
|
||||
```
|
||||
|
||||
**位置:** `RDEpubReaderView.podspec:17-20`
|
||||
|
||||
---
|
||||
|
||||
### H-11 · SnapKit 和 SSAlertSwift 疑似死依赖
|
||||
|
||||
全仓库搜索未找到任何 `snp.` 或 `SSAlert` 的使用点。这两个依赖可能增加了不必要的二进制体积和潜在冲突。
|
||||
|
||||
---
|
||||
|
||||
## 🟡 中优先级问题
|
||||
|
||||
### M-01 · `RDEPUBReaderState` 无同步保护(隐性风险)
|
||||
|
||||
所有属性(`parser`, `publication`, `activeBookmarks`, `pendingPageMapUpdates` 等)均为 `var`,无锁、无 `@MainActor`、无 `dispatchPrecondition`。当前所有访问确实在主线程(经追踪 16 个 `pendingPageMapUpdates` 访问点确认),但缺少形式化保障——未来开发者可能无意从后台线程访问而不自知。
|
||||
|
||||
**位置:** `RDEPUBReaderState.swift:1-71`
|
||||
|
||||
---
|
||||
|
||||
### M-02 · `currentSpineIndex` / `windowSpineIndices` 无锁但当前安全
|
||||
|
||||
这两个字段无锁保护,但追踪全部读写路径后确认当前均在主线程。风险是隐性的:store 对象被传入 `chapterLoadQueue` 上下文,未来开发者可能在该队列中访问这些属性而不自知。同文件的其他字段(`navigationLock`, `prefetchLock`, `buildingLock`, `cfiMapLock`)有锁保护,形成不一致的模式。
|
||||
|
||||
**位置:** `RDEPUBChapterRuntimeStore.swift:15-17`
|
||||
|
||||
---
|
||||
|
||||
### M-03 · `warmAnchors` 跨线程访问无同步
|
||||
|
||||
`RDEPUBBackgroundPriorityManager.warmAnchors` 在主线程写入(`addWarmAnchor`),在 `MetadataParseWorker` 的后台线程读取(`makeMetadataPriorityOrder`),无锁保护。
|
||||
|
||||
**位置:** `RDEPUBBackgroundPriorityPolicy.swift:65`
|
||||
|
||||
---
|
||||
|
||||
### M-04 · `assert(Thread.isMainThread)` 在 Release 中被移除
|
||||
|
||||
`RDEPUBReaderContext` 的 `makeLayoutSnapshot()`、`currentTextPageSize()` 等方法使用 `assert(Thread.isMainThread)` 检查。`assert` 在 `-O` 构建中被移除,生产环境中后台线程调用将静默访问 UIKit 属性,造成数据竞争且无诊断。
|
||||
|
||||
**应使用:** `dispatchPrecondition(condition: .onQueue(.main))` 或 `@MainActor` 标注。
|
||||
|
||||
**位置:** `RDEPUBReaderContext.swift:172, 188, 210, 215`
|
||||
|
||||
---
|
||||
|
||||
### M-05 · 磁盘缓存无自动淘汰策略
|
||||
|
||||
`RDEPUBChapterSummaryDiskCache` 和 `RDEPUBTextBookCache` 只有全量清除操作(`removeAll()`、`invalidateAll()`),无 LRU/大小/年龄驱逐策略。每次设置变更(字体、行距、主题)产生新缓存键,旧文件无限累积。写操作使用原子替换(tmp + replace),交错不会损坏数据,但可能读到旧值或看到不一致的目录状态。
|
||||
|
||||
**位置:**
|
||||
- `RDEPUBChapterSummaryDiskCache.swift:89` — `removeAll()` 全清
|
||||
- `RDEPUBTextBookCache.swift:191` — `invalidateAll()` 全清
|
||||
|
||||
---
|
||||
|
||||
### M-06 · CFI 范围分隔符可能与内容冲突
|
||||
|
||||
`RDEPUBCFICompatibility` 按 `".."` 和 `"-"` 分割 CFI 范围字符串,但这些字符可能出现在 CFI 文本断言内部(如 `[foo..bar]`)。代码先匹配 `".."` 再匹配 `"-"`,无歧义消解逻辑。
|
||||
|
||||
**位置:** `RDEPUBCFICompatibility.swift:18-21`
|
||||
|
||||
---
|
||||
|
||||
### M-07 · CFI 步索引对奇数输入容错不严谨
|
||||
|
||||
`RDEPUBCFIResolver` 中 `steps[1].index / 2 - 1` 对奇数索引做整除截断,可能映射到错误章节。`max(..., 0)` 仅防负索引,不防错误映射。属于 EPUB CFI 规范的输入验证问题。
|
||||
|
||||
**位置:** `RDEPUBCFIResolver.swift:38`
|
||||
|
||||
---
|
||||
|
||||
### M-08 · WKWebView 保留环需主动管理
|
||||
|
||||
`RDEPUBWebView` → `webView` → `configuration.userContentController` → `RDEPUBWebView`(作为 `WKScriptMessageHandler`)形成保留环。
|
||||
|
||||
主要解环路径是 `reset()` 和 `configureWebViewIfNeeded` 中主动调用 `teardownWebView()`。**注意:** `deinit` 虽也调用 `teardownWebView()`,但不能作为环会被打破的保证——保留环的存在意味着 `deinit` 可能永远不会触发。风险在于:若某条代码路径跳过了 `teardownWebView()`,环将持续且对象永远不会释放。
|
||||
|
||||
**位置:**
|
||||
- `RDEPUBWebView.swift:129` — `deinit` 调用 `teardownWebView()`(不可靠兜底)
|
||||
- `RDEPUBWebView.swift:138-160` — `reset()` 调用 `teardownWebView()`(主要解环路径)
|
||||
|
||||
---
|
||||
|
||||
### M-09 · `configuration` didSet 在 `isViewLoaded` 之前持久化
|
||||
|
||||
`RDEPUBReaderController.configuration` 的 `didSet` 在 `guard isViewLoaded` 之前执行了 `readerContext.configuration = configuration`、`applyWebViewDebugPolicy()` 和 `persistReaderSettingsIfNeeded()`。若在 view 加载前设置 configuration,这些副作用会在 UI 未就绪时触发。
|
||||
|
||||
**位置:** `RDEPUBReaderController.swift:8-20`
|
||||
|
||||
---
|
||||
|
||||
### M-10 · empty catch 吞掉章节加载错误
|
||||
|
||||
`RDEPUBReaderPaginationCoordinator` 中 `loadInitialInteractiveRuntimeChapters` 的 `catch {}` 为空,章节加载失败被静默忽略,无日志、无恢复。
|
||||
|
||||
**位置:** `RDEPUBReaderPaginationCoordinator.swift:283`
|
||||
|
||||
---
|
||||
|
||||
### M-11 · `RDEPUBReaderFontChoice.displayName` 始终返回中文
|
||||
|
||||
枚举的 `displayName` 计算属性硬编码中文字符串("系统"、"宋体"、"圆体"、"等宽"),无本地化基础设施。
|
||||
|
||||
**位置:** `RDEPUBReaderConfiguration.swift:18-28`
|
||||
|
||||
---
|
||||
|
||||
### M-12 · 大量硬编码中文字符串,本地化覆盖不完整
|
||||
|
||||
覆盖文件:`RDEPUBReaderSettingsViewController`、`RDEPUBReaderSearchBarView`、`RDEPUBReaderHighlightsViewController`、`RDEPUBReaderBookmarksViewController`、`RDEPUBReaderTopToolView`、`RDEPUBReaderBottomToolView`、`RDEPUBTextContentView`、`RDEPUBWebView+Configuration`、`RDEPUBChapterLoader`(错误信息)等。
|
||||
|
||||
项目中存在约 88 个硬编码中文字符串(如"拷贝"、"高亮"、"批注"、"目录"、"搜索"等),但同时也有使用 `NSLocalizedString` 的地方(如 `RDEPUBImageViewController.swift:126`)。本地化基础设施存在但覆盖不完整——新添加的 UI 文本大多直接硬编码中文而未走本地化流程。
|
||||
|
||||
---
|
||||
|
||||
### M-13 · DTCoreText 类型泄漏到 EPUBUI 层
|
||||
|
||||
5 个 EPUBUI 文件直接使用 `DTCoreTextLayoutFrame`、`DTCoreTextLayoutLine`、`DTCoreTextGlyphRun` 等 DTCoreText 类型(虽有 `#if canImport(DTCoreText)` 保护),意味着替换渲染引擎需修改 UI 层文件。
|
||||
|
||||
**位置:**
|
||||
- `RDEPUBPageInteractionController.swift`
|
||||
- `RDEPUBPageLayoutSnapshot.swift`
|
||||
- `RDEPUBTextPageRenderView.swift`
|
||||
- `RDEPUBTextContentView.swift`
|
||||
- `RDEPUBDarkImageAdjuster.swift`
|
||||
|
||||
---
|
||||
|
||||
### M-14 · `imageCache` 有 countLimit 但无 totalCostLimit
|
||||
|
||||
`RDEPUBChapterRuntimeStore.imageCache` 设有 `countLimit = 50`,但无 `totalCostLimit`。50 张大尺寸书籍插图可达 150-250MB。
|
||||
|
||||
**位置:** `RDEPUBChapterRuntimeStore.swift:41`
|
||||
|
||||
---
|
||||
|
||||
### M-15 · `UIMenuController` 已废弃
|
||||
|
||||
`RDEPUBWebView+Configuration.swift:39` 和 `RDEPUBTextContentView.swift:747-758` 使用 `UIMenuController`,该 API 从 iOS 16 起废弃。应迁移至 `UIEditMenuInteraction`(iOS 16+),并为 iOS 15 保留 fallback。当前代码在 iOS 17+ 上仍可工作但随时可能失效。
|
||||
|
||||
---
|
||||
|
||||
### M-16 · Dynamic Type 未支持
|
||||
|
||||
UI 中所有字体使用硬编码大小(`UIFont.systemFont(ofSize: 13/14/16/18/19)`),未使用 `UIFontMetrics` 或 `preferredFont(forTextStyle:)`。启用 Dynamic Type 的用户看不到文字大小变化。
|
||||
|
||||
---
|
||||
|
||||
### M-17 · URL Scheme Handler 取消时 `clearTask` 可能被跳过
|
||||
|
||||
`respondWithInMemoryData` line 128 的 `guard self.isTaskActive(taskID) else { return }` 在取消时直接返回,跳过了 line 146 的 `self.clearTask(taskID)`。当前因 `stop` 已调用 `clearTask` 而无害,但结构上脆弱。
|
||||
|
||||
**位置:** `RDEPUBResourceURLSchemeHandler.swift:128`
|
||||
|
||||
---
|
||||
|
||||
### M-18 · 硬编码颜色值未集成到主题系统
|
||||
|
||||
`RDEPUBReaderSearchBarView` 含 18 个硬编码颜色字面量,默认高亮颜色 `"#F8E16C"` 硬编码在 `RDEPUBReaderController+PublicAPI.swift:65,75`。这些应属于 `RDEPUBReaderTheme`。
|
||||
|
||||
---
|
||||
|
||||
### M-19 · 硬编码时间常量
|
||||
|
||||
多个 `DispatchQueue.main.asyncAfter(deadline:)` 和 `Thread.sleep` 使用硬编码延迟值(0.08s、0.1s、0.15s、0.2s、0.25s、0.3s、1.0s、3.5s),无命名常量或可配置选项。
|
||||
|
||||
---
|
||||
|
||||
## 🟢 低优先级问题
|
||||
|
||||
### L-01 · `RDEPUBChapterLoader` 中 `[self]` 强捕获
|
||||
|
||||
Line 103 的 `store.chapterLoadQueue.async { [self] in` 强捕获 self,阻止 loader 在队列任务完成前释放。应改为 `[weak self]`。
|
||||
|
||||
**位置:** `RDEPUBChapterLoader.swift:103`
|
||||
|
||||
---
|
||||
|
||||
### L-02 · MetadataParseWorker Thread.sleep 忙等待
|
||||
|
||||
`waitForReadingInteractionToSettle()` 使用 `Thread.sleep(forTimeInterval: 0.08)` 轮询,无上限等待。应考虑 `DispatchSource` 或条件变量。
|
||||
|
||||
**位置:** `RDEPUBMetadataParseWorker.swift:385-390`
|
||||
|
||||
---
|
||||
|
||||
### L-03 · `RDEPUBTextBookCache.queue.sync` 潜在死锁
|
||||
|
||||
`load()` 和 `save()` 均使用 `queue.sync`。若从 queue 内部调用则会死锁。当前未发现此调用路径,但模式脆弱。
|
||||
|
||||
**位置:** `RDEPUBTextBookCache.swift:136-189`
|
||||
|
||||
---
|
||||
|
||||
### L-04 · `pendingLoads` 字典无超时清理
|
||||
|
||||
`RDEPUBChapterLoader.pendingLoads` 的完成闭包数组在章节加载失败且 `resolvePendingLoad` 未被调用时可能累积。当前所有路径均调用 `resolvePendingLoad`,但无防御性超时。
|
||||
|
||||
**位置:** `RDEPUBChapterLoader.swift:7-10`
|
||||
|
||||
---
|
||||
|
||||
### L-05 · `RDEPUBChapterWindowCoordinator` 是死代码
|
||||
|
||||
该类定义了但未被任何代码实例化或引用。其中对 `currentSpineIndex`/`windowSpineIndices` 的7个访问点均为死代码。
|
||||
|
||||
---
|
||||
|
||||
### L-06 · 平台版本不一致
|
||||
|
||||
- `podspec`: `s.platform = :ios, "15.0"`
|
||||
- `Podfile`(根和 demo): `platform :ios, '15.6'`
|
||||
|
||||
---
|
||||
|
||||
### L-07 · `RDEPUBMetadataCancellationController` 微小竞争
|
||||
|
||||
`attach()` 方法在 `lock.unlock()` 和 `queue.cancelAllOperations()` 之间存在窄窗口,新操作可能被添加到队列但不会被取消。实际影响极小。
|
||||
|
||||
**位置:** `RDEPUBMetadataParseCancellationController.swift:attach()`
|
||||
|
||||
---
|
||||
|
||||
### L-08 · 页码标签硬编码偏移
|
||||
|
||||
`RDEPUBWebContentView` 和 `RDEPUBTextContentView` 的页码标签使用硬编码偏移(24pt, 20pt),在刘海屏设备上可能与安全区域重叠。
|
||||
|
||||
---
|
||||
|
||||
## 已排除项
|
||||
|
||||
以下原报告条目经核实后排除或合并:
|
||||
|
||||
| 原编号 | 原描述 | 排除原因 |
|
||||
|--------|--------|----------|
|
||||
| #6 | compactMap 结果越界 | 入口有 `childViewControllers.count == expectedCount` 检查,compactMap 后不会少于预期数量 |
|
||||
| #7 | `result!` 强解包崩溃 | 信号量等待的真实风险是无限等待/卡住,不是 result 为 nil;signal 前必定赋值 |
|
||||
| #21 | 搜索协调器 fallback 空 store | `?? RDEPUBChapterRuntimeStore()` 前的可选链若为 nil 则整个调用返回 nil,fallback 不会生效 |
|
||||
| #23 | init 时加载所有 HTML 到内存 | 逐章取 HTML 算哈希后只保存 digest,HTML 字符串不长期保留 |
|
||||
| #35 | `.nonPersistent()` 阻止 cookie/localStorage | 不持久化是有意设计,不是 bug;原始报告将"不持久化"误述为"不能用" |
|
||||
| #48 | `didMoveToSuperview` 每次重复 makeUI | 有 `didBuildUI` 防重入保护 |
|
||||
| 原L-04 | `activeTasks` 字典 `clearTask` 设 nil 不删键 | Swift 中 `dict[key] = nil` 等同于 `removeValue(forKey:)`,不保留空洞 |
|
||||
| 原M-18 | MetadataParseWorker OperationQueue 未在 deinit 取消 | 外层 `[weak self]` guard 确保 self 存活才执行 queue 操作;`cancellationController` 提供显式取消路径 |
|
||||
| 原L-08 | `ss_superViewController` 前缀暗示外部来源 | 命名风格猜测,非可验证缺陷 |
|
||||
|
||||
---
|
||||
|
||||
## 建议优先级排序
|
||||
|
||||
| 优先级 | 编号 | 修复复杂度 | 说明 |
|
||||
|--------|------|-----------|------|
|
||||
| P0 | H-01 | 低 | 在 `handleMemoryWarning` 中加一行 `chapterRuntimeStore.handleMemoryWarning()`,并在 controller 中转发 `didReceiveMemoryWarning` |
|
||||
| P0 | H-04 | 中 | MetadataParseWorker 和 LoadCoordinator 中 `unowned context` 改为 `weak`,闭包加 `guard` |
|
||||
| P0 | H-03 | 低 | `lastTextPaginationPageSize!` 改为 `guard let` 安全解包 |
|
||||
| P1 | H-08 | 中 | URLSchemeHandler 回调改到与 start 相同队列,取消路径加 `didFailWithError` |
|
||||
| P1 | H-02 | 中 | 主线程同步加载改为异步回调或确保不从主线程调用 |
|
||||
| P1 | H-07 | 低 | 关键持久化操作加日志,至少记录失败原因 |
|
||||
| P1 | H-10 | 低 | 给三个依赖加版本约束;移除 SnapKit 和 SSAlertSwift |
|
||||
| P2 | H-05 | 中 | 为 ChapterDataCache 加 LRU 逐出策略或最大条目数限制 |
|
||||
| P2 | H-06 | 低 | 为 DarkImageAdjuster.imageCache 设置 `totalCostLimit` |
|
||||
| P2 | H-09 | 中 | 搜索改为流式/分批加载,避免一次性持有所有章节 |
|
||||
| P2 | M-01/02 | 中 | 为 State 和 Store 关键属性加 `@MainActor` 或 `dispatchPrecondition` |
|
||||
| P2 | M-04 | 低 | `assert` 改为 `dispatchPrecondition` 或 `precondition` |
|
||||
| P2 | M-08 | 低 | 保留环加注释说明必须通过 `reset()` 打破 |
|
||||
| P2 | M-15 | 中 | 迁移至 `UIEditMenuInteraction` |
|
||||
606
Doc/Android EPUB 阅读器实施方案.md
Normal file
606
Doc/Android EPUB 阅读器实施方案.md
Normal file
@ -0,0 +1,606 @@
|
||||
# Android EPUB 阅读器实施方案
|
||||
|
||||
## Context
|
||||
|
||||
当前 ReadViewSDK 是一个功能完整的 iOS EPUB 阅读器框架(Swift + UIKit),支持 EPUB 2/3 解析、三种渲染模式(重排/固定布局/网页交互)、全文搜索、高亮/书签/批注、分页管理等。目标是基于现有 iOS 版本的架构和 JS 桥接协议,实现一套 Android 版本的 EPUB 阅读器。
|
||||
|
||||
**核心策略**:JS 桥接层(epub-bridge.js、WeReadApi.js 等)直接复用,Swift 逻辑层用 Kotlin 重写,UIKit UI 层用 Android 原生 View 重写。
|
||||
|
||||
---
|
||||
|
||||
## 第一阶段:项目基础设施(预计 1 周)
|
||||
|
||||
### 1.1 创建 Android 项目
|
||||
|
||||
```
|
||||
ReadViewSDK-Android/
|
||||
├── app/ # Demo 应用
|
||||
├── reader-core/ # Kotlin 核心库(解析、模型、搜索)
|
||||
├── reader-jsbridge/ # WebView JS 桥接层
|
||||
├── reader-ui/ # Android UI 组件(工具栏、搜索面板、设置等)
|
||||
├── reader-view/ # 翻页容器(ViewPager2 封装)
|
||||
└── build-logic/ # Gradle convention plugins
|
||||
```
|
||||
|
||||
- 语言:Kotlin
|
||||
- 最低 API:24(Android 7.0)
|
||||
- 目标 API:34
|
||||
- 构建工具:Gradle + Kotlin DSL
|
||||
- 依赖注入:手动(与 iOS 的 `RDEPUBReaderDependencies` 模式一致)
|
||||
|
||||
### 1.2 Gradle 模块划分
|
||||
|
||||
| 模块 | 对应 iOS 层 | 职责 |
|
||||
|------|------------|------|
|
||||
| `reader-core` | EPUBCore/ + EPUBTextRendering/ | 解析、模型、搜索引擎、CSS 生成、分页算法 |
|
||||
| `reader-jsbridge` | RDEPUBJavaScriptBridge + JS Resources | JS 文件管理、消息收发、脚本生成 |
|
||||
| `reader-ui` | EPUBUI/ | 工具栏、搜索面板、设置面板、目录列表、高亮管理 |
|
||||
| `reader-view` | ReaderView/ | 翻页容器(ViewPager2 + PagerSnapHelper) |
|
||||
|
||||
### 1.3 公共依赖
|
||||
|
||||
```kotlin
|
||||
// reader-core
|
||||
implementation("net.lingala.zip4j:zip4j:2.11.5") // 替代 ZIPFoundation
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0")
|
||||
implementation("com.google.code.gson:gson:2.11.0") // 替代 Codable
|
||||
implementation("androidx.webkit:webkit:1.10.0")
|
||||
|
||||
// reader-ui
|
||||
implementation("androidx.recyclerview:recyclerview:1.3.2")
|
||||
implementation("com.google.android.material:material:1.12.0")
|
||||
implementation("androidx.viewpager2:viewpager2:1.0.0")
|
||||
|
||||
// reader-view
|
||||
implementation("androidx.viewpager2:viewpager2:1.0.0")
|
||||
implementation("androidx.recyclerview:recyclerview:1.3.2")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第二阶段:Core 层 — EPUB 解析与模型(预计 2 周)
|
||||
|
||||
### 2.1 数据模型迁移
|
||||
|
||||
将 iOS 的 Codable struct 迁移为 Kotlin data class:
|
||||
|
||||
| iOS 文件 | Android 文件 | 关键类型 |
|
||||
|----------|-------------|---------|
|
||||
| `RDEPUBModels.swift` | `EpubModels.kt` | `EpubLayout`, `EpubMetadata`, `ManifestItem`, `SpineItem`, `TableOfContentsItem` |
|
||||
| `RDEPUBReadingLocationModels.swift` | `ReadingLocationModels.kt` | `ReadingLocation`, `Viewport`, `ReadingContext` |
|
||||
| `RDEPUBAnnotationModels.swift` | `AnnotationModels.kt` | `Selection`, `Highlight`, `Bookmark`, `Annotation` |
|
||||
| `RDEPUBPaginationModels.swift` | `PaginationModels.kt` | `EpubPage`, `ChapterInfo`, `FixedSpread` |
|
||||
| `RDEPUBSearchModels.swift` | `SearchModels.kt` | `SearchMatch`, `SearchResult`, `SearchState`, `SearchPresentation` |
|
||||
| `RDEPUBPreferences.swift` | `ReaderPreferences.kt` | `ReadingPreferences`(字体、行高、颜色、分栏) |
|
||||
| `RDEPUBRenderRequest.swift` | `RenderRequest.kt` | `ReflowableRenderRequest`, `FixedRenderRequest` |
|
||||
| `RDEPUBReaderConfiguration.swift` | `ReaderConfiguration.kt` | 完整阅读器配置 |
|
||||
| `RDEPUBReaderTheme.swift` | `ReaderTheme.kt` | 6 种预设主题 |
|
||||
|
||||
### 2.2 EPUB 解析器迁移
|
||||
|
||||
| iOS 文件 | Android 文件 | 说明 |
|
||||
|----------|-------------|------|
|
||||
| `RDEPUBParser.swift` | `EpubParser.kt` | 主解析入口 |
|
||||
| `RDEPUBParser+Archive.swift` | `EpubParser+Archive.kt` | zip4j 解压(替代 ZIPFoundation) |
|
||||
| `RDEPUBParser+Package.swift` | `EpubParser+Package.kt` | OPF XML SAX 解析 |
|
||||
| `RDEPUBParser+TOC.swift` | `EpubParser+TOC.kt` | NCX/Nav Document 解析 |
|
||||
| `RDEPUBParser+Resources.swift` | `EpubParser+Resources.kt` | HTML/资源文件读取 |
|
||||
| `RDEPUBParser+ReadingProfile.swift` | `EpubParser+ReadingProfile.kt` | 阅读模式判断 |
|
||||
| `RDEPUBPublication.swift` | `EpubPublication.kt` | Facade 门面 |
|
||||
| `RDEPUBResourceResolver.swift` | `ResourceResolver.kt` | URL 规范化 |
|
||||
|
||||
**XML 解析方案**:iOS 使用 `XMLParser`(SAX),Android 使用 `org.xml.sax.XMLReader`(同为 SAX),解析逻辑可逐行对照翻译。
|
||||
|
||||
### 2.3 搜索引擎迁移
|
||||
|
||||
| iOS 文件 | Android 文件 |
|
||||
|----------|-------------|
|
||||
| `RDEPUBSearchEngine.swift` | `EpubSearchEngine.kt` |
|
||||
| `RDEPUBTextSearchEngine.swift` | `TextSearchEngine.kt` |
|
||||
|
||||
搜索引擎核心逻辑(遍历 spine → 读取 HTML → 解析为纯文本 → NSString.range 搜索 → 生成 match)可直接翻译为 Kotlin `String.indexOf` 循环。
|
||||
|
||||
### 2.4 CSS 生成与样式
|
||||
|
||||
| iOS 文件 | Android 文件 |
|
||||
|----------|-------------|
|
||||
| `RDEPUBStyleSheetBuilder.swift` | `StyleSheetBuilder.kt` |
|
||||
| `RDEPUBFixedLayoutTemplate.swift` | `FixedLayoutTemplate.kt` |
|
||||
|
||||
CSS 生成逻辑完全平台无关,直接翻译即可。
|
||||
|
||||
### 2.5 分页算法
|
||||
|
||||
| iOS 文件 | Android 文件 | 说明 |
|
||||
|----------|-------------|------|
|
||||
| `RDEPUBPaginator.swift` | `EpubPaginator.kt` | 离屏 WebView 分页计算 |
|
||||
| `RDEPUBReadingSession.swift` | `ReadingSession.kt` | 状态机 + 页面管理 |
|
||||
|
||||
**关键决策**:Android 版统一使用 WebView 渲染,不实现 `EPUBTextRendering` 层(DTCoreText 的 Android 等价物过于复杂且收益低)。这意味着:
|
||||
|
||||
- **不迁移** `EPUBTextRendering/` 整个目录(18 个文件)
|
||||
- **不迁移** `EPUBUI/TextPage/` 目录(7 个文件)
|
||||
- Android 版的所有内容(包括重排模式)都通过 WebView + CSS column 分页实现
|
||||
- 这与 iOS 的 `webInteractive` 路径一致,已验证可行
|
||||
|
||||
---
|
||||
|
||||
## 第三阶段:JS 桥接层(预计 1 周)
|
||||
|
||||
### 3.1 JS 文件直接复用
|
||||
|
||||
将以下文件直接复制到 Android `assets/` 目录:
|
||||
|
||||
```
|
||||
reader-jsbridge/src/main/assets/js/
|
||||
├── epub-bridge.js # 直接复用
|
||||
├── WeReadApi.js # 直接复用
|
||||
├── cssInjector.js # 直接复用
|
||||
├── rangy-core.js # 直接复用
|
||||
└── rangy-serializer.js # 直接复用
|
||||
```
|
||||
|
||||
### 3.2 WKWebView → Android WebView 桥接映射
|
||||
|
||||
| iOS 机制 | Android 等价物 |
|
||||
|----------|--------------|
|
||||
| `WKScriptMessageHandler.userContentController(_:didReceive:)` | `@JavascriptInterface` 方法 |
|
||||
| `window.webkit.messageHandlers.{name}.postMessage()` | 修改 JS 端调用 `AndroidBridge.{method}()` |
|
||||
| `WKWebView.evaluateJavaScript()` | `WebView.evaluateJavascript()` |
|
||||
| `WKURLSchemeHandler` | `WebViewClient.shouldInterceptRequest()` |
|
||||
| `WKUserScript`(页面加载前注入) | `WebView.addJavascriptInterface()` + `WebViewClient.onPageStarted()` |
|
||||
|
||||
### 3.3 Android Bridge 实现
|
||||
|
||||
```kotlin
|
||||
// EpubBridge.kt — 对应 iOS RDEPUBJavaScriptBridge
|
||||
class EpubBridge(private val callback: BridgeCallback) {
|
||||
|
||||
@JavascriptInterface
|
||||
fun onProgressionChanged(json: String) { callback.onProgressionChanged(json) }
|
||||
|
||||
@JavascriptInterface
|
||||
fun onSelectionChanged(json: String) { callback.onSelectionChanged(json) }
|
||||
|
||||
@JavascriptInterface
|
||||
fun onInternalLink(href: String) { callback.onInternalLink(href) }
|
||||
|
||||
@JavascriptInterface
|
||||
fun onExternalLink(url: String) { callback.onExternalLink(url) }
|
||||
|
||||
@JavascriptInterface
|
||||
fun onJSError(message: String) { callback.onJSError(message) }
|
||||
|
||||
@JavascriptInterface
|
||||
fun onFixedLayoutReady() { callback.onFixedLayoutReady() }
|
||||
}
|
||||
```
|
||||
|
||||
**JS 端修改**:需要将 `window.webkit.messageHandlers.xxx.postMessage(data)` 替换为 `window.AndroidBridge.xxx(JSON.stringify(data))`。可以通过在注入时做字符串替换,或维护一个 Android 版本的 bridge JS 文件。
|
||||
|
||||
### 3.4 WebView 资源拦截
|
||||
|
||||
对应 iOS 的 `ss-reader://book/` 自定义协议:
|
||||
|
||||
```kotlin
|
||||
// EpubSchemeHandler.kt
|
||||
class EpubSchemeHandler(private val publication: EpubPublication) : WebViewClient() {
|
||||
|
||||
override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? {
|
||||
val url = request.url.toString()
|
||||
if (url.startsWith("ss-reader://book/")) {
|
||||
val path = url.removePrefix("ss-reader://book/")
|
||||
val data = publication.readResource(path)
|
||||
val mimeType = getMimeType(path)
|
||||
return WebResourceResponse(mimeType, "UTF-8", data.byteInputStream())
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.5 JavaScriptBridge 脚本生成
|
||||
|
||||
对应 iOS 的 `RDEPUBJavaScriptBridge` 中各种 `*Script()` 方法,生成 `evaluateJavascript()` 调用的 JS 代码字符串。这些方法的核心是拼接 JSON 参数 + 调用 `WeReadApi.*` 方法,逻辑完全相同。
|
||||
|
||||
---
|
||||
|
||||
## 第四阶段:翻页容器(预计 1.5 周)
|
||||
|
||||
### 4.1 PagerView 实现
|
||||
|
||||
对应 iOS `RDReaderView`(UICollectionView + UIPageViewController):
|
||||
|
||||
```kotlin
|
||||
// PagerView.kt — 对应 iOS RDReaderView
|
||||
class PagerView @JvmOverloads constructor(
|
||||
context: Context, attrs: AttributeSet? = null
|
||||
) : FrameLayout(context, attrs) {
|
||||
|
||||
private val viewPager: ViewPager2
|
||||
private val adapter: PageAdapter
|
||||
|
||||
enum class DisplayMode { HORIZONTAL, VERTICAL }
|
||||
var displayMode = DisplayMode.HORIZONTAL
|
||||
set(value) { /* 切换 orientation */ }
|
||||
|
||||
fun reloadData() { adapter.notifyDataSetChanged() }
|
||||
fun scrollToPage(index: Int, animated: Boolean) { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
**PageAdapter**:使用 `RecyclerView.Adapter`,每个 item 是一个 `FrameLayout`,内部放 `WebView`。
|
||||
|
||||
### 4.2 点击区域检测
|
||||
|
||||
对应 iOS `RDReaderTapRegionHandler`:
|
||||
|
||||
```kotlin
|
||||
// TapRegionHandler.kt
|
||||
class TapRegionHandler {
|
||||
enum class Region { LEFT, CENTER, RIGHT }
|
||||
|
||||
fun resolve(x: Float, viewWidth: Float): Region {
|
||||
return when {
|
||||
x < viewWidth / 3 -> Region.LEFT
|
||||
x > viewWidth * 2 / 3 -> Region.RIGHT
|
||||
else -> Region.CENTER
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 预加载控制
|
||||
|
||||
对应 iOS `RDReaderPreloadController`:
|
||||
|
||||
使用 `ViewPager2.offscreenPageLimit` 控制预加载页面数量(默认 1-2 页)。
|
||||
|
||||
---
|
||||
|
||||
## 第五阶段:UI 组件(预计 2 周)
|
||||
|
||||
### 5.1 阅读器主控制器
|
||||
|
||||
对应 iOS `RDEPUBReaderController`:
|
||||
|
||||
```kotlin
|
||||
// EpubReaderFragment.kt — Fragment 优于 Activity,方便宿主嵌入
|
||||
class EpubReaderFragment : Fragment() {
|
||||
|
||||
// 对应 iOS 的各 Coordinator
|
||||
private lateinit var runtime: ReaderRuntime
|
||||
private lateinit var pagerView: PagerView
|
||||
private lateinit var topBar: TopToolBar
|
||||
private lateinit var bottomBar: BottomToolBar
|
||||
private lateinit var searchPanel: SearchPanelView
|
||||
|
||||
// Public API
|
||||
fun openBook(epubUrl: Uri, configuration: ReaderConfiguration = ReaderConfiguration.default)
|
||||
fun goTo(location: ReadingLocation)
|
||||
fun goToPageNumber(pageNumber: Int)
|
||||
fun search(keyword: String)
|
||||
fun addHighlight(selection: Selection, color: Int, note: String?)
|
||||
fun addBookmark(note: String?)
|
||||
// ... 其余 public API
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 Coordinator 架构迁移
|
||||
|
||||
保持 iOS 的 Coordinator 模式,每个 Coordinator 作为独立的 Kotlin 类:
|
||||
|
||||
| iOS Coordinator | Android 类 | 职责 |
|
||||
|----------------|-----------|------|
|
||||
| `RDEPUBReaderRuntime` | `ReaderRuntime` | Facade,协调所有子 Coordinator |
|
||||
| `RDEPUBReaderLoadCoordinator` | `LoadCoordinator` | EPUB 文件解析 + Publication 初始化 |
|
||||
| `RDEPUBReaderPaginationCoordinator` | `PaginationCoordinator` | 分页计算 |
|
||||
| `RDEPUBReaderLocationCoordinator` | `LocationCoordinator` | 阅读位置管理 + 持久化 |
|
||||
| `RDEPUBReaderChromeCoordinator` | `ChromeCoordinator` | 工具栏状态同步 |
|
||||
| `RDEPUBReaderAnnotationCoordinator` | `AnnotationCoordinator` | 高亮/书签/批注 CRUD |
|
||||
| `RDEPUBReaderSearchCoordinator` | `SearchCoordinator` | 全文搜索协调 |
|
||||
| `RDEPUBReaderViewportMonitor` | `ViewportMonitor` | 屏幕旋转/尺寸变化检测 |
|
||||
|
||||
### 5.3 WebView 内容视图
|
||||
|
||||
对应 iOS `RDEPUBWebView` + `RDEPUBWebContentView`:
|
||||
|
||||
```kotlin
|
||||
// EpubWebView.kt — 封装 Android WebView
|
||||
class EpubWebView @JvmOverloads constructor(
|
||||
context: Context, attrs: AttributeSet? = null
|
||||
) : WebView(context, attrs) {
|
||||
|
||||
private val bridge = EpubBridge(/* ... */)
|
||||
|
||||
fun setup() {
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
addJavascriptInterface(bridge, "AndroidBridge")
|
||||
webViewClient = EpubSchemeHandler(publication)
|
||||
webChromeClient = WebChromeClient()
|
||||
}
|
||||
|
||||
fun loadChapter(spineIndex: Int, preferences: ReadingPreferences) {
|
||||
// 生成 HTML 模板 + 注入 CSS + 加载资源
|
||||
}
|
||||
|
||||
fun evaluateScript(script: String, callback: ((String?) -> Unit)? = null) {
|
||||
evaluateJavascript(script) { result -> callback?.invoke(result) }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 顶部工具栏
|
||||
|
||||
对应 iOS `RDEPUBReaderTopToolView`:
|
||||
|
||||
```xml
|
||||
<!-- top_tool_bar.xml -->
|
||||
<LinearLayout>
|
||||
<ImageButton android:id="@+id/btnBack" />
|
||||
<TextView android:id="@+id/tvTitle" weight="1" />
|
||||
<ImageButton android:id="@+id/btnSearch" />
|
||||
<ImageButton android:id="@+id/btnBookmark" />
|
||||
</LinearLayout>
|
||||
```
|
||||
|
||||
### 5.5 底部工具栏
|
||||
|
||||
对应 iOS `RDEPUBReaderBottomToolView`:
|
||||
|
||||
```xml
|
||||
<!-- bottom_tool_bar.xml -->
|
||||
<LinearLayout>
|
||||
<ImageButton android:id="@+id/btnTOC" /> <!-- 目录 -->
|
||||
<ImageButton android:id="@+id/btnBookmarks" /> <!-- 书签列表 -->
|
||||
<ImageButton android:id="@+id/btnHighlights" /> <!-- 高亮列表 -->
|
||||
<ImageButton android:id="@+id/btnAddHighlight" /> <!-- 添加高亮 -->
|
||||
<ImageButton android:id="@+id/btnSettings" /> <!-- 设置 -->
|
||||
</LinearLayout>
|
||||
```
|
||||
|
||||
### 5.6 搜索面板
|
||||
|
||||
对应 iOS `RDEPUBReaderSearchBarView`:
|
||||
|
||||
```kotlin
|
||||
// SearchPanelView.kt
|
||||
class SearchPanelView @JvmOverloads constructor(
|
||||
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
|
||||
) : LinearLayout(context, attrs, defStyleAttr) {
|
||||
|
||||
private val searchField: EditText
|
||||
private val cancelButton: Button
|
||||
private val resultsList: RecyclerView // 分组结果列表
|
||||
private val emptyStateLabel: TextView
|
||||
|
||||
fun updateResults(sections: List<SearchSection>, keyword: String, currentMatchIndex: Int?)
|
||||
fun showNoResults()
|
||||
fun showSearching()
|
||||
}
|
||||
```
|
||||
|
||||
### 5.7 设置面板
|
||||
|
||||
对应 iOS `RDEPUBReaderSettingsViewController`:
|
||||
|
||||
使用 `BottomSheetDialogFragment` 实现底部弹出设置面板:
|
||||
- 亮度调节(SeekBar)
|
||||
- 字体大小(+/- 按钮)
|
||||
- 字体选择
|
||||
- 行间距
|
||||
- 主题切换(6 种预设)
|
||||
- 翻页模式切换
|
||||
|
||||
### 5.8 目录列表
|
||||
|
||||
对应 iOS `RDEPUBReaderChapterListController`:
|
||||
|
||||
使用 `BottomSheetDialogFragment` + `RecyclerView` 实现目录列表,支持章节标题显示和点击跳转。
|
||||
|
||||
---
|
||||
|
||||
## 第六阶段:持久化与状态管理(预计 0.5 周)
|
||||
|
||||
### 6.1 持久化实现
|
||||
|
||||
对应 iOS `RDEPUBReaderPersistence`:
|
||||
|
||||
```kotlin
|
||||
// ReaderPersistence.kt
|
||||
interface ReaderPersistence {
|
||||
fun loadLocation(bookId: String): ReadingLocation?
|
||||
fun saveLocation(bookId: String, location: ReadingLocation)
|
||||
fun loadBookmarks(bookId: String): List<Bookmark>
|
||||
fun saveBookmarks(bookId: String, bookmarks: List<Bookmark>)
|
||||
fun loadHighlights(bookId: String): List<Highlight>
|
||||
fun saveHighlights(bookId: String, highlights: List<Highlight>)
|
||||
fun loadSettings(): ReaderSettings
|
||||
fun saveSettings(settings: ReaderSettings)
|
||||
}
|
||||
|
||||
// SharedPreferences 实现
|
||||
class SharedPrefsPersistence(context: Context) : ReaderPersistence {
|
||||
// 使用 Gson 序列化/反序列化
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 状态管理
|
||||
|
||||
使用 Kotlin `StateFlow` 替代 iOS 的手动状态同步:
|
||||
|
||||
```kotlin
|
||||
// ReaderState.kt
|
||||
data class ReaderUiState(
|
||||
val isLoading: Boolean = true,
|
||||
val currentPage: Int = 0,
|
||||
val totalPages: Int = 0,
|
||||
val title: String = "",
|
||||
val isToolbarVisible: Boolean = false,
|
||||
val isSearchVisible: Boolean = false,
|
||||
val bookmarks: List<Bookmark> = emptyList(),
|
||||
val highlights: List<Highlight> = emptyList(),
|
||||
val searchState: SearchState? = null,
|
||||
)
|
||||
|
||||
class ReaderStateManager {
|
||||
private val _uiState = MutableStateFlow(ReaderUiState())
|
||||
val uiState: StateFlow<ReaderUiState> = _uiState.asStateFlow()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第七阶段:主题与外观(预计 0.5 周)
|
||||
|
||||
### 7.1 主题系统
|
||||
|
||||
对应 iOS `RDEPUBReaderTheme`:
|
||||
|
||||
```kotlin
|
||||
// ReaderTheme.kt
|
||||
data class ReaderTheme(
|
||||
val name: String,
|
||||
val contentBackgroundColor: Int, // Color int
|
||||
val textColor: Int,
|
||||
val toolbarBackgroundColor: Int,
|
||||
val toolbarForegroundColor: Int,
|
||||
val isDark: Boolean
|
||||
) {
|
||||
companion object {
|
||||
val LIGHT = ReaderTheme("浅色", Color.WHITE, Color.BLACK, ...)
|
||||
val DARK = ReaderTheme("深色", Color.rgb(0x1A, 0x1A, 0x1A), Color.WHITE, ...)
|
||||
val YELLOW = ReaderTheme("纸质", Color.rgb(0xF5, 0xF0, 0xE0), Color.BLACK, ...)
|
||||
// ... 共 6 种
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 状态栏适配
|
||||
|
||||
根据主题动态设置状态栏颜色(`Window.statusBarColor`)和图标颜色(`WindowInsetsController.isAppearanceLightStatusBars`)。
|
||||
|
||||
---
|
||||
|
||||
## 第八阶段:测试与 Demo(预计 1 周)
|
||||
|
||||
### 8.1 单元测试
|
||||
|
||||
- `EpubParserTest` — EPUB 解析(container.xml、OPF、TOC)
|
||||
- `SearchEngineTest` — 搜索引擎
|
||||
- `StyleSheetBuilderTest` — CSS 生成
|
||||
- `ResourceResolverTest` — URL 解析
|
||||
- `LocationCoordinatorTest` — 位置管理
|
||||
|
||||
### 8.2 集成测试
|
||||
|
||||
- `EpubReaderIntegrationTest` — 完整阅读流程(打开 → 分页 → 翻页 → 搜索 → 高亮)
|
||||
|
||||
### 8.3 Demo App
|
||||
|
||||
对应 iOS `ReadViewDemo`:
|
||||
|
||||
```kotlin
|
||||
// MainActivity.kt
|
||||
class MainActivity : AppCompatActivity() {
|
||||
private fun openBook() {
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = "application/epub+zip"
|
||||
}
|
||||
startActivityForResult(intent, REQUEST_OPEN_BOOK)
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
if (requestCode == REQUEST_OPEN_BOOK && resultCode == RESULT_OK) {
|
||||
val uri = data?.data ?: return
|
||||
val fragment = EpubReaderFragment.newInstance(uri)
|
||||
// 嵌入 Fragment
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8.4 UI 自动化测试
|
||||
|
||||
对应 iOS 的 XCUIApplication 测试(24 个测试文件):
|
||||
- 使用 Espresso + UiAutomator
|
||||
- 覆盖:目录交互、页码导航、书签管理、高亮管理、搜索、设置效果、工具栏状态等
|
||||
|
||||
---
|
||||
|
||||
## 实施时间估算
|
||||
|
||||
| 阶段 | 内容 | 预计工时 |
|
||||
|------|------|---------|
|
||||
| 1 | 项目基础设施 | 1 周 |
|
||||
| 2 | Core 层(解析、模型、搜索、CSS) | 2 周 |
|
||||
| 3 | JS 桥接层 | 1 周 |
|
||||
| 4 | 翻页容器 | 1.5 周 |
|
||||
| 5 | UI 组件 | 2 周 |
|
||||
| 6 | 持久化与状态管理 | 0.5 周 |
|
||||
| 7 | 主题与外观 | 0.5 周 |
|
||||
| 8 | 测试与 Demo | 1 周 |
|
||||
| **合计** | | **约 9.5 周** |
|
||||
|
||||
---
|
||||
|
||||
## 关键技术决策
|
||||
|
||||
1. **统一 WebView 渲染**:不实现 DTCoreText 等效层,所有内容通过 WebView + CSS column 分页。简化实现,与 iOS 的 webInteractive 路径一致。
|
||||
|
||||
2. **Coordinator 模式保留**:与 iOS 架构保持一致,便于两端逻辑对照和维护。
|
||||
|
||||
3. **JS Bridge 微调**:Android 的 `@JavascriptInterface` 与 iOS 的 `WKScriptMessageHandler` 机制不同,JS 端需要适配(用 `AndroidBridge.*` 替代 `window.webkit.messageHandlers.*`)。
|
||||
|
||||
4. **不使用 Kotlin Multiplatform**:当前阶段直接用 Kotlin 重写,避免 KMP 的额外复杂度。未来如果需要共享逻辑层,可以逐步迁移。
|
||||
|
||||
5. **ViewPager2 替代 PageViewController**:Android 无 page curl 效果,使用 ViewPager2 + 自定义 PageTransformer 实现翻页动画(可选深度效果或滑动效果)。
|
||||
|
||||
---
|
||||
|
||||
## 文件映射索引(iOS → Android)
|
||||
|
||||
### 核心映射
|
||||
|
||||
| iOS Swift 文件 | Android Kotlin 文件 | 行数参考 |
|
||||
|---------------|-------------------|---------|
|
||||
| `RDEPUBParser.swift` + extensions | `EpubParser.kt` + extensions | ~600 |
|
||||
| `RDEPUBModels.swift` | `EpubModels.kt` | ~200 |
|
||||
| `RDEPUBPublication.swift` | `EpubPublication.kt` | ~150 |
|
||||
| `RDEPUBJavaScriptBridge.swift` | `EpubJavaScriptBridge.kt` | ~400 |
|
||||
| `RDEPUBStyleSheetBuilder.swift` | `StyleSheetBuilder.kt` | ~300 |
|
||||
| `RDEPUBSearchEngine.swift` | `EpubSearchEngine.kt` | ~150 |
|
||||
| `RDEPUBPaginator.swift` | `EpubPaginator.kt` | ~200 |
|
||||
| `RDEPUBReadingSession.swift` | `ReadingSession.kt` | ~250 |
|
||||
| `RDEPUBReaderController.swift` + extensions | `EpubReaderFragment.kt` + extensions | ~800 |
|
||||
| `RDEPUBReaderContext.swift` | `ReaderContext.kt` | ~150 |
|
||||
| `RDEPUBReaderRuntime.swift` | `ReaderRuntime.kt` | ~200 |
|
||||
| 各 Coordinator 文件 | 对应 `*Coordinator.kt` | 各 ~100-200 |
|
||||
| `RDEPUBWebView.swift` + extensions | `EpubWebView.kt` + extensions | ~500 |
|
||||
| `RDReaderView.swift` + extensions | `PagerView.kt` + extensions | ~400 |
|
||||
| `RDEPUBReaderSearchBarView.swift` | `SearchPanelView.kt` | ~500 |
|
||||
| `RDEPUBReaderTopToolView.swift` | `TopToolBar.kt` | ~150 |
|
||||
| `RDEPUBReaderBottomToolView.swift` | `BottomToolBar.kt` | ~200 |
|
||||
| `RDEPUBReaderSettingsViewController.swift` | `SettingsSheetFragment.kt` | ~300 |
|
||||
|
||||
### 直接复用(不需翻译)
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `epub-bridge.js` | JS 核心桥接 |
|
||||
| `WeReadApi.js` | JS API 门面 |
|
||||
| `cssInjector.js` | CSS 注入工具 |
|
||||
| `rangy-core.js` | Range 操作库 |
|
||||
| `rangy-serializer.js` | Range 序列化 |
|
||||
|
||||
### 不迁移(Android 不需要)
|
||||
|
||||
| iOS 文件/目录 | 原因 |
|
||||
|-------------|------|
|
||||
| `EPUBTextRendering/`(18 个文件) | DTCoreText/CoreText 无 Android 等价物,统一用 WebView |
|
||||
| `EPUBUI/TextPage/`(7 个文件) | 原生文字渲染层,Android 不需要 |
|
||||
| `RDEPUBTextContentView.swift` | 同上 |
|
||||
| `RDEPUBTextPageRenderView.swift` | 同上 |
|
||||
| `RDEPUBWebDecorationOverlayView.swift` | Android WebView 的高亮由 JS 直接处理,不需要原生 overlay |
|
||||
@ -177,7 +177,7 @@ while location < totalLength:
|
||||
│ ├─ 加载窗口内相邻章节
|
||||
│ └─ 应用局部 BookPageMap → 用户可立即阅读
|
||||
│
|
||||
└─ 后台解析(RDEPUBMetadataParseWorker.start())
|
||||
└─ 后台解析(paginateMetadataOnly)
|
||||
├─ 预计算所有章节 contentHash(串行,~1-3s)
|
||||
├─ 恢复已有磁盘缓存
|
||||
├─ 等待用户操作冷却 0.8s
|
||||
@ -196,7 +196,7 @@ while location < totalLength:
|
||||
var contentHashBySpineIndex: [Int: String] = [:]
|
||||
for spineIndex in allBuildableIndices {
|
||||
let html = parser.htmlString(forRelativePath: href)
|
||||
contentHashBySpineIndex[spineIndex] = html?.rd_sha256Hex ?? ""
|
||||
contentHashBySpineIndex[spineIndex] = html?.sha256Hex ?? ""
|
||||
}
|
||||
```
|
||||
|
||||
@ -206,7 +206,7 @@ for spineIndex in allBuildableIndices {
|
||||
|
||||
**问题:** 如果用户在后台解析进行中更改字号/行距,`context.currentRenderSignature()` 会返回新值,导致部分章节用旧签名、部分用新签名写入磁盘缓存,造成缓存不一致。
|
||||
|
||||
**解决:** 在 `RDEPUBMetadataParseWorker` 初始化时冻结签名:
|
||||
**解决:** 在 `paginateMetadataOnly` 开始时冻结签名:
|
||||
|
||||
```swift
|
||||
let renderSignature = context.currentRenderSignature()
|
||||
@ -298,14 +298,14 @@ return builder.build()
|
||||
|
||||
**触发条件:** `landscapeDualPageEnabled && isLandscape && !verticalScroll`
|
||||
|
||||
**页面配对逻辑(RDEpubReaderSpreadResolver):**
|
||||
**页面配对逻辑(RDReaderSpreadResolver):**
|
||||
|
||||
- 有封面页:封面独占一屏,后续页面两两配对
|
||||
- 配对规则:`(coverIndex, nil)`, `(1, 2)`, `(3, 4)`, ...
|
||||
- 无封面页:标准偶奇配对
|
||||
- 配对规则:`(0, 1)`, `(2, 3)`, `(4, 5)`, ...
|
||||
|
||||
**封面感知布局(RDEpubReaderFlowLayout):**
|
||||
**封面感知布局(RDReaderFlowLayout):**
|
||||
|
||||
```
|
||||
封面页: [====全屏宽度====]
|
||||
@ -314,7 +314,7 @@ return builder.build()
|
||||
|
||||
### 5.3 页面预加载
|
||||
|
||||
`RDEpubReaderPreloadController` 管理两个缓存:
|
||||
`RDReaderPreloadController` 管理两个缓存:
|
||||
- `preloadedPageViews` - 预渲染的页面视图
|
||||
- `pageCurlCachedViews` - 当前显示的页面视图
|
||||
|
||||
@ -580,7 +580,7 @@ protocol RDEPUBReaderPersistence {
|
||||
|
||||
## 9. 纯文本 (.txt) 支持
|
||||
|
||||
`RDEpubPlainTextBookBuilder` 复用 EPUB 渲染管线处理 .txt 文件:
|
||||
`RDPlainTextBookBuilder` 复用 EPUB 渲染管线处理 .txt 文件:
|
||||
|
||||
1. **解码:** 依次尝试 UTF-8 → GB18030 → GBK
|
||||
2. **分章:** 正则匹配 `^(第[零一二三四五六七八九十百千万\d]+[章节回卷].*)$`
|
||||
|
||||
@ -1,314 +0,0 @@
|
||||
# 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 静默失败 → 统一错误处理
|
||||
```
|
||||
@ -1,597 +0,0 @@
|
||||
# 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/RDEpubReaderView/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/RDEpubReaderView/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/RDEpubReaderView/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/RDEpubReaderView/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/RDEpubReaderView/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/RDEpubReaderView/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/RDEpubReaderView/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/RDEpubReaderView/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/RDEpubReaderView/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/RDEpubReaderView/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/RDEpubReaderView/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/RDEpubReaderView/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/RDEpubReaderView/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` | 错误类型定义 |
|
||||
@ -1,425 +0,0 @@
|
||||
# 章节运行时详解
|
||||
|
||||
> 最后更新:2026-06-18
|
||||
|
||||
本文档详细描述 ReadViewSDK 章节运行时子系统的架构、缓存策略、加载流程和优化机制。
|
||||
|
||||
---
|
||||
|
||||
## 1. 概述
|
||||
|
||||
章节运行时是 EPUBUI 层的核心子系统,负责章节的按需加载、缓存管理和页码映射。它位于 `RDEPUBReaderContext` → `RDEPUBReaderRuntime` 架构中,是实现大书(如 1000+ 章的网络小说)流畅阅读的关键。
|
||||
|
||||
**核心设计目标**:
|
||||
- 快速打开:用户点击书籍后 1-2 秒内可开始阅读
|
||||
- 按需加载:只加载当前窗口内的章节,内存占用可控
|
||||
- 渐进补全:后台逐步补全所有章节的页码信息
|
||||
|
||||
**关键文件**:
|
||||
- `Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/` 目录
|
||||
- `Sources/RDEpubReaderView/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 整体流程
|
||||
|
||||
```
|
||||
RDEPUBMetadataParseWorker.start(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?.rd_sha256Hex ?? ""
|
||||
}
|
||||
```
|
||||
|
||||
后续所有 `chapterCacheKey` 调用都使用预计算值。
|
||||
|
||||
### 7.3 冻结 renderSignature
|
||||
|
||||
**问题**:用户在后台解析进行中更改字号/行距,会导致部分章节用旧签名、部分用新签名写入缓存。
|
||||
|
||||
**解决**:在 `RDEPUBMetadataParseWorker` 初始化时冻结签名:
|
||||
|
||||
```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 版本 |
|
||||
@ -105,15 +105,15 @@
|
||||
## Evidence(仓库路径)
|
||||
|
||||
- Build flags:`Podfile`
|
||||
- Pod metadata/toolchain:`RDEpubReaderView.podspec`
|
||||
- Archive extraction:`Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+Archive.swift`
|
||||
- Resource path validation(post-extraction):`Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+Resources.swift`
|
||||
- Scheme handler reads full file bytes:`Sources/RDEpubReaderView/EPUBCore/RDEPUBResourceURLSchemeHandler.swift`
|
||||
- WebView bridge + message handling:`Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+JavaScriptBridge.swift`
|
||||
- External link opening:`Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController.swift`
|
||||
- UserDefaults persistence implementation:`Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderPersistence.swift`
|
||||
- Hidden paginator web view:`Sources/RDEpubReaderView/EPUBCore/RDEPUBPaginator.swift`
|
||||
- Debug logging:`Sources/RDEpubReaderView/EPUBCore/RDEPUBWebViewDebug.swift`
|
||||
- Pod metadata/toolchain:`RDReaderView.podspec`
|
||||
- Archive extraction:`Sources/RDReaderView/EPUBCore/RDEPUBParser+Archive.swift`
|
||||
- Resource path validation(post-extraction):`Sources/RDReaderView/EPUBCore/RDEPUBParser+Resources.swift`
|
||||
- Scheme handler reads full file bytes:`Sources/RDReaderView/EPUBCore/RDEPUBResourceURLSchemeHandler.swift`
|
||||
- WebView bridge + message handling:`Sources/RDReaderView/EPUBCore/RDEPUBWebView+JavaScriptBridge.swift`
|
||||
- External link opening:`Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift`
|
||||
- UserDefaults persistence implementation:`Sources/RDReaderView/EPUBUI/RDEPUBReaderPersistence.swift`
|
||||
- Hidden paginator web view:`Sources/RDReaderView/EPUBCore/RDEPUBPaginator.swift`
|
||||
- Debug logging:`Sources/RDReaderView/EPUBCore/RDEPUBWebViewDebug.swift`
|
||||
- Vendored dependencies:`Pods/`、`ReadViewDemo/Pods/`
|
||||
|
||||
---
|
||||
|
||||
@ -6,43 +6,43 @@
|
||||
|
||||
## 语言与工程约束
|
||||
|
||||
- **主要语言**:Swift(Podspec 声明 `s.swift_versions = ["5.10"]`,见 `RDEpubReaderView.podspec`)
|
||||
- **最低系统版本**:Podspec `iOS 15.0`(`RDEpubReaderView.podspec`),示例工程 Podfile/构建设置里常见为 `iOS 15.6`(`Podfile`)
|
||||
- **主要语言**:Swift(Podspec 声明 `s.swift_versions = ["5.10"]`,见 `RDReaderView.podspec`)
|
||||
- **最低系统版本**:Podspec `iOS 15.0`(`RDReaderView.podspec`),示例工程 Podfile/构建设置里常见为 `iOS 15.6`(`Podfile`)
|
||||
- **依赖管理**:CocoaPods(`Podfile`、`ReadViewDemo/Podfile`、`Podfile.lock`)
|
||||
|
||||
## 命名约定
|
||||
|
||||
**文件/类型命名(Swift):**
|
||||
- 以类型名为文件名的单文件组织较常见:`Sources/RDEpubReaderView/EPUBCore/RDEPUBParser.swift`
|
||||
- 以类型名为文件名的单文件组织较常见:`Sources/RDReaderView/EPUBCore/RDEPUBParser.swift`
|
||||
- 大量使用前缀区分模块域:
|
||||
- `RD...`:阅读器 UI/控制器相关(如 `Sources/RDEpubReaderView/RDEpubReaderView.swift`、`Sources/RDEpubReaderView/RDEpubURLReaderController.swift`)
|
||||
- `RDEPUB...`:EPUB Core/UI/渲染相关(如 `Sources/RDEpubReaderView/EPUBCore/RDEPUBModels.swift`)
|
||||
- Extension 文件使用 `+` 命名:`Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift`
|
||||
- `RD...`:阅读器 UI/控制器相关(如 `Sources/RDReaderView/RDReaderView.swift`、`Sources/RDReaderView/RDURLReaderController.swift`)
|
||||
- `RDEPUB...`:EPUB Core/UI/渲染相关(如 `Sources/RDReaderView/EPUBCore/RDEPUBModels.swift`)
|
||||
- Extension 文件使用 `+` 命名:`Sources/RDReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift`
|
||||
|
||||
**变量/函数命名:**
|
||||
- 基本遵循 Swift lowerCamelCase:`parse(epubURL:)`(`Sources/RDEpubReaderView/EPUBCore/RDEPUBParser.swift`)
|
||||
- 常量多用 `static let`:`kRDEPUBHighlightAttributeName`(`Sources/RDEpubReaderView/EPUBCore/Models/RDEPUBAnnotationModels.swift`)
|
||||
- 基本遵循 Swift lowerCamelCase:`parse(epubURL:)`(`Sources/RDReaderView/EPUBCore/RDEPUBParser.swift`)
|
||||
- 常量多用 `static let`:`kRDEPUBHighlightAttributeName`(`Sources/RDReaderView/EPUBCore/Models/RDEPUBAnnotationModels.swift`)
|
||||
|
||||
## 代码风格与排版(从现有代码归纳)
|
||||
|
||||
**缩进与换行:**
|
||||
- 多数文件使用 4 空格缩进(示例:`ReadViewDemo/ReadViewDemo/ViewController.swift`、`Sources/RDEpubReaderView/EPUBCore/RDEPUBModels.swift`)
|
||||
- 多数文件使用 4 空格缩进(示例:`ReadViewDemo/ReadViewDemo/ViewController.swift`、`Sources/RDReaderView/EPUBCore/RDEPUBModels.swift`)
|
||||
- 历史代码中更常见”强制换行/多行括号”风格
|
||||
|
||||
**空行与分组:**
|
||||
- UI 相关文件常用空行分隔属性/初始化/布局段落
|
||||
- `// MARK:` 用于分区组织(示例:`Sources/RDEpubReaderView/RDEpubReaderGestureController.swift`、`Sources/RDEpubReaderView/EPUBTextRendering/RDEpubPlainTextBookBuilder.swift`)
|
||||
- `// MARK:` 用于分区组织(示例:`Sources/RDReaderView/RDReaderGestureController.swift`、`Sources/RDReaderView/EPUBTextRendering/RDPlainTextBookBuilder.swift`)
|
||||
|
||||
**类型组织:**
|
||||
- 偏好用 `extension` 拆分职责/协议实现(示例:`ReadViewDemo/ReadViewDemo/ViewController.swift` 的 `UITableViewDataSource/Delegate`)
|
||||
- API 暴露处使用 `public`、`public final class`、`public enum/struct`(示例:`Sources/RDEpubReaderView/EPUBCore/RDEPUBModels.swift`)
|
||||
- “对外只读、内部可写”常用 `public internal(set)`(示例:`Sources/RDEpubReaderView/EPUBCore/RDEPUBParser.swift`)
|
||||
- API 暴露处使用 `public`、`public final class`、`public enum/struct`(示例:`Sources/RDReaderView/EPUBCore/RDEPUBModels.swift`)
|
||||
- “对外只读、内部可写”常用 `public internal(set)`(示例:`Sources/RDReaderView/EPUBCore/RDEPUBParser.swift`)
|
||||
|
||||
## 导入与依赖使用
|
||||
|
||||
**import:**
|
||||
- UIKit/UI 文件:`import UIKit`(大量文件)
|
||||
- Core/模型文件:`import Foundation`(如 `Sources/RDEpubReaderView/EPUBCore/RDEPUBModels.swift`)
|
||||
- Core/模型文件:`import Foundation`(如 `Sources/RDReaderView/EPUBCore/RDEPUBModels.swift`)
|
||||
- 三方依赖按需引入:
|
||||
- `SnapKit`:布局
|
||||
- `SSAlertSwift`:弹窗/提示
|
||||
@ -52,15 +52,15 @@
|
||||
|
||||
## 错误处理与日志
|
||||
|
||||
- Core 解析层倾向用 `throws` + 自定义 `Error`(示例:`Sources/RDEpubReaderView/EPUBCore/RDEPUBParser.swift`、`Sources/RDEpubReaderView/EPUBCore/RDEPUBModels.swift` 的 `RDEPUBParserError`)
|
||||
- Core 解析层倾向用 `throws` + 自定义 `Error`(示例:`Sources/RDReaderView/EPUBCore/RDEPUBParser.swift`、`Sources/RDReaderView/EPUBCore/RDEPUBModels.swift` 的 `RDEPUBParserError`)
|
||||
- UI/控制器层常见 `guard` 早返回(示例:`ReadViewDemo/ReadViewDemo/ViewController.swift`)
|
||||
- 未检测到统一日志框架(未发现专用 logging package/config);出现时以系统 API/局部输出为主(需按具体文件核对)。
|
||||
|
||||
## 注释与文档
|
||||
|
||||
- **项目规则(强约束)**:代码标识符保持英文,但**代码注释/文档/提交信息使用中文**(见 `CONTEXT.md`)。
|
||||
- 历史文件常带 Xcode 头部注释块(示例:`Sources/RDEpubReaderView/ReaderView/RDEpubReaderView.swift`)。
|
||||
- 公共 API 处存在少量三斜线文档注释(示例:`Sources/RDEpubReaderView/RDEpubReaderView.swift` 的中文说明)。
|
||||
- 历史文件常带 Xcode 头部注释块(示例:`Sources/RDReaderView/ReaderView/RDReaderView.swift`)。
|
||||
- 公共 API 处存在少量三斜线文档注释(示例:`Sources/RDReaderView/RDReaderView.swift` 的中文说明)。
|
||||
|
||||
## Lint / Formatter / 静态检查
|
||||
|
||||
@ -77,9 +77,9 @@
|
||||
|
||||
- `CONTEXT.md`
|
||||
- `Podfile`
|
||||
- `RDEpubReaderView.podspec`
|
||||
- `RDReaderView.podspec`
|
||||
- `ReadViewDemo/ReadViewDemo/ViewController.swift`
|
||||
- `Sources/RDEpubReaderView/ReaderView/RDEpubReaderView.swift`
|
||||
- `Sources/RDEpubReaderView/EPUBCore/RDEPUBParser.swift`
|
||||
- `Sources/RDEpubReaderView/EPUBCore/RDEPUBModels.swift`
|
||||
- `Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController.swift`
|
||||
- `Sources/RDReaderView/ReaderView/RDReaderView.swift`
|
||||
- `Sources/RDReaderView/EPUBCore/RDEPUBParser.swift`
|
||||
- `Sources/RDReaderView/EPUBCore/RDEPUBModels.swift`
|
||||
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift`
|
||||
|
||||
@ -1,642 +0,0 @@
|
||||
# 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` 管理导航状态 |
|
||||
@ -1,521 +0,0 @@
|
||||
# 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. 纯文本构建器
|
||||
|
||||
**文件:** `RDEpubPlainTextBookBuilder.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(全书模型 + 索引表)
|
||||
```
|
||||
@ -1,731 +0,0 @@
|
||||
# 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 = RDEpubReaderView() // 翻页容器
|
||||
}
|
||||
```
|
||||
|
||||
**扩展文件职责:**
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `RDEPUBReaderController+PublicAPI.swift` | 公开 API(跳转、搜索、标注、书签) |
|
||||
| `RDEPUBReaderController+DataSource.swift` | `RDEpubReaderPageProvider` 实现 |
|
||||
| `RDEPUBReaderController+ContentDelegates.swift` | WebView/TextContentView 代理路由 |
|
||||
| `RDEPUBReaderController+LocationResolution.swift` | 页码/位置解析、阅读状态同步 |
|
||||
| `RDEPUBReaderController+ExternalLinks.swift` | 外部链接处理(白名单、确认弹窗) |
|
||||
| `RDEPUBReaderController+AttachmentTooltip.swift` | 附件 alt 文本 tooltip 展示 |
|
||||
| `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: RDEpubReaderView?
|
||||
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) -> RDEpubPlainTextBookBuilder
|
||||
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` | 后台任务追踪日志 |
|
||||
| `RDEPUBMetadataParseWorker.swift` | 后台元数据解析 worker |
|
||||
| `RDEPUBMetadataParseCancellationController.swift` | 解析取消控制器 |
|
||||
|
||||
---
|
||||
|
||||
## 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 RDEpubURLReaderController
|
||||
|
||||
**文件:** `RDEpubURLReaderController.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 生成
|
||||
│
|
||||
└── RDEpubReaderView.transitionToPage() → 显示页面
|
||||
|
||||
用户翻页
|
||||
│
|
||||
▼
|
||||
RDEpubReaderView.currentPage 变化
|
||||
│
|
||||
├── RDEPUBReaderLocationCoordinator.recordPageChangeIfNeeded()
|
||||
│ └── persistence.saveLocation()
|
||||
│
|
||||
├── RDEPUBChapterWindowCoordinator 检查是否需要加载新章节
|
||||
│
|
||||
└── RDEPUBReaderChromeCoordinator.updateReaderChrome()
|
||||
```
|
||||
265
Doc/FeatureSolution/ReflowableEPUB_WXReadRenderer_Design.md
Normal file
265
Doc/FeatureSolution/ReflowableEPUB_WXReadRenderer_Design.md
Normal file
@ -0,0 +1,265 @@
|
||||
# Reflowable EPUB 使用 WXRead 风格原生渲染:详细设计
|
||||
|
||||
> 文档目的:讨论并固化“将 ReadViewSDK 的 reflowable EPUB 渲染/排版/分页改为参考 Doc/WXRead 的读书(WXRead)原生渲染方式”的可落地设计,供后续开发与回归使用。
|
||||
> 版本:v0(设计草案)
|
||||
> 日期:2026-05-21
|
||||
|
||||
## 0. 背景与结论(先说人话)
|
||||
|
||||
ReadViewSDK 当前对 EPUB 有三类渲染路径:
|
||||
|
||||
- `RDEPUBReadingProfile.webFixedLayout`:Fixed Layout EPUB → `WKWebView`(保持不变)
|
||||
- `RDEPUBReadingProfile.webInteractive`:交互式 EPUB(JS/音视频/表单/iframe/外链/bridge)→ `WKWebView`(保持不变)
|
||||
- `RDEPUBReadingProfile.textReflowable`:普通 reflowable EPUB → **当前走 DTCoreText → NSAttributedString → CoreText 分页 → 原生文本视图**(这是我们要“升级成 WXRead 风格”的主战场)
|
||||
|
||||
本次改造的最小可落地方向是:**保留三分流策略不变**,只增强 `.textReflowable` 分支,使其在“CSS 分层、样式一致性、资源解析、分页稳定性”等方面更接近 `Doc/WXRead/analysis/EPUB渲染管线详解.md` 所描述的 WXRead 管线,而不是引入新的 WebView 渲染。
|
||||
|
||||
## 1. 目标 / 非目标
|
||||
|
||||
### 1.1 目标(In Scope)
|
||||
|
||||
- G1:reflowable EPUB 的正文渲染/排版/分页改为“WXRead 风格原生渲染管线”:
|
||||
- XHTML/HTML →(CSS 分层 + 解析 + 后处理)→ `NSAttributedString`
|
||||
- `NSAttributedString` →(CoreText 分页)→ 单页内容
|
||||
- 单页内容 → 原生绘制/展示
|
||||
- G2:保持 Fixed Layout 与交互式 EPUB 的 `WKWebView` 路径不回归。
|
||||
- G3:不破坏 `RDURLReaderController` 打开 `.epub` / `.txt` 的主流程。
|
||||
- G4:Demo 可用于验证:至少 2-3 本典型 reflowable EPUB 在段落/标题/图片/链接等常见内容下可稳定阅读。
|
||||
|
||||
### 1.2 非目标(Out of Scope)
|
||||
|
||||
- N1:Fixed Layout EPUB 切换为原生渲染(明确不做)。
|
||||
- N2:交互式 EPUB 切换为原生渲染(明确不做)。
|
||||
- N3:一次性复刻 WXRead 对 DTCoreText 的所有深度魔改(例如自定义 CSS 属性体系、复杂后处理、分页避断规则等)——本次先按“问题驱动”逐步对齐。
|
||||
|
||||
## 2. 关键事实核验(当前代码真实路径)
|
||||
|
||||
> 纠偏说明:项目初始化时曾把”reflowable EPUB 当前路径”概括为偏 `WKWebView` 的历史性表述。经本次代码核验,当前真实主路径是 `.textReflowable` → `RDEPUBTextBookBuilder` → `RDEPUBDTCoreTextRenderer` → CoreText 分页 → `RDEPUBTextContentView`。本设计以代码事实为准,并默认后续实现都按此理解推进。
|
||||
|
||||
### 2.1 渲染路径分流(已存在)
|
||||
|
||||
- 判定在 `Sources/RDReaderView/EPUBCore/RDEPUBParser+ReadingProfile.swift`:
|
||||
- `metadata.layout == .fixed` → `.webFixedLayout`
|
||||
- `hasInteractiveContent() == true` → `.webInteractive`
|
||||
- 否则 → `.textReflowable`
|
||||
|
||||
### 2.2 `.textReflowable` 当前实现(已存在,且是正确切入点)
|
||||
|
||||
`Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift`:
|
||||
|
||||
- `publication.readingProfile == .textReflowable` 时:
|
||||
- 使用 `RDEPUBTextBookBuilder(renderer: resolvedTextRenderer())`
|
||||
- 默认 renderer 是 `RDEPUBDTCoreTextRenderer`(`#if canImport(DTCoreText)`)
|
||||
- 分页使用 `NSAttributedString.ss_pageRanges(size:)`(`CTFramesetterCreateFrame` + `CTFrameGetVisibleStringRange`)
|
||||
- UI 展示使用 `RDEPUBTextContentView`
|
||||
|
||||
结论:我们不需要“新起一个阅读器”,只要把 `.textReflowable` 的 **渲染(typesetter)层**与部分 **分页策略**升级即可。
|
||||
|
||||
## 3. WXRead 参考模型(我们要对齐的最小子集)
|
||||
|
||||
来自 `Doc/WXRead/analysis/EPUB渲染管线详解.md` 的管线:
|
||||
|
||||
1) `WREpubParser`:解析 EPUB 结构(spine、manifest、resourceMap)
|
||||
2) `WREpubTypesetter`:XHTML → `NSAttributedString`(CSS 级联 + HTML 解析 + 后处理)
|
||||
3) `WRCoreTextLayouter`:`NSAttributedString` → 分页布局(`CTTypesetter` + 分页算法)
|
||||
4) `WRCoreTextLayoutFrame`:单页 layout frame
|
||||
5) `WRPageView`:绘制到屏幕
|
||||
|
||||
本次设计对齐重点(最小集合):
|
||||
|
||||
- A:**CSS 分层与合成**(default/replace/dark/epub/user)并注入到渲染输入
|
||||
- B:**资源解析**(图片/CSS 的相对路径 baseURL)与稳定性保障
|
||||
- C:在现有分页基础上逐步迭代(先可用,后对齐“避免断页”等高级策略)
|
||||
|
||||
### 3.1 第一阶段实施假设(必须遵守)
|
||||
|
||||
- H1:**第一期只对齐“管线形态”和“CSS 分层策略”**,即把现有 `.textReflowable` renderer 增强为更接近 WXRead 的 typesetter 输入与样式组织方式。
|
||||
- H2:**第一期不实现 WXRead 对 DTCoreText 的深度魔改**,包括但不限于自定义 CSS 属性体系、复杂附件布局规则、完整的 `WRCoreTextLayouter` / `WRCoreTextLayoutFrame` 等价分页器。
|
||||
- H3:当开发过程中遇到图片断页、复杂样式缺失、附件布局异常等问题时,默认先作为“第二阶段问题清单”记录;只有在它阻塞 `REND-01` / `STAB-02` 的最小验收时,才允许做局部补丁,而不是扩展为全面重写分页引擎。
|
||||
|
||||
## 4. 是否能直接使用 Doc/WXRead 中的 JS/CSS?
|
||||
|
||||
结论:**不建议、也不应该直接把“来自读书 App bundle 的私有 JS/CSS”拷贝进 SDK 作为产品代码**;但可以按以下原则“选择性使用”:
|
||||
|
||||
### 4.1 可以使用的情况(需满足其一)
|
||||
|
||||
- 文件本身带有明确开源许可证声明,且我们按许可证要求引入(保留 license、署名、NOTICE 等),并建议从官方 upstream 获取:
|
||||
- 例如 `Doc/WXRead/resources/js/rangy-core.js` 明确标注 MIT
|
||||
- 例如 `Doc/WXRead/resources/js/Readability.js` 明确标注 Apache-2.0
|
||||
|
||||
> 建议:即便文件里有 license 头,也优先从其原始开源仓库拉取对应版本,而不是从逆向提取的副本直接入库,以降低合规风险。
|
||||
|
||||
### 4.2 不建议/不能直接使用的情况
|
||||
|
||||
- 无明确许可证头、看起来是读书私有逻辑/样式(例如 `weread-highlighter.js`、`MediaPlatform.js`、`replace.css` 等):默认视为私有作品,不应直接拷贝使用。
|
||||
- 即便是“Safari 默认样式”类文件(例如 `default.css` 的注释提到 Safari),也不建议直接照搬;我们可以根据需求写一份“SDK 自己的 default.css / replace.css”,只实现必要规则。
|
||||
|
||||
### 4.3 对本次需求的实际影响
|
||||
|
||||
本次 reflowable EPUB 走原生渲染,不依赖 WebView,因此 **JS 不是本次必需**。
|
||||
CSS 方面我们需要的是“分层策略”和一小部分通用排版规则,可在 SDK 内重写为“WXRead 风格的默认样式集合”。
|
||||
|
||||
## 5. 详细设计(核心)
|
||||
|
||||
### 5.1 总体架构:在现有 `.textReflowable` 上增量替换 renderer
|
||||
|
||||
新增一个 renderer(实现 `RDEPUBTextRenderer`):
|
||||
|
||||
- `RDEPUBWXReadTextRenderer`(新)
|
||||
- 输入:`html: String`, `baseURL: URL?`, `style: RDEPUBTextRenderStyle`
|
||||
- 输出:`RDEPUBRenderedChapterContent`(`NSAttributedString` + `fragmentOffsets`)
|
||||
- 内部职责:
|
||||
1) 读取/生成 CSS 各层(default/replace/dark/user)
|
||||
2) 与 EPUB 自带 CSS 共同作用(通过 HTML 注入 + baseURL)
|
||||
3) 调用 DTCoreText builder 生成 attributedString
|
||||
4) 做最小后处理(段落间距/字体/颜色标准化、fragment marker 提取等)
|
||||
|
||||
切换点:
|
||||
|
||||
- 在 `RDEPUBReaderController.resolvedTextRenderer()`(或其配置位置)增加策略:当开关启用时选择 `RDEPUBWXReadTextRenderer()`,否则沿用 `RDEPUBDTCoreTextRenderer()`。
|
||||
- 建议默认先提供“实验开关”(仅 Demo / debug 可见),降低回归风险。
|
||||
|
||||
### 5.2 CSS 分层策略(WXRead 风格)
|
||||
|
||||
我们在 SDK 内实现与 `Doc/WXRead/analysis/EPUB渲染管线详解.md` 一致的分层概念,但不直接照搬其私有样式文件:
|
||||
|
||||
- Layer 1:`default.css`(SDK 自己维护的基础排版规则)
|
||||
- Layer 2:`replace.css`(SDK 自己维护的替换/增强规则:标题、代码块、图片最大宽度等)
|
||||
- Layer 3:`dark.css`(暗色主题覆盖,仅在暗色主题启用)
|
||||
- Layer 4:EPUB 嵌入 CSS(书籍自带,DTCoreText 解析 HTML 时自然生效;相对路径靠 baseURL)
|
||||
- Layer 5:用户设置 CSS(由 `RDEPUBTextRenderStyle` 动态生成:字体、字号、行高、背景色、文字色等)
|
||||
|
||||
实现方式(建议):
|
||||
|
||||
1) 新增 `RDEPUBWXReadStyleSheetBuilder`:
|
||||
- `func makeDefaultCSS() -> String`
|
||||
- `func makeReplaceCSS() -> String`
|
||||
- `func makeDarkCSS(theme: RDEPUBTheme) -> String?`
|
||||
- `func makeUserCSS(style: RDEPUBTextRenderStyle, theme: RDEPUBTheme) -> String`
|
||||
- `func composeCSS(...) -> String`(按层拼接,后层覆盖前层)
|
||||
|
||||
2) 在 renderer 中将合成后的 CSS 注入到 HTML:
|
||||
- 若存在 `<head>`:插入 `<style id="rd-wxread-layered-style">...`
|
||||
- 若不存在:在 `<html>` 后插入 `<head>...`
|
||||
- 保持原 HTML 内容尽量不改动(外部脚本/交互内容已被 readingProfile 判定剔除到 web 分支)
|
||||
|
||||
### 5.3 baseURL 与资源解析
|
||||
|
||||
目前 `RDEPUBTextBookBuilder` 传入:
|
||||
|
||||
- `baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent()`
|
||||
|
||||
原则:
|
||||
|
||||
- baseURL 必须是“章节文件所在目录”,以确保:
|
||||
- `<img src="...">` 相对路径可解析
|
||||
- `<link href="...">` CSS 相对路径可解析(如果 DTCoreText 支持)
|
||||
|
||||
待核验点(实现时做小实验):
|
||||
|
||||
- DTCoreText 对 `<link rel="stylesheet">` 的解析策略是否完整;若不完整:
|
||||
- 兜底策略:在渲染前解析 HTML 中的 `<link rel="stylesheet">`,读取 CSS 内容并内联到 `<style>`(仅限 `file://` 且位于 EPUB 解压目录内)。
|
||||
|
||||
### 5.4 分页策略(阶段性)
|
||||
|
||||
现状:
|
||||
|
||||
- `NSAttributedString.ss_pageRanges(size:)` 使用 `CTFramesetterCreateFrame` + `CTFrameGetVisibleStringRange`,属于“最小可用分页”。
|
||||
|
||||
WXRead 的更高阶策略(参考 `Doc/WXRead/analysis/DTCoreText自定义修改分析.md`)可能包含:
|
||||
|
||||
- 避免孤行/断页
|
||||
- 图片/附件的分页边界处理
|
||||
- 特定块元素的分页规则
|
||||
|
||||
本次建议:
|
||||
|
||||
- Phase 2:先保持现有分页算法,只要渲染输入(CSS 分层 + 后处理)到位,就能显著改善一致性。
|
||||
- Phase 3:针对真实书籍出现的问题,逐条补齐分页规则(问题驱动),避免一开始就引入复杂分页器导致风险扩大。
|
||||
|
||||
> 范围约束:如果某个分页问题需要引入“新的复杂分页器”或大规模模拟 `WRCoreTextLayouter` / `WRCoreTextLayoutFrame`,应先暂停并回到方案讨论,不默认并入第一期实现。
|
||||
|
||||
### 5.5 与现有高亮/搜索/位置映射的兼容
|
||||
|
||||
当前 `.textReflowable` 路径:
|
||||
|
||||
- 高亮/搜索依赖 `RDEPUBTextBook` 的 `fragmentOffsets` 与 `location/progression` 映射(见 `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift` 与 `RDEPUBTextBook` 的 `pageNumber(for:)` / `location(forPageNumber:)`)。
|
||||
|
||||
兼容策略:
|
||||
|
||||
- 继续使用现有的 fragment marker 注入与提取:
|
||||
- `RDEPUBTextRendererSupport.injectFragmentMarkers(...)`
|
||||
- `RDEPUBTextRendererSupport.extractFragmentOffsets(...)`
|
||||
- renderer 只改变“CSS 注入与 DTCoreText options/后处理”,不改变 marker 体系与 `RDEPUBTextBook` 数据结构,以降低 UI 层回归。
|
||||
|
||||
## 6. 开发落点(文件 / 类型 / 目录)
|
||||
|
||||
### 6.1 新增文件(建议位置)
|
||||
|
||||
放在 `Sources/RDReaderView/EPUBTextRendering/`(因为它是 textReflowable 的渲染与分页域):
|
||||
|
||||
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBWXReadTextRenderer.swift`(新)
|
||||
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBWXReadStyleSheetBuilder.swift`(新)
|
||||
- (可选)`Sources/RDReaderView/EPUBTextRendering/RDEPUBWXReadHTMLPreprocessor.swift`(新:仅当需要内联 `<link>` CSS 时)
|
||||
|
||||
资源文件(建议):
|
||||
|
||||
- `Sources/RDReaderView/Resources/WXRead/default.css`(新,SDK 自己写)
|
||||
- `Sources/RDReaderView/Resources/WXRead/replace.css`(新,SDK 自己写)
|
||||
- `Sources/RDReaderView/Resources/WXRead/dark.css`(新,SDK 自己写)
|
||||
|
||||
> 注意:这些资源需被 `RDReaderView.podspec` 的 resource bundle 覆盖到(当前资源 bundle 为 `RDReaderViewAssets`,来源 `Sources/RDReaderView/Resources/**`)。
|
||||
|
||||
### 6.2 改动文件(建议最小改动)
|
||||
|
||||
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift`
|
||||
- 在 `resolvedTextRenderer()` 或相邻配置处增加选择逻辑(开关 / 版本策略)
|
||||
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift`
|
||||
- 如需内联 link CSS:在获取 `rawHTML` 后做预处理(保持接口不变)
|
||||
|
||||
## 7. 验收标准与验证方式(对应 REQUIREMENTS)
|
||||
|
||||
### 对应 REND-01 / REND-03
|
||||
|
||||
- 在 Demo 中打开 reflowable EPUB:
|
||||
- 正文渲染不依赖 `WKWebView`(可通过日志/断点确认不走 `RDEPUBWebContentView`)
|
||||
- CSS 分层生效:默认样式可控、主题/字号/行高变化可控
|
||||
- 图片/链接至少可正确显示/响应(链接行为按现有 text 内容策略)
|
||||
|
||||
### 对应 REND-02
|
||||
|
||||
- Fixed Layout EPUB:仍走 `.webFixedLayout` → `WKWebView` 路径
|
||||
- 交互式 EPUB:仍走 `.webInteractive` → `WKWebView` 路径(桥接与外链不回归)
|
||||
|
||||
### 对应 STAB-01 / STAB-02
|
||||
|
||||
- `RDURLReaderController` 打开 `.epub` / `.txt` 主流程不回归
|
||||
- 至少使用以下 3 类 reflowable EPUB 样本进行回归:
|
||||
- 样本 A:纯文本/小说类章节为主,验证基础段落、标题、分页与阅读位置恢复
|
||||
- 样本 B:包含内嵌图片与多段样式的章节,验证图片显示、图片前后分页、基础 CSS 生效
|
||||
- 样本 C:包含外链与多个 CSS 文件引用的章节,验证 baseURL、样式解析与链接呈现稳定性
|
||||
- 对以上样本的共同要求:不崩溃、不白屏、不无限加载;分页/翻页可用
|
||||
|
||||
## 8. 风险清单与降级策略
|
||||
|
||||
### 8.1 主要风险
|
||||
|
||||
- R1:DTCoreText 对 EPUB 内嵌 CSS / `<link>` CSS 支持不足,导致样式缺失
|
||||
- R2:分页质量不足(断页不美观、图片分页异常)
|
||||
- R3:`hasInteractiveContent()` 判定过宽,导致大量书被误判为 `.webInteractive`,覆盖率不足
|
||||
|
||||
### 8.2 降级/灰度(建议)
|
||||
|
||||
- D1:增加一个“渲染引擎开关”(仅 debug 或 demo 可配置),可在出现严重问题时快速回退到现有 `RDEPUBDTCoreTextRenderer`
|
||||
- D2:对 `hasInteractiveContent()` 的判定提供可配置白名单/黑名单(例如按 manifest properties、按 tag 命中级别)
|
||||
|
||||
## 9. 下一步(交接到开发)
|
||||
|
||||
建议按 `Doc/ARCHITECTURE-CONTEXT.md` 中的架构决策逐步推进:
|
||||
|
||||
- 先基于 `Doc/WXRead/analysis/*` 提炼“我们要实现的 CSS 分层最小集合”
|
||||
- 再在 `.textReflowable` renderer 里实现“分层 CSS 注入 + baseURL/资源解析兜底”
|
||||
- 最后用 Demo 书籍做回归,按问题驱动补齐分页/样式细节
|
||||
|
||||
---
|
||||
*Last updated: 2026-05-21 after discuss-feature-solution*
|
||||
365
Doc/FeatureSolution/高亮选区复刻WXRead实现方案.md
Normal file
365
Doc/FeatureSolution/高亮选区复刻WXRead实现方案.md
Normal file
@ -0,0 +1,365 @@
|
||||
# 将高亮选区实现 1:1 复刻为 WXRead 架构
|
||||
|
||||
## Context
|
||||
|
||||
当前 ReadViewSDK 的高亮选区实现与 WXRead 存在根本性架构差异。需要将选区系统从"UITextView 透明代理 + 独立 overlay 层"迁移到 WXRead 的"自定义手势 + CoreText 直接命中测试 + 统一 drawRect 绘制"架构。
|
||||
|
||||
## 核心差异对比
|
||||
|
||||
| 维度 | 当前 ReadViewSDK | WXRead |
|
||||
|------|-----------------|--------|
|
||||
| 选择触发 | UITextView 原生长按(透明文本) | 自定义 long-press(0.5s) + pan 手势 |
|
||||
| 命中测试 | DTCoreText `stringIndex(forPosition:)` | `CTLineGetStringIndexForPosition` + 坐标翻转 |
|
||||
| 选区绘制 | 独立 `RDEPUBSelectionOverlayView` overlay 层 | 同一 `drawRect:` 内绘制(文字+高亮+选区) |
|
||||
| 高亮绘制 | overlay 层计算 rect 后 CG 填充 | `com.weread.highlight` 自定义属性注入 NSAttributedString,在 `drawInContext:` 中读取绘制 |
|
||||
| 菜单系统 | 自定义 `selectionActionBar` UIStackView | `UIMenuController` + 自定义 items |
|
||||
| 手势模型 | 无 pan 手势(UITextView 自带拖拽) | long-press 启动 + pan 扩展,`isSelecting` 控制 pan 启停 |
|
||||
| 视图层级 | 3 层 overlay(background + text + foreground) | 单一 WRPageView 统一绘制 |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: 移除 UITextView,改用自定义手势 + CoreText 命中测试
|
||||
|
||||
### 1.1 修改 `RDEPUBPageInteractionController.swift`
|
||||
|
||||
当前已正确封装 DTCoreText 的 `stringIndex(forPosition:)` 和 `offset(forStringIndex:)`,算法与 WXRead 一致。
|
||||
|
||||
**新增方法:**
|
||||
- `characterIndexForViewPoint(at viewPoint: CGPoint, in view: UIView)` — 将 UIKit 坐标转为相对于 content view 的坐标后调用 `characterIndex(at:)`,对应 WXRead 的 `stringIndexForPoint:` + `WRSFlipPointForCoreText`
|
||||
|
||||
> 注意:DTCoreText 已在内部处理了 UIKit↔CoreText 坐标翻转(`line.baselineOrigin` 是 UIKit 坐标),所以不需要手动翻转 Y 轴。但 WXRead 的自定义 DTCoreText 需要手动翻转。当前项目用的是原版 DTCoreText pod,行为已正确。
|
||||
|
||||
### 1.2 重构 `RDEPUBTextSelectionController.swift`
|
||||
|
||||
**当前状态:** 遵循 `UITextViewDelegate`,通过 `textViewDidChangeSelection` 接收选区变化。`handleLongPress` 方法存在但未被任何手势调用。
|
||||
|
||||
**改为:**
|
||||
- 移除 `UITextViewDelegate` 遵循
|
||||
- 移除 `textViewDidChangeSelection(_:)` 和 `textViewDidChangeSelection(_:, page:)`
|
||||
- 新增状态属性:`selectionStartIndex: Int = NSNotFound`、`selectionEndIndex: Int = NSNotFound`、`isSelecting: Bool = false`
|
||||
- 重构 `handleLongPress`:
|
||||
- `.began`:调用 `characterIndex(at:)` 设置 `selectionStartIndex = selectionEndIndex = index`,设 `isSelecting = true`
|
||||
- `.ended`:设 `isSelectionFromInteraction = false`(保留,供后续扩展)
|
||||
- 新增 `handlePan(_ gesture:, page:, renderView:, interactionController:)`:
|
||||
- `.changed`:计算字符索引,更新 `selectionEndIndex`,计算 range = `(min, max - min)`,计算 rects,更新 renderView
|
||||
- 移除 `clearSelection` 的 `textView:` 参数
|
||||
- `makeSelection(from:, page:)` 保持不变(已正确基于绝对偏移构建 `RDEPUBSelection`)
|
||||
|
||||
### 1.3 重构 `RDEPUBTextContentView.swift` — 移除 UITextView
|
||||
|
||||
**删除:**
|
||||
- `textView: RDEPUBSelectableTextView` 属性及其初始化
|
||||
- `selectionProxyContent(from:)` 方法
|
||||
- `textView.delegate = selectionController` 等 textView 配置代码
|
||||
- `textView.frame = ...` 在 `layoutSubviews` 中的设置
|
||||
- `configure(page:...)` 中所有 `textView.attributedText = ...`、`textView.selectedRange = ...`、`textView.isHidden = ...` 赋值
|
||||
|
||||
**新增手势识别器(对齐 WXRead 的 WRPageView):**
|
||||
```swift
|
||||
private let longPressGR = UILongPressGestureRecognizer(target: ..., action: #selector(handleLongPress))
|
||||
private let panGR = UIPanGestureRecognizer(target: ..., action: #selector(handlePan))
|
||||
private let tapGR = UITapGestureRecognizer(target: ..., action: #selector(handleTap))
|
||||
```
|
||||
- `longPressGR.minimumPressDuration = 0.5`(与 WXRead 一致)
|
||||
- `panGR.isEnabled = false`(初始禁用,long-press began 时启用)
|
||||
- `tapGR.require(toFail: longPressGR)`(与 WXRead 一致)
|
||||
- 三个手势都添加到 contentView 自身
|
||||
|
||||
**手势响应:**
|
||||
- `handleLongPress`:转发给 `selectionController.handleLongPress`,启用 `panGR`
|
||||
- `handlePan`:转发给 `selectionController.handlePan`
|
||||
- `handleTap`:如果 `selectionController.isSelecting` 则 `clearSelection()`,否则转发给 delegate 做工具栏切换
|
||||
|
||||
**菜单改为 UIMenuController(对齐 WXRead):**
|
||||
- 删除 `selectionActionBar: UIStackView` 及相关方法(`showSelectionActionBarIfNeeded`、`hideSelectionActionBar`、`updateSelectionActionBarFrame`、`selectionMenuButton`)
|
||||
- 在 `selectionController.onSelectionChanged` 回调中,当 selection 非 nil 时调用 `showSelectionMenu(in:anchorRect:)`
|
||||
- `RDEPUBTextContentView` 设为 `canBecomeFirstResponder = true`,override `canPerformAction` 仅允许三个自定义 selector
|
||||
- 使用 `UIMenuController.shared` 配置 "拷贝"/"高亮"/"批注" 三个 `UIMenuItem`
|
||||
|
||||
**调整 `clearSelection()`:**
|
||||
```swift
|
||||
func clearSelection() {
|
||||
currentSelection = nil
|
||||
menuSelection = nil
|
||||
panGR.isEnabled = false
|
||||
selectionController.clearSelection(overlayView: overlayView, backgroundOverlayView: backgroundOverlayView)
|
||||
UIMenuController.shared.setMenuVisible(false, animated: true)
|
||||
}
|
||||
```
|
||||
|
||||
### 1.4 删除 `RDEPUBSelectableTextView.swift`
|
||||
|
||||
该文件的功能(屏蔽系统菜单、暴露自定义 action)已被 UIMenuController 方案替代,直接删除。
|
||||
|
||||
### 1.5 更新 `RDEPUBReaderController+ContentDelegates.swift`
|
||||
|
||||
`textContentView(_:, didRequestSelectionAction:, selection:)` 中的 `contentView.clearSelection()` 调用无需改动,新的 `clearSelection()` 签名兼容。
|
||||
|
||||
### Phase 1 验证
|
||||
- UI 测试 `ReaderAnnotationTests.testSelectionMenuCreatesHighlight` 必须通过
|
||||
- 手动验证:长按选词 → 弹出 UIMenuController → 点击"高亮" → 高亮创建成功
|
||||
- 手动验证:拖拽扩展选区 → 蓝色选区矩形正确绘制
|
||||
- 手动验证:单击空白处 → 选区清除
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: 统一绘制循环 — 高亮/选区在 draw(_:) 中绘制
|
||||
|
||||
### 2.1 扩展 `RDEPUBTextPageRenderView.swift`
|
||||
|
||||
**当前状态:** 仅调用 `layoutFrame.draw(in: context, options:)` 绘制文字。
|
||||
|
||||
**新增属性:**
|
||||
```swift
|
||||
var highlightRanges: [(range: NSRange, color: UIColor)] = [] { didSet { setNeedsDisplay() } }
|
||||
var underlineRanges: [(range: NSRange, color: UIColor, style: Int)] = [] { didSet { setNeedsDisplay() } }
|
||||
var selectionRects: [CGRect] = [] { didSet { setNeedsDisplay() } }
|
||||
var selectionColor: UIColor = UIColor(red: 70/255, green: 140/255, blue: 1, alpha: 0.24)
|
||||
```
|
||||
|
||||
**扩展 `draw(_:)`:**
|
||||
```swift
|
||||
override func draw(_ rect: CGRect) {
|
||||
guard let context = UIGraphicsGetCurrentContext(), let layoutFrame else { return }
|
||||
context.saveGState()
|
||||
|
||||
// 1. 绘制高亮背景(在文字下方,匹配 WXRead 的 drawHighlightsInContext:)
|
||||
drawHighlights(in: context, layoutFrame: layoutFrame)
|
||||
|
||||
// 2. 绘制文字
|
||||
layoutFrame.draw(in: context, options: drawOptions)
|
||||
|
||||
// 3. 绘制选区(在文字上方,匹配 WXRead 的 _drawSelectionInContext:)
|
||||
drawSelection(in: context)
|
||||
|
||||
context.restoreGState()
|
||||
}
|
||||
```
|
||||
|
||||
**`drawHighlights` 算法(对齐 WXRead `WRCoreTextLayoutFrame.drawHighlightsInContext:`):**
|
||||
```swift
|
||||
private func drawHighlights(in context: CGContext, layoutFrame: DTCoreTextLayoutFrame) {
|
||||
for (range, color) in highlightRanges {
|
||||
let lines = layoutFrame.lines as! [DTCoreTextLayoutLine]
|
||||
for line in lines {
|
||||
let overlap = NSIntersectionRange(range, line.stringRange)
|
||||
guard overlap.length > 0 else { continue }
|
||||
let startX = line.offset(forStringIndex: overlap.location)
|
||||
let endX = line.offset(forStringIndex: overlap.location + overlap.length)
|
||||
let rect = CGRect(
|
||||
x: line.baselineOrigin.x + startX,
|
||||
y: line.baselineOrigin.y - line.ascent,
|
||||
width: endX - startX,
|
||||
height: line.ascent + line.descent
|
||||
)
|
||||
color.withAlphaComponent(0.35).setFill() // WXRead 使用 35% alpha
|
||||
context.fill(rect)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> 说明:WXRead 使用 `[color colorWithAlphaComponent:0.3]`,WRPageHighlight 的预设色本身已是 35% alpha,最终效果等同。当前项目使用 0.45 alpha,需调整为 0.35 以完全对齐。
|
||||
|
||||
**`drawSelection` 算法(对齐 WXRead `_drawSelectionInContext:`):**
|
||||
```swift
|
||||
private func drawSelection(in context: CGContext) {
|
||||
guard !selectionRects.isEmpty else { return }
|
||||
selectionColor.setFill()
|
||||
for rect in selectionRects {
|
||||
context.fill(rect)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 简化 `RDEPUBTextContentView.swift` 视图层级
|
||||
|
||||
**删除/保留:**
|
||||
- 删除 `backgroundOverlayView` 属性(高亮背景现在由 renderView 在文字下方绘制)
|
||||
- 保留 `overlayView`(用于非 DTCoreText 回退路径和搜索高亮)
|
||||
- `configure(page:...)` 中,将 highlight 数据传给 `coreTextContentView` 而非 overlayView:
|
||||
|
||||
```swift
|
||||
// DTCoreText 路径
|
||||
coreTextContentView.highlightRanges = highlights.compactMap { highlight -> (NSRange, UIColor)? in
|
||||
guard let rangeInfo = highlight.rangeInfo,
|
||||
let info = RDEPUBTextOffsetRangeInfo.decode(from: rangeInfo) else { return nil }
|
||||
let absoluteRange = info.nsRange
|
||||
let overlap = NSIntersectionRange(absoluteRange, pageAbsoluteRange)
|
||||
guard overlap.length > 0 else { return nil }
|
||||
let relativeRange = NSRange(location: overlap.location - page.pageStartOffset, length: overlap.length)
|
||||
return (relativeRange, highlight.uiColor)
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 更新 `RDEPUBTextSelectionController.swift` — 直接更新 renderView
|
||||
|
||||
- `handleLongPress` 和 `handlePan` 现在直接更新 `renderView.selectionRects` 并调用 `renderView.setNeedsDisplay()`
|
||||
- 移除 `overlayView.updateSelection(absoluteRange:, rects:)` 调用
|
||||
|
||||
### Phase 2 验证
|
||||
- 视觉对比:高亮矩形与文字像素对齐
|
||||
- 性能测试:单次 `draw(_:)` 耗时应与之前持平或更快
|
||||
- 回归测试:搜索高亮仍正常显示
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: 高亮属性注入 NSAttributedString(对齐 WXRead `com.weread.highlight`)
|
||||
|
||||
### 3.1 定义自定义属性常量
|
||||
|
||||
```swift
|
||||
// 对齐 WXRead 的 kWRHighlightAttributeName / kWRUnderlineAttributeName
|
||||
let kRDEPUBHighlightAttributeName = NSAttributedString.Key("com.rdreader.highlight")
|
||||
let kRDEPUBUnderlineAttributeName = NSAttributedString.Key("com.rdreader.underline")
|
||||
```
|
||||
|
||||
### 3.2 新增 `RDEPUBChapterData.applyHighlights(to:page:highlights:)`
|
||||
|
||||
对齐 WXRead 的 `WRChapterData.addHighlightInRange:key:itemId:color:`:
|
||||
|
||||
```swift
|
||||
func applyHighlights(
|
||||
to content: NSMutableAttributedString,
|
||||
page: RDEPUBTextPage,
|
||||
highlights: [RDEPUBHighlight]
|
||||
) {
|
||||
for highlight in highlights {
|
||||
guard let rangeInfo = highlight.rangeInfo,
|
||||
let info = RDEPUBTextOffsetRangeInfo.decode(from: rangeInfo) else { continue }
|
||||
let absoluteRange = info.nsRange
|
||||
let pageRange = NSRange(location: page.pageStartOffset, length: page.pageEndOffset - page.pageStartOffset)
|
||||
let overlap = NSIntersectionRange(absoluteRange, pageRange)
|
||||
guard overlap.length > 0 else { continue }
|
||||
let relativeRange = NSRange(location: overlap.location - page.pageStartOffset, length: overlap.length)
|
||||
|
||||
switch highlight.style {
|
||||
case .highlight:
|
||||
content.addAttribute(kRDEPUBHighlightAttributeName, value: highlight.uiColor, range: relativeRange)
|
||||
case .underline:
|
||||
content.addAttribute(kRDEPUBUnderlineAttributeName, value: highlight.uiColor, range: relativeRange)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 `RDEPUBTextPageRenderView.draw(_:)` 从属性读取高亮
|
||||
|
||||
替代 Phase 2 的 `highlightRanges` 属性方案,改为在 `draw(_:)` 中枚举 attributed string 的自定义属性:
|
||||
|
||||
```swift
|
||||
private func drawHighlightsFromAttributes(in context: CGContext, attributedString: NSAttributedString) {
|
||||
let fullRange = NSRange(location: 0, length: attributedString.length)
|
||||
attributedString.enumerateAttribute(kRDEPUBHighlightAttributeName, in: fullRange) { value, range, _ in
|
||||
guard let color = value as? UIColor else { return }
|
||||
let rects = computeRects(for: range) // 复用 line 迭代 + CTLineGetOffsetForStringIndex
|
||||
color.withAlphaComponent(0.35).setFill()
|
||||
for rect in rects { context.fill(rect) }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 更新 `RDEPUBTextContentView.configure(page:...)`
|
||||
|
||||
```swift
|
||||
// 在传给 renderView 之前注入高亮属性
|
||||
let displayContent = darkImageAdjustedContentIfNeeded(...)
|
||||
chapterData.applyHighlights(to: displayContent, page: page, highlights: highlights)
|
||||
coreTextContentView.attributedDisplayContent = displayContent // 新增属性
|
||||
```
|
||||
|
||||
### Phase 3 验证
|
||||
- 高亮在翻页后仍正确显示(属性嵌入 attributed string,不依赖外部状态)
|
||||
- 高亮颜色、alpha、rect 与 WXRead 截图一致
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: 手势模型对齐 — long-press + pan + isSelecting
|
||||
|
||||
### 4.1 手势冲突处理
|
||||
|
||||
**风险:** pan 手势(选区扩展)可能与 RDReaderView 的翻页手势冲突。
|
||||
|
||||
**解决方案(对齐 WXRead):**
|
||||
- `panGR` 初始 `isEnabled = false`,仅在 `isSelecting = true` 时启用
|
||||
- `clearSelection()` 时禁用 `panGR`
|
||||
- 新增 delegate 方法通知父视图:
|
||||
```swift
|
||||
func textContentViewDidBeginSelection(_ contentView: RDEPUBTextContentView)
|
||||
func textContentViewDidEndSelection(_ contentView: RDEPUBTextContentView)
|
||||
```
|
||||
- `RDReaderView` 在 `didBeginSelection` 时禁用翻页手势,在 `didEndSelection` 时恢复
|
||||
|
||||
### 4.2 WXRead 手势时序对齐
|
||||
|
||||
WXRead 的手势流程:
|
||||
1. long-press `.began` → 设置 `selectionStartIndex = selectionEndIndex = index`,`isSelecting = true`,启用 panGR,`setNeedsDisplay`
|
||||
2. long-press `.ended` → 无额外操作(保留选区)
|
||||
3. pan `.changed` → 更新 `selectionEndIndex`,计算 rects,`setNeedsDisplay`
|
||||
4. single tap → `clearSelection()`,禁用 panGR
|
||||
|
||||
### Phase 4 验证
|
||||
- 长按选词 → 拖拽扩展 → 单击取消,全流程流畅
|
||||
- 无选区时翻页手势正常
|
||||
- 有选区时翻页手势被禁用
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: 菜单系统对齐 + 清理
|
||||
|
||||
### 5.1 UIMenuController 替换 selectionActionBar
|
||||
|
||||
已在 Phase 1 中完成。此阶段仅做清理:
|
||||
- 删除 `RDEPUBSelectableTextView.swift`
|
||||
- 标记 `RDEPUBSelectionOverlayView.swift` 和 `RDEPUBTextPageDecorationView.swift` 为 deprecated(保留给非 DTCoreText 回退路径)
|
||||
|
||||
### 5.2 更新 UI 测试
|
||||
|
||||
`ReaderAnnotationTests` 中查找菜单项的方式需更新:
|
||||
- 当前:`app.buttons["高亮"]`(UIStackView 中的按钮)
|
||||
- 改为:`app.menuItems["高亮"]`(UIMenuController 的菜单项)
|
||||
- 或者:保留 `accessibilityIdentifier` 在 RDEPUBTextContentView 上以便测试定位
|
||||
|
||||
### 5.3 高亮颜色对齐
|
||||
|
||||
WXRead 的 5 种预设色(35% alpha):
|
||||
- Yellow: `(1.0, 0.92, 0.23, 0.35)`
|
||||
- Blue: `(0.26, 0.65, 0.96, 0.35)`
|
||||
- Red: `(0.96, 0.26, 0.26, 0.35)`
|
||||
- Green: `(0.30, 0.85, 0.39, 0.35)`
|
||||
- Purple: `(0.67, 0.33, 0.97, 0.35)`
|
||||
|
||||
当前项目使用 CSS hex 颜色 + 0.45 alpha,需对齐为 WXRead 的 RGBA 值。
|
||||
|
||||
---
|
||||
|
||||
## 文件变更清单
|
||||
|
||||
| 文件 | 操作 | Phase |
|
||||
|------|------|-------|
|
||||
| `RDEPUBPageInteractionController.swift` | 修改:新增 `characterIndexForViewPoint` | 1 |
|
||||
| `RDEPUBTextSelectionController.swift` | 重构:移除 UITextViewDelegate,新增 pan 处理、状态机 | 1 |
|
||||
| `RDEPUBTextContentView.swift` | 重构:移除 textView,新增手势,替换菜单,简化层级 | 1,2,4 |
|
||||
| `RDEPUBSelectableTextView.swift` | **删除** | 1 |
|
||||
| `RDEPUBTextPageRenderView.swift` | 扩展:新增高亮/选区/下划线绘制逻辑 | 2,3 |
|
||||
| `RDEPUBChapterData.swift` | 新增:`applyHighlights(to:page:highlights:)` | 3 |
|
||||
| `RDEPUBReaderController+ContentDelegates.swift` | 小改:适配新 clearSelection 签名 | 1 |
|
||||
| `RDReaderView.swift` | 新增:选区期间禁用翻页手势 | 4 |
|
||||
| `RDEPUBSelectionOverlayView.swift` | 保留(deprecated for DTCoreText path) | 5 |
|
||||
| `RDEPUBTextPageDecorationView.swift` | 保留(deprecated for DTCoreText path) | 5 |
|
||||
| `RDEPUBTextAnnotationOverlay.swift` | 保留(deprecated for DTCoreText path) | 5 |
|
||||
| `ReaderAnnotationTests.swift` | 更新:菜单项查找方式 | 5 |
|
||||
|
||||
## 验证方案
|
||||
|
||||
1. **UI 测试**:`ReaderAnnotationTests` 全部通过
|
||||
2. **手动测试**:
|
||||
- 长按选词 → 蓝色选区高亮显示
|
||||
- 拖拽扩展选区 → 选区跟随手指
|
||||
- 点击"高亮" → 黄色高亮创建成功
|
||||
- 翻页后返回 → 高亮仍存在
|
||||
- 点击"拷贝" → 文本已复制
|
||||
- 点击"批注" → 弹出笔记输入框
|
||||
- 单击空白处 → 选区清除
|
||||
3. **性能测试**:`draw(_:)` 耗时 ≤ 之前(单次绘制 vs 三次绘制)
|
||||
4. **对比验证**:与 WXRead 截图对比高亮颜色、alpha、rect 位置
|
||||
@ -1,154 +0,0 @@
|
||||
# 长章节内存优化 — 代码实施清单
|
||||
|
||||
> 背景:DTCoreText 文本页已采用"整章上下文布局",分页与显示边界一致(不可回退)。现状代价:每次翻页/预加载都整章重拷贝 + 重排版后丢弃,且章节缓存里每页常驻一份子串副本。
|
||||
>
|
||||
> 总原则:保留"整章参与排版",去掉"整章按页复制、按页可变、按页缓存"。不改分页器,不上窗口化。
|
||||
|
||||
---
|
||||
|
||||
## P0:立即可做
|
||||
|
||||
### P0-1 削减每页 `content` 子串常驻副本
|
||||
|
||||
**现状**:分页产物为每页保存整段 `attributedSubstring`,全章加总约等于又一份整章副本,随邻接窗口(radius 1,共 3 章)常驻。
|
||||
|
||||
生成点:
|
||||
|
||||
- `Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLoader.swift:494`(`buildPagesFromRanges`)
|
||||
- `Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookBuilder.swift:294`
|
||||
- `Sources/RDEpubReaderView/EPUBTextRendering/RDEpubPlainTextBookBuilder.swift:78`
|
||||
|
||||
现存全部消费点(已盘点确认,仅 3 处):
|
||||
|
||||
- `RDEPUBTextContentView.swift:562` — 封面检测 `coverImage(from: page.content)`,仅 `pageIndexInChapter == 0` 且 href 含 "cover"
|
||||
- `RDEPUBTextContentView.swift:641` — `normalizedPageContent`,仅非 DTCoreText 的 `#else` 回退路径调用
|
||||
- `RDEPUBTextBookBuilder.swift:93` — 构建期 debug 日志 preview
|
||||
|
||||
**任务**:
|
||||
|
||||
- [x] 将 `RDEPUBTextPage.content` 改为按需构造:已改为基于 `chapterContent` + `contentRange` 的计算属性(带范围钳制),公开读取 API 不变
|
||||
- [x] 三个构造点(`RDEPUBChapterLoader` / `RDEPUBTextBookBuilder` / `RDEpubPlainTextBookBuilder`)不再生成每页子串;消费点经计算属性透明按需构造
|
||||
- [x] `Equatable` 确认:`content` 为派生值,由 `contentRange` + `chapterContent` 判等覆盖,语义不变
|
||||
|
||||
**API 兼容性注意**:
|
||||
|
||||
- `RDEPUBTextPage` 当前是 `public struct`,`content` 也是公开存储属性;如果 SDK 已存在外部接入方直接读取 `page.content`,则不应直接移除该字段
|
||||
- 优先方案是保留 `content` 对外访问语义,但将其改为按需构造的计算属性或受控访问器,避免对外 API 断裂
|
||||
- 若确认当前版本尚无稳定外部依赖,才可以评估把 `content` 从存储属性调整为内部实现细节
|
||||
- 在实施前应补做一次全仓库与接入侧检索,确认是否有外部调用依赖 `page.content` 的“可变存储”特性
|
||||
|
||||
**验收**:打开 3 章窗口后常驻内存下降约"一整章文本规模 × 窗口章数";封面页显示正常;非 DTCoreText 回退路径回归通过;`--demo-pagination-validate` 命中数不回升。
|
||||
|
||||
### P0-2 增加内存打点
|
||||
|
||||
- [x] 已新增 `RDEPUBMemoryProbe`(`--demo-memory-probe` 启动参数开启,输出 phys_footprint);挂载点:`RDEPUBChapterRuntimeStore.insertChapter`、`pageNum` 回调每 20 次翻页、`invalidateAllForSettingsChange`、`viewWillTransition` 转屏完成
|
||||
- [x] 用 `--demo-memory-probe` 运行 Demo 即可采集对比基线
|
||||
|
||||
### P0-3 保持现有缓存限制,防止错误复用
|
||||
|
||||
- [x] `shouldAvoidReaderPageCaching`(`RDEPUBTextContentView.swift:319`)保持现状,已补注释说明放开的前置条件是 P1-1 落地
|
||||
- [x] 本阶段未改 `RDEpubReaderPreloadController` 行为
|
||||
|
||||
---
|
||||
|
||||
## P1:第二波(P1-1 与 P1-2 必须同批落地)
|
||||
|
||||
### P1-1 章节级共享 displayContent
|
||||
|
||||
**现状**:`RDEPUBTextContentView.configure` 每次创建整章可变副本(`RDEPUBTextContentView.swift:443`)并按页写入三类属性:页范围主题色(453)、页范围暗黑图附件替换(448)、按页裁剪的高亮属性(459 `applyHighlightsToContent`)。
|
||||
|
||||
**约束**:共享字符串一旦被任一页的 `DTCoreTextLayouter`/framesetter 持有,就不得再修改——所有属性必须在共享内容构建时一次性注入,或转为 overlay(见 P1-2)。
|
||||
|
||||
**任务**:
|
||||
|
||||
- [x] 已新建 `RDEPUBChapterDisplayContentCache`(LRU 容量 2,主线程限定,`RDEPUBReaderController` 持有):`content` + `layouter` + `signature`
|
||||
- [x] 构建共享内容时一次性注入:全章范围主题色;高亮/下划线因 P1-2 转 overlay,不进共享内容也不进签名
|
||||
- [x] 暗黑图策略选 a:构建时全章一次性替换,复用 `RDEPUBDarkImageAdjuster` 的 NSCache
|
||||
- [x] `RDEPUBTextContentView.configure` 已改为接收 `displayCache` 参数并引用共享 entry,整章拷贝已删除
|
||||
- [x] 签名 = 章节内容对象标识 + 长度 + 主题双色 + 暗黑图配置;设置/主题变化经签名自动失效重建(高亮变化不触发重建,仅重建 overlay 装饰)
|
||||
- [x] 控制器在 `didReceiveMemoryWarning` 清空 display cache;设置变更重建 chapterContent 实例后由签名兜底换代
|
||||
|
||||
**验收**:连续翻页不再出现整章拷贝(打点确认);翻页延迟下降;高亮显示、高亮点击菜单、underline、搜索 currentMatch、附件点击、选区拖拽全部回归通过。
|
||||
|
||||
> 2026-07-08 回归记录:高亮/批注/选区/翻页/设置 13 项 UI 用例全过;SearchTests 10 项失败,但已在会话前提交 5a41066 与 P0 提交 e4e629a 上复现同样失败(搜索 0 命中),确认为先于本优化存在的独立回归(最后一次已知通过是 2026-06-08 全量跑),需单独排查,与 P1 改动无关。
|
||||
|
||||
### P1-2 高亮/下划线剥离为 overlay(与 P1-1 绑定)
|
||||
|
||||
**现状**:搜索高亮已走 overlay(`RDEPUBTextContentView.swift:759` 处 `buildDecorations` 传 `highlights: []`,注释明确为避免双画);普通高亮/下划线仍靠写 `kRDEPUBHighlightAttributeName` / `kRDEPUBUnderlineAttributeName` 属性由 render view 绘制。
|
||||
|
||||
**任务**:
|
||||
|
||||
- [x] `buildDecorations` 已传入真实 highlights,高亮进背景层(文字下方)、下划线进前景层
|
||||
- [x] `applyHighlightsToContent` 与 render view 的 `drawHighlights`/`computeHighlightRects`/`attributedDisplayContent` 已整体移除,无双画
|
||||
- [x] 高亮命中与菜单锚点继续走 `interactionController.selectionRects`,坐标保持 chapter-absolute(未改动)
|
||||
|
||||
**验收**:高亮/下划线视觉与改前一致(含跨页高亮的页内裁剪);高亮点击弹菜单正常;与搜索高亮叠加时无双画。
|
||||
|
||||
### P1-3 章节级共享 display layouter
|
||||
|
||||
**现状**:每个页面视图各建一个 `DTCoreTextLayouter`(`RDEPUBTextContentView.swift:465`);章节运行时已有分页用 `RDEPUBTextLayouter`(`RDEPUBRuntimeChapter.layouter`),display layouter 向同一模式靠拢。
|
||||
|
||||
**任务**:
|
||||
|
||||
- [x] `DTCoreTextLayouter` 已随共享内容放入 display cache,与 signature 同生命周期
|
||||
- [x] 页面按 `bounds` + `page.contentRange` 取 `layoutFrame`;`shouldCacheLayoutFrames = false` 保持,layoutFrame 按页新建(VerticalJustifier 的修改不会污染共享对象)
|
||||
- [x] framesetter 只依赖字符串、与 bounds 无关,bounds 变化无需失效共享对象;layoutFrame 随页面视图释放
|
||||
- [x] `RDEPUBTextPageBoundaryValidator` 校验路径不变
|
||||
|
||||
**验收**:翻页时不再重建 framesetter(打点确认);横竖屏切换后布局正确;`--demo-pagination-validate` 不回升。
|
||||
|
||||
### P1-4 评估重新放开文本页预加载缓存
|
||||
|
||||
前置:P1-1~P1-3 全部落地后,页面视图不再持有整章副本。
|
||||
|
||||
- [ ] 调整 `shouldAvoidReaderPageCaching` 判定,允许轻量化后的文本页进入 `RDEpubReaderPreloadController` 缓存
|
||||
- [ ] 对比放开前后的翻页流畅度与峰值内存,数据不佳则回退此项
|
||||
|
||||
---
|
||||
|
||||
## P2:视 P0/P1 打点数据决定
|
||||
|
||||
### P2-1 章节级 LRU 分层淘汰
|
||||
|
||||
**现状**:`RDEPUBChapterRuntimeStore` 已有邻接窗口(`windowSpineIndices`,radius 1)、`evictableSpineIndices` / `evict` / `evictAllExceptCurrent` / `handleMemoryWarning` / `invalidateAllForSettingsChange`;磁盘侧有 `RDEPUBChapterSummaryDiskCache`(分页结果 + cfiMap + metadata)。当前邻接章节仍持有完整 `RDEPUBRuntimeChapter`(typesetString + pages + layouter)。
|
||||
|
||||
**任务**:
|
||||
|
||||
- [ ] 邻接章节降级:保留 page map / chapter summary / 分页结果,释放 `typesetAttributedString`、display content、layouter
|
||||
- [ ] 进入邻接章节时按 summary 重建,测量重建延迟;延迟不可接受则维持现状
|
||||
- [ ] 远距章节仅保留磁盘缓存 + 轻量 metadata(现有 evict 已覆盖,确认即可)
|
||||
|
||||
### P2-2 图片附件深化
|
||||
|
||||
**现状**:暗黑图已走 `NSCache`(50 MB / 100 张,`RDEPUBDarkImageAdjuster.swift:9`)且仅处理当前页范围;章节运行时另有 100 MB `imageCache`。
|
||||
|
||||
**任务**:
|
||||
|
||||
- [ ] 大图按显示尺寸下采样后再参与 attachment
|
||||
- [ ] 页面离屏后释放 attachment 强引用的已解码大图;避免共享 content 长期持有
|
||||
- [ ] 回归:图片点击查看、暗黑模式切换、附件位置与命中
|
||||
|
||||
---
|
||||
|
||||
## P3:仅在 P0–P2 之后数据仍不达标时
|
||||
|
||||
- [ ] 上下文窗口化布局(当前页所在段落块 + 前后足以影响断行的文本窗口)。风险最高:易重新引入分页/显示边界不一致(CTTypesetter 断行上下文敏感,为已知已解 bug 的根因),选区/搜索/高亮/附件索引需全部重验。默认不做。
|
||||
|
||||
---
|
||||
|
||||
## 明确不做
|
||||
|
||||
1. 回退"页内子串重新布局"(重新引入显示不全/页末孤字)
|
||||
2. 取消 chapter-absolute 索引(破坏点击/选区/高亮定位一致性)
|
||||
3. 先改分页器(正确性与性能问题混杂,无法回归)
|
||||
4. 未落地 P1 前放开文本页 reader 级缓存(会驻留多份整章副本)
|
||||
|
||||
---
|
||||
|
||||
## 各阶段统一验收指标
|
||||
|
||||
**正确性**:`--demo-pagination-validate` 命中数不回升;长章节页末无孤字/缺字;高亮显示、搜索高亮、附件点击、选区拖拽行为一致。
|
||||
|
||||
**内存**(对比 P0-2 基线):打开长章节后常驻;连续翻页 20 页峰值;设置变更前后峰值;横竖屏切换峰值。
|
||||
|
||||
**性能**:首次打开章节耗时;连续翻页帧稳定性;设置切换恢复时间。
|
||||
@ -1,123 +0,0 @@
|
||||
# 分页问题调查记录(2026-07-06)
|
||||
|
||||
调查载体:《宝山辽墓材料与释读》(textReflowable / DTCoreText 路径),iPhone 15 Pro Max 尺寸(430×932,内容区 398×815pt)。当日共调查两个独立问题:
|
||||
|
||||
---
|
||||
|
||||
## 问题一:页底留空一行多,下一页首行未上移(已修复)
|
||||
|
||||
### 现象
|
||||
|
||||
第 6 页底部留有约 1.7 行高的空白,第 7 页首行"人目为帝羓,信有之也。[注]"本可容纳在第 6 页。截图实测:行距 75px@3x(25pt),第 6 页底部空隙 138px,足够再排一行。
|
||||
|
||||
### 根因链
|
||||
|
||||
1. `RDEPUBSemanticMarkerInjector.inferredHints` 给**所有** `<img>` 打上 `avoidPageBreakInside` 提示——包括行内脚注小图标(`<img class="qqreader-footnote">`,即"注"字图标)。
|
||||
2. `RDEPUBPageBreakPolicy.shouldTreatAvoidHintAsBlockProtection` 本有豁免:`blockKind == .attachment && placement != .centered → 不保护`。但 `applyPaginationSemantics` 按**结束标记的字符串顺序**应用属性:`<img>` 的结束标记先于外层 `<p>`,段落随后把图标位置的 `.rdPageBlockKind` 从 `attachment` 覆盖为 `paragraph`;而 `hints` 与 `placement` 因段落不携带这两个属性而幸存。豁免条件因此失效。
|
||||
3. `trimmedRangeForAvoidPageBreakInside` 把含注图标的行当作"不可分页块"的行,从页底裁掉(最多 3 行),推到下一页,留下空白。
|
||||
|
||||
### 验证手段
|
||||
|
||||
- Demo 启动参数 `--demo-pagination-debug` 输出 `[PAGINATION-DEBUG] avoidPageBreakInside removed N lines: …`;修复前被裁的行**全部**含 ``(脚注图标)。
|
||||
- 复现命令:`--demo-book-title 宝山 --demo-page 6 --demo-reset-state --demo-clear-cache --demo-pagination-debug`。
|
||||
|
||||
### 修复
|
||||
|
||||
`RDEPUBPageBreakPolicy.swift`:豁免条件放宽为 `(blockKind == .attachment || placement != nil) && placement != .centered`——不再依赖易被覆盖的 blockKind,只要附件 placement 是行内/基线(非居中)就不锁行。居中插图(bodyPic)的整块保护不受影响;同时覆盖 `s-pic`/`h-pic`/`g-pic` 等生僻字行内小图。
|
||||
|
||||
修复后验证:含 `` 的裁剪从多处降为 0;"人目为帝羓"行回到第 6 页且下一段首行补齐;全书页数 280 → 273(消除欠填页)。
|
||||
|
||||
> 备选方案(未采用):修 `applyPaginationSemantics` 的嵌套覆盖顺序(内层优先)。会改变列表/表格的 blockRange 语义,风险大。
|
||||
|
||||
---
|
||||
|
||||
## 问题二:页范围与显示内容度量错配,行中断页(2026-07-07 已定位根因,见文末新增章节)
|
||||
|
||||
### 现象
|
||||
|
||||
第 10 页最后一行只有孤字"相",第 11 页从"对简化,且无构造细致的翼墙。"开始——断点落在句子中间而非行边界。
|
||||
|
||||
### 铁证推理
|
||||
|
||||
当前设置下满行 26 字(字号 15、内容宽 398pt)。第 10 页范围要在"…而2号墓的门楼则相"后断开,要求该行装下 **27 字**——当前排版下不可能。**结论:页范围产生于另一套度量(更小字号或更宽版面),与显示时的排版不一致。** 同一套排版中分页断点必然落在行边界,孤字不可能出现。
|
||||
|
||||
### 已排除(受控实验)
|
||||
|
||||
在 `RDEPUBChapterLoader` 临时加 `--demo-chapter-dump` 转储(typesetString 逐属性段 + 页范围,写入 app Documents,实验后已还原):
|
||||
|
||||
- 冷启动(`MISS(fullRender)` 全量渲染分页)与热启动(`HIT(diskSummary)` 复用磁盘页范围)两条路径的字符串与页范围**完全一致**。字体压平、双重 `normalizeReadingAttributes`、语言码缺失(`makeChapterRenderRequest` 未传 `contentLanguageCode`)、`TailNormalizer` 尾页合并均验证为无影响或幂等。
|
||||
- 持久缓存键控正确:`RDEPUBChapterCacheKey.renderSignature` 含字体名/字号/行距倍数/lineSpacing/`layoutConfig.cacheSignature`(宽高、四边距、分栏、hyphenation、schemaVersion=17)+ 章节内容哈希。同设置跨启动复用安全。
|
||||
|
||||
### 主要嫌疑:会话中改设置的换表过渡态
|
||||
|
||||
- 用户两组截图之间调过行距(行距 25pt/页 32 行 → 28pt/页 28 行,字号未变);出现问题时显示总页数 196,既非 1.6 档全量分页(~273)也非 1.8 档(~290)——当时显示的是**未收敛的中间页表**。
|
||||
- 代码窗口:
|
||||
- `RDEPUBReaderRuntime.scheduleSettingsPreviewRepagination`:设置面板打开期间只重排**当前章**并 `applySettingsPreviewPageMap` 换部分页表,全量重排推迟到面板关闭(`needsFullRepaginationAfterSettingsClose`)。
|
||||
- `RDEPUBChapterRuntimeStore.chapterDataCache` 仅按 spineIndex 键控(无样式维度);`invalidateAllForSettingsChange` 清空后,**变更前已在飞行中的构建**完成时会把旧样式章节重新 `insertChapter` 插回。
|
||||
- 这些窗口里可能出现"旧页范围 + 新排版内容"的错配。
|
||||
|
||||
### 待办 / 复现所需
|
||||
|
||||
1. 确认触发操作:是否在设置面板调整行距/字号后(未关面板或刚关)翻页出现。
|
||||
2. 确认重启 app 后是否自愈(受控实验表明缓存路径本身一致,理应自愈;若不自愈则有持久化的脏状态)。
|
||||
3. 修复方向(待复现后定):设置变更代(generation)标记贯穿构建流程,飞行中的旧代构建完成后丢弃、不得插回 store;或 `chapterDataCache` 键加入 renderSignature。
|
||||
|
||||
### 经验教训
|
||||
|
||||
- 跨启动比较"第 N 页"截图无效:章节渐进加载过程中全局页码会漂移。
|
||||
- 判断断点是否合法的快捷方法:数满行字数。断点不在行边界 ⟺ 范围与显示度量不一致。
|
||||
|
||||
---
|
||||
|
||||
## 相关工具与命令备忘
|
||||
|
||||
| 用途 | 方法 |
|
||||
|---|---|
|
||||
| 分页裁剪日志 | 启动参数 `--demo-pagination-debug` |
|
||||
| 自动打开书籍/翻页 | `--demo-book-title 宝山 --demo-page N --demo-clear-cache --demo-reset-state` |
|
||||
| 注入阅读设置 | `simctl spawn <sim> defaults write cn.shen.ReadViewDemo ssreader.epub.settings -data <hex(JSON)>`,JSON 形如 `{"fontSize":15,"lineHeightMultiple":1.8}` |
|
||||
| 截屏 | `xcrun simctl io <sim> screenshot <绝对路径>`(相对路径会因只读文件系统失败) |
|
||||
| 模拟器 | 预装仅 16/17 系列;`simctl create` 可建 iPhone 15 Pro Max(430×932@3x 与问题截图同尺寸) |
|
||||
| 页边界/换行一致性校验 | 启动参数 `--demo-pagination-validate`(RDEPUBTextPageBoundaryValidator,逐页比对显示换行与全章上下文换行,输出 STALE-RANGE / DISPLAY-DIVERGE / DIAGNOSE / ATTR-DIFF) |
|
||||
| 设置面板自动翻转 | 启动参数 `--demo-settings-flip`(RDEPUBSettingsFlipAutomation,自动开面板→改行距→关面板→翻页,三种时序) |
|
||||
|
||||
---
|
||||
|
||||
## 问题二根因(2026-07-07 续查确认)
|
||||
|
||||
### 结论
|
||||
|
||||
**分页与显示使用同一引擎(DTCoreText/CoreText)但输入字符串不同:分页在“全章字符串”上下文中断行;显示把页内容取成子串后重新断行。`CTTypesetterSuggestLineBreak` 在同一文字、同一属性、同一宽度下,会因字符串上下文不同而在 CJK 标点压缩/悬挂处产生 ±1 字的断点漂移。** 页范围(全章上下文产物)与显示换行(子串产物)在页内任何一行发生漂移,累积到页尾就出现孤字(用户看到的「相」)或半空行。
|
||||
|
||||
与缓存、设置换表竞态无关:设置变更只是**搬动了页边界位置**,让某个边界恰好落在漂移点上,症状因此看似随设置变化出现/消失。这同时解释了此前所有观察:冷/热启动转储完全一致(分页自身是一致的,错配发生在分页 vs 显示);重启后"第 N 页"不复现(边界挪走了)。
|
||||
|
||||
### 证据(宝山书 spine 3,字号 15 / 行距 1.6 / 宽 398pt)
|
||||
|
||||
`--demo-pagination-validate` 基线运行(无任何设置操作)即命中,前 8 页中 5 页分叉。决定性样本 page 8/55 第 11 行(行首 offset 4224):
|
||||
|
||||
- 显示端断点 4252(行尾"…可以相"——孤字机制当场复现),全章上下文断点 4253("…可以相当")
|
||||
- 逐字符属性 diff:**完全一致**(无 ATTR-DIFF)
|
||||
- 对照排版:原始子串 = 4252,段落级上下文 = 4252,**只有全章上下文 = 4253**
|
||||
- page 6/55 的上下文探针行宽 408.39 > 框宽 398 —— 标点悬挂/压缩越界的直接证据
|
||||
|
||||
即断点差异既不是首行缩进归一化(`normalizedPageContent` 的 firstLineHeadIndent 处理本身是正确的),也不是属性差异,而是 CoreText 断行对字符串上下文(跨段落!)敏感。
|
||||
|
||||
### 修复(方案 A,2026-07-07 已实施)
|
||||
|
||||
显示端改为**全章上下文排版**:`RDEPUBTextContentView.configure` 取 `page.chapterContent` 的整章副本作为显示内容,`DTCoreTextLayouter(章节副本).layoutFrame(bounds, range: page.contentRange)` 只排当页范围。断行与分页器按构造一致(同串、同起点、同宽)。
|
||||
|
||||
配套改动:
|
||||
|
||||
- layoutFrame 的 stringRange 因此为**章节绝对坐标**。`RDEPUBPageLayoutSnapshot.build` 去掉 +pageStartOffset 归一(本就输出绝对坐标给消费方),`RDEPUBPageInteractionController` 去掉全部 -pageOffset 反换算;选区/高亮/点击测试/装饰层的消费接口本来就是绝对坐标,无需变动。
|
||||
- 主题前景色、暗色图片调整(`RDEPUBDarkImageAdjuster` 新增 `in range:` 参数)、高亮标记只施加在章节副本的当页范围上;`applyHighlightsToContent` 改用绝对 overlap 范围。
|
||||
- 删除 DTCoreText 路径对 `normalizedPageContent` 的调用——续段首行缩进/段前距归一化 hack 由上下文排版天然取代(非 DTCoreText 回退路径仍保留)。
|
||||
- layouter(framesetter)随显示内容缓存于 cell(`coreTextLayouter`),bounds 变化只重建 layoutFrame,避免每次 layout pass 对整章重建 framesetter。
|
||||
- `RDEPUBTextPageBoundaryValidator` 适配绝对坐标,保留为回归警报。
|
||||
|
||||
验证:`--demo-pagination-validate` 基线(原先 spine 3 前 8 页 5 处分叉)修复后 **0 命中**;第 10 页首行断点与分页一致(「…使用了更多」);Selection/Annotation UI 测试回归通过(见下)。
|
||||
|
||||
### 遗留(独立的潜在缺陷,本次未触发)
|
||||
|
||||
- `RDEPUBChapterRuntimeStore.chapterDataCache` 仅按 spineIndex 键控;设置变更时飞行中的旧构建完成后仍会插回(`RDEPUBChapterLoader.loadChapter` 的 `insertChapter` 无代际校验)。
|
||||
- `RDEPUBChapterLoader.pendingLoads` 按 spineIndex 合流,跨设置变更合流会把旧样式章节交给新请求。
|
||||
- 建议修复问题二后用 `--demo-settings-flip` + `--demo-pagination-validate` 回归验证这两个窗口。
|
||||
@ -1,455 +0,0 @@
|
||||
# ReaderView 模块代码级参考文档
|
||||
|
||||
> 最后更新:2026-06-18
|
||||
|
||||
---
|
||||
|
||||
## 1. 模块概述
|
||||
|
||||
`ReaderView` 是 ReadViewSDK 的**通用翻页容器层**,位于 EPUBUI 层之下。它提供与 EPUB 内容无关的页面展示、翻页动画、手势识别、页面预加载和双页布局能力。上层通过 `RDEpubReaderPageProvider` 协议提供页面内容视图,ReaderView 负责容器管理和翻页调度。
|
||||
|
||||
**文件清单(14 个 Swift 文件):**
|
||||
|
||||
| 文件 | 核心类型 | 职责 |
|
||||
|------|----------|------|
|
||||
| `RDEpubReaderView.swift` | `RDEpubReaderView` | 主容器视图,协调所有子组件 |
|
||||
| `RDEpubReaderViewProtocols.swift` | 协议 + 枚举 | 数据源、代理、导航协议定义 |
|
||||
| `RDEpubReaderFlowLayout.swift` | `RDEpubReaderFlowLayout` | UICollectionView 自定义布局 |
|
||||
| `RDEpubReaderGestureController.swift` | `RDEpubReaderGestureController` | 手势控制器(预留) |
|
||||
| `RDEpubReaderContentCell.swift` | `RDEpubReaderContentCell` | CollectionView 内容 Cell |
|
||||
| `RDEpubReaderPageChildViewController.swift` | `RDEpubReaderPageChildViewController` | PageCurl 模式子 VC |
|
||||
| `RDEpubReaderView+PageCurl.swift` | Extension | UIPageViewController 数据源/代理 |
|
||||
| `RDEpubReaderView+CollectionView.swift` | Extension | UICollectionView 数据源/布局代理 |
|
||||
| `RDEpubReaderView+ContentAccess.swift` | Extension | 内容视图访问与复用 |
|
||||
| `RDEpubReaderView+ToolView.swift` | Extension | 工具栏安装与动画 |
|
||||
| `Paging/RDEpubReaderPagingController.swift` | `RDEpubReaderPagingController` | 翻页状态机 |
|
||||
| `Paging/RDEpubReaderPreloadController.swift` | `RDEpubReaderPreloadController` | 页面预加载与缓存 |
|
||||
| `Paging/RDEpubReaderSpreadResolver.swift` | `RDEpubReaderSpreadResolver` | 双页展开计算 |
|
||||
| `Paging/RDEpubReaderTapRegionHandler.swift` | `RDEpubReaderTapRegionHandler` | 点击区域判定 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 协议定义(RDEpubReaderViewProtocols.swift)
|
||||
|
||||
### 2.1 RDEpubReaderDataSource(旧版数据源,已废弃)
|
||||
|
||||
```swift
|
||||
@objc public protocol RDEpubReaderDataSource: NSObjectProtocol {
|
||||
func pageCountOfReaderView(readerView: RDEpubReaderView) -> Int
|
||||
func pageContentView(readerView: RDEpubReaderView, pageNum: Int, containerView: UIView?) -> UIView
|
||||
func pageIdentifier(readerView: RDEpubReaderView, pageNum: Int) -> String?
|
||||
@objc optional func topToolView(readerView: RDEpubReaderView) -> UIView?
|
||||
@objc optional func bottomToolView(readerView: RDEpubReaderView) -> UIView?
|
||||
}
|
||||
```
|
||||
|
||||
> 向后兼容保留,新代码应使用 `RDEpubReaderPageProvider`。
|
||||
|
||||
### 2.2 RDEpubReaderPageProvider(推荐数据源)
|
||||
|
||||
```swift
|
||||
@objc public protocol RDEpubReaderPageProvider: NSObjectProtocol {
|
||||
func numberOfPages(in readerView: RDEpubReaderView) -> Int
|
||||
func readerView(_ readerView: RDEpubReaderView, viewForPageAt index: Int, reusableView: UIView?) -> UIView
|
||||
@objc optional func pageIdentifier(in readerView: RDEpubReaderView, index: Int) -> String?
|
||||
@objc optional func readerViewTopChrome(_ readerView: RDEpubReaderView) -> UIView?
|
||||
@objc optional func readerViewBottomChrome(_ readerView: RDEpubReaderView) -> UIView?
|
||||
}
|
||||
```
|
||||
|
||||
| 方法 | 说明 |
|
||||
|------|------|
|
||||
| `numberOfPages(in:)` | 返回总页数 |
|
||||
| `readerView(_:viewForPageAt:reusableView:)` | 为指定页码提供内容视图,`reusableView` 可复用 |
|
||||
| `pageIdentifier(in:index:)` | 返回页视图复用标识符,用于 CollectionView 注册 |
|
||||
| `readerViewTopChrome(_:)` | 返回顶部工具栏视图 |
|
||||
| `readerViewBottomChrome(_:)` | 返回底部工具栏视图 |
|
||||
|
||||
### 2.3 RDEpubReaderDelegate
|
||||
|
||||
```swift
|
||||
@objc public protocol RDEpubReaderDelegate: NSObjectProtocol {
|
||||
func pageNum(readerView: RDEpubReaderView, pageNum: Int)
|
||||
@objc optional func readerViewOrientationWillChange(readerView: RDEpubReaderView, isLandscape: Bool)
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 RDEpubReaderPageNavigating
|
||||
|
||||
```swift
|
||||
public protocol RDEpubReaderPageNavigating: AnyObject {
|
||||
var currentPage: Int { get }
|
||||
func reloadPages()
|
||||
func transition(to page: Int, animated: Bool)
|
||||
}
|
||||
```
|
||||
|
||||
`RDEpubReaderView` 遵循此协议,提供统一的页面导航接口。
|
||||
|
||||
### 2.5 枚举类型
|
||||
|
||||
```swift
|
||||
extension RDEpubReaderView {
|
||||
public enum DisplayType {
|
||||
case pageCurl // 仿真翻页(UIPageViewController)
|
||||
case horizontalScroll // 水平滑动(UICollectionView)
|
||||
case verticalScroll // 垂直滚动(UICollectionView)
|
||||
}
|
||||
|
||||
public enum PageDirection {
|
||||
case leftToRight // LTR
|
||||
case rightToLeft // RTL
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 核心类:RDEpubReaderView
|
||||
|
||||
**文件:** `RDEpubReaderView.swift`
|
||||
|
||||
`RDEpubReaderView` 是一个 `UIView` 子类,作为翻页容器的主入口。内部管理两种翻页引擎:
|
||||
- **PageCurl 模式**:使用 `UIPageViewController` 实现仿真翻页
|
||||
- **Scroll 模式**:使用 `UICollectionView` + 自定义 `RDEpubReaderFlowLayout` 实现滑动翻页
|
||||
|
||||
### 3.1 关键属性
|
||||
|
||||
| 属性 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `currentPage` | `Int` | 当前页码,变化时通知 delegate |
|
||||
| `currentDisplayType` | `DisplayType` | 当前显示模式 |
|
||||
| `pageDirection` | `PageDirection` | 页面方向(LTR/RTL) |
|
||||
| `landscapeDualPageEnabled` | `Bool` | 是否启用横屏双页 |
|
||||
| `coverPageIndex` | `Int?` | 封面页索引(独占一屏) |
|
||||
| `pagesPerScreen` | `Int` | 每屏页数(横屏双页时为 2) |
|
||||
| `preloadRadius` | `Int` | 预加载半径(默认 1) |
|
||||
| `dataSource` | `RDEpubReaderDataSource?` | 旧版数据源 |
|
||||
| `pageProvider` | `RDEpubReaderPageProvider?` | 推荐数据源 |
|
||||
| `delegate` | `RDEpubReaderDelegate?` | 事件代理 |
|
||||
| `toolViewAnimationDuration` | `TimeInterval` | 工具栏动画时长(0.3s) |
|
||||
|
||||
### 3.2 关键方法
|
||||
|
||||
```swift
|
||||
/// 切换显示模式(pageCurl / horizontalScroll / verticalScroll)
|
||||
public func switchReaderDisplayType(_ displayType: RDEpubReaderView.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` | `RDEpubReaderFlowLayout` | CollectionView 自定义布局 |
|
||||
| `spreadResolver` | `RDEpubReaderSpreadResolver` | 双页配对计算 |
|
||||
| `tapRegionHandler` | `RDEpubReaderTapRegionHandler` | 点击区域判定 |
|
||||
| `preloadController` | `RDEpubReaderPreloadController` | 页面预加载与缓存 |
|
||||
| `pagingController` | `RDEpubReaderPagingController` | 翻页状态管理 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 翻页状态机:RDEpubReaderPagingController
|
||||
|
||||
**文件:** `Paging/RDEpubReaderPagingController.swift`
|
||||
|
||||
管理 PageCurl 模式下的翻页请求队列,防止动画冲突。
|
||||
|
||||
```swift
|
||||
struct RDEpubReaderPagingController {
|
||||
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: RDEpubReaderView.DisplayType) -> Bool
|
||||
|
||||
/// 完成翻页动画,返回待处理的请求
|
||||
mutating func finishPageCurlTransition() -> PageTransitionRequest?
|
||||
|
||||
/// 重置状态(用于故障恢复)
|
||||
mutating func resetPendingState()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 页面预加载:RDEpubReaderPreloadController
|
||||
|
||||
**文件:** `Paging/RDEpubReaderPreloadController.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: RDEpubReaderView.DisplayType
|
||||
let isLandscape: Bool
|
||||
let pagesPerScreen: Int
|
||||
let boundsSize: CGSize
|
||||
let landscapeDualPageEnabled: Bool
|
||||
let coverPageIndex: Int?
|
||||
let totalPages: Int
|
||||
let spreadResolver: RDEpubReaderSpreadResolver
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 双页展开计算:RDEpubReaderSpreadResolver
|
||||
|
||||
**文件:** `Paging/RDEpubReaderSpreadResolver.swift`
|
||||
|
||||
纯函数式结构体,负责双页模式下的页面配对和导航计算。
|
||||
|
||||
```swift
|
||||
struct RDEpubReaderSpreadResolver {
|
||||
/// 判断是否为全屏页(封面页独占一屏)
|
||||
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. 点击区域判定:RDEpubReaderTapRegionHandler
|
||||
|
||||
**文件:** `Paging/RDEpubReaderTapRegionHandler.swift`
|
||||
|
||||
将屏幕三等分,判定点击属于左/中/右区域。
|
||||
|
||||
```swift
|
||||
struct RDEpubReaderTapRegionHandler {
|
||||
func resolveTapEvent(point: CGPoint, viewFrame: CGRect, isToolViewVisible: Bool) -> RDEpubReaderView.TapEvent
|
||||
}
|
||||
```
|
||||
|
||||
**逻辑:**
|
||||
- 左 1/3 → `.left`(上一页),工具栏可见时改为 `.center`
|
||||
- 中 1/3 → `.center`(切换工具栏)
|
||||
- 右 1/3 → `.right`(下一页),工具栏可见时改为 `.center`
|
||||
|
||||
---
|
||||
|
||||
## 8. 流式布局:RDEpubReaderFlowLayout
|
||||
|
||||
**文件:** `RDEpubReaderFlowLayout.swift`
|
||||
|
||||
`UICollectionViewFlowLayout` 子类,支持水平滚动和垂直滚动两种模式。
|
||||
|
||||
### 8.1 关键属性
|
||||
|
||||
| 属性 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `displayType` | `RDEpubReaderView.DisplayType` | 布局模式 |
|
||||
| `isLandscapeDualPage` | `Bool` | 是否横屏双页 |
|
||||
| `coverPageIndex` | `Int?` | 封面页索引 |
|
||||
| `pagesPerScreen` | `Int` | 每屏页数 |
|
||||
|
||||
### 8.2 协议
|
||||
|
||||
```swift
|
||||
public protocol RDEpubReaderFlowLayoutDataSoure: NSObjectProtocol {
|
||||
func heigtOfVerticalScrollPage(flowLayout: RDEpubReaderFlowLayout, pageIndex: Int) -> CGFloat?
|
||||
}
|
||||
|
||||
@objc public protocol RDEpubReaderFlowLayoutDelegate: NSObjectProtocol {
|
||||
func pageNum(flowLayout: RDEpubReaderFlowLayout, pageIndex: Int)
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3 关键方法
|
||||
|
||||
```swift
|
||||
/// 计算指定页码的 contentOffset
|
||||
func currentContentOffset(count: Int) -> CGPoint
|
||||
```
|
||||
|
||||
### 8.4 封面页布局逻辑
|
||||
|
||||
双页模式下,封面页占满整屏宽度,后续页面两两配对占半屏宽度。`coverAwareFrame(for:screenWidth:halfWidth:height:)` 方法根据页码计算对应的 frame。
|
||||
|
||||
---
|
||||
|
||||
## 9. 内容 Cell:RDEpubReaderContentCell
|
||||
|
||||
**文件:** `RDEpubReaderContentCell.swift`
|
||||
|
||||
`UICollectionViewCell` 子类,用于 Scroll 模式下承载页面内容视图。
|
||||
|
||||
```swift
|
||||
class RDEpubReaderContentCell: UICollectionViewCell {
|
||||
var containerView: UIView? // 设置时自动添加到 contentView,移除旧视图
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. PageCurl 子控制器:RDEpubReaderPageChildViewController
|
||||
|
||||
**文件:** `RDEpubReaderPageChildViewController.swift`
|
||||
|
||||
`UIViewController` 子类,作为 `UIPageViewController` 的页面 VC。
|
||||
|
||||
```swift
|
||||
class RDEpubReaderPageChildViewController: UIViewController {
|
||||
var contentView: UIView? // 内容视图,设置时自动安装到容器
|
||||
var pageNum: Int // 对应页码
|
||||
|
||||
init(contentView: UIView?, pageNum: Int = 0)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Extension 汇总
|
||||
|
||||
### 11.1 RDEpubReaderView+PageCurl
|
||||
|
||||
实现 `UIPageViewControllerDataSource` 和 `UIPageViewControllerDelegate`:
|
||||
|
||||
- `pageViewController(_:viewControllerBefore:)` — 提供前一页 VC(RTL 时逻辑反转)
|
||||
- `pageViewController(_:viewControllerAfter:)` — 提供后一页 VC
|
||||
- `pageViewController(_:didFinishAnimating:...)` — 完成动画后更新 currentPage
|
||||
- `pageViewController(_:willTransitionTo:)` — 即将翻页时预加载
|
||||
|
||||
**特殊页码:**
|
||||
- `RDEpubReaderView.blankPageNum`(`Int.max`)— 双页模式下的空白页
|
||||
- `RDEpubReaderView.blankEndPageNum`(`Int.max - 1`)— 末尾空白页
|
||||
|
||||
### 11.2 RDEpubReaderView+CollectionView
|
||||
|
||||
实现 `UICollectionViewDataSource`、`RDEpubReaderFlowLayoutDelegate`、`RDEpubReaderFlowLayoutDataSoure`:
|
||||
|
||||
- `collectionView(_:cellForItemAt:)` — 复用预加载视图或创建新 Cell
|
||||
- `collectionView(_:numberOfItemsInSection:)` — 返回总页数
|
||||
- `pageNum(flowLayout:pageIndex:)` — 滚动时更新当前页码
|
||||
|
||||
### 11.3 RDEpubReaderView+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 RDEpubReaderView+ToolView
|
||||
|
||||
管理顶部/底部工具栏的安装、显示/隐藏动画:
|
||||
|
||||
```swift
|
||||
/// 切换工具栏显示状态(点击中心区域触发)
|
||||
func tapCenter()
|
||||
|
||||
/// 安装工具栏视图到指定位置
|
||||
func installToolViewIfNeeded(_ toolView: UIView, position: ToolViewPosition)
|
||||
|
||||
/// 更新工具栏高度约束
|
||||
func updateToolViewHeightConstraintsIfNeeded()
|
||||
```
|
||||
|
||||
**动画效果:** 顶部工具栏从上方滑入,底部工具栏从下方滑入。
|
||||
|
||||
---
|
||||
|
||||
## 12. 设计模式总结
|
||||
|
||||
| 模式 | 应用 |
|
||||
|------|------|
|
||||
| **策略模式** | `DisplayType` 切换 PageCurl / Scroll 两种翻页策略 |
|
||||
| **适配器模式** | `RDEpubReaderLegacyDataSourceAdapter` 将旧 `RDEpubReaderDataSource` 适配为 `RDEpubReaderPageProvider` |
|
||||
| **命令队列** | `RDEpubReaderPagingController` 管理翻页请求队列 |
|
||||
| **缓存签名** | `RDEpubReaderPreloadController.CacheSignature` 检测环境变化自动失效 |
|
||||
| **关注点分离** | Extension 将不同功能拆分到独立文件 |
|
||||
| **纯函数** | `RDEpubReaderSpreadResolver` 和 `RDEpubReaderTapRegionHandler` 无状态计算 |
|
||||
|
||||
---
|
||||
|
||||
## 13. 数据流图
|
||||
|
||||
```
|
||||
用户点击屏幕
|
||||
│
|
||||
▼
|
||||
RDEpubReaderTapRegionHandler.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() → 预加载周围页面
|
||||
```
|
||||
@ -1,6 +1,6 @@
|
||||
# 测试说明
|
||||
|
||||
**分析日期:** 2026-06-22(更新)
|
||||
**分析日期:** 2026-06-09(更新)
|
||||
|
||||
## 测试框架与现状
|
||||
|
||||
@ -102,25 +102,6 @@ xcodebuild test \
|
||||
|
||||
测试报告输出到 `.artifacts/ui-tests/{timestamp}/UI-Test-Report.md`。
|
||||
|
||||
## 架构整改回归重点
|
||||
|
||||
在 2026-06-22 的阅读器架构整改后,以下场景应作为 smoke 回归最小集合:
|
||||
|
||||
- 打开大书后首次进入阅读页
|
||||
- 章节尾页连续翻页,确认跨章节动画无明显卡顿
|
||||
- 目录远跳到未预热章节,再返回当前阅读流
|
||||
- 关闭并重新打开书籍,恢复上次阅读位置
|
||||
- 长按选区、高亮、批注后再次翻页
|
||||
- 打开设置面板调整字号/间距,观察预览与最终全量 repagination
|
||||
|
||||
建议同时记录以下观测项:
|
||||
|
||||
- `prepareOnDemandChapter` 主线程 wall clock
|
||||
- `RDEpubReaderPreloadController` 预加载命中率
|
||||
- `bookPageMap` partial extension / full replacement 次数
|
||||
- 页面静态底图缓存命中率
|
||||
- CFI 延迟构建完成次数与耗时
|
||||
|
||||
## 覆盖率(Coverage)
|
||||
|
||||
- 当前未启用代码覆盖率收集
|
||||
|
||||
@ -1,349 +0,0 @@
|
||||
# 排版管线详解
|
||||
|
||||
> 最后更新:2026-06-18
|
||||
|
||||
本文档详细描述 ReadViewSDK 文本排版管线(Typesetter Pipeline)的 8 个处理阶段、核心算法和数据流。
|
||||
|
||||
---
|
||||
|
||||
## 1. 概述
|
||||
|
||||
排版管线是 EPUBTextRendering 层的核心组件,负责将 EPUB 原始 HTML 转换为可分页的 NSAttributedString。它是 textReflowable 渲染路径(本 SDK 核心路径)的关键环节。
|
||||
|
||||
**入口**:`RDEPUBTextTypesetterPipeline.makeRequest(from:)`
|
||||
|
||||
**输入**:`RDEPUBTypesettingInput`(原始 HTML、样式、布局配置)
|
||||
|
||||
**输出**:`RDEPUBTypesettingOutput`(渲染请求、诊断信息、兼容性报告)
|
||||
|
||||
**关键文件**:`Sources/RDEpubReaderView/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)
|
||||
@ -31,12 +31,12 @@ graph TB
|
||||
IndexTable[RDEPUBTextIndexTable]
|
||||
end
|
||||
|
||||
subgraph RDEpubReaderView
|
||||
ReaderView[RDEpubReaderView]
|
||||
FlowLayout[RDEpubReaderFlowLayout]
|
||||
Preload[RDEpubReaderPreloadController]
|
||||
Spread[RDEpubReaderSpreadResolver]
|
||||
TapRegion[RDEpubReaderTapRegionHandler]
|
||||
subgraph RDReaderView
|
||||
ReaderView[RDReaderView]
|
||||
FlowLayout[RDReaderFlowLayout]
|
||||
Preload[RDReaderPreloadController]
|
||||
Spread[RDReaderSpreadResolver]
|
||||
TapRegion[RDReaderTapRegionHandler]
|
||||
end
|
||||
|
||||
subgraph EPUBUI
|
||||
@ -297,11 +297,11 @@ classDiagram
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class RDEpubReaderView {
|
||||
class RDReaderView {
|
||||
+currentPage: Int
|
||||
+currentDisplayType: DisplayType
|
||||
+pageProvider: RDEpubReaderPageProvider?
|
||||
+delegate: RDEpubReaderDelegate?
|
||||
+pageProvider: RDReaderPageProvider?
|
||||
+delegate: RDReaderDelegate?
|
||||
+landscapeDualPageEnabled: Bool
|
||||
+pageDirection: PageDirection
|
||||
+coverPageIndex: Int?
|
||||
@ -318,14 +318,14 @@ classDiagram
|
||||
verticalScroll
|
||||
}
|
||||
|
||||
class RDEpubReaderPageProvider {
|
||||
class RDReaderPageProvider {
|
||||
<<protocol>>
|
||||
+numberOfPages(in:) Int
|
||||
+readerView(_:viewForPageAt:reusableView:) UIView
|
||||
+pageIdentifier(in:index:) String?
|
||||
}
|
||||
|
||||
class RDEpubReaderFlowLayout {
|
||||
class RDReaderFlowLayout {
|
||||
+displayType: DisplayType
|
||||
+isLandscapeDualPage: Bool
|
||||
+coverPageIndex: Int?
|
||||
@ -333,7 +333,7 @@ classDiagram
|
||||
+currentPage: Int
|
||||
}
|
||||
|
||||
class RDEpubReaderPreloadController {
|
||||
class RDReaderPreloadController {
|
||||
+radius: Int
|
||||
+pageViewForDisplay(pageNum:environment:contentViewProvider:) UIView?
|
||||
+takePreloadedView(for:) UIView?
|
||||
@ -341,13 +341,13 @@ classDiagram
|
||||
+invalidate(environment:)
|
||||
}
|
||||
|
||||
class RDEpubReaderSpreadResolver {
|
||||
class RDReaderSpreadResolver {
|
||||
+isFullScreenPage(...) Bool
|
||||
+dualPagePair(for:totalPages:coverPageIndex:) (Int, Int?)
|
||||
+nextPage(from:totalPages:pagesPerScreen:coverPageIndex:forward:) Int?
|
||||
}
|
||||
|
||||
class RDEpubReaderTapRegionHandler {
|
||||
class RDReaderTapRegionHandler {
|
||||
+resolveTapEvent(point:viewFrame:isToolViewVisible:) TapEvent
|
||||
}
|
||||
|
||||
@ -359,7 +359,7 @@ classDiagram
|
||||
right
|
||||
}
|
||||
|
||||
class RDEpubReaderPagingController {
|
||||
class RDReaderPagingController {
|
||||
+isTransitioning: Bool
|
||||
+didBuildUI: Bool
|
||||
+pendingTransitionRequest: PageTransitionRequest?
|
||||
@ -367,25 +367,25 @@ classDiagram
|
||||
+finishPageCurlTransition() PageTransitionRequest?
|
||||
}
|
||||
|
||||
class RDEpubReaderContentCell {
|
||||
class RDReaderContentCell {
|
||||
+containerView: UIView?
|
||||
}
|
||||
|
||||
class RDEpubReaderPageChildViewController {
|
||||
class RDReaderPageChildViewController {
|
||||
+contentView: UIView?
|
||||
+pageNum: Int
|
||||
}
|
||||
|
||||
RDEpubReaderView --> DisplayType : uses
|
||||
RDEpubReaderView --> RDEpubReaderPageProvider : delegates
|
||||
RDEpubReaderView --> RDEpubReaderFlowLayout : owns
|
||||
RDEpubReaderView --> RDEpubReaderPreloadController : owns
|
||||
RDEpubReaderView --> RDEpubReaderSpreadResolver : owns
|
||||
RDEpubReaderView --> RDEpubReaderTapRegionHandler : owns
|
||||
RDEpubReaderView --> RDEpubReaderPagingController : owns
|
||||
RDEpubReaderView --> RDEpubReaderContentCell : creates
|
||||
RDEpubReaderView --> RDEpubReaderPageChildViewController : creates
|
||||
RDEpubReaderTapRegionHandler --> TapEvent : produces
|
||||
RDReaderView --> DisplayType : uses
|
||||
RDReaderView --> RDReaderPageProvider : delegates
|
||||
RDReaderView --> RDReaderFlowLayout : owns
|
||||
RDReaderView --> RDReaderPreloadController : owns
|
||||
RDReaderView --> RDReaderSpreadResolver : owns
|
||||
RDReaderView --> RDReaderTapRegionHandler : owns
|
||||
RDReaderView --> RDReaderPagingController : owns
|
||||
RDReaderView --> RDReaderContentCell : creates
|
||||
RDReaderView --> RDReaderPageChildViewController : creates
|
||||
RDReaderTapRegionHandler --> TapEvent : produces
|
||||
```
|
||||
|
||||
---
|
||||
@ -403,7 +403,7 @@ classDiagram
|
||||
+highlights: [RDEPUBHighlight]
|
||||
+bookmarks: [RDEPUBBookmark]
|
||||
+tableOfContents: [EPUBTableOfContentsItem]
|
||||
+readerView: RDEpubReaderView
|
||||
+readerView: RDReaderView
|
||||
+readerContext: RDEPUBReaderContext
|
||||
+runtime: RDEPUBReaderRuntime?
|
||||
}
|
||||
@ -441,20 +441,10 @@ classDiagram
|
||||
class RDEPUBReaderPaginationCoordinator {
|
||||
-context: RDEPUBReaderContext
|
||||
+paginatePublication(restoreLocation:)
|
||||
+paginateMetadataOnly(token:restoreLocation:)
|
||||
+repaginatePreservingCurrentLocation()
|
||||
}
|
||||
|
||||
class RDEPUBMetadataParseWorker {
|
||||
-context: RDEPUBReaderContext
|
||||
-cancellationController: RDEPUBMetadataParseCancellationController
|
||||
+start(token:restoreLocation:)
|
||||
}
|
||||
|
||||
class RDEPUBMetadataParseCancellationController {
|
||||
+token: UUID
|
||||
+attach(queue:)
|
||||
+cancel()
|
||||
+isCancelled: Bool
|
||||
-restoreBookPageMapIfPossible(publication:) RDEPUBBookPageMap?
|
||||
-buildPageMap(from:summaries:) RDEPUBBookPageMap
|
||||
}
|
||||
|
||||
class RDEPUBReaderLoadCoordinator {
|
||||
@ -670,7 +660,7 @@ sequenceDiagram
|
||||
participant OQ as OperationQueue (N workers)
|
||||
participant Disk as Disk Cache
|
||||
|
||||
Main->>BG: RDEPUBMetadataParseWorker.start(token)
|
||||
Main->>BG: paginateMetadataOnly(token)
|
||||
BG->>BG: 预计算 contentHash (串行)
|
||||
BG->>Disk: readAll(catalog) 批量读取缓存
|
||||
BG->>Main: refreshBookPageMapInPlace (缓存部分)
|
||||
|
||||
25
Doc/index.md
25
Doc/index.md
@ -1,6 +1,6 @@
|
||||
# ReadViewSDK 文档索引
|
||||
|
||||
> 最后更新:2026-06-18
|
||||
> 最后更新:2026-06-12
|
||||
|
||||
---
|
||||
|
||||
@ -12,15 +12,6 @@
|
||||
| [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 文件) |
|
||||
|
||||
## 工程规范与风险
|
||||
|
||||
| 文档 | 说明 |
|
||||
@ -28,27 +19,19 @@
|
||||
| [CONCERNS.md](CONCERNS.md) | 代码库风险与关注点:12 项安全/性能/可维护性风险 |
|
||||
| [TESTING.md](TESTING.md) | 测试基础设施:UI 测试文件清单、运行方式、覆盖率 |
|
||||
| [CONVENTIONS.md](CONVENTIONS.md) | 编码规范:命名约定、代码风格、导入规范、错误处理 |
|
||||
| [ARCHITECTURE-CONTEXT.md](ARCHITECTURE-CONTEXT.md) | WXRead vs ReadViewSDK 架构差距分析:4 个领域、14 项决策 |
|
||||
|
||||
## 专题文档
|
||||
|
||||
| 文档 | 说明 |
|
||||
|------|------|
|
||||
| [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 低),含修复优先级建议 |
|
||||
| [PAGINATION_ISSUES_2026-07-06.md](PAGINATION_ISSUES_2026-07-06.md) | 分页问题调查记录:脚注图标 avoid-trim 裁行(已修复)+页范围/显示度量错配行中断页(待复现),含调试命令备忘 |
|
||||
| [LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md](LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md) | 长章节内存优化低风险实施方案:在保留整章上下文布局正确性的前提下,分阶段收敛整章副本、共享 layouter、治理缓存与图片内存 |
|
||||
| [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、RDEpubReaderView、EPUBUI)
|
||||
- **Swift 文件数:** 142 个(SDK Sources)
|
||||
- **模块总数:** 4 个(EPUBCore、EPUBTextRendering、RDReaderView、EPUBUI)
|
||||
- **Swift 文件数:** 139 个(SDK Sources)
|
||||
- **测试用例数:** 23 个测试类,约 99 个测试方法(UI 测试)
|
||||
- **最低 iOS 版本:** 15.6
|
||||
- **构建方式:** CocoaPods(本地 pod)
|
||||
|
||||
757
Doc/当前阅读器问题修复开发清单.md
Normal file
757
Doc/当前阅读器问题修复开发清单.md
Normal file
@ -0,0 +1,757 @@
|
||||
# 当前阅读器问题修复开发清单
|
||||
|
||||
> 最后更新:2026-06-13
|
||||
> 依据说明:本清单仅基于当前仓库代码实现整理,不以现有说明文档是否准确为前提。
|
||||
|
||||
---
|
||||
|
||||
## 1. 文档目标
|
||||
|
||||
本文将“当前阅读器还存在的问题”展开为可执行的逐任务开发清单,目标是把工作从“方向建议”落到“具体改哪里、怎么改、如何验收”。
|
||||
|
||||
本文聚焦以下四类问题:
|
||||
|
||||
1. 核心交互链路不稳定:选区、高亮、书签、工具栏状态分散。
|
||||
2. 默认安全策略偏弱:外链、解压、日志、持久化默认行为需要收口。
|
||||
3. 资源与分页稳定性不足:大资源内存峰值、离屏分页 WebView 边界控制不足。
|
||||
4. SDK 契约与回归手段不足:默认 no-op 容易误用,自动化观测点不够结构化。
|
||||
|
||||
---
|
||||
|
||||
## 2. 总体实施顺序
|
||||
|
||||
建议按以下顺序执行,减少返工:
|
||||
|
||||
1. `bookmark-chrome-state-unification`
|
||||
2. `selection-state-refactor`
|
||||
3. `external-link-policy-hardening`
|
||||
4. `epub-archive-path-validation`
|
||||
5. `webview-debug-sanitization`
|
||||
6. `persistence-defaults-hardening`
|
||||
7. `resource-scheme-streaming-and-limits`
|
||||
8. `pagination-webview-hardening`
|
||||
9. `cache-hardening`
|
||||
10. `sdk-contract-and-test-hardening`
|
||||
|
||||
原因:
|
||||
|
||||
- 任务 2 是已确认的重复 UI 状态写入问题,收益最高、风险相对低,应优先处理。
|
||||
- 任务 1 更偏“状态模型收口优化”,问题真实存在,但严重度低于任务 2。
|
||||
- 第 3-6 项收口默认安全行为,改动局部、收益高。
|
||||
- 第 7-9 项涉及资源与分页底层,副作用更大,放在前面稳定后更容易验证。
|
||||
- 最后一项负责把前面改动固化为稳定契约和测试基线。
|
||||
|
||||
---
|
||||
|
||||
## 3. 任务清单
|
||||
|
||||
## 任务 1:`selection-state-refactor`
|
||||
|
||||
### 3.1 目标
|
||||
|
||||
在不破坏当前清晰分层的前提下,收口选区相关状态的冗余表达,降低 view 层与 controller 层各持有一份选区状态所带来的维护成本和时序问题。
|
||||
|
||||
### 3.2 主要问题代码
|
||||
|
||||
- `Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextSelectionController.swift`
|
||||
- `Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift`
|
||||
- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderAnnotationCoordinator.swift`
|
||||
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift`
|
||||
|
||||
### 3.3 修改方案
|
||||
|
||||
当前代码并不是“混乱散写”,而是相对清晰的分层流:
|
||||
|
||||
- gesture
|
||||
- view
|
||||
- coordinator
|
||||
- context/controller
|
||||
|
||||
其中真正值得优化的点主要有两个:
|
||||
|
||||
1. view 层和 controller 层各自持有一份 `currentSelection`
|
||||
2. `menuSelection` 是为 `UIMenuController` 时序保底引入的 workaround,属于合理存在,但可以尝试被更明确的状态模型替代
|
||||
|
||||
新增一个统一的选区状态模型仍然是可行方案,例如:
|
||||
|
||||
```swift
|
||||
enum RDEPUBSelectionState: Equatable {
|
||||
case idle
|
||||
case selecting(anchor: Int)
|
||||
case selected(RDEPUBSelection)
|
||||
case committingAction(RDEPUBSelection, action: RDEPUBAnnotationMenuAction)
|
||||
}
|
||||
```
|
||||
|
||||
建议新增文件:
|
||||
|
||||
- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBSelectionState.swift`
|
||||
|
||||
建议在 `RDEPUBReaderAnnotationCoordinator` 内新增或抽出一个单一入口:
|
||||
|
||||
- `applySelectionState(_ state: RDEPUBSelectionState)`
|
||||
- `currentSelectionState`
|
||||
|
||||
具体修改:
|
||||
|
||||
1. `RDEPUBTextSelectionController`
|
||||
- 移除“自己就是最终状态源”的职责。
|
||||
- 保留字符范围计算与 `RDEPUBSelection?` 构建。
|
||||
- `isSelecting` 可保留为手势内部临时状态,但不再作为 UI 的最终依据。
|
||||
|
||||
2. `RDEPUBTextContentView`
|
||||
- 不再直接通过 `currentSelection != nil` 作为所有 UI 行为的唯一判断条件。
|
||||
- 长按、拖拽、结束只上报“开始选择 / 更新选择 / 结束选择 / 取消选择”事件。
|
||||
- `menuSelection` 不建议直接删除。
|
||||
- 第一版应先保留 `menuSelection`,并把它明确标注为菜单时序缓冲状态。
|
||||
- 待状态模型稳定后,再评估是否能安全移除。
|
||||
|
||||
3. `RDEPUBReaderAnnotationCoordinator.updateCurrentSelection(_:)`
|
||||
- 改造成 `applySelectionState(_:)`。
|
||||
- 在 `.selected` 时统一:
|
||||
- 更新 `controller.currentSelection`
|
||||
- 决定是否展示工具栏
|
||||
- 更新底部高亮按钮可用性
|
||||
- 通知 delegate
|
||||
- 在 `.idle` 时统一清理:
|
||||
- 清空 `controller.currentSelection`
|
||||
- 关闭菜单
|
||||
- 刷新按钮状态
|
||||
|
||||
4. `RDEPUBReaderController`
|
||||
- `currentSelection` 继续保留为公开只读语义,但底层来源改为 `selectionState` 推导,而不是多处直接赋值。
|
||||
|
||||
### 3.4 实施步骤
|
||||
|
||||
1. 新增 `RDEPUBSelectionState.swift`
|
||||
2. 在 `RDEPUBReaderAnnotationCoordinator` 中接管选区状态
|
||||
3. 重写 `RDEPUBTextContentView` 中选区相关回调
|
||||
4. 先收口零散的 `currentSelection != nil` 控制逻辑
|
||||
5. 保留 `menuSelection`,待验证不再需要后再移除
|
||||
6. 统一 delegate 通知路径
|
||||
|
||||
### 3.5 风险点
|
||||
|
||||
- 选区菜单弹出时机可能变化。
|
||||
- `menuSelection` 是现有时序补丁,若贸然删除,菜单动作可能拿不到正确 selection。
|
||||
- Web 路径和 Native Text 路径都要走统一状态流,避免只修一条。
|
||||
|
||||
### 3.6 验收标准
|
||||
|
||||
- 长按后必然进入“选区中”状态。
|
||||
- 松手后有有效文本时必然进入“已选中”状态。
|
||||
- 复制/高亮/批注后必然回到空闲状态。
|
||||
- `selection=1` 的 Demo 状态与按钮启用状态始终一致。
|
||||
|
||||
### 3.7 优先级修正
|
||||
|
||||
本任务应视为“结构优化 + 降低后续维护成本”,不是当前代码库里最严重的缺陷。建议优先级下调到与任务 2 同级,排在任务 2 之后实施。
|
||||
|
||||
---
|
||||
|
||||
## 任务 2:`bookmark-chrome-state-unification`
|
||||
|
||||
### 3.8 目标
|
||||
|
||||
统一顶部书签按钮、底部书签按钮、高亮按钮、添加高亮按钮的可用状态计算,避免不同协调器各自改一部分 UI。
|
||||
|
||||
### 3.9 主要问题代码
|
||||
|
||||
- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderChromeCoordinator.swift`
|
||||
- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderAnnotationCoordinator.swift`
|
||||
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderTopToolView.swift`
|
||||
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderBottomToolView.swift`
|
||||
|
||||
### 3.10 修改方案
|
||||
|
||||
新增统一 UI 状态模型:
|
||||
|
||||
```swift
|
||||
struct RDEPUBReaderUIState {
|
||||
let canToggleBookmark: Bool
|
||||
let hasBookmarkAtCurrentLocation: Bool
|
||||
let canShowBookmarks: Bool
|
||||
let canAddHighlight: Bool
|
||||
let canShowHighlights: Bool
|
||||
}
|
||||
```
|
||||
|
||||
建议新增文件:
|
||||
|
||||
- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderUIState.swift`
|
||||
|
||||
具体修改:
|
||||
|
||||
1. `RDEPUBReaderChromeCoordinator`
|
||||
- 新增 `makeUIState()` 和 `applyUIState(_:)`
|
||||
- `updateReaderChrome()` 内不再直接散写多个 `setXxxEnabled`
|
||||
|
||||
2. `RDEPUBReaderAnnotationCoordinator`
|
||||
- `updateBookmarkChrome()` 不再直接写 view,改为触发 `context.updateReaderChrome()`
|
||||
|
||||
3. `RDEPUBReaderTopToolView` / `RDEPUBReaderBottomToolView`
|
||||
- 保持 dumb view,只接收状态,不参与规则判断
|
||||
|
||||
### 3.11 实施步骤
|
||||
|
||||
1. 新增 `RDEPUBReaderUIState.swift`
|
||||
2. 改造 `updateReaderChrome()`
|
||||
3. 不要简单删除 `AnnotationCoordinator` 内的按钮状态刷新
|
||||
4. 区分两类触发时机:
|
||||
- `ChromeCoordinator` 负责整页/整轮 chrome 同步
|
||||
- `AnnotationCoordinator` 负责用户操作后的即时反馈触发
|
||||
5. 将二者统一收口到同一个状态计算入口,例如 `context.updateReaderChrome()`
|
||||
6. 调整书签新增/删除/跳转后的刷新入口为统一 `updateReaderChrome()`
|
||||
|
||||
### 3.12 风险点
|
||||
|
||||
- 如果简单删除 `AnnotationCoordinator` 内的调用,可能丢失“用户操作后立即反馈”的刷新时机。
|
||||
- 如果某些按钮当前依赖副作用显示,需要顺便梳理显示时机。
|
||||
|
||||
### 3.13 验收标准
|
||||
|
||||
- 添加第一个书签后,底部书签列表按钮立即可用。
|
||||
- 删除最后一个书签后,底部书签列表按钮立即禁用。
|
||||
- 当前位置已加书签时,顶部书签按钮始终是选中态。
|
||||
|
||||
---
|
||||
|
||||
## 任务 3:`external-link-policy-hardening`
|
||||
|
||||
### 3.14 目标
|
||||
|
||||
把“外链点击后直接打开系统应用”改成受控策略,避免 SDK 默认行为过于激进。
|
||||
|
||||
### 3.15 主要问题代码
|
||||
|
||||
- `Sources/RDReaderView/EPUBCore/RDEPUBWebView+JavaScriptBridge.swift`
|
||||
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift`
|
||||
- `Sources/RDReaderView/EPUBUI/Settings/RDEPUBReaderConfiguration.swift`
|
||||
|
||||
### 3.16 修改方案
|
||||
|
||||
在 `RDEPUBReaderConfiguration` 新增:
|
||||
|
||||
- `allowedExternalURLSchemes: Set<String>`
|
||||
- `requiresExternalLinkConfirmation: Bool`
|
||||
- 可选:`allowedExternalURLHosts: Set<String>?`
|
||||
|
||||
默认值建议:
|
||||
|
||||
- scheme 仅允许 `https`
|
||||
- 需要确认框
|
||||
|
||||
具体修改:
|
||||
|
||||
1. `RDEPUBWebView+JavaScriptBridge.swift`
|
||||
- 继续识别外链,但不再表示“允许直接打开”。
|
||||
- 只负责把外链事件上抛。
|
||||
|
||||
2. `RDEPUBReaderController+ContentDelegates.swift`
|
||||
- 新增私有方法:
|
||||
- `shouldAllowExternalURL(_:)`
|
||||
- `presentExternalLinkConfirmation(for:)`
|
||||
- `openExternalURLIfAllowed(_:)`
|
||||
- `didActivateExternalLink` 改为:
|
||||
- 先 delegate 回调
|
||||
- 再走配置校验
|
||||
- 再弹确认框
|
||||
- 最后调用 `UIApplication.shared.open`
|
||||
|
||||
3. 可选扩展 `RDEPUBReaderDelegate`
|
||||
- 增加可选拦截钩子:
|
||||
- `epubReader(_:shouldOpenExternalURL:) -> Bool`
|
||||
|
||||
### 3.17 实施步骤
|
||||
|
||||
1. 改配置模型与默认值
|
||||
2. 改控制器外链处理逻辑
|
||||
3. 增加确认对话框
|
||||
4. 增加测试用 mock URL 覆盖 `https`、`mailto`、未知 scheme`
|
||||
|
||||
### 3.18 风险点
|
||||
|
||||
- 某些 Demo 书可能依赖 `mailto` 或 `tel`,要明确它们现在默认不再直接打开。
|
||||
|
||||
### 3.19 验收标准
|
||||
|
||||
- `https` 外链在确认后打开。
|
||||
- 默认配置下 `mailto` 和 `tel` 不直接打开。
|
||||
- 未知 scheme 一律拒绝。
|
||||
|
||||
---
|
||||
|
||||
## 任务 4:`epub-archive-path-validation`
|
||||
|
||||
### 3.20 目标
|
||||
|
||||
为 EPUB 解压路径增加显式安全校验,防止恶意压缩包通过相对路径写出解压根目录。
|
||||
|
||||
### 3.21 主要问题代码
|
||||
|
||||
- `Sources/RDReaderView/EPUBCore/RDEPUBParser+Archive.swift`
|
||||
|
||||
### 3.22 修改方案
|
||||
|
||||
新增私有方法:
|
||||
|
||||
- `validatedExtractionDestination(for:extractionRoot:) -> URL?`
|
||||
|
||||
校验规则:
|
||||
|
||||
1. 拒绝绝对路径。
|
||||
2. 拒绝包含 `..` 的路径段。
|
||||
3. `standardizedFileURL.path` 必须位于 `extractionRoot.standardizedFileURL.path` 下。
|
||||
|
||||
具体修改:
|
||||
|
||||
1. `extractArchiveIfNeeded(epubURL:)`
|
||||
- 在循环内先调用 `validatedExtractionDestination`
|
||||
- 无效 entry 可:
|
||||
- 直接抛错终止解析,或
|
||||
- 记日志并跳过
|
||||
- 建议优先抛错,行为更清晰
|
||||
|
||||
2. 增加自定义错误:
|
||||
- 若当前 `RDEPUBParserError` 里没有合适 case,可新增 `invalidArchiveEntryPath(String)`
|
||||
|
||||
### 3.23 实施步骤
|
||||
|
||||
1. 增加路径校验方法
|
||||
2. 替换原始 `destinationURL` 生成逻辑
|
||||
3. 增加针对恶意 entry path 的单元测试
|
||||
|
||||
### 3.24 风险点
|
||||
|
||||
- 少数历史 EPUB 可能带奇怪路径分隔符,需要兼容性验证。
|
||||
|
||||
### 3.25 验收标准
|
||||
|
||||
- 正常 EPUB 仍可正常解压。
|
||||
- 带 `../` 的恶意 entry 会被拒绝。
|
||||
|
||||
---
|
||||
|
||||
## 任务 5:`webview-debug-sanitization`
|
||||
|
||||
### 3.26 目标
|
||||
|
||||
减少默认调试日志泄露阅读内容的风险,并关闭不必要的 inspectable 默认值。
|
||||
|
||||
### 3.27 主要问题代码
|
||||
|
||||
- `Sources/RDReaderView/EPUBCore/RDEPUBWebViewDebug.swift`
|
||||
- `Sources/RDReaderView/EPUBCore/RDEPUBPaginator.swift`
|
||||
- `Sources/RDReaderView/EPUBCore/RDEPUBWebView+Configuration.swift`
|
||||
|
||||
### 3.28 修改方案
|
||||
|
||||
1. `RDEPUBWebViewDebug`
|
||||
- `logMessage` 改为只打印:
|
||||
- message name
|
||||
- 字段名列表
|
||||
- 文本长度
|
||||
- URL 摘要
|
||||
- 不再打印完整 message body
|
||||
|
||||
2. 新增更细粒度配置
|
||||
- `RDEPUBWebViewDebugEnabled`
|
||||
- `RDEPUBVerboseWebViewDebugEnabled`
|
||||
|
||||
3. `RDEPUBPaginator` 与正常阅读 WebView
|
||||
- `isInspectable` 改为受配置控制
|
||||
- 默认关闭
|
||||
|
||||
4. `RDEPUBReaderConfiguration`
|
||||
- 增加:
|
||||
- `allowsInspectableWebViews`
|
||||
- `enablesVerboseWebViewLogging`
|
||||
|
||||
### 3.29 实施步骤
|
||||
|
||||
1. 改日志输出格式
|
||||
2. 改 inspectable 的默认逻辑
|
||||
3. 为 verbose 模式保留调试后门
|
||||
|
||||
### 3.30 风险点
|
||||
|
||||
- 调试某些 JS 问题时信息会变少,因此要保留显式 verbose 开关。
|
||||
|
||||
### 3.31 验收标准
|
||||
|
||||
- 默认 DEBUG 包不输出完整选中文本。
|
||||
- 默认分页 WebView 不可 inspect。
|
||||
- 打开 verbose 开关后仍能深度调试。
|
||||
|
||||
---
|
||||
|
||||
## 任务 6:`persistence-defaults-hardening`
|
||||
|
||||
### 3.32 目标
|
||||
|
||||
降低默认 `UserDefaults` 持久化的误用风险和数据膨胀风险,同时不破坏当前 API 可用性。
|
||||
|
||||
### 3.33 主要问题代码
|
||||
|
||||
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderPersistence.swift`
|
||||
- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderAnnotationCoordinator.swift`
|
||||
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift`
|
||||
|
||||
### 3.34 修改方案
|
||||
|
||||
短期目标:
|
||||
|
||||
1. 明确 `RDEPUBUserDefaultsPersistence` 是轻量实现。
|
||||
2. 高亮持久化优先依赖:
|
||||
- `location`
|
||||
- `rangeInfo`
|
||||
- `style`
|
||||
- `note`
|
||||
3. `text` 字段作为冗余缓存,而不是唯一恢复依据。
|
||||
|
||||
中期可选目标:
|
||||
|
||||
新增文件持久化实现:
|
||||
|
||||
- `Sources/RDReaderView/EPUBUI/RDEPUBFilePersistence.swift`
|
||||
|
||||
### 3.35 具体修改
|
||||
|
||||
1. `RDEPUBReaderPersistence.swift`
|
||||
- 为默认实现增加 DEBUG 警告,提示 bookmarks/settings 默认实现是 no-op
|
||||
|
||||
2. `RDEPUBUserDefaultsPersistence`
|
||||
- 增加大小保护,例如当高亮集合序列化后超阈值时打印告警
|
||||
- 可选增加版本字段,便于后续迁移
|
||||
|
||||
3. `RDEPUBReaderController`
|
||||
- 初始化默认 persistence 时,在注释与命名上明确这是默认轻量实现
|
||||
|
||||
### 3.36 实施步骤
|
||||
|
||||
1. 增加 warning/assert 机制
|
||||
2. 优化高亮持久化字段策略
|
||||
3. 视时间增加 `RDEPUBFilePersistence`
|
||||
|
||||
### 3.37 风险点
|
||||
|
||||
- 如果外部代码依赖 `text` 永远存在,需要做好兼容。
|
||||
|
||||
### 3.38 验收标准
|
||||
|
||||
- 未实现书签持久化时,DEBUG 下能看到明确提示。
|
||||
- 高亮即使 `text` 丢失,也可基于范围信息恢复定位。
|
||||
|
||||
---
|
||||
|
||||
## 任务 7:`resource-scheme-streaming-and-limits`
|
||||
|
||||
### 3.39 目标
|
||||
|
||||
降低 `WKURLSchemeHandler` 对大资源的内存冲击,避免每次都整块 `Data(contentsOf:)` 读入。
|
||||
|
||||
### 3.40 主要问题代码
|
||||
|
||||
- `Sources/RDReaderView/EPUBCore/RDEPUBResourceURLSchemeHandler.swift`
|
||||
|
||||
### 3.41 修改方案
|
||||
|
||||
分两步做:
|
||||
|
||||
第一步,先加大小阈值和告警:
|
||||
|
||||
- 读取文件属性获取大小
|
||||
- 超过阈值时走分块发送
|
||||
|
||||
第二步,再补流式发送:
|
||||
|
||||
- 使用 `FileHandle` 或 `InputStream`
|
||||
- 按固定 chunk size 反复 `didReceive(data)`
|
||||
|
||||
建议新增私有方法:
|
||||
|
||||
- `resourceMetadata(for:)`
|
||||
- `respondWithStreaming(fileURL:requestURL:taskID:urlSchemeTask:)`
|
||||
- `respondWithInMemoryData(...)`
|
||||
|
||||
### 3.42 实施步骤
|
||||
|
||||
1. 先加文件大小检测
|
||||
2. 小文件保留内存直读
|
||||
3. 大文件切换到分块发送
|
||||
4. 增加取消任务时的中断判断
|
||||
|
||||
### 3.43 风险点
|
||||
|
||||
- `WKURLSchemeHandler` 分块发送要注意 stop 之后不继续回调。
|
||||
|
||||
### 3.44 验收标准
|
||||
|
||||
- 大图片/字体加载时内存峰值下降。
|
||||
- 取消任务不会继续发送数据。
|
||||
|
||||
---
|
||||
|
||||
## 任务 8:`pagination-webview-hardening`
|
||||
|
||||
### 3.45 目标
|
||||
|
||||
加强离屏分页 WebView 的边界控制,减少外部资源波动和调试副作用。
|
||||
|
||||
### 3.46 主要问题代码
|
||||
|
||||
- `Sources/RDReaderView/EPUBCore/RDEPUBPaginator.swift`
|
||||
|
||||
### 3.47 修改方案
|
||||
|
||||
1. 为分页 WebView 增加严格导航限制
|
||||
- 仅允许 `file://`
|
||||
- 仅允许 `ss-reader://`
|
||||
- 拒绝 `http/https`
|
||||
|
||||
2. 将 `isInspectable` 受配置控制
|
||||
|
||||
3. 增加测量稳定性日志
|
||||
- 记录三轮测量结果
|
||||
- 当三轮差异过大时告警
|
||||
|
||||
建议新增私有方法:
|
||||
|
||||
- `shouldAllowPaginatorNavigation(url:)`
|
||||
- `recordMeasurement(pass:value:)`
|
||||
|
||||
### 3.48 实施步骤
|
||||
|
||||
1. 扩展 `WKNavigationDelegate`
|
||||
2. 加入导航 allowlist
|
||||
3. 记录多轮测量差异
|
||||
|
||||
### 3.49 风险点
|
||||
|
||||
- 某些 EPUB 样式若依赖外链资源,分页结果可能与当前行为不同,但这本身就是更安全的策略。
|
||||
|
||||
### 3.50 验收标准
|
||||
|
||||
- 分页 WebView 不发起外部导航。
|
||||
- 三轮测量波动可观测。
|
||||
|
||||
---
|
||||
|
||||
## 任务 9:`cache-hardening`
|
||||
|
||||
### 3.51 目标
|
||||
|
||||
提升章节摘要磁盘缓存的健壮性、可观测性和可清理能力。
|
||||
|
||||
### 3.52 主要问题代码
|
||||
|
||||
- `Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterSummaryDiskCache.swift`
|
||||
|
||||
### 3.53 修改方案
|
||||
|
||||
1. 原子写入
|
||||
- 先写 `.tmp`
|
||||
- 再 replace 到正式文件
|
||||
|
||||
2. 读失败分类
|
||||
- 文件不存在
|
||||
- 读取失败
|
||||
- 解码失败
|
||||
|
||||
3. 精细化清理
|
||||
- `removeAll()`
|
||||
- `removeAll(forBookID:)`
|
||||
- `removeAll(forRenderSignature:)`
|
||||
|
||||
4. 增加统计接口
|
||||
- 缓存文件数
|
||||
- 总大小
|
||||
|
||||
### 3.54 实施步骤
|
||||
|
||||
1. 改写入策略
|
||||
2. 增加读失败日志
|
||||
3. 增加清理接口
|
||||
4. 补测试覆盖损坏文件、半写文件
|
||||
|
||||
### 3.55 风险点
|
||||
|
||||
- 清理策略接口若公开,需要评估是否作为 public API。
|
||||
|
||||
### 3.56 验收标准
|
||||
|
||||
- 缓存文件损坏不会影响整本书重新打开。
|
||||
- 能按需清理缓存,而不是只能全删。
|
||||
|
||||
---
|
||||
|
||||
## 任务 10:`sdk-contract-and-test-hardening`
|
||||
|
||||
### 3.57 目标
|
||||
|
||||
把前面所有修复固化为更清晰的 SDK 契约和更稳定的测试观测点。
|
||||
|
||||
### 3.58 主要问题代码
|
||||
|
||||
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderPersistence.swift`
|
||||
- `Sources/RDReaderView/EPUBUI/RDURLReaderController.swift`
|
||||
- `ReadViewDemo/ReadViewDemoUITests/`
|
||||
|
||||
### 3.59 修改方案
|
||||
|
||||
1. 强化 persistence 契约
|
||||
- 继续保留默认实现,但在 DEBUG 下对 no-op 行为给出明确信号
|
||||
|
||||
2. Demo 状态能力修正
|
||||
- 当前仓库并非完全没有结构化状态。
|
||||
- `ReadViewDemo/ReadViewDemoUITests/Helpers/DemoReaderState.swift` 已经提供了状态解析器和 `waitForDemoReaderState(...)` 结构化等待能力。
|
||||
- 真正需要推进的不是再造一个 `DemoReaderSnapshot`,而是把仍然大量存在的 `waitForReaderState(containing:)` 迁移到结构化断言。
|
||||
|
||||
建议方向:
|
||||
|
||||
- 继续保留隐藏 label 输出
|
||||
- 保持 `key=value` 格式
|
||||
- 统一测试侧只通过 `DemoReaderState` 解析和断言
|
||||
- 逐步淘汰 `containing:` 子串匹配,避免 `highlights=1` 命中 `highlights=10` 之类误判
|
||||
|
||||
3. 补测试
|
||||
- 单元测试:
|
||||
- `RDEPUBParserArchiveSafetyTests`
|
||||
- `RDEPUBExternalLinkPolicyTests`
|
||||
- `RDEPUBChapterSummaryDiskCacheTests`
|
||||
- UI 测试:
|
||||
- 选区状态流
|
||||
- 书签启用/禁用
|
||||
- 外链确认框
|
||||
|
||||
### 3.60 实施步骤
|
||||
|
||||
1. 改 Demo 状态串生成逻辑
|
||||
2. 优先将 `waitForReaderState(containing:)` 迁移为 `waitForDemoReaderState(...)`
|
||||
3. 调整 UI 测试断言
|
||||
4. 增加单元测试 target 或现有测试目录内的新文件
|
||||
|
||||
### 3.61 风险点
|
||||
|
||||
- 现有 UI 测试如果强依赖旧状态串,需要同步迁移。
|
||||
|
||||
### 3.62 验收标准
|
||||
|
||||
- UI 测试失败时能明确知道是选区状态、书签状态还是导航状态失配。
|
||||
- persistence 的默认 no-op 行为在开发期不再“静默”。
|
||||
|
||||
---
|
||||
|
||||
## 4. 补充清理项
|
||||
|
||||
以下问题已在代码中可见,但未纳入前 10 个主任务,建议作为补充任务或穿插清理项处理。
|
||||
|
||||
### 4.1 `pageBreakBefore/pageBreakAfter` 相关死代码审计
|
||||
|
||||
涉及代码:
|
||||
|
||||
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift`
|
||||
- `Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBSemanticMarkerInjector.swift`
|
||||
- `Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBPageBreakPolicy.swift`
|
||||
- `Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBChapterPageCounter.swift`
|
||||
|
||||
建议动作:
|
||||
|
||||
- 审计 `pageBreakBefore/pageBreakAfter` 从注入到消费的完整链路
|
||||
- 判断是否有“已定义、已注入、但实际无有效触发”的死路径
|
||||
- 若确认无效,删除或补测试锁定其真实行为
|
||||
|
||||
### 4.2 `PAGINATION-DEBUG` 输出清理
|
||||
|
||||
涉及代码:
|
||||
|
||||
- `Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBCoreTextPageFrameFactory.swift`
|
||||
- `Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBChapterPageCounter.swift`
|
||||
|
||||
建议动作:
|
||||
|
||||
- 将裸 `print` 改为受控调试开关
|
||||
- 默认关闭
|
||||
- 避免分页细节和正文片段直接进入控制台
|
||||
|
||||
### 4.3 `ChapterRuntime` 旧代码与遗留路径清理
|
||||
|
||||
建议动作:
|
||||
|
||||
- 梳理 `ChapterRuntime` 下是否存在已不再被主流程使用的辅助类型或过渡实现
|
||||
- 对“仍被引用但语义已经过时”的代码先补注释标记
|
||||
- 对“完全无调用”的代码建立删除候选清单
|
||||
|
||||
---
|
||||
|
||||
## 5. 推荐里程碑切分
|
||||
|
||||
### 里程碑 A:核心交互稳定
|
||||
|
||||
包含任务:
|
||||
|
||||
- `selection-state-refactor`
|
||||
- `bookmark-chrome-state-unification`
|
||||
|
||||
完成标准:
|
||||
|
||||
- 选区、高亮、书签相关 UI 测试恢复稳定
|
||||
|
||||
### 里程碑 B:默认安全行为收口
|
||||
|
||||
包含任务:
|
||||
|
||||
- `external-link-policy-hardening`
|
||||
- `epub-archive-path-validation`
|
||||
- `webview-debug-sanitization`
|
||||
- `persistence-defaults-hardening`
|
||||
|
||||
完成标准:
|
||||
|
||||
- 默认配置下不再直接打开高风险外链
|
||||
- 解压路径具备显式校验
|
||||
- 默认调试日志不泄露完整阅读内容
|
||||
|
||||
### 里程碑 C:底层稳定性优化
|
||||
|
||||
包含任务:
|
||||
|
||||
- `resource-scheme-streaming-and-limits`
|
||||
- `pagination-webview-hardening`
|
||||
- `cache-hardening`
|
||||
|
||||
完成标准:
|
||||
|
||||
- 大资源加载和分页更稳定
|
||||
- 缓存更健壮、可清理
|
||||
|
||||
### 里程碑 D:契约与测试固化
|
||||
|
||||
包含任务:
|
||||
|
||||
- `sdk-contract-and-test-hardening`
|
||||
|
||||
完成标准:
|
||||
|
||||
- SDK 默认行为更清晰
|
||||
- 回归测试对关键状态具备稳定观测能力
|
||||
|
||||
---
|
||||
|
||||
## 6. 建议的开发节奏
|
||||
|
||||
若按 2 周一个小迭代,可参考:
|
||||
|
||||
1. 第 1 周:`bookmark-chrome-state-unification` + `selection-state-refactor`
|
||||
2. 第 2 周:`external-link-policy-hardening` + `epub-archive-path-validation` + `webview-debug-sanitization`
|
||||
3. 第 3 周:`persistence-defaults-hardening` + `resource-scheme-streaming-and-limits` + `pagination-webview-hardening`
|
||||
4. 第 4 周:`cache-hardening` + `sdk-contract-and-test-hardening` 与回归收口
|
||||
|
||||
如果只想先做最值的部分,建议最小交付集合为:
|
||||
|
||||
1. `bookmark-chrome-state-unification`
|
||||
2. `selection-state-refactor`
|
||||
3. `external-link-policy-hardening`
|
||||
4. `epub-archive-path-validation`
|
||||
|
||||
这四项完成后,阅读器的“主观可用性”和“默认安全性”会先提升一个台阶。
|
||||
2
Podfile
2
Podfile
@ -5,7 +5,7 @@ workspace 'ReadViewSDK.xcworkspace'
|
||||
project 'ReadViewSDK.xcodeproj'
|
||||
|
||||
target 'ReadViewSDK' do
|
||||
pod 'RDEpubReaderView', :path => '..'
|
||||
pod 'RDReaderView', :path => '..'
|
||||
end
|
||||
|
||||
post_install do |installer|
|
||||
|
||||
@ -1,19 +0,0 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = "RDEpubReaderView"
|
||||
s.module_name = "RDEpubReaderView"
|
||||
s.version = "0.0.1"
|
||||
s.summary = "A reader view for EPUB and plain-text books"
|
||||
s.platform = :ios, "15.0"
|
||||
s.swift_versions = ["5.10"]
|
||||
s.homepage = "http://192.168.21.200:8418/4v5u09Z5a4Yuc/ReadViewSDK.git"
|
||||
s.author = { "shenlei" => "shenlei@touchread.com" }
|
||||
s.source = { :git => "http://192.168.21.200:8418/4v5u09Z5a4Yuc/ReadViewSDK.git", :tag => "#{s.version}" }
|
||||
s.license = "MIT"
|
||||
s.source_files = "Sources/RDEpubReaderView/**/*.{swift}"
|
||||
s.resource_bundles = {
|
||||
"RDEpubReaderViewAssets" => ["Sources/RDEpubReaderView/EPUBCore/Resources/**/*"]
|
||||
}
|
||||
s.dependency "ZIPFoundation", "~> 0.9"
|
||||
s.dependency "DTCoreText", "~> 1.6"
|
||||
s.requires_arc = true
|
||||
end
|
||||
23
RDReaderView.podspec
Normal file
23
RDReaderView.podspec
Normal file
@ -0,0 +1,23 @@
|
||||
|
||||
|
||||
Pod::Spec.new do |s|
|
||||
s.name = "RDReaderView"
|
||||
s.version = "0.0.1"
|
||||
s.summary = "A reader view for novel"
|
||||
s.platform = :ios, "15.0"
|
||||
s.swift_versions = ["5.10"]
|
||||
s.homepage = "https://github.com/namesubai/RDReaderView.git"
|
||||
s.author = { "subai" => "804663401@qq.com" }
|
||||
s.source = { :git => "https://github.com/namesubai/RDReaderView.git", :tag => "#{s.version}"}
|
||||
s.license = "MIT"
|
||||
s.source_files = 'Sources/RDReaderView/**/*.{swift}'
|
||||
s.resource_bundles = {
|
||||
'RDReaderViewAssets' => ['Sources/RDReaderView/EPUBCore/Resources/**/*']
|
||||
}
|
||||
s.dependency 'ZIPFoundation', '~> 0.9'
|
||||
s.dependency 'DTCoreText'
|
||||
s.dependency 'SnapKit'
|
||||
s.dependency 'SSAlertSwift'
|
||||
s.requires_arc = true
|
||||
|
||||
end
|
||||
@ -3,14 +3,14 @@ platform :ios, '15.6'
|
||||
target 'ReadViewDemo' do
|
||||
# Comment the next line if you don't want to use dynamic frameworks
|
||||
use_frameworks!
|
||||
pod 'RDEpubReaderView', :path => '..'
|
||||
pod 'RDReaderView', :path => '..'
|
||||
|
||||
end
|
||||
|
||||
post_install do |installer|
|
||||
apply_settings = lambda do |config|
|
||||
config.build_settings['ENABLE_USER_SCRIPT_SANDBOXING'] = 'NO'
|
||||
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0'
|
||||
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.6'
|
||||
end
|
||||
|
||||
# Pods project (Pods.xcodeproj)
|
||||
|
||||
@ -16,30 +16,38 @@ PODS:
|
||||
- DTFoundation/Core
|
||||
- DTFoundation/UIKit (1.7.19):
|
||||
- DTFoundation/Core
|
||||
- RDEpubReaderView (0.0.1):
|
||||
- DTCoreText (~> 1.6)
|
||||
- RDReaderView (0.0.1):
|
||||
- DTCoreText
|
||||
- SnapKit
|
||||
- SSAlertSwift
|
||||
- ZIPFoundation (~> 0.9)
|
||||
- SnapKit (5.7.1)
|
||||
- SSAlertSwift (0.0.15)
|
||||
- ZIPFoundation (0.9.20)
|
||||
|
||||
DEPENDENCIES:
|
||||
- RDEpubReaderView (from `..`)
|
||||
- RDReaderView (from `..`)
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
- DTCoreText
|
||||
- DTFoundation
|
||||
- SnapKit
|
||||
- SSAlertSwift
|
||||
- ZIPFoundation
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
RDEpubReaderView:
|
||||
RDReaderView:
|
||||
:path: ".."
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
|
||||
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
|
||||
RDEpubReaderView: 5ff99b803f5a46e5ff4ebef3024dd217284c69ed
|
||||
RDReaderView: 54205c82c62ea6c674b43c833642d2643c98c573
|
||||
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
|
||||
SSAlertSwift: aad8dc0c20b36fcffe700b81d7be89d60c7ba4f1
|
||||
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
|
||||
|
||||
PODFILE CHECKSUM: adda84af3e8577b2a99d5b2cecf381511352f0bd
|
||||
PODFILE CHECKSUM: 775f5c8c488024e24d494aad6331e9fef8f9e2e5
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
@ -1,37 +0,0 @@
|
||||
{
|
||||
"name": "RDEpubReaderView",
|
||||
"module_name": "RDEpubReaderView",
|
||||
"version": "0.0.1",
|
||||
"summary": "A reader view for EPUB and plain-text books",
|
||||
"platforms": {
|
||||
"ios": "15.0"
|
||||
},
|
||||
"swift_versions": [
|
||||
"5.10"
|
||||
],
|
||||
"homepage": "http://192.168.21.200:8418/4v5u09Z5a4Yuc/ReadViewSDK.git",
|
||||
"authors": {
|
||||
"shenlei": "shenlei@touchread.com"
|
||||
},
|
||||
"source": {
|
||||
"git": "http://192.168.21.200:8418/4v5u09Z5a4Yuc/ReadViewSDK.git",
|
||||
"tag": "0.0.1"
|
||||
},
|
||||
"license": "MIT",
|
||||
"source_files": "Sources/RDEpubReaderView/**/*.{swift}",
|
||||
"resource_bundles": {
|
||||
"RDEpubReaderViewAssets": [
|
||||
"Sources/RDEpubReaderView/EPUBCore/Resources/**/*"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"ZIPFoundation": [
|
||||
"~> 0.9"
|
||||
],
|
||||
"DTCoreText": [
|
||||
"~> 1.6"
|
||||
]
|
||||
},
|
||||
"requires_arc": true,
|
||||
"swift_version": "5.10"
|
||||
}
|
||||
36
ReadViewDemo/Pods/Local Podspecs/RDReaderView.podspec.json
generated
Normal file
36
ReadViewDemo/Pods/Local Podspecs/RDReaderView.podspec.json
generated
Normal file
@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "RDReaderView",
|
||||
"version": "0.0.1",
|
||||
"summary": "A reader view for novel",
|
||||
"platforms": {
|
||||
"ios": "15.0"
|
||||
},
|
||||
"swift_versions": [
|
||||
"5.10"
|
||||
],
|
||||
"homepage": "https://github.com/namesubai/RDReaderView.git",
|
||||
"authors": {
|
||||
"subai": "804663401@qq.com"
|
||||
},
|
||||
"source": {
|
||||
"git": "https://github.com/namesubai/RDReaderView.git",
|
||||
"tag": "0.0.1"
|
||||
},
|
||||
"license": "MIT",
|
||||
"source_files": "Sources/RDReaderView/**/*.{swift}",
|
||||
"resource_bundles": {
|
||||
"RDReaderViewAssets": [
|
||||
"Sources/RDReaderView/EPUBCore/Resources/**/*"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"ZIPFoundation": [
|
||||
"~> 0.9"
|
||||
],
|
||||
"DTCoreText": [],
|
||||
"SnapKit": [],
|
||||
"SSAlertSwift": []
|
||||
},
|
||||
"requires_arc": true,
|
||||
"swift_version": "5.10"
|
||||
}
|
||||
20
ReadViewDemo/Pods/Manifest.lock
generated
20
ReadViewDemo/Pods/Manifest.lock
generated
@ -16,30 +16,38 @@ PODS:
|
||||
- DTFoundation/Core
|
||||
- DTFoundation/UIKit (1.7.19):
|
||||
- DTFoundation/Core
|
||||
- RDEpubReaderView (0.0.1):
|
||||
- DTCoreText (~> 1.6)
|
||||
- RDReaderView (0.0.1):
|
||||
- DTCoreText
|
||||
- SnapKit
|
||||
- SSAlertSwift
|
||||
- ZIPFoundation (~> 0.9)
|
||||
- SnapKit (5.7.1)
|
||||
- SSAlertSwift (0.0.15)
|
||||
- ZIPFoundation (0.9.20)
|
||||
|
||||
DEPENDENCIES:
|
||||
- RDEpubReaderView (from `..`)
|
||||
- RDReaderView (from `..`)
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
- DTCoreText
|
||||
- DTFoundation
|
||||
- SnapKit
|
||||
- SSAlertSwift
|
||||
- ZIPFoundation
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
RDEpubReaderView:
|
||||
RDReaderView:
|
||||
:path: ".."
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
|
||||
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
|
||||
RDEpubReaderView: 5ff99b803f5a46e5ff4ebef3024dd217284c69ed
|
||||
RDReaderView: 54205c82c62ea6c674b43c833642d2643c98c573
|
||||
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
|
||||
SSAlertSwift: aad8dc0c20b36fcffe700b81d7be89d60c7ba4f1
|
||||
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
|
||||
|
||||
PODFILE CHECKSUM: adda84af3e8577b2a99d5b2cecf381511352f0bd
|
||||
PODFILE CHECKSUM: 775f5c8c488024e24d494aad6331e9fef8f9e2e5
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
5126
ReadViewDemo/Pods/Pods.xcodeproj/project.pbxproj
generated
5126
ReadViewDemo/Pods/Pods.xcodeproj/project.pbxproj
generated
File diff suppressed because it is too large
Load Diff
110
ReadViewDemo/Pods/SSAlertSwift/README.md
generated
Normal file
110
ReadViewDemo/Pods/SSAlertSwift/README.md
generated
Normal file
@ -0,0 +1,110 @@
|
||||
# SSAlert
|
||||
实际开发中经常遇到各种自定义的弹窗的需求,其中编写弹窗动画和遮罩就很繁琐,而且一个一个去写重复的代码,代码会变得更加冗余。
|
||||
|
||||
SSAlert封装了动画部分,而且支持自定义动画。可以快速构建弹窗,简单易用,基本可以满足大部分弹窗需求。自带常用自定义弹窗,类型系统的UIAlertView,UIActionSheet。
|
||||
# 特性
|
||||
- 支持OC、Swift版本
|
||||
- 支持自定义弹窗内容,可以根据弹窗内容frame布局和自动布局确定弹窗大小
|
||||
- 支持动画展示后刷新弹窗的大小
|
||||
- 支带透明、黑色、无三种背景遮罩
|
||||
- 自带从上、下、左、右、中弹出动画,动画支持弹簧效果,而且可以设置位置偏移量
|
||||
- 支持自定义动画,动画隐藏回调
|
||||
- 支持模态视图弹窗,支持拖拽隐藏
|
||||
- 自带类似系统的弹窗UIAlertView,UIActionSheet
|
||||
# 效果图
|
||||
<img src="https://github.com/namesubai/SSAlert/blob/main/demo.gif" width = 20% height = 20% />
|
||||
|
||||
# 使用
|
||||
|
||||
## 1.导入代码
|
||||
|
||||
### Objective-C项目
|
||||
|
||||
1. pod 'SSAlert'
|
||||
2. #import <SSAlert/SSAlert.h>
|
||||
### Swift项目
|
||||
|
||||
1. pod 'SSAlertSwift'
|
||||
2. import SSAlertSwift
|
||||
|
||||
## 2.SSAlertView用法
|
||||
|
||||
### SSAlertView的初始化
|
||||
|
||||
SSAlertView的初始化有两种,一种是普通弹窗,一种是模态视图弹窗,两种的区别在于,前者是弹出一个View,后者是弹窗一个UIViewController。当需要点击弹窗上按钮不关闭弹窗跳转界面时候,可以使用模态视图弹窗。
|
||||
|
||||
普通弹窗初始化:
|
||||
```
|
||||
let customView = UIView()
|
||||
customView.frame = CGRect(x:0,y:0,width:200,height:200)
|
||||
let alertView = SSAlertView(customView: customView, onView: navigationController!.view)
|
||||
```
|
||||
模态视图弹窗初始化:
|
||||
```
|
||||
let customView = UIView()
|
||||
customView.frame = CGRect(x:0,y:0,width:200,height:200)
|
||||
let alertView = SSAlertView(customView: customView, fromViewController: self)
|
||||
```
|
||||
### SSAlertView的动画设置
|
||||
|
||||
遵循 **SSAlertAnimation** 协议的自定义动画都可以设置 **SSAlertView** 的 **animation** 属性
|
||||
|
||||
**SSAlertView**自带的动画效果:**SSAlertDefaultAnmation**,提供上、下、左、右、中弹出动画等
|
||||
```
|
||||
let animation = SSAlertDefaultAnmation(state: .fromCenter)
|
||||
alertView.animation = animation
|
||||
```
|
||||
### SSAlertView的展示和隐藏
|
||||
```
|
||||
alertView.show()
|
||||
alertView.hide()
|
||||
```
|
||||
### SSAlertView的大小刷新
|
||||
```
|
||||
customView.frame = CGRect(x:0,y:0,width:400,height:400)
|
||||
alertView.refreshFrame()
|
||||
```
|
||||
|
||||
## 3.自带类型系统UIAlert,UIActionSheet的用法
|
||||
```
|
||||
///
|
||||
let alertView = SSAlertView.alertView(style: .alert, title: "自定义Alert弹窗", message: "自带自定义Alert弹窗,类似系统的UIAlertView", cancelButton: "Cancel", otherButtons: ["OK"], onView: navigationController!.view){ index in
|
||||
print(index)
|
||||
}
|
||||
alertView.show()
|
||||
|
||||
///
|
||||
let alertView = SSAlertView.modalAlertView(style: .actionSheet, title: "自定义ActionSheet弹窗", message: "自带自定义ActionSheet弹窗,类似系统的ActionSheet", cancelButton: "Cancel", otherButtons: ["action1", "action2", "action3", "action4"], fromViewController: self) { index in
|
||||
print(index)
|
||||
}
|
||||
alertView.show()
|
||||
```
|
||||
|
||||
## 4.自定义SSAlertCommonView
|
||||
```
|
||||
let action1 = SSAlertAction(style: .actionSheet, title: "自定义Action1") {
|
||||
print("自定义Action1")
|
||||
}
|
||||
action1.titleColor = .red
|
||||
action1.backgroundColor = .yellow
|
||||
action1.titleFont = UIFont.systemFont(ofSize: 18)
|
||||
action1.height = 80
|
||||
|
||||
let action2 = SSAlertAction(style: .actionSheet, title: "自定义Action2") {
|
||||
print("自定义Action2")
|
||||
}
|
||||
action2.titleColor = .yellow
|
||||
action2.backgroundColor = .red
|
||||
action2.titleFont = UIFont.systemFont(ofSize: 22)
|
||||
action2.height = 55
|
||||
|
||||
let commonView = SSAlertCommonView(title: "自定义SSAlertCommonView", message: "文本文本文本文本文本文本", style: .actionSheet, actions: [action1, action2])
|
||||
commonView.backgroundColor = .white
|
||||
let alertView = SSAlertView(customView: commonView, onView: navigationController!.view, animation: SSAlertDefaultAnmation(state: .fromBottom))
|
||||
alertView.show()
|
||||
```
|
||||
|
||||
更多用法请下载代码,观看[demo](https://github.com/namesubai/SSAlert),
|
||||
|
||||
觉得好用,手动点个Star❤️❤️❤️❤️
|
||||
|
||||
24
ReadViewDemo/Pods/SSAlertSwift/Sources/SSAlertSwift/SSAlertAnimation.swift
generated
Normal file
24
ReadViewDemo/Pods/SSAlertSwift/Sources/SSAlertSwift/SSAlertAnimation.swift
generated
Normal file
@ -0,0 +1,24 @@
|
||||
//
|
||||
// SSAlertAnimation.swift
|
||||
// SSAlert
|
||||
//
|
||||
// Created by yangsq on 2021/8/13.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
public typealias PanProgress = CGFloat
|
||||
public typealias PanCancelProgress = CGFloat
|
||||
public typealias DimissIsCancel = Bool
|
||||
public protocol SSAlertAnimation {
|
||||
/// 动画时间
|
||||
func animationDuration() -> TimeInterval
|
||||
/// 展示动画
|
||||
func showAnimationOfAnimationView(animationView: SSAlertView, viewSize: CGSize, animated: Bool, completion:(((Bool) -> Void))?)
|
||||
/// 隐藏动画
|
||||
func hideAnimationOfAnimationView(animationView: SSAlertView, viewSize: CGSize, animated: Bool, completion:(((Bool) -> Bool))?)
|
||||
/// 刷新大小动画
|
||||
func refreshAnimationOfAnimationView(animationView: SSAlertView, viewSize: CGSize, animated: Bool, completion:(((Bool) -> Void))?)
|
||||
/// 拖拽隐藏动画,当是模态视图弹窗才有
|
||||
func panToDimissTransilatePoint(point: CGPoint, panViewFrame: CGRect) -> (PanProgress, PanCancelProgress)
|
||||
}
|
||||
|
||||
46
ReadViewDemo/Pods/SSAlertSwift/Sources/SSAlertSwift/SSAlertAnimationController.swift
generated
Normal file
46
ReadViewDemo/Pods/SSAlertSwift/Sources/SSAlertSwift/SSAlertAnimationController.swift
generated
Normal file
@ -0,0 +1,46 @@
|
||||
//
|
||||
// SSAlertAnimationController.swift
|
||||
// SSAlert
|
||||
//
|
||||
// Created by yangsq on 2021/8/13.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
|
||||
class SSAlertAnimationController: UIViewController {
|
||||
var isDimiss: Bool = false
|
||||
var isHideStatusBar = false {
|
||||
didSet {
|
||||
setNeedsStatusBarAppearanceUpdate()
|
||||
}
|
||||
}
|
||||
var isShowNavWhenViewWillDisappear = true
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .clear
|
||||
}
|
||||
|
||||
override var prefersStatusBarHidden: Bool {
|
||||
return isHideStatusBar
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
navigationController?.setNavigationBarHidden(true, animated: animated)
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
if !isDimiss, isShowNavWhenViewWillDisappear {
|
||||
navigationController?.setNavigationBarHidden(false, animated: animated)
|
||||
}
|
||||
}
|
||||
|
||||
override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) {
|
||||
super.dismiss(animated: flag, completion: completion)
|
||||
isDimiss = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
335
ReadViewDemo/Pods/SSAlertSwift/Sources/SSAlertSwift/SSAlertCommonView.swift
generated
Normal file
335
ReadViewDemo/Pods/SSAlertSwift/Sources/SSAlertSwift/SSAlertCommonView.swift
generated
Normal file
@ -0,0 +1,335 @@
|
||||
//
|
||||
// SSAlertCommonView.swift
|
||||
// SSAlert
|
||||
//
|
||||
// Created by yangsq on 2021/8/13.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public extension SSAlertAction {
|
||||
enum Style {
|
||||
case alert
|
||||
case actionSheet
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public class SSAlertAction: NSObject {
|
||||
public var title: String
|
||||
public var titleFont = UIFont.systemFont(ofSize: 16)
|
||||
public var titleColor: UIColor? = nil
|
||||
public var height: CGFloat = 55
|
||||
public var backgroundColor: UIColor? = nil
|
||||
public private(set) var style: Style
|
||||
private var addAction:(() -> Void)? = nil
|
||||
public init(style: Style, title: String, addAction: (() -> Void)? = nil) {
|
||||
self.style = style
|
||||
self.title = title
|
||||
self.addAction = addAction
|
||||
if style == .alert {
|
||||
height = 50
|
||||
}
|
||||
super.init()
|
||||
}
|
||||
func triggerAction() {
|
||||
if let addAction = addAction {
|
||||
addAction()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
extension SSAlertCommonView {
|
||||
public static var alertMaxWidth = UIScreen.main.bounds.width * 0.7
|
||||
public static var actionSheetMaxWidth = UIScreen.main.bounds.width
|
||||
public static var buttonSpace: CGFloat = 0.5
|
||||
|
||||
public static var safeAreaBottomMargin: CGFloat {
|
||||
if #available(iOS 11.0, *) {
|
||||
return UIApplication.shared.keyWindow?.safeAreaInsets.bottom ?? 0
|
||||
}
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
public class SSAlertCommonView: UIView {
|
||||
|
||||
public lazy var titleLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.font = UIFont.systemFont(ofSize: 16)
|
||||
label.numberOfLines = 0
|
||||
label.textAlignment = .center
|
||||
return label
|
||||
}()
|
||||
|
||||
public lazy var messageLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.font = UIFont.systemFont(ofSize: 14)
|
||||
label.numberOfLines = 0
|
||||
label.textAlignment = .center
|
||||
return label
|
||||
}()
|
||||
|
||||
public lazy var containerView: UIView = {
|
||||
let view = UIView()
|
||||
return view
|
||||
}()
|
||||
|
||||
public lazy var buttonView: UIView = {
|
||||
let view = UIView()
|
||||
return view
|
||||
}()
|
||||
|
||||
public var lineColor: UIColor? = nil {
|
||||
didSet {
|
||||
lineArray.forEach { lineView in
|
||||
lineView.backgroundColor = lineColor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public var lineEdgeInsets: UIEdgeInsets = .zero {
|
||||
didSet {
|
||||
refreshFrame()
|
||||
}
|
||||
}
|
||||
public private(set) var style: SSAlertAction.Style
|
||||
public private(set) var actions: [SSAlertAction]
|
||||
public private(set) var title: String?
|
||||
public private(set) var message: String?
|
||||
|
||||
private var buttonArray: [UIButton] = []
|
||||
private var lineArray: [UIView] = []
|
||||
private var hasText = false
|
||||
|
||||
|
||||
public init(title: String? = nil, message: String? = nil, style: SSAlertAction.Style, actions:[SSAlertAction] = []) {
|
||||
self.title = title
|
||||
self.message = message
|
||||
self.style = style
|
||||
self.actions = actions
|
||||
super.init(frame: .zero)
|
||||
makeUI()
|
||||
}
|
||||
|
||||
private func makeUI() {
|
||||
addSubview(containerView)
|
||||
if let title = title, !title.isEmpty {
|
||||
titleLabel.text = title
|
||||
containerView.addSubview(titleLabel)
|
||||
hasText = true
|
||||
}
|
||||
if let message = message, !message.isEmpty {
|
||||
messageLabel.text = message
|
||||
containerView.addSubview(messageLabel)
|
||||
hasText = true
|
||||
}
|
||||
|
||||
if actions.count > 0 {
|
||||
addSubview(buttonView)
|
||||
actions.forEach { action in
|
||||
let button = UIButton(type: .system)
|
||||
button.setTitle(action.title, for: .normal)
|
||||
button.backgroundColor = backgroundColor
|
||||
if let backgroundColor = action.backgroundColor {
|
||||
button.backgroundColor = backgroundColor
|
||||
}
|
||||
button.titleLabel?.font = action.titleFont
|
||||
button.setTitleColor(action.titleColor, for: .normal)
|
||||
button.addTarget(self, action: #selector(buttonAction(button:)), for: .touchUpInside)
|
||||
buttonView.addSubview(button)
|
||||
buttonArray.append(button)
|
||||
var isAddLineView = false
|
||||
if style == .actionSheet {
|
||||
if hasText {
|
||||
isAddLineView = true
|
||||
} else {
|
||||
if actions[actions.count - 1] != action {
|
||||
isAddLineView = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if actions.count > 2 {
|
||||
if hasText {
|
||||
isAddLineView = true
|
||||
} else {
|
||||
if actions[actions.count - 1] != action {
|
||||
isAddLineView = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if actions.count == 1 {
|
||||
if hasText {
|
||||
isAddLineView = true
|
||||
}
|
||||
} else {
|
||||
if hasText {
|
||||
isAddLineView = true
|
||||
} else {
|
||||
if actions[actions.count - 1] != action {
|
||||
isAddLineView = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
if isAddLineView {
|
||||
let linView = UIView()
|
||||
linView.backgroundColor = UIColor.lightGray.withAlphaComponent(0.5)
|
||||
buttonView.addSubview(linView)
|
||||
lineArray.append(linView)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
refreshFrame()
|
||||
}
|
||||
|
||||
private func refreshFrame() {
|
||||
let space: CGFloat = 15
|
||||
let viewMaxWidth = style == .alert ? SSAlertCommonView.alertMaxWidth : SSAlertCommonView.actionSheetMaxWidth
|
||||
var height: CGFloat = 0
|
||||
let width = viewMaxWidth
|
||||
let containerWidth = viewMaxWidth - space * 2
|
||||
var originY: CGFloat = 0
|
||||
var cotainerOriginY: CGFloat = 0
|
||||
if titleLabel.superview != nil {
|
||||
let titleSize = titleLabel.sizeThatFits(CGSize(width: containerWidth, height: CGFloat(MAXFLOAT)))
|
||||
titleLabel.ss_origin = CGPoint(x: 0, y: cotainerOriginY)
|
||||
titleLabel.ss_size = CGSize(width: containerWidth, height: titleSize.height)
|
||||
height += (titleSize.height + space)
|
||||
cotainerOriginY += (space + titleSize.height)
|
||||
originY = space
|
||||
}
|
||||
|
||||
if messageLabel.superview != nil {
|
||||
let messageSize = messageLabel.sizeThatFits(CGSize(width: containerWidth, height: CGFloat(MAXFLOAT)))
|
||||
messageLabel.ss_origin = CGPoint(x: 0, y: cotainerOriginY)
|
||||
messageLabel.ss_size = CGSize(width: containerWidth, height: messageSize.height)
|
||||
height += (messageSize.height + space)
|
||||
cotainerOriginY += (space + messageSize.height)
|
||||
originY = space
|
||||
}
|
||||
|
||||
containerView.ss_origin = CGPoint(x: space, y: originY)
|
||||
containerView.ss_size = CGSize(width: containerWidth, height: height)
|
||||
originY += height
|
||||
|
||||
if !buttonArray.isEmpty {
|
||||
let buttonSpace: CGFloat = SSAlertCommonView.buttonSpace
|
||||
var buttonTotalHeight: CGFloat = 0
|
||||
var buttonTopY: CGFloat = 0
|
||||
if style == .actionSheet {
|
||||
for index in 0..<buttonArray.count {
|
||||
let action = actions[index]
|
||||
let button = buttonArray[index]
|
||||
if lineArray.count == buttonArray.count {
|
||||
let lineView = lineArray[index]
|
||||
lineView.frame = CGRect(x: lineEdgeInsets.left, y: buttonTopY, width: width - lineEdgeInsets.left - lineEdgeInsets.right, height: buttonSpace)
|
||||
buttonTopY += buttonSpace
|
||||
}
|
||||
|
||||
if lineArray.count == buttonArray.count - 1 {
|
||||
if index < lineArray.count {
|
||||
let lineView = lineArray[index]
|
||||
lineView.frame = CGRect(x: lineEdgeInsets.left, y: buttonTopY + action.height, width: width - lineEdgeInsets.left - lineEdgeInsets.right, height: buttonSpace)
|
||||
buttonTopY += buttonSpace
|
||||
}
|
||||
}
|
||||
|
||||
if index == buttonArray.count - 1 {
|
||||
button.ss_size = CGSize(width: width, height: action.height + SSAlertCommonView.safeAreaBottomMargin)
|
||||
button.titleEdgeInsets = UIEdgeInsets(top: -SSAlertCommonView.safeAreaBottomMargin, left: 0, bottom: 0, right: 0)
|
||||
} else {
|
||||
button.ss_size = CGSize(width: width, height: action.height)
|
||||
}
|
||||
button.ss_origin = CGPoint(x: 0, y: buttonTopY)
|
||||
buttonTopY += button.ss_h
|
||||
}
|
||||
buttonTotalHeight = buttonTopY
|
||||
}
|
||||
|
||||
if style == .alert {
|
||||
if buttonArray.count > 2 {
|
||||
buttonArray.forEach { button in
|
||||
let index = buttonArray.firstIndex(of: button)
|
||||
let action = actions[index!]
|
||||
|
||||
if lineArray.count == buttonArray.count {
|
||||
let lineView = lineArray[index!]
|
||||
lineView.frame = CGRect(x: lineEdgeInsets.left, y: buttonTopY, width: width - lineEdgeInsets.left - lineEdgeInsets.right, height: buttonSpace)
|
||||
buttonTopY += buttonSpace
|
||||
}
|
||||
if lineArray.count == buttonArray.count - 1 {
|
||||
if index! < lineArray.count {
|
||||
let lineView = lineArray[index!]
|
||||
lineView.frame = CGRect(x: lineEdgeInsets.left, y: buttonTopY + action.height, width: width - lineEdgeInsets.left - lineEdgeInsets.right, height: buttonSpace)
|
||||
buttonTopY += buttonSpace
|
||||
}
|
||||
}
|
||||
|
||||
button.ss_origin = CGPoint(x: 0, y: buttonTopY)
|
||||
button.ss_size = CGSize(width: width, height: action.height)
|
||||
buttonTopY += action.height
|
||||
}
|
||||
buttonTotalHeight = buttonTopY
|
||||
} else {
|
||||
if buttonArray.count == 1 {
|
||||
let button = buttonArray.first!
|
||||
let action = actions.first!
|
||||
if lineArray.count > 0 {
|
||||
let lineView = lineArray.first!
|
||||
lineView.frame = CGRect(x: lineEdgeInsets.left, y: buttonTopY, width: width - lineEdgeInsets.left - lineEdgeInsets.right, height: buttonSpace)
|
||||
buttonTopY += buttonSpace
|
||||
}
|
||||
button.ss_origin = CGPoint(x: 0, y: buttonTopY)
|
||||
button.ss_size = CGSize(width: width, height: action.height)
|
||||
buttonTotalHeight = buttonTopY
|
||||
} else {
|
||||
let button1 = buttonArray.first!
|
||||
let button2 = buttonArray.last!
|
||||
let action1 = actions.first!
|
||||
let action2 = actions.last!
|
||||
let maxButtonHeight = max(action1.height, action2.height)
|
||||
if hasText {
|
||||
let lineView1 = lineArray.first!
|
||||
lineView1.frame = CGRect(x: 0, y: buttonTopY, width: width, height: buttonSpace)
|
||||
buttonTopY += buttonSpace
|
||||
}
|
||||
|
||||
button1.ss_origin = CGPoint(x: 0, y: buttonTopY)
|
||||
button1.ss_size = CGSize(width: width / 2 - buttonSpace / 2, height: maxButtonHeight)
|
||||
|
||||
|
||||
button2.ss_origin = CGPoint(x: button1.frame.maxX + buttonSpace, y: buttonTopY)
|
||||
button2.ss_size = CGSize(width: width / 2 - buttonSpace / 2, height: maxButtonHeight)
|
||||
|
||||
let lineView2 = lineArray.last!
|
||||
lineView2.frame = CGRect(x: button1.frame.maxX, y: buttonSpace, width: buttonSpace, height: button1.ss_h)
|
||||
buttonTopY += maxButtonHeight
|
||||
}
|
||||
buttonTotalHeight = buttonTopY
|
||||
}
|
||||
}
|
||||
|
||||
buttonView.ss_origin = CGPoint(x: 0, y: originY)
|
||||
buttonView.ss_size = CGSize(width: width, height: buttonTotalHeight)
|
||||
height += buttonTotalHeight + (originY > 0 ? space : 0)
|
||||
|
||||
}
|
||||
self.ss_size = CGSize(width: width, height: height)
|
||||
}
|
||||
|
||||
@objc private func buttonAction(button: UIButton) {
|
||||
let index = buttonArray.firstIndex(of: button)
|
||||
let action = actions[index!]
|
||||
action.triggerAction()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
223
ReadViewDemo/Pods/SSAlertSwift/Sources/SSAlertSwift/SSAlertDefaultAnmation.swift
generated
Normal file
223
ReadViewDemo/Pods/SSAlertSwift/Sources/SSAlertSwift/SSAlertDefaultAnmation.swift
generated
Normal file
@ -0,0 +1,223 @@
|
||||
//
|
||||
// SSAlertDefaultAnmation.swift
|
||||
// SSAlert
|
||||
//
|
||||
// Created by yangsq on 2021/8/13.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
extension SSAlertDefaultAnmation {
|
||||
public enum State {
|
||||
case fromTop, fromLeft, fromBottom, fromRight, fromCenter
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
open class SSAlertDefaultAnmation: NSObject {
|
||||
public var state: State
|
||||
/// 中心点偏移原来位置位移
|
||||
public var centerOffset: CGPoint = .zero
|
||||
/// 动画的时间
|
||||
public var duration: TimeInterval = 0.3
|
||||
/// 展示动画是否开启弹簧效果
|
||||
public var isSpringShowAnimation: Bool = true
|
||||
/// 滑动消失距离触发
|
||||
public var panDimissProgress: CGFloat = 0.4
|
||||
public var usingSpringWithDamping: CGFloat = 0.8
|
||||
public var initialSpringVelocity: CGFloat = 0.5
|
||||
private weak var animationView: SSAlertView? = nil
|
||||
public init(state: State) {
|
||||
self.state = state
|
||||
super.init()
|
||||
}
|
||||
|
||||
private func animation(animated: Bool, isSpringAnimation: Bool = true, animations: @escaping (() -> Void), completion: ((Bool) -> Void)? = nil) {
|
||||
if animated {
|
||||
if isSpringAnimation {
|
||||
UIView.animate(withDuration: animationDuration(),
|
||||
delay: 0,
|
||||
usingSpringWithDamping: usingSpringWithDamping,
|
||||
initialSpringVelocity: initialSpringVelocity,
|
||||
options: [],
|
||||
animations: animations,
|
||||
completion: completion)
|
||||
} else {
|
||||
UIView.animate(withDuration: animationDuration(),
|
||||
animations: animations,
|
||||
completion: completion)
|
||||
}
|
||||
|
||||
} else {
|
||||
animations()
|
||||
if let completion = completion {
|
||||
completion(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public func setCenterOffset(center offset: CGPoint, duration: TimeInterval = 0.3, animated: Bool = true, completion:((Bool) -> Void)?) {
|
||||
self.duration = duration
|
||||
self.animation(animated: animated, animations: {
|
||||
self.animationView?.transform = CGAffineTransform.init(translationX: offset.x, y: offset.y)
|
||||
}, completion: completion)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
extension SSAlertDefaultAnmation: SSAlertAnimation {
|
||||
public func animationDuration() -> TimeInterval {
|
||||
return duration
|
||||
}
|
||||
|
||||
public func showAnimationOfAnimationView(animationView: SSAlertView, viewSize: CGSize, animated: Bool, completion: (((Bool) -> Void))?) {
|
||||
guard let animationSuperView = animationView.superview else {
|
||||
return
|
||||
}
|
||||
self.animationView = animationView
|
||||
animationView.alpha = 1
|
||||
animationView.ss_size = viewSize
|
||||
switch state {
|
||||
case .fromBottom:
|
||||
animationView.ss_centerX = animationSuperView.ss_w / 2 + centerOffset.x
|
||||
animationView.ss_y = animationSuperView.ss_h
|
||||
animation(animated: animated, isSpringAnimation: isSpringShowAnimation, animations: { [weak self] in guard let self = self else { return }
|
||||
animationView.transform = CGAffineTransform.init(translationX: 0, y: -animationView.ss_h + self.centerOffset.y)
|
||||
animationView.backgroundMask?.alpha = 1;
|
||||
}, completion: completion)
|
||||
|
||||
case .fromTop:
|
||||
animationView.ss_centerX = animationSuperView.ss_w / 2 + centerOffset.x
|
||||
animationView.ss_y = -animationView.ss_h
|
||||
animation(animated: animated, isSpringAnimation: isSpringShowAnimation, animations: { [weak self] in guard let self = self else { return }
|
||||
animationView.transform = CGAffineTransform.init(translationX: 0, y: animationView.ss_h + self.centerOffset.y)
|
||||
animationView.backgroundMask?.alpha = 1;
|
||||
}, completion: completion)
|
||||
|
||||
case .fromLeft:
|
||||
animationView.ss_centerY = animationSuperView.ss_h / 2 + centerOffset.y
|
||||
animationView.ss_x = -animationView.ss_w
|
||||
animation(animated: animated, isSpringAnimation: isSpringShowAnimation, animations: {
|
||||
animationView.transform = CGAffineTransform.init(translationX: animationView.ss_w + self.centerOffset.x, y: 0)
|
||||
animationView.backgroundMask?.alpha = 1;
|
||||
}, completion: completion)
|
||||
|
||||
case .fromRight:
|
||||
animationView.ss_centerY = animationSuperView.ss_h / 2 + centerOffset.y
|
||||
animationView.ss_x = animationSuperView.ss_w
|
||||
animation(animated: animated, isSpringAnimation: isSpringShowAnimation, animations: {
|
||||
animationView.transform = CGAffineTransform.init(translationX: -animationView.ss_w + self.centerOffset.x, y: 0)
|
||||
animationView.backgroundMask?.alpha = 1;
|
||||
}, completion: completion)
|
||||
|
||||
case .fromCenter:
|
||||
animationView.ss_centerX = animationSuperView.ss_w / 2 + centerOffset.x
|
||||
animationView.ss_centerY = animationSuperView.ss_h / 2 + centerOffset.y
|
||||
animationView.transform = CGAffineTransform.init(scaleX: 1.5, y: 1.5)
|
||||
animationView.alpha = 0
|
||||
animation(animated: animated, isSpringAnimation: isSpringShowAnimation, animations: {
|
||||
animationView.alpha = 1
|
||||
animationView.transform = CGAffineTransform.identity
|
||||
animationView.backgroundMask?.alpha = 1;
|
||||
}, completion: completion)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
public func hideAnimationOfAnimationView(animationView: SSAlertView, viewSize: CGSize, animated: Bool, completion: (((Bool) -> Bool))?) {
|
||||
self.animationView = animationView
|
||||
animation(animated: animated, isSpringAnimation: false, animations: {
|
||||
if self.state == .fromCenter {
|
||||
animationView.alpha = 0
|
||||
} else {
|
||||
animationView.transform = CGAffineTransform.identity
|
||||
}
|
||||
animationView.backgroundMask?.alpha = 0
|
||||
}, completion: {
|
||||
finished in
|
||||
var isCancel = false
|
||||
if let completion = completion {
|
||||
isCancel = completion(finished)
|
||||
}
|
||||
if !isCancel {
|
||||
animationView.removeFromSuperview()
|
||||
animationView.backgroundMask?.removeFromSuperview()
|
||||
self.animationView = nil
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public func refreshAnimationOfAnimationView(animationView: SSAlertView, viewSize: CGSize, animated: Bool, completion: (((Bool) -> Void))?) {
|
||||
guard let animationSuperView = animationView.superview else {
|
||||
return
|
||||
}
|
||||
self.animationView = animationView
|
||||
animationView.alpha = 1
|
||||
animationView.ss_size = viewSize
|
||||
switch state {
|
||||
case .fromBottom:
|
||||
animationView.ss_centerX = animationSuperView.ss_w / 2.0 + centerOffset.x
|
||||
animation(animated: animated, animations: {
|
||||
animationView.ss_y = animationSuperView.ss_h - viewSize.height + self.centerOffset.y
|
||||
}, completion: completion)
|
||||
|
||||
case .fromTop:
|
||||
animationView.ss_centerX = animationSuperView.ss_w / 2.0 + centerOffset.x
|
||||
animation(animated: animated, animations: {
|
||||
animationView.ss_y = self.centerOffset.y
|
||||
}, completion: completion)
|
||||
|
||||
case .fromLeft:
|
||||
animationView.ss_centerY = animationSuperView.ss_h / 2.0 + centerOffset.y
|
||||
animation(animated: animated, animations: {
|
||||
animationView.ss_x = self.centerOffset.x
|
||||
}, completion: completion)
|
||||
|
||||
case .fromRight:
|
||||
animationView.ss_centerY = animationSuperView.ss_h / 2.0 + centerOffset.y
|
||||
animation(animated: animated, animations: {
|
||||
animationView.ss_x = animationSuperView.ss_w - viewSize.width + self.centerOffset.x
|
||||
}, completion: completion)
|
||||
|
||||
case .fromCenter:
|
||||
animationView.alpha = 0
|
||||
animationView.ss_centerX = animationSuperView.ss_w / 2 + centerOffset.x
|
||||
animationView.ss_centerY = animationSuperView.ss_h / 2 + centerOffset.y
|
||||
animationView.transform = CGAffineTransform.init(scaleX: 1.5, y: 1.5)
|
||||
animation(animated: animated, animations: {
|
||||
animationView.alpha = 1
|
||||
animationView.transform = CGAffineTransform.identity
|
||||
}, completion: completion)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
public func panToDimissTransilatePoint(point: CGPoint, panViewFrame: CGRect) -> (PanProgress, PanCancelProgress) {
|
||||
var progress: CGFloat = 0
|
||||
switch state {
|
||||
case .fromTop:
|
||||
if point.y <= 0 {
|
||||
progress = abs(point.y / panViewFrame.height)
|
||||
}
|
||||
case .fromBottom:
|
||||
if point.y >= 0 {
|
||||
progress = abs(point.y / panViewFrame.height)
|
||||
}
|
||||
|
||||
case .fromLeft:
|
||||
if point.x <= 0 {
|
||||
progress = abs(point.x / panViewFrame.width)
|
||||
}
|
||||
|
||||
case .fromRight:
|
||||
if point.x >= 0 {
|
||||
progress = abs(point.x / panViewFrame.width)
|
||||
}
|
||||
case .fromCenter:
|
||||
progress = abs(point.y / panViewFrame.height)
|
||||
}
|
||||
return (progress, panDimissProgress)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
222
ReadViewDemo/Pods/SSAlertSwift/Sources/SSAlertSwift/SSAlertPresentAnimation.swift
generated
Normal file
222
ReadViewDemo/Pods/SSAlertSwift/Sources/SSAlertSwift/SSAlertPresentAnimation.swift
generated
Normal file
@ -0,0 +1,222 @@
|
||||
//
|
||||
// SSAlertPresentAnimation.swift
|
||||
// SSAlert
|
||||
//
|
||||
// Created by yangsq on 2021/8/13.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
typealias PanDimissAnimation = (CGPoint, CGRect) -> (PanProgress, PanCancelProgress)
|
||||
typealias DimissAnimation = (DimissIsCancel) -> Void
|
||||
|
||||
class SSCustomInteractiveAnimation: UIPercentDrivenInteractiveTransition {
|
||||
private(set) var panGestureRecognizer: UIPanGestureRecognizer
|
||||
private var panDimissAnimation: PanDimissAnimation?
|
||||
private var dimissAnimation: DimissAnimation?
|
||||
|
||||
init(panGestureRecognizer: UIPanGestureRecognizer, panDimissAnimation: PanDimissAnimation?, dimissAnimation: DimissAnimation?) {
|
||||
self.panGestureRecognizer = panGestureRecognizer
|
||||
self.panDimissAnimation = panDimissAnimation
|
||||
self.dimissAnimation = dimissAnimation
|
||||
super.init()
|
||||
self.completionSpeed = 0.2
|
||||
panGestureRecognizer.addTarget(self, action: #selector(panAction(pan:)))
|
||||
}
|
||||
|
||||
@objc private func panAction(pan: UIPanGestureRecognizer) {
|
||||
guard let view = pan.view else {
|
||||
return
|
||||
}
|
||||
let poin = pan.translation(in: view)
|
||||
var progress: CGFloat = 0
|
||||
var cancelProgress: CGFloat = 0.4
|
||||
if let panDimissAnimation = self.panDimissAnimation {
|
||||
let (tProgress, tCancelProgress) = panDimissAnimation(poin, pan.view!.frame)
|
||||
progress = tProgress
|
||||
cancelProgress = tCancelProgress
|
||||
}
|
||||
switch pan.state {
|
||||
case .began:
|
||||
pan.setTranslation(CGPoint(x: 0, y: 0), in: view)
|
||||
case .changed:
|
||||
update(progress)
|
||||
case .cancelled:
|
||||
cancel()
|
||||
case .ended:
|
||||
if progress > cancelProgress {
|
||||
finish()
|
||||
dimissAnimation?(false)
|
||||
} else {
|
||||
cancel()
|
||||
dimissAnimation?(true)
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
override func startInteractiveTransition(_ transitionContext: UIViewControllerContextTransitioning) {
|
||||
super.startInteractiveTransition(transitionContext)
|
||||
}
|
||||
|
||||
deinit {
|
||||
panGestureRecognizer.removeTarget(self, action: #selector(panAction(pan:)))
|
||||
}
|
||||
}
|
||||
|
||||
class SSAlertPresentAnimation: NSObject {
|
||||
typealias AnimationCompletion = () -> Void
|
||||
var duration: TimeInterval = 0.35
|
||||
private var showAnimation: AnimationCompletion? = nil
|
||||
private var hideAnimation: AnimationCompletion? = nil
|
||||
private var endCompletion: AnimationCompletion? = nil
|
||||
private var panDimissAnimation: PanDimissAnimation? = nil
|
||||
private var dimissAnimation: DimissAnimation? = nil
|
||||
|
||||
private var transitionContext: UIViewControllerContextTransitioning?
|
||||
private weak var nav: UINavigationController?
|
||||
private weak var animationView: UIView?
|
||||
private var panGestureRecognizer: UIPanGestureRecognizer?
|
||||
|
||||
init(navigationViewController: UINavigationController, animationView: UIView, canPanDimiss: Bool = true) {
|
||||
self.nav = navigationViewController
|
||||
self.animationView = animationView
|
||||
super.init()
|
||||
if canPanDimiss {
|
||||
let pan = UIPanGestureRecognizer(target: self, action: #selector(panAction(pan:)))
|
||||
pan.maximumNumberOfTouches = 1
|
||||
animationView.addGestureRecognizer(pan)
|
||||
}
|
||||
}
|
||||
|
||||
@objc func panAction(pan: UIPanGestureRecognizer) {
|
||||
if pan.state == .began {
|
||||
panGestureRecognizer = pan
|
||||
nav?.viewControllers[0].dismiss(animated: true, completion: {
|
||||
[weak self] in guard let self = self else { return }
|
||||
self.panGestureRecognizer = nil
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
func animationCompletion(showAnimation: AnimationCompletion? = nil,
|
||||
hideAnimation: AnimationCompletion? = nil,
|
||||
endCompletion: AnimationCompletion? = nil,
|
||||
panDimissAnimation: PanDimissAnimation? = nil,
|
||||
dimissAnimation: DimissAnimation? = nil) {
|
||||
if showAnimation != nil {
|
||||
self.showAnimation = showAnimation
|
||||
}
|
||||
if hideAnimation != nil {
|
||||
self.hideAnimation = hideAnimation
|
||||
}
|
||||
|
||||
if endCompletion != nil {
|
||||
self.endCompletion = endCompletion
|
||||
}
|
||||
if panDimissAnimation != nil {
|
||||
self.panDimissAnimation = panDimissAnimation
|
||||
}
|
||||
if panDimissAnimation != nil {
|
||||
self.panDimissAnimation = panDimissAnimation
|
||||
}
|
||||
|
||||
if dimissAnimation != nil {
|
||||
self.dimissAnimation = dimissAnimation
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func setCompleteTransitionIsHide(isHide: Bool) -> Bool {
|
||||
if let transitionContext = transitionContext {
|
||||
let isCancelled = transitionContext.transitionWasCancelled
|
||||
if !isCancelled {
|
||||
if isHide {
|
||||
transitionContext.containerView.subviews.forEach { subView in
|
||||
subView.removeFromSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
transitionContext.completeTransition(!isCancelled)
|
||||
return isCancelled
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
extension SSAlertPresentAnimation: UIViewControllerTransitioningDelegate {
|
||||
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
||||
return self
|
||||
}
|
||||
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
||||
return self
|
||||
}
|
||||
|
||||
func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
|
||||
if self.panGestureRecognizer != nil {
|
||||
return SSCustomInteractiveAnimation(panGestureRecognizer: self.panGestureRecognizer!, panDimissAnimation: self.panDimissAnimation, dimissAnimation: dimissAnimation)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func interactionControllerForPresentation(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
|
||||
if self.panGestureRecognizer != nil {
|
||||
|
||||
return SSCustomInteractiveAnimation(panGestureRecognizer: self.panGestureRecognizer!, panDimissAnimation: self.panDimissAnimation, dimissAnimation: dimissAnimation)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension SSAlertPresentAnimation: UIViewControllerAnimatedTransitioning {
|
||||
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
|
||||
return self.duration
|
||||
}
|
||||
|
||||
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
||||
self.transitionContext = transitionContext
|
||||
let fromVC = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from)
|
||||
let toVC = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to)
|
||||
let containerView = transitionContext.containerView
|
||||
|
||||
if let fromVC = fromVC, let toVC = toVC {
|
||||
var fromView = fromVC.view
|
||||
var toView = toVC.view
|
||||
if transitionContext.responds(to: #selector(UIViewControllerContextTransitioning.view(forKey:))) {
|
||||
fromView = transitionContext.view(forKey: UITransitionContextViewKey.from)
|
||||
toView = transitionContext.view(forKey: UITransitionContextViewKey.to)
|
||||
}
|
||||
let isPresenting = toVC.presentingViewController == fromVC
|
||||
if isPresenting {
|
||||
if let toView = toView {
|
||||
containerView.addSubview(toView)
|
||||
|
||||
}
|
||||
if let showAnimation = self.showAnimation {
|
||||
showAnimation()
|
||||
}
|
||||
} else {
|
||||
if let toView = toView, let fromView = fromView {
|
||||
containerView.insertSubview(toView, belowSubview: fromView)
|
||||
|
||||
}
|
||||
if let hideAnimation = self.hideAnimation {
|
||||
hideAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
func animationEnded(_ transitionCompleted: Bool) {
|
||||
if let endCompletion = self.endCompletion, transitionCompleted, let transitionContext = transitionContext, transitionContext.isInteractive {
|
||||
endCompletion()
|
||||
}
|
||||
}
|
||||
}
|
||||
312
ReadViewDemo/Pods/SSAlertSwift/Sources/SSAlertSwift/SSAlertView.swift
generated
Normal file
312
ReadViewDemo/Pods/SSAlertSwift/Sources/SSAlertSwift/SSAlertView.swift
generated
Normal file
@ -0,0 +1,312 @@
|
||||
//
|
||||
// SSAlertView.swift
|
||||
// SSAlert
|
||||
//
|
||||
// Created by yangsq on 2021/8/13.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension SSAlertView {
|
||||
public enum BackgroundMaskType {
|
||||
/// 默认没有遮罩
|
||||
case none
|
||||
|
||||
/// 透明遮罩
|
||||
case clear
|
||||
|
||||
/// 黑色遮罩
|
||||
case black
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate extension SSAlertView {
|
||||
typealias HideCompletion = () -> Void
|
||||
class HideCompletionData: NSObject {
|
||||
var completion: HideCompletion?
|
||||
}
|
||||
}
|
||||
|
||||
open class SSAlertView: UIView {
|
||||
/// customView 的内间距
|
||||
public var customEdgeInsets: UIEdgeInsets = .zero {
|
||||
didSet {
|
||||
setNeedsDisplay()
|
||||
layoutIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
/// 展示和隐藏动画设置
|
||||
public var animation: SSAlertAnimation
|
||||
|
||||
///遮罩视图
|
||||
public private(set) var backgroundMask: UIView?
|
||||
|
||||
/// 是否点击遮罩隐藏,默认点击会隐藏
|
||||
public var canTouchMaskHide: Bool = true
|
||||
/// 点击遮罩隐藏是否开启动画,默认nil,跟随展示的时候设置是否开启动画
|
||||
public var isTouchMaskHideAnimated: Bool? = nil
|
||||
|
||||
/// 模态视图弹窗才有
|
||||
public private(set) var navigationController: UINavigationController?
|
||||
|
||||
public var isHideStatusBar = false {
|
||||
didSet {
|
||||
self.toViewContrller?.isHideStatusBar = isHideStatusBar
|
||||
}
|
||||
}
|
||||
public var isShowNavWhenViewWillDisappear = true {
|
||||
didSet {
|
||||
self.toViewContrller?.isShowNavWhenViewWillDisappear = isShowNavWhenViewWillDisappear
|
||||
}
|
||||
}
|
||||
private var onView: UIView
|
||||
public private(set) var maskType: BackgroundMaskType
|
||||
public private(set) var customView: UIView
|
||||
private weak var fromViewController: UIViewController?
|
||||
private weak var toViewContrller: SSAlertAnimationController?
|
||||
private var presentAnimation: SSAlertPresentAnimation?
|
||||
private var canPanDimiss: Bool = false
|
||||
private var hideCompletions = [HideCompletionData]()
|
||||
/// 初始化(普通视图弹窗)
|
||||
public init(customView: UIView,
|
||||
onView: UIView,
|
||||
animation: SSAlertAnimation = SSAlertDefaultAnmation(state: .fromCenter),
|
||||
maskType: BackgroundMaskType = .black) {
|
||||
self.animation = animation
|
||||
self.onView = onView
|
||||
self.maskType = maskType
|
||||
self.customView = customView
|
||||
super.init(frame: .zero)
|
||||
makeUI()
|
||||
}
|
||||
|
||||
/// 初始化(模态视图弹窗),canPanDimiss: 是否支持拖拽消息
|
||||
public convenience init(customView: UIView,
|
||||
fromViewController: UIViewController,
|
||||
animation: SSAlertAnimation = SSAlertDefaultAnmation(state: .fromCenter),
|
||||
navigationControllerClass: UINavigationController.Type = UINavigationController.self,
|
||||
maskType: BackgroundMaskType = .black,
|
||||
canPanDimiss: Bool = true) {
|
||||
let animaionVC = SSAlertAnimationController()
|
||||
self.init(customView: customView, onView: animaionVC.view, animation: animation, maskType: maskType)
|
||||
animaionVC.isHideStatusBar = self.isHideStatusBar
|
||||
if let superViewController = superViewController(view: customView), superViewController != animaionVC {
|
||||
animaionVC.addChild(superViewController)
|
||||
superViewController.didMove(toParent: animaionVC)
|
||||
}
|
||||
self.canPanDimiss = canPanDimiss
|
||||
self.fromViewController = fromViewController
|
||||
let nav = navigationControllerClass.init(rootViewController: animaionVC)
|
||||
nav.modalPresentationStyle = .custom
|
||||
self.navigationController = nav
|
||||
self.canPanDimiss = canPanDimiss
|
||||
let animation = SSAlertPresentAnimation(navigationViewController: nav, animationView: customView, canPanDimiss: canPanDimiss)
|
||||
self.navigationController!.transitioningDelegate = animation
|
||||
self.navigationController!.modalPresentationCapturesStatusBarAppearance = true
|
||||
self.presentAnimation = animation
|
||||
self.toViewContrller = animaionVC
|
||||
|
||||
}
|
||||
|
||||
open override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
backgroundMask?.frame = CGRect(x: 0, y: 0, width: onView.ss_w, height: onView.ss_h)
|
||||
customView.ss_x = customEdgeInsets.left
|
||||
customView.ss_y = customEdgeInsets.top
|
||||
customView.ss_w = frame.width - customEdgeInsets.left - customEdgeInsets.right
|
||||
customView.ss_h = frame.height - customEdgeInsets.top - customEdgeInsets.bottom
|
||||
}
|
||||
|
||||
|
||||
private func makeUI() {
|
||||
if maskType != .none {
|
||||
let backgroundMask = UIButton()
|
||||
backgroundMask.alpha = 0
|
||||
onView.addSubview(backgroundMask)
|
||||
backgroundMask.addTarget(self, action: #selector(backgroundMaskAction), for: .touchUpInside)
|
||||
self.backgroundMask = backgroundMask
|
||||
if maskType == .black {
|
||||
backgroundMask.backgroundColor = UIColor.black.withAlphaComponent(0.3)
|
||||
}
|
||||
}
|
||||
addSubview(customView)
|
||||
onView.addSubview(self)
|
||||
}
|
||||
/// 展示
|
||||
public func show(animated: Bool = true, completion: (() -> Void)? = nil) {
|
||||
if navigationController != nil {
|
||||
presentView(animated: animated, completion: completion)
|
||||
} else {
|
||||
showView(animated: animated, completion: completion)
|
||||
}
|
||||
}
|
||||
/// 隐藏
|
||||
public func hide(animated: Bool = true, completion: (() -> Void)? = nil) {
|
||||
if navigationController != nil {
|
||||
dimissView(animated: animated, completion: completion)
|
||||
} else {
|
||||
hideView(animated: animated, completion: completion)
|
||||
}
|
||||
}
|
||||
|
||||
/// 展示(普通视图弹窗)
|
||||
/// - Parameter animated: 是否动画
|
||||
private func showView(animated: Bool = true, completion: (() -> Void)? = nil) {
|
||||
isTouchMaskHideAnimated = animated
|
||||
var customSize = customView.systemLayoutSizeFitting(UIView.layoutFittingExpandedSize)
|
||||
if customView.ss_size != .zero {
|
||||
customSize = customView.ss_size
|
||||
}
|
||||
let size = CGSize(width: customSize.width + customEdgeInsets.left + customEdgeInsets.right, height: customSize.height + customEdgeInsets.top + customEdgeInsets.bottom)
|
||||
animation.showAnimationOfAnimationView(animationView: self, viewSize: size, animated: animated, completion: { _ in completion?() })
|
||||
}
|
||||
/// 隐藏(普通视图弹窗)
|
||||
/// - Parameter animated: 是否动画
|
||||
private func hideView(animated: Bool = true, completion: (() -> Void)? = nil) {
|
||||
var customSize = customView.systemLayoutSizeFitting(UIView.layoutFittingExpandedSize)
|
||||
if customView.ss_size != .zero {
|
||||
customSize = customView.ss_size
|
||||
}
|
||||
let size = CGSize(width: customSize.width + customEdgeInsets.left + customEdgeInsets.right, height: customSize.height + customEdgeInsets.top + customEdgeInsets.bottom)
|
||||
animation.hideAnimationOfAnimationView(animationView: self, viewSize: size, animated: animated) { [weak self] _ in guard let self = self else { return false }
|
||||
completion?()
|
||||
|
||||
self.hideCompletions.forEach { hideC in
|
||||
hideC.completion?()
|
||||
hideC.completion = nil
|
||||
self.hideCompletions.removeAll(where: {$0 == hideC})
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
}
|
||||
/// 展示(模态视图弹窗)
|
||||
/// - Parameter animated: 是否动画
|
||||
private func presentView(animated: Bool = true, completion: (() -> Void)? = nil) {
|
||||
isTouchMaskHideAnimated = animated
|
||||
var customSize = customView.systemLayoutSizeFitting(UIView.layoutFittingExpandedSize)
|
||||
if customView.ss_size != .zero {
|
||||
customSize = customView.ss_size
|
||||
}
|
||||
let size = CGSize(width: customSize.width + customEdgeInsets.left + customEdgeInsets.right, height: customSize.height + customEdgeInsets.top + customEdgeInsets.bottom)
|
||||
presentAnimation?.animationCompletion(showAnimation: {
|
||||
[weak self] in guard let self = self else { return }
|
||||
self.animation.showAnimationOfAnimationView(animationView: self, viewSize: size, animated: animated) { _ in
|
||||
self.presentAnimation?.setCompleteTransitionIsHide(isHide: false)
|
||||
completion?()
|
||||
}
|
||||
})
|
||||
self.presentAnimation?.duration = animation.animationDuration()
|
||||
///存在的问题: app当前显示的是弹窗界面,默认presnent出来的视图且modalPresentationStyle不是CurrentContext这这种,这个视图的presentingVC默认根控制器,例如存在tabbarVC的话就是tabbarVC,这个会造成一个问题:当这个视图dimiss的时候,会把所有的presentedVC都dimiss(其实调用dimiss的时候是调用当前视图的presentigngVC去dimiss,遵循随创建随释放)
|
||||
/// 这个通过设置 definesPresentationContext = true,modalPresentationStyle = UIModalPresentationOverCurrentContext,这样当前视图的presentigngVC为 self.navigationController,
|
||||
/// UIModalPresentationOverCurrentContext和 UIModalPresentationCurrentContext的区别是弹出的界面,前者不会把底部的视图stact移除,后者则会。
|
||||
self.navigationController?.definesPresentationContext = true
|
||||
self.navigationController?.modalPresentationStyle = .overCurrentContext
|
||||
self.fromViewController?.present(self.navigationController!, animated: true, completion: nil)
|
||||
if canPanDimiss {
|
||||
dimissViewDragToHideAnimation(animated: animated)
|
||||
}
|
||||
}
|
||||
/// 隐藏(模态视图弹窗)
|
||||
/// - Parameter animated: 是否动画
|
||||
private func dimissView(animated: Bool = true, completion: (() -> Void)? = nil) {
|
||||
var customSize = customView.systemLayoutSizeFitting(UIView.layoutFittingExpandedSize)
|
||||
if customView.ss_size != .zero {
|
||||
customSize = customView.ss_size
|
||||
}
|
||||
let size = CGSize(width: customSize.width + customEdgeInsets.left + customEdgeInsets.right, height: customSize.height + customEdgeInsets.top + customEdgeInsets.bottom)
|
||||
presentAnimation?.animationCompletion(hideAnimation: {
|
||||
[weak self] in guard let self = self else { return }
|
||||
self.animation.hideAnimationOfAnimationView(animationView: self, viewSize: size, animated: animated) { _ in
|
||||
return self.presentAnimation!.setCompleteTransitionIsHide(isHide: true)
|
||||
}
|
||||
})
|
||||
self.presentAnimation?.duration = animation.animationDuration()
|
||||
self.toViewContrller?.dismiss(animated: animated, completion: {
|
||||
[weak self] in guard let self = self else { return }
|
||||
completion?()
|
||||
self.hideCompletions.forEach { hideC in
|
||||
hideC.completion?()
|
||||
hideC.completion = nil
|
||||
self.hideCompletions.removeAll(where: {$0 == hideC})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private func dimissViewDragToHideAnimation(animated: Bool) {
|
||||
var customSize = customView.systemLayoutSizeFitting(UIView.layoutFittingExpandedSize)
|
||||
if customView.ss_size != .zero {
|
||||
customSize = customView.ss_size
|
||||
}
|
||||
let size = CGSize(width: customSize.width + customEdgeInsets.left + customEdgeInsets.right, height: customSize.height + customEdgeInsets.top + customEdgeInsets.bottom)
|
||||
presentAnimation?.animationCompletion(hideAnimation: {
|
||||
[weak self] in guard let self = self else { return }
|
||||
self.animation.hideAnimationOfAnimationView(animationView: self, viewSize: size, animated: animated) { _ in
|
||||
return self.presentAnimation!.setCompleteTransitionIsHide(isHide: true)
|
||||
}
|
||||
},endCompletion: {
|
||||
[weak self] in guard let self = self else { return }
|
||||
self.hideCompletions.forEach { hideC in
|
||||
hideC.completion?()
|
||||
hideC.completion = nil
|
||||
self.hideCompletions.removeAll(where: {$0 == hideC})
|
||||
}
|
||||
}, panDimissAnimation: { [weak self] point, frame in
|
||||
guard let self = self else { return (0, 0.4) }
|
||||
return self.animation.panToDimissTransilatePoint(point: point, panViewFrame: frame)
|
||||
}, dimissAnimation: { [weak self] isCancel in guard let self = self else { return }
|
||||
self.toViewContrller?.isDimiss = !isCancel
|
||||
})
|
||||
self.presentAnimation?.duration = animation.animationDuration()
|
||||
}
|
||||
|
||||
public func refreshFrame(animated: Bool = true) {
|
||||
var customSize = customView.systemLayoutSizeFitting(UIView.layoutFittingExpandedSize)
|
||||
if customView.ss_size != .zero {
|
||||
customSize = customView.ss_size
|
||||
}
|
||||
let size = CGSize(width: customSize.width + customEdgeInsets.left + customEdgeInsets.right, height: customSize.height + customEdgeInsets.top + customEdgeInsets.bottom)
|
||||
animation.refreshAnimationOfAnimationView(animationView: self, viewSize: size, animated: animated, completion: nil)
|
||||
}
|
||||
|
||||
|
||||
@objc private func backgroundMaskAction() {
|
||||
if canTouchMaskHide {
|
||||
if fromViewController != nil {
|
||||
dimissView(animated: isTouchMaskHideAnimated ?? false)
|
||||
} else {
|
||||
hideView(animated: isTouchMaskHideAnimated ?? false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func superViewController(view: UIView) -> UIViewController? {
|
||||
var next = view.next
|
||||
while next != nil {
|
||||
if next is UIViewController {
|
||||
return next as? UIViewController
|
||||
} else {
|
||||
next = next!.next
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
public func observeHideCompletion(completion: (() -> Void)?) {
|
||||
let completionData = HideCompletionData()
|
||||
completionData.completion = completion
|
||||
self.hideCompletions.append(completionData)
|
||||
}
|
||||
|
||||
required public init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
127
ReadViewDemo/Pods/SSAlertSwift/Sources/SSAlertSwift/SSAlertViewExtention.swift
generated
Normal file
127
ReadViewDemo/Pods/SSAlertSwift/Sources/SSAlertSwift/SSAlertViewExtention.swift
generated
Normal file
@ -0,0 +1,127 @@
|
||||
//
|
||||
// SSAlertViewExtention.swift
|
||||
// SSAlert
|
||||
//
|
||||
// Created by yangsq on 2021/8/13.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension SSAlertView {
|
||||
@discardableResult
|
||||
public class func alertView(style: SSAlertAction.Style, title: String? = nil, message: String? = nil, cancelButton: String? = nil, otherButtons: [String] = [], onView: UIView, onTrigger:((Int) -> Void)? = nil) -> SSAlertView {
|
||||
var actionArray = [SSAlertAction]()
|
||||
var cancelAction: SSAlertAction? = nil
|
||||
var alertView: SSAlertView?
|
||||
if style == .alert {
|
||||
if let cancelButton = cancelButton, !cancelButton.isEmpty {
|
||||
cancelAction = SSAlertAction(style: style, title: cancelButton, addAction: {
|
||||
if let onTrigger = onTrigger {
|
||||
onTrigger(0)
|
||||
}
|
||||
alertView?.hide()
|
||||
})
|
||||
actionArray.append(cancelAction!)
|
||||
}
|
||||
otherButtons.forEach { title in
|
||||
let action = SSAlertAction(style: style, title: title) {
|
||||
if let onTrigger = onTrigger {
|
||||
let index = otherButtons.firstIndex(of: title)
|
||||
onTrigger(cancelAction != nil ? (index! + 1) : index!)
|
||||
}
|
||||
}
|
||||
actionArray.append(action)
|
||||
}
|
||||
}
|
||||
|
||||
if style == .actionSheet {
|
||||
otherButtons.forEach { title in
|
||||
let action = SSAlertAction(style: style, title: title) {
|
||||
if let onTrigger = onTrigger {
|
||||
let index = otherButtons.firstIndex(of: title)
|
||||
onTrigger(cancelAction != nil ? (index! + 1) : index!)
|
||||
}
|
||||
}
|
||||
actionArray.append(action)
|
||||
}
|
||||
if let cancelButton = cancelButton, !cancelButton.isEmpty {
|
||||
cancelAction = SSAlertAction(style: style, title: cancelButton, addAction: {
|
||||
if let onTrigger = onTrigger {
|
||||
onTrigger(0)
|
||||
}
|
||||
alertView?.hide()
|
||||
})
|
||||
actionArray.append(cancelAction!)
|
||||
}
|
||||
}
|
||||
|
||||
let customView = SSAlertCommonView(title: title, message: message, style: style, actions: actionArray)
|
||||
customView.backgroundColor = .white
|
||||
alertView = SSAlertView(customView: customView, onView: onView, animation: SSAlertDefaultAnmation(state: style == .actionSheet ? .fromBottom : .fromCenter), maskType: .black)
|
||||
alertView!.canTouchMaskHide = false
|
||||
if style == .alert {
|
||||
alertView!.layer.cornerRadius = 10
|
||||
alertView!.layer.masksToBounds = true
|
||||
}
|
||||
return alertView!
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public class func modalAlertView(style: SSAlertAction.Style, title: String? = nil, message: String? = nil, cancelButton: String? = nil, otherButtons: [String] = [], fromViewController: UIViewController, navigationViewControllerClass: UINavigationController.Type = UINavigationController.self, onTrigger:((Int) -> Void)? = nil) -> SSAlertView {
|
||||
var actionArray = [SSAlertAction]()
|
||||
var cancelAction: SSAlertAction? = nil
|
||||
var alertView: SSAlertView?
|
||||
if style == .alert {
|
||||
if let cancelButton = cancelButton, !cancelButton.isEmpty {
|
||||
cancelAction = SSAlertAction(style: style, title: cancelButton, addAction: {
|
||||
if let onTrigger = onTrigger {
|
||||
onTrigger(0)
|
||||
}
|
||||
alertView?.hide()
|
||||
})
|
||||
actionArray.append(cancelAction!)
|
||||
}
|
||||
otherButtons.forEach { title in
|
||||
let action = SSAlertAction(style: style, title: title) {
|
||||
if let onTrigger = onTrigger {
|
||||
let index = otherButtons.firstIndex(of: title)
|
||||
onTrigger(cancelAction != nil ? (index! + 1) : index!)
|
||||
}
|
||||
}
|
||||
actionArray.append(action)
|
||||
}
|
||||
}
|
||||
|
||||
if style == .actionSheet {
|
||||
otherButtons.forEach { title in
|
||||
let action = SSAlertAction(style: style, title: title) {
|
||||
if let onTrigger = onTrigger {
|
||||
let index = otherButtons.firstIndex(of: title)
|
||||
onTrigger(cancelAction != nil ? (index! + 1) : index!)
|
||||
}
|
||||
}
|
||||
actionArray.append(action)
|
||||
}
|
||||
if let cancelButton = cancelButton, !cancelButton.isEmpty {
|
||||
cancelAction = SSAlertAction(style: style, title: cancelButton, addAction: {
|
||||
if let onTrigger = onTrigger {
|
||||
onTrigger(0)
|
||||
}
|
||||
alertView?.hide()
|
||||
})
|
||||
actionArray.append(cancelAction!)
|
||||
}
|
||||
}
|
||||
let customView = SSAlertCommonView(title: title, message: message, style: style, actions: actionArray)
|
||||
customView.backgroundColor = .white
|
||||
alertView = SSAlertView(customView: customView,fromViewController: fromViewController, animation: SSAlertDefaultAnmation(state: style == .actionSheet ? .fromBottom : .fromCenter), navigationControllerClass: navigationViewControllerClass, maskType: .black)
|
||||
alertView!.canTouchMaskHide = false
|
||||
if style == .alert {
|
||||
alertView!.layer.cornerRadius = 10
|
||||
alertView!.layer.masksToBounds = true
|
||||
}
|
||||
return alertView!
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
132
ReadViewDemo/Pods/SSAlertSwift/Sources/SSAlertSwift/UIViewFrameExtension.swift
generated
Normal file
132
ReadViewDemo/Pods/SSAlertSwift/Sources/SSAlertSwift/UIViewFrameExtension.swift
generated
Normal file
@ -0,0 +1,132 @@
|
||||
//
|
||||
// UIViewFrameExtension.swift
|
||||
// Pods-SSAlertSwiftDemo
|
||||
//
|
||||
// Created by yangsq on 2021/8/13.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
public extension UIView {
|
||||
var ss_size: CGSize {
|
||||
get {
|
||||
frame.size
|
||||
}
|
||||
set {
|
||||
var frame = frame
|
||||
frame.size = newValue
|
||||
self.frame = frame
|
||||
}
|
||||
}
|
||||
|
||||
var ss_origin: CGPoint {
|
||||
get {
|
||||
frame.origin
|
||||
}
|
||||
set {
|
||||
var frame = frame
|
||||
frame.origin = newValue
|
||||
self.frame = frame
|
||||
}
|
||||
}
|
||||
|
||||
var ss_center: CGPoint {
|
||||
get {
|
||||
center
|
||||
}
|
||||
set {
|
||||
center = newValue
|
||||
}
|
||||
}
|
||||
|
||||
var ss_x: CGFloat {
|
||||
get {
|
||||
frame.origin.x
|
||||
}
|
||||
set {
|
||||
var frame = frame
|
||||
frame.origin.x = newValue
|
||||
self.frame = frame
|
||||
}
|
||||
}
|
||||
|
||||
var ss_maxX: CGFloat {
|
||||
get {
|
||||
frame.maxX
|
||||
}
|
||||
}
|
||||
|
||||
var ss_y: CGFloat {
|
||||
get {
|
||||
frame.origin.y
|
||||
}
|
||||
set {
|
||||
var frame = frame
|
||||
frame.origin.y = newValue
|
||||
self.frame = frame
|
||||
}
|
||||
}
|
||||
|
||||
var ss_maxY: CGFloat {
|
||||
get {
|
||||
frame.maxY
|
||||
}
|
||||
set {
|
||||
var frame = frame
|
||||
frame.origin.y = newValue
|
||||
self.frame = frame
|
||||
}
|
||||
}
|
||||
|
||||
var ss_w: CGFloat {
|
||||
get {
|
||||
frame.width
|
||||
}
|
||||
set {
|
||||
var frame = frame
|
||||
frame.size.width = newValue
|
||||
self.frame = frame
|
||||
}
|
||||
}
|
||||
|
||||
var ss_h: CGFloat {
|
||||
get {
|
||||
frame.height
|
||||
}
|
||||
set {
|
||||
var frame = frame
|
||||
frame.size.height = newValue
|
||||
self.frame = frame
|
||||
}
|
||||
}
|
||||
|
||||
var ss_centerX: CGFloat {
|
||||
get {
|
||||
center.x
|
||||
}
|
||||
set {
|
||||
var center = center
|
||||
center.x = newValue
|
||||
self.center = center
|
||||
}
|
||||
}
|
||||
|
||||
var ss_centerY: CGFloat {
|
||||
get {
|
||||
center.y
|
||||
}
|
||||
set {
|
||||
var center = center
|
||||
center.y = newValue
|
||||
self.center = center
|
||||
}
|
||||
}
|
||||
|
||||
func ss_moveToCenter() {
|
||||
if let superV = superview {
|
||||
ss_center = CGPoint(x: superV.frame.width / 2, y: superV.frame.height / 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
19
ReadViewDemo/Pods/SnapKit/LICENSE
generated
Normal file
19
ReadViewDemo/Pods/SnapKit/LICENSE
generated
Normal file
@ -0,0 +1,19 @@
|
||||
Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
155
ReadViewDemo/Pods/SnapKit/README.md
generated
Normal file
155
ReadViewDemo/Pods/SnapKit/README.md
generated
Normal file
@ -0,0 +1,155 @@
|
||||
<img src="https://snapkit.github.io/SnapKit/images/banner.jpg" alt="" />
|
||||
|
||||
SnapKit is a DSL to make Auto Layout easy on both iOS and OS X.
|
||||
|
||||
[](https://travis-ci.org/SnapKit/SnapKit)
|
||||
[](https://github.com/SnapKit/SnapKit)
|
||||
[](https://cocoapods.org/pods/SnapKit)
|
||||
[](https://github.com/Carthage/Carthage)
|
||||
|
||||
#### ⚠️ **To use with Swift 4.x please ensure you are using >= 4.0.0** ⚠️
|
||||
#### ⚠️ **To use with Swift 5.x please ensure you are using >= 5.0.0** ⚠️
|
||||
|
||||
## Contents
|
||||
|
||||
- [Requirements](#requirements)
|
||||
- [Migration Guides](#migration-guides)
|
||||
- [Communication](#communication)
|
||||
- [Installation](#installation)
|
||||
- [Usage](#usage)
|
||||
- [Credits](#credits)
|
||||
- [License](#license)
|
||||
|
||||
## Requirements
|
||||
|
||||
- iOS 12.0+ / Mac OS X 10.13+ / tvOS 10.0+
|
||||
- Xcode 10.0+
|
||||
- Swift 4.0+
|
||||
|
||||
## Migration Guides
|
||||
|
||||
- [SnapKit 3.0 Migration Guide](Documentation/SnapKit%203.0%20Migration%20Guide.md)
|
||||
|
||||
## Communication
|
||||
|
||||
- If you **need help**, use [Stack Overflow](http://stackoverflow.com/questions/tagged/snapkit). (Tag 'snapkit')
|
||||
- If you'd like to **ask a general question**, use [Stack Overflow](http://stackoverflow.com/questions/tagged/snapkit).
|
||||
- If you **found a bug**, open an issue.
|
||||
- If you **have a feature request**, open an issue.
|
||||
- If you **want to contribute**, submit a pull request.
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
### CocoaPods
|
||||
|
||||
[CocoaPods](http://cocoapods.org) is a dependency manager for Cocoa projects. You can install it with the following command:
|
||||
|
||||
```bash
|
||||
$ gem install cocoapods
|
||||
```
|
||||
|
||||
> CocoaPods 1.1.0+ is required to build SnapKit 4.0.0+.
|
||||
|
||||
To integrate SnapKit into your Xcode project using CocoaPods, specify it in your `Podfile`:
|
||||
|
||||
```ruby
|
||||
source 'https://github.com/CocoaPods/Specs.git'
|
||||
platform :ios, '10.0'
|
||||
use_frameworks!
|
||||
|
||||
target '<Your Target Name>' do
|
||||
pod 'SnapKit', '~> 5.7.0'
|
||||
end
|
||||
```
|
||||
|
||||
Then, run the following command:
|
||||
|
||||
```bash
|
||||
$ pod install
|
||||
```
|
||||
|
||||
### Carthage
|
||||
|
||||
[Carthage](https://github.com/Carthage/Carthage) is a decentralized dependency manager that builds your dependencies and provides you with binary frameworks.
|
||||
|
||||
You can install Carthage with [Homebrew](http://brew.sh/) using the following command:
|
||||
|
||||
```bash
|
||||
$ brew update
|
||||
$ brew install carthage
|
||||
```
|
||||
|
||||
To integrate SnapKit into your Xcode project using Carthage, specify it in your `Cartfile`:
|
||||
|
||||
```ogdl
|
||||
github "SnapKit/SnapKit" ~> 5.0.0
|
||||
```
|
||||
|
||||
Run `carthage update` to build the framework and drag the built `SnapKit.framework` into your Xcode project.
|
||||
|
||||
### Swift Package Manager
|
||||
|
||||
[Swift Package Manager](https://swift.org/package-manager/) is a tool for managing the distribution of Swift code. It’s integrated with the Swift build system to automate the process of downloading, compiling, and linking dependencies.
|
||||
|
||||
> Xcode 11+ is required to build SnapKit using Swift Package Manager.
|
||||
|
||||
To integrate SnapKit into your Xcode project using Swift Package Manager, add it to the dependencies value of your `Package.swift`:
|
||||
|
||||
```swift
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/SnapKit/SnapKit.git", .upToNextMajor(from: "5.0.1"))
|
||||
]
|
||||
```
|
||||
|
||||
### Manually
|
||||
|
||||
If you prefer not to use either of the aforementioned dependency managers, you can integrate SnapKit into your project manually.
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
### Quick Start
|
||||
|
||||
```swift
|
||||
import SnapKit
|
||||
|
||||
class MyViewController: UIViewController {
|
||||
|
||||
lazy var box = UIView()
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
self.view.addSubview(box)
|
||||
box.backgroundColor = .green
|
||||
box.snp.makeConstraints { (make) -> Void in
|
||||
make.width.height.equalTo(50)
|
||||
make.center.equalTo(self.view)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
```
|
||||
|
||||
### Playground
|
||||
You can try SnapKit in Playground.
|
||||
|
||||
**Note:**
|
||||
|
||||
> To try SnapKit in playground, open `SnapKit.xcworkspace` and build SnapKit.framework for any simulator first.
|
||||
|
||||
### Resources
|
||||
|
||||
- [Documentation](https://snapkit.github.io/SnapKit/docs/)
|
||||
- [F.A.Q.](https://snapkit.github.io/SnapKit/faq/)
|
||||
|
||||
## Credits
|
||||
|
||||
- Robert Payne ([@robertjpayne](https://twitter.com/robertjpayne))
|
||||
- Many other contributors
|
||||
|
||||
## License
|
||||
|
||||
SnapKit is released under the MIT license. See LICENSE for details.
|
||||
341
ReadViewDemo/Pods/SnapKit/Sources/Constraint.swift
generated
Normal file
341
ReadViewDemo/Pods/SnapKit/Sources/Constraint.swift
generated
Normal file
@ -0,0 +1,341 @@
|
||||
//
|
||||
// SnapKit
|
||||
//
|
||||
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
public final class Constraint {
|
||||
|
||||
internal let sourceLocation: (String, UInt)
|
||||
internal let label: String?
|
||||
|
||||
private let from: ConstraintItem
|
||||
private let to: ConstraintItem
|
||||
private let relation: ConstraintRelation
|
||||
private let multiplier: ConstraintMultiplierTarget
|
||||
private var constant: ConstraintConstantTarget {
|
||||
didSet {
|
||||
self.updateConstantAndPriorityIfNeeded()
|
||||
}
|
||||
}
|
||||
private var priority: ConstraintPriorityTarget {
|
||||
didSet {
|
||||
self.updateConstantAndPriorityIfNeeded()
|
||||
}
|
||||
}
|
||||
public var layoutConstraints: [LayoutConstraint]
|
||||
|
||||
public var isActive: Bool {
|
||||
set {
|
||||
if newValue {
|
||||
activate()
|
||||
}
|
||||
else {
|
||||
deactivate()
|
||||
}
|
||||
}
|
||||
|
||||
get {
|
||||
for layoutConstraint in self.layoutConstraints {
|
||||
if layoutConstraint.isActive {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Initialization
|
||||
|
||||
internal init(from: ConstraintItem,
|
||||
to: ConstraintItem,
|
||||
relation: ConstraintRelation,
|
||||
sourceLocation: (String, UInt),
|
||||
label: String?,
|
||||
multiplier: ConstraintMultiplierTarget,
|
||||
constant: ConstraintConstantTarget,
|
||||
priority: ConstraintPriorityTarget) {
|
||||
self.from = from
|
||||
self.to = to
|
||||
self.relation = relation
|
||||
self.sourceLocation = sourceLocation
|
||||
self.label = label
|
||||
self.multiplier = multiplier
|
||||
self.constant = constant
|
||||
self.priority = priority
|
||||
self.layoutConstraints = []
|
||||
|
||||
// get attributes
|
||||
let layoutFromAttributes = self.from.attributes.layoutAttributes
|
||||
let layoutToAttributes = self.to.attributes.layoutAttributes
|
||||
|
||||
// get layout from
|
||||
let layoutFrom = self.from.layoutConstraintItem!
|
||||
|
||||
// get relation
|
||||
let layoutRelation = self.relation.layoutRelation
|
||||
|
||||
for layoutFromAttribute in layoutFromAttributes {
|
||||
// get layout to attribute
|
||||
let layoutToAttribute: LayoutAttribute
|
||||
#if canImport(UIKit)
|
||||
if layoutToAttributes.count > 0 {
|
||||
if self.from.attributes == .edges && self.to.attributes == .margins {
|
||||
switch layoutFromAttribute {
|
||||
case .left:
|
||||
layoutToAttribute = .leftMargin
|
||||
case .right:
|
||||
layoutToAttribute = .rightMargin
|
||||
case .top:
|
||||
layoutToAttribute = .topMargin
|
||||
case .bottom:
|
||||
layoutToAttribute = .bottomMargin
|
||||
default:
|
||||
fatalError()
|
||||
}
|
||||
} else if self.from.attributes == .margins && self.to.attributes == .edges {
|
||||
switch layoutFromAttribute {
|
||||
case .leftMargin:
|
||||
layoutToAttribute = .left
|
||||
case .rightMargin:
|
||||
layoutToAttribute = .right
|
||||
case .topMargin:
|
||||
layoutToAttribute = .top
|
||||
case .bottomMargin:
|
||||
layoutToAttribute = .bottom
|
||||
default:
|
||||
fatalError()
|
||||
}
|
||||
} else if self.from.attributes == .directionalEdges && self.to.attributes == .directionalMargins {
|
||||
switch layoutFromAttribute {
|
||||
case .leading:
|
||||
layoutToAttribute = .leadingMargin
|
||||
case .trailing:
|
||||
layoutToAttribute = .trailingMargin
|
||||
case .top:
|
||||
layoutToAttribute = .topMargin
|
||||
case .bottom:
|
||||
layoutToAttribute = .bottomMargin
|
||||
default:
|
||||
fatalError()
|
||||
}
|
||||
} else if self.from.attributes == .directionalMargins && self.to.attributes == .directionalEdges {
|
||||
switch layoutFromAttribute {
|
||||
case .leadingMargin:
|
||||
layoutToAttribute = .leading
|
||||
case .trailingMargin:
|
||||
layoutToAttribute = .trailing
|
||||
case .topMargin:
|
||||
layoutToAttribute = .top
|
||||
case .bottomMargin:
|
||||
layoutToAttribute = .bottom
|
||||
default:
|
||||
fatalError()
|
||||
}
|
||||
} else if self.from.attributes == self.to.attributes {
|
||||
layoutToAttribute = layoutFromAttribute
|
||||
} else {
|
||||
layoutToAttribute = layoutToAttributes[0]
|
||||
}
|
||||
} else {
|
||||
if self.to.target == nil && (layoutFromAttribute == .centerX || layoutFromAttribute == .centerY) {
|
||||
layoutToAttribute = layoutFromAttribute == .centerX ? .left : .top
|
||||
} else {
|
||||
layoutToAttribute = layoutFromAttribute
|
||||
}
|
||||
}
|
||||
#else
|
||||
if self.from.attributes == self.to.attributes {
|
||||
layoutToAttribute = layoutFromAttribute
|
||||
} else if layoutToAttributes.count > 0 {
|
||||
layoutToAttribute = layoutToAttributes[0]
|
||||
} else {
|
||||
layoutToAttribute = layoutFromAttribute
|
||||
}
|
||||
#endif
|
||||
|
||||
// get layout constant
|
||||
let layoutConstant: CGFloat = self.constant.constraintConstantTargetValueFor(layoutAttribute: layoutToAttribute)
|
||||
|
||||
// get layout to
|
||||
var layoutTo: AnyObject? = self.to.target
|
||||
|
||||
// use superview if possible
|
||||
if layoutTo == nil && layoutToAttribute != .width && layoutToAttribute != .height {
|
||||
layoutTo = layoutFrom.superview
|
||||
}
|
||||
|
||||
// create layout constraint
|
||||
let layoutConstraint = LayoutConstraint(
|
||||
item: layoutFrom,
|
||||
attribute: layoutFromAttribute,
|
||||
relatedBy: layoutRelation,
|
||||
toItem: layoutTo,
|
||||
attribute: layoutToAttribute,
|
||||
multiplier: self.multiplier.constraintMultiplierTargetValue,
|
||||
constant: layoutConstant
|
||||
)
|
||||
|
||||
// set label
|
||||
layoutConstraint.label = self.label
|
||||
|
||||
// set priority
|
||||
layoutConstraint.priority = LayoutPriority(rawValue: self.priority.constraintPriorityTargetValue)
|
||||
|
||||
// set constraint
|
||||
layoutConstraint.constraint = self
|
||||
|
||||
// append
|
||||
self.layoutConstraints.append(layoutConstraint)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Public
|
||||
|
||||
@available(*, deprecated, renamed:"activate()")
|
||||
public func install() {
|
||||
self.activate()
|
||||
}
|
||||
|
||||
@available(*, deprecated, renamed:"deactivate()")
|
||||
public func uninstall() {
|
||||
self.deactivate()
|
||||
}
|
||||
|
||||
public func activate() {
|
||||
self.activateIfNeeded()
|
||||
}
|
||||
|
||||
public func deactivate() {
|
||||
self.deactivateIfNeeded()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func update(offset: ConstraintOffsetTarget) -> Constraint {
|
||||
self.constant = offset.constraintOffsetTargetValue
|
||||
return self
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func update(inset: ConstraintInsetTarget) -> Constraint {
|
||||
self.constant = inset.constraintInsetTargetValue
|
||||
return self
|
||||
}
|
||||
|
||||
#if canImport(UIKit)
|
||||
@discardableResult
|
||||
@available(iOS 11.0, tvOS 11.0, *)
|
||||
public func update(inset: ConstraintDirectionalInsetTarget) -> Constraint {
|
||||
self.constant = inset.constraintDirectionalInsetTargetValue
|
||||
return self
|
||||
}
|
||||
#endif
|
||||
|
||||
@discardableResult
|
||||
public func update(priority: ConstraintPriorityTarget) -> Constraint {
|
||||
self.priority = priority.constraintPriorityTargetValue
|
||||
return self
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func update(priority: ConstraintPriority) -> Constraint {
|
||||
self.priority = priority.value
|
||||
return self
|
||||
}
|
||||
|
||||
@available(*, deprecated, renamed:"update(offset:)")
|
||||
public func updateOffset(amount: ConstraintOffsetTarget) -> Void { self.update(offset: amount) }
|
||||
|
||||
@available(*, deprecated, renamed:"update(inset:)")
|
||||
public func updateInsets(amount: ConstraintInsetTarget) -> Void { self.update(inset: amount) }
|
||||
|
||||
@available(*, deprecated, renamed:"update(priority:)")
|
||||
public func updatePriority(amount: ConstraintPriorityTarget) -> Void { self.update(priority: amount) }
|
||||
|
||||
@available(*, deprecated, message:"Use update(priority: ConstraintPriorityTarget) instead.")
|
||||
public func updatePriorityRequired() -> Void {}
|
||||
|
||||
@available(*, deprecated, message:"Use update(priority: ConstraintPriorityTarget) instead.")
|
||||
public func updatePriorityHigh() -> Void { fatalError("Must be implemented by Concrete subclass.") }
|
||||
|
||||
@available(*, deprecated, message:"Use update(priority: ConstraintPriorityTarget) instead.")
|
||||
public func updatePriorityMedium() -> Void { fatalError("Must be implemented by Concrete subclass.") }
|
||||
|
||||
@available(*, deprecated, message:"Use update(priority: ConstraintPriorityTarget) instead.")
|
||||
public func updatePriorityLow() -> Void { fatalError("Must be implemented by Concrete subclass.") }
|
||||
|
||||
// MARK: Internal
|
||||
|
||||
internal func updateConstantAndPriorityIfNeeded() {
|
||||
for layoutConstraint in self.layoutConstraints {
|
||||
let attribute = (layoutConstraint.secondAttribute == .notAnAttribute) ? layoutConstraint.firstAttribute : layoutConstraint.secondAttribute
|
||||
layoutConstraint.constant = self.constant.constraintConstantTargetValueFor(layoutAttribute: attribute)
|
||||
|
||||
let requiredPriority = ConstraintPriority.required.value
|
||||
if (layoutConstraint.priority.rawValue < requiredPriority), (self.priority.constraintPriorityTargetValue != requiredPriority) {
|
||||
layoutConstraint.priority = LayoutPriority(rawValue: self.priority.constraintPriorityTargetValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal func activateIfNeeded(updatingExisting: Bool = false) {
|
||||
guard let item = self.from.layoutConstraintItem else {
|
||||
print("WARNING: SnapKit failed to get from item from constraint. Activate will be a no-op.")
|
||||
return
|
||||
}
|
||||
let layoutConstraints = self.layoutConstraints
|
||||
|
||||
if updatingExisting {
|
||||
var existingLayoutConstraints: [LayoutConstraint] = []
|
||||
for constraint in item.constraints {
|
||||
existingLayoutConstraints += constraint.layoutConstraints
|
||||
}
|
||||
|
||||
for layoutConstraint in layoutConstraints {
|
||||
let existingLayoutConstraint = existingLayoutConstraints.first { $0 == layoutConstraint }
|
||||
guard let updateLayoutConstraint = existingLayoutConstraint else {
|
||||
fatalError("Updated constraint could not find existing matching constraint to update: \(layoutConstraint)")
|
||||
}
|
||||
|
||||
let updateLayoutAttribute = (updateLayoutConstraint.secondAttribute == .notAnAttribute) ? updateLayoutConstraint.firstAttribute : updateLayoutConstraint.secondAttribute
|
||||
updateLayoutConstraint.constant = self.constant.constraintConstantTargetValueFor(layoutAttribute: updateLayoutAttribute)
|
||||
}
|
||||
} else {
|
||||
NSLayoutConstraint.activate(layoutConstraints)
|
||||
item.add(constraints: [self])
|
||||
}
|
||||
}
|
||||
|
||||
internal func deactivateIfNeeded() {
|
||||
guard let item = self.from.layoutConstraintItem else {
|
||||
print("WARNING: SnapKit failed to get from item from constraint. Deactivate will be a no-op.")
|
||||
return
|
||||
}
|
||||
let layoutConstraints = self.layoutConstraints
|
||||
NSLayoutConstraint.deactivate(layoutConstraints)
|
||||
item.remove(constraints: [self])
|
||||
}
|
||||
}
|
||||
203
ReadViewDemo/Pods/SnapKit/Sources/ConstraintAttributes.swift
generated
Normal file
203
ReadViewDemo/Pods/SnapKit/Sources/ConstraintAttributes.swift
generated
Normal file
@ -0,0 +1,203 @@
|
||||
//
|
||||
// SnapKit
|
||||
//
|
||||
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
|
||||
internal struct ConstraintAttributes : OptionSet, ExpressibleByIntegerLiteral {
|
||||
|
||||
typealias IntegerLiteralType = UInt
|
||||
|
||||
internal init(rawValue: UInt) {
|
||||
self.rawValue = rawValue
|
||||
}
|
||||
internal init(_ rawValue: UInt) {
|
||||
self.init(rawValue: rawValue)
|
||||
}
|
||||
internal init(nilLiteral: ()) {
|
||||
self.rawValue = 0
|
||||
}
|
||||
internal init(integerLiteral rawValue: IntegerLiteralType) {
|
||||
self.init(rawValue: rawValue)
|
||||
}
|
||||
|
||||
internal private(set) var rawValue: UInt
|
||||
internal static var allZeros: ConstraintAttributes { return 0 }
|
||||
internal static func convertFromNilLiteral() -> ConstraintAttributes { return 0 }
|
||||
internal var boolValue: Bool { return self.rawValue != 0 }
|
||||
|
||||
internal func toRaw() -> UInt { return self.rawValue }
|
||||
internal static func fromRaw(_ raw: UInt) -> ConstraintAttributes? { return self.init(raw) }
|
||||
internal static func fromMask(_ raw: UInt) -> ConstraintAttributes { return self.init(raw) }
|
||||
|
||||
// normal
|
||||
|
||||
internal static let none: ConstraintAttributes = 0
|
||||
internal static let left: ConstraintAttributes = ConstraintAttributes(UInt(1) << 0)
|
||||
internal static let top: ConstraintAttributes = ConstraintAttributes(UInt(1) << 1)
|
||||
internal static let right: ConstraintAttributes = ConstraintAttributes(UInt(1) << 2)
|
||||
internal static let bottom: ConstraintAttributes = ConstraintAttributes(UInt(1) << 3)
|
||||
internal static let leading: ConstraintAttributes = ConstraintAttributes(UInt(1) << 4)
|
||||
internal static let trailing: ConstraintAttributes = ConstraintAttributes(UInt(1) << 5)
|
||||
internal static let width: ConstraintAttributes = ConstraintAttributes(UInt(1) << 6)
|
||||
internal static let height: ConstraintAttributes = ConstraintAttributes(UInt(1) << 7)
|
||||
internal static let centerX: ConstraintAttributes = ConstraintAttributes(UInt(1) << 8)
|
||||
internal static let centerY: ConstraintAttributes = ConstraintAttributes(UInt(1) << 9)
|
||||
internal static let lastBaseline: ConstraintAttributes = ConstraintAttributes(UInt(1) << 10)
|
||||
|
||||
@available(iOS 8.0, OSX 10.11, *)
|
||||
internal static let firstBaseline: ConstraintAttributes = ConstraintAttributes(UInt(1) << 11)
|
||||
|
||||
@available(iOS 8.0, *)
|
||||
internal static let leftMargin: ConstraintAttributes = ConstraintAttributes(UInt(1) << 12)
|
||||
|
||||
@available(iOS 8.0, *)
|
||||
internal static let rightMargin: ConstraintAttributes = ConstraintAttributes(UInt(1) << 13)
|
||||
|
||||
@available(iOS 8.0, *)
|
||||
internal static let topMargin: ConstraintAttributes = ConstraintAttributes(UInt(1) << 14)
|
||||
|
||||
@available(iOS 8.0, *)
|
||||
internal static let bottomMargin: ConstraintAttributes = ConstraintAttributes(UInt(1) << 15)
|
||||
|
||||
@available(iOS 8.0, *)
|
||||
internal static let leadingMargin: ConstraintAttributes = ConstraintAttributes(UInt(1) << 16)
|
||||
|
||||
@available(iOS 8.0, *)
|
||||
internal static let trailingMargin: ConstraintAttributes = ConstraintAttributes(UInt(1) << 17)
|
||||
|
||||
@available(iOS 8.0, *)
|
||||
internal static let centerXWithinMargins: ConstraintAttributes = ConstraintAttributes(UInt(1) << 18)
|
||||
|
||||
@available(iOS 8.0, *)
|
||||
internal static let centerYWithinMargins: ConstraintAttributes = ConstraintAttributes(UInt(1) << 19)
|
||||
|
||||
// aggregates
|
||||
|
||||
internal static let edges: ConstraintAttributes = [.horizontalEdges, .verticalEdges]
|
||||
internal static let horizontalEdges: ConstraintAttributes = [.left, .right]
|
||||
internal static let verticalEdges: ConstraintAttributes = [.top, .bottom]
|
||||
internal static let directionalEdges: ConstraintAttributes = [.directionalHorizontalEdges, .directionalVerticalEdges]
|
||||
internal static let directionalHorizontalEdges: ConstraintAttributes = [.leading, .trailing]
|
||||
internal static let directionalVerticalEdges: ConstraintAttributes = [.top, .bottom]
|
||||
internal static let size: ConstraintAttributes = [.width, .height]
|
||||
internal static let center: ConstraintAttributes = [.centerX, .centerY]
|
||||
|
||||
@available(iOS 8.0, *)
|
||||
internal static let margins: ConstraintAttributes = [.leftMargin, .topMargin, .rightMargin, .bottomMargin]
|
||||
|
||||
@available(iOS 8.0, *)
|
||||
internal static let directionalMargins: ConstraintAttributes = [.leadingMargin, .topMargin, .trailingMargin, .bottomMargin]
|
||||
|
||||
@available(iOS 8.0, *)
|
||||
internal static let centerWithinMargins: ConstraintAttributes = [.centerXWithinMargins, .centerYWithinMargins]
|
||||
|
||||
internal var layoutAttributes:[LayoutAttribute] {
|
||||
var attrs = [LayoutAttribute]()
|
||||
if (self.contains(ConstraintAttributes.left)) {
|
||||
attrs.append(.left)
|
||||
}
|
||||
if (self.contains(ConstraintAttributes.top)) {
|
||||
attrs.append(.top)
|
||||
}
|
||||
if (self.contains(ConstraintAttributes.right)) {
|
||||
attrs.append(.right)
|
||||
}
|
||||
if (self.contains(ConstraintAttributes.bottom)) {
|
||||
attrs.append(.bottom)
|
||||
}
|
||||
if (self.contains(ConstraintAttributes.leading)) {
|
||||
attrs.append(.leading)
|
||||
}
|
||||
if (self.contains(ConstraintAttributes.trailing)) {
|
||||
attrs.append(.trailing)
|
||||
}
|
||||
if (self.contains(ConstraintAttributes.width)) {
|
||||
attrs.append(.width)
|
||||
}
|
||||
if (self.contains(ConstraintAttributes.height)) {
|
||||
attrs.append(.height)
|
||||
}
|
||||
if (self.contains(ConstraintAttributes.centerX)) {
|
||||
attrs.append(.centerX)
|
||||
}
|
||||
if (self.contains(ConstraintAttributes.centerY)) {
|
||||
attrs.append(.centerY)
|
||||
}
|
||||
if (self.contains(ConstraintAttributes.lastBaseline)) {
|
||||
attrs.append(.lastBaseline)
|
||||
}
|
||||
|
||||
#if canImport(UIKit)
|
||||
if (self.contains(ConstraintAttributes.firstBaseline)) {
|
||||
attrs.append(.firstBaseline)
|
||||
}
|
||||
if (self.contains(ConstraintAttributes.leftMargin)) {
|
||||
attrs.append(.leftMargin)
|
||||
}
|
||||
if (self.contains(ConstraintAttributes.rightMargin)) {
|
||||
attrs.append(.rightMargin)
|
||||
}
|
||||
if (self.contains(ConstraintAttributes.topMargin)) {
|
||||
attrs.append(.topMargin)
|
||||
}
|
||||
if (self.contains(ConstraintAttributes.bottomMargin)) {
|
||||
attrs.append(.bottomMargin)
|
||||
}
|
||||
if (self.contains(ConstraintAttributes.leadingMargin)) {
|
||||
attrs.append(.leadingMargin)
|
||||
}
|
||||
if (self.contains(ConstraintAttributes.trailingMargin)) {
|
||||
attrs.append(.trailingMargin)
|
||||
}
|
||||
if (self.contains(ConstraintAttributes.centerXWithinMargins)) {
|
||||
attrs.append(.centerXWithinMargins)
|
||||
}
|
||||
if (self.contains(ConstraintAttributes.centerYWithinMargins)) {
|
||||
attrs.append(.centerYWithinMargins)
|
||||
}
|
||||
#endif
|
||||
|
||||
return attrs
|
||||
}
|
||||
}
|
||||
|
||||
internal func + (left: ConstraintAttributes, right: ConstraintAttributes) -> ConstraintAttributes {
|
||||
return left.union(right)
|
||||
}
|
||||
|
||||
internal func +=(left: inout ConstraintAttributes, right: ConstraintAttributes) {
|
||||
left.formUnion(right)
|
||||
}
|
||||
|
||||
internal func -=(left: inout ConstraintAttributes, right: ConstraintAttributes) {
|
||||
left.subtract(right)
|
||||
}
|
||||
|
||||
internal func ==(left: ConstraintAttributes, right: ConstraintAttributes) -> Bool {
|
||||
return left.rawValue == right.rawValue
|
||||
}
|
||||
37
ReadViewDemo/Pods/SnapKit/Sources/ConstraintConfig.swift
generated
Normal file
37
ReadViewDemo/Pods/SnapKit/Sources/ConstraintConfig.swift
generated
Normal file
@ -0,0 +1,37 @@
|
||||
//
|
||||
// SnapKit
|
||||
//
|
||||
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
public typealias ConstraintInterfaceLayoutDirection = UIUserInterfaceLayoutDirection
|
||||
#else
|
||||
import AppKit
|
||||
public typealias ConstraintInterfaceLayoutDirection = NSUserInterfaceLayoutDirection
|
||||
#endif
|
||||
|
||||
|
||||
public struct ConstraintConfig {
|
||||
|
||||
public static var interfaceLayoutDirection: ConstraintInterfaceLayoutDirection = .leftToRight
|
||||
|
||||
}
|
||||
213
ReadViewDemo/Pods/SnapKit/Sources/ConstraintConstantTarget.swift
generated
Normal file
213
ReadViewDemo/Pods/SnapKit/Sources/ConstraintConstantTarget.swift
generated
Normal file
@ -0,0 +1,213 @@
|
||||
//
|
||||
// SnapKit
|
||||
//
|
||||
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
|
||||
public protocol ConstraintConstantTarget {
|
||||
}
|
||||
|
||||
extension CGPoint: ConstraintConstantTarget {
|
||||
}
|
||||
|
||||
extension CGSize: ConstraintConstantTarget {
|
||||
}
|
||||
|
||||
extension ConstraintInsets: ConstraintConstantTarget {
|
||||
}
|
||||
|
||||
#if canImport(UIKit)
|
||||
@available(iOS 11.0, tvOS 11.0, *)
|
||||
extension ConstraintDirectionalInsets: ConstraintConstantTarget {
|
||||
}
|
||||
#endif
|
||||
|
||||
extension ConstraintConstantTarget {
|
||||
|
||||
internal func constraintConstantTargetValueFor(layoutAttribute: LayoutAttribute) -> CGFloat {
|
||||
if let value = self as? CGFloat {
|
||||
return value
|
||||
}
|
||||
|
||||
if let value = self as? Float {
|
||||
return CGFloat(value)
|
||||
}
|
||||
|
||||
if let value = self as? Double {
|
||||
return CGFloat(value)
|
||||
}
|
||||
|
||||
if let value = self as? Int {
|
||||
return CGFloat(value)
|
||||
}
|
||||
|
||||
if let value = self as? UInt {
|
||||
return CGFloat(value)
|
||||
}
|
||||
|
||||
if let value = self as? CGSize {
|
||||
if layoutAttribute == .width {
|
||||
return value.width
|
||||
} else if layoutAttribute == .height {
|
||||
return value.height
|
||||
} else {
|
||||
return 0.0
|
||||
}
|
||||
}
|
||||
|
||||
if let value = self as? CGPoint {
|
||||
#if canImport(UIKit)
|
||||
switch layoutAttribute {
|
||||
case .left, .right, .leading, .trailing, .centerX, .leftMargin, .rightMargin, .leadingMargin, .trailingMargin, .centerXWithinMargins:
|
||||
return value.x
|
||||
case .top, .bottom, .centerY, .topMargin, .bottomMargin, .centerYWithinMargins, .lastBaseline, .firstBaseline:
|
||||
return value.y
|
||||
case .width, .height, .notAnAttribute:
|
||||
return 0.0
|
||||
#if swift(>=5.0)
|
||||
@unknown default:
|
||||
return 0.0
|
||||
#endif
|
||||
}
|
||||
#else
|
||||
switch layoutAttribute {
|
||||
case .left, .right, .leading, .trailing, .centerX:
|
||||
return value.x
|
||||
case .top, .bottom, .centerY, .lastBaseline, .firstBaseline:
|
||||
return value.y
|
||||
case .width, .height, .notAnAttribute:
|
||||
return 0.0
|
||||
#if swift(>=5.0)
|
||||
@unknown default:
|
||||
return 0.0
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
if let value = self as? ConstraintInsets {
|
||||
#if canImport(UIKit)
|
||||
switch layoutAttribute {
|
||||
case .left, .leftMargin:
|
||||
return value.left
|
||||
case .top, .topMargin, .firstBaseline:
|
||||
return value.top
|
||||
case .right, .rightMargin:
|
||||
return -value.right
|
||||
case .bottom, .bottomMargin, .lastBaseline:
|
||||
return -value.bottom
|
||||
case .leading, .leadingMargin:
|
||||
return (ConstraintConfig.interfaceLayoutDirection == .leftToRight) ? value.left : value.right
|
||||
case .trailing, .trailingMargin:
|
||||
return (ConstraintConfig.interfaceLayoutDirection == .leftToRight) ? -value.right : -value.left
|
||||
case .centerX, .centerXWithinMargins:
|
||||
return (value.left - value.right) / 2
|
||||
case .centerY, .centerYWithinMargins:
|
||||
return (value.top - value.bottom) / 2
|
||||
case .width:
|
||||
return -(value.left + value.right)
|
||||
case .height:
|
||||
return -(value.top + value.bottom)
|
||||
case .notAnAttribute:
|
||||
return 0.0
|
||||
#if swift(>=5.0)
|
||||
@unknown default:
|
||||
return 0.0
|
||||
#endif
|
||||
}
|
||||
#else
|
||||
switch layoutAttribute {
|
||||
case .left:
|
||||
return value.left
|
||||
case .top, .firstBaseline:
|
||||
return value.top
|
||||
case .right:
|
||||
return -value.right
|
||||
case .bottom, .lastBaseline:
|
||||
return -value.bottom
|
||||
case .leading:
|
||||
return (ConstraintConfig.interfaceLayoutDirection == .leftToRight) ? value.left : value.right
|
||||
case .trailing:
|
||||
return (ConstraintConfig.interfaceLayoutDirection == .leftToRight) ? -value.right : -value.left
|
||||
case .centerX:
|
||||
return (value.left - value.right) / 2
|
||||
case .centerY:
|
||||
return (value.top - value.bottom) / 2
|
||||
case .width:
|
||||
return -(value.left + value.right)
|
||||
case .height:
|
||||
return -(value.top + value.bottom)
|
||||
case .notAnAttribute:
|
||||
return 0.0
|
||||
#if swift(>=5.0)
|
||||
@unknown default:
|
||||
return 0.0
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if canImport(UIKit)
|
||||
if #available(iOS 11.0, tvOS 11.0, *), let value = self as? ConstraintDirectionalInsets {
|
||||
switch layoutAttribute {
|
||||
case .left, .leftMargin:
|
||||
return (ConstraintConfig.interfaceLayoutDirection == .leftToRight) ? value.leading : value.trailing
|
||||
case .top, .topMargin, .firstBaseline:
|
||||
return value.top
|
||||
case .right, .rightMargin:
|
||||
return (ConstraintConfig.interfaceLayoutDirection == .leftToRight) ? -value.trailing : -value.leading
|
||||
case .bottom, .bottomMargin, .lastBaseline:
|
||||
return -value.bottom
|
||||
case .leading, .leadingMargin:
|
||||
return value.leading
|
||||
case .trailing, .trailingMargin:
|
||||
return -value.trailing
|
||||
case .centerX, .centerXWithinMargins:
|
||||
return (value.leading - value.trailing) / 2
|
||||
case .centerY, .centerYWithinMargins:
|
||||
return (value.top - value.bottom) / 2
|
||||
case .width:
|
||||
return -(value.leading + value.trailing)
|
||||
case .height:
|
||||
return -(value.top + value.bottom)
|
||||
case .notAnAttribute:
|
||||
return 0.0
|
||||
#if swift(>=5.0)
|
||||
@unknown default:
|
||||
return 0.0
|
||||
#else
|
||||
default:
|
||||
return 0.0
|
||||
#endif
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
return 0.0
|
||||
}
|
||||
|
||||
}
|
||||
209
ReadViewDemo/Pods/SnapKit/Sources/ConstraintDSL.swift
generated
Normal file
209
ReadViewDemo/Pods/SnapKit/Sources/ConstraintDSL.swift
generated
Normal file
@ -0,0 +1,209 @@
|
||||
//
|
||||
// SnapKit
|
||||
//
|
||||
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
|
||||
public protocol ConstraintDSL {
|
||||
|
||||
var target: AnyObject? { get }
|
||||
|
||||
func setLabel(_ value: String?)
|
||||
func label() -> String?
|
||||
|
||||
}
|
||||
extension ConstraintDSL {
|
||||
|
||||
public func setLabel(_ value: String?) {
|
||||
objc_setAssociatedObject(self.target as Any, &labelKey, value, .OBJC_ASSOCIATION_COPY_NONATOMIC)
|
||||
}
|
||||
public func label() -> String? {
|
||||
return objc_getAssociatedObject(self.target as Any, &labelKey) as? String
|
||||
}
|
||||
|
||||
}
|
||||
private var labelKey: UInt8 = 0
|
||||
|
||||
|
||||
public protocol ConstraintBasicAttributesDSL : ConstraintDSL {
|
||||
}
|
||||
extension ConstraintBasicAttributesDSL {
|
||||
|
||||
// MARK: Basics
|
||||
|
||||
public var left: ConstraintItem {
|
||||
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.left)
|
||||
}
|
||||
|
||||
public var top: ConstraintItem {
|
||||
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.top)
|
||||
}
|
||||
|
||||
public var right: ConstraintItem {
|
||||
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.right)
|
||||
}
|
||||
|
||||
public var bottom: ConstraintItem {
|
||||
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.bottom)
|
||||
}
|
||||
|
||||
public var leading: ConstraintItem {
|
||||
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.leading)
|
||||
}
|
||||
|
||||
public var trailing: ConstraintItem {
|
||||
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.trailing)
|
||||
}
|
||||
|
||||
public var width: ConstraintItem {
|
||||
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.width)
|
||||
}
|
||||
|
||||
public var height: ConstraintItem {
|
||||
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.height)
|
||||
}
|
||||
|
||||
public var centerX: ConstraintItem {
|
||||
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.centerX)
|
||||
}
|
||||
|
||||
public var centerY: ConstraintItem {
|
||||
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.centerY)
|
||||
}
|
||||
|
||||
public var edges: ConstraintItem {
|
||||
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.edges)
|
||||
}
|
||||
|
||||
public var directionalEdges: ConstraintItem {
|
||||
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.directionalEdges)
|
||||
}
|
||||
|
||||
public var horizontalEdges: ConstraintItem {
|
||||
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.horizontalEdges)
|
||||
}
|
||||
|
||||
public var verticalEdges: ConstraintItem {
|
||||
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.verticalEdges)
|
||||
}
|
||||
|
||||
public var directionalHorizontalEdges: ConstraintItem {
|
||||
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.directionalHorizontalEdges)
|
||||
}
|
||||
|
||||
public var directionalVerticalEdges: ConstraintItem {
|
||||
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.directionalVerticalEdges)
|
||||
}
|
||||
|
||||
public var size: ConstraintItem {
|
||||
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.size)
|
||||
}
|
||||
|
||||
public var center: ConstraintItem {
|
||||
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.center)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public protocol ConstraintAttributesDSL : ConstraintBasicAttributesDSL {
|
||||
}
|
||||
extension ConstraintAttributesDSL {
|
||||
|
||||
// MARK: Baselines
|
||||
@available(*, deprecated, renamed:"lastBaseline")
|
||||
public var baseline: ConstraintItem {
|
||||
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.lastBaseline)
|
||||
}
|
||||
|
||||
@available(iOS 8.0, OSX 10.11, *)
|
||||
public var lastBaseline: ConstraintItem {
|
||||
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.lastBaseline)
|
||||
}
|
||||
|
||||
@available(iOS 8.0, OSX 10.11, *)
|
||||
public var firstBaseline: ConstraintItem {
|
||||
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.firstBaseline)
|
||||
}
|
||||
|
||||
// MARK: Margins
|
||||
|
||||
@available(iOS 8.0, *)
|
||||
public var leftMargin: ConstraintItem {
|
||||
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.leftMargin)
|
||||
}
|
||||
|
||||
@available(iOS 8.0, *)
|
||||
public var topMargin: ConstraintItem {
|
||||
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.topMargin)
|
||||
}
|
||||
|
||||
@available(iOS 8.0, *)
|
||||
public var rightMargin: ConstraintItem {
|
||||
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.rightMargin)
|
||||
}
|
||||
|
||||
@available(iOS 8.0, *)
|
||||
public var bottomMargin: ConstraintItem {
|
||||
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.bottomMargin)
|
||||
}
|
||||
|
||||
@available(iOS 8.0, *)
|
||||
public var leadingMargin: ConstraintItem {
|
||||
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.leadingMargin)
|
||||
}
|
||||
|
||||
@available(iOS 8.0, *)
|
||||
public var trailingMargin: ConstraintItem {
|
||||
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.trailingMargin)
|
||||
}
|
||||
|
||||
@available(iOS 8.0, *)
|
||||
public var centerXWithinMargins: ConstraintItem {
|
||||
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.centerXWithinMargins)
|
||||
}
|
||||
|
||||
@available(iOS 8.0, *)
|
||||
public var centerYWithinMargins: ConstraintItem {
|
||||
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.centerYWithinMargins)
|
||||
}
|
||||
|
||||
@available(iOS 8.0, *)
|
||||
public var margins: ConstraintItem {
|
||||
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.margins)
|
||||
}
|
||||
|
||||
@available(iOS 8.0, *)
|
||||
public var directionalMargins: ConstraintItem {
|
||||
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.directionalMargins)
|
||||
}
|
||||
|
||||
@available(iOS 8.0, *)
|
||||
public var centerWithinMargins: ConstraintItem {
|
||||
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.centerWithinMargins)
|
||||
}
|
||||
|
||||
}
|
||||
69
ReadViewDemo/Pods/SnapKit/Sources/ConstraintDescription.swift
generated
Normal file
69
ReadViewDemo/Pods/SnapKit/Sources/ConstraintDescription.swift
generated
Normal file
@ -0,0 +1,69 @@
|
||||
//
|
||||
// SnapKit
|
||||
//
|
||||
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
|
||||
public class ConstraintDescription {
|
||||
|
||||
internal let item: LayoutConstraintItem
|
||||
internal var attributes: ConstraintAttributes
|
||||
internal var relation: ConstraintRelation? = nil
|
||||
internal var sourceLocation: (String, UInt)? = nil
|
||||
internal var label: String? = nil
|
||||
internal var related: ConstraintItem? = nil
|
||||
internal var multiplier: ConstraintMultiplierTarget = 1.0
|
||||
internal var constant: ConstraintConstantTarget = 0.0
|
||||
internal var priority: ConstraintPriorityTarget = 1000.0
|
||||
internal lazy var constraint: Constraint? = {
|
||||
guard let relation = self.relation,
|
||||
let related = self.related,
|
||||
let sourceLocation = self.sourceLocation else {
|
||||
return nil
|
||||
}
|
||||
let from = ConstraintItem(target: self.item, attributes: self.attributes)
|
||||
|
||||
return Constraint(
|
||||
from: from,
|
||||
to: related,
|
||||
relation: relation,
|
||||
sourceLocation: sourceLocation,
|
||||
label: self.label,
|
||||
multiplier: self.multiplier,
|
||||
constant: self.constant,
|
||||
priority: self.priority
|
||||
)
|
||||
}()
|
||||
|
||||
// MARK: Initialization
|
||||
|
||||
internal init(item: LayoutConstraintItem, attributes: ConstraintAttributes) {
|
||||
self.item = item
|
||||
self.attributes = attributes
|
||||
}
|
||||
|
||||
}
|
||||
49
ReadViewDemo/Pods/SnapKit/Sources/ConstraintDirectionalInsetTarget.swift
generated
Normal file
49
ReadViewDemo/Pods/SnapKit/Sources/ConstraintDirectionalInsetTarget.swift
generated
Normal file
@ -0,0 +1,49 @@
|
||||
//
|
||||
// SnapKit
|
||||
//
|
||||
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
#if canImport(UIKit)
|
||||
public protocol ConstraintDirectionalInsetTarget: ConstraintConstantTarget {
|
||||
}
|
||||
|
||||
@available(iOS 11.0, tvOS 11.0, *)
|
||||
extension ConstraintDirectionalInsets: ConstraintDirectionalInsetTarget {
|
||||
}
|
||||
|
||||
extension ConstraintDirectionalInsetTarget {
|
||||
|
||||
@available(iOS 11.0, tvOS 11.0, *)
|
||||
internal var constraintDirectionalInsetTargetValue: ConstraintDirectionalInsets {
|
||||
if let amount = self as? ConstraintDirectionalInsets {
|
||||
return amount
|
||||
} else {
|
||||
return ConstraintDirectionalInsets(top: 0, leading: 0, bottom: 0, trailing: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
34
ReadViewDemo/Pods/SnapKit/Sources/ConstraintDirectionalInsets.swift
generated
Normal file
34
ReadViewDemo/Pods/SnapKit/Sources/ConstraintDirectionalInsets.swift
generated
Normal file
@ -0,0 +1,34 @@
|
||||
//
|
||||
// SnapKit
|
||||
//
|
||||
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
|
||||
#if canImport(UIKit)
|
||||
@available(iOS 11.0, tvOS 11.0, *)
|
||||
public typealias ConstraintDirectionalInsets = NSDirectionalEdgeInsets
|
||||
#endif
|
||||
72
ReadViewDemo/Pods/SnapKit/Sources/ConstraintInsetTarget.swift
generated
Normal file
72
ReadViewDemo/Pods/SnapKit/Sources/ConstraintInsetTarget.swift
generated
Normal file
@ -0,0 +1,72 @@
|
||||
//
|
||||
// SnapKit
|
||||
//
|
||||
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
|
||||
public protocol ConstraintInsetTarget: ConstraintConstantTarget {
|
||||
}
|
||||
|
||||
extension Int: ConstraintInsetTarget {
|
||||
}
|
||||
|
||||
extension UInt: ConstraintInsetTarget {
|
||||
}
|
||||
|
||||
extension Float: ConstraintInsetTarget {
|
||||
}
|
||||
|
||||
extension Double: ConstraintInsetTarget {
|
||||
}
|
||||
|
||||
extension CGFloat: ConstraintInsetTarget {
|
||||
}
|
||||
|
||||
extension ConstraintInsets: ConstraintInsetTarget {
|
||||
}
|
||||
|
||||
extension ConstraintInsetTarget {
|
||||
|
||||
internal var constraintInsetTargetValue: ConstraintInsets {
|
||||
if let amount = self as? ConstraintInsets {
|
||||
return amount
|
||||
} else if let amount = self as? Float {
|
||||
return ConstraintInsets(top: CGFloat(amount), left: CGFloat(amount), bottom: CGFloat(amount), right: CGFloat(amount))
|
||||
} else if let amount = self as? Double {
|
||||
return ConstraintInsets(top: CGFloat(amount), left: CGFloat(amount), bottom: CGFloat(amount), right: CGFloat(amount))
|
||||
} else if let amount = self as? CGFloat {
|
||||
return ConstraintInsets(top: amount, left: amount, bottom: amount, right: amount)
|
||||
} else if let amount = self as? Int {
|
||||
return ConstraintInsets(top: CGFloat(amount), left: CGFloat(amount), bottom: CGFloat(amount), right: CGFloat(amount))
|
||||
} else if let amount = self as? UInt {
|
||||
return ConstraintInsets(top: CGFloat(amount), left: CGFloat(amount), bottom: CGFloat(amount), right: CGFloat(amount))
|
||||
} else {
|
||||
return ConstraintInsets(top: 0, left: 0, bottom: 0, right: 0)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
35
ReadViewDemo/Pods/SnapKit/Sources/ConstraintInsets.swift
generated
Normal file
35
ReadViewDemo/Pods/SnapKit/Sources/ConstraintInsets.swift
generated
Normal file
@ -0,0 +1,35 @@
|
||||
//
|
||||
// SnapKit
|
||||
//
|
||||
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
|
||||
#if canImport(UIKit)
|
||||
public typealias ConstraintInsets = UIEdgeInsets
|
||||
#else
|
||||
public typealias ConstraintInsets = NSEdgeInsets
|
||||
#endif
|
||||
61
ReadViewDemo/Pods/SnapKit/Sources/ConstraintItem.swift
generated
Normal file
61
ReadViewDemo/Pods/SnapKit/Sources/ConstraintItem.swift
generated
Normal file
@ -0,0 +1,61 @@
|
||||
//
|
||||
// SnapKit
|
||||
//
|
||||
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
|
||||
public final class ConstraintItem {
|
||||
|
||||
internal weak var target: AnyObject?
|
||||
internal let attributes: ConstraintAttributes
|
||||
|
||||
internal init(target: AnyObject?, attributes: ConstraintAttributes) {
|
||||
self.target = target
|
||||
self.attributes = attributes
|
||||
}
|
||||
|
||||
internal var layoutConstraintItem: LayoutConstraintItem? {
|
||||
return self.target as? LayoutConstraintItem
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public func ==(lhs: ConstraintItem, rhs: ConstraintItem) -> Bool {
|
||||
// pointer equality
|
||||
guard lhs !== rhs else {
|
||||
return true
|
||||
}
|
||||
|
||||
// must both have valid targets and identical attributes
|
||||
guard let target1 = lhs.target,
|
||||
let target2 = rhs.target,
|
||||
target1 === target2 && lhs.attributes == rhs.attributes else {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
36
ReadViewDemo/Pods/SnapKit/Sources/ConstraintLayoutGuide+Extensions.swift
generated
Normal file
36
ReadViewDemo/Pods/SnapKit/Sources/ConstraintLayoutGuide+Extensions.swift
generated
Normal file
@ -0,0 +1,36 @@
|
||||
//
|
||||
// SnapKit
|
||||
//
|
||||
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
|
||||
@available(iOS 9.0, OSX 10.11, *)
|
||||
public extension ConstraintLayoutGuide {
|
||||
|
||||
var snp: ConstraintLayoutGuideDSL {
|
||||
return ConstraintLayoutGuideDSL(guide: self)
|
||||
}
|
||||
|
||||
}
|
||||
37
ReadViewDemo/Pods/SnapKit/Sources/ConstraintLayoutGuide.swift
generated
Normal file
37
ReadViewDemo/Pods/SnapKit/Sources/ConstraintLayoutGuide.swift
generated
Normal file
@ -0,0 +1,37 @@
|
||||
//
|
||||
// SnapKit
|
||||
//
|
||||
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
|
||||
#if canImport(UIKit)
|
||||
@available(iOS 9.0, *)
|
||||
public typealias ConstraintLayoutGuide = UILayoutGuide
|
||||
#else
|
||||
@available(OSX 10.11, *)
|
||||
public typealias ConstraintLayoutGuide = NSLayoutGuide
|
||||
#endif
|
||||
66
ReadViewDemo/Pods/SnapKit/Sources/ConstraintLayoutGuideDSL.swift
generated
Normal file
66
ReadViewDemo/Pods/SnapKit/Sources/ConstraintLayoutGuideDSL.swift
generated
Normal file
@ -0,0 +1,66 @@
|
||||
//
|
||||
// SnapKit
|
||||
//
|
||||
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
|
||||
@available(iOS 9.0, OSX 10.11, *)
|
||||
public struct ConstraintLayoutGuideDSL: ConstraintAttributesDSL {
|
||||
|
||||
@discardableResult
|
||||
public func prepareConstraints(_ closure: (_ make: ConstraintMaker) -> Void) -> [Constraint] {
|
||||
return ConstraintMaker.prepareConstraints(item: self.guide, closure: closure)
|
||||
}
|
||||
|
||||
public func makeConstraints(_ closure: (_ make: ConstraintMaker) -> Void) {
|
||||
ConstraintMaker.makeConstraints(item: self.guide, closure: closure)
|
||||
}
|
||||
|
||||
public func remakeConstraints(_ closure: (_ make: ConstraintMaker) -> Void) {
|
||||
ConstraintMaker.remakeConstraints(item: self.guide, closure: closure)
|
||||
}
|
||||
|
||||
public func updateConstraints(_ closure: (_ make: ConstraintMaker) -> Void) {
|
||||
ConstraintMaker.updateConstraints(item: self.guide, closure: closure)
|
||||
}
|
||||
|
||||
public func removeConstraints() {
|
||||
ConstraintMaker.removeConstraints(item: self.guide)
|
||||
}
|
||||
|
||||
public var target: AnyObject? {
|
||||
return self.guide
|
||||
}
|
||||
|
||||
internal let guide: ConstraintLayoutGuide
|
||||
|
||||
internal init(guide: ConstraintLayoutGuide) {
|
||||
self.guide = guide
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
36
ReadViewDemo/Pods/SnapKit/Sources/ConstraintLayoutSupport.swift
generated
Normal file
36
ReadViewDemo/Pods/SnapKit/Sources/ConstraintLayoutSupport.swift
generated
Normal file
@ -0,0 +1,36 @@
|
||||
//
|
||||
// SnapKit
|
||||
//
|
||||
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
|
||||
#if canImport(UIKit)
|
||||
@available(iOS 8.0, *)
|
||||
public typealias ConstraintLayoutSupport = UILayoutSupport
|
||||
#else
|
||||
public class ConstraintLayoutSupport {}
|
||||
#endif
|
||||
56
ReadViewDemo/Pods/SnapKit/Sources/ConstraintLayoutSupportDSL.swift
generated
Normal file
56
ReadViewDemo/Pods/SnapKit/Sources/ConstraintLayoutSupportDSL.swift
generated
Normal file
@ -0,0 +1,56 @@
|
||||
//
|
||||
// SnapKit
|
||||
//
|
||||
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
|
||||
@available(iOS 8.0, *)
|
||||
public struct ConstraintLayoutSupportDSL: ConstraintDSL {
|
||||
|
||||
public var target: AnyObject? {
|
||||
return self.support
|
||||
}
|
||||
|
||||
internal let support: ConstraintLayoutSupport
|
||||
|
||||
internal init(support: ConstraintLayoutSupport) {
|
||||
self.support = support
|
||||
|
||||
}
|
||||
|
||||
public var top: ConstraintItem {
|
||||
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.top)
|
||||
}
|
||||
|
||||
public var bottom: ConstraintItem {
|
||||
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.bottom)
|
||||
}
|
||||
|
||||
public var height: ConstraintItem {
|
||||
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.height)
|
||||
}
|
||||
}
|
||||
224
ReadViewDemo/Pods/SnapKit/Sources/ConstraintMaker.swift
generated
Normal file
224
ReadViewDemo/Pods/SnapKit/Sources/ConstraintMaker.swift
generated
Normal file
@ -0,0 +1,224 @@
|
||||
//
|
||||
// SnapKit
|
||||
//
|
||||
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
public class ConstraintMaker {
|
||||
|
||||
public var left: ConstraintMakerExtendable {
|
||||
return self.makeExtendableWithAttributes(.left)
|
||||
}
|
||||
|
||||
public var top: ConstraintMakerExtendable {
|
||||
return self.makeExtendableWithAttributes(.top)
|
||||
}
|
||||
|
||||
public var bottom: ConstraintMakerExtendable {
|
||||
return self.makeExtendableWithAttributes(.bottom)
|
||||
}
|
||||
|
||||
public var right: ConstraintMakerExtendable {
|
||||
return self.makeExtendableWithAttributes(.right)
|
||||
}
|
||||
|
||||
public var leading: ConstraintMakerExtendable {
|
||||
return self.makeExtendableWithAttributes(.leading)
|
||||
}
|
||||
|
||||
public var trailing: ConstraintMakerExtendable {
|
||||
return self.makeExtendableWithAttributes(.trailing)
|
||||
}
|
||||
|
||||
public var width: ConstraintMakerExtendable {
|
||||
return self.makeExtendableWithAttributes(.width)
|
||||
}
|
||||
|
||||
public var height: ConstraintMakerExtendable {
|
||||
return self.makeExtendableWithAttributes(.height)
|
||||
}
|
||||
|
||||
public var centerX: ConstraintMakerExtendable {
|
||||
return self.makeExtendableWithAttributes(.centerX)
|
||||
}
|
||||
|
||||
public var centerY: ConstraintMakerExtendable {
|
||||
return self.makeExtendableWithAttributes(.centerY)
|
||||
}
|
||||
|
||||
@available(*, deprecated, renamed:"lastBaseline")
|
||||
public var baseline: ConstraintMakerExtendable {
|
||||
return self.makeExtendableWithAttributes(.lastBaseline)
|
||||
}
|
||||
|
||||
public var lastBaseline: ConstraintMakerExtendable {
|
||||
return self.makeExtendableWithAttributes(.lastBaseline)
|
||||
}
|
||||
|
||||
@available(iOS 8.0, OSX 10.11, *)
|
||||
public var firstBaseline: ConstraintMakerExtendable {
|
||||
return self.makeExtendableWithAttributes(.firstBaseline)
|
||||
}
|
||||
|
||||
@available(iOS 8.0, *)
|
||||
public var leftMargin: ConstraintMakerExtendable {
|
||||
return self.makeExtendableWithAttributes(.leftMargin)
|
||||
}
|
||||
|
||||
@available(iOS 8.0, *)
|
||||
public var rightMargin: ConstraintMakerExtendable {
|
||||
return self.makeExtendableWithAttributes(.rightMargin)
|
||||
}
|
||||
|
||||
@available(iOS 8.0, *)
|
||||
public var topMargin: ConstraintMakerExtendable {
|
||||
return self.makeExtendableWithAttributes(.topMargin)
|
||||
}
|
||||
|
||||
@available(iOS 8.0, *)
|
||||
public var bottomMargin: ConstraintMakerExtendable {
|
||||
return self.makeExtendableWithAttributes(.bottomMargin)
|
||||
}
|
||||
|
||||
@available(iOS 8.0, *)
|
||||
public var leadingMargin: ConstraintMakerExtendable {
|
||||
return self.makeExtendableWithAttributes(.leadingMargin)
|
||||
}
|
||||
|
||||
@available(iOS 8.0, *)
|
||||
public var trailingMargin: ConstraintMakerExtendable {
|
||||
return self.makeExtendableWithAttributes(.trailingMargin)
|
||||
}
|
||||
|
||||
@available(iOS 8.0, *)
|
||||
public var centerXWithinMargins: ConstraintMakerExtendable {
|
||||
return self.makeExtendableWithAttributes(.centerXWithinMargins)
|
||||
}
|
||||
|
||||
@available(iOS 8.0, *)
|
||||
public var centerYWithinMargins: ConstraintMakerExtendable {
|
||||
return self.makeExtendableWithAttributes(.centerYWithinMargins)
|
||||
}
|
||||
|
||||
public var edges: ConstraintMakerExtendable {
|
||||
return self.makeExtendableWithAttributes(.edges)
|
||||
}
|
||||
public var horizontalEdges: ConstraintMakerExtendable {
|
||||
return self.makeExtendableWithAttributes(.horizontalEdges)
|
||||
}
|
||||
public var verticalEdges: ConstraintMakerExtendable {
|
||||
return self.makeExtendableWithAttributes(.verticalEdges)
|
||||
}
|
||||
public var directionalEdges: ConstraintMakerExtendable {
|
||||
return self.makeExtendableWithAttributes(.directionalEdges)
|
||||
}
|
||||
public var directionalHorizontalEdges: ConstraintMakerExtendable {
|
||||
return self.makeExtendableWithAttributes(.directionalHorizontalEdges)
|
||||
}
|
||||
public var directionalVerticalEdges: ConstraintMakerExtendable {
|
||||
return self.makeExtendableWithAttributes(.directionalVerticalEdges)
|
||||
}
|
||||
public var size: ConstraintMakerExtendable {
|
||||
return self.makeExtendableWithAttributes(.size)
|
||||
}
|
||||
public var center: ConstraintMakerExtendable {
|
||||
return self.makeExtendableWithAttributes(.center)
|
||||
}
|
||||
|
||||
@available(iOS 8.0, *)
|
||||
public var margins: ConstraintMakerExtendable {
|
||||
return self.makeExtendableWithAttributes(.margins)
|
||||
}
|
||||
|
||||
@available(iOS 8.0, *)
|
||||
public var directionalMargins: ConstraintMakerExtendable {
|
||||
return self.makeExtendableWithAttributes(.directionalMargins)
|
||||
}
|
||||
|
||||
@available(iOS 8.0, *)
|
||||
public var centerWithinMargins: ConstraintMakerExtendable {
|
||||
return self.makeExtendableWithAttributes(.centerWithinMargins)
|
||||
}
|
||||
|
||||
public let item: LayoutConstraintItem
|
||||
private var descriptions = [ConstraintDescription]()
|
||||
|
||||
internal init(item: LayoutConstraintItem) {
|
||||
self.item = item
|
||||
self.item.prepare()
|
||||
}
|
||||
|
||||
internal func makeExtendableWithAttributes(_ attributes: ConstraintAttributes) -> ConstraintMakerExtendable {
|
||||
let description = ConstraintDescription(item: self.item, attributes: attributes)
|
||||
self.descriptions.append(description)
|
||||
return ConstraintMakerExtendable(description)
|
||||
}
|
||||
|
||||
internal static func prepareConstraints(item: LayoutConstraintItem, closure: (_ make: ConstraintMaker) -> Void) -> [Constraint] {
|
||||
let maker = ConstraintMaker(item: item)
|
||||
closure(maker)
|
||||
var constraints: [Constraint] = []
|
||||
for description in maker.descriptions {
|
||||
guard let constraint = description.constraint else {
|
||||
continue
|
||||
}
|
||||
constraints.append(constraint)
|
||||
}
|
||||
return constraints
|
||||
}
|
||||
|
||||
internal static func makeConstraints(item: LayoutConstraintItem, closure: (_ make: ConstraintMaker) -> Void) {
|
||||
let constraints = prepareConstraints(item: item, closure: closure)
|
||||
for constraint in constraints {
|
||||
constraint.activateIfNeeded(updatingExisting: false)
|
||||
}
|
||||
}
|
||||
|
||||
internal static func remakeConstraints(item: LayoutConstraintItem, closure: (_ make: ConstraintMaker) -> Void) {
|
||||
self.removeConstraints(item: item)
|
||||
self.makeConstraints(item: item, closure: closure)
|
||||
}
|
||||
|
||||
internal static func updateConstraints(item: LayoutConstraintItem, closure: (_ make: ConstraintMaker) -> Void) {
|
||||
guard item.constraints.count > 0 else {
|
||||
self.makeConstraints(item: item, closure: closure)
|
||||
return
|
||||
}
|
||||
|
||||
let constraints = prepareConstraints(item: item, closure: closure)
|
||||
for constraint in constraints {
|
||||
constraint.activateIfNeeded(updatingExisting: true)
|
||||
}
|
||||
}
|
||||
|
||||
internal static func removeConstraints(item: LayoutConstraintItem) {
|
||||
let constraints = item.constraints
|
||||
for constraint in constraints {
|
||||
constraint.deactivateIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
64
ReadViewDemo/Pods/SnapKit/Sources/ConstraintMakerEditable.swift
generated
Normal file
64
ReadViewDemo/Pods/SnapKit/Sources/ConstraintMakerEditable.swift
generated
Normal file
@ -0,0 +1,64 @@
|
||||
//
|
||||
// SnapKit
|
||||
//
|
||||
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
|
||||
public class ConstraintMakerEditable: ConstraintMakerPrioritizable {
|
||||
|
||||
@discardableResult
|
||||
public func multipliedBy(_ amount: ConstraintMultiplierTarget) -> ConstraintMakerEditable {
|
||||
self.description.multiplier = amount
|
||||
return self
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func dividedBy(_ amount: ConstraintMultiplierTarget) -> ConstraintMakerEditable {
|
||||
return self.multipliedBy(1.0 / amount.constraintMultiplierTargetValue)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func offset(_ amount: ConstraintOffsetTarget) -> ConstraintMakerEditable {
|
||||
self.description.constant = amount.constraintOffsetTargetValue
|
||||
return self
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func inset(_ amount: ConstraintInsetTarget) -> ConstraintMakerEditable {
|
||||
self.description.constant = amount.constraintInsetTargetValue
|
||||
return self
|
||||
}
|
||||
|
||||
#if canImport(UIKit)
|
||||
@discardableResult
|
||||
@available(iOS 11.0, tvOS 11.0, *)
|
||||
public func inset(_ amount: ConstraintDirectionalInsetTarget) -> ConstraintMakerEditable {
|
||||
self.description.constant = amount.constraintDirectionalInsetTargetValue
|
||||
return self
|
||||
}
|
||||
#endif
|
||||
}
|
||||
195
ReadViewDemo/Pods/SnapKit/Sources/ConstraintMakerExtendable.swift
generated
Normal file
195
ReadViewDemo/Pods/SnapKit/Sources/ConstraintMakerExtendable.swift
generated
Normal file
@ -0,0 +1,195 @@
|
||||
//
|
||||
// SnapKit
|
||||
//
|
||||
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
|
||||
public class ConstraintMakerExtendable: ConstraintMakerRelatable {
|
||||
|
||||
public var left: ConstraintMakerExtendable {
|
||||
self.description.attributes += .left
|
||||
return self
|
||||
}
|
||||
|
||||
public var top: ConstraintMakerExtendable {
|
||||
self.description.attributes += .top
|
||||
return self
|
||||
}
|
||||
|
||||
public var bottom: ConstraintMakerExtendable {
|
||||
self.description.attributes += .bottom
|
||||
return self
|
||||
}
|
||||
|
||||
public var right: ConstraintMakerExtendable {
|
||||
self.description.attributes += .right
|
||||
return self
|
||||
}
|
||||
|
||||
public var leading: ConstraintMakerExtendable {
|
||||
self.description.attributes += .leading
|
||||
return self
|
||||
}
|
||||
|
||||
public var trailing: ConstraintMakerExtendable {
|
||||
self.description.attributes += .trailing
|
||||
return self
|
||||
}
|
||||
|
||||
public var width: ConstraintMakerExtendable {
|
||||
self.description.attributes += .width
|
||||
return self
|
||||
}
|
||||
|
||||
public var height: ConstraintMakerExtendable {
|
||||
self.description.attributes += .height
|
||||
return self
|
||||
}
|
||||
|
||||
public var centerX: ConstraintMakerExtendable {
|
||||
self.description.attributes += .centerX
|
||||
return self
|
||||
}
|
||||
|
||||
public var centerY: ConstraintMakerExtendable {
|
||||
self.description.attributes += .centerY
|
||||
return self
|
||||
}
|
||||
|
||||
@available(*, deprecated, renamed:"lastBaseline")
|
||||
public var baseline: ConstraintMakerExtendable {
|
||||
self.description.attributes += .lastBaseline
|
||||
return self
|
||||
}
|
||||
|
||||
public var lastBaseline: ConstraintMakerExtendable {
|
||||
self.description.attributes += .lastBaseline
|
||||
return self
|
||||
}
|
||||
|
||||
@available(iOS 8.0, OSX 10.11, *)
|
||||
public var firstBaseline: ConstraintMakerExtendable {
|
||||
self.description.attributes += .firstBaseline
|
||||
return self
|
||||
}
|
||||
|
||||
@available(iOS 8.0, *)
|
||||
public var leftMargin: ConstraintMakerExtendable {
|
||||
self.description.attributes += .leftMargin
|
||||
return self
|
||||
}
|
||||
|
||||
@available(iOS 8.0, *)
|
||||
public var rightMargin: ConstraintMakerExtendable {
|
||||
self.description.attributes += .rightMargin
|
||||
return self
|
||||
}
|
||||
|
||||
@available(iOS 8.0, *)
|
||||
public var topMargin: ConstraintMakerExtendable {
|
||||
self.description.attributes += .topMargin
|
||||
return self
|
||||
}
|
||||
|
||||
@available(iOS 8.0, *)
|
||||
public var bottomMargin: ConstraintMakerExtendable {
|
||||
self.description.attributes += .bottomMargin
|
||||
return self
|
||||
}
|
||||
|
||||
@available(iOS 8.0, *)
|
||||
public var leadingMargin: ConstraintMakerExtendable {
|
||||
self.description.attributes += .leadingMargin
|
||||
return self
|
||||
}
|
||||
|
||||
@available(iOS 8.0, *)
|
||||
public var trailingMargin: ConstraintMakerExtendable {
|
||||
self.description.attributes += .trailingMargin
|
||||
return self
|
||||
}
|
||||
|
||||
@available(iOS 8.0, *)
|
||||
public var centerXWithinMargins: ConstraintMakerExtendable {
|
||||
self.description.attributes += .centerXWithinMargins
|
||||
return self
|
||||
}
|
||||
|
||||
@available(iOS 8.0, *)
|
||||
public var centerYWithinMargins: ConstraintMakerExtendable {
|
||||
self.description.attributes += .centerYWithinMargins
|
||||
return self
|
||||
}
|
||||
|
||||
public var edges: ConstraintMakerExtendable {
|
||||
self.description.attributes += .edges
|
||||
return self
|
||||
}
|
||||
public var horizontalEdges: ConstraintMakerExtendable {
|
||||
self.description.attributes += .horizontalEdges
|
||||
return self
|
||||
}
|
||||
public var verticalEdges: ConstraintMakerExtendable {
|
||||
self.description.attributes += .verticalEdges
|
||||
return self
|
||||
}
|
||||
public var directionalEdges: ConstraintMakerExtendable {
|
||||
self.description.attributes += .directionalEdges
|
||||
return self
|
||||
}
|
||||
public var directionalHorizontalEdges: ConstraintMakerExtendable {
|
||||
self.description.attributes += .directionalHorizontalEdges
|
||||
return self
|
||||
}
|
||||
public var directionalVerticalEdges: ConstraintMakerExtendable {
|
||||
self.description.attributes += .directionalVerticalEdges
|
||||
return self
|
||||
}
|
||||
public var size: ConstraintMakerExtendable {
|
||||
self.description.attributes += .size
|
||||
return self
|
||||
}
|
||||
|
||||
@available(iOS 8.0, *)
|
||||
public var margins: ConstraintMakerExtendable {
|
||||
self.description.attributes += .margins
|
||||
return self
|
||||
}
|
||||
|
||||
@available(iOS 8.0, *)
|
||||
public var directionalMargins: ConstraintMakerExtendable {
|
||||
self.description.attributes += .directionalMargins
|
||||
return self
|
||||
}
|
||||
|
||||
@available(iOS 8.0, *)
|
||||
public var centerWithinMargins: ConstraintMakerExtendable {
|
||||
self.description.attributes += .centerWithinMargins
|
||||
return self
|
||||
}
|
||||
|
||||
}
|
||||
49
ReadViewDemo/Pods/SnapKit/Sources/ConstraintMakerFinalizable.swift
generated
Normal file
49
ReadViewDemo/Pods/SnapKit/Sources/ConstraintMakerFinalizable.swift
generated
Normal file
@ -0,0 +1,49 @@
|
||||
//
|
||||
// SnapKit
|
||||
//
|
||||
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
|
||||
public class ConstraintMakerFinalizable {
|
||||
|
||||
internal let description: ConstraintDescription
|
||||
|
||||
internal init(_ description: ConstraintDescription) {
|
||||
self.description = description
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func labeled(_ label: String) -> ConstraintMakerFinalizable {
|
||||
self.description.label = label
|
||||
return self
|
||||
}
|
||||
|
||||
public var constraint: Constraint {
|
||||
return self.description.constraint!
|
||||
}
|
||||
|
||||
}
|
||||
70
ReadViewDemo/Pods/SnapKit/Sources/ConstraintMakerPrioritizable.swift
generated
Normal file
70
ReadViewDemo/Pods/SnapKit/Sources/ConstraintMakerPrioritizable.swift
generated
Normal file
@ -0,0 +1,70 @@
|
||||
//
|
||||
// SnapKit
|
||||
//
|
||||
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
@available(*, deprecated, message:"Use ConstraintMakerPrioritizable instead.")
|
||||
public typealias ConstraintMakerPriortizable = ConstraintMakerPrioritizable
|
||||
|
||||
public class ConstraintMakerPrioritizable: ConstraintMakerFinalizable {
|
||||
|
||||
@discardableResult
|
||||
public func priority(_ amount: ConstraintPriority) -> ConstraintMakerFinalizable {
|
||||
self.description.priority = amount.value
|
||||
return self
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func priority(_ amount: ConstraintPriorityTarget) -> ConstraintMakerFinalizable {
|
||||
self.description.priority = amount
|
||||
return self
|
||||
}
|
||||
|
||||
@available(*, deprecated, message:"Use priority(.required) instead.")
|
||||
@discardableResult
|
||||
public func priorityRequired() -> ConstraintMakerFinalizable {
|
||||
return self.priority(.required)
|
||||
}
|
||||
|
||||
@available(*, deprecated, message:"Use priority(.high) instead.")
|
||||
@discardableResult
|
||||
public func priorityHigh() -> ConstraintMakerFinalizable {
|
||||
return self.priority(.high)
|
||||
}
|
||||
|
||||
@available(*, deprecated, message:"Use priority(.medium) instead.")
|
||||
@discardableResult
|
||||
public func priorityMedium() -> ConstraintMakerFinalizable {
|
||||
return self.priority(.medium)
|
||||
}
|
||||
|
||||
@available(*, deprecated, message:"Use priority(.low) instead.")
|
||||
@discardableResult
|
||||
public func priorityLow() -> ConstraintMakerFinalizable {
|
||||
return self.priority(.low)
|
||||
}
|
||||
}
|
||||
57
ReadViewDemo/Pods/SnapKit/Sources/ConstraintMakerRelatable+Extensions.swift
generated
Normal file
57
ReadViewDemo/Pods/SnapKit/Sources/ConstraintMakerRelatable+Extensions.swift
generated
Normal file
@ -0,0 +1,57 @@
|
||||
//
|
||||
// SnapKit
|
||||
//
|
||||
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
|
||||
extension ConstraintMakerRelatable {
|
||||
|
||||
@discardableResult
|
||||
public func equalToSuperview<T: ConstraintRelatableTarget>(_ closure: (ConstraintView) -> T, _ file: String = #file, line: UInt = #line) -> ConstraintMakerEditable {
|
||||
guard let other = self.description.item.superview else {
|
||||
fatalError("Expected superview but found nil when attempting make constraint `equalToSuperview`.")
|
||||
}
|
||||
return self.relatedTo(closure(other), relation: .equal, file: file, line: line)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func lessThanOrEqualToSuperview<T: ConstraintRelatableTarget>(_ closure: (ConstraintView) -> T, _ file: String = #file, line: UInt = #line) -> ConstraintMakerEditable {
|
||||
guard let other = self.description.item.superview else {
|
||||
fatalError("Expected superview but found nil when attempting make constraint `lessThanOrEqualToSuperview`.")
|
||||
}
|
||||
return self.relatedTo(closure(other), relation: .lessThanOrEqual, file: file, line: line)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func greaterThanOrEqualToSuperview<T: ConstraintRelatableTarget>(_ closure: (ConstraintView) -> T, _ file: String = #file, line: UInt = #line) -> ConstraintMakerEditable {
|
||||
guard let other = self.description.item.superview else {
|
||||
fatalError("Expected superview but found nil when attempting make constraint `greaterThanOrEqualToSuperview`.")
|
||||
}
|
||||
return self.relatedTo(closure(other), relation: .greaterThanOrEqual, file: file, line: line)
|
||||
}
|
||||
|
||||
}
|
||||
115
ReadViewDemo/Pods/SnapKit/Sources/ConstraintMakerRelatable.swift
generated
Normal file
115
ReadViewDemo/Pods/SnapKit/Sources/ConstraintMakerRelatable.swift
generated
Normal file
@ -0,0 +1,115 @@
|
||||
//
|
||||
// SnapKit
|
||||
//
|
||||
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
|
||||
public class ConstraintMakerRelatable {
|
||||
|
||||
internal let description: ConstraintDescription
|
||||
|
||||
internal init(_ description: ConstraintDescription) {
|
||||
self.description = description
|
||||
}
|
||||
|
||||
internal func relatedTo(_ other: ConstraintRelatableTarget, relation: ConstraintRelation, file: String, line: UInt) -> ConstraintMakerEditable {
|
||||
let related: ConstraintItem
|
||||
let constant: ConstraintConstantTarget
|
||||
|
||||
if let other = other as? ConstraintItem {
|
||||
guard other.attributes == ConstraintAttributes.none ||
|
||||
other.attributes.layoutAttributes.count <= 1 ||
|
||||
other.attributes.layoutAttributes == self.description.attributes.layoutAttributes ||
|
||||
other.attributes == .edges && self.description.attributes == .margins ||
|
||||
other.attributes == .margins && self.description.attributes == .edges ||
|
||||
other.attributes == .directionalEdges && self.description.attributes == .directionalMargins ||
|
||||
other.attributes == .directionalMargins && self.description.attributes == .directionalEdges else {
|
||||
fatalError("Cannot constraint to multiple non identical attributes. (\(file), \(line))");
|
||||
}
|
||||
|
||||
related = other
|
||||
constant = 0.0
|
||||
} else if let other = other as? ConstraintView {
|
||||
related = ConstraintItem(target: other, attributes: ConstraintAttributes.none)
|
||||
constant = 0.0
|
||||
} else if let other = other as? ConstraintConstantTarget {
|
||||
related = ConstraintItem(target: nil, attributes: ConstraintAttributes.none)
|
||||
constant = other
|
||||
} else if #available(iOS 9.0, OSX 10.11, *), let other = other as? ConstraintLayoutGuide {
|
||||
related = ConstraintItem(target: other, attributes: ConstraintAttributes.none)
|
||||
constant = 0.0
|
||||
} else {
|
||||
fatalError("Invalid constraint. (\(file), \(line))")
|
||||
}
|
||||
|
||||
let editable = ConstraintMakerEditable(self.description)
|
||||
editable.description.sourceLocation = (file, line)
|
||||
editable.description.relation = relation
|
||||
editable.description.related = related
|
||||
editable.description.constant = constant
|
||||
return editable
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func equalTo(_ other: ConstraintRelatableTarget, _ file: String = #file, _ line: UInt = #line) -> ConstraintMakerEditable {
|
||||
return self.relatedTo(other, relation: .equal, file: file, line: line)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func equalToSuperview(_ file: String = #file, _ line: UInt = #line) -> ConstraintMakerEditable {
|
||||
guard let other = self.description.item.superview else {
|
||||
fatalError("Expected superview but found nil when attempting make constraint `equalToSuperview`.")
|
||||
}
|
||||
return self.relatedTo(other, relation: .equal, file: file, line: line)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func lessThanOrEqualTo(_ other: ConstraintRelatableTarget, _ file: String = #file, _ line: UInt = #line) -> ConstraintMakerEditable {
|
||||
return self.relatedTo(other, relation: .lessThanOrEqual, file: file, line: line)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func lessThanOrEqualToSuperview(_ file: String = #file, _ line: UInt = #line) -> ConstraintMakerEditable {
|
||||
guard let other = self.description.item.superview else {
|
||||
fatalError("Expected superview but found nil when attempting make constraint `lessThanOrEqualToSuperview`.")
|
||||
}
|
||||
return self.relatedTo(other, relation: .lessThanOrEqual, file: file, line: line)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func greaterThanOrEqualTo(_ other: ConstraintRelatableTarget, _ file: String = #file, line: UInt = #line) -> ConstraintMakerEditable {
|
||||
return self.relatedTo(other, relation: .greaterThanOrEqual, file: file, line: line)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func greaterThanOrEqualToSuperview(_ file: String = #file, line: UInt = #line) -> ConstraintMakerEditable {
|
||||
guard let other = self.description.item.superview else {
|
||||
fatalError("Expected superview but found nil when attempting make constraint `greaterThanOrEqualToSuperview`.")
|
||||
}
|
||||
return self.relatedTo(other, relation: .greaterThanOrEqual, file: file, line: line)
|
||||
}
|
||||
}
|
||||
75
ReadViewDemo/Pods/SnapKit/Sources/ConstraintMultiplierTarget.swift
generated
Normal file
75
ReadViewDemo/Pods/SnapKit/Sources/ConstraintMultiplierTarget.swift
generated
Normal file
@ -0,0 +1,75 @@
|
||||
//
|
||||
// SnapKit
|
||||
//
|
||||
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
|
||||
public protocol ConstraintMultiplierTarget {
|
||||
|
||||
var constraintMultiplierTargetValue: CGFloat { get }
|
||||
|
||||
}
|
||||
|
||||
extension Int: ConstraintMultiplierTarget {
|
||||
|
||||
public var constraintMultiplierTargetValue: CGFloat {
|
||||
return CGFloat(self)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension UInt: ConstraintMultiplierTarget {
|
||||
|
||||
public var constraintMultiplierTargetValue: CGFloat {
|
||||
return CGFloat(self)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension Float: ConstraintMultiplierTarget {
|
||||
|
||||
public var constraintMultiplierTargetValue: CGFloat {
|
||||
return CGFloat(self)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension Double: ConstraintMultiplierTarget {
|
||||
|
||||
public var constraintMultiplierTargetValue: CGFloat {
|
||||
return CGFloat(self)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension CGFloat: ConstraintMultiplierTarget {
|
||||
|
||||
public var constraintMultiplierTargetValue: CGFloat {
|
||||
return self
|
||||
}
|
||||
|
||||
}
|
||||
69
ReadViewDemo/Pods/SnapKit/Sources/ConstraintOffsetTarget.swift
generated
Normal file
69
ReadViewDemo/Pods/SnapKit/Sources/ConstraintOffsetTarget.swift
generated
Normal file
@ -0,0 +1,69 @@
|
||||
//
|
||||
// SnapKit
|
||||
//
|
||||
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
|
||||
public protocol ConstraintOffsetTarget: ConstraintConstantTarget {
|
||||
}
|
||||
|
||||
extension Int: ConstraintOffsetTarget {
|
||||
}
|
||||
|
||||
extension UInt: ConstraintOffsetTarget {
|
||||
}
|
||||
|
||||
extension Float: ConstraintOffsetTarget {
|
||||
}
|
||||
|
||||
extension Double: ConstraintOffsetTarget {
|
||||
}
|
||||
|
||||
extension CGFloat: ConstraintOffsetTarget {
|
||||
}
|
||||
|
||||
extension ConstraintOffsetTarget {
|
||||
|
||||
internal var constraintOffsetTargetValue: CGFloat {
|
||||
let offset: CGFloat
|
||||
if let amount = self as? Float {
|
||||
offset = CGFloat(amount)
|
||||
} else if let amount = self as? Double {
|
||||
offset = CGFloat(amount)
|
||||
} else if let amount = self as? CGFloat {
|
||||
offset = CGFloat(amount)
|
||||
} else if let amount = self as? Int {
|
||||
offset = CGFloat(amount)
|
||||
} else if let amount = self as? UInt {
|
||||
offset = CGFloat(amount)
|
||||
} else {
|
||||
offset = 0.0
|
||||
}
|
||||
return offset
|
||||
}
|
||||
|
||||
}
|
||||
77
ReadViewDemo/Pods/SnapKit/Sources/ConstraintPriority.swift
generated
Normal file
77
ReadViewDemo/Pods/SnapKit/Sources/ConstraintPriority.swift
generated
Normal file
@ -0,0 +1,77 @@
|
||||
//
|
||||
// SnapKit
|
||||
//
|
||||
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
public struct ConstraintPriority : ExpressibleByFloatLiteral, Equatable, Strideable {
|
||||
public typealias FloatLiteralType = Float
|
||||
|
||||
public let value: Float
|
||||
|
||||
public init(floatLiteral value: Float) {
|
||||
self.value = value
|
||||
}
|
||||
|
||||
public init(_ value: Float) {
|
||||
self.value = value
|
||||
}
|
||||
|
||||
public static var required: ConstraintPriority {
|
||||
return 1000.0
|
||||
}
|
||||
|
||||
public static var high: ConstraintPriority {
|
||||
return 750.0
|
||||
}
|
||||
|
||||
public static var medium: ConstraintPriority {
|
||||
#if os(OSX)
|
||||
return 501.0
|
||||
#else
|
||||
return 500.0
|
||||
#endif
|
||||
|
||||
}
|
||||
|
||||
public static var low: ConstraintPriority {
|
||||
return 250.0
|
||||
}
|
||||
|
||||
public static func ==(lhs: ConstraintPriority, rhs: ConstraintPriority) -> Bool {
|
||||
return lhs.value == rhs.value
|
||||
}
|
||||
|
||||
// MARK: Strideable
|
||||
|
||||
public func advanced(by n: FloatLiteralType) -> ConstraintPriority {
|
||||
return ConstraintPriority(floatLiteral: value + n)
|
||||
}
|
||||
|
||||
public func distance(to other: ConstraintPriority) -> FloatLiteralType {
|
||||
return other.value - value
|
||||
}
|
||||
}
|
||||
85
ReadViewDemo/Pods/SnapKit/Sources/ConstraintPriorityTarget.swift
generated
Normal file
85
ReadViewDemo/Pods/SnapKit/Sources/ConstraintPriorityTarget.swift
generated
Normal file
@ -0,0 +1,85 @@
|
||||
//
|
||||
// SnapKit
|
||||
//
|
||||
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
|
||||
public protocol ConstraintPriorityTarget {
|
||||
|
||||
var constraintPriorityTargetValue: Float { get }
|
||||
|
||||
}
|
||||
|
||||
extension Int: ConstraintPriorityTarget {
|
||||
|
||||
public var constraintPriorityTargetValue: Float {
|
||||
return Float(self)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension UInt: ConstraintPriorityTarget {
|
||||
|
||||
public var constraintPriorityTargetValue: Float {
|
||||
return Float(self)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension Float: ConstraintPriorityTarget {
|
||||
|
||||
public var constraintPriorityTargetValue: Float {
|
||||
return self
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension Double: ConstraintPriorityTarget {
|
||||
|
||||
public var constraintPriorityTargetValue: Float {
|
||||
return Float(self)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension CGFloat: ConstraintPriorityTarget {
|
||||
|
||||
public var constraintPriorityTargetValue: Float {
|
||||
return Float(self)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#if canImport(UIKit)
|
||||
extension UILayoutPriority: ConstraintPriorityTarget {
|
||||
|
||||
public var constraintPriorityTargetValue: Float {
|
||||
return self.rawValue
|
||||
}
|
||||
|
||||
}
|
||||
#endif
|
||||
72
ReadViewDemo/Pods/SnapKit/Sources/ConstraintRelatableTarget.swift
generated
Normal file
72
ReadViewDemo/Pods/SnapKit/Sources/ConstraintRelatableTarget.swift
generated
Normal file
@ -0,0 +1,72 @@
|
||||
//
|
||||
// SnapKit
|
||||
//
|
||||
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
|
||||
public protocol ConstraintRelatableTarget {
|
||||
}
|
||||
|
||||
extension Int: ConstraintRelatableTarget {
|
||||
}
|
||||
|
||||
extension UInt: ConstraintRelatableTarget {
|
||||
}
|
||||
|
||||
extension Float: ConstraintRelatableTarget {
|
||||
}
|
||||
|
||||
extension Double: ConstraintRelatableTarget {
|
||||
}
|
||||
|
||||
extension CGFloat: ConstraintRelatableTarget {
|
||||
}
|
||||
|
||||
extension CGSize: ConstraintRelatableTarget {
|
||||
}
|
||||
|
||||
extension CGPoint: ConstraintRelatableTarget {
|
||||
}
|
||||
|
||||
extension ConstraintInsets: ConstraintRelatableTarget {
|
||||
}
|
||||
|
||||
#if canImport(UIKit)
|
||||
@available(iOS 11.0, tvOS 11.0, *)
|
||||
extension ConstraintDirectionalInsets: ConstraintRelatableTarget {
|
||||
}
|
||||
#endif
|
||||
|
||||
extension ConstraintItem: ConstraintRelatableTarget {
|
||||
}
|
||||
|
||||
extension ConstraintView: ConstraintRelatableTarget {
|
||||
}
|
||||
|
||||
@available(iOS 9.0, OSX 10.11, *)
|
||||
extension ConstraintLayoutGuide: ConstraintRelatableTarget {
|
||||
}
|
||||
48
ReadViewDemo/Pods/SnapKit/Sources/ConstraintRelation.swift
generated
Normal file
48
ReadViewDemo/Pods/SnapKit/Sources/ConstraintRelation.swift
generated
Normal file
@ -0,0 +1,48 @@
|
||||
//
|
||||
// SnapKit
|
||||
//
|
||||
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
|
||||
internal enum ConstraintRelation : Int {
|
||||
case equal = 1
|
||||
case lessThanOrEqual
|
||||
case greaterThanOrEqual
|
||||
|
||||
internal var layoutRelation: LayoutRelation {
|
||||
get {
|
||||
switch(self) {
|
||||
case .equal:
|
||||
return .equal
|
||||
case .lessThanOrEqual:
|
||||
return .lessThanOrEqual
|
||||
case .greaterThanOrEqual:
|
||||
return .greaterThanOrEqual
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
152
ReadViewDemo/Pods/SnapKit/Sources/ConstraintView+Extensions.swift
generated
Normal file
152
ReadViewDemo/Pods/SnapKit/Sources/ConstraintView+Extensions.swift
generated
Normal file
@ -0,0 +1,152 @@
|
||||
//
|
||||
// SnapKit
|
||||
//
|
||||
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
|
||||
public extension ConstraintView {
|
||||
|
||||
@available(*, deprecated, renamed:"snp.left")
|
||||
var snp_left: ConstraintItem { return self.snp.left }
|
||||
|
||||
@available(*, deprecated, renamed:"snp.top")
|
||||
var snp_top: ConstraintItem { return self.snp.top }
|
||||
|
||||
@available(*, deprecated, renamed:"snp.right")
|
||||
var snp_right: ConstraintItem { return self.snp.right }
|
||||
|
||||
@available(*, deprecated, renamed:"snp.bottom")
|
||||
var snp_bottom: ConstraintItem { return self.snp.bottom }
|
||||
|
||||
@available(*, deprecated, renamed:"snp.leading")
|
||||
var snp_leading: ConstraintItem { return self.snp.leading }
|
||||
|
||||
@available(*, deprecated, renamed:"snp.trailing")
|
||||
var snp_trailing: ConstraintItem { return self.snp.trailing }
|
||||
|
||||
@available(*, deprecated, renamed:"snp.width")
|
||||
var snp_width: ConstraintItem { return self.snp.width }
|
||||
|
||||
@available(*, deprecated, renamed:"snp.height")
|
||||
var snp_height: ConstraintItem { return self.snp.height }
|
||||
|
||||
@available(*, deprecated, renamed:"snp.centerX")
|
||||
var snp_centerX: ConstraintItem { return self.snp.centerX }
|
||||
|
||||
@available(*, deprecated, renamed:"snp.centerY")
|
||||
var snp_centerY: ConstraintItem { return self.snp.centerY }
|
||||
|
||||
@available(*, deprecated, renamed:"snp.baseline")
|
||||
var snp_baseline: ConstraintItem { return self.snp.baseline }
|
||||
|
||||
@available(*, deprecated, renamed:"snp.lastBaseline")
|
||||
@available(iOS 8.0, OSX 10.11, *)
|
||||
var snp_lastBaseline: ConstraintItem { return self.snp.lastBaseline }
|
||||
|
||||
@available(iOS, deprecated, renamed:"snp.firstBaseline")
|
||||
@available(iOS 8.0, OSX 10.11, *)
|
||||
var snp_firstBaseline: ConstraintItem { return self.snp.firstBaseline }
|
||||
|
||||
@available(iOS, deprecated, renamed:"snp.leftMargin")
|
||||
@available(iOS 8.0, *)
|
||||
var snp_leftMargin: ConstraintItem { return self.snp.leftMargin }
|
||||
|
||||
@available(iOS, deprecated, renamed:"snp.topMargin")
|
||||
@available(iOS 8.0, *)
|
||||
var snp_topMargin: ConstraintItem { return self.snp.topMargin }
|
||||
|
||||
@available(iOS, deprecated, renamed:"snp.rightMargin")
|
||||
@available(iOS 8.0, *)
|
||||
var snp_rightMargin: ConstraintItem { return self.snp.rightMargin }
|
||||
|
||||
@available(iOS, deprecated, renamed:"snp.bottomMargin")
|
||||
@available(iOS 8.0, *)
|
||||
var snp_bottomMargin: ConstraintItem { return self.snp.bottomMargin }
|
||||
|
||||
@available(iOS, deprecated, renamed:"snp.leadingMargin")
|
||||
@available(iOS 8.0, *)
|
||||
var snp_leadingMargin: ConstraintItem { return self.snp.leadingMargin }
|
||||
|
||||
@available(iOS, deprecated, renamed:"snp.trailingMargin")
|
||||
@available(iOS 8.0, *)
|
||||
var snp_trailingMargin: ConstraintItem { return self.snp.trailingMargin }
|
||||
|
||||
@available(iOS, deprecated, renamed:"snp.centerXWithinMargins")
|
||||
@available(iOS 8.0, *)
|
||||
var snp_centerXWithinMargins: ConstraintItem { return self.snp.centerXWithinMargins }
|
||||
|
||||
@available(iOS, deprecated, renamed:"snp.centerYWithinMargins")
|
||||
@available(iOS 8.0, *)
|
||||
var snp_centerYWithinMargins: ConstraintItem { return self.snp.centerYWithinMargins }
|
||||
|
||||
@available(*, deprecated, renamed:"snp.edges")
|
||||
var snp_edges: ConstraintItem { return self.snp.edges }
|
||||
|
||||
@available(*, deprecated, renamed:"snp.size")
|
||||
var snp_size: ConstraintItem { return self.snp.size }
|
||||
|
||||
@available(*, deprecated, renamed:"snp.center")
|
||||
var snp_center: ConstraintItem { return self.snp.center }
|
||||
|
||||
@available(iOS, deprecated, renamed:"snp.margins")
|
||||
@available(iOS 8.0, *)
|
||||
var snp_margins: ConstraintItem { return self.snp.margins }
|
||||
|
||||
@available(iOS, deprecated, renamed:"snp.centerWithinMargins")
|
||||
@available(iOS 8.0, *)
|
||||
var snp_centerWithinMargins: ConstraintItem { return self.snp.centerWithinMargins }
|
||||
|
||||
@available(*, deprecated, renamed:"snp.prepareConstraints(_:)")
|
||||
func snp_prepareConstraints(_ closure: (_ make: ConstraintMaker) -> Void) -> [Constraint] {
|
||||
return self.snp.prepareConstraints(closure)
|
||||
}
|
||||
|
||||
@available(*, deprecated, renamed:"snp.makeConstraints(_:)")
|
||||
func snp_makeConstraints(_ closure: (_ make: ConstraintMaker) -> Void) {
|
||||
self.snp.makeConstraints(closure)
|
||||
}
|
||||
|
||||
@available(*, deprecated, renamed:"snp.remakeConstraints(_:)")
|
||||
func snp_remakeConstraints(_ closure: (_ make: ConstraintMaker) -> Void) {
|
||||
self.snp.remakeConstraints(closure)
|
||||
}
|
||||
|
||||
@available(*, deprecated, renamed:"snp.updateConstraints(_:)")
|
||||
func snp_updateConstraints(_ closure: (_ make: ConstraintMaker) -> Void) {
|
||||
self.snp.updateConstraints(closure)
|
||||
}
|
||||
|
||||
@available(*, deprecated, renamed:"snp.removeConstraints()")
|
||||
func snp_removeConstraints() {
|
||||
self.snp.removeConstraints()
|
||||
}
|
||||
|
||||
var snp: ConstraintViewDSL {
|
||||
return ConstraintViewDSL(view: self)
|
||||
}
|
||||
|
||||
}
|
||||
35
ReadViewDemo/Pods/SnapKit/Sources/ConstraintView.swift
generated
Normal file
35
ReadViewDemo/Pods/SnapKit/Sources/ConstraintView.swift
generated
Normal file
@ -0,0 +1,35 @@
|
||||
//
|
||||
// SnapKit
|
||||
//
|
||||
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
|
||||
#if canImport(UIKit)
|
||||
public typealias ConstraintView = UIView
|
||||
#else
|
||||
public typealias ConstraintView = NSView
|
||||
#endif
|
||||
101
ReadViewDemo/Pods/SnapKit/Sources/ConstraintViewDSL.swift
generated
Normal file
101
ReadViewDemo/Pods/SnapKit/Sources/ConstraintViewDSL.swift
generated
Normal file
@ -0,0 +1,101 @@
|
||||
//
|
||||
// SnapKit
|
||||
//
|
||||
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
|
||||
public struct ConstraintViewDSL: ConstraintAttributesDSL {
|
||||
|
||||
@discardableResult
|
||||
public func prepareConstraints(_ closure: (_ make: ConstraintMaker) -> Void) -> [Constraint] {
|
||||
return ConstraintMaker.prepareConstraints(item: self.view, closure: closure)
|
||||
}
|
||||
|
||||
public func makeConstraints(_ closure: (_ make: ConstraintMaker) -> Void) {
|
||||
ConstraintMaker.makeConstraints(item: self.view, closure: closure)
|
||||
}
|
||||
|
||||
public func remakeConstraints(_ closure: (_ make: ConstraintMaker) -> Void) {
|
||||
ConstraintMaker.remakeConstraints(item: self.view, closure: closure)
|
||||
}
|
||||
|
||||
public func updateConstraints(_ closure: (_ make: ConstraintMaker) -> Void) {
|
||||
ConstraintMaker.updateConstraints(item: self.view, closure: closure)
|
||||
}
|
||||
|
||||
public func removeConstraints() {
|
||||
ConstraintMaker.removeConstraints(item: self.view)
|
||||
}
|
||||
|
||||
public var contentHuggingHorizontalPriority: Float {
|
||||
get {
|
||||
return self.view.contentHuggingPriority(for: .horizontal).rawValue
|
||||
}
|
||||
nonmutating set {
|
||||
self.view.setContentHuggingPriority(LayoutPriority(rawValue: newValue), for: .horizontal)
|
||||
}
|
||||
}
|
||||
|
||||
public var contentHuggingVerticalPriority: Float {
|
||||
get {
|
||||
return self.view.contentHuggingPriority(for: .vertical).rawValue
|
||||
}
|
||||
nonmutating set {
|
||||
self.view.setContentHuggingPriority(LayoutPriority(rawValue: newValue), for: .vertical)
|
||||
}
|
||||
}
|
||||
|
||||
public var contentCompressionResistanceHorizontalPriority: Float {
|
||||
get {
|
||||
return self.view.contentCompressionResistancePriority(for: .horizontal).rawValue
|
||||
}
|
||||
nonmutating set {
|
||||
self.view.setContentCompressionResistancePriority(LayoutPriority(rawValue: newValue), for: .horizontal)
|
||||
}
|
||||
}
|
||||
|
||||
public var contentCompressionResistanceVerticalPriority: Float {
|
||||
get {
|
||||
return self.view.contentCompressionResistancePriority(for: .vertical).rawValue
|
||||
}
|
||||
nonmutating set {
|
||||
self.view.setContentCompressionResistancePriority(LayoutPriority(rawValue: newValue), for: .vertical)
|
||||
}
|
||||
}
|
||||
|
||||
public var target: AnyObject? {
|
||||
return self.view
|
||||
}
|
||||
|
||||
internal let view: ConstraintView
|
||||
|
||||
internal init(view: ConstraintView) {
|
||||
self.view = view
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
169
ReadViewDemo/Pods/SnapKit/Sources/Debugging.swift
generated
Normal file
169
ReadViewDemo/Pods/SnapKit/Sources/Debugging.swift
generated
Normal file
@ -0,0 +1,169 @@
|
||||
//
|
||||
// SnapKit
|
||||
//
|
||||
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
public extension LayoutConstraint {
|
||||
|
||||
override var description: String {
|
||||
var description = "<"
|
||||
|
||||
description += descriptionForObject(self)
|
||||
|
||||
if let firstItem = conditionalOptional(from: self.firstItem) {
|
||||
description += " \(descriptionForObject(firstItem))"
|
||||
}
|
||||
|
||||
if self.firstAttribute != .notAnAttribute {
|
||||
description += ".\(descriptionForAttribute(self.firstAttribute))"
|
||||
}
|
||||
|
||||
description += " \(descriptionForRelation(self.relation))"
|
||||
|
||||
if let secondItem = self.secondItem {
|
||||
description += " \(descriptionForObject(secondItem))"
|
||||
}
|
||||
|
||||
if self.secondAttribute != .notAnAttribute {
|
||||
description += ".\(descriptionForAttribute(self.secondAttribute))"
|
||||
}
|
||||
|
||||
if self.multiplier != 1.0 {
|
||||
description += " * \(self.multiplier)"
|
||||
}
|
||||
|
||||
if self.secondAttribute == .notAnAttribute {
|
||||
description += " \(self.constant)"
|
||||
} else {
|
||||
if self.constant > 0.0 {
|
||||
description += " + \(self.constant)"
|
||||
} else if self.constant < 0.0 {
|
||||
description += " - \(abs(self.constant))"
|
||||
}
|
||||
}
|
||||
|
||||
if self.priority.rawValue != 1000.0 {
|
||||
description += " ^\(self.priority)"
|
||||
}
|
||||
|
||||
description += ">"
|
||||
|
||||
return description
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private func descriptionForRelation(_ relation: LayoutRelation) -> String {
|
||||
switch relation {
|
||||
case .equal: return "=="
|
||||
case .greaterThanOrEqual: return ">="
|
||||
case .lessThanOrEqual: return "<="
|
||||
#if swift(>=5.0)
|
||||
@unknown default: return "unknown"
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private func descriptionForAttribute(_ attribute: LayoutAttribute) -> String {
|
||||
#if canImport(UIKit)
|
||||
switch attribute {
|
||||
case .notAnAttribute: return "notAnAttribute"
|
||||
case .top: return "top"
|
||||
case .left: return "left"
|
||||
case .bottom: return "bottom"
|
||||
case .right: return "right"
|
||||
case .leading: return "leading"
|
||||
case .trailing: return "trailing"
|
||||
case .width: return "width"
|
||||
case .height: return "height"
|
||||
case .centerX: return "centerX"
|
||||
case .centerY: return "centerY"
|
||||
case .lastBaseline: return "lastBaseline"
|
||||
case .firstBaseline: return "firstBaseline"
|
||||
case .topMargin: return "topMargin"
|
||||
case .leftMargin: return "leftMargin"
|
||||
case .bottomMargin: return "bottomMargin"
|
||||
case .rightMargin: return "rightMargin"
|
||||
case .leadingMargin: return "leadingMargin"
|
||||
case .trailingMargin: return "trailingMargin"
|
||||
case .centerXWithinMargins: return "centerXWithinMargins"
|
||||
case .centerYWithinMargins: return "centerYWithinMargins"
|
||||
#if swift(>=5.0)
|
||||
@unknown default: return "unknown"
|
||||
#endif
|
||||
}
|
||||
#else
|
||||
switch attribute {
|
||||
case .notAnAttribute: return "notAnAttribute"
|
||||
case .top: return "top"
|
||||
case .left: return "left"
|
||||
case .bottom: return "bottom"
|
||||
case .right: return "right"
|
||||
case .leading: return "leading"
|
||||
case .trailing: return "trailing"
|
||||
case .width: return "width"
|
||||
case .height: return "height"
|
||||
case .centerX: return "centerX"
|
||||
case .centerY: return "centerY"
|
||||
case .lastBaseline: return "lastBaseline"
|
||||
case .firstBaseline: return "firstBaseline"
|
||||
#if swift(>=5.0)
|
||||
@unknown default: return "unknown"
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func conditionalOptional<T>(from object: Optional<T>) -> Optional<T> {
|
||||
return object
|
||||
}
|
||||
|
||||
private func conditionalOptional<T>(from object: T) -> Optional<T> {
|
||||
return Optional.some(object)
|
||||
}
|
||||
|
||||
private func descriptionForObject(_ object: AnyObject) -> String {
|
||||
let pointerDescription = String(format: "%p", UInt(bitPattern: ObjectIdentifier(object)))
|
||||
var desc = ""
|
||||
|
||||
desc += type(of: object).description()
|
||||
|
||||
if let object = object as? ConstraintView {
|
||||
desc += ":\(object.snp.label() ?? pointerDescription)"
|
||||
} else if let object = object as? LayoutConstraint {
|
||||
desc += ":\(object.label ?? pointerDescription)"
|
||||
} else {
|
||||
desc += ":\(pointerDescription)"
|
||||
}
|
||||
|
||||
if let object = object as? LayoutConstraint, let file = object.constraint?.sourceLocation.0, let line = object.constraint?.sourceLocation.1 {
|
||||
desc += "@\((file as NSString).lastPathComponent)#\(line)"
|
||||
}
|
||||
|
||||
desc += ""
|
||||
return desc
|
||||
}
|
||||
61
ReadViewDemo/Pods/SnapKit/Sources/LayoutConstraint.swift
generated
Normal file
61
ReadViewDemo/Pods/SnapKit/Sources/LayoutConstraint.swift
generated
Normal file
@ -0,0 +1,61 @@
|
||||
//
|
||||
// SnapKit
|
||||
//
|
||||
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
|
||||
public class LayoutConstraint : NSLayoutConstraint {
|
||||
|
||||
public var label: String? {
|
||||
get {
|
||||
return self.identifier
|
||||
}
|
||||
set {
|
||||
self.identifier = newValue
|
||||
}
|
||||
}
|
||||
|
||||
internal weak var constraint: Constraint? = nil
|
||||
|
||||
}
|
||||
|
||||
internal func ==(lhs: LayoutConstraint, rhs: LayoutConstraint) -> Bool {
|
||||
// If firstItem or secondItem on either constraint has a dangling pointer
|
||||
// this comparison can cause a crash. The solution for this is to ensure
|
||||
// your layout code hold strong references to things like Views, LayoutGuides
|
||||
// and LayoutAnchors as SnapKit will not keep strong references to any of these.
|
||||
guard lhs.firstAttribute == rhs.firstAttribute &&
|
||||
lhs.secondAttribute == rhs.secondAttribute &&
|
||||
lhs.relation == rhs.relation &&
|
||||
lhs.priority == rhs.priority &&
|
||||
lhs.multiplier == rhs.multiplier &&
|
||||
lhs.secondItem === rhs.secondItem &&
|
||||
lhs.firstItem === rhs.firstItem else {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
93
ReadViewDemo/Pods/SnapKit/Sources/LayoutConstraintItem.swift
generated
Normal file
93
ReadViewDemo/Pods/SnapKit/Sources/LayoutConstraintItem.swift
generated
Normal file
@ -0,0 +1,93 @@
|
||||
//
|
||||
// SnapKit
|
||||
//
|
||||
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
|
||||
public protocol LayoutConstraintItem: AnyObject {
|
||||
}
|
||||
|
||||
@available(iOS 9.0, OSX 10.11, *)
|
||||
extension ConstraintLayoutGuide : LayoutConstraintItem {
|
||||
}
|
||||
|
||||
extension ConstraintView : LayoutConstraintItem {
|
||||
}
|
||||
|
||||
|
||||
extension LayoutConstraintItem {
|
||||
|
||||
internal func prepare() {
|
||||
if let view = self as? ConstraintView {
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
}
|
||||
}
|
||||
|
||||
internal var superview: ConstraintView? {
|
||||
if let view = self as? ConstraintView {
|
||||
return view.superview
|
||||
}
|
||||
|
||||
if #available(iOS 9.0, OSX 10.11, *), let guide = self as? ConstraintLayoutGuide {
|
||||
return guide.owningView
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
internal var constraints: [Constraint] {
|
||||
return self.constraintsSet.allObjects as! [Constraint]
|
||||
}
|
||||
|
||||
internal func add(constraints: [Constraint]) {
|
||||
let constraintsSet = self.constraintsSet
|
||||
for constraint in constraints {
|
||||
constraintsSet.add(constraint)
|
||||
}
|
||||
}
|
||||
|
||||
internal func remove(constraints: [Constraint]) {
|
||||
let constraintsSet = self.constraintsSet
|
||||
for constraint in constraints {
|
||||
constraintsSet.remove(constraint)
|
||||
}
|
||||
}
|
||||
|
||||
private var constraintsSet: NSMutableSet {
|
||||
let constraintsSet: NSMutableSet
|
||||
|
||||
if let existing = objc_getAssociatedObject(self, &constraintsKey) as? NSMutableSet {
|
||||
constraintsSet = existing
|
||||
} else {
|
||||
constraintsSet = NSMutableSet()
|
||||
objc_setAssociatedObject(self, &constraintsKey, constraintsSet, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
|
||||
}
|
||||
return constraintsSet
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
private var constraintsKey: UInt8 = 0
|
||||
14
ReadViewDemo/Pods/SnapKit/Sources/PrivacyInfo.xcprivacy
generated
Normal file
14
ReadViewDemo/Pods/SnapKit/Sources/PrivacyInfo.xcprivacy
generated
Normal file
@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSPrivacyTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array/>
|
||||
<key>NSPrivacyCollectedDataTypes</key>
|
||||
<array/>
|
||||
<key>NSPrivacyTrackingDomains</key>
|
||||
<array/>
|
||||
</dict>
|
||||
</plist>
|
||||
42
ReadViewDemo/Pods/SnapKit/Sources/Typealiases.swift
generated
Normal file
42
ReadViewDemo/Pods/SnapKit/Sources/Typealiases.swift
generated
Normal file
@ -0,0 +1,42 @@
|
||||
//
|
||||
// SnapKit
|
||||
//
|
||||
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
import Foundation
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#if swift(>=4.2)
|
||||
typealias LayoutRelation = NSLayoutConstraint.Relation
|
||||
typealias LayoutAttribute = NSLayoutConstraint.Attribute
|
||||
#else
|
||||
typealias LayoutRelation = NSLayoutRelation
|
||||
typealias LayoutAttribute = NSLayoutAttribute
|
||||
#endif
|
||||
typealias LayoutPriority = UILayoutPriority
|
||||
#else
|
||||
import AppKit
|
||||
typealias LayoutRelation = NSLayoutConstraint.Relation
|
||||
typealias LayoutAttribute = NSLayoutConstraint.Attribute
|
||||
typealias LayoutPriority = NSLayoutConstraint.Priority
|
||||
#endif
|
||||
|
||||
36
ReadViewDemo/Pods/SnapKit/Sources/UILayoutSupport+Extensions.swift
generated
Normal file
36
ReadViewDemo/Pods/SnapKit/Sources/UILayoutSupport+Extensions.swift
generated
Normal file
@ -0,0 +1,36 @@
|
||||
//
|
||||
// SnapKit
|
||||
//
|
||||
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
|
||||
@available(iOS 8.0, *)
|
||||
public extension ConstraintLayoutSupport {
|
||||
|
||||
var snp: ConstraintLayoutSupportDSL {
|
||||
return ConstraintLayoutSupportDSL(support: self)
|
||||
}
|
||||
|
||||
}
|
||||
@ -53,6 +53,29 @@ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
|
||||
## SnapKit
|
||||
|
||||
Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
|
||||
## ZIPFoundation
|
||||
|
||||
MIT License
|
||||
|
||||
@ -76,6 +76,35 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
<key>Type</key>
|
||||
<string>PSGroupSpecifier</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>FooterText</key>
|
||||
<string>Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
</string>
|
||||
<key>License</key>
|
||||
<string>MIT</string>
|
||||
<key>Title</key>
|
||||
<string>SnapKit</string>
|
||||
<key>Type</key>
|
||||
<string>PSGroupSpecifier</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>FooterText</key>
|
||||
<string>MIT License
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks.sh
|
||||
${BUILT_PRODUCTS_DIR}/DTCoreText/DTCoreText.framework
|
||||
${BUILT_PRODUCTS_DIR}/DTFoundation/DTFoundation.framework
|
||||
${BUILT_PRODUCTS_DIR}/RDEpubReaderView/RDEpubReaderView.framework
|
||||
${BUILT_PRODUCTS_DIR}/RDReaderView/RDReaderView.framework
|
||||
${BUILT_PRODUCTS_DIR}/SSAlertSwift/SSAlertSwift.framework
|
||||
${BUILT_PRODUCTS_DIR}/SnapKit/SnapKit.framework
|
||||
${BUILT_PRODUCTS_DIR}/ZIPFoundation/ZIPFoundation.framework
|
||||
@ -1,4 +1,6 @@
|
||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/DTCoreText.framework
|
||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/DTFoundation.framework
|
||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RDEpubReaderView.framework
|
||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RDReaderView.framework
|
||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SSAlertSwift.framework
|
||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SnapKit.framework
|
||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ZIPFoundation.framework
|
||||
@ -1,5 +1,7 @@
|
||||
${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks.sh
|
||||
${BUILT_PRODUCTS_DIR}/DTCoreText/DTCoreText.framework
|
||||
${BUILT_PRODUCTS_DIR}/DTFoundation/DTFoundation.framework
|
||||
${BUILT_PRODUCTS_DIR}/RDEpubReaderView/RDEpubReaderView.framework
|
||||
${BUILT_PRODUCTS_DIR}/RDReaderView/RDReaderView.framework
|
||||
${BUILT_PRODUCTS_DIR}/SSAlertSwift/SSAlertSwift.framework
|
||||
${BUILT_PRODUCTS_DIR}/SnapKit/SnapKit.framework
|
||||
${BUILT_PRODUCTS_DIR}/ZIPFoundation/ZIPFoundation.framework
|
||||
@ -1,4 +1,6 @@
|
||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/DTCoreText.framework
|
||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/DTFoundation.framework
|
||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RDEpubReaderView.framework
|
||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RDReaderView.framework
|
||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SSAlertSwift.framework
|
||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SnapKit.framework
|
||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ZIPFoundation.framework
|
||||
@ -178,13 +178,17 @@ code_sign_if_enabled() {
|
||||
if [[ "$CONFIGURATION" == "Debug" ]]; then
|
||||
install_framework "${BUILT_PRODUCTS_DIR}/DTCoreText/DTCoreText.framework"
|
||||
install_framework "${BUILT_PRODUCTS_DIR}/DTFoundation/DTFoundation.framework"
|
||||
install_framework "${BUILT_PRODUCTS_DIR}/RDEpubReaderView/RDEpubReaderView.framework"
|
||||
install_framework "${BUILT_PRODUCTS_DIR}/RDReaderView/RDReaderView.framework"
|
||||
install_framework "${BUILT_PRODUCTS_DIR}/SSAlertSwift/SSAlertSwift.framework"
|
||||
install_framework "${BUILT_PRODUCTS_DIR}/SnapKit/SnapKit.framework"
|
||||
install_framework "${BUILT_PRODUCTS_DIR}/ZIPFoundation/ZIPFoundation.framework"
|
||||
fi
|
||||
if [[ "$CONFIGURATION" == "Release" ]]; then
|
||||
install_framework "${BUILT_PRODUCTS_DIR}/DTCoreText/DTCoreText.framework"
|
||||
install_framework "${BUILT_PRODUCTS_DIR}/DTFoundation/DTFoundation.framework"
|
||||
install_framework "${BUILT_PRODUCTS_DIR}/RDEpubReaderView/RDEpubReaderView.framework"
|
||||
install_framework "${BUILT_PRODUCTS_DIR}/RDReaderView/RDReaderView.framework"
|
||||
install_framework "${BUILT_PRODUCTS_DIR}/SSAlertSwift/SSAlertSwift.framework"
|
||||
install_framework "${BUILT_PRODUCTS_DIR}/SnapKit/SnapKit.framework"
|
||||
install_framework "${BUILT_PRODUCTS_DIR}/ZIPFoundation/ZIPFoundation.framework"
|
||||
fi
|
||||
if [ "${COCOAPODS_PARALLEL_CODE_SIGN}" == "true" ]; then
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
|
||||
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
|
||||
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "${PODS_CONFIGURATION_BUILD_DIR}/RDReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/SSAlertSwift" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
|
||||
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
|
||||
HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText/DTCoreText.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation/DTFoundation.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView/RDEpubReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation/ZIPFoundation.framework/Headers" "$(SDKROOT)/usr/include/libxml2"
|
||||
HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText/DTCoreText.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation/DTFoundation.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDReaderView/RDReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/SSAlertSwift/SSAlertSwift.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit/SnapKit.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation/ZIPFoundation.framework/Headers" "$(SDKROOT)/usr/include/libxml2"
|
||||
LD_RUNPATH_SEARCH_PATHS = $(inherited) /usr/lib/swift '@executable_path/Frameworks' '@loader_path/Frameworks'
|
||||
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift
|
||||
OTHER_LDFLAGS = $(inherited) -l"xml2" -framework "CoreGraphics" -framework "CoreText" -framework "DTCoreText" -framework "DTFoundation" -framework "ImageIO" -framework "MediaPlayer" -framework "QuartzCore" -framework "RDEpubReaderView" -framework "ZIPFoundation"
|
||||
OTHER_MODULE_VERIFIER_FLAGS = $(inherited) "-F${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "-F${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
|
||||
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift $(SDKROOT)/usr/lib/swift
|
||||
OTHER_LDFLAGS = $(inherited) -l"swiftCoreGraphics" -l"xml2" -framework "CoreGraphics" -framework "CoreText" -framework "DTCoreText" -framework "DTFoundation" -framework "ImageIO" -framework "MediaPlayer" -framework "QuartzCore" -framework "RDReaderView" -framework "SSAlertSwift" -framework "SnapKit" -framework "ZIPFoundation"
|
||||
OTHER_MODULE_VERIFIER_FLAGS = $(inherited) "-F${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "-F${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/SSAlertSwift" "-F${PODS_CONFIGURATION_BUILD_DIR}/SnapKit" "-F${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
|
||||
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
|
||||
PODS_BUILD_DIR = ${BUILD_DIR}
|
||||
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
|
||||
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
|
||||
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "${PODS_CONFIGURATION_BUILD_DIR}/RDReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/SSAlertSwift" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
|
||||
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
|
||||
HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText/DTCoreText.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation/DTFoundation.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView/RDEpubReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation/ZIPFoundation.framework/Headers" "$(SDKROOT)/usr/include/libxml2"
|
||||
HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText/DTCoreText.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation/DTFoundation.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDReaderView/RDReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/SSAlertSwift/SSAlertSwift.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit/SnapKit.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation/ZIPFoundation.framework/Headers" "$(SDKROOT)/usr/include/libxml2"
|
||||
LD_RUNPATH_SEARCH_PATHS = $(inherited) /usr/lib/swift '@executable_path/Frameworks' '@loader_path/Frameworks'
|
||||
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift
|
||||
OTHER_LDFLAGS = $(inherited) -l"xml2" -framework "CoreGraphics" -framework "CoreText" -framework "DTCoreText" -framework "DTFoundation" -framework "ImageIO" -framework "MediaPlayer" -framework "QuartzCore" -framework "RDEpubReaderView" -framework "ZIPFoundation"
|
||||
OTHER_MODULE_VERIFIER_FLAGS = $(inherited) "-F${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "-F${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
|
||||
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift $(SDKROOT)/usr/lib/swift
|
||||
OTHER_LDFLAGS = $(inherited) -l"swiftCoreGraphics" -l"xml2" -framework "CoreGraphics" -framework "CoreText" -framework "DTCoreText" -framework "DTFoundation" -framework "ImageIO" -framework "MediaPlayer" -framework "QuartzCore" -framework "RDReaderView" -framework "SSAlertSwift" -framework "SnapKit" -framework "ZIPFoundation"
|
||||
OTHER_MODULE_VERIFIER_FLAGS = $(inherited) "-F${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "-F${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/SSAlertSwift" "-F${PODS_CONFIGURATION_BUILD_DIR}/SnapKit" "-F${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
|
||||
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
|
||||
PODS_BUILD_DIR = ${BUILD_DIR}
|
||||
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
|
||||
|
||||
@ -1,5 +0,0 @@
|
||||
#import <Foundation/Foundation.h>
|
||||
@interface PodsDummy_RDEpubReaderView : NSObject
|
||||
@end
|
||||
@implementation PodsDummy_RDEpubReaderView
|
||||
@end
|
||||
@ -1,6 +0,0 @@
|
||||
framework module RDEpubReaderView {
|
||||
umbrella header "RDEpubReaderView-umbrella.h"
|
||||
|
||||
export *
|
||||
module * { export * }
|
||||
}
|
||||
5
ReadViewDemo/Pods/Target Support Files/RDReaderView/RDReaderView-dummy.m
generated
Normal file
5
ReadViewDemo/Pods/Target Support Files/RDReaderView/RDReaderView-dummy.m
generated
Normal file
@ -0,0 +1,5 @@
|
||||
#import <Foundation/Foundation.h>
|
||||
@interface PodsDummy_RDReaderView : NSObject
|
||||
@end
|
||||
@implementation PodsDummy_RDReaderView
|
||||
@end
|
||||
@ -11,6 +11,6 @@
|
||||
#endif
|
||||
|
||||
|
||||
FOUNDATION_EXPORT double RDEpubReaderViewVersionNumber;
|
||||
FOUNDATION_EXPORT const unsigned char RDEpubReaderViewVersionString[];
|
||||
FOUNDATION_EXPORT double RDReaderViewVersionNumber;
|
||||
FOUNDATION_EXPORT const unsigned char RDReaderViewVersionString[];
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
|
||||
CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView
|
||||
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
|
||||
CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RDReaderView
|
||||
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "${PODS_CONFIGURATION_BUILD_DIR}/SSAlertSwift" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
|
||||
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
|
||||
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift
|
||||
OTHER_LDFLAGS = $(inherited) -framework "CoreGraphics" -framework "CoreText" -framework "DTCoreText" -framework "ImageIO" -framework "MediaPlayer" -framework "QuartzCore" -framework "ZIPFoundation"
|
||||
OTHER_LDFLAGS = $(inherited) -framework "CoreGraphics" -framework "CoreText" -framework "DTCoreText" -framework "ImageIO" -framework "MediaPlayer" -framework "QuartzCore" -framework "SSAlertSwift" -framework "SnapKit" -framework "ZIPFoundation"
|
||||
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
|
||||
PODS_BUILD_DIR = ${BUILD_DIR}
|
||||
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user