feat: 交互协调器拆分、附件提示、暗色图片适配、选区放大镜及文档清理

- 拆分 ContentDelegates/TextContentView 为独立协调器(InteractionCoordinator、LocationResolution、ExternalLinks、AttachmentTooltip)
- 新增 RDEPUBAttachmentTooltipView/OverlayView 附件气泡提示
- 新增 RDEPUBDarkImageAdjuster 暗色模式图片亮度适配
- 新增 RDEPUBSelectionLoupeView 选区放大镜
- 新增 MetadataParseWorker/CancellationController 元数据解析取消机制
- 重构 PresentationRuntime/PaginationCoordinator 精简职责
- 优化 ChapterLoader/WarmupOrchestrator 异步章节加载
- CFI 模块微调与 NoteModels 更新
- 清理冗余文档,更新架构/UML/业务逻辑文档

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
shenlei 2026-06-24 17:47:24 +08:00
parent 7de661eb54
commit d15f20b097
59 changed files with 4522 additions and 7220 deletions

View File

@ -1,115 +0,0 @@
# 架构分析WXRead 参考 vs ReadViewSDK 当前
**分析日期:** 2026-05-23
**分析基础:** `Doc/WXRead/decompiled-doc.md`、`Doc/WXRead/resources-doc.md`、`Doc/WXRead/读书EPUB阅读器实现架构.md`
**状态:** Decisions captured
---
<domain>
## 分析范围
对比读书 (WeRead v10.0.3) 逆向架构与当前 ReadViewSDK 项目的结构性差距聚焦四大方向分页引擎集成、CSS 处理、渲染架构、字体系统。
读书 EPUB 渲染的核心架构特征:
- Path A (EPUB): 纯 CoreText 渲染 — `WRPageView.drawRect:``CTFrameDraw`,不用 UITextView/UILabel
- 4 级语义断页:`WRCoreTextLayouter` + `WRCoreTextLayoutFrame` (语义边界 > 附件边界 > 块边界 > 帧限制)
- 5 层 CSS 级联:`default.css < replace.css < dark.css < EPUB 内嵌 < 用户设置`
- 字符级位置精度:`WREpubPositionConverter` (fileIndex, row, column) ↔ 全局字符偏移
- 标注直接在 CTFrame 层叠加绘制,搜索高亮同理
</domain>
<decisions>
## 已决事项
### Area 1: 分页引擎集成P0
| 决策 | 说明 |
|------|------|
| 将 `RDEPUBTextLayouter` 集成到 `RDEPUBTextBookBuilder` | 内部调用 `rd_paginatedFrames(size:)` 替代旧的 `ss_pageRanges(size:)`,外部 API 不变 |
| 分页元数据暴露到 `RDEPUBTextPage` | 新增 `metadata: RDEPUBTextPageMetadata` 字段(含 breakReason/blockKinds/semanticHints对外暴露分页质量数据 |
| `RDEPUBTextChapterPaginationDiagnostic` 增强 | 透传 RDEPUBTextLayoutFrame 的诊断信息 |
| 分页缓存 | 按 `bookID + fontSize + lineHeightMultiple + contentInsets` 生成缓存 key缓存完整 `RDEPUBTextBook` 到磁盘 |
### Area 2: CSS `<link>` 外部样式表处理P0
| 决策 | 说明 |
|------|------|
| 预处理内联 CSS | 渲染前扫描 HTML 中 `<link>` 标签,从 EPUB 解压目录读取 CSS 内容,注入 `<style>` 替换 `<link>` |
| 实现 5 层 CSS 级联 | `RDEPUBTextStyleSheetBuilder` 实现 `default < replace < dark < epub-embedded < user` 五层合并,使用已定义的 `RDEPUBTextStyleSheetPackage`/`RDEPUBTextStyleSheetLayer` |
### Area 3: CoreText 直接绘制迁移P1 — 大架构变更)
| 决策 | 说明 |
|------|------|
| 直接替换 UITextView | 新实现完全替代 `RDEPUBTextContentView`,不保留 UITextView 渐进迁移路径 |
| CoreText 原生选区 | `CTLineGetStringIndexForPosition` 坐标 hit test + 自定义选区绘制,不依赖 UITextView 选区 |
| 标注渲染CGContext 装饰层 | drawRect 中 CTFrameDraw 绘制文本后,遍历当前页 RDEPUBHighlightCGContext 绘制背景矩形highlight/ 下划线underline |
| 搜索高亮CGContext 叠加绘制 | 不修改底层 attributedString在 drawRect 中根据匹配范围直接绘制高亮背景 |
### Area 4: 字体系统P1 — 延迟到后续版本)
| 决策 | 说明 |
|------|------|
| 方案:内嵌固定字体集 | SDK bundle 内嵌常用中文字体Settings 面板新增字体选择。不做 CDN 动态下载 |
| 本版本范围:暂不做 | 字体切换功能延迟到后续迭代 |
</decisions>
<canonical_refs>
## 关键参考文档
**WXRead 逆向参考(必须阅读):**
- `Doc/WXRead/decompiled-doc.md` — 44 个逆向文件的职责说明
- `Doc/WXRead/resources-doc.md` — CSS/JS 资源文件清单与职责
- `Doc/WXRead/读书EPUB阅读器实现架构.md` — 双渲染引擎架构总览
- `Doc/WXRead/decompiled/WRCoreTextLayoutFrame.m` — 跨页避让、装饰元素、搜索高亮实现
- `Doc/WXRead/decompiled/WRCoreTextLayouter.m` — 4 级语义断页配置
- `Doc/WXRead/decompiled/WRPageView.m` — CoreText 直接绘制参考
- `Doc/WXRead/decompiled/WREpubTypesetter.m` — CSS 级联合并参考
- `Doc/WXRead/resources/css/replace.css` — 5 层 CSS 参考
**当前项目代码(已有的基础设施):**
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextLayouter.swift` — 已实现 4 级语义断页,待集成
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextLayoutFrame.swift` — 帧模型,含 breakReason/semanticHints
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift` — 已定义 `RDEPUBTextStyleSheetPackage`/`RDEPUBTextStyleSheetLayer`,未完全使用
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift` — 当前可能仍在用旧的 `ss_pageRanges`
- `Sources/RDReaderView/EPUBUI/RDEPUBTextContentView.swift` — UITextView 实现,待替换为 CoreText
</canonical_refs>
<roadmap_mapping>
## 与现有 Roadmap 的映射
| 决策 | 对应 Phase | 说明 |
|------|-----------|------|
| RDEPUBTextLayouter 集成 | Phase 8 (08-02) | 分页质量改善的核心 |
| 分页元数据暴露 | Phase 8 (08-03) | 分页诊断输出的一部分 |
| 分页缓存 | Phase 8 (08-01) | 缓存键与失效策略 |
| CSS `<link>` 内联 | Phase 7 范畴外 | 当前 Roadmap 未覆盖,需新增 phase 或合并到 Phase 8 |
| 5 层 CSS 级联 | Phase 7 + Phase 8 | Phase 7 已完成属性闭环,级联是后续增强 |
| **CoreText 直接绘制** | **v1.2 或 v2.0** | **超出 v1.1 Roadmap需要独立 milestone** |
| CoreText 原生选区 | 随 CoreText 迁移 | 同上 |
| 标注 CGContext 绘制 | 随 CoreText 迁移 | 同上 |
| 字体系统 | 未来版本 | 延迟 |
**关键发现:** CoreText 直接绘制迁移是最大的架构变更,超出 v1.1 的增量改进范围。建议作为 v1.2 独立 milestone 规划。
</roadmap_mapping>
<deferred>
## 延迟事项
- **字体系统**:内嵌固定字体集方案已决,延迟到后续版本
- **位置精度提升**字符级锚点P2与 CoreText 迁移关联
- **章节数据模型内聚**WRChapterData 模式P2架构清晰度改进
- **TTS / DRM / Pencil / 多栏排版**P3独立功能模块
- **翻页控制器健壮性**UIPageViewController crash patchP2当前未遇到相关崩溃
</deferred>
---
*分析范围WXRead 逆向文档 → ReadViewSDK 架构差距*
*产出4 个 Area、14 项决策、1 项延迟*

View File

@ -105,7 +105,7 @@ paginateTextPublication()
│ ├─ makePartialPageMap() // 构建局部 BookPageMap
│ └─ applyBookPageMap() // 应用到 UI用户可立即阅读
└─ paginateMetadataOnly() // 后台元数据解析
└─ RDEPUBMetadataParseWorker.start() // 后台元数据解析
├─ 预计算 contentHash串行 // 读取所有章节 HTML + SHA-256
├─ readAll(keys:) 恢复已有缓存
@ -245,7 +245,7 @@ RDEPUBReaderController
├─ RDEPUBReaderPaginationCoordinator // 分页入口与后台元数据解析
│ ├─ paginatePublication()
│ ├─ paginateMetadataOnly()
│ ├─ RDEPUBMetadataParseWorker.start()
│ └─ restoreBookPageMapIfPossible()
├─ RDEPUBPresentationRuntime // 分页状态与窗口替换

View File

@ -1,606 +0,0 @@
# Android EPUB 阅读器实施方案
## Context
当前 ReadViewSDK 是一个功能完整的 iOS EPUB 阅读器框架Swift + UIKit支持 EPUB 2/3 解析、三种渲染模式(重排/固定布局/网页交互)、全文搜索、高亮/书签/批注、分页管理等。目标是基于现有 iOS 版本的架构和 JS 桥接协议,实现一套 Android 版本的 EPUB 阅读器。
**核心策略**JS 桥接层epub-bridge.js、WeReadApi.js 等直接复用Swift 逻辑层用 Kotlin 重写UIKit UI 层用 Android 原生 View 重写。
---
## 第一阶段:项目基础设施(预计 1 周)
### 1.1 创建 Android 项目
```
ReadViewSDK-Android/
├── app/ # Demo 应用
├── reader-core/ # Kotlin 核心库(解析、模型、搜索)
├── reader-jsbridge/ # WebView JS 桥接层
├── reader-ui/ # Android UI 组件(工具栏、搜索面板、设置等)
├── reader-view/ # 翻页容器ViewPager2 封装)
└── build-logic/ # Gradle convention plugins
```
- 语言Kotlin
- 最低 API24Android 7.0
- 目标 API34
- 构建工具Gradle + Kotlin DSL
- 依赖注入:手动(与 iOS 的 `RDEPUBReaderDependencies` 模式一致)
### 1.2 Gradle 模块划分
| 模块 | 对应 iOS 层 | 职责 |
|------|------------|------|
| `reader-core` | EPUBCore/ + EPUBTextRendering/ | 解析、模型、搜索引擎、CSS 生成、分页算法 |
| `reader-jsbridge` | RDEPUBJavaScriptBridge + JS Resources | JS 文件管理、消息收发、脚本生成 |
| `reader-ui` | EPUBUI/ | 工具栏、搜索面板、设置面板、目录列表、高亮管理 |
| `reader-view` | ReaderView/ | 翻页容器ViewPager2 + PagerSnapHelper |
### 1.3 公共依赖
```kotlin
// reader-core
implementation("net.lingala.zip4j:zip4j:2.11.5") // 替代 ZIPFoundation
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0")
implementation("com.google.code.gson:gson:2.11.0") // 替代 Codable
implementation("androidx.webkit:webkit:1.10.0")
// reader-ui
implementation("androidx.recyclerview:recyclerview:1.3.2")
implementation("com.google.android.material:material:1.12.0")
implementation("androidx.viewpager2:viewpager2:1.0.0")
// reader-view
implementation("androidx.viewpager2:viewpager2:1.0.0")
implementation("androidx.recyclerview:recyclerview:1.3.2")
```
---
## 第二阶段Core 层 — EPUB 解析与模型(预计 2 周)
### 2.1 数据模型迁移
将 iOS 的 Codable struct 迁移为 Kotlin data class
| iOS 文件 | Android 文件 | 关键类型 |
|----------|-------------|---------|
| `RDEPUBModels.swift` | `EpubModels.kt` | `EpubLayout`, `EpubMetadata`, `ManifestItem`, `SpineItem`, `TableOfContentsItem` |
| `RDEPUBReadingLocationModels.swift` | `ReadingLocationModels.kt` | `ReadingLocation`, `Viewport`, `ReadingContext` |
| `RDEPUBAnnotationModels.swift` | `AnnotationModels.kt` | `Selection`, `Highlight`, `Bookmark`, `Annotation` |
| `RDEPUBPaginationModels.swift` | `PaginationModels.kt` | `EpubPage`, `ChapterInfo`, `FixedSpread` |
| `RDEPUBSearchModels.swift` | `SearchModels.kt` | `SearchMatch`, `SearchResult`, `SearchState`, `SearchPresentation` |
| `RDEPUBPreferences.swift` | `ReaderPreferences.kt` | `ReadingPreferences`(字体、行高、颜色、分栏) |
| `RDEPUBRenderRequest.swift` | `RenderRequest.kt` | `ReflowableRenderRequest`, `FixedRenderRequest` |
| `RDEPUBReaderConfiguration.swift` | `ReaderConfiguration.kt` | 完整阅读器配置 |
| `RDEPUBReaderTheme.swift` | `ReaderTheme.kt` | 6 种预设主题 |
### 2.2 EPUB 解析器迁移
| iOS 文件 | Android 文件 | 说明 |
|----------|-------------|------|
| `RDEPUBParser.swift` | `EpubParser.kt` | 主解析入口 |
| `RDEPUBParser+Archive.swift` | `EpubParser+Archive.kt` | zip4j 解压(替代 ZIPFoundation |
| `RDEPUBParser+Package.swift` | `EpubParser+Package.kt` | OPF XML SAX 解析 |
| `RDEPUBParser+TOC.swift` | `EpubParser+TOC.kt` | NCX/Nav Document 解析 |
| `RDEPUBParser+Resources.swift` | `EpubParser+Resources.kt` | HTML/资源文件读取 |
| `RDEPUBParser+ReadingProfile.swift` | `EpubParser+ReadingProfile.kt` | 阅读模式判断 |
| `RDEPUBPublication.swift` | `EpubPublication.kt` | Facade 门面 |
| `RDEPUBResourceResolver.swift` | `ResourceResolver.kt` | URL 规范化 |
**XML 解析方案**iOS 使用 `XMLParser`SAXAndroid 使用 `org.xml.sax.XMLReader`(同为 SAX解析逻辑可逐行对照翻译。
### 2.3 搜索引擎迁移
| iOS 文件 | Android 文件 |
|----------|-------------|
| `RDEPUBSearchEngine.swift` | `EpubSearchEngine.kt` |
| `RDEPUBTextSearchEngine.swift` | `TextSearchEngine.kt` |
搜索引擎核心逻辑(遍历 spine → 读取 HTML → 解析为纯文本 → NSString.range 搜索 → 生成 match可直接翻译为 Kotlin `String.indexOf` 循环。
### 2.4 CSS 生成与样式
| iOS 文件 | Android 文件 |
|----------|-------------|
| `RDEPUBStyleSheetBuilder.swift` | `StyleSheetBuilder.kt` |
| `RDEPUBFixedLayoutTemplate.swift` | `FixedLayoutTemplate.kt` |
CSS 生成逻辑完全平台无关,直接翻译即可。
### 2.5 分页算法
| iOS 文件 | Android 文件 | 说明 |
|----------|-------------|------|
| `RDEPUBPaginator.swift` | `EpubPaginator.kt` | 离屏 WebView 分页计算 |
| `RDEPUBReadingSession.swift` | `ReadingSession.kt` | 状态机 + 页面管理 |
**关键决策**Android 版统一使用 WebView 渲染,不实现 `EPUBTextRendering`DTCoreText 的 Android 等价物过于复杂且收益低)。这意味着:
- **不迁移** `EPUBTextRendering/` 整个目录18 个文件)
- **不迁移** `EPUBUI/TextPage/` 目录7 个文件)
- Android 版的所有内容(包括重排模式)都通过 WebView + CSS column 分页实现
- 这与 iOS 的 `webInteractive` 路径一致,已验证可行
---
## 第三阶段JS 桥接层(预计 1 周)
### 3.1 JS 文件直接复用
将以下文件直接复制到 Android `assets/` 目录:
```
reader-jsbridge/src/main/assets/js/
├── epub-bridge.js # 直接复用
├── WeReadApi.js # 直接复用
├── cssInjector.js # 直接复用
├── rangy-core.js # 直接复用
└── rangy-serializer.js # 直接复用
```
### 3.2 WKWebView → Android WebView 桥接映射
| iOS 机制 | Android 等价物 |
|----------|--------------|
| `WKScriptMessageHandler.userContentController(_:didReceive:)` | `@JavascriptInterface` 方法 |
| `window.webkit.messageHandlers.{name}.postMessage()` | 修改 JS 端调用 `AndroidBridge.{method}()` |
| `WKWebView.evaluateJavaScript()` | `WebView.evaluateJavascript()` |
| `WKURLSchemeHandler` | `WebViewClient.shouldInterceptRequest()` |
| `WKUserScript`(页面加载前注入) | `WebView.addJavascriptInterface()` + `WebViewClient.onPageStarted()` |
### 3.3 Android Bridge 实现
```kotlin
// EpubBridge.kt — 对应 iOS RDEPUBJavaScriptBridge
class EpubBridge(private val callback: BridgeCallback) {
@JavascriptInterface
fun onProgressionChanged(json: String) { callback.onProgressionChanged(json) }
@JavascriptInterface
fun onSelectionChanged(json: String) { callback.onSelectionChanged(json) }
@JavascriptInterface
fun onInternalLink(href: String) { callback.onInternalLink(href) }
@JavascriptInterface
fun onExternalLink(url: String) { callback.onExternalLink(url) }
@JavascriptInterface
fun onJSError(message: String) { callback.onJSError(message) }
@JavascriptInterface
fun onFixedLayoutReady() { callback.onFixedLayoutReady() }
}
```
**JS 端修改**:需要将 `window.webkit.messageHandlers.xxx.postMessage(data)` 替换为 `window.AndroidBridge.xxx(JSON.stringify(data))`。可以通过在注入时做字符串替换,或维护一个 Android 版本的 bridge JS 文件。
### 3.4 WebView 资源拦截
对应 iOS 的 `ss-reader://book/` 自定义协议:
```kotlin
// EpubSchemeHandler.kt
class EpubSchemeHandler(private val publication: EpubPublication) : WebViewClient() {
override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? {
val url = request.url.toString()
if (url.startsWith("ss-reader://book/")) {
val path = url.removePrefix("ss-reader://book/")
val data = publication.readResource(path)
val mimeType = getMimeType(path)
return WebResourceResponse(mimeType, "UTF-8", data.byteInputStream())
}
return null
}
}
```
### 3.5 JavaScriptBridge 脚本生成
对应 iOS 的 `RDEPUBJavaScriptBridge` 中各种 `*Script()` 方法,生成 `evaluateJavascript()` 调用的 JS 代码字符串。这些方法的核心是拼接 JSON 参数 + 调用 `WeReadApi.*` 方法,逻辑完全相同。
---
## 第四阶段:翻页容器(预计 1.5 周)
### 4.1 PagerView 实现
对应 iOS `RDReaderView`UICollectionView + UIPageViewController
```kotlin
// PagerView.kt — 对应 iOS RDReaderView
class PagerView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : FrameLayout(context, attrs) {
private val viewPager: ViewPager2
private val adapter: PageAdapter
enum class DisplayMode { HORIZONTAL, VERTICAL }
var displayMode = DisplayMode.HORIZONTAL
set(value) { /* 切换 orientation */ }
fun reloadData() { adapter.notifyDataSetChanged() }
fun scrollToPage(index: Int, animated: Boolean) { /* ... */ }
}
```
**PageAdapter**:使用 `RecyclerView.Adapter`,每个 item 是一个 `FrameLayout`,内部放 `WebView`
### 4.2 点击区域检测
对应 iOS `RDReaderTapRegionHandler`
```kotlin
// TapRegionHandler.kt
class TapRegionHandler {
enum class Region { LEFT, CENTER, RIGHT }
fun resolve(x: Float, viewWidth: Float): Region {
return when {
x < viewWidth / 3 -> Region.LEFT
x > viewWidth * 2 / 3 -> Region.RIGHT
else -> Region.CENTER
}
}
}
```
### 4.3 预加载控制
对应 iOS `RDReaderPreloadController`
使用 `ViewPager2.offscreenPageLimit` 控制预加载页面数量(默认 1-2 页)。
---
## 第五阶段UI 组件(预计 2 周)
### 5.1 阅读器主控制器
对应 iOS `RDEPUBReaderController`
```kotlin
// EpubReaderFragment.kt — Fragment 优于 Activity方便宿主嵌入
class EpubReaderFragment : Fragment() {
// 对应 iOS 的各 Coordinator
private lateinit var runtime: ReaderRuntime
private lateinit var pagerView: PagerView
private lateinit var topBar: TopToolBar
private lateinit var bottomBar: BottomToolBar
private lateinit var searchPanel: SearchPanelView
// Public API
fun openBook(epubUrl: Uri, configuration: ReaderConfiguration = ReaderConfiguration.default)
fun goTo(location: ReadingLocation)
fun goToPageNumber(pageNumber: Int)
fun search(keyword: String)
fun addHighlight(selection: Selection, color: Int, note: String?)
fun addBookmark(note: String?)
// ... 其余 public API
}
```
### 5.2 Coordinator 架构迁移
保持 iOS 的 Coordinator 模式,每个 Coordinator 作为独立的 Kotlin 类:
| iOS Coordinator | Android 类 | 职责 |
|----------------|-----------|------|
| `RDEPUBReaderRuntime` | `ReaderRuntime` | Facade协调所有子 Coordinator |
| `RDEPUBReaderLoadCoordinator` | `LoadCoordinator` | EPUB 文件解析 + Publication 初始化 |
| `RDEPUBReaderPaginationCoordinator` | `PaginationCoordinator` | 分页计算 |
| `RDEPUBReaderLocationCoordinator` | `LocationCoordinator` | 阅读位置管理 + 持久化 |
| `RDEPUBReaderChromeCoordinator` | `ChromeCoordinator` | 工具栏状态同步 |
| `RDEPUBReaderAnnotationCoordinator` | `AnnotationCoordinator` | 高亮/书签/批注 CRUD |
| `RDEPUBReaderSearchCoordinator` | `SearchCoordinator` | 全文搜索协调 |
| `RDEPUBReaderViewportMonitor` | `ViewportMonitor` | 屏幕旋转/尺寸变化检测 |
### 5.3 WebView 内容视图
对应 iOS `RDEPUBWebView` + `RDEPUBWebContentView`
```kotlin
// EpubWebView.kt — 封装 Android WebView
class EpubWebView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : WebView(context, attrs) {
private val bridge = EpubBridge(/* ... */)
fun setup() {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
addJavascriptInterface(bridge, "AndroidBridge")
webViewClient = EpubSchemeHandler(publication)
webChromeClient = WebChromeClient()
}
fun loadChapter(spineIndex: Int, preferences: ReadingPreferences) {
// 生成 HTML 模板 + 注入 CSS + 加载资源
}
fun evaluateScript(script: String, callback: ((String?) -> Unit)? = null) {
evaluateJavascript(script) { result -> callback?.invoke(result) }
}
}
```
### 5.4 顶部工具栏
对应 iOS `RDEPUBReaderTopToolView`
```xml
<!-- top_tool_bar.xml -->
<LinearLayout>
<ImageButton android:id="@+id/btnBack" />
<TextView android:id="@+id/tvTitle" weight="1" />
<ImageButton android:id="@+id/btnSearch" />
<ImageButton android:id="@+id/btnBookmark" />
</LinearLayout>
```
### 5.5 底部工具栏
对应 iOS `RDEPUBReaderBottomToolView`
```xml
<!-- bottom_tool_bar.xml -->
<LinearLayout>
<ImageButton android:id="@+id/btnTOC" /> <!-- 目录 -->
<ImageButton android:id="@+id/btnBookmarks" /> <!-- 书签列表 -->
<ImageButton android:id="@+id/btnHighlights" /> <!-- 高亮列表 -->
<ImageButton android:id="@+id/btnAddHighlight" /> <!-- 添加高亮 -->
<ImageButton android:id="@+id/btnSettings" /> <!-- 设置 -->
</LinearLayout>
```
### 5.6 搜索面板
对应 iOS `RDEPUBReaderSearchBarView`
```kotlin
// SearchPanelView.kt
class SearchPanelView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : LinearLayout(context, attrs, defStyleAttr) {
private val searchField: EditText
private val cancelButton: Button
private val resultsList: RecyclerView // 分组结果列表
private val emptyStateLabel: TextView
fun updateResults(sections: List<SearchSection>, keyword: String, currentMatchIndex: Int?)
fun showNoResults()
fun showSearching()
}
```
### 5.7 设置面板
对应 iOS `RDEPUBReaderSettingsViewController`
使用 `BottomSheetDialogFragment` 实现底部弹出设置面板:
- 亮度调节SeekBar
- 字体大小(+/- 按钮)
- 字体选择
- 行间距
- 主题切换6 种预设)
- 翻页模式切换
### 5.8 目录列表
对应 iOS `RDEPUBReaderChapterListController`
使用 `BottomSheetDialogFragment` + `RecyclerView` 实现目录列表,支持章节标题显示和点击跳转。
---
## 第六阶段:持久化与状态管理(预计 0.5 周)
### 6.1 持久化实现
对应 iOS `RDEPUBReaderPersistence`
```kotlin
// ReaderPersistence.kt
interface ReaderPersistence {
fun loadLocation(bookId: String): ReadingLocation?
fun saveLocation(bookId: String, location: ReadingLocation)
fun loadBookmarks(bookId: String): List<Bookmark>
fun saveBookmarks(bookId: String, bookmarks: List<Bookmark>)
fun loadHighlights(bookId: String): List<Highlight>
fun saveHighlights(bookId: String, highlights: List<Highlight>)
fun loadSettings(): ReaderSettings
fun saveSettings(settings: ReaderSettings)
}
// SharedPreferences 实现
class SharedPrefsPersistence(context: Context) : ReaderPersistence {
// 使用 Gson 序列化/反序列化
}
```
### 6.2 状态管理
使用 Kotlin `StateFlow` 替代 iOS 的手动状态同步:
```kotlin
// ReaderState.kt
data class ReaderUiState(
val isLoading: Boolean = true,
val currentPage: Int = 0,
val totalPages: Int = 0,
val title: String = "",
val isToolbarVisible: Boolean = false,
val isSearchVisible: Boolean = false,
val bookmarks: List<Bookmark> = emptyList(),
val highlights: List<Highlight> = emptyList(),
val searchState: SearchState? = null,
)
class ReaderStateManager {
private val _uiState = MutableStateFlow(ReaderUiState())
val uiState: StateFlow<ReaderUiState> = _uiState.asStateFlow()
}
```
---
## 第七阶段:主题与外观(预计 0.5 周)
### 7.1 主题系统
对应 iOS `RDEPUBReaderTheme`
```kotlin
// ReaderTheme.kt
data class ReaderTheme(
val name: String,
val contentBackgroundColor: Int, // Color int
val textColor: Int,
val toolbarBackgroundColor: Int,
val toolbarForegroundColor: Int,
val isDark: Boolean
) {
companion object {
val LIGHT = ReaderTheme("浅色", Color.WHITE, Color.BLACK, ...)
val DARK = ReaderTheme("深色", Color.rgb(0x1A, 0x1A, 0x1A), Color.WHITE, ...)
val YELLOW = ReaderTheme("纸质", Color.rgb(0xF5, 0xF0, 0xE0), Color.BLACK, ...)
// ... 共 6 种
}
}
```
### 7.2 状态栏适配
根据主题动态设置状态栏颜色(`Window.statusBarColor`)和图标颜色(`WindowInsetsController.isAppearanceLightStatusBars`)。
---
## 第八阶段:测试与 Demo预计 1 周)
### 8.1 单元测试
- `EpubParserTest` — EPUB 解析container.xml、OPF、TOC
- `SearchEngineTest` — 搜索引擎
- `StyleSheetBuilderTest` — CSS 生成
- `ResourceResolverTest` — URL 解析
- `LocationCoordinatorTest` — 位置管理
### 8.2 集成测试
- `EpubReaderIntegrationTest` — 完整阅读流程(打开 → 分页 → 翻页 → 搜索 → 高亮)
### 8.3 Demo App
对应 iOS `ReadViewDemo`
```kotlin
// MainActivity.kt
class MainActivity : AppCompatActivity() {
private fun openBook() {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/epub+zip"
}
startActivityForResult(intent, REQUEST_OPEN_BOOK)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == REQUEST_OPEN_BOOK && resultCode == RESULT_OK) {
val uri = data?.data ?: return
val fragment = EpubReaderFragment.newInstance(uri)
// 嵌入 Fragment
}
}
}
```
### 8.4 UI 自动化测试
对应 iOS 的 XCUIApplication 测试24 个测试文件):
- 使用 Espresso + UiAutomator
- 覆盖:目录交互、页码导航、书签管理、高亮管理、搜索、设置效果、工具栏状态等
---
## 实施时间估算
| 阶段 | 内容 | 预计工时 |
|------|------|---------|
| 1 | 项目基础设施 | 1 周 |
| 2 | Core 层解析、模型、搜索、CSS | 2 周 |
| 3 | JS 桥接层 | 1 周 |
| 4 | 翻页容器 | 1.5 周 |
| 5 | UI 组件 | 2 周 |
| 6 | 持久化与状态管理 | 0.5 周 |
| 7 | 主题与外观 | 0.5 周 |
| 8 | 测试与 Demo | 1 周 |
| **合计** | | **约 9.5 周** |
---
## 关键技术决策
1. **统一 WebView 渲染**:不实现 DTCoreText 等效层,所有内容通过 WebView + CSS column 分页。简化实现,与 iOS 的 webInteractive 路径一致。
2. **Coordinator 模式保留**:与 iOS 架构保持一致,便于两端逻辑对照和维护。
3. **JS Bridge 微调**Android 的 `@JavascriptInterface` 与 iOS 的 `WKScriptMessageHandler` 机制不同JS 端需要适配(用 `AndroidBridge.*` 替代 `window.webkit.messageHandlers.*`)。
4. **不使用 Kotlin Multiplatform**:当前阶段直接用 Kotlin 重写,避免 KMP 的额外复杂度。未来如果需要共享逻辑层,可以逐步迁移。
5. **ViewPager2 替代 PageViewController**Android 无 page curl 效果,使用 ViewPager2 + 自定义 PageTransformer 实现翻页动画(可选深度效果或滑动效果)。
---
## 文件映射索引iOS → Android
### 核心映射
| iOS Swift 文件 | Android Kotlin 文件 | 行数参考 |
|---------------|-------------------|---------|
| `RDEPUBParser.swift` + extensions | `EpubParser.kt` + extensions | ~600 |
| `RDEPUBModels.swift` | `EpubModels.kt` | ~200 |
| `RDEPUBPublication.swift` | `EpubPublication.kt` | ~150 |
| `RDEPUBJavaScriptBridge.swift` | `EpubJavaScriptBridge.kt` | ~400 |
| `RDEPUBStyleSheetBuilder.swift` | `StyleSheetBuilder.kt` | ~300 |
| `RDEPUBSearchEngine.swift` | `EpubSearchEngine.kt` | ~150 |
| `RDEPUBPaginator.swift` | `EpubPaginator.kt` | ~200 |
| `RDEPUBReadingSession.swift` | `ReadingSession.kt` | ~250 |
| `RDEPUBReaderController.swift` + extensions | `EpubReaderFragment.kt` + extensions | ~800 |
| `RDEPUBReaderContext.swift` | `ReaderContext.kt` | ~150 |
| `RDEPUBReaderRuntime.swift` | `ReaderRuntime.kt` | ~200 |
| 各 Coordinator 文件 | 对应 `*Coordinator.kt` | 各 ~100-200 |
| `RDEPUBWebView.swift` + extensions | `EpubWebView.kt` + extensions | ~500 |
| `RDReaderView.swift` + extensions | `PagerView.kt` + extensions | ~400 |
| `RDEPUBReaderSearchBarView.swift` | `SearchPanelView.kt` | ~500 |
| `RDEPUBReaderTopToolView.swift` | `TopToolBar.kt` | ~150 |
| `RDEPUBReaderBottomToolView.swift` | `BottomToolBar.kt` | ~200 |
| `RDEPUBReaderSettingsViewController.swift` | `SettingsSheetFragment.kt` | ~300 |
### 直接复用(不需翻译)
| 文件 | 说明 |
|------|------|
| `epub-bridge.js` | JS 核心桥接 |
| `WeReadApi.js` | JS API 门面 |
| `cssInjector.js` | CSS 注入工具 |
| `rangy-core.js` | Range 操作库 |
| `rangy-serializer.js` | Range 序列化 |
### 不迁移Android 不需要)
| iOS 文件/目录 | 原因 |
|-------------|------|
| `EPUBTextRendering/`18 个文件) | DTCoreText/CoreText 无 Android 等价物,统一用 WebView |
| `EPUBUI/TextPage/`7 个文件) | 原生文字渲染层Android 不需要 |
| `RDEPUBTextContentView.swift` | 同上 |
| `RDEPUBTextPageRenderView.swift` | 同上 |
| `RDEPUBWebDecorationOverlayView.swift` | Android WebView 的高亮由 JS 直接处理,不需要原生 overlay |

View File

@ -177,7 +177,7 @@ while location < totalLength:
│ ├─ 加载窗口内相邻章节
│ └─ 应用局部 BookPageMap → 用户可立即阅读
└─ 后台解析(paginateMetadataOnly
└─ 后台解析(RDEPUBMetadataParseWorker.start()
├─ 预计算所有章节 contentHash串行~1-3s
├─ 恢复已有磁盘缓存
├─ 等待用户操作冷却 0.8s
@ -196,7 +196,7 @@ while location < totalLength:
var contentHashBySpineIndex: [Int: String] = [:]
for spineIndex in allBuildableIndices {
let html = parser.htmlString(forRelativePath: href)
contentHashBySpineIndex[spineIndex] = html?.sha256Hex ?? ""
contentHashBySpineIndex[spineIndex] = html?.rd_sha256Hex ?? ""
}
```
@ -206,7 +206,7 @@ for spineIndex in allBuildableIndices {
**问题:** 如果用户在后台解析进行中更改字号/行距,`context.currentRenderSignature()` 会返回新值,导致部分章节用旧签名、部分用新签名写入磁盘缓存,造成缓存不一致。
**解决:** 在 `paginateMetadataOnly` 开始时冻结签名:
**解决:** 在 `RDEPUBMetadataParseWorker` 初始化时冻结签名:
```swift
let renderSignature = context.currentRenderSignature()

View File

@ -328,7 +328,7 @@ func resolvePage(absolutePageIndex: Int) -> RDEPUBResolvedPage?
### 7.1 整体流程
```
paginateMetadataOnly(token)
RDEPUBMetadataParseWorker.start(token)
├─ 预计算所有章节 contentHash串行
├─ readAll(keys:) 批量读取磁盘缓存
@ -354,7 +354,7 @@ paginateMetadataOnly(token)
var contentHashBySpineIndex: [Int: String] = [:]
for spineIndex in allBuildableIndices {
let html = parser.htmlString(forRelativePath: href)
contentHashBySpineIndex[spineIndex] = html?.sha256Hex ?? ""
contentHashBySpineIndex[spineIndex] = html?.rd_sha256Hex ?? ""
}
```
@ -364,7 +364,7 @@ for spineIndex in allBuildableIndices {
**问题**:用户在后台解析进行中更改字号/行距,会导致部分章节用旧签名、部分用新签名写入缓存。
**解决**:在 `paginateMetadataOnly` 开始时冻结签名:
**解决**:在 `RDEPUBMetadataParseWorker` 初始化时冻结签名:
```swift
let renderSignature = context.currentRenderSignature()

View File

@ -58,7 +58,10 @@ public final class RDEPUBReaderController: UIViewController {
|------|------|
| `RDEPUBReaderController+PublicAPI.swift` | 公开 API跳转、搜索、标注、书签 |
| `RDEPUBReaderController+DataSource.swift` | `RDReaderPageProvider` 实现 |
| `RDEPUBReaderController+ContentDelegates.swift` | WebView/TextContentView 代理 |
| `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` | 目录处理 |
@ -410,7 +413,8 @@ struct RDEPUBResolvedPage {
| `RDEPUBChapterLocation.swift` | 章节位置模型 |
| `RDEPUBChapterOffsetMap.swift` | 章节偏移映射 |
| `RDEPUBBackgroundTrace.swift` | 后台任务追踪日志 |
| `String+SHA256.swift` | SHA256 哈希扩展 |
| `RDEPUBMetadataParseWorker.swift` | 后台元数据解析 worker |
| `RDEPUBMetadataParseCancellationController.swift` | 解析取消控制器 |
---

View File

@ -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`:交互式 EPUBJS/音视频/表单/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
- G1reflowable EPUB 的正文渲染/排版/分页改为“WXRead 风格原生渲染管线”:
- XHTML/HTML →CSS 分层 + 解析 + 后处理)→ `NSAttributedString`
- `NSAttributedString`CoreText 分页)→ 单页内容
- 单页内容 → 原生绘制/展示
- G2保持 Fixed Layout 与交互式 EPUB 的 `WKWebView` 路径不回归。
- G3不破坏 `RDURLReaderController` 打开 `.epub` / `.txt` 的主流程。
- G4Demo 可用于验证:至少 2-3 本典型 reflowable EPUB 在段落/标题/图片/链接等常见内容下可稳定阅读。
### 1.2 非目标Out of Scope
- N1Fixed Layout EPUB 切换为原生渲染(明确不做)。
- N2交互式 EPUB 切换为原生渲染(明确不做)。
- N3一次性复刻 WXRead 对 DTCoreText 的所有深度魔改(例如自定义 CSS 属性体系、复杂后处理、分页避断规则等)——本次先按“问题驱动”逐步对齐。
## 2. 关键事实核验(当前代码真实路径)
> 纠偏说明项目初始化时曾把”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 4EPUB 嵌入 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 主要风险
- R1DTCoreText 对 EPUB 内嵌 CSS / `<link>` CSS 支持不足,导致样式缺失
- R2分页质量不足断页不美观、图片分页异常
- R3`hasInteractiveContent()` 判定过宽,导致大量书被误判为 `.webInteractive`,覆盖率不足
### 8.2 降级/灰度(建议)
- D1增加一个“渲染引擎开关”仅 debug 或 demo 可配置),可在出现严重问题时快速回退到现有 `RDEPUBDTCoreTextRenderer`
- D2`hasInteractiveContent()` 的判定提供可配置白名单/黑名单(例如按 manifest properties、按 tag 命中级别)
## 9. 下一步(交接到开发)
建议按 `Doc/ARCHITECTURE-CONTEXT.md` 中的架构决策逐步推进:
- 先基于 `Doc/WXRead/analysis/*` 提炼“我们要实现的 CSS 分层最小集合”
- 再在 `.textReflowable` renderer 里实现“分层 CSS 注入 + baseURL/资源解析兜底”
- 最后用 Demo 书籍做回归,按问题驱动补齐分页/样式细节
---
*Last updated: 2026-05-21 after discuss-feature-solution*

View File

@ -1,365 +0,0 @@
# 将高亮选区实现 1:1 复刻为 WXRead 架构
## Context
当前 ReadViewSDK 的高亮选区实现与 WXRead 存在根本性架构差异。需要将选区系统从"UITextView 透明代理 + 独立 overlay 层"迁移到 WXRead 的"自定义手势 + CoreText 直接命中测试 + 统一 drawRect 绘制"架构。
## 核心差异对比
| 维度 | 当前 ReadViewSDK | WXRead |
|------|-----------------|--------|
| 选择触发 | UITextView 原生长按(透明文本) | 自定义 long-press(0.5s) + pan 手势 |
| 命中测试 | DTCoreText `stringIndex(forPosition:)` | `CTLineGetStringIndexForPosition` + 坐标翻转 |
| 选区绘制 | 独立 `RDEPUBSelectionOverlayView` overlay 层 | 同一 `drawRect:` 内绘制(文字+高亮+选区) |
| 高亮绘制 | overlay 层计算 rect 后 CG 填充 | `com.weread.highlight` 自定义属性注入 NSAttributedString`drawInContext:` 中读取绘制 |
| 菜单系统 | 自定义 `selectionActionBar` UIStackView | `UIMenuController` + 自定义 items |
| 手势模型 | 无 pan 手势UITextView 自带拖拽) | long-press 启动 + pan 扩展,`isSelecting` 控制 pan 启停 |
| 视图层级 | 3 层 overlaybackground + text + foreground | 单一 WRPageView 统一绘制 |
---
## Phase 1: 移除 UITextView改用自定义手势 + CoreText 命中测试
### 1.1 修改 `RDEPUBPageInteractionController.swift`
当前已正确封装 DTCoreText 的 `stringIndex(forPosition:)``offset(forStringIndex:)`,算法与 WXRead 一致。
**新增方法:**
- `characterIndexForViewPoint(at viewPoint: CGPoint, in view: UIView)` — 将 UIKit 坐标转为相对于 content view 的坐标后调用 `characterIndex(at:)`,对应 WXRead 的 `stringIndexForPoint:` + `WRSFlipPointForCoreText`
> 注意DTCoreText 已在内部处理了 UIKit↔CoreText 坐标翻转(`line.baselineOrigin` 是 UIKit 坐标),所以不需要手动翻转 Y 轴。但 WXRead 的自定义 DTCoreText 需要手动翻转。当前项目用的是原版 DTCoreText pod行为已正确。
### 1.2 重构 `RDEPUBTextSelectionController.swift`
**当前状态:** 遵循 `UITextViewDelegate`,通过 `textViewDidChangeSelection` 接收选区变化。`handleLongPress` 方法存在但未被任何手势调用。
**改为:**
- 移除 `UITextViewDelegate` 遵循
- 移除 `textViewDidChangeSelection(_:)``textViewDidChangeSelection(_:, page:)`
- 新增状态属性:`selectionStartIndex: Int = NSNotFound`、`selectionEndIndex: Int = NSNotFound`、`isSelecting: Bool = false`
- 重构 `handleLongPress`
- `.began`:调用 `characterIndex(at:)` 设置 `selectionStartIndex = selectionEndIndex = index`,设 `isSelecting = true`
- `.ended`:设 `isSelectionFromInteraction = false`(保留,供后续扩展)
- 新增 `handlePan(_ gesture:, page:, renderView:, interactionController:)`
- `.changed`:计算字符索引,更新 `selectionEndIndex`,计算 range = `(min, max - min)`,计算 rects更新 renderView
- 移除 `clearSelection``textView:` 参数
- `makeSelection(from:, page:)` 保持不变(已正确基于绝对偏移构建 `RDEPUBSelection`
### 1.3 重构 `RDEPUBTextContentView.swift` — 移除 UITextView
**删除:**
- `textView: RDEPUBSelectableTextView` 属性及其初始化
- `selectionProxyContent(from:)` 方法
- `textView.delegate = selectionController` 等 textView 配置代码
- `textView.frame = ...``layoutSubviews` 中的设置
- `configure(page:...)` 中所有 `textView.attributedText = ...`、`textView.selectedRange = ...`、`textView.isHidden = ...` 赋值
**新增手势识别器(对齐 WXRead 的 WRPageView**
```swift
private let longPressGR = UILongPressGestureRecognizer(target: ..., action: #selector(handleLongPress))
private let panGR = UIPanGestureRecognizer(target: ..., action: #selector(handlePan))
private let tapGR = UITapGestureRecognizer(target: ..., action: #selector(handleTap))
```
- `longPressGR.minimumPressDuration = 0.5`(与 WXRead 一致)
- `panGR.isEnabled = false`初始禁用long-press began 时启用)
- `tapGR.require(toFail: longPressGR)`(与 WXRead 一致)
- 三个手势都添加到 contentView 自身
**手势响应:**
- `handleLongPress`:转发给 `selectionController.handleLongPress`,启用 `panGR`
- `handlePan`:转发给 `selectionController.handlePan`
- `handleTap`:如果 `selectionController.isSelecting``clearSelection()`,否则转发给 delegate 做工具栏切换
**菜单改为 UIMenuController对齐 WXRead**
- 删除 `selectionActionBar: UIStackView` 及相关方法(`showSelectionActionBarIfNeeded`、`hideSelectionActionBar`、`updateSelectionActionBarFrame`、`selectionMenuButton`
- 在 `selectionController.onSelectionChanged` 回调中,当 selection 非 nil 时调用 `showSelectionMenu(in:anchorRect:)`
- `RDEPUBTextContentView` 设为 `canBecomeFirstResponder = true`override `canPerformAction` 仅允许三个自定义 selector
- 使用 `UIMenuController.shared` 配置 "拷贝"/"高亮"/"批注" 三个 `UIMenuItem`
**调整 `clearSelection()`**
```swift
func clearSelection() {
currentSelection = nil
menuSelection = nil
panGR.isEnabled = false
selectionController.clearSelection(overlayView: overlayView, backgroundOverlayView: backgroundOverlayView)
UIMenuController.shared.setMenuVisible(false, animated: true)
}
```
### 1.4 删除 `RDEPUBSelectableTextView.swift`
该文件的功能(屏蔽系统菜单、暴露自定义 action已被 UIMenuController 方案替代,直接删除。
### 1.5 更新 `RDEPUBReaderController+ContentDelegates.swift`
`textContentView(_:, didRequestSelectionAction:, selection:)` 中的 `contentView.clearSelection()` 调用无需改动,新的 `clearSelection()` 签名兼容。
### Phase 1 验证
- UI 测试 `ReaderAnnotationTests.testSelectionMenuCreatesHighlight` 必须通过
- 手动验证:长按选词 → 弹出 UIMenuController → 点击"高亮" → 高亮创建成功
- 手动验证:拖拽扩展选区 → 蓝色选区矩形正确绘制
- 手动验证:单击空白处 → 选区清除
---
## Phase 2: 统一绘制循环 — 高亮/选区在 draw(_:) 中绘制
### 2.1 扩展 `RDEPUBTextPageRenderView.swift`
**当前状态:** 仅调用 `layoutFrame.draw(in: context, options:)` 绘制文字。
**新增属性:**
```swift
var highlightRanges: [(range: NSRange, color: UIColor)] = [] { didSet { setNeedsDisplay() } }
var underlineRanges: [(range: NSRange, color: UIColor, style: Int)] = [] { didSet { setNeedsDisplay() } }
var selectionRects: [CGRect] = [] { didSet { setNeedsDisplay() } }
var selectionColor: UIColor = UIColor(red: 70/255, green: 140/255, blue: 1, alpha: 0.24)
```
**扩展 `draw(_:)`**
```swift
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext(), let layoutFrame else { return }
context.saveGState()
// 1. 绘制高亮背景(在文字下方,匹配 WXRead 的 drawHighlightsInContext:
drawHighlights(in: context, layoutFrame: layoutFrame)
// 2. 绘制文字
layoutFrame.draw(in: context, options: drawOptions)
// 3. 绘制选区(在文字上方,匹配 WXRead 的 _drawSelectionInContext:
drawSelection(in: context)
context.restoreGState()
}
```
**`drawHighlights` 算法(对齐 WXRead `WRCoreTextLayoutFrame.drawHighlightsInContext:`**
```swift
private func drawHighlights(in context: CGContext, layoutFrame: DTCoreTextLayoutFrame) {
for (range, color) in highlightRanges {
let lines = layoutFrame.lines as! [DTCoreTextLayoutLine]
for line in lines {
let overlap = NSIntersectionRange(range, line.stringRange)
guard overlap.length > 0 else { continue }
let startX = line.offset(forStringIndex: overlap.location)
let endX = line.offset(forStringIndex: overlap.location + overlap.length)
let rect = CGRect(
x: line.baselineOrigin.x + startX,
y: line.baselineOrigin.y - line.ascent,
width: endX - startX,
height: line.ascent + line.descent
)
color.withAlphaComponent(0.35).setFill() // WXRead 使用 35% alpha
context.fill(rect)
}
}
}
```
> 说明WXRead 使用 `[color colorWithAlphaComponent:0.3]`WRPageHighlight 的预设色本身已是 35% alpha最终效果等同。当前项目使用 0.45 alpha需调整为 0.35 以完全对齐。
**`drawSelection` 算法(对齐 WXRead `_drawSelectionInContext:`**
```swift
private func drawSelection(in context: CGContext) {
guard !selectionRects.isEmpty else { return }
selectionColor.setFill()
for rect in selectionRects {
context.fill(rect)
}
}
```
### 2.2 简化 `RDEPUBTextContentView.swift` 视图层级
**删除/保留:**
- 删除 `backgroundOverlayView` 属性(高亮背景现在由 renderView 在文字下方绘制)
- 保留 `overlayView`(用于非 DTCoreText 回退路径和搜索高亮)
- `configure(page:...)` 中,将 highlight 数据传给 `coreTextContentView` 而非 overlayView
```swift
// DTCoreText 路径
coreTextContentView.highlightRanges = highlights.compactMap { highlight -> (NSRange, UIColor)? in
guard let rangeInfo = highlight.rangeInfo,
let info = RDEPUBTextOffsetRangeInfo.decode(from: rangeInfo) else { return nil }
let absoluteRange = info.nsRange
let overlap = NSIntersectionRange(absoluteRange, pageAbsoluteRange)
guard overlap.length > 0 else { return nil }
let relativeRange = NSRange(location: overlap.location - page.pageStartOffset, length: overlap.length)
return (relativeRange, highlight.uiColor)
}
```
### 2.3 更新 `RDEPUBTextSelectionController.swift` — 直接更新 renderView
- `handleLongPress``handlePan` 现在直接更新 `renderView.selectionRects` 并调用 `renderView.setNeedsDisplay()`
- 移除 `overlayView.updateSelection(absoluteRange:, rects:)` 调用
### Phase 2 验证
- 视觉对比:高亮矩形与文字像素对齐
- 性能测试:单次 `draw(_:)` 耗时应与之前持平或更快
- 回归测试:搜索高亮仍正常显示
---
## Phase 3: 高亮属性注入 NSAttributedString对齐 WXRead `com.weread.highlight`
### 3.1 定义自定义属性常量
```swift
// 对齐 WXRead 的 kWRHighlightAttributeName / kWRUnderlineAttributeName
let kRDEPUBHighlightAttributeName = NSAttributedString.Key("com.rdreader.highlight")
let kRDEPUBUnderlineAttributeName = NSAttributedString.Key("com.rdreader.underline")
```
### 3.2 新增 `RDEPUBChapterData.applyHighlights(to:page:highlights:)`
对齐 WXRead 的 `WRChapterData.addHighlightInRange:key:itemId:color:`
```swift
func applyHighlights(
to content: NSMutableAttributedString,
page: RDEPUBTextPage,
highlights: [RDEPUBHighlight]
) {
for highlight in highlights {
guard let rangeInfo = highlight.rangeInfo,
let info = RDEPUBTextOffsetRangeInfo.decode(from: rangeInfo) else { continue }
let absoluteRange = info.nsRange
let pageRange = NSRange(location: page.pageStartOffset, length: page.pageEndOffset - page.pageStartOffset)
let overlap = NSIntersectionRange(absoluteRange, pageRange)
guard overlap.length > 0 else { continue }
let relativeRange = NSRange(location: overlap.location - page.pageStartOffset, length: overlap.length)
switch highlight.style {
case .highlight:
content.addAttribute(kRDEPUBHighlightAttributeName, value: highlight.uiColor, range: relativeRange)
case .underline:
content.addAttribute(kRDEPUBUnderlineAttributeName, value: highlight.uiColor, range: relativeRange)
}
}
}
```
### 3.3 `RDEPUBTextPageRenderView.draw(_:)` 从属性读取高亮
替代 Phase 2 的 `highlightRanges` 属性方案,改为在 `draw(_:)` 中枚举 attributed string 的自定义属性:
```swift
private func drawHighlightsFromAttributes(in context: CGContext, attributedString: NSAttributedString) {
let fullRange = NSRange(location: 0, length: attributedString.length)
attributedString.enumerateAttribute(kRDEPUBHighlightAttributeName, in: fullRange) { value, range, _ in
guard let color = value as? UIColor else { return }
let rects = computeRects(for: range) // 复用 line 迭代 + CTLineGetOffsetForStringIndex
color.withAlphaComponent(0.35).setFill()
for rect in rects { context.fill(rect) }
}
}
```
### 3.4 更新 `RDEPUBTextContentView.configure(page:...)`
```swift
// 在传给 renderView 之前注入高亮属性
let displayContent = darkImageAdjustedContentIfNeeded(...)
chapterData.applyHighlights(to: displayContent, page: page, highlights: highlights)
coreTextContentView.attributedDisplayContent = displayContent // 新增属性
```
### Phase 3 验证
- 高亮在翻页后仍正确显示(属性嵌入 attributed string不依赖外部状态
- 高亮颜色、alpha、rect 与 WXRead 截图一致
---
## Phase 4: 手势模型对齐 — long-press + pan + isSelecting
### 4.1 手势冲突处理
**风险:** pan 手势(选区扩展)可能与 RDReaderView 的翻页手势冲突。
**解决方案(对齐 WXRead**
- `panGR` 初始 `isEnabled = false`,仅在 `isSelecting = true` 时启用
- `clearSelection()` 时禁用 `panGR`
- 新增 delegate 方法通知父视图:
```swift
func textContentViewDidBeginSelection(_ contentView: RDEPUBTextContentView)
func textContentViewDidEndSelection(_ contentView: RDEPUBTextContentView)
```
- `RDReaderView``didBeginSelection` 时禁用翻页手势,在 `didEndSelection` 时恢复
### 4.2 WXRead 手势时序对齐
WXRead 的手势流程:
1. long-press `.began` → 设置 `selectionStartIndex = selectionEndIndex = index``isSelecting = true`,启用 panGR`setNeedsDisplay`
2. long-press `.ended` → 无额外操作(保留选区)
3. pan `.changed` → 更新 `selectionEndIndex`,计算 rects`setNeedsDisplay`
4. single tap → `clearSelection()`,禁用 panGR
### Phase 4 验证
- 长按选词 → 拖拽扩展 → 单击取消,全流程流畅
- 无选区时翻页手势正常
- 有选区时翻页手势被禁用
---
## Phase 5: 菜单系统对齐 + 清理
### 5.1 UIMenuController 替换 selectionActionBar
已在 Phase 1 中完成。此阶段仅做清理:
- 删除 `RDEPUBSelectableTextView.swift`
- 标记 `RDEPUBSelectionOverlayView.swift``RDEPUBTextPageDecorationView.swift` 为 deprecated保留给非 DTCoreText 回退路径)
### 5.2 更新 UI 测试
`ReaderAnnotationTests` 中查找菜单项的方式需更新:
- 当前:`app.buttons["高亮"]`UIStackView 中的按钮)
- 改为:`app.menuItems["高亮"]`UIMenuController 的菜单项)
- 或者:保留 `accessibilityIdentifier` 在 RDEPUBTextContentView 上以便测试定位
### 5.3 高亮颜色对齐
WXRead 的 5 种预设色35% alpha
- Yellow: `(1.0, 0.92, 0.23, 0.35)`
- Blue: `(0.26, 0.65, 0.96, 0.35)`
- Red: `(0.96, 0.26, 0.26, 0.35)`
- Green: `(0.30, 0.85, 0.39, 0.35)`
- Purple: `(0.67, 0.33, 0.97, 0.35)`
当前项目使用 CSS hex 颜色 + 0.45 alpha需对齐为 WXRead 的 RGBA 值。
---
## 文件变更清单
| 文件 | 操作 | Phase |
|------|------|-------|
| `RDEPUBPageInteractionController.swift` | 修改:新增 `characterIndexForViewPoint` | 1 |
| `RDEPUBTextSelectionController.swift` | 重构:移除 UITextViewDelegate新增 pan 处理、状态机 | 1 |
| `RDEPUBTextContentView.swift` | 重构:移除 textView新增手势替换菜单简化层级 | 1,2,4 |
| `RDEPUBSelectableTextView.swift` | **删除** | 1 |
| `RDEPUBTextPageRenderView.swift` | 扩展:新增高亮/选区/下划线绘制逻辑 | 2,3 |
| `RDEPUBChapterData.swift` | 新增:`applyHighlights(to:page:highlights:)` | 3 |
| `RDEPUBReaderController+ContentDelegates.swift` | 小改:适配新 clearSelection 签名 | 1 |
| `RDReaderView.swift` | 新增:选区期间禁用翻页手势 | 4 |
| `RDEPUBSelectionOverlayView.swift` | 保留deprecated for DTCoreText path | 5 |
| `RDEPUBTextPageDecorationView.swift` | 保留deprecated for DTCoreText path | 5 |
| `RDEPUBTextAnnotationOverlay.swift` | 保留deprecated for DTCoreText path | 5 |
| `ReaderAnnotationTests.swift` | 更新:菜单项查找方式 | 5 |
## 验证方案
1. **UI 测试**`ReaderAnnotationTests` 全部通过
2. **手动测试**
- 长按选词 → 蓝色选区高亮显示
- 拖拽扩展选区 → 选区跟随手指
- 点击"高亮" → 黄色高亮创建成功
- 翻页后返回 → 高亮仍存在
- 点击"拷贝" → 文本已复制
- 点击"批注" → 弹出笔记输入框
- 单击空白处 → 选区清除
3. **性能测试**`draw(_:)` 耗时 ≤ 之前(单次绘制 vs 三次绘制)
4. **对比验证**:与 WXRead 截图对比高亮颜色、alpha、rect 位置

View File

@ -1,740 +0,0 @@
# ReadViewSDK 架构整改路线图
> 最后更新2026-06-22
> 适用范围:`Sources/RDReaderView/` 下的 EPUB 阅读器 SDK 主体代码
> 目标性质:可执行整改方案,而不是纯讨论文档
---
## 1. 文档目标
本文将当前 SDK 的架构问题整理为一份可逐阶段推进的整改路线图,目标是解决以下五类核心问题:
1. 共享状态与 UI 环境混杂,导致隐藏依赖过多。
2. 运行时协调中心过大,新增需求持续回流到单一大类。
3. 章节加载、分页、定位、选区等关键链路存在同步边界与模型重复。
4. 阅读容器层、业务控制层、渲染能力层之间的职责边界还不够稳定。
5. 用户感知最强的性能问题缺少独立的优先修复阶段。
本文不追求一次性重写,而强调:
- 先收口依赖方向
- 再拆核心状态与服务
- 最后统一抽象与模块边界
---
## 2. 当前架构问题摘要
基于当前代码,主要问题集中在以下对象与层次:
### 2.1 `RDEPUBReaderContext` 过重
涉及文件:
- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderContext.swift`
当前同时承担:
- 共享状态容器
- UI 环境查询入口
- service locator
- render/layout 参数推导
- controller/runtime 反向跳转
这会让下层对象表面上只依赖 `context`,实际上隐式依赖整棵 UI 树与运行时生命周期。
### 2.2 `RDEPUBReaderRuntime` 过大
涉及文件:
- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderRuntime.swift`
当前问题:
- 持有大量 coordinator
- 继续承接大量 facade API
- 同时编排分页、章节窗口、选区、书签、高亮、设置预览、page map 替换等多领域逻辑
结果是 runtime 已经成为事实上的架构中心点。
### 2.3 存在危险同步边界
涉及文件:
- `Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLoader.swift`
- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderContext.swift`
当前风险:
- 同步章节加载仍保留在系统内部
- 后台线程获取分页尺寸时可能回主线程同步查询
- 长链路上仍可能形成“主线程等后台,后台等主线程”的死锁型结构
### 2.4 分页状态模型重复
涉及对象:
- `bookPageMap`
- `pendingFullPageMap`
- `chapter window snapshot`
- `readingSession.activePages/activeChapters`
当前问题:
- 多套近似模型并存
- takeover/reconciliation 规则分散
- 维护“当前阅读窗口”的逻辑被多个对象共同持有
### 2.5 `RDReaderView` 既是容器又是兼容层
涉及文件:
- `Sources/RDReaderView/ReaderView/RDReaderView.swift`
- `Sources/RDReaderView/ReaderView/RDReaderViewProtocols.swift`
当前问题:
- 同时承载 page curl / scroll / dual-page / preload / tool chrome
- 同时兼容 `RDReaderDataSource``RDReaderPageProvider`
- 既做分页容器,又承担历史 API 适配职责
---
## 3. 整改原则
整改过程中遵循以下原则:
1. 不做一次性全量重写,采用分阶段替换。
2. 优先解决依赖方向错误,再解决类过大问题。
3. 所有阶段都必须保持 Demo 可运行、SDK 公共 API 尽量兼容。
4. 先抽象内部接口,再清理外部旧接口。
5. 每个阶段结束时必须有可验证的稳定输出。
---
## 4. 总体阶段划分
建议分为 6 个阶段推进:
1. Phase 0建立基线与护栏
2. Phase 0.5:优先消除主线程同步加载与跨章节翻页卡顿
3. Phase 1收缩 `Context`,切开环境与状态
4. Phase 2拆分 `Runtime` 与章节加载编排
5. Phase 3统一分页状态模型与导航状态机
6. Phase 4收敛 `ReaderView` 抽象与模块边界
建议执行顺序不可颠倒。
原因:
- 如果不先建立基线,后续性能与结构整改缺少可验证参照。
- 如果不先把最强用户痛点独立处理,后续阶段虽然架构更干净,但用户体感改善会滞后。
- `Context` 拆分是中期结构整改前置条件,但不是修复同步加载卡顿的前置条件。
- 如果不先拆 `Runtime`,分页模型和容器抽象重构会继续回流到 runtime。
- 如果不先统一分页状态模型,后续 UI/容器抽象无法稳定。
---
## 5. Phase 0建立基线与护栏
### 5.1 目标
在重构前建立可回归的技术基线,避免“结构越改越好,但行为逐渐漂移”。
### 5.2 主要工作
1. 建立架构整改分支与阶段文档索引。
2. 为以下关键链路补 smoke 验证清单:
- 打开一本大书
- 跨章节连续翻页
- 恢复上次阅读位置
- 搜索关键字并跳转
- 长按选区并添加高亮
- 打开目录远跳
3. 补充日志观察点:
- 章节首次构建耗时
- `bookPageMap` 扩展/替换次数
- CFI 延迟构建完成次数
- 页面静态底图缓存命中率
4. 把“禁止 UI 主路径同步章节加载”加成断言或日志告警。
5. 补充用户感知最直接的观测指标:
- 翻页路径主线程阻塞时长
- 跨章节翻页帧稳定性
- 预加载命中率
### 5.3 建议修改文件
- `Doc/TESTING.md`
- `Doc/ARCHITECTURE.md`
- `Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBBackgroundTrace.swift`
- 必要时新增轻量调试开关文件
### 5.4 验收标准
- 有一份明确的人工回归 checklist
- 核心链路能通过现有 Demo 手工验证
- 关键性能/状态切换点有结构化日志
### 5.5 关键观测指标
Phase 0 结束前,建议至少具备以下指标:
1. `prepareOnDemandChapter` 主线程 wall clock
- 目标:识别是否仍有 UI 主路径同步等待章节构建
2. 跨章节翻页时的帧稳定性
- 可使用 `os_signpost`、`CADisplayLink` 或简化采样方案
- 目标:量化“动画有没有明显掉帧”
3. `RDReaderPreloadController.takePreloadedView(for:)` 命中率
- 目标:评估预加载是否真的在帮助翻页,而不是名义存在
4. `bookPageMap` 扩展/替换频次
- 目标:评估分页窗口切换是否过于频繁
5. CFI 延迟构建次数与完成耗时
- 目标:评估交互增强能力是否被延迟过度
---
## 6. Phase 0.5:优先消除主线程同步加载与跨章节翻页卡顿
### 6.1 目标
在不等待大规模结构拆分的前提下,优先解决用户感知最强的问题:
- 跨章节翻页时主线程阻塞
- 章节边界预加载命中不足
- 页面进入时重复重绘开销过高
这是最高优先级阶段,目标是尽快让用户在大书场景下获得可感知的流畅度提升。
### 6.2 主要问题链路
当前高风险链路集中在:
- `RDEPUBReaderController+DataSource.pageContentView`
- `RDEPUBReaderController+DataSource.pageNum`
- `RDEPUBReaderRuntime.prepareOnDemandChapter`
- `RDEPUBReaderRuntime.extendPartialBookPageMapIfNeeded`
- `RDEPUBChapterLoader.loadChapterSynchronouslyForMigration`
- `RDReaderPreloadController`
- `RDEPUBTextPageRenderView`
### 6.3 具体工作
1. 将普通翻页路径上的章节准备改为异步
- `prepareOnDemandChapter` 不再在 UI 主路径同步等待章节构建
- 允许返回占位页,章节就绪后刷新当前可见内容
2. 将 `extendPartialBookPageMapIfNeeded` 改为后台批量加载
- 不在 `pageNum` 回调中同步循环加载多个章节
- 加载完成后回主线程合并 `bookPageMap`
3. 前移跨章节预热
- 用户接近章节尾页时提前 lookahead 下一章或下两章
- 不等 `pageContentView` 被请求后才开始准备
4. 调整预加载半径与策略
- `RDReaderPreloadController.radius` 不再固定为章节内相邻 1 页思维
- 预加载应感知章节边界,而不只是页号连续性
5. 为 `RDEPUBTextPageRenderView` 引入静态内容缓存
- 避免页面进入或选区变化时重复完整 CoreText 绘制
- 将“静态底图”和“动态选区/交互覆盖”尽量分离
6. 明确 `contentMode = .redraw` 的优化策略
- 不建议简单把 `contentMode` 改成缩放模式来规避重绘
- 建议对静态文本层使用手动位图缓存,必要时再评估 `CATiledLayer`
- 动态选区、高亮交互层应保持独立 overlay避免拖拽选区时重绘整页文本
7. 为主线程阻塞建立监控
- 重点观察跨章节翻页与恢复定位路径
### 6.4 建议修改文件
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderController+DataSource.swift`
- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderRuntime.swift`
- `Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLoader.swift`
- `Sources/RDReaderView/ReaderView/Paging/RDReaderPreloadController.swift`
- `Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextPageRenderView.swift`
- `Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift`
### 6.5 阶段边界
本阶段只解决“同步改异步”和“预热/缓存前移”的问题,不强行做大规模类拆分。
也就是说:
- 可以调整方法签名
- 可以新增轻量状态与回调
- 但不以“四服务化拆分 `RDEPUBChapterLoader`”为本阶段目标
### 6.6 风险点
- 占位页策略若处理不当,可能从“卡顿”变成“短暂空白”
- 异步章节准备如果重复触发,可能导致重复刷新和抖动
- `bookPageMap` 合并时若定位恢复策略不稳定,可能引起页码闪跳
### 6.7 阶段验收
- 普通翻页主路径不再依赖 UI 主线程同步章节加载
- 跨章节翻页体感明显改善
- 主线程阻塞监控下降到可接受水平
- 预加载命中率比整改前提高
- 选区拖拽时不再频繁整页重绘
---
## 7. Phase 1收缩 Context切开环境与状态
### 7.1 目标
把当前 `RDEPUBReaderContext` 从“全能对象”收缩为组合式对象,降低隐藏依赖。
### 7.2 改造结果
整改后至少形成三个对象:
#### `RDEPUBReaderState`
负责纯运行状态:
- `parser`
- `publication`
- `readingSession`
- `textBook`
- `bookPageMap`
- `pendingFullPageMap`
- `activeBookmarks`
- `activeHighlights`
- `searchState`
- `currentSelection`
#### `RDEPUBReaderEnvironment`
负责 UI 与设备环境:
- viewport size
- safeAreaInsets
- traitCollection 抽象
- brightness
- fallbackViewportSize
#### `RDEPUBReaderServices`
负责工厂与外部依赖:
- parser factory
- paginator factory
- text renderer factory
- text builder factory
- persistence
- cache repository factory
### 7.3 具体步骤
1. 新增文件:
- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderState.swift`
- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderEnvironment.swift`
- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderServices.swift`
2. 让 `RDEPUBReaderController` 初始化并持有这三个对象。
3. `RDEPUBReaderContext` 第一阶段暂时保留,但只作为过渡门面。
4. 逐步把这些方法迁出 `Context`
- `currentLayoutContext()`
- `currentTextPageSize()`
- `currentTextRenderStyle()`
- `currentTextLayoutConfig(pageSize:)`
- `makeParser()`
- `makePaginator()`
- `makeTextBookBuilder(...)`
5. 移除 `context.runtime` 这种反向访问。
6. 下层对象改成显式注入自己真正需要的 state/environment/services。
### 7.4 优先改造对象
1. `RDEPUBReaderPaginationCoordinator`
2. `RDEPUBReaderLocationCoordinator`
3. `RDEPUBChapterLoader`
4. `RDEPUBReaderRuntime`
### 7.5 风险点
- 迁移过程中容易出现旧 `context` 和新 `state/environment/services` 双写。
- 必须在阶段末关闭旧入口,否则后续会继续新增对 `context` 的依赖。
### 7.6 阶段验收
- `RDEPUBReaderContext` 不再直接访问 `controller.view`
- `RDEPUBReaderContext` 不再暴露 `runtime`
- 后台线程不再通过 `context` 回主线程同步取分页尺寸
---
## 8. Phase 2拆分 Runtime 与章节加载编排
### 8.1 目标
把当前大而全的 runtime 拆成稳定的领域 facade并把章节加载逻辑从“对象内联编排”改成“服务化编排”。
### 8.2 拆分方向
建议把 `RDEPUBReaderRuntime` 拆为以下 3 类 facade
#### `RDEPUBNavigationRuntime`
负责:
- 恢复阅读位置
- 跳转到页码/目录/高亮/书签
- 章节窗口维护
- jump session
#### `RDEPUBPresentationRuntime`
负责:
- 分页
- `bookPageMap` / `pendingFullPageMap`
- viewport monitor
- settings preview
- 当前快照替换
#### `RDEPUBAnnotationRuntime`
负责:
- 高亮
- 批注
- 书签
- 当前选区
- 搜索结果定位联动
### 8.3 章节加载服务化
当前 `RDEPUBChapterLoader` 既做缓存命中、章节构建、磁盘写回、延迟 CFI、主线程回调。Phase 2 不建议一开始就强制拆成 4 个服务,而是建议在 Phase 0.5 完成异步化后,根据残余复杂度做渐进式收口。
优先建议的落地方式是:
- 保留 `RDEPUBChapterLoader` 作为过渡门面
- 先抽出稳定边界最清晰的构建与缓存职责
- 把预热、lookahead、延迟 CFI、优先级调度收口到一个协调对象而不是立即拆成多个小服务
第一轮更合适的职责拆分可以是:
#### `RDEPUBChapterBuildService`
- 输入spineIndex + render/layout context
- 输出:`RDEPUBRuntimeChapter`
- 不关心 UI、不关心回调、不关心磁盘缓存
#### `RDEPUBChapterCacheRepository`
- 管理内存缓存与磁盘摘要缓存
- 提供统一读写接口
#### `RDEPUBChapterWarmupOrchestrator`
- 负责预热章节
- 负责延迟 CFI 构建
- 负责边界 lookahead 预取
- 负责导航优先级、预取优先级、取消与串行化策略
如果后续复杂度继续上升,再考虑把 `WarmupOrchestrator` 继续拆成更细的 service但这不应作为当前阶段的先决交付物。
### 8.4 具体步骤
1. 新增 facade 文件与必要的 service 文件。
2. 把 `RDEPUBReaderRuntime` 中的公共 API 先转发到新 facade。
3. 再把具体逻辑迁走。
4. `RDEPUBChapterLoader` 先收缩为过渡门面,内部优先委派到 build/cache/warmup orchestration。
5. `RDEPUBReaderRuntime` 最终只保留一个薄门面,负责兼容旧调用。
### 8.5 风险点
- facade 与旧 runtime 并存期较长,容易出现调用路径重复。
- 如果服务拆分粒度过细,可能先增加调用跳转和维护成本,收益却不明显。
- 章节加载优先级与取消策略若迁移不完整,可能引入新的页面空白或重复构建。
### 8.6 阶段验收
- `RDEPUBReaderRuntime.swift` 行数明显下降
- 章节加载主逻辑不再集中在单文件
- `RDEPUBReaderRuntime` 不再直接操作章节缓存细节
---
## 9. Phase 3统一分页状态模型与导航状态机
### 9.1 目标
统一分页窗口模型,并把关键导航链路从“多个 bool/pending 值”升级为显式状态机。
### 9.2 要解决的问题
当前并存:
- `bookPageMap`
- `pendingFullPageMap`
- `chapter window snapshot`
- `readingSession.activePages`
这些对象都在表达“用户当前能看到什么”,只是层次不同,导致 takeover 和刷新规则分散。
### 9.3 目标模型
建议建立统一的分页状态对象,例如:
```swift
struct RDEPUBPaginationState {
var activeWindow: RDEPUBPageWindow
var candidateFullMap: RDEPUBBookPageMap?
var chapterWindowSnapshot: RDEPUBChapterWindowSnapshot?
var source: Source
}
```
配套引入状态机:
```swift
enum RDEPUBNavigationState {
case idle
case initialLoading
case restoringLocation
case preparingChapter(spineIndex: Int)
case presentingWindow
case reconcilingFullMap
case repaginating
}
```
### 9.4 具体步骤
1. 新增 `RDEPUBPaginationState.swift`
2. 新增 `RDEPUBNavigationStateMachine.swift`
3. 把以下逻辑统一收口:
- `applyBookPageMap`
- `refreshBookPageMapInPlace`
- `applyPendingFullPageMapIfNeeded`
- `extendPartialBookPageMapIfNeeded`
- jump session 覆盖判断
4. 所有页码替换、窗口扩展、完整 map takeover 都必须经过统一 evaluator。
5. 把当前 scattered bool 收敛:
- `isRepaginating`
- `didStartInitialLoad`
- `isSettingsPanelOpen`
- `needsFullRepaginationAfterSettingsClose`
### 9.5 风险点
- 这是最容易影响“当前页恢复”和“目录跳转”的阶段。
- 必须先保留旧日志与旧行为兜底,再切换状态机入口。
### 9.6 阶段验收
- 翻页、远跳、恢复位置、设置变更后 repagination 都走统一状态流
- 不再出现多个对象各自判断“是否该替换当前窗口”
- `bookPageMap` 与 snapshot 的来源关系更清晰
---
## 10. Phase 4收敛 ReaderView 抽象与模块边界
### 10.1 目标
`RDReaderView` 重新成为“通用阅读分页容器”,而不是业务与兼容逻辑混合层。
### 10.2 具体方向
#### 统一 Provider 协议
对外保留:
- `RDReaderPageProvider`
逐步废弃:
- `RDReaderDataSource`
- `RDReaderLegacyDataSourceAdapter`
#### 拆分 ReaderView 角色
建议拆成以下内部组件:
- `RDReaderPagingSurface`
- `RDReaderChromeHost`
- `RDReaderInteractionRouter`
- `RDReaderPreloadManager`
#### 收紧模块依赖
需要形成明确约束:
- `EPUBCore` 不依赖 `EPUBUI`
- `EPUBTextRendering` 不依赖 `UIViewController`
- `ReaderView` 不依赖 `RDEPUBReaderController`
- `TextPage` 只依赖页面模型与交互协议,不直接访问 controller/runtime
### 10.3 具体步骤
1. 先把 `RDEPUBReaderController` 改成只通过 `RDReaderPageProvider` 对接容器。
2. 给旧 `RDReaderDataSource` 增加 deprecate 注释。
3. 把 tool view、tap routing、单双页布局决策进一步下沉到 ReaderView 内部组件。
4. 整理 `ReaderView` 对业务对象的隐式假设。
### 10.4 风险点
- 这是 API 层整改,最容易影响 SDK 使用方。
- 如果已有外部方直接实现 `RDReaderDataSource`,需要提供过渡期。
### 10.5 阶段验收
- `RDEPUBReaderController``RDReaderView` 之间只通过 page provider 交互
- `RDReaderLegacyDataSourceAdapter` 不再是主路径依赖
- ReaderView 层可以被描述为“业务无关的分页容器”
---
## 11. 配套横向任务
这些任务建议穿插在各阶段中进行:
### 11.1 建立缓存协议层
建议新增:
- `RDEPUBPageRenderCacheKey`
- `RDEPUBPageRenderCacheStore`
- `RDEPUBRenderInvalidationPolicy`
目标:
- 统一字体/主题/尺寸/highlight/search 的缓存失效规则
- 让底图缓存不再散落在 view 内部
### 11.2 建立定位能力层
建议新增:
- `RDEPUBTextAnchorService`
- `RDEPUBSelectionLocationService`
- `RDEPUBSearchAnchorService`
目标:
- 把 CFI、rangeAnchor、fragmentOffset 相关逻辑从 UI 与 text rendering 之间抽离
### 11.3 建立仓储接口
建议新增协议:
- `RDEPUBChapterSummaryRepository`
- `RDEPUBPageCountRepository`
- `RDEPUBAnnotationRepository`
目标:
- 解耦 loader 与具体缓存实现
- 便于做测试与未来存储替换
---
## 12. 建议执行顺序
建议按以下顺序创建实施任务:
1. `phase-0-baseline-and-guardrails`
2. `phase-0-5-remove-main-thread-sync-loading`
3. `phase-1-context-split`
4. `phase-2-runtime-and-chapter-orchestration-split`
5. `phase-3-pagination-state-unification`
6. `phase-4-reader-view-abstraction-cleanup`
7. `horizontal-cache-and-anchor-services`
原因:
- Phase 0.5 解决最强用户痛点,且不依赖 `Context` 拆分。
- Phase 1 是中期结构整改前置条件,但不是性能解阻塞前置条件。
- Phase 2 如果先做,会继续依赖旧 context。
- Phase 3 必须建立在 runtime 拆分之后,否则状态机会继续长进 runtime。
- Phase 4 应该最后做,避免 UI 容器抽象在业务状态还不稳定时反复返工。
---
## 13. 每阶段交付物清单
### Phase 0
- 基线文档
- smoke checklist
- 日志观测点
### Phase 0.5
- 异步化的 `prepareOnDemandChapter`
- 异步化的 `extendPartialBookPageMapIfNeeded`
- 跨章节预热逻辑
- 预加载半径与章节边界感知策略调整
- `RDEPUBTextPageRenderView` 静态内容缓存
- 主线程阻塞监控数据与整改前后对比
### Phase 1
- `RDEPUBReaderState`
- `RDEPUBReaderEnvironment`
- `RDEPUBReaderServices`
- 过渡版 `RDEPUBReaderContext`
### Phase 2
- runtime facade 拆分
- 渐进式的 chapter build/cache/warmup orchestration 收口
- 过渡版 `RDEPUBChapterLoader` 门面
- 旧 runtime 兼容门面
### Phase 3
- `RDEPUBPaginationState`
- `RDEPUBNavigationStateMachine`
- 统一 takeover/reconciliation 入口
### Phase 4
- `RDReaderPageProvider` 成为唯一主协议
- ReaderView 组件化
- 模块依赖约束文档
---
## 14. 验收口径
整改完成后,至少满足以下口径:
1. 普通翻页、跨章节翻页、目录远跳、恢复位置不再依赖 UI 主线程同步加载章节。
2. 下层服务不再通过 `context` 反向访问 `controller``runtime`
3. `RDEPUBReaderRuntime` 不再是主要业务实现载体,而是兼容门面。
4. 分页状态替换与窗口扩展有单一入口。
5. `RDReaderView` 可以独立描述为容器层,不持有明显业务规则。
---
## 15. 不建议立即做的事
以下事项不建议在第一轮整改中做:
1. 全量改名或移动全部文件目录。
2. 直接重写分页系统。
3. 直接废弃现有 `readingSession`
4. 一次性删除所有旧 API。
5. 在没有回归基线前同时推进多阶段大改。
这些操作返工风险过高,不适合当前代码体量。
---
## 16. 建议下一步
建议立即启动的实际工作是:
1. 将本路线图拆成 6 个 phase 文档或 issue。
2. 先执行 Phase 0。
3. 紧接着执行 Phase 0.5,优先交付用户可感知的翻页流畅度改进。
4. Phase 1 只做 `Context` 拆分,不夹带 ReaderView 或分页状态重构。
5. 每完成一个 phase就更新 `Doc/ARCHITECTURE.md`
如果需要继续推进,下一份建议产物是:
- `Doc/PhasePlan/phase-0-5-remove-main-thread-sync-loading.md`
它将把 Phase 0.5 再拆成更细的文件级改动清单、迁移顺序和验收步骤。

View File

@ -441,10 +441,20 @@ classDiagram
class RDEPUBReaderPaginationCoordinator {
-context: RDEPUBReaderContext
+paginatePublication(restoreLocation:)
+paginateMetadataOnly(token:restoreLocation:)
+repaginatePreservingCurrentLocation()
-restoreBookPageMapIfPossible(publication:) RDEPUBBookPageMap?
-buildPageMap(from:summaries:) RDEPUBBookPageMap
}
class RDEPUBMetadataParseWorker {
-context: RDEPUBReaderContext
-cancellationController: RDEPUBMetadataParseCancellationController
+start(token:restoreLocation:)
}
class RDEPUBMetadataParseCancellationController {
+token: UUID
+attach(queue:)
+cancel()
+isCancelled: Bool
}
class RDEPUBReaderLoadCoordinator {
@ -660,7 +670,7 @@ sequenceDiagram
participant OQ as OperationQueue (N workers)
participant Disk as Disk Cache
Main->>BG: paginateMetadataOnly(token)
Main->>BG: RDEPUBMetadataParseWorker.start(token)
BG->>BG: 预计算 contentHash (串行)
BG->>Disk: readAll(catalog) 批量读取缓存
BG->>Main: refreshBookPageMapInPlace (缓存部分)

View File

@ -28,7 +28,6 @@
| [CONCERNS.md](CONCERNS.md) | 代码库风险与关注点12 项安全/性能/可维护性风险 |
| [TESTING.md](TESTING.md) | 测试基础设施UI 测试文件清单、运行方式、覆盖率 |
| [CONVENTIONS.md](CONVENTIONS.md) | 编码规范:命名约定、代码风格、导入规范、错误处理 |
| [ARCHITECTURE-CONTEXT.md](ARCHITECTURE-CONTEXT.md) | WXRead vs ReadViewSDK 架构差距分析4 个领域、14 项决策 |
## 专题文档
@ -47,7 +46,7 @@
## 项目信息
- **模块总数:** 4 个EPUBCore、EPUBTextRendering、RDReaderView、EPUBUI
- **Swift 文件数:** 166SDK Sources
- **Swift 文件数:** 142SDK Sources
- **测试用例数:** 23 个测试类,约 99 个测试方法UI 测试)
- **最低 iOS 版本:** 15.6
- **构建方式:** CocoaPods本地 pod

View File

@ -1,110 +0,0 @@
# readoor vs ReadViewSDK 功能差距分析
> 最后更新2026-06-18
---
## 概述
本文档对比 readoor`BookView/EPUB/`EPUB 阅读器与 ReadViewSDK 的功能差异,列出 readoor 已实现但 ReadViewSDK **尚未实现**的功能,以及实现方式存在差异的功能。
---
## 未实现的功能
### 1. 字体下载
**readoor 状态:** 已实现。`RBCoreEpubFontDownloadViewController` + `RBCoreEpubFontDownloadHandler` 提供字体下载管理,支持用户从服务端下载自定义字体并通过 `@font-face` 注入阅读器。
**ReadViewSDK 状态:** 未实现。`RDEPUBFontNormalizer` 仅负责注册 EPUB 内嵌字体(`CTFontManagerRegisterFontsForURL`)和系统字体回退,无字体下载/商店功能。
**影响:** 用户只能使用系统字体和 EPUB 自带字体,无法扩展字体选择。
---
### 2. 服务器同步笔记/书签
**readoor 状态:** 已实现。`pullEpubNoteInfo`/`pushEpubNoteInfo` 通过 API 同步笔记到云端,支持多设备数据一致性。
**ReadViewSDK 状态:** 未实现。`RDEPUBReaderDelegate` 提供 `didUpdateHighlights`、`didUpdateBookmarks` 等回调,但仅为本地通知,无网络请求或远程同步逻辑。笔记/书签数据完全在本地管理。
**影响:** 用户换设备后阅读数据(书签、高亮、笔记)会丢失。
---
### 3. 加密 EPUB 解密
**readoor 状态:** 已实现。`STSRDFileManagerStorage readToFileDeCrypt:password:` 基于 bookID 拼接密码,对所有 EPUB 资源HTML、CSS、图片进行解密加载。
**ReadViewSDK 状态:** 未实现。`RDEPUBParser` 直接解析标准 EPUB 归档,无加解密层。
**影响:** 无法打开加密的 EPUB 文件。
---
### 4. 试读/购买权限控制
**readoor 状态:** 已实现。支持三种权限模式:免费试读、登录后免费、购买后阅读。通过 API 返回的权限字段控制章节可读性。
**ReadViewSDK 状态:** 未实现。无试读限制、付费墙或章节权限控制逻辑。
**影响:** SDK 无商业化能力,无法限制用户只阅读已购买的章节。
---
### 5. 阅读统计上报
**readoor 状态:** 已实现。`RDStatisticsManager` 上报阅读进度sectionNo + 页面内进度),集成埋点系统。
**ReadViewSDK 状态:** 未实现。JS 端 `reportProgression()` 仅将进度通过 `webkit.messageHandlers` 传递给原生端,属于本地 bridge 通信,无远程上报。
**影响:** 无法收集用户阅读行为数据用于运营分析。
---
### 6. 图片点击放大
**readoor 状态:** 已实现。点击 EPUB 内图片发送通知,弹出全屏图片查看(`showImageView`)。
**ReadViewSDK 状态:** 未实现。JS 端 `handleDocumentClick``epub-bridge.js:388-400`)仅处理链接点击,不处理图片点击事件。
**影响:** 用户无法放大查看 EPUB 中的小图片或细节图。
---
## 实现方式存在差异的功能
### 7. 亮度调节
**readoor 方式:** 通过黑色半透明 CALayer overlay 暗化屏幕,不改变系统亮度。
**ReadViewSDK 方式:** 直接设置 `UIScreen.main.brightness`(系统屏幕亮度 API。已实现但会影响系统全局亮度。
---
### 8. 搜索结果上下文
**readoor 方式:** 搜索结果显示匹配文本前后各 30 字符上下文。
**ReadViewSDK 方式:** `previewRadius` 为 12 字符(`RDEPUBReaderController+DataSource.swift:196-201`),上下文较短。
---
### 9. CSS 主题切换机制
**readoor 方式:** 通过 JS `classList.toggle("mycss")` 切换 CSS class 实现主题切换。
**ReadViewSDK 方式:** 通过 `cssInjector.js``window.RDInjectedCSS.setStyle(identifier, cssText, options)` 直接操作 style 标签内容。功能等价,但机制不同。
---
## 总结
| # | 功能 | 优先级 | 复杂度 |
|---|------|--------|--------|
| 1 | 字体下载 | 中 | 中(需后端接口 + 下载管理) |
| 2 | 服务器同步 | 高 | 高(需 API 设计 + 冲突解决) |
| 3 | 加密 EPUB | 高 | 中(需对接加密方案) |
| 4 | 试读/购买权限 | 高 | 低SDK 层暴露权限接口即可) |
| 5 | 阅读统计上报 | 中 | 低delegate 暴露事件即可) |
| 6 | 图片点击放大 | 中 | 低JS 端监听图片点击 + 原生展示) |

View File

@ -1,757 +0,0 @@
# 当前阅读器问题修复开发清单
> 最后更新2026-06-13
> 依据说明:本清单仅基于当前仓库代码实现整理,不以现有说明文档是否准确为前提。
---
## 1. 文档目标
本文将“当前阅读器还存在的问题”展开为可执行的逐任务开发清单,目标是把工作从“方向建议”落到“具体改哪里、怎么改、如何验收”。
本文聚焦以下四类问题:
1. 核心交互链路不稳定:选区、高亮、书签、工具栏状态分散。
2. 默认安全策略偏弱:外链、解压、日志、持久化默认行为需要收口。
3. 资源与分页稳定性不足:大资源内存峰值、离屏分页 WebView 边界控制不足。
4. SDK 契约与回归手段不足:默认 no-op 容易误用,自动化观测点不够结构化。
---
## 2. 总体实施顺序
建议按以下顺序执行,减少返工:
1. `bookmark-chrome-state-unification`
2. `selection-state-refactor`
3. `external-link-policy-hardening`
4. `epub-archive-path-validation`
5. `webview-debug-sanitization`
6. `persistence-defaults-hardening`
7. `resource-scheme-streaming-and-limits`
8. `pagination-webview-hardening`
9. `cache-hardening`
10. `sdk-contract-and-test-hardening`
原因:
- 任务 2 是已确认的重复 UI 状态写入问题,收益最高、风险相对低,应优先处理。
- 任务 1 更偏“状态模型收口优化”,问题真实存在,但严重度低于任务 2。
- 第 3-6 项收口默认安全行为,改动局部、收益高。
- 第 7-9 项涉及资源与分页底层,副作用更大,放在前面稳定后更容易验证。
- 最后一项负责把前面改动固化为稳定契约和测试基线。
---
## 3. 任务清单
## 任务 1`selection-state-refactor`
### 3.1 目标
在不破坏当前清晰分层的前提下,收口选区相关状态的冗余表达,降低 view 层与 controller 层各持有一份选区状态所带来的维护成本和时序问题。
### 3.2 主要问题代码
- `Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextSelectionController.swift`
- `Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift`
- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderAnnotationCoordinator.swift`
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift`
### 3.3 修改方案
当前代码并不是“混乱散写”,而是相对清晰的分层流:
- gesture
- view
- coordinator
- context/controller
其中真正值得优化的点主要有两个:
1. view 层和 controller 层各自持有一份 `currentSelection`
2. `menuSelection` 是为 `UIMenuController` 时序保底引入的 workaround属于合理存在但可以尝试被更明确的状态模型替代
新增一个统一的选区状态模型仍然是可行方案,例如:
```swift
enum RDEPUBSelectionState: Equatable {
case idle
case selecting(anchor: Int)
case selected(RDEPUBSelection)
case committingAction(RDEPUBSelection, action: RDEPUBAnnotationMenuAction)
}
```
建议新增文件:
- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBSelectionState.swift`
建议在 `RDEPUBReaderAnnotationCoordinator` 内新增或抽出一个单一入口:
- `applySelectionState(_ state: RDEPUBSelectionState)`
- `currentSelectionState`
具体修改:
1. `RDEPUBTextSelectionController`
- 移除“自己就是最终状态源”的职责。
- 保留字符范围计算与 `RDEPUBSelection?` 构建。
- `isSelecting` 可保留为手势内部临时状态,但不再作为 UI 的最终依据。
2. `RDEPUBTextContentView`
- 不再直接通过 `currentSelection != nil` 作为所有 UI 行为的唯一判断条件。
- 长按、拖拽、结束只上报“开始选择 / 更新选择 / 结束选择 / 取消选择”事件。
- `menuSelection` 不建议直接删除。
- 第一版应先保留 `menuSelection`,并把它明确标注为菜单时序缓冲状态。
- 待状态模型稳定后,再评估是否能安全移除。
3. `RDEPUBReaderAnnotationCoordinator.updateCurrentSelection(_:)`
- 改造成 `applySelectionState(_:)`
- 在 `.selected` 时统一:
- 更新 `controller.currentSelection`
- 决定是否展示工具栏
- 更新底部高亮按钮可用性
- 通知 delegate
- 在 `.idle` 时统一清理:
- 清空 `controller.currentSelection`
- 关闭菜单
- 刷新按钮状态
4. `RDEPUBReaderController`
- `currentSelection` 继续保留为公开只读语义,但底层来源改为 `selectionState` 推导,而不是多处直接赋值。
### 3.4 实施步骤
1. 新增 `RDEPUBSelectionState.swift`
2. 在 `RDEPUBReaderAnnotationCoordinator` 中接管选区状态
3. 重写 `RDEPUBTextContentView` 中选区相关回调
4. 先收口零散的 `currentSelection != nil` 控制逻辑
5. 保留 `menuSelection`,待验证不再需要后再移除
6. 统一 delegate 通知路径
### 3.5 风险点
- 选区菜单弹出时机可能变化。
- `menuSelection` 是现有时序补丁,若贸然删除,菜单动作可能拿不到正确 selection。
- Web 路径和 Native Text 路径都要走统一状态流,避免只修一条。
### 3.6 验收标准
- 长按后必然进入“选区中”状态。
- 松手后有有效文本时必然进入“已选中”状态。
- 复制/高亮/批注后必然回到空闲状态。
- `selection=1` 的 Demo 状态与按钮启用状态始终一致。
### 3.7 优先级修正
本任务应视为“结构优化 + 降低后续维护成本”,不是当前代码库里最严重的缺陷。建议优先级下调到与任务 2 同级,排在任务 2 之后实施。
---
## 任务 2`bookmark-chrome-state-unification`
### 3.8 目标
统一顶部书签按钮、底部书签按钮、高亮按钮、添加高亮按钮的可用状态计算,避免不同协调器各自改一部分 UI。
### 3.9 主要问题代码
- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderChromeCoordinator.swift`
- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderAnnotationCoordinator.swift`
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderTopToolView.swift`
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderBottomToolView.swift`
### 3.10 修改方案
新增统一 UI 状态模型:
```swift
struct RDEPUBReaderUIState {
let canToggleBookmark: Bool
let hasBookmarkAtCurrentLocation: Bool
let canShowBookmarks: Bool
let canAddHighlight: Bool
let canShowHighlights: Bool
}
```
建议新增文件:
- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderUIState.swift`
具体修改:
1. `RDEPUBReaderChromeCoordinator`
- 新增 `makeUIState()``applyUIState(_:)`
- `updateReaderChrome()` 内不再直接散写多个 `setXxxEnabled`
2. `RDEPUBReaderAnnotationCoordinator`
- `updateBookmarkChrome()` 不再直接写 view改为触发 `context.updateReaderChrome()`
3. `RDEPUBReaderTopToolView` / `RDEPUBReaderBottomToolView`
- 保持 dumb view只接收状态不参与规则判断
### 3.11 实施步骤
1. 新增 `RDEPUBReaderUIState.swift`
2. 改造 `updateReaderChrome()`
3. 不要简单删除 `AnnotationCoordinator` 内的按钮状态刷新
4. 区分两类触发时机:
- `ChromeCoordinator` 负责整页/整轮 chrome 同步
- `AnnotationCoordinator` 负责用户操作后的即时反馈触发
5. 将二者统一收口到同一个状态计算入口,例如 `context.updateReaderChrome()`
6. 调整书签新增/删除/跳转后的刷新入口为统一 `updateReaderChrome()`
### 3.12 风险点
- 如果简单删除 `AnnotationCoordinator` 内的调用,可能丢失“用户操作后立即反馈”的刷新时机。
- 如果某些按钮当前依赖副作用显示,需要顺便梳理显示时机。
### 3.13 验收标准
- 添加第一个书签后,底部书签列表按钮立即可用。
- 删除最后一个书签后,底部书签列表按钮立即禁用。
- 当前位置已加书签时,顶部书签按钮始终是选中态。
---
## 任务 3`external-link-policy-hardening`
### 3.14 目标
把“外链点击后直接打开系统应用”改成受控策略,避免 SDK 默认行为过于激进。
### 3.15 主要问题代码
- `Sources/RDReaderView/EPUBCore/RDEPUBWebView+JavaScriptBridge.swift`
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift`
- `Sources/RDReaderView/EPUBUI/Settings/RDEPUBReaderConfiguration.swift`
### 3.16 修改方案
`RDEPUBReaderConfiguration` 新增:
- `allowedExternalURLSchemes: Set<String>`
- `requiresExternalLinkConfirmation: Bool`
- 可选:`allowedExternalURLHosts: Set<String>?`
默认值建议:
- scheme 仅允许 `https`
- 需要确认框
具体修改:
1. `RDEPUBWebView+JavaScriptBridge.swift`
- 继续识别外链,但不再表示“允许直接打开”。
- 只负责把外链事件上抛。
2. `RDEPUBReaderController+ContentDelegates.swift`
- 新增私有方法:
- `shouldAllowExternalURL(_:)`
- `presentExternalLinkConfirmation(for:)`
- `openExternalURLIfAllowed(_:)`
- `didActivateExternalLink` 改为:
- 先 delegate 回调
- 再走配置校验
- 再弹确认框
- 最后调用 `UIApplication.shared.open`
3. 可选扩展 `RDEPUBReaderDelegate`
- 增加可选拦截钩子:
- `epubReader(_:shouldOpenExternalURL:) -> Bool`
### 3.17 实施步骤
1. 改配置模型与默认值
2. 改控制器外链处理逻辑
3. 增加确认对话框
4. 增加测试用 mock URL 覆盖 `https`、`mailto`、未知 scheme`
### 3.18 风险点
- 某些 Demo 书可能依赖 `mailto``tel`,要明确它们现在默认不再直接打开。
### 3.19 验收标准
- `https` 外链在确认后打开。
- 默认配置下 `mailto``tel` 不直接打开。
- 未知 scheme 一律拒绝。
---
## 任务 4`epub-archive-path-validation`
### 3.20 目标
为 EPUB 解压路径增加显式安全校验,防止恶意压缩包通过相对路径写出解压根目录。
### 3.21 主要问题代码
- `Sources/RDReaderView/EPUBCore/RDEPUBParser+Archive.swift`
### 3.22 修改方案
新增私有方法:
- `validatedExtractionDestination(for:extractionRoot:) -> URL?`
校验规则:
1. 拒绝绝对路径。
2. 拒绝包含 `..` 的路径段。
3. `standardizedFileURL.path` 必须位于 `extractionRoot.standardizedFileURL.path` 下。
具体修改:
1. `extractArchiveIfNeeded(epubURL:)`
- 在循环内先调用 `validatedExtractionDestination`
- 无效 entry 可:
- 直接抛错终止解析,或
- 记日志并跳过
- 建议优先抛错,行为更清晰
2. 增加自定义错误:
- 若当前 `RDEPUBParserError` 里没有合适 case可新增 `invalidArchiveEntryPath(String)`
### 3.23 实施步骤
1. 增加路径校验方法
2. 替换原始 `destinationURL` 生成逻辑
3. 增加针对恶意 entry path 的单元测试
### 3.24 风险点
- 少数历史 EPUB 可能带奇怪路径分隔符,需要兼容性验证。
### 3.25 验收标准
- 正常 EPUB 仍可正常解压。
- 带 `../` 的恶意 entry 会被拒绝。
---
## 任务 5`webview-debug-sanitization`
### 3.26 目标
减少默认调试日志泄露阅读内容的风险,并关闭不必要的 inspectable 默认值。
### 3.27 主要问题代码
- `Sources/RDReaderView/EPUBCore/RDEPUBWebViewDebug.swift`
- `Sources/RDReaderView/EPUBCore/RDEPUBPaginator.swift`
- `Sources/RDReaderView/EPUBCore/RDEPUBWebView+Configuration.swift`
### 3.28 修改方案
1. `RDEPUBWebViewDebug`
- `logMessage` 改为只打印:
- message name
- 字段名列表
- 文本长度
- URL 摘要
- 不再打印完整 message body
2. 新增更细粒度配置
- `RDEPUBWebViewDebugEnabled`
- `RDEPUBVerboseWebViewDebugEnabled`
3. `RDEPUBPaginator` 与正常阅读 WebView
- `isInspectable` 改为受配置控制
- 默认关闭
4. `RDEPUBReaderConfiguration`
- 增加:
- `allowsInspectableWebViews`
- `enablesVerboseWebViewLogging`
### 3.29 实施步骤
1. 改日志输出格式
2. 改 inspectable 的默认逻辑
3. 为 verbose 模式保留调试后门
### 3.30 风险点
- 调试某些 JS 问题时信息会变少,因此要保留显式 verbose 开关。
### 3.31 验收标准
- 默认 DEBUG 包不输出完整选中文本。
- 默认分页 WebView 不可 inspect。
- 打开 verbose 开关后仍能深度调试。
---
## 任务 6`persistence-defaults-hardening`
### 3.32 目标
降低默认 `UserDefaults` 持久化的误用风险和数据膨胀风险,同时不破坏当前 API 可用性。
### 3.33 主要问题代码
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderPersistence.swift`
- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderAnnotationCoordinator.swift`
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift`
### 3.34 修改方案
短期目标:
1. 明确 `RDEPUBUserDefaultsPersistence` 是轻量实现。
2. 高亮持久化优先依赖:
- `location`
- `rangeInfo`
- `style`
- `note`
3. `text` 字段作为冗余缓存,而不是唯一恢复依据。
中期可选目标:
新增文件持久化实现:
- `Sources/RDReaderView/EPUBUI/RDEPUBFilePersistence.swift`
### 3.35 具体修改
1. `RDEPUBReaderPersistence.swift`
- 为默认实现增加 DEBUG 警告,提示 bookmarks/settings 默认实现是 no-op
2. `RDEPUBUserDefaultsPersistence`
- 增加大小保护,例如当高亮集合序列化后超阈值时打印告警
- 可选增加版本字段,便于后续迁移
3. `RDEPUBReaderController`
- 初始化默认 persistence 时,在注释与命名上明确这是默认轻量实现
### 3.36 实施步骤
1. 增加 warning/assert 机制
2. 优化高亮持久化字段策略
3. 视时间增加 `RDEPUBFilePersistence`
### 3.37 风险点
- 如果外部代码依赖 `text` 永远存在,需要做好兼容。
### 3.38 验收标准
- 未实现书签持久化时DEBUG 下能看到明确提示。
- 高亮即使 `text` 丢失,也可基于范围信息恢复定位。
---
## 任务 7`resource-scheme-streaming-and-limits`
### 3.39 目标
降低 `WKURLSchemeHandler` 对大资源的内存冲击,避免每次都整块 `Data(contentsOf:)` 读入。
### 3.40 主要问题代码
- `Sources/RDReaderView/EPUBCore/RDEPUBResourceURLSchemeHandler.swift`
### 3.41 修改方案
分两步做:
第一步,先加大小阈值和告警:
- 读取文件属性获取大小
- 超过阈值时走分块发送
第二步,再补流式发送:
- 使用 `FileHandle``InputStream`
- 按固定 chunk size 反复 `didReceive(data)`
建议新增私有方法:
- `resourceMetadata(for:)`
- `respondWithStreaming(fileURL:requestURL:taskID:urlSchemeTask:)`
- `respondWithInMemoryData(...)`
### 3.42 实施步骤
1. 先加文件大小检测
2. 小文件保留内存直读
3. 大文件切换到分块发送
4. 增加取消任务时的中断判断
### 3.43 风险点
- `WKURLSchemeHandler` 分块发送要注意 stop 之后不继续回调。
### 3.44 验收标准
- 大图片/字体加载时内存峰值下降。
- 取消任务不会继续发送数据。
---
## 任务 8`pagination-webview-hardening`
### 3.45 目标
加强离屏分页 WebView 的边界控制,减少外部资源波动和调试副作用。
### 3.46 主要问题代码
- `Sources/RDReaderView/EPUBCore/RDEPUBPaginator.swift`
### 3.47 修改方案
1. 为分页 WebView 增加严格导航限制
- 仅允许 `file://`
- 仅允许 `ss-reader://`
- 拒绝 `http/https`
2. 将 `isInspectable` 受配置控制
3. 增加测量稳定性日志
- 记录三轮测量结果
- 当三轮差异过大时告警
建议新增私有方法:
- `shouldAllowPaginatorNavigation(url:)`
- `recordMeasurement(pass:value:)`
### 3.48 实施步骤
1. 扩展 `WKNavigationDelegate`
2. 加入导航 allowlist
3. 记录多轮测量差异
### 3.49 风险点
- 某些 EPUB 样式若依赖外链资源,分页结果可能与当前行为不同,但这本身就是更安全的策略。
### 3.50 验收标准
- 分页 WebView 不发起外部导航。
- 三轮测量波动可观测。
---
## 任务 9`cache-hardening`
### 3.51 目标
提升章节摘要磁盘缓存的健壮性、可观测性和可清理能力。
### 3.52 主要问题代码
- `Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterSummaryDiskCache.swift`
### 3.53 修改方案
1. 原子写入
- 先写 `.tmp`
- 再 replace 到正式文件
2. 读失败分类
- 文件不存在
- 读取失败
- 解码失败
3. 精细化清理
- `removeAll()`
- `removeAll(forBookID:)`
- `removeAll(forRenderSignature:)`
4. 增加统计接口
- 缓存文件数
- 总大小
### 3.54 实施步骤
1. 改写入策略
2. 增加读失败日志
3. 增加清理接口
4. 补测试覆盖损坏文件、半写文件
### 3.55 风险点
- 清理策略接口若公开,需要评估是否作为 public API。
### 3.56 验收标准
- 缓存文件损坏不会影响整本书重新打开。
- 能按需清理缓存,而不是只能全删。
---
## 任务 10`sdk-contract-and-test-hardening`
### 3.57 目标
把前面所有修复固化为更清晰的 SDK 契约和更稳定的测试观测点。
### 3.58 主要问题代码
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderPersistence.swift`
- `Sources/RDReaderView/EPUBUI/RDURLReaderController.swift`
- `ReadViewDemo/ReadViewDemoUITests/`
### 3.59 修改方案
1. 强化 persistence 契约
- 继续保留默认实现,但在 DEBUG 下对 no-op 行为给出明确信号
2. Demo 状态能力修正
- 当前仓库并非完全没有结构化状态。
- `ReadViewDemo/ReadViewDemoUITests/Helpers/DemoReaderState.swift` 已经提供了状态解析器和 `waitForDemoReaderState(...)` 结构化等待能力。
- 真正需要推进的不是再造一个 `DemoReaderSnapshot`,而是把仍然大量存在的 `waitForReaderState(containing:)` 迁移到结构化断言。
建议方向:
- 继续保留隐藏 label 输出
- 保持 `key=value` 格式
- 统一测试侧只通过 `DemoReaderState` 解析和断言
- 逐步淘汰 `containing:` 子串匹配,避免 `highlights=1` 命中 `highlights=10` 之类误判
3. 补测试
- 单元测试:
- `RDEPUBParserArchiveSafetyTests`
- `RDEPUBExternalLinkPolicyTests`
- `RDEPUBChapterSummaryDiskCacheTests`
- UI 测试:
- 选区状态流
- 书签启用/禁用
- 外链确认框
### 3.60 实施步骤
1. 改 Demo 状态串生成逻辑
2. 优先将 `waitForReaderState(containing:)` 迁移为 `waitForDemoReaderState(...)`
3. 调整 UI 测试断言
4. 增加单元测试 target 或现有测试目录内的新文件
### 3.61 风险点
- 现有 UI 测试如果强依赖旧状态串,需要同步迁移。
### 3.62 验收标准
- UI 测试失败时能明确知道是选区状态、书签状态还是导航状态失配。
- persistence 的默认 no-op 行为在开发期不再“静默”。
---
## 4. 补充清理项
以下问题已在代码中可见,但未纳入前 10 个主任务,建议作为补充任务或穿插清理项处理。
### 4.1 `pageBreakBefore/pageBreakAfter` 相关死代码审计
涉及代码:
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift`
- `Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBSemanticMarkerInjector.swift`
- `Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBPageBreakPolicy.swift`
- `Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBChapterPageCounter.swift`
建议动作:
- 审计 `pageBreakBefore/pageBreakAfter` 从注入到消费的完整链路
- 判断是否有“已定义、已注入、但实际无有效触发”的死路径
- 若确认无效,删除或补测试锁定其真实行为
### 4.2 `PAGINATION-DEBUG` 输出清理
涉及代码:
- `Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBCoreTextPageFrameFactory.swift`
- `Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBChapterPageCounter.swift`
建议动作:
- 将裸 `print` 改为受控调试开关
- 默认关闭
- 避免分页细节和正文片段直接进入控制台
### 4.3 `ChapterRuntime` 旧代码与遗留路径清理
建议动作:
- 梳理 `ChapterRuntime` 下是否存在已不再被主流程使用的辅助类型或过渡实现
- 对“仍被引用但语义已经过时”的代码先补注释标记
- 对“完全无调用”的代码建立删除候选清单
---
## 5. 推荐里程碑切分
### 里程碑 A核心交互稳定
包含任务:
- `selection-state-refactor`
- `bookmark-chrome-state-unification`
完成标准:
- 选区、高亮、书签相关 UI 测试恢复稳定
### 里程碑 B默认安全行为收口
包含任务:
- `external-link-policy-hardening`
- `epub-archive-path-validation`
- `webview-debug-sanitization`
- `persistence-defaults-hardening`
完成标准:
- 默认配置下不再直接打开高风险外链
- 解压路径具备显式校验
- 默认调试日志不泄露完整阅读内容
### 里程碑 C底层稳定性优化
包含任务:
- `resource-scheme-streaming-and-limits`
- `pagination-webview-hardening`
- `cache-hardening`
完成标准:
- 大资源加载和分页更稳定
- 缓存更健壮、可清理
### 里程碑 D契约与测试固化
包含任务:
- `sdk-contract-and-test-hardening`
完成标准:
- SDK 默认行为更清晰
- 回归测试对关键状态具备稳定观测能力
---
## 6. 建议的开发节奏
若按 2 周一个小迭代,可参考:
1. 第 1 周:`bookmark-chrome-state-unification` + `selection-state-refactor`
2. 第 2 周:`external-link-policy-hardening` + `epub-archive-path-validation` + `webview-debug-sanitization`
3. 第 3 周:`persistence-defaults-hardening` + `resource-scheme-streaming-and-limits` + `pagination-webview-hardening`
4. 第 4 周:`cache-hardening` + `sdk-contract-and-test-hardening` 与回归收口
如果只想先做最值的部分,建议最小交付集合为:
1. `bookmark-chrome-state-unification`
2. `selection-state-refactor`
3. `external-link-policy-hardening`
4. `epub-archive-path-validation`
这四项完成后,阅读器的“主观可用性”和“默认安全性”会先提升一个台阶。

View File

@ -1,744 +0,0 @@
# 标准级 EPUB 定位与兼容能力开发蓝图
> 最后更新2026-06-18
> 适用范围:`EPUBCore/CFI`、`EPUBCore/Notes`、`EPUBTextRendering`、`EPUBUI`
> 目标:把当前阅读器从“主流文本书可用”推进到“商业级 EPUB 阅读器必须具备的定位与兼容能力”。
---
## 1. 文档目标
本文不是概念方案,而是可直接拆任务、排期、开发和回归的实施蓝图,覆盖三项“必须做”的能力:
1. `EPUB CFI` 标准级定位与持久化
2. 脚注 / 尾注弹层阅读
3. 嵌入字体回退 + CSS 兼容层
其中 `EPUB CFI` 按完整版路线执行,核心要求是:
1. 不依赖 `id` 也能做章节内精确 DOM 定位
2. 支持“阅读位置 -> CFI”和“CFI -> 阅读位置”的双向恢复
3. 在换字号、换字体、换行距、重新分页后仍能稳定恢复
4. 为高亮、书签、搜索命中、脚注锚点、未来跨设备同步提供统一锚点
---
## 2. 当前基线
当前仓库已经有一部分基础设施,不是从零开始:
### 2.1 已有能力
1. 已有 `RDEPUBLocation.cfi / lastCFI / rangeCFI`
2. 已有 `RDEPUBCFIParser / Serializer / Resolver / Generator`
3. 已有 `RDEPUBCFIMap` 和章节级 `marker`
4. 已有基于文本节点路径的初版 `cfiMap` 构建
5. 已有脚注检测、解析和弹层 UI 的第一版入口
6. 已有 CSS 兼容层与字体 fallback resolver 的第一版模型
### 2.2 当前仍然不够的地方
当前实现更像“标准级路线的 Phase 0.5”,还差以下闭环:
1. 无 `id` 节点的 DOM 路径恢复还只是“文本对齐驱动”,未达到标准级双向校准
2. `CFI -> 章内偏移` 仍主要依赖单个 marker 命中,缺少区间级、断言级回退链
3. 还没有持久化“DOM 恢复所需的结构指纹”,章节缓存命中后无法做更强恢复
4. 脚注弹层还没有完整纳入 CFI 锚点、回跳和高亮链路
5. CSS 兼容层仍偏“样式修正函数”,还不是完整的兼容策略包
6. 缺少样书库、诊断报告和标准级回归基线
---
## 3. 为什么要做“标准级无 id 节点双向恢复”
### 3.1 要解决的真实问题
如果只用 `href + progression``href + chapterOffset`
1. 用户改字号、字体、行距、分栏后,位置会漂
2. 高亮恢复会落到错误字词
3. 搜索命中重开书后会跳偏
4. 脚注回跳在长章节里不稳定
5. 将来做跨设备同步时,同步点不可复用
### 3.2 做到标准级后的收益
1. 阅读位置恢复从“章节大致位置”升级到“字符级稳定锚点”
2. 高亮、批注、书签、搜索、脚注全部共用同一套定位协议
3. 章节重排后仍能尽可能落到同一语义位置
4. 对不规范 EPUB 的容错能力显著提升
5. 后续可以继续扩展到 WebView 路径、CFI 导出、跨端同步
---
## 4. 目标能力定义
## 4.1 CFI 能力分级
### L1 基础可用
1. 能解析 / 序列化单点 CFI 与 Range CFI
2. 能持久化阅读位置和高亮范围
3. 对带 `id` 的节点定位稳定
### L2 当前已接近
1. 能为文本节点生成路径
2. 能在章节内做初步文本节点映射
3. 能在部分重排场景下恢复位置
### L3 本文目标:标准级
1. 无 `id` 节点可精确恢复
2. 位置恢复有多级回退链
3. 断言、结构指纹、上下文窗口共同参与校准
4. 章节缓存可直接携带恢复元数据
5. 所有消费方统一走 `CFI` 主链路
---
## 5. 总体架构
建议把定位系统拆成四层:
1. `CFI Syntax Layer`
2. `DOM Anchor Extraction Layer`
3. `Recovery & Calibration Layer`
4. `Consumer Integration Layer`
### 5.1 CFI Syntax Layer
职责:
1. 负责解析、序列化、生成、Range 拼装
2. 不关心具体渲染结果
对应目录:
- `Sources/RDReaderView/EPUBCore/CFI/`
### 5.2 DOM Anchor Extraction Layer
职责:
1. 从原始 HTML 建立 DOM 路径
2. 为文本节点、fragment、note anchor 建立索引
3. 产出章节级恢复元数据
对应目录:
- `Sources/RDReaderView/EPUBCore/CFI/`
- `Sources/RDReaderView/EPUBTextRendering/BuildPipeline/`
### 5.3 Recovery & Calibration Layer
职责:
1. 输入 `CFI`,输出稳定的章节字符偏移
2. 输入字符偏移,输出稳定 `CFI`
3. 在 DOM、文本、分页变动时完成校准与回退
对应目录:
- `Sources/RDReaderView/EPUBTextRendering/`
- `Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/`
### 5.4 Consumer Integration Layer
职责:
1. 阅读位置恢复
2. 高亮 / 批注 / 书签
3. 搜索命中
4. 脚注弹层与回跳
对应目录:
- `Sources/RDReaderView/EPUBUI/`
---
## 6. 核心数据结构
## 6.1 在现有模型上补强,不推翻
### `RDEPUBCFIMap`
现有结构:
```swift
public struct RDEPUBCFIMap: Codable, Equatable {
public var href: String
public var markers: [RDEPUBCFIMarker]
public var textAssertions: [String: RDEPUBCFITextAssertion]
}
```
建议扩展为:
```swift
public struct RDEPUBCFIMap: Codable, Equatable {
public var href: String
public var renderVersion: Int
public var domVersion: Int
public var markers: [RDEPUBCFIMarker]
public var textAssertions: [String: RDEPUBCFITextAssertion]
public var pathRanges: [RDEPUBCFIPathRange]
public var recoveryMetadata: RDEPUBCFIRecoveryMetadata
}
```
新增原因:
1. `markers` 适合单点命中,但不足以做标准级回退
2. 需要显式记录“某个 DOM 路径覆盖哪段文本”
3. 需要缓存 DOM 恢复辅助信息,避免每次重扫 HTML
### `RDEPUBCFIMarker`
建议扩展字段:
```swift
public struct RDEPUBCFIMarker: Codable, Equatable {
public var cfiPath: RDEPUBCFIPath
public var chapterOffset: Int?
public var fragmentID: String?
public var textNodeLength: Int?
public var textNodeChecksum: UInt64?
public var normalizedTextPreview: String?
public var domSiblingSignature: String?
}
```
用途:
1. `textNodeChecksum` 用于节点文本快速比对
2. `normalizedTextPreview` 用于短窗口断言
3. `domSiblingSignature` 用于路径偏移时的邻接恢复
### 新增 `RDEPUBCFIPathRange`
```swift
public struct RDEPUBCFIPathRange: Codable, Equatable {
public var cfiPath: RDEPUBCFIPath
public var startOffset: Int
public var endOffset: Int
public var textNodeLength: Int
}
```
用途:
1. 表示某个文本节点在章节纯文本中的覆盖区间
2. 支撑“按区间查找最近路径”
3. 支撑 `chapterOffset -> nearest CFI path`
### 新增 `RDEPUBCFIRecoveryMetadata`
```swift
public struct RDEPUBCFIRecoveryMetadata: Codable, Equatable {
public var domFingerprint: String
public var normalizedTextChecksum: String
public var tokenIndex: [RDEPUBCFITokenAnchor]
public var fragmentPathMap: [String: RDEPUBCFIPath]
}
```
用途:
1. `domFingerprint` 判断原始 HTML 是否变化
2. `normalizedTextChecksum` 判断章节标准化文本是否变化
3. `tokenIndex` 为无 `id` 恢复提供次级定位锚
4. `fragmentPathMap` 保留锚点和脚注入口
### 新增 `RDEPUBCFITokenAnchor`
```swift
public struct RDEPUBCFITokenAnchor: Codable, Equatable {
public var token: String
public var occurrence: Int
public var chapterOffset: Int
public var cfiPath: RDEPUBCFIPath
}
```
用途:
1. 当文本节点路径不再精确命中时,用稀疏 token 锚做恢复
2. 对长章节做局部二次定位
---
## 7. 标准级无 id 节点双向恢复算法
## 7.1 正向:阅读位置 -> CFI
输入:
1. `fileIndex`
2. `chapterOffset`
3. `RDEPUBCFIMap`
输出:
1. 标准 `CFI`
2. 如有需要,附带 text assertion
算法顺序:
1. 在 `pathRanges` 中查找覆盖 `chapterOffset` 的文本节点
2. 计算该节点内的 `localOffset`
3. 生成 `contentPath + characterOffset`
4. 从前后文提取 assertion
5. 若该节点不存在,则回退到最近 `fragment`
6. 若 `fragment` 也缺失,则退化为 offset-backed CFI
### 关键要求
1. `chapterOffset` 不能直接依赖页码
2. assertion 必须来自标准化文本,而不是原始 HTML 片段
3. 对选区范围,`startCFI / endCFI` 必须分别独立生成,不能只存一个起点
## 7.2 逆向CFI -> 阅读位置
输入:
1. `RDEPUBCFI`
2. `RDEPUBCFIMap`
3. `chapterText`
输出:
1. `chapterOffset`
2. 必要时输出 `confidence`
建议新增恢复结果:
```swift
public struct RDEPUBCFIRecoveryResult: Equatable {
public enum Confidence: Int {
case exactPath
case assertionCalibrated
case siblingRecovered
case tokenRecovered
case fragmentFallback
case offsetFallback
}
public var chapterOffset: Int
public var confidence: Confidence
}
```
恢复链路必须固定为:
1. `exact-path`
- `cfiPath` 直接命中 marker 或 pathRange
2. `assertion-calibrated`
- 路径命中后,用 text assertion 微调偏移
3. `sibling-recovered`
- 路径不命中时,用父路径 + 兄弟签名查找邻近文本节点
4. `token-recovered`
- 使用 token anchor 在局部窗口重定位
5. `fragment-fallback`
- 回退到最近 fragment 起点
6. `offset-fallback`
- 使用旧式 chapterOffset 或 progression 兜底
这里的关键不是“某一步一定成功”,而是恢复链必须稳定、可解释、可诊断。
---
## 8. 为什么要加“结构指纹 + token 锚”
只做文本节点路径有两个问题:
1. 某些 EPUB 会在无 `id` 场景下插入大量包装标签,导致路径整体漂移
2. 某些章节文本重复度高,单靠短 assertion 可能落到错误位置
所以标准级方案需要两层辅助:
### 8.1 结构指纹
针对每个文本节点记录:
1. 父路径
2. 左右兄弟摘要
3. 标签名序列
4. 局部文本 checksum
这样即便 `cfiPath` 因中间插入一个 wrapper 节点失效,也能在兄弟集合里恢复出最可能目标。
### 8.2 token 锚
在章节标准化文本中抽样稀疏 token例如
1. 每 `N` 个词或每 `M` 个中文字符窗口抽一个 token
2. 每个 token 记录其 occurrence、offset 和所属 `cfiPath`
当路径和断言都不稳时:
1. 先用 token 锁定大致位置
2. 再在局部窗口里做文本节点回溯
这就是“无 id 节点也能做标准级恢复”的核心。
---
## 9. 分阶段实施
## Phase 1结构补强与缓存升级
### 目标
让章节缓存携带足够的 DOM 恢复元数据,为后续标准级恢复打底。
### 需要改的模块
1. `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIMap.swift`
2. `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIDOMPathBuilder.swift`
3. `Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookModels.swift`
4. `Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookBuilder.swift`
5. `Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterSummaryDiskCache.swift`
6. `Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLoader.swift`
### 具体任务
1. 扩展 `RDEPUBCFIMap`、`RDEPUBCFIMarker`
2. 新增 `RDEPUBCFIPathRange`
3. 新增 `RDEPUBCFIRecoveryMetadata`
4. 在章节构建阶段产出 `pathRanges / tokenIndex / domFingerprint`
5. 升级 chapter summary schema version
6. 为缓存加版本兼容分支,旧缓存 miss 后自动重建
### 验收
1. 新章节缓存可落盘完整 `cfiMap`
2. 旧缓存不会引发崩溃
3. 二次打开时不需要重新扫描整章 HTML 才能恢复定位
## Phase 2标准级 `CFI -> offset` 恢复链
### 目标
把当前“marker 命中 + chapterOffset 回退”的恢复方式升级为多级恢复链。
### 需要改的模块
1. `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextIndexTable.swift`
2. `Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterOffsetMap.swift`
3. `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIResolver.swift`
4. 建议新增:
- `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIRecoveryEngine.swift`
### 具体任务
1. 抽出 `RDEPUBCFIRecoveryEngine`
2. 输出 `RDEPUBCFIRecoveryResult`
3. 实现六级恢复链
4. 为每次恢复产出 `confidence`
5. 为 diagnostics 埋点:
- exact-path 命中率
- assertion 校准命中率
- token 恢复命中率
- offset 回退比例
### 验收
1. 无 `id` 文本节点可稳定恢复
2. 换字号 / 字体 / 行距后,阅读位置恢复成功率显著提升
3. 高亮与搜索命中恢复不再大面积偏移
## Phase 3标准级 `offset -> CFI` 生成链
### 目标
让所有阅读位置、选区、高亮和搜索结果都优先生成标准文本节点 CFI。
### 需要改的模块
1. `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIGenerator.swift`
2. `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextIndexTable.swift`
3. `Sources/RDReaderView/EPUBTextRendering/RDEPUBChapterData.swift`
4. `Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextSelectionController.swift`
5. `Sources/RDReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift`
6. `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextSearchEngine.swift`
### 具体任务
1. `chapterOffset -> pathRange` 精确映射
2. 统一单点 CFI 与范围 CFI 生成
3. 所有消费方优先写入 `cfi/rangeCFI`
4. 保留 `fragment/progression` 作为兜底兼容字段
### 验收
1. 书签、新建高亮、搜索命中都能生成标准 CFI
2. 相同文本范围在重新分页后仍能正确恢复
## Phase 4脚注 / 尾注弹层闭环
### 目标
把脚注从“跳出当前阅读流”改为“就地预览 + 可回跳”。
### 需要改的模块
1. `Sources/RDReaderView/EPUBCore/Notes/RDEPUBNoteDetector.swift`
2. `Sources/RDReaderView/EPUBCore/Notes/RDEPUBNoteResolver.swift`
3. `Sources/RDReaderView/EPUBCore/Notes/RDEPUBNoteModels.swift`
4. `Sources/RDReaderView/EPUBUI/Notes/RDEPUBNotePopupCoordinator.swift`
5. `Sources/RDReaderView/EPUBUI/Notes/RDEPUBNotePopupViewController.swift`
6. `Sources/RDReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift`
### 具体任务
1. 完善脚注链接识别:
- `epub:type=noteref`
- `role=doc-noteref`
- `href=#footnote-*`
- 章节内 / 跨章节 note link
2. note target 解析后落为 `CFI`
3. 弹层展示注释正文,而不是强制整章跳转
4. 弹层内支持:
- 查看上下文
- 跳到原文
- 跳到注释原位置
5. 建立“入口位置 CFI -> 弹层 -> 回跳位置 CFI”的闭环
### 验收
1. 点击脚注不打断当前阅读主流程
2. 长注释可滚动
3. 关闭弹层后能回到点击时的位置
4. 跨章节尾注也能稳定打开
## Phase 5样式兼容层与字体回退闭环
### 目标
减少“某些 EPUB 打开排版异常”的商业级缺陷。
### 需要改的模块
1. `Sources/RDReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBStyleCompatibilityModels.swift`
2. `Sources/RDReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBCSSCompatibilityLayer.swift`
3. `Sources/RDReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBFontFallbackResolver.swift`
4. `Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBStyleSheetComposer.swift`
5. `Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBHTMLNormalizer.swift`
6. `Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBRenderDiagnosticsCollector.swift`
### 具体任务
1. 建立 CSS 兼容策略包:
- 非法 `font-family` 归一化
- 极端 `line-height` 修正
- 过大 `margin/padding` 收口
- 表格 / 图片 / SVG 溢出保护
- 嵌套 `white-space` 异常归一化
2. 建立字体 fallback 链:
- 书内嵌入字体
- 用户选中字體
- 语言 fallback
- 系统兜底字体
3. diagnostics 输出:
- 缺失字体
- 被修正的 CSS 规则
- 可能导致排版异常的资源
### 验收
1. 缺字库 EPUB 不崩溃、不出现大面积 tofu
2. 非法 CSS 不导致正文不可读
3. diagnostics 可定位问题书源
---
## 10. 关键实现细节
## 10.1 DOM 路径构建规则
1. 元素节点使用偶数 step
2. 文本节点使用奇数 step
3. 忽略注释节点、doctype、处理指令
4. `script/style` 默认不参与文本定位
5. `ruby`、`rt`、`rp` 需要单独定义标准化策略,避免正文偏移
## 10.2 标准化文本规则必须固定
以下规则必须全链路共用同一份实现,否则 CFI 会漂:
1. HTML entity 解码
2. 连续空白压缩
3. 换行归一化
4. 零宽字符处理
5. `nbsp` 处理
6. 附件占位字符策略
7. 中文全角 / 半角是否归一
建议把规则集中到一个入口,避免 `Builder`、`Search`、`Selection` 各写一套。
## 10.3 `renderSignature``domFingerprint` 分工
1. `renderSignature`
- 描述“分页语义”
- 用于判定页图、chapter summary、layout cache 是否可复用
2. `domFingerprint`
- 描述“章节 DOM 结构”
- 用于判定 `cfiMap` 是否可复用
这两个概念不能混用。
## 10.4 本地缓存策略
建议缓存分三层:
1. `raw parse cache`
- OPF、manifest、spine、resource bytes
2. `chapter summary cache`
- 章节文本、fragmentOffsets、cfiMap、pathRanges、tokenIndex
3. `page map / runtime cache`
- 与具体排版参数绑定
原则:
1. 字号、字体、行距变化时,第三层必须失效
2. 第二层尽量复用
3. `cfiMap` 属于第二层,不应因单纯重排被清空
---
## 11. 文件改动清单
## 11.1 必改文件
### CFI 核心
1. `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIMap.swift`
2. `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIDOMPathBuilder.swift`
3. `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIGenerator.swift`
4. `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIResolver.swift`
5. 新增 `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIRecoveryEngine.swift`
### 构建与缓存
1. `Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookModels.swift`
2. `Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookBuilder.swift`
3. `Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLoader.swift`
4. `Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterSummaryDiskCache.swift`
### 定位消费方
1. `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextIndexTable.swift`
2. `Sources/RDReaderView/EPUBTextRendering/RDEPUBChapterData.swift`
3. `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextSearchEngine.swift`
4. `Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextSelectionController.swift`
5. `Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift`
6. `Sources/RDReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift`
7. `Sources/RDReaderView/EPUBUI/RDEPUBReaderController+DataSource.swift`
8. `Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterOffsetMap.swift`
9. `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderLocationCoordinator.swift`
### 脚注
1. `Sources/RDReaderView/EPUBCore/Notes/RDEPUBNoteDetector.swift`
2. `Sources/RDReaderView/EPUBCore/Notes/RDEPUBNoteResolver.swift`
3. `Sources/RDReaderView/EPUBUI/Notes/RDEPUBNotePopupCoordinator.swift`
4. `Sources/RDReaderView/EPUBUI/Notes/RDEPUBNotePopupViewController.swift`
### 样式兼容
1. `Sources/RDReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBStyleCompatibilityModels.swift`
2. `Sources/RDReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBCSSCompatibilityLayer.swift`
3. `Sources/RDReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBFontFallbackResolver.swift`
4. `Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBStyleSheetComposer.swift`
## 11.2 建议新增测试
1. `ReadViewDemo/ReadViewDemoTests/CFI/RDEPUBCFIRecoveryEngineTests.swift`
2. `ReadViewDemo/ReadViewDemoTests/CFI/RDEPUBCFITextNodeMapBuilderTests.swift`
3. `ReadViewDemo/ReadViewDemoTests/Notes/RDEPUBNoteResolverTests.swift`
4. `ReadViewDemo/ReadViewDemoTests/Compatibility/RDEPUBCSSCompatibilityLayerTests.swift`
5. `ReadViewDemo/ReadViewDemoUITests/ReaderUITests/LocationPersistenceTests.swift`
6. `ReadViewDemo/ReadViewDemoUITests/ReaderUITests/FootnotePopupTests.swift`
---
## 12. 回归样书与验收用例
至少准备以下样书:
1. 标准文本 EPUB节点 `id` 完整
2. 无 `id` 文本 EPUB
3. 重复文本很多的长章节 EPUB
4. 脚注密集型 EPUB
5. 嵌入字体缺失或损坏 EPUB
6. CSS 激进覆盖型 EPUB
7. 跨章节尾注 EPUB
关键验收:
1. 阅读到某章第 5 页,改字号后仍落在同一阅读语义位置
2. 高亮一段正文,改字体后还能精确回到同一段
3. 搜索命中结果重开书后可稳定定位
4. 点击脚注弹层展示,不强制整章跳转
5. 关闭脚注后回到点击前位置
6. 不规范 CSS 的书籍仍可阅读
7. 缺失嵌入字体时能稳定 fallback
---
## 13. 性能与风险要求
### 性能目标
1. 单章节 `cfiMap` 构建增量耗时 P50 `< 12ms`P95 `< 35ms`
2. `CFI -> offset` 恢复耗时 P50 `< 2ms`P95 `< 8ms`
3. 二次打开时不因 CFI 恢复而重新解析整本书
### 主要风险
1. 章节标准化文本规则不统一,导致生成和恢复不一致
2. token 锚过密,导致缓存膨胀
3. 断言窗口过短,在重复文本章节误命中
4. 兼容层修正规则过激,伤及正常书籍排版
对应策略:
1. 文本标准化统一收口
2. token 锚做稀疏抽样并设章节上限
3. diagnostics 输出恢复置信度
4. CSS 修正规则全部可开关并支持样书回归
---
## 14. 推荐排期
建议按 4 个迭代执行:
1. 第 1 迭代
- Phase 1
- Phase 2
2. 第 2 迭代
- Phase 3
- 核心位置恢复 UI 回归
3. 第 3 迭代
- Phase 4
- 脚注体验打磨
4. 第 4 迭代
- Phase 5
- 样书库、diagnostics、稳定性回归
---
## 15. 最终交付定义
完成本蓝图后,阅读器至少应达到以下标准:
1. 阅读位置、高亮、书签、搜索命中全部优先基于标准 CFI
2. 无 `id` 节点的 EPUB 仍能做精确字符级恢复
3. 脚注 / 尾注不再破坏主阅读流
4. 字体缺失和异常 CSS 不再轻易把正文排坏
5. 章节缓存、页图缓存、定位缓存职责清晰,二次打开可复用
这时阅读器才算从“功能可用”进入“商业级可发布”的基线。

File diff suppressed because it is too large Load Diff

View File

@ -17,6 +17,7 @@ final class AsyncChapterLoadingTests: XCTestCase {
private let app = XCUIApplication()
private let largeBook = "凡人修仙传"
private let singlePageOpeningBook = "宝山辽墓"
override func setUpWithError() throws {
continueAfterFailure = false
@ -106,6 +107,25 @@ final class AsyncChapterLoadingTests: XCTestCase {
// MARK: - 3. Cross-chapter page turning in scroll mode
func testSinglePageOpeningChapterIncludesImmediateLookaheadPages() throws {
app.launchAndOpenSampleBook(
bookTitleQuery: singlePageOpeningBook,
displayType: "scroll",
resetsReaderState: true
)
app.waitForReader(timeout: 20)
let initialState = app.waitForDemoReaderState(timeout: 15, description: "首开局部分页可立即前翻") { state in
state.mode == "bookPageMap" && state.knownPages != nil
}
XCTAssertGreaterThan(
initialState.knownPages ?? 0,
1,
"首章只有 1 页时,首开局部分页也应补充前瞻页,避免停留在总页数=1"
)
}
func testCrossChapterPageTurningInScrollMode() throws {
// Swipes forward across a chapter boundary in horizontal scroll mode,
// verifying continuous page number increments.

View File

@ -75,7 +75,7 @@ public enum RDEPUBCFIDOMPathBuilder {
match.numberOfRanges > 2 else {
return nil
}
return nsAttributes.substring(with: match.range(at: 2)).rd_cfiNilIfEmpty
return nsAttributes.substring(with: match.range(at: 2)).rd_nilIfEmpty
}
static func isIgnorableTag(_ tagName: String) -> Bool {
@ -138,8 +138,8 @@ enum RDEPUBCFITextNodeMapBuilder {
markers: markers,
pathRanges: pathRanges,
recoveryMetadata: RDEPUBCFIRecoveryMetadata(
domFingerprint: rawHTML.sha256Hex,
normalizedTextChecksum: normalizedChapter.normalizedText.sha256Hex,
domFingerprint: rawHTML.rd_sha256Hex,
normalizedTextChecksum: normalizedChapter.normalizedText.rd_sha256Hex,
tokenIndex: makeTokenAnchors(
normalizedChapter: normalizedChapter,
markers: markers,
@ -516,14 +516,6 @@ private struct RDEPUBNormalizedTextIndex {
}
}
extension String {
var rd_cfiNilIfEmpty: String? {
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}
private extension String {
var fnv1a64: UInt64 {
let offsetBasis: UInt64 = 14695981039346656037

View File

@ -114,7 +114,7 @@ public enum RDEPUBCFIParser {
if let open = rawStep.firstIndex(of: "["),
let close = rawStep.lastIndex(of: "]"),
open < close {
idAssertion = String(rawStep[rawStep.index(after: open)..<close]).rd_cfiNilIfEmpty
idAssertion = String(rawStep[rawStep.index(after: open)..<close]).rd_nilIfEmpty
} else {
idAssertion = nil
}

View File

@ -34,6 +34,6 @@ public struct RDEPUBCFIStep: Codable, Equatable, Hashable {
public init(index: Int, idAssertion: String? = nil) {
self.index = index
self.idAssertion = idAssertion?.rd_cfiNilIfEmpty
self.idAssertion = idAssertion?.rd_nilIfEmpty
}
}

View File

@ -28,7 +28,7 @@ public enum RDEPUBCFIResolver {
if cfi.packagePath.steps.count >= 3 {
manifestStep = cfi.packagePath.steps[2]
} else {
manifestStep = cfi.packagePath.steps.last(where: { $0.idAssertion?.rd_cfiNilIfEmpty != nil })
manifestStep = cfi.packagePath.steps.last(where: { $0.idAssertion?.rd_nilIfEmpty != nil })
}
let href = manifestStep?.idAssertion
@ -40,7 +40,7 @@ public enum RDEPUBCFIResolver {
fileIndex = nil
}
let fragmentID = cfi.contentPath.steps.last(where: { $0.idAssertion?.rd_cfiNilIfEmpty != nil })?.idAssertion
let fragmentID = cfi.contentPath.steps.last(where: { $0.idAssertion?.rd_nilIfEmpty != nil })?.idAssertion
return RDEPUBCFIResolverResult(
href: href,
fileIndex: fileIndex,

View File

@ -41,7 +41,7 @@ public struct RDEPUBNoteReference: Codable, Equatable {
self.targetHref = targetHref
self.targetFragment = targetFragment
self.targetCFI = targetCFI
self.label = label?.rdNoteNilIfEmpty
self.label = label?.rd_nilIfEmpty
self.kind = kind
}
}
@ -71,15 +71,8 @@ public struct RDEPUBResolvedNote: Equatable {
self.reference = reference
self.sourceLocation = sourceLocation
self.targetLocation = targetLocation
self.title = title?.rdNoteNilIfEmpty
self.title = title?.rd_nilIfEmpty
self.html = html
self.plainText = plainText
}
}
private extension String {
var rdNoteNilIfEmpty: String? {
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}

View File

@ -261,7 +261,11 @@ public final class RDEPUBReadingSession {
href: location.href,
progression: location.progression,
lastProgression: location.lastProgression,
fragment: location.fragment
fragment: location.fragment,
rangeAnchor: location.rangeAnchor,
cfi: location.cfi,
lastCFI: location.lastCFI,
rangeCFI: location.rangeCFI
)
let resource = RDEPUBViewportResource(

View File

@ -96,15 +96,3 @@ extension RDEPUBWebView {
currentLoadSignature = nil
}
}
extension UIColor {
var ss_hexString: String {
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var alpha: CGFloat = 0
getRed(&red, green: &green, blue: &blue, alpha: &alpha)
return String(format: "#%02X%02X%02X", Int(red * 255), Int(green * 255), Int(blue * 255))
}
}

View File

@ -16,7 +16,7 @@ extension RDEPUBWebView {
spread: spread,
viewportSize: viewportSize,
contentInset: contentInset,
backgroundColorCSS: backgroundColor?.ss_hexString,
backgroundColorCSS: backgroundColor?.rd_hexString,
fit: .page,
searchPresentation: nil
)
@ -50,7 +50,7 @@ extension RDEPUBWebView {
RDEPUBWebViewDebug.log(debugScope, message: "load fixed spread resources=\(request.spread.resources.map(\.href).joined(separator: ",")) fit=\(request.fit.rawValue)")
webView.loadHTMLString(
html,
baseURL: URL(string: "\(RDEPUBResourceURLSchemeHandler.scheme):///")
baseURL: URL(string: "\(RDEPUBResourceURLSchemeHandler.scheme)://\(RDEPUBResourceURLSchemeHandler.host)/")
)
}

View File

@ -17,15 +17,3 @@ extension NSAttributedString {
rd_paginatedFrames(size: size).map(\.contentRange)
}
}
extension UIColor {
var ss_cssString: String {
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var alpha: CGFloat = 0
getRed(&red, green: &green, blue: &blue, alpha: &alpha)
return String(format: "rgba(%d, %d, %d, %.3f)", Int(red * 255), Int(green * 255), Int(blue * 255), alpha)
}
}

View File

@ -102,8 +102,8 @@ public struct RDEPUBTextIndexTable {
}
public func anchor(for location: RDEPUBLocation) -> RDEPUBTextAnchor? {
if let cfi = RDEPUBCFICompatibility.parseLossy(location.cfi),
let anchor = anchor(for: cfi) {
if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(location.rangeCFI),
let anchor = anchor(for: cfiRange.start) {
return anchor
}
@ -111,6 +111,11 @@ public struct RDEPUBTextIndexTable {
return anchor
}
if let cfi = RDEPUBCFICompatibility.parseLossy(location.cfi),
let anchor = anchor(for: cfi) {
return anchor
}
guard let chapterIndex = hrefToChapterIndex[location.href],
let fileIndex = hrefToFileIndex[location.href] else { return nil }
let fragments = fragmentOffsetsByHref[location.href] ?? [:]

View File

@ -134,8 +134,8 @@ struct RDEPUBStyleSheetComposer {
}
private static func darkCSS(style: RDEPUBTextRenderStyle) -> String {
let background = style.backgroundColor?.ss_cssString ?? "rgba(0, 0, 0, 1.000)"
let text = style.textColor?.ss_cssString ?? "rgba(255, 255, 255, 1.000)"
let background = style.backgroundColor?.rd_cssString ?? "rgba(0, 0, 0, 1.000)"
let text = style.textColor?.rd_cssString ?? "rgba(255, 255, 255, 1.000)"
return RDEPUBAssetRepository.string(for: .wxReadDarkCSS) + "\n\n" + """
html, body {
background: \(background) !important;
@ -149,8 +149,8 @@ struct RDEPUBStyleSheetComposer {
private static func userCSS(style: RDEPUBTextRenderStyle) -> String {
let lineHeight = max((style.font.lineHeight + style.lineSpacing) / max(style.font.lineHeight, 1), 1)
let text = style.textColor.map { "color: \($0.ss_cssString) !important;" } ?? ""
let background = style.backgroundColor.map { "background: \($0.ss_cssString) !important;" } ?? ""
let text = style.textColor.map { "color: \($0.rd_cssString) !important;" } ?? ""
let background = style.backgroundColor.map { "background: \($0.rd_cssString) !important;" } ?? ""
return """
html, body {
font-family: "\(style.font.familyName)" !important;

View File

@ -0,0 +1,36 @@
import UIKit
final class RDEPUBAttachmentTooltipOverlayView: UIView {
var onBackgroundTap: (() -> Void)?
var tooltipView: RDEPUBAttachmentTooltipView? {
subviews.compactMap { $0 as? RDEPUBAttachmentTooltipView }.first
}
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
tapGesture.cancelsTouchesInView = false
addGestureRecognizer(tapGesture)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc
private func handleTap(_ gesture: UITapGestureRecognizer) {
let point = gesture.location(in: self)
guard let tooltipView else {
onBackgroundTap?()
return
}
if !tooltipView.frame.contains(point) {
onBackgroundTap?()
}
}
}

View File

@ -0,0 +1,188 @@
import UIKit
final class RDEPUBAttachmentTooltipView: UIView {
enum ArrowPlacement {
case top
case bottom
}
private let contentInsets = UIEdgeInsets(top: 18, left: 20, bottom: 24, right: 20)
private let arrowSize = CGSize(width: 20, height: 10)
private let cornerRadius: CGFloat = 18
private(set) var minimumArrowX: CGFloat = 28
private var arrowTipX: CGFloat?
private var arrowPlacement: ArrowPlacement = .bottom
private let textLabel: UILabel = {
let label = UILabel()
label.numberOfLines = 0
label.textColor = .white
label.font = .systemFont(ofSize: 16, weight: .regular)
label.lineBreakMode = .byWordWrapping
return label
}()
private let shapeLayer = CAShapeLayer()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
isOpaque = false
layer.addSublayer(shapeLayer)
addSubview(textLabel)
accessibilityIdentifier = "epub.reader.attachment.tooltip"
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
shapeLayer.frame = bounds
shapeLayer.path = bubblePath(in: bounds).cgPath
shapeLayer.fillColor = UIColor(white: 0.26, alpha: 0.96).cgColor
let topInset = contentInsets.top + (arrowPlacement == .top ? arrowSize.height : 0)
let bottomInset = contentInsets.bottom + (arrowPlacement == .bottom ? arrowSize.height : 0)
let labelFrame = bounds.inset(by: UIEdgeInsets(
top: topInset,
left: contentInsets.left,
bottom: bottomInset,
right: contentInsets.right
))
textLabel.frame = labelFrame
}
func setArrowTipX(_ value: CGFloat, placement: ArrowPlacement) {
arrowTipX = value
arrowPlacement = placement
setNeedsLayout()
}
func configure(text: String, maxWidth: CGFloat) {
textLabel.text = text
let labelMaxWidth = max(maxWidth - contentInsets.left - contentInsets.right, 120)
let labelSize = textLabel.sizeThatFits(CGSize(width: labelMaxWidth, height: .greatestFiniteMagnitude))
frame.size = CGSize(
width: min(maxWidth, labelSize.width + contentInsets.left + contentInsets.right),
height: labelSize.height + contentInsets.top + contentInsets.bottom + arrowSize.height
)
setNeedsLayout()
layoutIfNeeded()
}
private func bubblePath(in rect: CGRect) -> UIBezierPath {
let bubbleRect: CGRect
switch arrowPlacement {
case .bottom:
bubbleRect = CGRect(
x: rect.minX,
y: rect.minY,
width: rect.width,
height: rect.height - arrowSize.height
)
case .top:
bubbleRect = CGRect(
x: rect.minX,
y: rect.minY + arrowSize.height,
width: rect.width,
height: rect.height - arrowSize.height
)
}
let arrowMidX = min(
max(arrowTipX ?? bubbleRect.midX, minimumArrowX),
bubbleRect.width - minimumArrowX
)
let arrowHalfWidth = arrowSize.width / 2
let path = UIBezierPath()
switch arrowPlacement {
case .bottom:
path.move(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY))
path.addLine(to: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY))
path.addArc(
withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY + cornerRadius),
radius: cornerRadius,
startAngle: -.pi / 2,
endAngle: 0,
clockwise: true
)
path.addLine(to: CGPoint(x: bubbleRect.maxX, y: bubbleRect.maxY - cornerRadius))
path.addArc(
withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.maxY - cornerRadius),
radius: cornerRadius,
startAngle: 0,
endAngle: .pi / 2,
clockwise: true
)
path.addLine(to: CGPoint(x: arrowMidX + arrowHalfWidth, y: bubbleRect.maxY))
path.addLine(to: CGPoint(x: arrowMidX, y: bubbleRect.maxY + arrowSize.height))
path.addLine(to: CGPoint(x: arrowMidX - arrowHalfWidth, y: bubbleRect.maxY))
path.addLine(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY))
path.addArc(
withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY - cornerRadius),
radius: cornerRadius,
startAngle: .pi / 2,
endAngle: .pi,
clockwise: true
)
path.addLine(to: CGPoint(x: bubbleRect.minX, y: bubbleRect.minY + cornerRadius))
path.addArc(
withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY + cornerRadius),
radius: cornerRadius,
startAngle: .pi,
endAngle: -.pi / 2,
clockwise: true
)
case .top:
path.move(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY))
path.addLine(to: CGPoint(x: arrowMidX - arrowHalfWidth, y: bubbleRect.minY))
path.addLine(to: CGPoint(x: arrowMidX, y: bubbleRect.minY - arrowSize.height))
path.addLine(to: CGPoint(x: arrowMidX + arrowHalfWidth, y: bubbleRect.minY))
path.addLine(to: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY))
path.addArc(
withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY + cornerRadius),
radius: cornerRadius,
startAngle: -.pi / 2,
endAngle: 0,
clockwise: true
)
path.addLine(to: CGPoint(x: bubbleRect.maxX, y: bubbleRect.maxY - cornerRadius))
path.addArc(
withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.maxY - cornerRadius),
radius: cornerRadius,
startAngle: 0,
endAngle: .pi / 2,
clockwise: true
)
path.addLine(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY))
path.addArc(
withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY - cornerRadius),
radius: cornerRadius,
startAngle: .pi / 2,
endAngle: .pi,
clockwise: true
)
path.addLine(to: CGPoint(x: bubbleRect.minX, y: bubbleRect.minY + cornerRadius))
path.addArc(
withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY + cornerRadius),
radius: cornerRadius,
startAngle: .pi,
endAngle: -.pi / 2,
clockwise: true
)
}
path.close()
return path
}
}

View File

@ -0,0 +1,79 @@
import UIKit
extension RDEPUBReaderController {
func presentAttachmentTooltip(text: String, sourceView: UIView, sourceRect: CGRect, sourcePoint: CGPoint) {
hideAttachmentTooltipIfNeeded()
let overlay = RDEPUBAttachmentTooltipOverlayView(frame: view.bounds)
overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight]
overlay.onBackgroundTap = { [weak self, weak overlay] in
guard let self, let overlay else { return }
self.dismissAttachmentTooltipOverlay(overlay)
}
let tooltip = RDEPUBAttachmentTooltipView()
tooltip.alpha = 0
let horizontalPadding = max(view.safeAreaInsets.left, view.safeAreaInsets.right) + 12
tooltip.configure(text: text, maxWidth: min(view.bounds.width - horizontalPadding * 2, 320))
let anchorRect = sourceView.convert(sourceRect, to: view)
let rawAnchorPoint = sourceView.convert(sourcePoint, to: view)
let anchorPoint = CGPoint(
x: min(max(rawAnchorPoint.x, anchorRect.minX), anchorRect.maxX),
y: min(max(rawAnchorPoint.y, anchorRect.minY), anchorRect.maxY)
)
let verticalSpacing: CGFloat = 6
let tooltipSize = tooltip.frame.size
let idealX = anchorPoint.x - tooltipSize.width / 2
let minX = horizontalPadding
let maxX = max(minX, view.bounds.width - horizontalPadding - tooltipSize.width)
let originX = min(max(idealX, minX), maxX)
let topSafeY = view.safeAreaInsets.top + 12
let bottomSafeY = view.bounds.height - view.safeAreaInsets.bottom - 12
let availableSpaceAbove = anchorRect.minY - topSafeY
let availableSpaceBelow = bottomSafeY - anchorRect.maxY
let prefersAbove = availableSpaceAbove >= tooltipSize.height + verticalSpacing || availableSpaceAbove >= availableSpaceBelow
let tooltipPlacement: RDEPUBAttachmentTooltipView.ArrowPlacement = prefersAbove ? .bottom : .top
let originY: CGFloat
switch tooltipPlacement {
case .bottom:
originY = max(topSafeY, anchorRect.minY - tooltipSize.height - verticalSpacing)
case .top:
originY = min(bottomSafeY - tooltipSize.height, anchorRect.maxY + verticalSpacing)
}
let arrowTipX = min(
max(anchorPoint.x - originX, tooltip.minimumArrowX),
tooltipSize.width - tooltip.minimumArrowX
)
tooltip.setArrowTipX(arrowTipX, placement: tooltipPlacement)
tooltip.frame.origin = CGPoint(x: originX, y: originY)
overlay.addSubview(tooltip)
view.addSubview(overlay)
UIView.animate(withDuration: 0.2) {
tooltip.alpha = 1
}
DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) { [weak self, weak overlay] in
guard let self, let overlay else { return }
self.dismissAttachmentTooltipOverlay(overlay)
}
}
private func hideAttachmentTooltipIfNeeded() {
view.subviews
.compactMap { $0 as? RDEPUBAttachmentTooltipOverlayView }
.forEach { $0.removeFromSuperview() }
}
private func dismissAttachmentTooltipOverlay(_ overlay: RDEPUBAttachmentTooltipOverlayView) {
UIView.animate(withDuration: 0.18, animations: {
overlay.tooltipView?.alpha = 0
}, completion: { _ in
overlay.removeFromSuperview()
})
}
}

View File

@ -104,210 +104,6 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
runtime.presentHighlightActions(for: highlight, sourceView: contentView, sourceRect: sourceRect)
}
func pageNumber(for location: RDEPUBLocation) -> Int? {
if let publication,
let bookPageMap = readerContext.bookPageMap,
let spineIndex = readerContext.normalizedSpineIndex(for: location),
let entry = bookPageMap.entry(forSpineIndex: spineIndex) {
let normalizedLocation = publication.resourceResolver.normalizedLocation(
location,
relativeToSpineIndex: nil,
bookIdentifier: currentBookIdentifier
) ?? location
let localPageIndex: Int
if let summary = readerContext.chapterSummary(forSpineIndex: spineIndex) {
let offset = chapterOffset(for: normalizedLocation, fallbackEntry: entry)
localPageIndex = summary.pageRanges.firstIndex {
let range = $0.nsRange
return offset >= range.location && offset <= max(range.location + range.length - 1, range.location)
} ?? fallbackLocalPageIndex(for: normalizedLocation, pageCount: entry.pageCount)
} else {
localPageIndex = fallbackLocalPageIndex(for: normalizedLocation, pageCount: entry.pageCount)
}
return bookPageMap.absolutePageIndex(
spineIndex: spineIndex,
localPageIndex: min(max(localPageIndex, 0), max(entry.pageCount - 1, 0))
).map { $0 + 1 }
}
if let textBook, let publication {
if let anchor = location.rangeAnchor?.start {
if let page = textBook.indexTable.pageNumber(for: anchor, in: textBook) {
return page + 1
}
}
let normalizedLocation = publication.resourceResolver.normalizedLocation(
location,
relativeToSpineIndex: nil,
bookIdentifier: currentBookIdentifier
) ?? location
return textBook.pageNumber(
for: normalizedLocation,
resolver: publication.resourceResolver,
bookIdentifier: currentBookIdentifier
)
}
return readingSession?.queueNavigation(
to: location,
relativeToSpineIndex: nil,
bookIdentifier: currentBookIdentifier
)
}
func resolvedTextLocation(forPageNumber pageNumber: Int) -> RDEPUBLocation? {
if let resolvedPage = resolvedRuntimePage(forPageNumber: pageNumber) {
let startOffset = resolvedPage.page.pageStartOffset
let endOffset = max(startOffset, resolvedPage.page.pageEndOffset)
let chapterData = makeRuntimeChapterData(from: resolvedPage)
let location = chapterData.location(
for: NSRange(location: startOffset, length: max(endOffset - startOffset + 1, 1)),
bookIdentifier: currentBookIdentifier
)
if let publication {
return publication.resourceResolver.normalizedLocation(
location,
relativeToSpineIndex: nil,
bookIdentifier: currentBookIdentifier
) ?? location
}
return location
}
guard let textBook,
let page = textBook.page(at: pageNumber) else {
return nil
}
let location = textBook.chapterData(forPageNumber: pageNumber)?.location(forPage: page, bookIdentifier: currentBookIdentifier)
?? textBook.location(forPageNumber: pageNumber, bookIdentifier: currentBookIdentifier)
guard let location else { return nil }
if let publication {
return publication.resourceResolver.normalizedLocation(
location,
relativeToSpineIndex: nil,
bookIdentifier: currentBookIdentifier
) ?? location
}
return location
}
func synchronizeTextReadingState(pageNumber: Int, location: RDEPUBLocation) {
if let resolvedPage = resolvedRuntimePage(forPageNumber: pageNumber) {
readingSession?.updateReadingContext(
pageNumber: pageNumber,
location: location,
spineIndex: resolvedPage.page.spineIndex,
chapterIndex: resolvedPage.chapterIndex,
bookIdentifier: currentBookIdentifier
)
return
}
guard let textBook,
let page = textBook.page(at: pageNumber) else {
readingSession?.transition(to: .idle)
return
}
readingSession?.updateReadingContext(
pageNumber: pageNumber,
location: location,
spineIndex: page.spineIndex,
chapterIndex: page.chapterIndex,
bookIdentifier: currentBookIdentifier
)
}
func nativeTextSnapshot(from textBook: RDEPUBTextBook) -> RDEPUBNativeTextSnapshot {
let chapters = textBook.chapterInfos
let pages = textBook.pages.map {
EPUBPage(
spineIndex: $0.spineIndex,
chapterIndex: $0.chapterIndex,
pageIndexInChapter: $0.pageIndexInChapter,
totalPagesInChapter: $0.totalPagesInChapter,
chapterTitle: $0.chapterTitle,
fixedSpread: nil
)
}
return (pages, chapters)
}
private func resolvedRuntimePage(forPageNumber pageNumber: Int) -> RDEPUBResolvedPage? {
runtime.pageResolver.resolvePage(absolutePageIndex: pageNumber - 1)
}
func chapterOffset(for location: RDEPUBLocation, fallbackEntry: RDEPUBBookPageMapEntry) -> Int {
if let spineIndex = readerContext.normalizedSpineIndex(for: location),
let runtimeChapter = runtime.chapterRuntimeStore.chapterData(for: spineIndex),
let offset = runtimeChapter.chapterOffsetMap.chapterOffset(forCFI: location.cfi) {
return offset
}
if let cfi = RDEPUBCFICompatibility.parseLossy(location.cfi),
let cfiOffset = RDEPUBCFIResolver.resolve(cfi).chapterOffset {
return cfiOffset
}
if let anchor = location.rangeAnchor?.start {
return anchor.chapterOffset
}
if let fragment = location.fragment,
let offset = fallbackEntry.fragmentOffsets[fragment] {
return offset
}
return 0
}
func fallbackLocalPageIndex(for location: RDEPUBLocation, pageCount: Int) -> Int {
guard pageCount > 1 else { return 0 }
return min(
pageCount - 1,
max(0, Int(round(location.navigationProgression * Double(pageCount - 1))))
)
}
private func nearestFragmentID(beforeOrAt offset: Int, fragmentOffsets: [String: Int]) -> String? {
var bestID: String?
var bestOffset = Int.min
for (fragmentID, fragmentOffset) in fragmentOffsets where fragmentOffset <= offset && fragmentOffset > bestOffset {
bestOffset = fragmentOffset
bestID = fragmentID
}
return bestID
}
private func shouldAllowExternalURL(_ url: URL) -> Bool {
guard let scheme = url.scheme?.lowercased() else { return false }
if delegate?.epubReader(self, shouldOpenExternalURL: url) == false {
return false
}
return configuration.allowedExternalURLSchemes.contains(scheme)
}
private func openExternalURLIfAllowed(_ url: URL) {
guard shouldAllowExternalURL(url) else { return }
if configuration.requiresExternalLinkConfirmation {
presentExternalLinkConfirmation(for: url)
} else {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
}
}
private func presentExternalLinkConfirmation(for url: URL) {
let alert = UIAlertController(
title: "打开外部链接",
message: url.absoluteString,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "打开", style: .default) { _ in
UIApplication.shared.open(url, options: [:], completionHandler: nil)
})
present(alert, animated: true)
}
private func presentNotePopupIfPossible(for location: RDEPUBLocation, fromSpineIndex: Int) -> Bool {
guard let publication else { return false }
let sourceHref = publication.resourceResolver.href(forSpineIndex: fromSpineIndex) ?? location.href
@ -354,317 +150,4 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
readerView.transitionToPage(pageNum: max(pageNumber - 1, 0), animated: animated)
}
private func presentAttachmentTooltip(text: String, sourceView: UIView, sourceRect: CGRect, sourcePoint: CGPoint) {
hideAttachmentTooltipIfNeeded()
let overlay = RDEPUBAttachmentTooltipOverlayView(frame: view.bounds)
overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight]
overlay.onBackgroundTap = { [weak self, weak overlay] in
guard let self, let overlay else { return }
self.dismissAttachmentTooltipOverlay(overlay)
}
let tooltip = RDEPUBAttachmentTooltipView()
tooltip.alpha = 0
let horizontalPadding = max(view.safeAreaInsets.left, view.safeAreaInsets.right) + 12
tooltip.configure(text: text, maxWidth: min(view.bounds.width - horizontalPadding * 2, 320))
let anchorRect = sourceView.convert(sourceRect, to: view)
let rawAnchorPoint = sourceView.convert(sourcePoint, to: view)
let anchorPoint = CGPoint(
x: min(max(rawAnchorPoint.x, anchorRect.minX), anchorRect.maxX),
y: min(max(rawAnchorPoint.y, anchorRect.minY), anchorRect.maxY)
)
let verticalSpacing: CGFloat = 6
let tooltipSize = tooltip.frame.size
let idealX = anchorPoint.x - tooltipSize.width / 2
let minX = horizontalPadding
let maxX = max(minX, view.bounds.width - horizontalPadding - tooltipSize.width)
let originX = min(max(idealX, minX), maxX)
let topSafeY = view.safeAreaInsets.top + 12
let bottomSafeY = view.bounds.height - view.safeAreaInsets.bottom - 12
let availableSpaceAbove = anchorRect.minY - topSafeY
let availableSpaceBelow = bottomSafeY - anchorRect.maxY
let prefersAbove = availableSpaceAbove >= tooltipSize.height + verticalSpacing || availableSpaceAbove >= availableSpaceBelow
let tooltipPlacement: RDEPUBAttachmentTooltipView.ArrowPlacement = prefersAbove ? .bottom : .top
let originY: CGFloat
switch tooltipPlacement {
case .bottom:
originY = max(topSafeY, anchorRect.minY - tooltipSize.height - verticalSpacing)
case .top:
originY = min(bottomSafeY - tooltipSize.height, anchorRect.maxY + verticalSpacing)
}
let arrowTipX = min(
max(anchorPoint.x - originX, tooltip.minimumArrowX),
tooltipSize.width - tooltip.minimumArrowX
)
tooltip.setArrowTipX(arrowTipX, placement: tooltipPlacement)
tooltip.frame.origin = CGPoint(x: originX, y: originY)
overlay.addSubview(tooltip)
view.addSubview(overlay)
UIView.animate(withDuration: 0.2) {
tooltip.alpha = 1
}
DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) { [weak self, weak overlay] in
guard let self, let overlay else { return }
self.dismissAttachmentTooltipOverlay(overlay)
}
}
private func hideAttachmentTooltipIfNeeded() {
view.subviews
.compactMap { $0 as? RDEPUBAttachmentTooltipOverlayView }
.forEach { $0.removeFromSuperview() }
}
private func dismissAttachmentTooltipOverlay(_ overlay: RDEPUBAttachmentTooltipOverlayView) {
UIView.animate(withDuration: 0.18, animations: {
overlay.tooltipView?.alpha = 0
}, completion: { _ in
overlay.removeFromSuperview()
})
}
}
private extension RDEPUBReaderController {
func makeRuntimeChapterData(from resolvedPage: RDEPUBResolvedPage) -> RDEPUBChapterData {
let textChapter = RDEPUBTextChapter(
chapterIndex: resolvedPage.chapterIndex,
spineIndex: resolvedPage.chapter.spineIndex,
href: resolvedPage.chapter.href,
title: resolvedPage.chapter.title,
attributedContent: resolvedPage.chapter.typesetAttributedString,
fragmentOffsets: resolvedPage.chapter.chapterOffsetMap.fragmentOffsets,
cfiMap: resolvedPage.chapter.chapterOffsetMap.cfiMap,
pageBreakReasons: resolvedPage.chapter.pages.map(\.metadata.breakReason),
pages: resolvedPage.chapter.pages
)
return RDEPUBChapterData(
chapter: textChapter,
indexTable: RDEPUBTextIndexTable(chapters: [textChapter])
)
}
}
private final class RDEPUBAttachmentTooltipOverlayView: UIView {
var onBackgroundTap: (() -> Void)?
var tooltipView: RDEPUBAttachmentTooltipView? {
subviews.compactMap { $0 as? RDEPUBAttachmentTooltipView }.first
}
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
tapGesture.cancelsTouchesInView = false
addGestureRecognizer(tapGesture)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc
private func handleTap(_ gesture: UITapGestureRecognizer) {
let point = gesture.location(in: self)
guard let tooltipView else {
onBackgroundTap?()
return
}
if !tooltipView.frame.contains(point) {
onBackgroundTap?()
}
}
}
private final class RDEPUBAttachmentTooltipView: UIView {
enum ArrowPlacement {
case top
case bottom
}
private let contentInsets = UIEdgeInsets(top: 18, left: 20, bottom: 24, right: 20)
private let arrowSize = CGSize(width: 20, height: 10)
private let cornerRadius: CGFloat = 18
private(set) var minimumArrowX: CGFloat = 28
private var arrowTipX: CGFloat?
private var arrowPlacement: ArrowPlacement = .bottom
private let textLabel: UILabel = {
let label = UILabel()
label.numberOfLines = 0
label.textColor = .white
label.font = .systemFont(ofSize: 16, weight: .regular)
label.lineBreakMode = .byWordWrapping
return label
}()
private let shapeLayer = CAShapeLayer()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
isOpaque = false
layer.addSublayer(shapeLayer)
addSubview(textLabel)
accessibilityIdentifier = "epub.reader.attachment.tooltip"
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
shapeLayer.frame = bounds
shapeLayer.path = bubblePath(in: bounds).cgPath
shapeLayer.fillColor = UIColor(white: 0.26, alpha: 0.96).cgColor
let topInset = contentInsets.top + (arrowPlacement == .top ? arrowSize.height : 0)
let bottomInset = contentInsets.bottom + (arrowPlacement == .bottom ? arrowSize.height : 0)
let labelFrame = bounds.inset(by: UIEdgeInsets(
top: topInset,
left: contentInsets.left,
bottom: bottomInset,
right: contentInsets.right
))
textLabel.frame = labelFrame
}
func setArrowTipX(_ value: CGFloat, placement: ArrowPlacement) {
arrowTipX = value
arrowPlacement = placement
setNeedsLayout()
}
func configure(text: String, maxWidth: CGFloat) {
textLabel.text = text
let labelMaxWidth = max(maxWidth - contentInsets.left - contentInsets.right, 120)
let labelSize = textLabel.sizeThatFits(CGSize(width: labelMaxWidth, height: .greatestFiniteMagnitude))
frame.size = CGSize(
width: min(maxWidth, labelSize.width + contentInsets.left + contentInsets.right),
height: labelSize.height + contentInsets.top + contentInsets.bottom + arrowSize.height
)
setNeedsLayout()
layoutIfNeeded()
}
private func bubblePath(in rect: CGRect) -> UIBezierPath {
let bubbleRect: CGRect
switch arrowPlacement {
case .bottom:
bubbleRect = CGRect(
x: rect.minX,
y: rect.minY,
width: rect.width,
height: rect.height - arrowSize.height
)
case .top:
bubbleRect = CGRect(
x: rect.minX,
y: rect.minY + arrowSize.height,
width: rect.width,
height: rect.height - arrowSize.height
)
}
let arrowMidX = min(
max(arrowTipX ?? bubbleRect.midX, minimumArrowX),
bubbleRect.width - minimumArrowX
)
let arrowHalfWidth = arrowSize.width / 2
let path = UIBezierPath()
switch arrowPlacement {
case .bottom:
path.move(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY))
path.addLine(to: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY))
path.addArc(
withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY + cornerRadius),
radius: cornerRadius,
startAngle: -.pi / 2,
endAngle: 0,
clockwise: true
)
path.addLine(to: CGPoint(x: bubbleRect.maxX, y: bubbleRect.maxY - cornerRadius))
path.addArc(
withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.maxY - cornerRadius),
radius: cornerRadius,
startAngle: 0,
endAngle: .pi / 2,
clockwise: true
)
path.addLine(to: CGPoint(x: arrowMidX + arrowHalfWidth, y: bubbleRect.maxY))
path.addLine(to: CGPoint(x: arrowMidX, y: bubbleRect.maxY + arrowSize.height))
path.addLine(to: CGPoint(x: arrowMidX - arrowHalfWidth, y: bubbleRect.maxY))
path.addLine(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY))
path.addArc(
withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY - cornerRadius),
radius: cornerRadius,
startAngle: .pi / 2,
endAngle: .pi,
clockwise: true
)
path.addLine(to: CGPoint(x: bubbleRect.minX, y: bubbleRect.minY + cornerRadius))
path.addArc(
withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY + cornerRadius),
radius: cornerRadius,
startAngle: .pi,
endAngle: -.pi / 2,
clockwise: true
)
case .top:
path.move(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY))
path.addLine(to: CGPoint(x: arrowMidX - arrowHalfWidth, y: bubbleRect.minY))
path.addLine(to: CGPoint(x: arrowMidX, y: bubbleRect.minY - arrowSize.height))
path.addLine(to: CGPoint(x: arrowMidX + arrowHalfWidth, y: bubbleRect.minY))
path.addLine(to: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY))
path.addArc(
withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY + cornerRadius),
radius: cornerRadius,
startAngle: -.pi / 2,
endAngle: 0,
clockwise: true
)
path.addLine(to: CGPoint(x: bubbleRect.maxX, y: bubbleRect.maxY - cornerRadius))
path.addArc(
withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.maxY - cornerRadius),
radius: cornerRadius,
startAngle: 0,
endAngle: .pi / 2,
clockwise: true
)
path.addLine(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY))
path.addArc(
withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY - cornerRadius),
radius: cornerRadius,
startAngle: .pi / 2,
endAngle: .pi,
clockwise: true
)
path.addLine(to: CGPoint(x: bubbleRect.minX, y: bubbleRect.minY + cornerRadius))
path.addArc(
withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY + cornerRadius),
radius: cornerRadius,
startAngle: .pi,
endAngle: -.pi / 2,
clockwise: true
)
}
path.close()
return path
}
}

View File

@ -33,7 +33,22 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderPageProvider, RDRe
forAbsolutePageNumber: pageNum + 1,
allowSynchronousLoad: false
)
if let resolvedPage = runtime.pageResolver.resolvePage(absolutePageIndex: pageNum) {
var resolvedPage = runtime.pageResolver.resolvePage(absolutePageIndex: pageNum)
let shouldAllowSynchronousFallback = (
readerView.currentPage < 0 || readerView.currentPage == pageNum
) && !readerView.isPageCurlTransitioning
if resolvedPage == nil, shouldAllowSynchronousFallback {
RDEPUBBackgroundTrace.log(
"LoadingPage",
"sync fallback requested page=\(pageNum + 1) currentPage=\(readerView.currentPage + 1) totalKnown=\(pageCountOfReaderView(readerView: readerView))"
)
_ = runtime.prepareOnDemandChapter(
forAbsolutePageNumber: pageNum + 1,
allowSynchronousLoad: true
)
resolvedPage = runtime.pageResolver.resolvePage(absolutePageIndex: pageNum)
}
if let resolvedPage {
let contentView = (containerView as? RDEPUBTextContentView) ?? RDEPUBTextContentView()
contentView.delegate = self
readerView.registerSelectionGestureDependenciesIfNeeded(for: contentView)
@ -69,6 +84,10 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderPageProvider, RDRe
guard let readerView, let contentView else { return }
readerView.updateSelectionPagingSuppression(for: contentView, isSuppressed: isSuppressed)
}
RDEPUBBackgroundTrace.log(
"LoadingPage",
"configureLoading page=\(pageNum + 1) currentPage=\(readerView.currentPage + 1) totalKnown=\(pageCountOfReaderView(readerView: readerView)) hasMap=\(readerContext.bookPageMap != nil)"
)
contentView.configureLoading(
pageNumber: pageNum + 1,
totalPages: pageCountOfReaderView(readerView: readerView),
@ -341,9 +360,24 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderPageProvider, RDRe
guard !isRepaginating else { return }
let previousCurrentPage = readerView.currentPage
let wasPageCurlTransitioning = readerView.isPageCurlTransitioning
runtime.applyPendingFullPageMapIfNeeded()
if wasPageCurlTransitioning {
DispatchQueue.main.async { [weak self, weak readerView] in
guard let self, let readerView, !readerView.isPageCurlTransitioning else { return }
self.runtime.applyPendingFullPageMapIfNeeded()
}
}
let effectivePageNum = readerView.currentPage >= 0 ? readerView.currentPage : pageNum
RDEPUBBackgroundTrace.log(
"PageEvent",
"pageNum callback raw=\(pageNum + 1) effective=\(effectivePageNum + 1) previousCurrent=\(previousCurrentPage + 1) readerCurrent=\(readerView.currentPage + 1) totalPages=\(pageCountOfReaderView(readerView: readerView)) display=\(readerView.currentDisplayType)"
)
if previousCurrentPage != readerView.currentPage, effectivePageNum != pageNum {
RDEPUBBackgroundTrace.log(
"PageEvent",
"pageNum callback aborted dueToCurrentPageMutation raw=\(pageNum + 1) effective=\(effectivePageNum + 1)"
)
return
}

View File

@ -0,0 +1,35 @@
import UIKit
extension RDEPUBReaderController {
func openExternalURLIfAllowed(_ url: URL) {
guard shouldAllowExternalURL(url) else { return }
if configuration.requiresExternalLinkConfirmation {
presentExternalLinkConfirmation(for: url)
} else {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
}
}
private func shouldAllowExternalURL(_ url: URL) -> Bool {
guard let scheme = url.scheme?.lowercased() else { return false }
if delegate?.epubReader(self, shouldOpenExternalURL: url) == false {
return false
}
return configuration.allowedExternalURLSchemes.contains(scheme)
}
private func presentExternalLinkConfirmation(for url: URL) {
let alert = UIAlertController(
title: "打开外部链接",
message: url.absoluteString,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "打开", style: .default) { _ in
UIApplication.shared.open(url, options: [:], completionHandler: nil)
})
present(alert, animated: true)
}
}

View File

@ -0,0 +1,223 @@
import UIKit
extension RDEPUBReaderController {
func pageNumber(for location: RDEPUBLocation) -> Int? {
if let publication,
let bookPageMap = readerContext.bookPageMap,
let spineIndex = readerContext.normalizedSpineIndex(for: location),
let entry = bookPageMap.entry(forSpineIndex: spineIndex) {
let normalizedLocation = publication.resourceResolver.normalizedLocation(
location,
relativeToSpineIndex: nil,
bookIdentifier: currentBookIdentifier
) ?? location
let localPageIndex = resolvedLocalPageIndex(
for: normalizedLocation,
spineIndex: spineIndex,
fallbackEntry: entry
) ?? fallbackLocalPageIndex(for: normalizedLocation, pageCount: entry.pageCount)
return bookPageMap.absolutePageIndex(
spineIndex: spineIndex,
localPageIndex: min(max(localPageIndex, 0), max(entry.pageCount - 1, 0))
).map { $0 + 1 }
}
if let textBook, let publication {
if let anchor = location.rangeAnchor?.start {
if let page = textBook.indexTable.pageNumber(for: anchor, in: textBook) {
return page + 1
}
}
let normalizedLocation = publication.resourceResolver.normalizedLocation(
location,
relativeToSpineIndex: nil,
bookIdentifier: currentBookIdentifier
) ?? location
return textBook.pageNumber(
for: normalizedLocation,
resolver: publication.resourceResolver,
bookIdentifier: currentBookIdentifier
)
}
return readingSession?.queueNavigation(
to: location,
relativeToSpineIndex: nil,
bookIdentifier: currentBookIdentifier
)
}
func resolvedTextLocation(forPageNumber pageNumber: Int) -> RDEPUBLocation? {
if let resolvedPage = resolvedRuntimePage(forPageNumber: pageNumber) {
let startOffset = resolvedPage.page.pageStartOffset
let endOffset = max(startOffset, resolvedPage.page.pageEndOffset)
let chapterData = makeRuntimeChapterData(from: resolvedPage)
let location = chapterData.location(
for: NSRange(location: startOffset, length: max(endOffset - startOffset + 1, 1)),
bookIdentifier: currentBookIdentifier
)
if let publication {
return publication.resourceResolver.normalizedLocation(
location,
relativeToSpineIndex: nil,
bookIdentifier: currentBookIdentifier
) ?? location
}
return location
}
guard let textBook,
let page = textBook.page(at: pageNumber) else {
return nil
}
let location = textBook.chapterData(forPageNumber: pageNumber)?.location(forPage: page, bookIdentifier: currentBookIdentifier)
?? textBook.location(forPageNumber: pageNumber, bookIdentifier: currentBookIdentifier)
guard let location else { return nil }
if let publication {
return publication.resourceResolver.normalizedLocation(
location,
relativeToSpineIndex: nil,
bookIdentifier: currentBookIdentifier
) ?? location
}
return location
}
func synchronizeTextReadingState(pageNumber: Int, location: RDEPUBLocation) {
if let resolvedPage = resolvedRuntimePage(forPageNumber: pageNumber) {
readingSession?.updateReadingContext(
pageNumber: pageNumber,
location: location,
spineIndex: resolvedPage.page.spineIndex,
chapterIndex: resolvedPage.chapterIndex,
bookIdentifier: currentBookIdentifier
)
return
}
guard let textBook,
let page = textBook.page(at: pageNumber) else {
readingSession?.transition(to: .idle)
return
}
readingSession?.updateReadingContext(
pageNumber: pageNumber,
location: location,
spineIndex: page.spineIndex,
chapterIndex: page.chapterIndex,
bookIdentifier: currentBookIdentifier
)
}
func nativeTextSnapshot(from textBook: RDEPUBTextBook) -> RDEPUBNativeTextSnapshot {
let chapters = textBook.chapterInfos
let pages = textBook.pages.map {
EPUBPage(
spineIndex: $0.spineIndex,
chapterIndex: $0.chapterIndex,
pageIndexInChapter: $0.pageIndexInChapter,
totalPagesInChapter: $0.totalPagesInChapter,
chapterTitle: $0.chapterTitle,
fixedSpread: nil
)
}
return (pages, chapters)
}
func chapterOffset(for location: RDEPUBLocation, fallbackEntry: RDEPUBBookPageMapEntry) -> Int {
if let spineIndex = readerContext.normalizedSpineIndex(for: location),
let runtimeChapter = runtime.chapterRuntimeStore.chapterData(for: spineIndex),
let cfi = primaryLocationCFI(for: location),
let offset = runtimeChapter.chapterOffsetMap.chapterOffset(forCFI: cfi) {
return offset
}
if let rawCFI = primaryLocationCFI(for: location),
let cfi = RDEPUBCFICompatibility.parseLossy(rawCFI),
let cfiOffset = RDEPUBCFIResolver.resolve(cfi).chapterOffset {
return cfiOffset
}
if let anchor = location.rangeAnchor?.start {
return anchor.chapterOffset
}
if let fragment = location.fragment,
let offset = fallbackEntry.fragmentOffsets[fragment] {
return offset
}
return 0
}
func fallbackLocalPageIndex(for location: RDEPUBLocation, pageCount: Int) -> Int {
guard pageCount > 1 else { return 0 }
return min(
pageCount - 1,
max(0, Int(round(location.navigationProgression * Double(pageCount - 1))))
)
}
private func resolvedRuntimePage(forPageNumber pageNumber: Int) -> RDEPUBResolvedPage? {
runtime.pageResolver.resolvePage(absolutePageIndex: pageNumber - 1)
}
private func resolvedLocalPageIndex(
for location: RDEPUBLocation,
spineIndex: Int,
fallbackEntry: RDEPUBBookPageMapEntry
) -> Int? {
let offset = chapterOffset(for: location, fallbackEntry: fallbackEntry)
if let runtimeChapter = runtime.chapterRuntimeStore.chapterData(for: spineIndex),
let pageIndex = runtimeChapter.chapterOffsetMap.pageIndex(forChapterOffset: offset) {
return pageIndex
}
if let summary = readerContext.chapterSummary(forSpineIndex: spineIndex) {
return summary.pageRanges.firstIndex {
let range = $0.nsRange
return offset >= range.location && offset <= max(range.location + range.length - 1, range.location)
}
}
return nil
}
private func primaryLocationCFI(for location: RDEPUBLocation) -> String? {
if let rangeCFI = RDEPUBCFICompatibility.parseRangeLossy(location.rangeCFI) {
return rangeCFI.start.rawValue
}
return location.cfi
}
private func nearestFragmentID(beforeOrAt offset: Int, fragmentOffsets: [String: Int]) -> String? {
var bestID: String?
var bestOffset = Int.min
for (fragmentID, fragmentOffset) in fragmentOffsets where fragmentOffset <= offset && fragmentOffset > bestOffset {
bestOffset = fragmentOffset
bestID = fragmentID
}
return bestID
}
private func makeRuntimeChapterData(from resolvedPage: RDEPUBResolvedPage) -> RDEPUBChapterData {
let textChapter = RDEPUBTextChapter(
chapterIndex: resolvedPage.chapterIndex,
spineIndex: resolvedPage.chapter.spineIndex,
href: resolvedPage.chapter.href,
title: resolvedPage.chapter.title,
attributedContent: resolvedPage.chapter.typesetAttributedString,
fragmentOffsets: resolvedPage.chapter.chapterOffsetMap.fragmentOffsets,
cfiMap: resolvedPage.chapter.chapterOffsetMap.cfiMap,
pageBreakReasons: resolvedPage.chapter.pages.map(\.metadata.breakReason),
pages: resolvedPage.chapter.pages
)
return RDEPUBChapterData(
chapter: textChapter,
indexTable: RDEPUBTextIndexTable(chapters: [textChapter])
)
}
}

View File

@ -99,7 +99,7 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
override func apply(theme: RDEPUBReaderTheme) {
super.apply(theme: theme)
let isDarkBackground = theme.contentBackgroundColor.rd_searchIsDarkBackground
let isDarkBackground = theme.contentBackgroundColor.rd_isDarkBackground
let overlayColor = isDarkBackground
? UIColor(white: 0.12, alpha: 0.92)
@ -422,21 +422,6 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
}
}
private extension UIColor {
var rd_searchIsDarkBackground: Bool {
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var alpha: CGFloat = 0
guard getRed(&red, green: &green, blue: &blue, alpha: &alpha) else {
return false
}
let luminance = (0.299 * red) + (0.587 * green) + (0.114 * blue)
return luminance < 0.5
}
}
extension RDEPUBReaderSearchBarView: UITableViewDataSource, UITableViewDelegate {
func numberOfSections(in tableView: UITableView) -> Int {

View File

@ -2,10 +2,21 @@ import Foundation
final class RDEPUBChapterLoader {
private unowned let context: RDEPUBReaderContext
private typealias LoadCompletion = (Result<RDEPUBRuntimeChapter, Error>) -> Void
private struct PendingLoad {
var priority: LoadPriority
var completions: [LoadCompletion]
}
private weak var context: RDEPUBReaderContext?
private var summaryDiskCache: RDEPUBChapterSummaryDiskCache?
private let pendingLoadsLock = NSLock()
private var pendingLoads: [Int: PendingLoad] = [:]
var onDeferredCFIMapReady: ((Int) -> Void)?
init(context: RDEPUBReaderContext) {
@ -25,31 +36,70 @@ final class RDEPUBChapterLoader {
case prefetch
}
private enum LoadRegistrationResult {
case created
case joined(existingPriority: LoadPriority, effectivePriority: LoadPriority)
}
func loadChapter(
spineIndex: Int,
store: RDEPUBChapterRuntimeStore,
priority: LoadPriority = .navigation,
completion: @escaping (Result<RDEPUBRuntimeChapter, Error>) -> Void
) {
RDEPUBBackgroundTrace.log("ChapterLoader", "request spine=\(spineIndex) priority=\(priority)")
if let cached = store.chapterData(for: spineIndex) {
RDEPUBBackgroundTrace.log("ChapterLoader", "cache hit spine=\(spineIndex) priority=\(priority)")
scheduleDeferredCFIMapBuildIfNeeded(
for: cached,
cacheKey: makeCacheKey(spineIndex: spineIndex),
store: store
)
if let context {
scheduleDeferredCFIMapBuildIfNeeded(
for: cached,
cacheKey: makeCacheKey(spineIndex: spineIndex, context: context),
store: store
)
}
DispatchQueue.main.async {
completion(.success(cached))
}
return
}
store.markBuilding(true)
let completionOnMain: LoadCompletion = { result in
DispatchQueue.main.async {
completion(result)
}
}
store.chapterLoadQueue.async {
RDEPUBBackgroundTrace.log("ChapterLoader", "queue start spine=\(spineIndex) priority=\(priority)")
let cacheKey = self.makeCacheKey(spineIndex: spineIndex)
let registration = registerPendingLoad(
spineIndex: spineIndex,
priority: priority,
completion: completionOnMain
)
switch registration {
case .joined(let existingPriority, let effectivePriority):
RDEPUBBackgroundTrace.log(
"ChapterLoader",
"dedupe spine=\(spineIndex) existingPriority=\(existingPriority) requestedPriority=\(priority) effectivePriority=\(effectivePriority)"
)
return
case .created:
break
}
store.markBuilding(true)
_ = store.beginPendingChapterLoad(for: spineIndex)
store.chapterLoadQueue.async { [self] in
guard let context = self.context else {
store.endPendingChapterLoad(for: spineIndex)
store.markBuilding(false)
self.resolvePendingLoad(spineIndex: spineIndex, result: .failure(RDEPUBChapterLoadError.missingParser))
return
}
let queuePriority = self.pendingPriority(for: spineIndex) ?? priority
RDEPUBBackgroundTrace.log("ChapterLoader", "queue start spine=\(spineIndex) priority=\(queuePriority)")
let cacheKey = self.makeCacheKey(spineIndex: spineIndex, context: context)
let precomputedPageRanges = store.pageCount(for: cacheKey)?.pageRanges
let diskSummary: RDEPUBChapterSummary?
@ -71,12 +121,13 @@ final class RDEPUBChapterLoader {
let chapter = try RDEPUBBackgroundTrace.measure(
"ChapterLoader",
"buildChapter spine=\(spineIndex) priority=\(priority) cachedRanges=\(availablePageRanges?.count ?? 0)"
"buildChapter spine=\(spineIndex) priority=\(queuePriority) cachedRanges=\(availablePageRanges?.count ?? 0)"
) {
try self.buildChapter(
spineIndex: spineIndex,
availablePageRanges: availablePageRanges,
diskSummary: diskSummary
diskSummary: diskSummary,
context: context
)
}
RDEPUBBackgroundTrace.log("ChapterLoader", "buildChapter OK: spine=\(spineIndex) pages=\(chapter.pages.count)")
@ -96,42 +147,38 @@ final class RDEPUBChapterLoader {
store: store
)
switch priority {
let effectivePriority = self.pendingPriority(for: spineIndex) ?? queuePriority
store.endPendingChapterLoad(for: spineIndex)
switch effectivePriority {
case .navigation:
let nextTarget = store.consumeNavigationTarget()
if let target = nextTarget, target != spineIndex {
store.markBuilding(false)
self.loadChapter(spineIndex: target, store: store, priority: .navigation, completion: completion)
self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter))
self.loadChapter(spineIndex: target, store: store, priority: .navigation, completion: { _ in })
return
}
store.markBuilding(false)
DispatchQueue.main.async {
completion(.success(chapter))
}
self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter))
case .preview:
store.markBuilding(false)
DispatchQueue.main.async {
completion(.success(chapter))
}
self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter))
case .prefetch:
store.removePrefetchTarget(spineIndex)
store.markBuilding(false)
DispatchQueue.main.async {
completion(.success(chapter))
}
self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter))
}
} catch {
RDEPUBBackgroundTrace.log("ChapterLoader", "buildChapter FAILED: spine=\(spineIndex) error=\(error)")
store.endPendingChapterLoad(for: spineIndex)
store.markBuilding(false)
DispatchQueue.main.async {
completion(.failure(error))
}
self.resolvePendingLoad(spineIndex: spineIndex, result: .failure(error))
}
}
}
@ -140,11 +187,15 @@ final class RDEPUBChapterLoader {
spineIndex: Int,
store: RDEPUBChapterRuntimeStore?
) throws -> RDEPUBRuntimeChapter {
guard let context else {
throw RDEPUBChapterLoadError.missingParser
}
if let cached = store?.chapterData(for: spineIndex) {
if let store {
scheduleDeferredCFIMapBuildIfNeeded(
for: cached,
cacheKey: makeCacheKey(spineIndex: spineIndex),
cacheKey: makeCacheKey(spineIndex: spineIndex, context: context),
store: store
)
}
@ -155,6 +206,26 @@ final class RDEPUBChapterLoader {
throw RDEPUBChapterLoadError.missingParser
}
if store.hasPendingChapterLoad(for: spineIndex) {
var result: Result<RDEPUBRuntimeChapter, Error>?
let semaphore = DispatchSemaphore(value: 0)
let registration = registerPendingLoad(
spineIndex: spineIndex,
priority: .navigation
) { pendingResult in
result = pendingResult
semaphore.signal()
}
if case .joined(let existingPriority, let effectivePriority) = registration {
RDEPUBBackgroundTrace.log(
"ChapterLoader",
"sync join spine=\(spineIndex) existingPriority=\(existingPriority) effectivePriority=\(effectivePriority)"
)
semaphore.wait()
return try result!.get()
}
}
store.assertNotOnChapterLoadQueue()
var result: Result<RDEPUBRuntimeChapter, Error>?
@ -167,7 +238,7 @@ final class RDEPUBChapterLoader {
"sync buildChapter spine=\(spineIndex)"
) {
try autoreleasepool { () -> Result<RDEPUBRuntimeChapter, Error> in
let cacheKey = self.makeCacheKey(spineIndex: spineIndex)
let cacheKey = self.makeCacheKey(spineIndex: spineIndex, context: context)
let precomputedPageRanges = store.pageCount(for: cacheKey)?.pageRanges
let diskSummary: RDEPUBChapterSummary?
if precomputedPageRanges == nil {
@ -178,7 +249,8 @@ final class RDEPUBChapterLoader {
let chapter = try self.buildChapter(
spineIndex: spineIndex,
availablePageRanges: precomputedPageRanges ?? diskSummary?.pageRanges.map(\.nsRange),
diskSummary: diskSummary
diskSummary: diskSummary,
context: context
)
store.insertChapter(chapter)
let pageCount = RDEPUBRuntimePageCount(
@ -206,10 +278,48 @@ final class RDEPUBChapterLoader {
return try result!.get()
}
private func registerPendingLoad(
spineIndex: Int,
priority: LoadPriority,
completion: @escaping LoadCompletion
) -> LoadRegistrationResult {
pendingLoadsLock.lock()
defer { pendingLoadsLock.unlock() }
if var pending = pendingLoads[spineIndex] {
let existingPriority = pending.priority
pending.priority = LoadPriority.higherPriority(existingPriority, priority)
pending.completions.append(completion)
pendingLoads[spineIndex] = pending
return .joined(existingPriority: existingPriority, effectivePriority: pending.priority)
}
pendingLoads[spineIndex] = PendingLoad(priority: priority, completions: [completion])
return .created
}
private func pendingPriority(for spineIndex: Int) -> LoadPriority? {
pendingLoadsLock.lock()
let priority = pendingLoads[spineIndex]?.priority
pendingLoadsLock.unlock()
return priority
}
private func resolvePendingLoad(spineIndex: Int, result: Result<RDEPUBRuntimeChapter, Error>) {
pendingLoadsLock.lock()
let completions = pendingLoads.removeValue(forKey: spineIndex)?.completions ?? []
pendingLoadsLock.unlock()
guard !completions.isEmpty else { return }
completions.forEach { $0(result) }
}
private func buildChapter(
spineIndex: Int,
availablePageRanges: [NSRange]?,
diskSummary: RDEPUBChapterSummary? = nil
diskSummary: RDEPUBChapterSummary? = nil,
context: RDEPUBReaderContext
) throws -> RDEPUBRuntimeChapter {
guard let parser = context.parser,
let publication = context.publication else {
@ -231,7 +341,8 @@ final class RDEPUBChapterLoader {
pageSize: pageSize,
style: style,
layoutConfig: layoutConfig,
diskSummary: diskSummary
diskSummary: diskSummary,
context: context
)
}
@ -251,7 +362,8 @@ final class RDEPUBChapterLoader {
from: result.chapter,
spineIndex: spineIndex,
pageSize: pageSize,
layoutConfig: layoutConfig
layoutConfig: layoutConfig,
context: context
)
}
@ -263,7 +375,8 @@ final class RDEPUBChapterLoader {
pageSize: CGSize,
style: RDEPUBTextRenderStyle,
layoutConfig: RDEPUBTextLayoutConfig,
diskSummary: RDEPUBChapterSummary? = nil
diskSummary: RDEPUBChapterSummary? = nil,
context: RDEPUBReaderContext
) throws -> RDEPUBRuntimeChapter {
let spineItem = publication.spine[spineIndex]
let href = spineItem.href
@ -465,7 +578,8 @@ final class RDEPUBChapterLoader {
from chapter: RDEPUBTextChapter,
spineIndex: Int,
pageSize: CGSize,
layoutConfig: RDEPUBTextLayoutConfig
layoutConfig: RDEPUBTextLayoutConfig,
context: RDEPUBReaderContext
) throws -> RDEPUBRuntimeChapter {
let layouter = RDEPUBTextLayouter(
attributedString: chapter.attributedContent,
@ -482,7 +596,7 @@ final class RDEPUBChapterLoader {
)
let pageRanges = chapter.pages.map { $0.contentRange }
let cacheKey = makeCacheKey(spineIndex: spineIndex)
let cacheKey = makeCacheKey(spineIndex: spineIndex, context: context)
summaryDiskCache?.write(summary: makeSummary(for: chapter.pages, fragmentOffsets: chapter.fragmentOffsets, offsetMap: offsetMap, cacheKey: cacheKey), for: cacheKey)
return RDEPUBRuntimeChapter(
@ -498,7 +612,7 @@ final class RDEPUBChapterLoader {
)
}
private func makeCacheKey(spineIndex: Int) -> RDEPUBChapterCacheKey {
private func makeCacheKey(spineIndex: Int, context: RDEPUBReaderContext) -> RDEPUBChapterCacheKey {
let style = context.currentTextRenderStyle()
let layoutConfig = context.currentTextLayoutConfig(pageSize: context.currentTextPageSize())
@ -513,7 +627,7 @@ final class RDEPUBChapterLoader {
"\(RDEPUBChapterSummary.currentSchemaVersion)"
].joined(separator: "|")
let contentHash = contentHashForSpineIndex(spineIndex)
let contentHash = contentHashForSpineIndex(spineIndex, context: context)
return RDEPUBChapterCacheKey(
bookID: context.currentBookIdentifier ?? "",
@ -540,7 +654,7 @@ final class RDEPUBChapterLoader {
store.chapterLoadQueue.async {
defer { store.endBuildingCFIMap(for: spineIndex) }
guard let rawHTML = self.context.parser?.htmlString(forRelativePath: href),
guard let rawHTML = self.context?.parser?.htmlString(forRelativePath: href),
let chapterText else {
return
}
@ -586,12 +700,12 @@ final class RDEPUBChapterLoader {
)
}
private func contentHashForSpineIndex(_ spineIndex: Int) -> String {
private func contentHashForSpineIndex(_ spineIndex: Int, context: RDEPUBReaderContext) -> String {
guard let parser = context.parser,
let publication = context.publication else { return "" }
let href = publication.spine[spineIndex].href
guard let html = parser.htmlString(forRelativePath: href) else { return "" }
return html.sha256Hex
return html.rd_sha256Hex
}
private func requireHTMLString(_ parser: RDEPUBParser, href: String) throws -> String {
@ -638,13 +752,34 @@ final class RDEPUBChapterLoader {
markers: markers,
recoveryMetadata: RDEPUBCFIRecoveryMetadata(
domFingerprint: "",
normalizedTextChecksum: RDEPUBCFITextNodeMapBuilder.normalizedText(from: chapterText).sha256Hex,
normalizedTextChecksum: RDEPUBCFITextNodeMapBuilder.normalizedText(from: chapterText).rd_sha256Hex,
fragmentPathMap: domPaths
)
)
}
}
private extension RDEPUBChapterLoader.LoadPriority {
static func higherPriority(_ lhs: Self, _ rhs: Self) -> Self {
if lhs.rank >= rhs.rank {
return lhs
}
return rhs
}
var rank: Int {
switch self {
case .prefetch:
return 0
case .preview:
return 1
case .navigation:
return 2
}
}
}
enum RDEPUBChapterLoadError: LocalizedError {
case missingParser

View File

@ -32,6 +32,10 @@ final class RDEPUBChapterRuntimeStore {
private let cfiMapLock = NSLock()
private var pendingChapterLoadSpineIndices: Set<Int> = []
private let pendingChapterLoadLock = NSLock()
init() {
imageCache.countLimit = 50
@ -146,6 +150,25 @@ final class RDEPUBChapterRuntimeStore {
buildingLock.unlock()
}
func beginPendingChapterLoad(for spineIndex: Int) -> Bool {
pendingChapterLoadLock.lock()
defer { pendingChapterLoadLock.unlock() }
return pendingChapterLoadSpineIndices.insert(spineIndex).inserted
}
func endPendingChapterLoad(for spineIndex: Int) {
pendingChapterLoadLock.lock()
pendingChapterLoadSpineIndices.remove(spineIndex)
pendingChapterLoadLock.unlock()
}
func hasPendingChapterLoad(for spineIndex: Int) -> Bool {
pendingChapterLoadLock.lock()
let hasPendingLoad = pendingChapterLoadSpineIndices.contains(spineIndex)
pendingChapterLoadLock.unlock()
return hasPendingLoad
}
func beginBuildingCFIMap(for spineIndex: Int) -> Bool {
cfiMapLock.lock()
defer { cfiMapLock.unlock() }
@ -166,5 +189,8 @@ final class RDEPUBChapterRuntimeStore {
cfiMapLock.lock()
buildingCFIMapSpineIndices.removeAll()
cfiMapLock.unlock()
pendingChapterLoadLock.lock()
pendingChapterLoadSpineIndices.removeAll()
pendingChapterLoadLock.unlock()
}
}

View File

@ -119,7 +119,7 @@ final class RDEPUBChapterSummaryDiskCache {
let bookPrefix = Self.cacheNamespacePrefix(for: key.bookID)
let renderPrefix = Self.cacheNamespacePrefix(for: key.renderSignature)
let rawKey = "\(key.bookID)_\(key.spineIndex)_\(key.renderSignature)_\(key.chapterContentHash)"
let digest = rawKey.sha256Hex
let digest = rawKey.rd_sha256Hex
return cacheDirectory.appendingPathComponent("\(bookPrefix)__\(renderPrefix)__\(digest).json")
}
@ -150,7 +150,7 @@ final class RDEPUBChapterSummaryDiskCache {
}
private static func cacheNamespacePrefix(for rawValue: String) -> String {
rawValue.sha256Hex.prefix(12).lowercased()
rawValue.rd_sha256Hex.prefix(12).lowercased()
}
}

View File

@ -24,6 +24,14 @@ final class RDEPUBChapterWarmupOrchestrator {
private var isExtendingPartialBookPageMap = false
private let prepareRequestStateLock = NSLock()
private var pendingPreparePageNumbers: Set<Int> = []
private var recentPrepareTimestamps: [Int: CFAbsoluteTime] = [:]
private let prepareRequestDebounceInterval: CFTimeInterval = 0.15
init(
context: RDEPUBReaderContext,
store: RDEPUBChapterRuntimeStore,
@ -58,6 +66,16 @@ final class RDEPUBChapterWarmupOrchestrator {
guard let spineIndex = bookPageMap.spineIndex(forAbsolutePage: absolutePageIndex) else {
return false
}
let chapterReady = store.chapterData(for: spineIndex) != nil
if let debouncedResult = debouncedPrepareResult(
pageNumber: pageNumber,
spineIndex: spineIndex,
chapterReady: chapterReady,
allowSynchronousLoad: allowSynchronousLoad
) {
return debouncedResult
}
store.setCurrentChapter(
spineIndex: spineIndex,
@ -70,7 +88,7 @@ final class RDEPUBChapterWarmupOrchestrator {
"prepareOnDemandChapter page=\(pageNumber) absoluteIndex=\(absolutePageIndex) spine=\(spineIndex)"
)
if store.chapterData(for: spineIndex) == nil {
if !chapterReady {
guard allowSynchronousLoad else {
scheduleAsynchronousChapterPreparation(
spineIndex: spineIndex,
@ -85,11 +103,13 @@ final class RDEPUBChapterWarmupOrchestrator {
store: store
)
} catch {
clearPendingPreparePageNumber(pageNumber)
RDEPUBBackgroundTrace.log("Runtime", "prepareOnDemandChapter FAILED: spine=\(spineIndex) error=\(error)")
return false
}
}
markPrepareResolved(pageNumber)
presentationRuntime.navigationStateMachine.transition(to: .presentingWindow)
completion?(true)
maybePrefetchUpcomingChapters(aroundAbsolutePageNumber: pageNumber, in: bookPageMap)
@ -189,6 +209,7 @@ final class RDEPUBChapterWarmupOrchestrator {
appendLoadedForwardChaptersToCurrentPageMapIfPossible()
continue
}
guard shouldSchedulePrefetch(for: spineIndex) else { continue }
store.addPrefetchTarget(spineIndex)
RDEPUBBackgroundTrace.log("Runtime", "initial open prefetch forward spine=\(spineIndex)")
@ -226,9 +247,10 @@ final class RDEPUBChapterWarmupOrchestrator {
return true
}
if let pendingMap = context.pendingFullPageMap,
pendingMap.entry(forSpineIndex: targetSpineIndex) != nil {
presentationRuntime.applyPendingFullPageMapIfNeeded()
if context.pendingPageMapUpdates.contains(where: { update in
update.pageMap.entry(forSpineIndex: targetSpineIndex) != nil
}) {
presentationRuntime.commitPendingPageMapUpdateIfNeeded()
if context.bookPageMap?.entry(forSpineIndex: targetSpineIndex) != nil {
if isDistantJump {
jumpSessionManager.createSession(
@ -284,6 +306,10 @@ final class RDEPUBChapterWarmupOrchestrator {
asynchronouslyPreparingSpineIndices.removeAll()
isExtendingPartialBookPageMap = false
asyncLoadStateLock.unlock()
prepareRequestStateLock.lock()
pendingPreparePageNumbers.removeAll()
recentPrepareTimestamps.removeAll()
prepareRequestStateLock.unlock()
}
private func applyAsyncPartialBookPageMapExtension(
@ -337,7 +363,7 @@ final class RDEPUBChapterWarmupOrchestrator {
"Runtime",
"extendPartialBookPageMap applied chapters=\(newMap.totalChapters) pages=\(newMap.totalPages)"
)
presentationRuntime.applyExtendedPartialPageMap(
presentationRuntime.queueExtendedPartialPageMap(
newMap,
currentPageNumber: currentPageNumber,
currentLocation: currentLocation
@ -349,7 +375,13 @@ final class RDEPUBChapterWarmupOrchestrator {
triggerPageNumber: Int,
completion: ((Bool) -> Void)?
) {
guard beginAsynchronousChapterPreparation(for: spineIndex) else { return }
guard beginAsynchronousChapterPreparation(for: spineIndex) else {
RDEPUBBackgroundTrace.log(
"Runtime",
"prepareOnDemandChapter async deduped spine=\(spineIndex) page=\(triggerPageNumber)"
)
return
}
RDEPUBBackgroundTrace.log(
"Runtime",
"prepareOnDemandChapter async spine=\(spineIndex) page=\(triggerPageNumber)"
@ -363,11 +395,13 @@ final class RDEPUBChapterWarmupOrchestrator {
self.endAsynchronousChapterPreparation(for: spineIndex)
switch result {
case .success:
self.markPrepareResolved(triggerPageNumber)
self.presentationRuntime.navigationStateMachine.transition(to: .presentingWindow)
completion?(true)
self.appendLoadedForwardChaptersToCurrentPageMapIfPossible()
self.refreshVisibleContentIfNeeded(afterPreparing: spineIndex, triggerPageNumber: triggerPageNumber)
case .failure(let error):
self.clearPendingPreparePageNumber(triggerPageNumber)
RDEPUBBackgroundTrace.log(
"Runtime",
"prepareOnDemandChapter async FAILED: spine=\(spineIndex) error=\(error)"
@ -389,7 +423,7 @@ final class RDEPUBChapterWarmupOrchestrator {
}
for adjacentSpineIndex in store.windowSpineIndices where adjacentSpineIndex != spineIndex {
guard store.chapterData(for: adjacentSpineIndex) == nil else { continue }
guard shouldSchedulePrefetch(for: adjacentSpineIndex) else { continue }
store.addPrefetchTarget(adjacentSpineIndex)
RDEPUBBackgroundTrace.log(
"Runtime",
@ -428,7 +462,7 @@ final class RDEPUBChapterWarmupOrchestrator {
let targets = buildableIndices.dropFirst(currentPosition + 1).prefix(lookaheadChapterCount)
for targetSpineIndex in targets {
guard store.chapterData(for: targetSpineIndex) == nil else { continue }
guard shouldSchedulePrefetch(for: targetSpineIndex) else { continue }
store.addPrefetchTarget(targetSpineIndex)
RDEPUBBackgroundTrace.log(
"Runtime",
@ -441,7 +475,9 @@ final class RDEPUBChapterWarmupOrchestrator {
}
}
private func appendLoadedForwardChaptersToCurrentPageMapIfPossible() {
private func appendLoadedForwardChaptersToCurrentPageMapIfPossible(
minimumTrailingPages: Int = 2
) {
guard let publication = context.publication,
let currentMap = context.bookPageMap,
let readerView = context.readerView,
@ -449,8 +485,13 @@ final class RDEPUBChapterWarmupOrchestrator {
return
}
let currentPageNumber = max(readerView.currentPage + 1, 1)
let trailingPages = currentMap.totalPages - currentPageNumber
guard trailingPages <= minimumTrailingPages else { return }
let buildableIndices = buildableSpineIndices(in: publication)
var appendedEntries: [RDEPUBBookPageMapEntry] = []
var projectedTotalPages = currentMap.totalPages
for spineIndex in buildableIndices where spineIndex > lastKnownSpineIndex {
guard let chapter = store.chapterData(for: spineIndex) else { break }
appendedEntries.append(
@ -463,6 +504,10 @@ final class RDEPUBChapterWarmupOrchestrator {
fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets
)
)
projectedTotalPages += chapter.pages.count
if projectedTotalPages - currentPageNumber > minimumTrailingPages {
break
}
}
guard !appendedEntries.isEmpty else { return }
@ -497,12 +542,68 @@ final class RDEPUBChapterWarmupOrchestrator {
RDEPUBBackgroundTrace.log(
"Runtime",
"appendLoadedForwardChapters chapters=\(newMap.totalChapters) pages=\(newMap.totalPages)"
"appendLoadedForwardChapters currentPage=\(currentPageNumber) trailingBefore=\(trailingPages) trailingAfter=\(newMap.totalPages - currentPageNumber) chapters=\(newMap.totalChapters) pages=\(newMap.totalPages)"
)
context.bookPageMap = newMap
context.replaceActiveSnapshot(presentationRuntime.makeSnapshot(from: newMap))
readerView.reloadPageCountOnly()
presentationRuntime.queueForwardAppendedPageMap(newMap)
}
private func shouldSchedulePrefetch(for spineIndex: Int) -> Bool {
guard store.chapterData(for: spineIndex) == nil else { return false }
guard !store.hasPrefetchTarget(spineIndex) else { return false }
guard !store.hasPendingChapterLoad(for: spineIndex) else { return false }
return true
}
private func debouncedPrepareResult(
pageNumber: Int,
spineIndex: Int,
chapterReady: Bool,
allowSynchronousLoad: Bool
) -> Bool? {
prepareRequestStateLock.lock()
defer { prepareRequestStateLock.unlock() }
let now = CFAbsoluteTimeGetCurrent()
recentPrepareTimestamps = recentPrepareTimestamps.filter { now - $0.value <= prepareRequestDebounceInterval }
if !allowSynchronousLoad && !chapterReady {
let inserted = pendingPreparePageNumbers.insert(pageNumber).inserted
if !inserted {
RDEPUBBackgroundTrace.log(
"Runtime",
"prepareOnDemandChapter page deduped page=\(pageNumber) spine=\(spineIndex) chapterReady=false"
)
return false
}
return nil
}
pendingPreparePageNumbers.remove(pageNumber)
if let lastTimestamp = recentPrepareTimestamps[pageNumber],
now - lastTimestamp <= prepareRequestDebounceInterval {
RDEPUBBackgroundTrace.log(
"Runtime",
"prepareOnDemandChapter page debounced page=\(pageNumber) spine=\(spineIndex) chapterReady=\(chapterReady)"
)
return chapterReady
}
recentPrepareTimestamps[pageNumber] = now
return nil
}
private func markPrepareResolved(_ pageNumber: Int) {
prepareRequestStateLock.lock()
pendingPreparePageNumbers.remove(pageNumber)
recentPrepareTimestamps[pageNumber] = CFAbsoluteTimeGetCurrent()
prepareRequestStateLock.unlock()
}
private func clearPendingPreparePageNumber(_ pageNumber: Int) {
prepareRequestStateLock.lock()
pendingPreparePageNumbers.remove(pageNumber)
prepareRequestStateLock.unlock()
}
private func refreshVisibleContentIfNeeded(afterPreparing spineIndex: Int, triggerPageNumber: Int) {
@ -510,16 +611,41 @@ final class RDEPUBChapterWarmupOrchestrator {
let bookPageMap = context.bookPageMap else {
return
}
if readerView.isPageCurlTransitioning {
RDEPUBBackgroundTrace.log(
"LoadingPage",
"defer refreshVisibleContent spine=\(spineIndex) triggerPage=\(triggerPageNumber) reason=pageCurlTransition"
)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
self?.refreshVisibleContentIfNeeded(
afterPreparing: spineIndex,
triggerPageNumber: triggerPageNumber
)
}
return
}
let visiblePageNumber = readerView.currentPage + 1
if visiblePageNumber == triggerPageNumber {
RDEPUBBackgroundTrace.log(
"LoadingPage",
"refreshVisibleContent matchedTrigger spine=\(spineIndex) triggerPage=\(triggerPageNumber) visiblePage=\(visiblePageNumber)"
)
refreshVisibleContentPreservingLocation()
return
}
guard visiblePageNumber > 0,
let visibleSpineIndex = bookPageMap.spineIndex(forAbsolutePage: visiblePageNumber - 1),
visibleSpineIndex == spineIndex else {
RDEPUBBackgroundTrace.log(
"LoadingPage",
"skip refreshVisibleContent spine=\(spineIndex) triggerPage=\(triggerPageNumber) visiblePage=\(visiblePageNumber)"
)
return
}
RDEPUBBackgroundTrace.log(
"LoadingPage",
"refreshVisibleContent matchedVisibleSpine spine=\(spineIndex) triggerPage=\(triggerPageNumber) visiblePage=\(visiblePageNumber)"
)
refreshVisibleContentPreservingLocation()
}

View File

@ -1,11 +0,0 @@
import CryptoKit
extension String {
var sha256Hex: String {
let digest = SHA256.hash(data: Data(self.utf8))
return digest.map { String(format: "%02x", $0) }.joined()
}
}

View File

@ -0,0 +1,44 @@
import Foundation
final class RDEPUBMetadataParseCancellationController {
let token: UUID
private let lock = NSLock()
private weak var queue: OperationQueue?
private var cancelled = false
init(token: UUID) {
self.token = token
}
func attach(queue: OperationQueue) {
let shouldCancelImmediately: Bool
lock.lock()
self.queue = queue
shouldCancelImmediately = cancelled
lock.unlock()
if shouldCancelImmediately {
queue.cancelAllOperations()
}
}
func cancel() {
let queueToCancel: OperationQueue?
lock.lock()
cancelled = true
queueToCancel = queue
lock.unlock()
queueToCancel?.cancelAllOperations()
}
var isCancelled: Bool {
lock.lock()
let value = cancelled
lock.unlock()
return value
}
}

View File

@ -0,0 +1,572 @@
import Foundation
final class RDEPUBMetadataParseWorker {
private static let maxRetryCount = 3
private static let retryDelays: [TimeInterval] = [0.5, 2.0, 8.0]
private final class ParseState {
var summariesBySpineIndex: [Int: RDEPUBChapterSummary]
var totalResolvedCount: Int
var lastAppliedCount: Int
init(
summariesBySpineIndex: [Int: RDEPUBChapterSummary],
totalResolvedCount: Int,
lastAppliedCount: Int
) {
self.summariesBySpineIndex = summariesBySpineIndex
self.totalResolvedCount = totalResolvedCount
self.lastAppliedCount = lastAppliedCount
}
}
private let backgroundInteractionCooldown: CFAbsoluteTime = 0.8
unowned let context: RDEPUBReaderContext
let cancellationController: RDEPUBMetadataParseCancellationController
let pageMapRefreshInterval: Int
private let token: UUID
private let parser: RDEPUBParser
private let publication: RDEPUBPublication
private let pageSize: CGSize
private let layoutConfig: RDEPUBTextLayoutConfig
private let style: RDEPUBTextRenderStyle
private let renderSignature: String
private let allBuildableIndices: [Int]
private let summaryDiskCache: RDEPUBChapterSummaryDiskCache?
private let workerCount: Int
private let contentHashBySpineIndex: [Int: String]
private let catalog: [(key: RDEPUBChapterCacheKey, spineIndex: Int, href: String, title: String)]
init(
context: RDEPUBReaderContext,
cancellationController: RDEPUBMetadataParseCancellationController,
token: UUID,
parser: RDEPUBParser,
publication: RDEPUBPublication
) {
self.context = context
self.cancellationController = cancellationController
self.token = token
self.parser = parser
self.publication = publication
let pageSize = context.currentTextPageSize()
self.pageSize = pageSize
self.layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
self.style = context.currentTextRenderStyle()
self.renderSignature = context.currentRenderSignature()
self.allBuildableIndices = publication.spine.indices.filter { index in
guard publication.spine.indices.contains(index) else { return false }
let item = publication.spine[index]
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
}
self.summaryDiskCache = context.runtime?.summaryDiskCache
self.workerCount = max(1, context.configuration.metadataParsingConcurrency)
self.pageMapRefreshInterval = RDEPUBReaderPaginationCoordinator.pageMapRefreshInterval
var hashes: [Int: String] = [:]
for spineIndex in allBuildableIndices {
guard let href = publication.spine.indices.contains(spineIndex)
? publication.spine[spineIndex].href : nil,
let html = parser.htmlString(forRelativePath: href) else {
hashes[spineIndex] = ""
continue
}
hashes[spineIndex] = html.rd_sha256Hex
}
self.contentHashBySpineIndex = hashes
let ctx = context
let spine = publication.spine
let sig = renderSignature
self.catalog = allBuildableIndices.map { spineIndex in
let item = spine[spineIndex]
return (
key: ctx.chapterCacheKey(
forSpineIndex: spineIndex,
precomputedContentHash: hashes[spineIndex] ?? "",
renderSignature: sig
),
spineIndex: spineIndex,
href: item.href,
title: item.title
)
}
}
func start(token: UUID, restoreLocation: RDEPUBLocation?) {
let context = self.context
let cancellationController = self.cancellationController
DispatchQueue.global(qos: .utility).async { [weak self] in
guard let self else { return }
defer { self.context.runtime?.paginationCoordinator.finishMetadataParseCancellationController(cancellationController) }
guard context.controller != nil,
!cancellationController.isCancelled,
context.paginationToken == token else { return }
if let restoredPageMap = self.restoreBookPageMapIfPossible() {
RDEPUBBackgroundTrace.log(
"MetadataParse",
"full cache restore hit chapters=\(restoredPageMap.totalChapters) pages=\(restoredPageMap.totalPages)"
)
DispatchQueue.main.async {
guard context.paginationToken == token,
context.controller != nil,
!cancellationController.isCancelled else { return }
context.runtime?.refreshBookPageMapInPlace(restoredPageMap)
}
return
}
let prewarmStart = CFAbsoluteTimeGetCurrent()
let prewarmMs = Int((CFAbsoluteTimeGetCurrent() - prewarmStart) * 1000)
RDEPUBBackgroundTrace.log("MetadataParse", "prewarmHashMs=\(prewarmMs) chapters=\(self.allBuildableIndices.count)")
let restored = self.summaryDiskCache?.readAll(keys: self.catalog)
RDEPUBBackgroundTrace.log(
"MetadataParse",
"begin token=\(token.uuidString) buildableChapters=\(self.allBuildableIndices.count) concurrency=\(self.workerCount)"
)
let cachedSummaries = restored?.summaries ?? [:]
let cachedSpineIndices = Set(cachedSummaries.keys)
let resultLock = NSLock()
let parseState = ParseState(
summariesBySpineIndex: cachedSummaries,
totalResolvedCount: cachedSpineIndices.count,
lastAppliedCount: cachedSpineIndices.count
)
if !cachedSpineIndices.isEmpty {
RDEPUBBackgroundTrace.log(
"MetadataParse",
"resumeFromCache cachedChapters=\(cachedSpineIndices.count) total=\(self.allBuildableIndices.count)"
)
let cachedMap = self.buildPageMap(summaries: parseState.summariesBySpineIndex)
DispatchQueue.main.async {
guard context.paginationToken == token,
context.controller != nil else { return }
context.runtime?.refreshBookPageMapInPlace(cachedMap)
}
}
let prioritizedSpineIndices: [Int]
if let priorityManager = context.runtime?.backgroundPriorityManager {
let currentSpineIndex = context.runtime?.locationCoordinator.currentVisibleLocation()
.flatMap { context.normalizedSpineIndex(for: $0) }
prioritizedSpineIndices = priorityManager.makeMetadataPriorityOrder(
allBuildableIndices: self.allBuildableIndices,
currentSpineIndex: currentSpineIndex,
cachedSpineIndices: cachedSpineIndices
)
RDEPUBBackgroundTrace.log(
"MetadataParse",
"prioritized hot=\(prioritizedSpineIndices.prefix(10).count) total=\(prioritizedSpineIndices.count)"
)
} else {
prioritizedSpineIndices = self.allBuildableIndices.filter { !cachedSpineIndices.contains($0) }
}
let uncachedSpineIndices = prioritizedSpineIndices
self.waitForReadingInteractionToSettle(cancellationController: cancellationController)
guard !cancellationController.isCancelled,
context.controller != nil,
context.paginationToken == token else {
RDEPUBBackgroundTrace.log("MetadataParse", "abort before queue start")
return
}
let wallClockStart = CFAbsoluteTimeGetCurrent()
var totalRenderMs: Double = 0
var totalWriteMs: Double = 0
var totalMergeMs: Double = 0
var completedChapters = 0
var failedChapters = 0
let timingLock = NSLock()
let queue = OperationQueue()
queue.name = "com.rdreader.metadata.parse"
queue.qualityOfService = .utility
queue.maxConcurrentOperationCount = self.workerCount
cancellationController.attach(queue: queue)
let refreshInterval = self.pageMapRefreshInterval
for (offset, spineIndex) in uncachedSpineIndices.enumerated() {
let operation = BlockOperation()
operation.addExecutionBlock { [weak operation] in
guard context.controller != nil,
context.paginationToken == token,
!cancellationController.isCancelled,
operation?.isCancelled != true else {
return
}
do {
RDEPUBBackgroundTrace.log("MetadataParse", "正在解析 spine=\(spineIndex) \(offset + 1)/\(uncachedSpineIndices.count)")
let renderResult: RDEPUBChapterSummary? = try autoreleasepool { () -> RDEPUBChapterSummary? in
guard context.controller != nil,
context.paginationToken == token,
!cancellationController.isCancelled,
operation?.isCancelled != true else {
return nil
}
let chapterBuilder = context.makeTextBookBuilder(layoutConfig: self.layoutConfig)
let renderStart = CFAbsoluteTimeGetCurrent()
guard let result = try chapterBuilder.buildChapter(
parser: self.parser,
publication: self.publication,
spineIndex: spineIndex,
pageSize: self.pageSize,
style: self.style
) else {
return nil
}
guard context.controller != nil,
context.paginationToken == token,
!cancellationController.isCancelled,
operation?.isCancelled != true else {
RDEPUBBackgroundTrace.log("MetadataParse", "drop rendered chapter due to cancellation spine=\(spineIndex)")
return nil
}
let renderElapsed = (CFAbsoluteTimeGetCurrent() - renderStart) * 1000
let chapter = result.chapter
let precomputedHash = self.contentHashBySpineIndex[spineIndex] ?? ""
let cacheKey = context.chapterCacheKey(
forSpineIndex: spineIndex,
precomputedContentHash: precomputedHash,
renderSignature: self.renderSignature
)
let summary = RDEPUBChapterSummary(
pageRanges: chapter.pages.map { .init(location: $0.contentRange.location, length: $0.contentRange.length) },
pageCount: chapter.pages.count,
fragmentOffsets: chapter.fragmentOffsets,
cfiMap: chapter.cfiMap,
renderSignature: cacheKey.renderSignature,
schemaVersion: RDEPUBChapterSummary.currentSchemaVersion,
chapterContentHash: cacheKey.chapterContentHash,
pageMetadataList: chapter.pages.map { .from($0.metadata) }
)
guard context.controller != nil,
context.paginationToken == token,
!cancellationController.isCancelled,
operation?.isCancelled != true else {
RDEPUBBackgroundTrace.log("MetadataParse", "skip disk write due to cancellation spine=\(spineIndex)")
return nil
}
let writeStart = CFAbsoluteTimeGetCurrent()
self.summaryDiskCache?.write(summary: summary, for: cacheKey)
let writeElapsed = (CFAbsoluteTimeGetCurrent() - writeStart) * 1000
timingLock.lock()
totalRenderMs += renderElapsed
totalWriteMs += writeElapsed
completedChapters += 1
timingLock.unlock()
RDEPUBBackgroundTrace.log(
"MetadataParse",
"spine=\(spineIndex) renderMs=\(Int(renderElapsed)) writeMs=\(Int(writeElapsed))"
)
return summary
}
guard let renderResult else { return }
guard context.controller != nil,
context.paginationToken == token,
!cancellationController.isCancelled,
operation?.isCancelled != true else {
return
}
var snapshot: [Int: RDEPUBChapterSummary]?
resultLock.lock()
parseState.summariesBySpineIndex[spineIndex] = renderResult
parseState.totalResolvedCount += 1
if parseState.totalResolvedCount - parseState.lastAppliedCount >= refreshInterval
|| parseState.totalResolvedCount == self.allBuildableIndices.count {
parseState.lastAppliedCount = parseState.totalResolvedCount
snapshot = parseState.summariesBySpineIndex
}
resultLock.unlock()
if let snapshot {
let mergeStart = CFAbsoluteTimeGetCurrent()
let partialMap = self.buildPageMap(summaries: snapshot)
let mergeElapsed = (CFAbsoluteTimeGetCurrent() - mergeStart) * 1000
timingLock.lock()
totalMergeMs += mergeElapsed
timingLock.unlock()
DispatchQueue.main.async {
guard context.paginationToken == token,
context.controller != nil,
!cancellationController.isCancelled else { return }
context.runtime?.refreshBookPageMapInPlace(partialMap)
}
}
} catch {
guard !cancellationController.isCancelled,
context.paginationToken == token,
context.controller != nil,
operation?.isCancelled != true else {
return
}
timingLock.lock()
failedChapters += 1
timingLock.unlock()
RDEPUBBackgroundTrace.log("MetadataParse", "buildChapter FAILED: spine=\(spineIndex) error=\(error)")
self.scheduleRetry(
spineIndex: spineIndex,
retryCount: 0,
resultLock: resultLock,
parseState: parseState,
cancellationController: cancellationController
)
}
}
queue.addOperation(operation)
}
queue.waitUntilAllOperationsAreFinished()
if !cancellationController.isCancelled,
context.paginationToken == token,
context.controller != nil {
self.summaryDiskCache?.flushPendingWrites()
}
let wallClockMs = Int((CFAbsoluteTimeGetCurrent() - wallClockStart) * 1000)
timingLock.lock()
let renderTotal = Int(totalRenderMs)
let writeTotal = Int(totalWriteMs)
let mergeTotal = Int(totalMergeMs)
let rendered = completedChapters
let failed = failedChapters
timingLock.unlock()
let avgRenderMs = rendered > 0 ? renderTotal / rendered : 0
RDEPUBBackgroundTrace.log(
"MetadataParse",
"timing wallClockMs=\(wallClockMs) chapters=\(rendered) failed=\(failed) " +
"prewarmHashMs=\(prewarmMs) renderTotalMs=\(renderTotal) writeTotalMs=\(writeTotal) " +
"mergeTotalMs=\(mergeTotal) avgRenderMs=\(avgRenderMs) concurrency=\(self.workerCount)"
)
context.lastMetadataParseWallClockMs = wallClockMs
context.lastMetadataParseConcurrency = self.workerCount
guard context.controller != nil,
context.paginationToken == token,
!cancellationController.isCancelled else {
RDEPUBBackgroundTrace.log("MetadataParse", "abort: reader dismissed or token changed")
return
}
let finalMergeStart = CFAbsoluteTimeGetCurrent()
let pageMap = self.buildPageMap(summaries: parseState.summariesBySpineIndex)
let finalMergeMs = Int((CFAbsoluteTimeGetCurrent() - finalMergeStart) * 1000)
RDEPUBBackgroundTrace.log(
"MetadataParse",
"complete chapters=\(pageMap.totalChapters) pages=\(pageMap.totalPages) finalMergeMs=\(finalMergeMs)"
)
if let coverageStore = context.runtime?.backgroundCoverageStore {
let resolvedSpineIndices = Set(parseState.summariesBySpineIndex.keys)
let lowerSpine = resolvedSpineIndices.min() ?? 0
let upperSpine = resolvedSpineIndices.max() ?? 0
let estimatedBytes = 256 + pageMap.entries.count * 96 + resolvedSpineIndices.count * 16
let segment = RDEPUBBackgroundCoverageSegment(
lowerSpineIndex: lowerSpine,
upperSpineIndex: upperSpine,
pageMap: pageMap,
resolvedSpineIndices: resolvedSpineIndices,
generatedAt: CFAbsoluteTimeGetCurrent(),
renderSignature: self.renderSignature,
estimatedMemoryBytes: estimatedBytes
)
coverageStore.addSegment(segment)
}
DispatchQueue.main.async {
guard context.paginationToken == token,
context.controller != nil else { return }
context.runtime?.refreshBookPageMapInPlace(pageMap)
}
}
}
// MARK: - Private
private func waitForReadingInteractionToSettle(
cancellationController: RDEPUBMetadataParseCancellationController? = nil
) {
while context.controller != nil,
cancellationController?.isCancelled != true,
context.secondsSinceLastUserNavigation() < backgroundInteractionCooldown {
Thread.sleep(forTimeInterval: 0.08)
}
}
private func scheduleRetry(
spineIndex: Int,
retryCount: Int,
resultLock: NSLock,
parseState: ParseState,
cancellationController: RDEPUBMetadataParseCancellationController
) {
guard retryCount < Self.maxRetryCount else {
RDEPUBBackgroundTrace.log(
"MetadataParse",
"spine=\(spineIndex) max retries reached, marking as deferredFailure"
)
return
}
let delay = Self.retryDelays[min(retryCount, Self.retryDelays.count - 1)]
RDEPUBBackgroundTrace.log(
"MetadataParse",
"scheduling retry for spine=\(spineIndex) attempt=\(retryCount + 1) delay=\(delay)s"
)
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + delay) { [weak self] in
guard let self else { return }
let context = self.context
guard context.controller != nil,
context.paginationToken == self.token,
!cancellationController.isCancelled else {
return
}
do {
let chapterBuilder = context.makeTextBookBuilder(layoutConfig: self.layoutConfig)
guard let result = try chapterBuilder.buildChapter(
parser: self.parser,
publication: self.publication,
spineIndex: spineIndex,
pageSize: self.pageSize,
style: self.style
) else {
return
}
guard context.controller != nil,
context.paginationToken == self.token,
!cancellationController.isCancelled else {
RDEPUBBackgroundTrace.log("MetadataParse", "drop retry result due to cancellation spine=\(spineIndex)")
return
}
let chapter = result.chapter
let precomputedHash = self.contentHashBySpineIndex[spineIndex] ?? ""
let cacheKey = context.chapterCacheKey(
forSpineIndex: spineIndex,
precomputedContentHash: precomputedHash,
renderSignature: self.renderSignature
)
let summary = RDEPUBChapterSummary(
pageRanges: chapter.pages.map { .init(location: $0.contentRange.location, length: $0.contentRange.length) },
pageCount: chapter.pages.count,
fragmentOffsets: chapter.fragmentOffsets,
cfiMap: chapter.cfiMap,
renderSignature: cacheKey.renderSignature,
schemaVersion: RDEPUBChapterSummary.currentSchemaVersion,
chapterContentHash: cacheKey.chapterContentHash,
pageMetadataList: chapter.pages.map { .from($0.metadata) }
)
guard context.controller != nil,
context.paginationToken == self.token,
!cancellationController.isCancelled else {
RDEPUBBackgroundTrace.log("MetadataParse", "skip retry disk write due to cancellation spine=\(spineIndex)")
return
}
self.summaryDiskCache?.write(summary: summary, for: cacheKey)
resultLock.lock()
parseState.summariesBySpineIndex[spineIndex] = summary
parseState.totalResolvedCount += 1
let shouldRefresh =
parseState.totalResolvedCount - parseState.lastAppliedCount >= self.pageMapRefreshInterval
|| parseState.totalResolvedCount == self.allBuildableIndices.count
if shouldRefresh {
parseState.lastAppliedCount = parseState.totalResolvedCount
}
resultLock.unlock()
if shouldRefresh {
let partialMap = self.buildPageMap(summaries: parseState.summariesBySpineIndex)
DispatchQueue.main.async {
guard context.paginationToken == self.token,
context.controller != nil,
!cancellationController.isCancelled else { return }
context.runtime?.refreshBookPageMapInPlace(partialMap)
}
}
RDEPUBBackgroundTrace.log(
"MetadataParse",
"retry succeeded for spine=\(spineIndex) attempt=\(retryCount + 1)"
)
} catch {
RDEPUBBackgroundTrace.log(
"MetadataParse",
"retry failed for spine=\(spineIndex) attempt=\(retryCount + 1) error=\(error)"
)
self.scheduleRetry(
spineIndex: spineIndex,
retryCount: retryCount + 1,
resultLock: resultLock,
parseState: parseState,
cancellationController: cancellationController
)
}
}
}
private func restoreBookPageMapIfPossible() -> RDEPUBBookPageMap? {
guard let summaryDiskCache else { return nil }
guard summaryDiskCache.isCacheComplete(keys: catalog.map(\.key)) else {
return nil
}
let restored = summaryDiskCache.readAll(keys: catalog)
guard restored.summaries.count == catalog.count else {
return nil
}
return restored.mapBuilder.build()
}
private func buildPageMap(
summaries: [Int: RDEPUBChapterSummary]
) -> RDEPUBBookPageMap {
var builder = RDEPUBBookPageMap.Builder()
for item in catalog {
guard let summary = summaries[item.spineIndex] else { continue }
builder.add(
spineIndex: item.spineIndex,
href: item.href,
title: item.title,
pageCount: summary.pageCount,
fragmentOffsets: summary.fragmentOffsets
)
}
return builder.build()
}
}

View File

@ -14,7 +14,7 @@ struct RDEPUBPaginationState {
var activePageMap: RDEPUBBookPageMap?
var pendingFullPageMap: RDEPUBBookPageMap?
var pendingPageMapUpdates: [RDEPUBPendingPageMapUpdate] = []
var chapterWindowSnapshot: RDEPUBChapterWindowSnapshot?

View File

@ -1,5 +1,17 @@
import UIKit
enum RDEPUBPendingPageMapUpdateKind {
case reconcileFullMap
case extendPartial(currentPageNumber: Int, currentLocation: RDEPUBLocation?)
case appendForward
}
struct RDEPUBPendingPageMapUpdate {
let pageMap: RDEPUBBookPageMap
let source: RDEPUBPaginationStateSource
let kind: RDEPUBPendingPageMapUpdateKind
}
final class RDEPUBPresentationRuntime {
private unowned let context: RDEPUBReaderContext
@ -34,77 +46,63 @@ final class RDEPUBPresentationRuntime {
navigationStateMachine.transition(to: .presentingWindow)
context.textBook = nil
context.bookPageMap = bookPageMap
context.pendingFullPageMap = nil
context.pendingPageMapUpdates.removeAll()
context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap))
paginationState.activePageMap = bookPageMap
paginationState.pendingFullPageMap = nil
paginationState.pendingPageMapUpdates.removeAll()
paginationState.source = .initialPartial
finishPagination(restoreLocation)
}
func refreshBookPageMapInPlace(_ bookPageMap: RDEPUBBookPageMap) {
if let pendingMap = context.pendingFullPageMap {
let shouldKeepExisting =
pendingMap.totalChapters > bookPageMap.totalChapters ||
(pendingMap.totalChapters == bookPageMap.totalChapters &&
pendingMap.totalPages >= bookPageMap.totalPages)
if shouldKeepExisting {
return
}
}
navigationStateMachine.transition(to: .reconcilingFullMap)
context.pendingFullPageMap = bookPageMap
paginationState.pendingFullPageMap = bookPageMap
enqueuePendingPageMapUpdate(
RDEPUBPendingPageMapUpdate(
pageMap: bookPageMap,
source: .pendingFullMap,
kind: .reconcileFullMap
)
)
}
func applyPendingFullPageMapIfNeeded() {
guard let pendingMap = context.pendingFullPageMap,
let readerView = context.readerView,
func commitPendingPageMapUpdateIfNeeded() {
guard let readerView = context.readerView,
let controller = context.controller else { return }
guard !controller.isRepaginating else { return }
guard !readerView.isPageCurlTransitioning else {
RDEPUBBackgroundTrace.log("PageMapCommit", "defer commit reason=pageCurlTransition")
return
}
navigationStateMachine.transition(to: .reconcilingFullMap)
let decision = reconciliationCoordinator.evaluateTakeover(
candidatePageMap: pendingMap,
candidateSegment: nil,
currentWindow: context.bookPageMap,
jumpSession: jumpSessionManager.activeSession
)
switch decision {
case .keepCurrentWindow:
RDEPUBBackgroundTrace.log("Reconciliation", "decision: keepCurrentWindow")
case .fullReplace(let newPageMap):
RDEPUBBackgroundTrace.log("Reconciliation", "decision: fullReplace")
applyFullPageMapReplacement(newPageMap, readerView: readerView, controller: controller)
case .expandWindow, .segmentReplace:
RDEPUBBackgroundTrace.log("Reconciliation", "decision: unexpected segment decision")
let rankedUpdates = rankedPendingPageMapUpdates()
for (index, update) in rankedUpdates {
if commitPendingPageMapUpdate(
update,
at: index,
readerView: readerView,
controller: controller
) {
return
}
}
}
func applyExtendedPartialPageMap(
func queueExtendedPartialPageMap(
_ bookPageMap: RDEPUBBookPageMap,
currentPageNumber: Int,
currentLocation: RDEPUBLocation?
) {
guard let readerView = context.readerView else { return }
navigationStateMachine.transition(to: .presentingWindow)
context.bookPageMap = bookPageMap
context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap))
paginationState.activePageMap = bookPageMap
paginationState.source = .asyncExtension
readerView.reloadPageCountOnly()
if let currentLocation,
locationCoordinator.restoreReadingLocation(currentLocation, animated: false) {
return
}
readerView.transitionToPage(pageNum: max(currentPageNumber - 1, 0), animated: false)
enqueuePendingPageMapUpdate(
RDEPUBPendingPageMapUpdate(
pageMap: bookPageMap,
source: .asyncExtension,
kind: .extendPartial(
currentPageNumber: currentPageNumber,
currentLocation: currentLocation
)
)
)
}
func applySettingsPreviewPageMap(_ bookPageMap: RDEPUBBookPageMap) {
@ -115,6 +113,16 @@ final class RDEPUBPresentationRuntime {
paginationState.source = .settingsPreview
}
func queueForwardAppendedPageMap(_ bookPageMap: RDEPUBBookPageMap) {
enqueuePendingPageMapUpdate(
RDEPUBPendingPageMapUpdate(
pageMap: bookPageMap,
source: .asyncExtension,
kind: .appendForward
)
)
}
func clear() {
paginationState = RDEPUBPaginationState()
navigationStateMachine.transition(to: .idle)
@ -150,22 +158,17 @@ final class RDEPUBPresentationRuntime {
) {
let currentLocation = locationCoordinator.currentVisibleLocation()
context.pendingFullPageMap = nil
context.textBook = nil
context.bookPageMap = newPageMap
context.replaceActiveSnapshot(makeSnapshot(from: newPageMap))
paginationState.activePageMap = newPageMap
paginationState.pendingFullPageMap = nil
paginationState.source = .fullReplacement
navigationStateMachine.transition(to: .presentingWindow)
applyPageMapToLiveModel(newPageMap, source: .fullReplacement)
if let currentLocation {
let newPageNumber = controller.pageNumber(for: currentLocation) ?? (readerView.currentPage + 1)
let newPage = max(0, newPageNumber - 1)
readerView.reloadPageCountOnly()
if newPage != readerView.currentPage {
readerView.transitionToPage(pageNum: newPage, animated: false)
if rebindVisibleLocation(currentLocation, readerView: readerView, controller: controller) == false {
let newPageNumber = controller.pageNumber(for: currentLocation) ?? (readerView.currentPage + 1)
let newPage = max(0, newPageNumber - 1)
rebindVisiblePage(
to: newPage,
readerView: readerView
)
}
} else {
readerView.reloadPageCountOnly()
@ -181,4 +184,184 @@ final class RDEPUBPresentationRuntime {
}
}
}
private func enqueuePendingPageMapUpdate(_ update: RDEPUBPendingPageMapUpdate) {
var updates = context.pendingPageMapUpdates
if let existingIndex = updates.firstIndex(where: {
pendingPageMapUpdateKindMatches($0.kind, update.kind)
}) {
let existing = updates[existingIndex]
if shouldReplacePendingPageMapUpdate(existing, with: update) {
updates[existingIndex] = update
}
} else {
updates.append(update)
}
context.pendingPageMapUpdates = updates
paginationState.pendingPageMapUpdates = updates
commitPendingPageMapUpdateIfNeeded()
}
private func rankedPendingPageMapUpdates() -> [(Int, RDEPUBPendingPageMapUpdate)] {
context.pendingPageMapUpdates.enumerated().sorted { lhs, rhs in
pendingPriority(for: lhs.element.kind) > pendingPriority(for: rhs.element.kind)
}
}
private func commitPendingPageMapUpdate(
_ update: RDEPUBPendingPageMapUpdate,
at index: Int,
readerView: RDReaderView,
controller: RDEPUBReaderController
) -> Bool {
switch update.kind {
case .reconcileFullMap:
navigationStateMachine.transition(to: .reconcilingFullMap)
let decision = reconciliationCoordinator.evaluateTakeover(
candidatePageMap: update.pageMap,
candidateSegment: nil,
currentWindow: context.bookPageMap,
jumpSession: jumpSessionManager.activeSession
)
switch decision {
case .keepCurrentWindow:
RDEPUBBackgroundTrace.log("Reconciliation", "decision: keepCurrentWindow")
return false
case .fullReplace(let newPageMap):
RDEPUBBackgroundTrace.log("Reconciliation", "decision: fullReplace")
removePendingPageMapUpdate(at: index)
applyFullPageMapReplacement(newPageMap, readerView: readerView, controller: controller)
return true
case .expandWindow, .segmentReplace:
RDEPUBBackgroundTrace.log("Reconciliation", "decision: unexpected segment decision")
removePendingPageMapUpdate(at: index)
return false
}
case .extendPartial(let currentPageNumber, let currentLocation):
removePendingPageMapUpdate(at: index)
applyPageMapToLiveModel(update.pageMap, source: update.source)
if let currentLocation,
rebindVisibleLocation(currentLocation, readerView: readerView, controller: controller) {
return true
}
rebindVisiblePage(
to: max(currentPageNumber - 1, 0),
readerView: readerView
)
return true
case .appendForward:
removePendingPageMapUpdate(at: index)
applyPageMapToLiveModel(update.pageMap, source: update.source)
readerView.reloadPageCountOnly()
return true
}
}
private func rebindVisiblePage(to pageIndex: Int, readerView: RDReaderView) {
if readerView.currentDisplayType == .pageCurl {
readerView.transitionToPage(pageNum: pageIndex, animated: false)
} else {
readerView.reloadPageCountOnly()
if pageIndex != readerView.currentPage {
readerView.transitionToPage(pageNum: pageIndex, animated: false)
}
}
}
private func rebindVisibleLocation(
_ location: RDEPUBLocation,
readerView: RDReaderView,
controller: RDEPUBReaderController
) -> Bool {
guard let targetPageNumber = controller.pageNumber(for: location) else {
return false
}
if context.bookPageMap != nil,
context.runtime?.prepareOnDemandChapter(
forAbsolutePageNumber: targetPageNumber,
allowSynchronousLoad: true
) == false {
return false
}
rebindVisiblePage(
to: max(targetPageNumber - 1, 0),
readerView: readerView
)
return true
}
private func applyPageMapToLiveModel(
_ pageMap: RDEPUBBookPageMap,
source: RDEPUBPaginationStateSource
) {
navigationStateMachine.transition(to: .presentingWindow)
context.bookPageMap = pageMap
context.replaceActiveSnapshot(makeSnapshot(from: pageMap))
discardSupersededPendingPageMapUpdates(afterApplying: pageMap)
paginationState.activePageMap = pageMap
paginationState.source = source
}
private func removePendingPageMapUpdate(at index: Int) {
var updates = context.pendingPageMapUpdates
guard updates.indices.contains(index) else { return }
updates.remove(at: index)
context.pendingPageMapUpdates = updates
paginationState.pendingPageMapUpdates = updates
}
private func discardSupersededPendingPageMapUpdates(afterApplying liveMap: RDEPUBBookPageMap) {
let updates = context.pendingPageMapUpdates.filter { update in
update.pageMap.totalChapters > liveMap.totalChapters
|| (
update.pageMap.totalChapters == liveMap.totalChapters
&& update.pageMap.totalPages > liveMap.totalPages
)
}
context.pendingPageMapUpdates = updates
paginationState.pendingPageMapUpdates = updates
}
private func pendingPriority(for kind: RDEPUBPendingPageMapUpdateKind) -> Int {
switch kind {
case .extendPartial:
return 3
case .appendForward:
return 2
case .reconcileFullMap:
return 1
}
}
private func pendingPageMapUpdateKindMatches(
_ lhs: RDEPUBPendingPageMapUpdateKind,
_ rhs: RDEPUBPendingPageMapUpdateKind
) -> Bool {
switch (lhs, rhs) {
case (.reconcileFullMap, .reconcileFullMap),
(.appendForward, .appendForward),
(.extendPartial, .extendPartial):
return true
default:
return false
}
}
private func shouldReplacePendingPageMapUpdate(
_ existing: RDEPUBPendingPageMapUpdate,
with candidate: RDEPUBPendingPageMapUpdate
) -> Bool {
candidate.pageMap.totalChapters > existing.pageMap.totalChapters
|| (
candidate.pageMap.totalChapters == existing.pageMap.totalChapters
&& candidate.pageMap.totalPages >= existing.pageMap.totalPages
)
}
}

View File

@ -83,9 +83,9 @@ final class RDEPUBReaderContext {
set { state.searchState = newValue }
}
var pendingFullPageMap: RDEPUBBookPageMap? {
get { state.pendingFullPageMap }
set { state.pendingFullPageMap = newValue }
var pendingPageMapUpdates: [RDEPUBPendingPageMapUpdate] {
get { state.pendingPageMapUpdates }
set { state.pendingPageMapUpdates = newValue }
}
var lastTextPaginationPageSize: CGSize? {
@ -249,7 +249,7 @@ final class RDEPUBReaderContext {
let publication,
publication.spine.indices.contains(spineIndex) {
let href = publication.spine[spineIndex].href
contentHash = parser.htmlString(forRelativePath: href)?.sha256Hex ?? ""
contentHash = parser.htmlString(forRelativePath: href)?.rd_sha256Hex ?? ""
} else {
contentHash = ""
}

View File

@ -2,77 +2,13 @@ import Foundation
final class RDEPUBReaderPaginationCoordinator {
private final class MetadataParseState {
var summariesBySpineIndex: [Int: RDEPUBChapterSummary]
var totalResolvedCount: Int
var lastAppliedCount: Int
init(
summariesBySpineIndex: [Int: RDEPUBChapterSummary],
totalResolvedCount: Int,
lastAppliedCount: Int
) {
self.summariesBySpineIndex = summariesBySpineIndex
self.totalResolvedCount = totalResolvedCount
self.lastAppliedCount = lastAppliedCount
}
}
private final class MetadataParseCancellationController {
let token: UUID
private let lock = NSLock()
private weak var queue: OperationQueue?
private var cancelled = false
init(token: UUID) {
self.token = token
}
func attach(queue: OperationQueue) {
let shouldCancelImmediately: Bool
lock.lock()
self.queue = queue
shouldCancelImmediately = cancelled
lock.unlock()
if shouldCancelImmediately {
queue.cancelAllOperations()
}
}
func cancel() {
let queueToCancel: OperationQueue?
lock.lock()
cancelled = true
queueToCancel = queue
lock.unlock()
queueToCancel?.cancelAllOperations()
}
var isCancelled: Bool {
lock.lock()
let value = cancelled
lock.unlock()
return value
}
}
private let backgroundInteractionCooldown: CFAbsoluteTime = 0.8
static var pageMapRefreshInterval: Int = 32
private unowned let context: RDEPUBReaderContext
private let metadataParseControlLock = NSLock()
private var activeMetadataParseCancellationController: MetadataParseCancellationController?
private var activeMetadataParseCancellationController: RDEPUBMetadataParseCancellationController?
init(context: RDEPUBReaderContext) {
self.context = context
@ -148,7 +84,7 @@ final class RDEPUBReaderPaginationCoordinator {
guard let controller = context.controller else { return }
context.textBook = textBook
context.bookPageMap = nil
context.pendingFullPageMap = nil
context.pendingPageMapUpdates.removeAll()
let snapshot = controller.nativeTextSnapshot(from: textBook)
context.replaceActiveSnapshot(snapshot)
@ -167,7 +103,7 @@ final class RDEPUBReaderPaginationCoordinator {
guard context.controller != nil else { return }
context.textBook = nil
context.bookPageMap = nil
context.pendingFullPageMap = nil
context.pendingPageMapUpdates.removeAll()
context.replaceActiveSnapshot(snapshot)
guard !snapshot.pages.isEmpty else {
@ -206,8 +142,34 @@ final class RDEPUBReaderPaginationCoordinator {
func refreshVisibleContentPreservingLocation() {
guard let readerView = context.readerView else { return }
if readerView.isPageCurlTransitioning {
RDEPUBBackgroundTrace.log(
"LoadingPage",
"defer refreshVisibleContent currentPage=\(readerView.currentPage + 1) reason=pageCurlTransition"
)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
self?.refreshVisibleContentPreservingLocation()
}
return
}
let restoreLocation = context.currentVisibleLocation() ?? context.persistenceLocation()
readerView.reloadData()
RDEPUBBackgroundTrace.log(
"LoadingPage",
"refreshVisibleContentPreservingLocation currentPage=\(readerView.currentPage + 1) restoreHref=\(restoreLocation?.href ?? "nil") restoreCFI=\(restoreLocation?.cfi ?? "nil")"
)
if readerView.currentDisplayType == .pageCurl, readerView.currentPage >= 0 {
RDEPUBBackgroundTrace.log(
"LoadingPage",
"refreshVisibleContent using transitionToPage currentPage=\(readerView.currentPage + 1)"
)
readerView.transitionToPage(pageNum: readerView.currentPage, animated: false)
} else {
RDEPUBBackgroundTrace.log(
"LoadingPage",
"refreshVisibleContent using reloadData currentPage=\(readerView.currentPage + 1)"
)
readerView.reloadData()
}
if let restoreLocation {
_ = context.restoreReadingLocation(restoreLocation)
}
@ -275,6 +237,11 @@ final class RDEPUBReaderPaginationCoordinator {
"QuickOpen",
"ready anchorSpine=\(runtimeChapter.spineIndex) pages=\(runtimeChapter.pages.count)"
)
let initialChapters = self.loadInitialInteractiveRuntimeChapters(
anchorChapter: runtimeChapter,
publication: publication,
runtime: runtime
)
DispatchQueue.main.async {
guard context.paginationToken == token,
@ -284,13 +251,21 @@ final class RDEPUBReaderPaginationCoordinator {
totalSpineCount: publication.spine.count,
windowRadius: context.configuration.chapterWindowRadius
)
let partialMap = self.makePartialPageMap(from: [runtimeChapter])
let partialMap = self.makePartialPageMap(from: initialChapters)
runtime.applyBookPageMap(partialMap, restoreLocation: restoreLocation)
runtime.prefetchForwardChaptersAfterInitialOpen(
anchorSpineIndex: runtimeChapter.spineIndex,
totalSpineCount: publication.spine.count
)
self.paginateMetadataOnly(token: token, restoreLocation: restoreLocation)
let cancellationController = self.beginMetadataParseCancellationController(for: token)
let worker = RDEPUBMetadataParseWorker(
context: context,
cancellationController: cancellationController,
token: token,
parser: parser,
publication: publication
)
worker.start(token: token, restoreLocation: restoreLocation)
}
} catch {
DispatchQueue.main.async {
@ -321,6 +296,55 @@ final class RDEPUBReaderPaginationCoordinator {
throw lastError ?? RDEPUBParserError.emptySpine
}
private func loadInitialInteractiveRuntimeChapters(
anchorChapter: RDEPUBRuntimeChapter,
publication: RDEPUBPublication,
runtime: RDEPUBReaderRuntime
) -> [RDEPUBRuntimeChapter] {
let minimumInteractivePageCount = 2
let maximumAdditionalChapters = 1
guard anchorChapter.pages.count < minimumInteractivePageCount else {
return [anchorChapter]
}
let buildableSpineIndices = publication.spine.indices.filter { isBuildableTextSpine(at: $0, in: publication) }
guard let anchorPosition = buildableSpineIndices.firstIndex(of: anchorChapter.spineIndex) else {
return [anchorChapter]
}
var selectedChapters: [RDEPUBRuntimeChapter] = [anchorChapter]
for offset in 1...maximumAdditionalChapters {
let candidatePositions = [anchorPosition + offset, anchorPosition - offset]
for candidatePosition in candidatePositions {
guard buildableSpineIndices.indices.contains(candidatePosition) else { continue }
let spineIndex = buildableSpineIndices[candidatePosition]
guard selectedChapters.contains(where: { $0.spineIndex == spineIndex }) == false else { continue }
do {
let chapter = try runtime.chapterLoader.loadChapterSynchronouslyForMigration(
spineIndex: spineIndex,
store: runtime.chapterRuntimeStore
)
selectedChapters.append(chapter)
} catch {
RDEPUBBackgroundTrace.log(
"QuickOpen",
"lookahead skip spine=\(spineIndex) reason=\(error)"
)
}
}
let loadedPageCount = selectedChapters.reduce(0) { $0 + $1.pages.count }
if loadedPageCount >= minimumInteractivePageCount {
break
}
}
return selectedChapters
}
private func makePartialPageMap(from chapters: [RDEPUBRuntimeChapter]) -> RDEPUBBookPageMap {
var builder = RDEPUBBookPageMap.Builder()
for chapter in chapters {
@ -352,524 +376,12 @@ final class RDEPUBReaderPaginationCoordinator {
publication.spine.indices.filter { isBuildableTextSpine(at: $0, in: publication) }
}
private func waitForReadingInteractionToSettle(
using context: RDEPUBReaderContext,
cancellationController: MetadataParseCancellationController? = nil
) {
while context.controller != nil,
cancellationController?.isCancelled != true,
context.secondsSinceLastUserNavigation() < backgroundInteractionCooldown {
Thread.sleep(forTimeInterval: 0.08)
}
}
private func isBuildableTextSpine(at index: Int, in publication: RDEPUBPublication) -> Bool {
guard publication.spine.indices.contains(index) else { return false }
let item = publication.spine[index]
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
}
private static let maxRetryCount = 3
private static let retryDelays: [TimeInterval] = [0.5, 2.0, 8.0]
func paginateMetadataOnly(token: UUID, restoreLocation: RDEPUBLocation?) {
let context = self.context
guard let parser = context.parser,
let publication = context.publication else { return }
let cancellationController = beginMetadataParseCancellationController(for: token)
let pageSize = context.currentTextPageSize()
let layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
let style = context.currentTextRenderStyle()
let renderSignature = context.currentRenderSignature()
let allBuildableIndices = allBuildableSpineIndices(in: publication)
let summaryDiskCache = context.runtime?.summaryDiskCache
let workerCount = max(1, context.configuration.metadataParsingConcurrency)
let cpuCount = ProcessInfo.processInfo.activeProcessorCount
RDEPUBBackgroundTrace.log("MetadataParse", "config concurrency=\(workerCount) cpuCores=\(cpuCount)")
DispatchQueue.global(qos: .utility).async { [weak self] in
guard let self else { return }
defer { self.finishMetadataParseCancellationController(cancellationController) }
guard context.controller != nil,
!cancellationController.isCancelled,
context.paginationToken == token else { return }
if let restoredPageMap = self.restoreBookPageMapIfPossible(publication: publication) {
RDEPUBBackgroundTrace.log(
"MetadataParse",
"full cache restore hit chapters=\(restoredPageMap.totalChapters) pages=\(restoredPageMap.totalPages)"
)
DispatchQueue.main.async {
guard context.paginationToken == token,
context.controller != nil,
!cancellationController.isCancelled else { return }
context.runtime?.refreshBookPageMapInPlace(restoredPageMap)
}
return
}
let prewarmStart = CFAbsoluteTimeGetCurrent()
var contentHashBySpineIndex: [Int: String] = [:]
for spineIndex in allBuildableIndices {
guard !cancellationController.isCancelled,
context.paginationToken == token,
context.controller != nil else {
RDEPUBBackgroundTrace.log("MetadataParse", "abort during content hash prewarm")
return
}
guard let href = publication.spine.indices.contains(spineIndex)
? publication.spine[spineIndex].href : nil,
let html = parser.htmlString(forRelativePath: href) else {
contentHashBySpineIndex[spineIndex] = ""
continue
}
contentHashBySpineIndex[spineIndex] = html.sha256Hex
}
let prewarmMs = Int((CFAbsoluteTimeGetCurrent() - prewarmStart) * 1000)
RDEPUBBackgroundTrace.log("MetadataParse", "prewarmHashMs=\(prewarmMs) chapters=\(allBuildableIndices.count)")
let catalog = allBuildableIndices.map { spineIndex in
let item = publication.spine[spineIndex]
return (
key: context.chapterCacheKey(
forSpineIndex: spineIndex,
precomputedContentHash: contentHashBySpineIndex[spineIndex] ?? "",
renderSignature: renderSignature
),
spineIndex: spineIndex,
href: item.href,
title: item.title
)
}
let restored = summaryDiskCache?.readAll(keys: catalog)
RDEPUBBackgroundTrace.log(
"MetadataParse",
"begin token=\(token.uuidString) buildableChapters=\(allBuildableIndices.count) concurrency=\(workerCount)"
)
let cachedSummaries = restored?.summaries ?? [:]
let cachedSpineIndices = Set(cachedSummaries.keys)
let resultLock = NSLock()
let parseState = MetadataParseState(
summariesBySpineIndex: cachedSummaries,
totalResolvedCount: cachedSpineIndices.count,
lastAppliedCount: cachedSpineIndices.count
)
if !cachedSpineIndices.isEmpty {
RDEPUBBackgroundTrace.log(
"MetadataParse",
"resumeFromCache cachedChapters=\(cachedSpineIndices.count) total=\(allBuildableIndices.count)"
)
let cachedMap = self.buildPageMap(from: catalog, summaries: parseState.summariesBySpineIndex)
DispatchQueue.main.async {
guard context.paginationToken == token,
context.controller != nil else { return }
context.runtime?.refreshBookPageMapInPlace(cachedMap)
}
}
let prioritizedSpineIndices: [Int]
if let priorityManager = context.runtime?.backgroundPriorityManager {
let currentSpineIndex = context.runtime?.locationCoordinator.currentVisibleLocation()
.flatMap { context.normalizedSpineIndex(for: $0) }
prioritizedSpineIndices = priorityManager.makeMetadataPriorityOrder(
allBuildableIndices: allBuildableIndices,
currentSpineIndex: currentSpineIndex,
cachedSpineIndices: cachedSpineIndices
)
RDEPUBBackgroundTrace.log(
"MetadataParse",
"prioritized hot=\(prioritizedSpineIndices.prefix(10).count) total=\(prioritizedSpineIndices.count)"
)
} else {
prioritizedSpineIndices = allBuildableIndices.filter { !cachedSpineIndices.contains($0) }
}
let uncachedSpineIndices = prioritizedSpineIndices
self.waitForReadingInteractionToSettle(
using: context,
cancellationController: cancellationController
)
guard !cancellationController.isCancelled,
context.controller != nil,
context.paginationToken == token else {
RDEPUBBackgroundTrace.log("MetadataParse", "abort before queue start")
return
}
let wallClockStart = CFAbsoluteTimeGetCurrent()
var totalRenderMs: Double = 0
var totalWriteMs: Double = 0
var totalMergeMs: Double = 0
var completedChapters = 0
var failedChapters = 0
let timingLock = NSLock()
let queue = OperationQueue()
queue.name = "com.rdreader.metadata.parse"
queue.qualityOfService = .utility
queue.maxConcurrentOperationCount = workerCount
cancellationController.attach(queue: queue)
let refreshInterval = RDEPUBReaderPaginationCoordinator.pageMapRefreshInterval
for (offset, spineIndex) in uncachedSpineIndices.enumerated() {
let operation = BlockOperation()
operation.addExecutionBlock { [weak operation] in
guard context.controller != nil,
context.paginationToken == token,
!cancellationController.isCancelled,
operation?.isCancelled != true else {
return
}
do {
RDEPUBBackgroundTrace.log("MetadataParse", "正在解析 spine=\(spineIndex) \(offset + 1)/\(uncachedSpineIndices.count)")
let renderResult: RDEPUBChapterSummary? = try autoreleasepool { () -> RDEPUBChapterSummary? in
guard context.controller != nil,
context.paginationToken == token,
!cancellationController.isCancelled,
operation?.isCancelled != true else {
return nil
}
let chapterBuilder = context.makeTextBookBuilder(layoutConfig: layoutConfig)
let renderStart = CFAbsoluteTimeGetCurrent()
guard let result = try chapterBuilder.buildChapter(
parser: parser,
publication: publication,
spineIndex: spineIndex,
pageSize: pageSize,
style: style
) else {
return nil
}
guard context.controller != nil,
context.paginationToken == token,
!cancellationController.isCancelled,
operation?.isCancelled != true else {
RDEPUBBackgroundTrace.log("MetadataParse", "drop rendered chapter due to cancellation spine=\(spineIndex)")
return nil
}
let renderElapsed = (CFAbsoluteTimeGetCurrent() - renderStart) * 1000
let chapter = result.chapter
let precomputedHash = contentHashBySpineIndex[spineIndex] ?? ""
let cacheKey = context.chapterCacheKey(
forSpineIndex: spineIndex,
precomputedContentHash: precomputedHash,
renderSignature: renderSignature
)
let summary = RDEPUBChapterSummary(
pageRanges: chapter.pages.map { .init(location: $0.contentRange.location, length: $0.contentRange.length) },
pageCount: chapter.pages.count,
fragmentOffsets: chapter.fragmentOffsets,
cfiMap: chapter.cfiMap,
renderSignature: cacheKey.renderSignature,
schemaVersion: RDEPUBChapterSummary.currentSchemaVersion,
chapterContentHash: cacheKey.chapterContentHash,
pageMetadataList: chapter.pages.map { .from($0.metadata) }
)
guard context.controller != nil,
context.paginationToken == token,
!cancellationController.isCancelled,
operation?.isCancelled != true else {
RDEPUBBackgroundTrace.log("MetadataParse", "skip disk write due to cancellation spine=\(spineIndex)")
return nil
}
let writeStart = CFAbsoluteTimeGetCurrent()
summaryDiskCache?.write(summary: summary, for: cacheKey)
let writeElapsed = (CFAbsoluteTimeGetCurrent() - writeStart) * 1000
timingLock.lock()
totalRenderMs += renderElapsed
totalWriteMs += writeElapsed
completedChapters += 1
timingLock.unlock()
RDEPUBBackgroundTrace.log(
"MetadataParse",
"spine=\(spineIndex) renderMs=\(Int(renderElapsed)) writeMs=\(Int(writeElapsed))"
)
return summary
}
guard let renderResult else { return }
guard context.controller != nil,
context.paginationToken == token,
!cancellationController.isCancelled,
operation?.isCancelled != true else {
return
}
var snapshot: [Int: RDEPUBChapterSummary]?
resultLock.lock()
parseState.summariesBySpineIndex[spineIndex] = renderResult
parseState.totalResolvedCount += 1
if parseState.totalResolvedCount - parseState.lastAppliedCount >= refreshInterval
|| parseState.totalResolvedCount == allBuildableIndices.count {
parseState.lastAppliedCount = parseState.totalResolvedCount
snapshot = parseState.summariesBySpineIndex
}
resultLock.unlock()
if let snapshot {
let mergeStart = CFAbsoluteTimeGetCurrent()
let partialMap = self.buildPageMap(from: catalog, summaries: snapshot)
let mergeElapsed = (CFAbsoluteTimeGetCurrent() - mergeStart) * 1000
timingLock.lock()
totalMergeMs += mergeElapsed
timingLock.unlock()
DispatchQueue.main.async {
guard context.paginationToken == token,
context.controller != nil,
!cancellationController.isCancelled else { return }
context.runtime?.refreshBookPageMapInPlace(partialMap)
}
}
} catch {
guard !cancellationController.isCancelled,
context.paginationToken == token,
context.controller != nil,
operation?.isCancelled != true else {
return
}
timingLock.lock()
failedChapters += 1
timingLock.unlock()
RDEPUBBackgroundTrace.log("MetadataParse", "buildChapter FAILED: spine=\(spineIndex) error=\(error)")
self.scheduleRetry(
spineIndex: spineIndex,
retryCount: 0,
token: token,
context: context,
parser: parser,
publication: publication,
pageSize: pageSize,
layoutConfig: layoutConfig,
style: style,
renderSignature: renderSignature,
summaryDiskCache: summaryDiskCache,
contentHashBySpineIndex: contentHashBySpineIndex,
resultLock: resultLock,
parseState: parseState,
allBuildableIndices: allBuildableIndices,
catalog: catalog,
refreshInterval: refreshInterval,
cancellationController: cancellationController
)
}
}
queue.addOperation(operation)
}
queue.waitUntilAllOperationsAreFinished()
if !cancellationController.isCancelled,
context.paginationToken == token,
context.controller != nil {
summaryDiskCache?.flushPendingWrites()
}
let wallClockMs = Int((CFAbsoluteTimeGetCurrent() - wallClockStart) * 1000)
timingLock.lock()
let renderTotal = Int(totalRenderMs)
let writeTotal = Int(totalWriteMs)
let mergeTotal = Int(totalMergeMs)
let rendered = completedChapters
let failed = failedChapters
timingLock.unlock()
let avgRenderMs = rendered > 0 ? renderTotal / rendered : 0
RDEPUBBackgroundTrace.log(
"MetadataParse",
"timing wallClockMs=\(wallClockMs) chapters=\(rendered) failed=\(failed) " +
"prewarmHashMs=\(prewarmMs) renderTotalMs=\(renderTotal) writeTotalMs=\(writeTotal) " +
"mergeTotalMs=\(mergeTotal) avgRenderMs=\(avgRenderMs) concurrency=\(workerCount)"
)
context.lastMetadataParseWallClockMs = wallClockMs
context.lastMetadataParseConcurrency = workerCount
guard context.controller != nil,
context.paginationToken == token,
!cancellationController.isCancelled else {
RDEPUBBackgroundTrace.log("MetadataParse", "abort: reader dismissed or token changed")
return
}
let finalMergeStart = CFAbsoluteTimeGetCurrent()
let pageMap = self.buildPageMap(from: catalog, summaries: parseState.summariesBySpineIndex)
let finalMergeMs = Int((CFAbsoluteTimeGetCurrent() - finalMergeStart) * 1000)
RDEPUBBackgroundTrace.log(
"MetadataParse",
"complete chapters=\(pageMap.totalChapters) pages=\(pageMap.totalPages) finalMergeMs=\(finalMergeMs)"
)
if let coverageStore = context.runtime?.backgroundCoverageStore {
let resolvedSpineIndices = Set(parseState.summariesBySpineIndex.keys)
let lowerSpine = resolvedSpineIndices.min() ?? 0
let upperSpine = resolvedSpineIndices.max() ?? 0
let estimatedBytes = 256 + pageMap.entries.count * 96 + resolvedSpineIndices.count * 16
let segment = RDEPUBBackgroundCoverageSegment(
lowerSpineIndex: lowerSpine,
upperSpineIndex: upperSpine,
pageMap: pageMap,
resolvedSpineIndices: resolvedSpineIndices,
generatedAt: CFAbsoluteTimeGetCurrent(),
renderSignature: renderSignature,
estimatedMemoryBytes: estimatedBytes
)
coverageStore.addSegment(segment)
}
DispatchQueue.main.async {
guard context.paginationToken == token,
context.controller != nil else { return }
context.runtime?.refreshBookPageMapInPlace(pageMap)
}
}
}
private func scheduleRetry(
spineIndex: Int,
retryCount: Int,
token: UUID,
context: RDEPUBReaderContext,
parser: RDEPUBParser,
publication: RDEPUBPublication,
pageSize: CGSize,
layoutConfig: RDEPUBTextLayoutConfig,
style: RDEPUBTextRenderStyle,
renderSignature: String,
summaryDiskCache: RDEPUBChapterSummaryDiskCache?,
contentHashBySpineIndex: [Int: String],
resultLock: NSLock,
parseState: MetadataParseState,
allBuildableIndices: [Int],
catalog: [(key: RDEPUBChapterCacheKey, spineIndex: Int, href: String, title: String)],
refreshInterval: Int,
cancellationController: MetadataParseCancellationController
) {
guard retryCount < Self.maxRetryCount else {
RDEPUBBackgroundTrace.log(
"MetadataParse",
"spine=\(spineIndex) max retries reached, marking as deferredFailure"
)
return
}
let delay = Self.retryDelays[min(retryCount, Self.retryDelays.count - 1)]
RDEPUBBackgroundTrace.log(
"MetadataParse",
"scheduling retry for spine=\(spineIndex) attempt=\(retryCount + 1) delay=\(delay)s"
)
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + delay) { [weak self] in
guard let self else { return }
guard context.controller != nil,
context.paginationToken == token,
!cancellationController.isCancelled else {
return
}
do {
let chapterBuilder = context.makeTextBookBuilder(layoutConfig: layoutConfig)
guard let result = try chapterBuilder.buildChapter(
parser: parser,
publication: publication,
spineIndex: spineIndex,
pageSize: pageSize,
style: style
) else {
return
}
guard context.controller != nil,
context.paginationToken == token,
!cancellationController.isCancelled else {
RDEPUBBackgroundTrace.log("MetadataParse", "drop retry result due to cancellation spine=\(spineIndex)")
return
}
let chapter = result.chapter
let precomputedHash = contentHashBySpineIndex[spineIndex] ?? ""
let cacheKey = context.chapterCacheKey(
forSpineIndex: spineIndex,
precomputedContentHash: precomputedHash,
renderSignature: renderSignature
)
let summary = RDEPUBChapterSummary(
pageRanges: chapter.pages.map { .init(location: $0.contentRange.location, length: $0.contentRange.length) },
pageCount: chapter.pages.count,
fragmentOffsets: chapter.fragmentOffsets,
cfiMap: chapter.cfiMap,
renderSignature: cacheKey.renderSignature,
schemaVersion: RDEPUBChapterSummary.currentSchemaVersion,
chapterContentHash: cacheKey.chapterContentHash,
pageMetadataList: chapter.pages.map { .from($0.metadata) }
)
guard context.controller != nil,
context.paginationToken == token,
!cancellationController.isCancelled else {
RDEPUBBackgroundTrace.log("MetadataParse", "skip retry disk write due to cancellation spine=\(spineIndex)")
return
}
summaryDiskCache?.write(summary: summary, for: cacheKey)
resultLock.lock()
parseState.summariesBySpineIndex[spineIndex] = summary
parseState.totalResolvedCount += 1
let shouldRefresh =
parseState.totalResolvedCount - parseState.lastAppliedCount >= refreshInterval
|| parseState.totalResolvedCount == allBuildableIndices.count
if shouldRefresh {
parseState.lastAppliedCount = parseState.totalResolvedCount
}
resultLock.unlock()
if shouldRefresh {
let partialMap = self.buildPageMap(from: catalog, summaries: parseState.summariesBySpineIndex)
DispatchQueue.main.async {
guard context.paginationToken == token,
context.controller != nil,
!cancellationController.isCancelled else { return }
context.runtime?.refreshBookPageMapInPlace(partialMap)
}
}
RDEPUBBackgroundTrace.log(
"MetadataParse",
"retry succeeded for spine=\(spineIndex) attempt=\(retryCount + 1)"
)
} catch {
RDEPUBBackgroundTrace.log(
"MetadataParse",
"retry failed for spine=\(spineIndex) attempt=\(retryCount + 1) error=\(error)"
)
self.scheduleRetry(
spineIndex: spineIndex,
retryCount: retryCount + 1,
token: token,
context: context,
parser: parser,
publication: publication,
pageSize: pageSize,
layoutConfig: layoutConfig,
style: style,
renderSignature: renderSignature,
summaryDiskCache: summaryDiskCache,
contentHashBySpineIndex: contentHashBySpineIndex,
resultLock: resultLock,
parseState: parseState,
allBuildableIndices: allBuildableIndices,
catalog: catalog,
refreshInterval: refreshInterval,
cancellationController: cancellationController
)
}
}
}
func cancelActiveMetadataParseWork() {
metadataParseControlLock.lock()
let controller = activeMetadataParseCancellationController
@ -878,17 +390,7 @@ final class RDEPUBReaderPaginationCoordinator {
controller?.cancel()
}
private func beginMetadataParseCancellationController(for token: UUID) -> MetadataParseCancellationController {
let controller = MetadataParseCancellationController(token: token)
metadataParseControlLock.lock()
let previous = activeMetadataParseCancellationController
activeMetadataParseCancellationController = controller
metadataParseControlLock.unlock()
previous?.cancel()
return controller
}
private func finishMetadataParseCancellationController(_ controller: MetadataParseCancellationController) {
func finishMetadataParseCancellationController(_ controller: RDEPUBMetadataParseCancellationController) {
metadataParseControlLock.lock()
if activeMetadataParseCancellationController === controller {
activeMetadataParseCancellationController = nil
@ -896,52 +398,13 @@ final class RDEPUBReaderPaginationCoordinator {
metadataParseControlLock.unlock()
}
private func restoreBookPageMapIfPossible(publication: RDEPUBPublication) -> RDEPUBBookPageMap? {
guard let summaryDiskCache = context.runtime?.summaryDiskCache,
let parser = context.parser else {
return nil
}
let renderSignature = context.currentRenderSignature()
let catalog = allBuildableSpineIndices(in: publication).map { spineIndex in
let item = publication.spine[spineIndex]
let href = item.href
let contentHash = parser.htmlString(forRelativePath: href)?.sha256Hex ?? ""
return (
key: context.chapterCacheKey(
forSpineIndex: spineIndex,
precomputedContentHash: contentHash,
renderSignature: renderSignature
),
spineIndex: spineIndex,
href: href,
title: item.title
)
}
guard summaryDiskCache.isCacheComplete(keys: catalog.map(\.key)) else {
return nil
}
let restored = summaryDiskCache.readAll(keys: catalog)
guard restored.summaries.count == catalog.count else {
return nil
}
return restored.mapBuilder.build()
}
private func buildPageMap(
from catalog: [(key: RDEPUBChapterCacheKey, spineIndex: Int, href: String, title: String)],
summaries: [Int: RDEPUBChapterSummary]
) -> RDEPUBBookPageMap {
var builder = RDEPUBBookPageMap.Builder()
for item in catalog {
guard let summary = summaries[item.spineIndex] else { continue }
builder.add(
spineIndex: item.spineIndex,
href: item.href,
title: item.title,
pageCount: summary.pageCount,
fragmentOffsets: summary.fragmentOffsets
)
}
return builder.build()
private func beginMetadataParseCancellationController(for token: UUID) -> RDEPUBMetadataParseCancellationController {
let controller = RDEPUBMetadataParseCancellationController(token: token)
metadataParseControlLock.lock()
let previous = activeMetadataParseCancellationController
activeMetadataParseCancellationController = controller
metadataParseControlLock.unlock()
previous?.cancel()
return controller
}
}

View File

@ -105,7 +105,7 @@ final class RDEPUBReaderRuntime {
context.readingSession = nil
context.textBook = nil
context.bookPageMap = nil
context.pendingFullPageMap = nil
context.pendingPageMapUpdates.removeAll()
context.activeBookmarks = []
context.activeHighlights = []
context.searchState = nil
@ -349,7 +349,7 @@ final class RDEPUBReaderRuntime {
}
func applyPendingFullPageMapIfNeeded() {
presentationRuntime.applyPendingFullPageMapIfNeeded()
presentationRuntime.commitPendingPageMapUpdateIfNeeded()
}
func finishPagination(restoreLocation: RDEPUBLocation?) {
@ -605,7 +605,7 @@ final class RDEPUBReaderRuntime {
paginationCoordinator.cancelActiveMetadataParseWork()
chapterRuntimeStore.invalidateAllForSettingsChange()
context.bookPageMap = nil
context.pendingFullPageMap = nil
context.pendingPageMapUpdates.removeAll()
jumpSessionManager.clearSession()
backgroundPriorityManager.reset()
backgroundCoverageStore.clearAll()

View File

@ -45,7 +45,7 @@ final class RDEPUBReaderServices {
func makeChapterSummaryDiskCache(bookIdentifier: String?) -> RDEPUBChapterSummaryDiskCache {
let cachesDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
?? FileManager.default.temporaryDirectory
let bookID = (bookIdentifier ?? "default").sha256Hex
let bookID = (bookIdentifier ?? "default").rd_sha256Hex
let directory = cachesDirectory
.appendingPathComponent("RDEPUBChapterSummaryCache", isDirectory: true)
.appendingPathComponent(bookID, isDirectory: true)

View File

@ -24,7 +24,7 @@ final class RDEPUBReaderState {
var searchState: RDEPUBSearchState?
var pendingFullPageMap: RDEPUBBookPageMap?
var pendingPageMapUpdates: [RDEPUBPendingPageMapUpdate] = []
var lastTextPaginationPageSize: CGSize?

View File

@ -88,10 +88,10 @@ public struct RDEPUBReaderTheme: Equatable {
extension RDEPUBReaderTheme {
var themeBackgroundColorCSS: String {
contentBackgroundColor.ss_cssString
contentBackgroundColor.rd_cssString
}
var themeTextColorCSS: String {
contentTextColor.ss_cssString
contentTextColor.rd_cssString
}
}

View File

@ -0,0 +1,17 @@
import Foundation
import CryptoKit
extension String {
/// trim nil
var rd_nilIfEmpty: String? {
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
/// SHA256 hex
var rd_sha256Hex: String {
let digest = SHA256.hash(data: Data(self.utf8))
return digest.map { String(format: "%02x", $0) }.joined()
}
}

View File

@ -0,0 +1,92 @@
import UIKit
#if canImport(DTCoreText)
import DTCoreText
#endif
enum RDEPUBDarkImageAdjuster {
private static let imageCache = NSCache<NSString, UIImage>()
#if canImport(DTCoreText)
static func adjustIfNeeded(
_ content: NSMutableAttributedString,
configuration: RDEPUBReaderConfiguration
) -> NSMutableAttributedString {
guard configuration.darkImageAdjustmentEnabled,
configuration.darkImageBlendRatio > 0,
configuration.theme.contentBackgroundColor.rd_isDarkBackground else {
return content
}
let fullRange = NSRange(location: 0, length: content.length)
content.enumerateAttribute(.attachment, in: fullRange) { value, range, _ in
guard let attachment = value as? DTImageTextAttachment,
!isCoverAttachment(attachment),
let image = attachment.image,
shouldAdjust(image) else { return }
let adjustedAttachment = DTImageTextAttachment()
adjustedAttachment.image = adjustedImage(
image,
backgroundColor: configuration.theme.contentBackgroundColor,
blendRatio: configuration.darkImageBlendRatio,
cacheKey: cacheKey(for: attachment, image: image, configuration: configuration)
)
adjustedAttachment.originalSize = attachment.originalSize
adjustedAttachment.displaySize = attachment.displaySize
adjustedAttachment.verticalAlignment = attachment.verticalAlignment
adjustedAttachment.contentURL = attachment.contentURL
adjustedAttachment.hyperLinkURL = attachment.hyperLinkURL
adjustedAttachment.hyperLinkGUID = attachment.hyperLinkGUID
adjustedAttachment.attributes = attachment.attributes
content.addAttribute(.attachment, value: adjustedAttachment, range: range)
}
return content
}
private static func isCoverAttachment(_ attachment: DTTextAttachment) -> Bool {
let lowercasedClasses = ((attachment.attributes["class"] as? String) ?? "").lowercased()
let lowercasedPath = attachment.contentURL?.lastPathComponent.lowercased()
?? ((attachment.attributes["src"] as? String) ?? "").lowercased()
return lowercasedClasses.contains("rd-front-cover-image") || lowercasedPath.contains("cover")
}
private static func shouldAdjust(_ image: UIImage) -> Bool {
image.size.width >= 80 && image.size.height >= 80
}
private static func cacheKey(
for attachment: DTImageTextAttachment,
image: UIImage,
configuration: RDEPUBReaderConfiguration
) -> NSString {
let source = attachment.contentURL?.absoluteString
?? "\(Unmanaged.passUnretained(image).toOpaque())"
return "\(source)|\(image.size.width)x\(image.size.height)|\(configuration.theme.contentBackgroundColor.rd_cssString)|\(configuration.darkImageBlendRatio)" as NSString
}
private static func adjustedImage(
_ image: UIImage,
backgroundColor: UIColor,
blendRatio: CGFloat,
cacheKey: NSString
) -> UIImage {
if let cached = imageCache.object(forKey: cacheKey) { return cached }
let format = UIGraphicsImageRendererFormat()
format.scale = image.scale
format.opaque = false
let renderer = UIGraphicsImageRenderer(size: image.size, format: format)
let adjusted = renderer.image { context in
image.draw(in: CGRect(origin: .zero, size: image.size))
backgroundColor.withAlphaComponent(max(0, min(0.35, blendRatio))).setFill()
context.cgContext.setBlendMode(.sourceAtop)
context.fill(CGRect(origin: .zero, size: image.size))
}
imageCache.setObject(adjusted, forKey: cacheKey)
return adjusted
}
#endif
}

View File

@ -0,0 +1,78 @@
import UIKit
final class RDEPUBSelectionLoupeView: UIView {
private let imageView = UIImageView()
private let magnification: CGFloat = 1.45
private let captureSize = CGSize(width: 84, height: 84)
override init(frame: CGRect) {
super.init(frame: CGRect(origin: .zero, size: CGSize(width: 96, height: 96)))
isUserInteractionEnabled = false
backgroundColor = .clear
layer.shadowColor = UIColor.black.cgColor
layer.shadowOpacity = 0.18
layer.shadowRadius = 10
layer.shadowOffset = CGSize(width: 0, height: 5)
imageView.frame = bounds
imageView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
imageView.layer.cornerRadius = bounds.width / 2
imageView.layer.cornerCurve = .continuous
imageView.layer.borderWidth = 1.5
imageView.layer.borderColor = UIColor(white: 0.82, alpha: 0.95).cgColor
imageView.clipsToBounds = true
addSubview(imageView)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func present(sourceView: UIView, focusPoint: CGPoint, hostBounds: CGRect, targetPoint: CGPoint) {
imageView.image = snapshot(from: sourceView, focusPoint: focusPoint)
let targetCenter = CGPoint(
x: min(max(targetPoint.x, hostBounds.minX + bounds.width / 2), hostBounds.maxX - bounds.width / 2),
y: min(
max(hostBounds.minY + bounds.height / 2, targetPoint.y - 74),
hostBounds.maxY - bounds.height / 2
)
)
center = targetCenter
if isHidden {
alpha = 0
transform = CGAffineTransform(scaleX: 0.92, y: 0.92)
isHidden = false
UIView.animate(withDuration: 0.12) {
self.alpha = 1
self.transform = .identity
}
}
}
func dismiss() {
guard !isHidden else { return }
isHidden = true
alpha = 0
imageView.image = nil
}
private func snapshot(from sourceView: UIView, focusPoint: CGPoint) -> UIImage {
let renderer = UIGraphicsImageRenderer(size: captureSize)
return renderer.image { context in
let cgContext = context.cgContext
cgContext.setFillColor(UIColor.systemBackground.cgColor)
cgContext.fill(CGRect(origin: .zero, size: captureSize))
cgContext.translateBy(
x: captureSize.width / 2 - focusPoint.x * magnification,
y: captureSize.height / 2 - focusPoint.y * magnification
)
cgContext.scaleBy(x: magnification, y: magnification)
sourceView.layer.render(in: cgContext)
}
}
}

View File

@ -0,0 +1,241 @@
import UIKit
final class RDEPUBTextContentInteractionCoordinator: NSObject {
enum SelectionInteractionState: Equatable {
case idle
case selectionPending
case selecting
case selectionActive
case adjustingHandle
}
struct Dependencies {
let hasRenderableContent: () -> Bool
let currentSelectionProvider: () -> RDEPUBSelection?
let isSelectionControllerSelecting: () -> Bool
let hasActiveSelection: () -> Bool
let selectionHandleAtPoint: (CGPoint) -> RDEPUBTextSelectionController.BoundaryHandle?
let selectionContainsPoint: (CGPoint) -> Bool
let renderPointForGesture: (UIGestureRecognizer) -> CGPoint
let renderPointForTouch: (UITouch) -> CGPoint
let performLongPressSelection: (UILongPressGestureRecognizer) -> Void
let performPanSelection: (UIPanGestureRecognizer) -> Void
let adjustSelection: (RDEPUBTextSelectionController.BoundaryHandle, CGPoint) -> Void
let presentLoupeAtPoint: (CGPoint) -> Void
let dismissLoupe: () -> Void
let showSelectionMenu: () -> Void
let hideSelectionMenu: () -> Void
let selectionTapSuppressionDidChange: (Bool) -> Void
let selectionPagingSuppressionDidChange: (Bool) -> Void
}
private let dependencies: Dependencies
private var activeSelectionHandle: RDEPUBTextSelectionController.BoundaryHandle?
private(set) var interactionState: SelectionInteractionState = .idle
var isInteractionInProgress: Bool {
interactionState != .idle
}
init(dependencies: Dependencies) {
self.dependencies = dependencies
super.init()
}
func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
dependencies.performLongPressSelection(gesture)
switch gesture.state {
case .began:
activeSelectionHandle = nil
updateSelectionInteractionState(.selecting)
dependencies.hideSelectionMenu()
dependencies.presentLoupeAtPoint(dependencies.renderPointForGesture(gesture))
case .changed:
updateSelectionInteractionState(.selecting)
dependencies.presentLoupeAtPoint(dependencies.renderPointForGesture(gesture))
case .ended:
activeSelectionHandle = nil
dependencies.dismissLoupe()
dependencies.showSelectionMenu()
case .cancelled, .failed:
activeSelectionHandle = nil
dependencies.dismissLoupe()
default:
break
}
}
func handlePan(_ gesture: UIPanGestureRecognizer) {
let point = dependencies.renderPointForGesture(gesture)
if gesture.state == .began, activeSelectionHandle == nil,
let handle = dependencies.selectionHandleAtPoint(point) {
activeSelectionHandle = handle
updateSelectionInteractionState(.adjustingHandle)
dependencies.hideSelectionMenu()
dependencies.presentLoupeAtPoint(point)
}
if let activeSelectionHandle {
dependencies.adjustSelection(activeSelectionHandle, point)
dependencies.presentLoupeAtPoint(point)
} else {
dependencies.performPanSelection(gesture)
if dependencies.isSelectionControllerSelecting() {
dependencies.presentLoupeAtPoint(point)
}
}
switch gesture.state {
case .ended:
activeSelectionHandle = nil
updateSelectionInteractionState(
dependencies.currentSelectionProvider() == nil ? .idle : .selectionActive
)
dependencies.dismissLoupe()
dependencies.showSelectionMenu()
case .cancelled, .failed:
activeSelectionHandle = nil
updateSelectionInteractionState(
dependencies.currentSelectionProvider() == nil ? .idle : .selectionActive
)
dependencies.dismissLoupe()
default:
break
}
}
func selectionControllerStateDidChange(_ state: RDEPUBTextSelectionController.InteractionState) {
switch state {
case .idle:
if dependencies.currentSelectionProvider() == nil, activeSelectionHandle == nil {
updateSelectionInteractionState(.idle)
}
case .selecting:
updateSelectionInteractionState(.selecting)
case .selectionActive:
updateSelectionInteractionState(
dependencies.currentSelectionProvider() == nil ? .idle : .selectionActive
)
case .adjustingHandle:
updateSelectionInteractionState(.adjustingHandle)
}
}
func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard interactionState == .idle,
activeSelectionHandle == nil,
dependencies.hasRenderableContent(),
let touch = touches.first else {
return
}
let point = dependencies.renderPointForTouch(touch)
guard dependencies.selectionHandleAtPoint(point) == nil else { return }
updateSelectionInteractionState(.selectionPending)
}
func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
resetSelectionPendingIfNeeded()
}
func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
resetSelectionPendingIfNeeded()
}
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer is UIPanGestureRecognizer {
if dependencies.isSelectionControllerSelecting() {
return true
}
let point = dependencies.renderPointForGesture(gestureRecognizer)
return dependencies.selectionHandleAtPoint(point) != nil
}
if gestureRecognizer is UILongPressGestureRecognizer {
guard dependencies.hasRenderableContent() else {
return true
}
let point = dependencies.renderPointForGesture(gestureRecognizer)
if dependencies.selectionHandleAtPoint(point) != nil {
return false
}
if dependencies.hasActiveSelection(), dependencies.selectionContainsPoint(point) {
return false
}
return true
}
return true
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
guard dependencies.hasRenderableContent() else {
return true
}
let point = dependencies.renderPointForTouch(touch)
if let handle = dependencies.selectionHandleAtPoint(point) {
if gestureRecognizer is UIPanGestureRecognizer {
activeSelectionHandle = handle
updateSelectionInteractionState(.adjustingHandle)
dependencies.hideSelectionMenu()
return true
}
if gestureRecognizer is UILongPressGestureRecognizer || gestureRecognizer is UITapGestureRecognizer {
return false
}
}
return true
}
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
gestureRecognizer is UILongPressGestureRecognizer || gestureRecognizer is UIPanGestureRecognizer
}
func reset() {
activeSelectionHandle = nil
updateSelectionInteractionState(.idle)
dependencies.dismissLoupe()
}
private func resetSelectionPendingIfNeeded() {
guard interactionState == .selectionPending else { return }
if dependencies.currentSelectionProvider() != nil {
updateSelectionInteractionState(.selectionActive)
} else {
updateSelectionInteractionState(.idle)
}
}
private func updateSelectionInteractionState(_ state: SelectionInteractionState) {
let previousTapSuppressed = interactionState != .idle
let previousPagingSuppressed = shouldSuppressPagingInteraction(for: interactionState)
interactionState = state
let currentTapSuppressed = interactionState != .idle
let currentPagingSuppressed = shouldSuppressPagingInteraction(for: interactionState)
if previousTapSuppressed != currentTapSuppressed {
dependencies.selectionTapSuppressionDidChange(currentTapSuppressed)
}
if previousPagingSuppressed != currentPagingSuppressed {
dependencies.selectionPagingSuppressionDidChange(currentPagingSuppressed)
}
}
private func shouldSuppressPagingInteraction(for state: SelectionInteractionState) -> Bool {
switch state {
case .idle, .selectionPending, .selectionActive:
return false
case .selecting, .adjustingHandle:
return true
}
}
}

View File

@ -26,17 +26,7 @@ protocol RDEPUBTextContentViewDelegate: AnyObject {
)
}
final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
enum SelectionInteractionState: Equatable {
case idle
case selectionPending
case selecting
case selectionActive
case adjustingHandle
}
private static let darkAdjustedImageCache = NSCache<NSString, UIImage>()
final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReaderCachePolicyProviding {
private var contentInsets: UIEdgeInsets = .zero
@ -48,12 +38,8 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
private var menuSelection: RDEPUBSelection?
private var activeSelectionHandle: RDEPUBTextSelectionController.BoundaryHandle?
private let selectionLoupeView = RDEPUBSelectionLoupeView()
private var selectionInteractionState: SelectionInteractionState = .idle
private var currentHighlights: [RDEPUBHighlight] = []
private var currentSearchState: RDEPUBSearchState?
@ -123,6 +109,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
private lazy var panGestureRecognizer: UIPanGestureRecognizer = {
let gesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
gesture.isEnabled = false
gesture.delegate = self
return gesture
}()
@ -133,6 +120,91 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
return gesture
}()
private lazy var interactionCoordinator: RDEPUBTextContentInteractionCoordinator = {
RDEPUBTextContentInteractionCoordinator(
dependencies: .init(
hasRenderableContent: { [weak self] in
self?.hasInteractiveTextContent ?? false
},
currentSelectionProvider: { [weak self] in self?.currentSelection },
isSelectionControllerSelecting: { [weak self] in
self?.selectionController.isSelecting ?? false
},
hasActiveSelection: { [weak self] in
self?.selectionController.hasActiveSelection ?? false
},
selectionHandleAtPoint: { [weak self] point in
guard let renderView = self?.coreTextRenderView else { return nil }
let handle = renderView.selectionHandle(at: point)
switch handle {
case .start:
return .start
case .end:
return .end
case nil:
return nil
}
},
selectionContainsPoint: { [weak self] point in
self?.coreTextRenderView?.selectionContains(point) ?? false
},
renderPointForGesture: { [weak self] gesture in
guard let self else { return .zero }
return gesture.location(in: self.coreTextRenderView ?? self)
},
renderPointForTouch: { [weak self] touch in
guard let self else { return .zero }
return touch.location(in: self.coreTextRenderView ?? self)
},
performLongPressSelection: { [weak self] gesture in
guard let self else { return }
self.layoutIfNeeded()
self.selectionController.handleLongPress(
gesture,
renderView: self.coreTextRenderView,
interactionController: self.interactionController
)
},
performPanSelection: { [weak self] gesture in
guard let self else { return }
self.layoutIfNeeded()
self.selectionController.handlePan(
gesture,
renderView: self.coreTextRenderView,
interactionController: self.interactionController
)
},
adjustSelection: { [weak self] handle, point in
guard let self else { return }
self.selectionController.updateSelection(
byAdjusting: handle,
at: point,
renderView: self.coreTextRenderView,
interactionController: self.interactionController
)
},
presentLoupeAtPoint: { [weak self] point in
self?.updateSelectionLoupe(for: point)
},
dismissLoupe: { [weak self] in
self?.selectionLoupeView.dismiss()
},
showSelectionMenu: { [weak self] in
self?.showSelectionMenuIfNeeded()
},
hideSelectionMenu: { [weak self] in
self?.hideSelectionMenu()
},
selectionTapSuppressionDidChange: { [weak self] isSuppressed in
self?.selectionTapSuppressionDidChange?(isSuppressed)
},
selectionPagingSuppressionDidChange: { [weak self] isSuppressed in
self?.selectionPagingSuppressionDidChange?(isSuppressed)
}
)
)
}()
override init(frame: CGRect) {
super.init(frame: frame)
accessibilityIdentifier = "epub.reader.content.view"
@ -159,11 +231,16 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
} else {
self.hideSelectionMenu()
}
self.updateSelectionPanAvailability()
self.updateViewInteractionAvailability()
self.updateAccessibilityDecorationSummary()
self.delegate?.textContentView(self, didChangeSelection: selection)
}
selectionController.interactionStateDidChange = { [weak self] state in
self?.handleSelectionControllerStateChange(state)
guard let self else { return }
self.interactionCoordinator.selectionControllerStateDidChange(state)
self.updateSelectionPanAvailability()
self.updateViewInteractionAvailability()
}
selectionController.pageProvider = { [weak self] in self?.currentPage }
selectionController.chapterCFIMapProvider = { [weak self] in self?.currentChapterCFIMap }
@ -183,7 +260,19 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
}
var isSelectionInteractionInProgress: Bool {
selectionInteractionState != .idle
interactionCoordinator.isInteractionInProgress
}
var shouldAvoidReaderPageCaching: Bool {
currentPage == nil || loadingSpinner.isAnimating
}
private var hasInteractiveTextContent: Bool {
#if canImport(DTCoreText)
currentPage != nil && coreTextRenderView?.isHidden == false
#else
currentPage != nil
#endif
}
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
@ -241,7 +330,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
currentChapterFragmentOffsets = chapterFragmentOffsets
currentSelection = nil
menuSelection = nil
updateSelectionInteractionState(.idle)
interactionCoordinator.reset()
currentHighlights = highlights
currentSearchState = searchState
selectionController.clearSelection(renderView: coreTextRenderView)
@ -257,6 +346,9 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
coreTextDisplayContent = nil
coreTextDisplayRange = nil
#endif
updateStaticGestureAvailability()
updateSelectionPanAvailability()
updateViewInteractionAvailability()
delegate?.textContentView(self, didChangeSelection: nil)
setNeedsLayout()
return
@ -266,7 +358,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
coverImageView.image = nil
#if canImport(DTCoreText)
let displayContent = darkImageAdjustedContentIfNeeded(
let displayContent = RDEPUBDarkImageAdjuster.adjustIfNeeded(
normalizedPageContent(from: page),
configuration: configuration
)
@ -301,6 +393,9 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
#if canImport(DTCoreText)
backgroundOverlayView.configure(page: page, selectionColor: overlayView.selectionColor, snapshot: interactionController.snapshot)
#endif
updateStaticGestureAvailability()
updateSelectionPanAvailability()
updateViewInteractionAvailability()
updateAccessibilityDecorationSummary()
delegate?.textContentView(self, didChangeSelection: nil)
@ -317,8 +412,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
currentChapterFragmentOffsets = [:]
currentSelection = nil
menuSelection = nil
activeSelectionHandle = nil
updateSelectionInteractionState(.idle)
interactionCoordinator.reset()
currentHighlights = []
currentSearchState = nil
selectionController.clearSelection(renderView: coreTextRenderView)
@ -340,6 +434,9 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
overlayView.clearSelection()
backgroundOverlayView.clearSelection()
updateStaticGestureAvailability()
updateSelectionPanAvailability()
updateViewInteractionAvailability()
delegate?.textContentView(self, didChangeSelection: nil)
loadingSpinner.startAnimating()
setNeedsLayout()
@ -349,12 +446,12 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
currentSelection = nil
menuSelection = nil
currentSearchState = nil
activeSelectionHandle = nil
updateSelectionInteractionState(.idle)
selectionLoupeView.dismiss()
interactionCoordinator.reset()
selectionController.clearSelection(renderView: coreTextRenderView)
overlayView.clearSelection()
backgroundOverlayView.clearSelection()
updateSelectionPanAvailability()
updateViewInteractionAvailability()
updateAccessibilityDecorationSummary()
UIMenuController.shared.setMenuVisible(false, animated: true)
}
@ -411,87 +508,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
return nil
}
#if canImport(DTCoreText)
private func darkImageAdjustedContentIfNeeded(
_ content: NSMutableAttributedString,
configuration: RDEPUBReaderConfiguration
) -> NSMutableAttributedString {
guard configuration.darkImageAdjustmentEnabled,
configuration.darkImageBlendRatio > 0,
configuration.theme.contentBackgroundColor.rd_isDarkReaderBackground else {
return content
}
let fullRange = NSRange(location: 0, length: content.length)
content.enumerateAttribute(.attachment, in: fullRange) { value, range, _ in
guard let attachment = value as? DTImageTextAttachment,
!isCoverAttachment(attachment),
let image = attachment.image,
shouldAdjustDarkImage(image) else { return }
let adjustedAttachment = DTImageTextAttachment()
adjustedAttachment.image = adjustedImage(
image,
backgroundColor: configuration.theme.contentBackgroundColor,
blendRatio: configuration.darkImageBlendRatio,
cacheKey: darkImageCacheKey(for: attachment, image: image, configuration: configuration)
)
adjustedAttachment.originalSize = attachment.originalSize
adjustedAttachment.displaySize = attachment.displaySize
adjustedAttachment.verticalAlignment = attachment.verticalAlignment
adjustedAttachment.contentURL = attachment.contentURL
adjustedAttachment.hyperLinkURL = attachment.hyperLinkURL
adjustedAttachment.hyperLinkGUID = attachment.hyperLinkGUID
adjustedAttachment.attributes = attachment.attributes
content.addAttribute(.attachment, value: adjustedAttachment, range: range)
}
return content
}
private func isCoverAttachment(_ attachment: DTTextAttachment) -> Bool {
let lowercasedClasses = ((attachment.attributes["class"] as? String) ?? "").lowercased()
let lowercasedPath = attachment.contentURL?.lastPathComponent.lowercased()
?? ((attachment.attributes["src"] as? String) ?? "").lowercased()
return lowercasedClasses.contains("rd-front-cover-image") || lowercasedPath.contains("cover")
}
private func shouldAdjustDarkImage(_ image: UIImage) -> Bool {
image.size.width >= 80 && image.size.height >= 80
}
private func darkImageCacheKey(
for attachment: DTImageTextAttachment,
image: UIImage,
configuration: RDEPUBReaderConfiguration
) -> NSString {
let source = attachment.contentURL?.absoluteString
?? "\(Unmanaged.passUnretained(image).toOpaque())"
return "\(source)|\(image.size.width)x\(image.size.height)|\(configuration.theme.contentBackgroundColor.ss_cssString)|\(configuration.darkImageBlendRatio)" as NSString
}
private func adjustedImage(
_ image: UIImage,
backgroundColor: UIColor,
blendRatio: CGFloat,
cacheKey: NSString
) -> UIImage {
if let cached = Self.darkAdjustedImageCache.object(forKey: cacheKey) { return cached }
let format = UIGraphicsImageRendererFormat()
format.scale = image.scale
format.opaque = false
let renderer = UIGraphicsImageRenderer(size: image.size, format: format)
let adjusted = renderer.image { context in
image.draw(in: CGRect(origin: .zero, size: image.size))
backgroundColor.withAlphaComponent(max(0, min(0.35, blendRatio))).setFill()
context.cgContext.setBlendMode(.sourceAtop)
context.fill(CGRect(origin: .zero, size: image.size))
}
Self.darkAdjustedImageCache.setObject(adjusted, forKey: cacheKey)
return adjusted
}
#endif
private func applyHighlightsToContent(
_ content: NSMutableAttributedString,
highlights: [RDEPUBHighlight],
@ -581,80 +597,38 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
#endif
@objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
#if canImport(DTCoreText)
layoutIfNeeded()
selectionController.handleLongPress(
gesture,
renderView: coreTextRenderView,
interactionController: interactionController
)
switch gesture.state {
case .began:
activeSelectionHandle = nil
updateSelectionInteractionState(.selecting)
hideSelectionMenu()
updateSelectionLoupe(for: gesture.location(in: coreTextRenderView ?? self))
case .changed:
updateSelectionInteractionState(.selecting)
updateSelectionLoupe(for: gesture.location(in: coreTextRenderView ?? self))
case .ended:
selectionLoupeView.dismiss()
showSelectionMenuIfNeeded()
case .cancelled, .failed:
activeSelectionHandle = nil
selectionLoupeView.dismiss()
default:
break
}
#endif
interactionCoordinator.handleLongPress(gesture)
}
@objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
#if canImport(DTCoreText)
layoutIfNeeded()
let point = gesture.location(in: coreTextRenderView ?? self)
if gesture.state == .began, activeSelectionHandle == nil {
if let renderView = coreTextRenderView,
let handle = renderView.selectionHandle(at: point) {
activeSelectionHandle = handle == .start ? .start : .end
updateSelectionInteractionState(.adjustingHandle)
hideSelectionMenu()
updateSelectionLoupe(for: point)
}
}
interactionCoordinator.handlePan(gesture)
}
if let activeSelectionHandle, let renderView = coreTextRenderView {
selectionController.updateSelection(
byAdjusting: activeSelectionHandle,
at: point,
renderView: renderView,
interactionController: interactionController
)
updateSelectionLoupe(for: point)
} else {
selectionController.handlePan(
gesture,
renderView: coreTextRenderView,
interactionController: interactionController
)
if selectionController.isSelecting {
updateSelectionLoupe(for: point)
}
}
switch gesture.state {
case .ended:
activeSelectionHandle = nil
updateSelectionInteractionState(currentSelection == nil ? .idle : .selectionActive)
selectionLoupeView.dismiss()
showSelectionMenuIfNeeded()
case .cancelled, .failed:
activeSelectionHandle = nil
updateSelectionInteractionState(currentSelection == nil ? .idle : .selectionActive)
selectionLoupeView.dismiss()
default:
break
}
#endif
private func updateSelectionPanAvailability() {
let shouldEnablePan = selectionController.isSelecting
|| selectionController.hasActiveSelection
|| interactionCoordinator.interactionState == .adjustingHandle
guard panGestureRecognizer.isEnabled != shouldEnablePan else { return }
panGestureRecognizer.isEnabled = shouldEnablePan
}
private func updateViewInteractionAvailability() {
let shouldEnableInteraction = hasInteractiveTextContent
|| selectionController.isSelecting
|| selectionController.hasActiveSelection
|| interactionCoordinator.interactionState != .idle
guard isUserInteractionEnabled != shouldEnableInteraction else { return }
isUserInteractionEnabled = shouldEnableInteraction
}
private func updateStaticGestureAvailability() {
let shouldEnableLongPress = hasInteractiveTextContent && loadingSpinner.isAnimating == false
let shouldEnableTap = hasInteractiveTextContent && loadingSpinner.isAnimating == false
let longPressChanged = longPressGestureRecognizer.isEnabled != shouldEnableLongPress
let tapChanged = tapGestureRecognizer.isEnabled != shouldEnableTap
guard longPressChanged || tapChanged else { return }
longPressGestureRecognizer.isEnabled = shouldEnableLongPress
tapGestureRecognizer.isEnabled = shouldEnableTap
}
@objc private func handleTap(_ gesture: UITapGestureRecognizer) {
@ -728,44 +702,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
#endif
}
private func handleSelectionControllerStateChange(_ state: RDEPUBTextSelectionController.InteractionState) {
switch state {
case .idle:
if currentSelection == nil, activeSelectionHandle == nil {
updateSelectionInteractionState(.idle)
}
case .selecting:
updateSelectionInteractionState(.selecting)
case .selectionActive:
updateSelectionInteractionState(currentSelection == nil ? .idle : .selectionActive)
case .adjustingHandle:
updateSelectionInteractionState(.adjustingHandle)
}
}
private func updateSelectionInteractionState(_ state: SelectionInteractionState) {
let previousTapSuppressed = selectionInteractionState != .idle
let previousPagingSuppressed = shouldSuppressPagingInteraction(for: selectionInteractionState)
selectionInteractionState = state
let currentTapSuppressed = selectionInteractionState != .idle
let currentPagingSuppressed = shouldSuppressPagingInteraction(for: selectionInteractionState)
if previousTapSuppressed != currentTapSuppressed {
selectionTapSuppressionDidChange?(currentTapSuppressed)
}
if previousPagingSuppressed != currentPagingSuppressed {
selectionPagingSuppressionDidChange?(currentPagingSuppressed)
}
}
private func shouldSuppressPagingInteraction(for state: SelectionInteractionState) -> Bool {
switch state {
case .idle, .selectionPending, .selectionActive:
return false
case .selecting, .adjustingHandle:
return true
}
}
private var coreTextRenderView: RDEPUBTextPageRenderView? {
#if canImport(DTCoreText)
return coreTextContentView
@ -784,7 +720,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
}
private func highlight(at point: CGPoint) -> RDEPUBHighlight? {
guard let page = currentPage else { return nil }
guard currentPage != nil else { return nil }
let absoluteRange = backgroundOverlayView.absoluteRange(at: point) ?? overlayView.absoluteRange(at: point)
guard let absoluteRange else { return nil }
let matches = currentHighlights.filter { highlight in
@ -857,7 +793,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
func shouldSuppressReaderTap(at point: CGPoint) -> Bool {
#if canImport(DTCoreText)
if selectionInteractionState == .selectionPending {
if interactionCoordinator.interactionState == .selectionPending {
return true
}
@ -871,7 +807,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
if selectionController.hasActiveSelection, renderView.selectionContains(renderPoint) {
return true
}
switch selectionInteractionState {
switch interactionCoordinator.interactionState {
case .idle:
return false
case .selectionPending, .selecting, .selectionActive, .adjustingHandle:
@ -884,65 +820,20 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesBegan(touches, with: event)
#if canImport(DTCoreText)
guard selectionInteractionState == .idle,
activeSelectionHandle == nil,
currentPage != nil,
let touch = touches.first,
let renderView = coreTextRenderView else {
return
}
let point = touch.location(in: renderView)
guard renderView.selectionHandle(at: point) == nil else { return }
updateSelectionInteractionState(.selectionPending)
#endif
interactionCoordinator.touchesBegan(touches, with: event)
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesEnded(touches, with: event)
resetSelectionPendingIfNeeded()
interactionCoordinator.touchesEnded(touches, with: event)
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesCancelled(touches, with: event)
resetSelectionPendingIfNeeded()
}
private func resetSelectionPendingIfNeeded() {
guard selectionInteractionState == .selectionPending else { return }
if currentSelection != nil {
updateSelectionInteractionState(.selectionActive)
} else {
updateSelectionInteractionState(.idle)
}
interactionCoordinator.touchesCancelled(touches, with: event)
}
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer === panGestureRecognizer {
if selectionController.isSelecting {
return true
}
guard let pan = gestureRecognizer as? UIPanGestureRecognizer,
let renderView = coreTextRenderView else {
return false
}
let point = pan.location(in: renderView)
return renderView.selectionHandle(at: point) != nil
}
if gestureRecognizer === longPressGestureRecognizer {
guard let longPress = gestureRecognizer as? UILongPressGestureRecognizer,
let renderView = coreTextRenderView else {
return true
}
let point = longPress.location(in: renderView)
if renderView.selectionHandle(at: point) != nil {
return false
}
if selectionController.hasActiveSelection, renderView.selectionContains(point) {
return false
}
return true
}
if gestureRecognizer === tapGestureRecognizer {
guard let tapGestureRecognizer = gestureRecognizer as? UITapGestureRecognizer else {
return false
@ -962,123 +853,35 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
}
return currentPage != nil
}
return true
return interactionCoordinator.gestureRecognizerShouldBegin(gestureRecognizer)
}
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldReceive touch: UITouch
) -> Bool {
guard let renderView = coreTextRenderView else {
return true
}
let point = touch.location(in: renderView)
if let handle = renderView.selectionHandle(at: point) {
if gestureRecognizer === panGestureRecognizer {
activeSelectionHandle = handle == .start ? .start : .end
updateSelectionInteractionState(.adjustingHandle)
hideSelectionMenu()
if gestureRecognizer === tapGestureRecognizer {
guard let renderView = coreTextRenderView else {
return true
}
if gestureRecognizer === longPressGestureRecognizer || gestureRecognizer === tapGestureRecognizer {
let point = touch.location(in: renderView)
if renderView.selectionHandle(at: point) != nil {
return false
}
}
return true
return interactionCoordinator.gestureRecognizer(gestureRecognizer, shouldReceive: touch)
}
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
gestureRecognizer === longPressGestureRecognizer || gestureRecognizer === panGestureRecognizer
}
}
private final class RDEPUBSelectionLoupeView: UIView {
private let imageView = UIImageView()
private let magnification: CGFloat = 1.45
private let captureSize = CGSize(width: 84, height: 84)
override init(frame: CGRect) {
super.init(frame: CGRect(origin: .zero, size: CGSize(width: 96, height: 96)))
isUserInteractionEnabled = false
backgroundColor = .clear
layer.shadowColor = UIColor.black.cgColor
layer.shadowOpacity = 0.18
layer.shadowRadius = 10
layer.shadowOffset = CGSize(width: 0, height: 5)
imageView.frame = bounds
imageView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
imageView.layer.cornerRadius = bounds.width / 2
imageView.layer.cornerCurve = .continuous
imageView.layer.borderWidth = 1.5
imageView.layer.borderColor = UIColor(white: 0.82, alpha: 0.95).cgColor
imageView.clipsToBounds = true
addSubview(imageView)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func present(sourceView: UIView, focusPoint: CGPoint, hostBounds: CGRect, targetPoint: CGPoint) {
imageView.image = snapshot(from: sourceView, focusPoint: focusPoint)
let targetCenter = CGPoint(
x: min(max(targetPoint.x, hostBounds.minX + bounds.width / 2), hostBounds.maxX - bounds.width / 2),
y: min(
max(hostBounds.minY + bounds.height / 2, targetPoint.y - 74),
hostBounds.maxY - bounds.height / 2
gestureRecognizer === tapGestureRecognizer
|| interactionCoordinator.gestureRecognizer(
gestureRecognizer,
shouldRecognizeSimultaneouslyWith: otherGestureRecognizer
)
)
center = targetCenter
if isHidden {
alpha = 0
transform = CGAffineTransform(scaleX: 0.92, y: 0.92)
isHidden = false
UIView.animate(withDuration: 0.12) {
self.alpha = 1
self.transform = .identity
}
}
}
func dismiss() {
guard !isHidden else { return }
isHidden = true
alpha = 0
imageView.image = nil
}
private func snapshot(from sourceView: UIView, focusPoint: CGPoint) -> UIImage {
let renderer = UIGraphicsImageRenderer(size: captureSize)
return renderer.image { context in
let cgContext = context.cgContext
cgContext.setFillColor(UIColor.systemBackground.cgColor)
cgContext.fill(CGRect(origin: .zero, size: captureSize))
cgContext.translateBy(
x: captureSize.width / 2 - focusPoint.x * magnification,
y: captureSize.height / 2 - focusPoint.y * magnification
)
cgContext.scaleBy(x: magnification, y: magnification)
sourceView.layer.render(in: cgContext)
}
}
}
private extension UIColor {
var rd_isDarkReaderBackground: Bool {
var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0
guard getRed(&red, green: &green, blue: &blue, alpha: &alpha) else { return false }
return (0.2126 * red + 0.7152 * green + 0.0722 * blue) < 0.35
}
}

View File

@ -0,0 +1,31 @@
import UIKit
extension UIColor {
/// rgba CSS "rgba(255, 128, 0, 1.000)"
var rd_cssString: String {
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var alpha: CGFloat = 0
getRed(&red, green: &green, blue: &blue, alpha: &alpha)
return String(format: "rgba(%d, %d, %d, %.3f)", Int(red * 255), Int(green * 255), Int(blue * 255), alpha)
}
/// #RRGGBB hex
var rd_hexString: String {
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var alpha: CGFloat = 0
getRed(&red, green: &green, blue: &blue, alpha: &alpha)
return String(format: "#%02X%02X%02X", Int(red * 255), Int(green * 255), Int(blue * 255))
}
/// BT.709 < 0.4
var rd_isDarkBackground: Bool {
var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0
guard getRed(&red, green: &green, blue: &blue, alpha: &alpha) else { return false }
return (0.2126 * red + 0.7152 * green + 0.0722 * blue) < 0.4
}
}

View File

@ -1,6 +1,10 @@
import UIKit
protocol RDReaderCachePolicyProviding {
var shouldAvoidReaderPageCaching: Bool { get }
}
final class RDReaderPreloadController {
var radius: Int = 2
@ -76,7 +80,11 @@ final class RDReaderPreloadController {
) -> UIView {
let reusableView = detachedReusablePageView(for: pageNum)
let view = contentViewProvider(pageNum, reusableView) ?? reusableView ?? UIView()
pageCurlCachedViews[pageNum] = view
if shouldCache(view: view, for: pageNum, environment: environment) {
pageCurlCachedViews[pageNum] = view
} else {
pageCurlCachedViews.removeValue(forKey: pageNum)
}
return view
}
@ -105,12 +113,20 @@ final class RDReaderPreloadController {
for targetPage in targets {
let existing = detachedReusablePageView(for: targetPage)
let contentView = contentViewProvider(targetPage, existing) ?? existing ?? UIView()
preloadedPageViews[targetPage] = contentView
let shouldCacheContentView = shouldCache(view: contentView, for: targetPage, environment: environment)
if shouldCacheContentView {
preloadedPageViews[targetPage] = contentView
} else {
preloadedPageViews.removeValue(forKey: targetPage)
}
if contentView.superview !== preloadHostView {
contentView.removeFromSuperview()
preloadHostView.addSubview(contentView)
}
contentView.frame = preloadHostView.bounds
if !shouldCacheContentView {
contentView.removeFromSuperview()
}
}
}
@ -139,6 +155,14 @@ final class RDReaderPreloadController {
&& pageNum < environment.totalPages
}
private func shouldCache(view: UIView, for pageNum: Int, environment: Environment) -> Bool {
guard shouldCachePage(pageNum, environment: environment) else { return false }
if let policyProvider = view as? RDReaderCachePolicyProviding {
return !policyProvider.shouldAvoidReaderPageCaching
}
return true
}
private func isDualPage(environment: Environment) -> Bool {
environment.landscapeDualPageEnabled
&& environment.isLandscape

View File

@ -96,6 +96,11 @@ extension RDReaderView: UIPageViewControllerDataSource, UIPageViewControllerDele
}
public func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
let resolvedPage = (pageViewController.viewControllers?.first as? RDReaderPageChildViewController)?.pageNum ?? -1
RDEPUBBackgroundTrace.log(
"PageCurl",
"didFinishAnimating finished=\(finished) completed=\(completed) resolvedPage=\(resolvedPage + 1) currentPageBeforeFinish=\(currentPage + 1)"
)
if completed, let firstVC = pageViewController.viewControllers?.first as? RDReaderPageChildViewController {
let pn = firstVC.pageNum
if pn != RDReaderView.blankPageNum && pn != RDReaderView.blankEndPageNum {
@ -114,8 +119,17 @@ extension RDReaderView: UIPageViewControllerDataSource, UIPageViewControllerDele
if let target = pendingViewControllers.first as? RDReaderPageChildViewController,
target.pageNum != RDReaderView.blankPageNum,
target.pageNum != RDReaderView.blankEndPageNum {
RDEPUBBackgroundTrace.log(
"PageCurl",
"willTransitionTo targetPage=\(target.pageNum + 1) currentPage=\(currentPage + 1)"
)
predictedPageDirection = target.pageNum >= currentPage
primePageCache(around: target.pageNum, preferredForward: predictedPageDirection)
} else {
RDEPUBBackgroundTrace.log(
"PageCurl",
"willTransitionTo blank target currentPage=\(currentPage + 1)"
)
}
}
}

View File

@ -267,6 +267,10 @@ public class RDReaderView: UIView {
set { pagingController.pendingTransitionRequest = newValue }
}
var isPageCurlTransitioning: Bool {
currentDisplayType == .pageCurl && pagingController.isTransitioning
}
override init(frame: CGRect) {
super.init(frame: frame)
accessibilityIdentifier = "epub.reader.content"
@ -401,6 +405,8 @@ public class RDReaderView: UIView {
if let pending = pagingController.finishPageCurlTransition() {
transitionToPage(pageNum: pending.pageNum, animated: pending.animated)
}
updatePagingInteractionSuppression()
}
private var preloadEnvironment: RDReaderPreloadController.Environment {
@ -603,12 +609,24 @@ public class RDReaderView: UIView {
}
private func updatePagingInteractionSuppression() {
let shouldSuppress = selectionPagingSuppressedContentViews.allObjects.isEmpty == false
let shouldSuppress = activePagingSuppressionContentViews().isEmpty == false
guard shouldSuppress != isPagingInteractionSuppressed else { return }
isPagingInteractionSuppressed = shouldSuppress
setPagingInteractionEnabled(!shouldSuppress)
}
private func activePagingSuppressionContentViews() -> [UIView] {
selectionPagingSuppressedContentViews.allObjects.filter { contentView in
guard contentView.window != nil,
contentView.isDescendant(of: self),
contentView.bounds.isEmpty == false else {
return false
}
let frameInReader = contentView.convert(contentView.bounds, to: self)
return frameInReader.intersects(bounds)
}
}
private func setPagingInteractionEnabled(_ isEnabled: Bool) {
collectionView.isScrollEnabled = isEnabled
pageViewController.gestureRecognizers.forEach { $0.isEnabled = isEnabled }
@ -721,7 +739,13 @@ public class RDReaderView: UIView {
}
public func reloadPageCountOnly() {
collectionView.reloadData()
if currentDisplayType == .pageCurl {
if !isPageCurlTransitioning, currentPage >= 0, numberOfPages() > 0 {
transitionToPage(pageNum: currentPage, animated: false)
}
} else {
collectionView.reloadData()
}
topToolView = resolvedTopChromeView()
bottomToolView = resolvedBottomChromeView()
}