Compare commits
67
Commits
46fa9fceff
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ccb7157b2 | ||
|
|
063a493b18 | ||
|
|
d7fcda345d | ||
|
|
d5a7755702 | ||
|
|
83dfa40299 | ||
|
|
c4be426299 | ||
|
|
e4e629a06c | ||
|
|
5a41066b66 | ||
|
|
d0efe3f2cc | ||
|
|
ca408c20ab | ||
|
|
5ae7823ef8 | ||
|
|
bd6e915fbd | ||
|
|
7132e4952b | ||
|
|
f796823db8 | ||
|
|
47fe2dc450 | ||
|
|
9e91207011 | ||
|
|
71f4feb12f | ||
|
|
99bdc98895 | ||
|
|
22e7e44220 | ||
|
|
b8aa10c535 | ||
|
|
15b15d0e11 | ||
|
|
8c5059e72f | ||
|
|
2fa58b1026 | ||
|
|
c555329948 | ||
|
|
d7f28c3f10 | ||
|
|
d15f20b097 | ||
|
|
7de661eb54 | ||
|
|
c65c190b71 | ||
|
|
f50495ad91 | ||
|
|
6ed3fcb071 | ||
|
|
c64460988a | ||
|
|
e976ceebd5 | ||
|
|
6f75b083f7 | ||
|
|
27e9b85ddb | ||
|
|
cdc74e5ad9 | ||
|
|
ed390da147 | ||
|
|
d15187b730 | ||
|
|
0e7c0577e3 | ||
|
|
d20196ee34 | ||
|
|
feb05eaf87 | ||
|
|
584976ac5f | ||
|
|
9801af05d3 | ||
|
|
c76beed03c | ||
|
|
488350d956 | ||
|
|
1c6108061c | ||
|
|
70133e4e6e | ||
|
|
948004eed1 | ||
|
|
1efb9d172f | ||
|
|
44202357c0 | ||
|
|
ea21c6a831 | ||
|
|
22adb76332 | ||
|
|
736902b489 | ||
|
|
83b705b9ae | ||
|
|
0e7d952fe5 | ||
|
|
23182e8b5a | ||
|
|
c0aac56083 | ||
|
|
54798ba578 | ||
|
|
5af90c1148 | ||
|
|
2a94921e88 | ||
|
|
a318c0e3d0 | ||
|
|
5125b8de51 | ||
|
|
22bb86959c | ||
|
|
86e6922f7b | ||
|
|
26afb9f7dc | ||
|
|
3e2669d8a8 | ||
|
|
d7339aa255 | ||
|
|
21c8bbb23a |
+26
@@ -0,0 +1,26 @@
|
|||||||
|
|
||||||
|
# Xcode user data
|
||||||
|
xcuserdata/
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
.artifacts/
|
||||||
|
.deriveddata/
|
||||||
|
.swift-module-cache/
|
||||||
|
|
||||||
|
# This repository checks in the Demo Pods project. Keep generated files for the
|
||||||
|
# local RDEpubReaderView pod visible even when a global gitignore excludes Pods.
|
||||||
|
!ReadViewDemo/Pods/
|
||||||
|
!ReadViewDemo/Pods/Local Podspecs/
|
||||||
|
!ReadViewDemo/Pods/Local Podspecs/RDEpubReaderView.podspec.json
|
||||||
|
!ReadViewDemo/Pods/Target Support Files/
|
||||||
|
!ReadViewDemo/Pods/Target Support Files/RDEpubReaderView/
|
||||||
|
!ReadViewDemo/Pods/Target Support Files/RDEpubReaderView/**
|
||||||
|
|
||||||
|
# macOS system files
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Local backup directories
|
||||||
|
*手动解包备份*
|
||||||
|
|
||||||
|
# AI tool output
|
||||||
|
_ssoft-output/
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# Milestones
|
|
||||||
|
|
||||||
## v1.0
|
|
||||||
|
|
||||||
- **Date:** 2026-05-22
|
|
||||||
- **Scope:** Phases 1-5, 13 plans
|
|
||||||
- **Archive:** [v1.0-ROADMAP.md](/Users/shen/Work/Code/ReadViewSDK/.planning/milestones/v1.0-ROADMAP.md)
|
|
||||||
- **Requirements:** [v1.0-REQUIREMENTS.md](/Users/shen/Work/Code/ReadViewSDK/.planning/milestones/v1.0-REQUIREMENTS.md)
|
|
||||||
- **Audit:** [v1.0-MILESTONE-AUDIT.md](/Users/shen/Work/Code/ReadViewSDK/.planning/v1.0-MILESTONE-AUDIT.md)
|
|
||||||
|
|
||||||
### Delivered
|
|
||||||
|
|
||||||
- Directly evolved the native reflowable EPUB path toward a WXRead-style renderer instead of introducing a parallel engine.
|
|
||||||
- Added chapter-level CSS preprocessing, explicit CSS layering, and chapter-scoped renderer inputs.
|
|
||||||
- Upgraded pagination to use richer page metadata and layouter/layout-frame semantics while preserving offset compatibility.
|
|
||||||
- Reconnected reader restore, highlight, search, and repagination behavior without modifying `RDReaderView`.
|
|
||||||
- Built a 5-book validation matrix across native reflowable, fixed/interactive WebKit, and TXT paths.
|
|
||||||
|
|
||||||
### Known Gaps
|
|
||||||
|
|
||||||
- `REND-01`, `REND-02`, `REND-03`, `REND-04`
|
|
||||||
- `COMP-01`, `COMP-03`, `COMP-04`
|
|
||||||
- `STAB-01`, `STAB-02`
|
|
||||||
|
|
||||||
Known gaps were accepted at close as requirement bookkeeping debt; see the milestone audit for details.
|
|
||||||
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
# ReadViewSDK
|
|
||||||
|
|
||||||
## 这是什么
|
|
||||||
|
|
||||||
`ReadViewSDK` 是一个 iOS 阅读 SDK(`RDReaderView`),提供 EPUB/TXT 的打开、分页与阅读器 UI,并包含一个用于演示集成的 Demo 工程(`ReadViewDemo`)。当前项目面向 **brownfield(已有代码)**,目标是对现有 reflowable EPUB 阅读内核做一次直接重构:在旧引擎基础上演进为参考微信读书(WXRead)的原生渲染方案,而不是保留并行的第二套原生引擎。
|
|
||||||
|
|
||||||
## 核心价值
|
|
||||||
|
|
||||||
稳定可用的 EPUB/TXT 阅读体验。
|
|
||||||
|
|
||||||
## Current Milestone: v1.1 WXRead 深化对齐
|
|
||||||
|
|
||||||
**Goal:** 在 v1.0 已完成的 native reflowable 基础上,继续补齐页面几何能力、自定义分页属性闭环、分页质量/缓存,以及更强的自动化验证。
|
|
||||||
|
|
||||||
**Target features:**
|
|
||||||
- native text `layout frame` 几何查询与命中能力
|
|
||||||
- WXRead 风格分页属性从 HTML/CSS 到 paginator 的闭环
|
|
||||||
- 复杂图文章节分页质量与缓存/性能采样
|
|
||||||
- native reflowable 主路径自动化或稳定半自动化回归
|
|
||||||
|
|
||||||
## Current State
|
|
||||||
|
|
||||||
- 已完成 `v1.0`:现有 reflowable EPUB native path 已完成一轮 WXRead 风格原生化演进。
|
|
||||||
- chapter-level CSS 分层、页面元数据、复杂分页、reader restore/search/highlight 回接,以及 demo 样本矩阵都已落地。
|
|
||||||
- Fixed Layout / interactive EPUB 仍保持 `WKWebView` 路径,`RDReaderView` 仍保持既有分页容器契约。
|
|
||||||
|
|
||||||
## 需求
|
|
||||||
|
|
||||||
### 已验证(现有能力)
|
|
||||||
|
|
||||||
- ✓ 支持通过 `RDURLReaderController` 以 URL 打开 `.epub` / `.txt` 并进入阅读器(现有)
|
|
||||||
- ✓ 具备 `RDEPUBReaderController` 作为主阅读器控制器,负责加载/分页/状态管理(现有)
|
|
||||||
- ✓ 具备 `RDReaderView` 作为分页容器视图,支持翻页/滚动等呈现模式(现有)
|
|
||||||
- ✓ reflowable EPUB 当前主路径已是基于 `DTCoreText` / `NSAttributedString` / CoreText 的原生文本渲染与分页;但其样式分层、资源解析与分页策略仍较基础,本次将增强为更接近 WXRead 的实现(现有实现;本次将改造)
|
|
||||||
- ✓ 固定版式(Fixed Layout)与交互式内容存在 `WKWebView` 相关能力与桥接(现有)
|
|
||||||
- ✓ TXT 通过 `RDPlainTextBookBuilder` / `RDEPUBTextBook` 路径进入同一阅读器 UX(现有)
|
|
||||||
- ✓ 默认使用 `UserDefaults` 进行部分阅读器状态/设置持久化(现有)
|
|
||||||
- ✓ v1.0 已完成 native reflowable 原生化基础:CSS 分层、页面级 metadata、分页器强化、reader 回接与样本矩阵验证(v1.0)
|
|
||||||
|
|
||||||
### 进行中(本次范围)
|
|
||||||
|
|
||||||
- [ ] v1.1 继续补齐 WXRead 深化对齐:layout frame 几何能力、自定义分页属性闭环、分页质量/缓存、以及更强的自动化验证
|
|
||||||
|
|
||||||
### 不做(明确排除)
|
|
||||||
|
|
||||||
- Fixed Layout EPUB:继续使用 `WKWebView`,不切换到 WXRead 渲染方式
|
|
||||||
- 交互式 EPUB(含 JS / 音视频 / 表单 / iframe / 外链 / 脚本桥接等):继续使用 `WKWebView`,不切换到 WXRead 渲染方式
|
|
||||||
- 当前翻页代码(包括 `RDReaderView` 及其现有翻页模式/翻页交互逻辑):不做修改
|
|
||||||
- 直接拷贝使用微信读书私有 JS/CSS/私有实现代码:不做
|
|
||||||
- 同时保留两套 reflowable 原生引擎:不做
|
|
||||||
|
|
||||||
## 背景与上下文
|
|
||||||
|
|
||||||
- 仓库形态:iOS SDK + Demo App;Demo 通过 CocoaPods 以本地 `:path` 引入 SDK。
|
|
||||||
- 当前分层:`EPUBCore`(解析/分页/状态)、`EPUBUI`(阅读器 UX)、`EPUBTextRendering`(TXT/TextBook / reflowable 原生文本渲染)、以及 `LegacyRDReaderController`(历史实现并存)。
|
|
||||||
- WXRead 参考资料位于 `Doc/WXRead/`,包含对微信读书 EPUB 阅读器的逆向分析文档与相关符号/源码片段。
|
|
||||||
- 当前 `.textReflowable` 主路径已基于 `DTCoreText` / `NSAttributedString` / CoreText 分页,但分页能力、页面语义、自定义属性体系与复杂块元素处理仍远弱于 WXRead。
|
|
||||||
|
|
||||||
## 约束
|
|
||||||
|
|
||||||
- **平台**:iOS 15+(Podspec 声明 iOS 15.0;工程中常见 15.6)— 现有基线
|
|
||||||
- **语言与风格**:代码标识符保持英文;文档/计划使用中文(见 `CONTEXT.md`)— 项目约束
|
|
||||||
- **依赖管理**:CocoaPods 为主(`RDReaderView.podspec`、`Podfile`、`ReadViewDemo/Podfile`)— 现状约束
|
|
||||||
- **引擎策略**:必须基于现有旧引擎直接演进,不新增并行原生引擎 — 本次关键约束
|
|
||||||
- **范围控制**:仅重构 reflowable EPUB 原生渲染内核;Fixed Layout 与交互式内容保持 `WKWebView` — 本次目标边界
|
|
||||||
- **翻页边界**:不修改当前翻页代码(`RDReaderView` 及现有 page curl / scroll 交互逻辑)— 新内核必须适配现有翻页容器
|
|
||||||
- **兼容性**:阅读位置映射、高亮/选区、搜索结果定位、字号/行高/主题切换后的重新分页必须继续可用 — 核心功能约束
|
|
||||||
- **稳定性优先**:任何改造需以“可回归验证、不破坏现有打开/阅读主流程”为前提 — 核心价值驱动
|
|
||||||
|
|
||||||
## 关键决策
|
|
||||||
|
|
||||||
| 决策 | 原因 | 结果 |
|
|
||||||
|---|---|---|
|
|
||||||
| reflowable EPUB 旧引擎直接重构为 WXRead 风格原生渲染 | 需要页面级排版能力,而不是继续在轻量 renderer 外壳上打补丁 | ✓ Good |
|
|
||||||
| Fixed Layout 与交互式 EPUB 继续使用 `WKWebView` | 降低风险与范围,避免破坏既有能力 | ✓ Good |
|
|
||||||
| 不保留并行 reflowable 原生引擎 | 避免双引擎长期维护成本,把演进压力集中在现有主路径上 | ✓ Good |
|
|
||||||
| 不修改当前翻页代码 | 控制改动半径,避免把阅读容器与翻页交互回归风险卷入本次内核重构 | ✓ Good |
|
|
||||||
|
|
||||||
## Next Milestone Goals
|
|
||||||
|
|
||||||
- 补齐 native text `layout frame` 几何查询能力,减少 reader 交互对 `UITextView` 黑盒的依赖
|
|
||||||
- 建立 WXRead 风格自定义分页属性从 HTML/CSS 到 paginator 的闭环
|
|
||||||
- 提升复杂图文章节分页质量,并增加缓存与性能采样
|
|
||||||
- 把 native reflowable 主路径的验证从 runtime spot-check 提升到自动化或稳定半自动化
|
|
||||||
|
|
||||||
## 演进
|
|
||||||
|
|
||||||
本文件会在阶段切换与里程碑完成时持续演进。
|
|
||||||
|
|
||||||
**每个 Phase 完成后**(通过 `$gsd-transition`):
|
|
||||||
1. 有需求被证伪 → 移到“不做”并说明原因
|
|
||||||
2. 有需求被验证 → 移到“已验证”并记录来源 Phase
|
|
||||||
3. 出现新需求 → 加到“进行中”
|
|
||||||
4. 产生关键决策 → 追加到“关键决策”
|
|
||||||
5. “这是什么”是否仍准确 → 如有漂移及时更新
|
|
||||||
|
|
||||||
**每个 Milestone 完成后**(通过 `$gsd-complete-milestone`):
|
|
||||||
1. 全面复查所有章节
|
|
||||||
2. 核心价值是否仍是最高优先级
|
|
||||||
3. “不做”是否需要调整边界与理由
|
|
||||||
4. 更新背景与上下文到当前真实状态
|
|
||||||
|
|
||||||
---
|
|
||||||
*Last updated: 2026-05-22 after v1.0 milestone completion*
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
# 需求:ReadViewSDK v1.1(WXRead 深化对齐)
|
|
||||||
|
|
||||||
**定义日期:** 2026-05-22
|
|
||||||
**核心价值:** 在已完成的 reflowable EPUB 原生化基础上,继续补齐 WXRead 风格的页面级排版能力、阅读交互精度与回归可验证性。
|
|
||||||
|
|
||||||
## 背景
|
|
||||||
|
|
||||||
v1 已完成以下主目标:
|
|
||||||
|
|
||||||
- reflowable EPUB 已具备 WXRead 风格的五层 CSS 组织与章节级 render context
|
|
||||||
- 分页器已具备 block / attachment 边界感知与页面元数据输出
|
|
||||||
- reader 主流程、高亮、搜索、位置恢复、主题/字号/行高重分页已接回
|
|
||||||
- fixed layout / interactive EPUB 继续保持 `WKWebView` 路径
|
|
||||||
|
|
||||||
与 `Doc/WXRead/analysis/` 对照后,当前主要差距已不在“是否走原生管线”,而在以下深化能力:
|
|
||||||
|
|
||||||
- 页面级 layout frame 的几何查询与绘制能力不足
|
|
||||||
- 自定义 CSS / attributed string 属性到分页器的闭环仍不完整
|
|
||||||
- 分页质量、缓存、复杂块元素规则与自动化验证仍弱于 WXRead
|
|
||||||
|
|
||||||
## v1.1 需求(本次范围)
|
|
||||||
|
|
||||||
### 页面级布局与几何能力
|
|
||||||
|
|
||||||
- [ ] **LAYOUT-01**:为 native text 分页结果补齐页面级 layout frame 几何查询能力,至少包括字符串范围矩形查询、矩形反查文本范围、截断检测、命中定位所需的基础 API
|
|
||||||
- [ ] **LAYOUT-02**:阅读器侧的高亮、搜索命中、选区、点击定位等行为应优先建立在 layout frame 几何能力之上,而不是继续强依赖 `UITextView` 的黑盒行为
|
|
||||||
- [ ] **LAYOUT-03**:页面级模型需要继续保留并兼容 `pageStartOffset`、`pageEndOffset`、`fragmentOffsets`、`RDEPUBTextOffsetRangeInfo` 等既有语义
|
|
||||||
|
|
||||||
### WXRead 自定义属性闭环
|
|
||||||
|
|
||||||
- [ ] **ATTR-01**:补齐一条从章节 HTML / CSS 到 `NSAttributedString` 再到分页器的自定义属性闭环,至少覆盖 `avoidPageBreakInside`、`pageBreakBefore`、`pageBreakAfter`、`pageRelate`
|
|
||||||
- [ ] **ATTR-02**:图片与附件相关排版需要支持类似 `wr-vertical-center-style` 的语义输入,并在分页 / 展示阶段保留可消费的信息
|
|
||||||
- [ ] **ATTR-03**:复杂块元素至少要能区分代码块、表格、列表、引用块等类别,以支持更精细的分页边界控制与诊断
|
|
||||||
|
|
||||||
### 分页质量与性能收敛
|
|
||||||
|
|
||||||
- [ ] **QUAL-01**:为 layout frame / 分页结果增加缓存机制,避免同一章节在相同视口与排版配置下重复全量排版
|
|
||||||
- [ ] **QUAL-02**:复杂图文章节的分页质量要进一步提升,减少不合理断页、孤行/寡行、图片前后留白异常、代码块/表格被粗暴截断等问题
|
|
||||||
- [ ] **QUAL-03**:图片与附件的尺寸策略、暗色模式适配、页面背景类信息需要具备可验证的处理规则,而不是依赖默认 DTCoreText 行为
|
|
||||||
- [ ] **QUAL-04**:分页深化后不能显著恶化首屏时间、重分页耗时或交互流畅度;若无法立即建立硬性指标,至少要输出稳定的诊断与采样数据
|
|
||||||
|
|
||||||
### 稳定性与自动化验证
|
|
||||||
|
|
||||||
- [ ] **AUTO-01**:建立覆盖 native reflowable 主路径的自动化回归能力,至少覆盖打开书籍、分页完成、搜索命中、主题/字号切换、位置恢复
|
|
||||||
- [ ] **AUTO-02**:样本矩阵需要持续覆盖复杂图文、代码/表格/列表、附件密集、fixed/interactive WebKit、TXT 五类路径
|
|
||||||
- [ ] **AUTO-03**:每项核心能力都应尽量附着到可重复证据:日志、断言、诊断摘要、UI 自动化步骤或稳定的人工检查清单
|
|
||||||
|
|
||||||
## 成功标准
|
|
||||||
|
|
||||||
以下结论必须全部为 TRUE:
|
|
||||||
|
|
||||||
1. native reflowable 路径拥有可复用的页面几何查询能力,reader 交互不再主要依赖 `UITextView` 推断页面几何
|
|
||||||
2. 至少一组 WXRead 风格自定义分页属性已形成端到端闭环,并对复杂块元素分页产生可见收益
|
|
||||||
3. 同一批复杂样本在新一轮分页器深化后,分页质量优于当前 v1 完成态,而不是仅增加结构复杂度
|
|
||||||
4. 自动化或半自动化验证覆盖到 native text 的关键交互,不再主要依赖单次手工 spot check
|
|
||||||
5. fixed layout、interactive EPUB、TXT 和既有 `RDReaderView` 翻页容器不回归
|
|
||||||
|
|
||||||
## 非目标(本次明确不做)
|
|
||||||
|
|
||||||
| 功能 | 原因 |
|
|
||||||
|---|---|
|
|
||||||
| 重写或替换 `RDReaderView` 翻页容器 | 本次继续只深化 native reflowable 内核与页面几何能力 |
|
|
||||||
| 复刻 WXRead 的完整业务层能力(翻译/双语、免费试读、网络协议、DRM) | 这些属于业务闭环,不是当前 SDK 与 WXRead 的主要技术差距 |
|
|
||||||
| 将 fixed layout / interactive EPUB 改为原生渲染 | 仍保持 `WKWebView` 路径以控制风险 |
|
|
||||||
| 直接拷贝微信读书私有 CSS / JS / 私有实现代码 | 仅参考设计思路,不直接搬运私有实现 |
|
|
||||||
| 一次性完整重写成自绘 `WRPageView` 等价体系 | 风险过大,优先通过 layout frame 几何层与局部 reader 接线演进 |
|
|
||||||
|
|
||||||
## 风险与约束
|
|
||||||
|
|
||||||
- `RDReaderView`、现有 page curl / horizontal / vertical 交互逻辑继续视为稳定容器契约
|
|
||||||
- `RDEPUBReaderController` / `RDURLReaderController` 公开入口必须保持兼容
|
|
||||||
- 所有分页深化都必须保留 offset-based 兼容语义,避免破坏现有高亮、搜索、恢复位置数据
|
|
||||||
- 若要引入自绘或替换 `UITextView` 页面展示,需要以“先引入几何能力、再最小替换”的顺序推进
|
|
||||||
- 若性能采样显示分页深化明显拖慢主流程,应优先做缓存与局部优化,而不是继续扩展特性面
|
|
||||||
|
|
||||||
## 建议优先级
|
|
||||||
|
|
||||||
1. `LAYOUT-*`:先补 layout frame 几何能力
|
|
||||||
2. `ATTR-*`:再补自定义属性到分页器闭环
|
|
||||||
3. `QUAL-*`:随后做分页质量与缓存收敛
|
|
||||||
4. `AUTO-*`:最后把运行时验证补成自动化或稳定半自动化
|
|
||||||
|
|
||||||
## 候选 Phase 映射
|
|
||||||
|
|
||||||
| 需求 | 建议 Phase | 说明 |
|
|
||||||
|---|---:|---|
|
|
||||||
| LAYOUT-01 | Phase 6 | layout frame 几何 API 与截断检测 |
|
|
||||||
| LAYOUT-02 | Phase 6 | reader 交互从 `UITextView` 推断迁移到几何层 |
|
|
||||||
| LAYOUT-03 | Phase 6 | 保持 offset / fragment 兼容契约 |
|
|
||||||
| ATTR-01 | Phase 7 | 自定义分页属性闭环 |
|
|
||||||
| ATTR-02 | Phase 7 | 图片 / 附件布局语义 |
|
|
||||||
| ATTR-03 | Phase 7 | block type 分类与分页规则 |
|
|
||||||
| QUAL-01 | Phase 8 | layout frame / pagination cache |
|
|
||||||
| QUAL-02 | Phase 8 | 复杂样本分页质量提升 |
|
|
||||||
| QUAL-03 | Phase 8 | 图片暗色与页面背景类规则 |
|
|
||||||
| QUAL-04 | Phase 8 | 诊断与性能采样 |
|
|
||||||
| AUTO-01 | Phase 9 | native reflowable 自动化回归 |
|
|
||||||
| AUTO-02 | Phase 9 | 样本矩阵扩展 |
|
|
||||||
| AUTO-03 | Phase 9 | 证据标准化 |
|
|
||||||
|
|
||||||
**覆盖统计:**
|
|
||||||
- v1.1 需求总数:13
|
|
||||||
- 页面级布局:3
|
|
||||||
- 属性闭环:3
|
|
||||||
- 质量与性能:4
|
|
||||||
- 自动化验证:3
|
|
||||||
|
|
||||||
---
|
|
||||||
*Requirements defined: 2026-05-22*
|
|
||||||
*Last updated: 2026-05-22 after WXRead gap analysis*
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
# 需求:ReadViewSDK v1.1
|
|
||||||
|
|
||||||
**定义日期:** 2026-05-22
|
|
||||||
**核心价值:** 稳定可用的 EPUB/TXT 阅读体验
|
|
||||||
|
|
||||||
## v1.1 需求(本次范围)
|
|
||||||
|
|
||||||
### 页面级布局与几何能力
|
|
||||||
|
|
||||||
- [ ] **LAYOUT-01**:为 native text 分页结果补齐页面级 layout frame 几何查询能力,至少包括字符串范围矩形查询、矩形反查文本范围、截断检测、命中定位所需的基础 API
|
|
||||||
- [ ] **LAYOUT-02**:阅读器侧的高亮、搜索命中、选区、点击定位等行为应优先建立在 layout frame 几何能力之上,而不是继续强依赖 `UITextView` 的黑盒行为
|
|
||||||
- [ ] **LAYOUT-03**:页面级模型需要继续保留并兼容 `pageStartOffset`、`pageEndOffset`、`fragmentOffsets`、`RDEPUBTextOffsetRangeInfo` 等既有语义
|
|
||||||
|
|
||||||
### WXRead 自定义属性闭环
|
|
||||||
|
|
||||||
- [ ] **ATTR-01**:补齐一条从章节 HTML / CSS 到 `NSAttributedString` 再到分页器的自定义属性闭环,至少覆盖 `avoidPageBreakInside`、`pageBreakBefore`、`pageBreakAfter`、`pageRelate`
|
|
||||||
- [ ] **ATTR-02**:图片与附件相关排版需要支持类似 `wr-vertical-center-style` 的语义输入,并在分页 / 展示阶段保留可消费的信息
|
|
||||||
- [ ] **ATTR-03**:复杂块元素至少要能区分代码块、表格、列表、引用块等类别,以支持更精细的分页边界控制与诊断
|
|
||||||
|
|
||||||
### 分页质量与性能收敛
|
|
||||||
|
|
||||||
- [ ] **QUAL-01**:为 layout frame / 分页结果增加缓存机制,避免同一章节在相同视口与排版配置下重复全量排版
|
|
||||||
- [ ] **QUAL-02**:复杂图文章节的分页质量要进一步提升,减少不合理断页、孤行/寡行、图片前后留白异常、代码块/表格被粗暴截断等问题
|
|
||||||
- [ ] **QUAL-03**:图片与附件的尺寸策略、暗色模式适配、页面背景类信息需要具备可验证的处理规则,而不是依赖默认 DTCoreText 行为
|
|
||||||
- [ ] **QUAL-04**:分页深化后不能显著恶化首屏时间、重分页耗时或交互流畅度;若无法立即建立硬性指标,至少要输出稳定的诊断与采样数据
|
|
||||||
|
|
||||||
### 稳定性与自动化验证
|
|
||||||
|
|
||||||
- [ ] **AUTO-01**:建立覆盖 native reflowable 主路径的自动化回归能力,至少覆盖打开书籍、分页完成、搜索命中、主题/字号切换、位置恢复
|
|
||||||
- [ ] **AUTO-02**:样本矩阵需要持续覆盖复杂图文、代码/表格/列表、附件密集、fixed/interactive WebKit、TXT 五类路径
|
|
||||||
- [ ] **AUTO-03**:每项核心能力都应尽量附着到可重复证据:日志、断言、诊断摘要、UI 自动化步骤或稳定的人工检查清单
|
|
||||||
|
|
||||||
## Future Requirements
|
|
||||||
|
|
||||||
### 后续增强(暂不进入 v1.1 roadmap)
|
|
||||||
|
|
||||||
- **PERF-01**:建立更严格的性能基线与持续采样门槛(首屏时间、重分页时间、内存峰值)
|
|
||||||
- **GEOM-01**:如果几何层成熟,进一步评估是否需要逐步替换 `UITextView` 展示层
|
|
||||||
- **INTL-01**:更完整的国际化排版增强(RTL、断字、多语种高级回退)
|
|
||||||
|
|
||||||
## Out of Scope
|
|
||||||
|
|
||||||
| 功能 | 原因 |
|
|
||||||
|------|------|
|
|
||||||
| 重写或替换 `RDReaderView` 翻页容器 | 本次继续只深化 native reflowable 内核与页面几何能力 |
|
|
||||||
| 复刻 WXRead 的完整业务层能力(翻译/双语、免费试读、网络协议、DRM) | 这些属于业务闭环,不是当前 SDK 与 WXRead 的主要技术差距 |
|
|
||||||
| 将 fixed layout / interactive EPUB 改为原生渲染 | 仍保持 `WKWebView` 路径以控制风险 |
|
|
||||||
| 直接拷贝微信读书私有 CSS / JS / 私有实现代码 | 仅参考设计思路,不直接搬运私有实现 |
|
|
||||||
| 一次性完整重写成自绘 `WRPageView` 等价体系 | 风险过大,优先通过 layout frame 几何层与局部 reader 接线演进 |
|
|
||||||
|
|
||||||
## Traceability
|
|
||||||
|
|
||||||
| Requirement | Phase | Status |
|
|
||||||
|-------------|-------|--------|
|
|
||||||
| LAYOUT-01 | Phase 6 | Pending |
|
|
||||||
| LAYOUT-02 | Phase 6 | Pending |
|
|
||||||
| LAYOUT-03 | Phase 6 | Pending |
|
|
||||||
| ATTR-01 | Phase 7 | Pending |
|
|
||||||
| ATTR-02 | Phase 7 | Pending |
|
|
||||||
| ATTR-03 | Phase 7 | Pending |
|
|
||||||
| QUAL-01 | Phase 8 | Pending |
|
|
||||||
| QUAL-02 | Phase 8 | Pending |
|
|
||||||
| QUAL-03 | Phase 8 | Pending |
|
|
||||||
| QUAL-04 | Phase 8 | Pending |
|
|
||||||
| AUTO-01 | Phase 9 | Pending |
|
|
||||||
| AUTO-02 | Phase 9 | Pending |
|
|
||||||
| AUTO-03 | Phase 9 | Pending |
|
|
||||||
|
|
||||||
**Coverage:**
|
|
||||||
- v1.1 requirements: 13 total
|
|
||||||
- Mapped to phases: 13
|
|
||||||
- Unmapped: 0 ✓
|
|
||||||
|
|
||||||
---
|
|
||||||
*Requirements defined: 2026-05-22*
|
|
||||||
*Last updated: 2026-05-22 after v1.1 milestone initialization*
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
# 路线图:ReadViewSDK v1.1
|
|
||||||
|
|
||||||
## 概述
|
|
||||||
|
|
||||||
`v1.1` 聚焦在 v1.0 已完成的 native reflowable EPUB 基础上继续深化 WXRead 对齐,不重新打开更大范围的架构重写。实施顺序固定为:先补页面级 layout frame 几何能力,再建立自定义分页属性闭环,然后收敛分页质量/缓存,最后把 native reflowable 的回归证据提升到自动化或稳定半自动化。`RDReaderView` 继续视为稳定容器,fixed layout / interactive EPUB 继续保留 `WKWebView` 路径。
|
|
||||||
|
|
||||||
## Phases
|
|
||||||
|
|
||||||
- [ ] **Phase 6: 页面几何与交互命中层** - 为 native text 建立可复用的 layout frame 几何能力,并让 reader 交互优先依赖该几何层
|
|
||||||
- [ ] **Phase 7: WXRead 自定义属性闭环** - 建立章节 HTML/CSS → attributed string → paginator 的自定义分页属性闭环
|
|
||||||
- [ ] **Phase 8: 分页质量、缓存与性能采样** - 改善复杂图文分页质量并加入缓存、暗色/附件规则和性能诊断
|
|
||||||
- [ ] **Phase 9: 自动化回归与证据标准化** - 把 native reflowable 主路径回归能力提升到自动化或稳定半自动化,并扩展样本矩阵
|
|
||||||
|
|
||||||
## Phase Details
|
|
||||||
|
|
||||||
### Phase 6: 页面几何与交互命中层
|
|
||||||
**Goal**: 为 native text 分页结果建立页面级 layout frame 几何查询能力,并把 reader 交互逐步迁移到显式几何层,而不是继续主要依赖 `UITextView` 的黑盒行为。
|
|
||||||
**Depends on**: Phase 5
|
|
||||||
**Requirements**: LAYOUT-01, LAYOUT-02, LAYOUT-03
|
|
||||||
**Success Criteria** (必须为 TRUE):
|
|
||||||
1. native text 页面可查询字符串范围矩形、矩形反查文本范围、截断与命中信息
|
|
||||||
2. 至少一条 reader 交互链路(如搜索命中、高亮或点击定位)优先消费 layout frame 几何结果
|
|
||||||
3. `pageStartOffset`、`pageEndOffset`、`fragmentOffsets`、`RDEPUBTextOffsetRangeInfo` 兼容语义保持稳定
|
|
||||||
**Plans**: 3 plans
|
|
||||||
|
|
||||||
Plans:
|
|
||||||
- [ ] 06-01:为 `RDEPUBTextLayoutFrame` / page model 补齐几何查询 API 与截断诊断
|
|
||||||
- [ ] 06-02:让 reader 的命中/定位链路优先消费 layout frame 几何层
|
|
||||||
- [ ] 06-03:验证 offset / fragment 兼容契约在新几何层下不回归
|
|
||||||
|
|
||||||
Cross-cutting constraints:
|
|
||||||
- 不替换 `RDReaderView`
|
|
||||||
- 不引入新的公开 reader 入口
|
|
||||||
- 优先增量接线,不一次性重写展示层
|
|
||||||
|
|
||||||
### Phase 7: WXRead 自定义属性闭环
|
|
||||||
**Goal**: 把章节 HTML/CSS 中的分页相关语义稳定地传导到 attributed string 与 paginator,让复杂块元素、图片和附件拥有更精细的分页控制。
|
|
||||||
**Depends on**: Phase 6
|
|
||||||
**Requirements**: ATTR-01, ATTR-02, ATTR-03
|
|
||||||
**Success Criteria** (必须为 TRUE):
|
|
||||||
1. 至少 `avoidPageBreakInside`、`pageBreakBefore`、`pageBreakAfter`、`pageRelate` 形成端到端闭环
|
|
||||||
2. 图片/附件垂直居中等语义在分页或展示阶段仍可被消费
|
|
||||||
3. 代码块、表格、列表、引用块等类别在分页诊断中可区分,并影响分页边界控制
|
|
||||||
**Plans**: 3 plans
|
|
||||||
|
|
||||||
Plans:
|
|
||||||
- [ ] 07-01:定义并接线分页相关自定义属性从 HTML/CSS 到 attributed string 的映射
|
|
||||||
- [ ] 07-02:补齐图片/附件语义与页面消费规则
|
|
||||||
- [ ] 07-03:为复杂块元素分类与分页规则建立诊断和验证路径
|
|
||||||
|
|
||||||
Cross-cutting constraints:
|
|
||||||
- 继续沿用现有 chapter preprocessing 与 renderer contract
|
|
||||||
- 不直接搬运微信读书私有实现
|
|
||||||
- 新属性必须可诊断、可回归,不是隐式魔法行为
|
|
||||||
|
|
||||||
### Phase 8: 分页质量、缓存与性能采样
|
|
||||||
**Goal**: 在新几何层与属性闭环之上提升复杂图文章节分页质量,并控制重复排版开销与主题/附件规则的一致性。
|
|
||||||
**Depends on**: Phase 7
|
|
||||||
**Requirements**: QUAL-01, QUAL-02, QUAL-03, QUAL-04
|
|
||||||
**Success Criteria** (必须为 TRUE):
|
|
||||||
1. 相同视口与排版配置下,同一章节不会重复全量排版
|
|
||||||
2. 复杂图文章节分页质量可见改善,减少粗暴截断、不合理留白、孤行/寡行
|
|
||||||
3. 图片尺寸策略、暗色模式适配、页面背景类信息有明确处理规则和诊断证据
|
|
||||||
4. 首屏时间、重分页耗时或交互流畅度不会出现明显退化,且有稳定采样数据
|
|
||||||
**Plans**: 3 plans
|
|
||||||
|
|
||||||
Plans:
|
|
||||||
- [ ] 08-01:为 layout frame / pagination 增加缓存键与失效策略
|
|
||||||
- [ ] 08-02:针对复杂样本收敛分页质量与图片/附件/背景规则
|
|
||||||
- [ ] 08-03:补充性能采样与质量诊断输出
|
|
||||||
|
|
||||||
Cross-cutting constraints:
|
|
||||||
- 先做缓存和局部优化,再考虑进一步扩展特性面
|
|
||||||
- 质量提升必须基于真实样本,而不是仅靠 synthetic case
|
|
||||||
- 保持与现有 offset-based consumer 的兼容
|
|
||||||
|
|
||||||
### Phase 9: 自动化回归与证据标准化
|
|
||||||
**Goal**: 建立覆盖 native reflowable 主路径的自动化或稳定半自动化回归能力,并把样本矩阵和运行时证据标准化。
|
|
||||||
**Depends on**: Phase 8
|
|
||||||
**Requirements**: AUTO-01, AUTO-02, AUTO-03
|
|
||||||
**Success Criteria** (必须为 TRUE):
|
|
||||||
1. native reflowable 至少覆盖打开书籍、分页完成、搜索命中、主题/字号切换、位置恢复
|
|
||||||
2. 样本矩阵持续覆盖复杂图文、代码/表格/列表、附件密集、fixed/interactive WebKit、TXT 五类路径
|
|
||||||
3. 每项核心能力都具备可重复证据:日志、断言、诊断摘要、UI 自动化步骤或稳定人工检查清单
|
|
||||||
**Plans**: 2 plans
|
|
||||||
|
|
||||||
Plans:
|
|
||||||
- [ ] 09-01:建立 native reflowable 关键交互自动化或稳定半自动化验证
|
|
||||||
- [ ] 09-02:扩展样本矩阵并统一证据标准、日志摘要与 rerun 清单
|
|
||||||
|
|
||||||
Cross-cutting constraints:
|
|
||||||
- 不为验证再造第二套 demo shell
|
|
||||||
- 优先复用 `ReadViewDemo`、现有日志和诊断输出
|
|
||||||
- WebKit / TXT 路径仍必须留在矩阵内,不能只测 native reflowable
|
|
||||||
|
|
||||||
## Progress
|
|
||||||
|
|
||||||
| Phase | Plans Complete | Status | Completed |
|
|
||||||
|---|---:|---|---|
|
|
||||||
| 6. 页面几何与交互命中层 | 0/3 | Not started | - |
|
|
||||||
| 7. WXRead 自定义属性闭环 | 0/3 | Not started | - |
|
|
||||||
| 8. 分页质量、缓存与性能采样 | 0/3 | Not started | - |
|
|
||||||
| 9. 自动化回归与证据标准化 | 0/2 | Not started | - |
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
---
|
|
||||||
gsd_state_version: 1.0
|
|
||||||
milestone: v1.1
|
|
||||||
milestone_name: WXRead 深化对齐
|
|
||||||
status: ready_to_execute
|
|
||||||
last_updated: "2026-05-22T05:54:18.839Z"
|
|
||||||
last_activity: 2026-05-22
|
|
||||||
progress:
|
|
||||||
total_phases: 4
|
|
||||||
completed_phases: 0
|
|
||||||
total_plans: 11
|
|
||||||
completed_plans: 0
|
|
||||||
percent: 0
|
|
||||||
---
|
|
||||||
|
|
||||||
# STATE
|
|
||||||
|
|
||||||
**状态日期:** 2026-05-22
|
|
||||||
**项目:** ReadViewSDK(brownfield)
|
|
||||||
|
|
||||||
## Project Reference
|
|
||||||
|
|
||||||
参见:`.planning/PROJECT.md`(更新于 2026-05-22)
|
|
||||||
|
|
||||||
**核心价值:** 稳定可用的 EPUB/TXT 阅读体验
|
|
||||||
**当前关注:** `v1.1` 已完成 Phase 6 规划,下一步进入执行
|
|
||||||
|
|
||||||
## 当前结论(摘要)
|
|
||||||
|
|
||||||
- `v1.1` 已基于现有候选需求正式生成 active `REQUIREMENTS.md` 与 `ROADMAP.md`。
|
|
||||||
- 当前活跃 roadmap 包含 Phase 6-9:页面几何、属性闭环、分页质量/缓存、自动化回归。
|
|
||||||
- `v1.0` 归档与 audit 仍保留在 `.planning/milestones/` 和 `.planning/v1.0-MILESTONE-AUDIT.md` 供后续追溯。
|
|
||||||
|
|
||||||
## 当前风险焦点
|
|
||||||
|
|
||||||
- native text 几何查询、自定义分页属性闭环、分页质量与自动化验证是 `v1.1` 的主要技术风险
|
|
||||||
- `v1.0` 遗留的 requirement bookkeeping gap 已归档,不应阻塞 `v1.1` 执行,但后续引用历史证据时需注意
|
|
||||||
- simulator 仍会输出既有 `.SFUI-Semibold` CoreText 替代提示,但未造成功能性失败
|
|
||||||
|
|
||||||
## 现有代码库地图
|
|
||||||
|
|
||||||
- 代码库地图:`.planning/codebase/`
|
|
||||||
- 关键文档:
|
|
||||||
- `.planning/codebase/ARCHITECTURE.md`
|
|
||||||
- `.planning/codebase/STACK.md`
|
|
||||||
- `.planning/codebase/STRUCTURE.md`
|
|
||||||
- `.planning/codebase/CONCERNS.md`
|
|
||||||
- 方案文档:
|
|
||||||
- `Doc/FeatureSolution/ReflowableEPUB_WXReadRenderer_Design.md`
|
|
||||||
|
|
||||||
## 下一步
|
|
||||||
|
|
||||||
- 建议下一步:`$gsd-discuss-phase 6`
|
|
||||||
- 已完成的产物目录:
|
|
||||||
- `.planning/milestones/`
|
|
||||||
- `.planning/REQUIREMENTS-v1.1-WXRead-next.md`
|
|
||||||
|
|
||||||
## Current Position
|
|
||||||
|
|
||||||
Phase: 6
|
|
||||||
Plan: 06-01 / 06-02 / 06-03
|
|
||||||
Status: Ready for execute-phase
|
|
||||||
Last activity: 2026-05-22 — Phase 6 context, research, validation, and plans created
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
<!-- refreshed: 2026-05-21 -->
|
|
||||||
# 架构概览
|
|
||||||
|
|
||||||
**分析日期:** 2026-05-21
|
|
||||||
|
|
||||||
## 系统概述
|
|
||||||
|
|
||||||
本仓库包含一个 iOS **阅读 SDK**(`RDReaderView`)以及一个用于演示集成的 **Demo App**(`ReadViewDemo`)。Demo 通过 CocoaPods 以本地 `:path` 方式引入 SDK。
|
|
||||||
|
|
||||||
```text
|
|
||||||
┌─────────────────────────────────────────────────────────────────────────┐
|
|
||||||
│ Demo App │
|
|
||||||
│ `ReadViewDemo/ReadViewDemo` │
|
|
||||||
│ - 列表展示内置 .epub/.txt → push `RDURLReaderController` │
|
|
||||||
└───────────────────────────────┬─────────────────────────────────────────┘
|
|
||||||
│ uses
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────────────────────────────────────┐
|
|
||||||
│ Public SDK │
|
|
||||||
│ `Sources/RDReaderView` │
|
|
||||||
│ 入口控制器: │
|
|
||||||
│ - `RDURLReaderController`(基于 URL:epub/txt) │
|
|
||||||
│ - `RDEPUBReaderController`(EPUB + 外部 TextBook) │
|
|
||||||
│ 核心视图: │
|
|
||||||
│ - `RDReaderView`(仿真翻页 / 横向 / 纵向模式) │
|
|
||||||
└───────────────┬───────────────────────────┬─────────────────────────────┘
|
|
||||||
│ │
|
|
||||||
▼ ▼
|
|
||||||
┌───────────────────────────┐ ┌─────────────────────────────────────────┐
|
|
||||||
│ EPUBCore │ │ EPUBTextRendering │
|
|
||||||
│ `Sources/RDReaderView/ │ │ `Sources/RDReaderView/EPUBTextRendering`│
|
|
||||||
│ EPUBCore` │ │ - 从 .txt 构建 `RDEPUBTextBook` │
|
|
||||||
│ - 解析/解压 EPUB │ │ - 文本章节渲染/搜索 │
|
|
||||||
│ - spine/TOC/location 模型 │ └─────────────────────────────────────────┘
|
|
||||||
│ - 基于 WKWebView 分页 │
|
|
||||||
│ - 资源解析/寻址 │
|
|
||||||
└───────────────┬───────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────────────────────────────────────┐
|
|
||||||
│ EPUBUI │
|
|
||||||
│ `Sources/RDReaderView/EPUBUI` │
|
|
||||||
│ - 阅读器 UX:工具栏/主题/设置 │
|
|
||||||
│ - 默认持久化(UserDefaults) │
|
|
||||||
│ - 协调 parser + paginator + RDReaderView │
|
|
||||||
└─────────────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## 组件职责
|
|
||||||
|
|
||||||
| 组件 | 职责 | 文件 |
|
|
||||||
|---|---|---|
|
|
||||||
| `RDURLReaderController` | 面向“URL 打开”的顶层入口;根据后缀路由 epub vs txt;当分页失败时回退为纯文本展示 | `Sources/RDReaderView/RDURLReaderController.swift` |
|
|
||||||
| `RDEPUBReaderController` | 主阅读器控制器;协调解析/分页,连接 `RDReaderView`;管理阅读状态、选择/高亮/书签、工具视图等 | `Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift` |
|
|
||||||
| `RDReaderView` | 分页容器视图,支持仿真翻页与滚动模式;通过 data source 获取页面视图并回调当前页变化 | `Sources/RDReaderView/RDReaderView.swift` |
|
|
||||||
| `RDEPUBParser` | 负责解析 `.epub`(container.xml + OPF);构建 manifest/spine/TOC;产出 `RDEPUBPublication` | `Sources/RDReaderView/EPUBCore/RDEPUBParser.swift` |
|
|
||||||
| `RDEPUBPublication` | 对解析后的 publication 做只读封装(metadata/spine/TOC/资源解析、fixed-layout 判定等) | `Sources/RDReaderView/EPUBCore/RDEPUBPublication.swift` |
|
|
||||||
| `RDEPUBPaginator` | 以 `WKWebView` 测量并生成分页信息(章节页范围、CFI 映射等) | `Sources/RDReaderView/EPUBCore/RDEPUBPaginator.swift` |
|
|
||||||
| `RDEPUBReadingSession` | 阅读会话状态(当前章节/页、分页缓存、交互状态等),为 UI 层提供数据 | `Sources/RDReaderView/EPUBCore/RDEPUBReadingSession.swift` |
|
|
||||||
| `RDEPUBReaderPersistence` | 阅读器持久化协议(位置/设置/书签/高亮等),默认实现使用 UserDefaults | `Sources/RDReaderView/EPUBUI/RDEPUBReaderPersistence.swift` |
|
|
||||||
|
|
||||||
> 备注:实际类型与职责以代码为准;上表按文件职责做抽象总结。
|
|
||||||
|
|
||||||
## 分层结构
|
|
||||||
|
|
||||||
**Demo App 层:**
|
|
||||||
- 目的:展示集成方式与最小化书籍选择 UX。
|
|
||||||
- 位置:`ReadViewDemo/ReadViewDemo`
|
|
||||||
- 依赖:本地 path 的 `RDReaderView` pod、UIKit。
|
|
||||||
|
|
||||||
**SDK UI 层(Reader UX):**
|
|
||||||
- 目的:阅读器控制器 UX + 设置持久化 + 工具视图。
|
|
||||||
- 位置:`Sources/RDReaderView/EPUBUI`
|
|
||||||
- 依赖:`EPUBCore`、`EPUBTextRendering`、`RDReaderView`。
|
|
||||||
|
|
||||||
**SDK View 层(分页容器):**
|
|
||||||
- 目的:页面呈现模式(翻页/滚动)与手势/工具栏显示控制。
|
|
||||||
- 位置:`Sources/RDReaderView/RDReaderView.swift`、`Sources/RDReaderView/RDReaderFlowLayout.swift`
|
|
||||||
- 依赖:UIKit。
|
|
||||||
|
|
||||||
**SDK Core 层(EPUB 解析/分页/状态):**
|
|
||||||
- 目的:解析 EPUB 结构、资源寻址、分页计算、导航状态。
|
|
||||||
- 位置:`Sources/RDReaderView/EPUBCore`
|
|
||||||
- 依赖:Foundation、WebKit(分页/导航)、ZIPFoundation(解压;通过 Podspec 依赖)。
|
|
||||||
|
|
||||||
**SDK 文本渲染层:**
|
|
||||||
- 目的:为纯文本输入构建分页结构并提供渲染/搜索能力。
|
|
||||||
- 位置:`Sources/RDReaderView/EPUBTextRendering`
|
|
||||||
- 依赖:UIKit/Foundation(以及通过 Podspec 依赖的 DTCoreText 相关能力)。
|
|
||||||
|
|
||||||
## 数据流
|
|
||||||
|
|
||||||
### 主路径(通过 URL 打开书籍)
|
|
||||||
|
|
||||||
1. Demo 选择文件并 push reader(`ReadViewDemo/ReadViewDemo/ViewController.swift`)。
|
|
||||||
2. `RDURLReaderController` 根据扩展名分发(`Sources/RDReaderView/RDURLReaderController.swift`)。
|
|
||||||
3. 对 EPUB:`RDEPUBReaderController` 开始加载,解析 publication,并为当前视口执行分页(`Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift`)。
|
|
||||||
4. 分页由 `RDEPUBPaginator`(`WKWebView` 测量)生成 `EPUBPage` / `EPUBChapterInfo` 等快照,供 `RDReaderView` 渲染(`Sources/RDReaderView/EPUBCore/RDEPUBPaginator.swift`、`Sources/RDReaderView/EPUBCore/RDEPUBReadingSession.swift`)。
|
|
||||||
5. `RDReaderView` 展示页面并输出页切换回调(`Sources/RDReaderView/RDReaderView.swift`)。
|
|
||||||
|
|
||||||
### 次路径(打开纯文本文件)
|
|
||||||
|
|
||||||
1. `RDURLReaderController` 使用 `RDPlainTextBookBuilder` 构建 `RDEPUBTextBook`,分页尺寸/样式来自 `RDEPUBReaderConfiguration`(`Sources/RDReaderView/RDURLReaderController.swift`、`Sources/RDReaderView/EPUBTextRendering/RDPlainTextBookBuilder.swift`)。
|
|
||||||
2. `RDEPUBReaderController` 以 “external TextBook” 模式运行,复用相同的阅读器 UX 与持久化链路(`Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift`)。
|
|
||||||
|
|
||||||
**状态管理:**
|
|
||||||
- 内存态主要由 `RDEPUBReadingSession` 与 `RDEPUBReaderController` 维护。
|
|
||||||
- 默认持久化为 UserDefaults(`RDEPUBUserDefaultsPersistence`,见 `Sources/RDReaderView/EPUBUI/RDEPUBReaderPersistence.swift`)。
|
|
||||||
|
|
||||||
## 入口点
|
|
||||||
|
|
||||||
**SDK:**
|
|
||||||
- `RDURLReaderController`:URL 入口,封装 epub/txt 分支(`Sources/RDReaderView/RDURLReaderController.swift`)。
|
|
||||||
- `RDEPUBReaderController`:可直接打开 epub URL,或读取已构建的 `RDEPUBTextBook`(`Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift`)。
|
|
||||||
- `RDReaderView`:可复用的分页视图(`Sources/RDReaderView/RDReaderView.swift`)。
|
|
||||||
|
|
||||||
**Demo:**
|
|
||||||
- UIKit 生命周期(`ReadViewDemo/ReadViewDemo/AppDelegate.swift`、`ReadViewDemo/ReadViewDemo/SceneDelegate.swift`)。
|
|
||||||
|
|
||||||
## 架构约束
|
|
||||||
|
|
||||||
- **平台:** iOS 15+(`RDReaderView.podspec` 中 `s.platform = :ios, "15.0"`)。
|
|
||||||
- **视口耦合:** 分页与视口大小/insets 强耦合,视口变化会触发重新分页(`Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift`)。
|
|
||||||
- **WebKit 依赖:** 可重排 EPUB 的分页使用离屏、非持久化的 `WKWebView`(`Sources/RDReaderView/EPUBCore/RDEPUBPaginator.swift`)。
|
|
||||||
|
|
||||||
## 错误处理
|
|
||||||
|
|
||||||
**策略:** URL 入口采用“尽量可用”的 fail-soft 体验;Reader Controller 提供可见的 loading/error UI。
|
|
||||||
|
|
||||||
**模式:**
|
|
||||||
- URL 入口在文本分页失败时回退为 `UITextView`(`Sources/RDReaderView/RDURLReaderController.swift`)。
|
|
||||||
- Parser 通过 `RDEPUBParserError` 抛出类型化错误(`Sources/RDReaderView/EPUBCore/RDEPUBModels.swift`)。
|
|
||||||
|
|
||||||
## Evidence(关键证据)
|
|
||||||
|
|
||||||
检查过的关键文件:
|
|
||||||
- `RDReaderView.podspec`
|
|
||||||
- `Podfile`
|
|
||||||
- `ReadViewDemo/ReadViewDemo.xcworkspace/contents.xcworkspacedata`
|
|
||||||
- `ReadViewDemo/ReadViewDemo.xcodeproj/project.pbxproj`
|
|
||||||
- `ReadViewDemo/ReadViewDemo/AppDelegate.swift`
|
|
||||||
- `ReadViewDemo/ReadViewDemo/SceneDelegate.swift`
|
|
||||||
- `ReadViewDemo/ReadViewDemo/ViewController.swift`
|
|
||||||
- `Sources/RDReaderView/RDURLReaderController.swift`
|
|
||||||
- `Sources/RDReaderView/RDReaderView.swift`
|
|
||||||
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift`
|
|
||||||
- `Sources/RDReaderView/EPUBCore/RDEPUBParser.swift`
|
|
||||||
- `Sources/RDReaderView/EPUBCore/RDEPUBPaginator.swift`
|
|
||||||
- `Sources/RDReaderView/EPUBCore/RDEPUBPublication.swift`
|
|
||||||
- `Sources/RDReaderView/EPUBCore/RDEPUBReadingSession.swift`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*架构分析:2026-05-21*
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
# 编码规范
|
|
||||||
|
|
||||||
**分析日期:** 2026-05-21
|
|
||||||
|
|
||||||
> 说明:本仓库以 iOS/Swift 为主,未检测到统一的 lint/format 工具配置;代码风格在不同模块/年代间存在差异。`.planning/` 下的文档按项目规则使用中文描述,但代码标识符保持英文。
|
|
||||||
|
|
||||||
## 语言与工程约束
|
|
||||||
|
|
||||||
- **主要语言**:Swift(Podspec 声明 `s.swift_versions = ["5.10"]`,见 `RDReaderView.podspec`)
|
|
||||||
- **最低系统版本**:Podspec `iOS 15.0`(`RDReaderView.podspec`),示例工程 Podfile/构建设置里常见为 `iOS 15.6`(`Podfile`)
|
|
||||||
- **依赖管理**:CocoaPods(`Podfile`、`ReadViewDemo/Podfile`、`Podfile.lock`)
|
|
||||||
|
|
||||||
## 命名约定
|
|
||||||
|
|
||||||
**文件/类型命名(Swift):**
|
|
||||||
- 以类型名为文件名的单文件组织较常见:`Sources/RDReaderView/EPUBCore/RDEPUBParser.swift`
|
|
||||||
- 大量使用前缀区分模块域:
|
|
||||||
- `RD...`:阅读器 UI/控制器相关(如 `Sources/RDReaderView/RDReaderView.swift`、`Sources/RDReaderView/RDURLReaderController.swift`)
|
|
||||||
- `RDEPUB...`:EPUB Core/UI/渲染相关(如 `Sources/RDReaderView/EPUBCore/RDEPUBModels.swift`)
|
|
||||||
- Extension 文件使用 `+` 命名:`Sources/RDReaderView/LegacyRDReaderController/RDReaderController+EPUBText.swift`
|
|
||||||
|
|
||||||
**变量/函数命名:**
|
|
||||||
- 基本遵循 Swift lowerCamelCase:`parse(epubURL:)`(`Sources/RDReaderView/EPUBCore/RDEPUBParser.swift`)
|
|
||||||
- UI 代码中常见简写:`imageV`(`Sources/RDReaderView/LegacyRDReaderController/RDReaderCoverView.swift`)
|
|
||||||
- 常量多用 `static let`:`epubPaginationCacheStorageKey`(`Sources/RDReaderView/LegacyRDReaderController/RDReaderController.swift`)
|
|
||||||
|
|
||||||
## 代码风格与排版(从现有代码归纳)
|
|
||||||
|
|
||||||
**缩进与换行:**
|
|
||||||
- 多数文件使用 4 空格缩进(示例:`ReadViewDemo/ReadViewDemo/ViewController.swift`、`Sources/RDReaderView/EPUBCore/RDEPUBModels.swift`)
|
|
||||||
- 历史代码中更常见“强制换行/多行括号”风格(示例:`Sources/RDReaderView/LegacyRDReaderController/RDReaderController.swift` 的 `CGRect(...)`)
|
|
||||||
|
|
||||||
**空行与分组:**
|
|
||||||
- UI 相关文件常用空行分隔属性/初始化/布局段落(示例:`Sources/RDReaderView/LegacyRDReaderController/RDReaderCoverView.swift`)
|
|
||||||
- `// MARK:` 用于分区组织(示例:`Sources/RDReaderView/RDReaderGestureController.swift`、`Sources/RDReaderView/LegacyRDReaderController/RDReaderController.swift`、`Sources/RDReaderView/EPUBTextRendering/RDPlainTextBookBuilder.swift`)
|
|
||||||
|
|
||||||
**类型组织:**
|
|
||||||
- 偏好用 `extension` 拆分职责/协议实现(示例:`ReadViewDemo/ReadViewDemo/ViewController.swift` 的 `UITableViewDataSource/Delegate`)
|
|
||||||
- API 暴露处使用 `public`、`public final class`、`public enum/struct`(示例:`Sources/RDReaderView/EPUBCore/RDEPUBModels.swift`)
|
|
||||||
- “对外只读、内部可写”常用 `public internal(set)`(示例:`Sources/RDReaderView/EPUBCore/RDEPUBParser.swift`)
|
|
||||||
|
|
||||||
## 导入与依赖使用
|
|
||||||
|
|
||||||
**import:**
|
|
||||||
- UIKit/UI 文件:`import UIKit`(大量文件)
|
|
||||||
- Core/模型文件:`import Foundation`(如 `Sources/RDReaderView/EPUBCore/RDEPUBModels.swift`)
|
|
||||||
- 三方依赖按需引入:
|
|
||||||
- `SnapKit`:布局(如 `Sources/RDReaderView/LegacyRDReaderController/RDReaderCoverView.swift`)
|
|
||||||
- `SSAlertSwift`:弹窗/提示(如 `Sources/RDReaderView/LegacyRDReaderController/RDReaderController.swift`)
|
|
||||||
|
|
||||||
**Pods 目录说明:**
|
|
||||||
- `ReadViewDemo/Pods/**` 为依赖源码/生成配置,通常不作为本仓库代码风格的“标准样式”参考。
|
|
||||||
|
|
||||||
## 错误处理与日志
|
|
||||||
|
|
||||||
- Core 解析层倾向用 `throws` + 自定义 `Error`(示例:`Sources/RDReaderView/EPUBCore/RDEPUBParser.swift`、`Sources/RDReaderView/EPUBCore/RDEPUBModels.swift` 的 `RDEPUBParserError`)
|
|
||||||
- UI/控制器层常见 `guard` 早返回(示例:`ReadViewDemo/ReadViewDemo/ViewController.swift`)
|
|
||||||
- 未检测到统一日志框架(未发现专用 logging package/config);出现时以系统 API/局部输出为主(需按具体文件核对)。
|
|
||||||
|
|
||||||
## 注释与文档
|
|
||||||
|
|
||||||
- **项目规则(强约束)**:代码标识符保持英文,但**代码注释/文档/提交信息使用中文**(见 `CONTEXT.md`)。
|
|
||||||
- 历史文件常带 Xcode 头部注释块(示例:`Sources/RDReaderView/RDReaderView.swift`、`Sources/RDReaderView/LegacyRDReaderController/RDReaderCoverView.swift`)。
|
|
||||||
- 公共 API 处存在少量三斜线文档注释(示例:`Sources/RDReaderView/RDReaderView.swift` 的中文说明)。
|
|
||||||
|
|
||||||
## Lint / Formatter / 静态检查
|
|
||||||
|
|
||||||
**未检测到(仓库根与常见位置):**
|
|
||||||
- SwiftLint 配置:`.swiftlint.yml` / `swiftlint.yml`
|
|
||||||
- SwiftFormat 配置:`.swiftformat`
|
|
||||||
- 通用格式化配置:`.editorconfig`
|
|
||||||
- 其他:ESLint/Prettier/Biome 等(本仓库非 JS/TS 主体)
|
|
||||||
|
|
||||||
**可执行的工程级格式化/检查:**
|
|
||||||
- 主要依赖 Xcode(或 Swift 编译器)自身检查;如需引入 SwiftLint/SwiftFormat,应先新增对应配置文件并在 CI/构建脚本中接入。
|
|
||||||
|
|
||||||
## Evidence(关键证据文件)
|
|
||||||
|
|
||||||
- `CONTEXT.md`
|
|
||||||
- `Podfile`
|
|
||||||
- `RDReaderView.podspec`
|
|
||||||
- `ReadViewDemo/ReadViewDemo/ViewController.swift`
|
|
||||||
- `Sources/RDReaderView/RDReaderView.swift`
|
|
||||||
- `Sources/RDReaderView/LegacyRDReaderController/RDReaderController.swift`
|
|
||||||
- `Sources/RDReaderView/LegacyRDReaderController/RDReaderCoverView.swift`
|
|
||||||
- `Sources/RDReaderView/EPUBCore/RDEPUBParser.swift`
|
|
||||||
- `Sources/RDReaderView/EPUBCore/RDEPUBModels.swift`
|
|
||||||
- `ReadViewDemo/ReadViewDemo.xcodeproj/project.pbxproj`
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
# 外部集成
|
|
||||||
|
|
||||||
**分析日期:** 2026-05-21
|
|
||||||
|
|
||||||
## API 与外部服务
|
|
||||||
|
|
||||||
**网络 / Web 服务:**
|
|
||||||
- 在 SDK 源码(`Sources/RDReaderView/**`)中未检测到:未发现 `URLSession` 使用,也未发现常见第三方网络 SDK。
|
|
||||||
|
|
||||||
**托管服务 SDK(统计/崩溃/广告/支付):**
|
|
||||||
- 在仓库源码中未检测到:未发现 Firebase、Sentry、Mixpanel/Segment/Amplitude、AppCenter 等。
|
|
||||||
|
|
||||||
## 数据存储
|
|
||||||
|
|
||||||
**数据库:**
|
|
||||||
- 未检测到(`Sources/RDReaderView/**` 中未发现 Core Data / SQLite / Realm / GRDB 使用)。
|
|
||||||
|
|
||||||
**文件存储(本地文件系统):**
|
|
||||||
- EPUB 解压使用 `FileManager`,将解压内容存放在 app caches 或临时目录下,并使用“确定性签名”目录名 — `Sources/RDReaderView/EPUBCore/RDEPUBParser+Archive.swift`。
|
|
||||||
|
|
||||||
**偏好 / 轻量持久化:**
|
|
||||||
- 使用 `UserDefaults` 保存设置/状态(例如 debug 开关、阅读器状态快照)— `Sources/RDReaderView/EPUBCore/RDEPUBWebViewDebug.swift`、`Sources/RDReaderView/EPUBUI/RDEPUBReaderPersistence.swift`、`Sources/RDReaderView/LegacyRDReaderController/RDReaderController.swift`。
|
|
||||||
|
|
||||||
## 认证与身份
|
|
||||||
|
|
||||||
- 不适用(未检测到认证提供方或身份流程)。
|
|
||||||
|
|
||||||
## 监控与可观测性
|
|
||||||
|
|
||||||
**错误追踪 / 崩溃上报:**
|
|
||||||
- 未检测到(无 Crashlytics/Sentry 等)。
|
|
||||||
|
|
||||||
**日志:**
|
|
||||||
- 存在用于 EPUB WebView 调试的本地 debug 日志工具 — `Sources/RDReaderView/EPUBCore/RDEPUBWebViewDebug.swift`。
|
|
||||||
|
|
||||||
## 构建期集成
|
|
||||||
|
|
||||||
**CocoaPods:**
|
|
||||||
- SDK 作为 CocoaPod 发布并声明外部依赖 — `RDReaderView.podspec`。
|
|
||||||
- 示例 App 通过本地 `:path => '..'` 引用该 Pod — `ReadViewDemo/Podfile`。
|
|
||||||
- 仓库根 `Podfile` 也声明了 `RDReaderView` 的本地 path 依赖,并设置共享构建参数覆盖 — `Podfile`。
|
|
||||||
|
|
||||||
**构建设置覆盖:**
|
|
||||||
- 在 `post_install` 中统一设置 `ENABLE_USER_SCRIPT_SANDBOXING = NO`、`IPHONEOS_DEPLOYMENT_TARGET = 15.6` — `Podfile`、`ReadViewDemo/Podfile`。
|
|
||||||
|
|
||||||
## 渲染 / 内嵌 Web 内容
|
|
||||||
|
|
||||||
**WKWebView 集成:**
|
|
||||||
- 使用 `WKWebViewConfiguration` 并设置 `websiteDataStore = .nonPersistent()`;注册自定义 URL scheme handler 以服务 EPUB 资源 — `Sources/RDReaderView/EPUBCore/RDEPUBWebView+Configuration.swift`。
|
|
||||||
- 注入 user script,并注册 script message handler 实现 JavaScript bridge — `Sources/RDReaderView/EPUBCore/RDEPUBWebView+Configuration.swift`、`Sources/RDReaderView/EPUBCore/RDEPUBJavaScriptBridge.swift`。
|
|
||||||
|
|
||||||
## CI/CD 与部署
|
|
||||||
|
|
||||||
**CI 流水线:**
|
|
||||||
- 未检测到(未发现 `.github/` 或常见 CI 配置文件)。
|
|
||||||
|
|
||||||
**部署:**
|
|
||||||
- 未检测到 App 部署流水线;仓库形态为 iOS SDK + Demo App(`RDReaderView.podspec`、`ReadViewDemo/`)。
|
|
||||||
|
|
||||||
## 环境配置
|
|
||||||
|
|
||||||
**必须的环境变量:**
|
|
||||||
- 未检测到(未发现 `.env*` 使用,也未发现 `Sources/RDReaderView/**` 中读取环境变量的代码)。
|
|
||||||
|
|
||||||
**密钥存放:**
|
|
||||||
- 基于当前仓库内容检查结果:不适用。
|
|
||||||
|
|
||||||
## Webhooks 与回调
|
|
||||||
|
|
||||||
**入站:**
|
|
||||||
- 未检测到。
|
|
||||||
|
|
||||||
**出站:**
|
|
||||||
- 未检测到。
|
|
||||||
|
|
||||||
## Evidence(关键证据)
|
|
||||||
|
|
||||||
检查过的关键文件:
|
|
||||||
- `RDReaderView.podspec`
|
|
||||||
- `ReadViewDemo/Podfile`
|
|
||||||
- `ReadViewDemo/Podfile.lock`
|
|
||||||
- `Podfile`
|
|
||||||
- `Sources/RDReaderView/EPUBCore/RDEPUBParser+Archive.swift`
|
|
||||||
- `Sources/RDReaderView/EPUBCore/RDEPUBWebView+Configuration.swift`
|
|
||||||
- `Sources/RDReaderView/EPUBCore/RDEPUBJavaScriptBridge.swift`
|
|
||||||
- `Sources/RDReaderView/LegacyRDReaderController/RDReaderController.swift`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*外部集成审计:2026-05-21*
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
# 技术栈
|
|
||||||
|
|
||||||
**分析日期:** 2026-05-21
|
|
||||||
|
|
||||||
## 语言
|
|
||||||
|
|
||||||
**主要语言:**
|
|
||||||
- Swift(Podspec 声明 Swift 5.10)— SDK 实现在 `Sources/RDReaderView/**/*.swift`,示例 App 在 `ReadViewDemo/ReadViewDemo/*.swift`。
|
|
||||||
|
|
||||||
**次要语言:**
|
|
||||||
- Objective-C — 主要来自示例工程 vendored 的 CocoaPods 依赖源码 `ReadViewDemo/Pods/**`(例如 DTFoundation/DTCoreText)。
|
|
||||||
|
|
||||||
## 运行环境
|
|
||||||
|
|
||||||
**平台:**
|
|
||||||
- iOS — 最低 iOS 15.x(SDK 声明 iOS 15.0;Demo/Podfile 常见为 15.6)。
|
|
||||||
|
|
||||||
**使用到的 Apple Framework(不完全列举):**
|
|
||||||
- UIKit — `Sources/RDReaderView/**` 内的 UI 与控制器实现。
|
|
||||||
- WebKit — EPUB Web 渲染相关配置见 `Sources/RDReaderView/EPUBCore/RDEPUBWebView+Configuration.swift`。
|
|
||||||
- CoreText — 文本分页/渲染支持(`Sources/RDReaderView/EPUBTextRendering/**`)。
|
|
||||||
- Foundation — 文件系统/持久化等基础能力(`Sources/RDReaderView/**`)。
|
|
||||||
|
|
||||||
## 依赖管理
|
|
||||||
|
|
||||||
**CocoaPods:**
|
|
||||||
- SDK 以 Podspec 形式发布:`RDReaderView.podspec`。
|
|
||||||
- 示例工程通过 CocoaPods 集成:`ReadViewDemo/Podfile`、`ReadViewDemo/Podfile.lock`。
|
|
||||||
- Lockfile 记录的 CocoaPods 版本:1.16.2(`ReadViewDemo/Podfile.lock`)。
|
|
||||||
|
|
||||||
**未检测到:**
|
|
||||||
- Swift Package Manager(未发现 `Package.swift`)
|
|
||||||
- Carthage(未发现 `Cartfile`)
|
|
||||||
|
|
||||||
## 框架与第三方库
|
|
||||||
|
|
||||||
**核心库(本仓库):**
|
|
||||||
- `RDReaderView`(阅读器 UI + EPUB/TXT 阅读能力)— `Sources/RDReaderView/**`。
|
|
||||||
|
|
||||||
**Pod 声明的第三方依赖:**
|
|
||||||
- `ZIPFoundation (~> 0.9)` — EPUB 压缩包读取/解压与解析。
|
|
||||||
- `DTCoreText` — HTML → NSAttributedString 渲染(在 `#if canImport(DTCoreText)` 条件下使用)。
|
|
||||||
- `SnapKit` — Auto Layout 约束封装。
|
|
||||||
- `SSAlertSwift` — 弹窗/提示 UI 工具。
|
|
||||||
|
|
||||||
## 构建与开发工具
|
|
||||||
|
|
||||||
**Xcode 工程(示例 App):**
|
|
||||||
- Workspace/Project:`ReadViewDemo/ReadViewDemo.xcworkspace`、`ReadViewDemo/ReadViewDemo.xcodeproj`。
|
|
||||||
|
|
||||||
**CocoaPods post_install 构建设置(仓库内配置):**
|
|
||||||
- 在 Pods 与用户工程上统一设置 `IPHONEOS_DEPLOYMENT_TARGET = 15.6` 和 `ENABLE_USER_SCRIPT_SANDBOXING = NO` — 见 `Podfile`、`ReadViewDemo/Podfile`。
|
|
||||||
|
|
||||||
## 资源与素材
|
|
||||||
|
|
||||||
**资源 bundle:**
|
|
||||||
- Podspec 声明了 `RDReaderViewAssets` 资源 bundle,来源为 `Sources/RDReaderView/Resources/**`(`RDReaderView.podspec`)。
|
|
||||||
|
|
||||||
## 平台要求
|
|
||||||
|
|
||||||
**开发:**
|
|
||||||
- 需要 macOS + Xcode(iOS SDK)构建 Demo workspace/project(`ReadViewDemo/ReadViewDemo.xcworkspace`)。
|
|
||||||
- 需要 CocoaPods 安装 Demo 依赖(`ReadViewDemo/Podfile.lock` 体现了 CocoaPods 使用)。
|
|
||||||
|
|
||||||
**发布/分发:**
|
|
||||||
- 以 CocoaPod 形式分发(`RDReaderView.podspec`)。
|
|
||||||
|
|
||||||
## Evidence(关键证据)
|
|
||||||
|
|
||||||
检查过的关键文件:
|
|
||||||
- `RDReaderView.podspec`
|
|
||||||
- `Podfile`
|
|
||||||
- `ReadViewDemo/Podfile`
|
|
||||||
- `ReadViewDemo/Podfile.lock`
|
|
||||||
- `ReadViewDemo/ReadViewDemo.xcodeproj/project.pbxproj`
|
|
||||||
- `Sources/RDReaderView/EPUBCore/RDEPUBWebView+Configuration.swift`
|
|
||||||
- `Sources/RDReaderView/EPUBCore/RDEPUBParser+Archive.swift`
|
|
||||||
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBDTCoreTextRenderer.swift`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*技术栈分析:2026-05-21*
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
# 代码库结构
|
|
||||||
|
|
||||||
**分析日期:** 2026-05-21
|
|
||||||
|
|
||||||
## 目录布局
|
|
||||||
|
|
||||||
```text
|
|
||||||
ReadViewSDK/
|
|
||||||
├── Sources/
|
|
||||||
│ └── RDReaderView/ # SDK 实现(Swift)
|
|
||||||
│ ├── EPUBCore/ # EPUB 解压/解析/模型/分页/状态(Foundation/WebKit)
|
|
||||||
│ ├── EPUBTextRendering/ # TXT/TextBook 构建 + 文本渲染/搜索
|
|
||||||
│ ├── EPUBUI/ # 阅读器控制器 UX、设置、持久化、工具视图
|
|
||||||
│ ├── LegacyRDReaderController/# 旧版阅读器控制器 + 工具视图
|
|
||||||
│ ├── Resources/ # 资源(pod resource bundle)
|
|
||||||
│ ├── RDReaderView.swift # 核心分页视图 + DS/delegate 协议
|
|
||||||
│ ├── RDReaderFlowLayout.swift # 滚动模式的 CollectionView 分页布局
|
|
||||||
│ └── RDURLReaderController.swift # 基于 URL 的入口控制器
|
|
||||||
├── ReadViewDemo/
|
|
||||||
│ ├── ReadViewDemo/ # Demo App 源码/资源(UIKit)
|
|
||||||
│ ├── ReadViewDemo.xcodeproj/ # Demo target 的 Xcode project
|
|
||||||
│ ├── ReadViewDemo.xcworkspace/ # 集成 Pods 工程的 workspace
|
|
||||||
│ ├── Podfile # Demo 的 Pods 集成(本地 path)
|
|
||||||
│ └── Podfile.lock # Demo 的锁定依赖版本
|
|
||||||
├── Pods/ # 仓库根目录的 CocoaPods 产物(本地开发)
|
|
||||||
├── Podfile # 仓库级 Pods 集成脚本(见说明)
|
|
||||||
├── RDReaderView.podspec # SDK 的 Podspec(分发与依赖声明)
|
|
||||||
├── Doc/ # 参考资料/分析产物
|
|
||||||
└── .planning/codebase/ # 生成的代码库地图(本目录)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 目录职责
|
|
||||||
|
|
||||||
**`Sources/RDReaderView/EPUBCore`:**
|
|
||||||
- 目的:EPUB 解压 + 解析 + 核心模型 + 分页 + 导航状态。
|
|
||||||
- 关键文件:
|
|
||||||
- `Sources/RDReaderView/EPUBCore/RDEPUBParser.swift`
|
|
||||||
- `Sources/RDReaderView/EPUBCore/RDEPUBPaginator.swift`
|
|
||||||
- `Sources/RDReaderView/EPUBCore/RDEPUBPublication.swift`
|
|
||||||
- `Sources/RDReaderView/EPUBCore/RDEPUBReadingSession.swift`
|
|
||||||
|
|
||||||
**`Sources/RDReaderView/EPUBUI`:**
|
|
||||||
- 目的:阅读器 UX 协调与对外 reader controller API。
|
|
||||||
- 关键文件:
|
|
||||||
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift`
|
|
||||||
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderConfiguration.swift`
|
|
||||||
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderPersistence.swift`
|
|
||||||
|
|
||||||
**`Sources/RDReaderView/EPUBTextRendering`:**
|
|
||||||
- 目的:从 `.txt` 构建 `RDEPUBTextBook`,并提供渲染/搜索等能力。
|
|
||||||
- 关键文件:
|
|
||||||
- `Sources/RDReaderView/EPUBTextRendering/RDPlainTextBookBuilder.swift`
|
|
||||||
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift`
|
|
||||||
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift`
|
|
||||||
|
|
||||||
**`Sources/RDReaderView`(顶层文件):**
|
|
||||||
- 目的:SDK 的对外入口与核心分页视图/布局基础设施。
|
|
||||||
- 关键文件:
|
|
||||||
- `Sources/RDReaderView/RDURLReaderController.swift`
|
|
||||||
- `Sources/RDReaderView/RDReaderView.swift`
|
|
||||||
- `Sources/RDReaderView/RDReaderFlowLayout.swift`
|
|
||||||
|
|
||||||
**`ReadViewDemo/ReadViewDemo`:**
|
|
||||||
- 目的:Demo App,用于发现内置书籍并打开 SDK。
|
|
||||||
- 关键文件:
|
|
||||||
- `ReadViewDemo/ReadViewDemo/ViewController.swift`
|
|
||||||
- `ReadViewDemo/ReadViewDemo/AppDelegate.swift`
|
|
||||||
- `ReadViewDemo/ReadViewDemo/SceneDelegate.swift`
|
|
||||||
|
|
||||||
## 关键文件位置
|
|
||||||
|
|
||||||
**SDK 入口点:**
|
|
||||||
- `Sources/RDReaderView/RDURLReaderController.swift`:URL 入口(epub/txt 路由)。
|
|
||||||
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift`:主阅读器控制器(EPUB + external TextBook)。
|
|
||||||
- `Sources/RDReaderView/RDReaderView.swift`:分页视图与 data source/delegate 协议。
|
|
||||||
|
|
||||||
**Demo 入口点:**
|
|
||||||
- `ReadViewDemo/ReadViewDemo/AppDelegate.swift`:App 生命周期入口(`@main`)。
|
|
||||||
- `ReadViewDemo/ReadViewDemo/SceneDelegate.swift`:Window 与 root navigation controller 配置。
|
|
||||||
- `ReadViewDemo/ReadViewDemo/ViewController.swift`:书籍列表 → 打开 reader。
|
|
||||||
|
|
||||||
**配置/打包:**
|
|
||||||
- `RDReaderView.podspec`:SDK 打包(源码 + 资源 + 依赖)。
|
|
||||||
- `ReadViewDemo/Podfile`:Demo 的 Pods 集成(`pod 'RDReaderView', :path => '..'`)。
|
|
||||||
- `ReadViewDemo/ReadViewDemo.xcworkspace/contents.xcworkspacedata`:workspace 结构。
|
|
||||||
|
|
||||||
## 命名约定
|
|
||||||
|
|
||||||
**模块(目录)划分:**
|
|
||||||
- `EPUBCore`、`EPUBUI`、`EPUBTextRendering`:按职责分层拆分于 `Sources/RDReaderView/` 下。
|
|
||||||
|
|
||||||
**类型前缀:**
|
|
||||||
- `RD*`:阅读器容器视图与 legacy controller/tooling(例如 `RDReaderView`、`RDURLReaderController`)。
|
|
||||||
- `RDEPUB*`:EPUB 解析/分页/阅读器 UI 域(例如 `RDEPUBParser`、`RDEPUBPaginator`、`RDEPUBReaderController` 及相关模型)。
|
|
||||||
|
|
||||||
## 组件关系(从入口到渲染)
|
|
||||||
|
|
||||||
- `RDURLReaderController` 根据文件类型选择实现:
|
|
||||||
- `.epub` → `RDEPUBReaderController(epubURL:configuration:persistence:)`
|
|
||||||
- `.txt` → `RDPlainTextBookBuilder` → `RDEPUBReaderController(textBook:...)`
|
|
||||||
- 回退路径:分页失败时使用 `UITextView` 展示原始文本
|
|
||||||
- 入口文件:`Sources/RDReaderView/RDURLReaderController.swift`
|
|
||||||
- `RDEPUBReaderController` 负责阅读器生命周期:
|
|
||||||
- parse(`RDEPUBParser`)→ publication(`RDEPUBPublication`)→ paginate(`RDEPUBPaginator`)→ display(`RDReaderView`)
|
|
||||||
- 通过 `RDEPUBReaderPersistence` 持久化设置/位置/书签/高亮等
|
|
||||||
- 入口文件:`Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift`
|
|
||||||
|
|
||||||
## 新代码应放在哪里
|
|
||||||
|
|
||||||
**新增面向读者的 UI 功能(工具视图/菜单/手势):**
|
|
||||||
- 主要:`Sources/RDReaderView/EPUBUI/`
|
|
||||||
- 若影响分页呈现:`Sources/RDReaderView/RDReaderView.swift` 或 `Sources/RDReaderView/RDReaderFlowLayout.swift`
|
|
||||||
|
|
||||||
**新增 EPUB 解析/模型支持:**
|
|
||||||
- 主要:`Sources/RDReaderView/EPUBCore/`(parser/models/resolver)
|
|
||||||
|
|
||||||
**新增纯文本导入/渲染行为:**
|
|
||||||
- 主要:`Sources/RDReaderView/EPUBTextRendering/`
|
|
||||||
|
|
||||||
**更新 Demo / 复现步骤:**
|
|
||||||
- 主要:`ReadViewDemo/ReadViewDemo/`
|
|
||||||
|
|
||||||
## 特殊目录说明
|
|
||||||
|
|
||||||
**`Pods/` 与 `ReadViewDemo/Pods/`:**
|
|
||||||
- 用途:CocoaPods 生成产物,服务本地开发/示例工程。
|
|
||||||
- 是否生成:是。
|
|
||||||
- 是否提交:当前工作区中存在(通常按生成目录对待)。
|
|
||||||
|
|
||||||
**`Doc/`:**
|
|
||||||
- 用途:文档/分析资料(不属于 SDK 运行时的一部分)。
|
|
||||||
|
|
||||||
## Evidence(关键证据)
|
|
||||||
|
|
||||||
检查过的关键文件:
|
|
||||||
- `RDReaderView.podspec`
|
|
||||||
- `Podfile`
|
|
||||||
- `ReadViewDemo/Podfile`
|
|
||||||
- `ReadViewDemo/ReadViewDemo.xcodeproj/project.pbxproj`
|
|
||||||
- `ReadViewDemo/ReadViewDemo.xcworkspace/contents.xcworkspacedata`
|
|
||||||
- `ReadViewDemo/ReadViewDemo/ViewController.swift`
|
|
||||||
- `Sources/RDReaderView/RDURLReaderController.swift`
|
|
||||||
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift`
|
|
||||||
- `Sources/RDReaderView/EPUBCore/RDEPUBParser.swift`
|
|
||||||
- `Sources/RDReaderView/EPUBCore/RDEPUBPaginator.swift`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*结构分析:2026-05-21*
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
# 测试说明
|
|
||||||
|
|
||||||
**分析日期:** 2026-05-21
|
|
||||||
|
|
||||||
## 测试框架与现状
|
|
||||||
|
|
||||||
**Runner:**
|
|
||||||
- XCTest(iOS/Xcode 默认测试框架)
|
|
||||||
- **当前仓库状态**:未检测到任何 XCTest 测试文件或测试 Target(未发现 `import XCTest`/`XCTestCase`,且示例工程 `ReadViewDemo` 的 `project.pbxproj` 仅声明应用 Target)。
|
|
||||||
|
|
||||||
**断言库:**
|
|
||||||
- XCTest 内建断言(当前仓库未见使用案例)
|
|
||||||
|
|
||||||
## 测试文件组织
|
|
||||||
|
|
||||||
**位置:**
|
|
||||||
- 未检测到 `*Tests*` 目录或 `*.test.*` / `*.spec.*` 文件(排除 `ReadViewDemo/Pods/**` 第三方依赖源码)。
|
|
||||||
|
|
||||||
**命名:**
|
|
||||||
- 未检测到(无测试用例)
|
|
||||||
|
|
||||||
## 如何运行(在当前仓库结构下)
|
|
||||||
|
|
||||||
### CocoaPods 依赖准备
|
|
||||||
|
|
||||||
> 仓库包含示例工程 `ReadViewDemo`,并已提交 `Pods/` 与 `Podfile.lock`。如本地环境未同步,可在仓库根或示例工程目录运行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pod install
|
|
||||||
```
|
|
||||||
|
|
||||||
(依赖入口:`Podfile`、`ReadViewDemo/Podfile`)
|
|
||||||
|
|
||||||
### 运行/构建示例工程
|
|
||||||
|
|
||||||
- Xcode 打开:`ReadViewDemo/ReadViewDemo.xcworkspace`
|
|
||||||
- Scheme/Target 名称从工程文件可见为 `ReadViewDemo`(见 `ReadViewDemo/ReadViewDemo.xcodeproj/project.pbxproj`)
|
|
||||||
|
|
||||||
### 运行测试
|
|
||||||
|
|
||||||
**当前无测试可运行。** 若未来添加 `ReadViewDemoTests`(或为 SDK 增加独立测试工程/SwiftPM 包),可用 Xcode 或 `xcodebuild test` 运行。
|
|
||||||
|
|
||||||
示例(需要存在测试 Scheme/Target 后才有效):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
xcodebuild test \
|
|
||||||
-workspace ReadViewDemo/ReadViewDemo.xcworkspace \
|
|
||||||
-scheme ReadViewDemo \
|
|
||||||
-destination 'platform=iOS Simulator,name=iPhone 15'
|
|
||||||
```
|
|
||||||
|
|
||||||
> 注:本环境中执行 `xcodebuild -list` 发生过 CoreSimulator/日志权限相关错误,属于运行环境限制;测试命令以项目结构为依据给出,实际执行以本机 Xcode/Simulator 可用性为准。
|
|
||||||
|
|
||||||
## 覆盖率(Coverage)
|
|
||||||
|
|
||||||
- 未检测到覆盖率配置或现有覆盖率报告产物(例如 `.xcresult` 固化路径或 CI 输出)。
|
|
||||||
- 若新增 XCTest Target,可在 Xcode Scheme 的 Test 设置中启用 Coverage,或通过 `xcodebuild test` 生成 `.xcresult` 后用 Xcode 查看。
|
|
||||||
|
|
||||||
## Mocking / Fixtures
|
|
||||||
|
|
||||||
- 未检测到统一 mocking 框架或 fixtures 目录(无测试用例)。
|
|
||||||
|
|
||||||
## Evidence(关键证据文件)
|
|
||||||
|
|
||||||
- `ReadViewDemo/ReadViewDemo.xcodeproj/project.pbxproj`
|
|
||||||
- `Podfile`
|
|
||||||
- `ReadViewDemo/Podfile`
|
|
||||||
- `ReadViewDemo/Podfile.lock`
|
|
||||||
- `RDReaderView.podspec`
|
|
||||||
- `ReadViewDemo/ReadViewDemo/ViewController.swift`
|
|
||||||
- `Sources/RDReaderView/EPUBCore/RDEPUBParser.swift`
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
{
|
|
||||||
"model_profile": "balanced",
|
|
||||||
"commit_docs": true,
|
|
||||||
"parallelization": true,
|
|
||||||
"search_gitignored": false,
|
|
||||||
"brave_search": false,
|
|
||||||
"firecrawl": false,
|
|
||||||
"exa_search": false,
|
|
||||||
"git": {
|
|
||||||
"branching_strategy": "none",
|
|
||||||
"phase_branch_template": "gsd/phase-{phase}-{slug}",
|
|
||||||
"milestone_branch_template": "gsd/{milestone}-{slug}",
|
|
||||||
"quick_branch_template": null
|
|
||||||
},
|
|
||||||
"workflow": {
|
|
||||||
"research": true,
|
|
||||||
"plan_check": true,
|
|
||||||
"verifier": true,
|
|
||||||
"nyquist_validation": true,
|
|
||||||
"auto_advance": true,
|
|
||||||
"node_repair": true,
|
|
||||||
"node_repair_budget": 2,
|
|
||||||
"ui_phase": true,
|
|
||||||
"ui_safety_gate": true,
|
|
||||||
"text_mode": false,
|
|
||||||
"research_before_questions": false,
|
|
||||||
"discuss_mode": "discuss",
|
|
||||||
"skip_discuss": false,
|
|
||||||
"code_review": true,
|
|
||||||
"code_review_depth": "standard"
|
|
||||||
},
|
|
||||||
"ship": {
|
|
||||||
"pr_body_sections": [
|
|
||||||
{
|
|
||||||
"heading": "User Stories & Acceptance Criteria",
|
|
||||||
"enabled": true,
|
|
||||||
"source": "REQUIREMENTS.md ## User Stories || REQUIREMENTS.md ## Acceptance Criteria",
|
|
||||||
"fallback": "- 验收标准以关联的 REQUIREMENTS 与验证证据为准。"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"heading": "Risks & Dependencies",
|
|
||||||
"enabled": true,
|
|
||||||
"source": "PLAN.md ## Risks || PLAN.md ## Dependencies",
|
|
||||||
"fallback": "- 当前无已知高风险依赖。"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"heading": "Success Metrics & Release Criteria",
|
|
||||||
"enabled": true,
|
|
||||||
"source": "REQUIREMENTS.md ## Definition of Done || VERIFICATION.md ## Release Criteria",
|
|
||||||
"fallback": "- 当自动化验证与必要的手工检查通过后发布。"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"heading": "Stakeholder Review & Approval",
|
|
||||||
"enabled": false,
|
|
||||||
"template": "- 需要 {phase_name} 的产品/负责人审批。"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hooks": {
|
|
||||||
"context_warnings": true
|
|
||||||
},
|
|
||||||
"project_code": null,
|
|
||||||
"phase_naming": "sequential",
|
|
||||||
"agent_skills": {},
|
|
||||||
"features": {},
|
|
||||||
"resolve_model_ids": "omit",
|
|
||||||
"mode": "yolo",
|
|
||||||
"granularity": "coarse"
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
# 需求归档:ReadViewSDK v1.0
|
|
||||||
|
|
||||||
**归档日期:** 2026-05-22
|
|
||||||
**核心价值:** 稳定可用的 EPUB/TXT 阅读体验
|
|
||||||
|
|
||||||
## 结果说明
|
|
||||||
|
|
||||||
本归档按 milestone close 生成。v1.0 的实现与运行时证据整体达成,但由于 close-out 前未完全回写 requirement bookkeeping,以下需求状态分为:
|
|
||||||
|
|
||||||
- `Validated`:实现、phase verification、summary metadata 三者一致
|
|
||||||
- `Delivered with accepted gaps`:实现和运行时证据存在,但 requirement bookkeeping 未完全对齐,作为已接受的归档缺口保留
|
|
||||||
|
|
||||||
## v1 Requirements
|
|
||||||
|
|
||||||
### 渲染内核重构(Reflowable EPUB)
|
|
||||||
|
|
||||||
- [x] **REND-01**:直接重构当前 `.textReflowable` 路径,使 reflowable EPUB 不再依赖当前“简单 `DTCoreText` renderer + `pageRanges` 切页”模式,而是具备 WXRead 风格的页面级原生排版能力
|
|
||||||
- [x] **REND-02**:在旧引擎基础上引入 WXRead 风格的 CSS 分层策略(`default / replace / dark / epub / user`),并让样式作用于章节级 HTML → `NSAttributedString` 转换过程
|
|
||||||
- [x] **REND-03**:在旧引擎基础上引入自定义 DTCoreText 属性体系与页面级 `NSAttributedString` 元数据,能够承载分页、块元素、图片、页面语义等布局信息
|
|
||||||
- [x] **REND-04**:将当前简单的 CoreText 切页逻辑升级为具备页面语义的复杂分页器,具备与 `WRCoreTextLayouter / WRCoreTextLayoutFrame` 核心能力等价的分页与页面布局控制能力
|
|
||||||
|
|
||||||
### 兼容性与主流程
|
|
||||||
|
|
||||||
- [x] **COMP-01**:`RDURLReaderController` / `RDEPUBReaderController` 作为公开入口继续可用,`.epub` / `.txt` 打开主流程不回归
|
|
||||||
- [x] **COMP-02**:Fixed Layout EPUB 与交互式 EPUB 继续使用 `WKWebView`,行为不因本次改造产生回归
|
|
||||||
- [x] **COMP-03**:reflowable EPUB 的阅读位置映射、高亮/选区、搜索结果定位、字号/行高/主题切换后的重新分页在新内核下继续可用
|
|
||||||
- [x] **COMP-04**:当前翻页代码(`RDReaderView` 及现有 page curl / horizontal scroll / vertical scroll 交互逻辑)不做修改,新内核必须适配既有翻页容器
|
|
||||||
|
|
||||||
### 稳定性与验证
|
|
||||||
|
|
||||||
- [x] **STAB-01**:至少 3 类样本书验证通过:纯文本/小说类、含图片与复杂段落样式的章节、含外链与多个 CSS 文件引用的章节
|
|
||||||
- [x] **STAB-02**:以上样本在新内核下不出现崩溃、白屏、无限加载、严重错页或关键交互链路失效
|
|
||||||
|
|
||||||
## Requirement Outcomes
|
|
||||||
|
|
||||||
| Requirement | Archived Outcome | Notes |
|
|
||||||
|-------------|------------------|-------|
|
|
||||||
| REND-01 | Delivered with accepted gaps | Phase 2 established renderer/context path; audit still flagged bookkeeping incompleteness. |
|
|
||||||
| REND-02 | Delivered with accepted gaps | Phase 2 verification marked it satisfied; archive accepts missing checkbox sync. |
|
|
||||||
| REND-03 | Delivered with accepted gaps | Phase 3 implemented page metadata; requirement-level verification mapping was missing. |
|
|
||||||
| REND-04 | Delivered with accepted gaps | Phase 3 delivered stronger pagination semantics; requirement bookkeeping remained orphaned. |
|
|
||||||
| COMP-01 | Delivered with accepted gaps | Phase 4 restored reader main flow, but requirement mapping was not backfilled. |
|
|
||||||
| COMP-02 | Validated | Phase 1 fully documented and verified the `WKWebView` retention boundary. |
|
|
||||||
| COMP-03 | Delivered with accepted gaps | Phase 4 runtime evidence supports compatibility, though close-out evidence chain was incomplete. |
|
|
||||||
| COMP-04 | Delivered with accepted gaps | `RDReaderView` remained untouched across the milestone, but requirement metadata was not reconciled. |
|
|
||||||
| STAB-01 | Delivered with accepted gaps | Phase 5 5-book matrix covers required categories. |
|
|
||||||
| STAB-02 | Delivered with accepted gaps | Phase 5 runtime/UI evidence shows no sampled crash/blank-page regression. |
|
|
||||||
|
|
||||||
## Out of Scope
|
|
||||||
|
|
||||||
| 功能 | 原因 |
|
|
||||||
|---|---|
|
|
||||||
| Fixed Layout EPUB 改为原生渲染 | 本次明确保留 `WKWebView` 路径以控制风险 |
|
|
||||||
| 交互式 EPUB 改为原生渲染 | 交互能力(JS/音视频/表单/iframe/外链/bridge)更适配 `WKWebView`,本次不改 |
|
|
||||||
| 修改当前翻页代码(`RDReaderView` / 现有翻页模式与交互逻辑) | 本次只重构 reflowable 渲染内核,不把翻页容器一起纳入改造 |
|
|
||||||
| 直接拷贝使用微信读书私有 JS/CSS/私有实现代码 | 只能参考设计与行为,不直接搬运私有实现 |
|
|
||||||
| 同时保留两套 reflowable 原生引擎 | 本次要求直接演进旧引擎,不维护双轨 |
|
|
||||||
|
|
||||||
## Traceability
|
|
||||||
|
|
||||||
| Requirement | Phase | Final Status |
|
|
||||||
|-------------|-------|--------------|
|
|
||||||
| REND-01 | Phase 2 | Delivered with accepted gaps |
|
|
||||||
| REND-02 | Phase 2 | Delivered with accepted gaps |
|
|
||||||
| REND-03 | Phase 3 | Delivered with accepted gaps |
|
|
||||||
| REND-04 | Phase 3 | Delivered with accepted gaps |
|
|
||||||
| COMP-01 | Phase 4 | Delivered with accepted gaps |
|
|
||||||
| COMP-02 | Phase 1 | Validated |
|
|
||||||
| COMP-03 | Phase 4 | Delivered with accepted gaps |
|
|
||||||
| COMP-04 | Phase 4 | Delivered with accepted gaps |
|
|
||||||
| STAB-01 | Phase 5 | Delivered with accepted gaps |
|
|
||||||
| STAB-02 | Phase 5 | Delivered with accepted gaps |
|
|
||||||
|
|
||||||
**Coverage:**
|
|
||||||
- v1 requirements: 10 total
|
|
||||||
- Archived as shipped: 10
|
|
||||||
- Strict audit passed without gaps: 1
|
|
||||||
- Archived with accepted evidence gaps: 9
|
|
||||||
|
|
||||||
---
|
|
||||||
*Requirements defined: 2026-05-21*
|
|
||||||
*Archived: 2026-05-22 after milestone close with accepted audit gaps*
|
|
||||||
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
# Milestone v1.0: WXRead Native Reflowable Foundation
|
|
||||||
|
|
||||||
**Status:** ✅ SHIPPED 2026-05-22
|
|
||||||
**Phases:** 1-5
|
|
||||||
**Total Plans:** 13
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
v1.0 completed the direct evolution of the existing reflowable EPUB native path instead of introducing a parallel engine. The milestone established chapter-level CSS preprocessing, richer page metadata, a stronger paginator, reader-shell reintegration, and sample-corpus regression evidence across native reflowable, fixed/interactive WebKit, and TXT flows.
|
|
||||||
|
|
||||||
The milestone was archived with accepted audit gaps: the runtime and source evidence indicate the implementation is broadly complete, but requirement bookkeeping in `REQUIREMENTS.md`, `VERIFICATION.md`, and `SUMMARY.md` was not fully reconciled before close-out.
|
|
||||||
|
|
||||||
## Phases
|
|
||||||
|
|
||||||
### Phase 1: 对齐现状、边界与重构切入点
|
|
||||||
|
|
||||||
**Goal**: 把“当前旧引擎是什么、哪些能力必须保留、哪些路径绝对不能动”说清楚,形成直接重构旧引擎的实施基线。
|
|
||||||
**Depends on**: Nothing (first phase)
|
|
||||||
**Plans**: 2 plans
|
|
||||||
|
|
||||||
Plans:
|
|
||||||
|
|
||||||
- [x] 01-01:审计当前 `.textReflowable` 路径(`RDEPUBDTCoreTextRenderer` / `RDEPUBTextBookBuilder` / `RDEPUBTextPaginationSupport` / `RDEPUBTextContentView`)
|
|
||||||
- [x] 01-02:结合 `Doc/WXRead/analysis/*` 提炼旧引擎可直接演进的切入点与必须保留的兼容链路
|
|
||||||
|
|
||||||
**Details:**
|
|
||||||
|
|
||||||
- 固定了 native `.textReflowable` 的真实调用链和 `WKWebView` 分流边界。
|
|
||||||
- 把 `RDReaderView` 明确记录为稳定容器,不纳入这轮重构。
|
|
||||||
- 产出了后续 Phase 2-4 的实施顺序和硬边界。
|
|
||||||
|
|
||||||
### Phase 2: 重构 typesetter 与 CSS 分层
|
|
||||||
|
|
||||||
**Goal**: 在现有旧引擎基础上引入 WXRead 风格的 CSS 分层与章节级 HTML → attributed string 增强,让 renderer 输入具备更完整的排版语义。
|
|
||||||
**Depends on**: Phase 1
|
|
||||||
**Plans**: 3 plans
|
|
||||||
|
|
||||||
Plans:
|
|
||||||
|
|
||||||
- [x] 02-01:设计并实现旧引擎中的 WXRead 风格 stylesheet builder / HTML 预处理增强
|
|
||||||
- [x] 02-02:改造 `RDEPUBDTCoreTextRenderer` 与相邻渲染链路,使其承接新的样式分层与章节上下文
|
|
||||||
- [x] 02-03:验证章节级图片/CSS/基础资源在新渲染输入下可正常解析
|
|
||||||
|
|
||||||
**Details:**
|
|
||||||
|
|
||||||
- 建立了 `default / replace / dark / epub / user` 五层 CSS 顺序。
|
|
||||||
- 把 chapter preprocessing、baseURL、linked CSS 内联、资源标准化接入 native renderer。
|
|
||||||
- 用 demo 样本书验证了 reflowable 章节资源解析。
|
|
||||||
|
|
||||||
### Phase 3: 重构属性体系与复杂分页器
|
|
||||||
|
|
||||||
**Goal**: 在旧引擎路径中引入页面级 attributed string 元数据与更复杂的分页/页面布局能力,替代当前简单 `pageRanges` 切页模式。
|
|
||||||
**Depends on**: Phase 2
|
|
||||||
**Plans**: 3 plans
|
|
||||||
|
|
||||||
Plans:
|
|
||||||
|
|
||||||
- [x] 03-01:定义并实现页面级 attributed string 元数据与自定义属性键
|
|
||||||
- [x] 03-02:在旧引擎基础上重构分页器,使其具备接近 `WRCoreTextLayouter / WRCoreTextLayoutFrame` 的核心能力
|
|
||||||
- [x] 03-03:验证复杂块元素、图片与分页边界控制在新分页器下可工作
|
|
||||||
|
|
||||||
**Details:**
|
|
||||||
|
|
||||||
- 引入了页面元数据、attachment/block break reasons、layout frame 语义。
|
|
||||||
- 保持 `pageStartOffset`、`pageEndOffset`、`fragmentOffsets`、`RDEPUBTextOffsetRangeInfo` 兼容不变。
|
|
||||||
- 在真实 EPUB 上输出分页诊断,证明新分页语义已生效。
|
|
||||||
|
|
||||||
### Phase 4: 接回现有 reader 能力链路
|
|
||||||
|
|
||||||
**Goal**: 让新内核在不新增并行原生引擎、且不修改当前翻页代码的前提下,继续服务现有 reader UI、阅读位置、高亮、搜索与主题切换能力。
|
|
||||||
**Depends on**: Phase 3
|
|
||||||
**Plans**: 3 plans
|
|
||||||
|
|
||||||
Plans:
|
|
||||||
|
|
||||||
- [x] 04-01:将新内核接回 `RDEPUBTextBookBuilder` / `RDEPUBTextContentView` / `RDEPUBReaderController`,不修改 `RDReaderView`
|
|
||||||
- [x] 04-02:修复并验证阅读位置映射、高亮、搜索等兼容能力
|
|
||||||
- [x] 04-03:验证字体、行高、主题切换后的重新分页与状态恢复
|
|
||||||
|
|
||||||
**Details:**
|
|
||||||
|
|
||||||
- native text backend 被接回现有 reader shell,`RDReaderView` 保持不变。
|
|
||||||
- 位置恢复、高亮、搜索、TOC、重分页行为围绕 absolute offset 契约继续工作。
|
|
||||||
- demo 输出恢复诊断,证明 semantic location 在主题/字号变化后仍可恢复。
|
|
||||||
|
|
||||||
### Phase 5: 回归验证与稳定性收敛
|
|
||||||
|
|
||||||
**Goal**: 围绕样本书和主流程做回归,收敛分页正确性、位置映射稳定性与关键阅读交互问题。
|
|
||||||
**Depends on**: Phase 4
|
|
||||||
**Plans**: 2 plans
|
|
||||||
|
|
||||||
Plans:
|
|
||||||
|
|
||||||
- [x] 05-01:构建样本书验证矩阵与诊断手段(日志/断言/复现清单)
|
|
||||||
- [x] 05-02:收敛分页、位置映射、图片/块元素分页与主题切换后的稳定性问题
|
|
||||||
|
|
||||||
**Details:**
|
|
||||||
|
|
||||||
- demo 启动摘要扩展为 5 本样本书的矩阵验证。
|
|
||||||
- 覆盖 native reflowable、fixed/interactive WebKit、TXT 三条主路径。
|
|
||||||
- 运行时证据确认 `样本验证:5/5 通过`,并保留分页/恢复诊断。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Milestone Summary
|
|
||||||
|
|
||||||
**Decimal Phases:**
|
|
||||||
|
|
||||||
- None
|
|
||||||
|
|
||||||
**Key Decisions:**
|
|
||||||
|
|
||||||
- 选择直接演进现有 `.textReflowable` 原生引擎,而不是引入并行原生引擎。
|
|
||||||
- Fixed Layout 与交互式 EPUB 继续保留 `WKWebView` 路径。
|
|
||||||
- `RDReaderView` 作为稳定分页容器不纳入本轮改造范围。
|
|
||||||
- 兼容链路继续围绕 offset-based location/highlight/search/restore 语义维持。
|
|
||||||
|
|
||||||
**Issues Resolved:**
|
|
||||||
|
|
||||||
- chapter-level CSS 分层、preprocessing 和资源标准化接入 native renderer。
|
|
||||||
- 分页器从纯 visible-range slicing 升级为带 block/attachment-aware 语义的 layouter path。
|
|
||||||
- native reader shell 与位置恢复、高亮、搜索、主题/字号切换重新打通。
|
|
||||||
- demo 建立了覆盖 reflowable / WebKit / TXT 的统一样本矩阵。
|
|
||||||
|
|
||||||
**Issues Deferred:**
|
|
||||||
|
|
||||||
- requirement bookkeeping 未在 milestone close 前完全回写,详见 `v1.0-MILESTONE-AUDIT.md`。
|
|
||||||
- 尚无完整 UI 自动化覆盖所有样本和关键交互。
|
|
||||||
- Nyquist validation 文档仍存在 draft / partial 状态。
|
|
||||||
|
|
||||||
**Technical Debt Incurred:**
|
|
||||||
|
|
||||||
- `REQUIREMENTS.md`、`VERIFICATION.md`、`SUMMARY.md` 的 requirement evidence chain 需要后续补齐。
|
|
||||||
- simulator 运行仍会出现既有 `.SFUI-Semibold` CoreText substitution note。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
_For current project status, see `.planning/ROADMAP.md`_
|
|
||||||
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
---
|
|
||||||
phase: 6
|
|
||||||
plan: 06-01
|
|
||||||
type: execute
|
|
||||||
wave: 1
|
|
||||||
depends_on: []
|
|
||||||
files_modified:
|
|
||||||
- Sources/RDReaderView/EPUBTextRendering/RDEPUBTextLayoutFrame.swift
|
|
||||||
- Sources/RDReaderView/EPUBTextRendering/RDEPUBTextLayouter.swift
|
|
||||||
- Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPaginationSupport.swift
|
|
||||||
- Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift
|
|
||||||
- Sources/RDReaderView/EPUBCore/RDEPUBReadingModels.swift
|
|
||||||
autonomous: true
|
|
||||||
requirements:
|
|
||||||
- LAYOUT-01
|
|
||||||
- LAYOUT-03
|
|
||||||
---
|
|
||||||
|
|
||||||
# Phase 6-01: Page Geometry Model
|
|
||||||
|
|
||||||
<objective>
|
|
||||||
Add a reusable page geometry layer on top of native text pagination so callers can query page-relative rectangles and map rectangles back to text ranges without breaking offset continuity.
|
|
||||||
</objective>
|
|
||||||
|
|
||||||
<must_haves>
|
|
||||||
- `RDEPUBTextLayoutFrame` can expose page geometry derived from CoreText, not guessed from offsets.
|
|
||||||
- `pageStartOffset`, `pageEndOffset`, `fragmentOffsets`, and `RDEPUBTextOffsetRangeInfo` semantics remain stable.
|
|
||||||
- The geometry API is reusable by selection, search, highlight, and tap-locate work in later plans.
|
|
||||||
</must_haves>
|
|
||||||
|
|
||||||
<tasks>
|
|
||||||
<task id="06-01-01">
|
|
||||||
<type>execute</type>
|
|
||||||
<action>Extend `RDEPUBTextLayoutFrame` and the page model with explicit geometry value types, including a page-level geometry container and line or fragment geometry records. Populate them from `RDEPUBTextLayouter` using CoreText line data so the page can answer range-to-rect and rect-to-range queries without changing pagination break rules.</action>
|
|
||||||
<read_first>
|
|
||||||
- Sources/RDReaderView/EPUBTextRendering/RDEPUBTextLayoutFrame.swift
|
|
||||||
- Sources/RDReaderView/EPUBTextRendering/RDEPUBTextLayouter.swift
|
|
||||||
- Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPaginationSupport.swift
|
|
||||||
- Sources/RDReaderView/EPUBCore/RDEPUBReadingModels.swift
|
|
||||||
</read_first>
|
|
||||||
<acceptance_criteria>
|
|
||||||
- `RDEPUBTextLayoutFrame` or its companion geometry type exposes a query path for at least string-range rectangles and reverse lookup from rects to `NSRange`.
|
|
||||||
- Existing metadata fields (`contentRange`, `breakReason`, `blockRange`, `attachmentRanges`, `attachmentKinds`, `trailingFragmentID`, `diagnostics`) remain present and unchanged in meaning.
|
|
||||||
- `RDEPUBTextLayouter` still emits the same page boundaries for the sample books after the geometry data is added.
|
|
||||||
</acceptance_criteria>
|
|
||||||
</task>
|
|
||||||
|
|
||||||
<task id="06-01-02">
|
|
||||||
<type>execute</type>
|
|
||||||
<action>Thread the new geometry data through `RDEPUBTextBookBuilder` and `RDEPUBTextBook` so each page retains its geometry alongside `pageStartOffset`, `pageEndOffset`, and `fragmentOffsets`. Keep `pageNumber(for:)` and `location(forPageNumber:)` compatible with the existing absolute-offset contract.</action>
|
|
||||||
<read_first>
|
|
||||||
- Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift
|
|
||||||
- Sources/RDReaderView/EPUBCore/RDEPUBReadingModels.swift
|
|
||||||
- Sources/RDReaderView/EPUBTextRendering/RDEPUBTextLayouter.swift
|
|
||||||
- Sources/RDReaderView/LegacyRDReaderController/RDEPUBTextPaging.swift
|
|
||||||
</read_first>
|
|
||||||
<acceptance_criteria>
|
|
||||||
- `RDEPUBTextBook` pages still map absolute offsets to pages through `pageStartOffset` / `pageEndOffset`.
|
|
||||||
- `RDEPUBTextBook.location(forPageNumber:)` still returns a stable `RDEPUBLocation` for native text pages.
|
|
||||||
- The sample text book builds successfully with the new geometry fields attached to each page.
|
|
||||||
</acceptance_criteria>
|
|
||||||
</task>
|
|
||||||
</tasks>
|
|
||||||
|
|
||||||
<verification>
|
|
||||||
- Build the `ReadViewDemo` workspace with the `ReadViewDemo` scheme.
|
|
||||||
- Confirm the new page geometry API compiles and the sample book still paginates.
|
|
||||||
- Confirm no existing offset-based selection or restore code needs to change to keep building.
|
|
||||||
</verification>
|
|
||||||
|
|
||||||
<success_criteria>
|
|
||||||
- Native text pages expose reusable geometry data.
|
|
||||||
- The offset-based page contract remains intact.
|
|
||||||
- Later selection overlay work can consume the geometry layer without re-deriving page boundaries.
|
|
||||||
</success_criteria>
|
|
||||||
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
---
|
|
||||||
phase: 6
|
|
||||||
plan: 06-02
|
|
||||||
type: execute
|
|
||||||
wave: 2
|
|
||||||
depends_on:
|
|
||||||
- 06-01
|
|
||||||
files_modified:
|
|
||||||
- Sources/RDReaderView/EPUBUI/RDEPUBTextContentView.swift
|
|
||||||
- Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift
|
|
||||||
- Sources/RDReaderView/EPUBUI/RDEPUBSelectionOverlayView.swift
|
|
||||||
- Sources/RDReaderView/EPUBCore/RDEPUBReadingModels.swift
|
|
||||||
autonomous: true
|
|
||||||
requirements:
|
|
||||||
- LAYOUT-02
|
|
||||||
- LAYOUT-03
|
|
||||||
---
|
|
||||||
|
|
||||||
# Phase 6-02: Selection Overlay and Hit Flow
|
|
||||||
|
|
||||||
<objective>
|
|
||||||
Move native text selection presentation onto a custom overlay backed by page geometry while keeping the controller-owned selection state, persistence, and absolute offset payload unchanged.
|
|
||||||
</objective>
|
|
||||||
|
|
||||||
<must_haves>
|
|
||||||
- Selection rendering is driven by overlay geometry, not by the default `UITextView` selection chrome.
|
|
||||||
- `RDEPUBReaderController` remains the single owner of current selection, persistence, and menu actions.
|
|
||||||
- Selections still round-trip through `RDEPUBTextOffsetRangeInfo` and preserve absolute offsets.
|
|
||||||
</must_haves>
|
|
||||||
|
|
||||||
<tasks>
|
|
||||||
<task id="06-02-01">
|
|
||||||
<type>execute</type>
|
|
||||||
<action>Add `RDEPUBSelectionOverlayView` and embed it in `RDEPUBTextContentView` as the visual selection surface. Use the page geometry from 06-01 to draw the selected range and page-local hit regions, while leaving `UITextView` as the text host and input source.</action>
|
|
||||||
<read_first>
|
|
||||||
- Sources/RDReaderView/EPUBUI/RDEPUBTextContentView.swift
|
|
||||||
- Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift
|
|
||||||
- Sources/RDReaderView/EPUBCore/RDEPUBReadingModels.swift
|
|
||||||
- Sources/RDReaderView/EPUBTextRendering/RDEPUBTextLayoutFrame.swift
|
|
||||||
</read_first>
|
|
||||||
<acceptance_criteria>
|
|
||||||
- A dedicated overlay view or layer exists for native selection presentation.
|
|
||||||
- Overlay updates when a new selection is made, when the page is reconfigured, and when selection is cleared.
|
|
||||||
- The visible selection treatment no longer depends on `UITextView`'s default selection chrome as the primary presentation path.
|
|
||||||
</acceptance_criteria>
|
|
||||||
</task>
|
|
||||||
|
|
||||||
<task id="06-02-02">
|
|
||||||
<type>execute</type>
|
|
||||||
<action>Update the `RDEPUBTextContentView` delegate flow so selection changes still produce `RDEPUBSelection` objects with absolute offsets, `RDEPUBReaderController` still normalizes and persists them, and copy/highlight/annotate menu actions continue to work through the same controller-owned selection state.</action>
|
|
||||||
<read_first>
|
|
||||||
- Sources/RDReaderView/EPUBUI/RDEPUBTextContentView.swift
|
|
||||||
- Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift
|
|
||||||
- Sources/RDReaderView/EPUBCore/RDEPUBReadingModels.swift
|
|
||||||
- Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift
|
|
||||||
</read_first>
|
|
||||||
<acceptance_criteria>
|
|
||||||
- `currentSelection` still updates when the user changes the selection.
|
|
||||||
- Selection menu actions still route through the controller and continue to create highlights or annotations.
|
|
||||||
- Clearing the selection clears both the overlay state and the controller's current selection.
|
|
||||||
</acceptance_criteria>
|
|
||||||
</task>
|
|
||||||
</tasks>
|
|
||||||
|
|
||||||
<verification>
|
|
||||||
- Build and run `ReadViewDemo` on the simulator.
|
|
||||||
- Select text in a native reflowable sample and confirm the overlay is the visible selection surface.
|
|
||||||
- Trigger copy/highlight/annotate actions and confirm the controller still receives a valid selection payload.
|
|
||||||
</verification>
|
|
||||||
|
|
||||||
<success_criteria>
|
|
||||||
- Selection presentation is overlay-backed.
|
|
||||||
- Selection state still round-trips through absolute offsets.
|
|
||||||
- Reader interaction behavior remains stable for native text pages.
|
|
||||||
</success_criteria>
|
|
||||||
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
---
|
|
||||||
phase: 6
|
|
||||||
plan: 06-03
|
|
||||||
type: execute
|
|
||||||
wave: 3
|
|
||||||
depends_on:
|
|
||||||
- 06-01
|
|
||||||
- 06-02
|
|
||||||
files_modified:
|
|
||||||
- ReadViewDemo/ReadViewDemo/ViewController.swift
|
|
||||||
- Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift
|
|
||||||
- Sources/RDReaderView/EPUBUI/RDEPUBTextContentView.swift
|
|
||||||
- Sources/RDReaderView/EPUBCore/RDEPUBReadingModels.swift
|
|
||||||
autonomous: true
|
|
||||||
requirements:
|
|
||||||
- LAYOUT-01
|
|
||||||
- LAYOUT-02
|
|
||||||
- LAYOUT-03
|
|
||||||
---
|
|
||||||
|
|
||||||
# Phase 6-03: Geometry Verification and Diagnostics
|
|
||||||
|
|
||||||
<objective>
|
|
||||||
Expose concrete demo diagnostics for the new geometry and selection overlay path, then verify that page geometry, restore, search, and selection remain compatible on real sample books.
|
|
||||||
</objective>
|
|
||||||
|
|
||||||
<must_haves>
|
|
||||||
- The demo can report geometry and selection evidence for a native reflowable page.
|
|
||||||
- Offset-based restore and page lookup continue to resolve the same content after geometry and overlay changes.
|
|
||||||
- Any geometry regression is visible in simulator logs or the demo status text.
|
|
||||||
</must_haves>
|
|
||||||
|
|
||||||
<tasks>
|
|
||||||
<task id="06-03-01">
|
|
||||||
<type>execute</type>
|
|
||||||
<action>Add a geometry diagnostic hook to `RDEPUBReaderController` or `RDEPUBTextContentView` that reports the active page index, the selected absolute offset range, and the geometry summary for the current native page. Keep the diagnostic format deterministic so the demo can surface it in startup logs.</action>
|
|
||||||
<read_first>
|
|
||||||
- Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift
|
|
||||||
- Sources/RDReaderView/EPUBUI/RDEPUBTextContentView.swift
|
|
||||||
- Sources/RDReaderView/EPUBCore/RDEPUBReadingModels.swift
|
|
||||||
- Sources/RDReaderView/EPUBTextRendering/RDEPUBTextLayoutFrame.swift
|
|
||||||
</read_first>
|
|
||||||
<acceptance_criteria>
|
|
||||||
- A runtime call can produce a geometry summary string for the current native page.
|
|
||||||
- The summary includes the selected absolute range or an explicit no-selection state.
|
|
||||||
- The summary is deterministic enough to compare across rebuilds on the same sample book.
|
|
||||||
</acceptance_criteria>
|
|
||||||
</task>
|
|
||||||
|
|
||||||
<task id="06-03-02">
|
|
||||||
<type>execute</type>
|
|
||||||
<action>Extend `ReadViewDemo/ViewController.swift` startup validation so the native reflowable sample output includes the new geometry summary alongside the existing pagination and restore diagnostics. Keep the TXT and fixed/interactive matrix paths intact.</action>
|
|
||||||
<read_first>
|
|
||||||
- ReadViewDemo/ReadViewDemo/ViewController.swift
|
|
||||||
- Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift
|
|
||||||
- Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift
|
|
||||||
- Sources/RDReaderView/EPUBCore/RDEPUBReadingModels.swift
|
|
||||||
</read_first>
|
|
||||||
<acceptance_criteria>
|
|
||||||
- The simulator startup log includes a geometry/selection summary for at least one native reflowable sample.
|
|
||||||
- The existing sample validation matrix still reports the native, fixed/interactive, and TXT paths.
|
|
||||||
- Font or theme changes still preserve the same href and absolute-offset semantics in the restore diagnostic output.
|
|
||||||
</acceptance_criteria>
|
|
||||||
</task>
|
|
||||||
</tasks>
|
|
||||||
|
|
||||||
<verification>
|
|
||||||
- Build and launch `ReadViewDemo` in the simulator.
|
|
||||||
- Open a native reflowable sample, select text, and confirm the geometry summary matches the overlay selection.
|
|
||||||
- Change font size or theme and confirm restore diagnostics still report stable href and offset behavior.
|
|
||||||
</verification>
|
|
||||||
|
|
||||||
<success_criteria>
|
|
||||||
- Geometry and overlay behavior are visible in the demo.
|
|
||||||
- Selection, restore, and sample validation remain compatible after the phase 6 changes.
|
|
||||||
- The phase produces reusable diagnostics for later phases without requiring a second demo shell.
|
|
||||||
</success_criteria>
|
|
||||||
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
# Phase 6: 页面几何与交互命中层 - Context
|
|
||||||
|
|
||||||
**Gathered:** 2026-05-22
|
|
||||||
**Status:** Ready for planning
|
|
||||||
|
|
||||||
<domain>
|
|
||||||
## Phase Boundary
|
|
||||||
|
|
||||||
为 native text 分页结果建立可复用的页面级 layout frame 几何能力,并把 reader 的命中、选区和定位行为逐步迁移到显式几何层,而不是继续主要依赖 `UITextView` 的黑盒行为。此阶段不替换 `RDReaderView`,fixed / interactive EPUB 继续走 `WKWebView`,并且必须保持 `pageStartOffset`、`pageEndOffset`、`fragmentOffsets`、`RDEPUBTextOffsetRangeInfo` 的既有语义稳定。
|
|
||||||
|
|
||||||
</domain>
|
|
||||||
|
|
||||||
<decisions>
|
|
||||||
## Implementation Decisions
|
|
||||||
|
|
||||||
### Geometry Coverage
|
|
||||||
- **D-01:** 几何 API 以 `selection` 为第一优先级,但实现不能停留在单点能力上;页面几何层应至少覆盖字符串范围矩形查询、矩形反查文本范围、截断检测、命中定位所需的基础 API。
|
|
||||||
- **D-02:** 用户明确希望前两项讨论点都“全做”,因此 Phase 6 的几何层不能只做 selection 的局部补丁,而要把 selection 相关的几何查询做成可复用、可持续扩展的基础层。
|
|
||||||
|
|
||||||
### Interaction Migration
|
|
||||||
- **D-03:** 第一条迁移链路选择 `selection`。Phase 6 优先把选区范围获取、选区命中、选区展示和 offset 归一化迁移到 layout frame 几何层。
|
|
||||||
- **D-04:** 搜索命中、高亮和点击定位保持兼容,但不作为本阶段的主迁移链路;它们应当复用同一套几何接口,而不是各自再造一层逻辑。
|
|
||||||
|
|
||||||
### Display Strategy
|
|
||||||
- **D-05:** 展示策略选择 `custom overlay`。`UITextView` 继续承担基础文本布局/输入能力,但选区和命中展示不应继续绑定在它的黑盒 selection 表现上。
|
|
||||||
- **D-06:** overlay 方案必须与现有 offset-based 语义对齐,不能引入只可视不可追踪的新状态。
|
|
||||||
|
|
||||||
### Geometry Precision
|
|
||||||
- **D-07:** 精度优先级选择 `glyph 级精度`。在兼容性不被破坏的前提下,优先追求更精确的 glyph/fragment 级几何,而不是只做到粗粒度 block 级命中。
|
|
||||||
- **D-08:** 兼容性要求仍然保留,但仅作为精度实现的约束条件,不作为本阶段的目标上限。
|
|
||||||
|
|
||||||
### the agent's Discretion
|
|
||||||
- `search`、`highlight`、`tap locate` 在 Phase 6 中保持可接入状态;如果实现 selection 几何时顺手能抽出共享接口,可以一并收口,但不允许为了覆盖面而重写 reader 展示层。
|
|
||||||
|
|
||||||
</decisions>
|
|
||||||
|
|
||||||
<canonical_refs>
|
|
||||||
## Canonical References
|
|
||||||
|
|
||||||
**Downstream agents MUST read these before planning or implementing.**
|
|
||||||
|
|
||||||
### Milestone framing
|
|
||||||
- `.planning/ROADMAP.md` — Phase 6 的目标、依赖、成功标准与 cross-cutting constraints
|
|
||||||
- `.planning/REQUIREMENTS.md` — LAYOUT-01 / LAYOUT-02 / LAYOUT-03 的具体要求与 out-of-scope 约束
|
|
||||||
- `.planning/STATE.md` — 当前里程碑、当前 phase 和规划状态
|
|
||||||
|
|
||||||
### Native pagination and geometry pipeline
|
|
||||||
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextLayouter.swift` — 当前分页 frame 的生成、断页原因、attachment/block 诊断逻辑
|
|
||||||
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextLayoutFrame.swift` — 现有 page frame 结构与元数据承载点
|
|
||||||
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPaginationSupport.swift` — 现有分页支持入口和 page range 适配
|
|
||||||
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift` — page / offset continuity 的构建逻辑
|
|
||||||
- `Sources/RDReaderView/EPUBCore/RDEPUBReadingModels.swift` — `pageStartOffset`、`pageEndOffset`、`fragmentOffsets`、`RDEPUBTextOffsetRangeInfo` 等核心语义
|
|
||||||
|
|
||||||
### Reader integration
|
|
||||||
- `Sources/RDReaderView/EPUBUI/RDEPUBTextContentView.swift` — 当前 `UITextView` selection、highlight 与 offset overlap 逻辑
|
|
||||||
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift` — reader 状态编排、位置恢复、搜索和高亮接线点
|
|
||||||
|
|
||||||
No external specs — requirements are fully captured in decisions above
|
|
||||||
|
|
||||||
</canonical_refs>
|
|
||||||
|
|
||||||
<code_context>
|
|
||||||
## Existing Code Insights
|
|
||||||
|
|
||||||
### Reusable Assets
|
|
||||||
- `RDEPUBTextLayoutFrame` already carries content range, break reason, block range, attachment ranges, attachment kinds and diagnostics; it is the natural home for extra geometry fields.
|
|
||||||
- `RDEPUBTextLayouter` already has a stable frame-generation pipeline and can be extended without changing the upstream chapter preprocessing contract.
|
|
||||||
- `RDEPUBTextBookBuilder` already preserves absolute offsets and fragment continuity, which keeps selection and restore compatible with later geometry work.
|
|
||||||
- `RDEPUBReaderController` already centralizes reader state and interaction orchestration, so selection migration should route through it instead of creating a parallel controller.
|
|
||||||
|
|
||||||
### Established Patterns
|
|
||||||
- Offset-based consumers already rely on `pageStartOffset` / `pageEndOffset` / `fragmentOffsets` rather than page-local ad hoc state.
|
|
||||||
- Existing highlight and search rendering are driven by offset overlap, which is a useful bridge for moving selection to a geometry-backed overlay.
|
|
||||||
- Native reflowable EPUB remains the primary target for deeper internals; fixed / interactive EPUB stays on `WKWebView`.
|
|
||||||
|
|
||||||
### Integration Points
|
|
||||||
- Geometry APIs should live next to the page frame and pagination support code, not inside the reader view shell.
|
|
||||||
- Selection overlay work must integrate with `RDEPUBTextContentView` and the controller flow that already owns selection restore and highlight updates.
|
|
||||||
- Any new geometry result type should be consumable by future search/highlight/tap-locate work without duplicating page traversal or offset math.
|
|
||||||
|
|
||||||
</code_context>
|
|
||||||
|
|
||||||
<specifics>
|
|
||||||
## Specific Ideas
|
|
||||||
|
|
||||||
- The user wants Phase 6 to be selection-first and to complete that path fully, not as a partial shim.
|
|
||||||
- The user prefers a custom overlay for selection presentation rather than continuing to lean on `UITextView` selection visuals.
|
|
||||||
- The geometry target is glyph-level precision, with compatibility kept as a constraint rather than the primary target.
|
|
||||||
|
|
||||||
</specifics>
|
|
||||||
|
|
||||||
<deferred>
|
|
||||||
## Deferred Ideas
|
|
||||||
|
|
||||||
None — the discussion stayed within Phase 6 scope. `search` / `highlight` / `tap locate` remain compatible consumers of the same geometry layer, but they are not the primary migrated chain in this phase.
|
|
||||||
|
|
||||||
</deferred>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Phase: 6-页面几何与交互命中层*
|
|
||||||
*Context gathered: 2026-05-22*
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
# Phase 6: 页面几何与交互命中层 - Discussion Log
|
|
||||||
|
|
||||||
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
|
|
||||||
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
|
|
||||||
|
|
||||||
**Date:** 2026-05-22
|
|
||||||
**Phase:** 6-页面几何与交互命中层
|
|
||||||
**Areas discussed:** Geometry Coverage, Interaction Migration, Display Strategy, Geometry Precision
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Geometry Coverage
|
|
||||||
|
|
||||||
| Option | Description | Selected |
|
|
||||||
|--------|-------------|----------|
|
|
||||||
| `selection` | Prioritize selection geometry first | ✓ |
|
|
||||||
| `search / highlight / tap locate` | Prioritize other interaction geometry first | |
|
|
||||||
| `all` | Spread coverage across all consumers immediately | |
|
|
||||||
|
|
||||||
**User's choice:** `selection`
|
|
||||||
**Notes:** User additionally said the first two discussion areas should be fully done, so this is not a thin one-off patch.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Interaction Migration
|
|
||||||
|
|
||||||
| Option | Description | Selected |
|
|
||||||
|--------|-------------|----------|
|
|
||||||
| `selection` | Migrate selection range / hit / display flow first | ✓ |
|
|
||||||
| `search hit` | Migrate search hit resolution first | |
|
|
||||||
| `highlight paint` | Migrate highlight painting first | |
|
|
||||||
| `tap locate` | Migrate tap / point locate first | |
|
|
||||||
|
|
||||||
**User's choice:** `selection`
|
|
||||||
**Notes:** The selection chain is the first reader interaction to move onto the geometry layer.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Display Strategy
|
|
||||||
|
|
||||||
| Option | Description | Selected |
|
|
||||||
|--------|-------------|----------|
|
|
||||||
| `custom overlay` | Add a dedicated overlay for selection/highlight rendering | ✓ |
|
|
||||||
| `UITextView only` | Keep all display logic inside `UITextView` selection behavior | |
|
|
||||||
| `hybrid` | Use `UITextView` plus overlay only for a subset | |
|
|
||||||
|
|
||||||
**User's choice:** `custom overlay`
|
|
||||||
**Notes:** Overlay is preferred for selection presentation; `UITextView` should not remain the main black-box selection renderer.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Geometry Precision
|
|
||||||
|
|
||||||
| Option | Description | Selected |
|
|
||||||
|--------|-------------|----------|
|
|
||||||
| `glyph 级精度` | Prefer precise glyph-level geometry and reverse mapping | ✓ |
|
|
||||||
| `block 级兼容` | Prefer coarse block-level compatibility first | |
|
|
||||||
| `balanced` | Stop at whatever precision is easiest to ship | |
|
|
||||||
|
|
||||||
**User's choice:** `glyph 级精度`
|
|
||||||
**Notes:** Compatibility remains a constraint, but precision is the target.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## the agent's Discretion
|
|
||||||
|
|
||||||
- Keep `search` / `highlight` / `tap locate` compatible with the same geometry layer, but do not make them the primary migration target for this phase.
|
|
||||||
|
|
||||||
## Deferred Ideas
|
|
||||||
|
|
||||||
- Broader interaction migration beyond selection can reuse the same geometry layer in later work.
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# Phase 6: 页面几何与交互命中层 - Patterns
|
|
||||||
|
|
||||||
## Reusable Patterns
|
|
||||||
|
|
||||||
### Offset continuity is already the compatibility contract
|
|
||||||
|
|
||||||
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift` and `Sources/RDReaderView/LegacyRDReaderController/RDEPUBTextPaging.swift` both preserve `pageStartOffset`, `pageEndOffset`, and `fragmentOffsets`.
|
|
||||||
- `Sources/RDReaderView/EPUBCore/RDEPUBReadingModels.swift` keeps `RDEPUBTextOffsetRangeInfo` as the absolute-offset payload used by selection and highlight consumers.
|
|
||||||
- Phase 6 should keep that contract intact even after a geometry overlay is introduced.
|
|
||||||
|
|
||||||
### The layouter already knows more than the view layer
|
|
||||||
|
|
||||||
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextLayouter.swift` already determines `blockRange`, `attachmentRanges`, `attachmentKinds`, `breakReason`, and `trailingFragmentID`.
|
|
||||||
- That makes the layouter the right place to enrich `RDEPUBTextLayoutFrame` with queryable page geometry instead of re-deriving page rules inside `RDEPUBTextContentView`.
|
|
||||||
|
|
||||||
### Reader coordination already belongs to the controller
|
|
||||||
|
|
||||||
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift` already owns selection persistence, current-location updates, highlight creation, search state, and repagination.
|
|
||||||
- Any new geometry-backed selection flow should plug into the controller rather than creating a second reader state owner.
|
|
||||||
|
|
||||||
### Content view should become a thin host
|
|
||||||
|
|
||||||
- `Sources/RDReaderView/EPUBUI/RDEPUBTextContentView.swift` currently mixes display, selection, highlight painting, and offset conversion.
|
|
||||||
- For this phase, the safest direction is to let the view host text plus an overlay and keep the selection model conversion in one place.
|
|
||||||
|
|
||||||
## Closest Existing Analogs
|
|
||||||
|
|
||||||
- `RDEPUBTextLayoutFrame` is the natural home for page geometry metadata.
|
|
||||||
- `RDEPUBTextBookBuilder` is the natural place to lift geometry results into page-level book state.
|
|
||||||
- `RDEPUBReaderController` is the natural adapter layer for user interactions and persistence.
|
|
||||||
- `RDEPUBTextContentView` is the natural host for an overlay layer because it already owns the page view composition.
|
|
||||||
|
|
||||||
## Design Guardrails
|
|
||||||
|
|
||||||
- Do not move fixed/interactive EPUB off `WKWebView`.
|
|
||||||
- Do not change `RDReaderView`.
|
|
||||||
- Do not break the existing offset-based consumers while introducing geometry queries.
|
|
||||||
- Do not let search/highlight/tap-locate become separate geometry implementations; they should reuse the same page geometry API later.
|
|
||||||
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
# Phase 6: 页面几何与交互命中层 - Research
|
|
||||||
|
|
||||||
**Date:** 2026-05-22
|
|
||||||
**Phase:** 6
|
|
||||||
|
|
||||||
## Research Question
|
|
||||||
|
|
||||||
What do we need to know to plan page geometry and interaction hit handling well for the native text path?
|
|
||||||
|
|
||||||
## Current State
|
|
||||||
|
|
||||||
- `Sources/RDReaderView/EPUBUI/RDEPUBTextContentView.swift` still renders native text through `UITextView` and derives selection from `selectedRange`.
|
|
||||||
- `RDEPUBTextContentView` currently converts the selected range into `RDEPUBSelection` by using page-relative offsets plus `RDEPUBTextOffsetRangeInfo` absolute offsets.
|
|
||||||
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift` normalizes selections, persists them, and remains the central coordination point for reader interactions.
|
|
||||||
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextLayouter.swift` already computes page boundaries, break reasons, block ranges, attachment ranges, and trailing fragment IDs from CoreText frames.
|
|
||||||
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextLayoutFrame.swift` is currently only a metadata wrapper around `contentRange` plus pagination diagnostics.
|
|
||||||
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift` preserves absolute offsets, page continuity, and fragment offsets when assembling `RDEPUBTextBook`.
|
|
||||||
- `Sources/RDReaderView/EPUBCore/RDEPUBReadingModels.swift` already keeps the offset-based reader contract alive through `pageStartOffset`, `pageEndOffset`, `fragmentOffsets`, and `RDEPUBTextOffsetRangeInfo`.
|
|
||||||
|
|
||||||
## What This Means
|
|
||||||
|
|
||||||
1. The missing abstraction is a page-level geometry layer, not a new pagination pipeline.
|
|
||||||
2. The right insertion point is beside the layout frame / pagination code, because that layer already knows page boundaries and fragment continuity.
|
|
||||||
3. Selection should move first because the current user decision explicitly made selection the primary migrated interaction chain.
|
|
||||||
4. `custom overlay` is the right display model for Phase 6 because `UITextView` selection visuals are still the black-box dependency we are trying to reduce.
|
|
||||||
5. Precision target should be glyph-level or fragment-level geometry, with the existing offset contract kept as the compatibility anchor.
|
|
||||||
|
|
||||||
## Recommended Planning Shape
|
|
||||||
|
|
||||||
- **Plan 06-01:** add page geometry query APIs to the page frame / book layer.
|
|
||||||
- **Plan 06-02:** move selection presentation and hit handling onto a custom overlay while keeping offset-based selection data intact.
|
|
||||||
- **Plan 06-03:** verify selection geometry, offset continuity, and restore/search/highlight compatibility across the existing sample books.
|
|
||||||
|
|
||||||
## Technical Risks
|
|
||||||
|
|
||||||
- `UITextView` can provide selection behavior, but it is not a reliable source for a stable custom overlay pipeline if the overlay needs richer glyph-level rects.
|
|
||||||
- If geometry is bolted into the reader controller, the phase will become a UI-layer patch instead of a reusable page geometry layer.
|
|
||||||
- Any new geometry result must remain consumable by future `search`, `highlight`, and `tap locate` work without re-deriving offsets or page boundaries.
|
|
||||||
|
|
||||||
## Validation Implications
|
|
||||||
|
|
||||||
- There is no dedicated test target in the repo, so plan verification will need to rely on simulator build/run plus observable runtime behavior.
|
|
||||||
- The phase should define concrete UI and log checks for selection overlay behavior, offset preservation, and current-location compatibility.
|
|
||||||
- The verification strategy should keep one manual check for glyph-level selection/overlay behavior because that is the user-visible risk area for this phase.
|
|
||||||
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
---
|
|
||||||
phase: 6
|
|
||||||
slug: page-geometry-and-interaction-hit-layer
|
|
||||||
status: draft
|
|
||||||
nyquist_compliant: true
|
|
||||||
wave_0_complete: false
|
|
||||||
created: 2026-05-22
|
|
||||||
---
|
|
||||||
|
|
||||||
# Phase 6 — Validation Strategy
|
|
||||||
|
|
||||||
> Per-phase validation contract for page geometry and selection overlay work.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Test Infrastructure
|
|
||||||
|
|
||||||
| Property | Value |
|
|
||||||
|----------|-------|
|
|
||||||
| **Framework** | none — simulator build/run and manual UAT |
|
|
||||||
| **Config file** | none |
|
|
||||||
| **Quick run command** | `xcodebuild -workspace ReadViewDemo/ReadViewDemo.xcworkspace -scheme ReadViewDemo -configuration Debug -destination 'platform=iOS Simulator,name=iPhone 17' build` |
|
|
||||||
| **Full suite command** | `xcodebuild -workspace ReadViewDemo/ReadViewDemo.xcworkspace -scheme ReadViewDemo -configuration Debug -destination 'platform=iOS Simulator,name=iPhone 17' build && build_run_sim` |
|
|
||||||
| **Estimated runtime** | ~180 seconds |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Sampling Rate
|
|
||||||
|
|
||||||
- After every task commit: run the quick build command.
|
|
||||||
- After every plan wave: run the full simulator build/run path and confirm reader startup.
|
|
||||||
- Before execution handoff: confirm the selection overlay and offset preservation checks below.
|
|
||||||
- Max feedback latency: 180 seconds.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Per-Task Verification Map
|
|
||||||
|
|
||||||
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|
|
||||||
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
|
|
||||||
| 06-01-01 | 06-01 | 1 | LAYOUT-01 | — | N/A | build | `xcodebuild ... build` | ✅ | ⬜ pending |
|
|
||||||
| 06-01-02 | 06-01 | 1 | LAYOUT-03 | — | N/A | build | `xcodebuild ... build` | ✅ | ⬜ pending |
|
|
||||||
| 06-02-01 | 06-02 | 2 | LAYOUT-02 | — | N/A | simulator | `build_run_sim` | ✅ | ⬜ pending |
|
|
||||||
| 06-02-02 | 06-02 | 2 | LAYOUT-03 | — | N/A | simulator | `build_run_sim` | ✅ | ⬜ pending |
|
|
||||||
| 06-03-01 | 06-03 | 3 | LAYOUT-01 | — | N/A | manual | simulator inspection | ✅ | ⬜ pending |
|
|
||||||
| 06-03-02 | 06-03 | 3 | LAYOUT-02 | — | N/A | manual | simulator inspection | ✅ | ⬜ pending |
|
|
||||||
| 06-03-03 | 06-03 | 3 | LAYOUT-03 | — | N/A | manual | simulator inspection | ✅ | ⬜ pending |
|
|
||||||
|
|
||||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Wave 0 Requirements
|
|
||||||
|
|
||||||
- Existing build path covers the phase requirements; no new test target is required.
|
|
||||||
- Manual checks must be available in the demo app using the native text sample books.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Manual-Only Verifications
|
|
||||||
|
|
||||||
| Behavior | Requirement | Why Manual | Test Instructions |
|
|
||||||
|----------|-------------|------------|-------------------|
|
|
||||||
| Selection overlay appears and tracks the selected text range | LAYOUT-02 | Overlay geometry is visible behavior, not a source-only assertion | Open a native text sample, select text, verify overlay bounds follow the selected text and not the default `UITextView` selection chrome |
|
|
||||||
| Selection still serializes through `RDEPUBTextOffsetRangeInfo` | LAYOUT-03 | Offset continuity is best verified through persisted selection state and runtime logs | Create a selection, refresh the page, and confirm restored selection points to the same absolute offsets |
|
|
||||||
| Geometry queries do not break page navigation / current location | LAYOUT-01 | Page geometry must be validated through runtime behavior and current-location flow | Move between pages, search, and restore location after repagination; confirm page mapping stays stable |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Validation Sign-Off
|
|
||||||
|
|
||||||
- [ ] All tasks have `<acceptance_criteria>` or manual checks
|
|
||||||
- [ ] Sampling continuity: no 3 consecutive tasks without a build or simulator check
|
|
||||||
- [ ] Wave 0 covers missing infrastructure
|
|
||||||
- [ ] No watch-mode flags
|
|
||||||
- [ ] Feedback latency < 180s
|
|
||||||
- [ ] `nyquist_compliant: true` set in frontmatter
|
|
||||||
|
|
||||||
**Approval:** pending
|
|
||||||
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
---
|
|
||||||
milestone: v1.0
|
|
||||||
audited: 2026-05-22T05:32:00Z
|
|
||||||
status: gaps_found
|
|
||||||
scores:
|
|
||||||
requirements: 1/10
|
|
||||||
phases: 5/5
|
|
||||||
integration: 3/3
|
|
||||||
flows: 4/4
|
|
||||||
nyquist:
|
|
||||||
compliant_phases: []
|
|
||||||
partial_phases: [1, 2, 3, 4, 5]
|
|
||||||
missing_phases: []
|
|
||||||
overall: partial
|
|
||||||
gaps:
|
|
||||||
requirements:
|
|
||||||
- id: "REND-01"
|
|
||||||
status: "unsatisfied"
|
|
||||||
phase: "Phase 2"
|
|
||||||
claimed_by_plans: ["02-02-PLAN.md", "02-03-PLAN.md"]
|
|
||||||
completed_by_plans: []
|
|
||||||
verification_status: "partial"
|
|
||||||
evidence: "02-VERIFICATION.md only marks REND-01 as partial; REQUIREMENTS.md remains unchecked; no SUMMARY frontmatter claims completion."
|
|
||||||
- id: "REND-02"
|
|
||||||
status: "partial"
|
|
||||||
phase: "Phase 2"
|
|
||||||
claimed_by_plans: ["02-01-PLAN.md", "02-02-PLAN.md", "02-03-PLAN.md"]
|
|
||||||
completed_by_plans: []
|
|
||||||
verification_status: "passed"
|
|
||||||
evidence: "02-VERIFICATION.md marks REND-02 satisfied, but REQUIREMENTS.md remains unchecked and no SUMMARY frontmatter records completion."
|
|
||||||
- id: "REND-03"
|
|
||||||
status: "orphaned"
|
|
||||||
phase: "Phase 3"
|
|
||||||
claimed_by_plans: ["03-01-PLAN.md", "03-02-PLAN.md", "03-03-PLAN.md"]
|
|
||||||
completed_by_plans: []
|
|
||||||
verification_status: "orphaned"
|
|
||||||
evidence: "Phase 3 summary/verifications describe page metadata and paginator work, but no Phase 3 VERIFICATION requirements table maps back to REND-03 and REQUIREMENTS.md remains pending."
|
|
||||||
- id: "REND-04"
|
|
||||||
status: "orphaned"
|
|
||||||
phase: "Phase 3"
|
|
||||||
claimed_by_plans: ["03-02-PLAN.md", "03-03-PLAN.md"]
|
|
||||||
completed_by_plans: []
|
|
||||||
verification_status: "orphaned"
|
|
||||||
evidence: "Phase 3 runtime evidence proves stronger pagination, but no requirement-level verification entry or checked-off requirement exists for REND-04."
|
|
||||||
- id: "COMP-01"
|
|
||||||
status: "orphaned"
|
|
||||||
phase: "Phase 4"
|
|
||||||
claimed_by_plans: ["04-01-PLAN.md"]
|
|
||||||
completed_by_plans: []
|
|
||||||
verification_status: "orphaned"
|
|
||||||
evidence: "Phase 4 verification describes restored reader entry flow, but no explicit requirement table or summary frontmatter marks COMP-01 complete."
|
|
||||||
- id: "COMP-03"
|
|
||||||
status: "orphaned"
|
|
||||||
phase: "Phase 4"
|
|
||||||
claimed_by_plans: ["04-02-PLAN.md"]
|
|
||||||
completed_by_plans: []
|
|
||||||
verification_status: "orphaned"
|
|
||||||
evidence: "Search/highlight/restore behavior is evidenced in Phase 4, but not reconciled into REQUIREMENTS.md or requirement-level verification records."
|
|
||||||
- id: "COMP-04"
|
|
||||||
status: "orphaned"
|
|
||||||
phase: "Phase 4"
|
|
||||||
claimed_by_plans: ["04-03-PLAN.md"]
|
|
||||||
completed_by_plans: []
|
|
||||||
verification_status: "orphaned"
|
|
||||||
evidence: "ROADMAP and summaries say RDReaderView stayed untouched, but milestone requirement tracking never marks COMP-04 complete."
|
|
||||||
- id: "STAB-01"
|
|
||||||
status: "orphaned"
|
|
||||||
phase: "Phase 5"
|
|
||||||
claimed_by_plans: ["05-01-PLAN.md"]
|
|
||||||
completed_by_plans: []
|
|
||||||
verification_status: "orphaned"
|
|
||||||
evidence: "05-VERIFICATION.md shows a 5-book matrix across required categories, but no requirement table or checked requirement ties that evidence to STAB-01."
|
|
||||||
- id: "STAB-02"
|
|
||||||
status: "orphaned"
|
|
||||||
phase: "Phase 5"
|
|
||||||
claimed_by_plans: ["05-02-PLAN.md"]
|
|
||||||
completed_by_plans: []
|
|
||||||
verification_status: "orphaned"
|
|
||||||
evidence: "05-VERIFICATION.md shows no crash/blank-page regressions in sampled flows, but milestone requirement tracking never records STAB-02 as satisfied."
|
|
||||||
integration: []
|
|
||||||
flows: []
|
|
||||||
tech_debt:
|
|
||||||
- phase: "01-current-engine-boundaries"
|
|
||||||
items:
|
|
||||||
- "01-VALIDATION.md remains draft with pending Wave 0 sign-off and nyquist_compliant false."
|
|
||||||
- phase: "02-typesetter-css"
|
|
||||||
items:
|
|
||||||
- "02-VALIDATION.md remains draft with pending Wave 0 sign-off and nyquist_compliant false."
|
|
||||||
- "02-VERIFICATION.md leaves REND-01 only partial even though later phases likely complete the milestone intent."
|
|
||||||
- phase: "03-page-metadata-pagination"
|
|
||||||
items:
|
|
||||||
- "03-VALIDATION.md has nyquist_compliant true but all task rows and sign-off remain pending."
|
|
||||||
- "03-VERIFICATION.md lacks a requirement coverage section for REND-03 / REND-04."
|
|
||||||
- phase: "04-reader-capabilities"
|
|
||||||
items:
|
|
||||||
- "04-VALIDATION.md has no frontmatter compliance markers."
|
|
||||||
- "04-VERIFICATION.md lacks requirement-level mapping for COMP-01 / COMP-03 / COMP-04."
|
|
||||||
- "Residual risk notes admit no full interactive UI automation for TOC/highlight/search."
|
|
||||||
- phase: "05-regression-stability"
|
|
||||||
items:
|
|
||||||
- "05-VALIDATION.md documents the matrix but not Nyquist frontmatter compliance."
|
|
||||||
- "05-VERIFICATION.md lacks requirement-level mapping for STAB-01 / STAB-02."
|
|
||||||
- "Residual risk notes admit no fully automated UI walkthrough of every sample/interaction."
|
|
||||||
---
|
|
||||||
|
|
||||||
# Milestone v1.0 Audit
|
|
||||||
|
|
||||||
## Result
|
|
||||||
|
|
||||||
Status: `gaps_found`
|
|
||||||
|
|
||||||
The implementation milestone appears substantively complete at the phase and runtime-evidence level, but the milestone does **not** pass the strict close-out gate because requirement bookkeeping was never reconciled after execution. Under the required 3-source cross-reference, only `COMP-02` is fully satisfied.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- Milestone: `v1.0`
|
|
||||||
- Phases in scope: `01` through `05`
|
|
||||||
- Completed phases: `5/5`
|
|
||||||
- Verification files present: `5/5`
|
|
||||||
- Summary files present: `13/13`
|
|
||||||
|
|
||||||
## Phase Audit
|
|
||||||
|
|
||||||
| Phase | Verification | Summary Coverage | Audit Result |
|
|
||||||
|-------|--------------|------------------|--------------|
|
|
||||||
| 1 | present, `passed` | 2/2 | pass |
|
|
||||||
| 2 | present, `passed` | 3/3 | pass with requirement bookkeeping gap |
|
|
||||||
| 3 | present, runtime evidence strong | 3/3 | pass with orphaned requirement gap |
|
|
||||||
| 4 | present, runtime evidence strong | 3/3 | pass with orphaned requirement gap |
|
|
||||||
| 5 | present, runtime evidence strong | 2/2 | pass with orphaned requirement gap |
|
|
||||||
|
|
||||||
## Requirements Cross-Reference
|
|
||||||
|
|
||||||
| Requirement | Traceability | Verification | Summary Frontmatter | Final |
|
|
||||||
|-------------|--------------|--------------|---------------------|-------|
|
|
||||||
| COMP-02 | Complete | satisfied in Phase 1 | listed in 01 summaries | satisfied |
|
|
||||||
| REND-02 | Pending | satisfied in Phase 2 | not listed | partial |
|
|
||||||
| REND-01 | Pending | partial in Phase 2 | not listed | unsatisfied |
|
|
||||||
| REND-03 | Pending | no requirement table entry | not listed | orphaned |
|
|
||||||
| REND-04 | Pending | no requirement table entry | not listed | orphaned |
|
|
||||||
| COMP-01 | Pending | no requirement table entry | not listed | orphaned |
|
|
||||||
| COMP-03 | Pending | no requirement table entry | not listed | orphaned |
|
|
||||||
| COMP-04 | Pending | no requirement table entry | not listed | orphaned |
|
|
||||||
| STAB-01 | Pending | no requirement table entry | not listed | orphaned |
|
|
||||||
| STAB-02 | Pending | no requirement table entry | not listed | orphaned |
|
|
||||||
|
|
||||||
Interpretation:
|
|
||||||
|
|
||||||
- The code and runtime evidence strongly suggest several orphaned requirements were actually delivered.
|
|
||||||
- The milestone audit still fails because `REQUIREMENTS.md`, `*-VERIFICATION.md`, and `requirements-completed` summary metadata do not agree.
|
|
||||||
- This is a close-out gap, not necessarily an implementation gap.
|
|
||||||
|
|
||||||
## Integration Check
|
|
||||||
|
|
||||||
Cross-phase integration appears healthy from the shipped evidence:
|
|
||||||
|
|
||||||
- Phase 2 chapter preprocessing and CSS layering feed the Phase 3 paginator path.
|
|
||||||
- Phase 3 offset-compatible pagination semantics feed Phase 4 reader restore/search/highlight behavior.
|
|
||||||
- Phase 5 validates native reflowable, fixed/interactive WebKit, and TXT from the same demo/runtime surface.
|
|
||||||
|
|
||||||
No cross-phase wiring break or broken end-to-end flow was evidenced in the verification artifacts.
|
|
||||||
|
|
||||||
## Runtime / E2E Evidence
|
|
||||||
|
|
||||||
Observed milestone-level evidence from Phase 3-5 verification:
|
|
||||||
|
|
||||||
- `EPUB 资源验证:2/2 通过`
|
|
||||||
- `分页诊断:宝山辽墓材料与释读 ...`
|
|
||||||
- `恢复诊断:宝山辽墓材料与释读 ...`
|
|
||||||
- `样本验证:5/5 通过`
|
|
||||||
- `矩阵[Fixed/互动] 2/2`
|
|
||||||
- `矩阵[TXT] 1/1`
|
|
||||||
|
|
||||||
These support the claim that the milestone behavior is broadly working, but they do not by themselves satisfy the workflow's requirement-traceability gate.
|
|
||||||
|
|
||||||
## Nyquist Coverage
|
|
||||||
|
|
||||||
| Phase | VALIDATION.md | Compliance | Notes |
|
|
||||||
|-------|---------------|------------|-------|
|
|
||||||
| 1 | present | partial | draft, `nyquist_compliant: false` |
|
|
||||||
| 2 | present | partial | draft, `nyquist_compliant: false` |
|
|
||||||
| 3 | present | partial | `nyquist_compliant: true`, but task/sign-off rows remain pending |
|
|
||||||
| 4 | present | partial | validation doc exists, but no frontmatter compliance markers |
|
|
||||||
| 5 | present | partial | validation doc exists, but no frontmatter compliance markers |
|
|
||||||
|
|
||||||
## Why This Fails Close-Out
|
|
||||||
|
|
||||||
The milestone close workflow requires all of the following to align:
|
|
||||||
|
|
||||||
1. `REQUIREMENTS.md` checkboxes and traceability statuses
|
|
||||||
2. phase `VERIFICATION.md` requirement coverage
|
|
||||||
3. phase `SUMMARY.md` `requirements-completed` frontmatter
|
|
||||||
|
|
||||||
That alignment is missing for 9 of 10 requirements.
|
|
||||||
|
|
||||||
## Recommended Closure Path
|
|
||||||
|
|
||||||
1. Reconcile the requirement evidence into the existing phase artifacts:
|
|
||||||
- add requirement coverage sections where missing in Phases 3-5
|
|
||||||
- update `requirements-completed` frontmatter in relevant summaries
|
|
||||||
- update `.planning/REQUIREMENTS.md` checkboxes and traceability statuses
|
|
||||||
2. Run `$gsd-validate-phase 1`, `$gsd-validate-phase 2`, `$gsd-validate-phase 3`, `$gsd-validate-phase 4`, `$gsd-validate-phase 5` if you want Nyquist compliance brought up to date before close-out.
|
|
||||||
3. Re-run `$gsd-audit-milestone`.
|
|
||||||
|
|
||||||
## Bottom Line
|
|
||||||
|
|
||||||
The milestone looks operationally complete, but it is **not archive-ready** under the workflow’s audit rules because the requirement evidence chain is incomplete.
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
schemaVersion: 1
|
||||||
|
enabledWorkflows:
|
||||||
|
- simulator
|
||||||
|
debug: true
|
||||||
|
sentryDisabled: false
|
||||||
|
setupPreferences:
|
||||||
|
platforms:
|
||||||
|
- iOS
|
||||||
|
sessionDefaults:
|
||||||
|
workspacePath: ReadViewDemo/ReadViewDemo.xcworkspace
|
||||||
|
scheme: ReadViewDemo
|
||||||
|
simulatorId: FD12D2CF-AA13-48AF-B2D2-F1DFE806E839
|
||||||
|
simulatorName: iPhone 17 Pro
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
- If using XcodeBuildMCP, use the installed XcodeBuildMCP skill before calling XcodeBuildMCP tools.
|
||||||
+151
@@ -0,0 +1,151 @@
|
|||||||
|
# ReadViewSDK 代码审查报告
|
||||||
|
|
||||||
|
> 审查日期:2026-06-26
|
||||||
|
> 审查范围:Sources/RDEpubReaderView 全部源码
|
||||||
|
> 审查方法:逐文件阅读 + 交叉验证 + 线程模型分析
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 确认成立的问题
|
||||||
|
|
||||||
|
### P1-1 WKURLSchemeHandler 同步文件 I/O 阻塞调用线程
|
||||||
|
|
||||||
|
**文件**: `EPUBCore/RDEPUBResourceURLSchemeHandler.swift:51-174`
|
||||||
|
|
||||||
|
**现象**: `webView(_:start:)` 回调中,所有文件读取均同步执行:
|
||||||
|
|
||||||
|
- `respondWithInMemoryData`(第119行)使用 `Data(contentsOf:)` 全量读入 ≤512KB 的文件
|
||||||
|
- `respondWithStreaming`(第143行)在 `while true` 循环中同步读 64KB 块并逐块回调 `urlSchemeTask.didReceive(data)`
|
||||||
|
|
||||||
|
Apple 未保证 WKURLSchemeHandler 回调线程为主线程,实际在 WKWebView 内部队列上执行。同步阻塞该线程会:
|
||||||
|
|
||||||
|
- 若在主线程:直接造成 UI 卡顿/ANR
|
||||||
|
- 若在 WKWebView 内部队列:阻塞资源加载管线,影响渲染时序
|
||||||
|
|
||||||
|
**建议**: 将文件读取移至后台队列,通过 `DispatchQueue.main.async` 或回调队列回传 `didReceive`/`didFinish`。流式场景可改为分批异步读取。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P1-2 EPUB 解压缓存目录无清理策略
|
||||||
|
|
||||||
|
**文件**: `EPUBCore/RDEPUBParser+Archive.swift:79-90`
|
||||||
|
|
||||||
|
**现象**: `temporaryExtractionDirectory(for:)` 按 `(slug)-(fileSize)-(modificationTimestamp)` 生成缓存目录,存放在 `Caches/ssreaderview-epub/`。全局搜索确认不存在任何清理逻辑——无 LRU 淘汰、无总量上限、无 `removeItem` 调用指向该目录。
|
||||||
|
|
||||||
|
一本 50MB 的 EPUB 解压后约 100-200MB,用户打开多本书后缓存目录无限增长。
|
||||||
|
|
||||||
|
**建议**: 添加缓存清理策略:
|
||||||
|
1. 提供 `clearCache()` 公开方法供宿主 App 在低存储时调用
|
||||||
|
2. 每次打开新书时按访问时间淘汰超出上限的旧缓存
|
||||||
|
3. 或采用 `FileManager.default.urls(for: .cachesDirectory:)` 依赖系统自动清理
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P1-3 搜索在主线程同步执行
|
||||||
|
|
||||||
|
**文件**: `EPUBUI/ReaderController/RDEPUBReaderSearchCoordinator.swift:15-36`
|
||||||
|
|
||||||
|
**现象**: `search(keyword:)` 直接调用 `resolvedSearchMatches(for:)`,该方法遍历整本书的所有 spine 条目执行 HTML→文本转换或章节同步加载 + NSRange 搜索,全程在主线程完成。
|
||||||
|
|
||||||
|
对于大型 EPUB(数百章):
|
||||||
|
- UI 完全冻结直到搜索完成
|
||||||
|
- 用户无法取消或看到进度
|
||||||
|
- 可能触发 iOS 看门狗终止(0x8badf00d)
|
||||||
|
|
||||||
|
`resolvedOnDemandSearchMatches(for:)`(第125行)更严重:它同步加载每个章节(`loadChapterSynchronouslyForMigration`),整本书搜索意味着逐章同步加载。
|
||||||
|
|
||||||
|
**建议**: 将搜索逻辑移至后台队列,对大型书籍实施分批搜索 + 渐进结果回调,并在 UI 层添加搜索进度指示和取消能力。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P2-1 ZIP 无效条目导致整本 EPUB 打开失败
|
||||||
|
|
||||||
|
**文件**: `EPUBCore/RDEPUBParser+Archive.swift:41-44`
|
||||||
|
|
||||||
|
**现象**: 解压循环中,若 `validatedExtractionDestination` 返回 `nil`(条目路径验证失败),直接 `throw` 终止整本书的解析。实际 EPUB 中常包含无害条目(macOS `__MACOSX/` 目录、`.DS_Store`、Thumbs.db 等),这些不应阻止打开。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
for entry in archive {
|
||||||
|
guard let destinationURL = validatedExtractionDestination(for: entry.path, ...) else {
|
||||||
|
throw RDEPUBParserError.invalidArchiveEntryPath(entry.path) // 应改为 continue
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**建议**: 将验证失败的条目改为 `continue` + 日志警告,并增加对已知无害条目路径的跳过逻辑。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P2-2 javaScriptStringLiteral 实现依赖隐式假设
|
||||||
|
|
||||||
|
**文件**: `EPUBCore/RDEPUBJavaScriptBridge.swift:169-174`
|
||||||
|
|
||||||
|
**现象**: `javaScriptStringLiteral` 将值包装进单元素数组做 JSON 编码,再暴力去掉 `[` 和 `]`:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
private static func javaScriptStringLiteral(_ value: String?) -> String {
|
||||||
|
guard let value else { return "null" }
|
||||||
|
return jsonString(from: [value], fallback: "[null]")
|
||||||
|
.replacingOccurrences(of: "[", with: "")
|
||||||
|
.replacingOccurrences(of: "]", with: "")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
这不是 XSS/注入问题(`JSONSerialization` 已正确转义引号/反斜杠等危险字符),但该方法依赖"值不含 `[` 或 `]`"的隐式假设。当前调用场景传入的是 CSS 颜色值(如 `#FFFFFF`、`rgba(...)`),不含方括号,所以实际安全。但未来如果有人将包含方括号的值传入,输出会被静默破坏。
|
||||||
|
|
||||||
|
**建议**: 改为更直观的安全编码方式,如直接对单值做 `JSONSerialization` 后去掉两端引号,或添加文档注释明确标注此方法的约束前提。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P3-1 后台线程同步 hop 主线程获取布局信息
|
||||||
|
|
||||||
|
**文件**: `EPUBUI/ReaderController/RDEPUBReaderContext.swift:160-186`
|
||||||
|
|
||||||
|
**现象**: `currentTextPageSize()` 在非主线程且缓存不可用时,通过 `DispatchQueue.main.sync` 回到主线程获取布局信息。调用方包括 `RDEPUBMetadataParseWorker`(后台初始化,第72行)和 `RDEPUBChapterLoader`(`chapterLoadQueue.async`,第298行)。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
func currentTextPageSize() -> CGSize {
|
||||||
|
if Thread.isMainThread {
|
||||||
|
// 主线程路径 — 安全
|
||||||
|
...
|
||||||
|
} else if let lastTextPaginationPageSize, ... {
|
||||||
|
// 缓存路径 — 无需 hop
|
||||||
|
return lastTextPaginationPageSize
|
||||||
|
} else {
|
||||||
|
let mainThreadSize = DispatchQueue.main.sync { [weak self] in
|
||||||
|
self?.currentTextPageSize() ?? .zero
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Thread.isMainThread` 保护使得从主线程调用不会死锁。实际风险是**时序耦合**:后台任务阻塞等待主线程布局信息,如果主线程正在忙于 UI 操作(如翻页动画),后台线程会被卡住直到主线程空闲。这是一个线程模型/可维护性问题,而非可直接触发的死锁。
|
||||||
|
|
||||||
|
**建议**: 将布局参数作为初始化参数传入后台任务,而非在后台线程通过 `DispatchQueue.main.sync` 获取。这样后台线程完全自主,不依赖主线程时序。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 已撤回的原始误判
|
||||||
|
|
||||||
|
以下条目经逐条代码验证后确认不成立或定级过高:
|
||||||
|
|
||||||
|
| 原始编号 | 原始结论 | 修正结论 |
|
||||||
|
|---------|---------|---------|
|
||||||
|
| ~~P0 引用循环~~ | Runtime/Coordinator 强引用 context 形成循环 | **不成立**。全量搜索确认所有 Coordinator/Runtime 均使用 `unowned let context` 或 `weak var context` |
|
||||||
|
| ~~P0 XSS 注入~~ | `javaScriptStringLiteral` 的 `]` 替换导致注入 | **不成立**。`JSONSerialization` 编码已处理引号/反斜杠等危险字符。实际是健壮性问题,非安全问题(已调整为 P2-2) |
|
||||||
|
| ~~P1 Publication 暴露 parser~~ | 外部可直接修改 parser 内部状态 | **不成立**。`RDEPUBParser` 关键属性均为 `public internal(set)`,外部模块无法修改 |
|
||||||
|
| ~~P2 NSRange 越界~~ | UTF-16 和 Swift String 混用导致偏移 | **不成立**。搜索代码全程在 `NSString` + `NSRange` 域内操作,未混合 Swift String 索引 |
|
||||||
|
| ~~P2 后台 deinit 崩溃~~ | teardownWebView 可能在后台线程执行 | **证据不足**。`RDEPUBWebView` 是 UIView 子类,正常在主线程释放,无证据表明会后台释放 |
|
||||||
|
| ~~P0 ZIP 路径遍历绕过~~ | `%2e%2e` 可绕过路径校验 | **论证过头**。当前实现有组件级 `..` 检查 + `standardizedFileURL` 前缀校验,双层防护有效 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 问题汇总
|
||||||
|
|
||||||
|
| 严重度 | 编号 | 问题 | 类型 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| 🟠 P1 | P1-1 | WKURLSchemeHandler 同步文件 I/O 阻塞调用线程 | 性能 |
|
||||||
|
| 🟠 P1 | P1-2 | EPUB 解压缓存目录无清理策略 | 存储 |
|
||||||
|
| 🟠 P1 | P1-3 | 搜索在主线程同步执行,大书可致 ANR | 性能 |
|
||||||
|
| 🟡 P2 | P2-1 | ZIP 无效条目导致整本打开失败 | 鲁棒性 |
|
||||||
|
| 🟡 P2 | P2-2 | javaScriptStringLiteral 实现依赖隐式假设 | 可维护性 |
|
||||||
|
| 🔵 P3 | P3-1 | 后台线程同步 hop 主线程获取布局信息 | 线程模型 |
|
||||||
|
|
||||||
|
**整体评价**: SDK 架构设计良好,模块分层清晰,引用管理(unowned/weak)使用正确,分页取消机制有 `paginationToken` + `cancellationController` 兜底。主要值得修复的是三个性能类 P1 问题(主线程阻塞搜索、资源加载阻塞、缓存无清理),两个 P2 鲁棒性/可维护性问题,以及一个 P3 线程模型优化建议。
|
||||||
+6
-7
@@ -1,19 +1,18 @@
|
|||||||
# Project Context & Rules
|
# Project Context & Rules
|
||||||
|
|
||||||
## Language Specification
|
## Language Specification
|
||||||
- **Planning & Documentation:** All plans, task lists, and specifications generated inside the `.planning/` directory MUST be written in Chinese.
|
- **Project Docs:** All documents generated or updated under `Doc/`, project root Markdown documentation MUST be written in Chinese.
|
||||||
- **Project Docs:** All documents generated or updated under `Doc/`, project root Markdown documentation, and GSD-produced delivery/analysis documents MUST be written in Chinese.
|
|
||||||
- **Codebase:** Source code syntax (class names, function names, variables, file names, API identifiers, route identifiers) must remain in English, but all code comments, documentation (Docstrings), and commit messages MUST be written in Chinese.
|
- **Codebase:** Source code syntax (class names, function names, variables, file names, API identifiers, route identifiers) must remain in English, but all code comments, documentation (Docstrings), and commit messages MUST be written in Chinese.
|
||||||
|
|
||||||
## Agent / Sub-agent Enforcement (GSD & Codex)
|
## Agent / Sub-agent Enforcement
|
||||||
- **Mandatory pre-read:** Any agent/sub-agent that generates or updates files MUST read this file (`CONTEXT.md`) first and follow it as the source-of-truth for language/style constraints.
|
- **Mandatory pre-read:** Any agent/sub-agent that generates or updates files MUST read this file (`CONTEXT.md`) first and follow it as the source-of-truth for language/style constraints.
|
||||||
- **Chinese-only outputs:** Any artifact written under `.planning/` (including `.planning/codebase/*.md`) MUST be Chinese. Do not output English headings/sections unless the content is a literal code identifier or a proper noun that should not be translated.
|
- **Chinese-only outputs:** Any artifact written under `Doc/` MUST be Chinese. Do not output English headings/sections unless the content is a literal code identifier or a proper noun that should not be translated.
|
||||||
- **When spawning sub-agents:** The orchestrator MUST either:
|
- **When spawning sub-agents:** The orchestrator MUST either:
|
||||||
- set `fork_context=true`, AND
|
- set `fork_context=true`, AND
|
||||||
- explicitly instruct the agent to read `CONTEXT.md` before writing; OR
|
- explicitly instruct the agent to read `CONTEXT.md` before writing; OR
|
||||||
- (if `fork_context=false`) explicitly include the above constraints in the sub-agent prompt and still require a `CONTEXT.md` pre-read.
|
- (if `fork_context=false`) explicitly include the above constraints in the sub-agent prompt and still require a `CONTEXT.md` pre-read.
|
||||||
|
|
||||||
### Recommended prompt snippet (copy into sub-agent tasks)
|
### Recommended prompt snippet (copy into sub-agent tasks)
|
||||||
1) First read `CONTEXT.md` and follow all rules.
|
1) First read `CONTEXT.md` and follow all rules.
|
||||||
2) All markdown you write under `.planning/` MUST be Chinese.
|
2) All markdown you write under `Doc/` MUST be Chinese.
|
||||||
3) Keep code identifiers / file paths / commands in English as-is.
|
3) Keep code identifiers / file paths / commands in English as-is.
|
||||||
|
|||||||
@@ -0,0 +1,483 @@
|
|||||||
|
# 公开 API 参考手册
|
||||||
|
|
||||||
|
> 最后更新:2026-06-18
|
||||||
|
|
||||||
|
本文档列出 ReadViewSDK 所有公开 API 的完整签名、参数说明和使用方法。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. RDEPUBReaderController — 入口控制器
|
||||||
|
|
||||||
|
**文件**:`Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController.swift` 及扩展
|
||||||
|
|
||||||
|
### 1.1 初始化
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// EPUB 文件初始化
|
||||||
|
init(
|
||||||
|
epubURL: URL, // EPUB 文件路径
|
||||||
|
configuration: RDEPUBReaderConfiguration = .default, // 阅读配置
|
||||||
|
persistence: RDEPUBReaderPersistence? = nil, // 持久化实现
|
||||||
|
dependencies: RDEPUBReaderDependencies? = nil // 依赖注入
|
||||||
|
)
|
||||||
|
|
||||||
|
// 预构建 TextBook 初始化(如 .txt 文件)
|
||||||
|
init(
|
||||||
|
textBook: RDEPUBTextBook, // 预构建的文本书籍
|
||||||
|
bookIdentifier: String, // 书籍唯一标识
|
||||||
|
title: String, // 书籍标题
|
||||||
|
textFileURL: URL? = nil, // 原始文件路径
|
||||||
|
configuration: RDEPUBReaderConfiguration = .default,
|
||||||
|
persistence: RDEPUBReaderPersistence? = nil,
|
||||||
|
dependencies: RDEPUBReaderDependencies? = nil
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 公开属性
|
||||||
|
|
||||||
|
| 属性 | 类型 | 读写 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `delegate` | `RDEPUBReaderDelegate?` | 读写 | 委托代理 |
|
||||||
|
| `configuration` | `RDEPUBReaderConfiguration` | 读写 | 阅读配置,设置后触发重新分页 |
|
||||||
|
| `currentLocation` | `RDEPUBLocation?` | 只读 | 当前阅读位置 |
|
||||||
|
| `currentPageNumber` | `Int?` | 只读 | 当前页码(1-based) |
|
||||||
|
| `currentSelection` | `RDEPUBSelection?` | 只读 | 当前文本选区 |
|
||||||
|
| `highlights` | `[RDEPUBHighlight]` | 只读 | 所有高亮标注 |
|
||||||
|
| `bookmarks` | `[RDEPUBBookmark]` | 只读 | 所有书签 |
|
||||||
|
| `annotations` | `[RDEPUBAnnotation]` | 只读 | 合并排序后的统一标注列表 |
|
||||||
|
| `tableOfContents` | `[EPUBTableOfContentsItem]` | 只读 | 目录树(嵌套结构) |
|
||||||
|
| `flattenedTableOfContents` | `[RDEPUBReaderTableOfContentsItem]` | 只读 | 扁平化目录列表 |
|
||||||
|
| `currentTableOfContentsItem` | `RDEPUBReaderTableOfContentsItem?` | 只读 | 当前所在目录项 |
|
||||||
|
|
||||||
|
### 1.3 导航方法
|
||||||
|
|
||||||
|
```swift
|
||||||
|
/// 重新加载书籍
|
||||||
|
func reloadBook()
|
||||||
|
|
||||||
|
/// 跳转到指定位置
|
||||||
|
func go(to location: RDEPUBLocation)
|
||||||
|
|
||||||
|
/// 跳转到指定页码(1-based)
|
||||||
|
/// - Returns: 是否跳转成功
|
||||||
|
@discardableResult
|
||||||
|
func go(toPageNumber pageNumber: Int, animated: Bool = false) -> Bool
|
||||||
|
|
||||||
|
/// 跳转到目录项(EPUBTableOfContentsItem)
|
||||||
|
@discardableResult
|
||||||
|
func go(toTableOfContentsItem item: EPUBTableOfContentsItem, animated: Bool = true) -> Bool
|
||||||
|
|
||||||
|
/// 跳转到目录项(RDEPUBReaderTableOfContentsItem)
|
||||||
|
@discardableResult
|
||||||
|
func go(toTableOfContentsItem item: RDEPUBReaderTableOfContentsItem, animated: Bool = true) -> Bool
|
||||||
|
|
||||||
|
/// 跳转到目录 href(支持 fragment,如 "chapter1.xhtml#section2")
|
||||||
|
@discardableResult
|
||||||
|
func go(toTableOfContentsHref href: String, animated: Bool = true) -> Bool
|
||||||
|
|
||||||
|
/// 跳转到指定高亮位置
|
||||||
|
@discardableResult
|
||||||
|
func go(toHighlightID id: String, animated: Bool = true) -> Bool
|
||||||
|
|
||||||
|
/// 跳转到指定书签位置
|
||||||
|
@discardableResult
|
||||||
|
func go(toBookmarkID id: String, animated: Bool = true) -> Bool
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.4 高亮方法
|
||||||
|
|
||||||
|
```swift
|
||||||
|
/// 添加高亮(使用当前选区或指定选区)
|
||||||
|
/// - Parameters:
|
||||||
|
/// - selection: 选区信息,nil 时使用 currentSelection
|
||||||
|
/// - color: 高亮颜色(十六进制),默认 "#F8E16C"
|
||||||
|
/// - note: 可选备注
|
||||||
|
/// - Returns: 创建的高亮对象,失败返回 nil
|
||||||
|
@discardableResult
|
||||||
|
func addHighlight(
|
||||||
|
from selection: RDEPUBSelection? = nil,
|
||||||
|
color: String = "#F8E16C",
|
||||||
|
note: String? = nil
|
||||||
|
) -> RDEPUBHighlight?
|
||||||
|
|
||||||
|
/// 添加标注(支持高亮和下划线样式)
|
||||||
|
@discardableResult
|
||||||
|
func addAnnotation(
|
||||||
|
from selection: RDEPUBSelection? = nil,
|
||||||
|
style: RDEPUBHighlightStyle,
|
||||||
|
color: String = "#F8E16C",
|
||||||
|
note: String? = nil
|
||||||
|
) -> RDEPUBHighlight?
|
||||||
|
|
||||||
|
/// 更新或插入高亮
|
||||||
|
@discardableResult
|
||||||
|
func upsertHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight?
|
||||||
|
|
||||||
|
/// 删除高亮
|
||||||
|
@discardableResult
|
||||||
|
func removeHighlight(id: String) -> RDEPUBHighlight?
|
||||||
|
|
||||||
|
/// 更新高亮备注
|
||||||
|
@discardableResult
|
||||||
|
func updateHighlightNote(id: String, note: String?) -> RDEPUBHighlight?
|
||||||
|
|
||||||
|
/// 删除所有高亮
|
||||||
|
func removeAllHighlights()
|
||||||
|
|
||||||
|
/// 根据 ID 查找高亮
|
||||||
|
func highlight(withID id: String) -> RDEPUBHighlight?
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.5 书签方法
|
||||||
|
|
||||||
|
```swift
|
||||||
|
/// 添加书签(使用当前位置)
|
||||||
|
@discardableResult
|
||||||
|
func addBookmark(note: String? = nil) -> RDEPUBBookmark?
|
||||||
|
|
||||||
|
/// 切换书签状态(已存在则删除,不存在则添加)
|
||||||
|
@discardableResult
|
||||||
|
func toggleBookmark(note: String? = nil) -> RDEPUBBookmark?
|
||||||
|
|
||||||
|
/// 删除书签
|
||||||
|
@discardableResult
|
||||||
|
func removeBookmark(id: String) -> RDEPUBBookmark?
|
||||||
|
|
||||||
|
/// 根据 ID 查找书签
|
||||||
|
func bookmark(withID id: String) -> RDEPUBBookmark?
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.6 搜索方法
|
||||||
|
|
||||||
|
```swift
|
||||||
|
/// 开始搜索
|
||||||
|
func search(keyword: String)
|
||||||
|
|
||||||
|
/// 跳转到下一个匹配
|
||||||
|
/// - Returns: 是否有下一个匹配
|
||||||
|
@discardableResult
|
||||||
|
func searchNext() -> Bool
|
||||||
|
|
||||||
|
/// 跳转到上一个匹配
|
||||||
|
@discardableResult
|
||||||
|
func searchPrevious() -> Bool
|
||||||
|
|
||||||
|
/// 清除搜索
|
||||||
|
func clearSearch()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.7 其他方法
|
||||||
|
|
||||||
|
```swift
|
||||||
|
/// 清除当前文本选区
|
||||||
|
func clearSelection()
|
||||||
|
|
||||||
|
/// 获取当前页面的语义摘要(调试用)
|
||||||
|
func nativeTextSemanticSummary() -> String?
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. RDEPUBReaderDelegate — 委托协议
|
||||||
|
|
||||||
|
**文件**:`Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderDelegate.swift`
|
||||||
|
|
||||||
|
所有方法均有默认空实现,可按需实现。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public protocol RDEPUBReaderDelegate: AnyObject {
|
||||||
|
|
||||||
|
/// 书籍打开完成
|
||||||
|
func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication)
|
||||||
|
|
||||||
|
/// 阅读位置变化
|
||||||
|
func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation)
|
||||||
|
|
||||||
|
/// 到达书籍末尾
|
||||||
|
func epubReaderDidReachEnd(_ reader: UIViewController)
|
||||||
|
|
||||||
|
/// 文本选区变化(nil 表示取消选中)
|
||||||
|
func epubReader(_ reader: UIViewController, didChangeSelection selection: RDEPUBSelection?)
|
||||||
|
|
||||||
|
/// 高亮列表变化
|
||||||
|
func epubReader(_ reader: UIViewController, didUpdateHighlights highlights: [RDEPUBHighlight])
|
||||||
|
|
||||||
|
/// 书签列表变化
|
||||||
|
func epubReader(_ reader: UIViewController, didUpdateBookmarks bookmarks: [RDEPUBBookmark])
|
||||||
|
|
||||||
|
/// 搜索结果更新
|
||||||
|
func epubReader(_ reader: UIViewController, didUpdateSearchResult result: RDEPUBSearchResult?)
|
||||||
|
|
||||||
|
/// 当前搜索匹配项变化
|
||||||
|
func epubReader(_ reader: UIViewController, didChangeCurrentSearchMatch match: RDEPUBSearchMatch?)
|
||||||
|
|
||||||
|
/// 当前目录项变化
|
||||||
|
func epubReader(_ reader: UIViewController, didUpdateCurrentTableOfContentsItem item: RDEPUBReaderTableOfContentsItem?)
|
||||||
|
|
||||||
|
/// 外部链接被点击
|
||||||
|
func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL)
|
||||||
|
|
||||||
|
/// 是否允许打开外部链接(返回 false 拦截)
|
||||||
|
func epubReader(_ reader: UIViewController, shouldOpenExternalURL url: URL) -> Bool
|
||||||
|
|
||||||
|
/// 错误回调
|
||||||
|
func epubReader(_ reader: UIViewController, didFailWithError error: Error)
|
||||||
|
|
||||||
|
/// 配置顶部工具栏(自定义按钮等)
|
||||||
|
func epubReader(_ reader: UIViewController, configureTopToolView topToolView: RDEPUBReaderTopToolView)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. RDEPUBReaderPersistence — 持久化协议
|
||||||
|
|
||||||
|
**文件**:`Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderPersistence.swift`
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public protocol RDEPUBReaderPersistence: AnyObject {
|
||||||
|
|
||||||
|
func loadLocation(for bookIdentifier: String) -> RDEPUBLocation?
|
||||||
|
func saveLocation(_ location: RDEPUBLocation, for bookIdentifier: String)
|
||||||
|
|
||||||
|
func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark]
|
||||||
|
func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String)
|
||||||
|
|
||||||
|
func loadHighlights(for bookIdentifier: String) -> [RDEPUBHighlight]
|
||||||
|
func saveHighlights(_ highlights: [RDEPUBHighlight], for bookIdentifier: String)
|
||||||
|
|
||||||
|
func loadReaderSettings() -> RDEPUBReaderSettings?
|
||||||
|
func saveReaderSettings(_ settings: RDEPUBReaderSettings)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
默认实现 `RDEPUBUserDefaultsPersistence` 使用 UserDefaults 存储:
|
||||||
|
|
||||||
|
| 数据 | Key 格式 |
|
||||||
|
|------|----------|
|
||||||
|
| 阅读位置 | `ssreader.epub.location.{bookID}` |
|
||||||
|
| 书签 | `ssreader.epub.bookmarks.{bookID}` |
|
||||||
|
| 高亮 | `ssreader.epub.highlights.{bookID}` |
|
||||||
|
| 全局设置 | `ssreader.epub.settings` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. RDEPUBReaderConfiguration — 配置模型
|
||||||
|
|
||||||
|
**文件**:`Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderConfiguration.swift`
|
||||||
|
|
||||||
|
| 属性 | 类型 | 默认值 | 说明 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| `fontSize` | `CGFloat` | `15` | 字体大小(pt) |
|
||||||
|
| `lineHeightMultiple` | `CGFloat` | `1.6` | 行距倍数 |
|
||||||
|
| `fontChoice` | `RDEPUBReaderFontChoice` | `.system` | 字体选择(system/serif/rounded/monospaced) |
|
||||||
|
| `numberOfColumns` | `Int` | `1` | 每页列数(1 或 2) |
|
||||||
|
| `columnGap` | `CGFloat` | `20` | 列间距 |
|
||||||
|
| `displayType` | `RDEpubReaderView.DisplayType` | `.pageCurl` | 翻页模式 |
|
||||||
|
| `landscapeDualPageEnabled` | `Bool` | `true` | 横屏双页 |
|
||||||
|
| `showsTableOfContents` | `Bool` | `true` | 显示目录 |
|
||||||
|
| `allowsHighlights` | `Bool` | `true` | 允许高亮 |
|
||||||
|
| `showsSettingsPanel` | `Bool` | `true` | 显示设置面板 |
|
||||||
|
| `reflowableContentInsets` | `UIEdgeInsets` | `(40,16,40,16)` | 重排内容内边距 |
|
||||||
|
| `fixedContentInset` | `UIEdgeInsets` | `.zero` | 固定布局内边距 |
|
||||||
|
| `theme` | `RDEPUBReaderTheme` | `.light` | 阅读主题 |
|
||||||
|
| `darkImageAdjustmentEnabled` | `Bool` | `true` | 暗色模式图片调整 |
|
||||||
|
| `darkImageBlendRatio` | `CGFloat` | `0.15` | 暗色图片混合比例(0-0.35) |
|
||||||
|
| `fixedLayoutFit` | `RDEPUBFixedLayoutFit` | `.page` | 固定布局适配方式 |
|
||||||
|
| `fixedLayoutSpreadMode` | `RDEPUBFixedLayoutSpreadMode` | `.automatic` | 固定布局跨页模式 |
|
||||||
|
| `textRenderingEngine` | `RDEPUBTextRenderingEngine` | `.dtCoreText` | 文本渲染引擎 |
|
||||||
|
| `onDemandChapterWindowSize` | `Int` | `3` | 按需加载窗口大小(奇数,3-15) |
|
||||||
|
| `metadataParsingConcurrency` | `Int` | CPU 核心数 | 后台解析并发数 |
|
||||||
|
| `jumpSessionPolicy` | `RDEPUBJumpSessionPolicy` | `.default` | 远距跳转策略 |
|
||||||
|
| `allowedExternalURLSchemes` | `Set<String>` | `["https"]` | 允许的外部 URL 协议 |
|
||||||
|
| `requiresExternalLinkConfirmation` | `Bool` | `true` | 外部链接需确认 |
|
||||||
|
| `allowsInspectableWebViews` | `Bool` | `false` | WebView 可调试 |
|
||||||
|
| `enablesVerboseWebViewLogging` | `Bool` | `false` | WebView 详细日志 |
|
||||||
|
|
||||||
|
**计算属性**:
|
||||||
|
- `chapterWindowRadius: Int` — 内存缓存窗口半径(`onDemandChapterWindowSize / 2`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 翻页容器协议
|
||||||
|
|
||||||
|
**文件**:`Sources/RDEpubReaderView/ReaderView/RDEpubReaderViewProtocols.swift`
|
||||||
|
|
||||||
|
### 5.1 RDEpubReaderPageProvider(推荐)
|
||||||
|
|
||||||
|
```swift
|
||||||
|
@objc public protocol RDEpubReaderPageProvider: NSObjectProtocol {
|
||||||
|
|
||||||
|
/// 总页数
|
||||||
|
func numberOfPages(in readerView: RDEpubReaderView) -> Int
|
||||||
|
|
||||||
|
/// 返回指定页的视图
|
||||||
|
/// - Parameters:
|
||||||
|
/// - index: 页码索引(0-based)
|
||||||
|
/// - reusableView: 可复用的旧视图(可能为 nil)
|
||||||
|
func readerView(_ readerView: RDEpubReaderView, viewForPageAt index: Int, reusableView: UIView?) -> UIView
|
||||||
|
|
||||||
|
/// 页面标识符(用于缓存去重)
|
||||||
|
@objc optional func pageIdentifier(in readerView: RDEpubReaderView, index: Int) -> String?
|
||||||
|
|
||||||
|
/// 顶部工具栏视图
|
||||||
|
@objc optional func readerViewTopChrome(_ readerView: RDEpubReaderView) -> UIView?
|
||||||
|
|
||||||
|
/// 底部工具栏视图
|
||||||
|
@objc optional func readerViewBottomChrome(_ readerView: RDEpubReaderView) -> UIView?
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 RDEpubReaderDelegate
|
||||||
|
|
||||||
|
```swift
|
||||||
|
@objc public protocol RDEpubReaderDelegate: NSObjectProtocol {
|
||||||
|
|
||||||
|
/// 页面变化回调
|
||||||
|
func pageNum(readerView: RDEpubReaderView, pageNum: Int)
|
||||||
|
|
||||||
|
/// 屏幕方向即将变化
|
||||||
|
@objc optional func readerViewOrientationWillChange(readerView: RDEpubReaderView, isLandscape: Bool)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 RDEpubReaderPageNavigating
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public protocol RDEpubReaderPageNavigating: AnyObject {
|
||||||
|
|
||||||
|
/// 当前页码
|
||||||
|
var currentPage: Int { get }
|
||||||
|
|
||||||
|
/// 重新加载所有页面
|
||||||
|
func reloadPages()
|
||||||
|
|
||||||
|
/// 跳转到指定页
|
||||||
|
func transition(to page: Int, animated: Bool)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 RDEpubReaderDataSource(遗留)
|
||||||
|
|
||||||
|
```swift
|
||||||
|
@objc public protocol RDEpubReaderDataSource: NSObjectProtocol {
|
||||||
|
func pageCountOfReaderView(readerView: RDEpubReaderView) -> Int
|
||||||
|
func pageContentView(readerView: RDEpubReaderView, pageNum: Int, containerView: UIView?) -> UIView
|
||||||
|
func pageIdentifier(readerView: RDEpubReaderView, pageNum: Int) -> String?
|
||||||
|
@objc optional func topToolView(readerView: RDEpubReaderView) -> UIView?
|
||||||
|
@objc optional func bottomToolView(readerView: RDEpubReaderView) -> UIView?
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
通过 `RDEpubReaderLegacyDataSourceAdapter` 自动适配到 `RDEpubReaderPageProvider`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 数据模型速查
|
||||||
|
|
||||||
|
### 6.1 RDEPUBLocation
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public struct RDEPUBLocation: Codable, Equatable {
|
||||||
|
public var bookIdentifier: String? // 书籍标识
|
||||||
|
public var href: String // 章节文件路径
|
||||||
|
public var progression: Double // 章节内进度(0.0-1.0)
|
||||||
|
public var lastProgression: Double? // 上次进度(用于恢复方向)
|
||||||
|
public var fragment: String? // 片段 ID(如 #section1)
|
||||||
|
public var rangeAnchor: RDEPUBTextRangeAnchor? // 文本范围锚点
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 RDEPUBBookmark
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public struct RDEPUBBookmark: Codable, Equatable, Identifiable {
|
||||||
|
public let id: String // 唯一标识
|
||||||
|
public let bookIdentifier: String?
|
||||||
|
public let location: RDEPUBLocation
|
||||||
|
public let chapterTitle: String?
|
||||||
|
public let note: String?
|
||||||
|
public let createdAt: Date
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 RDEPUBHighlight
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public struct RDEPUBHighlight: Codable, Equatable, Identifiable {
|
||||||
|
public let id: String
|
||||||
|
public let bookIdentifier: String?
|
||||||
|
public let location: RDEPUBLocation
|
||||||
|
public let text: String // 高亮文本内容
|
||||||
|
public let style: RDEPUBHighlightStyle // .highlight / .underline
|
||||||
|
public let color: String // 十六进制颜色
|
||||||
|
public let note: String?
|
||||||
|
public let rangeInfo: String? // CoreText 选区范围信息
|
||||||
|
public let createdAt: Date
|
||||||
|
public var uiColor: UIColor { ... } // 计算属性
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.4 RDEPUBAnnotation
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public struct RDEPUBAnnotation: Codable, Equatable, Identifiable {
|
||||||
|
public let id: String
|
||||||
|
public let bookIdentifier: String?
|
||||||
|
public let kind: RDEPUBAnnotationKind // .bookmark / .highlight / .underline
|
||||||
|
public let location: RDEPUBLocation
|
||||||
|
public let text: String?
|
||||||
|
public let rangeInfo: String?
|
||||||
|
public let chapterTitle: String?
|
||||||
|
public let createdAt: Date
|
||||||
|
public var bookmark: RDEPUBBookmark? { ... } // kind == .bookmark 时有值
|
||||||
|
public var highlight: RDEPUBHighlight? { ... } // kind == .highlight/.underline 时有值
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.5 RDEPUBSelection
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public struct RDEPUBSelection: Equatable {
|
||||||
|
public let bookIdentifier: String?
|
||||||
|
public let location: RDEPUBLocation
|
||||||
|
public let text: String // 选中文本
|
||||||
|
public let rangeInfo: String? // CoreText 选区范围
|
||||||
|
public let createdAt: Date
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.6 EPUBPage / EPUBChapterInfo
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public struct EPUBPage: Equatable {
|
||||||
|
public let spineIndex: Int
|
||||||
|
public let chapterIndex: Int
|
||||||
|
public let pageIndexInChapter: Int
|
||||||
|
public let totalPagesInChapter: Int
|
||||||
|
public let chapterTitle: String
|
||||||
|
public let fixedSpread: EPUBFixedSpread?
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct EPUBChapterInfo: Equatable {
|
||||||
|
public let spineIndex: Int
|
||||||
|
public let title: String
|
||||||
|
public let pageCount: Int
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.7 RDEPUBSearchResult / RDEPUBSearchMatch
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public struct RDEPUBSearchResult: Equatable {
|
||||||
|
public let keyword: String
|
||||||
|
public let matches: [RDEPUBSearchMatch]
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct RDEPUBSearchMatch: Equatable {
|
||||||
|
public let spineIndex: Int
|
||||||
|
public let progression: Double
|
||||||
|
public let previewText: String
|
||||||
|
public let rangeAnchor: RDEPUBTextRangeAnchor?
|
||||||
|
}
|
||||||
|
```
|
||||||
+448
-363
@@ -1,415 +1,500 @@
|
|||||||
# RDReaderView 架构文档
|
# ReadViewSDK 系统架构文档
|
||||||
|
|
||||||
|
> 最后更新:2026-06-22
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 1. 项目概览
|
## 1. 项目概览
|
||||||
|
|
||||||
RDReaderView 是一个 iOS 阅读器组件库(CocoaPods),提供开箱即用的 EPUB 阅读能力,并保留对纯文本翻页的支持。
|
ReadViewSDK 是一个 iOS EPUB 阅读器 SDK,支持文本重排(Reflowable)和固定布局(Fixed Layout)两种 EPUB 格式,同时兼容纯文本 (.txt) 文件。SDK 提供完整的 EPUB 解析、文本渲染、分页计算、阅读 UI 和标注管理能力。
|
||||||
|
|
||||||
- **最低 iOS 版本**:15.0(podspec 仍标注 9.0,实际 demo Podfile 要求 15.0)
|
**技术栈:**
|
||||||
- **Swift 版本**:5.0+
|
- 语言:Swift 5,最低 iOS 15.6
|
||||||
- **依赖**:ZIPFoundation(EPUB 解压)、DTCoreText(文本 EPUB 渲染)
|
- 依赖:DTCoreText(HTML→NSAttributedString)、ZIPFoundation(EPUB 解压)、SnapKit(Auto Layout)、SSAlertSwift(弹窗)
|
||||||
- **Demo 额外依赖**:SnapKit、SSAlertSwift
|
- 构建:CocoaPods,本地 pod 引用
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. 总体分层
|
## 2. 分层架构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ ReadViewDemo (Demo App) │
|
||||||
|
│ ViewController · LaunchAutomationPlan · UITests │
|
||||||
|
└──────────────────────────────┬──────────────────────────────┘
|
||||||
|
│ imports RDEpubReaderView
|
||||||
|
┌──────────────────────────────▼──────────────────────────────┐
|
||||||
|
│ EPUBUI 层 │
|
||||||
|
│ RDEPUBReaderController · Coordinators · Settings · TextPage │
|
||||||
|
│ ChapterRuntime(Loader · Store · DiskCache · PageMap) │
|
||||||
|
└──────────────────────────────┬──────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌──────────────────────────────▼──────────────────────────────┐
|
||||||
|
│ RDEpubReaderView 层 │
|
||||||
|
│ RDEpubReaderView · FlowLayout · PreloadController │
|
||||||
|
│ SpreadResolver · TapRegionHandler · PagingController │
|
||||||
|
└──────────────────────────────┬──────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌──────────────────────────────▼──────────────────────────────┐
|
||||||
|
│ EPUBTextRendering 层 │
|
||||||
|
│ TypesetterPipeline · DTCoreTextRenderer · Pagination │
|
||||||
|
│ BuildPipeline(TextBookBuilder · Cache · Sampler) │
|
||||||
|
└──────────────────────────────┬──────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌──────────────────────────────▼──────────────────────────────┐
|
||||||
|
│ EPUBCore 层 │
|
||||||
|
│ RDEPUBParser · Publication · ResourceResolver │
|
||||||
|
│ ReadingSession · WebView · JavaScriptBridge · Paginator │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 各层职责
|
||||||
|
|
||||||
|
| 层 | 职责 | 核心类 |
|
||||||
|
|---|---|---|
|
||||||
|
| **EPUBCore** | EPUB 文件解析、资源管理、WebView 渲染、JS 桥接 | `RDEPUBParser`, `RDEPUBPublication`, `RDEPUBWebView` |
|
||||||
|
| **EPUBTextRendering** | HTML→NSAttributedString 转换、排版、分页、全书构建 | `RDEPUBTextBookBuilder`, `RDEPUBCoreTextPageFrameFactory` |
|
||||||
|
| **RDEpubReaderView** | 通用翻页容器、手势识别、页面预加载、双页布局 | `RDEpubReaderView`, `RDEpubReaderFlowLayout`, `RDEpubReaderPreloadController` |
|
||||||
|
| **EPUBUI** | 阅读器控制器、协调器模式、设置面板、按需加载、磁盘缓存 | `RDEPUBReaderController`, `RDEPUBReaderPaginationCoordinator` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 核心数据流
|
||||||
|
|
||||||
|
### 3.1 打开书籍流程
|
||||||
|
|
||||||
|
```
|
||||||
|
用户点击书籍
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
RDEPUBReaderController(epubURL:delegate:persistence:) // 初始化,传入 epubURL
|
||||||
|
│
|
||||||
|
├─ viewDidLoad() → startInitialLoadIfNeeded()
|
||||||
|
│ │
|
||||||
|
│ ├─ RDEPUBReaderLoadCoordinator.startInitialLoadIfNeeded()
|
||||||
|
│ │ ├─ RDEPUBParser.parse(epubURL:) // 解压 ZIP → 解析 OPF → 构建 spine/TOC
|
||||||
|
│ │ │ └─ extractArchiveIfNeeded() // ZIPFoundation 解压到 Caches
|
||||||
|
│ │ │ └─ parseContainerRootFile() // SAX 解析 container.xml
|
||||||
|
│ │ │ └─ parseOPF() // SAX 解析 OPF (metadata/manifest/spine)
|
||||||
|
│ │ │ └─ parseTOC() // NCX 或 Navigation Document
|
||||||
|
│ │ │
|
||||||
|
│ │ ├─ RDEPUBPublication(parser:) // 创建门面对象
|
||||||
|
│ │ ├─ 恢复持久化数据(书签/高亮/位置)
|
||||||
|
│ │ └─ applyParsedPublication()
|
||||||
|
│ │
|
||||||
|
│ └─ 判断 readingProfile:
|
||||||
|
│ ├─ .textReflowable → 文本排版路径(本 SDK 核心路径)
|
||||||
|
│ ├─ .webInteractive → WebView 渲染路径
|
||||||
|
│ └─ .webFixedLayout → 固定布局路径
|
||||||
|
│
|
||||||
|
└─ runtime.paginatePublication() // 进入分页流程
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 文本重排分页流程(大书优化路径)
|
||||||
|
|
||||||
|
```
|
||||||
|
paginateTextPublication()
|
||||||
|
│
|
||||||
|
├─ restoreBookPageMapIfPossible() // 尝试从磁盘恢复完整 BookPageMap
|
||||||
|
│ └─ summaryDiskCache.readAll(keys:) // 批量读取磁盘摘要
|
||||||
|
│ └─ 如果完整 → 直接 applyBookPageMap() → 完成
|
||||||
|
│
|
||||||
|
├─ 快速打开路径:
|
||||||
|
│ ├─ loadFirstRenderableRuntimeChapter() // 加载第一个可渲染章节
|
||||||
|
│ ├─ loadInitialRuntimeChapters() // 加载窗口内相邻章节
|
||||||
|
│ ├─ makePartialPageMap() // 构建局部 BookPageMap
|
||||||
|
│ └─ applyBookPageMap() // 应用到 UI,用户可立即阅读
|
||||||
|
│
|
||||||
|
└─ RDEPUBMetadataParseWorker.start() // 后台元数据解析
|
||||||
|
│
|
||||||
|
├─ 预计算 contentHash(串行) // 读取所有章节 HTML + SHA-256
|
||||||
|
├─ readAll(keys:) 恢复已有缓存
|
||||||
|
├─ waitForReadingInteractionToSettle() // 等待用户操作冷却 0.8s
|
||||||
|
│
|
||||||
|
├─ OperationQueue (并发 N):
|
||||||
|
│ ├─ buildChapter() // 渲染单章 (HTML→NSAttrStr→分页)
|
||||||
|
│ ├─ chapterCacheKey() // 构建缓存键(复用预计算 hash)
|
||||||
|
│ ├─ RDEPUBChapterSummary 写盘 // 异步写入磁盘摘要
|
||||||
|
│ └─ 每 32 章刷新 BookPageMap // 增量更新 UI
|
||||||
|
│
|
||||||
|
└─ 最终 applyBookPageMap() // 完整页码映射
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 单章渲染管线
|
||||||
|
|
||||||
|
```
|
||||||
|
Raw HTML (从 EPUB 解压目录读取)
|
||||||
|
│
|
||||||
|
▼ [RDEPUBTextTypesetterPipeline]
|
||||||
|
├─ RDEPUBHTMLNormalizer // 去 CR、合并空行、规范化附件标记
|
||||||
|
├─ RDEPUBSemanticMarkerInjector // 注入分页语义标记 ${rd-sem-start/end}
|
||||||
|
├─ RDEPUBRenderDiagnosticsCollector // 内联 <link stylesheet>、收集图片诊断
|
||||||
|
├─ RDEPUBStyleSheetComposer // 构建 5 层 CSS (default/replace/dark/epub/user)
|
||||||
|
├─ RDEPUBFontNormalizer // 注册 @font-face 嵌入字体
|
||||||
|
├─ RDEPUBFragmentMarkerInjector // 注入 fragment 锚点标记 ${id=xxx}
|
||||||
|
│
|
||||||
|
▼ [RDEPUBDTCoreTextRenderer]
|
||||||
|
├─ DTHTMLAttributedStringBuilder // HTML → NSAttributedString
|
||||||
|
├─ applyPaginationSemantics() // 将语义标记转为NSAttributedString属性
|
||||||
|
├─ extractFragmentOffsets() // 提取 fragment ID→offset 映射
|
||||||
|
├─ normalizeReadingAttributes() // 规范化字体、行距、颜色、附件
|
||||||
|
│
|
||||||
|
▼ [RDEPUBChapterPageCounter]
|
||||||
|
├─ CTFramesetter 创建帧
|
||||||
|
├─ RDEPUBPageBreakPolicy 应用语义边界规则
|
||||||
|
│ ├─ avoidPageBreakInside // 避免块内分页
|
||||||
|
│ ├─ keepWithNext // 标题与正文不分离
|
||||||
|
│ ├─ widow/orphan 控制 // 孤行/寡行控制
|
||||||
|
│ └─ pageRelate // 微信读书跨页关联
|
||||||
|
├─ RDEPUBChapterTailNormalizer // 删除/合并空白尾页
|
||||||
|
│
|
||||||
|
▼ [RDEPUBTextBookBuilder]
|
||||||
|
└─ 组装 RDEPUBTextPage[] → RDEPUBTextChapter → RDEPUBTextBook
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 缓存架构(三级缓存)
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────┐
|
||||||
│ Demo / 宿主 App │
|
│ Tier 1: 内存缓存 (RDEPUBChapterRuntimeStore) │
|
||||||
│ ViewController → RDReaderController(demo 级路由控制器) │
|
│ ├─ chapterDataCache: [Int: RDEPUBRuntimeChapter] │
|
||||||
└───────────────────────────┬─────────────────────────────┘
|
│ ├─ pageCountCache: [CacheKey: RDEPUBRuntimePageCount] │
|
||||||
│
|
│ ├─ imageCache: NSCache<NSString, UIImage> (50 上限) │
|
||||||
┌───────────────────────────▼─────────────────────────────┐
|
│ └─ 窗口驱逐:只保留当前章节 ± windowRadius 的章节 │
|
||||||
│ EPUBUI 层(library 级读者 UI) │
|
└──────────────────────────┬──────────────────────────────┘
|
||||||
│ RDEPUBReaderController(开箱即用入口) │
|
│ miss
|
||||||
│ RDEPUBReaderConfiguration / Theme / Persistence │
|
┌──────────────────────────▼──────────────────────────────┐
|
||||||
│ TopToolView / BottomToolView / ChapterList / Settings │
|
│ Tier 2: 磁盘摘要缓存 (RDEPUBChapterSummaryDiskCache) │
|
||||||
│ RDEPUBTextContentView / RDEPUBWebContentView │
|
│ ├─ 路径: ~/Caches/RDEPUBChapterSummaryCache/{bookID}/ │
|
||||||
└───────────────────────────┬─────────────────────────────┘
|
│ ├─ 文件名: SHA256(bookID_spineIdx_renderSig_contentHash)│
|
||||||
│
|
│ ├─ 格式: JSON (RDEPUBChapterSummary) │
|
||||||
┌───────────────────────────▼─────────────────────────────┐
|
│ ├─ 写入: 异步(serial DispatchQueue) │
|
||||||
│ 翻页容器层(RDReaderView) │
|
│ └─ 读取: 同步(readAll 批量读取) │
|
||||||
│ RDReaderView(UIView,统一翻页外壳) │
|
└──────────────────────────┬──────────────────────────────┘
|
||||||
│ 4 种翻页模式:pageCurl / horizontalScroll / │
|
│ miss
|
||||||
│ verticalScroll / horizontalCoverScroll │
|
┌──────────────────────────▼──────────────────────────────┐
|
||||||
│ RDReaderFlowLayout / RDReaderContentCell │
|
│ Tier 3: 全书分页缓存 (RDEPUBTextBookCache) │
|
||||||
│ RDReaderPageChildViewController(pageCurl 页包装) │
|
│ ├─ 路径: ~/Caches/RDEPUBTextBookCache/ │
|
||||||
└───────────────────────────┬─────────────────────────────┘
|
│ ├─ 格式: NSSecureCoding archive │
|
||||||
│
|
│ ├─ 内容: 每章的 pageRanges + breakReasons + semanticHints│
|
||||||
┌───────────────────────────▼─────────────────────────────┐
|
│ └─ 失效: schemaVersion 变更时全部失效 │
|
||||||
│ EPUBCore 层(EPUB 引擎) │
|
└─────────────────────────────────────────────────────────┘
|
||||||
│ │
|
|
||||||
│ Publication 层 │
|
|
||||||
│ RDEPUBParser(+Archive / +Package / +TOC / …) │
|
|
||||||
│ RDEPUBPublication(出版物聚合对象) │
|
|
||||||
│ RDEPUBModels(metadata / manifest / spine 模型) │
|
|
||||||
│ RDEPUBReadingModels(location / viewport / highlight) │
|
|
||||||
│ │
|
|
||||||
│ Services 层 │
|
|
||||||
│ RDEPUBResourceResolver(资源 URL 统一入口) │
|
|
||||||
│ RDEPUBPreferences(展示参数聚合) │
|
|
||||||
│ RDEPUBPaginator(离屏分页服务) │
|
|
||||||
│ │
|
|
||||||
│ Navigator 层 │
|
|
||||||
│ RDEPUBReadingSession(状态机 + 会话协调) │
|
|
||||||
│ RDEPUBNavigatorState(状态枚举) │
|
|
||||||
│ RDEPUBNavigatorLayoutContext │
|
|
||||||
│ │
|
|
||||||
│ Resource View 层 │
|
|
||||||
│ RDEPUBWebView(+Configuration / +Reflowable / │
|
|
||||||
│ +FixedLayout / +JavaScriptBridge) │
|
|
||||||
│ RDEPUBResourceURLSchemeHandler(ss-reader:// 协议) │
|
|
||||||
│ RDEPUBStyleSheetBuilder / RDEPUBJavaScriptBridge │
|
|
||||||
│ RDEPUBFixedLayoutTemplate / RDEPUBAssetRepository │
|
|
||||||
│ RDEPUBRenderRequest │
|
|
||||||
└───────────────────────────┬─────────────────────────────┘
|
|
||||||
│
|
|
||||||
┌───────────────────────────▼─────────────────────────────┐
|
|
||||||
│ EPUBTextRendering 层(文本 EPUB 渲染) │
|
|
||||||
│ RDEPUBTextRenderer(协议) │
|
|
||||||
│ RDEPUBDTCoreTextRenderer(DTCoreText 实现) │
|
|
||||||
│ RDEPUBTextRendererSupport / RDEPUBTextPaginationSupport │
|
|
||||||
│ RDEPUBTextBookBuilder │
|
|
||||||
└──────────────────────────────────────────────────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
### 缓存键设计
|
||||||
|
|
||||||
## 3. 翻页容器层(RDReaderView)
|
|
||||||
|
|
||||||
### 3.1 四种翻页模式
|
|
||||||
|
|
||||||
| 模式 | 实现方式 | 特点 |
|
|
||||||
|------|----------|------|
|
|
||||||
| `pageCurl` | UIPageViewController | 原生翻书效果,手势由系统提供 |
|
|
||||||
| `horizontalScroll` | UICollectionView + RDReaderFlowLayout | 每屏显示 2 项,水平分页滚动 |
|
|
||||||
| `verticalScroll` | UICollectionView + RDReaderFlowLayout | 全宽项目,垂直连续滚动 |
|
|
||||||
| `horizontalCoverScroll` | UICollectionView + RDReaderFlowLayout | 覆盖滚动效果,Z 轴动画 |
|
|
||||||
|
|
||||||
### 3.2 数据源协议
|
|
||||||
|
|
||||||
```swift
|
```swift
|
||||||
public protocol RDReaderDataSource: NSObjectProtocol {
|
struct RDEPUBChapterCacheKey: Hashable {
|
||||||
func pageCountOfReaderView(readerView: RDReaderView) -> Int
|
let bookID: String // 书籍唯一标识
|
||||||
func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView
|
let spineIndex: Int // 章节索引
|
||||||
func pageIdentifier(readerView: RDReaderView, pageNum: Int) -> String?
|
let renderSignature: String // 渲染参数签名(字体/字号/行距/布局/版本)
|
||||||
@objc optional func topToolView(readerView: RDReaderView) -> UIView?
|
let chapterContentHash: String // 章节 HTML 的 SHA-256
|
||||||
@objc optional func bottomToolView(readerView: RDReaderView) -> UIView?
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.3 手势分区(scroll 模式)
|
缓存键的四元组设计确保:
|
||||||
|
- 换字体/字号 → renderSignature 变化 → 缓存失效
|
||||||
屏幕水平三等分:
|
- EPUB 内容更新 → contentHash 变化 → 缓存失效
|
||||||
- **左 1/3**:上一页
|
- 不同书籍 → bookID 不同 → 互不干扰
|
||||||
- **中 1/3**:显示 / 隐藏工具栏
|
|
||||||
- **右 1/3**:下一页
|
|
||||||
|
|
||||||
### 3.4 翻页模式切换
|
|
||||||
|
|
||||||
`switchReaderDisplayType(_ type:)` 会完全销毁并重建底层 view(pageViewController 或 collectionView),然后重新加载数据。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. EPUB 引擎层(EPUBCore)
|
## 5. ReaderController 组合架构
|
||||||
|
|
||||||
### 4.1 Publication 层:解析与聚合
|
|
||||||
|
|
||||||
**主链路:**
|
|
||||||
|
|
||||||
```
|
```
|
||||||
epubURL
|
RDEPUBReaderController
|
||||||
→ RDEPUBParser.parse(epubURL:)
|
│
|
||||||
→ extractArchive # ZIP 解压到沙盒临时目录
|
├─ RDEPUBReaderContext(过渡门面)
|
||||||
→ parseContainerXML # 定位 OPF 路径
|
│ ├─ RDEPUBReaderState
|
||||||
→ parseOPF # 解析 metadata / manifest / spine
|
│ │ ├─ parser / publication / readingSession
|
||||||
→ parseTOC # 解析 NCX 或 Nav 目录
|
│ │ ├─ textBook / bookPageMap / pendingFullPageMap
|
||||||
→ RDEPUBPublication(parser:) # 聚合解析结果,挂载 resourceResolver
|
│ │ ├─ activeBookmarks / activeHighlights / searchState
|
||||||
|
│ │ └─ currentSelection / paginationToken / snapshot
|
||||||
|
│ │
|
||||||
|
│ ├─ RDEPUBReaderEnvironment
|
||||||
|
│ │ ├─ viewport / safeArea / traitCollection 抽象
|
||||||
|
│ │ ├─ brightness / fallbackViewportSize
|
||||||
|
│ │ └─ renderStyle / layoutConfig 推导
|
||||||
|
│ │
|
||||||
|
│ └─ RDEPUBReaderServices
|
||||||
|
│ ├─ parser / paginator / builder factory
|
||||||
|
│ ├─ renderer factory
|
||||||
|
│ └─ chapter summary disk cache factory
|
||||||
|
│
|
||||||
|
└─ RDEPUBReaderRuntime(兼容门面)
|
||||||
|
├─ load / pagination / location / search / chrome / annotation coordinator
|
||||||
|
├─ RDEPUBPresentationRuntime
|
||||||
|
├─ RDEPUBChapterWarmupOrchestrator
|
||||||
|
└─ 旧 API 转发
|
||||||
```
|
```
|
||||||
|
|
||||||
**RDEPUBPublication 暴露的能力:**
|
当前 `RDEPUBReaderContext` 仍存在,但主要作用已经收缩为过渡门面:
|
||||||
|
|
||||||
| 属性 / 方法 | 说明 |
|
- 对外维持兼容访问面
|
||||||
|------------|------|
|
- 对内把纯状态、环境推导、工厂依赖拆开
|
||||||
| `metadata` | 书名、作者、语言、layout 等 |
|
- 避免新逻辑继续把 `Context` 当作全能对象扩散
|
||||||
| `manifest` | id → RDEPUBManifestItem 映射 |
|
|
||||||
| `spine` | 有序 spine 列表(包含 href) |
|
|
||||||
| `tableOfContents` | 树形目录 |
|
|
||||||
| `layout` | `.reflowable` 或 `.fixed` |
|
|
||||||
| `readingProfile` | `.webInteractive` / `.webFixedLayout` / `.textReflowable` |
|
|
||||||
| `resourceResolver` | 统一资源 URL 解析入口 |
|
|
||||||
| `fixedLayoutSpreadEnabled(for:viewportSize:)` | 判断是否启用双页 spread |
|
|
||||||
| `makeFixedSpreads(preferences:viewportSize:)` | 生成 fixed spread 页模型 |
|
|
||||||
|
|
||||||
**readingProfile 判定逻辑:**
|
---
|
||||||
|
|
||||||
|
## 6. 章节按需加载与分页窗口架构
|
||||||
|
|
||||||
```
|
```
|
||||||
layout == .fixed → webFixedLayout
|
RDEPUBReaderController
|
||||||
layout == .reflowable + 含交互脚本 → webInteractive
|
│
|
||||||
layout == .reflowable + 无交互脚本 → textReflowable
|
├─ RDEPUBReaderPaginationCoordinator // 分页入口与后台元数据解析
|
||||||
|
│ ├─ paginatePublication()
|
||||||
|
│ ├─ RDEPUBMetadataParseWorker.start()
|
||||||
|
│ └─ restoreBookPageMapIfPossible()
|
||||||
|
│
|
||||||
|
├─ RDEPUBPresentationRuntime // 分页状态与窗口替换
|
||||||
|
│ ├─ applyBookPageMap()
|
||||||
|
│ ├─ refreshBookPageMapInPlace()
|
||||||
|
│ └─ applyPendingFullPageMapIfNeeded()
|
||||||
|
│
|
||||||
|
├─ RDEPUBChapterWarmupOrchestrator // 按需章节预热与边界预取
|
||||||
|
│ ├─ prepareOnDemandChapter()
|
||||||
|
│ ├─ extendPartialBookPageMapIfNeeded()
|
||||||
|
│ ├─ prefetchForwardChaptersAfterInitialOpen()
|
||||||
|
│ └─ ensureNavigationTargetAvailable()
|
||||||
|
│
|
||||||
|
├─ RDEPUBChapterLoader // 章节构建与缓存门面
|
||||||
|
│ ├─ loadChapter() // 异步加载(主路径)
|
||||||
|
│ └─ loadChapterSynchronouslyForMigration()
|
||||||
|
│
|
||||||
|
└─ RDEPUBBookPageMap / RDEPUBPaginationState
|
||||||
|
├─ 当前激活窗口
|
||||||
|
├─ 待接管完整 page map
|
||||||
|
└─ 来源与切换状态
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4.2 Services 层
|
这轮整改后,跨章节主路径的关键变化是:
|
||||||
|
|
||||||
#### RDEPUBResourceResolver
|
- 普通翻页时不再要求 UI 主线程同步等待章节构建
|
||||||
|
- 章节边界会前移预热相邻章节与 lookahead 章节
|
||||||
|
- `bookPageMap` 的 partial extension 和 full replacement 统一经过 `RDEPUBPresentationRuntime`
|
||||||
|
- `RDEPUBReaderRuntime` 不再内联维护整套预热/扩窗实现
|
||||||
|
|
||||||
统一处理所有资源路径转换,是 WebView 和 Paginator 访问资源的唯一入口:
|
---
|
||||||
|
|
||||||
| 方法 | 说明 |
|
## 6. WebView 渲染架构(Web 路径)
|
||||||
|------|------|
|
|
||||||
| `fileURL(forHref:)` | href → 本地文件 URL |
|
|
||||||
| `schemeURL(forHref:)` | href → `ss-reader://` 协议 URL |
|
|
||||||
| `href(forSpineIndex:)` | spineIndex → href |
|
|
||||||
| `normalizedHref(_:)` | 相对路径标准化(统一相对 OPF) |
|
|
||||||
|
|
||||||
#### RDEPUBPaginator
|
```
|
||||||
|
RDEPUBWebView (UIView)
|
||||||
|
│
|
||||||
|
├─ WKWebView (RDEPUBAnnotationWebView)
|
||||||
|
│ ├─ 自定义选择菜单(拷贝/高亮/批注)
|
||||||
|
│ └─ 禁用系统手势(滚动手势由原生控制)
|
||||||
|
│
|
||||||
|
├─ RDEPUBResourceURLSchemeHandler
|
||||||
|
│ ├─ 协议: ss-reader://book/<path>
|
||||||
|
│ ├─ 映射: URL → 本地 EPUB 解压文件
|
||||||
|
│ └─ 容错: 缺失资源返回空响应(不 404)
|
||||||
|
│
|
||||||
|
├─ JavaScript Bridge (6 种消息)
|
||||||
|
│ ├─ ssReaderProgressionChanged // 阅读进度更新
|
||||||
|
│ ├─ ssReaderSelectionChanged // 文本选区变化
|
||||||
|
│ ├─ ssReaderInternalLink // EPUB 内部链接
|
||||||
|
│ ├─ ssReaderExternalLink // 外部链接
|
||||||
|
│ ├─ ssReaderJSError // JS 运行时错误
|
||||||
|
│ └─ ssReaderFixedLayoutReady // 固定布局加载完成
|
||||||
|
│
|
||||||
|
└─ 注入脚本
|
||||||
|
├─ epub-bridge.js (502 行) // 核心桥接,分页/滚动/选区/高亮
|
||||||
|
├─ cssInjector.js (45 行) // CSS 注入管理
|
||||||
|
├─ WeReadApi.js (67 行) // 微信读书 API 兼容层
|
||||||
|
├─ rangy-core.js / rangy-serializer.js // DOM Range 序列化
|
||||||
|
└─ CSS: default/replace/dark/latin // 4 套样式表
|
||||||
|
```
|
||||||
|
|
||||||
使用隐藏的 `WKWebView` 离屏加载每个 spine 资源,通过 JS 注入分页 CSS,回调每个资源的页数:
|
---
|
||||||
|
|
||||||
|
## 7. 翻页容器架构(RDEpubReaderView)
|
||||||
|
|
||||||
|
```
|
||||||
|
RDEpubReaderView (UIView)
|
||||||
|
│
|
||||||
|
├─ 三种翻页模式:
|
||||||
|
│ ├─ .pageCurl → UIPageViewController (翻页动画)
|
||||||
|
│ ├─ .horizontalScroll → UICollectionView (水平滑动)
|
||||||
|
│ └─ .verticalScroll → UICollectionView (垂直滚动)
|
||||||
|
│
|
||||||
|
├─ 组合对象:
|
||||||
|
│ ├─ RDEpubReaderPagingController // 翻页状态管理、请求队列
|
||||||
|
│ ├─ RDEpubReaderPreloadController // 页面预加载、缓存管理
|
||||||
|
│ ├─ RDEpubReaderSpreadResolver // 双页展开计算
|
||||||
|
│ └─ RDEpubReaderTapRegionHandler // 点击区域分类(左/中/右)
|
||||||
|
│
|
||||||
|
├─ 数据源协议:
|
||||||
|
│ ├─ RDEpubReaderPageProvider (新) // 格式无关,优先级高
|
||||||
|
│ └─ RDEpubReaderDataSource (旧) // 遗留兼容,通过 Adapter 适配
|
||||||
|
│
|
||||||
|
└─ 手势流:
|
||||||
|
点击 → TapRegionHandler → 分类(左/中/右)
|
||||||
|
├─ 左 → goPreviousPage()
|
||||||
|
├─ 右 → goNextPage()
|
||||||
|
└─ 中 → tapCenter() (切换工具栏)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 配置与设置
|
||||||
|
|
||||||
|
### RDEPUBReaderConfiguration
|
||||||
|
|
||||||
```swift
|
```swift
|
||||||
paginator.calculate(publication: publication, preferences: preferences, viewportSize: size) { pageCounts in
|
struct RDEPUBReaderConfiguration {
|
||||||
// pageCounts[i] = spine[i] 的页数
|
var fontSize: CGFloat // 默认 16
|
||||||
|
var fontChoice: FontChoice // 系统/宋体/楷体
|
||||||
|
var lineHeightMultiple: CGFloat // 默认 1.5
|
||||||
|
var numberOfColumns: Int // 1 或 2
|
||||||
|
var columnGap: CGFloat // 默认 20
|
||||||
|
var theme: ReaderTheme // 6 种主题
|
||||||
|
var displayType: RDEpubReaderView.DisplayType // pageCurl/horizontal/vertical
|
||||||
|
var onDemandChapterWindowSize: Int // 默认 3(奇数,最小 3,最大 15)
|
||||||
|
var metadataParsingConcurrency: Int // 默认 CPU 核心数
|
||||||
|
var chapterWindowRadius: Int // 内存缓存窗口半径
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### RDEPUBPreferences
|
### 持久化
|
||||||
|
|
||||||
聚合 WebView 和 Paginator 共用的展示参数:字号、行距、主题色、边距、布局适配模式、spread 模式。
|
| 数据 | 存储方式 | Key 前缀 |
|
||||||
|
|------|----------|----------|
|
||||||
### 4.3 Navigator 层
|
| 阅读位置 | UserDefaults | `ssreader.epub.location.{bookID}` |
|
||||||
|
| 书签 | UserDefaults | `ssreader.epub.bookmarks.{bookID}` |
|
||||||
#### RDEPUBNavigatorState 状态机
|
| 高亮标注 | UserDefaults | `ssreader.epub.highlights.{bookID}` |
|
||||||
|
| 全局设置 | UserDefaults | `ssreader.epub.settings` |
|
||||||
```
|
| 章节摘要 | 磁盘文件 | `~/Caches/RDEPUBChapterSummaryCache/` |
|
||||||
initializing → loading → idle ←→ jumping
|
| 全书分页 | 磁盘文件 | `~/Caches/RDEPUBTextBookCache/` |
|
||||||
←→ moving
|
|
||||||
←→ repaginating
|
|
||||||
```
|
|
||||||
|
|
||||||
| 状态 | 含义 | 允许动作 |
|
|
||||||
|------|------|----------|
|
|
||||||
| `initializing` | 刚打开书籍 | 接收初始恢复位置 |
|
|
||||||
| `loading` | 生成首轮 page model | 缓存 pending navigation |
|
|
||||||
| `idle` | 显示稳定 | 应用 staged snapshot、处理跳转、保存位置 |
|
|
||||||
| `jumping` | 目录 / 内部链接跳转中 | 重放 pending location |
|
|
||||||
| `moving` | 用户翻页中 | 更新当前 pageNum |
|
|
||||||
| `repaginating` | 重新分页中(字号/横竖屏变化) | 缓存当前位置,等待完成 |
|
|
||||||
|
|
||||||
#### RDEPUBReadingSession
|
|
||||||
|
|
||||||
会话级协调者,持有:
|
|
||||||
|
|
||||||
- `publication`:出版物只读视图
|
|
||||||
- `activePages / activeChapters`:当前展示的页模型和章节信息
|
|
||||||
- `stagedPages / stagedChapters`:后台分页完成后暂存,等待 idle 状态时应用
|
|
||||||
- `pendingNavigationLocation / pendingNavigationPageNum`:等待当前加载完成后再执行的跳转请求
|
|
||||||
- `currentViewport / currentReadingContext`:当前可见区域的位置信息
|
|
||||||
|
|
||||||
关键操作:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
session.stageSnapshot(snapshot, restoreLocation:) // 后台分页完成,暂存结果
|
|
||||||
session.consumeStagedSnapshotIfAllowed() // idle 时消费暂存(线程安全切换)
|
|
||||||
session.transition(to: .idle) // 状态跃迁
|
|
||||||
session.clearPendingNavigation() // 取消待执行跳转
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.4 Resource View 层
|
|
||||||
|
|
||||||
#### RDEPUBWebView
|
|
||||||
|
|
||||||
承载单个 spine 资源的 WKWebView,按职责拆分为 4 个扩展文件:
|
|
||||||
|
|
||||||
| 扩展 | 职责 |
|
|
||||||
|------|------|
|
|
||||||
| `+Configuration` | WKWebViewConfiguration、schemeHandler 注册、user scripts |
|
|
||||||
| `+Reflowable` | 注入分页 CSS、滚动到指定 progression、接收 JS 事件 |
|
|
||||||
| `+FixedLayout` | fixed-layout HTML wrapper 生成和加载 |
|
|
||||||
| `+JavaScriptBridge` | JS ↔ Swift 消息路由、选区、高亮、进度上报 |
|
|
||||||
|
|
||||||
#### ss-reader:// 协议
|
|
||||||
|
|
||||||
所有 spine 资源(XHTML、CSS、图片、字体)统一通过 `ss-reader://book/<relative-path>` 访问,由 `RDEPUBResourceURLSchemeHandler` 从本地解压目录读取并返回。
|
|
||||||
|
|
||||||
优点:
|
|
||||||
- 相对资源路径在 WebView 中自然解析
|
|
||||||
- fixed-layout iframe 不再白屏
|
|
||||||
- 无需 `allowingReadAccessTo` 路径权限
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. 文本 EPUB 渲染层(EPUBTextRendering)
|
## 9. 目录结构
|
||||||
|
|
||||||
适用于 `readingProfile == .textReflowable` 的书籍(纯文本小说类 EPUB2)。
|
|
||||||
|
|
||||||
### 5.1 渲染链路
|
|
||||||
|
|
||||||
```
|
```
|
||||||
章节 HTML 文件
|
Sources/RDEpubReaderView/
|
||||||
→ RDEPUBTextRendererSupport.injectFragmentMarkers(into:) # 注入 fragment 标记
|
├── EPUBCore/ # EPUB 解析与 WebView 渲染
|
||||||
→ RDEPUBDTCoreTextRenderer.renderChapter(html:baseURL:style:)
|
│ ├── Models/ # 数据模型
|
||||||
→ DTHTMLAttributedStringBuilder # HTML → NSAttributedString
|
│ │ ├── RDEPUBAnnotationModels.swift
|
||||||
→ extractFragmentOffsets # fragment → 字符偏移量映射
|
│ │ ├── RDEPUBPaginationModels.swift
|
||||||
→ normalizeReadingAttributes # 统一字体/行距
|
│ │ └── RDEPUBReadingLocationModels.swift
|
||||||
→ RDEPUBRenderedChapterContent
|
│ ├── Resources/ # JS/CSS/HTML 静态资源
|
||||||
.attributedString # 渲染后的富文本
|
│ │ ├── epub-bridge.js
|
||||||
.fragmentOffsets # fragment id → 字符偏移
|
│ │ ├── cssInjector.js
|
||||||
|
│ │ ├── WeReadApi.js
|
||||||
|
│ │ ├── rangy-core.js / rangy-serializer.js
|
||||||
|
│ │ ├── wxread-default.css / wxread-dark.css / wxread-replace-latin.css
|
||||||
|
│ │ └── epub-fixed-layout.html
|
||||||
|
│ ├── RDEPUBParser.swift # 核心解析器
|
||||||
|
│ ├── RDEPUBParser+Archive.swift # ZIP 解压
|
||||||
|
│ ├── RDEPUBParser+Package.swift # OPF 解析
|
||||||
|
│ ├── RDEPUBParser+TOC.swift # 目录解析
|
||||||
|
│ ├── RDEPUBParser+Resources.swift # 资源访问
|
||||||
|
│ ├── RDEPUBPublication.swift # 出版物门面
|
||||||
|
│ ├── RDEPUBResourceResolver.swift # URL 解析
|
||||||
|
│ ├── RDEPUBReadingSession.swift # 阅读会话状态机
|
||||||
|
│ ├── RDEPUBWebView.swift # WebView 主类
|
||||||
|
│ ├── RDEPUBWebView+*.swift # WebView 扩展(配置/重排/固定/搜索/JS桥接)
|
||||||
|
│ ├── RDEPUBPaginator.swift # 离屏分页计算器
|
||||||
|
│ ├── RDEPUBSearchEngine.swift # 全文搜索引擎
|
||||||
|
│ └── ...
|
||||||
|
│
|
||||||
|
├── EPUBTextRendering/ # 文本渲染引擎
|
||||||
|
│ ├── Typesetter/ # 排版管线
|
||||||
|
│ │ ├── RDEPUBTypesettingPipeline.swift
|
||||||
|
│ │ ├── RDEPUBHTMLNormalizer.swift
|
||||||
|
│ │ ├── RDEPUBSemanticMarkerInjector.swift
|
||||||
|
│ │ ├── RDEPUBFragmentMarkerInjector.swift
|
||||||
|
│ │ ├── RDEPUBFontNormalizer.swift
|
||||||
|
│ │ ├── RDEPUBAttachmentNormalizer.swift
|
||||||
|
│ │ ├── RDEPUBStyleSheetComposer.swift
|
||||||
|
│ │ ├── RDEPUBRenderDiagnosticsCollector.swift
|
||||||
|
│ │ └── RDEPUBTextRendererSupport.swift
|
||||||
|
│ ├── Pagination/ # 分页引擎
|
||||||
|
│ │ ├── RDEPUBChapterPageCounter.swift
|
||||||
|
│ │ ├── RDEPUBCoreTextPageFrameFactory.swift
|
||||||
|
│ │ ├── RDEPUBPageBreakPolicy.swift
|
||||||
|
│ │ ├── RDEPUBTextLayoutFrame.swift
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── BuildPipeline/ # 全书构建
|
||||||
|
│ │ ├── RDEPUBTextBookBuilder.swift
|
||||||
|
│ │ ├── RDEPUBTextBookModels.swift
|
||||||
|
│ │ ├── RDEPUBTextBookCache.swift
|
||||||
|
│ │ ├── RDEPUBChapterTailNormalizer.swift
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── RDEPUBTextRenderer.swift # 渲染器协议与类型定义
|
||||||
|
│ ├── RDEPUBDTCoreTextRenderer.swift # DTCoreText 渲染器实现
|
||||||
|
│ ├── RDEPUBChapterData.swift # 章节查询门面
|
||||||
|
│ ├── RDEPUBTextIndexTable.swift # 全书索引表
|
||||||
|
│ └── RDEpubPlainTextBookBuilder.swift # 纯文本 (.txt) 构建器
|
||||||
|
│
|
||||||
|
├── ReaderView/ # 通用翻页容器
|
||||||
|
│ ├── Paging/ # 翻页子系统
|
||||||
|
│ │ ├── RDEpubReaderPagingController.swift
|
||||||
|
│ │ ├── RDEpubReaderPreloadController.swift
|
||||||
|
│ │ ├── RDEpubReaderSpreadResolver.swift
|
||||||
|
│ │ └── RDEpubReaderTapRegionHandler.swift
|
||||||
|
│ ├── RDEpubReaderView.swift # 主容器视图
|
||||||
|
│ ├── RDEpubReaderView+*.swift # 扩展(CollectionView/PageCurl/ToolView/ContentAccess)
|
||||||
|
│ ├── RDEpubReaderFlowLayout.swift # 自定义 CollectionView 布局
|
||||||
|
│ ├── RDEpubReaderContentCell.swift # 滚动模式 Cell
|
||||||
|
│ └── RDEpubReaderPageChildViewController.swift // 翻页模式子 VC
|
||||||
|
│
|
||||||
|
└── EPUBUI/ # 阅读器 UI
|
||||||
|
├── ReaderController/ # 控制器与协调器
|
||||||
|
│ ├── ChapterRuntime/ # 章节运行时
|
||||||
|
│ │ ├── RDEPUBChapterLoader.swift
|
||||||
|
│ │ ├── RDEPUBChapterRuntimeStore.swift
|
||||||
|
│ │ ├── RDEPUBChapterSummaryDiskCache.swift
|
||||||
|
│ │ ├── RDEPUBBookPageMap.swift
|
||||||
|
│ │ ├── RDEPUBPageResolver.swift
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── RDEPUBReaderContext.swift // 共享状态中心
|
||||||
|
│ ├── RDEPUBReaderRuntime.swift // 运行时协调器(Facade)
|
||||||
|
│ ├── RDEPUBReaderDependencies.swift // 依赖注入
|
||||||
|
│ ├── RDEPUBReaderLoadCoordinator.swift // 加载协调器
|
||||||
|
│ ├── RDEPUBReaderPaginationCoordinator.swift // 分页协调器
|
||||||
|
│ ├── RDEPUBReaderLocationCoordinator.swift // 位置协调器
|
||||||
|
│ ├── RDEPUBReaderSearchCoordinator.swift // 搜索协调器
|
||||||
|
│ ├── RDEPUBReaderChromeCoordinator.swift // 工具栏协调器
|
||||||
|
│ ├── RDEPUBReaderAnnotationCoordinator.swift // 标注协调器
|
||||||
|
│ ├── RDEPUBReaderAssemblyCoordinator.swift // 组装协调器
|
||||||
|
│ ├── RDEPUBReaderViewportMonitor.swift // 视口监控
|
||||||
|
│ └── ...
|
||||||
|
├── Settings/ # 设置面板
|
||||||
|
│ ├── RDEPUBReaderConfiguration.swift # 配置模型
|
||||||
|
│ ├── RDEPUBReaderSettings.swift # 持久化设置
|
||||||
|
│ ├── RDEPUBReaderSettingsViewController.swift # 设置面板 VC
|
||||||
|
│ ├── RDEPUBReaderTheme.swift # 主题定义
|
||||||
|
│ └── ...
|
||||||
|
├── TextPage/ # 文本页面渲染
|
||||||
|
│ ├── RDEPUBTextContentView.swift # 文本内容视图(CoreText)
|
||||||
|
│ ├── RDEPUBTextSelectionController.swift # 文本选择控制器
|
||||||
|
│ ├── RDEPUBPageInteractionController.swift # 点击交互控制器
|
||||||
|
│ ├── RDEPUBSelectionOverlayView.swift # 选区覆盖层
|
||||||
|
│ ├── RDEPUBTextAnnotationOverlay.swift # 标注覆盖层
|
||||||
|
│ ├── RDEPUBTextPageRenderView.swift # CoreText 绘制视图
|
||||||
|
│ └── ...
|
||||||
|
├── RDEPUBReaderController.swift # 主控制器(+ContentDelegates/DataSource/PublicAPI 等扩展)
|
||||||
|
├── RDEPUBReaderDelegate.swift # 公开委托协议
|
||||||
|
├── RDEPUBReaderPersistence.swift # 持久化协议
|
||||||
|
└── ...
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5.2 分页
|
|
||||||
|
|
||||||
```
|
|
||||||
RDEPUBTextBookBuilder.buildBook(publication:style:pageSize:renderer:)
|
|
||||||
→ 逐章节调用 renderer.renderChapter(...)
|
|
||||||
→ RDEPUBTextPaginationSupport.paginate(attributedString:pageSize:)
|
|
||||||
→ RDEPUBTextBook(章节 + 页模型 + fragment 索引)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.3 内容显示
|
|
||||||
|
|
||||||
`RDEPUBTextContentView` 基于 `NSAttributedString` + `UITextView`(或 DTCoreText 自定义绘制),实现:
|
|
||||||
- 每页显示对应字符范围的内容
|
|
||||||
- 高亮重叠渲染
|
|
||||||
- 支持按 fragment 偏移量跳转
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. EPUBUI 层(开箱即用读者 UI)
|
## 10. 关键设计模式
|
||||||
|
|
||||||
`RDEPUBReaderController` 是 library 层提供的完整读者入口,调用方只需传入 EPUB 文件 URL:
|
| 模式 | 应用场景 |
|
||||||
|
|
||||||
```swift
|
|
||||||
let controller = RDEPUBReaderController(
|
|
||||||
epubURL: url,
|
|
||||||
configuration: .default,
|
|
||||||
persistence: RDEPUBReaderPersistence(storageKey: "my-book")
|
|
||||||
)
|
|
||||||
controller.delegate = self
|
|
||||||
present(controller, animated: true)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.1 内置能力
|
|
||||||
|
|
||||||
| 能力 | 对应文件 |
|
|
||||||
|------|----------|
|
|------|----------|
|
||||||
| 顶部工具栏(书名、返回、目录、高亮入口) | RDEPUBReaderTopToolView |
|
| **Coordinator** | 分页/标注/搜索/导航各一个协调器,解耦 Controller |
|
||||||
| 底部工具栏(进度条、页码) | RDEPUBReaderBottomToolView |
|
| **Facade** | `RDEPUBPublication` 封装 `RDEPUBParser`,`RDEPUBChapterData` 封装章节查询 |
|
||||||
| 目录面板 | RDEPUBReaderChapterListController |
|
| **Builder** | `RDEPUBBookPageMap.Builder` 增量构建页码映射 |
|
||||||
| 高亮管理 | RDEPUBReaderHighlightsViewController |
|
| **Strategy** | `RDEPUBTextRenderer` 协议,可替换渲染器实现 |
|
||||||
| 设置面板(字号、行距、主题、翻页模式) | RDEPUBReaderSettingsViewController |
|
| **Pipeline** | `RDEPUBTextTypesetterPipeline` 排版管线(8 个逻辑阶段,封装为 5-6 个顶层调用) |
|
||||||
| 阅读位置持久化 | RDEPUBReaderPersistence |
|
| **Adapter** | `RDEpubReaderLegacyDataSourceAdapter` 适配旧数据源协议 |
|
||||||
|
| **三级缓存** | 内存 → 磁盘摘要 → 全书分页,逐级降级 |
|
||||||
### 6.2 配置项(RDEPUBReaderConfiguration)
|
| **Token 取消** | `paginationToken` 确保过期异步任务不干扰新任务 |
|
||||||
|
| **Frozen Parameters** | 后台任务冻结 `renderSignature`,避免运行中参数漂移 |
|
||||||
| 属性 | 默认值 | 说明 |
|
|
||||||
|------|--------|------|
|
|
||||||
| `fontSize` | 15 | 字号(pt) |
|
|
||||||
| `lineHeightMultiple` | 1.6 | 行距倍数 |
|
|
||||||
| `displayType` | `.pageCurl` | 翻页模式 |
|
|
||||||
| `landscapeDualPageEnabled` | `true` | 横屏双页 |
|
|
||||||
| `showsTableOfContents` | `true` | 是否显示目录入口 |
|
|
||||||
| `allowsHighlights` | `true` | 是否显示高亮入口 |
|
|
||||||
| `showsSettingsPanel` | `true` | 是否显示设置入口 |
|
|
||||||
| `theme` | `.light` | 主题 |
|
|
||||||
| `fixedLayoutSpreadMode` | `.automatic` | fixed-layout spread 模式 |
|
|
||||||
| `textRenderingEngine` | `.dtCoreText` | 文本 EPUB 渲染引擎 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 关键数据流
|
|
||||||
|
|
||||||
### 7.1 打开 EPUB 书籍
|
|
||||||
|
|
||||||
```
|
|
||||||
RDEPUBReaderController.init(epubURL:)
|
|
||||||
→ viewDidLoad
|
|
||||||
→ RDEPUBParser.parse(epubURL:)
|
|
||||||
→ RDEPUBPublication(parser:)
|
|
||||||
→ RDEPUBReadingSession(publication:)
|
|
||||||
→ readingProfile 判断渲染路径
|
|
||||||
textReflowable:
|
|
||||||
RDEPUBTextBookBuilder.buildBook(...) # 后台分页
|
|
||||||
→ session.stageSnapshot(...)
|
|
||||||
→ session.consumeStagedSnapshotIfAllowed() # idle 时应用
|
|
||||||
→ readerView.reloadData()
|
|
||||||
webInteractive / webFixedLayout:
|
|
||||||
RDEPUBPaginator.calculate(...) # 离屏 WebView 分页
|
|
||||||
→ session.stageSnapshot(...)
|
|
||||||
→ readerView.reloadData()
|
|
||||||
→ persistence.restoreLocation() # 恢复上次阅读位置
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.2 翻页
|
|
||||||
|
|
||||||
```
|
|
||||||
用户手势(tap / swipe)
|
|
||||||
→ RDReaderView 检测手势分区 / 翻页方向
|
|
||||||
→ delegate.pageNum(readerView:pageNum:)
|
|
||||||
→ RDEPUBReaderController 根据 pageNum 找 EPUBPage
|
|
||||||
→ RDEPUBReadingSession.transition(to: .moving)
|
|
||||||
→ readerView.pageContentView(pageNum:) 回调
|
|
||||||
→ 创建 RDEPUBWebContentView 或 RDEPUBTextContentView
|
|
||||||
→ loadPage(spineIndex: pageIndexInChapter: preferences:)
|
|
||||||
→ JS bridge 上报 progression → session.currentViewport 更新
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.3 定位模型(RDEPUBLocation)
|
|
||||||
|
|
||||||
EPUB 是可重排内容,字号 / 横竖屏变化会使页号失效。定位模型使用 `href + progression`:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
struct RDEPUBLocation: Codable, Equatable {
|
|
||||||
var bookIdentifier: String? // 隔离不同书的进度
|
|
||||||
var href: String // OPF 相对路径(对应 spine 资源)
|
|
||||||
var progression: Double // 视口起始位置 [0, 1]
|
|
||||||
var lastProgression: Double? // 视口末尾位置 [0, 1]
|
|
||||||
var fragment: String? // 锚点
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**恢复流程:**
|
|
||||||
|
|
||||||
1. 标准化 `href`(相对 OPF)
|
|
||||||
2. 找到对应 spineIndex
|
|
||||||
3. 由 `navigationProgression` 估算扁平页号
|
|
||||||
4. 若有 `fragment`,加载后在 WebView 中锚点滚动
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 已知限制与后续待办
|
|
||||||
|
|
||||||
| 问题 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| Demo 层 RDReaderController 过大 | 约 1900+ 行,仍混有 EPUB 加载、UI 管理、数据源逻辑,待进一步拆分 |
|
|
||||||
| RDEPUBParser.swift 仍偏大 | 约 900 行,虽已拆出多个扩展文件,OPF metadata 细节仍集中 |
|
|
||||||
| 横竖屏切换已接入一阶段支持 | `RDEPUBReaderController.viewWillTransition()` 统一接管正文重分页,`RDReaderView` 保留容器级双页布局刷新;仍需补齐固定回归矩阵 |
|
|
||||||
| 固定手势分区比例 | 三等分固定写死,不支持自定义 |
|
|
||||||
| 自动化测试为首批接入状态 | 已有 `ReadViewSDKDemoTests` / `ReadViewSDKDemoUITests` 与 parser / resolver / persistence / smoke 用例,分页、恢复链路和更多 UI 闭环仍需继续补齐 |
|
|
||||||
| 基线验证待跑 | 四本样书(凡人修仙传、爱忘事熊爷爷、宝山辽墓、张学良传)的分页耗时、目录命中率、末页事件等数据尚未收集 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 构建与依赖
|
|
||||||
|
|
||||||
### 9.1 安装
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd RDReaderDemo
|
|
||||||
pod install
|
|
||||||
# 打开 RDReaderDemo.xcworkspace,选 RDReaderDemo scheme,构建
|
|
||||||
```
|
|
||||||
|
|
||||||
### 9.2 主要依赖
|
|
||||||
|
|
||||||
| 依赖 | 用途 | 使用层 |
|
|
||||||
|------|------|--------|
|
|
||||||
| ZIPFoundation | EPUB ZIP 解压 | RDEPUBParser+Archive |
|
|
||||||
| DTCoreText | HTML → NSAttributedString | RDEPUBDTCoreTextRenderer |
|
|
||||||
| SnapKit | Demo 布局 | Demo 层 |
|
|
||||||
| SSAlertSwift | Demo 弹窗 | Demo 层 |
|
|
||||||
|
|
||||||
### 9.3 podspec 关键配置
|
|
||||||
|
|
||||||
- `s.source_files = 'Sources/RDReaderView/**/*.{swift}'`(递归包含所有子目录)
|
|
||||||
- `s.resource_bundles`:包含 JS、CSS 等资源文件
|
|
||||||
- Library 本身无需 demo 层依赖
|
|
||||||
|
|||||||
@@ -0,0 +1,411 @@
|
|||||||
|
# ReadViewSDK 代码审查报告
|
||||||
|
|
||||||
|
> 审查范围:`Sources/RDEpubReaderView/` 全部 184 个 Swift 文件(约 32,300 行)
|
||||||
|
> 审查维度:并发安全、内存管理、API 正确性、Swift/iOS 平台特定、架构与依赖
|
||||||
|
> 审查日期:2026-06-26
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 严重程度定义
|
||||||
|
|
||||||
|
| 标记 | 含义 |
|
||||||
|
|------|------|
|
||||||
|
| 🔴 高 | 有明确代码路径可导致崩溃、数据丢失或严重功能缺陷 |
|
||||||
|
| 🟡 中 | 方向正确但风险程度取决于运行时条件;或当前安全但架构上脆弱 |
|
||||||
|
| 🟢 低 | 代码异味、可维护性问题或未来风险 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔴 高优先级问题
|
||||||
|
|
||||||
|
### H-01 · 内存警告未连接章节缓存清理
|
||||||
|
|
||||||
|
`RDEPUBChapterRuntimeStore.handleMemoryWarning()` 已实现(清空除当前章节外的缓存 + 清除图片缓存),但从未被调用。
|
||||||
|
|
||||||
|
- `RDEPUBReaderRuntime.handleMemoryWarning()` 只清理了 `backgroundCoverageStore`,未调用 `chapterRuntimeStore.handleMemoryWarning()`
|
||||||
|
- `RDEPUBReaderController` 未重写 `didReceiveMemoryWarning`
|
||||||
|
|
||||||
|
**影响:** 系统内存压力下,`RDEPUBChapterDataCache`(无上限字典)和 `imageCache`(仅 countLimit=50,无 totalCostLimit)不会主动释放内存。
|
||||||
|
|
||||||
|
**位置:**
|
||||||
|
- `RDEPUBChapterRuntimeStore.swift:103` — `handleMemoryWarning()` 方法存在
|
||||||
|
- `RDEPUBReaderRuntime.swift:606-620` — 只清理 backgroundCoverageStore
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H-02 · 主线程信号量等待
|
||||||
|
|
||||||
|
`RDEPUBChapterLoader.loadChapterSynchronouslyForMigration()` 使用 `DispatchSemaphore.wait()` 阻塞调用线程。当从 `RDEPUBPresentationRuntime.rebindVisibleLocation()` 以 `allowSynchronousLoad: true` 调用时,会在主线程等待。
|
||||||
|
|
||||||
|
**影响:** 如果章节数据未缓存,主线程被阻塞直到章节构建完成,可能导致 ANR 或死锁(如果构建过程需要主线程资源)。
|
||||||
|
|
||||||
|
**位置:**
|
||||||
|
- `RDEPUBChapterLoader.swift:206-268` — `semaphore.wait()`
|
||||||
|
- `RDEPUBPresentationRuntime.swift:282-286` — `allowSynchronousLoad: true`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H-03 · asyncAfter 闭包中的强制解包
|
||||||
|
|
||||||
|
`RDEPUBReaderController+DataSource.swift:425-436` 在 0.3 秒延迟闭包中强制解包 `lastTextPaginationPageSize!`。方法入口的 `guard let lastTextPaginationPageSize` 不保护延迟闭包——0.3 秒后该值可能已变为 `nil`。
|
||||||
|
|
||||||
|
**位置:**
|
||||||
|
```swift
|
||||||
|
// line 432-433
|
||||||
|
let stillChanged = abs(currentSize.width - self.lastTextPaginationPageSize!.width) > 0.5
|
||||||
|
|| abs(currentSize.height - self.lastTextPaginationPageSize!.height) > 0.5
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H-04 · `unowned context` 在后台操作中的崩溃路径
|
||||||
|
|
||||||
|
以下两个类在后台操作中访问 `unowned let context: RDEPUBReaderContext`,若 controller 在操作期间释放则会崩溃:
|
||||||
|
|
||||||
|
**RDEPUBMetadataParseWorker:**
|
||||||
|
- Line 117: `let context = self.context` 在 `[weak self]` guard 之前将 unowned 引用捕获为局部变量
|
||||||
|
- Lines 197-324: `BlockOperation` 闭包直接使用该局部变量(unowned),无 `[weak self]` 保护
|
||||||
|
- Lines 296-301, 372-376: `DispatchQueue.main.async` 闭包捕获 unowned 局部变量
|
||||||
|
|
||||||
|
**RDEPUBReaderLoadCoordinator:**
|
||||||
|
- Line 28: 闭包只捕获 `[weak controller]`,未捕获 `[weak self]`
|
||||||
|
- Line 30: `self.context.makeParser()` 在后台队列访问 unowned 引用
|
||||||
|
- Lines 41-42, 53-54: `DispatchQueue.main.async` 闭包直接访问 `self.context`
|
||||||
|
|
||||||
|
**影响:** 若 `RDEPUBReaderController` 在后台操作期间释放(如用户快速退出阅读器),`unowned` 引用悬挂导致 EXC_BAD_ACCESS。
|
||||||
|
|
||||||
|
**位置:**
|
||||||
|
- `RDEPUBMetadataParseWorker.swift:117, 197-324, 296-301, 372-376`
|
||||||
|
- `RDEPUBReaderLoadCoordinator.swift:28-57`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H-05 · 章节数据缓存无容量限制
|
||||||
|
|
||||||
|
`RDEPUBChapterDataCache` 是无上限的 `[Int: RDEPUBRuntimeChapter]` 字典,仅通过显式 `remove(spineIndex:)` 或 `removeAll()` 逐出。每章持有完整的 `typesetAttributedString`(50-200KB+),50 章可达 5-10MB+。
|
||||||
|
|
||||||
|
**位置:** `RDEPUBChapterDataCache.swift:5` — `private var storage: [Int: RDEPUBRuntimeChapter] = [:]`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H-06 · 暗色图片静态 NSCache 无 totalCostLimit
|
||||||
|
|
||||||
|
`RDEPUBDarkImageAdjuster.imageCache` 是 static `NSCache<NSString, UIImage>()`,无 `totalCostLimit`,跨越整个 App 生命周期累积调整后的图片。
|
||||||
|
|
||||||
|
**位置:** `RDEPUBDarkImageAdjuster.swift:9`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H-07 · 持久化操作静默丢弃错误
|
||||||
|
|
||||||
|
`RDEPUBReaderPersistence` 中所有编解码操作均使用 `try?`,失败时:
|
||||||
|
- **保存**:静默返回,数据丢失无任何反馈(lines 84, 98, 112, 131)
|
||||||
|
- **加载**:返回 `nil` 或空数组,损坏/版本不匹配的数据无法恢复(lines 80, 94, 108, 127)
|
||||||
|
|
||||||
|
**影响:** 用户书签和阅读位置可能在无声中丢失。
|
||||||
|
|
||||||
|
**位置:** `RDEPUBReaderPersistence.swift:76-131`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H-08 · WKURLSchemeHandler 线程违规与取消回调缺失
|
||||||
|
|
||||||
|
两个确定 bug:
|
||||||
|
|
||||||
|
**(a) 不同队列回调:** `webView(_:start:)` 在某个串行队列(由 WebKit 调度)上被调用,但 `respondWithInMemoryData` 和 `respondWithStreaming` 将 `didReceive`/`didFinish`/`didFailWithError` 显式切到了 `ioQueue`(line 121, 151)。这至少违反了"回调应与 `start` 使用同一串行队列"的要求。
|
||||||
|
|
||||||
|
**(b) 取消时不调用完成回调:** 任务被 `stop` 后,`isTaskActive` 返回 false 时:
|
||||||
|
- streaming 路径(line 178):`guard self.isTaskActive(taskID) else { return }` — 仅 return,不调用 `didFinish` 或 `didFailWithError`
|
||||||
|
- in-memory 路径(line 128, 141):同理
|
||||||
|
|
||||||
|
这违反了"每个 started task 必须收到完成回调"的协议要求。
|
||||||
|
|
||||||
|
**位置:** `RDEPUBResourceURLSchemeHandler.swift:121-187`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H-09 · 搜索在后台线程同步加载所有章节
|
||||||
|
|
||||||
|
`RDEPUBReaderSearchCoordinator` 在 `searchQueue` 上遍历所有 buildable spine 索引,逐个同步加载章节。每章持有完整 `typesetAttributedString`,大型书籍搜索时内存峰值显著。
|
||||||
|
|
||||||
|
**位置:** `RDEPUBReaderSearchCoordinator.swift:47, 173-236`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H-10 · 依赖版本未锁定
|
||||||
|
|
||||||
|
`RDEpubReaderView.podspec` 中 DTCoreText、SnapKit、SSAlertSwift 无版本约束:
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
s.dependency 'ZIPFoundation', '~> 0.9' # ✅ 已锁定
|
||||||
|
s.dependency 'DTCoreText' # ❌ 无约束
|
||||||
|
s.dependency 'SnapKit' # ❌ 无约束
|
||||||
|
s.dependency 'SSAlertSwift' # ❌ 无约束
|
||||||
|
```
|
||||||
|
|
||||||
|
**位置:** `RDEpubReaderView.podspec:17-20`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H-11 · SnapKit 和 SSAlertSwift 疑似死依赖
|
||||||
|
|
||||||
|
全仓库搜索未找到任何 `snp.` 或 `SSAlert` 的使用点。这两个依赖可能增加了不必要的二进制体积和潜在冲突。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟡 中优先级问题
|
||||||
|
|
||||||
|
### M-01 · `RDEPUBReaderState` 无同步保护(隐性风险)
|
||||||
|
|
||||||
|
所有属性(`parser`, `publication`, `activeBookmarks`, `pendingPageMapUpdates` 等)均为 `var`,无锁、无 `@MainActor`、无 `dispatchPrecondition`。当前所有访问确实在主线程(经追踪 16 个 `pendingPageMapUpdates` 访问点确认),但缺少形式化保障——未来开发者可能无意从后台线程访问而不自知。
|
||||||
|
|
||||||
|
**位置:** `RDEPUBReaderState.swift:1-71`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M-02 · `currentSpineIndex` / `windowSpineIndices` 无锁但当前安全
|
||||||
|
|
||||||
|
这两个字段无锁保护,但追踪全部读写路径后确认当前均在主线程。风险是隐性的:store 对象被传入 `chapterLoadQueue` 上下文,未来开发者可能在该队列中访问这些属性而不自知。同文件的其他字段(`navigationLock`, `prefetchLock`, `buildingLock`, `cfiMapLock`)有锁保护,形成不一致的模式。
|
||||||
|
|
||||||
|
**位置:** `RDEPUBChapterRuntimeStore.swift:15-17`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M-03 · `warmAnchors` 跨线程访问无同步
|
||||||
|
|
||||||
|
`RDEPUBBackgroundPriorityManager.warmAnchors` 在主线程写入(`addWarmAnchor`),在 `MetadataParseWorker` 的后台线程读取(`makeMetadataPriorityOrder`),无锁保护。
|
||||||
|
|
||||||
|
**位置:** `RDEPUBBackgroundPriorityPolicy.swift:65`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M-04 · `assert(Thread.isMainThread)` 在 Release 中被移除
|
||||||
|
|
||||||
|
`RDEPUBReaderContext` 的 `makeLayoutSnapshot()`、`currentTextPageSize()` 等方法使用 `assert(Thread.isMainThread)` 检查。`assert` 在 `-O` 构建中被移除,生产环境中后台线程调用将静默访问 UIKit 属性,造成数据竞争且无诊断。
|
||||||
|
|
||||||
|
**应使用:** `dispatchPrecondition(condition: .onQueue(.main))` 或 `@MainActor` 标注。
|
||||||
|
|
||||||
|
**位置:** `RDEPUBReaderContext.swift:172, 188, 210, 215`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M-05 · 磁盘缓存无自动淘汰策略
|
||||||
|
|
||||||
|
`RDEPUBChapterSummaryDiskCache` 和 `RDEPUBTextBookCache` 只有全量清除操作(`removeAll()`、`invalidateAll()`),无 LRU/大小/年龄驱逐策略。每次设置变更(字体、行距、主题)产生新缓存键,旧文件无限累积。写操作使用原子替换(tmp + replace),交错不会损坏数据,但可能读到旧值或看到不一致的目录状态。
|
||||||
|
|
||||||
|
**位置:**
|
||||||
|
- `RDEPUBChapterSummaryDiskCache.swift:89` — `removeAll()` 全清
|
||||||
|
- `RDEPUBTextBookCache.swift:191` — `invalidateAll()` 全清
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M-06 · CFI 范围分隔符可能与内容冲突
|
||||||
|
|
||||||
|
`RDEPUBCFICompatibility` 按 `".."` 和 `"-"` 分割 CFI 范围字符串,但这些字符可能出现在 CFI 文本断言内部(如 `[foo..bar]`)。代码先匹配 `".."` 再匹配 `"-"`,无歧义消解逻辑。
|
||||||
|
|
||||||
|
**位置:** `RDEPUBCFICompatibility.swift:18-21`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M-07 · CFI 步索引对奇数输入容错不严谨
|
||||||
|
|
||||||
|
`RDEPUBCFIResolver` 中 `steps[1].index / 2 - 1` 对奇数索引做整除截断,可能映射到错误章节。`max(..., 0)` 仅防负索引,不防错误映射。属于 EPUB CFI 规范的输入验证问题。
|
||||||
|
|
||||||
|
**位置:** `RDEPUBCFIResolver.swift:38`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M-08 · WKWebView 保留环需主动管理
|
||||||
|
|
||||||
|
`RDEPUBWebView` → `webView` → `configuration.userContentController` → `RDEPUBWebView`(作为 `WKScriptMessageHandler`)形成保留环。
|
||||||
|
|
||||||
|
主要解环路径是 `reset()` 和 `configureWebViewIfNeeded` 中主动调用 `teardownWebView()`。**注意:** `deinit` 虽也调用 `teardownWebView()`,但不能作为环会被打破的保证——保留环的存在意味着 `deinit` 可能永远不会触发。风险在于:若某条代码路径跳过了 `teardownWebView()`,环将持续且对象永远不会释放。
|
||||||
|
|
||||||
|
**位置:**
|
||||||
|
- `RDEPUBWebView.swift:129` — `deinit` 调用 `teardownWebView()`(不可靠兜底)
|
||||||
|
- `RDEPUBWebView.swift:138-160` — `reset()` 调用 `teardownWebView()`(主要解环路径)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M-09 · `configuration` didSet 在 `isViewLoaded` 之前持久化
|
||||||
|
|
||||||
|
`RDEPUBReaderController.configuration` 的 `didSet` 在 `guard isViewLoaded` 之前执行了 `readerContext.configuration = configuration`、`applyWebViewDebugPolicy()` 和 `persistReaderSettingsIfNeeded()`。若在 view 加载前设置 configuration,这些副作用会在 UI 未就绪时触发。
|
||||||
|
|
||||||
|
**位置:** `RDEPUBReaderController.swift:8-20`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M-10 · empty catch 吞掉章节加载错误
|
||||||
|
|
||||||
|
`RDEPUBReaderPaginationCoordinator` 中 `loadInitialInteractiveRuntimeChapters` 的 `catch {}` 为空,章节加载失败被静默忽略,无日志、无恢复。
|
||||||
|
|
||||||
|
**位置:** `RDEPUBReaderPaginationCoordinator.swift:283`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M-11 · `RDEPUBReaderFontChoice.displayName` 始终返回中文
|
||||||
|
|
||||||
|
枚举的 `displayName` 计算属性硬编码中文字符串("系统"、"宋体"、"圆体"、"等宽"),无本地化基础设施。
|
||||||
|
|
||||||
|
**位置:** `RDEPUBReaderConfiguration.swift:18-28`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M-12 · 大量硬编码中文字符串,本地化覆盖不完整
|
||||||
|
|
||||||
|
覆盖文件:`RDEPUBReaderSettingsViewController`、`RDEPUBReaderSearchBarView`、`RDEPUBReaderHighlightsViewController`、`RDEPUBReaderBookmarksViewController`、`RDEPUBReaderTopToolView`、`RDEPUBReaderBottomToolView`、`RDEPUBTextContentView`、`RDEPUBWebView+Configuration`、`RDEPUBChapterLoader`(错误信息)等。
|
||||||
|
|
||||||
|
项目中存在约 88 个硬编码中文字符串(如"拷贝"、"高亮"、"批注"、"目录"、"搜索"等),但同时也有使用 `NSLocalizedString` 的地方(如 `RDEPUBImageViewController.swift:126`)。本地化基础设施存在但覆盖不完整——新添加的 UI 文本大多直接硬编码中文而未走本地化流程。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M-13 · DTCoreText 类型泄漏到 EPUBUI 层
|
||||||
|
|
||||||
|
5 个 EPUBUI 文件直接使用 `DTCoreTextLayoutFrame`、`DTCoreTextLayoutLine`、`DTCoreTextGlyphRun` 等 DTCoreText 类型(虽有 `#if canImport(DTCoreText)` 保护),意味着替换渲染引擎需修改 UI 层文件。
|
||||||
|
|
||||||
|
**位置:**
|
||||||
|
- `RDEPUBPageInteractionController.swift`
|
||||||
|
- `RDEPUBPageLayoutSnapshot.swift`
|
||||||
|
- `RDEPUBTextPageRenderView.swift`
|
||||||
|
- `RDEPUBTextContentView.swift`
|
||||||
|
- `RDEPUBDarkImageAdjuster.swift`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M-14 · `imageCache` 有 countLimit 但无 totalCostLimit
|
||||||
|
|
||||||
|
`RDEPUBChapterRuntimeStore.imageCache` 设有 `countLimit = 50`,但无 `totalCostLimit`。50 张大尺寸书籍插图可达 150-250MB。
|
||||||
|
|
||||||
|
**位置:** `RDEPUBChapterRuntimeStore.swift:41`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M-15 · `UIMenuController` 已废弃
|
||||||
|
|
||||||
|
`RDEPUBWebView+Configuration.swift:39` 和 `RDEPUBTextContentView.swift:747-758` 使用 `UIMenuController`,该 API 从 iOS 16 起废弃。应迁移至 `UIEditMenuInteraction`(iOS 16+),并为 iOS 15 保留 fallback。当前代码在 iOS 17+ 上仍可工作但随时可能失效。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M-16 · Dynamic Type 未支持
|
||||||
|
|
||||||
|
UI 中所有字体使用硬编码大小(`UIFont.systemFont(ofSize: 13/14/16/18/19)`),未使用 `UIFontMetrics` 或 `preferredFont(forTextStyle:)`。启用 Dynamic Type 的用户看不到文字大小变化。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M-17 · URL Scheme Handler 取消时 `clearTask` 可能被跳过
|
||||||
|
|
||||||
|
`respondWithInMemoryData` line 128 的 `guard self.isTaskActive(taskID) else { return }` 在取消时直接返回,跳过了 line 146 的 `self.clearTask(taskID)`。当前因 `stop` 已调用 `clearTask` 而无害,但结构上脆弱。
|
||||||
|
|
||||||
|
**位置:** `RDEPUBResourceURLSchemeHandler.swift:128`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M-18 · 硬编码颜色值未集成到主题系统
|
||||||
|
|
||||||
|
`RDEPUBReaderSearchBarView` 含 18 个硬编码颜色字面量,默认高亮颜色 `"#F8E16C"` 硬编码在 `RDEPUBReaderController+PublicAPI.swift:65,75`。这些应属于 `RDEPUBReaderTheme`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M-19 · 硬编码时间常量
|
||||||
|
|
||||||
|
多个 `DispatchQueue.main.asyncAfter(deadline:)` 和 `Thread.sleep` 使用硬编码延迟值(0.08s、0.1s、0.15s、0.2s、0.25s、0.3s、1.0s、3.5s),无命名常量或可配置选项。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟢 低优先级问题
|
||||||
|
|
||||||
|
### L-01 · `RDEPUBChapterLoader` 中 `[self]` 强捕获
|
||||||
|
|
||||||
|
Line 103 的 `store.chapterLoadQueue.async { [self] in` 强捕获 self,阻止 loader 在队列任务完成前释放。应改为 `[weak self]`。
|
||||||
|
|
||||||
|
**位置:** `RDEPUBChapterLoader.swift:103`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### L-02 · MetadataParseWorker Thread.sleep 忙等待
|
||||||
|
|
||||||
|
`waitForReadingInteractionToSettle()` 使用 `Thread.sleep(forTimeInterval: 0.08)` 轮询,无上限等待。应考虑 `DispatchSource` 或条件变量。
|
||||||
|
|
||||||
|
**位置:** `RDEPUBMetadataParseWorker.swift:385-390`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### L-03 · `RDEPUBTextBookCache.queue.sync` 潜在死锁
|
||||||
|
|
||||||
|
`load()` 和 `save()` 均使用 `queue.sync`。若从 queue 内部调用则会死锁。当前未发现此调用路径,但模式脆弱。
|
||||||
|
|
||||||
|
**位置:** `RDEPUBTextBookCache.swift:136-189`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### L-04 · `pendingLoads` 字典无超时清理
|
||||||
|
|
||||||
|
`RDEPUBChapterLoader.pendingLoads` 的完成闭包数组在章节加载失败且 `resolvePendingLoad` 未被调用时可能累积。当前所有路径均调用 `resolvePendingLoad`,但无防御性超时。
|
||||||
|
|
||||||
|
**位置:** `RDEPUBChapterLoader.swift:7-10`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### L-05 · `RDEPUBChapterWindowCoordinator` 是死代码
|
||||||
|
|
||||||
|
该类定义了但未被任何代码实例化或引用。其中对 `currentSpineIndex`/`windowSpineIndices` 的7个访问点均为死代码。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### L-06 · 平台版本不一致
|
||||||
|
|
||||||
|
- `podspec`: `s.platform = :ios, "15.0"`
|
||||||
|
- `Podfile`(根和 demo): `platform :ios, '15.6'`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### L-07 · `RDEPUBMetadataCancellationController` 微小竞争
|
||||||
|
|
||||||
|
`attach()` 方法在 `lock.unlock()` 和 `queue.cancelAllOperations()` 之间存在窄窗口,新操作可能被添加到队列但不会被取消。实际影响极小。
|
||||||
|
|
||||||
|
**位置:** `RDEPUBMetadataParseCancellationController.swift:attach()`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### L-08 · 页码标签硬编码偏移
|
||||||
|
|
||||||
|
`RDEPUBWebContentView` 和 `RDEPUBTextContentView` 的页码标签使用硬编码偏移(24pt, 20pt),在刘海屏设备上可能与安全区域重叠。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 已排除项
|
||||||
|
|
||||||
|
以下原报告条目经核实后排除或合并:
|
||||||
|
|
||||||
|
| 原编号 | 原描述 | 排除原因 |
|
||||||
|
|--------|--------|----------|
|
||||||
|
| #6 | compactMap 结果越界 | 入口有 `childViewControllers.count == expectedCount` 检查,compactMap 后不会少于预期数量 |
|
||||||
|
| #7 | `result!` 强解包崩溃 | 信号量等待的真实风险是无限等待/卡住,不是 result 为 nil;signal 前必定赋值 |
|
||||||
|
| #21 | 搜索协调器 fallback 空 store | `?? RDEPUBChapterRuntimeStore()` 前的可选链若为 nil 则整个调用返回 nil,fallback 不会生效 |
|
||||||
|
| #23 | init 时加载所有 HTML 到内存 | 逐章取 HTML 算哈希后只保存 digest,HTML 字符串不长期保留 |
|
||||||
|
| #35 | `.nonPersistent()` 阻止 cookie/localStorage | 不持久化是有意设计,不是 bug;原始报告将"不持久化"误述为"不能用" |
|
||||||
|
| #48 | `didMoveToSuperview` 每次重复 makeUI | 有 `didBuildUI` 防重入保护 |
|
||||||
|
| 原L-04 | `activeTasks` 字典 `clearTask` 设 nil 不删键 | Swift 中 `dict[key] = nil` 等同于 `removeValue(forKey:)`,不保留空洞 |
|
||||||
|
| 原M-18 | MetadataParseWorker OperationQueue 未在 deinit 取消 | 外层 `[weak self]` guard 确保 self 存活才执行 queue 操作;`cancellationController` 提供显式取消路径 |
|
||||||
|
| 原L-08 | `ss_superViewController` 前缀暗示外部来源 | 命名风格猜测,非可验证缺陷 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 建议优先级排序
|
||||||
|
|
||||||
|
| 优先级 | 编号 | 修复复杂度 | 说明 |
|
||||||
|
|--------|------|-----------|------|
|
||||||
|
| P0 | H-01 | 低 | 在 `handleMemoryWarning` 中加一行 `chapterRuntimeStore.handleMemoryWarning()`,并在 controller 中转发 `didReceiveMemoryWarning` |
|
||||||
|
| P0 | H-04 | 中 | MetadataParseWorker 和 LoadCoordinator 中 `unowned context` 改为 `weak`,闭包加 `guard` |
|
||||||
|
| P0 | H-03 | 低 | `lastTextPaginationPageSize!` 改为 `guard let` 安全解包 |
|
||||||
|
| P1 | H-08 | 中 | URLSchemeHandler 回调改到与 start 相同队列,取消路径加 `didFailWithError` |
|
||||||
|
| P1 | H-02 | 中 | 主线程同步加载改为异步回调或确保不从主线程调用 |
|
||||||
|
| P1 | H-07 | 低 | 关键持久化操作加日志,至少记录失败原因 |
|
||||||
|
| P1 | H-10 | 低 | 给三个依赖加版本约束;移除 SnapKit 和 SSAlertSwift |
|
||||||
|
| P2 | H-05 | 中 | 为 ChapterDataCache 加 LRU 逐出策略或最大条目数限制 |
|
||||||
|
| P2 | H-06 | 低 | 为 DarkImageAdjuster.imageCache 设置 `totalCostLimit` |
|
||||||
|
| P2 | H-09 | 中 | 搜索改为流式/分批加载,避免一次性持有所有章节 |
|
||||||
|
| P2 | M-01/02 | 中 | 为 State 和 Store 关键属性加 `@MainActor` 或 `dispatchPrecondition` |
|
||||||
|
| P2 | M-04 | 低 | `assert` 改为 `dispatchPrecondition` 或 `precondition` |
|
||||||
|
| P2 | M-08 | 低 | 保留环加注释说明必须通过 `reset()` 打破 |
|
||||||
|
| P2 | M-15 | 中 | 迁移至 `UIEditMenuInteraction` |
|
||||||
@@ -0,0 +1,620 @@
|
|||||||
|
# ReadViewSDK 业务逻辑文档
|
||||||
|
|
||||||
|
> 最后更新:2026-06-09
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. EPUB 解析流程
|
||||||
|
|
||||||
|
### 1.1 文件解压
|
||||||
|
|
||||||
|
**入口:** `RDEPUBParser.parse(epubURL:)`
|
||||||
|
|
||||||
|
EPUB 文件本质是 ZIP 压缩包。解压流程:
|
||||||
|
|
||||||
|
1. 计算缓存目录:`~/Library/Caches/ssreaderview-epub/{slug}-{fileSize}-{modifiedTimestamp}/`
|
||||||
|
2. 如果缓存目录已存在且包含 `META-INF/container.xml`,跳过解压
|
||||||
|
3. 否则使用 `ZIPFoundation` 解压到缓存目录
|
||||||
|
4. 解压是幂等的,重复调用不会重复解压
|
||||||
|
|
||||||
|
### 1.2 OPF 解析
|
||||||
|
|
||||||
|
OPF(Open Packaging Format)是 EPUB 的核心描述文件。使用 SAX 解析(`XMLParser` + `XMLParserDelegate`)以降低内存占用。
|
||||||
|
|
||||||
|
**解析内容:**
|
||||||
|
|
||||||
|
| 区域 | 提取字段 |
|
||||||
|
|------|----------|
|
||||||
|
| `<metadata>` | identifier, title, author, language, version, rendition:layout, rendition:spread, readingProgression |
|
||||||
|
| `<manifest>` | id, href, media-type, properties, fallback, media-overlay |
|
||||||
|
| `<spine>` | idref, linear, properties, page-spread |
|
||||||
|
| `<spine toc="...">` | NCX 文件 id |
|
||||||
|
|
||||||
|
**Spine 构建:** 将 spine 引用映射到 manifest 条目,规范化 href 相对于 OPF 目录,解析每个条目的布局覆盖(rendition:layout-pre-paginated/reflowable)。
|
||||||
|
|
||||||
|
### 1.3 目录解析
|
||||||
|
|
||||||
|
TOC(目录)按优先级尝试三种来源:
|
||||||
|
|
||||||
|
1. **NCX(EPUB 2):** 解析 `<navPoint>` 树形结构,支持嵌套
|
||||||
|
2. **Navigation Document(EPUB 3):** 解析 `<nav epub:type="toc">` 中的 `<ol>/<li>/<a>` 结构
|
||||||
|
3. **回退:** 直接使用 spine 条目的 title 列表
|
||||||
|
|
||||||
|
### 1.4 阅读配置文件判断
|
||||||
|
|
||||||
|
```swift
|
||||||
|
enum RDEPUBReadingProfile {
|
||||||
|
case webInteractive // 通用 EPUB,通过 WebView 渲染
|
||||||
|
case webFixedLayout // 固定布局 EPUB(漫画、杂志)
|
||||||
|
case textReflowable // 文本重排,通过 CoreText 渲染(本 SDK 核心路径)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
判断逻辑(`RDEPUBParser+ReadingProfile.swift`):
|
||||||
|
- `rendition:layout == pre-paginated` → `.webFixedLayout`
|
||||||
|
- 纯 HTML/XHTML 内容且无复杂交互 → `.textReflowable`
|
||||||
|
- 其他 → `.webInteractive`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 文本渲染管线
|
||||||
|
|
||||||
|
### 2.1 HTML 预处理(Typesetter Pipeline)
|
||||||
|
|
||||||
|
`RDEPUBTextTypesetterPipeline.makeRequest(from:)` 按顺序执行 8 个阶段:
|
||||||
|
|
||||||
|
**阶段 1:HTML 规范化** (`RDEPUBHTMLNormalizer`)
|
||||||
|
- 删除 `<hr lang="zh-CN">分页符</hr>` 分页标记
|
||||||
|
- CR → LF,合并连续空行
|
||||||
|
- 规范化附件 HTML 标记:
|
||||||
|
- `div.qrbodyPic / div.bodyPic` → 合并样式到 `<img>`
|
||||||
|
- `img.qqreader-footnote` → 行内 1em×1em
|
||||||
|
- `h1.frontCover > img` → 封面图片 100% 宽度
|
||||||
|
|
||||||
|
**阶段 2:语义标记注入** (`RDEPUBSemanticMarkerInjector`)
|
||||||
|
- 遍历所有 HTML 标签,维护开标签栈
|
||||||
|
- 为有分页语义的标签注入标记:
|
||||||
|
- `${rd-sem-start:id=X;block=...;hints=...;placement=...}`
|
||||||
|
- `${rd-sem-end:id=X}`
|
||||||
|
- 空标签(img, br, hr)同时注入开始和结束标记
|
||||||
|
|
||||||
|
**阶段 3:样式表内联** (`RDEPUBRenderDiagnosticsCollector`)
|
||||||
|
- 查找 `<link rel=stylesheet href=...>`
|
||||||
|
- 读取 CSS 文件内容
|
||||||
|
- 重写 CSS 中的相对 `url()` 引用
|
||||||
|
- 内联到 HTML 的 `<head>` 中
|
||||||
|
|
||||||
|
**阶段 4:CSS 层合成** (`RDEPUBStyleSheetComposer`)
|
||||||
|
- 构建 5 层 CSS,按优先级从低到高:
|
||||||
|
1. **default** - 基础阅读器样式(隐藏 head/title/style,默认字体大小)
|
||||||
|
2. **replace** - 微信读书风格格式化(代码块、标题、引用)
|
||||||
|
3. **dark** - 暗色模式覆盖(仅暗色主题时注入)
|
||||||
|
4. **epub** - EPUB 自带样式
|
||||||
|
5. **user** - 用户设置(字号、行距、颜色,!important)
|
||||||
|
- 语言检测:检查 lang 属性和文本采样,拉丁语言使用专用 CSS
|
||||||
|
|
||||||
|
**阶段 5:字体注册** (`RDEPUBFontNormalizer`)
|
||||||
|
- 解析 `@font-face { url(...) }` 块
|
||||||
|
- 通过 `CTFontManagerRegisterFontsForURL(.process)` 注册嵌入字体
|
||||||
|
- 已注册字体路径缓存在 `registeredFontPaths` 集合中,避免重复注册
|
||||||
|
|
||||||
|
**阶段 6:Base URL 注入** (`RDEPUBHTMLNormalizer`)
|
||||||
|
- 注入 `<base href="...">` 用于相对路径解析
|
||||||
|
|
||||||
|
**阶段 7:Fragment 标记注入** (`RDEPUBFragmentMarkerInjector`)
|
||||||
|
- 查找 `<tag id="xxx">` 标签
|
||||||
|
- 在其前面注入 `${id=xxx}` 标记
|
||||||
|
|
||||||
|
**阶段 8:图片诊断收集** (`RDEPUBRenderDiagnosticsCollector`)
|
||||||
|
- 扫描 `<img src="...">` 标签
|
||||||
|
- 解析引用路径,检查文件是否存在
|
||||||
|
- 收集诊断信息用于调试
|
||||||
|
|
||||||
|
### 2.2 HTML→NSAttributedString
|
||||||
|
|
||||||
|
`RDEPUBDTCoreTextRenderer.renderChapter(request:)`:
|
||||||
|
|
||||||
|
1. 将 HTML 编码为 `Data`
|
||||||
|
2. 使用 `DTHTMLAttributedStringBuilder` 构建 `NSAttributedString`
|
||||||
|
3. 在 `willFlushCallback` 中对每个 DOM 元素调用 `RDEPUBAttachmentNormalizer.prepareHTMLElementForReaderRendering()`
|
||||||
|
4. 后处理:
|
||||||
|
- `applyPaginationSemantics()` - 将 `${rd-sem-start/end}` 标记转为 NSAttributedString 属性
|
||||||
|
- `extractFragmentOffsets()` - 提取 fragment ID→offset 映射,删除标记文本
|
||||||
|
- `normalizeReadingAttributes()` - 规范化字体、行距、颜色、附件
|
||||||
|
|
||||||
|
### 2.3 分页计算
|
||||||
|
|
||||||
|
`RDEPUBChapterPageCounter.layoutFrames()` 使用迭代循环:
|
||||||
|
|
||||||
|
```
|
||||||
|
location = 0
|
||||||
|
while location < totalLength:
|
||||||
|
1. 创建 CTFrame(通过 CTFramesetter)
|
||||||
|
2. 获取可见范围 (CTFrameGetVisibleStringRange)
|
||||||
|
3. 应用 avoidPageBreakInside 规则(最多移除 3 行尾部)
|
||||||
|
4. 应用 keepWithNext 规则(最多移除 3 行尾部)
|
||||||
|
5. 应用 widow/orphan 控制
|
||||||
|
6. 应用 pageBreakPolicy 调整
|
||||||
|
7. 记录页面范围
|
||||||
|
8. location = 调整后的范围末尾
|
||||||
|
```
|
||||||
|
|
||||||
|
**分页规则优先级:**
|
||||||
|
|
||||||
|
| 规则 | 说明 | 最大调整行数 |
|
||||||
|
|------|------|-------------|
|
||||||
|
| avoidPageBreakInside | 块内不分页(标题、图片等) | 3 行 |
|
||||||
|
| keepWithNext | 标题与正文不分离 | 3 行 |
|
||||||
|
| widow control | 段落最后一行不留到下一页 | 1 行 |
|
||||||
|
| orphan control | 段落第一行不单独在上一页 | 1 行 |
|
||||||
|
| pageRelate | 微信读书跨页关联 | 1 行 |
|
||||||
|
| attachment boundary | 块级图片前后分页 | 0(精确切分) |
|
||||||
|
|
||||||
|
### 2.4 尾页规范化
|
||||||
|
|
||||||
|
`RDEPUBChapterTailNormalizer.normalize()` 三遍处理:
|
||||||
|
|
||||||
|
1. **删除空白中间帧:** 无可见字符且无附件的帧
|
||||||
|
2. **删除空白尾部帧:** 从末尾开始删除同类空白帧
|
||||||
|
3. **合并短尾帧:** 如果最后一帧 ≤2 个可见字符且前一帧 ≥8 倍长,合并
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 后台元数据解析(大书优化)
|
||||||
|
|
||||||
|
### 3.1 整体流程
|
||||||
|
|
||||||
|
对于《凡人修仙传》这样的大书(~1000 章),后台解析是核心性能路径。
|
||||||
|
|
||||||
|
```
|
||||||
|
打开书籍
|
||||||
|
│
|
||||||
|
├─ 尝试磁盘缓存恢复(restoreBookPageMapIfPossible)
|
||||||
|
│ └─ 成功 → 直接显示完整页码
|
||||||
|
│
|
||||||
|
├─ 快速打开(3-5 章窗口)
|
||||||
|
│ ├─ 同步加载首个可渲染章节
|
||||||
|
│ ├─ 加载窗口内相邻章节
|
||||||
|
│ └─ 应用局部 BookPageMap → 用户可立即阅读
|
||||||
|
│
|
||||||
|
└─ 后台解析(RDEPUBMetadataParseWorker.start())
|
||||||
|
├─ 预计算所有章节 contentHash(串行,~1-3s)
|
||||||
|
├─ 恢复已有磁盘缓存
|
||||||
|
├─ 等待用户操作冷却 0.8s
|
||||||
|
├─ 并发渲染未缓存章节(OperationQueue,N=CPU核心数)
|
||||||
|
├─ 每 32 章增量刷新 UI
|
||||||
|
└─ 最终完整刷新
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 预计算 contentHash 优化
|
||||||
|
|
||||||
|
**问题:** 每个章节在渲染后需要构建缓存键,原始实现会重新读取 HTML 并计算 SHA-256。
|
||||||
|
|
||||||
|
**优化:** 在后台解析开始时,串行预计算所有章节的 contentHash:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
var contentHashBySpineIndex: [Int: String] = [:]
|
||||||
|
for spineIndex in allBuildableIndices {
|
||||||
|
let html = parser.htmlString(forRelativePath: href)
|
||||||
|
contentHashBySpineIndex[spineIndex] = html?.rd_sha256Hex ?? ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
后续所有 `chapterCacheKey` 调用都使用预计算值,避免重复 I/O 和 SHA-256 计算。
|
||||||
|
|
||||||
|
### 3.3 冻结 renderSignature
|
||||||
|
|
||||||
|
**问题:** 如果用户在后台解析进行中更改字号/行距,`context.currentRenderSignature()` 会返回新值,导致部分章节用旧签名、部分用新签名写入磁盘缓存,造成缓存不一致。
|
||||||
|
|
||||||
|
**解决:** 在 `RDEPUBMetadataParseWorker` 初始化时冻结签名:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
let renderSignature = context.currentRenderSignature()
|
||||||
|
// 后续所有 chapterCacheKey 调用使用此固定值
|
||||||
|
```
|
||||||
|
|
||||||
|
token 机制确保设置变更会触发新的解析任务(新 token),旧任务自动废弃。
|
||||||
|
|
||||||
|
### 3.4 锁区瘦身
|
||||||
|
|
||||||
|
**原始实现:** `resultLock` 内调用 `buildPageMap()`,该方法遍历全量 catalog 和 summaries。
|
||||||
|
|
||||||
|
**优化:** 锁内只做写入和计数,快照数据后锁外构建 pageMap:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
var snapshot: [Int: RDEPUBChapterSummary]?
|
||||||
|
resultLock.lock()
|
||||||
|
summariesBySpineIndex[spineIndex] = renderResult
|
||||||
|
totalResolvedCount += 1
|
||||||
|
if shouldRefresh {
|
||||||
|
snapshot = summariesBySpineIndex // 快照
|
||||||
|
}
|
||||||
|
resultLock.unlock()
|
||||||
|
|
||||||
|
if let snapshot {
|
||||||
|
let partialMap = buildPageMap(from: catalog, summaries: snapshot) // 锁外
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.5 可配置刷新间隔
|
||||||
|
|
||||||
|
```swift
|
||||||
|
static var pageMapRefreshInterval: Int = 32 // 默认 32 章
|
||||||
|
```
|
||||||
|
|
||||||
|
可实测调优:32 / 48 / 64。值越大,UI 刷新频率越低,后台解析吞吐越高。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 章节按需加载
|
||||||
|
|
||||||
|
### 4.1 加载优先级
|
||||||
|
|
||||||
|
`RDEPUBChapterLoader.loadChapter(spineIndex:store:)` 按以下优先级:
|
||||||
|
|
||||||
|
1. **Tier 1 内存缓存** - `store.chapterData(for: spineIndex)` → 直接返回
|
||||||
|
2. **Tier 1 页数缓存** - `store.pageCount(for: cacheKey)` → 跳过分页计算
|
||||||
|
3. **Tier 2 磁盘缓存** - `summaryDiskCache.read(for: cacheKey)` → 恢复页范围
|
||||||
|
4. **全量构建** - 渲染 + 分页 + 写缓存
|
||||||
|
|
||||||
|
### 4.2 窗口驱逐策略
|
||||||
|
|
||||||
|
`RDEPUBChapterRuntimeStore.setCurrentChapter(spineIndex:totalSpineCount:windowRadius:)`:
|
||||||
|
|
||||||
|
- 保留范围:`[spineIndex - windowRadius, spineIndex + windowRadius]`
|
||||||
|
- 窗口半径由 `configuration.chapterWindowRadius` 控制
|
||||||
|
- 超出窗口的章节从内存缓存中驱逐
|
||||||
|
- 内存警告时驱逐除当前章节外的所有缓存
|
||||||
|
|
||||||
|
### 4.3 BookPageMap 增量刷新
|
||||||
|
|
||||||
|
`RDEPUBBookPageMap` 是轻量页码映射(~100KB/1000 章),不持有 `NSAttributedString`。
|
||||||
|
|
||||||
|
Builder 模式支持增量构建:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
var builder = RDEPUBBookPageMap.Builder()
|
||||||
|
for item in catalog {
|
||||||
|
if let summary = summaries[item.spineIndex] {
|
||||||
|
builder.add(spineIndex:href:title:pageCount:fragmentOffsets:)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return builder.build()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 翻页容器逻辑
|
||||||
|
|
||||||
|
### 5.1 三种翻页模式
|
||||||
|
|
||||||
|
| 模式 | 底层实现 | 特点 |
|
||||||
|
|------|----------|------|
|
||||||
|
| pageCurl | UIPageViewController | 翻页动画,系统手势 |
|
||||||
|
| horizontalScroll | UICollectionView + 自定义 Layout | 水平滑动,分页锁定 |
|
||||||
|
| verticalScroll | UICollectionView + 自定义 Layout | 垂直滚动,连续滚动 |
|
||||||
|
|
||||||
|
### 5.2 双页展开(Landscape Dual Page)
|
||||||
|
|
||||||
|
**触发条件:** `landscapeDualPageEnabled && isLandscape && !verticalScroll`
|
||||||
|
|
||||||
|
**页面配对逻辑(RDEpubReaderSpreadResolver):**
|
||||||
|
|
||||||
|
- 有封面页:封面独占一屏,后续页面两两配对
|
||||||
|
- 配对规则:`(coverIndex, nil)`, `(1, 2)`, `(3, 4)`, ...
|
||||||
|
- 无封面页:标准偶奇配对
|
||||||
|
- 配对规则:`(0, 1)`, `(2, 3)`, `(4, 5)`, ...
|
||||||
|
|
||||||
|
**封面感知布局(RDEpubReaderFlowLayout):**
|
||||||
|
|
||||||
|
```
|
||||||
|
封面页: [====全屏宽度====]
|
||||||
|
配对页: [==半宽==][==半宽==]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 页面预加载
|
||||||
|
|
||||||
|
`RDEpubReaderPreloadController` 管理两个缓存:
|
||||||
|
- `preloadedPageViews` - 预渲染的页面视图
|
||||||
|
- `pageCurlCachedViews` - 当前显示的页面视图
|
||||||
|
|
||||||
|
**预加载策略:**
|
||||||
|
1. 每次页面变化后调用 `prime(around:preferredForward:)`
|
||||||
|
2. 计算预测目标:前后各 `radius` 个展开页 + 预测方向额外 1 个展开页
|
||||||
|
3. 将目标页面预渲染到隐藏的 `preloadHostView`
|
||||||
|
4. 缓存签名(displayType + isLandscape + pagesPerScreen + boundsSize)变化时清除缓存
|
||||||
|
|
||||||
|
### 5.4 点击区域处理
|
||||||
|
|
||||||
|
屏幕三等分:
|
||||||
|
|
||||||
|
```
|
||||||
|
[ 左 1/3 ][ 中 1/3 ][ 右 1/3 ]
|
||||||
|
上一页 切换工具栏 下一页
|
||||||
|
```
|
||||||
|
|
||||||
|
RTL 模式下左右互换。工具栏显示时,左右区域变为 `.center`(隐藏工具栏)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 标注系统
|
||||||
|
|
||||||
|
### 6.1 数据模型
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// 统一标注类型
|
||||||
|
struct RDEPUBAnnotation {
|
||||||
|
let id: String
|
||||||
|
let bookIdentifier: String?
|
||||||
|
let kind: RDEPUBAnnotationKind // .bookmark, .highlight, .underline
|
||||||
|
let location: RDEPUBLocation
|
||||||
|
let text: String?
|
||||||
|
let rangeInfo: String? // CoreText 选区范围信息
|
||||||
|
let chapterTitle: String?
|
||||||
|
let createdAt: Date
|
||||||
|
// computed: bookmark, highlight
|
||||||
|
}
|
||||||
|
|
||||||
|
// 书签
|
||||||
|
struct RDEPUBBookmark {
|
||||||
|
let id: String
|
||||||
|
let bookIdentifier: String?
|
||||||
|
let location: RDEPUBLocation
|
||||||
|
let chapterTitle: String?
|
||||||
|
let note: String?
|
||||||
|
let createdAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
// 高亮
|
||||||
|
struct RDEPUBHighlight {
|
||||||
|
let id: String
|
||||||
|
let bookIdentifier: String?
|
||||||
|
let location: RDEPUBLocation
|
||||||
|
let text: String
|
||||||
|
let style: RDEPUBHighlightStyle // .highlight, .underline
|
||||||
|
let color: String
|
||||||
|
let note: String?
|
||||||
|
let rangeInfo: String?
|
||||||
|
let createdAt: Date
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 选区处理流程
|
||||||
|
|
||||||
|
SDK 支持两条选区处理路径,分别对应 WebView 渲染和原生文本渲染:
|
||||||
|
|
||||||
|
**路径 A:WebView 渲染(webInteractive / webFixedLayout)**
|
||||||
|
|
||||||
|
```
|
||||||
|
用户长按 → WKWebView 选区变化
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
JS Bridge: ssReaderSelectionChanged
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
RDEPUBReaderAnnotationCoordinator
|
||||||
|
├─ 解析选区位置 (RDEPUBLocation)
|
||||||
|
├─ 提取选中文本
|
||||||
|
├─ 创建 RDEPUBSelection
|
||||||
|
└─ 显示操作菜单(拷贝/高亮/批注)
|
||||||
|
```
|
||||||
|
|
||||||
|
**路径 B:原生文本渲染(textReflowable)**
|
||||||
|
|
||||||
|
```
|
||||||
|
用户长按 → RDEPUBTextContentView.handleLongPress(_:)
|
||||||
|
│
|
||||||
|
├─ layoutIfNeeded() // 确保布局完成
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
RDEPUBTextSelectionController.handleLongPress(_:)
|
||||||
|
├─ RDEPUBPageInteractionController.characterIndexForViewPoint()
|
||||||
|
│ └─ CoreText CTLineGetStringIndexForPosition 字符命中
|
||||||
|
├─ 计算选区范围 (NSRange)
|
||||||
|
├─ isSelecting = true
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
用户拖拽 → handlePan(_:)
|
||||||
|
├─ 更新选区范围
|
||||||
|
├─ RDEPUBSelectionOverlayView 绘制选区高亮
|
||||||
|
└─ .ended/.cancelled/.failed → isSelecting = false(保留选区)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
RDEPUBTextContentView delegate → RDEPUBReaderAnnotationCoordinator
|
||||||
|
├─ 创建 RDEPUBSelection(含 rangeInfo)
|
||||||
|
└─ 显示操作菜单(拷贝/高亮/批注)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 高亮渲染
|
||||||
|
|
||||||
|
文本模式下,高亮通过 NSAttributedString 属性注入:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// 注入高亮属性
|
||||||
|
attributedString.addAttribute(
|
||||||
|
kRDEPUBHighlightAttributeName, // "com.rdreader.highlight"
|
||||||
|
value: highlight,
|
||||||
|
range: nsRange
|
||||||
|
)
|
||||||
|
|
||||||
|
// 注入下划线属性
|
||||||
|
attributedString.addAttribute(
|
||||||
|
kRDEPUBUnderlineAttributeName, // "com.rdreader.underline"
|
||||||
|
value: highlight,
|
||||||
|
range: nsRange
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
CoreText 渲染时识别这些自定义属性并绘制高亮背景/下划线。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 搜索系统
|
||||||
|
|
||||||
|
### 7.1 全文搜索
|
||||||
|
|
||||||
|
SDK 提供两个搜索引擎,分别服务于不同的渲染路径:
|
||||||
|
|
||||||
|
**文本渲染路径:`RDEPUBTextSearchEngine`**
|
||||||
|
|
||||||
|
`RDEPUBTextSearchEngine.search(keyword:)`:
|
||||||
|
|
||||||
|
1. 遍历 `RDEPUBTextBook.chapters` 中的所有章节
|
||||||
|
2. 对每个章节的 `attributedContent`(已渲染的 NSAttributedString)执行搜索
|
||||||
|
3. 使用 `NSString.range(of:options:.caseInsensitive)` 进行大小写不敏感搜索
|
||||||
|
4. 为每个匹配生成:
|
||||||
|
- `progression`:0.0-1.0 的阅读进度
|
||||||
|
- `previewText`:匹配位置前后各 12 字符
|
||||||
|
- `rangeAnchor`:精确的文本锚点(RDEPUBTextRangeAnchor)
|
||||||
|
|
||||||
|
**纯文本文件搜索:`RDEPUBTextSearchEngine.searchWithoutPublication(textBook:keyword:)`**
|
||||||
|
|
||||||
|
静态方法,用于 `.txt` 文件(有 TextBook 但无 Publication)的搜索:
|
||||||
|
|
||||||
|
1. 遍历 TextBook 章节的 attributedContent
|
||||||
|
2. 跳过 publication 依赖的 href 规范化
|
||||||
|
3. 不生成 rangeAnchor(纯文本无 CoreText 锚点)
|
||||||
|
|
||||||
|
**WebView 渲染路径:`RDEPUBHTMLSearchEngine`**
|
||||||
|
|
||||||
|
1. 遍历所有 linear spine 条目(html/xhtml 类型)
|
||||||
|
2. 读取 HTML,转为纯文本(正则去除标签)
|
||||||
|
3. 执行大小写不敏感搜索
|
||||||
|
4. 通过 JS Bridge 在 WebView DOM 中绘制搜索高亮
|
||||||
|
|
||||||
|
### 7.2 搜索结果导航
|
||||||
|
|
||||||
|
搜索结果通过 `RDEPUBSearchState` 管理:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
struct RDEPUBSearchState {
|
||||||
|
let keyword: String
|
||||||
|
let matches: [RDEPUBSearchMatch]
|
||||||
|
var currentMatchIndex: Int?
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**搜索结果导航流程:**
|
||||||
|
|
||||||
|
`RDEPUBReaderSearchCoordinator` 协调搜索结果的前进/后退导航:
|
||||||
|
|
||||||
|
1. `goToNextMatch()` / `goToPreviousMatch()` 更新 `currentMatchIndex`
|
||||||
|
2. 根据匹配位置计算目标页码,触发页面跳转
|
||||||
|
3. 通知 `RDEPUBReaderController` 更新搜索计数显示
|
||||||
|
|
||||||
|
**搜索结果渲染:**
|
||||||
|
|
||||||
|
- **WebView 路径:** 通过 JS Bridge 接收搜索高亮数据,在 DOM 中绘制高亮矩形
|
||||||
|
- **原生文本路径:** `RDEPUBTextContentView` 根据 `rangeAnchor` 在 CoreText 绘制层高亮匹配文本
|
||||||
|
|
||||||
|
**搜索栏生命周期:**
|
||||||
|
|
||||||
|
搜索栏采用延迟安装模式:
|
||||||
|
|
||||||
|
1. `showSearchBar()` 设置 `isSearchBarVisible = true`,仅在工具栏可见时立即安装视图
|
||||||
|
2. 若工具栏隐藏,延迟到 `handleToolViewVisibilityChanged(isVisible: true)` 时安装
|
||||||
|
3. `installSearchBarView()` 负责实际的视图层级添加、约束、动画和焦点管理
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 设置与主题
|
||||||
|
|
||||||
|
### 8.1 配置变更流程
|
||||||
|
|
||||||
|
```
|
||||||
|
用户修改设置
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
RDEPUBReaderConfiguration 更新
|
||||||
|
│
|
||||||
|
├─ 字号/字体/行距变更 → 触发重新分页
|
||||||
|
│ └─ renderSignature 变化 → 磁盘缓存失效 → 后台重新解析
|
||||||
|
│
|
||||||
|
├─ 主题变更 → 刷新可见内容
|
||||||
|
│ └─ CSS 层重建(dark/user 层)
|
||||||
|
│
|
||||||
|
├─ 显示模式变更 → 切换翻页容器
|
||||||
|
│ └─ switchReaderDisplayType()
|
||||||
|
│
|
||||||
|
└─ 列数变更 → 触发重新分页
|
||||||
|
└─ layoutConfig.cacheSignature 变化
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 主题系统
|
||||||
|
|
||||||
|
6 种内置主题,通过 `RDEPUBReaderThemeSelector` 选择:
|
||||||
|
|
||||||
|
| 主题 | 背景色 | 文字色 |
|
||||||
|
|------|--------|--------|
|
||||||
|
| 默认白 | #FFFFFF | #333333 |
|
||||||
|
| 护眼绿 | #CCE8CF | #333333 |
|
||||||
|
| 牛皮纸 | #E8D8B8 | #333333 |
|
||||||
|
| 深灰 | #333333 | #CCCCCC |
|
||||||
|
| 纯黑 | #000000 | #B4B4B6 |
|
||||||
|
| 暗蓝 | #1A2332 | #8A9BB5 |
|
||||||
|
|
||||||
|
暗色主题(深灰/纯黑/暗蓝)注入 `wxread-dark.css` 覆盖层。
|
||||||
|
|
||||||
|
### 8.3 持久化策略
|
||||||
|
|
||||||
|
设置通过 `RDEPUBReaderPersistence` 协议持久化:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
protocol RDEPUBReaderPersistence {
|
||||||
|
func saveLocation(_ location: RDEPUBLocation, for bookIdentifier: String)
|
||||||
|
func loadLocation(for bookIdentifier: String) -> RDEPUBLocation?
|
||||||
|
func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String)
|
||||||
|
func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark]
|
||||||
|
func saveHighlights(_ highlights: [RDEPUBHighlight], for bookIdentifier: String)
|
||||||
|
func loadHighlights(for bookIdentifier: String) -> [RDEPUBHighlight]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
默认实现使用 `UserDefaults`,键格式:
|
||||||
|
- 位置:`ssreader.epub.location.{bookID}`
|
||||||
|
- 书签:`ssreader.epub.bookmarks.{bookID}`
|
||||||
|
- 高亮:`ssreader.epub.highlights.{bookID}`
|
||||||
|
- 设置:`ssreader.epub.settings`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 纯文本 (.txt) 支持
|
||||||
|
|
||||||
|
`RDEpubPlainTextBookBuilder` 复用 EPUB 渲染管线处理 .txt 文件:
|
||||||
|
|
||||||
|
1. **解码:** 依次尝试 UTF-8 → GB18030 → GBK
|
||||||
|
2. **分章:** 正则匹配 `^(第[零一二三四五六七八九十百千万\d]+[章节回卷].*)$`
|
||||||
|
3. **包装 HTML:** 每行包裹 `<p>` 标签
|
||||||
|
4. **渲染+分页:** 复用 `RDEPUBTextRenderer` 和 `RDEPUBCoreTextPageFrameFactory`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 性能关键路径
|
||||||
|
|
||||||
|
### 10.1 首次打开(冷启动)
|
||||||
|
|
||||||
|
| 阶段 | 耗时占比 | 优化方向 |
|
||||||
|
|------|----------|----------|
|
||||||
|
| ZIP 解压 | ~5% | 缓存解压目录 |
|
||||||
|
| OPF 解析 | ~1% | SAX 流式解析 |
|
||||||
|
| 首章渲染 | ~15% | 快速打开路径 |
|
||||||
|
| 后台全量解析 | ~70% | 并发、缓存、预计算 |
|
||||||
|
| 磁盘 I/O | ~9% | 异步写入、批量读取 |
|
||||||
|
|
||||||
|
### 10.2 二次打开(热缓存)
|
||||||
|
|
||||||
|
| 阶段 | 耗时占比 | 说明 |
|
||||||
|
|------|----------|------|
|
||||||
|
| 磁盘摘要恢复 | ~30% | readAll 批量读取 |
|
||||||
|
| BookPageMap 构建 | ~5% | Builder 模式 |
|
||||||
|
| UI 应用 | ~65% | 刷新 collection view |
|
||||||
|
|
||||||
|
### 10.3 关键优化项
|
||||||
|
|
||||||
|
| 优化 | 收益 | 文件 |
|
||||||
|
|------|------|------|
|
||||||
|
| 预计算 contentHash | 消除每章重复 HTML 读取 + SHA-256 | RDEPUBReaderPaginationCoordinator |
|
||||||
|
| 冻结 renderSignature | 避免参数漂移导致缓存不一致 | RDEPUBReaderContext |
|
||||||
|
| 锁区瘦身 | 降低并发锁竞争 | RDEPUBReaderPaginationCoordinator |
|
||||||
|
| 可配置并发数 | 适配不同设备 | RDEPUBReaderConfiguration |
|
||||||
|
| 可配置刷新间隔 | 平衡 UI 响应和吞吐 | RDEPUBReaderPaginationCoordinator |
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
# CFI 实现问题分析报告
|
||||||
|
|
||||||
|
> 最后更新:2026-06-18
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
本报告覆盖 `EPUBCore/CFI/` 目录下全部 13 个 Swift 文件的代码审查结果。共发现 **11 个问题**,按严重程度分为高/中/低三级。
|
||||||
|
|
||||||
|
| 严重程度 | 数量 | 说明 |
|
||||||
|
|----------|------|------|
|
||||||
|
| **高** | 3 | rawValue 短路、UTF-16 混用、content path 硬编码 |
|
||||||
|
| **中** | 5 | Resolver 假设不健壮、HTML 正则解析、text assertion 转义、token 匹配宽泛、end offset 边界 |
|
||||||
|
| **低** | 3 | 重复 nilIfEmpty、线性扫描、parent 静默失败 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 高优先级
|
||||||
|
|
||||||
|
### 1. Serializer 的 rawValue 短路逻辑 — 数据一致性风险
|
||||||
|
|
||||||
|
**文件:** `RDEPUBCFISerializer.swift:5-9`、`33-36`
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public static func serialize(_ cfi: RDEPUBCFI) -> String {
|
||||||
|
if !cfi.rawValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
return cfi.rawValue // 直接返回原始值,不检查组件是否变化
|
||||||
|
}
|
||||||
|
// ... 从组件重新构建
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**问题:** `RDEPUBCFI` 同时存储了 `rawValue`(原始字符串)和解析后的组件(`packagePath`、`contentPath`、`characterOffset` 等)。如果有人修改了组件但没有清空 `rawValue`,序列化会返回**过期的旧值**。
|
||||||
|
|
||||||
|
`RDEPUBCFIGenerator` 中已经出现了这个 workaround — 先创建 `rawValue: ""` 的 CFI,序列化后再创建一个新 CFI 填入 `rawValue`。这说明开发者意识到了问题,但没有从根源修复。
|
||||||
|
|
||||||
|
**建议:** 要么移除 `rawValue` 缓存,每次都从组件重新构建;要么让 `rawValue` 成为 `computed property`,在 `packagePath`/`contentPath` 等变化时自动失效。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. UTF-16 vs Character Offset 混用
|
||||||
|
|
||||||
|
**文件:** `RDEPUBCFIDOMPathBuilder.swift:435-458`(`RDEPUBNormalizedTextIndex.tokenSamples`)与 `RDEPUBCFIDOMPathBuilder.swift:405-428`(`RDEPUBNormalizedTextIndex.init`)
|
||||||
|
|
||||||
|
**问题:** `RDEPUBNormalizedTextIndex` 内部同时维护了两种偏移体系,但没有统一:
|
||||||
|
|
||||||
|
1. **`normalizedToChapterOffsets` 数组**(`init` 中构建):通过遍历 `nsSource.length`(UTF-16 code unit 数)逐个构建,每个 UTF-16 code unit 对应一个数组条目。因此数组下标是 **UTF-16 索引**。
|
||||||
|
|
||||||
|
2. **`tokenSamples()` 返回的 offset**:使用 `Array(normalizedText)` 生成 token,`Array` 按 Swift Character 拆分,返回的 offset 是 `characters` 数组的下标——即 **Character 索引**。
|
||||||
|
|
||||||
|
3. **`chapterOffset(forNormalizedOffset:)`**:用传入的 offset 直接查表 `normalizedToChapterOffsets[offset]`,期望接收 UTF-16 索引。
|
||||||
|
|
||||||
|
当 `normalizedText` 含多 code unit 字符时,`normalizedText.count`(Character 数)< `nsSource.length`(UTF-16 code unit 数),导致 `tokenSamples` 返回的 Character 索引在查 UTF-16 索引表时越界或错位。
|
||||||
|
|
||||||
|
此外,`calibratedOffset`(`RDEPUBCFIRecoveryEngine.swift:240-279`)全程使用 `NSString`/`NSRange`(UTF-16 偏移)进行搜索和打分,这与 `tokenSamples` 的 Character 偏移体系不一致。
|
||||||
|
|
||||||
|
**影响场景:**
|
||||||
|
- 含 emoji 的书籍(如 😀,UTF-16 surrogate pair 占 2 code unit,但 Character 计为 1)
|
||||||
|
- 含 CJK 扩展 B 区汉字的古籍
|
||||||
|
- 含数学符号的教材(如 𝕏,占 2 code unit)
|
||||||
|
|
||||||
|
**建议:** 统一偏移基准。推荐全部使用 UTF-16 偏移(与 NSRange/NSAttributedString 一致),将 `tokenSamples` 改为基于 UTF-16 索引生成 token,或在 `chapterOffset(forNormalizedOffset:)` 入口处显式转换。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Generator 的 content path 硬编码
|
||||||
|
|
||||||
|
**文件:** `RDEPUBCFIGenerator.swift:17-19`
|
||||||
|
|
||||||
|
```swift
|
||||||
|
let contentPath = RDEPUBCFIPath(steps: [
|
||||||
|
RDEPUBCFIStep(index: 4), // <body> 的第 2 个元素子节点
|
||||||
|
RDEPUBCFIStep(index: 2, idAssertion: fragmentID) // 第 1 个文本节点
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
**问题:** `makeOffsetCFI` 硬编码 content path 为 `/4/2`,即假设目标文本在 `<body>` 的第 2 个元素子节点的第 1 个文本子节点中。以下情况会出错:
|
||||||
|
|
||||||
|
| 场景 | 预期 content path | 实际生成 |
|
||||||
|
|------|-------------------|----------|
|
||||||
|
| 文本直接在 `<body>` 下 | `/4/N`(N 为文本节点奇数索引) | `/4/2` ❌ |
|
||||||
|
| 文本在 3 层嵌套中 | `/4/2/2/2` | `/4/2` ❌ |
|
||||||
|
| `<body>` 前有注释节点 | 索引需要偏移 | `/4/2` ❌ |
|
||||||
|
|
||||||
|
`makeCFI` 方法允许传入自定义 `contentPath`,但 `makeOffsetCFI` 没有这个灵活性,而它是 `makeOffsetRangeCFI` 的内部依赖。
|
||||||
|
|
||||||
|
**建议:** `makeOffsetCFI` 应接受可选的 `contentPath` 参数,或从 `RDEPUBCFIMap` 中查找正确的路径。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 中优先级
|
||||||
|
|
||||||
|
### 4. Resolver 的 CFI 结构假设不健壮
|
||||||
|
|
||||||
|
**文件:** `RDEPUBCFIResolver.swift:23-40`
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public static func resolve(_ cfi: RDEPUBCFI) -> RDEPUBCFIResolverResult {
|
||||||
|
let manifestStep = cfi.packagePath.steps.last(where: { $0.idAssertion?.isEmpty == false })
|
||||||
|
let href = manifestStep?.idAssertion
|
||||||
|
|
||||||
|
let fileIndex = cfi.packagePath.steps
|
||||||
|
.dropFirst()
|
||||||
|
.last
|
||||||
|
.map { max(($0.index / 2) - 1, 0) }
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**问题 1 — manifest 步骤查找不可靠:** 用 `last(where:)` 搜索带 id assertion 的步骤。EPUB CFI 规范中 package path 结构固定:`/6`(package)→ `/2[n]`(spine)→ `/2n[manifest-id]`。应明确取第三个步骤,而非搜索。
|
||||||
|
|
||||||
|
**问题 2 — fileIndex 计算错误:** `dropFirst().last` 取的是**最后一个**步骤,不是第二个。如果 package path 有 3 个步骤(如 Fixed Layout spread),取到的是 manifest 步骤而非 spine 步骤。正确做法是 `steps.count >= 2 ? steps[1] : nil`。
|
||||||
|
|
||||||
|
**问题 3 — 可读性差:** `idAssertion?.isEmpty == false` 逻辑取反应,应该是 `idAssertion?.nilIfEmpty != nil`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. DOMPathBuilder 用正则解析 HTML — 脆弱且有边界情况
|
||||||
|
|
||||||
|
**文件:** `RDEPUBCFIDOMPathBuilder.swift:5-8`
|
||||||
|
|
||||||
|
```swift
|
||||||
|
guard let regex = try? NSRegularExpression(
|
||||||
|
pattern: #"</?\s*([A-Za-z][A-Za-z0-9:_-]*)([^>]*)>"#,
|
||||||
|
options: [.caseInsensitive]
|
||||||
|
) else { return [:] }
|
||||||
|
```
|
||||||
|
|
||||||
|
**边界情况清单:**
|
||||||
|
|
||||||
|
| 输入 | 后果 |
|
||||||
|
|------|------|
|
||||||
|
| `<!-- <div> -->` | 正则匹配注释内的 `<div>`,导致错误的子节点计数 |
|
||||||
|
| `<![CDATA[ <tag> ]]>` | 同上 |
|
||||||
|
| `<div data-value="a>b">` | 属性值中的 `>` 导致正则截断 |
|
||||||
|
| `<input checked>` | 无值属性解析正常,但 `idAttribute` 正则要求引号 |
|
||||||
|
| `<script>if (a < b) {}</script>` | `< b` 可能被匹配为标签 |
|
||||||
|
| `<br/>` vs `<br />` | `hasSuffix("/>")` 可能在属性值以 `/` 结尾时误判 |
|
||||||
|
|
||||||
|
**建议:** 考虑使用 `DTCoreText` 或 `libxml2` 进行真正的 HTML 解析。如果必须用正则,至少先移除注释和 CDATA。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. Text Assertion 解析不处理转义
|
||||||
|
|
||||||
|
**文件:** `RDEPUBCFIParser.swift:133-153`
|
||||||
|
|
||||||
|
```swift
|
||||||
|
private static func parseTextAssertion(from body: String) -> RDEPUBCFITextAssertion? {
|
||||||
|
guard let open = body.firstIndex(of: "["),
|
||||||
|
let close = body.lastIndex(of: "]"),
|
||||||
|
open < close else { return nil }
|
||||||
|
let raw = String(body[body.index(after: open)..<close])
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**问题:** EPUB CFI 规范中,text assertion 内的 `[` 和 `]` 可以用反斜杠转义(`\[` 和 `\]`)。当前实现用 `firstIndex(of: "[")` 和 `lastIndex(of: "]")` 找括号,不处理转义。
|
||||||
|
|
||||||
|
**示例:** CFI `epubcfi(/6/4[chap01]!/4/2/1:3[前缀,文\[本\],后缀])` 中,精确文本是 `文[本]`,但当前实现会错误地在 `\]` 处截断。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. Recovery Engine 的 token 匹配过于宽泛
|
||||||
|
|
||||||
|
**文件:** `RDEPUBCFIRecoveryEngine.swift:136-139`
|
||||||
|
|
||||||
|
```swift
|
||||||
|
let candidateAnchors = cfiMap.recoveryMetadata.tokenIndex.filter { anchor in
|
||||||
|
exact.contains(anchor.token) || anchor.token.contains(exact)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**问题:** 双向 `contains` 匹配会导致大量误匹配:
|
||||||
|
|
||||||
|
- `exact = "的"` 会匹配所有包含 "的" 的 token(中文中极其常见)
|
||||||
|
- `anchor.token = "这是一个很长的句子"` 如果 `exact = "是"`,也会匹配
|
||||||
|
- 英文中 `exact = "the"` 同样会匹配大量 token
|
||||||
|
|
||||||
|
**建议:**
|
||||||
|
1. 设置最小 token 长度阈值(如 ≥ 4 字符)
|
||||||
|
2. 改为前缀/后缀匹配而非任意包含
|
||||||
|
3. 要求 token 与 exact 的长度比在合理范围内(如 0.5 ~ 2.0)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. `makeOffsetRangeCFI` 的 end offset 边界
|
||||||
|
|
||||||
|
**文件:** `RDEPUBCFIGenerator.swift:78-93`
|
||||||
|
|
||||||
|
```swift
|
||||||
|
let start = makeOffsetCFI(
|
||||||
|
href: href, fileIndex: fileIndex,
|
||||||
|
chapterOffset: startOffset, // startOffset 可能为负
|
||||||
|
sideBias: .before, ...
|
||||||
|
)
|
||||||
|
let end = makeOffsetCFI(
|
||||||
|
href: href, fileIndex: fileIndex,
|
||||||
|
chapterOffset: max(startOffset, endOffset), // 基于原始 startOffset
|
||||||
|
sideBias: .after, ...
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**问题:** 如果 `startOffset` 为负数:
|
||||||
|
1. `start` 的 `chapterOffset` 被 `max(chapterOffset, 0)` clamp 到 0
|
||||||
|
2. `end` 的 `chapterOffset` 是 `max(startOffset, endOffset)`,如果 `endOffset` 也为负,结果为负数,再被 clamp 到 0
|
||||||
|
3. 最终 start 和 end 都是 0,range 退化为点
|
||||||
|
|
||||||
|
但如果 `startOffset = -5`,`endOffset = 10`:
|
||||||
|
- `start.chapterOffset` = `max(-5, 0)` = 0
|
||||||
|
- `end.chapterOffset` = `max(-5, 10)` = 10
|
||||||
|
- 这个结果是正确的
|
||||||
|
|
||||||
|
然而如果 `startOffset = 10`,`endOffset = 5`(反向 range):
|
||||||
|
- `start.chapterOffset` = 10
|
||||||
|
- `end.chapterOffset` = `max(10, 5)` = 10
|
||||||
|
- range 退化为点,丢失了反向信息
|
||||||
|
|
||||||
|
**建议:** 在入口处验证 `startOffset <= endOffset`,或支持反向 range。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 低优先级
|
||||||
|
|
||||||
|
### 9. 重复的 `nilIfEmpty` 扩展
|
||||||
|
|
||||||
|
**文件:** 4 个文件各自定义了 `private extension String { var nilIfEmpty }`:
|
||||||
|
|
||||||
|
| 文件 | 行号 |
|
||||||
|
|------|------|
|
||||||
|
| `RDEPUBCFIPath.swift` | 41-46 |
|
||||||
|
| `RDEPUBCFIParser.swift` | 172-177 |
|
||||||
|
| `RDEPUBCFIDOMPathBuilder.swift` | 90-95 |
|
||||||
|
| `RDEPUBCFITextAssertion.swift` | 18-24 |
|
||||||
|
|
||||||
|
**建议:** 提取为一个共享的 `internal` 扩展,放在单独文件或 `RDEPUBCFI.swift` 中。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 10. `RDEPUBCFIMap.marker(matching:)` 线性扫描
|
||||||
|
|
||||||
|
**文件:** `RDEPUBCFIMap.swift:30-32`
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public func marker(matching path: RDEPUBCFIPath) -> RDEPUBCFIMarker? {
|
||||||
|
markers.first { $0.cfiPath == path }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**问题:** 对于大章节(几百个文本节点),每次查找都做 O(n) 线性扫描。在恢复引擎中可能被多次调用,性能成为瓶颈。
|
||||||
|
|
||||||
|
**建议:** 在 `RDEPUBCFIMap` 初始化时构建 `[RDEPUBCFIPath: Int]` 索引字典(path → marker 数组下标),查找降为 O(1)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 11. Range 解析的 parent 静默失败
|
||||||
|
|
||||||
|
**文件:** `RDEPUBCFIParser.swift:51`
|
||||||
|
|
||||||
|
```swift
|
||||||
|
return RDEPUBCFIRange(
|
||||||
|
rawValue: rawValue,
|
||||||
|
parent: try? parse(parentRaw), // 静默忽略解析失败
|
||||||
|
start: try parse(startRaw),
|
||||||
|
end: try parse(endRaw)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**问题:** `parent` 用 `try?` 静默忽略解析失败,但 `start` 和 `end` 用 `try` 抛出。如果 parent 解析失败,`parent` 为 `nil`,后续 `canonicalRangeComponents` 会从 start/end 推导 parent,推导逻辑可能与原始 parent 不一致。
|
||||||
|
|
||||||
|
**建议:** 统一错误处理策略 — 要么全部抛出,要么全部容错。当前的混合策略会让调试困难。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录:受影响文件清单
|
||||||
|
|
||||||
|
| 文件 | 涉及问题 |
|
||||||
|
|------|----------|
|
||||||
|
| `RDEPUBCFI.swift` | #1(rawValue 存储) |
|
||||||
|
| `RDEPUBCFIParser.swift` | #6(text assertion 转义)、#11(parent 静默失败)、#9(nilIfEmpty) |
|
||||||
|
| `RDEPUBCFISerializer.swift` | #1(rawValue 短路) |
|
||||||
|
| `RDEPUBCFIPath.swift` | #9(nilIfEmpty) |
|
||||||
|
| `RDEPUBCFIResolver.swift` | #4(结构假设) |
|
||||||
|
| `RDEPUBCFIGenerator.swift` | #3(content path 硬编码)、#8(end offset 边界) |
|
||||||
|
| `RDEPUBCFIMap.swift` | #10(线性扫描) |
|
||||||
|
| `RDEPUBCFIDOMPathBuilder.swift` | #2(UTF-16/Character 偏移混用)、#5(HTML 正则解析)、#9(nilIfEmpty) |
|
||||||
|
| `RDEPUBCFIRecoveryEngine.swift` | #2(calibratedOffset 使用 UTF-16 偏移)、#7(token 匹配) |
|
||||||
|
| `RDEPUBCFITextAssertion.swift` | #9(nilIfEmpty) |
|
||||||
|
| `RDEPUBCFICompatibility.swift` | 无直接问题 |
|
||||||
|
| `RDEPUBCFIError.swift` | 无直接问题 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 修复优先级建议
|
||||||
|
|
||||||
|
```
|
||||||
|
Phase 1(高风险,影响正确性)
|
||||||
|
├── #1 rawValue 短路 → 改为 computed property 或移除缓存
|
||||||
|
├── #2 UTF-16/Character 偏移混用 → tokenSamples 用 Character 索引查 UTF-16 索引表,统一为 UTF-16 偏移
|
||||||
|
└── #3 content path 硬编码 → 从 CFIMap 查找或接受参数
|
||||||
|
|
||||||
|
Phase 2(中风险,影响健壮性)
|
||||||
|
├── #4 Resolver 假设 → 明确取固定索引步骤
|
||||||
|
├── #5 HTML 正则 → 先清理注释/CDATA,或换用 DOM 解析器
|
||||||
|
├── #6 text assertion 转义 → 实现反斜杠转义处理
|
||||||
|
├── #7 token 匹配 → 加最小长度阈值和长度比约束
|
||||||
|
└── #8 end offset → 入口验证或支持反向 range
|
||||||
|
|
||||||
|
Phase 3(低风险,代码质量)
|
||||||
|
├── #9 nilIfEmpty → 提取共享扩展
|
||||||
|
├── #10 线性扫描 → 构建索引字典
|
||||||
|
└── #11 parent 静默失败 → 统一错误处理
|
||||||
|
```
|
||||||
@@ -0,0 +1,597 @@
|
|||||||
|
# CFI 子系统文档
|
||||||
|
|
||||||
|
> 最后更新:2026-06-18
|
||||||
|
|
||||||
|
本文档详细描述 ReadViewSDK 中 EPUB CFI(Canonical Fragment Identifier)子系统的架构、数据模型、解析流程与容错机制。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. CFI 规范简介
|
||||||
|
|
||||||
|
EPUB CFI 是 EPUB 3 规范定义的标准化片段标识符,用于精确定位 EPUB 内容中的任意位置。其语法形式为:
|
||||||
|
|
||||||
|
```
|
||||||
|
epubcfi(/6/4!ch01.xhtml/4/2/1:3)
|
||||||
|
├──────┘ ├────────┘ ├──────┘ └─┘
|
||||||
|
│ │ │ └─ 字符偏移(characterOffset)
|
||||||
|
│ │ └─ 内容路径(contentPath):定位 DOM 节点
|
||||||
|
│ └─ 包路径(packagePath):定位 OPF manifest 中的资源
|
||||||
|
└─ epubcfi() 包装器
|
||||||
|
```
|
||||||
|
|
||||||
|
关键规则:
|
||||||
|
- **步进(step)**:以 `/` 分隔,偶数索引表示元素节点,奇数索引表示文本节点
|
||||||
|
- **id 断言**:`[id]` 形式,如 `/4[ch01.xhtml]`,用于增强定位鲁棒性
|
||||||
|
- **范围 CFI**:`epubcfi(/parent,/start,/end)` 三段逗号分隔,表示起止范围
|
||||||
|
- **侧偏**:`;s=b`(before)或 `;s=a`(after),指示锚点偏向
|
||||||
|
- **文本断言**:`[prefix,exact,suffix]`,用于断言定位处的文本内容
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 核心数据模型
|
||||||
|
|
||||||
|
### 2.1 RDEPUBCFI
|
||||||
|
|
||||||
|
顶层 CFI 模型,对应一个完整的 `epubcfi(...)` 字符串。
|
||||||
|
|
||||||
|
**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFI.swift`
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public struct RDEPUBCFI: Codable, Equatable, Hashable {
|
||||||
|
public var rawValue: String // 原始 CFI 字符串
|
||||||
|
public var packagePath: RDEPUBCFIPath // 包路径(定位 XHTML 文件)
|
||||||
|
public var contentPath: RDEPUBCFIPath // 内容路径(定位 DOM 节点)
|
||||||
|
public var characterOffset: Int? // 文本节点内的字符偏移
|
||||||
|
public var sideBias: RDEPUBCFISideBias? // 侧偏方向
|
||||||
|
public var textAssertion: RDEPUBCFITextAssertion? // 文本断言
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**侧偏枚举**:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public enum RDEPUBCFISideBias: String, Codable {
|
||||||
|
case before = "b" // 锚点偏向起始侧
|
||||||
|
case after = "a" // 锚点偏向结束侧
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例**:解析 `epubcfi(/6/4[ch01.xhtml]!/4/2/1:100;s=b)` 后:
|
||||||
|
- `packagePath.steps` = `[Step(index: 6), Step(index: 4, idAssertion: "ch01.xhtml")]`
|
||||||
|
- `contentPath.steps` = `[Step(index: 4), Step(index: 2), Step(index: 1)]`
|
||||||
|
- `characterOffset` = 100
|
||||||
|
- `sideBias` = `.before`
|
||||||
|
|
||||||
|
### 2.2 RDEPUBCFIPath
|
||||||
|
|
||||||
|
路径模型,由一组 `RDEPUBCFIStep` 组成,描述从根节点到目标节点的遍历序列。
|
||||||
|
|
||||||
|
**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIPath.swift`
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public struct RDEPUBCFIPath: Codable, Equatable, Hashable {
|
||||||
|
public var steps: [RDEPUBCFIStep]
|
||||||
|
|
||||||
|
// 计算两条路径的公共前缀
|
||||||
|
public func commonPrefix(with other: RDEPUBCFIPath) -> RDEPUBCFIPath
|
||||||
|
|
||||||
|
// 去除指定前缀后的剩余路径
|
||||||
|
public func droppingPrefix(_ prefix: RDEPUBCFIPath) -> RDEPUBCFIPath
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**RDEPUBCFIStep**:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public struct RDEPUBCFIStep: Codable, Equatable, Hashable {
|
||||||
|
public var index: Int // 节点索引(偶数=元素,奇数=文本)
|
||||||
|
public var idAssertion: String? // 可选的 id 断言,如 "ch01.xhtml"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`commonPrefix` 方法在范围 CFI 序列化时用于提取起止点的公共父路径;`droppingPrefix` 用于生成相对于父级的路径片段。
|
||||||
|
|
||||||
|
### 2.3 RDEPUBCFIRange
|
||||||
|
|
||||||
|
范围模型,表示文档中的一个连续区域,由父级 CFI 和起止 CFI 组成。
|
||||||
|
|
||||||
|
**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIRange.swift`
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public struct RDEPUBCFIRange: Codable, Equatable, Hashable {
|
||||||
|
public var rawValue: String // 原始范围 CFI 字符串
|
||||||
|
public var parent: RDEPUBCFI? // 公共父级(起止共享的前缀路径)
|
||||||
|
public var start: RDEPUBCFI // 起始位置
|
||||||
|
public var end: RDEPUBCFI // 结束位置
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
序列化时输出格式:`epubcfi(/parent_path,/start_terminal,/end_terminal)`,其中起止路径相对于父级路径输出。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 解析与序列化
|
||||||
|
|
||||||
|
### 3.1 RDEPUBCFIParser
|
||||||
|
|
||||||
|
将 CFI 字符串解析为结构化模型。
|
||||||
|
|
||||||
|
**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIParser.swift`
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public enum RDEPUBCFIParser {
|
||||||
|
// 解析单点 CFI
|
||||||
|
public static func parse(_ rawValue: String?) throws -> RDEPUBCFI
|
||||||
|
|
||||||
|
// 解析范围 CFI
|
||||||
|
public static func parseRange(_ rawValue: String?) throws -> RDEPUBCFIRange
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**解析流程**(`parse` 方法):
|
||||||
|
|
||||||
|
1. **去包装**:剥离 `epubcfi(...)` 外层,提取 body
|
||||||
|
2. **拆分包路径与内容路径**:以第一个 `!` 为分隔符
|
||||||
|
3. **解析路径**:按 `/` 分割为 step,每个 step 可含 `[idAssertion]`
|
||||||
|
4. **解析偏移与限定符**:从内容路径中提取 `:offset`、`;s=b/a`、`[textAssertion]`
|
||||||
|
|
||||||
|
**关键细节**:
|
||||||
|
- `firstIndexOutsideBrackets` 方法确保 `:` 分隔符在方括号外才被识别,避免与文本断言中的内容混淆
|
||||||
|
- `parseRange` 要求恰好 3 个逗号分隔部分(parent, start, end),否则抛出 `unsupportedRange` 错误
|
||||||
|
|
||||||
|
**使用示例**:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
let cfi = try RDEPUBCFIParser.parse(
|
||||||
|
"epubcfi(/6/4[ch01.xhtml]!/4/2/1:50;s=a[前缀,目标文本,后缀])"
|
||||||
|
)
|
||||||
|
print(cfi.characterOffset) // Optional(50)
|
||||||
|
print(cfi.sideBias) // Optional(.after)
|
||||||
|
print(cfi.textAssertion?.exact) // Optional("目标文本")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 RDEPUBCFISerializer
|
||||||
|
|
||||||
|
将 CFI 模型序列化为标准字符串。
|
||||||
|
|
||||||
|
**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFISerializer.swift`
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public enum RDEPUBCFISerializer {
|
||||||
|
// 序列化单点 CFI
|
||||||
|
public static func serialize(_ cfi: RDEPUBCFI) -> String
|
||||||
|
|
||||||
|
// 序列化范围 CFI
|
||||||
|
public static func serializeRange(_ range: RDEPUBCFIRange) -> String
|
||||||
|
|
||||||
|
// 序列化路径为 step 字符串
|
||||||
|
public static func serializePath(_ path: RDEPUBCFIPath) -> String
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**序列化逻辑**:
|
||||||
|
- 若 `rawValue` 非空,直接返回(避免重复序列化)
|
||||||
|
- 范围序列化先通过 `canonicalRangeComponents` 提取或推导公共父级,再分别序列化起止终端路径(相对于父级)
|
||||||
|
- 终端路径使用 `droppingPrefix` 去除与父级共享的部分
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
let cfi = RDEPUBCFIGenerator.makeOffsetCFI(
|
||||||
|
href: "chapter1.xhtml", fileIndex: 0, chapterOffset: 120
|
||||||
|
)
|
||||||
|
let serialized = RDEPUBCFISerializer.serialize(cfi)
|
||||||
|
// => "epubcfi(/6/2[chapter1.xhtml]!/4/2:120)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. DOM 解析与生成
|
||||||
|
|
||||||
|
### 4.1 RDEPUBCFIResolver
|
||||||
|
|
||||||
|
将 CFI 解析为可直接用于资源定位的结果。
|
||||||
|
|
||||||
|
**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIResolver.swift`
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public struct RDEPUBCFIResolverResult: Equatable {
|
||||||
|
public var href: String? // XHTML 文件路径(来自 idAssertion)
|
||||||
|
public var fileIndex: Int? // 文件索引
|
||||||
|
public var chapterOffset: Int? // 章节内字符偏移
|
||||||
|
public var fragmentID: String? // 片段 ID(如 #section1)
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum RDEPUBCFIResolver {
|
||||||
|
public static func resolve(_ cfi: RDEPUBCFI) -> RDEPUBCFIResolverResult
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**解析规则**:
|
||||||
|
- `href`:从 `packagePath` 中最后一个含 `idAssertion` 的 step 提取
|
||||||
|
- `fileIndex`:`packagePath` 中倒数第二个 step 的 `(index / 2) - 1`
|
||||||
|
- `fragmentID`:从 `contentPath` 中最后一个含 `idAssertion` 的 step 提取
|
||||||
|
- `chapterOffset`:直接取 `cfi.characterOffset`
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
let cfi = try RDEPUBCFIParser.parse("epubcfi(/6/4[ch01.xhtml]!/4/2[chap1]:80)")
|
||||||
|
let result = RDEPUBCFIResolver.resolve(cfi)
|
||||||
|
print(result.href) // Optional("ch01.xhtml")
|
||||||
|
print(result.fileIndex) // Optional(0)
|
||||||
|
print(result.fragmentID) // Optional("chap1")
|
||||||
|
print(result.chapterOffset) // Optional(80)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 RDEPUBCFIGenerator
|
||||||
|
|
||||||
|
从已知的章节信息生成 CFI。
|
||||||
|
|
||||||
|
**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIGenerator.swift`
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public enum RDEPUBCFIGenerator {
|
||||||
|
// 根据偏移量生成单点 CFI
|
||||||
|
public static func makeOffsetCFI(
|
||||||
|
href: String,
|
||||||
|
fileIndex: Int,
|
||||||
|
chapterOffset: Int,
|
||||||
|
fragmentID: String? = nil,
|
||||||
|
sideBias: RDEPUBCFISideBias? = nil,
|
||||||
|
textAssertion: RDEPUBCFITextAssertion? = nil
|
||||||
|
) -> RDEPUBCFI
|
||||||
|
|
||||||
|
// 根据自定义内容路径生成 CFI
|
||||||
|
public static func makeCFI(
|
||||||
|
href: String,
|
||||||
|
fileIndex: Int,
|
||||||
|
contentPath: RDEPUBCFIPath,
|
||||||
|
characterOffset: Int,
|
||||||
|
sideBias: RDEPUBCFISideBias? = nil,
|
||||||
|
textAssertion: RDEPUBCFITextAssertion? = nil
|
||||||
|
) -> RDEPUBCFI
|
||||||
|
|
||||||
|
// 生成范围 CFI(起止偏移量)
|
||||||
|
public static func makeOffsetRangeCFI(
|
||||||
|
href: String,
|
||||||
|
fileIndex: Int,
|
||||||
|
startOffset: Int,
|
||||||
|
endOffset: Int,
|
||||||
|
fragmentID: String? = nil,
|
||||||
|
startTextAssertion: RDEPUBCFITextAssertion? = nil,
|
||||||
|
endTextAssertion: RDEPUBCFITextAssertion? = nil
|
||||||
|
) -> RDEPUBCFIRange
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**路径构造规则**:
|
||||||
|
- `packagePath` 固定为 `[Step(index: 6), Step(index: (fileIndex+1)*2, idAssertion: href)]`
|
||||||
|
- `contentPath` 默认为 `[Step(index: 4), Step(index: 2, idAssertion: fragmentID)]`
|
||||||
|
- 范围 CFI 的起始点自动附加 `sideBias: .before`,结束点附加 `sideBias: .after`
|
||||||
|
|
||||||
|
### 4.3 RDEPUBCFIDOMPathBuilder
|
||||||
|
|
||||||
|
从 HTML 源码中提取带有 `id` 属性的元素路径映射。
|
||||||
|
|
||||||
|
**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIDOMPathBuilder.swift`
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public enum RDEPUBCFIDOMPathBuilder {
|
||||||
|
// 从 HTML 中提取 fragmentID -> CFIPath 映射
|
||||||
|
public static func fragmentPaths(in html: String) -> [String: RDEPUBCFIPath]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**实现细节**:
|
||||||
|
- 使用正则表达式匹配 HTML 标签,维护一个模拟 DOM 栈
|
||||||
|
- 每遇到开标签,计算子节点索引(`childIndex * 2`),生成 `RDEPUBCFIStep`
|
||||||
|
- 从标签属性中提取 `id` 或 `xml:id` 作为 `idAssertion`
|
||||||
|
- 识别并跳过 void 元素(`br`、`img`、`input` 等)和可忽略标签(`!doctype`)
|
||||||
|
- 闭标签时弹出栈顶,重置该深度的子节点计数
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 容错恢复引擎(RDEPUBCFIRecoveryEngine)
|
||||||
|
|
||||||
|
当 CFI 精确定位失败时(如 DOM 结构变更),恢复引擎通过多级降级策略尝试找到最佳匹配位置。
|
||||||
|
|
||||||
|
**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIRecoveryEngine.swift`
|
||||||
|
|
||||||
|
### 5.1 恢复置信度
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public struct RDEPUBCFIRecoveryResult: Equatable {
|
||||||
|
public enum Confidence: Int, Codable {
|
||||||
|
case exactPath // 精确路径匹配
|
||||||
|
case assertionCalibrated // 路径匹配 + 文本断言校准
|
||||||
|
case siblingRecovered // 兄弟节点恢复
|
||||||
|
case tokenRecovered // 词元索引恢复
|
||||||
|
case fragmentFallback // 片段 ID 降级
|
||||||
|
case offsetFallback // 偏移量兜底
|
||||||
|
}
|
||||||
|
public var chapterOffset: Int
|
||||||
|
public var confidence: Confidence
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 恢复策略(按优先级)
|
||||||
|
|
||||||
|
| 优先级 | 策略 | 说明 |
|
||||||
|
|--------|------|------|
|
||||||
|
| 1 | `exactPath` | 在 CFIMap 中查找精确匹配的 marker 或 pathRange |
|
||||||
|
| 2 | `assertionCalibrated` | 精确路径匹配后,使用文本断言在窗口内校准偏移 |
|
||||||
|
| 3 | `siblingRecovered` | 查找同父级的兄弟节点,通过 `siblingScore` 选择最近匹配 |
|
||||||
|
| 4 | `tokenRecovered` | 利用词元索引(token index)在章节文本中搜索匹配 |
|
||||||
|
| 5 | `fragmentFallback` | 回退到 fragment ID 对应的已知偏移量 |
|
||||||
|
| 6 | `offsetFallback` | 使用兜底偏移量 |
|
||||||
|
|
||||||
|
### 5.3 核心算法
|
||||||
|
|
||||||
|
**校准机制**(`calibratedOffset`):
|
||||||
|
- 在给定偏移量附近 ±512 字符窗口内搜索 `textAssertion.exact` 文本
|
||||||
|
- 对每个候选位置计算 `assertionScore`:距离越近分数越低,prefix/suffix 匹配则大幅加分(不匹配 +10000)
|
||||||
|
- 选取得分最低的候选位置
|
||||||
|
|
||||||
|
**兄弟恢复**(`siblingRecovered`):
|
||||||
|
- 查找与目标路径同父级、同深度的已知 marker
|
||||||
|
- 通过 `siblingSignature`(`parent_path#childIndex`)和索引距离计算相似度分数
|
||||||
|
|
||||||
|
**词元恢复**(`tokenRecovered`):
|
||||||
|
- 在预构建的 token index 中查找包含 `textAssertion.exact` 的词元
|
||||||
|
- 对每个候选锚点执行校准,选择校准距离最小的结果
|
||||||
|
|
||||||
|
**调用入口**:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
let result = RDEPUBCFIRecoveryEngine.recover(
|
||||||
|
cfi: someCFI,
|
||||||
|
cfiMap: chapterCFIMap,
|
||||||
|
chapterText: "章节纯文本...",
|
||||||
|
fragmentOffsets: ["section1": 150, "section2": 800],
|
||||||
|
fallbackOffset: 0,
|
||||||
|
lastOffset: 5000
|
||||||
|
)
|
||||||
|
// result?.confidence 反映恢复质量
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 文本断言验证(RDEPUBCFITextAssertion)
|
||||||
|
|
||||||
|
文本断言用于在 CFI 定位后验证所指位置的文本内容是否符合预期,增强定位鲁棒性。
|
||||||
|
|
||||||
|
**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFITextAssertion.swift`
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public struct RDEPUBCFITextAssertion: Codable, Equatable, Hashable {
|
||||||
|
public var prefix: String? // 目标文本之前的上下文
|
||||||
|
public var exact: String? // 精确匹配的目标文本
|
||||||
|
public var suffix: String? // 目标文本之后的上下文
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**在 CFI 中的表示**:`[prefix,exact,suffix]`,位于偏移量和侧偏之后。
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// CFI: epubcfi(/6/4[ch01.xhtml]!/4/2/1:100;s=a[这是一段,目标文本,后续内容])
|
||||||
|
let assertion = cfi.textAssertion
|
||||||
|
print(assertion?.prefix) // Optional("这是一段")
|
||||||
|
print(assertion?.exact) // Optional("目标文本")
|
||||||
|
print(assertion?.suffix) // Optional("后续内容")
|
||||||
|
```
|
||||||
|
|
||||||
|
**容错引擎中的应用**:
|
||||||
|
- `exact` 用于在窗口内搜索实际文本位置
|
||||||
|
- `prefix` 和 `suffix` 用于对候选位置评分,优先选择上下文都匹配的位置
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 兼容性处理(RDEPUBCFICompatibility)
|
||||||
|
|
||||||
|
提供宽松解析接口,兼容非标准 CFI 格式。
|
||||||
|
|
||||||
|
**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFICompatibility.swift`
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public enum RDEPUBCFICompatibility {
|
||||||
|
// 宽松解析单点 CFI(解析失败返回 nil 而非抛异常)
|
||||||
|
public static func parseLossy(_ rawValue: String?) -> RDEPUBCFI?
|
||||||
|
|
||||||
|
// 宽松解析范围 CFI
|
||||||
|
public static func parseRangeLossy(_ rawValue: String?) -> RDEPUBCFIRange?
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**范围 CFI 兼容策略**:
|
||||||
|
- 优先使用标准 `parseRange`(逗号三分段格式)
|
||||||
|
- 若失败,尝试以 `..` 或 `-` 作为分隔符拆分为两个独立 CFI 分别解析
|
||||||
|
- 支持 `epubcfi(...)-epubcfi(...)` 或 `epubcfi(...)..epubcfi(...)` 格式
|
||||||
|
|
||||||
|
**使用场景**:处理第三方生成器或旧版本导出的非标准范围标记。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. CFI 映射(RDEPUBCFIMap)
|
||||||
|
|
||||||
|
CFI 映射是章节级别的索引结构,将 CFI 路径映射到章节文本偏移量,是容错恢复引擎的核心数据源。
|
||||||
|
|
||||||
|
**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIMap.swift`
|
||||||
|
|
||||||
|
### 8.1 RDEPUBCFIMap
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public struct RDEPUBCFIMap: Codable, Equatable {
|
||||||
|
public var href: String // 章节文件路径
|
||||||
|
public var renderVersion: Int // 渲染版本号
|
||||||
|
public var domVersion: Int // DOM 版本号
|
||||||
|
public var markers: [RDEPUBCFIMarker] // CFI 路径到偏移量的标记列表
|
||||||
|
public var textAssertions: [String: RDEPUBCFITextAssertion] // 文本断言缓存
|
||||||
|
public var pathRanges: [RDEPUBCFIPathRange] // 路径范围列表
|
||||||
|
public var recoveryMetadata: RDEPUBCFIRecoveryMetadata // 恢复元数据
|
||||||
|
|
||||||
|
// 精确匹配 marker
|
||||||
|
public func marker(matching path: RDEPUBCFIPath) -> RDEPUBCFIMarker?
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 RDEPUBCFIMarker
|
||||||
|
|
||||||
|
每个 marker 记录一个 CFI 路径对应的章节信息:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public struct RDEPUBCFIMarker: Codable, Equatable {
|
||||||
|
public var cfiPath: RDEPUBCFIPath // CFI 路径
|
||||||
|
public var chapterOffset: Int? // 章节内字符偏移
|
||||||
|
public var fragmentID: String? // 片段 ID
|
||||||
|
public var textNodeLength: Int? // 文本节点长度
|
||||||
|
public var textNodeChecksum: UInt64? // 文本节点校验和(FNV-1a 64)
|
||||||
|
public var normalizedTextPreview: String? // 归一化文本预览(前 24 字符)
|
||||||
|
public var domSiblingSignature: String? // DOM 兄弟签名
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 RDEPUBCFIPathRange
|
||||||
|
|
||||||
|
描述一个 CFI 路径对应的文本偏移范围:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public struct RDEPUBCFIPathRange: Codable, Equatable {
|
||||||
|
public var cfiPath: RDEPUBCFIPath // CFI 路径
|
||||||
|
public var startOffset: Int // 范围起始偏移
|
||||||
|
public var endOffset: Int // 范围结束偏移
|
||||||
|
public var textNodeLength: Int // 文本节点长度
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.4 RDEPUBCFIRecoveryMetadata
|
||||||
|
|
||||||
|
恢复元数据,为容错引擎提供多维度恢复依据:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public struct RDEPUBCFIRecoveryMetadata: Codable, Equatable {
|
||||||
|
public var domFingerprint: String // DOM 指纹(SHA-256)
|
||||||
|
public var normalizedTextChecksum: String // 归一化文本校验和
|
||||||
|
public var tokenIndex: [RDEPUBCFITokenAnchor] // 词元锚点索引
|
||||||
|
public var fragmentPathMap: [String: RDEPUBCFIPath] // fragment -> 路径映射
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.5 RDEPUBCFITokenAnchor
|
||||||
|
|
||||||
|
词元锚点,用于基于内容的恢复:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public struct RDEPUBCFITokenAnchor: Codable, Equatable {
|
||||||
|
public var token: String // 采样词元(12 字符窗口,96 步长,最多 64 个)
|
||||||
|
public var occurrence: Int // 该词元的出现次数
|
||||||
|
public var chapterOffset: Int // 对应的章节偏移
|
||||||
|
public var cfiPath: RDEPUBCFIPath // 对应的 CFI 路径
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.6 映射构建流程
|
||||||
|
|
||||||
|
`RDEPUBCFITextNodeMapBuilder.makeMap` 方法负责构建完整映射:
|
||||||
|
|
||||||
|
1. **归一化文本**:解码 HTML 实体、合并连续空白、统一空白字符
|
||||||
|
2. **提取 fragment 路径**:通过 `RDEPUBCFIDOMPathBuilder.fragmentPaths` 获取 id -> path 映射
|
||||||
|
3. **构建 markers**:逐标签遍历 HTML,维护 DOM 栈,为每个文本节点创建 marker
|
||||||
|
4. **构建 pathRanges**:将 markers 转换为偏移范围列表
|
||||||
|
5. **构建恢复元数据**:生成 DOM 指纹、文本校验和、词元锚点索引
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 错误类型(RDEPUBCFIError)
|
||||||
|
|
||||||
|
**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIError.swift`
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public enum RDEPUBCFIError: Error, Equatable {
|
||||||
|
case empty // 输入为空或空白
|
||||||
|
case invalidWrapper(String) // 缺少 epubcfi() 包装
|
||||||
|
case invalidPath(String) // 路径格式无效(不以 / 开头)
|
||||||
|
case unsupportedRange(String) // 范围格式不正确(非三段逗号分隔)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 错误 | 触发条件 |
|
||||||
|
|------|----------|
|
||||||
|
| `empty` | 输入为 `nil`、空字符串或纯空白 |
|
||||||
|
| `invalidWrapper` | 未以 `epubcfi(` 开头或未以 `)` 结尾 |
|
||||||
|
| `invalidPath` | 路径部分非空且不以 `/` 开头 |
|
||||||
|
| `unsupportedRange` | 范围 CFI 的逗号分隔部分不等于 3 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 使用场景与调用链
|
||||||
|
|
||||||
|
### 10.1 保存阅读位置
|
||||||
|
|
||||||
|
```
|
||||||
|
用户翻页
|
||||||
|
→ RDEPUBCFIGenerator.makeOffsetCFI(href, fileIndex, chapterOffset)
|
||||||
|
→ RDEPUBCFISerializer.serialize(cfi)
|
||||||
|
→ 存储 epubcfi(...) 字符串
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.2 恢复阅读位置
|
||||||
|
|
||||||
|
```
|
||||||
|
加载 epubcfi(...) 字符串
|
||||||
|
→ RDEPUBCFIParser.parse(rawValue)
|
||||||
|
→ RDEPUBCFIResolver.resolve(cfi)
|
||||||
|
→ 获取 href, fileIndex, chapterOffset
|
||||||
|
→ 若偏移量无效,进入恢复引擎
|
||||||
|
→ RDEPUBCFIRecoveryEngine.recover(cfi, cfiMap, chapterText, ...)
|
||||||
|
→ 依次尝试 exactPath → sibling → token → fragment → offset
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.3 高亮选中文本
|
||||||
|
|
||||||
|
```
|
||||||
|
用户选择文本范围
|
||||||
|
→ 获取 start/end DOM 位置
|
||||||
|
→ RDEPUBCFIGenerator.makeOffsetRangeCFI(href, fileIndex, startOffset, endOffset)
|
||||||
|
→ RDEPUBCFISerializer.serializeRange(range)
|
||||||
|
→ 存储范围 CFI
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.4 章节加载时构建索引
|
||||||
|
|
||||||
|
```
|
||||||
|
章节 HTML 加载完成
|
||||||
|
→ RDEPUBCFITextNodeMapBuilder.makeMap(href, rawHTML, chapterText, fragmentOffsets)
|
||||||
|
→ 生成 RDEPUBCFIMap(markers + pathRanges + recoveryMetadata)
|
||||||
|
→ 缓存供后续恢复使用
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.5 兼容性解析
|
||||||
|
|
||||||
|
```
|
||||||
|
外部导入非标准 CFI
|
||||||
|
→ RDEPUBCFICompatibility.parseLossy(rawValue) // 宽松解析
|
||||||
|
→ RDEPUBCFICompatibility.parseRangeLossy(rawValue) // 支持 .. / - 分隔符
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文件清单
|
||||||
|
|
||||||
|
| 文件 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `RDEPUBCFI.swift` | 顶层 CFI 模型与侧偏枚举 |
|
||||||
|
| `RDEPUBCFIPath.swift` | 路径模型与 step 定义 |
|
||||||
|
| `RDEPUBCFIRange.swift` | 范围模型 |
|
||||||
|
| `RDEPUBCFIParser.swift` | 字符串 → 模型解析 |
|
||||||
|
| `RDEPUBCFISerializer.swift` | 模型 → 字符串序列化 |
|
||||||
|
| `RDEPUBCFIResolver.swift` | CFI → 资源定位结果 |
|
||||||
|
| `RDEPUBCFIGenerator.swift` | 章节信息 → CFI 生成 |
|
||||||
|
| `RDEPUBCFIDOMPathBuilder.swift` | HTML → fragment 路径映射 + 文本节点映射构建 |
|
||||||
|
| `RDEPUBCFIRecoveryEngine.swift` | 多级容错恢复引擎 |
|
||||||
|
| `RDEPUBCFITextAssertion.swift` | 文本断言模型 |
|
||||||
|
| `RDEPUBCFICompatibility.swift` | 非标准格式兼容解析 |
|
||||||
|
| `RDEPUBCFIMap.swift` | CFI 映射、marker、恢复元数据 |
|
||||||
|
| `RDEPUBCFIError.swift` | 错误类型定义 |
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
# 章节运行时详解
|
||||||
|
|
||||||
|
> 最后更新:2026-06-18
|
||||||
|
|
||||||
|
本文档详细描述 ReadViewSDK 章节运行时子系统的架构、缓存策略、加载流程和优化机制。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 概述
|
||||||
|
|
||||||
|
章节运行时是 EPUBUI 层的核心子系统,负责章节的按需加载、缓存管理和页码映射。它位于 `RDEPUBReaderContext` → `RDEPUBReaderRuntime` 架构中,是实现大书(如 1000+ 章的网络小说)流畅阅读的关键。
|
||||||
|
|
||||||
|
**核心设计目标**:
|
||||||
|
- 快速打开:用户点击书籍后 1-2 秒内可开始阅读
|
||||||
|
- 按需加载:只加载当前窗口内的章节,内存占用可控
|
||||||
|
- 渐进补全:后台逐步补全所有章节的页码信息
|
||||||
|
|
||||||
|
**关键文件**:
|
||||||
|
- `Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/` 目录
|
||||||
|
- `Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 三级缓存架构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ Tier 1: 内存缓存 (RDEPUBChapterRuntimeStore) │
|
||||||
|
│ ├─ chapterDataCache: [Int: RDEPUBRuntimeChapter] │
|
||||||
|
│ ├─ pageCountCache: [CacheKey: RDEPUBRuntimePageCount] │
|
||||||
|
│ ├─ imageCache: NSCache<NSString, UIImage> (50 上限) │
|
||||||
|
│ └─ 窗口驱逐:只保留当前章节 ± windowRadius 的章节 │
|
||||||
|
└──────────────────────────┬──────────────────────────────┘
|
||||||
|
│ miss
|
||||||
|
┌──────────────────────────▼──────────────────────────────┐
|
||||||
|
│ Tier 2: 磁盘摘要缓存 (RDEPUBChapterSummaryDiskCache) │
|
||||||
|
│ ├─ 路径: ~/Caches/RDEPUBChapterSummaryCache/{bookID}/ │
|
||||||
|
│ ├─ 文件名: SHA256(bookID_spineIdx_renderSig_contentHash)│
|
||||||
|
│ ├─ 格式: JSON (RDEPUBChapterSummary) │
|
||||||
|
│ ├─ 写入: 异步(serial DispatchQueue) │
|
||||||
|
│ └─ 读取: 同步(readAll 批量读取) │
|
||||||
|
└──────────────────────────┬──────────────────────────────┘
|
||||||
|
│ miss
|
||||||
|
┌──────────────────────────▼──────────────────────────────┐
|
||||||
|
│ Tier 3: 全书分页缓存 (RDEPUBTextBookCache) │
|
||||||
|
│ ├─ 路径: ~/Caches/RDEPUBTextBookCache/ │
|
||||||
|
│ ├─ 格式: NSSecureCoding archive │
|
||||||
|
│ ├─ 内容: 每章的 pageRanges + breakReasons + semanticHints│
|
||||||
|
│ └─ 失效: schemaVersion 变更时全部失效 │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.1 Tier 1: RDEPUBChapterRuntimeStore
|
||||||
|
|
||||||
|
**文件**:`RDEPUBChapterRuntimeStore.swift`
|
||||||
|
|
||||||
|
内存缓存,存储当前窗口内的章节数据。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
final class RDEPUBChapterRuntimeStore {
|
||||||
|
// 章节数据缓存(完整 RuntimeChapter)
|
||||||
|
private let chapterDataCache = RDEPUBChapterDataCache()
|
||||||
|
|
||||||
|
// 页数缓存(仅页范围,不含 NSAttributedString)
|
||||||
|
private let pageCountCache = RDEPUBPageCountCache()
|
||||||
|
|
||||||
|
// 图片缓存(NSCache,上限 50 张)
|
||||||
|
let imageCache = NSCache<NSString, UIImage>()
|
||||||
|
|
||||||
|
// 章节加载队列(串行,userInitiated QoS)
|
||||||
|
let chapterLoadQueue = DispatchQueue(label: "com.rdreader.chapterload", qos: .userInitiated)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**窗口驱逐策略**:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
func setCurrentChapter(spineIndex: Int, totalSpineCount: Int, windowRadius: Int = 1) {
|
||||||
|
currentSpineIndex = spineIndex
|
||||||
|
let lowerBound = max(0, spineIndex - radius)
|
||||||
|
let upperBound = min(totalSpineCount - 1, spineIndex + radius)
|
||||||
|
windowSpineIndices = Array(lowerBound...upperBound)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 保留范围:`[spineIndex - windowRadius, spineIndex + windowRadius]`
|
||||||
|
- `evictableSpineIndices()` 返回窗口外的已缓存章节索引
|
||||||
|
- 章节切换时调用 `setCurrentChapter` 更新窗口,然后驱逐窗口外章节
|
||||||
|
|
||||||
|
**内存警告处理**:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
func handleMemoryWarning() {
|
||||||
|
evictAllExceptCurrent() // 驱逐除当前章节外的所有缓存
|
||||||
|
imageCache.removeAllObjects() // 清空图片缓存
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**导航优先级抢占**:
|
||||||
|
|
||||||
|
当用户快速翻页时,新的导航请求可以抢占正在构建的章节:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
func setNavigationTarget(spineIndex: Int) // 设置导航目标
|
||||||
|
func consumeNavigationTarget() -> Int? // 消费导航目标(构建完成后检查)
|
||||||
|
```
|
||||||
|
|
||||||
|
`RDEPUBChapterLoader` 在完成当前章节构建后,会检查是否有新的导航目标,如有则立即切换到新目标。
|
||||||
|
|
||||||
|
### 2.2 Tier 2: RDEPUBChapterSummaryDiskCache
|
||||||
|
|
||||||
|
**文件**:`RDEPUBChapterSummaryDiskCache.swift`
|
||||||
|
|
||||||
|
磁盘摘要缓存,存储章节的分页元数据(不含完整 NSAttributedString)。
|
||||||
|
|
||||||
|
**存储路径**:`~/Library/Caches/RDEPUBChapterSummaryCache/{bookID}/`
|
||||||
|
|
||||||
|
**文件命名**:`SHA256(bookID_spineIndex_renderSignature_contentHash).json`
|
||||||
|
|
||||||
|
**缓存内容**(RDEPUBChapterSummary):
|
||||||
|
- `pageRanges: [NSRange]` — 每页的文本范围
|
||||||
|
- `pageCount: Int` — 总页数
|
||||||
|
- `fragmentOffsets: [String: Int]` — fragment ID 到偏移量的映射
|
||||||
|
- `cfiMap: RDEPUBCFIMap?` — CFI 映射
|
||||||
|
- `renderSignature: String` — 渲染参数签名
|
||||||
|
- `chapterContentHash: String` — 章节 HTML 的 SHA-256
|
||||||
|
- `pageMetadataList: [PageMetadataSummary]` — 每页的语义元数据
|
||||||
|
|
||||||
|
**写入策略**:
|
||||||
|
- 异步写入(serial DispatchQueue)
|
||||||
|
- 原子写入(先写临时文件,再 rename)
|
||||||
|
|
||||||
|
**批量读取**:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
func readAll(keys: [RDEPUBChapterCacheKey]) -> (
|
||||||
|
summaries: [Int: RDEPUBChapterSummary],
|
||||||
|
partialBuilder: RDEPUBBookPageMap.Builder
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
一次读取所有章节的摘要,同时构建 `BookPageMap.Builder`,避免重复遍历。
|
||||||
|
|
||||||
|
### 2.3 Tier 3: RDEPUBTextBookCache
|
||||||
|
|
||||||
|
全书分页缓存,使用 `NSSecureCoding` 归档。包含每章的完整分页信息(pageRanges、breakReasons、semanticHints)。当 `schemaVersion` 变更时全部失效。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. RDEPUBChapterCacheKey — 缓存键设计
|
||||||
|
|
||||||
|
**文件**:`RDEPUBChapterCacheKey.swift`
|
||||||
|
|
||||||
|
```swift
|
||||||
|
struct RDEPUBChapterCacheKey: Hashable {
|
||||||
|
let bookID: String // 书籍唯一标识
|
||||||
|
let spineIndex: Int // 章节索引
|
||||||
|
let renderSignature: String // 渲染参数签名
|
||||||
|
let chapterContentHash: String // 章节 HTML 的 SHA-256
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**renderSignature 组成**:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
let renderSignature = [
|
||||||
|
style.font.fontName, // 字体名称
|
||||||
|
"\(style.font.pointSize)", // 字号
|
||||||
|
"\(lineHeightMultiple)", // 行距倍数
|
||||||
|
"\(style.lineSpacing)", // 行间距
|
||||||
|
layoutConfig.cacheSignature, // 布局参数签名
|
||||||
|
"\(schemaVersion)" // 缓存 schema 版本
|
||||||
|
].joined(separator: "|")
|
||||||
|
```
|
||||||
|
|
||||||
|
**缓存失效语义**:
|
||||||
|
- 换字体/字号 → renderSignature 变化 → 缓存失效
|
||||||
|
- EPUB 内容更新 → contentHash 变化 → 缓存失效
|
||||||
|
- 不同书籍 → bookID 不同 → 互不干扰
|
||||||
|
- schemaVersion 变更 → 全部失效
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. RDEPUBChapterLoader — 章节加载器
|
||||||
|
|
||||||
|
**文件**:`RDEPUBChapterLoader.swift`
|
||||||
|
|
||||||
|
### 4.1 加载流程
|
||||||
|
|
||||||
|
```swift
|
||||||
|
func loadChapter(
|
||||||
|
spineIndex: Int,
|
||||||
|
store: RDEPUBChapterRuntimeStore,
|
||||||
|
priority: LoadPriority = .navigation,
|
||||||
|
completion: @escaping (Result<RDEPUBRuntimeChapter, Error>) -> Void
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**三级缓存串联**:
|
||||||
|
|
||||||
|
1. **Tier 1 命中**:`store.chapterData(for: spineIndex)` → 直接返回
|
||||||
|
2. **Tier 1 页数缓存命中**:`store.pageCount(for: cacheKey)` → 轻量路径(跳过分页计算)
|
||||||
|
3. **Tier 2 命中**:`summaryDiskCache?.read(for: cacheKey)` → 轻量路径
|
||||||
|
4. **全部未命中**:完整路径(渲染 + 分页 + 写缓存)
|
||||||
|
|
||||||
|
### 4.2 轻量路径 vs 完整路径
|
||||||
|
|
||||||
|
**轻量路径**(有缓存页范围时):
|
||||||
|
1. 读取 HTML 并渲染为 NSAttributedString
|
||||||
|
2. 使用缓存的 pageRanges 直接构建页面
|
||||||
|
3. 跳过分页计算(最耗时的步骤)
|
||||||
|
|
||||||
|
**完整路径**(无缓存时):
|
||||||
|
1. 使用 `RDEPUBTextBookBuilder.buildChapter()` 完整构建
|
||||||
|
2. 包含 HTML→NSAttributedString→分页→尾页规范化全流程
|
||||||
|
3. 构建完成后写入磁盘摘要缓存
|
||||||
|
|
||||||
|
### 4.3 加载优先级
|
||||||
|
|
||||||
|
```swift
|
||||||
|
enum LoadPriority {
|
||||||
|
case navigation // 用户导航触发(最高优先级,可抢占)
|
||||||
|
case preview // 预览触发
|
||||||
|
case prefetch // 预取触发(最低优先级)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**导航优先级抢占**:当 `priority == .navigation` 时,完成构建后检查 `consumeNavigationTarget()`,如有新目标则立即切换。
|
||||||
|
|
||||||
|
### 4.4 同步加载
|
||||||
|
|
||||||
|
```swift
|
||||||
|
func loadChapterSynchronouslyForMigration(
|
||||||
|
spineIndex: Int,
|
||||||
|
store: RDEPUBChapterRuntimeStore?
|
||||||
|
) throws -> RDEPUBRuntimeChapter
|
||||||
|
```
|
||||||
|
|
||||||
|
使用信号量阻塞当前线程,等待章节加载完成。用于快速打开路径中加载首个可渲染章节。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. RDEPUBBookPageMap — 轻量页码映射
|
||||||
|
|
||||||
|
**文件**:`RDEPUBBookPageMap.swift`
|
||||||
|
|
||||||
|
轻量级全书页码映射,不持有 NSAttributedString,内存占用约 100KB/1000 章。
|
||||||
|
|
||||||
|
### 5.1 数据结构
|
||||||
|
|
||||||
|
```swift
|
||||||
|
struct RDEPUBBookPageMapEntry {
|
||||||
|
let spineIndex: Int
|
||||||
|
let href: String
|
||||||
|
let title: String
|
||||||
|
let pageCount: Int
|
||||||
|
let absolutePageStart: Int // 该章节的起始绝对页码
|
||||||
|
let fragmentOffsets: [String: Int]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RDEPUBBookPageMap {
|
||||||
|
let entries: [RDEPUBBookPageMapEntry]
|
||||||
|
let totalPages: Int
|
||||||
|
var totalChapters: Int { entries.count }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 页码查询
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// 绝对页码 → 章节索引(二分查找,O(log n))
|
||||||
|
func spineIndex(forAbsolutePage absolutePage: Int) -> Int?
|
||||||
|
|
||||||
|
// 绝对页码 → 章节内本地页码
|
||||||
|
func localPageIndex(forAbsolutePage absolutePage: Int) -> Int?
|
||||||
|
|
||||||
|
// (spineIndex, localPageIndex) → 绝对页码
|
||||||
|
func absolutePageIndex(spineIndex: Int, localPageIndex: Int) -> Int?
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Builder 增量构建
|
||||||
|
|
||||||
|
```swift
|
||||||
|
struct Builder {
|
||||||
|
mutating func add(
|
||||||
|
spineIndex: Int,
|
||||||
|
href: String,
|
||||||
|
title: String,
|
||||||
|
pageCount: Int,
|
||||||
|
fragmentOffsets: [String: Int]
|
||||||
|
)
|
||||||
|
|
||||||
|
func build() -> RDEPUBBookPageMap
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Builder 模式支持增量添加章节信息,`build()` 时自动按 spineIndex 排序并计算绝对页码。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. RDEPUBPageResolver — 页码解析器
|
||||||
|
|
||||||
|
**文件**:`RDEPUBPageResolver.swift`
|
||||||
|
|
||||||
|
将绝对页码解析为章节和本地页码的组合。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
struct RDEPUBResolvedPage {
|
||||||
|
let spineIndex: Int
|
||||||
|
let localPageIndex: Int
|
||||||
|
let page: RDEPUBTextPage?
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolvePage(absolutePageIndex: Int) -> RDEPUBResolvedPage?
|
||||||
|
```
|
||||||
|
|
||||||
|
解析流程:
|
||||||
|
1. 从 `BookPageMap` 查找 spineIndex 和 localPageIndex
|
||||||
|
2. 从 `ChapterRuntimeStore` 获取已加载的章节数据
|
||||||
|
3. 如果章节未加载,触发按需加载
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 后台元数据解析优化
|
||||||
|
|
||||||
|
**文件**:`RDEPUBReaderPaginationCoordinator.swift`
|
||||||
|
|
||||||
|
### 7.1 整体流程
|
||||||
|
|
||||||
|
```
|
||||||
|
RDEPUBMetadataParseWorker.start(token)
|
||||||
|
│
|
||||||
|
├─ 预计算所有章节 contentHash(串行)
|
||||||
|
├─ readAll(keys:) 批量读取磁盘缓存
|
||||||
|
├─ refreshBookPageMapInPlace(缓存部分)
|
||||||
|
├─ waitForReadingInteractionToSettle(0.8s 冷却)
|
||||||
|
│
|
||||||
|
├─ OperationQueue (并发 N=CPU核心数):
|
||||||
|
│ ├─ buildChapter(spineIndex) // 渲染+分页
|
||||||
|
│ ├─ chapterCacheKey(spineIndex) // 复用预计算 hash
|
||||||
|
│ ├─ summary.write() // 异步写盘
|
||||||
|
│ └─ 每 32 章刷新 BookPageMap
|
||||||
|
│
|
||||||
|
└─ 最终 refreshBookPageMapInPlace
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 预计算 contentHash
|
||||||
|
|
||||||
|
**问题**:每个章节在构建缓存键时需要读取 HTML 并计算 SHA-256,重复 I/O 开销大。
|
||||||
|
|
||||||
|
**优化**:在后台解析开始时,串行预计算所有章节的 contentHash:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
var contentHashBySpineIndex: [Int: String] = [:]
|
||||||
|
for spineIndex in allBuildableIndices {
|
||||||
|
let html = parser.htmlString(forRelativePath: href)
|
||||||
|
contentHashBySpineIndex[spineIndex] = html?.rd_sha256Hex ?? ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
后续所有 `chapterCacheKey` 调用都使用预计算值。
|
||||||
|
|
||||||
|
### 7.3 冻结 renderSignature
|
||||||
|
|
||||||
|
**问题**:用户在后台解析进行中更改字号/行距,会导致部分章节用旧签名、部分用新签名写入缓存。
|
||||||
|
|
||||||
|
**解决**:在 `RDEPUBMetadataParseWorker` 初始化时冻结签名:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
let renderSignature = context.currentRenderSignature()
|
||||||
|
// 后续所有 chapterCacheKey 调用使用此固定值
|
||||||
|
```
|
||||||
|
|
||||||
|
token 机制确保设置变更会触发新的解析任务(新 token),旧任务自动废弃。
|
||||||
|
|
||||||
|
### 7.4 锁区瘦身
|
||||||
|
|
||||||
|
**原始实现**:`resultLock` 内调用 `buildPageMap()`,遍历全量 catalog 和 summaries。
|
||||||
|
|
||||||
|
**优化**:锁内只做写入和计数,快照数据后锁外构建 pageMap:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
var snapshot: [Int: RDEPUBChapterSummary]?
|
||||||
|
resultLock.lock()
|
||||||
|
summariesBySpineIndex[spineIndex] = renderResult
|
||||||
|
totalResolvedCount += 1
|
||||||
|
if shouldRefresh {
|
||||||
|
snapshot = summariesBySpineIndex // 快照
|
||||||
|
}
|
||||||
|
resultLock.unlock()
|
||||||
|
|
||||||
|
if let snapshot {
|
||||||
|
let partialMap = buildPageMap(from: catalog, summaries: snapshot) // 锁外
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.5 可配置刷新间隔
|
||||||
|
|
||||||
|
```swift
|
||||||
|
static var pageMapRefreshInterval: Int = 32 // 默认 32 章
|
||||||
|
```
|
||||||
|
|
||||||
|
可实测调优:32 / 48 / 64。值越大,UI 刷新频率越低,后台解析吞吐越高。
|
||||||
|
|
||||||
|
### 7.6 用户交互冷却
|
||||||
|
|
||||||
|
等待用户操作冷却 0.8 秒后再开始后台解析,避免与用户翻页操作竞争资源:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
try await Task.sleep(nanoseconds: 800_000_000)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 关键配置参数
|
||||||
|
|
||||||
|
| 参数 | 位置 | 默认值 | 说明 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| `onDemandChapterWindowSize` | `RDEPUBReaderConfiguration` | `3` | 按需加载窗口大小(奇数,3-15) |
|
||||||
|
| `chapterWindowRadius` | 计算属性 | `1` | 内存缓存窗口半径(windowSize / 2) |
|
||||||
|
| `metadataParsingConcurrency` | `RDEPUBReaderConfiguration` | CPU 核心数 | 后台解析并发数 |
|
||||||
|
| `pageMapRefreshInterval` | 静态变量 | `32` | 每 N 章刷新一次 UI |
|
||||||
|
| `imageCache.countLimit` | `RDEPUBChapterRuntimeStore` | `50` | 图片缓存上限 |
|
||||||
|
| 冷却时间 | 硬编码 | `0.8s` | 用户交互冷却时间 |
|
||||||
|
| `schemaVersion` | `RDEPUBChapterSummary` | - | 缓存 schema 版本 |
|
||||||
@@ -1,293 +0,0 @@
|
|||||||
# ReadSDK 代码规范
|
|
||||||
|
|
||||||
## 适用范围
|
|
||||||
|
|
||||||
本文档适用于 `ReadSDK` 新增代码与重构代码。
|
|
||||||
|
|
||||||
- 规范覆盖 `Sources/` 与 `RDReaderDemo/` 中的 Swift 代码。
|
|
||||||
- 命名、分层与职责边界以 SDK 可维护性和可扩展性为优先。
|
|
||||||
- 本文档分为两类内容:
|
|
||||||
- `已观察到的约定`:当前工程常见写法。
|
|
||||||
- `建议统一的规范`:后续统一执行的规则。
|
|
||||||
|
|
||||||
## 新架构目录与职责
|
|
||||||
|
|
||||||
### 已观察到的约定
|
|
||||||
|
|
||||||
- `Sources/RDReaderView`:阅读核心容器、翻页能力与基础视图。
|
|
||||||
- `EPUBCore`:EPUB 解析、资源定位、导航状态、分页与会话协调。
|
|
||||||
- `EPUBTextRendering`:文本渲染引擎与分页支持。
|
|
||||||
- `EPUBUI`:可开箱即用的 Reader UI 层。
|
|
||||||
- `RDReaderDemo`:示例应用与调试入口。
|
|
||||||
|
|
||||||
### 建议统一的规范
|
|
||||||
|
|
||||||
- 新增业务能力优先归入 `Sources/RDReaderView` 下的对应模块目录。
|
|
||||||
- `Core` 层只承载解析、会话、状态机与通用能力,不写页面级交互。
|
|
||||||
- `UI` 层只承载展示、事件分发和轻量状态同步,不直接处理底层解析逻辑。
|
|
||||||
- 若模块持续膨胀,优先在当前模块下继续拆分子文件,不跨目录散落实现。
|
|
||||||
|
|
||||||
## 命名规范
|
|
||||||
|
|
||||||
### 已观察到的约定
|
|
||||||
|
|
||||||
- 当前工程历史命名以 `SS`、`RDEPUB` 开头。
|
|
||||||
- 控制器常用 `...Controller`,视图常用 `...View`,会话对象常用 `...Session`。
|
|
||||||
- 扩展文件采用 `类型名+功能域.swift`,如 `Parser+Archive.swift`。
|
|
||||||
- 事件方法常使用 `Action` 结尾,例如 `pageTapAction()`、`themeChangeAction()`。
|
|
||||||
- 绑定数据的方法常使用 `bind`、`update`、`refresh`、`configure` 等动词。
|
|
||||||
|
|
||||||
### 建议统一的规范
|
|
||||||
|
|
||||||
- **所有新增类型必须以 `RD` 开头。**
|
|
||||||
- EPUB 相关类型统一以 `RDEPUB` 开头。
|
|
||||||
- 方法名、变量名沿用 Swift 小驼峰,不增加额外前缀。
|
|
||||||
- 类型名应反映职责,不使用过宽泛的后缀;只有真正承担协调逻辑时才使用 `Manager`、`Handler`。
|
|
||||||
- 事件处理方法统一使用"对象/意图 + Action"命名,例如 `pageTapAction`、`themeChangeAction`。
|
|
||||||
- 数据绑定方法优先使用以下语义:
|
|
||||||
- `bind...`:将模型绑定到视图或模块。
|
|
||||||
- `update...`:增量刷新已有界面或状态。
|
|
||||||
- `configure...`:一次性配置样式或依赖。
|
|
||||||
- `refresh...`:重新拉取或重建数据状态。
|
|
||||||
- 避免新增拼写不一致的方法名;若发现历史命名拼写错误,新增代码必须使用正确拼写,旧接口修复时应配合调用点一起调整。
|
|
||||||
|
|
||||||
命名示例:
|
|
||||||
|
|
||||||
- `RDReaderView`
|
|
||||||
- `RDEPUBParser`
|
|
||||||
- `RDEPUBPublication`
|
|
||||||
- `RDEPUBReadingSession`
|
|
||||||
- `RDEPUBReaderController`
|
|
||||||
- `RDEPUBReaderTheme`
|
|
||||||
|
|
||||||
扩展文件命名示例:
|
|
||||||
|
|
||||||
```text
|
|
||||||
RDEPUBParser.swift
|
|
||||||
RDEPUBParser+Archive.swift
|
|
||||||
RDEPUBParser+Package.swift
|
|
||||||
RDEPUBParser+TOC.swift
|
|
||||||
RDEPUBParser+Resources.swift
|
|
||||||
RDEPUBWebView.swift
|
|
||||||
RDEPUBWebView+Configuration.swift
|
|
||||||
RDEPUBWebView+Reflowable.swift
|
|
||||||
```
|
|
||||||
|
|
||||||
## 分层与职责边界
|
|
||||||
|
|
||||||
### 已观察到的约定
|
|
||||||
|
|
||||||
- 阅读入口负责容器装配、翻页模式切换和事件分发。
|
|
||||||
- EPUB 核心层负责解析、导航、分页、资源读取与定位。
|
|
||||||
- 渲染层负责 HTML/富文本渲染与分页支持。
|
|
||||||
- UI 层负责主题、工具栏、目录、设置等交互能力。
|
|
||||||
|
|
||||||
### 建议统一的规范
|
|
||||||
|
|
||||||
- `RD...Controller`:负责页面级编排与流程调度,不承载复杂渲染细节。
|
|
||||||
- `RD...View`:负责展示与局部交互,不承载完整业务流程。
|
|
||||||
- `RDEPUB...Core`:负责解析、会话状态与数据模型,不依赖具体页面。
|
|
||||||
- 配置、主题、定位、进度模型统一下沉为 `struct`。
|
|
||||||
- 跨层通信优先通过会话层或协议,不做跨层直接写状态。
|
|
||||||
|
|
||||||
## UI 与布局规范
|
|
||||||
|
|
||||||
### 已观察到的约定
|
|
||||||
|
|
||||||
- 视图多采用 `lazy var` 初始化,并在闭包内完成默认配置。
|
|
||||||
- 自定义 View 通常在 `init(frame:)` 或业务绑定后调用 `initView()` 完成视图树搭建。
|
|
||||||
- 复杂页面使用分区 extension 组织代理与事件实现。
|
|
||||||
- 页面或组件内部按职责拆分样式方法,例如 `topBarStyle()`、`contentStyle()`。
|
|
||||||
- 页面经常通过回调闭包把交互抛给外层,例如 `pageChangeCallback`、`selectionCallback`。
|
|
||||||
- 模块内存在多处布局方式混用情况。
|
|
||||||
|
|
||||||
### 建议统一的规范
|
|
||||||
|
|
||||||
- SDK 层新增 UI 使用 Auto Layout 原生约束,Demo 层可使用 SnapKit;单文件内不混用多套布局体系。
|
|
||||||
- 视图层初始化顺序保持一致:
|
|
||||||
- 定义属性与子视图
|
|
||||||
- 在 `initView()` 中组装视图树
|
|
||||||
- 在独立方法中拆分样式和状态刷新逻辑
|
|
||||||
- 当约束会被多次切换时:
|
|
||||||
- 首次创建使用 `makeConstraints`(SnapKit)或 `NSLayoutConstraint`
|
|
||||||
- 重建结构使用 `remakeConstraints`(SnapKit)或先移除再添加
|
|
||||||
- 仅修改常量时使用 `updateConstraints`(SnapKit)或修改 `constant` 属性
|
|
||||||
- 布局分支明显时,优先拆成语义化私有方法,不要把所有状态分支堆在一个超长方法里。
|
|
||||||
- 对外暴露的 UI 刷新入口建议以 `bind` 或 `update` 开头,避免把布局细节暴露给调用方。
|
|
||||||
- 交互事件通过闭包或 delegate 抛出,避免子视图持有上层业务依赖。
|
|
||||||
|
|
||||||
## 交互与状态处理规范
|
|
||||||
|
|
||||||
### 已观察到的约定
|
|
||||||
|
|
||||||
- 事件处理使用 `@objc` + selector。
|
|
||||||
- 异步回调中广泛使用 `[weak self]`。
|
|
||||||
- 状态判断常通过 `guard` 提前返回。
|
|
||||||
|
|
||||||
### 建议统一的规范
|
|
||||||
|
|
||||||
- 按钮、通知、系统回调放入独立 extension 分组。
|
|
||||||
- 异步闭包默认先使用 `[weak self]`,仅在必要时改强引用。
|
|
||||||
- 多前置条件入口统一先 `guard` 校验,减少嵌套。
|
|
||||||
- 导航与进度恢复统一通过 `RDEPUBReadingSession` 协调。
|
|
||||||
|
|
||||||
## 代码风格细则
|
|
||||||
|
|
||||||
### 已观察到的约定
|
|
||||||
|
|
||||||
- extension 分区较常见。
|
|
||||||
- 解析与渲染模型多数采用 `struct`。
|
|
||||||
- 存在少量历史命名不统一与可选值处理不一致情况。
|
|
||||||
|
|
||||||
### 建议统一的规范
|
|
||||||
|
|
||||||
- 默认遵循”最小可见性”:`private` > `fileprivate` > `internal` > `public`。
|
|
||||||
- 纯数据模型使用 `struct` + `Codable` + `Equatable`。
|
|
||||||
- 服务对象使用 `final class`,避免无意义继承。
|
|
||||||
- 错误类型统一使用 `enum + LocalizedError`。
|
|
||||||
- 新增代码避免强制解包;若当前上下文无法避免,至少先在上层收敛边界。
|
|
||||||
- 统一优先使用 `guard` 做前置失败处理,减少深层嵌套。
|
|
||||||
- extension 的拆分原则以”单一职责”优先:
|
|
||||||
- 事件处理一组
|
|
||||||
- 代理 / DataSource 实现一组
|
|
||||||
- 工具方法一组
|
|
||||||
- 通知适配一组
|
|
||||||
- 调试代码继续使用 `#if DEBUG` 包裹,不把调试边框、日志、测试分支直接带入正式逻辑。
|
|
||||||
|
|
||||||
## 文件组织规范
|
|
||||||
|
|
||||||
### 已观察到的约定
|
|
||||||
|
|
||||||
- 目录按阅读容器、EPUB Core、渲染、UI 分层组织。
|
|
||||||
- 大类通过 `+Extension` 文件拆分职责。
|
|
||||||
|
|
||||||
### 建议统一的规范
|
|
||||||
|
|
||||||
- 目录保持以下分层,不跨层放置实现:
|
|
||||||
- `Sources/RDReaderView/EPUBCore`
|
|
||||||
- `Sources/RDReaderView/EPUBTextRendering`
|
|
||||||
- `Sources/RDReaderView/EPUBUI`
|
|
||||||
- 单文件建议不超过 `600` 行;超出后按职责拆分 extension 文件。
|
|
||||||
- extension 文件命名统一 `RD类型名+功能域.swift`。
|
|
||||||
|
|
||||||
## 注释规范
|
|
||||||
|
|
||||||
- 注释、错误提示、日志统一使用中文。
|
|
||||||
- 关键流程方法保留”为什么这样做”的注释,不写重复代码字面行为的注释。
|
|
||||||
- 调试输出统一放在 `#if DEBUG` 下。
|
|
||||||
- 新代码保留有信息量的注释,避免重复描述显而易见的代码行为。
|
|
||||||
|
|
||||||
## 已观察到的项目模式
|
|
||||||
|
|
||||||
### 模式 1:统一入口 + 扩展拆分
|
|
||||||
|
|
||||||
- `RDEPUBParser` 负责 EPUB 解析主入口,不同能力拆到 `+Archive`、`+Package`、`+TOC`、`+Resources` 等扩展文件。
|
|
||||||
- `RDEPUBReadingSession` 负责阅读会话主入口,状态管理、分页、定位等能力拆到扩展文件。
|
|
||||||
- 该模式适合继续用于解析器、会话管理、控制器工具方法等横向能力。
|
|
||||||
|
|
||||||
### 模式 2:页面编排在 Controller,局部交互下沉到 View
|
|
||||||
|
|
||||||
- `RDEPUBReaderController` 负责阅读页整体编排:翻页容器装配、工具栏切换、阅读位置恢复。
|
|
||||||
- `RDReaderView` 负责分页容器布局与翻页交互,并通过 DataSource / Delegate 把数据需求交回外层。
|
|
||||||
- 该模式保持 Controller 管流程、View 管展示的职责分离。
|
|
||||||
|
|
||||||
### 模式 3:列表与容器逻辑通过扩展拆开
|
|
||||||
|
|
||||||
- 复杂容器将 `UICollectionViewDataSource`、`UICollectionViewDelegate`、`UICollectionViewDelegateFlowLayout` 分别拆分到 extension。
|
|
||||||
- 该模式降低单文件中主逻辑与代理逻辑的耦合,适合继续用于任何包含列表或容器的组件。
|
|
||||||
|
|
||||||
### 模式 4:渲染路径抽象
|
|
||||||
|
|
||||||
- `RDEPUBReadingProfile` 根据 EPUB 特征自动选择渲染路径(`webFixedLayout` / `webInteractive` / `textReflowable`)。
|
|
||||||
- 新增渲染相关功能时,必须评估对三种路径的覆盖情况。
|
|
||||||
|
|
||||||
## 待统一项
|
|
||||||
|
|
||||||
- 当前访问控制级别存在混用:同一类里 `public`、默认 `internal`、`private` 并存,建议后续新增代码默认从最小可见范围开始声明。
|
|
||||||
- 当前存在少量强制解包,建议新增代码优先通过前置校验收敛风险。
|
|
||||||
- 当前存在拼写不一致问题,建议后续新增代码统一使用标准英文单词,旧接口如需修复应配合调用点一起调整。
|
|
||||||
- 当前部分注释偏”过程说明”或遗留调试注释,建议新代码保留有信息量的注释。
|
|
||||||
- 当前个别 View 在数据绑定阶段再次调用 `initView()` 重建界面,这种方式在复杂组件中容易引入重复添加子视图或状态不一致。建议新增组件优先区分”初始化视图结构”和”刷新数据状态”两个阶段。
|
|
||||||
|
|
||||||
## 禁忌事项
|
|
||||||
|
|
||||||
| 禁忌 | 替代做法 |
|
|
||||||
|------|----------|
|
|
||||||
| 新增类型不加 `RD` 前缀 | 所有新增类型统一 `RD` / `RDEPUB` 前缀 |
|
|
||||||
| UI 层直接拼装解析状态 | 通过 `RDEPUBReadingSession` 获取状态 |
|
|
||||||
| 控制器直接操作底层解析细节 | 通过 `RDEPUBPublication`、`RDEPUBParser` 暴露接口 |
|
|
||||||
| 强制解包可选值 | `guard let` / `if let` |
|
|
||||||
| 用页号单独恢复阅读进度 | 统一使用 `href + progression` |
|
|
||||||
|
|
||||||
## 使用建议
|
|
||||||
|
|
||||||
- 新增功能前先确定目录归属和职责边界。
|
|
||||||
- 命名先定前缀再落代码:类型一律 `RD` 开头。
|
|
||||||
- 若需迁移历史 `SS` 前缀,按模块渐进替换,优先替换新增与重构触达文件。
|
|
||||||
|
|
||||||
## 旧 SS 命名迁移到 RD 的分阶段执行清单
|
|
||||||
|
|
||||||
### 阶段 0:冻结新增 SS 命名(立即执行)
|
|
||||||
|
|
||||||
- 目标:从当前时点开始,不再引入新的 `SS`/`RDEPUB` 类型名。
|
|
||||||
- 动作:
|
|
||||||
- 新增类型统一使用 `RD`/`RDEPUB` 前缀。
|
|
||||||
- Code Review 增加命名检查项:发现新增 `SS` 命名必须驳回。
|
|
||||||
- 在 PR 模板中加入“本次是否新增旧前缀命名”勾选项。
|
|
||||||
- 验收:
|
|
||||||
- 新提交代码中,新增类型 `SS` 前缀数量为 `0`。
|
|
||||||
|
|
||||||
### 阶段 1:建立迁移映射表(第 1 周)
|
|
||||||
|
|
||||||
- 目标:明确“旧名 -> 新名”一一映射,避免多人并行改名冲突。
|
|
||||||
- 动作:
|
|
||||||
- 统计核心公开类型、内部核心类型、测试类型三类清单。
|
|
||||||
- 建立命名映射表,例如:
|
|
||||||
- `RDReaderView` -> `RDReaderView`
|
|
||||||
- `RDEPUBParser` -> `RDEPUBParser`
|
|
||||||
- `RDEPUBReadingSession` -> `RDEPUBReadingSession`
|
|
||||||
- 对外 API 单独标记“需兼容过渡”的类型。
|
|
||||||
- 验收:
|
|
||||||
- 映射表覆盖全部高频核心类型,且团队评审通过。
|
|
||||||
|
|
||||||
### 阶段 2:先迁移内部类型(第 2-3 周)
|
|
||||||
|
|
||||||
- 目标:优先改内部实现,降低外部兼容压力。
|
|
||||||
- 动作:
|
|
||||||
- 按模块分批迁移:`EPUBCore` -> `EPUBTextRendering` -> `EPUBUI`。
|
|
||||||
- 每批次只改一个子模块,避免超大 PR。
|
|
||||||
- 同步修复调用点、扩展文件名与注释中的旧命名。
|
|
||||||
- 验收:
|
|
||||||
- 目标模块内类型命名全部满足 `RD` 规则。
|
|
||||||
- 编译通过,Demo 阅读主流程可用。
|
|
||||||
|
|
||||||
### 阶段 3:迁移公开 API 并保留兼容层(第 3-4 周)
|
|
||||||
|
|
||||||
- 目标:完成对外接口改名,同时给接入方提供平滑升级窗口。
|
|
||||||
- 动作:
|
|
||||||
- 对外公开类型切换为 `RD` 命名。
|
|
||||||
- 旧公开类型保留兼容别名,并标注废弃说明(`deprecated`)。
|
|
||||||
- 在 Release Note 提供“旧名/新名对照表”和迁移示例。
|
|
||||||
- 验收:
|
|
||||||
- 新接入示例仅使用 `RD` 命名。
|
|
||||||
- 旧接入代码在兼容期内无需立即改动即可编译。
|
|
||||||
|
|
||||||
### 阶段 4:清理兼容层与收口(下一主版本)
|
|
||||||
|
|
||||||
- 目标:在约定主版本移除旧前缀,完成命名收口。
|
|
||||||
- 动作:
|
|
||||||
- 删除 `SS` 兼容别名与过渡代码。
|
|
||||||
- 清理文档、注释、示例工程中的旧前缀残留。
|
|
||||||
- 对外发布最终迁移公告与升级说明。
|
|
||||||
- 验收:
|
|
||||||
- 工程内无 `SS`/`RDEPUB` 类型定义残留。
|
|
||||||
- 文档与示例代码全部为 `RD`/`RDEPUB` 命名。
|
|
||||||
|
|
||||||
### 迁移过程约束
|
|
||||||
|
|
||||||
- 每次迁移 PR 必须包含:
|
|
||||||
- 命名改动清单
|
|
||||||
- 影响范围说明
|
|
||||||
- 回归验证结果(编译、Demo 主流程、关键阅读路径)
|
|
||||||
- 禁止在同一 PR 中同时做“大规模命名迁移 + 业务逻辑重构”。
|
|
||||||
- 若改名会影响外部接入,必须先补迁移文档再合并代码。
|
|
||||||
@@ -61,15 +61,7 @@
|
|||||||
|
|
||||||
## 可维护性与技术债
|
## 可维护性与技术债
|
||||||
|
|
||||||
### 8) Legacy 与新实现并存(潜在双栈维护)
|
### 8) DEBUG 下日志与 Web Inspector 可能泄露内容
|
||||||
- **问题:** SDK 同时存在 `LegacyRDReaderController/*` 与较新的 `EPUBCore`/`EPUBUI` 实现,且存在体量很大的 controller 文件。
|
|
||||||
- **影响:** 行为重复、修复分叉、上手成本上升,长期维护成本更高。
|
|
||||||
- **建议缓解:**
|
|
||||||
- 明确弃用/冻结计划:哪些 API 仍受支持,哪些仅做兼容不再演进。
|
|
||||||
- 抽取共用原语(models、persistence、navigation)到统一位置,逐步移除重复实现。
|
|
||||||
- 提供兼容 shim,推动调用方迁移离开 legacy API。
|
|
||||||
|
|
||||||
### 9) DEBUG 下日志与 Web Inspector 可能泄露内容
|
|
||||||
- **问题:** Debug 辅助会打印导航/消息体;selection payload 可能包含用户选中文本;且 DEBUG 下可能默认开启 inspectable。
|
- **问题:** Debug 辅助会打印导航/消息体;selection payload 可能包含用户选中文本;且 DEBUG 下可能默认开启 inspectable。
|
||||||
- **影响:** 在 debug 构建(含内部测试/QA)中,日志可能包含敏感内容并被导出/分享。
|
- **影响:** 在 debug 构建(含内部测试/QA)中,日志可能包含敏感内容并被导出/分享。
|
||||||
- **建议缓解:**
|
- **建议缓解:**
|
||||||
@@ -78,21 +70,21 @@
|
|||||||
|
|
||||||
## 构建与依赖脆弱性
|
## 构建与依赖脆弱性
|
||||||
|
|
||||||
### 10) 仓库中包含 vendored 的 `Pods/` 目录
|
### 9) 仓库中包含 vendored 的 `Pods/` 目录
|
||||||
- **问题:** 仓库根目录有 `Pods/`,示例工程内也有 `ReadViewDemo/Pods/`。
|
- **问题:** 仓库根目录有 `Pods/`,示例工程内也有 `ReadViewDemo/Pods/`。
|
||||||
- **影响:** diff 体积大、clone 慢、易产生 merge 冲突,也容易出现依赖陈旧导致的构建不一致。
|
- **影响:** diff 体积大、clone 慢、易产生 merge 冲突,也容易出现依赖陈旧导致的构建不一致。
|
||||||
- **建议缓解:**
|
- **建议缓解:**
|
||||||
- 通常建议不要提交 Pods,仅提交 `Podfile.lock`,在 CI 中执行 `pod install`。
|
- 通常建议不要提交 Pods,仅提交 `Podfile.lock`,在 CI 中执行 `pod install`。
|
||||||
- 如确有 vendoring 政策,需文档化并提供同步/校验工具,避免漂移。
|
- 如确有 vendoring 政策,需文档化并提供同步/校验工具,避免漂移。
|
||||||
|
|
||||||
### 11) Podfile 与 podspec 的部署版本不一致
|
### 10) Podfile 与 podspec 的部署版本不一致
|
||||||
- **问题:** `Podfile` 设为 `platform :ios, '15.6'`,而 podspec 声明 `s.platform = :ios, "15.0"`。
|
- **问题:** `Podfile` 设为 `platform :ios, '15.6'`,而 podspec 声明 `s.platform = :ios, "15.0"`。
|
||||||
- **影响:** 对外承诺与本地构建基线不一致,容易造成集成方预期偏差。
|
- **影响:** 对外承诺与本地构建基线不一致,容易造成集成方预期偏差。
|
||||||
- **建议缓解:**
|
- **建议缓解:**
|
||||||
- 对齐 `Podfile`、`*.podspec`、Xcode project 的 deployment target。
|
- 对齐 `Podfile`、`*.podspec`、Xcode project 的 deployment target。
|
||||||
- 在 CI 中增加漂移检查,防止版本被无意改动。
|
- 在 CI 中增加漂移检查,防止版本被无意改动。
|
||||||
|
|
||||||
### 12) podspec 将 Swift 版本钉死为 5.10
|
### 11) podspec 将 Swift 版本钉死为 5.10
|
||||||
- **问题:** `s.swift_versions = ["5.10"]` 将 pod 与特定 Swift 版本强绑定。
|
- **问题:** `s.swift_versions = ["5.10"]` 将 pod 与特定 Swift 版本强绑定。
|
||||||
- **影响:** 旧工具链的集成方无法使用;未来升级可能出现“突然不兼容”,尤其当依赖未同步时。
|
- **影响:** 旧工具链的集成方无法使用;未来升级可能出现“突然不兼容”,尤其当依赖未同步时。
|
||||||
- **建议缓解:**
|
- **建议缓解:**
|
||||||
@@ -101,7 +93,7 @@
|
|||||||
|
|
||||||
## 不清晰/容易踩坑的契约
|
## 不清晰/容易踩坑的契约
|
||||||
|
|
||||||
### 13) 默认的持久化协议实现可能静默丢失数据
|
### 12) 默认的持久化协议实现可能静默丢失数据
|
||||||
- **问题:** `RDEPUBReaderPersistence` 通过 protocol extension 提供了 bookmarks/settings 的默认 no-op 实现。
|
- **问题:** `RDEPUBReaderPersistence` 通过 protocol extension 提供了 bookmarks/settings 的默认 no-op 实现。
|
||||||
- **影响:** 集成方可能误以为这些能力默认会持久化,但实际数据会被静默丢弃,且不易排查。
|
- **影响:** 集成方可能误以为这些能力默认会持久化,但实际数据会被静默丢弃,且不易排查。
|
||||||
- **建议缓解:**
|
- **建议缓解:**
|
||||||
@@ -113,16 +105,15 @@
|
|||||||
## Evidence(仓库路径)
|
## Evidence(仓库路径)
|
||||||
|
|
||||||
- Build flags:`Podfile`
|
- Build flags:`Podfile`
|
||||||
- Pod metadata/toolchain:`RDReaderView.podspec`
|
- Pod metadata/toolchain:`RDEpubReaderView.podspec`
|
||||||
- Archive extraction:`Sources/RDReaderView/EPUBCore/RDEPUBParser+Archive.swift`
|
- Archive extraction:`Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+Archive.swift`
|
||||||
- Resource path validation(post-extraction):`Sources/RDReaderView/EPUBCore/RDEPUBParser+Resources.swift`
|
- Resource path validation(post-extraction):`Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+Resources.swift`
|
||||||
- Scheme handler reads full file bytes:`Sources/RDReaderView/EPUBCore/RDEPUBResourceURLSchemeHandler.swift`
|
- Scheme handler reads full file bytes:`Sources/RDEpubReaderView/EPUBCore/RDEPUBResourceURLSchemeHandler.swift`
|
||||||
- WebView bridge + message handling:`Sources/RDReaderView/EPUBCore/RDEPUBWebView+JavaScriptBridge.swift`
|
- WebView bridge + message handling:`Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+JavaScriptBridge.swift`
|
||||||
- External link opening(new UI):`Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift`
|
- External link opening:`Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController.swift`
|
||||||
- External link opening + selection persistence(legacy):`Sources/RDReaderView/LegacyRDReaderController/RDReaderController.swift`
|
- UserDefaults persistence implementation:`Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderPersistence.swift`
|
||||||
- UserDefaults persistence implementation:`Sources/RDReaderView/EPUBUI/RDEPUBReaderPersistence.swift`
|
- Hidden paginator web view:`Sources/RDEpubReaderView/EPUBCore/RDEPUBPaginator.swift`
|
||||||
- Hidden paginator web view:`Sources/RDReaderView/EPUBCore/RDEPUBPaginator.swift`
|
- Debug logging:`Sources/RDEpubReaderView/EPUBCore/RDEPUBWebViewDebug.swift`
|
||||||
- Debug logging:`Sources/RDReaderView/EPUBCore/RDEPUBWebViewDebug.swift`
|
|
||||||
- Vendored dependencies:`Pods/`、`ReadViewDemo/Pods/`
|
- Vendored dependencies:`Pods/`、`ReadViewDemo/Pods/`
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# 编码规范
|
||||||
|
|
||||||
|
**分析日期:** 2026-05-21
|
||||||
|
|
||||||
|
> 说明:本仓库以 iOS/Swift 为主,未检测到统一的 lint/format 工具配置;代码风格在不同模块间存在差异。文档按项目规则使用中文描述,但代码标识符保持英文。
|
||||||
|
|
||||||
|
## 语言与工程约束
|
||||||
|
|
||||||
|
- **主要语言**:Swift(Podspec 声明 `s.swift_versions = ["5.10"]`,见 `RDEpubReaderView.podspec`)
|
||||||
|
- **最低系统版本**:Podspec `iOS 15.0`(`RDEpubReaderView.podspec`),示例工程 Podfile/构建设置里常见为 `iOS 15.6`(`Podfile`)
|
||||||
|
- **依赖管理**:CocoaPods(`Podfile`、`ReadViewDemo/Podfile`、`Podfile.lock`)
|
||||||
|
|
||||||
|
## 命名约定
|
||||||
|
|
||||||
|
**文件/类型命名(Swift):**
|
||||||
|
- 以类型名为文件名的单文件组织较常见:`Sources/RDEpubReaderView/EPUBCore/RDEPUBParser.swift`
|
||||||
|
- 大量使用前缀区分模块域:
|
||||||
|
- `RD...`:阅读器 UI/控制器相关(如 `Sources/RDEpubReaderView/RDEpubReaderView.swift`、`Sources/RDEpubReaderView/RDEpubURLReaderController.swift`)
|
||||||
|
- `RDEPUB...`:EPUB Core/UI/渲染相关(如 `Sources/RDEpubReaderView/EPUBCore/RDEPUBModels.swift`)
|
||||||
|
- Extension 文件使用 `+` 命名:`Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift`
|
||||||
|
|
||||||
|
**变量/函数命名:**
|
||||||
|
- 基本遵循 Swift lowerCamelCase:`parse(epubURL:)`(`Sources/RDEpubReaderView/EPUBCore/RDEPUBParser.swift`)
|
||||||
|
- 常量多用 `static let`:`kRDEPUBHighlightAttributeName`(`Sources/RDEpubReaderView/EPUBCore/Models/RDEPUBAnnotationModels.swift`)
|
||||||
|
|
||||||
|
## 代码风格与排版(从现有代码归纳)
|
||||||
|
|
||||||
|
**缩进与换行:**
|
||||||
|
- 多数文件使用 4 空格缩进(示例:`ReadViewDemo/ReadViewDemo/ViewController.swift`、`Sources/RDEpubReaderView/EPUBCore/RDEPUBModels.swift`)
|
||||||
|
- 历史代码中更常见”强制换行/多行括号”风格
|
||||||
|
|
||||||
|
**空行与分组:**
|
||||||
|
- UI 相关文件常用空行分隔属性/初始化/布局段落
|
||||||
|
- `// MARK:` 用于分区组织(示例:`Sources/RDEpubReaderView/RDEpubReaderGestureController.swift`、`Sources/RDEpubReaderView/EPUBTextRendering/RDEpubPlainTextBookBuilder.swift`)
|
||||||
|
|
||||||
|
**类型组织:**
|
||||||
|
- 偏好用 `extension` 拆分职责/协议实现(示例:`ReadViewDemo/ReadViewDemo/ViewController.swift` 的 `UITableViewDataSource/Delegate`)
|
||||||
|
- API 暴露处使用 `public`、`public final class`、`public enum/struct`(示例:`Sources/RDEpubReaderView/EPUBCore/RDEPUBModels.swift`)
|
||||||
|
- “对外只读、内部可写”常用 `public internal(set)`(示例:`Sources/RDEpubReaderView/EPUBCore/RDEPUBParser.swift`)
|
||||||
|
|
||||||
|
## 导入与依赖使用
|
||||||
|
|
||||||
|
**import:**
|
||||||
|
- UIKit/UI 文件:`import UIKit`(大量文件)
|
||||||
|
- Core/模型文件:`import Foundation`(如 `Sources/RDEpubReaderView/EPUBCore/RDEPUBModels.swift`)
|
||||||
|
- 三方依赖按需引入:
|
||||||
|
- `SnapKit`:布局
|
||||||
|
- `SSAlertSwift`:弹窗/提示
|
||||||
|
|
||||||
|
**Pods 目录说明:**
|
||||||
|
- `ReadViewDemo/Pods/**` 为依赖源码/生成配置,通常不作为本仓库代码风格的“标准样式”参考。
|
||||||
|
|
||||||
|
## 错误处理与日志
|
||||||
|
|
||||||
|
- Core 解析层倾向用 `throws` + 自定义 `Error`(示例:`Sources/RDEpubReaderView/EPUBCore/RDEPUBParser.swift`、`Sources/RDEpubReaderView/EPUBCore/RDEPUBModels.swift` 的 `RDEPUBParserError`)
|
||||||
|
- UI/控制器层常见 `guard` 早返回(示例:`ReadViewDemo/ReadViewDemo/ViewController.swift`)
|
||||||
|
- 未检测到统一日志框架(未发现专用 logging package/config);出现时以系统 API/局部输出为主(需按具体文件核对)。
|
||||||
|
|
||||||
|
## 注释与文档
|
||||||
|
|
||||||
|
- **项目规则(强约束)**:代码标识符保持英文,但**代码注释/文档/提交信息使用中文**(见 `CONTEXT.md`)。
|
||||||
|
- 历史文件常带 Xcode 头部注释块(示例:`Sources/RDEpubReaderView/ReaderView/RDEpubReaderView.swift`)。
|
||||||
|
- 公共 API 处存在少量三斜线文档注释(示例:`Sources/RDEpubReaderView/RDEpubReaderView.swift` 的中文说明)。
|
||||||
|
|
||||||
|
## Lint / Formatter / 静态检查
|
||||||
|
|
||||||
|
**未检测到(仓库根与常见位置):**
|
||||||
|
- SwiftLint 配置:`.swiftlint.yml` / `swiftlint.yml`
|
||||||
|
- SwiftFormat 配置:`.swiftformat`
|
||||||
|
- 通用格式化配置:`.editorconfig`
|
||||||
|
- 其他:ESLint/Prettier/Biome 等(本仓库非 JS/TS 主体)
|
||||||
|
|
||||||
|
**可执行的工程级格式化/检查:**
|
||||||
|
- 主要依赖 Xcode(或 Swift 编译器)自身检查;如需引入 SwiftLint/SwiftFormat,应先新增对应配置文件并在 CI/构建脚本中接入。
|
||||||
|
|
||||||
|
## Evidence(关键证据文件)
|
||||||
|
|
||||||
|
- `CONTEXT.md`
|
||||||
|
- `Podfile`
|
||||||
|
- `RDEpubReaderView.podspec`
|
||||||
|
- `ReadViewDemo/ReadViewDemo/ViewController.swift`
|
||||||
|
- `Sources/RDEpubReaderView/ReaderView/RDEpubReaderView.swift`
|
||||||
|
- `Sources/RDEpubReaderView/EPUBCore/RDEPUBParser.swift`
|
||||||
|
- `Sources/RDEpubReaderView/EPUBCore/RDEPUBModels.swift`
|
||||||
|
- `Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController.swift`
|
||||||
@@ -0,0 +1,642 @@
|
|||||||
|
# EPUBCore 模块代码级参考文档
|
||||||
|
|
||||||
|
> 最后更新:2026-06-18
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 模块概述
|
||||||
|
|
||||||
|
`EPUBCore` 是 ReadViewSDK 的**基础层**,负责 EPUB 文件解析、资源管理、WebView 渲染、JavaScript 桥接、CFI 定位、搜索和分页计算。它是上层 EPUBTextRendering 和 EPUBUI 的数据提供者。
|
||||||
|
|
||||||
|
**文件清单(~50 个 Swift 文件):**
|
||||||
|
|
||||||
|
| 子系统 | 核心文件 | 职责 |
|
||||||
|
|--------|----------|------|
|
||||||
|
| **解析器** | `RDEPUBParser*.swift` | EPUB 解压、OPF 解析、TOC 解析、资源提取 |
|
||||||
|
| **数据模型** | `RDEPUBModels.swift` | Metadata、ManifestItem、SpineItem、TOC 等基础模型 |
|
||||||
|
| **Publication** | `RDEPUBPublication.swift` | 出版物抽象,聚合 parser 和 resourceResolver |
|
||||||
|
| **阅读会话** | `RDEPUBReadingSession.swift` | 管理分页快照、阅读位置、待导航状态 |
|
||||||
|
| **WebView** | `RDEPUBWebView*.swift` | WKWebView 封装,支持 Reflowable 和 Fixed Layout |
|
||||||
|
| **JS 桥接** | `RDEPUBJavaScriptBridge.swift` | JavaScript 消息协议和脚本注入 |
|
||||||
|
| **资源解析** | `RDEPUBResourceResolver.swift` | href 归一化、文件路径解析 |
|
||||||
|
| **URL 方案** | `RDEPUBResourceURLSchemeHandler.swift` | 自定义 `ss-reader://` 协议处理 |
|
||||||
|
| **分页器** | `RDEPUBPaginator.swift` | WebView 分页计算(按章测量页数) |
|
||||||
|
| **CFI** | `CFI/RDEPUBCFI*.swift` | EPUB CFI 解析、生成、序列化、恢复 |
|
||||||
|
| **搜索** | `RDEPUBSearchEngine.swift` | HTML 全文搜索 |
|
||||||
|
| **样式** | `RDEPUBStyleSheetBuilder.swift` | CSS 注入与分页样式生成 |
|
||||||
|
| **偏好** | `RDEPUBPreferences.swift` | 阅读偏好设置模型 |
|
||||||
|
| **资源文件** | `RDEPUBAssetRepository.swift` | JS/CSS/HTML 资源文件加载 |
|
||||||
|
| **笔记** | `Notes/RDEPUBNote*.swift` | 脚注/尾注检测与解析 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 解析器子系统
|
||||||
|
|
||||||
|
### 2.1 RDEPUBParser
|
||||||
|
|
||||||
|
**文件:** `RDEPUBParser.swift` + 扩展文件
|
||||||
|
|
||||||
|
EPUB 文件解析的核心类,负责从 .epub 文件中提取结构化数据。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public final class RDEPUBParser {
|
||||||
|
public internal(set) var metadata: RDEPUBMetadata // 书籍元数据
|
||||||
|
public internal(set) var manifest: [String: RDEPUBManifestItem] // 资源清单
|
||||||
|
public internal(set) var spine: [RDEPUBSpineItem] // 阅读顺序
|
||||||
|
public internal(set) var tableOfContents: [EPUBTableOfContentsItem] // 目录
|
||||||
|
public internal(set) var extractionRootURL: URL? // 解压根目录
|
||||||
|
public internal(set) var opfURL: URL? // OPF 文件路径
|
||||||
|
|
||||||
|
public var opfDirectoryURL: URL? // OPF 所在目录
|
||||||
|
|
||||||
|
/// 解析 EPUB 文件(解压 + 解析 container.xml + 解析 OPF)
|
||||||
|
public func parse(epubURL: URL) throws
|
||||||
|
|
||||||
|
/// 直接解析 OPF 文件
|
||||||
|
public func parseOPF(at opfURL: URL) throws
|
||||||
|
|
||||||
|
/// 解析 Navigation Document 或 NCX
|
||||||
|
public func parseTOC() -> [EPUBTableOfContentsItem]
|
||||||
|
|
||||||
|
/// 从 parser 创建 Publication
|
||||||
|
public func makePublication() -> RDEPUBPublication
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**扩展文件职责:**
|
||||||
|
|
||||||
|
| 文件 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `RDEPUBParser+Archive.swift` | ZIP 解压(ZIPFoundation),提取到 Caches 目录 |
|
||||||
|
| `RDEPUBParser+Package.swift` | OPF XML SAX 解析(metadata/manifest/spine) |
|
||||||
|
| `RDEPUBParser+TOC.swift` | Navigation Document 和 NCX 解析 |
|
||||||
|
| `RDEPUBParser+Resources.swift` | 资源文件读取(HTML 字符串、文件 URL) |
|
||||||
|
| `RDEPUBParser+ReadingProfile.swift` | 阅读配置文件检测 |
|
||||||
|
|
||||||
|
### 2.2 RDEPUBParserError
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public enum RDEPUBParserError: LocalizedError {
|
||||||
|
case archiveOpenFailed(URL)
|
||||||
|
case missingContainerXML
|
||||||
|
case missingRootFile
|
||||||
|
case invalidRootFilePath(String)
|
||||||
|
case invalidXML(URL)
|
||||||
|
case missingManifestItem(idref: String)
|
||||||
|
case emptySpine
|
||||||
|
case invalidArchiveEntryPath(String)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 数据模型
|
||||||
|
|
||||||
|
### 3.1 RDEPUBMetadata
|
||||||
|
|
||||||
|
**文件:** `RDEPUBModels.swift`
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public struct RDEPUBMetadata: Codable, Equatable {
|
||||||
|
public var identifier: String? // ISBN/UUID
|
||||||
|
public var title: String // 书名
|
||||||
|
public var author: String? // 作者
|
||||||
|
public var language: String? // 语言
|
||||||
|
public var version: String? // EPUB 版本
|
||||||
|
public var layout: RDEPUBLayout // reflowable / fixed
|
||||||
|
public var spread: String? // none / auto
|
||||||
|
public var readingProgression: RDEPUBReadingProgression // ltr / rtl / auto
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 RDEPUBManifestItem
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public struct RDEPUBManifestItem: Codable, Equatable {
|
||||||
|
public var id: String // 资源 ID
|
||||||
|
public var href: String // 相对路径
|
||||||
|
public var mediaType: String // MIME 类型
|
||||||
|
public var properties: [String] // 属性(nav, mathml, svg 等)
|
||||||
|
public var fallback: String? // 回退资源 ID
|
||||||
|
public var mediaOverlay: String? // 媒体叠加 ID
|
||||||
|
public var title: String? // 标题
|
||||||
|
|
||||||
|
public var isNavigationDocument: Bool // 是否为 Navigation Document
|
||||||
|
public var isNCX: Bool // 是否为 NCX 文件
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 RDEPUBSpineItem
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public struct RDEPUBSpineItem: Codable, Equatable {
|
||||||
|
public var idref: String // 引用 manifest ID
|
||||||
|
public var href: String // 解析后的相对路径
|
||||||
|
public var mediaType: String // MIME 类型
|
||||||
|
public var title: String // 章节标题
|
||||||
|
public var linear: Bool // 是否为线性内容
|
||||||
|
public var properties: [String] // 属性(page-spread-left/right)
|
||||||
|
public var pageSpread: RDEPUBPageSpread? // 页面位置
|
||||||
|
public var layout: RDEPUBLayout? // 章节级布局覆盖
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 EPUBTableOfContentsItem
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public struct EPUBTableOfContentsItem: Codable, Equatable {
|
||||||
|
public var title: String // 目录标题
|
||||||
|
public var href: String // 链接地址
|
||||||
|
public var children: [EPUBTableOfContentsItem] // 子目录(支持多级)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.5 枚举类型
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public enum RDEPUBLayout: String, Codable {
|
||||||
|
case reflowable // 文本重排
|
||||||
|
case fixed // 固定布局
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum RDEPUBReadingProfile: String, Codable {
|
||||||
|
case webInteractive // WebView 交互模式(原 EPUB 渲染)
|
||||||
|
case webFixedLayout // WebView 固定布局
|
||||||
|
case textReflowable // 原生文本渲染(DTCoreText)
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum RDEPUBReadingProgression: String, Codable {
|
||||||
|
case ltr, rtl, auto
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum RDEPUBPageSpread: String, Codable {
|
||||||
|
case left, right, center
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Publication
|
||||||
|
|
||||||
|
**文件:** `RDEPUBPublication.swift`
|
||||||
|
|
||||||
|
聚合 `RDEPUBParser` 和 `RDEPUBResourceResolver`,提供统一的出版物访问接口。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public final class RDEPUBPublication {
|
||||||
|
public let parser: RDEPUBParser
|
||||||
|
public let resourceResolver: RDEPUBResourceResolver
|
||||||
|
|
||||||
|
public var metadata: RDEPUBMetadata
|
||||||
|
public var manifest: [String: RDEPUBManifestItem]
|
||||||
|
public var spine: [RDEPUBSpineItem]
|
||||||
|
public var tableOfContents: [EPUBTableOfContentsItem]
|
||||||
|
public var layout: RDEPUBLayout
|
||||||
|
public var readingProfile: RDEPUBReadingProfile
|
||||||
|
public var readingProgression: RDEPUBReadingProgression
|
||||||
|
public var bookIdentifier: String?
|
||||||
|
|
||||||
|
/// 判断 Fixed Layout 是否启用双页展开
|
||||||
|
public func fixedLayoutSpreadEnabled(for preferences: RDEPUBPreferences, viewportSize: CGSize) -> Bool
|
||||||
|
|
||||||
|
/// 生成 Fixed Layout 的 Spread 列表
|
||||||
|
public func makeFixedSpreads(preferences: RDEPUBPreferences, viewportSize: CGSize) -> [EPUBFixedSpread]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 阅读会话
|
||||||
|
|
||||||
|
**文件:** `RDEPUBReadingSession.swift`
|
||||||
|
|
||||||
|
管理阅读过程中的分页快照、阅读位置和待导航状态。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public final class RDEPUBReadingSession {
|
||||||
|
public typealias PaginationSnapshot = (pages: [EPUBPage], chapters: [EPUBChapterInfo])
|
||||||
|
|
||||||
|
public let publication: RDEPUBPublication
|
||||||
|
public private(set) var navigatorState: RDEPUBNavigatorState
|
||||||
|
public private(set) var activePages: [EPUBPage] // 当前活跃页列表
|
||||||
|
public private(set) var activeChapters: [EPUBChapterInfo] // 当前活跃章节列表
|
||||||
|
public private(set) var stagedPages: [EPUBPage]? // 暂存页列表(待应用)
|
||||||
|
public private(set) var stagedChapters: [EPUBChapterInfo]?
|
||||||
|
public private(set) var currentViewport: RDEPUBViewport?
|
||||||
|
public private(set) var currentReadingContext: RDEPUBReadingContext?
|
||||||
|
|
||||||
|
// 状态管理
|
||||||
|
public func transition(to state: RDEPUBNavigatorState)
|
||||||
|
public func resetRuntimeState()
|
||||||
|
|
||||||
|
// 快照管理
|
||||||
|
public func setActiveSnapshot(_ snapshot: PaginationSnapshot)
|
||||||
|
public func stageSnapshot(_ snapshot: PaginationSnapshot, restoreLocation: RDEPUBLocation?)
|
||||||
|
public func consumeStagedSnapshotIfAllowed() -> (snapshot: PaginationSnapshot, restoreLocation: RDEPUBLocation?)?
|
||||||
|
|
||||||
|
// 导航
|
||||||
|
public func queueNavigation(to location: RDEPUBLocation, ...) -> Int?
|
||||||
|
public func updateReadingContext(pageNumber:location:spineIndex:chapterIndex:bookIdentifier:)
|
||||||
|
public func currentReadingLocation(bookIdentifier: String?) -> RDEPUBLocation?
|
||||||
|
|
||||||
|
// 分页快照生成
|
||||||
|
public func makePaginationSnapshot(pageCounts:preferences:layoutContext:) -> PaginationSnapshot
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.1 RDEPUBNavigatorState
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public enum RDEPUBNavigatorState: String, Codable {
|
||||||
|
case initializing // 初始化中
|
||||||
|
case loading // 加载中
|
||||||
|
case idle // 空闲(可应用快照)
|
||||||
|
case jumping // 跳转中
|
||||||
|
case moving // 翻页中
|
||||||
|
case repaginating // 重新分页中
|
||||||
|
|
||||||
|
public var isStableForSnapshotApplication: Bool // 仅 idle 状态为 true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. WebView 子系统
|
||||||
|
|
||||||
|
### 6.1 RDEPUBWebView
|
||||||
|
|
||||||
|
**文件:** `RDEPUBWebView.swift` + 扩展文件
|
||||||
|
|
||||||
|
`UIView` 子类,内部封装 `WKWebView`,支持 Reflowable 和 Fixed Layout 两种渲染模式。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public final class RDEPUBWebView: UIView {
|
||||||
|
public weak var delegate: RDEPUBWebViewDelegate?
|
||||||
|
public var onRendered: (() -> Void)?
|
||||||
|
|
||||||
|
var publication: RDEPUBPublication?
|
||||||
|
var currentRenderRequest: RDEPUBRenderRequest?
|
||||||
|
var webView: WKWebView?
|
||||||
|
var schemeHandler: RDEPUBResourceURLSchemeHandler?
|
||||||
|
var currentSpineIndex: Int
|
||||||
|
var currentHref: String
|
||||||
|
var currentPageIndex: Int
|
||||||
|
var currentTotalPagesInChapter: Int
|
||||||
|
var viewportSize: CGSize
|
||||||
|
var currentFontSize: CGFloat
|
||||||
|
var currentLineHeightMultiple: CGFloat
|
||||||
|
var targetLocation: RDEPUBLocation?
|
||||||
|
var pendingHighlights: [RDEPUBHighlight]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**扩展文件:**
|
||||||
|
|
||||||
|
| 文件 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `RDEPUBWebView+Configuration.swift` | WKWebView 配置(scheme handler、user script) |
|
||||||
|
| `RDEPUBWebView+Reflowable.swift` | Reflowable 模式渲染逻辑 |
|
||||||
|
| `RDEPUBWebView+FixedLayout.swift` | Fixed Layout 模式渲染逻辑 |
|
||||||
|
| `RDEPUBWebView+JavaScriptBridge.swift` | JS 消息接收与处理 |
|
||||||
|
| `RDEPUBWebView+Search.swift` | 搜索高亮渲染 |
|
||||||
|
|
||||||
|
### 6.2 RDEPUBWebViewDelegate
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public protocol RDEPUBWebViewDelegate: AnyObject {
|
||||||
|
func epubWebView(_ webView: RDEPUBWebView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int)
|
||||||
|
func epubWebView(_ webView: RDEPUBWebView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int)
|
||||||
|
func epubWebView(_ webView: RDEPUBWebView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction)
|
||||||
|
func epubWebView(_ webView: RDEPUBWebView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int)
|
||||||
|
func epubWebView(_ webView: RDEPUBWebView, didActivateExternalLink url: URL)
|
||||||
|
func epubWebView(_ webView: RDEPUBWebView, didLogJavaScriptError message: String)
|
||||||
|
func epubWebViewDidFinishRendering(_ webView: RDEPUBWebView)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. JavaScript 桥接
|
||||||
|
|
||||||
|
**文件:** `RDEPUBJavaScriptBridge.swift`
|
||||||
|
|
||||||
|
定义 Native ↔ WebView 的消息协议和脚本注入。
|
||||||
|
|
||||||
|
### 7.1 消息类型
|
||||||
|
|
||||||
|
```swift
|
||||||
|
enum RDEPUBJavaScriptBridgeMessage: String, CaseIterable {
|
||||||
|
case progressionChanged = "ssReaderProgressionChanged" // 阅读进度变化
|
||||||
|
case selectionChanged = "ssReaderSelectionChanged" // 文本选择变化
|
||||||
|
case internalLink = "ssReaderInternalLink" // 内部链接点击
|
||||||
|
case externalLink = "ssReaderExternalLink" // 外部链接点击
|
||||||
|
case javaScriptError = "ssReaderJSError" // JS 错误
|
||||||
|
case fixedLayoutReady = "ssReaderFixedLayoutReady" // Fixed Layout 就绪
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 注入的 JS 资源
|
||||||
|
|
||||||
|
- `rangy-core.js` — 范围与选择 API
|
||||||
|
- `rangy-serializer.js` — 选择序列化
|
||||||
|
- `cssInjector.js` — CSS 注入
|
||||||
|
- `WeReadApi.js` — 阅读器 API(分页、跳转、高亮)
|
||||||
|
- `epub-bridge.js` — 消息桥接
|
||||||
|
|
||||||
|
### 7.3 关键方法
|
||||||
|
|
||||||
|
```swift
|
||||||
|
enum RDEPUBJavaScriptBridge {
|
||||||
|
static var messageNames: [String] // 所有消息名称
|
||||||
|
static var userScript: String // 注入的 User Script
|
||||||
|
|
||||||
|
/// 生成 Reflowable 渲染脚本
|
||||||
|
static func applyPresentationScript(for request: RDEPUBReflowableRenderRequest) -> String
|
||||||
|
|
||||||
|
/// 生成 Fixed Layout 渲染脚本
|
||||||
|
static func applyFixedPresentationScript(for request: RDEPUBFixedRenderRequest) -> String
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 资源解析
|
||||||
|
|
||||||
|
**文件:** `RDEPUBResourceResolver.swift`
|
||||||
|
|
||||||
|
负责 href 归一化、文件路径解析和 spine 索引查找。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public final class RDEPUBResourceResolver {
|
||||||
|
public var opfDirectoryURL: URL?
|
||||||
|
|
||||||
|
/// 将相对路径转为文件 URL
|
||||||
|
public func fileURL(forRelativePath relativePath: String) -> URL?
|
||||||
|
|
||||||
|
/// 将相对路径转为 ss-reader:// 资源 URL
|
||||||
|
public func resourceURL(forRelativePath relativePath: String) -> URL?
|
||||||
|
|
||||||
|
/// href 归一化(去除 fragment,解析相对路径)
|
||||||
|
public func normalizedHref(_ href: String, relativeToSpineIndex spineIndex: Int? = nil) -> String?
|
||||||
|
public func normalizedHref(_ href: String, relativeToHref baseHref: String) -> String?
|
||||||
|
|
||||||
|
/// 位置归一化(合并 href + fragment + progression)
|
||||||
|
public func normalizedLocation(_ location: RDEPUBLocation, relativeToSpineIndex spineIndex: Int?, bookIdentifier: String?) -> RDEPUBLocation?
|
||||||
|
|
||||||
|
/// 根据 href 查找 spine 索引
|
||||||
|
public func spineIndex(forNormalizedHref normalizedHref: String) -> Int?
|
||||||
|
public func spineIndex(for location: RDEPUBLocation) -> Int?
|
||||||
|
|
||||||
|
/// 获取 spine 项的 href 和 title
|
||||||
|
public func href(forSpineIndex spineIndex: Int) -> String?
|
||||||
|
public func title(forSpineIndex spineIndex: Int) -> String?
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. URL 方案处理器
|
||||||
|
|
||||||
|
**文件:** `RDEPUBResourceURLSchemeHandler.swift`
|
||||||
|
|
||||||
|
实现 `WKURLSchemeHandler`,拦截 `ss-reader://book/` 请求,从 EPUB 解压目录流式读取资源文件。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||||
|
public static let scheme = "ss-reader"
|
||||||
|
public static let host = "book"
|
||||||
|
|
||||||
|
public init(parser: RDEPUBParser)
|
||||||
|
|
||||||
|
// WKURLSchemeHandler
|
||||||
|
public func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask)
|
||||||
|
public func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 分页器
|
||||||
|
|
||||||
|
**文件:** `RDEPUBPaginator.swift`
|
||||||
|
|
||||||
|
使用隐藏的 WKWebView 逐章加载 HTML,通过 JS 测量每章的页数。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public final class RDEPUBPaginator: NSObject {
|
||||||
|
/// 计算所有章节的页数
|
||||||
|
public func calculate(
|
||||||
|
parser: RDEPUBParser,
|
||||||
|
hostingView: UIView,
|
||||||
|
presentation: RDEPUBPresentationStyle,
|
||||||
|
completion: @escaping ([Int]) -> Void
|
||||||
|
)
|
||||||
|
|
||||||
|
/// 计算单个章节的页数
|
||||||
|
public func calculateSingleChapter(
|
||||||
|
parser: RDEPUBParser,
|
||||||
|
spineIndex: Int,
|
||||||
|
hostingView: UIView,
|
||||||
|
presentation: RDEPUBPresentationStyle,
|
||||||
|
completion: @escaping (Int) -> Void
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 搜索引擎
|
||||||
|
|
||||||
|
**文件:** `RDEPUBSearchEngine.swift`
|
||||||
|
|
||||||
|
```swift
|
||||||
|
protocol RDEPUBSearchEngine {
|
||||||
|
func search(keyword: String) -> [RDEPUBSearchMatch]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### RDEPUBHTMLSearchEngine
|
||||||
|
|
||||||
|
基于 HTML 的搜索引擎,逐章加载 HTML → 转为纯文本 → 关键词匹配。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
final class RDEPUBHTMLSearchEngine: RDEPUBSearchEngine {
|
||||||
|
init(parser: RDEPUBParser, publication: RDEPUBPublication)
|
||||||
|
func search(keyword: String) -> [RDEPUBSearchMatch]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### RDEPUBTextSearchEngine(EPUBTextRendering 模块)
|
||||||
|
|
||||||
|
基于原生文本的搜索引擎,在 `NSAttributedString` 上直接搜索,性能更好。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
|
||||||
|
init(textBook: RDEPUBTextBook, publication: RDEPUBPublication)
|
||||||
|
func search(keyword: String) -> [RDEPUBSearchMatch]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. CFI 子系统
|
||||||
|
|
||||||
|
**文件:** `CFI/RDEPUBCFI*.swift`(13 个文件)
|
||||||
|
|
||||||
|
实现 EPUB CFI(Canonical Fragment Identifier)标准,用于精确定位 EPUB 内容。
|
||||||
|
|
||||||
|
| 文件 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `RDEPUBCFI.swift` | CFI 值类型,核心解析入口 |
|
||||||
|
| `RDEPUBCFIParser.swift` | CFI 字符串解析器 |
|
||||||
|
| `RDEPUBCFISerializer.swift` | CFI 序列化为字符串 |
|
||||||
|
| `RDEPUBCFIPath.swift` | CFI 路径节点 |
|
||||||
|
| `RDEPUBCFIRange.swift` | CFI 范围(起止点) |
|
||||||
|
| `RDEPUBCFIResolver.swift` | CFI → DOM 位置解析 |
|
||||||
|
| `RDEPUBCFIGenerator.swift` | DOM 位置 → CFI 生成 |
|
||||||
|
| `RDEPUBCFIMap.swift` | 章节级 CFI 映射表 |
|
||||||
|
| `RDEPUBCFIDOMPathBuilder.swift` | DOM 路径构建 |
|
||||||
|
| `RDEPUBCFITextAssertion.swift` | 文本断言(偏移校验) |
|
||||||
|
| `RDEPUBCFIRecoveryEngine.swift` | CFI 恢复引擎(节点变化后重新定位) |
|
||||||
|
| `RDEPUBCFICompatibility.swift` | CFI 兼容性处理 |
|
||||||
|
| `RDEPUBCFIError.swift` | 错误类型 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 样式构建
|
||||||
|
|
||||||
|
**文件:** `RDEPUBStyleSheetBuilder.swift`
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public enum RDEPUBStyleSheetBuilder {
|
||||||
|
/// 向 HTML 注入分页 CSS(用于 Paginator 测量)
|
||||||
|
public static func injectPaginationCSS(into html: String, presentation: RDEPUBPresentationStyle) -> String
|
||||||
|
|
||||||
|
/// 生成渲染用 CSS(viewport 尺寸、字体、行高、主题色)
|
||||||
|
public static func renderCSS(for presentation: RDEPUBPresentationStyle) -> String
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 偏好设置
|
||||||
|
|
||||||
|
**文件:** `RDEPUBPreferences.swift`
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public struct RDEPUBPreferences: Equatable {
|
||||||
|
public var fontSize: CGFloat // 字体大小
|
||||||
|
public var lineHeightMultiple: CGFloat // 行高倍数
|
||||||
|
public var reflowableContentInsets: UIEdgeInsets // Reflowable 内容边距
|
||||||
|
public var fixedContentInset: UIEdgeInsets // Fixed Layout 内容边距
|
||||||
|
public var themeBackgroundColor: String? // 主题背景色(CSS)
|
||||||
|
public var themeTextColor: String? // 主题文字色(CSS)
|
||||||
|
public var fixedBackgroundColor: String? // Fixed 背景色
|
||||||
|
public var fixedLayoutFit: RDEPUBFixedLayoutFit // Fixed 适配模式
|
||||||
|
public var fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode // 双页模式
|
||||||
|
public var numberOfColumns: Int // 列数
|
||||||
|
public var columnGap: CGFloat // 列间距
|
||||||
|
|
||||||
|
/// 生成 PresentationStyle
|
||||||
|
public func presentationStyle(viewportSize: CGSize) -> RDEPUBPresentationStyle
|
||||||
|
|
||||||
|
/// 生成渲染请求
|
||||||
|
public func renderRequest(for page: EPUBPage, publication: RDEPUBPublication, viewportSize: CGSize, ...) -> RDEPUBRenderRequest?
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. 渲染请求模型
|
||||||
|
|
||||||
|
**文件:** `RDEPUBRenderRequest.swift`
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public enum RDEPUBRenderRequest: Equatable {
|
||||||
|
case reflowable(RDEPUBReflowableRenderRequest)
|
||||||
|
case fixed(RDEPUBFixedRenderRequest)
|
||||||
|
|
||||||
|
public var isFixedLayout: Bool
|
||||||
|
public var primarySpineIndex: Int
|
||||||
|
public var primaryHref: String
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct RDEPUBReflowableRenderRequest: Equatable {
|
||||||
|
public var spineIndex: Int
|
||||||
|
public var href: String
|
||||||
|
public var pageIndex: Int
|
||||||
|
public var totalPagesInChapter: Int
|
||||||
|
public var presentation: RDEPUBPresentationStyle
|
||||||
|
public var targetLocation: RDEPUBLocation?
|
||||||
|
public var highlights: [RDEPUBHighlight]
|
||||||
|
public var searchPresentation: RDEPUBSearchPresentation?
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct RDEPUBFixedRenderRequest: Equatable {
|
||||||
|
public var spread: EPUBFixedSpread
|
||||||
|
public var viewportSize: CGSize
|
||||||
|
public var contentInset: UIEdgeInsets
|
||||||
|
public var fit: RDEPUBFixedLayoutFit
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### RDEPUBPresentationStyle
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public struct RDEPUBPresentationStyle: Equatable {
|
||||||
|
public var viewportSize: CGSize
|
||||||
|
public var contentInsets: UIEdgeInsets
|
||||||
|
public var fontSize: CGFloat
|
||||||
|
public var lineHeightMultiple: CGFloat
|
||||||
|
public var numberOfColumns: Int
|
||||||
|
public var columnGap: CGFloat
|
||||||
|
public var themeBackgroundColor: String?
|
||||||
|
public var themeTextColor: String?
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. 资源仓库
|
||||||
|
|
||||||
|
**文件:** `RDEPUBAssetRepository.swift`
|
||||||
|
|
||||||
|
管理 SDK 内置的 JS/CSS/HTML 资源文件加载。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
enum RDEPUBAsset: String {
|
||||||
|
case rangyCoreScript, rangySerializerScript, cssInjectorScript
|
||||||
|
case weReadAPIScript, bridgeScript, fixedLayoutTemplate
|
||||||
|
case wxReadDefaultCSS, wxReadReplaceCSS, wxReadDarkCSS, wxReadLatinReplaceCSS
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RDEPUBAssetRepository {
|
||||||
|
/// 加载资源文件内容,支持模板替换
|
||||||
|
static func string(for asset: RDEPUBAsset, replacements: [String: String] = [:]) -> String
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. 笔记子系统
|
||||||
|
|
||||||
|
**文件:** `Notes/RDEPUBNote*.swift`
|
||||||
|
|
||||||
|
| 文件 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `RDEPUBNoteModels.swift` | 脚注/尾注数据模型 |
|
||||||
|
| `RDEPUBNoteDetector.swift` | 从 HTML 中检测脚注标记 |
|
||||||
|
| `RDEPUBNoteResolver.swift` | 解析脚注内容 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. 设计模式总结
|
||||||
|
|
||||||
|
| 模式 | 应用 |
|
||||||
|
|------|------|
|
||||||
|
| **Builder 模式** | `RDEPUBParser` → `RDEPUBPublication` 构建链 |
|
||||||
|
| **Strategy 模式** | `RDEPUBSearchEngine` 协议,HTML 和 Text 两种实现 |
|
||||||
|
| **URL Scheme 拦截** | `RDEPUBResourceURLSchemeHandler` 自定义协议 |
|
||||||
|
| **消息桥接** | `RDEPUBJavaScriptBridge` Native ↔ JS 双向通信 |
|
||||||
|
| **快照管理** | `RDEPUBReadingSession` staged/active 双快照 |
|
||||||
|
| **状态机** | `RDEPUBNavigatorState` 管理导航状态 |
|
||||||
@@ -1,357 +0,0 @@
|
|||||||
# EPUBCore 功能实现逻辑
|
|
||||||
|
|
||||||
## 1. 范围与目标
|
|
||||||
|
|
||||||
- 代码范围:`Sources/RDReaderView/EPUBCore/`(30 个 Swift 文件 + 2 个资源文件)
|
|
||||||
- 目标:说明 EPUBCore 如何完成 EPUB 解析、资源定位、阅读会话管理、离屏分页、JS 桥接渲染和全文搜索。
|
|
||||||
- 主链路关键词:`epubURL -> RDEPUBParser.parse -> container.xml -> OPF -> spine/TOC -> RDEPUBPublication -> RDEPUBReadingSession -> RDEPUBWebView/Paginator -> 分页/渲染`。
|
|
||||||
|
|
||||||
## 2. 关键对象职责
|
|
||||||
|
|
||||||
### 2.1 解析器 `RDEPUBParser`
|
|
||||||
|
|
||||||
- 文件:`EPUBCore/RDEPUBParser.swift` + 5 个扩展文件
|
|
||||||
- 入口方法:`parse(epubURL:)`
|
|
||||||
- 职责:
|
|
||||||
- ZIP 解压到缓存目录(`RDEPUBParser+Archive.swift`)
|
|
||||||
- 解析 `META-INF/container.xml` 定位 OPF 根文件
|
|
||||||
- 解析 OPF 文档提取 metadata / manifest / spine(`RDEPUBParser+Package.swift`)
|
|
||||||
- 解析目录支持 NCX(EPUB 2)和 Nav Document(EPUB 3)(`RDEPUBParser+TOC.swift`)
|
|
||||||
- 判断阅读配置文件类型(`RDEPUBParser+ReadingProfile.swift`)
|
|
||||||
- 资源路径转换:相对路径 ↔ 文件 URL ↔ `ss-reader://` 自定义 scheme URL(`RDEPUBParser+Resources.swift`)
|
|
||||||
|
|
||||||
### 2.2 Publication 门面 `RDEPUBPublication`
|
|
||||||
|
|
||||||
- 文件:`EPUBCore/RDEPUBPublication.swift`
|
|
||||||
- 职责:
|
|
||||||
- 包装 `RDEPUBParser` + `RDEPUBResourceResolver`,对外暴露只读接口
|
|
||||||
- 提供 metadata、manifest、spine、TOC、layout、readingProfile、readingProgression
|
|
||||||
- 计算 fixed layout 的 spread 组合:`makeFixedSpreads(preferences:viewportSize:)`
|
|
||||||
|
|
||||||
### 2.3 阅读会话 `RDEPUBReadingSession`
|
|
||||||
|
|
||||||
- 文件:`EPUBCore/RDEPUBReadingSession.swift` + `RDEPUBNavigatorState.swift`
|
|
||||||
- 职责:
|
|
||||||
- 管理导航状态机:`initializing -> loading -> idle <-> jumping/moving/repaginating`
|
|
||||||
- 维护活跃/暂存分页快照(`activePages` / `activeChapters` / `stagedPages` / `stagedChapters`)
|
|
||||||
- 管理待定导航目标(`pendingNavigationLocation` / `pendingNavigationPageNum`)
|
|
||||||
- 更新阅读上下文(`currentReadingContext`:location + viewport + pageNumber + chapterIndex)
|
|
||||||
- 构建分页快照:`makePaginationSnapshot(pageCounts:preferences:layoutContext:)`
|
|
||||||
|
|
||||||
### 2.4 WebView 渲染层 `RDEPUBWebView`
|
|
||||||
|
|
||||||
- 文件:`EPUBCore/RDEPUBWebView.swift` + 5 个扩展文件
|
|
||||||
- 职责:
|
|
||||||
- 内部持有 WKWebView,配置自定义 scheme handler 和 JS 桥接
|
|
||||||
- 可重排内容加载:`loadPage(parser:spineIndex:pageIndex:...)`(`+Reflowable.swift`)
|
|
||||||
- 固定版式内容加载:`loadFixedSpread(parser:spread:...)`(`+FixedLayout.swift`)
|
|
||||||
- 处理 JS 桥接消息和链接导航拦截(`+JavaScriptBridge.swift`)
|
|
||||||
- 搜索高亮装饰(`+Search.swift`)
|
|
||||||
- 定义 `RDEPUBWebViewDelegate` 协议(6 个回调方法)
|
|
||||||
|
|
||||||
### 2.5 离屏分页器 `RDEPUBPaginator`
|
|
||||||
|
|
||||||
- 文件:`EPUBCore/RDEPUBPaginator.swift`
|
|
||||||
- 职责:
|
|
||||||
- 创建隐藏的 WKWebView 加载每个 spine 项的 HTML
|
|
||||||
- 注入分页 CSS,运行 JS 测量 `scrollWidth`
|
|
||||||
- 计算 `ceil(scrollWidth / viewportWidth)` 作为页数
|
|
||||||
- 多次测量取最大值(延迟 0ms / 80ms / 180ms)确保布局稳定
|
|
||||||
|
|
||||||
### 2.6 资源解析 `RDEPUBResourceResolver` + `RDEPUBResourceURLSchemeHandler`
|
|
||||||
|
|
||||||
- 文件:`EPUBCore/RDEPUBResourceResolver.swift`、`EPUBCore/RDEPUBResourceURLSchemeHandler.swift`
|
|
||||||
- 职责:
|
|
||||||
- `RDEPUBResourceResolver`:对外门面,提供 href 标准化、spine 索引查找、Location 构建
|
|
||||||
- `RDEPUBResourceURLSchemeHandler`:实现 `WKURLSchemeHandler`,将 `ss-reader://book/<path>` 映射到解压后的文件系统路径,缺失的可选资源(字体/图片/CSS/JS)返回空 200
|
|
||||||
|
|
||||||
### 2.7 JS 桥接
|
|
||||||
|
|
||||||
- 文件:`EPUBCore/RDEPUBJavaScriptBridge.swift`、`Resources/epub-bridge.js`
|
|
||||||
- 6 种消息类型(JS → Swift):
|
|
||||||
- `ssReaderProgressionChanged`:阅读进度变化
|
|
||||||
- `ssReaderSelectionChanged`:文本选择变化
|
|
||||||
- `ssReaderInternalLink`:内部链接点击
|
|
||||||
- `ssReaderExternalLink`:外部链接点击
|
|
||||||
- `ssReaderJSError`:JS 错误
|
|
||||||
- `ssReaderFixedLayoutReady`:固定版式渲染完成
|
|
||||||
- JS 端 `window.RDReaderBridge` 暴露:`applyPagination`、`setPageMetrics`、`scrollToPage`、`scrollToLocation`、`setHighlights`、`clearHighlights`、`reportProgression`、`selectionPayload`
|
|
||||||
|
|
||||||
### 2.8 搜索引擎 `RDEPUBHTMLSearchEngine`
|
|
||||||
|
|
||||||
- 文件:`EPUBCore/RDEPUBSearchEngine.swift`、`EPUBCore/RDEPUBSearchModels.swift`
|
|
||||||
- 职责:
|
|
||||||
- 将 HTML 转为纯文本(NSAttributedString 或正则兜底)
|
|
||||||
- 执行大小写不敏感的全文搜索
|
|
||||||
- 返回 `RDEPUBSearchMatch` 数组(含 progression 偏移和预览文本)
|
|
||||||
|
|
||||||
### 2.9 配置与样式
|
|
||||||
|
|
||||||
- `RDEPUBPreferences`(`EPUBCore/RDEPUBPreferences.swift`):用户阅读偏好(字号、行高、内容边距、主题色、固定版式适配模式),构建 `RDEPUBPresentationStyle` 和 `RDEPUBRenderRequest`
|
|
||||||
- `RDEPUBNavigatorLayoutContext`(`EPUBCore/RDEPUBNavigatorLayoutContext.swift`):容器布局上下文(容器尺寸、每屏页数、安全区域、设备类型)
|
|
||||||
- `RDEPUBStyleSheetBuilder`(`EPUBCore/RDEPUBStyleSheetBuilder.swift`):生成分页 CSS、渲染 CSS 和测量 JS 脚本
|
|
||||||
- `RDEPUBFixedLayoutTemplate`(`EPUBCore/RDEPUBFixedLayoutTemplate.swift`):生成固定版式 HTML 模板
|
|
||||||
|
|
||||||
## 3. 主流程(代码级)
|
|
||||||
|
|
||||||
### 3.1 EPUB 解析全流程
|
|
||||||
|
|
||||||
1. 入口:`RDEPUBParser.parse(epubURL:)`。
|
|
||||||
2. 调用 `extractArchiveIfNeeded(epubURL:)`:
|
|
||||||
- 缓存路径:`~/Library/Caches/ssreaderview-epub/<slug>-<fileSize>-<timestamp>/`
|
|
||||||
- 若目录已存在则跳过解压
|
|
||||||
- 否则用 ZIPFoundation 解压 ZIP 到缓存目录
|
|
||||||
3. 解析 `META-INF/container.xml`:
|
|
||||||
- `ContainerXMLParserDelegate`(SAX)找到第一个 `<rootfile>` 的 `full-path` 属性
|
|
||||||
4. 解析 OPF 文档:
|
|
||||||
- `OPFPackageParserDelegate`(SAX)分段解析 metadata / manifest / spine
|
|
||||||
- 提取 `<package>` 版本和 unique-identifier
|
|
||||||
- 提取 `<dc:identifier>` / `<dc:title>` / `<dc:creator>` / `<dc:language>`
|
|
||||||
- 提取 `<meta property="rendition:layout">` 判断 fixed/reflowable
|
|
||||||
- 提取 manifest 每个 `<item>` 为 `RDEPUBManifestItem`
|
|
||||||
- 提取 spine 每个 `<itemref>` 为 `OPFSpineReference`
|
|
||||||
5. 构建 spine:`buildSpine(from:opfURL:)`
|
|
||||||
- 将 spine reference 匹配到 manifest item
|
|
||||||
- 标准化 href(相对于 OPF 目录)
|
|
||||||
- 确定每项的有效 layout(显式属性覆盖出版级设置)
|
|
||||||
- 返回 `[RDEPUBSpineItem]`,空 spine 抛 `emptySpine` 错误
|
|
||||||
6. 解析目录:`parseTOC(from:opfURL:)`
|
|
||||||
- 优先 NCX(`NCXParserDelegate`),其次 Nav Document(`NavDocumentParserDelegate`)
|
|
||||||
- 兜底使用 spine 标题
|
|
||||||
- href 标准化保留 fragment 标识符
|
|
||||||
7. 创建 Publication:`makePublication()` → `RDEPUBPublication(parser: self)`
|
|
||||||
|
|
||||||
### 3.2 阅读配置文件判断
|
|
||||||
|
|
||||||
- 入口:`RDEPUBParser+ReadingProfile.swift`
|
|
||||||
- 判断逻辑:
|
|
||||||
- 扫描 manifest 检查是否有 `scripted` 属性的项
|
|
||||||
- 扫描 HTML body 检查是否有 `script`、`iframe`、`video` 等交互元素
|
|
||||||
- 结果:
|
|
||||||
- `.webFixedLayout`:固定版式 EPUB(漫画、绘本)— WKWebView 渲染
|
|
||||||
- `.webInteractive`:可重排 + 交互脚本 EPUB — WKWebView 渲染
|
|
||||||
- `.textReflowable`:纯文本可重排 EPUB — DTCoreText 渲染
|
|
||||||
|
|
||||||
### 3.3 离屏分页测量
|
|
||||||
|
|
||||||
1. 入口:`RDEPUBPaginator.calculate(parser:hostingView:presentation:completion:)`
|
|
||||||
2. 初始化所有 spine 项页数为 `[1, 1, ..., 1]`
|
|
||||||
3. 固定版式直接返回(每 spread = 1 页)
|
|
||||||
4. 顺序遍历可渲染的 HTML/XHTML spine 项
|
|
||||||
5. 对每项:
|
|
||||||
- `webView.loadFileURL(...)` 加载文件
|
|
||||||
- `didFinish` 后启动多次测量(`scheduleMeasurementPass`)
|
|
||||||
- 3 次测量延迟 [0ms, 80ms, 180ms]
|
|
||||||
- 每次执行 `measurementScript`:注入分页 CSS → 测量 `scrollWidth` → 返回 `Math.max(1, Math.ceil(totalWidth / viewportWidth))`
|
|
||||||
- 取多次测量的最大值
|
|
||||||
6. 全部完成后回调 `completion([Int])` 页数数组
|
|
||||||
7. `activeSessionID`(UUID)防止过期回调污染结果
|
|
||||||
|
|
||||||
### 3.4 WebView 可重排内容加载
|
|
||||||
|
|
||||||
1. 入口:`RDEPUBWebView.loadPage(parser:spineIndex:pageIndex:...)`
|
|
||||||
2. 计算 `loadSignature`(包含 publication key、spine index、href、viewport、字号、行高、主题色、目标位置、高亮等)
|
|
||||||
3. 若签名与 `currentLoadSignature` 相同且已渲染/正在加载,跳过重复请求
|
|
||||||
4. 若签名匹配但文档 URL 已加载,仅调用 `applyPresentation()` 重新应用样式
|
|
||||||
5. 否则 `handleReflowableLoad(...)` 执行完整加载:
|
|
||||||
- 读取 HTML 内容
|
|
||||||
- 调用 `applyPresentationScript(for:)` 生成 JS
|
|
||||||
- JS 执行:`RDReaderBridge.applyPagination(css)` → `setPageMetrics` → 清除并重新应用高亮 → 滚动到目标位置 → 报告进度 → 应用搜索装饰
|
|
||||||
|
|
||||||
### 3.5 固定版式内容加载
|
|
||||||
|
|
||||||
1. 入口:`RDEPUBWebView.loadFixedSpread(parser:spread:...)`
|
|
||||||
2. `RDEPUBFixedLayoutTemplate.html(for:publication:)` 从模板生成 HTML,包含 spread 中每个资源的 `<iframe>`
|
|
||||||
3. `webView.loadHTMLString(html, baseURL: "ss-reader://book/")` 加载
|
|
||||||
4. 模板 JS 加载每个 iframe,检测页面尺寸,计算缩放(auto/page/width 适配模式),定位 iframe
|
|
||||||
5. 所有 iframe 加载完成后 JS 发送 `ssReaderFixedLayoutReady`
|
|
||||||
6. Swift 收到消息后应用搜索装饰并调用 `rendered()`
|
|
||||||
7. 1 秒兜底定时器确保即使 JS 消息丢失也能调用 `rendered()`
|
|
||||||
|
|
||||||
### 3.6 JS 桥接消息处理
|
|
||||||
|
|
||||||
- 入口:`RDEPUBWebView+JavaScriptBridge.swift` 的 `userContentController(_:didReceive:)`
|
|
||||||
- 分发逻辑:
|
|
||||||
- `progressionChanged` → 提取 progression/lastProgression/fragment → `delegate?.epubWebView(_:didUpdateLocation:spineIndex:)`
|
|
||||||
- `selectionChanged` → 提取 text/rangeInfo → 创建 `RDEPUBSelection` → `delegate?.epubWebView(_:didChangeSelection:spineIndex:)`
|
|
||||||
- `internalLink` → 创建 `RDEPUBLocation` → `delegate?.epubWebView(_:didActivateInternalLink:fromSpineIndex:)`
|
|
||||||
- `externalLink` → 提取 URL → `delegate?.epubWebView(_:didActivateExternalLink:)`
|
|
||||||
- `javaScriptError` → `delegate?.epubWebView(_:didLogJavaScriptError:)`
|
|
||||||
- `fixedLayoutReady` → 应用搜索装饰 → `rendered()`
|
|
||||||
|
|
||||||
### 3.7 链接导航拦截
|
|
||||||
|
|
||||||
- 入口:`decidePolicyFor navigationAction`
|
|
||||||
- 逻辑:
|
|
||||||
- `ss-reader://` URL + `.linkActivated` → 内部链接代理回调,取消导航
|
|
||||||
- `http/https/mailto/tel` → 外部链接代理回调,取消导航
|
|
||||||
- 其他 → 允许导航
|
|
||||||
|
|
||||||
## 4. 异常与边界处理
|
|
||||||
|
|
||||||
- `RDEPUBParserError`(7 种,全部 `LocalizedError`,中文描述):
|
|
||||||
- `archiveOpenFailed(URL)`:ZIPFoundation 无法打开 EPUB 文件
|
|
||||||
- `missingContainerXML`:`META-INF/container.xml` 不存在
|
|
||||||
- `missingRootFile`:container.xml 中无 `<rootfile>`
|
|
||||||
- `invalidRootFilePath(String)`:rootfile 路径对应的 OPF 文件不存在
|
|
||||||
- `invalidXML(URL)`:XMLParser 无法创建或解析失败
|
|
||||||
- `missingManifestItem(idref:)`:spine 引用了不存在的 manifest 项
|
|
||||||
- `emptySpine`:构建后 spine 为空
|
|
||||||
- 目录解析:NCX 和 Nav Document 解析器失败时返回空数组,不抛异常,兜底使用 spine 标题
|
|
||||||
- 资源缺失:`RDEPUBResourceURLSchemeHandler` 对缺失的可选资源(字体/图片/CSS/JS)返回空 200,避免 WKWebView 控制台噪音
|
|
||||||
- 分页失败:`RDEPUBPaginator` 导航失败(`didFail` / `didFailProvisional`)时将页数设为 1 并继续下一项
|
|
||||||
- HTML 转纯文本兜底:`RDEPUBHTMLSearchEngine` 在 NSAttributedString 失败时回退到正则去标签
|
|
||||||
- 固定版式 1 秒兜底:`scheduleFixedLayoutReadyFallback()` 确保即使 JS ready 消息丢失也能调用 `rendered()`
|
|
||||||
- 重复请求跳过:`handleReflowableLoad` 在 loadSignature 匹配且已渲染/正在加载时跳过
|
|
||||||
|
|
||||||
## 5. 数据结构与字段映射
|
|
||||||
|
|
||||||
### 5.1 EPUB 元数据
|
|
||||||
|
|
||||||
```
|
|
||||||
RDEPUBParser
|
|
||||||
├── metadata: RDEPUBMetadata
|
|
||||||
│ ├── identifier, title, author, language, version
|
|
||||||
│ ├── layout: RDEPUBLayout (.reflowable | .fixed)
|
|
||||||
│ ├── readingProgression: RDEPUBReadingProgression (.ltr | .rtl | .auto)
|
|
||||||
│ └── spread: String?
|
|
||||||
├── manifest: [String: RDEPUBManifestItem]
|
|
||||||
│ ├── id, href, mediaType
|
|
||||||
│ ├── properties: [String] (nav, scripted, cover-image, rendition:layout-*)
|
|
||||||
│ └── title: String?
|
|
||||||
├── spine: [RDEPUBSpineItem]
|
|
||||||
│ ├── idref -> manifest[id]
|
|
||||||
│ ├── href (normalized), mediaType, title, linear
|
|
||||||
│ ├── pageSpread: RDEPUBPageSpread? (.left | .right | .center)
|
|
||||||
│ └── layout: RDEPUBLayout? (per-item override)
|
|
||||||
└── tableOfContents: [EPUBTableOfContentsItem]
|
|
||||||
├── title, href
|
|
||||||
└── children: [EPUBTableOfContentsItem] (recursive)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.2 阅读位置模型
|
|
||||||
|
|
||||||
```
|
|
||||||
RDEPUBLocation
|
|
||||||
├── bookIdentifier, href
|
|
||||||
├── progression: Double? (0..1,章节内起始位置)
|
|
||||||
├── lastProgression: Double? (0..1,章节内结束位置)
|
|
||||||
├── fragment: String? (#anchor)
|
|
||||||
└── navigationProgression (computed: midpoint of progression and lastProgression)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.3 视口模型
|
|
||||||
|
|
||||||
```
|
|
||||||
RDEPUBViewport
|
|
||||||
├── resources: [RDEPUBViewportResource]
|
|
||||||
│ ├── href, spineIndex
|
|
||||||
│ ├── progression, lastProgression, fragment
|
|
||||||
├── visiblePageNumber, chapterIndex
|
|
||||||
└── isFixedLayout: Bool
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.4 渲染请求
|
|
||||||
|
|
||||||
```
|
|
||||||
RDEPUBRenderRequest (enum)
|
|
||||||
├── .reflowable(RDEPUBReflowableRenderRequest)
|
|
||||||
│ ├── spineIndex, href, pageIndex, totalPagesInChapter
|
|
||||||
│ ├── presentation: RDEPUBPresentationStyle
|
|
||||||
│ │ ├── viewportSize, contentInsets
|
|
||||||
│ │ ├── fontSize, lineHeightMultiple
|
|
||||||
│ │ └── themeBackgroundColor, themeTextColor
|
|
||||||
│ ├── targetLocation: RDEPUBLocation?
|
|
||||||
│ ├── highlights: [RDEPUBHighlight]
|
|
||||||
│ └── searchPresentation: RDEPUBSearchPresentation?
|
|
||||||
└── .fixed(RDEPUBFixedRenderRequest)
|
|
||||||
├── spread: EPUBFixedSpread
|
|
||||||
│ └── resources: [EPUBFixedSpreadResource]
|
|
||||||
├── viewportSize, contentInset
|
|
||||||
├── backgroundColorCSS
|
|
||||||
├── fit: RDEPUBFixedLayoutFit (.auto | .page | .width)
|
|
||||||
└── searchPresentation: RDEPUBSearchPresentation?
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.5 高亮与选择
|
|
||||||
|
|
||||||
```
|
|
||||||
RDEPUBHighlight
|
|
||||||
├── id, bookIdentifier
|
|
||||||
├── location: RDEPUBLocation
|
|
||||||
├── text, rangeInfo (JSON-serialized DOM range)
|
|
||||||
├── color: String, note: String
|
|
||||||
└── createdAt: Date
|
|
||||||
|
|
||||||
RDEPUBSelection
|
|
||||||
├── bookIdentifier
|
|
||||||
├── location: RDEPUBLocation
|
|
||||||
├── text, rangeInfo
|
|
||||||
└── createdAt: Date
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.6 搜索模型
|
|
||||||
|
|
||||||
```
|
|
||||||
RDEPUBSearchState
|
|
||||||
├── keyword: String
|
|
||||||
├── matches: [RDEPUBSearchMatch]
|
|
||||||
│ ├── href, progression
|
|
||||||
│ ├── previewText, localMatchIndex
|
|
||||||
│ ├── rangeLocation, rangeLength
|
|
||||||
└── currentMatchIndex: Int
|
|
||||||
```
|
|
||||||
|
|
||||||
## 6. 分页与渲染机制
|
|
||||||
|
|
||||||
### 6.1 CSS 多栏分页原理
|
|
||||||
|
|
||||||
- HTML 内容被包裹为 `body > div#ss-reader-viewport > div#ss-reader-content`
|
|
||||||
- `#ss-reader-content` 应用 `-webkit-column-width: <viewportWidth>px`
|
|
||||||
- `html` / `body` 设置 `overflow: hidden`
|
|
||||||
- 页间导航通过 `transform: translate3d(-N*stride, 0, 0)` 实现
|
|
||||||
|
|
||||||
### 6.2 重新布局触发条件
|
|
||||||
|
|
||||||
- `scheduleRelayout()`(60ms 防抖)在以下事件触发:
|
|
||||||
- `window.load`、`window.resize`
|
|
||||||
- `document.fonts.ready`
|
|
||||||
- 每个 `<img>` / `<iframe>` / `<video>` 的 load/error 事件
|
|
||||||
|
|
||||||
### 6.3 选择变化处理
|
|
||||||
|
|
||||||
- `selectionchange` 监听器(120ms 防抖)发送 `ssReaderSelectionChanged` 消息,携带 text、rangeInfo、progression
|
|
||||||
|
|
||||||
## 7. 缓存策略
|
|
||||||
|
|
||||||
- **EPUB 解压缓存**:解压目录按 `文件大小 + 修改时间` 生成缓存 key,已存在则跳过解压
|
|
||||||
- **Load Signature 去重**:每次渲染请求计算签名(含 publication key、layout、spine index、viewport、字号、行高、主题色、目标位置、高亮),相同签名跳过重复加载
|
|
||||||
- **WKWebView 复用**:同一 publication 配置下复用已有 WKWebView 实例,仅切换 publication 时才 teardown
|
|
||||||
- **非持久化数据存储**:`WKWebViewConfiguration().websiteDataStore = .nonPersistent()`,不缓存 Web 内容到磁盘
|
|
||||||
|
|
||||||
## 8. 联调与排查建议
|
|
||||||
|
|
||||||
- 排查 1:EPUB 解析失败
|
|
||||||
- 检查 `parse(epubURL:)` 抛出的 `RDEPUBParserError` 具体类型
|
|
||||||
- 检查 EPUB 文件是否损坏(ZIP 能否正常打开)
|
|
||||||
- 检查 `META-INF/container.xml` 是否存在且格式正确
|
|
||||||
- 排查 2:目录为空
|
|
||||||
- 检查 OPF 中是否有 NCX 或 Nav Document 引用
|
|
||||||
- 检查 NCX 文件路径是否正确(相对于 OPF 目录)
|
|
||||||
- 目录解析失败时会兜底使用 spine 标题
|
|
||||||
- 排查 3:页面内容空白
|
|
||||||
- 检查 `ss-reader://` scheme handler 是否正确映射资源路径
|
|
||||||
- 检查 `RDEPUBResourceURLSchemeHandler` 日志(`RDEPUBWebViewDebug`)
|
|
||||||
- 确认 HTML 文件在解压目录中实际存在
|
|
||||||
- 排查 4:分页页数不正确
|
|
||||||
- 检查 `RDEPUBPaginator` 多次测量结果是否一致
|
|
||||||
- 检查 viewport 尺寸计算是否正确(容器宽度 / pagesPerScreen)
|
|
||||||
- 检查 CSS 分页样式是否正确注入
|
|
||||||
- 排查 5:JS 桥接消息未收到
|
|
||||||
- 确认 `epub-bridge.js` 已正确注入(检查 `WKUserContentController`)
|
|
||||||
- 检查 JS 控制台是否有错误
|
|
||||||
- 固定版式检查是否收到 `ssReaderFixedLayoutReady`,否则看 1 秒兜底定时器
|
|
||||||
- 排查 6:内部链接不跳转
|
|
||||||
- 检查链接 href 是否以 `ss-reader://` 开头
|
|
||||||
- 检查 `decidePolicyFor navigationAction` 中的链接类型判断
|
|
||||||
- 确认 `RDEPUBLocation` 构建是否正确(href + fragment)
|
|
||||||
@@ -0,0 +1,521 @@
|
|||||||
|
# EPUBTextRendering 模块代码级参考文档
|
||||||
|
|
||||||
|
> 最后更新:2026-06-18
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 模块概述
|
||||||
|
|
||||||
|
`EPUBTextRendering` 是 ReadViewSDK 的**文本渲染层**,负责将 EPUB HTML 内容转换为原生 `NSAttributedString`,执行排版分页,并构建全书文本模型。它位于 EPUBCore 之上、EPUBUI 之下,是 `textReflowable` 阅读配置文件的核心引擎。
|
||||||
|
|
||||||
|
**文件清单(~30 个 Swift 文件):**
|
||||||
|
|
||||||
|
| 子系统 | 核心文件 | 职责 |
|
||||||
|
|--------|----------|------|
|
||||||
|
| **排版管线** | `Typesetter/RDEPUBTypesettingPipeline.swift` | HTML → 标记化 HTML 的多阶段管线 |
|
||||||
|
| **文本渲染** | `RDEPUBTextRenderer.swift`, `RDEPUBDTCoreTextRenderer.swift` | HTML → NSAttributedString 转换 |
|
||||||
|
| **分页引擎** | `Pagination/*.swift` | CoreText 排版、页帧计算、分页策略 |
|
||||||
|
| **构建管线** | `BuildPipeline/*.swift` | 全书构建(渲染 → 分页 → 缓存) |
|
||||||
|
| **搜索** | `RDEPUBTextSearchEngine.swift` | 基于原生文本的全文搜索 |
|
||||||
|
| **索引** | `RDEPUBTextIndexTable.swift` | 全书字符偏移索引、CFI 映射 |
|
||||||
|
| **位置转换** | `RDEPUBTextPositionConverter.swift` | 字符偏移 ↔ 页码转换 |
|
||||||
|
| **章节数据** | `RDEPUBChapterData.swift` | 章节级数据访问封装 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 排版管线(Typesetter)
|
||||||
|
|
||||||
|
### 2.1 RDEPUBTextTypesettingPipeline
|
||||||
|
|
||||||
|
**文件:** `Typesetter/RDEPUBTypesettingPipeline.swift`
|
||||||
|
|
||||||
|
多阶段 HTML 处理管线,将原始 HTML 转换为可渲染的标记化 HTML。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
struct RDEPUBTextTypesetterPipeline {
|
||||||
|
func makeRequest(from input: RDEPUBTypesettingInput) -> RDEPUBTypesettingOutput
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**管线阶段:**
|
||||||
|
|
||||||
|
```
|
||||||
|
原始 HTML
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
RDEPUBHTMLNormalizer → 清理 HTML(移除脚本、修复标签)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
RDEPUBSemanticMarkerInjector → 注入语义标记(段落、列表、表格等)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
RDEPUBCFIMarkerInjector → 注入 CFI 定位标记
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
RDEPUBStyleSheetComposer → 合并样式表(内联 CSS + 兼容性处理)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
RDEPUBFontNormalizer → 注册嵌入字体、字体回退
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
RDEPUBFragmentMarkerInjector → 注入片段标记(用于锚点定位)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
标记化 HTML + 诊断报告
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 管线输入/输出
|
||||||
|
|
||||||
|
```swift
|
||||||
|
struct RDEPUBTypesettingInput {
|
||||||
|
var href: String // 章节 href
|
||||||
|
var spineIndex: Int?
|
||||||
|
var title: String // 章节标题
|
||||||
|
var rawHTML: String // 原始 HTML
|
||||||
|
var baseURL: URL? // 资源基础 URL
|
||||||
|
var style: RDEPUBTextRenderStyle // 渲染样式
|
||||||
|
var resourceResolver: RDEPUBResourceResolver?
|
||||||
|
var contentLanguageCode: String?
|
||||||
|
var pageSize: CGSize?
|
||||||
|
var layoutConfig: RDEPUBTextLayoutConfig?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RDEPUBTypesettingOutput {
|
||||||
|
var request: RDEPUBTextChapterRenderRequest // 渲染请求
|
||||||
|
var diagnostics: [RDEPUBTextResourceReferenceDiagnostic] // 资源引用诊断
|
||||||
|
var styleCompatibilityReport: RDEPUBCSSCompatibilityReport // CSS 兼容性报告
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 各阶段处理器
|
||||||
|
|
||||||
|
| 处理器 | 文件 | 职责 |
|
||||||
|
|--------|------|------|
|
||||||
|
| `RDEPUBHTMLNormalizer` | `RDEPUBHTMLNormalizer.swift` | 清理 HTML:移除 `<script>`、修复未闭合标签、规范化编码 |
|
||||||
|
| `RDEPUBSemanticMarkerInjector` | `RDEPUBSemanticMarkerInjector.swift` | 为块级元素注入 `data-rd-block-kind` 属性 |
|
||||||
|
| `RDEPUBCFIMarkerInjector` | `RDEPUBCFIMarkerInjector.swift` | 为文本节点注入 `data-rd-cfi` 属性 |
|
||||||
|
| `RDEPUBStyleSheetComposer` | `RDEPUBStyleSheetComposer.swift` | 合并 EPUB 原始 CSS + SDK 样式 + 兼容性补丁 |
|
||||||
|
| `RDEPUBFontNormalizer` | `RDEPUBFontNormalizer.swift` | 检测并注册 `@font-face` 嵌入字体 |
|
||||||
|
| `RDEPUBFragmentMarkerInjector` | `RDEPUBFragmentMarkerInjector.swift` | 为 `id` 属性注入片段偏移标记 |
|
||||||
|
| `RDEPUBAttachmentNormalizer` | `RDEPUBAttachmentNormalizer.swift` | 规范化 `<img>`、`<svg>` 等附件元素 |
|
||||||
|
| `RDEPUBRenderDiagnosticsCollector` | `RDEPUBRenderDiagnosticsCollector.swift` | 收集渲染诊断信息(缺失资源、不支持的特性) |
|
||||||
|
|
||||||
|
### 2.4 CSS 兼容性层
|
||||||
|
|
||||||
|
**文件:** `Typesetter/Compatibility/`
|
||||||
|
|
||||||
|
| 文件 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `RDEPUBCSSCompatibilityLayer.swift` | CSS 兼容性补丁(webkit 前缀、不支持属性替换) |
|
||||||
|
| `RDEPUBFontFallbackResolver.swift` | 字体回退链解析 |
|
||||||
|
| `RDEPUBStyleCompatibilityModels.swift` | 兼容性报告模型 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 文本渲染器
|
||||||
|
|
||||||
|
### 3.1 RDEPUBTextRenderer(协议)
|
||||||
|
|
||||||
|
**文件:** `RDEPUBTextRenderer.swift`
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public protocol RDEPUBTextRenderer {
|
||||||
|
/// 将 HTML 渲染为 NSAttributedString
|
||||||
|
func render(html: String, baseURL: URL?, style: RDEPUBTextRenderStyle) -> NSAttributedString
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 RDEPUBDTCoreTextRenderer
|
||||||
|
|
||||||
|
**文件:** `RDEPUBDTCoreTextRenderer.swift`
|
||||||
|
|
||||||
|
基于 DTCoreText 的渲染器实现,将 HTML 转换为 `NSAttributedString`。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public final class RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
|
||||||
|
public init()
|
||||||
|
public func render(html: String, baseURL: URL?, style: RDEPUBTextRenderStyle) -> NSAttributedString
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 渲染样式
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public struct RDEPUBTextRenderStyle {
|
||||||
|
public var font: UIFont // 字体
|
||||||
|
public var lineSpacing: CGFloat // 行间距
|
||||||
|
public var textColor: UIColor? // 文字颜色
|
||||||
|
public var backgroundColor: UIColor? // 背景颜色
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 NSAttributedString 扩展键
|
||||||
|
|
||||||
|
```swift
|
||||||
|
extension NSAttributedString.Key {
|
||||||
|
static let rdPageBlockRange // 块级元素范围
|
||||||
|
static let rdPageBlockIndex // 块索引
|
||||||
|
static let rdPageFragmentID // 片段 ID
|
||||||
|
static let rdPageAttachmentKind // 附件类型
|
||||||
|
static let rdPageBlockKind // 块类型(paragraph/list/table/code/blockquote/attachment/generic)
|
||||||
|
static let rdPageSemanticHints // 语义提示(avoidPageBreakInside/keepWithNext/pageBreakBefore/After)
|
||||||
|
static let rdPageAttachmentPlacement // 附件放置方式(inline/baseline/centered)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 分页引擎
|
||||||
|
|
||||||
|
### 4.1 RDEPUBTextLayouter
|
||||||
|
|
||||||
|
**文件:** `Pagination/RDEPUBTextLayouter.swift`
|
||||||
|
|
||||||
|
分页的核心入口,组合 `RDEPUBCoreTextPageFrameFactory` 和 `RDEPUBChapterPageCounter`。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
struct RDEPUBTextLayouter {
|
||||||
|
init(attributedString: NSAttributedString, pageSize: CGSize, config: RDEPUBTextLayoutConfig = .default)
|
||||||
|
func layoutFrames(fragmentOffsets: [String: Int] = [:]) -> [RDEPUBTextLayoutFrame]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 RDEPUBTextLayoutConfig
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public struct RDEPUBTextLayoutConfig: Equatable {
|
||||||
|
public var frameWidth: CGFloat // 帧宽度
|
||||||
|
public var frameHeight: CGFloat // 帧高度
|
||||||
|
public var edgeInsets: UIEdgeInsets // 内边距
|
||||||
|
public var numberOfColumns: Int // 列数
|
||||||
|
public var columnGap: CGFloat // 列间距
|
||||||
|
public var avoidOrphans: Bool // 避免孤行
|
||||||
|
public var avoidWidows: Bool // 避免寡行
|
||||||
|
public var avoidPageBreakInsideEnabled: Bool // 避免块内分页
|
||||||
|
public var hyphenation: Bool // 连字符断词
|
||||||
|
|
||||||
|
public static let `default`: RDEPUBTextLayoutConfig
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 RDEPUBCoreTextPageFrameFactory
|
||||||
|
|
||||||
|
**文件:** `Pagination/RDEPUBCoreTextPageFrameFactory.swift`
|
||||||
|
|
||||||
|
使用 CoreText 排版引擎,将 `NSAttributedString` 排列到指定尺寸的帧中。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
struct RDEPUBCoreTextPageFrameFactory {
|
||||||
|
init(attributedString: NSAttributedString, pageSize: CGSize, config: RDEPUBTextLayoutConfig)
|
||||||
|
func makeFrames(fragmentOffsets: [String: Int] = [:]) -> [RDEPUBTextLayoutFrame]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 RDEPUBChapterPageCounter
|
||||||
|
|
||||||
|
**文件:** `Pagination/RDEPUBChapterPageCounter.swift`
|
||||||
|
|
||||||
|
基于 `RDEPUBCoreTextPageFrameFactory` 计算页范围。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
struct RDEPUBChapterPageCounter {
|
||||||
|
init(factory: RDEPUBCoreTextPageFrameFactory)
|
||||||
|
func layoutFrames(fragmentOffsets: [String: Int] = [:]) -> [RDEPUBTextLayoutFrame]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 RDEPUBTextLayoutFrame
|
||||||
|
|
||||||
|
**文件:** `Pagination/RDEPUBTextLayoutFrame.swift`
|
||||||
|
|
||||||
|
表示一个排版帧(一页的内容范围)。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public struct RDEPUBTextLayoutFrame {
|
||||||
|
public let range: NSRange // 字符范围
|
||||||
|
public let frameIndex: Int // 帧索引
|
||||||
|
public let diagnostics: [String] // 诊断信息
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.6 RDEPUBPageBreakPolicy
|
||||||
|
|
||||||
|
**文件:** `Pagination/RDEPUBPageBreakPolicy.swift`
|
||||||
|
|
||||||
|
分页策略,决定在何处断页。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
struct RDEPUBPageBreakPolicy {
|
||||||
|
/// 检查指定位置是否允许分页
|
||||||
|
func canBreak(at offset: Int, in attributedString: NSAttributedString) -> Bool
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 构建管线
|
||||||
|
|
||||||
|
### 5.1 RDEPUBTextBookBuilder
|
||||||
|
|
||||||
|
**文件:** `BuildPipeline/RDEPUBTextBookBuilder.swift`
|
||||||
|
|
||||||
|
全书构建的核心类,串联渲染、分页、缓存和诊断。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public final class RDEPUBTextBookBuilder {
|
||||||
|
public init(renderer: RDEPUBTextRenderer, cache: RDEPUBTextBookCache?, layoutConfig: RDEPUBTextLayoutConfig)
|
||||||
|
|
||||||
|
/// 构建全书文本模型
|
||||||
|
public func build(
|
||||||
|
parser: RDEPUBParser,
|
||||||
|
publication: RDEPUBPublication,
|
||||||
|
pageSize: CGSize,
|
||||||
|
style: RDEPUBTextRenderStyle
|
||||||
|
) throws -> RDEPUBTextBook
|
||||||
|
|
||||||
|
// 诊断信息
|
||||||
|
public private(set) var lastBuildResourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic]
|
||||||
|
public private(set) var lastBuildPaginationDiagnostics: [RDEPUBTextChapterPaginationDiagnostic]
|
||||||
|
public private(set) var lastBuildPerformanceSamples: [RDEPUBTextPerformanceSample]
|
||||||
|
public private(set) var lastBuildCacheStats: (hits: Int, misses: Int)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**构建流程:**
|
||||||
|
|
||||||
|
```
|
||||||
|
RDEPUBParser + RDEPUBPublication
|
||||||
|
│
|
||||||
|
▼ 遍历 spine(linear 章节)
|
||||||
|
│
|
||||||
|
├── 检查缓存(RDEPUBPaginationCacheCoordinator)
|
||||||
|
│ ├── 命中 → 直接使用缓存的分页结果
|
||||||
|
│ └── 未命中 → 执行渲染 + 分页
|
||||||
|
│
|
||||||
|
├── RDEPUBChapterRenderPipeline(渲染单章)
|
||||||
|
│ └── RDEPUBTextTypesetterPipeline → RDEPUBTextRenderer
|
||||||
|
│
|
||||||
|
├── RDEPUBChapterPaginationPipeline(分页单章)
|
||||||
|
│ └── RDEPUBTextLayouter → [RDEPUBTextLayoutFrame]
|
||||||
|
│
|
||||||
|
├── RDEPUBChapterTailNormalizer(章节尾部规范化)
|
||||||
|
│
|
||||||
|
└── 组装 RDEPUBTextChapter + RDEPUBTextPage
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
RDEPUBTextBook(全书模型)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 辅助组件
|
||||||
|
|
||||||
|
| 文件 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `RDEPUBChapterRenderPipeline.swift` | 单章渲染管线 |
|
||||||
|
| `RDEPUBChapterPaginationPipeline.swift` | 单章分页管线 |
|
||||||
|
| `RDEPUBChapterTailNormalizer.swift` | 章节尾部空白处理 |
|
||||||
|
| `RDEPUBPaginationCacheCoordinator.swift` | 分页缓存协调 |
|
||||||
|
| `RDEPUBTextBookCache.swift` | 分页缓存存储 |
|
||||||
|
| `RDEPUBTextPerformanceSampler.swift` | 性能采样 |
|
||||||
|
| `RDEPUBBuildDiagnosticsReporter.swift` | 构建诊断报告 |
|
||||||
|
| `RDEPUBTextBuildPipelineInterfaces.swift` | 管线接口定义 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 全书模型
|
||||||
|
|
||||||
|
### 6.1 RDEPUBTextBook
|
||||||
|
|
||||||
|
**文件:** `BuildPipeline/RDEPUBTextBookModels.swift`
|
||||||
|
|
||||||
|
全书文本模型,包含所有章节和页面。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public struct RDEPUBTextBook {
|
||||||
|
public var chapters: [RDEPUBTextChapter] // 所有章节
|
||||||
|
public var pages: [RDEPUBTextPage] // 所有页面(扁平化)
|
||||||
|
public let indexTable: RDEPUBTextIndexTable // 索引表
|
||||||
|
public var positionConverter: RDEPUBTextPositionConverter
|
||||||
|
|
||||||
|
// 章节查询
|
||||||
|
public func chapterData(for href: String) -> RDEPUBChapterData?
|
||||||
|
public func chapterData(forSpineIndex spineIndex: Int) -> RDEPUBChapterData?
|
||||||
|
public func chapterData(atChapterIndex index: Int) -> RDEPUBChapterData?
|
||||||
|
public func chapterData(forPageNumber pageNumber: Int) -> RDEPUBChapterData?
|
||||||
|
|
||||||
|
// 页码查询
|
||||||
|
public func page(at pageNumber: Int) -> RDEPUBTextPage?
|
||||||
|
public func pageNumber(for location: RDEPUBLocation, resolver: RDEPUBResourceResolver, bookIdentifier: String?) -> Int?
|
||||||
|
public func location(forPageNumber pageNumber: Int, bookIdentifier: String?) -> RDEPUBLocation?
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 RDEPUBTextChapter
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public struct RDEPUBTextChapter: Equatable {
|
||||||
|
public var chapterIndex: Int // 章节索引
|
||||||
|
public var spineIndex: Int // spine 索引
|
||||||
|
public var href: String // 章节 href
|
||||||
|
public var title: String // 章节标题
|
||||||
|
public var attributedContent: NSAttributedString // 章节完整富文本
|
||||||
|
public var fragmentOffsets: [String: Int] // 片段 ID → 字符偏移
|
||||||
|
public var cfiMap: RDEPUBCFIMap? // CFI 映射
|
||||||
|
public var pageBreakReasons: [RDEPUBTextPageBreakReason] // 分页原因
|
||||||
|
public var pages: [RDEPUBTextPage] // 章节内页面
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 RDEPUBTextPage
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public struct RDEPUBTextPage: Equatable {
|
||||||
|
public var absolutePageIndex: Int // 全书绝对页码
|
||||||
|
public var chapterIndex: Int // 章节索引
|
||||||
|
public var spineIndex: Int // spine 索引
|
||||||
|
public var href: String // 章节 href
|
||||||
|
public var chapterTitle: String // 章节标题
|
||||||
|
public var pageIndexInChapter: Int // 章节内页码
|
||||||
|
public var totalPagesInChapter: Int // 章节总页数
|
||||||
|
public var chapterContent: NSAttributedString // 章节完整内容
|
||||||
|
public var content: NSAttributedString // 页面内容(子范围)
|
||||||
|
public var contentRange: NSRange // 在章节内容中的范围
|
||||||
|
public var pageStartOffset: Int // 起始偏移
|
||||||
|
public var pageEndOffset: Int // 结束偏移
|
||||||
|
public var metadata: RDEPUBTextPageMetadata // 页元数据
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 索引与位置转换
|
||||||
|
|
||||||
|
### 7.1 RDEPUBTextIndexTable
|
||||||
|
|
||||||
|
**文件:** `RDEPUBTextIndexTable.swift`
|
||||||
|
|
||||||
|
全书字符偏移索引,支持快速查找。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public struct RDEPUBTextIndexTable {
|
||||||
|
public let chapterStartOffsets: [Int] // 各章节起始偏移
|
||||||
|
public let chapterLengths: [Int] // 各章节长度
|
||||||
|
public let hrefToChapterIndex: [String: Int] // href → 章节索引
|
||||||
|
public let hrefToFileIndex: [String: Int] // href → 文件索引
|
||||||
|
public let fragmentOffsetsByHref: [String: [String: Int]] // 片段偏移
|
||||||
|
public let fileRowColumnMap: [Int: [RDEPUBRowColumnIndex]] // 行列映射
|
||||||
|
public let fileCFIMap: [Int: RDEPUBCFIMap] // CFI 映射
|
||||||
|
public var totalCharacterCount: Int // 总字符数
|
||||||
|
|
||||||
|
public init(chapters: [RDEPUBTextChapter])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 RDEPUBTextPositionConverter
|
||||||
|
|
||||||
|
**文件:** `RDEPUBTextPositionConverter.swift`
|
||||||
|
|
||||||
|
字符偏移 ↔ 页码双向转换。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public struct RDEPUBTextPositionConverter {
|
||||||
|
init(book: RDEPUBTextBook)
|
||||||
|
|
||||||
|
/// 字符偏移 → 页码
|
||||||
|
public func pageNumber(for offset: Int) -> Int?
|
||||||
|
|
||||||
|
/// 页码 → 字符偏移范围
|
||||||
|
public func offsetRange(forPageNumber pageNumber: Int) -> NSRange?
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 RDEPUBChapterData
|
||||||
|
|
||||||
|
**文件:** `RDEPUBChapterData.swift`
|
||||||
|
|
||||||
|
章节级数据访问封装。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public struct RDEPUBChapterData {
|
||||||
|
public let chapter: RDEPUBTextChapter
|
||||||
|
public let indexTable: RDEPUBTextIndexTable
|
||||||
|
|
||||||
|
/// 位置 → 页码
|
||||||
|
public func pageNumber(for location: RDEPUBLocation) -> Int?
|
||||||
|
|
||||||
|
/// 页面 → 位置
|
||||||
|
public func location(for page: RDEPUBTextPage, bookIdentifier: String?) -> RDEPUBLocation?
|
||||||
|
|
||||||
|
/// 字符范围 → 范围锚点
|
||||||
|
public func rangeAnchor(for range: NSRange) -> RDEPUBRangeAnchor
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 搜索引擎
|
||||||
|
|
||||||
|
**文件:** `RDEPUBTextSearchEngine.swift`
|
||||||
|
|
||||||
|
基于原生 `NSAttributedString` 的搜索引擎,比 HTML 搜索更高效。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
|
||||||
|
init(textBook: RDEPUBTextBook, publication: RDEPUBPublication)
|
||||||
|
func search(keyword: String) -> [RDEPUBSearchMatch]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
搜索结果包含:href、progression、previewText、rangeAnchor、cfi 等定位信息。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 纯文本构建器
|
||||||
|
|
||||||
|
**文件:** `RDEpubPlainTextBookBuilder.swift`
|
||||||
|
|
||||||
|
从纯文本(.txt)文件构建 `RDEPUBTextBook`,用于支持 TXT 格式阅读。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 设计模式总结
|
||||||
|
|
||||||
|
| 模式 | 应用 |
|
||||||
|
|------|------|
|
||||||
|
| **管线模式** | `RDEPUBTextTypesettingPipeline` 多阶段 HTML 处理 |
|
||||||
|
| **策略模式** | `RDEPUBTextRenderer` 协议,`RDEPUBDTCoreTextRenderer` 实现 |
|
||||||
|
| **Builder 模式** | `RDEPUBTextBookBuilder` 全书构建 |
|
||||||
|
| **缓存协调** | `RDEPUBPaginationCacheCoordinator` 分页结果缓存 |
|
||||||
|
| **索引表** | `RDEPUBTextIndexTable` 全书字符偏移快速查找 |
|
||||||
|
| **关注点分离** | 渲染/分页/缓存/诊断各司其职 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 数据流图
|
||||||
|
|
||||||
|
```
|
||||||
|
EPUB HTML 文件
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
RDEPUBTypesettingPipeline
|
||||||
|
│ HTMLNormalizer → SemanticMarker → CFI → StyleSheet → Font → Fragment
|
||||||
|
▼
|
||||||
|
标记化 HTML + CSS
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
RDEPUBDTCoreTextRenderer
|
||||||
|
│ DTCoreText HTML → NSAttributedString
|
||||||
|
▼
|
||||||
|
NSAttributedString(带语义属性)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
RDEPUBTextLayouter
|
||||||
|
│ CoreText 排版 → 帧计算 → 分页策略
|
||||||
|
▼
|
||||||
|
[RDEPUBTextLayoutFrame](页面范围列表)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
RDEPUBTextChapter + RDEPUBTextPage
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
RDEPUBTextBook(全书模型 + 索引表)
|
||||||
|
```
|
||||||
@@ -1,264 +0,0 @@
|
|||||||
# EPUBTextRendering 功能实现逻辑
|
|
||||||
|
|
||||||
## 1. 范围与目标
|
|
||||||
|
|
||||||
- 代码范围:`Sources/RDReaderView/EPUBTextRendering/`(6 个 Swift 文件)
|
|
||||||
- 目标:说明文本渲染路径如何将 EPUB HTML 转换为 NSAttributedString、按字符范围分页、构建书籍模型,并支持全文搜索与 textReflowable 标注定位。
|
|
||||||
- 主链路关键词:`RDEPUBParser HTML -> DTCoreText 渲染 -> 片段标记注入/提取 -> CoreText 分页 -> RDEPUBTextBook -> 页面查找/位置转换`。
|
|
||||||
- 适用范围:仅用于 `textReflowable` 阅读配置文件(纯文本可重排 EPUB,如小说)。固定版式和交互式 EPUB 使用 WebView 渲染路径。
|
|
||||||
|
|
||||||
## 2. 关键对象职责
|
|
||||||
|
|
||||||
### 2.1 渲染协议 `RDEPUBTextRenderer`
|
|
||||||
|
|
||||||
- 文件:`EPUBTextRendering/RDEPUBTextRenderer.swift`
|
|
||||||
- 职责:
|
|
||||||
- 定义单方法协议:`renderChapter(html:baseURL:style:) throws -> RDEPUBRenderedChapterContent`
|
|
||||||
- 作为渲染后端的抽象点,当前仅 `RDEPUBDTCoreTextRenderer` 一个实现
|
|
||||||
|
|
||||||
### 2.2 DTCoreText 渲染器 `RDEPUBDTCoreTextRenderer`
|
|
||||||
|
|
||||||
- 文件:`EPUBTextRendering/RDEPUBDTCoreTextRenderer.swift`
|
|
||||||
- 职责:
|
|
||||||
- 实现 `RDEPUBTextRenderer` 协议
|
|
||||||
- 使用 `DTHTMLAttributedStringBuilder` 将 HTML 转为 NSAttributedString
|
|
||||||
- 渲染后处理:提取片段标记、统一字体/行间距/颜色
|
|
||||||
- DTCoreText 不可用时回退到纯文本渲染
|
|
||||||
|
|
||||||
### 2.3 渲染支持工具 `RDEPUBTextRendererSupport`
|
|
||||||
|
|
||||||
- 文件:`EPUBTextRendering/RDEPUBTextRendererSupport.swift`
|
|
||||||
- 职责:
|
|
||||||
- 片段标记注入:扫描 HTML 中带 `id` 属性的元素,在其前注入 `${id=<fragmentID>}` 不可见标记
|
|
||||||
- 片段偏移提取:从 NSAttributedString 中提取标记位置并删除标记
|
|
||||||
- 字体标准化:保留 HTML 中的粗体/斜体 trait,统一使用配置的基础字体族和字号
|
|
||||||
- 段落样式创建:强制应用配置的行间距和段间距
|
|
||||||
- 兜底渲染:DTCoreText 不可用时从原始 HTML 字符串生成纯文本 NSAttributedString
|
|
||||||
|
|
||||||
### 2.4 分页支持 `NSAttributedString.ss_pageRanges(size:)`
|
|
||||||
|
|
||||||
- 文件:`EPUBTextRendering/RDEPUBTextPaginationSupport.swift`
|
|
||||||
- 职责:
|
|
||||||
- 基于 CoreText framesetter 的分页
|
|
||||||
- 从位置 0 开始,反复创建 CTFrame 测量可见字符范围
|
|
||||||
- 返回 `[NSRange]`,每个 range 对应一页
|
|
||||||
- 安全保护:`visibleRange.length == 0` 时 break 防止死循环
|
|
||||||
|
|
||||||
### 2.5 书籍构建器 `RDEPUBTextBookBuilder`
|
|
||||||
|
|
||||||
- 文件:`EPUBTextRendering/RDEPUBTextBookBuilder.swift`
|
|
||||||
- 职责:
|
|
||||||
- 遍历 publication 的 linear spine 项(仅 HTML/XHTML 类型)
|
|
||||||
- 对每项:HTML 标准化 → 渲染 → 跳过空白封面/标题页 → 分页 → 构建 `RDEPUBTextPage` 数组
|
|
||||||
- 汇总所有章节为 `RDEPUBTextBook`
|
|
||||||
- 后台队列执行,主线程回调结果
|
|
||||||
|
|
||||||
### 2.6 全文搜索引擎 `RDEPUBTextSearchEngine`
|
|
||||||
|
|
||||||
- 文件:`EPUBTextRendering/RDEPUBTextSearchEngine.swift`
|
|
||||||
- 职责:
|
|
||||||
- 实现 `RDEPUBSearchEngine` 协议
|
|
||||||
- 在已渲染的 NSAttributedString 纯文本上执行线性搜索
|
|
||||||
- 返回 `RDEPUBSearchMatch` 数组(含 href、progression、预览文本、匹配位置)
|
|
||||||
|
|
||||||
## 3. 主流程(代码级)
|
|
||||||
|
|
||||||
### 3.1 完整渲染-分页流程
|
|
||||||
|
|
||||||
1. 入口:`RDEPUBTextBookBuilder.build(parser:publication:pageSize:style:)`
|
|
||||||
2. 遍历 `publication.spine` 中 `item.linear == true` 的项
|
|
||||||
3. 过滤仅处理 mediaType 包含 "html" 或 "xhtml" 的项
|
|
||||||
4. 读取 `parser.htmlString(forRelativePath: item.href)` 获取原始 HTML
|
|
||||||
5. HTML 标准化:
|
|
||||||
- 移除中文分页标记:`<hr lang="zh-CN">分页符</hr>`
|
|
||||||
- `\r` 替换为 `\n`
|
|
||||||
- 连续 `\n` 合并为单个
|
|
||||||
6. 检查是否跳过(空白且 href 包含 "cover" 或 "title")
|
|
||||||
7. 调用 `renderer.renderChapter(html:baseURL:style:)` 渲染
|
|
||||||
|
|
||||||
### 3.2 DTCoreText 渲染管线
|
|
||||||
|
|
||||||
1. 片段标记注入:`RDEPUBTextRendererSupport.injectFragmentMarkers(into: html)`
|
|
||||||
- 正则 `(<[^>]+\sid="([^"]+)"[^>]*>)` 匹配带 id 的元素
|
|
||||||
- 在匹配标签前插入 `${id=<fragmentID>}` 文本标记
|
|
||||||
2. UTF-8 编码为 Data
|
|
||||||
3. `DTHTMLAttributedStringBuilder(html:options:documentAttributes:)` 执行渲染
|
|
||||||
4. DTCoreText 选项:
|
|
||||||
- `NSTextSizeMultiplierDocumentOption: 1.0`
|
|
||||||
- `DTDefaultFontFamily` / `DTDefaultFontName` / `DTDefaultFontSize`:来自 `style.font`
|
|
||||||
- `DTDefaultLineHeightMultiplier`:`(font.lineHeight + lineSpacing) / max(font.lineHeight, 1)`
|
|
||||||
- `DTUseiOS6Attributes: true`:生成 UIKit 兼容属性
|
|
||||||
- `NSBaseURLDocumentOption`:资源相对路径解析
|
|
||||||
- `DTDefaultTextColor`:可选文本颜色
|
|
||||||
5. 后处理:
|
|
||||||
- 提取片段偏移并删除标记
|
|
||||||
- `normalizeReadingAttributes`:遍历每个属性 run,统一字体(保留粗/斜体 trait)、强制行间距/段间距、覆盖前景色
|
|
||||||
|
|
||||||
### 3.3 CoreText 分页算法
|
|
||||||
|
|
||||||
1. 从 `NSAttributedString` 创建 `CTFramesetter`
|
|
||||||
2. 创建页面大小的 `CGPath`
|
|
||||||
3. 从 location = 0 开始循环:
|
|
||||||
- 创建 CTFrame(range 从当前 location 到字符串末尾)
|
|
||||||
- `CTFrameGetVisibleStringRange(frame)` 获取可见字符数
|
|
||||||
- 追加 `NSRange(location: currentLocation, length: visibleRange.length)`
|
|
||||||
- location += visibleRange.length
|
|
||||||
4. 循环终止条件:`location + visibleRange.length >= attributedString.length`
|
|
||||||
5. 安全保护:`visibleRange.length == 0` 时 break
|
|
||||||
|
|
||||||
### 3.4 页面与位置互转
|
|
||||||
|
|
||||||
**Location → Page Number**(`RDEPUBTextBook.pageNumber(for:resolver:bookIdentifier:)`):
|
|
||||||
1. 有 fragment 时:用 `fragmentOffsets` 查找字符偏移,再定位到对应页面
|
|
||||||
2. 无 fragment 时:用 `navigationProgression`(progression 和 lastProgression 的中点)计算字符偏移
|
|
||||||
3. 遍历 pages 找到 `pageStartOffset <= offset < pageEndOffset` 的页面
|
|
||||||
|
|
||||||
**Page Number → Location**(`RDEPUBTextBook.location(forPageNumber:bookIdentifier:)`):
|
|
||||||
1. 通过 page number 查找 `RDEPUBTextPage`
|
|
||||||
2. 计算 `progression = pageStartOffset / totalCharacters`
|
|
||||||
3. 计算 `lastProgression = pageEndOffset / totalCharacters`
|
|
||||||
4. 构建 `RDEPUBLocation`(href + progression + lastProgression)
|
|
||||||
|
|
||||||
### 3.5 标注定位与恢复
|
|
||||||
|
|
||||||
textReflowable 路径不复用 WebView 的 DOM range,而使用章节字符偏移作为精确锚点:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"kind": "text-offset",
|
|
||||||
"href": "chapter.xhtml",
|
|
||||||
"start": 1234,
|
|
||||||
"end": 1260
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
1. `RDEPUBTextContentView` 保留当前 `RDEPUBTextPage`,从 `page.pageStartOffset` 将页内 `UITextView.selectedRange` 映射为章节全局 `start/end`
|
|
||||||
2. 生成 `RDEPUBSelection` 后回传 `RDEPUBReaderController`,共享层继续负责创建、去重、持久化、列表和跳转
|
|
||||||
3. 页面重绘时,`RDEPUBTextContentView` 仅消费 `kind == "text-offset"` 的 rangeInfo,计算当前页 `[pageStartOffset, pageEndOffset]` 与标注 `[start, end)` 的重叠
|
|
||||||
4. `highlight` 样式叠加 `.backgroundColor`,`underline` 样式叠加 `.underlineStyle` 和 `.underlineColor`
|
|
||||||
5. 字号、行高、主题变化导致重新分页后,标注仍以章节全局字符偏移恢复到新的页面切片
|
|
||||||
|
|
||||||
## 4. 异常与边界处理
|
|
||||||
|
|
||||||
- `RDEPUBTextRenderingError.htmlEncodingFailed`:HTML 字符串无法编码为 UTF-8 Data(极罕见)
|
|
||||||
- DTCoreText builder 返回 nil:回退到 `fallbackAttributedString`,生成纯文本(含 HTML 标签原文),降级体验
|
|
||||||
- 空白章节跳过:href 包含 "cover" 或 "title" 且渲染后文本为空的章节被跳过
|
|
||||||
- 分页零结果兜底:`pageRanges` 为空但 content 非空时,整个章节作为一页
|
|
||||||
- CoreText 零高度安全保护:`visibleRange.length == 0` 时 break 防止无限循环
|
|
||||||
- Token 取消:`paginationToken`(UUID)防止异步完成后的过期结果污染 UI
|
|
||||||
|
|
||||||
## 5. 数据结构与字段映射
|
|
||||||
|
|
||||||
### 5.1 渲染配置
|
|
||||||
|
|
||||||
```swift
|
|
||||||
RDEPUBTextRenderStyle
|
|
||||||
├── font: UIFont // 基础字体
|
|
||||||
├── lineSpacing: CGFloat // 额外行间距
|
|
||||||
├── textColor: UIColor? // 文本颜色(nil 时保持 HTML 原始颜色)
|
|
||||||
└── backgroundColor: UIColor? // 背景色
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.2 渲染输出
|
|
||||||
|
|
||||||
```swift
|
|
||||||
RDEPUBRenderedChapterContent
|
|
||||||
├── attributedString: NSAttributedString // 渲染后的富文本
|
|
||||||
└── fragmentOffsets: [String: Int] // fragment ID -> 字符偏移映射
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.3 页面模型
|
|
||||||
|
|
||||||
```swift
|
|
||||||
RDEPUBTextPage
|
|
||||||
├── absolutePageIndex: Int // 全局页码
|
|
||||||
├── chapterIndex: Int // 章节索引
|
|
||||||
├── spineIndex: Int // spine 索引
|
|
||||||
├── href: String // 章节 href
|
|
||||||
├── chapterTitle: String? // 章节标题
|
|
||||||
├── pageIndexInChapter: Int // 章节内页码
|
|
||||||
├── totalPagesInChapter: Int // 章节总页数
|
|
||||||
├── content: NSAttributedString // 本页的富文本切片
|
|
||||||
├── contentRange: NSRange // 在整章富文本中的 range
|
|
||||||
├── pageStartOffset: Int // 起始字符偏移
|
|
||||||
└── pageEndOffset: Int // 结束字符偏移
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.4 章节模型
|
|
||||||
|
|
||||||
```swift
|
|
||||||
RDEPUBTextChapter
|
|
||||||
├── chapterIndex: Int
|
|
||||||
├── spineIndex: Int
|
|
||||||
├── href: String
|
|
||||||
├── title: String?
|
|
||||||
├── attributedContent: NSAttributedString // 整章渲染后的富文本
|
|
||||||
├── fragmentOffsets: [String: Int]
|
|
||||||
└── pages: [RDEPUBTextPage]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.5 书籍模型
|
|
||||||
|
|
||||||
```swift
|
|
||||||
RDEPUBTextBook
|
|
||||||
├── chapters: [RDEPUBTextChapter]
|
|
||||||
├── pages: [RDEPUBTextPage] // 扁平化页面列表
|
|
||||||
├── page(at:) -> RDEPUBTextPage? // 1-based 页码查找
|
|
||||||
├── pageNumber(for:resolver:bookIdentifier:) -> Int? // Location -> 页码
|
|
||||||
└── location(forPageNumber:bookIdentifier:) -> RDEPUBLocation? // 页码 -> Location
|
|
||||||
```
|
|
||||||
|
|
||||||
## 6. 与 EPUBCore 层的集成
|
|
||||||
|
|
||||||
### 消费的数据
|
|
||||||
|
|
||||||
| 来源 | 数据 | 用途 |
|
|
||||||
|------|------|------|
|
|
||||||
| `RDEPUBParser` | `htmlString(forRelativePath:)` | 读取章节 HTML 内容 |
|
|
||||||
| `RDEPUBParser` | `fileURL(forRelativePath:)` | 计算 DTCoreText 的 baseURL |
|
|
||||||
| `RDEPUBPublication` | `spine` | 遍历 linear HTML/XHTML 项 |
|
|
||||||
| `RDEPUBPublication` | `tableOfContents` | 匹配章节标题 |
|
|
||||||
| `RDEPUBSpineItem` | `href`, `mediaType`, `linear`, `title` | 过滤和标识章节 |
|
|
||||||
| `RDEPUBResourceResolver` | `normalizedLocation` / `normalizedHref` | 位置查找时标准化 href |
|
|
||||||
| `RDEPUBLocation` | `href`, `fragment`, `navigationProgression` | 页面定位 |
|
|
||||||
| `RDEPUBSearchEngine` | 协议接口 | `RDEPUBTextSearchEngine` 实现此协议 |
|
|
||||||
| `RDEPUBHighlight` | `style`, `rangeInfo`, `color`, `note` | text-offset 标注恢复与页内绘制 |
|
|
||||||
|
|
||||||
## 7. 配置参数
|
|
||||||
|
|
||||||
- **字号**:`RDEPUBReaderConfiguration.fontSize`,默认 15pt
|
|
||||||
- **行高倍数**:`lineHeightMultiple`,默认 1.6(行间距 = font.lineHeight * 0.6,最低 4pt)
|
|
||||||
- **内容边距**:`reflowableContentInsets`,默认 (40, 16, 40, 16),影响有效页面尺寸
|
|
||||||
- **主题色**:通过 `RDEPUBReaderTheme` 的 `contentTextColor` / `contentBackgroundColor` 传入
|
|
||||||
- **渲染引擎**:`RDEPUBTextRenderingEngine`,当前仅 `.dtCoreText`
|
|
||||||
|
|
||||||
## 8. 性能特征
|
|
||||||
|
|
||||||
- **全文渲染**:每章一次性渲染为完整的 NSMutableAttributedString,无分块/懒加载
|
|
||||||
- **内存**:`RDEPUBTextBook` 同时持有 chapters(含完整 attributedContent)和 pages(含子串切片),存在一定程度的内存重复
|
|
||||||
- **后台执行**:`build` 方法在 `DispatchQueue.global(qos: .userInitiated)` 执行,不阻塞主线程
|
|
||||||
- **搜索性能**:线性扫描每章的 `attributedContent.string`,无索引,搜索时间与总文本量线性相关
|
|
||||||
- **无分页缓存**:字号/行高变化时全书重新渲染和分页,无增量更新
|
|
||||||
|
|
||||||
## 9. 联调与排查建议
|
|
||||||
|
|
||||||
- 排查 1:渲染后文本为空
|
|
||||||
- 检查 `parser.htmlString(forRelativePath:)` 是否返回非 nil
|
|
||||||
- 检查 spine 项的 mediaType 是否包含 "html" 或 "xhtml"
|
|
||||||
- 空白封面/标题页会被 `shouldSkipChapter` 跳过
|
|
||||||
- 排查 2:分页页数不正确
|
|
||||||
- 检查 `pageSize` 计算是否正确(viewport - contentInsets)
|
|
||||||
- 检查 DTCoreText 行高倍数是否正确应用
|
|
||||||
- 检查 CoreText 分页是否有 `visibleRange.length == 0` 的异常情况
|
|
||||||
- 排查 3:字体/颜色不正确
|
|
||||||
- 检查 `RDEPUBTextRenderStyle` 配置
|
|
||||||
- 检查 `normalizeReadingAttributes` 是否正确保留粗/斜体 trait
|
|
||||||
- 检查 `foregroundColor` 覆盖逻辑
|
|
||||||
- 排查 4:Fragment 定位失败
|
|
||||||
- 检查 `injectFragmentMarkers` 是否正确注入标记
|
|
||||||
- 检查 `extractFragmentOffsets` 是否正确提取偏移
|
|
||||||
- 确认标记在 DTCoreText 渲染后仍存在于 attributed string 中
|
|
||||||
- 排查 5:搜索结果不准确
|
|
||||||
- 搜索基于已渲染的纯文本(去 HTML 标签),非原始 HTML
|
|
||||||
- 确认 `attributedContent.string` 包含预期的文本内容
|
|
||||||
- 搜索为大小写不敏感的线性扫描
|
|
||||||
@@ -0,0 +1,731 @@
|
|||||||
|
# EPUBUI 模块代码级参考文档
|
||||||
|
|
||||||
|
> 最后更新:2026-06-18
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 模块概述
|
||||||
|
|
||||||
|
`EPUBUI` 是 ReadViewSDK 的**用户界面层**,位于架构最顶层。它包含阅读器控制器、协调器模式、章节运行时系统、设置面板、文本页面渲染和标注管理等子系统。
|
||||||
|
|
||||||
|
**文件清单(~60 个 Swift 文件):**
|
||||||
|
|
||||||
|
| 子系统 | 目录 | 核心文件 | 职责 |
|
||||||
|
|--------|------|----------|------|
|
||||||
|
| **阅读器控制器** | `EPUBUI/` | `RDEPUBReaderController*.swift` | 主控制器及其扩展 |
|
||||||
|
| **协调器** | `ReaderController/` | `RDEPUBReader*Coordinator.swift` | 各功能域协调器 |
|
||||||
|
| **运行时** | `ReaderController/` | `RDEPUBReaderRuntime.swift`, `RDEPUBReaderContext.swift` | 运行时状态与依赖 |
|
||||||
|
| **章节运行时** | `ReaderController/ChapterRuntime/` | `RDEPUBChapter*.swift` | 按需加载、缓存、页图 |
|
||||||
|
| **设置** | `Settings/` | `RDEPUBReaderSettings*.swift` | 配置、主题、设置面板 |
|
||||||
|
| **文本页面** | `TextPage/` | `RDEPUBTextContentView*.swift` | 原生文本渲染页面 |
|
||||||
|
| **UI 组件** | `EPUBUI/` | `RDEPUBReader*ToolView.swift` | 工具栏、搜索栏、目录 |
|
||||||
|
| **笔记弹层** | `Notes/` | `RDEPUBNotePopup*.swift` | 脚注弹层 |
|
||||||
|
| **WebView** | `EPUBUI/` | `RDEPUBWebContentView.swift` | WebView 内容页面 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 阅读器控制器
|
||||||
|
|
||||||
|
### 2.1 RDEPUBReaderController
|
||||||
|
|
||||||
|
**文件:** `RDEPUBReaderController.swift` + 扩展文件
|
||||||
|
|
||||||
|
主控制器,`UIViewController` 子类,是整个阅读器的入口。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public final class RDEPUBReaderController: UIViewController {
|
||||||
|
public weak var delegate: RDEPUBReaderDelegate?
|
||||||
|
public var configuration: RDEPUBReaderConfiguration // 配置(变化时自动重新分页/刷新)
|
||||||
|
public var currentLocation: RDEPUBLocation? // 当前阅读位置
|
||||||
|
public var currentPageNumber: Int? // 当前页码(1-based)
|
||||||
|
public var currentSelection: RDEPUBSelection? // 当前选中文本
|
||||||
|
public var highlights: [RDEPUBHighlight] // 高亮列表
|
||||||
|
public var bookmarks: [RDEPUBBookmark] // 书签列表
|
||||||
|
public var annotations: [RDEPUBAnnotation] // 标注列表(高亮+书签,按时间排序)
|
||||||
|
public var tableOfContents: [EPUBTableOfContentsItem] // 目录树
|
||||||
|
public var flattenedTableOfContents: [RDEPUBReaderTableOfContentsItem] // 扁平化目录
|
||||||
|
|
||||||
|
let epubURL: URL // EPUB 文件路径
|
||||||
|
let persistence: RDEPUBReaderPersistence? // 持久化代理
|
||||||
|
let dependencies: RDEPUBReaderDependencies // 依赖注入
|
||||||
|
let readerView = RDEpubReaderView() // 翻页容器
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**扩展文件职责:**
|
||||||
|
|
||||||
|
| 文件 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `RDEPUBReaderController+PublicAPI.swift` | 公开 API(跳转、搜索、标注、书签) |
|
||||||
|
| `RDEPUBReaderController+DataSource.swift` | `RDEpubReaderPageProvider` 实现 |
|
||||||
|
| `RDEPUBReaderController+ContentDelegates.swift` | WebView/TextContentView 代理路由 |
|
||||||
|
| `RDEPUBReaderController+LocationResolution.swift` | 页码/位置解析、阅读状态同步 |
|
||||||
|
| `RDEPUBReaderController+ExternalLinks.swift` | 外部链接处理(白名单、确认弹窗) |
|
||||||
|
| `RDEPUBReaderController+AttachmentTooltip.swift` | 附件 alt 文本 tooltip 展示 |
|
||||||
|
| `RDEPUBReaderController+RenderSupport.swift` | 渲染辅助(WebView/TextContent 创建) |
|
||||||
|
| `RDEPUBReaderController+RuntimeBridge.swift` | Runtime 桥接 |
|
||||||
|
| `RDEPUBReaderController+TableOfContents.swift` | 目录处理 |
|
||||||
|
|
||||||
|
### 2.2 RDEPUBReaderDelegate
|
||||||
|
|
||||||
|
**文件:** `RDEPUBReaderDelegate.swift`
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public protocol RDEPUBReaderDelegate: AnyObject {
|
||||||
|
func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication)
|
||||||
|
func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation)
|
||||||
|
func epubReaderDidReachEnd(_ reader: UIViewController)
|
||||||
|
func epubReader(_ reader: UIViewController, didChangeSelection selection: RDEPUBSelection?)
|
||||||
|
func epubReader(_ reader: UIViewController, didUpdateHighlights highlights: [RDEPUBHighlight])
|
||||||
|
func epubReader(_ reader: UIViewController, didUpdateBookmarks bookmarks: [RDEPUBBookmark])
|
||||||
|
func epubReader(_ reader: UIViewController, didUpdateSearchResult result: RDEPUBSearchResult?)
|
||||||
|
func epubReader(_ reader: UIViewController, didChangeCurrentSearchMatch match: RDEPUBSearchMatch?)
|
||||||
|
func epubReader(_ reader: UIViewController, didUpdateCurrentTableOfContentsItem item: RDEPUBReaderTableOfContentsItem?)
|
||||||
|
func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL)
|
||||||
|
func epubReader(_ reader: UIViewController, shouldOpenExternalURL url: URL) -> Bool
|
||||||
|
func epubReader(_ reader: UIViewController, didFailWithError error: Error)
|
||||||
|
func epubReader(_ reader: UIViewController, configureTopToolView topToolView: RDEPUBReaderTopToolView)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
所有方法均有默认空实现。
|
||||||
|
|
||||||
|
### 2.3 RDEPUBReaderPersistence
|
||||||
|
|
||||||
|
**文件:** `RDEPUBReaderPersistence.swift`
|
||||||
|
|
||||||
|
持久化协议,由宿主 App 实现。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public protocol RDEPUBReaderPersistence: AnyObject {
|
||||||
|
func loadLocation(for bookIdentifier: String) -> RDEPUBLocation?
|
||||||
|
func saveLocation(_ location: RDEPUBLocation, for bookIdentifier: String)
|
||||||
|
func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark]
|
||||||
|
func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String)
|
||||||
|
func loadHighlights(for bookIdentifier: String) -> [RDEPUBHighlight]
|
||||||
|
func saveHighlights(_ highlights: [RDEPUBHighlight], for bookIdentifier: String)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 运行时与上下文
|
||||||
|
|
||||||
|
### 3.1 RDEPUBReaderContext
|
||||||
|
|
||||||
|
**文件:** `ReaderController/RDEPUBReaderContext.swift`
|
||||||
|
|
||||||
|
阅读器的共享上下文,持有所有运行时状态。协调器通过 `unowned` 引用访问。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
final class RDEPUBReaderContext {
|
||||||
|
weak var controller: RDEPUBReaderController?
|
||||||
|
weak var readerView: RDEpubReaderView?
|
||||||
|
var dependencies: RDEPUBReaderDependencies
|
||||||
|
var runtime: RDEPUBReaderRuntime?
|
||||||
|
|
||||||
|
// 解析状态
|
||||||
|
var parser: RDEPUBParser?
|
||||||
|
var publication: RDEPUBPublication?
|
||||||
|
var readingSession: RDEPUBReadingSession?
|
||||||
|
var textBook: RDEPUBTextBook?
|
||||||
|
var bookPageMap: RDEPUBBookPageMap?
|
||||||
|
|
||||||
|
// 标注状态
|
||||||
|
var activeBookmarks: [RDEPUBBookmark]
|
||||||
|
var activeHighlights: [RDEPUBHighlight]
|
||||||
|
var currentBookIdentifier: String?
|
||||||
|
var selectionState: RDEPUBSelectionState
|
||||||
|
var currentSelection: RDEPUBSelection?
|
||||||
|
|
||||||
|
// 搜索状态
|
||||||
|
var searchState: RDEPUBSearchState?
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 RDEPUBReaderDependencies
|
||||||
|
|
||||||
|
**文件:** `ReaderController/RDEPUBReaderDependencies.swift`
|
||||||
|
|
||||||
|
依赖注入容器,支持测试替身。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public struct RDEPUBReaderDependencies {
|
||||||
|
public var environment: any RDEPUBReaderDisplayEnvironment
|
||||||
|
public var makeParser: () -> RDEPUBParser
|
||||||
|
public var makePaginator: () -> RDEPUBPaginator
|
||||||
|
public var makeTextBookBuilder: (RDEPUBTextRenderer, RDEPUBTextBookCache?, RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder
|
||||||
|
public var makePlainTextBookBuilder: (RDEPUBTextRenderer, RDEPUBTextLayoutConfig) -> RDEpubPlainTextBookBuilder
|
||||||
|
public var makeTextRenderer: (RDEPUBTextRenderingEngine) -> RDEPUBTextRenderer
|
||||||
|
|
||||||
|
public static var live: RDEPUBReaderDependencies // 默认实现
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 RDEPUBReaderRuntime
|
||||||
|
|
||||||
|
**文件:** `ReaderController/RDEPUBReaderRuntime.swift`
|
||||||
|
|
||||||
|
运行时管理器,持有所有协调器和子系统。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
final class RDEPUBReaderRuntime {
|
||||||
|
lazy var chapterRuntimeStore = RDEPUBChapterRuntimeStore()
|
||||||
|
lazy var summaryDiskCache: RDEPUBChapterSummaryDiskCache
|
||||||
|
lazy var chapterLoader: RDEPUBChapterLoader
|
||||||
|
lazy var pageResolver: RDEPUBPageResolver
|
||||||
|
lazy var loadCoordinator: RDEPUBReaderLoadCoordinator
|
||||||
|
lazy var paginationCoordinator: RDEPUBReaderPaginationCoordinator
|
||||||
|
lazy var locationCoordinator: RDEPUBReaderLocationCoordinator
|
||||||
|
lazy var searchCoordinator: RDEPUBReaderSearchCoordinator
|
||||||
|
lazy var chromeCoordinator: RDEPUBReaderChromeCoordinator
|
||||||
|
lazy var annotationCoordinator: RDEPUBReaderAnnotationCoordinator
|
||||||
|
lazy var viewportMonitor: RDEPUBReaderViewportMonitor
|
||||||
|
lazy var jumpSessionManager: RDEPUBJumpSessionManager
|
||||||
|
lazy var backgroundPriorityManager: RDEPUBBackgroundPriorityManager
|
||||||
|
lazy var backgroundCoverageStore: RDEPUBBackgroundCoverageStore
|
||||||
|
lazy var reconciliationCoordinator: RDEPUBPageMapReconciliationCoordinator
|
||||||
|
|
||||||
|
var isSettingsPanelOpen: Bool
|
||||||
|
var needsFullRepaginationAfterSettingsClose: Bool
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 协调器子系统
|
||||||
|
|
||||||
|
采用 **Coordinator 模式**,每个功能域由独立的协调器管理。
|
||||||
|
|
||||||
|
### 4.1 RDEPUBReaderLoadCoordinator
|
||||||
|
|
||||||
|
**文件:** `ReaderController/RDEPUBReaderLoadCoordinator.swift`
|
||||||
|
|
||||||
|
负责初始加载流程:解析 EPUB → 创建 Publication → 恢复阅读位置。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
final class RDEPUBReaderLoadCoordinator {
|
||||||
|
func startInitialLoadIfNeeded() // 开始初始加载
|
||||||
|
func loadPublication() // 解析 EPUB 文件
|
||||||
|
func applyParsedPublication(...) // 应用解析结果
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 RDEPUBReaderPaginationCoordinator
|
||||||
|
|
||||||
|
**文件:** `ReaderController/RDEPUBReaderPaginationCoordinator.swift`
|
||||||
|
|
||||||
|
负责分页调度:WebView 分页 → 文本构建 → 页图生成。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
final class RDEPUBReaderPaginationCoordinator {
|
||||||
|
func startPagination(...) // 开始分页
|
||||||
|
func applyPageCounts(...) // 应用页数结果
|
||||||
|
func repaginatePreservingCurrentLocation() // 重新分页(保持位置)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 RDEPUBReaderLocationCoordinator
|
||||||
|
|
||||||
|
**文件:** `ReaderController/RDEPUBReaderLocationCoordinator.swift`
|
||||||
|
|
||||||
|
负责位置管理:恢复位置、记录位置变化、目录跳转。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
final class RDEPUBReaderLocationCoordinator {
|
||||||
|
func restoreReadingLocation(_ location: RDEPUBLocation, animated: Bool, ...) -> Bool
|
||||||
|
func currentVisibleLocation() -> RDEPUBLocation?
|
||||||
|
func recordPageChangeIfNeeded()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 RDEPUBReaderSearchCoordinator
|
||||||
|
|
||||||
|
**文件:** `ReaderController/RDEPUBReaderSearchCoordinator.swift`
|
||||||
|
|
||||||
|
负责搜索管理:执行搜索、导航到匹配项、清除搜索。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
final class RDEPUBReaderSearchCoordinator {
|
||||||
|
func search(keyword: String)
|
||||||
|
func searchNext() -> Bool
|
||||||
|
func searchPrevious() -> Bool
|
||||||
|
func selectSearchMatch(at index: Int) -> Bool
|
||||||
|
func clearSearch()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 RDEPUBReaderChromeCoordinator
|
||||||
|
|
||||||
|
**文件:** `ReaderController/RDEPUBReaderChromeCoordinator.swift`
|
||||||
|
|
||||||
|
负责 UI Chrome(工具栏、搜索栏)的创建和状态更新。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
final class RDEPUBReaderChromeCoordinator {
|
||||||
|
func makeTopToolView() -> RDEPUBReaderTopToolView
|
||||||
|
func makeBottomToolView() -> RDEPUBReaderBottomToolView
|
||||||
|
func updateReaderChrome()
|
||||||
|
func toggleSearchBar()
|
||||||
|
func presentTableOfContents()
|
||||||
|
func presentSettings()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.6 RDEPUBReaderAnnotationCoordinator
|
||||||
|
|
||||||
|
**文件:** `ReaderController/RDEPUBReaderAnnotationCoordinator.swift`
|
||||||
|
|
||||||
|
负责标注管理:高亮、书签的增删改查。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
final class RDEPUBReaderAnnotationCoordinator {
|
||||||
|
func addHighlight(from selection: RDEPUBSelection?, color: String, note: String?) -> RDEPUBHighlight?
|
||||||
|
func removeHighlight(withID id: String)
|
||||||
|
func addBookmark(for location: RDEPUBLocation, ...) -> RDEPUBBookmark?
|
||||||
|
func removeBookmark(withID id: String)
|
||||||
|
func toggleBookmark() -> Bool
|
||||||
|
func updateCurrentSelection(_ selection: RDEPUBSelection?)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 章节运行时子系统
|
||||||
|
|
||||||
|
**目录:** `ReaderController/ChapterRuntime/`
|
||||||
|
|
||||||
|
按需加载章节、管理章节缓存、维护全书页图。
|
||||||
|
|
||||||
|
### 5.1 RDEPUBChapterRuntimeStore
|
||||||
|
|
||||||
|
**文件:** `ChapterRuntime/RDEPUBChapterRuntimeStore.swift`
|
||||||
|
|
||||||
|
章节数据的核心存储,管理内存缓存和窗口。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
final class RDEPUBChapterRuntimeStore {
|
||||||
|
let chapterLoadQueue: DispatchQueue // 后台加载队列
|
||||||
|
private(set) var currentSpineIndex: Int?
|
||||||
|
private(set) var windowSpineIndices: [Int] // 当前窗口内的 spine 索引
|
||||||
|
|
||||||
|
func chapterData(for spineIndex: Int) -> RDEPUBRuntimeChapter?
|
||||||
|
func insertChapter(_ chapter: RDEPUBRuntimeChapter)
|
||||||
|
func setCurrentChapter(spineIndex: Int, totalSpineCount: Int, windowRadius: Int)
|
||||||
|
func evictableSpineIndices() -> [Int]
|
||||||
|
func evict(spineIndex: Int)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 RDEPUBChapterLoader
|
||||||
|
|
||||||
|
**文件:** `ChapterRuntime/RDEPUBChapterLoader.swift`
|
||||||
|
|
||||||
|
章节按需加载器,支持优先级和磁盘摘要缓存。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
final class RDEPUBChapterLoader {
|
||||||
|
enum LoadPriority {
|
||||||
|
case navigation // 导航(最高优先级)
|
||||||
|
case preview // 预览
|
||||||
|
case prefetch // 预取(最低优先级)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadChapter(
|
||||||
|
spineIndex: Int,
|
||||||
|
store: RDEPUBChapterRuntimeStore,
|
||||||
|
priority: LoadPriority,
|
||||||
|
completion: @escaping (Result<RDEPUBRuntimeChapter, Error>) -> Void
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**加载流程:**
|
||||||
|
1. 检查内存缓存(`RDEPUBChapterDataCache`)
|
||||||
|
2. 检查页数缓存(`RDEPUBPageCountCache`)
|
||||||
|
3. 检查磁盘摘要缓存(`RDEPUBChapterSummaryDiskCache`)
|
||||||
|
4. 构建章节(排版 + 分页)
|
||||||
|
5. 写入缓存
|
||||||
|
|
||||||
|
### 5.3 RDEPUBChapterWindowCoordinator
|
||||||
|
|
||||||
|
**文件:** `ChapterRuntime/RDEPUBChapterWindowCoordinator.swift`
|
||||||
|
|
||||||
|
管理章节窗口(当前章 + 前后各 N 章),协调加载和驱逐。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
final class RDEPUBChapterWindowCoordinator {
|
||||||
|
private(set) var currentSnapshot: RDEPUBChapterWindowSnapshot?
|
||||||
|
var onSnapshotChanged: ((RDEPUBChapterWindowSnapshot) -> Void)?
|
||||||
|
|
||||||
|
func openBook(at targetSpineIndex: Int, restoreChapterOffset: Int?)
|
||||||
|
func navigateToChapter(at spineIndex: Int)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 RDEPUBBookPageMap
|
||||||
|
|
||||||
|
**文件:** `ChapterRuntime/RDEPUBBookPageMap.swift`
|
||||||
|
|
||||||
|
全书页图,映射绝对页码 ↔ spine 索引 + 本地页码。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
struct RDEPUBBookPageMap {
|
||||||
|
let entries: [RDEPUBBookPageMapEntry]
|
||||||
|
let totalPages: Int
|
||||||
|
|
||||||
|
func absolutePageIndex(spineIndex: Int, localPageIndex: Int) -> Int?
|
||||||
|
func spineIndex(forAbsolutePage absolutePage: Int) -> Int?
|
||||||
|
func localPageIndex(forAbsolutePage absolutePage: Int) -> Int?
|
||||||
|
func chapterIndex(forSpineIndex spineIndex: Int) -> Int?
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.5 RDEPUBPageResolver
|
||||||
|
|
||||||
|
**文件:** `ChapterRuntime/RDEPUBPageResolver.swift`
|
||||||
|
|
||||||
|
将绝对页码解析为具体的页面数据。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
final class RDEPUBPageResolver {
|
||||||
|
func resolvePage(absolutePageIndex: Int) -> RDEPUBResolvedPage?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RDEPUBResolvedPage {
|
||||||
|
let page: RDEPUBTextPage
|
||||||
|
let chapter: RDEPUBRuntimeChapter
|
||||||
|
let chapterIndex: Int
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.6 其他 ChapterRuntime 组件
|
||||||
|
|
||||||
|
| 文件 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `RDEPUBRuntimeChapter.swift` | 运行时章节模型(包含分页后的页面列表) |
|
||||||
|
| `RDEPUBRuntimePageCount.swift` | 页数缓存模型 |
|
||||||
|
| `RDEPUBChapterDataCache.swift` | 章节数据内存缓存(NSCache) |
|
||||||
|
| `RDEPUBPageCountCache.swift` | 页数缓存 |
|
||||||
|
| `RDEPUBChapterCacheKey.swift` | 缓存键(pageSize + fontSize + lineHeight) |
|
||||||
|
| `RDEPUBChapterSummaryDiskCache.swift` | 章节摘要磁盘缓存 |
|
||||||
|
| `RDEPUBChapterWindowSnapshot.swift` | 窗口快照(当前可见章节集合) |
|
||||||
|
| `RDEPUBChapterLocation.swift` | 章节位置模型 |
|
||||||
|
| `RDEPUBChapterOffsetMap.swift` | 章节偏移映射 |
|
||||||
|
| `RDEPUBBackgroundTrace.swift` | 后台任务追踪日志 |
|
||||||
|
| `RDEPUBMetadataParseWorker.swift` | 后台元数据解析 worker |
|
||||||
|
| `RDEPUBMetadataParseCancellationController.swift` | 解析取消控制器 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 设置子系统
|
||||||
|
|
||||||
|
**目录:** `Settings/`
|
||||||
|
|
||||||
|
### 6.1 RDEPUBReaderConfiguration
|
||||||
|
|
||||||
|
**文件:** `Settings/RDEPUBReaderConfiguration.swift`
|
||||||
|
|
||||||
|
阅读器配置模型。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public struct RDEPUBReaderConfiguration: Equatable {
|
||||||
|
public var fontSize: CGFloat
|
||||||
|
public var lineHeightMultiple: CGFloat
|
||||||
|
public var fontChoice: RDEPUBReaderFontChoice
|
||||||
|
public var numberOfColumns: Int
|
||||||
|
public var columnGap: CGFloat
|
||||||
|
// ... 更多配置项
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum RDEPUBReaderFontChoice: String, Codable, CaseIterable {
|
||||||
|
case system // 系统字体
|
||||||
|
case serif // 宋体
|
||||||
|
case rounded // 圆体
|
||||||
|
case monospaced // 等宽
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum RDEPUBTextRenderingEngine: Equatable {
|
||||||
|
case dtCoreText
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 RDEPUBReaderSettings
|
||||||
|
|
||||||
|
**文件:** `Settings/RDEPUBReaderSettings.swift`
|
||||||
|
|
||||||
|
设置状态管理。
|
||||||
|
|
||||||
|
### 6.3 RDEPUBReaderSettingsViewController
|
||||||
|
|
||||||
|
**文件:** `Settings/RDEPUBReaderSettingsViewController.swift`
|
||||||
|
|
||||||
|
设置面板 VC(字体大小、行高、字体选择、主题等)。
|
||||||
|
|
||||||
|
### 6.4 RDEPUBReaderTheme
|
||||||
|
|
||||||
|
**文件:** `Settings/RDEPUBReaderTheme.swift`
|
||||||
|
|
||||||
|
主题定义(日间/夜间/护眼等)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 文本页面子系统
|
||||||
|
|
||||||
|
**目录:** `TextPage/`
|
||||||
|
|
||||||
|
原生文本渲染模式(`textReflowable`)的页面视图。
|
||||||
|
|
||||||
|
### 7.1 RDEPUBTextContentView
|
||||||
|
|
||||||
|
**文件:** `TextPage/RDEPUBTextContentView.swift`
|
||||||
|
|
||||||
|
文本内容视图,使用 CoreText 渲染 `NSAttributedString`。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||||
|
weak var delegate: RDEPUBTextContentViewDelegate?
|
||||||
|
|
||||||
|
func configure(page: RDEPUBTextPage, ...)
|
||||||
|
func applyHighlights(_ highlights: [RDEPUBHighlight])
|
||||||
|
func applySearchState(_ state: RDEPUBSearchState?)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 RDEPUBTextPageRenderView
|
||||||
|
|
||||||
|
**文件:** `TextPage/RDEPUBTextPageRenderView.swift`
|
||||||
|
|
||||||
|
CoreText 渲染视图,绘制 `NSAttributedString` 到屏幕上。
|
||||||
|
|
||||||
|
### 7.3 RDEPUBTextAnnotationOverlay
|
||||||
|
|
||||||
|
**文件:** `TextPage/RDEPUBTextAnnotationOverlay.swift`
|
||||||
|
|
||||||
|
标注叠加层,绘制高亮和下划线。
|
||||||
|
|
||||||
|
### 7.4 RDEPUBSelectionOverlayView
|
||||||
|
|
||||||
|
**文件:** `TextPage/RDEPUBSelectionOverlayView.swift`
|
||||||
|
|
||||||
|
文本选择叠加层(选择手柄)。
|
||||||
|
|
||||||
|
### 7.5 RDEPUBTextSelectionController
|
||||||
|
|
||||||
|
**文件:** `TextPage/RDEPUBTextSelectionController.swift`
|
||||||
|
|
||||||
|
文本选择手势控制器。
|
||||||
|
|
||||||
|
### 7.6 RDEPUBPageInteractionController
|
||||||
|
|
||||||
|
**文件:** `TextPage/RDEPUBPageInteractionController.swift`
|
||||||
|
|
||||||
|
页面交互控制器(长按选择、点击标注等)。
|
||||||
|
|
||||||
|
### 7.7 RDEPUBPageLayoutSnapshot
|
||||||
|
|
||||||
|
**文件:** `TextPage/RDEPUBPageLayoutSnapshot.swift`
|
||||||
|
|
||||||
|
页面布局快照(用于选择定位)。
|
||||||
|
|
||||||
|
### 7.8 RDEPUBTextPageDecorationView
|
||||||
|
|
||||||
|
**文件:** `TextPage/RDEPUBTextPageDecorationView.swift`
|
||||||
|
|
||||||
|
页面装饰视图(页码、页眉等)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. UI 组件
|
||||||
|
|
||||||
|
### 8.1 工具栏
|
||||||
|
|
||||||
|
| 文件 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `RDEPUBReaderTopToolView.swift` | 顶部工具栏(返回、搜索、书签) |
|
||||||
|
| `RDEPUBReaderBottomToolView.swift` | 底部工具栏(目录、书签、高亮、设置) |
|
||||||
|
| `RDEPUBReaderToolView.swift` | 工具栏基类 |
|
||||||
|
|
||||||
|
### 8.2 搜索栏
|
||||||
|
|
||||||
|
**文件:** `RDEPUBReaderSearchBarView.swift`
|
||||||
|
|
||||||
|
搜索输入栏(输入框 + 上一个/下一个 + 关闭)。
|
||||||
|
|
||||||
|
### 8.3 目录
|
||||||
|
|
||||||
|
**文件:** `RDEPUBReaderChapterListController.swift`
|
||||||
|
|
||||||
|
目录列表 VC,支持多级目录展开。
|
||||||
|
|
||||||
|
**文件:** `RDEPUBReaderTableOfContentsItem.swift`
|
||||||
|
|
||||||
|
目录项模型。
|
||||||
|
|
||||||
|
### 8.4 高亮管理
|
||||||
|
|
||||||
|
**文件:** `RDEPUBReaderHighlightsViewController.swift`
|
||||||
|
|
||||||
|
高亮列表 VC。
|
||||||
|
|
||||||
|
### 8.5 笔记弹层
|
||||||
|
|
||||||
|
**目录:** `Notes/`
|
||||||
|
|
||||||
|
| 文件 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `RDEPUBNotePopupCoordinator.swift` | 脚注弹层协调器 |
|
||||||
|
| `RDEPUBNotePopupViewController.swift` | 脚注弹层 VC |
|
||||||
|
|
||||||
|
### 8.6 WebView 内容视图
|
||||||
|
|
||||||
|
**文件:** `RDEPUBWebContentView.swift`
|
||||||
|
|
||||||
|
WebView 模式的内容页面视图。
|
||||||
|
|
||||||
|
### 8.7 装饰叠加层
|
||||||
|
|
||||||
|
**文件:** `RDEPUBWebDecorationOverlayView.swift`
|
||||||
|
|
||||||
|
WebView 模式的装饰叠加层(高亮、搜索高亮)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 状态模型
|
||||||
|
|
||||||
|
### 9.1 RDEPUBSelectionState
|
||||||
|
|
||||||
|
```swift
|
||||||
|
enum RDEPUBSelectionState: Equatable {
|
||||||
|
case idle // 无选择
|
||||||
|
case selecting(anchor: Int) // 选择中
|
||||||
|
case selected(RDEPUBSelection) // 已选择
|
||||||
|
case committingAction(RDEPUBSelection, action: RDEPUBAnnotationMenuAction) // 执行操作中
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.2 RDEPUBReaderUIState
|
||||||
|
|
||||||
|
```swift
|
||||||
|
struct RDEPUBReaderUIState {
|
||||||
|
let canToggleBookmark: Bool
|
||||||
|
let hasBookmarkAtCurrentLocation: Bool
|
||||||
|
let canShowBookmarks: Bool
|
||||||
|
let canAddHighlight: Bool
|
||||||
|
let canShowHighlights: Bool
|
||||||
|
let showsTableOfContents: Bool
|
||||||
|
let allowsHighlights: Bool
|
||||||
|
let showsSettingsPanel: Bool
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.3 RDEPUBViewportTypes
|
||||||
|
|
||||||
|
**文件:** `RDEPUBViewportTypes.swift`
|
||||||
|
|
||||||
|
视口相关模型。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 其他组件
|
||||||
|
|
||||||
|
### 10.1 RDEPUBReaderViewportMonitor
|
||||||
|
|
||||||
|
**文件:** `ReaderController/RDEPUBReaderViewportMonitor.swift`
|
||||||
|
|
||||||
|
视口变化监听器(旋转、尺寸变化)。
|
||||||
|
|
||||||
|
### 10.2 RDEPUBJumpSession
|
||||||
|
|
||||||
|
**文件:** `ReaderController/RDEPUBJumpSession.swift`
|
||||||
|
|
||||||
|
跳转会话管理(远距跳转时的加载状态)。
|
||||||
|
|
||||||
|
### 10.3 RDEPUBBackgroundPriorityPolicy
|
||||||
|
|
||||||
|
**文件:** `ReaderController/RDEPUBBackgroundPriorityPolicy.swift`
|
||||||
|
|
||||||
|
后台加载优先级策略。
|
||||||
|
|
||||||
|
### 10.4 RDEPUBBackgroundCoverageStore
|
||||||
|
|
||||||
|
**文件:** `ReaderController/RDEPUBBackgroundCoverageStore.swift`
|
||||||
|
|
||||||
|
后台加载覆盖范围追踪。
|
||||||
|
|
||||||
|
### 10.5 RDEPUBPageMapReconciliationCoordinator
|
||||||
|
|
||||||
|
**文件:** `ReaderController/RDEPUBPageMapReconciliationCoordinator.swift`
|
||||||
|
|
||||||
|
页图协调器(按需分页与全量分页的结果合并)。
|
||||||
|
|
||||||
|
### 10.6 RDEpubURLReaderController
|
||||||
|
|
||||||
|
**文件:** `RDEpubURLReaderController.swift`
|
||||||
|
|
||||||
|
URL 阅读器控制器(用于打开单个 URL)。
|
||||||
|
|
||||||
|
### 10.7 UIColor+RDEPUBHex
|
||||||
|
|
||||||
|
**文件:** `UIColor+RDEPUBHex.swift`
|
||||||
|
|
||||||
|
UIColor 十六进制扩展。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 设计模式总结
|
||||||
|
|
||||||
|
| 模式 | 应用 |
|
||||||
|
|------|------|
|
||||||
|
| **Coordinator 模式** | 7 个独立协调器分管各功能域 |
|
||||||
|
| **Context 模式** | `RDEPUBReaderContext` 共享上下文,避免循环依赖 |
|
||||||
|
| **依赖注入** | `RDEPUBReaderDependencies` 工厂方法注入 |
|
||||||
|
| **按需加载** | `RDEPUBChapterLoader` 章节级按需加载 |
|
||||||
|
| **窗口管理** | `RDEPUBChapterWindowCoordinator` 滑动窗口驱逐 |
|
||||||
|
| **快照管理** | `RDEPUBBookPageMap` 全书页图快照 |
|
||||||
|
| **状态机** | `RDEPUBSelectionState` 选择状态管理 |
|
||||||
|
| **磁盘缓存** | `RDEPUBChapterSummaryDiskCache` 章节摘要持久化 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 数据流图
|
||||||
|
|
||||||
|
```
|
||||||
|
用户打开 EPUB
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
RDEPUBReaderController.init(epubURL:)
|
||||||
|
│
|
||||||
|
▼ viewDidLoad()
|
||||||
|
RDEPUBReaderLoadCoordinator.startInitialLoadIfNeeded()
|
||||||
|
│
|
||||||
|
├── RDEPUBParser.parse(epubURL:) → 解析 EPUB
|
||||||
|
├── RDEPUBPublication 创建
|
||||||
|
├── 恢复阅读位置(persistence.loadLocation)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
RDEPUBReaderRuntime.applyParsedPublication()
|
||||||
|
│
|
||||||
|
├── 根据 readingProfile 选择渲染模式
|
||||||
|
│ ├── webInteractive → WebView 分页 → RDEPUBPaginator
|
||||||
|
│ └── textReflowable → 文本构建 → RDEPUBTextBookBuilder
|
||||||
|
│
|
||||||
|
├── RDEPUBChapterWindowCoordinator.openBook()
|
||||||
|
│ └── RDEPUBChapterLoader.loadChapter() → 按需加载
|
||||||
|
│
|
||||||
|
├── RDEPUBBookPageMap 生成
|
||||||
|
│
|
||||||
|
└── RDEpubReaderView.transitionToPage() → 显示页面
|
||||||
|
|
||||||
|
用户翻页
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
RDEpubReaderView.currentPage 变化
|
||||||
|
│
|
||||||
|
├── RDEPUBReaderLocationCoordinator.recordPageChangeIfNeeded()
|
||||||
|
│ └── persistence.saveLocation()
|
||||||
|
│
|
||||||
|
├── RDEPUBChapterWindowCoordinator 检查是否需要加载新章节
|
||||||
|
│
|
||||||
|
└── RDEPUBReaderChromeCoordinator.updateReaderChrome()
|
||||||
|
```
|
||||||
@@ -1,307 +0,0 @@
|
|||||||
# EPUBUI 功能实现逻辑
|
|
||||||
|
|
||||||
## 1. 范围与目标
|
|
||||||
|
|
||||||
- 代码范围:`Sources/RDReaderView/EPUBUI/`(16 个 Swift 文件)
|
|
||||||
- 目标:说明 EPUBUI 如何作为开箱即用的阅读器 UI 层,协调 EPUBCore 解析、EPUBTextRendering 文本渲染、RDReaderView 分页容器,提供完整的阅读体验(工具栏、目录、高亮批注、设置面板、阅读位置持久化、搜索)。
|
|
||||||
- 主链路关键词:`RDEPUBReaderController.init -> 解析 EPUB -> 分页 -> 渲染 -> 用户交互(翻页/工具栏/设置/高亮/搜索)-> 持久化`。
|
|
||||||
|
|
||||||
## 2. 关键对象职责
|
|
||||||
|
|
||||||
### 2.1 主控制器 `RDEPUBReaderController`
|
|
||||||
|
|
||||||
- 文件:`EPUBUI/RDEPUBReaderController.swift`(~1255 行)
|
|
||||||
- 入口方法:`init(epubURL:configuration:persistence:)`
|
|
||||||
- 职责:
|
|
||||||
- 加载 EPUB:后台解析 → 应用分页 → 恢复阅读位置
|
|
||||||
- 实现 `RDReaderDataSource` / `RDReaderDelegate` 为容器提供数据
|
|
||||||
- 实现 `RDEPUBWebContentViewDelegate` 接收 WebView 事件
|
|
||||||
- 管理工具栏显隐、目录面板、高亮管理、设置面板、搜索
|
|
||||||
- 持久化阅读位置、高亮、用户设置
|
|
||||||
- 对外暴露 `RDEPUBReaderDelegate` 回调宿主 App
|
|
||||||
|
|
||||||
### 2.2 配置 `RDEPUBReaderConfiguration`
|
|
||||||
|
|
||||||
- 文件:`EPUBUI/RDEPUBReaderConfiguration.swift`
|
|
||||||
- 13 个配置项:
|
|
||||||
- `fontSize`(默认 15)、`lineHeightMultiple`(默认 1.6)
|
|
||||||
- `displayType`(默认 .pageCurl)、`landscapeDualPageEnabled`(默认 true)
|
|
||||||
- `showsTableOfContents`(默认 true)、`allowsHighlights`(默认 true)、`showsSettingsPanel`(默认 true)
|
|
||||||
- `reflowableContentInsets`(默认 40/16/40/16)、`fixedContentInset`(默认 .zero)
|
|
||||||
- `theme`(默认 .light)
|
|
||||||
- `fixedLayoutFit`(默认 .page)、`fixedLayoutSpreadMode`(默认 .automatic)
|
|
||||||
- `textRenderingEngine`(默认 .dtCoreText)
|
|
||||||
- 变更检测:
|
|
||||||
- `requiresRepagination`:fontSize / lineHeightMultiple / contentInsets / fixedLayoutFit / fixedLayoutSpreadMode / textRenderingEngine 变化 → 完整重新分页
|
|
||||||
- `requiresVisibleRefresh`:theme 变化 → 刷新可见内容(不重新分页)
|
|
||||||
|
|
||||||
### 2.3 委托协议 `RDEPUBReaderDelegate`
|
|
||||||
|
|
||||||
- 文件:`EPUBUI/RDEPUBReaderDelegate.swift`
|
|
||||||
- 10 个可选方法(全部有默认空实现):
|
|
||||||
- `didOpen`:成功打开出版物
|
|
||||||
- `didUpdateLocation`:翻页或滚动时位置更新
|
|
||||||
- `didReachEnd`:到达最后一页
|
|
||||||
- `didChangeSelection`:文本选择变化或清除
|
|
||||||
- `didUpdateHighlights`:高亮增删改
|
|
||||||
- `didUpdateSearchResult`:搜索状态变化
|
|
||||||
- `didChangeCurrentSearchMatch`:当前搜索匹配项变化
|
|
||||||
- `didUpdateCurrentTableOfContentsItem`:翻页时匹配的目录项
|
|
||||||
- `didActivateExternalLink`:外部链接点击
|
|
||||||
- `didFailWithError`:解析或分页错误
|
|
||||||
|
|
||||||
### 2.4 持久化 `RDEPUBReaderPersistence`
|
|
||||||
|
|
||||||
- 文件:`EPUBUI/RDEPUBReaderPersistence.swift`
|
|
||||||
- 协议定义 6 个方法:loadLocation / saveLocation / loadHighlights / saveHighlights / loadReaderSettings / saveReaderSettings
|
|
||||||
- 默认实现 `RDEPUBUserDefaultsPersistence`:
|
|
||||||
- 位置:`ssreader.epub.location.<bookIdentifier>`(JSON 编码)
|
|
||||||
- 高亮:`ssreader.epub.highlights.<bookIdentifier>`(JSON 编码)
|
|
||||||
- 设置:`ssreader.epub.settings`(全局,非按书)
|
|
||||||
|
|
||||||
### 2.5 主题 `RDEPUBReaderTheme`
|
|
||||||
|
|
||||||
- 文件:`EPUBUI/RDEPUBReaderTheme.swift`
|
|
||||||
- 6 个颜色属性:contentBackgroundColor、contentTextColor、toolBackgroundColor、toolControlTextColor、toolControlBorderUnselectColor、toolLineColor
|
|
||||||
- 6 个内置预设:`.light`、`.dark`、`.yellow`、`.green`、`.pink`、`.blue`
|
|
||||||
- 计算属性 `themeBackgroundColorCSS` / `themeTextColorCSS` 用于 Web 渲染路径
|
|
||||||
|
|
||||||
### 2.6 设置面板 `RDEPUBReaderSettingsViewController`
|
|
||||||
|
|
||||||
- 文件:`EPUBUI/RDEPUBReaderSettingsViewController.swift`
|
|
||||||
- 5 个控制项:
|
|
||||||
- 亮度滑块(0-1)
|
|
||||||
- 字号 A-/A+(范围 12-36,步长 1)
|
|
||||||
- 行高分段(紧凑 1.3 / 标准 1.6 / 宽松 1.9)
|
|
||||||
- 显示模式分段(仿真 / 横滑 / 竖滑 / 覆盖)
|
|
||||||
- 主题选择(6 个圆形色块按钮)
|
|
||||||
- 所有变更通过闭包实时回调
|
|
||||||
|
|
||||||
### 2.7 内容视图
|
|
||||||
|
|
||||||
- `RDEPUBTextContentView`(`EPUBUI/RDEPUBTextContentView.swift`):textReflowable 路径,UITextView 展示富文本,支持系统选区、标注菜单、用户标注叠加和搜索高亮叠加
|
|
||||||
- `RDEPUBWebContentView`(`EPUBUI/RDEPUBWebContentView.swift`):web 路径,包装 `RDEPUBWebView`,转发位置、选区、标注菜单、链接和 JS 错误事件
|
|
||||||
|
|
||||||
### 2.8 其他 UI 组件
|
|
||||||
|
|
||||||
- `RDEPUBReaderTopToolView`:顶部导航栏(返回按钮 + 标题)
|
|
||||||
- `RDEPUBReaderBottomToolView`:底部工具栏(目录 / 批注 / 标注 / 设置 4 个按钮)
|
|
||||||
- `RDEPUBReaderChapterListController`:目录列表(modal UITableViewController,当前项高亮 systemBlue)
|
|
||||||
- `RDEPUBReaderHighlightsViewController`:标注管理(全部 / 批注 / 划线过滤,列表 + 编辑备注 + 删除 + 跳转,空态按过滤条件提示)
|
|
||||||
|
|
||||||
## 3. 主流程(代码级)
|
|
||||||
|
|
||||||
### 3.1 初始化与加载
|
|
||||||
|
|
||||||
1. `init(epubURL:configuration:persistence:)`:
|
|
||||||
- 加载持久化的阅读设置,叠加到 configuration
|
|
||||||
- 恢复屏幕亮度
|
|
||||||
2. `viewDidLoad`:
|
|
||||||
- 设置背景色
|
|
||||||
- `setupReaderView()`:添加 RDReaderView 全屏约束,注册 `RDEPUBTextContentView` 和 `RDEPUBWebContentView`
|
|
||||||
- `setupLoadingIndicator()`、`setupErrorLabel()`
|
|
||||||
3. `viewDidAppear`:调用 `startInitialLoadIfNeeded()`
|
|
||||||
4. 加载序列:
|
|
||||||
- `loadPublication()` → 读取持久化位置
|
|
||||||
- 后台 `RDEPUBParser.parse(epubURL:)` 解析
|
|
||||||
- 主线程 `applyParsedPublication()` → `paginatePublication()`
|
|
||||||
|
|
||||||
### 3.2 分页流程
|
|
||||||
|
|
||||||
`paginatePublication(restoreLocation:)` 根据 readingProfile 分三条路径:
|
|
||||||
|
|
||||||
**textReflowable 路径**:
|
|
||||||
1. 后台队列:`RDEPUBTextBookBuilder.build(parser:publication:pageSize:style:)`
|
|
||||||
2. 主线程 `applyTextBook(textBook:)`:保存 textBook,reloadData
|
|
||||||
|
|
||||||
**fixed layout 路径**:
|
|
||||||
1. 创建快照:`pageCounts: Array(repeating: 1, count: publication.spine.count)`
|
|
||||||
2. `applyPaginationSnapshot(snapshot:restoreLocation:)`
|
|
||||||
|
|
||||||
**web interactive 路径**:
|
|
||||||
1. `RDEPUBPaginator.calculate(parser:hostingView:presentation:completion:)`
|
|
||||||
2. 回调收到页数数组后 `applyPaginationSnapshot(snapshot:restoreLocation:)`
|
|
||||||
|
|
||||||
三条路径汇合 `finishPagination(restoreLocation:)`:
|
|
||||||
- `readerView.reloadData()`
|
|
||||||
- `restoreReadingLocation(restoreLocation)`
|
|
||||||
|
|
||||||
### 3.3 页面内容工厂
|
|
||||||
|
|
||||||
`pageContentView(readerView:pageNum:containerView:)`:
|
|
||||||
- 有 textBook → 出队/创建 `RDEPUBTextContentView`,调用 `configure(page:pageNumber:totalPages:configuration:searchState:)`
|
|
||||||
- 无 textBook → 出队/创建 `RDEPUBWebContentView`,调用 `configure(publication:request:pageNumber:totalPages:theme:)`
|
|
||||||
|
|
||||||
`pageIdentifier(readerView:pageNum:)`:返回对应类名字符串用于 cell 出队
|
|
||||||
|
|
||||||
### 3.4 工具栏交互
|
|
||||||
|
|
||||||
- RDReaderView 内部处理点击手势,中心 1/3 区域切换工具栏
|
|
||||||
- 顶部工具栏:返回按钮 pop/dismiss,标题设为 metadata.title
|
|
||||||
- 底部工具栏:4 个按钮按配置 flags 控制可见性
|
|
||||||
- 目录按钮 → present `RDEPUBReaderChapterListController`(.pageSheet)
|
|
||||||
- 批注按钮 → present `RDEPUBReaderHighlightsViewController`(.pageSheet)
|
|
||||||
- 标注按钮 → `presentAnnotationActionSheet(for:)`(需有 currentSelection,否则 disabled),可创建高亮、划线或带 note 的批注
|
|
||||||
- 设置按钮 → present `RDEPUBReaderSettingsViewController`(.pageSheet)
|
|
||||||
|
|
||||||
### 3.5 设置变更处理
|
|
||||||
|
|
||||||
- 字号变化:`configuration.fontSize = newValue` → `requiresRepagination` → `repaginatePreservingCurrentLocation()`
|
|
||||||
- 行高变化:同上
|
|
||||||
- 显示模式变化:`readerView.switchReaderDisplayType()` 重建容器
|
|
||||||
- 主题变化:`configuration.theme = newValue` → `requiresVisibleRefresh` → `refreshVisibleContentPreservingLocation()`
|
|
||||||
- 亮度变化:`UIScreen.main.brightness = value` + 持久化
|
|
||||||
|
|
||||||
### 3.6 标注管理
|
|
||||||
|
|
||||||
- 创建:`addAnnotation(from:style:color:note:)` → 从 currentSelection 构建 → 去重 → 追加到 activeHighlights → 持久化 → 刷新可见内容
|
|
||||||
- 兼容入口:`addHighlight(from:color:note:)` 仍保留,内部按 `.highlight` 创建
|
|
||||||
- 样式:`RDEPUBHighlightStyle.highlight` 使用背景色,`RDEPUBHighlightStyle.underline` 使用下划线;旧数据缺少 style 时默认按普通高亮解码
|
|
||||||
- 批注:note 非空时同一条 `RDEPUBHighlight` 同时承担批注语义
|
|
||||||
- 删除:`removeHighlight(id:)` → 移除 → 持久化 → 刷新
|
|
||||||
- 编辑备注:`updateHighlightNote(id:note:)` → 更新 → 持久化 → 刷新
|
|
||||||
- 跳转:`go(toHighlightID:animated:)` → 从高亮 location 解析页码 → `transitionToPage`
|
|
||||||
- WebView 选区:`epub-bridge.js` 生成 `dom-range` rangeInfo,Web 内容通过 payload 的 `style` 字段恢复高亮或划线
|
|
||||||
- DT 选区:`RDEPUBTextContentView` 把页内 selectedRange 映射为章节全局 text-offset rangeInfo,并在当前页与标注范围重叠时叠加背景或下划线
|
|
||||||
- 选区菜单:WebView 与 DT 路径都通过自定义菜单动作收口到 `拷贝 / 高亮 / 批注`,动作最终复用同一套创建与批注输入流程
|
|
||||||
|
|
||||||
### 3.7 书签管理
|
|
||||||
|
|
||||||
- 创建/切换:`toggleBookmark(note:)` 基于 `currentVisibleLocation()` 生成当前位置书签;若同一语义位置已存在书签则执行取消
|
|
||||||
- 位置范围:书签使用 `RDEPUBLocation`,持久化字段包含 `href / fragment / progression`,不依赖正文选区
|
|
||||||
- 当前位置命中:优先比较标准化后的 `href`,若双方都有 `fragment` 则直接比较;否则按 `navigationProgression` 容差判断(fixed-layout 0.01,reflowable 0.05)
|
|
||||||
- 标题补全:优先复用当前 TOC 项标题,否则根据扁平目录用 href 反查章节标题
|
|
||||||
- 列表管理:底部工具栏 `书签` 入口弹出 `RDEPUBReaderBookmarksViewController`,支持按创建时间倒序展示、跳转与删除
|
|
||||||
- 顶部状态:TopToolView 右上角书签按钮显示当前页是否已加书签,点击可直接添加/取消
|
|
||||||
- 持久化:每次新增/删除都会通过 `RDEPUBReaderPersistence.saveBookmarks(_:for:)` 按书保存,并回调 `didUpdateBookmarks`
|
|
||||||
|
|
||||||
### 3.8 搜索流程
|
|
||||||
|
|
||||||
1. `search(keyword:)`:标准化输入
|
|
||||||
2. textBook 路径:`RDEPUBTextSearchEngine.search(keyword:)`
|
|
||||||
3. web 路径:`RDEPUBHTMLSearchEngine.search(keyword:)`
|
|
||||||
4. 存储结果到 `searchState`,导航到第一个匹配
|
|
||||||
5. `searchNext()` / `searchPrevious()`:循环推进 currentMatchIndex 并导航
|
|
||||||
6. `clearSearch()`:清除状态,刷新可见内容
|
|
||||||
|
|
||||||
### 3.9 阅读位置持久化
|
|
||||||
|
|
||||||
**保存时机**:
|
|
||||||
- 每次翻页:`pageNum(readerView:pageNum:)` → `persist(location:)`
|
|
||||||
- WebView 滚动:`epubWebContentView(_:didUpdateLocation:)` → `persist(location:)`
|
|
||||||
|
|
||||||
**恢复时机**:
|
|
||||||
- 初始加载:`loadPublication()` 读取 → 传递到 `finishPagination()` → `restoreReadingLocation()`
|
|
||||||
- 显式导航:`go(to:)` / `go(toTableOfContentsHref:)` / `go(toHighlightID:)` / 搜索导航
|
|
||||||
|
|
||||||
**恢复逻辑**:
|
|
||||||
- textBook 路径:`textBook.pageNumber(for:resolver:bookIdentifier:)` → `readerView.transitionToPage`
|
|
||||||
- readingSession 路径:`readingSession.queueNavigation(to:)` → `transitionToPage`
|
|
||||||
|
|
||||||
### 3.9 横竖屏适配
|
|
||||||
|
|
||||||
- `viewWillTransition(to:with:)`:等待旋转过渡完成后,统一触发 viewport 变化处理
|
|
||||||
- `viewDidLayoutSubviews`:用于补齐分屏、多窗口、safe area 变化等非旋转型视口变化
|
|
||||||
- 控制器内部使用 viewport signature 做去重,避免一次旋转触发多次正文重分页
|
|
||||||
- RDReaderView 的 `readerViewOrientationWillChange` 仅保留容器级双页布局和 pageCurl 重建职责,不再直接驱动 EPUBUI 重新分页
|
|
||||||
|
|
||||||
## 4. 异常与边界处理
|
|
||||||
|
|
||||||
- EPUB 解析失败:显示 errorLabel,delegate 收到 `didFailWithError`
|
|
||||||
- 分页失败:主线程 `handle(error:)` 处理
|
|
||||||
- `paginationToken` 防竞态:每次加载/分页生成新 UUID,后台完成时校验 token 一致才应用结果
|
|
||||||
- 标注去重:`addAnnotation` 检查相同 location、text、rangeInfo 和 style 是否已存在标注
|
|
||||||
- 自定义主题不可持久化:仅 6 个内置 preset 可序列化,自定义主题仅当前会话有效
|
|
||||||
- 目录页码缺失:`flattenedTableOfContents` 中 pageNumber 可能为 nil(无法定位到对应页面的 TOC 项)
|
|
||||||
|
|
||||||
## 5. 数据结构与字段映射
|
|
||||||
|
|
||||||
### 5.1 持久化键值
|
|
||||||
|
|
||||||
| 数据 | UserDefaults Key | 编码 |
|
|
||||||
|------|-----------------|------|
|
|
||||||
| 阅读位置 | `ssreader.epub.location.<bookIdentifier>` | JSON → `RDEPUBLocation` |
|
|
||||||
| 高亮列表 | `ssreader.epub.highlights.<bookIdentifier>` | JSON → `[RDEPUBHighlight]` |
|
|
||||||
| 书签列表 | `ssreader.epub.bookmarks.<bookIdentifier>` | JSON → `[RDEPUBBookmark]` |
|
|
||||||
| 阅读设置 | `ssreader.epub.settings` | JSON → `RDEPUBReaderSettings` |
|
|
||||||
|
|
||||||
### 5.2 Book Identifier
|
|
||||||
|
|
||||||
- 优先使用 `parser.metadata.identifier`
|
|
||||||
- 回退使用 `epubURL.lastPathComponent`
|
|
||||||
|
|
||||||
### 5.3 设置快照
|
|
||||||
|
|
||||||
```swift
|
|
||||||
RDEPUBReaderSettings (Codable)
|
|
||||||
├── brightness: CGFloat
|
|
||||||
├── fontSize: CGFloat
|
|
||||||
├── lineHeightMultiple: CGFloat
|
|
||||||
├── displayMode: RDEPUBReaderDisplayMode
|
|
||||||
└── themePreset: RDEPUBReaderThemePreset?
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.4 目录扁平化项
|
|
||||||
|
|
||||||
```swift
|
|
||||||
RDEPUBReaderTableOfContentsItem
|
|
||||||
├── title: String
|
|
||||||
├── href: String
|
|
||||||
├── depth: Int // 缩进层级
|
|
||||||
└── pageNumber: Int? // 可能为 nil
|
|
||||||
```
|
|
||||||
|
|
||||||
## 6. 视图绑定规则
|
|
||||||
|
|
||||||
- 规则 1:内容视图通过 class name 注册到 RDReaderView,出队时根据 textBook 是否存在选择类型
|
|
||||||
- 规则 2:RDEPUBTextContentView 展示 attributedText + 搜索高亮叠加 + 页码标签("N / M")
|
|
||||||
- 规则 3:RDEPUBWebContentView 包装 RDEPUBWebView + 页码标签
|
|
||||||
- 规则 4:顶部工具栏固定提供返回与书签切换入口;底部工具栏提供目录、书签、标注、设置等管理入口
|
|
||||||
- 规则 5:设置面板所有变更实时生效(闭包回调),无需确认按钮
|
|
||||||
|
|
||||||
## 7. 通知协作与回调机制
|
|
||||||
|
|
||||||
### 外部通信(Delegate)
|
|
||||||
|
|
||||||
- `RDEPUBReaderDelegate`:11 个可选方法,覆盖打开、位置更新、到达末尾、选择变化、高亮更新、书签更新、搜索更新、目录项更新、外部链接、错误
|
|
||||||
|
|
||||||
### 内部通信(闭包)
|
|
||||||
|
|
||||||
- `RDEPUBReaderTopToolView.onBack / onToggleBookmark`
|
|
||||||
- `RDEPUBReaderBottomToolView.onShowTableOfContents / onShowBookmarks / onShowHighlights / onAddHighlight / onShowSettings`
|
|
||||||
- `RDEPUBReaderChapterListController.onSelectItem`
|
|
||||||
- `RDEPUBReaderHighlightsViewController.onSelectHighlight / onUpdateHighlight / onDeleteHighlight`
|
|
||||||
- `RDEPUBReaderSettingsViewController.onBrightnessChange / onFontSizeChange / onLineHeightChange / onDisplayTypeChange / onThemeChange`
|
|
||||||
|
|
||||||
### 容器通信(协议)
|
|
||||||
|
|
||||||
- `RDReaderDataSource` / `RDReaderDelegate`:RDEPUBReaderController 实现,为 RDReaderView 提供页面数据和接收页面变化事件
|
|
||||||
- `RDEPUBWebContentViewDelegate`:RDEPUBReaderController 实现,接收 WebView 的位置更新、选择变化、链接激活、JS 错误
|
|
||||||
|
|
||||||
## 8. 联调与排查建议
|
|
||||||
|
|
||||||
- 排查 1:阅读器打开后空白
|
|
||||||
- 检查 `RDEPUBParser.parse(epubURL:)` 是否成功
|
|
||||||
- 检查 `startInitialLoadIfNeeded()` 是否被调用(需要 viewDidAppear)
|
|
||||||
- 检查 errorLabel 是否显示
|
|
||||||
- 排查 2:翻页后位置不恢复
|
|
||||||
- 检查 `persistence.loadLocation(for:)` 是否返回非 nil
|
|
||||||
- 检查 `restoreReadingLocation` 是否在 `finishPagination` 中被调用
|
|
||||||
- textBook 路径检查 `textBook.pageNumber(for:)` 返回值
|
|
||||||
- 排查 3:设置变更后无效果
|
|
||||||
- 字号/行高变化需触发 `repaginatePreservingCurrentLocation()`
|
|
||||||
- 主题变化只需 `refreshVisibleContentPreservingLocation()`
|
|
||||||
- 检查 `requiresRepagination` / `requiresVisibleRefresh` 的判断逻辑
|
|
||||||
- 排查 4:高亮不显示
|
|
||||||
- 确认 `currentSelection` 非 nil
|
|
||||||
- 确认 `addHighlight` 成功返回
|
|
||||||
- 确认 `refreshVisibleContentPreservingLocation()` 被调用
|
|
||||||
- textBook 路径检查 `RDEPUBTextContentView.configure` 中的搜索高亮逻辑
|
|
||||||
- 排查 5:搜索无结果
|
|
||||||
- textBook 路径确认 `RDEPUBTextSearchEngine.search` 被调用
|
|
||||||
- web 路径确认 `RDEPUBHTMLSearchEngine.search` 被调用
|
|
||||||
- 检查搜索关键词是否为空
|
|
||||||
- 排查 6:横屏布局异常
|
|
||||||
- 确认 `landscapeDualPageEnabled` 是否为 true
|
|
||||||
- 检查 `viewDidLayoutSubviews` 是否触发了重新分页
|
|
||||||
- 检查 RDReaderView 的 orientation change 处理
|
|
||||||
@@ -1,347 +0,0 @@
|
|||||||
# EPUB 阅读模块维护与排查指南
|
|
||||||
|
|
||||||
## 1. 文档目的
|
|
||||||
|
|
||||||
本文档说明 EPUB 阅读模块的关键文件职责、主要数据流、常见问题排查和后续扩展建议,供维护时快速建立上下文。
|
|
||||||
|
|
||||||
完整架构设计请参考 [ARCHITECTURE.md](ARCHITECTURE.md)。
|
|
||||||
编码规范请参考 [CODING_STYLE.md](CODING_STYLE.md)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 关键文件速查
|
|
||||||
|
|
||||||
### 2.1 EPUBCore 层
|
|
||||||
|
|
||||||
| 文件 | 职责 |
|
|
||||||
|------|------|
|
|
||||||
| `RDEPUBParser.swift` + 扩展 | EPUB 解压、container.xml、OPF、manifest、spine、TOC/NCX 解析 |
|
|
||||||
| `RDEPUBPublication.swift` | 解析结果的唯一聚合入口,外部不直接访问 parser 字段 |
|
|
||||||
| `RDEPUBModels.swift` | metadata / manifest / spine / TOC 基础模型 |
|
|
||||||
| `RDEPUBReadingModels.swift` | RDEPUBLocation、RDEPUBViewport、RDEPUBHighlight、选区模型 |
|
|
||||||
| `RDEPUBResourceResolver.swift` | href → fileURL / schemeURL 的统一入口,`normalizedHref` 标准化路径 |
|
|
||||||
| `RDEPUBPaginator.swift` | 离屏 WKWebView 分页,计算每个 spine 资源被视口切成多少页 |
|
|
||||||
| `RDEPUBReadingSession.swift` | 状态机 + 会话协调,管理 staged/active/pending 页模型 |
|
|
||||||
| `RDEPUBNavigatorState.swift` | 状态枚举(initializing/loading/idle/jumping/moving/repaginating) |
|
|
||||||
| `RDEPUBWebView.swift` + 扩展 | 承载 spine 资源的 WKWebView,分页 CSS 注入、JS bridge、fixed wrapper |
|
|
||||||
| `RDEPUBResourceURLSchemeHandler.swift` | `ss-reader://` 协议,从本地解压目录提供所有 EPUB 资源 |
|
|
||||||
| `RDEPUBStyleSheetBuilder.swift` | 生成注入 WebView 的 CSS |
|
|
||||||
| `RDEPUBJavaScriptBridge.swift` | JS ↔ Swift 消息定义和编解码 |
|
|
||||||
|
|
||||||
### 2.2 EPUBTextRendering 层
|
|
||||||
|
|
||||||
| 文件 | 职责 |
|
|
||||||
|------|------|
|
|
||||||
| `RDEPUBTextRenderer.swift` | 渲染引擎协议 + 错误类型 + 样式 / 内容模型 |
|
|
||||||
| `RDEPUBDTCoreTextRenderer.swift` | DTCoreText 实现,HTML → NSAttributedString |
|
|
||||||
| `RDEPUBTextRendererSupport.swift` | fragment 标记注入、fragment offset 提取、属性标准化 |
|
|
||||||
| `RDEPUBTextPaginationSupport.swift` | NSAttributedString 切页算法 |
|
|
||||||
| `RDEPUBTextBookBuilder.swift` | 驱动逐章节渲染和分页,生成 RDEPUBTextBook |
|
|
||||||
|
|
||||||
### 2.3 EPUBUI 层
|
|
||||||
|
|
||||||
| 文件 | 职责 |
|
|
||||||
|------|------|
|
|
||||||
| `RDEPUBReaderController.swift` | 开箱即用读者控制器,整合 session / readerView / UI |
|
|
||||||
| `RDEPUBReaderConfiguration.swift` | 外部配置项(字号、主题、翻页模式等) |
|
|
||||||
| `RDEPUBReaderPersistence.swift` | 阅读位置和高亮的本地持久化 |
|
|
||||||
| `RDEPUBReaderSettings.swift` | 运行时设置状态 |
|
|
||||||
| `RDEPUBWebContentView.swift` | WKWebView 内容视图,供 RDReaderView 渲染 WebView 类 spine |
|
|
||||||
| `RDEPUBTextContentView.swift` | 文本内容视图,供 RDReaderView 渲染文本类 spine |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 资源服务协议说明
|
|
||||||
|
|
||||||
加载 spine 资源统一使用 `ss-reader://` 协议,不直接使用 `file://`:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// 正确方式
|
|
||||||
let schemeURL = publication.resourceResolver.schemeURL(forHref: spineItem.href)
|
|
||||||
webView.load(URLRequest(url: schemeURL))
|
|
||||||
|
|
||||||
// 禁止方式(relative 资源、图片、CSS 会失效)
|
|
||||||
webView.loadHTMLString(htmlString, baseURL: nil)
|
|
||||||
```
|
|
||||||
|
|
||||||
这样 HTML 内部的相对 CSS、图片、字体和 iframe 资源都会通过同一个 schemeHandler 解析,避免 `loadHTMLString` 或 `allowingReadAccessTo` 导致的资源失效问题。
|
|
||||||
|
|
||||||
### 3.1 DTCoreText 实现逻辑(textReflowable 路径)
|
|
||||||
|
|
||||||
当 `readingProfile == .textReflowable` 时,渲染链路不再走 WebView 列分页,而是走 `EPUBTextRendering/` 的 DTCoreText 文本渲染链。
|
|
||||||
|
|
||||||
**端到端流程:**
|
|
||||||
|
|
||||||
1. `RDEPUBTextBookBuilder` 按 spine 顺序读取章节 HTML。
|
|
||||||
2. `RDEPUBTextRendererSupport.injectFragmentMarkers(into:)` 注入 fragment 标记,保证目录锚点可映射到文本偏移。
|
|
||||||
3. `RDEPUBDTCoreTextRenderer.renderChapter(html:baseURL:style:)` 执行章节渲染。
|
|
||||||
4. 渲染成功时,内部通过 `DTHTMLAttributedStringBuilder` 将 HTML 转为 `NSAttributedString`。
|
|
||||||
5. `RDEPUBTextRendererSupport.extractFragmentOffsets(from:)` 提取 `fragment -> 字符偏移` 映射。
|
|
||||||
6. `RDEPUBTextRendererSupport.normalizeReadingAttributes(in:style:)` 统一字体、行距、颜色等阅读属性。
|
|
||||||
7. `RDEPUBTextPaginationSupport` 将富文本按可视区域切页,构建页模型。
|
|
||||||
8. `RDEPUBTextBookBuilder` 汇总章节与页映射,交给 `RDEPUBTextContentView` 呈现。
|
|
||||||
|
|
||||||
**关键实现点:**
|
|
||||||
|
|
||||||
1. 条件编译:`#if canImport(DTCoreText)`
|
|
||||||
- 可用时走 DTCoreText 路径。
|
|
||||||
- 不可用时走 fallback(纯 `NSAttributedString` 降级),确保工程可编译可运行。
|
|
||||||
2. `dtOptions(baseURL:style:)` 会注入:
|
|
||||||
- 默认字体族 / 字体名 / 字号
|
|
||||||
- 行高倍数(由字体行高与行距折算)
|
|
||||||
- `NSBaseURLDocumentOption`(保证相对资源解析)
|
|
||||||
- 默认文本颜色
|
|
||||||
3. fragment 偏移在“属性标准化前后”都要保持稳定,避免目录跳转漂移。
|
|
||||||
4. 文本分页以字符范围为准,不以 WebView 像素滚动位置为准。
|
|
||||||
|
|
||||||
**失败与降级策略:**
|
|
||||||
|
|
||||||
1. HTML 转 UTF-8 失败:抛出 `htmlEncodingFailed`。
|
|
||||||
2. DTCoreText builder 失败:进入 fallback 渲染路径。
|
|
||||||
3. fallback 仍会执行 fragment 提取与阅读属性标准化,保证目录跳转和阅读样式能力不丢失。
|
|
||||||
|
|
||||||
**与定位恢复的关系:**
|
|
||||||
|
|
||||||
1. 仍使用 `RDEPUBLocation`(`href + progression`)作为跨会话恢复模型。
|
|
||||||
2. textReflowable 模式下,`fragmentOffsets` 用于将目录锚点映射到分页后的字符区间,再换算到页索引。
|
|
||||||
3. 字号/行距变化触发重分页后,优先按 `href + progression` 回落恢复,再用 fragment 做细化定位。
|
|
||||||
|
|
||||||
**推荐日志点(DTCoreText 专用):**
|
|
||||||
|
|
||||||
1. `RDEPUBDTCoreTextRenderer.renderChapter(...)`:记录章节 href、输入 HTML 长度、渲染耗时。
|
|
||||||
2. `makeAttributedString(from:baseURL:style:)`:记录 DTCoreText 是否成功返回 attributed string。
|
|
||||||
3. `extractFragmentOffsets(from:)`:记录 fragment 数量与关键锚点是否命中。
|
|
||||||
4. `RDEPUBTextPaginationSupport`:记录总页数、每页字符范围边界。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 常见问题排查
|
|
||||||
|
|
||||||
### 4.1 EPUB 打不开或解析失败
|
|
||||||
|
|
||||||
**优先检查:**
|
|
||||||
|
|
||||||
1. `RDEPUBParser.parse(epubURL:)` 是否抛出异常,打印具体错误
|
|
||||||
2. `RDEPUBParser+Archive.swift` — 解压是否成功
|
|
||||||
3. `META-INF/container.xml` 是否存在
|
|
||||||
4. OPF 中 manifest / spine 是否完整
|
|
||||||
|
|
||||||
**常见原因:**
|
|
||||||
|
|
||||||
- 文件不是标准 EPUB ZIP 结构
|
|
||||||
- `container.xml` 找不到 OPF 路径
|
|
||||||
- OPF manifest 或 spine 格式异常
|
|
||||||
- 文件路径包含特殊字符导致解压目录路径错误
|
|
||||||
|
|
||||||
**排查顺序:**
|
|
||||||
|
|
||||||
1. 确认 EPUB 已成功复制到沙盒
|
|
||||||
2. 确认解压目录存在(`extractionRootURL`)
|
|
||||||
3. 确认 `opfDirectoryURL` 不为 nil
|
|
||||||
4. 确认 `spine.count > 0`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4.2 EPUB 显示空白页
|
|
||||||
|
|
||||||
**优先检查:**
|
|
||||||
|
|
||||||
1. `RDEPUBWebContentView.loadPage(...)` 传入的 spineIndex 是否在范围内
|
|
||||||
2. `RDEPUBWebView+Reflowable` — 分页 CSS 注入是否成功
|
|
||||||
3. `WKNavigationDelegate.didFinish` 是否正常触发
|
|
||||||
4. `RDEPUBResourceURLSchemeHandler` 是否正确返回资源数据
|
|
||||||
|
|
||||||
**常见原因:**
|
|
||||||
|
|
||||||
1. scheme URL 拼装错误,资源请求 404
|
|
||||||
2. 注入的 CSS 破坏了原始布局(尤其是 `column-width` 样式冲突)
|
|
||||||
3. fixed-layout 书籍走了 reflowable 渲染路径(readingProfile 判定错误)
|
|
||||||
|
|
||||||
**排查顺序:**
|
|
||||||
|
|
||||||
1. 确认 `schemeURL(forHref:)` 返回的 URL 对应文件真实存在
|
|
||||||
2. 用 `RDEPUBWebViewDebug` 打开 WebView inspector 查看网络请求
|
|
||||||
3. 确认 readingProfile 判定正确(webInteractive / webFixedLayout / textReflowable)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4.3 修改字号后位置恢复不准
|
|
||||||
|
|
||||||
**原因:** 字号变化触发重新分页(repaginating),页号失效。
|
|
||||||
|
|
||||||
**正确做法:**
|
|
||||||
|
|
||||||
当前实现依赖 `href + progression` 恢复位置,不依赖纯页号。排查重点:
|
|
||||||
|
|
||||||
1. `RDEPUBLocation.href` 是否已通过 `normalizedHref` 标准化
|
|
||||||
2. `progression` 是否在字号变化后重新从 WebView 读取
|
|
||||||
3. `RDEPUBReadingSession.navigatorState` 是否在重分页后回到 `.idle` 并消费了 staged snapshot
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4.4 目录点击后跳错位置
|
|
||||||
|
|
||||||
**优先检查:**
|
|
||||||
|
|
||||||
1. `RDEPUBParser+TOC.swift` — TOC href 是否正确标准化为相对 OPF 路径
|
|
||||||
2. `RDEPUBResourceResolver.normalizedHref(_:)` — 跳转时是否使用了标准化 href
|
|
||||||
3. `fragment` 是否正确传入 WebView 锚点滚动
|
|
||||||
|
|
||||||
**常见原因:**
|
|
||||||
|
|
||||||
1. TOC 链接是相对 Nav 或 NCX 文件目录,未转为相对 OPF
|
|
||||||
2. `href#fragment` 只命中了资源,未命中锚点
|
|
||||||
3. 同一资源中多个 TOC 条目被折叠成同一页
|
|
||||||
|
|
||||||
**排查步骤:**
|
|
||||||
|
|
||||||
1. 打印原始 TOC href 和标准化后 href 进行比较
|
|
||||||
2. 确认 `fragment` 字段被保留在 RDEPUBLocation 中
|
|
||||||
3. 确认 WebView 收到 `fragment` 后执行了 `scrollToFragment(fragment:)` JS 调用
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4.5 正文内部链接不工作
|
|
||||||
|
|
||||||
**优先检查:**
|
|
||||||
|
|
||||||
1. `RDEPUBWebView+JavaScriptBridge` 中内部链接拦截逻辑
|
|
||||||
2. `RDEPUBReaderController.navigateToLocation(_:)` — 跨资源跳转路径
|
|
||||||
|
|
||||||
**常见原因:**
|
|
||||||
|
|
||||||
1. 链接是空 `#fragment`,但当前 DOM 中找不到对应元素
|
|
||||||
2. 跨资源链接 href 未被 normalizedHref 处理
|
|
||||||
3. 对应 spine 资源没有分页结果(pageCounts[spineIndex] == 0)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4.6 图片多时位置跳动
|
|
||||||
|
|
||||||
**原因:** 图片异步加载导致 DOM 高度变化,进度报告时机早于实际布局稳定。
|
|
||||||
|
|
||||||
**排查:**
|
|
||||||
|
|
||||||
1. 检查 JS `ResizeObserver` 是否在图片加载后触发了进度重同步
|
|
||||||
2. 确认 `didUpdateLocation` 不在 pending navigation 未消费前覆盖目标位置
|
|
||||||
3. 确认 `scrollToLocation()` JS 调用时序在 `didFinish` 之后
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4.7 高亮未显示或恢复失败
|
|
||||||
|
|
||||||
**优先检查:**
|
|
||||||
|
|
||||||
1. `RDEPUBHighlight` 是否成功写入 persistence
|
|
||||||
2. `RDEPUBReaderController.activeHighlights` 是否正确过滤出当前 spine 的高亮
|
|
||||||
3. `RDEPUBWebView+JavaScriptBridge` 中 `setHighlights()` JS 调用是否触发
|
|
||||||
4. JS 中 `rangeFromInfo()` 是否能找到对应 DOM 节点(嵌套节点恢复容易失败)
|
|
||||||
|
|
||||||
**排查顺序:**
|
|
||||||
|
|
||||||
1. 确认有效选区(`currentSelection` 不为 nil)
|
|
||||||
2. 确认 `addHighlight()` 调用后 persistence 中数据已写入
|
|
||||||
3. 确认当前页传入的 highlights 数组不为空
|
|
||||||
4. 确认 JS 收到了 highlights 数据并尝试了 rangeFromInfo 恢复
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4.8 fixed-layout 书籍显示异常
|
|
||||||
|
|
||||||
**优先检查:**
|
|
||||||
|
|
||||||
1. `RDEPUBPublication.readingProfile` 是否判定为 `.webFixedLayout`
|
|
||||||
2. `RDEPUBFixedLayoutTemplate` — wrapper HTML 是否正确生成
|
|
||||||
3. viewport meta 尺寸是否与书籍 OPF 中声明一致
|
|
||||||
4. spread 模式是否正确(单页 vs 双页)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 推荐日志点
|
|
||||||
|
|
||||||
遇到复杂问题时,建议先在以下位置加日志:
|
|
||||||
|
|
||||||
1. `RDEPUBParser.parse(epubURL:)` — 打印解压路径、OPF 路径、spine 数量
|
|
||||||
2. `RDEPUBReadingSession.transition(to:)` — 打印状态跃迁
|
|
||||||
3. `RDEPUBPaginator` — 打印每个 spine 资源的分页结果
|
|
||||||
4. `RDEPUBReaderController.restoreLocation(_:)` — 打印恢复位置的 href / progression
|
|
||||||
5. `RDEPUBWebView+JavaScriptBridge` — 打印所有 JS 消息收发
|
|
||||||
6. `RDEPUBResourceURLSchemeHandler` — 打印资源请求 URL 和响应状态
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 标准排查顺序
|
|
||||||
|
|
||||||
当 EPUB 功能出问题时,建议按以下顺序排查:
|
|
||||||
|
|
||||||
1. **解析是否成功** — parser.spine.count > 0?metadata 正确?
|
|
||||||
2. **readingProfile 是否正确** — 用了对的渲染路径?
|
|
||||||
3. **TOC href 是否标准化** — 与 spine href 格式一致?
|
|
||||||
4. **分页结果是否合理** — 每个 spine 资源的 pageCount > 0?
|
|
||||||
5. **资源是否能正常加载** — ss-reader:// 请求是否 200?
|
|
||||||
6. **JS bridge 是否正常** — progression 上报是否到达 Swift 侧?
|
|
||||||
7. **状态机是否流转正确** — session.navigatorState 是否符合预期?
|
|
||||||
8. **控制器是否正确映射页号** — EPUBPage 列表与 RDReaderView 页号对应是否正确?
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 后续扩展建议
|
|
||||||
|
|
||||||
1. **拆分 demo 层 RDReaderController**:约 1900+ 行,EPUB 加载、UI、数据源职责仍混在一起,建议继续向 library 层迁移
|
|
||||||
2. **基线验证**:用四本样书跑一轮分页耗时、目录命中率、末页事件、设置恢复稳定性数据
|
|
||||||
3. **横竖屏支持回归矩阵**:控制器级 `viewWillTransition()` 与 viewport 去重已接入,下一步重点是补齐 pageCurl / scroll、三条正文路径和 iPad 视口变化场景的固定回归
|
|
||||||
4. **Pod 分层**:评估是否将 EPUBCore / EPUBTextRendering / EPUBUI 拆为独立 subspec
|
|
||||||
5. **RDEPUBParser 继续收敛**:OPF metadata 细节处理仍偏重,可考虑按 OPFMetadataParser 单独拆出
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 阅读器能力现状与下一阶段规划
|
|
||||||
|
|
||||||
### 8.1 当前已具备的核心能力
|
|
||||||
|
|
||||||
当前 SDK 已具备一套可用的 EPUB 阅读器主链路,包含:
|
|
||||||
|
|
||||||
1. **EPUB 打开与解析**:支持 ZIP 解压、OPF / spine / TOC 解析,以及三类 readingProfile 分流
|
|
||||||
2. **正文渲染与分页**:支持 WebView 路径和 DTCoreText 文本路径,覆盖固定版式、交互式可重排和纯文本可重排 EPUB
|
|
||||||
3. **完整阅读交互**:支持目录跳转、翻页、阅读位置恢复、主题切换、字号和行高调整、亮度调整
|
|
||||||
4. **标注能力**:支持高亮、划线、批注、标注列表、编辑、删除、跳转与持久化恢复
|
|
||||||
5. **书签能力**:支持当前位置书签添加/取消、书签列表、删除、跳转与持久化恢复
|
|
||||||
6. **自动化测试基础能力**:已接入 `ReadViewSDKDemoTests` / `ReadViewSDKDemoUITests`、样书夹具定位工具,以及 parser / resolver / persistence 首批 XCTest 和阅读器启动 smoke 用例
|
|
||||||
7. **搜索能力**:支持全文搜索、匹配项跳转、当前匹配高亮
|
|
||||||
|
|
||||||
### 8.2 高优先级待补能力
|
|
||||||
|
|
||||||
从“可用 MVP”走向“稳定可交付阅读器”,当前最值得优先完成的是:
|
|
||||||
|
|
||||||
1. **样书基线验证**
|
|
||||||
需要基于四本样书补齐分页耗时、目录命中率、末页事件、设置恢复稳定性等基线数据
|
|
||||||
|
|
||||||
### 8.3 面向完整商业阅读器的下一阶段能力
|
|
||||||
|
|
||||||
若目标是继续向完整阅读产品演进,建议后续补齐以下能力:
|
|
||||||
|
|
||||||
1. **阅读进度展示**
|
|
||||||
包括全书进度、章节进度、页码信息、剩余页数或剩余章节等
|
|
||||||
2. **更完整的标注管理**
|
|
||||||
包括按章节分组、排序、筛选、批量删除、标注导出、复制批注内容等
|
|
||||||
3. **更成熟的搜索体验**
|
|
||||||
包括搜索结果列表页、章节聚合、上下文预览、搜索历史等
|
|
||||||
4. **更多阅读增强能力**
|
|
||||||
包括字体切换、自定义主题、翻页手势自定义、TTS 朗读、无障碍支持等
|
|
||||||
|
|
||||||
### 8.4 工程侧持续建设建议
|
|
||||||
|
|
||||||
除了阅读器功能本身,还建议并行推进以下工程项:
|
|
||||||
|
|
||||||
1. **控制器继续拆分**
|
|
||||||
`RDEPUBReaderController` 与 demo 层控制器都偏大,继续叠加功能会增加维护成本
|
|
||||||
2. **Parser 与模块职责继续收敛**
|
|
||||||
`RDEPUBParser` 仍承担较多 OPF metadata 解析细节,后续可继续按职责拆分
|
|
||||||
3. **回归验收矩阵固化**
|
|
||||||
建议把样书、渲染路径、主题、字号、翻页模式、标注恢复等整理为固定回归清单
|
|
||||||
|
|
||||||
### 8.5 推荐下一阶段实施顺序
|
|
||||||
|
|
||||||
如果以“最小投入换最大稳定性提升”为目标,建议按下面顺序推进:
|
|
||||||
|
|
||||||
1. **样书基线验证**
|
|
||||||
@@ -1,265 +0,0 @@
|
|||||||
# Reflowable EPUB 使用 WXRead 风格原生渲染:详细设计
|
|
||||||
|
|
||||||
> 文档目的:讨论并固化“将 ReadViewSDK 的 reflowable EPUB 渲染/排版/分页改为参考 Doc/WXRead 的微信读书(WXRead)原生渲染方式”的可落地设计,供后续开发与回归使用。
|
|
||||||
> 版本:v0(设计草案)
|
|
||||||
> 日期:2026-05-21
|
|
||||||
|
|
||||||
## 0. 背景与结论(先说人话)
|
|
||||||
|
|
||||||
ReadViewSDK 当前对 EPUB 有三类渲染路径:
|
|
||||||
|
|
||||||
- `RDEPUBReadingProfile.webFixedLayout`:Fixed Layout EPUB → `WKWebView`(保持不变)
|
|
||||||
- `RDEPUBReadingProfile.webInteractive`:交互式 EPUB(JS/音视频/表单/iframe/外链/bridge)→ `WKWebView`(保持不变)
|
|
||||||
- `RDEPUBReadingProfile.textReflowable`:普通 reflowable EPUB → **当前走 DTCoreText → NSAttributedString → CoreText 分页 → 原生文本视图**(这是我们要“升级成 WXRead 风格”的主战场)
|
|
||||||
|
|
||||||
本次改造的最小可落地方向是:**保留三分流策略不变**,只增强 `.textReflowable` 分支,使其在“CSS 分层、样式一致性、资源解析、分页稳定性”等方面更接近 `Doc/WXRead/analysis/EPUB渲染管线详解.md` 所描述的 WXRead 管线,而不是引入新的 WebView 渲染。
|
|
||||||
|
|
||||||
## 1. 目标 / 非目标
|
|
||||||
|
|
||||||
### 1.1 目标(In Scope)
|
|
||||||
|
|
||||||
- G1:reflowable EPUB 的正文渲染/排版/分页改为“WXRead 风格原生渲染管线”:
|
|
||||||
- XHTML/HTML →(CSS 分层 + 解析 + 后处理)→ `NSAttributedString`
|
|
||||||
- `NSAttributedString` →(CoreText 分页)→ 单页内容
|
|
||||||
- 单页内容 → 原生绘制/展示
|
|
||||||
- G2:保持 Fixed Layout 与交互式 EPUB 的 `WKWebView` 路径不回归。
|
|
||||||
- G3:不破坏 `RDURLReaderController` 打开 `.epub` / `.txt` 的主流程。
|
|
||||||
- G4:Demo 可用于验证:至少 2-3 本典型 reflowable EPUB 在段落/标题/图片/链接等常见内容下可稳定阅读。
|
|
||||||
|
|
||||||
### 1.2 非目标(Out of Scope)
|
|
||||||
|
|
||||||
- N1:Fixed Layout EPUB 切换为原生渲染(明确不做)。
|
|
||||||
- N2:交互式 EPUB 切换为原生渲染(明确不做)。
|
|
||||||
- N3:一次性复刻 WXRead 对 DTCoreText 的所有深度魔改(例如自定义 CSS 属性体系、复杂后处理、分页避断规则等)——本次先按“问题驱动”逐步对齐。
|
|
||||||
|
|
||||||
## 2. 关键事实核验(当前代码真实路径)
|
|
||||||
|
|
||||||
> 纠偏说明:`.planning/PROJECT.md` 在初始化时曾把“reflowable EPUB 当前路径”概括为偏 `WKWebView` 的历史性表述。经本次代码核验,当前真实主路径是 `.textReflowable` → `RDEPUBTextBookBuilder` → `RDEPUBDTCoreTextRenderer` → CoreText 分页 → `RDEPUBTextContentView`。本设计以代码事实为准,并默认后续计划/实现都按此理解推进。
|
|
||||||
|
|
||||||
### 2.1 渲染路径分流(已存在)
|
|
||||||
|
|
||||||
- 判定在 `Sources/RDReaderView/EPUBCore/RDEPUBParser+ReadingProfile.swift`:
|
|
||||||
- `metadata.layout == .fixed` → `.webFixedLayout`
|
|
||||||
- `hasInteractiveContent() == true` → `.webInteractive`
|
|
||||||
- 否则 → `.textReflowable`
|
|
||||||
|
|
||||||
### 2.2 `.textReflowable` 当前实现(已存在,且是正确切入点)
|
|
||||||
|
|
||||||
`Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift`:
|
|
||||||
|
|
||||||
- `publication.readingProfile == .textReflowable` 时:
|
|
||||||
- 使用 `RDEPUBTextBookBuilder(renderer: resolvedTextRenderer())`
|
|
||||||
- 默认 renderer 是 `RDEPUBDTCoreTextRenderer`(`#if canImport(DTCoreText)`)
|
|
||||||
- 分页使用 `NSAttributedString.ss_pageRanges(size:)`(`CTFramesetterCreateFrame` + `CTFrameGetVisibleStringRange`)
|
|
||||||
- UI 展示使用 `RDEPUBTextContentView`
|
|
||||||
|
|
||||||
结论:我们不需要“新起一个阅读器”,只要把 `.textReflowable` 的 **渲染(typesetter)层**与部分 **分页策略**升级即可。
|
|
||||||
|
|
||||||
## 3. WXRead 参考模型(我们要对齐的最小子集)
|
|
||||||
|
|
||||||
来自 `Doc/WXRead/analysis/EPUB渲染管线详解.md` 的管线:
|
|
||||||
|
|
||||||
1) `WREpubParser`:解析 EPUB 结构(spine、manifest、resourceMap)
|
|
||||||
2) `WREpubTypesetter`:XHTML → `NSAttributedString`(CSS 级联 + HTML 解析 + 后处理)
|
|
||||||
3) `WRCoreTextLayouter`:`NSAttributedString` → 分页布局(`CTTypesetter` + 分页算法)
|
|
||||||
4) `WRCoreTextLayoutFrame`:单页 layout frame
|
|
||||||
5) `WRPageView`:绘制到屏幕
|
|
||||||
|
|
||||||
本次设计对齐重点(最小集合):
|
|
||||||
|
|
||||||
- A:**CSS 分层与合成**(default/replace/dark/epub/user)并注入到渲染输入
|
|
||||||
- B:**资源解析**(图片/CSS 的相对路径 baseURL)与稳定性保障
|
|
||||||
- C:在现有分页基础上逐步迭代(先可用,后对齐“避免断页”等高级策略)
|
|
||||||
|
|
||||||
### 3.1 第一阶段实施假设(必须遵守)
|
|
||||||
|
|
||||||
- H1:**第一期只对齐“管线形态”和“CSS 分层策略”**,即把现有 `.textReflowable` renderer 增强为更接近 WXRead 的 typesetter 输入与样式组织方式。
|
|
||||||
- H2:**第一期不实现 WXRead 对 DTCoreText 的深度魔改**,包括但不限于自定义 CSS 属性体系、复杂附件布局规则、完整的 `WRCoreTextLayouter` / `WRCoreTextLayoutFrame` 等价分页器。
|
|
||||||
- H3:当开发过程中遇到图片断页、复杂样式缺失、附件布局异常等问题时,默认先作为“第二阶段问题清单”记录;只有在它阻塞 `REND-01` / `STAB-02` 的最小验收时,才允许做局部补丁,而不是扩展为全面重写分页引擎。
|
|
||||||
|
|
||||||
## 4. 是否能直接使用 Doc/WXRead 中的 JS/CSS?
|
|
||||||
|
|
||||||
结论:**不建议、也不应该直接把“来自微信读书 App bundle 的私有 JS/CSS”拷贝进 SDK 作为产品代码**;但可以按以下原则“选择性使用”:
|
|
||||||
|
|
||||||
### 4.1 可以使用的情况(需满足其一)
|
|
||||||
|
|
||||||
- 文件本身带有明确开源许可证声明,且我们按许可证要求引入(保留 license、署名、NOTICE 等),并建议从官方 upstream 获取:
|
|
||||||
- 例如 `Doc/WXRead/resources/js/rangy-core.js` 明确标注 MIT
|
|
||||||
- 例如 `Doc/WXRead/resources/js/Readability.js` 明确标注 Apache-2.0
|
|
||||||
|
|
||||||
> 建议:即便文件里有 license 头,也优先从其原始开源仓库拉取对应版本,而不是从逆向提取的副本直接入库,以降低合规风险。
|
|
||||||
|
|
||||||
### 4.2 不建议/不能直接使用的情况
|
|
||||||
|
|
||||||
- 无明确许可证头、看起来是微信读书私有逻辑/样式(例如 `weread-highlighter.js`、`MediaPlatform.js`、`replace.css` 等):默认视为私有作品,不应直接拷贝使用。
|
|
||||||
- 即便是“Safari 默认样式”类文件(例如 `default.css` 的注释提到 Safari),也不建议直接照搬;我们可以根据需求写一份“SDK 自己的 default.css / replace.css”,只实现必要规则。
|
|
||||||
|
|
||||||
### 4.3 对本次需求的实际影响
|
|
||||||
|
|
||||||
本次 reflowable EPUB 走原生渲染,不依赖 WebView,因此 **JS 不是本次必需**。
|
|
||||||
CSS 方面我们需要的是“分层策略”和一小部分通用排版规则,可在 SDK 内重写为“WXRead 风格的默认样式集合”。
|
|
||||||
|
|
||||||
## 5. 详细设计(核心)
|
|
||||||
|
|
||||||
### 5.1 总体架构:在现有 `.textReflowable` 上增量替换 renderer
|
|
||||||
|
|
||||||
新增一个 renderer(实现 `RDEPUBTextRenderer`):
|
|
||||||
|
|
||||||
- `RDEPUBWXReadTextRenderer`(新)
|
|
||||||
- 输入:`html: String`, `baseURL: URL?`, `style: RDEPUBTextRenderStyle`
|
|
||||||
- 输出:`RDEPUBRenderedChapterContent`(`NSAttributedString` + `fragmentOffsets`)
|
|
||||||
- 内部职责:
|
|
||||||
1) 读取/生成 CSS 各层(default/replace/dark/user)
|
|
||||||
2) 与 EPUB 自带 CSS 共同作用(通过 HTML 注入 + baseURL)
|
|
||||||
3) 调用 DTCoreText builder 生成 attributedString
|
|
||||||
4) 做最小后处理(段落间距/字体/颜色标准化、fragment marker 提取等)
|
|
||||||
|
|
||||||
切换点:
|
|
||||||
|
|
||||||
- 在 `RDEPUBReaderController.resolvedTextRenderer()`(或其配置位置)增加策略:当开关启用时选择 `RDEPUBWXReadTextRenderer()`,否则沿用 `RDEPUBDTCoreTextRenderer()`。
|
|
||||||
- 建议默认先提供“实验开关”(仅 Demo / debug 可见),降低回归风险。
|
|
||||||
|
|
||||||
### 5.2 CSS 分层策略(WXRead 风格)
|
|
||||||
|
|
||||||
我们在 SDK 内实现与 `Doc/WXRead/analysis/EPUB渲染管线详解.md` 一致的分层概念,但不直接照搬其私有样式文件:
|
|
||||||
|
|
||||||
- Layer 1:`default.css`(SDK 自己维护的基础排版规则)
|
|
||||||
- Layer 2:`replace.css`(SDK 自己维护的替换/增强规则:标题、代码块、图片最大宽度等)
|
|
||||||
- Layer 3:`dark.css`(暗色主题覆盖,仅在暗色主题启用)
|
|
||||||
- Layer 4:EPUB 嵌入 CSS(书籍自带,DTCoreText 解析 HTML 时自然生效;相对路径靠 baseURL)
|
|
||||||
- Layer 5:用户设置 CSS(由 `RDEPUBTextRenderStyle` 动态生成:字体、字号、行高、背景色、文字色等)
|
|
||||||
|
|
||||||
实现方式(建议):
|
|
||||||
|
|
||||||
1) 新增 `RDEPUBWXReadStyleSheetBuilder`:
|
|
||||||
- `func makeDefaultCSS() -> String`
|
|
||||||
- `func makeReplaceCSS() -> String`
|
|
||||||
- `func makeDarkCSS(theme: RDEPUBTheme) -> String?`
|
|
||||||
- `func makeUserCSS(style: RDEPUBTextRenderStyle, theme: RDEPUBTheme) -> String`
|
|
||||||
- `func composeCSS(...) -> String`(按层拼接,后层覆盖前层)
|
|
||||||
|
|
||||||
2) 在 renderer 中将合成后的 CSS 注入到 HTML:
|
|
||||||
- 若存在 `<head>`:插入 `<style id="rd-wxread-layered-style">...`
|
|
||||||
- 若不存在:在 `<html>` 后插入 `<head>...`
|
|
||||||
- 保持原 HTML 内容尽量不改动(外部脚本/交互内容已被 readingProfile 判定剔除到 web 分支)
|
|
||||||
|
|
||||||
### 5.3 baseURL 与资源解析
|
|
||||||
|
|
||||||
目前 `RDEPUBTextBookBuilder` 传入:
|
|
||||||
|
|
||||||
- `baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent()`
|
|
||||||
|
|
||||||
原则:
|
|
||||||
|
|
||||||
- baseURL 必须是“章节文件所在目录”,以确保:
|
|
||||||
- `<img src="...">` 相对路径可解析
|
|
||||||
- `<link href="...">` CSS 相对路径可解析(如果 DTCoreText 支持)
|
|
||||||
|
|
||||||
待核验点(实现时做小实验):
|
|
||||||
|
|
||||||
- DTCoreText 对 `<link rel="stylesheet">` 的解析策略是否完整;若不完整:
|
|
||||||
- 兜底策略:在渲染前解析 HTML 中的 `<link rel="stylesheet">`,读取 CSS 内容并内联到 `<style>`(仅限 `file://` 且位于 EPUB 解压目录内)。
|
|
||||||
|
|
||||||
### 5.4 分页策略(阶段性)
|
|
||||||
|
|
||||||
现状:
|
|
||||||
|
|
||||||
- `NSAttributedString.ss_pageRanges(size:)` 使用 `CTFramesetterCreateFrame` + `CTFrameGetVisibleStringRange`,属于“最小可用分页”。
|
|
||||||
|
|
||||||
WXRead 的更高阶策略(参考 `Doc/WXRead/analysis/DTCoreText自定义修改分析.md`)可能包含:
|
|
||||||
|
|
||||||
- 避免孤行/断页
|
|
||||||
- 图片/附件的分页边界处理
|
|
||||||
- 特定块元素的分页规则
|
|
||||||
|
|
||||||
本次建议:
|
|
||||||
|
|
||||||
- Phase 2:先保持现有分页算法,只要渲染输入(CSS 分层 + 后处理)到位,就能显著改善一致性。
|
|
||||||
- Phase 3:针对真实书籍出现的问题,逐条补齐分页规则(问题驱动),避免一开始就引入复杂分页器导致风险扩大。
|
|
||||||
|
|
||||||
> 范围约束:如果某个分页问题需要引入“新的复杂分页器”或大规模模拟 `WRCoreTextLayouter` / `WRCoreTextLayoutFrame`,应先暂停并回到方案讨论,不默认并入第一期实现。
|
|
||||||
|
|
||||||
### 5.5 与现有高亮/搜索/位置映射的兼容
|
|
||||||
|
|
||||||
当前 `.textReflowable` 路径:
|
|
||||||
|
|
||||||
- 高亮/搜索依赖 `RDEPUBTextBook` 的 `fragmentOffsets` 与 `location/progression` 映射(见 `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift` 与 `RDEPUBTextBook` 的 `pageNumber(for:)` / `location(forPageNumber:)`)。
|
|
||||||
|
|
||||||
兼容策略:
|
|
||||||
|
|
||||||
- 继续使用现有的 fragment marker 注入与提取:
|
|
||||||
- `RDEPUBTextRendererSupport.injectFragmentMarkers(...)`
|
|
||||||
- `RDEPUBTextRendererSupport.extractFragmentOffsets(...)`
|
|
||||||
- renderer 只改变“CSS 注入与 DTCoreText options/后处理”,不改变 marker 体系与 `RDEPUBTextBook` 数据结构,以降低 UI 层回归。
|
|
||||||
|
|
||||||
## 6. 开发落点(文件 / 类型 / 目录)
|
|
||||||
|
|
||||||
### 6.1 新增文件(建议位置)
|
|
||||||
|
|
||||||
放在 `Sources/RDReaderView/EPUBTextRendering/`(因为它是 textReflowable 的渲染与分页域):
|
|
||||||
|
|
||||||
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBWXReadTextRenderer.swift`(新)
|
|
||||||
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBWXReadStyleSheetBuilder.swift`(新)
|
|
||||||
- (可选)`Sources/RDReaderView/EPUBTextRendering/RDEPUBWXReadHTMLPreprocessor.swift`(新:仅当需要内联 `<link>` CSS 时)
|
|
||||||
|
|
||||||
资源文件(建议):
|
|
||||||
|
|
||||||
- `Sources/RDReaderView/Resources/WXRead/default.css`(新,SDK 自己写)
|
|
||||||
- `Sources/RDReaderView/Resources/WXRead/replace.css`(新,SDK 自己写)
|
|
||||||
- `Sources/RDReaderView/Resources/WXRead/dark.css`(新,SDK 自己写)
|
|
||||||
|
|
||||||
> 注意:这些资源需被 `RDReaderView.podspec` 的 resource bundle 覆盖到(当前资源 bundle 为 `RDReaderViewAssets`,来源 `Sources/RDReaderView/Resources/**`)。
|
|
||||||
|
|
||||||
### 6.2 改动文件(建议最小改动)
|
|
||||||
|
|
||||||
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift`
|
|
||||||
- 在 `resolvedTextRenderer()` 或相邻配置处增加选择逻辑(开关 / 版本策略)
|
|
||||||
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift`
|
|
||||||
- 如需内联 link CSS:在获取 `rawHTML` 后做预处理(保持接口不变)
|
|
||||||
|
|
||||||
## 7. 验收标准与验证方式(对应 REQUIREMENTS)
|
|
||||||
|
|
||||||
### 对应 REND-01 / REND-03
|
|
||||||
|
|
||||||
- 在 Demo 中打开 reflowable EPUB:
|
|
||||||
- 正文渲染不依赖 `WKWebView`(可通过日志/断点确认不走 `RDEPUBWebContentView`)
|
|
||||||
- CSS 分层生效:默认样式可控、主题/字号/行高变化可控
|
|
||||||
- 图片/链接至少可正确显示/响应(链接行为按现有 text 内容策略)
|
|
||||||
|
|
||||||
### 对应 REND-02
|
|
||||||
|
|
||||||
- Fixed Layout EPUB:仍走 `.webFixedLayout` → `WKWebView` 路径
|
|
||||||
- 交互式 EPUB:仍走 `.webInteractive` → `WKWebView` 路径(桥接与外链不回归)
|
|
||||||
|
|
||||||
### 对应 STAB-01 / STAB-02
|
|
||||||
|
|
||||||
- `RDURLReaderController` 打开 `.epub` / `.txt` 主流程不回归
|
|
||||||
- 至少使用以下 3 类 reflowable EPUB 样本进行回归:
|
|
||||||
- 样本 A:纯文本/小说类章节为主,验证基础段落、标题、分页与阅读位置恢复
|
|
||||||
- 样本 B:包含内嵌图片与多段样式的章节,验证图片显示、图片前后分页、基础 CSS 生效
|
|
||||||
- 样本 C:包含外链与多个 CSS 文件引用的章节,验证 baseURL、样式解析与链接呈现稳定性
|
|
||||||
- 对以上样本的共同要求:不崩溃、不白屏、不无限加载;分页/翻页可用
|
|
||||||
|
|
||||||
## 8. 风险清单与降级策略
|
|
||||||
|
|
||||||
### 8.1 主要风险
|
|
||||||
|
|
||||||
- R1:DTCoreText 对 EPUB 内嵌 CSS / `<link>` CSS 支持不足,导致样式缺失
|
|
||||||
- R2:分页质量不足(断页不美观、图片分页异常)
|
|
||||||
- R3:`hasInteractiveContent()` 判定过宽,导致大量书被误判为 `.webInteractive`,覆盖率不足
|
|
||||||
|
|
||||||
### 8.2 降级/灰度(建议)
|
|
||||||
|
|
||||||
- D1:增加一个“渲染引擎开关”(仅 debug 或 demo 可配置),可在出现严重问题时快速回退到现有 `RDEPUBDTCoreTextRenderer`
|
|
||||||
- D2:对 `hasInteractiveContent()` 的判定提供可配置白名单/黑名单(例如按 manifest properties、按 tag 命中级别)
|
|
||||||
|
|
||||||
## 9. 下一步(交接到开发)
|
|
||||||
|
|
||||||
建议按 `.planning/ROADMAP.md` 从 Phase 1 开始推进:
|
|
||||||
|
|
||||||
- 先基于 `Doc/WXRead/analysis/*` 提炼“我们要实现的 CSS 分层最小集合”
|
|
||||||
- 再在 `.textReflowable` renderer 里实现“分层 CSS 注入 + baseURL/资源解析兜底”
|
|
||||||
- 最后用 Demo 书籍做回归,按问题驱动补齐分页/样式细节
|
|
||||||
|
|
||||||
---
|
|
||||||
*Last updated: 2026-05-21 after discuss-feature-solution*
|
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
# 长章节内存优化 — 代码实施清单
|
||||||
|
|
||||||
|
> 背景:DTCoreText 文本页已采用"整章上下文布局",分页与显示边界一致(不可回退)。现状代价:每次翻页/预加载都整章重拷贝 + 重排版后丢弃,且章节缓存里每页常驻一份子串副本。
|
||||||
|
>
|
||||||
|
> 总原则:保留"整章参与排版",去掉"整章按页复制、按页可变、按页缓存"。不改分页器,不上窗口化。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P0:立即可做
|
||||||
|
|
||||||
|
### P0-1 削减每页 `content` 子串常驻副本
|
||||||
|
|
||||||
|
**现状**:分页产物为每页保存整段 `attributedSubstring`,全章加总约等于又一份整章副本,随邻接窗口(radius 1,共 3 章)常驻。
|
||||||
|
|
||||||
|
生成点:
|
||||||
|
|
||||||
|
- `Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLoader.swift:494`(`buildPagesFromRanges`)
|
||||||
|
- `Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookBuilder.swift:294`
|
||||||
|
- `Sources/RDEpubReaderView/EPUBTextRendering/RDEpubPlainTextBookBuilder.swift:78`
|
||||||
|
|
||||||
|
现存全部消费点(已盘点确认,仅 3 处):
|
||||||
|
|
||||||
|
- `RDEPUBTextContentView.swift:562` — 封面检测 `coverImage(from: page.content)`,仅 `pageIndexInChapter == 0` 且 href 含 "cover"
|
||||||
|
- `RDEPUBTextContentView.swift:641` — `normalizedPageContent`,仅非 DTCoreText 的 `#else` 回退路径调用
|
||||||
|
- `RDEPUBTextBookBuilder.swift:93` — 构建期 debug 日志 preview
|
||||||
|
|
||||||
|
**任务**:
|
||||||
|
|
||||||
|
- [x] 将 `RDEPUBTextPage.content` 改为按需构造:已改为基于 `chapterContent` + `contentRange` 的计算属性(带范围钳制),公开读取 API 不变
|
||||||
|
- [x] 三个构造点(`RDEPUBChapterLoader` / `RDEPUBTextBookBuilder` / `RDEpubPlainTextBookBuilder`)不再生成每页子串;消费点经计算属性透明按需构造
|
||||||
|
- [x] `Equatable` 确认:`content` 为派生值,由 `contentRange` + `chapterContent` 判等覆盖,语义不变
|
||||||
|
|
||||||
|
**API 兼容性注意**:
|
||||||
|
|
||||||
|
- `RDEPUBTextPage` 当前是 `public struct`,`content` 也是公开存储属性;如果 SDK 已存在外部接入方直接读取 `page.content`,则不应直接移除该字段
|
||||||
|
- 优先方案是保留 `content` 对外访问语义,但将其改为按需构造的计算属性或受控访问器,避免对外 API 断裂
|
||||||
|
- 若确认当前版本尚无稳定外部依赖,才可以评估把 `content` 从存储属性调整为内部实现细节
|
||||||
|
- 在实施前应补做一次全仓库与接入侧检索,确认是否有外部调用依赖 `page.content` 的“可变存储”特性
|
||||||
|
|
||||||
|
**验收**:打开 3 章窗口后常驻内存下降约"一整章文本规模 × 窗口章数";封面页显示正常;非 DTCoreText 回退路径回归通过;`--demo-pagination-validate` 命中数不回升。
|
||||||
|
|
||||||
|
### P0-2 增加内存打点
|
||||||
|
|
||||||
|
- [x] 已新增 `RDEPUBMemoryProbe`(`--demo-memory-probe` 启动参数开启,输出 phys_footprint);挂载点:`RDEPUBChapterRuntimeStore.insertChapter`、`pageNum` 回调每 20 次翻页、`invalidateAllForSettingsChange`、`viewWillTransition` 转屏完成
|
||||||
|
- [x] 用 `--demo-memory-probe` 运行 Demo 即可采集对比基线
|
||||||
|
|
||||||
|
### P0-3 保持现有缓存限制,防止错误复用
|
||||||
|
|
||||||
|
- [x] `shouldAvoidReaderPageCaching`(`RDEPUBTextContentView.swift:319`)保持现状,已补注释说明放开的前置条件是 P1-1 落地
|
||||||
|
- [x] 本阶段未改 `RDEpubReaderPreloadController` 行为
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P1:第二波(P1-1 与 P1-2 必须同批落地)
|
||||||
|
|
||||||
|
### P1-1 章节级共享 displayContent
|
||||||
|
|
||||||
|
**现状**:`RDEPUBTextContentView.configure` 每次创建整章可变副本(`RDEPUBTextContentView.swift:443`)并按页写入三类属性:页范围主题色(453)、页范围暗黑图附件替换(448)、按页裁剪的高亮属性(459 `applyHighlightsToContent`)。
|
||||||
|
|
||||||
|
**约束**:共享字符串一旦被任一页的 `DTCoreTextLayouter`/framesetter 持有,就不得再修改——所有属性必须在共享内容构建时一次性注入,或转为 overlay(见 P1-2)。
|
||||||
|
|
||||||
|
**任务**:
|
||||||
|
|
||||||
|
- [x] 已新建 `RDEPUBChapterDisplayContentCache`(LRU 容量 2,主线程限定,`RDEPUBReaderController` 持有):`content` + `layouter` + `signature`
|
||||||
|
- [x] 构建共享内容时一次性注入:全章范围主题色;高亮/下划线因 P1-2 转 overlay,不进共享内容也不进签名
|
||||||
|
- [x] 暗黑图策略选 a:构建时全章一次性替换,复用 `RDEPUBDarkImageAdjuster` 的 NSCache
|
||||||
|
- [x] `RDEPUBTextContentView.configure` 已改为接收 `displayCache` 参数并引用共享 entry,整章拷贝已删除
|
||||||
|
- [x] 签名 = 章节内容对象标识 + 长度 + 主题双色 + 暗黑图配置;设置/主题变化经签名自动失效重建(高亮变化不触发重建,仅重建 overlay 装饰)
|
||||||
|
- [x] 控制器在 `didReceiveMemoryWarning` 清空 display cache;设置变更重建 chapterContent 实例后由签名兜底换代
|
||||||
|
|
||||||
|
**验收**:连续翻页不再出现整章拷贝(打点确认);翻页延迟下降;高亮显示、高亮点击菜单、underline、搜索 currentMatch、附件点击、选区拖拽全部回归通过。
|
||||||
|
|
||||||
|
> 2026-07-08 回归记录:高亮/批注/选区/翻页/设置 13 项 UI 用例全过;SearchTests 10 项失败,但已在会话前提交 5a41066 与 P0 提交 e4e629a 上复现同样失败(搜索 0 命中),确认为先于本优化存在的独立回归(最后一次已知通过是 2026-06-08 全量跑),需单独排查,与 P1 改动无关。
|
||||||
|
|
||||||
|
### P1-2 高亮/下划线剥离为 overlay(与 P1-1 绑定)
|
||||||
|
|
||||||
|
**现状**:搜索高亮已走 overlay(`RDEPUBTextContentView.swift:759` 处 `buildDecorations` 传 `highlights: []`,注释明确为避免双画);普通高亮/下划线仍靠写 `kRDEPUBHighlightAttributeName` / `kRDEPUBUnderlineAttributeName` 属性由 render view 绘制。
|
||||||
|
|
||||||
|
**任务**:
|
||||||
|
|
||||||
|
- [x] `buildDecorations` 已传入真实 highlights,高亮进背景层(文字下方)、下划线进前景层
|
||||||
|
- [x] `applyHighlightsToContent` 与 render view 的 `drawHighlights`/`computeHighlightRects`/`attributedDisplayContent` 已整体移除,无双画
|
||||||
|
- [x] 高亮命中与菜单锚点继续走 `interactionController.selectionRects`,坐标保持 chapter-absolute(未改动)
|
||||||
|
|
||||||
|
**验收**:高亮/下划线视觉与改前一致(含跨页高亮的页内裁剪);高亮点击弹菜单正常;与搜索高亮叠加时无双画。
|
||||||
|
|
||||||
|
### P1-3 章节级共享 display layouter
|
||||||
|
|
||||||
|
**现状**:每个页面视图各建一个 `DTCoreTextLayouter`(`RDEPUBTextContentView.swift:465`);章节运行时已有分页用 `RDEPUBTextLayouter`(`RDEPUBRuntimeChapter.layouter`),display layouter 向同一模式靠拢。
|
||||||
|
|
||||||
|
**任务**:
|
||||||
|
|
||||||
|
- [x] `DTCoreTextLayouter` 已随共享内容放入 display cache,与 signature 同生命周期
|
||||||
|
- [x] 页面按 `bounds` + `page.contentRange` 取 `layoutFrame`;`shouldCacheLayoutFrames = false` 保持,layoutFrame 按页新建(VerticalJustifier 的修改不会污染共享对象)
|
||||||
|
- [x] framesetter 只依赖字符串、与 bounds 无关,bounds 变化无需失效共享对象;layoutFrame 随页面视图释放
|
||||||
|
- [x] `RDEPUBTextPageBoundaryValidator` 校验路径不变
|
||||||
|
|
||||||
|
**验收**:翻页时不再重建 framesetter(打点确认);横竖屏切换后布局正确;`--demo-pagination-validate` 不回升。
|
||||||
|
|
||||||
|
### P1-4 评估重新放开文本页预加载缓存
|
||||||
|
|
||||||
|
前置:P1-1~P1-3 全部落地后,页面视图不再持有整章副本。
|
||||||
|
|
||||||
|
- [ ] 调整 `shouldAvoidReaderPageCaching` 判定,允许轻量化后的文本页进入 `RDEpubReaderPreloadController` 缓存
|
||||||
|
- [ ] 对比放开前后的翻页流畅度与峰值内存,数据不佳则回退此项
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P2:视 P0/P1 打点数据决定
|
||||||
|
|
||||||
|
### P2-1 章节级 LRU 分层淘汰
|
||||||
|
|
||||||
|
**现状**:`RDEPUBChapterRuntimeStore` 已有邻接窗口(`windowSpineIndices`,radius 1)、`evictableSpineIndices` / `evict` / `evictAllExceptCurrent` / `handleMemoryWarning` / `invalidateAllForSettingsChange`;磁盘侧有 `RDEPUBChapterSummaryDiskCache`(分页结果 + cfiMap + metadata)。当前邻接章节仍持有完整 `RDEPUBRuntimeChapter`(typesetString + pages + layouter)。
|
||||||
|
|
||||||
|
**任务**:
|
||||||
|
|
||||||
|
- [ ] 邻接章节降级:保留 page map / chapter summary / 分页结果,释放 `typesetAttributedString`、display content、layouter
|
||||||
|
- [ ] 进入邻接章节时按 summary 重建,测量重建延迟;延迟不可接受则维持现状
|
||||||
|
- [ ] 远距章节仅保留磁盘缓存 + 轻量 metadata(现有 evict 已覆盖,确认即可)
|
||||||
|
|
||||||
|
### P2-2 图片附件深化
|
||||||
|
|
||||||
|
**现状**:暗黑图已走 `NSCache`(50 MB / 100 张,`RDEPUBDarkImageAdjuster.swift:9`)且仅处理当前页范围;章节运行时另有 100 MB `imageCache`。
|
||||||
|
|
||||||
|
**任务**:
|
||||||
|
|
||||||
|
- [ ] 大图按显示尺寸下采样后再参与 attachment
|
||||||
|
- [ ] 页面离屏后释放 attachment 强引用的已解码大图;避免共享 content 长期持有
|
||||||
|
- [ ] 回归:图片点击查看、暗黑模式切换、附件位置与命中
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P3:仅在 P0–P2 之后数据仍不达标时
|
||||||
|
|
||||||
|
- [ ] 上下文窗口化布局(当前页所在段落块 + 前后足以影响断行的文本窗口)。风险最高:易重新引入分页/显示边界不一致(CTTypesetter 断行上下文敏感,为已知已解 bug 的根因),选区/搜索/高亮/附件索引需全部重验。默认不做。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 明确不做
|
||||||
|
|
||||||
|
1. 回退"页内子串重新布局"(重新引入显示不全/页末孤字)
|
||||||
|
2. 取消 chapter-absolute 索引(破坏点击/选区/高亮定位一致性)
|
||||||
|
3. 先改分页器(正确性与性能问题混杂,无法回归)
|
||||||
|
4. 未落地 P1 前放开文本页 reader 级缓存(会驻留多份整章副本)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 各阶段统一验收指标
|
||||||
|
|
||||||
|
**正确性**:`--demo-pagination-validate` 命中数不回升;长章节页末无孤字/缺字;高亮显示、搜索高亮、附件点击、选区拖拽行为一致。
|
||||||
|
|
||||||
|
**内存**(对比 P0-2 基线):打开长章节后常驻;连续翻页 20 页峰值;设置变更前后峰值;横竖屏切换峰值。
|
||||||
|
|
||||||
|
**性能**:首次打开章节耗时;连续翻页帧稳定性;设置切换恢复时间。
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# 分页问题调查记录(2026-07-06)
|
||||||
|
|
||||||
|
调查载体:《宝山辽墓材料与释读》(textReflowable / DTCoreText 路径),iPhone 15 Pro Max 尺寸(430×932,内容区 398×815pt)。当日共调查两个独立问题:
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题一:页底留空一行多,下一页首行未上移(已修复)
|
||||||
|
|
||||||
|
### 现象
|
||||||
|
|
||||||
|
第 6 页底部留有约 1.7 行高的空白,第 7 页首行"人目为帝羓,信有之也。[注]"本可容纳在第 6 页。截图实测:行距 75px@3x(25pt),第 6 页底部空隙 138px,足够再排一行。
|
||||||
|
|
||||||
|
### 根因链
|
||||||
|
|
||||||
|
1. `RDEPUBSemanticMarkerInjector.inferredHints` 给**所有** `<img>` 打上 `avoidPageBreakInside` 提示——包括行内脚注小图标(`<img class="qqreader-footnote">`,即"注"字图标)。
|
||||||
|
2. `RDEPUBPageBreakPolicy.shouldTreatAvoidHintAsBlockProtection` 本有豁免:`blockKind == .attachment && placement != .centered → 不保护`。但 `applyPaginationSemantics` 按**结束标记的字符串顺序**应用属性:`<img>` 的结束标记先于外层 `<p>`,段落随后把图标位置的 `.rdPageBlockKind` 从 `attachment` 覆盖为 `paragraph`;而 `hints` 与 `placement` 因段落不携带这两个属性而幸存。豁免条件因此失效。
|
||||||
|
3. `trimmedRangeForAvoidPageBreakInside` 把含注图标的行当作"不可分页块"的行,从页底裁掉(最多 3 行),推到下一页,留下空白。
|
||||||
|
|
||||||
|
### 验证手段
|
||||||
|
|
||||||
|
- Demo 启动参数 `--demo-pagination-debug` 输出 `[PAGINATION-DEBUG] avoidPageBreakInside removed N lines: …`;修复前被裁的行**全部**含 ``(脚注图标)。
|
||||||
|
- 复现命令:`--demo-book-title 宝山 --demo-page 6 --demo-reset-state --demo-clear-cache --demo-pagination-debug`。
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
`RDEPUBPageBreakPolicy.swift`:豁免条件放宽为 `(blockKind == .attachment || placement != nil) && placement != .centered`——不再依赖易被覆盖的 blockKind,只要附件 placement 是行内/基线(非居中)就不锁行。居中插图(bodyPic)的整块保护不受影响;同时覆盖 `s-pic`/`h-pic`/`g-pic` 等生僻字行内小图。
|
||||||
|
|
||||||
|
修复后验证:含 `` 的裁剪从多处降为 0;"人目为帝羓"行回到第 6 页且下一段首行补齐;全书页数 280 → 273(消除欠填页)。
|
||||||
|
|
||||||
|
> 备选方案(未采用):修 `applyPaginationSemantics` 的嵌套覆盖顺序(内层优先)。会改变列表/表格的 blockRange 语义,风险大。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题二:页范围与显示内容度量错配,行中断页(2026-07-07 已定位根因,见文末新增章节)
|
||||||
|
|
||||||
|
### 现象
|
||||||
|
|
||||||
|
第 10 页最后一行只有孤字"相",第 11 页从"对简化,且无构造细致的翼墙。"开始——断点落在句子中间而非行边界。
|
||||||
|
|
||||||
|
### 铁证推理
|
||||||
|
|
||||||
|
当前设置下满行 26 字(字号 15、内容宽 398pt)。第 10 页范围要在"…而2号墓的门楼则相"后断开,要求该行装下 **27 字**——当前排版下不可能。**结论:页范围产生于另一套度量(更小字号或更宽版面),与显示时的排版不一致。** 同一套排版中分页断点必然落在行边界,孤字不可能出现。
|
||||||
|
|
||||||
|
### 已排除(受控实验)
|
||||||
|
|
||||||
|
在 `RDEPUBChapterLoader` 临时加 `--demo-chapter-dump` 转储(typesetString 逐属性段 + 页范围,写入 app Documents,实验后已还原):
|
||||||
|
|
||||||
|
- 冷启动(`MISS(fullRender)` 全量渲染分页)与热启动(`HIT(diskSummary)` 复用磁盘页范围)两条路径的字符串与页范围**完全一致**。字体压平、双重 `normalizeReadingAttributes`、语言码缺失(`makeChapterRenderRequest` 未传 `contentLanguageCode`)、`TailNormalizer` 尾页合并均验证为无影响或幂等。
|
||||||
|
- 持久缓存键控正确:`RDEPUBChapterCacheKey.renderSignature` 含字体名/字号/行距倍数/lineSpacing/`layoutConfig.cacheSignature`(宽高、四边距、分栏、hyphenation、schemaVersion=17)+ 章节内容哈希。同设置跨启动复用安全。
|
||||||
|
|
||||||
|
### 主要嫌疑:会话中改设置的换表过渡态
|
||||||
|
|
||||||
|
- 用户两组截图之间调过行距(行距 25pt/页 32 行 → 28pt/页 28 行,字号未变);出现问题时显示总页数 196,既非 1.6 档全量分页(~273)也非 1.8 档(~290)——当时显示的是**未收敛的中间页表**。
|
||||||
|
- 代码窗口:
|
||||||
|
- `RDEPUBReaderRuntime.scheduleSettingsPreviewRepagination`:设置面板打开期间只重排**当前章**并 `applySettingsPreviewPageMap` 换部分页表,全量重排推迟到面板关闭(`needsFullRepaginationAfterSettingsClose`)。
|
||||||
|
- `RDEPUBChapterRuntimeStore.chapterDataCache` 仅按 spineIndex 键控(无样式维度);`invalidateAllForSettingsChange` 清空后,**变更前已在飞行中的构建**完成时会把旧样式章节重新 `insertChapter` 插回。
|
||||||
|
- 这些窗口里可能出现"旧页范围 + 新排版内容"的错配。
|
||||||
|
|
||||||
|
### 待办 / 复现所需
|
||||||
|
|
||||||
|
1. 确认触发操作:是否在设置面板调整行距/字号后(未关面板或刚关)翻页出现。
|
||||||
|
2. 确认重启 app 后是否自愈(受控实验表明缓存路径本身一致,理应自愈;若不自愈则有持久化的脏状态)。
|
||||||
|
3. 修复方向(待复现后定):设置变更代(generation)标记贯穿构建流程,飞行中的旧代构建完成后丢弃、不得插回 store;或 `chapterDataCache` 键加入 renderSignature。
|
||||||
|
|
||||||
|
### 经验教训
|
||||||
|
|
||||||
|
- 跨启动比较"第 N 页"截图无效:章节渐进加载过程中全局页码会漂移。
|
||||||
|
- 判断断点是否合法的快捷方法:数满行字数。断点不在行边界 ⟺ 范围与显示度量不一致。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 相关工具与命令备忘
|
||||||
|
|
||||||
|
| 用途 | 方法 |
|
||||||
|
|---|---|
|
||||||
|
| 分页裁剪日志 | 启动参数 `--demo-pagination-debug` |
|
||||||
|
| 自动打开书籍/翻页 | `--demo-book-title 宝山 --demo-page N --demo-clear-cache --demo-reset-state` |
|
||||||
|
| 注入阅读设置 | `simctl spawn <sim> defaults write cn.shen.ReadViewDemo ssreader.epub.settings -data <hex(JSON)>`,JSON 形如 `{"fontSize":15,"lineHeightMultiple":1.8}` |
|
||||||
|
| 截屏 | `xcrun simctl io <sim> screenshot <绝对路径>`(相对路径会因只读文件系统失败) |
|
||||||
|
| 模拟器 | 预装仅 16/17 系列;`simctl create` 可建 iPhone 15 Pro Max(430×932@3x 与问题截图同尺寸) |
|
||||||
|
| 页边界/换行一致性校验 | 启动参数 `--demo-pagination-validate`(RDEPUBTextPageBoundaryValidator,逐页比对显示换行与全章上下文换行,输出 STALE-RANGE / DISPLAY-DIVERGE / DIAGNOSE / ATTR-DIFF) |
|
||||||
|
| 设置面板自动翻转 | 启动参数 `--demo-settings-flip`(RDEPUBSettingsFlipAutomation,自动开面板→改行距→关面板→翻页,三种时序) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题二根因(2026-07-07 续查确认)
|
||||||
|
|
||||||
|
### 结论
|
||||||
|
|
||||||
|
**分页与显示使用同一引擎(DTCoreText/CoreText)但输入字符串不同:分页在“全章字符串”上下文中断行;显示把页内容取成子串后重新断行。`CTTypesetterSuggestLineBreak` 在同一文字、同一属性、同一宽度下,会因字符串上下文不同而在 CJK 标点压缩/悬挂处产生 ±1 字的断点漂移。** 页范围(全章上下文产物)与显示换行(子串产物)在页内任何一行发生漂移,累积到页尾就出现孤字(用户看到的「相」)或半空行。
|
||||||
|
|
||||||
|
与缓存、设置换表竞态无关:设置变更只是**搬动了页边界位置**,让某个边界恰好落在漂移点上,症状因此看似随设置变化出现/消失。这同时解释了此前所有观察:冷/热启动转储完全一致(分页自身是一致的,错配发生在分页 vs 显示);重启后"第 N 页"不复现(边界挪走了)。
|
||||||
|
|
||||||
|
### 证据(宝山书 spine 3,字号 15 / 行距 1.6 / 宽 398pt)
|
||||||
|
|
||||||
|
`--demo-pagination-validate` 基线运行(无任何设置操作)即命中,前 8 页中 5 页分叉。决定性样本 page 8/55 第 11 行(行首 offset 4224):
|
||||||
|
|
||||||
|
- 显示端断点 4252(行尾"…可以相"——孤字机制当场复现),全章上下文断点 4253("…可以相当")
|
||||||
|
- 逐字符属性 diff:**完全一致**(无 ATTR-DIFF)
|
||||||
|
- 对照排版:原始子串 = 4252,段落级上下文 = 4252,**只有全章上下文 = 4253**
|
||||||
|
- page 6/55 的上下文探针行宽 408.39 > 框宽 398 —— 标点悬挂/压缩越界的直接证据
|
||||||
|
|
||||||
|
即断点差异既不是首行缩进归一化(`normalizedPageContent` 的 firstLineHeadIndent 处理本身是正确的),也不是属性差异,而是 CoreText 断行对字符串上下文(跨段落!)敏感。
|
||||||
|
|
||||||
|
### 修复(方案 A,2026-07-07 已实施)
|
||||||
|
|
||||||
|
显示端改为**全章上下文排版**:`RDEPUBTextContentView.configure` 取 `page.chapterContent` 的整章副本作为显示内容,`DTCoreTextLayouter(章节副本).layoutFrame(bounds, range: page.contentRange)` 只排当页范围。断行与分页器按构造一致(同串、同起点、同宽)。
|
||||||
|
|
||||||
|
配套改动:
|
||||||
|
|
||||||
|
- layoutFrame 的 stringRange 因此为**章节绝对坐标**。`RDEPUBPageLayoutSnapshot.build` 去掉 +pageStartOffset 归一(本就输出绝对坐标给消费方),`RDEPUBPageInteractionController` 去掉全部 -pageOffset 反换算;选区/高亮/点击测试/装饰层的消费接口本来就是绝对坐标,无需变动。
|
||||||
|
- 主题前景色、暗色图片调整(`RDEPUBDarkImageAdjuster` 新增 `in range:` 参数)、高亮标记只施加在章节副本的当页范围上;`applyHighlightsToContent` 改用绝对 overlap 范围。
|
||||||
|
- 删除 DTCoreText 路径对 `normalizedPageContent` 的调用——续段首行缩进/段前距归一化 hack 由上下文排版天然取代(非 DTCoreText 回退路径仍保留)。
|
||||||
|
- layouter(framesetter)随显示内容缓存于 cell(`coreTextLayouter`),bounds 变化只重建 layoutFrame,避免每次 layout pass 对整章重建 framesetter。
|
||||||
|
- `RDEPUBTextPageBoundaryValidator` 适配绝对坐标,保留为回归警报。
|
||||||
|
|
||||||
|
验证:`--demo-pagination-validate` 基线(原先 spine 3 前 8 页 5 处分叉)修复后 **0 命中**;第 10 页首行断点与分页一致(「…使用了更多」);Selection/Annotation UI 测试回归通过(见下)。
|
||||||
|
|
||||||
|
### 遗留(独立的潜在缺陷,本次未触发)
|
||||||
|
|
||||||
|
- `RDEPUBChapterRuntimeStore.chapterDataCache` 仅按 spineIndex 键控;设置变更时飞行中的旧构建完成后仍会插回(`RDEPUBChapterLoader.loadChapter` 的 `insertChapter` 无代际校验)。
|
||||||
|
- `RDEPUBChapterLoader.pendingLoads` 按 spineIndex 合流,跨设置变更合流会把旧样式章节交给新请求。
|
||||||
|
- 建议修复问题二后用 `--demo-settings-flip` + `--demo-pagination-validate` 回归验证这两个窗口。
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
# RDReaderView 功能实现逻辑
|
|
||||||
|
|
||||||
## 1. 范围与目标
|
|
||||||
|
|
||||||
- 代码范围:`Sources/RDReaderView/` 根目录(6 个 Swift 文件)
|
|
||||||
- 目标:说明分页阅读器容器如何管理四种显示模式、DataSource/Delegate 协议、翻页交互、工具栏动画、双屏适配和 RTL 支持。
|
|
||||||
- 主链路关键词:`RDReaderDataSource -> reloadData -> DisplayType 切换 -> 翻页/滚动 -> RDReaderDelegate.pageNum -> 工具栏显隐`。
|
|
||||||
|
|
||||||
## 2. 关键对象职责
|
|
||||||
|
|
||||||
### 2.1 核心容器 `RDReaderView`
|
|
||||||
|
|
||||||
- 文件:`Sources/RDReaderView/RDReaderView.swift`(~717 行)
|
|
||||||
- 入口方法:`reloadData()`
|
|
||||||
- 职责:
|
|
||||||
- 管理四种显示模式的视图层级切换
|
|
||||||
- 持有 `UIPageViewController`(pageCurl 模式)或 `UICollectionView`(滚动模式)
|
|
||||||
- 处理点击手势(左/中/右三区域)
|
|
||||||
- 管理工具栏(topToolView / bottomToolView)的显示/隐藏动画
|
|
||||||
- 检测横竖屏变化并触发重新布局
|
|
||||||
- 管理双屏配对逻辑(含封面页处理)
|
|
||||||
- RTL 语言方向支持
|
|
||||||
|
|
||||||
### 2.2 自定义布局 `RDReaderFlowLayout`
|
|
||||||
|
|
||||||
- 文件:`Sources/RDReaderView/RDReaderFlowLayout.swift`(~387 行)
|
|
||||||
- 职责:
|
|
||||||
- 继承 `UICollectionViewFlowLayout`,为三种滚动模式提供布局计算
|
|
||||||
- 水平滚动:全屏宽 item,pagingEnabled
|
|
||||||
- 垂直滚动:可变高度 item,累加计算
|
|
||||||
- 水平覆盖滚动:zIndex 分层 + 阴影效果模拟深度
|
|
||||||
- 封面感知帧计算:封面页全屏宽,后续页面两两配对半屏宽
|
|
||||||
|
|
||||||
### 2.3 内容 Cell `RDReaderContentCell`
|
|
||||||
|
|
||||||
- 文件:`Sources/RDReaderView/RDReaderContentCell.swift`(~37 行)
|
|
||||||
- 职责:
|
|
||||||
- `UICollectionViewCell` 子类,作为内容视图的薄壳宿主
|
|
||||||
- `containerView` 属性 setter 自动移除旧视图、添加新视图
|
|
||||||
- `layoutSubviews` 将 containerView 填满 contentView.bounds
|
|
||||||
|
|
||||||
### 2.4 页面子控制器 `RDReaderPageChildViewController`
|
|
||||||
|
|
||||||
- 文件:`Sources/RDReaderView/RDReaderPageChildViewController.swift`(~65 行)
|
|
||||||
- 职责:
|
|
||||||
- 仅用于 pageCurl 模式,作为 `UIPageViewController` 的子控制器
|
|
||||||
- 持有 `contentView: UIView?` 和 `pageNum: Int`
|
|
||||||
- `contentView` didSet 在 view 已加载时自动调用 `installContentView()`
|
|
||||||
|
|
||||||
### 2.5 手势控制器 `RDReaderGestureController`
|
|
||||||
|
|
||||||
- 文件:`Sources/RDReaderView/RDReaderGestureController.swift`(~42 行)
|
|
||||||
- 职责:
|
|
||||||
- 当前为占位组件,存储 topToolView / bottomToolView 引用
|
|
||||||
- 实际手势逻辑在 `RDReaderView.tapCenter()` 中实现
|
|
||||||
|
|
||||||
### 2.6 URL 入口 `RDURLReaderController`
|
|
||||||
|
|
||||||
- 文件:`Sources/RDReaderView/RDURLReaderController.swift`(~105 行)
|
|
||||||
- 职责:
|
|
||||||
- 最简入口:传入 URL 即可打开书籍
|
|
||||||
- `.epub` 扩展名 → 创建 `RDEPUBReaderController`
|
|
||||||
- 其他 → 创建 `RDPlainTextReaderController`(UITextView 只读展示,尝试 UTF-8 → GBK → GB2312 解码)
|
|
||||||
- 导航栏标题设为文件名(去掉扩展名)
|
|
||||||
|
|
||||||
## 3. 主流程(代码级)
|
|
||||||
|
|
||||||
### 3.1 协议定义
|
|
||||||
|
|
||||||
**RDReaderDataSource**(数据供给):
|
|
||||||
```swift
|
|
||||||
func pageCountOfReaderView(readerView: RDReaderView) -> Int
|
|
||||||
func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView
|
|
||||||
func pageIdentifier(readerView: RDReaderView, pageNum: Int) -> String?
|
|
||||||
@objc optional func topToolView(readerView: RDReaderView) -> UIView?
|
|
||||||
@objc optional func bottomToolView(readerView: RDReaderView) -> UIView?
|
|
||||||
```
|
|
||||||
|
|
||||||
**RDReaderDelegate**(事件回调):
|
|
||||||
```swift
|
|
||||||
func pageNum(readerView: RDReaderView, pageNum: Int)
|
|
||||||
@objc optional func readerViewOrientationWillChange(readerView: RDReaderView, isLandscape: Bool)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2 初始化与数据加载
|
|
||||||
|
|
||||||
1. 调用方创建 `RDReaderView`,设置 `dataSource` 和 `delegate`。
|
|
||||||
2. 调用 `reloadData()`。
|
|
||||||
3. `reloadData` 内部调用 `switchReaderDisplayType(currentDisplayType)` 重建视图层级。
|
|
||||||
4. 同时从 `dataSource` 获取 `topToolView` 和 `bottomToolView` 并添加到视图层级。
|
|
||||||
|
|
||||||
### 3.3 四种显示模式切换
|
|
||||||
|
|
||||||
**pageCurl 模式**:
|
|
||||||
- 创建 `UIPageViewController`(transitionStyle: .pageCurl)
|
|
||||||
- 通过 `attachPageViewControllerIfNeeded()` 添加为父 VC 的 child VC
|
|
||||||
- 横屏双页时重建 `UIPageViewController`(spineLocation: .mid, isDoubleSided: true)
|
|
||||||
- RTL 时翻转导航方向
|
|
||||||
|
|
||||||
**horizontalScroll 模式**:
|
|
||||||
- 移除 pageViewController,插入 `UICollectionView`
|
|
||||||
- `RDReaderFlowLayout` 设置 scrollDirection = .horizontal, isPagingEnabled = true
|
|
||||||
- itemSize 宽度 = 容器宽度 / pagesPerScreen
|
|
||||||
- RTL 时对 collectionView 做 `scaleX: -1` 翻转,cell contentView 再翻转回来
|
|
||||||
|
|
||||||
**verticalScroll 模式**:
|
|
||||||
- 同一 collectionView,scrollDirection = .vertical, isPagingEnabled = false
|
|
||||||
- 页面高度可变,通过 `RDReaderFlowLayoutDataSoure.heigtOfVerticalScrollPage` 查询
|
|
||||||
- collectionViewContentSize 为所有页面高度之和
|
|
||||||
|
|
||||||
**horizontalCoverScroll 模式**:
|
|
||||||
- 水平分页,但带封面滑动动画
|
|
||||||
- `layoutAttributesForElements` 中:
|
|
||||||
- 仅计算当前页附近的窄窗口内的 item attributes
|
|
||||||
- 当前页之前的 item zIndex = -1,当前及之后 zIndex = 1
|
|
||||||
- 顶层页面边缘添加阴影效果
|
|
||||||
|
|
||||||
### 3.4 翻页交互
|
|
||||||
|
|
||||||
**点击手势**(`tapAction(tap:)`):
|
|
||||||
- 屏幕分为左 1/3、中 1/3、右 1/3 三个区域
|
|
||||||
- 左区域:工具栏隐藏时翻上一页(RTL 时翻下一页);工具栏显示时触发 `tapCenter()`
|
|
||||||
- 中区域:始终触发 `tapCenter()`
|
|
||||||
- 右区域:工具栏隐藏时翻下一页(RTL 时翻上一页);工具栏显示时触发 `tapCenter()`
|
|
||||||
|
|
||||||
**goNextPage() / goPreviousPage()**:
|
|
||||||
- 双页模式使用 `adjacentDualPage(from:forward:)` 计算目标页
|
|
||||||
- 单页模式直接 +1 / -1
|
|
||||||
- 调用 `transitionToPage(pageNum:animated:true)` 执行跳转
|
|
||||||
|
|
||||||
**工具栏切换**(`tapCenter()`):
|
|
||||||
- 显示时:topToolView 从上方滑入,bottomToolView 从下方滑入(CGAffineTransform translationY)
|
|
||||||
- 隐藏时:反向动画
|
|
||||||
- 工具栏显示期间禁用 collectionView 和 pageViewController 的用户交互
|
|
||||||
|
|
||||||
### 3.5 双页配对逻辑
|
|
||||||
|
|
||||||
**`dualPagePair(for pageNum:)`** 返回 `(left: Int, right: Int?)`:
|
|
||||||
- 有封面页:封面页 → (coverIndex, nil);封面后的页面两两配对(coverIndex+1 与 coverIndex+2,coverIndex+3 与 coverIndex+4...)
|
|
||||||
- 无封面页:标准偶奇配对(0+1, 2+3, 4+5...)
|
|
||||||
|
|
||||||
**空白哨兵页**:
|
|
||||||
- `blankPageNum = Int.max`,`blankEndPageNum = Int.max - 1`
|
|
||||||
- 仅用于 pageCurl 双页模式,填充封面页独占或总页数为奇数时的右侧空白
|
|
||||||
|
|
||||||
### 3.6 横竖屏变化处理
|
|
||||||
|
|
||||||
1. `layoutSubviews()` 检测 `previousIsLandscape` 与当前 `isLandscape` 的变化
|
|
||||||
2. 异步调用 `orientationChanged(isNowLandscape:)` 避免嵌套布局
|
|
||||||
3. 通知 delegate `readerViewOrientationWillChange`
|
|
||||||
4. 更新 `layout.isLandscapeDualPage` 和 `layout.coverPageIndex`
|
|
||||||
5. pageCurl 模式:重建 UIPageViewController(spineLocation 不可变)并跳转到保存的页面
|
|
||||||
6. 滚动模式:invalidate layout,reload data,强制布局,无动画滚动到保存的 offset
|
|
||||||
|
|
||||||
### 3.7 CollectionView DataSource / Layout Delegate
|
|
||||||
|
|
||||||
**cellForItemAt**:
|
|
||||||
- 用 `dataSource.pageIdentifier` 获取重用标识符
|
|
||||||
- 出队 `RDReaderContentCell`
|
|
||||||
- 调用 `dataSource.pageContentView(pageNum:containerView:)` 传入 cell 已有的 containerView 以便复用
|
|
||||||
- RTL 水平模式对 cell.contentView 做 `scaleX: -1` 翻转
|
|
||||||
|
|
||||||
**pageNum(flowLayout:pageIndex:)**:
|
|
||||||
- 布局检测到滚动中页面变化时更新 `currentPage`
|
|
||||||
|
|
||||||
## 4. 异常与边界处理
|
|
||||||
|
|
||||||
- `currentPage` 初始值为 -1,首次设置时 delegate 会收到回调
|
|
||||||
- 空白哨兵页(Int.max / Int.max-1)在 delegate 回调中被过滤,不会通知到外部
|
|
||||||
- pageCurl 模式下 collectionView 的 DataSource 方法存在但不被调用(collectionView 已从视图层级移除)
|
|
||||||
- 横竖屏变化时异步处理避免嵌套 layoutSubviews
|
|
||||||
- `UIView.ss_superViewController` 通过响应链查找最近的父 VC,用于正确挂载 pageViewController
|
|
||||||
|
|
||||||
## 5. 数据结构与字段映射
|
|
||||||
|
|
||||||
### 5.1 显示模式
|
|
||||||
|
|
||||||
```swift
|
|
||||||
RDReaderView.DisplayType
|
|
||||||
├── .pageCurl // UIPageViewController 翻页效果
|
|
||||||
├── .horizontalScroll // UICollectionView 水平滚动
|
|
||||||
├── .verticalScroll // UICollectionView 垂直滚动
|
|
||||||
└── .horizontalCoverScroll // UICollectionView 水平覆盖动画
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.2 翻页方向
|
|
||||||
|
|
||||||
```swift
|
|
||||||
RDReaderView.PageDirection
|
|
||||||
├── .leftToRight // 默认,中文/英文
|
|
||||||
└── .rightToLeft // 日文漫画等
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.3 关键公开属性
|
|
||||||
|
|
||||||
| 属性 | 类型 | 默认值 | 用途 |
|
|
||||||
|------|------|--------|------|
|
|
||||||
| `dataSource` | `RDReaderDataSource?` | nil | 弱引用数据源 |
|
|
||||||
| `delegate` | `RDReaderDelegate?` | nil | 弱引用事件监听 |
|
|
||||||
| `currentDisplayType` | `DisplayType` | .pageCurl | 当前显示模式 |
|
|
||||||
| `toolViewAnimationDuration` | `TimeInterval` | 0.3 | 工具栏动画时长 |
|
|
||||||
| `landscapeDualPageEnabled` | `Bool` | false | 横屏双页 |
|
|
||||||
| `pageDirection` | `PageDirection` | .leftToRight | 翻页方向 |
|
|
||||||
| `coverPageIndex` | `Int?` | nil | 封面页索引 |
|
|
||||||
| `currentPage` | `Int` | -1 | 当前页码(didSet 通知 delegate) |
|
|
||||||
|
|
||||||
### 5.4 计算属性
|
|
||||||
|
|
||||||
- `pagesPerScreen: Int`:横屏 + 双页启用 + 非垂直滚动 → 2;否则 1
|
|
||||||
- `isLandscape: Bool`:`bounds.width > bounds.height`
|
|
||||||
- `hasCoverPage: Bool`:`coverPageIndex != nil`
|
|
||||||
|
|
||||||
## 6. 视图绑定规则
|
|
||||||
|
|
||||||
- 规则 1:内容视图通过 `register(contentView:contentViewWithReuseIdentifier:)` 注册,内部注册对应的 `RDReaderContentCell`
|
|
||||||
- 规则 2:Cell 复用两层:`RDReaderContentCell`(UICollectionViewCell)宿主 `containerView`(实际内容视图)
|
|
||||||
- 规则 3:`pageContentView(readerView:pageNum:containerView:)` 的 `containerView` 参数是从回收 cell 中取出的旧视图,允许数据源复用或重新渲染
|
|
||||||
- 规则 4:工具栏通过 `topToolView` / `bottomToolView` 协议方法提供,由 `RDReaderView` 管理显隐动画
|
|
||||||
- 规则 5:RTL 支持通过 collectionView `scaleX: -1` + cell contentView `scaleX: -1` 实现,文本正确显示
|
|
||||||
|
|
||||||
## 7. 通知协作与回调机制
|
|
||||||
|
|
||||||
- 无 NotificationCenter 使用
|
|
||||||
- 所有通信通过协议和闭包:
|
|
||||||
- `RDReaderDataSource`:数据供给(页数、内容视图、标识符、工具栏)
|
|
||||||
- `RDReaderDelegate`:事件通知(页面变化、横竖屏即将变化)
|
|
||||||
- `RDReaderFlowLayoutDelegate`:布局检测到页面变化时通知
|
|
||||||
- `RDReaderFlowLayoutDataSoure`:垂直滚动时提供每页高度
|
|
||||||
|
|
||||||
## 8. 联调与排查建议
|
|
||||||
|
|
||||||
- 排查 1:页面不显示
|
|
||||||
- 确认 `dataSource` 已设置且 `reloadData()` 已调用
|
|
||||||
- 确认 `pageCountOfReaderView` 返回值 > 0
|
|
||||||
- 确认 `pageContentView(pageNum:containerView:)` 返回非 nil 视图
|
|
||||||
- 排查 2:翻页无反应
|
|
||||||
- 确认工具栏是否正在显示(工具栏显示时左右点击变为切换工具栏)
|
|
||||||
- 确认 `currentPage` 的 didSet 是否被触发
|
|
||||||
- pageCurl 模式检查 `UIPageViewController` 的 dataSource 方法是否正确返回
|
|
||||||
- 排查 3:横屏双页异常
|
|
||||||
- 确认 `landscapeDualPageEnabled` 是否为 true
|
|
||||||
- pageCurl 模式需重建 UIPageViewController(spineLocation 不可变)
|
|
||||||
- 检查 `dualPagePair` 配对逻辑和空白哨兵页处理
|
|
||||||
- 排查 4:RTL 方向错误
|
|
||||||
- 确认 `pageDirection` 是否设为 `.rightToLeft`
|
|
||||||
- 水平滚动模式检查 collectionView 是否做了 `scaleX: -1`
|
|
||||||
- pageCurl 模式检查导航方向是否翻转
|
|
||||||
- 排查 5:工具栏动画异常
|
|
||||||
- 确认 `topToolView` / `bottomToolView` 是否通过 dataSource 正确提供
|
|
||||||
- 检查 `toolViewAnimationDuration` 值
|
|
||||||
- 确认工具栏显示期间用户交互是否被正确禁用/恢复
|
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
# ReaderView 模块代码级参考文档
|
||||||
|
|
||||||
|
> 最后更新:2026-06-18
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 模块概述
|
||||||
|
|
||||||
|
`ReaderView` 是 ReadViewSDK 的**通用翻页容器层**,位于 EPUBUI 层之下。它提供与 EPUB 内容无关的页面展示、翻页动画、手势识别、页面预加载和双页布局能力。上层通过 `RDEpubReaderPageProvider` 协议提供页面内容视图,ReaderView 负责容器管理和翻页调度。
|
||||||
|
|
||||||
|
**文件清单(14 个 Swift 文件):**
|
||||||
|
|
||||||
|
| 文件 | 核心类型 | 职责 |
|
||||||
|
|------|----------|------|
|
||||||
|
| `RDEpubReaderView.swift` | `RDEpubReaderView` | 主容器视图,协调所有子组件 |
|
||||||
|
| `RDEpubReaderViewProtocols.swift` | 协议 + 枚举 | 数据源、代理、导航协议定义 |
|
||||||
|
| `RDEpubReaderFlowLayout.swift` | `RDEpubReaderFlowLayout` | UICollectionView 自定义布局 |
|
||||||
|
| `RDEpubReaderGestureController.swift` | `RDEpubReaderGestureController` | 手势控制器(预留) |
|
||||||
|
| `RDEpubReaderContentCell.swift` | `RDEpubReaderContentCell` | CollectionView 内容 Cell |
|
||||||
|
| `RDEpubReaderPageChildViewController.swift` | `RDEpubReaderPageChildViewController` | PageCurl 模式子 VC |
|
||||||
|
| `RDEpubReaderView+PageCurl.swift` | Extension | UIPageViewController 数据源/代理 |
|
||||||
|
| `RDEpubReaderView+CollectionView.swift` | Extension | UICollectionView 数据源/布局代理 |
|
||||||
|
| `RDEpubReaderView+ContentAccess.swift` | Extension | 内容视图访问与复用 |
|
||||||
|
| `RDEpubReaderView+ToolView.swift` | Extension | 工具栏安装与动画 |
|
||||||
|
| `Paging/RDEpubReaderPagingController.swift` | `RDEpubReaderPagingController` | 翻页状态机 |
|
||||||
|
| `Paging/RDEpubReaderPreloadController.swift` | `RDEpubReaderPreloadController` | 页面预加载与缓存 |
|
||||||
|
| `Paging/RDEpubReaderSpreadResolver.swift` | `RDEpubReaderSpreadResolver` | 双页展开计算 |
|
||||||
|
| `Paging/RDEpubReaderTapRegionHandler.swift` | `RDEpubReaderTapRegionHandler` | 点击区域判定 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 协议定义(RDEpubReaderViewProtocols.swift)
|
||||||
|
|
||||||
|
### 2.1 RDEpubReaderDataSource(旧版数据源,已废弃)
|
||||||
|
|
||||||
|
```swift
|
||||||
|
@objc public protocol RDEpubReaderDataSource: NSObjectProtocol {
|
||||||
|
func pageCountOfReaderView(readerView: RDEpubReaderView) -> Int
|
||||||
|
func pageContentView(readerView: RDEpubReaderView, pageNum: Int, containerView: UIView?) -> UIView
|
||||||
|
func pageIdentifier(readerView: RDEpubReaderView, pageNum: Int) -> String?
|
||||||
|
@objc optional func topToolView(readerView: RDEpubReaderView) -> UIView?
|
||||||
|
@objc optional func bottomToolView(readerView: RDEpubReaderView) -> UIView?
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> 向后兼容保留,新代码应使用 `RDEpubReaderPageProvider`。
|
||||||
|
|
||||||
|
### 2.2 RDEpubReaderPageProvider(推荐数据源)
|
||||||
|
|
||||||
|
```swift
|
||||||
|
@objc public protocol RDEpubReaderPageProvider: NSObjectProtocol {
|
||||||
|
func numberOfPages(in readerView: RDEpubReaderView) -> Int
|
||||||
|
func readerView(_ readerView: RDEpubReaderView, viewForPageAt index: Int, reusableView: UIView?) -> UIView
|
||||||
|
@objc optional func pageIdentifier(in readerView: RDEpubReaderView, index: Int) -> String?
|
||||||
|
@objc optional func readerViewTopChrome(_ readerView: RDEpubReaderView) -> UIView?
|
||||||
|
@objc optional func readerViewBottomChrome(_ readerView: RDEpubReaderView) -> UIView?
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `numberOfPages(in:)` | 返回总页数 |
|
||||||
|
| `readerView(_:viewForPageAt:reusableView:)` | 为指定页码提供内容视图,`reusableView` 可复用 |
|
||||||
|
| `pageIdentifier(in:index:)` | 返回页视图复用标识符,用于 CollectionView 注册 |
|
||||||
|
| `readerViewTopChrome(_:)` | 返回顶部工具栏视图 |
|
||||||
|
| `readerViewBottomChrome(_:)` | 返回底部工具栏视图 |
|
||||||
|
|
||||||
|
### 2.3 RDEpubReaderDelegate
|
||||||
|
|
||||||
|
```swift
|
||||||
|
@objc public protocol RDEpubReaderDelegate: NSObjectProtocol {
|
||||||
|
func pageNum(readerView: RDEpubReaderView, pageNum: Int)
|
||||||
|
@objc optional func readerViewOrientationWillChange(readerView: RDEpubReaderView, isLandscape: Bool)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 RDEpubReaderPageNavigating
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public protocol RDEpubReaderPageNavigating: AnyObject {
|
||||||
|
var currentPage: Int { get }
|
||||||
|
func reloadPages()
|
||||||
|
func transition(to page: Int, animated: Bool)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`RDEpubReaderView` 遵循此协议,提供统一的页面导航接口。
|
||||||
|
|
||||||
|
### 2.5 枚举类型
|
||||||
|
|
||||||
|
```swift
|
||||||
|
extension RDEpubReaderView {
|
||||||
|
public enum DisplayType {
|
||||||
|
case pageCurl // 仿真翻页(UIPageViewController)
|
||||||
|
case horizontalScroll // 水平滑动(UICollectionView)
|
||||||
|
case verticalScroll // 垂直滚动(UICollectionView)
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum PageDirection {
|
||||||
|
case leftToRight // LTR
|
||||||
|
case rightToLeft // RTL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 核心类:RDEpubReaderView
|
||||||
|
|
||||||
|
**文件:** `RDEpubReaderView.swift`
|
||||||
|
|
||||||
|
`RDEpubReaderView` 是一个 `UIView` 子类,作为翻页容器的主入口。内部管理两种翻页引擎:
|
||||||
|
- **PageCurl 模式**:使用 `UIPageViewController` 实现仿真翻页
|
||||||
|
- **Scroll 模式**:使用 `UICollectionView` + 自定义 `RDEpubReaderFlowLayout` 实现滑动翻页
|
||||||
|
|
||||||
|
### 3.1 关键属性
|
||||||
|
|
||||||
|
| 属性 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `currentPage` | `Int` | 当前页码,变化时通知 delegate |
|
||||||
|
| `currentDisplayType` | `DisplayType` | 当前显示模式 |
|
||||||
|
| `pageDirection` | `PageDirection` | 页面方向(LTR/RTL) |
|
||||||
|
| `landscapeDualPageEnabled` | `Bool` | 是否启用横屏双页 |
|
||||||
|
| `coverPageIndex` | `Int?` | 封面页索引(独占一屏) |
|
||||||
|
| `pagesPerScreen` | `Int` | 每屏页数(横屏双页时为 2) |
|
||||||
|
| `preloadRadius` | `Int` | 预加载半径(默认 1) |
|
||||||
|
| `dataSource` | `RDEpubReaderDataSource?` | 旧版数据源 |
|
||||||
|
| `pageProvider` | `RDEpubReaderPageProvider?` | 推荐数据源 |
|
||||||
|
| `delegate` | `RDEpubReaderDelegate?` | 事件代理 |
|
||||||
|
| `toolViewAnimationDuration` | `TimeInterval` | 工具栏动画时长(0.3s) |
|
||||||
|
|
||||||
|
### 3.2 关键方法
|
||||||
|
|
||||||
|
```swift
|
||||||
|
/// 切换显示模式(pageCurl / horizontalScroll / verticalScroll)
|
||||||
|
public func switchReaderDisplayType(_ displayType: RDEpubReaderView.DisplayType)
|
||||||
|
|
||||||
|
/// 跳转到指定页
|
||||||
|
public func transitionToPage(pageNum: Int, animated: Bool = false)
|
||||||
|
|
||||||
|
/// 重新加载所有页面
|
||||||
|
public func reloadData()
|
||||||
|
|
||||||
|
/// 仅重载页数(不重建内容)
|
||||||
|
public func reloadPageCountOnly()
|
||||||
|
|
||||||
|
/// 判断指定页是否为全屏页(封面页独占一屏)
|
||||||
|
public func isFullScreenPage(_ pageNum: Int) -> Bool
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 内部子组件
|
||||||
|
|
||||||
|
| 组件 | 类型 | 职责 |
|
||||||
|
|------|------|------|
|
||||||
|
| `pageViewController` | `UIPageViewController` | PageCurl 翻页引擎 |
|
||||||
|
| `collectionView` | `UICollectionView` | Scroll 翻页引擎 |
|
||||||
|
| `layout` | `RDEpubReaderFlowLayout` | CollectionView 自定义布局 |
|
||||||
|
| `spreadResolver` | `RDEpubReaderSpreadResolver` | 双页配对计算 |
|
||||||
|
| `tapRegionHandler` | `RDEpubReaderTapRegionHandler` | 点击区域判定 |
|
||||||
|
| `preloadController` | `RDEpubReaderPreloadController` | 页面预加载与缓存 |
|
||||||
|
| `pagingController` | `RDEpubReaderPagingController` | 翻页状态管理 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 翻页状态机:RDEpubReaderPagingController
|
||||||
|
|
||||||
|
**文件:** `Paging/RDEpubReaderPagingController.swift`
|
||||||
|
|
||||||
|
管理 PageCurl 模式下的翻页请求队列,防止动画冲突。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
struct RDEpubReaderPagingController {
|
||||||
|
struct PageTransitionRequest: Equatable {
|
||||||
|
let pageNum: Int
|
||||||
|
let animated: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var pendingTransitionRequest: PageTransitionRequest? // 待处理请求
|
||||||
|
var isTransitioning: Bool // 是否正在翻页动画中
|
||||||
|
var didBuildUI: Bool // UI 是否已构建
|
||||||
|
|
||||||
|
/// 判断是否应排队请求(PageCurl 模式下动画中返回 true)
|
||||||
|
mutating func shouldQueuePageTransition(_ request: PageTransitionRequest, currentDisplayType: RDEpubReaderView.DisplayType) -> Bool
|
||||||
|
|
||||||
|
/// 完成翻页动画,返回待处理的请求
|
||||||
|
mutating func finishPageCurlTransition() -> PageTransitionRequest?
|
||||||
|
|
||||||
|
/// 重置状态(用于故障恢复)
|
||||||
|
mutating func resetPendingState()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 页面预加载:RDEpubReaderPreloadController
|
||||||
|
|
||||||
|
**文件:** `Paging/RDEpubReaderPreloadController.swift`
|
||||||
|
|
||||||
|
负责在当前页周围预渲染页面视图,减少翻页时的白屏时间。
|
||||||
|
|
||||||
|
### 5.1 核心机制
|
||||||
|
|
||||||
|
- **缓存签名(CacheSignature)**:基于 `displayType + isLandscape + pagesPerScreen + boundsSize` 生成签名,签名变化时清空缓存
|
||||||
|
- **双缓存池**:`preloadedPageViews`(预加载池)和 `pageCurlCachedViews`(PageCurl 缓存池)
|
||||||
|
- **预测性预加载**:根据 `preferredForward` 方向多预加载一页
|
||||||
|
|
||||||
|
### 5.2 关键方法
|
||||||
|
|
||||||
|
```swift
|
||||||
|
/// 为指定页获取视图(优先从缓存取)
|
||||||
|
func pageViewForDisplay(pageNum: Int, environment: Environment, contentViewProvider: (Int, UIView?) -> UIView?) -> UIView
|
||||||
|
|
||||||
|
/// 在指定页周围预加载
|
||||||
|
func prime(around pageNum: Int, preferredForward: Bool?, parentView: UIView, environment: Environment, contentViewProvider: (Int, UIView?) -> UIView?)
|
||||||
|
|
||||||
|
/// 取走预加载的视图(用于 CollectionView 复用)
|
||||||
|
func takePreloadedView(for pageNum: Int) -> UIView?
|
||||||
|
|
||||||
|
/// 使缓存失效
|
||||||
|
func invalidate(environment: Environment)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Environment 结构体
|
||||||
|
|
||||||
|
```swift
|
||||||
|
struct Environment {
|
||||||
|
let displayType: RDEpubReaderView.DisplayType
|
||||||
|
let isLandscape: Bool
|
||||||
|
let pagesPerScreen: Int
|
||||||
|
let boundsSize: CGSize
|
||||||
|
let landscapeDualPageEnabled: Bool
|
||||||
|
let coverPageIndex: Int?
|
||||||
|
let totalPages: Int
|
||||||
|
let spreadResolver: RDEpubReaderSpreadResolver
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 双页展开计算:RDEpubReaderSpreadResolver
|
||||||
|
|
||||||
|
**文件:** `Paging/RDEpubReaderSpreadResolver.swift`
|
||||||
|
|
||||||
|
纯函数式结构体,负责双页模式下的页面配对和导航计算。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
struct RDEpubReaderSpreadResolver {
|
||||||
|
/// 判断是否为全屏页(封面页独占一屏)
|
||||||
|
func isFullScreenPage(_ pageNum: Int, landscapeDualPageEnabled: Bool, isLandscape: Bool, coverPageIndex: Int?) -> Bool
|
||||||
|
|
||||||
|
/// 计算双页配对(left, right?),right 为 nil 表示独占一屏
|
||||||
|
func dualPagePair(for pageNum: Int, totalPages: Int, coverPageIndex: Int?) -> (left: Int, right: Int?)
|
||||||
|
|
||||||
|
/// 计算相邻双页的起始页码
|
||||||
|
func adjacentDualPage(from pageNum: Int, totalPages: Int, coverPageIndex: Int?, forward: Bool) -> Int?
|
||||||
|
|
||||||
|
/// 计算下一页(支持单页和双页模式)
|
||||||
|
func nextPage(from currentPage: Int, totalPages: Int, pagesPerScreen: Int, coverPageIndex: Int?, forward: Bool) -> Int?
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**封面页逻辑:**
|
||||||
|
- 封面页(`coverPageIndex`)独占一屏,不与其他页配对
|
||||||
|
- 封面后的页面从 `coverIndex + 1` 开始两两配对
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 点击区域判定:RDEpubReaderTapRegionHandler
|
||||||
|
|
||||||
|
**文件:** `Paging/RDEpubReaderTapRegionHandler.swift`
|
||||||
|
|
||||||
|
将屏幕三等分,判定点击属于左/中/右区域。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
struct RDEpubReaderTapRegionHandler {
|
||||||
|
func resolveTapEvent(point: CGPoint, viewFrame: CGRect, isToolViewVisible: Bool) -> RDEpubReaderView.TapEvent
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**逻辑:**
|
||||||
|
- 左 1/3 → `.left`(上一页),工具栏可见时改为 `.center`
|
||||||
|
- 中 1/3 → `.center`(切换工具栏)
|
||||||
|
- 右 1/3 → `.right`(下一页),工具栏可见时改为 `.center`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 流式布局:RDEpubReaderFlowLayout
|
||||||
|
|
||||||
|
**文件:** `RDEpubReaderFlowLayout.swift`
|
||||||
|
|
||||||
|
`UICollectionViewFlowLayout` 子类,支持水平滚动和垂直滚动两种模式。
|
||||||
|
|
||||||
|
### 8.1 关键属性
|
||||||
|
|
||||||
|
| 属性 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `displayType` | `RDEpubReaderView.DisplayType` | 布局模式 |
|
||||||
|
| `isLandscapeDualPage` | `Bool` | 是否横屏双页 |
|
||||||
|
| `coverPageIndex` | `Int?` | 封面页索引 |
|
||||||
|
| `pagesPerScreen` | `Int` | 每屏页数 |
|
||||||
|
|
||||||
|
### 8.2 协议
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public protocol RDEpubReaderFlowLayoutDataSoure: NSObjectProtocol {
|
||||||
|
func heigtOfVerticalScrollPage(flowLayout: RDEpubReaderFlowLayout, pageIndex: Int) -> CGFloat?
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc public protocol RDEpubReaderFlowLayoutDelegate: NSObjectProtocol {
|
||||||
|
func pageNum(flowLayout: RDEpubReaderFlowLayout, pageIndex: Int)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 关键方法
|
||||||
|
|
||||||
|
```swift
|
||||||
|
/// 计算指定页码的 contentOffset
|
||||||
|
func currentContentOffset(count: Int) -> CGPoint
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.4 封面页布局逻辑
|
||||||
|
|
||||||
|
双页模式下,封面页占满整屏宽度,后续页面两两配对占半屏宽度。`coverAwareFrame(for:screenWidth:halfWidth:height:)` 方法根据页码计算对应的 frame。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 内容 Cell:RDEpubReaderContentCell
|
||||||
|
|
||||||
|
**文件:** `RDEpubReaderContentCell.swift`
|
||||||
|
|
||||||
|
`UICollectionViewCell` 子类,用于 Scroll 模式下承载页面内容视图。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
class RDEpubReaderContentCell: UICollectionViewCell {
|
||||||
|
var containerView: UIView? // 设置时自动添加到 contentView,移除旧视图
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. PageCurl 子控制器:RDEpubReaderPageChildViewController
|
||||||
|
|
||||||
|
**文件:** `RDEpubReaderPageChildViewController.swift`
|
||||||
|
|
||||||
|
`UIViewController` 子类,作为 `UIPageViewController` 的页面 VC。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
class RDEpubReaderPageChildViewController: UIViewController {
|
||||||
|
var contentView: UIView? // 内容视图,设置时自动安装到容器
|
||||||
|
var pageNum: Int // 对应页码
|
||||||
|
|
||||||
|
init(contentView: UIView?, pageNum: Int = 0)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Extension 汇总
|
||||||
|
|
||||||
|
### 11.1 RDEpubReaderView+PageCurl
|
||||||
|
|
||||||
|
实现 `UIPageViewControllerDataSource` 和 `UIPageViewControllerDelegate`:
|
||||||
|
|
||||||
|
- `pageViewController(_:viewControllerBefore:)` — 提供前一页 VC(RTL 时逻辑反转)
|
||||||
|
- `pageViewController(_:viewControllerAfter:)` — 提供后一页 VC
|
||||||
|
- `pageViewController(_:didFinishAnimating:...)` — 完成动画后更新 currentPage
|
||||||
|
- `pageViewController(_:willTransitionTo:)` — 即将翻页时预加载
|
||||||
|
|
||||||
|
**特殊页码:**
|
||||||
|
- `RDEpubReaderView.blankPageNum`(`Int.max`)— 双页模式下的空白页
|
||||||
|
- `RDEpubReaderView.blankEndPageNum`(`Int.max - 1`)— 末尾空白页
|
||||||
|
|
||||||
|
### 11.2 RDEpubReaderView+CollectionView
|
||||||
|
|
||||||
|
实现 `UICollectionViewDataSource`、`RDEpubReaderFlowLayoutDelegate`、`RDEpubReaderFlowLayoutDataSoure`:
|
||||||
|
|
||||||
|
- `collectionView(_:cellForItemAt:)` — 复用预加载视图或创建新 Cell
|
||||||
|
- `collectionView(_:numberOfItemsInSection:)` — 返回总页数
|
||||||
|
- `pageNum(flowLayout:pageIndex:)` — 滚动时更新当前页码
|
||||||
|
|
||||||
|
### 11.3 RDEpubReaderView+ContentAccess
|
||||||
|
|
||||||
|
提供内容视图的注册、复用和查询:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
/// 注册内容视图类型(类似 UICollectionView 的 register)
|
||||||
|
public func register(contentView: UIView.Type, contentViewWithReuseIdentifier identifier: String)
|
||||||
|
|
||||||
|
/// 获取可复用的内容视图
|
||||||
|
public func dequeueReusableContentView(withReuseIdentifier identifier: String, for pageNum: Int) -> UIView
|
||||||
|
|
||||||
|
/// 获取指定页的内容视图
|
||||||
|
public func pageContentView(pageNum: Int) -> UIView?
|
||||||
|
|
||||||
|
/// 计算单页尺寸(考虑双页模式)
|
||||||
|
public func resolvedSinglePageSize(pageNum: Int? = nil) -> CGSize
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.4 RDEpubReaderView+ToolView
|
||||||
|
|
||||||
|
管理顶部/底部工具栏的安装、显示/隐藏动画:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
/// 切换工具栏显示状态(点击中心区域触发)
|
||||||
|
func tapCenter()
|
||||||
|
|
||||||
|
/// 安装工具栏视图到指定位置
|
||||||
|
func installToolViewIfNeeded(_ toolView: UIView, position: ToolViewPosition)
|
||||||
|
|
||||||
|
/// 更新工具栏高度约束
|
||||||
|
func updateToolViewHeightConstraintsIfNeeded()
|
||||||
|
```
|
||||||
|
|
||||||
|
**动画效果:** 顶部工具栏从上方滑入,底部工具栏从下方滑入。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 设计模式总结
|
||||||
|
|
||||||
|
| 模式 | 应用 |
|
||||||
|
|------|------|
|
||||||
|
| **策略模式** | `DisplayType` 切换 PageCurl / Scroll 两种翻页策略 |
|
||||||
|
| **适配器模式** | `RDEpubReaderLegacyDataSourceAdapter` 将旧 `RDEpubReaderDataSource` 适配为 `RDEpubReaderPageProvider` |
|
||||||
|
| **命令队列** | `RDEpubReaderPagingController` 管理翻页请求队列 |
|
||||||
|
| **缓存签名** | `RDEpubReaderPreloadController.CacheSignature` 检测环境变化自动失效 |
|
||||||
|
| **关注点分离** | Extension 将不同功能拆分到独立文件 |
|
||||||
|
| **纯函数** | `RDEpubReaderSpreadResolver` 和 `RDEpubReaderTapRegionHandler` 无状态计算 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 数据流图
|
||||||
|
|
||||||
|
```
|
||||||
|
用户点击屏幕
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
RDEpubReaderTapRegionHandler.resolveTapEvent()
|
||||||
|
│
|
||||||
|
├── .left → goPreviousPage() ──→ spreadResolver.nextPage(forward: false)
|
||||||
|
├── .right → goNextPage() ──→ spreadResolver.nextPage(forward: true)
|
||||||
|
└── .center → tapCenter() ──→ 显示/隐藏工具栏
|
||||||
|
|
||||||
|
transitionToPage(pageNum:)
|
||||||
|
│
|
||||||
|
├── PageCurl 模式
|
||||||
|
│ ├── pagingController.shouldQueuePageTransition() → 排队或执行
|
||||||
|
│ ├── pageViewForDisplay() → preloadController 取缓存视图
|
||||||
|
│ ├── pageViewController.setViewControllers()
|
||||||
|
│ └── primePageCache() → 预加载周围页面
|
||||||
|
│
|
||||||
|
└── Scroll 模式
|
||||||
|
├── collectionView.reloadData()
|
||||||
|
├── collectionView.setContentOffset()
|
||||||
|
└── primePageCache() → 预加载周围页面
|
||||||
|
```
|
||||||
+142
@@ -0,0 +1,142 @@
|
|||||||
|
# 测试说明
|
||||||
|
|
||||||
|
**分析日期:** 2026-06-22(更新)
|
||||||
|
|
||||||
|
## 测试框架与现状
|
||||||
|
|
||||||
|
**Runner:**
|
||||||
|
- XCTest(iOS/Xcode 默认测试框架)
|
||||||
|
- **当前仓库状态**:包含 UI 测试 Target `ReadViewDemoUITests`,位于 `ReadViewDemo/ReadViewDemoUITests/`
|
||||||
|
|
||||||
|
**断言库:**
|
||||||
|
- XCTest 内建断言(`XCTAssert*`、`XCTAssertEqual`、`XCTAssertNotNil` 等)
|
||||||
|
- XCUIElement 断言(`exists`、`waitForExistence`、`isHittable` 等)
|
||||||
|
|
||||||
|
## 测试文件组织
|
||||||
|
|
||||||
|
**位置:**
|
||||||
|
- 测试文件:`ReadViewDemo/ReadViewDemoUITests/ReaderUITests/`
|
||||||
|
- 辅助文件:`ReadViewDemo/ReadViewDemoUITests/Helpers/`
|
||||||
|
|
||||||
|
**文件清单(24 个测试文件):**
|
||||||
|
|
||||||
|
| 测试文件 | 覆盖功能 |
|
||||||
|
|----------|----------|
|
||||||
|
| BookmarkTests.swift | 书签基础操作 |
|
||||||
|
| BookmarkManagementTests.swift | 书签管理(增删改查) |
|
||||||
|
| ConcurrentParsingTests.swift | 并发解析稳定性 |
|
||||||
|
| ConfigurableWindowTests.swift | 可配置章节窗口 |
|
||||||
|
| DisplayTypeTests.swift | 显示模式切换 |
|
||||||
|
| ErrorAndEdgeCaseTests.swift | 错误与边界情况 |
|
||||||
|
| FanrenParseTimeTest.swift | 《凡人修仙传》解析性能基准 |
|
||||||
|
| HighlightsManagementTests.swift | 高亮标注管理 |
|
||||||
|
| LargeBookOnDemandTests.swift | 大书按需加载 |
|
||||||
|
| LocationPersistenceTests.swift | 阅读位置持久化 |
|
||||||
|
| MetadataParseBenchmarkTests.swift | 元数据解析性能基准 |
|
||||||
|
| PageNavigationTests.swift | 页面导航 |
|
||||||
|
| ReaderAnnotationTests.swift | 标注基础功能 |
|
||||||
|
| ReaderAnnotationExtendedTests.swift | 标注扩展功能 |
|
||||||
|
| ReaderOpenCloseTests.swift | 阅读器打开/关闭 |
|
||||||
|
| ReaderToolbarTests.swift | 工具栏交互 |
|
||||||
|
| SearchTests.swift | 全文搜索 |
|
||||||
|
| SelectionMenuTests.swift | 选区菜单 |
|
||||||
|
| SettingsEffectTests.swift | 设置效果验证 |
|
||||||
|
| SettingsExtendedTests.swift | 设置扩展功能 |
|
||||||
|
| SettingsPanelTests.swift | 设置面板交互 |
|
||||||
|
| TableOfContentsTests.swift | 目录功能 |
|
||||||
|
| TOCInteractionTests.swift | 目录交互 |
|
||||||
|
| ToolbarStateTests.swift | 工具栏状态管理 |
|
||||||
|
|
||||||
|
**辅助文件(3 个):**
|
||||||
|
|
||||||
|
| 文件 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| AccessibilityIdentifiers.swift | UI 元素 accessibility identifier 定义 |
|
||||||
|
| DemoReaderState.swift | 测试用阅读器状态辅助 |
|
||||||
|
| XCUIApplication+Launch.swift | XCUIApplication 启动配置扩展 |
|
||||||
|
|
||||||
|
**统计:**
|
||||||
|
- 测试类:23 个(XCTestCase 子类)
|
||||||
|
- 测试方法:约 99 个(`func test...`)
|
||||||
|
- 辅助文件:3 个
|
||||||
|
|
||||||
|
## 如何运行
|
||||||
|
|
||||||
|
### CocoaPods 依赖准备
|
||||||
|
|
||||||
|
> 仓库包含示例工程 `ReadViewDemo`,并已提交 `Pods/` 与 `Podfile.lock`。如本地环境未同步,可在仓库根或示例工程目录运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pod install
|
||||||
|
```
|
||||||
|
|
||||||
|
(依赖入口:`Podfile`、`ReadViewDemo/Podfile`)
|
||||||
|
|
||||||
|
### 运行 UI 测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xcodebuild test \
|
||||||
|
-workspace ReadViewDemo/ReadViewDemo.xcworkspace \
|
||||||
|
-scheme ReadViewDemo \
|
||||||
|
-destination 'platform=iOS Simulator,name=iPhone 15' \
|
||||||
|
-only-testing:ReadViewDemoUITests
|
||||||
|
```
|
||||||
|
|
||||||
|
### 运行单个测试类
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xcodebuild test \
|
||||||
|
-workspace ReadViewDemo/ReadViewDemo.xcworkspace \
|
||||||
|
-scheme ReadViewDemo \
|
||||||
|
-destination 'platform=iOS Simulator,name=iPhone 15' \
|
||||||
|
-only-testing:ReadViewDemoUITests/SearchTests
|
||||||
|
```
|
||||||
|
|
||||||
|
### 运行脚本
|
||||||
|
|
||||||
|
项目提供了自动化回归脚本:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/run_ui_regression.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
测试报告输出到 `.artifacts/ui-tests/{timestamp}/UI-Test-Report.md`。
|
||||||
|
|
||||||
|
## 架构整改回归重点
|
||||||
|
|
||||||
|
在 2026-06-22 的阅读器架构整改后,以下场景应作为 smoke 回归最小集合:
|
||||||
|
|
||||||
|
- 打开大书后首次进入阅读页
|
||||||
|
- 章节尾页连续翻页,确认跨章节动画无明显卡顿
|
||||||
|
- 目录远跳到未预热章节,再返回当前阅读流
|
||||||
|
- 关闭并重新打开书籍,恢复上次阅读位置
|
||||||
|
- 长按选区、高亮、批注后再次翻页
|
||||||
|
- 打开设置面板调整字号/间距,观察预览与最终全量 repagination
|
||||||
|
|
||||||
|
建议同时记录以下观测项:
|
||||||
|
|
||||||
|
- `prepareOnDemandChapter` 主线程 wall clock
|
||||||
|
- `RDEpubReaderPreloadController` 预加载命中率
|
||||||
|
- `bookPageMap` partial extension / full replacement 次数
|
||||||
|
- 页面静态底图缓存命中率
|
||||||
|
- CFI 延迟构建完成次数与耗时
|
||||||
|
|
||||||
|
## 覆盖率(Coverage)
|
||||||
|
|
||||||
|
- 当前未启用代码覆盖率收集
|
||||||
|
- 若需启用,可在 Xcode Scheme → Test → Options 中勾选 "Code Coverage"
|
||||||
|
- 或通过 `xcodebuild test` 加 `-enableCodeCoverage YES` 参数
|
||||||
|
|
||||||
|
## Mocking / Fixtures
|
||||||
|
|
||||||
|
- 未使用统一 mocking 框架
|
||||||
|
- 测试通过 Demo App 加载真实 EPUB 文件进行端到端验证
|
||||||
|
- 辅助文件 `DemoReaderState.swift` 提供测试状态管理
|
||||||
|
|
||||||
|
## Evidence(关键证据文件)
|
||||||
|
|
||||||
|
- `ReadViewDemo/ReadViewDemo.xcodeproj/project.pbxproj`
|
||||||
|
- `ReadViewDemo/ReadViewDemoUITests/` 目录
|
||||||
|
- `ReadViewDemo/Podfile`
|
||||||
|
- `ReadViewDemo/Podfile.lock`
|
||||||
|
- `.artifacts/ui-tests/` 下的历史测试报告
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
# 排版管线详解
|
||||||
|
|
||||||
|
> 最后更新:2026-06-18
|
||||||
|
|
||||||
|
本文档详细描述 ReadViewSDK 文本排版管线(Typesetter Pipeline)的 8 个处理阶段、核心算法和数据流。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 概述
|
||||||
|
|
||||||
|
排版管线是 EPUBTextRendering 层的核心组件,负责将 EPUB 原始 HTML 转换为可分页的 NSAttributedString。它是 textReflowable 渲染路径(本 SDK 核心路径)的关键环节。
|
||||||
|
|
||||||
|
**入口**:`RDEPUBTextTypesetterPipeline.makeRequest(from:)`
|
||||||
|
|
||||||
|
**输入**:`RDEPUBTypesettingInput`(原始 HTML、样式、布局配置)
|
||||||
|
|
||||||
|
**输出**:`RDEPUBTypesettingOutput`(渲染请求、诊断信息、兼容性报告)
|
||||||
|
|
||||||
|
**关键文件**:`Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 管线总览
|
||||||
|
|
||||||
|
```
|
||||||
|
Raw HTML (从 EPUB 解压目录读取)
|
||||||
|
│
|
||||||
|
▼ [阶段 1] RDEPUBHTMLNormalizer
|
||||||
|
│ 去 CR、合并空行、规范化附件 HTML
|
||||||
|
│
|
||||||
|
▼ [阶段 2] RDEPUBSemanticMarkerInjector
|
||||||
|
│ 注入分页语义标记 ${rd-sem-start/end}
|
||||||
|
│
|
||||||
|
▼ [阶段 3] RDEPUBCFIMarkerInjector
|
||||||
|
│ 注入 CFI 路径标记
|
||||||
|
│
|
||||||
|
▼ [阶段 4] RDEPUBStyleSheetComposer
|
||||||
|
│ 内联 <link stylesheet>、构建 5 层 CSS、注入 <base href>
|
||||||
|
│
|
||||||
|
▼ [阶段 5] RDEPUBFontNormalizer
|
||||||
|
│ 解析 @font-face、注册嵌入字体
|
||||||
|
│
|
||||||
|
▼ [阶段 6] RDEPUBFragmentMarkerInjector
|
||||||
|
│ 注入 fragment 锚点标记 ${id=xxx}
|
||||||
|
│
|
||||||
|
▼ [阶段 7] RDEPUBRenderDiagnosticsCollector
|
||||||
|
│ 收集图片诊断、资源引用检查
|
||||||
|
│
|
||||||
|
▼ 构建 RDEPUBTextChapterRenderRequest
|
||||||
|
│
|
||||||
|
▼ [阶段 8] RDEPUBDTCoreTextRenderer
|
||||||
|
HTML → NSAttributedString → 后处理 → 分页
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 阶段 1:HTML 规范化(RDEPUBHTMLNormalizer)
|
||||||
|
|
||||||
|
**文件**:`RDEPUBHTMLNormalizer.swift`
|
||||||
|
|
||||||
|
### 3.1 处理内容
|
||||||
|
|
||||||
|
| 规则 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| CR → LF | 统一换行符 |
|
||||||
|
| 合并连续空行 | 多个空行合并为一个 |
|
||||||
|
| 删除分页标记 | `<hr lang="zh-CN">分页符</hr>` |
|
||||||
|
| 附件 HTML 规范化 | 见下表 |
|
||||||
|
|
||||||
|
### 3.2 附件 HTML 规范化规则
|
||||||
|
|
||||||
|
| 原始 HTML | 规范化结果 |
|
||||||
|
|-----------|-----------|
|
||||||
|
| `div.qrbodyPic / div.bodyPic` | 合并样式到 `<img>` |
|
||||||
|
| `img.qqreader-footnote` | 行内 1em×1em |
|
||||||
|
| `h1.frontCover > img` | 封面图片 100% 宽度 |
|
||||||
|
|
||||||
|
### 3.3 Base URL 注入
|
||||||
|
|
||||||
|
在 `<head>` 开头注入 `<base href="...">` 用于相对路径解析:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
static func injectBaseHref(into html: String, baseURL: URL?) -> String
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 阶段 2:语义标记注入(RDEPUBSemanticMarkerInjector)
|
||||||
|
|
||||||
|
**文件**:`RDEPUBSemanticMarkerInjector.swift`
|
||||||
|
|
||||||
|
### 4.1 标记语法
|
||||||
|
|
||||||
|
```html
|
||||||
|
${rd-sem-start:id=X;block=...;hints=...;placement=...}
|
||||||
|
...内容...
|
||||||
|
${rd-sem-end:id=X}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 注入规则
|
||||||
|
|
||||||
|
- 遍历所有 HTML 标签,维护开标签栈
|
||||||
|
- 为有分页语义的标签注入标记:
|
||||||
|
- `block`:块类型(paragraph、heading、list 等)
|
||||||
|
- `hints`:分页提示(avoidPageBreakInside、keepWithNext 等)
|
||||||
|
- `placement`:附件位置(inline、block)
|
||||||
|
- 空标签(img, br, hr)同时注入开始和结束标记
|
||||||
|
|
||||||
|
### 4.3 语义提示类型
|
||||||
|
|
||||||
|
| 提示 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `avoidPageBreakInside` | 避免在块内分页 |
|
||||||
|
| `keepWithNext` | 与下一个块保持同页 |
|
||||||
|
| `attachmentBlock` | 块级附件(图片等) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 阶段 3:CFI 标记注入(RDEPUBCFIMarkerInjector)
|
||||||
|
|
||||||
|
**文件**:`RDEPUBCFIMarkerInjector.swift`
|
||||||
|
|
||||||
|
注入 CFI 路径标记,用于后续 CFI 映射构建。在每个文本节点前注入 CFI 路径信息,使渲染后的 NSAttributedString 能够建立 CFI 路径到文本偏移量的映射。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 阶段 4:CSS 层合成(RDEPUBStyleSheetComposer)
|
||||||
|
|
||||||
|
**文件**:`RDEPUBStyleSheetComposer.swift`
|
||||||
|
|
||||||
|
### 6.1 五层 CSS 架构
|
||||||
|
|
||||||
|
按优先级从低到高:
|
||||||
|
|
||||||
|
| 层 | Kind | 说明 | 注入位置 |
|
||||||
|
|----|------|------|----------|
|
||||||
|
| 1 | `.default` | 基础阅读器样式(隐藏 head/title/style,默认字体大小) | `<head>` 开头 |
|
||||||
|
| 2 | `.replace` | 格式化样式(代码块、标题、引用、列表) | `<head>` 开头 |
|
||||||
|
| 3 | `.dark` | 暗色模式覆盖(仅暗色主题时注入) | `<head>` 开头 |
|
||||||
|
| 4 | `.epub` | EPUB 自带样式(内联后的 `<link stylesheet>`) | `<head>` 末尾 |
|
||||||
|
| 5 | `.user` | 用户设置(字号、行距、颜色,!important) | `<head>` 末尾 |
|
||||||
|
|
||||||
|
### 6.2 语言检测
|
||||||
|
|
||||||
|
```swift
|
||||||
|
static func prefersLatinLanguageCSS(
|
||||||
|
languageCode: String?,
|
||||||
|
sourceHTML: String
|
||||||
|
) -> Bool
|
||||||
|
```
|
||||||
|
|
||||||
|
检测逻辑:
|
||||||
|
1. 检查 `lang` 属性(如 `lang="en"`)
|
||||||
|
2. 对 HTML 文本采样,统计拉丁字符比例
|
||||||
|
3. 拉丁语言使用专用 CSS(`wxread-replace-latin.css`)
|
||||||
|
|
||||||
|
### 6.3 样式表内联
|
||||||
|
|
||||||
|
`RDEPUBRenderDiagnosticsCollector.inlineLinkedStyleSheets()` 负责:
|
||||||
|
1. 查找 `<link rel=stylesheet href=...>`
|
||||||
|
2. 读取 CSS 文件内容
|
||||||
|
3. 重写 CSS 中的相对 `url()` 引用
|
||||||
|
4. 内联到 HTML 的 `<head>` 中
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 阶段 5:字体注册(RDEPUBFontNormalizer)
|
||||||
|
|
||||||
|
**文件**:`RDEPUBFontNormalizer.swift`
|
||||||
|
|
||||||
|
### 7.1 处理流程
|
||||||
|
|
||||||
|
1. 解析 `@font-face { url(...) }` 块
|
||||||
|
2. 提取字体文件路径和 family 名称
|
||||||
|
3. 通过 `CTFontManagerRegisterFontsForURL(.process)` 注册嵌入字体
|
||||||
|
4. 已注册字体路径缓存在 `registeredFontPaths` 集合中,避免重复注册
|
||||||
|
|
||||||
|
### 7.2 注册结果
|
||||||
|
|
||||||
|
```swift
|
||||||
|
struct RDEPUBFontRegistrationResult {
|
||||||
|
let descriptor: RDEPUBFontDescriptor
|
||||||
|
let didRegister: Bool
|
||||||
|
let errorDescription: String?
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
注册失败的字体不会阻断管线,但会记录到兼容性报告中。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 阶段 6:Fragment 标记注入(RDEPUBFragmentMarkerInjector)
|
||||||
|
|
||||||
|
**文件**:`RDEPUBFragmentMarkerInjector.swift`
|
||||||
|
|
||||||
|
扫描 HTML 中的 `id` 属性,在其前面注入 `${id=xxx}` 标记:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<h2 id="section1">标题</h2>
|
||||||
|
→
|
||||||
|
${id=section1}<h2 id="section1">标题</h2>
|
||||||
|
```
|
||||||
|
|
||||||
|
渲染后这些标记会被转换为 NSAttributedString 属性,用于 fragment 偏移量提取。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 阶段 7:诊断收集(RDEPUBRenderDiagnosticsCollector)
|
||||||
|
|
||||||
|
**文件**:`RDEPUBRenderDiagnosticsCollector.swift`
|
||||||
|
|
||||||
|
### 9.1 图片诊断
|
||||||
|
|
||||||
|
- 扫描 `<img src="...">` 标签
|
||||||
|
- 解析引用路径,检查文件是否存在
|
||||||
|
- 收集诊断信息用于调试
|
||||||
|
|
||||||
|
### 9.2 资源引用检查
|
||||||
|
|
||||||
|
- 检查 CSS 中的 `url()` 引用
|
||||||
|
- 验证字体文件是否存在
|
||||||
|
- 记录缺失资源的诊断信息
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 阶段 8:渲染与后处理(RDEPUBDTCoreTextRenderer)
|
||||||
|
|
||||||
|
**文件**:`RDEPUBDTCoreTextRenderer.swift`
|
||||||
|
|
||||||
|
### 10.1 HTML → NSAttributedString
|
||||||
|
|
||||||
|
```swift
|
||||||
|
func renderChapter(request: RDEPUBTextChapterRenderRequest) throws -> RDEPUBRenderedChapterContent
|
||||||
|
```
|
||||||
|
|
||||||
|
1. 将 HTML 编码为 `Data`
|
||||||
|
2. 使用 `DTHTMLAttributedStringBuilder` 构建 `NSAttributedString`
|
||||||
|
3. 在 `willFlushCallback` 中对每个 DOM 元素调用 `RDEPUBAttachmentNormalizer.prepareHTMLElementForReaderRendering()`
|
||||||
|
|
||||||
|
### 10.2 后处理
|
||||||
|
|
||||||
|
**applyPaginationSemantics()**:
|
||||||
|
- 将 `${rd-sem-start/end}` 标记转为 NSAttributedString 属性
|
||||||
|
- 属性键:`.rdPageSemanticHints`、`.rdPageBlockKind`、`.rdPageAttachmentPlacement`
|
||||||
|
|
||||||
|
**extractFragmentOffsets()**:
|
||||||
|
- 提取 `${id=xxx}` 标记
|
||||||
|
- 生成 fragment ID → 字符偏移量映射
|
||||||
|
- 删除标记文本
|
||||||
|
|
||||||
|
**normalizeReadingAttributes()**:
|
||||||
|
- 规范化字体(应用用户选择的字体)
|
||||||
|
- 调整行距(应用 lineHeightMultiple)
|
||||||
|
- 设置文字颜色(应用主题颜色)
|
||||||
|
- 处理附件(图片缩放、对齐)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 分页计算
|
||||||
|
|
||||||
|
### 11.1 RDEPUBChapterPageCounter
|
||||||
|
|
||||||
|
**文件**:`Pagination/RDEPUBChapterPageCounter.swift`
|
||||||
|
|
||||||
|
使用 CoreText 迭代分页:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
func layoutFrames(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame]
|
||||||
|
```
|
||||||
|
|
||||||
|
**分页循环**:
|
||||||
|
|
||||||
|
```
|
||||||
|
location = 0
|
||||||
|
while location < totalLength:
|
||||||
|
1. 创建 CTFrame(通过 CTFramesetter)
|
||||||
|
2. 获取可见范围 (CTFrameGetVisibleStringRange)
|
||||||
|
3. 应用 avoidPageBreakInside 规则(最多移除 3 行尾部)
|
||||||
|
4. 应用 keepWithNext 规则(最多移除 3 行尾部)
|
||||||
|
5. 应用 widow/orphan 控制
|
||||||
|
6. 应用 pageBreakPolicy 调整
|
||||||
|
7. 记录页面范围
|
||||||
|
8. location = 调整后的范围末尾
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.2 RDEPUBPageBreakPolicy
|
||||||
|
|
||||||
|
**文件**:`Pagination/RDEPUBPageBreakPolicy.swift`
|
||||||
|
|
||||||
|
分页规则优先级:
|
||||||
|
|
||||||
|
| 规则 | 说明 | 最大调整行数 |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `avoidPageBreakInside` | 块内不分页(标题、图片等) | 3 行 |
|
||||||
|
| `keepWithNext` | 标题与正文不分离 | 3 行 |
|
||||||
|
| widow control | 段落最后一行不留到下一页 | 1 行 |
|
||||||
|
| orphan control | 段落第一行不单独在上一页 | 1 行 |
|
||||||
|
| attachment boundary | 块级图片前后分页 | 0(精确切分) |
|
||||||
|
|
||||||
|
**判断方法**:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
func lineIsInAvoidPageBreakInsideBlock(_ lineRange: NSRange) -> Bool
|
||||||
|
func lineIsInKeepWithNextBlock(_ lineRange: NSRange) -> Bool
|
||||||
|
func adjustedRange(from:totalLength:lineRanges:factory:) -> (range, breakReason, ...)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.3 RDEPUBChapterTailNormalizer
|
||||||
|
|
||||||
|
**文件**:`BuildPipeline/RDEPUBChapterTailNormalizer.swift`
|
||||||
|
|
||||||
|
三遍处理:
|
||||||
|
|
||||||
|
1. **删除空白中间帧**:无可见字符且无附件的帧
|
||||||
|
2. **删除空白尾部帧**:从末尾开始删除同类空白帧
|
||||||
|
3. **合并短尾帧**:如果最后一帧 ≤2 个可见字符且前一帧 ≥8 倍长,合并
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 诊断与调试
|
||||||
|
|
||||||
|
### 12.1 兼容性报告
|
||||||
|
|
||||||
|
```swift
|
||||||
|
struct RDEPUBCSSCompatibilityReport {
|
||||||
|
let unsupportedRules: [String] // 不支持的 CSS 规则
|
||||||
|
let normalizedRules: [String] // 已规范化的规则
|
||||||
|
let fontFailures: [String] // 字体注册失败
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 12.2 资源诊断
|
||||||
|
|
||||||
|
```swift
|
||||||
|
struct RDEPUBTextResourceReferenceDiagnostic {
|
||||||
|
let href: String // 引用路径
|
||||||
|
let exists: Bool // 文件是否存在
|
||||||
|
let type: String // 资源类型(image/font/stylesheet)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 12.3 语义摘要
|
||||||
|
|
||||||
|
`RDEPUBReaderController.nativeTextSemanticSummary()` 返回当前页面的语义摘要,包含:
|
||||||
|
- 页码
|
||||||
|
- 分页原因(breakReason)
|
||||||
|
- 块类型(blockKinds)
|
||||||
|
- 语义提示(semanticHints)
|
||||||
|
- 附件位置(attachmentPlacements)
|
||||||
@@ -0,0 +1,734 @@
|
|||||||
|
# ReadViewSDK UML 类图
|
||||||
|
|
||||||
|
> 最后更新:2026-06-09
|
||||||
|
> 图表格式:Mermaid(可在 GitHub / VS Code / Mermaid Live Editor 中渲染)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 整体模块关系
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph EPUBCore
|
||||||
|
Parser[RDEPUBParser]
|
||||||
|
Pub[RDEPUBPublication]
|
||||||
|
Resolver[RDEPUBResourceResolver]
|
||||||
|
Session[RDEPUBReadingSession]
|
||||||
|
WebView[RDEPUBWebView]
|
||||||
|
Paginator[RDEPUBPaginator]
|
||||||
|
SearchEngine[RDEPUBHTMLSearchEngine]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph EPUBTextRendering
|
||||||
|
Builder[RDEPUBTextBookBuilder]
|
||||||
|
Renderer[RDEPUBDTCoreTextRenderer]
|
||||||
|
Pipeline[RDEPUBTextTypesetterPipeline]
|
||||||
|
Counter[RDEPUBChapterPageCounter]
|
||||||
|
FrameFactory[RDEPUBCoreTextPageFrameFactory]
|
||||||
|
BreakPolicy[RDEPUBPageBreakPolicy]
|
||||||
|
BookCache[RDEPUBTextBookCache]
|
||||||
|
ChapterData[RDEPUBChapterData]
|
||||||
|
IndexTable[RDEPUBTextIndexTable]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph RDEpubReaderView
|
||||||
|
ReaderView[RDEpubReaderView]
|
||||||
|
FlowLayout[RDEpubReaderFlowLayout]
|
||||||
|
Preload[RDEpubReaderPreloadController]
|
||||||
|
Spread[RDEpubReaderSpreadResolver]
|
||||||
|
TapRegion[RDEpubReaderTapRegionHandler]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph EPUBUI
|
||||||
|
Controller[RDEPUBReaderController]
|
||||||
|
Context[RDEPUBReaderContext]
|
||||||
|
Runtime[RDEPUBReaderRuntime]
|
||||||
|
PaginationCoord[RDEPUBReaderPaginationCoordinator]
|
||||||
|
Loader[RDEPUBChapterLoader]
|
||||||
|
RuntimeStore[RDEPUBChapterRuntimeStore]
|
||||||
|
DiskCache[RDEPUBChapterSummaryDiskCache]
|
||||||
|
PageMap[RDEPUBBookPageMap]
|
||||||
|
Config[RDEPUBReaderConfiguration]
|
||||||
|
end
|
||||||
|
|
||||||
|
Parser --> Pub
|
||||||
|
Pub --> Resolver
|
||||||
|
Session --> Pub
|
||||||
|
WebView --> Pub
|
||||||
|
WebView --> Resolver
|
||||||
|
Paginator --> Parser
|
||||||
|
|
||||||
|
Builder --> Renderer
|
||||||
|
Builder --> Pipeline
|
||||||
|
Builder --> Counter
|
||||||
|
Counter --> FrameFactory
|
||||||
|
FrameFactory --> BreakPolicy
|
||||||
|
Builder --> BookCache
|
||||||
|
ChapterData --> IndexTable
|
||||||
|
|
||||||
|
Controller --> Context
|
||||||
|
Context --> Runtime
|
||||||
|
Runtime --> Loader
|
||||||
|
Runtime --> RuntimeStore
|
||||||
|
Runtime --> DiskCache
|
||||||
|
PaginationCoord --> Context
|
||||||
|
PaginationCoord --> PageMap
|
||||||
|
Loader --> RuntimeStore
|
||||||
|
Loader --> DiskCache
|
||||||
|
|
||||||
|
Controller --> ReaderView
|
||||||
|
Controller --> Session
|
||||||
|
Controller --> Builder
|
||||||
|
ReaderView --> FlowLayout
|
||||||
|
ReaderView --> Preload
|
||||||
|
ReaderView --> Spread
|
||||||
|
ReaderView --> TapRegion
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. EPUBCore 核心类图
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
class RDEPUBParser {
|
||||||
|
+metadata: RDEPUBMetadata
|
||||||
|
+manifest: [String: RDEPUBManifestItem]
|
||||||
|
+spine: [RDEPUBSpineItem]
|
||||||
|
+tableOfContents: [EPUBTableOfContentsItem]
|
||||||
|
+extractionRootURL: URL
|
||||||
|
+opfURL: URL
|
||||||
|
+opfDirectoryURL: URL
|
||||||
|
+parse(epubURL: URL) throws
|
||||||
|
+makePublication() RDEPUBPublication
|
||||||
|
+htmlString(forRelativePath:) String?
|
||||||
|
+htmlString(forSpineIndex:) String?
|
||||||
|
+coverImage() UIImage?
|
||||||
|
+resourceURL(forRelativePath:) URL
|
||||||
|
+fileURL(forRelativePath:) URL?
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBPublication {
|
||||||
|
+parser: RDEPUBParser
|
||||||
|
+resourceResolver: RDEPUBResourceResolver
|
||||||
|
+metadata: RDEPUBMetadata
|
||||||
|
+manifest: [RDEPUBManifestItem]
|
||||||
|
+spine: [RDEPUBSpineItem]
|
||||||
|
+tableOfContents: [EPUBTableOfContentsItem]
|
||||||
|
+layout: RDEPUBLayout
|
||||||
|
+readingProfile: RDEPUBReadingProfile
|
||||||
|
+readingProgression: RDEPUBReadingProgression
|
||||||
|
+bookIdentifier: String?
|
||||||
|
+fixedLayoutSpreadEnabled(preferences:viewportSize:) Bool
|
||||||
|
+makeFixedSpreads(preferences:viewportSize:) [EPUBFixedSpread]
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBResourceResolver {
|
||||||
|
+parser: RDEPUBParser
|
||||||
|
+fileURL(forRelativePath:) URL?
|
||||||
|
+resourceURL(forRelativePath:) URL
|
||||||
|
+normalizedHref(_:relativeToSpineIndex:) String?
|
||||||
|
+normalizedLocation(_:relativeToSpineIndex:bookIdentifier:) RDEPUBLocation?
|
||||||
|
+spineIndex(forNormalizedHref:) Int?
|
||||||
|
+spineIndex(for:) Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBReadingSession {
|
||||||
|
+publication: RDEPUBPublication
|
||||||
|
+navigatorState: RDEPUBNavigatorState
|
||||||
|
+activePages: [EPUBPage]
|
||||||
|
+activeChapters: [EPUBChapterInfo]
|
||||||
|
+currentViewport: RDEPUBViewport?
|
||||||
|
+currentReadingContext: RDEPUBReadingContext?
|
||||||
|
+transition(to: RDEPUBNavigatorState)
|
||||||
|
+setActiveSnapshot(_:)
|
||||||
|
+queueNavigation(to:relativeToSpineIndex:bookIdentifier:) Int
|
||||||
|
+updateReadingContext(...)
|
||||||
|
+makePaginationSnapshot(pageCounts:preferences:layoutContext:)
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBWebView {
|
||||||
|
+publication: RDEPUBPublication?
|
||||||
|
+currentRenderRequest: RDEPUBRenderRequest?
|
||||||
|
+webView: WKWebView
|
||||||
|
+delegate: RDEPUBWebViewDelegate?
|
||||||
|
+load(publication:request:)
|
||||||
|
+reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBNavigatorState {
|
||||||
|
<<enum>>
|
||||||
|
initializing
|
||||||
|
loading
|
||||||
|
idle
|
||||||
|
jumping
|
||||||
|
moving
|
||||||
|
repaginating
|
||||||
|
+isStableForSnapshotApplication: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
RDEPUBParser --> RDEPUBPublication : creates
|
||||||
|
RDEPUBPublication --> RDEPUBResourceResolver : owns
|
||||||
|
RDEPUBReadingSession --> RDEPUBPublication : wraps
|
||||||
|
RDEPUBReadingSession --> RDEPUBNavigatorState : manages
|
||||||
|
RDEPUBWebView --> RDEPUBPublication : uses
|
||||||
|
RDEPUBWebView --> RDEPUBResourceResolver : uses
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. EPUBTextRendering 类图
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
class RDEPUBTextBookBuilder {
|
||||||
|
+renderer: RDEPUBTextRenderer
|
||||||
|
+cache: RDEPUBTextBookCache?
|
||||||
|
+layoutConfig: RDEPUBTextLayoutConfig
|
||||||
|
+renderPipeline: RDEPUBChapterRenderPipeline
|
||||||
|
+paginationPipeline: RDEPUBChapterPaginationPipeline
|
||||||
|
+tailNormalizer: RDEPUBChapterTailNormalizer
|
||||||
|
+build(parser:publication:pageSize:style:) throws RDEPUBTextBook
|
||||||
|
+buildChapter(parser:publication:spineIndex:pageSize:style:) throws RDEPUBTextChapterBuildResult?
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBTextRenderer {
|
||||||
|
<<protocol>>
|
||||||
|
+renderChapter(request:) throws RDEPUBRenderedChapterContent
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBDTCoreTextRenderer {
|
||||||
|
+renderChapter(request:) throws RDEPUBRenderedChapterContent
|
||||||
|
+renderChapter(html:baseURL:style:) throws RDEPUBRenderedChapterContent
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBTextTypesetterPipeline {
|
||||||
|
+makeRequest(from: RDEPUBTypesettingInput) RDEPUBTypesettingOutput
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBChapterPageCounter {
|
||||||
|
+factory: RDEPUBCoreTextPageFrameFactory
|
||||||
|
+layoutFrames(fragmentOffsets:) [RDEPUBTextLayoutFrame]
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBCoreTextPageFrameFactory {
|
||||||
|
+attributedString: NSAttributedString
|
||||||
|
+pageSize: CGSize
|
||||||
|
+config: RDEPUBTextLayoutConfig
|
||||||
|
+makeFrames(...) [RDEPUBTextLayoutFrame]
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBPageBreakPolicy {
|
||||||
|
+lineIsInAvoidPageBreakInsideBlock(_:) Bool
|
||||||
|
+lineIsInKeepWithNextBlock(_:) Bool
|
||||||
|
+adjustedRange(from:totalLength:lineRanges:factory:) RDEPUBPageBreakDecision
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBTextBook {
|
||||||
|
+chapters: [RDEPUBTextChapter]
|
||||||
|
+pages: [RDEPUBTextPage]
|
||||||
|
+indexTable: RDEPUBTextIndexTable
|
||||||
|
+positionConverter: RDEPUBTextPositionConverter
|
||||||
|
+chapterData(for href:) RDEPUBChapterData?
|
||||||
|
+chapterData(forPageNumber:) RDEPUBChapterData?
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBTextChapter {
|
||||||
|
+chapterIndex: Int
|
||||||
|
+spineIndex: Int
|
||||||
|
+href: String
|
||||||
|
+title: String
|
||||||
|
+attributedContent: NSAttributedString
|
||||||
|
+fragmentOffsets: [String: Int]
|
||||||
|
+pages: [RDEPUBTextPage]
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBTextPage {
|
||||||
|
+absolutePageIndex: Int
|
||||||
|
+chapterIndex: Int
|
||||||
|
+spineIndex: Int
|
||||||
|
+contentRange: NSRange
|
||||||
|
+metadata: RDEPUBTextPageMetadata
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBTextBookCache {
|
||||||
|
+schemaVersion: Int
|
||||||
|
+load(key:) [String: RDEPUBTextChapterPaginationCache]?
|
||||||
|
+save(_:key:)
|
||||||
|
+invalidateAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBChapterData {
|
||||||
|
+chapterIndex: Int
|
||||||
|
+spineIndex: Int
|
||||||
|
+href: String
|
||||||
|
+title: String
|
||||||
|
+pageCount: Int
|
||||||
|
+page(containing:) RDEPUBTextPage?
|
||||||
|
+pageNumber(containing:) Int?
|
||||||
|
+absoluteRange(for location:) ClosedRange<Int>?
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBTextIndexTable {
|
||||||
|
+chapterStartOffsets: [Int]
|
||||||
|
+totalCharacterCount: Int
|
||||||
|
+anchor(forAbsoluteIndex:in:) RDEPUBTextAnchor
|
||||||
|
+pageNumber(for:in:) Int
|
||||||
|
+globalIndex(for:) Int
|
||||||
|
}
|
||||||
|
|
||||||
|
RDEPUBTextBookBuilder --> RDEPUBTextRenderer : uses
|
||||||
|
RDEPUBDTCoreTextRenderer ..|> RDEPUBTextRenderer
|
||||||
|
RDEPUBTextBookBuilder --> RDEPUBTextTypesetterPipeline : uses
|
||||||
|
RDEPUBTextBookBuilder --> RDEPUBChapterPageCounter : uses
|
||||||
|
RDEPUBChapterPageCounter --> RDEPUBCoreTextPageFrameFactory : uses
|
||||||
|
RDEPUBCoreTextPageFrameFactory --> RDEPUBPageBreakPolicy : uses
|
||||||
|
RDEPUBTextBookBuilder --> RDEPUBTextBookCache : uses
|
||||||
|
RDEPUBTextBook --> RDEPUBTextChapter : contains
|
||||||
|
RDEPUBTextChapter --> RDEPUBTextPage : contains
|
||||||
|
RDEPUBTextBook --> RDEPUBTextIndexTable : computes
|
||||||
|
RDEPUBChapterData --> RDEPUBTextChapter : wraps
|
||||||
|
RDEPUBChapterData --> RDEPUBTextIndexTable : uses
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. ReaderView 类图
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
class RDEpubReaderView {
|
||||||
|
+currentPage: Int
|
||||||
|
+currentDisplayType: DisplayType
|
||||||
|
+pageProvider: RDEpubReaderPageProvider?
|
||||||
|
+delegate: RDEpubReaderDelegate?
|
||||||
|
+landscapeDualPageEnabled: Bool
|
||||||
|
+pageDirection: PageDirection
|
||||||
|
+coverPageIndex: Int?
|
||||||
|
+pagesPerScreen: Int
|
||||||
|
+transitionToPage(pageNum:animated:)
|
||||||
|
+reloadData()
|
||||||
|
+switchReaderDisplayType(_:)
|
||||||
|
}
|
||||||
|
|
||||||
|
class DisplayType {
|
||||||
|
<<enum>>
|
||||||
|
pageCurl
|
||||||
|
horizontalScroll
|
||||||
|
verticalScroll
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEpubReaderPageProvider {
|
||||||
|
<<protocol>>
|
||||||
|
+numberOfPages(in:) Int
|
||||||
|
+readerView(_:viewForPageAt:reusableView:) UIView
|
||||||
|
+pageIdentifier(in:index:) String?
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEpubReaderFlowLayout {
|
||||||
|
+displayType: DisplayType
|
||||||
|
+isLandscapeDualPage: Bool
|
||||||
|
+coverPageIndex: Int?
|
||||||
|
+pagesPerScreen: Int
|
||||||
|
+currentPage: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEpubReaderPreloadController {
|
||||||
|
+radius: Int
|
||||||
|
+pageViewForDisplay(pageNum:environment:contentViewProvider:) UIView?
|
||||||
|
+takePreloadedView(for:) UIView?
|
||||||
|
+prime(around:preferredForward:parentView:environment:contentViewProvider:)
|
||||||
|
+invalidate(environment:)
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEpubReaderSpreadResolver {
|
||||||
|
+isFullScreenPage(...) Bool
|
||||||
|
+dualPagePair(for:totalPages:coverPageIndex:) (Int, Int?)
|
||||||
|
+nextPage(from:totalPages:pagesPerScreen:coverPageIndex:forward:) Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEpubReaderTapRegionHandler {
|
||||||
|
+resolveTapEvent(point:viewFrame:isToolViewVisible:) TapEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
class TapEvent {
|
||||||
|
<<enum>>
|
||||||
|
none
|
||||||
|
left
|
||||||
|
center
|
||||||
|
right
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEpubReaderPagingController {
|
||||||
|
+isTransitioning: Bool
|
||||||
|
+didBuildUI: Bool
|
||||||
|
+pendingTransitionRequest: PageTransitionRequest?
|
||||||
|
+shouldQueuePageTransition(_:currentDisplayType:) Bool
|
||||||
|
+finishPageCurlTransition() PageTransitionRequest?
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEpubReaderContentCell {
|
||||||
|
+containerView: UIView?
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEpubReaderPageChildViewController {
|
||||||
|
+contentView: UIView?
|
||||||
|
+pageNum: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
RDEpubReaderView --> DisplayType : uses
|
||||||
|
RDEpubReaderView --> RDEpubReaderPageProvider : delegates
|
||||||
|
RDEpubReaderView --> RDEpubReaderFlowLayout : owns
|
||||||
|
RDEpubReaderView --> RDEpubReaderPreloadController : owns
|
||||||
|
RDEpubReaderView --> RDEpubReaderSpreadResolver : owns
|
||||||
|
RDEpubReaderView --> RDEpubReaderTapRegionHandler : owns
|
||||||
|
RDEpubReaderView --> RDEpubReaderPagingController : owns
|
||||||
|
RDEpubReaderView --> RDEpubReaderContentCell : creates
|
||||||
|
RDEpubReaderView --> RDEpubReaderPageChildViewController : creates
|
||||||
|
RDEpubReaderTapRegionHandler --> TapEvent : produces
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. EPUBUI 控制器与运行时类图
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
class RDEPUBReaderController {
|
||||||
|
+delegate: RDEPUBReaderDelegate?
|
||||||
|
+configuration: RDEPUBReaderConfiguration
|
||||||
|
+currentLocation: RDEPUBLocation?
|
||||||
|
+currentPageNumber: Int?
|
||||||
|
+currentSelection: RDEPUBSelection?
|
||||||
|
+highlights: [RDEPUBHighlight]
|
||||||
|
+bookmarks: [RDEPUBBookmark]
|
||||||
|
+tableOfContents: [EPUBTableOfContentsItem]
|
||||||
|
+readerView: RDEpubReaderView
|
||||||
|
+readerContext: RDEPUBReaderContext
|
||||||
|
+runtime: RDEPUBReaderRuntime?
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBReaderContext {
|
||||||
|
+parser: RDEPUBParser?
|
||||||
|
+publication: RDEPUBPublication?
|
||||||
|
+readingSession: RDEPUBReadingSession?
|
||||||
|
+textBook: RDEPUBTextBook?
|
||||||
|
+bookPageMap: RDEPUBBookPageMap?
|
||||||
|
+configuration: RDEPUBReaderConfiguration
|
||||||
|
+persistence: RDEPUBReaderPersistence?
|
||||||
|
+currentRenderSignature() String
|
||||||
|
+chapterCacheKey(forSpineIndex:) RDEPUBChapterCacheKey
|
||||||
|
+chapterCacheKey(forSpineIndex:precomputedContentHash:renderSignature:) RDEPUBChapterCacheKey
|
||||||
|
+makeTextBookBuilder(layoutConfig:) RDEPUBTextBookBuilder
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBReaderRuntime {
|
||||||
|
+chapterLoader: RDEPUBChapterLoader
|
||||||
|
+chapterRuntimeStore: RDEPUBChapterRuntimeStore
|
||||||
|
+summaryDiskCache: RDEPUBChapterSummaryDiskCache
|
||||||
|
+pageResolver: RDEPUBPageResolver
|
||||||
|
+loadCoordinator: RDEPUBReaderLoadCoordinator
|
||||||
|
+paginationCoordinator: RDEPUBReaderPaginationCoordinator
|
||||||
|
+locationCoordinator: RDEPUBReaderLocationCoordinator
|
||||||
|
+searchCoordinator: RDEPUBReaderSearchCoordinator
|
||||||
|
+chromeCoordinator: RDEPUBReaderChromeCoordinator
|
||||||
|
+annotationCoordinator: RDEPUBReaderAnnotationCoordinator
|
||||||
|
+viewportMonitor: RDEPUBReaderViewportMonitor
|
||||||
|
+applyBookPageMap(_:restoreLocation:)
|
||||||
|
+refreshBookPageMapInPlace(_:)
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBReaderPaginationCoordinator {
|
||||||
|
-context: RDEPUBReaderContext
|
||||||
|
+paginatePublication(restoreLocation:)
|
||||||
|
+repaginatePreservingCurrentLocation()
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBMetadataParseWorker {
|
||||||
|
-context: RDEPUBReaderContext
|
||||||
|
-cancellationController: RDEPUBMetadataParseCancellationController
|
||||||
|
+start(token:restoreLocation:)
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBMetadataParseCancellationController {
|
||||||
|
+token: UUID
|
||||||
|
+attach(queue:)
|
||||||
|
+cancel()
|
||||||
|
+isCancelled: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBReaderLoadCoordinator {
|
||||||
|
-context: RDEPUBReaderContext
|
||||||
|
+startInitialLoadIfNeeded()
|
||||||
|
-loadPublication()
|
||||||
|
-applyParsedPublication()
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBReaderSearchCoordinator {
|
||||||
|
-context: RDEPUBReaderContext
|
||||||
|
+search(keyword:)
|
||||||
|
+goToNextMatch()
|
||||||
|
+goToPreviousMatch()
|
||||||
|
+clearSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBReaderAnnotationCoordinator {
|
||||||
|
-context: RDEPUBReaderContext
|
||||||
|
+addHighlight(from:)
|
||||||
|
+removeHighlight(id:)
|
||||||
|
+toggleBookmark(at:)
|
||||||
|
+handleSelectionMenuAction(_:)
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBChapterLoader {
|
||||||
|
-context: RDEPUBReaderContext
|
||||||
|
+loadChapter(spineIndex:store:) async throws RDEPUBRuntimeChapter
|
||||||
|
+loadChapterSynchronouslyForMigration(spineIndex:store:) throws RDEPUBRuntimeChapter
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBChapterRuntimeStore {
|
||||||
|
+chapterDataCache: [Int: RDEPUBRuntimeChapter]
|
||||||
|
+pageCountCache: RDEPUBPageCountCache
|
||||||
|
+imageCache: NSCache
|
||||||
|
+setCurrentChapter(spineIndex:totalSpineCount:windowRadius:)
|
||||||
|
+chapterData(for:) RDEPUBRuntimeChapter?
|
||||||
|
+pageCount(for:) RDEPUBRuntimePageCount?
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBChapterSummaryDiskCache {
|
||||||
|
+cacheDirectory: URL
|
||||||
|
+read(for:) RDEPUBChapterSummary?
|
||||||
|
+readAll(keys:) ([Int: RDEPUBChapterSummary], RDEPUBBookPageMap.Builder)
|
||||||
|
+write(summary:for:)
|
||||||
|
+flushPendingWrites()
|
||||||
|
+isCacheComplete(keys:) Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBBookPageMap {
|
||||||
|
+totalChapters: Int
|
||||||
|
+totalPages: Int
|
||||||
|
+chapters: [EPUBChapterInfo]
|
||||||
|
+pages: [EPUBPage]
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBChapterCacheKey {
|
||||||
|
+bookID: String
|
||||||
|
+spineIndex: Int
|
||||||
|
+renderSignature: String
|
||||||
|
+chapterContentHash: String
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBReaderConfiguration {
|
||||||
|
+fontSize: CGFloat
|
||||||
|
+fontChoice: FontChoice
|
||||||
|
+lineHeightMultiple: CGFloat
|
||||||
|
+numberOfColumns: Int
|
||||||
|
+theme: ReaderTheme
|
||||||
|
+displayType: DisplayType
|
||||||
|
+onDemandChapterWindowSize: Int
|
||||||
|
+metadataParsingConcurrency: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
RDEPUBReaderController --> RDEPUBReaderContext : owns
|
||||||
|
RDEPUBReaderController --> RDEPUBReaderRuntime : owns
|
||||||
|
RDEPUBReaderController --> RDEPUBReaderPaginationCoordinator : owns
|
||||||
|
RDEPUBReaderContext --> RDEPUBReaderConfiguration : holds
|
||||||
|
RDEPUBReaderRuntime --> RDEPUBChapterLoader : owns
|
||||||
|
RDEPUBReaderRuntime --> RDEPUBChapterRuntimeStore : owns
|
||||||
|
RDEPUBReaderRuntime --> RDEPUBChapterSummaryDiskCache : owns
|
||||||
|
RDEPUBReaderRuntime --> RDEPUBReaderLoadCoordinator : owns
|
||||||
|
RDEPUBReaderRuntime --> RDEPUBReaderSearchCoordinator : owns
|
||||||
|
RDEPUBReaderRuntime --> RDEPUBReaderAnnotationCoordinator : owns
|
||||||
|
RDEPUBReaderPaginationCoordinator --> RDEPUBReaderContext : uses
|
||||||
|
RDEPUBChapterLoader --> RDEPUBReaderContext : uses
|
||||||
|
RDEPUBChapterLoader --> RDEPUBChapterRuntimeStore : uses
|
||||||
|
RDEPUBChapterLoader --> RDEPUBChapterSummaryDiskCache : uses
|
||||||
|
RDEPUBChapterSummaryDiskCache --> RDEPUBChapterCacheKey : uses
|
||||||
|
RDEPUBBookPageMap --> EPUBChapterInfo : contains
|
||||||
|
RDEPUBBookPageMap --> EPUBPage : contains
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 数据模型关系图
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
class RDEPUBLocation {
|
||||||
|
+bookIdentifier: String?
|
||||||
|
+href: String
|
||||||
|
+progression: Double
|
||||||
|
+lastProgression: Double?
|
||||||
|
+fragment: String?
|
||||||
|
+rangeAnchor: RDEPUBTextRangeAnchor?
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBTextAnchor {
|
||||||
|
+fileIndex: Int
|
||||||
|
+row: Int
|
||||||
|
+column: Int
|
||||||
|
+chapterOffset: Int
|
||||||
|
+fragmentID: String?
|
||||||
|
+spineIndex: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBTextRangeAnchor {
|
||||||
|
+start: RDEPUBTextAnchor
|
||||||
|
+end: RDEPUBTextAnchor
|
||||||
|
+nsRange: NSRange
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBSelection {
|
||||||
|
+bookIdentifier: String?
|
||||||
|
+location: RDEPUBLocation
|
||||||
|
+text: String
|
||||||
|
+rangeInfo: String?
|
||||||
|
+createdAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBHighlight {
|
||||||
|
+id: String
|
||||||
|
+bookIdentifier: String?
|
||||||
|
+location: RDEPUBLocation
|
||||||
|
+text: String
|
||||||
|
+style: RDEPUBHighlightStyle
|
||||||
|
+color: String
|
||||||
|
+note: String?
|
||||||
|
+createdAt: Date
|
||||||
|
+uiColor: UIColor
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBBookmark {
|
||||||
|
+id: String
|
||||||
|
+bookIdentifier: String?
|
||||||
|
+location: RDEPUBLocation
|
||||||
|
+chapterTitle: String?
|
||||||
|
+note: String?
|
||||||
|
+createdAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBAnnotation {
|
||||||
|
+id: String
|
||||||
|
+bookIdentifier: String?
|
||||||
|
+kind: RDEPUBAnnotationKind
|
||||||
|
+location: RDEPUBLocation
|
||||||
|
+text: String?
|
||||||
|
+rangeInfo: String?
|
||||||
|
+chapterTitle: String?
|
||||||
|
+createdAt: Date
|
||||||
|
+bookmark: RDEPUBBookmark?
|
||||||
|
+highlight: RDEPUBHighlight?
|
||||||
|
}
|
||||||
|
|
||||||
|
class EPUBPage {
|
||||||
|
+spineIndex: Int
|
||||||
|
+chapterIndex: Int
|
||||||
|
+pageIndexInChapter: Int
|
||||||
|
+totalPagesInChapter: Int
|
||||||
|
+chapterTitle: String
|
||||||
|
+fixedSpread: EPUBFixedSpread?
|
||||||
|
}
|
||||||
|
|
||||||
|
class EPUBChapterInfo {
|
||||||
|
+spineIndex: Int
|
||||||
|
+title: String
|
||||||
|
+pageCount: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBViewport {
|
||||||
|
+resources: [RDEPUBViewportResource]
|
||||||
|
+visiblePageNumber: Int
|
||||||
|
+chapterIndex: Int?
|
||||||
|
+isFixedLayout: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBReadingContext {
|
||||||
|
+location: RDEPUBLocation
|
||||||
|
+viewport: RDEPUBViewport
|
||||||
|
+pageNumber: Int
|
||||||
|
+chapterIndex: Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
RDEPUBLocation --> RDEPUBTextRangeAnchor : optional
|
||||||
|
RDEPUBTextRangeAnchor --> RDEPUBTextAnchor : start/end
|
||||||
|
RDEPUBSelection --> RDEPUBLocation : references
|
||||||
|
RDEPUBHighlight --> RDEPUBLocation : references
|
||||||
|
RDEPUBBookmark --> RDEPUBLocation : references
|
||||||
|
RDEPUBAnnotation --> RDEPUBLocation : references
|
||||||
|
RDEPUBReadingContext --> RDEPUBLocation : holds
|
||||||
|
RDEPUBReadingContext --> RDEPUBViewport : holds
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 后台解析并发模型
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Main as Main Thread
|
||||||
|
participant BG as Background Queue
|
||||||
|
participant OQ as OperationQueue (N workers)
|
||||||
|
participant Disk as Disk Cache
|
||||||
|
|
||||||
|
Main->>BG: RDEPUBMetadataParseWorker.start(token)
|
||||||
|
BG->>BG: 预计算 contentHash (串行)
|
||||||
|
BG->>Disk: readAll(catalog) 批量读取缓存
|
||||||
|
BG->>Main: refreshBookPageMapInPlace (缓存部分)
|
||||||
|
BG->>BG: waitForReadingInteractionToSettle (0.8s)
|
||||||
|
|
||||||
|
loop 每个未缓存章节
|
||||||
|
BG->>OQ: addOperation(spineIndex)
|
||||||
|
OQ->>OQ: buildChapter (渲染+分页)
|
||||||
|
OQ->>OQ: chapterCacheKey (复用预计算hash)
|
||||||
|
OQ->>Disk: write(summary) 异步写盘
|
||||||
|
OQ->>OQ: resultLock: 累加结果
|
||||||
|
alt 每32章或最后一章
|
||||||
|
OQ->>Main: refreshBookPageMapInPlace
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
OQ->>BG: waitUntilAllOperationsAreFinished
|
||||||
|
BG->>Disk: flushPendingWrites
|
||||||
|
BG->>Main: refreshBookPageMapInPlace (最终)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 缓存键与失效策略
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
subgraph CacheKey["RDEPUBChapterCacheKey"]
|
||||||
|
A[bookID]
|
||||||
|
B[spineIndex]
|
||||||
|
C[renderSignature]
|
||||||
|
D[chapterContentHash]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph RenderSignature["renderSignature 组成"]
|
||||||
|
E[fontName]
|
||||||
|
F[fontSize]
|
||||||
|
G[lineHeightMultiple]
|
||||||
|
H[lineSpacing]
|
||||||
|
I[layoutConfig.cacheSignature]
|
||||||
|
J[schemaVersion]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph CacheSignature["layoutConfig.cacheSignature"]
|
||||||
|
K[frameWidth]
|
||||||
|
L[frameHeight]
|
||||||
|
M[edgeInsets]
|
||||||
|
N[numberOfColumns]
|
||||||
|
O[columnGap]
|
||||||
|
P[avoidOrphans/Widows]
|
||||||
|
Q[hyphenation]
|
||||||
|
R[imageMaxHeightRatio]
|
||||||
|
end
|
||||||
|
|
||||||
|
C --> E & F & G & H & I & J
|
||||||
|
I --> K & L & M & N & O & P & Q & R
|
||||||
|
|
||||||
|
style CacheKey fill:#e1f5fe
|
||||||
|
style RenderSignature fill:#fff3e0
|
||||||
|
style CacheSignature fill:#e8f5e9
|
||||||
|
```
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# 微信读书 EPUB 阅读器 — 核心架构与数据流图
|
# 读书 EPUB 阅读器 — 核心架构与数据流图
|
||||||
|
|
||||||
> 逆向工程目标: WeRead v10.0.3 (Build 79), arm64, 63MB 主二进制
|
> 目标: WeRead v10.0.3 (Build 79)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -10,12 +10,11 @@
|
|||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
│ WeRead.app (iOS/macOS Catalyst) │
|
│ WeRead.app (iOS/macOS Catalyst) │ │
|
||||||
│ 主二进制: 63MB, Mach-O arm64 │
|
|
||||||
├─────────────────────────────────────────────────────────────────────┤
|
├─────────────────────────────────────────────────────────────────────┤
|
||||||
│ │
|
│ │
|
||||||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||||
│ │ 主二进制 (WeRead) │ │
|
│ │ WeRead │ │
|
||||||
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │ │
|
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │ │
|
||||||
│ │ │ 阅读器模块 │ │ 书架模块 │ │ 其他业务模块 │ │ │
|
│ │ │ 阅读器模块 │ │ 书架模块 │ │ 其他业务模块 │ │ │
|
||||||
│ │ │ WRReader* │ │ WRBookshelf* │ │ WRDiscover/WRMarket │ │ │
|
│ │ │ WRReader* │ │ WRBookshelf* │ │ WRDiscover/WRMarket │ │ │
|
||||||
@@ -216,7 +215,7 @@ WRReaderViewController (431 methods, 阅读器主控制器)
|
|||||||
|
|
||||||
```
|
```
|
||||||
┌──────────┐ HTTPS ┌──────────────────────────────────────────────────┐
|
┌──────────┐ HTTPS ┌──────────────────────────────────────────────────┐
|
||||||
│ 微信读书 │─────────────→│ ① WRBookNetwork.loadTarForEpubBookId:chapter: │
|
│ 读书 │─────────────→│ ① WRBookNetwork.loadTarForEpubBookId:chapter: │
|
||||||
│ 服务器 │ 加密 ZIP │ isPreload: │
|
│ 服务器 │ 加密 ZIP │ isPreload: │
|
||||||
│ │ {bookId}_ │ ↓ │
|
│ │ {bookId}_ │ ↓ │
|
||||||
│ │ DECRYPT.zip │ ② WRBookNetwork.handleUnzipWithBookId: │
|
│ │ DECRYPT.zip │ ② WRBookNetwork.handleUnzipWithBookId: │
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# 微信读书 EPUB 阅读器 — 符号恢复与核心算法
|
# 读书 EPUB 阅读器 — 符号恢复与核心算法
|
||||||
|
|
||||||
> 基于 WeRead v10.0.3 (Build 79) 二进制逆向
|
> 基于 WeRead v10.0.3 (Build 79) 二进制逆向
|
||||||
> 方法签名来源: __objc_methname 段 + binary strings
|
> 方法签名来源: __objc_methname 段 + binary strings
|
||||||
@@ -254,7 +254,7 @@ def cascade_stylesheets(default_css: str, replace_css: str, dark_css: str,
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
default_css: Safari 默认样式 (default.css)
|
default_css: Safari 默认样式 (default.css)
|
||||||
replace_css: 微信读书默认替换 (replace.css)
|
replace_css: 读书默认替换 (replace.css)
|
||||||
dark_css: 暗黑主题 (dark.css)
|
dark_css: 暗黑主题 (dark.css)
|
||||||
epub_css: EPUB 书籍内嵌 CSS
|
epub_css: EPUB 书籍内嵌 CSS
|
||||||
user_settings_css: 用户自定义设置 (字号/行高/主题)
|
user_settings_css: 用户自定义设置 (字号/行高/主题)
|
||||||
@@ -295,7 +295,7 @@ def cascade_stylesheets(default_css: str, replace_css: str, dark_css: str,
|
|||||||
|
|
||||||
# 按优先级逐层合并
|
# 按优先级逐层合并
|
||||||
result = parse_css(default_css) # 层1: 基础
|
result = parse_css(default_css) # 层1: 基础
|
||||||
result = merge(result, parse_css(replace_css)) # 层2: 微信读书默认
|
result = merge(result, parse_css(replace_css)) # 层2: 读书默认
|
||||||
result = merge(result, parse_css(dark_css)) # 层3: 暗黑主题
|
result = merge(result, parse_css(dark_css)) # 层3: 暗黑主题
|
||||||
result = merge(result, parse_css(epub_css)) # 层4: EPUB 内嵌
|
result = merge(result, parse_css(epub_css)) # 层4: EPUB 内嵌
|
||||||
result = merge(result, parse_css(user_settings_css)) # 层5: 用户设置 (最高优先级)
|
result = merge(result, parse_css(user_settings_css)) # 层5: 用户设置 (最高优先级)
|
||||||
@@ -630,7 +630,7 @@ def convert_hans_to_hant(attributed_string: str) -> str:
|
|||||||
# OpenCC: opencc_convert("s2t", text)
|
# OpenCC: opencc_convert("s2t", text)
|
||||||
|
|
||||||
# 简化实现: 使用 Unicode 码点映射表
|
# 简化实现: 使用 Unicode 码点映射表
|
||||||
# 微信读书内部可能使用腾讯自研的繁简转换库
|
# 读书内部可能使用腾讯自研的繁简转换库
|
||||||
conversion_table = load_hans_to_hant_table() # ~8000 个映射
|
conversion_table = load_hans_to_hant_table() # ~8000 个映射
|
||||||
|
|
||||||
result = []
|
result = []
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
# 微信读书 EPUB 阅读器 — 数据结构与 API 协议定义
|
# 读书 EPUB 阅读器 — 数据结构与 API 协议定义
|
||||||
|
|
||||||
> 基于 WeRead v10.0.3 (Build 79) 逆向工程
|
|
||||||
> 数据结构来源: __objc_methtype + class_ro_t ivar 段
|
|
||||||
> API 来源: WRBookNetwork 44 个 class method 签名
|
|
||||||
|
|
||||||
|
> 基于 WeRead v10.0.3 (Build 79)
|
||||||
---
|
---
|
||||||
|
|
||||||
## 一、关键数据结构定义 (Header Files)
|
## 一、关键数据结构定义 (Header Files)
|
||||||
@@ -129,7 +126,7 @@ typedef NS_ENUM(NSInteger, WRChapterFormat) {
|
|||||||
// 表格
|
// 表格
|
||||||
@property (nonatomic, strong) DTTableStyle *tableStyle; // 表格样式
|
@property (nonatomic, strong) DTTableStyle *tableStyle; // 表格样式
|
||||||
|
|
||||||
// 微信读书自定义属性
|
// 读书自定义属性
|
||||||
@property (nonatomic, assign) NSInteger verticalCenterStyle; // wr-vertical-center-style
|
@property (nonatomic, assign) NSInteger verticalCenterStyle; // wr-vertical-center-style
|
||||||
@property (nonatomic, assign) BOOL pageRelate; // weread-page-relate
|
@property (nonatomic, assign) BOOL pageRelate; // weread-page-relate
|
||||||
@property (nonatomic, assign) BOOL avoidPageBreakInside; // 断页保护
|
@property (nonatomic, assign) BOOL avoidPageBreakInside; // 断页保护
|
||||||
@@ -185,7 +182,7 @@ typedef NS_ENUM(NSInteger, WRChapterFormat) {
|
|||||||
@property (nonatomic, assign) CGFloat maximumLineHeight; // 最大行高
|
@property (nonatomic, assign) CGFloat maximumLineHeight; // 最大行高
|
||||||
@property (nonatomic, assign) CGFloat lineHeightMultiple; // 行高倍数
|
@property (nonatomic, assign) CGFloat lineHeightMultiple; // 行高倍数
|
||||||
|
|
||||||
// 微信读书扩展
|
// 读书扩展
|
||||||
@property (nonatomic, assign) CGFloat defaultTabInterval; // 默认制表位
|
@property (nonatomic, assign) CGFloat defaultTabInterval; // 默认制表位
|
||||||
|
|
||||||
@end
|
@end
|
||||||
@@ -809,7 +806,7 @@ NSString *const kWRUserVidKey = @"com.weread.user.vid";
|
|||||||
### 3.4 NSAttributedString 自定义属性键
|
### 3.4 NSAttributedString 自定义属性键
|
||||||
|
|
||||||
```objc
|
```objc
|
||||||
// 微信读书在 NSAttributedString 中注入的自定义属性
|
// 读书在 NSAttributedString 中注入的自定义属性
|
||||||
// 用于在排版结果中传递页面布局元数据
|
// 用于在排版结果中传递页面布局元数据
|
||||||
|
|
||||||
NSString *const DTPageBackgroundColorAttribute = @"DTPageBackgroundColor";
|
NSString *const DTPageBackgroundColorAttribute = @"DTPageBackgroundColor";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# CSS/JS/字体资源分析
|
# CSS/JS/字体资源分析
|
||||||
|
|
||||||
本文档对微信读书 EPUB 阅读器的前端资源文件进行全面分析,涵盖 CSS 样式表、JavaScript 脚本和字体文件。
|
本文档对读书 EPUB 阅读器的前端资源文件进行全面分析,涵盖 CSS 样式表、JavaScript 脚本和字体文件。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
### 1.1 样式层叠架构
|
### 1.1 样式层叠架构
|
||||||
|
|
||||||
微信读书的 CSS 采用分层架构:
|
读书的 CSS 采用分层架构:
|
||||||
|
|
||||||
```
|
```
|
||||||
default.css (基础HTML样式)
|
default.css (基础HTML样式)
|
||||||
@@ -46,7 +46,7 @@ Safari 派生的基础 HTML 默认样式,为 WebView 渲染提供标准化起
|
|||||||
|
|
||||||
**路径**: `output/resources/css/replace.css` (172行)
|
**路径**: `output/resources/css/replace.css` (172行)
|
||||||
|
|
||||||
EPUB 内容的核心替换样式,定义了微信读书特有的排版规则。
|
EPUB 内容的核心替换样式,定义了读书特有的排版规则。
|
||||||
|
|
||||||
**字体设置**:
|
**字体设置**:
|
||||||
- 标题和版权页使用 `"SourceHanSerifCN-Medium"` 字体
|
- 标题和版权页使用 `"SourceHanSerifCN-Medium"` 字体
|
||||||
@@ -62,7 +62,7 @@ EPUB 内容的核心替换样式,定义了微信读书特有的排版规则。
|
|||||||
| `.fifthTitle` | 1.05em | normal |
|
| `.fifthTitle` | 1.05em | normal |
|
||||||
| `.sixthTitle` | 1em | normal |
|
| `.sixthTitle` | 1em | normal |
|
||||||
|
|
||||||
**微信读书自定义CSS属性**:
|
**读书自定义CSS属性**:
|
||||||
|
|
||||||
```css
|
```css
|
||||||
/* 垂直居中样式 - 值2用于正文图片 */
|
/* 垂直居中样式 - 值2用于正文图片 */
|
||||||
@@ -545,7 +545,7 @@ getAllForwardLinks()
|
|||||||
// 转换H5链接为原生数据属性
|
// 转换H5链接为原生数据属性
|
||||||
transferH5Link()
|
transferH5Link()
|
||||||
|
|
||||||
// 解密微信读书加密链接
|
// 解密读书加密链接
|
||||||
WRLink_decrypt(str)
|
WRLink_decrypt(str)
|
||||||
// type '3' = 数字
|
// type '3' = 数字
|
||||||
// 其他 = 十六进制编码文本
|
// 其他 = 十六进制编码文本
|
||||||
@@ -573,7 +573,7 @@ document.addEventListener("selectionchange", debounce(function(e) {
|
|||||||
|
|
||||||
#### 2.4.1 rangy-highlighter.js (711行)
|
#### 2.4.1 rangy-highlighter.js (711行)
|
||||||
|
|
||||||
Rangy 高亮器模块,带微信读书自定义修改。
|
Rangy 高亮器模块,带读书自定义修改。
|
||||||
|
|
||||||
**CharacterRange 类**:
|
**CharacterRange 类**:
|
||||||
```javascript
|
```javascript
|
||||||
@@ -1099,7 +1099,7 @@ line-height: 30px;
|
|||||||
|
|
||||||
## 四、关键发现与实现细节
|
## 四、关键发现与实现细节
|
||||||
|
|
||||||
### 4.1 微信读书自定义CSS属性
|
### 4.1 读书自定义CSS属性
|
||||||
|
|
||||||
**wr-vertical-center-style**:
|
**wr-vertical-center-style**:
|
||||||
- 值 `1`:`.wr-vertical-center` 类,强制垂直居中
|
- 值 `1`:`.wr-vertical-center` 类,强制垂直居中
|
||||||
@@ -1137,7 +1137,7 @@ resultIframe.src = 'wereadapijs://private/setresult/' + scene + '&' + encodedDat
|
|||||||
2. `rangy-textrange.js` - 字符级范围操作(支持图片文本)
|
2. `rangy-textrange.js` - 字符级范围操作(支持图片文本)
|
||||||
3. `rangy-classapplier.js` - CSS类应用
|
3. `rangy-classapplier.js` - CSS类应用
|
||||||
4. `rangy-highlighter.js` - 高亮管理(序列化/反序列化)
|
4. `rangy-highlighter.js` - 高亮管理(序列化/反序列化)
|
||||||
5. `weread-highlighter.js` - 微信读书业务逻辑
|
5. `weread-highlighter.js` - 读书业务逻辑
|
||||||
|
|
||||||
**标注持久化**:
|
**标注持久化**:
|
||||||
```javascript
|
```javascript
|
||||||
@@ -1278,13 +1278,13 @@ RE.insertBook = function(id, title, author, cover) {
|
|||||||
|
|
||||||
## 六、总结
|
## 六、总结
|
||||||
|
|
||||||
微信读书的前端资源架构体现了以下设计特点:
|
读书的前端资源架构体现了以下设计特点:
|
||||||
|
|
||||||
1. **分层样式系统**:通过 default.css → replace.css/MediaPlatform.css → dark.css 的层叠,实现内容类型和主题的灵活切换
|
1. **分层样式系统**:通过 default.css → replace.css/MediaPlatform.css → dark.css 的层叠,实现内容类型和主题的灵活切换
|
||||||
|
|
||||||
2. **原生桥接机制**:WeReadApi.js 通过 iframe URL scheme 实现 JS 与原生的高效通信,支持异步回调
|
2. **原生桥接机制**:WeReadApi.js 通过 iframe URL scheme 实现 JS 与原生的高效通信,支持异步回调
|
||||||
|
|
||||||
3. **扩展的Rangy库**:在标准 Rangy 基础上添加了图片文本支持、高亮合并、序列化等微信读书特有功能
|
3. **扩展的Rangy库**:在标准 Rangy 基础上添加了图片文本支持、高亮合并、序列化等读书特有功能
|
||||||
|
|
||||||
4. **完整的标注系统**:支持高亮、想法、好友想法、引用、TTS五种标注类型,每种有独立的样式和交互
|
4. **完整的标注系统**:支持高亮、想法、好友想法、引用、TTS五种标注类型,每种有独立的样式和交互
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# DTCoreText 自定义修改分析
|
# DTCoreText 自定义修改分析
|
||||||
|
|
||||||
微信读书 (WeRead) 基于开源 DTCoreText 库进行了深度定制,将其从一个通用的 HTML-to-NSAttributedString 转换库改造为一套完整的电子书分页渲染引擎。本文档详细记录所有自定义修改。
|
读书 (WeRead) 基于开源 DTCoreText 库进行了深度定制,将其从一个通用的 HTML-to-NSAttributedString 转换库改造为一套完整的电子书分页渲染引擎。本文档详细记录所有自定义修改。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# EPUB 渲染管线详解
|
# EPUB 渲染管线详解
|
||||||
|
|
||||||
微信读书 (WeRead) 的 EPUB 渲染管线将原始 XHTML 文件转换为可分页、可交互的阅读视图。本文档完整描述从 EPUB 文件到屏幕像素的每一步。
|
读书 (WeRead) 的 EPUB 渲染管线将原始 XHTML 文件转换为可分页、可交互的阅读视图。本文档完整描述从 EPUB 文件到屏幕像素的每一步。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,334 @@
|
|||||||
|
# WXRead/decompiled 逆向代码说明
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
此目录包含从读书 (WeRead) iOS 客户端二进制逆向还原的头文件和实现代码。共 44 个文件(22 对 .h/.m),分为三大模块:
|
||||||
|
|
||||||
|
1. **阅读器核心** — 阅读控制器、页面渲染、排版引擎、EPUB 解析
|
||||||
|
2. **标注系统** — 划线、高亮、书签、想法、Pencil 手写笔记
|
||||||
|
3. **DTCoreText 定制** — 基于开源 DTCoreText 库的读书私有定制
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 架构总览
|
||||||
|
|
||||||
|
```
|
||||||
|
WRReaderViewController (阅读器主控制器)
|
||||||
|
├── WRPageViewController (翻页控制器, 支持 curl/slide/fade/none)
|
||||||
|
│ └── WRPageView (页面渲染视图, CoreText 直接绘制)
|
||||||
|
│ └── WRCoreTextLayoutFrame (单页排版帧)
|
||||||
|
│ └── WRCoreTextLayoutLine (单行排版)
|
||||||
|
├── WREpubParser (EPUB 文件解析)
|
||||||
|
├── WREpubTypesetter (XHTML → NSAttributedString 排版)
|
||||||
|
│ └── DTHTMLAttributedStringBuilder (HTML DOM → 属性字符串)
|
||||||
|
│ └── DTHTMLElement (DOM 节点)
|
||||||
|
├── WRChapterData (章节数据模型)
|
||||||
|
├── WRChapterPageCount (分页计算器)
|
||||||
|
├── WRCoreTextLayouter (CoreText 排版器)
|
||||||
|
└── WRBookmark / WRPageHighlight / WRPageMark / WRPageUnderline (标注模型)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 阅读器核心模块
|
||||||
|
|
||||||
|
### `WRReaderViewController`
|
||||||
|
|
||||||
|
阅读器的主入口控制器,431 个方法,管理阅读状态的完整生命周期。
|
||||||
|
|
||||||
|
**核心职责:**
|
||||||
|
- 管理 `WRReadingProgress`(阅读进度:章节索引、页码、字符偏移、滚动位置、阅读百分比)
|
||||||
|
- 章节导航:`gotoChapterIdx:position:positionOfFile:`、`jumpReadingToNextChapter`、`jumpReadingToPreChapter`
|
||||||
|
- 页面渲染:`renderPageView:progressData:source:` — 获取/计算章节数据、分页、创建/更新 WRPageView
|
||||||
|
- 排版重排:`recomposeCurrentPageViewWithSource:` — 字号/行距/主题变更后触发
|
||||||
|
- 进度持久化:`_saveReadingProgressAndIsAsync:`
|
||||||
|
- 支持涂鸦模式 (doodle mode) 和自动阅读 (auto-read)
|
||||||
|
- 通过 `WRReaderViewControllerDelegate` 回调通知外部进度变化和退出
|
||||||
|
|
||||||
|
### `WRPageViewController`
|
||||||
|
|
||||||
|
自定义的翻页控制器,支持四种翻页动画:
|
||||||
|
|
||||||
|
| 枚举值 | 样式 | 说明 |
|
||||||
|
|--------|------|------|
|
||||||
|
| `WRPageFlippingStyleCurl` | 纸张翻页 | 基于 UIPageViewController 的 curl 效果 |
|
||||||
|
| `WRPageFlippingStyleSlide` | 水平滑动 | UIScrollView 驱动的滑动翻页 |
|
||||||
|
| `WRPageFlippingStyleFade` | 淡入淡出 | 交叉渐变 |
|
||||||
|
| `WRPageFlippingStyleNone` | 无动画 | 瞬间切换 |
|
||||||
|
|
||||||
|
**关键特性:**
|
||||||
|
- 内部封装 `UIPageViewController`,提供 `WRPageViewControllerDelegate` / `WRPageViewControllerDataSource` 协议
|
||||||
|
- 包含 3 个故障修补方法,处理 Apple `UIPageViewController` 的已知崩溃:
|
||||||
|
- `detectNavigationDirectionCrashWithPageViewController:` — 检测导航方向崩溃
|
||||||
|
- `patchNavigationDirectionFault` — 修复导航方向状态不一致
|
||||||
|
- `patchNoViewControllerManagingPageViewFault` — 修复子 VC 丢失
|
||||||
|
- `patchUIPageCurlFault` — 修复快速翻页时的动画故障
|
||||||
|
|
||||||
|
### `WRPageView`
|
||||||
|
|
||||||
|
单页渲染视图,继承 `UIView`,通过 CoreText 的 `drawRect:` 直接绘制文本,不使用 `UILabel` 或 `UITextView`。
|
||||||
|
|
||||||
|
**核心绘制:**
|
||||||
|
- `drawInContext:withData:size:inRect:position:` — 将文本和行内图片直接绘制到 CGContext
|
||||||
|
- 通过 `CTLineGetStringIndexForPosition` 进行 CoreText 级别的坐标点击测试
|
||||||
|
|
||||||
|
**UI 覆盖层:**
|
||||||
|
- 章节标题头 (headerLabel)、背景图片 (backgroundImageView)
|
||||||
|
- 加载指示器 (activityIndicator)、加载进度条 (loadingProgressView)
|
||||||
|
- 状态提示 (statusLabel)、重试按钮 (retryButton)
|
||||||
|
- 分享按钮、书签按钮、字号调节按钮
|
||||||
|
- 好友想法按钮 (friendReviewsButton)
|
||||||
|
|
||||||
|
**文本选区:**
|
||||||
|
- `stringIndexForPoint:` / `lineRangeForStringIndex:` — CoreText 坐标与字符索引转换
|
||||||
|
- `isSelecting` / `selectedRange` — 选区状态
|
||||||
|
- `clearSelection` — 清除选区
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## EPUB 解析与排版
|
||||||
|
|
||||||
|
### `WREpubParser`
|
||||||
|
|
||||||
|
EPUB 文件解析器,处理完整的 EPUB 结构:
|
||||||
|
|
||||||
|
**解析流程:** `container.xml` → `content.opf` → `spine` → 章节文件
|
||||||
|
|
||||||
|
**主要 API:**
|
||||||
|
- `initWithFilePath:book:` — 初始化
|
||||||
|
- `parse:` — 执行解析,返回 YES/NO
|
||||||
|
- `parseContainerXML:` — 解析 container.xml 获取 OPF 路径
|
||||||
|
- `parseOPFAtRelativePath:error:` — 解析 content.opf,填充章节列表和资源映射
|
||||||
|
- `parseNCX:` — 解析 toc.ncx 目录树
|
||||||
|
- `contentForChapterAtIndex:error:` — 读取单章 XHTML 内容
|
||||||
|
- `absolutePathForResource:` — 将相对资源路径解析为绝对路径
|
||||||
|
|
||||||
|
**错误码:**
|
||||||
|
|
||||||
|
| 错误码 | 含义 |
|
||||||
|
|--------|------|
|
||||||
|
| -1000 | 文件未找到 |
|
||||||
|
| -1001 | container.xml 解析失败 |
|
||||||
|
| -1002 | OPF 解析失败 |
|
||||||
|
| -1003 | NCX 解析失败 |
|
||||||
|
| -1004 | spine 为空 |
|
||||||
|
| -1005 | 章节加载失败 |
|
||||||
|
| -1006 | 解密失败 |
|
||||||
|
|
||||||
|
### `WREpubTypesetter`
|
||||||
|
|
||||||
|
EPUB 排版器,XHTML → `NSAttributedString` 的转换核心。全部为类方法,无需实例化。
|
||||||
|
|
||||||
|
**主入口方法:**
|
||||||
|
```objc
|
||||||
|
+ (NSAttributedString *)attributeStringWithFilePath:priority:
|
||||||
|
insertArticleToolAttachment:insertBookChapterToolAttachment:
|
||||||
|
insertRecommendView:book:chapter:pageFlippingStyle:
|
||||||
|
renderErrorReason:isStyleFileNotFound:options:
|
||||||
|
```
|
||||||
|
|
||||||
|
**处理流程:**
|
||||||
|
1. 加载并级联合并 CSS(用户设置 > EPUB 内嵌 > 默认样式)
|
||||||
|
2. 将 XHTML + 合并后的 CSS 喂入 `DTHTMLAttributedStringBuilder`
|
||||||
|
3. 遍历生成的元素树,修补图片、链接、自定义属性
|
||||||
|
4. 应用用户排版偏好(字体、行距、主题)
|
||||||
|
5. 可选截断用于免费试读预览
|
||||||
|
|
||||||
|
**自定义 CSS 属性:**
|
||||||
|
- `WREpubTypesetterVerticalCenterStyleAttribute` — 行内元素垂直居中(`wr-vertical-center-style`)
|
||||||
|
- `WREpubTypesetterPageRelateAttribute` — 跨页关联标记(`weread-page-relate`)
|
||||||
|
|
||||||
|
**翻译渲染错误上报:**
|
||||||
|
- `tryReportTranslationError:` — 双语(原文 + 翻译)渲染异常时上报分析
|
||||||
|
|
||||||
|
### `WREpubPositionConverter`
|
||||||
|
|
||||||
|
文件位置与字符位置的双向转换器。用于书签同步和阅读进度追踪。
|
||||||
|
|
||||||
|
**核心概念:** 将 `(fileIndex, row, column)` 三元组映射为全局字符偏移量。
|
||||||
|
|
||||||
|
**主要 API:**
|
||||||
|
- `initWithFilePaths:attributedStrings:offset:isContainIntroFlyleaf:` — 初始化
|
||||||
|
- `initIndices` — 构建内部索引表(调用前不能执行转换)
|
||||||
|
- `indicesInFile:forRowColumnPairs:stringIndices:` — 行/列 → 字符索引
|
||||||
|
- `stringRangeFromFileRange:` — 文件内范围 → 全局字符串范围
|
||||||
|
- `totalCharacterCount` — 全书总字符数
|
||||||
|
- `fileIndexForCharacterPosition:` — 全局位置 → 文件索引
|
||||||
|
- `localOffsetInFileAtIndex:forGlobalPosition:` — 全局位置 → 文件内偏移
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 分页与布局
|
||||||
|
|
||||||
|
### `WRChapterPageCount`
|
||||||
|
|
||||||
|
章节分页计算器。管理每页的 `NSRange`。
|
||||||
|
|
||||||
|
**关键属性:**
|
||||||
|
- `pageRanges` — 每页的字符范围数组 (NSValue-wrapped NSRange)
|
||||||
|
- `totalPages` — 总页数
|
||||||
|
|
||||||
|
**关键方法:**
|
||||||
|
- `currentCacheKeyWithBookId:` — 生成缓存键(编码书 ID + 当前排版设置,设置变更则失效)
|
||||||
|
- `rangeValueWithPageInfo:` — 从服务端分页数据提取范围
|
||||||
|
- `rangeForPageAtIndex:` / `pageIndexForCharacterIndex:` — 页码 ↔ 字符位置
|
||||||
|
- `recalculatePageRangesForAttributedString:drawingSize:margins:` — 重算分页
|
||||||
|
|
||||||
|
### `WRCoreTextLayouter`
|
||||||
|
|
||||||
|
读书封装的 CoreText 排版器,创建 `CTTypesetter` / `CTFramesetter` 并产出 `WRCoreTextLayoutFrame` 页面对象。
|
||||||
|
|
||||||
|
**初始化:**
|
||||||
|
- `initWithAttributedString:` / `initWithAttributedString:config:`
|
||||||
|
- `WRCoreTextLayoutConfig` 配置:frame 宽高、内边距、栏数、栏间距、孤行/寡行控制、连字符
|
||||||
|
|
||||||
|
**排版 API:**
|
||||||
|
- `createTypesetter` / `createFramesetter` / `invalidateTypesetter`
|
||||||
|
- `layoutFrameWithRange:frame:` — 单帧排版
|
||||||
|
- `layoutFramesForPageSize:` — 全文分页排版
|
||||||
|
- `numberOfPagesForPageSize:` / `rangeForPageAtIndex:pageSize:` — 分页查询
|
||||||
|
- `pageBackgroundImageAtRange:themeBgColor:` — 页面背景图
|
||||||
|
- `resizedImageForImagePath:` — 图片缩放适配
|
||||||
|
|
||||||
|
### `WRCoreTextLayoutFrame`
|
||||||
|
|
||||||
|
单页排版帧,封装 `CTFrame`,管理 `WRCoreTextLayoutLine` 行数组。
|
||||||
|
|
||||||
|
**核心功能:**
|
||||||
|
- 文本绘制:`drawInContext:image:size:inRect:position:`(含 CTFrameDraw + 图片附件 + 装饰元素)
|
||||||
|
- 分栏支持:`numberOfColumns` / `columnGap`
|
||||||
|
- 跨页避让:`avoidPageBreakInsideByRemovingLastLinesIfNeeded` — CSS `avoid-page-break-inside` 实现
|
||||||
|
- 点击测试:`characterIndexAtPoint:` / `lineIndexAtPoint:` / `rectForCharacterAtIndex:`
|
||||||
|
- 选区操作:`selectedText` / `selectedRanges` / `selectTextInRange:` / `clearSelection`
|
||||||
|
- 搜索高亮:`highlightSearchResults:` / `clearSearchHighlights`
|
||||||
|
- 装饰元素:删除线范围、下划线范围、高亮范围、链接范围
|
||||||
|
- 响应式更新:通过 `RACSubject` (ReactiveObjC) 发出布局变更通知
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 标注系统
|
||||||
|
|
||||||
|
### `WRBookmark`
|
||||||
|
|
||||||
|
书签/标注的数据模型,支持 5 种类型:
|
||||||
|
|
||||||
|
| 类型 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `WRBookmarkTypeHighlight` | 彩色高亮 |
|
||||||
|
| `WRBookmarkTypeUnderline` | 下划线 |
|
||||||
|
| `WRBookmarkTypeMark` | 页面书签(折角) |
|
||||||
|
| `WRBookmarkTypeNote` | 文字笔记 |
|
||||||
|
| `WRBookmarkTypePencil` | Apple Pencil 手绘 |
|
||||||
|
|
||||||
|
**关键属性:** `bookmarkId`、`bookId`、`chapterUid`、`chapterOffset`、`markText`、`startPos`/`endPos`、`colorStyle`、`anchorId`、`rangeKey`、`syncKey`
|
||||||
|
|
||||||
|
**持久化:** `toDictionary` / `fromDictionary`、`toJSONData` / `fromJSONData`
|
||||||
|
|
||||||
|
**工厂方法:**
|
||||||
|
- `bookmarkWithBookId:chapterUid:offset:text:type:`
|
||||||
|
- `highlightWithBookId:chapterUid:startPos:endPos:text:colorStyle:`
|
||||||
|
|
||||||
|
### `WRPageHighlight`
|
||||||
|
|
||||||
|
文本高亮标注模型。5 种预设颜色:黄、蓝、红、绿、紫。
|
||||||
|
|
||||||
|
**方法:**
|
||||||
|
- `initWithBookId:chapterUid:startPos:endPos:text:color:` — 创建高亮
|
||||||
|
- `toBookmark` — 转为 WRBookmark 进行持久化/同步
|
||||||
|
- `uiColor` — 返回渲染用 UIColor
|
||||||
|
|
||||||
|
### `WRPageUnderline`
|
||||||
|
|
||||||
|
下划线标注模型。支持 4 种下划线样式:实线、虚线、波浪线、点线。
|
||||||
|
|
||||||
|
**方法:**
|
||||||
|
- `toBookmark` / `fromBookmark:` — 与 WRBookmark 双向转换
|
||||||
|
- `underlinePathForRect:` — 返回渲染用 UIBezierPath
|
||||||
|
- `containsPosition:` — 范围查询
|
||||||
|
- `mergeWithUnderline:` — 合并相邻下划线
|
||||||
|
- `setNote:` — 附加笔记
|
||||||
|
|
||||||
|
### `WRPageMark`
|
||||||
|
|
||||||
|
页面书签(折角)模型。25 个方法,功能最丰富的标注类型。
|
||||||
|
|
||||||
|
**关键特性:**
|
||||||
|
- 支持 3 种展示模式:图标、列表、行内
|
||||||
|
- 关联标注:`linkedHighlights` / `linkedUnderlines` — 书签位置上附带的高亮和下划线
|
||||||
|
- 批量操作:`marksSortedByPosition:` / `marksSortedByDate:` / `marksInChapter:fromMarks:` / `marksInRange:fromMarks:`
|
||||||
|
- 序列化:`toDictionary` / `fromDictionary`、`toJSONData` / `fromJSONData`
|
||||||
|
|
||||||
|
### `WRReaderPencilNoteManager`
|
||||||
|
|
||||||
|
Apple Pencil 手写笔记管理器。11 个类方法,全部为类方法调用。
|
||||||
|
|
||||||
|
**支持 9 种画笔颜色/样式:** 黑、灰、红、蓝、黄、绿、铅笔、钢笔、荧光笔
|
||||||
|
|
||||||
|
**本地存储:**
|
||||||
|
- `drawingFileDirectory` / `drawingFilePathWithReviewItemId:` — 文件路径管理
|
||||||
|
- `writeDrawingDataToLocal:` / `writeDrawingToLocal:` — 本地写入(支持 draft/published 状态)
|
||||||
|
- `checkDrawingExistsWithReviewItemId:` — 存在性检查
|
||||||
|
- `deleteDrawingWithReviewItemId:` / `deleteAllReviewDrawings` — 删除
|
||||||
|
|
||||||
|
**云端同步:**
|
||||||
|
- `uploadPencilDrawing:colorStyle:onlyUploadImage:canRetry:` — 上传绘制图片和数据到腾讯云 COS
|
||||||
|
- `uploadPencilNoteData:suffix:` — 上传 PKDrawing 序列化数据
|
||||||
|
- `downloadDrawingDataFromCosWithUrl:desPath:callback:` — 从 COS 下载
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DTCoreText 定制模块
|
||||||
|
|
||||||
|
读书基于开源库 [DTCoreText](https://github.com/Cocoanetics/DTCoreText) 做了大量私有定制。
|
||||||
|
|
||||||
|
### `DTHTMLAttributedStringBuilder`
|
||||||
|
|
||||||
|
HTML DOM → `NSAttributedString` 的 SAX 风格解析构建器。`WREpubTypesetter` 的底层依赖。
|
||||||
|
|
||||||
|
**WeRead 自定义属性键:**
|
||||||
|
- `DTPageBackgroundColorAttribute` / `DTPageBackgroundImageAttribute` — 页面背景
|
||||||
|
- `DTPageBreakAfterAttribute` / `DTPageBreakBeforeAttribute` — 分页控制
|
||||||
|
- `DTPageBreakInsideAvoidAttribute` — 跨页避让
|
||||||
|
- `DTPageRelateAttribute` — 跨页关联
|
||||||
|
- `DTHTMLVerticalCenterAttribute` — 垂直居中
|
||||||
|
- `DTHTMLTranslateTagAttribute` — 翻译标签
|
||||||
|
|
||||||
|
### `DTHTMLElement`
|
||||||
|
|
||||||
|
HTML 元素 DOM 节点。构建解析时的 DOM 树,每个节点可通过 `attributedString` 方法生成对应的属性字符串。
|
||||||
|
|
||||||
|
**关键属性:** `tagName`、`parent`/`children`、`styleDictionary`、`isBlockElement`、`shouldAvoidPageBreakInside`
|
||||||
|
|
||||||
|
### `DTCoreTextLayouter` / `DTCoreTextLayoutFrame` / `DTCoreTextLayoutLine` / `DTCoreTextGlyphRun`
|
||||||
|
|
||||||
|
DTCoreText 的四层排版层次结构(与 `WR` 前缀版本功能对应,是底层实现):
|
||||||
|
|
||||||
|
```
|
||||||
|
DTCoreTextLayouter (排版器, CTTypesetter 包装)
|
||||||
|
└── DTCoreTextLayoutFrame (排版帧, CTFrame 包装)
|
||||||
|
└── DTCoreTextLayoutLine (排版行, CTLine 包装)
|
||||||
|
└── DTCoreTextGlyphRun (字形运行, CTRun 包装)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Layouter** — 创建 CTTypesetter,产出 LayoutFrame,支持帧缓存
|
||||||
|
- **LayoutFrame** — 管理排版行数组,支持多栏、分页避让、点击测试、选区、搜索
|
||||||
|
- **LayoutLine** — 封装 CTLine,管理 GlyphRun 数组,提供排版指标(ascent/descent/leading/width)和点击测试
|
||||||
|
- **GlyphRun** — 封装 CTRun,管理字形(glyph)级别操作:位置、路径绘制、附件(图片)、字体属性、绘制方向
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据流
|
||||||
|
|
||||||
|
```
|
||||||
|
EPUB 文件 (.epub)
|
||||||
|
↓ WREpubParser.parse
|
||||||
|
解析 container.xml → content.opf → toc.ncx
|
||||||
|
↓ WREpubTypesetter.attributeStringWithFilePath
|
||||||
|
XHTML + CSS → DTHTMLAttributedStringBuilder → NSAttributedString
|
||||||
|
↓ WRCoreTextLayouter / WRChapterPageCount
|
||||||
|
分页排版 → [NSRange per page]
|
||||||
|
↓ WRPageView.drawInContext
|
||||||
|
CoreText 绘制到 CGContext
|
||||||
|
↓ WRPageViewController
|
||||||
|
翻页展示
|
||||||
|
```
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
// DTHTMLAttributedStringBuilder.h
|
// DTHTMLAttributedStringBuilder.h
|
||||||
// DTCoreText (WeRead custom fork)
|
// DTCoreText (WeRead custom fork)
|
||||||
//
|
//
|
||||||
// Reverse-engineered from WeChat Reading (微信读书) binary.
|
// Reverse-engineered from WeChat Reading (读书) binary.
|
||||||
// This class converts HTML DOM into NSAttributedString via SAX-style parsing.
|
// This class converts HTML DOM into NSAttributedString via SAX-style parsing.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// DTHTMLElement.h
|
// DTHTMLElement.h
|
||||||
// DTCoreText (WeRead custom fork)
|
// DTCoreText (WeRead custom fork)
|
||||||
//
|
//
|
||||||
// Reverse-engineered from WeChat Reading (微信读书) binary.
|
// Reverse-engineered from WeChat Reading (读书) binary.
|
||||||
// Represents a single HTML element in the DOM tree built during parsing.
|
// Represents a single HTML element in the DOM tree built during parsing.
|
||||||
// Each node can produce an NSAttributedString via -attributedString.
|
// Each node can produce an NSAttributedString via -attributedString.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//
|
//
|
||||||
// WRBookmark.h
|
// WRBookmark.h
|
||||||
// WeRead (微信读书)
|
// WeRead (读书)
|
||||||
// Reverse-engineered header
|
// Reverse-engineered header
|
||||||
//
|
//
|
||||||
// Bookmark model. Stores bookId, chapterUid, and position information.
|
// Bookmark model. Stores bookId, chapterUid, and position information.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//
|
//
|
||||||
// WRBookmark.m
|
// WRBookmark.m
|
||||||
// WeRead (微信读书)
|
// WeRead (读书)
|
||||||
// Reverse-engineered implementation reconstruction
|
// Reverse-engineered implementation reconstruction
|
||||||
//
|
//
|
||||||
// Detailed pseudo-code based on binary analysis (many NSString ivars,
|
// Detailed pseudo-code based on binary analysis (many NSString ivars,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//
|
//
|
||||||
// WRChapterData.h
|
// WRChapterData.h
|
||||||
// WeRead (微信读书)
|
// WeRead (读书)
|
||||||
//
|
//
|
||||||
// Reverse-engineered header reconstruction.
|
// Reverse-engineered header reconstruction.
|
||||||
// Chapter data model. Stores the typeset NSAttributedString.
|
// Chapter data model. Stores the typeset NSAttributedString.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//
|
//
|
||||||
// WRChapterData.m
|
// WRChapterData.m
|
||||||
// WeRead (微信读书)
|
// WeRead (读书)
|
||||||
//
|
//
|
||||||
// Detailed pseudo-code reconstruction of the chapter data model.
|
// Detailed pseudo-code reconstruction of the chapter data model.
|
||||||
// Stores the typeset NSAttributedString, manages highlights, underlines,
|
// Stores the typeset NSAttributedString, manages highlights, underlines,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//
|
//
|
||||||
// WRChapterPageCount.h
|
// WRChapterPageCount.h
|
||||||
// WeRead (微信读书)
|
// WeRead (读书)
|
||||||
//
|
//
|
||||||
// Reverse-engineered header reconstruction.
|
// Reverse-engineered header reconstruction.
|
||||||
// Pagination calculator. Manages NSRange for each page within a chapter.
|
// Pagination calculator. Manages NSRange for each page within a chapter.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//
|
//
|
||||||
// WRChapterPageCount.m
|
// WRChapterPageCount.m
|
||||||
// WeRead (微信读书)
|
// WeRead (读书)
|
||||||
//
|
//
|
||||||
// Detailed pseudo-code reconstruction of the pagination calculator.
|
// Detailed pseudo-code reconstruction of the pagination calculator.
|
||||||
// Computes page breaks by simulating CoreText line layout and fitting
|
// Computes page breaks by simulating CoreText line layout and fitting
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//
|
//
|
||||||
// WREpubParser.h
|
// WREpubParser.h
|
||||||
// WeRead (微信读书)
|
// WeRead (读书)
|
||||||
// Reverse-engineered header
|
// Reverse-engineered header
|
||||||
//
|
//
|
||||||
// EPUB file parser. Parses OPF (content.opf), NCX (toc.ncx), and XHTML chapter files.
|
// EPUB file parser. Parses OPF (content.opf), NCX (toc.ncx), and XHTML chapter files.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//
|
//
|
||||||
// WREpubParser.m
|
// WREpubParser.m
|
||||||
// WeRead (微信读书)
|
// WeRead (读书)
|
||||||
// Reverse-engineered implementation reconstruction
|
// Reverse-engineered implementation reconstruction
|
||||||
//
|
//
|
||||||
// Detailed pseudo-code based on binary analysis, ivar types, known methods,
|
// Detailed pseudo-code based on binary analysis, ivar types, known methods,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//
|
//
|
||||||
// WREpubPositionConverter.h
|
// WREpubPositionConverter.h
|
||||||
// WeRead (微信读书)
|
// WeRead (读书)
|
||||||
// Reverse-engineered header
|
// Reverse-engineered header
|
||||||
//
|
//
|
||||||
// Position converter between file positions and character positions.
|
// Position converter between file positions and character positions.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//
|
//
|
||||||
// WREpubPositionConverter.m
|
// WREpubPositionConverter.m
|
||||||
// WeRead (微信读书)
|
// WeRead (读书)
|
||||||
// Reverse-engineered implementation reconstruction
|
// Reverse-engineered implementation reconstruction
|
||||||
//
|
//
|
||||||
// Detailed pseudo-code based on binary analysis, ivar types, known methods,
|
// Detailed pseudo-code based on binary analysis, ivar types, known methods,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//
|
//
|
||||||
// WREpubTypesetter.h
|
// WREpubTypesetter.h
|
||||||
// WeRead (微信读书) - Reverse Engineered
|
// WeRead (读书) - Reverse Engineered
|
||||||
//
|
//
|
||||||
// EPUB Typesetter: Converts XHTML content into NSAttributedString
|
// EPUB Typesetter: Converts XHTML content into NSAttributedString
|
||||||
// via DTHTMLAttributedStringBuilder with CSS cascade, image handling,
|
// via DTHTMLAttributedStringBuilder with CSS cascade, image handling,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//
|
//
|
||||||
// WREpubTypesetter.m
|
// WREpubTypesetter.m
|
||||||
// WeRead (微信读书) - Reverse Engineered Implementation Reconstruction
|
// WeRead (读书) - Reverse Engineered Implementation Reconstruction
|
||||||
//
|
//
|
||||||
// This is a detailed pseudo-code reconstruction based on:
|
// This is a detailed pseudo-code reconstruction based on:
|
||||||
// - Binary strings output (method signatures, CSS filenames, attribute names)
|
// - Binary strings output (method signatures, CSS filenames, attribute names)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//
|
//
|
||||||
// WRPageHighlight.h
|
// WRPageHighlight.h
|
||||||
// WeRead (微信读书)
|
// WeRead (读书)
|
||||||
// Reverse-engineered header
|
// Reverse-engineered header
|
||||||
//
|
//
|
||||||
// Annotation model for text highlights. Stores the highlighted text range,
|
// Annotation model for text highlights. Stores the highlighted text range,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//
|
//
|
||||||
// WRPageHighlight.m
|
// WRPageHighlight.m
|
||||||
// WeRead (微信读书)
|
// WeRead (读书)
|
||||||
// Reverse-engineered implementation reconstruction
|
// Reverse-engineered implementation reconstruction
|
||||||
//
|
//
|
||||||
// 3 methods identified from binary. WRPageHighlight is a lightweight
|
// 3 methods identified from binary. WRPageHighlight is a lightweight
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//
|
//
|
||||||
// WRPageMark.h
|
// WRPageMark.h
|
||||||
// WeRead (微信读书)
|
// WeRead (读书)
|
||||||
// Reverse-engineered header
|
// Reverse-engineered header
|
||||||
//
|
//
|
||||||
// Annotation model for page bookmarks/marks (dog-ear style).
|
// Annotation model for page bookmarks/marks (dog-ear style).
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//
|
//
|
||||||
// WRPageMark.m
|
// WRPageMark.m
|
||||||
// WeRead (微信读书)
|
// WeRead (读书)
|
||||||
// Reverse-engineered implementation reconstruction
|
// Reverse-engineered implementation reconstruction
|
||||||
//
|
//
|
||||||
// 25 methods identified from binary. WRPageMark is the most comprehensive
|
// 25 methods identified from binary. WRPageMark is the most comprehensive
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//
|
//
|
||||||
// WRPageUnderline.h
|
// WRPageUnderline.h
|
||||||
// WeRead (微信读书)
|
// WeRead (读书)
|
||||||
// Reverse-engineered header
|
// Reverse-engineered header
|
||||||
//
|
//
|
||||||
// Annotation model for text underlines. 10 methods identified from binary.
|
// Annotation model for text underlines. 10 methods identified from binary.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//
|
//
|
||||||
// WRPageUnderline.m
|
// WRPageUnderline.m
|
||||||
// WeRead (微信读书)
|
// WeRead (读书)
|
||||||
// Reverse-engineered implementation reconstruction
|
// Reverse-engineered implementation reconstruction
|
||||||
//
|
//
|
||||||
// 10 methods identified from binary. WRPageUnderline renders underline
|
// 10 methods identified from binary. WRPageUnderline renders underline
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//
|
//
|
||||||
// WRPageView.h
|
// WRPageView.h
|
||||||
// WeRead (微信读书)
|
// WeRead (读书)
|
||||||
//
|
//
|
||||||
// Reverse-engineered header reconstruction.
|
// Reverse-engineered header reconstruction.
|
||||||
// The page rendering view. Inherits UIView. Uses CoreText direct drawing
|
// The page rendering view. Inherits UIView. Uses CoreText direct drawing
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//
|
//
|
||||||
// WRPageView.m
|
// WRPageView.m
|
||||||
// WeRead (微信读书)
|
// WeRead (读书)
|
||||||
//
|
//
|
||||||
// Detailed pseudo-code reconstruction of the page rendering view.
|
// Detailed pseudo-code reconstruction of the page rendering view.
|
||||||
// This view draws text directly via CoreText into CGContext — no UILabel
|
// This view draws text directly via CoreText into CGContext — no UILabel
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//
|
//
|
||||||
// WRPageViewController.h
|
// WRPageViewController.h
|
||||||
// WeRead (微信读书)
|
// WeRead (读书)
|
||||||
//
|
//
|
||||||
// Reverse-engineered header reconstruction.
|
// Reverse-engineered header reconstruction.
|
||||||
// Based on UIPageViewController. Supports UIPageCurl (simulation) and
|
// Based on UIPageViewController. Supports UIPageCurl (simulation) and
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//
|
//
|
||||||
// WRPageViewController.m
|
// WRPageViewController.m
|
||||||
// WeRead (微信读书)
|
// WeRead (读书)
|
||||||
//
|
//
|
||||||
// Detailed pseudo-code reconstruction of the page view controller.
|
// Detailed pseudo-code reconstruction of the page view controller.
|
||||||
// Wraps UIPageViewController with fault detection and patching for
|
// Wraps UIPageViewController with fault detection and patching for
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//
|
//
|
||||||
// WRPreloadBookManager.h
|
// WRPreloadBookManager.h
|
||||||
// WeRead (微信读书)
|
// WeRead (读书)
|
||||||
// Reverse-engineered header
|
// Reverse-engineered header
|
||||||
//
|
//
|
||||||
// Preload manager. Predicts which chapters to preload based on book ranking
|
// Preload manager. Predicts which chapters to preload based on book ranking
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//
|
//
|
||||||
// WRPreloadBookManager.m
|
// WRPreloadBookManager.m
|
||||||
// WeRead (微信读书)
|
// WeRead (读书)
|
||||||
// Reverse-engineered implementation reconstruction
|
// Reverse-engineered implementation reconstruction
|
||||||
//
|
//
|
||||||
// Detailed pseudo-code based on binary analysis, ivar types (NSMutableDictionary),
|
// Detailed pseudo-code based on binary analysis, ivar types (NSMutableDictionary),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//
|
//
|
||||||
// WRReaderPencilNoteManager.h
|
// WRReaderPencilNoteManager.h
|
||||||
// WeRead (微信读书)
|
// WeRead (读书)
|
||||||
// Reverse-engineered header
|
// Reverse-engineered header
|
||||||
//
|
//
|
||||||
// Apple Pencil note manager. 11 class methods identified from binary.
|
// Apple Pencil note manager. 11 class methods identified from binary.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//
|
//
|
||||||
// WRReaderPencilNoteManager.m
|
// WRReaderPencilNoteManager.m
|
||||||
// WeRead (微信读书)
|
// WeRead (读书)
|
||||||
// Reverse-engineered implementation reconstruction
|
// Reverse-engineered implementation reconstruction
|
||||||
//
|
//
|
||||||
// 11 class methods identified from binary. Manages Apple Pencil
|
// 11 class methods identified from binary. Manages Apple Pencil
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//
|
//
|
||||||
// WRReaderViewController.h
|
// WRReaderViewController.h
|
||||||
// WeRead (微信读书)
|
// WeRead (读书)
|
||||||
//
|
//
|
||||||
// Reverse-engineered header reconstruction.
|
// Reverse-engineered header reconstruction.
|
||||||
// Main reader controller. Manages reading state, progress saving,
|
// Main reader controller. Manages reading state, progress saving,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//
|
//
|
||||||
// WRReaderViewController.m
|
// WRReaderViewController.m
|
||||||
// WeRead (微信读书)
|
// WeRead (读书)
|
||||||
//
|
//
|
||||||
// Detailed pseudo-code reconstruction of the main reader controller.
|
// Detailed pseudo-code reconstruction of the main reader controller.
|
||||||
// 431 methods total. This file covers the key methods for page rendering,
|
// 431 methods total. This file covers the key methods for page rendering,
|
||||||
|
|||||||
@@ -1,439 +0,0 @@
|
|||||||
+[WRBookNetwork _removeTranslateHtml:]
|
|
||||||
+[WRBookNetwork addMileStone:callback:]
|
|
||||||
+[WRBookNetwork addReview:shareToWechat:audioArticleId:outlineContent:audioColumnId:callback:]
|
|
||||||
+[WRBookNetwork automaticallyMarkFinishReadingWithBookId:callback:]
|
|
||||||
+[WRBookNetwork chaptersInfoFromFile:checkTranslate:]
|
|
||||||
+[WRBookNetwork checkFMCards:callback:]_block_invoke
|
|
||||||
+[WRBookNetwork clearPreloadKVWithBookId:chapterUid:zipPath:]
|
|
||||||
+[WRBookNetwork dislikeReviewById:isDislike:withParams:callback:]
|
|
||||||
+[WRBookNetwork fetchLockInfoWithBookId:callback:]
|
|
||||||
+[WRBookNetwork fileContentWithChapter:book:shouldRemoveHtmlTags:filterTranslateContent:]
|
|
||||||
+[WRBookNetwork fileContentWithChapter:book:shouldRemoveHtmlTags:filterTranslateContent:]_block_invoke
|
|
||||||
+[WRBookNetwork handleUnzipErrorWithPath:plainBookDirectory:]
|
|
||||||
+[WRBookNetwork handleUnzipWithBookId:zipPath:encryptKey:plainBookDirectory:]
|
|
||||||
+[WRBookNetwork handleUnzipWithBookId:zipPath:encryptKey:plainBookDirectory:]_block_invoke
|
|
||||||
+[WRBookNetwork likeReviewById:isUnlike:withParams:callback:]
|
|
||||||
+[WRBookNetwork loadArticleBookDetailWithBookId:callback:]
|
|
||||||
+[WRBookNetwork loadBookDetailPodcastsWithBookId:withSynckey:withListType:withFilterType:withMaxIdx:withCount:]
|
|
||||||
+[WRBookNetwork loadBookInfoWithBookId:source:callback:]
|
|
||||||
+[WRBookNetwork loadBookLectureAuthors:callback:]
|
|
||||||
+[WRBookNetwork loadBookReadDetailInfoWithBookId:callback:]
|
|
||||||
+[WRBookNetwork loadBookReadInfoWithBookId:callback:]
|
|
||||||
+[WRBookNetwork loadBookReadInfoWithBookIdAndVid:vid:callback:]
|
|
||||||
+[WRBookNetwork loadBookmarkListWithBookId:syncKey:callback:]
|
|
||||||
+[WRBookNetwork loadChapterContentWithParam:callback:]
|
|
||||||
+[WRBookNetwork loadChapterContentWithParam:callback:]_block_invoke
|
|
||||||
+[WRBookNetwork loadExchangeRecordListWithParams:callback:]_block_invoke
|
|
||||||
+[WRBookNetwork loadFMCardsWithBookId:withSynckey:withListType:withFilterType:withMaxIdx:withCount:]
|
|
||||||
+[WRBookNetwork loadFriendMarkWithBookId:synckey:]_block_invoke
|
|
||||||
+[WRBookNetwork loadPodcastsWithSynckey:withBookId:withCount:withMaxIdx:callback:]
|
|
||||||
+[WRBookNetwork loadReadTimeWelfareActionWithBookId:opt:secretKey:firstEnter:]
|
|
||||||
+[WRBookNetwork loadRelatedBooksForReviewDetail:callback:]
|
|
||||||
+[WRBookNetwork loadTarForEpubBookId:chapter:isPreload:]
|
|
||||||
+[WRBookNetwork loadTarForEpubBookId:chapter:isPreload:]_block_invoke
|
|
||||||
+[WRBookNetwork loadTarForEpubBookId:chapter:isPreload:]_block_invoke_2
|
|
||||||
+[WRBookNetwork loadTarForEpubBookId:chapter:isPreload:]_block_invoke_5
|
|
||||||
+[WRBookNetwork loadTarForEpubBookId:chapter:isPreload:]_block_invoke_6
|
|
||||||
+[WRBookNetwork loadTarForEpubBookId:chapter:isPreload:]_block_invoke_7
|
|
||||||
+[WRBookNetwork loadTarForEpubBookId:chapters:isPreload:]
|
|
||||||
+[WRBookNetwork loadTopicReviewlist:callback:]
|
|
||||||
+[WRBookNetwork markReadingStatus:bookIds:isCancel:callback:]
|
|
||||||
+[WRBookNetwork markReadingStatus:withBookId:isCancel:withFinishInfo:callback:]
|
|
||||||
+[WRBookNetwork pollingChapterTranslateStatusWithBookId:isFreeTrialActive:referenceLocationDict:chapterTranslations:from:]
|
|
||||||
+[WRBookNetwork postReviewHideWithBookId:hide:callback:]
|
|
||||||
+[WRBookNetwork processChapterInfosFromChapterDownload:bookId:]
|
|
||||||
+[WRBookNetwork processEncryptedBookFileAtPath:encryptKey:book:chapterUid:isFromReview:]
|
|
||||||
+[WRBookNetwork repostReview:reposted:callback:]
|
|
||||||
+[WRBookNetwork resetChapterPaidIfNeededWithBookId:chapterUid:]
|
|
||||||
+[WRBookNetwork rewardReviewForId:price:timestamp:callback:]
|
|
||||||
+[WRBookNetwork savePreloadInfoWithDownloadParam:chaptersStr:timeFlag:tmpFilePath:encryptKey:]
|
|
||||||
+[WRBookNetwork searchResultsForBook:chapterUid:searchString:posBeg:posEnd:mode:callback:]
|
|
||||||
+[WRBookNetwork searchResultsForLocalBook:chapterUid:searchString:posBeg:posEnd:mode:callback:]
|
|
||||||
+[WRBookNetwork setFinishReading:withBookId:callback:]
|
|
||||||
+[WRBookNetwork setIsStartReading:withBookId:callback:]
|
|
||||||
+[WRBookNetwork storeTarEpubImageToDiskWithBookId:chapterUid:untarDirectory:]
|
|
||||||
+[WRBookNetwork uploadBookProgressAndReadingTime:callback:offlineCallback:]
|
|
||||||
+[WRBookNetworkReporter verifyZipWithBook:zipPath:fileSize:unzippedFiles:]
|
|
||||||
+[WRChapterData addUnderLineToAttributedString:range:itemId:style:color:]
|
|
||||||
+[WRChapterData freeTrialChapterCutOffStringLocaionWithAttributedString:book:]
|
|
||||||
+[WRChapterDownloadManger imageManagerForEpubBookWithBookId:]
|
|
||||||
+[WRChapterDownloadManger loadChapterContentNetworkWithParam:callback:]
|
|
||||||
+[WRChapterPageCount currentCacheKeyWithBookId:]
|
|
||||||
+[WRChapterPageCount rangeValueWithPageInfo:]
|
|
||||||
+[WRCoreTextLayouter convertHansToHantWithAttributedString:]_block_invoke
|
|
||||||
+[WREncryptedFileManager decryptContentsOfFile:forBookId:isFileLost:]
|
|
||||||
+[WREncryptedFileManager encryptFileForBookId:originalEncryptKey:atPath:toPath:]
|
|
||||||
+[WREncryptedFileManager keyForBookId:]
|
|
||||||
+[WREpubPositionConverter transformToNumberHtmlEntitesWithOriginHtmlEitites:]_block_invoke
|
|
||||||
+[WREpubTypesetter attributeStringWithFilePath:priority:insertArticleToolAttachment:insertBookChapterToolAttachment:insertRecommendView:book:chapter:pageFlippingStyle:renderErrorReason:isStyleFileNotFound:options:]
|
|
||||||
+[WREpubTypesetter tryReportTranslationError:bookId:chapter:isTranslationStyleNotFound:isTranslationContentNotFound:isTranslateTagButNoTranslateStyle:]
|
|
||||||
+[WRMarkContentStore markContentListForBookId:chapterUid:offset:count:]
|
|
||||||
+[WRMarkContentStore syncMarkContentListForBookId:chapterUid:]
|
|
||||||
+[WRMarketNetwork loadCategoryBooks:param:callback:]
|
|
||||||
+[WRPreloadBookManager clearKV]
|
|
||||||
+[WRPreloadBookManager encryptKeyForPath:bookId:]
|
|
||||||
+[WRPreloadBookManager fileNameForKey:bookId:]
|
|
||||||
+[WRPreloadBookManager removeEncryptKeyForPath:bookId:]
|
|
||||||
+[WRPreloadBookManager removeFileNameForKey:bookId:]
|
|
||||||
+[WRPreloadBookManager removeKVWithBookId:]
|
|
||||||
+[WRPreloadBookManager saveEncryptKey:forPath:bookId:]
|
|
||||||
+[WRPreloadBookManager saveFileNameDict:bookId:]
|
|
||||||
+[WRReaderBackgroundAdapter updateDownloadedBackground]_block_invoke
|
|
||||||
+[WRReaderBackgroundAdapter updateDownloadedBackground]_block_invoke_2
|
|
||||||
+[WRReaderBitmapColorHelper checkImageShouldAddBackgound:imageUrl:completedBlock:]
|
|
||||||
+[WRReaderBitmapColorHelper checkWhiteBlackImageAndTransformToTransparent:completedBlock:]
|
|
||||||
+[WRReaderBitmapColorHelper whiteToTransparentWithImage:completedBlock:]
|
|
||||||
+[WRReaderBookBorrowUtils redeemShortTimeReadWithTargetView:bookId:successBlock:]
|
|
||||||
+[WRReaderBookBorrowUtils redeemShortTimeReadWithTargetView:bookId:successBlock:]_block_invoke
|
|
||||||
+[WRReaderBookBorrowUtils showBorrowConfirmationPanelWithBorrowInfo:confirmBlock:cancelBlock:willShowBlock:]
|
|
||||||
+[WRReaderBookBorrowUtils showBorrowConfirmationPanelWithBorrowInfo:confirmBlock:cancelBlock:willShowBlock:]_block_invoke
|
|
||||||
+[WRReaderBookBorrowUtils showBorrowConfirmationPanelWithBorrowInfo:confirmBlock:cancelBlock:willShowBlock:]_block_invoke_3
|
|
||||||
+[WRReaderCatalogSearchManager appendSearchHistoryWithBookId:keyword:]
|
|
||||||
+[WRReaderCatalogSearchManager purgeSearchHistoryWithBookId:]
|
|
||||||
+[WRReaderCatalogSearchManager searchHistoryWithBookId:]
|
|
||||||
+[WRReaderCht2sManager autoConvertToCht2sStatusWithBook:dataTranslateMode:scene:hasRights:]
|
|
||||||
+[WRReaderCht2sManager canConvertCht2sWithBookId:]
|
|
||||||
+[WRReaderCht2sManager checkCht2sBookShouldSwitchLanguageForBookId:chapterUid:]
|
|
||||||
+[WRReaderContentNavigationManager addRangeValueToOutlineItem:]
|
|
||||||
+[WRReaderContentNavigationManager findParentNodesFromChapterOutlineLeafItems:]_block_invoke
|
|
||||||
+[WRReaderContentNavigationManager rangeForOutlineItem:]
|
|
||||||
+[WRReaderPencilNoteManager authCosForPencilDataWithSuffix:]_block_invoke
|
|
||||||
+[WRReaderPencilNoteManager checkDrawingExistsWithReviewItemId:reviewId:isDraft:]
|
|
||||||
+[WRReaderPencilNoteManager deleteAllReviewDrawings]
|
|
||||||
+[WRReaderPencilNoteManager deleteDrawingWithReviewItemId:reviewId:isDraft:]
|
|
||||||
+[WRReaderPencilNoteManager downloadDrawingDataFromCosWithUrl:desPath:callback:]
|
|
||||||
+[WRReaderPencilNoteManager downloadDrawingDataFromCosWithUrl:desPath:callback:]_block_invoke
|
|
||||||
+[WRReaderPencilNoteManager downloadDrawingWithReviewItemId:reviewId:drawingUrl:dataBlock:]_block_invoke_2
|
|
||||||
+[WRReaderPencilNoteManager downloadDrawingWithReviewItemId:reviewId:drawingUrl:dataBlock:]_block_invoke_3
|
|
||||||
+[WRReaderPencilNoteManager drawingFileDirectory]
|
|
||||||
+[WRReaderPencilNoteManager drawingFilePathWithReviewItemId:reviewId:isDraft:]
|
|
||||||
+[WRReaderPencilNoteManager imageFilePathWithReviewItemId:reviewId:]
|
|
||||||
+[WRReaderPencilNoteManager readDrawingWithReview:dataBlock:drawingUrlBlock:]_block_invoke
|
|
||||||
+[WRReaderPencilNoteManager readDrawingWithReview:dataBlock:drawingUrlBlock:]_block_invoke_2
|
|
||||||
+[WRReaderPencilNoteManager readDrawingWithReview:dataBlock:drawingUrlBlock:]_block_invoke_3
|
|
||||||
+[WRReaderPencilNoteManager uploadPencilDrawing:colorStyle:onlyUploadImage:canRetry:]
|
|
||||||
+[WRReaderPencilNoteManager uploadPencilDrawing:colorStyle:onlyUploadImage:canRetry:]_block_invoke
|
|
||||||
+[WRReaderPencilNoteManager uploadPencilNoteData:suffix:]
|
|
||||||
+[WRReaderPencilNoteManager uploadPencilNoteData:suffix:]_block_invoke
|
|
||||||
+[WRReaderPencilNoteManager uploadPencilNoteData:suffix:]_block_invoke_3
|
|
||||||
+[WRReaderPencilNoteManager writeDrawingDataToLocal:reviewItemId:reviewId:isDraft:]
|
|
||||||
+[WRReaderPencilNoteManager writeDrawingToLocal:reviewItemId:reviewId:isDraft:]
|
|
||||||
+[WRReaderProgress localProgressForBook:]
|
|
||||||
+[WRReaderTranslationManager canSwitchTranslationWithBookId:]
|
|
||||||
+[WRReaderTranslationManager chapterDataCacheKeyWithBookId:chapterUid:]
|
|
||||||
+[WRReaderTranslationManager isPlayChineseWithBookId:chapterUid:]
|
|
||||||
+[WRReaderTranslationManager tryPollingConvertStatusWithBook:isForTranslate:referenceLocationDict:isFreeTrialActive:chapterTranslations:updateTipsBlock:chapterTranslateCompletion:stopPollingBlock:errorBlock:isBatch:from:]_block_invoke
|
|
||||||
+[WRReaderTranslationManager updatePlayTranslation:forBookId:]
|
|
||||||
+[WRReaderTranslationManager wordCountWithBookId:chapterUid:]
|
|
||||||
+[WRReaderUnderlineStyleButtonManager bookmarkColorForColorStyle:underlineStyle:]
|
|
||||||
-[DTCoreTextGlyphRun newPathWithGlyphs]
|
|
||||||
-[DTHTMLAttributedStringBuilder _buildString]
|
|
||||||
-[DTHTMLAttributedStringBuilder _registerTagEndHandlers]_block_invoke_5
|
|
||||||
-[DTHTMLAttributedStringBuilder _registerTagStartHandlers]_block_invoke_11
|
|
||||||
-[DTHTMLAttributedStringBuilder parser:didStartElement:attributes:position:]
|
|
||||||
-[DTHTMLAttributedStringBuilder parser:foundCDATA:]
|
|
||||||
-[DTHTMLAttributedStringBuilder parser:foundCharacters:position:]
|
|
||||||
-[DTHTMLAttributedStringBuilder parserDidEndDocument:]
|
|
||||||
-[DTHTMLElement applyStyleDictionary:isLatinLanguageBook:]
|
|
||||||
-[DTHTMLElement attributedString]
|
|
||||||
-[DTHTMLElement interpretAttributes]
|
|
||||||
-[WRChapter isChapterContentDownloaded]
|
|
||||||
-[WRChapterData addAutoReadUnderLineInRange:style:color:]
|
|
||||||
-[WRChapterData addHighlightInRange:key:itemId:color:]
|
|
||||||
-[WRChapterData addReviewUnderlineInRange:itemId:type:]
|
|
||||||
-[WRChapterData addTempReviewHighlightInRange:itemId:]
|
|
||||||
-[WRChapterData addTempReviewHighlightInRange:itemId:color:]
|
|
||||||
-[WRChapterData deleteReviewUnderlineInRange:type:]
|
|
||||||
-[WRChapterData freeTrialChapterCutOffRealStringLocation]
|
|
||||||
-[WRChapterData generateOutlineContents]
|
|
||||||
-[WRChapterData markFreeTrialChapterCutOffStringLocation:]
|
|
||||||
-[WRChapterData rangeOfPage:]
|
|
||||||
-[WRChapterDownloadManger _preloadChapterContentWithBook:type:bookRank:]
|
|
||||||
-[WRChapterDownloadManger addDownloadTaskCount]
|
|
||||||
-[WRChapterDownloadManger createDownloadTaskSignalWithParam:callback:]_block_invoke
|
|
||||||
-[WRChapterDownloadManger createDownloadTaskSignalWithParam:callback:]_block_invoke_3
|
|
||||||
-[WRChapterDownloadManger downloadComicsChaptersIfNotExistWithParam:withCallback:]
|
|
||||||
-[WRChapterDownloadManger loadChapterContentWithParam:callback:]
|
|
||||||
-[WRChapterDownloadManger networkChanged]
|
|
||||||
-[WRChapterDownloadManger preloadShelfBooksForType:]
|
|
||||||
-[WRChapterDownloadManger removeDownloadTaskCount]
|
|
||||||
-[WRChapterDownloadManger resetChapterDownloadTaskCount]
|
|
||||||
-[WRChapterRecommendAlbumsView handleTouchAlbum:]
|
|
||||||
-[WRChapterRecommendAlbumsView handleTouchAlbum:]_block_invoke
|
|
||||||
-[WRChapterRecommendAlbumsView handleTouchAlbum:]_block_invoke_2
|
|
||||||
-[WRChapterRecommendAlbumsView initWithDataChangeSignal:]_block_invoke
|
|
||||||
-[WRCoreTextLayoutFrame avoidPageBreakInsideByRemovingLastLinesIfNeeded]
|
|
||||||
-[WRCoreTextLayoutFrame drawInContext:image:size:inRect:position:]
|
|
||||||
-[WRCoreTextLayoutFrame getRenderHeight]
|
|
||||||
-[WRCoreTextLayoutFrame lines]
|
|
||||||
-[WRCoreTextLayouter pageBackgroundImageAtRange:themeBgColor:]
|
|
||||||
-[WRCoreTextLayouter resizedImageForImagePath:rect:position:sizePattern:darkMode:themeBgColor:]
|
|
||||||
-[WREpubParser epubController:didFailWithError:]
|
|
||||||
-[WREpubPositionConverter indicesInFile:forRowColumnPairs:stringIndices:string:fileIndexOffset:stringIndexOffset:]
|
|
||||||
-[WREpubPositionConverter initIndices]
|
|
||||||
-[WREpubPositionConverter initWithFilePaths:attributedStrings:offset:isContainIntroFlyleaf:]
|
|
||||||
-[WREpubPositionConverter stringRangeFromFileRange:]
|
|
||||||
-[WRMarketPopToBookShelfTransition animateTransition:]_block_invoke_2
|
|
||||||
-[WRPreloadBookManager _preloadWholeBook:scene:]
|
|
||||||
-[WRPreloadBookManager _preloadWholeBook:scene:]_block_invoke
|
|
||||||
-[WRPreloadBookManager booksDirectoryInfoForNonVIPWithOnlyCalc:]
|
|
||||||
-[WRPreloadBookManager booksDirectoryInfoForVIPWithOnlyCalc:]
|
|
||||||
-[WRPreloadBookManager calcAndClearPreloadBookWithCompletion:onlyCalc:]
|
|
||||||
-[WRPreloadBookManager cleanUpPreloadBook]
|
|
||||||
-[WRPreloadBookManager cleanUpPreloadBook]_block_invoke
|
|
||||||
-[WRPreloadBookManager cleanUpPreloadBook]_block_invoke_2
|
|
||||||
-[WRPreloadBookManager downloadBook:uids:]
|
|
||||||
-[WRPreloadBookManager downloadBook:uids:]_block_invoke
|
|
||||||
-[WRPreloadBookManager downloadingUidsWithBookId:allChapters:fromChapterIdx:]
|
|
||||||
-[WRPreloadBookManager isBookCanPreloadForNonPayingVIP:]
|
|
||||||
-[WRPreloadBookManager isBookCanPreloadForPayingVIP:outReasons:]
|
|
||||||
-[WRPreloadBookManager preloadWithBook:fromChapterIdx:]
|
|
||||||
-[WRPreloadBookManager preloadWithBook:fromChapterIdx:]_block_invoke
|
|
||||||
-[WRPreloadBookManager refreshSetting]
|
|
||||||
-[WRPreloadBookManager remainingDiskSizeInMB]
|
|
||||||
-[WRPreloadBookManager resetBookDataWithBookId:]
|
|
||||||
-[WRPreloadBookManager stopCleanUpPreloadBookTask]
|
|
||||||
-[WRPreloadBookManager unzipBookCacheContentWithBookId:]
|
|
||||||
-[WRPreloadBookManager unzipBookCacheContentWithBookId:]_block_invoke
|
|
||||||
-[WRReaderAIKnowledgeViewController observeValueForKeyPath:ofObject:change:context:]
|
|
||||||
-[WRReaderAuthorReviewDetailViewController handleCommentButtonEvent:]
|
|
||||||
-[WRReaderAuthorReviewDetailViewController handleComposeButtonEvent:]_block_invoke
|
|
||||||
-[WRReaderAuthorReviewDetailViewController handleDeleteComment:]_block_invoke_2
|
|
||||||
-[WRReaderAuthorReviewDetailViewController handlePraiseButtonEvent:]
|
|
||||||
-[WRReaderAuthorReviewDetailViewController handlePraiseButtonEvent:]_block_invoke
|
|
||||||
-[WRReaderAuthorReviewDetailViewController handlePraiseButtonEvent:]_block_invoke_3
|
|
||||||
-[WRReaderAuthorReviewDetailViewController keyboardWillChangeFrameWithUserInfo:]_block_invoke
|
|
||||||
-[WRReaderAuthorReviewDetailViewController tableView:cellForRowAtIndexPath:]
|
|
||||||
-[WRReaderAuthorReviewListViewController handleAvatarButtonEvent:]
|
|
||||||
-[WRReaderAuthorReviewListViewController handlePraiseButtonEvent:]
|
|
||||||
-[WRReaderAuthorReviewListViewController handlePraiseButtonEvent:]_block_invoke
|
|
||||||
-[WRReaderAuthorReviewListViewController handlePraiseButtonEvent:]_block_invoke_3
|
|
||||||
-[WRReaderAuthorReviewListViewController tableView:cellForRowAtIndexPath:]
|
|
||||||
-[WRReaderAutoPlayer calcPageDuration]
|
|
||||||
-[WRReaderAutoPlayer clauseFinished:]
|
|
||||||
-[WRReaderAutoPlayer didBecomeActive]
|
|
||||||
-[WRReaderAutoPlayer initWithDelegate:chapterData:forVerticalReader:]
|
|
||||||
-[WRReaderAutoPlayer pause]
|
|
||||||
-[WRReaderAutoPlayer play]
|
|
||||||
-[WRReaderAutoPlayer setCurrentClauseIndex:]
|
|
||||||
-[WRReaderAutoPlayer setCurrentLocation:]
|
|
||||||
-[WRReaderAutoPlayer setSpeed:]
|
|
||||||
-[WRReaderAutoPlayer setStatus:]
|
|
||||||
-[WRReaderAutoPlayer setTmpClauseIndex:]
|
|
||||||
-[WRReaderAutoPlayer setTmpLocation:]
|
|
||||||
-[WRReaderAutoPlayer setupTimer]
|
|
||||||
-[WRReaderAutoPlayer stop]
|
|
||||||
-[WRReaderAutoPlayer willResignActive]
|
|
||||||
-[WRReaderBackground(WRReaderBackgroundAdapter_Private) downloadWithProgressHandler:completionHandler:]_block_invoke
|
|
||||||
-[WRReaderBookmarkViewController addBookmarkInCurrentReferenceArea]
|
|
||||||
-[WRReaderBookmarkViewController deleteBookmarkWithItemId:callback:]
|
|
||||||
-[WRReaderBrightnessChangeView brightSliderChanged:]
|
|
||||||
-[WRReaderBrightnessChangeView initReaderBackgroundViewUI]_block_invoke
|
|
||||||
-[WRReaderBrightnessChangeView initReaderBackgroundViewUI]_block_invoke_2
|
|
||||||
-[WRReaderBrightnessChangeView sizeThatFits:]
|
|
||||||
-[WRReaderCatalogContainerViewController pageController:didShowPageAtIndex:]_block_invoke
|
|
||||||
-[WRReaderCatalogContainerViewController pageController:titleConfigurationAtIndex:]
|
|
||||||
-[WRReaderCatalogContainerViewController pageController:viewControllerAtIndex:]
|
|
||||||
-[WRReaderCatalogContainerViewController selectCatalogViewController]
|
|
||||||
-[WRReaderCatalogContainerViewController selectOutlineViewController]
|
|
||||||
-[WRReaderCatalogContainerViewController selectSearchContentViewController]
|
|
||||||
-[WRReaderCatalogDataSource initWithBookId:type:delegate:]_block_invoke
|
|
||||||
-[WRReaderCatalogViewController shouldRefreshCurrentAndBackupIndexPathCell]
|
|
||||||
-[WRReaderChangeFirstIndentTableViewCell handleTapItemView:]
|
|
||||||
-[WRReaderChangeOrientationView tableView:cellForRowAtIndexPath:]
|
|
||||||
-[WRReaderChangePageTurningStyleView tableView:cellForRowAtIndexPath:]
|
|
||||||
-[WRReaderCht2sManager updatePlayCht2s:forBookId:]
|
|
||||||
-[WRReaderComicsViewModel chapterReviewForReviewId:]
|
|
||||||
-[WRReaderComicsViewModel contentForChapterUid:]
|
|
||||||
-[WRReaderComicsViewModel imageForURL:]_block_invoke_2
|
|
||||||
-[WRReaderComicsViewModel markAsLiked:forChapterUid:]
|
|
||||||
-[WRReaderComicsViewModel markAsLiked:forReview:]
|
|
||||||
-[WRReaderComicsViewModel preloadNextChapterIfNeededWithCurrentChapterUid:]_block_invoke
|
|
||||||
-[WRReaderComicsViewModel refreshAvailableRangeWithVisibleSectionRange:]
|
|
||||||
-[WRReaderContentNavigationManager didLoadDataWithSusseed:]_block_invoke
|
|
||||||
-[WRReaderContentNavigationManager findCurrentOutlineItemWithUniqId:chapterUid:]
|
|
||||||
-[WRReaderContentNavigationManager findNextOutlineItemInRanges:loadMoreChapterUids:]
|
|
||||||
-[WRReaderContentNavigationManager findPrevOutlineItemInRanges:loadMoreChapterUids:]
|
|
||||||
-[WRReaderContentNavigationManager hasNextOutlineItemInRanges:]
|
|
||||||
-[WRReaderContentNavigationManager hasNextOutlineItem]
|
|
||||||
-[WRReaderContentNavigationManager hasPrevOutlineItemInRanges:]
|
|
||||||
-[WRReaderContentNavigationManager hasPrevOutlineItem]
|
|
||||||
-[WRReaderContentNavigationManager initSearchDataSourceWithBook:]
|
|
||||||
-[WRReaderContentNavigationManager isCurrentOutlineItemVisible]
|
|
||||||
-[WRReaderContentNavigationManager jumpToNextOutlineItemWithPreviousLoadMoreChapterUids:]
|
|
||||||
-[WRReaderContentNavigationManager jumpToNextSearchItem]
|
|
||||||
-[WRReaderContentNavigationManager jumpToOutlineItem:]
|
|
||||||
-[WRReaderContentNavigationManager jumpToOutlineItemWithUniqId:chapterUid:highlight:]
|
|
||||||
-[WRReaderContentNavigationManager jumpToPrevOutlineItemWithPreviousLoadMoreChapterUids:]
|
|
||||||
-[WRReaderContentNavigationManager jumpToPrevSearchItem]
|
|
||||||
-[WRReaderContentNavigationManager jumpToSearchItem:]
|
|
||||||
-[WRReaderContentNavigationManager nextOutlineItemFromMemoryWithLoadMoreChapterUids:]
|
|
||||||
-[WRReaderContentNavigationManager prevOutlineItemFromMemoryWithLoadMoreChapterUids:]
|
|
||||||
-[WRReaderContentNavigationManager searchFrontAndBehindWithKeyword:chapterUid:position:addCurrentItem:callback:]
|
|
||||||
-[WRReaderContentNavigationManager searchFrontAndBehindWithKeyword:chapterUid:position:addCurrentItem:callback:]_block_invoke
|
|
||||||
-[WRReaderContentNavigationManager setCurrentSearchItem:]
|
|
||||||
-[WRReaderContentNavigationManager setupOutlineWithInitialDict:]
|
|
||||||
-[WRReaderContentNavigationManager updateOutlineDataWithBookId:]
|
|
||||||
-[WRReaderDictionaryViewController collectionView:cellForItemAtIndexPath:]
|
|
||||||
-[WRReaderDictionaryViewController observeValueForKeyPath:ofObject:change:context:]
|
|
||||||
-[WRReaderDictionaryViewController refreshData]_block_invoke_4
|
|
||||||
-[WRReaderEndOfTrialButton renderWithLabelDictionary:]
|
|
||||||
-[WRReaderEndOfTrialView handleCampaignButtonTapWithItem:]
|
|
||||||
-[WRReaderEndOfTrialView handleCampaignButtonTapWithItem:]_block_invoke
|
|
||||||
-[WRReaderEndOfTrialView parseRightsViewData]
|
|
||||||
-[WRReaderFloatReviewsViewController initHeaderToolViewIfNeeded]_block_invoke_2
|
|
||||||
-[WRReaderFloatReviewsViewController renderWithHyperlinksInfo:]
|
|
||||||
-[WRReaderFloatReviewsViewController setSelectedText:hyperlinksInfo:]
|
|
||||||
-[WRReaderFontSizeChangeView autoTestHandleChangeValueWithSlider:index:]
|
|
||||||
-[WRReaderFontSizeChangeView sizeThatFits:]
|
|
||||||
-[WRReaderFontSizeChangeView updateFontFamilyChangeButtonFont]
|
|
||||||
-[WRReaderHotUnderlinesViewController updateAppearanceForSectionHeaderView:inTableView:section:isPinned:]
|
|
||||||
-[WRReaderLastPageActionButtonContainerView createFontViewWithFinishIndex:iterationBlock:]
|
|
||||||
-[WRReaderLastPageBadgeView handleCheckButtonDownEvent:]
|
|
||||||
-[WRReaderLastPageBadgeView handleCheckButtonUpEvent:]
|
|
||||||
-[WRReaderLastPageBadgeView handleCheckButtonUpEvent:]_block_invoke_3
|
|
||||||
-[WRReaderLastPageBadgeView handleCheckButtonUpOutSideEvent:]
|
|
||||||
-[WRReaderLastPageViewController refreshFinishedBookInfo]_block_invoke
|
|
||||||
-[WRReaderLastPageViewController rootViewDidChangeIntrinsicSize:]
|
|
||||||
-[WRReaderLastPageViewController showing]
|
|
||||||
-[WRReaderMilestoneLabelView renderWithAttributedStrings:]
|
|
||||||
-[WRReaderMistakeViewController viewDidLoad]_block_invoke
|
|
||||||
-[WRReaderMistakeViewController viewDidLoad]_block_invoke_2
|
|
||||||
-[WRReaderModalView headerViewHeight]
|
|
||||||
-[WRReaderMoreOperationController renderWithBook:scene:]
|
|
||||||
-[WRReaderNoteViewModel initWithBookId:]
|
|
||||||
-[WRReaderOutlineViewController handleJumpToClickContentWithParams:]
|
|
||||||
-[WRReaderOutlineViewController initWithBookId:chapterUid:]_block_invoke
|
|
||||||
-[WRReaderOutlineViewController initWithBookId:chapterUid:]_block_invoke_2
|
|
||||||
-[WRReaderOutlineViewController viewDidLoad]
|
|
||||||
-[WRReaderPanelSelectionView panelHeaderHeight]
|
|
||||||
-[WRReaderPanelSelectionView panelTitle]
|
|
||||||
-[WRReaderPanelSelectionView tableView:cellForRowAtIndexPath:]
|
|
||||||
-[WRReaderPanelSelectionView tableView:didSelectRowAtIndexPath:]
|
|
||||||
-[WRReaderPanelSelectionView tableView:numberOfRowsInSection:]
|
|
||||||
-[WRReaderPencilNoteBaseView handleCanvasViewDrawingDidChange:]
|
|
||||||
-[WRReaderPencilNoteReviewManager editPencilReview:secretMode:imageDict:drawingUrl:didEditBlock:]_block_invoke
|
|
||||||
-[WRReaderPencilNoteReviewManager tryResendAllDraftDrawingReviews]_block_invoke
|
|
||||||
-[WRReaderPencilNoteReviewManager tryResendAllDraftDrawingReviews]_block_invoke_2
|
|
||||||
-[WRReaderPencilNoteReviewManager tryResendAllDraftDrawingReviews]_block_invoke_4
|
|
||||||
-[WRReaderPencilNoteReviewManager tryResendAllDraftDrawingReviews]_block_invoke_5
|
|
||||||
-[WRReaderPencilNoteReviewManager tryResendAllDraftDrawingReviews]_block_invoke_6
|
|
||||||
-[WRReaderPencilNoteReviewManager uploadDrawing:thenEditReview:secretMode:readerVC:didUploadBlock:didEditReviewBlock:didWriteReviewBlock:]_block_invoke
|
|
||||||
-[WRReaderPencilNoteReviewManager writeReview:secretMode:readerVC:imageDict:drawingUrl:didWriteBlock:]
|
|
||||||
-[WRReaderPencilNoteReviewManager writeReview:secretMode:readerVC:imageDict:drawingUrl:didWriteBlock:]_block_invoke
|
|
||||||
-[WRReaderPencilNoteReviewManager writeReview:secretMode:readerVC:imageDict:drawingUrl:didWriteBlock:]_block_invoke_2
|
|
||||||
-[WRReaderPencilNoteViewController addPageReview:]
|
|
||||||
-[WRReaderPencilNoteViewController addRangeReview:]
|
|
||||||
-[WRReaderPencilNoteViewController checkUpdatePencilPageReview:pageNoteView:]
|
|
||||||
-[WRReaderPencilNoteViewController checkUpdatePencilPageReview:pageNoteView:]_block_invoke
|
|
||||||
-[WRReaderPencilNoteViewController checkUpdatePencilPageReview:pageNoteView:]_block_invoke_2
|
|
||||||
-[WRReaderPencilNoteViewController checkUpdatePencilPageReview]
|
|
||||||
-[WRReaderPencilNoteViewController checkUpdatePencilPageReview]_block_invoke
|
|
||||||
-[WRReaderPencilNoteViewController checkUpdatePencilRangeReview]
|
|
||||||
-[WRReaderPencilNoteViewController checkUpdatePencilRangeReview]_block_invoke
|
|
||||||
-[WRReaderPencilNoteViewController checkUpdatePencilRangeReview]_block_invoke_2
|
|
||||||
-[WRReaderPencilNoteViewController checkUpdatePencilRangeReview]_block_invoke_3
|
|
||||||
-[WRReaderPencilNoteViewController checkUpdateRangeReviewDrawingAsDraft]
|
|
||||||
-[WRReaderPencilNoteViewController dealloc]
|
|
||||||
-[WRReaderPencilNoteViewController deleteEmptyDrawingReview:]_block_invoke_2
|
|
||||||
-[WRReaderPencilNoteViewController editPencilReview:secretMode:imageDict:drawingUrl:]_block_invoke
|
|
||||||
-[WRReaderPencilNoteViewController editRangeReviewForColorStyleSilentWithReview:]_block_invoke
|
|
||||||
-[WRReaderPencilNoteViewController editRangeReviewForColorStyleSilentWithReview:]_block_invoke_2
|
|
||||||
-[WRReaderPencilNoteViewController handleBookmarkUpdatedNotification:]
|
|
||||||
-[WRReaderPencilNoteViewController handleCloseRangeReviewEditWithShowAlert:]
|
|
||||||
-[WRReaderPencilNoteViewController handleCloseRangeReviewEditWithShowAlert:]_block_invoke
|
|
||||||
-[WRReaderPencilNoteViewController handleDeleteRangeReview]_block_invoke_2
|
|
||||||
-[WRReaderPencilNoteViewController init]
|
|
||||||
-[WRReaderPencilNoteViewController resetContextWithRangeReviews:chapterIdx:defaultSecretMode:]
|
|
||||||
-[WRReaderPencilNoteViewController setupPageNoteView]_block_invoke
|
|
||||||
-[WRReaderPencilNoteViewController setupRangeNoteView]
|
|
||||||
-[WRReaderPencilNoteViewController setupRangeNoteView]_block_invoke
|
|
||||||
-[WRReaderPencilNoteViewController switchToRangeReview:curRange:curColorStyle:]
|
|
||||||
-[WRReaderPencilPageNoteListView addCanvasViewDrawingDidChangeBlockWithNoteView:]_block_invoke
|
|
||||||
-[WRReaderPencilPageNoteListView autoSaveEditingDrawingToLocalAsDraft]
|
|
||||||
-[WRReaderPencilPageNoteListView handleUpdatePageNoteReviewsWithBlock:]
|
|
||||||
-[WRReaderProgressChangeView handleNextButtonClick:]
|
|
||||||
-[WRReaderProgressChangeView handlePreButtonClick:]
|
|
||||||
-[WRReaderProgressChangeView sizeThatFits:]
|
|
||||||
-[WRReaderProgressData initWithBook:forPDF:]
|
|
||||||
-[WRReaderProgressData toCGIJsonDict]
|
|
||||||
-[WRReaderReviewsRNViewController handleWriteReviewButtonEvent:]
|
|
||||||
-[WRReaderSegmentViewController handleNotesOutputComputerButton]_block_invoke_2
|
|
||||||
-[WRReaderSegmentViewController initNoteControllerIfNeeded]
|
|
||||||
-[WRReaderSinglePurchaseEndTrailBookContentView renderData]
|
|
||||||
-[WRReaderTextSelector updateTurningPageTimerWithGesture:]
|
|
||||||
-[WRReaderTimer handleReadingTime:]
|
|
||||||
-[WRReaderTimer setActive:]
|
|
||||||
-[WRReaderTimer startTimer]
|
|
||||||
-[WRReaderUnderlineStyleButtonManager updateSelectedStyleButtonUI]
|
|
||||||
-[WRReaderViewController _saveReadingProgressAndIsAsync:]
|
|
||||||
-[WRReaderViewController addObservers]_block_invoke
|
|
||||||
-[WRReaderViewController addObservers]_block_invoke_2
|
|
||||||
-[WRReaderViewController animateAfterWritePageReview]_block_invoke
|
|
||||||
-[WRReaderViewController autoTestGetCurrentScreenContentString]
|
|
||||||
-[WRReaderViewController calculateReadingTimeAndUploadIfNeeded]
|
|
||||||
-[WRReaderViewController calculateReadingTimeFromLastReadDate]
|
|
||||||
-[WRReaderViewController canReadFromLocalWithChapterData:]
|
|
||||||
-[WRReaderViewController changeTypesetterAttributesWithBlock:]
|
|
||||||
-[WRReaderViewController checkLocationInCurrentPage:stringLocation:chapterIdx:]
|
|
||||||
-[WRReaderViewController detectExcessPageViews]
|
|
||||||
-[WRReaderViewController didEnterBackGround]
|
|
||||||
-[WRReaderViewController didEnterForeGround]
|
|
||||||
-[WRReaderViewController didFlipPage]
|
|
||||||
-[WRReaderViewController didReceiveMemoryWarning]
|
|
||||||
-[WRReaderViewController firstlyWillRenderPageView:progressData:source:]
|
|
||||||
-[WRReaderViewController gotoChapterIdx:filePosition:pageOfFlyleaf:shouldAutoJumpToCover:]
|
|
||||||
-[WRReaderViewController gotoChapterIdx:position:positionOfFile:]
|
|
||||||
-[WRReaderViewController handleBackProgressButtonClick:]
|
|
||||||
-[WRReaderViewController handleLastPageGesture:]
|
|
||||||
-[WRReaderViewController handleLastPageTapGesture:]
|
|
||||||
-[WRReaderViewController handleSizeChanged]
|
|
||||||
-[WRReaderViewController initChapterPageCount]
|
|
||||||
-[WRReaderViewController initChapterPageCount]_block_invoke
|
|
||||||
-[WRReaderViewController initChapterPageCount]_block_invoke_3
|
|
||||||
-[WRReaderViewController initWithBook:progress:forceUseInitialProgress:doodleMode:autoRead:]
|
|
||||||
-[WRReaderViewController initWithBookId:]
|
|
||||||
-[WRReaderViewController invokeVaildReadingProgress]
|
|
||||||
-[WRReaderViewController isLastPageWithChapterIdx:page:]
|
|
||||||
-[WRReaderViewController isReviewChapterFirstPageWithChapterData:page:]
|
|
||||||
-[WRReaderViewController isReviewChapterInMiddlePageWithChapterData:page:]
|
|
||||||
-[WRReaderViewController isReviewChapterLastPageWithChapterData:page:]
|
|
||||||
-[WRReaderViewController jumpReadingToNeigbourChapterForward:]
|
|
||||||
-[WRReaderViewController jumpReadingToNextChapter]
|
|
||||||
-[WRReaderViewController jumpReadingToPreChapter]
|
|
||||||
-[WRReaderViewController jumpToChapterUid:chapterOffset:shouldAutoJumpToCover:jumpToCloseChapterIdxIfNotExists:jumpFromStartProgress:completion:]
|
|
||||||
-[WRReaderViewController jumpToChapterUid:chapterOffset:shouldAutoJumpToCover:jumpToCloseChapterIdxIfNotExists:jumpFromStartProgress:completion:]_block_invoke
|
|
||||||
-[WRReaderViewController jumpToChapterUid:page:]
|
|
||||||
-[WRReaderViewController loadCurrentPagePencilNotes]
|
|
||||||
-[WRReaderViewController pageView:didClickPencilNote:]
|
|
||||||
-[WRReaderViewController recomposeAndClearCache:]
|
|
||||||
-[WRReaderViewController recomposeCurrentPageViewWithSource:]
|
|
||||||
-[WRReaderViewController recomposeLoadedPageView:]
|
|
||||||
-[WRReaderViewController refreshWithBook:]
|
|
||||||
-[WRReaderViewController reloadCurrentPageView]
|
|
||||||
-[WRReaderViewController reloadPageViewsWithProgressData:source:]
|
|
||||||
-[WRReaderViewController removeAllPageViewLinkBookPaperHighlight]
|
|
||||||
-[WRReaderViewController removeExcessPageViews:pageViewsToKeep:]
|
|
||||||
-[WRReaderViewController renderErrorPageView:error:chapter:andChapterFormat:progressData:source:]
|
|
||||||
-[WRReaderViewController renderLoadingContentPageView:progressData:source:]
|
|
||||||
-[WRReaderViewController renderLoadingContentPageView:progressData:source:]_block_invoke
|
|
||||||
-[WRReaderViewController renderPageView:nextToChapterIdx:page:]
|
|
||||||
-[WRReaderViewController renderPageView:previousToChapterIdx:page:]
|
|
||||||
-[WRReaderViewController renderPageView:progressData:source:]
|
|
||||||
-[WRReaderViewController savePreviousPagePencilNote]
|
|
||||||
-[WRReaderViewController scrollToLastReadingProgressIfNeeded]
|
|
||||||
-[WRReaderViewController setBaseReaderViewModel:]_block_invoke_2
|
|
||||||
-[WRReaderViewController showMileStoreRemind]
|
|
||||||
-[WRReaderViewController startShowingReadingPageView]
|
|
||||||
-[WRReaderViewController syncAuthorFlyleafDatas]
|
|
||||||
-[WRReaderViewController syncBookInfoAndChapterInfoWithCallback:]_block_invoke
|
|
||||||
-[WRReaderViewController syncFlyleapDataWithBook:]
|
|
||||||
-[WRReaderViewController tryToJumpToAuthorFlyLeafPage]
|
|
||||||
-[WRReaderViewController tryToJumpToAuthorFlyLeafPage]_block_invoke
|
|
||||||
-[WRReaderViewController turnToPlayingFileLocation:stringLocation:chapterIdx:]
|
|
||||||
-[WRReaderViewController uploadReadingProgressWithCallback:]
|
|
||||||
-[WRReaderViewController viewDidLoad]
|
|
||||||
-[WRReaderViewController viewWillDisappear:]
|
|
||||||
-[WRReaderViewController willPopInNavigationControllerWithAnimated:]
|
|
||||||
-[WRReaderViewModel loadChapterDataWithChapterIdx:reviewVid:tryRead:callback:]
|
|
||||||
-[WRReaderViewModel updateInfoAfterAutoPaidChapter:]
|
|
||||||
@@ -1,320 +0,0 @@
|
|||||||
DTCoreText
|
|
||||||
DTCoreTextFontCollection
|
|
||||||
DTCoreTextFontDescriptor
|
|
||||||
DTCoreTextGlyphRun
|
|
||||||
DTCoreTextLayoutFrame
|
|
||||||
DTCoreTextLayoutFrameAccessibilityElementGenerator
|
|
||||||
DTCoreTextLayoutLine
|
|
||||||
DTCoreTextLayouter
|
|
||||||
DTCoreTextParagraphStyle
|
|
||||||
DTHTMLAttributedStringBuilder
|
|
||||||
DTHTMLElement
|
|
||||||
DTHTMLParser
|
|
||||||
DTHTMLParserDelegate
|
|
||||||
DTHTMLParserNode
|
|
||||||
DTHTMLParserTextNode
|
|
||||||
WRBookMarketManager
|
|
||||||
WRBookMarketRankListDataSource
|
|
||||||
WRBookMarketSearchData
|
|
||||||
WRBookMarketSearchDataSource
|
|
||||||
WRBookMarketUIHelper
|
|
||||||
WRBookMarketViewController
|
|
||||||
WRBookNetwork
|
|
||||||
WRBookNetworkReporter
|
|
||||||
WRBookmark
|
|
||||||
WRChapter
|
|
||||||
WRChapterAnchor
|
|
||||||
WRChapterData
|
|
||||||
WRChapterDownloadManger
|
|
||||||
WRChapterDownloadParam
|
|
||||||
WRChapterListener
|
|
||||||
WRChapterPageCount
|
|
||||||
WRChapterReadData
|
|
||||||
WRChapterRecommendAlbumsView
|
|
||||||
WRChapterRecommendAlbumsViewCell
|
|
||||||
WRChapterReviewsDownLoadModel
|
|
||||||
WRCoreTextFloatStatement
|
|
||||||
WRCoreTextImageAttributedStringFormatter
|
|
||||||
WRCoreTextLayoutFrame
|
|
||||||
WRCoreTextLayouter
|
|
||||||
WREncryptedFileManager
|
|
||||||
WREpubPage
|
|
||||||
WREpubParser
|
|
||||||
WREpubPositionConverter
|
|
||||||
WREpubTypesetter
|
|
||||||
WRMarkContent
|
|
||||||
WRMarkContentChapter
|
|
||||||
WRMarkContentInfoViewModel
|
|
||||||
WRMarkContentStore
|
|
||||||
WRMarkContentViewModel
|
|
||||||
WRMarketIcon
|
|
||||||
WRMarketNetwork
|
|
||||||
WRMarketPopToBookShelfTransition
|
|
||||||
WRMarketPopToDiscoverTransition
|
|
||||||
WRMarketPushToSearchTransition
|
|
||||||
WRMarketSearchModel
|
|
||||||
WRMarketSearchScopeTab
|
|
||||||
WRPageAVAttachment
|
|
||||||
WRPageAudioAttachment
|
|
||||||
WRPageAudioView
|
|
||||||
WRPageBookAttachment
|
|
||||||
WRPageBookView
|
|
||||||
WRPageChapterRewardAttachment
|
|
||||||
WRPageChapterRewardView
|
|
||||||
WRPageChapterToolAttachment
|
|
||||||
WRPageChapterToolAttachmentDelegate
|
|
||||||
WRPageChapterToolAttachmentHelper
|
|
||||||
WRPageChapterToolDelegate
|
|
||||||
WRPageChapterToolModel
|
|
||||||
WRPageChapterToolShareTipsView
|
|
||||||
WRPageChapterToolStore
|
|
||||||
WRPageCodeView
|
|
||||||
WRPageCodeViewController
|
|
||||||
WRPageComicView
|
|
||||||
WRPageCountReportManager
|
|
||||||
WRPageCoverAttachment
|
|
||||||
WRPageCoverAttachmentDelegate
|
|
||||||
WRPageCoverOrPageFlyleafAttachmentDelegate
|
|
||||||
WRPageData
|
|
||||||
WRPageElement
|
|
||||||
WRPageFlowCollectionViewLayout
|
|
||||||
WRPageFlyleafAttachment
|
|
||||||
WRPageHighlight
|
|
||||||
WRPageHighlightView
|
|
||||||
WRPageHyperlinksAttachment
|
|
||||||
WRPageHyperlinksAttachmentDelegate
|
|
||||||
WRPageIframeAttachment
|
|
||||||
WRPageIframeView
|
|
||||||
WRPageImageAttachment
|
|
||||||
WRPageImageAttachmentDelegate
|
|
||||||
WRPageImageAttachmentPreviewDelegate
|
|
||||||
WRPageImageAttachmentPreviewViewController
|
|
||||||
WRPageImageView
|
|
||||||
WRPageMark
|
|
||||||
WRPageMarkDelegate
|
|
||||||
WRPageMarkView
|
|
||||||
WRPagePreAttachment
|
|
||||||
WRPageRemindView
|
|
||||||
WRPageSearchHighlight
|
|
||||||
WRPageSliderAttachment
|
|
||||||
WRPageTableAttachment
|
|
||||||
WRPageTextView
|
|
||||||
WRPageTextViewDelegate
|
|
||||||
WRPageTurnModeControl
|
|
||||||
WRPageUnderline
|
|
||||||
WRPageVideoView
|
|
||||||
WRPageView
|
|
||||||
WRPageViewController
|
|
||||||
WRPageViewControllerDelegate
|
|
||||||
WRPageViewDelegate
|
|
||||||
WRPageViewRichElementStub
|
|
||||||
WRPreloadBookManager
|
|
||||||
WRPreloadBookSettingHeaderView
|
|
||||||
WRPreloadBookSettingViewController
|
|
||||||
WRPreloadStatisticsManager
|
|
||||||
WRReaderAIKnowledgeViewController
|
|
||||||
WRReaderAdDialog
|
|
||||||
WRReaderAnchorManager
|
|
||||||
WRReaderAuthorReviewDetailViewController
|
|
||||||
WRReaderAuthorReviewListViewCell
|
|
||||||
WRReaderAuthorReviewListViewController
|
|
||||||
WRReaderAutoPlayer
|
|
||||||
WRReaderAutoPlayerDelegate
|
|
||||||
WRReaderBackground
|
|
||||||
WRReaderBackgroundAdapter
|
|
||||||
WRReaderBackgroundChangeCell
|
|
||||||
WRReaderBackgroundChangeView
|
|
||||||
WRReaderBasePopupCellAdapter
|
|
||||||
WRReaderBitmapColorHelper
|
|
||||||
WRReaderBodyImageHelper
|
|
||||||
WRReaderBookBorrowUtils
|
|
||||||
WRReaderBookIntroControl
|
|
||||||
WRReaderBookReviewStarView
|
|
||||||
WRReaderBookmarkCell
|
|
||||||
WRReaderBookmarkViewController
|
|
||||||
WRReaderBorrowButton
|
|
||||||
WRReaderBrightnessBackgroundButton
|
|
||||||
WRReaderBrightnessChangeView
|
|
||||||
WRReaderBusinessHelper
|
|
||||||
WRReaderCatalogBaseCell
|
|
||||||
WRReaderCatalogBaseCellModel
|
|
||||||
WRReaderCatalogBaseCellView
|
|
||||||
WRReaderCatalogBatchBuyChapterView
|
|
||||||
WRReaderCatalogBookMarkCell
|
|
||||||
WRReaderCatalogBookMarkCellView
|
|
||||||
WRReaderCatalogCell
|
|
||||||
WRReaderCatalogCellUtils
|
|
||||||
WRReaderCatalogContainerViewController
|
|
||||||
WRReaderCatalogCurrentReadingInfo
|
|
||||||
WRReaderCatalogDataSource
|
|
||||||
WRReaderCatalogDataSourceDelegate
|
|
||||||
WRReaderCatalogDataSourceProtocol
|
|
||||||
WRReaderCatalogFooterCell
|
|
||||||
WRReaderCatalogHeaderView
|
|
||||||
WRReaderCatalogItemModel
|
|
||||||
WRReaderCatalogReadingCell
|
|
||||||
WRReaderCatalogReadingCellModel
|
|
||||||
WRReaderCatalogReadingCellView
|
|
||||||
WRReaderCatalogSearchHistoryView
|
|
||||||
WRReaderCatalogSearchManager
|
|
||||||
WRReaderCatalogSectionItem
|
|
||||||
WRReaderCatalogTableHeaderView
|
|
||||||
WRReaderCatalogViewController
|
|
||||||
WRReaderChangeFirstIndentTableViewCell
|
|
||||||
WRReaderChangeFirstIndentTableViewCellItemView
|
|
||||||
WRReaderChangeFirstIndentView
|
|
||||||
WRReaderChangeFirstIndentViewController
|
|
||||||
WRReaderChangeOrientationView
|
|
||||||
WRReaderChangeOrientationViewController
|
|
||||||
WRReaderChangePageTurningStyleView
|
|
||||||
WRReaderChangePageTurningStyleViewController
|
|
||||||
WRReaderCht2sManager
|
|
||||||
WRReaderColorThemeManager
|
|
||||||
WRReaderColorableProtocol
|
|
||||||
WRReaderComicsViewModel
|
|
||||||
WRReaderContentNavigationBottomControl
|
|
||||||
WRReaderContentNavigationControl
|
|
||||||
WRReaderContentNavigationManager
|
|
||||||
WRReaderContentNavigationTopControl
|
|
||||||
WRReaderContext
|
|
||||||
WRReaderControlBarView
|
|
||||||
WRReaderCurProgressTipsView
|
|
||||||
WRReaderDictionaryAIKnowledgeCollectionViewCell
|
|
||||||
WRReaderDictionaryCollectionBaseViewCell
|
|
||||||
WRReaderDictionaryCollectionResultViewCell
|
|
||||||
WRReaderDictionaryCollectionView
|
|
||||||
WRReaderDictionaryEncyclopediaCollectionViewCell
|
|
||||||
WRReaderDictionarySearchCollectionViewCell
|
|
||||||
WRReaderDictionaryTranslationCollectionViewCell
|
|
||||||
WRReaderDictionaryView
|
|
||||||
WRReaderDictionaryViewController
|
|
||||||
WRReaderEndOfTrialButton
|
|
||||||
WRReaderEndOfTrialView
|
|
||||||
WRReaderExchangePushInfo
|
|
||||||
WRReaderExperienceViewController
|
|
||||||
WRReaderFloatReviewsViewController
|
|
||||||
WRReaderFont
|
|
||||||
WRReaderFontChangeCategoryViewController
|
|
||||||
WRReaderFontChangeCategoryViewModel
|
|
||||||
WRReaderFontChangeCell
|
|
||||||
WRReaderFontChangeModalView
|
|
||||||
WRReaderFontChangeShareViewModel
|
|
||||||
WRReaderFontChangeTabsView
|
|
||||||
WRReaderFontChangeViewController
|
|
||||||
WRReaderFontConfig
|
|
||||||
WRReaderFontSizeChangeView
|
|
||||||
WRReaderFontTableViewCell
|
|
||||||
WRReaderFooterPageView
|
|
||||||
WRReaderFooternotePopupView
|
|
||||||
WRReaderHotUnderLinesTotalCountViewController
|
|
||||||
WRReaderHotUnderlinesCell
|
|
||||||
WRReaderHotUnderlinesViewController
|
|
||||||
WRReaderIncentiveButton
|
|
||||||
WRReaderInfoDebuggerViewController
|
|
||||||
WRReaderInfoDebuggerViewModel
|
|
||||||
WRReaderLastPageActionButton
|
|
||||||
WRReaderLastPageActionButtonContainerView
|
|
||||||
WRReaderLastPageBadgeView
|
|
||||||
WRReaderLastPageDelegate
|
|
||||||
WRReaderLastPageHelper
|
|
||||||
WRReaderLastPageMilestoneCardView
|
|
||||||
WRReaderLastPageReviewItem
|
|
||||||
WRReaderLastPageReviewsView
|
|
||||||
WRReaderLastPageViewController
|
|
||||||
WRReaderMaskProtocol
|
|
||||||
WRReaderMaskViewController
|
|
||||||
WRReaderMenuView
|
|
||||||
WRReaderMilestoneLabelView
|
|
||||||
WRReaderMistakeDialogContentView
|
|
||||||
WRReaderMistakeViewController
|
|
||||||
WRReaderModalView
|
|
||||||
WRReaderMoreOperationController
|
|
||||||
WRReaderNoteViewModel
|
|
||||||
WRReaderNotesSectionHeaderView
|
|
||||||
WRReaderOSSLogHelper
|
|
||||||
WRReaderOutlineViewController
|
|
||||||
WRReaderPaidVIPButton
|
|
||||||
WRReaderPanelSelectionView
|
|
||||||
WRReaderPanelSelectionViewController
|
|
||||||
WRReaderPencilNote
|
|
||||||
WRReaderPencilNoteBackgroundView
|
|
||||||
WRReaderPencilNoteBaseView
|
|
||||||
WRReaderPencilNoteButtonGroupView
|
|
||||||
WRReaderPencilNoteEraserTips
|
|
||||||
WRReaderPencilNoteEraserTipsManager
|
|
||||||
WRReaderPencilNoteHelper
|
|
||||||
WRReaderPencilNoteManager
|
|
||||||
WRReaderPencilNoteMaskView
|
|
||||||
WRReaderPencilNoteRangeReviewItem
|
|
||||||
WRReaderPencilNoteRangeReviewsPanel
|
|
||||||
WRReaderPencilNoteReviewManager
|
|
||||||
WRReaderPencilNoteTopToolView
|
|
||||||
WRReaderPencilNoteViewController
|
|
||||||
WRReaderPencilPageNoteListView
|
|
||||||
WRReaderPencilPageNoteView
|
|
||||||
WRReaderPencilRangeNoteView
|
|
||||||
WRReaderPlayerEntranceView
|
|
||||||
WRReaderPopupInfoTableViewCell
|
|
||||||
WRReaderPopupLoadMoreTableViewCell
|
|
||||||
WRReaderPopupReviewTableViewCell
|
|
||||||
WRReaderPresellPageView
|
|
||||||
WRReaderProgress
|
|
||||||
WRReaderProgressButton
|
|
||||||
WRReaderProgressChangeRuleView
|
|
||||||
WRReaderProgressChangeView
|
|
||||||
WRReaderProgressData
|
|
||||||
WRReaderProgressShareView
|
|
||||||
WRReaderProgressSlider
|
|
||||||
WRReaderProgressSliderDelegate
|
|
||||||
WRReaderProgressSmallChangeView
|
|
||||||
WRReaderReadOverToBuyView
|
|
||||||
WRReaderRefreshFooterView
|
|
||||||
WRReaderRefreshHeaderView
|
|
||||||
WRReaderReivewCountLabel
|
|
||||||
WRReaderReviewToolsView
|
|
||||||
WRReaderReviewsRNViewController
|
|
||||||
WRReaderSearchTableHeaderView
|
|
||||||
WRReaderSegmentViewController
|
|
||||||
WRReaderSettingPanelManager
|
|
||||||
WRReaderSettingPanelView
|
|
||||||
WRReaderSettingProtocol
|
|
||||||
WRReaderSinglePurchaseEndTrailBookContentView
|
|
||||||
WRReaderSizeManager
|
|
||||||
WRReaderStatisticPanelViewController
|
|
||||||
WRReaderStatisticRNViewController
|
|
||||||
WRReaderTextSelector
|
|
||||||
WRReaderTimeHelper
|
|
||||||
WRReaderTimer
|
|
||||||
WRReaderTraditionalChineseView
|
|
||||||
WRReaderTranslationManager
|
|
||||||
WRReaderTranslationUpgradeViewController
|
|
||||||
WRReaderUIFactory
|
|
||||||
WRReaderUnderlineStyleButtonManager
|
|
||||||
WRReaderVIPButton
|
|
||||||
WRReaderVIPButtonProtocol
|
|
||||||
WRReaderViewController
|
|
||||||
WRReaderViewControllerDelegate
|
|
||||||
WRReaderViewControllerHelper
|
|
||||||
WRReaderViewModel
|
|
||||||
WRReaderVisiblePageInfo
|
|
||||||
WRReaderVoteView
|
|
||||||
WRReaderWelfareCoinPanel
|
|
||||||
WRReaderWriteReviewSimpleView
|
|
||||||
WRUnderLineView
|
|
||||||
WRUnderline
|
|
||||||
WRUnderlineColorButtonsScrollView
|
|
||||||
WRUnderlineLayoutManager
|
|
||||||
WRUnderlineStore
|
|
||||||
WrBookmarkLoadErrorReport
|
|
||||||
WrBookmarkLoadErrorReportRoot
|
|
||||||
WrPageFlippingAnimationTypeRoot
|
|
||||||
WrReaderActionReport
|
|
||||||
WrReaderActionReportRoot
|
|
||||||
WrReaderActionRoot
|
|
||||||
WrReaderItemtypeRoot
|
|
||||||
WrReaderModuleRoot
|
|
||||||
WrReaderPayingtypeRoot
|
|
||||||
WrReaderSearchAuthoriedFailedTypeRoot
|
|
||||||
WrReaderSearchFailedtypeRoot
|
|
||||||
WrReaderSearchWords
|
|
||||||
WrReaderSearchWordsRoot
|
|
||||||
@@ -1,798 +0,0 @@
|
|||||||
ivarLayout 0x1031bcf9c
|
|
||||||
layout map 0x11 0x12
|
|
||||||
name 0x1031bcf89 DTCoreTextLayouter
|
|
||||||
baseMethods 0x102acf508
|
|
||||||
entsize 12 (relative)
|
|
||||||
count 12
|
|
||||||
name 0x104ef88 (0x103b1e498)
|
|
||||||
types 0x95e950 (0x10342de64) @24@0:8@16
|
|
||||||
imp 0xfd5764d8 (0x1000459f0)
|
|
||||||
name 0x103a3ec (0x103b09908)
|
|
||||||
types 0x95e89b (0x10342ddbb) v16@0:8
|
|
||||||
imp 0xfd576560 (0x100045a84)
|
|
||||||
name 0x1058528 (0x103b27a50)
|
|
||||||
types 0x95ff95 (0x10342f4c1) @64@0:8{CGRect={CGPoint=dd}{CGSize=dd}}16{_NSRange=QQ}48
|
|
||||||
imp 0xfd576598 (0x100045ac8)
|
|
||||||
name 0x102881c (0x103af7d50)
|
|
||||||
types 0x95e883 (0x10342ddbb) v16@0:8
|
|
||||||
imp 0xfd576734 (0x100045c70)
|
|
||||||
name 0x10436d0 (0x103b12c10)
|
|
||||||
types 0x95ffb6 (0x10342f4fa) ^{__CTFramesetter=}16@0:8
|
|
||||||
imp 0xfd576754 (0x100045c9c)
|
|
||||||
name 0x1075b84 (0x103b450d0)
|
|
||||||
types 0x95e860 (0x10342ddb0) v24@0:8@16
|
|
||||||
imp 0xfd5767c4 (0x100045d18)
|
|
||||||
name 0x102e2c8 (0x103afd820)
|
|
||||||
types 0x95e84c (0x10342dda8) @16@0:8
|
|
||||||
imp 0xfd57684c (0x100045dac)
|
|
||||||
name 0x108cf3c (0x103b5c4a0)
|
|
||||||
types 0x95e8b5 (0x10342de1d) v20@0:8B16
|
|
||||||
imp 0xfd576848 (0x100045db4)
|
|
||||||
name 0x1094710 (0x103b63c80)
|
|
||||||
types 0x95e9a8 (0x10342df1c) B16@0:8
|
|
||||||
imp 0xfd57689c (0x100045e14)
|
|
||||||
--
|
|
||||||
reserved 0x0
|
|
||||||
ivarLayout 0x0
|
|
||||||
name 0x1031bcf89 DTCoreTextLayouter
|
|
||||||
baseMethods 0x0
|
|
||||||
baseProtocols 0x0
|
|
||||||
ivars 0x0
|
|
||||||
weakIvarLayout 0x0
|
|
||||||
baseProperties 0x0
|
|
||||||
000000010383bc60 0x103ba2550
|
|
||||||
isa 0x103ba2528
|
|
||||||
superclass 0x0 _OBJC_CLASS_$_NSObject
|
|
||||||
cache 0x0 __objc_empty_cache
|
|
||||||
vtable 0x0
|
|
||||||
data 0x10388b308
|
|
||||||
flags 0x90
|
|
||||||
instanceStart 8
|
|
||||||
instanceSize 8
|
|
||||||
reserved 0x0
|
|
||||||
ivarLayout 0x0
|
|
||||||
name 0x1031bcf9f TABAnimatedChainManagerImpl
|
|
||||||
baseMethods 0x102acf5a0
|
|
||||||
entsize 12 (relative)
|
|
||||||
count 3
|
|
||||||
name 0x1033af0 (0x103b03098)
|
|
||||||
types 0x95ff9d (0x10342f549) v48@0:8@16@24@?32@40
|
|
||||||
imp 0xfd576cac (0x10004625c)
|
|
||||||
name 0x1033aec (0x103b030a0)
|
|
||||||
types 0x95ffa6 (0x10342f55e) v56@0:8@16@24@?32#40@48
|
|
||||||
imp 0xfd576d1c (0x1000462d8)
|
|
||||||
name 0x103bf00 (0x103b0b4c0)
|
|
||||||
types 0x95e7f7 (0x10342ddbb) v16@0:8
|
|
||||||
imp 0xfd576d9c (0x100046364)
|
|
||||||
baseProtocols 0x10388b260
|
|
||||||
--
|
|
||||||
ivarLayout 0x1031bc939
|
|
||||||
layout map 0x04
|
|
||||||
name 0x1031bfb46 WREpubParser
|
|
||||||
baseMethods 0x102aec440
|
|
||||||
entsize 12 (relative)
|
|
||||||
count 11
|
|
||||||
name 0x1032c20 (0x103b1f068)
|
|
||||||
types 0x9419c3 (0x10342de0f) @32@0:8@16@24
|
|
||||||
imp 0xfd7ec460 (0x1002d88b0)
|
|
||||||
name 0x10459b4 (0x103b31e08)
|
|
||||||
types 0x941ac4 (0x10342df1c) B16@0:8
|
|
||||||
imp 0xfd7ec68c (0x1002d8ae8)
|
|
||||||
name 0x1023418 (0x103b0f878)
|
|
||||||
types 0x9419c4 (0x10342de28) v32@0:8@16@24
|
|
||||||
imp 0xfd7ec6b4 (0x1002d8b1c)
|
|
||||||
name 0x102f9b4 (0x103b1be20)
|
|
||||||
types 0x94199f (0x10342de0f) @32@0:8@16@24
|
|
||||||
imp 0xfd7ed284 (0x1002d96f8)
|
|
||||||
name 0x102f5a0 (0x103b1ba18)
|
|
||||||
types 0x941993 (0x10342de0f) @32@0:8@16@24
|
|
||||||
imp 0xfd7ed468 (0x1002d98e8)
|
|
||||||
name 0x101a8d4 (0x103b06d58)
|
|
||||||
types 0x941987 (0x10342de0f) @32@0:8@16@24
|
|
||||||
imp 0xfd7ed4ec (0x1002d9978)
|
|
||||||
name 0x100ead8 (0x103afaf68)
|
|
||||||
types 0x9454c8 (0x10343195c) v40@0:8@16@24Q32
|
|
||||||
imp 0xfd7ed82c (0x1002d9cc4)
|
|
||||||
name 0x100dc6c (0x103afa108)
|
|
||||||
types 0x94b021 (0x1034374c1) v56@0:8@16@24Q32@40@48
|
|
||||||
imp 0xfd7edbd0 (0x1002da074)
|
|
||||||
name 0x10233c8 (0x103b0f870)
|
|
||||||
types 0x94197c (0x10342de28) v32@0:8@16@24
|
|
||||||
imp 0xfd7ee028 (0x1002da4d8)
|
|
||||||
--
|
|
||||||
reserved 0x0
|
|
||||||
ivarLayout 0x0
|
|
||||||
name 0x1031bfb46 WREpubParser
|
|
||||||
baseMethods 0x102aec428
|
|
||||||
entsize 12 (relative)
|
|
||||||
count 1
|
|
||||||
name 0x1046580 (0x103b329b0)
|
|
||||||
types 0x941a30 (0x10342de64) @24@0:8@16
|
|
||||||
imp 0xfd7ee188 (0x1002da5c0)
|
|
||||||
baseProtocols 0x1038b61b8
|
|
||||||
count 1
|
|
||||||
list[0] 0x103c17ce8
|
|
||||||
isa 0x0
|
|
||||||
name 0x1031bfb53 KFEpubControllerDelegate
|
|
||||||
protocols 0x1038b6130
|
|
||||||
count 1
|
|
||||||
list[0] 0x103c15580
|
|
||||||
isa 0x0
|
|
||||||
name 0x1031bc9c5 NSObject
|
|
||||||
protocols 0x0
|
|
||||||
instanceMethods 0x103884670
|
|
||||||
entsize 24
|
|
||||||
count 19
|
|
||||||
name 0x103348607 isEqual:
|
|
||||||
types 0x10342de6f B24@0:8@16
|
|
||||||
imp 0x0
|
|
||||||
name 0x1032e9bde class
|
|
||||||
types 0x10342df44 #16@0:8
|
|
||||||
imp 0x0
|
|
||||||
name 0x1033a90f1 self
|
|
||||||
types 0x10342dda8 @16@0:8
|
|
||||||
imp 0x0
|
|
||||||
name 0x1033780e4 performSelector:
|
|
||||||
--
|
|
||||||
ivarLayout 0x1031bfd8e
|
|
||||||
layout map 0x16 0x1c 0x12 0x15 0x11
|
|
||||||
name 0x1031bfd80 WRChapterData
|
|
||||||
baseMethods 0x102aeddf0
|
|
||||||
entsize 12 (relative)
|
|
||||||
count 118
|
|
||||||
name 0x102f158 (0x103b1cf50)
|
|
||||||
types 0x93ffac (0x10342dda8) @16@0:8
|
|
||||||
imp 0xfd809f38 (0x1002f7d38)
|
|
||||||
name 0x101552c (0x103b03330)
|
|
||||||
types 0x940101 (0x10342df09) q16@0:8
|
|
||||||
imp 0xfd809f80 (0x1002f7d8c)
|
|
||||||
name 0x1019920 (0x103b07730)
|
|
||||||
types 0x94150c (0x10342f320) {_NSRange=QQ}16@0:8
|
|
||||||
imp 0xfd80a044 (0x1002f7e5c)
|
|
||||||
name 0x103fd04 (0x103b2db20)
|
|
||||||
types 0x941500 (0x10342f320) {_NSRange=QQ}16@0:8
|
|
||||||
imp 0xfd80a07c (0x1002f7ea0)
|
|
||||||
name 0x1033920 (0x103b21748)
|
|
||||||
types 0x9400dd (0x10342df09) q16@0:8
|
|
||||||
imp 0xfd80a0b0 (0x1002f7ee0)
|
|
||||||
name 0x10353cc (0x103b23200)
|
|
||||||
types 0x94427a (0x1034320b2) B24@0:8q16
|
|
||||||
imp 0xfd80a0bc (0x1002f7ef8)
|
|
||||||
name 0x10353c8 (0x103b23208)
|
|
||||||
types 0x94426e (0x1034320b2) B24@0:8q16
|
|
||||||
imp 0xfd80a158 (0x1002f7fa0)
|
|
||||||
name 0x1035b9c (0x103b239e8)
|
|
||||||
types 0x944262 (0x1034320b2) B24@0:8q16
|
|
||||||
imp 0xfd80a18c (0x1002f7fe0)
|
|
||||||
name 0x1035c60 (0x103b23ab8)
|
|
||||||
types 0x944256 (0x1034320b2) B24@0:8q16
|
|
||||||
imp 0xfd80a1c0 (0x1002f8020)
|
|
||||||
--
|
|
||||||
reserved 0x0
|
|
||||||
ivarLayout 0x0
|
|
||||||
name 0x1031bfd80 WRChapterData
|
|
||||||
baseMethods 0x102aedd90
|
|
||||||
entsize 12 (relative)
|
|
||||||
count 7
|
|
||||||
name 0x100d288 (0x103afb020)
|
|
||||||
types 0x949a69 (0x103437805) v64@0:8@16{_NSRange=QQ}24q40q48@56
|
|
||||||
imp 0xfd80a52c (0x1002f82cc)
|
|
||||||
name 0x101d21c (0x103b0afc0)
|
|
||||||
types 0x940008 (0x10342ddb0) v24@0:8@16
|
|
||||||
imp 0xfd80a694 (0x1002f8440)
|
|
||||||
name 0x10161e8 (0x103b03f98)
|
|
||||||
types 0x949a74 (0x103437828) B40@0:8{_NSRange=QQ}16Q32
|
|
||||||
imp 0xfd80c140 (0x1002f9ef8)
|
|
||||||
name 0x1078b54 (0x103b66910)
|
|
||||||
types 0x9400a4 (0x10342de64) @24@0:8@16
|
|
||||||
imp 0xfd80c154 (0x1002f9f18)
|
|
||||||
name 0x1036ed8 (0x103b24ca0)
|
|
||||||
types 0x94008a (0x10342de56) B32@0:8@16@24
|
|
||||||
imp 0xfd80cf10 (0x1002face0)
|
|
||||||
name 0x104396c (0x103b31740)
|
|
||||||
types 0x949a6a (0x103437842) {_NSRange=QQ}32@0:8q16@24
|
|
||||||
imp 0xfd80e084 (0x1002fbe60)
|
|
||||||
name 0x1024f08 (0x103b12ce8)
|
|
||||||
types 0x9422b4 (0x103430098) q32@0:8@16@24
|
|
||||||
imp 0xfd80e738 (0x1002fc520)
|
|
||||||
baseProtocols 0x0
|
|
||||||
ivars 0x0
|
|
||||||
weakIvarLayout 0x0
|
|
||||||
baseProperties 0x0
|
|
||||||
000000010383c6c8 0x103ba8d38
|
|
||||||
isa 0x103ba8d60
|
|
||||||
--
|
|
||||||
ivarLayout 0x1031c36ba
|
|
||||||
layout map 0x01 0xa2
|
|
||||||
name 0x1031c36a4 WRCoreTextLayoutFrame
|
|
||||||
baseMethods 0x102b1a760
|
|
||||||
entsize 12 (relative)
|
|
||||||
count 47
|
|
||||||
name 0x1004bd8 (0x103b1f340)
|
|
||||||
types 0x922b0e (0x10343d27a) @80@0:8^{__CTFramesetter=}16@24{_NSRange=QQ}32{CGRect={CGPoint=dd}{CGSize=dd}}48
|
|
||||||
imp 0xfdba1dec (0x1006bc55c)
|
|
||||||
name 0xff9e1c (0x103b14590)
|
|
||||||
types 0x9137ac (0x10342df24) d16@0:8
|
|
||||||
imp 0xfdba1ef4 (0x1006bc670)
|
|
||||||
name 0x10388c8 (0x103b53048)
|
|
||||||
types 0x9143b0 (0x10342eb34) v48@0:8{CGRect={CGPoint=dd}{CGSize=dd}}16
|
|
||||||
imp 0xfdba1ef0 (0x1006bc678)
|
|
||||||
name 0x101dd44 (0x103b384d0)
|
|
||||||
types 0x922b3b (0x10343d2cb) B48@0:8{_NSRange=QQ}16{_NSRange=QQ}32
|
|
||||||
imp 0xfdba2028 (0x1006bc7bc)
|
|
||||||
name 0xfffc48 (0x103b1a3e0)
|
|
||||||
types 0x9136d3 (0x10342de6f) B24@0:8@16
|
|
||||||
imp 0xfdba2034 (0x1006bc7d4)
|
|
||||||
name 0x1007a5c (0x103b22200)
|
|
||||||
types 0x917ade (0x103432286) B32@0:8{_NSRange=QQ}16
|
|
||||||
imp 0xfdba20d0 (0x1006bc87c)
|
|
||||||
name 0xfed5f0 (0x103b07da0)
|
|
||||||
types 0x922b3d (0x10343d2f1) @64@0:8^{__CTTypesetter=}16d24{_NSRange=QQ}32d48@56
|
|
||||||
imp 0xfdba218c (0x1006bc944)
|
|
||||||
name 0x100debc (0x103b28678)
|
|
||||||
types 0x9135e8 (0x10342dda8) @16@0:8
|
|
||||||
imp 0xfdba2f20 (0x1006bd6e4)
|
|
||||||
name 0xfec0d0 (0x103b06898)
|
|
||||||
types 0x91373d (0x10342df09) q16@0:8
|
|
||||||
imp 0xfdba4788 (0x1006bef58)
|
|
||||||
--
|
|
||||||
reserved 0x0
|
|
||||||
ivarLayout 0x0
|
|
||||||
name 0x1031c36a4 WRCoreTextLayoutFrame
|
|
||||||
baseMethods 0x102b1a650
|
|
||||||
entsize 12 (relative)
|
|
||||||
count 2
|
|
||||||
name 0xfecc98 (0x103b072f0)
|
|
||||||
types 0x922bcf (0x10343d22b) @64@0:8@16@24{_NSRange=QQ}32{_NSRange=QQ}48
|
|
||||||
imp 0xfdba9834 (0x1006c3e94)
|
|
||||||
name 0x10492bc (0x103b63920)
|
|
||||||
types 0x922bef (0x10343d257) B60@0:8@16@24{_NSRange=QQ}32q48B56
|
|
||||||
imp 0xfdba9a84 (0x1006c40f0)
|
|
||||||
baseProtocols 0x0
|
|
||||||
ivars 0x0
|
|
||||||
weakIvarLayout 0x0
|
|
||||||
baseProperties 0x0
|
|
||||||
000000010383d6e0 0x103bb2e28
|
|
||||||
isa 0x103bb2e50
|
|
||||||
superclass 0x0 _OBJC_CLASS_$_QMUINavigationButton
|
|
||||||
cache 0x0 __objc_empty_cache
|
|
||||||
vtable 0x0
|
|
||||||
data 0x103901990
|
|
||||||
flags 0x194 RO_HAS_CXX_STRUCTORS
|
|
||||||
instanceStart 8
|
|
||||||
instanceSize 56
|
|
||||||
reserved 0x0
|
|
||||||
ivarLayout 0x1031bcc1a
|
|
||||||
layout map 0x12
|
|
||||||
name 0x1031c36bd WRReviewNavigationButton
|
|
||||||
baseMethods 0x102b1a9a0
|
|
||||||
entsize 12 (relative)
|
|
||||||
count 15
|
|
||||||
name 0x10025a8 (0x103b1cf50)
|
|
||||||
--
|
|
||||||
reserved 0x0
|
|
||||||
ivarLayout 0x0
|
|
||||||
name 0x1031c405b DTCoreTextLayoutFrameAccessibilityElementGenerator
|
|
||||||
baseMethods 0x102b22c00
|
|
||||||
entsize 12 (relative)
|
|
||||||
count 7
|
|
||||||
name 0xfd6a98 (0x103af96a0)
|
|
||||||
types 0x90c852 (0x10342f45e) @40@0:8@16@24@?32
|
|
||||||
imp 0xfdc59bb0 (0x10077c7c0)
|
|
||||||
name 0xfd6a94 (0x103af96a8)
|
|
||||||
types 0x91ba43 (0x10343e65b) @48@0:8Q16@24@32@?40
|
|
||||||
imp 0xfdc59ccc (0x10077c8e8)
|
|
||||||
name 0xfeca30 (0x103b0f650)
|
|
||||||
types 0x91194f (0x103434573) v40@0:8@16Q24@?32
|
|
||||||
imp 0xfdc59ee0 (0x10077cb08)
|
|
||||||
name 0xfd6a64 (0x103af9690)
|
|
||||||
types 0x91ba40 (0x10343e670) @72@0:8@16{_NSRange=QQ}24@40@48@56@?64
|
|
||||||
imp 0xfdc5a1b0 (0x10077cde4)
|
|
||||||
name 0xfd6a50 (0x103af9688)
|
|
||||||
types 0x91ba5b (0x10343e697) @64@0:8@16{_NSRange=QQ}24@40@48@56
|
|
||||||
imp 0xfdc5a2d4 (0x10077cf14)
|
|
||||||
name 0x104f19c (0x103b71de0)
|
|
||||||
types 0x90c807 (0x10342f44f) @32@0:8@16@?24
|
|
||||||
imp 0xfdc5a468 (0x10077d0b4)
|
|
||||||
name 0xfeff18 (0x103b12b68)
|
|
||||||
types 0x914fd8 (0x103437c2c) {CGRect={CGPoint=dd}{CGSize=dd}}24@0:8@16
|
|
||||||
imp 0xfdc5a490 (0x10077d0e8)
|
|
||||||
baseProtocols 0x0
|
|
||||||
ivars 0x0
|
|
||||||
weakIvarLayout 0x0
|
|
||||||
baseProperties 0x0
|
|
||||||
Meta Class
|
|
||||||
isa 0x0 _OBJC_METACLASS_$_NSObject
|
|
||||||
--
|
|
||||||
reserved 0x0
|
|
||||||
ivarLayout 0x0
|
|
||||||
name 0x1031c405b DTCoreTextLayoutFrameAccessibilityElementGenerator
|
|
||||||
baseMethods 0x0
|
|
||||||
baseProtocols 0x0
|
|
||||||
ivars 0x0
|
|
||||||
weakIvarLayout 0x0
|
|
||||||
baseProperties 0x0
|
|
||||||
000000010383d9a0 0x103bb49a8
|
|
||||||
isa 0x103bb49d0
|
|
||||||
superclass 0x0 _OBJC_CLASS_$_UICollectionView
|
|
||||||
cache 0x0 __objc_empty_cache
|
|
||||||
vtable 0x0
|
|
||||||
data 0x10390e140
|
|
||||||
flags 0x194 RO_HAS_CXX_STRUCTORS
|
|
||||||
instanceStart 8
|
|
||||||
instanceSize 48
|
|
||||||
reserved 0x0
|
|
||||||
ivarLayout 0x1031bcd23
|
|
||||||
layout map 0x11
|
|
||||||
name 0x1031c408e WRDiscoverCollectionView
|
|
||||||
baseMethods 0x102b22c60
|
|
||||||
entsize 12 (relative)
|
|
||||||
count 20
|
|
||||||
name 0x1001838 (0x103b244a0)
|
|
||||||
types 0x90b2b0 (0x10342df1c) B16@0:8
|
|
||||||
imp 0xfdc5a9b8 (0x10077d628)
|
|
||||||
name 0x101ef44 (0x103b41bb8)
|
|
||||||
types 0x91ba42 (0x10343e6ba) v36@0:8Q16B24@?28
|
|
||||||
imp 0xfdc5aa5c (0x10077d6d8)
|
|
||||||
name 0x1049f78 (0x103b6cbf8)
|
|
||||||
types 0x90b1eb (0x10342de6f) B24@0:8@16
|
|
||||||
imp 0xfdc5abc0 (0x10077d848)
|
|
||||||
--
|
|
||||||
ivarLayout 0x1031c51df
|
|
||||||
layout map 0x12 0x11 0x21
|
|
||||||
name 0x1031c51cc WRChapterPageCount
|
|
||||||
baseMethods 0x102b2ed70
|
|
||||||
entsize 12 (relative)
|
|
||||||
count 29
|
|
||||||
name 0xfefcf8 (0x103b1ea70)
|
|
||||||
types 0x910f9d (0x10343fd19) @40@0:8@16Q24q32
|
|
||||||
imp 0xfdd88e88 (0x1008b7c08)
|
|
||||||
name 0x100981c (0x103b385a0)
|
|
||||||
types 0x908ad4 (0x10343785c) {_NSRange=QQ}24@0:8q16
|
|
||||||
imp 0xfdd892a4 (0x1008b8030)
|
|
||||||
name 0xff70c8 (0x103b25e58)
|
|
||||||
types 0x8ff014 (0x10342dda8) @16@0:8
|
|
||||||
imp 0xfdd89500 (0x1008b8298)
|
|
||||||
name 0x1023294 (0x103b52030)
|
|
||||||
types 0x8ff010 (0x10342ddb0) v24@0:8@16
|
|
||||||
imp 0xfdd89504 (0x1008b82a8)
|
|
||||||
name 0xfd4710 (0x103b034b8)
|
|
||||||
types 0x8feffc (0x10342dda8) @16@0:8
|
|
||||||
imp 0xfdd89504 (0x1008b82b4)
|
|
||||||
name 0x10190fc (0x103b47eb0)
|
|
||||||
types 0x8feff8 (0x10342ddb0) v24@0:8@16
|
|
||||||
imp 0xfdd89508 (0x1008b82c4)
|
|
||||||
name 0xfd4650 (0x103b03410)
|
|
||||||
types 0x8ff145 (0x10342df09) q16@0:8
|
|
||||||
imp 0xfdd89508 (0x1008b82d0)
|
|
||||||
name 0x1019084 (0x103b47e50)
|
|
||||||
types 0x8ff141 (0x10342df11) v24@0:8q16
|
|
||||||
imp 0xfdd8950c (0x1008b82e0)
|
|
||||||
name 0xfd0fb0 (0x103affd88)
|
|
||||||
types 0x8fefcc (0x10342dda8) @16@0:8
|
|
||||||
imp 0xfdd89510 (0x1008b82f0)
|
|
||||||
--
|
|
||||||
reserved 0x0
|
|
||||||
ivarLayout 0x0
|
|
||||||
name 0x1031c51cc WRChapterPageCount
|
|
||||||
baseMethods 0x102b2ece0
|
|
||||||
entsize 12 (relative)
|
|
||||||
count 11
|
|
||||||
name 0xff2708 (0x103b213f0)
|
|
||||||
types 0x8ff0bc (0x10342dda8) @16@0:8
|
|
||||||
imp 0xfddc1db4 (0x1008f0aa4)
|
|
||||||
name 0xfdab1c (0x103b09810)
|
|
||||||
types 0x8ff0b0 (0x10342dda8) @16@0:8
|
|
||||||
imp 0xfddc1dc4 (0x1008f0ac0)
|
|
||||||
name 0x103b978 (0x103b6a678)
|
|
||||||
types 0x8ff0a4 (0x10342dda8) @16@0:8
|
|
||||||
imp 0xfddc1dc4 (0x1008f0acc)
|
|
||||||
name 0x10063cc (0x103b350d8)
|
|
||||||
types 0x8ff098 (0x10342dda8) @16@0:8
|
|
||||||
imp 0xfddc1dc4 (0x1008f0ad8)
|
|
||||||
name 0x10046e8 (0x103b33400)
|
|
||||||
types 0x8ff08c (0x10342dda8) @16@0:8
|
|
||||||
imp 0xfddc1dc4 (0x1008f0ae4)
|
|
||||||
name 0xfdc50c (0x103b0b230)
|
|
||||||
types 0x8ff080 (0x10342dda8) @16@0:8
|
|
||||||
imp 0xfddc1df0 (0x1008f0b1c)
|
|
||||||
name 0x10027f0 (0x103b31520)
|
|
||||||
types 0x910fe5 (0x10343fd19) @40@0:8@16Q24q32
|
|
||||||
imp 0xfdd89030 (0x1008b7d68)
|
|
||||||
name 0xfd982c (0x103b08568)
|
|
||||||
types 0x9006b8 (0x10342f3f8) Q24@0:8@16
|
|
||||||
imp 0xfdd89128 (0x1008b7e6c)
|
|
||||||
name 0xfee0c0 (0x103b1ce08)
|
|
||||||
types 0x8ff05c (0x10342dda8) @16@0:8
|
|
||||||
imp 0xfdd892d4 (0x1008b8024)
|
|
||||||
--
|
|
||||||
ivarLayout 0x1031c6257
|
|
||||||
layout map 0x04 0x15 0x28
|
|
||||||
name 0x1031c624c WRPageView
|
|
||||||
baseMethods 0x102b3b018
|
|
||||||
entsize 12 (relative)
|
|
||||||
count 104
|
|
||||||
name 0xfe4310 (0x103b1f330)
|
|
||||||
types 0x9064ba (0x1034414de) @64@0:8{CGRect={CGPoint=dd}{CGSize=dd}}16Q48q56
|
|
||||||
imp 0xfde85418 (0x1009c0440)
|
|
||||||
name 0x10294e4 (0x103b64510)
|
|
||||||
types 0x8f2eec (0x10342df1c) B16@0:8
|
|
||||||
imp 0xfde854e0 (0x1009c0514)
|
|
||||||
name 0x102a918 (0x103b65950)
|
|
||||||
types 0x8f2d7f (0x10342ddbb) v16@0:8
|
|
||||||
imp 0xfde85584 (0x1009c05c4)
|
|
||||||
name 0xfe0064 (0x103b1b0a8)
|
|
||||||
types 0x8f2d73 (0x10342ddbb) v16@0:8
|
|
||||||
imp 0xfde85704 (0x1009c0750)
|
|
||||||
name 0xfe0068 (0x103b1b0b8)
|
|
||||||
types 0x8f2d67 (0x10342ddbb) v16@0:8
|
|
||||||
imp 0xfde85770 (0x1009c07c8)
|
|
||||||
name 0xfe2eec (0x103b1df48)
|
|
||||||
types 0x8f2d5b (0x10342ddbb) v16@0:8
|
|
||||||
imp 0xfde85798 (0x1009c07fc)
|
|
||||||
name 0xfe0048 (0x103b1b0b0)
|
|
||||||
types 0x8f2d4f (0x10342ddbb) v16@0:8
|
|
||||||
imp 0xfde85af0 (0x1009c0b60)
|
|
||||||
name 0xfe2ac4 (0x103b1db38)
|
|
||||||
types 0x8f2d43 (0x10342ddbb) v16@0:8
|
|
||||||
imp 0xfde85ca8 (0x1009c0d24)
|
|
||||||
name 0xfe66d8 (0x103b21758)
|
|
||||||
types 0x8f2d24 (0x10342dda8) @16@0:8
|
|
||||||
imp 0xfde85df8 (0x1009c0e80)
|
|
||||||
--
|
|
||||||
reserved 0x0
|
|
||||||
ivarLayout 0x0
|
|
||||||
name 0x1031c624c WRPageView
|
|
||||||
baseMethods 0x102b3b000
|
|
||||||
entsize 12 (relative)
|
|
||||||
count 1
|
|
||||||
name 0xff1cb8 (0x103b2ccc0)
|
|
||||||
types 0x8f6062 (0x10343106e) d32@0:8@16@24
|
|
||||||
imp 0xfde86674 (0x1009c1684)
|
|
||||||
baseProtocols 0x0
|
|
||||||
ivars 0x0
|
|
||||||
weakIvarLayout 0x0
|
|
||||||
baseProperties 0x0
|
|
||||||
000000010383e338 0x103bba998
|
|
||||||
isa 0x103bba9c0
|
|
||||||
superclass 0x103bc8688
|
|
||||||
cache 0x0 __objc_empty_cache
|
|
||||||
vtable 0x0
|
|
||||||
data 0x103932f00
|
|
||||||
flags 0x194 RO_HAS_CXX_STRUCTORS
|
|
||||||
instanceStart 16
|
|
||||||
instanceSize 32
|
|
||||||
reserved 0x0
|
|
||||||
ivarLayout 0x1031bc967
|
|
||||||
layout map 0x02
|
|
||||||
name 0x1031c625b WRConfirmH5PayViewController
|
|
||||||
baseMethods 0x102b3b500
|
|
||||||
entsize 12 (relative)
|
|
||||||
count 15
|
|
||||||
name 0x10011c8 (0x103b3c6d0)
|
|
||||||
types 0x8f28af (0x10342ddbb) v16@0:8
|
|
||||||
imp 0xfde90aa0 (0x1009cbfb0)
|
|
||||||
name 0xfedbb4 (0x103b290c8)
|
|
||||||
--
|
|
||||||
ivarLayout 0x1031c646d
|
|
||||||
layout map 0x44 0x81 0x31
|
|
||||||
name 0x1031c6457 DTCoreTextLayoutFrame
|
|
||||||
baseMethods 0x102b3ca60
|
|
||||||
entsize 12 (relative)
|
|
||||||
count 54
|
|
||||||
name 0xfc84e8 (0x103b04f50)
|
|
||||||
types 0x8f4143 (0x103430baf) q32@0:8{CGPoint=dd}16
|
|
||||||
imp 0xfdd403fc (0x10087ce6c)
|
|
||||||
name 0xfcc3a4 (0x103b08e18)
|
|
||||||
types 0x900b89 (0x10343d601) {CGRect={CGPoint=dd}{CGSize=dd}}24@0:8q16
|
|
||||||
imp 0xfdd406b8 (0x10087d134)
|
|
||||||
name 0xfe2878 (0x103b1f2f8)
|
|
||||||
types 0x904d71 (0x1034417f5) @72@0:8{CGRect={CGPoint=dd}{CGSize=dd}}16@48{_NSRange=QQ}56
|
|
||||||
imp 0xfdeb5da4 (0x1009f282c)
|
|
||||||
name 0xfe2864 (0x103b1f2f0)
|
|
||||||
types 0x8fdd25 (0x10343a7b5) @56@0:8{CGRect={CGPoint=dd}{CGSize=dd}}16@48
|
|
||||||
imp 0xfdeb5f20 (0x1009f29b4)
|
|
||||||
name 0xfcce70 (0x103b09908)
|
|
||||||
types 0x8f131f (0x10342ddbb) v16@0:8
|
|
||||||
imp 0xfdeb5f20 (0x1009f29c0)
|
|
||||||
name 0xfce904 (0x103b0b3a8)
|
|
||||||
types 0x8f1300 (0x10342dda8) @16@0:8
|
|
||||||
imp 0xfdeb5f6c (0x1009f2a18)
|
|
||||||
name 0xfbacd8 (0x103af7788)
|
|
||||||
types 0x900b34 (0x10343d5e8) {CGPoint=dd}32@0:8@16@24
|
|
||||||
imp 0xfdeb5fa4 (0x1009f2a5c)
|
|
||||||
name 0xfbacdc (0x103af7798)
|
|
||||||
types 0x8f2c9e (0x10342f75e) d24@0:8@16
|
|
||||||
imp 0xfdeb6274 (0x1009f2d38)
|
|
||||||
name 0xfbacc8 (0x103af7790)
|
|
||||||
types 0x900b1c (0x10343d5e8) {CGPoint=dd}32@0:8@16@24
|
|
||||||
imp 0xfdeb6364 (0x1009f2e34)
|
|
||||||
--
|
|
||||||
reserved 0x0
|
|
||||||
ivarLayout 0x0
|
|
||||||
name 0x1031c6457 DTCoreTextLayoutFrame
|
|
||||||
baseMethods 0x102b3ca40
|
|
||||||
entsize 12 (relative)
|
|
||||||
count 2
|
|
||||||
name 0x101fb08 (0x103b5c550)
|
|
||||||
types 0x8f13d1 (0x10342de1d) v20@0:8B16
|
|
||||||
imp 0xfdeba310 (0x1009f6d60)
|
|
||||||
name 0x10273a4 (0x103b63df8)
|
|
||||||
types 0x8f14c4 (0x10342df1c) B16@0:8
|
|
||||||
imp 0xfdeba310 (0x1009f6d6c)
|
|
||||||
baseProtocols 0x0
|
|
||||||
ivars 0x0
|
|
||||||
weakIvarLayout 0x0
|
|
||||||
baseProperties 0x0
|
|
||||||
000000010383e3c8 0x103bbaf38
|
|
||||||
isa 0x103bbaf60
|
|
||||||
superclass 0x0 _OBJC_CLASS_$_UIView
|
|
||||||
cache 0x0 __objc_empty_cache
|
|
||||||
vtable 0x0
|
|
||||||
data 0x103935920
|
|
||||||
flags 0x194 RO_HAS_CXX_STRUCTORS
|
|
||||||
instanceStart 8
|
|
||||||
instanceSize 280
|
|
||||||
reserved 0x0
|
|
||||||
ivarLayout 0x1031c64b4
|
|
||||||
layout map 0x02 0xb9
|
|
||||||
name 0x1031c647c WHLyricPageTextView
|
|
||||||
baseMethods 0x102b3ccf0
|
|
||||||
entsize 12 (relative)
|
|
||||||
count 60
|
|
||||||
name 0xfe0258 (0x103b1cf50)
|
|
||||||
--
|
|
||||||
reserved 0x0
|
|
||||||
ivarLayout 0x0
|
|
||||||
name 0x1031c67a2 WREpubTypesetter
|
|
||||||
baseMethods 0x0
|
|
||||||
baseProtocols 0x0
|
|
||||||
ivars 0x0
|
|
||||||
weakIvarLayout 0x0
|
|
||||||
baseProperties 0x0
|
|
||||||
Meta Class
|
|
||||||
isa 0x0 _OBJC_METACLASS_$_NSObject
|
|
||||||
superclass 0x0 _OBJC_METACLASS_$_NSObject
|
|
||||||
cache 0x0 __objc_empty_cache
|
|
||||||
vtable 0x0
|
|
||||||
data 0x10393a6e8
|
|
||||||
flags 0x91 RO_META
|
|
||||||
instanceStart 40
|
|
||||||
instanceSize 40
|
|
||||||
reserved 0x0
|
|
||||||
ivarLayout 0x0
|
|
||||||
name 0x1031c67a2 WREpubTypesetter
|
|
||||||
baseMethods 0x102b40010
|
|
||||||
entsize 12 (relative)
|
|
||||||
count 9
|
|
||||||
name 0xfbd748 (0x103afd760)
|
|
||||||
types 0x902393 (0x1034423af) @92@0:8@16Q24B32B36B40@44@52q60^q68^B76@84
|
|
||||||
imp 0xfdf0898c (0x100a489ac)
|
|
||||||
name 0x102d67c (0x103b6d6a0)
|
|
||||||
types 0x9023b2 (0x1034423da) v52@0:8@16@24@32B40B44B48
|
|
||||||
imp 0xfdf09b70 (0x100a49b9c)
|
|
||||||
name 0xfc41f8 (0x103b04228)
|
|
||||||
types 0x9023c0 (0x1034423f4) v40@0:8@16^B24^B32
|
|
||||||
imp 0xfdf0a380 (0x100a4a3b8)
|
|
||||||
name 0xfe4fbc (0x103b24ff8)
|
|
||||||
types 0x8ede2f (0x10342de6f) B24@0:8@16
|
|
||||||
imp 0xfdf0a5b8 (0x100a4a5fc)
|
|
||||||
name 0xfc9180 (0x103b091c8)
|
|
||||||
types 0x8ede18 (0x10342de64) @24@0:8@16
|
|
||||||
imp 0xfdf0a644 (0x100a4a694)
|
|
||||||
name 0xfc917c (0x103b091d0)
|
|
||||||
types 0x8ede0c (0x10342de64) @24@0:8@16
|
|
||||||
imp 0xfdf0a754 (0x100a4a7b0)
|
|
||||||
name 0xfc9178 (0x103b091d8)
|
|
||||||
types 0x8ede97 (0x10342defb) @28@0:8@16B24
|
|
||||||
imp 0xfdf0a864 (0x100a4a8cc)
|
|
||||||
name 0xfe3dbc (0x103b23e28)
|
|
||||||
types 0x8eddff (0x10342de6f) B24@0:8@16
|
|
||||||
imp 0xfdf0aa10 (0x100a4aa84)
|
|
||||||
name 0xfc9170 (0x103b091e8)
|
|
||||||
types 0x8edd93 (0x10342de0f) @32@0:8@16@24
|
|
||||||
imp 0xfdf0aa70 (0x100a4aaf0)
|
|
||||||
--
|
|
||||||
ivarLayout 0x1031bc9ce
|
|
||||||
layout map 0x13
|
|
||||||
name 0x1031c67c4 WRCoreTextLayouter
|
|
||||||
baseMethods 0x102b40160
|
|
||||||
entsize 12 (relative)
|
|
||||||
count 15
|
|
||||||
name 0xfbd6f8 (0x103afd860)
|
|
||||||
types 0x8edc3c (0x10342dda8) @16@0:8
|
|
||||||
imp 0xfdf0b9a8 (0x100a4bb18)
|
|
||||||
name 0xfde32c (0x103b1e4a0)
|
|
||||||
types 0x8edc97 (0x10342de0f) @32@0:8@16@24
|
|
||||||
imp 0xfdf0ba50 (0x100a4bbcc)
|
|
||||||
name 0xfd2a90 (0x103b12c10)
|
|
||||||
types 0x8ef376 (0x10342f4fa) ^{__CTFramesetter=}16@0:8
|
|
||||||
imp 0xfdf0badc (0x100a4bc64)
|
|
||||||
name 0xfc7b5c (0x103b07ce8)
|
|
||||||
types 0x902277 (0x103442407) @72@0:8{_NSRange=QQ}16{CGRect={CGPoint=dd}{CGSize=dd}}32Q64
|
|
||||||
imp 0xfdf0bb4c (0x100a4bce0)
|
|
||||||
name 0x1004f38 (0x103b450d0)
|
|
||||||
types 0x8edc14 (0x10342ddb0) v24@0:8@16
|
|
||||||
imp 0xfdf0bc0c (0x100a4bdac)
|
|
||||||
name 0xff1274 (0x103b31418)
|
|
||||||
types 0x8f7c6e (0x103437e16) @40@0:8{_NSRange=QQ}16@32
|
|
||||||
imp 0xfdf0c034 (0x100a4c1e0)
|
|
||||||
name 0xff1270 (0x103b31420)
|
|
||||||
types 0x8f7c62 (0x103437e16) @40@0:8{_NSRange=QQ}16@32
|
|
||||||
imp 0xfdf0c1ac (0x100a4c364)
|
|
||||||
name 0xfd0444 (0x103b10600)
|
|
||||||
types 0x8edbe8 (0x10342dda8) @16@0:8
|
|
||||||
imp 0xfdf0c410 (0x100a4c5d4)
|
|
||||||
name 0xfff160 (0x103b3f328)
|
|
||||||
types 0x902277 (0x103442443) @84@0:8@16{CGRect={CGPoint=dd}{CGSize=dd}}24Q56Q64B72@76
|
|
||||||
imp 0xfdf0c420 (0x100a4c5f0)
|
|
||||||
--
|
|
||||||
reserved 0x0
|
|
||||||
ivarLayout 0x0
|
|
||||||
name 0x1031c67c4 WRCoreTextLayouter
|
|
||||||
baseMethods 0x102b40118
|
|
||||||
entsize 12 (relative)
|
|
||||||
count 5
|
|
||||||
name 0x1028e50 (0x103b68f70)
|
|
||||||
types 0x8edd4b (0x10342de6f) B24@0:8@16
|
|
||||||
imp 0xfdf0b47c (0x100a4b5a4)
|
|
||||||
name 0xfe54fc (0x103b25628)
|
|
||||||
types 0x8edd3f (0x10342de6f) B24@0:8@16
|
|
||||||
imp 0xfdf0b7cc (0x100a4b900)
|
|
||||||
name 0x10300e8 (0x103b70220)
|
|
||||||
types 0x8f10a1 (0x1034311dd) v28@0:8B16@20
|
|
||||||
imp 0xfdf0b8b8 (0x100a4b9f8)
|
|
||||||
name 0xfc6ebc (0x103b07000)
|
|
||||||
types 0x8edd1c (0x10342de64) @24@0:8@16
|
|
||||||
imp 0xfdf0bd08 (0x100a4be54)
|
|
||||||
name 0xfc6eb8 (0x103b07008)
|
|
||||||
types 0x8edd10 (0x10342de64) @24@0:8@16
|
|
||||||
imp 0xfdf0c020 (0x100a4c178)
|
|
||||||
baseProtocols 0x0
|
|
||||||
ivars 0x0
|
|
||||||
weakIvarLayout 0x0
|
|
||||||
baseProperties 0x0
|
|
||||||
000000010383e4b8 0x103bbb898
|
|
||||||
isa 0x103bbb8c0
|
|
||||||
superclass 0x103bb0ee8
|
|
||||||
cache 0x0 __objc_empty_cache
|
|
||||||
vtable 0x0
|
|
||||||
data 0x10393ab58
|
|
||||||
flags 0x194 RO_HAS_CXX_STRUCTORS
|
|
||||||
instanceStart 16
|
|
||||||
--
|
|
||||||
ivarLayout 0x1031c75f6
|
|
||||||
layout map 0x02 0x2f 0x01 0x33 0x11
|
|
||||||
name 0x1031c75c3 DTHTMLAttributedStringBuilder
|
|
||||||
baseMethods 0x102b4aa58
|
|
||||||
entsize 12 (relative)
|
|
||||||
count 25
|
|
||||||
name 0xfd4958 (0x103b1f3b8)
|
|
||||||
types 0x8eb102 (0x103435b66) @40@0:8@16@24^@32
|
|
||||||
imp 0xfdffe5dc (0x100b49044)
|
|
||||||
name 0xfacf14 (0x103af7980)
|
|
||||||
types 0x8e34ac (0x10342df1c) B16@0:8
|
|
||||||
imp 0xfdffe7dc (0x100b49250)
|
|
||||||
name 0xfc8d80 (0x103b137f8)
|
|
||||||
types 0x8e332c (0x10342dda8) @16@0:8
|
|
||||||
imp 0xfdfff100 (0x100b49b80)
|
|
||||||
name 0xfae034 (0x103af8ab8)
|
|
||||||
types 0x8e3333 (0x10342ddbb) v16@0:8
|
|
||||||
imp 0xfdfff124 (0x100b49bb0)
|
|
||||||
name 0xfae020 (0x103af8ab0)
|
|
||||||
types 0x8e3327 (0x10342ddbb) v16@0:8
|
|
||||||
imp 0xfdffff00 (0x100b4a998)
|
|
||||||
name 0xfe7d3c (0x103b327d8)
|
|
||||||
types 0x8f88ae (0x10344334e) v56@0:8@16@24@32{CGPoint=dd}40
|
|
||||||
imp 0xfe000968 (0x100b4b40c)
|
|
||||||
name 0xfe7d20 (0x103b327c8)
|
|
||||||
types 0x8e337c (0x10342de28) v32@0:8@16@24
|
|
||||||
imp 0xfe000efc (0x100b4b9ac)
|
|
||||||
name 0xfe7be4 (0x103b32698)
|
|
||||||
types 0x8e32f8 (0x10342ddb0) v24@0:8@16
|
|
||||||
imp 0xfe0010c8 (0x100b4bb84)
|
|
||||||
name 0xfe7d30 (0x103b327f0)
|
|
||||||
types 0x8e3364 (0x10342de28) v32@0:8@16@24
|
|
||||||
imp 0xfe0015b8 (0x100b4c080)
|
|
||||||
--
|
|
||||||
reserved 0x0
|
|
||||||
ivarLayout 0x0
|
|
||||||
name 0x1031c75c3 DTHTMLAttributedStringBuilder
|
|
||||||
baseMethods 0x0
|
|
||||||
baseProtocols 0x10394b7f0
|
|
||||||
count 1
|
|
||||||
list[0] 0x103c1bed0
|
|
||||||
isa 0x0
|
|
||||||
name 0x1031c75e1 DTHTMLParserDelegate
|
|
||||||
protocols 0x10394b690
|
|
||||||
count 1
|
|
||||||
list[0] 0x103c15580
|
|
||||||
isa 0x0
|
|
||||||
name 0x1031bc9c5 NSObject
|
|
||||||
protocols 0x0
|
|
||||||
instanceMethods 0x103884670
|
|
||||||
entsize 24
|
|
||||||
count 19
|
|
||||||
name 0x103348607 isEqual:
|
|
||||||
types 0x10342de6f B24@0:8@16
|
|
||||||
imp 0x0
|
|
||||||
name 0x1032e9bde class
|
|
||||||
types 0x10342df44 #16@0:8
|
|
||||||
imp 0x0
|
|
||||||
name 0x1033a90f1 self
|
|
||||||
types 0x10342dda8 @16@0:8
|
|
||||||
imp 0x0
|
|
||||||
name 0x1033780e4 performSelector:
|
|
||||||
types 0x10342df4c @24@0:8:16
|
|
||||||
imp 0x0
|
|
||||||
name 0x103378163 performSelector:withObject:
|
|
||||||
types 0x10342df57 @32@0:8:16@24
|
|
||||||
imp 0x0
|
|
||||||
--
|
|
||||||
ivarLayout 0x1031c0ccf
|
|
||||||
layout map 0x61
|
|
||||||
name 0x1031cb9a9 WRPageViewController
|
|
||||||
baseMethods 0x102b81cf8
|
|
||||||
entsize 12 (relative)
|
|
||||||
count 73
|
|
||||||
name 0xfbd2d0 (0x103b3efd0)
|
|
||||||
types 0x8ac0b7 (0x10342ddbb) v16@0:8
|
|
||||||
imp 0xfe4dedec (0x101060af4)
|
|
||||||
name 0xff0bac (0x103b728b8)
|
|
||||||
types 0x8c707f (0x103448d8f) v72@0:8@16q24{CGPoint=dd}32q48B56B60@?64
|
|
||||||
imp 0xfe4dedf0 (0x101060b04)
|
|
||||||
name 0xfdf8d0 (0x103b615e8)
|
|
||||||
types 0x8b32e5 (0x103435001) v44@0:8@16q24B32@?36
|
|
||||||
imp 0xfe4df5ec (0x10106130c)
|
|
||||||
name 0xf9d194 (0x103b1eeb8)
|
|
||||||
types 0x8bdff1 (0x10343fd19) @40@0:8@16Q24q32
|
|
||||||
imp 0xfe4df984 (0x1010616b0)
|
|
||||||
name 0xfaf870 (0x103b315a0)
|
|
||||||
types 0x8ac1d5 (0x10342df09) q16@0:8
|
|
||||||
imp 0xfe4dfba8 (0x1010618e0)
|
|
||||||
name 0xff0084 (0x103b71dc0)
|
|
||||||
types 0x8ac07b (0x10342ddbb) v16@0:8
|
|
||||||
imp 0xfe4dfbac (0x1010618f0)
|
|
||||||
name 0xff0038 (0x103b71d80)
|
|
||||||
types 0x8ac0d1 (0x10342de1d) v20@0:8B16
|
|
||||||
imp 0xfe4dfcb4 (0x101061a04)
|
|
||||||
name 0xf87bb4 (0x103b09908)
|
|
||||||
types 0x8ac063 (0x10342ddbb) v16@0:8
|
|
||||||
imp 0xfe4dfcf0 (0x101061a4c)
|
|
||||||
name 0xf78590 (0x103afa2f0)
|
|
||||||
types 0x8ac057 (0x10342ddbb) v16@0:8
|
|
||||||
imp 0xfe4dfe08 (0x101061b70)
|
|
||||||
--
|
|
||||||
reserved 0x0
|
|
||||||
ivarLayout 0x0
|
|
||||||
name 0x1031cb9a9 WRPageViewController
|
|
||||||
baseMethods 0x102b81c98
|
|
||||||
entsize 12 (relative)
|
|
||||||
count 7
|
|
||||||
name 0xf9f110 (0x103b20db0)
|
|
||||||
types 0x8ac117 (0x10342ddbb) v16@0:8
|
|
||||||
imp 0xfe4dc830 (0x10105e4d8)
|
|
||||||
name 0xfb0c9c (0x103b32948)
|
|
||||||
types 0x8ac10b (0x10342ddbb) v16@0:8
|
|
||||||
imp 0xfe4dc99c (0x10105e650)
|
|
||||||
name 0xfb0c88 (0x103b32940)
|
|
||||||
types 0x8ac0ff (0x10342ddbb) v16@0:8
|
|
||||||
imp 0xfe4dd264 (0x10105ef24)
|
|
||||||
name 0xfb0c74 (0x103b32938)
|
|
||||||
types 0x8ac0f3 (0x10342ddbb) v16@0:8
|
|
||||||
imp 0xfe4ddb28 (0x10105f7f4)
|
|
||||||
name 0xf89a18 (0x103b0b6e8)
|
|
||||||
types 0x8c708d (0x103448d61) B48@0:8@16@24B32B36B40B44
|
|
||||||
imp 0xfe4de164 (0x10105fe3c)
|
|
||||||
name 0xfa437c (0x103b26058)
|
|
||||||
types 0x8ae3b8 (0x103430098) q32@0:8@16@24
|
|
||||||
imp 0xfe4dedac (0x101060a90)
|
|
||||||
name 0xfe3180 (0x103b64e68)
|
|
||||||
types 0x8ac0c4 (0x10342ddb0) v24@0:8@16
|
|
||||||
imp 0xfe4e15b8 (0x1010632a8)
|
|
||||||
baseProtocols 0x1039a0c00
|
|
||||||
count 4
|
|
||||||
list[0] 0x103c17bc8
|
|
||||||
isa 0x0
|
|
||||||
name 0x1031bf992 UIPageViewControllerDataSource
|
|
||||||
protocols 0x1038b4590
|
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
# WXRead/resources 文件说明
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
resources/
|
||||||
|
├── js/
|
||||||
|
│ ├── Text/ # 文本编辑/展示相关
|
||||||
|
│ │ ├── WeReadApi.js
|
||||||
|
│ │ ├── rich_editor.js
|
||||||
|
│ │ ├── rich_display.js
|
||||||
|
│ │ ├── correctArticle.js
|
||||||
|
│ │ └── style_html.js
|
||||||
|
│ ├── Readability.js
|
||||||
|
│ ├── weread-highlighter.js
|
||||||
|
│ ├── rangy-core.js
|
||||||
|
│ ├── rangy-classapplier.js
|
||||||
|
│ ├── rangy-highlighter.js
|
||||||
|
│ ├── rangy-textrange.js
|
||||||
|
│ ├── highlight.min.js
|
||||||
|
│ ├── cssInjector.js
|
||||||
|
│ └── patch_6.2.1.4.js
|
||||||
|
├── css/
|
||||||
|
│ ├── Style/
|
||||||
|
│ │ ├── style.css
|
||||||
|
│ │ ├── preview.css
|
||||||
|
│ │ └── normalize.css
|
||||||
|
│ ├── default.css
|
||||||
|
│ ├── dark.css
|
||||||
|
│ ├── replace.css
|
||||||
|
│ ├── replaceForLatinLanguageBook.css
|
||||||
|
│ ├── replaceForMPChapter.css
|
||||||
|
│ ├── MPExtra.css
|
||||||
|
│ ├── MediaPlatform.css
|
||||||
|
│ ├── WREpubMPVideo.css
|
||||||
|
│ └── xcode.min.css
|
||||||
|
└── fonts/ # 字体文件
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## JS 文件
|
||||||
|
|
||||||
|
### 核心功能
|
||||||
|
|
||||||
|
#### `Readability.js`
|
||||||
|
Arc90 的 Readability 库 (v1.7.1),用于从网页中提取正文内容。通过正则匹配和 DOM 分析算法,从杂乱的 HTML 中识别出文章主体,过滤导航、广告、侧栏等非内容元素。是读书解析公众号文章内容的基础依赖。
|
||||||
|
|
||||||
|
#### `weread-highlighter.js`
|
||||||
|
读书的划线/高亮核心模块。依赖 Rangy 库实现以下功能:
|
||||||
|
- 初始化 Rangy 高亮器,注册 `highlight`(划线)、`review`(自己的想法)、`friend-review`(好友想法)、`reference`(想法圈划线)、`tts`(朗读高亮)等样式
|
||||||
|
- 监听 `selectionchange` 事件,将选区位置信息通过 `wereadBridge` 通知原生层
|
||||||
|
- 提供 `getShareAbstractText()` 从文章段落中提取分享摘要文本
|
||||||
|
- 字体缩放 `changeFontSizeWithScale()` 支持
|
||||||
|
- 图片点击事件绑定和 H5 链接转换
|
||||||
|
|
||||||
|
#### `cssInjector.js`
|
||||||
|
CSS 动态注入/移除工具。提供 `inject(json)` 和 `remove(identifier)` 两个方法,通过创建/删除 `<style>` 元素实现运行时样式切换。
|
||||||
|
|
||||||
|
#### `patch_6.2.1.4.js`
|
||||||
|
版本补丁脚本。修改 `WRAboutViewController` 的 `viewDidLoad`,将版权信息文案中的 "rights" 和 "reserved" 首字母改为大写。
|
||||||
|
|
||||||
|
### Rangy 库系列 (第三方)
|
||||||
|
|
||||||
|
#### `rangy-core.js`
|
||||||
|
Rangy 核心库 (v1.3.0, MIT License)。跨浏览器的 Range 和 Selection 操作库,提供统一的 API 处理 DOM 选区。
|
||||||
|
|
||||||
|
#### `rangy-classapplier.js`
|
||||||
|
Rangy ClassApplier 模块。对 Range 和 Selection 添加/移除/切换 CSS 类,是实现划线高亮的基础。
|
||||||
|
|
||||||
|
#### `rangy-highlighter.js`
|
||||||
|
Rangy Highlighter 模块。依赖 core 和 ClassApplier,提供更高层的文本高亮 API,支持序列化/反序列化高亮位置。
|
||||||
|
|
||||||
|
#### `rangy-textrange.js`
|
||||||
|
Rangy TextRange 模块。支持按字符/词偏移移动选区边界、文本搜索、选区保存/恢复等功能。
|
||||||
|
|
||||||
|
#### `highlight.min.js`
|
||||||
|
highlight.js 代码语法高亮库(压缩版)。用于在阅读器中高亮显示 `<pre><code>` 代码块。
|
||||||
|
|
||||||
|
### Text 子目录
|
||||||
|
|
||||||
|
#### `Text/WeReadApi.js`
|
||||||
|
读书 WebView Bridge。定义 `WeReadBridge` 类,通过隐藏 iframe + 自定义 URL scheme(`wereadapijs://`)实现 JS 与原生层的双向通信。支持回调机制和可用功能查询。
|
||||||
|
|
||||||
|
#### `Text/rich_editor.js`
|
||||||
|
富文本编辑器核心模块。定义 `RE` 对象,管理编辑器 DOM 引用和编辑操作。
|
||||||
|
|
||||||
|
#### `Text/rich_display.js`
|
||||||
|
富文本展示模块。定义 `RDisplay` 对象,处理展示模式下的交互(书籍卡片触摸反馈、点击高亮等)。
|
||||||
|
|
||||||
|
#### `Text/correctArticle.js`
|
||||||
|
文章内容校正脚本。用于对公众号文章的 HTML 结构进行清洗和修正。
|
||||||
|
|
||||||
|
#### `Text/style_html.js`
|
||||||
|
JS Beautifier 库。HTML/JS/CSS 代码格式化工具(MIT License),用于代码块的美化显示。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CSS 文件
|
||||||
|
|
||||||
|
### 默认/基础样式
|
||||||
|
|
||||||
|
#### `default.css`
|
||||||
|
公众号文章的基础阅读样式。覆盖微信原生样式,定义:
|
||||||
|
- 文章区域布局(`rich_media_area_primary`:padding、字号 18px、行高 30px)
|
||||||
|
- 标题样式(`rich_media_title`:24px/36px)
|
||||||
|
- 内容区域溢出处理、blockquote 样式
|
||||||
|
- 隐藏工具栏(`rich_media_tool`)
|
||||||
|
- 划线/想法/高亮样式(`.highlight` 橙色、`.review` 红色虚线、`.friend-review` 灰色虚线)
|
||||||
|
- 隐藏 iPad 上的 PC 二维码
|
||||||
|
- TTS 朗读高亮(`.tts` 蓝色背景)
|
||||||
|
- 公众号图片链接禁用点击
|
||||||
|
|
||||||
|
#### `dark.css`
|
||||||
|
深色模式样式。全局覆盖为纯黑背景(`rgba(0,0,0,1)`),文字颜色设为灰色(`rgba(180,180,182,1)`),链接为蓝色,blockquote 为深色背景。适配搜狗百科和百度搜索的特殊元素。
|
||||||
|
|
||||||
|
#### `xcode.min.css`
|
||||||
|
代码语法高亮配色方案(highlight.js 的 Xcode 主题),白色背景,关键字紫色、字符串红色、注释绿色等。
|
||||||
|
|
||||||
|
### 内容替换样式
|
||||||
|
|
||||||
|
#### `replace.css`
|
||||||
|
EPUB 书籍的主要排版样式。定义:
|
||||||
|
- 代码块字体(Menlo/Consolas)、版权标题、图片说明文字
|
||||||
|
- 六级标题样式(`.firstTitle` ~ `.sixthTitle`,1.0em~1.5em)
|
||||||
|
- 首字下沉(`.ftext`)、引用(`.conQuot`)、副标题(`.subHead`)
|
||||||
|
- 图片垂直居中(`wr-vertical-center-style`)、翻译行高
|
||||||
|
- 章节工具栏高度(`.book-chapter-tool`:78px)
|
||||||
|
|
||||||
|
#### `replaceForLatinLanguageBook.css`
|
||||||
|
拉丁语系书籍的排版样式。与 `replace.css` 类似,但标题使用 `Source Han Serif CN` 字体并加粗(而非 `SourceHanSerifCN-Medium` normal),适配西文排版习惯。
|
||||||
|
|
||||||
|
#### `replaceForMPChapter.css`
|
||||||
|
公众号文集(小文章)的排版样式。在 `replace.css` 基础上增加了:
|
||||||
|
- `.weread-page-relate` 跨页关联标记
|
||||||
|
- `.chapter-reward` 打赏区域(120px)
|
||||||
|
- `.chapter-tool` 工具栏(140px)
|
||||||
|
- `.re_bookItem` 书籍卡片间距
|
||||||
|
- `img[data-image-size="large"]` 大图全宽
|
||||||
|
- `.unsupported_iframe` 不支持控件的提示样式
|
||||||
|
- 强制清除 `<p>`、`<strong>`、`<span>`、`<section>` 的背景色
|
||||||
|
|
||||||
|
### 公众号专用样式
|
||||||
|
|
||||||
|
#### `MPExtra.css`
|
||||||
|
公众号文章的完整样式表(从微信 CSS 复制并修改)。包含:
|
||||||
|
- 微信 UI 组件样式(`.rich_media_*`、`.weui-*`)
|
||||||
|
- 小程序卡片样式(`.weapp_*`)
|
||||||
|
- GIF 图片播放提示
|
||||||
|
- 全局消息提示栏
|
||||||
|
- 对话框组件(`.weui-dialog`)
|
||||||
|
- 公众号音频播放器样式(`.audio_*`,含 Retina 适配)
|
||||||
|
- 被删公众号链接隐藏处理
|
||||||
|
- iPhone 横屏适配
|
||||||
|
|
||||||
|
#### `MediaPlatform.css`
|
||||||
|
公众号文章阅读的精简样式。覆盖微信原生样式中的关键属性:背景色、字号、行高、blockquote、QQ 音乐区域、分享图片等。主要用于非文集场景的公众号阅读。
|
||||||
|
|
||||||
|
#### `WREpubMPVideo.css`
|
||||||
|
腾讯视频播放器的自定义样式。完全重写视频控制器 UI:
|
||||||
|
- 自定义播放/暂停、全屏按钮图标(使用 CDN sprite 图)
|
||||||
|
- 进度条、时间显示器布局
|
||||||
|
- 隐藏广告和下载提示
|
||||||
|
- 响应式适配(iPhone 6/6P 尺寸)
|
||||||
|
|
||||||
|
### Style 子目录
|
||||||
|
|
||||||
|
#### `Style/style.css`
|
||||||
|
笔记/想法的编辑和展示样式(EPUB 基准 1em=16px)。定义:
|
||||||
|
- 编辑器容器(`.re`)基础样式
|
||||||
|
- 段落、标题、列表、blockquote 样式
|
||||||
|
- 书籍卡片组件(`.re_bookItem`,含 Retina 边框)
|
||||||
|
- 图片样式(`.re_img`、`.bodyPic`)
|
||||||
|
- QQ 表情样式
|
||||||
|
- 深色模式(`.re_Night`)
|
||||||
|
|
||||||
|
#### `Style/preview.css`
|
||||||
|
想法/笔记的预览样式。与 `style.css` 类似但适用于阅读展示场景,字号 18px,行高 30px,使用 `-apple-system` 字体。
|
||||||
|
|
||||||
|
#### `Style/normalize.css`
|
||||||
|
normalize.css v3.0.2(MIT License)。标准化各浏览器的默认样式差异。
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
|
|
||||||
var generator = {};
|
|
||||||
|
|
||||||
generator.generateHtml = function (content, js, css) {
|
|
||||||
var result = content;
|
|
||||||
result = result.replace("<!--headTrap<body></body><head></head><html></html>-->", "");
|
|
||||||
result = result.replace("<!--tailTrap<body></body><head></head><html></html>-->", "");
|
|
||||||
var match = result.match(/window\.__nonce_str = "(\d+)"/);
|
|
||||||
var nonce = "";
|
|
||||||
if (match && match.length > 1) {
|
|
||||||
nonce = "nonce='" + match[1] + "'";
|
|
||||||
}
|
|
||||||
var insertJs = "<script " + (nonce.length > 1 ? nonce : "") + " type='text/javascript'>" + js + "</script></body>";
|
|
||||||
var insertStyle = "<style type='text/css'>" + css + "</style></head>";
|
|
||||||
result = result.replace("</head>", insertStyle);
|
|
||||||
result = result.replace("</body>", insertJs);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
generator.generateHtmlForMPReview = function (content,js, css) {
|
|
||||||
var result = generator.generateHtml(content,js,css)
|
|
||||||
var modifiedCopyRight = " <span id = 'copyright_logo' stype = 'display:none' > ";
|
|
||||||
var reg = /<span id="copyright_logo".*>?/;
|
|
||||||
result = result.replace(reg,modifiedCopyRight);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
(function(){
|
|
||||||
var head = document.getElementsByTagName("head")[0];
|
|
||||||
var meta = document.createElement('meta');
|
|
||||||
meta.name = "viewport";
|
|
||||||
meta.content = "width=device-width,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no";
|
|
||||||
head.appendChild(meta);
|
|
||||||
|
|
||||||
var children = document.body.children;
|
|
||||||
var titleFounded = false;
|
|
||||||
for(var i = 0; i < children.length; i++){
|
|
||||||
var child = children[i]
|
|
||||||
var tagName = child.tagName.toLowerCase()
|
|
||||||
// 隐藏标题前的空段落
|
|
||||||
if(!child.textContent){
|
|
||||||
child.style.display = "none";
|
|
||||||
}else if(titleFounded){
|
|
||||||
// 如果找到标题了,那么遇到第一个不为空的短路,就不再处理了
|
|
||||||
break
|
|
||||||
}
|
|
||||||
// 隐藏标题
|
|
||||||
if(tagName === 'h1' || tagName === 'h2' || tagName === 'h3'){
|
|
||||||
child.style.display = "none"
|
|
||||||
titleFounded = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
var mediaPlatform = {};
|
|
||||||
|
|
||||||
mediaPlatform.getMPHeight = function() {
|
|
||||||
var height = document.getElementById('js_article').scrollHeight;
|
|
||||||
return height;
|
|
||||||
};
|
|
||||||
|
|
||||||
mediaPlatform.getMPInfo = function() {
|
|
||||||
var title;
|
|
||||||
if (msg_title) {
|
|
||||||
title = msg_title;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
title = document.getElementsByTagName('h1')[0].innerHTML.toString();
|
|
||||||
}
|
|
||||||
var thumbUrl;
|
|
||||||
if (msg_cdn_url) {
|
|
||||||
thumbUrl = msg_cdn_url;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
thumbUrl = document.getElementsByTagName('img')[0].src.toString();
|
|
||||||
}
|
|
||||||
var account;
|
|
||||||
if (nickname) {
|
|
||||||
account = nickname;
|
|
||||||
}
|
|
||||||
if (!title) {title = "";}
|
|
||||||
if (!thumbUrl) {thumbUrl= "";}
|
|
||||||
if (!account) {account = "";}
|
|
||||||
|
|
||||||
var info = {
|
|
||||||
"title": title,
|
|
||||||
"thumbUrl": thumbUrl,
|
|
||||||
"account": account,
|
|
||||||
}
|
|
||||||
return info;
|
|
||||||
};
|
|
||||||
|
|
||||||
mediaPlatform.getMPInfoStr = function() {
|
|
||||||
return JSON.stringify(mediaPlatform.getMPInfo());
|
|
||||||
};
|
|
||||||
|
|
||||||
// 这里的做法是为了非独立图片(下方有内容)可以保持跟内容12px的距离
|
|
||||||
mediaPlatform.handleImageBlank = function() {
|
|
||||||
var imgs = document.getElementsByTagName('img');
|
|
||||||
for (var i = 0; i < imgs.length; i++) {
|
|
||||||
var nextNode = imgs[i].nextSibling;
|
|
||||||
var preNode = imgs[i].previousSibling;
|
|
||||||
|
|
||||||
// 只处理上方有空行的情况,下方有空行经常会跟图片的说明文字连用,尝试修改会导致样式错乱
|
|
||||||
if (preNode && preNode.nodeName.toLowerCase() == 'br') {
|
|
||||||
// 处理图片上面有个br,但是br不能自定义高度,所以直接去掉重新加margin
|
|
||||||
if (preNode.style) {
|
|
||||||
preNode.style.display = "none";
|
|
||||||
}
|
|
||||||
imgs[i].style.marginTop = '12px';
|
|
||||||
} else if (preNode && preNode.nodeType == 3 && !this.emptyTextNode(preNode)) {
|
|
||||||
// 处理图片跟上面文字在同一个p里面,但是没有br隔开
|
|
||||||
imgs[i].style.marginTop = '12px';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
mediaPlatform.emptyTextNode = function(el) {
|
|
||||||
return !el.data || /^(\s|\t)+$/g.test(el.data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 干掉p>br的情况的margin-top
|
|
||||||
mediaPlatform.updateBlankHeight = function() {
|
|
||||||
var blanks = document.getElementsByTagName('br');
|
|
||||||
for (var i = blanks.length - 1; i >= 0; i--) {
|
|
||||||
var parentNode = blanks[i].parentNode;
|
|
||||||
if (parentNode.nodeName.toLowerCase() == "p" && parentNode.childNodes.length == 1) {
|
|
||||||
parentNode.style.height = '20px';
|
|
||||||
parentNode.style.minHeight = '0em';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 观察到部分公众号文章首评的懒加载图片无法正常显示,这是由于 scroll 事件没有触发导致的(怀疑是在 rangy-classapplier 引入后产生了混合反应,但在 Android 上确是好的)。这里的解决办法是手动触发一次 scroll 事件,但由于我们的 webview 其实是完整高度的,本身并不能滚动,所以这样做也不会产生抖动或者其他副作用。
|
|
||||||
mediaPlatform.protectLazyLoad = function() {
|
|
||||||
window.scrollTo(0, 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
mediaPlatform.init = function() {
|
|
||||||
mediaPlatform.updateBlankHeight();
|
|
||||||
mediaPlatform.handleImageBlank();
|
|
||||||
mediaPlatform.protectLazyLoad();
|
|
||||||
};
|
|
||||||
|
|
||||||
mediaPlatform.getAuthor = function() {
|
|
||||||
var find = false;
|
|
||||||
var author = document.getElementById('js_name');
|
|
||||||
if (author != null) {
|
|
||||||
console.log('mediaPlatform.getAuthor ' + author.innerText)
|
|
||||||
author.addEventListener('click',function(){
|
|
||||||
wereadBridge.handleWithRichEditor("onClickAuthor",{"param" : author.innerText, "cmd": "onClickAuthor"}, "", "");
|
|
||||||
}, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mediaPlatform.init();
|
|
||||||
|
|
||||||
// 用于在退出公众号文章的时候暂停视频播放
|
|
||||||
// 由于腾讯视频是通过 iFrame 内嵌的,没有办法直接操作,我们用刷新 iFrame 的方式实现停止播放的效果
|
|
||||||
function pauseMedia() {
|
|
||||||
pauseCurrentAudio();
|
|
||||||
pauseCurrentVideo();
|
|
||||||
}
|
|
||||||
|
|
||||||
function pauseCurrentAudio() {
|
|
||||||
var audios = document.querySelectorAll('audio');
|
|
||||||
for (var i = 0; i < audios.length; i++) {
|
|
||||||
var audio = audios[i];
|
|
||||||
audio.pause();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function pauseCurrentVideo() {
|
|
||||||
var iframes = document.querySelectorAll('iframe');
|
|
||||||
for (var i = 0; i < iframes.length; i++) {
|
|
||||||
var oldFrame = iframes[i];
|
|
||||||
oldFrame.src = oldFrame.src;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,349 +0,0 @@
|
|||||||
var WRAUDIO_TEMPLATE = '<div id="wraudioid_xxx" class="audio_container">\
|
|
||||||
<div id="wraudio_button" class="audio_button"></div>\
|
|
||||||
<div class="audio_content">\
|
|
||||||
<p id="wraudio_title" class="audio_title"></p>\
|
|
||||||
<div class="audio_progress">\
|
|
||||||
<div class="audio_progress_bottom"></div>\
|
|
||||||
<div id="wraudio_progress_bar" class="audio_progress_top" style="width: 0;"></div>\
|
|
||||||
<div id="wraudio_progress_button" class="audio_progress_current" style="left: 0;"></div>\
|
|
||||||
</div>\
|
|
||||||
<div class="audio_time">\
|
|
||||||
<span id="wraudio_time_elapsed" class="audio_time_left"></span>\
|
|
||||||
<span id="wraudio_time_remains" class="audio_time_right"></span>\
|
|
||||||
</div>\
|
|
||||||
</div>\
|
|
||||||
</div>';
|
|
||||||
|
|
||||||
var _Utils = function () {
|
|
||||||
this.findChildById = function (element, childID) {
|
|
||||||
var retElement = null;
|
|
||||||
var lstChildren = Utils.getAllDescendant(element);
|
|
||||||
|
|
||||||
for (var i = 0; i < lstChildren.length; i++) {
|
|
||||||
if (lstChildren[i].id == childID) {
|
|
||||||
retElement = lstChildren[i];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return retElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.getAllDescendant = function (element, lstChildrenNodes) {
|
|
||||||
lstChildrenNodes = lstChildrenNodes ? lstChildrenNodes : [];
|
|
||||||
|
|
||||||
var lstChildren = element.childNodes;
|
|
||||||
|
|
||||||
for (var i = 0; i < lstChildren.length; i++) {
|
|
||||||
if (lstChildren[i].nodeType == 1) // 1 is 'ELEMENT_NODE'
|
|
||||||
{
|
|
||||||
lstChildrenNodes.push(lstChildren[i]);
|
|
||||||
lstChildrenNodes = Utils.getAllDescendant(lstChildren[i], lstChildrenNodes);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return lstChildrenNodes;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._formatTime = function (time, quotationStyle) {
|
|
||||||
if (time < 0) {
|
|
||||||
return '- : -'
|
|
||||||
}
|
|
||||||
var hour = parseInt(time / 3600);
|
|
||||||
var min = parseInt((time - hour * 3600) / 60);
|
|
||||||
var sec = parseInt((time - hour * 3600 - min * 60));
|
|
||||||
var timeText = '';
|
|
||||||
if (hour > 0) {
|
|
||||||
timeText += hour + ':';
|
|
||||||
}
|
|
||||||
if (quotationStyle) {
|
|
||||||
if (min > 0) {
|
|
||||||
timeText += min + "'";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (min < 10) {
|
|
||||||
timeText += '0' + min + ':';
|
|
||||||
} else {
|
|
||||||
timeText += min + ':';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (quotationStyle) {
|
|
||||||
timeText += sec + "''";
|
|
||||||
} else {
|
|
||||||
if (sec < 10) {
|
|
||||||
timeText += '0' + sec;
|
|
||||||
} else {
|
|
||||||
timeText += sec;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return timeText;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.formatTime = function (time, remains) {
|
|
||||||
var intTime = parseInt(time);
|
|
||||||
var desc = this._formatTime(intTime);
|
|
||||||
if (remains && intTime > 0) {
|
|
||||||
return '-' + desc;
|
|
||||||
}
|
|
||||||
return desc;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var Utils = new _Utils;
|
|
||||||
var player = {audio: null, current: null};
|
|
||||||
var videoIframes = [];
|
|
||||||
|
|
||||||
function getContentRoot() {
|
|
||||||
return document.getElementById('editor');
|
|
||||||
}
|
|
||||||
|
|
||||||
function createWRAudio(title, src, duration) {
|
|
||||||
var tmp = document.createElement('div');
|
|
||||||
tmp.innerHTML = WRAUDIO_TEMPLATE;
|
|
||||||
var wrAudio = tmp.firstChild;
|
|
||||||
|
|
||||||
wrAudio.id = 'wraudioid_' + Date.now() + '_' + Math.floor((Math.random() * 1000) + 1);
|
|
||||||
wrAudio.src = src;
|
|
||||||
wrAudio.duration = duration;
|
|
||||||
|
|
||||||
var titleEl = Utils.findChildById(wrAudio, 'wraudio_title', true);
|
|
||||||
titleEl.innerHTML = title || '';
|
|
||||||
var button = Utils.findChildById(wrAudio, 'wraudio_button', true);
|
|
||||||
button.addEventListener('click', function() {
|
|
||||||
clickPlayButton(wrAudio);
|
|
||||||
}, false);
|
|
||||||
|
|
||||||
var elapsedEl = Utils.findChildById(wrAudio, 'wraudio_time_elapsed', true);
|
|
||||||
var remainsEl = Utils.findChildById(wrAudio, 'wraudio_time_remains', true);
|
|
||||||
elapsedEl.innerHTML = Utils.formatTime(0);
|
|
||||||
remainsEl.innerHTML = Utils.formatTime(duration, true);
|
|
||||||
return wrAudio;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetWRAudio(wrAudio) {
|
|
||||||
if (!wrAudio) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var button = Utils.findChildById(wrAudio, 'wraudio_button', true);
|
|
||||||
button.classList.remove('active');
|
|
||||||
var elapsedEl = Utils.findChildById(wrAudio, 'wraudio_time_elapsed', true);
|
|
||||||
var remainsEl = Utils.findChildById(wrAudio, 'wraudio_time_remains', true);
|
|
||||||
elapsedEl.innerHTML = Utils.formatTime(0);
|
|
||||||
remainsEl.innerHTML = Utils.formatTime(wrAudio.duration, true);
|
|
||||||
|
|
||||||
var progressBar = Utils.findChildById(wrAudio, 'wraudio_progress_bar', true);
|
|
||||||
var progressButton = Utils.findChildById(wrAudio, 'wraudio_progress_button', true);
|
|
||||||
progressBar.style.width = '0';
|
|
||||||
progressButton.style.left = '0';
|
|
||||||
}
|
|
||||||
|
|
||||||
function clickPlayButton(wrAudio) {
|
|
||||||
if (!wrAudio) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var src = wrAudio.src;
|
|
||||||
if (!player.current) {
|
|
||||||
player.current = wrAudio;
|
|
||||||
player.audio.src = src;
|
|
||||||
player.audio.play();
|
|
||||||
} else if (player.current.id != wrAudio.id) {
|
|
||||||
var last = player.current;
|
|
||||||
player.current = wrAudio;
|
|
||||||
resetWRAudio(last);
|
|
||||||
|
|
||||||
player.audio.src = src;
|
|
||||||
player.audio.play();
|
|
||||||
} else {
|
|
||||||
if (!player.audio.paused) {
|
|
||||||
player.audio.pause();
|
|
||||||
} else {
|
|
||||||
player.audio.play();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function pauseCurrentAudio() {
|
|
||||||
if (player.audio) {
|
|
||||||
player.audio.pause();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function pauseCurrentVideo() {
|
|
||||||
let newVideoIframes = [];
|
|
||||||
for (var i = 0; i < videoIframes.length; i++) {
|
|
||||||
var oldFrame = videoIframes[i];
|
|
||||||
oldFrame.src = oldFrame.src;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function pauseMedia() {
|
|
||||||
pauseCurrentAudio();
|
|
||||||
pauseCurrentVideo();
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPlay() {
|
|
||||||
var button = Utils.findChildById(player.current, 'wraudio_button', true);
|
|
||||||
button.classList.add('active');
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPause() {
|
|
||||||
var button = Utils.findChildById(player.current, 'wraudio_button', true);
|
|
||||||
button.classList.remove('active');
|
|
||||||
}
|
|
||||||
|
|
||||||
function onEnded() {
|
|
||||||
var button = Utils.findChildById(player.current, 'wraudio_button', true);
|
|
||||||
button.classList.remove('active');
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDurationchange() {
|
|
||||||
player.current.duration = player.audio.duration;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onVideoPlaying() {
|
|
||||||
pauseCurrentAudio();
|
|
||||||
}
|
|
||||||
|
|
||||||
function onTimeupdate() {
|
|
||||||
var wrAudio = player.current;
|
|
||||||
var elapsedEl = Utils.findChildById(wrAudio, 'wraudio_time_elapsed', true);
|
|
||||||
var remainsEl = Utils.findChildById(wrAudio, 'wraudio_time_remains', true);
|
|
||||||
elapsedEl.innerHTML = Utils.formatTime(player.audio.currentTime);
|
|
||||||
remainsEl.innerHTML = Utils.formatTime(player.audio.duration - player.audio.currentTime, true);
|
|
||||||
|
|
||||||
var progressBar = Utils.findChildById(wrAudio, 'wraudio_progress_bar', true);
|
|
||||||
var progressButton = Utils.findChildById(wrAudio, 'wraudio_progress_button', true);
|
|
||||||
var progress = 0;
|
|
||||||
if (player.audio.duration > 0) {
|
|
||||||
progress = parseInt(player.audio.currentTime / player.audio.duration * 100);
|
|
||||||
}
|
|
||||||
progressBar.style.width = progress + '%';
|
|
||||||
progressButton.style.left = progress + '%';
|
|
||||||
}
|
|
||||||
|
|
||||||
function adjustMPContent() {
|
|
||||||
var imgs = document.querySelectorAll('img[data-src]');
|
|
||||||
for (var i = 0; i < imgs.length ; i++) {
|
|
||||||
var img = imgs[i];
|
|
||||||
img.src = img.getAttribute('data-src');
|
|
||||||
img.style = 'width: auto !important; height: auto !important; visibility: visible !important;max-width:100% !important';
|
|
||||||
}
|
|
||||||
|
|
||||||
var links = document.querySelectorAll('a[href^="https://open.weixin.qq.com/connect/oauth2/authorize"],a[href^="http://open.weixin.qq.com/connect/oauth2/authorize"]');
|
|
||||||
for (var i = 0; i < links.length ; i++) {
|
|
||||||
var link = links[i]
|
|
||||||
|
|
||||||
params = link.href.split('&');
|
|
||||||
for (var j = 0;j<params.length;j++) {
|
|
||||||
param = params[j];
|
|
||||||
if (param.startsWith('redirect_uri=')) {
|
|
||||||
link.href = decodeURIComponent(param.split('redirect_uri=')[1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
var iframes = document.querySelectorAll('iframe[data-src]');
|
|
||||||
for (var i = 0; i < iframes.length ; i++) {
|
|
||||||
(function (index) {
|
|
||||||
var iframe = iframes[index];
|
|
||||||
var iframeWidth = document.body.offsetWidth;
|
|
||||||
var iframeHeight = iframeWidth * 0.75;
|
|
||||||
var src = iframe.getAttribute('data-src');
|
|
||||||
src = src.replace(/preview.html/, 'player.html');
|
|
||||||
src = src.replace(/&(width=.*height=.*)&/, '&width=' + iframeWidth + '&height=' + iframeHeight + '&');
|
|
||||||
iframe.src = src;
|
|
||||||
// 是否视频
|
|
||||||
/*
|
|
||||||
if ((' ' + iframe.className + ' ').indexOf(' video_iframe ') > -1) {
|
|
||||||
iframe.onload = function () {
|
|
||||||
var styleNode = document.createElement('style');
|
|
||||||
styleNode.type = 'text/css';
|
|
||||||
styleNode.appendChild(document.createTextNode(''));
|
|
||||||
iframe.contentDocument.body.appendChild(styleNode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
// if (iframe.classList.contains("video_iframe")) {
|
|
||||||
// iframe.onload = function() {
|
|
||||||
// if (iframe.contentWindow && iframe.contentWindow.document) {
|
|
||||||
// var videoEl = iframe.contentWindow.document.getElementById("tenvideo_video_player_0");
|
|
||||||
// if (videoEl) {
|
|
||||||
// videoEl.onplaying = function() {
|
|
||||||
// onVideoPlaying();
|
|
||||||
// };
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// };
|
|
||||||
// }
|
|
||||||
videoIframes.push(iframe);
|
|
||||||
})(i);
|
|
||||||
}
|
|
||||||
|
|
||||||
var votes = document.querySelectorAll(".vote_area");
|
|
||||||
for (var i = 0; i < votes.length; i++) {
|
|
||||||
var vote = votes[i];
|
|
||||||
vote.parentElement.removeChild(vote);
|
|
||||||
}
|
|
||||||
|
|
||||||
var miniPrograms = document.querySelectorAll("mp-miniprogram");
|
|
||||||
for (var i = 0; i < miniPrograms.length; i++) {
|
|
||||||
var miniProgram = miniPrograms[i];
|
|
||||||
var parentElement = miniProgram.parentElement;
|
|
||||||
if (parentElement && parentElement.tagName && parentElement.tagName.toLowerCase() === 'p') {
|
|
||||||
if (parentElement.parentElement) {
|
|
||||||
parentElement.parentElement.removeChild(parentElement);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var miniAppLinks = document.querySelectorAll(".weapp_text_link, .weapp_image_link");
|
|
||||||
for (var i = 0; i < miniAppLinks.length; i++) {
|
|
||||||
var miniAppLink = miniAppLinks[i];
|
|
||||||
miniAppLink.parentElement.removeChild(miniAppLink);
|
|
||||||
}
|
|
||||||
|
|
||||||
var needPlayer = false;
|
|
||||||
var mpvoices = document.querySelectorAll('mpvoice[voice_encode_fileid]');
|
|
||||||
for (var i = 0; i < mpvoices.length ; i++) {
|
|
||||||
var mpvoice = mpvoices[i];
|
|
||||||
var title = mpvoice.getAttribute('name');
|
|
||||||
var src = 'https://res.wx.qq.com/voice/getvoice?mediaid=' + mpvoice.getAttribute('voice_encode_fileid');
|
|
||||||
var duration = mpvoice.hasAttribute('play_length') ? mpvoice.getAttribute('play_length') / 1000 : -1;
|
|
||||||
var wrAudio = createWRAudio(title, src, duration);
|
|
||||||
mpvoice.parentElement.appendChild(wrAudio);
|
|
||||||
needPlayer = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
var qqmusics = document.querySelectorAll('qqmusic[audiourl]');
|
|
||||||
for (var i = 0; i < qqmusics.length ; i++) {
|
|
||||||
var qqmusic = qqmusics[i];
|
|
||||||
var title = qqmusic.getAttribute('music_name');
|
|
||||||
var src = qqmusic.getAttribute('audiourl');
|
|
||||||
var duration = qqmusic.hasAttribute('play_length') ? qqmusic.getAttribute('play_length') / 1000 : -1;
|
|
||||||
var wrAudio = createWRAudio(title, src, duration);
|
|
||||||
qqmusic.parentElement.appendChild(wrAudio);
|
|
||||||
needPlayer = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (needPlayer) {
|
|
||||||
var audio = document.createElement('audio');
|
|
||||||
audio.autoplay = false;
|
|
||||||
audio.controls = true;
|
|
||||||
audio.style.display = "none";
|
|
||||||
|
|
||||||
audio.addEventListener("play", onPlay, false);
|
|
||||||
audio.addEventListener("pause", onPause, false);
|
|
||||||
audio.addEventListener("ended", onEnded, false);
|
|
||||||
audio.addEventListener("durationchange", onDurationchange, false);
|
|
||||||
audio.addEventListener("timeupdate", onTimeupdate, false);
|
|
||||||
|
|
||||||
getContentRoot().appendChild(audio);
|
|
||||||
player.audio = audio;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
adjustMPContent();
|
|
||||||
}, false);
|
|
||||||
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
(function() {
|
|
||||||
if (window.wrClickHandler) {
|
|
||||||
window.removeEventListener('click', window.wrClickHandler, true);
|
|
||||||
}
|
|
||||||
window.wrClickHandler = function(e) {
|
|
||||||
console.log('Hook: Click event intercepted', {
|
|
||||||
target: e.target,
|
|
||||||
tagName: e.target.tagName,
|
|
||||||
id: e.target.id,
|
|
||||||
className: e.target.className
|
|
||||||
});
|
|
||||||
|
|
||||||
var eventInfo = null;
|
|
||||||
var currentElement = e.target;
|
|
||||||
while (currentElement && currentElement !== document) {
|
|
||||||
var tagName = currentElement.tagName || '';
|
|
||||||
var currentId = currentElement.id || '';
|
|
||||||
if (currentId === 'js_name') {
|
|
||||||
// 公众号顶部的作者
|
|
||||||
eventInfo = {
|
|
||||||
contentType: 'profile',
|
|
||||||
bizId: ''
|
|
||||||
};
|
|
||||||
console.log('Hook: MP author click intercepted');
|
|
||||||
break;
|
|
||||||
} else if (currentId === 'copyright_info') {
|
|
||||||
// 公众号顶部的文章来源
|
|
||||||
var bizId = typeof source_encode_biz !== 'undefined' ? source_encode_biz : '';
|
|
||||||
if (bizId) {
|
|
||||||
eventInfo = {
|
|
||||||
contentType: 'profile',
|
|
||||||
bizId: bizId
|
|
||||||
};
|
|
||||||
console.log('Hook: Copyright profile click intercepted, bizId:', bizId);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
} else if (tagName === 'MP-COMMON-PROFILE') {
|
|
||||||
// 公众号
|
|
||||||
var bizId = currentElement.dataset && currentElement.dataset['id'] ? currentElement.dataset['id'] : '';
|
|
||||||
if (bizId) {
|
|
||||||
eventInfo = {
|
|
||||||
contentType: 'profile',
|
|
||||||
bizId: bizId
|
|
||||||
};
|
|
||||||
console.log('Hook: Profile click intercepted, bizId:', bizId);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
} else if (currentElement.href && currentElement.href.indexOf('mp.weixin.qq.com') !== -1) {
|
|
||||||
// 公众号文章链接
|
|
||||||
eventInfo = {
|
|
||||||
contentType: 'link',
|
|
||||||
url: currentElement.href
|
|
||||||
};
|
|
||||||
console.log('Hook: Link click intercepted, URL:', currentElement.href);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
currentElement = currentElement.parentElement;
|
|
||||||
}
|
|
||||||
// 如果有 eventInfo,则阻断事件冒泡,拦截公众号原始的跳转弹窗
|
|
||||||
if (eventInfo) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
}
|
|
||||||
if (eventInfo && wereadBridge) {
|
|
||||||
wereadBridge.execMPReaderMethod('MPReader', {
|
|
||||||
cmd: 'clickMpInterceptor',
|
|
||||||
data: eventInfo
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener('click', window.wrClickHandler, true);
|
|
||||||
console.log('Hook: Click interceptor setup completed');
|
|
||||||
})();
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user