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:
parent
7de661eb54
commit
d15f20b097
@ -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 绘制文本后,遍历当前页 RDEPUBHighlight,CGContext 绘制背景矩形(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 patch):P2,当前未遇到相关崩溃
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*分析范围:WXRead 逆向文档 → ReadViewSDK 架构差距*
|
||||
*产出:4 个 Area、14 项决策、1 项延迟*
|
||||
@ -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 // 分页状态与窗口替换
|
||||
|
||||
@ -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
|
||||
- 最低 API:24(Android 7.0)
|
||||
- 目标 API:34
|
||||
- 构建工具: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`(SAX),Android 使用 `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 |
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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` | 解析取消控制器 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -1,265 +0,0 @@
|
||||
# Reflowable EPUB 使用 WXRead 风格原生渲染:详细设计
|
||||
|
||||
> 文档目的:讨论并固化“将 ReadViewSDK 的 reflowable EPUB 渲染/排版/分页改为参考 Doc/WXRead 的读书(WXRead)原生渲染方式”的可落地设计,供后续开发与回归使用。
|
||||
> 版本:v0(设计草案)
|
||||
> 日期:2026-05-21
|
||||
|
||||
## 0. 背景与结论(先说人话)
|
||||
|
||||
ReadViewSDK 当前对 EPUB 有三类渲染路径:
|
||||
|
||||
- `RDEPUBReadingProfile.webFixedLayout`:Fixed Layout EPUB → `WKWebView`(保持不变)
|
||||
- `RDEPUBReadingProfile.webInteractive`:交互式 EPUB(JS/音视频/表单/iframe/外链/bridge)→ `WKWebView`(保持不变)
|
||||
- `RDEPUBReadingProfile.textReflowable`:普通 reflowable EPUB → **当前走 DTCoreText → NSAttributedString → CoreText 分页 → 原生文本视图**(这是我们要“升级成 WXRead 风格”的主战场)
|
||||
|
||||
本次改造的最小可落地方向是:**保留三分流策略不变**,只增强 `.textReflowable` 分支,使其在“CSS 分层、样式一致性、资源解析、分页稳定性”等方面更接近 `Doc/WXRead/analysis/EPUB渲染管线详解.md` 所描述的 WXRead 管线,而不是引入新的 WebView 渲染。
|
||||
|
||||
## 1. 目标 / 非目标
|
||||
|
||||
### 1.1 目标(In Scope)
|
||||
|
||||
- G1:reflowable EPUB 的正文渲染/排版/分页改为“WXRead 风格原生渲染管线”:
|
||||
- XHTML/HTML →(CSS 分层 + 解析 + 后处理)→ `NSAttributedString`
|
||||
- `NSAttributedString` →(CoreText 分页)→ 单页内容
|
||||
- 单页内容 → 原生绘制/展示
|
||||
- G2:保持 Fixed Layout 与交互式 EPUB 的 `WKWebView` 路径不回归。
|
||||
- G3:不破坏 `RDURLReaderController` 打开 `.epub` / `.txt` 的主流程。
|
||||
- G4:Demo 可用于验证:至少 2-3 本典型 reflowable EPUB 在段落/标题/图片/链接等常见内容下可稳定阅读。
|
||||
|
||||
### 1.2 非目标(Out of Scope)
|
||||
|
||||
- N1:Fixed Layout EPUB 切换为原生渲染(明确不做)。
|
||||
- N2:交互式 EPUB 切换为原生渲染(明确不做)。
|
||||
- N3:一次性复刻 WXRead 对 DTCoreText 的所有深度魔改(例如自定义 CSS 属性体系、复杂后处理、分页避断规则等)——本次先按“问题驱动”逐步对齐。
|
||||
|
||||
## 2. 关键事实核验(当前代码真实路径)
|
||||
|
||||
> 纠偏说明:项目初始化时曾把”reflowable EPUB 当前路径”概括为偏 `WKWebView` 的历史性表述。经本次代码核验,当前真实主路径是 `.textReflowable` → `RDEPUBTextBookBuilder` → `RDEPUBDTCoreTextRenderer` → CoreText 分页 → `RDEPUBTextContentView`。本设计以代码事实为准,并默认后续实现都按此理解推进。
|
||||
|
||||
### 2.1 渲染路径分流(已存在)
|
||||
|
||||
- 判定在 `Sources/RDReaderView/EPUBCore/RDEPUBParser+ReadingProfile.swift`:
|
||||
- `metadata.layout == .fixed` → `.webFixedLayout`
|
||||
- `hasInteractiveContent() == true` → `.webInteractive`
|
||||
- 否则 → `.textReflowable`
|
||||
|
||||
### 2.2 `.textReflowable` 当前实现(已存在,且是正确切入点)
|
||||
|
||||
`Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift`:
|
||||
|
||||
- `publication.readingProfile == .textReflowable` 时:
|
||||
- 使用 `RDEPUBTextBookBuilder(renderer: resolvedTextRenderer())`
|
||||
- 默认 renderer 是 `RDEPUBDTCoreTextRenderer`(`#if canImport(DTCoreText)`)
|
||||
- 分页使用 `NSAttributedString.ss_pageRanges(size:)`(`CTFramesetterCreateFrame` + `CTFrameGetVisibleStringRange`)
|
||||
- UI 展示使用 `RDEPUBTextContentView`
|
||||
|
||||
结论:我们不需要“新起一个阅读器”,只要把 `.textReflowable` 的 **渲染(typesetter)层**与部分 **分页策略**升级即可。
|
||||
|
||||
## 3. WXRead 参考模型(我们要对齐的最小子集)
|
||||
|
||||
来自 `Doc/WXRead/analysis/EPUB渲染管线详解.md` 的管线:
|
||||
|
||||
1) `WREpubParser`:解析 EPUB 结构(spine、manifest、resourceMap)
|
||||
2) `WREpubTypesetter`:XHTML → `NSAttributedString`(CSS 级联 + HTML 解析 + 后处理)
|
||||
3) `WRCoreTextLayouter`:`NSAttributedString` → 分页布局(`CTTypesetter` + 分页算法)
|
||||
4) `WRCoreTextLayoutFrame`:单页 layout frame
|
||||
5) `WRPageView`:绘制到屏幕
|
||||
|
||||
本次设计对齐重点(最小集合):
|
||||
|
||||
- A:**CSS 分层与合成**(default/replace/dark/epub/user)并注入到渲染输入
|
||||
- B:**资源解析**(图片/CSS 的相对路径 baseURL)与稳定性保障
|
||||
- C:在现有分页基础上逐步迭代(先可用,后对齐“避免断页”等高级策略)
|
||||
|
||||
### 3.1 第一阶段实施假设(必须遵守)
|
||||
|
||||
- H1:**第一期只对齐“管线形态”和“CSS 分层策略”**,即把现有 `.textReflowable` renderer 增强为更接近 WXRead 的 typesetter 输入与样式组织方式。
|
||||
- H2:**第一期不实现 WXRead 对 DTCoreText 的深度魔改**,包括但不限于自定义 CSS 属性体系、复杂附件布局规则、完整的 `WRCoreTextLayouter` / `WRCoreTextLayoutFrame` 等价分页器。
|
||||
- H3:当开发过程中遇到图片断页、复杂样式缺失、附件布局异常等问题时,默认先作为“第二阶段问题清单”记录;只有在它阻塞 `REND-01` / `STAB-02` 的最小验收时,才允许做局部补丁,而不是扩展为全面重写分页引擎。
|
||||
|
||||
## 4. 是否能直接使用 Doc/WXRead 中的 JS/CSS?
|
||||
|
||||
结论:**不建议、也不应该直接把“来自读书 App bundle 的私有 JS/CSS”拷贝进 SDK 作为产品代码**;但可以按以下原则“选择性使用”:
|
||||
|
||||
### 4.1 可以使用的情况(需满足其一)
|
||||
|
||||
- 文件本身带有明确开源许可证声明,且我们按许可证要求引入(保留 license、署名、NOTICE 等),并建议从官方 upstream 获取:
|
||||
- 例如 `Doc/WXRead/resources/js/rangy-core.js` 明确标注 MIT
|
||||
- 例如 `Doc/WXRead/resources/js/Readability.js` 明确标注 Apache-2.0
|
||||
|
||||
> 建议:即便文件里有 license 头,也优先从其原始开源仓库拉取对应版本,而不是从逆向提取的副本直接入库,以降低合规风险。
|
||||
|
||||
### 4.2 不建议/不能直接使用的情况
|
||||
|
||||
- 无明确许可证头、看起来是读书私有逻辑/样式(例如 `weread-highlighter.js`、`MediaPlatform.js`、`replace.css` 等):默认视为私有作品,不应直接拷贝使用。
|
||||
- 即便是“Safari 默认样式”类文件(例如 `default.css` 的注释提到 Safari),也不建议直接照搬;我们可以根据需求写一份“SDK 自己的 default.css / replace.css”,只实现必要规则。
|
||||
|
||||
### 4.3 对本次需求的实际影响
|
||||
|
||||
本次 reflowable EPUB 走原生渲染,不依赖 WebView,因此 **JS 不是本次必需**。
|
||||
CSS 方面我们需要的是“分层策略”和一小部分通用排版规则,可在 SDK 内重写为“WXRead 风格的默认样式集合”。
|
||||
|
||||
## 5. 详细设计(核心)
|
||||
|
||||
### 5.1 总体架构:在现有 `.textReflowable` 上增量替换 renderer
|
||||
|
||||
新增一个 renderer(实现 `RDEPUBTextRenderer`):
|
||||
|
||||
- `RDEPUBWXReadTextRenderer`(新)
|
||||
- 输入:`html: String`, `baseURL: URL?`, `style: RDEPUBTextRenderStyle`
|
||||
- 输出:`RDEPUBRenderedChapterContent`(`NSAttributedString` + `fragmentOffsets`)
|
||||
- 内部职责:
|
||||
1) 读取/生成 CSS 各层(default/replace/dark/user)
|
||||
2) 与 EPUB 自带 CSS 共同作用(通过 HTML 注入 + baseURL)
|
||||
3) 调用 DTCoreText builder 生成 attributedString
|
||||
4) 做最小后处理(段落间距/字体/颜色标准化、fragment marker 提取等)
|
||||
|
||||
切换点:
|
||||
|
||||
- 在 `RDEPUBReaderController.resolvedTextRenderer()`(或其配置位置)增加策略:当开关启用时选择 `RDEPUBWXReadTextRenderer()`,否则沿用 `RDEPUBDTCoreTextRenderer()`。
|
||||
- 建议默认先提供“实验开关”(仅 Demo / debug 可见),降低回归风险。
|
||||
|
||||
### 5.2 CSS 分层策略(WXRead 风格)
|
||||
|
||||
我们在 SDK 内实现与 `Doc/WXRead/analysis/EPUB渲染管线详解.md` 一致的分层概念,但不直接照搬其私有样式文件:
|
||||
|
||||
- Layer 1:`default.css`(SDK 自己维护的基础排版规则)
|
||||
- Layer 2:`replace.css`(SDK 自己维护的替换/增强规则:标题、代码块、图片最大宽度等)
|
||||
- Layer 3:`dark.css`(暗色主题覆盖,仅在暗色主题启用)
|
||||
- Layer 4:EPUB 嵌入 CSS(书籍自带,DTCoreText 解析 HTML 时自然生效;相对路径靠 baseURL)
|
||||
- Layer 5:用户设置 CSS(由 `RDEPUBTextRenderStyle` 动态生成:字体、字号、行高、背景色、文字色等)
|
||||
|
||||
实现方式(建议):
|
||||
|
||||
1) 新增 `RDEPUBWXReadStyleSheetBuilder`:
|
||||
- `func makeDefaultCSS() -> String`
|
||||
- `func makeReplaceCSS() -> String`
|
||||
- `func makeDarkCSS(theme: RDEPUBTheme) -> String?`
|
||||
- `func makeUserCSS(style: RDEPUBTextRenderStyle, theme: RDEPUBTheme) -> String`
|
||||
- `func composeCSS(...) -> String`(按层拼接,后层覆盖前层)
|
||||
|
||||
2) 在 renderer 中将合成后的 CSS 注入到 HTML:
|
||||
- 若存在 `<head>`:插入 `<style id="rd-wxread-layered-style">...`
|
||||
- 若不存在:在 `<html>` 后插入 `<head>...`
|
||||
- 保持原 HTML 内容尽量不改动(外部脚本/交互内容已被 readingProfile 判定剔除到 web 分支)
|
||||
|
||||
### 5.3 baseURL 与资源解析
|
||||
|
||||
目前 `RDEPUBTextBookBuilder` 传入:
|
||||
|
||||
- `baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent()`
|
||||
|
||||
原则:
|
||||
|
||||
- baseURL 必须是“章节文件所在目录”,以确保:
|
||||
- `<img src="...">` 相对路径可解析
|
||||
- `<link href="...">` CSS 相对路径可解析(如果 DTCoreText 支持)
|
||||
|
||||
待核验点(实现时做小实验):
|
||||
|
||||
- DTCoreText 对 `<link rel="stylesheet">` 的解析策略是否完整;若不完整:
|
||||
- 兜底策略:在渲染前解析 HTML 中的 `<link rel="stylesheet">`,读取 CSS 内容并内联到 `<style>`(仅限 `file://` 且位于 EPUB 解压目录内)。
|
||||
|
||||
### 5.4 分页策略(阶段性)
|
||||
|
||||
现状:
|
||||
|
||||
- `NSAttributedString.ss_pageRanges(size:)` 使用 `CTFramesetterCreateFrame` + `CTFrameGetVisibleStringRange`,属于“最小可用分页”。
|
||||
|
||||
WXRead 的更高阶策略(参考 `Doc/WXRead/analysis/DTCoreText自定义修改分析.md`)可能包含:
|
||||
|
||||
- 避免孤行/断页
|
||||
- 图片/附件的分页边界处理
|
||||
- 特定块元素的分页规则
|
||||
|
||||
本次建议:
|
||||
|
||||
- Phase 2:先保持现有分页算法,只要渲染输入(CSS 分层 + 后处理)到位,就能显著改善一致性。
|
||||
- Phase 3:针对真实书籍出现的问题,逐条补齐分页规则(问题驱动),避免一开始就引入复杂分页器导致风险扩大。
|
||||
|
||||
> 范围约束:如果某个分页问题需要引入“新的复杂分页器”或大规模模拟 `WRCoreTextLayouter` / `WRCoreTextLayoutFrame`,应先暂停并回到方案讨论,不默认并入第一期实现。
|
||||
|
||||
### 5.5 与现有高亮/搜索/位置映射的兼容
|
||||
|
||||
当前 `.textReflowable` 路径:
|
||||
|
||||
- 高亮/搜索依赖 `RDEPUBTextBook` 的 `fragmentOffsets` 与 `location/progression` 映射(见 `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift` 与 `RDEPUBTextBook` 的 `pageNumber(for:)` / `location(forPageNumber:)`)。
|
||||
|
||||
兼容策略:
|
||||
|
||||
- 继续使用现有的 fragment marker 注入与提取:
|
||||
- `RDEPUBTextRendererSupport.injectFragmentMarkers(...)`
|
||||
- `RDEPUBTextRendererSupport.extractFragmentOffsets(...)`
|
||||
- renderer 只改变“CSS 注入与 DTCoreText options/后处理”,不改变 marker 体系与 `RDEPUBTextBook` 数据结构,以降低 UI 层回归。
|
||||
|
||||
## 6. 开发落点(文件 / 类型 / 目录)
|
||||
|
||||
### 6.1 新增文件(建议位置)
|
||||
|
||||
放在 `Sources/RDReaderView/EPUBTextRendering/`(因为它是 textReflowable 的渲染与分页域):
|
||||
|
||||
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBWXReadTextRenderer.swift`(新)
|
||||
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBWXReadStyleSheetBuilder.swift`(新)
|
||||
- (可选)`Sources/RDReaderView/EPUBTextRendering/RDEPUBWXReadHTMLPreprocessor.swift`(新:仅当需要内联 `<link>` CSS 时)
|
||||
|
||||
资源文件(建议):
|
||||
|
||||
- `Sources/RDReaderView/Resources/WXRead/default.css`(新,SDK 自己写)
|
||||
- `Sources/RDReaderView/Resources/WXRead/replace.css`(新,SDK 自己写)
|
||||
- `Sources/RDReaderView/Resources/WXRead/dark.css`(新,SDK 自己写)
|
||||
|
||||
> 注意:这些资源需被 `RDReaderView.podspec` 的 resource bundle 覆盖到(当前资源 bundle 为 `RDReaderViewAssets`,来源 `Sources/RDReaderView/Resources/**`)。
|
||||
|
||||
### 6.2 改动文件(建议最小改动)
|
||||
|
||||
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift`
|
||||
- 在 `resolvedTextRenderer()` 或相邻配置处增加选择逻辑(开关 / 版本策略)
|
||||
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift`
|
||||
- 如需内联 link CSS:在获取 `rawHTML` 后做预处理(保持接口不变)
|
||||
|
||||
## 7. 验收标准与验证方式(对应 REQUIREMENTS)
|
||||
|
||||
### 对应 REND-01 / REND-03
|
||||
|
||||
- 在 Demo 中打开 reflowable EPUB:
|
||||
- 正文渲染不依赖 `WKWebView`(可通过日志/断点确认不走 `RDEPUBWebContentView`)
|
||||
- CSS 分层生效:默认样式可控、主题/字号/行高变化可控
|
||||
- 图片/链接至少可正确显示/响应(链接行为按现有 text 内容策略)
|
||||
|
||||
### 对应 REND-02
|
||||
|
||||
- Fixed Layout EPUB:仍走 `.webFixedLayout` → `WKWebView` 路径
|
||||
- 交互式 EPUB:仍走 `.webInteractive` → `WKWebView` 路径(桥接与外链不回归)
|
||||
|
||||
### 对应 STAB-01 / STAB-02
|
||||
|
||||
- `RDURLReaderController` 打开 `.epub` / `.txt` 主流程不回归
|
||||
- 至少使用以下 3 类 reflowable EPUB 样本进行回归:
|
||||
- 样本 A:纯文本/小说类章节为主,验证基础段落、标题、分页与阅读位置恢复
|
||||
- 样本 B:包含内嵌图片与多段样式的章节,验证图片显示、图片前后分页、基础 CSS 生效
|
||||
- 样本 C:包含外链与多个 CSS 文件引用的章节,验证 baseURL、样式解析与链接呈现稳定性
|
||||
- 对以上样本的共同要求:不崩溃、不白屏、不无限加载;分页/翻页可用
|
||||
|
||||
## 8. 风险清单与降级策略
|
||||
|
||||
### 8.1 主要风险
|
||||
|
||||
- R1:DTCoreText 对 EPUB 内嵌 CSS / `<link>` CSS 支持不足,导致样式缺失
|
||||
- R2:分页质量不足(断页不美观、图片分页异常)
|
||||
- R3:`hasInteractiveContent()` 判定过宽,导致大量书被误判为 `.webInteractive`,覆盖率不足
|
||||
|
||||
### 8.2 降级/灰度(建议)
|
||||
|
||||
- D1:增加一个“渲染引擎开关”(仅 debug 或 demo 可配置),可在出现严重问题时快速回退到现有 `RDEPUBDTCoreTextRenderer`
|
||||
- D2:对 `hasInteractiveContent()` 的判定提供可配置白名单/黑名单(例如按 manifest properties、按 tag 命中级别)
|
||||
|
||||
## 9. 下一步(交接到开发)
|
||||
|
||||
建议按 `Doc/ARCHITECTURE-CONTEXT.md` 中的架构决策逐步推进:
|
||||
|
||||
- 先基于 `Doc/WXRead/analysis/*` 提炼“我们要实现的 CSS 分层最小集合”
|
||||
- 再在 `.textReflowable` renderer 里实现“分层 CSS 注入 + baseURL/资源解析兜底”
|
||||
- 最后用 Demo 书籍做回归,按问题驱动补齐分页/样式细节
|
||||
|
||||
---
|
||||
*Last updated: 2026-05-21 after discuss-feature-solution*
|
||||
@ -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 层 overlay(background + 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 位置
|
||||
@ -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 再拆成更细的文件级改动清单、迁移顺序和验收步骤。
|
||||
@ -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 (缓存部分)
|
||||
|
||||
@ -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 文件数:** 166 个(SDK Sources)
|
||||
- **Swift 文件数:** 142 个(SDK Sources)
|
||||
- **测试用例数:** 23 个测试类,约 99 个测试方法(UI 测试)
|
||||
- **最低 iOS 版本:** 15.6
|
||||
- **构建方式:** CocoaPods(本地 pod)
|
||||
|
||||
@ -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 端监听图片点击 + 原生展示) |
|
||||
@ -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`
|
||||
|
||||
这四项完成后,阅读器的“主观可用性”和“默认安全性”会先提升一个台阶。
|
||||
@ -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. 章节缓存、页图缓存、定位缓存职责清晰,二次打开可复用
|
||||
|
||||
这时阅读器才算从“功能可用”进入“商业级可发布”的基线。
|
||||
3596
ReadViewDemo/Pods/Pods.xcodeproj/project.pbxproj
generated
3596
ReadViewDemo/Pods/Pods.xcodeproj/project.pbxproj
generated
File diff suppressed because it is too large
Load Diff
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)/")
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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] ?? [:]
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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?()
|
||||
}
|
||||
}
|
||||
}
|
||||
188
Sources/RDReaderView/EPUBUI/RDEPUBAttachmentTooltipView.swift
Normal file
188
Sources/RDReaderView/EPUBUI/RDEPUBAttachmentTooltipView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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])
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -14,7 +14,7 @@ struct RDEPUBPaginationState {
|
||||
|
||||
var activePageMap: RDEPUBBookPageMap?
|
||||
|
||||
var pendingFullPageMap: RDEPUBBookPageMap?
|
||||
var pendingPageMapUpdates: [RDEPUBPendingPageMapUpdate] = []
|
||||
|
||||
var chapterWindowSnapshot: RDEPUBChapterWindowSnapshot?
|
||||
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 = ""
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -24,7 +24,7 @@ final class RDEPUBReaderState {
|
||||
|
||||
var searchState: RDEPUBSearchState?
|
||||
|
||||
var pendingFullPageMap: RDEPUBBookPageMap?
|
||||
var pendingPageMapUpdates: [RDEPUBPendingPageMapUpdate] = []
|
||||
|
||||
var lastTextPaginationPageSize: CGSize?
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
17
Sources/RDReaderView/EPUBUI/String+RDEPUB.swift
Normal file
17
Sources/RDReaderView/EPUBUI/String+RDEPUB.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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?
|
||||
@ -88,12 +74,12 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
let view = RDEPUBTextPageDecorationView()
|
||||
return view
|
||||
}()
|
||||
|
||||
|
||||
private let overlayView: RDEPUBTextAnnotationOverlay = {
|
||||
let view = RDEPUBTextAnnotationOverlay()
|
||||
return view
|
||||
}()
|
||||
|
||||
|
||||
private let coverImageView: UIImageView = {
|
||||
let view = UIImageView()
|
||||
view.contentMode = .scaleAspectFit
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
31
Sources/RDReaderView/EPUBUI/UIColor+RDEPUB.swift
Normal file
31
Sources/RDReaderView/EPUBUI/UIColor+RDEPUB.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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)"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user