Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ccb7157b2 | ||
|
|
063a493b18 | ||
|
|
d7fcda345d | ||
|
|
d5a7755702 | ||
|
|
83dfa40299 | ||
|
|
c4be426299 | ||
|
|
e4e629a06c | ||
|
|
5a41066b66 |
18
.gitignore
vendored
18
.gitignore
vendored
@ -6,3 +6,21 @@ xcuserdata/
|
||||
.artifacts/
|
||||
.deriveddata/
|
||||
.swift-module-cache/
|
||||
|
||||
# This repository checks in the Demo Pods project. Keep generated files for the
|
||||
# local RDEpubReaderView pod visible even when a global gitignore excludes Pods.
|
||||
!ReadViewDemo/Pods/
|
||||
!ReadViewDemo/Pods/Local Podspecs/
|
||||
!ReadViewDemo/Pods/Local Podspecs/RDEpubReaderView.podspec.json
|
||||
!ReadViewDemo/Pods/Target Support Files/
|
||||
!ReadViewDemo/Pods/Target Support Files/RDEpubReaderView/
|
||||
!ReadViewDemo/Pods/Target Support Files/RDEpubReaderView/**
|
||||
|
||||
# macOS system files
|
||||
.DS_Store
|
||||
|
||||
# Local backup directories
|
||||
*手动解包备份*
|
||||
|
||||
# AI tool output
|
||||
_ssoft-output/
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
# ReadViewSDK 代码审查报告
|
||||
|
||||
> 审查日期:2026-06-26
|
||||
> 审查范围:Sources/RDReaderView 全部源码
|
||||
> 审查范围:Sources/RDEpubReaderView 全部源码
|
||||
> 审查方法:逐文件阅读 + 交叉验证 + 线程模型分析
|
||||
|
||||
---
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
|
||||
## 1. RDEPUBReaderController — 入口控制器
|
||||
|
||||
**文件**:`Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift` 及扩展
|
||||
**文件**:`Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController.swift` 及扩展
|
||||
|
||||
### 1.1 初始化
|
||||
|
||||
@ -180,7 +180,7 @@ func nativeTextSemanticSummary() -> String?
|
||||
|
||||
## 2. RDEPUBReaderDelegate — 委托协议
|
||||
|
||||
**文件**:`Sources/RDReaderView/EPUBUI/RDEPUBReaderDelegate.swift`
|
||||
**文件**:`Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderDelegate.swift`
|
||||
|
||||
所有方法均有默认空实现,可按需实现。
|
||||
|
||||
@ -232,7 +232,7 @@ public protocol RDEPUBReaderDelegate: AnyObject {
|
||||
|
||||
## 3. RDEPUBReaderPersistence — 持久化协议
|
||||
|
||||
**文件**:`Sources/RDReaderView/EPUBUI/RDEPUBReaderPersistence.swift`
|
||||
**文件**:`Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderPersistence.swift`
|
||||
|
||||
```swift
|
||||
public protocol RDEPUBReaderPersistence: AnyObject {
|
||||
@ -264,7 +264,7 @@ public protocol RDEPUBReaderPersistence: AnyObject {
|
||||
|
||||
## 4. RDEPUBReaderConfiguration — 配置模型
|
||||
|
||||
**文件**:`Sources/RDReaderView/EPUBUI/Settings/RDEPUBReaderConfiguration.swift`
|
||||
**文件**:`Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderConfiguration.swift`
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
@ -273,7 +273,7 @@ public protocol RDEPUBReaderPersistence: AnyObject {
|
||||
| `fontChoice` | `RDEPUBReaderFontChoice` | `.system` | 字体选择(system/serif/rounded/monospaced) |
|
||||
| `numberOfColumns` | `Int` | `1` | 每页列数(1 或 2) |
|
||||
| `columnGap` | `CGFloat` | `20` | 列间距 |
|
||||
| `displayType` | `RDReaderView.DisplayType` | `.pageCurl` | 翻页模式 |
|
||||
| `displayType` | `RDEpubReaderView.DisplayType` | `.pageCurl` | 翻页模式 |
|
||||
| `landscapeDualPageEnabled` | `Bool` | `true` | 横屏双页 |
|
||||
| `showsTableOfContents` | `Bool` | `true` | 显示目录 |
|
||||
| `allowsHighlights` | `Bool` | `true` | 允许高亮 |
|
||||
@ -301,50 +301,50 @@ public protocol RDEPUBReaderPersistence: AnyObject {
|
||||
|
||||
## 5. 翻页容器协议
|
||||
|
||||
**文件**:`Sources/RDReaderView/ReaderView/RDReaderViewProtocols.swift`
|
||||
**文件**:`Sources/RDEpubReaderView/ReaderView/RDEpubReaderViewProtocols.swift`
|
||||
|
||||
### 5.1 RDReaderPageProvider(推荐)
|
||||
### 5.1 RDEpubReaderPageProvider(推荐)
|
||||
|
||||
```swift
|
||||
@objc public protocol RDReaderPageProvider: NSObjectProtocol {
|
||||
@objc public protocol RDEpubReaderPageProvider: NSObjectProtocol {
|
||||
|
||||
/// 总页数
|
||||
func numberOfPages(in readerView: RDReaderView) -> Int
|
||||
func numberOfPages(in readerView: RDEpubReaderView) -> Int
|
||||
|
||||
/// 返回指定页的视图
|
||||
/// - Parameters:
|
||||
/// - index: 页码索引(0-based)
|
||||
/// - reusableView: 可复用的旧视图(可能为 nil)
|
||||
func readerView(_ readerView: RDReaderView, viewForPageAt index: Int, reusableView: UIView?) -> UIView
|
||||
func readerView(_ readerView: RDEpubReaderView, viewForPageAt index: Int, reusableView: UIView?) -> UIView
|
||||
|
||||
/// 页面标识符(用于缓存去重)
|
||||
@objc optional func pageIdentifier(in readerView: RDReaderView, index: Int) -> String?
|
||||
@objc optional func pageIdentifier(in readerView: RDEpubReaderView, index: Int) -> String?
|
||||
|
||||
/// 顶部工具栏视图
|
||||
@objc optional func readerViewTopChrome(_ readerView: RDReaderView) -> UIView?
|
||||
@objc optional func readerViewTopChrome(_ readerView: RDEpubReaderView) -> UIView?
|
||||
|
||||
/// 底部工具栏视图
|
||||
@objc optional func readerViewBottomChrome(_ readerView: RDReaderView) -> UIView?
|
||||
@objc optional func readerViewBottomChrome(_ readerView: RDEpubReaderView) -> UIView?
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 RDReaderDelegate
|
||||
### 5.2 RDEpubReaderDelegate
|
||||
|
||||
```swift
|
||||
@objc public protocol RDReaderDelegate: NSObjectProtocol {
|
||||
@objc public protocol RDEpubReaderDelegate: NSObjectProtocol {
|
||||
|
||||
/// 页面变化回调
|
||||
func pageNum(readerView: RDReaderView, pageNum: Int)
|
||||
func pageNum(readerView: RDEpubReaderView, pageNum: Int)
|
||||
|
||||
/// 屏幕方向即将变化
|
||||
@objc optional func readerViewOrientationWillChange(readerView: RDReaderView, isLandscape: Bool)
|
||||
@objc optional func readerViewOrientationWillChange(readerView: RDEpubReaderView, isLandscape: Bool)
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 RDReaderPageNavigating
|
||||
### 5.3 RDEpubReaderPageNavigating
|
||||
|
||||
```swift
|
||||
public protocol RDReaderPageNavigating: AnyObject {
|
||||
public protocol RDEpubReaderPageNavigating: AnyObject {
|
||||
|
||||
/// 当前页码
|
||||
var currentPage: Int { get }
|
||||
@ -357,19 +357,19 @@ public protocol RDReaderPageNavigating: AnyObject {
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 RDReaderDataSource(遗留)
|
||||
### 5.4 RDEpubReaderDataSource(遗留)
|
||||
|
||||
```swift
|
||||
@objc public protocol RDReaderDataSource: NSObjectProtocol {
|
||||
func pageCountOfReaderView(readerView: RDReaderView) -> Int
|
||||
func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView
|
||||
func pageIdentifier(readerView: RDReaderView, pageNum: Int) -> String?
|
||||
@objc optional func topToolView(readerView: RDReaderView) -> UIView?
|
||||
@objc optional func bottomToolView(readerView: RDReaderView) -> UIView?
|
||||
@objc public protocol RDEpubReaderDataSource: NSObjectProtocol {
|
||||
func pageCountOfReaderView(readerView: RDEpubReaderView) -> Int
|
||||
func pageContentView(readerView: RDEpubReaderView, pageNum: Int, containerView: UIView?) -> UIView
|
||||
func pageIdentifier(readerView: RDEpubReaderView, pageNum: Int) -> String?
|
||||
@objc optional func topToolView(readerView: RDEpubReaderView) -> UIView?
|
||||
@objc optional func bottomToolView(readerView: RDEpubReaderView) -> UIView?
|
||||
}
|
||||
```
|
||||
|
||||
通过 `RDReaderLegacyDataSourceAdapter` 自动适配到 `RDReaderPageProvider`。
|
||||
通过 `RDEpubReaderLegacyDataSourceAdapter` 自动适配到 `RDEpubReaderPageProvider`。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -22,7 +22,7 @@ ReadViewSDK 是一个 iOS EPUB 阅读器 SDK,支持文本重排(Reflowable
|
||||
│ ReadViewDemo (Demo App) │
|
||||
│ ViewController · LaunchAutomationPlan · UITests │
|
||||
└──────────────────────────────┬──────────────────────────────┘
|
||||
│ imports RDReaderView
|
||||
│ imports RDEpubReaderView
|
||||
┌──────────────────────────────▼──────────────────────────────┐
|
||||
│ EPUBUI 层 │
|
||||
│ RDEPUBReaderController · Coordinators · Settings · TextPage │
|
||||
@ -30,8 +30,8 @@ ReadViewSDK 是一个 iOS EPUB 阅读器 SDK,支持文本重排(Reflowable
|
||||
└──────────────────────────────┬──────────────────────────────┘
|
||||
│
|
||||
┌──────────────────────────────▼──────────────────────────────┐
|
||||
│ RDReaderView 层 │
|
||||
│ RDReaderView · FlowLayout · PreloadController │
|
||||
│ RDEpubReaderView 层 │
|
||||
│ RDEpubReaderView · FlowLayout · PreloadController │
|
||||
│ SpreadResolver · TapRegionHandler · PagingController │
|
||||
└──────────────────────────────┬──────────────────────────────┘
|
||||
│
|
||||
@ -54,7 +54,7 @@ ReadViewSDK 是一个 iOS EPUB 阅读器 SDK,支持文本重排(Reflowable
|
||||
|---|---|---|
|
||||
| **EPUBCore** | EPUB 文件解析、资源管理、WebView 渲染、JS 桥接 | `RDEPUBParser`, `RDEPUBPublication`, `RDEPUBWebView` |
|
||||
| **EPUBTextRendering** | HTML→NSAttributedString 转换、排版、分页、全书构建 | `RDEPUBTextBookBuilder`, `RDEPUBCoreTextPageFrameFactory` |
|
||||
| **RDReaderView** | 通用翻页容器、手势识别、页面预加载、双页布局 | `RDReaderView`, `RDReaderFlowLayout`, `RDReaderPreloadController` |
|
||||
| **RDEpubReaderView** | 通用翻页容器、手势识别、页面预加载、双页布局 | `RDEpubReaderView`, `RDEpubReaderFlowLayout`, `RDEpubReaderPreloadController` |
|
||||
| **EPUBUI** | 阅读器控制器、协调器模式、设置面板、按需加载、磁盘缓存 | `RDEPUBReaderController`, `RDEPUBReaderPaginationCoordinator` |
|
||||
|
||||
---
|
||||
@ -310,10 +310,10 @@ RDEPUBWebView (UIView)
|
||||
|
||||
---
|
||||
|
||||
## 7. 翻页容器架构(RDReaderView)
|
||||
## 7. 翻页容器架构(RDEpubReaderView)
|
||||
|
||||
```
|
||||
RDReaderView (UIView)
|
||||
RDEpubReaderView (UIView)
|
||||
│
|
||||
├─ 三种翻页模式:
|
||||
│ ├─ .pageCurl → UIPageViewController (翻页动画)
|
||||
@ -321,14 +321,14 @@ RDReaderView (UIView)
|
||||
│ └─ .verticalScroll → UICollectionView (垂直滚动)
|
||||
│
|
||||
├─ 组合对象:
|
||||
│ ├─ RDReaderPagingController // 翻页状态管理、请求队列
|
||||
│ ├─ RDReaderPreloadController // 页面预加载、缓存管理
|
||||
│ ├─ RDReaderSpreadResolver // 双页展开计算
|
||||
│ └─ RDReaderTapRegionHandler // 点击区域分类(左/中/右)
|
||||
│ ├─ RDEpubReaderPagingController // 翻页状态管理、请求队列
|
||||
│ ├─ RDEpubReaderPreloadController // 页面预加载、缓存管理
|
||||
│ ├─ RDEpubReaderSpreadResolver // 双页展开计算
|
||||
│ └─ RDEpubReaderTapRegionHandler // 点击区域分类(左/中/右)
|
||||
│
|
||||
├─ 数据源协议:
|
||||
│ ├─ RDReaderPageProvider (新) // 格式无关,优先级高
|
||||
│ └─ RDReaderDataSource (旧) // 遗留兼容,通过 Adapter 适配
|
||||
│ ├─ RDEpubReaderPageProvider (新) // 格式无关,优先级高
|
||||
│ └─ RDEpubReaderDataSource (旧) // 遗留兼容,通过 Adapter 适配
|
||||
│
|
||||
└─ 手势流:
|
||||
点击 → TapRegionHandler → 分类(左/中/右)
|
||||
@ -351,7 +351,7 @@ struct RDEPUBReaderConfiguration {
|
||||
var numberOfColumns: Int // 1 或 2
|
||||
var columnGap: CGFloat // 默认 20
|
||||
var theme: ReaderTheme // 6 种主题
|
||||
var displayType: RDReaderView.DisplayType // pageCurl/horizontal/vertical
|
||||
var displayType: RDEpubReaderView.DisplayType // pageCurl/horizontal/vertical
|
||||
var onDemandChapterWindowSize: Int // 默认 3(奇数,最小 3,最大 15)
|
||||
var metadataParsingConcurrency: Int // 默认 CPU 核心数
|
||||
var chapterWindowRadius: Int // 内存缓存窗口半径
|
||||
@ -374,7 +374,7 @@ struct RDEPUBReaderConfiguration {
|
||||
## 9. 目录结构
|
||||
|
||||
```
|
||||
Sources/RDReaderView/
|
||||
Sources/RDEpubReaderView/
|
||||
├── EPUBCore/ # EPUB 解析与 WebView 渲染
|
||||
│ ├── Models/ # 数据模型
|
||||
│ │ ├── RDEPUBAnnotationModels.swift
|
||||
@ -428,19 +428,19 @@ Sources/RDReaderView/
|
||||
│ ├── RDEPUBDTCoreTextRenderer.swift # DTCoreText 渲染器实现
|
||||
│ ├── RDEPUBChapterData.swift # 章节查询门面
|
||||
│ ├── RDEPUBTextIndexTable.swift # 全书索引表
|
||||
│ └── RDPlainTextBookBuilder.swift # 纯文本 (.txt) 构建器
|
||||
│ └── RDEpubPlainTextBookBuilder.swift # 纯文本 (.txt) 构建器
|
||||
│
|
||||
├── ReaderView/ # 通用翻页容器
|
||||
│ ├── Paging/ # 翻页子系统
|
||||
│ │ ├── RDReaderPagingController.swift
|
||||
│ │ ├── RDReaderPreloadController.swift
|
||||
│ │ ├── RDReaderSpreadResolver.swift
|
||||
│ │ └── RDReaderTapRegionHandler.swift
|
||||
│ ├── RDReaderView.swift # 主容器视图
|
||||
│ ├── RDReaderView+*.swift # 扩展(CollectionView/PageCurl/ToolView/ContentAccess)
|
||||
│ ├── RDReaderFlowLayout.swift # 自定义 CollectionView 布局
|
||||
│ ├── RDReaderContentCell.swift # 滚动模式 Cell
|
||||
│ └── RDReaderPageChildViewController.swift // 翻页模式子 VC
|
||||
│ │ ├── RDEpubReaderPagingController.swift
|
||||
│ │ ├── RDEpubReaderPreloadController.swift
|
||||
│ │ ├── RDEpubReaderSpreadResolver.swift
|
||||
│ │ └── RDEpubReaderTapRegionHandler.swift
|
||||
│ ├── RDEpubReaderView.swift # 主容器视图
|
||||
│ ├── RDEpubReaderView+*.swift # 扩展(CollectionView/PageCurl/ToolView/ContentAccess)
|
||||
│ ├── RDEpubReaderFlowLayout.swift # 自定义 CollectionView 布局
|
||||
│ ├── RDEpubReaderContentCell.swift # 滚动模式 Cell
|
||||
│ └── RDEpubReaderPageChildViewController.swift // 翻页模式子 VC
|
||||
│
|
||||
└── EPUBUI/ # 阅读器 UI
|
||||
├── ReaderController/ # 控制器与协调器
|
||||
@ -494,7 +494,7 @@ Sources/RDReaderView/
|
||||
| **Builder** | `RDEPUBBookPageMap.Builder` 增量构建页码映射 |
|
||||
| **Strategy** | `RDEPUBTextRenderer` 协议,可替换渲染器实现 |
|
||||
| **Pipeline** | `RDEPUBTextTypesetterPipeline` 排版管线(8 个逻辑阶段,封装为 5-6 个顶层调用) |
|
||||
| **Adapter** | `RDReaderLegacyDataSourceAdapter` 适配旧数据源协议 |
|
||||
| **Adapter** | `RDEpubReaderLegacyDataSourceAdapter` 适配旧数据源协议 |
|
||||
| **三级缓存** | 内存 → 磁盘摘要 → 全书分页,逐级降级 |
|
||||
| **Token 取消** | `paginationToken` 确保过期异步任务不干扰新任务 |
|
||||
| **Frozen Parameters** | 后台任务冻结 `renderSignature`,避免运行中参数漂移 |
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# ReadViewSDK 代码审查报告
|
||||
|
||||
> 审查范围:`Sources/RDReaderView/` 全部 184 个 Swift 文件(约 32,300 行)
|
||||
> 审查范围:`Sources/RDEpubReaderView/` 全部 184 个 Swift 文件(约 32,300 行)
|
||||
> 审查维度:并发安全、内存管理、API 正确性、Swift/iOS 平台特定、架构与依赖
|
||||
> 审查日期:2026-06-26
|
||||
|
||||
@ -134,7 +134,7 @@ let stillChanged = abs(currentSize.width - self.lastTextPaginationPageSize!.widt
|
||||
|
||||
### H-10 · 依赖版本未锁定
|
||||
|
||||
`RDReaderView.podspec` 中 DTCoreText、SnapKit、SSAlertSwift 无版本约束:
|
||||
`RDEpubReaderView.podspec` 中 DTCoreText、SnapKit、SSAlertSwift 无版本约束:
|
||||
|
||||
```ruby
|
||||
s.dependency 'ZIPFoundation', '~> 0.9' # ✅ 已锁定
|
||||
@ -143,7 +143,7 @@ s.dependency 'SnapKit' # ❌ 无约束
|
||||
s.dependency 'SSAlertSwift' # ❌ 无约束
|
||||
```
|
||||
|
||||
**位置:** `RDReaderView.podspec:17-20`
|
||||
**位置:** `RDEpubReaderView.podspec:17-20`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -298,14 +298,14 @@ return builder.build()
|
||||
|
||||
**触发条件:** `landscapeDualPageEnabled && isLandscape && !verticalScroll`
|
||||
|
||||
**页面配对逻辑(RDReaderSpreadResolver):**
|
||||
**页面配对逻辑(RDEpubReaderSpreadResolver):**
|
||||
|
||||
- 有封面页:封面独占一屏,后续页面两两配对
|
||||
- 配对规则:`(coverIndex, nil)`, `(1, 2)`, `(3, 4)`, ...
|
||||
- 无封面页:标准偶奇配对
|
||||
- 配对规则:`(0, 1)`, `(2, 3)`, `(4, 5)`, ...
|
||||
|
||||
**封面感知布局(RDReaderFlowLayout):**
|
||||
**封面感知布局(RDEpubReaderFlowLayout):**
|
||||
|
||||
```
|
||||
封面页: [====全屏宽度====]
|
||||
@ -314,7 +314,7 @@ return builder.build()
|
||||
|
||||
### 5.3 页面预加载
|
||||
|
||||
`RDReaderPreloadController` 管理两个缓存:
|
||||
`RDEpubReaderPreloadController` 管理两个缓存:
|
||||
- `preloadedPageViews` - 预渲染的页面视图
|
||||
- `pageCurlCachedViews` - 当前显示的页面视图
|
||||
|
||||
@ -580,7 +580,7 @@ protocol RDEPUBReaderPersistence {
|
||||
|
||||
## 9. 纯文本 (.txt) 支持
|
||||
|
||||
`RDPlainTextBookBuilder` 复用 EPUB 渲染管线处理 .txt 文件:
|
||||
`RDEpubPlainTextBookBuilder` 复用 EPUB 渲染管线处理 .txt 文件:
|
||||
|
||||
1. **解码:** 依次尝试 UTF-8 → GB18030 → GBK
|
||||
2. **分章:** 正则匹配 `^(第[零一二三四五六七八九十百千万\d]+[章节回卷].*)$`
|
||||
|
||||
@ -34,7 +34,7 @@ epubcfi(/6/4!ch01.xhtml/4/2/1:3)
|
||||
|
||||
顶层 CFI 模型,对应一个完整的 `epubcfi(...)` 字符串。
|
||||
|
||||
**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFI.swift`
|
||||
**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFI.swift`
|
||||
|
||||
```swift
|
||||
public struct RDEPUBCFI: Codable, Equatable, Hashable {
|
||||
@ -66,7 +66,7 @@ public enum RDEPUBCFISideBias: String, Codable {
|
||||
|
||||
路径模型,由一组 `RDEPUBCFIStep` 组成,描述从根节点到目标节点的遍历序列。
|
||||
|
||||
**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIPath.swift`
|
||||
**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIPath.swift`
|
||||
|
||||
```swift
|
||||
public struct RDEPUBCFIPath: Codable, Equatable, Hashable {
|
||||
@ -95,7 +95,7 @@ public struct RDEPUBCFIStep: Codable, Equatable, Hashable {
|
||||
|
||||
范围模型,表示文档中的一个连续区域,由父级 CFI 和起止 CFI 组成。
|
||||
|
||||
**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIRange.swift`
|
||||
**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIRange.swift`
|
||||
|
||||
```swift
|
||||
public struct RDEPUBCFIRange: Codable, Equatable, Hashable {
|
||||
@ -116,7 +116,7 @@ public struct RDEPUBCFIRange: Codable, Equatable, Hashable {
|
||||
|
||||
将 CFI 字符串解析为结构化模型。
|
||||
|
||||
**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIParser.swift`
|
||||
**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIParser.swift`
|
||||
|
||||
```swift
|
||||
public enum RDEPUBCFIParser {
|
||||
@ -154,7 +154,7 @@ print(cfi.textAssertion?.exact) // Optional("目标文本")
|
||||
|
||||
将 CFI 模型序列化为标准字符串。
|
||||
|
||||
**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFISerializer.swift`
|
||||
**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFISerializer.swift`
|
||||
|
||||
```swift
|
||||
public enum RDEPUBCFISerializer {
|
||||
@ -192,7 +192,7 @@ let serialized = RDEPUBCFISerializer.serialize(cfi)
|
||||
|
||||
将 CFI 解析为可直接用于资源定位的结果。
|
||||
|
||||
**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIResolver.swift`
|
||||
**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIResolver.swift`
|
||||
|
||||
```swift
|
||||
public struct RDEPUBCFIResolverResult: Equatable {
|
||||
@ -228,7 +228,7 @@ print(result.chapterOffset) // Optional(80)
|
||||
|
||||
从已知的章节信息生成 CFI。
|
||||
|
||||
**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIGenerator.swift`
|
||||
**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIGenerator.swift`
|
||||
|
||||
```swift
|
||||
public enum RDEPUBCFIGenerator {
|
||||
@ -274,7 +274,7 @@ public enum RDEPUBCFIGenerator {
|
||||
|
||||
从 HTML 源码中提取带有 `id` 属性的元素路径映射。
|
||||
|
||||
**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIDOMPathBuilder.swift`
|
||||
**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIDOMPathBuilder.swift`
|
||||
|
||||
```swift
|
||||
public enum RDEPUBCFIDOMPathBuilder {
|
||||
@ -296,7 +296,7 @@ public enum RDEPUBCFIDOMPathBuilder {
|
||||
|
||||
当 CFI 精确定位失败时(如 DOM 结构变更),恢复引擎通过多级降级策略尝试找到最佳匹配位置。
|
||||
|
||||
**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIRecoveryEngine.swift`
|
||||
**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIRecoveryEngine.swift`
|
||||
|
||||
### 5.1 恢复置信度
|
||||
|
||||
@ -361,7 +361,7 @@ let result = RDEPUBCFIRecoveryEngine.recover(
|
||||
|
||||
文本断言用于在 CFI 定位后验证所指位置的文本内容是否符合预期,增强定位鲁棒性。
|
||||
|
||||
**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFITextAssertion.swift`
|
||||
**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFITextAssertion.swift`
|
||||
|
||||
```swift
|
||||
public struct RDEPUBCFITextAssertion: Codable, Equatable, Hashable {
|
||||
@ -393,7 +393,7 @@ print(assertion?.suffix) // Optional("后续内容")
|
||||
|
||||
提供宽松解析接口,兼容非标准 CFI 格式。
|
||||
|
||||
**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFICompatibility.swift`
|
||||
**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFICompatibility.swift`
|
||||
|
||||
```swift
|
||||
public enum RDEPUBCFICompatibility {
|
||||
@ -418,7 +418,7 @@ public enum RDEPUBCFICompatibility {
|
||||
|
||||
CFI 映射是章节级别的索引结构,将 CFI 路径映射到章节文本偏移量,是容错恢复引擎的核心数据源。
|
||||
|
||||
**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIMap.swift`
|
||||
**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIMap.swift`
|
||||
|
||||
### 8.1 RDEPUBCFIMap
|
||||
|
||||
@ -506,7 +506,7 @@ public struct RDEPUBCFITokenAnchor: Codable, Equatable {
|
||||
|
||||
## 9. 错误类型(RDEPUBCFIError)
|
||||
|
||||
**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIError.swift`
|
||||
**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIError.swift`
|
||||
|
||||
```swift
|
||||
public enum RDEPUBCFIError: Error, Equatable {
|
||||
|
||||
@ -16,8 +16,8 @@
|
||||
- 渐进补全:后台逐步补全所有章节的页码信息
|
||||
|
||||
**关键文件**:
|
||||
- `Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/` 目录
|
||||
- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift`
|
||||
- `Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/` 目录
|
||||
- `Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -105,15 +105,15 @@
|
||||
## Evidence(仓库路径)
|
||||
|
||||
- Build flags:`Podfile`
|
||||
- Pod metadata/toolchain:`RDReaderView.podspec`
|
||||
- Archive extraction:`Sources/RDReaderView/EPUBCore/RDEPUBParser+Archive.swift`
|
||||
- Resource path validation(post-extraction):`Sources/RDReaderView/EPUBCore/RDEPUBParser+Resources.swift`
|
||||
- Scheme handler reads full file bytes:`Sources/RDReaderView/EPUBCore/RDEPUBResourceURLSchemeHandler.swift`
|
||||
- WebView bridge + message handling:`Sources/RDReaderView/EPUBCore/RDEPUBWebView+JavaScriptBridge.swift`
|
||||
- External link opening:`Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift`
|
||||
- UserDefaults persistence implementation:`Sources/RDReaderView/EPUBUI/RDEPUBReaderPersistence.swift`
|
||||
- Hidden paginator web view:`Sources/RDReaderView/EPUBCore/RDEPUBPaginator.swift`
|
||||
- Debug logging:`Sources/RDReaderView/EPUBCore/RDEPUBWebViewDebug.swift`
|
||||
- Pod metadata/toolchain:`RDEpubReaderView.podspec`
|
||||
- Archive extraction:`Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+Archive.swift`
|
||||
- Resource path validation(post-extraction):`Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+Resources.swift`
|
||||
- Scheme handler reads full file bytes:`Sources/RDEpubReaderView/EPUBCore/RDEPUBResourceURLSchemeHandler.swift`
|
||||
- WebView bridge + message handling:`Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+JavaScriptBridge.swift`
|
||||
- External link opening:`Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController.swift`
|
||||
- UserDefaults persistence implementation:`Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderPersistence.swift`
|
||||
- Hidden paginator web view:`Sources/RDEpubReaderView/EPUBCore/RDEPUBPaginator.swift`
|
||||
- Debug logging:`Sources/RDEpubReaderView/EPUBCore/RDEPUBWebViewDebug.swift`
|
||||
- Vendored dependencies:`Pods/`、`ReadViewDemo/Pods/`
|
||||
|
||||
---
|
||||
|
||||
@ -6,43 +6,43 @@
|
||||
|
||||
## 语言与工程约束
|
||||
|
||||
- **主要语言**:Swift(Podspec 声明 `s.swift_versions = ["5.10"]`,见 `RDReaderView.podspec`)
|
||||
- **最低系统版本**:Podspec `iOS 15.0`(`RDReaderView.podspec`),示例工程 Podfile/构建设置里常见为 `iOS 15.6`(`Podfile`)
|
||||
- **主要语言**:Swift(Podspec 声明 `s.swift_versions = ["5.10"]`,见 `RDEpubReaderView.podspec`)
|
||||
- **最低系统版本**:Podspec `iOS 15.0`(`RDEpubReaderView.podspec`),示例工程 Podfile/构建设置里常见为 `iOS 15.6`(`Podfile`)
|
||||
- **依赖管理**:CocoaPods(`Podfile`、`ReadViewDemo/Podfile`、`Podfile.lock`)
|
||||
|
||||
## 命名约定
|
||||
|
||||
**文件/类型命名(Swift):**
|
||||
- 以类型名为文件名的单文件组织较常见:`Sources/RDReaderView/EPUBCore/RDEPUBParser.swift`
|
||||
- 以类型名为文件名的单文件组织较常见:`Sources/RDEpubReaderView/EPUBCore/RDEPUBParser.swift`
|
||||
- 大量使用前缀区分模块域:
|
||||
- `RD...`:阅读器 UI/控制器相关(如 `Sources/RDReaderView/RDReaderView.swift`、`Sources/RDReaderView/RDURLReaderController.swift`)
|
||||
- `RDEPUB...`:EPUB Core/UI/渲染相关(如 `Sources/RDReaderView/EPUBCore/RDEPUBModels.swift`)
|
||||
- Extension 文件使用 `+` 命名:`Sources/RDReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift`
|
||||
- `RD...`:阅读器 UI/控制器相关(如 `Sources/RDEpubReaderView/RDEpubReaderView.swift`、`Sources/RDEpubReaderView/RDEpubURLReaderController.swift`)
|
||||
- `RDEPUB...`:EPUB Core/UI/渲染相关(如 `Sources/RDEpubReaderView/EPUBCore/RDEPUBModels.swift`)
|
||||
- Extension 文件使用 `+` 命名:`Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift`
|
||||
|
||||
**变量/函数命名:**
|
||||
- 基本遵循 Swift lowerCamelCase:`parse(epubURL:)`(`Sources/RDReaderView/EPUBCore/RDEPUBParser.swift`)
|
||||
- 常量多用 `static let`:`kRDEPUBHighlightAttributeName`(`Sources/RDReaderView/EPUBCore/Models/RDEPUBAnnotationModels.swift`)
|
||||
- 基本遵循 Swift lowerCamelCase:`parse(epubURL:)`(`Sources/RDEpubReaderView/EPUBCore/RDEPUBParser.swift`)
|
||||
- 常量多用 `static let`:`kRDEPUBHighlightAttributeName`(`Sources/RDEpubReaderView/EPUBCore/Models/RDEPUBAnnotationModels.swift`)
|
||||
|
||||
## 代码风格与排版(从现有代码归纳)
|
||||
|
||||
**缩进与换行:**
|
||||
- 多数文件使用 4 空格缩进(示例:`ReadViewDemo/ReadViewDemo/ViewController.swift`、`Sources/RDReaderView/EPUBCore/RDEPUBModels.swift`)
|
||||
- 多数文件使用 4 空格缩进(示例:`ReadViewDemo/ReadViewDemo/ViewController.swift`、`Sources/RDEpubReaderView/EPUBCore/RDEPUBModels.swift`)
|
||||
- 历史代码中更常见”强制换行/多行括号”风格
|
||||
|
||||
**空行与分组:**
|
||||
- UI 相关文件常用空行分隔属性/初始化/布局段落
|
||||
- `// MARK:` 用于分区组织(示例:`Sources/RDReaderView/RDReaderGestureController.swift`、`Sources/RDReaderView/EPUBTextRendering/RDPlainTextBookBuilder.swift`)
|
||||
- `// MARK:` 用于分区组织(示例:`Sources/RDEpubReaderView/RDEpubReaderGestureController.swift`、`Sources/RDEpubReaderView/EPUBTextRendering/RDEpubPlainTextBookBuilder.swift`)
|
||||
|
||||
**类型组织:**
|
||||
- 偏好用 `extension` 拆分职责/协议实现(示例:`ReadViewDemo/ReadViewDemo/ViewController.swift` 的 `UITableViewDataSource/Delegate`)
|
||||
- API 暴露处使用 `public`、`public final class`、`public enum/struct`(示例:`Sources/RDReaderView/EPUBCore/RDEPUBModels.swift`)
|
||||
- “对外只读、内部可写”常用 `public internal(set)`(示例:`Sources/RDReaderView/EPUBCore/RDEPUBParser.swift`)
|
||||
- API 暴露处使用 `public`、`public final class`、`public enum/struct`(示例:`Sources/RDEpubReaderView/EPUBCore/RDEPUBModels.swift`)
|
||||
- “对外只读、内部可写”常用 `public internal(set)`(示例:`Sources/RDEpubReaderView/EPUBCore/RDEPUBParser.swift`)
|
||||
|
||||
## 导入与依赖使用
|
||||
|
||||
**import:**
|
||||
- UIKit/UI 文件:`import UIKit`(大量文件)
|
||||
- Core/模型文件:`import Foundation`(如 `Sources/RDReaderView/EPUBCore/RDEPUBModels.swift`)
|
||||
- Core/模型文件:`import Foundation`(如 `Sources/RDEpubReaderView/EPUBCore/RDEPUBModels.swift`)
|
||||
- 三方依赖按需引入:
|
||||
- `SnapKit`:布局
|
||||
- `SSAlertSwift`:弹窗/提示
|
||||
@ -52,15 +52,15 @@
|
||||
|
||||
## 错误处理与日志
|
||||
|
||||
- Core 解析层倾向用 `throws` + 自定义 `Error`(示例:`Sources/RDReaderView/EPUBCore/RDEPUBParser.swift`、`Sources/RDReaderView/EPUBCore/RDEPUBModels.swift` 的 `RDEPUBParserError`)
|
||||
- Core 解析层倾向用 `throws` + 自定义 `Error`(示例:`Sources/RDEpubReaderView/EPUBCore/RDEPUBParser.swift`、`Sources/RDEpubReaderView/EPUBCore/RDEPUBModels.swift` 的 `RDEPUBParserError`)
|
||||
- UI/控制器层常见 `guard` 早返回(示例:`ReadViewDemo/ReadViewDemo/ViewController.swift`)
|
||||
- 未检测到统一日志框架(未发现专用 logging package/config);出现时以系统 API/局部输出为主(需按具体文件核对)。
|
||||
|
||||
## 注释与文档
|
||||
|
||||
- **项目规则(强约束)**:代码标识符保持英文,但**代码注释/文档/提交信息使用中文**(见 `CONTEXT.md`)。
|
||||
- 历史文件常带 Xcode 头部注释块(示例:`Sources/RDReaderView/ReaderView/RDReaderView.swift`)。
|
||||
- 公共 API 处存在少量三斜线文档注释(示例:`Sources/RDReaderView/RDReaderView.swift` 的中文说明)。
|
||||
- 历史文件常带 Xcode 头部注释块(示例:`Sources/RDEpubReaderView/ReaderView/RDEpubReaderView.swift`)。
|
||||
- 公共 API 处存在少量三斜线文档注释(示例:`Sources/RDEpubReaderView/RDEpubReaderView.swift` 的中文说明)。
|
||||
|
||||
## Lint / Formatter / 静态检查
|
||||
|
||||
@ -77,9 +77,9 @@
|
||||
|
||||
- `CONTEXT.md`
|
||||
- `Podfile`
|
||||
- `RDReaderView.podspec`
|
||||
- `RDEpubReaderView.podspec`
|
||||
- `ReadViewDemo/ReadViewDemo/ViewController.swift`
|
||||
- `Sources/RDReaderView/ReaderView/RDReaderView.swift`
|
||||
- `Sources/RDReaderView/EPUBCore/RDEPUBParser.swift`
|
||||
- `Sources/RDReaderView/EPUBCore/RDEPUBModels.swift`
|
||||
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift`
|
||||
- `Sources/RDEpubReaderView/ReaderView/RDEpubReaderView.swift`
|
||||
- `Sources/RDEpubReaderView/EPUBCore/RDEPUBParser.swift`
|
||||
- `Sources/RDEpubReaderView/EPUBCore/RDEPUBModels.swift`
|
||||
- `Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController.swift`
|
||||
|
||||
@ -471,7 +471,7 @@ final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
|
||||
|
||||
## 9. 纯文本构建器
|
||||
|
||||
**文件:** `RDPlainTextBookBuilder.swift`
|
||||
**文件:** `RDEpubPlainTextBookBuilder.swift`
|
||||
|
||||
从纯文本(.txt)文件构建 `RDEPUBTextBook`,用于支持 TXT 格式阅读。
|
||||
|
||||
|
||||
@ -48,7 +48,7 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
let epubURL: URL // EPUB 文件路径
|
||||
let persistence: RDEPUBReaderPersistence? // 持久化代理
|
||||
let dependencies: RDEPUBReaderDependencies // 依赖注入
|
||||
let readerView = RDReaderView() // 翻页容器
|
||||
let readerView = RDEpubReaderView() // 翻页容器
|
||||
}
|
||||
```
|
||||
|
||||
@ -57,7 +57,7 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `RDEPUBReaderController+PublicAPI.swift` | 公开 API(跳转、搜索、标注、书签) |
|
||||
| `RDEPUBReaderController+DataSource.swift` | `RDReaderPageProvider` 实现 |
|
||||
| `RDEPUBReaderController+DataSource.swift` | `RDEpubReaderPageProvider` 实现 |
|
||||
| `RDEPUBReaderController+ContentDelegates.swift` | WebView/TextContentView 代理路由 |
|
||||
| `RDEPUBReaderController+LocationResolution.swift` | 页码/位置解析、阅读状态同步 |
|
||||
| `RDEPUBReaderController+ExternalLinks.swift` | 外部链接处理(白名单、确认弹窗) |
|
||||
@ -120,7 +120,7 @@ public protocol RDEPUBReaderPersistence: AnyObject {
|
||||
```swift
|
||||
final class RDEPUBReaderContext {
|
||||
weak var controller: RDEPUBReaderController?
|
||||
weak var readerView: RDReaderView?
|
||||
weak var readerView: RDEpubReaderView?
|
||||
var dependencies: RDEPUBReaderDependencies
|
||||
var runtime: RDEPUBReaderRuntime?
|
||||
|
||||
@ -155,7 +155,7 @@ public struct RDEPUBReaderDependencies {
|
||||
public var makeParser: () -> RDEPUBParser
|
||||
public var makePaginator: () -> RDEPUBPaginator
|
||||
public var makeTextBookBuilder: (RDEPUBTextRenderer, RDEPUBTextBookCache?, RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder
|
||||
public var makePlainTextBookBuilder: (RDEPUBTextRenderer, RDEPUBTextLayoutConfig) -> RDPlainTextBookBuilder
|
||||
public var makePlainTextBookBuilder: (RDEPUBTextRenderer, RDEPUBTextLayoutConfig) -> RDEpubPlainTextBookBuilder
|
||||
public var makeTextRenderer: (RDEPUBTextRenderingEngine) -> RDEPUBTextRenderer
|
||||
|
||||
public static var live: RDEPUBReaderDependencies // 默认实现
|
||||
@ -659,9 +659,9 @@ struct RDEPUBReaderUIState {
|
||||
|
||||
页图协调器(按需分页与全量分页的结果合并)。
|
||||
|
||||
### 10.6 RDURLReaderController
|
||||
### 10.6 RDEpubURLReaderController
|
||||
|
||||
**文件:** `RDURLReaderController.swift`
|
||||
**文件:** `RDEpubURLReaderController.swift`
|
||||
|
||||
URL 阅读器控制器(用于打开单个 URL)。
|
||||
|
||||
@ -715,12 +715,12 @@ RDEPUBReaderRuntime.applyParsedPublication()
|
||||
│
|
||||
├── RDEPUBBookPageMap 生成
|
||||
│
|
||||
└── RDReaderView.transitionToPage() → 显示页面
|
||||
└── RDEpubReaderView.transitionToPage() → 显示页面
|
||||
|
||||
用户翻页
|
||||
│
|
||||
▼
|
||||
RDReaderView.currentPage 变化
|
||||
RDEpubReaderView.currentPage 变化
|
||||
│
|
||||
├── RDEPUBReaderLocationCoordinator.recordPageChangeIfNeeded()
|
||||
│ └── persistence.saveLocation()
|
||||
|
||||
154
Doc/LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md
Normal file
154
Doc/LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md
Normal file
@ -0,0 +1,154 @@
|
||||
# 长章节内存优化 — 代码实施清单
|
||||
|
||||
> 背景: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 页峰值;设置变更前后峰值;横竖屏切换峰值。
|
||||
|
||||
**性能**:首次打开章节耗时;连续翻页帧稳定性;设置切换恢复时间。
|
||||
123
Doc/PAGINATION_ISSUES_2026-07-06.md
Normal file
123
Doc/PAGINATION_ISSUES_2026-07-06.md
Normal file
@ -0,0 +1,123 @@
|
||||
# 分页问题调查记录(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` 回归验证这两个窗口。
|
||||
@ -6,54 +6,54 @@
|
||||
|
||||
## 1. 模块概述
|
||||
|
||||
`ReaderView` 是 ReadViewSDK 的**通用翻页容器层**,位于 EPUBUI 层之下。它提供与 EPUB 内容无关的页面展示、翻页动画、手势识别、页面预加载和双页布局能力。上层通过 `RDReaderPageProvider` 协议提供页面内容视图,ReaderView 负责容器管理和翻页调度。
|
||||
`ReaderView` 是 ReadViewSDK 的**通用翻页容器层**,位于 EPUBUI 层之下。它提供与 EPUB 内容无关的页面展示、翻页动画、手势识别、页面预加载和双页布局能力。上层通过 `RDEpubReaderPageProvider` 协议提供页面内容视图,ReaderView 负责容器管理和翻页调度。
|
||||
|
||||
**文件清单(14 个 Swift 文件):**
|
||||
|
||||
| 文件 | 核心类型 | 职责 |
|
||||
|------|----------|------|
|
||||
| `RDReaderView.swift` | `RDReaderView` | 主容器视图,协调所有子组件 |
|
||||
| `RDReaderViewProtocols.swift` | 协议 + 枚举 | 数据源、代理、导航协议定义 |
|
||||
| `RDReaderFlowLayout.swift` | `RDReaderFlowLayout` | UICollectionView 自定义布局 |
|
||||
| `RDReaderGestureController.swift` | `RDReaderGestureController` | 手势控制器(预留) |
|
||||
| `RDReaderContentCell.swift` | `RDReaderContentCell` | CollectionView 内容 Cell |
|
||||
| `RDReaderPageChildViewController.swift` | `RDReaderPageChildViewController` | PageCurl 模式子 VC |
|
||||
| `RDReaderView+PageCurl.swift` | Extension | UIPageViewController 数据源/代理 |
|
||||
| `RDReaderView+CollectionView.swift` | Extension | UICollectionView 数据源/布局代理 |
|
||||
| `RDReaderView+ContentAccess.swift` | Extension | 内容视图访问与复用 |
|
||||
| `RDReaderView+ToolView.swift` | Extension | 工具栏安装与动画 |
|
||||
| `Paging/RDReaderPagingController.swift` | `RDReaderPagingController` | 翻页状态机 |
|
||||
| `Paging/RDReaderPreloadController.swift` | `RDReaderPreloadController` | 页面预加载与缓存 |
|
||||
| `Paging/RDReaderSpreadResolver.swift` | `RDReaderSpreadResolver` | 双页展开计算 |
|
||||
| `Paging/RDReaderTapRegionHandler.swift` | `RDReaderTapRegionHandler` | 点击区域判定 |
|
||||
| `RDEpubReaderView.swift` | `RDEpubReaderView` | 主容器视图,协调所有子组件 |
|
||||
| `RDEpubReaderViewProtocols.swift` | 协议 + 枚举 | 数据源、代理、导航协议定义 |
|
||||
| `RDEpubReaderFlowLayout.swift` | `RDEpubReaderFlowLayout` | UICollectionView 自定义布局 |
|
||||
| `RDEpubReaderGestureController.swift` | `RDEpubReaderGestureController` | 手势控制器(预留) |
|
||||
| `RDEpubReaderContentCell.swift` | `RDEpubReaderContentCell` | CollectionView 内容 Cell |
|
||||
| `RDEpubReaderPageChildViewController.swift` | `RDEpubReaderPageChildViewController` | PageCurl 模式子 VC |
|
||||
| `RDEpubReaderView+PageCurl.swift` | Extension | UIPageViewController 数据源/代理 |
|
||||
| `RDEpubReaderView+CollectionView.swift` | Extension | UICollectionView 数据源/布局代理 |
|
||||
| `RDEpubReaderView+ContentAccess.swift` | Extension | 内容视图访问与复用 |
|
||||
| `RDEpubReaderView+ToolView.swift` | Extension | 工具栏安装与动画 |
|
||||
| `Paging/RDEpubReaderPagingController.swift` | `RDEpubReaderPagingController` | 翻页状态机 |
|
||||
| `Paging/RDEpubReaderPreloadController.swift` | `RDEpubReaderPreloadController` | 页面预加载与缓存 |
|
||||
| `Paging/RDEpubReaderSpreadResolver.swift` | `RDEpubReaderSpreadResolver` | 双页展开计算 |
|
||||
| `Paging/RDEpubReaderTapRegionHandler.swift` | `RDEpubReaderTapRegionHandler` | 点击区域判定 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 协议定义(RDReaderViewProtocols.swift)
|
||||
## 2. 协议定义(RDEpubReaderViewProtocols.swift)
|
||||
|
||||
### 2.1 RDReaderDataSource(旧版数据源,已废弃)
|
||||
### 2.1 RDEpubReaderDataSource(旧版数据源,已废弃)
|
||||
|
||||
```swift
|
||||
@objc public protocol RDReaderDataSource: NSObjectProtocol {
|
||||
func pageCountOfReaderView(readerView: RDReaderView) -> Int
|
||||
func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView
|
||||
func pageIdentifier(readerView: RDReaderView, pageNum: Int) -> String?
|
||||
@objc optional func topToolView(readerView: RDReaderView) -> UIView?
|
||||
@objc optional func bottomToolView(readerView: RDReaderView) -> UIView?
|
||||
@objc public protocol RDEpubReaderDataSource: NSObjectProtocol {
|
||||
func pageCountOfReaderView(readerView: RDEpubReaderView) -> Int
|
||||
func pageContentView(readerView: RDEpubReaderView, pageNum: Int, containerView: UIView?) -> UIView
|
||||
func pageIdentifier(readerView: RDEpubReaderView, pageNum: Int) -> String?
|
||||
@objc optional func topToolView(readerView: RDEpubReaderView) -> UIView?
|
||||
@objc optional func bottomToolView(readerView: RDEpubReaderView) -> UIView?
|
||||
}
|
||||
```
|
||||
|
||||
> 向后兼容保留,新代码应使用 `RDReaderPageProvider`。
|
||||
> 向后兼容保留,新代码应使用 `RDEpubReaderPageProvider`。
|
||||
|
||||
### 2.2 RDReaderPageProvider(推荐数据源)
|
||||
### 2.2 RDEpubReaderPageProvider(推荐数据源)
|
||||
|
||||
```swift
|
||||
@objc public protocol RDReaderPageProvider: NSObjectProtocol {
|
||||
func numberOfPages(in readerView: RDReaderView) -> Int
|
||||
func readerView(_ readerView: RDReaderView, viewForPageAt index: Int, reusableView: UIView?) -> UIView
|
||||
@objc optional func pageIdentifier(in readerView: RDReaderView, index: Int) -> String?
|
||||
@objc optional func readerViewTopChrome(_ readerView: RDReaderView) -> UIView?
|
||||
@objc optional func readerViewBottomChrome(_ readerView: RDReaderView) -> UIView?
|
||||
@objc public protocol RDEpubReaderPageProvider: NSObjectProtocol {
|
||||
func numberOfPages(in readerView: RDEpubReaderView) -> Int
|
||||
func readerView(_ readerView: RDEpubReaderView, viewForPageAt index: Int, reusableView: UIView?) -> UIView
|
||||
@objc optional func pageIdentifier(in readerView: RDEpubReaderView, index: Int) -> String?
|
||||
@objc optional func readerViewTopChrome(_ readerView: RDEpubReaderView) -> UIView?
|
||||
@objc optional func readerViewBottomChrome(_ readerView: RDEpubReaderView) -> UIView?
|
||||
}
|
||||
```
|
||||
|
||||
@ -65,31 +65,31 @@
|
||||
| `readerViewTopChrome(_:)` | 返回顶部工具栏视图 |
|
||||
| `readerViewBottomChrome(_:)` | 返回底部工具栏视图 |
|
||||
|
||||
### 2.3 RDReaderDelegate
|
||||
### 2.3 RDEpubReaderDelegate
|
||||
|
||||
```swift
|
||||
@objc public protocol RDReaderDelegate: NSObjectProtocol {
|
||||
func pageNum(readerView: RDReaderView, pageNum: Int)
|
||||
@objc optional func readerViewOrientationWillChange(readerView: RDReaderView, isLandscape: Bool)
|
||||
@objc public protocol RDEpubReaderDelegate: NSObjectProtocol {
|
||||
func pageNum(readerView: RDEpubReaderView, pageNum: Int)
|
||||
@objc optional func readerViewOrientationWillChange(readerView: RDEpubReaderView, isLandscape: Bool)
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 RDReaderPageNavigating
|
||||
### 2.4 RDEpubReaderPageNavigating
|
||||
|
||||
```swift
|
||||
public protocol RDReaderPageNavigating: AnyObject {
|
||||
public protocol RDEpubReaderPageNavigating: AnyObject {
|
||||
var currentPage: Int { get }
|
||||
func reloadPages()
|
||||
func transition(to page: Int, animated: Bool)
|
||||
}
|
||||
```
|
||||
|
||||
`RDReaderView` 遵循此协议,提供统一的页面导航接口。
|
||||
`RDEpubReaderView` 遵循此协议,提供统一的页面导航接口。
|
||||
|
||||
### 2.5 枚举类型
|
||||
|
||||
```swift
|
||||
extension RDReaderView {
|
||||
extension RDEpubReaderView {
|
||||
public enum DisplayType {
|
||||
case pageCurl // 仿真翻页(UIPageViewController)
|
||||
case horizontalScroll // 水平滑动(UICollectionView)
|
||||
@ -105,13 +105,13 @@ extension RDReaderView {
|
||||
|
||||
---
|
||||
|
||||
## 3. 核心类:RDReaderView
|
||||
## 3. 核心类:RDEpubReaderView
|
||||
|
||||
**文件:** `RDReaderView.swift`
|
||||
**文件:** `RDEpubReaderView.swift`
|
||||
|
||||
`RDReaderView` 是一个 `UIView` 子类,作为翻页容器的主入口。内部管理两种翻页引擎:
|
||||
`RDEpubReaderView` 是一个 `UIView` 子类,作为翻页容器的主入口。内部管理两种翻页引擎:
|
||||
- **PageCurl 模式**:使用 `UIPageViewController` 实现仿真翻页
|
||||
- **Scroll 模式**:使用 `UICollectionView` + 自定义 `RDReaderFlowLayout` 实现滑动翻页
|
||||
- **Scroll 模式**:使用 `UICollectionView` + 自定义 `RDEpubReaderFlowLayout` 实现滑动翻页
|
||||
|
||||
### 3.1 关键属性
|
||||
|
||||
@ -124,16 +124,16 @@ extension RDReaderView {
|
||||
| `coverPageIndex` | `Int?` | 封面页索引(独占一屏) |
|
||||
| `pagesPerScreen` | `Int` | 每屏页数(横屏双页时为 2) |
|
||||
| `preloadRadius` | `Int` | 预加载半径(默认 1) |
|
||||
| `dataSource` | `RDReaderDataSource?` | 旧版数据源 |
|
||||
| `pageProvider` | `RDReaderPageProvider?` | 推荐数据源 |
|
||||
| `delegate` | `RDReaderDelegate?` | 事件代理 |
|
||||
| `dataSource` | `RDEpubReaderDataSource?` | 旧版数据源 |
|
||||
| `pageProvider` | `RDEpubReaderPageProvider?` | 推荐数据源 |
|
||||
| `delegate` | `RDEpubReaderDelegate?` | 事件代理 |
|
||||
| `toolViewAnimationDuration` | `TimeInterval` | 工具栏动画时长(0.3s) |
|
||||
|
||||
### 3.2 关键方法
|
||||
|
||||
```swift
|
||||
/// 切换显示模式(pageCurl / horizontalScroll / verticalScroll)
|
||||
public func switchReaderDisplayType(_ displayType: RDReaderView.DisplayType)
|
||||
public func switchReaderDisplayType(_ displayType: RDEpubReaderView.DisplayType)
|
||||
|
||||
/// 跳转到指定页
|
||||
public func transitionToPage(pageNum: Int, animated: Bool = false)
|
||||
@ -154,22 +154,22 @@ public func isFullScreenPage(_ pageNum: Int) -> Bool
|
||||
|------|------|------|
|
||||
| `pageViewController` | `UIPageViewController` | PageCurl 翻页引擎 |
|
||||
| `collectionView` | `UICollectionView` | Scroll 翻页引擎 |
|
||||
| `layout` | `RDReaderFlowLayout` | CollectionView 自定义布局 |
|
||||
| `spreadResolver` | `RDReaderSpreadResolver` | 双页配对计算 |
|
||||
| `tapRegionHandler` | `RDReaderTapRegionHandler` | 点击区域判定 |
|
||||
| `preloadController` | `RDReaderPreloadController` | 页面预加载与缓存 |
|
||||
| `pagingController` | `RDReaderPagingController` | 翻页状态管理 |
|
||||
| `layout` | `RDEpubReaderFlowLayout` | CollectionView 自定义布局 |
|
||||
| `spreadResolver` | `RDEpubReaderSpreadResolver` | 双页配对计算 |
|
||||
| `tapRegionHandler` | `RDEpubReaderTapRegionHandler` | 点击区域判定 |
|
||||
| `preloadController` | `RDEpubReaderPreloadController` | 页面预加载与缓存 |
|
||||
| `pagingController` | `RDEpubReaderPagingController` | 翻页状态管理 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 翻页状态机:RDReaderPagingController
|
||||
## 4. 翻页状态机:RDEpubReaderPagingController
|
||||
|
||||
**文件:** `Paging/RDReaderPagingController.swift`
|
||||
**文件:** `Paging/RDEpubReaderPagingController.swift`
|
||||
|
||||
管理 PageCurl 模式下的翻页请求队列,防止动画冲突。
|
||||
|
||||
```swift
|
||||
struct RDReaderPagingController {
|
||||
struct RDEpubReaderPagingController {
|
||||
struct PageTransitionRequest: Equatable {
|
||||
let pageNum: Int
|
||||
let animated: Bool
|
||||
@ -180,7 +180,7 @@ struct RDReaderPagingController {
|
||||
var didBuildUI: Bool // UI 是否已构建
|
||||
|
||||
/// 判断是否应排队请求(PageCurl 模式下动画中返回 true)
|
||||
mutating func shouldQueuePageTransition(_ request: PageTransitionRequest, currentDisplayType: RDReaderView.DisplayType) -> Bool
|
||||
mutating func shouldQueuePageTransition(_ request: PageTransitionRequest, currentDisplayType: RDEpubReaderView.DisplayType) -> Bool
|
||||
|
||||
/// 完成翻页动画,返回待处理的请求
|
||||
mutating func finishPageCurlTransition() -> PageTransitionRequest?
|
||||
@ -192,9 +192,9 @@ struct RDReaderPagingController {
|
||||
|
||||
---
|
||||
|
||||
## 5. 页面预加载:RDReaderPreloadController
|
||||
## 5. 页面预加载:RDEpubReaderPreloadController
|
||||
|
||||
**文件:** `Paging/RDReaderPreloadController.swift`
|
||||
**文件:** `Paging/RDEpubReaderPreloadController.swift`
|
||||
|
||||
负责在当前页周围预渲染页面视图,减少翻页时的白屏时间。
|
||||
|
||||
@ -224,27 +224,27 @@ func invalidate(environment: Environment)
|
||||
|
||||
```swift
|
||||
struct Environment {
|
||||
let displayType: RDReaderView.DisplayType
|
||||
let displayType: RDEpubReaderView.DisplayType
|
||||
let isLandscape: Bool
|
||||
let pagesPerScreen: Int
|
||||
let boundsSize: CGSize
|
||||
let landscapeDualPageEnabled: Bool
|
||||
let coverPageIndex: Int?
|
||||
let totalPages: Int
|
||||
let spreadResolver: RDReaderSpreadResolver
|
||||
let spreadResolver: RDEpubReaderSpreadResolver
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 双页展开计算:RDReaderSpreadResolver
|
||||
## 6. 双页展开计算:RDEpubReaderSpreadResolver
|
||||
|
||||
**文件:** `Paging/RDReaderSpreadResolver.swift`
|
||||
**文件:** `Paging/RDEpubReaderSpreadResolver.swift`
|
||||
|
||||
纯函数式结构体,负责双页模式下的页面配对和导航计算。
|
||||
|
||||
```swift
|
||||
struct RDReaderSpreadResolver {
|
||||
struct RDEpubReaderSpreadResolver {
|
||||
/// 判断是否为全屏页(封面页独占一屏)
|
||||
func isFullScreenPage(_ pageNum: Int, landscapeDualPageEnabled: Bool, isLandscape: Bool, coverPageIndex: Int?) -> Bool
|
||||
|
||||
@ -265,15 +265,15 @@ struct RDReaderSpreadResolver {
|
||||
|
||||
---
|
||||
|
||||
## 7. 点击区域判定:RDReaderTapRegionHandler
|
||||
## 7. 点击区域判定:RDEpubReaderTapRegionHandler
|
||||
|
||||
**文件:** `Paging/RDReaderTapRegionHandler.swift`
|
||||
**文件:** `Paging/RDEpubReaderTapRegionHandler.swift`
|
||||
|
||||
将屏幕三等分,判定点击属于左/中/右区域。
|
||||
|
||||
```swift
|
||||
struct RDReaderTapRegionHandler {
|
||||
func resolveTapEvent(point: CGPoint, viewFrame: CGRect, isToolViewVisible: Bool) -> RDReaderView.TapEvent
|
||||
struct RDEpubReaderTapRegionHandler {
|
||||
func resolveTapEvent(point: CGPoint, viewFrame: CGRect, isToolViewVisible: Bool) -> RDEpubReaderView.TapEvent
|
||||
}
|
||||
```
|
||||
|
||||
@ -284,9 +284,9 @@ struct RDReaderTapRegionHandler {
|
||||
|
||||
---
|
||||
|
||||
## 8. 流式布局:RDReaderFlowLayout
|
||||
## 8. 流式布局:RDEpubReaderFlowLayout
|
||||
|
||||
**文件:** `RDReaderFlowLayout.swift`
|
||||
**文件:** `RDEpubReaderFlowLayout.swift`
|
||||
|
||||
`UICollectionViewFlowLayout` 子类,支持水平滚动和垂直滚动两种模式。
|
||||
|
||||
@ -294,7 +294,7 @@ struct RDReaderTapRegionHandler {
|
||||
|
||||
| 属性 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `displayType` | `RDReaderView.DisplayType` | 布局模式 |
|
||||
| `displayType` | `RDEpubReaderView.DisplayType` | 布局模式 |
|
||||
| `isLandscapeDualPage` | `Bool` | 是否横屏双页 |
|
||||
| `coverPageIndex` | `Int?` | 封面页索引 |
|
||||
| `pagesPerScreen` | `Int` | 每屏页数 |
|
||||
@ -302,12 +302,12 @@ struct RDReaderTapRegionHandler {
|
||||
### 8.2 协议
|
||||
|
||||
```swift
|
||||
public protocol RDReaderFlowLayoutDataSoure: NSObjectProtocol {
|
||||
func heigtOfVerticalScrollPage(flowLayout: RDReaderFlowLayout, pageIndex: Int) -> CGFloat?
|
||||
public protocol RDEpubReaderFlowLayoutDataSoure: NSObjectProtocol {
|
||||
func heigtOfVerticalScrollPage(flowLayout: RDEpubReaderFlowLayout, pageIndex: Int) -> CGFloat?
|
||||
}
|
||||
|
||||
@objc public protocol RDReaderFlowLayoutDelegate: NSObjectProtocol {
|
||||
func pageNum(flowLayout: RDReaderFlowLayout, pageIndex: Int)
|
||||
@objc public protocol RDEpubReaderFlowLayoutDelegate: NSObjectProtocol {
|
||||
func pageNum(flowLayout: RDEpubReaderFlowLayout, pageIndex: Int)
|
||||
}
|
||||
```
|
||||
|
||||
@ -324,28 +324,28 @@ func currentContentOffset(count: Int) -> CGPoint
|
||||
|
||||
---
|
||||
|
||||
## 9. 内容 Cell:RDReaderContentCell
|
||||
## 9. 内容 Cell:RDEpubReaderContentCell
|
||||
|
||||
**文件:** `RDReaderContentCell.swift`
|
||||
**文件:** `RDEpubReaderContentCell.swift`
|
||||
|
||||
`UICollectionViewCell` 子类,用于 Scroll 模式下承载页面内容视图。
|
||||
|
||||
```swift
|
||||
class RDReaderContentCell: UICollectionViewCell {
|
||||
class RDEpubReaderContentCell: UICollectionViewCell {
|
||||
var containerView: UIView? // 设置时自动添加到 contentView,移除旧视图
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. PageCurl 子控制器:RDReaderPageChildViewController
|
||||
## 10. PageCurl 子控制器:RDEpubReaderPageChildViewController
|
||||
|
||||
**文件:** `RDReaderPageChildViewController.swift`
|
||||
**文件:** `RDEpubReaderPageChildViewController.swift`
|
||||
|
||||
`UIViewController` 子类,作为 `UIPageViewController` 的页面 VC。
|
||||
|
||||
```swift
|
||||
class RDReaderPageChildViewController: UIViewController {
|
||||
class RDEpubReaderPageChildViewController: UIViewController {
|
||||
var contentView: UIView? // 内容视图,设置时自动安装到容器
|
||||
var pageNum: Int // 对应页码
|
||||
|
||||
@ -357,7 +357,7 @@ class RDReaderPageChildViewController: UIViewController {
|
||||
|
||||
## 11. Extension 汇总
|
||||
|
||||
### 11.1 RDReaderView+PageCurl
|
||||
### 11.1 RDEpubReaderView+PageCurl
|
||||
|
||||
实现 `UIPageViewControllerDataSource` 和 `UIPageViewControllerDelegate`:
|
||||
|
||||
@ -367,18 +367,18 @@ class RDReaderPageChildViewController: UIViewController {
|
||||
- `pageViewController(_:willTransitionTo:)` — 即将翻页时预加载
|
||||
|
||||
**特殊页码:**
|
||||
- `RDReaderView.blankPageNum`(`Int.max`)— 双页模式下的空白页
|
||||
- `RDReaderView.blankEndPageNum`(`Int.max - 1`)— 末尾空白页
|
||||
- `RDEpubReaderView.blankPageNum`(`Int.max`)— 双页模式下的空白页
|
||||
- `RDEpubReaderView.blankEndPageNum`(`Int.max - 1`)— 末尾空白页
|
||||
|
||||
### 11.2 RDReaderView+CollectionView
|
||||
### 11.2 RDEpubReaderView+CollectionView
|
||||
|
||||
实现 `UICollectionViewDataSource`、`RDReaderFlowLayoutDelegate`、`RDReaderFlowLayoutDataSoure`:
|
||||
实现 `UICollectionViewDataSource`、`RDEpubReaderFlowLayoutDelegate`、`RDEpubReaderFlowLayoutDataSoure`:
|
||||
|
||||
- `collectionView(_:cellForItemAt:)` — 复用预加载视图或创建新 Cell
|
||||
- `collectionView(_:numberOfItemsInSection:)` — 返回总页数
|
||||
- `pageNum(flowLayout:pageIndex:)` — 滚动时更新当前页码
|
||||
|
||||
### 11.3 RDReaderView+ContentAccess
|
||||
### 11.3 RDEpubReaderView+ContentAccess
|
||||
|
||||
提供内容视图的注册、复用和查询:
|
||||
|
||||
@ -396,7 +396,7 @@ public func pageContentView(pageNum: Int) -> UIView?
|
||||
public func resolvedSinglePageSize(pageNum: Int? = nil) -> CGSize
|
||||
```
|
||||
|
||||
### 11.4 RDReaderView+ToolView
|
||||
### 11.4 RDEpubReaderView+ToolView
|
||||
|
||||
管理顶部/底部工具栏的安装、显示/隐藏动画:
|
||||
|
||||
@ -420,11 +420,11 @@ func updateToolViewHeightConstraintsIfNeeded()
|
||||
| 模式 | 应用 |
|
||||
|------|------|
|
||||
| **策略模式** | `DisplayType` 切换 PageCurl / Scroll 两种翻页策略 |
|
||||
| **适配器模式** | `RDReaderLegacyDataSourceAdapter` 将旧 `RDReaderDataSource` 适配为 `RDReaderPageProvider` |
|
||||
| **命令队列** | `RDReaderPagingController` 管理翻页请求队列 |
|
||||
| **缓存签名** | `RDReaderPreloadController.CacheSignature` 检测环境变化自动失效 |
|
||||
| **适配器模式** | `RDEpubReaderLegacyDataSourceAdapter` 将旧 `RDEpubReaderDataSource` 适配为 `RDEpubReaderPageProvider` |
|
||||
| **命令队列** | `RDEpubReaderPagingController` 管理翻页请求队列 |
|
||||
| **缓存签名** | `RDEpubReaderPreloadController.CacheSignature` 检测环境变化自动失效 |
|
||||
| **关注点分离** | Extension 将不同功能拆分到独立文件 |
|
||||
| **纯函数** | `RDReaderSpreadResolver` 和 `RDReaderTapRegionHandler` 无状态计算 |
|
||||
| **纯函数** | `RDEpubReaderSpreadResolver` 和 `RDEpubReaderTapRegionHandler` 无状态计算 |
|
||||
|
||||
---
|
||||
|
||||
@ -434,7 +434,7 @@ func updateToolViewHeightConstraintsIfNeeded()
|
||||
用户点击屏幕
|
||||
│
|
||||
▼
|
||||
RDReaderTapRegionHandler.resolveTapEvent()
|
||||
RDEpubReaderTapRegionHandler.resolveTapEvent()
|
||||
│
|
||||
├── .left → goPreviousPage() ──→ spreadResolver.nextPage(forward: false)
|
||||
├── .right → goNextPage() ──→ spreadResolver.nextPage(forward: true)
|
||||
|
||||
@ -116,7 +116,7 @@ xcodebuild test \
|
||||
建议同时记录以下观测项:
|
||||
|
||||
- `prepareOnDemandChapter` 主线程 wall clock
|
||||
- `RDReaderPreloadController` 预加载命中率
|
||||
- `RDEpubReaderPreloadController` 预加载命中率
|
||||
- `bookPageMap` partial extension / full replacement 次数
|
||||
- 页面静态底图缓存命中率
|
||||
- CFI 延迟构建完成次数与耗时
|
||||
|
||||
@ -16,7 +16,7 @@
|
||||
|
||||
**输出**:`RDEPUBTypesettingOutput`(渲染请求、诊断信息、兼容性报告)
|
||||
|
||||
**关键文件**:`Sources/RDReaderView/EPUBTextRendering/Typesetter/`
|
||||
**关键文件**:`Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -31,12 +31,12 @@ graph TB
|
||||
IndexTable[RDEPUBTextIndexTable]
|
||||
end
|
||||
|
||||
subgraph RDReaderView
|
||||
ReaderView[RDReaderView]
|
||||
FlowLayout[RDReaderFlowLayout]
|
||||
Preload[RDReaderPreloadController]
|
||||
Spread[RDReaderSpreadResolver]
|
||||
TapRegion[RDReaderTapRegionHandler]
|
||||
subgraph RDEpubReaderView
|
||||
ReaderView[RDEpubReaderView]
|
||||
FlowLayout[RDEpubReaderFlowLayout]
|
||||
Preload[RDEpubReaderPreloadController]
|
||||
Spread[RDEpubReaderSpreadResolver]
|
||||
TapRegion[RDEpubReaderTapRegionHandler]
|
||||
end
|
||||
|
||||
subgraph EPUBUI
|
||||
@ -297,11 +297,11 @@ classDiagram
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class RDReaderView {
|
||||
class RDEpubReaderView {
|
||||
+currentPage: Int
|
||||
+currentDisplayType: DisplayType
|
||||
+pageProvider: RDReaderPageProvider?
|
||||
+delegate: RDReaderDelegate?
|
||||
+pageProvider: RDEpubReaderPageProvider?
|
||||
+delegate: RDEpubReaderDelegate?
|
||||
+landscapeDualPageEnabled: Bool
|
||||
+pageDirection: PageDirection
|
||||
+coverPageIndex: Int?
|
||||
@ -318,14 +318,14 @@ classDiagram
|
||||
verticalScroll
|
||||
}
|
||||
|
||||
class RDReaderPageProvider {
|
||||
class RDEpubReaderPageProvider {
|
||||
<<protocol>>
|
||||
+numberOfPages(in:) Int
|
||||
+readerView(_:viewForPageAt:reusableView:) UIView
|
||||
+pageIdentifier(in:index:) String?
|
||||
}
|
||||
|
||||
class RDReaderFlowLayout {
|
||||
class RDEpubReaderFlowLayout {
|
||||
+displayType: DisplayType
|
||||
+isLandscapeDualPage: Bool
|
||||
+coverPageIndex: Int?
|
||||
@ -333,7 +333,7 @@ classDiagram
|
||||
+currentPage: Int
|
||||
}
|
||||
|
||||
class RDReaderPreloadController {
|
||||
class RDEpubReaderPreloadController {
|
||||
+radius: Int
|
||||
+pageViewForDisplay(pageNum:environment:contentViewProvider:) UIView?
|
||||
+takePreloadedView(for:) UIView?
|
||||
@ -341,13 +341,13 @@ classDiagram
|
||||
+invalidate(environment:)
|
||||
}
|
||||
|
||||
class RDReaderSpreadResolver {
|
||||
class RDEpubReaderSpreadResolver {
|
||||
+isFullScreenPage(...) Bool
|
||||
+dualPagePair(for:totalPages:coverPageIndex:) (Int, Int?)
|
||||
+nextPage(from:totalPages:pagesPerScreen:coverPageIndex:forward:) Int?
|
||||
}
|
||||
|
||||
class RDReaderTapRegionHandler {
|
||||
class RDEpubReaderTapRegionHandler {
|
||||
+resolveTapEvent(point:viewFrame:isToolViewVisible:) TapEvent
|
||||
}
|
||||
|
||||
@ -359,7 +359,7 @@ classDiagram
|
||||
right
|
||||
}
|
||||
|
||||
class RDReaderPagingController {
|
||||
class RDEpubReaderPagingController {
|
||||
+isTransitioning: Bool
|
||||
+didBuildUI: Bool
|
||||
+pendingTransitionRequest: PageTransitionRequest?
|
||||
@ -367,25 +367,25 @@ classDiagram
|
||||
+finishPageCurlTransition() PageTransitionRequest?
|
||||
}
|
||||
|
||||
class RDReaderContentCell {
|
||||
class RDEpubReaderContentCell {
|
||||
+containerView: UIView?
|
||||
}
|
||||
|
||||
class RDReaderPageChildViewController {
|
||||
class RDEpubReaderPageChildViewController {
|
||||
+contentView: UIView?
|
||||
+pageNum: Int
|
||||
}
|
||||
|
||||
RDReaderView --> DisplayType : uses
|
||||
RDReaderView --> RDReaderPageProvider : delegates
|
||||
RDReaderView --> RDReaderFlowLayout : owns
|
||||
RDReaderView --> RDReaderPreloadController : owns
|
||||
RDReaderView --> RDReaderSpreadResolver : owns
|
||||
RDReaderView --> RDReaderTapRegionHandler : owns
|
||||
RDReaderView --> RDReaderPagingController : owns
|
||||
RDReaderView --> RDReaderContentCell : creates
|
||||
RDReaderView --> RDReaderPageChildViewController : creates
|
||||
RDReaderTapRegionHandler --> TapEvent : produces
|
||||
RDEpubReaderView --> DisplayType : uses
|
||||
RDEpubReaderView --> RDEpubReaderPageProvider : delegates
|
||||
RDEpubReaderView --> RDEpubReaderFlowLayout : owns
|
||||
RDEpubReaderView --> RDEpubReaderPreloadController : owns
|
||||
RDEpubReaderView --> RDEpubReaderSpreadResolver : owns
|
||||
RDEpubReaderView --> RDEpubReaderTapRegionHandler : owns
|
||||
RDEpubReaderView --> RDEpubReaderPagingController : owns
|
||||
RDEpubReaderView --> RDEpubReaderContentCell : creates
|
||||
RDEpubReaderView --> RDEpubReaderPageChildViewController : creates
|
||||
RDEpubReaderTapRegionHandler --> TapEvent : produces
|
||||
```
|
||||
|
||||
---
|
||||
@ -403,7 +403,7 @@ classDiagram
|
||||
+highlights: [RDEPUBHighlight]
|
||||
+bookmarks: [RDEPUBBookmark]
|
||||
+tableOfContents: [EPUBTableOfContentsItem]
|
||||
+readerView: RDReaderView
|
||||
+readerView: RDEpubReaderView
|
||||
+readerContext: RDEPUBReaderContext
|
||||
+runtime: RDEPUBReaderRuntime?
|
||||
}
|
||||
|
||||
@ -37,6 +37,8 @@
|
||||
| [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) | 面向大书按需分页模式的完整优化方案:远距目录跳转、后台补全优先级、页图接管协议与分阶段实施路径 |
|
||||
@ -45,7 +47,7 @@
|
||||
|
||||
## 项目信息
|
||||
|
||||
- **模块总数:** 4 个(EPUBCore、EPUBTextRendering、RDReaderView、EPUBUI)
|
||||
- **模块总数:** 4 个(EPUBCore、EPUBTextRendering、RDEpubReaderView、EPUBUI)
|
||||
- **Swift 文件数:** 142 个(SDK Sources)
|
||||
- **测试用例数:** 23 个测试类,约 99 个测试方法(UI 测试)
|
||||
- **最低 iOS 版本:** 15.6
|
||||
|
||||
2
Podfile
2
Podfile
@ -5,7 +5,7 @@ workspace 'ReadViewSDK.xcworkspace'
|
||||
project 'ReadViewSDK.xcodeproj'
|
||||
|
||||
target 'ReadViewSDK' do
|
||||
pod 'RDReaderView', :path => '..'
|
||||
pod 'RDEpubReaderView', :path => '..'
|
||||
end
|
||||
|
||||
post_install do |installer|
|
||||
|
||||
19
RDEpubReaderView.podspec
Normal file
19
RDEpubReaderView.podspec
Normal file
@ -0,0 +1,19 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = "RDEpubReaderView"
|
||||
s.module_name = "RDEpubReaderView"
|
||||
s.version = "0.0.1"
|
||||
s.summary = "A reader view for EPUB and plain-text books"
|
||||
s.platform = :ios, "15.0"
|
||||
s.swift_versions = ["5.10"]
|
||||
s.homepage = "http://192.168.21.200:8418/4v5u09Z5a4Yuc/ReadViewSDK.git"
|
||||
s.author = { "shenlei" => "shenlei@touchread.com" }
|
||||
s.source = { :git => "http://192.168.21.200:8418/4v5u09Z5a4Yuc/ReadViewSDK.git", :tag => "#{s.version}" }
|
||||
s.license = "MIT"
|
||||
s.source_files = "Sources/RDEpubReaderView/**/*.{swift}"
|
||||
s.resource_bundles = {
|
||||
"RDEpubReaderViewAssets" => ["Sources/RDEpubReaderView/EPUBCore/Resources/**/*"]
|
||||
}
|
||||
s.dependency "ZIPFoundation", "~> 0.9"
|
||||
s.dependency "DTCoreText", "~> 1.6"
|
||||
s.requires_arc = true
|
||||
end
|
||||
@ -1,21 +0,0 @@
|
||||
|
||||
|
||||
Pod::Spec.new do |s|
|
||||
s.name = "RDReaderView"
|
||||
s.version = "0.0.1"
|
||||
s.summary = "A reader view for novel"
|
||||
s.platform = :ios, "15.6"
|
||||
s.swift_versions = ["5.10"]
|
||||
s.homepage = "https://github.com/namesubai/RDReaderView.git"
|
||||
s.author = { "subai" => "804663401@qq.com" }
|
||||
s.source = { :git => "https://github.com/namesubai/RDReaderView.git", :tag => "#{s.version}"}
|
||||
s.license = "MIT"
|
||||
s.source_files = 'Sources/RDReaderView/**/*.{swift}'
|
||||
s.resource_bundles = {
|
||||
'RDReaderViewAssets' => ['Sources/RDReaderView/EPUBCore/Resources/**/*']
|
||||
}
|
||||
s.dependency 'ZIPFoundation', '~> 0.9'
|
||||
s.dependency 'DTCoreText', '~> 1.6'
|
||||
s.requires_arc = true
|
||||
|
||||
end
|
||||
@ -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 'RDReaderView', :path => '..'
|
||||
pod 'RDEpubReaderView', :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.6'
|
||||
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0'
|
||||
end
|
||||
|
||||
# Pods project (Pods.xcodeproj)
|
||||
|
||||
@ -16,13 +16,13 @@ PODS:
|
||||
- DTFoundation/Core
|
||||
- DTFoundation/UIKit (1.7.19):
|
||||
- DTFoundation/Core
|
||||
- RDReaderView (0.0.1):
|
||||
- RDEpubReaderView (0.0.1):
|
||||
- DTCoreText (~> 1.6)
|
||||
- ZIPFoundation (~> 0.9)
|
||||
- ZIPFoundation (0.9.20)
|
||||
|
||||
DEPENDENCIES:
|
||||
- RDReaderView (from `..`)
|
||||
- RDEpubReaderView (from `..`)
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
@ -31,15 +31,15 @@ SPEC REPOS:
|
||||
- ZIPFoundation
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
RDReaderView:
|
||||
RDEpubReaderView:
|
||||
:path: ".."
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
|
||||
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
|
||||
RDReaderView: 2e0eeeff4bcfe8bbc09642344d326440af280ed5
|
||||
RDEpubReaderView: 5ff99b803f5a46e5ff4ebef3024dd217284c69ed
|
||||
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
|
||||
|
||||
PODFILE CHECKSUM: 775f5c8c488024e24d494aad6331e9fef8f9e2e5
|
||||
PODFILE CHECKSUM: adda84af3e8577b2a99d5b2cecf381511352f0bd
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
37
ReadViewDemo/Pods/Local Podspecs/RDEpubReaderView.podspec.json
generated
Normal file
37
ReadViewDemo/Pods/Local Podspecs/RDEpubReaderView.podspec.json
generated
Normal file
@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "RDEpubReaderView",
|
||||
"module_name": "RDEpubReaderView",
|
||||
"version": "0.0.1",
|
||||
"summary": "A reader view for EPUB and plain-text books",
|
||||
"platforms": {
|
||||
"ios": "15.0"
|
||||
},
|
||||
"swift_versions": [
|
||||
"5.10"
|
||||
],
|
||||
"homepage": "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"
|
||||
}
|
||||
@ -1,36 +0,0 @@
|
||||
{
|
||||
"name": "RDReaderView",
|
||||
"version": "0.0.1",
|
||||
"summary": "A reader view for novel",
|
||||
"platforms": {
|
||||
"ios": "15.6"
|
||||
},
|
||||
"swift_versions": [
|
||||
"5.10"
|
||||
],
|
||||
"homepage": "https://github.com/namesubai/RDReaderView.git",
|
||||
"authors": {
|
||||
"subai": "804663401@qq.com"
|
||||
},
|
||||
"source": {
|
||||
"git": "https://github.com/namesubai/RDReaderView.git",
|
||||
"tag": "0.0.1"
|
||||
},
|
||||
"license": "MIT",
|
||||
"source_files": "Sources/RDReaderView/**/*.{swift}",
|
||||
"resource_bundles": {
|
||||
"RDReaderViewAssets": [
|
||||
"Sources/RDReaderView/EPUBCore/Resources/**/*"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"ZIPFoundation": [
|
||||
"~> 0.9"
|
||||
],
|
||||
"DTCoreText": [
|
||||
"~> 1.6"
|
||||
]
|
||||
},
|
||||
"requires_arc": true,
|
||||
"swift_version": "5.10"
|
||||
}
|
||||
10
ReadViewDemo/Pods/Manifest.lock
generated
10
ReadViewDemo/Pods/Manifest.lock
generated
@ -16,13 +16,13 @@ PODS:
|
||||
- DTFoundation/Core
|
||||
- DTFoundation/UIKit (1.7.19):
|
||||
- DTFoundation/Core
|
||||
- RDReaderView (0.0.1):
|
||||
- RDEpubReaderView (0.0.1):
|
||||
- DTCoreText (~> 1.6)
|
||||
- ZIPFoundation (~> 0.9)
|
||||
- ZIPFoundation (0.9.20)
|
||||
|
||||
DEPENDENCIES:
|
||||
- RDReaderView (from `..`)
|
||||
- RDEpubReaderView (from `..`)
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
@ -31,15 +31,15 @@ SPEC REPOS:
|
||||
- ZIPFoundation
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
RDReaderView:
|
||||
RDEpubReaderView:
|
||||
:path: ".."
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
|
||||
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
|
||||
RDReaderView: 2e0eeeff4bcfe8bbc09642344d326440af280ed5
|
||||
RDEpubReaderView: 5ff99b803f5a46e5ff4ebef3024dd217284c69ed
|
||||
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
|
||||
|
||||
PODFILE CHECKSUM: 775f5c8c488024e24d494aad6331e9fef8f9e2e5
|
||||
PODFILE CHECKSUM: adda84af3e8577b2a99d5b2cecf381511352f0bd
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
3308
ReadViewDemo/Pods/Pods.xcodeproj/project.pbxproj
generated
3308
ReadViewDemo/Pods/Pods.xcodeproj/project.pbxproj
generated
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,5 @@
|
||||
${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks.sh
|
||||
${BUILT_PRODUCTS_DIR}/DTCoreText/DTCoreText.framework
|
||||
${BUILT_PRODUCTS_DIR}/DTFoundation/DTFoundation.framework
|
||||
${BUILT_PRODUCTS_DIR}/RDReaderView/RDReaderView.framework
|
||||
${BUILT_PRODUCTS_DIR}/RDEpubReaderView/RDEpubReaderView.framework
|
||||
${BUILT_PRODUCTS_DIR}/ZIPFoundation/ZIPFoundation.framework
|
||||
@ -1,4 +1,4 @@
|
||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/DTCoreText.framework
|
||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/DTFoundation.framework
|
||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RDReaderView.framework
|
||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RDEpubReaderView.framework
|
||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ZIPFoundation.framework
|
||||
@ -1,5 +1,5 @@
|
||||
${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks.sh
|
||||
${BUILT_PRODUCTS_DIR}/DTCoreText/DTCoreText.framework
|
||||
${BUILT_PRODUCTS_DIR}/DTFoundation/DTFoundation.framework
|
||||
${BUILT_PRODUCTS_DIR}/RDReaderView/RDReaderView.framework
|
||||
${BUILT_PRODUCTS_DIR}/RDEpubReaderView/RDEpubReaderView.framework
|
||||
${BUILT_PRODUCTS_DIR}/ZIPFoundation/ZIPFoundation.framework
|
||||
@ -1,4 +1,4 @@
|
||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/DTCoreText.framework
|
||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/DTFoundation.framework
|
||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RDReaderView.framework
|
||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RDEpubReaderView.framework
|
||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ZIPFoundation.framework
|
||||
@ -178,13 +178,13 @@ code_sign_if_enabled() {
|
||||
if [[ "$CONFIGURATION" == "Debug" ]]; then
|
||||
install_framework "${BUILT_PRODUCTS_DIR}/DTCoreText/DTCoreText.framework"
|
||||
install_framework "${BUILT_PRODUCTS_DIR}/DTFoundation/DTFoundation.framework"
|
||||
install_framework "${BUILT_PRODUCTS_DIR}/RDReaderView/RDReaderView.framework"
|
||||
install_framework "${BUILT_PRODUCTS_DIR}/RDEpubReaderView/RDEpubReaderView.framework"
|
||||
install_framework "${BUILT_PRODUCTS_DIR}/ZIPFoundation/ZIPFoundation.framework"
|
||||
fi
|
||||
if [[ "$CONFIGURATION" == "Release" ]]; then
|
||||
install_framework "${BUILT_PRODUCTS_DIR}/DTCoreText/DTCoreText.framework"
|
||||
install_framework "${BUILT_PRODUCTS_DIR}/DTFoundation/DTFoundation.framework"
|
||||
install_framework "${BUILT_PRODUCTS_DIR}/RDReaderView/RDReaderView.framework"
|
||||
install_framework "${BUILT_PRODUCTS_DIR}/RDEpubReaderView/RDEpubReaderView.framework"
|
||||
install_framework "${BUILT_PRODUCTS_DIR}/ZIPFoundation/ZIPFoundation.framework"
|
||||
fi
|
||||
if [ "${COCOAPODS_PARALLEL_CODE_SIGN}" == "true" ]; then
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
|
||||
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "${PODS_CONFIGURATION_BUILD_DIR}/RDReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
|
||||
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
|
||||
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
|
||||
HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText/DTCoreText.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation/DTFoundation.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDReaderView/RDReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation/ZIPFoundation.framework/Headers" "$(SDKROOT)/usr/include/libxml2"
|
||||
HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText/DTCoreText.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation/DTFoundation.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView/RDEpubReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation/ZIPFoundation.framework/Headers" "$(SDKROOT)/usr/include/libxml2"
|
||||
LD_RUNPATH_SEARCH_PATHS = $(inherited) /usr/lib/swift '@executable_path/Frameworks' '@loader_path/Frameworks'
|
||||
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift
|
||||
OTHER_LDFLAGS = $(inherited) -l"xml2" -framework "CoreGraphics" -framework "CoreText" -framework "DTCoreText" -framework "DTFoundation" -framework "ImageIO" -framework "MediaPlayer" -framework "QuartzCore" -framework "RDReaderView" -framework "ZIPFoundation"
|
||||
OTHER_MODULE_VERIFIER_FLAGS = $(inherited) "-F${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "-F${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
|
||||
OTHER_LDFLAGS = $(inherited) -l"xml2" -framework "CoreGraphics" -framework "CoreText" -framework "DTCoreText" -framework "DTFoundation" -framework "ImageIO" -framework "MediaPlayer" -framework "QuartzCore" -framework "RDEpubReaderView" -framework "ZIPFoundation"
|
||||
OTHER_MODULE_VERIFIER_FLAGS = $(inherited) "-F${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "-F${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
|
||||
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
|
||||
PODS_BUILD_DIR = ${BUILD_DIR}
|
||||
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
|
||||
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "${PODS_CONFIGURATION_BUILD_DIR}/RDReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
|
||||
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
|
||||
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
|
||||
HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText/DTCoreText.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation/DTFoundation.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDReaderView/RDReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation/ZIPFoundation.framework/Headers" "$(SDKROOT)/usr/include/libxml2"
|
||||
HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText/DTCoreText.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation/DTFoundation.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView/RDEpubReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation/ZIPFoundation.framework/Headers" "$(SDKROOT)/usr/include/libxml2"
|
||||
LD_RUNPATH_SEARCH_PATHS = $(inherited) /usr/lib/swift '@executable_path/Frameworks' '@loader_path/Frameworks'
|
||||
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift
|
||||
OTHER_LDFLAGS = $(inherited) -l"xml2" -framework "CoreGraphics" -framework "CoreText" -framework "DTCoreText" -framework "DTFoundation" -framework "ImageIO" -framework "MediaPlayer" -framework "QuartzCore" -framework "RDReaderView" -framework "ZIPFoundation"
|
||||
OTHER_MODULE_VERIFIER_FLAGS = $(inherited) "-F${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "-F${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
|
||||
OTHER_LDFLAGS = $(inherited) -l"xml2" -framework "CoreGraphics" -framework "CoreText" -framework "DTCoreText" -framework "DTFoundation" -framework "ImageIO" -framework "MediaPlayer" -framework "QuartzCore" -framework "RDEpubReaderView" -framework "ZIPFoundation"
|
||||
OTHER_MODULE_VERIFIER_FLAGS = $(inherited) "-F${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "-F${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
|
||||
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
|
||||
PODS_BUILD_DIR = ${BUILD_DIR}
|
||||
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
|
||||
|
||||
5
ReadViewDemo/Pods/Target Support Files/RDEpubReaderView/RDEpubReaderView-dummy.m
generated
Normal file
5
ReadViewDemo/Pods/Target Support Files/RDEpubReaderView/RDEpubReaderView-dummy.m
generated
Normal file
@ -0,0 +1,5 @@
|
||||
#import <Foundation/Foundation.h>
|
||||
@interface PodsDummy_RDEpubReaderView : NSObject
|
||||
@end
|
||||
@implementation PodsDummy_RDEpubReaderView
|
||||
@end
|
||||
@ -11,6 +11,6 @@
|
||||
#endif
|
||||
|
||||
|
||||
FOUNDATION_EXPORT double RDReaderViewVersionNumber;
|
||||
FOUNDATION_EXPORT const unsigned char RDReaderViewVersionString[];
|
||||
FOUNDATION_EXPORT double RDEpubReaderViewVersionNumber;
|
||||
FOUNDATION_EXPORT const unsigned char RDEpubReaderViewVersionString[];
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
|
||||
CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RDReaderView
|
||||
CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView
|
||||
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
|
||||
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
|
||||
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift
|
||||
6
ReadViewDemo/Pods/Target Support Files/RDEpubReaderView/RDEpubReaderView.modulemap
generated
Normal file
6
ReadViewDemo/Pods/Target Support Files/RDEpubReaderView/RDEpubReaderView.modulemap
generated
Normal file
@ -0,0 +1,6 @@
|
||||
framework module RDEpubReaderView {
|
||||
umbrella header "RDEpubReaderView-umbrella.h"
|
||||
|
||||
export *
|
||||
module * { export * }
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
|
||||
CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RDReaderView
|
||||
CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView
|
||||
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
|
||||
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
|
||||
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift
|
||||
@ -1,5 +0,0 @@
|
||||
#import <Foundation/Foundation.h>
|
||||
@interface PodsDummy_RDReaderView : NSObject
|
||||
@end
|
||||
@implementation PodsDummy_RDReaderView
|
||||
@end
|
||||
@ -1,6 +0,0 @@
|
||||
framework module RDReaderView {
|
||||
umbrella header "RDReaderView-umbrella.h"
|
||||
|
||||
export *
|
||||
module * { export * }
|
||||
}
|
||||
@ -18,6 +18,8 @@
|
||||
1A2B3C4D00000011AABBCC01 /* DemoReaderState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000012AABBCC01 /* DemoReaderState.swift */; };
|
||||
1A2B3C4D00000014AABBCC01 /* FanrenParseTimeTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000013AABBCC01 /* FanrenParseTimeTest.swift */; };
|
||||
1A2B3C4D00000015AABBCC01 /* AsyncChapterLoadingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000016AABBCC01 /* AsyncChapterLoadingTests.swift */; };
|
||||
1A2B3C4D00000021AABBCC01 /* MemoryFootprintTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000020AABBCC01 /* MemoryFootprintTests.swift */; };
|
||||
1A2B3C4D00000030AABBCC01 /* FixedLayoutRotationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000031AABBCC01 /* FixedLayoutRotationTests.swift */; };
|
||||
23BB1155EA379786DAA10A89 /* DisplayTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */; };
|
||||
369C9658D870DCAFC17EB7F7 /* SearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFE01DBDCB8D790832A4DE3F /* SearchTests.swift */; };
|
||||
3BC5C96D7A0ACF35F2192CC7 /* XCUIApplication+Launch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74B4C44287820D68ED6570F8 /* XCUIApplication+Launch.swift */; };
|
||||
@ -53,6 +55,8 @@
|
||||
1A2B3C4D00000012AABBCC01 /* DemoReaderState.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DemoReaderState.swift; sourceTree = "<group>"; };
|
||||
1A2B3C4D00000013AABBCC01 /* FanrenParseTimeTest.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FanrenParseTimeTest.swift; sourceTree = "<group>"; };
|
||||
1A2B3C4D00000016AABBCC01 /* AsyncChapterLoadingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AsyncChapterLoadingTests.swift; sourceTree = "<group>"; };
|
||||
1A2B3C4D00000020AABBCC01 /* MemoryFootprintTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MemoryFootprintTests.swift; sourceTree = "<group>"; };
|
||||
1A2B3C4D00000031AABBCC01 /* FixedLayoutRotationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FixedLayoutRotationTests.swift; sourceTree = "<group>"; };
|
||||
201C2B482287866487EFAE66 /* SettingsPanelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SettingsPanelTests.swift; sourceTree = "<group>"; };
|
||||
20BD15E5D8F04E7E9A239E14 /* ReaderAnnotationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderAnnotationTests.swift; sourceTree = "<group>"; };
|
||||
3A43AED288BFCA3ADBA97DD7 /* Pods-ReadViewDemo.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ReadViewDemo.release.xcconfig"; path = "Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo.release.xcconfig"; sourceTree = "<group>"; };
|
||||
@ -192,7 +196,9 @@
|
||||
1A2B3C4D00000010AABBCC01 /* MetadataParseBenchmarkTests.swift */,
|
||||
1A2B3C4D00000013AABBCC01 /* FanrenParseTimeTest.swift */,
|
||||
CFE01DBDCB8D790832A4DE3F /* SearchTests.swift */,
|
||||
1A2B3C4D00000020AABBCC01 /* MemoryFootprintTests.swift */,
|
||||
1A2B3C4D00000016AABBCC01 /* AsyncChapterLoadingTests.swift */,
|
||||
1A2B3C4D00000031AABBCC01 /* FixedLayoutRotationTests.swift */,
|
||||
);
|
||||
path = ReaderUITests;
|
||||
sourceTree = "<group>";
|
||||
@ -357,7 +363,9 @@
|
||||
1A2B3C4D0000000FAABBCC01 /* MetadataParseBenchmarkTests.swift in Sources */,
|
||||
1A2B3C4D00000014AABBCC01 /* FanrenParseTimeTest.swift in Sources */,
|
||||
369C9658D870DCAFC17EB7F7 /* SearchTests.swift in Sources */,
|
||||
1A2B3C4D00000021AABBCC01 /* MemoryFootprintTests.swift in Sources */,
|
||||
1A2B3C4D00000015AABBCC01 /* AsyncChapterLoadingTests.swift in Sources */,
|
||||
1A2B3C4D00000030AABBCC01 /* FixedLayoutRotationTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@ -390,7 +398,7 @@
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = MG4Z7FU83W;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = cn.shen.ReadViewDemoUITests;
|
||||
PRODUCT_MODULE_NAME = ReadViewDemoUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@ -415,7 +423,7 @@
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = MG4Z7FU83W;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = cn.shen.ReadViewDemoUITests;
|
||||
PRODUCT_MODULE_NAME = ReadViewDemoUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@ -448,7 +456,7 @@
|
||||
INFOPLIST_KEY_UIMainStoryboardFile = Main;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@ -487,7 +495,7 @@
|
||||
INFOPLIST_KEY_UIMainStoryboardFile = Main;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@ -559,7 +567,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
@ -616,7 +624,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import UIKit
|
||||
import RDReaderView
|
||||
import RDEpubReaderView
|
||||
|
||||
final class ViewController: UIViewController {
|
||||
private struct DemoBook: Equatable {
|
||||
@ -10,10 +10,10 @@ final class ViewController: UIViewController {
|
||||
|
||||
private struct LaunchAutomationPlan {
|
||||
let bookTitleQuery: String
|
||||
let displayType: RDReaderView.DisplayType?
|
||||
let displayType: RDEpubReaderView.DisplayType?
|
||||
let pageNumber: Int?
|
||||
let resetsReaderState: Bool
|
||||
let displaySequence: [RDReaderView.DisplayType]
|
||||
let displaySequence: [RDEpubReaderView.DisplayType]
|
||||
let stepDelay: TimeInterval
|
||||
let windowSize: Int?
|
||||
let concurrency: Int?
|
||||
@ -23,7 +23,7 @@ final class ViewController: UIViewController {
|
||||
let enablesVerboseWebLogs: Bool
|
||||
let searchKeyword: String?
|
||||
|
||||
nonisolated private static func parseDisplayType(_ rawValue: String) -> RDReaderView.DisplayType? {
|
||||
nonisolated private static func parseDisplayType(_ rawValue: String) -> RDEpubReaderView.DisplayType? {
|
||||
switch rawValue.lowercased() {
|
||||
case "pagecurl", "curl":
|
||||
return .pageCurl
|
||||
@ -213,8 +213,8 @@ final class ViewController: UIViewController {
|
||||
_ book: DemoBook,
|
||||
configuration: RDEPUBReaderConfiguration = .default,
|
||||
automationPlan: LaunchAutomationPlan? = nil
|
||||
) -> RDURLReaderController {
|
||||
let controller = RDURLReaderController(bookURL: book.fileURL, epubConfiguration: configuration)
|
||||
) -> RDEpubURLReaderController {
|
||||
let controller = RDEpubURLReaderController(bookURL: book.fileURL, epubConfiguration: configuration)
|
||||
controller.title = book.title
|
||||
|
||||
if let navigationController {
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
这是 ReadViewSDK 的 Phase 5 纯文本验证样本。
|
||||
|
||||
它用于覆盖 RDURLReaderController 与 RDPlainTextBookBuilder 的 TXT 路径,
|
||||
它用于覆盖 RDEpubURLReaderController 与 RDEpubPlainTextBookBuilder 的 TXT 路径,
|
||||
确认纯文本分页、打开主流程,以及后续 reader 壳层回归不会失效。
|
||||
|
||||
为了让 UI 自动化可以稳定验证翻页、长按选区、书签和目录行为,
|
||||
|
||||
@ -54,7 +54,6 @@ enum IDs {
|
||||
static let settingsFontValue = "epub.reader.settings.font.value"
|
||||
static let settingsFontChoice = "epub.reader.settings.font.choice"
|
||||
static let settingsLineHeight = "epub.reader.settings.lineHeight"
|
||||
static let settingsColumns = "epub.reader.settings.columns"
|
||||
static let settingsDisplayType = "epub.reader.settings.displayType"
|
||||
static let settingsDone = "epub.reader.settings.done"
|
||||
static func settingsTheme(_ index: Int) -> String { "epub.reader.settings.theme.\(index)" }
|
||||
|
||||
@ -28,6 +28,7 @@ struct DemoReaderState {
|
||||
|
||||
var isOpened: Bool { fields["reader"] == "opened" }
|
||||
var page: Int? { fields["page"].flatMap(Int.init) }
|
||||
var pagesPerScreen: Int? { fields["pagesPerScreen"].flatMap(Int.init) }
|
||||
var display: String? { fields["display"] }
|
||||
var toolbar: String? { fields["toolbar"] }
|
||||
var highlights: Int? { fields["highlights"].flatMap(Int.init) }
|
||||
@ -58,6 +59,7 @@ struct DemoReaderState {
|
||||
var lastExternalURL: String? { decodedField("lastExternalURL") }
|
||||
var lastError: String? { decodedField("lastError") }
|
||||
var searchMatchText: String? { decodedField("searchMatchText") }
|
||||
var footprintMB: Double? { fields["footprintMB"].flatMap(Double.init) }
|
||||
}
|
||||
|
||||
extension XCUIApplication {
|
||||
|
||||
@ -394,6 +394,10 @@ final class AsyncChapterLoadingTests: XCTestCase {
|
||||
for _ in 0..<10 {
|
||||
paging.swipeLeft()
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.4))
|
||||
XCTAssertTrue(
|
||||
app.activityIndicators[IDs.readerLoadingSpinner].waitForNonExistence(timeout: 10),
|
||||
"横屏翻页后的页面不应一直停留在 loading"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,63 @@
|
||||
import XCTest
|
||||
|
||||
/// 固定布局(pre-paginated)书籍的旋转回归:
|
||||
/// 每次旋转会使预加载缓存签名失效并批量丢弃 WKWebView 页面,
|
||||
/// 历史上曾因 script message handler 保留环导致 WebView 泄漏累积,
|
||||
/// 多次旋转后在 CA commit 阶段 abort(CA::Render::Encoder::grow)。
|
||||
final class FixedLayoutRotationTests: XCTestCase {
|
||||
private let app = XCUIApplication()
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
XCUIDevice.shared.orientation = .portrait
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
XCUIDevice.shared.orientation = .portrait
|
||||
}
|
||||
|
||||
func testFixedLayoutBookSurvivesRepeatedRotation() {
|
||||
app.launchAndOpenSampleBook(bookTitleQuery: "熊爷爷")
|
||||
app.waitForReader()
|
||||
|
||||
for _ in 0..<3 {
|
||||
XCUIDevice.shared.orientation = .landscapeLeft
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(2))
|
||||
let landscapeState = app.waitForDemoReaderState(
|
||||
timeout: 5,
|
||||
description: "固定版式横屏保持单页"
|
||||
) { $0.pagesPerScreen == 1 }
|
||||
XCTAssertEqual(landscapeState.pagesPerScreen, 1, "交互型固定版式书横屏不应启用双页")
|
||||
XCUIDevice.shared.orientation = .portrait
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(2))
|
||||
}
|
||||
|
||||
XCTAssertEqual(app.state, .runningForeground, "反复旋转后 app 已退出(疑似崩溃)")
|
||||
app.waitForReader()
|
||||
}
|
||||
|
||||
func testFixedLayoutCachedPageCanReturnAfterPageTurn() {
|
||||
app.launchAndOpenSampleBook(bookTitleQuery: "熊爷爷")
|
||||
app.waitForReader()
|
||||
|
||||
let content = app.otherElements[IDs.readerContent]
|
||||
XCTAssertTrue(content.waitForExistence(timeout: 5), "固定版式阅读区域未出现")
|
||||
let initialState = app.waitForDemoReaderState(timeout: 5, description: "固定版式初始页") {
|
||||
$0.page != nil
|
||||
}
|
||||
let initialPage = initialState.page ?? 0
|
||||
|
||||
content.swipeLeft()
|
||||
let nextState = app.waitForDemoReaderState(timeout: 8, description: "固定版式向后翻页") { state in
|
||||
guard let page = state.page else { return false }
|
||||
return page != initialPage
|
||||
}
|
||||
|
||||
content.swipeRight()
|
||||
let returnedState = app.waitForDemoReaderState(timeout: 8, description: "固定版式返回缓存页") {
|
||||
$0.page == initialPage
|
||||
}
|
||||
XCTAssertNotEqual(nextState.page, returnedState.page, "固定版式返回缓存页后页码未恢复")
|
||||
XCTAssertTrue(content.exists, "返回缓存页后阅读区域不应消失")
|
||||
}
|
||||
}
|
||||
@ -19,9 +19,9 @@ final class HighlightsManagementTests: XCTestCase {
|
||||
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||
start.press(forDuration: 0.8, thenDragTo: end)
|
||||
|
||||
let highlightMenuItem = app.menuItems["高亮"]
|
||||
let highlightMenuItem = app.menuItems["划线"]
|
||||
if highlightMenuItem.waitForExistence(timeout: 3) { highlightMenuItem.tap() }
|
||||
else if app.buttons["高亮"].waitForExistence(timeout: 2) { app.buttons["高亮"].tap() }
|
||||
else if app.buttons["划线"].waitForExistence(timeout: 2) { app.buttons["划线"].tap() }
|
||||
|
||||
app.waitForDemoReaderState(timeout: 5, description: "highlights exists") { $0.highlights != nil }
|
||||
|
||||
@ -42,7 +42,7 @@ final class HighlightsManagementTests: XCTestCase {
|
||||
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||
start.press(forDuration: 0.8, thenDragTo: end)
|
||||
|
||||
let highlightMenuItem = app.menuItems["高亮"]
|
||||
let highlightMenuItem = app.menuItems["划线"]
|
||||
if highlightMenuItem.waitForExistence(timeout: 3) { highlightMenuItem.tap() }
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
@ -63,7 +63,7 @@ final class HighlightsManagementTests: XCTestCase {
|
||||
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||
start.press(forDuration: 0.8, thenDragTo: end)
|
||||
|
||||
let highlightMenuItem = app.menuItems["高亮"]
|
||||
let highlightMenuItem = app.menuItems["划线"]
|
||||
if highlightMenuItem.waitForExistence(timeout: 3) { highlightMenuItem.tap() }
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
@ -102,7 +102,7 @@ final class HighlightsManagementTests: XCTestCase {
|
||||
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||
start.press(forDuration: 0.8, thenDragTo: end)
|
||||
|
||||
let highlightMenuItem = app.menuItems["高亮"]
|
||||
let highlightMenuItem = app.menuItems["划线"]
|
||||
if highlightMenuItem.waitForExistence(timeout: 3) { highlightMenuItem.tap() }
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
@ -112,15 +112,14 @@ final class HighlightsManagementTests: XCTestCase {
|
||||
XCTAssertTrue(highlightsTable.waitForExistence(timeout: 5))
|
||||
XCTAssertTrue(waitForCellCount(in: highlightsTable, minimum: 1, timeout: 5))
|
||||
|
||||
highlightsTable.cells.element(boundBy: 0).tap()
|
||||
let editButton = app.navigationBars.buttons["编辑"].firstMatch
|
||||
XCTAssertTrue(editButton.waitForExistence(timeout: 3), "标注列表应提供编辑入口")
|
||||
editButton.tap()
|
||||
|
||||
let deleteButton = app.buttons["删除标注"].firstMatch
|
||||
let deleteHighlightButton = app.buttons["删除高亮"].firstMatch
|
||||
if deleteButton.waitForExistence(timeout: 3) {
|
||||
deleteButton.tap()
|
||||
} else if deleteHighlightButton.waitForExistence(timeout: 1) {
|
||||
deleteHighlightButton.tap()
|
||||
}
|
||||
let deleteControl = highlightsTable.buttons["删除"].firstMatch
|
||||
XCTAssertTrue(deleteControl.waitForExistence(timeout: 3), "编辑状态应显示删除控件")
|
||||
deleteControl.tap()
|
||||
app.buttons["删除"].lastMatch.tap()
|
||||
|
||||
XCTAssertTrue(app.staticTexts[IDs.readerHighlightsEmptyLabel].waitForExistence(timeout: 5), "删除后应显示空状态")
|
||||
}
|
||||
|
||||
@ -136,8 +136,8 @@ final class LocationPersistenceTests: XCTestCase {
|
||||
let start = content.coordinate(withNormalizedOffset: CGVector(dx: 0.2, dy: 0.5))
|
||||
let end = content.coordinate(withNormalizedOffset: CGVector(dx: 0.55, dy: 0.5))
|
||||
start.press(forDuration: 0.8, thenDragTo: end)
|
||||
XCTAssertTrue(app.menuItems["高亮"].waitForExistence(timeout: 3))
|
||||
app.menuItems["高亮"].tap()
|
||||
XCTAssertTrue(app.menuItems["划线"].waitForExistence(timeout: 3))
|
||||
app.menuItems["划线"].tap()
|
||||
app.waitForDemoReaderState(timeout: 8, description: "highlights=1") { $0.highlights == 1 }
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
@ -167,7 +167,7 @@ final class LocationPersistenceTests: XCTestCase {
|
||||
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||
start.press(forDuration: 0.8, thenDragTo: end)
|
||||
|
||||
let highlightItem = app.menuItems["高亮"]
|
||||
let highlightItem = app.menuItems["划线"]
|
||||
XCTAssertTrue(highlightItem.waitForExistence(timeout: 3), "应出现高亮菜单")
|
||||
highlightItem.tap()
|
||||
app.waitForDemoReaderState(timeout: 8, description: "highlights=1") { $0.highlights == 1 }
|
||||
|
||||
@ -0,0 +1,147 @@
|
||||
import XCTest
|
||||
|
||||
/// Quantitative memory-footprint harness for the long-chapter optimization
|
||||
/// (Doc/LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md, P0-2 / P1-4 / P2). Reads
|
||||
/// phys_footprint back through the demo state `footprintMB` field, so these
|
||||
/// tests double as a decision tool: the recorded numbers are attached to the
|
||||
/// result, and the assertions are deliberately loose upper bounds that only
|
||||
/// catch gross regressions (e.g. each visible page pinning a full-chapter
|
||||
/// copy), not few-MB noise.
|
||||
final class MemoryFootprintTests: XCTestCase {
|
||||
private let app = XCUIApplication()
|
||||
private let largeBookQuery = "凡人修仙传"
|
||||
|
||||
/// Turning 20 pages must not grow footprint beyond this over the post-open
|
||||
/// baseline. Sized to flag "per-page full-chapter copy" class leaks while
|
||||
/// tolerating cache warmup and simulator noise.
|
||||
private let pageTurnGrowthBudgetMB = 180.0
|
||||
|
||||
/// A settings change rebuilds display objects; peak may spike transiently
|
||||
/// but must settle back within this of the pre-change reading.
|
||||
private let settingsChangeGrowthBudgetMB = 220.0
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
func testFootprintStableAcrossTwentyPageTurns() throws {
|
||||
app.launchAndOpenSampleBook(
|
||||
bookTitleQuery: largeBookQuery,
|
||||
displayType: "scroll",
|
||||
resetsReaderState: true
|
||||
)
|
||||
app.waitForReader(timeout: 20)
|
||||
app.waitForDemoReaderState(timeout: 8, description: "display=horizontalScroll") {
|
||||
$0.display == "horizontalScroll"
|
||||
}
|
||||
|
||||
let baseline = try requireFootprint(description: "打开长书后基线")
|
||||
let paging = app.collectionViews[IDs.readerPaging]
|
||||
XCTAssertTrue(paging.waitForExistence(timeout: 5), "分页滚动视图未出现")
|
||||
|
||||
var readings: [Double] = [baseline]
|
||||
var lastPage = app.currentDemoReaderState()?.page ?? 0
|
||||
var turnsMade = 0
|
||||
|
||||
for _ in 0..<20 {
|
||||
paging.swipeLeft()
|
||||
let state = app.waitForDemoReaderState(timeout: 8, description: "翻页后页码变化") { state in
|
||||
guard let page = state.page else { return false }
|
||||
return page != lastPage
|
||||
}
|
||||
guard let page = state.page, page != lastPage else { break }
|
||||
lastPage = page
|
||||
turnsMade += 1
|
||||
if let footprint = state.footprintMB {
|
||||
readings.append(footprint)
|
||||
}
|
||||
}
|
||||
|
||||
let peak = readings.max() ?? baseline
|
||||
let final = readings.last ?? baseline
|
||||
let growth = final - baseline
|
||||
attachReport(
|
||||
name: "page-turns",
|
||||
lines: [
|
||||
"turnsMade=\(turnsMade)",
|
||||
"baselineMB=\(fmt(baseline))",
|
||||
"peakMB=\(fmt(peak))",
|
||||
"finalMB=\(fmt(final))",
|
||||
"growthMB=\(fmt(growth))",
|
||||
"budgetMB=\(fmt(pageTurnGrowthBudgetMB))",
|
||||
"series=\(readings.map(fmt).joined(separator: ","))"
|
||||
]
|
||||
)
|
||||
|
||||
XCTAssertGreaterThan(turnsMade, 0, "未能翻动任何页,无法测量翻页内存")
|
||||
XCTAssertLessThan(
|
||||
growth, pageTurnGrowthBudgetMB,
|
||||
"连续翻页后常驻内存增长 \(fmt(growth))MB 超出预算 \(fmt(pageTurnGrowthBudgetMB))MB(疑似每页驻留整章副本)"
|
||||
)
|
||||
}
|
||||
|
||||
func testFootprintSettlesAfterFontSizeChange() throws {
|
||||
app.launchAndOpenSampleBook(
|
||||
bookTitleQuery: largeBookQuery,
|
||||
resetsReaderState: true
|
||||
)
|
||||
app.waitForReader(timeout: 20)
|
||||
app.showReaderChromeIfNeeded()
|
||||
|
||||
let before = try requireFootprint(description: "改字号前")
|
||||
|
||||
XCTAssertTrue(app.buttons[IDs.readerSettings].waitForExistence(timeout: 5), "设置按钮不存在")
|
||||
app.buttons[IDs.readerSettings].tap()
|
||||
XCTAssertTrue(app.scrollViews[IDs.settingsScroll].waitForExistence(timeout: 5), "设置面板未出现")
|
||||
XCTAssertTrue(app.buttons[IDs.settingsFontIncrease].waitForExistence(timeout: 3), "字号增大按钮不存在")
|
||||
app.buttons[IDs.settingsFontIncrease].tap()
|
||||
app.buttons[IDs.settingsFontIncrease].tap()
|
||||
XCTAssertTrue(app.buttons[IDs.settingsDone].waitForExistence(timeout: 3), "完成按钮不存在")
|
||||
app.buttons[IDs.settingsDone].tap()
|
||||
|
||||
// Allow repagination + display-cache rebuild to settle.
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(2.5))
|
||||
let after = try requireFootprint(description: "改字号并重排后")
|
||||
|
||||
let growth = after - before
|
||||
attachReport(
|
||||
name: "font-size-change",
|
||||
lines: [
|
||||
"beforeMB=\(fmt(before))",
|
||||
"afterMB=\(fmt(after))",
|
||||
"growthMB=\(fmt(growth))",
|
||||
"budgetMB=\(fmt(settingsChangeGrowthBudgetMB))"
|
||||
]
|
||||
)
|
||||
|
||||
XCTAssertLessThan(
|
||||
growth, settingsChangeGrowthBudgetMB,
|
||||
"改字号重排后常驻内存增长 \(fmt(growth))MB 超出预算 \(fmt(settingsChangeGrowthBudgetMB))MB(疑似旧显示对象未释放)"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func requireFootprint(description: String) throws -> Double {
|
||||
let state = app.waitForDemoReaderState(timeout: 8, description: description) {
|
||||
($0.footprintMB ?? 0) > 0
|
||||
}
|
||||
guard let footprint = state.footprintMB, footprint > 0 else {
|
||||
throw XCTSkip("demo 状态未暴露 footprintMB,跳过内存测量:\(state.rawValue)")
|
||||
}
|
||||
return footprint
|
||||
}
|
||||
|
||||
private func fmt(_ value: Double) -> String {
|
||||
String(format: "%.1f", value)
|
||||
}
|
||||
|
||||
private func attachReport(name: String, lines: [String]) {
|
||||
let text = lines.joined(separator: "\n")
|
||||
let attachment = XCTAttachment(string: text)
|
||||
attachment.name = "memory-\(name)"
|
||||
attachment.lifetime = .keepAlways
|
||||
add(attachment)
|
||||
print("[MemoryFootprintTests] \(name)\n\(text)")
|
||||
}
|
||||
}
|
||||
@ -22,7 +22,7 @@ final class ReaderAnnotationTests: XCTestCase {
|
||||
|
||||
app.waitForDemoReaderState(timeout: 5, description: "selection=1") { $0.selection == 1 }
|
||||
|
||||
let highlightMenuItem = app.menuItems["高亮"]
|
||||
let highlightMenuItem = app.menuItems["划线"]
|
||||
let highlightButton = app.buttons[IDs.readerSelectionHighlight]
|
||||
if highlightMenuItem.waitForExistence(timeout: 3) {
|
||||
highlightMenuItem.tap()
|
||||
@ -65,7 +65,7 @@ final class ReaderAnnotationTests: XCTestCase {
|
||||
|
||||
app.waitForDemoReaderState(timeout: 5, description: "selection=1") { $0.selection == 1 }
|
||||
|
||||
let annotateMenuItem = app.menuItems["批注"]
|
||||
let annotateMenuItem = app.menuItems["注释"]
|
||||
XCTAssertTrue(annotateMenuItem.waitForExistence(timeout: 3), "选中文本后未出现批注菜单")
|
||||
annotateMenuItem.tap()
|
||||
|
||||
|
||||
@ -209,7 +209,9 @@ final class SearchTests: XCTestCase {
|
||||
XCTAssertFalse(searchBar.waitForExistence(timeout: 2), "关闭后搜索栏应消失")
|
||||
}
|
||||
|
||||
// MARK: - 搜索栏与工具栏联动
|
||||
// MARK: - 搜索面板与工具栏联动(全屏面板模型)
|
||||
// 全屏搜索面板覆盖内容区,面板打开时无法点击内容切换 chrome;
|
||||
// 选中结果后面板关闭(搜索状态保留),chrome 隐藏/恢复联动面板。
|
||||
|
||||
func testSearchBarHidesWhenToolbarsHide() {
|
||||
app.launchAndOpenSampleBook(searchKeyword: "的")
|
||||
@ -217,12 +219,15 @@ final class SearchTests: XCTestCase {
|
||||
app.showReaderChromeIfNeeded()
|
||||
|
||||
let searchBar = app.otherElements[IDs.searchBar]
|
||||
XCTAssertTrue(searchBar.waitForExistence(timeout: 10), "搜索栏应可见")
|
||||
XCTAssertTrue(searchBar.waitForExistence(timeout: 10), "搜索面板应可见")
|
||||
|
||||
// 隐藏工具栏
|
||||
// 选中结果后面板关闭并跳转,搜索状态保留
|
||||
selectSearchResult(at: 0)
|
||||
XCTAssertFalse(searchBar.waitForExistence(timeout: 2), "选中结果后搜索面板应关闭")
|
||||
|
||||
// 隐藏工具栏,面板应保持关闭
|
||||
app.hideReaderChromeIfNeeded()
|
||||
|
||||
XCTAssertFalse(searchBar.waitForExistence(timeout: 2), "工具栏隐藏时搜索栏也应隐藏")
|
||||
XCTAssertFalse(searchBar.waitForExistence(timeout: 2), "工具栏隐藏时搜索面板应保持关闭")
|
||||
}
|
||||
|
||||
func testSearchBarRestoresWhenToolbarsShow() {
|
||||
@ -233,13 +238,14 @@ final class SearchTests: XCTestCase {
|
||||
let searchBar = app.otherElements[IDs.searchBar]
|
||||
XCTAssertTrue(searchBar.waitForExistence(timeout: 10))
|
||||
|
||||
// 隐藏工具栏
|
||||
// 选中结果关闭面板,再隐藏工具栏
|
||||
selectSearchResult(at: 0)
|
||||
app.hideReaderChromeIfNeeded()
|
||||
XCTAssertFalse(searchBar.waitForExistence(timeout: 2))
|
||||
|
||||
// 重新显示工具栏
|
||||
// 重新显示工具栏:搜索状态存在时面板应自动恢复
|
||||
app.showReaderChromeIfNeeded()
|
||||
XCTAssertTrue(searchBar.waitForExistence(timeout: 3), "工具栏恢复时搜索栏也应恢复")
|
||||
XCTAssertTrue(searchBar.waitForExistence(timeout: 3), "工具栏恢复时搜索面板也应恢复")
|
||||
|
||||
// 搜索状态应保留
|
||||
let countLabel = app.staticTexts[IDs.searchCount]
|
||||
@ -286,6 +292,9 @@ final class SearchTests: XCTestCase {
|
||||
}
|
||||
let rangeCFIBefore = stateBefore.rangeCFI
|
||||
|
||||
// 全屏面板挡住工具栏,先选中当前匹配关闭面板(保留搜索状态)
|
||||
selectSearchResult(at: 0)
|
||||
|
||||
// 改变字体触发重排
|
||||
app.showReaderChromeIfNeeded()
|
||||
let settingsButton = app.buttons[IDs.readerSettings]
|
||||
@ -304,8 +313,8 @@ final class SearchTests: XCTestCase {
|
||||
}
|
||||
Thread.sleep(forTimeInterval: 1)
|
||||
|
||||
// 重新打开搜索并导航到匹配
|
||||
app.showReaderChromeIfNeeded()
|
||||
// 重新打开搜索面板(搜索状态保留),前后导航强制重解析当前命中
|
||||
reopenSearchPanel()
|
||||
let prevButton = app.buttons[IDs.searchPrevious]
|
||||
let nextButton = app.buttons[IDs.searchNext]
|
||||
if prevButton.waitForExistence(timeout: 3), prevButton.isEnabled {
|
||||
@ -349,6 +358,9 @@ final class SearchTests: XCTestCase {
|
||||
return
|
||||
}
|
||||
|
||||
// 全屏面板挡住工具栏,先选中当前匹配关闭面板(保留搜索状态)
|
||||
selectSearchResult(at: 0)
|
||||
|
||||
// 改变字体大小触发重新排版
|
||||
app.showReaderChromeIfNeeded()
|
||||
let settingsButton = app.buttons[IDs.readerSettings]
|
||||
@ -367,15 +379,9 @@ final class SearchTests: XCTestCase {
|
||||
}
|
||||
Thread.sleep(forTimeInterval: 1)
|
||||
|
||||
// 测试多个匹配位置的跳转正确性
|
||||
let nextButton = app.buttons[IDs.searchNext]
|
||||
let prevButton = app.buttons[IDs.searchPrevious]
|
||||
XCTAssertTrue(nextButton.waitForExistence(timeout: 3), "下一个按钮应存在")
|
||||
XCTAssertTrue(prevButton.waitForExistence(timeout: 3), "上一个按钮应存在")
|
||||
|
||||
// 导航到第 2 个匹配,记录页码
|
||||
nextButton.tap()
|
||||
Thread.sleep(forTimeInterval: 0.5)
|
||||
// 跳到第 2 个匹配,记录页码
|
||||
reopenSearchPanel()
|
||||
selectSearchResult(at: 1)
|
||||
let stateMatch2 = app.waitForDemoReaderState(timeout: 5, description: "match 2 page") {
|
||||
($0.page ?? 0) > 0 && $0.rangeCFI?.isEmpty == false
|
||||
}
|
||||
@ -383,18 +389,11 @@ final class SearchTests: XCTestCase {
|
||||
let rangeCFIAtMatch2 = stateMatch2.rangeCFI
|
||||
|
||||
// 翻到其他页
|
||||
let content = app.otherElements[IDs.readerContent].firstMatch
|
||||
if content.waitForExistence(timeout: 3) {
|
||||
for _ in 0..<4 {
|
||||
content.swipeLeft()
|
||||
Thread.sleep(forTimeInterval: 0.3)
|
||||
}
|
||||
Thread.sleep(forTimeInterval: 0.5)
|
||||
}
|
||||
swipeAwayFromCurrentPage()
|
||||
|
||||
// 跳转到第 3 个匹配
|
||||
nextButton.tap()
|
||||
Thread.sleep(forTimeInterval: 0.5)
|
||||
// 跳到第 3 个匹配
|
||||
reopenSearchPanel()
|
||||
selectSearchResult(at: 2)
|
||||
let stateMatch3 = app.waitForDemoReaderState(timeout: 5, description: "match 3 page") {
|
||||
($0.page ?? 0) > 0 && $0.rangeCFI?.isEmpty == false
|
||||
}
|
||||
@ -402,17 +401,11 @@ final class SearchTests: XCTestCase {
|
||||
let rangeCFIAtMatch3 = stateMatch3.rangeCFI
|
||||
|
||||
// 再翻到其他页
|
||||
if content.waitForExistence(timeout: 3) {
|
||||
for _ in 0..<4 {
|
||||
content.swipeLeft()
|
||||
Thread.sleep(forTimeInterval: 0.3)
|
||||
}
|
||||
Thread.sleep(forTimeInterval: 0.5)
|
||||
}
|
||||
swipeAwayFromCurrentPage()
|
||||
|
||||
// 跳回第 2 个匹配,验证页码不变
|
||||
prevButton.tap()
|
||||
Thread.sleep(forTimeInterval: 0.5)
|
||||
reopenSearchPanel()
|
||||
selectSearchResult(at: 1)
|
||||
let stateBack2 = app.waitForDemoReaderState(timeout: 5, description: "back to match 2") {
|
||||
$0.page == pageAtMatch2 && $0.rangeCFI == rangeCFIAtMatch2
|
||||
}
|
||||
@ -422,17 +415,11 @@ final class SearchTests: XCTestCase {
|
||||
"重排后搜索跳回第 2 个匹配应命中同一段文本")
|
||||
|
||||
// 再翻到其他页
|
||||
if content.waitForExistence(timeout: 3) {
|
||||
for _ in 0..<4 {
|
||||
content.swipeLeft()
|
||||
Thread.sleep(forTimeInterval: 0.3)
|
||||
}
|
||||
Thread.sleep(forTimeInterval: 0.5)
|
||||
}
|
||||
swipeAwayFromCurrentPage()
|
||||
|
||||
// 跳到第 3 个匹配,验证页码不变
|
||||
nextButton.tap()
|
||||
Thread.sleep(forTimeInterval: 0.5)
|
||||
reopenSearchPanel()
|
||||
selectSearchResult(at: 2)
|
||||
let stateBack3 = app.waitForDemoReaderState(timeout: 5, description: "back to match 3") {
|
||||
$0.page == pageAtMatch3 && $0.rangeCFI == rangeCFIAtMatch3
|
||||
}
|
||||
@ -452,4 +439,39 @@ final class SearchTests: XCTestCase {
|
||||
let searchBar = app.otherElements[IDs.searchBar]
|
||||
XCTAssertTrue(searchBar.waitForExistence(timeout: 3), "搜索栏应出现")
|
||||
}
|
||||
|
||||
/// 点击全屏搜索面板中的结果行:跳转到对应匹配并关闭面板(搜索状态保留)
|
||||
private func selectSearchResult(at matchIndex: Int) {
|
||||
let cell = app.cells["epub.reader.search.result.\(matchIndex)"].firstMatch
|
||||
XCTAssertTrue(cell.waitForExistence(timeout: 10), "搜索结果第 \(matchIndex) 项应存在")
|
||||
cell.tap()
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
|
||||
}
|
||||
|
||||
/// 重新打开搜索面板。chrome 恢复时若有搜索状态面板会自动恢复;
|
||||
/// chrome 本就可见时需点击工具栏搜索按钮。
|
||||
private func reopenSearchPanel() {
|
||||
app.showReaderChromeIfNeeded()
|
||||
let searchBar = app.otherElements[IDs.searchBar]
|
||||
if searchBar.waitForExistence(timeout: 2) {
|
||||
return
|
||||
}
|
||||
let searchButton = app.buttons[IDs.readerSearch]
|
||||
XCTAssertTrue(searchButton.waitForExistence(timeout: 3), "搜索按钮应存在")
|
||||
searchButton.tap()
|
||||
XCTAssertTrue(searchBar.waitForExistence(timeout: 3), "搜索面板应重新打开")
|
||||
}
|
||||
|
||||
/// 隐藏 chrome 后向后翻若干页,使当前页离开搜索命中页
|
||||
private func swipeAwayFromCurrentPage() {
|
||||
app.hideReaderChromeIfNeeded()
|
||||
let content = app.otherElements[IDs.readerContent].firstMatch
|
||||
if content.waitForExistence(timeout: 3) {
|
||||
for _ in 0..<4 {
|
||||
content.swipeLeft()
|
||||
Thread.sleep(forTimeInterval: 0.3)
|
||||
}
|
||||
Thread.sleep(forTimeInterval: 0.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -29,7 +29,7 @@ final class SelectionAnnotateTests: XCTestCase {
|
||||
Thread.sleep(forTimeInterval: 0.5)
|
||||
|
||||
// 点击批注菜单项
|
||||
let annotateItem = app.menuItems["批注"]
|
||||
let annotateItem = app.menuItems["注释"]
|
||||
guard annotateItem.waitForExistence(timeout: 3) else {
|
||||
throw XCTSkip("批注菜单项不存在")
|
||||
}
|
||||
@ -56,7 +56,7 @@ final class SelectionAnnotateTests: XCTestCase {
|
||||
|
||||
Thread.sleep(forTimeInterval: 0.5)
|
||||
|
||||
let annotateItem = app.menuItems["批注"]
|
||||
let annotateItem = app.menuItems["注释"]
|
||||
guard annotateItem.waitForExistence(timeout: 3) else {
|
||||
throw XCTSkip("批注菜单项不存在")
|
||||
}
|
||||
@ -101,7 +101,7 @@ final class SelectionAnnotateTests: XCTestCase {
|
||||
start.press(forDuration: 0.8, thenDragTo: end)
|
||||
Thread.sleep(forTimeInterval: 0.5)
|
||||
|
||||
let highlightItem = app.menuItems["高亮"]
|
||||
let highlightItem = app.menuItems["划线"]
|
||||
guard highlightItem.waitForExistence(timeout: 3) else {
|
||||
throw XCTSkip("高亮菜单项不存在")
|
||||
}
|
||||
|
||||
@ -18,7 +18,7 @@ final class SelectionMenuTests: XCTestCase {
|
||||
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||
start.press(forDuration: 0.8, thenDragTo: end)
|
||||
|
||||
XCTAssertTrue(app.menuItems["拷贝"].waitForExistence(timeout: 3), "选区菜单应包含拷贝选项")
|
||||
XCTAssertTrue(app.menuItems["复制"].waitForExistence(timeout: 3), "选区菜单应包含拷贝选项")
|
||||
}
|
||||
|
||||
func testSelectionMenuShowsAnnotateOption() throws {
|
||||
@ -32,7 +32,7 @@ final class SelectionMenuTests: XCTestCase {
|
||||
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||
start.press(forDuration: 0.8, thenDragTo: end)
|
||||
|
||||
XCTAssertTrue(app.menuItems["批注"].waitForExistence(timeout: 3), "选区菜单应包含批注选项")
|
||||
XCTAssertTrue(app.menuItems["注释"].waitForExistence(timeout: 3), "选区菜单应包含批注选项")
|
||||
}
|
||||
|
||||
func testTapBlankAreaClearsSelection() throws {
|
||||
@ -46,14 +46,14 @@ final class SelectionMenuTests: XCTestCase {
|
||||
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||
start.press(forDuration: 0.8, thenDragTo: end)
|
||||
|
||||
XCTAssertTrue(app.menuItems["拷贝"].waitForExistence(timeout: 3), "选区菜单应出现")
|
||||
XCTAssertTrue(app.menuItems["复制"].waitForExistence(timeout: 3), "选区菜单应出现")
|
||||
|
||||
let contentArea = app.otherElements[IDs.readerContentView].firstMatch
|
||||
if contentArea.waitForExistence(timeout: 3) {
|
||||
contentArea.coordinate(withNormalizedOffset: CGVector(dx: 0.1, dy: 0.1)).tap()
|
||||
}
|
||||
|
||||
let menuGone = !app.menuItems["拷贝"].waitForExistence(timeout: 2)
|
||||
let menuGone = !app.menuItems["复制"].waitForExistence(timeout: 2)
|
||||
XCTAssertTrue(menuGone, "点击空白区域后选区菜单应消失")
|
||||
}
|
||||
|
||||
@ -68,7 +68,7 @@ final class SelectionMenuTests: XCTestCase {
|
||||
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||
start.press(forDuration: 0.8, thenDragTo: end)
|
||||
|
||||
let copyMenuItem = app.menuItems["拷贝"]
|
||||
let copyMenuItem = app.menuItems["复制"]
|
||||
if copyMenuItem.waitForExistence(timeout: 3) { copyMenuItem.tap() }
|
||||
|
||||
app.waitForDemoReaderState(timeout: 5, description: "reader=opened") { $0.isOpened }
|
||||
|
||||
@ -44,24 +44,6 @@ final class SettingsEffectTests: XCTestCase {
|
||||
XCTAssertTrue(contentView.waitForExistence(timeout: 5), "主题切换后内容区应存在")
|
||||
}
|
||||
|
||||
func testColumnCountChangeUpdatesLayout() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
app.buttons[IDs.readerSettings].tap()
|
||||
XCTAssertTrue(app.scrollViews[IDs.settingsScroll].waitForExistence(timeout: 5))
|
||||
app.scrollViews[IDs.settingsScroll].swipeUp()
|
||||
|
||||
let columnsControl = app.segmentedControls[IDs.settingsColumns]
|
||||
if columnsControl.waitForExistence(timeout: 3) && columnsControl.buttons.count > 1 {
|
||||
columnsControl.buttons.element(boundBy: 1).tap()
|
||||
}
|
||||
|
||||
app.buttons[IDs.settingsDone].tap()
|
||||
app.waitForDemoReaderState(timeout: 8, description: "reader=opened") { $0.isOpened }
|
||||
}
|
||||
|
||||
func testLineHeightChangeUpdatesContent() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
@ -36,18 +36,6 @@ final class SettingsExtendedTests: XCTestCase {
|
||||
XCTAssertTrue(app.buttons[IDs.readerBack].waitForExistence(timeout: 5))
|
||||
}
|
||||
|
||||
func testColumnCountControl() {
|
||||
openSettings()
|
||||
let columns = app.segmentedControls[IDs.settingsColumns]
|
||||
XCTAssertTrue(columns.waitForExistence(timeout: 3), "分栏控件未出现")
|
||||
|
||||
columns.buttons["单栏"].tap()
|
||||
columns.buttons["双栏"].tap()
|
||||
|
||||
app.buttons[IDs.settingsDone].tap()
|
||||
XCTAssertTrue(app.buttons[IDs.readerBack].waitForExistence(timeout: 5))
|
||||
}
|
||||
|
||||
func testDisplayTypeFromSettings() {
|
||||
openSettings()
|
||||
let displayType = app.segmentedControls[IDs.settingsDisplayType]
|
||||
@ -73,7 +61,6 @@ final class SettingsExtendedTests: XCTestCase {
|
||||
|
||||
XCTAssertTrue(app.sliders[IDs.settingsBrightness].waitForExistence(timeout: 3))
|
||||
XCTAssertTrue(app.segmentedControls[IDs.settingsLineHeight].waitForExistence(timeout: 3))
|
||||
XCTAssertTrue(app.segmentedControls[IDs.settingsColumns].waitForExistence(timeout: 3))
|
||||
XCTAssertTrue(app.segmentedControls[IDs.settingsDisplayType].waitForExistence(timeout: 3))
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import UIKit
|
||||
import RDReaderView
|
||||
import RDEpubReaderView
|
||||
|
||||
final class ViewController: UIViewController {
|
||||
private struct DemoBook: Equatable {
|
||||
@ -10,12 +10,12 @@ final class ViewController: UIViewController {
|
||||
|
||||
private struct LaunchAutomationPlan {
|
||||
let bookTitleQuery: String
|
||||
let displayType: RDReaderView.DisplayType?
|
||||
let displayType: RDEpubReaderView.DisplayType?
|
||||
let pageNumber: Int?
|
||||
let displaySequence: [RDReaderView.DisplayType]
|
||||
let displaySequence: [RDEpubReaderView.DisplayType]
|
||||
let stepDelay: TimeInterval
|
||||
|
||||
nonisolated private static func parseDisplayType(_ rawValue: String) -> RDReaderView.DisplayType? {
|
||||
nonisolated private static func parseDisplayType(_ rawValue: String) -> RDEpubReaderView.DisplayType? {
|
||||
switch rawValue.lowercased() {
|
||||
case "pagecurl", "curl":
|
||||
return .pageCurl
|
||||
@ -328,7 +328,7 @@ final class ViewController: UIViewController {
|
||||
) -> (report: BookValidationReport, diagnostics: [String]) {
|
||||
let fileExtension = book.fileURL.pathExtension.lowercased()
|
||||
if fileExtension == "txt" {
|
||||
let builder = RDPlainTextBookBuilder()
|
||||
let builder = RDEpubPlainTextBookBuilder()
|
||||
do {
|
||||
let textBook = try builder.build(textFileURL: book.fileURL, pageSize: pageSize, style: style)
|
||||
let passed = !textBook.pages.isEmpty && !textBook.chapters.isEmpty
|
||||
@ -646,8 +646,8 @@ final class ViewController: UIViewController {
|
||||
_ book: DemoBook,
|
||||
configuration: RDEPUBReaderConfiguration = .default,
|
||||
automationPlan: LaunchAutomationPlan? = nil
|
||||
) -> RDURLReaderController {
|
||||
let controller = RDURLReaderController(bookURL: book.fileURL, epubConfiguration: configuration)
|
||||
) -> RDEpubURLReaderController {
|
||||
let controller = RDEpubURLReaderController(bookURL: book.fileURL, epubConfiguration: configuration)
|
||||
controller.title = book.title
|
||||
|
||||
if let navigationController {
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
public let kRDEPUBHighlightAttributeName = NSAttributedString.Key("com.rdreader.highlight")
|
||||
public let kRDEPUBHighlightAttributeName = NSAttributedString.Key("com.RDEpubReader.highlight")
|
||||
|
||||
public let kRDEPUBUnderlineAttributeName = NSAttributedString.Key("com.rdreader.underline")
|
||||
public let kRDEPUBUnderlineAttributeName = NSAttributedString.Key("com.RDEpubReader.underline")
|
||||
|
||||
public struct RDEPUBSelection: Codable, Equatable {
|
||||
|
||||
@ -110,6 +110,10 @@ public struct RDEPUBHighlight: Codable, Equatable {
|
||||
|
||||
public var note: String?
|
||||
|
||||
/// True when this record was created directly as an annotation instead of
|
||||
/// adding a note to an existing underline.
|
||||
public var isAnnotationOnly: Bool
|
||||
|
||||
public var createdAt: Date
|
||||
|
||||
public init(
|
||||
@ -118,9 +122,10 @@ public struct RDEPUBHighlight: Codable, Equatable {
|
||||
location: RDEPUBLocation,
|
||||
text: String,
|
||||
rangeInfo: String? = nil,
|
||||
style: RDEPUBHighlightStyle = .highlight,
|
||||
style: RDEPUBHighlightStyle = .underline,
|
||||
color: String = "#F8E16C",
|
||||
note: String? = nil,
|
||||
isAnnotationOnly: Bool = false,
|
||||
createdAt: Date = Date()
|
||||
) {
|
||||
self.id = id
|
||||
@ -131,6 +136,7 @@ public struct RDEPUBHighlight: Codable, Equatable {
|
||||
self.style = style
|
||||
self.color = color
|
||||
self.note = note?.nilIfEmpty
|
||||
self.isAnnotationOnly = isAnnotationOnly
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
@ -152,6 +158,7 @@ public struct RDEPUBHighlight: Codable, Equatable {
|
||||
case style
|
||||
case color
|
||||
case note
|
||||
case isAnnotationOnly
|
||||
case createdAt
|
||||
}
|
||||
|
||||
@ -164,12 +171,13 @@ public struct RDEPUBHighlight: Codable, Equatable {
|
||||
rangeInfo = try container.decodeIfPresent(String.self, forKey: .rangeInfo)?.nilIfEmpty
|
||||
if let rawStyle = try container.decodeIfPresent(String.self, forKey: .style),
|
||||
let decodedStyle = RDEPUBHighlightStyle(rawValue: rawStyle) {
|
||||
style = decodedStyle
|
||||
style = decodedStyle == .highlight ? .underline : decodedStyle
|
||||
} else {
|
||||
style = .highlight
|
||||
style = .underline
|
||||
}
|
||||
color = try container.decodeIfPresent(String.self, forKey: .color) ?? "#F8E16C"
|
||||
note = try container.decodeIfPresent(String.self, forKey: .note)?.nilIfEmpty
|
||||
isAnnotationOnly = try container.decodeIfPresent(Bool.self, forKey: .isAnnotationOnly) ?? false
|
||||
createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date()
|
||||
}
|
||||
|
||||
@ -183,6 +191,7 @@ public struct RDEPUBHighlight: Codable, Equatable {
|
||||
try container.encode(style, forKey: .style)
|
||||
try container.encode(color, forKey: .color)
|
||||
try container.encodeIfPresent(note, forKey: .note)
|
||||
try container.encode(isAnnotationOnly, forKey: .isAnnotationOnly)
|
||||
try container.encode(createdAt, forKey: .createdAt)
|
||||
}
|
||||
|
||||
@ -59,13 +59,22 @@ public final class RDEPUBNoteResolver {
|
||||
guard let fragment,
|
||||
!fragment.isEmpty,
|
||||
let fileURL = resourceResolver.fileURL(forRelativePath: href),
|
||||
let html = try? String(contentsOf: fileURL, encoding: .utf8) else {
|
||||
let html = chapterHTML(at: fileURL) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return elementHTML(withID: fragment, in: html)
|
||||
}
|
||||
|
||||
/// 读取章节 HTML:优先经加密资源 provider 解密,明文文件保持原直读路径
|
||||
private func chapterHTML(at fileURL: URL) -> String? {
|
||||
if let provided = resourceResolver.providedResourceData(at: fileURL) {
|
||||
return String(data: provided, encoding: .utf8)
|
||||
?? String(data: provided, encoding: .utf16)
|
||||
}
|
||||
return try? String(contentsOf: fileURL, encoding: .utf8)
|
||||
}
|
||||
|
||||
private func elementHTML(withID id: String, in html: String) -> String? {
|
||||
let escapedID = NSRegularExpression.escapedPattern(for: id)
|
||||
let pattern = #"<([A-Za-z][A-Za-z0-9:_-]*)(?=[^>]*(?:id|xml:id)\s*=\s*(['"])"# + escapedID + #"\2)[^>]*>"#
|
||||
@ -60,11 +60,13 @@ enum RDEPUBAssetRepository {
|
||||
private static var resolvedBundle: Bundle? = {
|
||||
let hostBundles = [Bundle(for: RDEPUBAssetBundleToken.self), Bundle.main] + Bundle.allFrameworks + Bundle.allBundles
|
||||
for hostBundle in hostBundles {
|
||||
if let url = hostBundle.url(forResource: "RDReaderViewAssets", withExtension: "bundle"),
|
||||
for bundleName in ["RDEpubReaderViewAssets", "RDReaderViewAssets"] {
|
||||
if let url = hostBundle.url(forResource: bundleName, withExtension: "bundle"),
|
||||
let bundle = Bundle(url: url) {
|
||||
return bundle
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
}
|
||||
@ -24,6 +24,13 @@ public enum RDEPUBPageSpread: String, Codable {
|
||||
case center
|
||||
}
|
||||
|
||||
/// EPUB 渲染朝向(rendition:orientation / iBooks display-options)。
|
||||
public enum RDEPUBOrientation: String, Codable {
|
||||
case portrait
|
||||
case landscape
|
||||
case auto
|
||||
}
|
||||
|
||||
public struct RDEPUBMetadata: Codable, Equatable {
|
||||
|
||||
public var identifier: String?
|
||||
@ -42,6 +49,10 @@ public struct RDEPUBMetadata: Codable, Equatable {
|
||||
|
||||
public var readingProgression: RDEPUBReadingProgression
|
||||
|
||||
/// 渲染朝向;nil = 未声明(不锁定,跟随系统)。
|
||||
/// 来源优先级:OPF rendition:orientation > META-INF/com.apple.ibooks.display-options.xml。
|
||||
public var orientation: RDEPUBOrientation?
|
||||
|
||||
public init(
|
||||
identifier: String? = nil,
|
||||
title: String = "",
|
||||
@ -50,7 +61,8 @@ public struct RDEPUBMetadata: Codable, Equatable {
|
||||
version: String? = nil,
|
||||
layout: RDEPUBLayout = .reflowable,
|
||||
spread: String? = nil,
|
||||
readingProgression: RDEPUBReadingProgression = .auto
|
||||
readingProgression: RDEPUBReadingProgression = .auto,
|
||||
orientation: RDEPUBOrientation? = nil
|
||||
) {
|
||||
self.identifier = identifier
|
||||
self.title = title
|
||||
@ -60,6 +72,7 @@ public struct RDEPUBMetadata: Codable, Equatable {
|
||||
self.layout = layout
|
||||
self.spread = spread
|
||||
self.readingProgression = readingProgression
|
||||
self.orientation = orientation
|
||||
}
|
||||
}
|
||||
|
||||
@ -151,3 +151,48 @@ private final class ContainerXMLParserDelegate: NSObject, XMLParserDelegate {
|
||||
rootFilePath = attributeDict["full-path"]?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析 com.apple.ibooks.display-options.xml,提取 orientation-lock 选项值。
|
||||
final class IBooksDisplayOptionsParserDelegate: NSObject, XMLParserDelegate {
|
||||
|
||||
private(set) var orientationLock: String?
|
||||
|
||||
private var isCapturingOrientationOption = false
|
||||
private var buffer = ""
|
||||
|
||||
func parser(
|
||||
_ parser: XMLParser,
|
||||
didStartElement elementName: String,
|
||||
namespaceURI: String?,
|
||||
qualifiedName qName: String?,
|
||||
attributes attributeDict: [String: String] = [:]
|
||||
) {
|
||||
let name = XMLName.localName(from: qName ?? elementName)
|
||||
guard name == "option" else { return }
|
||||
let optionName = (attributeDict["name"] ?? "").lowercased()
|
||||
if optionName == "orientation-lock" {
|
||||
isCapturingOrientationOption = true
|
||||
buffer = ""
|
||||
}
|
||||
}
|
||||
|
||||
func parser(_ parser: XMLParser, foundCharacters string: String) {
|
||||
guard isCapturingOrientationOption else { return }
|
||||
buffer += string
|
||||
}
|
||||
|
||||
func parser(
|
||||
_ parser: XMLParser,
|
||||
didEndElement elementName: String,
|
||||
namespaceURI: String?,
|
||||
qualifiedName qName: String?
|
||||
) {
|
||||
let name = XMLName.localName(from: qName ?? elementName)
|
||||
guard name == "option", isCapturingOrientationOption else { return }
|
||||
isCapturingOrientationOption = false
|
||||
let value = buffer.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !value.isEmpty, orientationLock == nil {
|
||||
orientationLock = value
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -111,11 +111,18 @@ final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
|
||||
updatedItem.title = manifestTitleByID[item.id]
|
||||
return updatedItem
|
||||
}
|
||||
// 部分历史 EPUB 的 OPF manifest 含重复 id。`uniqueKeysWithValues` 会在
|
||||
// 此处触发运行时断言,导致整本书无法打开;spine 按 OPF 顺序解析,故保留
|
||||
// 第一个声明即可维持其原始引用语义。
|
||||
let manifestByID = manifest.reduce(into: [String: RDEPUBManifestItem]()) { result, item in
|
||||
guard !item.id.isEmpty, result[item.id] == nil else { return }
|
||||
result[item.id] = item
|
||||
}
|
||||
|
||||
return OPFPackageDocument(
|
||||
metadata: metadata,
|
||||
manifest: manifest,
|
||||
manifestByID: Dictionary(uniqueKeysWithValues: manifest.map { ($0.id, $0) }),
|
||||
manifestByID: manifestByID,
|
||||
spineReferences: spineReferences,
|
||||
ncxItem: manifest.first(where: { $0.id == ncxID || $0.isNCX }),
|
||||
navigationItem: manifest.first(where: { $0.isNavigationDocument })
|
||||
@ -180,6 +187,11 @@ final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
|
||||
property == "rendition:spread" {
|
||||
metadata.spread = attributeDict["content"]
|
||||
}
|
||||
if let property = currentMetaProperty?.lowercased(),
|
||||
property == "rendition:orientation",
|
||||
let value = attributeDict["content"] {
|
||||
metadata.orientation = orientation(from: value)
|
||||
}
|
||||
if let property = currentMetaProperty?.lowercased(),
|
||||
property == "title",
|
||||
let refinesID = currentMetaRefinesID,
|
||||
@ -270,6 +282,8 @@ final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
|
||||
metadata.layout = layout(from: normalizedValue)
|
||||
case "rendition:spread":
|
||||
metadata.spread = normalizedValue
|
||||
case "rendition:orientation":
|
||||
metadata.orientation = orientation(from: normalizedValue)
|
||||
case "dcterms:identifier", "identifier":
|
||||
if metadata.identifier == nil {
|
||||
metadata.identifier = normalizedValue
|
||||
@ -315,6 +329,19 @@ final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
|
||||
private func layout(from rawValue: String) -> RDEPUBLayout {
|
||||
rawValue.lowercased().contains("pre-paginated") ? .fixed : .reflowable
|
||||
}
|
||||
|
||||
private func orientation(from rawValue: String) -> RDEPUBOrientation? {
|
||||
switch rawValue.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) {
|
||||
case "portrait":
|
||||
return .portrait
|
||||
case "landscape":
|
||||
return .landscape
|
||||
case "auto":
|
||||
return .auto
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum XMLSection {
|
||||
@ -62,7 +62,8 @@ extension RDEPUBParser {
|
||||
}
|
||||
for item in coverCandidates {
|
||||
if let fileURL = fileURL(forRelativePath: item.href),
|
||||
let image = UIImage(contentsOfFile: fileURL.path) {
|
||||
let data = resourceData(at: fileURL),
|
||||
let image = UIImage(data: data) {
|
||||
return image
|
||||
}
|
||||
}
|
||||
@ -80,6 +81,10 @@ extension RDEPUBParser {
|
||||
guard let fileURL = fileURL(forRelativePath: relativePath) else {
|
||||
return nil
|
||||
}
|
||||
if let provided = providedResourceData(at: fileURL) {
|
||||
return String(data: provided, encoding: .utf8)
|
||||
?? String(data: provided, encoding: .utf16)
|
||||
}
|
||||
return try? String(contentsOf: fileURL)
|
||||
}
|
||||
|
||||
@ -19,6 +19,28 @@ public final class RDEPUBParser {
|
||||
opfURL?.deletingLastPathComponent()
|
||||
}
|
||||
|
||||
/// 加密资源数据提供者;nil 时所有资源按明文直读。
|
||||
/// 必须在 `parse(epubURL:)` 之前设置(推荐通过 `RDEPUBReaderDependencies.live(resourceDataProvider:)` 注入)。
|
||||
public var resourceDataProvider: RDEPUBResourceDataProvider? {
|
||||
didSet {
|
||||
guard resourceDataProvider != nil else { return }
|
||||
RDEPUBResourceAccessRegistry.register(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// 仅经 provider 获取解密数据;无 provider 或 provider 判定无需解密时返回 nil
|
||||
public func providedResourceData(at fileURL: URL) -> Data? {
|
||||
resourceDataProvider?.resourceData(at: fileURL)
|
||||
}
|
||||
|
||||
/// 统一资源读取入口:优先 provider 解密,其次磁盘明文
|
||||
public func resourceData(at fileURL: URL) -> Data? {
|
||||
if let provided = providedResourceData(at: fileURL) {
|
||||
return provided
|
||||
}
|
||||
return try? Data(contentsOf: fileURL)
|
||||
}
|
||||
|
||||
public init() {}
|
||||
|
||||
// MARK: - Cache Management
|
||||
@ -101,6 +123,37 @@ public final class RDEPUBParser {
|
||||
}
|
||||
|
||||
try parseOPF(at: packageURL)
|
||||
|
||||
// 兜底:OPF 未声明朝向时,回退读取 iBooks display-options
|
||||
if metadata.orientation == nil {
|
||||
let displayOptionsURL = extractionURL
|
||||
.appendingPathComponent("META-INF/com.apple.ibooks.display-options.xml")
|
||||
if let orientation = parseIBooksOrientation(at: displayOptionsURL) {
|
||||
metadata.orientation = orientation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析 `com.apple.ibooks.display-options.xml` 中的 orientation-lock。
|
||||
/// 结构:platform[@name] / option[@name="orientation-lock"] 文本值。
|
||||
private func parseIBooksOrientation(at url: URL) -> RDEPUBOrientation? {
|
||||
guard FileManager.default.fileExists(atPath: url.path),
|
||||
let parser = XMLParser(contentsOf: url) else {
|
||||
return nil
|
||||
}
|
||||
let delegate = IBooksDisplayOptionsParserDelegate()
|
||||
parser.delegate = delegate
|
||||
guard parser.parse() else { return nil }
|
||||
switch delegate.orientationLock?.lowercased() {
|
||||
case "portrait-orientation", "portrait":
|
||||
return .portrait
|
||||
case "landscape-orientation", "landscape":
|
||||
return .landscape
|
||||
case "none":
|
||||
return .auto
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
public func parseOPF(at opfURL: URL) throws {
|
||||
@ -121,7 +174,8 @@ public final class RDEPUBParser {
|
||||
let packageDocument = delegate.packageDocument()
|
||||
self.opfURL = opfURL
|
||||
self.metadata = packageDocument.metadata
|
||||
self.manifest = Dictionary(uniqueKeysWithValues: packageDocument.manifest.map { ($0.id, $0) })
|
||||
// 与 packageDocument 使用同一份已去重索引,避免重复 manifest id 再次触发断言。
|
||||
self.manifest = packageDocument.manifestByID
|
||||
self.spine = try buildSpine(from: packageDocument, opfURL: opfURL)
|
||||
self.tableOfContents = parseTOC(from: packageDocument, opfURL: opfURL)
|
||||
}
|
||||
@ -36,6 +36,13 @@ public final class RDEPUBPublication {
|
||||
parser.readingProfile()
|
||||
}
|
||||
|
||||
/// Scripted fixed-layout publications own a complete interactive scene per
|
||||
/// spine item. Combining scenes in landscape can start multiple timelines
|
||||
/// and media tracks, so these publications remain single-page.
|
||||
public lazy var requiresSinglePagePresentation: Bool = {
|
||||
layout == .fixed && parser.hasInteractiveContent()
|
||||
}()
|
||||
|
||||
public var readingProgression: RDEPUBReadingProgression {
|
||||
metadata.readingProgression
|
||||
}
|
||||
@ -0,0 +1,63 @@
|
||||
import Foundation
|
||||
|
||||
/// 加密 EPUB 资源数据提供者。
|
||||
///
|
||||
/// 宿主 App 实现该协议以支持“解压目录内单文件加密”的 EPUB:
|
||||
/// SDK 读取章节 HTML、图片、内联 CSS 等资源时会优先向 provider 索取解密后的数据,
|
||||
/// provider 返回 nil 表示该文件无需解密,SDK 回退为直接读取磁盘明文
|
||||
/// (明文书、或加密书内的明文文件均走该兜底路径,因此对明文书零影响)。
|
||||
///
|
||||
/// 密钥推导与解密算法完全由宿主实现,SDK 不持有任何密钥。
|
||||
///
|
||||
/// 注意:方法会在排版 / 渲染的后台队列被同步调用,实现必须线程安全,
|
||||
/// 且除必要的文件 I/O 与解密计算外不得阻塞(例如不要在其中发起网络请求)。
|
||||
public protocol RDEPUBResourceDataProvider: AnyObject {
|
||||
|
||||
/// 返回指定文件解密后的数据。
|
||||
/// - Parameter fileURL: 解压目录内资源文件的绝对路径
|
||||
/// - Returns: 解密后的数据;返回 nil 表示该文件不需要解密,由 SDK 直读磁盘
|
||||
func resourceData(at fileURL: URL) -> Data?
|
||||
}
|
||||
|
||||
/// 加密资源访问登记表。
|
||||
///
|
||||
/// DTCoreText 在解析 `<img>` 时由自己内部加载图片文件,拿不到 parser 实例,
|
||||
/// 因此这里以弱引用登记所有启用了 provider 的 parser,
|
||||
/// 供 `RDEPUBDecryptingImageAttachment` 按文件路径反查所属书籍的 provider。
|
||||
enum RDEPUBResourceAccessRegistry {
|
||||
|
||||
private struct WeakParserBox {
|
||||
weak var parser: RDEPUBParser?
|
||||
}
|
||||
|
||||
private static let lock = NSLock()
|
||||
|
||||
private static var boxes: [WeakParserBox] = []
|
||||
|
||||
/// 登记启用了 provider 的 parser(重复登记会被去重,弱引用失效项顺带清理)
|
||||
static func register(_ parser: RDEPUBParser) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
boxes.removeAll { $0.parser == nil || $0.parser === parser }
|
||||
boxes.append(WeakParserBox(parser: parser))
|
||||
#if canImport(DTCoreText)
|
||||
RDEPUBDecryptingImageAttachment.registerTagClassIfNeeded()
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 按文件路径反查 provider 并返回解密数据;
|
||||
/// 仅当文件位于某个已登记 parser 的解压目录内才会命中
|
||||
static func providedResourceData(at fileURL: URL) -> Data? {
|
||||
let targetPath = fileURL.resolvingSymlinksInPath().standardizedFileURL.path
|
||||
lock.lock()
|
||||
let parsers = boxes.compactMap(\.parser)
|
||||
lock.unlock()
|
||||
for parser in parsers {
|
||||
guard let rootURL = parser.extractionRootURL else { continue }
|
||||
let rootPath = rootURL.resolvingSymlinksInPath().standardizedFileURL.path
|
||||
guard targetPath.hasPrefix(rootPath + "/") else { continue }
|
||||
return parser.providedResourceData(at: fileURL)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@ -25,6 +25,21 @@ public final class RDEPUBResourceResolver {
|
||||
parser.fileURL(forResourceURL: resourceURL)
|
||||
}
|
||||
|
||||
/// 是否配置了加密资源数据提供者
|
||||
public var hasResourceDataProvider: Bool {
|
||||
parser.resourceDataProvider != nil
|
||||
}
|
||||
|
||||
/// 仅经 provider 获取解密数据;无 provider 或 provider 判定无需解密时返回 nil
|
||||
public func providedResourceData(at fileURL: URL) -> Data? {
|
||||
parser.providedResourceData(at: fileURL)
|
||||
}
|
||||
|
||||
/// 统一资源读取入口:优先 provider 解密,其次磁盘明文
|
||||
public func resourceData(at fileURL: URL) -> Data? {
|
||||
parser.resourceData(at: fileURL)
|
||||
}
|
||||
|
||||
public func normalizedHref(_ href: String, relativeToSpineIndex spineIndex: Int? = nil) -> String? {
|
||||
guard let opfDirectoryURL else {
|
||||
return href.components(separatedBy: "#").first
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user