feat: add in-reader search and restructure documentation

- Add RDEPUBReaderSearchBarView with animated show/hide, keyword
  navigation, and match counting integrated into the reader controller
- Restructure docs: replace scattered design docs with consolidated
  BUSINESS_LOGIC.md and UML_CLASS_DIAGRAMS.md; update ARCHITECTURE.md
- Add SearchTests and FanrenParseTimeTest; enhance LargeBookOnDemandTests
- Add scripts/run_ui_regression.sh and summarize_ui_results.py for
  automated UI test execution and reporting

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
shenlei 2026-06-05 17:34:50 +08:00
parent d20196ee34
commit 0e7c0577e3
37 changed files with 4941 additions and 7621 deletions

View File

@ -1,507 +1,440 @@
# RDReaderView 架构文档
# ReadViewSDK 系统架构文档
> 最后更新2026-06-04
---
## 1. 项目概览
RDReaderView 是一个 iOS 阅读器组件库CocoaPods提供开箱即用的 EPUB 阅读能力,并保留对纯文本翻页的支持。
ReadViewSDK 是一个 iOS EPUB 阅读器 SDK支持文本重排Reflowable和固定布局Fixed Layout两种 EPUB 格式,同时兼容纯文本 (.txt) 文件。SDK 提供完整的 EPUB 解析、文本渲染、分页计算、阅读 UI 和标注管理能力
- **最低 iOS 版本**15.0
- **Swift 版本**5.10+
- **依赖**ZIPFoundationEPUB 解压、DTCoreText文本 EPUB 渲染
- **Demo 额外依赖**SnapKit、SSAlertSwift
**技术栈:**
- 语言Swift 5最低 iOS 15.6
- 依赖DTCoreTextHTML→NSAttributedString、ZIPFoundationEPUB 解压、SnapKitAuto Layout、SSAlertSwift弹窗
- 构建CocoaPods本地 pod 引用
---
## 2. 总体分层
## 2. 分层架构
```
┌─────────────────────────────────────────────────────────────┐
│ ReadViewDemo (Demo App) │
│ ViewController · LaunchAutomationPlan · UITests │
└──────────────────────────────┬──────────────────────────────┘
│ imports RDReaderView
┌──────────────────────────────▼──────────────────────────────┐
│ EPUBUI 层 │
│ RDEPUBReaderController · Coordinators · Settings · TextPage │
│ ChapterRuntimeLoader · Store · DiskCache · PageMap
└──────────────────────────────┬──────────────────────────────┘
┌──────────────────────────────▼──────────────────────────────┐
│ RDReaderView 层 │
│ RDReaderView · FlowLayout · PreloadController │
│ SpreadResolver · TapRegionHandler · PagingController │
└──────────────────────────────┬──────────────────────────────┘
┌──────────────────────────────▼──────────────────────────────┐
│ EPUBTextRendering 层 │
│ TypesetterPipeline · DTCoreTextRenderer · Pagination │
│ BuildPipelineTextBookBuilder · Cache · Sampler
└──────────────────────────────┬──────────────────────────────┘
┌──────────────────────────────▼──────────────────────────────┐
│ EPUBCore 层 │
│ RDEPUBParser · Publication · ResourceResolver │
│ ReadingSession · WebView · JavaScriptBridge · Paginator │
└─────────────────────────────────────────────────────────────┘
```
### 各层职责
| 层 | 职责 | 核心类 |
|---|---|---|
| **EPUBCore** | EPUB 文件解析、资源管理、WebView 渲染、JS 桥接 | `RDEPUBParser`, `RDEPUBPublication`, `RDEPUBWebView` |
| **EPUBTextRendering** | HTML→NSAttributedString 转换、排版、分页、全书构建 | `RDEPUBTextBookBuilder`, `RDEPUBCoreTextPageFrameFactory` |
| **RDReaderView** | 通用翻页容器、手势识别、页面预加载、双页布局 | `RDReaderView`, `RDReaderFlowLayout`, `RDReaderPreloadController` |
| **EPUBUI** | 阅读器控制器、协调器模式、设置面板、按需加载、磁盘缓存 | `RDEPUBReaderController`, `RDEPUBReaderPaginationCoordinator` |
---
## 3. 核心数据流
### 3.1 打开书籍流程
```
用户点击书籍
RDEPUBReaderController.openBook(epubURL:)
├─ RDEPUBParser.parse(epubURL:) // 解压 ZIP → 解析 OPF → 构建 spine/TOC
│ └─ extractArchiveIfNeeded() // ZIPFoundation 解压到 Caches
│ └─ parseContainerRootFile() // SAX 解析 container.xml
│ └─ parseOPF() // SAX 解析 OPF (metadata/manifest/spine)
│ └─ parseTOC() // NCX 或 Navigation Document
├─ RDEPUBPublication(parser:) // 创建门面对象
├─ 判断 readingProfile:
│ ├─ .textReflowable → 文本排版路径(本 SDK 核心路径)
│ ├─ .webInteractive → WebView 渲染路径
│ └─ .webFixedLayout → 固定布局路径
└─ paginateTextPublication() // 进入分页流程
```
### 3.2 文本重排分页流程(大书优化路径)
```
paginateTextPublication()
├─ restoreBookPageMapIfPossible() // 尝试从磁盘恢复完整 BookPageMap
│ └─ summaryDiskCache.readAll(keys:) // 批量读取磁盘摘要
│ └─ 如果完整 → 直接 applyBookPageMap() → 完成
├─ 快速打开路径:
│ ├─ loadFirstRenderableRuntimeChapter() // 加载第一个可渲染章节
│ ├─ loadInitialRuntimeChapters() // 加载窗口内相邻章节
│ ├─ makePartialPageMap() // 构建局部 BookPageMap
│ └─ applyBookPageMap() // 应用到 UI用户可立即阅读
└─ paginateMetadataOnly() // 后台元数据解析
├─ 预计算 contentHash串行 // 读取所有章节 HTML + SHA-256
├─ readAll(keys:) 恢复已有缓存
├─ waitForReadingInteractionToSettle() // 等待用户操作冷却 0.8s
├─ OperationQueue (并发 N):
│ ├─ buildChapter() // 渲染单章 (HTML→NSAttrStr→分页)
│ ├─ chapterCacheKey() // 构建缓存键(复用预计算 hash
│ ├─ RDEPUBChapterSummary 写盘 // 异步写入磁盘摘要
│ └─ 每 32 章刷新 BookPageMap // 增量更新 UI
└─ 最终 applyBookPageMap() // 完整页码映射
```
### 3.3 单章渲染管线
```
Raw HTML (从 EPUB 解压目录读取)
▼ [RDEPUBTextTypesetterPipeline]
├─ RDEPUBHTMLNormalizer // 去 CR、合并空行、规范化附件标记
├─ RDEPUBSemanticMarkerInjector // 注入分页语义标记 ${rd-sem-start/end}
├─ RDEPUBRenderDiagnosticsCollector // 内联 <link stylesheet>、收集图片诊断
├─ RDEPUBStyleSheetComposer // 构建 5 层 CSS (default/replace/dark/epub/user)
├─ RDEPUBFontNormalizer // 注册 @font-face 嵌入字体
├─ RDEPUBFragmentMarkerInjector // 注入 fragment 锚点标记 ${id=xxx}
▼ [RDEPUBDTCoreTextRenderer]
├─ DTHTMLAttributedStringBuilder // HTML → NSAttributedString
├─ applyPaginationSemantics() // 将语义标记转为NSAttributedString属性
├─ extractFragmentOffsets() // 提取 fragment ID→offset 映射
├─ normalizeReadingAttributes() // 规范化字体、行距、颜色、附件
▼ [RDEPUBChapterPageCounter]
├─ CTFramesetter 创建帧
├─ RDEPUBPageBreakPolicy 应用语义边界规则
│ ├─ avoidPageBreakInside // 避免块内分页
│ ├─ keepWithNext // 标题与正文不分离
│ ├─ widow/orphan 控制 // 孤行/寡行控制
│ └─ pageRelate // 微信读书跨页关联
├─ RDEPUBChapterTailNormalizer // 删除/合并空白尾页
▼ [RDEPUBTextBookBuilder]
└─ 组装 RDEPUBTextPage[] → RDEPUBTextChapter → RDEPUBTextBook
```
---
## 4. 缓存架构(三级缓存)
```
┌─────────────────────────────────────────────────────────┐
│ Demo / 宿主 App │
│ ViewController → RDURLReaderControllerdemo 级路由控制器)│
└───────────────────────────┬─────────────────────────────┘
┌───────────────────────────▼─────────────────────────────┐
│ EPUBUI 层library 级读者 UI
│ │
│ 主控制器 │
│ RDEPUBReaderController开箱即用入口
│ +ContentDelegates / +DataSource / +PublicAPI │
│ +RenderSupport / +RuntimeBridge / +TableOfContents │
│ RDURLReaderControllerURL 阅读入口) │
│ │
│ ReaderController/(协调器) │
│ RDEPUBReaderRuntime中央运行时协调器
│ RDEPUBReaderContext上下文状态容器
│ RDEPUBReaderDependencies依赖注入
│ RDEPUBReaderLoadCoordinatorEPUB 加载) │
│ RDEPUBReaderPaginationCoordinator分页协调
│ RDEPUBReaderLocationCoordinator位置持久化
│ RDEPUBReaderAnnotationCoordinator标注管理
│ RDEPUBReaderSearchCoordinator搜索
│ RDEPUBReaderChromeCoordinator工具栏
│ RDEPUBReaderAssemblyCoordinatorUI 组装) │
│ RDEPUBReaderViewportMonitor视口变化监听
│ │
│ Settings/(配置与主题) │
│ RDEPUBReaderConfiguration / RDEPUBReaderSettings │
│ RDEPUBReaderSettingsViewController / RDEPUBReaderTheme │
│ │
│ TextPage/(文本页面交互) │
│ RDEPUBTextContentView / RDEPUBTextPageRenderView │
│ RDEPUBSelectableTextView / RDEPUBTextSelectionController│
│ RDEPUBSelectionOverlayView / RDEPUBTextAnnotationOverlay│
│ RDEPUBPageInteractionController / RDEPUBPageLayoutSnapshot│
│ RDEPUBTextPageDecorationView │
│ │
│ 工具栏与面板 │
│ RDEPUBReaderTopToolView / RDEPUBReaderBottomToolView │
│ RDEPUBReaderToolView基类
│ RDEPUBReaderChapterListController目录面板
│ RDEPUBReaderHighlightsViewController高亮管理
│ RDEPUBReaderPersistence位置持久化
│ RDEPUBReaderDelegate / RDEPUBReaderTableOfContentsItem│
│ RDEPUBWebContentView / RDEPUBWebDecorationOverlayView │
│ RDEPUBViewportTypes / UIColor+RDEPUBHex │
└───────────────────────────┬─────────────────────────────┘
┌───────────────────────────▼─────────────────────────────┐
│ 翻页容器层RDReaderView
│ │
│ RDReaderViewUIView统一翻页外壳
│ 3 种翻页模式pageCurl / horizontalScroll / │
│ verticalScroll │
│ RDReaderViewProtocolsDataSource / Delegate / DisplayType
│ +CollectionView / +ContentAccess / +PageCurl / +ToolView │
│ RDReaderFlowLayout / RDReaderContentCell │
│ RDReaderPageChildViewControllerpageCurl 页包装) │
│ RDReaderGestureController │
│ │
│ Paging/(翻页控制) │
│ RDReaderPagingController转场与排队
│ RDReaderPreloadController预加载与缓存
│ RDReaderSpreadResolver双页配对
│ RDReaderTapRegionHandler手势分区
└───────────────────────────┬─────────────────────────────┘
┌───────────────────────────▼─────────────────────────────┐
│ EPUBCore 层EPUB 引擎) │
│ │
│ 解析与模型 │
│ RDEPUBParser+Archive / +Package / +TOC / │
│ +ReadingProfile / +Resources
│ RDEPUBPublication出版物聚合对象
│ RDEPUBModelsmetadata / manifest / spine 模型) │
│ Models/ │
│ RDEPUBReadingLocationModelslocation 模型) │
│ RDEPUBPaginationModels分页模型
│ RDEPUBAnnotationModels标注模型
│ RDEPUBTextAnchor / RDEPUBTextRangeAnchor文本锚点
│ RDEPUBRenderRequest渲染请求模型
│ │
│ 服务层 │
│ RDEPUBResourceResolver资源 URL 统一入口) │
│ RDEPUBResourceURLSchemeHandlerss-reader:// 协议) │
│ RDEPUBPreferences展示参数聚合
│ RDEPUBPaginator离屏分页服务
│ RDEPUBStyleSheetBuilder / RDEPUBJavaScriptBridge │
│ RDEPUBFixedLayoutTemplate / RDEPUBAssetRepository │
│ │
│ 会话与导航 │
│ RDEPUBReadingSession状态机 + 会话协调) │
│ RDEPUBNavigatorState状态枚举
│ RDEPUBNavigatorLayoutContext │
│ │
│ WebView 渲染 │
│ RDEPUBWebView+Configuration / +Reflowable / │
│ +FixedLayout / +JavaScriptBridge / │
│ +Search
│ RDEPUBWebViewDebug调试日志工具
│ │
│ 搜索 │
│ RDEPUBSearchEngine协议/ RDEPUBHTMLSearchEngine │
│ RDEPUBSearchModelsSearchMatch/Result/State/Presentation
└───────────────────────────┬─────────────────────────────┘
┌───────────────────────────▼─────────────────────────────┐
│ EPUBTextRendering 层(文本 EPUB 渲染) │
│ │
│ 渲染 │
│ RDEPUBTextRenderer协议
│ RDEPUBDTCoreTextRendererDTCoreText 实现) │
│ RDPlainTextBookBuilder纯文本书籍构建
│ RDEPUBTextPositionConverter位置转换器
│ RDEPUBTextSearchEngine文本搜索引擎
│ RDEPUBTextIndexTable / RDEPUBChapterData │
│ │
│ BuildPipeline/(构建管线) │
│ RDEPUBTextBookBuilder分页书籍构建器
│ RDEPUBTextBookCache / RDEPUBTextBookModels │
│ RDEPUBTextBuildPipelineInterfaces管线协议
│ RDEPUBPaginationCacheCoordinator缓存协调
│ RDEPUBChapterTailNormalizer章尾规范化
│ RDEPUBBuildDiagnosticsReporter诊断报告
│ RDEPUBTextPerformanceSampler性能采样
│ │
│ Pagination/(分页引擎) │
│ RDEPUBTextLayouter / RDEPUBTextLayoutFrame │
│ RDEPUBChapterPageCounter / RDEPUBCoreTextPageFrameFactory│
│ RDEPUBPageBreakPolicy断页策略
│ RDEPUBTextPaginationInterfaces分页协议
│ RDEPUBTextPaginationSupport分页支持
│ │
│ Typesetter/(排版管线) │
│ RDEPUBTypesettingPipeline排版管线编排
│ RDEPUBHTMLNormalizerHTML 规范化) │
│ RDEPUBStyleSheetComposerCSS 组合) │
│ RDEPUBFontNormalizer字体规范化
│ RDEPUBAttachmentNormalizer附件规范化
│ RDEPUBFragmentMarkerInjectorFragment 标记注入) │
│ RDEPUBSemanticMarkerInjector语义标记注入
│ RDEPUBRenderDiagnosticsCollector渲染诊断
│ RDEPUBTextRendererSupport渲染辅助工具
└──────────────────────────────────────────────────────────┘
│ Tier 1: 内存缓存 (RDEPUBChapterRuntimeStore) │
│ ├─ chapterDataCache: [Int: RDEPUBRuntimeChapter] │
│ ├─ pageCountCache: [CacheKey: RDEPUBRuntimePageCount] │
│ ├─ imageCache: NSCache<NSString, UIImage> (50 上限) │
│ └─ 窗口驱逐:只保留当前章节 ± windowRadius 的章节 │
└──────────────────────────┬──────────────────────────────┘
│ miss
┌──────────────────────────▼──────────────────────────────┐
│ Tier 2: 磁盘摘要缓存 (RDEPUBChapterSummaryDiskCache) │
│ ├─ 路径: ~/Caches/RDEPUBChapterSummaryCache/{bookID}/ │
│ ├─ 文件名: SHA256(bookID_spineIdx_renderSig_contentHash)│
│ ├─ 格式: JSON (RDEPUBChapterSummary) │
│ ├─ 写入: 异步serial DispatchQueue
│ └─ 读取: 同步readAll 批量读取) │
└──────────────────────────┬──────────────────────────────┘
│ miss
┌──────────────────────────▼──────────────────────────────┐
│ Tier 3: 全书分页缓存 (RDEPUBTextBookCache) │
│ ├─ 路径: ~/Caches/RDEPUBTextBookCache/ │
│ ├─ 格式: NSSecureCoding archive │
│ ├─ 内容: 每章的 pageRanges + breakReasons + semanticHints│
│ └─ 失效: schemaVersion 变更时全部失效 │
└─────────────────────────────────────────────────────────┘
```
---
## 3. 翻页容器层RDReaderView
### 3.1 三种翻页模式
| 模式 | 实现方式 | 特点 |
|------|----------|------|
| `pageCurl` | UIPageViewController | 原生翻书效果,手势由系统提供 |
| `horizontalScroll` | UICollectionView + RDReaderFlowLayout | 每屏显示 2 项,水平分页滚动 |
| `verticalScroll` | UICollectionView + RDReaderFlowLayout | 全宽项目,垂直连续滚动 |
### 3.2 数据源协议
### 缓存键设计
```swift
public protocol RDReaderDataSource: NSObjectProtocol {
func pageCountOfReaderView(readerView: RDReaderView) -> Int
func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView
func pageIdentifier(readerView: RDReaderView, pageNum: Int) -> String?
@objc optional func topToolView(readerView: RDReaderView) -> UIView?
@objc optional func bottomToolView(readerView: RDReaderView) -> UIView?
struct RDEPUBChapterCacheKey: Hashable {
let bookID: String // 书籍唯一标识
let spineIndex: Int // 章节索引
let renderSignature: String // 渲染参数签名(字体/字号/行距/布局/版本)
let chapterContentHash: String // 章节 HTML 的 SHA-256
}
```
### 3.3 手势分区scroll 模式)
屏幕水平三等分:
- **左 1/3**:上一页
- **中 1/3**:显示 / 隐藏工具栏
- **右 1/3**:下一页
### 3.4 翻页模式切换
`switchReaderDisplayType(_ type:)` 会完全销毁并重建底层 viewpageViewController 或 collectionView然后重新加载数据。
缓存键的四元组设计确保:
- 换字体/字号 → renderSignature 变化 → 缓存失效
- EPUB 内容更新 → contentHash 变化 → 缓存失效
- 不同书籍 → bookID 不同 → 互不干扰
---
## 4. EPUB 引擎层EPUBCore
### 4.1 Publication 层:解析与聚合
**主链路:**
## 5. 章节按需加载架构
```
epubURL
→ RDEPUBParser.parse(epubURL:)
→ extractArchive # ZIP 解压到沙盒临时目录
→ parseContainerXML # 定位 OPF 路径
→ parseOPF # 解析 metadata / manifest / spine
→ parseTOC # 解析 NCX 或 Nav 目录
→ RDEPUBPublication(parser:) # 聚合解析结果,挂载 resourceResolver
RDEPUBReaderController
├─ RDEPUBReaderContext // 共享状态中心
│ ├─ parser, publication, readingSession
│ ├─ configuration, persistence
│ └─ 便捷方法 (renderStyle, layoutConfig, cacheKey)
├─ RDEPUBReaderRuntime // 运行时协调器集合
│ ├─ chapterLoader // 章节加载器
│ ├─ chapterRuntimeStore // 内存缓存
│ ├─ summaryDiskCache // 磁盘摘要缓存
│ └─ viewportMonitor // 视口变化监控
├─ RDEPUBReaderPaginationCoordinator // 分页协调器
│ ├─ paginatePublication() // 入口
│ ├─ paginateMetadataOnly() // 后台元数据解析
│ └─ restoreBookPageMapIfPossible() // 缓存恢复
├─ RDEPUBChapterLoader // 章节加载器
│ ├─ loadChapter() // 异步加载Tier1→Tier2→全量构建
│ └─ loadChapterSynchronouslyForMigration() // 同步加载(快速打开用)
└─ RDEPUBBookPageMap // 轻量页码映射
├─ ~100KB/1000章不持有 NSAttributedString
└─ 支持增量刷新 (Builder pattern)
```
**RDEPUBPublication 暴露的能力:**
---
| 属性 / 方法 | 说明 |
|------------|------|
| `metadata` | 书名、作者、语言、layout 等 |
| `manifest` | id → RDEPUBManifestItem 映射 |
| `spine` | 有序 spine 列表(包含 href |
| `tableOfContents` | 树形目录 |
| `layout` | `.reflowable``.fixed` |
| `readingProfile` | `.webInteractive` / `.webFixedLayout` / `.textReflowable` |
| `resourceResolver` | 统一资源 URL 解析入口 |
| `fixedLayoutSpreadEnabled(for:viewportSize:)` | 判断是否启用双页 spread |
| `makeFixedSpreads(preferences:viewportSize:)` | 生成 fixed spread 页模型 |
**readingProfile 判定逻辑:**
## 6. WebView 渲染架构Web 路径)
```
layout == .fixed → webFixedLayout
layout == .reflowable + 含交互脚本 → webInteractive
layout == .reflowable + 无交互脚本 → textReflowable
RDEPUBWebView (UIView)
├─ WKWebView (RDEPUBAnnotationWebView)
│ ├─ 自定义选择菜单(拷贝/高亮/批注)
│ └─ 禁用系统手势(滚动手势由原生控制)
├─ RDEPUBResourceURLSchemeHandler
│ ├─ 协议: ss-reader://book/<path>
│ ├─ 映射: URL → 本地 EPUB 解压文件
│ └─ 容错: 缺失资源返回空响应(不 404
├─ JavaScript Bridge (6 种消息)
│ ├─ ssReaderProgressionChanged // 阅读进度更新
│ ├─ ssReaderSelectionChanged // 文本选区变化
│ ├─ ssReaderInternalLink // EPUB 内部链接
│ ├─ ssReaderExternalLink // 外部链接
│ ├─ ssReaderJSError // JS 运行时错误
│ └─ ssReaderFixedLayoutReady // 固定布局加载完成
└─ 注入脚本
├─ epub-bridge.js (502 行) // 核心桥接,分页/滚动/选区/高亮
├─ cssInjector.js (45 行) // CSS 注入管理
├─ WeReadApi.js (67 行) // 微信读书 API 兼容层
├─ rangy-core.js / rangy-serializer.js // DOM Range 序列化
└─ CSS: default/replace/dark/latin // 4 套样式表
```
### 4.2 Services 层
---
#### RDEPUBResourceResolver
## 7. 翻页容器架构RDReaderView
统一处理所有资源路径转换,是 WebView 和 Paginator 访问资源的唯一入口:
```
RDReaderView (UIView)
├─ 三种翻页模式:
│ ├─ .pageCurl → UIPageViewController (翻页动画)
│ ├─ .horizontalScroll → UICollectionView (水平滑动)
│ └─ .verticalScroll → UICollectionView (垂直滚动)
├─ 组合对象:
│ ├─ RDReaderPagingController // 翻页状态管理、请求队列
│ ├─ RDReaderPreloadController // 页面预加载、缓存管理
│ ├─ RDReaderSpreadResolver // 双页展开计算
│ └─ RDReaderTapRegionHandler // 点击区域分类(左/中/右)
├─ 数据源协议:
│ ├─ RDReaderPageProvider (新) // 格式无关,优先级高
│ └─ RDReaderDataSource (旧) // 遗留兼容,通过 Adapter 适配
└─ 手势流:
点击 → TapRegionHandler → 分类(左/中/右)
├─ 左 → goPreviousPage()
├─ 右 → goNextPage()
└─ 中 → tapCenter() (切换工具栏)
```
| 方法 | 说明 |
|------|------|
| `fileURL(forHref:)` | href → 本地文件 URL |
| `schemeURL(forHref:)` | href → `ss-reader://` 协议 URL |
| `href(forSpineIndex:)` | spineIndex → href |
| `normalizedHref(_:)` | 相对路径标准化(统一相对 OPF |
---
#### RDEPUBPaginator
## 8. 配置与设置
使用隐藏的 `WKWebView` 离屏加载每个 spine 资源,通过 JS 注入分页 CSS回调每个资源的页数
### RDEPUBReaderConfiguration
```swift
paginator.calculate(publication: publication, preferences: preferences, viewportSize: size) { pageCounts in
// pageCounts[i] = spine[i] 的页数
struct RDEPUBReaderConfiguration {
var fontSize: CGFloat // 默认 16
var fontChoice: FontChoice // 系统/宋体/楷体
var lineHeightMultiple: CGFloat // 默认 1.5
var numberOfColumns: Int // 1 或 2
var columnGap: CGFloat // 默认 20
var theme: ReaderTheme // 6 种主题
var displayType: RDReaderView.DisplayType // pageCurl/horizontal/vertical
var onDemandChapterWindowSize: Int // 默认 3奇数最小 3最大 15
var metadataParsingConcurrency: Int // 默认 CPU 核心数
var chapterWindowRadius: Int // 内存缓存窗口半径
}
```
#### RDEPUBPreferences
### 持久化
聚合 WebView 和 Paginator 共用的展示参数字号、行距、主题色、边距、布局适配模式、spread 模式。
### 4.3 Navigator 层
#### RDEPUBNavigatorState 状态机
```
initializing → loading → idle ←→ jumping
←→ moving
←→ repaginating
```
| 状态 | 含义 | 允许动作 |
|------|------|----------|
| `initializing` | 刚打开书籍 | 接收初始恢复位置 |
| `loading` | 生成首轮 page model | 缓存 pending navigation |
| `idle` | 显示稳定 | 应用 staged snapshot、处理跳转、保存位置 |
| `jumping` | 目录 / 内部链接跳转中 | 重放 pending location |
| `moving` | 用户翻页中 | 更新当前 pageNum |
| `repaginating` | 重新分页中(字号/横竖屏变化) | 缓存当前位置,等待完成 |
#### RDEPUBReadingSession
会话级协调者,持有:
- `publication`:出版物只读视图
- `activePages / activeChapters`:当前展示的页模型和章节信息
- `stagedPages / stagedChapters`:后台分页完成后暂存,等待 idle 状态时应用
- `pendingNavigationLocation / pendingNavigationPageNum`:等待当前加载完成后再执行的跳转请求
- `currentViewport / currentReadingContext`:当前可见区域的位置信息
关键操作:
```swift
session.stageSnapshot(snapshot, restoreLocation:) // 后台分页完成,暂存结果
session.consumeStagedSnapshotIfAllowed() // idle 时消费暂存(线程安全切换)
session.transition(to: .idle) // 状态跃迁
session.clearPendingNavigation() // 取消待执行跳转
```
### 4.4 Resource View 层
#### RDEPUBWebView
承载单个 spine 资源的 WKWebView按职责拆分为 4 个扩展文件:
| 扩展 | 职责 |
|------|------|
| `+Configuration` | WKWebViewConfiguration、schemeHandler 注册、user scripts |
| `+Reflowable` | 注入分页 CSS、滚动到指定 progression、接收 JS 事件 |
| `+FixedLayout` | fixed-layout HTML wrapper 生成和加载 |
| `+JavaScriptBridge` | JS ↔ Swift 消息路由、选区、高亮、进度上报 |
| `+Search` | 搜索高亮装饰 |
#### ss-reader:// 协议
所有 spine 资源XHTML、CSS、图片、字体统一通过 `ss-reader://book/<relative-path>` 访问,由 `RDEPUBResourceURLSchemeHandler` 从本地解压目录读取并返回。
优点:
- 相对资源路径在 WebView 中自然解析
- fixed-layout iframe 不再白屏
- 无需 `allowingReadAccessTo` 路径权限
| 数据 | 存储方式 | Key 前缀 |
|------|----------|----------|
| 阅读位置 | UserDefaults | `ssreader.epub.location.{bookID}` |
| 书签 | UserDefaults | `ssreader.epub.bookmarks.{bookID}` |
| 高亮标注 | UserDefaults | `ssreader.epub.highlights.{bookID}` |
| 全局设置 | UserDefaults | `ssreader.epub.settings` |
| 章节摘要 | 磁盘文件 | `~/Caches/RDEPUBChapterSummaryCache/` |
| 全书分页 | 磁盘文件 | `~/Caches/RDEPUBTextBookCache/` |
---
## 5. 文本 EPUB 渲染层EPUBTextRendering
适用于 `readingProfile == .textReflowable` 的书籍(纯文本小说类 EPUB2
### 5.1 渲染链路
## 9. 目录结构
```
章节 HTML 文件
→ RDEPUBTextRendererSupport.injectFragmentMarkers(into:) # 注入 fragment 标记
→ RDEPUBDTCoreTextRenderer.renderChapter(html:baseURL:style:)
→ DTHTMLAttributedStringBuilder # HTML → NSAttributedString
→ extractFragmentOffsets # fragment → 字符偏移量映射
→ normalizeReadingAttributes # 统一字体/行距
→ RDEPUBRenderedChapterContent
.attributedString # 渲染后的富文本
.fragmentOffsets # fragment id → 字符偏移
Sources/RDReaderView/
├── EPUBCore/ # EPUB 解析与 WebView 渲染
│ ├── Models/ # 数据模型
│ │ ├── RDEPUBAnnotationModels.swift
│ │ ├── RDEPUBPaginationModels.swift
│ │ └── RDEPUBReadingLocationModels.swift
│ ├── Resources/ # JS/CSS/HTML 静态资源
│ │ ├── epub-bridge.js
│ │ ├── cssInjector.js
│ │ ├── WeReadApi.js
│ │ ├── rangy-core.js / rangy-serializer.js
│ │ ├── wxread-default.css / wxread-dark.css / wxread-replace.css
│ │ └── epub-fixed-layout.html
│ ├── RDEPUBParser.swift # 核心解析器
│ ├── RDEPUBParser+Archive.swift # ZIP 解压
│ ├── RDEPUBParser+Package.swift # OPF 解析
│ ├── RDEPUBParser+TOC.swift # 目录解析
│ ├── RDEPUBParser+Resources.swift # 资源访问
│ ├── RDEPUBPublication.swift # 出版物门面
│ ├── RDEPUBResourceResolver.swift # URL 解析
│ ├── RDEPUBReadingSession.swift # 阅读会话状态机
│ ├── RDEPUBWebView.swift # WebView 主类
│ ├── RDEPUBWebView+*.swift # WebView 扩展(配置/重排/固定/搜索/JS桥接
│ ├── RDEPUBPaginator.swift # 离屏分页计算器
│ ├── RDEPUBSearchEngine.swift # 全文搜索引擎
│ └── ...
├── EPUBTextRendering/ # 文本渲染引擎
│ ├── Typesetter/ # 排版管线
│ │ ├── RDEPUBTypesettingPipeline.swift
│ │ ├── RDEPUBHTMLNormalizer.swift
│ │ ├── RDEPUBSemanticMarkerInjector.swift
│ │ ├── RDEPUBFragmentMarkerInjector.swift
│ │ ├── RDEPUBFontNormalizer.swift
│ │ ├── RDEPUBAttachmentNormalizer.swift
│ │ ├── RDEPUBStyleSheetComposer.swift
│ │ ├── RDEPUBRenderDiagnosticsCollector.swift
│ │ └── RDEPUBTextRendererSupport.swift
│ ├── Pagination/ # 分页引擎
│ │ ├── RDEPUBChapterPageCounter.swift
│ │ ├── RDEPUBCoreTextPageFrameFactory.swift
│ │ ├── RDEPUBPageBreakPolicy.swift
│ │ ├── RDEPUBTextLayoutFrame.swift
│ │ └── ...
│ ├── BuildPipeline/ # 全书构建
│ │ ├── RDEPUBTextBookBuilder.swift
│ │ ├── RDEPUBTextBookModels.swift
│ │ ├── RDEPUBTextBookCache.swift
│ │ ├── RDEPUBChapterTailNormalizer.swift
│ │ └── ...
│ ├── RDEPUBTextRenderer.swift # 渲染器协议与类型定义
│ ├── RDEPUBDTCoreTextRenderer.swift # DTCoreText 渲染器实现
│ ├── RDEPUBChapterData.swift # 章节查询门面
│ ├── RDEPUBTextIndexTable.swift # 全书索引表
│ └── RDPlainTextBookBuilder.swift # 纯文本 (.txt) 构建器
├── ReaderView/ # 通用翻页容器
│ ├── Paging/ # 翻页子系统
│ │ ├── RDReaderPagingController.swift
│ │ ├── RDReaderPreloadController.swift
│ │ ├── RDReaderSpreadResolver.swift
│ │ └── RDReaderTapRegionHandler.swift
│ ├── RDReaderView.swift # 主容器视图
│ ├── RDReaderView+*.swift # 扩展CollectionView/PageCurl/ToolView/ContentAccess
│ ├── RDReaderFlowLayout.swift # 自定义 CollectionView 布局
│ ├── RDReaderContentCell.swift # 滚动模式 Cell
│ └── RDReaderPageChildViewController.swift // 翻页模式子 VC
└── EPUBUI/ # 阅读器 UI
├── ReaderController/ # 控制器与协调器
│ ├── ChapterRuntime/ # 章节运行时
│ │ ├── RDEPUBChapterLoader.swift
│ │ ├── RDEPUBChapterRuntimeStore.swift
│ │ ├── RDEPUBChapterSummaryDiskCache.swift
│ │ ├── RDEPUBChapterCacheKey.swift
│ │ ├── RDEPUBBookPageMap.swift
│ │ ├── RDEPUBChapterSummary.swift
│ │ └── ...
│ ├── RDEPUBReaderContext.swift // 共享状态中心
│ ├── RDEPUBReaderRuntime.swift // 运行时协调器
│ ├── RDEPUBReaderPaginationCoordinator.swift // 分页协调器
│ ├── RDEPUBReaderAnnotationCoordinator.swift // 标注协调器
│ ├── RDEPUBReaderSearchCoordinator.swift // 搜索协调器
│ ├── RDEPUBReaderNavigationCoordinator.swift // 导航协调器
│ └── ...
├── Settings/ # 设置面板
│ ├── RDEPUBReaderSettingsViewController.swift
│ ├── RDEPUBReaderThemeSelector.swift
│ └── ...
├── TextPage/ # 文本页面渲染
│ ├── RDEPUBTextContentView.swift
│ ├── RDEPUBTextPageViewController.swift
│ └── ...
├── RDEPUBReaderController.swift # 主控制器
├── RDEPUBReaderConfiguration.swift # 配置模型
└── ...
```
### 5.2 分页
```
RDEPUBTextBookBuilder.buildBook(publication:style:pageSize:renderer:)
→ 逐章节调用 renderer.renderChapter(...)
→ RDEPUBTextPaginationSupport.paginate(attributedString:pageSize:)
→ RDEPUBTextBook章节 + 页模型 + fragment 索引)
```
### 5.3 内容显示
`RDEPUBTextContentView` 基于 `NSAttributedString` + `UITextView`(或 DTCoreText 自定义绘制),实现:
- 每页显示对应字符范围的内容
- 高亮重叠渲染
- 支持按 fragment 偏移量跳转
---
## 6. EPUBUI 层(开箱即用读者 UI
## 10. 关键设计模式
`RDEPUBReaderController` 是 library 层提供的完整读者入口,调用方只需传入 EPUB 文件 URL
```swift
let controller = RDEPUBReaderController(
epubURL: url,
configuration: .default,
persistence: RDEPUBReaderPersistence(storageKey: "my-book")
)
controller.delegate = self
present(controller, animated: true)
```
### 6.1 内置能力
| 能力 | 对应文件 |
| 模式 | 应用场景 |
|------|----------|
| 顶部工具栏(书名、返回、目录、高亮入口) | RDEPUBReaderTopToolView |
| 底部工具栏(进度条、页码) | RDEPUBReaderBottomToolView |
| 目录面板 | RDEPUBReaderChapterListController |
| 高亮管理 | RDEPUBReaderHighlightsViewController |
| 设置面板(字号、行距、主题、翻页模式) | RDEPUBReaderSettingsViewController |
| 阅读位置持久化 | RDEPUBReaderPersistence |
### 6.2 配置项RDEPUBReaderConfiguration
| 属性 | 默认值 | 说明 |
|------|--------|------|
| `fontSize` | 15 | 字号pt |
| `lineHeightMultiple` | 1.6 | 行距倍数 |
| `displayType` | `.pageCurl` | 翻页模式 |
| `landscapeDualPageEnabled` | `true` | 横屏双页 |
| `showsTableOfContents` | `true` | 是否显示目录入口 |
| `allowsHighlights` | `true` | 是否显示高亮入口 |
| `showsSettingsPanel` | `true` | 是否显示设置入口 |
| `reflowableContentInsets` | (40,16,40,16) | 可重排内容内边距 |
| `fixedContentInset` | .zero | 固定版式内容内边距 |
| `theme` | `.light` | 主题 |
| `fixedLayoutFit` | `.page` | 固定版式适配模式 |
| `fixedLayoutSpreadMode` | `.automatic` | fixed-layout spread 模式 |
| `textRenderingEngine` | `.dtCoreText` | 文本 EPUB 渲染引擎 |
---
## 7. 关键数据流
### 7.1 打开 EPUB 书籍
```
RDEPUBReaderController.init(epubURL:)
→ viewDidLoad
→ RDEPUBParser.parse(epubURL:)
→ RDEPUBPublication(parser:)
→ RDEPUBReadingSession(publication:)
→ readingProfile 判断渲染路径
textReflowable:
RDEPUBTextBookBuilder.buildBook(...) # 后台分页
→ session.stageSnapshot(...)
→ session.consumeStagedSnapshotIfAllowed() # idle 时应用
→ readerView.reloadData()
webInteractive / webFixedLayout:
RDEPUBPaginator.calculate(...) # 离屏 WebView 分页
→ session.stageSnapshot(...)
→ readerView.reloadData()
→ persistence.restoreLocation() # 恢复上次阅读位置
```
### 7.2 翻页
```
用户手势tap / swipe
→ RDReaderView 检测手势分区 / 翻页方向
→ delegate.pageNum(readerView:pageNum:)
→ RDEPUBReaderController 根据 pageNum 找 EPUBPage
→ RDEPUBReadingSession.transition(to: .moving)
→ readerView.pageContentView(pageNum:) 回调
→ 创建 RDEPUBWebContentView 或 RDEPUBTextContentView
→ loadPage(spineIndex: pageIndexInChapter: preferences:)
→ JS bridge 上报 progression → session.currentViewport 更新
```
### 7.3 定位模型RDEPUBLocation
EPUB 是可重排内容,字号 / 横竖屏变化会使页号失效。定位模型使用 `href + progression`
```swift
struct RDEPUBLocation: Codable, Equatable {
var bookIdentifier: String? // 隔离不同书的进度
var href: String // OPF 相对路径(对应 spine 资源)
var progression: Double // 视口起始位置 [0, 1]
var lastProgression: Double? // 视口末尾位置 [0, 1]
var fragment: String? // 锚点
var rangeAnchor: RDEPUBTextRangeAnchor? // 文本范围锚点(用于文本 EPUB 高亮定位)
}
```
**恢复流程:**
1. 标准化 `href`(相对 OPF
2. 找到对应 spineIndex
3. 由 `navigationProgression` 估算扁平页号
4. 若有 `fragment`,加载后在 WebView 中锚点滚动
---
## 8. 已知限制与后续待办
| 问题 | 说明 |
|------|------|
| RDEPUBReaderController 过大 | 约 1995 行,仍混有 EPUB 加载、UI 管理、数据源逻辑,待进一步拆分 |
| 部分 UI 文件仍然偏大 | RDEPUBTextContentView ~728 行、RDEPUBTextBookBuilder ~717 行,需继续拆分 |
| 横竖屏切换已接入一阶段支持 | `RDEPUBReaderController.viewWillTransition()` 统一接管正文重分页,`RDReaderView` 保留容器级双页布局刷新;仍需补齐固定回归矩阵 |
| 固定手势分区比例 | 三等分固定写死,不支持自定义 |
| 自动化测试为首批接入状态 | 已有 `ReadViewSDKDemoTests` / `ReadViewSDKDemoUITests` 与 parser / resolver / persistence / smoke 用例,分页、恢复链路和更多 UI 闭环仍需继续补齐 |
| 基线验证待跑 | 四本样书(凡人修仙传、爱忘事熊爷爷、宝山辽墓、张学良传)的分页耗时、目录命中率、末页事件等数据尚未收集 |
---
## 9. 构建与依赖
### 9.1 安装
```bash
cd RDReaderDemo
pod install
# 打开 RDReaderDemo.xcworkspace选 RDReaderDemo scheme构建
```
### 9.2 主要依赖
| 依赖 | 用途 | 使用层 |
|------|------|--------|
| ZIPFoundation (~> 0.9) | EPUB ZIP 解压 | RDEPUBParser+Archive |
| DTCoreText | HTML → NSAttributedString | RDEPUBDTCoreTextRenderer |
| SnapKit | Demo 布局 | Demo 层 |
| SSAlertSwift | Demo 弹窗 | Demo 层 |
### 9.3 podspec 关键配置
- `s.source_files = 'Sources/RDReaderView/**/*.{swift}'`(递归包含所有子目录)
- `s.resource_bundles`:包含 JS、CSS 等资源文件
- Library 本身无需 demo 层依赖
| **Coordinator** | 分页/标注/搜索/导航各一个协调器,解耦 Controller |
| **Facade** | `RDEPUBPublication` 封装 `RDEPUBParser``RDEPUBChapterData` 封装章节查询 |
| **Builder** | `RDEPUBBookPageMap.Builder` 增量构建页码映射 |
| **Strategy** | `RDEPUBTextRenderer` 协议,可替换渲染器实现 |
| **Pipeline** | `RDEPUBTextTypesetterPipeline` 8 阶段排版管线 |
| **State Machine** | `RDEPUBNavigatorState` 管理阅读器状态转换 |
| **Adapter** | `RDReaderLegacyDataSourceAdapter` 适配旧数据源协议 |
| **三级缓存** | 内存 → 磁盘摘要 → 全书分页,逐级降级 |
| **Token 取消** | `paginationToken` 确保过期异步任务不干扰新任务 |
| **Frozen Parameters** | 后台任务冻结 `renderSignature`,避免运行中参数漂移 |

546
Doc/BUSINESS_LOGIC.md Normal file
View File

@ -0,0 +1,546 @@
# ReadViewSDK 业务逻辑文档
> 最后更新2026-06-04
---
## 1. EPUB 解析流程
### 1.1 文件解压
**入口:** `RDEPUBParser.parse(epubURL:)`
EPUB 文件本质是 ZIP 压缩包。解压流程:
1. 计算缓存目录:`~/Library/Caches/ssreaderview-epub/{slug}-{fileSize}-{modifiedTimestamp}/`
2. 如果缓存目录已存在且包含 `META-INF/container.xml`,跳过解压
3. 否则使用 `ZIPFoundation` 解压到缓存目录
4. 解压是幂等的,重复调用不会重复解压
### 1.2 OPF 解析
OPFOpen Packaging Format是 EPUB 的核心描述文件。使用 SAX 解析(`XMLParser` + `XMLParserDelegate`)以降低内存占用。
**解析内容:**
| 区域 | 提取字段 |
|------|----------|
| `<metadata>` | identifier, title, author, language, version, rendition:layout, rendition:spread, readingProgression |
| `<manifest>` | id, href, media-type, properties, fallback, media-overlay |
| `<spine>` | idref, linear, properties, page-spread |
| `<spine toc="...">` | NCX 文件 id |
**Spine 构建:** 将 spine 引用映射到 manifest 条目,规范化 href 相对于 OPF 目录解析每个条目的布局覆盖rendition:layout-pre-paginated/reflowable
### 1.3 目录解析
TOC目录按优先级尝试三种来源
1. **NCXEPUB 2** 解析 `<navPoint>` 树形结构,支持嵌套
2. **Navigation DocumentEPUB 3** 解析 `<nav epub:type="toc">` 中的 `<ol>/<li>/<a>` 结构
3. **回退:** 直接使用 spine 条目的 title 列表
### 1.4 阅读配置文件判断
```swift
enum RDEPUBReadingProfile {
case webInteractive // 通用 EPUB通过 WebView 渲染
case webFixedLayout // 固定布局 EPUB漫画、杂志
case textReflowable // 文本重排,通过 CoreText 渲染(本 SDK 核心路径)
}
```
判断逻辑(`RDEPUBParser+ReadingProfile.swift`
- `rendition:layout == pre-paginated``.webFixedLayout`
- 纯 HTML/XHTML 内容且无复杂交互 → `.textReflowable`
- 其他 → `.webInteractive`
---
## 2. 文本渲染管线
### 2.1 HTML 预处理Typesetter Pipeline
`RDEPUBTextTypesetterPipeline.makeRequest(from:)` 按顺序执行 8 个阶段:
**阶段 1HTML 规范化** (`RDEPUBHTMLNormalizer`)
- 删除 `<hr lang="zh-CN">分页符</hr>` 分页标记
- CR → LF合并连续空行
- 规范化附件 HTML 标记:
- `div.qrbodyPic / div.bodyPic` → 合并样式到 `<img>`
- `img.qqreader-footnote` → 行内 1em×1em
- `h1.frontCover > img` → 封面图片 100% 宽度
**阶段 2语义标记注入** (`RDEPUBSemanticMarkerInjector`)
- 遍历所有 HTML 标签,维护开标签栈
- 为有分页语义的标签注入标记:
- `${rd-sem-start:id=X;block=...;hints=...;placement=...}`
- `${rd-sem-end:id=X}`
- 空标签img, br, hr同时注入开始和结束标记
**阶段 3样式表内联** (`RDEPUBRenderDiagnosticsCollector`)
- 查找 `<link rel=stylesheet href=...>`
- 读取 CSS 文件内容
- 重写 CSS 中的相对 `url()` 引用
- 内联到 HTML 的 `<head>`
**阶段 4CSS 层合成** (`RDEPUBStyleSheetComposer`)
- 构建 5 层 CSS按优先级从低到高
1. **default** - 基础阅读器样式(隐藏 head/title/style默认字体大小
2. **replace** - 微信读书风格格式化(代码块、标题、引用)
3. **dark** - 暗色模式覆盖(仅暗色主题时注入)
4. **epub** - EPUB 自带样式
5. **user** - 用户设置(字号、行距、颜色,!important
- 语言检测:检查 lang 属性和文本采样,拉丁语言使用专用 CSS
**阶段 5字体注册** (`RDEPUBFontNormalizer`)
- 解析 `@font-face { url(...) }`
- 通过 `CTFontManagerRegisterFontsForURL(.process)` 注册嵌入字体
- 已注册字体路径缓存在 `registeredFontPaths` 集合中,避免重复注册
**阶段 6Base URL 注入** (`RDEPUBHTMLNormalizer`)
- 注入 `<base href="...">` 用于相对路径解析
**阶段 7Fragment 标记注入** (`RDEPUBFragmentMarkerInjector`)
- 查找 `<tag id="xxx">` 标签
- 在其前面注入 `${id=xxx}` 标记
**阶段 8图片诊断收集** (`RDEPUBRenderDiagnosticsCollector`)
- 扫描 `<img src="...">` 标签
- 解析引用路径,检查文件是否存在
- 收集诊断信息用于调试
### 2.2 HTML→NSAttributedString
`RDEPUBDTCoreTextRenderer.renderChapter(request:)`
1. 将 HTML 编码为 `Data`
2. 使用 `DTHTMLAttributedStringBuilder` 构建 `NSAttributedString`
3. 在 `willFlushCallback` 中对每个 DOM 元素调用 `RDEPUBAttachmentNormalizer.prepareHTMLElementForReaderRendering()`
4. 后处理:
- `applyPaginationSemantics()` - 将 `${rd-sem-start/end}` 标记转为 NSAttributedString 属性
- `extractFragmentOffsets()` - 提取 fragment ID→offset 映射,删除标记文本
- `normalizeReadingAttributes()` - 规范化字体、行距、颜色、附件
### 2.3 分页计算
`RDEPUBChapterPageCounter.layoutFrames()` 使用迭代循环:
```
location = 0
while location < totalLength:
1. 创建 CTFrame通过 CTFramesetter
2. 获取可见范围 (CTFrameGetVisibleStringRange)
3. 应用 avoidPageBreakInside 规则(最多移除 3 行尾部)
4. 应用 keepWithNext 规则(最多移除 3 行尾部)
5. 应用 widow/orphan 控制
6. 应用 pageBreakPolicy 调整
7. 记录页面范围
8. location = 调整后的范围末尾
```
**分页规则优先级:**
| 规则 | 说明 | 最大调整行数 |
|------|------|-------------|
| avoidPageBreakInside | 块内不分页(标题、图片等) | 3 行 |
| keepWithNext | 标题与正文不分离 | 3 行 |
| widow control | 段落最后一行不留到下一页 | 1 行 |
| orphan control | 段落第一行不单独在上一页 | 1 行 |
| pageRelate | 微信读书跨页关联 | 1 行 |
| attachment boundary | 块级图片前后分页 | 0精确切分 |
### 2.4 尾页规范化
`RDEPUBChapterTailNormalizer.normalize()` 三遍处理:
1. **删除空白中间帧:** 无可见字符且无附件的帧
2. **删除空白尾部帧:** 从末尾开始删除同类空白帧
3. **合并短尾帧:** 如果最后一帧 ≤2 个可见字符且前一帧 ≥8 倍长,合并
---
## 3. 后台元数据解析(大书优化)
### 3.1 整体流程
对于《凡人修仙传》这样的大书(~1000 章),后台解析是核心性能路径。
```
打开书籍
├─ 尝试磁盘缓存恢复restoreBookPageMapIfPossible
│ └─ 成功 → 直接显示完整页码
├─ 快速打开3-5 章窗口)
│ ├─ 同步加载首个可渲染章节
│ ├─ 加载窗口内相邻章节
│ └─ 应用局部 BookPageMap → 用户可立即阅读
└─ 后台解析paginateMetadataOnly
├─ 预计算所有章节 contentHash串行~1-3s
├─ 恢复已有磁盘缓存
├─ 等待用户操作冷却 0.8s
├─ 并发渲染未缓存章节OperationQueueN=CPU核心数
├─ 每 32 章增量刷新 UI
└─ 最终完整刷新
```
### 3.2 预计算 contentHash 优化
**问题:** 每个章节在渲染后需要构建缓存键,原始实现会重新读取 HTML 并计算 SHA-256。
**优化:** 在后台解析开始时,串行预计算所有章节的 contentHash
```swift
var contentHashBySpineIndex: [Int: String] = [:]
for spineIndex in allBuildableIndices {
let html = parser.htmlString(forRelativePath: href)
contentHashBySpineIndex[spineIndex] = html?.sha256Hex ?? ""
}
```
后续所有 `chapterCacheKey` 调用都使用预计算值,避免重复 I/O 和 SHA-256 计算。
### 3.3 冻结 renderSignature
**问题:** 如果用户在后台解析进行中更改字号/行距,`context.currentRenderSignature()` 会返回新值,导致部分章节用旧签名、部分用新签名写入磁盘缓存,造成缓存不一致。
**解决:** 在 `paginateMetadataOnly` 开始时冻结签名:
```swift
let renderSignature = context.currentRenderSignature()
// 后续所有 chapterCacheKey 调用使用此固定值
```
token 机制确保设置变更会触发新的解析任务(新 token旧任务自动废弃。
### 3.4 锁区瘦身
**原始实现:** `resultLock` 内调用 `buildPageMap()`,该方法遍历全量 catalog 和 summaries。
**优化:** 锁内只做写入和计数,快照数据后锁外构建 pageMap
```swift
var snapshot: [Int: RDEPUBChapterSummary]?
resultLock.lock()
summariesBySpineIndex[spineIndex] = renderResult
totalResolvedCount += 1
if shouldRefresh {
snapshot = summariesBySpineIndex // 快照
}
resultLock.unlock()
if let snapshot {
let partialMap = buildPageMap(from: catalog, summaries: snapshot) // 锁外
}
```
### 3.5 可配置刷新间隔
```swift
static var pageMapRefreshInterval: Int = 32 // 默认 32 章
```
可实测调优32 / 48 / 64。值越大UI 刷新频率越低,后台解析吞吐越高。
---
## 4. 章节按需加载
### 4.1 加载优先级
`RDEPUBChapterLoader.loadChapter(spineIndex:store:)` 按以下优先级:
1. **Tier 1 内存缓存** - `store.chapterData(for: spineIndex)` → 直接返回
2. **Tier 1 页数缓存** - `store.pageCount(for: cacheKey)` → 跳过分页计算
3. **Tier 2 磁盘缓存** - `summaryDiskCache.read(for: cacheKey)` → 恢复页范围
4. **全量构建** - 渲染 + 分页 + 写缓存
### 4.2 窗口驱逐策略
`RDEPUBChapterRuntimeStore.setCurrentChapter(spineIndex:totalSpineCount:windowRadius:)`
- 保留范围:`[spineIndex - windowRadius, spineIndex + windowRadius]`
- 窗口半径由 `configuration.chapterWindowRadius` 控制
- 超出窗口的章节从内存缓存中驱逐
- 内存警告时驱逐除当前章节外的所有缓存
### 4.3 BookPageMap 增量刷新
`RDEPUBBookPageMap` 是轻量页码映射(~100KB/1000 章),不持有 `NSAttributedString`
Builder 模式支持增量构建:
```swift
var builder = RDEPUBBookPageMap.Builder()
for item in catalog {
if let summary = summaries[item.spineIndex] {
builder.add(spineIndex:href:title:pageCount:fragmentOffsets:)
}
}
return builder.build()
```
---
## 5. 翻页容器逻辑
### 5.1 三种翻页模式
| 模式 | 底层实现 | 特点 |
|------|----------|------|
| pageCurl | UIPageViewController | 翻页动画,系统手势 |
| horizontalScroll | UICollectionView + 自定义 Layout | 水平滑动,分页锁定 |
| verticalScroll | UICollectionView + 自定义 Layout | 垂直滚动,连续滚动 |
### 5.2 双页展开Landscape Dual Page
**触发条件:** `landscapeDualPageEnabled && isLandscape && !verticalScroll`
**页面配对逻辑RDReaderSpreadResolver**
- 有封面页:封面独占一屏,后续页面两两配对
- 配对规则:`(coverIndex, nil)`, `(1, 2)`, `(3, 4)`, ...
- 无封面页:标准偶奇配对
- 配对规则:`(0, 1)`, `(2, 3)`, `(4, 5)`, ...
**封面感知布局RDReaderFlowLayout**
```
封面页: [====全屏宽度====]
配对页: [==半宽==][==半宽==]
```
### 5.3 页面预加载
`RDReaderPreloadController` 管理两个缓存:
- `preloadedPageViews` - 预渲染的页面视图
- `pageCurlCachedViews` - 当前显示的页面视图
**预加载策略:**
1. 每次页面变化后调用 `prime(around:preferredForward:)`
2. 计算预测目标:前后各 `radius` 个展开页 + 预测方向额外 1 个展开页
3. 将目标页面预渲染到隐藏的 `preloadHostView`
4. 缓存签名displayType + isLandscape + pagesPerScreen + boundsSize变化时清除缓存
### 5.4 点击区域处理
屏幕三等分:
```
[ 左 1/3 ][ 中 1/3 ][ 右 1/3 ]
上一页 切换工具栏 下一页
```
RTL 模式下左右互换。工具栏显示时,左右区域变为 `.center`(隐藏工具栏)。
---
## 6. 标注系统
### 6.1 数据模型
```swift
// 统一标注类型
struct RDEPUBAnnotation {
let id: String
let kind: RDEPUBAnnotationKind // .bookmark, .highlight, .underline
let location: RDEPUBLocation
let text: String?
let color: String?
let note: String?
}
// 书签
struct RDEPUBBookmark {
let id: String
let location: RDEPUBLocation
let chapterTitle: String?
let note: String?
}
// 高亮
struct RDEPUBHighlight {
let id: String
let location: RDEPUBLocation
let text: String
let style: RDEPUBHighlightStyle // .highlight, .underline
let color: String
let note: String?
}
```
### 6.2 选区处理流程
```
用户长按 → WKWebView 选区变化
JS Bridge: ssReaderSelectionChanged
RDEPUBReaderAnnotationCoordinator
├─ 解析选区位置 (RDEPUBLocation)
├─ 提取选中文本
├─ 创建 RDEPUBSelection
└─ 显示操作菜单(拷贝/高亮/批注)
```
### 6.3 高亮渲染
文本模式下,高亮通过 NSAttributedString 属性注入:
```swift
// 注入高亮属性
attributedString.addAttribute(
kRDEPUBHighlightAttributeName, // "com.rdreader.highlight"
value: highlight,
range: nsRange
)
// 注入下划线属性
attributedString.addAttribute(
kRDEPUBUnderlineAttributeName, // "com.rdreader.underline"
value: highlight,
range: nsRange
)
```
CoreText 渲染时识别这些自定义属性并绘制高亮背景/下划线。
---
## 7. 搜索系统
### 7.1 全文搜索
`RDEPUBTextSearchEngine.search(keyword:)`
1. 遍历所有 linear spine 条目html/xhtml 类型)
2. 读取 HTML转为纯文本
- 优先:`NSAttributedString(data:options:documentAttributes:)` with `.documentType: .html`
- 回退:正则去除 HTML 标签
3. 执行大小写不敏感的 `NSString.range(of:options:)` 搜索
4. 为每个匹配生成:
- `progression`0.0-1.0 的阅读进度
- `previewText`:匹配位置前后各 12 字符
- `rangeAnchor`:精确的文本锚点
### 7.2 搜索结果导航
搜索结果通过 `RDEPUBSearchState` 管理:
```swift
struct RDEPUBSearchState {
let keyword: String
let matches: [RDEPUBSearchMatch]
var currentMatchIndex: Int?
}
```
WebView 通过 JS Bridge 接收搜索高亮数据,在 DOM 中绘制高亮矩形。
---
## 8. 设置与主题
### 8.1 配置变更流程
```
用户修改设置
RDEPUBReaderConfiguration 更新
├─ 字号/字体/行距变更 → 触发重新分页
│ └─ renderSignature 变化 → 磁盘缓存失效 → 后台重新解析
├─ 主题变更 → 刷新可见内容
│ └─ CSS 层重建dark/user 层)
├─ 显示模式变更 → 切换翻页容器
│ └─ switchReaderDisplayType()
└─ 列数变更 → 触发重新分页
└─ layoutConfig.cacheSignature 变化
```
### 8.2 主题系统
6 种内置主题,通过 `RDEPUBReaderThemeSelector` 选择:
| 主题 | 背景色 | 文字色 |
|------|--------|--------|
| 默认白 | #FFFFFF | #333333 |
| 护眼绿 | #CCE8CF | #333333 |
| 牛皮纸 | #E8D8B8 | #333333 |
| 深灰 | #333333 | #CCCCCC |
| 纯黑 | #000000 | #B4B4B6 |
| 暗蓝 | #1A2332 | #8A9BB5 |
暗色主题(深灰/纯黑/暗蓝)注入 `wxread-dark.css` 覆盖层。
### 8.3 持久化策略
设置通过 `RDEPUBReaderPersistence` 协议持久化:
```swift
protocol RDEPUBReaderPersistence {
func saveLocation(_ location: RDEPUBLocation, for bookIdentifier: String)
func loadLocation(for bookIdentifier: String) -> RDEPUBLocation?
func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String)
func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark]
func saveHighlights(_ highlights: [RDEPUBHighlight], for bookIdentifier: String)
func loadHighlights(for bookIdentifier: String) -> [RDEPUBHighlight]
}
```
默认实现使用 `UserDefaults`,键格式:
- 位置:`ssreader.epub.location.{bookID}`
- 书签:`ssreader.epub.bookmarks.{bookID}`
- 高亮:`ssreader.epub.highlights.{bookID}`
- 设置:`ssreader.epub.settings`
---
## 9. 纯文本 (.txt) 支持
`RDPlainTextBookBuilder` 复用 EPUB 渲染管线处理 .txt 文件:
1. **解码:** 依次尝试 UTF-8 → GB18030 → GBK
2. **分章:** 正则匹配 `^(第[零一二三四五六七八九十百千万\d]+[章节回卷].*)$`
3. **包装 HTML** 每行包裹 `<p>` 标签
4. **渲染+分页:** 复用 `RDEPUBTextRenderer``RDEPUBCoreTextPageFrameFactory`
---
## 10. 性能关键路径
### 10.1 首次打开(冷启动)
| 阶段 | 耗时占比 | 优化方向 |
|------|----------|----------|
| ZIP 解压 | ~5% | 缓存解压目录 |
| OPF 解析 | ~1% | SAX 流式解析 |
| 首章渲染 | ~15% | 快速打开路径 |
| 后台全量解析 | ~70% | 并发、缓存、预计算 |
| 磁盘 I/O | ~9% | 异步写入、批量读取 |
### 10.2 二次打开(热缓存)
| 阶段 | 耗时占比 | 说明 |
|------|----------|------|
| 磁盘摘要恢复 | ~30% | readAll 批量读取 |
| BookPageMap 构建 | ~5% | Builder 模式 |
| UI 应用 | ~65% | 刷新 collection view |
### 10.3 关键优化项
| 优化 | 收益 | 文件 |
|------|------|------|
| 预计算 contentHash | 消除每章重复 HTML 读取 + SHA-256 | RDEPUBReaderPaginationCoordinator |
| 冻结 renderSignature | 避免参数漂移导致缓存不一致 | RDEPUBReaderContext |
| 锁区瘦身 | 降低并发锁竞争 | RDEPUBReaderPaginationCoordinator |
| 可配置并发数 | 适配不同设备 | RDEPUBReaderConfiguration |
| 可配置刷新间隔 | 平衡 UI 响应和吞吐 | RDEPUBReaderPaginationCoordinator |

View File

@ -1,295 +0,0 @@
# ReadViewSDK 代码规范
## 适用范围
本文档适用于 `ReadViewSDK` 新增代码与重构代码。
- 规范覆盖 `Sources/``RDReaderDemo/` 中的 Swift 代码。
- 命名、分层与职责边界以 SDK 可维护性和可扩展性为优先。
- 本文档分为两类内容:
- `已观察到的约定`:当前工程常见写法。
- `建议统一的规范`:后续统一执行的规则。
## 新架构目录与职责
### 已观察到的约定
- `Sources/RDReaderView`:阅读核心容器、翻页能力与基础视图。
- `EPUBCore`EPUB 解析、资源定位、导航状态、分页与会话协调。
- `EPUBTextRendering`:文本渲染引擎与分页支持。
- `EPUBUI`:可开箱即用的 Reader UI 层。
- `RDReaderDemo`:示例应用与调试入口。
### 建议统一的规范
- 新增业务能力优先归入 `Sources/RDReaderView` 下的对应模块目录。
- `Core` 层只承载解析、会话、状态机与通用能力,不写页面级交互。
- `UI` 层只承载展示、事件分发和轻量状态同步,不直接处理底层解析逻辑。
- 若模块持续膨胀,优先在当前模块下继续拆分子文件,不跨目录散落实现。
## 命名规范
### 已观察到的约定
- 当前工程历史命名以 `SS`、`RDEPUB` 开头。
- 控制器常用 `...Controller`,视图常用 `...View`,会话对象常用 `...Session`
- 扩展文件采用 `类型名+功能域.swift`,如 `Parser+Archive.swift`
- 事件方法常使用 `Action` 结尾,例如 `pageTapAction()`、`themeChangeAction()`。
- 绑定数据的方法常使用 `bind`、`update`、`refresh`、`configure` 等动词。
### 建议统一的规范
- **所有新增类型必须以 `RD` 开头。**
- EPUB 相关类型统一以 `RDEPUB` 开头。
- 方法名、变量名沿用 Swift 小驼峰,不增加额外前缀。
- 类型名应反映职责,不使用过宽泛的后缀;只有真正承担协调逻辑时才使用 `Manager`、`Handler`。
- 事件处理方法统一使用"对象/意图 + Action"命名,例如 `pageTapAction`、`themeChangeAction`。
- 数据绑定方法优先使用以下语义:
- `bind...`:将模型绑定到视图或模块。
- `update...`:增量刷新已有界面或状态。
- `configure...`:一次性配置样式或依赖。
- `refresh...`:重新拉取或重建数据状态。
- 避免新增拼写不一致的方法名;若发现历史命名拼写错误,新增代码必须使用正确拼写,旧接口修复时应配合调用点一起调整。
命名示例:
- `RDReaderView`
- `RDEPUBParser`
- `RDEPUBPublication`
- `RDEPUBReadingSession`
- `RDEPUBReaderController`
- `RDEPUBReaderTheme`
扩展文件命名示例:
```text
RDEPUBParser.swift
RDEPUBParser+Archive.swift
RDEPUBParser+Package.swift
RDEPUBParser+TOC.swift
RDEPUBParser+Resources.swift
RDEPUBWebView.swift
RDEPUBWebView+Configuration.swift
RDEPUBWebView+Reflowable.swift
```
## 分层与职责边界
### 已观察到的约定
- 阅读入口负责容器装配、翻页模式切换和事件分发。
- EPUB 核心层负责解析、导航、分页、资源读取与定位。
- 渲染层负责 HTML/富文本渲染与分页支持。
- UI 层负责主题、工具栏、目录、设置等交互能力。
### 建议统一的规范
- `RD...Controller`:负责页面级编排与流程调度,不承载复杂渲染细节。
- `RD...View`:负责展示与局部交互,不承载完整业务流程。
- `RDEPUB...Core`:负责解析、会话状态与数据模型,不依赖具体页面。
- 配置、主题、定位、进度模型统一下沉为 `struct`
- 跨层通信优先通过会话层或协议,不做跨层直接写状态。
## UI 与布局规范
### 已观察到的约定
- 视图多采用 `lazy var` 初始化,并在闭包内完成默认配置。
- 自定义 View 通常在 `init(frame:)` 或业务绑定后调用 `initView()` 完成视图树搭建。
- 复杂页面使用分区 extension 组织代理与事件实现。
- 页面或组件内部按职责拆分样式方法,例如 `topBarStyle()`、`contentStyle()`。
- 页面经常通过回调闭包把交互抛给外层,例如 `pageChangeCallback`、`selectionCallback`。
- 模块内存在多处布局方式混用情况。
### 建议统一的规范
- SDK 层新增 UI 使用 Auto Layout 原生约束Demo 层可使用 SnapKit单文件内不混用多套布局体系。
- 视图层初始化顺序保持一致:
- 定义属性与子视图
- 在 `initView()` 中组装视图树
- 在独立方法中拆分样式和状态刷新逻辑
- 当约束会被多次切换时:
- 首次创建使用 `makeConstraints`SnapKit`NSLayoutConstraint`
- 重建结构使用 `remakeConstraints`SnapKit或先移除再添加
- 仅修改常量时使用 `updateConstraints`SnapKit或修改 `constant` 属性
- 布局分支明显时,优先拆成语义化私有方法,不要把所有状态分支堆在一个超长方法里。
- 对外暴露的 UI 刷新入口建议以 `bind``update` 开头,避免把布局细节暴露给调用方。
- 交互事件通过闭包或 delegate 抛出,避免子视图持有上层业务依赖。
## 交互与状态处理规范
### 已观察到的约定
- 事件处理使用 `@objc` + selector。
- 异步回调中广泛使用 `[weak self]`
- 状态判断常通过 `guard` 提前返回。
### 建议统一的规范
- 按钮、通知、系统回调放入独立 extension 分组。
- 异步闭包默认先使用 `[weak self]`,仅在必要时改强引用。
- 多前置条件入口统一先 `guard` 校验,减少嵌套。
- 导航与进度恢复统一通过 `RDEPUBReadingSession` 协调。
## 代码风格细则
### 已观察到的约定
- extension 分区较常见。
- 解析与渲染模型多数采用 `struct`
- 存在少量历史命名不统一与可选值处理不一致情况。
### 建议统一的规范
- 默认遵循”最小可见性”:`private` > `fileprivate` > `internal` > `public`
- 纯数据模型使用 `struct` + `Codable` + `Equatable`
- 服务对象使用 `final class`,避免无意义继承。
- 错误类型统一使用 `enum + LocalizedError`
- 新增代码避免强制解包;若当前上下文无法避免,至少先在上层收敛边界。
- 统一优先使用 `guard` 做前置失败处理,减少深层嵌套。
- extension 的拆分原则以”单一职责”优先:
- 事件处理一组
- 代理 / DataSource 实现一组
- 工具方法一组
- 通知适配一组
- 调试代码继续使用 `#if DEBUG` 包裹,不把调试边框、日志、测试分支直接带入正式逻辑。
## 文件组织规范
### 已观察到的约定
- 目录按阅读容器、EPUB Core、渲染、UI 分层组织。
- 大类通过 `+Extension` 文件拆分职责。
### 建议统一的规范
- 目录保持以下分层,不跨层放置实现:
- `Sources/RDReaderView/EPUBCore`
- `Sources/RDReaderView/EPUBTextRendering`
- `Sources/RDReaderView/EPUBUI`
- 单文件建议不超过 `600` 行;超出后按职责拆分 extension 文件。
- extension 文件命名统一 `RD类型名+功能域.swift`
## 注释规范
- 注释、错误提示、日志统一使用中文。
- 关键流程方法保留”为什么这样做”的注释,不写重复代码字面行为的注释。
- 调试输出统一放在 `#if DEBUG` 下。
- 新代码保留有信息量的注释,避免重复描述显而易见的代码行为。
## 已观察到的项目模式
### 模式 1统一入口 + 扩展拆分
- `RDEPUBParser` 负责 EPUB 解析主入口,不同能力拆到 `+Archive`、`+Package`、`+TOC`、`+Resources` 等扩展文件。
- `RDEPUBReadingSession` 负责阅读会话主入口,状态管理、分页、定位等能力拆到扩展文件。
- 该模式适合继续用于解析器、会话管理、控制器工具方法等横向能力。
### 模式 2页面编排在 Controller局部交互下沉到 View
- `RDEPUBReaderController` 负责阅读页整体编排:翻页容器装配、工具栏切换、阅读位置恢复。
- `RDReaderView` 负责分页容器布局与翻页交互,并通过 DataSource / Delegate 把数据需求交回外层。
- 该模式保持 Controller 管流程、View 管展示的职责分离。
### 模式 3列表与容器逻辑通过扩展拆开
- 复杂容器将 `UICollectionViewDataSource`、`UICollectionViewDelegate`、`UICollectionViewDelegateFlowLayout` 分别拆分到 extension。
- 该模式降低单文件中主逻辑与代理逻辑的耦合,适合继续用于任何包含列表或容器的组件。
### 模式 4渲染路径抽象
- `RDEPUBReadingProfile` 根据 EPUB 特征自动选择渲染路径(`webFixedLayout` / `webInteractive` / `textReflowable`)。
- 新增渲染相关功能时,必须评估对三种路径的覆盖情况。
## 待统一项
- 当前访问控制级别存在混用:同一类里 `public`、默认 `internal`、`private` 并存,建议后续新增代码默认从最小可见范围开始声明。
- 当前存在少量强制解包,建议新增代码优先通过前置校验收敛风险。
- 当前存在拼写不一致问题,建议后续新增代码统一使用标准英文单词,旧接口如需修复应配合调用点一起调整。
- 当前部分注释偏”过程说明”或遗留调试注释,建议新代码保留有信息量的注释。
- 当前个别 View 在数据绑定阶段再次调用 `initView()` 重建界面,这种方式在复杂组件中容易引入重复添加子视图或状态不一致。建议新增组件优先区分”初始化视图结构”和”刷新数据状态”两个阶段。
## 禁忌事项
| 禁忌 | 替代做法 |
|------|----------|
| 新增类型不加 `RD` 前缀 | 所有新增类型统一 `RD` / `RDEPUB` 前缀 |
| UI 层直接拼装解析状态 | 通过 `RDEPUBReadingSession` 获取状态 |
| 控制器直接操作底层解析细节 | 通过 `RDEPUBPublication`、`RDEPUBParser` 暴露接口 |
| 强制解包可选值 | `guard let` / `if let` |
| 用页号单独恢复阅读进度 | 统一使用 `href + progression` |
## 使用建议
- 新增功能前先确定目录归属和职责边界。
- 命名先定前缀再落代码:类型一律 `RD` 开头。
- 若需迁移历史 `SS` 前缀,按模块渐进替换,优先替换新增与重构触达文件。
## 旧 SS 命名迁移到 RD 的执行状态
**迁移已完成**:源码中已无 `SS` 前缀类型定义,全部使用 `RD`/`RDEPUB` 前缀。新增代码必须继续遵守此规则。
### 阶段 0冻结新增 SS 命名(已完成)
- 目标:从当前时点开始,不再引入新的 `SS`/`RDEPUB` 类型名。
- 动作:
- 新增类型统一使用 `RD`/`RDEPUB` 前缀。
- Code Review 增加命名检查项:发现新增 `SS` 命名必须驳回。
- 在 PR 模板中加入”本次是否新增旧前缀命名”勾选项。
- 验收:
- 新提交代码中,新增类型 `SS` 前缀数量为 `0`
### 阶段 1建立迁移映射表第 1 周)
- 目标:明确“旧名 -> 新名”一一映射,避免多人并行改名冲突。
- 动作:
- 统计核心公开类型、内部核心类型、测试类型三类清单。
- 建立命名映射表,例如:
- `RDReaderView` -> `RDReaderView`
- `RDEPUBParser` -> `RDEPUBParser`
- `RDEPUBReadingSession` -> `RDEPUBReadingSession`
- 对外 API 单独标记“需兼容过渡”的类型。
- 验收:
- 映射表覆盖全部高频核心类型,且团队评审通过。
### 阶段 2先迁移内部类型第 2-3 周)
- 目标:优先改内部实现,降低外部兼容压力。
- 动作:
- 按模块分批迁移:`EPUBCore` -> `EPUBTextRendering` -> `EPUBUI`
- 每批次只改一个子模块,避免超大 PR。
- 同步修复调用点、扩展文件名与注释中的旧命名。
- 验收:
- 目标模块内类型命名全部满足 `RD` 规则。
- 编译通过Demo 阅读主流程可用。
### 阶段 3迁移公开 API 并保留兼容层(第 3-4 周)
- 目标:完成对外接口改名,同时给接入方提供平滑升级窗口。
- 动作:
- 对外公开类型切换为 `RD` 命名。
- 旧公开类型保留兼容别名,并标注废弃说明(`deprecated`)。
- 在 Release Note 提供“旧名/新名对照表”和迁移示例。
- 验收:
- 新接入示例仅使用 `RD` 命名。
- 旧接入代码在兼容期内无需立即改动即可编译。
### 阶段 4清理兼容层与收口下一主版本
- 目标:在约定主版本移除旧前缀,完成命名收口。
- 动作:
- 删除 `SS` 兼容别名与过渡代码。
- 清理文档、注释、示例工程中的旧前缀残留。
- 对外发布最终迁移公告与升级说明。
- 验收:
- 工程内无 `SS`/`RDEPUB` 类型定义残留。
- 文档与示例代码全部为 `RD`/`RDEPUB` 命名。
### 迁移过程约束
- 每次迁移 PR 必须包含:
- 命名改动清单
- 影响范围说明
- 回归验证结果编译、Demo 主流程、关键阅读路径)
- 禁止在同一 PR 中同时做“大规模命名迁移 + 业务逻辑重构”。
- 若改名会影响外部接入,必须先补迁移文档再合并代码。

View File

@ -1,402 +0,0 @@
# EPUBCore 功能实现逻辑
## 1. 范围与目标
- 代码范围:`Sources/RDReaderView/EPUBCore/`31 个 Swift 文件 + 2 个资源文件)
- 目标:说明 EPUBCore 如何完成 EPUB 解析、资源定位、阅读会话管理、离屏分页、JS 桥接渲染和全文搜索。
- 主链路关键词:`epubURL -> RDEPUBParser.parse -> container.xml -> OPF -> spine/TOC -> RDEPUBPublication -> RDEPUBReadingSession -> RDEPUBWebView/Paginator -> 分页/渲染`。
## 2. 关键对象职责
### 2.1 解析器 `RDEPUBParser`
- 文件:`EPUBCore/RDEPUBParser.swift` + 5 个扩展文件
- 入口方法:`parse(epubURL:)`
- 职责:
- ZIP 解压到缓存目录(`RDEPUBParser+Archive.swift`
- 解析 `META-INF/container.xml` 定位 OPF 根文件
- 解析 OPF 文档提取 metadata / manifest / spine`RDEPUBParser+Package.swift`
- 解析目录支持 NCXEPUB 2和 Nav DocumentEPUB 3`RDEPUBParser+TOC.swift`
- 判断阅读配置文件类型(`RDEPUBParser+ReadingProfile.swift`
- 资源路径转换:相对路径 ↔ 文件 URL ↔ `ss-reader://` 自定义 scheme URL`RDEPUBParser+Resources.swift`
### 2.2 Publication 门面 `RDEPUBPublication`
- 文件:`EPUBCore/RDEPUBPublication.swift`
- 职责:
- 包装 `RDEPUBParser` + `RDEPUBResourceResolver`,对外暴露只读接口
- 提供 metadata、manifest、spine、TOC、layout、readingProfile、readingProgression
- 计算 fixed layout 的 spread 组合:`makeFixedSpreads(preferences:viewportSize:)`
### 2.3 阅读会话 `RDEPUBReadingSession`
- 文件:`EPUBCore/RDEPUBReadingSession.swift` + `RDEPUBNavigatorState.swift`
- 职责:
- 管理导航状态机:`initializing -> loading -> idle <-> jumping/moving/repaginating`
- 维护活跃/暂存分页快照(`activePages` / `activeChapters` / `stagedPages` / `stagedChapters`
- 管理待定导航目标(`pendingNavigationLocation` / `pendingNavigationPageNum`
- 更新阅读上下文(`currentReadingContext`location + viewport + pageNumber + chapterIndex
- 构建分页快照:`makePaginationSnapshot(pageCounts:preferences:layoutContext:)`
### 2.4 WebView 渲染层 `RDEPUBWebView`
- 文件:`EPUBCore/RDEPUBWebView.swift` + 5 个扩展文件(+Configuration / +Reflowable / +FixedLayout / +JavaScriptBridge / +Search
- 职责:
- 内部持有 WKWebView配置自定义 scheme handler 和 JS 桥接
- 可重排内容加载:`loadPage(parser:spineIndex:pageIndex:...)``+Reflowable.swift`
- 固定版式内容加载:`loadFixedSpread(parser:spread:...)``+FixedLayout.swift`
- 处理 JS 桥接消息和链接导航拦截(`+JavaScriptBridge.swift`
- 搜索高亮装饰(`+Search.swift`
- 定义 `RDEPUBWebViewDelegate` 协议6 个回调方法)
### 2.5 离屏分页器 `RDEPUBPaginator`
- 文件:`EPUBCore/RDEPUBPaginator.swift`
- 职责:
- 创建隐藏的 WKWebView 加载每个 spine 项的 HTML
- 注入分页 CSS运行 JS 测量 `scrollWidth`
- 计算 `ceil(scrollWidth / viewportWidth)` 作为页数
- 多次测量取最大值(延迟 0ms / 80ms / 180ms确保布局稳定
### 2.6 资源解析 `RDEPUBResourceResolver` + `RDEPUBResourceURLSchemeHandler`
- 文件:`EPUBCore/RDEPUBResourceResolver.swift`、`EPUBCore/RDEPUBResourceURLSchemeHandler.swift`
- 职责:
- `RDEPUBResourceResolver`:对外门面,提供 href 标准化、spine 索引查找、Location 构建
- `RDEPUBResourceURLSchemeHandler`:实现 `WKURLSchemeHandler`,将 `ss-reader://book/<path>` 映射到解压后的文件系统路径,缺失的可选资源(字体/图片/CSS/JS返回空 200
### 2.7 JS 桥接
- 文件:`EPUBCore/RDEPUBJavaScriptBridge.swift`、`Resources/epub-bridge.js`
- 6 种消息类型JS → Swift
- `ssReaderProgressionChanged`:阅读进度变化
- `ssReaderSelectionChanged`:文本选择变化
- `ssReaderInternalLink`:内部链接点击
- `ssReaderExternalLink`:外部链接点击
- `ssReaderJSError`JS 错误
- `ssReaderFixedLayoutReady`:固定版式渲染完成
- JS 端 `window.RDReaderBridge` 暴露:`applyPagination`、`setPageMetrics`、`scrollToPage`、`scrollToLocation`、`setHighlights`、`clearHighlights`、`reportProgression`、`selectionPayload`
### 2.8 搜索引擎 `RDEPUBSearchEngine` / `RDEPUBHTMLSearchEngine`
- 文件:`EPUBCore/RDEPUBSearchEngine.swift`、`EPUBCore/RDEPUBSearchModels.swift`
- 职责:
- `RDEPUBSearchEngine`:搜索协议定义
- `RDEPUBHTMLSearchEngine`WebView 路径实现,将 HTML 转为纯文本NSAttributedString 或正则兜底),执行大小写不敏感的全文搜索
- 搜索模型:`RDEPUBSearchMatch`、`RDEPUBSearchResult`、`RDEPUBSearchState`、`RDEPUBSearchPresentation`
### 2.9 配置与样式
- `RDEPUBPreferences``EPUBCore/RDEPUBPreferences.swift`):用户阅读偏好(字号、行高、内容边距、主题色、固定版式适配模式),构建 `RDEPUBPresentationStyle``RDEPUBRenderRequest`
- `RDEPUBNavigatorLayoutContext``EPUBCore/RDEPUBNavigatorLayoutContext.swift`):容器布局上下文(容器尺寸、每屏页数、安全区域、设备类型)
- `RDEPUBStyleSheetBuilder``EPUBCore/RDEPUBStyleSheetBuilder.swift`):生成分页 CSS、渲染 CSS 和测量 JS 脚本
- `RDEPUBFixedLayoutTemplate``EPUBCore/RDEPUBFixedLayoutTemplate.swift`):生成固定版式 HTML 模板
### 2.10 文本锚点 `RDEPUBTextAnchor` / `RDEPUBTextRangeAnchor`
- 文件:`EPUBCore/RDEPUBTextAnchor.swift`
- 职责:
- `RDEPUBTextAnchor`:精确定位到 EPUB 中的某个字符位置,包含 fileIndexspine 索引、row行号、column列号、chapterOffset章节内字符偏移、fragmentID最近的 fragment
- `RDEPUBTextRangeAnchor`:由起止锚点组成的文本区间,可转换为 NSRange
- 支持 Codable 序列化,兼容 `fileIndex``spineIndex` 两种 key
- 用于文本 EPUB 的高亮精确锚定和跨会话恢复
### 2.11 渲染请求模型 `RDEPUBRenderRequest`
- 文件:`EPUBCore/RDEPUBRenderRequest.swift`
- 职责:
- `RDEPUBFixedLayoutFit`:固定版式适配模式枚举(`.auto` / `.page` / `.width`
- `RDEPUBFixedLayoutSpreadMode`Spread 显示模式枚举(`.automatic` / `.always` / `.never`
- `RDEPUBPresentationStyle`WebView 和 Paginator 共用的视觉参数viewportSize、contentInsets、fontSize、lineHeightMultiple、主题色
- `RDEPUBReflowableRenderRequest`可重排内容渲染请求spineIndex、href、pageIndex、presentation、highlights、searchPresentation
- `RDEPUBFixedRenderRequest`固定版式渲染请求spread、viewportSize、fit、searchPresentation
- `RDEPUBRenderRequest`:统一枚举(`.reflowable` / `.fixed`WebView 根据此类型选择渲染路径
### 2.12 调试工具 `RDEPUBWebViewDebug`
- 文件:`EPUBCore/RDEPUBWebViewDebug.swift`
- 职责:
- WebView 调试日志工具集DEBUG 模式默认开启
- 支持导航事件、JS 执行、消息接收、URL Scheme 任务的日志记录
- 可通过 UserDefaults `"RDEPUBWebViewDebugEnabled"` 覆盖开关
### 2.13 资源加载器 `RDEPUBAssetRepository`
- 文件:`EPUBCore/RDEPUBAssetRepository.swift`
- 职责:
- 从资源包加载 JS 桥接脚本(`epub-bridge.js`)和固定版式 HTML 模板(`epub-fixed-layout.html`
- 支持 `{{token}}` 模板变量替换
- 自动定位 `RDReaderViewAssets.bundle`(优先已解析子 bundle兜底宿主 bundle
## 3. 主流程(代码级)
### 3.1 EPUB 解析全流程
1. 入口:`RDEPUBParser.parse(epubURL:)`。
2. 调用 `extractArchiveIfNeeded(epubURL:)`
- 缓存路径:`~/Library/Caches/ssreaderview-epub/<slug>-<fileSize>-<timestamp>/`
- 若目录已存在则跳过解压
- 否则用 ZIPFoundation 解压 ZIP 到缓存目录
3. 解析 `META-INF/container.xml`
- `ContainerXMLParserDelegate`SAX找到第一个 `<rootfile>``full-path` 属性
4. 解析 OPF 文档:
- `OPFPackageParserDelegate`SAX分段解析 metadata / manifest / spine
- 提取 `<package>` 版本和 unique-identifier
- 提取 `<dc:identifier>` / `<dc:title>` / `<dc:creator>` / `<dc:language>`
- 提取 `<meta property="rendition:layout">` 判断 fixed/reflowable
- 提取 manifest 每个 `<item>``RDEPUBManifestItem`
- 提取 spine 每个 `<itemref>``OPFSpineReference`
5. 构建 spine`buildSpine(from:opfURL:)`
- 将 spine reference 匹配到 manifest item
- 标准化 href相对于 OPF 目录)
- 确定每项的有效 layout显式属性覆盖出版级设置
- 返回 `[RDEPUBSpineItem]`,空 spine 抛 `emptySpine` 错误
6. 解析目录:`parseTOC(from:opfURL:)`
- 优先 NCX`NCXParserDelegate`),其次 Nav Document`NavDocumentParserDelegate`
- 兜底使用 spine 标题
- href 标准化保留 fragment 标识符
7. 创建 Publication`makePublication()` → `RDEPUBPublication(parser: self)`
### 3.2 阅读配置文件判断
- 入口:`RDEPUBParser+ReadingProfile.swift`
- 判断逻辑:
- 扫描 manifest 检查是否有 `scripted` 属性的项
- 扫描 HTML body 检查是否有 `script`、`iframe`、`video` 等交互元素
- 结果:
- `.webFixedLayout`:固定版式 EPUB漫画、绘本— WKWebView 渲染
- `.webInteractive`:可重排 + 交互脚本 EPUB — WKWebView 渲染
- `.textReflowable`:纯文本可重排 EPUB — DTCoreText 渲染
### 3.3 离屏分页测量
1. 入口:`RDEPUBPaginator.calculate(parser:hostingView:presentation:completion:)`
2. 初始化所有 spine 项页数为 `[1, 1, ..., 1]`
3. 固定版式直接返回(每 spread = 1 页)
4. 顺序遍历可渲染的 HTML/XHTML spine 项
5. 对每项:
- `webView.loadFileURL(...)` 加载文件
- `didFinish` 后启动多次测量(`scheduleMeasurementPass`
- 3 次测量延迟 [0ms, 80ms, 180ms]
- 每次执行 `measurementScript`:注入分页 CSS → 测量 `scrollWidth` → 返回 `Math.max(1, Math.ceil(totalWidth / viewportWidth))`
- 取多次测量的最大值
6. 全部完成后回调 `completion([Int])` 页数数组
7. `activeSessionID`UUID防止过期回调污染结果
### 3.4 WebView 可重排内容加载
1. 入口:`RDEPUBWebView.loadPage(parser:spineIndex:pageIndex:...)`
2. 计算 `loadSignature`(包含 publication key、spine index、href、viewport、字号、行高、主题色、目标位置、高亮等
3. 若签名与 `currentLoadSignature` 相同且已渲染/正在加载,跳过重复请求
4. 若签名匹配但文档 URL 已加载,仅调用 `applyPresentation()` 重新应用样式
5. 否则 `handleReflowableLoad(...)` 执行完整加载:
- 读取 HTML 内容
- 调用 `applyPresentationScript(for:)` 生成 JS
- JS 执行:`RDReaderBridge.applyPagination(css)` → `setPageMetrics` → 清除并重新应用高亮 → 滚动到目标位置 → 报告进度 → 应用搜索装饰
### 3.5 固定版式内容加载
1. 入口:`RDEPUBWebView.loadFixedSpread(parser:spread:...)`
2. `RDEPUBFixedLayoutTemplate.html(for:publication:)` 从模板生成 HTML包含 spread 中每个资源的 `<iframe>`
3. `webView.loadHTMLString(html, baseURL: "ss-reader://book/")` 加载
4. 模板 JS 加载每个 iframe检测页面尺寸计算缩放auto/page/width 适配模式),定位 iframe
5. 所有 iframe 加载完成后 JS 发送 `ssReaderFixedLayoutReady`
6. Swift 收到消息后应用搜索装饰并调用 `rendered()`
7. 1 秒兜底定时器确保即使 JS 消息丢失也能调用 `rendered()`
### 3.6 JS 桥接消息处理
- 入口:`RDEPUBWebView+JavaScriptBridge.swift` 的 `userContentController(_:didReceive:)`
- 分发逻辑:
- `progressionChanged` → 提取 progression/lastProgression/fragment → `delegate?.epubWebView(_:didUpdateLocation:spineIndex:)`
- `selectionChanged` → 提取 text/rangeInfo → 创建 `RDEPUBSelection``delegate?.epubWebView(_:didChangeSelection:spineIndex:)`
- `internalLink` → 创建 `RDEPUBLocation``delegate?.epubWebView(_:didActivateInternalLink:fromSpineIndex:)`
- `externalLink` → 提取 URL → `delegate?.epubWebView(_:didActivateExternalLink:)`
- `javaScriptError``delegate?.epubWebView(_:didLogJavaScriptError:)`
- `fixedLayoutReady` → 应用搜索装饰 → `rendered()`
### 3.7 链接导航拦截
- 入口:`decidePolicyFor navigationAction`
- 逻辑:
- `ss-reader://` URL + `.linkActivated` → 内部链接代理回调,取消导航
- `http/https/mailto/tel` → 外部链接代理回调,取消导航
- 其他 → 允许导航
## 4. 异常与边界处理
- `RDEPUBParserError`7 种,全部 `LocalizedError`,中文描述):
- `archiveOpenFailed(URL)`ZIPFoundation 无法打开 EPUB 文件
- `missingContainerXML``META-INF/container.xml` 不存在
- `missingRootFile`container.xml 中无 `<rootfile>`
- `invalidRootFilePath(String)`rootfile 路径对应的 OPF 文件不存在
- `invalidXML(URL)`XMLParser 无法创建或解析失败
- `missingManifestItem(idref:)`spine 引用了不存在的 manifest 项
- `emptySpine`:构建后 spine 为空
- 目录解析NCX 和 Nav Document 解析器失败时返回空数组,不抛异常,兜底使用 spine 标题
- 资源缺失:`RDEPUBResourceURLSchemeHandler` 对缺失的可选资源(字体/图片/CSS/JS返回空 200避免 WKWebView 控制台噪音
- 分页失败:`RDEPUBPaginator` 导航失败(`didFail` / `didFailProvisional`)时将页数设为 1 并继续下一项
- HTML 转纯文本兜底:`RDEPUBHTMLSearchEngine` 在 NSAttributedString 失败时回退到正则去标签
- 固定版式 1 秒兜底:`scheduleFixedLayoutReadyFallback()` 确保即使 JS ready 消息丢失也能调用 `rendered()`
- 重复请求跳过:`handleReflowableLoad` 在 loadSignature 匹配且已渲染/正在加载时跳过
## 5. 数据结构与字段映射
### 5.1 EPUB 元数据
```
RDEPUBParser
├── metadata: RDEPUBMetadata
│ ├── identifier, title, author, language, version
│ ├── layout: RDEPUBLayout (.reflowable | .fixed)
│ ├── readingProgression: RDEPUBReadingProgression (.ltr | .rtl | .auto)
│ └── spread: String?
├── manifest: [String: RDEPUBManifestItem]
│ ├── id, href, mediaType
│ ├── properties: [String] (nav, scripted, cover-image, rendition:layout-*)
│ └── title: String?
├── spine: [RDEPUBSpineItem]
│ ├── idref -> manifest[id]
│ ├── href (normalized), mediaType, title, linear
│ ├── pageSpread: RDEPUBPageSpread? (.left | .right | .center)
│ └── layout: RDEPUBLayout? (per-item override)
└── tableOfContents: [EPUBTableOfContentsItem]
├── title, href
└── children: [EPUBTableOfContentsItem] (recursive)
```
### 5.2 阅读位置模型
```
RDEPUBLocation
├── bookIdentifier, href
├── progression: Double? (0..1,章节内起始位置)
├── lastProgression: Double? (0..1,章节内结束位置)
├── fragment: String? (#anchor)
├── rangeAnchor: RDEPUBTextRangeAnchor? (文本范围锚点,用于高亮精确定位)
└── navigationProgression (computed: midpoint of progression and lastProgression)
```
### 5.3 视口模型
```
RDEPUBViewport
├── resources: [RDEPUBViewportResource]
│ ├── href, spineIndex
│ ├── progression, lastProgression, fragment
├── visiblePageNumber, chapterIndex
└── isFixedLayout: Bool
```
### 5.4 渲染请求
```
RDEPUBRenderRequest (enum)
├── .reflowable(RDEPUBReflowableRenderRequest)
│ ├── spineIndex, href, pageIndex, totalPagesInChapter
│ ├── presentation: RDEPUBPresentationStyle
│ │ ├── viewportSize, contentInsets
│ │ ├── fontSize, lineHeightMultiple
│ │ └── themeBackgroundColor, themeTextColor
│ ├── targetLocation: RDEPUBLocation?
│ ├── highlights: [RDEPUBHighlight]
│ └── searchPresentation: RDEPUBSearchPresentation?
└── .fixed(RDEPUBFixedRenderRequest)
├── spread: EPUBFixedSpread
│ └── resources: [EPUBFixedSpreadResource]
├── viewportSize, contentInset
├── backgroundColorCSS
├── fit: RDEPUBFixedLayoutFit (.auto | .page | .width)
└── searchPresentation: RDEPUBSearchPresentation?
```
### 5.5 高亮与选择
```
RDEPUBHighlight
├── id, bookIdentifier
├── location: RDEPUBLocation
├── text, rangeInfo (JSON-serialized DOM range 或 text-offset)
├── style: RDEPUBHighlightStyle (.highlight | .underline)
├── color: String, note: String
└── createdAt: Date
RDEPUBSelection
├── bookIdentifier
├── location: RDEPUBLocation
├── text, rangeInfo
└── createdAt: Date
RDEPUBBookmark
├── id, bookIdentifier
├── location: RDEPUBLocation
├── title: String?
├── note: String?
└── createdAt: Date
```
### 5.6 搜索模型
```
RDEPUBSearchState
├── keyword: String
├── matches: [RDEPUBSearchMatch]
│ ├── href, progression
│ ├── previewText, localMatchIndex
│ ├── rangeLocation, rangeLength
└── currentMatchIndex: Int
```
## 6. 分页与渲染机制
### 6.1 CSS 多栏分页原理
- HTML 内容被包裹为 `body > div#ss-reader-viewport > div#ss-reader-content`
- `#ss-reader-content` 应用 `-webkit-column-width: <viewportWidth>px`
- `html` / `body` 设置 `overflow: hidden`
- 页间导航通过 `transform: translate3d(-N*stride, 0, 0)` 实现
### 6.2 重新布局触发条件
- `scheduleRelayout()`60ms 防抖)在以下事件触发:
- `window.load`、`window.resize`
- `document.fonts.ready`
- 每个 `<img>` / `<iframe>` / `<video>` 的 load/error 事件
### 6.3 选择变化处理
- `selectionchange` 监听器120ms 防抖)发送 `ssReaderSelectionChanged` 消息,携带 text、rangeInfo、progression
## 7. 缓存策略
- **EPUB 解压缓存**:解压目录按 `文件大小 + 修改时间` 生成缓存 key已存在则跳过解压
- **Load Signature 去重**:每次渲染请求计算签名(含 publication key、layout、spine index、viewport、字号、行高、主题色、目标位置、高亮相同签名跳过重复加载
- **WKWebView 复用**:同一 publication 配置下复用已有 WKWebView 实例,仅切换 publication 时才 teardown
- **非持久化数据存储**`WKWebViewConfiguration().websiteDataStore = .nonPersistent()`,不缓存 Web 内容到磁盘
## 8. 联调与排查建议
- 排查 1EPUB 解析失败
- 检查 `parse(epubURL:)` 抛出的 `RDEPUBParserError` 具体类型
- 检查 EPUB 文件是否损坏ZIP 能否正常打开)
- 检查 `META-INF/container.xml` 是否存在且格式正确
- 排查 2目录为空
- 检查 OPF 中是否有 NCX 或 Nav Document 引用
- 检查 NCX 文件路径是否正确(相对于 OPF 目录)
- 目录解析失败时会兜底使用 spine 标题
- 排查 3页面内容空白
- 检查 `ss-reader://` scheme handler 是否正确映射资源路径
- 检查 `RDEPUBResourceURLSchemeHandler` 日志(`RDEPUBWebViewDebug`
- 确认 HTML 文件在解压目录中实际存在
- 排查 4分页页数不正确
- 检查 `RDEPUBPaginator` 多次测量结果是否一致
- 检查 viewport 尺寸计算是否正确(容器宽度 / pagesPerScreen
- 检查 CSS 分页样式是否正确注入
- 排查 5JS 桥接消息未收到
- 确认 `epub-bridge.js` 已正确注入(检查 `WKUserContentController`
- 检查 JS 控制台是否有错误
- 固定版式检查是否收到 `ssReaderFixedLayoutReady`,否则看 1 秒兜底定时器
- 排查 6内部链接不跳转
- 检查链接 href 是否以 `ss-reader://` 开头
- 检查 `decidePolicyFor navigationAction` 中的链接类型判断
- 确认 `RDEPUBLocation` 构建是否正确href + fragment

View File

@ -1,352 +0,0 @@
# EPUBTextRendering 功能实现逻辑
## 1. 范围与目标
- 代码范围:`Sources/RDReaderView/EPUBTextRendering/`13 个 Swift 文件)
- 目标:说明文本渲染路径如何将 EPUB HTML 转换为 NSAttributedString、按字符范围分页、构建书籍模型并支持全文搜索与 textReflowable 标注定位。
- 主链路关键词:`RDEPUBParser HTML -> DTCoreText 渲染 -> 片段标记注入/提取 -> CoreText 分页 -> RDEPUBTextBook -> 页面查找/位置转换`。
- 适用范围:仅用于 `textReflowable` 阅读配置文件(纯文本可重排 EPUB如小说。固定版式和交互式 EPUB 使用 WebView 渲染路径。
## 2. 关键对象职责
### 2.1 渲染协议 `RDEPUBTextRenderer`
- 文件:`EPUBTextRendering/RDEPUBTextRenderer.swift`
- 职责:
- 定义双方法协议:
- `renderChapter(request: RDEPUBTextChapterRenderRequest) throws -> RDEPUBRenderedChapterContent`(主方法,携带完整上下文)
- `renderChapter(html:baseURL:style:) throws -> RDEPUBRenderedChapterContent`(便捷方法,默认实现转发到主方法)
- 作为渲染后端的抽象点,当前仅 `RDEPUBDTCoreTextRenderer` 一个实现
### 2.2 DTCoreText 渲染器 `RDEPUBDTCoreTextRenderer`
- 文件:`EPUBTextRendering/RDEPUBDTCoreTextRenderer.swift`
- 职责:
- 实现 `RDEPUBTextRenderer` 协议
- 使用 `DTHTMLAttributedStringBuilder` 将 HTML 转为 NSAttributedString
- 渲染后处理:提取片段标记、统一字体/行间距/颜色
- DTCoreText 不可用时回退到纯文本渲染
### 2.3 渲染支持工具 `RDEPUBTextRendererSupport`
- 文件:`EPUBTextRendering/RDEPUBTextRendererSupport.swift`
- 职责:
- 片段标记注入:扫描 HTML 中带 `id` 属性的元素,在其前注入 `${id=<fragmentID>}` 不可见标记
- 片段偏移提取:从 NSAttributedString 中提取标记位置并删除标记
- 字体标准化:保留 HTML 中的粗体/斜体 trait统一使用配置的基础字体族和字号
- 段落样式创建:强制应用配置的行间距和段间距
- 兜底渲染DTCoreText 不可用时从原始 HTML 字符串生成纯文本 NSAttributedString
### 2.4 分页支持 `NSAttributedString.ss_pageRanges(size:)` / `rd_paginatedFrames(size:)`
- 文件:`EPUBTextRendering/RDEPUBTextPaginationSupport.swift`
- 职责:
- `rd_paginatedFrames(size:) -> [RDEPUBTextLayoutFrame]`:基于 `RDEPUBTextLayouter` 的增强分页,返回带语义元数据的帧对象
- `ss_pageRanges(size:) -> [NSRange]`:便捷方法,内部调用 `rd_paginatedFrames` 后提取 contentRange
- 从位置 0 开始,反复创建 CTFrame 测量可见字符范围
- 返回 `[NSRange]`,每个 range 对应一页
- 安全保护:`visibleRange.length == 0` 时 break 防止死循环
### 2.5 书籍构建器 `RDEPUBTextBookBuilder`
- 文件:`EPUBTextRendering/RDEPUBTextBookBuilder.swift`
- 职责:
- 遍历 publication 的 linear spine 项(仅 HTML/XHTML 类型)
- 对每项HTML 标准化 → 渲染 → 跳过空白封面/标题页 → 分页 → 构建 `RDEPUBTextPage` 数组
- 汇总所有章节为 `RDEPUBTextBook`
- 后台队列执行,主线程回调结果
### 2.6 全文搜索引擎 `RDEPUBTextSearchEngine`
- 文件:`EPUBTextRendering/RDEPUBTextSearchEngine.swift`
- 职责:
- 实现 `RDEPUBSearchEngine` 协议
- 在已渲染的 NSAttributedString 纯文本上执行线性搜索
- 返回 `RDEPUBSearchMatch` 数组(含 href、progression、预览文本、匹配位置
### 2.6.1 章节数据模型 `RDEPUBChapterData`
- 文件:`EPUBTextRendering/RDEPUBChapterData.swift`
- 职责:
- 每章节的数据聚合模型,管理高亮、搜索结果和页面查询
- 支持按页码范围过滤当前页的高亮列表
- 支持搜索结果在章节内的定位和匹配索引计算
### 2.7 CoreText 分页引擎 `RDEPUBTextLayouter` / `RDEPUBTextLayoutFrame`
- 文件:`EPUBTextRendering/RDEPUBTextLayouter.swift`、`EPUBTextRendering/RDEPUBTextLayoutFrame.swift`
- 职责:
- `RDEPUBTextLayouter`:封装 `CTFramesetter`,逐帧计算分页结果,支持语义边界调整(避免在附件/代码块/列表中间断页)
- `RDEPUBTextLayoutFrame`:单帧分页结果,包含 contentRange、breakReason、blockRange、attachment 信息、语义提示和诊断数据
- 替代原有的纯 `NSRange` 分页,返回带丰富元数据的帧对象
### 2.8 纯文本构建器 `RDPlainTextBookBuilder`
- 文件:`EPUBTextRendering/RDPlainTextBookBuilder.swift`
- 职责:
- 从纯文本文件(.txt 等)构建 `RDEPUBTextBook`
- 将纯文本按段落切分后渲染为 NSAttributedString
- 按页面尺寸分页,输出与 EPUB 文本路径一致的书籍模型
### 2.9 文本索引表 `RDEPUBTextIndexTable`
- 文件:`EPUBTextRendering/RDEPUBTextIndexTable.swift`
- 职责:
- 构建全书的文本结构映射章节起始偏移、href 与章节/spine 索引对应、fragment 偏移映射、行列索引映射
- `RDEPUBRowColumnIndex`:行-列索引条目,记录每行的字符范围
- 支持锚点与位置之间的双向转换:
- `anchor(forAbsoluteIndex:in:)`:绝对索引 → 文本锚点
- `anchor(for:)`:阅读位置 → 文本锚点(优先 rangeAnchor其次 fragment/progression
- `absoluteIndex(for:)`:锚点 → 全书绝对索引
- `location(for:in:bookIdentifier:)`:锚点/范围锚点 → 阅读位置
- 支持页码查找:`pageNumber(for:in:)` 通过锚点定位到对应页面
### 2.10 性能采样器 `RDEPUBTextPerformanceSampler`
- 文件:`EPUBTextRendering/RDEPUBTextPerformanceSampler.swift`
- 职责:
- `RDEPUBTextPerformanceSample`单章节性能数据chapterHref、renderDuration、paginateDuration、pageCount、attributedStringLength、cacheHit
- `RDEPUBTextPerformanceSampler`:书籍构建过程的性能采样器
- 由 `RDEPUBTextBookBuilder` 在构建过程中使用,每章记录一个采样点
- `summary()` 输出汇总报告:总渲染/分页耗时和缓存命中率
## 3. 主流程(代码级)
### 3.1 完整渲染-分页流程
1. 入口:`RDEPUBTextBookBuilder.build(parser:publication:pageSize:style:)`
2. 遍历 `publication.spine``item.linear == true` 的项
3. 过滤仅处理 mediaType 包含 "html" 或 "xhtml" 的项
4. 读取 `parser.htmlString(forRelativePath: item.href)` 获取原始 HTML
5. HTML 标准化:
- 移除中文分页标记:`<hr lang="zh-CN">分页符</hr>`
- `\r` 替换为 `\n`
- 连续 `\n` 合并为单个
6. 检查是否跳过(空白且 href 包含 "cover" 或 "title"
7. 调用 `renderer.renderChapter(html:baseURL:style:)` 渲染
### 3.2 DTCoreText 渲染管线
1. 片段标记注入:`RDEPUBTextRendererSupport.injectFragmentMarkers(into: html)`
- 正则 `(<[^>]+\sid="([^"]+)"[^>]*>)` 匹配带 id 的元素
- 在匹配标签前插入 `${id=<fragmentID>}` 文本标记
2. UTF-8 编码为 Data
3. `DTHTMLAttributedStringBuilder(html:options:documentAttributes:)` 执行渲染
4. DTCoreText 选项:
- `NSTextSizeMultiplierDocumentOption: 1.0`
- `DTDefaultFontFamily` / `DTDefaultFontName` / `DTDefaultFontSize`:来自 `style.font`
- `DTDefaultLineHeightMultiplier``(font.lineHeight + lineSpacing) / max(font.lineHeight, 1)`
- `DTUseiOS6Attributes: true`:生成 UIKit 兼容属性
- `NSBaseURLDocumentOption`:资源相对路径解析
- `DTDefaultTextColor`:可选文本颜色
5. 后处理:
- 提取片段偏移并删除标记
- `normalizeReadingAttributes`:遍历每个属性 run统一字体保留粗/斜体 trait、强制行间距/段间距、覆盖前景色
### 3.3 CoreText 分页算法
1. 从 `NSAttributedString` 创建 `CTFramesetter`
2. 创建页面大小的 `CGPath`
3. 从 location = 0 开始循环:
- 创建 CTFramerange 从当前 location 到字符串末尾)
- `CTFrameGetVisibleStringRange(frame)` 获取可见字符数
- 追加 `NSRange(location: currentLocation, length: visibleRange.length)`
- location += visibleRange.length
4. 循环终止条件:`location + visibleRange.length >= attributedString.length`
5. 安全保护:`visibleRange.length == 0` 时 break
### 3.4 页面与位置互转
**Location → Page Number**`RDEPUBTextBook.pageNumber(for:resolver:bookIdentifier:)`
1. 有 fragment 时:用 `fragmentOffsets` 查找字符偏移,再定位到对应页面
2. 无 fragment 时:用 `navigationProgression`progression 和 lastProgression 的中点)计算字符偏移
3. 遍历 pages 找到 `pageStartOffset <= offset < pageEndOffset` 的页面
**Page Number → Location**`RDEPUBTextBook.location(forPageNumber:bookIdentifier:)`
1. 通过 page number 查找 `RDEPUBTextPage`
2. 计算 `progression = pageStartOffset / totalCharacters`
3. 计算 `lastProgression = pageEndOffset / totalCharacters`
4. 构建 `RDEPUBLocation`href + progression + lastProgression
### 3.5 标注定位与恢复
textReflowable 路径不复用 WebView 的 DOM range而使用章节字符偏移作为精确锚点
```json
{
"kind": "text-offset",
"href": "chapter.xhtml",
"start": 1234,
"end": 1260
}
```
1. `RDEPUBTextContentView` 保留当前 `RDEPUBTextPage`,从 `page.pageStartOffset` 将页内 `UITextView.selectedRange` 映射为章节全局 `start/end`
2. 生成 `RDEPUBSelection` 后回传 `RDEPUBReaderController`,共享层继续负责创建、去重、持久化、列表和跳转
3. 页面重绘时,`RDEPUBTextContentView` 仅消费 `kind == "text-offset"` 的 rangeInfo计算当前页 `[pageStartOffset, pageEndOffset]` 与标注 `[start, end)` 的重叠
4. `highlight` 样式叠加 `.backgroundColor``underline` 样式叠加 `.underlineStyle``.underlineColor`
5. 字号、行高、主题变化导致重新分页后,标注仍以章节全局字符偏移恢复到新的页面切片
## 4. 异常与边界处理
- `RDEPUBTextRenderingError.htmlEncodingFailed`HTML 字符串无法编码为 UTF-8 Data极罕见
- DTCoreText builder 返回 nil回退到 `fallbackAttributedString`,生成纯文本(含 HTML 标签原文),降级体验
- 空白章节跳过href 包含 "cover" 或 "title" 且渲染后文本为空的章节被跳过
- 分页零结果兜底:`pageRanges` 为空但 content 非空时,整个章节作为一页
- CoreText 零高度安全保护:`visibleRange.length == 0` 时 break 防止无限循环
- Token 取消:`paginationToken`UUID防止异步完成后的过期结果污染 UI
## 5. 数据结构与字段映射
### 5.1 渲染配置
```swift
RDEPUBTextRenderStyle
├── font: UIFont // 基础字体
├── lineSpacing: CGFloat // 额外行间距
├── textColor: UIColor? // 文本颜色nil 时保持 HTML 原始颜色)
└── backgroundColor: UIColor? // 背景色
```
### 5.2 渲染请求上下文
```swift
RDEPUBTextChapterRenderRequest
├── context: RDEPUBTextChapterContext
│ ├── href: String
│ ├── title: String
│ ├── html: String
│ ├── baseURL: URL?
│ ├── stylesheet: RDEPUBTextStyleSheetPackage
│ │ ├── layers: [RDEPUBTextStyleSheetLayer]
│ │ │ └── kind (.default/.replace/.dark/.epub/.user) + css
│ │ └── combinedCSS (computed)
│ └── resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic]
└── style: RDEPUBTextRenderStyle
```
### 5.3 渲染输出
```swift
RDEPUBRenderedChapterContent
├── attributedString: NSAttributedString // 渲染后的富文本
├── fragmentOffsets: [String: Int] // fragment ID -> 字符偏移映射
└── resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic]
```
### 5.4 分页帧模型(新增)
```swift
RDEPUBTextLayoutFrame
├── contentRange: NSRange // 本帧字符范围
├── breakReason: RDEPUBTextPageBreakReason // 分页原因
├── blockRange: NSRange? // 所属 block 范围
├── attachmentRanges: [NSRange] // 附件范围
├── attachmentKinds: [RDEPUBTextAttachmentKind]
├── blockKinds: [RDEPUBTextBlockKind] // (.paragraph/.list/.table/.code/.blockquote/.attachment/.generic)
├── semanticHints: [RDEPUBTextSemanticHint] // (.avoidPageBreakInside/.pageBreakBefore/.pageBreakAfter/.pageRelate)
├── attachmentPlacements: [RDEPUBTextAttachmentPlacement] // (.inline/.baseline/.centered)
├── trailingFragmentID: String? // 帧尾最近的 fragment ID
└── diagnostics: [String] // 分页诊断信息
RDEPUBTextLayouter
├── init(attributedString:pageSize:)
└── layoutFrames(fragmentOffsets:) -> [RDEPUBTextLayoutFrame]
```
### 5.5 页面模型
```swift
RDEPUBTextPage
├── absolutePageIndex: Int // 全局页码
├── chapterIndex: Int // 章节索引
├── spineIndex: Int // spine 索引
├── href: String // 章节 href
├── chapterTitle: String? // 章节标题
├── pageIndexInChapter: Int // 章节内页码
├── totalPagesInChapter: Int // 章节总页数
├── content: NSAttributedString // 本页的富文本切片
├── contentRange: NSRange // 在整章富文本中的 range
├── pageStartOffset: Int // 起始字符偏移
└── pageEndOffset: Int // 结束字符偏移
```
### 5.6 章节模型
```swift
RDEPUBTextChapter
├── chapterIndex: Int
├── spineIndex: Int
├── href: String
├── title: String?
├── attributedContent: NSAttributedString // 整章渲染后的富文本
├── fragmentOffsets: [String: Int]
└── pages: [RDEPUBTextPage]
```
### 5.7 书籍模型
```swift
RDEPUBTextBook
├── chapters: [RDEPUBTextChapter]
├── pages: [RDEPUBTextPage] // 扁平化页面列表
├── page(at:) -> RDEPUBTextPage? // 1-based 页码查找
├── pageNumber(for:resolver:bookIdentifier:) -> Int? // Location -> 页码
└── location(forPageNumber:bookIdentifier:) -> RDEPUBLocation? // 页码 -> Location
```
## 6. 与 EPUBCore 层的集成
### 消费的数据
| 来源 | 数据 | 用途 |
|------|------|------|
| `RDEPUBParser` | `htmlString(forRelativePath:)` | 读取章节 HTML 内容 |
| `RDEPUBParser` | `fileURL(forRelativePath:)` | 计算 DTCoreText 的 baseURL |
| `RDEPUBPublication` | `spine` | 遍历 linear HTML/XHTML 项 |
| `RDEPUBPublication` | `tableOfContents` | 匹配章节标题 |
| `RDEPUBSpineItem` | `href`, `mediaType`, `linear`, `title` | 过滤和标识章节 |
| `RDEPUBResourceResolver` | `normalizedLocation` / `normalizedHref` | 位置查找时标准化 href |
| `RDEPUBLocation` | `href`, `fragment`, `navigationProgression` | 页面定位 |
| `RDEPUBSearchEngine` | 协议接口 | `RDEPUBTextSearchEngine` 实现此协议 |
| `RDEPUBHighlight` | `style`, `rangeInfo`, `color`, `note` | text-offset 标注恢复与页内绘制 |
## 7. 配置参数
- **字号**`RDEPUBReaderConfiguration.fontSize`,默认 15pt
- **行高倍数**`lineHeightMultiple`,默认 1.6(行间距 = font.lineHeight * 0.6,最低 4pt
- **内容边距**`reflowableContentInsets`,默认 (40, 16, 40, 16),影响有效页面尺寸
- **主题色**:通过 `RDEPUBReaderTheme``contentTextColor` / `contentBackgroundColor` 传入
- **渲染引擎**`RDEPUBTextRenderingEngine`,当前仅 `.dtCoreText`
## 8. 性能特征
- **全文渲染**:每章一次性渲染为完整的 NSMutableAttributedString无分块/懒加载
- **内存**`RDEPUBTextBook` 同时持有 chapters含完整 attributedContent和 pages含子串切片存在一定程度的内存重复
- **后台执行**`build` 方法在 `DispatchQueue.global(qos: .userInitiated)` 执行,不阻塞主线程
- **搜索性能**:线性扫描每章的 `attributedContent.string`,无索引,搜索时间与总文本量线性相关
- **分页缓存**`RDEPUBTextBookCache` 支持磁盘缓存分页结果,字号/行高变化时优先从缓存恢复
- **性能采样**`RDEPUBTextPerformanceSampler` 记录每章的渲染和分页耗时,支持缓存命中率统计
## 9. 联调与排查建议
- 排查 1渲染后文本为空
- 检查 `parser.htmlString(forRelativePath:)` 是否返回非 nil
- 检查 spine 项的 mediaType 是否包含 "html" 或 "xhtml"
- 空白封面/标题页会被 `shouldSkipChapter` 跳过
- 排查 2分页页数不正确
- 检查 `pageSize` 计算是否正确viewport - contentInsets
- 检查 DTCoreText 行高倍数是否正确应用
- 检查 CoreText 分页是否有 `visibleRange.length == 0` 的异常情况
- 排查 3字体/颜色不正确
- 检查 `RDEPUBTextRenderStyle` 配置
- 检查 `normalizeReadingAttributes` 是否正确保留粗/斜体 trait
- 检查 `foregroundColor` 覆盖逻辑
- 排查 4Fragment 定位失败
- 检查 `injectFragmentMarkers` 是否正确注入标记
- 检查 `extractFragmentOffsets` 是否正确提取偏移
- 确认标记在 DTCoreText 渲染后仍存在于 attributed string 中
- 排查 5搜索结果不准确
- 搜索基于已渲染的纯文本(去 HTML 标签),非原始 HTML
- 确认 `attributedContent.string` 包含预期的文本内容
- 搜索为大小写不敏感的线性扫描

View File

@ -1,324 +0,0 @@
# EPUBUI 功能实现逻辑
## 1. 范围与目标
- 代码范围:`Sources/RDReaderView/EPUBUI/`19 个 Swift 文件)
- 目标:说明 EPUBUI 如何作为开箱即用的阅读器 UI 层,协调 EPUBCore 解析、EPUBTextRendering 文本渲染、RDReaderView 分页容器,提供完整的阅读体验(工具栏、目录、高亮批注、设置面板、阅读位置持久化、搜索)。
- 主链路关键词:`RDEPUBReaderController.init -> 解析 EPUB -> 分页 -> 渲染 -> 用户交互(翻页/工具栏/设置/高亮/搜索)-> 持久化`。
## 2. 关键对象职责
### 2.1 主控制器 `RDEPUBReaderController`
- 文件:`EPUBUI/RDEPUBReaderController.swift`~1995 行)
- 入口方法:
- `init(epubURL:configuration:persistence:)` — 标准 EPUB 阅读入口
- `init(textBook:bookIdentifier:title:textFileURL:configuration:)` — 纯文本书籍阅读入口(由 `RDPlainTextBookBuilder` 构建 `RDEPUBTextBook` 后传入)
- 职责:
- 加载 EPUB后台解析 → 应用分页 → 恢复阅读位置
- 实现 `RDReaderDataSource` / `RDReaderDelegate` 为容器提供数据
- 实现 `RDEPUBWebContentViewDelegate` 接收 WebView 事件
- 管理工具栏显隐、目录面板、高亮管理、设置面板、搜索
- 持久化阅读位置、高亮、用户设置
- 对外暴露 `RDEPUBReaderDelegate` 回调宿主 App
### 2.2 配置 `RDEPUBReaderConfiguration`
- 文件:`EPUBUI/RDEPUBReaderConfiguration.swift`
- 13 个配置项:
- `fontSize`(默认 15、`lineHeightMultiple`(默认 1.6
- `displayType`(默认 .pageCurl、`landscapeDualPageEnabled`(默认 true
- `showsTableOfContents`(默认 true、`allowsHighlights`(默认 true、`showsSettingsPanel`(默认 true
- `reflowableContentInsets`(默认 top:40 left:16 bottom:40 right:16、`fixedContentInset`(默认 .zero
- `theme`(默认 .light
- `fixedLayoutFit`(默认 .page、`fixedLayoutSpreadMode`(默认 .automatic
- `textRenderingEngine`(默认 .dtCoreText
- 变更检测:
- `requiresRepagination`fontSize / lineHeightMultiple / contentInsets / fixedLayoutFit / fixedLayoutSpreadMode / textRenderingEngine 变化 → 完整重新分页
- `requiresVisibleRefresh`theme 变化 → 刷新可见内容(不重新分页)
### 2.3 委托协议 `RDEPUBReaderDelegate`
- 文件:`EPUBUI/RDEPUBReaderDelegate.swift`
- 12 个可选方法(全部有默认空实现):
- `epubReader(_:didOpen:)`:成功打开出版物
- `epubReader(_:didUpdateLocation:)`:翻页或滚动时位置更新
- `epubReaderDidReachEnd(_:)`:到达最后一页
- `epubReader(_:didChangeSelection:)`:文本选择变化或清除
- `epubReader(_:didUpdateHighlights:)`:高亮增删改
- `epubReader(_:didUpdateBookmarks:)`:书签增删改
- `epubReader(_:didUpdateSearchResult:)`:搜索状态变化
- `epubReader(_:didChangeCurrentSearchMatch:)`:当前搜索匹配项变化
- `epubReader(_:didUpdateCurrentTableOfContentsItem:)`:翻页时匹配的目录项
- `epubReader(_:didActivateExternalLink:)`:外部链接点击
- `epubReader(_:didFailWithError:)`:解析或分页错误
- `epubReader(_:configureTopToolView:)`:自定义顶部工具栏
### 2.4 持久化 `RDEPUBReaderPersistence`
- 文件:`EPUBUI/RDEPUBReaderPersistence.swift`
- 协议定义 8 个方法loadLocation / saveLocation / loadHighlights / saveHighlights / loadBookmarks / saveBookmarks / loadReaderSettings / saveReaderSettings
- 默认实现 `RDEPUBUserDefaultsPersistence`
- 位置:`ssreader.epub.location.<bookIdentifier>`JSON 编码)
- 高亮:`ssreader.epub.highlights.<bookIdentifier>`JSON 编码)
- 书签:`ssreader.epub.bookmarks.<bookIdentifier>`JSON 编码)
- 设置:`ssreader.epub.settings`(全局,非按书)
### 2.5 主题 `RDEPUBReaderTheme`
- 文件:`EPUBUI/RDEPUBReaderTheme.swift`~125 行)
- 6 个颜色属性contentBackgroundColor、contentTextColor、toolBackgroundColor、toolControlTextColor、toolControlBorderUnselectColor、toolLineColor
- 6 个内置预设:`.light`、`.dark`、`.yellow`、`.green`、`.pink`、`.blue`
- `RDEPUBReaderThemePreset` 枚举将预设映射为可序列化值,用于持久化
- 计算属性 `themeBackgroundColorCSS` / `themeTextColorCSS` 用于 Web 渲染路径
### 2.6 设置面板 `RDEPUBReaderSettingsViewController`
- 文件:`EPUBUI/RDEPUBReaderSettingsViewController.swift`~311 行)
- 5 个控制项:
- 亮度滑块0-1
- 字号 A-/A+(范围 12-36步长 1
- 行高分段(紧凑 1.3 / 标准 1.6 / 宽松 1.9
- 显示模式分段(仿真 / 横滑 / 竖滑 / 覆盖)
- 主题选择6 个圆形色块按钮)
- 所有变更通过闭包实时回调onBrightnessChange / onFontSizeChange / onLineHeightChange / onDisplayTypeChange / onThemeChange
### 2.7 内容视图
- `RDEPUBTextContentView``EPUBUI/RDEPUBTextContentView.swift`textReflowable 路径UITextView 展示富文本,支持系统选区、标注菜单、用户标注叠加和搜索高亮叠加
- `RDEPUBWebContentView``EPUBUI/RDEPUBWebContentView.swift`web 路径,包装 `RDEPUBWebView`,转发位置、选区、标注菜单、链接和 JS 错误事件
### 2.8 其他 UI 组件
- `RDEPUBReaderToolView``EPUBUI/RDEPUBReaderToolView.swift`):工具栏基类,提供分隔线和主题适配的通用逻辑
- `RDEPUBReaderTopToolView`:顶部导航栏(返回按钮 + 书签切换 + 标题)
- `RDEPUBReaderBottomToolView`:底部工具栏(目录 / 书签 / 标注 / 设置 4 个按钮)
- `RDEPUBReaderChapterListController`目录列表modal UITableViewController当前项高亮 systemBlue
- `RDEPUBReaderHighlightsViewController`:标注管理(全部 / 批注 / 划线过滤,列表 + 编辑备注 + 删除 + 跳转,空态按过滤条件提示)
- `RDEPUBReaderSettings``EPUBUI/RDEPUBReaderSettings.swift`可持久化用户设置模型brightness / fontSize / lineHeightMultiple / displayMode / themePreset`RDEPUBReaderDisplayMode``RDEPUBReaderThemePreset` 枚举
- `RDEPUBReaderTableOfContentsItem``EPUBUI/RDEPUBReaderTableOfContentsItem.swift`展平后的目录条目title / href / depth / pageNumber
- `RDEPUBPageInteractionController``EPUBUI/RDEPUBPageInteractionController.swift`CoreText 页面级交互控制器,处理文本选区和高亮装饰
- `RDEPUBSelectionOverlayView``EPUBUI/RDEPUBSelectionOverlayView.swift`):选区覆盖视图,显示选中文本的高亮装饰
- `RDEPUBPageLayoutSnapshot``EPUBUI/RDEPUBPageLayoutSnapshot.swift`):页面几何模型,存储 CoreText 渲染的页面布局信息
- `RDURLReaderController``EPUBUI/RDURLReaderController.swift`~221 行):最简 URL 入口,传入 URL 即可打开书籍(.epub → RDEPUBReaderController其他 → RDPlainTextBookBuilder 构建后传入),分页失败时回退到纯 UITextView 展示
## 3. 主流程(代码级)
### 3.1 初始化与加载
1. `init(epubURL:configuration:persistence:)`
- 加载持久化的阅读设置,叠加到 configuration
- 恢复屏幕亮度
2. `viewDidLoad`
- 设置背景色
- `setupReaderView()`:添加 RDReaderView 全屏约束,注册 `RDEPUBTextContentView``RDEPUBWebContentView`
- `setupLoadingIndicator()`、`setupErrorLabel()`
3. `viewDidAppear`:调用 `startInitialLoadIfNeeded()`
4. 加载序列:
- `loadPublication()` → 读取持久化位置
- 后台 `RDEPUBParser.parse(epubURL:)` 解析
- 主线程 `applyParsedPublication()``paginatePublication()`
### 3.2 分页流程
`paginatePublication(restoreLocation:)` 根据 readingProfile 分三条路径:
**textReflowable 路径**
1. 后台队列:`RDEPUBTextBookBuilder.build(parser:publication:pageSize:style:)`
2. 主线程 `applyTextBook(textBook:)`:保存 textBookreloadData
**fixed layout 路径**
1. 创建快照:`pageCounts: Array(repeating: 1, count: publication.spine.count)`
2. `applyPaginationSnapshot(snapshot:restoreLocation:)`
**web interactive 路径**
1. `RDEPUBPaginator.calculate(parser:hostingView:presentation:completion:)`
2. 回调收到页数数组后 `applyPaginationSnapshot(snapshot:restoreLocation:)`
三条路径汇合 `finishPagination(restoreLocation:)`
- `readerView.reloadData()`
- `restoreReadingLocation(restoreLocation)`
### 3.3 页面内容工厂
`pageContentView(readerView:pageNum:containerView:)`
- 有 textBook → 出队/创建 `RDEPUBTextContentView`,调用 `configure(page:pageNumber:totalPages:configuration:searchState:)`
- 无 textBook → 出队/创建 `RDEPUBWebContentView`,调用 `configure(publication:request:pageNumber:totalPages:theme:)`
`pageIdentifier(readerView:pageNum:)`:返回对应类名字符串用于 cell 出队
### 3.4 工具栏交互
- RDReaderView 内部处理点击手势,中心 1/3 区域切换工具栏
- 顶部工具栏:返回按钮 pop/dismiss标题设为 metadata.title
- 底部工具栏4 个按钮按配置 flags 控制可见性
- 目录按钮 → present `RDEPUBReaderChapterListController`.pageSheet
- 批注按钮 → present `RDEPUBReaderHighlightsViewController`.pageSheet
- 标注按钮 → `presentAnnotationActionSheet(for:)`(需有 currentSelection否则 disabled可创建高亮、划线或带 note 的批注
- 设置按钮 → present `RDEPUBReaderSettingsViewController`.pageSheet
### 3.5 设置变更处理
- 字号变化:`configuration.fontSize = newValue` → `requiresRepagination``repaginatePreservingCurrentLocation()`
- 行高变化:同上
- 显示模式变化:`readerView.switchReaderDisplayType()` 重建容器
- 主题变化:`configuration.theme = newValue` → `requiresVisibleRefresh``refreshVisibleContentPreservingLocation()`
- 亮度变化:`UIScreen.main.brightness = value` + 持久化
### 3.6 标注管理
- 创建:`addAnnotation(from:style:color:note:)` → 从 currentSelection 构建 → 去重 → 追加到 activeHighlights → 持久化 → 刷新可见内容
- 兼容入口:`addHighlight(from:color:note:)` 仍保留,内部按 `.highlight` 创建
- 样式:`RDEPUBHighlightStyle.highlight` 使用背景色,`RDEPUBHighlightStyle.underline` 使用下划线;旧数据缺少 style 时默认按普通高亮解码
- 批注note 非空时同一条 `RDEPUBHighlight` 同时承担批注语义
- 删除:`removeHighlight(id:)` → 移除 → 持久化 → 刷新
- 编辑备注:`updateHighlightNote(id:note:)` → 更新 → 持久化 → 刷新
- 跳转:`go(toHighlightID:animated:)` → 从高亮 location 解析页码 → `transitionToPage`
- WebView 选区:`epub-bridge.js` 生成 `dom-range` rangeInfoWeb 内容通过 payload 的 `style` 字段恢复高亮或划线
- DT 选区:`RDEPUBTextContentView` 把页内 selectedRange 映射为章节全局 text-offset rangeInfo并在当前页与标注范围重叠时叠加背景或下划线
- 选区菜单WebView 与 DT 路径都通过自定义菜单动作收口到 `拷贝 / 高亮 / 批注`,动作最终复用同一套创建与批注输入流程
### 3.7 书签管理
- 创建/切换:`toggleBookmark(note:)` 基于 `currentVisibleLocation()` 生成当前位置书签;若同一语义位置已存在书签则执行取消
- 位置范围:书签使用 `RDEPUBLocation`,持久化字段包含 `href / fragment / progression`,不依赖正文选区
- 当前位置命中:优先比较标准化后的 `href`,若双方都有 `fragment` 则直接比较;否则按 `navigationProgression` 容差判断fixed-layout 0.01reflowable 0.05
- 标题补全:优先复用当前 TOC 项标题,否则根据扁平目录用 href 反查章节标题
- 列表管理:底部工具栏 `书签` 入口弹出 `RDEPUBReaderBookmarksViewController`,支持按创建时间倒序展示、跳转与删除
- 顶部状态TopToolView 右上角书签按钮显示当前页是否已加书签,点击可直接添加/取消
- 持久化:每次新增/删除都会通过 `RDEPUBReaderPersistence.saveBookmarks(_:for:)` 按书保存,并回调 `didUpdateBookmarks`
### 3.8 搜索流程
1. `search(keyword:)`:标准化输入
2. textBook 路径:`RDEPUBTextSearchEngine.search(keyword:)`
3. web 路径:`RDEPUBHTMLSearchEngine.search(keyword:)`
4. 存储结果到 `searchState`,导航到第一个匹配
5. `searchNext()` / `searchPrevious()`:循环推进 currentMatchIndex 并导航
6. `clearSearch()`:清除状态,刷新可见内容
### 3.9 阅读位置持久化
**保存时机**
- 每次翻页:`pageNum(readerView:pageNum:)` → `persist(location:)`
- WebView 滚动:`epubWebContentView(_:didUpdateLocation:)` → `persist(location:)`
**恢复时机**
- 初始加载:`loadPublication()` 读取 → 传递到 `finishPagination()``restoreReadingLocation()`
- 显式导航:`go(to:)` / `go(toTableOfContentsHref:)` / `go(toHighlightID:)` / 搜索导航
**恢复逻辑**
- textBook 路径:`textBook.pageNumber(for:resolver:bookIdentifier:)` → `readerView.transitionToPage`
- readingSession 路径:`readingSession.queueNavigation(to:)` → `transitionToPage`
### 3.9 横竖屏适配
- `viewWillTransition(to:with:)`:等待旋转过渡完成后,统一触发 viewport 变化处理
- `viewDidLayoutSubviews`用于补齐分屏、多窗口、safe area 变化等非旋转型视口变化
- 控制器内部使用 viewport signature 做去重,避免一次旋转触发多次正文重分页
- RDReaderView 的 `readerViewOrientationWillChange` 仅保留容器级双页布局和 pageCurl 重建职责,不再直接驱动 EPUBUI 重新分页
## 4. 异常与边界处理
- EPUB 解析失败:显示 errorLabeldelegate 收到 `didFailWithError`
- 分页失败:主线程 `handle(error:)` 处理
- `paginationToken` 防竞态:每次加载/分页生成新 UUID后台完成时校验 token 一致才应用结果
- 标注去重:`addAnnotation` 检查相同 location、text、rangeInfo 和 style 是否已存在标注
- 自定义主题不可持久化:仅 6 个内置 preset 可序列化,自定义主题仅当前会话有效
- 目录页码缺失:`flattenedTableOfContents` 中 pageNumber 可能为 nil无法定位到对应页面的 TOC 项)
## 5. 数据结构与字段映射
### 5.1 持久化键值
| 数据 | UserDefaults Key | 编码 |
|------|-----------------|------|
| 阅读位置 | `ssreader.epub.location.<bookIdentifier>` | JSON → `RDEPUBLocation` |
| 高亮列表 | `ssreader.epub.highlights.<bookIdentifier>` | JSON → `[RDEPUBHighlight]` |
| 书签列表 | `ssreader.epub.bookmarks.<bookIdentifier>` | JSON → `[RDEPUBBookmark]` |
| 阅读设置 | `ssreader.epub.settings` | JSON → `RDEPUBReaderSettings` |
### 5.2 设置模型
```swift
RDEPUBReaderSettings (Codable)
├── brightness: CGFloat?
├── fontSize: CGFloat?
├── lineHeightMultiple: CGFloat?
├── displayMode: RDEPUBReaderDisplayMode?
└── themePreset: RDEPUBReaderThemePreset?
```
`RDEPUBReaderDisplayMode`可序列化的翻页模式枚举pageCurl / horizontalScroll / verticalScroll`RDReaderView.DisplayType` 相互转换。注意:历史版本遗留的 `horizontalCoverScroll` 会自动映射为 `horizontalScroll`
`RDEPUBReaderThemePreset`可序列化的主题预设枚举light / yellow / green / pink / blue / dark`RDEPUBReaderTheme` 相互转换。
### 5.3 Book Identifier
- 优先使用 `parser.metadata.identifier`
- 回退使用 `epubURL.lastPathComponent`
### 5.4 目录扁平化项
```swift
RDEPUBReaderTableOfContentsItem
├── title: String
├── href: String
├── depth: Int // 缩进层级
└── pageNumber: Int? // 可能为 nil
```
## 6. 视图绑定规则
- 规则 1内容视图通过 class name 注册到 RDReaderView出队时根据 textBook 是否存在选择类型
- 规则 2RDEPUBTextContentView 展示 attributedText + 搜索高亮叠加 + 页码标签("N / M"
- 规则 3RDEPUBWebContentView 包装 RDEPUBWebView + 页码标签
- 规则 4顶部工具栏固定提供返回与书签切换入口底部工具栏提供目录、书签、标注、设置等管理入口
- 规则 5设置面板所有变更实时生效闭包回调无需确认按钮
## 7. 通知协作与回调机制
### 外部通信Delegate
- `RDEPUBReaderDelegate`12 个可选方法,覆盖打开、位置更新、到达末尾、选择变化、高亮更新、书签更新、搜索更新、目录项更新、外部链接、错误、工具栏自定义
### 内部通信(闭包)
- `RDEPUBReaderTopToolView.onBack / onToggleBookmark`
- `RDEPUBReaderBottomToolView.onShowTableOfContents / onShowBookmarks / onShowHighlights / onAddHighlight / onShowSettings`
- `RDEPUBReaderChapterListController.onSelectItem`
- `RDEPUBReaderHighlightsViewController.onSelectHighlight / onUpdateHighlight / onDeleteHighlight`
- `RDEPUBReaderSettingsViewController.onBrightnessChange / onFontSizeChange / onLineHeightChange / onDisplayTypeChange / onThemeChange`
### 容器通信(协议)
- `RDReaderDataSource` / `RDReaderDelegate`RDEPUBReaderController 实现,为 RDReaderView 提供页面数据和接收页面变化事件
- `RDEPUBWebContentViewDelegate`RDEPUBReaderController 实现,接收 WebView 的位置更新、选择变化、链接激活、JS 错误
## 8. 联调与排查建议
- 排查 1阅读器打开后空白
- 检查 `RDEPUBParser.parse(epubURL:)` 是否成功
- 检查 `startInitialLoadIfNeeded()` 是否被调用(需要 viewDidAppear
- 检查 errorLabel 是否显示
- 排查 2翻页后位置不恢复
- 检查 `persistence.loadLocation(for:)` 是否返回非 nil
- 检查 `restoreReadingLocation` 是否在 `finishPagination` 中被调用
- textBook 路径检查 `textBook.pageNumber(for:)` 返回值
- 排查 3设置变更后无效果
- 字号/行高变化需触发 `repaginatePreservingCurrentLocation()`
- 主题变化只需 `refreshVisibleContentPreservingLocation()`
- 检查 `requiresRepagination` / `requiresVisibleRefresh` 的判断逻辑
- 排查 4高亮不显示
- 确认 `currentSelection` 非 nil
- 确认 `addHighlight` 成功返回
- 确认 `refreshVisibleContentPreservingLocation()` 被调用
- textBook 路径检查 `RDEPUBTextContentView.configure` 中的搜索高亮逻辑
- 排查 5搜索无结果
- textBook 路径确认 `RDEPUBTextSearchEngine.search` 被调用
- web 路径确认 `RDEPUBHTMLSearchEngine.search` 被调用
- 检查搜索关键词是否为空
- 排查 6横屏布局异常
- 确认 `landscapeDualPageEnabled` 是否为 true
- 检查 `viewDidLayoutSubviews` 是否触发了重新分页
- 检查 RDReaderView 的 orientation change 处理

View File

@ -1,377 +0,0 @@
# EPUB 阅读模块维护与排查指南
## 1. 文档目的
本文档说明 EPUB 阅读模块的关键文件职责、主要数据流、常见问题排查和后续扩展建议,供维护时快速建立上下文。
完整架构设计请参考 [ARCHITECTURE.md](ARCHITECTURE.md)。
编码规范请参考 [CODING_STYLE.md](CODING_STYLE.md)。
---
## 2. 关键文件速查
### 2.1 EPUBCore 层
| 文件 | 职责 |
|------|------|
| `RDEPUBParser.swift` + 5 扩展 | EPUB 解压、container.xml、OPF、manifest、spine、TOC/NCX 解析、阅读配置文件判断 |
| `RDEPUBPublication.swift` | 解析结果的唯一聚合入口,外部不直接访问 parser 字段 |
| `RDEPUBModels.swift` | metadata / manifest / spine / TOC 基础模型 |
| `RDEPUBReadingModels.swift` | RDEPUBLocation、RDEPUBViewport、RDEPUBHighlight、RDEPUBBookmark、选区模型 |
| `RDEPUBResourceResolver.swift` | href → fileURL / schemeURL 的统一入口,`normalizedHref` 标准化路径 |
| `RDEPUBPaginator.swift` | 离屏 WKWebView 分页,计算每个 spine 资源被视口切成多少页 |
| `RDEPUBReadingSession.swift` | 状态机 + 会话协调,管理 staged/active/pending 页模型 |
| `RDEPUBNavigatorState.swift` | 状态枚举initializing/loading/idle/jumping/moving/repaginating |
| `RDEPUBWebView.swift` + 5 扩展 | 承载 spine 资源的 WKWebView分页 CSS 注入、JS bridge、fixed wrapper、搜索装饰 |
| `RDEPUBResourceURLSchemeHandler.swift` | `ss-reader://` 协议,从本地解压目录提供所有 EPUB 资源 |
| `RDEPUBStyleSheetBuilder.swift` | 生成注入 WebView 的 CSS |
| `RDEPUBJavaScriptBridge.swift` | JS ↔ Swift 消息定义和编解码 |
| `RDEPUBSearchEngine.swift` | 搜索协议定义 + WebView 全文搜索引擎 |
| `RDEPUBSearchModels.swift` | 搜索模型SearchMatch/Result/State/Presentation |
| `RDEPUBTextAnchor.swift` | 文本锚点和范围锚点(精确定位字符位置) |
| `RDEPUBRenderRequest.swift` | 渲染请求模型、展示样式、固定版式适配/Spread 枚举 |
| `RDEPUBWebViewDebug.swift` | WebView 调试日志工具(导航/JS/消息/Scheme |
| `RDEPUBAssetRepository.swift` | 静态资源加载器JS 脚本、HTML 模板、模板变量替换) |
| `RDEPUBPreferences.swift` | 用户阅读偏好聚合 |
| `RDEPUBNavigatorLayoutContext.swift` | 容器布局上下文 |
| `RDEPUBFixedLayoutTemplate.swift` | 固定版式 HTML 模板生成 |
### 2.2 EPUBTextRendering 层
| 文件 | 职责 |
|------|------|
| `RDEPUBTextRenderer.swift` | 渲染引擎协议 + 错误类型 + 样式 / 内容模型 |
| `RDEPUBDTCoreTextRenderer.swift` | DTCoreText 实现HTML → NSAttributedString |
| `RDEPUBTextRendererSupport.swift` | fragment 标记注入、fragment offset 提取、属性标准化 |
| `RDEPUBTextPaginationSupport.swift` | NSAttributedString 切页算法 |
| `RDEPUBTextBookBuilder.swift` | 驱动逐章节渲染和分页,生成 RDEPUBTextBook |
| `RDEPUBTextLayouter.swift` | CoreText 分页引擎(~810 行,含 4 级语义边界调整) |
| `RDEPUBTextLayoutFrame.swift` | 单帧分页结果模型contentRange、breakReason、语义提示 |
| `RDEPUBTextBookCache.swift` | 分页结果磁盘缓存 |
| `RDEPUBChapterData.swift` | 章节数据聚合模型(高亮、搜索结果、页面查询) |
| `RDEPUBTextSearchEngine.swift` | 纯文本全文搜索引擎 |
| `RDPlainTextBookBuilder.swift` | 纯文本(.txt书籍构建器 |
| `RDEPUBTextIndexTable.swift` | 全书文本索引表(章节偏移、行列映射、锚点转换) |
| `RDEPUBTextPerformanceSampler.swift` | 性能采样器(渲染/分页耗时、缓存命中率) |
### 2.3 EPUBUI 层
| 文件 | 职责 |
|------|------|
| `RDEPUBReaderController.swift` | 开箱即用读者控制器(~1995 行),整合 session / readerView / UI |
| `RDEPUBReaderConfiguration.swift` | 外部配置项13 个:字号、主题、翻页模式等) |
| `RDEPUBReaderPersistence.swift` | 阅读位置、高亮、书签的本地持久化8 个方法) |
| `RDEPUBReaderSettings.swift` | 可持久化用户设置模型 + DisplayMode/ThemePreset 枚举 |
| `RDEPUBReaderTheme.swift` | 6 个内置主题预设 + 颜色属性 |
| `RDEPUBReaderDelegate.swift` | 12 个可选委托方法 |
| `RDEPUBReaderToolView.swift` | 工具栏基类(分隔线 + 主题适配) |
| `RDEPUBReaderTopToolView.swift` | 顶部导航栏(返回 + 书签 + 标题) |
| `RDEPUBReaderBottomToolView.swift` | 底部工具栏(目录/书签/标注/设置) |
| `RDEPUBReaderChapterListController.swift` | 目录列表面板 |
| `RDEPUBReaderHighlightsViewController.swift` | 标注管理面板(过滤、编辑、删除、跳转) |
| `RDEPUBReaderSettingsViewController.swift` | 设置面板(亮度、字号、行高、模式、主题) |
| `RDEPUBReaderTableOfContentsItem.swift` | 展平目录条目模型 |
| `RDEPUBWebContentView.swift` | WKWebView 内容视图,供 RDReaderView 渲染 WebView 类 spine |
| `RDEPUBTextContentView.swift` | 文本内容视图(~728 行),供 RDReaderView 渲染文本类 spine |
| `RDEPUBPageInteractionController.swift` | CoreText 页面级交互控制器 |
| `RDEPUBSelectionOverlayView.swift` | 选区覆盖视图 |
| `RDEPUBPageLayoutSnapshot.swift` | 页面几何模型 |
| `RDURLReaderController.swift` | 最简 URL 入口(.epub/.txt 自动识别) |
---
## 3. 资源服务协议说明
加载 spine 资源统一使用 `ss-reader://` 协议,不直接使用 `file://`
```swift
// 正确方式
let schemeURL = publication.resourceResolver.schemeURL(forHref: spineItem.href)
webView.load(URLRequest(url: schemeURL))
// 禁止方式relative 资源、图片、CSS 会失效)
webView.loadHTMLString(htmlString, baseURL: nil)
```
这样 HTML 内部的相对 CSS、图片、字体和 iframe 资源都会通过同一个 schemeHandler 解析,避免 `loadHTMLString``allowingReadAccessTo` 导致的资源失效问题。
### 3.1 DTCoreText 实现逻辑textReflowable 路径)
`readingProfile == .textReflowable` 时,渲染链路不再走 WebView 列分页,而是走 `EPUBTextRendering/` 的 DTCoreText 文本渲染链。
**端到端流程:**
1. `RDEPUBTextBookBuilder` 按 spine 顺序读取章节 HTML。
2. `RDEPUBTextRendererSupport.injectFragmentMarkers(into:)` 注入 fragment 标记,保证目录锚点可映射到文本偏移。
3. `RDEPUBDTCoreTextRenderer.renderChapter(html:baseURL:style:)` 执行章节渲染。
4. 渲染成功时,内部通过 `DTHTMLAttributedStringBuilder` 将 HTML 转为 `NSAttributedString`
5. `RDEPUBTextRendererSupport.extractFragmentOffsets(from:)` 提取 `fragment -> 字符偏移` 映射。
6. `RDEPUBTextRendererSupport.normalizeReadingAttributes(in:style:)` 统一字体、行距、颜色等阅读属性。
7. `RDEPUBTextPaginationSupport` 将富文本按可视区域切页,构建页模型。
8. `RDEPUBTextBookBuilder` 汇总章节与页映射,交给 `RDEPUBTextContentView` 呈现。
**关键实现点:**
1. 条件编译:`#if canImport(DTCoreText)`
- 可用时走 DTCoreText 路径。
- 不可用时走 fallback`NSAttributedString` 降级),确保工程可编译可运行。
2. `dtOptions(baseURL:style:)` 会注入:
- 默认字体族 / 字体名 / 字号
- 行高倍数(由字体行高与行距折算)
- `NSBaseURLDocumentOption`(保证相对资源解析)
- 默认文本颜色
3. fragment 偏移在“属性标准化前后”都要保持稳定,避免目录跳转漂移。
4. 文本分页以字符范围为准,不以 WebView 像素滚动位置为准。
**失败与降级策略:**
1. HTML 转 UTF-8 失败:抛出 `htmlEncodingFailed`
2. DTCoreText builder 失败:进入 fallback 渲染路径。
3. fallback 仍会执行 fragment 提取与阅读属性标准化,保证目录跳转和阅读样式能力不丢失。
**与定位恢复的关系:**
1. 仍使用 `RDEPUBLocation``href + progression`)作为跨会话恢复模型。
2. textReflowable 模式下,`fragmentOffsets` 用于将目录锚点映射到分页后的字符区间,再换算到页索引。
3. 字号/行距变化触发重分页后,优先按 `href + progression` 回落恢复,再用 fragment 做细化定位。
**推荐日志点DTCoreText 专用):**
1. `RDEPUBDTCoreTextRenderer.renderChapter(...)`:记录章节 href、输入 HTML 长度、渲染耗时。
2. `makeAttributedString(from:baseURL:style:)`:记录 DTCoreText 是否成功返回 attributed string。
3. `extractFragmentOffsets(from:)`:记录 fragment 数量与关键锚点是否命中。
4. `RDEPUBTextPaginationSupport`:记录总页数、每页字符范围边界。
---
## 4. 常见问题排查
### 4.1 EPUB 打不开或解析失败
**优先检查:**
1. `RDEPUBParser.parse(epubURL:)` 是否抛出异常,打印具体错误
2. `RDEPUBParser+Archive.swift` — 解压是否成功
3. `META-INF/container.xml` 是否存在
4. OPF 中 manifest / spine 是否完整
**常见原因:**
- 文件不是标准 EPUB ZIP 结构
- `container.xml` 找不到 OPF 路径
- OPF manifest 或 spine 格式异常
- 文件路径包含特殊字符导致解压目录路径错误
**排查顺序:**
1. 确认 EPUB 已成功复制到沙盒
2. 确认解压目录存在(`extractionRootURL`
3. 确认 `opfDirectoryURL` 不为 nil
4. 确认 `spine.count > 0`
---
### 4.2 EPUB 显示空白页
**优先检查:**
1. `RDEPUBWebContentView.loadPage(...)` 传入的 spineIndex 是否在范围内
2. `RDEPUBWebView+Reflowable` — 分页 CSS 注入是否成功
3. `WKNavigationDelegate.didFinish` 是否正常触发
4. `RDEPUBResourceURLSchemeHandler` 是否正确返回资源数据
**常见原因:**
1. scheme URL 拼装错误,资源请求 404
2. 注入的 CSS 破坏了原始布局(尤其是 `column-width` 样式冲突)
3. fixed-layout 书籍走了 reflowable 渲染路径readingProfile 判定错误)
**排查顺序:**
1. 确认 `schemeURL(forHref:)` 返回的 URL 对应文件真实存在
2. 用 `RDEPUBWebViewDebug` 打开 WebView inspector 查看网络请求
3. 确认 readingProfile 判定正确webInteractive / webFixedLayout / textReflowable
---
### 4.3 修改字号后位置恢复不准
**原因:** 字号变化触发重新分页repaginating页号失效。
**正确做法:**
当前实现依赖 `href + progression` 恢复位置,不依赖纯页号。排查重点:
1. `RDEPUBLocation.href` 是否已通过 `normalizedHref` 标准化
2. `progression` 是否在字号变化后重新从 WebView 读取
3. `RDEPUBReadingSession.navigatorState` 是否在重分页后回到 `.idle` 并消费了 staged snapshot
---
### 4.4 目录点击后跳错位置
**优先检查:**
1. `RDEPUBParser+TOC.swift` — TOC href 是否正确标准化为相对 OPF 路径
2. `RDEPUBResourceResolver.normalizedHref(_:)` — 跳转时是否使用了标准化 href
3. `fragment` 是否正确传入 WebView 锚点滚动
**常见原因:**
1. TOC 链接是相对 Nav 或 NCX 文件目录,未转为相对 OPF
2. `href#fragment` 只命中了资源,未命中锚点
3. 同一资源中多个 TOC 条目被折叠成同一页
**排查步骤:**
1. 打印原始 TOC href 和标准化后 href 进行比较
2. 确认 `fragment` 字段被保留在 RDEPUBLocation 中
3. 确认 WebView 收到 `fragment` 后执行了 `scrollToFragment(fragment:)` JS 调用
---
### 4.5 正文内部链接不工作
**优先检查:**
1. `RDEPUBWebView+JavaScriptBridge` 中内部链接拦截逻辑
2. `RDEPUBReaderController.navigateToLocation(_:)` — 跨资源跳转路径
**常见原因:**
1. 链接是空 `#fragment`,但当前 DOM 中找不到对应元素
2. 跨资源链接 href 未被 normalizedHref 处理
3. 对应 spine 资源没有分页结果pageCounts[spineIndex] == 0
---
### 4.6 图片多时位置跳动
**原因:** 图片异步加载导致 DOM 高度变化,进度报告时机早于实际布局稳定。
**排查:**
1. 检查 JS `ResizeObserver` 是否在图片加载后触发了进度重同步
2. 确认 `didUpdateLocation` 不在 pending navigation 未消费前覆盖目标位置
3. 确认 `scrollToLocation()` JS 调用时序在 `didFinish` 之后
---
### 4.7 高亮未显示或恢复失败
**优先检查:**
1. `RDEPUBHighlight` 是否成功写入 persistence
2. `RDEPUBReaderController.activeHighlights` 是否正确过滤出当前 spine 的高亮
3. `RDEPUBWebView+JavaScriptBridge``setHighlights()` JS 调用是否触发
4. JS 中 `rangeFromInfo()` 是否能找到对应 DOM 节点(嵌套节点恢复容易失败)
**排查顺序:**
1. 确认有效选区(`currentSelection` 不为 nil
2. 确认 `addHighlight()` 调用后 persistence 中数据已写入
3. 确认当前页传入的 highlights 数组不为空
4. 确认 JS 收到了 highlights 数据并尝试了 rangeFromInfo 恢复
---
### 4.8 fixed-layout 书籍显示异常
**优先检查:**
1. `RDEPUBPublication.readingProfile` 是否判定为 `.webFixedLayout`
2. `RDEPUBFixedLayoutTemplate` — wrapper HTML 是否正确生成
3. viewport meta 尺寸是否与书籍 OPF 中声明一致
4. spread 模式是否正确(单页 vs 双页)
---
## 5. 推荐日志点
遇到复杂问题时,建议先在以下位置加日志:
1. `RDEPUBParser.parse(epubURL:)` — 打印解压路径、OPF 路径、spine 数量
2. `RDEPUBReadingSession.transition(to:)` — 打印状态跃迁
3. `RDEPUBPaginator` — 打印每个 spine 资源的分页结果
4. `RDEPUBReaderController.restoreLocation(_:)` — 打印恢复位置的 href / progression
5. `RDEPUBWebView+JavaScriptBridge` — 打印所有 JS 消息收发
6. `RDEPUBResourceURLSchemeHandler` — 打印资源请求 URL 和响应状态
---
## 6. 标准排查顺序
当 EPUB 功能出问题时,建议按以下顺序排查:
1. **解析是否成功** — parser.spine.count > 0metadata 正确?
2. **readingProfile 是否正确** — 用了对的渲染路径?
3. **TOC href 是否标准化** — 与 spine href 格式一致?
4. **分页结果是否合理** — 每个 spine 资源的 pageCount > 0
5. **资源是否能正常加载** — ss-reader:// 请求是否 200
6. **JS bridge 是否正常** — progression 上报是否到达 Swift 侧?
7. **状态机是否流转正确** — session.navigatorState 是否符合预期?
8. **控制器是否正确映射页号** — EPUBPage 列表与 RDReaderView 页号对应是否正确?
---
## 7. 后续扩展建议
1. **拆分 RDEPUBReaderController**:约 1995 行EPUB 加载、UI、数据源职责仍混在一起建议继续向 library 层迁移
2. **基线验证**:用四本样书跑一轮分页耗时、目录命中率、末页事件、设置恢复稳定性数据
3. **横竖屏支持回归矩阵**:控制器级 `viewWillTransition()` 与 viewport 去重已接入,下一步重点是补齐 pageCurl / scroll、三条正文路径和 iPad 视口变化场景的固定回归
4. **Pod 分层**:评估是否将 EPUBCore / EPUBTextRendering / EPUBUI 拆为独立 subspec
5. **RDEPUBParser 继续收敛**OPF metadata 细节处理仍偏重,可考虑按 OPFMetadataParser 单独拆出
---
## 8. 阅读器能力现状与下一阶段规划
### 8.1 当前已具备的核心能力
当前 SDK 已具备一套可用的 EPUB 阅读器主链路,包含:
1. **EPUB 打开与解析**:支持 ZIP 解压、OPF / spine / TOC 解析,以及三类 readingProfile 分流
2. **正文渲染与分页**:支持 WebView 路径和 DTCoreText 文本路径,覆盖固定版式、交互式可重排和纯文本可重排 EPUB
3. **完整阅读交互**:支持目录跳转、翻页、阅读位置恢复、主题切换、字号和行高调整、亮度调整
4. **标注能力**:支持高亮、划线、批注、标注列表、编辑、删除、跳转与持久化恢复
5. **书签能力**:支持当前位置书签添加/取消、书签列表、删除、跳转与持久化恢复
6. **自动化测试基础能力**:已接入 `ReadViewSDKDemoTests` / `ReadViewSDKDemoUITests`、样书夹具定位工具,以及 parser / resolver / persistence 首批 XCTest 和阅读器启动 smoke 用例
7. **搜索能力**:支持全文搜索、匹配项跳转、当前匹配高亮
### 8.2 高优先级待补能力
从“可用 MVP”走向“稳定可交付阅读器”当前最值得优先完成的是
1. **样书基线验证**
需要基于四本样书补齐分页耗时、目录命中率、末页事件、设置恢复稳定性等基线数据
### 8.3 面向完整商业阅读器的下一阶段能力
若目标是继续向完整阅读产品演进,建议后续补齐以下能力:
1. **阅读进度展示**
包括全书进度、章节进度、页码信息、剩余页数或剩余章节等
2. **更完整的标注管理**
包括按章节分组、排序、筛选、批量删除、标注导出、复制批注内容等
3. **更成熟的搜索体验**
包括搜索结果列表页、章节聚合、上下文预览、搜索历史等
4. **更多阅读增强能力**
包括字体切换、自定义主题、翻页手势自定义、TTS 朗读、无障碍支持等
### 8.4 工程侧持续建设建议
除了阅读器功能本身,还建议并行推进以下工程项:
1. **控制器继续拆分**
`RDEPUBReaderController` 与 demo 层控制器都偏大,继续叠加功能会增加维护成本
2. **Parser 与模块职责继续收敛**
`RDEPUBParser` 仍承担较多 OPF metadata 解析细节,后续可继续按职责拆分
3. **回归验收矩阵固化**
建议把样书、渲染路径、主题、字号、翻页模式、标注恢复等整理为固定回归清单
### 8.5 推荐下一阶段实施顺序
如果以“最小投入换最大稳定性提升”为目标,建议按下面顺序推进:
1. **样书基线验证**

View File

@ -1,233 +0,0 @@
# RDReaderView 功能实现逻辑
## 1. 范围与目标
- 代码范围:`Sources/RDReaderView/ReaderView/`5 个 Swift 文件)
- 目标说明分页阅读器容器如何管理三种显示模式、DataSource/Delegate 协议、翻页交互、工具栏动画、双屏适配和 RTL 支持。
- 主链路关键词:`RDReaderDataSource -> reloadData -> DisplayType 切换 -> 翻页/滚动 -> RDReaderDelegate.pageNum -> 工具栏显隐`。
## 2. 关键对象职责
### 2.1 核心容器 `RDReaderView`
- 文件:`Sources/RDReaderView/ReaderView/RDReaderView.swift`~1219 行)
- 入口方法:`reloadData()`
- 职责:
- 管理三种显示模式的视图层级切换
- 持有 `UIPageViewController`pageCurl 模式)或 `UICollectionView`(滚动模式)
- 处理点击手势(左/中/右三区域)
- 管理工具栏topToolView / bottomToolView的显示/隐藏动画
- 检测横竖屏变化并触发重新布局
- 管理双屏配对逻辑(含封面页处理)
- RTL 语言方向支持
### 2.2 自定义布局 `RDReaderFlowLayout`
- 文件:`Sources/RDReaderView/ReaderView/RDReaderFlowLayout.swift`~375 行)
- 职责:
- 继承 `UICollectionViewFlowLayout`,为两种滚动模式提供布局计算
- 水平滚动:全屏宽 itempagingEnabled
- 垂直滚动:可变高度 item累加计算
- 封面感知帧计算:封面页全屏宽,后续页面两两配对半屏宽
### 2.3 内容 Cell `RDReaderContentCell`
- 文件:`Sources/RDReaderView/ReaderView/RDReaderContentCell.swift`~55 行)
- 职责:
- `UICollectionViewCell` 子类,作为内容视图的薄壳宿主
- `containerView` 属性 setter 自动移除旧视图、添加新视图
- `layoutSubviews` 将 containerView 填满 contentView.bounds
### 2.4 页面子控制器 `RDReaderPageChildViewController`
- 文件:`Sources/RDReaderView/ReaderView/RDReaderPageChildViewController.swift`~89 行)
- 职责:
- 仅用于 pageCurl 模式,作为 `UIPageViewController` 的子控制器
- 持有 `contentView: UIView?``pageNum: Int`
- `contentView` didSet 在 view 已加载时自动调用 `installContentView()`
### 2.5 手势控制器 `RDReaderGestureController`
- 文件:`Sources/RDReaderView/ReaderView/RDReaderGestureController.swift`~58 行)
- 职责:
- 当前为占位组件,存储 topToolView / bottomToolView 引用
- 实际手势逻辑在 `RDReaderView.tapCenter()` 中实现
## 3. 主流程(代码级)
### 3.1 协议定义
**RDReaderDataSource**(数据供给):
```swift
func pageCountOfReaderView(readerView: RDReaderView) -> Int
func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView
func pageIdentifier(readerView: RDReaderView, pageNum: Int) -> String?
@objc optional func topToolView(readerView: RDReaderView) -> UIView?
@objc optional func bottomToolView(readerView: RDReaderView) -> UIView?
```
**RDReaderDelegate**(事件回调):
```swift
func pageNum(readerView: RDReaderView, pageNum: Int)
@objc optional func readerViewOrientationWillChange(readerView: RDReaderView, isLandscape: Bool)
```
### 3.2 初始化与数据加载
1. 调用方创建 `RDReaderView`,设置 `dataSource``delegate`
2. 调用 `reloadData()`
3. `reloadData` 内部调用 `switchReaderDisplayType(currentDisplayType)` 重建视图层级。
4. 同时从 `dataSource` 获取 `topToolView``bottomToolView` 并添加到视图层级。
### 3.3 三种显示模式切换
**pageCurl 模式**
- 创建 `UIPageViewController`transitionStyle: .pageCurl
- 通过 `attachPageViewControllerIfNeeded()` 添加为父 VC 的 child VC
- 横屏双页时重建 `UIPageViewController`spineLocation: .mid, isDoubleSided: true
- RTL 时翻转导航方向
**horizontalScroll 模式**
- 移除 pageViewController插入 `UICollectionView`
- `RDReaderFlowLayout` 设置 scrollDirection = .horizontal, isPagingEnabled = true
- itemSize 宽度 = 容器宽度 / pagesPerScreen
- RTL 时对 collectionView 做 `scaleX: -1` 翻转cell contentView 再翻转回来
**verticalScroll 模式**
- 同一 collectionViewscrollDirection = .vertical, isPagingEnabled = false
- 页面高度可变,通过 `RDReaderFlowLayoutDataSoure.heigtOfVerticalScrollPage` 查询
- collectionViewContentSize 为所有页面高度之和
### 3.4 翻页交互
**点击手势**`tapAction(tap:)`
- 屏幕分为左 1/3、中 1/3、右 1/3 三个区域
- 左区域工具栏隐藏时翻上一页RTL 时翻下一页);工具栏显示时触发 `tapCenter()`
- 中区域:始终触发 `tapCenter()`
- 右区域工具栏隐藏时翻下一页RTL 时翻上一页);工具栏显示时触发 `tapCenter()`
**goNextPage() / goPreviousPage()**
- 双页模式使用 `adjacentDualPage(from:forward:)` 计算目标页
- 单页模式直接 +1 / -1
- 调用 `transitionToPage(pageNum:animated:true)` 执行跳转
**工具栏切换**`tapCenter()`
- 显示时topToolView 从上方滑入bottomToolView 从下方滑入CGAffineTransform translationY
- 隐藏时:反向动画
- 工具栏显示期间禁用 collectionView 和 pageViewController 的用户交互
### 3.5 双页配对逻辑
**`dualPagePair(for pageNum:)`** 返回 `(left: Int, right: Int?)`
- 有封面页:封面页 → (coverIndex, nil)封面后的页面两两配对coverIndex+1 与 coverIndex+2coverIndex+3 与 coverIndex+4...
- 无封面页标准偶奇配对0+1, 2+3, 4+5...
**空白哨兵页**
- `blankPageNum = Int.max``blankEndPageNum = Int.max - 1`
- 仅用于 pageCurl 双页模式,填充封面页独占或总页数为奇数时的右侧空白
### 3.6 横竖屏变化处理
1. `layoutSubviews()` 检测 `previousIsLandscape` 与当前 `isLandscape` 的变化
2. 异步调用 `orientationChanged(isNowLandscape:)` 避免嵌套布局
3. 通知 delegate `readerViewOrientationWillChange`
4. 更新 `layout.isLandscapeDualPage``layout.coverPageIndex`
5. pageCurl 模式:重建 UIPageViewControllerspineLocation 不可变)并跳转到保存的页面
6. 滚动模式invalidate layoutreload data强制布局无动画滚动到保存的 offset
### 3.7 CollectionView DataSource / Layout Delegate
**cellForItemAt**
- 用 `dataSource.pageIdentifier` 获取重用标识符
- 出队 `RDReaderContentCell`
- 调用 `dataSource.pageContentView(pageNum:containerView:)` 传入 cell 已有的 containerView 以便复用
- RTL 水平模式对 cell.contentView 做 `scaleX: -1` 翻转
**pageNum(flowLayout:pageIndex:)**
- 布局检测到滚动中页面变化时更新 `currentPage`
## 4. 异常与边界处理
- `currentPage` 初始值为 -1首次设置时 delegate 会收到回调
- 空白哨兵页Int.max / Int.max-1在 delegate 回调中被过滤,不会通知到外部
- pageCurl 模式下 collectionView 的 DataSource 方法存在但不被调用collectionView 已从视图层级移除)
- 横竖屏变化时异步处理避免嵌套 layoutSubviews
- `UIView.ss_superViewController` 通过响应链查找最近的父 VC用于正确挂载 pageViewController
## 5. 数据结构与字段映射
### 5.1 显示模式
```swift
RDReaderView.DisplayType
├── .pageCurl // UIPageViewController 翻页效果
├── .horizontalScroll // UICollectionView 水平滚动
└── .verticalScroll // UICollectionView 垂直滚动
```
### 5.2 翻页方向
```swift
RDReaderView.PageDirection
├── .leftToRight // 默认,中文/英文
└── .rightToLeft // 日文漫画等
```
### 5.3 关键公开属性
| 属性 | 类型 | 默认值 | 用途 |
|------|------|--------|------|
| `dataSource` | `RDReaderDataSource?` | nil | 弱引用数据源 |
| `delegate` | `RDReaderDelegate?` | nil | 弱引用事件监听 |
| `currentDisplayType` | `DisplayType` | .pageCurl | 当前显示模式 |
| `toolViewAnimationDuration` | `TimeInterval` | 0.3 | 工具栏动画时长 |
| `landscapeDualPageEnabled` | `Bool` | false | 横屏双页 |
| `pageDirection` | `PageDirection` | .leftToRight | 翻页方向 |
| `coverPageIndex` | `Int?` | nil | 封面页索引 |
| `currentPage` | `Int` | -1 | 当前页码didSet 通知 delegate |
### 5.4 计算属性
- `pagesPerScreen: Int`:横屏 + 双页启用 + 非垂直滚动 → 2否则 1
- `isLandscape: Bool``bounds.width > bounds.height`
- `hasCoverPage: Bool``coverPageIndex != nil`
## 6. 视图绑定规则
- 规则 1内容视图通过 `register(contentView:contentViewWithReuseIdentifier:)` 注册,内部注册对应的 `RDReaderContentCell`
- 规则 2Cell 复用两层:`RDReaderContentCell`UICollectionViewCell宿主 `containerView`(实际内容视图)
- 规则 3`pageContentView(readerView:pageNum:containerView:)` 的 `containerView` 参数是从回收 cell 中取出的旧视图,允许数据源复用或重新渲染
- 规则 4工具栏通过 `topToolView` / `bottomToolView` 协议方法提供,由 `RDReaderView` 管理显隐动画
- 规则 5RTL 支持通过 collectionView `scaleX: -1` + cell contentView `scaleX: -1` 实现,文本正确显示
## 7. 通知协作与回调机制
- 无 NotificationCenter 使用
- 所有通信通过协议和闭包:
- `RDReaderDataSource`:数据供给(页数、内容视图、标识符、工具栏)
- `RDReaderDelegate`:事件通知(页面变化、横竖屏即将变化)
- `RDReaderFlowLayoutDelegate`:布局检测到页面变化时通知
- `RDReaderFlowLayoutDataSoure`:垂直滚动时提供每页高度
## 8. 联调与排查建议
- 排查 1页面不显示
- 确认 `dataSource` 已设置且 `reloadData()` 已调用
- 确认 `pageCountOfReaderView` 返回值 > 0
- 确认 `pageContentView(pageNum:containerView:)` 返回非 nil 视图
- 排查 2翻页无反应
- 确认工具栏是否正在显示(工具栏显示时左右点击变为切换工具栏)
- 确认 `currentPage` 的 didSet 是否被触发
- pageCurl 模式检查 `UIPageViewController` 的 dataSource 方法是否正确返回
- 排查 3横屏双页异常
- 确认 `landscapeDualPageEnabled` 是否为 true
- pageCurl 模式需重建 UIPageViewControllerspineLocation 不可变)
- 检查 `dualPagePair` 配对逻辑和空白哨兵页处理
- 排查 4RTL 方向错误
- 确认 `pageDirection` 是否设为 `.rightToLeft`
- 水平滚动模式检查 collectionView 是否做了 `scaleX: -1`
- pageCurl 模式检查导航方向是否翻转
- 排查 5工具栏动画异常
- 确认 `topToolView` / `bottomToolView` 是否通过 dataSource 正确提供
- 检查 `toolViewAnimationDuration`
- 确认工具栏显示期间用户交互是否被正确禁用/恢复

View File

@ -1,415 +0,0 @@
# ReadViewSDK UI 自动化测试可执行方案
## 1. 目标与边界
本文档描述当前架构下可落地的 XCUITest 方案。目标不是一次性覆盖所有 SDK API而是先为 Demo App 的核心阅读链路建立稳定回归网:
- 书库页面能展示样本书。
- 样本书能打开阅读器。
- 点击阅读区域中部能显示顶部和底部工具栏。
- 顶部返回按钮能关闭阅读器并回到书库。
- 设置面板能打开、操作、关闭。
- 三种翻页模式能通过启动参数切换并保持阅读器可用。
XCUITest 运行在独立进程,不能直接调用 `RDEPUBReaderController` 的 Swift 公共 API。因此需要通过 UI 元素、launch arguments、Demo 测试状态标签或截图附件验证结果。SDK 公共 API 可以由单元测试或 Demo 自动化入口间接驱动,不应写成 XCUITest 的直接依赖。
## 2. 当前可用基础
### 2.1 已有 accessibilityIdentifier
| 标识符 | 当前文件 | 用途 |
|--------|----------|------|
| `epub.reader.back` | `Sources/RDReaderView/EPUBUI/RDEPUBReaderTopToolView.swift` | 顶部返回按钮 |
| `epub.reader.bookmark` | `Sources/RDReaderView/EPUBUI/RDEPUBReaderTopToolView.swift` | 顶部书签按钮 |
| `epub.reader.title` | `Sources/RDReaderView/EPUBUI/RDEPUBReaderTopToolView.swift` | 顶部标题 |
| `epub.reader.toc` | `Sources/RDReaderView/EPUBUI/RDEPUBReaderBottomToolView.swift` | 目录按钮 |
| `epub.reader.bookmarks` | `Sources/RDReaderView/EPUBUI/RDEPUBReaderBottomToolView.swift` | 书签列表按钮 |
| `epub.reader.highlights` | `Sources/RDReaderView/EPUBUI/RDEPUBReaderBottomToolView.swift` | 高亮列表按钮 |
| `epub.reader.add-highlight` | `Sources/RDReaderView/EPUBUI/RDEPUBReaderBottomToolView.swift` | 新建高亮按钮 |
| `epub.reader.settings` | `Sources/RDReaderView/EPUBUI/RDEPUBReaderBottomToolView.swift` | 设置按钮 |
| `demo.root` | `ReadViewDemo/ReadViewDemo/ViewController.swift` | Demo 根视图 |
| `demo.status` | `ReadViewDemo/ReadViewDemo/ViewController.swift` | Demo 状态标签 |
| `demo.books.table` | `ReadViewDemo/ReadViewDemo/ViewController.swift` | 书库表格 |
| `demo.books.empty` | `ReadViewDemo/ReadViewDemo/ViewController.swift` | 空书库提示 |
| `demo.book.{n}` | `ReadViewDemo/ReadViewDemo/ViewController.swift` | 书库第 n 本书 |
| `demo.reader.host` | `ReadViewDemo/ReadViewDemo/ViewController.swift` | modal 场景下的阅读器宿主 |
### 2.2 已有 Demo 启动参数
`ReadViewDemo/ReadViewDemo/ViewController.swift` 已有 `LaunchAutomationPlan`
```bash
--demo-book-title <关键词>
--demo-display-type <pagecurl|scroll|vertical>
--demo-page <页码>
--demo-display-sequence <pagecurl,scroll,vertical>
--demo-step-delay <>
```
首批测试优先使用 `--demo-book-title 回归验证样本`。该样本目前存在于 `ReadViewDemo/ReadViewDemo/book/回归验证样本.txt`,适合作为稳定自动化入口。
## 3. 当前架构下的落点
不要把测试辅助职责重新塞回 `RDEPUBReaderController.swift`。identifier 和测试状态应按真实 UI/职责归属放置:
| 能力 | 落点 | 原因 |
|------|------|------|
| 顶部工具栏容器 | `RDEPUBReaderTopToolView.swift` | 工具栏自身创建和维护顶部按钮 |
| 底部工具栏容器 | `RDEPUBReaderBottomToolView.swift` | 工具栏自身创建和维护底部按钮 |
| 阅读点击区域 | `RDReaderView.swift` | 点击中区、翻页手势都由 ReaderView 处理 |
| 滚动翻页容器 | `RDReaderView.swift``collectionView` | 横滑/竖滑模式使用 collection view |
| 单页内容 cell | `RDReaderContentCell.swift` 或当前分页 cell 文件 | 验证 cell 存在,不验证文本排版细节 |
| 设置面板控件 | `EPUBUI/Settings/RDEPUBReaderSettingsViewController.swift` | 设置面板已从 EPUBUI 根目录迁移到 Settings |
| 目录列表 | `RDEPUBReaderChapterListController.swift` | 目录是独立 UITableViewController |
| 高亮/书签列表 | `RDEPUBReaderHighlightsViewController.swift` | 高亮和书签管理器在该文件中 |
| Demo 自动化状态 | `ViewController.swift``RDURLReaderController.swift` | XCUITest 通过文本/identifier 读取状态 |
## 4. 需要新增的最小标识符
### 4.1 P0 必需
| 标识符 | 建议文件 | 用途 |
|--------|----------|------|
| `epub.reader.topToolbar` | `RDEPUBReaderTopToolView.swift` | 断言顶部工具栏显示/隐藏 |
| `epub.reader.bottomToolbar` | `RDEPUBReaderBottomToolView.swift` | 断言底部工具栏显示/隐藏 |
| `epub.reader.content` | `RDReaderView.swift` | 点击阅读区域中部、滑动翻页 |
| `epub.reader.paging` | `RDReaderView.swift``collectionView` | 横滑/竖滑容器存在性 |
| `epub.reader.settings.scroll` | `RDEPUBReaderSettingsViewController.swift` | 设置面板已打开 |
| `epub.reader.settings.brightness` | `RDEPUBReaderSettingsViewController.swift` | 亮度 slider |
| `epub.reader.settings.font.decrease` | `RDEPUBReaderSettingsViewController.swift` | 字号减 |
| `epub.reader.settings.font.increase` | `RDEPUBReaderSettingsViewController.swift` | 字号加 |
| `epub.reader.settings.font.value` | `RDEPUBReaderSettingsViewController.swift` | 字号值 |
| `epub.reader.settings.displayType` | `RDEPUBReaderSettingsViewController.swift` | 翻页模式分段控件 |
| `epub.reader.settings.done` | `RDEPUBReaderSettingsViewController.swift` | 完成按钮 |
| `demo.reader.state` | Demo 层 | 输出当前打开状态、页码、翻页模式 |
### 4.2 P1 后续补充
| 标识符 | 建议文件 | 用途 |
|--------|----------|------|
| `epub.reader.toc.list` | `RDEPUBReaderChapterListController.swift` | 目录列表 |
| `epub.reader.toc.cell.{n}` | `RDEPUBReaderChapterListController.swift` | 目录项 |
| `epub.reader.bookmark.list` | `RDEPUBReaderHighlightsViewController.swift` 的书签控制器 | 书签列表 |
| `epub.reader.bookmark.cell.{n}` | `RDEPUBReaderHighlightsViewController.swift` 的书签控制器 | 书签项 |
| `epub.reader.highlight.list` | `RDEPUBReaderHighlightsViewController.swift` | 高亮列表 |
| `epub.reader.highlight.cell.{n}` | `RDEPUBReaderHighlightsViewController.swift` | 高亮项 |
| `epub.reader.settings.lineHeight` | `RDEPUBReaderSettingsViewController.swift` | 行距分段控件 |
| `epub.reader.settings.columns` | `RDEPUBReaderSettingsViewController.swift` | 栏数分段控件 |
| `epub.reader.settings.theme.{n}` | `RDEPUBReaderSettingsViewController.swift` | 主题按钮 |
`epub.reader.pageIndicator` 暂不列为必需项,因为当前没有稳定的页码指示器 UI。若需要断言页码优先通过 `demo.reader.state` 暴露 `page=...`,或者给 `RDReaderView` 设置 `accessibilityValue`
## 5. Demo 测试状态设计
建议增加一个仅用于自动化的状态标签:
```swift
stateLabel.accessibilityIdentifier = "demo.reader.state"
stateLabel.isHidden = true
stateLabel.text = "reader=opened page=1 display=pagecurl toolbar=hidden"
```
状态来源可以在 Demo 层更新,不要求 SDK 为测试暴露内部对象:
- 打开阅读器后:`reader=opened`
- 返回书库后:`reader=closed`
- 跳页或翻页后:`page=<n>`
- 切换翻页模式后:`display=pagecurl|scroll|vertical`
- 工具栏显示后:`toolbar=visible`
这个标签能显著减少 XCUITest 对动画、布局和截图的猜测,是当前架构下最稳的可执行方案。
## 6. UI Test Target 创建方式
使用 workspace不使用单独的 xcodeproj
1. 打开 `ReadViewDemo/ReadViewDemo.xcworkspace`
2. File -> New -> Target -> iOS UI Testing Bundle
3. Product Name: `ReadViewDemoUITests`
4. Target to be Tested: `ReadViewDemo`
5. 新增测试目录:
```text
ReadViewDemo/
ReadViewDemoUITests/
Helpers/
AccessibilityIdentifiers.swift
XCUIApplication+Launch.swift
XCUIElement+Wait.swift
ReaderUITests/
BookListTests.swift
ReaderOpenCloseTests.swift
ReaderToolbarTests.swift
SettingsPanelTests.swift
DisplayTypeTests.swift
SmokeScreenshotTests.swift
```
首批不要拆太多测试类,避免还没稳定就出现维护成本。
## 7. P0 测试用例
### 7.1 启动辅助
```swift
import XCTest
enum IDs {
static let demoStatus = "demo.status"
static let demoBooksTable = "demo.books.table"
static func demoBook(_ n: Int) -> String { "demo.book.\(n)" }
static let demoReaderState = "demo.reader.state"
static let readerBack = "epub.reader.back"
static let readerTitle = "epub.reader.title"
static let readerTopToolbar = "epub.reader.topToolbar"
static let readerBottomToolbar = "epub.reader.bottomToolbar"
static let readerContent = "epub.reader.content"
static let readerSettings = "epub.reader.settings"
static let settingsScroll = "epub.reader.settings.scroll"
static let settingsFontIncrease = "epub.reader.settings.font.increase"
static let settingsFontDecrease = "epub.reader.settings.font.decrease"
static let settingsFontValue = "epub.reader.settings.font.value"
static let settingsDone = "epub.reader.settings.done"
}
extension XCUIApplication {
func launchAndOpenSampleBook(
displayType: String? = nil,
pageNumber: Int? = nil
) {
var args = ["--demo-book-title", "回归验证样本"]
if let displayType {
args += ["--demo-display-type", displayType]
}
if let pageNumber {
args += ["--demo-page", "\(pageNumber)"]
}
launchArguments = args
launch()
}
@discardableResult
func waitForReader(timeout: TimeInterval = 10) -> XCUIElement {
let backButton = buttons[IDs.readerBack]
XCTAssertTrue(backButton.waitForExistence(timeout: timeout))
return backButton
}
}
```
### 7.2 书库与打开关闭
```swift
final class ReaderOpenCloseTests: XCTestCase {
private let app = XCUIApplication()
override func setUpWithError() throws {
continueAfterFailure = false
}
func testBookListShowsSampleBooks() {
app.launch()
XCTAssertTrue(app.tables[IDs.demoBooksTable].waitForExistence(timeout: 5))
XCTAssertTrue(app.cells[IDs.demoBook(0)].waitForExistence(timeout: 5))
}
func testLaunchArgumentOpensReader() {
app.launchAndOpenSampleBook()
app.waitForReader()
XCTAssertTrue(app.staticTexts[IDs.readerTitle].exists)
}
func testBackButtonReturnsToBookList() {
app.launchAndOpenSampleBook()
app.waitForReader().tap()
XCTAssertTrue(app.tables[IDs.demoBooksTable].waitForExistence(timeout: 5))
}
}
```
### 7.3 工具栏显示
```swift
final class ReaderToolbarTests: XCTestCase {
private let app = XCUIApplication()
override func setUpWithError() throws {
continueAfterFailure = false
}
func testTapCenterShowsToolbars() {
app.launchAndOpenSampleBook()
app.waitForReader()
let content = app.otherElements[IDs.readerContent]
XCTAssertTrue(content.waitForExistence(timeout: 5))
content.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
XCTAssertTrue(app.otherElements[IDs.readerTopToolbar].waitForExistence(timeout: 3))
XCTAssertTrue(app.otherElements[IDs.readerBottomToolbar].waitForExistence(timeout: 3))
}
}
```
### 7.4 设置面板
```swift
final class SettingsPanelTests: XCTestCase {
private let app = XCUIApplication()
override func setUpWithError() throws {
continueAfterFailure = false
}
func testOpenChangeFontAndCloseSettings() {
app.launchAndOpenSampleBook()
app.waitForReader()
app.buttons[IDs.readerSettings].tap()
XCTAssertTrue(app.scrollViews[IDs.settingsScroll].waitForExistence(timeout: 5))
let fontValue = app.staticTexts[IDs.settingsFontValue]
XCTAssertTrue(fontValue.waitForExistence(timeout: 3))
let before = fontValue.label
app.buttons[IDs.settingsFontIncrease].tap()
XCTAssertNotEqual(before, fontValue.label)
app.buttons[IDs.settingsFontDecrease].tap()
app.buttons[IDs.settingsDone].tap()
XCTAssertTrue(app.buttons[IDs.readerBack].waitForExistence(timeout: 5))
}
}
```
### 7.5 翻页模式 Smoke Test
```swift
final class DisplayTypeTests: XCTestCase {
private let app = XCUIApplication()
override func setUpWithError() throws {
continueAfterFailure = false
}
func testOpenWithPageCurl() {
app.launchAndOpenSampleBook(displayType: "pagecurl")
app.waitForReader()
}
func testOpenWithHorizontalScroll() {
app.launchAndOpenSampleBook(displayType: "scroll")
app.waitForReader()
}
func testOpenWithVerticalScroll() {
app.launchAndOpenSampleBook(displayType: "vertical")
app.waitForReader()
}
}
```
## 8. P1 测试用例
P0 稳定后再加入:
- 目录打开、目录列表存在、点击第一项返回阅读器。
- 添加书签后按钮状态变化,打开书签列表存在对应 cell。
- 设置面板切换行距、栏数、主题后阅读器仍可用。
- 横滑/竖滑模式下拖动内容区域后 `demo.reader.state` 的 page 变化。
- 高亮列表打开和空态显示。
搜索和文本选择高亮不建议作为第一批 UI 自动化。它们依赖文本命中、长按选择、浮层菜单和异步渲染flaky 风险更高。可以先用单元测试覆盖搜索引擎,用 P1/P2 再补 UI 冒烟。
## 9. CI 集成
### 9.1 手动命令
```bash
xcodebuild test \
-workspace ReadViewDemo/ReadViewDemo.xcworkspace \
-scheme ReadViewDemo \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-only-testing:ReadViewDemoUITests
```
如果本机没有 `iPhone 16`,先查看可用模拟器:
```bash
xcrun simctl list devices available
```
### 9.2 GitHub Actions
```yaml
name: UI Tests
on:
pull_request:
branches: [main, develop]
push:
branches: [main, develop]
jobs:
ui-tests:
runs-on: macos-15
timeout-minutes: 40
steps:
- uses: actions/checkout@v4
- name: Install Pods
run: cd ReadViewDemo && pod install
- name: Run UI Tests
run: |
xcodebuild test \
-workspace ReadViewDemo/ReadViewDemo.xcworkspace \
-scheme ReadViewDemo \
-destination 'platform=iOS Simulator,name=iPhone 16,OS=latest' \
-only-testing:ReadViewDemoUITests \
-resultBundlePath ui-test-results.xcresult
- name: Upload xcresult
if: always()
uses: actions/upload-artifact@v4
with:
name: ui-test-results
path: ui-test-results.xcresult
```
CI 首批只跑 P0。P1 可以先本地或 nightly 跑,稳定后再进入 PR 必过。
## 10. 实施顺序
| 阶段 | 内容 | 验收标准 |
|------|------|----------|
| 1 | 补 P0 identifiers | XCUITest 能定位内容区、工具栏、设置控件 |
| 2 | 增加 `demo.reader.state` | 能通过 hidden label 读到 opened/page/display/toolbar |
| 3 | 创建 `ReadViewDemoUITests` | `xcodebuild test` 能发现测试 target |
| 4 | 实现 P0 用例 | 本地模拟器连续跑 3 次通过 |
| 5 | 接入 CI | PR 中 P0 UI Tests 可运行并上传 xcresult |
| 6 | 扩展 P1 | 目录、书签、主题、翻页断言逐步加入 |
## 11. 工时预估
| 工作 | 预估 |
|------|------|
| P0 identifiers + Demo 状态标签 | 2-3h |
| UI Test Target + helpers | 1-2h |
| P0 测试实现与稳定性调整 | 4-6h |
| CI 接入 | 1-2h |
| P1 首批扩展 | 4-8h |
首个可用版本建议按 1.5 到 2 天排期。后续每加入一组复杂交互,先本地观察稳定性,再进入 CI 必过集合。
## 12. 风险与约束
- UI 自动化不能依赖固定动画时间,优先使用 `waitForExistence` 和状态标签。
- 不要用截图像素对比作为 PR 必过项;截图附件适合作为人工排查材料。
- 样本书必须稳定存在,优先使用 `回归验证样本.txt`
- 工具栏默认可能隐藏,测试应先点击阅读区域中部再断言底部按钮。
- 设置面板、目录面板是导航/弹出结构,测试应等待面板根控件,而不是立即点击内部控件。
- 若未来 Reader UI 迁移到真实 AppDemo-only 的 `demo.reader.state` 不应进入 SDK 公共 API。

687
Doc/UML_CLASS_DIAGRAMS.md Normal file
View File

@ -0,0 +1,687 @@
# ReadViewSDK UML 类图
> 最后更新2026-06-04
> 图表格式Mermaid可在 GitHub / VS Code / Mermaid Live Editor 中渲染)
---
## 1. 整体模块关系
```mermaid
graph TB
subgraph EPUBCore
Parser[RDEPUBParser]
Pub[RDEPUBPublication]
Resolver[RDEPUBResourceResolver]
Session[RDEPUBReadingSession]
WebView[RDEPUBWebView]
Paginator[RDEPUBPaginator]
SearchEngine[RDEPUBHTMLSearchEngine]
end
subgraph EPUBTextRendering
Builder[RDEPUBTextBookBuilder]
Renderer[RDEPUBDTCoreTextRenderer]
Pipeline[RDEPUBTextTypesetterPipeline]
Counter[RDEPUBChapterPageCounter]
FrameFactory[RDEPUBCoreTextPageFrameFactory]
BreakPolicy[RDEPUBPageBreakPolicy]
BookCache[RDEPUBTextBookCache]
ChapterData[RDEPUBChapterData]
IndexTable[RDEPUBTextIndexTable]
end
subgraph RDReaderView
ReaderView[RDReaderView]
FlowLayout[RDReaderFlowLayout]
Preload[RDReaderPreloadController]
Spread[RDReaderSpreadResolver]
TapRegion[RDReaderTapRegionHandler]
end
subgraph EPUBUI
Controller[RDEPUBReaderController]
Context[RDEPUBReaderContext]
Runtime[RDEPUBReaderRuntime]
PaginationCoord[RDEPUBReaderPaginationCoordinator]
Loader[RDEPUBChapterLoader]
RuntimeStore[RDEPUBChapterRuntimeStore]
DiskCache[RDEPUBChapterSummaryDiskCache]
PageMap[RDEPUBBookPageMap]
Config[RDEPUBReaderConfiguration]
end
Parser --> Pub
Pub --> Resolver
Session --> Pub
WebView --> Pub
WebView --> Resolver
Paginator --> Parser
Builder --> Renderer
Builder --> Pipeline
Builder --> Counter
Counter --> FrameFactory
FrameFactory --> BreakPolicy
Builder --> BookCache
ChapterData --> IndexTable
Controller --> Context
Context --> Runtime
Runtime --> Loader
Runtime --> RuntimeStore
Runtime --> DiskCache
PaginationCoord --> Context
PaginationCoord --> PageMap
Loader --> RuntimeStore
Loader --> DiskCache
Controller --> ReaderView
Controller --> Session
Controller --> Builder
ReaderView --> FlowLayout
ReaderView --> Preload
ReaderView --> Spread
ReaderView --> TapRegion
```
---
## 2. EPUBCore 核心类图
```mermaid
classDiagram
class RDEPUBParser {
+metadata: RDEPUBMetadata
+manifest: [String: RDEPUBManifestItem]
+spine: [RDEPUBSpineItem]
+tableOfContents: [EPUBTableOfContentsItem]
+extractionRootURL: URL
+opfURL: URL
+opfDirectoryURL: URL
+parse(epubURL: URL) throws
+makePublication() RDEPUBPublication
+htmlString(forRelativePath:) String?
+htmlString(forSpineIndex:) String?
+coverImage() UIImage?
+resourceURL(forRelativePath:) URL
+fileURL(forRelativePath:) URL?
}
class RDEPUBPublication {
+parser: RDEPUBParser
+resourceResolver: RDEPUBResourceResolver
+metadata: RDEPUBMetadata
+manifest: [RDEPUBManifestItem]
+spine: [RDEPUBSpineItem]
+tableOfContents: [EPUBTableOfContentsItem]
+layout: RDEPUBLayout
+readingProfile: RDEPUBReadingProfile
+readingProgression: RDEPUBReadingProgression
+bookIdentifier: String?
+fixedLayoutSpreadEnabled(preferences:viewportSize:) Bool
+makeFixedSpreads(preferences:viewportSize:) [EPUBFixedSpread]
}
class RDEPUBResourceResolver {
+parser: RDEPUBParser
+fileURL(forRelativePath:) URL?
+resourceURL(forRelativePath:) URL
+normalizedHref(_:relativeToSpineIndex:) String?
+normalizedLocation(_:relativeToSpineIndex:bookIdentifier:) RDEPUBLocation?
+spineIndex(forNormalizedHref:) Int?
+spineIndex(for:) Int?
}
class RDEPUBReadingSession {
+publication: RDEPUBPublication
+navigatorState: RDEPUBNavigatorState
+activePages: [EPUBPage]
+activeChapters: [EPUBChapterInfo]
+currentViewport: RDEPUBViewport?
+currentReadingContext: RDEPUBReadingContext?
+transition(to: RDEPUBNavigatorState)
+setActiveSnapshot(_:)
+queueNavigation(to:relativeToSpineIndex:bookIdentifier:) Int
+updateReadingContext(...)
+makePaginationSnapshot(pageCounts:preferences:layoutContext:)
}
class RDEPUBWebView {
+publication: RDEPUBPublication?
+currentRenderRequest: RDEPUBRenderRequest?
+webView: WKWebView
+delegate: RDEPUBWebViewDelegate?
+load(publication:request:)
+reset()
}
class RDEPUBNavigatorState {
<<enum>>
initializing
loading
idle
jumping
moving
repaginating
+isStableForSnapshotApplication: Bool
}
RDEPUBParser --> RDEPUBPublication : creates
RDEPUBPublication --> RDEPUBResourceResolver : owns
RDEPUBReadingSession --> RDEPUBPublication : wraps
RDEPUBReadingSession --> RDEPUBNavigatorState : manages
RDEPUBWebView --> RDEPUBPublication : uses
RDEPUBWebView --> RDEPUBResourceResolver : uses
```
---
## 3. EPUBTextRendering 类图
```mermaid
classDiagram
class RDEPUBTextBookBuilder {
+renderer: RDEPUBTextRenderer
+cache: RDEPUBTextBookCache?
+layoutConfig: RDEPUBTextLayoutConfig
+renderPipeline: RDEPUBChapterRenderPipeline
+paginationPipeline: RDEPUBChapterPaginationPipeline
+tailNormalizer: RDEPUBChapterTailNormalizer
+build(parser:publication:pageSize:style:) throws RDEPUBTextBook
+buildChapter(parser:publication:spineIndex:pageSize:style:) throws RDEPUBTextChapterBuildResult?
}
class RDEPUBTextRenderer {
<<protocol>>
+renderChapter(request:) throws RDEPUBRenderedChapterContent
}
class RDEPUBDTCoreTextRenderer {
+renderChapter(request:) throws RDEPUBRenderedChapterContent
+renderChapter(html:baseURL:style:) throws RDEPUBRenderedChapterContent
}
class RDEPUBTextTypesetterPipeline {
+makeRequest(from: RDEPUBTypesettingInput) RDEPUBTypesettingOutput
}
class RDEPUBChapterPageCounter {
+factory: RDEPUBCoreTextPageFrameFactory
+layoutFrames(fragmentOffsets:) [RDEPUBTextLayoutFrame]
}
class RDEPUBCoreTextPageFrameFactory {
+attributedString: NSAttributedString
+pageSize: CGSize
+config: RDEPUBTextLayoutConfig
+makeFrames(...) [RDEPUBTextLayoutFrame]
}
class RDEPUBPageBreakPolicy {
+lineIsInAvoidPageBreakInsideBlock(_:) Bool
+lineIsInKeepWithNextBlock(_:) Bool
+adjustedRange(from:totalLength:lineRanges:factory:) RDEPUBPageBreakDecision
}
class RDEPUBTextBook {
+chapters: [RDEPUBTextChapter]
+pages: [RDEPUBTextPage]
+indexTable: RDEPUBTextIndexTable
+positionConverter: RDEPUBTextPositionConverter
+chapterData(for href:) RDEPUBChapterData?
+chapterData(forPageNumber:) RDEPUBChapterData?
}
class RDEPUBTextChapter {
+chapterIndex: Int
+spineIndex: Int
+href: String
+title: String
+attributedContent: NSAttributedString
+fragmentOffsets: [String: Int]
+pages: [RDEPUBTextPage]
}
class RDEPUBTextPage {
+absolutePageIndex: Int
+chapterIndex: Int
+spineIndex: Int
+contentRange: NSRange
+metadata: RDEPUBTextPageMetadata
}
class RDEPUBTextBookCache {
+schemaVersion: Int
+load(key:) [String: RDEPUBTextChapterPaginationCache]?
+save(_:key:)
+invalidateAll()
}
class RDEPUBChapterData {
+chapterIndex: Int
+spineIndex: Int
+href: String
+title: String
+pageCount: Int
+page(containing:) RDEPUBTextPage?
+pageNumber(containing:) Int?
+absoluteRange(for location:) ClosedRange<Int>?
}
class RDEPUBTextIndexTable {
+chapterStartOffsets: [Int]
+totalCharacterCount: Int
+anchor(forAbsoluteIndex:in:) RDEPUBTextAnchor
+pageNumber(for:in:) Int
+globalIndex(for:) Int
}
RDEPUBTextBookBuilder --> RDEPUBTextRenderer : uses
RDEPUBDTCoreTextRenderer ..|> RDEPUBTextRenderer
RDEPUBTextBookBuilder --> RDEPUBTextTypesetterPipeline : uses
RDEPUBTextBookBuilder --> RDEPUBChapterPageCounter : uses
RDEPUBChapterPageCounter --> RDEPUBCoreTextPageFrameFactory : uses
RDEPUBCoreTextPageFrameFactory --> RDEPUBPageBreakPolicy : uses
RDEPUBTextBookBuilder --> RDEPUBTextBookCache : uses
RDEPUBTextBook --> RDEPUBTextChapter : contains
RDEPUBTextChapter --> RDEPUBTextPage : contains
RDEPUBTextBook --> RDEPUBTextIndexTable : computes
RDEPUBChapterData --> RDEPUBTextChapter : wraps
RDEPUBChapterData --> RDEPUBTextIndexTable : uses
```
---
## 4. ReaderView 类图
```mermaid
classDiagram
class RDReaderView {
+currentPage: Int
+currentDisplayType: DisplayType
+pageProvider: RDReaderPageProvider?
+delegate: RDReaderDelegate?
+landscapeDualPageEnabled: Bool
+pageDirection: PageDirection
+coverPageIndex: Int?
+pagesPerScreen: Int
+transitionToPage(pageNum:animated:)
+reloadData()
+switchReaderDisplayType(_:)
}
class DisplayType {
<<enum>>
pageCurl
horizontalScroll
verticalScroll
}
class RDReaderPageProvider {
<<protocol>>
+numberOfPages(in:) Int
+readerView(_:viewForPageAt:reusableView:) UIView
+pageIdentifier(in:index:) String?
}
class RDReaderFlowLayout {
+displayType: DisplayType
+isLandscapeDualPage: Bool
+coverPageIndex: Int?
+pagesPerScreen: Int
+currentPage: Int
}
class RDReaderPreloadController {
+radius: Int
+pageViewForDisplay(pageNum:environment:contentViewProvider:) UIView?
+takePreloadedView(for:) UIView?
+prime(around:preferredForward:parentView:environment:contentViewProvider:)
+invalidate(environment:)
}
class RDReaderSpreadResolver {
+isFullScreenPage(...) Bool
+dualPagePair(for:totalPages:coverPageIndex:) (Int, Int?)
+nextPage(from:totalPages:pagesPerScreen:coverPageIndex:forward:) Int?
}
class RDReaderTapRegionHandler {
+resolveTapEvent(point:viewFrame:isToolViewVisible:) TapEvent
}
class TapEvent {
<<enum>>
none
left
center
right
}
class RDReaderPagingController {
+isTransitioning: Bool
+didBuildUI: Bool
+pendingTransitionRequest: PageTransitionRequest?
+shouldQueuePageTransition(_:currentDisplayType:) Bool
+finishPageCurlTransition() PageTransitionRequest?
}
class RDReaderContentCell {
+containerView: UIView?
}
class RDReaderPageChildViewController {
+contentView: UIView?
+pageNum: Int
}
RDReaderView --> DisplayType : uses
RDReaderView --> RDReaderPageProvider : delegates
RDReaderView --> RDReaderFlowLayout : owns
RDReaderView --> RDReaderPreloadController : owns
RDReaderView --> RDReaderSpreadResolver : owns
RDReaderView --> RDReaderTapRegionHandler : owns
RDReaderView --> RDReaderPagingController : owns
RDReaderView --> RDReaderContentCell : creates
RDReaderView --> RDReaderPageChildViewController : creates
RDReaderTapRegionHandler --> TapEvent : produces
```
---
## 5. EPUBUI 控制器与运行时类图
```mermaid
classDiagram
class RDEPUBReaderController {
+delegate: RDEPUBReaderDelegate?
+configuration: RDEPUBReaderConfiguration
+currentLocation: RDEPUBLocation?
+currentPageNumber: Int?
+currentSelection: RDEPUBSelection?
+highlights: [RDEPUBHighlight]
+bookmarks: [RDEPUBBookmark]
+tableOfContents: [EPUBTableOfContentsItem]
+readerView: RDReaderView
+readerContext: RDEPUBReaderContext
+runtime: RDEPUBReaderRuntime?
}
class RDEPUBReaderContext {
+parser: RDEPUBParser?
+publication: RDEPUBPublication?
+readingSession: RDEPUBReadingSession?
+textBook: RDEPUBTextBook?
+bookPageMap: RDEPUBBookPageMap?
+configuration: RDEPUBReaderConfiguration
+persistence: RDEPUBReaderPersistence?
+currentRenderSignature() String
+chapterCacheKey(forSpineIndex:) RDEPUBChapterCacheKey
+chapterCacheKey(forSpineIndex:precomputedContentHash:renderSignature:) RDEPUBChapterCacheKey
+makeTextBookBuilder(layoutConfig:) RDEPUBTextBookBuilder
}
class RDEPUBReaderRuntime {
+chapterLoader: RDEPUBChapterLoader
+chapterRuntimeStore: RDEPUBChapterRuntimeStore
+summaryDiskCache: RDEPUBChapterSummaryDiskCache
+viewportMonitor: RDEPUBViewportMonitor
+applyBookPageMap(_:restoreLocation:)
+refreshBookPageMapInPlace(_:)
}
class RDEPUBReaderPaginationCoordinator {
-context: RDEPUBReaderContext
+paginatePublication(restoreLocation:)
+paginateMetadataOnly(token:restoreLocation:)
+repaginatePreservingCurrentLocation()
-restoreBookPageMapIfPossible(publication:) RDEPUBBookPageMap?
-buildPageMap(from:summaries:) RDEPUBBookPageMap
}
class RDEPUBChapterLoader {
-context: RDEPUBReaderContext
+loadChapter(spineIndex:store:) async throws RDEPUBRuntimeChapter
+loadChapterSynchronouslyForMigration(spineIndex:store:) throws RDEPUBRuntimeChapter
}
class RDEPUBChapterRuntimeStore {
+chapterDataCache: [Int: RDEPUBRuntimeChapter]
+pageCountCache: RDEPUBPageCountCache
+imageCache: NSCache
+setCurrentChapter(spineIndex:totalSpineCount:windowRadius:)
+chapterData(for:) RDEPUBRuntimeChapter?
+pageCount(for:) RDEPUBRuntimePageCount?
}
class RDEPUBChapterSummaryDiskCache {
+cacheDirectory: URL
+read(for:) RDEPUBChapterSummary?
+readAll(keys:) ([Int: RDEPUBChapterSummary], RDEPUBBookPageMap.Builder)
+write(summary:for:)
+flushPendingWrites()
+isCacheComplete(keys:) Bool
}
class RDEPUBBookPageMap {
+totalChapters: Int
+totalPages: Int
+chapters: [EPUBChapterInfo]
+pages: [EPUBPage]
}
class RDEPUBChapterCacheKey {
+bookID: String
+spineIndex: Int
+renderSignature: String
+chapterContentHash: String
}
class RDEPUBReaderConfiguration {
+fontSize: CGFloat
+fontChoice: FontChoice
+lineHeightMultiple: CGFloat
+numberOfColumns: Int
+theme: ReaderTheme
+displayType: DisplayType
+onDemandChapterWindowSize: Int
+metadataParsingConcurrency: Int
}
RDEPUBReaderController --> RDEPUBReaderContext : owns
RDEPUBReaderController --> RDEPUBReaderRuntime : owns
RDEPUBReaderController --> RDEPUBReaderPaginationCoordinator : owns
RDEPUBReaderContext --> RDEPUBReaderConfiguration : holds
RDEPUBReaderRuntime --> RDEPUBChapterLoader : owns
RDEPUBReaderRuntime --> RDEPUBChapterRuntimeStore : owns
RDEPUBReaderRuntime --> RDEPUBChapterSummaryDiskCache : owns
RDEPUBReaderPaginationCoordinator --> RDEPUBReaderContext : uses
RDEPUBChapterLoader --> RDEPUBReaderContext : uses
RDEPUBChapterLoader --> RDEPUBChapterRuntimeStore : uses
RDEPUBChapterLoader --> RDEPUBChapterSummaryDiskCache : uses
RDEPUBChapterSummaryDiskCache --> RDEPUBChapterCacheKey : uses
RDEPUBBookPageMap --> EPUBChapterInfo : contains
RDEPUBBookPageMap --> EPUBPage : contains
```
---
## 6. 数据模型关系图
```mermaid
classDiagram
class RDEPUBLocation {
+bookIdentifier: String?
+href: String
+progression: Double
+lastProgression: Double?
+fragment: String?
+rangeAnchor: RDEPUBTextRangeAnchor?
}
class RDEPUBTextAnchor {
+fileIndex: Int
+row: Int
+column: Int
+chapterOffset: Int
+fragmentID: String?
+spineIndex: Int
}
class RDEPUBTextRangeAnchor {
+start: RDEPUBTextAnchor
+end: RDEPUBTextAnchor
+nsRange: NSRange
}
class RDEPUBSelection {
+bookIdentifier: String?
+location: RDEPUBLocation
+text: String
+rangeInfo: String?
+createdAt: Date
}
class RDEPUBHighlight {
+id: String
+bookIdentifier: String?
+location: RDEPUBLocation
+text: String
+style: RDEPUBHighlightStyle
+color: String
+note: String?
+createdAt: Date
+uiColor: UIColor
}
class RDEPUBBookmark {
+id: String
+bookIdentifier: String?
+location: RDEPUBLocation
+chapterTitle: String?
+note: String?
+createdAt: Date
}
class RDEPUBAnnotation {
+id: String
+kind: RDEPUBAnnotationKind
+location: RDEPUBLocation
+text: String?
+bookmark: RDEPUBBookmark?
+highlight: RDEPUBHighlight?
}
class EPUBPage {
+spineIndex: Int
+chapterIndex: Int
+pageIndexInChapter: Int
+totalPagesInChapter: Int
+chapterTitle: String
+fixedSpread: EPUBFixedSpread?
}
class EPUBChapterInfo {
+spineIndex: Int
+title: String
+pageCount: Int
}
class RDEPUBViewport {
+resources: [RDEPUBViewportResource]
+visiblePageNumber: Int
+chapterIndex: Int?
+isFixedLayout: Bool
}
class RDEPUBReadingContext {
+location: RDEPUBLocation
+viewport: RDEPUBViewport
+pageNumber: Int
+chapterIndex: Int?
}
RDEPUBLocation --> RDEPUBTextRangeAnchor : optional
RDEPUBTextRangeAnchor --> RDEPUBTextAnchor : start/end
RDEPUBSelection --> RDEPUBLocation : references
RDEPUBHighlight --> RDEPUBLocation : references
RDEPUBBookmark --> RDEPUBLocation : references
RDEPUBAnnotation --> RDEPUBLocation : references
RDEPUBReadingContext --> RDEPUBLocation : holds
RDEPUBReadingContext --> RDEPUBViewport : holds
```
---
## 7. 后台解析并发模型
```mermaid
sequenceDiagram
participant Main as Main Thread
participant BG as Background Queue
participant OQ as OperationQueue (N workers)
participant Disk as Disk Cache
Main->>BG: paginateMetadataOnly(token)
BG->>BG: 预计算 contentHash (串行)
BG->>Disk: readAll(catalog) 批量读取缓存
BG->>Main: refreshBookPageMapInPlace (缓存部分)
BG->>BG: waitForReadingInteractionToSettle (0.8s)
loop 每个未缓存章节
BG->>OQ: addOperation(spineIndex)
OQ->>OQ: buildChapter (渲染+分页)
OQ->>OQ: chapterCacheKey (复用预计算hash)
OQ->>Disk: write(summary) 异步写盘
OQ->>OQ: resultLock: 累加结果
alt 每32章或最后一章
OQ->>Main: refreshBookPageMapInPlace
end
end
OQ->>BG: waitUntilAllOperationsAreFinished
BG->>Disk: flushPendingWrites
BG->>Main: refreshBookPageMapInPlace (最终)
```
---
## 8. 缓存键与失效策略
```mermaid
graph LR
subgraph CacheKey["RDEPUBChapterCacheKey"]
A[bookID]
B[spineIndex]
C[renderSignature]
D[chapterContentHash]
end
subgraph RenderSignature["renderSignature 组成"]
E[fontName]
F[fontSize]
G[lineHeightMultiple]
H[lineSpacing]
I[layoutConfig.cacheSignature]
J[schemaVersion]
end
subgraph CacheSignature["layoutConfig.cacheSignature"]
K[frameWidth]
L[frameHeight]
M[edgeInsets]
N[numberOfColumns]
O[columnGap]
P[avoidOrphans/Widows]
Q[hyphenation]
R[imageMaxHeightRatio]
end
C --> E & F & G & H & I & J
I --> K & L & M & N & O & P & Q & R
style CacheKey fill:#e1f5fe
style RenderSignature fill:#fff3e0
style CacheSignature fill:#e8f5e9
```

View File

@ -1,37 +1,27 @@
# ReadViewSDK 文档索引
## 架构与规范
> 最后更新2026-06-04
---
## 核心文档
| 文档 | 说明 |
|------|------|
| [ARCHITECTURE.md](ARCHITECTURE.md) | 四层架构总览EPUBCore → EPUBTextRendering → RDReaderView → EPUBUI、数据流、分页模式、位置模型、已知限制 |
| [CODING_STYLE.md](CODING_STYLE.md) | 命名规范RD/RDEPUB 前缀、分层规则、UI 布局规范、代码风格细则、extension 拆分、SS→RD 迁移计划 |
| [EPUB_MAINTENANCE.md](EPUB_MAINTENANCE.md) | 文件职责表、DTCoreText 渲染管线、常见排查场景、推荐日志断点、阅读器后续能力规划 |
| [ARCHITECTURE.md](ARCHITECTURE.md) | 系统架构总览:分层架构、核心数据流、缓存架构、目录结构、设计模式 |
| [UML_CLASS_DIAGRAMS.md](UML_CLASS_DIAGRAMS.md) | UML 类图Mermaid 格式):模块关系、核心类图、并发模型、缓存键设计 |
| [BUSINESS_LOGIC.md](BUSINESS_LOGIC.md) | 业务逻辑详解EPUB 解析、文本渲染管线、后台解析优化、翻页容器、标注系统、搜索、设置 |
## 模块功能实现逻辑
| 文档 | 对应模块 | 说明 |
|------|----------|------|
| [EPUBCore_功能实现逻辑.md](EPUBCore_功能实现逻辑.md) | `Sources/RDReaderView/EPUBCore/`31 Swift + 2 资源) | EPUB 解析全流程ZIP→container.xml→OPF→spine/TOC、阅读会话状态机、离屏分页测量、`ss-reader://` 资源协议、JS 桥接6 种消息、WebView 渲染管线、文本锚点定位、渲染请求模型、缓存策略 |
| [EPUBTextRendering_功能实现逻辑.md](EPUBTextRendering_功能实现逻辑.md) | `Sources/RDReaderView/EPUBTextRendering/`13 文件) | DTCoreText HTML→NSAttributedString 渲染管线、片段标记注入/提取、CoreText 分页引擎含语义边界调整、文本索引表、Location↔PageNumber 双向转换、分页缓存、性能采样、全文搜索引擎、纯文本构建器 |
| [RDReaderView_功能实现逻辑.md](RDReaderView_功能实现逻辑.md) | `Sources/RDReaderView/ReaderView/`5 文件) | 三种显示模式pageCurl/horizontalScroll/verticalScroll、DataSource/Delegate 协议、点击三区域翻页、工具栏动画、双页配对与哨兵页、横竖屏适配、RTL 支持 |
| [EPUBUI_功能实现逻辑.md](EPUBUI_功能实现逻辑.md) | `Sources/RDReaderView/EPUBUI/`19 文件) | RDEPUBReaderController 全生命周期、三条渲染路径分发、配置变更检测与响应、工具栏/目录/高亮/书签/搜索/设置面板交互流、阅读位置持久化、主题管理、CoreText 页面交互 |
## 方案讨论与规划文档
## 专题文档
| 文档 | 说明 |
|------|------|
| [阅读器功能开发计划.md](阅读器功能开发计划.md) | 渲染质量三方对比ReadViewSDK vs WXRead、功能开发计划与落地状态、优先级路线图 |
| [架构对比分析_WXRead_vs_ReadViewSDK.md](架构对比分析_WXRead_vs_ReadViewSDK.md) | ReadViewSDK 与 WXRead 的逐项架构核查,确认主链路复刻完成度 |
| [ReflowableEPUB_WXReadRenderer_Design.md](FeatureSolution/ReflowableEPUB_WXReadRenderer_Design.md) | 基于读书的 CoreText 渲染架构,设计 Reflowable EPUB 的增强文本渲染方案CSS 分层、类型器升级) |
| [大书后台解析优化实施清单_30秒目标.md](大书后台解析优化实施清单_30秒目标.md) | 《凡人修仙传》后台解析性能优化方案,目标从 70s 压缩到 30-50s |
## 测试与质量
## 项目信息
| 文档 | 说明 |
|------|------|
| [UI自动化测试工作文档.md](UI自动化测试工作文档.md) | XCUITest UI 自动化测试完整工作计划现状分析、accessibilityIdentifier 补充清单、UI Test Target 创建、测试用例设计(书库/阅读器/工具栏/设置/书签/高亮/目录/截图、CI 集成方案、实施检查清单 |
## 目录约定
- `Doc/`:项目级文档(架构、规范、维护指南、模块实现逻辑)
- `Doc/FeatureSolution/`:方案讨论文档(由 `discuss-sdk-feature-solution` skill 生成)
- **模块总数:** 4 个EPUBCore、EPUBTextRendering、RDReaderView、EPUBUI
- **Swift 文件数:** 138 个
- **测试用例数:** 18 个测试类66 个测试方法
- **最低 iOS 版本:** 15.6
- **构建方式:** CocoaPods本地 pod

View File

@ -1,379 +0,0 @@
# 双层 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

View File

@ -1,438 +0,0 @@
# 《凡人修仙传》快速进入阅读器方案
> 适用场景:`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`

View File

@ -1,290 +0,0 @@
# 架构对比分析:读书 vs ReadViewSDK
> 基于读书 v10.0.3 (Build 79) 逆向文档,与当前 ReadViewSDK 代码在 2026-06-02 的核查结果整合。
> 本文档已吸收原 [WXRead剩余问题修复计划.md](/Users/shen/Work/Code/ReadViewSDK/Doc/WXRead剩余问题修复计划.md) 的阶段方案,后续以本文档作为单一真值。
> 标注说明:
> - `✅ 已实现`:当前代码已经按接近 WXRead 的路径落地
> - `⚠️ 有差异/有问题`:已经实现一部分,但仍与 WXRead 有结构差异,或仍有已知问题
> - `❌ 未实现`:当前代码中仍缺失
> - `❌ 明确不实现`:经确认不纳入当前 EPUB 阅读器复刻范围
---
## 核心结论
ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链路已经收口到:
- CoreText 页面直绘
- `RDEPUBTextLayouter` 分页
- 5 层 CSS 级联
- `<link>` 样式表内联
- 页面级 hit test / 选区 / 高亮 / 批注
- 字符锚点与 `fileIndex/row/column` 语义的完整位置模型
核查结论ReadViewSDK 已经完成 EPUB 阅读器主链路的大部分复刻,但当前代码里仍有少量“能力面已接入、实现路径未完全等价”的差异。和 2026-05-24 版本文档相比,当前最需要重新标注的是:
1. 文本分页主路径现已真正消费多栏 path并补上 `avoidOrphans` / `avoidWidows` / `hyphenation` 行为
2. 代码仍保留若干 fallback / degrade 路径,与 WXRead 的单一主链路实现方式不同
3. `replaceForMPChapter.css`、繁简转换、TTS / DRM / Pencil 仍属于不适用或明确不实现范围
---
## 核查摘要
### ✅ 已实现
- 文本页已经改成页面级 CoreText 直绘。
- `RDEPUBTextLayouter` 已接入 `RDEPUBTextBookBuilder` / `RDPlainTextBookBuilder`
- 4 级语义断页、`avoidPageBreakInside` 页尾回退、分页缓存都已经落地。
- 5 层 CSS 级联和 EPUB `<link>` 外部样式表内联都已经落地。
- CoreText 页面 hit test、长按选区、菜单锚点、复制/高亮/批注主链路已经接入页面几何。
- CoreText 路径的高亮、注释、搜索命中已经走页面装饰层,而不是继续改正文布局真值。
- WebView 路径的标注绘制、搜索高亮已经改成“JS 产出 rect原生 overlay 负责 CGContext 绘制”,不再依赖 DOM 包裹着色。
- 分页器已经补上 inline footnote attachment 不整段挪页、标题 `keepWithNext`、`weread-page-relate` 页首借行这几类 WXRead 风格规则。
- 章节尾部“仅空白/段落分隔符”的尾页丢弃,以及极短尾页回并已经落地,`宝山辽墓材料与释读` 第 31 页空白问题已修复。
- `RDEPUBTextLayoutConfig` 已补齐到 WXRead 同级配置面:`frameWidth/frameHeight/edgeInsets/numberOfColumns/columnGap/avoidOrphans/avoidWidows/hyphenation`,并已接入分页入口与缓存键。
- CoreText 分页路径现已消费多栏 layout path不再把 `numberOfColumns` 只停留在配置层。
- `avoidOrphans`、`avoidWidows`、`hyphenation` 现已接入实际排版/分页行为,而不是仅存在于配置模型。
- `RDEPUBChapterData` 已统一承载章节分页结果、位置查询、搜索/高亮回查与目录语义,章节模型主链路已经收口。
### ⚠️ 有差异/有问题
- 仍保留降级链路:`RDEPUBDTCoreTextRenderer` 在 `DTCoreText` 不可用或构建失败时会退回 HTML 纯文本渲染;`RDURLReaderController` 在纯文本分页失败时会退回 `UITextView` 展示。这说明 ReadViewSDK 仍不是 WXRead 那种完全单一路径的生产态实现。
- `RDReaderGestureController` 仍是未接入的占位组件,实际点击分区逻辑直接落在 `RDReaderView`,与 WXRead 更完整的翻页/手势控制器拆分相比,宿主层职责仍稍偏重。
### ❌ 未实现
- 繁简转换(明确不实现)
- TTS / DRM / Pencil明确不实现
---
## 1. 渲染路径对比
| 维度 | 读书 | ReadViewSDK | 核查 |
|------|---------|-------------|------|
| EPUB 文本渲染 | `WRPageView.drawRect:``CTFrameDraw` 到 CGContext | `RDEPUBTextContentView``RDEPUBDirectCoreTextPageView.draw(_:)` + `DTCoreTextLayoutFrame.draw(in:)` 页面级直绘 | ✅ 已实现 |
| WebView 路径 | WKWebView公众号/文集文章) | WKWebViewinteractive/fixed-layout EPUB | ✅ 已实现 |
| 文本选区 | `CTLineGetStringIndexForPosition` 坐标级 hit test | `RDEPUBPageInteractionController` + `RDEPUBSelectionOverlayView` 已接入主链路 | ✅ 已实现 |
| 标注绘制 | `CTFrame` 层叠加绘制CGContext | CoreText / WebView 两条链路都已收口到原生页面装饰层绘制 | ✅ 已实现 |
| 搜索高亮 | `WRCoreTextLayoutFrame.highlightSearchResults:` 直接绘制 | CoreText / WebView 两条链路都已统一为原生页面装饰对象绘制 | ✅ 已实现 |
**结论:** 渲染层里“页面级绘制 + 页面装饰对象收口”这一块已经完成,页面几何回查也已经补到与 WXRead 同一路径的页面快照闭环。
---
## 2. 分页引擎对比
| 维度 | 读书 | ReadViewSDK | 核查 |
|------|---------|-------------|------|
| 分页核心 | `WRCoreTextLayouter` + `WRCoreTextLayoutFrame` | `RDEPUBTextLayouter` + `RDEPUBTextLayoutFrame` | ✅ 已实现 |
| 断页策略 | 4 级语义断页 | 已启用 4 级语义断页 | ✅ 已实现 |
| `avoidPageBreakInside` | 页尾最多回退若干行避免破碎 | 已实现页尾最多回退 3 行 | ✅ 已实现 |
| inline footnote | 行内脚注不应触发整段挪页 | 已修正为仅块级 attachment 参与整块挪页 | ✅ 已实现 |
| 标题 keep-with-next | 标题不能孤悬页尾 | 已补 `keepWithNext` 语义与页尾回退 | ✅ 已实现 |
| `weread-page-relate` | 页首关联块需要借上一页一行 | 已补页首 `pageRelate` 借行规则 | ✅ 已实现 |
| 章节尾页收口 | 丢弃空白尾页、合并极短尾页 | 已补尾页空白丢弃与超短尾页回并 | ✅ 已实现 |
| 分页配置 | `WRCoreTextLayoutConfig`(含多栏等) | `RDEPUBTextLayoutConfig` 已补齐同级参数,并已接入多栏 path、孤行/寡行保护与 hyphenation 行为 | ✅ 已实现 |
| 缓存 | 按书籍 + 排版设置缓存 | 已有 `RDEPUBTextBookCache` 磁盘缓存 | ✅ 已实现 |
**结论:** 分页主链路已经按 WXRead 的主要思路收口;当前剩余差异不再集中在分页配置或分页算法本身,而更多落在 fallback 路径和宿主层实现细节。
---
## 3. CSS 处理对比
| 维度 | 读书 | ReadViewSDK | 核查 |
|------|---------|-------------|------|
| CSS 级联层级 | 5 级:`default < replace < dark < epub < user` | `RDEPUBTextStyleSheetPackage` 已实际生成并注入 5 | 已实现 |
| EPUB `<link>` CSS | 解析前级联合并外部样式 | 已实现抽取、内联、URL 重写与诊断 | ✅ 已实现 |
| 自定义 CSS 属性 | `wr-vertical-center-style`、`weread-page-relate`、断页相关私有属性 | 已按 WXRead 语义接入 `wr-vertical-center-style`、`weread-page-relate`、`page-break-* / break-*`、`avoidPageBreakInside` 与 attachment placement | ✅ 已实现 |
| WXRead 私有 CSS 文件 | 使用 WeRead 自带 `default/replace/dark/...` | 已把 `default/replace/dark` 作为 SDK 内置资源镜像注入 5 层级联 | ✅ 已实现 |
| `replaceForLatinLanguageBook.css` | 拉丁语言专项字体规则 | 已按章节语言 / 正文拉丁字符占比自动切换 Latin replace 层 | ✅ 已实现 |
**结论:** CSS 主机制、私有资源层和拉丁语言分支都已经按 WXRead 路径收口;这一节当前不再是剩余差距。
---
## 4. 位置模型对比
| 维度 | 读书 | ReadViewSDK | 核查 |
|------|---------|-------------|------|
| 主模型 | `WREpubPositionConverter` `(fileIndex, row, column)` | `RDEPUBTextPositionConverter` + `RDEPUBTextAnchor/rangeAnchor`,已补齐全书字符位置与页码双向转换 | ✅ 已实现 |
| 恢复精度 | 字符级双向恢复 | 当前位置、选区、高亮、搜索命中都已优先走 `rangeAnchor` / `fileIndex-row-column` 恢复,`progression` 仅作回退 | ✅ 已实现 |
| 旧数据兼容 | 兼容历史位置模型 | 已兼容旧 `spineIndex` 等旧字段解码 | ✅ 已实现 |
| 全量双向转换 | 文件位置 <-> 全书字符位置 <-> 页码 | `RDEPUBTextPositionConverter` / `RDEPUBTextIndexTable` 已补齐 `fileIndex,row,column <-> chapterOffset <-> globalOffset <-> pageNumber <-> location` | ✅ 已实现 |
**结论:** 位置模型这条链路已经按 WXRead 的 `PositionConverter` 思路收口,当前不再是位置恢复能力缺失的问题。
---
## 5. 章节数据模型对比
| 维度 | 读书 | ReadViewSDK | 核查 |
|------|---------|-------------|------|
| 核心模型 | `WRChapterData` 一体化承载渲染结果 + 标注 + 搜索 + 目录 | `RDEPUBChapterData` 现已统一承载章节分页结果、位置查询、搜索/高亮回查与目录语义 | ✅ 已实现 |
| 页内高亮查询 | 章节对象直接提供 | `RDEPUBChapterData.highlights(on:from:)` 已提供 | ✅ 已实现 |
| 页内搜索查询 | 章节对象直接提供 | `RDEPUBChapterData.searchResults(on:from:)` 已提供 | ✅ 已实现 |
| 页码/位置查询 | 章节对象内聚 | `RDEPUBChapterData` 已统一提供 chapter/page/location/searchMatch 双向回查 | ✅ 已实现 |
| 目录与章节语义 | 基于章节数据统一管理 | 已由 `RDEPUBChapterData` 统一提供 TOC 命中、主目录项解析与章节页码定位 | ✅ 已实现 |
**结论:** 章节对象这条链路已经按 WXRead 的 `WRChapterData` 思路收口,章节分页、位置、搜索、高亮与目录语义现在都围绕 `RDEPUBChapterData` 统一组织。
---
## 6. 翻页控制器与宿主层对比
| 维度 | 读书 | ReadViewSDK | 核查 |
|------|---------|-------------|------|
| 翻页动画 | curl / slide / fade / none | 已有多种翻页模式 | ✅ 已实现 |
| `UIPageViewController` crash patch | 3 个以上专项 patch | 已补 pageCurl 异常检测、转场期间跳转排队、动画后校验与异步重建恢复 | ✅ 已实现 |
| 预加载 | `WRForecastUtils` 预测 + 后台准备 | 已围绕当前页、当前 spread 和可见方向预热相邻页视图,并在布局环境变化时裁剪缓存 | ✅ 已实现 |
| 预测翻页 | 基于用户方向预测预建内容 | 已按手势/滚动方向和 pending target 预测下一屏,优先向预测方向额外预热一屏 | ✅ 已实现 |
| 显示切换一致性 | 翻页模式/单双页/方向切换稳定恢复 | 已补切换前位置快照、切换后按 location 恢复,以及横竖屏过渡期间的延迟恢复链路 | ✅ 已实现 |
**结论:** 翻页控制器与宿主层这条链路已经按 WXRead 的主实现思路收口pageCurl 稳定性、预测预加载和显示切换恢复现在都走统一闭环。
---
## 7. 资源体系对比
### CSS
| 文件 | 读书职责 | ReadViewSDK 状态 | 核查 |
|------|-------------|-----------------|------|
| `default.css` | 基础 HTML 标签样式 | 已以内置资源镜像接入 | ✅ 已实现 |
| `replace.css` | 标题、图片、引用、分页控制 | 已以内置资源镜像接入 | ✅ 已实现 |
| `dark.css` | 暗色主题 | 已以内置资源镜像接入,并保留主题色覆盖层 | ✅ 已实现 |
| `replaceForLatinLanguageBook.css` | 拉丁语言字体 | 已接入章节语言自动切换 | ✅ 已实现 |
| `replaceForMPChapter.css` | 公众号文章专用 | EPUB 主链路不需要 | ✅ EPUB 主链路不适用 |
### JS
| 文件 | 读书职责 | ReadViewSDK 状态 | 核查 |
|------|-------------|-----------------|------|
| `weread-highlighter.js` | Web 高亮引擎 | `epub-bridge.js` 现只负责 rect 解析与桥接,最终由原生 overlay 绘制 | ✅ 已实现 |
| `rangy-*` | 选区/高亮库 | CoreText 路径不需要;`webInteractive` / `webFixedLayout` 已接入 `rangy-core.js` + `rangy-serializer.js` 等价层,统一负责 DOM range 序列化与 iframe 文档回放 | ✅ 已实现 |
| `cssInjector.js` | 动态 CSS 注入 | 已接入 `cssInjector.js`,由 `WeReadApi``webInteractive` / `webFixedLayout` 路径按需向主文档和 iframe 文档注入主题/分页样式 | ✅ 已实现 |
| `WeReadApi.js` | JS-Native 桥接 | 已接入 `WeReadApi.js`,并由 `window.WeReadApi` 统一封装分页、主题、搜索、高亮和 progression bridge | ✅ 已实现 |
**结论:** CoreText 路径不需要 `rangy` / `cssInjector` / `WeReadApi`;但 `webInteractive``webFixedLayout` 两条 Web 路径都需要。当前资源体系已经把这套 Web 资源层完整接回 SDKJS 侧职责也已按 WXRead 思路收口。
---
## 8. 缺失能力清单
| 能力 | 核查 | 当前优先级 |
|------|------|-----------|
| 页面几何模型完全等价 `WRCoreTextLayoutFrame` | ✅ 已实现 | - |
| `WRBookmark` 统一标注模型 | ✅ 已实现 | - |
| 多栏排版 | ✅ 已实现 | - |
| `avoidOrphans / avoidWidows / hyphenation` 行为落地 | ✅ 已实现 | - |
| 纯文本/渲染失败 fallback 清理 | ⚠️ 仍保留纯文本 fallback 路径 | 低 |
| 字体动态加载 | ✅ 已实现 | - |
| 繁简转换 | ❌ 明确不实现 | - |
| TTS / DRM / Pencil | ❌ 明确不实现 | - |
---
## 9. 收口路线(整合原修复计划)
### P1页面几何与分页行为闭环
- `RDEPUBPageLayoutSnapshot` 已补齐 line/run/attachment/contentBounds 与 absoluteRange 命中闭环
- overlay / decoration / selection 回查已经统一到页面快照层
- 问题书分页细节已回收到现有分页规则,不再构成架构缺口
当前状态:`✅ 已实现`
### P2位置模型与章节数据内聚
- `rangeAnchor` / `fileIndex/row/column` 双向位置转换已完成
- `RDEPUBChapterData` 章节语义内聚已完成
- `RDEPUBAnnotation` 已把书签/高亮统一收口到单一标注语义层
当前状态:`✅ 已实现`
### P3宿主层稳定性
- 宿主层 pageCurl 修复、预测预加载和显示切换恢复已完成
- 剩余工作不再是主链路缺失,而是问题书和极端交互场景的持续回归
当前状态:`✅ 已实现`
### P4外围能力
- 多栏阅读器开关 / UI 暴露已完成
- EPUB 内嵌字体动态注册已完成
- 繁简转换、TTS / DRM / Pencil 明确不实现,不纳入收口范围
当前状态:`✅ 范围内已实现`
---
## 补充说明本次复核新增结论2026-06-02
和上一篇版本相比,当前最重要的修正不是“新增缺很多能力”,而是把原先写得过满的结论收回来:
- `RDEPUBTextLayoutConfig` 这一轮已经从“配置面补齐”推进到“行为面落地”,`avoidOrphans`、`avoidWidows`、`hyphenation` 不再只是模型字段。
- 多栏能力这一轮已经接到文本 `CoreText` 分页主路径,`numberOfColumns` 不再只影响 Web/CSS 与设置入口。
- 文本阅读主链路虽然已经不再依赖旧 `UITextView`,但仓库中仍保留 `DTCoreText` 不可用时的纯文本 fallback以及纯文本分页失败时的 `UITextView` fallback应视作与 WXRead 的实现差异,而不是主链路能力。
- 公众号文章专用 `replaceForMPChapter.css` 仍不属于当前 EPUB 主链路缺口,应继续按“不适用”记录,而不是“待补齐”。
---
## 10. 读书关键类职责速查
| 类名 | 职责 | ReadViewSDK 对应 | 核查 |
|------|------|-----------------|------|
| `WRReaderViewController` | 阅读器主控制器 | `RDEPUBReaderController` | ✅ 已有对应 |
| `WRPageViewController` | 翻页控制器 + crash patch | `RDReaderView` | ✅ 已实现 |
| `WRPageView` | CoreText 直接绘制页面 | `RDEPUBTextContentView` + `RDEPUBDirectCoreTextPageView` | ✅ 已实现 |
| `WREpubTypesetter` | HTML → NSAttributedStringCSS 级联) | `RDEPUBDTCoreTextRenderer` | ✅ 已实现 |
| `WRCoreTextLayouter` | CoreText 排版引擎 | `RDEPUBTextLayouter` | ✅ 已实现 |
| `WRCoreTextLayoutFrame` | 排版帧(绘制/选区/搜索/装饰) | `RDEPUBTextLayoutFrame` + snapshot/interaction/overlay | ✅ 已实现 |
| `WRChapterData` | 章节数据模型 | `RDEPUBChapterData` | ✅ 已实现 |
| `WRChapterPageCount` | 分页计算 + 缓存 key | `RDEPUBTextBookBuilder` + `RDEPUBTextBookCache` | ✅ 已实现 |
| `WREpubPositionConverter` | 字符级位置双向转换 | `RDEPUBTextPositionConverter` + `RDEPUBTextIndexTable` | ✅ 已实现 |
| `WRBookmark` | 标注统一模型 | `RDEPUBAnnotation`(兼容 `RDEPUBHighlight` / `RDEPUBBookmark` | ✅ 已实现 |
| `DTHTMLAttributedStringBuilder` | HTML DOM → NSAttributedString | 同(复用 DTCoreText | ✅ 已实现 |
---
## 11. 当前数据流
```text
EPUB 文件
-> RDEPUBParser.parse (container.xml -> OPF -> spine)
-> readingProfile 分流:
textReflowable:
-> RDEPUBDTCoreTextRenderer
-> 5 层 CSS 级联
-> <link> CSS 内联
-> 自定义分页语义注入
-> RDEPUBTextBookBuilder
-> RDEPUBTextLayouter
-> RDEPUBTextBookCache
-> RDEPUBTextContentView
-> RDEPUBDirectCoreTextPageView
-> RDEPUBPageInteractionController
-> RDEPUBSelectionOverlayView
webInteractive:
-> RDEPUBPaginator
-> RDEPUBWebContentView
-> rangy-core.js / rangy-serializer.js
-> cssInjector.js
-> WeReadApi.js
-> epub-bridge.js
webFixedLayout:
-> RDEPUBWebContentView
-> rangy-core.js / rangy-serializer.js
-> cssInjector.js
-> WeReadApi.js
-> epub-bridge.js
```
---
*分析基础:读书 v10.0.3 (Build 79) 逆向文档*
*当前代码核查日期2026-05-24*

View File

@ -1,617 +0,0 @@
# 阅读器功能开发计划
> 本文档整合了渲染质量三方对比ReadViewSDK vs WXRead与功能开发计划作为阅读器能力演进的单一真值。
> 基于读书 v10.0.3 逆向分析,与当前 ReadViewSDK 代码核查结果整合。
## 背景
渲染内核的架构对齐解决的是"代码可维护性"问题。本文档梳理从"能用的阅读器"到"能上架的商业阅读器"还需要补齐哪些能力,每项能力标注三方状态和具体实施计划。
### 三方对比总览(渲染质量)
| 缺失项 | 严重度 | ReadViewSDK | WXRead | 差距说明 |
| --- | --- | --- | --- | --- |
| **竖排文字** | 高 | ❌ 未实现 | ❌ 未实现 | 双方均无 `writing-mode` 支持 |
| **Ruby 注音** | 高 | ❌ 未实现 | ❌ 未实现 | 双方均无 `<ruby>`/`<rt>` 处理 |
| **数学公式** | 中 | ❌ 未实现 | ⚠️ 仅字体回退 | 非原生渲染范畴,需 WebView 回退 |
| **复杂图文分页质量** | 高 | ✅ 已实现 | ✅ 已实现 | 双方均已实现 |
| **字体选择器** | 中 | ✅ 已实现 | ⚠️ 管线完备UI 未见 | 已支持四档字体选择 |
| **连字/断字** | 中 | ⚠️ 仅配置标记 | ⚠️ 仅属性声明 | 双方均未实际实现 |
| **多语言排版回退** | 中 | ⚠️ 语言检测有,简繁转换无 | ✅ 已实现 | WXRead 额外有简繁转换 |
### ReadViewSDK 领先 WXRead 的项
| 项 | 说明 |
| --- | --- |
| **`keepWithNext`** | 已实现 `trimmedRangeForKeepWithNext`WXRead 反编译代码中未找到对应实现 |
### 不需要对标 WXRead 的项
以下项 WXRead 自身也未实现不属于必须补齐的能力竖排文字、Ruby 注音、数学公式、Hyphenation 断字。
---
## 一、渲染质量
### 1. 字体选择器
**当前状态**:✅ 基础能力已完成。
已支持系统、宋体、圆体、等宽四档字体选择;设置项可持久化;切换字体会触发重新分页;分页缓存签名已包含字体信息,避免不同字体复用旧缓存。
**已落地文件清单**
| 文件 | 改动 | 状态 |
| --- | --- | --- |
| `RDEPUBReaderConfiguration.swift` | 新增 `RDEPUBReaderFontChoice`,配置增加 `fontChoice` | 已完成 |
| `RDEPUBReaderSettings.swift` | 新增 `fontChoice` 持久化,更新 `applying(to:)``capture(configuration:brightness:)` | 已完成 |
| `RDEPUBReaderContext.swift` | `currentTextRenderStyle()` 改用 `configuration.fontChoice.font(ofSize:)` | 已完成 |
| `RDURLReaderController.swift` | URL 阅读器同步使用 `fontChoice` 生成文字样式 | 已完成 |
| `RDEPUBReaderController+RuntimeBridge.swift` | `requiresRepagination(from:to:)` 增加 `fontChoice` 变更检查 | 已完成 |
| `RDEPUBPaginationCacheCoordinator.swift` | 分页缓存签名增加字体名 | 已完成 |
| `RDEPUBReaderSettingsViewController.swift` | 新增 `onFontChoiceChange` 回调、字体分段控件和 accessibility identifier | 已完成 |
| `RDEPUBReaderChromeCoordinator.swift` | 设置面板接线字体变更回调 | 已完成 |
| `SettingsPanelTests.swift` | UI 自动化覆盖字体选择 | 已完成 |
**当前验收结果**
- ✅ 切换字体后触发重排。
- ✅ 字体选择可随阅读器设置持久化。
- ✅ 分页缓存按字体隔离。
- ✅ UI 自动化已覆盖设置面板字体选择。
**后续增强计划**
1. 引入更多内置字体包建议思源宋体、思源黑体、方正书宋、方正兰亭黑、Lora、OpenDyslexic
2. 使用 `CTFontManagerRegisterFontsForURL` 注册 bundle 字体。
3. 字体列表从枚举升级为资源驱动模型,每项包含 `displayName`、`fontName`、`previewText`、可用性状态。
4. 字体选择 UI 从分段控件升级为横向预览卡片,展示真实字体效果。
5. 增加字体资源加载失败兜底,失败时回退系统字体并保留用户设置。
---
### 2. 暗色模式图片处理
**当前状态**:✅ 基础能力已完成。
暗色主题下会对正文图片做显示副本调暗,降低白底图片在深色背景上的刺眼程度。处理只作用于展示层,不修改分页原始内容;封面和小图会跳过,避免误处理封面、图标和装饰图。
**已落地文件清单**
| 文件 | 改动 | 状态 |
| --- | --- | --- |
| `RDEPUBReaderConfiguration.swift` | 新增 `darkImageAdjustmentEnabled``darkImageBlendRatio` | 已完成 |
| `RDEPUBReaderController+RuntimeBridge.swift` | 主题和暗色图片配置变更触发可见页刷新 | 已完成 |
| `RDEPUBTextContentView.swift` | DTCoreText 展示内容增加暗色图片显示副本处理 | 已完成 |
| `RDEPUBTextContentView.swift` | 增加暗色图片缓存,避免重复生成 | 已完成 |
| `SettingsPanelTests.swift` | UI 自动化覆盖暗色主题切换 | 已完成 |
**当前实现策略**
- 仅当背景亮度低于阈值、配置开启、混合比例大于 0 时启用。
- 仅处理正文 inline image attachment。
- 跳过封面和小于 80x80 的图片。
- 使用 `NSCache` 缓存处理后的图片。
- 使用主题背景色按比例覆盖原图,透明区域保持透明。
**当前验收结果**
- ✅ 暗色模式下图片不再完全以原始亮色块直出。
- ✅ 图片内容仍保持可辨认,不做反色。
- ✅ 处理结果有缓存。
- ✅ UI 自动化已覆盖暗色主题入口。
**后续增强计划**
1. 增加真实 EPUB 图片样本的截图回归测试。
2. 按图片平均亮度自适应 `darkImageBlendRatio`
3. 增加用户侧开关,允许关闭暗色图片处理。
4. 如果后续恢复 WebView 路径,再补充 CSS filter 策略。
---
### 3. 简繁转换
**当前状态**:零实现。语言元数据已提取(`publication.metadata.language`),但仅用于拉丁/CJK CSS 分轨。
**WXRead 实现参考**
- `WREpubTypesetter` 检测 `book.language``Hant`/`TW`/`HK` 时调用 `_WRConvertHansToHantIfNeeded()`
- 使用 `CFStringTransform` 两步转换Hans → Latin → Hant
**实施步骤**
**Step 1转换工具**
1. 新增 `RDEPUBTextChineseConverter.swift`,提供:
```swift
enum RDEPUBChineseScript { case hans, hant }
func convert(_ text: String, to script: RDEPUBChineseScript) -> String
```
2. 实现使用 `CFStringTransform`
```swift
let mutable = NSMutableString(string: text) as CFMutableString
CFStringTransform(mutable, nil, kCFStringTransformToLatin, false) // Hans → Pinyin
CFStringTransform(mutable, nil, kCFStringTransformStripDiacritics, false) // Pinyin → stripped
// 然后通过字典映射到繁体
```
或直接使用 Apple 的 `kCFStringTransformHansToHant`(如果可用)。
**Step 2判断是否需要转换**
3. 在 `RDEPUBTextBookBuilder.build()``RDEPUBTextRendererSupport.makeChapterRenderRequest()` 中:
- 读取 `publication.metadata.language`
- 如果语言为 `zh-Hant`/`zh-TW`/`zh-HK` 且用户设置为简体,或语言为 `zh-Hans`/`zh-CN` 且用户设置为繁体,触发转换
4. 新增 `RDEPUBReaderConfiguration.chineseScript: RDEPUBChineseScript?`nil = 跟随书籍)
**Step 3应用转换**
5. 在 `normalizeReadingAttributes(in:style:)` 之后、返回 `RDEPUBTextChapterRenderRequest` 之前,对 attributed string 的文本内容执行转换
6. 需要保持 attributed string 的属性(字体、颜色、附件)不变,只替换字符
**验收标准**
- 简体 EPUB 在繁体模式下显示繁体
- 繁体 EPUB 在简体模式下显示简体
- 转换不影响分页结果(转换后字符数可能变化,需验证)
- 高亮、搜索功能在转换后仍然正常
---
### 4. 孤行/寡行控制
**当前状态**`RDEPUBTextLayoutConfig` 声明了 `avoidOrphans`/`avoidWidows`(默认 true`RDEPUBTextLayouter` 完全不读取这两个标记。
**实现原理**
- **寡行widow**:段落最后一行单独出现在页底 → 应将该行拉到下一页
- **孤行orphan**:段落第一行单独出现在页首 → 应将前一页最后一行拉过来
- 通常只处理寡行(保留至少 2 行在页底),孤行处理会引发连锁重排
**实施步骤**
**Step 1CoreText 路径**
1. 在 `RDEPUBTextLayouter.layoutFramesUsingCoreText()` 的分页循环中(~行 63-137`adjustedRange` 之后、追加 frame 之前,增加寡行检查:
```swift
if config.avoidWidows {
finalRange = trimmedRangeForAvoidWidows(
from: ctFrame,
proposed: finalRange,
lineRanges: lineRanges
)
}
```
2. `trimmedRangeForAvoidWidows` 实现:
- 从 proposed range 的最后一行向前检查
- 如果最后一行是一个段落的最后一行,且该段落在此页只有 1 行 → 回退到上一个段落边界
- 最多移除 2 行(避免过度收缩)
**Step 2DTCoreText 路径**
3. 在 `layoutFramesUsingDTCoreText()` 中增加相同逻辑
**Step 3段落边界检测**
4. 利用已有的 `paragraphRange(containing:)` 方法(`NSString.paragraphRange`)获取段落范围
5. 对比当前页最后一行的 range 和段落的 range判断是否为段落唯一一行
**验收标准**
- 分页页数可能轻微增加1-3 页),但排版质量提升
- 已知的寡行问题页在修复后不再出现段落最后一行孤悬页底
- `avoidOrphans`/`avoidWidows = false` 时行为不变
---
### 5. Hyphenation 断字
**当前状态**`hyphenation: Bool = true` 配置标记已声明,但 `NSParagraphStyle.hyphenationFactor` 从未设置。CoreText 路径更不支持断字。
**技术约束**
- `NSParagraphStyle.hyphenationFactor``UITextView`(降级路径)有效
- CoreText 直绘路径需要设置 `kCTParagraphStyleSpecifierHyphenationFactor`
- 中文场景断字影响较小(中文无空格分词),主要改善西文排版
**实施步骤**
**Step 1UITextView 降级路径**
1. 在 `RDEPUBTextRendererSupport.paragraphStyle(lineSpacing:)`(行 929-934中增加
```swift
style.hyphenationFactor = 1.0
```
2. 在 `normalizeReadingAttributes()`(行 137-143对已有的 `NSMutableParagraphStyle` 增加:
```swift
paragraphStyle.hyphenationFactor = 1.0
```
**Step 2CoreText 直绘路径**
3. 在 `RDEPUBTextLayouter` 的 frame 构造中,对 `CTParagraphStyle` 增加 hyphenation factor
```swift
var hyphenationFactor: Float = 1.0
let settings = [CTParagraphStyleSetting(
spec: .hyphenationFactor,
valueSize: MemoryLayout<Float>.size,
value: &hyphenationFactor
)]
let paragraphStyle = CTParagraphStyleCreate(settings, settings.count)
```
4. 将此 paragraph style 设置到 attributed string 的段落属性上
**Step 3条件控制**
5. 读取 `config.hyphenation` 标记,为 false 时跳过上述设置
**验收标准**
- 西文长单词在行末正确断字(显示连字符)
- 中文排版不受影响
- `hyphenation = false` 时回到当前行为
---
## 二、工程成熟度
### 6. 自动化测试
**当前状态**:✅ UI 自动化测试基础设施已完成,单元测试和分页回归基准待补。
已在 Demo 工程中接入 `ReadViewDemoUITests`,覆盖打开书籍、阅读器基础交互、顶部/底部工具栏、设置面板、字体选择和暗色主题切换。
**已落地内容**
| 项 | 状态 |
| --- | --- |
| UI Test target `ReadViewDemoUITests` | ✅ 已完成 |
| Demo 自动打开测试 EPUB 的入口 | ✅ 已完成 |
| Accessibility identifier 体系 | ✅ 已完成 |
| 阅读器基础打开测试 | ✅ 已完成 |
| 顶部/底部工具栏测试 | ✅ 已完成 |
| 设置面板测试 | ✅ 已完成 |
| 字体选择 UI 测试 | ✅ 已完成 |
| 暗色主题切换 UI 测试 | ✅ 已完成 |
**当前验证结果**
```text
xcodebuild build: BUILD SUCCEEDED
SettingsPanelTests: TEST SUCCEEDED
ReadViewDemoUITests 全量 8 个 UI 测试: TEST SUCCEEDED
```
**下一步实施步骤**
**Step 1单元测试基础设施**
1. 在 `ReadViewDemo.xcodeproj` 中新增 Unit Test target `ReadViewSDKTests`
2. 创建 `Tests/` 目录,配置 `import RDReaderView` 或通过 Demo target 暴露内部测试入口
3. 新增 `.xctestplan` 配置,把 UI 测试和单元测试拆分为不同测试组
**Step 2分页回归测试最高优先级**
4. 新增 `RDEPUBPaginationRegressionTests.swift`,固定 6 类样本章节:
- 纯正文章节
- 图 + 图注 + 正文章节
- 脚注小图标章节
- 多段标题章节
- 大图跨页章节
- 长段落连续章节
5. 每个样本:构建 `RDEPUBTextBook`,断言总页数不变、已知页的 `contentRange` 不变
6. 使用 golden file 对比 `RDEPUBTextChapterPaginationDiagnostic` 输出
**Step 3位置映射测试**
7. 新增 `RDEPUBLocationMappingTests.swift`
- `location → pageNumber` 正向映射
- `pageNumber → location` 逆向映射
- 往返一致性断言
**Step 4渲染管线测试**
8. 新增 `RDEPUBTypesetterTests.swift`
- `normalizeHTML` 输入输出对比
- fragment marker 注入/提取一致性
- 语义标记注入后 `rdPage*` 属性计数不变
**Step 5CI可选**
9. 配置 GitHub Actions 或 Jenkins在 PR 时自动运行测试
**验收标准**
- UI 自动化全量测试稳定通过
- 6 个分页基准样本的页数和 contentRange 稳定
- 位置映射往返测试通过
- 新 PR 触发测试自动运行
---
### 7. 性能基线
**当前状态**`RDEPUBTextPerformanceSampler` 用 `CFAbsoluteTimeGetCurrent()` 采样,结果仅 `print()` 输出。无 Instruments 可视化、无内存监控、无阈值告警。
**实施步骤**
**Step 1os_signpost 集成**
1. 在 `RDEPUBTextPerformanceSampler` 中引入 `os.signpost`
```swift
import os.signpost
let log = OSLog(subsystem: "com.rdreader", category: .pointsOfInterest)
```
2. 在 build/render/paginate 各阶段插入 `os_signpost(.begin, ...)` / `os_signpost(.end, ...)`
3. 这样 Instruments 的 Points of Interest 能直接可视化各阶段耗时
**Step 2内存监控**
4. 新增 `RDEPUBMemoryMonitor`
```swift
func currentMemoryFootprint() -> UInt64 // task_info.resident_size
```
5. 在 `RDEPUBTextBookBuilder.build()` 的每章循环中记录内存峰值
6. 在 `RDEPUBTextPerformanceSample` 中增加 `peakMemoryBytes: UInt64`
**Step 3阈值告警**
7. 定义基准值:首屏 < 2s单章渲染 < 500ms全书构建 < 30s按书的大小可调
8. 超出阈值时通过 `os_log(.error, ...)` 输出警告
9. 在 Demo 中展示性能摘要面板
**Step 4持久化**
10. 性能样本可选持久化到文件,供回归对比
**验收标准**
- Instruments 能看到各阶段的 signpost 区间
- 内存峰值在大书79MB 样本)构建过程中有记录
- 超出阈值时有日志输出
---
### 8. 崩溃防护
**当前状态**`RDReaderView` 有 pageCurl 崩溃检测和异步恢复,但无全局异常捕获。
**实施步骤**
**Step 1NSException 捕获**
1. 新增 `RDEPUBCrashGuard` 工具类:
```swift
static func performSafely(_ block: () throws -> Void) rethrows
static func performWithObjCExceptionHandling(_ block: () -> Void) -> Bool
```
2. 在关键入口包裹 `@try/@catch`
- `RDEPUBTextBookBuilder.build()` 的每章循环体
- `RDEPUBTextLayouter.layoutFrames()` 的分页循环
- `RDEPUBTextRendererSupport.makeChapterRenderRequest()` 的 HTML 处理
**Step 2分页/渲染单章隔离**
3. 单章渲染/分页失败时,降级到空白页或上一次缓存结果,不中断全书构建
4. 在 `RDEPUBTextChapterPaginationDiagnostic` 中记录错误状态
**Step 3全局信号处理可选**
5. 注册 `NSSetUncaughtExceptionHandler` 记录 ObjC 异常
6. 注册 signal handlerSIGABRT, SIGSEGV记录崩溃现场
7. 下次启动时上报(如果后续有上报系统)
**验收标准**
- 单章渲染异常不导致整个 build 中断
- pageCurl 崩溃场景已有保护,验证不退化
- 崩溃日志可追踪到具体章节和阶段
---
### 9. 内存管理
**当前状态**:无显式内存预算。无 `didReceiveMemoryWarning` 处理。`RDEPUBTextBookCache` 磁盘缓存无大小限制。
**实施步骤**
**Step 1内存警告响应**
1. 在 `RDEPUBReaderController` 中监听 `UIApplication.didReceiveMemoryWarningNotification`
2. 收到警告时:
- 清空 `RDReaderPreloadController` 的预加载缓存
- 通知 `RDEPUBTextBookCache` 的内存缓存(如果有)清空
- 释放非当前可见章节的渲染结果
**Step 2章节级内存释放**
3. 在 `RDEPUBTextBookBuilder` 构建完成后,不再持有已构建章节的 `NSAttributedString`
4. 当前只持有 `RDEPUBTextBook`(包含 `[RDEPUBTextPage]` 的 NSRange内存占用已较低
5. 如果后续引入 attributed string 缓存,需增加 LRU 淘汰策略
**Step 3图片内存控制**
6. `RDReaderPreloadController` 预加载的 page view 数量与内存挂钩
7. 当内存压力时减少 `preloadRadius`(从 1 降到 0
**验收标准**
- 在大书79MB构建过程中收到内存警告时app 不被系统杀掉
- 预加载缓存在内存压力下自动收缩
- 当前页内容不丢失
---
### 10. 增量构建
**当前状态**:全书一次性分页。`RDEPUBTextBookBuilder.build()` 遍历全部 spine item输出完整 `RDEPUBTextBook`
**实施步骤**
**Step 1章节级构建接口**
1. 在 `RDEPUBTextBookBuilder` 上新增:
```swift
func buildChapter(
at spineIndex: Int,
publication: RDEPUBPublication,
parser: RDEPUBParser,
pageSize: CGSize,
style: RDEPUBTextRenderStyle
) -> RDEPUBTextChapter?
```
2. 内部逻辑从 `build()` 的循环体中提取,单章可独立构建
**Step 2按需加载**
3. `RDEPUBReaderLoadCoordinator` 在打开书时只构建当前章节和相邻 ±1 章
4. 用户翻到新章节时,后台异步构建该章节
5. 使用 `DispatchQueue` 串行队列保证线程安全
**Step 3与缓存联动**
6. `RDEPUBPaginationCacheCoordinator` 支持单章缓存命中检查
7. 缓存命中时跳过构建,直接加载
**验收标准**
- 大书首次打开时间缩短(只构建 3 章而非全书)
- 翻到未构建章节时无明显卡顿(后台预构建)
- 全书构建 API 保持不变(兼容现有调用方)
---
### 11. 缓存管理
**当前状态**`RDEPUBTextBookCache` 磁盘缓存无大小限制、无淘汰策略、无选择性失效。无内存缓存。
**实施步骤**
**Step 1磁盘缓存容量控制**
1. 新增 `maxDiskCacheSize: UInt64`(默认 200MB
2. 在 `save(_:key:)` 写入后检查总大小
3. 超限时按修改时间删除最旧的缓存文件,直到低于阈值
**Step 2选择性失效**
4. 新增 `invalidate(key:)` 方法,删除指定缓存文件
5. 当用户修改 `fontChoice``fontSize` 时,只失效当前书的缓存(而非 `invalidateAll`
**Step 3内存缓存NSCache**
6. 在 `RDEPUBTextBookCache` 上层增加 `NSCache<NSString, PaginationCacheArchive>`
7. `load(key:)` 先查内存缓存miss 后查磁盘并回填
8. 内存警告时清空 NSCache
**Step 4缓存统计持久化**
9. `RDEPUBTextBookCache` 新增 `cacheStats` 属性,记录总命中/缺失次数
10. 可用于调试和性能分析
**验收标准**
- 磁盘缓存不超过设定上限
- 修改字号/字体后,旧缓存被正确失效
- 连续打开同一本书时,第二次命中内存缓存(< 1ms
---
## 三、可访问性
### 12. VoiceOver
**当前状态**9 个 `accessibilityIdentifier`(仅用于 UI 测试),无 `accessibilityLabel`、`accessibilityHint`、`accessibilityTraits`。阅读内容区域无任何无障碍支持。
**实施步骤**
**Step 1工具栏无障碍最低门槛**
1. `RDEPUBReaderTopToolView`
- `backButton.accessibilityLabel = "返回"`
- `bookmarkButton.accessibilityLabel = "书签"` + `accessibilityValue` 反映当前状态(已添加/未添加)
- `titleLabel.accessibilityLabel` = 书籍标题
2. `RDEPUBReaderBottomToolView`
- 各按钮补充 `accessibilityLabel`"目录"、"书签列表"、"高亮列表"、"添加高亮"、"设置"
- 进度 slider 补充 `accessibilityValue`"第 X 页,共 Y 页"
**Step 2设置面板**
3. `RDEPUBReaderSettingsViewController` 所有控件补充 label 和 traits
**Step 3阅读内容区域中等难度**
4. `RDEPUBTextContentView` 设置 `isAccessibilityElement = true`
5. `accessibilityLabel` = 当前页纯文本内容
6. 翻页时发出 `UIAccessibility.pageScrolledNotification`
**Step 4目录和高亮列表**
7. `RDEPUBReaderChapterListController` 列表项设置 `accessibilityLabel`(章节标题 + 页码)
8. `RDEPUBReaderHighlightsViewController` 列表项设置 `accessibilityLabel`(高亮文本 + 章节)
**验收标准**
- VoiceOver 用户能完整导航工具栏
- 翻页时 VoiceOver 朗读新页内容
- 目录和高亮列表可被 VoiceOver 逐项朗读
---
### 13. Dynamic Type
**当前状态**:字号由 `RDEPUBReaderConfiguration.fontSize` 控制,不响应系统 `UIContentSizeCategory` 变化。
**实施步骤**
1. 在 `RDEPUBReaderSettingsViewController` 中监听 `UIContentSizeCategory.didChangeNotification`
2. 当系统字体大小变化时,根据新的 `UIContentSizeCategory` 计算等效字号
3. 或者:在 `RDEPUBReaderConfiguration` 中增加 `useSystemFontSize: Bool`,启用时忽略 `fontSize`,使用系统推荐值
4. 字号映射表(参考 Apple 的 `preferredFont(forTextStyle:)` 返回值)
**验收标准**
- 系统字体调大后,阅读器字号自动跟随
- 用户手动调节字号后,覆盖系统设置
---
### 14. 高对比度
**当前状态**6 个固定主题预设,不跟随系统 `UIAccessibility.isDarkerSystemColorsEnabled`
**实施步骤**
1. `RDEPUBReaderTheme` 增加 `highContrastVariant: RDEPUBReaderTheme?` 属性
2. 监听 `UIAccessibility.darkerSystemColorsStatusDidChangeNotification`
3. 高对比度启用时,自动切换到高对比度主题变体(更大色彩对比度)
4. 或提供独立的"高对比度"主题预设
**验收标准**
- 系统高对比度开启后,阅读器自动切换到高对比度主题
- 关闭后恢复原主题
---
### 15. DRM
**当前状态**:需求文档明确声明 DRM 不在 SDK 范围内。如果需要支持,建议通过以下方式:
**建议方案**
1. 不在 SDK 内实现 DRM而是通过 `RDEPUBParser` 的输入端控制:
- `RDEPUBParser` 接受 `Data``URL`,调用方可以先解密再传入
- 或新增 `RDEPUBDRMProvider` 协议:
```swift
protocol RDEPUBDRMProvider {
func decryptedData(for resourceURL: URL) -> Data?
func isProtected(_ resourceURL: URL) -> Bool
}
```
2. SDK 内的 `ss-reader://` URL scheme handler 在读取资源时调用 `DRMProvider`
3. 商业 DRM如 Adobe ADEPT、Readium LCP由调用方集成SDK 提供接入点
**验收标准**
- SDK 提供清晰的 DRM 接入协议
- 不引入 DRM 依赖,保持 SDK 轻量
- 调用方能通过协议接入自己的 DRM 方案
---
## 四、功能完整度(暂未展开)
以下功能需求已识别但暂未进入详细开发计划:
| 缺失项 | 严重度 | 说明 |
| --- | --- | --- |
| **书架/书库管理** | 高 | SDK 只能打开单本书,无书架 UI、阅读历史、分类管理 |
| **批注导出/分享** | 高 | 高亮/笔记只有本地存储,无导出、分享、复制到剪贴板 |
| **阅读统计** | 中 | 无阅读时长追踪、阅读速度、连续阅读天数 |
| **TTS 朗读** | 中 | 微信读书核心功能之一,当前无任何语音相关代码 |
| **全局搜索** | 中 | 当前搜索只在单本书内,无跨书搜索 |
| **离线/云端同步** | 高 | 无 iCloud/自建同步,阅读进度和笔记只在本地 |
| **夜间模式定时切换** | 低 | 有暗色主题但不能跟随系统或定时切换 |
---
## 五、按优先级排序的建议路线
### P0 — 影响商业发布
1. **分页回归基准** — UI 自动化测试已启动,下一步需要把分页结果、首屏时间、截图差异纳入回归基准
2. **字体选择器增强** — 基础字体选择器已实现,后续需补:更多内置字体包、字体预览、字体资源加载失败兜底
3. **书架/书库管理** — 商业阅读器的入口
4. **暗色模式图片处理增强** — 基础处理已实现,后续可补:按图片亮度自适应混合比例
### P1 — 影响用户留存
5. **批注导出/分享** — 深度阅读用户的核心需求
6. **阅读统计/时长追踪** — 用户粘性和产品数据的基础
7. **性能基线与大书优化** — 大书卡顿是用户流失的主要原因
8. **简繁转换** — 面向港澳台用户需要
### P2 — 提升竞争力
9. **TTS 朗读** — 通勤场景、无障碍场景刚需
10. **云端同步** — 多设备用户的基本需求
11. **VoiceOver 完善** — 合规和品牌形象
12. **全局搜索** — 藏书量大时的效率工具
---
## 六、总结
经过三方对比,渲染质量层面的真实差距比最初评估要小:
- **图文分页质量**:双方基本对齐,我们甚至在 `keepWithNext` 上领先
- **真正的差距**:字体包数量、分页回归基准、简繁转换
- **WXRead 也没做的**竖排、ruby、公式、hyphenation——这些不是必须对齐的
如果目标是"能上架的商业阅读器",当前已补齐字体选择器、暗色模式图片处理和基础 UI 自动化测试。下一步最值得投入的是分页回归基准、更多字体资源和书架/书库管理。

File diff suppressed because it is too large Load Diff

View File

@ -12,6 +12,11 @@
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 */; };
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 */; };
1A2B3C4D00000014AABBCC01 /* FanrenParseTimeTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000013AABBCC01 /* FanrenParseTimeTest.swift */; };
23BB1155EA379786DAA10A89 /* DisplayTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */; };
3BC5C96D7A0ACF35F2192CC7 /* XCUIApplication+Launch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74B4C44287820D68ED6570F8 /* XCUIApplication+Launch.swift */; };
4509ED928F228F43888E063D /* ReaderToolbarTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */; };
@ -21,10 +26,7 @@
C08FF8D030048DC5147729E9 /* ReaderOpenCloseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */; };
DE1437A969DA1C5F0CBB047D /* Pods_ReadViewDemo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 792DF85CE4A3DD80D67843C7 /* Pods_ReadViewDemo.framework */; };
FEDB5937CEB858CB06E38E2D /* SettingsPanelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201C2B482287866487EFAE66 /* SettingsPanelTests.swift */; };
1A2B3C4D0000000BAABBCC01 /* ConfigurableWindowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D0000000CAABBCC01 /* ConfigurableWindowTests.swift */; };
1A2B3C4D0000000DAABBCC01 /* ConcurrentParsingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D0000000EAABBCC01 /* ConcurrentParsingTests.swift */; };
1A2B3C4D0000000FAABBCC01 /* MetadataParseBenchmarkTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000010AABBCC01 /* MetadataParseBenchmarkTests.swift */; };
1A2B3C4D00000011AABBCC01 /* DemoReaderState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000012AABBCC01 /* DemoReaderState.swift */; };
369C9658D870DCAFC17EB7F7 /* SearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFE01DBDCB8D790832A4DE3F /* SearchTests.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@ -44,6 +46,11 @@
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>"; };
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>"; };
1A2B3C4D00000013AABBCC01 /* FanrenParseTimeTest.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FanrenParseTimeTest.swift; sourceTree = "<group>"; };
201C2B482287866487EFAE66 /* SettingsPanelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SettingsPanelTests.swift; sourceTree = "<group>"; };
20BD15E5D8F04E7E9A239E14 /* ReaderAnnotationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderAnnotationTests.swift; sourceTree = "<group>"; };
3A43AED288BFCA3ADBA97DD7 /* Pods-ReadViewDemo.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ReadViewDemo.release.xcconfig"; path = "Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo.release.xcconfig"; sourceTree = "<group>"; };
@ -54,12 +61,9 @@
8FFD606A5A1CBDCC3CA87F1C /* ReadViewDemoUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ReadViewDemoUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderOpenCloseTests.swift; sourceTree = "<group>"; };
BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderToolbarTests.swift; sourceTree = "<group>"; };
1A2B3C4D0000000CAABBCC01 /* ConfigurableWindowTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConfigurableWindowTests.swift; sourceTree = "<group>"; };
1A2B3C4D0000000EAABBCC01 /* ConcurrentParsingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConcurrentParsingTests.swift; sourceTree = "<group>"; };
1A2B3C4D00000010AABBCC01 /* MetadataParseBenchmarkTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MetadataParseBenchmarkTests.swift; sourceTree = "<group>"; };
1A2B3C4D00000012AABBCC01 /* DemoReaderState.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DemoReaderState.swift; sourceTree = "<group>"; };
DE070C1D2FBF0CC900ED065F /* ReadViewDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ReadViewDemo.app; sourceTree = BUILT_PRODUCTS_DIR; };
FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DisplayTypeTests.swift; sourceTree = "<group>"; };
CFE01DBDCB8D790832A4DE3F /* SearchTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SearchTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@ -184,6 +188,8 @@
1A2B3C4D0000000CAABBCC01 /* ConfigurableWindowTests.swift */,
1A2B3C4D0000000EAABBCC01 /* ConcurrentParsingTests.swift */,
1A2B3C4D00000010AABBCC01 /* MetadataParseBenchmarkTests.swift */,
1A2B3C4D00000013AABBCC01 /* FanrenParseTimeTest.swift */,
CFE01DBDCB8D790832A4DE3F /* SearchTests.swift */,
);
path = ReaderUITests;
sourceTree = "<group>";
@ -314,14 +320,10 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks.sh\"\n";
@ -350,6 +352,8 @@
1A2B3C4D0000000BAABBCC01 /* ConfigurableWindowTests.swift in Sources */,
1A2B3C4D0000000DAABBCC01 /* ConcurrentParsingTests.swift in Sources */,
1A2B3C4D0000000FAABBCC01 /* MetadataParseBenchmarkTests.swift in Sources */,
1A2B3C4D00000014AABBCC01 /* FanrenParseTimeTest.swift in Sources */,
369C9658D870DCAFC17EB7F7 /* SearchTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

@ -18,6 +18,7 @@ final class ViewController: UIViewController {
let windowSize: Int?
let concurrency: Int?
let clearsCache: Bool
let searchKeyword: String?
nonisolated private static func parseDisplayType(_ rawValue: String) -> RDReaderView.DisplayType? {
switch rawValue.lowercased() {
@ -56,6 +57,7 @@ final class ViewController: UIViewController {
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")
let searchKeyword = value(after: "--demo-search-keyword")
return LaunchAutomationPlan(
bookTitleQuery: bookTitleQuery,
@ -66,7 +68,8 @@ final class ViewController: UIViewController {
stepDelay: stepDelay,
windowSize: windowSize,
concurrency: concurrency,
clearsCache: clearsCache
clearsCache: clearsCache,
searchKeyword: searchKeyword
)
}
}
@ -229,6 +232,11 @@ final class ViewController: UIViewController {
stepDelay: automationPlan.stepDelay
)
}
if let searchKeyword = automationPlan.searchKeyword {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
controller?.performDemoSearch(keyword: searchKeyword)
}
}
}
}

View File

@ -27,6 +27,14 @@ enum IDs {
static let readerSelectionText = "epub.reader.selection.text"
static let readerSelectionHighlight = "epub.reader.selection.高亮"
static let readerSearch = "epub.reader.search"
static let searchBar = "epub.reader.search.bar"
static let searchField = "epub.reader.search.field"
static let searchPrevious = "epub.reader.search.previous"
static let searchNext = "epub.reader.search.next"
static let searchClose = "epub.reader.search.close"
static let searchCount = "epub.reader.search.count"
static let settingsScroll = "epub.reader.settings.scroll"
static let settingsBrightness = "epub.reader.settings.brightness"
static let settingsFontIncrease = "epub.reader.settings.font.increase"

View File

@ -8,7 +8,8 @@ extension XCUIApplication {
resetsReaderState: Bool = true,
windowSize: Int? = nil,
concurrency: Int? = nil,
clearsCache: Bool = false
clearsCache: Bool = false,
searchKeyword: String? = nil
) {
var args = ["--demo-book-title", bookTitleQuery]
if resetsReaderState {
@ -29,6 +30,9 @@ extension XCUIApplication {
if let concurrency {
args += ["--demo-concurrency", "\(concurrency)"]
}
if let searchKeyword {
args += ["--demo-search-keyword", searchKeyword]
}
launchArguments = args
launch()
}

View File

@ -0,0 +1,54 @@
import XCTest
final class FanrenParseTimeTest: XCTestCase {
private let app = XCUIApplication()
override func setUpWithError() throws {
continueAfterFailure = false
}
///
func testFanrenParseTime() throws {
let cpuCount = ProcessInfo.processInfo.activeProcessorCount
print("[Fanren] 开始测试 - 设备CPU核心数: \(cpuCount)")
// 使
app.launchAndOpenSampleBook(
bookTitleQuery: "凡人修仙传",
resetsReaderState: true,
concurrency: cpuCount,
clearsCache: true
)
//
app.waitForReader(timeout: 20)
//
_ = app.waitForDemoReaderState(timeout: 30, description: "首屏加载") { state in
state.mode == "bookPageMap" && (state.page ?? 0) >= 1
}
print("[Fanren] 首屏加载完成")
// parseMs > 0
let completed = app.waitForDemoReaderState(timeout: 900, description: "后台解析完成") { state in
(state.parseMs ?? 0) > 0
}
let parseMs = completed.parseMs ?? 0
let concurrency = completed.parseConcurrency ?? 0
print("[Fanren] ---- 测试结果 ----")
print("[Fanren] 后台解析耗时: \(parseMs)ms")
print("[Fanren] 并发数: \(concurrency)")
print("[Fanren] CPU核心数: \(cpuCount)")
//
XCTAssertGreaterThan(parseMs, 0, "parseMs应大于0")
//
app.showReaderChromeIfNeeded()
if app.buttons[IDs.readerBack].waitForExistence(timeout: 5) {
app.buttons[IDs.readerBack].tap()
}
}
}

View File

@ -70,6 +70,79 @@ final class LargeBookOnDemandTests: XCTestCase {
XCTAssertEqual(reopenedState.knownChapters, reopenedState.buildableChapters)
}
func testMiddleChapterOpenPositionStableAfterFullParse() throws {
//
app.launchAndOpenSampleBook(
bookTitleQuery: largeBookQuery,
pageNumber: 50,
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
}
let pageAfterOpen = partialState.page ?? 0
XCTAssertGreaterThan(pageAfterOpen, 1, "应从中间章节打开,当前页=\(pageAfterOpen)")
//
let fullState = app.waitForDemoReaderState(timeout: 90, description: "后台解析完成") { state in
state.pagination == "full"
}
XCTAssertEqual(fullState.pagination, "full", "后台解析应完成")
// Cpending map
let pageAfterParse = fullState.page ?? 0
XCTAssertEqual(pageAfterParse, pageAfterOpen,
"后台解析完成后页码不应跳转:解析前=\(pageAfterOpen) 解析后=\(pageAfterParse)")
// pending map
let paging = app.collectionViews[IDs.readerPaging]
if paging.waitForExistence(timeout: 5) {
paging.swipeLeft()
}
let afterSwipe = app.waitForDemoReaderState(timeout: 10, description: "翻页后页码推进") { state in
state.page != nil && state.page != pageAfterParse
}
XCTAssertGreaterThan(afterSwipe.page ?? 0, pageAfterParse,
"翻页后页码应前进:翻页前=\(pageAfterParse) 翻页后=\(afterSwipe.page ?? 0)")
}
func testMiddleChapterOpenSwipeAfterFullParseResolvesCorrectly() throws {
//
app.launchAndOpenSampleBook(
bookTitleQuery: largeBookQuery,
pageNumber: 30,
resetsReaderState: true
)
app.waitForReader(timeout: 20)
_ = app.waitForDemoReaderState(timeout: 15, description: "局部分页就绪") { state in
state.mode == "bookPageMap" && state.pagination == "partial"
}
//
_ = app.waitForDemoReaderState(timeout: 90, description: "后台解析完成") { state in
state.pagination == "full"
}
// 3
let paging = app.collectionViews[IDs.readerPaging]
XCTAssertTrue(paging.waitForExistence(timeout: 5), "分页视图不存在")
var previousPage = app.currentDemoReaderState()?.page ?? 0
for i in 0..<3 {
paging.swipeLeft()
RunLoop.current.run(until: Date().addingTimeInterval(1))
let currentState = app.currentDemoReaderState()
let currentPage = currentState?.page ?? 0
XCTAssertGreaterThan(currentPage, previousPage,
"\(i + 1) 次翻页后页码应递增:前=\(previousPage) 后=\(currentPage)")
previousPage = currentPage
}
}
func testLargeBookContinuousPagingExtendsKnownPageMap() throws {
app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, displayType: "scroll", resetsReaderState: true)
app.waitForReader(timeout: 20)

View File

@ -0,0 +1,250 @@
import XCTest
final class SearchTests: XCTestCase {
private let app = XCUIApplication()
override func setUpWithError() throws {
continueAfterFailure = false
}
// MARK: -
func testSearchButtonExistsInToolbar() {
app.launchAndOpenSampleBook()
app.waitForReader()
app.showReaderChromeIfNeeded()
let searchButton = app.buttons[IDs.readerSearch]
XCTAssertTrue(searchButton.waitForExistence(timeout: 5), "搜索按钮应出现在顶部工具栏")
}
// MARK: - /
func testTapSearchButtonShowsSearchBar() {
app.launchAndOpenSampleBook()
app.waitForReader()
app.showReaderChromeIfNeeded()
let searchButton = app.buttons[IDs.readerSearch]
XCTAssertTrue(searchButton.waitForExistence(timeout: 5), "搜索按钮应存在")
searchButton.tap()
let searchBar = app.otherElements[IDs.searchBar]
XCTAssertTrue(searchBar.waitForExistence(timeout: 3), "点击搜索按钮后搜索栏应出现")
}
func testSearchBarAutoFocusesTextField() {
app.launchAndOpenSampleBook()
app.waitForReader()
app.showReaderChromeIfNeeded()
let searchButton = app.buttons[IDs.readerSearch]
XCTAssertTrue(searchButton.waitForExistence(timeout: 5))
searchButton.tap()
let searchBar = app.otherElements[IDs.searchBar]
XCTAssertTrue(searchBar.waitForExistence(timeout: 3), "搜索栏应出现")
XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 3), "搜索栏出现后键盘应弹出")
}
func testCloseButtonHidesSearchBar() {
app.launchAndOpenSampleBook()
app.waitForReader()
app.showReaderChromeIfNeeded()
openSearchBar()
let closeButton = app.buttons[IDs.searchClose]
XCTAssertTrue(closeButton.waitForExistence(timeout: 3), "关闭按钮应存在")
closeButton.tap()
let searchBar = app.otherElements[IDs.searchBar]
XCTAssertFalse(searchBar.waitForExistence(timeout: 2), "点击关闭后搜索栏应消失")
}
// MARK: - launch argument
func testSearchFindsMatchesAndShowsCount() {
app.launchAndOpenSampleBook(searchKeyword: "")
app.waitForReader()
let countLabel = app.staticTexts[IDs.searchCount]
XCTAssertTrue(countLabel.waitForExistence(timeout: 10), "匹配计数标签应出现")
let countText = countLabel.label
XCTAssertTrue(countText.contains("/"), "计数格式应为 'N/M',实际:\(countText)")
let parts = countText.split(separator: "/")
XCTAssertEqual(parts.count, 2, "计数应包含两部分,实际:\(countText)")
if let total = Int(parts[1]) {
XCTAssertGreaterThan(total, 0, "关键词 '的' 应有匹配结果")
}
}
func testSearchNavigatesToFirstMatch() {
app.launchAndOpenSampleBook(searchKeyword: "")
app.waitForReader()
let countLabel = app.staticTexts[IDs.searchCount]
XCTAssertTrue(countLabel.waitForExistence(timeout: 10), "匹配计数应出现")
let countText = countLabel.label
XCTAssertTrue(countText.hasPrefix("1/"), "搜索后应自动跳转到第一个匹配,当前:\(countText)")
}
func testSearchNextButtonAdvancesMatch() {
app.launchAndOpenSampleBook(searchKeyword: "")
app.waitForReader()
let countLabel = app.staticTexts[IDs.searchCount]
XCTAssertTrue(countLabel.waitForExistence(timeout: 10))
XCTAssertTrue(countLabel.label.hasPrefix("1/"), "初始应在第 1 个匹配")
let nextButton = app.buttons[IDs.searchNext]
XCTAssertTrue(nextButton.waitForExistence(timeout: 3))
nextButton.tap()
XCTAssertTrue(countLabel.label.hasPrefix("2/"), "点击下一个后应变为第 2 个匹配,当前:\(countLabel.label)")
}
func testSearchPreviousButtonGoesBack() {
app.launchAndOpenSampleBook(searchKeyword: "")
app.waitForReader()
let countLabel = app.staticTexts[IDs.searchCount]
XCTAssertTrue(countLabel.waitForExistence(timeout: 10))
// 2
let nextButton = app.buttons[IDs.searchNext]
XCTAssertTrue(nextButton.waitForExistence(timeout: 3))
nextButton.tap()
XCTAssertTrue(countLabel.label.hasPrefix("2/"))
// 退 1
let previousButton = app.buttons[IDs.searchPrevious]
XCTAssertTrue(previousButton.waitForExistence(timeout: 3))
previousButton.tap()
XCTAssertTrue(countLabel.label.hasPrefix("1/"), "点击上一个后应回到第 1 个匹配,当前:\(countLabel.label)")
}
func testSearchNextWrapsAround() {
app.launchAndOpenSampleBook(searchKeyword: "")
app.waitForReader()
let countLabel = app.staticTexts[IDs.searchCount]
XCTAssertTrue(countLabel.waitForExistence(timeout: 10))
let countText = countLabel.label
let parts = countText.split(separator: "/")
guard parts.count == 2, let total = Int(parts[1]), total > 1 else {
XCTSkip("需要至少 2 个匹配才能测试循环")
return
}
//
let nextButton = app.buttons[IDs.searchNext]
for _ in 0..<(total - 1) {
nextButton.tap()
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
}
XCTAssertTrue(countLabel.label.hasPrefix("\(total)/"), "应到达最后一个匹配")
// 1
nextButton.tap()
XCTAssertTrue(countLabel.label.hasPrefix("1/"), "超过最后一个后应循环到第 1 个,当前:\(countLabel.label)")
}
// MARK: -
func testSearchWithNoResultsShowsZeroCount() {
app.launchAndOpenSampleBook(searchKeyword: "xyzzy_nonexistent_keyword_12345")
app.waitForReader()
let countLabel = app.staticTexts[IDs.searchCount]
XCTAssertTrue(countLabel.waitForExistence(timeout: 10), "计数标签应出现")
XCTAssertEqual(countLabel.label, "0/0", "无匹配时应显示 0/0")
}
func testSearchNavigationDisabledWhenNoResults() {
app.launchAndOpenSampleBook(searchKeyword: "xyzzy_nonexistent_keyword_12345")
app.waitForReader()
let countLabel = app.staticTexts[IDs.searchCount]
XCTAssertTrue(countLabel.waitForExistence(timeout: 10))
XCTAssertEqual(countLabel.label, "0/0")
let prevButton = app.buttons[IDs.searchPrevious]
let nextButton = app.buttons[IDs.searchNext]
XCTAssertTrue(prevButton.waitForExistence(timeout: 3))
XCTAssertTrue(nextButton.waitForExistence(timeout: 3))
XCTAssertFalse(prevButton.isEnabled, "无结果时上一个按钮应禁用")
XCTAssertFalse(nextButton.isEnabled, "无结果时下一个按钮应禁用")
}
// MARK: -
func testCloseSearchClearsState() {
app.launchAndOpenSampleBook(searchKeyword: "")
app.waitForReader()
let countLabel = app.staticTexts[IDs.searchCount]
XCTAssertTrue(countLabel.waitForExistence(timeout: 10))
XCTAssertNotEqual(countLabel.label, "0/0", "应有匹配结果")
//
let closeButton = app.buttons[IDs.searchClose]
XCTAssertTrue(closeButton.waitForExistence(timeout: 3))
closeButton.tap()
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
//
let searchBar = app.otherElements[IDs.searchBar]
XCTAssertFalse(searchBar.waitForExistence(timeout: 2), "关闭后搜索栏应消失")
}
// MARK: -
func testSearchBarHidesWhenToolbarsHide() {
app.launchAndOpenSampleBook(searchKeyword: "")
app.waitForReader()
let searchBar = app.otherElements[IDs.searchBar]
XCTAssertTrue(searchBar.waitForExistence(timeout: 10), "搜索栏应可见")
//
app.hideReaderChromeIfNeeded()
XCTAssertFalse(searchBar.waitForExistence(timeout: 2), "工具栏隐藏时搜索栏也应隐藏")
}
func testSearchBarRestoresWhenToolbarsShow() {
app.launchAndOpenSampleBook(searchKeyword: "")
app.waitForReader()
let searchBar = app.otherElements[IDs.searchBar]
XCTAssertTrue(searchBar.waitForExistence(timeout: 10))
//
app.hideReaderChromeIfNeeded()
XCTAssertFalse(searchBar.waitForExistence(timeout: 2))
//
app.showReaderChromeIfNeeded()
XCTAssertTrue(searchBar.waitForExistence(timeout: 3), "工具栏恢复时搜索栏也应恢复")
//
let countLabel = app.staticTexts[IDs.searchCount]
XCTAssertTrue(countLabel.waitForExistence(timeout: 3))
XCTAssertNotEqual(countLabel.label, "0/0", "搜索状态应在工具栏恢复后保留")
}
// MARK: -
private func openSearchBar() {
let searchButton = app.buttons[IDs.readerSearch]
if searchButton.waitForExistence(timeout: 5) {
searchButton.tap()
}
let searchBar = app.otherElements[IDs.searchBar]
XCTAssertTrue(searchBar.waitForExistence(timeout: 3), "搜索栏应出现")
}
}

View File

@ -111,6 +111,10 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
readerContext.markUserNavigationActivity()
updateCurrentSelection(nil)
reconcileTextPaginationSizeIfNeeded(for: pageNum)
// map
runtime.applyPendingFullPageMapIfNeeded()
if readerContext.bookPageMap != nil {
_ = runtime.prepareOnDemandChapter(forAbsolutePageNumber: pageNum + 1)
runtime.extendPartialBookPageMapIfNeeded(currentPageNumber: pageNum + 1)

View File

@ -155,6 +155,9 @@ public final class RDEPUBReaderController: UIViewController {
}
lazy var topToolView = runtime.makeTopToolView()
lazy var bottomToolView = runtime.makeBottomToolView()
lazy var searchBarView = RDEPUBReaderSearchBarView()
///
private(set) var isSearchBarVisible = false
var currentBookIdentifier: String? {
get { readerContext.currentBookIdentifier }
set { readerContext.currentBookIdentifier = newValue }
@ -273,6 +276,9 @@ public final class RDEPUBReaderController: UIViewController {
currentBrightness = currentBrightness
readerAssemblyCoordinator.assembleInterface()
readerAssemblyCoordinator.finishExternalTextBookLaunchIfNeeded()
readerView.onToolViewVisibilityChanged = { [weak self] isVisible in
self?.handleToolViewVisibilityChanged(isVisible: isVisible)
}
}
public override func viewWillAppear(_ animated: Bool) {
@ -302,4 +308,100 @@ public final class RDEPUBReaderController: UIViewController {
runtime.viewportMonitor.viewWillTransition(with: coordinator)
}
// MARK: -
/// readerView
func showSearchBar() {
guard !isSearchBarVisible else { return }
isSearchBarVisible = true
searchBarView.apply(theme: configuration.theme)
readerView.addSubview(searchBarView)
readerView.searchBarView = searchBarView
let topToolbarHeight: CGFloat = readerView.safeAreaInsets.top + 52
NSLayoutConstraint.activate([
searchBarView.leadingAnchor.constraint(equalTo: readerView.leadingAnchor),
searchBarView.trailingAnchor.constraint(equalTo: readerView.trailingAnchor),
searchBarView.topAnchor.constraint(equalTo: readerView.topAnchor, constant: topToolbarHeight),
searchBarView.heightAnchor.constraint(equalToConstant: 52)
])
searchBarView.transform = CGAffineTransform(translationX: 0, y: -52)
UIView.animate(withDuration: 0.3) {
self.searchBarView.transform = .identity
}
searchBarView.onSearchSubmit = { [weak self] keyword in
self?.runtime.search(keyword: keyword)
self?.updateSearchCount()
}
searchBarView.onSearchPrevious = { [weak self] in
_ = self?.runtime.searchPrevious()
self?.updateSearchCount()
}
searchBarView.onSearchNext = { [weak self] in
_ = self?.runtime.searchNext()
self?.updateSearchCount()
}
searchBarView.onClose = { [weak self] in
self?.hideSearchBar(clearSearch: true)
}
if let keyword = searchState?.keyword, !keyword.isEmpty {
searchBarView.restoreKeyword(keyword)
updateSearchCount()
}
//
DispatchQueue.main.async { [weak self] in
self?.searchBarView.textField.becomeFirstResponder()
}
}
///
/// - Parameter clearSearch:
func hideSearchBar(clearSearch: Bool = false) {
guard isSearchBarVisible else { return }
isSearchBarVisible = false
searchBarView.textField.resignFirstResponder()
UIView.animate(withDuration: 0.3, animations: {
self.searchBarView.transform = CGAffineTransform(translationX: 0, y: -52)
}) { _ in
self.searchBarView.removeFromSuperview()
self.searchBarView.transform = .identity
self.readerView.searchBarView = nil
}
if clearSearch {
runtime.clearSearch()
}
}
///
private func updateSearchCount() {
guard let searchState else {
searchBarView.showNoResults()
return
}
if let index = searchState.currentMatchIndex {
searchBarView.updateMatchCount(current: index + 1, total: searchState.matches.count)
} else if searchState.matches.isEmpty {
searchBarView.showNoResults()
}
}
/// RDReaderView
func handleToolViewVisibilityChanged(isVisible: Bool) {
if isVisible {
if searchState != nil && !isSearchBarVisible {
showSearchBar()
}
} else {
if isSearchBarVisible {
hideSearchBar(clearSearch: false)
}
}
}
}

View File

@ -0,0 +1,251 @@
import UIKit
// MARK: -
///
/// /
final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
// MARK:
///
var onSearchSubmit: ((String) -> Void)?
///
var onSearchPrevious: (() -> Void)?
///
var onSearchNext: (() -> Void)?
///
var onClose: (() -> Void)?
// MARK: UI
private let containerView: UIView = {
let view = UIView()
view.layer.cornerRadius = 8
view.layer.masksToBounds = true
view.isAccessibilityElement = false
view.accessibilityElementsHidden = false
return view
}()
private let searchIcon: UIImageView = {
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFit
imageView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 14, weight: .medium)
if #available(iOS 13.0, *) {
imageView.image = UIImage(systemName: "magnifyingglass")
}
imageView.tintColor = .gray
return imageView
}()
let textField: UITextField = {
let field = UITextField()
field.placeholder = "搜索..."
field.font = UIFont.systemFont(ofSize: 15)
field.returnKeyType = .search
field.autocorrectionType = .no
field.autocapitalizationType = .none
field.clearButtonMode = .whileEditing
field.isAccessibilityElement = true
if #available(iOS 13.0, *) {
field.accessibilityTraits = .searchField
}
return field
}()
private let previousButton = RDEPUBReaderTintButton(type: .system)
private let nextButton = RDEPUBReaderTintButton(type: .system)
private let countLabel: UILabel = {
let label = UILabel()
label.font = UIFont.systemFont(ofSize: 13, weight: .medium)
label.textAlignment = .center
label.setContentHuggingPriority(.required, for: .horizontal)
label.setContentCompressionResistancePriority(.required, for: .horizontal)
return label
}()
private let closeButton = RDEPUBReaderTintButton(type: .system)
// MARK:
private let horizontalInset: CGFloat = 12
private let spacing: CGFloat = 6
private let containerHeight: CGFloat = 36
override init(frame: CGRect) {
super.init(frame: frame)
accessibilityIdentifier = "epub.reader.search.bar"
shouldGroupAccessibilityChildren = false
isAccessibilityElement = false
setupSubviews()
setupConstraints()
setupActions()
updateNavigationEnabled(false)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK:
override func lineFrame(in bounds: CGRect) -> CGRect {
CGRect(x: 0, y: bounds.height - 0.5, width: bounds.width, height: 0.5)
}
override func apply(theme: RDEPUBReaderTheme) {
super.apply(theme: theme)
backgroundColor = theme.toolBackgroundColor
containerView.backgroundColor = theme.toolControlBorderUnselectColor
searchIcon.tintColor = theme.toolControlTextColor
textField.textColor = theme.toolControlTextColor
textField.attributedPlaceholder = NSAttributedString(
string: "搜索...",
attributes: [.foregroundColor: theme.toolControlTextColor.withAlphaComponent(0.5)]
)
countLabel.textColor = theme.toolControlTextColor
previousButton.tintColor = theme.toolControlTextColor
nextButton.tintColor = theme.toolControlTextColor
closeButton.tintColor = theme.toolControlTextColor
}
// MARK:
///
func updateMatchCount(current: Int, total: Int) {
countLabel.text = "\(current)/\(total)"
updateNavigationEnabled(total > 0)
}
///
func showNoResults() {
countLabel.text = "0/0"
updateNavigationEnabled(false)
}
///
func showSearching() {
countLabel.text = "搜索中..."
updateNavigationEnabled(false)
}
///
func restoreKeyword(_ keyword: String) {
textField.text = keyword
}
// MARK:
private func setupSubviews() {
addSubview(containerView)
containerView.addSubview(searchIcon)
containerView.addSubview(textField)
addSubview(previousButton)
addSubview(nextButton)
addSubview(countLabel)
addSubview(closeButton)
if #available(iOS 13.0, *) {
previousButton.setImage(UIImage(systemName: "chevron.up")?.withRenderingMode(.alwaysTemplate), for: .normal)
nextButton.setImage(UIImage(systemName: "chevron.down")?.withRenderingMode(.alwaysTemplate), for: .normal)
closeButton.setImage(UIImage(systemName: "xmark")?.withRenderingMode(.alwaysTemplate), for: .normal)
} else {
previousButton.setTitle("", for: .normal)
nextButton.setTitle("", for: .normal)
closeButton.setTitle("", for: .normal)
}
previousButton.accessibilityIdentifier = "epub.reader.search.previous"
nextButton.accessibilityIdentifier = "epub.reader.search.next"
closeButton.accessibilityIdentifier = "epub.reader.search.close"
countLabel.accessibilityIdentifier = "epub.reader.search.count"
textField.accessibilityIdentifier = "epub.reader.search.field"
[previousButton, nextButton, closeButton].forEach { button in
button.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .medium)
button.tintColor = .black
button.setTitleColor(.black, for: .normal)
}
}
private func setupConstraints() {
[containerView, searchIcon, textField, previousButton, nextButton, countLabel, closeButton].forEach {
$0.translatesAutoresizingMaskIntoConstraints = false
}
NSLayoutConstraint.activate([
//
containerView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalInset),
containerView.centerYAnchor.constraint(equalTo: centerYAnchor),
containerView.heightAnchor.constraint(equalToConstant: containerHeight),
//
searchIcon.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 10),
searchIcon.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
searchIcon.widthAnchor.constraint(equalToConstant: 16),
//
textField.leadingAnchor.constraint(equalTo: searchIcon.trailingAnchor, constant: 6),
textField.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -8),
textField.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
textField.heightAnchor.constraint(equalToConstant: containerHeight - 4),
//
previousButton.leadingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: spacing),
previousButton.centerYAnchor.constraint(equalTo: centerYAnchor),
previousButton.widthAnchor.constraint(equalToConstant: 32),
previousButton.heightAnchor.constraint(equalToConstant: 32),
//
nextButton.leadingAnchor.constraint(equalTo: previousButton.trailingAnchor, constant: spacing),
nextButton.centerYAnchor.constraint(equalTo: centerYAnchor),
nextButton.widthAnchor.constraint(equalToConstant: 32),
nextButton.heightAnchor.constraint(equalToConstant: 32),
//
countLabel.leadingAnchor.constraint(equalTo: nextButton.trailingAnchor, constant: spacing),
countLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
countLabel.widthAnchor.constraint(greaterThanOrEqualToConstant: 44),
//
closeButton.leadingAnchor.constraint(equalTo: countLabel.trailingAnchor, constant: spacing),
closeButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -horizontalInset),
closeButton.centerYAnchor.constraint(equalTo: centerYAnchor),
closeButton.widthAnchor.constraint(equalToConstant: 32),
closeButton.heightAnchor.constraint(equalToConstant: 32)
])
}
private func setupActions() {
textField.addTarget(self, action: #selector(textFieldDidReturn), for: .editingDidEndOnExit)
previousButton.addTarget(self, action: #selector(previousAction), for: .touchUpInside)
nextButton.addTarget(self, action: #selector(nextAction), for: .touchUpInside)
closeButton.addTarget(self, action: #selector(closeAction), for: .touchUpInside)
}
private func updateNavigationEnabled(_ enabled: Bool) {
previousButton.isEnabled = enabled
previousButton.alpha = enabled ? 1 : 0.45
nextButton.isEnabled = enabled
nextButton.alpha = enabled ? 1 : 0.45
}
@objc private func textFieldDidReturn() {
guard let keyword = textField.text, !keyword.isEmpty else { return }
onSearchSubmit?(keyword)
textField.resignFirstResponder()
}
@objc private func previousAction() {
onSearchPrevious?()
}
@objc private func nextAction() {
onSearchNext?()
}
@objc private func closeAction() {
onClose?()
}
}

View File

@ -10,8 +10,11 @@ public final class RDEPUBReaderTopToolView: RDEPUBReaderToolView {
var onBack: (() -> Void)?
///
var onToggleBookmark: (() -> Void)?
///
var onSearch: (() -> Void)?
private let backButton = RDEPUBReaderTintButton(type: .system)
private let searchButton = RDEPUBReaderTintButton(type: .system)
private let bookmarkButton = RDEPUBReaderTintButton(type: .system)
private let titleLabel: UILabel = {
let label = UILabel()
@ -27,14 +30,17 @@ public final class RDEPUBReaderTopToolView: RDEPUBReaderToolView {
accessibilityIdentifier = "epub.reader.topToolbar"
self.backgroundColor = .white
addSubview(backButton)
addSubview(searchButton)
addSubview(bookmarkButton)
addSubview(titleLabel)
backButton.translatesAutoresizingMaskIntoConstraints = false
searchButton.translatesAutoresizingMaskIntoConstraints = false
bookmarkButton.translatesAutoresizingMaskIntoConstraints = false
titleLabel.translatesAutoresizingMaskIntoConstraints = false
backButton.addTarget(self, action: #selector(backAction), for: .touchUpInside)
searchButton.addTarget(self, action: #selector(searchAction), for: .touchUpInside)
bookmarkButton.addTarget(self, action: #selector(bookmarkAction), for: .touchUpInside)
NSLayoutConstraint.activate([
@ -50,17 +56,26 @@ public final class RDEPUBReaderTopToolView: RDEPUBReaderToolView {
bookmarkButton.widthAnchor.constraint(equalToConstant: 44),
bookmarkButton.heightAnchor.constraint(equalToConstant: 44),
searchButton.trailingAnchor.constraint(equalTo: bookmarkButton.leadingAnchor, constant: -4),
searchButton.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 4),
searchButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4),
searchButton.widthAnchor.constraint(equalToConstant: 44),
searchButton.heightAnchor.constraint(equalToConstant: 44),
titleLabel.leadingAnchor.constraint(equalTo: backButton.trailingAnchor, constant: 8),
titleLabel.trailingAnchor.constraint(equalTo: bookmarkButton.leadingAnchor, constant: -8),
titleLabel.trailingAnchor.constraint(equalTo: searchButton.leadingAnchor, constant: -8),
titleLabel.centerYAnchor.constraint(equalTo: backButton.centerYAnchor)
])
if #available(iOS 13.0, *) {
backButton.setImage(UIImage(systemName: "chevron.left")?.withRenderingMode(.alwaysTemplate), for: .normal)
searchButton.setImage(UIImage(systemName: "magnifyingglass")?.withRenderingMode(.alwaysTemplate), for: .normal)
} else {
backButton.setTitle("返回", for: .normal)
searchButton.setTitle("搜索", for: .normal)
}
backButton.accessibilityIdentifier = "epub.reader.back"
searchButton.accessibilityIdentifier = "epub.reader.search"
bookmarkButton.accessibilityIdentifier = "epub.reader.bookmark"
titleLabel.accessibilityIdentifier = "epub.reader.title"
updateBookmarkButtonAppearance()
@ -76,12 +91,14 @@ public final class RDEPUBReaderTopToolView: RDEPUBReaderToolView {
override public func apply(theme: RDEPUBReaderTheme) {
super.apply(theme: theme)
titleLabel.textColor = .black
backButton.tintColor = .black
bookmarkButton.tintColor = .black
titleLabel.textColor = theme.toolControlTextColor
backButton.tintColor = theme.toolControlTextColor
searchButton.tintColor = theme.toolControlTextColor
bookmarkButton.tintColor = theme.toolControlTextColor
if #unavailable(iOS 13.0) {
backButton.setTitleColor(.black, for: .normal)
bookmarkButton.setTitleColor(.black, for: .normal)
backButton.setTitleColor(theme.toolControlTextColor, for: .normal)
searchButton.setTitleColor(theme.toolControlTextColor, for: .normal)
bookmarkButton.setTitleColor(theme.toolControlTextColor, for: .normal)
}
updateBookmarkButtonAppearance()
}
@ -104,6 +121,10 @@ public final class RDEPUBReaderTopToolView: RDEPUBReaderToolView {
onBack?()
}
@objc private func searchAction() {
onSearch?()
}
@objc private func bookmarkAction() {
onToggleBookmark?()
}

View File

@ -146,6 +146,19 @@ public final class RDURLReaderController: UIViewController {
}
}
/// Demo
///
/// - Parameter keyword:
public func performDemoSearch(keyword: String) {
guard let readerController else { return }
readerController.showSearchBar()
//
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
readerController.search(keyword: keyword)
readerController.updateSearchCount()
}
}
///
/// Child View Controller
private func embedReaderController() {

View File

@ -19,12 +19,15 @@ final class RDEPUBReaderChromeCoordinator {
context.controller
}
///
///
func makeTopToolView() -> RDEPUBReaderTopToolView {
let toolView = RDEPUBReaderTopToolView()
toolView.onBack = { [weak self] in
self?.handleBackAction()
}
toolView.onSearch = { [weak self] in
self?.toggleSearchBar()
}
toolView.onToggleBookmark = { [weak self] in
_ = self?.context.runtime?.toggleBookmark()
}
@ -76,6 +79,7 @@ final class RDEPUBReaderChromeCoordinator {
controller.configuration.allowsHighlights && !controller.activeHighlights.isEmpty
)
controller.updateBookmarkChrome()
updateSearchBar()
}
///
@ -140,6 +144,29 @@ final class RDEPUBReaderChromeCoordinator {
controller.present(navigationController, animated: true)
}
/// /
func toggleSearchBar() {
guard let controller else { return }
if controller.isSearchBarVisible {
controller.hideSearchBar()
} else {
controller.showSearchBar()
}
}
///
func updateSearchBar() {
guard let controller else { return }
controller.searchBarView.apply(theme: controller.configuration.theme)
if let searchState = controller.searchState {
if let index = searchState.currentMatchIndex {
controller.searchBarView.updateMatchCount(current: index + 1, total: searchState.matches.count)
} else if searchState.matches.isEmpty {
controller.searchBarView.showNoResults()
}
}
}
/// pop dismiss
func handleBackAction() {
guard let controller else { return }

View File

@ -50,6 +50,9 @@ final class RDEPUBReaderContext {
var paginator: RDEPUBPaginator?
///
var searchState: RDEPUBSearchState?
/// BookPageMap
/// map
var pendingFullPageMap: RDEPUBBookPageMap?
///
var lastTextPaginationPageSize: CGSize?
/// OperationQueue
@ -205,18 +208,6 @@ final class RDEPUBReaderContext {
}
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,
@ -226,15 +217,53 @@ final class RDEPUBReaderContext {
} else {
contentHash = ""
}
return chapterCacheKey(
forSpineIndex: spineIndex,
precomputedContentHash: contentHash,
renderSignature: currentRenderSignature()
)
}
return RDEPUBChapterCacheKey(
/// 使 contentHash HTML SHA-256
///
func chapterCacheKey(forSpineIndex spineIndex: Int, precomputedContentHash: String) -> RDEPUBChapterCacheKey {
chapterCacheKey(
forSpineIndex: spineIndex,
precomputedContentHash: precomputedContentHash,
renderSignature: currentRenderSignature()
)
}
/// 使 contentHash
/// live context
func chapterCacheKey(
forSpineIndex spineIndex: Int,
precomputedContentHash: String,
renderSignature: String
) -> RDEPUBChapterCacheKey {
RDEPUBChapterCacheKey(
bookID: currentBookIdentifier ?? "",
spineIndex: spineIndex,
renderSignature: renderSignature,
chapterContentHash: contentHash
chapterContentHash: precomputedContentHash
)
}
///
func currentRenderSignature() -> String {
let style = currentTextRenderStyle()
let pageSize = currentTextPageSize()
let layoutConfig = currentTextLayoutConfig(pageSize: pageSize)
return [
style.font.fontName,
"\(style.font.pointSize)",
"\(configuration.lineHeightMultiple)",
"\(style.lineSpacing)",
layoutConfig.cacheSignature,
"\(RDEPUBChapterSummary.currentSchemaVersion)"
].joined(separator: "|")
}
func chapterSummary(forSpineIndex spineIndex: Int) -> RDEPUBChapterSummary? {
runtime?.summaryDiskCache.read(for: chapterCacheKey(forSpineIndex: spineIndex))
}

View File

@ -10,6 +10,8 @@ import Foundation
/// -
final class RDEPUBReaderPaginationCoordinator {
private let backgroundInteractionCooldown: CFAbsoluteTime = 0.8
/// N pageMap
static var pageMapRefreshInterval: Int = 32
private unowned let context: RDEPUBReaderContext
@ -80,6 +82,7 @@ final class RDEPUBReaderPaginationCoordinator {
guard let controller = context.controller else { return }
context.textBook = textBook
context.bookPageMap = nil
context.pendingFullPageMap = nil
let snapshot = controller.nativeTextSnapshot(from: textBook)
context.replaceActiveSnapshot(snapshot)
@ -99,6 +102,7 @@ final class RDEPUBReaderPaginationCoordinator {
guard context.controller != nil else { return }
context.textBook = nil
context.bookPageMap = nil
context.pendingFullPageMap = nil
context.replaceActiveSnapshot(snapshot)
guard !snapshot.pages.isEmpty else {
@ -388,6 +392,7 @@ final class RDEPUBReaderPaginationCoordinator {
let pageSize = context.currentTextPageSize()
let layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
let style = context.currentTextRenderStyle()
let renderSignature = context.currentRenderSignature()
let allBuildableIndices = allBuildableSpineIndices(in: publication)
let summaryDiskCache = context.runtime?.summaryDiskCache
let workerCount = max(1, context.configuration.metadataParsingConcurrency)
@ -397,10 +402,30 @@ final class RDEPUBReaderPaginationCoordinator {
DispatchQueue.global(qos: .utility).async { [weak self] in
guard let self else { return }
guard context.controller != nil else { return }
// contentHash + SHA-256
let prewarmStart = CFAbsoluteTimeGetCurrent()
var contentHashBySpineIndex: [Int: String] = [:]
for spineIndex in allBuildableIndices {
guard let href = publication.spine.indices.contains(spineIndex)
? publication.spine[spineIndex].href : nil,
let html = parser.htmlString(forRelativePath: href) else {
contentHashBySpineIndex[spineIndex] = ""
continue
}
contentHashBySpineIndex[spineIndex] = html.sha256Hex
}
let prewarmMs = Int((CFAbsoluteTimeGetCurrent() - prewarmStart) * 1000)
RDEPUBBackgroundTrace.log("MetadataParse", "prewarmHashMs=\(prewarmMs) chapters=\(allBuildableIndices.count)")
let catalog = allBuildableIndices.map { spineIndex in
let item = publication.spine[spineIndex]
return (
key: context.chapterCacheKey(forSpineIndex: spineIndex),
key: context.chapterCacheKey(
forSpineIndex: spineIndex,
precomputedContentHash: contentHashBySpineIndex[spineIndex] ?? "",
renderSignature: renderSignature
),
spineIndex: spineIndex,
href: item.href,
title: item.title
@ -438,6 +463,7 @@ final class RDEPUBReaderPaginationCoordinator {
let wallClockStart = CFAbsoluteTimeGetCurrent()
var totalRenderMs: Double = 0
var totalWriteMs: Double = 0
var totalMergeMs: Double = 0
var completedChapters = 0
var failedChapters = 0
let timingLock = NSLock()
@ -447,6 +473,8 @@ final class RDEPUBReaderPaginationCoordinator {
queue.qualityOfService = .utility
queue.maxConcurrentOperationCount = workerCount
let refreshInterval = RDEPUBReaderPaginationCoordinator.pageMapRefreshInterval
for (offset, spineIndex) in uncachedSpineIndices.enumerated() {
queue.addOperation {
guard context.controller != nil,
@ -471,7 +499,12 @@ final class RDEPUBReaderPaginationCoordinator {
let renderElapsed = (CFAbsoluteTimeGetCurrent() - renderStart) * 1000
let chapter = result.chapter
let cacheKey = context.chapterCacheKey(forSpineIndex: spineIndex)
let precomputedHash = contentHashBySpineIndex[spineIndex] ?? ""
let cacheKey = context.chapterCacheKey(
forSpineIndex: spineIndex,
precomputedContentHash: precomputedHash,
renderSignature: renderSignature
)
let summary = RDEPUBChapterSummary(
pageRanges: chapter.pages.map { .init(location: $0.contentRange.location, length: $0.contentRange.length) },
pageCount: chapter.pages.count,
@ -500,17 +533,24 @@ final class RDEPUBReaderPaginationCoordinator {
guard let renderResult else { return }
var partialMap: RDEPUBBookPageMap?
// pageMap
var snapshot: [Int: RDEPUBChapterSummary]?
resultLock.lock()
summariesBySpineIndex[spineIndex] = renderResult
totalResolvedCount += 1
if totalResolvedCount - lastAppliedCount >= 32 || totalResolvedCount == allBuildableIndices.count {
if totalResolvedCount - lastAppliedCount >= refreshInterval || totalResolvedCount == allBuildableIndices.count {
lastAppliedCount = totalResolvedCount
partialMap = self.buildPageMap(from: catalog, summaries: summariesBySpineIndex)
snapshot = summariesBySpineIndex
}
resultLock.unlock()
if let partialMap {
if let snapshot {
let mergeStart = CFAbsoluteTimeGetCurrent()
let partialMap = self.buildPageMap(from: catalog, summaries: snapshot)
let mergeElapsed = (CFAbsoluteTimeGetCurrent() - mergeStart) * 1000
timingLock.lock()
totalMergeMs += mergeElapsed
timingLock.unlock()
DispatchQueue.main.async {
guard context.paginationToken == token,
context.controller != nil else { return }
@ -532,6 +572,7 @@ final class RDEPUBReaderPaginationCoordinator {
timingLock.lock()
let renderTotal = Int(totalRenderMs)
let writeTotal = Int(totalWriteMs)
let mergeTotal = Int(totalMergeMs)
let rendered = completedChapters
let failed = failedChapters
timingLock.unlock()
@ -539,7 +580,8 @@ final class RDEPUBReaderPaginationCoordinator {
RDEPUBBackgroundTrace.log(
"MetadataParse",
"timing wallClockMs=\(wallClockMs) chapters=\(rendered) failed=\(failed) " +
"renderTotalMs=\(renderTotal) writeTotalMs=\(writeTotal) avgRenderMs=\(avgRenderMs) concurrency=\(workerCount)"
"prewarmHashMs=\(prewarmMs) renderTotalMs=\(renderTotal) writeTotalMs=\(writeTotal) " +
"mergeTotalMs=\(mergeTotal) avgRenderMs=\(avgRenderMs) concurrency=\(workerCount)"
)
context.lastMetadataParseWallClockMs = wallClockMs
context.lastMetadataParseConcurrency = workerCount
@ -550,10 +592,12 @@ final class RDEPUBReaderPaginationCoordinator {
return
}
let finalMergeStart = CFAbsoluteTimeGetCurrent()
let pageMap = self.buildPageMap(from: catalog, summaries: summariesBySpineIndex)
let finalMergeMs = Int((CFAbsoluteTimeGetCurrent() - finalMergeStart) * 1000)
RDEPUBBackgroundTrace.log(
"MetadataParse",
"complete chapters=\(pageMap.totalChapters) pages=\(pageMap.totalPages)"
"complete chapters=\(pageMap.totalChapters) pages=\(pageMap.totalPages) finalMergeMs=\(finalMergeMs)"
)
DispatchQueue.main.async {
@ -565,15 +609,23 @@ final class RDEPUBReaderPaginationCoordinator {
}
private func restoreBookPageMapIfPossible(publication: RDEPUBPublication) -> RDEPUBBookPageMap? {
guard let summaryDiskCache = context.runtime?.summaryDiskCache else {
guard let summaryDiskCache = context.runtime?.summaryDiskCache,
let parser = context.parser else {
return nil
}
let renderSignature = context.currentRenderSignature()
let catalog = allBuildableSpineIndices(in: publication).map { spineIndex in
let item = publication.spine[spineIndex]
let href = item.href
let contentHash = parser.htmlString(forRelativePath: href)?.sha256Hex ?? ""
return (
key: context.chapterCacheKey(forSpineIndex: spineIndex),
key: context.chapterCacheKey(
forSpineIndex: spineIndex,
precomputedContentHash: contentHash,
renderSignature: renderSignature
),
spineIndex: spineIndex,
href: item.href,
href: href,
title: item.title
)
}

View File

@ -52,6 +52,7 @@ final class RDEPUBReaderRuntime {
context.readingSession = nil
context.textBook = nil
context.bookPageMap = nil
context.pendingFullPageMap = nil
context.activeBookmarks = []
context.activeHighlights = []
context.searchState = nil
@ -312,31 +313,37 @@ final class RDEPUBReaderRuntime {
}
func refreshBookPageMapInPlace(_ bookPageMap: RDEPUBBookPageMap) {
guard let readerView = context.readerView,
// map
// map
context.pendingFullPageMap = bookPageMap
}
/// BookPageMap
func applyPendingFullPageMapIfNeeded() {
guard let pendingMap = context.pendingFullPageMap,
let readerView = context.readerView,
let controller = context.controller else { return }
let currentPage = max(readerView.currentPage, 0)
context.pendingFullPageMap = nil
// map
let currentLocation = locationCoordinator.currentVisibleLocation()
// map
context.textBook = nil
context.bookPageMap = bookPageMap
context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap))
context.bookPageMap = pendingMap
context.replaceActiveSnapshot(makeSnapshot(from: pendingMap))
//
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)
}
}
// map
if let currentLocation {
locationCoordinator.persist(location: currentLocation)
} else if let resolvedLocation = controller.resolvedTextLocation(forPageNumber: currentPage + 1) {
locationCoordinator.persist(location: resolvedLocation)
let newPageNumber = controller.pageNumber(for: currentLocation) ?? (readerView.currentPage + 1)
let newPage = max(0, newPageNumber - 1)
readerView.reloadPageCountOnly()
if newPage != readerView.currentPage {
readerView.transitionToPage(pageNum: newPage, animated: false)
}
} else {
readerView.reloadPageCountOnly()
}
}
@ -530,6 +537,7 @@ final class RDEPUBReaderRuntime {
func clearOnDemandPageModeState() {
chapterRuntimeStore.invalidateAllForSettingsChange()
context.bookPageMap = nil
context.pendingFullPageMap = nil
}
private func makeSnapshot(from bookPageMap: RDEPUBBookPageMap) -> RDEPUBReadingSession.PaginationSnapshot {

View File

@ -55,6 +55,7 @@ extension RDReaderView {
collectionView.isUserInteractionEnabled = true
pageViewController.view.isUserInteractionEnabled = true
}
onToolViewVisibilityChanged?(isShowToolView)
}
///

View File

@ -168,6 +168,10 @@ public class RDReaderView: UIView {
public var currentDisplayType: RDReaderView.DisplayType = .pageCurl
/// / 0.3
public var toolViewAnimationDuration: TimeInterval = 0.3
///
var onToolViewVisibilityChanged: ((Bool) -> Void)?
/// tapCenter
var searchBarView: UIView?
///
public var landscapeDualPageEnabled: Bool = false
/// /
@ -777,6 +781,9 @@ extension RDReaderView: UIGestureRecognizerDelegate {
if let bottomToolView, isHitView(touch.view, inside: bottomToolView, point: point) {
return false
}
if let searchBarView, isHitView(touch.view, inside: searchBarView, point: point) {
return false
}
return true
}

159
scripts/run_ui_regression.sh Executable file
View File

@ -0,0 +1,159 @@
#!/usr/bin/env bash
set -u
set -o pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PROJECT_PATH="${PROJECT_PATH:-$ROOT_DIR/ReadViewDemo/ReadViewDemo.xcodeproj}"
SCHEME_NAME="${SCHEME_NAME:-ReadViewDemo}"
CONFIGURATION="${CONFIGURATION:-Debug}"
SIMULATOR_NAME="${SIMULATOR_NAME:-iPhone 16 Plus}"
DESTINATION="${DESTINATION:-platform=iOS Simulator,name=${SIMULATOR_NAME}}"
RESULTS_ROOT="${RESULTS_ROOT:-$ROOT_DIR/.artifacts/ui-tests}"
DERIVED_DATA_PATH="${DERIVED_DATA_PATH:-$RESULTS_ROOT/DerivedData}"
ONLY_TESTING="${ONLY_TESTING:-}"
TEST_PLAN="${TEST_PLAN:-}"
TEST_TYPE_ARG="${1:-}"
TEST_TYPE="${TEST_TYPE:-${TEST_TYPE_ARG:-quick}}"
TIMESTAMP="$(date +"%Y%m%d-%H%M%S")"
RUN_DIR="$RESULTS_ROOT/$TIMESTAMP"
XCRESULT_PATH="$RUN_DIR/ReadViewDemoUITests.xcresult"
RAW_LOG_PATH="$RUN_DIR/xcodebuild.log"
REPORT_MD_PATH="$RUN_DIR/UI-Test-Report.md"
REPORT_JSON_PATH="$RUN_DIR/ui-test-results.json"
REPORT_TXT_PATH="$RUN_DIR/summary.txt"
SUMMARY_SCRIPT="$ROOT_DIR/scripts/summarize_ui_results.py"
TARGET_NAME="ReadViewDemoUITests"
QUICK_TESTS=(
BookmarkManagementTests
BookmarkTests
DisplayTypeTests
ErrorAndEdgeCaseTests
HighlightsManagementTests
LocationPersistenceTests
PageNavigationTests
ReaderAnnotationExtendedTests
ReaderAnnotationTests
ReaderOpenCloseTests
ReaderToolbarTests
SearchTests
SelectionMenuTests
SettingsEffectTests
SettingsExtendedTests
SettingsPanelTests
TOCInteractionTests
TableOfContentsTests
ToolbarStateTests
)
LARGE_BOOK_TESTS=(
ConfigurableWindowTests
ConcurrentParsingTests
LargeBookOnDemandTests
)
PERFORMANCE_TESTS=(
FanrenParseTimeTest
MetadataParseBenchmarkTests
)
mkdir -p "$RUN_DIR" "$DERIVED_DATA_PATH"
append_only_testing_items() {
local raw_list="$1"
local item
IFS=',' read -r -a ONLY_TESTING_ITEMS <<< "$raw_list"
for item in "${ONLY_TESTING_ITEMS[@]}"; do
local trimmed_item
trimmed_item="$(echo "$item" | xargs)"
if [[ -n "$trimmed_item" ]]; then
XCODEBUILD_ARGS+=(-only-testing:"$trimmed_item")
fi
done
}
apply_test_type_filter() {
local selected_type="$1"
local test_name
case "$selected_type" in
quick)
for test_name in "${QUICK_TESTS[@]}"; do
XCODEBUILD_ARGS+=(-only-testing:"$TARGET_NAME/$test_name")
done
;;
large-book)
for test_name in "${LARGE_BOOK_TESTS[@]}"; do
XCODEBUILD_ARGS+=(-only-testing:"$TARGET_NAME/$test_name")
done
;;
performance)
for test_name in "${PERFORMANCE_TESTS[@]}"; do
XCODEBUILD_ARGS+=(-only-testing:"$TARGET_NAME/$test_name")
done
;;
all)
;;
*)
echo "Unsupported TEST_TYPE: $selected_type" >&2
echo "Supported values: quick, large-book, performance, all" >&2
exit 2
;;
esac
}
echo "UI regression run"
echo " project: $PROJECT_PATH"
echo " scheme: $SCHEME_NAME"
echo " configuration:$CONFIGURATION"
echo " destination: $DESTINATION"
echo " test type: $TEST_TYPE"
echo " results: $RUN_DIR"
XCODEBUILD_ARGS=(
xcodebuild
test
-project "$PROJECT_PATH"
-scheme "$SCHEME_NAME"
-configuration "$CONFIGURATION"
-destination "$DESTINATION"
-resultBundlePath "$XCRESULT_PATH"
-derivedDataPath "$DERIVED_DATA_PATH"
)
if [[ -n "$TEST_PLAN" ]]; then
XCODEBUILD_ARGS+=(-testPlan "$TEST_PLAN")
fi
if [[ -n "$ONLY_TESTING" ]]; then
append_only_testing_items "$ONLY_TESTING"
else
apply_test_type_filter "$TEST_TYPE"
fi
set +e
"${XCODEBUILD_ARGS[@]}" 2>&1 | tee "$RAW_LOG_PATH"
TEST_EXIT_CODE=${PIPESTATUS[0]}
set -e
python3 "$SUMMARY_SCRIPT" \
--xcresult "$XCRESULT_PATH" \
--raw-log "$RAW_LOG_PATH" \
--output-md "$REPORT_MD_PATH" \
--output-json "$REPORT_JSON_PATH" \
--output-txt "$REPORT_TXT_PATH" \
--test-type "$TEST_TYPE" \
--xcodebuild-exit-code "$TEST_EXIT_CODE"
echo
cat "$REPORT_TXT_PATH"
echo
echo "Artifacts"
echo " summary: $REPORT_TXT_PATH"
echo " report: $REPORT_MD_PATH"
echo " json: $REPORT_JSON_PATH"
echo " xcresult: $XCRESULT_PATH"
echo " raw log: $RAW_LOG_PATH"
exit "$TEST_EXIT_CODE"

465
scripts/summarize_ui_results.py Executable file
View File

@ -0,0 +1,465 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
from collections import Counter, defaultdict
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
@dataclass
class FailureItem:
test_name: str
target_name: str
category: str
summary: str
failure_text: str
test_identifier: str | None = None
def run_json_command(command: list[str]) -> dict[str, Any] | list[Any] | None:
try:
completed = subprocess.run(
command,
check=True,
capture_output=True,
text=True,
)
except (OSError, subprocess.CalledProcessError):
return None
stdout = completed.stdout.strip()
if not stdout:
return None
try:
return json.loads(stdout)
except json.JSONDecodeError:
return None
def compact_text(text: str) -> str:
return re.sub(r"\s+", " ", text).strip()
def summarize_failure_text(text: str) -> str:
compact = compact_text(text)
if not compact:
return "No failure message captured"
for delimiter in (" - ", ". ", "\n", ""):
if delimiter in compact:
head = compact.split(delimiter, 1)[0].strip()
if len(head) >= 12:
return head[:220]
return compact[:220]
def classify_failure(text: str, test_name: str) -> str:
haystack = f"{test_name} {text}".lower()
if any(token in haystack for token in [
"timed out",
"timeout",
"failed to fulfill",
"waitfordemoreaderstate",
"waitforexistence",
"等待",
"超时",
]):
return "Timeout"
if any(token in haystack for token in [
"no matches found",
"failed to find",
"does not exist",
"existence",
"not hittable",
"不存在",
"未出现",
]):
return "Element Missing"
if any(token in haystack for token in [
"simulator",
"lost connection",
"test runner exited",
"application state",
"quit unexpectedly",
"connection interrupted",
"xcodebuild",
]):
return "Environment"
if any(token in haystack for token in [
"page",
"pagination",
"bookpagemap",
"parsems",
"readerstate",
"location",
"页码",
"分页",
"位置",
]):
return "Navigation/State"
if any(token in haystack for token in [
"xctassert",
"assertion",
"xctfail",
"failed:",
]):
return "Assertion Failure"
return "Functional Failure"
def normalize_root_cause(text: str) -> str:
compact = summarize_failure_text(text).lower()
compact = re.sub(r"\b\d+\b", "<n>", compact)
compact = re.sub(r"'[^']+'", "'<value>'", compact)
compact = re.sub(r'"[^"]+"', '"<value>"', compact)
return compact[:180]
def load_summary_from_xcresult(xcresult_path: Path) -> dict[str, Any] | None:
return run_json_command([
"xcrun",
"xcresulttool",
"get",
"test-results",
"summary",
"--path",
str(xcresult_path),
"--compact",
])
def load_tests_from_xcresult(xcresult_path: Path) -> dict[str, Any] | None:
return run_json_command([
"xcrun",
"xcresulttool",
"get",
"test-results",
"tests",
"--path",
str(xcresult_path),
"--compact",
])
def flatten_failed_test_nodes(node: dict[str, Any], out: list[dict[str, Any]]) -> None:
node_type = node.get("nodeType")
result = node.get("result")
if node_type == "Test Case" and result == "Failed":
out.append(node)
for child in node.get("children", []) or []:
flatten_failed_test_nodes(child, out)
def extract_failures(
summary: dict[str, Any] | None,
tests_payload: dict[str, Any] | None,
raw_log_path: Path | None,
) -> list[FailureItem]:
failures: list[FailureItem] = []
raw_failures = None
if summary:
raw_failures = summary.get("testFailures")
if isinstance(raw_failures, dict):
raw_failures = [raw_failures]
if isinstance(raw_failures, list):
for item in raw_failures:
if not isinstance(item, dict):
continue
test_name = item.get("testName") or "Unknown test"
target_name = item.get("targetName") or "Unknown target"
failure_text = item.get("failureText") or ""
failures.append(FailureItem(
test_name=test_name,
target_name=target_name,
category=classify_failure(failure_text, test_name),
summary=summarize_failure_text(failure_text),
failure_text=compact_text(failure_text),
test_identifier=item.get("testIdentifierString") or item.get("testIdentifierURL"),
))
if failures:
return dedupe_failures(failures)
if tests_payload:
failed_nodes: list[dict[str, Any]] = []
for node in tests_payload.get("testNodes", []) or []:
flatten_failed_test_nodes(node, failed_nodes)
for node in failed_nodes:
test_name = node.get("name") or "Unknown test"
details = node.get("details") or ""
failures.append(FailureItem(
test_name=test_name,
target_name="Unknown target",
category=classify_failure(details, test_name),
summary=summarize_failure_text(details),
failure_text=compact_text(details),
test_identifier=node.get("nodeIdentifier") or node.get("nodeIdentifierURL"),
))
if failures:
return dedupe_failures(failures)
if raw_log_path and raw_log_path.exists():
log_text = raw_log_path.read_text(encoding="utf-8", errors="replace")
matches = re.findall(
r"Test Case '-\[(.*?)\]' failed .*?\n(.*?)(?=\nTest Case '-\[|\Z)",
log_text,
flags=re.S,
)
for test_name, block in matches:
failure_text = compact_text(block)
failures.append(FailureItem(
test_name=test_name,
target_name="Unknown target",
category=classify_failure(failure_text, test_name),
summary=summarize_failure_text(failure_text),
failure_text=failure_text,
))
return dedupe_failures(failures)
def dedupe_failures(failures: list[FailureItem]) -> list[FailureItem]:
deduped: dict[tuple[str, str], FailureItem] = {}
for failure in failures:
key = (failure.test_name, failure.summary)
deduped.setdefault(key, failure)
return list(deduped.values())
def build_root_causes(failures: list[FailureItem]) -> list[dict[str, Any]]:
grouped: dict[str, list[FailureItem]] = defaultdict(list)
for failure in failures:
grouped[normalize_root_cause(failure.failure_text)].append(failure)
ranked = sorted(
grouped.items(),
key=lambda item: (-len(item[1]), item[0]),
)
result = []
for normalized_text, items in ranked[:5]:
result.append({
"summary": items[0].summary,
"category": items[0].category,
"count": len(items),
"tests": [failure.test_name for failure in items[:5]],
})
return result
def build_report_payload(
args: argparse.Namespace,
summary: dict[str, Any] | None,
tests_payload: dict[str, Any] | None,
failures: list[FailureItem],
) -> dict[str, Any]:
device_summaries = []
if summary:
devices_and_configurations = summary.get("devicesAndConfigurations")
if isinstance(devices_and_configurations, dict):
devices_and_configurations = [devices_and_configurations]
if isinstance(devices_and_configurations, list):
for item in devices_and_configurations:
if not isinstance(item, dict):
continue
device = item.get("device") or {}
config = item.get("testPlanConfiguration") or {}
device_summaries.append({
"device_name": device.get("deviceName"),
"model_name": device.get("modelName"),
"os_version": device.get("osVersion"),
"configuration_name": config.get("configurationName"),
"passed_tests": item.get("passedTests"),
"failed_tests": item.get("failedTests"),
})
categories = Counter(failure.category for failure in failures)
payload = {
"generated_at": datetime.now(timezone.utc).isoformat(),
"xcodebuild_exit_code": args.xcodebuild_exit_code,
"test_type": args.test_type,
"artifacts": {
"xcresult": str(Path(args.xcresult).resolve()) if args.xcresult else None,
"raw_log": str(Path(args.raw_log).resolve()) if args.raw_log else None,
},
"summary": {
"title": summary.get("title") if summary else None,
"environment_description": summary.get("environmentDescription") if summary else None,
"result": summary.get("result") if summary else ("Failed" if failures else "Passed"),
"total_test_count": summary.get("totalTestCount") if summary else None,
"passed_tests": summary.get("passedTests") if summary else None,
"failed_tests": summary.get("failedTests") if summary else len(failures),
"skipped_tests": summary.get("skippedTests") if summary else None,
"devices": device_summaries,
},
"failure_categories": dict(sorted(categories.items(), key=lambda item: (-item[1], item[0]))),
"top_root_causes": build_root_causes(failures),
"failures": [asdict(failure) for failure in failures],
"token_saving_notes": [
"Raw xcodebuild output is intentionally omitted from this report.",
"Use the xcresult or raw log paths only when a single failure needs deeper inspection.",
"Failures are deduplicated by test name and compact summary.",
],
}
if tests_payload and not payload["summary"]["total_test_count"]:
flat_failed_nodes: list[dict[str, Any]] = []
for node in tests_payload.get("testNodes", []) or []:
flatten_failed_test_nodes(node, flat_failed_nodes)
payload["summary"]["failed_tests"] = len(flat_failed_nodes)
return payload
def render_markdown(payload: dict[str, Any]) -> str:
summary = payload["summary"]
lines = [
"# UI Test Report",
"",
"## Snapshot",
"",
f"- Test type: `{payload.get('test_type')}`",
f"- Result: `{summary.get('result')}`",
f"- xcodebuild exit code: `{payload['xcodebuild_exit_code']}`",
f"- Total tests: `{summary.get('total_test_count')}`",
f"- Passed: `{summary.get('passed_tests')}`",
f"- Failed: `{summary.get('failed_tests')}`",
f"- Skipped: `{summary.get('skipped_tests')}`",
]
if summary.get("environment_description"):
lines.append(f"- Environment: `{summary['environment_description']}`")
artifacts = payload["artifacts"]
lines.extend([
f"- xcresult: `{artifacts.get('xcresult')}`",
f"- raw log: `{artifacts.get('raw_log')}`",
"",
])
if payload["failure_categories"]:
lines.extend([
"## Failure Categories",
"",
])
for category, count in payload["failure_categories"].items():
lines.append(f"- `{category}`: {count}")
lines.append("")
if payload["top_root_causes"]:
lines.extend([
"## Top Root Causes",
"",
])
for item in payload["top_root_causes"]:
lines.append(f"- `{item['category']}` x{item['count']}: {item['summary']}")
lines.append("")
lines.extend([
"## Failed Tests",
"",
])
if payload["failures"]:
for failure in payload["failures"]:
lines.append(
f"- `{failure['test_name']}` [{failure['category']}] {failure['summary']}"
)
else:
lines.append("- None")
lines.append("")
lines.extend([
"## Token-Saving Notes",
"",
])
for note in payload["token_saving_notes"]:
lines.append(f"- {note}")
lines.append("")
return "\n".join(lines)
def render_text_summary(payload: dict[str, Any]) -> str:
summary = payload["summary"]
total = summary.get("total_test_count")
passed = summary.get("passed_tests")
failed = summary.get("failed_tests")
skipped = summary.get("skipped_tests")
lines = [
"UI test summary",
f" type: {payload.get('test_type')}",
f" result: {summary.get('result')}",
f" tests: total={total} passed={passed} failed={failed} skipped={skipped}",
]
if payload["failure_categories"]:
category_text = ", ".join(
f"{name}={count}" for name, count in payload["failure_categories"].items()
)
lines.append(f" categories: {category_text}")
if payload["top_root_causes"]:
top = payload["top_root_causes"][0]
lines.append(f" top root cause: {top['summary']} ({top['count']} tests)")
return "\n".join(lines)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Summarize UI test xcresult output into a low-token report.")
parser.add_argument("--xcresult", help="Path to .xcresult bundle", default="")
parser.add_argument("--raw-log", help="Path to xcodebuild raw log", default="")
parser.add_argument("--output-md", required=True, help="Markdown report output path")
parser.add_argument("--output-json", required=True, help="JSON report output path")
parser.add_argument("--output-txt", required=True, help="Plain-text summary output path")
parser.add_argument("--test-type", default="unknown", help="Selected UI test group")
parser.add_argument("--xcodebuild-exit-code", type=int, required=True, help="Exit code from xcodebuild")
return parser.parse_args()
def main() -> int:
args = parse_args()
xcresult_path = Path(args.xcresult) if args.xcresult else None
raw_log_path = Path(args.raw_log) if args.raw_log else None
summary = None
tests_payload = None
if xcresult_path and xcresult_path.exists():
summary = load_summary_from_xcresult(xcresult_path)
tests_payload = load_tests_from_xcresult(xcresult_path)
failures = extract_failures(summary, tests_payload, raw_log_path)
payload = build_report_payload(args, summary, tests_payload, failures)
output_md = Path(args.output_md)
output_json = Path(args.output_json)
output_txt = Path(args.output_txt)
output_md.parent.mkdir(parents=True, exist_ok=True)
output_json.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
output_md.write_text(render_markdown(payload) + "\n", encoding="utf-8")
output_txt.write_text(render_text_summary(payload) + "\n", encoding="utf-8")
return 0
if __name__ == "__main__":
sys.exit(main())