6 Commits
Author SHA1 Message Date
shenandshen d20196ee34 feat: configurable chapter window & parallel metadata parsing with benchmark
1. Configurable chapter window size (onDemandChapterWindowSize: 3-15)
   - Parameterized window radius in RDEPUBChapterRuntimeStore
   - Updated RDEPUBChapterWindowCoordinator to use configurable radius
   - RDEPUBChapterWindowSnapshot.from() accepts chapter array instead of fixed prev/next
   - Even numbers round up to odd (4→5), min 3, max 15

2. Configurable metadata parsing concurrency (metadataParsingConcurrency)
   - Default equals CPU core count
   - Parallel execution via OperationQueue in paginateMetadataOnly
   - Each worker creates independent builder instance
   - NSLock protects result aggregation

3. Per-chapter and total wall-clock timing instrumentation
   - Separated render vs I/O timing per chapter
   - Summary log with wallClockMs, renderTotalMs, writeTotalMs, avgRenderMs
   - Timing stored in RDEPUBReaderContext for test access

4. UI automation test infrastructure
   - Added --demo-window-size, --demo-concurrency, --demo-clear-cache launch args
   - DemoReaderState exposes windowSize, parseMs, parseConcurrency
   - ConfigurableWindowTests: 5 test cases for window size 3/5/15
   - ConcurrentParsingTests: 4 test cases for concurrency 2/4
   - MetadataParseBenchmarkTests: serial vs parallel benchmark

5. Bug fixes
   - Fixed page snap-back during background parsing (isUserInteracting check)
   - Reduced BookPageMap refresh frequency from 16 to 32 chapters
   - Moved waitForReadingInteractionToSettle outside operation loop

6. Design doc: dual-layer PageMap (estimated + precise mixed)
2026-06-03 23:38:11 +08:00
shenlei feb05eaf87 feat: complete large-book pagination runtime and UI coverage 2026-06-03 17:47:16 +08:00
shenandshen 584976ac5f docs: 补充大书优化方案的 WXRead 复刻依据与缓存语义
添加 3.0 节记录 WXRead 逆向依据(WRReaderViewController 核心语义),
完善 pageCountCache 失效策略与 key 生成说明,对齐 WXRead 运行时行为。
2026-06-03 07:22:25 +08:00
shenleiandClaude Opus 4.7 9801af05d3 test: 增强 UI 测试基础设施与阅读器自动化测试
- 新增 BookmarkTests/PageNavigationTests/TableOfContentsTests 等阅读器功能测试
- 扩展 AccessibilityIdentifiers 支持更多 UI 元素定位
- XCUIApplication+Launch: 增加启动参数支持(reset state、自定义书籍)
- ReaderAnnotationTests: 完善标注测试覆盖
- ViewController: 支持 --demo-reset-state 参数清理持久化状态
- 新增凡人修仙传测试样本

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 21:17:04 +08:00
shenleiandClaude Opus 4.7 c76beed03c feat(reader): 实现大书快速进入与增量分页路径
- PaginationCoordinator: 新增 textReflowable 快速进入路径,支持增量章节构建
  与 staged 合并策略,避免整书一次性构建的主线程阻塞
- TextBookBuilder: 重构为支持单章构建与增量合并模式
- ChapterPageCounter: 优化分页计数逻辑,支持章节级分页缓存
- PageFrameFactory: 新增 CoreText 页面帧工厂,提升排版性能
- ReaderContext/ReaderRuntime: 适配增量分页状态管理

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 21:16:57 +08:00
shenleiandClaude Opus 4.7 488350d956 docs: 大书优化方案文档与架构分析更新
新增大书优化实施方案(内存与主线程、快速进入阅读器)和 WXRead 内存策略分析文档,
更新架构对比分析文档,完善章节级缓存运行时、串行加载、轻量磁盘摘要等设计细节。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 21:16:50 +08:00
74 changed files with 9565 additions and 2125 deletions
@@ -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 # 架构对比分析:读书 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) 的阶段方案,后续以本文档作为单一真值。 > 本文档已吸收原 [WXRead剩余问题修复计划.md](/Users/shen/Work/Code/ReadViewSDK/Doc/WXRead剩余问题修复计划.md) 的阶段方案,后续以本文档作为单一真值。
> 标注说明: > 标注说明:
@@ -22,10 +22,11 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
- 页面级 hit test / 选区 / 高亮 / 批注 - 页面级 hit test / 选区 / 高亮 / 批注
- 字符锚点与 `fileIndex/row/column` 语义的完整位置模型 - 字符锚点与 `fileIndex/row/column` 语义的完整位置模型
核查结论:在当前约定范围内,ReadViewSDK 已经完成 EPUB 阅读器对 WXRead 文档主链路的复刻。当前文档里不再保留主链路级 `⚠️` 项,剩余仅有两类 核查结论:ReadViewSDK 已经完成 EPUB 阅读器主链路的大部分复刻,但当前代码里仍有少量“能力面已接入、实现路径未完全等价”的差异。和 2026-05-24 版本文档相比,当前最需要重新标注的是
1. `replaceForMPChapter.css` 这类公众号文章专用资源,对 EPUB 主链路不适用 1. 文本分页主路径现已真正消费多栏 path,并补上 `avoidOrphans` / `avoidWidows` / `hyphenation` 行为
2. 繁简转换、TTS / DRM / Pencil 这类已明确排除在本次复刻范围之外的外围能力 2. 代码仍保留若干 fallback / degrade 路径,与 WXRead 的单一主链路实现方式不同
3. `replaceForMPChapter.css`、繁简转换、TTS / DRM / Pencil 仍属于不适用或明确不实现范围
--- ---
@@ -43,11 +44,14 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
- 分页器已经补上 inline footnote attachment 不整段挪页、标题 `keepWithNext``weread-page-relate` 页首借行这几类 WXRead 风格规则。 - 分页器已经补上 inline footnote attachment 不整段挪页、标题 `keepWithNext``weread-page-relate` 页首借行这几类 WXRead 风格规则。
- 章节尾部“仅空白/段落分隔符”的尾页丢弃,以及极短尾页回并已经落地,`宝山辽墓材料与释读` 第 31 页空白问题已修复。 - 章节尾部“仅空白/段落分隔符”的尾页丢弃,以及极短尾页回并已经落地,`宝山辽墓材料与释读` 第 31 页空白问题已修复。
- `RDEPUBTextLayoutConfig` 已补齐到 WXRead 同级配置面:`frameWidth/frameHeight/edgeInsets/numberOfColumns/columnGap/avoidOrphans/avoidWidows/hyphenation`,并已接入分页入口与缓存键。 - `RDEPUBTextLayoutConfig` 已补齐到 WXRead 同级配置面:`frameWidth/frameHeight/edgeInsets/numberOfColumns/columnGap/avoidOrphans/avoidWidows/hyphenation`,并已接入分页入口与缓存键。
- CoreText 分页路径现已消费多栏 layout path,不再把 `numberOfColumns` 只停留在配置层。
- `avoidOrphans``avoidWidows``hyphenation` 现已接入实际排版/分页行为,而不是仅存在于配置模型。
- `RDEPUBChapterData` 已统一承载章节分页结果、位置查询、搜索/高亮回查与目录语义,章节模型主链路已经收口。 - `RDEPUBChapterData` 已统一承载章节分页结果、位置查询、搜索/高亮回查与目录语义,章节模型主链路已经收口。
### ⚠️ 有差异/有问题 ### ⚠️ 有差异/有问题
- 无主链路遗留项;当前仅保留明确不适用或明确不实现的范围说明 - 仍保留降级链路:`RDEPUBDTCoreTextRenderer``DTCoreText` 不可用或构建失败时会退回 HTML 纯文本渲染;`RDURLReaderController` 在纯文本分页失败时会退回 `UITextView` 展示。这说明 ReadViewSDK 仍不是 WXRead 那种完全单一路径的生产态实现
- `RDReaderGestureController` 仍是未接入的占位组件,实际点击分区逻辑直接落在 `RDReaderView`,与 WXRead 更完整的翻页/手势控制器拆分相比,宿主层职责仍稍偏重。
### ❌ 未实现 ### ❌ 未实现
@@ -81,10 +85,10 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
| 标题 keep-with-next | 标题不能孤悬页尾 | 已补 `keepWithNext` 语义与页尾回退 | ✅ 已实现 | | 标题 keep-with-next | 标题不能孤悬页尾 | 已补 `keepWithNext` 语义与页尾回退 | ✅ 已实现 |
| `weread-page-relate` | 页首关联块需要借上一页一行 | 已补页首 `pageRelate` 借行规则 | ✅ 已实现 | | `weread-page-relate` | 页首关联块需要借上一页一行 | 已补页首 `pageRelate` 借行规则 | ✅ 已实现 |
| 章节尾页收口 | 丢弃空白尾页、合并极短尾页 | 已补尾页空白丢弃与超短尾页回并 | ✅ 已实现 | | 章节尾页收口 | 丢弃空白尾页、合并极短尾页 | 已补尾页空白丢弃与超短尾页回并 | ✅ 已实现 |
| 分页配置 | `WRCoreTextLayoutConfig`(含多栏等) | `RDEPUBTextLayoutConfig` 已补齐同级参数,并接入分页入口/缓存键/列路径构建 | ✅ 已实现 | | 分页配置 | `WRCoreTextLayoutConfig`(含多栏等) | `RDEPUBTextLayoutConfig` 已补齐同级参数,并接入多栏 path、孤行/寡行保护与 hyphenation 行为 | ✅ 已实现 |
| 缓存 | 按书籍 + 排版设置缓存 | 已有 `RDEPUBTextBookCache` 磁盘缓存 | ✅ 已实现 | | 缓存 | 按书籍 + 排版设置缓存 | 已有 `RDEPUBTextBookCache` 磁盘缓存 | ✅ 已实现 |
**结论:** 分页主链路已经按 WXRead 收口当前差异不再在分页引擎或分页配置能力面上 **结论:** 分页主链路已经按 WXRead 的主要思路收口当前剩余差异不再集中在分页配置或分页算法本身,而更多落在 fallback 路径和宿主层实现细节
--- ---
@@ -175,6 +179,8 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
| 页面几何模型完全等价 `WRCoreTextLayoutFrame` | ✅ 已实现 | - | | 页面几何模型完全等价 `WRCoreTextLayoutFrame` | ✅ 已实现 | - |
| `WRBookmark` 统一标注模型 | ✅ 已实现 | - | | `WRBookmark` 统一标注模型 | ✅ 已实现 | - |
| 多栏排版 | ✅ 已实现 | - | | 多栏排版 | ✅ 已实现 | - |
| `avoidOrphans / avoidWidows / hyphenation` 行为落地 | ✅ 已实现 | - |
| 纯文本/渲染失败 fallback 清理 | ⚠️ 仍保留纯文本 fallback 路径 | 低 |
| 字体动态加载 | ✅ 已实现 | - | | 字体动态加载 | ✅ 已实现 | - |
| 繁简转换 | ❌ 明确不实现 | - | | 繁简转换 | ❌ 明确不实现 | - |
| TTS / DRM / Pencil | ❌ 明确不实现 | - | | 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. 读书关键类职责速查 ## 10. 读书关键类职责速查
| 类名 | 职责 | ReadViewSDK 对应 | 核查 | | 类名 | 职责 | ReadViewSDK 对应 | 核查 |
+1 -1
View File
@@ -43,7 +43,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS: SPEC CHECKSUMS:
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0 DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
RDReaderView: 54205c82c62ea6c674b43c833642d2643c98c573 RDReaderView: 617ec758a5db3c10024acf83b110465ea8343b5b
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
SSAlertSwift: aad8dc0c20b36fcffe700b81d7be89d60c7ba4f1 SSAlertSwift: aad8dc0c20b36fcffe700b81d7be89d60c7ba4f1
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351 ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
+9 -3
View File
@@ -27,9 +27,15 @@
"ZIPFoundation": [ "ZIPFoundation": [
"~> 0.9" "~> 0.9"
], ],
"DTCoreText": [], "DTCoreText": [
"SnapKit": [],
"SSAlertSwift": [] ],
"SnapKit": [
],
"SSAlertSwift": [
]
}, },
"requires_arc": true, "requires_arc": true,
"swift_version": "5.10" "swift_version": "5.10"
+1 -1
View File
@@ -43,7 +43,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS: SPEC CHECKSUMS:
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0 DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
RDReaderView: 54205c82c62ea6c674b43c833642d2643c98c573 RDReaderView: 617ec758a5db3c10024acf83b110465ea8343b5b
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
SSAlertSwift: aad8dc0c20b36fcffe700b81d7be89d60c7ba4f1 SSAlertSwift: aad8dc0c20b36fcffe700b81d7be89d60c7ba4f1
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351 ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
File diff suppressed because it is too large Load Diff
@@ -7,6 +7,11 @@
objects = { objects = {
/* Begin PBXBuildFile section */ /* 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 */; }; 23BB1155EA379786DAA10A89 /* DisplayTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */; };
3BC5C96D7A0ACF35F2192CC7 /* XCUIApplication+Launch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74B4C44287820D68ED6570F8 /* XCUIApplication+Launch.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 */; }; 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 */; }; C08FF8D030048DC5147729E9 /* ReaderOpenCloseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */; };
DE1437A969DA1C5F0CBB047D /* Pods_ReadViewDemo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 792DF85CE4A3DD80D67843C7 /* Pods_ReadViewDemo.framework */; }; DE1437A969DA1C5F0CBB047D /* Pods_ReadViewDemo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 792DF85CE4A3DD80D67843C7 /* Pods_ReadViewDemo.framework */; };
FEDB5937CEB858CB06E38E2D /* SettingsPanelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201C2B482287866487EFAE66 /* SettingsPanelTests.swift */; }; 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 */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -30,6 +39,11 @@
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
00F495E17B90CCF1C7FE8C27 /* AccessibilityIdentifiers.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AccessibilityIdentifiers.swift; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 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>"; }; 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; }; 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>"; }; 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>"; }; 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; }; 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>"; }; FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DisplayTypeTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@@ -98,6 +116,7 @@
children = ( children = (
00F495E17B90CCF1C7FE8C27 /* AccessibilityIdentifiers.swift */, 00F495E17B90CCF1C7FE8C27 /* AccessibilityIdentifiers.swift */,
74B4C44287820D68ED6570F8 /* XCUIApplication+Launch.swift */, 74B4C44287820D68ED6570F8 /* XCUIApplication+Launch.swift */,
1A2B3C4D00000012AABBCC01 /* DemoReaderState.swift */,
); );
path = Helpers; path = Helpers;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -152,11 +171,19 @@
EACA0D274176D594361EF6B9 /* ReaderUITests */ = { EACA0D274176D594361EF6B9 /* ReaderUITests */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
1A2B3C4D00000002AABBCC01 /* BookmarkTests.swift */,
FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */, FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */,
1A2B3C4D00000008AABBCC01 /* PageNavigationTests.swift */,
20BD15E5D8F04E7E9A239E14 /* ReaderAnnotationTests.swift */, 20BD15E5D8F04E7E9A239E14 /* ReaderAnnotationTests.swift */,
1A2B3C4D0000000AAABBCC01 /* ReaderAnnotationExtendedTests.swift */,
9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */, 9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */,
BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */, BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */,
1A2B3C4D00000006AABBCC01 /* SettingsExtendedTests.swift */,
201C2B482287866487EFAE66 /* SettingsPanelTests.swift */, 201C2B482287866487EFAE66 /* SettingsPanelTests.swift */,
1A2B3C4D00000004AABBCC01 /* TableOfContentsTests.swift */,
1A2B3C4D0000000CAABBCC01 /* ConfigurableWindowTests.swift */,
1A2B3C4D0000000EAABBCC01 /* ConcurrentParsingTests.swift */,
1A2B3C4D00000010AABBCC01 /* MetadataParseBenchmarkTests.swift */,
); );
path = ReaderUITests; path = ReaderUITests;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -309,11 +336,20 @@
files = ( files = (
63B2852E59996922386A3111 /* AccessibilityIdentifiers.swift in Sources */, 63B2852E59996922386A3111 /* AccessibilityIdentifiers.swift in Sources */,
3BC5C96D7A0ACF35F2192CC7 /* XCUIApplication+Launch.swift in Sources */, 3BC5C96D7A0ACF35F2192CC7 /* XCUIApplication+Launch.swift in Sources */,
1A2B3C4D00000011AABBCC01 /* DemoReaderState.swift in Sources */,
1A2B3C4D00000001AABBCC01 /* BookmarkTests.swift in Sources */,
23BB1155EA379786DAA10A89 /* DisplayTypeTests.swift in Sources */, 23BB1155EA379786DAA10A89 /* DisplayTypeTests.swift in Sources */,
1A2B3C4D00000007AABBCC01 /* PageNavigationTests.swift in Sources */,
52A6A0C9941A4B5F9B88C6B0 /* ReaderAnnotationTests.swift in Sources */, 52A6A0C9941A4B5F9B88C6B0 /* ReaderAnnotationTests.swift in Sources */,
1A2B3C4D00000009AABBCC01 /* ReaderAnnotationExtendedTests.swift in Sources */,
C08FF8D030048DC5147729E9 /* ReaderOpenCloseTests.swift in Sources */, C08FF8D030048DC5147729E9 /* ReaderOpenCloseTests.swift in Sources */,
4509ED928F228F43888E063D /* ReaderToolbarTests.swift in Sources */, 4509ED928F228F43888E063D /* ReaderToolbarTests.swift in Sources */,
1A2B3C4D00000005AABBCC01 /* SettingsExtendedTests.swift in Sources */,
FEDB5937CEB858CB06E38E2D /* SettingsPanelTests.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; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -344,6 +380,7 @@
CODE_SIGN_STYLE = Manual; CODE_SIGN_STYLE = Manual;
DEVELOPMENT_TEAM = ""; DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = MG4Z7FU83W; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = MG4Z7FU83W;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.6; IPHONEOS_DEPLOYMENT_TARGET = 15.6;
PRODUCT_BUNDLE_IDENTIFIER = cn.shen.ReadViewDemoUITests; PRODUCT_BUNDLE_IDENTIFIER = cn.shen.ReadViewDemoUITests;
@@ -368,6 +405,7 @@
CODE_SIGN_STYLE = Manual; CODE_SIGN_STYLE = Manual;
DEVELOPMENT_TEAM = ""; DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = MG4Z7FU83W; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = MG4Z7FU83W;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.6; IPHONEOS_DEPLOYMENT_TARGET = 15.6;
PRODUCT_BUNDLE_IDENTIFIER = cn.shen.ReadViewDemoUITests; PRODUCT_BUNDLE_IDENTIFIER = cn.shen.ReadViewDemoUITests;
+51 -480
View File
@@ -12,8 +12,12 @@ final class ViewController: UIViewController {
let bookTitleQuery: String let bookTitleQuery: String
let displayType: RDReaderView.DisplayType? let displayType: RDReaderView.DisplayType?
let pageNumber: Int? let pageNumber: Int?
let resetsReaderState: Bool
let displaySequence: [RDReaderView.DisplayType] let displaySequence: [RDReaderView.DisplayType]
let stepDelay: TimeInterval let stepDelay: TimeInterval
let windowSize: Int?
let concurrency: Int?
let clearsCache: Bool
nonisolated private static func parseDisplayType(_ rawValue: String) -> RDReaderView.DisplayType? { nonisolated private static func parseDisplayType(_ rawValue: String) -> RDReaderView.DisplayType? {
switch rawValue.lowercased() { switch rawValue.lowercased() {
@@ -43,92 +47,30 @@ final class ViewController: UIViewController {
let displayType = value(after: "--demo-display-type").flatMap(parseDisplayType) let displayType = value(after: "--demo-display-type").flatMap(parseDisplayType)
let pageNumber = value(after: "--demo-page").flatMap(Int.init) let pageNumber = value(after: "--demo-page").flatMap(Int.init)
let resetsReaderState = arguments.contains("--demo-reset-state")
let displaySequence = value(after: "--demo-display-sequence") let displaySequence = value(after: "--demo-display-sequence")
.map { raw in .map { raw in
raw.split(separator: ",").compactMap { parseDisplayType(String($0)) } raw.split(separator: ",").compactMap { parseDisplayType(String($0)) }
} ?? [] } ?? []
let stepDelay = value(after: "--demo-step-delay").flatMap(TimeInterval.init) ?? 1.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( return LaunchAutomationPlan(
bookTitleQuery: bookTitleQuery, bookTitleQuery: bookTitleQuery,
displayType: displayType, displayType: displayType,
pageNumber: pageNumber, pageNumber: pageNumber,
resetsReaderState: resetsReaderState,
displaySequence: displaySequence, 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 = { private let statusLabel: UILabel = {
let label = UILabel() let label = UILabel()
label.numberOfLines = 0 label.numberOfLines = 0
@@ -160,15 +102,9 @@ final class ViewController: UIViewController {
}() }()
private var books: [DemoBook] = [] private var books: [DemoBook] = []
private var validationSummary: ResourceValidationSummary?
private var validationTask: Task<Void, Never>?
private let launchAutomationPlan = LaunchAutomationPlan.parse(arguments: ProcessInfo.processInfo.arguments) private let launchAutomationPlan = LaunchAutomationPlan.parse(arguments: ProcessInfo.processInfo.arguments)
private var didRunLaunchAutomation = false private var didRunLaunchAutomation = false
deinit {
validationTask?.cancel()
}
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
view.backgroundColor = .systemBackground view.backgroundColor = .systemBackground
@@ -209,7 +145,6 @@ final class ViewController: UIViewController {
books = discoverBooks() books = discoverBooks()
tableView.reloadData() tableView.reloadData()
updateChrome() updateChrome()
validateSampleBooks()
} }
private func updateChrome() { private func updateChrome() {
@@ -217,14 +152,7 @@ final class ViewController: UIViewController {
statusLabel.text = "ReadViewSDK Demo\nbook 目录下未找到 txt 或 epub 文件" statusLabel.text = "ReadViewSDK Demo\nbook 目录下未找到 txt 或 epub 文件"
tableView.backgroundView = emptyStateLabel tableView.backgroundView = emptyStateLabel
} else { } else {
var lines = [ statusLabel.text = "ReadViewSDK Demo\n已发现 \(books.count) 本本地图书,点击即可进入阅读器"
"ReadViewSDK Demo",
"已发现 \(books.count) 本本地图书,点击即可进入阅读器"
]
if let validationSummary {
lines.append(validationSummary.statusText)
}
statusLabel.text = lines.joined(separator: "\n")
tableView.backgroundView = nil 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 @discardableResult
private func openBook( private func openBook(
_ book: DemoBook, _ book: DemoBook,
@@ -680,27 +235,6 @@ final class ViewController: UIViewController {
return controller 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() { private func runLaunchAutomationIfNeeded() {
guard !didRunLaunchAutomation, guard !didRunLaunchAutomation,
let launchAutomationPlan, let launchAutomationPlan,
@@ -714,13 +248,50 @@ final class ViewController: UIViewController {
return return
} }
if launchAutomationPlan.resetsReaderState {
resetPersistedReaderState()
}
if launchAutomationPlan.clearsCache {
clearChapterSummaryCache()
}
var configuration = RDEPUBReaderConfiguration.default var configuration = RDEPUBReaderConfiguration.default
if let displayType = launchAutomationPlan.displayType { if let displayType = launchAutomationPlan.displayType {
configuration.displayType = 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) _ = 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 { extension ViewController: UITableViewDataSource {
@@ -5,7 +5,491 @@
它用于覆盖 RDURLReaderController 与 RDPlainTextBookBuilder 的 TXT 路径, 它用于覆盖 RDURLReaderController 与 RDPlainTextBookBuilder 的 TXT 路径,
确认纯文本分页、打开主流程,以及后续 reader 壳层回归不会失效。 确认纯文本分页、打开主流程,以及后续 reader 壳层回归不会失效。
为了让 UI 自动化可以稳定验证翻页、长按选区、书签和目录行为,
这个样本会刻意保持为“可稳定分页”的长度,而不是一屏就显示完的短文本。
第一节 多页验证
阅读器在处理纯文本时,会根据当前设备尺寸、字体大小、行距和内边距进行重新分页。
这意味着同一份文本在不同设备上看到的“第 2 页、第 3 页”内容可能略有差异,
但只要整体篇幅足够长,跳转到后续页码、执行选区手势、验证状态标签,就依然具备稳定性。
第二节 自动化约束
自动化测试并不追求复杂叙事,它更在意内容是否连续、段落是否足够、字符分布是否均匀。
因此这里使用连续的中文段落,让文本布局更接近真实阅读场景,也方便测试长按选词时命中正文区域。
第三节 翻页前提
如果样本文本过短,阅读器即使工作正常,也只能停留在第一页。
这会让依赖 page=2 的测试误以为是“启动参数失效”,实际上是目标页根本不存在。
为了避免这种误判,样本内容应该明确保证至少能覆盖数页。
第四节 文本密度
当前章节继续补充说明:段落长度适中、标点分布自然、句子结构重复但不完全相同。
这样既能帮助验证分页器的稳定性,也能帮助标注测试在不同屏幕密度下复现相近的触控位置。
第五节 选区交互
长按后拖拽选区,通常依赖可选中的连续文字块。
如果页面上只有很少文字,或者文字被标题、空白和分隔线稀释,选区命中率会显著下降。
因此这里增加多个连续段落,让第二页和后续页面都具备足够的正文密度。
第六节 书签行为
书签测试需要在当前位置创建标记,再验证底部工具栏中的书签列表入口是否可用。
如果页面状态更新稍慢,测试就需要等待按钮从“出现”切换到“可点击”。
这类状态同步问题和可访问性问题不同,但都应该被自动化显式观察到。
第七节 目录与章节
虽然 TXT 没有像 EPUB 那样的正式目录树,但阅读器仍然会以章节标题和文本位置组织阅读内容。
在更复杂的样本书中,目录面板与书签面板都是重要的回归点,因此本样本也会保留明显的章节与小节结构。
第二章 稳定性 第二章 稳定性
如果这个样本能被成功发现、分页并进入阅读器, 如果这个样本能被成功发现、分页并进入阅读器,
就说明 TXT 支持仍然保留在当前收敛后的架构中。 就说明 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 readerBottomToolbar = "epub.reader.bottomToolbar"
static let readerContent = "epub.reader.content" static let readerContent = "epub.reader.content"
static let readerPaging = "epub.reader.paging" 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 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 readerSelectionText = "epub.reader.selection.text"
static let readerSelectionHighlight = "epub.reader.selection.高亮" static let readerSelectionHighlight = "epub.reader.selection.高亮"
static let settingsScroll = "epub.reader.settings.scroll" static let settingsScroll = "epub.reader.settings.scroll"
static let settingsBrightness = "epub.reader.settings.brightness"
static let settingsFontIncrease = "epub.reader.settings.font.increase" static let settingsFontIncrease = "epub.reader.settings.font.increase"
static let settingsFontDecrease = "epub.reader.settings.font.decrease" static let settingsFontDecrease = "epub.reader.settings.font.decrease"
static let settingsFontValue = "epub.reader.settings.font.value" static let settingsFontValue = "epub.reader.settings.font.value"
static let settingsFontChoice = "epub.reader.settings.font.choice" 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 let settingsDone = "epub.reader.settings.done"
static func settingsTheme(_ index: Int) -> String { "epub.reader.settings.theme.\(index)" } 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 import XCTest
extension XCUIApplication { extension XCUIApplication {
func launchAndOpenSampleBook(displayType: String? = nil, pageNumber: Int? = nil) { func launchAndOpenSampleBook(
var args = ["--demo-book-title", "回归验证样本"] 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 { if let displayType {
args += ["--demo-display-type", displayType] args += ["--demo-display-type", displayType]
} }
if let pageNumber { if let pageNumber {
args += ["--demo-page", "\(pageNumber)"] args += ["--demo-page", "\(pageNumber)"]
} }
if let windowSize {
args += ["--demo-window-size", "\(windowSize)"]
}
if let concurrency {
args += ["--demo-concurrency", "\(concurrency)"]
}
launchArguments = args launchArguments = args
launch() launch()
} }
@@ -17,10 +37,17 @@ extension XCUIApplication {
func waitForReader(timeout: TimeInterval = 12) -> XCUIElement { func waitForReader(timeout: TimeInterval = 12) -> XCUIElement {
let state = staticTexts[IDs.demoReaderState] let state = staticTexts[IDs.demoReaderState]
XCTAssertTrue(state.waitForExistence(timeout: timeout), "阅读器状态标签未出现") 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 return state
} }
func waitForReaderPage(_ pageNumber: Int, timeout: TimeInterval = 12) {
_ = waitForDemoReaderState(timeout: timeout, description: "page=\(pageNumber)") { state in
state.page == pageNumber
}
}
func showReaderChromeIfNeeded() { func showReaderChromeIfNeeded() {
if buttons[IDs.readerBack].exists && buttons[IDs.readerSettings].exists { if buttons[IDs.readerBack].exists && buttons[IDs.readerSettings].exists {
return 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) { func waitForReaderState(containing expectedText: String, timeout: TimeInterval = 8) {
let state = staticTexts[IDs.demoReaderState] let state = staticTexts[IDs.demoReaderState]
let deadline = Date().addingTimeInterval(timeout) let deadline = Date().addingTimeInterval(timeout)
var lastLabel = "<missing>"
while Date() < deadline { while Date() < deadline {
if state.exists && state.label.contains(expectedText) { if state.exists {
lastLabel = state.label
if lastLabel.contains(expectedText) {
return return
} }
}
RunLoop.current.run(until: Date().addingTimeInterval(0.1)) RunLoop.current.run(until: Date().addingTimeInterval(0.1))
} }
let currentLabel = state.exists ? state.label : "<missing>" XCTFail("阅读器状态未包含 \(expectedText),当前:\(lastLabel)")
XCTFail("阅读器状态未包含 \(expectedText),当前:\(currentLabel)")
} }
} }
@@ -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() { func testSelectionMenuCreatesHighlight() {
app.launchAndOpenSampleBook(pageNumber: 2) app.launchAndOpenSampleBook(pageNumber: 2)
app.waitForReader() app.waitForReader()
app.waitForReaderPage(2)
app.hideReaderChromeIfNeeded()
let content = app.otherElements[IDs.readerSelectionText].firstMatch let content = app.otherElements[IDs.readerSelectionText].firstMatch
XCTAssertTrue(content.waitForExistence(timeout: 5), "阅读内容区域未出现") XCTAssertTrue(content.waitForExistence(timeout: 5), "阅读内容区域未出现")
let start = content.coordinate(withNormalizedOffset: CGVector(dx: 0.35, dy: 0.42)) app.waitForReaderState(containing: "toolbar=hidden", timeout: 5)
let end = content.coordinate(withNormalizedOffset: CGVector(dx: 0.68, dy: 0.42))
start.press(forDuration: 0.6, thenDragTo: end) 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) app.waitForReaderState(containing: "selection=1", timeout: 5)
@@ -36,15 +40,19 @@ final class ReaderAnnotationTests: XCTestCase {
func testLongPressSelectionDoesNotShowToolbars() { func testLongPressSelectionDoesNotShowToolbars() {
app.launchAndOpenSampleBook(pageNumber: 2) app.launchAndOpenSampleBook(pageNumber: 2)
app.waitForReader() app.waitForReader()
app.waitForReaderPage(2)
app.hideReaderChromeIfNeeded()
let content = app.otherElements[IDs.readerSelectionText].firstMatch let content = app.otherElements[IDs.readerSelectionText].firstMatch
XCTAssertTrue(content.waitForExistence(timeout: 5), "阅读内容区域未出现") 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) 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 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")) { for item in spine where item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml")) {
guard let html = htmlString(forRelativePath: item.href) else { guard let html = htmlString(forRelativePath: item.href) else {
continue continue
} }
if html.range(of: interactivePattern, options: [.regularExpression, .caseInsensitive]) != nil { if containsInteractiveMarkup(html) {
return true return true
} }
} }
return false 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
}
} }
@@ -94,12 +94,98 @@ public final class RDEPUBTextBookBuilder {
let cachedPagination = cacheCoordinator.load(key: cacheKey) let cachedPagination = cacheCoordinator.load(key: cacheKey)
for (spineIndex, item) in publication.spine.enumerated() where item.linear { for (spineIndex, item) in publication.spine.enumerated() where item.linear {
guard item.mediaType.contains("html") || item.mediaType.contains("xhtml"), 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)
print("[PaginationDebug] absPage=\(page.absolutePageIndex + 1) localPage=\(page.pageIndexInChapter + 1) range=\(NSStringFromRange(page.contentRange)) break=\(page.metadata.breakReason.rawValue) preview=\(preview)")
for note in page.metadata.diagnostics.prefix(4) {
print("[PaginationDebug] note=\(note)")
}
}
}
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)
sampler.totalBuildDuration = CFAbsoluteTimeGetCurrent() - buildStart
//
cacheCoordinator.save(chapters: chapters, key: cacheKey)
print(sampler.summary())
lastBuildPerformanceSamples = sampler.samples
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 { let rawHTML = parser.htmlString(forRelativePath: item.href) else {
continue return nil
} }
//
let chapterTitle = resolvedChapterTitle(for: item, toc: publication.tableOfContents) let chapterTitle = resolvedChapterTitle(for: item, toc: publication.tableOfContents)
let request = RDEPUBTextTypesetterPipeline().makeRequest( let request = RDEPUBTextTypesetterPipeline().makeRequest(
from: RDEPUBTypesettingInput( from: RDEPUBTypesettingInput(
@@ -115,34 +201,27 @@ public final class RDEPUBTextBookBuilder {
) )
).request ).request
// HTML NSAttributedString
let renderStart = CFAbsoluteTimeGetCurrent() let renderStart = CFAbsoluteTimeGetCurrent()
let rendered = try renderPipeline.render(request) let rendered = try renderPipeline.render(request)
let renderDuration = CFAbsoluteTimeGetCurrent() - renderStart let renderDuration = CFAbsoluteTimeGetCurrent() - renderStart
lastBuildResourceDiagnostics.append(contentsOf: rendered.resourceDiagnostics)
let plainText = rendered.attributedString.string.trimmingCharacters(in: .whitespacesAndNewlines) let plainText = rendered.attributedString.string.trimmingCharacters(in: .whitespacesAndNewlines)
if item.href.lowercased().contains("cover") { 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))") 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 shouldSkipChapter(item: item, content: rendered.attributedString, text: plainText) {
if item.href.lowercased().contains("cover") { if item.href.lowercased().contains("cover") {
print("[EPUB][Cover] skipped href=\(item.href)") print("[EPUB][Cover] skipped href=\(item.href)")
} }
continue return nil
} }
let chapterIndex = chapters.count
let content = NSMutableAttributedString(attributedString: rendered.attributedString) let content = NSMutableAttributedString(attributedString: rendered.attributedString)
//
let paginateStart = CFAbsoluteTimeGetCurrent() let paginateStart = CFAbsoluteTimeGetCurrent()
let layoutFrames: [RDEPUBTextLayoutFrame] let layoutFrames: [RDEPUBTextLayoutFrame]
let isCacheHit: Bool let isCacheHit: Bool
if isAttachmentOnlyCoverChapter(item: item, content: content, plainText: plainText) { if isAttachmentOnlyCoverChapter(item: item, content: content, plainText: plainText) {
//
layoutFrames = [ layoutFrames = [
RDEPUBTextLayoutFrame( RDEPUBTextLayoutFrame(
contentRange: NSRange(location: 0, length: content.length), contentRange: NSRange(location: 0, length: content.length),
@@ -163,7 +242,6 @@ public final class RDEPUBTextBookBuilder {
] ]
isCacheHit = false isCacheHit = false
} else if let cached = cachedPagination?[item.href] { } else if let cached = cachedPagination?[item.href] {
// 使 CoreText
layoutFrames = cached.pageRanges.enumerated().map { idx, range in layoutFrames = cached.pageRanges.enumerated().map { idx, range in
let breakReason = idx < cached.breakReasons.count ? cached.breakReasons[idx] : .frameLimit let breakReason = idx < cached.breakReasons.count ? cached.breakReasons[idx] : .frameLimit
return RDEPUBTextLayoutFrame( return RDEPUBTextLayoutFrame(
@@ -176,12 +254,15 @@ public final class RDEPUBTextBookBuilder {
semanticHints: cached.semanticHints, semanticHints: cached.semanticHints,
attachmentPlacements: [], attachmentPlacements: [],
trailingFragmentID: nil, trailingFragmentID: nil,
diagnostics: ["page break: \(breakReason.rawValue)", "page range: \(NSStringFromRange(range))", "source: cache hit"] diagnostics: [
"page break: \(breakReason.rawValue)",
"page range: \(NSStringFromRange(range))",
"source: cache hit"
]
) )
} }
isCacheHit = true isCacheHit = true
} else { } else {
// CoreText
layoutFrames = content.length > 0 layoutFrames = content.length > 0
? paginationPipeline.frames( ? paginationPipeline.frames(
for: content, for: content,
@@ -192,9 +273,8 @@ public final class RDEPUBTextBookBuilder {
: [] : []
isCacheHit = false isCacheHit = false
} }
let paginateDuration = CFAbsoluteTimeGetCurrent() - paginateStart
// let paginateDuration = CFAbsoluteTimeGetCurrent() - paginateStart
let normalizedFrames = tailNormalizer.normalize( let normalizedFrames = tailNormalizer.normalize(
layoutFrames, layoutFrames,
content: content, content: content,
@@ -219,31 +299,16 @@ public final class RDEPUBTextBookBuilder {
) )
] ]
: normalizedFrames : normalizedFrames
if item.href.lowercased().contains("cover") { if item.href.lowercased().contains("cover") {
print("[EPUB][Cover] paginated href=\(item.href) pages=\(effectiveFrames.count) firstRange=\(effectiveFrames.first.map { NSStringFromRange($0.contentRange) } ?? "none")") 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 chapterAttributedContent = content.copy() as! NSAttributedString
let pages = effectiveFrames.enumerated().map { localPageIndex, frame in let pages = effectiveFrames.enumerated().map { localPageIndex, frame in
let range = frame.contentRange let range = frame.contentRange
return RDEPUBTextPage( return RDEPUBTextPage(
absolutePageIndex: flatPages.count + localPageIndex, absolutePageIndex: absolutePageStartIndex + localPageIndex,
chapterIndex: chapterIndex, chapterIndex: chapterIndex,
spineIndex: spineIndex, spineIndex: spineIndex,
href: item.href, href: item.href,
@@ -259,20 +324,7 @@ public final class RDEPUBTextBookBuilder {
) )
} }
if isPaginationDebugEnabled, let chapter = RDEPUBTextChapter(
item.href.contains("Chapter_3.xhtml") {
print("[PaginationDebug] href=\(item.href) pages=\(pages.count)")
for page in pages {
let preview = debugPreview(for: page.content, limit: 36)
print("[PaginationDebug] absPage=\(page.absolutePageIndex + 1) localPage=\(page.pageIndexInChapter + 1) range=\(NSStringFromRange(page.contentRange)) break=\(page.metadata.breakReason.rawValue) preview=\(preview)")
for note in page.metadata.diagnostics.prefix(4) {
print("[PaginationDebug] note=\(note)")
}
}
}
chapters.append(
RDEPUBTextChapter(
chapterIndex: chapterIndex, chapterIndex: chapterIndex,
spineIndex: spineIndex, spineIndex: spineIndex,
href: item.href, href: item.href,
@@ -282,28 +334,27 @@ public final class RDEPUBTextBookBuilder {
pageBreakReasons: pages.map(\.metadata.breakReason), pageBreakReasons: pages.map(\.metadata.breakReason),
pages: pages pages: pages
) )
let performanceSample = RDEPUBTextPerformanceSample(
chapterHref: item.href,
renderDuration: renderDuration,
paginateDuration: paginateDuration,
pageCount: effectiveFrames.count,
attributedStringLength: content.length,
cacheHit: isCacheHit
) )
lastBuildPaginationDiagnostics.append( let diagnostic = diagnosticsReporter.chapterDiagnostic(
diagnosticsReporter.chapterDiagnostic(
href: item.href, href: item.href,
title: chapterTitle, title: chapterTitle,
pages: pages pages: pages
) )
return RDEPUBTextChapterBuildResult(
chapter: chapter,
resourceDiagnostics: rendered.resourceDiagnostics,
paginationDiagnostic: diagnostic,
performanceSample: performanceSample,
cacheHit: isCacheHit
) )
flatPages.append(contentsOf: pages)
}
let book = RDEPUBTextBook(chapters: chapters, pages: flatPages)
sampler.totalBuildDuration = CFAbsoluteTimeGetCurrent() - buildStart
//
cacheCoordinator.save(chapters: chapters, key: cacheKey)
print(sampler.summary())
lastBuildPerformanceSamples = sampler.samples
return book
} }
// MARK: - // MARK: -
@@ -21,6 +21,17 @@ public struct RDEPUBTextChapterPaginationDiagnostic: Equatable {
public var sampleNotes: [String] 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: - // MARK: -
/// ///
@@ -180,4 +191,3 @@ public struct RDEPUBTextBook {
return chapterData.location(forPage: page, bookIdentifier: bookIdentifier) return chapterData.location(forPage: page, bookIdentifier: bookIdentifier)
} }
} }
@@ -20,9 +20,8 @@ struct RDEPUBChapterPageCounter {
private let pageSize: CGSize private let pageSize: CGSize
private let config: RDEPUBTextLayoutConfig private let config: RDEPUBTextLayoutConfig
private let pageBreakPolicy: RDEPUBPageBreakPolicy private let pageBreakPolicy: RDEPUBPageBreakPolicy
/// CoreText
private let framesetter: CTFramesetter private let framesetter: CTFramesetter
/// DTCoreText /// DTCoreText
private let dtLayoutRect: CGRect private let dtLayoutRect: CGRect
@@ -59,27 +58,26 @@ struct RDEPUBChapterPageCounter {
var frames: [RDEPUBTextLayoutFrame] = [] var frames: [RDEPUBTextLayoutFrame] = []
var location = 0 var location = 0
let resolvedSize = config.resolvedFrameSize(fallback: pageSize) let resolvedSize = config.resolvedFrameSize(fallback: pageSize)
let usableWidth = resolvedSize.width - config.edgeInsets.left - config.edgeInsets.right let contentRect = config.contentRect(fallback: resolvedSize)
let usableHeight = resolvedSize.height - config.edgeInsets.top - config.edgeInsets.bottom
guard usableWidth > 0, usableHeight > 0 else { guard contentRect.width > 0, contentRect.height > 0 else {
return [] return []
} }
while location < attributedString.length { while location < attributedString.length {
let framePath = CGMutablePath() let framePath = RDEPUBCoreTextPageFrameFactory.makeLayoutPath(
let pageRect = CGRect( pageSize: resolvedSize,
x: config.edgeInsets.left, config: config
y: config.edgeInsets.bottom,
width: usableWidth,
height: usableHeight
) )
framePath.addRect(pageRect) let frame = CTFramesetterCreateFrame(
let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(location, 0), framePath, nil) framesetter,
let proposedRange = proposedRangeUsingWXReadPageCount( CFRangeMake(location, 0),
framePath,
nil
)
let proposedRange = proposedVisibleRange(
from: frame, from: frame,
start: location, start: location,
usableHeight: usableHeight,
totalLength: attributedString.length totalLength: attributedString.length
) )
guard proposedRange.length > 0 else { guard proposedRange.length > 0 else {
@@ -87,12 +85,18 @@ struct RDEPUBChapterPageCounter {
} }
let avoidAdjusted = factory.trimmedRangeForAvoidPageBreakInside(from: frame, proposed: proposedRange) let avoidAdjusted = factory.trimmedRangeForAvoidPageBreakInside(from: frame, proposed: proposedRange)
let keepWithNextAdjusted = factory.trimmedRangeForKeepWithNext(from: frame, proposed: avoidAdjusted)
let lineRanges = RDEPUBCoreTextPageFrameFactory.lineRanges(from: frame) let lineRanges = RDEPUBCoreTextPageFrameFactory.lineRanges(from: frame)
let widowOrphanAdjusted = factory.trimmedRangeForWidowAndOrphanControl(
proposed: keepWithNextAdjusted,
lineRanges: lineRanges
)
let effectiveLineRanges = lineRangesWithinRange(lineRanges, range: widowOrphanAdjusted)
let adjusted = pageBreakPolicy.adjustedRange( let adjusted = pageBreakPolicy.adjustedRange(
from: avoidAdjusted, from: widowOrphanAdjusted,
totalLength: attributedString.length, totalLength: attributedString.length,
lineRanges: lineRanges, lineRanges: effectiveLineRanges,
factory: factory factory: factory
) )
let trailingFragmentID = factory.nearestTrailingFragmentID( let trailingFragmentID = factory.nearestTrailingFragmentID(
@@ -142,6 +146,10 @@ struct RDEPUBChapterPageCounter {
var frames: [RDEPUBTextLayoutFrame] = [] var frames: [RDEPUBTextLayoutFrame] = []
var location = 0 var location = 0
let pageRect = dtLayoutRect 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 { while location < attributedString.length {
guard let layoutFrame = layouter.layoutFrame(with: pageRect, range: NSRange(location: location, length: 0)) else { 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 avoidAdjusted = factory.trimmedRangeForAvoidPageBreakInside(from: layoutFrame, proposed: proposedRange)
let lineAdjusted = factory.trimmedRangeForKeepWithNext(from: layoutFrame, proposed: avoidAdjusted) let lineAdjusted = factory.trimmedRangeForKeepWithNext(from: layoutFrame, proposed: avoidAdjusted)
let lineRanges = RDEPUBCoreTextPageFrameFactory.lineRanges(from: layoutFrame) 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( let adjusted = pageBreakPolicy.adjustedRange(
from: lineAdjusted, from: widowOrphanAdjusted,
totalLength: attributedString.length, totalLength: attributedString.length,
lineRanges: lineRanges, lineRanges: effectiveLineRanges,
factory: factory factory: factory
) )
let verifiedRange: NSRange let verifiedRange: NSRange
@@ -231,40 +254,29 @@ struct RDEPUBChapterPageCounter {
// MARK: - WXRead // MARK: - WXRead
/// WXRead `WRChapterPageCount.recalculatePageRangesForAttributedString` /// CoreText frame path
private func proposedRangeUsingWXReadPageCount( private func proposedVisibleRange(
from frame: CTFrame, from frame: CTFrame,
start location: Int, start location: Int,
usableHeight: CGFloat,
totalLength: Int totalLength: Int
) -> NSRange { ) -> NSRange {
let lines = CTFrameGetLines(frame) as! [CTLine] let visibleRange = CTFrameGetVisibleStringRange(frame)
guard !lines.isEmpty else { guard visibleRange.length > 0 else {
return NSRange(location: location, length: 0) return NSRange(location: location, length: 0)
} }
var origins = [CGPoint](repeating: .zero, count: lines.count) let resolvedLocation = max(location, visibleRange.location)
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins) let visibleEnd = visibleRange.location + visibleRange.length
let length = min(max(visibleEnd - resolvedLocation, 0), totalLength - resolvedLocation)
var pageCharCount = 0 guard length > 0 else {
for (index, line) in lines.enumerated() { return NSRange(location: resolvedLocation, length: 0)
let lineRange = CTLineGetStringRange(line) }
let lineY = origins[index].y return NSRange(location: resolvedLocation, length: length)
var ascent: CGFloat = 0
var descent: CGFloat = 0
CTLineGetTypographicBounds(line, &ascent, &descent, nil)
if lineY - ascent > usableHeight {
break
} }
pageCharCount += lineRange.length private func lineRangesWithinRange(_ lineRanges: [NSRange], range: NSRange) -> [NSRange] {
lineRanges.filter { lineRange in
lineRange.location >= range.location && NSMaxRange(lineRange) <= NSMaxRange(range)
} }
if pageCharCount == 0 {
pageCharCount = 1
}
return NSRange(location: location, length: min(pageCharCount, totalLength - location))
} }
} }
@@ -146,6 +146,12 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
let endLocation = lastValidLine.location + lastValidLine.length let endLocation = lastValidLine.location + lastValidLine.length
let adjustedLength = endLocation - proposed.location let adjustedLength = endLocation - proposed.location
guard adjustedLength > 0 else { return proposed } 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) return NSRange(location: proposed.location, length: adjustedLength)
} }
@@ -180,9 +186,47 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
let endLocation = lastValidLine.location + lastValidLine.length let endLocation = lastValidLine.location + lastValidLine.length
let adjustedLength = endLocation - proposed.location let adjustedLength = endLocation - proposed.location
guard adjustedLength > 0 else { return proposed } 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) 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: - // MARK: -
/// CTFrame /// CTFrame
@@ -263,6 +307,107 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) } .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] { func attachmentKinds(in range: NSRange) -> [RDEPUBTextAttachmentKind] {
guard let safeRange = clampedRange(range), safeRange.length > 0 else { guard let safeRange = clampedRange(range), safeRange.length > 0 else {
@@ -41,7 +41,11 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
let attributedString = NSMutableAttributedString(attributedString: rendered) let attributedString = NSMutableAttributedString(attributedString: rendered)
RDEPUBSemanticMarkerInjector.applyPaginationSemantics(in: attributedString) RDEPUBSemanticMarkerInjector.applyPaginationSemantics(in: attributedString)
let fragmentOffsets = RDEPUBFragmentMarkerInjector.extractFragmentOffsets(from: 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( return RDEPUBRenderedChapterContent(
attributedString: attributedString, attributedString: attributedString,
fragmentOffsets: fragmentOffsets, fragmentOffsets: fragmentOffsets,
@@ -76,7 +80,11 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
let attributedString = RDEPUBTextRendererSupport.fallbackAttributedString(for: request.context.html, style: request.style) let attributedString = RDEPUBTextRendererSupport.fallbackAttributedString(for: request.context.html, style: request.style)
RDEPUBSemanticMarkerInjector.applyPaginationSemantics(in: attributedString) RDEPUBSemanticMarkerInjector.applyPaginationSemantics(in: attributedString)
let fragmentOffsets = RDEPUBFragmentMarkerInjector.extractFragmentOffsets(from: 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( return RDEPUBRenderedChapterContent(
attributedString: attributedString, attributedString: attributedString,
fragmentOffsets: fragmentOffsets, fragmentOffsets: fragmentOffsets,
@@ -104,7 +104,11 @@ enum RDEPUBTextRendererSupport {
// MARK: - RDEPUBDTCoreTextRenderer // 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) let fullRange = NSRange(location: 0, length: attributedString.length)
var blockIndex = 0 var blockIndex = 0
let sourceText = attributedString.string as NSString 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) let paragraph = (attributes[.paragraphStyle] as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle ?? paragraphStyle(lineSpacing: style.lineSpacing)
paragraph.lineSpacing = style.lineSpacing paragraph.lineSpacing = style.lineSpacing
paragraph.paragraphSpacing = max(paragraph.paragraphSpacing, style.lineSpacing / 2) paragraph.paragraphSpacing = max(paragraph.paragraphSpacing, style.lineSpacing / 2)
paragraph.hyphenationFactor = layoutConfig.hyphenation ? 1.0 : 0.0
var updatedAttributes = attributes var updatedAttributes = attributes
updatedAttributes[.font] = normalizedFont updatedAttributes[.font] = normalizedFont
@@ -30,12 +30,15 @@ final class RDEPUBReaderChapterListController: UITableViewController {
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
view.accessibilityIdentifier = "epub.reader.toc.panel"
tableView.accessibilityIdentifier = "epub.reader.toc.table"
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell")
tableView.tableFooterView = UIView(frame: .zero) tableView.tableFooterView = UIView(frame: .zero)
tableView.backgroundColor = theme.contentBackgroundColor tableView.backgroundColor = theme.contentBackgroundColor
navigationController?.navigationBar.tintColor = theme.toolControlTextColor navigationController?.navigationBar.tintColor = theme.toolControlTextColor
navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor
navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor] navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor]
navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.toc.navbar"
} }
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
@@ -107,6 +107,31 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
/// EPUB /// EPUB
func pageNumber(for location: RDEPUBLocation) -> Int? { 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 textBook, let publication {
if let anchor = location.rangeAnchor?.start { if let anchor = location.rangeAnchor?.start {
if let page = textBook.indexTable.pageNumber(for: anchor, in: textBook) { if let page = textBook.indexTable.pageNumber(for: anchor, in: textBook) {
@@ -135,6 +160,47 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
/// ///
func resolvedTextLocation(forPageNumber pageNumber: Int) -> RDEPUBLocation? { 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, guard let textBook,
let publication, let publication,
let page = textBook.page(at: pageNumber) else { let page = textBook.page(at: pageNumber) else {
@@ -154,6 +220,17 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
/// spine /// spine
func synchronizeTextReadingState(pageNumber: Int, location: RDEPUBLocation) { 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, guard let textBook,
let page = textBook.page(at: pageNumber) else { let page = textBook.page(at: pageNumber) else {
readingSession?.transition(to: .idle) readingSession?.transition(to: .idle)
@@ -184,4 +261,37 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
} }
return (pages, chapters) 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 { extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
/// ///
public func pageCountOfReaderView(readerView: RDReaderView) -> Int { public func pageCountOfReaderView(readerView: RDReaderView) -> Int {
textBook?.pages.count ?? activePages.count readerContext.bookPageMap?.totalPages ?? textBook?.pages.count ?? activePages.count
} }
/// 退 Web /// 退 Web
public func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView { 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) { if let textBook, let page = textBook.page(at: pageNum + 1) {
let contentView = (containerView as? RDEPUBTextContentView) ?? RDEPUBTextContentView() let contentView = (containerView as? RDEPUBTextContentView) ?? RDEPUBTextContentView()
contentView.delegate = self contentView.delegate = self
@@ -53,7 +70,7 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
/// Web /// Web
public func pageIdentifier(readerView: RDReaderView, pageNum: Int) -> String? { public func pageIdentifier(readerView: RDReaderView, pageNum: Int) -> String? {
textBook == nil (textBook == nil && readerContext.bookPageMap == nil)
? NSStringFromClass(RDEPUBWebContentView.self) ? NSStringFromClass(RDEPUBWebContentView.self)
: NSStringFromClass(RDEPUBTextContentView.self) : NSStringFromClass(RDEPUBTextContentView.self)
} }
@@ -91,15 +108,20 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
/// ///
public func pageNum(readerView: RDReaderView, pageNum: Int) { public func pageNum(readerView: RDReaderView, pageNum: Int) {
readerContext.markUserNavigationActivity()
updateCurrentSelection(nil) updateCurrentSelection(nil)
reconcileTextPaginationSizeIfNeeded(for: pageNum) reconcileTextPaginationSizeIfNeeded(for: pageNum)
if readerContext.bookPageMap != nil {
_ = runtime.prepareOnDemandChapter(forAbsolutePageNumber: pageNum + 1)
runtime.extendPartialBookPageMapIfNeeded(currentPageNumber: pageNum + 1)
}
let totalPages = pageCountOfReaderView(readerView: readerView) let totalPages = pageCountOfReaderView(readerView: readerView)
if totalPages > 0, pageNum == totalPages - 1 { if totalPages > 0, pageNum == totalPages - 1 {
delegate?.epubReaderDidReachEnd(self) delegate?.epubReaderDidReachEnd(self)
} }
if textBook != nil, if (textBook != nil || readerContext.bookPageMap != nil),
let location = resolvedTextLocation(forPageNumber: pageNum + 1) { let location = resolvedTextLocation(forPageNumber: pageNum + 1) {
persist(location: location) persist(location: location)
synchronizeTextReadingState(pageNumber: pageNum + 1, location: location) synchronizeTextReadingState(pageNumber: pageNum + 1, location: location)
@@ -122,7 +144,7 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
/// ///
private func reconcileTextPaginationSizeIfNeeded(for pageNum: Int) { private func reconcileTextPaginationSizeIfNeeded(for pageNum: Int) {
guard textBook != nil, guard textBook != nil || readerContext.bookPageMap != nil,
!isRepaginating, !isRepaginating,
!isReconcilingTextPaginationSize, !isReconcilingTextPaginationSize,
pageNum >= 0, pageNum >= 0,
@@ -146,7 +168,7 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
guard let self else { return } guard let self else { return }
self.isReconcilingTextPaginationSize = false 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() self.repaginatePreservingCurrentLocation()
} }
} }
@@ -50,8 +50,18 @@ extension RDEPUBReaderController {
/// 访 /// 访
/// - Returns: nil /// - Returns: nil
public func nativeTextSemanticSummary() -> String? { public func nativeTextSemanticSummary() -> String? {
guard let textBook, let resolvedPage: RDEPUBTextPage?
let page = textBook.page(at: max(readerView.currentPage + 1, 1)) ?? textBook.pages.first else { 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 return nil
} }
@@ -242,4 +252,3 @@ extension RDEPUBReaderController {
runtime.clearSearch() runtime.clearSearch()
} }
} }
@@ -233,6 +233,7 @@ extension RDEPUBReaderController {
hideLoading() hideLoading()
readerContext.clearActiveSnapshot() readerContext.clearActiveSnapshot()
textBook = nil textBook = nil
readerContext.bookPageMap = nil
readerView.reloadData() readerView.reloadData()
errorLabel.text = error.localizedDescription errorLabel.text = error.localizedDescription
errorLabel.isHidden = false errorLabel.isHidden = false
@@ -13,18 +13,12 @@ import Foundation
extension RDEPUBReaderController { extension RDEPUBReaderController {
/// href /// href
func resolvedCurrentTableOfContentsItem() -> RDEPUBReaderTableOfContentsItem? { func resolvedCurrentTableOfContentsItem() -> RDEPUBReaderTableOfContentsItem? {
let items = flattenedTableOfContents let items = flattenedTableOfContentsItems(
from: publication?.tableOfContents ?? [],
includePageNumbers: false
)
guard !items.isEmpty else { return nil } 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, guard let publication,
let currentLocation = currentVisibleLocation(), let currentLocation = currentVisibleLocation(),
let normalizedCurrentHref = publication.resourceResolver.normalizedHref(currentLocation.href) else { let normalizedCurrentHref = publication.resourceResolver.normalizedHref(currentLocation.href) else {
@@ -60,28 +54,12 @@ extension RDEPUBReaderController {
/// 线 /// 线
func flattenedTableOfContentsItems( func flattenedTableOfContentsItems(
from items: [EPUBTableOfContentsItem], from items: [EPUBTableOfContentsItem],
depth: Int = 0 depth: Int = 0,
includePageNumbers: Bool = true
) -> [RDEPUBReaderTableOfContentsItem] { ) -> [RDEPUBReaderTableOfContentsItem] {
items.flatMap { item in items.flatMap { item in
let location = RDEPUBLocation(bookIdentifier: currentBookIdentifier, href: item.href, progression: 0) let location = RDEPUBLocation(bookIdentifier: currentBookIdentifier, href: item.href, progression: 0)
let pageNumber: Int? let pageNumber = includePageNumbers ? resolvedTableOfContentsPageNumber(for: location) : nil
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 current = RDEPUBReaderTableOfContentsItem( let current = RDEPUBReaderTableOfContentsItem(
title: item.title, title: item.title,
@@ -89,8 +67,53 @@ extension RDEPUBReaderController {
depth: depth, depth: depth,
pageNumber: pageNumber 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() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
view.accessibilityIdentifier = "epub.reader.bookmarks.panel"
tableView.accessibilityIdentifier = "epub.reader.bookmarks.table"
tableView.tableFooterView = UIView(frame: .zero) tableView.tableFooterView = UIView(frame: .zero)
tableView.separatorInset = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16) tableView.separatorInset = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)
applyTheme() applyTheme()
@@ -286,6 +288,7 @@ final class RDEPUBReaderBookmarksViewController: UITableViewController {
navigationController?.navigationBar.tintColor = theme.toolControlTextColor navigationController?.navigationBar.tintColor = theme.toolControlTextColor
navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor
navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor] navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor]
navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.bookmarks.navbar"
} }
private func updateEmptyState() { private func updateEmptyState() {
@@ -25,6 +25,12 @@ import UIKit
/// navigationController?.pushViewController(controller, animated: true) /// navigationController?.pushViewController(controller, animated: true)
/// ``` /// ```
public final class RDURLReaderController: UIViewController { public final class RDURLReaderController: UIViewController {
private struct PendingDemoPageRequest {
let pageNumber: Int
let animated: Bool
var attemptCount: Int
}
/// URL /// URL
private let bookURL: URL private let bookURL: URL
/// EPUB /// EPUB
@@ -41,6 +47,12 @@ public final class RDURLReaderController: UIViewController {
label.text = "reader=opening" label.text = "reader=opening"
return label 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: /// - Parameters:
@@ -71,7 +83,17 @@ public final class RDURLReaderController: UIViewController {
public override func viewDidAppear(_ animated: Bool) { public override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated) super.viewDidAppear(animated)
emitDemoState() startDemoStateTimerIfNeeded()
refreshDemoState()
}
public override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
stopDemoStateTimer()
}
deinit {
stopDemoStateTimer()
} }
/// Demo /// Demo
@@ -88,9 +110,12 @@ public final class RDURLReaderController: UIViewController {
/// - Returns: /// - Returns:
@discardableResult @discardableResult
public func goToDemoPage(_ pageNumber: Int, animated: Bool = false) -> Bool { 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 { if moved {
emitDemoState(prefix: "page=\(pageNumber)") pendingDemoPageRequest = nil
isRetryingPendingDemoPage = false
} else {
queuePendingDemoPageNavigation(pageNumber, animated: animated)
} }
return moved return moved
} }
@@ -142,8 +167,8 @@ public final class RDURLReaderController: UIViewController {
edgeInsets: epubConfiguration.reflowableContentInsets, edgeInsets: epubConfiguration.reflowableContentInsets,
numberOfColumns: 1, numberOfColumns: 1,
columnGap: 20, columnGap: 20,
avoidOrphans: true, avoidOrphans: false,
avoidWidows: true, avoidWidows: false,
avoidPageBreakInsideEnabled: true, avoidPageBreakInsideEnabled: true,
hyphenation: true, hyphenation: true,
imageMaxHeightRatio: 0.85 imageMaxHeightRatio: 0.85
@@ -187,6 +212,75 @@ public final class RDURLReaderController: UIViewController {
embeddedController as? RDEPUBReaderController 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 /// Demo
private func logDemoState(prefix: String) { private func logDemoState(prefix: String) {
guard let readerController else { return } 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) { 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 page = readerController?.currentPageNumber.map(String.init) ?? "nil"
let display = readerController?.configuration.displayType.demoArgumentValue ?? epubConfiguration.displayType.demoArgumentValue let display = readerController?.configuration.displayType.demoArgumentValue ?? epubConfiguration.displayType.demoArgumentValue
let toolbar = readerController?.readerView.isShowToolView == true ? "visible" : "hidden" let toolbar = readerController?.readerView.isShowToolView == true ? "visible" : "hidden"
let highlights = readerController?.highlights.count ?? 0 let highlights = readerController?.highlights.count ?? 0
let selection = readerController?.currentSelection == nil ? 0 : 1 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 demoStateLabel.text = state
if let prefix { if let logPrefix {
logDemoState(prefix: prefix) logDemoState(prefix: logPrefix)
} else { } else if logIfChanged, state != lastEmittedDemoState {
print("[ReadViewDemo] automation \(state)") 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 { extension RDURLReaderController: RDEPUBReaderDelegate {
public func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication) {
retryPendingDemoPageIfNeeded()
emitDemoState()
}
public func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation) { public func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation) {
retryPendingDemoPageIfNeeded()
emitDemoState() emitDemoState()
} }
@@ -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)
}
}
}
@@ -0,0 +1,8 @@
import Foundation
struct RDEPUBChapterCacheKey: Hashable {
let bookID: String
let spineIndex: Int
let renderSignature: String
let chapterContentHash: String
}
@@ -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()
}
}
@@ -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)"
}
}
}
@@ -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?
/// progressionfragmentID 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 }
}
@@ -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
}
}
@@ -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()
}
}
@@ -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
)
}
}
}
@@ -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
}
@@ -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 }
}
@@ -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
)
}
}
@@ -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)
}
}
@@ -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
}
}
@@ -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) setupLoadingIndicator(controller.loadingIndicator, in: controller.view)
setupErrorLabel(controller.errorLabel, in: controller.view) setupErrorLabel(controller.errorLabel, in: controller.view)
controller.delegate?.epubReader(controller, configureTopToolView: controller.topToolView) controller.delegate?.epubReader(controller, configureTopToolView: controller.topToolView)
print("[ReadViewDemo] assembleInterface: dataSource=\(readerView.dataSource != nil ? "set" : "nil"), numberOfPages=\(readerView.numberOfPages())")
} }
/// ///
func finishExternalTextBookLaunchIfNeeded() { func finishExternalTextBookLaunchIfNeeded() {
guard let runtime = context.runtime, guard let runtime = context.runtime,
let controller = context.controller,
context.isExternalTextBook else { context.isExternalTextBook else {
return return
} }
@@ -37,12 +39,19 @@ final class RDEPUBReaderAssemblyCoordinator {
context.activeBookmarks = context.persistence?.loadBookmarks(for: id) ?? [] context.activeBookmarks = context.persistence?.loadBookmarks(for: id) ?? []
context.activeHighlights = context.persistence?.loadHighlights(for: id) ?? [] context.activeHighlights = context.persistence?.loadHighlights(for: id) ?? []
} }
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) runtime.finishPagination(restoreLocation: restoreLocation)
} }
}
private func setupReaderView(_ readerView: RDReaderView, in containerView: UIView) { private func setupReaderView(_ readerView: RDReaderView, in containerView: UIView) {
readerView.dataSource = context.controller as? RDReaderDataSource readerView.dataSource = context.controller
readerView.delegate = context.controller as? RDReaderDelegate readerView.delegate = context.controller
readerView.translatesAutoresizingMaskIntoConstraints = false readerView.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(readerView) containerView.addSubview(readerView)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
@@ -117,7 +117,10 @@ final class RDEPUBReaderChromeCoordinator {
func presentTableOfContents() { func presentTableOfContents() {
guard let controller else { return } guard let controller else { return }
guard controller.configuration.showsTableOfContents 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 } guard !items.isEmpty else { return }
let chapterController = RDEPUBReaderChapterListController( let chapterController = RDEPUBReaderChapterListController(
@@ -9,6 +9,9 @@ import UIKit
/// - 便renderStylelayoutConfig /// - 便renderStylelayoutConfig
/// - controller UIKit /// - controller UIKit
final class RDEPUBReaderContext { final class RDEPUBReaderContext {
private let activityLock = NSLock()
private var lastUserNavigationTimestamp: CFAbsoluteTime = 0
// MARK: - // MARK: -
/// UIKit /// UIKit
@@ -31,7 +34,10 @@ final class RDEPUBReaderContext {
/// ///
var readingSession: RDEPUBReadingSession? var readingSession: RDEPUBReadingSession?
/// ///
/// nil ChapterRuntimeStore 访
var textBook: RDEPUBTextBook? var textBook: RDEPUBTextBook?
/// 100KB/1000 NSAttributedString
var bookPageMap: RDEPUBBookPageMap?
/// ///
var activeBookmarks: [RDEPUBBookmark] = [] var activeBookmarks: [RDEPUBBookmark] = []
/// ///
@@ -46,6 +52,10 @@ final class RDEPUBReaderContext {
var searchState: RDEPUBSearchState? var searchState: RDEPUBSearchState?
/// ///
var lastTextPaginationPageSize: CGSize? var lastTextPaginationPageSize: CGSize?
/// OperationQueue
var lastMetadataParseWallClockMs: Int = 0
/// 使
var lastMetadataParseConcurrency: Int = 0
/// ///
var currentSelection: RDEPUBSelection? var currentSelection: RDEPUBSelection?
@@ -128,8 +138,9 @@ final class RDEPUBReaderContext {
edgeInsets: configuration.reflowableContentInsets, edgeInsets: configuration.reflowableContentInsets,
numberOfColumns: configuration.numberOfColumns, numberOfColumns: configuration.numberOfColumns,
columnGap: configuration.columnGap, columnGap: configuration.columnGap,
avoidOrphans: true, //
avoidWidows: true, avoidOrphans: false,
avoidWidows: false,
avoidPageBreakInsideEnabled: true, avoidPageBreakInsideEnabled: true,
hyphenation: true, hyphenation: true,
imageMaxHeightRatio: 0.85, imageMaxHeightRatio: 0.85,
@@ -183,6 +194,66 @@ final class RDEPUBReaderContext {
dependencies.makeTextBookBuilder(resolvedTextRenderer(), textBookCache, layoutConfig) 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 { func makePlainTextBookBuilder(layoutConfig: RDEPUBTextLayoutConfig) -> RDPlainTextBookBuilder {
dependencies.makePlainTextBookBuilder(resolvedTextRenderer(), layoutConfig) dependencies.makePlainTextBookBuilder(resolvedTextRenderer(), layoutConfig)
@@ -205,6 +276,22 @@ final class RDEPUBReaderContext {
persistence?.saveLocation(location, for: currentBookIdentifier) 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 /// href
func textChapterData(forNormalizedHref href: String) -> RDEPUBChapterData? { func textChapterData(forNormalizedHref href: String) -> RDEPUBChapterData? {
guard let textBook, let publication else { return nil } guard let textBook, let publication else { return nil }
@@ -25,7 +25,16 @@ final class RDEPUBReaderLocationCoordinator {
return false 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( _ = context.readingSession?.queueNavigation(
to: location, to: location,
relativeToSpineIndex: nil, relativeToSpineIndex: nil,
@@ -44,7 +53,7 @@ final class RDEPUBReaderLocationCoordinator {
let readerView = context.readerView else { let readerView = context.readerView else {
return nil 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 controller.resolvedTextLocation(forPageNumber: readerView.currentPage + 1)
} }
return context.readingSession?.currentReadingLocation(bookIdentifier: context.currentBookIdentifier) return context.readingSession?.currentReadingLocation(bookIdentifier: context.currentBookIdentifier)
@@ -4,18 +4,20 @@ import Foundation
/// ///
/// ///
/// - /Fixed Layout/Web /// - /Fixed Layout/Web
/// - /// -
/// - /// -
/// - /// -
/// - /// -
final class RDEPUBReaderPaginationCoordinator { final class RDEPUBReaderPaginationCoordinator {
private let backgroundInteractionCooldown: CFAbsoluteTime = 0.8
private unowned let context: RDEPUBReaderContext private unowned let context: RDEPUBReaderContext
init(context: RDEPUBReaderContext) { init(context: RDEPUBReaderContext) {
self.context = context self.context = context
} }
/// TextBookBuilderFixed Layout Web Paginator /// /Fixed Layout Web Paginator
func paginatePublication(restoreLocation: RDEPUBLocation?) { func paginatePublication(restoreLocation: RDEPUBLocation?) {
guard let controller = context.controller, guard let controller = context.controller,
let parser = context.parser, let parser = context.parser,
@@ -29,38 +31,22 @@ final class RDEPUBReaderPaginationCoordinator {
controller.showLoading() controller.showLoading()
let token = UUID() let token = UUID()
context.paginationToken = token context.paginationToken = token
print("[EPUB][Pagination] profile=\(publication.readingProfile.rawValue) layout=\(publication.layout.rawValue) spine=\(publication.spine.count)")
if publication.readingProfile == .textReflowable { if publication.readingProfile == .textReflowable {
let pageSize = controller.currentTextPageSize() print("[EPUB][Pagination] path=text-reflowable-on-demand")
context.lastTextPaginationPageSize = pageSize paginateTextPublication(
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, parser: parser,
publication: publication, publication: publication,
pageSize: pageSize, readingSession: readingSession,
style: renderStyle restoreLocation: restoreLocation,
token: token
) )
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)
}
}
}
return return
} }
if publication.layout == .fixed { if publication.layout == .fixed {
print("[EPUB][Pagination] path=fixed-layout")
let snapshot = readingSession.makePaginationSnapshot( let snapshot = readingSession.makePaginationSnapshot(
pageCounts: Array(repeating: 1, count: publication.spine.count), pageCounts: Array(repeating: 1, count: publication.spine.count),
preferences: controller.currentPreferences(), preferences: controller.currentPreferences(),
@@ -71,6 +57,7 @@ final class RDEPUBReaderPaginationCoordinator {
} }
let paginator = context.makePaginator() let paginator = context.makePaginator()
print("[EPUB][Pagination] path=web-paginator")
context.paginator = paginator context.paginator = paginator
paginator.calculate( paginator.calculate(
parser: parser, parser: parser,
@@ -92,6 +79,7 @@ final class RDEPUBReaderPaginationCoordinator {
func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) { func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
guard let controller = context.controller else { return } guard let controller = context.controller else { return }
context.textBook = textBook context.textBook = textBook
context.bookPageMap = nil
let snapshot = controller.nativeTextSnapshot(from: textBook) let snapshot = controller.nativeTextSnapshot(from: textBook)
context.replaceActiveSnapshot(snapshot) context.replaceActiveSnapshot(snapshot)
@@ -108,8 +96,9 @@ final class RDEPUBReaderPaginationCoordinator {
_ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]), _ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]),
restoreLocation: RDEPUBLocation? restoreLocation: RDEPUBLocation?
) { ) {
guard let controller = context.controller else { return } guard context.controller != nil else { return }
context.textBook = nil context.textBook = nil
context.bookPageMap = nil
context.replaceActiveSnapshot(snapshot) context.replaceActiveSnapshot(snapshot)
guard !snapshot.pages.isEmpty else { guard !snapshot.pages.isEmpty else {
@@ -129,6 +118,7 @@ final class RDEPUBReaderPaginationCoordinator {
readerView.reloadData() readerView.reloadData()
if let targetLocation = restoreLocation { if let targetLocation = restoreLocation {
controller.restoreReadingLocation(targetLocation) controller.restoreReadingLocation(targetLocation)
context.readingSession?.transition(to: .idle)
} else { } else {
readerView.transitionToPage(pageNum: 0) readerView.transitionToPage(pageNum: 0)
context.readingSession?.transition(to: .idle) context.readingSession?.transition(to: .idle)
@@ -168,4 +158,450 @@ final class RDEPUBReaderPaginationCoordinator {
context.runtime?.applyTextBook(newBook, restoreLocation: restoreLocation) 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
/// pageCountpageRangesfragmentOffsets
/// 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 { final class RDEPUBReaderRuntime {
private unowned let context: RDEPUBReaderContext 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 loadCoordinator = RDEPUBReaderLoadCoordinator(context: context)
lazy var paginationCoordinator = RDEPUBReaderPaginationCoordinator(context: context) lazy var paginationCoordinator = RDEPUBReaderPaginationCoordinator(context: context)
lazy var locationCoordinator = RDEPUBReaderLocationCoordinator(context: context) lazy var locationCoordinator = RDEPUBReaderLocationCoordinator(context: context)
@@ -42,9 +51,11 @@ final class RDEPUBReaderRuntime {
context.clearActiveSnapshot() context.clearActiveSnapshot()
context.readingSession = nil context.readingSession = nil
context.textBook = nil context.textBook = nil
context.bookPageMap = nil
context.activeBookmarks = [] context.activeBookmarks = []
context.activeHighlights = [] context.activeHighlights = []
context.searchState = nil context.searchState = nil
clearOnDemandPageModeState()
viewportMonitor.resetForReload() viewportMonitor.resetForReload()
annotationCoordinator.updateCurrentSelection(nil) annotationCoordinator.updateCurrentSelection(nil)
readerView.reloadData() readerView.reloadData()
@@ -73,11 +84,26 @@ final class RDEPUBReaderRuntime {
return false return false
} }
if context.textBook != nil { if let textBook = context.textBook {
guard let location = controller.resolvedTextLocation(forPageNumber: pageNumber) else { guard textBook.page(at: pageNumber) != nil else {
return false 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 { guard context.activePages.indices.contains(pageNumber - 1) else {
@@ -278,6 +304,42 @@ final class RDEPUBReaderRuntime {
paginationCoordinator.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation) 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?) { func finishPagination(restoreLocation: RDEPUBLocation?) {
paginationCoordinator.finishPagination(restoreLocation: restoreLocation) paginationCoordinator.finishPagination(restoreLocation: restoreLocation)
@@ -321,4 +383,175 @@ final class RDEPUBReaderRuntime {
) { ) {
viewportMonitor.handleViewportChangeIfNeeded(reason: reason, viewportSignature: viewportSignature) 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 public var fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode
/// 使 DTCoreText /// 使 DTCoreText
public var textRenderingEngine: RDEPUBTextRenderingEngine 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: // MARK:
@@ -128,6 +134,8 @@ public struct RDEPUBReaderConfiguration: Equatable {
/// - fixedLayoutFit: /// - fixedLayoutFit:
/// - fixedLayoutSpreadMode: /// - fixedLayoutSpreadMode:
/// - textRenderingEngine: /// - textRenderingEngine:
/// - onDemandChapterWindowSize: 3...15
/// - metadataParsingConcurrency: CPU
public init( public init(
fontSize: CGFloat = 15, fontSize: CGFloat = 15,
lineHeightMultiple: CGFloat = 1.6, lineHeightMultiple: CGFloat = 1.6,
@@ -146,7 +154,9 @@ public struct RDEPUBReaderConfiguration: Equatable {
darkImageBlendRatio: CGFloat = 0.15, darkImageBlendRatio: CGFloat = 0.15,
fixedLayoutFit: RDEPUBFixedLayoutFit = .page, fixedLayoutFit: RDEPUBFixedLayoutFit = .page,
fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode = .automatic, fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode = .automatic,
textRenderingEngine: RDEPUBTextRenderingEngine = .dtCoreText textRenderingEngine: RDEPUBTextRenderingEngine = .dtCoreText,
onDemandChapterWindowSize: Int = 3,
metadataParsingConcurrency: Int = ProcessInfo.processInfo.activeProcessorCount
) { ) {
self.fontSize = fontSize self.fontSize = fontSize
self.lineHeightMultiple = lineHeightMultiple self.lineHeightMultiple = lineHeightMultiple
@@ -166,12 +176,25 @@ public struct RDEPUBReaderConfiguration: Equatable {
self.fixedLayoutFit = fixedLayoutFit self.fixedLayoutFit = fixedLayoutFit
self.fixedLayoutSpreadMode = fixedLayoutSpreadMode self.fixedLayoutSpreadMode = fixedLayoutSpreadMode
self.textRenderingEngine = textRenderingEngine self.textRenderingEngine = textRenderingEngine
self.onDemandChapterWindowSize = Self.normalizedChapterWindowSize(onDemandChapterWindowSize)
self.metadataParsingConcurrency = max(1, metadataParsingConcurrency)
} }
/// 使 /// 使
public static let `default` = RDEPUBReaderConfiguration() 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: - // MARK: -
extension RDEPUBReaderConfiguration { extension RDEPUBReaderConfiguration {
@@ -24,7 +24,7 @@ extension RDReaderView {
} }
let contentViewClass = contentViews[identifier] let contentViewClass = contentViews[identifier]
assert(contentViewClass != nil, "请调用register(contentViewcontentViewWithReuseIdentifier:)") assert(contentViewClass != nil, "请调用register(contentViewcontentViewWithReuseIdentifier:)")
var contentView = contentViewClass!.init() let contentView = contentViewClass!.init()
return contentView return contentView
} }
@@ -742,6 +742,13 @@ public class RDReaderView: UIView {
bottomToolView = resolvedBottomChromeView() bottomToolView = resolvedBottomChromeView()
} }
///
public func reloadPageCountOnly() {
collectionView.reloadData()
topToolView = resolvedTopChromeView()
bottomToolView = resolvedBottomChromeView()
}
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")