Compare commits
6
Commits
0.0.2
...
d20196ee34
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d20196ee34 | ||
|
|
feb05eaf87 | ||
|
|
584976ac5f | ||
|
|
9801af05d3 | ||
|
|
c76beed03c | ||
|
|
488350d956 |
@@ -0,0 +1,459 @@
|
||||
# WXRead 阅读器内存优化方案
|
||||
|
||||
> 基于读书 v10.0.3 逆向代码分析,提炼 WXRead 处理大书内存的核心策略。
|
||||
> 创建日期:2026-06-02
|
||||
|
||||
---
|
||||
|
||||
## 1. 核心设计原则
|
||||
|
||||
WXRead 的内存管理建立在一个关键架构决策之上:**永远不持有全书数据模型**。
|
||||
|
||||
与"先构建整本 TextBook 再进入阅读器"不同,WXRead 的数据流是:
|
||||
|
||||
```
|
||||
单章 XHTML
|
||||
→ WREpubTypesetter.attributeStringWithFilePath: (单章排版)
|
||||
→ WRChapterData (单章数据模型)
|
||||
→ WRChapterPageCount (单章分页)
|
||||
→ WRPageView.drawRect: (单页渲染)
|
||||
```
|
||||
|
||||
每一步都是章节级或页面级操作,从不需要同时持有全书的 `NSAttributedString`。
|
||||
|
||||
---
|
||||
|
||||
## 2. 章节级缓存模型
|
||||
|
||||
### 2.1 缓存结构
|
||||
|
||||
```objc
|
||||
// WRReaderViewController ()
|
||||
@property (nonatomic, strong) NSMutableDictionary<NSNumber *, WRChapterData *> *chapterDataCache;
|
||||
@property (nonatomic, strong) NSMutableDictionary<NSString *, WRChapterPageCount *> *pageCountCache;
|
||||
```
|
||||
|
||||
关键设计决策:
|
||||
|
||||
| 设计点 | 选择 | 原因 |
|
||||
|--------|------|------|
|
||||
| 缓存类型 | `NSMutableDictionary` 而非 `NSCache` | 需要精确控制淘汰时机,NSCache 的自动淘汰不可预测 |
|
||||
| 缓存粒度 | 按章节索引(`NSNumber *`) | 每章独立加载、独立释放 |
|
||||
| 分页缓存 | 按缓存键(含排版设置) | 设置变更时整批失效 |
|
||||
|
||||
### 2.2 缓存内容
|
||||
|
||||
每个 `WRChapterData` 持有:
|
||||
|
||||
```objc
|
||||
@interface WRChapterData : NSObject
|
||||
@property (nonatomic, strong) NSMutableAttributedString *typesetAttributedString; // 排版后富文本
|
||||
@property (nonatomic, strong) WRCoreTextLayouter *layouter; // 排版器
|
||||
@property (nonatomic, strong) NSArray<NSValue *> *pageRanges; // 页范围数组
|
||||
@property (nonatomic, strong) NSArray<NSDictionary *> *highlights; // 高亮
|
||||
@property (nonatomic, strong) NSArray<NSDictionary *> *underlines; // 下划线
|
||||
@property (nonatomic, strong) NSSet<NSNumber *> *bookmarkedPages; // 书签
|
||||
@property (nonatomic, strong) NSAttributedString *sourceAttributedString; // 源文本
|
||||
@end
|
||||
```
|
||||
|
||||
**注意**:`WRChapterData` 不持有页面视图、不持有其他章节的引用、不持有全书索引表。章节之间完全解耦。
|
||||
|
||||
---
|
||||
|
||||
## 3. 内存警告处理
|
||||
|
||||
### 3.1 监听注册
|
||||
|
||||
```objc
|
||||
// WRReaderViewController.initWithBook:progress:...
|
||||
[[NSNotificationCenter defaultCenter]
|
||||
addObserver:self
|
||||
selector:@selector(_handleMemoryWarning:)
|
||||
name:UIApplicationDidReceiveMemoryWarningNotification
|
||||
object:nil];
|
||||
```
|
||||
|
||||
在 `initWithBook:` 中注册,确保从阅读器创建之初就监听。
|
||||
|
||||
### 3.2 处理逻辑
|
||||
|
||||
```objc
|
||||
- (void)_handleMemoryWarning:(NSNotification *)note {
|
||||
NSLog(@"[WRReader] Memory warning received, purging chapter cache.");
|
||||
|
||||
// 1. 保留当前章节
|
||||
NSUInteger currentIdx = self.readingProgress.chapterIndex;
|
||||
WRChapterData *currentData = self.chapterDataCache[@(currentIdx)];
|
||||
|
||||
// 2. 清空全部缓存
|
||||
[self.chapterDataCache removeAllObjects];
|
||||
|
||||
// 3. 恢复当前章节
|
||||
if (currentData) {
|
||||
self.chapterDataCache[@(currentIdx)] = currentData;
|
||||
}
|
||||
|
||||
// 4. 清空分页缓存(可在需要时重新计算)
|
||||
[self.pageCountCache removeAllObjects];
|
||||
}
|
||||
```
|
||||
|
||||
策略总结:
|
||||
|
||||
| 步骤 | 操作 | 目的 |
|
||||
|------|------|------|
|
||||
| 1 | 保存当前章节引用 | 当前页不能中断 |
|
||||
| 2 | `removeAllObjects` | 一次性释放所有非当前章 |
|
||||
| 3 | 恢复当前章节 | 保证阅读不中断 |
|
||||
| 4 | 清空分页缓存 | `pageCountCache` 可重建,释放额外内存 |
|
||||
|
||||
### 3.3 为什么不用 NSCache
|
||||
|
||||
WXRead 选择 `NSMutableDictionary` + 手动淘汰而非 `NSCache`,原因:
|
||||
|
||||
1. **确定性**:内存警告时必须立即释放,NSCache 的淘汰时机不可控
|
||||
2. **当前章保护**:NSCache 无法 pin 住当前章不被淘汰
|
||||
3. **可预测性**:开发和调试时行为一致,不会因系统内存压力变化而变化
|
||||
|
||||
---
|
||||
|
||||
## 4. 章节加载与预取
|
||||
|
||||
### 4.1 串行后台队列
|
||||
|
||||
```objc
|
||||
// 初始化
|
||||
_chapterLoadQueue = dispatch_queue_create("com.weread.chapterload", DISPATCH_QUEUE_SERIAL);
|
||||
|
||||
// 加载章节
|
||||
- (void)_loadChapterAtIndex:(NSUInteger)index
|
||||
completion:(void (^)(WRChapterData *, NSError *))completion {
|
||||
// 先查缓存
|
||||
WRChapterData *cached = self.chapterDataCache[@(index)];
|
||||
if (cached) {
|
||||
if (completion) completion(cached, nil);
|
||||
return;
|
||||
}
|
||||
|
||||
// 防止重复加载
|
||||
if (self.isLoadingChapter) return;
|
||||
self.isLoadingChapter = YES;
|
||||
|
||||
dispatch_async(self.chapterLoadQueue, ^{
|
||||
// 后台:获取内容 → 排版 → 生成 WRChapterData
|
||||
NSError *error = nil;
|
||||
WRChapterData *chapterData = [self _fetchChapterDataForIndex:index error:&error];
|
||||
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
self.isLoadingChapter = NO;
|
||||
if (chapterData) {
|
||||
self.chapterDataCache[@(index)] = chapterData;
|
||||
if (completion) completion(chapterData, nil);
|
||||
} else {
|
||||
// 重试逻辑
|
||||
if (self.loadRetryCount < self.maxRetryCount) {
|
||||
self.loadRetryCount++;
|
||||
[self _loadChapterAtIndex:index completion:completion];
|
||||
} else {
|
||||
self.loadRetryCount = 0;
|
||||
if (completion) completion(nil, error);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
设计要点:
|
||||
|
||||
| 要点 | 实现 | 目的 |
|
||||
|------|------|------|
|
||||
| 串行队列 | `com.weread.chapterload` | 避免并发加载导致内存峰值叠加 |
|
||||
| 防重复 | `isLoadingChapter` 标志 | 同时只加载一章,控制内存瞬时占用 |
|
||||
| 先查缓存 | `chapterDataCache[@(index)]` | 命中则直接返回,不触发后台任务 |
|
||||
| 重试机制 | `maxRetryCount = 3` | 网络异常时自动重试 |
|
||||
|
||||
### 4.2 相邻章节预取
|
||||
|
||||
```objc
|
||||
- (void)_prefetchAdjacentChaptersForIndex:(NSUInteger)index {
|
||||
// 预取下一章
|
||||
if (index + 1 < self.totalChapters) {
|
||||
NSUInteger nextIdx = index + 1;
|
||||
if (!self.chapterDataCache[@(nextIdx)]) {
|
||||
dispatch_async(self.chapterLoadQueue, ^{
|
||||
[self _fetchChapterDataForIndex:nextIdx error:NULL];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 预取上一章
|
||||
if (index > 0) {
|
||||
NSUInteger prevIdx = index - 1;
|
||||
if (!self.chapterDataCache[@(prevIdx)]) {
|
||||
dispatch_async(self.chapterLoadQueue, ^{
|
||||
[self _fetchChapterDataForIndex:prevIdx error:NULL];
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**触发时机**:
|
||||
1. `renderPageView:` 渲染完成后(第 282 行)
|
||||
2. `didFlipPage` 翻页完成后(第 409 行)
|
||||
|
||||
**预取窗口**:仅当前章 ± 1 章,不做全书预加载。这保证内存占用与用户实际阅读位置绑定。
|
||||
|
||||
---
|
||||
|
||||
## 5. 图片缓存
|
||||
|
||||
### 5.1 NSCache 限制
|
||||
|
||||
```objc
|
||||
// WRCoreTextLayouter.commonInit
|
||||
_imageCache = [[NSCache alloc] init];
|
||||
_imageCache.countLimit = 50; // 最多缓存 50 张缩放后的图片
|
||||
```
|
||||
|
||||
图片是内存大户,WXRead 对图片缓存的处理策略:
|
||||
|
||||
| 策略 | 实现 | 原因 |
|
||||
|------|------|------|
|
||||
| 使用 `NSCache` | 自动在内存压力时淘汰 | 图片可以重新生成,丢失代价低 |
|
||||
| `countLimit = 50` | 限制数量 | 防止图片无限累积 |
|
||||
| 按章节归属 | 每个 `WRCoreTextLayouter` 独立持有 | 章节释放时图片一起释放 |
|
||||
|
||||
### 5.2 CoreText 对象释放
|
||||
|
||||
```objc
|
||||
// WRCoreTextLayouter.dealloc
|
||||
- (void)dealloc {
|
||||
if (_typesetter) {
|
||||
CFRelease(_typesetter);
|
||||
_typesetter = NULL;
|
||||
}
|
||||
if (_framesetter) {
|
||||
CFRelease(_framesetter);
|
||||
_framesetter = NULL;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`CTTypesetter` 和 `CTFramesetter` 是 C 对象,不会被 ARC 自动释放。WXRead 在 `dealloc` 中显式释放,防止内存泄漏。
|
||||
|
||||
---
|
||||
|
||||
## 6. 预加载缓存管理
|
||||
|
||||
### 6.1 预加载场景
|
||||
|
||||
```objc
|
||||
typedef NS_ENUM(NSInteger, WRPreloadScene) {
|
||||
WRPreloadSceneNone = 0,
|
||||
WRPreloadSceneShelf = 1, // 书架页预加载
|
||||
WRPreloadSceneReading = 2, // 阅读时预加载后续章节
|
||||
WRPreloadSceneWiFi = 3, // WiFi 下激进预加载
|
||||
WRPreloadSceneManual = 4, // 用户手动触发
|
||||
};
|
||||
```
|
||||
|
||||
### 6.2 缓存清理接口
|
||||
|
||||
```objc
|
||||
// 清理指定书籍的预加载数据
|
||||
+ (void)removeKVWithBookId:(NSString *)bookId;
|
||||
|
||||
// 清理全部预加载缓存
|
||||
+ (void)clearKV;
|
||||
|
||||
// 计算并可选清理预加载缓存
|
||||
- (void)calcAndClearPreloadBookWithCompletion:(void (^)(NSUInteger totalSize))completion
|
||||
onlyCalc:(BOOL)onlyCalc;
|
||||
```
|
||||
|
||||
预加载数据(已下载但未阅读的章节)存储在磁盘,不占用运行时内存。清理接口用于管理磁盘空间。
|
||||
|
||||
---
|
||||
|
||||
## 7. 排版重排的内存影响
|
||||
|
||||
### 7.1 设置变更时的缓存处理
|
||||
|
||||
```objc
|
||||
- (void)recomposeCurrentPageViewWithSource:(NSString *)source {
|
||||
self.isRecomposing = YES;
|
||||
|
||||
// 清空分页缓存(排版参数变了,旧分页无效)
|
||||
[self.pageCountCache removeAllObjects];
|
||||
|
||||
// 重新排版当前章节
|
||||
WRChapterData *chapterData = self.currentChapterData;
|
||||
if (chapterData) {
|
||||
[self _reTypesetChapterData:chapterData];
|
||||
}
|
||||
|
||||
// 重新渲染
|
||||
[self renderPageView:self.activePageViews.firstObject
|
||||
progressData:self.readingProgress
|
||||
source:source];
|
||||
|
||||
self.isRecomposing = NO;
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 全局设置变更
|
||||
|
||||
```objc
|
||||
- (void)reloadPageViewsWithProgressData:(WRReadingProgress *)progressData
|
||||
source:(NSString *)source {
|
||||
// 清空所有缓存(字号/行距变化影响所有章节的排版)
|
||||
[self.chapterDataCache removeAllObjects];
|
||||
[self.pageCountCache removeAllObjects];
|
||||
|
||||
// 重新加载当前章
|
||||
[self _loadChapterAtIndex:progressData.chapterIndex
|
||||
completion:^(WRChapterData *chapterData, NSError *error) {
|
||||
if (chapterData) {
|
||||
[self _displayChapter:chapterData
|
||||
atPageIndex:progressData.pageIndex
|
||||
animated:NO];
|
||||
}
|
||||
}];
|
||||
}
|
||||
```
|
||||
|
||||
全局设置变更时清空全部缓存,因为排版参数影响所有章节。这是一次性内存释放,后续按需重新加载。
|
||||
|
||||
---
|
||||
|
||||
## 8. 位置持久化
|
||||
|
||||
### 8.1 多字段模型
|
||||
|
||||
```objc
|
||||
@interface WRReadingProgress : NSObject
|
||||
@property (nonatomic, copy) NSString *bookId;
|
||||
@property (nonatomic, assign) NSUInteger chapterIndex; // 章节索引
|
||||
@property (nonatomic, assign) NSUInteger pageIndex; // 章内页码
|
||||
@property (nonatomic, assign) NSUInteger charIndex; // 章内字符偏移
|
||||
@property (nonatomic, assign) CGFloat scrollOffset; // 滚动偏移
|
||||
@property (nonatomic, copy) NSString *chapterId;
|
||||
@property (nonatomic, assign) double readPercentage; // 阅读百分比
|
||||
@end
|
||||
```
|
||||
|
||||
### 8.2 保存时机
|
||||
|
||||
```objc
|
||||
// 1. 每次翻页
|
||||
- (void)didFlipPage {
|
||||
// ...
|
||||
[self _saveReadingProgressAndIsAsync:YES];
|
||||
}
|
||||
|
||||
// 2. 30 秒定时器
|
||||
_progressSaveTimer = [NSTimer scheduledTimerWithTimeInterval:30.0
|
||||
target:self
|
||||
selector:@selector(_periodicProgressSave)
|
||||
userInfo:nil
|
||||
repeats:YES];
|
||||
|
||||
// 3. 退出时同步保存
|
||||
- (void)_periodicProgressSave {
|
||||
[self _saveReadingProgressAndIsAsync:YES];
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3 保存格式
|
||||
|
||||
```objc
|
||||
- (void)_saveReadingProgressAndIsAsync:(BOOL)isAsync {
|
||||
NSDictionary *dict = @{
|
||||
@"bookId": progress.bookId ?: @"",
|
||||
@"chapterIndex": @(progress.chapterIndex),
|
||||
@"pageIndex": @(progress.pageIndex),
|
||||
@"charIndex": @(progress.charIndex),
|
||||
@"scrollOffset": @(progress.scrollOffset),
|
||||
@"chapterId": progress.chapterId ?: @"",
|
||||
@"readPercentage": @(progress.readPercentage),
|
||||
@"timestamp": @([[NSDate date] timeIntervalSince1970]),
|
||||
};
|
||||
|
||||
if (isAsync) {
|
||||
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
|
||||
[[NSUserDefaults standardUserDefaults] setObject:dict forKey:key];
|
||||
[[NSUserDefaults standardUserDefaults] synchronize];
|
||||
});
|
||||
} else {
|
||||
[[NSUserDefaults standardUserDefaults] setObject:dict forKey:key];
|
||||
[[NSUserDefaults standardUserDefaults] synchronize];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**位置恢复精度**:`charIndex` 是主锚点。页面变化(字号、屏幕尺寸)不影响恢复,因为字符偏移量是稳定的。
|
||||
|
||||
---
|
||||
|
||||
## 9. 内存占用模型
|
||||
|
||||
### 9.1 单章内存估算
|
||||
|
||||
假设一章 EPUB 约 5000 字符:
|
||||
|
||||
| 组件 | 估算大小 | 说明 |
|
||||
|------|----------|------|
|
||||
| `typesetAttributedString` | ~200-500 KB | 含字体、段落样式、图片附件 |
|
||||
| `WRCoreTextLayouter` | ~50-100 KB | CTTypesetter + CTFramesetter |
|
||||
| `pageRanges` | ~1-5 KB | NSRange 数组 |
|
||||
| 高亮/下划线/书签 | ~1-10 KB | 取决于标注数量 |
|
||||
| 图片缓存 | ~0-2 MB | 取决于章内图片数量 |
|
||||
| **单章合计** | **~0.3-3 MB** | 图片是主要变量 |
|
||||
|
||||
### 9.2 全书内存估算
|
||||
|
||||
WXRead 的内存占用 = 当前章 + 预取章(±1)+ 系统开销:
|
||||
|
||||
| 场景 | 缓存章节数 | 估算内存 |
|
||||
|------|------------|----------|
|
||||
| 正常阅读 | 1(当前)+ 2(预取)= 3 章 | ~1-9 MB |
|
||||
| 内存警告后 | 1 章(仅当前) | ~0.3-3 MB |
|
||||
| 快速连续翻页 | 最多 3 章(串行加载限制) | ~1-9 MB |
|
||||
|
||||
**对比**:如果持有全书数据(500 章 × 1 MB/章 = 500 MB),WXRead 的策略将其控制在个位数 MB。
|
||||
|
||||
---
|
||||
|
||||
## 10. 策略总结
|
||||
|
||||
| 策略 | 实现 | 效果 |
|
||||
|------|------|------|
|
||||
| **章节级缓存** | `NSMutableDictionary<NSNumber *, WRChapterData *>` | 内存与阅读位置绑定,不随全书增长 |
|
||||
| **手动淘汰** | 内存警告时清空非当前章 | 确定性释放,当前章不中断 |
|
||||
| **串行加载** | `com.weread.chapterload` 队列 | 避免并发加载内存峰值叠加 |
|
||||
| **±1 预取** | 翻页后异步预取相邻章 | 平衡流畅性与内存占用 |
|
||||
| **图片 NSCache** | `countLimit = 50` | 图片自动淘汰,章节释放时一起释放 |
|
||||
| **C 对象显式释放** | `dealloc` 中 `CFRelease` | 防止 CoreText 内存泄漏 |
|
||||
| **不分页全书** | 按章节独立分页 | 避免持有全书页范围数组 |
|
||||
| **不持有全书索引** | 无全局 `RDEPUBTextIndexTable` 等价物 | 按需查询,非常驻 |
|
||||
|
||||
---
|
||||
|
||||
## 附录:WXRead 关键文件索引
|
||||
|
||||
| 文件 | 职责 | 内存相关 |
|
||||
|------|------|----------|
|
||||
| `WRReaderViewController.m` | 阅读器主控制器 | `chapterDataCache`、内存警告处理、预取 |
|
||||
| `WRChapterData.h/m` | 章节数据模型 | 持有 `typesetAttributedString`、`layouter` |
|
||||
| `WRCoreTextLayouter.h/m` | CoreText 排版器 | 图片缓存 `NSCache(countLimit=50)`、C 对象释放 |
|
||||
| `WRChapterPageCount.h/m` | 章节分页计算 | `pageRanges` 数组 |
|
||||
| `WRPreloadBookManager.h/m` | 预加载管理 | 磁盘缓存管理,不占运行时内存 |
|
||||
| `WREpubTypesetter.m` | EPUB 排版器 | 单章排版,不持有全书状态 |
|
||||
|
||||
---
|
||||
|
||||
*基于读书 v10.0.3 逆向分析*
|
||||
*文档创建:2026-06-02*
|
||||
@@ -0,0 +1,379 @@
|
||||
# 双层 PageMap 方案:估算 + 精确混合
|
||||
|
||||
> 目标:大书首次打开时,快速给出全书总页数(< 1s),同时保留当前窗口章节的精确分页结果。
|
||||
> 约束:估算值不覆盖已精确分页的 quick-window 章节,不写入磁盘缓存,不伪装成精确 summary。
|
||||
|
||||
---
|
||||
|
||||
## 1. 问题
|
||||
|
||||
当前 `paginateMetadataOnly` 对全书逐章做完整渲染 + CoreText 分页 + 写磁盘缓存。2470 章在真机上串行约 867s(并发 6 约 256s)。在此期间:
|
||||
|
||||
- 总页数持续变化(从 partial 到 full)
|
||||
- 进度条不准确
|
||||
- 目录页码缺失
|
||||
|
||||
用户看到的是"总页数从 0 慢慢涨到 18595",体验差。
|
||||
|
||||
---
|
||||
|
||||
## 2. 方案概述
|
||||
|
||||
```
|
||||
quick open(现有,不变)
|
||||
→ 当前窗口 3-5 章精确分页
|
||||
→ applyBookPageMap(partialMap, restoreLocation:)
|
||||
→ 用户可立即阅读
|
||||
|
||||
估算补全(新增,< 1s)
|
||||
→ 遍历所有非窗口章节,快速估算 pageCount
|
||||
→ 与 quick open 的精确值合并为 mixedMap
|
||||
→ refreshBookPageMapInPlace(mixedMap)
|
||||
→ 用户立即看到接近真实的全书总页数
|
||||
|
||||
精确解析(现有,后台逐步)
|
||||
→ paginateMetadataOnly 逐章精确渲染
|
||||
→ 每 32 章用精确值替换 mixedMap 中的估算值
|
||||
→ refreshBookPageMapInPlace(updatedMap)
|
||||
→ 总页数微调至精确值
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 核心约束
|
||||
|
||||
### 3.1 不覆盖已精确分页的章节
|
||||
|
||||
quick open 已经对当前窗口章(`initialWindowSpineIndices` 返回的 3-5 章)做了完整渲染 + CoreText 分页,生成了精确的 `partialMap`。这个 map 立刻用于 `applyBookPageMap(restoreLocation:)` 恢复阅读位置。
|
||||
|
||||
估算层**不能**用全书估算 map 整体替换这个 partial map,否则:
|
||||
- 当前窗口章的精确 pageCount 被冲掉
|
||||
- `pageNumber(for:)` 的绝对页号映射漂移
|
||||
- `prepareOnDemandChapter(forAbsolutePageNumber:)` 定位出错
|
||||
|
||||
正确做法:**精确局部 + 估算尾部**的混合 map。窗口章保留精确值,其余章节填估算值。
|
||||
|
||||
### 3.2 估算值只存内存,不落盘
|
||||
|
||||
估算的 pageCount 必须**只存在于内存态的 BookPageMap**中,不能写入:
|
||||
- `RDEPUBChapterSummaryDiskCache`(否则 loader 误以为有精确 pageRanges 可复用)
|
||||
- `RDEPUBPageCountCache`(否则 loader 跳过完整分页)
|
||||
- 任何磁盘持久化存储
|
||||
|
||||
后续 `paginateMetadataOnly` 的精确结果会逐章替换估算值,并写入磁盘缓存。磁盘上永远只有精确数据。
|
||||
|
||||
### 3.3 估算方法必须足够快
|
||||
|
||||
目标:< 1s 完成全书估算(2470 章)。每章允许 ~0.4ms。
|
||||
|
||||
可行方法:遍历 spine,对每章只做 HTML 纯文本提取(`NSString` 去标签),用 `textLength / estimatedCharsPerPage` 得到页数。不做 CSS 渲染、不做NSAttributedString 构建、不做 CoreText 分页。
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据流
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ quick open (现有) │
|
||||
│ 当前窗口章 → 精确 partialMap │
|
||||
└──────────────┬──────────────┘
|
||||
│
|
||||
┌──────────────▼──────────────┐
|
||||
│ estimateRemainingChapters │
|
||||
│ 非窗口章 → 估算 pageCount │
|
||||
└──────────────┬──────────────┘
|
||||
│
|
||||
┌──────────────▼──────────────┐
|
||||
│ mergePreciseAndEstimated │
|
||||
│ 精确窗口 + 估算尾部 → mixedMap │
|
||||
└──────────────┬──────────────┘
|
||||
│
|
||||
┌──────────────▼──────────────┐
|
||||
│ refreshBookPageMapInPlace │
|
||||
│ 用户立即看到全书总页数 │
|
||||
└──────────────┬──────────────┘
|
||||
│
|
||||
┌──────────────▼──────────────┐
|
||||
│ paginateMetadataOnly (现有) │
|
||||
│ 逐章精确渲染,替换估算值 │
|
||||
│ 每 32 章刷新一次 │
|
||||
└──────────────┬──────────────┘
|
||||
│
|
||||
┌──────────────▼──────────────┐
|
||||
│ 最终精确 BookPageMap │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 实现细节
|
||||
|
||||
### 5.1 估算方法
|
||||
|
||||
在 `RDEPUBReaderPaginationCoordinator` 中新增:
|
||||
|
||||
```swift
|
||||
/// 快速估算非窗口章节的页数,只使用纯文本长度,不渲染。
|
||||
/// 返回 spineIndex -> estimatedPageCount 的字典。
|
||||
private func estimateChapterPageCounts(
|
||||
for spineIndices: [Int],
|
||||
publication: RDEPUBPublication,
|
||||
parser: RDEPUBParser,
|
||||
pageSize: CGSize,
|
||||
style: RDEPUBTextRenderStyle
|
||||
) -> [Int: Int] {
|
||||
let charsPerPage = estimatedCharsPerPage(pageSize: pageSize, style: style)
|
||||
var result: [Int: Int] = [:]
|
||||
for spineIndex in spineIndices {
|
||||
let item = publication.spine[spineIndex]
|
||||
guard item.linear,
|
||||
item.mediaType.contains("html") || item.mediaType.contains("xhtml"),
|
||||
let htmlString = parser.htmlString(forRelativePath: item.href) else {
|
||||
continue
|
||||
}
|
||||
// 快速提取纯文本:去 HTML 标签
|
||||
let plainText = htmlString
|
||||
.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let textLength = plainText.count
|
||||
let estimatedPages = max(1, Int(ceil(Double(textLength) / Double(charsPerPage))))
|
||||
result[spineIndex] = estimatedPages
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// 根据排版参数估算每页字符数。
|
||||
private func estimatedCharsPerPage(
|
||||
pageSize: CGSize,
|
||||
style: RDEPUBTextRenderStyle
|
||||
) -> Double {
|
||||
let layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
|
||||
let columns = max(1, layoutConfig.numberOfColumns)
|
||||
let columnGap = layoutConfig.columnGap
|
||||
let insets = layoutConfig.edgeInsets
|
||||
let usableWidth = pageSize.width - insets.left - insets.right - CGFloat(columns - 1) * columnGap
|
||||
let usableHeight = pageSize.height - insets.top - insets.bottom
|
||||
let lineHeight = style.fontSize * style.lineHeightMultiple
|
||||
let linesPerPage = Int(usableHeight / lineHeight) * columns
|
||||
// 中文平均字符宽度约 0.5 * fontSize,英文约 0.6 * fontSize
|
||||
// 取中位数 0.55 作为粗估
|
||||
let avgCharWidth = style.fontSize * 0.55
|
||||
let charsPerLine = max(1, Int(usableWidth / avgCharWidth))
|
||||
return Double(linesPerPage * charsPerLine)
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 混合 Map 构建
|
||||
|
||||
```swift
|
||||
/// 构建混合 BookPageMap:窗口章用精确值,其余用估算值。
|
||||
private func buildMixedPageMap(
|
||||
preciseEntries: [Int: RDEPUBBookPageMapEntry], // quick open 的精确结果
|
||||
estimatedPageCounts: [Int: Int], // 估算的 pageCount
|
||||
catalog: [(key: RDEPUBChapterCacheKey, spineIndex: Int, href: String, title: String)]
|
||||
) -> RDEPUBBookPageMap {
|
||||
var builder = RDEPUBBookPageMap.Builder()
|
||||
for item in catalog {
|
||||
if let precise = preciseEntries[item.spineIndex] {
|
||||
// 窗口章:用精确值
|
||||
builder.add(
|
||||
spineIndex: item.spineIndex,
|
||||
href: item.href,
|
||||
title: item.title,
|
||||
pageCount: precise.pageCount,
|
||||
fragmentOffsets: precise.fragmentOffsets
|
||||
)
|
||||
} else if let estimatedCount = estimatedPageCounts[item.spineIndex] {
|
||||
// 非窗口章:用估算值,不写 fragmentOffsets
|
||||
builder.add(
|
||||
spineIndex: item.spineIndex,
|
||||
href: item.href,
|
||||
title: item.title,
|
||||
pageCount: estimatedCount,
|
||||
fragmentOffsets: [:]
|
||||
)
|
||||
}
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 集成点
|
||||
|
||||
在 `paginateTextPublication` 中,quick open 完成后、`paginateMetadataOnly` 之前插入:
|
||||
|
||||
```swift
|
||||
// 现有:quick open 生成精确 partialMap
|
||||
let quickWindowChapters = try loadInitialRuntimeChapters(...)
|
||||
let partialMap = makePartialPageMap(from: quickWindowChapters)
|
||||
runtime.applyBookPageMap(partialMap, restoreLocation: restoreLocation)
|
||||
|
||||
// 新增:估算剩余章节,构建混合 map
|
||||
let windowSpineIndices = Set(quickWindowChapters.map(\.spineIndex))
|
||||
let allBuildable = allBuildableSpineIndices(in: publication)
|
||||
let remainingSpineIndices = allBuildable.filter { !windowSpineIndices.contains($0) }
|
||||
let estimatedCounts = estimateChapterPageCounts(
|
||||
for: remainingSpineIndices,
|
||||
publication: publication,
|
||||
parser: parser,
|
||||
pageSize: pageSize,
|
||||
style: style
|
||||
)
|
||||
let preciseEntries = makePreciseEntries(from: quickWindowChapters)
|
||||
let catalog = allBuildable.map { ... } // 同 paginateMetadataOnly 的 catalog 构建
|
||||
let mixedMap = buildMixedPageMap(
|
||||
preciseEntries: preciseEntries,
|
||||
estimatedPageCounts: estimatedCounts,
|
||||
catalog: catalog
|
||||
)
|
||||
DispatchQueue.main.async {
|
||||
runtime.refreshBookPageMapInPlace(mixedMap)
|
||||
}
|
||||
|
||||
// 现有:后台精确解析(会逐章替换估算值)
|
||||
paginateMetadataOnly(token: token, restoreLocation: restoreLocation)
|
||||
```
|
||||
|
||||
### 5.4 精确值替换估算值
|
||||
|
||||
`paginateMetadataOnly` 现有逻辑不需要大改。只需确保:
|
||||
|
||||
1. `summariesBySpineIndex` 初始包含缓存命中 + 估算值(作为 fallback)
|
||||
2. 每完成一章精确解析,用精确值覆盖该 spineIndex 的条目
|
||||
3. `buildPageMap` 时,精确值自然替换估算值
|
||||
|
||||
具体改动:在 `paginateMetadataOnly` 的 `cachedSummaries` 之后,把估算值也注入 `summariesBySpineIndex`,但标记为估算(比如用一个 `Set<Int>` 记录哪些是估算值)。精确解析完成后,精确值会自动覆盖同 key 的估算值。
|
||||
|
||||
```swift
|
||||
// 在 paginateMetadataOnly 内部,cachedSummaries 初始化后:
|
||||
var summariesBySpineIndex = cachedSummaries
|
||||
|
||||
// 注入估算值(仅填充未缓存的章节)
|
||||
for (spineIndex, pageCount) in estimatedPageCounts {
|
||||
if !cachedSpineIndices.contains(spineIndex) {
|
||||
// 用估算值创建一个最小 summary,只填 pageCount
|
||||
// 不写 pageRanges、不写 fragmentOffsets
|
||||
summariesBySpineIndex[spineIndex] = RDEPUBChapterSummary.estimated(
|
||||
pageCount: pageCount
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
需要在 `RDEPUBChapterSummary` 上新增一个工厂方法:
|
||||
|
||||
```swift
|
||||
extension RDEPUBChapterSummary {
|
||||
/// 创建估算用的最小摘要,只含 pageCount,不含精确 pageRanges。
|
||||
/// 此摘要不写入磁盘缓存。
|
||||
static func estimated(pageCount: Int) -> RDEPUBChapterSummary {
|
||||
RDEPUBChapterSummary(
|
||||
pageRanges: [],
|
||||
pageCount: pageCount,
|
||||
fragmentOffsets: [:],
|
||||
renderSignature: "estimated",
|
||||
schemaVersion: currentSchemaVersion,
|
||||
chapterContentHash: "estimated",
|
||||
pageMetadataList: []
|
||||
)
|
||||
}
|
||||
|
||||
var isEstimated: Bool { renderSignature == "estimated" }
|
||||
}
|
||||
```
|
||||
|
||||
### 5.5 磁盘缓存保护
|
||||
|
||||
在 `paginateMetadataOnly` 的写盘逻辑中,跳过估算 summary:
|
||||
|
||||
```swift
|
||||
// 写盘前检查
|
||||
if !summary.isEstimated {
|
||||
summaryDiskCache?.write(summary: summary, for: cacheKey)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 需要修改的文件
|
||||
|
||||
| 文件 | 改动 | 行数估算 |
|
||||
|---|---|---|
|
||||
| `RDEPUBReaderPaginationCoordinator.swift` | 新增 `estimateChapterPageCounts`、`estimatedCharsPerPage`、`buildMixedPageMap`;在 `paginateTextPublication` 中集成;`paginateMetadataOnly` 中注入估算值并跳过估算写盘 | ~80 行 |
|
||||
| `RDEPUBChapterSummary` (in `RDEPUBChapterSummaryDiskCache.swift`) | 新增 `estimated(pageCount:)` 工厂方法和 `isEstimated` 属性 | ~10 行 |
|
||||
| `RDEPUBBookPageMap` (in `RDEPUBBookPageMap.swift`) | 确认 `Builder.add()` 支持 `fragmentOffsets: [:]`(空字典) | 可能 0 行(已支持) |
|
||||
|
||||
---
|
||||
|
||||
## 7. 不需要修改的文件
|
||||
|
||||
| 文件 | 原因 |
|
||||
|---|---|
|
||||
| `RDEPUBChapterLoader` | 没有精确缓存时自动走完整渲染 + 分页,已有 fallback |
|
||||
| `RDEPUBChapterRuntimeStore` | 不涉及 |
|
||||
| `RDEPUBReaderRuntime` | `refreshBookPageMapInPlace` 已支持替换式刷新 |
|
||||
| `RDEPUBChapterWindowCoordinator` | 不涉及 |
|
||||
| `RDReaderView` | 不涉及 |
|
||||
| `RDEPUBReaderConfiguration` | 不涉及 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 验证方式
|
||||
|
||||
### 8.1 单元验证
|
||||
|
||||
- 打开《凡人修仙传》,记录从 `applyBookPageMap` 到 `refreshBookPageMapInPlace(mixedMap)` 的耗时,应 < 1s
|
||||
- 验证 mixedMap 中窗口章的 pageCount 与精确 partialMap 一致
|
||||
- 验证 mixedMap 中非窗口章的 pageCount 是估算值(与精确值有偏差但量级正确)
|
||||
- 验证 `paginateMetadataOnly` 完成后,所有章节的 pageCount 被精确值替换
|
||||
|
||||
### 8.2 UI 验证
|
||||
|
||||
- 首次打开大书,总页数在 1s 内从 0 跳到接近真实值(如 18000+),而非逐步增长
|
||||
- 当前窗口章的翻页、位置恢复不受影响
|
||||
- 后台精确解析期间,总页数微调(如 18000 → 18595),无大幅跳变
|
||||
- 二次打开仍走精确缓存路径,不走估算
|
||||
|
||||
### 8.3 基准对比
|
||||
|
||||
| 指标 | 当前实现 | 双层方案 |
|
||||
|---|---|---|
|
||||
| 首次显示全书总页数 | ~256s(并发 6) | < 1s |
|
||||
| 总页数精度 | 100%(精确) | 首屏 ~95%(估算),后台 100% |
|
||||
| 当前章体验 | 不受影响 | 不受影响 |
|
||||
| 磁盘缓存 | 只有精确值 | 只有精确值(估算不落盘) |
|
||||
|
||||
---
|
||||
|
||||
## 9. 风险与缓解
|
||||
|
||||
### 9.1 估算页数与精确页数偏差大
|
||||
|
||||
**风险**:某些章节(图片多、CSS 复杂、中英文混排)的估算页数可能与精确值偏差 20%+。
|
||||
|
||||
**缓解**:
|
||||
- 估算只影响总页数显示,不影响阅读体验
|
||||
- 后台精确解析会逐步替换,偏差是暂时的
|
||||
- 可在 UI 上标注"页数计算中..."降低用户预期
|
||||
|
||||
### 9.2 纯文本提取不准确
|
||||
|
||||
**风险**:`<script>`、`<style>` 标签内的文本被误计入,导致估算偏高。
|
||||
|
||||
**缓解**:正则去标签时先移除 `<script>...</script>` 和 `<style>...</style>` 块。
|
||||
|
||||
### 9.3 估算值干扰精确值的合并
|
||||
|
||||
**风险**:`paginateMetadataOnly` 注入估算值后,如果某章精确解析失败,该章会保留估算值而非报错。
|
||||
|
||||
**缓解**:在 `buildPageMap` 中检查 `isEstimated`,对仍然为估算值的章节标记为 unknown(页数 0),而非保留可能不准确的估算值。
|
||||
|
||||
---
|
||||
|
||||
## 10. 实施顺序
|
||||
|
||||
1. `RDEPUBChapterSummary` 新增 `estimated(pageCount:)` 和 `isEstimated`
|
||||
2. `RDEPUBReaderPaginationCoordinator` 新增估算方法
|
||||
3. 在 `paginateTextPublication` 的 quick open 之后、`paginateMetadataOnly` 之前插入混合 map 构建
|
||||
4. `paginateMetadataOnly` 中注入估算值、跳过估算写盘
|
||||
5. 真机验证《凡人修仙传》的首屏总页数显示速度和精度
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,438 @@
|
||||
# 《凡人修仙传》快速进入阅读器方案
|
||||
|
||||
> 适用场景:`textReflowable` 路径打开超大正文 EPUB,典型样本为《凡人修仙传》精校版全本。
|
||||
> 目标:把“进入阅读器前必须等全书分页完成”改成“先快速可读,再后台补齐全书能力”。
|
||||
> 结论先行:当前首屏慢的主因不是单章分页太慢,而是 **打开流程要求先完成全书 `RDEPUBTextBookBuilder.build()`**。
|
||||
|
||||
---
|
||||
|
||||
## 1. 当前慢在哪里
|
||||
|
||||
结合当前代码,打开 reflowable EPUB 的主链路是:
|
||||
|
||||
```text
|
||||
RDEPUBReaderController.viewDidLoad
|
||||
-> RDEPUBReaderLoadCoordinator.loadPublication()
|
||||
-> applyParsedPublication(...)
|
||||
-> RDEPUBReaderPaginationCoordinator.paginatePublication()
|
||||
-> if publication.readingProfile == .textReflowable
|
||||
-> RDEPUBTextBookBuilder.build(...)
|
||||
-> 遍历全部 spine item
|
||||
-> 每章 render + paginate
|
||||
-> 汇总成完整 RDEPUBTextBook
|
||||
-> applyTextBook(...)
|
||||
-> readerView.reloadData()
|
||||
-> restoreReadingLocation(...)
|
||||
```
|
||||
|
||||
关键事实:
|
||||
|
||||
- [RDEPUBReaderPaginationCoordinator.swift](/Users/shenlei/Work/ReadViewSDK/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift) 在 `textReflowable` 路径里会先 `showLoading()`,然后后台执行 `builder.build(...)`,构建完成前不会进入正文。
|
||||
- [RDEPUBTextBookBuilder.swift](/Users/shenlei/Work/ReadViewSDK/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookBuilder.swift) 的 `build()` 会遍历全部 `publication.spine`,逐章完成:
|
||||
- HTML 读取
|
||||
- `DTCoreText` 渲染
|
||||
- CoreText 分页
|
||||
- 页面模型组装
|
||||
- 全书 `RDEPUBTextBook` 汇总
|
||||
- 也就是说,对《凡人修仙传》这种章节数多、正文长的大书,当前实际是“**全书构建完成后才能看到第一页**”。
|
||||
|
||||
这条链路的体验问题是:
|
||||
|
||||
- 首屏等待时间和“全书总字数/总章节数”线性相关,而不是和“当前阅读位置附近内容规模”相关。
|
||||
- 即使用户只想看第一页,也要先支付整本书的 render + paginate 成本。
|
||||
- 恢复到历史位置时也是同样问题,因为当前恢复逻辑依赖完整 `RDEPUBTextBook` 的页码与位置映射。
|
||||
|
||||
---
|
||||
|
||||
## 2. 方案目标
|
||||
|
||||
### 用户目标
|
||||
|
||||
- 点击书籍后,阅读器应尽快进入正文页,而不是长时间停留在 loading。
|
||||
- 即使全书尚未构建完成,也至少能:
|
||||
- 看到当前章节
|
||||
- 翻当前章节内的页
|
||||
- 恢复到“接近上次阅读位置”的章节
|
||||
|
||||
### 技术目标
|
||||
|
||||
- 首屏进入从“全书 ready”改成“当前章节 ready”。
|
||||
- 全书构建改为后台增量完成。
|
||||
- 不破坏现有 `RDEPUBReaderController` / `RDReaderView` 的主公开 API。
|
||||
- 保留当前 `RDEPUBTextBookCache` 的价值,但把缓存粒度从“整本书一次命中”扩展到“单章可命中”。
|
||||
|
||||
### 首期验收指标
|
||||
|
||||
- 大书首次打开时,进入正文的等待时间显著短于当前实现。
|
||||
- 首屏进入只依赖“当前章节”或“当前位置附近章节”构建完成。
|
||||
- 后台继续构建剩余章节时,不阻塞阅读。
|
||||
|
||||
---
|
||||
|
||||
## 3. 推荐方案:两阶段进入 + 章节级增量构建
|
||||
|
||||
## 阶段 A:快速进入
|
||||
|
||||
打开书后只做这些事情:
|
||||
|
||||
1. 解析 EPUB 基础元数据、spine、TOC
|
||||
2. 确定恢复位置对应的 `spineIndex`
|
||||
3. 只构建当前章节,必要时附带相邻 `±1` 章
|
||||
4. 先生成一个“局部 TextBook / 局部 Snapshot”
|
||||
5. 立即进入阅读器并恢复到该章节内位置
|
||||
|
||||
这一阶段的原则是:
|
||||
|
||||
- 先解决“能进入”
|
||||
- 不要求立刻具备全书搜索、全书目录页码、全书绝对页码精度
|
||||
|
||||
## 阶段 B:后台补全
|
||||
|
||||
进入正文后,再后台串行完成:
|
||||
|
||||
1. 当前章节相邻章节预构建
|
||||
2. 剩余章节逐步构建
|
||||
3. 持续补齐全书:
|
||||
- `RDEPUBTextBook.chapters`
|
||||
- `RDEPUBTextBook.pages`
|
||||
- `RDEPUBTextIndexTable`
|
||||
- 全书页码/位置映射
|
||||
4. 构建完成后静默替换到完整模型
|
||||
|
||||
这样用户的体感是:
|
||||
|
||||
- 很快进书
|
||||
- 越读越完整
|
||||
- 而不是“先等很久,之后一次性全有”
|
||||
|
||||
---
|
||||
|
||||
## 4. 为什么这是最快可落地的方案
|
||||
|
||||
相比继续优化单次 `build()` 的 CPU 细节,这个方案收益更直接:
|
||||
|
||||
- 《凡人修仙传》的核心问题是“全书串行工作量太大”,不是“当前章节单章慢到不可接受”。
|
||||
- 当前代码已经天然按“章节”组织:
|
||||
- `publication.spine`
|
||||
- `RDEPUBTextChapter`
|
||||
- `RDEPUBChapterData`
|
||||
- 每章 `render + paginate`
|
||||
- [阅读器功能开发计划.md](/Users/shenlei/Work/ReadViewSDK/Doc/阅读器功能开发计划.md) 里也已经明确把“增量构建”列为方向,说明这条路和现有架构一致。
|
||||
|
||||
换句话说,这不是推翻重做,而是把现有 `RDEPUBTextBookBuilder.build()` 从“必须一次性跑完整本书”拆成“可按章节单独执行”。
|
||||
|
||||
---
|
||||
|
||||
## 5. 具体改造点
|
||||
|
||||
## 5.1 构建层:把全书构建拆成章节级能力
|
||||
|
||||
当前:
|
||||
|
||||
- [RDEPUBTextBookBuilder.swift](/Users/shenlei/Work/ReadViewSDK/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookBuilder.swift) 只有全书 `build(...)`
|
||||
|
||||
建议新增:
|
||||
|
||||
```swift
|
||||
func buildChapter(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
spineIndex: Int,
|
||||
pageSize: CGSize,
|
||||
style: RDEPUBTextRenderStyle
|
||||
) throws -> RDEPUBTextChapterBuildResult
|
||||
```
|
||||
|
||||
建议返回:
|
||||
|
||||
- `chapter: RDEPUBTextChapter`
|
||||
- `paginationDiagnostic`
|
||||
- `resourceDiagnostics`
|
||||
- `performanceSample`
|
||||
|
||||
这样做的好处:
|
||||
|
||||
- 章节构建逻辑可以和现有 `build()` 共享
|
||||
- 后续“首屏只构建一章”和“后台补全整本书”都走同一套实现
|
||||
|
||||
## 5.2 数据层:允许 TextBook 从“不完整”逐步变完整
|
||||
|
||||
当前:
|
||||
|
||||
- `RDEPUBTextBook` 默认假设 `chapters/pages/indexTable` 已经是完整全书
|
||||
|
||||
建议新增一个运行时模型,例如:
|
||||
|
||||
```swift
|
||||
final class RDEPUBIncrementalTextBookStore
|
||||
```
|
||||
|
||||
职责:
|
||||
|
||||
- 保存已完成构建的章节
|
||||
- 维护 `spineIndex -> chapter` 映射
|
||||
- 动态生成当前可用的 pages snapshot
|
||||
- 在“部分章节可用”时提供章节内阅读支持
|
||||
|
||||
建议暴露能力:
|
||||
|
||||
- `chapter(forSpineIndex:)`
|
||||
- `availablePagesSnapshot()`
|
||||
- `merge(chapter:)`
|
||||
- `isChapterReady(_:)`
|
||||
- `readyChapterRange(around:)`
|
||||
|
||||
原因:
|
||||
|
||||
- `RDEPUBTextBook` 更适合“完整产物”
|
||||
- 增量加载需要一个“半成品但可读”的状态容器
|
||||
|
||||
## 5.3 分页协调层:把一次性 loading 改成两阶段 loading
|
||||
|
||||
当前:
|
||||
|
||||
- [RDEPUBReaderPaginationCoordinator.swift](/Users/shenlei/Work/ReadViewSDK/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift) 中 `paginatePublication()` 直接把全书构建作为进入阅读器前置条件
|
||||
|
||||
建议改成:
|
||||
|
||||
### Step 1:首次只构建目标章节
|
||||
|
||||
- 根据 `restoreLocation` 算出目标 `spineIndex`
|
||||
- 若无恢复位置,默认 `spineIndex = 0`
|
||||
- 只构建该章,必要时加 `±1` 章
|
||||
|
||||
### Step 2:先应用局部 snapshot
|
||||
|
||||
- `readerView.reloadData()`
|
||||
- 允许用户开始阅读
|
||||
- tool chrome 可先显示,但某些依赖全书的能力先降级
|
||||
|
||||
### Step 3:后台继续全书补建
|
||||
|
||||
- 串行队列逐章构建剩余章节
|
||||
- 每完成一章,就 merge 进 store
|
||||
- 必要时再刷新目录页码、搜索索引、页码总数
|
||||
|
||||
## 5.4 位置恢复:首期优先恢复“章节”,二期补齐“页内精度”
|
||||
|
||||
当前:
|
||||
|
||||
- 恢复逻辑大量依赖完整 `RDEPUBTextBook` 的 pageNumber / location 映射
|
||||
|
||||
首期建议:
|
||||
|
||||
- 打开时先把恢复目标收敛到 `spineIndex + fragment/rangeAnchor`
|
||||
- 只要目标章节 ready,就先进入该章节
|
||||
- 若该章节内页内恢复信息尚未完整,先恢复到该章节接近位置
|
||||
|
||||
这样能显著减少“为了恢复精确页码,必须先构建全书”的耦合。
|
||||
|
||||
## 5.5 缓存层:从整书缓存扩展到单章缓存
|
||||
|
||||
当前:
|
||||
|
||||
- `RDEPUBPaginationCacheCoordinator` / `RDEPUBTextBookCache` 更偏整书结果
|
||||
|
||||
建议:
|
||||
|
||||
- 缓存 key 增加 `spineIndex`
|
||||
- 支持单章 page ranges 命中
|
||||
- 首次打开大书时,优先读取:
|
||||
- 当前章节缓存
|
||||
- 相邻章节缓存
|
||||
|
||||
收益:
|
||||
|
||||
- 同一本大书二次打开时,可直接秒开到当前章节
|
||||
- 不用再等待整本书缓存完全重建
|
||||
|
||||
---
|
||||
|
||||
## 6. 首期最小可交付版本
|
||||
|
||||
为了最快解决《凡人修仙传》慢启动,建议首期只做下面这些:
|
||||
|
||||
1. 为 `RDEPUBTextBookBuilder` 抽出章节级构建接口
|
||||
2. `RDEPUBReaderPaginationCoordinator` 首次打开时只构建目标章节
|
||||
3. 用“局部 pages snapshot”驱动 `RDReaderView`
|
||||
4. 后台串行构建剩余章节
|
||||
5. 当前章节缓存命中优先
|
||||
|
||||
首期明确不做:
|
||||
|
||||
- 全书搜索实时可用
|
||||
- 全书总页数一开始就准确
|
||||
- 目录面板一开始就显示所有章节页码
|
||||
|
||||
这些能力可以在后台补建完成后逐步恢复。
|
||||
|
||||
---
|
||||
|
||||
## 7. UI 与交互建议
|
||||
|
||||
为了让“快速进入但后台仍在准备”体验自然,建议增加轻量提示:
|
||||
|
||||
### 阅读器内状态提示
|
||||
|
||||
- 首屏进入后不再是全屏 loading
|
||||
- 改为顶部或底部轻提示:
|
||||
- `正在准备后续章节...`
|
||||
- `已进入阅读,可继续翻页`
|
||||
|
||||
### 未就绪章节翻页策略
|
||||
|
||||
当用户快速翻到尚未构建的章节时:
|
||||
|
||||
- 优先命中后台预构建结果
|
||||
- 如果还未就绪:
|
||||
- 显示章节级 loading skeleton
|
||||
- 不要退回全屏 blocking loading
|
||||
|
||||
### 目录与搜索降级
|
||||
|
||||
- 目录先显示标题,不强依赖页码
|
||||
- 搜索面板可在全书索引未完成时显示:
|
||||
- `正文已可阅读,全文搜索仍在准备中`
|
||||
|
||||
---
|
||||
|
||||
## 8. 风险与应对
|
||||
|
||||
## 风险 1:现有很多 API 默认依赖完整 TextBook
|
||||
|
||||
例如:
|
||||
|
||||
- `pageNumber(for:)`
|
||||
- `location(forPageNumber:)`
|
||||
- `chapterData(forPageNumber:)`
|
||||
|
||||
应对:
|
||||
|
||||
- 首期不要强行让这些 API 在“半本书”状态下也完整成立
|
||||
- 先给增量模式增加“可用性边界”
|
||||
- 在运行时根据 `isFullyBuilt` / `isChapterReady` 分流
|
||||
|
||||
## 风险 2:局部 snapshot 和完整 snapshot 切换时页码跳动
|
||||
|
||||
应对:
|
||||
|
||||
- 局部模式优先用章节内位置恢复,不强调绝对页码稳定
|
||||
- 后台切换到完整模型时,按 `location` 而不是按 `pageNumber` 恢复
|
||||
|
||||
## 风险 3:后台补建打断当前交互
|
||||
|
||||
应对:
|
||||
|
||||
- 章节构建使用串行后台队列
|
||||
- UI 合并更新节流,例如“每完成 N 章再刷新一次目录状态”
|
||||
- 当前可见章节不重复重建
|
||||
|
||||
---
|
||||
|
||||
## 9. 推荐实施顺序
|
||||
|
||||
### Phase 1:快速进入 MVP
|
||||
|
||||
- 抽 `buildChapter(...)`
|
||||
- 首次打开只构建目标章节
|
||||
- 局部 snapshot 驱动阅读器
|
||||
- 后台补建剩余章节
|
||||
|
||||
### Phase 2:缓存加速
|
||||
|
||||
- 单章分页缓存
|
||||
- 恢复位置附近章节优先命中
|
||||
- 二次打开大书进一步提速
|
||||
|
||||
### Phase 3:全书能力渐进恢复
|
||||
|
||||
- 全书搜索索引后台构建
|
||||
- TOC 页码后台补齐
|
||||
- 全书总页数在构建完成后更新
|
||||
|
||||
---
|
||||
|
||||
## 10. 建议验收方式
|
||||
|
||||
建议拿《凡人修仙传》精校版全本做专项验证,记录以下指标:
|
||||
|
||||
- 点击书籍到首屏可读的耗时
|
||||
- 点击书籍到全书构建完成的耗时
|
||||
- 首屏进入时用户是否已经可以翻当前章节
|
||||
- 翻到下一章节时是否出现明显阻塞
|
||||
- 二次打开同一本书时是否明显快于首次
|
||||
|
||||
重点不是只看“总构建时长”,而是看:
|
||||
|
||||
- `Time To First Readable Page`
|
||||
- `Time To Full Book Ready`
|
||||
|
||||
这两个指标在大书场景里要分开看。
|
||||
|
||||
---
|
||||
|
||||
## 11. 最终建议
|
||||
|
||||
对《凡人修仙传》这类超大正文书,最快见效的方案不是继续压榨单次全书分页性能,而是:
|
||||
|
||||
**把阅读器打开流程从“全书先构建完”改成“当前章节先可读,剩余章节后台补齐”。**
|
||||
|
||||
这是当前代码架构下收益最高、侵入性也相对可控的做法,因为:
|
||||
|
||||
- 现有数据天然按章节组织
|
||||
- `RDEPUBTextBookBuilder` 已经具备章节级循环结构
|
||||
- `RDEPUBReaderPaginationCoordinator` 也已经是集中调度入口
|
||||
|
||||
如果只允许做一件事来解决《凡人修仙传》打开慢的问题,我建议优先做:
|
||||
|
||||
**Phase 1:章节级增量构建 + 两阶段进入阅读器。**
|
||||
|
||||
---
|
||||
|
||||
## 12. 当前落地状态(2026-06-02)
|
||||
|
||||
本轮已按 Phase 1 做了首期快速进入优化,当前实现状态如下。
|
||||
|
||||
### 已完成
|
||||
|
||||
- `RDEPUBTextBookBuilder` 已抽出章节级构建接口 `buildChapter(...)`,单章构建复用原有 render、分页、尾页规范化、诊断和分页缓存逻辑。
|
||||
- `RDEPUBReaderPaginationCoordinator` 的 `textReflowable` 路径已改为两阶段:
|
||||
- 第一阶段:按恢复位置优先构建可用章节,并立即应用局部 `RDEPUBTextBook` 进入阅读器。
|
||||
- 第二阶段:后台继续按章节增量构建,但增量结果会先暂存,只在用户空闲时再合并到当前可读内容,最终补齐为完整全书模型。
|
||||
- 快速进入阶段不只尝试单个 spine,而是按离恢复位置最近的可构建 HTML/XHTML spine 逐个尝试,避免封面、版权页、空白扉页导致首屏快速路径落空。
|
||||
- 已修正阅读路径误判:普通静态 SVG 封面不再把整本小说误判为 `webInteractive`,避免错误掉回 `WKWebView` 分页路径。
|
||||
- 首包策略已调整为“目标章节 + 后续 2 章”,默认先提供 3 章连续可读内容。
|
||||
- 后台补齐策略已调整为“每新增 20 章生成一份新的局部结果”,并优先补当前可读窗口之后的章节,再回补前文。
|
||||
- 增量结果不再一生成就立即 `applyTextBook`,而是只保留最近一份 staged `RDEPUBTextBook`,等用户停止翻页且阅读器回到 idle 后再统一合并,减少翻页过程中的 UI reload 和主线程抖动。
|
||||
- 已对后台分页任务做降干扰处理:章节补建切到较低优先级队列,用户刚翻页时后台任务会短暂停让,减少 pageCurl 翻页时的 CPU 抢占。
|
||||
- 后台完整构建失败时,如果局部章节已经成功进入阅读器,则不再把用户退回阻塞式错误流程,只结束 loading;如果局部章节也未成功,则按原错误处理。
|
||||
|
||||
### 仍未完成
|
||||
|
||||
- 还没有引入独立的 `RDEPUBIncrementalTextBookStore`,当前首期实现采用“局部 `RDEPUBTextBook` 先应用,完整 `RDEPUBTextBook` 后替换”的 MVP 路径。
|
||||
- 目录页码、全文搜索、全书总页数仍依赖后台完整构建完成后恢复。
|
||||
- 尚未加入可视化的“后续章节准备中”轻提示。
|
||||
|
||||
### 当前预期效果
|
||||
|
||||
对《凡人修仙传》精校版全本这类章节多、正文长的大书,首屏进入不再等待整本书逐章 render + paginate 全部完成,而是先等待目标正文附近 3 章构建完成。全书分页仍会继续执行,但它从“进入阅读器前置条件”变成了“阅读器内后台补齐任务”;后台每补齐 20 章会生成一份新的局部结果,不过这份结果会先暂存,等用户空闲时再并入当前可读内容。
|
||||
|
||||
### 验证状态
|
||||
|
||||
已通过 CocoaPods workspace 构建 Demo,确认本轮快速进入优化可编译:
|
||||
|
||||
```text
|
||||
xcodebuild -workspace ReadViewDemo/ReadViewDemo.xcworkspace \
|
||||
-scheme ReadViewDemo \
|
||||
-configuration Debug \
|
||||
-derivedDataPath /private/tmp/ReadViewDemoDerivedData \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
CODE_SIGNING_REQUIRED=NO \
|
||||
build
|
||||
```
|
||||
|
||||
结果:`BUILD SUCCEEDED`。
|
||||
|
||||
注意:直接构建 `ReadViewDemo.xcodeproj` 会绕开 Pods,导致 `import RDReaderView` 模块解析失败;验证时应使用 `ReadViewDemo/ReadViewDemo.xcworkspace`。
|
||||
|
||||
后续仍需要在真机或模拟器上用《凡人修仙传》精校版全本做实际打开耗时对比,重点记录 `Time To First Readable Page` 和 `Time To Full Book Ready`。
|
||||
@@ -1,6 +1,6 @@
|
||||
# 架构对比分析:读书 vs ReadViewSDK
|
||||
|
||||
> 基于读书 v10.0.3 (Build 79) 逆向文档,与当前 ReadViewSDK 代码在 2026-05-24 的核查结果整合。
|
||||
> 基于读书 v10.0.3 (Build 79) 逆向文档,与当前 ReadViewSDK 代码在 2026-06-02 的核查结果整合。
|
||||
> 本文档已吸收原 [WXRead剩余问题修复计划.md](/Users/shen/Work/Code/ReadViewSDK/Doc/WXRead剩余问题修复计划.md) 的阶段方案,后续以本文档作为单一真值。
|
||||
|
||||
> 标注说明:
|
||||
@@ -22,10 +22,11 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
|
||||
- 页面级 hit test / 选区 / 高亮 / 批注
|
||||
- 字符锚点与 `fileIndex/row/column` 语义的完整位置模型
|
||||
|
||||
核查结论:在当前约定范围内,ReadViewSDK 已经完成 EPUB 阅读器对 WXRead 文档主链路的复刻。当前文档里不再保留主链路级 `⚠️` 项,剩余仅有两类:
|
||||
核查结论:ReadViewSDK 已经完成 EPUB 阅读器主链路的大部分复刻,但当前代码里仍有少量“能力面已接入、实现路径未完全等价”的差异。和 2026-05-24 版本文档相比,当前最需要重新标注的是:
|
||||
|
||||
1. `replaceForMPChapter.css` 这类公众号文章专用资源,对 EPUB 主链路不适用
|
||||
2. 繁简转换、TTS / DRM / Pencil 这类已明确排除在本次复刻范围之外的外围能力
|
||||
1. 文本分页主路径现已真正消费多栏 path,并补上 `avoidOrphans` / `avoidWidows` / `hyphenation` 行为
|
||||
2. 代码仍保留若干 fallback / degrade 路径,与 WXRead 的单一主链路实现方式不同
|
||||
3. `replaceForMPChapter.css`、繁简转换、TTS / DRM / Pencil 仍属于不适用或明确不实现范围
|
||||
|
||||
---
|
||||
|
||||
@@ -43,11 +44,14 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
|
||||
- 分页器已经补上 inline footnote attachment 不整段挪页、标题 `keepWithNext`、`weread-page-relate` 页首借行这几类 WXRead 风格规则。
|
||||
- 章节尾部“仅空白/段落分隔符”的尾页丢弃,以及极短尾页回并已经落地,`宝山辽墓材料与释读` 第 31 页空白问题已修复。
|
||||
- `RDEPUBTextLayoutConfig` 已补齐到 WXRead 同级配置面:`frameWidth/frameHeight/edgeInsets/numberOfColumns/columnGap/avoidOrphans/avoidWidows/hyphenation`,并已接入分页入口与缓存键。
|
||||
- CoreText 分页路径现已消费多栏 layout path,不再把 `numberOfColumns` 只停留在配置层。
|
||||
- `avoidOrphans`、`avoidWidows`、`hyphenation` 现已接入实际排版/分页行为,而不是仅存在于配置模型。
|
||||
- `RDEPUBChapterData` 已统一承载章节分页结果、位置查询、搜索/高亮回查与目录语义,章节模型主链路已经收口。
|
||||
|
||||
### ⚠️ 有差异/有问题
|
||||
|
||||
- 无主链路遗留项;当前仅保留明确不适用或明确不实现的范围说明。
|
||||
- 仍保留降级链路:`RDEPUBDTCoreTextRenderer` 在 `DTCoreText` 不可用或构建失败时会退回 HTML 纯文本渲染;`RDURLReaderController` 在纯文本分页失败时会退回 `UITextView` 展示。这说明 ReadViewSDK 仍不是 WXRead 那种完全单一路径的生产态实现。
|
||||
- `RDReaderGestureController` 仍是未接入的占位组件,实际点击分区逻辑直接落在 `RDReaderView`,与 WXRead 更完整的翻页/手势控制器拆分相比,宿主层职责仍稍偏重。
|
||||
|
||||
### ❌ 未实现
|
||||
|
||||
@@ -81,10 +85,10 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
|
||||
| 标题 keep-with-next | 标题不能孤悬页尾 | 已补 `keepWithNext` 语义与页尾回退 | ✅ 已实现 |
|
||||
| `weread-page-relate` | 页首关联块需要借上一页一行 | 已补页首 `pageRelate` 借行规则 | ✅ 已实现 |
|
||||
| 章节尾页收口 | 丢弃空白尾页、合并极短尾页 | 已补尾页空白丢弃与超短尾页回并 | ✅ 已实现 |
|
||||
| 分页配置 | `WRCoreTextLayoutConfig`(含多栏等) | `RDEPUBTextLayoutConfig` 已补齐同级参数,并接入分页入口/缓存键/列路径构建 | ✅ 已实现 |
|
||||
| 分页配置 | `WRCoreTextLayoutConfig`(含多栏等) | `RDEPUBTextLayoutConfig` 已补齐同级参数,并已接入多栏 path、孤行/寡行保护与 hyphenation 行为 | ✅ 已实现 |
|
||||
| 缓存 | 按书籍 + 排版设置缓存 | 已有 `RDEPUBTextBookCache` 磁盘缓存 | ✅ 已实现 |
|
||||
|
||||
**结论:** 分页主链路已经按 WXRead 收口,当前差异不再落在分页引擎或分页配置能力面上。
|
||||
**结论:** 分页主链路已经按 WXRead 的主要思路收口;当前剩余差异不再集中在分页配置或分页算法本身,而更多落在 fallback 路径和宿主层实现细节。
|
||||
|
||||
---
|
||||
|
||||
@@ -175,6 +179,8 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
|
||||
| 页面几何模型完全等价 `WRCoreTextLayoutFrame` | ✅ 已实现 | - |
|
||||
| `WRBookmark` 统一标注模型 | ✅ 已实现 | - |
|
||||
| 多栏排版 | ✅ 已实现 | - |
|
||||
| `avoidOrphans / avoidWidows / hyphenation` 行为落地 | ✅ 已实现 | - |
|
||||
| 纯文本/渲染失败 fallback 清理 | ⚠️ 仍保留纯文本 fallback 路径 | 低 |
|
||||
| 字体动态加载 | ✅ 已实现 | - |
|
||||
| 繁简转换 | ❌ 明确不实现 | - |
|
||||
| TTS / DRM / Pencil | ❌ 明确不实现 | - |
|
||||
@@ -216,6 +222,17 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
|
||||
|
||||
---
|
||||
|
||||
## 补充说明:本次复核新增结论(2026-06-02)
|
||||
|
||||
和上一篇版本相比,当前最重要的修正不是“新增缺很多能力”,而是把原先写得过满的结论收回来:
|
||||
|
||||
- `RDEPUBTextLayoutConfig` 这一轮已经从“配置面补齐”推进到“行为面落地”,`avoidOrphans`、`avoidWidows`、`hyphenation` 不再只是模型字段。
|
||||
- 多栏能力这一轮已经接到文本 `CoreText` 分页主路径,`numberOfColumns` 不再只影响 Web/CSS 与设置入口。
|
||||
- 文本阅读主链路虽然已经不再依赖旧 `UITextView`,但仓库中仍保留 `DTCoreText` 不可用时的纯文本 fallback,以及纯文本分页失败时的 `UITextView` fallback,应视作与 WXRead 的实现差异,而不是主链路能力。
|
||||
- 公众号文章专用 `replaceForMPChapter.css` 仍不属于当前 EPUB 主链路缺口,应继续按“不适用”记录,而不是“待补齐”。
|
||||
|
||||
---
|
||||
|
||||
## 10. 读书关键类职责速查
|
||||
|
||||
| 类名 | 职责 | ReadViewSDK 对应 | 核查 |
|
||||
|
||||
@@ -43,7 +43,7 @@ EXTERNAL SOURCES:
|
||||
SPEC CHECKSUMS:
|
||||
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
|
||||
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
|
||||
RDReaderView: 54205c82c62ea6c674b43c833642d2643c98c573
|
||||
RDReaderView: 617ec758a5db3c10024acf83b110465ea8343b5b
|
||||
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
|
||||
SSAlertSwift: aad8dc0c20b36fcffe700b81d7be89d60c7ba4f1
|
||||
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
|
||||
|
||||
+9
-3
@@ -27,9 +27,15 @@
|
||||
"ZIPFoundation": [
|
||||
"~> 0.9"
|
||||
],
|
||||
"DTCoreText": [],
|
||||
"SnapKit": [],
|
||||
"SSAlertSwift": []
|
||||
"DTCoreText": [
|
||||
|
||||
],
|
||||
"SnapKit": [
|
||||
|
||||
],
|
||||
"SSAlertSwift": [
|
||||
|
||||
]
|
||||
},
|
||||
"requires_arc": true,
|
||||
"swift_version": "5.10"
|
||||
|
||||
Generated
+1
-1
@@ -43,7 +43,7 @@ EXTERNAL SOURCES:
|
||||
SPEC CHECKSUMS:
|
||||
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
|
||||
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
|
||||
RDReaderView: 54205c82c62ea6c674b43c833642d2643c98c573
|
||||
RDReaderView: 617ec758a5db3c10024acf83b110465ea8343b5b
|
||||
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
|
||||
SSAlertSwift: aad8dc0c20b36fcffe700b81d7be89d60c7ba4f1
|
||||
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
|
||||
|
||||
+1370
-1293
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,11 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
1A2B3C4D00000001AABBCC01 /* BookmarkTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000002AABBCC01 /* BookmarkTests.swift */; };
|
||||
1A2B3C4D00000003AABBCC01 /* TableOfContentsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000004AABBCC01 /* TableOfContentsTests.swift */; };
|
||||
1A2B3C4D00000005AABBCC01 /* SettingsExtendedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000006AABBCC01 /* SettingsExtendedTests.swift */; };
|
||||
1A2B3C4D00000007AABBCC01 /* PageNavigationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000008AABBCC01 /* PageNavigationTests.swift */; };
|
||||
1A2B3C4D00000009AABBCC01 /* ReaderAnnotationExtendedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D0000000AAABBCC01 /* ReaderAnnotationExtendedTests.swift */; };
|
||||
23BB1155EA379786DAA10A89 /* DisplayTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */; };
|
||||
3BC5C96D7A0ACF35F2192CC7 /* XCUIApplication+Launch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74B4C44287820D68ED6570F8 /* XCUIApplication+Launch.swift */; };
|
||||
4509ED928F228F43888E063D /* ReaderToolbarTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */; };
|
||||
@@ -16,6 +21,10 @@
|
||||
C08FF8D030048DC5147729E9 /* ReaderOpenCloseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */; };
|
||||
DE1437A969DA1C5F0CBB047D /* Pods_ReadViewDemo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 792DF85CE4A3DD80D67843C7 /* Pods_ReadViewDemo.framework */; };
|
||||
FEDB5937CEB858CB06E38E2D /* SettingsPanelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201C2B482287866487EFAE66 /* SettingsPanelTests.swift */; };
|
||||
1A2B3C4D0000000BAABBCC01 /* ConfigurableWindowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D0000000CAABBCC01 /* ConfigurableWindowTests.swift */; };
|
||||
1A2B3C4D0000000DAABBCC01 /* ConcurrentParsingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D0000000EAABBCC01 /* ConcurrentParsingTests.swift */; };
|
||||
1A2B3C4D0000000FAABBCC01 /* MetadataParseBenchmarkTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000010AABBCC01 /* MetadataParseBenchmarkTests.swift */; };
|
||||
1A2B3C4D00000011AABBCC01 /* DemoReaderState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000012AABBCC01 /* DemoReaderState.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -30,6 +39,11 @@
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
00F495E17B90CCF1C7FE8C27 /* AccessibilityIdentifiers.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AccessibilityIdentifiers.swift; sourceTree = "<group>"; };
|
||||
1A2B3C4D00000002AABBCC01 /* BookmarkTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookmarkTests.swift; sourceTree = "<group>"; };
|
||||
1A2B3C4D00000004AABBCC01 /* TableOfContentsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TableOfContentsTests.swift; sourceTree = "<group>"; };
|
||||
1A2B3C4D00000006AABBCC01 /* SettingsExtendedTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SettingsExtendedTests.swift; sourceTree = "<group>"; };
|
||||
1A2B3C4D00000008AABBCC01 /* PageNavigationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PageNavigationTests.swift; sourceTree = "<group>"; };
|
||||
1A2B3C4D0000000AAABBCC01 /* ReaderAnnotationExtendedTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderAnnotationExtendedTests.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>"; };
|
||||
@@ -40,6 +54,10 @@
|
||||
8FFD606A5A1CBDCC3CA87F1C /* ReadViewDemoUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ReadViewDemoUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderOpenCloseTests.swift; sourceTree = "<group>"; };
|
||||
BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderToolbarTests.swift; sourceTree = "<group>"; };
|
||||
1A2B3C4D0000000CAABBCC01 /* ConfigurableWindowTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConfigurableWindowTests.swift; sourceTree = "<group>"; };
|
||||
1A2B3C4D0000000EAABBCC01 /* ConcurrentParsingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConcurrentParsingTests.swift; sourceTree = "<group>"; };
|
||||
1A2B3C4D00000010AABBCC01 /* MetadataParseBenchmarkTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MetadataParseBenchmarkTests.swift; sourceTree = "<group>"; };
|
||||
1A2B3C4D00000012AABBCC01 /* DemoReaderState.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DemoReaderState.swift; sourceTree = "<group>"; };
|
||||
DE070C1D2FBF0CC900ED065F /* ReadViewDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ReadViewDemo.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DisplayTypeTests.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
@@ -98,6 +116,7 @@
|
||||
children = (
|
||||
00F495E17B90CCF1C7FE8C27 /* AccessibilityIdentifiers.swift */,
|
||||
74B4C44287820D68ED6570F8 /* XCUIApplication+Launch.swift */,
|
||||
1A2B3C4D00000012AABBCC01 /* DemoReaderState.swift */,
|
||||
);
|
||||
path = Helpers;
|
||||
sourceTree = "<group>";
|
||||
@@ -152,11 +171,19 @@
|
||||
EACA0D274176D594361EF6B9 /* ReaderUITests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1A2B3C4D00000002AABBCC01 /* BookmarkTests.swift */,
|
||||
FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */,
|
||||
1A2B3C4D00000008AABBCC01 /* PageNavigationTests.swift */,
|
||||
20BD15E5D8F04E7E9A239E14 /* ReaderAnnotationTests.swift */,
|
||||
1A2B3C4D0000000AAABBCC01 /* ReaderAnnotationExtendedTests.swift */,
|
||||
9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */,
|
||||
BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */,
|
||||
1A2B3C4D00000006AABBCC01 /* SettingsExtendedTests.swift */,
|
||||
201C2B482287866487EFAE66 /* SettingsPanelTests.swift */,
|
||||
1A2B3C4D00000004AABBCC01 /* TableOfContentsTests.swift */,
|
||||
1A2B3C4D0000000CAABBCC01 /* ConfigurableWindowTests.swift */,
|
||||
1A2B3C4D0000000EAABBCC01 /* ConcurrentParsingTests.swift */,
|
||||
1A2B3C4D00000010AABBCC01 /* MetadataParseBenchmarkTests.swift */,
|
||||
);
|
||||
path = ReaderUITests;
|
||||
sourceTree = "<group>";
|
||||
@@ -309,11 +336,20 @@
|
||||
files = (
|
||||
63B2852E59996922386A3111 /* AccessibilityIdentifiers.swift in Sources */,
|
||||
3BC5C96D7A0ACF35F2192CC7 /* XCUIApplication+Launch.swift in Sources */,
|
||||
1A2B3C4D00000011AABBCC01 /* DemoReaderState.swift in Sources */,
|
||||
1A2B3C4D00000001AABBCC01 /* BookmarkTests.swift in Sources */,
|
||||
23BB1155EA379786DAA10A89 /* DisplayTypeTests.swift in Sources */,
|
||||
1A2B3C4D00000007AABBCC01 /* PageNavigationTests.swift in Sources */,
|
||||
52A6A0C9941A4B5F9B88C6B0 /* ReaderAnnotationTests.swift in Sources */,
|
||||
1A2B3C4D00000009AABBCC01 /* ReaderAnnotationExtendedTests.swift in Sources */,
|
||||
C08FF8D030048DC5147729E9 /* ReaderOpenCloseTests.swift in Sources */,
|
||||
4509ED928F228F43888E063D /* ReaderToolbarTests.swift in Sources */,
|
||||
1A2B3C4D00000005AABBCC01 /* SettingsExtendedTests.swift in Sources */,
|
||||
FEDB5937CEB858CB06E38E2D /* SettingsPanelTests.swift in Sources */,
|
||||
1A2B3C4D00000003AABBCC01 /* TableOfContentsTests.swift in Sources */,
|
||||
1A2B3C4D0000000BAABBCC01 /* ConfigurableWindowTests.swift in Sources */,
|
||||
1A2B3C4D0000000DAABBCC01 /* ConcurrentParsingTests.swift in Sources */,
|
||||
1A2B3C4D0000000FAABBCC01 /* MetadataParseBenchmarkTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -344,6 +380,7 @@
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = MG4Z7FU83W;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = cn.shen.ReadViewDemoUITests;
|
||||
@@ -368,6 +405,7 @@
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = MG4Z7FU83W;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = cn.shen.ReadViewDemoUITests;
|
||||
|
||||
@@ -12,8 +12,12 @@ final class ViewController: UIViewController {
|
||||
let bookTitleQuery: String
|
||||
let displayType: RDReaderView.DisplayType?
|
||||
let pageNumber: Int?
|
||||
let resetsReaderState: Bool
|
||||
let displaySequence: [RDReaderView.DisplayType]
|
||||
let stepDelay: TimeInterval
|
||||
let windowSize: Int?
|
||||
let concurrency: Int?
|
||||
let clearsCache: Bool
|
||||
|
||||
nonisolated private static func parseDisplayType(_ rawValue: String) -> RDReaderView.DisplayType? {
|
||||
switch rawValue.lowercased() {
|
||||
@@ -43,92 +47,30 @@ final class ViewController: UIViewController {
|
||||
|
||||
let displayType = value(after: "--demo-display-type").flatMap(parseDisplayType)
|
||||
let pageNumber = value(after: "--demo-page").flatMap(Int.init)
|
||||
let resetsReaderState = arguments.contains("--demo-reset-state")
|
||||
let displaySequence = value(after: "--demo-display-sequence")
|
||||
.map { raw in
|
||||
raw.split(separator: ",").compactMap { parseDisplayType(String($0)) }
|
||||
} ?? []
|
||||
let stepDelay = value(after: "--demo-step-delay").flatMap(TimeInterval.init) ?? 1.0
|
||||
let windowSize = value(after: "--demo-window-size").flatMap(Int.init)
|
||||
let concurrency = value(after: "--demo-concurrency").flatMap(Int.init)
|
||||
let clearsCache = arguments.contains("--demo-clear-cache")
|
||||
|
||||
return LaunchAutomationPlan(
|
||||
bookTitleQuery: bookTitleQuery,
|
||||
displayType: displayType,
|
||||
pageNumber: pageNumber,
|
||||
resetsReaderState: resetsReaderState,
|
||||
displaySequence: displaySequence,
|
||||
stepDelay: stepDelay
|
||||
stepDelay: stepDelay,
|
||||
windowSize: windowSize,
|
||||
concurrency: concurrency,
|
||||
clearsCache: clearsCache
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private enum ValidationCategory: String, CaseIterable {
|
||||
case textNovel = "TXT/小说"
|
||||
case textRich = "复杂图文"
|
||||
case webFixed = "Fixed/互动"
|
||||
case plainText = "TXT"
|
||||
|
||||
var sortOrder: Int {
|
||||
switch self {
|
||||
case .textNovel: return 0
|
||||
case .textRich: return 1
|
||||
case .webFixed: return 2
|
||||
case .plainText: return 3
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct BookValidationReport {
|
||||
let title: String
|
||||
let category: ValidationCategory
|
||||
let profile: String
|
||||
let passed: Bool
|
||||
let notes: [String]
|
||||
}
|
||||
|
||||
private struct ResourceValidationSummary {
|
||||
let checkedCount: Int
|
||||
let passedCount: Int
|
||||
let failedBooks: [String]
|
||||
let matrixLines: [String]
|
||||
let diagnosticLines: [String]
|
||||
let rerunHint: String
|
||||
|
||||
var statusText: String {
|
||||
var lines: [String] = []
|
||||
if checkedCount == 0 {
|
||||
lines.append("样本验证:未发现可验证样本")
|
||||
} else if failedBooks.isEmpty {
|
||||
lines.append("样本验证:\(passedCount)/\(checkedCount) 通过")
|
||||
} else {
|
||||
let titles = failedBooks.prefix(2).joined(separator: "、")
|
||||
lines.append("样本验证:\(passedCount)/\(checkedCount) 通过,失败样本:\(titles)")
|
||||
}
|
||||
lines.append(contentsOf: matrixLines.prefix(3))
|
||||
lines.append(contentsOf: prioritizedDiagnosticLines())
|
||||
if !rerunHint.isEmpty {
|
||||
lines.append(rerunHint)
|
||||
}
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
private func prioritizedDiagnosticLines() -> [String] {
|
||||
var selected: [String] = []
|
||||
if let semanticLine = diagnosticLines.first(where: { $0.contains("属性闭环诊断") }) {
|
||||
selected.append(semanticLine)
|
||||
}
|
||||
if let paginationLine = diagnosticLines.first(where: { $0.contains("分页诊断") && !selected.contains($0) }) {
|
||||
selected.append(paginationLine)
|
||||
}
|
||||
if selected.count < 2 {
|
||||
for line in diagnosticLines where !selected.contains(line) {
|
||||
selected.append(line)
|
||||
if selected.count == 2 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return selected
|
||||
}
|
||||
}
|
||||
|
||||
private let statusLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.numberOfLines = 0
|
||||
@@ -160,15 +102,9 @@ final class ViewController: UIViewController {
|
||||
}()
|
||||
|
||||
private var books: [DemoBook] = []
|
||||
private var validationSummary: ResourceValidationSummary?
|
||||
private var validationTask: Task<Void, Never>?
|
||||
private let launchAutomationPlan = LaunchAutomationPlan.parse(arguments: ProcessInfo.processInfo.arguments)
|
||||
private var didRunLaunchAutomation = false
|
||||
|
||||
deinit {
|
||||
validationTask?.cancel()
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .systemBackground
|
||||
@@ -209,7 +145,6 @@ final class ViewController: UIViewController {
|
||||
books = discoverBooks()
|
||||
tableView.reloadData()
|
||||
updateChrome()
|
||||
validateSampleBooks()
|
||||
}
|
||||
|
||||
private func updateChrome() {
|
||||
@@ -217,14 +152,7 @@ final class ViewController: UIViewController {
|
||||
statusLabel.text = "ReadViewSDK Demo\nbook 目录下未找到 txt 或 epub 文件"
|
||||
tableView.backgroundView = emptyStateLabel
|
||||
} else {
|
||||
var lines = [
|
||||
"ReadViewSDK Demo",
|
||||
"已发现 \(books.count) 本本地图书,点击即可进入阅读器"
|
||||
]
|
||||
if let validationSummary {
|
||||
lines.append(validationSummary.statusText)
|
||||
}
|
||||
statusLabel.text = lines.joined(separator: "\n")
|
||||
statusLabel.text = "ReadViewSDK Demo\n已发现 \(books.count) 本本地图书,点击即可进入阅读器"
|
||||
tableView.backgroundView = nil
|
||||
}
|
||||
}
|
||||
@@ -268,379 +196,6 @@ final class ViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
private func validateSampleBooks() {
|
||||
validationTask?.cancel()
|
||||
guard !books.isEmpty else {
|
||||
validationSummary = nil
|
||||
updateChrome()
|
||||
return
|
||||
}
|
||||
|
||||
let pageSize = currentReaderPageSize()
|
||||
let style = currentReaderTextStyle()
|
||||
validationTask = Task.detached(priority: .utility) { [books] in
|
||||
let summary = Self.validateBooks(books, pageSize: pageSize, style: style)
|
||||
await MainActor.run {
|
||||
print("[ReadViewDemo] \(summary.statusText)")
|
||||
self.validationSummary = summary
|
||||
self.updateChrome()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private static func validateBooks(
|
||||
_ books: [DemoBook],
|
||||
pageSize: CGSize,
|
||||
style: RDEPUBTextRenderStyle
|
||||
) -> ResourceValidationSummary {
|
||||
var checkedCount = 0
|
||||
var passedCount = 0
|
||||
var failedBooks: [String] = []
|
||||
var reports: [BookValidationReport] = []
|
||||
var diagnosticLines: [String] = []
|
||||
|
||||
for book in books {
|
||||
let result = validateBook(book, pageSize: pageSize, style: style)
|
||||
checkedCount += 1
|
||||
if result.report.passed {
|
||||
passedCount += 1
|
||||
} else {
|
||||
failedBooks.append(book.title)
|
||||
}
|
||||
reports.append(result.report)
|
||||
diagnosticLines.append(contentsOf: result.diagnostics)
|
||||
}
|
||||
|
||||
return ResourceValidationSummary(
|
||||
checkedCount: checkedCount,
|
||||
passedCount: passedCount,
|
||||
failedBooks: failedBooks,
|
||||
matrixLines: makeMatrixLines(from: reports),
|
||||
diagnosticLines: diagnosticLines,
|
||||
rerunHint: "复现入口:启动 Demo 查看摘要,打开样本书后再验证搜索 / 高亮 / 主题字号切换"
|
||||
)
|
||||
}
|
||||
|
||||
nonisolated private static func validateBook(
|
||||
_ book: DemoBook,
|
||||
pageSize: CGSize,
|
||||
style: RDEPUBTextRenderStyle
|
||||
) -> (report: BookValidationReport, diagnostics: [String]) {
|
||||
let fileExtension = book.fileURL.pathExtension.lowercased()
|
||||
if fileExtension == "txt" {
|
||||
let builder = RDPlainTextBookBuilder()
|
||||
do {
|
||||
let textBook = try builder.build(textFileURL: book.fileURL, pageSize: pageSize, style: style)
|
||||
let passed = !textBook.pages.isEmpty && !textBook.chapters.isEmpty
|
||||
let report = BookValidationReport(
|
||||
title: book.title,
|
||||
category: .plainText,
|
||||
profile: "txt",
|
||||
passed: passed,
|
||||
notes: [
|
||||
"章节 \(textBook.chapters.count)",
|
||||
"页数 \(textBook.pages.count)"
|
||||
]
|
||||
)
|
||||
let diagnostics = [
|
||||
"TXT 验证:\(book.title) · chapters \(textBook.chapters.count) · pages \(textBook.pages.count)"
|
||||
]
|
||||
return (report, diagnostics)
|
||||
} catch {
|
||||
let report = BookValidationReport(
|
||||
title: book.title,
|
||||
category: .plainText,
|
||||
profile: "txt",
|
||||
passed: false,
|
||||
notes: [error.localizedDescription]
|
||||
)
|
||||
return (report, ["TXT 验证失败:\(book.title) · \(error.localizedDescription)"])
|
||||
}
|
||||
}
|
||||
|
||||
let parser = RDEPUBParser()
|
||||
do {
|
||||
try parser.parse(epubURL: book.fileURL)
|
||||
let publication = parser.makePublication()
|
||||
switch publication.readingProfile {
|
||||
case .textReflowable:
|
||||
return validateReflowableBook(
|
||||
book,
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
pageSize: pageSize,
|
||||
style: style
|
||||
)
|
||||
case .webFixedLayout, .webInteractive:
|
||||
return validateWebBook(
|
||||
book,
|
||||
publication: publication,
|
||||
viewportSize: pageSize
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
let report = BookValidationReport(
|
||||
title: book.title,
|
||||
category: .textRich,
|
||||
profile: "parse-failed",
|
||||
passed: false,
|
||||
notes: [error.localizedDescription]
|
||||
)
|
||||
return (report, ["EPUB 验证失败:\(book.title) · \(error.localizedDescription)"])
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private static func validateReflowableBook(
|
||||
_ book: DemoBook,
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
pageSize: CGSize,
|
||||
style: RDEPUBTextRenderStyle
|
||||
) -> (report: BookValidationReport, diagnostics: [String]) {
|
||||
let builder = RDEPUBTextBookBuilder()
|
||||
do {
|
||||
let textBook = try builder.build(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
pageSize: pageSize,
|
||||
style: style
|
||||
)
|
||||
let missingResources = builder.lastBuildResourceDiagnostics.filter { !$0.existsOnDisk }
|
||||
let diagnostics = builder.lastBuildPaginationDiagnostics
|
||||
let attachmentPages = diagnostics.reduce(0) { $0 + $1.attachmentPageCount }
|
||||
let category: ValidationCategory = attachmentPages > 0 ? .textRich : .textNovel
|
||||
let passed = !textBook.pages.isEmpty && missingResources.isEmpty
|
||||
|
||||
var reportNotes = [
|
||||
"章节 \(textBook.chapters.count)",
|
||||
"页数 \(textBook.pages.count)"
|
||||
]
|
||||
if attachmentPages > 0 {
|
||||
reportNotes.append("attachment 页 \(attachmentPages)")
|
||||
}
|
||||
|
||||
var summaryLines: [String] = []
|
||||
if let paginationSummary = makePaginationSummary(diagnostics, title: book.title) {
|
||||
summaryLines.append("分页诊断:\(paginationSummary)")
|
||||
}
|
||||
if let semanticSummary = builder.phase7SemanticSummary(title: book.title) {
|
||||
summaryLines.append("属性闭环诊断:\(semanticSummary)")
|
||||
}
|
||||
if let restoreSummary = makeRestoreSummary(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
textBook: textBook,
|
||||
pageSize: pageSize,
|
||||
style: style,
|
||||
title: book.title
|
||||
) {
|
||||
summaryLines.append("恢复诊断:\(restoreSummary)")
|
||||
}
|
||||
|
||||
let report = BookValidationReport(
|
||||
title: book.title,
|
||||
category: category,
|
||||
profile: publication.readingProfile.rawValue,
|
||||
passed: passed,
|
||||
notes: reportNotes
|
||||
)
|
||||
return (report, summaryLines)
|
||||
} catch {
|
||||
let category: ValidationCategory = book.title.contains("凡人") ? .textNovel : .textRich
|
||||
let report = BookValidationReport(
|
||||
title: book.title,
|
||||
category: category,
|
||||
profile: publication.readingProfile.rawValue,
|
||||
passed: false,
|
||||
notes: [error.localizedDescription]
|
||||
)
|
||||
return (report, ["EPUB 验证失败:\(book.title) · \(error.localizedDescription)"])
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private static func validateWebBook(
|
||||
_ book: DemoBook,
|
||||
publication: RDEPUBPublication,
|
||||
viewportSize: CGSize
|
||||
) -> (report: BookValidationReport, diagnostics: [String]) {
|
||||
let linearItems = publication.spine.filter(\.linear)
|
||||
let missingFiles = linearItems.filter {
|
||||
guard let normalizedHref = publication.resourceResolver.normalizedHref($0.href) else {
|
||||
return true
|
||||
}
|
||||
return publication.resourceResolver.fileURL(forRelativePath: normalizedHref) == nil
|
||||
}
|
||||
let defaultConfiguration = RDEPUBReaderConfiguration.default
|
||||
let preferences = RDEPUBPreferences(
|
||||
fontSize: defaultConfiguration.fontSize,
|
||||
lineHeightMultiple: defaultConfiguration.lineHeightMultiple,
|
||||
reflowableContentInsets: defaultConfiguration.reflowableContentInsets,
|
||||
fixedContentInset: defaultConfiguration.fixedContentInset,
|
||||
fixedLayoutFit: defaultConfiguration.fixedLayoutFit,
|
||||
fixedLayoutSpreadMode: defaultConfiguration.fixedLayoutSpreadMode
|
||||
)
|
||||
let spreadCount = publication.layout == .fixed
|
||||
? publication.makeFixedSpreads(
|
||||
preferences: preferences,
|
||||
viewportSize: viewportSize
|
||||
).count
|
||||
: 0
|
||||
let passed = !linearItems.isEmpty && missingFiles.isEmpty && (publication.layout != .fixed || spreadCount > 0)
|
||||
let category: ValidationCategory = .webFixed
|
||||
let report = BookValidationReport(
|
||||
title: book.title,
|
||||
category: category,
|
||||
profile: publication.readingProfile.rawValue,
|
||||
passed: passed,
|
||||
notes: [
|
||||
"spine \(linearItems.count)",
|
||||
publication.layout == .fixed ? "spread \(spreadCount)" : "interactive"
|
||||
]
|
||||
)
|
||||
let diagnostic = "Web 路径验证:\(book.title) · profile \(publication.readingProfile.rawValue) · spine \(linearItems.count) · missing \(missingFiles.count)"
|
||||
return (report, [diagnostic])
|
||||
}
|
||||
|
||||
nonisolated private static func makeMatrixLines(from reports: [BookValidationReport]) -> [String] {
|
||||
let grouped = Dictionary(grouping: reports, by: \.category)
|
||||
return ValidationCategory.allCases.compactMap { category in
|
||||
guard let items = grouped[category], !items.isEmpty else { return nil }
|
||||
let passed = items.filter(\.passed).count
|
||||
let notes = items.prefix(2).map { "\($0.title)(\($0.profile))" }.joined(separator: "、")
|
||||
return "矩阵[\(category.rawValue)] \(passed)/\(items.count) · \(notes)"
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private static func makePaginationSummary(
|
||||
_ diagnostics: [RDEPUBTextChapterPaginationDiagnostic],
|
||||
title: String
|
||||
) -> String? {
|
||||
guard !diagnostics.isEmpty else { return nil }
|
||||
let attachmentPages = diagnostics.reduce(0) { $0 + $1.attachmentPageCount }
|
||||
let semanticBreakPages = diagnostics.reduce(0) { $0 + $1.blockAdjustedPageCount }
|
||||
let blockKinds = uniqueValues(diagnostics.flatMap(\.blockKinds))
|
||||
let semanticHints = uniqueValues(diagnostics.flatMap(\.semanticHints))
|
||||
let attachmentPlacements = uniqueValues(diagnostics.flatMap(\.attachmentPlacements))
|
||||
let breakReasonCounts = diagnostics
|
||||
.flatMap(\.breakReasons)
|
||||
.reduce(into: [RDEPUBTextPageBreakReason: Int]()) { counts, reason in
|
||||
counts[reason, default: 0] += 1
|
||||
}
|
||||
let orderedReasons = breakReasonCounts
|
||||
.sorted { lhs, rhs in
|
||||
if lhs.value == rhs.value {
|
||||
return lhs.key.rawValue < rhs.key.rawValue
|
||||
}
|
||||
return lhs.value > rhs.value
|
||||
}
|
||||
.map { "\($0.key.rawValue):\($0.value)" }
|
||||
.joined(separator: ", ")
|
||||
let note = diagnostics
|
||||
.flatMap(\.sampleNotes)
|
||||
.first(where: { $0.contains("attachment") || $0.contains("block") || $0.contains("page break") })
|
||||
var parts = [
|
||||
"\(title)",
|
||||
"章节 \(diagnostics.count)",
|
||||
"attachment 页 \(attachmentPages)",
|
||||
"semantic break 页 \(semanticBreakPages)",
|
||||
blockKinds.isEmpty ? nil : "block kinds [\(blockKinds.map(\.rawValue).joined(separator: ","))]",
|
||||
semanticHints.isEmpty ? nil : "hints [\(semanticHints.map(\.rawValue).joined(separator: ","))]",
|
||||
attachmentPlacements.isEmpty ? nil : "placements [\(attachmentPlacements.map(\.rawValue).joined(separator: ","))]",
|
||||
orderedReasons.isEmpty ? nil : "reasons [\(orderedReasons)]"
|
||||
].compactMap { $0 }
|
||||
if let note {
|
||||
parts.append(note)
|
||||
}
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
nonisolated private static func makeRestoreSummary(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
textBook: RDEPUBTextBook,
|
||||
pageSize: CGSize,
|
||||
style: RDEPUBTextRenderStyle,
|
||||
title: String
|
||||
) -> String? {
|
||||
guard textBook.pages.count > 1 else { return nil }
|
||||
let targetPageNumber = min(max(textBook.pages.count / 2, 1), textBook.pages.count)
|
||||
guard let restoreLocation = textBook.location(forPageNumber: targetPageNumber, bookIdentifier: publication.metadata.identifier),
|
||||
let baseResolvedPage = textBook.pageNumber(
|
||||
for: restoreLocation,
|
||||
resolver: publication.resourceResolver,
|
||||
bookIdentifier: publication.metadata.identifier
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let alternateFont = UIFont.systemFont(ofSize: style.font.pointSize + 2)
|
||||
let alternateLineSpacing = style.lineSpacing + max(alternateFont.lineHeight * 0.15, 2)
|
||||
let alternateStyle = RDEPUBTextRenderStyle(
|
||||
font: alternateFont,
|
||||
lineSpacing: alternateLineSpacing,
|
||||
textColor: style.textColor,
|
||||
backgroundColor: style.backgroundColor
|
||||
)
|
||||
let themeStyle = RDEPUBTextRenderStyle(
|
||||
font: style.font,
|
||||
lineSpacing: style.lineSpacing,
|
||||
textColor: .white,
|
||||
backgroundColor: .black
|
||||
)
|
||||
|
||||
let builder = RDEPUBTextBookBuilder()
|
||||
guard let alternateBook = try? builder.build(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
pageSize: pageSize,
|
||||
style: alternateStyle
|
||||
), let alternatePageNumber = alternateBook.pageNumber(
|
||||
for: restoreLocation,
|
||||
resolver: publication.resourceResolver,
|
||||
bookIdentifier: publication.metadata.identifier
|
||||
), let alternateResolvedLocation = alternateBook.location(
|
||||
forPageNumber: alternatePageNumber,
|
||||
bookIdentifier: publication.metadata.identifier
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let themeBuilder = RDEPUBTextBookBuilder()
|
||||
let themeBook = try? themeBuilder.build(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
pageSize: pageSize,
|
||||
style: themeStyle
|
||||
)
|
||||
let themePageNumber = themeBook?.pageNumber(
|
||||
for: restoreLocation,
|
||||
resolver: publication.resourceResolver,
|
||||
bookIdentifier: publication.metadata.identifier
|
||||
)
|
||||
|
||||
let hrefStable = (publication.resourceResolver.normalizedHref(alternateResolvedLocation.href) ?? alternateResolvedLocation.href) ==
|
||||
(publication.resourceResolver.normalizedHref(restoreLocation.href) ?? restoreLocation.href)
|
||||
let progressionDelta = abs(alternateResolvedLocation.navigationProgression - restoreLocation.navigationProgression)
|
||||
let themeStable = themePageNumber == baseResolvedPage
|
||||
|
||||
return [
|
||||
title,
|
||||
"base \(baseResolvedPage)",
|
||||
"font-shift \(alternatePageNumber)",
|
||||
"theme-stable \(themeStable ? "yes" : "no")",
|
||||
"href-stable \(hrefStable ? "yes" : "no")",
|
||||
String(format: "progression-delta %.3f", progressionDelta)
|
||||
].joined(separator: " · ")
|
||||
}
|
||||
|
||||
nonisolated private static func uniqueValues<T: Equatable>(_ values: [T]) -> [T] {
|
||||
values.reduce(into: [T]()) { result, value in
|
||||
if !result.contains(value) {
|
||||
result.append(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func openBook(
|
||||
_ book: DemoBook,
|
||||
@@ -680,27 +235,6 @@ final class ViewController: UIViewController {
|
||||
return controller
|
||||
}
|
||||
|
||||
private func currentReaderPageSize() -> CGSize {
|
||||
let viewportSize = UIScreen.main.bounds.size
|
||||
let insets = RDEPUBReaderConfiguration.default.reflowableContentInsets
|
||||
return CGSize(
|
||||
width: max(viewportSize.width - insets.left - insets.right, 1),
|
||||
height: max(viewportSize.height - insets.top - insets.bottom, 1)
|
||||
)
|
||||
}
|
||||
|
||||
private func currentReaderTextStyle() -> RDEPUBTextRenderStyle {
|
||||
let configuration = RDEPUBReaderConfiguration.default
|
||||
let font = configuration.fontChoice.font(ofSize: configuration.fontSize)
|
||||
let lineSpacing = max(font.lineHeight * (configuration.lineHeightMultiple - 1), 4)
|
||||
return RDEPUBTextRenderStyle(
|
||||
font: font,
|
||||
lineSpacing: lineSpacing,
|
||||
textColor: configuration.theme.contentTextColor,
|
||||
backgroundColor: configuration.theme.contentBackgroundColor
|
||||
)
|
||||
}
|
||||
|
||||
private func runLaunchAutomationIfNeeded() {
|
||||
guard !didRunLaunchAutomation,
|
||||
let launchAutomationPlan,
|
||||
@@ -714,13 +248,50 @@ final class ViewController: UIViewController {
|
||||
return
|
||||
}
|
||||
|
||||
if launchAutomationPlan.resetsReaderState {
|
||||
resetPersistedReaderState()
|
||||
}
|
||||
if launchAutomationPlan.clearsCache {
|
||||
clearChapterSummaryCache()
|
||||
}
|
||||
|
||||
var configuration = RDEPUBReaderConfiguration.default
|
||||
if let displayType = launchAutomationPlan.displayType {
|
||||
configuration.displayType = displayType
|
||||
}
|
||||
if let windowSize = launchAutomationPlan.windowSize {
|
||||
configuration.onDemandChapterWindowSize = windowSize
|
||||
}
|
||||
if let concurrency = launchAutomationPlan.concurrency {
|
||||
configuration.metadataParsingConcurrency = concurrency
|
||||
}
|
||||
_ = openBook(book, configuration: configuration, automationPlan: launchAutomationPlan)
|
||||
}
|
||||
|
||||
private func resetPersistedReaderState() {
|
||||
let defaults = UserDefaults.standard
|
||||
let prefixes = [
|
||||
"ssreader.epub.location.",
|
||||
"ssreader.epub.bookmarks.",
|
||||
"ssreader.epub.highlights."
|
||||
]
|
||||
let settingsKey = "ssreader.epub.settings"
|
||||
|
||||
for key in defaults.dictionaryRepresentation().keys {
|
||||
if prefixes.contains(where: { key.hasPrefix($0) }) || key == settingsKey {
|
||||
defaults.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func clearChapterSummaryCache() {
|
||||
let cachesDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
|
||||
?? FileManager.default.temporaryDirectory
|
||||
let cacheDir = cachesDirectory.appendingPathComponent("RDEPUBChapterSummaryCache", isDirectory: true)
|
||||
try? FileManager.default.removeItem(at: cacheDir)
|
||||
print("[ReadViewDemo] cleared chapter summary cache at \(cacheDir.path)")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension ViewController: UITableViewDataSource {
|
||||
|
||||
@@ -5,7 +5,491 @@
|
||||
它用于覆盖 RDURLReaderController 与 RDPlainTextBookBuilder 的 TXT 路径,
|
||||
确认纯文本分页、打开主流程,以及后续 reader 壳层回归不会失效。
|
||||
|
||||
为了让 UI 自动化可以稳定验证翻页、长按选区、书签和目录行为,
|
||||
这个样本会刻意保持为“可稳定分页”的长度,而不是一屏就显示完的短文本。
|
||||
|
||||
第一节 多页验证
|
||||
|
||||
阅读器在处理纯文本时,会根据当前设备尺寸、字体大小、行距和内边距进行重新分页。
|
||||
这意味着同一份文本在不同设备上看到的“第 2 页、第 3 页”内容可能略有差异,
|
||||
但只要整体篇幅足够长,跳转到后续页码、执行选区手势、验证状态标签,就依然具备稳定性。
|
||||
|
||||
第二节 自动化约束
|
||||
|
||||
自动化测试并不追求复杂叙事,它更在意内容是否连续、段落是否足够、字符分布是否均匀。
|
||||
因此这里使用连续的中文段落,让文本布局更接近真实阅读场景,也方便测试长按选词时命中正文区域。
|
||||
|
||||
第三节 翻页前提
|
||||
|
||||
如果样本文本过短,阅读器即使工作正常,也只能停留在第一页。
|
||||
这会让依赖 page=2 的测试误以为是“启动参数失效”,实际上是目标页根本不存在。
|
||||
为了避免这种误判,样本内容应该明确保证至少能覆盖数页。
|
||||
|
||||
第四节 文本密度
|
||||
|
||||
当前章节继续补充说明:段落长度适中、标点分布自然、句子结构重复但不完全相同。
|
||||
这样既能帮助验证分页器的稳定性,也能帮助标注测试在不同屏幕密度下复现相近的触控位置。
|
||||
|
||||
第五节 选区交互
|
||||
|
||||
长按后拖拽选区,通常依赖可选中的连续文字块。
|
||||
如果页面上只有很少文字,或者文字被标题、空白和分隔线稀释,选区命中率会显著下降。
|
||||
因此这里增加多个连续段落,让第二页和后续页面都具备足够的正文密度。
|
||||
|
||||
第六节 书签行为
|
||||
|
||||
书签测试需要在当前位置创建标记,再验证底部工具栏中的书签列表入口是否可用。
|
||||
如果页面状态更新稍慢,测试就需要等待按钮从“出现”切换到“可点击”。
|
||||
这类状态同步问题和可访问性问题不同,但都应该被自动化显式观察到。
|
||||
|
||||
第七节 目录与章节
|
||||
|
||||
虽然 TXT 没有像 EPUB 那样的正式目录树,但阅读器仍然会以章节标题和文本位置组织阅读内容。
|
||||
在更复杂的样本书中,目录面板与书签面板都是重要的回归点,因此本样本也会保留明显的章节与小节结构。
|
||||
|
||||
第二章 稳定性
|
||||
|
||||
如果这个样本能被成功发现、分页并进入阅读器,
|
||||
就说明 TXT 支持仍然保留在当前收敛后的架构中。
|
||||
|
||||
第一节 持续验证
|
||||
|
||||
稳定性不是指“永不变化”,而是指在进行架构收敛、依赖升级、分页算法调整之后,
|
||||
仍然可以通过一组明确的自动化测试迅速判断主要阅读能力是否保持正常。
|
||||
|
||||
第二节 页面状态
|
||||
|
||||
测试会观察隐藏的状态标签,确认 reader=opened、page=2、toolbar=hidden、selection=1、
|
||||
highlights=1 等关键信号。这些信号本身不是面向用户的 UI,但对于自动化诊断非常有帮助。
|
||||
|
||||
第三节 页面长度
|
||||
|
||||
为了确保这份 TXT 在手机模拟器上可靠地产生多页,
|
||||
这里继续补充几段自然文本。文字越接近真实段落,分页器越能暴露真实问题,
|
||||
比起简单重复同一句话,更容易发现孤行、断段和换页边界上的细节缺陷。
|
||||
|
||||
第四节 回归节奏
|
||||
|
||||
每次修改阅读器入口、分页器、选区菜单或工具栏逻辑时,
|
||||
都可以重新运行这组围绕本样本构建的测试。
|
||||
如果样本依旧能稳定跳到第二页、完成高亮、打开书签面板,
|
||||
那就说明最核心的 TXT 阅读链路依然是健康的。
|
||||
|
||||
第五节 补充段落 A
|
||||
|
||||
这是一段用于扩充篇幅的说明文字。它描述了阅读器在不同字号下的视觉密度变化,
|
||||
也提醒我们不要把单一设备上的页码结果绝对化,而应确保测试样本足够长以适配常见机型。
|
||||
|
||||
第六节 补充段落 B
|
||||
|
||||
这是一段用于扩充篇幅的说明文字。它强调自动化测试需要稳定样本、稳定入口、稳定查询标识,
|
||||
只有这样,失败时我们才能快速区分是产品逻辑缺陷,还是测试前提本身不成立。
|
||||
|
||||
第七节 补充段落 C
|
||||
|
||||
这是一段用于扩充篇幅的说明文字。它补充说明,若未来字体默认值、边距或行距发生变化,
|
||||
只要这份样本文本仍然明显长于一页,依赖 page=2 的测试就仍然具备合理性。
|
||||
|
||||
第八节 补充段落 D
|
||||
|
||||
这是一段用于扩充篇幅的说明文字。它重复强调:自动化要避免建立在偶然时序之上,
|
||||
应等待明确状态,再执行点击、长按或断言,这能显著提升 UI 回归的解释力。
|
||||
|
||||
第九节 补充段落 E
|
||||
|
||||
这是一段用于扩充篇幅的说明文字。它说明目录、书签、标注、设置和翻页并不是孤立功能,
|
||||
而是阅读体验中的连续路径,因此测试设计也应围绕连续操作来展开。
|
||||
|
||||
第十节 收束
|
||||
|
||||
当你读到这里时,这份样本文本的篇幅已经足以覆盖多页。
|
||||
如果阅读器仍然无法跳到第二页,那么问题就更可能出在跳转实现、状态同步或分页结果映射上,
|
||||
而不是样本文本长度不足。
|
||||
|
||||
附录 扩展段落 01
|
||||
|
||||
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
|
||||
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
|
||||
|
||||
附录 扩展段落 02
|
||||
|
||||
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
|
||||
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
|
||||
|
||||
附录 扩展段落 03
|
||||
|
||||
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
|
||||
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
|
||||
|
||||
附录 扩展段落 04
|
||||
|
||||
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
|
||||
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
|
||||
|
||||
附录 扩展段落 05
|
||||
|
||||
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
|
||||
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
|
||||
|
||||
附录 扩展段落 06
|
||||
|
||||
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
|
||||
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
|
||||
|
||||
附录 扩展段落 07
|
||||
|
||||
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
|
||||
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
|
||||
|
||||
附录 扩展段落 08
|
||||
|
||||
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
|
||||
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
|
||||
|
||||
附录 扩展段落 09
|
||||
|
||||
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
|
||||
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
|
||||
|
||||
附录 扩展段落 10
|
||||
|
||||
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
|
||||
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
|
||||
|
||||
附录 扩展段落 11
|
||||
|
||||
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
|
||||
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
|
||||
|
||||
附录 扩展段落 12
|
||||
|
||||
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
|
||||
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
|
||||
|
||||
附录 扩展段落 13
|
||||
|
||||
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
|
||||
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
|
||||
|
||||
附录 扩展段落 14
|
||||
|
||||
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
|
||||
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
|
||||
|
||||
附录 扩展段落 15
|
||||
|
||||
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
|
||||
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
|
||||
|
||||
附录 扩展段落 16
|
||||
|
||||
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
|
||||
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
|
||||
|
||||
附录 扩展段落 17
|
||||
|
||||
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
|
||||
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
|
||||
|
||||
附录 扩展段落 18
|
||||
|
||||
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
|
||||
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
|
||||
|
||||
附录 扩展段落 19
|
||||
|
||||
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
|
||||
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
|
||||
|
||||
附录 扩展段落 20
|
||||
|
||||
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
|
||||
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
|
||||
|
||||
第三章 页面容量保障
|
||||
|
||||
本章的唯一目的是确保样本文本在所有常见 iOS 模拟器上都能产生至少三页。
|
||||
iPhone 17 Pro 的屏幕较大,默认字号下一屏可以容纳较多文字,
|
||||
因此需要更多段落来保证 page=2、page=3 始终存在。
|
||||
|
||||
第一节 模拟器差异
|
||||
|
||||
不同型号的 iPhone 模拟器屏幕尺寸差异明显。iPhone SE 系列屏幕较小,同样长度的文本能产生更多页;
|
||||
而 iPhone Pro Max 和 iPhone 17 Pro 等大屏设备,一屏可以显示更多行,
|
||||
需要更长的文本才能保证至少两页。这就是为什么样本文本需要远超一屏容量的原因。
|
||||
|
||||
第二节 分页器工作原理
|
||||
|
||||
阅读器的分页器会根据当前可用区域(屏幕尺寸减去安全区域和内边距)计算每页可显示的行数,
|
||||
然后将整篇文本按行切分为多页。字号越大、行距越宽,每页能显示的文字越少,总页数越多。
|
||||
在默认字号和行距下,iPhone 17 Pro 大约需要四千到五千个中文字符才能确保至少两页。
|
||||
|
||||
第三节 选区测试的前置条件
|
||||
|
||||
文本选区测试需要在第二页执行长按拖拽手势。如果文本只有一页,
|
||||
那么所有依赖 page=2 的选区测试都会在跳转阶段失败,根本无法到达选区交互步骤。
|
||||
因此保证至少两页是选区测试能够执行的最低前提。
|
||||
|
||||
第四节 书签与标注
|
||||
|
||||
书签测试在当前页添加标记后验证按钮状态变化。标注测试则需要先跳转到第二页,
|
||||
再执行选区和高亮操作。两者都依赖页面导航的正确性,也都需要样本文本足够长。
|
||||
|
||||
第五节 内容质量
|
||||
|
||||
与简单重复同一句话相比,使用自然语言段落能更好地模拟真实阅读场景。
|
||||
分页器在处理不同长度的段落、标题和空行时可能表现出不同的边界行为,
|
||||
因此样本文本应包含多样化的段落长度和结构。
|
||||
|
||||
第六节 持续维护
|
||||
|
||||
当阅读器的默认字号、行距或内边距发生变化时,可能需要重新评估样本文本的长度。
|
||||
如果未来默认设置导致每页显示更多文字,则需要相应增加样本文本以维持至少三页的保障。
|
||||
这是一个需要持续关注的维护点。
|
||||
|
||||
第七节 历史教训
|
||||
|
||||
在之前的测试运行中,样本文本仅有 207 行约 11000 字节,在 iPhone 17 Pro 上仅产生一页。
|
||||
这导致两个标注测试(testSelectionMenuCreatesHighlight 和 testLongPressSelectionDoesNotShowToolbars)
|
||||
因为无法跳转到第二页而失败。这个教训直接促成了本章内容的添加。
|
||||
|
||||
第四章 额外内容填充
|
||||
|
||||
以下段落用于进一步扩充文本总量,确保在最大屏幕的模拟器上也能稳定分页。
|
||||
|
||||
第一节 技术架构
|
||||
|
||||
ReadViewSDK 采用分层架构设计。底层是文本解析和分页引擎,负责将 EPUB 或 TXT 文件
|
||||
转换为结构化的页面数据。中间层是阅读器控制器,管理页面渲染、手势交互和状态同步。
|
||||
顶层是 UI 层,包括工具栏、设置面板、目录和书签等用户可见的界面元素。
|
||||
|
||||
第二节 测试策略
|
||||
|
||||
自动化测试分为多个层次。单元测试覆盖分页算法和数据模型的正确性。
|
||||
UI 测试覆盖用户可见的交互流程,包括打开书籍、翻页、选区、书签和设置。
|
||||
回归测试则在每次代码变更后运行,确保已有功能不被破坏。
|
||||
|
||||
第三节 中文排版
|
||||
|
||||
中文排版有其特殊性。字符等宽、标点禁则、段首缩进等规则都需要分页器正确处理。
|
||||
此外,中英文混排时的间距处理、数字和标点的断行行为也是分页器需要考虑的细节。
|
||||
|
||||
第四节 无障碍支持
|
||||
|
||||
阅读器的无障碍支持包括 VoiceOver 朗读、动态字体大小和高对比度模式。
|
||||
自动化测试中的 accessibilityIdentifier 既服务于 UI 测试的元素定位,
|
||||
也间接验证了无障碍标识的完整性。
|
||||
|
||||
第五节 性能考量
|
||||
|
||||
大文本文件的分页性能是一个重要指标。分页算法的时间复杂度应该是线性的,
|
||||
即处理时间与文本长度成正比。对于超长文本(如百万字小说),
|
||||
分页过程应该在合理时间内完成,不影响用户的打开体验。
|
||||
|
||||
第六节 国际化
|
||||
|
||||
虽然当前样本文本是中文,但阅读器框架需要支持多种语言。
|
||||
不同语言的分页规则可能有所不同,例如阿拉伯语的从右到左排版、
|
||||
日语的竖排模式等。这些都需要在分页器设计中预留扩展点。
|
||||
|
||||
第七节 数据持久化
|
||||
|
||||
阅读器需要持久化用户的阅读进度、书签、标注和设置。
|
||||
这些数据通常存储在 UserDefaults 或本地数据库中。
|
||||
当用户重新打开同一本书时,阅读器应该恢复到上次的阅读位置。
|
||||
|
||||
第八节 网络与离线
|
||||
|
||||
虽然当前 Demo 应用只支持本地书籍,但完整的阅读器产品通常需要支持
|
||||
在线书库、云端同步和离线缓存。这些功能不在当前测试范围内,
|
||||
但架构设计应该为未来的网络功能预留接口。
|
||||
|
||||
第九节 安全性
|
||||
|
||||
阅读器处理用户上传的 EPUB 文件时,需要防范恶意文件的攻击。
|
||||
EPUB 本质上是 ZIP 压缩包,包含 HTML、CSS 和 JavaScript 文件。
|
||||
解析器需要对这些内容进行安全检查,防止路径遍历、XSS 等安全漏洞。
|
||||
|
||||
第十节 版本兼容
|
||||
|
||||
EPUB 格式有多个版本(EPUB 2、EPUB 3),不同版本的规范差异需要解析器正确处理。
|
||||
此外,不同阅读器生成的 EPUB 文件可能有各种非标准扩展,
|
||||
解析器需要具备足够的容错能力来处理这些变体。
|
||||
|
||||
第五章 最终保障
|
||||
|
||||
本章再次追加大量段落,作为最终的页面容量保障。
|
||||
|
||||
第一节 设备矩阵
|
||||
|
||||
测试需要覆盖的设备矩阵包括:iPhone SE(小屏)、iPhone 16(中屏)、
|
||||
iPhone 17 Pro(大屏)、iPhone 17 Pro Max(超大屏)和 iPad(平板)。
|
||||
每种设备的屏幕尺寸和安全区域都不同,分页结果也会有差异。
|
||||
|
||||
第二节 测试稳定性
|
||||
|
||||
UI 自动化测试的一个常见问题是不稳定(flaky test)。
|
||||
造成不稳定的因素包括:网络延迟、动画未完成、异步操作未同步、
|
||||
设备性能差异等。为了提高测试稳定性,应该使用明确的状态等待而不是固定延时。
|
||||
|
||||
第三节 断言策略
|
||||
|
||||
测试断言应该足够具体以便定位问题,又不能过于脆弱导致误报。
|
||||
例如,验证"页面包含某个文本"比验证"页面的精确布局坐标"更稳定,
|
||||
因为后者容易因设备差异或字体渲染变化而失败。
|
||||
|
||||
第四节 测试数据管理
|
||||
|
||||
测试使用的数据(如样本书籍)应该与测试代码一起版本控制。
|
||||
这样可以确保测试环境的一致性,也方便在不同分支上复现问题。
|
||||
|
||||
第五节 持续集成
|
||||
|
||||
自动化测试应该集成到 CI/CD 流程中。每次提交代码后自动运行测试,
|
||||
可以尽早发现问题。测试结果应该有清晰的报告,包括失败的截图和日志。
|
||||
|
||||
第六节 测试覆盖率
|
||||
|
||||
测试覆盖率是衡量测试质量的一个指标,但不是唯一指标。
|
||||
高覆盖率不一定意味着高质量的测试。更重要的是测试是否覆盖了关键路径和边界情况。
|
||||
|
||||
第七节 代码审查
|
||||
|
||||
测试代码本身也需要代码审查。审查的重点包括:测试逻辑是否正确、
|
||||
断言是否有意义、测试数据是否合理、是否有遗漏的边界情况等。
|
||||
|
||||
第八节 文档维护
|
||||
|
||||
测试文档应该与代码同步更新。当测试用例发生变化时,
|
||||
文档应该及时反映这些变化。过时的文档比没有文档更有害。
|
||||
|
||||
第九节 团队协作
|
||||
|
||||
自动化测试需要团队的共同维护。开发人员在修改功能代码时应该同步更新测试,
|
||||
测试人员在发现新的测试场景时应该及时添加测试用例。
|
||||
|
||||
第十节 本章结语
|
||||
|
||||
以上内容的唯一目的是确保回归验证样本在所有目标设备上都能产生足够的页面。
|
||||
如果你正在阅读这段文字,说明样本的篇幅已经达到了预期目标。
|
||||
后续的 UI 自动化测试可以安全地依赖 page=2 的存在性。
|
||||
|
||||
附录 补充内容 A
|
||||
|
||||
本段落继续扩充文本。阅读器在渲染纯文本时,会逐行计算文本高度,
|
||||
并与可用区域进行比较。当累计高度超过可用区域时,就会开始新的一页。
|
||||
这个过程对用户是透明的,但对自动化测试来说,理解这个机制有助于设计更可靠的测试。
|
||||
|
||||
附录 补充内容 B
|
||||
|
||||
阅读器的状态标签是一个隐藏的 UILabel,专门用于自动化测试。
|
||||
它以结构化的格式报告阅读器的内部状态,包括:是否打开、当前页码、显示模式、
|
||||
工具栏可见性、高亮数量和选区状态。测试可以通过轮询这个标签来等待特定状态的出现。
|
||||
|
||||
附录 补充内容 C
|
||||
|
||||
手势交互是阅读器的核心体验之一。轻触屏幕中央可以切换工具栏的显示和隐藏。
|
||||
左滑和右滑可以翻页。长按并拖拽可以选中文本。这些手势的正确实现和测试
|
||||
是阅读器质量保证的重要组成部分。
|
||||
|
||||
附录 补充内容 D
|
||||
|
||||
设置面板允许用户自定义阅读体验。可配置的选项包括:字体大小、字体类型、
|
||||
行距、分栏数、显示模式和主题。每次设置变更都会触发页面重新分页,
|
||||
因此测试需要等待分页完成后才能进行后续操作。
|
||||
|
||||
附录 补充内容 E
|
||||
|
||||
目录面板显示书籍的章节目录。用户可以通过点击目录项快速跳转到指定章节。
|
||||
对于 EPUB 书籍,目录数据来自 OPF 文件中的 NCX 或 nav 文件。
|
||||
对于 TXT 书籍,目录则通过正则表达式匹配章节标题来生成。
|
||||
|
||||
附录 补充内容 F
|
||||
|
||||
书签功能允许用户标记感兴趣的页面。书签数据包括页码、添加时间和可选的备注。
|
||||
书签面板以列表形式展示所有书签,用户可以点击书签快速跳转到对应页面。
|
||||
删除书签需要用户确认,防止误操作。
|
||||
|
||||
附录 补充内容 G
|
||||
|
||||
标注功能(高亮)允许用户选中文本并添加高亮标记。高亮数据包括选中文本的范围、
|
||||
颜色和添加时间。高亮面板以列表形式展示所有高亮,用户可以点击高亮跳转到对应位置。
|
||||
支持多种高亮颜色,方便用户分类管理。
|
||||
|
||||
附录 补充内容 H
|
||||
|
||||
阅读器的翻页动画有三种模式:仿真翻页(模拟纸张翻动效果)、
|
||||
水平滑动(左右平移切换页面)和垂直滚动(连续滚动浏览内容)。
|
||||
用户可以根据个人喜好在设置中选择。不同模式下的分页逻辑略有不同。
|
||||
|
||||
附录 补充内容 I
|
||||
|
||||
夜间模式是阅读器的重要功能之一。在夜间模式下,背景色变为深色,
|
||||
文字色变为浅色,减少屏幕对眼睛的刺激。主题切换需要重新渲染页面内容,
|
||||
因为文字颜色和背景色的变化会影响分页结果。
|
||||
|
||||
附录 补充内容 J
|
||||
|
||||
字号调整是另一个常用功能。增大字号可以让文字更清晰易读,
|
||||
但同时会减少每页显示的文字量,增加总页数。阅读器需要在字号变化后
|
||||
重新分页并尝试保持当前阅读位置不变。
|
||||
|
||||
附录 补充内容 K
|
||||
|
||||
行距调整影响行与行之间的距离。较大的行距可以提高阅读舒适度,
|
||||
但同样会减少每页显示的行数。行距通常以倍数表示,如 1.2 倍、1.5 倍等。
|
||||
|
||||
附录 补充内容 L
|
||||
|
||||
分栏模式在 iPad 等大屏设备上特别有用。双栏模式可以在横屏时
|
||||
同时显示两页内容,提高大屏设备的空间利用率。分栏数的切换也需要重新分页。
|
||||
|
||||
附录 补充内容 M
|
||||
|
||||
阅读进度是用户关心的核心信息之一。阅读器通常通过进度条或页码
|
||||
来显示当前阅读位置。对于 EPUB 书籍,还可以显示章节内的百分比进度。
|
||||
|
||||
附录 补充内容 N
|
||||
|
||||
搜索功能允许用户在书籍中查找特定文本。搜索结果以列表形式展示,
|
||||
用户可以点击结果跳转到对应位置。搜索功能的实现需要考虑性能问题,
|
||||
特别是对于大文本文件的搜索。
|
||||
|
||||
附录 补充内容 O
|
||||
|
||||
分享功能允许用户将感兴趣的段落分享到社交媒体或发送给朋友。
|
||||
分享内容通常包括选中的文本、书籍信息和阅读位置。分享功能的实现
|
||||
需要考虑不同平台的分享接口差异。
|
||||
|
||||
附录 补充内容 P
|
||||
|
||||
阅读统计功能记录用户的阅读习惯,包括每日阅读时长、阅读页数和阅读速度。
|
||||
这些数据可以帮助用户了解自己的阅读习惯,也可以用于推荐系统。
|
||||
|
||||
附录 补充内容 Q
|
||||
|
||||
云同步功能允许用户在不同设备间同步阅读进度、书签和标注。
|
||||
云同步的实现需要考虑数据冲突解决、增量同步和离线支持等问题。
|
||||
|
||||
附录 补充内容 R
|
||||
|
||||
离线缓存功能允许用户在没有网络连接时继续阅读已下载的书籍。
|
||||
缓存策略需要考虑存储空间管理和缓存更新等问题。
|
||||
|
||||
附录 补充内容 S
|
||||
|
||||
阅读器的启动性能是一个重要指标。用户期望阅读器能够快速打开并显示内容。
|
||||
优化启动性能的方法包括:延迟加载非关键组件、预加载常用数据、
|
||||
使用缓存减少重复计算等。
|
||||
|
||||
附录 补充内容 T
|
||||
|
||||
内存管理对于阅读器来说非常重要。大文本文件的渲染需要大量内存,
|
||||
如果不当管理可能导致内存警告甚至应用崩溃。
|
||||
阅读器应该使用分页加载和及时释放不可见页面的策略来控制内存使用。
|
||||
|
||||
附录 最终段落
|
||||
|
||||
本样本的最终目标是为 ReadViewSDK 的 UI 自动化测试提供一个可靠的基础。
|
||||
通过确保样本在所有目标设备上都能产生足够的页面,
|
||||
我们可以建立一套稳定的回归测试体系,为阅读器的质量保驾护航。
|
||||
|
||||
@@ -12,15 +12,30 @@ enum IDs {
|
||||
static let readerBottomToolbar = "epub.reader.bottomToolbar"
|
||||
static let readerContent = "epub.reader.content"
|
||||
static let readerPaging = "epub.reader.paging"
|
||||
static let readerBookmark = "epub.reader.bookmark"
|
||||
static let readerToc = "epub.reader.toc"
|
||||
static let readerBookmarks = "epub.reader.bookmarks"
|
||||
static let readerHighlights = "epub.reader.highlights"
|
||||
static let readerAddHighlight = "epub.reader.add-highlight"
|
||||
static let readerSettings = "epub.reader.settings"
|
||||
static let readerTocPanel = "epub.reader.toc.panel"
|
||||
static let readerTocTable = "epub.reader.toc.table"
|
||||
static let readerTocNavBar = "epub.reader.toc.navbar"
|
||||
static let readerBookmarksPanel = "epub.reader.bookmarks.panel"
|
||||
static let readerBookmarksTable = "epub.reader.bookmarks.table"
|
||||
static let readerBookmarksNavBar = "epub.reader.bookmarks.navbar"
|
||||
static let readerSelectionText = "epub.reader.selection.text"
|
||||
static let readerSelectionHighlight = "epub.reader.selection.高亮"
|
||||
|
||||
static let settingsScroll = "epub.reader.settings.scroll"
|
||||
static let settingsBrightness = "epub.reader.settings.brightness"
|
||||
static let settingsFontIncrease = "epub.reader.settings.font.increase"
|
||||
static let settingsFontDecrease = "epub.reader.settings.font.decrease"
|
||||
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)" }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import XCTest
|
||||
|
||||
struct DemoReaderState {
|
||||
let rawValue: String
|
||||
let fields: [String: String]
|
||||
|
||||
init(rawValue: String) {
|
||||
self.rawValue = rawValue
|
||||
var parsed: [String: String] = [:]
|
||||
let separators = CharacterSet.whitespacesAndNewlines.union(CharacterSet(charactersIn: ";"))
|
||||
for token in rawValue.components(separatedBy: separators) where !token.isEmpty {
|
||||
guard let delimiterIndex = token.firstIndex(of: "=") else { continue }
|
||||
let key = String(token[..<delimiterIndex])
|
||||
let value = String(token[token.index(after: delimiterIndex)...])
|
||||
parsed[key] = value
|
||||
}
|
||||
fields = parsed
|
||||
}
|
||||
|
||||
subscript(key: String) -> String? {
|
||||
fields[key]
|
||||
}
|
||||
|
||||
var isOpened: Bool { fields["reader"] == "opened" }
|
||||
var page: Int? { fields["page"].flatMap(Int.init) }
|
||||
var display: String? { fields["display"] }
|
||||
var toolbar: String? { fields["toolbar"] }
|
||||
var highlights: Int? { fields["highlights"].flatMap(Int.init) }
|
||||
var selection: Int? { fields["selection"].flatMap(Int.init) }
|
||||
var href: String? { fields["href"] }
|
||||
var progression: Double? { fields["progression"].flatMap(Double.init) }
|
||||
var mode: String? { fields["mode"] }
|
||||
var pagination: String? { fields["pagination"] }
|
||||
var knownPages: Int? { fields["knownPages"].flatMap(Int.init) }
|
||||
var knownChapters: Int? { fields["knownChapters"].flatMap(Int.init) }
|
||||
var buildableChapters: Int? { fields["buildableChapters"].flatMap(Int.init) }
|
||||
var avoidWidows: Int? { fields["avoidWidows"].flatMap(Int.init) }
|
||||
var avoidOrphans: Int? { fields["avoidOrphans"].flatMap(Int.init) }
|
||||
var windowSize: Int? { fields["windowSize"].flatMap(Int.init) }
|
||||
var parseMs: Int? { fields["parseMs"].flatMap(Int.init) }
|
||||
var parseConcurrency: Int? { fields["parseConcurrency"].flatMap(Int.init) }
|
||||
}
|
||||
|
||||
extension XCUIApplication {
|
||||
func currentDemoReaderState() -> DemoReaderState? {
|
||||
let state = staticTexts[IDs.demoReaderState]
|
||||
guard state.exists else { return nil }
|
||||
return DemoReaderState(rawValue: state.label)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func waitForDemoReaderState(
|
||||
timeout: TimeInterval = 8,
|
||||
description: String,
|
||||
where predicate: (DemoReaderState) -> Bool
|
||||
) -> DemoReaderState {
|
||||
let stateElement = staticTexts[IDs.demoReaderState]
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
var lastState = DemoReaderState(rawValue: "<missing>")
|
||||
|
||||
while Date() < deadline {
|
||||
if stateElement.exists {
|
||||
lastState = DemoReaderState(rawValue: stateElement.label)
|
||||
if predicate(lastState) {
|
||||
return lastState
|
||||
}
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||
}
|
||||
|
||||
XCTFail("阅读器状态未满足条件: \(description),当前:\(lastState.rawValue)")
|
||||
return lastState
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,34 @@
|
||||
import XCTest
|
||||
|
||||
extension XCUIApplication {
|
||||
func launchAndOpenSampleBook(displayType: String? = nil, pageNumber: Int? = nil) {
|
||||
var args = ["--demo-book-title", "回归验证样本"]
|
||||
func launchAndOpenSampleBook(
|
||||
bookTitleQuery: String = "回归验证样本",
|
||||
displayType: String? = nil,
|
||||
pageNumber: Int? = nil,
|
||||
resetsReaderState: Bool = true,
|
||||
windowSize: Int? = nil,
|
||||
concurrency: Int? = nil,
|
||||
clearsCache: Bool = false
|
||||
) {
|
||||
var args = ["--demo-book-title", bookTitleQuery]
|
||||
if resetsReaderState {
|
||||
args.append("--demo-reset-state")
|
||||
}
|
||||
if clearsCache {
|
||||
args.append("--demo-clear-cache")
|
||||
}
|
||||
if let displayType {
|
||||
args += ["--demo-display-type", displayType]
|
||||
}
|
||||
if let pageNumber {
|
||||
args += ["--demo-page", "\(pageNumber)"]
|
||||
}
|
||||
if let windowSize {
|
||||
args += ["--demo-window-size", "\(windowSize)"]
|
||||
}
|
||||
if let concurrency {
|
||||
args += ["--demo-concurrency", "\(concurrency)"]
|
||||
}
|
||||
launchArguments = args
|
||||
launch()
|
||||
}
|
||||
@@ -17,10 +37,17 @@ extension XCUIApplication {
|
||||
func waitForReader(timeout: TimeInterval = 12) -> XCUIElement {
|
||||
let state = staticTexts[IDs.demoReaderState]
|
||||
XCTAssertTrue(state.waitForExistence(timeout: timeout), "阅读器状态标签未出现")
|
||||
XCTAssertTrue(state.label.contains("reader=opened"), "阅读器未进入 opened 状态:\(state.label)")
|
||||
let parsed = DemoReaderState(rawValue: state.label)
|
||||
XCTAssertTrue(parsed.isOpened, "阅读器未进入 opened 状态:\(state.label)")
|
||||
return state
|
||||
}
|
||||
|
||||
func waitForReaderPage(_ pageNumber: Int, timeout: TimeInterval = 12) {
|
||||
_ = waitForDemoReaderState(timeout: timeout, description: "page=\(pageNumber)") { state in
|
||||
state.page == pageNumber
|
||||
}
|
||||
}
|
||||
|
||||
func showReaderChromeIfNeeded() {
|
||||
if buttons[IDs.readerBack].exists && buttons[IDs.readerSettings].exists {
|
||||
return
|
||||
@@ -34,18 +61,33 @@ extension XCUIApplication {
|
||||
}
|
||||
}
|
||||
|
||||
func hideReaderChromeIfNeeded() {
|
||||
for _ in 0..<3 {
|
||||
guard buttons[IDs.readerBack].exists else { return }
|
||||
|
||||
let content = otherElements[IDs.readerContent]
|
||||
if content.waitForExistence(timeout: 2) {
|
||||
content.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.6)).tap()
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
|
||||
}
|
||||
}
|
||||
|
||||
func waitForReaderState(containing expectedText: String, timeout: TimeInterval = 8) {
|
||||
let state = staticTexts[IDs.demoReaderState]
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
var lastLabel = "<missing>"
|
||||
|
||||
while Date() < deadline {
|
||||
if state.exists && state.label.contains(expectedText) {
|
||||
return
|
||||
if state.exists {
|
||||
lastLabel = state.label
|
||||
if lastLabel.contains(expectedText) {
|
||||
return
|
||||
}
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||
}
|
||||
|
||||
let currentLabel = state.exists ? state.label : "<missing>"
|
||||
XCTFail("阅读器状态未包含 \(expectedText),当前:\(currentLabel)")
|
||||
XCTFail("阅读器状态未包含 \(expectedText),当前:\(lastLabel)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import XCTest
|
||||
|
||||
final class BookmarkManagementTests: XCTestCase {
|
||||
private let app = XCUIApplication()
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
func testBookmarksPanelShowsBookmarkAfterAdding() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
let bookmarkButton = app.buttons[IDs.readerBookmark]
|
||||
XCTAssertTrue(bookmarkButton.waitForExistence(timeout: 3), "书签按钮不存在")
|
||||
bookmarkButton.tap()
|
||||
app.waitForReaderState(containing: "bookmarks=1", timeout: 5)
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
let bookmarksButton = app.buttons[IDs.readerBookmarks]
|
||||
XCTAssertTrue(bookmarksButton.waitForExistence(timeout: 3), "书签列表按钮不存在")
|
||||
bookmarksButton.tap()
|
||||
|
||||
let bookmarksTable = app.tables[IDs.readerBookmarksTable]
|
||||
XCTAssertTrue(bookmarksTable.waitForExistence(timeout: 5), "书签表格未出现")
|
||||
XCTAssertTrue(waitForCellCount(in: bookmarksTable, minimum: 1, timeout: 5), "添加书签后面板应有至少 1 行数据")
|
||||
}
|
||||
|
||||
func testBookmarksPanelEmptyState() throws {
|
||||
app.launchAndOpenSampleBook(resetsReaderState: true)
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
let bookmarksButton = app.buttons[IDs.readerBookmarks]
|
||||
XCTAssertTrue(bookmarksButton.waitForExistence(timeout: 3), "书签列表按钮不存在")
|
||||
bookmarksButton.tap()
|
||||
|
||||
XCTAssertTrue(app.tables[IDs.readerBookmarksTable].waitForExistence(timeout: 5), "书签面板未出现")
|
||||
XCTAssertTrue(app.staticTexts[IDs.readerBookmarksEmptyLabel].waitForExistence(timeout: 5), "空状态 label 应存在")
|
||||
XCTAssertTrue(app.staticTexts[IDs.readerBookmarksEmptyLabel].label.contains("暂无"), "空状态应显示暂无书签")
|
||||
}
|
||||
|
||||
func testDeleteBookmarkFromPanel() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
app.buttons[IDs.readerBookmark].tap()
|
||||
app.waitForReaderState(containing: "bookmarks=1", timeout: 5)
|
||||
app.showReaderChromeIfNeeded()
|
||||
app.buttons[IDs.readerBookmarks].tap()
|
||||
|
||||
let bookmarksTable = app.tables[IDs.readerBookmarksTable]
|
||||
XCTAssertTrue(bookmarksTable.waitForExistence(timeout: 5))
|
||||
XCTAssertTrue(waitForCellCount(in: bookmarksTable, minimum: 1, timeout: 5))
|
||||
|
||||
bookmarksTable.cells.element(boundBy: 0).tap()
|
||||
|
||||
let deleteButton = app.buttons["删除书签"]
|
||||
if deleteButton.waitForExistence(timeout: 3) { deleteButton.tap() }
|
||||
|
||||
XCTAssertTrue(app.staticTexts[IDs.readerBookmarksEmptyLabel].waitForExistence(timeout: 5), "删除后应显示空状态")
|
||||
}
|
||||
|
||||
func testBookmarkIconStateToggle() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
let bookmarkButton = app.buttons[IDs.readerBookmark]
|
||||
XCTAssertTrue(bookmarkButton.waitForExistence(timeout: 3), "书签按钮不存在")
|
||||
|
||||
bookmarkButton.tap()
|
||||
XCTAssertTrue(bookmarkButton.waitForExistence(timeout: 3), "添加书签后按钮应仍然存在")
|
||||
|
||||
bookmarkButton.tap()
|
||||
XCTAssertTrue(bookmarkButton.waitForExistence(timeout: 3), "移除书签后按钮应仍然存在")
|
||||
}
|
||||
|
||||
private func waitForCellCount(
|
||||
in table: XCUIElement,
|
||||
minimum: Int,
|
||||
timeout: TimeInterval
|
||||
) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if table.cells.count >= minimum {
|
||||
return true
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||
}
|
||||
return table.cells.count >= minimum
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import XCTest
|
||||
|
||||
final class BookmarkTests: XCTestCase {
|
||||
private let app = XCUIApplication()
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
func testBookmarkButtonExistsInToolbar() {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader()
|
||||
app.showReaderChromeIfNeeded()
|
||||
|
||||
let bookmarkButton = app.buttons[IDs.readerBookmark]
|
||||
XCTAssertTrue(bookmarkButton.waitForExistence(timeout: 5), "书签按钮未出现")
|
||||
}
|
||||
|
||||
func testToggleBookmark() {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader()
|
||||
app.showReaderChromeIfNeeded()
|
||||
|
||||
let bookmarkButton = app.buttons[IDs.readerBookmark]
|
||||
XCTAssertTrue(bookmarkButton.waitForExistence(timeout: 5), "书签按钮未出现")
|
||||
bookmarkButton.tap()
|
||||
Thread.sleep(forTimeInterval: 1)
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
XCTAssertTrue(bookmarkButton.waitForExistence(timeout: 5), "书签按钮再次点击后未出现")
|
||||
bookmarkButton.tap()
|
||||
}
|
||||
|
||||
func testBookmarksButtonVisibleAfterBookmarkAdded() {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader()
|
||||
app.showReaderChromeIfNeeded()
|
||||
|
||||
let bookmarkButton = app.buttons[IDs.readerBookmark]
|
||||
XCTAssertTrue(bookmarkButton.waitForExistence(timeout: 5), "书签按钮未出现")
|
||||
bookmarkButton.tap()
|
||||
Thread.sleep(forTimeInterval: 1)
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
let bookmarksListButton = app.buttons[IDs.readerBookmarks]
|
||||
XCTAssertTrue(bookmarksListButton.waitForExistence(timeout: 5), "书签列表按钮未出现")
|
||||
}
|
||||
|
||||
func testBookmarksListButtonExists() {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader()
|
||||
app.showReaderChromeIfNeeded()
|
||||
|
||||
let bookmarksListButton = app.buttons[IDs.readerBookmarks]
|
||||
XCTAssertTrue(bookmarksListButton.waitForExistence(timeout: 5), "书签列表按钮未出现")
|
||||
}
|
||||
|
||||
func testBookmarksPanelIsAccessible() {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader()
|
||||
app.showReaderChromeIfNeeded()
|
||||
|
||||
let bookmarkButton = app.buttons[IDs.readerBookmark]
|
||||
XCTAssertTrue(bookmarkButton.waitForExistence(timeout: 5), "书签按钮未出现")
|
||||
bookmarkButton.tap()
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
let bookmarksListButton = app.buttons[IDs.readerBookmarks]
|
||||
XCTAssertTrue(bookmarksListButton.waitForExistence(timeout: 5), "书签列表按钮未出现")
|
||||
|
||||
let enabledPredicate = NSPredicate(format: "isEnabled == true")
|
||||
expectation(for: enabledPredicate, evaluatedWith: bookmarksListButton)
|
||||
waitForExpectations(timeout: 10)
|
||||
|
||||
XCTAssertTrue(bookmarksListButton.isEnabled, "书签列表按钮未启用")
|
||||
bookmarksListButton.tap()
|
||||
|
||||
let bookmarksPanel = app.tables[IDs.readerBookmarksTable]
|
||||
XCTAssertTrue(bookmarksPanel.waitForExistence(timeout: 5), "书签面板未出现")
|
||||
XCTAssertTrue(app.navigationBars[IDs.readerBookmarksNavBar].waitForExistence(timeout: 2), "书签导航栏未暴露")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import XCTest
|
||||
|
||||
final class ConcurrentParsingTests: XCTestCase {
|
||||
private let app = XCUIApplication()
|
||||
private let largeBookQuery = "凡人修仙传"
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
func testConcurrency2ParsingCompletes() throws {
|
||||
app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, resetsReaderState: true, concurrency: 2)
|
||||
app.waitForReader(timeout: 20)
|
||||
|
||||
_ = app.waitForDemoReaderState(timeout: 15, description: "concurrency=2 首开") { state in
|
||||
state.mode == "bookPageMap" && (state.page ?? 0) >= 1
|
||||
}
|
||||
|
||||
XCTAssertTrue(app.otherElements[IDs.readerContent].waitForExistence(timeout: 5), "concurrency=2 应可阅读")
|
||||
|
||||
let completed = app.waitForDemoReaderState(timeout: 90, description: "concurrency=2 后台解析完成") { state in
|
||||
state.pagination == "full"
|
||||
}
|
||||
|
||||
XCTAssertEqual(completed.pagination, "full", "concurrency=2 后台解析应最终完成")
|
||||
}
|
||||
|
||||
func testConcurrency4ParsingCompletes() throws {
|
||||
app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, resetsReaderState: true, concurrency: 4)
|
||||
app.waitForReader(timeout: 20)
|
||||
|
||||
_ = app.waitForDemoReaderState(timeout: 15, description: "concurrency=4 首开") { state in
|
||||
state.mode == "bookPageMap" && (state.page ?? 0) >= 1
|
||||
}
|
||||
|
||||
XCTAssertTrue(app.otherElements[IDs.readerContent].waitForExistence(timeout: 5), "concurrency=4 应可阅读")
|
||||
|
||||
let completed = app.waitForDemoReaderState(timeout: 90, description: "concurrency=4 后台解析完成") { state in
|
||||
state.pagination == "full"
|
||||
}
|
||||
|
||||
XCTAssertEqual(completed.pagination, "full", "concurrency=4 后台解析应最终完成")
|
||||
}
|
||||
|
||||
func testConcurrentParsingProgresses() throws {
|
||||
app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, resetsReaderState: true, concurrency: 2)
|
||||
app.waitForReader(timeout: 20)
|
||||
|
||||
let initialState = app.waitForDemoReaderState(timeout: 15, description: "获取初始进度") { state in
|
||||
state.mode == "bookPageMap" && state.knownChapters != nil
|
||||
}
|
||||
let initialKnown = initialState.knownChapters ?? 0
|
||||
|
||||
let progressed = app.waitForDemoReaderState(timeout: 60, description: "concurrency=2 后台解析推进") { state in
|
||||
guard state.mode == "bookPageMap" else { return false }
|
||||
return state.pagination == "full" || (state.knownChapters ?? 0) > initialKnown
|
||||
}
|
||||
|
||||
XCTAssertTrue(
|
||||
progressed.pagination == "full" || (progressed.knownChapters ?? 0) > initialKnown,
|
||||
"concurrency=2 后台解析应推进,初始=\(initialKnown) 当前=\(progressed.knownChapters ?? 0)"
|
||||
)
|
||||
}
|
||||
|
||||
func testConcurrentParsingWithWindowSize() throws {
|
||||
app.launchAndOpenSampleBook(
|
||||
bookTitleQuery: largeBookQuery,
|
||||
resetsReaderState: true,
|
||||
windowSize: 5,
|
||||
concurrency: 2
|
||||
)
|
||||
app.waitForReader(timeout: 20)
|
||||
|
||||
let state = app.waitForDemoReaderState(timeout: 15, description: "window=5 + concurrency=2") { state in
|
||||
state.mode == "bookPageMap" && state.knownChapters != nil
|
||||
}
|
||||
|
||||
XCTAssertEqual(state.windowSize, 5, "windowSize 应为 5")
|
||||
XCTAssertTrue(app.otherElements[IDs.readerContent].waitForExistence(timeout: 5), "组合配置应可阅读")
|
||||
|
||||
let completed = app.waitForDemoReaderState(timeout: 90, description: "组合配置后台解析完成") { state in
|
||||
state.pagination == "full"
|
||||
}
|
||||
|
||||
XCTAssertEqual(completed.pagination, "full", "组合配置后台解析应最终完成")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import XCTest
|
||||
|
||||
final class ConfigurableWindowTests: XCTestCase {
|
||||
private let app = XCUIApplication()
|
||||
private let largeBookQuery = "凡人修仙传"
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
func testWindowSize5PreloadsMoreChapters() throws {
|
||||
app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, resetsReaderState: true, windowSize: 5)
|
||||
app.waitForReader(timeout: 20)
|
||||
|
||||
let state = app.waitForDemoReaderState(timeout: 15, description: "window=5 预加载更多章节") { state in
|
||||
state.mode == "bookPageMap" && state.knownChapters != nil
|
||||
}
|
||||
|
||||
XCTAssertEqual(state.windowSize, 5, "windowSize 应为 5")
|
||||
XCTAssertTrue((state.knownChapters ?? 0) >= 3, "window=5 首开应至少预加载 3 章(当前章 + 相邻),实际=\(state.knownChapters ?? 0)")
|
||||
XCTAssertTrue(app.otherElements[IDs.readerContent].waitForExistence(timeout: 5), "window=5 首开应可阅读")
|
||||
}
|
||||
|
||||
func testWindowSize3MinimumWorks() throws {
|
||||
app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, resetsReaderState: true, windowSize: 3)
|
||||
app.waitForReader(timeout: 20)
|
||||
|
||||
let state = app.waitForDemoReaderState(timeout: 15, description: "window=3 最小窗口") { state in
|
||||
state.mode == "bookPageMap" && state.knownChapters != nil
|
||||
}
|
||||
|
||||
XCTAssertEqual(state.windowSize, 3, "windowSize 应为 3")
|
||||
XCTAssertTrue(app.otherElements[IDs.readerContent].waitForExistence(timeout: 5), "window=3 应可阅读")
|
||||
}
|
||||
|
||||
func testWindowSize15MaximumWorks() throws {
|
||||
app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, resetsReaderState: true, windowSize: 15)
|
||||
app.waitForReader(timeout: 20)
|
||||
|
||||
let state = app.waitForDemoReaderState(timeout: 15, description: "window=15 最大窗口") { state in
|
||||
state.mode == "bookPageMap" && state.knownChapters != nil
|
||||
}
|
||||
|
||||
XCTAssertEqual(state.windowSize, 15, "windowSize 应为 15")
|
||||
XCTAssertTrue((state.knownChapters ?? 0) >= 3, "window=15 首开应预加载多章")
|
||||
XCTAssertTrue(app.otherElements[IDs.readerContent].waitForExistence(timeout: 5), "window=15 应可阅读")
|
||||
}
|
||||
|
||||
func testWindowSize5ChapterNavigationSmooth() throws {
|
||||
app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, resetsReaderState: true, windowSize: 5)
|
||||
app.waitForReader(timeout: 20)
|
||||
|
||||
_ = app.waitForDemoReaderState(timeout: 15, description: "等待首章加载") { state in
|
||||
state.mode == "bookPageMap" && (state.page ?? 0) >= 1
|
||||
}
|
||||
|
||||
let paging = app.collectionViews[IDs.readerPaging]
|
||||
XCTAssertTrue(paging.waitForExistence(timeout: 5), "分页视图不存在")
|
||||
|
||||
// 快速连续翻页,window=5 应该有更多章在缓存中
|
||||
for _ in 0..<6 {
|
||||
paging.swipeLeft()
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
|
||||
}
|
||||
|
||||
let afterSwipe = app.currentDemoReaderState()
|
||||
XCTAssertNotNil(afterSwipe, "翻页后应能读取状态")
|
||||
XCTAssertTrue((afterSwipe?.page ?? 0) > 1, "连续翻页后页码应推进")
|
||||
}
|
||||
|
||||
func testEvenWindowSizeRoundsUp() throws {
|
||||
// 传 4 应被归一化为 5
|
||||
app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, resetsReaderState: true, windowSize: 4)
|
||||
app.waitForReader(timeout: 20)
|
||||
|
||||
let state = app.waitForDemoReaderState(timeout: 15, description: "偶数窗口向上取奇") { state in
|
||||
state.mode == "bookPageMap" && state.windowSize != nil
|
||||
}
|
||||
|
||||
XCTAssertEqual(state.windowSize, 5, "windowSize=4 应被归一化为 5")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import XCTest
|
||||
|
||||
final class ErrorAndEdgeCaseTests: XCTestCase {
|
||||
private let app = XCUIApplication()
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
func testVerticalScrollSwipeNavigation() throws {
|
||||
app.launchAndOpenSampleBook(displayType: "verticalscroll")
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
let paging = app.collectionViews[IDs.readerPaging]
|
||||
if paging.waitForExistence(timeout: 5) { paging.swipeUp() }
|
||||
|
||||
app.waitForReaderState(containing: "reader=opened", timeout: 5)
|
||||
}
|
||||
|
||||
func testOpenAndCloseMultipleTimes() throws {
|
||||
for iteration in 1...3 {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
let backButton = app.buttons[IDs.readerBack]
|
||||
XCTAssertTrue(backButton.waitForExistence(timeout: 3), "返回按钮不存在")
|
||||
backButton.tap()
|
||||
|
||||
let booksTable = app.tables[IDs.demoBooksTable]
|
||||
XCTAssertTrue(booksTable.waitForExistence(timeout: 5), "书列表未出现")
|
||||
}
|
||||
}
|
||||
|
||||
func testRapidPageNavigation() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
app.waitForReaderPage(1, timeout: 5)
|
||||
|
||||
let paging = app.collectionViews[IDs.readerPaging]
|
||||
guard paging.waitForExistence(timeout: 5) else { throw XCTSkip("分页视图不存在") }
|
||||
|
||||
for _ in 1...5 { paging.swipeLeft() }
|
||||
|
||||
app.waitForReaderState(containing: "reader=opened", timeout: 8)
|
||||
}
|
||||
|
||||
func testSettingsPanelScrollReachesAllControls() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
app.buttons[IDs.readerSettings].tap()
|
||||
|
||||
let settingsScroll = app.scrollViews[IDs.settingsScroll]
|
||||
XCTAssertTrue(settingsScroll.waitForExistence(timeout: 5), "设置面板未出现")
|
||||
|
||||
XCTAssertTrue(app.buttons[IDs.settingsFontIncrease].waitForExistence(timeout: 3))
|
||||
XCTAssertTrue(app.buttons[IDs.settingsBrightness].waitForExistence(timeout: 3))
|
||||
|
||||
settingsScroll.swipeUp()
|
||||
XCTAssertTrue(app.segmentedControls[IDs.settingsDisplayType].waitForExistence(timeout: 5))
|
||||
|
||||
settingsScroll.swipeUp()
|
||||
XCTAssertTrue(app.buttons[IDs.settingsTheme(5)].waitForExistence(timeout: 5))
|
||||
|
||||
app.buttons[IDs.settingsDone].tap()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import XCTest
|
||||
|
||||
final class HighlightsManagementTests: XCTestCase {
|
||||
private let app = XCUIApplication()
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
func testHighlightsPanelOpens() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
let selectionText = app.otherElements[IDs.readerSelectionText].firstMatch
|
||||
guard selectionText.waitForExistence(timeout: 5) else {
|
||||
throw XCTSkip("文本选区元素不存在")
|
||||
}
|
||||
let start = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.3, dy: 0.5))
|
||||
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||
start.press(forDuration: 0.8, thenDragTo: end)
|
||||
|
||||
let highlightMenuItem = app.menuItems["高亮"]
|
||||
if highlightMenuItem.waitForExistence(timeout: 3) { highlightMenuItem.tap() }
|
||||
else if app.buttons["高亮"].waitForExistence(timeout: 2) { app.buttons["高亮"].tap() }
|
||||
|
||||
app.waitForReaderState(containing: "highlights=", timeout: 5)
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
XCTAssertTrue(app.buttons[IDs.readerHighlights].waitForExistence(timeout: 3), "高亮列表按钮不存在")
|
||||
app.buttons[IDs.readerHighlights].tap()
|
||||
|
||||
XCTAssertTrue(app.tables[IDs.readerHighlightsTable].waitForExistence(timeout: 5), "高亮管理面板未出现")
|
||||
}
|
||||
|
||||
func testHighlightsPanelShowsCreatedHighlight() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
let selectionText = app.otherElements[IDs.readerSelectionText].firstMatch
|
||||
guard selectionText.waitForExistence(timeout: 5) else { throw XCTSkip("文本选区元素不存在") }
|
||||
let start = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.3, dy: 0.5))
|
||||
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||
start.press(forDuration: 0.8, thenDragTo: end)
|
||||
|
||||
let highlightMenuItem = app.menuItems["高亮"]
|
||||
if highlightMenuItem.waitForExistence(timeout: 3) { highlightMenuItem.tap() }
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
app.buttons[IDs.readerHighlights].tap()
|
||||
|
||||
let highlightsTable = app.tables[IDs.readerHighlightsTable]
|
||||
XCTAssertTrue(highlightsTable.waitForExistence(timeout: 5), "高亮表格未出现")
|
||||
XCTAssertTrue(waitForCellCount(in: highlightsTable, minimum: 1, timeout: 5), "创建高亮后面板应至少有 1 行数据")
|
||||
}
|
||||
|
||||
func testHighlightsPanelFilterAll() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
let selectionText = app.otherElements[IDs.readerSelectionText].firstMatch
|
||||
guard selectionText.waitForExistence(timeout: 5) else { throw XCTSkip("文本选区元素不存在") }
|
||||
let start = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.3, dy: 0.5))
|
||||
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||
start.press(forDuration: 0.8, thenDragTo: end)
|
||||
|
||||
let highlightMenuItem = app.menuItems["高亮"]
|
||||
if highlightMenuItem.waitForExistence(timeout: 3) { highlightMenuItem.tap() }
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
app.buttons[IDs.readerHighlights].tap()
|
||||
|
||||
let filterControl = app.segmentedControls[IDs.readerHighlightsFilter]
|
||||
XCTAssertTrue(filterControl.waitForExistence(timeout: 5), "筛选控件未出现")
|
||||
filterControl.buttons.element(boundBy: 0).tap()
|
||||
|
||||
let highlightsTable = app.tables[IDs.readerHighlightsTable]
|
||||
XCTAssertTrue(highlightsTable.waitForExistence(timeout: 3))
|
||||
XCTAssertTrue(waitForCellCount(in: highlightsTable, minimum: 1, timeout: 5), "筛选全部后应有高亮数据")
|
||||
}
|
||||
|
||||
func testHighlightsPanelEmptyState() throws {
|
||||
app.launchAndOpenSampleBook(resetsReaderState: true)
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
let highlightsButton = app.buttons[IDs.readerHighlights]
|
||||
XCTAssertTrue(highlightsButton.waitForExistence(timeout: 3), "高亮列表按钮不存在")
|
||||
highlightsButton.tap()
|
||||
|
||||
XCTAssertTrue(app.tables[IDs.readerHighlightsTable].waitForExistence(timeout: 5), "高亮管理面板未出现")
|
||||
XCTAssertTrue(app.staticTexts[IDs.readerHighlightsEmptyLabel].waitForExistence(timeout: 5), "空状态 label 应存在")
|
||||
XCTAssertTrue(app.staticTexts[IDs.readerHighlightsEmptyLabel].label.contains("暂无"), "空状态应显示暂无标注")
|
||||
}
|
||||
|
||||
func testDeleteHighlightFromPanel() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
let selectionText = app.otherElements[IDs.readerSelectionText].firstMatch
|
||||
guard selectionText.waitForExistence(timeout: 5) else { throw XCTSkip("文本选区元素不存在") }
|
||||
let start = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.3, dy: 0.5))
|
||||
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||
start.press(forDuration: 0.8, thenDragTo: end)
|
||||
|
||||
let highlightMenuItem = app.menuItems["高亮"]
|
||||
if highlightMenuItem.waitForExistence(timeout: 3) { highlightMenuItem.tap() }
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
app.buttons[IDs.readerHighlights].tap()
|
||||
|
||||
let highlightsTable = app.tables[IDs.readerHighlightsTable]
|
||||
XCTAssertTrue(highlightsTable.waitForExistence(timeout: 5))
|
||||
XCTAssertTrue(waitForCellCount(in: highlightsTable, minimum: 1, timeout: 5))
|
||||
|
||||
highlightsTable.cells.element(boundBy: 0).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()
|
||||
}
|
||||
|
||||
XCTAssertTrue(app.staticTexts[IDs.readerHighlightsEmptyLabel].waitForExistence(timeout: 5), "删除后应显示空状态")
|
||||
}
|
||||
|
||||
private func waitForCellCount(
|
||||
in table: XCUIElement,
|
||||
minimum: Int,
|
||||
timeout: TimeInterval
|
||||
) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if table.cells.count >= minimum {
|
||||
return true
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||
}
|
||||
return table.cells.count >= minimum
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import XCTest
|
||||
|
||||
final class LargeBookOnDemandTests: XCTestCase {
|
||||
private let app = XCUIApplication()
|
||||
private let largeBookQuery = "凡人修仙传"
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
func testLargeBookInitialOpenIsReadableWhilePaginationIsPartial() throws {
|
||||
app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, resetsReaderState: true)
|
||||
app.waitForReader(timeout: 20)
|
||||
|
||||
let partialState = app.waitForDemoReaderState(timeout: 15, description: "大书首开进入局部分页") { state in
|
||||
state.mode == "bookPageMap" && state.pagination == "partial" && (state.page ?? 0) >= 1
|
||||
}
|
||||
|
||||
XCTAssertEqual(partialState.mode, "bookPageMap")
|
||||
XCTAssertEqual(partialState.pagination, "partial")
|
||||
XCTAssertTrue(app.otherElements[IDs.readerContent].waitForExistence(timeout: 5), "首开局部分页阶段应已可阅读")
|
||||
}
|
||||
|
||||
func testLargeBookBackgroundPaginationProgresses() throws {
|
||||
app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, resetsReaderState: true)
|
||||
app.waitForReader(timeout: 20)
|
||||
|
||||
let initialState = app.waitForDemoReaderState(timeout: 15, description: "获取初始分页进度") { state in
|
||||
state.mode == "bookPageMap" && state.knownChapters != nil && state.buildableChapters != nil
|
||||
}
|
||||
let initialKnownChapters = initialState.knownChapters ?? 0
|
||||
let initialKnownPages = initialState.knownPages ?? 0
|
||||
|
||||
let progressedState = app.waitForDemoReaderState(timeout: 45, description: "后台分页推进") { state in
|
||||
guard state.mode == "bookPageMap" else { return false }
|
||||
if state.pagination == "full" {
|
||||
return true
|
||||
}
|
||||
return (state.knownChapters ?? 0) > initialKnownChapters || (state.knownPages ?? 0) > initialKnownPages
|
||||
}
|
||||
|
||||
XCTAssertEqual(progressedState.mode, "bookPageMap")
|
||||
XCTAssertTrue(
|
||||
(progressedState.knownChapters ?? 0) >= initialKnownChapters ||
|
||||
(progressedState.knownPages ?? 0) >= initialKnownPages
|
||||
)
|
||||
}
|
||||
|
||||
func testLargeBookSecondOpenRestoresFromCache() throws {
|
||||
app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, resetsReaderState: true)
|
||||
app.waitForReader(timeout: 20)
|
||||
|
||||
_ = app.waitForDemoReaderState(timeout: 60, description: "首次打开完成全书缓存") { state in
|
||||
state.mode == "bookPageMap" && state.pagination == "full" && (state.buildableChapters ?? 0) > 0
|
||||
}
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
XCTAssertTrue(app.buttons[IDs.readerBack].waitForExistence(timeout: 5), "返回按钮不存在")
|
||||
app.buttons[IDs.readerBack].tap()
|
||||
XCTAssertTrue(app.tables[IDs.demoBooksTable].waitForExistence(timeout: 5), "书架列表未出现")
|
||||
|
||||
app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, resetsReaderState: false)
|
||||
app.waitForReader(timeout: 15)
|
||||
|
||||
let reopenedState = app.waitForDemoReaderState(timeout: 10, description: "二开直接恢复完整缓存") { state in
|
||||
state.mode == "bookPageMap" && state.pagination == "full" && (state.knownChapters ?? 0) == (state.buildableChapters ?? -1)
|
||||
}
|
||||
|
||||
XCTAssertEqual(reopenedState.pagination, "full")
|
||||
XCTAssertEqual(reopenedState.knownChapters, reopenedState.buildableChapters)
|
||||
}
|
||||
|
||||
func testLargeBookContinuousPagingExtendsKnownPageMap() throws {
|
||||
app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, displayType: "scroll", resetsReaderState: true)
|
||||
app.waitForReader(timeout: 20)
|
||||
app.waitForReaderState(containing: "display=horizontalScroll", timeout: 8)
|
||||
|
||||
let initialState = app.waitForDemoReaderState(timeout: 15, description: "初始局部分页") { state in
|
||||
state.mode == "bookPageMap" && state.knownPages != nil && state.page != nil
|
||||
}
|
||||
let initialKnownPages = initialState.knownPages ?? 0
|
||||
let initialPage = initialState.page ?? 0
|
||||
|
||||
let paging = app.collectionViews[IDs.readerPaging]
|
||||
XCTAssertTrue(paging.waitForExistence(timeout: 5), "分页视图不存在")
|
||||
|
||||
for _ in 0..<4 {
|
||||
paging.swipeLeft()
|
||||
}
|
||||
|
||||
let extendedState = app.waitForDemoReaderState(timeout: 20, description: "连续翻页触发扩窗") { state in
|
||||
guard state.mode == "bookPageMap" else { return false }
|
||||
let pageAdvanced = (state.page ?? 0) > initialPage
|
||||
let pageMapExtended = (state.knownPages ?? 0) > initialKnownPages || state.pagination == "full"
|
||||
return pageAdvanced && pageMapExtended
|
||||
}
|
||||
|
||||
XCTAssertTrue((extendedState.page ?? 0) > initialPage, "连续翻页后当前页应向后推进")
|
||||
XCTAssertTrue(
|
||||
(extendedState.knownPages ?? 0) > initialKnownPages || extendedState.pagination == "full",
|
||||
"连续翻页后已知页图应扩窗或直接完成全量分页"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import XCTest
|
||||
|
||||
final class LocationPersistenceTests: XCTestCase {
|
||||
private let app = XCUIApplication()
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
func testReadingPositionRestoredOnReopen() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
_ = app.waitForDemoReaderState(timeout: 5, description: "存在页码") { $0.page != nil }
|
||||
|
||||
let paging = app.collectionViews[IDs.readerPaging]
|
||||
if paging.waitForExistence(timeout: 5) {
|
||||
paging.swipeLeft()
|
||||
}
|
||||
|
||||
let stateAfterSwipe = app.waitForDemoReaderState(timeout: 8, description: "翻页后页码变化") { state in
|
||||
(state.page ?? 0) > 1
|
||||
}
|
||||
let pageAfterSwipe = stateAfterSwipe.page ?? 0
|
||||
|
||||
guard pageAfterSwipe > 1 else {
|
||||
throw XCTSkip("当前翻页模式下未能翻到第 2 页")
|
||||
}
|
||||
|
||||
let backButton = app.buttons[IDs.readerBack]
|
||||
if backButton.waitForExistence(timeout: 3) { backButton.tap() }
|
||||
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
let restoredState = app.waitForDemoReaderState(timeout: 8, description: "恢复阅读位置") { state in
|
||||
state.page != nil
|
||||
}
|
||||
let restoredPage = restoredState.page ?? 0
|
||||
XCTAssertEqual(restoredPage, pageAfterSwipe, "重新打开后应恢复到上次阅读页码")
|
||||
}
|
||||
|
||||
func testResetStateLaunchesAtFirstPage() throws {
|
||||
app.launchAndOpenSampleBook(resetsReaderState: true)
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
let state = app.waitForDemoReaderState(timeout: 5, description: "首开第 1 页") { state in
|
||||
state.page != nil
|
||||
}
|
||||
let page = state.page ?? 0
|
||||
XCTAssertEqual(page, 1, "重置状态后应从第 1 页开始")
|
||||
}
|
||||
|
||||
func testPositionSurvivesFontChange() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
let paging = app.collectionViews[IDs.readerPaging]
|
||||
if paging.waitForExistence(timeout: 5) { paging.swipeLeft() }
|
||||
app.waitForReaderState(containing: "page=", timeout: 5)
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
if app.buttons[IDs.readerSettings].waitForExistence(timeout: 3) { app.buttons[IDs.readerSettings].tap() }
|
||||
if app.buttons[IDs.settingsFontIncrease].waitForExistence(timeout: 5) { app.buttons[IDs.settingsFontIncrease].tap() }
|
||||
if app.buttons[IDs.settingsDone].waitForExistence(timeout: 3) { app.buttons[IDs.settingsDone].tap() }
|
||||
|
||||
let backButton = app.buttons[IDs.readerBack]
|
||||
if backButton.waitForExistence(timeout: 3) { backButton.tap() }
|
||||
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
app.waitForReaderState(containing: "reader=opened", timeout: 8)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import XCTest
|
||||
|
||||
final class MetadataParseBenchmarkTests: XCTestCase {
|
||||
private let app = XCUIApplication()
|
||||
private let largeBookQuery = "凡人修仙传"
|
||||
private let parseTimeout: TimeInterval = 900
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
/// 单次解析基准:用指定并发数打开大书,等待解析完成,返回 parseMs
|
||||
private func runParseBenchmark(concurrency: Int) throws -> (parseMs: Int, concurrency: Int) {
|
||||
app.launchAndOpenSampleBook(
|
||||
bookTitleQuery: largeBookQuery,
|
||||
resetsReaderState: true,
|
||||
concurrency: concurrency,
|
||||
clearsCache: true
|
||||
)
|
||||
app.waitForReader(timeout: 20)
|
||||
|
||||
// 等待首屏可读
|
||||
_ = app.waitForDemoReaderState(timeout: 15, description: "首屏加载 concurrency=\(concurrency)") { state in
|
||||
state.mode == "bookPageMap" && (state.page ?? 0) >= 1
|
||||
}
|
||||
|
||||
// 等待 parseMs 写入(OperationQueue 完成时设置,早于 pagination=full)
|
||||
let completed = app.waitForDemoReaderState(timeout: parseTimeout, description: "解析完成 concurrency=\(concurrency)") { state in
|
||||
(state.parseMs ?? 0) > 0
|
||||
}
|
||||
|
||||
let parseMs = completed.parseMs ?? 0
|
||||
let actualConcurrency = completed.parseConcurrency ?? 0
|
||||
|
||||
XCTAssertTrue(parseMs > 0, "parseMs 应大于 0")
|
||||
XCTAssertEqual(actualConcurrency, concurrency, "实际并发数应等于配置值")
|
||||
|
||||
// pagination=full 可能因个别章节失败而不达到,不阻塞基准测试
|
||||
|
||||
// 返回书架
|
||||
app.showReaderChromeIfNeeded()
|
||||
if app.buttons[IDs.readerBack].waitForExistence(timeout: 5) {
|
||||
app.buttons[IDs.readerBack].tap()
|
||||
}
|
||||
XCTAssertTrue(app.tables[IDs.demoBooksTable].waitForExistence(timeout: 10), "返回书架失败")
|
||||
|
||||
return (parseMs, actualConcurrency)
|
||||
}
|
||||
|
||||
func testMetadataParseBenchmark() throws {
|
||||
let cpuCount = ProcessInfo.processInfo.activeProcessorCount
|
||||
|
||||
// 1. 串行基准
|
||||
let serial = try runParseBenchmark(concurrency: 1)
|
||||
print("[Benchmark] concurrency=1 parseMs=\(serial.parseMs)")
|
||||
|
||||
// 2. CPU 核心数并发
|
||||
let parallel = try runParseBenchmark(concurrency: cpuCount)
|
||||
print("[Benchmark] concurrency=\(cpuCount) parseMs=\(parallel.parseMs)")
|
||||
|
||||
// 3. 加速比
|
||||
let speedup = Double(serial.parseMs) / max(Double(parallel.parseMs), 1)
|
||||
let efficiency = speedup / Double(cpuCount) * 100
|
||||
|
||||
print("[Benchmark] ---- 结果 ----")
|
||||
print("[Benchmark] CPU cores: \(cpuCount)")
|
||||
print("[Benchmark] serial(1): \(serial.parseMs)ms")
|
||||
print("[Benchmark] parallel(\(cpuCount)): \(parallel.parseMs)ms")
|
||||
print("[Benchmark] speedup: \(String(format: "%.2f", speedup))x")
|
||||
print("[Benchmark] efficiency: \(String(format: "%.1f", efficiency))%")
|
||||
|
||||
if efficiency > 70 {
|
||||
print("[Benchmark] 结论: 渲染受限,并发数=\(cpuCount) 合理")
|
||||
} else if efficiency > 40 {
|
||||
print("[Benchmark] 结论: 有 I/O 等待,可试探 concurrency=\(Int(Double(cpuCount) * 1.25))~\(Int(Double(cpuCount) * 1.5))")
|
||||
} else {
|
||||
print("[Benchmark] 结论: I/O 或锁竞争严重,建议降低并发数或排查瓶颈")
|
||||
}
|
||||
|
||||
// 并行应该比串行快
|
||||
XCTAssertTrue(parallel.parseMs < serial.parseMs, "并发解析(\(parallel.parseMs)ms)应快于串行(\(serial.parseMs)ms)")
|
||||
}
|
||||
|
||||
func testMetadataParseScaling() throws {
|
||||
let cpuCount = ProcessInfo.processInfo.activeProcessorCount
|
||||
let concurrency1 = try runParseBenchmark(concurrency: 1)
|
||||
let concurrencyN = try runParseBenchmark(concurrency: cpuCount)
|
||||
|
||||
let speedup = Double(concurrency1.parseMs) / max(Double(concurrencyN.parseMs), 1)
|
||||
|
||||
print("[Scaling] concurrency=1: \(concurrency1.parseMs)ms")
|
||||
print("[Scaling] concurrency=\(cpuCount): \(concurrencyN.parseMs)ms")
|
||||
print("[Scaling] speedup: \(String(format: "%.2f", speedup))x on \(cpuCount) cores")
|
||||
|
||||
XCTAssertTrue(speedup >= 1.5, "并发加速比(\(String(format: "%.2f", speedup)))过低,可能存在瓶颈")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import XCTest
|
||||
|
||||
final class PageNavigationTests: XCTestCase {
|
||||
private let app = XCUIApplication()
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
func testContentAreaExistsForNavigation() {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader()
|
||||
|
||||
let content = app.otherElements[IDs.readerContent]
|
||||
XCTAssertTrue(content.waitForExistence(timeout: 5), "阅读内容区域未出现")
|
||||
}
|
||||
|
||||
func testPagingViewExistsInScrollMode() {
|
||||
app.launchAndOpenSampleBook(displayType: "scroll")
|
||||
app.waitForReader()
|
||||
app.waitForReaderState(containing: "display=horizontalScroll", timeout: 5)
|
||||
|
||||
let paging = app.collectionViews[IDs.readerPaging]
|
||||
XCTAssertTrue(paging.waitForExistence(timeout: 5), "分页滚动视图未出现")
|
||||
}
|
||||
|
||||
func testPagingViewExistsInVerticalScrollMode() {
|
||||
app.launchAndOpenSampleBook(displayType: "vertical")
|
||||
app.waitForReader()
|
||||
app.waitForReaderState(containing: "display=verticalScroll", timeout: 5)
|
||||
|
||||
let paging = app.collectionViews[IDs.readerPaging]
|
||||
XCTAssertTrue(paging.waitForExistence(timeout: 5), "分页滚动视图未出现")
|
||||
}
|
||||
|
||||
func testSwipeInHorizontalScrollMode() {
|
||||
app.launchAndOpenSampleBook(displayType: "scroll")
|
||||
app.waitForReader()
|
||||
app.waitForReaderState(containing: "display=horizontalScroll", timeout: 5)
|
||||
|
||||
let paging = app.collectionViews[IDs.readerPaging]
|
||||
XCTAssertTrue(paging.waitForExistence(timeout: 5))
|
||||
|
||||
let beforeState = app.waitForDemoReaderState(timeout: 5, description: "滑动前页码") { $0.page != nil }
|
||||
let beforePage = beforeState.page ?? 0
|
||||
|
||||
paging.swipeLeft()
|
||||
let afterState = app.waitForDemoReaderState(timeout: 8, description: "水平滑动后页码变化") { state in
|
||||
guard let page = state.page else { return false }
|
||||
return page != beforePage
|
||||
}
|
||||
XCTAssertNotEqual(beforePage, afterState.page, "水平滑动后页码未变化")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import XCTest
|
||||
|
||||
final class ReaderAnnotationExtendedTests: XCTestCase {
|
||||
private let app = XCUIApplication()
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
func testBottomToolbarHasAddHighlightButton() {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader()
|
||||
app.showReaderChromeIfNeeded()
|
||||
|
||||
let addHighlightButton = app.buttons[IDs.readerAddHighlight]
|
||||
XCTAssertTrue(addHighlightButton.waitForExistence(timeout: 5), "底部工具栏标注按钮未出现")
|
||||
}
|
||||
|
||||
func testBottomToolbarHasHighlightsButton() {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader()
|
||||
app.showReaderChromeIfNeeded()
|
||||
|
||||
let highlightsButton = app.buttons[IDs.readerHighlights]
|
||||
XCTAssertTrue(highlightsButton.waitForExistence(timeout: 5), "底部工具栏高亮列表按钮未出现")
|
||||
}
|
||||
|
||||
func testBottomToolbarButtonsLayout() {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader()
|
||||
app.showReaderChromeIfNeeded()
|
||||
|
||||
XCTAssertTrue(app.buttons[IDs.readerToc].waitForExistence(timeout: 5), "目录按钮未出现")
|
||||
XCTAssertTrue(app.buttons[IDs.readerBookmarks].exists, "书签按钮未出现")
|
||||
XCTAssertTrue(app.buttons[IDs.readerHighlights].exists, "高亮按钮未出现")
|
||||
XCTAssertTrue(app.buttons[IDs.readerAddHighlight].exists, "标注按钮未出现")
|
||||
XCTAssertTrue(app.buttons[IDs.readerSettings].exists, "设置按钮未出现")
|
||||
}
|
||||
|
||||
func testSelectionTextElementExists() {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader()
|
||||
|
||||
let selectionText = app.otherElements[IDs.readerSelectionText].firstMatch
|
||||
XCTAssertTrue(selectionText.waitForExistence(timeout: 5), "文本选区元素未出现")
|
||||
}
|
||||
|
||||
func testReaderStateReportsPageInfo() {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader()
|
||||
|
||||
let state = app.staticTexts[IDs.demoReaderState]
|
||||
XCTAssertTrue(state.waitForExistence(timeout: 5))
|
||||
XCTAssertTrue(state.label.contains("reader=opened"), "状态未包含 reader=opened")
|
||||
XCTAssertTrue(state.label.contains("page="), "状态未包含 page 信息")
|
||||
XCTAssertTrue(state.label.contains("display="), "状态未包含 display 信息")
|
||||
}
|
||||
|
||||
func testReaderStateReportsHighlightCount() {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader()
|
||||
|
||||
let state = app.staticTexts[IDs.demoReaderState]
|
||||
XCTAssertTrue(state.waitForExistence(timeout: 5))
|
||||
XCTAssertTrue(state.label.contains("highlights=0"), "初始高亮数应为 0")
|
||||
}
|
||||
}
|
||||
@@ -10,13 +10,17 @@ final class ReaderAnnotationTests: XCTestCase {
|
||||
func testSelectionMenuCreatesHighlight() {
|
||||
app.launchAndOpenSampleBook(pageNumber: 2)
|
||||
app.waitForReader()
|
||||
app.waitForReaderPage(2)
|
||||
app.hideReaderChromeIfNeeded()
|
||||
|
||||
let content = app.otherElements[IDs.readerSelectionText].firstMatch
|
||||
XCTAssertTrue(content.waitForExistence(timeout: 5), "阅读内容区域未出现")
|
||||
|
||||
let start = content.coordinate(withNormalizedOffset: CGVector(dx: 0.35, dy: 0.42))
|
||||
let end = content.coordinate(withNormalizedOffset: CGVector(dx: 0.68, dy: 0.42))
|
||||
start.press(forDuration: 0.6, thenDragTo: end)
|
||||
app.waitForReaderState(containing: "toolbar=hidden", timeout: 5)
|
||||
|
||||
let start = content.coordinate(withNormalizedOffset: CGVector(dx: 0.35, dy: 0.45))
|
||||
let end = content.coordinate(withNormalizedOffset: CGVector(dx: 0.68, dy: 0.45))
|
||||
start.press(forDuration: 0.8, thenDragTo: end)
|
||||
|
||||
app.waitForReaderState(containing: "selection=1", timeout: 5)
|
||||
|
||||
@@ -36,15 +40,19 @@ final class ReaderAnnotationTests: XCTestCase {
|
||||
func testLongPressSelectionDoesNotShowToolbars() {
|
||||
app.launchAndOpenSampleBook(pageNumber: 2)
|
||||
app.waitForReader()
|
||||
app.waitForReaderPage(2)
|
||||
app.hideReaderChromeIfNeeded()
|
||||
|
||||
let content = app.otherElements[IDs.readerSelectionText].firstMatch
|
||||
XCTAssertTrue(content.waitForExistence(timeout: 5), "阅读内容区域未出现")
|
||||
|
||||
let start = content.coordinate(withNormalizedOffset: CGVector(dx: 0.35, dy: 0.42))
|
||||
let end = content.coordinate(withNormalizedOffset: CGVector(dx: 0.68, dy: 0.42))
|
||||
start.press(forDuration: 0.6, thenDragTo: end)
|
||||
|
||||
app.waitForReaderState(containing: "selection=1", timeout: 5)
|
||||
app.waitForReaderState(containing: "toolbar=hidden", timeout: 5)
|
||||
|
||||
let start = content.coordinate(withNormalizedOffset: CGVector(dx: 0.35, dy: 0.45))
|
||||
let end = content.coordinate(withNormalizedOffset: CGVector(dx: 0.68, dy: 0.45))
|
||||
start.press(forDuration: 0.8, thenDragTo: end)
|
||||
|
||||
app.waitForReaderState(containing: "selection=1", timeout: 8)
|
||||
app.waitForReaderState(containing: "toolbar=hidden", timeout: 3)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import XCTest
|
||||
|
||||
final class SelectionMenuTests: XCTestCase {
|
||||
private let app = XCUIApplication()
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
func testSelectionMenuShowsCopyOption() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
let selectionText = app.otherElements[IDs.readerSelectionText].firstMatch
|
||||
guard selectionText.waitForExistence(timeout: 5) else { throw XCTSkip("文本选区元素不存在") }
|
||||
|
||||
let start = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.3, dy: 0.5))
|
||||
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), "选区菜单应包含拷贝选项")
|
||||
}
|
||||
|
||||
func testSelectionMenuShowsAnnotateOption() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
let selectionText = app.otherElements[IDs.readerSelectionText].firstMatch
|
||||
guard selectionText.waitForExistence(timeout: 5) else { throw XCTSkip("文本选区元素不存在") }
|
||||
|
||||
let start = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.3, dy: 0.5))
|
||||
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), "选区菜单应包含批注选项")
|
||||
}
|
||||
|
||||
func testTapBlankAreaClearsSelection() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
let selectionText = app.otherElements[IDs.readerSelectionText].firstMatch
|
||||
guard selectionText.waitForExistence(timeout: 5) else { throw XCTSkip("文本选区元素不存在") }
|
||||
|
||||
let start = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.3, dy: 0.5))
|
||||
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), "选区菜单应出现")
|
||||
|
||||
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)
|
||||
XCTAssertTrue(menuGone, "点击空白区域后选区菜单应消失")
|
||||
}
|
||||
|
||||
func testCopySelectedText() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
let selectionText = app.otherElements[IDs.readerSelectionText].firstMatch
|
||||
guard selectionText.waitForExistence(timeout: 5) else { throw XCTSkip("文本选区元素不存在") }
|
||||
|
||||
let start = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.3, dy: 0.5))
|
||||
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||
start.press(forDuration: 0.8, thenDragTo: end)
|
||||
|
||||
let copyMenuItem = app.menuItems["拷贝"]
|
||||
if copyMenuItem.waitForExistence(timeout: 3) { copyMenuItem.tap() }
|
||||
|
||||
app.waitForReaderState(containing: "reader=opened", timeout: 5)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import XCTest
|
||||
|
||||
final class SettingsEffectTests: XCTestCase {
|
||||
private let app = XCUIApplication()
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
func testFontSizeChangeUpdatesContent() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
app.waitForReaderState(containing: "page=", timeout: 5)
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
XCTAssertTrue(app.buttons[IDs.readerSettings].waitForExistence(timeout: 3), "设置按钮不存在")
|
||||
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()
|
||||
|
||||
XCTAssertTrue(app.buttons[IDs.settingsDone].waitForExistence(timeout: 3), "完成按钮不存在")
|
||||
app.buttons[IDs.settingsDone].tap()
|
||||
|
||||
app.waitForReaderState(containing: "reader=opened", timeout: 8)
|
||||
}
|
||||
|
||||
func testThemeSwitchChangesBackground() 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 darkThemeButton = app.buttons[IDs.settingsTheme(5)]
|
||||
if darkThemeButton.waitForExistence(timeout: 3) { darkThemeButton.tap() }
|
||||
|
||||
app.buttons[IDs.settingsDone].tap()
|
||||
|
||||
let contentView = app.otherElements[IDs.readerContentView]
|
||||
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.waitForReaderState(containing: "reader=opened", timeout: 8)
|
||||
}
|
||||
|
||||
func testLineHeightChangeUpdatesContent() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
app.buttons[IDs.readerSettings].tap()
|
||||
XCTAssertTrue(app.scrollViews[IDs.settingsScroll].waitForExistence(timeout: 5))
|
||||
|
||||
let lineHeightControl = app.segmentedControls[IDs.settingsLineHeight]
|
||||
if lineHeightControl.waitForExistence(timeout: 3) && lineHeightControl.buttons.count > 1 {
|
||||
lineHeightControl.buttons.element(boundBy: 1).tap()
|
||||
}
|
||||
|
||||
app.buttons[IDs.settingsDone].tap()
|
||||
app.waitForReaderState(containing: "reader=opened", timeout: 8)
|
||||
}
|
||||
|
||||
func testDisplayTypeChangeRePaginates() throws {
|
||||
app.launchAndOpenSampleBook(displayType: "horizontalscroll")
|
||||
app.waitForReader(timeout: 12)
|
||||
app.waitForReaderState(containing: "display=horizontalScroll", timeout: 5)
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
app.buttons[IDs.readerSettings].tap()
|
||||
XCTAssertTrue(app.scrollViews[IDs.settingsScroll].waitForExistence(timeout: 5))
|
||||
|
||||
let displayTypeControl = app.segmentedControls[IDs.settingsDisplayType]
|
||||
if displayTypeControl.waitForExistence(timeout: 3) && displayTypeControl.buttons.count > 0 {
|
||||
displayTypeControl.buttons.element(boundBy: 0).tap()
|
||||
}
|
||||
|
||||
app.buttons[IDs.settingsDone].tap()
|
||||
app.waitForReaderState(containing: "display=", timeout: 8)
|
||||
}
|
||||
|
||||
func testDefaultBodyPaginationDoesNotEnableWidowOrphanCompaction() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
let state = app.waitForDemoReaderState(timeout: 8, description: "默认正文分页策略") { state in
|
||||
state.avoidWidows != nil && state.avoidOrphans != nil
|
||||
}
|
||||
|
||||
XCTAssertEqual(state.avoidWidows, 0, "默认正文分页不应启用 widow compaction,避免页尾明显留白")
|
||||
XCTAssertEqual(state.avoidOrphans, 0, "默认正文分页不应启用 orphan compaction,避免提前换页")
|
||||
}
|
||||
|
||||
func testSettingsPersistAfterReopen() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
app.buttons[IDs.readerSettings].tap()
|
||||
|
||||
XCTAssertTrue(app.buttons[IDs.settingsFontIncrease].waitForExistence(timeout: 5))
|
||||
let fontValueLabel = app.staticTexts[IDs.settingsFontValue]
|
||||
XCTAssertTrue(fontValueLabel.waitForExistence(timeout: 3))
|
||||
let originalFontValue = fontValueLabel.label
|
||||
|
||||
app.buttons[IDs.settingsFontIncrease].tap()
|
||||
let newFontValue = fontValueLabel.label
|
||||
XCTAssertNotEqual(newFontValue, originalFontValue, "点击增大后字号 label 应变化")
|
||||
|
||||
app.buttons[IDs.settingsDone].tap()
|
||||
app.buttons[IDs.readerBack].tap()
|
||||
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
app.buttons[IDs.readerSettings].tap()
|
||||
|
||||
let persistedFontValue = app.staticTexts[IDs.settingsFontValue]
|
||||
if persistedFontValue.waitForExistence(timeout: 5) {
|
||||
XCTAssertEqual(persistedFontValue.label, newFontValue, "重新打开后字号应持久化为之前的值")
|
||||
}
|
||||
|
||||
app.buttons[IDs.settingsDone].tap()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import XCTest
|
||||
|
||||
final class SettingsExtendedTests: XCTestCase {
|
||||
private let app = XCUIApplication()
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
private func openSettings() {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader()
|
||||
app.showReaderChromeIfNeeded()
|
||||
|
||||
XCTAssertTrue(app.buttons[IDs.readerSettings].waitForExistence(timeout: 5))
|
||||
app.buttons[IDs.readerSettings].tap()
|
||||
XCTAssertTrue(app.scrollViews[IDs.settingsScroll].waitForExistence(timeout: 5))
|
||||
}
|
||||
|
||||
func testBrightnessSliderExists() {
|
||||
openSettings()
|
||||
let slider = app.sliders[IDs.settingsBrightness]
|
||||
XCTAssertTrue(slider.waitForExistence(timeout: 3), "亮度滑块未出现")
|
||||
}
|
||||
|
||||
func testLineHeightControl() {
|
||||
openSettings()
|
||||
let lineHeight = app.segmentedControls[IDs.settingsLineHeight]
|
||||
XCTAssertTrue(lineHeight.waitForExistence(timeout: 3), "行距控件未出现")
|
||||
|
||||
lineHeight.buttons["紧凑"].tap()
|
||||
lineHeight.buttons["标准"].tap()
|
||||
lineHeight.buttons["宽松"].tap()
|
||||
|
||||
app.buttons[IDs.settingsDone].tap()
|
||||
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]
|
||||
XCTAssertTrue(displayType.waitForExistence(timeout: 3), "翻页方式控件未出现")
|
||||
|
||||
displayType.buttons["横滑"].tap()
|
||||
app.buttons[IDs.settingsDone].tap()
|
||||
app.waitForReaderState(containing: "display=horizontalScroll", timeout: 5)
|
||||
}
|
||||
|
||||
func testAllThemeButtonsExist() {
|
||||
openSettings()
|
||||
for index in 0...5 {
|
||||
let themeButton = app.buttons[IDs.settingsTheme(index)]
|
||||
XCTAssertTrue(themeButton.waitForExistence(timeout: 3), "主题按钮 \(index) 未出现")
|
||||
}
|
||||
}
|
||||
|
||||
func testScrollSettingsToReachAllControls() {
|
||||
openSettings()
|
||||
let scroll = app.scrollViews[IDs.settingsScroll]
|
||||
XCTAssertTrue(scroll.waitForExistence(timeout: 3))
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import XCTest
|
||||
|
||||
final class TOCInteractionTests: XCTestCase {
|
||||
private let app = XCUIApplication()
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
func testTOCPanelShowsChapterList() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
let tocButton = app.buttons[IDs.readerToc]
|
||||
XCTAssertTrue(tocButton.waitForExistence(timeout: 3), "目录按钮不存在")
|
||||
tocButton.tap()
|
||||
|
||||
let tocTable = app.tables[IDs.readerTocTable]
|
||||
XCTAssertTrue(tocTable.waitForExistence(timeout: 5), "目录表格未出现")
|
||||
XCTAssertTrue(tocTable.cells.count > 0, "目录应至少有 1 个章节")
|
||||
}
|
||||
|
||||
func testTOCNavigatesToChapter() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
let initialState = app.waitForDemoReaderState(timeout: 5, description: "初始页码") { state in
|
||||
state.page != nil
|
||||
}
|
||||
let initialPage = initialState.page ?? 0
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
app.buttons[IDs.readerToc].tap()
|
||||
|
||||
let tocTable = app.tables[IDs.readerTocTable]
|
||||
XCTAssertTrue(tocTable.waitForExistence(timeout: 5), "目录表格未出现")
|
||||
|
||||
if tocTable.cells.count > 1 {
|
||||
tocTable.cells.element(boundBy: 1).tap()
|
||||
|
||||
let updatedState = app.waitForDemoReaderState(timeout: 8, description: "目录跳转后页码变化") { state in
|
||||
guard let page = state.page else { return false }
|
||||
return page != initialPage
|
||||
}
|
||||
let updatedPage = updatedState.page ?? 0
|
||||
XCTAssertNotEqual(updatedPage, initialPage, "目录跳转后页码应变化")
|
||||
} else {
|
||||
tocTable.cells.element(boundBy: 0).tap()
|
||||
app.waitForReaderState(containing: "reader=opened", timeout: 8)
|
||||
}
|
||||
}
|
||||
|
||||
func testTOCEmptyHandling() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
app.buttons[IDs.readerToc].tap()
|
||||
|
||||
XCTAssertTrue(app.otherElements[IDs.readerTocPanel].waitForExistence(timeout: 5), "目录面板应可打开")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import XCTest
|
||||
|
||||
final class TableOfContentsTests: XCTestCase {
|
||||
private let app = XCUIApplication()
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
func testTocButtonExists() {
|
||||
app.launchAndOpenSampleBook(bookTitleQuery: "宝山辽墓材料与释读")
|
||||
app.waitForReader()
|
||||
app.showReaderChromeIfNeeded()
|
||||
|
||||
let tocButton = app.buttons[IDs.readerToc]
|
||||
XCTAssertTrue(tocButton.waitForExistence(timeout: 5), "目录按钮未出现")
|
||||
}
|
||||
|
||||
func testTocButtonTappable() {
|
||||
app.launchAndOpenSampleBook(bookTitleQuery: "宝山辽墓材料与释读")
|
||||
app.waitForReader()
|
||||
app.showReaderChromeIfNeeded()
|
||||
|
||||
let tocButton = app.buttons[IDs.readerToc]
|
||||
XCTAssertTrue(tocButton.waitForExistence(timeout: 5))
|
||||
XCTAssertTrue(tocButton.isEnabled, "目录按钮不可点击")
|
||||
tocButton.tap()
|
||||
|
||||
let tocPanel = app.tables[IDs.readerTocTable]
|
||||
XCTAssertTrue(tocPanel.waitForExistence(timeout: 5), "目录面板未出现")
|
||||
XCTAssertTrue(app.navigationBars[IDs.readerTocNavBar].waitForExistence(timeout: 2), "目录导航栏未暴露")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import XCTest
|
||||
|
||||
final class ToolbarStateTests: XCTestCase {
|
||||
private let app = XCUIApplication()
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
func testTapContentHidesToolbar() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
|
||||
XCTAssertTrue(app.otherElements[IDs.readerTopToolbar].waitForExistence(timeout: 3), "顶部工具栏应可见")
|
||||
XCTAssertTrue(app.otherElements[IDs.readerBottomToolbar].waitForExistence(timeout: 3), "底部工具栏应可见")
|
||||
|
||||
app.hideReaderChromeIfNeeded()
|
||||
|
||||
let backButton = app.buttons[IDs.readerBack]
|
||||
let hidden = !backButton.waitForExistence(timeout: 2)
|
||||
XCTAssertTrue(hidden, "点击内容区后工具栏应隐藏")
|
||||
}
|
||||
|
||||
func testToolbarShowHideToggle() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
XCTAssertTrue(app.buttons[IDs.readerBack].waitForExistence(timeout: 3))
|
||||
|
||||
app.hideReaderChromeIfNeeded()
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
XCTAssertTrue(app.buttons[IDs.readerBack].waitForExistence(timeout: 3))
|
||||
|
||||
app.waitForReaderState(containing: "reader=opened", timeout: 5)
|
||||
}
|
||||
|
||||
func testAddHighlightButtonDisabledWithoutSelection() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
|
||||
let addHighlightButton = app.buttons[IDs.readerAddHighlight]
|
||||
XCTAssertTrue(addHighlightButton.waitForExistence(timeout: 3), "标注按钮应存在")
|
||||
|
||||
let beforeState = app.waitForDemoReaderState(timeout: 3, description: "初始高亮数") { $0.highlights != nil }
|
||||
let highlightsBefore = beforeState.highlights ?? 0
|
||||
|
||||
addHighlightButton.tap()
|
||||
|
||||
let afterState = app.waitForDemoReaderState(timeout: 3, description: "点击后的高亮数") { $0.highlights != nil }
|
||||
let highlightsAfter = afterState.highlights ?? 0
|
||||
XCTAssertEqual(highlightsAfter, highlightsBefore, "无选区时点击标注按钮不应创建标注")
|
||||
}
|
||||
|
||||
func testPageCurlSwipeNavigation() throws {
|
||||
app.launchAndOpenSampleBook(displayType: "pagecurl")
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
app.waitForReaderPage(1, timeout: 5)
|
||||
|
||||
let paging = app.collectionViews[IDs.readerPaging]
|
||||
if paging.waitForExistence(timeout: 5) { paging.swipeLeft() }
|
||||
|
||||
app.waitForReaderState(containing: "reader=opened", timeout: 5)
|
||||
}
|
||||
}
|
||||
@@ -30,16 +30,27 @@ extension RDEPUBParser {
|
||||
return true
|
||||
}
|
||||
|
||||
let interactivePattern = #"<(script|iframe|video|audio|canvas|svg|form)\b|\bon(load|click|touchstart|touchend|mouseover)=|hype_generated_script|swiper|webview"#
|
||||
for item in spine where item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml")) {
|
||||
guard let html = htmlString(forRelativePath: item.href) else {
|
||||
continue
|
||||
}
|
||||
if html.range(of: interactivePattern, options: [.regularExpression, .caseInsensitive]) != nil {
|
||||
if containsInteractiveMarkup(html) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/// 检测 HTML 是否包含需要走 Web 交互渲染的特征。
|
||||
/// 注意:普通小说 EPUB 常用静态 SVG 包封面图,不能仅因 `<svg>` 就误判为 interactive。
|
||||
private func containsInteractiveMarkup(_ html: String) -> Bool {
|
||||
let interactivePattern = #"<(script|iframe|video|audio|canvas|object|embed)\b|\bon(load|click|touchstart|touchend|mouseover|submit|change|input)=|hype_generated_script|swiper|webview"#
|
||||
if html.range(of: interactivePattern, options: [.regularExpression, .caseInsensitive]) != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
let dynamicSVGPattern = #"<svg\b[^>]*>[\s\S]*?(<(script|foreignObject|animate|animateMotion|animateTransform|set)\b|\bon(load|click|touchstart|touchend|mouseover)=)"#
|
||||
return html.range(of: dynamicSVGPattern, options: [.regularExpression, .caseInsensitive]) != nil
|
||||
}
|
||||
}
|
||||
|
||||
+235
-184
@@ -94,173 +94,20 @@ public final class RDEPUBTextBookBuilder {
|
||||
let cachedPagination = cacheCoordinator.load(key: cacheKey)
|
||||
|
||||
for (spineIndex, item) in publication.spine.enumerated() where item.linear {
|
||||
guard item.mediaType.contains("html") || item.mediaType.contains("xhtml"),
|
||||
let rawHTML = parser.htmlString(forRelativePath: item.href) else {
|
||||
continue
|
||||
}
|
||||
|
||||
// 从目录表中解析章节标题
|
||||
let chapterTitle = resolvedChapterTitle(for: item, toc: publication.tableOfContents)
|
||||
let request = RDEPUBTextTypesetterPipeline().makeRequest(
|
||||
from: RDEPUBTypesettingInput(
|
||||
href: item.href,
|
||||
title: chapterTitle,
|
||||
rawHTML: rawHTML,
|
||||
baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent(),
|
||||
style: style,
|
||||
resourceResolver: publication.resourceResolver,
|
||||
contentLanguageCode: publication.metadata.language,
|
||||
pageSize: pageSize,
|
||||
layoutConfig: layoutConfig
|
||||
)
|
||||
).request
|
||||
|
||||
// 渲染 HTML → NSAttributedString
|
||||
let renderStart = CFAbsoluteTimeGetCurrent()
|
||||
let rendered = try renderPipeline.render(request)
|
||||
let renderDuration = CFAbsoluteTimeGetCurrent() - renderStart
|
||||
|
||||
lastBuildResourceDiagnostics.append(contentsOf: rendered.resourceDiagnostics)
|
||||
|
||||
let plainText = rendered.attributedString.string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if item.href.lowercased().contains("cover") {
|
||||
print("[EPUB][Cover] rendered href=\(item.href) textLength=\(plainText.count) attrLength=\(rendered.attributedString.length) attachments=\(attachmentCount(in: rendered.attributedString))")
|
||||
}
|
||||
// 跳过空白的封面/扉页章节
|
||||
if shouldSkipChapter(item: item, content: rendered.attributedString, text: plainText) {
|
||||
if item.href.lowercased().contains("cover") {
|
||||
print("[EPUB][Cover] skipped href=\(item.href)")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
let chapterIndex = chapters.count
|
||||
let content = NSMutableAttributedString(attributedString: rendered.attributedString)
|
||||
|
||||
// 分页:封面图片章节走特殊路径,缓存命中则跳过分页引擎
|
||||
let paginateStart = CFAbsoluteTimeGetCurrent()
|
||||
let layoutFrames: [RDEPUBTextLayoutFrame]
|
||||
let isCacheHit: Bool
|
||||
if isAttachmentOnlyCoverChapter(item: item, content: content, plainText: plainText) {
|
||||
// 纯图片封面:整个内容作为一页
|
||||
layoutFrames = [
|
||||
RDEPUBTextLayoutFrame(
|
||||
contentRange: NSRange(location: 0, length: content.length),
|
||||
breakReason: .chapterEnd,
|
||||
blockRange: nil,
|
||||
attachmentRanges: attachmentRanges(in: content),
|
||||
attachmentKinds: [],
|
||||
blockKinds: [],
|
||||
semanticHints: [],
|
||||
attachmentPlacements: [],
|
||||
trailingFragmentID: nil,
|
||||
diagnostics: [
|
||||
"page break: chapterEnd",
|
||||
"cover fallback: single attachment page",
|
||||
"page range: \(NSStringFromRange(NSRange(location: 0, length: content.length)))"
|
||||
]
|
||||
)
|
||||
]
|
||||
isCacheHit = false
|
||||
} else if let cached = cachedPagination?[item.href] {
|
||||
// 缓存命中:直接使用缓存的页范围,跳过 CoreText 分页
|
||||
layoutFrames = cached.pageRanges.enumerated().map { idx, range in
|
||||
let breakReason = idx < cached.breakReasons.count ? cached.breakReasons[idx] : .frameLimit
|
||||
return RDEPUBTextLayoutFrame(
|
||||
contentRange: range,
|
||||
breakReason: breakReason,
|
||||
blockRange: nil,
|
||||
attachmentRanges: [],
|
||||
attachmentKinds: [],
|
||||
blockKinds: [],
|
||||
semanticHints: cached.semanticHints,
|
||||
attachmentPlacements: [],
|
||||
trailingFragmentID: nil,
|
||||
diagnostics: ["page break: \(breakReason.rawValue)", "page range: \(NSStringFromRange(range))", "source: cache hit"]
|
||||
)
|
||||
}
|
||||
isCacheHit = true
|
||||
} else {
|
||||
// 缓存未命中:调用 CoreText 分页引擎
|
||||
layoutFrames = content.length > 0
|
||||
? paginationPipeline.frames(
|
||||
for: content,
|
||||
pageSize: pageSize,
|
||||
config: layoutConfig,
|
||||
fragmentOffsets: rendered.fragmentOffsets
|
||||
)
|
||||
: []
|
||||
isCacheHit = false
|
||||
}
|
||||
let paginateDuration = CFAbsoluteTimeGetCurrent() - paginateStart
|
||||
|
||||
// 尾页规范化:丢弃纯空白尾页、合并过短尾页
|
||||
let normalizedFrames = tailNormalizer.normalize(
|
||||
layoutFrames,
|
||||
content: content,
|
||||
href: item.href
|
||||
)
|
||||
let effectiveFrames = normalizedFrames.isEmpty && content.length > 0
|
||||
? [
|
||||
RDEPUBTextLayoutFrame(
|
||||
contentRange: NSRange(location: 0, length: content.length),
|
||||
breakReason: .chapterEnd,
|
||||
blockRange: nil,
|
||||
attachmentRanges: [],
|
||||
attachmentKinds: [],
|
||||
blockKinds: [],
|
||||
semanticHints: [],
|
||||
attachmentPlacements: [],
|
||||
trailingFragmentID: nil,
|
||||
diagnostics: [
|
||||
"page break: chapterEnd",
|
||||
"page range: \(NSStringFromRange(NSRange(location: 0, length: content.length)))"
|
||||
]
|
||||
)
|
||||
]
|
||||
: normalizedFrames
|
||||
if item.href.lowercased().contains("cover") {
|
||||
print("[EPUB][Cover] paginated href=\(item.href) pages=\(effectiveFrames.count) firstRange=\(effectiveFrames.first.map { NSStringFromRange($0.contentRange) } ?? "none")")
|
||||
}
|
||||
|
||||
// 记录性能采样
|
||||
sampler.record(RDEPUBTextPerformanceSample(
|
||||
chapterHref: item.href,
|
||||
renderDuration: renderDuration,
|
||||
paginateDuration: paginateDuration,
|
||||
pageCount: effectiveFrames.count,
|
||||
attributedStringLength: content.length,
|
||||
cacheHit: isCacheHit
|
||||
))
|
||||
if isCacheHit {
|
||||
lastBuildCacheStats.hits += 1
|
||||
} else {
|
||||
lastBuildCacheStats.misses += 1
|
||||
}
|
||||
|
||||
// 构建页面和章节模型
|
||||
let chapterAttributedContent = content.copy() as! NSAttributedString
|
||||
let pages = effectiveFrames.enumerated().map { localPageIndex, frame in
|
||||
let range = frame.contentRange
|
||||
return RDEPUBTextPage(
|
||||
absolutePageIndex: flatPages.count + localPageIndex,
|
||||
chapterIndex: chapterIndex,
|
||||
spineIndex: spineIndex,
|
||||
href: item.href,
|
||||
chapterTitle: chapterTitle,
|
||||
pageIndexInChapter: localPageIndex,
|
||||
totalPagesInChapter: effectiveFrames.count,
|
||||
chapterContent: chapterAttributedContent,
|
||||
content: content.attributedSubstring(from: range),
|
||||
contentRange: range,
|
||||
pageStartOffset: range.location,
|
||||
pageEndOffset: range.location + max(range.length - 1, 0),
|
||||
metadata: frame.metadata
|
||||
)
|
||||
}
|
||||
guard let result = try buildChapter(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
spineIndex: spineIndex,
|
||||
pageSize: pageSize,
|
||||
style: style,
|
||||
chapterIndex: chapters.count,
|
||||
absolutePageStartIndex: flatPages.count,
|
||||
cachedPagination: cachedPagination
|
||||
) else { continue }
|
||||
|
||||
if isPaginationDebugEnabled,
|
||||
item.href.contains("Chapter_3.xhtml") {
|
||||
let pages = result.chapter.pages
|
||||
print("[PaginationDebug] href=\(item.href) pages=\(pages.count)")
|
||||
for page in pages {
|
||||
let preview = debugPreview(for: page.content, limit: 36)
|
||||
@@ -271,26 +118,16 @@ public final class RDEPUBTextBookBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
chapters.append(
|
||||
RDEPUBTextChapter(
|
||||
chapterIndex: chapterIndex,
|
||||
spineIndex: spineIndex,
|
||||
href: item.href,
|
||||
title: chapterTitle,
|
||||
attributedContent: chapterAttributedContent,
|
||||
fragmentOffsets: rendered.fragmentOffsets,
|
||||
pageBreakReasons: pages.map(\.metadata.breakReason),
|
||||
pages: pages
|
||||
)
|
||||
)
|
||||
lastBuildPaginationDiagnostics.append(
|
||||
diagnosticsReporter.chapterDiagnostic(
|
||||
href: item.href,
|
||||
title: chapterTitle,
|
||||
pages: pages
|
||||
)
|
||||
)
|
||||
flatPages.append(contentsOf: pages)
|
||||
chapters.append(result.chapter)
|
||||
flatPages.append(contentsOf: result.chapter.pages)
|
||||
lastBuildResourceDiagnostics.append(contentsOf: result.resourceDiagnostics)
|
||||
lastBuildPaginationDiagnostics.append(result.paginationDiagnostic)
|
||||
sampler.record(result.performanceSample)
|
||||
if result.cacheHit {
|
||||
lastBuildCacheStats.hits += 1
|
||||
} else {
|
||||
lastBuildCacheStats.misses += 1
|
||||
}
|
||||
}
|
||||
|
||||
let book = RDEPUBTextBook(chapters: chapters, pages: flatPages)
|
||||
@@ -306,6 +143,220 @@ public final class RDEPUBTextBookBuilder {
|
||||
return book
|
||||
}
|
||||
|
||||
/// 构建指定 spine 章节,用于大书快速首屏和后台增量补齐。
|
||||
public func buildChapter(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
spineIndex: Int,
|
||||
pageSize: CGSize,
|
||||
style: RDEPUBTextRenderStyle,
|
||||
chapterIndex: Int = 0,
|
||||
absolutePageStartIndex: Int = 0
|
||||
) throws -> RDEPUBTextChapterBuildResult? {
|
||||
let bookID = publication.metadata.identifier ?? publication.metadata.title
|
||||
let cacheKey = cacheCoordinator.cacheKey(bookID: bookID, pageSize: pageSize, style: style)
|
||||
let cachedPagination = cacheCoordinator.load(key: cacheKey)
|
||||
return try buildChapter(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
spineIndex: spineIndex,
|
||||
pageSize: pageSize,
|
||||
style: style,
|
||||
chapterIndex: chapterIndex,
|
||||
absolutePageStartIndex: absolutePageStartIndex,
|
||||
cachedPagination: cachedPagination
|
||||
)
|
||||
}
|
||||
|
||||
private func buildChapter(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
spineIndex: Int,
|
||||
pageSize: CGSize,
|
||||
style: RDEPUBTextRenderStyle,
|
||||
chapterIndex: Int,
|
||||
absolutePageStartIndex: Int,
|
||||
cachedPagination: [String: RDEPUBTextChapterPaginationCache]?
|
||||
) throws -> RDEPUBTextChapterBuildResult? {
|
||||
guard publication.spine.indices.contains(spineIndex) else { return nil }
|
||||
let item = publication.spine[spineIndex]
|
||||
guard item.linear,
|
||||
item.mediaType.contains("html") || item.mediaType.contains("xhtml"),
|
||||
let rawHTML = parser.htmlString(forRelativePath: item.href) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let chapterTitle = resolvedChapterTitle(for: item, toc: publication.tableOfContents)
|
||||
let request = RDEPUBTextTypesetterPipeline().makeRequest(
|
||||
from: RDEPUBTypesettingInput(
|
||||
href: item.href,
|
||||
title: chapterTitle,
|
||||
rawHTML: rawHTML,
|
||||
baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent(),
|
||||
style: style,
|
||||
resourceResolver: publication.resourceResolver,
|
||||
contentLanguageCode: publication.metadata.language,
|
||||
pageSize: pageSize,
|
||||
layoutConfig: layoutConfig
|
||||
)
|
||||
).request
|
||||
|
||||
let renderStart = CFAbsoluteTimeGetCurrent()
|
||||
let rendered = try renderPipeline.render(request)
|
||||
let renderDuration = CFAbsoluteTimeGetCurrent() - renderStart
|
||||
|
||||
let plainText = rendered.attributedString.string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if item.href.lowercased().contains("cover") {
|
||||
print("[EPUB][Cover] rendered href=\(item.href) textLength=\(plainText.count) attrLength=\(rendered.attributedString.length) attachments=\(attachmentCount(in: rendered.attributedString))")
|
||||
}
|
||||
if shouldSkipChapter(item: item, content: rendered.attributedString, text: plainText) {
|
||||
if item.href.lowercased().contains("cover") {
|
||||
print("[EPUB][Cover] skipped href=\(item.href)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
let content = NSMutableAttributedString(attributedString: rendered.attributedString)
|
||||
let paginateStart = CFAbsoluteTimeGetCurrent()
|
||||
let layoutFrames: [RDEPUBTextLayoutFrame]
|
||||
let isCacheHit: Bool
|
||||
|
||||
if isAttachmentOnlyCoverChapter(item: item, content: content, plainText: plainText) {
|
||||
layoutFrames = [
|
||||
RDEPUBTextLayoutFrame(
|
||||
contentRange: NSRange(location: 0, length: content.length),
|
||||
breakReason: .chapterEnd,
|
||||
blockRange: nil,
|
||||
attachmentRanges: attachmentRanges(in: content),
|
||||
attachmentKinds: [],
|
||||
blockKinds: [],
|
||||
semanticHints: [],
|
||||
attachmentPlacements: [],
|
||||
trailingFragmentID: nil,
|
||||
diagnostics: [
|
||||
"page break: chapterEnd",
|
||||
"cover fallback: single attachment page",
|
||||
"page range: \(NSStringFromRange(NSRange(location: 0, length: content.length)))"
|
||||
]
|
||||
)
|
||||
]
|
||||
isCacheHit = false
|
||||
} else if let cached = cachedPagination?[item.href] {
|
||||
layoutFrames = cached.pageRanges.enumerated().map { idx, range in
|
||||
let breakReason = idx < cached.breakReasons.count ? cached.breakReasons[idx] : .frameLimit
|
||||
return RDEPUBTextLayoutFrame(
|
||||
contentRange: range,
|
||||
breakReason: breakReason,
|
||||
blockRange: nil,
|
||||
attachmentRanges: [],
|
||||
attachmentKinds: [],
|
||||
blockKinds: [],
|
||||
semanticHints: cached.semanticHints,
|
||||
attachmentPlacements: [],
|
||||
trailingFragmentID: nil,
|
||||
diagnostics: [
|
||||
"page break: \(breakReason.rawValue)",
|
||||
"page range: \(NSStringFromRange(range))",
|
||||
"source: cache hit"
|
||||
]
|
||||
)
|
||||
}
|
||||
isCacheHit = true
|
||||
} else {
|
||||
layoutFrames = content.length > 0
|
||||
? paginationPipeline.frames(
|
||||
for: content,
|
||||
pageSize: pageSize,
|
||||
config: layoutConfig,
|
||||
fragmentOffsets: rendered.fragmentOffsets
|
||||
)
|
||||
: []
|
||||
isCacheHit = false
|
||||
}
|
||||
|
||||
let paginateDuration = CFAbsoluteTimeGetCurrent() - paginateStart
|
||||
let normalizedFrames = tailNormalizer.normalize(
|
||||
layoutFrames,
|
||||
content: content,
|
||||
href: item.href
|
||||
)
|
||||
let effectiveFrames = normalizedFrames.isEmpty && content.length > 0
|
||||
? [
|
||||
RDEPUBTextLayoutFrame(
|
||||
contentRange: NSRange(location: 0, length: content.length),
|
||||
breakReason: .chapterEnd,
|
||||
blockRange: nil,
|
||||
attachmentRanges: [],
|
||||
attachmentKinds: [],
|
||||
blockKinds: [],
|
||||
semanticHints: [],
|
||||
attachmentPlacements: [],
|
||||
trailingFragmentID: nil,
|
||||
diagnostics: [
|
||||
"page break: chapterEnd",
|
||||
"page range: \(NSStringFromRange(NSRange(location: 0, length: content.length)))"
|
||||
]
|
||||
)
|
||||
]
|
||||
: normalizedFrames
|
||||
|
||||
if item.href.lowercased().contains("cover") {
|
||||
print("[EPUB][Cover] paginated href=\(item.href) pages=\(effectiveFrames.count) firstRange=\(effectiveFrames.first.map { NSStringFromRange($0.contentRange) } ?? "none")")
|
||||
}
|
||||
|
||||
let chapterAttributedContent = content.copy() as! NSAttributedString
|
||||
let pages = effectiveFrames.enumerated().map { localPageIndex, frame in
|
||||
let range = frame.contentRange
|
||||
return RDEPUBTextPage(
|
||||
absolutePageIndex: absolutePageStartIndex + localPageIndex,
|
||||
chapterIndex: chapterIndex,
|
||||
spineIndex: spineIndex,
|
||||
href: item.href,
|
||||
chapterTitle: chapterTitle,
|
||||
pageIndexInChapter: localPageIndex,
|
||||
totalPagesInChapter: effectiveFrames.count,
|
||||
chapterContent: chapterAttributedContent,
|
||||
content: content.attributedSubstring(from: range),
|
||||
contentRange: range,
|
||||
pageStartOffset: range.location,
|
||||
pageEndOffset: range.location + max(range.length - 1, 0),
|
||||
metadata: frame.metadata
|
||||
)
|
||||
}
|
||||
|
||||
let chapter = RDEPUBTextChapter(
|
||||
chapterIndex: chapterIndex,
|
||||
spineIndex: spineIndex,
|
||||
href: item.href,
|
||||
title: chapterTitle,
|
||||
attributedContent: chapterAttributedContent,
|
||||
fragmentOffsets: rendered.fragmentOffsets,
|
||||
pageBreakReasons: pages.map(\.metadata.breakReason),
|
||||
pages: pages
|
||||
)
|
||||
let performanceSample = RDEPUBTextPerformanceSample(
|
||||
chapterHref: item.href,
|
||||
renderDuration: renderDuration,
|
||||
paginateDuration: paginateDuration,
|
||||
pageCount: effectiveFrames.count,
|
||||
attributedStringLength: content.length,
|
||||
cacheHit: isCacheHit
|
||||
)
|
||||
let diagnostic = diagnosticsReporter.chapterDiagnostic(
|
||||
href: item.href,
|
||||
title: chapterTitle,
|
||||
pages: pages
|
||||
)
|
||||
|
||||
return RDEPUBTextChapterBuildResult(
|
||||
chapter: chapter,
|
||||
resourceDiagnostics: rendered.resourceDiagnostics,
|
||||
paginationDiagnostic: diagnostic,
|
||||
performanceSample: performanceSample,
|
||||
cacheHit: isCacheHit
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 章节标题解析
|
||||
|
||||
/// 从目录表中查找章节标题,找不到则回退到 spine item 的 title 或 href
|
||||
|
||||
@@ -21,6 +21,17 @@ public struct RDEPUBTextChapterPaginationDiagnostic: Equatable {
|
||||
public var sampleNotes: [String]
|
||||
}
|
||||
|
||||
// MARK: - 章节构建结果
|
||||
|
||||
/// 单章构建结果,用于大书快速进入阅读器和后台增量补齐。
|
||||
public struct RDEPUBTextChapterBuildResult {
|
||||
public var chapter: RDEPUBTextChapter
|
||||
public var resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic]
|
||||
public var paginationDiagnostic: RDEPUBTextChapterPaginationDiagnostic
|
||||
public var performanceSample: RDEPUBTextPerformanceSample
|
||||
public var cacheHit: Bool
|
||||
}
|
||||
|
||||
// MARK: - 页面数据模型
|
||||
|
||||
/// 分页后的单页数据,包含全书绝对页码、所属章节、内容范围等。
|
||||
@@ -180,4 +191,3 @@ public struct RDEPUBTextBook {
|
||||
return chapterData.location(forPage: page, bookIdentifier: bookIdentifier)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,9 +20,8 @@ struct RDEPUBChapterPageCounter {
|
||||
private let pageSize: CGSize
|
||||
private let config: RDEPUBTextLayoutConfig
|
||||
private let pageBreakPolicy: RDEPUBPageBreakPolicy
|
||||
|
||||
/// CoreText 帧设置器
|
||||
private let framesetter: CTFramesetter
|
||||
|
||||
/// DTCoreText 路径可直接消费的单矩形布局区域
|
||||
private let dtLayoutRect: CGRect
|
||||
|
||||
@@ -59,27 +58,26 @@ struct RDEPUBChapterPageCounter {
|
||||
var frames: [RDEPUBTextLayoutFrame] = []
|
||||
var location = 0
|
||||
let resolvedSize = config.resolvedFrameSize(fallback: pageSize)
|
||||
let usableWidth = resolvedSize.width - config.edgeInsets.left - config.edgeInsets.right
|
||||
let usableHeight = resolvedSize.height - config.edgeInsets.top - config.edgeInsets.bottom
|
||||
let contentRect = config.contentRect(fallback: resolvedSize)
|
||||
|
||||
guard usableWidth > 0, usableHeight > 0 else {
|
||||
guard contentRect.width > 0, contentRect.height > 0 else {
|
||||
return []
|
||||
}
|
||||
|
||||
while location < attributedString.length {
|
||||
let framePath = CGMutablePath()
|
||||
let pageRect = CGRect(
|
||||
x: config.edgeInsets.left,
|
||||
y: config.edgeInsets.bottom,
|
||||
width: usableWidth,
|
||||
height: usableHeight
|
||||
let framePath = RDEPUBCoreTextPageFrameFactory.makeLayoutPath(
|
||||
pageSize: resolvedSize,
|
||||
config: config
|
||||
)
|
||||
framePath.addRect(pageRect)
|
||||
let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(location, 0), framePath, nil)
|
||||
let proposedRange = proposedRangeUsingWXReadPageCount(
|
||||
let frame = CTFramesetterCreateFrame(
|
||||
framesetter,
|
||||
CFRangeMake(location, 0),
|
||||
framePath,
|
||||
nil
|
||||
)
|
||||
let proposedRange = proposedVisibleRange(
|
||||
from: frame,
|
||||
start: location,
|
||||
usableHeight: usableHeight,
|
||||
totalLength: attributedString.length
|
||||
)
|
||||
guard proposedRange.length > 0 else {
|
||||
@@ -87,12 +85,18 @@ struct RDEPUBChapterPageCounter {
|
||||
}
|
||||
|
||||
let avoidAdjusted = factory.trimmedRangeForAvoidPageBreakInside(from: frame, proposed: proposedRange)
|
||||
let keepWithNextAdjusted = factory.trimmedRangeForKeepWithNext(from: frame, proposed: avoidAdjusted)
|
||||
let lineRanges = RDEPUBCoreTextPageFrameFactory.lineRanges(from: frame)
|
||||
let widowOrphanAdjusted = factory.trimmedRangeForWidowAndOrphanControl(
|
||||
proposed: keepWithNextAdjusted,
|
||||
lineRanges: lineRanges
|
||||
)
|
||||
let effectiveLineRanges = lineRangesWithinRange(lineRanges, range: widowOrphanAdjusted)
|
||||
|
||||
let adjusted = pageBreakPolicy.adjustedRange(
|
||||
from: avoidAdjusted,
|
||||
from: widowOrphanAdjusted,
|
||||
totalLength: attributedString.length,
|
||||
lineRanges: lineRanges,
|
||||
lineRanges: effectiveLineRanges,
|
||||
factory: factory
|
||||
)
|
||||
let trailingFragmentID = factory.nearestTrailingFragmentID(
|
||||
@@ -142,6 +146,10 @@ struct RDEPUBChapterPageCounter {
|
||||
var frames: [RDEPUBTextLayoutFrame] = []
|
||||
var location = 0
|
||||
let pageRect = dtLayoutRect
|
||||
let isDebug = ProcessInfo.processInfo.arguments.contains("--demo-pagination-debug")
|
||||
if isDebug {
|
||||
print("[PAGINATION-DEBUG] pageRect=\(pageRect) totalLength=\(attributedString.length)")
|
||||
}
|
||||
|
||||
while location < attributedString.length {
|
||||
guard let layoutFrame = layouter.layoutFrame(with: pageRect, range: NSRange(location: location, length: 0)) else {
|
||||
@@ -157,10 +165,25 @@ struct RDEPUBChapterPageCounter {
|
||||
let avoidAdjusted = factory.trimmedRangeForAvoidPageBreakInside(from: layoutFrame, proposed: proposedRange)
|
||||
let lineAdjusted = factory.trimmedRangeForKeepWithNext(from: layoutFrame, proposed: avoidAdjusted)
|
||||
let lineRanges = RDEPUBCoreTextPageFrameFactory.lineRanges(from: layoutFrame)
|
||||
let widowOrphanAdjusted = factory.trimmedRangeForWidowAndOrphanControl(
|
||||
proposed: lineAdjusted,
|
||||
lineRanges: lineRanges
|
||||
)
|
||||
|
||||
if isDebug {
|
||||
let pageCount = frames.count + 1
|
||||
let previewText = (attributedString.string as NSString).substring(with: NSRange(location: location, length: min(20, attributedString.length - location)))
|
||||
let avoidRemoved = proposedRange.length - avoidAdjusted.length
|
||||
let kwNextRemoved = avoidAdjusted.length - lineAdjusted.length
|
||||
let widowRemoved = lineAdjusted.length - widowOrphanAdjusted.length
|
||||
let totalRemoved = proposedRange.length - widowOrphanAdjusted.length
|
||||
print("[PAGINATION-DEBUG] page#\(pageCount) loc=\(location) proposed=\(proposedRange.length) avoid(-\(avoidRemoved)) kwNext(-\(kwNextRemoved)) widowOrphan(-\(widowRemoved)) total(-\(totalRemoved)) lastLine=\"\(previewText)\"")
|
||||
}
|
||||
let effectiveLineRanges = lineRangesWithinRange(lineRanges, range: widowOrphanAdjusted)
|
||||
let adjusted = pageBreakPolicy.adjustedRange(
|
||||
from: lineAdjusted,
|
||||
from: widowOrphanAdjusted,
|
||||
totalLength: attributedString.length,
|
||||
lineRanges: lineRanges,
|
||||
lineRanges: effectiveLineRanges,
|
||||
factory: factory
|
||||
)
|
||||
let verifiedRange: NSRange
|
||||
@@ -231,40 +254,29 @@ struct RDEPUBChapterPageCounter {
|
||||
|
||||
// MARK: - WXRead 分页对齐
|
||||
|
||||
/// 逐句对齐 WXRead `WRChapterPageCount.recalculatePageRangesForAttributedString` 的分页循环。
|
||||
private func proposedRangeUsingWXReadPageCount(
|
||||
/// 读取 CoreText frame 当前可见的字符范围;多栏 path 下也能返回整页可见区。
|
||||
private func proposedVisibleRange(
|
||||
from frame: CTFrame,
|
||||
start location: Int,
|
||||
usableHeight: CGFloat,
|
||||
totalLength: Int
|
||||
) -> NSRange {
|
||||
let lines = CTFrameGetLines(frame) as! [CTLine]
|
||||
guard !lines.isEmpty else {
|
||||
let visibleRange = CTFrameGetVisibleStringRange(frame)
|
||||
guard visibleRange.length > 0 else {
|
||||
return NSRange(location: location, length: 0)
|
||||
}
|
||||
|
||||
var origins = [CGPoint](repeating: .zero, count: lines.count)
|
||||
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins)
|
||||
|
||||
var pageCharCount = 0
|
||||
for (index, line) in lines.enumerated() {
|
||||
let lineRange = CTLineGetStringRange(line)
|
||||
let lineY = origins[index].y
|
||||
var ascent: CGFloat = 0
|
||||
var descent: CGFloat = 0
|
||||
CTLineGetTypographicBounds(line, &ascent, &descent, nil)
|
||||
|
||||
if lineY - ascent > usableHeight {
|
||||
break
|
||||
}
|
||||
|
||||
pageCharCount += lineRange.length
|
||||
let resolvedLocation = max(location, visibleRange.location)
|
||||
let visibleEnd = visibleRange.location + visibleRange.length
|
||||
let length = min(max(visibleEnd - resolvedLocation, 0), totalLength - resolvedLocation)
|
||||
guard length > 0 else {
|
||||
return NSRange(location: resolvedLocation, length: 0)
|
||||
}
|
||||
return NSRange(location: resolvedLocation, length: length)
|
||||
}
|
||||
|
||||
if pageCharCount == 0 {
|
||||
pageCharCount = 1
|
||||
private func lineRangesWithinRange(_ lineRanges: [NSRange], range: NSRange) -> [NSRange] {
|
||||
lineRanges.filter { lineRange in
|
||||
lineRange.location >= range.location && NSMaxRange(lineRange) <= NSMaxRange(range)
|
||||
}
|
||||
|
||||
return NSRange(location: location, length: min(pageCharCount, totalLength - location))
|
||||
}
|
||||
}
|
||||
|
||||
+145
@@ -146,6 +146,12 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
|
||||
let endLocation = lastValidLine.location + lastValidLine.length
|
||||
let adjustedLength = endLocation - proposed.location
|
||||
guard adjustedLength > 0 else { return proposed }
|
||||
|
||||
if ProcessInfo.processInfo.arguments.contains("--demo-pagination-debug") {
|
||||
let removedText = (attributedString.string as NSString).substring(with: NSRange(location: endLocation, length: min(proposed.length - adjustedLength, 40)))
|
||||
print("[PAGINATION-DEBUG] avoidPageBreakInside removed \(linesToRemove) lines: \"\(removedText)\"")
|
||||
}
|
||||
|
||||
return NSRange(location: proposed.location, length: adjustedLength)
|
||||
}
|
||||
|
||||
@@ -180,9 +186,47 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
|
||||
let endLocation = lastValidLine.location + lastValidLine.length
|
||||
let adjustedLength = endLocation - proposed.location
|
||||
guard adjustedLength > 0 else { return proposed }
|
||||
|
||||
if ProcessInfo.processInfo.arguments.contains("--demo-pagination-debug") {
|
||||
let removedText = (attributedString.string as NSString).substring(with: NSRange(location: endLocation, length: min(proposed.length - adjustedLength, 40)))
|
||||
print("[PAGINATION-DEBUG] keepWithNext removed \(linesToRemove) lines: \"\(removedText)\"")
|
||||
}
|
||||
|
||||
return NSRange(location: proposed.location, length: adjustedLength)
|
||||
}
|
||||
|
||||
/// 根据 avoidWidows / avoidOrphans 修正页尾断点,避免段首孤悬页尾或段末独悬下一页。
|
||||
func trimmedRangeForWidowAndOrphanControl(
|
||||
proposed: NSRange,
|
||||
lineRanges: [NSRange]
|
||||
) -> NSRange {
|
||||
guard !lineRanges.isEmpty else { return proposed }
|
||||
guard config.avoidWidows || config.avoidOrphans else { return proposed }
|
||||
|
||||
var adjusted = proposed
|
||||
var visibleLines = lineRanges
|
||||
|
||||
if config.avoidWidows,
|
||||
let widowAdjusted = trimmedRangeAvoidingWidow(
|
||||
proposed: adjusted,
|
||||
lineRanges: visibleLines
|
||||
),
|
||||
widowAdjusted != adjusted {
|
||||
adjusted = widowAdjusted
|
||||
visibleLines = Array(visibleLines.dropLast())
|
||||
}
|
||||
|
||||
if config.avoidOrphans,
|
||||
let orphanAdjusted = trimmedRangeAvoidingOrphan(
|
||||
proposed: adjusted,
|
||||
lineRanges: visibleLines
|
||||
) {
|
||||
adjusted = orphanAdjusted
|
||||
}
|
||||
|
||||
return adjusted
|
||||
}
|
||||
|
||||
// MARK: - 行信息提取
|
||||
|
||||
/// 获取 CTFrame 中所有行的字符范围
|
||||
@@ -263,6 +307,107 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
|
||||
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
|
||||
}
|
||||
|
||||
// MARK: - Widow / Orphan 控制
|
||||
|
||||
private func trimmedRangeAvoidingWidow(
|
||||
proposed: NSRange,
|
||||
lineRanges: [NSRange]
|
||||
) -> NSRange? {
|
||||
guard lineRanges.count >= 2,
|
||||
let lastLine = lineRanges.last else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let paragraphRange = paragraphRange(containing: lastLine.location)
|
||||
let trailingLines = trailingLineCount(in: lineRanges, paragraphRange: paragraphRange)
|
||||
let paragraphContinuesOnNextPage = NSMaxRange(proposed) < NSMaxRange(paragraphRange)
|
||||
|
||||
guard trailingLines == 1, paragraphContinuesOnNextPage else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let keptLine = lineRanges[lineRanges.count - 2]
|
||||
let adjustedLength = NSMaxRange(keptLine) - proposed.location
|
||||
guard adjustedLength > 0 else { return nil }
|
||||
|
||||
if ProcessInfo.processInfo.arguments.contains("--demo-pagination-debug") {
|
||||
let removedText = (attributedString.string as NSString).substring(with: NSRange(location: NSMaxRange(keptLine), length: min(proposed.length - adjustedLength, 40)))
|
||||
print("[PAGINATION-DEBUG] widow control removed 1 line: \"\(removedText)\" paragraphRange=\(NSStringFromRange(paragraphRange))")
|
||||
}
|
||||
|
||||
return NSRange(location: proposed.location, length: adjustedLength)
|
||||
}
|
||||
|
||||
private func trimmedRangeAvoidingOrphan(
|
||||
proposed: NSRange,
|
||||
lineRanges: [NSRange]
|
||||
) -> NSRange? {
|
||||
guard lineRanges.count >= 2,
|
||||
let lastLine = lineRanges.last else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let paragraphRange = paragraphRange(containing: lastLine.location)
|
||||
let pageEnd = NSMaxRange(proposed)
|
||||
let paragraphEnd = NSMaxRange(paragraphRange)
|
||||
guard pageEnd < paragraphEnd else { return nil }
|
||||
|
||||
let remainingLineCount = estimatedRemainingLineCount(
|
||||
in: paragraphRange,
|
||||
startingAt: pageEnd
|
||||
)
|
||||
let trailingLines = trailingLineCount(in: lineRanges, paragraphRange: paragraphRange)
|
||||
guard remainingLineCount == 1,
|
||||
trailingLines >= 2 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let keptLine = lineRanges[lineRanges.count - 2]
|
||||
let adjustedLength = NSMaxRange(keptLine) - proposed.location
|
||||
guard adjustedLength > 0 else { return nil }
|
||||
return NSRange(location: proposed.location, length: adjustedLength)
|
||||
}
|
||||
|
||||
private func trailingLineCount(
|
||||
in lineRanges: [NSRange],
|
||||
paragraphRange: NSRange
|
||||
) -> Int {
|
||||
var count = 0
|
||||
for lineRange in lineRanges.reversed() {
|
||||
if NSIntersectionRange(lineRange, paragraphRange).length > 0 {
|
||||
count += 1
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
private func estimatedRemainingLineCount(
|
||||
in paragraphRange: NSRange,
|
||||
startingAt location: Int
|
||||
) -> Int {
|
||||
let paragraphEnd = NSMaxRange(paragraphRange)
|
||||
var currentLocation = max(location, paragraphRange.location)
|
||||
guard currentLocation < paragraphEnd else { return 0 }
|
||||
|
||||
let width = max(config.columnRects(fallback: pageSize).first?.width ?? config.contentRect(fallback: pageSize).width, 1)
|
||||
let typesetter = CTTypesetterCreateWithAttributedString(attributedString)
|
||||
var lineCount = 0
|
||||
|
||||
while currentLocation < paragraphEnd {
|
||||
let suggestedCount = CTTypesetterSuggestLineBreak(typesetter, currentLocation, Double(width))
|
||||
let lineLength = max(suggestedCount, 1)
|
||||
currentLocation += lineLength
|
||||
lineCount += 1
|
||||
if lineCount > 2 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return lineCount
|
||||
}
|
||||
|
||||
/// 获取指定范围内的附件类型列表(去重)
|
||||
func attachmentKinds(in range: NSRange) -> [RDEPUBTextAttachmentKind] {
|
||||
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
|
||||
|
||||
@@ -41,7 +41,11 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
|
||||
let attributedString = NSMutableAttributedString(attributedString: rendered)
|
||||
RDEPUBSemanticMarkerInjector.applyPaginationSemantics(in: attributedString)
|
||||
let fragmentOffsets = RDEPUBFragmentMarkerInjector.extractFragmentOffsets(from: attributedString)
|
||||
RDEPUBTextRendererSupport.normalizeReadingAttributes(in: attributedString, style: request.style)
|
||||
RDEPUBTextRendererSupport.normalizeReadingAttributes(
|
||||
in: attributedString,
|
||||
style: request.style,
|
||||
layoutConfig: request.layoutConfig ?? .default
|
||||
)
|
||||
return RDEPUBRenderedChapterContent(
|
||||
attributedString: attributedString,
|
||||
fragmentOffsets: fragmentOffsets,
|
||||
@@ -76,7 +80,11 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
|
||||
let attributedString = RDEPUBTextRendererSupport.fallbackAttributedString(for: request.context.html, style: request.style)
|
||||
RDEPUBSemanticMarkerInjector.applyPaginationSemantics(in: attributedString)
|
||||
let fragmentOffsets = RDEPUBFragmentMarkerInjector.extractFragmentOffsets(from: attributedString)
|
||||
RDEPUBTextRendererSupport.normalizeReadingAttributes(in: attributedString, style: request.style)
|
||||
RDEPUBTextRendererSupport.normalizeReadingAttributes(
|
||||
in: attributedString,
|
||||
style: request.style,
|
||||
layoutConfig: request.layoutConfig ?? .default
|
||||
)
|
||||
return RDEPUBRenderedChapterContent(
|
||||
attributedString: attributedString,
|
||||
fragmentOffsets: fragmentOffsets,
|
||||
|
||||
@@ -104,7 +104,11 @@ enum RDEPUBTextRendererSupport {
|
||||
// MARK: - 后渲染归一化(由 RDEPUBDTCoreTextRenderer 调用)
|
||||
|
||||
/// 规范化阅读属性:统一字体、行距、颜色,并注入分页语义属性。
|
||||
static func normalizeReadingAttributes(in attributedString: NSMutableAttributedString, style: RDEPUBTextRenderStyle) {
|
||||
static func normalizeReadingAttributes(
|
||||
in attributedString: NSMutableAttributedString,
|
||||
style: RDEPUBTextRenderStyle,
|
||||
layoutConfig: RDEPUBTextLayoutConfig = .default
|
||||
) {
|
||||
let fullRange = NSRange(location: 0, length: attributedString.length)
|
||||
var blockIndex = 0
|
||||
let sourceText = attributedString.string as NSString
|
||||
@@ -114,6 +118,7 @@ enum RDEPUBTextRendererSupport {
|
||||
let paragraph = (attributes[.paragraphStyle] as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle ?? paragraphStyle(lineSpacing: style.lineSpacing)
|
||||
paragraph.lineSpacing = style.lineSpacing
|
||||
paragraph.paragraphSpacing = max(paragraph.paragraphSpacing, style.lineSpacing / 2)
|
||||
paragraph.hyphenationFactor = layoutConfig.hyphenation ? 1.0 : 0.0
|
||||
|
||||
var updatedAttributes = attributes
|
||||
updatedAttributes[.font] = normalizedFont
|
||||
|
||||
@@ -30,12 +30,15 @@ final class RDEPUBReaderChapterListController: UITableViewController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.accessibilityIdentifier = "epub.reader.toc.panel"
|
||||
tableView.accessibilityIdentifier = "epub.reader.toc.table"
|
||||
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell")
|
||||
tableView.tableFooterView = UIView(frame: .zero)
|
||||
tableView.backgroundColor = theme.contentBackgroundColor
|
||||
navigationController?.navigationBar.tintColor = theme.toolControlTextColor
|
||||
navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor
|
||||
navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor]
|
||||
navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.toc.navbar"
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
@@ -62,4 +65,4 @@ final class RDEPUBReaderChapterListController: UITableViewController {
|
||||
guard let currentItem else { return false }
|
||||
return currentItem.href == item.href
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +107,31 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
||||
|
||||
/// 根据 EPUB 位置计算对应的页码
|
||||
func pageNumber(for location: RDEPUBLocation) -> Int? {
|
||||
if let publication,
|
||||
let bookPageMap = readerContext.bookPageMap,
|
||||
let spineIndex = readerContext.normalizedSpineIndex(for: location),
|
||||
let entry = bookPageMap.entry(forSpineIndex: spineIndex) {
|
||||
let normalizedLocation = publication.resourceResolver.normalizedLocation(
|
||||
location,
|
||||
relativeToSpineIndex: nil,
|
||||
bookIdentifier: currentBookIdentifier
|
||||
) ?? location
|
||||
let localPageIndex: Int
|
||||
if let summary = readerContext.chapterSummary(forSpineIndex: spineIndex) {
|
||||
let offset = chapterOffset(for: normalizedLocation, fallbackEntry: entry)
|
||||
localPageIndex = summary.pageRanges.firstIndex {
|
||||
let range = $0.nsRange
|
||||
return offset >= range.location && offset <= max(range.location + range.length - 1, range.location)
|
||||
} ?? fallbackLocalPageIndex(for: normalizedLocation, pageCount: entry.pageCount)
|
||||
} else {
|
||||
localPageIndex = fallbackLocalPageIndex(for: normalizedLocation, pageCount: entry.pageCount)
|
||||
}
|
||||
return bookPageMap.absolutePageIndex(
|
||||
spineIndex: spineIndex,
|
||||
localPageIndex: min(max(localPageIndex, 0), max(entry.pageCount - 1, 0))
|
||||
).map { $0 + 1 }
|
||||
}
|
||||
|
||||
if let textBook, let publication {
|
||||
if let anchor = location.rangeAnchor?.start {
|
||||
if let page = textBook.indexTable.pageNumber(for: anchor, in: textBook) {
|
||||
@@ -135,6 +160,47 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
||||
|
||||
/// 根据页码解析对应的文本位置
|
||||
func resolvedTextLocation(forPageNumber pageNumber: Int) -> RDEPUBLocation? {
|
||||
if let resolvedPage = resolvedRuntimePage(forPageNumber: pageNumber) {
|
||||
let chapterLength = max(resolvedPage.chapter.typesetAttributedString.length, 1)
|
||||
let startOffset = resolvedPage.page.pageStartOffset
|
||||
let endOffset = max(startOffset, resolvedPage.page.pageEndOffset)
|
||||
let fragmentID = nearestFragmentID(
|
||||
beforeOrAt: startOffset,
|
||||
fragmentOffsets: resolvedPage.chapter.chapterOffsetMap.fragmentOffsets
|
||||
)
|
||||
let location = RDEPUBLocation(
|
||||
bookIdentifier: currentBookIdentifier,
|
||||
href: resolvedPage.page.href,
|
||||
progression: Double(startOffset) / Double(chapterLength),
|
||||
lastProgression: Double(endOffset) / Double(chapterLength),
|
||||
fragment: fragmentID,
|
||||
rangeAnchor: RDEPUBTextRangeAnchor(
|
||||
start: RDEPUBTextAnchor(
|
||||
fileIndex: resolvedPage.page.spineIndex,
|
||||
row: 0,
|
||||
column: 0,
|
||||
chapterOffset: startOffset,
|
||||
fragmentID: fragmentID
|
||||
),
|
||||
end: RDEPUBTextAnchor(
|
||||
fileIndex: resolvedPage.page.spineIndex,
|
||||
row: 0,
|
||||
column: 0,
|
||||
chapterOffset: endOffset,
|
||||
fragmentID: fragmentID
|
||||
)
|
||||
)
|
||||
)
|
||||
if let publication {
|
||||
return publication.resourceResolver.normalizedLocation(
|
||||
location,
|
||||
relativeToSpineIndex: nil,
|
||||
bookIdentifier: currentBookIdentifier
|
||||
) ?? location
|
||||
}
|
||||
return location
|
||||
}
|
||||
|
||||
guard let textBook,
|
||||
let publication,
|
||||
let page = textBook.page(at: pageNumber) else {
|
||||
@@ -154,6 +220,17 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
||||
|
||||
/// 同步文本阅读状态到阅读会话(页码、位置、spine、章节等)
|
||||
func synchronizeTextReadingState(pageNumber: Int, location: RDEPUBLocation) {
|
||||
if let resolvedPage = resolvedRuntimePage(forPageNumber: pageNumber) {
|
||||
readingSession?.updateReadingContext(
|
||||
pageNumber: pageNumber,
|
||||
location: location,
|
||||
spineIndex: resolvedPage.page.spineIndex,
|
||||
chapterIndex: resolvedPage.chapterIndex,
|
||||
bookIdentifier: currentBookIdentifier
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
guard let textBook,
|
||||
let page = textBook.page(at: pageNumber) else {
|
||||
readingSession?.transition(to: .idle)
|
||||
@@ -184,4 +261,37 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
||||
}
|
||||
return (pages, chapters)
|
||||
}
|
||||
|
||||
private func resolvedRuntimePage(forPageNumber pageNumber: Int) -> RDEPUBResolvedPage? {
|
||||
runtime.pageResolver.resolvePage(absolutePageIndex: pageNumber - 1)
|
||||
}
|
||||
|
||||
func chapterOffset(for location: RDEPUBLocation, fallbackEntry: RDEPUBBookPageMapEntry) -> Int {
|
||||
if let anchor = location.rangeAnchor?.start {
|
||||
return anchor.chapterOffset
|
||||
}
|
||||
if let fragment = location.fragment,
|
||||
let offset = fallbackEntry.fragmentOffsets[fragment] {
|
||||
return offset
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func fallbackLocalPageIndex(for location: RDEPUBLocation, pageCount: Int) -> Int {
|
||||
guard pageCount > 1 else { return 0 }
|
||||
return min(
|
||||
pageCount - 1,
|
||||
max(0, Int(round(location.navigationProgression * Double(pageCount - 1))))
|
||||
)
|
||||
}
|
||||
|
||||
private func nearestFragmentID(beforeOrAt offset: Int, fragmentOffsets: [String: Int]) -> String? {
|
||||
var bestID: String?
|
||||
var bestOffset = Int.min
|
||||
for (fragmentID, fragmentOffset) in fragmentOffsets where fragmentOffset <= offset && fragmentOffset > bestOffset {
|
||||
bestOffset = fragmentOffset
|
||||
bestID = fragmentID
|
||||
}
|
||||
return bestID
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,11 +15,28 @@ import UIKit
|
||||
extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||||
/// 返回阅读器总页数
|
||||
public func pageCountOfReaderView(readerView: RDReaderView) -> Int {
|
||||
textBook?.pages.count ?? activePages.count
|
||||
readerContext.bookPageMap?.totalPages ?? textBook?.pages.count ?? activePages.count
|
||||
}
|
||||
|
||||
/// 为指定页码创建或复用内容视图(优先文本渲染,回退 Web 渲染)
|
||||
public func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView {
|
||||
if readerContext.bookPageMap != nil {
|
||||
_ = runtime.prepareOnDemandChapter(forAbsolutePageNumber: pageNum + 1)
|
||||
if let resolvedPage = runtime.pageResolver.resolvePage(absolutePageIndex: pageNum) {
|
||||
let contentView = (containerView as? RDEPUBTextContentView) ?? RDEPUBTextContentView()
|
||||
contentView.delegate = self
|
||||
contentView.configure(
|
||||
page: resolvedPage.page,
|
||||
pageNumber: pageNum + 1,
|
||||
totalPages: pageCountOfReaderView(readerView: readerView),
|
||||
configuration: configuration,
|
||||
highlights: textHighlights(for: resolvedPage.page),
|
||||
searchState: searchState
|
||||
)
|
||||
return contentView
|
||||
}
|
||||
}
|
||||
|
||||
if let textBook, let page = textBook.page(at: pageNum + 1) {
|
||||
let contentView = (containerView as? RDEPUBTextContentView) ?? RDEPUBTextContentView()
|
||||
contentView.delegate = self
|
||||
@@ -53,7 +70,7 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||||
|
||||
/// 返回页面内容视图的重用标识符(区分文本与 Web 渲染)
|
||||
public func pageIdentifier(readerView: RDReaderView, pageNum: Int) -> String? {
|
||||
textBook == nil
|
||||
(textBook == nil && readerContext.bookPageMap == nil)
|
||||
? NSStringFromClass(RDEPUBWebContentView.self)
|
||||
: NSStringFromClass(RDEPUBTextContentView.self)
|
||||
}
|
||||
@@ -91,15 +108,20 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||||
|
||||
/// 页码变化回调:清除选区、同步阅读状态、检测到达末尾
|
||||
public func pageNum(readerView: RDReaderView, pageNum: Int) {
|
||||
readerContext.markUserNavigationActivity()
|
||||
updateCurrentSelection(nil)
|
||||
reconcileTextPaginationSizeIfNeeded(for: pageNum)
|
||||
if readerContext.bookPageMap != nil {
|
||||
_ = runtime.prepareOnDemandChapter(forAbsolutePageNumber: pageNum + 1)
|
||||
runtime.extendPartialBookPageMapIfNeeded(currentPageNumber: pageNum + 1)
|
||||
}
|
||||
|
||||
let totalPages = pageCountOfReaderView(readerView: readerView)
|
||||
if totalPages > 0, pageNum == totalPages - 1 {
|
||||
delegate?.epubReaderDidReachEnd(self)
|
||||
}
|
||||
|
||||
if textBook != nil,
|
||||
if (textBook != nil || readerContext.bookPageMap != nil),
|
||||
let location = resolvedTextLocation(forPageNumber: pageNum + 1) {
|
||||
persist(location: location)
|
||||
synchronizeTextReadingState(pageNumber: pageNum + 1, location: location)
|
||||
@@ -122,7 +144,7 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||||
|
||||
/// 检测页面尺寸变化并触发重新分页(避免布局错乱)
|
||||
private func reconcileTextPaginationSizeIfNeeded(for pageNum: Int) {
|
||||
guard textBook != nil,
|
||||
guard textBook != nil || readerContext.bookPageMap != nil,
|
||||
!isRepaginating,
|
||||
!isReconcilingTextPaginationSize,
|
||||
pageNum >= 0,
|
||||
@@ -146,7 +168,7 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.isReconcilingTextPaginationSize = false
|
||||
guard self.textBook != nil, !self.isRepaginating else { return }
|
||||
guard (self.textBook != nil || self.readerContext.bookPageMap != nil), !self.isRepaginating else { return }
|
||||
self.repaginatePreservingCurrentLocation()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,8 +50,18 @@ extension RDEPUBReaderController {
|
||||
/// 获取当前页的原生文本语义摘要,用于调试和可访问性
|
||||
/// - Returns: 包含页码、断行原因、块类型等信息的摘要字符串,无内容时返回 nil
|
||||
public func nativeTextSemanticSummary() -> String? {
|
||||
guard let textBook,
|
||||
let page = textBook.page(at: max(readerView.currentPage + 1, 1)) ?? textBook.pages.first else {
|
||||
let resolvedPage: RDEPUBTextPage?
|
||||
if let textBook {
|
||||
resolvedPage = textBook.page(at: max(readerView.currentPage + 1, 1)) ?? textBook.pages.first
|
||||
} else if readerContext.bookPageMap != nil {
|
||||
let absolutePageIndex = max(readerView.currentPage, 0)
|
||||
_ = runtime.prepareOnDemandChapter(forAbsolutePageNumber: absolutePageIndex + 1)
|
||||
resolvedPage = runtime.pageResolver.resolvePage(absolutePageIndex: absolutePageIndex)?.page
|
||||
} else {
|
||||
resolvedPage = nil
|
||||
}
|
||||
|
||||
guard let page = resolvedPage else {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -242,4 +252,3 @@ extension RDEPUBReaderController {
|
||||
runtime.clearSearch()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -233,6 +233,7 @@ extension RDEPUBReaderController {
|
||||
hideLoading()
|
||||
readerContext.clearActiveSnapshot()
|
||||
textBook = nil
|
||||
readerContext.bookPageMap = nil
|
||||
readerView.reloadData()
|
||||
errorLabel.text = error.localizedDescription
|
||||
errorLabel.isHidden = false
|
||||
|
||||
@@ -13,18 +13,12 @@ import Foundation
|
||||
extension RDEPUBReaderController {
|
||||
/// 解析当前阅读位置对应的目录项,按页码或 href 匹配
|
||||
func resolvedCurrentTableOfContentsItem() -> RDEPUBReaderTableOfContentsItem? {
|
||||
let items = flattenedTableOfContents
|
||||
let items = flattenedTableOfContentsItems(
|
||||
from: publication?.tableOfContents ?? [],
|
||||
includePageNumbers: false
|
||||
)
|
||||
guard !items.isEmpty else { return nil }
|
||||
|
||||
let currentPageNumber = max(readerView.currentPage + 1, 1)
|
||||
let pageAnchoredMatch = items.last { item in
|
||||
guard let pageNumber = item.pageNumber else { return false }
|
||||
return pageNumber <= currentPageNumber
|
||||
}
|
||||
if let pageAnchoredMatch {
|
||||
return pageAnchoredMatch
|
||||
}
|
||||
|
||||
guard let publication,
|
||||
let currentLocation = currentVisibleLocation(),
|
||||
let normalizedCurrentHref = publication.resourceResolver.normalizedHref(currentLocation.href) else {
|
||||
@@ -60,28 +54,12 @@ extension RDEPUBReaderController {
|
||||
/// 将嵌套目录树递归扁平化为线性列表,并计算每项目标页码
|
||||
func flattenedTableOfContentsItems(
|
||||
from items: [EPUBTableOfContentsItem],
|
||||
depth: Int = 0
|
||||
depth: Int = 0,
|
||||
includePageNumbers: Bool = true
|
||||
) -> [RDEPUBReaderTableOfContentsItem] {
|
||||
items.flatMap { item in
|
||||
let location = RDEPUBLocation(bookIdentifier: currentBookIdentifier, href: item.href, progression: 0)
|
||||
let pageNumber: Int?
|
||||
if let textBook, let publication,
|
||||
let chapterData = textBook.chapterData(for: location, resolver: publication.resourceResolver, bookIdentifier: currentBookIdentifier) {
|
||||
let normalizedLocation = publication.resourceResolver.normalizedLocation(
|
||||
location,
|
||||
bookIdentifier: currentBookIdentifier
|
||||
) ?? location
|
||||
pageNumber = chapterData.pageNumber(for: normalizedLocation)
|
||||
?? textBook.pageNumber(
|
||||
for: location,
|
||||
resolver: publication.resourceResolver,
|
||||
bookIdentifier: currentBookIdentifier
|
||||
)
|
||||
} else if let readingSession {
|
||||
pageNumber = readingSession.pageIndex(for: location, bookIdentifier: currentBookIdentifier).map { $0 + 1 }
|
||||
} else {
|
||||
pageNumber = nil
|
||||
}
|
||||
let pageNumber = includePageNumbers ? resolvedTableOfContentsPageNumber(for: location) : nil
|
||||
|
||||
let current = RDEPUBReaderTableOfContentsItem(
|
||||
title: item.title,
|
||||
@@ -89,8 +67,53 @@ extension RDEPUBReaderController {
|
||||
depth: depth,
|
||||
pageNumber: pageNumber
|
||||
)
|
||||
return [current] + flattenedTableOfContentsItems(from: item.children, depth: depth + 1)
|
||||
return [current] + flattenedTableOfContentsItems(
|
||||
from: item.children,
|
||||
depth: depth + 1,
|
||||
includePageNumbers: includePageNumbers
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func resolvedTableOfContentsPageNumber(for location: RDEPUBLocation) -> Int? {
|
||||
if let publication,
|
||||
let bookPageMap = readerContext.bookPageMap,
|
||||
let spineIndex = readerContext.normalizedSpineIndex(for: location),
|
||||
let entry = bookPageMap.entry(forSpineIndex: spineIndex) {
|
||||
let normalizedLocation = publication.resourceResolver.normalizedLocation(
|
||||
location,
|
||||
bookIdentifier: currentBookIdentifier
|
||||
) ?? location
|
||||
let localPageIndex: Int
|
||||
if let summary = readerContext.chapterSummary(forSpineIndex: spineIndex) {
|
||||
let offset = chapterOffset(for: normalizedLocation, fallbackEntry: entry)
|
||||
localPageIndex = summary.pageRanges.firstIndex {
|
||||
let range = $0.nsRange
|
||||
return offset >= range.location && offset <= max(range.location + range.length - 1, range.location)
|
||||
} ?? fallbackLocalPageIndex(for: normalizedLocation, pageCount: entry.pageCount)
|
||||
} else {
|
||||
localPageIndex = fallbackLocalPageIndex(for: normalizedLocation, pageCount: entry.pageCount)
|
||||
}
|
||||
return bookPageMap.absolutePageIndex(
|
||||
spineIndex: spineIndex,
|
||||
localPageIndex: min(max(localPageIndex, 0), max(entry.pageCount - 1, 0))
|
||||
).map { $0 + 1 }
|
||||
}
|
||||
|
||||
if let textBook, let publication,
|
||||
let chapterData = textBook.chapterData(for: location, resolver: publication.resourceResolver, bookIdentifier: currentBookIdentifier) {
|
||||
let normalizedLocation = publication.resourceResolver.normalizedLocation(
|
||||
location,
|
||||
bookIdentifier: currentBookIdentifier
|
||||
) ?? location
|
||||
return chapterData.pageNumber(for: normalizedLocation)
|
||||
?? textBook.pageNumber(
|
||||
for: location,
|
||||
resolver: publication.resourceResolver,
|
||||
bookIdentifier: currentBookIdentifier
|
||||
)
|
||||
}
|
||||
|
||||
return readingSession?.pageIndex(for: location, bookIdentifier: currentBookIdentifier).map { $0 + 1 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,6 +247,8 @@ final class RDEPUBReaderBookmarksViewController: UITableViewController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.accessibilityIdentifier = "epub.reader.bookmarks.panel"
|
||||
tableView.accessibilityIdentifier = "epub.reader.bookmarks.table"
|
||||
tableView.tableFooterView = UIView(frame: .zero)
|
||||
tableView.separatorInset = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)
|
||||
applyTheme()
|
||||
@@ -286,6 +288,7 @@ final class RDEPUBReaderBookmarksViewController: UITableViewController {
|
||||
navigationController?.navigationBar.tintColor = theme.toolControlTextColor
|
||||
navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor
|
||||
navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor]
|
||||
navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.bookmarks.navbar"
|
||||
}
|
||||
|
||||
private func updateEmptyState() {
|
||||
|
||||
@@ -25,6 +25,12 @@ import UIKit
|
||||
/// navigationController?.pushViewController(controller, animated: true)
|
||||
/// ```
|
||||
public final class RDURLReaderController: UIViewController {
|
||||
private struct PendingDemoPageRequest {
|
||||
let pageNumber: Int
|
||||
let animated: Bool
|
||||
var attemptCount: Int
|
||||
}
|
||||
|
||||
/// 书籍文件 URL
|
||||
private let bookURL: URL
|
||||
/// EPUB 阅读器配置(字体、行距、主题等)
|
||||
@@ -41,6 +47,12 @@ public final class RDURLReaderController: UIViewController {
|
||||
label.text = "reader=opening"
|
||||
return label
|
||||
}()
|
||||
private var pendingDemoPageRequest: PendingDemoPageRequest?
|
||||
private var isRetryingPendingDemoPage = false
|
||||
private let maxPendingDemoPageAttempts = 24
|
||||
private let pendingDemoPageRetryDelay: TimeInterval = 0.25
|
||||
private var demoStateTimer: Timer?
|
||||
private var lastEmittedDemoState = ""
|
||||
|
||||
/// 初始化方法
|
||||
/// - Parameters:
|
||||
@@ -71,7 +83,17 @@ public final class RDURLReaderController: UIViewController {
|
||||
|
||||
public override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
emitDemoState()
|
||||
startDemoStateTimerIfNeeded()
|
||||
refreshDemoState()
|
||||
}
|
||||
|
||||
public override func viewDidDisappear(_ animated: Bool) {
|
||||
super.viewDidDisappear(animated)
|
||||
stopDemoStateTimer()
|
||||
}
|
||||
|
||||
deinit {
|
||||
stopDemoStateTimer()
|
||||
}
|
||||
|
||||
/// 切换阅读器的翻页模式(Demo 用)
|
||||
@@ -88,9 +110,12 @@ public final class RDURLReaderController: UIViewController {
|
||||
/// - Returns: 是否成功跳转
|
||||
@discardableResult
|
||||
public func goToDemoPage(_ pageNumber: Int, animated: Bool = false) -> Bool {
|
||||
let moved = readerController?.go(toPageNumber: pageNumber, animated: animated) ?? false
|
||||
let moved = performDemoPageNavigation(pageNumber, animated: animated)
|
||||
if moved {
|
||||
emitDemoState(prefix: "page=\(pageNumber)")
|
||||
pendingDemoPageRequest = nil
|
||||
isRetryingPendingDemoPage = false
|
||||
} else {
|
||||
queuePendingDemoPageNavigation(pageNumber, animated: animated)
|
||||
}
|
||||
return moved
|
||||
}
|
||||
@@ -142,8 +167,8 @@ public final class RDURLReaderController: UIViewController {
|
||||
edgeInsets: epubConfiguration.reflowableContentInsets,
|
||||
numberOfColumns: 1,
|
||||
columnGap: 20,
|
||||
avoidOrphans: true,
|
||||
avoidWidows: true,
|
||||
avoidOrphans: false,
|
||||
avoidWidows: false,
|
||||
avoidPageBreakInsideEnabled: true,
|
||||
hyphenation: true,
|
||||
imageMaxHeightRatio: 0.85
|
||||
@@ -187,6 +212,75 @@ public final class RDURLReaderController: UIViewController {
|
||||
embeddedController as? RDEPUBReaderController
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func performDemoPageNavigation(_ pageNumber: Int, animated: Bool) -> Bool {
|
||||
guard let readerController else { return false }
|
||||
guard pageNumber > 0 else { return false }
|
||||
|
||||
let knownPages = readerController.readerContext.bookPageMap?.totalPages
|
||||
?? readerController.textBook?.pages.count
|
||||
?? readerController.activePages.count
|
||||
let numPages = readerController.readerView.numberOfPages()
|
||||
print("[ReadViewDemo] performDemoPageNavigation(\(pageNumber)): knownPages=\(knownPages), readerView.numberOfPages=\(numPages), currentPage=\(readerController.readerView.currentPage)")
|
||||
let moved = readerController.go(toPageNumber: pageNumber, animated: animated)
|
||||
print("[ReadViewDemo] performDemoPageNavigation(\(pageNumber)): moved=\(moved), after currentPage=\(readerController.readerView.currentPage)")
|
||||
if moved {
|
||||
emitDemoState(prefix: "page=\(pageNumber)")
|
||||
}
|
||||
return moved
|
||||
}
|
||||
|
||||
private func queuePendingDemoPageNavigation(_ pageNumber: Int, animated: Bool) {
|
||||
if let pendingDemoPageRequest,
|
||||
pendingDemoPageRequest.pageNumber == pageNumber,
|
||||
pendingDemoPageRequest.animated == animated {
|
||||
return
|
||||
}
|
||||
|
||||
pendingDemoPageRequest = PendingDemoPageRequest(
|
||||
pageNumber: pageNumber,
|
||||
animated: animated,
|
||||
attemptCount: 0
|
||||
)
|
||||
retryPendingDemoPageIfNeeded()
|
||||
}
|
||||
|
||||
private func retryPendingDemoPageIfNeeded() {
|
||||
guard !isRetryingPendingDemoPage else { return }
|
||||
guard pendingDemoPageRequest != nil else { return }
|
||||
|
||||
isRetryingPendingDemoPage = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + pendingDemoPageRetryDelay) { [weak self] in
|
||||
self?.attemptPendingDemoPageNavigation()
|
||||
}
|
||||
}
|
||||
|
||||
private func attemptPendingDemoPageNavigation() {
|
||||
guard var pendingDemoPageRequest else {
|
||||
isRetryingPendingDemoPage = false
|
||||
return
|
||||
}
|
||||
|
||||
if performDemoPageNavigation(pendingDemoPageRequest.pageNumber, animated: pendingDemoPageRequest.animated) {
|
||||
self.pendingDemoPageRequest = nil
|
||||
isRetryingPendingDemoPage = false
|
||||
return
|
||||
}
|
||||
|
||||
pendingDemoPageRequest.attemptCount += 1
|
||||
if pendingDemoPageRequest.attemptCount >= maxPendingDemoPageAttempts {
|
||||
self.pendingDemoPageRequest = nil
|
||||
isRetryingPendingDemoPage = false
|
||||
print("[ReadViewDemo] automation page=\(pendingDemoPageRequest.pageNumber) failed after \(pendingDemoPageRequest.attemptCount) attempts")
|
||||
return
|
||||
}
|
||||
|
||||
self.pendingDemoPageRequest = pendingDemoPageRequest
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + pendingDemoPageRetryDelay) { [weak self] in
|
||||
self?.attemptPendingDemoPageNavigation()
|
||||
}
|
||||
}
|
||||
|
||||
/// 输出当前阅读器状态日志(Demo 调试用)
|
||||
private func logDemoState(prefix: String) {
|
||||
guard let readerController else { return }
|
||||
@@ -208,19 +302,90 @@ public final class RDURLReaderController: UIViewController {
|
||||
])
|
||||
}
|
||||
|
||||
private func startDemoStateTimerIfNeeded() {
|
||||
guard demoStateTimer == nil else { return }
|
||||
demoStateTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
|
||||
self?.refreshDemoState(logIfChanged: false)
|
||||
}
|
||||
if let demoStateTimer {
|
||||
RunLoop.main.add(demoStateTimer, forMode: .common)
|
||||
}
|
||||
}
|
||||
|
||||
private func stopDemoStateTimer() {
|
||||
demoStateTimer?.invalidate()
|
||||
demoStateTimer = nil
|
||||
}
|
||||
|
||||
private func emitDemoState(prefix: String? = nil) {
|
||||
refreshDemoState(logPrefix: prefix, logIfChanged: true)
|
||||
}
|
||||
|
||||
private func refreshDemoState(logPrefix: String? = nil, logIfChanged: Bool = true) {
|
||||
let page = readerController?.currentPageNumber.map(String.init) ?? "nil"
|
||||
let display = readerController?.configuration.displayType.demoArgumentValue ?? epubConfiguration.displayType.demoArgumentValue
|
||||
let toolbar = readerController?.readerView.isShowToolView == true ? "visible" : "hidden"
|
||||
let highlights = readerController?.highlights.count ?? 0
|
||||
let selection = readerController?.currentSelection == nil ? 0 : 1
|
||||
let state = "reader=opened page=\(page) display=\(display) toolbar=\(toolbar) highlights=\(highlights) selection=\(selection)"
|
||||
let location = readerController?.currentLocation
|
||||
let href = encodedDemoLocationHref(location?.href)
|
||||
let progression = location.map { String(format: "%.4f", $0.navigationProgression) } ?? "nil"
|
||||
let mapSnapshot = demoPaginationSnapshot()
|
||||
let layoutConfig = readerController?.readerContext.currentTextLayoutConfig(pageSize: currentTextPageSize())
|
||||
let state = [
|
||||
"reader=opened",
|
||||
"page=\(page)",
|
||||
"display=\(display)",
|
||||
"toolbar=\(toolbar)",
|
||||
"highlights=\(highlights)",
|
||||
"selection=\(selection)",
|
||||
"href=\(href)",
|
||||
"progression=\(progression)",
|
||||
"mode=\(mapSnapshot.mode)",
|
||||
"pagination=\(mapSnapshot.phase)",
|
||||
"knownPages=\(mapSnapshot.knownPages)",
|
||||
"knownChapters=\(mapSnapshot.knownChapters)",
|
||||
"buildableChapters=\(mapSnapshot.buildableChapters)",
|
||||
"avoidWidows=\(layoutConfig?.avoidWidows == true ? 1 : 0)",
|
||||
"avoidOrphans=\(layoutConfig?.avoidOrphans == true ? 1 : 0)",
|
||||
"windowSize=\(readerController?.configuration.onDemandChapterWindowSize ?? epubConfiguration.onDemandChapterWindowSize)",
|
||||
"parseMs=\(readerController?.readerContext.lastMetadataParseWallClockMs ?? 0)",
|
||||
"parseConcurrency=\(readerController?.readerContext.lastMetadataParseConcurrency ?? 0)"
|
||||
].joined(separator: " ")
|
||||
demoStateLabel.text = state
|
||||
if let prefix {
|
||||
logDemoState(prefix: prefix)
|
||||
} else {
|
||||
if let logPrefix {
|
||||
logDemoState(prefix: logPrefix)
|
||||
} else if logIfChanged, state != lastEmittedDemoState {
|
||||
print("[ReadViewDemo] automation \(state)")
|
||||
}
|
||||
lastEmittedDemoState = state
|
||||
}
|
||||
|
||||
private func encodedDemoLocationHref(_ href: String?) -> String {
|
||||
guard let href, !href.isEmpty else { return "nil" }
|
||||
return href.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? href.replacingOccurrences(of: " ", with: "%20")
|
||||
}
|
||||
|
||||
private func demoPaginationSnapshot() -> (mode: String, phase: String, knownPages: Int, knownChapters: Int, buildableChapters: Int) {
|
||||
guard let readerController else {
|
||||
return ("unavailable", "none", 0, 0, 0)
|
||||
}
|
||||
|
||||
if let bookPageMap = readerController.readerContext.bookPageMap {
|
||||
let buildableChapters = readerController.publication?.spine.filter {
|
||||
$0.linear && ($0.mediaType.contains("html") || $0.mediaType.contains("xhtml"))
|
||||
}.count ?? 0
|
||||
let phase = buildableChapters > 0 && bookPageMap.totalChapters >= buildableChapters ? "full" : "partial"
|
||||
return ("bookPageMap", phase, bookPageMap.totalPages, bookPageMap.totalChapters, buildableChapters)
|
||||
}
|
||||
|
||||
if let textBook = readerController.textBook {
|
||||
return ("textBook", "full", textBook.pages.count, textBook.chapters.count, textBook.chapters.count)
|
||||
}
|
||||
|
||||
let snapshotChapters = readerController.activeChapters.count
|
||||
let snapshotPages = readerController.activePages.count
|
||||
return ("snapshot", snapshotPages > 0 ? "full" : "none", snapshotPages, snapshotChapters, snapshotChapters)
|
||||
}
|
||||
|
||||
/// 计算文本分页的页面尺寸。
|
||||
@@ -259,7 +424,13 @@ public final class RDURLReaderController: UIViewController {
|
||||
}
|
||||
|
||||
extension RDURLReaderController: RDEPUBReaderDelegate {
|
||||
public func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication) {
|
||||
retryPendingDemoPageIfNeeded()
|
||||
emitDemoState()
|
||||
}
|
||||
|
||||
public func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation) {
|
||||
retryPendingDemoPageIfNeeded()
|
||||
emitDemoState()
|
||||
}
|
||||
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import Foundation
|
||||
|
||||
enum RDEPUBBackgroundTrace {
|
||||
static func log(_ scope: String, _ message: String) {
|
||||
let threadRole = Thread.isMainThread ? "main" : "bg"
|
||||
let threadName = resolvedThreadName()
|
||||
let queueLabel = resolvedQueueLabel()
|
||||
print("[EPUB][\(scope)][\(threadRole)][queue=\(queueLabel)][thread=\(threadName)] \(message)")
|
||||
}
|
||||
|
||||
static func measure<T>(_ scope: String, _ message: String, work: () throws -> T) rethrows -> T {
|
||||
let startedAt = CFAbsoluteTimeGetCurrent()
|
||||
log(scope, "START \(message)")
|
||||
do {
|
||||
let result = try work()
|
||||
let elapsedMs = Int((CFAbsoluteTimeGetCurrent() - startedAt) * 1000)
|
||||
log(scope, "END \(message) elapsedMs=\(elapsedMs)")
|
||||
return result
|
||||
} catch {
|
||||
let elapsedMs = Int((CFAbsoluteTimeGetCurrent() - startedAt) * 1000)
|
||||
log(scope, "FAIL \(message) elapsedMs=\(elapsedMs) error=\(error)")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private static func resolvedThreadName() -> String {
|
||||
if let name = Thread.current.name, !name.isEmpty {
|
||||
return name
|
||||
}
|
||||
if Thread.isMainThread {
|
||||
return "main"
|
||||
}
|
||||
return String(describing: Unmanaged.passUnretained(Thread.current).toOpaque())
|
||||
}
|
||||
|
||||
private static func resolvedQueueLabel() -> String {
|
||||
String(validatingUTF8: __dispatch_queue_get_label(nil)) ?? "unknown"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import Foundation
|
||||
|
||||
/// BookPageMap 中的单个条目,记录一个章节的轻量元数据。
|
||||
/// 不持有 NSAttributedString,每条约 100 字节。
|
||||
struct RDEPUBBookPageMapEntry {
|
||||
let spineIndex: Int
|
||||
let href: String
|
||||
let title: String
|
||||
/// 该章节的页数
|
||||
let pageCount: Int
|
||||
/// 该章节在全书中的绝对起始页码(从 0 开始)
|
||||
let absolutePageStart: Int
|
||||
/// fragment ID → 字符偏移量映射
|
||||
let fragmentOffsets: [String: Int]
|
||||
}
|
||||
|
||||
/// 全书轻量页码映射:仅存储每章的页数和起始位置,
|
||||
/// 内存成本约 100 字节/章,1000 章 ≈ 100KB。
|
||||
///
|
||||
/// 提供 spineIndex ↔ 绝对页码的双向查询,
|
||||
/// 用于进度条、目录跳转、位置恢复等不依赖内容的场景。
|
||||
struct RDEPUBBookPageMap {
|
||||
let entries: [RDEPUBBookPageMapEntry]
|
||||
/// 按 spineIndex 索引的查找表
|
||||
private let indexBySpine: [Int: Int] // spineIndex -> entries 数组下标
|
||||
/// 全书总页数
|
||||
let totalPages: Int
|
||||
|
||||
init(entries: [RDEPUBBookPageMapEntry]) {
|
||||
self.entries = entries
|
||||
var mapping: [Int: Int] = [:]
|
||||
for (i, entry) in entries.enumerated() {
|
||||
mapping[entry.spineIndex] = i
|
||||
}
|
||||
self.indexBySpine = mapping
|
||||
self.totalPages = entries.last.map { $0.absolutePageStart + $0.pageCount } ?? 0
|
||||
}
|
||||
|
||||
static let empty = RDEPUBBookPageMap(entries: [])
|
||||
|
||||
// MARK: - 查询
|
||||
|
||||
/// spineIndex + 本地页码 → 全书绝对页码
|
||||
func absolutePageIndex(spineIndex: Int, localPageIndex: Int) -> Int? {
|
||||
guard let idx = indexBySpine[spineIndex] else { return nil }
|
||||
let entry = entries[idx]
|
||||
guard localPageIndex >= 0, localPageIndex < entry.pageCount else { return nil }
|
||||
return entry.absolutePageStart + localPageIndex
|
||||
}
|
||||
|
||||
/// 全书绝对页码 → spineIndex
|
||||
func spineIndex(forAbsolutePage absolutePage: Int) -> Int? {
|
||||
guard absolutePage >= 0, absolutePage < totalPages else { return nil }
|
||||
// 二分查找:entries 按 absolutePageStart 有序
|
||||
var lo = 0, hi = entries.count
|
||||
while lo < hi {
|
||||
let mid = lo + (hi - lo) / 2
|
||||
if entries[mid].absolutePageStart <= absolutePage {
|
||||
lo = mid + 1
|
||||
} else {
|
||||
hi = mid
|
||||
}
|
||||
}
|
||||
guard lo > 0 else { return nil }
|
||||
return entries[lo - 1].spineIndex
|
||||
}
|
||||
|
||||
/// 全书绝对页码 → 本地页码(章节内偏移)
|
||||
func localPageIndex(forAbsolutePage absolutePage: Int) -> Int? {
|
||||
guard let si = spineIndex(forAbsolutePage: absolutePage),
|
||||
let idx = indexBySpine[si] else { return nil }
|
||||
let entry = entries[idx]
|
||||
let local = absolutePage - entry.absolutePageStart
|
||||
guard local >= 0, local < entry.pageCount else { return nil }
|
||||
return local
|
||||
}
|
||||
|
||||
/// 获取指定 spineIndex 的条目
|
||||
func entry(forSpineIndex spineIndex: Int) -> RDEPUBBookPageMapEntry? {
|
||||
guard let idx = indexBySpine[spineIndex] else { return nil }
|
||||
return entries[idx]
|
||||
}
|
||||
|
||||
/// 获取指定 spineIndex 在 entries 中的章节序号。
|
||||
func chapterIndex(forSpineIndex spineIndex: Int) -> Int? {
|
||||
indexBySpine[spineIndex]
|
||||
}
|
||||
|
||||
/// 获取指定 spineIndex 的页数
|
||||
func pageCount(forSpineIndex spineIndex: Int) -> Int? {
|
||||
entry(forSpineIndex: spineIndex)?.pageCount
|
||||
}
|
||||
|
||||
/// 全书总章节数
|
||||
var totalChapters: Int { entries.count }
|
||||
|
||||
// MARK: - 构建
|
||||
|
||||
/// Builder:从各章的 pageCount 逐步构建 BookPageMap
|
||||
struct Builder {
|
||||
private var items: [(spineIndex: Int, href: String, title: String, pageCount: Int, fragmentOffsets: [String: Int])] = []
|
||||
|
||||
mutating func add(spineIndex: Int, href: String, title: String, pageCount: Int, fragmentOffsets: [String: Int]) {
|
||||
items.append((spineIndex, href, title, pageCount, fragmentOffsets))
|
||||
}
|
||||
|
||||
func build() -> RDEPUBBookPageMap {
|
||||
// 按 spineIndex 排序
|
||||
let sorted = items.sorted { $0.spineIndex < $1.spineIndex }
|
||||
var entries: [RDEPUBBookPageMapEntry] = []
|
||||
var absolutePageStart = 0
|
||||
for item in sorted {
|
||||
entries.append(RDEPUBBookPageMapEntry(
|
||||
spineIndex: item.spineIndex,
|
||||
href: item.href,
|
||||
title: item.title,
|
||||
pageCount: item.pageCount,
|
||||
absolutePageStart: absolutePageStart,
|
||||
fragmentOffsets: item.fragmentOffsets
|
||||
))
|
||||
absolutePageStart += item.pageCount
|
||||
}
|
||||
return RDEPUBBookPageMap(entries: entries)
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import Foundation
|
||||
|
||||
struct RDEPUBChapterCacheKey: Hashable {
|
||||
let bookID: String
|
||||
let spineIndex: Int
|
||||
let renderSignature: String
|
||||
let chapterContentHash: String
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBChapterDataCache {
|
||||
private var storage: [Int: RDEPUBRuntimeChapter] = [:]
|
||||
private let lock = NSLock()
|
||||
|
||||
subscript(_ spineIndex: Int) -> RDEPUBRuntimeChapter? {
|
||||
get {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return storage[spineIndex]
|
||||
}
|
||||
set {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
storage[spineIndex] = newValue
|
||||
}
|
||||
}
|
||||
|
||||
var storedSpineIndices: [Int] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return Array(storage.keys)
|
||||
}
|
||||
|
||||
func remove(spineIndex: Int) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
storage.removeValue(forKey: spineIndex)
|
||||
}
|
||||
|
||||
func removeAll() {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
storage.removeAll()
|
||||
}
|
||||
}
|
||||
+534
@@ -0,0 +1,534 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBChapterLoader {
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
private var summaryDiskCache: RDEPUBChapterSummaryDiskCache?
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
func setSummaryDiskCache(_ cache: RDEPUBChapterSummaryDiskCache) {
|
||||
summaryDiskCache = cache
|
||||
}
|
||||
|
||||
// MARK: - 请求优先级
|
||||
|
||||
enum LoadPriority {
|
||||
case navigation // 前台导航:用户主动跳章,完成后检查导航目标队列
|
||||
case prefetch // 后台预取:±1 相邻章,完成后仅回填缓存 + 刷新快照
|
||||
}
|
||||
|
||||
// MARK: - 主入口:加载单个章节
|
||||
|
||||
/// 在 chapterLoadQueue 上构建单章,完成后回调到主线程
|
||||
func loadChapter(
|
||||
spineIndex: Int,
|
||||
store: RDEPUBChapterRuntimeStore,
|
||||
priority: LoadPriority = .navigation,
|
||||
completion: @escaping (Result<RDEPUBRuntimeChapter, Error>) -> Void
|
||||
) {
|
||||
// 1. 查内存缓存(统一回主线程,保证 completion 线程语义一致)
|
||||
if let cached = store.chapterData(for: spineIndex) {
|
||||
RDEPUBBackgroundTrace.log("ChapterLoader", "cache hit spine=\(spineIndex) priority=\(priority)")
|
||||
DispatchQueue.main.async {
|
||||
completion(.success(cached))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 2-3. 构建缓存键 + 查内存级 pageCountCache 统一移到串行队列执行,
|
||||
// 避免 contentHashForSpineIndex 的 SHA256 + 磁盘 I/O 阻塞主线程
|
||||
store.markBuilding(true)
|
||||
store.chapterLoadQueue.async {
|
||||
RDEPUBBackgroundTrace.log("ChapterLoader", "queue start spine=\(spineIndex) priority=\(priority)")
|
||||
let cacheKey = self.makeCacheKey(spineIndex: spineIndex)
|
||||
|
||||
// 仅当内存级 pageCountCache 未命中时才查磁盘摘要
|
||||
let precomputedPageRanges = store.pageCount(for: cacheKey)?.pageRanges
|
||||
let diskSummary: RDEPUBChapterSummary?
|
||||
if precomputedPageRanges == nil {
|
||||
diskSummary = self.summaryDiskCache?.read(for: cacheKey)
|
||||
if diskSummary != nil {
|
||||
RDEPUBBackgroundTrace.log("ChapterLoader", "磁盘摘要缓存命中 spine=\(spineIndex)")
|
||||
} else {
|
||||
RDEPUBBackgroundTrace.log("ChapterLoader", "缓存未命中 spine=\(spineIndex)")
|
||||
}
|
||||
} else {
|
||||
diskSummary = nil
|
||||
RDEPUBBackgroundTrace.log("ChapterLoader", "页数缓存命中 spine=\(spineIndex)")
|
||||
}
|
||||
let diskPageRanges = diskSummary?.pageRanges.map { $0.nsRange }
|
||||
let availablePageRanges = precomputedPageRanges ?? diskPageRanges
|
||||
|
||||
do {
|
||||
let chapter = try RDEPUBBackgroundTrace.measure(
|
||||
"ChapterLoader",
|
||||
"buildChapter spine=\(spineIndex) priority=\(priority) cachedRanges=\(availablePageRanges?.count ?? 0)"
|
||||
) {
|
||||
try self.buildChapter(
|
||||
spineIndex: spineIndex,
|
||||
availablePageRanges: availablePageRanges,
|
||||
diskSummary: diskSummary
|
||||
)
|
||||
}
|
||||
RDEPUBBackgroundTrace.log("ChapterLoader", "buildChapter OK: spine=\(spineIndex) pages=\(chapter.pages.count)")
|
||||
|
||||
// 5. 回填缓存
|
||||
store.insertChapter(chapter)
|
||||
let pc = RDEPUBRuntimePageCount(
|
||||
cacheKey: cacheKey,
|
||||
spineIndex: spineIndex,
|
||||
pageRanges: chapter.pageRanges,
|
||||
pageCount: chapter.pages.count,
|
||||
renderSignature: cacheKey.renderSignature
|
||||
)
|
||||
store.insertPageCount(pc, for: cacheKey)
|
||||
|
||||
// 6. 按优先级处理完成逻辑
|
||||
switch priority {
|
||||
case .navigation:
|
||||
// 前台导航:检查是否有更新的导航目标(§14.4 取消语义)
|
||||
let nextTarget = store.consumeNavigationTarget()
|
||||
if let target = nextTarget, target != spineIndex {
|
||||
// 当前结果不再是用户目标,丢弃,转而加载新目标
|
||||
store.markBuilding(false)
|
||||
self.loadChapter(spineIndex: target, store: store, priority: .navigation, completion: completion)
|
||||
return
|
||||
}
|
||||
store.markBuilding(false)
|
||||
DispatchQueue.main.async {
|
||||
completion(.success(chapter))
|
||||
}
|
||||
|
||||
case .prefetch:
|
||||
// 后台预取:仅回填缓存,标记预取目标完成
|
||||
// 不触发跳章,不检查导航目标队列
|
||||
store.removePrefetchTarget(spineIndex)
|
||||
store.markBuilding(false)
|
||||
DispatchQueue.main.async {
|
||||
completion(.success(chapter))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
RDEPUBBackgroundTrace.log("ChapterLoader", "buildChapter FAILED: spine=\(spineIndex) error=\(error)")
|
||||
store.markBuilding(false)
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 同步加载入口(仅供 legacy 位置迁移使用)
|
||||
|
||||
/// 约束:
|
||||
/// - 必须复用同一个 chapterLoadQueue,保持 WXRead 的单章串行语义
|
||||
/// - 不允许恢复整书 RDEPUBTextBook
|
||||
/// - 只允许从主线程或明确的非 chapterLoadQueue 上下文调用
|
||||
/// - 调用前必须执行 store.assertNotOnChapterLoadQueue()
|
||||
/// - 只在首次迁移且目标章未命中缓存时使用
|
||||
func loadChapterSynchronouslyForMigration(
|
||||
spineIndex: Int,
|
||||
store: RDEPUBChapterRuntimeStore?
|
||||
) throws -> RDEPUBRuntimeChapter {
|
||||
if let cached = store?.chapterData(for: spineIndex) {
|
||||
return cached
|
||||
}
|
||||
|
||||
guard let store else {
|
||||
throw RDEPUBChapterLoadError.missingParser
|
||||
}
|
||||
|
||||
store.assertNotOnChapterLoadQueue()
|
||||
|
||||
var result: Result<RDEPUBRuntimeChapter, Error>?
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
RDEPUBBackgroundTrace.log("ChapterLoader", "sync request spine=\(spineIndex)")
|
||||
store.chapterLoadQueue.async {
|
||||
do {
|
||||
result = try RDEPUBBackgroundTrace.measure(
|
||||
"ChapterLoader",
|
||||
"sync buildChapter spine=\(spineIndex)"
|
||||
) {
|
||||
try autoreleasepool { () -> Result<RDEPUBRuntimeChapter, Error> in
|
||||
let cacheKey = self.makeCacheKey(spineIndex: spineIndex)
|
||||
let precomputedPageRanges = store.pageCount(for: cacheKey)?.pageRanges
|
||||
let diskSummary: RDEPUBChapterSummary?
|
||||
if precomputedPageRanges == nil {
|
||||
diskSummary = self.summaryDiskCache?.read(for: cacheKey)
|
||||
} else {
|
||||
diskSummary = nil
|
||||
}
|
||||
let chapter = try self.buildChapter(
|
||||
spineIndex: spineIndex,
|
||||
availablePageRanges: precomputedPageRanges ?? diskSummary?.pageRanges.map(\.nsRange),
|
||||
diskSummary: diskSummary
|
||||
)
|
||||
store.insertChapter(chapter)
|
||||
let pageCount = RDEPUBRuntimePageCount(
|
||||
cacheKey: cacheKey,
|
||||
spineIndex: spineIndex,
|
||||
pageRanges: chapter.pageRanges,
|
||||
pageCount: chapter.pages.count,
|
||||
renderSignature: cacheKey.renderSignature
|
||||
)
|
||||
store.insertPageCount(pageCount, for: cacheKey)
|
||||
return .success(chapter)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
result = .failure(error)
|
||||
}
|
||||
semaphore.signal()
|
||||
}
|
||||
semaphore.wait()
|
||||
return try result!.get()
|
||||
}
|
||||
|
||||
// MARK: - 单章构建(支持轻量缓存命中后跳过分页)
|
||||
|
||||
private func buildChapter(
|
||||
spineIndex: Int,
|
||||
availablePageRanges: [NSRange]?,
|
||||
diskSummary: RDEPUBChapterSummary? = nil
|
||||
) throws -> RDEPUBRuntimeChapter {
|
||||
guard let parser = context.parser,
|
||||
let publication = context.publication else {
|
||||
throw RDEPUBChapterLoadError.missingParser
|
||||
}
|
||||
|
||||
let pageSize = context.currentTextPageSize()
|
||||
let style = context.currentTextRenderStyle()
|
||||
let layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
|
||||
|
||||
if let pageRanges = availablePageRanges {
|
||||
// ---- 轻量路径:pageCountCache 或 chapterSummaryDiskCache 命中 ----
|
||||
RDEPUBBackgroundTrace.log("ChapterLoader", "轻量路径 spine=\(spineIndex) 缓存页数=\(pageRanges.count)")
|
||||
return try buildChapterFromCachedPageRanges(
|
||||
spineIndex: spineIndex,
|
||||
pageRanges: pageRanges,
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
pageSize: pageSize,
|
||||
style: style,
|
||||
layoutConfig: layoutConfig,
|
||||
diskSummary: diskSummary
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 完整路径:无缓存,走全量渲染 + 分页 ----
|
||||
RDEPUBBackgroundTrace.log("ChapterLoader", "完整路径 spine=\(spineIndex)")
|
||||
let builder = context.makeTextBookBuilder(layoutConfig: layoutConfig)
|
||||
guard let result = try builder.buildChapter(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
spineIndex: spineIndex,
|
||||
pageSize: pageSize,
|
||||
style: style
|
||||
) else {
|
||||
throw RDEPUBChapterLoadError.emptyChapter(spineIndex: spineIndex)
|
||||
}
|
||||
|
||||
return try assembleRuntimeChapter(
|
||||
from: result.chapter,
|
||||
spineIndex: spineIndex,
|
||||
pageSize: pageSize,
|
||||
layoutConfig: layoutConfig
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 轻量路径:复用已有 pageRanges,跳过完整分页
|
||||
|
||||
private func buildChapterFromCachedPageRanges(
|
||||
spineIndex: Int,
|
||||
pageRanges: [NSRange],
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
pageSize: CGSize,
|
||||
style: RDEPUBTextRenderStyle,
|
||||
layoutConfig: RDEPUBTextLayoutConfig,
|
||||
diskSummary: RDEPUBChapterSummary? = nil
|
||||
) throws -> RDEPUBRuntimeChapter {
|
||||
let spineItem = publication.spine[spineIndex]
|
||||
let href = spineItem.href
|
||||
let title = spineItem.title ?? ""
|
||||
let baseURL = parser.fileURL(forRelativePath: href)?.deletingLastPathComponent()
|
||||
|
||||
// 1. 只做 HTML → NSAttributedString 渲染,不做分页
|
||||
let request = RDEPUBTextRendererSupport.makeChapterRenderRequest(
|
||||
href: href,
|
||||
title: title,
|
||||
rawHTML: try requireHTMLString(parser, href: href),
|
||||
baseURL: baseURL,
|
||||
style: style,
|
||||
resourceResolver: publication.resourceResolver,
|
||||
pageSize: pageSize,
|
||||
layoutConfig: layoutConfig
|
||||
)
|
||||
let renderer = context.resolvedTextRenderer()
|
||||
let rendered = try renderer.renderChapter(request: request)
|
||||
|
||||
let typesetString = NSMutableAttributedString(attributedString: rendered.attributedString)
|
||||
RDEPUBTextRendererSupport.normalizeReadingAttributes(
|
||||
in: typesetString, style: style, layoutConfig: layoutConfig
|
||||
)
|
||||
|
||||
// 2. metadata 来源策略:
|
||||
// - diskSummary 非空(磁盘路径命中):从摘要恢复完整 metadata
|
||||
// - diskSummary 为空(pageCountCache 命中但没走磁盘):从 attributedString 属性推断
|
||||
let metadataSource = diskSummary?.pageMetadataList
|
||||
|
||||
// 3. 直接用缓存的 pageRanges 构建 pages(跳过 CoreText 分页)
|
||||
let pages = buildPagesFromRanges(
|
||||
pageRanges: pageRanges,
|
||||
typesetString: typesetString,
|
||||
spineIndex: spineIndex,
|
||||
href: href,
|
||||
title: title,
|
||||
metadataSource: metadataSource
|
||||
)
|
||||
|
||||
// 4. 构建 layouter(用于后续可能的重新分页场景)
|
||||
let layouter = RDEPUBTextLayouter(
|
||||
attributedString: typesetString,
|
||||
pageSize: pageSize,
|
||||
config: layoutConfig
|
||||
)
|
||||
|
||||
// 5. 构建 chapterOffsetMap
|
||||
let offsetMap = RDEPUBChapterOffsetMap(
|
||||
fragmentOffsets: rendered.fragmentOffsets,
|
||||
pageStartOffsets: pages.map { $0.pageStartOffset },
|
||||
pageEndOffsets: pages.map { $0.pageEndOffset }
|
||||
)
|
||||
|
||||
return RDEPUBRuntimeChapter(
|
||||
spineIndex: spineIndex,
|
||||
href: href,
|
||||
title: title,
|
||||
sourceAttributedString: nil, // 轻量路径不保留原始 source,降低内存
|
||||
typesetAttributedString: typesetString,
|
||||
layouter: layouter,
|
||||
pageRanges: pageRanges,
|
||||
pages: pages,
|
||||
chapterOffsetMap: offsetMap
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 从缓存的 pageRanges 直接构建 RDEPUBTextPage 数组
|
||||
|
||||
private func buildPagesFromRanges(
|
||||
pageRanges: [NSRange],
|
||||
typesetString: NSAttributedString,
|
||||
spineIndex: Int,
|
||||
href: String,
|
||||
title: String,
|
||||
metadataSource: [RDEPUBChapterSummary.PageMetadataSummary]? = nil
|
||||
) -> [RDEPUBTextPage] {
|
||||
let totalPageCount = pageRanges.count
|
||||
return pageRanges.enumerated().map { (pageIndex, range) in
|
||||
let pageContent = typesetString.attributedSubstring(from: range)
|
||||
let metadata: RDEPUBTextPageMetadata
|
||||
if let metaList = metadataSource, pageIndex < metaList.count {
|
||||
// 从摘要缓存恢复完整 metadata
|
||||
metadata = metaList[pageIndex].toPageMetadata()
|
||||
} else {
|
||||
// 无缓存 metadata,从 attributedString 属性推断
|
||||
metadata = inferPageMetadata(
|
||||
from: typesetString,
|
||||
range: range,
|
||||
isLastPage: pageIndex == totalPageCount - 1
|
||||
)
|
||||
}
|
||||
return RDEPUBTextPage(
|
||||
absolutePageIndex: -1,
|
||||
chapterIndex: 0,
|
||||
spineIndex: spineIndex,
|
||||
href: href,
|
||||
chapterTitle: title,
|
||||
pageIndexInChapter: pageIndex,
|
||||
totalPagesInChapter: totalPageCount,
|
||||
chapterContent: typesetString,
|
||||
content: pageContent,
|
||||
contentRange: range,
|
||||
pageStartOffset: range.location,
|
||||
pageEndOffset: range.location + range.length - 1,
|
||||
metadata: metadata
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 从 attributedString 的自定义属性推断页 metadata
|
||||
|
||||
private func inferPageMetadata(
|
||||
from string: NSAttributedString,
|
||||
range: NSRange,
|
||||
isLastPage: Bool
|
||||
) -> RDEPUBTextPageMetadata {
|
||||
var attachmentRanges: [NSRange] = []
|
||||
var attachmentKinds: [RDEPUBTextAttachmentKind] = []
|
||||
var blockKinds: [RDEPUBTextBlockKind] = []
|
||||
var semanticHints: [RDEPUBTextSemanticHint] = []
|
||||
var attachmentPlacements: [RDEPUBTextAttachmentPlacement] = []
|
||||
var trailingFragmentID: String? = nil
|
||||
|
||||
string.enumerateAttribute(.rdPageAttachmentKind, in: range, options: []) { value, attrRange, _ in
|
||||
if let rawValue = value as? String,
|
||||
let kind = RDEPUBTextAttachmentKind(rawValue: rawValue) {
|
||||
attachmentRanges.append(attrRange)
|
||||
attachmentKinds.append(kind)
|
||||
}
|
||||
}
|
||||
string.enumerateAttribute(.rdPageBlockKind, in: range, options: []) { value, _, _ in
|
||||
if let rawValue = value as? String,
|
||||
let kind = RDEPUBTextBlockKind(rawValue: rawValue),
|
||||
!blockKinds.contains(kind) {
|
||||
blockKinds.append(kind)
|
||||
}
|
||||
}
|
||||
string.enumerateAttribute(.rdPageSemanticHints, in: range, options: []) { value, _, _ in
|
||||
if let rawValue = value as? String {
|
||||
let hints = rawValue
|
||||
.split(separator: ",")
|
||||
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
|
||||
for hint in hints where !semanticHints.contains(hint) {
|
||||
semanticHints.append(hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
string.enumerateAttribute(.rdPageAttachmentPlacement, in: range, options: []) { value, _, _ in
|
||||
if let rawValue = value as? String,
|
||||
let placement = RDEPUBTextAttachmentPlacement(rawValue: rawValue),
|
||||
!attachmentPlacements.contains(placement) {
|
||||
attachmentPlacements.append(placement)
|
||||
}
|
||||
}
|
||||
string.enumerateAttribute(.rdPageFragmentID, in: range, options: [.reverse]) { value, _, stop in
|
||||
if let fid = value as? String {
|
||||
trailingFragmentID = fid
|
||||
stop.pointee = true
|
||||
}
|
||||
}
|
||||
|
||||
return RDEPUBTextPageMetadata(
|
||||
breakReason: isLastPage ? .chapterEnd : .frameLimit,
|
||||
blockRange: nil,
|
||||
attachmentRanges: attachmentRanges,
|
||||
attachmentKinds: attachmentKinds,
|
||||
blockKinds: blockKinds,
|
||||
semanticHints: semanticHints,
|
||||
attachmentPlacements: attachmentPlacements,
|
||||
trailingFragmentID: trailingFragmentID,
|
||||
diagnostics: []
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 从完整构建结果组装 RDEPUBRuntimeChapter
|
||||
|
||||
private func assembleRuntimeChapter(
|
||||
from chapter: RDEPUBTextChapter,
|
||||
spineIndex: Int,
|
||||
pageSize: CGSize,
|
||||
layoutConfig: RDEPUBTextLayoutConfig
|
||||
) throws -> RDEPUBRuntimeChapter {
|
||||
let layouter = RDEPUBTextLayouter(
|
||||
attributedString: chapter.attributedContent,
|
||||
pageSize: pageSize,
|
||||
config: layoutConfig
|
||||
)
|
||||
|
||||
let offsetMap = RDEPUBChapterOffsetMap(
|
||||
fragmentOffsets: chapter.fragmentOffsets,
|
||||
pageStartOffsets: chapter.pages.map { $0.pageStartOffset },
|
||||
pageEndOffsets: chapter.pages.map { $0.pageEndOffset }
|
||||
)
|
||||
|
||||
let pageRanges = chapter.pages.map { $0.contentRange }
|
||||
|
||||
// 回填磁盘摘要(P2 阶段生效)
|
||||
let cacheKey = makeCacheKey(spineIndex: spineIndex)
|
||||
let summary = RDEPUBChapterSummary(
|
||||
pageRanges: pageRanges.map { .init(location: $0.location, length: $0.length) },
|
||||
pageCount: chapter.pages.count,
|
||||
fragmentOffsets: chapter.fragmentOffsets,
|
||||
renderSignature: cacheKey.renderSignature,
|
||||
schemaVersion: RDEPUBChapterSummary.currentSchemaVersion,
|
||||
chapterContentHash: cacheKey.chapterContentHash,
|
||||
pageMetadataList: chapter.pages.map { .from($0.metadata) }
|
||||
)
|
||||
summaryDiskCache?.write(summary: summary, for: cacheKey)
|
||||
|
||||
return RDEPUBRuntimeChapter(
|
||||
spineIndex: spineIndex,
|
||||
href: chapter.href,
|
||||
title: chapter.title,
|
||||
sourceAttributedString: nil,
|
||||
typesetAttributedString: chapter.attributedContent,
|
||||
layouter: layouter,
|
||||
pageRanges: pageRanges,
|
||||
pages: chapter.pages,
|
||||
chapterOffsetMap: offsetMap
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 缓存键
|
||||
|
||||
private func makeCacheKey(spineIndex: Int) -> RDEPUBChapterCacheKey {
|
||||
let style = context.currentTextRenderStyle()
|
||||
let layoutConfig = context.currentTextLayoutConfig(pageSize: context.currentTextPageSize())
|
||||
|
||||
// renderSignature 必须覆盖 §8.2 定义的全部参数
|
||||
let lineHeightMultiple = context.configuration.lineHeightMultiple
|
||||
|
||||
let renderSignature = [
|
||||
style.font.fontName,
|
||||
"\(style.font.pointSize)",
|
||||
"\(lineHeightMultiple)",
|
||||
"\(style.lineSpacing)",
|
||||
layoutConfig.cacheSignature,
|
||||
"\(RDEPUBChapterSummary.currentSchemaVersion)"
|
||||
].joined(separator: "|")
|
||||
|
||||
let contentHash = contentHashForSpineIndex(spineIndex)
|
||||
|
||||
return RDEPUBChapterCacheKey(
|
||||
bookID: context.currentBookIdentifier ?? "",
|
||||
spineIndex: spineIndex,
|
||||
renderSignature: renderSignature,
|
||||
chapterContentHash: contentHash
|
||||
)
|
||||
}
|
||||
|
||||
private func contentHashForSpineIndex(_ spineIndex: Int) -> String {
|
||||
guard let parser = context.parser,
|
||||
let publication = context.publication else { return "" }
|
||||
let href = publication.spine[spineIndex].href
|
||||
guard let html = parser.htmlString(forRelativePath: href) else { return "" }
|
||||
return html.sha256Hex
|
||||
}
|
||||
|
||||
private func requireHTMLString(_ parser: RDEPUBParser, href: String) throws -> String {
|
||||
guard let html = parser.htmlString(forRelativePath: href) else {
|
||||
throw RDEPUBChapterLoadError.emptyChapterHref(href)
|
||||
}
|
||||
return html
|
||||
}
|
||||
}
|
||||
|
||||
enum RDEPUBChapterLoadError: LocalizedError {
|
||||
case missingParser
|
||||
case emptyChapter(spineIndex: Int)
|
||||
case emptyChapterHref(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .missingParser:
|
||||
return "章节加载失败:缺少解析上下文。"
|
||||
case .emptyChapter(let spineIndex):
|
||||
return "章节加载失败:第 \(spineIndex) 章无法生成分页内容。"
|
||||
case .emptyChapterHref(let href):
|
||||
return "章节加载失败:未找到章节资源 \(href)。"
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import Foundation
|
||||
|
||||
/// 章节级位置模型——单章内的精确定位
|
||||
/// 取代旧版 RDEPUBLocation 的全局 progression 方式
|
||||
public struct RDEPUBChapterLocation: Codable, Equatable {
|
||||
/// 章节在 spine 中的索引
|
||||
public var spineIndex: Int
|
||||
/// 章内字符偏移(从章首算起,0-based)
|
||||
public var chapterOffset: Int
|
||||
/// HTML fragment ID(如章节内锚点 #section1)
|
||||
public var fragmentID: String?
|
||||
/// 章内 progression(可选,fragmentID 优先时为 nil)
|
||||
public var progressionInChapter: Double?
|
||||
/// schema 版本:1=粗估降级, 2=精确值
|
||||
public var schemaVersion: Int
|
||||
|
||||
public init(
|
||||
spineIndex: Int,
|
||||
chapterOffset: Int,
|
||||
fragmentID: String? = nil,
|
||||
progressionInChapter: Double? = nil,
|
||||
schemaVersion: Int = 2
|
||||
) {
|
||||
self.spineIndex = spineIndex
|
||||
self.chapterOffset = chapterOffset
|
||||
self.fragmentID = fragmentID.flatMap { $0.isEmpty ? nil : $0 }
|
||||
self.progressionInChapter = progressionInChapter
|
||||
self.schemaVersion = schemaVersion
|
||||
}
|
||||
|
||||
/// 粗估结果标记:schemaVersion == 1 表示 chapterOffset 由 progression 粗估得来
|
||||
var isFallbackEstimate: Bool { schemaVersion == 1 }
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import Foundation
|
||||
|
||||
struct RDEPUBChapterOffsetMap {
|
||||
let fragmentOffsets: [String: Int]
|
||||
let pageStartOffsets: [Int]
|
||||
let pageEndOffsets: [Int]
|
||||
|
||||
/// fragmentID -> 章内字符偏移
|
||||
func chapterOffset(forFragmentID fragmentID: String) -> Int? {
|
||||
return fragmentOffsets[fragmentID]
|
||||
}
|
||||
|
||||
/// 章内字符偏移 -> 章内页码(从 0 开始)
|
||||
func pageIndex(forChapterOffset offset: Int) -> Int? {
|
||||
for i in 0..<pageStartOffsets.count {
|
||||
if offset >= pageStartOffsets[i] && offset <= pageEndOffsets[i] {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
import UIKit
|
||||
|
||||
final class RDEPUBChapterRuntimeStore {
|
||||
|
||||
// MARK: - 子缓存
|
||||
|
||||
/// 章节运行时主缓存(等价 WXRead chapterDataCache)
|
||||
private let chapterDataCache = RDEPUBChapterDataCache()
|
||||
|
||||
/// 轻量分页结构缓存(等价 WXRead pageCountCache)
|
||||
private let pageCountCache = RDEPUBPageCountCache()
|
||||
|
||||
/// 图片缓存(独立 NSCache,等价 WXRead imageCache)
|
||||
let imageCache = NSCache<NSString, UIImage>()
|
||||
|
||||
/// 串行加载队列(等价 WXRead com.weread.chapterload)
|
||||
/// QoS .userInitiated:用户主动跳章/开书属于前台交互,需尽快完成
|
||||
let chapterLoadQueue = DispatchQueue(label: "com.rdreader.chapterload", qos: .userInitiated)
|
||||
private let chapterLoadQueueKey = DispatchSpecificKey<Void>()
|
||||
|
||||
// MARK: - 窗口状态
|
||||
|
||||
/// 当前章 spineIndex
|
||||
private(set) var currentSpineIndex: Int?
|
||||
|
||||
/// 当前窗口内的 spineIndex 集合(以当前章为中心,按配置半径展开)
|
||||
private(set) var windowSpineIndices: [Int] = []
|
||||
|
||||
// MARK: - 请求通道(前台导航 vs 后台预取,语义独立,互不抢占)
|
||||
|
||||
/// 前台导航目标(用户主动跳章:目录/书签/搜索/翻章)
|
||||
/// 仅保留最后一次目标,旧的排队请求可被取消
|
||||
private var pendingNavigationTarget: Int?
|
||||
private let navigationLock = NSLock()
|
||||
|
||||
/// 后台预取目标集合(±1 相邻章预取)
|
||||
/// 预取不抢占前台导航通道,预取完成后仅刷新快照,不触发跳章
|
||||
private var pendingPrefetchTargets: Set<Int> = []
|
||||
private let prefetchLock = NSLock()
|
||||
|
||||
/// 是否有章节正在构建中
|
||||
private(set) var isBuilding: Bool = false
|
||||
private let buildingLock = NSLock()
|
||||
|
||||
// MARK: - 初始化
|
||||
|
||||
init() {
|
||||
imageCache.countLimit = 50
|
||||
chapterLoadQueue.setSpecific(key: chapterLoadQueueKey, value: ())
|
||||
}
|
||||
|
||||
func assertNotOnChapterLoadQueue() {
|
||||
dispatchPrecondition(condition: .notOnQueue(chapterLoadQueue))
|
||||
}
|
||||
|
||||
// MARK: - 缓存查询(线程安全,通过 cache wrapper 的 lock 保护)
|
||||
|
||||
func chapterData(for spineIndex: Int) -> RDEPUBRuntimeChapter? {
|
||||
return chapterDataCache[spineIndex]
|
||||
}
|
||||
|
||||
func pageCount(for key: RDEPUBChapterCacheKey) -> RDEPUBRuntimePageCount? {
|
||||
return pageCountCache[key]
|
||||
}
|
||||
|
||||
// MARK: - 缓存插入
|
||||
|
||||
func insertChapter(_ chapter: RDEPUBRuntimeChapter) {
|
||||
chapterDataCache[chapter.spineIndex] = chapter
|
||||
}
|
||||
|
||||
func insertPageCount(_ pc: RDEPUBRuntimePageCount, for key: RDEPUBChapterCacheKey) {
|
||||
pageCountCache[key] = pc
|
||||
}
|
||||
|
||||
// MARK: - 窗口管理
|
||||
|
||||
/// 设定当前章,自动计算按半径展开的窗口
|
||||
func setCurrentChapter(spineIndex: Int, totalSpineCount: Int, windowRadius: Int = 1) {
|
||||
currentSpineIndex = spineIndex
|
||||
let radius = max(0, windowRadius)
|
||||
let lowerBound = max(0, spineIndex - radius)
|
||||
let upperBound = min(totalSpineCount - 1, spineIndex + radius)
|
||||
guard lowerBound <= upperBound else {
|
||||
windowSpineIndices = [spineIndex]
|
||||
return
|
||||
}
|
||||
windowSpineIndices = Array(lowerBound...upperBound)
|
||||
}
|
||||
|
||||
/// 返回窗口外、应该淘汰的 spineIndex
|
||||
func evictableSpineIndices() -> [Int] {
|
||||
let windowSet = Set(windowSpineIndices)
|
||||
return chapterDataCache.storedSpineIndices.filter { !windowSet.contains($0) }
|
||||
}
|
||||
|
||||
// MARK: - 淘汰
|
||||
|
||||
func evict(spineIndex: Int) {
|
||||
chapterDataCache.remove(spineIndex: spineIndex)
|
||||
pageCountCache.remove(forSpineIndex: spineIndex)
|
||||
}
|
||||
|
||||
func evictAllExceptCurrent() {
|
||||
guard let current = currentSpineIndex else {
|
||||
chapterDataCache.removeAll()
|
||||
pageCountCache.removeAll()
|
||||
return
|
||||
}
|
||||
let currentChapter = chapterDataCache[current]
|
||||
chapterDataCache.removeAll()
|
||||
if let ch = currentChapter {
|
||||
chapterDataCache[current] = ch
|
||||
}
|
||||
// WXRead 语义:内存警告时 pageCountCache 全量清空
|
||||
pageCountCache.removeAll()
|
||||
}
|
||||
|
||||
// MARK: - 内存警告
|
||||
|
||||
func handleMemoryWarning() {
|
||||
evictAllExceptCurrent()
|
||||
imageCache.removeAllObjects()
|
||||
}
|
||||
|
||||
// MARK: - 前台导航请求管理(§14.4 取消语义)
|
||||
|
||||
/// 注册前台导航目标(用户主动跳章时调用)
|
||||
/// 仅保留最后一次目标,旧的排队请求可被取消
|
||||
func setNavigationTarget(spineIndex: Int) {
|
||||
navigationLock.lock()
|
||||
pendingNavigationTarget = spineIndex
|
||||
navigationLock.unlock()
|
||||
}
|
||||
|
||||
/// 消费前台导航目标(章节构建完成后调用,检查是否有更新的目标)
|
||||
func consumeNavigationTarget() -> Int? {
|
||||
navigationLock.lock()
|
||||
let target = pendingNavigationTarget
|
||||
pendingNavigationTarget = nil
|
||||
navigationLock.unlock()
|
||||
return target
|
||||
}
|
||||
|
||||
// MARK: - 后台预取请求管理
|
||||
|
||||
/// 注册后台预取目标(±1 相邻章预取时调用)
|
||||
/// 预取不抢占前台导航通道
|
||||
func addPrefetchTarget(_ spineIndex: Int) {
|
||||
prefetchLock.lock()
|
||||
pendingPrefetchTargets.insert(spineIndex)
|
||||
prefetchLock.unlock()
|
||||
}
|
||||
|
||||
/// 标记预取目标已完成
|
||||
func removePrefetchTarget(_ spineIndex: Int) {
|
||||
prefetchLock.lock()
|
||||
pendingPrefetchTargets.remove(spineIndex)
|
||||
prefetchLock.unlock()
|
||||
}
|
||||
|
||||
/// 清空所有预取目标(切章时调用,旧预取结果不再需要)
|
||||
func clearPrefetchTargets() {
|
||||
prefetchLock.lock()
|
||||
pendingPrefetchTargets.removeAll()
|
||||
prefetchLock.unlock()
|
||||
}
|
||||
|
||||
/// 检查是否有待处理的预取目标
|
||||
func hasPrefetchTarget(_ spineIndex: Int) -> Bool {
|
||||
prefetchLock.lock()
|
||||
let has = pendingPrefetchTargets.contains(spineIndex)
|
||||
prefetchLock.unlock()
|
||||
return has
|
||||
}
|
||||
|
||||
func markBuilding(_ building: Bool) {
|
||||
buildingLock.lock()
|
||||
isBuilding = building
|
||||
buildingLock.unlock()
|
||||
}
|
||||
|
||||
// MARK: - P1: 排版参数变化后整体失效(§8.5)
|
||||
|
||||
func invalidateAllForSettingsChange() {
|
||||
chapterDataCache.removeAll()
|
||||
pageCountCache.removeAll()
|
||||
imageCache.removeAllObjects()
|
||||
}
|
||||
}
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBChapterSummaryDiskCache {
|
||||
private let cacheDirectory: URL
|
||||
private let fileManager = FileManager.default
|
||||
private let queue = DispatchQueue(label: "com.rdreader.summarydiskcache", qos: .utility)
|
||||
|
||||
init(cacheDirectory: URL) {
|
||||
self.cacheDirectory = cacheDirectory
|
||||
try? fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
// MARK: - 写入(异步)
|
||||
|
||||
func write(summary: RDEPUBChapterSummary, for key: RDEPUBChapterCacheKey) {
|
||||
queue.async {
|
||||
self.writeImmediately(summary: summary, for: key)
|
||||
}
|
||||
}
|
||||
|
||||
/// 同步写入:用于后台整书元数据构建完成前,确保摘要文件已经真实落盘。
|
||||
func writeSynchronously(summary: RDEPUBChapterSummary, for key: RDEPUBChapterCacheKey) {
|
||||
queue.sync {
|
||||
self.writeImmediately(summary: summary, for: key)
|
||||
}
|
||||
}
|
||||
|
||||
/// 等待此前已排队的异步写入全部落盘。
|
||||
func flushPendingWrites() {
|
||||
queue.sync { }
|
||||
}
|
||||
|
||||
// MARK: - 读取(同步,因为 loadChapter 已在串行队列上)
|
||||
|
||||
func read(for key: RDEPUBChapterCacheKey) -> RDEPUBChapterSummary? {
|
||||
let fileURL = self.fileURL(for: key)
|
||||
guard let data = try? Data(contentsOf: fileURL) else { return nil }
|
||||
return try? JSONDecoder().decode(RDEPUBChapterSummary.self, from: data)
|
||||
}
|
||||
|
||||
// MARK: - 批量读取:二次打开时直接从磁盘构建 BookPageMap
|
||||
|
||||
/// 批量读取指定缓存键列表的摘要,返回 spineIndex → summary 映射。
|
||||
/// 同步方法,应在后台线程调用。
|
||||
/// 由调用方负责构建完整的缓存键列表(含正确的 contentHash)。
|
||||
func readAll(keys: [(key: RDEPUBChapterCacheKey, spineIndex: Int, href: String, title: String)]) -> (
|
||||
summaries: [Int: RDEPUBChapterSummary],
|
||||
mapBuilder: RDEPUBBookPageMap.Builder
|
||||
) {
|
||||
var summaries: [Int: RDEPUBChapterSummary] = [:]
|
||||
var mapBuilder = RDEPUBBookPageMap.Builder()
|
||||
|
||||
for item in keys {
|
||||
if let summary = read(for: item.key) {
|
||||
summaries[item.spineIndex] = summary
|
||||
mapBuilder.add(
|
||||
spineIndex: item.spineIndex,
|
||||
href: item.href,
|
||||
title: item.title,
|
||||
pageCount: summary.pageCount,
|
||||
fragmentOffsets: summary.fragmentOffsets
|
||||
)
|
||||
}
|
||||
}
|
||||
return (summaries, mapBuilder)
|
||||
}
|
||||
|
||||
/// 检查指定缓存键列表是否全部有对应的磁盘摘要。
|
||||
func isCacheComplete(keys: [RDEPUBChapterCacheKey]) -> Bool {
|
||||
for key in keys {
|
||||
if read(for: key) == nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// 语义化别名:用于判断当前 renderSignature 下是否具备完整章节摘要集合。
|
||||
func containsCompleteSet(keys: [RDEPUBChapterCacheKey]) -> Bool {
|
||||
isCacheComplete(keys: keys)
|
||||
}
|
||||
|
||||
/// 清空所有缓存文件
|
||||
func removeAll() {
|
||||
guard let files = try? fileManager.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: nil) else { return }
|
||||
for fileURL in files where fileURL.pathExtension == "json" {
|
||||
try? fileManager.removeItem(at: fileURL)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - key -> 文件路径
|
||||
|
||||
/// 使用确定性字符串拼接生成文件名,不依赖 Hashable.hashValue
|
||||
private func fileURL(for key: RDEPUBChapterCacheKey) -> URL {
|
||||
let rawKey = "\(key.bookID)_\(key.spineIndex)_\(key.renderSignature)_\(key.chapterContentHash)"
|
||||
let digest = rawKey.sha256Hex
|
||||
return cacheDirectory.appendingPathComponent("\(digest).json")
|
||||
}
|
||||
|
||||
private func writeImmediately(summary: RDEPUBChapterSummary, for key: RDEPUBChapterCacheKey) {
|
||||
let fileURL = self.fileURL(for: key)
|
||||
let data = try? JSONEncoder().encode(summary)
|
||||
try? data?.write(to: fileURL)
|
||||
}
|
||||
}
|
||||
|
||||
struct RDEPUBChapterSummary: Codable {
|
||||
let pageRanges: [RangeData]
|
||||
let pageCount: Int
|
||||
let fragmentOffsets: [String: Int]
|
||||
let renderSignature: String
|
||||
let schemaVersion: Int
|
||||
let chapterContentHash: String
|
||||
let pageMetadataList: [PageMetadataSummary]
|
||||
|
||||
static let currentSchemaVersion = 6
|
||||
|
||||
struct RangeData: Codable {
|
||||
let location: Int
|
||||
let length: Int
|
||||
var nsRange: NSRange { NSRange(location: location, length: length) }
|
||||
}
|
||||
|
||||
struct PageMetadataSummary: Codable {
|
||||
let breakReason: String
|
||||
let attachmentRanges: [RangeData]
|
||||
let attachmentKinds: [String]
|
||||
let blockKinds: [String]
|
||||
let semanticHints: [String]
|
||||
let attachmentPlacements: [String]
|
||||
let trailingFragmentID: String?
|
||||
|
||||
func toPageMetadata() -> RDEPUBTextPageMetadata {
|
||||
RDEPUBTextPageMetadata(
|
||||
breakReason: RDEPUBTextPageBreakReason(rawValue: breakReason) ?? .frameLimit,
|
||||
blockRange: nil,
|
||||
attachmentRanges: attachmentRanges.map { $0.nsRange },
|
||||
attachmentKinds: attachmentKinds.compactMap { RDEPUBTextAttachmentKind(rawValue: $0) },
|
||||
blockKinds: blockKinds.compactMap { RDEPUBTextBlockKind(rawValue: $0) },
|
||||
semanticHints: semanticHints.compactMap { RDEPUBTextSemanticHint(rawValue: $0) },
|
||||
attachmentPlacements: attachmentPlacements.compactMap { RDEPUBTextAttachmentPlacement(rawValue: $0) },
|
||||
trailingFragmentID: trailingFragmentID,
|
||||
diagnostics: []
|
||||
)
|
||||
}
|
||||
|
||||
static func from(_ metadata: RDEPUBTextPageMetadata) -> PageMetadataSummary {
|
||||
PageMetadataSummary(
|
||||
breakReason: metadata.breakReason.rawValue,
|
||||
attachmentRanges: metadata.attachmentRanges.map { .init(location: $0.location, length: $0.length) },
|
||||
attachmentKinds: metadata.attachmentKinds.map { $0.rawValue },
|
||||
blockKinds: metadata.blockKinds.map { $0.rawValue },
|
||||
semanticHints: metadata.semanticHints.map { $0.rawValue },
|
||||
attachmentPlacements: metadata.attachmentPlacements.map { $0.rawValue },
|
||||
trailingFragmentID: metadata.trailingFragmentID
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+290
@@ -0,0 +1,290 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBChapterWindowCoordinator {
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
private let store: RDEPUBChapterRuntimeStore
|
||||
private let loader: RDEPUBChapterLoader
|
||||
|
||||
/// 当前窗口快照
|
||||
private(set) var currentSnapshot: RDEPUBChapterWindowSnapshot?
|
||||
|
||||
/// 窗口切换回调
|
||||
var onSnapshotChanged: ((RDEPUBChapterWindowSnapshot) -> Void)?
|
||||
|
||||
init(context: RDEPUBReaderContext, store: RDEPUBChapterRuntimeStore, loader: RDEPUBChapterLoader) {
|
||||
self.context = context
|
||||
self.store = store
|
||||
self.loader = loader
|
||||
}
|
||||
|
||||
/// 章节加载完成后恢复位置用
|
||||
private var restoreChapterOffset: Int?
|
||||
|
||||
// MARK: - 打开书籍
|
||||
|
||||
func openBook(at targetSpineIndex: Int, restoreChapterOffset: Int? = nil) {
|
||||
let totalSpineCount = context.publication?.spine.count ?? 0
|
||||
store.setCurrentChapter(
|
||||
spineIndex: targetSpineIndex,
|
||||
totalSpineCount: totalSpineCount,
|
||||
windowRadius: context.configuration.chapterWindowRadius
|
||||
)
|
||||
self.restoreChapterOffset = restoreChapterOffset
|
||||
|
||||
// 标记切章进行中
|
||||
isSwitchingChapter = true
|
||||
|
||||
// 注册前台导航目标
|
||||
store.setNavigationTarget(spineIndex: targetSpineIndex)
|
||||
// 清空旧预取目标
|
||||
store.clearPrefetchTargets()
|
||||
|
||||
// 加载目标章(前台导航优先级)
|
||||
loadChapterWithFallback(initialSpineIndex: targetSpineIndex, totalSpineCount: totalSpineCount)
|
||||
}
|
||||
|
||||
/// 加载章节,如果当前章节失败则自动尝试下一个可渲染的章节
|
||||
private func loadChapterWithFallback(initialSpineIndex: Int, totalSpineCount: Int) {
|
||||
loader.loadChapter(spineIndex: initialSpineIndex, store: store, priority: .navigation) { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
switch result {
|
||||
case .success(let chapter):
|
||||
self.isSwitchingChapter = false
|
||||
self.buildSnapshotAroundCurrent(chapter: chapter)
|
||||
case .failure(let error):
|
||||
print("[EPUB][WindowCoord] loadChapter failed at spine=\(initialSpineIndex): \(error), trying next")
|
||||
// 自动跳过不可渲染的章节(封面/版权页等 linear=false 的 spine 项)
|
||||
let nextIndex = initialSpineIndex + 1
|
||||
if nextIndex < totalSpineCount {
|
||||
self.store.setCurrentChapter(
|
||||
spineIndex: nextIndex,
|
||||
totalSpineCount: totalSpineCount,
|
||||
windowRadius: self.context.configuration.chapterWindowRadius
|
||||
)
|
||||
self.store.setNavigationTarget(spineIndex: nextIndex)
|
||||
self.loadChapterWithFallback(initialSpineIndex: nextIndex, totalSpineCount: totalSpineCount)
|
||||
} else {
|
||||
// 所有章节都不可渲染
|
||||
self.isSwitchingChapter = false
|
||||
self.handle(error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 构建窗口快照
|
||||
|
||||
private func buildSnapshotAroundCurrent(chapter: RDEPUBRuntimeChapter) {
|
||||
guard let current = store.currentSpineIndex else {
|
||||
print("[EPUB][WindowCoord] buildSnapshot: currentSpineIndex is nil, ABORT")
|
||||
return
|
||||
}
|
||||
let chapters = store.windowSpineIndices.compactMap { spineIndex -> RDEPUBRuntimeChapter? in
|
||||
if spineIndex == chapter.spineIndex {
|
||||
return chapter
|
||||
}
|
||||
return store.chapterData(for: spineIndex)
|
||||
}
|
||||
let snapshot = RDEPUBChapterWindowSnapshot.from(chapters: chapters, anchorSpineIndex: current)
|
||||
print("[EPUB][WindowCoord] snapshot: chapters=\(snapshot.chapters.count) pages=\(snapshot.pageCount) anchorPage=\(snapshot.anchorPageOffset)")
|
||||
currentSnapshot = snapshot
|
||||
isApplyingSnapshot = true
|
||||
onSnapshotChanged?(snapshot)
|
||||
isApplyingSnapshot = false
|
||||
|
||||
// 首次打开时恢复到指定 chapterOffset
|
||||
if let offset = restoreChapterOffset,
|
||||
let chapter = snapshot.chapterForPage(flattenedPageIndex: snapshot.anchorPageOffset),
|
||||
let pageIndex = chapter.chapterOffsetMap.pageIndex(forChapterOffset: offset) {
|
||||
let targetPage = snapshot.anchorPageOffset + pageIndex
|
||||
context.readerView?.transitionToPage(pageNum: targetPage, animated: false)
|
||||
} else if snapshot.pageCount > 0 {
|
||||
// 首次打开且没有恢复位置时,必须显式落到当前章首屏。
|
||||
// reloadData() 内部 switchReaderDisplayType 已将 currentPage 从 -1 置为 0,
|
||||
// 但 0 不一定是目标章的起始页(anchorPageOffset),仍需显式 transition。
|
||||
context.readerView?.transitionToPage(pageNum: snapshot.anchorPageOffset, animated: false)
|
||||
}
|
||||
restoreChapterOffset = nil
|
||||
|
||||
// 预取窗口内尚未加载的章节
|
||||
prefetchAdjacent(current: current)
|
||||
}
|
||||
|
||||
// MARK: - 预取(后台优先级,不抢占前台导航通道)
|
||||
|
||||
private func prefetchAdjacent(current: Int) {
|
||||
for spineIndex in store.windowSpineIndices where spineIndex != current {
|
||||
guard store.chapterData(for: spineIndex) == nil else { continue }
|
||||
store.addPrefetchTarget(spineIndex)
|
||||
loader.loadChapter(spineIndex: spineIndex, store: store, priority: .prefetch) { [weak self] result in
|
||||
guard let self = self, case .success = result else { return }
|
||||
self.refreshSnapshot()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 翻章
|
||||
|
||||
/// 到达章末,翻到下一章
|
||||
func flipToNextChapter(completion: @escaping (Result<RDEPUBChapterWindowSnapshot, Error>) -> Void) {
|
||||
guard let current = store.currentSpineIndex else { return }
|
||||
let next = current + 1
|
||||
let totalSpineCount = context.publication?.spine.count ?? 0
|
||||
guard next < totalSpineCount else { return }
|
||||
|
||||
flipToChapter(spineIndex: next, completion: completion)
|
||||
}
|
||||
|
||||
/// 到达章首,翻到上一章
|
||||
func flipToPreviousChapter(completion: @escaping (Result<RDEPUBChapterWindowSnapshot, Error>) -> Void) {
|
||||
guard let current = store.currentSpineIndex, current > 0 else { return }
|
||||
flipToChapter(spineIndex: current - 1, completion: completion)
|
||||
}
|
||||
|
||||
/// 跳转到指定章节(目录/书签/搜索)
|
||||
func flipToChapter(
|
||||
spineIndex: Int,
|
||||
completion: @escaping (Result<RDEPUBChapterWindowSnapshot, Error>) -> Void
|
||||
) {
|
||||
let totalSpineCount = context.publication?.spine.count ?? 0
|
||||
|
||||
// 注册前台导航目标
|
||||
store.setNavigationTarget(spineIndex: spineIndex)
|
||||
// 清空后台预取目标
|
||||
store.clearPrefetchTargets()
|
||||
// 标记切章进行中
|
||||
isSwitchingChapter = true
|
||||
|
||||
// 先淘汰旧窗口外章节
|
||||
store.setCurrentChapter(
|
||||
spineIndex: spineIndex,
|
||||
totalSpineCount: totalSpineCount,
|
||||
windowRadius: context.configuration.chapterWindowRadius
|
||||
)
|
||||
let evictable = store.evictableSpineIndices()
|
||||
for idx in evictable {
|
||||
store.evict(spineIndex: idx)
|
||||
}
|
||||
|
||||
// 如果目标章已在缓存中,直接构建快照
|
||||
if let cached = store.chapterData(for: spineIndex) {
|
||||
buildSnapshotAroundCurrent(chapter: cached)
|
||||
isSwitchingChapter = false
|
||||
if let snap = currentSnapshot {
|
||||
completion(.success(snap))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 未命中缓存,走加载链路(前台导航优先级)
|
||||
loader.loadChapter(spineIndex: spineIndex, store: store, priority: .navigation) { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
self.isSwitchingChapter = false
|
||||
switch result {
|
||||
case .success(let chapter):
|
||||
self.buildSnapshotAroundCurrent(chapter: chapter)
|
||||
if let snap = self.currentSnapshot {
|
||||
completion(.success(snap))
|
||||
}
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 刷新快照(预取完成后调用)
|
||||
|
||||
func refreshSnapshot() {
|
||||
guard let current = store.currentSpineIndex,
|
||||
let currentChapter = store.chapterData(for: current) else { return }
|
||||
|
||||
// 空闲门槛检查
|
||||
guard isReaderIdle() else {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
|
||||
self?.refreshSnapshot()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let chapters = store.windowSpineIndices.compactMap { store.chapterData(for: $0) }
|
||||
let newSnapshot = RDEPUBChapterWindowSnapshot.from(chapters: chapters, anchorSpineIndex: current)
|
||||
|
||||
if snapshotContentChanged(old: currentSnapshot, new: newSnapshot) {
|
||||
currentSnapshot = newSnapshot
|
||||
isApplyingSnapshot = true
|
||||
onSnapshotChanged?(newSnapshot)
|
||||
isApplyingSnapshot = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - P1: 翻章后维护窗口
|
||||
|
||||
/// 当前章常驻,预取新的相邻章
|
||||
func maintainWindow(afterMovingTo spineIndex: Int) {
|
||||
let totalSpineCount = context.publication?.spine.count ?? 0
|
||||
|
||||
// 淘汰窗口外章节
|
||||
store.setCurrentChapter(
|
||||
spineIndex: spineIndex,
|
||||
totalSpineCount: totalSpineCount,
|
||||
windowRadius: context.configuration.chapterWindowRadius
|
||||
)
|
||||
for idx in store.evictableSpineIndices() {
|
||||
store.evict(spineIndex: idx)
|
||||
}
|
||||
|
||||
prefetchAdjacent(current: spineIndex)
|
||||
}
|
||||
|
||||
// MARK: - 内部辅助
|
||||
|
||||
private func handle(error: Error) {
|
||||
// 日志记录,不中断当前阅读状态
|
||||
print("[RDEPUBChapterWindowCoordinator] chapter load error: \(error)")
|
||||
// 确保 loading 指示器在加载失败时也被隐藏(避免永久白屏)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.context.hideLoading()
|
||||
// 如果有快照但没内容显示,显示错误提示
|
||||
if self.currentSnapshot == nil {
|
||||
print("[RDEPUBChapterWindowCoordinator] No snapshot after error, page will be blank")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func snapshotContentChanged(
|
||||
old: RDEPUBChapterWindowSnapshot?,
|
||||
new: RDEPUBChapterWindowSnapshot
|
||||
) -> Bool {
|
||||
guard let old = old else { return true }
|
||||
|
||||
if old.chapters.count != new.chapters.count { return true }
|
||||
|
||||
let oldSpines = old.chapters.map { $0.spineIndex }
|
||||
let newSpines = new.chapters.map { $0.spineIndex }
|
||||
if oldSpines != newSpines { return true }
|
||||
|
||||
if old.pageCount != new.pageCount { return true }
|
||||
|
||||
for (oldCh, newCh) in zip(old.chapters, new.chapters) {
|
||||
if oldCh.pages.count != newCh.pages.count { return true }
|
||||
}
|
||||
|
||||
if old.anchorChapterIndex != new.anchorChapterIndex
|
||||
|| old.anchorPageOffset != new.anchorPageOffset { return true }
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private func isReaderIdle() -> Bool {
|
||||
guard !store.isBuilding else { return false }
|
||||
guard !isSwitchingChapter else { return false }
|
||||
guard !isApplyingSnapshot else { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
/// 切章进行中标记
|
||||
private var isSwitchingChapter: Bool = false
|
||||
/// 应用快照进行中标记
|
||||
private var isApplyingSnapshot: Bool = false
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
import Foundation
|
||||
|
||||
struct RDEPUBChapterWindowSnapshot {
|
||||
/// 窗口中的章节(有序)
|
||||
let chapters: [RDEPUBRuntimeChapter]
|
||||
|
||||
/// 展平后的连续页数组(供 RDReaderView 消费)
|
||||
/// 窗口内页码不写回 RDEPUBTextPage 模型;
|
||||
/// flattenedPages 的数组下标就是窗口内连续页码(从 0 开始)。
|
||||
let flattenedPages: [RDEPUBTextPage]
|
||||
|
||||
/// 当前章在 chapters 数组中的索引
|
||||
let anchorChapterIndex: Int
|
||||
|
||||
/// 当前章在 flattenedPages 中的起始页码(从 0 开始,窗口内编号)
|
||||
let anchorPageOffset: Int
|
||||
|
||||
/// 当前窗口首章的 spineIndex,用于调试日志和跨窗口映射
|
||||
let windowStartSpineIndex: Int
|
||||
|
||||
// MARK: - 构建
|
||||
|
||||
/// 从章节窗口构建快照
|
||||
static func from(
|
||||
chapters: [RDEPUBRuntimeChapter],
|
||||
anchorSpineIndex: Int
|
||||
) -> RDEPUBChapterWindowSnapshot {
|
||||
let sortedChapters = chapters.sorted { $0.spineIndex < $1.spineIndex }
|
||||
let anchorIndex = sortedChapters.firstIndex { $0.spineIndex == anchorSpineIndex } ?? 0
|
||||
let pageOffset = sortedChapters.prefix(anchorIndex).reduce(0) { $0 + $1.pages.count }
|
||||
|
||||
// 展平页数组
|
||||
var allPages: [RDEPUBTextPage] = []
|
||||
for (chIdx, ch) in sortedChapters.enumerated() {
|
||||
for var page in ch.pages {
|
||||
page.chapterIndex = chIdx
|
||||
allPages.append(page)
|
||||
}
|
||||
}
|
||||
|
||||
let windowStartSpineIndex = sortedChapters.first?.spineIndex ?? anchorSpineIndex
|
||||
|
||||
return RDEPUBChapterWindowSnapshot(
|
||||
chapters: sortedChapters,
|
||||
flattenedPages: allPages,
|
||||
anchorChapterIndex: anchorIndex,
|
||||
anchorPageOffset: pageOffset,
|
||||
windowStartSpineIndex: windowStartSpineIndex
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 查询
|
||||
|
||||
/// 窗口内页码(即 flattenedPages 下标)-> 所属章节
|
||||
func chapterForPage(flattenedPageIndex: Int) -> RDEPUBRuntimeChapter? {
|
||||
var offset = 0
|
||||
for ch in chapters {
|
||||
if flattenedPageIndex < offset + ch.pages.count {
|
||||
return ch
|
||||
}
|
||||
offset += ch.pages.count
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 窗口内页码(即 flattenedPages 下标)-> 所属章节的 spineIndex
|
||||
func spineIndexForPage(flattenedPageIndex: Int) -> Int? {
|
||||
return chapterForPage(flattenedPageIndex: flattenedPageIndex)?.spineIndex
|
||||
}
|
||||
|
||||
/// 总页数(窗口内)
|
||||
var pageCount: Int { flattenedPages.count }
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
import Foundation
|
||||
|
||||
struct RDEPUBLocationConverter {
|
||||
|
||||
// MARK: - 主路径:先构建目标章,拿到真实长度后再精确转换
|
||||
|
||||
/// 旧版 RDEPUBLocation -> 新版 RDEPUBChapterLocation
|
||||
/// 主迁移路径:要求先构建目标章,用真实 chapterLength 做精确转换
|
||||
/// 仅在无法获取章节长度时才降级到粗估 fallback
|
||||
static func convert(
|
||||
legacy location: RDEPUBLocation,
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
chapterLengthProvider: ((Int) -> Int?)? = nil
|
||||
) -> RDEPUBChapterLocation? {
|
||||
// 1. 从 href 找到 spineIndex
|
||||
guard let spineItem = publication.spine.first(where: {
|
||||
$0.href == location.href || $0.href.contains(location.href)
|
||||
}) else { return nil }
|
||||
|
||||
let spineIndex = publication.spine.firstIndex(of: spineItem) ?? 0
|
||||
|
||||
// 2. 优先用 fragmentID 定位(最精确,不受 progression 精度影响)
|
||||
if let fragmentID = location.fragment {
|
||||
return RDEPUBChapterLocation(
|
||||
spineIndex: spineIndex,
|
||||
chapterOffset: 0, // fragmentID 由 chapterOffsetMap 精确解析
|
||||
fragmentID: fragmentID,
|
||||
progressionInChapter: location.progression
|
||||
)
|
||||
}
|
||||
|
||||
// 3. 有 chapterLength 时做精确转换
|
||||
if let provider = chapterLengthProvider,
|
||||
let chapterLength = provider(spineIndex), chapterLength > 0 {
|
||||
return convert(
|
||||
legacy: location,
|
||||
spineIndex: spineIndex,
|
||||
chapterLength: chapterLength
|
||||
)
|
||||
}
|
||||
|
||||
// 4. Fallback:无法获取章节长度时的粗估(仅作临时降级)
|
||||
let estimatedOffset = Int(location.progression * 10000)
|
||||
return RDEPUBChapterLocation(
|
||||
spineIndex: spineIndex,
|
||||
chapterOffset: estimatedOffset,
|
||||
fragmentID: nil,
|
||||
progressionInChapter: location.progression,
|
||||
schemaVersion: 1 // 标记为降级结果,后续可被精确值覆盖
|
||||
)
|
||||
}
|
||||
|
||||
/// 精确转换:已知章节实际长度
|
||||
static func convert(
|
||||
legacy location: RDEPUBLocation,
|
||||
spineIndex: Int,
|
||||
chapterLength: Int
|
||||
) -> RDEPUBChapterLocation? {
|
||||
let offset = Int(location.progression * Double(chapterLength))
|
||||
return RDEPUBChapterLocation(
|
||||
spineIndex: spineIndex,
|
||||
chapterOffset: offset,
|
||||
fragmentID: location.fragment,
|
||||
progressionInChapter: location.progression,
|
||||
schemaVersion: 2
|
||||
)
|
||||
}
|
||||
|
||||
/// 从已构建的 RDEPUBRuntimeChapter 做精确转换(推荐迁移路径)
|
||||
static func convert(
|
||||
legacy location: RDEPUBLocation,
|
||||
chapter: RDEPUBRuntimeChapter
|
||||
) -> RDEPUBChapterLocation? {
|
||||
// 优先用 fragmentID
|
||||
if let fragmentID = location.fragment,
|
||||
let fragmentOffset = chapter.chapterOffsetMap.chapterOffset(forFragmentID: fragmentID) {
|
||||
return RDEPUBChapterLocation(
|
||||
spineIndex: chapter.spineIndex,
|
||||
chapterOffset: fragmentOffset,
|
||||
fragmentID: fragmentID,
|
||||
progressionInChapter: nil,
|
||||
schemaVersion: 2
|
||||
)
|
||||
}
|
||||
|
||||
// 用 progression + 真实长度
|
||||
let chapterLength = chapter.typesetAttributedString.length
|
||||
return convert(
|
||||
legacy: location,
|
||||
spineIndex: chapter.spineIndex,
|
||||
chapterLength: chapterLength
|
||||
)
|
||||
}
|
||||
|
||||
/// 新版 -> 旧版(兼容外部接口)
|
||||
static func toLegacy(
|
||||
chapterLocation: RDEPUBChapterLocation,
|
||||
href: String,
|
||||
chapterLength: Int
|
||||
) -> RDEPUBLocation {
|
||||
let progression = chapterLength > 0
|
||||
? Double(chapterLocation.chapterOffset) / Double(chapterLength)
|
||||
: 0
|
||||
return RDEPUBLocation(
|
||||
href: href,
|
||||
progression: min(max(progression, 0), 1),
|
||||
fragment: chapterLocation.fragmentID
|
||||
)
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBPageCountCache {
|
||||
private var storage: [RDEPUBChapterCacheKey: RDEPUBRuntimePageCount] = [:]
|
||||
private let lock = NSLock()
|
||||
|
||||
subscript(key: RDEPUBChapterCacheKey) -> RDEPUBRuntimePageCount? {
|
||||
get {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return storage[key]
|
||||
}
|
||||
set {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
storage[key] = newValue
|
||||
}
|
||||
}
|
||||
|
||||
func entriesForSpineIndex(_ spineIndex: Int) -> [(RDEPUBChapterCacheKey, RDEPUBRuntimePageCount)] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return storage.filter { $0.value.spineIndex == spineIndex }.map { ($0.key, $0.value) }
|
||||
}
|
||||
|
||||
func remove(forSpineIndex spineIndex: Int) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
storage = storage.filter { $0.value.spineIndex != spineIndex }
|
||||
}
|
||||
|
||||
func removeAll() {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
storage.removeAll()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import Foundation
|
||||
|
||||
struct RDEPUBResolvedPage {
|
||||
let page: RDEPUBTextPage
|
||||
let chapter: RDEPUBRuntimeChapter
|
||||
let chapterIndex: Int
|
||||
}
|
||||
|
||||
final class RDEPUBPageResolver {
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
private let store: RDEPUBChapterRuntimeStore
|
||||
|
||||
init(context: RDEPUBReaderContext, store: RDEPUBChapterRuntimeStore) {
|
||||
self.context = context
|
||||
self.store = store
|
||||
}
|
||||
|
||||
func resolvePage(absolutePageIndex: Int) -> RDEPUBResolvedPage? {
|
||||
guard let bookPageMap = context.bookPageMap,
|
||||
let spineIndex = bookPageMap.spineIndex(forAbsolutePage: absolutePageIndex),
|
||||
let localPageIndex = bookPageMap.localPageIndex(forAbsolutePage: absolutePageIndex),
|
||||
let chapter = store.chapterData(for: spineIndex),
|
||||
chapter.pages.indices.contains(localPageIndex),
|
||||
let chapterIndex = bookPageMap.chapterIndex(forSpineIndex: spineIndex) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var page = chapter.pages[localPageIndex]
|
||||
page.absolutePageIndex = absolutePageIndex
|
||||
page.chapterIndex = chapterIndex
|
||||
page.pageIndexInChapter = localPageIndex
|
||||
page.totalPagesInChapter = chapter.pages.count
|
||||
return RDEPUBResolvedPage(page: page, chapter: chapter, chapterIndex: chapterIndex)
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBRuntimeChapter {
|
||||
let spineIndex: Int
|
||||
let href: String
|
||||
let title: String
|
||||
|
||||
/// 原始富文本(可按策略释放,不强制常驻)
|
||||
var sourceAttributedString: NSAttributedString?
|
||||
|
||||
/// 排版后富文本
|
||||
let typesetAttributedString: NSAttributedString
|
||||
|
||||
/// 排版器
|
||||
let layouter: RDEPUBTextLayouter
|
||||
|
||||
/// 页范围
|
||||
let pageRanges: [NSRange]
|
||||
|
||||
/// 页面数组
|
||||
let pages: [RDEPUBTextPage]
|
||||
|
||||
/// 章节偏移映射
|
||||
let chapterOffsetMap: RDEPUBChapterOffsetMap
|
||||
|
||||
init(
|
||||
spineIndex: Int,
|
||||
href: String,
|
||||
title: String,
|
||||
sourceAttributedString: NSAttributedString?,
|
||||
typesetAttributedString: NSAttributedString,
|
||||
layouter: RDEPUBTextLayouter,
|
||||
pageRanges: [NSRange],
|
||||
pages: [RDEPUBTextPage],
|
||||
chapterOffsetMap: RDEPUBChapterOffsetMap
|
||||
) {
|
||||
self.spineIndex = spineIndex
|
||||
self.href = href
|
||||
self.title = title
|
||||
self.sourceAttributedString = sourceAttributedString
|
||||
self.typesetAttributedString = typesetAttributedString
|
||||
self.layouter = layouter
|
||||
self.pageRanges = pageRanges
|
||||
self.pages = pages
|
||||
self.chapterOffsetMap = chapterOffsetMap
|
||||
}
|
||||
|
||||
/// 释放 sourceAttributedString 以降低内存
|
||||
func releaseSourceText() {
|
||||
sourceAttributedString = nil
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
struct RDEPUBRuntimePageCount {
|
||||
let cacheKey: RDEPUBChapterCacheKey
|
||||
let spineIndex: Int
|
||||
let pageRanges: [NSRange]
|
||||
let pageCount: Int
|
||||
let renderSignature: String
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import CryptoKit
|
||||
|
||||
extension String {
|
||||
var sha256Hex: String {
|
||||
let digest = SHA256.hash(data: Data(self.utf8))
|
||||
return digest.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
}
|
||||
@@ -23,11 +23,13 @@ final class RDEPUBReaderAssemblyCoordinator {
|
||||
setupLoadingIndicator(controller.loadingIndicator, in: controller.view)
|
||||
setupErrorLabel(controller.errorLabel, in: controller.view)
|
||||
controller.delegate?.epubReader(controller, configureTopToolView: controller.topToolView)
|
||||
print("[ReadViewDemo] assembleInterface: dataSource=\(readerView.dataSource != nil ? "set" : "nil"), numberOfPages=\(readerView.numberOfPages())")
|
||||
}
|
||||
|
||||
/// 外部纯文本图书启动时,加载已保存的书签、高亮和阅读位置,完成分页收尾。
|
||||
func finishExternalTextBookLaunchIfNeeded() {
|
||||
guard let runtime = context.runtime,
|
||||
let controller = context.controller,
|
||||
context.isExternalTextBook else {
|
||||
return
|
||||
}
|
||||
@@ -37,12 +39,19 @@ final class RDEPUBReaderAssemblyCoordinator {
|
||||
context.activeBookmarks = context.persistence?.loadBookmarks(for: id) ?? []
|
||||
context.activeHighlights = context.persistence?.loadHighlights(for: id) ?? []
|
||||
}
|
||||
runtime.finishPagination(restoreLocation: restoreLocation)
|
||||
|
||||
if let textBook = controller.textBook {
|
||||
print("[ReadViewDemo] finishExternalTextBook: applying textBook with \(textBook.pages.count) pages")
|
||||
runtime.applyTextBook(textBook, restoreLocation: restoreLocation)
|
||||
print("[ReadViewDemo] finishExternalTextBook: after applyTextBook, numberOfPages=\(context.readerView?.numberOfPages() ?? -1)")
|
||||
} else {
|
||||
runtime.finishPagination(restoreLocation: restoreLocation)
|
||||
}
|
||||
}
|
||||
|
||||
private func setupReaderView(_ readerView: RDReaderView, in containerView: UIView) {
|
||||
readerView.dataSource = context.controller as? RDReaderDataSource
|
||||
readerView.delegate = context.controller as? RDReaderDelegate
|
||||
readerView.dataSource = context.controller
|
||||
readerView.delegate = context.controller
|
||||
readerView.translatesAutoresizingMaskIntoConstraints = false
|
||||
containerView.addSubview(readerView)
|
||||
NSLayoutConstraint.activate([
|
||||
|
||||
@@ -117,7 +117,10 @@ final class RDEPUBReaderChromeCoordinator {
|
||||
func presentTableOfContents() {
|
||||
guard let controller else { return }
|
||||
guard controller.configuration.showsTableOfContents else { return }
|
||||
let items = controller.flattenedTableOfContents
|
||||
let items = controller.flattenedTableOfContentsItems(
|
||||
from: controller.publication?.tableOfContents ?? [],
|
||||
includePageNumbers: false
|
||||
)
|
||||
guard !items.isEmpty else { return }
|
||||
|
||||
let chapterController = RDEPUBReaderChapterListController(
|
||||
|
||||
@@ -9,6 +9,9 @@ import UIKit
|
||||
/// - 便捷方法(renderStyle、layoutConfig 等)
|
||||
/// - 弱引用 controller(仅用于 UIKit 呈现操作)
|
||||
final class RDEPUBReaderContext {
|
||||
private let activityLock = NSLock()
|
||||
private var lastUserNavigationTimestamp: CFAbsoluteTime = 0
|
||||
|
||||
// MARK: - 引用
|
||||
|
||||
/// 弱引用阅读器控制器,用于 UIKit 呈现操作。
|
||||
@@ -31,7 +34,10 @@ final class RDEPUBReaderContext {
|
||||
/// 当前阅读会话,管理页面和章节状态。
|
||||
var readingSession: RDEPUBReadingSession?
|
||||
/// 原生文本排版生成的图书模型(仅文本重排模式)。
|
||||
/// 章节模式下为 nil,内容通过 ChapterRuntimeStore 访问。
|
||||
var textBook: RDEPUBTextBook?
|
||||
/// 全书轻量页码映射(章节模式)。约 100KB/1000章,不持有 NSAttributedString。
|
||||
var bookPageMap: RDEPUBBookPageMap?
|
||||
/// 当前书籍的所有书签。
|
||||
var activeBookmarks: [RDEPUBBookmark] = []
|
||||
/// 当前书籍的所有高亮标注。
|
||||
@@ -46,6 +52,10 @@ final class RDEPUBReaderContext {
|
||||
var searchState: RDEPUBSearchState?
|
||||
/// 上次文本分页时的页面尺寸,用于检测是否需要重新分页。
|
||||
var lastTextPaginationPageSize: CGSize?
|
||||
/// 后台元数据解析耗时(毫秒),仅包含 OperationQueue 并行阶段。
|
||||
var lastMetadataParseWallClockMs: Int = 0
|
||||
/// 后台元数据解析使用的并发数。
|
||||
var lastMetadataParseConcurrency: Int = 0
|
||||
/// 当前用户文本选区。
|
||||
var currentSelection: RDEPUBSelection?
|
||||
|
||||
@@ -128,8 +138,9 @@ final class RDEPUBReaderContext {
|
||||
edgeInsets: configuration.reflowableContentInsets,
|
||||
numberOfColumns: configuration.numberOfColumns,
|
||||
columnGap: configuration.columnGap,
|
||||
avoidOrphans: true,
|
||||
avoidWidows: true,
|
||||
// 小说正文更看重尽量铺满页面,避免页尾出现明显留白。
|
||||
avoidOrphans: false,
|
||||
avoidWidows: false,
|
||||
avoidPageBreakInsideEnabled: true,
|
||||
hyphenation: true,
|
||||
imageMaxHeightRatio: 0.85,
|
||||
@@ -183,6 +194,66 @@ final class RDEPUBReaderContext {
|
||||
dependencies.makeTextBookBuilder(resolvedTextRenderer(), textBookCache, layoutConfig)
|
||||
}
|
||||
|
||||
func makeChapterSummaryDiskCache() -> RDEPUBChapterSummaryDiskCache {
|
||||
let cachesDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
|
||||
?? FileManager.default.temporaryDirectory
|
||||
let bookID = (currentBookIdentifier ?? "default").sha256Hex
|
||||
let directory = cachesDirectory
|
||||
.appendingPathComponent("RDEPUBChapterSummaryCache", isDirectory: true)
|
||||
.appendingPathComponent(bookID, isDirectory: true)
|
||||
return RDEPUBChapterSummaryDiskCache(cacheDirectory: directory)
|
||||
}
|
||||
|
||||
func chapterCacheKey(forSpineIndex spineIndex: Int) -> RDEPUBChapterCacheKey {
|
||||
let style = currentTextRenderStyle()
|
||||
let pageSize = currentTextPageSize()
|
||||
let layoutConfig = currentTextLayoutConfig(pageSize: pageSize)
|
||||
let renderSignature = [
|
||||
style.font.fontName,
|
||||
"\(style.font.pointSize)",
|
||||
"\(configuration.lineHeightMultiple)",
|
||||
"\(style.lineSpacing)",
|
||||
layoutConfig.cacheSignature,
|
||||
"\(RDEPUBChapterSummary.currentSchemaVersion)"
|
||||
].joined(separator: "|")
|
||||
|
||||
let contentHash: String
|
||||
if let parser,
|
||||
let publication,
|
||||
publication.spine.indices.contains(spineIndex) {
|
||||
let href = publication.spine[spineIndex].href
|
||||
contentHash = parser.htmlString(forRelativePath: href)?.sha256Hex ?? ""
|
||||
} else {
|
||||
contentHash = ""
|
||||
}
|
||||
|
||||
return RDEPUBChapterCacheKey(
|
||||
bookID: currentBookIdentifier ?? "",
|
||||
spineIndex: spineIndex,
|
||||
renderSignature: renderSignature,
|
||||
chapterContentHash: contentHash
|
||||
)
|
||||
}
|
||||
|
||||
func chapterSummary(forSpineIndex spineIndex: Int) -> RDEPUBChapterSummary? {
|
||||
runtime?.summaryDiskCache.read(for: chapterCacheKey(forSpineIndex: spineIndex))
|
||||
}
|
||||
|
||||
func normalizedSpineIndex(for location: RDEPUBLocation) -> Int? {
|
||||
guard let publication else { return nil }
|
||||
let normalizedLocation = publication.resourceResolver.normalizedLocation(
|
||||
location,
|
||||
relativeToSpineIndex: nil,
|
||||
bookIdentifier: currentBookIdentifier
|
||||
) ?? location
|
||||
guard let normalizedHref = publication.resourceResolver.normalizedHref(normalizedLocation.href) else {
|
||||
return nil
|
||||
}
|
||||
return publication.spine.firstIndex {
|
||||
publication.resourceResolver.normalizedHref($0.href) == normalizedHref
|
||||
}
|
||||
}
|
||||
|
||||
/// 工厂方法:创建纯文本图书构建器。
|
||||
func makePlainTextBookBuilder(layoutConfig: RDEPUBTextLayoutConfig) -> RDPlainTextBookBuilder {
|
||||
dependencies.makePlainTextBookBuilder(resolvedTextRenderer(), layoutConfig)
|
||||
@@ -205,6 +276,22 @@ final class RDEPUBReaderContext {
|
||||
persistence?.saveLocation(location, for: currentBookIdentifier)
|
||||
}
|
||||
|
||||
/// 记录最近一次用户翻页/跳转行为,用于后台分页让路。
|
||||
func markUserNavigationActivity() {
|
||||
activityLock.lock()
|
||||
lastUserNavigationTimestamp = CFAbsoluteTimeGetCurrent()
|
||||
activityLock.unlock()
|
||||
}
|
||||
|
||||
/// 距离最近一次用户翻页/跳转已经过去的时间。
|
||||
func secondsSinceLastUserNavigation() -> CFAbsoluteTime {
|
||||
activityLock.lock()
|
||||
let timestamp = lastUserNavigationTimestamp
|
||||
activityLock.unlock()
|
||||
guard timestamp > 0 else { return .greatestFiniteMagnitude }
|
||||
return CFAbsoluteTimeGetCurrent() - timestamp
|
||||
}
|
||||
|
||||
/// 根据规范化 href 获取文本章节数据。
|
||||
func textChapterData(forNormalizedHref href: String) -> RDEPUBChapterData? {
|
||||
guard let textBook, let publication else { return nil }
|
||||
|
||||
@@ -25,7 +25,16 @@ final class RDEPUBReaderLocationCoordinator {
|
||||
return false
|
||||
}
|
||||
|
||||
if context.textBook == nil {
|
||||
if context.bookPageMap != nil {
|
||||
guard context.runtime?.prepareOnDemandChapter(forAbsolutePageNumber: targetPageNumber) == true else {
|
||||
return false
|
||||
}
|
||||
_ = context.readingSession?.queueNavigation(
|
||||
to: location,
|
||||
relativeToSpineIndex: nil,
|
||||
bookIdentifier: context.currentBookIdentifier
|
||||
)
|
||||
} else if context.textBook == nil {
|
||||
_ = context.readingSession?.queueNavigation(
|
||||
to: location,
|
||||
relativeToSpineIndex: nil,
|
||||
@@ -44,7 +53,7 @@ final class RDEPUBReaderLocationCoordinator {
|
||||
let readerView = context.readerView else {
|
||||
return nil
|
||||
}
|
||||
if context.textBook != nil, readerView.currentPage >= 0 {
|
||||
if (context.textBook != nil || context.bookPageMap != nil), readerView.currentPage >= 0 {
|
||||
return controller.resolvedTextLocation(forPageNumber: readerView.currentPage + 1)
|
||||
}
|
||||
return context.readingSession?.currentReadingLocation(bookIdentifier: context.currentBookIdentifier)
|
||||
|
||||
+465
-29
@@ -4,18 +4,20 @@ import Foundation
|
||||
///
|
||||
/// 职责:
|
||||
/// - 根据出版物类型(文本重排/Fixed Layout/Web 内容)选择分页策略
|
||||
/// - 后台构建文本图书模型并应用分页快照
|
||||
/// - 文本大书优先恢复分页摘要并切换到按需加载
|
||||
/// - 重新分页时保持当前阅读位置
|
||||
/// - 刷新可见内容并保持位置
|
||||
/// - 重建外部纯文本图书
|
||||
final class RDEPUBReaderPaginationCoordinator {
|
||||
private let backgroundInteractionCooldown: CFAbsoluteTime = 0.8
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
/// 对出版物执行分页:文本重排走 TextBookBuilder,Fixed Layout 直接生成快照,Web 内容走 Paginator。
|
||||
/// 对出版物执行分页:文本重排优先走摘要恢复/按需加载,Fixed Layout 直接生成快照,Web 内容走 Paginator。
|
||||
func paginatePublication(restoreLocation: RDEPUBLocation?) {
|
||||
guard let controller = context.controller,
|
||||
let parser = context.parser,
|
||||
@@ -29,38 +31,22 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
controller.showLoading()
|
||||
let token = UUID()
|
||||
context.paginationToken = token
|
||||
print("[EPUB][Pagination] profile=\(publication.readingProfile.rawValue) layout=\(publication.layout.rawValue) spine=\(publication.spine.count)")
|
||||
|
||||
if publication.readingProfile == .textReflowable {
|
||||
let pageSize = controller.currentTextPageSize()
|
||||
context.lastTextPaginationPageSize = pageSize
|
||||
let layoutConfig = controller.currentTextLayoutConfig(pageSize: pageSize)
|
||||
let builder = context.makeTextBookBuilder(layoutConfig: layoutConfig)
|
||||
let renderStyle = controller.currentTextRenderStyle()
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak controller] in
|
||||
guard let controller else { return }
|
||||
do {
|
||||
let textBook = try builder.build(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
pageSize: pageSize,
|
||||
style: renderStyle
|
||||
)
|
||||
DispatchQueue.main.async {
|
||||
guard self.context.paginationToken == token else { return }
|
||||
self.context.runtime?.applyTextBook(textBook, restoreLocation: restoreLocation)
|
||||
}
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
guard self.context.paginationToken == token else { return }
|
||||
self.context.handle(error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
print("[EPUB][Pagination] path=text-reflowable-on-demand")
|
||||
paginateTextPublication(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
readingSession: readingSession,
|
||||
restoreLocation: restoreLocation,
|
||||
token: token
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if publication.layout == .fixed {
|
||||
print("[EPUB][Pagination] path=fixed-layout")
|
||||
let snapshot = readingSession.makePaginationSnapshot(
|
||||
pageCounts: Array(repeating: 1, count: publication.spine.count),
|
||||
preferences: controller.currentPreferences(),
|
||||
@@ -71,6 +57,7 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
}
|
||||
|
||||
let paginator = context.makePaginator()
|
||||
print("[EPUB][Pagination] path=web-paginator")
|
||||
context.paginator = paginator
|
||||
paginator.calculate(
|
||||
parser: parser,
|
||||
@@ -92,6 +79,7 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
|
||||
guard let controller = context.controller else { return }
|
||||
context.textBook = textBook
|
||||
context.bookPageMap = nil
|
||||
let snapshot = controller.nativeTextSnapshot(from: textBook)
|
||||
context.replaceActiveSnapshot(snapshot)
|
||||
|
||||
@@ -108,8 +96,9 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
_ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]),
|
||||
restoreLocation: RDEPUBLocation?
|
||||
) {
|
||||
guard let controller = context.controller else { return }
|
||||
guard context.controller != nil else { return }
|
||||
context.textBook = nil
|
||||
context.bookPageMap = nil
|
||||
context.replaceActiveSnapshot(snapshot)
|
||||
|
||||
guard !snapshot.pages.isEmpty else {
|
||||
@@ -129,6 +118,7 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
readerView.reloadData()
|
||||
if let targetLocation = restoreLocation {
|
||||
controller.restoreReadingLocation(targetLocation)
|
||||
context.readingSession?.transition(to: .idle)
|
||||
} else {
|
||||
readerView.transitionToPage(pageNum: 0)
|
||||
context.readingSession?.transition(to: .idle)
|
||||
@@ -168,4 +158,450 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
context.runtime?.applyTextBook(newBook, restoreLocation: restoreLocation)
|
||||
}
|
||||
}
|
||||
|
||||
private func paginateTextPublication(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
readingSession: RDEPUBReadingSession,
|
||||
restoreLocation: RDEPUBLocation?,
|
||||
token: UUID
|
||||
) {
|
||||
guard let controller = context.controller else { return }
|
||||
let context = self.context
|
||||
|
||||
let pageSize = controller.currentTextPageSize()
|
||||
context.lastTextPaginationPageSize = pageSize
|
||||
|
||||
DispatchQueue.global(qos: .utility).async { [weak controller] in
|
||||
guard controller != nil else { return }
|
||||
guard context.controller != nil else { return }
|
||||
if let restoredPageMap = self.restoreBookPageMapIfPossible(publication: publication) {
|
||||
DispatchQueue.main.async {
|
||||
guard context.paginationToken == token,
|
||||
context.controller != nil else { return }
|
||||
context.runtime?.applyBookPageMap(restoredPageMap, restoreLocation: restoreLocation)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let prioritizedCandidates = self.prioritizedBuildableSpineIndices(
|
||||
publication: publication,
|
||||
readingSession: readingSession,
|
||||
restoreLocation: restoreLocation
|
||||
)
|
||||
guard prioritizedCandidates.first != nil else {
|
||||
DispatchQueue.main.async {
|
||||
guard context.paginationToken == token,
|
||||
context.controller != nil else { return }
|
||||
context.handle(error: RDEPUBParserError.emptySpine)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
guard context.controller != nil,
|
||||
let runtime = context.runtime else { return }
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"QuickOpen",
|
||||
"begin token=\(token.uuidString) candidates=\(prioritizedCandidates.count) restoreSpine=\(readingSession.initialSpineIndex(for: restoreLocation))"
|
||||
)
|
||||
let runtimeChapter = try RDEPUBBackgroundTrace.measure(
|
||||
"QuickOpen",
|
||||
"loadFirstRenderableRuntimeChapter"
|
||||
) {
|
||||
try self.loadFirstRenderableRuntimeChapter(
|
||||
prioritizedSpineIndices: prioritizedCandidates,
|
||||
runtime: runtime
|
||||
)
|
||||
}
|
||||
let quickWindowChapters = try RDEPUBBackgroundTrace.measure(
|
||||
"QuickOpen",
|
||||
"loadInitialRuntimeChapters anchorSpine=\(runtimeChapter.spineIndex)"
|
||||
) {
|
||||
try self.loadInitialRuntimeChapters(
|
||||
anchorSpineIndex: runtimeChapter.spineIndex,
|
||||
publication: publication,
|
||||
runtime: runtime
|
||||
)
|
||||
}
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"QuickOpen",
|
||||
"ready anchorSpine=\(runtimeChapter.spineIndex) quickWindow=\(quickWindowChapters.map { $0.spineIndex }) pages=\(quickWindowChapters.reduce(0) { $0 + $1.pages.count })"
|
||||
)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
guard context.paginationToken == token,
|
||||
context.controller != nil else { return }
|
||||
runtime.chapterRuntimeStore.setCurrentChapter(
|
||||
spineIndex: runtimeChapter.spineIndex,
|
||||
totalSpineCount: publication.spine.count,
|
||||
windowRadius: context.configuration.chapterWindowRadius
|
||||
)
|
||||
let partialMap = self.makePartialPageMap(from: quickWindowChapters)
|
||||
runtime.applyBookPageMap(partialMap, restoreLocation: restoreLocation)
|
||||
self.paginateMetadataOnly(token: token, restoreLocation: restoreLocation)
|
||||
}
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
guard context.paginationToken == token,
|
||||
context.controller != nil else { return }
|
||||
context.handle(error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadFirstRenderableRuntimeChapter(
|
||||
prioritizedSpineIndices: [Int],
|
||||
runtime: RDEPUBReaderRuntime
|
||||
) throws -> RDEPUBRuntimeChapter {
|
||||
var lastError: Error?
|
||||
for spineIndex in prioritizedSpineIndices {
|
||||
do {
|
||||
return try runtime.chapterLoader.loadChapterSynchronouslyForMigration(
|
||||
spineIndex: spineIndex,
|
||||
store: runtime.chapterRuntimeStore
|
||||
)
|
||||
} catch {
|
||||
lastError = error
|
||||
RDEPUBBackgroundTrace.log("QuickOpen", "skip spine=\(spineIndex) reason=\(error)")
|
||||
}
|
||||
}
|
||||
throw lastError ?? RDEPUBParserError.emptySpine
|
||||
}
|
||||
|
||||
private func loadInitialRuntimeChapters(
|
||||
anchorSpineIndex: Int,
|
||||
publication: RDEPUBPublication,
|
||||
runtime: RDEPUBReaderRuntime
|
||||
) throws -> [RDEPUBRuntimeChapter] {
|
||||
let windowSpineIndices = initialWindowSpineIndices(
|
||||
around: anchorSpineIndex,
|
||||
in: publication,
|
||||
maxChapterCount: context.configuration.onDemandChapterWindowSize
|
||||
)
|
||||
var chapters: [RDEPUBRuntimeChapter] = []
|
||||
for spineIndex in windowSpineIndices {
|
||||
do {
|
||||
let chapter = try runtime.chapterLoader.loadChapterSynchronouslyForMigration(
|
||||
spineIndex: spineIndex,
|
||||
store: runtime.chapterRuntimeStore
|
||||
)
|
||||
chapters.append(chapter)
|
||||
} catch {
|
||||
if spineIndex == anchorSpineIndex {
|
||||
throw error
|
||||
}
|
||||
RDEPUBBackgroundTrace.log("QuickOpen", "skip adjacent spine=\(spineIndex) reason=\(error)")
|
||||
}
|
||||
}
|
||||
return chapters
|
||||
}
|
||||
|
||||
private func initialWindowSpineIndices(
|
||||
around anchorSpineIndex: Int,
|
||||
in publication: RDEPUBPublication,
|
||||
maxChapterCount: Int = 3
|
||||
) -> [Int] {
|
||||
let normalizedMaxChapterCount = RDEPUBReaderConfiguration.normalizedChapterWindowSize(maxChapterCount)
|
||||
let buildableIndices = allBuildableSpineIndices(in: publication)
|
||||
guard let anchorPosition = buildableIndices.firstIndex(of: anchorSpineIndex) else {
|
||||
return [anchorSpineIndex]
|
||||
}
|
||||
|
||||
var selected = [anchorSpineIndex]
|
||||
var nextPosition = anchorPosition + 1
|
||||
var previousPosition = anchorPosition - 1
|
||||
|
||||
while selected.count < normalizedMaxChapterCount,
|
||||
nextPosition < buildableIndices.count || previousPosition >= 0 {
|
||||
if nextPosition < buildableIndices.count {
|
||||
selected.append(buildableIndices[nextPosition])
|
||||
nextPosition += 1
|
||||
if selected.count == normalizedMaxChapterCount {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if previousPosition >= 0 {
|
||||
selected.insert(buildableIndices[previousPosition], at: 0)
|
||||
previousPosition -= 1
|
||||
}
|
||||
}
|
||||
|
||||
return selected
|
||||
}
|
||||
|
||||
private func makePartialPageMap(from chapters: [RDEPUBRuntimeChapter]) -> RDEPUBBookPageMap {
|
||||
var builder = RDEPUBBookPageMap.Builder()
|
||||
for chapter in chapters {
|
||||
builder.add(
|
||||
spineIndex: chapter.spineIndex,
|
||||
href: chapter.href,
|
||||
title: chapter.title,
|
||||
pageCount: chapter.pages.count,
|
||||
fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets
|
||||
)
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private func prioritizedBuildableSpineIndices(
|
||||
publication: RDEPUBPublication,
|
||||
readingSession: RDEPUBReadingSession,
|
||||
restoreLocation: RDEPUBLocation?
|
||||
) -> [Int] {
|
||||
let preferred = readingSession.initialSpineIndex(for: restoreLocation)
|
||||
return publication.spine.indices
|
||||
.filter { isBuildableTextSpine(at: $0, in: publication) }
|
||||
.sorted { lhs, rhs in
|
||||
abs(lhs - preferred) < abs(rhs - preferred)
|
||||
}
|
||||
}
|
||||
|
||||
private func allBuildableSpineIndices(in publication: RDEPUBPublication) -> [Int] {
|
||||
publication.spine.indices.filter { isBuildableTextSpine(at: $0, in: publication) }
|
||||
}
|
||||
|
||||
private func waitForReadingInteractionToSettle(using context: RDEPUBReaderContext) {
|
||||
while context.controller != nil,
|
||||
context.secondsSinceLastUserNavigation() < backgroundInteractionCooldown {
|
||||
Thread.sleep(forTimeInterval: 0.08)
|
||||
}
|
||||
}
|
||||
|
||||
private func isBuildableTextSpine(at index: Int, in publication: RDEPUBPublication) -> Bool {
|
||||
guard publication.spine.indices.contains(index) else { return false }
|
||||
let item = publication.spine[index]
|
||||
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
|
||||
}
|
||||
|
||||
// MARK: - 元数据专用解析(Phase 0)
|
||||
|
||||
/// 后台遍历所有章节,只提取轻量元数据(pageCount、pageRanges、fragmentOffsets),
|
||||
/// 写入磁盘摘要缓存,不累积 RDEPUBTextBook。
|
||||
func paginateMetadataOnly(token: UUID, restoreLocation: RDEPUBLocation?) {
|
||||
let context = self.context
|
||||
guard let parser = context.parser,
|
||||
let publication = context.publication else { return }
|
||||
|
||||
let pageSize = context.currentTextPageSize()
|
||||
let layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
|
||||
let style = context.currentTextRenderStyle()
|
||||
let allBuildableIndices = allBuildableSpineIndices(in: publication)
|
||||
let summaryDiskCache = context.runtime?.summaryDiskCache
|
||||
let workerCount = max(1, context.configuration.metadataParsingConcurrency)
|
||||
let cpuCount = ProcessInfo.processInfo.activeProcessorCount
|
||||
RDEPUBBackgroundTrace.log("MetadataParse", "config concurrency=\(workerCount) cpuCores=\(cpuCount)")
|
||||
|
||||
DispatchQueue.global(qos: .utility).async { [weak self] in
|
||||
guard let self else { return }
|
||||
guard context.controller != nil else { return }
|
||||
let catalog = allBuildableIndices.map { spineIndex in
|
||||
let item = publication.spine[spineIndex]
|
||||
return (
|
||||
key: context.chapterCacheKey(forSpineIndex: spineIndex),
|
||||
spineIndex: spineIndex,
|
||||
href: item.href,
|
||||
title: item.title
|
||||
)
|
||||
}
|
||||
let restored = summaryDiskCache?.readAll(keys: catalog)
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"MetadataParse",
|
||||
"begin token=\(token.uuidString) buildableChapters=\(allBuildableIndices.count) concurrency=\(workerCount)"
|
||||
)
|
||||
let cachedSummaries = restored?.summaries ?? [:]
|
||||
let cachedSpineIndices = Set(cachedSummaries.keys)
|
||||
let resultLock = NSLock()
|
||||
var summariesBySpineIndex = cachedSummaries
|
||||
var totalResolvedCount = cachedSpineIndices.count
|
||||
var lastAppliedCount = cachedSpineIndices.count
|
||||
|
||||
if !cachedSpineIndices.isEmpty {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"MetadataParse",
|
||||
"resumeFromCache cachedChapters=\(cachedSpineIndices.count) total=\(allBuildableIndices.count)"
|
||||
)
|
||||
let cachedMap = self.buildPageMap(from: catalog, summaries: summariesBySpineIndex)
|
||||
DispatchQueue.main.async {
|
||||
guard context.paginationToken == token,
|
||||
context.controller != nil else { return }
|
||||
context.runtime?.refreshBookPageMapInPlace(cachedMap)
|
||||
}
|
||||
}
|
||||
|
||||
let uncachedSpineIndices = allBuildableIndices.filter { !cachedSpineIndices.contains($0) }
|
||||
|
||||
self.waitForReadingInteractionToSettle(using: context)
|
||||
|
||||
let wallClockStart = CFAbsoluteTimeGetCurrent()
|
||||
var totalRenderMs: Double = 0
|
||||
var totalWriteMs: Double = 0
|
||||
var completedChapters = 0
|
||||
var failedChapters = 0
|
||||
let timingLock = NSLock()
|
||||
|
||||
let queue = OperationQueue()
|
||||
queue.name = "com.rdreader.metadata.parse"
|
||||
queue.qualityOfService = .utility
|
||||
queue.maxConcurrentOperationCount = workerCount
|
||||
|
||||
for (offset, spineIndex) in uncachedSpineIndices.enumerated() {
|
||||
queue.addOperation {
|
||||
guard context.controller != nil,
|
||||
context.paginationToken == token else {
|
||||
return
|
||||
}
|
||||
do {
|
||||
RDEPUBBackgroundTrace.log("MetadataParse", "正在解析 spine=\(spineIndex) \(offset + 1)/\(uncachedSpineIndices.count)")
|
||||
|
||||
let renderResult: RDEPUBChapterSummary? = try autoreleasepool { () -> RDEPUBChapterSummary? in
|
||||
let chapterBuilder = context.makeTextBookBuilder(layoutConfig: layoutConfig)
|
||||
let renderStart = CFAbsoluteTimeGetCurrent()
|
||||
guard let result = try chapterBuilder.buildChapter(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
spineIndex: spineIndex,
|
||||
pageSize: pageSize,
|
||||
style: style
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
let renderElapsed = (CFAbsoluteTimeGetCurrent() - renderStart) * 1000
|
||||
|
||||
let chapter = result.chapter
|
||||
let cacheKey = context.chapterCacheKey(forSpineIndex: spineIndex)
|
||||
let summary = RDEPUBChapterSummary(
|
||||
pageRanges: chapter.pages.map { .init(location: $0.contentRange.location, length: $0.contentRange.length) },
|
||||
pageCount: chapter.pages.count,
|
||||
fragmentOffsets: chapter.fragmentOffsets,
|
||||
renderSignature: cacheKey.renderSignature,
|
||||
schemaVersion: RDEPUBChapterSummary.currentSchemaVersion,
|
||||
chapterContentHash: cacheKey.chapterContentHash,
|
||||
pageMetadataList: chapter.pages.map { .from($0.metadata) }
|
||||
)
|
||||
let writeStart = CFAbsoluteTimeGetCurrent()
|
||||
summaryDiskCache?.write(summary: summary, for: cacheKey)
|
||||
let writeElapsed = (CFAbsoluteTimeGetCurrent() - writeStart) * 1000
|
||||
|
||||
timingLock.lock()
|
||||
totalRenderMs += renderElapsed
|
||||
totalWriteMs += writeElapsed
|
||||
completedChapters += 1
|
||||
timingLock.unlock()
|
||||
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"MetadataParse",
|
||||
"spine=\(spineIndex) renderMs=\(Int(renderElapsed)) writeMs=\(Int(writeElapsed))"
|
||||
)
|
||||
return summary
|
||||
}
|
||||
|
||||
guard let renderResult else { return }
|
||||
|
||||
var partialMap: RDEPUBBookPageMap?
|
||||
resultLock.lock()
|
||||
summariesBySpineIndex[spineIndex] = renderResult
|
||||
totalResolvedCount += 1
|
||||
if totalResolvedCount - lastAppliedCount >= 32 || totalResolvedCount == allBuildableIndices.count {
|
||||
lastAppliedCount = totalResolvedCount
|
||||
partialMap = self.buildPageMap(from: catalog, summaries: summariesBySpineIndex)
|
||||
}
|
||||
resultLock.unlock()
|
||||
|
||||
if let partialMap {
|
||||
DispatchQueue.main.async {
|
||||
guard context.paginationToken == token,
|
||||
context.controller != nil else { return }
|
||||
context.runtime?.refreshBookPageMapInPlace(partialMap)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
timingLock.lock()
|
||||
failedChapters += 1
|
||||
timingLock.unlock()
|
||||
RDEPUBBackgroundTrace.log("MetadataParse", "buildChapter FAILED: spine=\(spineIndex) error=\(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
queue.waitUntilAllOperationsAreFinished()
|
||||
summaryDiskCache?.flushPendingWrites()
|
||||
|
||||
let wallClockMs = Int((CFAbsoluteTimeGetCurrent() - wallClockStart) * 1000)
|
||||
timingLock.lock()
|
||||
let renderTotal = Int(totalRenderMs)
|
||||
let writeTotal = Int(totalWriteMs)
|
||||
let rendered = completedChapters
|
||||
let failed = failedChapters
|
||||
timingLock.unlock()
|
||||
let avgRenderMs = rendered > 0 ? renderTotal / rendered : 0
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"MetadataParse",
|
||||
"timing wallClockMs=\(wallClockMs) chapters=\(rendered) failed=\(failed) " +
|
||||
"renderTotalMs=\(renderTotal) writeTotalMs=\(writeTotal) avgRenderMs=\(avgRenderMs) concurrency=\(workerCount)"
|
||||
)
|
||||
context.lastMetadataParseWallClockMs = wallClockMs
|
||||
context.lastMetadataParseConcurrency = workerCount
|
||||
|
||||
guard context.controller != nil,
|
||||
context.paginationToken == token else {
|
||||
RDEPUBBackgroundTrace.log("MetadataParse", "abort: reader dismissed or token changed")
|
||||
return
|
||||
}
|
||||
|
||||
let pageMap = self.buildPageMap(from: catalog, summaries: summariesBySpineIndex)
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"MetadataParse",
|
||||
"complete chapters=\(pageMap.totalChapters) pages=\(pageMap.totalPages)"
|
||||
)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
guard context.paginationToken == token,
|
||||
context.controller != nil else { return }
|
||||
context.runtime?.refreshBookPageMapInPlace(pageMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func restoreBookPageMapIfPossible(publication: RDEPUBPublication) -> RDEPUBBookPageMap? {
|
||||
guard let summaryDiskCache = context.runtime?.summaryDiskCache else {
|
||||
return nil
|
||||
}
|
||||
let catalog = allBuildableSpineIndices(in: publication).map { spineIndex in
|
||||
let item = publication.spine[spineIndex]
|
||||
return (
|
||||
key: context.chapterCacheKey(forSpineIndex: spineIndex),
|
||||
spineIndex: spineIndex,
|
||||
href: item.href,
|
||||
title: item.title
|
||||
)
|
||||
}
|
||||
guard summaryDiskCache.isCacheComplete(keys: catalog.map(\.key)) else {
|
||||
return nil
|
||||
}
|
||||
let restored = summaryDiskCache.readAll(keys: catalog)
|
||||
guard restored.summaries.count == catalog.count else {
|
||||
return nil
|
||||
}
|
||||
return restored.mapBuilder.build()
|
||||
}
|
||||
|
||||
private func buildPageMap(
|
||||
from catalog: [(key: RDEPUBChapterCacheKey, spineIndex: Int, href: String, title: String)],
|
||||
summaries: [Int: RDEPUBChapterSummary]
|
||||
) -> RDEPUBBookPageMap {
|
||||
var builder = RDEPUBBookPageMap.Builder()
|
||||
for item in catalog {
|
||||
guard let summary = summaries[item.spineIndex] else { continue }
|
||||
builder.add(
|
||||
spineIndex: item.spineIndex,
|
||||
href: item.href,
|
||||
title: item.title,
|
||||
pageCount: summary.pageCount,
|
||||
fragmentOffsets: summary.fragmentOffsets
|
||||
)
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,15 @@ import UIKit
|
||||
final class RDEPUBReaderRuntime {
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
lazy var chapterRuntimeStore = RDEPUBChapterRuntimeStore()
|
||||
lazy var summaryDiskCache = context.makeChapterSummaryDiskCache()
|
||||
lazy var chapterLoader: RDEPUBChapterLoader = {
|
||||
let loader = RDEPUBChapterLoader(context: context)
|
||||
loader.setSummaryDiskCache(summaryDiskCache)
|
||||
return loader
|
||||
}()
|
||||
lazy var pageResolver = RDEPUBPageResolver(context: context, store: chapterRuntimeStore)
|
||||
|
||||
lazy var loadCoordinator = RDEPUBReaderLoadCoordinator(context: context)
|
||||
lazy var paginationCoordinator = RDEPUBReaderPaginationCoordinator(context: context)
|
||||
lazy var locationCoordinator = RDEPUBReaderLocationCoordinator(context: context)
|
||||
@@ -42,9 +51,11 @@ final class RDEPUBReaderRuntime {
|
||||
context.clearActiveSnapshot()
|
||||
context.readingSession = nil
|
||||
context.textBook = nil
|
||||
context.bookPageMap = nil
|
||||
context.activeBookmarks = []
|
||||
context.activeHighlights = []
|
||||
context.searchState = nil
|
||||
clearOnDemandPageModeState()
|
||||
viewportMonitor.resetForReload()
|
||||
annotationCoordinator.updateCurrentSelection(nil)
|
||||
readerView.reloadData()
|
||||
@@ -73,11 +84,26 @@ final class RDEPUBReaderRuntime {
|
||||
return false
|
||||
}
|
||||
|
||||
if context.textBook != nil {
|
||||
guard let location = controller.resolvedTextLocation(forPageNumber: pageNumber) else {
|
||||
if let textBook = context.textBook {
|
||||
guard textBook.page(at: pageNumber) != nil else {
|
||||
return false
|
||||
}
|
||||
return locationCoordinator.restoreReadingLocation(location, animated: animated)
|
||||
readerView.transitionToPage(pageNum: pageNumber - 1, animated: animated)
|
||||
if let location = locationCoordinator.currentVisibleLocation() {
|
||||
context.persist(location: location)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if context.bookPageMap != nil {
|
||||
guard prepareOnDemandChapter(forAbsolutePageNumber: pageNumber) else {
|
||||
return false
|
||||
}
|
||||
readerView.transitionToPage(pageNum: pageNumber - 1, animated: animated)
|
||||
if let location = locationCoordinator.currentVisibleLocation() {
|
||||
context.persist(location: location)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
guard context.activePages.indices.contains(pageNumber - 1) else {
|
||||
@@ -278,6 +304,42 @@ final class RDEPUBReaderRuntime {
|
||||
paginationCoordinator.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
func applyBookPageMap(_ bookPageMap: RDEPUBBookPageMap, restoreLocation: RDEPUBLocation?) {
|
||||
context.textBook = nil
|
||||
context.bookPageMap = bookPageMap
|
||||
context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap))
|
||||
paginationCoordinator.finishPagination(restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
func refreshBookPageMapInPlace(_ bookPageMap: RDEPUBBookPageMap) {
|
||||
guard let readerView = context.readerView,
|
||||
let controller = context.controller else { return }
|
||||
|
||||
let currentPage = max(readerView.currentPage, 0)
|
||||
let currentLocation = locationCoordinator.currentVisibleLocation()
|
||||
context.textBook = nil
|
||||
context.bookPageMap = bookPageMap
|
||||
context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap))
|
||||
|
||||
// 仅刷新总页数,不重建页面内容(避免后台元数据解析期间刷新掉用户选区)
|
||||
readerView.reloadPageCountOnly()
|
||||
|
||||
// 用户正在选区或正在滑动翻页时跳过页面跳转,避免打断交互
|
||||
let cv = readerView.collectionView
|
||||
let isUserInteracting = cv.isTracking || cv.isDragging || cv.isDecelerating
|
||||
if context.currentSelection == nil, !isUserInteracting, bookPageMap.totalPages > 0 {
|
||||
let maxValidPage = max(bookPageMap.totalPages - 1, 0)
|
||||
if currentPage > maxValidPage {
|
||||
readerView.transitionToPage(pageNum: maxValidPage, animated: false)
|
||||
}
|
||||
}
|
||||
if let currentLocation {
|
||||
locationCoordinator.persist(location: currentLocation)
|
||||
} else if let resolvedLocation = controller.resolvedTextLocation(forPageNumber: currentPage + 1) {
|
||||
locationCoordinator.persist(location: resolvedLocation)
|
||||
}
|
||||
}
|
||||
|
||||
/// 完成分页流程并恢复阅读位置
|
||||
func finishPagination(restoreLocation: RDEPUBLocation?) {
|
||||
paginationCoordinator.finishPagination(restoreLocation: restoreLocation)
|
||||
@@ -321,4 +383,175 @@ final class RDEPUBReaderRuntime {
|
||||
) {
|
||||
viewportMonitor.handleViewportChangeIfNeeded(reason: reason, viewportSignature: viewportSignature)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func prepareOnDemandChapter(forAbsolutePageNumber pageNumber: Int) -> Bool {
|
||||
guard let bookPageMap = context.bookPageMap,
|
||||
let publication = context.publication else {
|
||||
return false
|
||||
}
|
||||
let absolutePageIndex = pageNumber - 1
|
||||
guard let spineIndex = bookPageMap.spineIndex(forAbsolutePage: absolutePageIndex) else {
|
||||
return false
|
||||
}
|
||||
|
||||
chapterRuntimeStore.setCurrentChapter(
|
||||
spineIndex: spineIndex,
|
||||
totalSpineCount: publication.spine.count,
|
||||
windowRadius: context.configuration.chapterWindowRadius
|
||||
)
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"prepareOnDemandChapter page=\(pageNumber) absoluteIndex=\(absolutePageIndex) spine=\(spineIndex)"
|
||||
)
|
||||
|
||||
if chapterRuntimeStore.chapterData(for: spineIndex) == nil {
|
||||
do {
|
||||
_ = try chapterLoader.loadChapterSynchronouslyForMigration(
|
||||
spineIndex: spineIndex,
|
||||
store: chapterRuntimeStore
|
||||
)
|
||||
} catch {
|
||||
RDEPUBBackgroundTrace.log("Runtime", "prepareOnDemandChapter FAILED: spine=\(spineIndex) error=\(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
for evictable in chapterRuntimeStore.evictableSpineIndices() {
|
||||
chapterRuntimeStore.evict(spineIndex: evictable)
|
||||
}
|
||||
|
||||
for adjacentSpineIndex in chapterRuntimeStore.windowSpineIndices where adjacentSpineIndex != spineIndex {
|
||||
guard chapterRuntimeStore.chapterData(for: adjacentSpineIndex) == nil else { continue }
|
||||
chapterRuntimeStore.addPrefetchTarget(adjacentSpineIndex)
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"schedule prefetch currentSpine=\(spineIndex) adjacentSpine=\(adjacentSpineIndex)"
|
||||
)
|
||||
chapterLoader.loadChapter(
|
||||
spineIndex: adjacentSpineIndex,
|
||||
store: chapterRuntimeStore,
|
||||
priority: .prefetch
|
||||
) { _ in }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func extendPartialBookPageMapIfNeeded(currentPageNumber: Int, minimumTrailingPages: Int = 2, batchChapterCount: Int = 3) {
|
||||
guard let publication = context.publication,
|
||||
let currentMap = context.bookPageMap,
|
||||
let readerView = context.readerView else {
|
||||
return
|
||||
}
|
||||
|
||||
let buildableSpineIndices = publication.spine.indices.filter {
|
||||
let item = publication.spine[$0]
|
||||
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
|
||||
}
|
||||
guard currentMap.totalChapters < buildableSpineIndices.count else {
|
||||
return
|
||||
}
|
||||
guard currentMap.totalPages - currentPageNumber <= minimumTrailingPages else {
|
||||
return
|
||||
}
|
||||
|
||||
let lastKnownSpineIndex = currentMap.entries.last?.spineIndex ?? -1
|
||||
let nextSpineIndices = buildableSpineIndices.filter { $0 > lastKnownSpineIndex }.prefix(batchChapterCount)
|
||||
guard !nextSpineIndices.isEmpty else {
|
||||
return
|
||||
}
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"extendPartialBookPageMap currentPage=\(currentPageNumber) totalPages=\(currentMap.totalPages) appendSpines=\(Array(nextSpineIndices))"
|
||||
)
|
||||
|
||||
var appendedEntries: [RDEPUBBookPageMapEntry] = []
|
||||
for spineIndex in nextSpineIndices {
|
||||
do {
|
||||
let chapter = try chapterLoader.loadChapterSynchronouslyForMigration(
|
||||
spineIndex: spineIndex,
|
||||
store: chapterRuntimeStore
|
||||
)
|
||||
appendedEntries.append(
|
||||
RDEPUBBookPageMapEntry(
|
||||
spineIndex: chapter.spineIndex,
|
||||
href: chapter.href,
|
||||
title: chapter.title,
|
||||
pageCount: chapter.pages.count,
|
||||
absolutePageStart: 0,
|
||||
fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets
|
||||
)
|
||||
)
|
||||
} catch {
|
||||
RDEPUBBackgroundTrace.log("Runtime", "extendPartialBookPageMap skip spine=\(spineIndex) error=\(error)")
|
||||
}
|
||||
}
|
||||
|
||||
guard !appendedEntries.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
let combinedEntries = (currentMap.entries.map {
|
||||
RDEPUBBookPageMapEntry(
|
||||
spineIndex: $0.spineIndex,
|
||||
href: $0.href,
|
||||
title: $0.title,
|
||||
pageCount: $0.pageCount,
|
||||
absolutePageStart: 0,
|
||||
fragmentOffsets: $0.fragmentOffsets
|
||||
)
|
||||
} + appendedEntries).sorted { $0.spineIndex < $1.spineIndex }
|
||||
|
||||
var absolutePageStart = 0
|
||||
let normalizedEntries = combinedEntries.map { entry -> RDEPUBBookPageMapEntry in
|
||||
let normalized = RDEPUBBookPageMapEntry(
|
||||
spineIndex: entry.spineIndex,
|
||||
href: entry.href,
|
||||
title: entry.title,
|
||||
pageCount: entry.pageCount,
|
||||
absolutePageStart: absolutePageStart,
|
||||
fragmentOffsets: entry.fragmentOffsets
|
||||
)
|
||||
absolutePageStart += entry.pageCount
|
||||
return normalized
|
||||
}
|
||||
|
||||
let newMap = RDEPUBBookPageMap(entries: normalizedEntries)
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"extendPartialBookPageMap applied chapters=\(newMap.totalChapters) pages=\(newMap.totalPages)"
|
||||
)
|
||||
context.bookPageMap = newMap
|
||||
context.replaceActiveSnapshot(makeSnapshot(from: newMap))
|
||||
readerView.reloadData()
|
||||
readerView.transitionToPage(pageNum: max(currentPageNumber - 1, 0), animated: false)
|
||||
}
|
||||
|
||||
func clearOnDemandPageModeState() {
|
||||
chapterRuntimeStore.invalidateAllForSettingsChange()
|
||||
context.bookPageMap = nil
|
||||
}
|
||||
|
||||
private func makeSnapshot(from bookPageMap: RDEPUBBookPageMap) -> RDEPUBReadingSession.PaginationSnapshot {
|
||||
let pages = bookPageMap.entries.flatMap { entry in
|
||||
(0..<entry.pageCount).map { localPageIndex in
|
||||
EPUBPage(
|
||||
spineIndex: entry.spineIndex,
|
||||
chapterIndex: bookPageMap.chapterIndex(forSpineIndex: entry.spineIndex) ?? 0,
|
||||
pageIndexInChapter: localPageIndex,
|
||||
totalPagesInChapter: entry.pageCount,
|
||||
chapterTitle: entry.title,
|
||||
fixedSpread: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
let chapters = bookPageMap.entries.map { entry in
|
||||
EPUBChapterInfo(
|
||||
spineIndex: entry.spineIndex,
|
||||
title: entry.title,
|
||||
pageCount: entry.pageCount
|
||||
)
|
||||
}
|
||||
return (pages, chapters)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +107,12 @@ public struct RDEPUBReaderConfiguration: Equatable {
|
||||
public var fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode
|
||||
/// 文本渲染引擎,默认使用 DTCoreText
|
||||
public var textRenderingEngine: RDEPUBTextRenderingEngine
|
||||
/// 章节按需加载窗口大小(总章节数,包含当前章),默认 3,范围 3...15,偶数自动向上取奇
|
||||
public var onDemandChapterWindowSize: Int
|
||||
/// 后台元数据解析并发数,默认为 CPU 核心数。
|
||||
/// 若 profiling 显示 renderTotalMs ≈ wallClockMs(渲染受限),维持核心数即可;
|
||||
/// 若 writeTotalMs 占比显著(I/O 等待),可试探 cpuCount * 1.25~1.5 以填充 I/O 等待间隙。
|
||||
public var metadataParsingConcurrency: Int
|
||||
|
||||
// MARK: 初始化
|
||||
|
||||
@@ -128,6 +134,8 @@ public struct RDEPUBReaderConfiguration: Equatable {
|
||||
/// - fixedLayoutFit: 固定布局适配模式
|
||||
/// - fixedLayoutSpreadMode: 固定布局跨页模式
|
||||
/// - textRenderingEngine: 文本渲染引擎
|
||||
/// - onDemandChapterWindowSize: 章节按需加载窗口大小(总章节数 3...15,偶数自动向上取奇)
|
||||
/// - metadataParsingConcurrency: 后台元数据解析并发数,默认 CPU 核心数
|
||||
public init(
|
||||
fontSize: CGFloat = 15,
|
||||
lineHeightMultiple: CGFloat = 1.6,
|
||||
@@ -146,7 +154,9 @@ public struct RDEPUBReaderConfiguration: Equatable {
|
||||
darkImageBlendRatio: CGFloat = 0.15,
|
||||
fixedLayoutFit: RDEPUBFixedLayoutFit = .page,
|
||||
fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode = .automatic,
|
||||
textRenderingEngine: RDEPUBTextRenderingEngine = .dtCoreText
|
||||
textRenderingEngine: RDEPUBTextRenderingEngine = .dtCoreText,
|
||||
onDemandChapterWindowSize: Int = 3,
|
||||
metadataParsingConcurrency: Int = ProcessInfo.processInfo.activeProcessorCount
|
||||
) {
|
||||
self.fontSize = fontSize
|
||||
self.lineHeightMultiple = lineHeightMultiple
|
||||
@@ -166,12 +176,25 @@ public struct RDEPUBReaderConfiguration: Equatable {
|
||||
self.fixedLayoutFit = fixedLayoutFit
|
||||
self.fixedLayoutSpreadMode = fixedLayoutSpreadMode
|
||||
self.textRenderingEngine = textRenderingEngine
|
||||
self.onDemandChapterWindowSize = Self.normalizedChapterWindowSize(onDemandChapterWindowSize)
|
||||
self.metadataParsingConcurrency = max(1, metadataParsingConcurrency)
|
||||
}
|
||||
|
||||
/// 默认配置实例,使用所有参数的默认值
|
||||
public static let `default` = RDEPUBReaderConfiguration()
|
||||
}
|
||||
|
||||
extension RDEPUBReaderConfiguration {
|
||||
static func normalizedChapterWindowSize(_ size: Int) -> Int {
|
||||
let clamped = max(3, min(15, size))
|
||||
return clamped % 2 == 0 ? clamped + 1 : clamped
|
||||
}
|
||||
|
||||
var chapterWindowRadius: Int {
|
||||
onDemandChapterWindowSize / 2
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 配置转换
|
||||
|
||||
extension RDEPUBReaderConfiguration {
|
||||
|
||||
@@ -24,7 +24,7 @@ extension RDReaderView {
|
||||
}
|
||||
let contentViewClass = contentViews[identifier]
|
||||
assert(contentViewClass != nil, "请调用register(contentView:contentViewWithReuseIdentifier:)")
|
||||
var contentView = contentViewClass!.init()
|
||||
let contentView = contentViewClass!.init()
|
||||
return contentView
|
||||
}
|
||||
|
||||
|
||||
@@ -742,6 +742,13 @@ public class RDReaderView: UIView {
|
||||
bottomToolView = resolvedBottomChromeView()
|
||||
}
|
||||
|
||||
/// 仅刷新总页数,不重建页面内容(用于后台元数据解析期间避免刷新掉用户选区)
|
||||
public func reloadPageCountOnly() {
|
||||
collectionView.reloadData()
|
||||
topToolView = resolvedTopChromeView()
|
||||
bottomToolView = resolvedBottomChromeView()
|
||||
}
|
||||
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
|
||||
Reference in New Issue
Block a user