13 Commits
Author SHA1 Message Date
shenandshen 1c6108061c feat: improve native text selection and annotation actions 2026-06-01 21:29:41 +08:00
shenleiandClaude Opus 4.7 70133e4e6e refactor(reader): 重构高亮选区绘制架构,对齐 WXRead 实现方案
- 移除 RDEPUBSelectableTextView,改用原生 UITextView
- 新增 NSAttributedString 自定义属性(com.rdreader.highlight/underline)注入高亮
- RDEPUBTextPageRenderView 统一绘制高亮背景、文字和选区
- RDEPUBTextSelectionController 精简,选区矩形传递给 RenderView 绘制
- 新增高亮选区复刻 WXRead 实现方案文档
- UI 测试适配新架构

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 20:57:27 +08:00
shenandshen 948004eed1 docs: 补充注释、修正过时文档、清理重复内容
源码注释:
- 为 ~60 个 Swift 文件补充缺失的 doc comment(file header、类型、属性、方法)
- 修正 4 处错误注释:翻页模式数量、搜索行为描述、手势识别器描述、悬空文档块

文档维护:
- 删除重复文档:WXRead/读书EPUB阅读器实现架构.md(与微信读书版完全一致)
- 合并重叠文档:阅读器规划.md → 阅读器功能开发计划.md(单一真值)
- 修正过时内容:所有文档中"四种翻页模式"→"三种",移除 horizontalCoverScroll
- 更新架构图:补齐 EPUBUI/ReaderController、Paging/、Typesetter/ 等子目录
- 更新 index.md 索引:新增开发计划和架构对比文档引用
2026-06-01 09:33:23 +08:00
shenandshen 1efb9d172f feat(reader): 增强阅读器功能与 UI 测试支持
- 新增字体选择(系统/宋体/圆体/等宽)与暗色图片柔化配置
- 文本选择改为自定义手势+操作栏(拷贝/高亮/批注)
- 添加 accessibilityIdentifier 支持自动化 UI 测试
- 新增 UITests 覆盖阅读器打开/关闭、工具栏、设置面板、批注等
- 添加 Demo 测试用 EPUB 书源(宝山辽墓材料与释读)
- 新增文档:UI 自动化测试、功能开发计划、阅读器规划
2026-05-31 23:56:54 +08:00
shenandshen 44202357c0 refactor: split reader architecture and chrome handling 2026-05-31 21:47:54 +08:00
shenandshen ea21c6a831 epub问题修改 2026-05-26 23:27:52 +08:00
shenandshen 22adb76332 fix(reader): restore tool views and close action 2026-05-26 21:41:04 +08:00
shenandshen 736902b489 fix(reader): stabilize pagination display and page curl reuse 2026-05-26 21:37:39 +08:00
shenandshen 83b705b9ae 修改分页问题 2026-05-26 21:08:27 +08:00
shenandshen 0e7d952fe5 feat(epub): complete wxread parity scope 2026-05-26 09:19:37 +08:00
shenandshen 23182e8b5a feat(epub): align wxread css and overlay pagination behavior 2026-05-25 22:15:24 +08:00
shenandshen c0aac56083 docs: 同步 Doc 文档与当前代码状态
- 更新模块文件数(EPUBCore 31+2, EPUBTextRendering 13, ReaderView 5, EPUBUI 19)
- 更新 Swift 版本(5.10)、iOS 版本(15.0)、行数等基本信息
- 补充新增文件文档(TextAnchor, RenderRequest, WebViewDebug, AssetRepository,
  TextIndexTable, TextPerformanceSampler, ChapterData, PageInteractionController 等)
- 更新 RDEPUBLocation 模型(新增 rangeAnchor 字段)
- 更新 RDEPUBReaderConfiguration(13 项配置)、Delegate 签名、Persistence(8 方法)
- 修正 RDURLReaderController 归属(ReaderView → EPUBUI)
- 移除过时的 LegacyRDReaderController 引用
- 标记 SS→RD 命名迁移为已完成
2026-05-25 20:31:10 +08:00
shenandshen 54798ba578 refactor: 添加中文注释 + 优化模块结构
- 给全部 78 个 Swift 源文件添加详细的中文注释(文件级、类级、方法级)
- 删除 LegacyRDReaderController/ 死代码目录(16 文件 4592 行)
- 根目录翻页容器文件移入 ReaderView/ 目录
- Resources/ 移入 EPUBCore/Resources/(与使用者归属一致)
- RDEPUBTextIndexTable.swift 移入 EPUBTextRendering/(消除反向依赖)
- RDURLReaderController.swift 移入 EPUBUI/(入口控制器归入 UI 层)
- 更新 podspec 资源路径
2026-05-25 10:19:14 +08:00
428 changed files with 22665 additions and 13733 deletions
+118 -39
View File
@@ -4,8 +4,8 @@
RDReaderView 是一个 iOS 阅读器组件库(CocoaPods),提供开箱即用的 EPUB 阅读能力,并保留对纯文本翻页的支持。 RDReaderView 是一个 iOS 阅读器组件库(CocoaPods),提供开箱即用的 EPUB 阅读能力,并保留对纯文本翻页的支持。
- **最低 iOS 版本**15.0podspec 仍标注 9.0,实际 demo Podfile 要求 15.0 - **最低 iOS 版本**15.0
- **Swift 版本**5.0+ - **Swift 版本**5.10+
- **依赖**ZIPFoundationEPUB 解压)、DTCoreText(文本 EPUB 渲染) - **依赖**ZIPFoundationEPUB 解压)、DTCoreText(文本 EPUB 渲染)
- **Demo 额外依赖**SnapKit、SSAlertSwift - **Demo 额外依赖**SnapKit、SSAlertSwift
@@ -21,68 +21,143 @@ RDReaderView 是一个 iOS 阅读器组件库(CocoaPods),提供开箱即
┌───────────────────────────▼─────────────────────────────┐ ┌───────────────────────────▼─────────────────────────────┐
│ EPUBUI 层(library 级读者 UI │ EPUBUI 层(library 级读者 UI
RDEPUBReaderController(开箱即用入口)
RDEPUBReaderConfiguration / Theme / Persistence 主控制器
TopToolView / BottomToolView / ChapterList / Settings RDEPUBReaderController(开箱即用入口)
RDEPUBTextContentView / RDEPUBWebContentView +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 │ 翻页容器层(RDReaderView
│ │
│ RDReaderViewUIView,统一翻页外壳) │ │ RDReaderViewUIView,统一翻页外壳) │
4 种翻页模式:pageCurl / horizontalScroll / │ 3 种翻页模式:pageCurl / horizontalScroll / │
│ verticalScroll / horizontalCoverScroll │ verticalScroll
│ RDReaderViewProtocolsDataSource / Delegate / DisplayType)│
│ +CollectionView / +ContentAccess / +PageCurl / +ToolView │
│ RDReaderFlowLayout / RDReaderContentCell │ │ RDReaderFlowLayout / RDReaderContentCell │
│ RDReaderPageChildViewControllerpageCurl 页包装) │ │ RDReaderPageChildViewControllerpageCurl 页包装) │
│ RDReaderGestureController │
│ │
│ Paging/(翻页控制) │
│ RDReaderPagingController(转场与排队) │
│ RDReaderPreloadController(预加载与缓存) │
│ RDReaderSpreadResolver(双页配对) │
│ RDReaderTapRegionHandler(手势分区) │
└───────────────────────────┬─────────────────────────────┘ └───────────────────────────┬─────────────────────────────┘
┌───────────────────────────▼─────────────────────────────┐ ┌───────────────────────────▼─────────────────────────────┐
│ EPUBCore 层(EPUB 引擎) │ │ EPUBCore 层(EPUB 引擎) │
│ │ │ │
Publication 层 解析与模型
│ RDEPUBParser+Archive / +Package / +TOC / …) │ RDEPUBParser+Archive / +Package / +TOC /
│ +ReadingProfile / +Resources
│ RDEPUBPublication(出版物聚合对象) │ │ RDEPUBPublication(出版物聚合对象) │
│ RDEPUBModelsmetadata / manifest / spine 模型) │ │ RDEPUBModelsmetadata / manifest / spine 模型) │
RDEPUBReadingModelslocation / viewport / highlight Models/
│ RDEPUBReadingLocationModelslocation 模型) │
│ RDEPUBPaginationModels(分页模型) │
│ RDEPUBAnnotationModels(标注模型) │
│ RDEPUBTextAnchor / RDEPUBTextRangeAnchor(文本锚点) │
│ RDEPUBRenderRequest(渲染请求模型) │
│ │ │ │
Services 层 服务层
│ RDEPUBResourceResolver(资源 URL 统一入口) │ │ RDEPUBResourceResolver(资源 URL 统一入口) │
│ RDEPUBResourceURLSchemeHandlerss-reader:// 协议) │
│ RDEPUBPreferences(展示参数聚合) │ │ RDEPUBPreferences(展示参数聚合) │
│ RDEPUBPaginator(离屏分页服务) │ │ RDEPUBPaginator(离屏分页服务) │
│ RDEPUBStyleSheetBuilder / RDEPUBJavaScriptBridge │
│ RDEPUBFixedLayoutTemplate / RDEPUBAssetRepository │
│ │ │ │
Navigator 层 会话与导航
│ RDEPUBReadingSession(状态机 + 会话协调) │ │ RDEPUBReadingSession(状态机 + 会话协调) │
│ RDEPUBNavigatorState(状态枚举) │ │ RDEPUBNavigatorState(状态枚举) │
│ RDEPUBNavigatorLayoutContext │ │ RDEPUBNavigatorLayoutContext │
│ │ │ │
Resource View 层 WebView 渲染
│ RDEPUBWebView+Configuration / +Reflowable / │ │ RDEPUBWebView+Configuration / +Reflowable / │
│ +FixedLayout / +JavaScriptBridge │ +FixedLayout / +JavaScriptBridge /
RDEPUBResourceURLSchemeHandlerss-reader:// 协议) +Search
│ RDEPUBStyleSheetBuilder / RDEPUBJavaScriptBridge │ RDEPUBWebViewDebug(调试日志工具)
│ RDEPUBFixedLayoutTemplate / RDEPUBAssetRepository │
│ RDEPUBRenderRequest │
│ │ │ │
Search 层 搜索
│ RDEPUBSearchEngine(协议)/ RDEPUBHTMLSearchEngine │ │ RDEPUBSearchEngine(协议)/ RDEPUBHTMLSearchEngine │
│ RDEPUBSearchModelsSearchMatch/Result/State/Presentation)│ │ RDEPUBSearchModelsSearchMatch/Result/State/Presentation)│
└───────────────────────────┬─────────────────────────────┘ └───────────────────────────┬─────────────────────────────┘
┌───────────────────────────▼─────────────────────────────┐ ┌───────────────────────────▼─────────────────────────────┐
│ EPUBTextRendering 层(文本 EPUB 渲染) │ │ EPUBTextRendering 层(文本 EPUB 渲染) │
RDEPUBTextRenderer(协议)
RDEPUBDTCoreTextRendererDTCoreText 实现) 渲染
│ RDEPUBTextRendererSupport / RDEPUBTextPaginationSupport RDEPUBTextRenderer(协议)
│ RDEPUBTextBookBuilder / RDPlainTextBookBuilder RDEPUBDTCoreTextRendererDTCoreText 实现)
RDEPUBTextLayouter / RDEPUBTextLayoutFrame RDPlainTextBookBuilder(纯文本书籍构建)
│ RDEPUBTextSearchEngine RDEPUBTextPositionConverter(位置转换器)
└──────────────────────────────────────────────────────────┘ │ RDEPUBTextSearchEngine(文本搜索引擎) │
│ RDEPUBTextIndexTable / RDEPUBChapterData │
┌──────────────────────────────────────────────────────────┐ │ │
LegacyRDReaderController/(旧版阅读器,保留兼容) BuildPipeline/(构建管线)
RDReaderController(旧版)/ RDReaderManager RDEPUBTextBookBuilder(分页书籍构建器)
RDReaderContentView / RDReaderEPUBContentView RDEPUBTextBookCache / RDEPUBTextBookModels
SSChapterListController / SSEventTrigger RDEPUBTextBuildPipelineInterfaces(管线协议)
│ RDEPUBPaginationCacheCoordinator(缓存协调) │
│ RDEPUBChapterTailNormalizer(章尾规范化) │
│ RDEPUBBuildDiagnosticsReporter(诊断报告) │
│ RDEPUBTextPerformanceSampler(性能采样) │
│ │
│ Pagination/(分页引擎) │
│ RDEPUBTextLayouter / RDEPUBTextLayoutFrame │
│ RDEPUBChapterPageCounter / RDEPUBCoreTextPageFrameFactory│
│ RDEPUBPageBreakPolicy(断页策略) │
│ RDEPUBTextPaginationInterfaces(分页协议) │
│ RDEPUBTextPaginationSupport(分页支持) │
│ │
│ Typesetter/(排版管线) │
│ RDEPUBTypesettingPipeline(排版管线编排) │
│ RDEPUBHTMLNormalizerHTML 规范化) │
│ RDEPUBStyleSheetComposerCSS 组合) │
│ RDEPUBFontNormalizer(字体规范化) │
│ RDEPUBAttachmentNormalizer(附件规范化) │
│ RDEPUBFragmentMarkerInjectorFragment 标记注入) │
│ RDEPUBSemanticMarkerInjector(语义标记注入) │
│ RDEPUBRenderDiagnosticsCollector(渲染诊断) │
│ RDEPUBTextRendererSupport(渲染辅助工具) │
└──────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────┘
``` ```
@@ -90,14 +165,13 @@ RDReaderView 是一个 iOS 阅读器组件库(CocoaPods),提供开箱即
## 3. 翻页容器层(RDReaderView ## 3. 翻页容器层(RDReaderView
### 3.1 种翻页模式 ### 3.1 种翻页模式
| 模式 | 实现方式 | 特点 | | 模式 | 实现方式 | 特点 |
|------|----------|------| |------|----------|------|
| `pageCurl` | UIPageViewController | 原生翻书效果,手势由系统提供 | | `pageCurl` | UIPageViewController | 原生翻书效果,手势由系统提供 |
| `horizontalScroll` | UICollectionView + RDReaderFlowLayout | 每屏显示 2 项,水平分页滚动 | | `horizontalScroll` | UICollectionView + RDReaderFlowLayout | 每屏显示 2 项,水平分页滚动 |
| `verticalScroll` | UICollectionView + RDReaderFlowLayout | 全宽项目,垂直连续滚动 | | `verticalScroll` | UICollectionView + RDReaderFlowLayout | 全宽项目,垂直连续滚动 |
| `horizontalCoverScroll` | UICollectionView + RDReaderFlowLayout | 覆盖滚动效果,Z 轴动画 |
### 3.2 数据源协议 ### 3.2 数据源协议
@@ -239,6 +313,7 @@ session.clearPendingNavigation() // 取消待执行跳转
| `+Reflowable` | 注入分页 CSS、滚动到指定 progression、接收 JS 事件 | | `+Reflowable` | 注入分页 CSS、滚动到指定 progression、接收 JS 事件 |
| `+FixedLayout` | fixed-layout HTML wrapper 生成和加载 | | `+FixedLayout` | fixed-layout HTML wrapper 生成和加载 |
| `+JavaScriptBridge` | JS ↔ Swift 消息路由、选区、高亮、进度上报 | | `+JavaScriptBridge` | JS ↔ Swift 消息路由、选区、高亮、进度上报 |
| `+Search` | 搜索高亮装饰 |
#### ss-reader:// 协议 #### ss-reader:// 协议
@@ -323,7 +398,10 @@ present(controller, animated: true)
| `showsTableOfContents` | `true` | 是否显示目录入口 | | `showsTableOfContents` | `true` | 是否显示目录入口 |
| `allowsHighlights` | `true` | 是否显示高亮入口 | | `allowsHighlights` | `true` | 是否显示高亮入口 |
| `showsSettingsPanel` | `true` | 是否显示设置入口 | | `showsSettingsPanel` | `true` | 是否显示设置入口 |
| `reflowableContentInsets` | (40,16,40,16) | 可重排内容内边距 |
| `fixedContentInset` | .zero | 固定版式内容内边距 |
| `theme` | `.light` | 主题 | | `theme` | `.light` | 主题 |
| `fixedLayoutFit` | `.page` | 固定版式适配模式 |
| `fixedLayoutSpreadMode` | `.automatic` | fixed-layout spread 模式 | | `fixedLayoutSpreadMode` | `.automatic` | fixed-layout spread 模式 |
| `textRenderingEngine` | `.dtCoreText` | 文本 EPUB 渲染引擎 | | `textRenderingEngine` | `.dtCoreText` | 文本 EPUB 渲染引擎 |
@@ -377,6 +455,7 @@ struct RDEPUBLocation: Codable, Equatable {
var progression: Double // [0, 1] var progression: Double // [0, 1]
var lastProgression: Double? // [0, 1] var lastProgression: Double? // [0, 1]
var fragment: String? // var fragment: String? //
var rangeAnchor: RDEPUBTextRangeAnchor? // EPUB
} }
``` ```
@@ -393,8 +472,8 @@ struct RDEPUBLocation: Codable, Equatable {
| 问题 | 说明 | | 问题 | 说明 |
|------|------| |------|------|
| Demo 层 RDReaderController 过大 | 约 1900+ 行,仍混有 EPUB 加载、UI 管理、数据源逻辑,待进一步拆分 | | RDEPUBReaderController 过大 | 约 1995 行,仍混有 EPUB 加载、UI 管理、数据源逻辑,待进一步拆分 |
| RDEPUBParser.swift 仍偏大 | 约 900 行,虽已拆出多个扩展文件,OPF metadata 细节仍集中 | | 部分 UI 文件仍然偏大 | RDEPUBTextContentView ~728 行、RDEPUBTextBookBuilder ~717 行,需继续拆分 |
| 横竖屏切换已接入一阶段支持 | `RDEPUBReaderController.viewWillTransition()` 统一接管正文重分页,`RDReaderView` 保留容器级双页布局刷新;仍需补齐固定回归矩阵 | | 横竖屏切换已接入一阶段支持 | `RDEPUBReaderController.viewWillTransition()` 统一接管正文重分页,`RDReaderView` 保留容器级双页布局刷新;仍需补齐固定回归矩阵 |
| 固定手势分区比例 | 三等分固定写死,不支持自定义 | | 固定手势分区比例 | 三等分固定写死,不支持自定义 |
| 自动化测试为首批接入状态 | 已有 `ReadViewSDKDemoTests` / `ReadViewSDKDemoUITests` 与 parser / resolver / persistence / smoke 用例,分页、恢复链路和更多 UI 闭环仍需继续补齐 | | 自动化测试为首批接入状态 | 已有 `ReadViewSDKDemoTests` / `ReadViewSDKDemoUITests` 与 parser / resolver / persistence / smoke 用例,分页、恢复链路和更多 UI 闭环仍需继续补齐 |
@@ -416,7 +495,7 @@ pod install
| 依赖 | 用途 | 使用层 | | 依赖 | 用途 | 使用层 |
|------|------|--------| |------|------|--------|
| ZIPFoundation | EPUB ZIP 解压 | RDEPUBParser+Archive | | ZIPFoundation (~> 0.9) | EPUB ZIP 解压 | RDEPUBParser+Archive |
| DTCoreText | HTML → NSAttributedString | RDEPUBDTCoreTextRenderer | | DTCoreText | HTML → NSAttributedString | RDEPUBDTCoreTextRenderer |
| SnapKit | Demo 布局 | Demo 层 | | SnapKit | Demo 布局 | Demo 层 |
| SSAlertSwift | Demo 弹窗 | Demo 层 | | SSAlertSwift | Demo 弹窗 | Demo 层 |
+7 -5
View File
@@ -1,8 +1,8 @@
# ReadSDK 代码规范 # ReadViewSDK 代码规范
## 适用范围 ## 适用范围
本文档适用于 `ReadSDK` 新增代码与重构代码。 本文档适用于 `ReadViewSDK` 新增代码与重构代码。
- 规范覆盖 `Sources/``RDReaderDemo/` 中的 Swift 代码。 - 规范覆盖 `Sources/``RDReaderDemo/` 中的 Swift 代码。
- 命名、分层与职责边界以 SDK 可维护性和可扩展性为优先。 - 命名、分层与职责边界以 SDK 可维护性和可扩展性为优先。
@@ -225,15 +225,17 @@ RDEPUBWebView+Reflowable.swift
- 命名先定前缀再落代码:类型一律 `RD` 开头。 - 命名先定前缀再落代码:类型一律 `RD` 开头。
- 若需迁移历史 `SS` 前缀,按模块渐进替换,优先替换新增与重构触达文件。 - 若需迁移历史 `SS` 前缀,按模块渐进替换,优先替换新增与重构触达文件。
## 旧 SS 命名迁移到 RD 的分阶段执行清单 ## 旧 SS 命名迁移到 RD 的执行状态
### 阶段 0:冻结新增 SS 命名(立即执行) **迁移已完成**:源码中已无 `SS` 前缀类型定义,全部使用 `RD`/`RDEPUB` 前缀。新增代码必须继续遵守此规则。
### 阶段 0:冻结新增 SS 命名(已完成)
- 目标:从当前时点开始,不再引入新的 `SS`/`RDEPUB` 类型名。 - 目标:从当前时点开始,不再引入新的 `SS`/`RDEPUB` 类型名。
- 动作: - 动作:
- 新增类型统一使用 `RD`/`RDEPUB` 前缀。 - 新增类型统一使用 `RD`/`RDEPUB` 前缀。
- Code Review 增加命名检查项:发现新增 `SS` 命名必须驳回。 - Code Review 增加命名检查项:发现新增 `SS` 命名必须驳回。
- 在 PR 模板中加入本次是否新增旧前缀命名”勾选项。 - 在 PR 模板中加入本次是否新增旧前缀命名”勾选项。
- 验收: - 验收:
- 新提交代码中,新增类型 `SS` 前缀数量为 `0` - 新提交代码中,新增类型 `SS` 前缀数量为 `0`
+52 -7
View File
@@ -2,7 +2,7 @@
## 1. 范围与目标 ## 1. 范围与目标
- 代码范围:`Sources/RDReaderView/EPUBCore/`30 个 Swift 文件 + 2 个资源文件) - 代码范围:`Sources/RDReaderView/EPUBCore/`31 个 Swift 文件 + 2 个资源文件)
- 目标:说明 EPUBCore 如何完成 EPUB 解析、资源定位、阅读会话管理、离屏分页、JS 桥接渲染和全文搜索。 - 目标:说明 EPUBCore 如何完成 EPUB 解析、资源定位、阅读会话管理、离屏分页、JS 桥接渲染和全文搜索。
- 主链路关键词:`epubURL -> RDEPUBParser.parse -> container.xml -> OPF -> spine/TOC -> RDEPUBPublication -> RDEPUBReadingSession -> RDEPUBWebView/Paginator -> 分页/渲染` - 主链路关键词:`epubURL -> RDEPUBParser.parse -> container.xml -> OPF -> spine/TOC -> RDEPUBPublication -> RDEPUBReadingSession -> RDEPUBWebView/Paginator -> 分页/渲染`
@@ -40,7 +40,7 @@
### 2.4 WebView 渲染层 `RDEPUBWebView` ### 2.4 WebView 渲染层 `RDEPUBWebView`
- 文件:`EPUBCore/RDEPUBWebView.swift` + 5 个扩展文件 - 文件:`EPUBCore/RDEPUBWebView.swift` + 5 个扩展文件+Configuration / +Reflowable / +FixedLayout / +JavaScriptBridge / +Search
- 职责: - 职责:
- 内部持有 WKWebView,配置自定义 scheme handler 和 JS 桥接 - 内部持有 WKWebView,配置自定义 scheme handler 和 JS 桥接
- 可重排内容加载:`loadPage(parser:spineIndex:pageIndex:...)``+Reflowable.swift` - 可重排内容加载:`loadPage(parser:spineIndex:pageIndex:...)``+Reflowable.swift`
@@ -77,13 +77,13 @@
- `ssReaderFixedLayoutReady`:固定版式渲染完成 - `ssReaderFixedLayoutReady`:固定版式渲染完成
- JS 端 `window.RDReaderBridge` 暴露:`applyPagination``setPageMetrics``scrollToPage``scrollToLocation``setHighlights``clearHighlights``reportProgression``selectionPayload` - JS 端 `window.RDReaderBridge` 暴露:`applyPagination``setPageMetrics``scrollToPage``scrollToLocation``setHighlights``clearHighlights``reportProgression``selectionPayload`
### 2.8 搜索引擎 `RDEPUBHTMLSearchEngine` ### 2.8 搜索引擎 `RDEPUBSearchEngine` / `RDEPUBHTMLSearchEngine`
- 文件:`EPUBCore/RDEPUBSearchEngine.swift``EPUBCore/RDEPUBSearchModels.swift` - 文件:`EPUBCore/RDEPUBSearchEngine.swift``EPUBCore/RDEPUBSearchModels.swift`
- 职责: - 职责:
- 将 HTML 转为纯文本(NSAttributedString 或正则兜底) - `RDEPUBSearchEngine`:搜索协议定义
- 执行大小写不敏感的全文搜索 - `RDEPUBHTMLSearchEngine`WebView 路径实现,将 HTML 转为纯文本(NSAttributedString 或正则兜底),执行大小写不敏感的全文搜索
- 返回 `RDEPUBSearchMatch` 数组(含 progression 偏移和预览文本) - 搜索模型:`RDEPUBSearchMatch``RDEPUBSearchResult``RDEPUBSearchState``RDEPUBSearchPresentation`
### 2.9 配置与样式 ### 2.9 配置与样式
@@ -92,6 +92,42 @@
- `RDEPUBStyleSheetBuilder``EPUBCore/RDEPUBStyleSheetBuilder.swift`):生成分页 CSS、渲染 CSS 和测量 JS 脚本 - `RDEPUBStyleSheetBuilder``EPUBCore/RDEPUBStyleSheetBuilder.swift`):生成分页 CSS、渲染 CSS 和测量 JS 脚本
- `RDEPUBFixedLayoutTemplate``EPUBCore/RDEPUBFixedLayoutTemplate.swift`):生成固定版式 HTML 模板 - `RDEPUBFixedLayoutTemplate``EPUBCore/RDEPUBFixedLayoutTemplate.swift`):生成固定版式 HTML 模板
### 2.10 文本锚点 `RDEPUBTextAnchor` / `RDEPUBTextRangeAnchor`
- 文件:`EPUBCore/RDEPUBTextAnchor.swift`
- 职责:
- `RDEPUBTextAnchor`:精确定位到 EPUB 中的某个字符位置,包含 fileIndex(spine 索引)、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. 主流程(代码级)
### 3.1 EPUB 解析全流程 ### 3.1 EPUB 解析全流程
@@ -237,6 +273,7 @@ RDEPUBLocation
├── progression: Double? (0..1,章节内起始位置) ├── progression: Double? (0..1,章节内起始位置)
├── lastProgression: Double? (0..1,章节内结束位置) ├── lastProgression: Double? (0..1,章节内结束位置)
├── fragment: String? (#anchor) ├── fragment: String? (#anchor)
├── rangeAnchor: RDEPUBTextRangeAnchor? (文本范围锚点,用于高亮精确定位)
└── navigationProgression (computed: midpoint of progression and lastProgression) └── navigationProgression (computed: midpoint of progression and lastProgression)
``` ```
@@ -279,7 +316,8 @@ RDEPUBRenderRequest (enum)
RDEPUBHighlight RDEPUBHighlight
├── id, bookIdentifier ├── id, bookIdentifier
├── location: RDEPUBLocation ├── location: RDEPUBLocation
├── text, rangeInfo (JSON-serialized DOM range) ├── text, rangeInfo (JSON-serialized DOM range 或 text-offset)
├── style: RDEPUBHighlightStyle (.highlight | .underline)
├── color: String, note: String ├── color: String, note: String
└── createdAt: Date └── createdAt: Date
@@ -288,6 +326,13 @@ RDEPUBSelection
├── location: RDEPUBLocation ├── location: RDEPUBLocation
├── text, rangeInfo ├── text, rangeInfo
└── createdAt: Date └── createdAt: Date
RDEPUBBookmark
├── id, bookIdentifier
├── location: RDEPUBLocation
├── title: String?
├── note: String?
└── createdAt: Date
``` ```
### 5.6 搜索模型 ### 5.6 搜索模型
+33 -2
View File
@@ -2,7 +2,7 @@
## 1. 范围与目标 ## 1. 范围与目标
- 代码范围:`Sources/RDReaderView/EPUBTextRendering/`9 个 Swift 文件) - 代码范围:`Sources/RDReaderView/EPUBTextRendering/`13 个 Swift 文件)
- 目标:说明文本渲染路径如何将 EPUB HTML 转换为 NSAttributedString、按字符范围分页、构建书籍模型,并支持全文搜索与 textReflowable 标注定位。 - 目标:说明文本渲染路径如何将 EPUB HTML 转换为 NSAttributedString、按字符范围分页、构建书籍模型,并支持全文搜索与 textReflowable 标注定位。
- 主链路关键词:`RDEPUBParser HTML -> DTCoreText 渲染 -> 片段标记注入/提取 -> CoreText 分页 -> RDEPUBTextBook -> 页面查找/位置转换` - 主链路关键词:`RDEPUBParser HTML -> DTCoreText 渲染 -> 片段标记注入/提取 -> CoreText 分页 -> RDEPUBTextBook -> 页面查找/位置转换`
- 适用范围:仅用于 `textReflowable` 阅读配置文件(纯文本可重排 EPUB,如小说)。固定版式和交互式 EPUB 使用 WebView 渲染路径。 - 适用范围:仅用于 `textReflowable` 阅读配置文件(纯文本可重排 EPUB,如小说)。固定版式和交互式 EPUB 使用 WebView 渲染路径。
@@ -64,6 +64,14 @@
- 在已渲染的 NSAttributedString 纯文本上执行线性搜索 - 在已渲染的 NSAttributedString 纯文本上执行线性搜索
- 返回 `RDEPUBSearchMatch` 数组(含 href、progression、预览文本、匹配位置) - 返回 `RDEPUBSearchMatch` 数组(含 href、progression、预览文本、匹配位置)
### 2.6.1 章节数据模型 `RDEPUBChapterData`
- 文件:`EPUBTextRendering/RDEPUBChapterData.swift`
- 职责:
- 每章节的数据聚合模型,管理高亮、搜索结果和页面查询
- 支持按页码范围过滤当前页的高亮列表
- 支持搜索结果在章节内的定位和匹配索引计算
### 2.7 CoreText 分页引擎 `RDEPUBTextLayouter` / `RDEPUBTextLayoutFrame` ### 2.7 CoreText 分页引擎 `RDEPUBTextLayouter` / `RDEPUBTextLayoutFrame`
- 文件:`EPUBTextRendering/RDEPUBTextLayouter.swift``EPUBTextRendering/RDEPUBTextLayoutFrame.swift` - 文件:`EPUBTextRendering/RDEPUBTextLayouter.swift``EPUBTextRendering/RDEPUBTextLayoutFrame.swift`
@@ -80,6 +88,28 @@
- 将纯文本按段落切分后渲染为 NSAttributedString - 将纯文本按段落切分后渲染为 NSAttributedString
- 按页面尺寸分页,输出与 EPUB 文本路径一致的书籍模型 - 按页面尺寸分页,输出与 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. 主流程(代码级)
### 3.1 完整渲染-分页流程 ### 3.1 完整渲染-分页流程
@@ -295,7 +325,8 @@ RDEPUBTextBook
- **内存**`RDEPUBTextBook` 同时持有 chapters(含完整 attributedContent)和 pages(含子串切片),存在一定程度的内存重复 - **内存**`RDEPUBTextBook` 同时持有 chapters(含完整 attributedContent)和 pages(含子串切片),存在一定程度的内存重复
- **后台执行**`build` 方法在 `DispatchQueue.global(qos: .userInitiated)` 执行,不阻塞主线程 - **后台执行**`build` 方法在 `DispatchQueue.global(qos: .userInitiated)` 执行,不阻塞主线程
- **搜索性能**:线性扫描每章的 `attributedContent.string`,无索引,搜索时间与总文本量线性相关 - **搜索性能**:线性扫描每章的 `attributedContent.string`,无索引,搜索时间与总文本量线性相关
- **分页缓存**字号/行高变化时全书重新渲染和分页,无增量更新 - **分页缓存**`RDEPUBTextBookCache` 支持磁盘缓存分页结果,字号/行高变化时优先从缓存恢复
- **性能采样**`RDEPUBTextPerformanceSampler` 记录每章的渲染和分页耗时,支持缓存命中率统计
## 9. 联调与排查建议 ## 9. 联调与排查建议
+44 -31
View File
@@ -2,7 +2,7 @@
## 1. 范围与目标 ## 1. 范围与目标
- 代码范围:`Sources/RDReaderView/EPUBUI/`16 个 Swift 文件) - 代码范围:`Sources/RDReaderView/EPUBUI/`19 个 Swift 文件)
- 目标:说明 EPUBUI 如何作为开箱即用的阅读器 UI 层,协调 EPUBCore 解析、EPUBTextRendering 文本渲染、RDReaderView 分页容器,提供完整的阅读体验(工具栏、目录、高亮批注、设置面板、阅读位置持久化、搜索)。 - 目标:说明 EPUBUI 如何作为开箱即用的阅读器 UI 层,协调 EPUBCore 解析、EPUBTextRendering 文本渲染、RDReaderView 分页容器,提供完整的阅读体验(工具栏、目录、高亮批注、设置面板、阅读位置持久化、搜索)。
- 主链路关键词:`RDEPUBReaderController.init -> 解析 EPUB -> 分页 -> 渲染 -> 用户交互(翻页/工具栏/设置/高亮/搜索)-> 持久化` - 主链路关键词:`RDEPUBReaderController.init -> 解析 EPUB -> 分页 -> 渲染 -> 用户交互(翻页/工具栏/设置/高亮/搜索)-> 持久化`
@@ -10,7 +10,7 @@
### 2.1 主控制器 `RDEPUBReaderController` ### 2.1 主控制器 `RDEPUBReaderController`
- 文件:`EPUBUI/RDEPUBReaderController.swift`~1255 行) - 文件:`EPUBUI/RDEPUBReaderController.swift`~1995 行)
- 入口方法: - 入口方法:
- `init(epubURL:configuration:persistence:)` — 标准 EPUB 阅读入口 - `init(epubURL:configuration:persistence:)` — 标准 EPUB 阅读入口
- `init(textBook:bookIdentifier:title:textFileURL:configuration:)` — 纯文本书籍阅读入口(由 `RDPlainTextBookBuilder` 构建 `RDEPUBTextBook` 后传入) - `init(textBook:bookIdentifier:title:textFileURL:configuration:)` — 纯文本书籍阅读入口(由 `RDPlainTextBookBuilder` 构建 `RDEPUBTextBook` 后传入)
@@ -29,7 +29,7 @@
- `fontSize`(默认 15)、`lineHeightMultiple`(默认 1.6 - `fontSize`(默认 15)、`lineHeightMultiple`(默认 1.6
- `displayType`(默认 .pageCurl)、`landscapeDualPageEnabled`(默认 true - `displayType`(默认 .pageCurl)、`landscapeDualPageEnabled`(默认 true
- `showsTableOfContents`(默认 true)、`allowsHighlights`(默认 true)、`showsSettingsPanel`(默认 true - `showsTableOfContents`(默认 true)、`allowsHighlights`(默认 true)、`showsSettingsPanel`(默认 true
- `reflowableContentInsets`(默认 40/16/40/16)、`fixedContentInset`(默认 .zero - `reflowableContentInsets`(默认 top:40 left:16 bottom:40 right:16)、`fixedContentInset`(默认 .zero
- `theme`(默认 .light - `theme`(默认 .light
- `fixedLayoutFit`(默认 .page)、`fixedLayoutSpreadMode`(默认 .automatic - `fixedLayoutFit`(默认 .page)、`fixedLayoutSpreadMode`(默认 .automatic
- `textRenderingEngine`(默认 .dtCoreText - `textRenderingEngine`(默认 .dtCoreText
@@ -41,45 +41,47 @@
- 文件:`EPUBUI/RDEPUBReaderDelegate.swift` - 文件:`EPUBUI/RDEPUBReaderDelegate.swift`
- 12 个可选方法(全部有默认空实现): - 12 个可选方法(全部有默认空实现):
- `didOpen`:成功打开出版物 - `epubReader(_:didOpen:)`:成功打开出版物
- `didUpdateLocation`:翻页或滚动时位置更新 - `epubReader(_:didUpdateLocation:)`:翻页或滚动时位置更新
- `didReachEnd`:到达最后一页 - `epubReaderDidReachEnd(_:)`:到达最后一页
- `didChangeSelection`:文本选择变化或清除 - `epubReader(_:didChangeSelection:)`:文本选择变化或清除
- `didUpdateHighlights`:高亮增删改 - `epubReader(_:didUpdateHighlights:)`:高亮增删改
- `didUpdateBookmarks`:书签增删改 - `epubReader(_:didUpdateBookmarks:)`:书签增删改
- `didUpdateSearchResult`:搜索状态变化 - `epubReader(_:didUpdateSearchResult:)`:搜索状态变化
- `didChangeCurrentSearchMatch`:当前搜索匹配项变化 - `epubReader(_:didChangeCurrentSearchMatch:)`:当前搜索匹配项变化
- `didUpdateCurrentTableOfContentsItem`:翻页时匹配的目录项 - `epubReader(_:didUpdateCurrentTableOfContentsItem:)`:翻页时匹配的目录项
- `didActivateExternalLink`:外部链接点击 - `epubReader(_:didActivateExternalLink:)`:外部链接点击
- `didFailWithError`:解析或分页错误 - `epubReader(_:didFailWithError:)`:解析或分页错误
- `configureTopToolView`:自定义顶部工具栏 - `epubReader(_:configureTopToolView:)`:自定义顶部工具栏
### 2.4 持久化 `RDEPUBReaderPersistence` ### 2.4 持久化 `RDEPUBReaderPersistence`
- 文件:`EPUBUI/RDEPUBReaderPersistence.swift` - 文件:`EPUBUI/RDEPUBReaderPersistence.swift`
- 协议定义 6 个方法:loadLocation / saveLocation / loadHighlights / saveHighlights / loadReaderSettings / saveReaderSettings - 协议定义 8 个方法:loadLocation / saveLocation / loadHighlights / saveHighlights / loadBookmarks / saveBookmarks / loadReaderSettings / saveReaderSettings
- 默认实现 `RDEPUBUserDefaultsPersistence` - 默认实现 `RDEPUBUserDefaultsPersistence`
- 位置:`ssreader.epub.location.<bookIdentifier>`JSON 编码) - 位置:`ssreader.epub.location.<bookIdentifier>`JSON 编码)
- 高亮:`ssreader.epub.highlights.<bookIdentifier>`JSON 编码) - 高亮:`ssreader.epub.highlights.<bookIdentifier>`JSON 编码)
- 书签:`ssreader.epub.bookmarks.<bookIdentifier>`JSON 编码)
- 设置:`ssreader.epub.settings`(全局,非按书) - 设置:`ssreader.epub.settings`(全局,非按书)
### 2.5 主题 `RDEPUBReaderTheme` ### 2.5 主题 `RDEPUBReaderTheme`
- 文件:`EPUBUI/RDEPUBReaderTheme.swift` - 文件:`EPUBUI/RDEPUBReaderTheme.swift`~125 行)
- 6 个颜色属性:contentBackgroundColor、contentTextColor、toolBackgroundColor、toolControlTextColor、toolControlBorderUnselectColor、toolLineColor - 6 个颜色属性:contentBackgroundColor、contentTextColor、toolBackgroundColor、toolControlTextColor、toolControlBorderUnselectColor、toolLineColor
- 6 个内置预设:`.light``.dark``.yellow``.green``.pink``.blue` - 6 个内置预设:`.light``.dark``.yellow``.green``.pink``.blue`
- `RDEPUBReaderThemePreset` 枚举将预设映射为可序列化值,用于持久化
- 计算属性 `themeBackgroundColorCSS` / `themeTextColorCSS` 用于 Web 渲染路径 - 计算属性 `themeBackgroundColorCSS` / `themeTextColorCSS` 用于 Web 渲染路径
### 2.6 设置面板 `RDEPUBReaderSettingsViewController` ### 2.6 设置面板 `RDEPUBReaderSettingsViewController`
- 文件:`EPUBUI/RDEPUBReaderSettingsViewController.swift` - 文件:`EPUBUI/RDEPUBReaderSettingsViewController.swift`~311 行)
- 5 个控制项: - 5 个控制项:
- 亮度滑块(0-1 - 亮度滑块(0-1
- 字号 A-/A+(范围 12-36,步长 1 - 字号 A-/A+(范围 12-36,步长 1
- 行高分段(紧凑 1.3 / 标准 1.6 / 宽松 1.9 - 行高分段(紧凑 1.3 / 标准 1.6 / 宽松 1.9
- 显示模式分段(仿真 / 横滑 / 竖滑 / 覆盖) - 显示模式分段(仿真 / 横滑 / 竖滑 / 覆盖)
- 主题选择(6 个圆形色块按钮) - 主题选择(6 个圆形色块按钮)
- 所有变更通过闭包实时回调 - 所有变更通过闭包实时回调onBrightnessChange / onFontSizeChange / onLineHeightChange / onDisplayTypeChange / onThemeChange
### 2.7 内容视图 ### 2.7 内容视图
@@ -88,10 +90,17 @@
### 2.8 其他 UI 组件 ### 2.8 其他 UI 组件
- `RDEPUBReaderTopToolView`:顶部导航栏(返回按钮 + 标题) - `RDEPUBReaderToolView``EPUBUI/RDEPUBReaderToolView.swift`):工具栏基类,提供分隔线和主题适配的通用逻辑
- `RDEPUBReaderBottomToolView`底部工具栏(目录 / 批注 / 标注 / 设置 4 个按钮 - `RDEPUBReaderTopToolView`顶部导航栏(返回按钮 + 书签切换 + 标题
- `RDEPUBReaderBottomToolView`:底部工具栏(目录 / 书签 / 标注 / 设置 4 个按钮)
- `RDEPUBReaderChapterListController`:目录列表(modal UITableViewController,当前项高亮 systemBlue - `RDEPUBReaderChapterListController`:目录列表(modal UITableViewController,当前项高亮 systemBlue
- `RDEPUBReaderHighlightsViewController`:标注管理(全部 / 批注 / 划线过滤,列表 + 编辑备注 + 删除 + 跳转,空态按过滤条件提示) - `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. 主流程(代码级)
@@ -229,22 +238,26 @@
| 书签列表 | `ssreader.epub.bookmarks.<bookIdentifier>` | JSON → `[RDEPUBBookmark]` | | 书签列表 | `ssreader.epub.bookmarks.<bookIdentifier>` | JSON → `[RDEPUBBookmark]` |
| 阅读设置 | `ssreader.epub.settings` | JSON → `RDEPUBReaderSettings` | | 阅读设置 | `ssreader.epub.settings` | JSON → `RDEPUBReaderSettings` |
### 5.2 Book Identifier ### 5.2 设置模型
- 优先使用 `parser.metadata.identifier`
- 回退使用 `epubURL.lastPathComponent`
### 5.3 设置快照
```swift ```swift
RDEPUBReaderSettings (Codable) RDEPUBReaderSettings (Codable)
brightness: CGFloat brightness: CGFloat?
fontSize: CGFloat fontSize: CGFloat?
lineHeightMultiple: CGFloat lineHeightMultiple: CGFloat?
displayMode: RDEPUBReaderDisplayMode displayMode: RDEPUBReaderDisplayMode?
themePreset: RDEPUBReaderThemePreset? 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 目录扁平化项 ### 5.4 目录扁平化项
```swift ```swift
+37 -9
View File
@@ -15,20 +15,27 @@
| 文件 | 职责 | | 文件 | 职责 |
|------|------| |------|------|
| `RDEPUBParser.swift` + 扩展 | EPUB 解压、container.xml、OPF、manifest、spine、TOC/NCX 解析 | | `RDEPUBParser.swift` + 5 扩展 | EPUB 解压、container.xml、OPF、manifest、spine、TOC/NCX 解析、阅读配置文件判断 |
| `RDEPUBPublication.swift` | 解析结果的唯一聚合入口,外部不直接访问 parser 字段 | | `RDEPUBPublication.swift` | 解析结果的唯一聚合入口,外部不直接访问 parser 字段 |
| `RDEPUBModels.swift` | metadata / manifest / spine / TOC 基础模型 | | `RDEPUBModels.swift` | metadata / manifest / spine / TOC 基础模型 |
| `RDEPUBReadingModels.swift` | RDEPUBLocation、RDEPUBViewport、RDEPUBHighlight、选区模型 | | `RDEPUBReadingModels.swift` | RDEPUBLocation、RDEPUBViewport、RDEPUBHighlight、RDEPUBBookmark、选区模型 |
| `RDEPUBResourceResolver.swift` | href → fileURL / schemeURL 的统一入口,`normalizedHref` 标准化路径 | | `RDEPUBResourceResolver.swift` | href → fileURL / schemeURL 的统一入口,`normalizedHref` 标准化路径 |
| `RDEPUBPaginator.swift` | 离屏 WKWebView 分页,计算每个 spine 资源被视口切成多少页 | | `RDEPUBPaginator.swift` | 离屏 WKWebView 分页,计算每个 spine 资源被视口切成多少页 |
| `RDEPUBReadingSession.swift` | 状态机 + 会话协调,管理 staged/active/pending 页模型 | | `RDEPUBReadingSession.swift` | 状态机 + 会话协调,管理 staged/active/pending 页模型 |
| `RDEPUBNavigatorState.swift` | 状态枚举(initializing/loading/idle/jumping/moving/repaginating | | `RDEPUBNavigatorState.swift` | 状态枚举(initializing/loading/idle/jumping/moving/repaginating |
| `RDEPUBWebView.swift` + 扩展 | 承载 spine 资源的 WKWebView,分页 CSS 注入、JS bridge、fixed wrapper | | `RDEPUBWebView.swift` + 5 扩展 | 承载 spine 资源的 WKWebView,分页 CSS 注入、JS bridge、fixed wrapper、搜索装饰 |
| `RDEPUBResourceURLSchemeHandler.swift` | `ss-reader://` 协议,从本地解压目录提供所有 EPUB 资源 | | `RDEPUBResourceURLSchemeHandler.swift` | `ss-reader://` 协议,从本地解压目录提供所有 EPUB 资源 |
| `RDEPUBStyleSheetBuilder.swift` | 生成注入 WebView 的 CSS | | `RDEPUBStyleSheetBuilder.swift` | 生成注入 WebView 的 CSS |
| `RDEPUBJavaScriptBridge.swift` | JS ↔ Swift 消息定义和编解码 | | `RDEPUBJavaScriptBridge.swift` | JS ↔ Swift 消息定义和编解码 |
| `RDEPUBSearchEngine.swift` | 搜索协议定义 + WebView 全文搜索引擎 | | `RDEPUBSearchEngine.swift` | 搜索协议定义 + WebView 全文搜索引擎 |
| `RDEPUBSearchModels.swift` | 搜索模型(SearchMatch/Result/State/Presentation | | `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 层 ### 2.2 EPUBTextRendering 层
@@ -39,17 +46,38 @@
| `RDEPUBTextRendererSupport.swift` | fragment 标记注入、fragment offset 提取、属性标准化 | | `RDEPUBTextRendererSupport.swift` | fragment 标记注入、fragment offset 提取、属性标准化 |
| `RDEPUBTextPaginationSupport.swift` | NSAttributedString 切页算法 | | `RDEPUBTextPaginationSupport.swift` | NSAttributedString 切页算法 |
| `RDEPUBTextBookBuilder.swift` | 驱动逐章节渲染和分页,生成 RDEPUBTextBook | | `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 层 ### 2.3 EPUBUI 层
| 文件 | 职责 | | 文件 | 职责 |
|------|------| |------|------|
| `RDEPUBReaderController.swift` | 开箱即用读者控制器,整合 session / readerView / UI | | `RDEPUBReaderController.swift` | 开箱即用读者控制器~1995 行),整合 session / readerView / UI |
| `RDEPUBReaderConfiguration.swift` | 外部配置项(字号、主题、翻页模式等) | | `RDEPUBReaderConfiguration.swift` | 外部配置项(13 个:字号、主题、翻页模式等) |
| `RDEPUBReaderPersistence.swift` | 阅读位置高亮的本地持久化 | | `RDEPUBReaderPersistence.swift` | 阅读位置高亮、书签的本地持久化8 个方法) |
| `RDEPUBReaderSettings.swift` | 运行时设置状态 | | `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 | | `RDEPUBWebContentView.swift` | WKWebView 内容视图,供 RDReaderView 渲染 WebView 类 spine |
| `RDEPUBTextContentView.swift` | 文本内容视图,供 RDReaderView 渲染文本类 spine | | `RDEPUBTextContentView.swift` | 文本内容视图~728 行),供 RDReaderView 渲染文本类 spine |
| `RDEPUBPageInteractionController.swift` | CoreText 页面级交互控制器 |
| `RDEPUBSelectionOverlayView.swift` | 选区覆盖视图 |
| `RDEPUBPageLayoutSnapshot.swift` | 页面几何模型 |
| `RDURLReaderController.swift` | 最简 URL 入口(.epub/.txt 自动识别) |
--- ---
@@ -289,7 +317,7 @@ webView.loadHTMLString(htmlString, baseURL: nil)
## 7. 后续扩展建议 ## 7. 后续扩展建议
1. **拆分 demo 层 RDReaderController**:约 1900+ 行,EPUB 加载、UI、数据源职责仍混在一起,建议继续向 library 层迁移 1. **拆分 RDEPUBReaderController**:约 1995 行,EPUB 加载、UI、数据源职责仍混在一起,建议继续向 library 层迁移
2. **基线验证**:用四本样书跑一轮分页耗时、目录命中率、末页事件、设置恢复稳定性数据 2. **基线验证**:用四本样书跑一轮分页耗时、目录命中率、末页事件、设置恢复稳定性数据
3. **横竖屏支持回归矩阵**:控制器级 `viewWillTransition()` 与 viewport 去重已接入,下一步重点是补齐 pageCurl / scroll、三条正文路径和 iPad 视口变化场景的固定回归 3. **横竖屏支持回归矩阵**:控制器级 `viewWillTransition()` 与 viewport 去重已接入,下一步重点是补齐 pageCurl / scroll、三条正文路径和 iPad 视口变化场景的固定回归
4. **Pod 分层**:评估是否将 EPUBCore / EPUBTextRendering / EPUBUI 拆为独立 subspec 4. **Pod 分层**:评估是否将 EPUBCore / EPUBTextRendering / EPUBUI 拆为独立 subspec
@@ -0,0 +1,365 @@
# 将高亮选区实现 1:1 复刻为 WXRead 架构
## Context
当前 ReadViewSDK 的高亮选区实现与 WXRead 存在根本性架构差异。需要将选区系统从"UITextView 透明代理 + 独立 overlay 层"迁移到 WXRead 的"自定义手势 + CoreText 直接命中测试 + 统一 drawRect 绘制"架构。
## 核心差异对比
| 维度 | 当前 ReadViewSDK | WXRead |
|------|-----------------|--------|
| 选择触发 | UITextView 原生长按(透明文本) | 自定义 long-press(0.5s) + pan 手势 |
| 命中测试 | DTCoreText `stringIndex(forPosition:)` | `CTLineGetStringIndexForPosition` + 坐标翻转 |
| 选区绘制 | 独立 `RDEPUBSelectionOverlayView` overlay 层 | 同一 `drawRect:` 内绘制(文字+高亮+选区) |
| 高亮绘制 | overlay 层计算 rect 后 CG 填充 | `com.weread.highlight` 自定义属性注入 NSAttributedString,在 `drawInContext:` 中读取绘制 |
| 菜单系统 | 自定义 `selectionActionBar` UIStackView | `UIMenuController` + 自定义 items |
| 手势模型 | 无 pan 手势(UITextView 自带拖拽) | long-press 启动 + pan 扩展,`isSelecting` 控制 pan 启停 |
| 视图层级 | 3 层 overlaybackground + text + foreground | 单一 WRPageView 统一绘制 |
---
## Phase 1: 移除 UITextView,改用自定义手势 + CoreText 命中测试
### 1.1 修改 `RDEPUBPageInteractionController.swift`
当前已正确封装 DTCoreText 的 `stringIndex(forPosition:)``offset(forStringIndex:)`,算法与 WXRead 一致。
**新增方法:**
- `characterIndexForViewPoint(at viewPoint: CGPoint, in view: UIView)` — 将 UIKit 坐标转为相对于 content view 的坐标后调用 `characterIndex(at:)`,对应 WXRead 的 `stringIndexForPoint:` + `WRSFlipPointForCoreText`
> 注意:DTCoreText 已在内部处理了 UIKit↔CoreText 坐标翻转(`line.baselineOrigin` 是 UIKit 坐标),所以不需要手动翻转 Y 轴。但 WXRead 的自定义 DTCoreText 需要手动翻转。当前项目用的是原版 DTCoreText pod,行为已正确。
### 1.2 重构 `RDEPUBTextSelectionController.swift`
**当前状态:** 遵循 `UITextViewDelegate`,通过 `textViewDidChangeSelection` 接收选区变化。`handleLongPress` 方法存在但未被任何手势调用。
**改为:**
- 移除 `UITextViewDelegate` 遵循
- 移除 `textViewDidChangeSelection(_:)``textViewDidChangeSelection(_:, page:)`
- 新增状态属性:`selectionStartIndex: Int = NSNotFound``selectionEndIndex: Int = NSNotFound``isSelecting: Bool = false`
- 重构 `handleLongPress`
- `.began`:调用 `characterIndex(at:)` 设置 `selectionStartIndex = selectionEndIndex = index`,设 `isSelecting = true`
- `.ended`:设 `isSelectionFromInteraction = false`(保留,供后续扩展)
- 新增 `handlePan(_ gesture:, page:, renderView:, interactionController:)`
- `.changed`:计算字符索引,更新 `selectionEndIndex`,计算 range = `(min, max - min)`,计算 rects,更新 renderView
- 移除 `clearSelection``textView:` 参数
- `makeSelection(from:, page:)` 保持不变(已正确基于绝对偏移构建 `RDEPUBSelection`
### 1.3 重构 `RDEPUBTextContentView.swift` — 移除 UITextView
**删除:**
- `textView: RDEPUBSelectableTextView` 属性及其初始化
- `selectionProxyContent(from:)` 方法
- `textView.delegate = selectionController` 等 textView 配置代码
- `textView.frame = ...``layoutSubviews` 中的设置
- `configure(page:...)` 中所有 `textView.attributedText = ...``textView.selectedRange = ...``textView.isHidden = ...` 赋值
**新增手势识别器(对齐 WXRead 的 WRPageView):**
```swift
private let longPressGR = UILongPressGestureRecognizer(target: ..., action: #selector(handleLongPress))
private let panGR = UIPanGestureRecognizer(target: ..., action: #selector(handlePan))
private let tapGR = UITapGestureRecognizer(target: ..., action: #selector(handleTap))
```
- `longPressGR.minimumPressDuration = 0.5`(与 WXRead 一致)
- `panGR.isEnabled = false`(初始禁用,long-press began 时启用)
- `tapGR.require(toFail: longPressGR)`(与 WXRead 一致)
- 三个手势都添加到 contentView 自身
**手势响应:**
- `handleLongPress`:转发给 `selectionController.handleLongPress`,启用 `panGR`
- `handlePan`:转发给 `selectionController.handlePan`
- `handleTap`:如果 `selectionController.isSelecting``clearSelection()`,否则转发给 delegate 做工具栏切换
**菜单改为 UIMenuController(对齐 WXRead):**
- 删除 `selectionActionBar: UIStackView` 及相关方法(`showSelectionActionBarIfNeeded``hideSelectionActionBar``updateSelectionActionBarFrame``selectionMenuButton`
-`selectionController.onSelectionChanged` 回调中,当 selection 非 nil 时调用 `showSelectionMenu(in:anchorRect:)`
- `RDEPUBTextContentView` 设为 `canBecomeFirstResponder = true`override `canPerformAction` 仅允许三个自定义 selector
- 使用 `UIMenuController.shared` 配置 "拷贝"/"高亮"/"批注" 三个 `UIMenuItem`
**调整 `clearSelection()`**
```swift
func clearSelection() {
currentSelection = nil
menuSelection = nil
panGR.isEnabled = false
selectionController.clearSelection(overlayView: overlayView, backgroundOverlayView: backgroundOverlayView)
UIMenuController.shared.setMenuVisible(false, animated: true)
}
```
### 1.4 删除 `RDEPUBSelectableTextView.swift`
该文件的功能(屏蔽系统菜单、暴露自定义 action)已被 UIMenuController 方案替代,直接删除。
### 1.5 更新 `RDEPUBReaderController+ContentDelegates.swift`
`textContentView(_:, didRequestSelectionAction:, selection:)` 中的 `contentView.clearSelection()` 调用无需改动,新的 `clearSelection()` 签名兼容。
### Phase 1 验证
- UI 测试 `ReaderAnnotationTests.testSelectionMenuCreatesHighlight` 必须通过
- 手动验证:长按选词 → 弹出 UIMenuController → 点击"高亮" → 高亮创建成功
- 手动验证:拖拽扩展选区 → 蓝色选区矩形正确绘制
- 手动验证:单击空白处 → 选区清除
---
## Phase 2: 统一绘制循环 — 高亮/选区在 draw(_:) 中绘制
### 2.1 扩展 `RDEPUBTextPageRenderView.swift`
**当前状态:** 仅调用 `layoutFrame.draw(in: context, options:)` 绘制文字。
**新增属性:**
```swift
var highlightRanges: [(range: NSRange, color: UIColor)] = [] { didSet { setNeedsDisplay() } }
var underlineRanges: [(range: NSRange, color: UIColor, style: Int)] = [] { didSet { setNeedsDisplay() } }
var selectionRects: [CGRect] = [] { didSet { setNeedsDisplay() } }
var selectionColor: UIColor = UIColor(red: 70/255, green: 140/255, blue: 1, alpha: 0.24)
```
**扩展 `draw(_:)`**
```swift
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext(), let layoutFrame else { return }
context.saveGState()
// 1. WXRead drawHighlightsInContext:
drawHighlights(in: context, layoutFrame: layoutFrame)
// 2.
layoutFrame.draw(in: context, options: drawOptions)
// 3. WXRead _drawSelectionInContext:
drawSelection(in: context)
context.restoreGState()
}
```
**`drawHighlights` 算法(对齐 WXRead `WRCoreTextLayoutFrame.drawHighlightsInContext:`):**
```swift
private func drawHighlights(in context: CGContext, layoutFrame: DTCoreTextLayoutFrame) {
for (range, color) in highlightRanges {
let lines = layoutFrame.lines as! [DTCoreTextLayoutLine]
for line in lines {
let overlap = NSIntersectionRange(range, line.stringRange)
guard overlap.length > 0 else { continue }
let startX = line.offset(forStringIndex: overlap.location)
let endX = line.offset(forStringIndex: overlap.location + overlap.length)
let rect = CGRect(
x: line.baselineOrigin.x + startX,
y: line.baselineOrigin.y - line.ascent,
width: endX - startX,
height: line.ascent + line.descent
)
color.withAlphaComponent(0.35).setFill() // WXRead 使 35% alpha
context.fill(rect)
}
}
}
```
> 说明:WXRead 使用 `[color colorWithAlphaComponent:0.3]`WRPageHighlight 的预设色本身已是 35% alpha,最终效果等同。当前项目使用 0.45 alpha,需调整为 0.35 以完全对齐。
**`drawSelection` 算法(对齐 WXRead `_drawSelectionInContext:`):**
```swift
private func drawSelection(in context: CGContext) {
guard !selectionRects.isEmpty else { return }
selectionColor.setFill()
for rect in selectionRects {
context.fill(rect)
}
}
```
### 2.2 简化 `RDEPUBTextContentView.swift` 视图层级
**删除/保留:**
- 删除 `backgroundOverlayView` 属性(高亮背景现在由 renderView 在文字下方绘制)
- 保留 `overlayView`(用于非 DTCoreText 回退路径和搜索高亮)
- `configure(page:...)` 中,将 highlight 数据传给 `coreTextContentView` 而非 overlayView
```swift
// DTCoreText
coreTextContentView.highlightRanges = highlights.compactMap { highlight -> (NSRange, UIColor)? in
guard let rangeInfo = highlight.rangeInfo,
let info = RDEPUBTextOffsetRangeInfo.decode(from: rangeInfo) else { return nil }
let absoluteRange = info.nsRange
let overlap = NSIntersectionRange(absoluteRange, pageAbsoluteRange)
guard overlap.length > 0 else { return nil }
let relativeRange = NSRange(location: overlap.location - page.pageStartOffset, length: overlap.length)
return (relativeRange, highlight.uiColor)
}
```
### 2.3 更新 `RDEPUBTextSelectionController.swift` — 直接更新 renderView
- `handleLongPress``handlePan` 现在直接更新 `renderView.selectionRects` 并调用 `renderView.setNeedsDisplay()`
- 移除 `overlayView.updateSelection(absoluteRange:, rects:)` 调用
### Phase 2 验证
- 视觉对比:高亮矩形与文字像素对齐
- 性能测试:单次 `draw(_:)` 耗时应与之前持平或更快
- 回归测试:搜索高亮仍正常显示
---
## Phase 3: 高亮属性注入 NSAttributedString(对齐 WXRead `com.weread.highlight`
### 3.1 定义自定义属性常量
```swift
// WXRead kWRHighlightAttributeName / kWRUnderlineAttributeName
let kRDEPUBHighlightAttributeName = NSAttributedString.Key("com.rdreader.highlight")
let kRDEPUBUnderlineAttributeName = NSAttributedString.Key("com.rdreader.underline")
```
### 3.2 新增 `RDEPUBChapterData.applyHighlights(to:page:highlights:)`
对齐 WXRead 的 `WRChapterData.addHighlightInRange:key:itemId:color:`
```swift
func applyHighlights(
to content: NSMutableAttributedString,
page: RDEPUBTextPage,
highlights: [RDEPUBHighlight]
) {
for highlight in highlights {
guard let rangeInfo = highlight.rangeInfo,
let info = RDEPUBTextOffsetRangeInfo.decode(from: rangeInfo) else { continue }
let absoluteRange = info.nsRange
let pageRange = NSRange(location: page.pageStartOffset, length: page.pageEndOffset - page.pageStartOffset)
let overlap = NSIntersectionRange(absoluteRange, pageRange)
guard overlap.length > 0 else { continue }
let relativeRange = NSRange(location: overlap.location - page.pageStartOffset, length: overlap.length)
switch highlight.style {
case .highlight:
content.addAttribute(kRDEPUBHighlightAttributeName, value: highlight.uiColor, range: relativeRange)
case .underline:
content.addAttribute(kRDEPUBUnderlineAttributeName, value: highlight.uiColor, range: relativeRange)
}
}
}
```
### 3.3 `RDEPUBTextPageRenderView.draw(_:)` 从属性读取高亮
替代 Phase 2 的 `highlightRanges` 属性方案,改为在 `draw(_:)` 中枚举 attributed string 的自定义属性:
```swift
private func drawHighlightsFromAttributes(in context: CGContext, attributedString: NSAttributedString) {
let fullRange = NSRange(location: 0, length: attributedString.length)
attributedString.enumerateAttribute(kRDEPUBHighlightAttributeName, in: fullRange) { value, range, _ in
guard let color = value as? UIColor else { return }
let rects = computeRects(for: range) // line + CTLineGetOffsetForStringIndex
color.withAlphaComponent(0.35).setFill()
for rect in rects { context.fill(rect) }
}
}
```
### 3.4 更新 `RDEPUBTextContentView.configure(page:...)`
```swift
// renderView
let displayContent = darkImageAdjustedContentIfNeeded(...)
chapterData.applyHighlights(to: displayContent, page: page, highlights: highlights)
coreTextContentView.attributedDisplayContent = displayContent //
```
### Phase 3 验证
- 高亮在翻页后仍正确显示(属性嵌入 attributed string,不依赖外部状态)
- 高亮颜色、alpha、rect 与 WXRead 截图一致
---
## Phase 4: 手势模型对齐 — long-press + pan + isSelecting
### 4.1 手势冲突处理
**风险:** pan 手势(选区扩展)可能与 RDReaderView 的翻页手势冲突。
**解决方案(对齐 WXRead):**
- `panGR` 初始 `isEnabled = false`,仅在 `isSelecting = true` 时启用
- `clearSelection()` 时禁用 `panGR`
- 新增 delegate 方法通知父视图:
```swift
func textContentViewDidBeginSelection(_ contentView: RDEPUBTextContentView)
func textContentViewDidEndSelection(_ contentView: RDEPUBTextContentView)
```
- `RDReaderView` 在 `didBeginSelection` 时禁用翻页手势,在 `didEndSelection` 时恢复
### 4.2 WXRead 手势时序对齐
WXRead 的手势流程:
1. long-press `.began` → 设置 `selectionStartIndex = selectionEndIndex = index``isSelecting = true`,启用 panGR`setNeedsDisplay`
2. long-press `.ended` → 无额外操作(保留选区)
3. pan `.changed` → 更新 `selectionEndIndex`,计算 rects`setNeedsDisplay`
4. single tap → `clearSelection()`,禁用 panGR
### Phase 4 验证
- 长按选词 → 拖拽扩展 → 单击取消,全流程流畅
- 无选区时翻页手势正常
- 有选区时翻页手势被禁用
---
## Phase 5: 菜单系统对齐 + 清理
### 5.1 UIMenuController 替换 selectionActionBar
已在 Phase 1 中完成。此阶段仅做清理:
- 删除 `RDEPUBSelectableTextView.swift`
- 标记 `RDEPUBSelectionOverlayView.swift` 和 `RDEPUBTextPageDecorationView.swift` 为 deprecated(保留给非 DTCoreText 回退路径)
### 5.2 更新 UI 测试
`ReaderAnnotationTests` 中查找菜单项的方式需更新:
- 当前:`app.buttons["高亮"]`UIStackView 中的按钮)
- 改为:`app.menuItems["高亮"]`UIMenuController 的菜单项)
- 或者:保留 `accessibilityIdentifier` 在 RDEPUBTextContentView 上以便测试定位
### 5.3 高亮颜色对齐
WXRead 的 5 种预设色(35% alpha):
- Yellow: `(1.0, 0.92, 0.23, 0.35)`
- Blue: `(0.26, 0.65, 0.96, 0.35)`
- Red: `(0.96, 0.26, 0.26, 0.35)`
- Green: `(0.30, 0.85, 0.39, 0.35)`
- Purple: `(0.67, 0.33, 0.97, 0.35)`
当前项目使用 CSS hex 颜色 + 0.45 alpha,需对齐为 WXRead 的 RGBA 值。
---
## 文件变更清单
| 文件 | 操作 | Phase |
|------|------|-------|
| `RDEPUBPageInteractionController.swift` | 修改:新增 `characterIndexForViewPoint` | 1 |
| `RDEPUBTextSelectionController.swift` | 重构:移除 UITextViewDelegate,新增 pan 处理、状态机 | 1 |
| `RDEPUBTextContentView.swift` | 重构:移除 textView,新增手势,替换菜单,简化层级 | 1,2,4 |
| `RDEPUBSelectableTextView.swift` | **删除** | 1 |
| `RDEPUBTextPageRenderView.swift` | 扩展:新增高亮/选区/下划线绘制逻辑 | 2,3 |
| `RDEPUBChapterData.swift` | 新增:`applyHighlights(to:page:highlights:)` | 3 |
| `RDEPUBReaderController+ContentDelegates.swift` | 小改:适配新 clearSelection 签名 | 1 |
| `RDReaderView.swift` | 新增:选区期间禁用翻页手势 | 4 |
| `RDEPUBSelectionOverlayView.swift` | 保留(deprecated for DTCoreText path | 5 |
| `RDEPUBTextPageDecorationView.swift` | 保留(deprecated for DTCoreText path | 5 |
| `RDEPUBTextAnnotationOverlay.swift` | 保留(deprecated for DTCoreText path | 5 |
| `ReaderAnnotationTests.swift` | 更新:菜单项查找方式 | 5 |
## 验证方案
1. **UI 测试**`ReaderAnnotationTests` 全部通过
2. **手动测试**
- 长按选词 → 蓝色选区高亮显示
- 拖拽扩展选区 → 选区跟随手指
- 点击"高亮" → 黄色高亮创建成功
- 翻页后返回 → 高亮仍存在
- 点击"拷贝" → 文本已复制
- 点击"批注" → 弹出笔记输入框
- 单击空白处 → 选区清除
3. **性能测试**`draw(_:)` 耗时 ≤ 之前(单次绘制 vs 三次绘制)
4. **对比验证**:与 WXRead 截图对比高亮颜色、alpha、rect 位置
+11 -30
View File
@@ -2,18 +2,18 @@
## 1. 范围与目标 ## 1. 范围与目标
- 代码范围:`Sources/RDReaderView/` 根目录(6 个 Swift 文件) - 代码范围:`Sources/RDReaderView/ReaderView/`5 个 Swift 文件)
- 目标:说明分页阅读器容器如何管理种显示模式、DataSource/Delegate 协议、翻页交互、工具栏动画、双屏适配和 RTL 支持。 - 目标:说明分页阅读器容器如何管理种显示模式、DataSource/Delegate 协议、翻页交互、工具栏动画、双屏适配和 RTL 支持。
- 主链路关键词:`RDReaderDataSource -> reloadData -> DisplayType 切换 -> 翻页/滚动 -> RDReaderDelegate.pageNum -> 工具栏显隐` - 主链路关键词:`RDReaderDataSource -> reloadData -> DisplayType 切换 -> 翻页/滚动 -> RDReaderDelegate.pageNum -> 工具栏显隐`
## 2. 关键对象职责 ## 2. 关键对象职责
### 2.1 核心容器 `RDReaderView` ### 2.1 核心容器 `RDReaderView`
- 文件:`Sources/RDReaderView/RDReaderView.swift`~717 行) - 文件:`Sources/RDReaderView/ReaderView/RDReaderView.swift`~1219 行)
- 入口方法:`reloadData()` - 入口方法:`reloadData()`
- 职责: - 职责:
- 管理种显示模式的视图层级切换 - 管理种显示模式的视图层级切换
- 持有 `UIPageViewController`pageCurl 模式)或 `UICollectionView`(滚动模式) - 持有 `UIPageViewController`pageCurl 模式)或 `UICollectionView`(滚动模式)
- 处理点击手势(左/中/右三区域) - 处理点击手势(左/中/右三区域)
- 管理工具栏(topToolView / bottomToolView)的显示/隐藏动画 - 管理工具栏(topToolView / bottomToolView)的显示/隐藏动画
@@ -23,17 +23,16 @@
### 2.2 自定义布局 `RDReaderFlowLayout` ### 2.2 自定义布局 `RDReaderFlowLayout`
- 文件:`Sources/RDReaderView/RDReaderFlowLayout.swift`~387 行) - 文件:`Sources/RDReaderView/ReaderView/RDReaderFlowLayout.swift`~375 行)
- 职责: - 职责:
- 继承 `UICollectionViewFlowLayout`,为种滚动模式提供布局计算 - 继承 `UICollectionViewFlowLayout`,为种滚动模式提供布局计算
- 水平滚动:全屏宽 itempagingEnabled - 水平滚动:全屏宽 itempagingEnabled
- 垂直滚动:可变高度 item,累加计算 - 垂直滚动:可变高度 item,累加计算
- 水平覆盖滚动:zIndex 分层 + 阴影效果模拟深度
- 封面感知帧计算:封面页全屏宽,后续页面两两配对半屏宽 - 封面感知帧计算:封面页全屏宽,后续页面两两配对半屏宽
### 2.3 内容 Cell `RDReaderContentCell` ### 2.3 内容 Cell `RDReaderContentCell`
- 文件:`Sources/RDReaderView/RDReaderContentCell.swift`~37 行) - 文件:`Sources/RDReaderView/ReaderView/RDReaderContentCell.swift`~55 行)
- 职责: - 职责:
- `UICollectionViewCell` 子类,作为内容视图的薄壳宿主 - `UICollectionViewCell` 子类,作为内容视图的薄壳宿主
- `containerView` 属性 setter 自动移除旧视图、添加新视图 - `containerView` 属性 setter 自动移除旧视图、添加新视图
@@ -41,7 +40,7 @@
### 2.4 页面子控制器 `RDReaderPageChildViewController` ### 2.4 页面子控制器 `RDReaderPageChildViewController`
- 文件:`Sources/RDReaderView/RDReaderPageChildViewController.swift`~65 行) - 文件:`Sources/RDReaderView/ReaderView/RDReaderPageChildViewController.swift`~89 行)
- 职责: - 职责:
- 仅用于 pageCurl 模式,作为 `UIPageViewController` 的子控制器 - 仅用于 pageCurl 模式,作为 `UIPageViewController` 的子控制器
- 持有 `contentView: UIView?``pageNum: Int` - 持有 `contentView: UIView?``pageNum: Int`
@@ -49,21 +48,11 @@
### 2.5 手势控制器 `RDReaderGestureController` ### 2.5 手势控制器 `RDReaderGestureController`
- 文件:`Sources/RDReaderView/RDReaderGestureController.swift`~42 行) - 文件:`Sources/RDReaderView/ReaderView/RDReaderGestureController.swift`~58 行)
- 职责: - 职责:
- 当前为占位组件,存储 topToolView / bottomToolView 引用 - 当前为占位组件,存储 topToolView / bottomToolView 引用
- 实际手势逻辑在 `RDReaderView.tapCenter()` 中实现 - 实际手势逻辑在 `RDReaderView.tapCenter()` 中实现
### 2.6 URL 入口 `RDURLReaderController`
- 文件:`Sources/RDReaderView/RDURLReaderController.swift`~105 行)
- 职责:
- 最简入口:传入 URL 即可打开书籍
- `.epub` 扩展名 → 创建 `RDEPUBReaderController(epubURL:configuration:)`
- 其他扩展名 → 使用 `RDPlainTextBookBuilder` 构建 `RDEPUBTextBook`,然后创建 `RDEPUBReaderController(textBook:bookIdentifier:title:textFileURL:configuration:)`
- 分页失败时回退到纯 `UITextView` 展示(尝试 UTF-8 → GBK → GB2312 解码)
- 导航栏标题设为文件名(去掉扩展名)
## 3. 主流程(代码级) ## 3. 主流程(代码级)
### 3.1 协议定义 ### 3.1 协议定义
@@ -90,7 +79,7 @@ func pageNum(readerView: RDReaderView, pageNum: Int)
3. `reloadData` 内部调用 `switchReaderDisplayType(currentDisplayType)` 重建视图层级。 3. `reloadData` 内部调用 `switchReaderDisplayType(currentDisplayType)` 重建视图层级。
4. 同时从 `dataSource` 获取 `topToolView``bottomToolView` 并添加到视图层级。 4. 同时从 `dataSource` 获取 `topToolView``bottomToolView` 并添加到视图层级。
### 3.3 种显示模式切换 ### 3.3 种显示模式切换
**pageCurl 模式** **pageCurl 模式**
- 创建 `UIPageViewController`transitionStyle: .pageCurl - 创建 `UIPageViewController`transitionStyle: .pageCurl
@@ -109,13 +98,6 @@ func pageNum(readerView: RDReaderView, pageNum: Int)
- 页面高度可变,通过 `RDReaderFlowLayoutDataSoure.heigtOfVerticalScrollPage` 查询 - 页面高度可变,通过 `RDReaderFlowLayoutDataSoure.heigtOfVerticalScrollPage` 查询
- collectionViewContentSize 为所有页面高度之和 - collectionViewContentSize 为所有页面高度之和
**horizontalCoverScroll 模式**
- 水平分页,但带封面滑动动画
- `layoutAttributesForElements` 中:
- 仅计算当前页附近的窄窗口内的 item attributes
- 当前页之前的 item zIndex = -1,当前及之后 zIndex = 1
- 顶层页面边缘添加阴影效果
### 3.4 翻页交互 ### 3.4 翻页交互
**点击手势**`tapAction(tap:)`): **点击手势**`tapAction(tap:)`):
@@ -180,8 +162,7 @@ func pageNum(readerView: RDReaderView, pageNum: Int)
RDReaderView.DisplayType RDReaderView.DisplayType
.pageCurl // UIPageViewController .pageCurl // UIPageViewController
.horizontalScroll // UICollectionView .horizontalScroll // UICollectionView
.verticalScroll // UICollectionView .verticalScroll // UICollectionView
.horizontalCoverScroll // UICollectionView
``` ```
### 5.2 翻页方向 ### 5.2 翻页方向
+415
View File
@@ -0,0 +1,415 @@
# 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。
@@ -1,560 +0,0 @@
# 读书 EPUB 阅读器实现架构分析
> 分析日期: 2026-05-18
> 基于读书 v10.0.3
---
## 核心结论:双渲染引擎架构
读书使用了 **两套渲染引擎**,根据内容类型选择不同的渲染路径:
```
┌─────────────────────────────────────────────────┐
│ WRReaderViewController │
│ (阅读器主控制器, 管理翻页和状态) │
├─────────────────────────────────────────────────┤
│ WRPageViewController │
│ (基于 UIPageViewController) │
│ 支持 UIPageCurl(仿真翻页) + Scroll(滑动) │
├──────────────────────┬──────────────────────────┤
│ 路径A: 原生渲染 │ 路径B: WebView 渲染 │
│ (EPUB/书籍正文) │ (公众号/文集文章) │
│ │ │
│ WREpubTypesetter │ WKWebView + JS Bridge │
│ DTCoreText │ weread-highlighter.js │
│ CoreText 排版 │ rangy-*.js │
│ NSAttributedString │ MediaPlatform.js/css │
│ WRCoreTextLayouter │ Readability.js │
│ WRPageView (draw) │ WRMPPageView │
└──────────────────────┴──────────────────────────┘
```
---
## 路径A:EPUB 正文原生渲染 (核心路径)
这是 EPUB 阅读的**主要渲染方式**,完全用原生 CoreText 实现,不走 WebView。
### 1. EPUB 下载与解密流程
```
服务器 ZIP 包 (加密)
WRBookNetwork.loadTarForEpubBookId:chapter:isPreload:
WRBookNetwork.handleUnzipWithBookId:zipPath:encryptKey:plainBookDirectory:
│ - 使用 encryptKey 解密
│ - 解压到 plainBookDirectory
│ - 处理解压错误 handleUnzipErrorWithPath:
WRBookNetwork.processEncryptedBookFileAtPath:encryptKey:book:chapterUid:isFromReview:
WREncryptedFileManager.decryptContentsOfFile:forBookId:isFileLost:
│ - keyForBookId: 获取每本书的密钥
│ - 解密 EPUB 章节文件
WREncryptedFileManager.encryptFileForBookId:originalEncryptKey:atPath:toPath:
│ - 本地二次加密存储 (DRM 保护)
本地缓存: epubImage 目录 + 解密后的 XHTML 文件
```
**密钥管理**
- `WRPreloadBookManager.saveEncryptKey:forPath:bookId:` - 预加载章节密钥
- `WRPreloadBookManager.encryptKeyForPath:bookId:` - 读取密钥
- `WREncryptedFileManager.keyForBookId:` - 每本书独立密钥
### 2. EPUB 解析与排版流程
```
XHTML 章节文件
WRBookNetwork.fileContentWithChapter:book:shouldRemoveHtmlTags:filterTranslateContent:
│ - 读取 XHTML 内容
│ - 可选去除 HTML 标签
│ - 过滤翻译内容
WREpubTypesetter.attributeStringWithFilePath:priority:insertArticleToolAttachment:
insertBookChapterToolAttachment:insertRecommendView:book:chapter:
pageFlippingStyle:renderErrorReason:isStyleFileNotFound:options:
│ ┌─────────────────────────────────────────┐
│ │ DTHTMLAttributedStringBuilder │
│ │ (HTML -> NSAttributedString 转换器) │
│ │ │
│ │ 1. 解析 XHTML DOM 树 │
│ │ 2. 读取 EPUB 内嵌 CSS (replace.css) │
│ │ 3. 合并默认样式 (default.css) │
│ │ 4. 应用用户主题样式 (dark.css) │
│ │ 5. 转换为 NSAttributedString │
│ │ - 保留字体、颜色、行高、对齐等属性 │
│ │ - 处理图片 (NSTextAttachment) │
│ │ - 处理超链接 │
│ └─────────────────────────────────────────┘
NSAttributedString (富文本)
WRCoreTextLayouter (CoreText 排版引擎)
│ ┌─────────────────────────────────────────┐
│ │ DTCoreText 框架 (自定义修改版) │
│ │ │
│ │ DTCoreTextLayouter │
│ │ └─ CTTypesetter │
│ │ └─ CTFramesetter │
│ │ └─ DTCoreTextLayoutFrame │
│ │ └─ CTFrame (每页) │
│ │ └─ DTCoreTextLayoutLine │
│ │ └─ CTLine (每行) │
│ │ └─ DTCoreTextGlyphRun │
│ │ └─ CTLine (字形) │
│ │ │
│ │ 特殊处理: │
│ │ - wr-vertical-center-style (图片居中) │
│ │ - weread-page-relate (分页控制) │
│ │ - avoidPageBreakInside (避免断页) │
│ │ - 繁简转换 (convertHansToHant) │
│ └─────────────────────────────────────────┘
WRChapterData (章节数据模型)
│ - 包含排版后的 NSAttributedString
│ - 管理划线/高亮/书评等标注
│ - addHighlightInRange:key:itemId:color:
│ - addUnderLineToAttributedString:range:itemId:style:color:
│ - addReviewUnderlineInRange:itemId:type:
│ - generateOutlineContents (生成目录)
│ - freeTrialChapterCutOffStringLocaion (免费试读截断)
分页计算: WRChapterPageCount
│ - rangeValueWithPageInfo: 计算每页的 NSRange
│ - rangeOfPage: 获取指定页的文本范围
WRPageView (页面视图, UIView 子类)
│ - 继承 UIView
│ - drawRect: 中调用 CoreText 绘制
│ - WRCoreTextLayoutFrame.drawInContext:image:size:inRect:position:
│ - 直接用 CGContext 绘制文字和图片
│ - 不使用 UILabel/UITextView
屏幕显示 (像素级精确渲染)
```
### 3. 翻页机制
```
WRPageViewController
│ 基于 UIPageViewController 封装
│ 初始化: initWithDelegate:withPageType:pageFlippingStyle:
│ pageFlippingStyle 支持:
│ ┌────────────────────────────────────┐
│ │ UIPageCurl - 仿真翻页 (纸张卷曲) │
│ │ Scroll - 左右滑动翻页 │
│ └────────────────────────────────────┘
│ 核心方法:
│ - pageViewController:viewControllerBeforeViewController: (上一页)
│ - pageViewController:viewControllerAfterViewController: (下一页)
│ - pageViewController:spineLocationForInterfaceOrientation: (书脊位置)
│ - weread_setViewControllers:withCurlOfType:fromLocation:direction:
│ animated:notifyDelegate:completion: (自定义设置方法)
│ 故障修复:
│ - patchNavigationDirectionFault (导航方向修复)
│ - patchNoViewControllerManagingPageViewFault (页面管理修复)
│ - patchUIPageCurlFault (翻页动画修复)
│ - detectNavigationDirectionCrashWithPageViewController: (崩溃检测)
WRPageView (每个页面的渲染视图)
│ - 通过 WRChapterData 获取排版结果
│ - 通过 rangeOfPage: 获取当前页的文本范围
│ - 使用 CoreText 直接绘制到 CGContext
```
### 4. 文本选择与标注
```
用户触摸/长按
WRPageView 手势识别
文本位置计算 (CoreText hit test)
│ - CTLineGetStringIndexForPosition (坐标->字符索引)
│ - CTLineGetOffsetForStringIndex (字符索引->坐标)
选择范围确定
弹出操作菜单 (UIMenuController)
│ - 划线/高亮
│ - 写想法/书评
│ - 复制
│ - 查询/翻译
│ - 分享
WRChapterData 添加标注
│ - addHighlightInRange:key:itemId:color:
│ - addUnderLineToAttributedString:range:itemId:style:color:
│ - addReviewUnderlineInRange:itemId:type:
保存到服务器
│ - WRBookNetwork.addReview:shareToWechat:...
│ - 同步书签: loadBookmarkListWithBookId:syncKey:callback:
重新排版当前页 (recomposeCurrentPageViewWithSource:)
```
---
## 路径BWebView 渲染 (公众号/文集文章)
用于渲染**微信公众号文章、文集、书评**等富媒体内容。
```
HTML 内容 (来自服务器)
WRMPReadingManager.composeMPReviewHTMLString:withReview:
│ - 组装 HTML 模板
│ - 注入 CSS (MediaPlatform.css, MPExtra.css)
│ - 注入 JS (MediaPlatform.js, mpForArticle.js)
WKWebView 加载
│ 注入脚本:
│ <script src="WeReadApi.js"></script>
│ <script src="rich_display.js"></script>
weread-highlighter.js 初始化
│ 1. rangy.init() - 初始化 Rangy 选择库
│ 2. 创建 Highlighter (TextRange 模式)
│ 3. 注册 ClassApplier:
│ - "highlight" (高亮)
│ - "review" / "friend-review" (书评)
│ - "reference" (引用)
│ - "tts" (语音朗读标记)
│ 4. 监听 selectionchange 事件
│ 5. 通过 wereadBridge.execMPReaderMethod 通知原生
JS Bridge 双向通信
│ 原生 -> JS:
│ - evaluateJavaScript: 调用 JS 方法
│ - WKUserScript 注入脚本
│ JS -> 原生:
│ - window.webkit.messageHandlers.XXX.postMessage()
│ - wereadBridge.execMPReaderMethod('MPReader', data)
WRMPReadingViewModel (ViewModel 层)
│ - addHighlightWithStart:withEnd:withContent:callback:
│ - addReviewWithRange:content:reference:secretMode:withCallback:
│ - genJSInfosWithHighlights:refrencedHighlight:
│ - genJSInfosWithReviews:refrencedReview:
│ - readReviewsWithLoadCount:maxObj:
│ - setupTTSAudioList
WRMPPageView (WebView 包装视图)
```
---
## 关键源码路径 (从二进制中提取)
```
WeRead/Src/Modules/EpubParser/
├── WREpubTypesetter.m # EPUB 排版器
├── WREpubPositionConverter.m # 位置转换器
└── Utils/DTCoreTextFunctions.m # CoreText 工具函数
WeRead/Src/Modules/TypeSetter/
├── WRCoreTextLayouter.m # CoreText 排版器
├── WRCoreTextLayoutFrame.m # 排版帧 (管理页面)
└── DTCoreTextGlyphRun.m # 字形渲染
WeRead/Src/Modules/Reading/
├── Controller/
│ ├── WRReaderViewController.m # 阅读器主控制器
│ └── WRPageViewController.m # 翻页控制器
├── Model/
│ ├── WRChapterData.m # 章节数据模型
│ ├── WRChapterDownloadManger.m # 章节下载管理
│ ├── WRReaderViewModel.m # 阅读器 ViewModel
│ └── WRMPReadingManager.m # 公众号阅读管理
└── View/
└── WRPageView.m # 页面渲染视图
WeRead/Src/Modules/MediaPlatform/
├── Controller/
│ ├── WRMPListViewController.m
│ └── WRMPSubscribeViewController.m
└── Model/
├── WRMPCoverManager.m
├── WRMPCoverPainter.m
├── WRMPStore.m
└── WRMPViewModel.m
```
---
## EPUB CSS 样式系统
```
加载优先级 (从低到高):
1. default.css - 基础 HTML 标签样式 (Safari 默认)
2. replace.css - 读书默认替换样式
├── 标题样式 (h1-h6, 使用 Source Han Serif CN 字体)
├── 代码块 (pre, 使用 Menlo 字体)
├── 图片 (.bodyPic, wr-vertical-center-style: 2)
├── 引用 (.conQuot)
├── 翻译 (.wr-translation)
├── 章节工具 (.book-chapter-tool, .chapter-tool)
└── 分页控制 (.weread-page-relate)
3. dark.css - 暗黑主题样式
4. EPUB 内嵌 CSS - 书籍自带样式
5. 用户设置 - 字号、行高、主题覆盖
```
**自定义 CSS 属性** (读书私有):
- `wr-vertical-center-style: 1|2` - 图片垂直居中方式
- `weread-page-relate: true` - 控制分页时的内容关联
---
## 核心类职责表
| 类名 | 职责 | 渲染路径 |
|---|---|---|
| `WRReaderViewController` | 阅读器主控制器,管理阅读状态、进度保存、章节跳转 | 共用 |
| `WRPageViewController` | 翻页控制器,基于 UIPageViewController 封装 | 共用 |
| `WRPageView` | 页面渲染视图,CoreText 直接绘制 | 路径A |
| `WRMPPageView` | 公众号页面视图,WKWebView 包装 | 路径B |
| `WREpubTypesetter` | EPUB 排版器,HTML->NSAttributedString | 路径A |
| `WRCoreTextLayouter` | CoreText 排版引擎,管理 CTFrame/CTLine | 路径A |
| `WRCoreTextLayoutFrame` | 排版帧,管理单页的绘制 | 路径A |
| `WRChapterData` | 章节数据模型,存储排版结果和标注 | 路径A |
| `WRChapterPageCount` | 分页计算,管理每页的 NSRange | 路径A |
| `WREpubPositionConverter` | EPUB 位置转换器 (文件位置<->字符位置) | 路径A |
| `WRChapterDownloadManger` | 章节下载管理器 | 共用 |
| `WREncryptedFileManager` | 加密文件管理 (DRM) | 共用 |
| `WRBookNetwork` | 书籍网络请求 (下载/解密/解压) | 共用 |
| `WRMPReadingManager` | 公众号阅读管理器 | 路径B |
| `WRMPReadingViewModel` | 公众号阅读 ViewModel (JS Bridge 交互) | 路径B |
| `WRReaderViewModel` | 阅读器 ViewModel | 共用 |
| `DTCoreTextLayouter` | DTCoreText 排版器 (第三方库修改版) | 路径A |
| `DTHTMLAttributedStringBuilder` | HTML->NSAttributedString 构建器 | 路径A |
| `WRReaderPencilNoteManager` | Apple Pencil 手写笔记管理 | 共用 |
| `WRReaderTranslationManager` | 翻译管理 (繁简转换/中英翻译) | 共用 |
| `WRReaderCht2sManager` | 繁体转简体管理 | 共用 |
---
## JavaScript 文件职责
| 文件 | 职责 |
|---|---|
| `weread-highlighter.js` | 核心高亮引擎,初始化 Rangy,管理选择和高亮 |
| `rangy-core.js` | Rangy 核心库,跨浏览器 Range/Selection 封装 |
| `rangy-highlighter.js` | Rangy 高亮模块,管理高亮的创建/删除/序列化 |
| `rangy-classapplier.js` | Rangy ClassApplier 模块,CSS 类应用器 |
| `rangy-textrange.js` | Rangy TextRange 模块,文本范围操作 |
| `Readability.js` | Arc90 Readability 库,提取文章正文 |
| `MediaPlatform.js` | 公众号平台 JS,原生-JS 桥接 |
| `mpForArticle.js` | 文章相关 JS 逻辑 |
| `MPExtra.css` | 公众号额外样式 |
| `MediaPlatform.css` | 公众号基础样式 |
| `WeReadApi.js` | 读书 JS API (供 WebView 调用原生功能) |
| `rich_display.js` | 富文本显示逻辑 |
| `cssInjector.js` | CSS 注入器 |
| `highlight.min.js` | 代码语法高亮 (highlight.js) |
---
## DRM 与安全机制
```
┌─────────────────────────────────────────────┐
│ DRM 保护链 │
├─────────────────────────────────────────────┤
│ │
│ 1. 传输层: HTTPS + 加密 ZIP │
│ - 服务器下发加密的 .zip 文件 │
│ - 文件名格式: {bookId}_DECRYPT.zip │
│ │
│ 2. 解密层: 逐章解密 │
│ - WREncryptedFileManager │
│ - keyForBookId: (每本书独立密钥) │
│ - decryptContentsOfFile:forBookId: │
│ │
│ 3. 存储层: 本地二次加密 │
│ - encryptFileForBookId:atPath:toPath: │
│ - 解密后立即重新加密存储 │
│ - 防止直接拷贝文件读取 │
│ │
│ 4. 密钥管理: │
│ - WRPreloadBookManager 管理预加载密钥 │
│ - 密钥与设备绑定 │
│ - 通过 Keychain 安全存储 │
│ │
│ 5. 免费试读控制: │
│ - freeTrialChapterCutOffStringLocaion │
│ - 服务端控制试读范围 │
│ - 客户端截断显示 │
│ │
│ 6. 章节付费: │
│ - isChapterAvailableForBookId:chapterUid │
│ - getCouponBuyChapterWithBookId: │
│ - resetChapterPaidIfNeeded │
│ │
└─────────────────────────────────────────────┘
```
---
## 字体系统
```
内嵌字体:
├── SourceHanSerifCN-Medium.ttf # 思源宋体 (正文默认)
├── FZJuZhenXinFang.ttf # 方正聚珍新仿
├── Lora-Regular.ttf / Italic.ttf # Lora 衬线体
├── PlayfairDisplay-Regular.ttf # Playfair Display
├── WeReadLS-Regular/Medium/Bold # 读书 LS 系列
├── WeReadRN-Regular.ttf # RN 专用字体
├── WeRead-Icon.ttf # 图标字体
├── WeRead-Rating-Icon.ttf # 评分图标
├── OpenDyslexic-Regular.otf # 阅读障碍友好字体
├── WeChatNumber.ttf # 微信数字字体
├── SharpGroteskTRIAL*.ttf # Sharp Grotesk 系列
└── icon_font.ttf # 通用图标字体
动态字体:
├── CDN 下载: cdn.weread.qq.com/app/assets/font/
│ ├── FZLTHProGBK_B/DB/SB.zip # 方正兰亭黑系列
│ ├── FZQingKBYSJF-M.zip # 方正清刻本悦宋
│ ├── FZSKBXKK.zip # 方正书宋
│ ├── FZYBKSK.zip # 方正中楷
│ └── SourceHanSansCN-Heavy.zip # 思源黑体
└── WRFontsManager 管理:
- unZipFileAndRegisterFont:completionHandler:filePath:lateOverWrite:
- 动态下载 + 解压 + 注册
```
---
## 预加载与缓存策略
```
预加载策略:
├── WRForecastUtils.shouldPreloadChapterUidsForBook:type:bookRank:archiveRank:
│ - 根据书籍排名和用户行为预测需要预加载的章节
├── WRChapterDownloadManger._preloadChapterContentWithBook:type:bookRank:
│ - 后台预下载相邻章节
├── WRPreloadBookManager
│ - saveEncryptKey:forPath:bookId: (缓存密钥)
│ - saveFileNameDict:bookId: (缓存文件名映射)
│ - clearKV (清理缓存)
└── SDWebImage 缓存:
- epubImage 目录缓存书籍图片
- com.hackemist.SDWebImageCache.epubImage
缓存目录结构:
├── Documents/
│ └── {bookId}/
│ ├── plainBookDirectory/ (解密后的 EPUB 文件)
│ ├── epubImage/ (书籍图片缓存)
│ └── _DECRYPT.zip (下载的加密 ZIP)
└── Library/
└── {cachePath}/
├── epubImage/ (SDWebImage 缓存)
└── com.hackemist.SDWebImageCache.epubImage
```
---
## 朗读 (TTS) 集成
```
朗读流程:
├── WRReadAloudAudio.parseCGIInfos: (解析音频信息)
├── TTS 引擎: wxtts (微信语音合成)
│ ├── 离线资源: wxtts_offline_resource5.zip
│ ├── 在线资源: getWxttsSeginfo / getWxttsVoice
│ └── VITS/VALL-E 模型 (高保真语音)
├── 文本分段:
│ - weread-highlighter.js 中的 "tts" ClassApplier
│ - 标记当前朗读位置
└── 进度同步:
- lastListenedChapterOffset / lastListenedChapterUid
- MPReading/lastListenedReviewId (公众号朗读进度)
```
---
## Apple Pencil 手写笔记
```
WRReaderPencilNoteManager:
├── 数据存储:
│ - writeDrawingDataToLocal:reviewItemId:reviewId:isDraft:
│ - drawingFilePathWithReviewItemId:reviewId:isDraft:
│ - 本地草稿 + 云端同步
├── 数据上传:
│ - uploadPencilDrawing:colorStyle:onlyUploadImage:canRetry:
│ - uploadPencilNoteData:suffix:
│ - 使用腾讯云 COS 存储
│ - authCosForPencilDataWithSuffix: (COS 认证)
├── 数据下载:
│ - downloadDrawingDataFromCosWithUrl:desPath:callback:
│ - downloadDrawingWithReviewItemId:reviewId:drawingUrl:dataBlock:
└── 图片导出:
- imageFilePathWithReviewItemId:
- 手绘笔记可导出为图片
```
---
## 总结
| 特性 | 实现方式 |
|---|---|
| **EPUB 解析** | 自研 EPUB Parser,解析 OPF/NCX/XHTML |
| **文字排版** | DTCoreText (CoreText 封装) + 自定义 WRCoreTextLayouter |
| **页面渲染** | CGContext 直接绘制,不用 UILabel/UITextView |
| **翻页效果** | UIPageViewController (UIPageCurl + Scroll) |
| **文本选择** | CoreText hit test + Rangy.js (WebView 场景) |
| **标注系统** | NSAttributedString 属性注入 + 服务器同步 |
| **图片处理** | NSTextAttachment + CDN 尺寸优化 + 白底透明化 |
| **DRM** | 逐章加密 + 逐书密钥 + 本地二次加密 |
| **公众号文章** | WKWebView + JS Bridge + Rangy 高亮 |
| **繁简转换** | CoreText 层面的 convertHansToHant |
| **字体** | 内嵌 Source Han Serif CN + 动态下载字体 |
| **预加载** | 后台预下载相邻章节 ZIP + 解密缓存 |
| **TTS 朗读** | wxtts 引擎 + VITS/VALL-E 高保真模型 |
| **手写笔记** | PencilKit + 腾讯云 COS 存储 |
-10
View File
@@ -1,10 +0,0 @@
# WXRead 剩余问题修复计划
> 本文档内容已整合进入 [架构对比分析_WXRead_vs_ReadViewSDK.md](/Users/shen/Work/Code/ReadViewSDK/Doc/架构对比分析_WXRead_vs_ReadViewSDK.md)。
> 后续请只维护对比文档中的以下部分:
>
> - `核查摘要`
> - `缺失能力清单`
> - `收口路线(整合原修复计划)`
保留本文件仅为兼容旧引用,避免计划与现状继续出现双份口径。
+14 -6
View File
@@ -12,16 +12,24 @@
| 文档 | 对应模块 | 说明 | | 文档 | 对应模块 | 说明 |
|------|----------|------| |------|----------|------|
| [EPUBCore_功能实现逻辑.md](EPUBCore_功能实现逻辑.md) | `Sources/RDReaderView/EPUBCore/`30 文件 | EPUB 解析全流程(ZIP→container.xml→OPF→spine/TOC)、阅读会话状态机、离屏分页测量、`ss-reader://` 资源协议、JS 桥接(6 种消息)、WebView 渲染管线、缓存策略 | | [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/`9 文件) | DTCoreText HTML→NSAttributedString 渲染管线、片段标记注入/提取、CoreText 分页引擎(含语义边界调整)、Location↔PageNumber 双向转换、全文搜索引擎、纯文本构建器 | | [EPUBTextRendering_功能实现逻辑.md](EPUBTextRendering_功能实现逻辑.md) | `Sources/RDReaderView/EPUBTextRendering/`13 文件) | DTCoreText HTML→NSAttributedString 渲染管线、片段标记注入/提取、CoreText 分页引擎(含语义边界调整)、文本索引表、Location↔PageNumber 双向转换、分页缓存、性能采样、全文搜索引擎、纯文本构建器 |
| [RDReaderView_功能实现逻辑.md](RDReaderView_功能实现逻辑.md) | `Sources/RDReaderView/` 根目录(6 文件) | 种显示模式(pageCurl/horizontalScroll/verticalScroll/horizontalCoverScroll)、DataSource/Delegate 协议、点击三区域翻页、工具栏动画、双页配对与哨兵页、横竖屏适配、RTL 支持 | | [RDReaderView_功能实现逻辑.md](RDReaderView_功能实现逻辑.md) | `Sources/RDReaderView/ReaderView/`5 文件) | 种显示模式(pageCurl/horizontalScroll/verticalScroll)、DataSource/Delegate 协议、点击三区域翻页、工具栏动画、双页配对与哨兵页、横竖屏适配、RTL 支持 |
| [EPUBUI_功能实现逻辑.md](EPUBUI_功能实现逻辑.md) | `Sources/RDReaderView/EPUBUI/`16 文件) | RDEPUBReaderController 全生命周期、三条渲染路径分发、配置变更检测与响应、工具栏/目录/高亮/搜索/设置面板交互流、阅读位置持久化、主题管理 | | [EPUBUI_功能实现逻辑.md](EPUBUI_功能实现逻辑.md) | `Sources/RDReaderView/EPUBUI/`19 文件) | RDEPUBReaderController 全生命周期、三条渲染路径分发、配置变更检测与响应、工具栏/目录/高亮/书签/搜索/设置面板交互流、阅读位置持久化、主题管理、CoreText 页面交互 |
## 方案讨论文档 ## 方案讨论与规划文档
| 文档 | 说明 | | 文档 | 说明 |
|------|------| |------|------|
| [ReflowableEPUB_WXReadRenderer_Design.md](FeatureSolution/ReflowableEPUB_WXReadRenderer_Design.md) | 基于读书反编译的 CoreText 渲染架构,设计 Reflowable EPUB 的增强文本渲染方案(CSS 分层、类型器升级) | | [阅读器功能开发计划.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 分层、类型器升级) |
## 测试与质量
| 文档 | 说明 |
|------|------|
| [UI自动化测试工作文档.md](UI自动化测试工作文档.md) | XCUITest UI 自动化测试完整工作计划:现状分析、accessibilityIdentifier 补充清单、UI Test Target 创建、测试用例设计(书库/阅读器/工具栏/设置/书签/高亮/目录/截图)、CI 集成方案、实施检查清单 |
## 目录约定 ## 目录约定
+76 -83
View File
@@ -7,6 +7,7 @@
> - `✅ 已实现`:当前代码已经按接近 WXRead 的路径落地 > - `✅ 已实现`:当前代码已经按接近 WXRead 的路径落地
> - `⚠️ 有差异/有问题`:已经实现一部分,但仍与 WXRead 有结构差异,或仍有已知问题 > - `⚠️ 有差异/有问题`:已经实现一部分,但仍与 WXRead 有结构差异,或仍有已知问题
> - `❌ 未实现`:当前代码中仍缺失 > - `❌ 未实现`:当前代码中仍缺失
> - `❌ 明确不实现`:经确认不纳入当前 EPUB 阅读器复刻范围
--- ---
@@ -19,14 +20,12 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
- 5 层 CSS 级联 - 5 层 CSS 级联
- `<link>` 样式表内联 - `<link>` 样式表内联
- 页面级 hit test / 选区 / 高亮 / 批注 - 页面级 hit test / 选区 / 高亮 / 批注
- 字符锚点与 `fileIndex/row/column` 语义的初步位置模型 - 字符锚点与 `fileIndex/row/column` 语义的完整位置模型
和 WXRead 现在的主要差距,已经从“能不能工作”收缩成“是否完全同构” 核查结论:在当前约定范围内,ReadViewSDK 已经完成 EPUB 阅读器对 WXRead 文档主链路的复刻。当前文档里不再保留主链路级 `⚠️` 项,剩余仅有两类
1. 页面几何模型还没完全等价 `WRCoreTextLayoutFrame` 1. `replaceForMPChapter.css` 这类公众号文章专用资源,对 EPUB 主链路不适用
2. 章节数据模型还没完全收口成 `WRChapterData` 2. 繁简转换、TTS / DRM / Pencil 这类已明确排除在本次复刻范围之外的外围能力
3. 位置模型已进入字符级锚点阶段,但还不是完整 `WREpubPositionConverter`
4. 翻页稳定性、预加载、预测翻页、显示切换一致性已经接入主链路,但还没完全补齐到 WXRead 同构
--- ---
@@ -40,26 +39,20 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
- 5 层 CSS 级联和 EPUB `<link>` 外部样式表内联都已经落地。 - 5 层 CSS 级联和 EPUB `<link>` 外部样式表内联都已经落地。
- CoreText 页面 hit test、长按选区、菜单锚点、复制/高亮/批注主链路已经接入页面几何。 - CoreText 页面 hit test、长按选区、菜单锚点、复制/高亮/批注主链路已经接入页面几何。
- CoreText 路径的高亮、注释、搜索命中已经走页面装饰层,而不是继续改正文布局真值。 - CoreText 路径的高亮、注释、搜索命中已经走页面装饰层,而不是继续改正文布局真值。
- WebView 路径的高亮/批注展示链路已经补齐,不再因为 DOM 包裹失败而“有记录但不着色 - WebView 路径的标注绘制、搜索高亮已经改成“JS 产出 rect,原生 overlay 负责 CGContext 绘制”,不再依赖 DOM 包裹着色。
- 分页器已经补上 inline footnote attachment 不整段挪页、标题 `keepWithNext``weread-page-relate` 页首借行这几类 WXRead 风格规则。 - 分页器已经补上 inline footnote attachment 不整段挪页、标题 `keepWithNext``weread-page-relate` 页首借行这几类 WXRead 风格规则。
- 章节尾部“仅空白/段落分隔符”的尾页丢弃,以及极短尾页回并已经落地,`宝山辽墓材料与释读` 第 31 页空白问题已修复。 - 章节尾部“仅空白/段落分隔符”的尾页丢弃,以及极短尾页回并已经落地,`宝山辽墓材料与释读` 第 31 页空白问题已修复。
- `RDEPUBTextLayoutConfig` 已补齐到 WXRead 同级配置面:`frameWidth/frameHeight/edgeInsets/numberOfColumns/columnGap/avoidOrphans/avoidWidows/hyphenation`,并已接入分页入口与缓存键。
- `RDEPUBChapterData` 已统一承载章节分页结果、位置查询、搜索/高亮回查与目录语义,章节模型主链路已经收口。
### ⚠️ 有差异/有问题 ### ⚠️ 有差异/有问题
- `RDEPUBPageLayoutSnapshot`、overlay、decoration 的对象边界还没完全等价 `WRCoreTextLayoutFrame` - 无主链路遗留项;当前仅保留明确不适用或明确不实现的范围说明
- `RDEPUBSelectionOverlayView.absoluteRange(at:)` 一类回查能力仍不完整,页面几何闭环还差最后一段。
- 位置模型虽然已经有 `rangeAnchor``fileIndex/row/column` 语义,但还不是完整的 WXRead 双向位置转换器。
- `RDEPUBChapterData` 已承担页内高亮/搜索/页码查询职责,但还没完全吸收 controller/book 层的章节语义。
- `RDReaderView` 已有 `pageCurl` fault patch、预加载、预测翻页和显示切换位置恢复,但还不是 WXRead 那种完整宿主层补丁集。
- `default.css` / `replace.css` / `dark.css` 的效果已接近,但资源体系和 WXRead 私有 CSS 仍不是一比一镜像。
### ❌ 未实现 ### ❌ 未实现
- 完整 `WRChapterData` 同构模型 - 繁简转换(明确不实现)
- 完整 `WREpubPositionConverter` 同构模型 - TTS / DRM / Pencil(明确不实现)
- 预加载、预测翻页、显示切换一致性的完整宿主层方案
- 多栏、字体动态加载、繁简转换
- TTS / DRM / Pencil
--- ---
@@ -70,10 +63,10 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
| EPUB 文本渲染 | `WRPageView.drawRect:``CTFrameDraw` 到 CGContext | `RDEPUBTextContentView``RDEPUBDirectCoreTextPageView.draw(_:)` + `DTCoreTextLayoutFrame.draw(in:)` 页面级直绘 | ✅ 已实现 | | EPUB 文本渲染 | `WRPageView.drawRect:``CTFrameDraw` 到 CGContext | `RDEPUBTextContentView``RDEPUBDirectCoreTextPageView.draw(_:)` + `DTCoreTextLayoutFrame.draw(in:)` 页面级直绘 | ✅ 已实现 |
| WebView 路径 | WKWebView(公众号/文集文章) | WKWebViewinteractive/fixed-layout EPUB | ✅ 已实现 | | WebView 路径 | WKWebView(公众号/文集文章) | WKWebViewinteractive/fixed-layout EPUB | ✅ 已实现 |
| 文本选区 | `CTLineGetStringIndexForPosition` 坐标级 hit test | `RDEPUBPageInteractionController` + `RDEPUBSelectionOverlayView` 已接入主链路 | ✅ 已实现 | | 文本选区 | `CTLineGetStringIndexForPosition` 坐标级 hit test | `RDEPUBPageInteractionController` + `RDEPUBSelectionOverlayView` 已接入主链路 | ✅ 已实现 |
| 标注绘制 | `CTFrame` 层叠加绘制(CGContext | CoreText 路径已走页面装饰层;WebView 路径仍是 JS/DOM 高亮 | ⚠️ 有差异/有问题 | | 标注绘制 | `CTFrame` 层叠加绘制(CGContext | CoreText / WebView 两条链路都已收口到原生页面装饰层绘制 | ✅ 已实现 |
| 搜索高亮 | `WRCoreTextLayoutFrame.highlightSearchResults:` 直接绘制 | CoreText 路径已页面化;不同渲染路径仍不是同一套对象模型 | ⚠️ 有差异/有问题 | | 搜索高亮 | `WRCoreTextLayoutFrame.highlightSearchResults:` 直接绘制 | CoreText / WebView 两条链路都已统一为原生页面装饰对象绘制 | ✅ 已实现 |
**结论:** 文本页的“是否页面级 CoreText”已经不是问题,当前差距是页面装饰对象和双路径一致性 **结论:** 渲染层里“页面级绘制 + 页面装饰对象收口”这一块已经完成,页面几何回查也已经补到与 WXRead 同一路径的页面快照闭环
--- ---
@@ -88,10 +81,10 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
| 标题 keep-with-next | 标题不能孤悬页尾 | 已补 `keepWithNext` 语义与页尾回退 | ✅ 已实现 | | 标题 keep-with-next | 标题不能孤悬页尾 | 已补 `keepWithNext` 语义与页尾回退 | ✅ 已实现 |
| `weread-page-relate` | 页首关联块需要借上一页一行 | 已补页首 `pageRelate` 借行规则 | ✅ 已实现 | | `weread-page-relate` | 页首关联块需要借上一页一行 | 已补页首 `pageRelate` 借行规则 | ✅ 已实现 |
| 章节尾页收口 | 丢弃空白尾页、合并极短尾页 | 已补尾页空白丢弃与超短尾页回并 | ✅ 已实现 | | 章节尾页收口 | 丢弃空白尾页、合并极短尾页 | 已补尾页空白丢弃与超短尾页回并 | ✅ 已实现 |
| 分页配置 | `WRCoreTextLayoutConfig`(含多栏等) | 已有 `RDEPUBTextLayoutConfig`,但多栏等仍缺 | ⚠️ 有差异/有问题 | | 分页配置 | `WRCoreTextLayoutConfig`(含多栏等) | `RDEPUBTextLayoutConfig` 已补齐同级参数,并接入分页入口/缓存键/列路径构建 | ✅ 已实现 |
| 缓存 | 按书籍 + 排版设置缓存 | 已有 `RDEPUBTextBookCache` 磁盘缓存 | ✅ 已实现 | | 缓存 | 按书籍 + 排版设置缓存 | 已有 `RDEPUBTextBookCache` 磁盘缓存 | ✅ 已实现 |
**结论:** 当前分页质量问题已经进入问题书细抠阶段,不再是“缺分页器”的阶段;章节尾部空白页这类系统性问题也已经开始按 WXRead 规则收口 **结论:** 分页主链路已经按 WXRead 收口,当前差异不再落在分页引擎或分页配置能力面上
--- ---
@@ -101,11 +94,11 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
|------|---------|-------------|------| |------|---------|-------------|------|
| CSS 级联层级 | 5 级:`default < replace < dark < epub < user` | `RDEPUBTextStyleSheetPackage` 已实际生成并注入 5 层 | ✅ 已实现 | | CSS 级联层级 | 5 级:`default < replace < dark < epub < user` | `RDEPUBTextStyleSheetPackage` 已实际生成并注入 5 层 | ✅ 已实现 |
| EPUB `<link>` CSS | 解析前级联合并外部样式 | 已实现抽取、内联、URL 重写与诊断 | ✅ 已实现 | | EPUB `<link>` CSS | 解析前级联合并外部样式 | 已实现抽取、内联、URL 重写与诊断 | ✅ 已实现 |
| 自定义 CSS 属性 | `wr-vertical-center-style``weread-page-relate`、断页相关私有属性 | 已支持 `pageRelate``avoidPageBreakInside`attachment placement;仍非完整 WeRead 属性全集 | ⚠️ 有差异/有问题 | | 自定义 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/...` | 当前是等价策略,不是原文件原样镜像 | ⚠️ 有差异/有问题 | | WXRead 私有 CSS 文件 | 使用 WeRead 自带 `default/replace/dark/...` | 已把 `default/replace/dark` 作为 SDK 内置资源镜像注入 5 层级联 | ✅ 已实现 |
| `replaceForLatinLanguageBook.css` | 拉丁语言专项字体规则 | 无对应 | ❌ 未实现 | | `replaceForLatinLanguageBook.css` | 拉丁语言专项字体规则 | 已按章节语言 / 正文拉丁字符占比自动切换 Latin replace 层 | ✅ 已实现 |
**结论:** CSS 主机制已对齐,剩下是规则细节和私有资源体系差异 **结论:** CSS 主机制、私有资源层和拉丁语言分支都已经按 WXRead 路径收口;这一节当前不再是剩余差距
--- ---
@@ -113,12 +106,12 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
| 维度 | 读书 | ReadViewSDK | 核查 | | 维度 | 读书 | ReadViewSDK | 核查 |
|------|---------|-------------|------| |------|---------|-------------|------|
| 主模型 | `WREpubPositionConverter` `(fileIndex, row, column)` | `RDEPUBTextAnchor` + `rangeAnchor` + `RDEPUBTextIndexTable` | ⚠️ 有差异/有问题 | | 主模型 | `WREpubPositionConverter` `(fileIndex, row, column)` | `RDEPUBTextPositionConverter` + `RDEPUBTextAnchor/rangeAnchor`,已补齐全书字符位置与页码双向转换 | ✅ 已实现 |
| 恢复精度 | 字符级双向恢复 | 已优先按 anchor 恢复,弱化 `progression` | ⚠️ 有差异/有问题 | | 恢复精度 | 字符级双向恢复 | 当前位置、选区、高亮、搜索命中都已优先走 `rangeAnchor` / `fileIndex-row-column` 恢复,`progression` 仅作回退 | ✅ 已实现 |
| 旧数据兼容 | 兼容历史位置模型 | 已兼容旧 `spineIndex` 等旧字段解码 | ✅ 已实现 | | 旧数据兼容 | 兼容历史位置模型 | 已兼容旧 `spineIndex` 等旧字段解码 | ✅ 已实现 |
| 全量双向转换 | 文件位置 <-> 全书字符位置 <-> 页码 | 已有一部分,但还不是完整 `WREpubPositionConverter` | ⚠️ 有差异/有问题 | | 全量双向转换 | 文件位置 <-> 全书字符位置 <-> 页码 | `RDEPUBTextPositionConverter` / `RDEPUBTextIndexTable` 已补齐 `fileIndex,row,column <-> chapterOffset <-> globalOffset <-> pageNumber <-> location` | ✅ 已实现 |
**结论:** 这一块已经从 `❌` 进入 `⚠️`,但还没有达到“WXRead 同构” **结论:** 位置模型这条链路已经按 WXRead 的 `PositionConverter` 思路收口,当前不再是位置恢复能力缺失的问题
--- ---
@@ -126,13 +119,13 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
| 维度 | 读书 | ReadViewSDK | 核查 | | 维度 | 读书 | ReadViewSDK | 核查 |
|------|---------|-------------|------| |------|---------|-------------|------|
| 核心模型 | `WRChapterData` 一体化承载渲染结果 + 标注 + 搜索 + 目录 | `RDEPUBChapterData` 已存在,但职责仍未完全收拢 | ⚠️ 有差异/有问题 | | 核心模型 | `WRChapterData` 一体化承载渲染结果 + 标注 + 搜索 + 目录 | `RDEPUBChapterData` 现已统一承载章节分页结果、位置查询、搜索/高亮回查与目录语义 | ✅ 已实现 |
| 页内高亮查询 | 章节对象直接提供 | `RDEPUBChapterData.highlights(on:from:)` 已提供 | ✅ 已实现 | | 页内高亮查询 | 章节对象直接提供 | `RDEPUBChapterData.highlights(on:from:)` 已提供 | ✅ 已实现 |
| 页内搜索查询 | 章节对象直接提供 | `RDEPUBChapterData.searchResults(on:from:)` 已提供 | ✅ 已实现 | | 页内搜索查询 | 章节对象直接提供 | `RDEPUBChapterData.searchResults(on:from:)` 已提供 | ✅ 已实现 |
| 页码/位置查询 | 章节对象内聚 | 已部分迁入 `RDEPUBChapterData`,仍有一部分散在 controller/book | ⚠️ 有差异/有问题 | | 页码/位置查询 | 章节对象内聚 | `RDEPUBChapterData` 已统一提供 chapter/page/location/searchMatch 双向回查 | ✅ 已实现 |
| 目录与章节语义 | 基于章节数据统一管理 | 仍大量依赖独立 TOC 解析与 controller 调度 | ⚠️ 有差异/有问题 | | 目录与章节语义 | 基于章节数据统一管理 | 已由 `RDEPUBChapterData` 统一提供 TOC 命中、主目录项解析与章节页码定位 | ✅ 已实现 |
**结论:** 章节对象已经不是空白,但也还没收口成 WXRead 那种唯一章节语义中心 **结论:** 章节对象这条链路已经按 WXRead 的 `WRChapterData` 思路收口,章节分页、位置、搜索、高亮与目录语义现在都围绕 `RDEPUBChapterData` 统一组织
--- ---
@@ -141,12 +134,12 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
| 维度 | 读书 | ReadViewSDK | 核查 | | 维度 | 读书 | ReadViewSDK | 核查 |
|------|---------|-------------|------| |------|---------|-------------|------|
| 翻页动画 | curl / slide / fade / none | 已有多种翻页模式 | ✅ 已实现 | | 翻页动画 | curl / slide / fade / none | 已有多种翻页模式 | ✅ 已实现 |
| `UIPageViewController` crash patch | 3 个以上专项 patch | 已 pageCurl fault detect + 异步重建 | ⚠️ 有差异/有问题 | | `UIPageViewController` crash patch | 3 个以上专项 patch | 已 pageCurl 异常检测、转场期间跳转排队、动画后校验与异步重建恢复 | ✅ 已实现 |
| 预加载 | `WRForecastUtils` 预测 + 后台准备 | 已围绕当前页与预测方向预热相邻页视图 | ⚠️ 有差异/有问题 | | 预加载 | `WRForecastUtils` 预测 + 后台准备 | 已围绕当前页、当前 spread 和可见方向预热相邻页视图,并在布局环境变化时裁剪缓存 | ✅ 已实现 |
| 预测翻页 | 基于用户方向预测预建内容 | 已按可见 spread 和预测方向预热缓存 | ⚠️ 有差异/有问题 | | 预测翻页 | 基于用户方向预测预建内容 | 已按手势/滚动方向和 pending target 预测下一屏,优先向预测方向额外预热一屏 | ✅ 已实现 |
| 显示切换一致性 | 翻页模式/单双页/方向切换稳定恢复 | 已接入切换前记录 location、切换后按位置回落 | ⚠️ 有差异/有问题 | | 显示切换一致性 | 翻页模式/单双页/方向切换稳定恢复 | 已切换前位置快照、切换后按 location 恢复,以及横竖屏过渡期间的延迟恢复链路 | ✅ 已实现 |
**结论:** Phase 6 已进入“有实现、继续细抠”的阶段,不再是空白;但离 WXRead 的完整宿主层稳定性还有差距 **结论:** 翻页控制器与宿主层这条链路已经按 WXRead 的主实现思路收口,pageCurl 稳定性、预测预加载和显示切换恢复现在都走统一闭环
--- ---
@@ -156,22 +149,22 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
| 文件 | 读书职责 | ReadViewSDK 状态 | 核查 | | 文件 | 读书职责 | ReadViewSDK 状态 | 核查 |
|------|-------------|-----------------|------| |------|-------------|-----------------|------|
| `default.css` | 基础 HTML 标签样式 | 已有等价基础 CSS | ⚠️ 有差异/有问题 | | `default.css` | 基础 HTML 标签样式 | 已以内置资源镜像接入 | ✅ 已实现 |
| `replace.css` | 标题、图片、引用、分页控制 | 已有等价 replace 层 | ⚠️ 有差异/有问题 | | `replace.css` | 标题、图片、引用、分页控制 | 已以内置资源镜像接入 | ✅ 已实现 |
| `dark.css` | 暗色主题 | 已有 dark 层 | ⚠️ 有差异/有问题 | | `dark.css` | 暗色主题 | 已以内置资源镜像接入,并保留主题色覆盖层 | ✅ 已实现 |
| `replaceForLatinLanguageBook.css` | 拉丁语言字体 | 无 | ❌ 未实现 | | `replaceForLatinLanguageBook.css` | 拉丁语言字体 | 已接入章节语言自动切换 | ✅ 已实现 |
| `replaceForMPChapter.css` | 公众号文章专用 | EPUB 主链路不需要 | ❌ 未实现(暂不需要) | | `replaceForMPChapter.css` | 公众号文章专用 | EPUB 主链路不需要 | ✅ EPUB 主链路不适用 |
### JS ### JS
| 文件 | 读书职责 | ReadViewSDK 状态 | 核查 | | 文件 | 读书职责 | ReadViewSDK 状态 | 核查 |
|------|-------------|-----------------|------| |------|-------------|-----------------|------|
| `weread-highlighter.js` | Web 高亮引擎 | `epub-bridge.js` 已接住高亮/批注展示 | ⚠️ 有差异/有问题 | | `weread-highlighter.js` | Web 高亮引擎 | `epub-bridge.js` 现只负责 rect 解析与桥接,最终由原生 overlay 绘制 | ✅ 已实现 |
| `rangy-*` | 选区/高亮库 | CoreText 路径不需要,Web 路径也未采用 | ⚠️ 有差异/有问题 | | `rangy-*` | 选区/高亮库 | CoreText 路径不需要`webInteractive` / `webFixedLayout` 已接入 `rangy-core.js` + `rangy-serializer.js` 等价层,统一负责 DOM range 序列化与 iframe 文档回放 | ✅ 已实现 |
| `cssInjector.js` | 动态 CSS 注入 | 现为原生侧静态注入 | ⚠️ 有差异/有问题 | | `cssInjector.js` | 动态 CSS 注入 | 已接入 `cssInjector.js`,由 `WeReadApi``webInteractive` / `webFixedLayout` 路径按需向主文档和 iframe 文档注入主题/分页样式 | ✅ 已实现 |
| `WeReadApi.js` | JS-Native 桥接 | `window.RDReaderBridge` | ⚠️ 有差异/有问题 | | `WeReadApi.js` | JS-Native 桥接 | 已接入 `WeReadApi.js`,并由 `window.WeReadApi` 统一封装分页、主题、搜索、高亮和 progression bridge | ✅ 已实现 |
**结论:** 不建议也不应该把 WXRead 私有 JS/CSS 资源整包原样搬用;当前应继续走“能力等价、实现自有”的路线 **结论:** CoreText 路径不需要 `rangy` / `cssInjector` / `WeReadApi`;但 `webInteractive``webFixedLayout` 两条 Web 路径都需要。当前资源体系已经把这套 Web 资源层完整接回 SDK,JS 侧职责也已按 WXRead 思路收口
--- ---
@@ -179,17 +172,12 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
| 能力 | 核查 | 当前优先级 | | 能力 | 核查 | 当前优先级 |
|------|------|-----------| |------|------|-----------|
| 页面几何模型完全等价 `WRCoreTextLayoutFrame` | ⚠️ 有差异/有问题 | P1 | | 页面几何模型完全等价 `WRCoreTextLayoutFrame` | ✅ 已实现 | - |
| `RDEPUBChapterData` 最终内聚 | ⚠️ 有差异/有问题 | P2 | | `WRBookmark` 统一标注模型 | ✅ 已实现 | - |
| 完整字符级位置转换器 | ⚠️ 有差异/有问题 | P2 | | 多栏排版 | ✅ 已实现 | - |
| 翻页 crash patch 完整版 | ⚠️ 有差异/有问题 | P3 | | 字体动态加载 | ✅ 已实现 | - |
| 预加载 | ⚠️ 有差异/有问题 | P3 | | 繁简转换 | ❌ 明确不实现 | - |
| 预测翻页 | ⚠️ 有差异/有问题 | P3 | | TTS / DRM / Pencil | ❌ 明确不实现 | - |
| 显示切换一致性 | ⚠️ 有差异/有问题 | P3 |
| 多栏排版 | ❌ 未实现 | P4 |
| 字体动态加载 | ❌ 未实现 | P4 |
| 繁简转换 | ❌ 未实现 | P4 |
| TTS / DRM / Pencil | ❌ 未实现 | P4 |
--- ---
@@ -197,36 +185,34 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
### P1:页面几何与分页行为闭环 ### P1:页面几何与分页行为闭环
- `RDEPUBPageLayoutSnapshot` 继续补齐到更接近 `WRCoreTextLayoutFrame` - `RDEPUBPageLayoutSnapshot` 已补齐 line/run/attachment/contentBounds 与 absoluteRange 命中闭环
- overlay / decoration / selection 回查能力继续统一 - overlay / decoration / selection 回查已经统一到页面快照层
- 继续结合问题书压平标题、脚注段、`weread-page-relate`、attachment、缩进段等分页细节 - 问题书分页细节已回收到现有分页规则,不再构成架构缺口
当前状态:`⚠️ 进行中` 当前状态:`✅ 已实现`
### P2:位置模型与章节数据内聚 ### P2:位置模型与章节数据内聚
- `rangeAnchor` / `fileIndex/row/column` 继续收成完整位置转换 - `rangeAnchor` / `fileIndex/row/column` 双向位置转换已完成
- 把 controller / book 层散落的正文语义继续迁入 `RDEPUBChapterData` - `RDEPUBChapterData` 章节语义内聚已完成
- `RDEPUBAnnotation` 已把书签/高亮统一收口到单一标注语义层
当前状态:`⚠️ 进行中` 当前状态:`✅ 已实现`
### P3:宿主层稳定性 ### P3:宿主层稳定性
- `pageCurl` crash patch 完整化 - 宿主层 pageCurl 修复、预测预加载和显示切换恢复已完成
- 预加载细化 - 剩余工作不再是主链路缺失,而是问题书和极端交互场景的持续回归
- 预测翻页细化
- 显示切换一致性继续压平
当前状态:`⚠️ 进行中` 当前状态:`✅ 已实现`
### P4:外围能力 ### P4:外围能力
- 多栏 - 多栏阅读器开关 / UI 暴露已完成
- 字体系统 - EPUB 内嵌字体动态注册已完成
- 繁简转换 - 繁简转换、TTS / DRM / Pencil 明确不实现,不纳入收口范围
- TTS / DRM / Pencil
当前状态:`❌ 未开始` 当前状态:`✅ 范围内已实现`
--- ---
@@ -235,15 +221,15 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
| 类名 | 职责 | ReadViewSDK 对应 | 核查 | | 类名 | 职责 | ReadViewSDK 对应 | 核查 |
|------|------|-----------------|------| |------|------|-----------------|------|
| `WRReaderViewController` | 阅读器主控制器 | `RDEPUBReaderController` | ✅ 已有对应 | | `WRReaderViewController` | 阅读器主控制器 | `RDEPUBReaderController` | ✅ 已有对应 |
| `WRPageViewController` | 翻页控制器 + crash patch | `RDReaderView` | ⚠️ 有差异/有问题 | | `WRPageViewController` | 翻页控制器 + crash patch | `RDReaderView` | ✅ 已实现 |
| `WRPageView` | CoreText 直接绘制页面 | `RDEPUBTextContentView` + `RDEPUBDirectCoreTextPageView` | ✅ 已实现 | | `WRPageView` | CoreText 直接绘制页面 | `RDEPUBTextContentView` + `RDEPUBDirectCoreTextPageView` | ✅ 已实现 |
| `WREpubTypesetter` | HTML → NSAttributedStringCSS 级联) | `RDEPUBDTCoreTextRenderer` | ✅ 已实现 | | `WREpubTypesetter` | HTML → NSAttributedStringCSS 级联) | `RDEPUBDTCoreTextRenderer` | ✅ 已实现 |
| `WRCoreTextLayouter` | CoreText 排版引擎 | `RDEPUBTextLayouter` | ✅ 已实现 | | `WRCoreTextLayouter` | CoreText 排版引擎 | `RDEPUBTextLayouter` | ✅ 已实现 |
| `WRCoreTextLayoutFrame` | 排版帧(绘制/选区/搜索/装饰) | `RDEPUBTextLayoutFrame` + snapshot/interaction/overlay | ⚠️ 有差异/有问题 | | `WRCoreTextLayoutFrame` | 排版帧(绘制/选区/搜索/装饰) | `RDEPUBTextLayoutFrame` + snapshot/interaction/overlay | ✅ 已实现 |
| `WRChapterData` | 章节数据模型 | `RDEPUBChapterData` | ⚠️ 有差异/有问题 | | `WRChapterData` | 章节数据模型 | `RDEPUBChapterData` | ✅ 已实现 |
| `WRChapterPageCount` | 分页计算 + 缓存 key | `RDEPUBTextBookBuilder` + `RDEPUBTextBookCache` | ✅ 已实现 | | `WRChapterPageCount` | 分页计算 + 缓存 key | `RDEPUBTextBookBuilder` + `RDEPUBTextBookCache` | ✅ 已实现 |
| `WREpubPositionConverter` | 字符级位置双向转换 | `RDEPUBTextAnchor` + `RDEPUBTextIndexTable` | ⚠️ 有差异/有问题 | | `WREpubPositionConverter` | 字符级位置双向转换 | `RDEPUBTextPositionConverter` + `RDEPUBTextIndexTable` | ✅ 已实现 |
| `WRBookmark` | 标注统一模型 | `RDEPUBHighlight` + `RDEPUBBookmark` | ⚠️ 有差异/有问题 | | `WRBookmark` | 标注统一模型 | `RDEPUBAnnotation`(兼容 `RDEPUBHighlight` / `RDEPUBBookmark` | ✅ 已实现 |
| `DTHTMLAttributedStringBuilder` | HTML DOM → NSAttributedString | 同(复用 DTCoreText | ✅ 已实现 | | `DTHTMLAttributedStringBuilder` | HTML DOM → NSAttributedString | 同(复用 DTCoreText | ✅ 已实现 |
--- ---
@@ -269,9 +255,16 @@ EPUB 文件
webInteractive: webInteractive:
-> RDEPUBPaginator -> RDEPUBPaginator
-> RDEPUBWebContentView -> RDEPUBWebContentView
-> rangy-core.js / rangy-serializer.js
-> cssInjector.js
-> WeReadApi.js
-> epub-bridge.js -> epub-bridge.js
webFixedLayout: webFixedLayout:
-> RDEPUBWebContentView -> RDEPUBWebContentView
-> rangy-core.js / rangy-serializer.js
-> cssInjector.js
-> WeReadApi.js
-> epub-bridge.js
``` ```
--- ---
+617
View File
@@ -0,0 +1,617 @@
# 阅读器功能开发计划
> 本文档整合了渲染质量三方对比(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 自动化测试。下一步最值得投入的是分页回归基准、更多字体资源和书架/书库管理。
+1 -1
View File
@@ -12,7 +12,7 @@ Pod::Spec.new do |s|
s.license = "MIT" s.license = "MIT"
s.source_files = 'Sources/RDReaderView/**/*.{swift}' s.source_files = 'Sources/RDReaderView/**/*.{swift}'
s.resource_bundles = { s.resource_bundles = {
'RDReaderViewAssets' => ['Sources/RDReaderView/Resources/**/*'] 'RDReaderViewAssets' => ['Sources/RDReaderView/EPUBCore/Resources/**/*']
} }
s.dependency 'ZIPFoundation', '~> 0.9' s.dependency 'ZIPFoundation', '~> 0.9'
s.dependency 'DTCoreText' s.dependency 'DTCoreText'
+1 -1
View File
@@ -43,7 +43,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS: SPEC CHECKSUMS:
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0 DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
RDReaderView: 438edba3372d14a7dde3c0334ebd4cc1945ca926 RDReaderView: 54205c82c62ea6c674b43c833642d2643c98c573
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
SSAlertSwift: aad8dc0c20b36fcffe700b81d7be89d60c7ba4f1 SSAlertSwift: aad8dc0c20b36fcffe700b81d7be89d60c7ba4f1
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351 ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
+1 -1
View File
@@ -20,7 +20,7 @@
"source_files": "Sources/RDReaderView/**/*.{swift}", "source_files": "Sources/RDReaderView/**/*.{swift}",
"resource_bundles": { "resource_bundles": {
"RDReaderViewAssets": [ "RDReaderViewAssets": [
"Sources/RDReaderView/Resources/**/*" "Sources/RDReaderView/EPUBCore/Resources/**/*"
] ]
}, },
"dependencies": { "dependencies": {
+1 -1
View File
@@ -43,7 +43,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS: SPEC CHECKSUMS:
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0 DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
RDReaderView: 438edba3372d14a7dde3c0334ebd4cc1945ca926 RDReaderView: 54205c82c62ea6c674b43c833642d2643c98c573
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
SSAlertSwift: aad8dc0c20b36fcffe700b81d7be89d60c7ba4f1 SSAlertSwift: aad8dc0c20b36fcffe700b81d7be89d60c7ba4f1
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351 ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
File diff suppressed because it is too large Load Diff
@@ -7,14 +7,41 @@
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
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 */; };
52A6A0C9941A4B5F9B88C6B0 /* ReaderAnnotationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20BD15E5D8F04E7E9A239E14 /* ReaderAnnotationTests.swift */; };
63B2852E59996922386A3111 /* AccessibilityIdentifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00F495E17B90CCF1C7FE8C27 /* AccessibilityIdentifiers.swift */; };
9AAFB855EC7AC09D1E0B6742 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 697A5BCF0D93DBEC18E21A09 /* Foundation.framework */; };
C08FF8D030048DC5147729E9 /* ReaderOpenCloseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */; };
DE1437A969DA1C5F0CBB047D /* Pods_ReadViewDemo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 792DF85CE4A3DD80D67843C7 /* Pods_ReadViewDemo.framework */; }; DE1437A969DA1C5F0CBB047D /* Pods_ReadViewDemo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 792DF85CE4A3DD80D67843C7 /* Pods_ReadViewDemo.framework */; };
FEDB5937CEB858CB06E38E2D /* SettingsPanelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201C2B482287866487EFAE66 /* SettingsPanelTests.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
053B5CD55E42BCD8E4E77C14 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = DE070C152FBF0CC900ED065F /* Project object */;
proxyType = 1;
remoteGlobalIDString = DE070C1C2FBF0CC900ED065F;
remoteInfo = ReadViewDemo;
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
00F495E17B90CCF1C7FE8C27 /* AccessibilityIdentifiers.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AccessibilityIdentifiers.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>"; }; 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>"; };
423988C9F7F09A1B78D97CC1 /* Pods-ReadViewDemo.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ReadViewDemo.debug.xcconfig"; path = "Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo.debug.xcconfig"; sourceTree = "<group>"; }; 423988C9F7F09A1B78D97CC1 /* Pods-ReadViewDemo.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ReadViewDemo.debug.xcconfig"; path = "Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo.debug.xcconfig"; sourceTree = "<group>"; };
697A5BCF0D93DBEC18E21A09 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
74B4C44287820D68ED6570F8 /* XCUIApplication+Launch.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "XCUIApplication+Launch.swift"; sourceTree = "<group>"; };
792DF85CE4A3DD80D67843C7 /* Pods_ReadViewDemo.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_ReadViewDemo.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 792DF85CE4A3DD80D67843C7 /* Pods_ReadViewDemo.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_ReadViewDemo.framework; sourceTree = BUILT_PRODUCTS_DIR; };
8FFD606A5A1CBDCC3CA87F1C /* ReadViewDemoUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ReadViewDemoUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderOpenCloseTests.swift; sourceTree = "<group>"; };
BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderToolbarTests.swift; sourceTree = "<group>"; };
DE070C1D2FBF0CC900ED065F /* ReadViewDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ReadViewDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; DE070C1D2FBF0CC900ED065F /* ReadViewDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ReadViewDemo.app; sourceTree = BUILT_PRODUCTS_DIR; };
FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DisplayTypeTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -39,6 +66,14 @@
/* End PBXFileSystemSynchronizedRootGroup section */ /* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
3B8BD3932B95613EF7F4A449 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
9AAFB855EC7AC09D1E0B6742 /* Foundation.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
DE070C1A2FBF0CC900ED065F /* Frameworks */ = { DE070C1A2FBF0CC900ED065F /* Frameworks */ = {
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@@ -50,10 +85,28 @@
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
0F03902FB2E1DBD16B6E862F /* iOS */ = {
isa = PBXGroup;
children = (
697A5BCF0D93DBEC18E21A09 /* Foundation.framework */,
);
name = iOS;
sourceTree = "<group>";
};
5354236B404004825520D52F /* Helpers */ = {
isa = PBXGroup;
children = (
00F495E17B90CCF1C7FE8C27 /* AccessibilityIdentifiers.swift */,
74B4C44287820D68ED6570F8 /* XCUIApplication+Launch.swift */,
);
path = Helpers;
sourceTree = "<group>";
};
9F8446930C35441C7E9A4DFF /* Frameworks */ = { 9F8446930C35441C7E9A4DFF /* Frameworks */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
792DF85CE4A3DD80D67843C7 /* Pods_ReadViewDemo.framework */, 792DF85CE4A3DD80D67843C7 /* Pods_ReadViewDemo.framework */,
0F03902FB2E1DBD16B6E862F /* iOS */,
); );
name = Frameworks; name = Frameworks;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -74,6 +127,7 @@
DE070C1E2FBF0CC900ED065F /* Products */, DE070C1E2FBF0CC900ED065F /* Products */,
BAF21F51585EC06F8713C5C6 /* Pods */, BAF21F51585EC06F8713C5C6 /* Pods */,
9F8446930C35441C7E9A4DFF /* Frameworks */, 9F8446930C35441C7E9A4DFF /* Frameworks */,
E31A99F5F070435147BC13CC /* ReadViewDemoUITests */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@@ -81,13 +135,53 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
DE070C1D2FBF0CC900ED065F /* ReadViewDemo.app */, DE070C1D2FBF0CC900ED065F /* ReadViewDemo.app */,
8FFD606A5A1CBDCC3CA87F1C /* ReadViewDemoUITests.xctest */,
); );
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
E31A99F5F070435147BC13CC /* ReadViewDemoUITests */ = {
isa = PBXGroup;
children = (
5354236B404004825520D52F /* Helpers */,
EACA0D274176D594361EF6B9 /* ReaderUITests */,
);
path = ReadViewDemoUITests;
sourceTree = "<group>";
};
EACA0D274176D594361EF6B9 /* ReaderUITests */ = {
isa = PBXGroup;
children = (
FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */,
20BD15E5D8F04E7E9A239E14 /* ReaderAnnotationTests.swift */,
9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */,
BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */,
201C2B482287866487EFAE66 /* SettingsPanelTests.swift */,
);
path = ReaderUITests;
sourceTree = "<group>";
};
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
7BE8E325ADE250D48D1A997A /* ReadViewDemoUITests */ = {
isa = PBXNativeTarget;
buildConfigurationList = ADC5920FDF58B08AA59F7567 /* Build configuration list for PBXNativeTarget "ReadViewDemoUITests" */;
buildPhases = (
3033E27A4E50D8AF46B7388D /* Sources */,
3B8BD3932B95613EF7F4A449 /* Frameworks */,
ADEE76F68D081663B23CD0BB /* Resources */,
);
buildRules = (
);
dependencies = (
B0E4ACDE6B79427A20B5BE9A /* PBXTargetDependency */,
);
name = ReadViewDemoUITests;
productName = ReadViewDemoUITests;
productReference = 8FFD606A5A1CBDCC3CA87F1C /* ReadViewDemoUITests.xctest */;
productType = "com.apple.product-type.bundle.ui-testing";
};
DE070C1C2FBF0CC900ED065F /* ReadViewDemo */ = { DE070C1C2FBF0CC900ED065F /* ReadViewDemo */ = {
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = DE070C302FBF0CCA00ED065F /* Build configuration list for PBXNativeTarget "ReadViewDemo" */; buildConfigurationList = DE070C302FBF0CCA00ED065F /* Build configuration list for PBXNativeTarget "ReadViewDemo" */;
@@ -140,11 +234,19 @@
projectRoot = ""; projectRoot = "";
targets = ( targets = (
DE070C1C2FBF0CC900ED065F /* ReadViewDemo */, DE070C1C2FBF0CC900ED065F /* ReadViewDemo */,
7BE8E325ADE250D48D1A997A /* ReadViewDemoUITests */,
); );
}; };
/* End PBXProject section */ /* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */ /* Begin PBXResourcesBuildPhase section */
ADEE76F68D081663B23CD0BB /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
DE070C1B2FBF0CC900ED065F /* Resources */ = { DE070C1B2FBF0CC900ED065F /* Resources */ = {
isa = PBXResourcesBuildPhase; isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@@ -201,6 +303,20 @@
/* End PBXShellScriptBuildPhase section */ /* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
3033E27A4E50D8AF46B7388D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
63B2852E59996922386A3111 /* AccessibilityIdentifiers.swift in Sources */,
3BC5C96D7A0ACF35F2192CC7 /* XCUIApplication+Launch.swift in Sources */,
23BB1155EA379786DAA10A89 /* DisplayTypeTests.swift in Sources */,
52A6A0C9941A4B5F9B88C6B0 /* ReaderAnnotationTests.swift in Sources */,
C08FF8D030048DC5147729E9 /* ReaderOpenCloseTests.swift in Sources */,
4509ED928F228F43888E063D /* ReaderToolbarTests.swift in Sources */,
FEDB5937CEB858CB06E38E2D /* SettingsPanelTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
DE070C192FBF0CC900ED065F /* Sources */ = { DE070C192FBF0CC900ED065F /* Sources */ = {
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@@ -210,7 +326,63 @@
}; };
/* End PBXSourcesBuildPhase section */ /* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
B0E4ACDE6B79427A20B5BE9A /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
name = ReadViewDemo;
target = DE070C1C2FBF0CC900ED065F /* ReadViewDemo */;
targetProxy = 053B5CD55E42BCD8E4E77C14 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */ /* Begin XCBuildConfiguration section */
258209CBC2293BAD3EF2B836 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CLANG_ENABLE_OBJC_WEAK = NO;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = MG4Z7FU83W;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
PRODUCT_BUNDLE_IDENTIFIER = cn.shen.ReadViewDemoUITests;
PRODUCT_MODULE_NAME = ReadViewDemoUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = TouchLinked_dev;
SDKROOT = iphoneos;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = ReadViewDemo;
VALIDATE_PRODUCT = YES;
WRAPPER_EXTENSION = xctest;
};
name = Release;
};
2995C92FBA100970A8A427B4 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CLANG_ENABLE_OBJC_WEAK = NO;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = MG4Z7FU83W;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
PRODUCT_BUNDLE_IDENTIFIER = cn.shen.ReadViewDemoUITests;
PRODUCT_MODULE_NAME = ReadViewDemoUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = TouchLinked_dev;
SDKROOT = iphoneos;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = ReadViewDemo;
WRAPPER_EXTENSION = xctest;
};
name = Debug;
};
DE070C312FBF0CCA00ED065F /* Debug */ = { DE070C312FBF0CCA00ED065F /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = 423988C9F7F09A1B78D97CC1 /* Pods-ReadViewDemo.debug.xcconfig */; baseConfigurationReference = 423988C9F7F09A1B78D97CC1 /* Pods-ReadViewDemo.debug.xcconfig */;
@@ -411,6 +583,15 @@
/* End XCBuildConfiguration section */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */ /* Begin XCConfigurationList section */
ADC5920FDF58B08AA59F7567 /* Build configuration list for PBXNativeTarget "ReadViewDemoUITests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
258209CBC2293BAD3EF2B836 /* Release */,
2995C92FBA100970A8A427B4 /* Debug */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
DE070C182FBF0CC900ED065F /* Build configuration list for PBXProject "ReadViewDemo" */ = { DE070C182FBF0CC900ED065F /* Build configuration list for PBXProject "ReadViewDemo" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (
@@ -0,0 +1,89 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1540"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "DE070C1D2FBF0CC900ED065F"
BuildableName = "ReadViewDemo.app"
BlueprintName = "ReadViewDemo"
ReferencedContainer = "container:ReadViewDemo.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
codeCoverageEnabled = "NO">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "7BE8E325ADE250D48D1A997A"
BuildableName = "ReadViewDemoUITests.xctest"
BlueprintName = "ReadViewDemoUITests"
ReferencedContainer = "container:ReadViewDemo.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "DE070C1D2FBF0CC900ED065F"
BuildableName = "ReadViewDemo.app"
BlueprintName = "ReadViewDemo"
ReferencedContainer = "container:ReadViewDemo.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "DE070C1D2FBF0CC900ED065F"
BuildableName = "ReadViewDemo.app"
BlueprintName = "ReadViewDemo"
ReferencedContainer = "container:ReadViewDemo.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -661,6 +661,9 @@ final class ViewController: UIViewController {
if let automationPlan { if let automationPlan {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak controller] in DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak controller] in
if let displayType = automationPlan.displayType {
controller?.applyDemoDisplayType(displayType)
}
if let pageNumber = automationPlan.pageNumber { if let pageNumber = automationPlan.pageNumber {
_ = controller?.goToDemoPage(pageNumber) _ = controller?.goToDemoPage(pageNumber)
} }
@@ -688,7 +691,7 @@ final class ViewController: UIViewController {
private func currentReaderTextStyle() -> RDEPUBTextRenderStyle { private func currentReaderTextStyle() -> RDEPUBTextRenderStyle {
let configuration = RDEPUBReaderConfiguration.default let configuration = RDEPUBReaderConfiguration.default
let font = UIFont.systemFont(ofSize: configuration.fontSize) let font = configuration.fontChoice.font(ofSize: configuration.fontSize)
let lineSpacing = max(font.lineHeight * (configuration.lineHeightMultiple - 1), 4) let lineSpacing = max(font.lineHeight * (configuration.lineHeightMultiple - 1), 4)
return RDEPUBTextRenderStyle( return RDEPUBTextRenderStyle(
font: font, font: font,
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>
Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 482 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 507 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 561 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 571 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 513 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 443 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 868 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 589 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 600 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 571 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 724 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 417 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Some files were not shown because too many files have changed in this diff Show More