feat: EPUB 阅读器搜索、选中注释、书签 chrome 状态及大量重构优化
- 新增 RDEPUBReaderSearchCoordinator 与 RDEPUBSelectionState 管理搜索和选中状态 - 新增 BookmarkChromeStateTests、NavigationBackwardTests、SelectionAnnotateTests 等 UI 测试 - 新增多个边界测试 epub 样本(损坏结构、空归档、缺失文件、流式外链验证) - 重构阅读器 chrome 状态管理,统一 tool bar 与 search bar 交互 - 优化大书分页缓存策略(RDEPUBChapterSummaryDiskCache、RDEPUBPageCountCache) - 移除废弃的 RDEPUBLocationConverter 和 RDEPUBPageBreakPolicy - 更新 epub-bridge.js 与 JS bridge 通信协议 - 全面更新现有 UI 测试以适配新的 helper 和状态管理
This commit is contained in:
parent
27e9b85ddb
commit
6f75b083f7
606
Doc/Android EPUB 阅读器实施方案.md
Normal file
606
Doc/Android EPUB 阅读器实施方案.md
Normal file
@ -0,0 +1,606 @@
|
|||||||
|
# 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 |
|
||||||
@ -1,6 +1,6 @@
|
|||||||
# 当前阅读器问题修复开发清单
|
# 当前阅读器问题修复开发清单
|
||||||
|
|
||||||
> 最后更新:2026-06-12
|
> 最后更新:2026-06-13
|
||||||
> 依据说明:本清单仅基于当前仓库代码实现整理,不以现有说明文档是否准确为前提。
|
> 依据说明:本清单仅基于当前仓库代码实现整理,不以现有说明文档是否准确为前提。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@ -43,7 +43,7 @@ EXTERNAL SOURCES:
|
|||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
|
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
|
||||||
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
|
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
|
||||||
RDReaderView: 617ec758a5db3c10024acf83b110465ea8343b5b
|
RDReaderView: 54205c82c62ea6c674b43c833642d2643c98c573
|
||||||
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
|
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
|
||||||
SSAlertSwift: aad8dc0c20b36fcffe700b81d7be89d60c7ba4f1
|
SSAlertSwift: aad8dc0c20b36fcffe700b81d7be89d60c7ba4f1
|
||||||
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
|
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
|
||||||
|
|||||||
@ -27,15 +27,9 @@
|
|||||||
"ZIPFoundation": [
|
"ZIPFoundation": [
|
||||||
"~> 0.9"
|
"~> 0.9"
|
||||||
],
|
],
|
||||||
"DTCoreText": [
|
"DTCoreText": [],
|
||||||
|
"SnapKit": [],
|
||||||
],
|
"SSAlertSwift": []
|
||||||
"SnapKit": [
|
|
||||||
|
|
||||||
],
|
|
||||||
"SSAlertSwift": [
|
|
||||||
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"requires_arc": true,
|
"requires_arc": true,
|
||||||
"swift_version": "5.10"
|
"swift_version": "5.10"
|
||||||
|
|||||||
2
ReadViewDemo/Pods/Manifest.lock
generated
2
ReadViewDemo/Pods/Manifest.lock
generated
@ -43,7 +43,7 @@ EXTERNAL SOURCES:
|
|||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
|
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
|
||||||
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
|
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
|
||||||
RDReaderView: 617ec758a5db3c10024acf83b110465ea8343b5b
|
RDReaderView: 54205c82c62ea6c674b43c833642d2643c98c573
|
||||||
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
|
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
|
||||||
SSAlertSwift: aad8dc0c20b36fcffe700b81d7be89d60c7ba4f1
|
SSAlertSwift: aad8dc0c20b36fcffe700b81d7be89d60c7ba4f1
|
||||||
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
|
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
|
||||||
|
|||||||
2726
ReadViewDemo/Pods/Pods.xcodeproj/project.pbxproj
generated
2726
ReadViewDemo/Pods/Pods.xcodeproj/project.pbxproj
generated
File diff suppressed because it is too large
Load Diff
@ -18,6 +18,7 @@
|
|||||||
1A2B3C4D00000011AABBCC01 /* DemoReaderState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000012AABBCC01 /* DemoReaderState.swift */; };
|
1A2B3C4D00000011AABBCC01 /* DemoReaderState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000012AABBCC01 /* DemoReaderState.swift */; };
|
||||||
1A2B3C4D00000014AABBCC01 /* FanrenParseTimeTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000013AABBCC01 /* FanrenParseTimeTest.swift */; };
|
1A2B3C4D00000014AABBCC01 /* FanrenParseTimeTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000013AABBCC01 /* FanrenParseTimeTest.swift */; };
|
||||||
23BB1155EA379786DAA10A89 /* DisplayTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */; };
|
23BB1155EA379786DAA10A89 /* DisplayTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */; };
|
||||||
|
369C9658D870DCAFC17EB7F7 /* SearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFE01DBDCB8D790832A4DE3F /* SearchTests.swift */; };
|
||||||
3BC5C96D7A0ACF35F2192CC7 /* XCUIApplication+Launch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74B4C44287820D68ED6570F8 /* XCUIApplication+Launch.swift */; };
|
3BC5C96D7A0ACF35F2192CC7 /* XCUIApplication+Launch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74B4C44287820D68ED6570F8 /* XCUIApplication+Launch.swift */; };
|
||||||
4509ED928F228F43888E063D /* ReaderToolbarTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */; };
|
4509ED928F228F43888E063D /* ReaderToolbarTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */; };
|
||||||
52A6A0C9941A4B5F9B88C6B0 /* ReaderAnnotationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20BD15E5D8F04E7E9A239E14 /* ReaderAnnotationTests.swift */; };
|
52A6A0C9941A4B5F9B88C6B0 /* ReaderAnnotationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20BD15E5D8F04E7E9A239E14 /* ReaderAnnotationTests.swift */; };
|
||||||
@ -26,7 +27,6 @@
|
|||||||
C08FF8D030048DC5147729E9 /* ReaderOpenCloseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */; };
|
C08FF8D030048DC5147729E9 /* ReaderOpenCloseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */; };
|
||||||
DE1437A969DA1C5F0CBB047D /* Pods_ReadViewDemo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 792DF85CE4A3DD80D67843C7 /* Pods_ReadViewDemo.framework */; };
|
DE1437A969DA1C5F0CBB047D /* Pods_ReadViewDemo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 792DF85CE4A3DD80D67843C7 /* Pods_ReadViewDemo.framework */; };
|
||||||
FEDB5937CEB858CB06E38E2D /* SettingsPanelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201C2B482287866487EFAE66 /* SettingsPanelTests.swift */; };
|
FEDB5937CEB858CB06E38E2D /* SettingsPanelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201C2B482287866487EFAE66 /* SettingsPanelTests.swift */; };
|
||||||
369C9658D870DCAFC17EB7F7 /* SearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFE01DBDCB8D790832A4DE3F /* SearchTests.swift */; };
|
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@ -61,9 +61,9 @@
|
|||||||
8FFD606A5A1CBDCC3CA87F1C /* ReadViewDemoUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ReadViewDemoUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
8FFD606A5A1CBDCC3CA87F1C /* ReadViewDemoUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ReadViewDemoUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderOpenCloseTests.swift; sourceTree = "<group>"; };
|
9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderOpenCloseTests.swift; sourceTree = "<group>"; };
|
||||||
BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderToolbarTests.swift; sourceTree = "<group>"; };
|
BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderToolbarTests.swift; sourceTree = "<group>"; };
|
||||||
|
CFE01DBDCB8D790832A4DE3F /* SearchTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SearchTests.swift; sourceTree = "<group>"; };
|
||||||
DE070C1D2FBF0CC900ED065F /* ReadViewDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ReadViewDemo.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
DE070C1D2FBF0CC900ED065F /* ReadViewDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ReadViewDemo.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DisplayTypeTests.swift; sourceTree = "<group>"; };
|
FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DisplayTypeTests.swift; sourceTree = "<group>"; };
|
||||||
CFE01DBDCB8D790832A4DE3F /* SearchTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SearchTests.swift; sourceTree = "<group>"; };
|
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
|||||||
@ -18,6 +18,9 @@ final class ViewController: UIViewController {
|
|||||||
let windowSize: Int?
|
let windowSize: Int?
|
||||||
let concurrency: Int?
|
let concurrency: Int?
|
||||||
let clearsCache: Bool
|
let clearsCache: Bool
|
||||||
|
let corruptsCache: Bool
|
||||||
|
let allowsInspectableWebViews: Bool
|
||||||
|
let enablesVerboseWebLogs: Bool
|
||||||
let searchKeyword: String?
|
let searchKeyword: String?
|
||||||
|
|
||||||
nonisolated private static func parseDisplayType(_ rawValue: String) -> RDReaderView.DisplayType? {
|
nonisolated private static func parseDisplayType(_ rawValue: String) -> RDReaderView.DisplayType? {
|
||||||
@ -57,6 +60,9 @@ final class ViewController: UIViewController {
|
|||||||
let windowSize = value(after: "--demo-window-size").flatMap(Int.init)
|
let windowSize = value(after: "--demo-window-size").flatMap(Int.init)
|
||||||
let concurrency = value(after: "--demo-concurrency").flatMap(Int.init)
|
let concurrency = value(after: "--demo-concurrency").flatMap(Int.init)
|
||||||
let clearsCache = arguments.contains("--demo-clear-cache")
|
let clearsCache = arguments.contains("--demo-clear-cache")
|
||||||
|
let corruptsCache = arguments.contains("--demo-corrupt-cache")
|
||||||
|
let allowsInspectableWebViews = arguments.contains("--demo-allow-inspectable-webviews")
|
||||||
|
let enablesVerboseWebLogs = arguments.contains("--demo-enable-verbose-weblogs")
|
||||||
let searchKeyword = value(after: "--demo-search-keyword")
|
let searchKeyword = value(after: "--demo-search-keyword")
|
||||||
|
|
||||||
return LaunchAutomationPlan(
|
return LaunchAutomationPlan(
|
||||||
@ -69,6 +75,9 @@ final class ViewController: UIViewController {
|
|||||||
windowSize: windowSize,
|
windowSize: windowSize,
|
||||||
concurrency: concurrency,
|
concurrency: concurrency,
|
||||||
clearsCache: clearsCache,
|
clearsCache: clearsCache,
|
||||||
|
corruptsCache: corruptsCache,
|
||||||
|
allowsInspectableWebViews: allowsInspectableWebViews,
|
||||||
|
enablesVerboseWebLogs: enablesVerboseWebLogs,
|
||||||
searchKeyword: searchKeyword
|
searchKeyword: searchKeyword
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -262,6 +271,9 @@ final class ViewController: UIViewController {
|
|||||||
if launchAutomationPlan.clearsCache {
|
if launchAutomationPlan.clearsCache {
|
||||||
clearChapterSummaryCache()
|
clearChapterSummaryCache()
|
||||||
}
|
}
|
||||||
|
if launchAutomationPlan.corruptsCache {
|
||||||
|
corruptChapterSummaryCache()
|
||||||
|
}
|
||||||
|
|
||||||
var configuration = RDEPUBReaderConfiguration.default
|
var configuration = RDEPUBReaderConfiguration.default
|
||||||
if let displayType = launchAutomationPlan.displayType {
|
if let displayType = launchAutomationPlan.displayType {
|
||||||
@ -273,6 +285,8 @@ final class ViewController: UIViewController {
|
|||||||
if let concurrency = launchAutomationPlan.concurrency {
|
if let concurrency = launchAutomationPlan.concurrency {
|
||||||
configuration.metadataParsingConcurrency = concurrency
|
configuration.metadataParsingConcurrency = concurrency
|
||||||
}
|
}
|
||||||
|
configuration.allowsInspectableWebViews = launchAutomationPlan.allowsInspectableWebViews
|
||||||
|
configuration.enablesVerboseWebViewLogging = launchAutomationPlan.enablesVerboseWebLogs
|
||||||
_ = openBook(book, configuration: configuration, automationPlan: launchAutomationPlan)
|
_ = openBook(book, configuration: configuration, automationPlan: launchAutomationPlan)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -300,6 +314,19 @@ final class ViewController: UIViewController {
|
|||||||
print("[ReadViewDemo] cleared chapter summary cache at \(cacheDir.path)")
|
print("[ReadViewDemo] cleared chapter summary cache at \(cacheDir.path)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func corruptChapterSummaryCache() {
|
||||||
|
let cachesDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
|
||||||
|
?? FileManager.default.temporaryDirectory
|
||||||
|
let cacheDir = cachesDirectory.appendingPathComponent("RDEPUBChapterSummaryCache", isDirectory: true)
|
||||||
|
guard let enumerator = FileManager.default.enumerator(at: cacheDir, includingPropertiesForKeys: nil) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for case let fileURL as URL in enumerator where fileURL.pathExtension == "json" {
|
||||||
|
try? Data("corrupted-cache".utf8).write(to: fileURL, options: .atomic)
|
||||||
|
}
|
||||||
|
print("[ReadViewDemo] corrupted chapter summary cache at \(cacheDir.path)")
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ViewController: UITableViewDataSource {
|
extension ViewController: UITableViewDataSource {
|
||||||
|
|||||||
BIN
ReadViewDemo/ReadViewDemo/book/Web交互流式外链验证.epub
Normal file
BIN
ReadViewDemo/ReadViewDemo/book/Web交互流式外链验证.epub
Normal file
Binary file not shown.
1
ReadViewDemo/ReadViewDemo/book/损坏结构样本.epub
Normal file
1
ReadViewDemo/ReadViewDemo/book/损坏结构样本.epub
Normal file
@ -0,0 +1 @@
|
|||||||
|
not a valid epub archive
|
||||||
0
ReadViewDemo/ReadViewDemo/book/空归档样本.epub
Normal file
0
ReadViewDemo/ReadViewDemo/book/空归档样本.epub
Normal file
BIN
ReadViewDemo/ReadViewDemo/book/缺失文件样本.epub
Normal file
BIN
ReadViewDemo/ReadViewDemo/book/缺失文件样本.epub
Normal file
Binary file not shown.
@ -40,6 +40,16 @@ struct DemoReaderState {
|
|||||||
var windowSize: Int? { fields["windowSize"].flatMap(Int.init) }
|
var windowSize: Int? { fields["windowSize"].flatMap(Int.init) }
|
||||||
var parseMs: Int? { fields["parseMs"].flatMap(Int.init) }
|
var parseMs: Int? { fields["parseMs"].flatMap(Int.init) }
|
||||||
var parseConcurrency: Int? { fields["parseConcurrency"].flatMap(Int.init) }
|
var parseConcurrency: Int? { fields["parseConcurrency"].flatMap(Int.init) }
|
||||||
|
var inspectable: Int? { fields["inspectable"].flatMap(Int.init) }
|
||||||
|
var streamedResources: Int? { fields["streamedResources"].flatMap(Int.init) }
|
||||||
|
var inMemoryResources: Int? { fields["inMemoryResources"].flatMap(Int.init) }
|
||||||
|
var resourceFailures: Int? { fields["resourceFailures"].flatMap(Int.init) }
|
||||||
|
var cacheFiles: Int? { fields["cacheFiles"].flatMap(Int.init) }
|
||||||
|
var cacheBytes: Int? { fields["cacheBytes"].flatMap(Int.init) }
|
||||||
|
var externalLinks: Int? { fields["externalLinks"].flatMap(Int.init) }
|
||||||
|
var lastExternalURL: String? { fields["lastExternalURL"] }
|
||||||
|
var lastError: String? { fields["lastError"] }
|
||||||
|
var searchMatchText: String? { fields["searchMatchText"] }
|
||||||
}
|
}
|
||||||
|
|
||||||
extension XCUIApplication {
|
extension XCUIApplication {
|
||||||
|
|||||||
@ -9,6 +9,9 @@ extension XCUIApplication {
|
|||||||
windowSize: Int? = nil,
|
windowSize: Int? = nil,
|
||||||
concurrency: Int? = nil,
|
concurrency: Int? = nil,
|
||||||
clearsCache: Bool = false,
|
clearsCache: Bool = false,
|
||||||
|
corruptsCache: Bool = false,
|
||||||
|
allowsInspectableWebViews: Bool = false,
|
||||||
|
enablesVerboseWebLogs: Bool = false,
|
||||||
searchKeyword: String? = nil
|
searchKeyword: String? = nil
|
||||||
) {
|
) {
|
||||||
var args = ["--demo-book-title", bookTitleQuery]
|
var args = ["--demo-book-title", bookTitleQuery]
|
||||||
@ -18,6 +21,15 @@ extension XCUIApplication {
|
|||||||
if clearsCache {
|
if clearsCache {
|
||||||
args.append("--demo-clear-cache")
|
args.append("--demo-clear-cache")
|
||||||
}
|
}
|
||||||
|
if corruptsCache {
|
||||||
|
args.append("--demo-corrupt-cache")
|
||||||
|
}
|
||||||
|
if allowsInspectableWebViews {
|
||||||
|
args.append("--demo-allow-inspectable-webviews")
|
||||||
|
}
|
||||||
|
if enablesVerboseWebLogs {
|
||||||
|
args.append("--demo-enable-verbose-weblogs")
|
||||||
|
}
|
||||||
if let displayType {
|
if let displayType {
|
||||||
args += ["--demo-display-type", displayType]
|
args += ["--demo-display-type", displayType]
|
||||||
}
|
}
|
||||||
@ -52,16 +64,27 @@ extension XCUIApplication {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func showReaderChromeIfNeeded() {
|
func showReaderChromeIfNeeded(
|
||||||
if buttons[IDs.readerBack].exists && buttons[IDs.readerSettings].exists {
|
requiredButtonIDs: [String] = [IDs.readerBack, IDs.readerSettings],
|
||||||
|
timeout: TimeInterval = 5
|
||||||
|
) {
|
||||||
|
if readerChromeIsVisible(requiredButtonIDs: requiredButtonIDs) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let content = otherElements[IDs.readerContent]
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
if content.waitForExistence(timeout: 3) {
|
while Date() < deadline {
|
||||||
content.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
let content = otherElements[IDs.readerContent]
|
||||||
} else {
|
if content.waitForExistence(timeout: 1) {
|
||||||
windows.firstMatch.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
content.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
||||||
|
} else {
|
||||||
|
windows.firstMatch.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
||||||
|
}
|
||||||
|
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.35))
|
||||||
|
if readerChromeIsVisible(requiredButtonIDs: requiredButtonIDs) {
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -94,4 +117,39 @@ extension XCUIApplication {
|
|||||||
|
|
||||||
XCTFail("阅读器状态未包含 \(expectedText),当前:\(lastLabel)")
|
XCTFail("阅读器状态未包含 \(expectedText),当前:\(lastLabel)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func requireSelectionText(
|
||||||
|
expectedPage: Int? = nil,
|
||||||
|
timeout: TimeInterval = 12
|
||||||
|
) throws -> XCUIElement {
|
||||||
|
let selectionText = otherElements[IDs.readerSelectionText].firstMatch
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
|
||||||
|
while Date() < deadline {
|
||||||
|
if let expectedPage {
|
||||||
|
let state = currentDemoReaderState()
|
||||||
|
if state?.page != expectedPage {
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if selectionText.exists {
|
||||||
|
return selectionText
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = otherElements[IDs.readerContent]
|
||||||
|
if content.exists {
|
||||||
|
content.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
||||||
|
}
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
|
||||||
|
}
|
||||||
|
|
||||||
|
throw XCTSkip("文本选区元素未出现,当前渲染路径未暴露 \(IDs.readerSelectionText)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func readerChromeIsVisible(requiredButtonIDs: [String]) -> Bool {
|
||||||
|
requiredButtonIDs.allSatisfy { buttons[$0].exists }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,74 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
/// 书签 chrome 状态测试
|
||||||
|
/// 覆盖:书签列表按钮在零书签时禁用、添加后启用、书签跨重启存活
|
||||||
|
final class BookmarkChromeStateTests: XCTestCase {
|
||||||
|
|
||||||
|
private let app = XCUIApplication()
|
||||||
|
|
||||||
|
override func setUpWithError() throws {
|
||||||
|
continueAfterFailure = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 零书签时,底部书签列表按钮应禁用
|
||||||
|
func testBookmarksListButtonDisabledWhenNoBookmarks() throws {
|
||||||
|
app.launchAndOpenSampleBook()
|
||||||
|
app.waitForReader()
|
||||||
|
|
||||||
|
app.showReaderChromeIfNeeded()
|
||||||
|
|
||||||
|
let bookmarksButton = app.buttons[IDs.readerBookmarks]
|
||||||
|
guard bookmarksButton.waitForExistence(timeout: 3) else {
|
||||||
|
throw XCTSkip("书签列表按钮不存在")
|
||||||
|
}
|
||||||
|
// 零书签时按钮应存在但不可用
|
||||||
|
XCTAssertFalse(bookmarksButton.isEnabled, "零书签时书签列表按钮应禁用")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 添加书签后,底部书签列表按钮应启用
|
||||||
|
func testBookmarksListButtonEnabledAfterAdding() throws {
|
||||||
|
app.launchAndOpenSampleBook()
|
||||||
|
app.waitForReader()
|
||||||
|
|
||||||
|
app.showReaderChromeIfNeeded()
|
||||||
|
|
||||||
|
// 添加书签
|
||||||
|
let bookmarkButton = app.buttons[IDs.readerBookmark]
|
||||||
|
guard bookmarkButton.waitForExistence(timeout: 3) else {
|
||||||
|
throw XCTSkip("书签按钮不存在")
|
||||||
|
}
|
||||||
|
bookmarkButton.tap()
|
||||||
|
|
||||||
|
// 验证书签列表按钮启用
|
||||||
|
let bookmarksButton = app.buttons[IDs.readerBookmarks]
|
||||||
|
guard bookmarksButton.waitForExistence(timeout: 3) else {
|
||||||
|
throw XCTSkip("书签列表按钮不存在")
|
||||||
|
}
|
||||||
|
XCTAssertTrue(bookmarksButton.isEnabled, "添加书签后书签列表按钮应启用")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 书签跨重启存活
|
||||||
|
func testBookmarkSurvivesRestart() {
|
||||||
|
app.launchAndOpenSampleBook()
|
||||||
|
app.waitForReader()
|
||||||
|
|
||||||
|
// 添加书签
|
||||||
|
app.showReaderChromeIfNeeded()
|
||||||
|
let bookmarkButton = app.buttons[IDs.readerBookmark]
|
||||||
|
bookmarkButton.waitForExistence(timeout: 3)
|
||||||
|
bookmarkButton.tap()
|
||||||
|
|
||||||
|
// 等待书签状态确认
|
||||||
|
app.waitForDemoReaderState(timeout: 5, description: "bookmarks=1") { $0.bookmarks == 1 }
|
||||||
|
|
||||||
|
// 关闭阅读器
|
||||||
|
app.buttons[IDs.readerBack].tap()
|
||||||
|
|
||||||
|
// 重新打开(不重置状态)
|
||||||
|
app.launchAndOpenSampleBook(resetsReaderState: false)
|
||||||
|
app.waitForReader()
|
||||||
|
|
||||||
|
// 验证书签仍然存在
|
||||||
|
app.waitForDemoReaderState(timeout: 5, description: "bookmarks=1 after restart") { $0.bookmarks == 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -15,7 +15,7 @@ final class BookmarkManagementTests: XCTestCase {
|
|||||||
let bookmarkButton = app.buttons[IDs.readerBookmark]
|
let bookmarkButton = app.buttons[IDs.readerBookmark]
|
||||||
XCTAssertTrue(bookmarkButton.waitForExistence(timeout: 3), "书签按钮不存在")
|
XCTAssertTrue(bookmarkButton.waitForExistence(timeout: 3), "书签按钮不存在")
|
||||||
bookmarkButton.tap()
|
bookmarkButton.tap()
|
||||||
app.waitForReaderState(containing: "bookmarks=1", timeout: 5)
|
app.waitForDemoReaderState(timeout: 5, description: "bookmarks=1") { $0.bookmarks == 1 }
|
||||||
|
|
||||||
app.showReaderChromeIfNeeded()
|
app.showReaderChromeIfNeeded()
|
||||||
let bookmarksButton = app.buttons[IDs.readerBookmarks]
|
let bookmarksButton = app.buttons[IDs.readerBookmarks]
|
||||||
@ -47,7 +47,7 @@ final class BookmarkManagementTests: XCTestCase {
|
|||||||
|
|
||||||
app.showReaderChromeIfNeeded()
|
app.showReaderChromeIfNeeded()
|
||||||
app.buttons[IDs.readerBookmark].tap()
|
app.buttons[IDs.readerBookmark].tap()
|
||||||
app.waitForReaderState(containing: "bookmarks=1", timeout: 5)
|
app.waitForDemoReaderState(timeout: 5, description: "bookmarks=1") { $0.bookmarks == 1 }
|
||||||
app.showReaderChromeIfNeeded()
|
app.showReaderChromeIfNeeded()
|
||||||
app.buttons[IDs.readerBookmarks].tap()
|
app.buttons[IDs.readerBookmarks].tap()
|
||||||
|
|
||||||
@ -78,6 +78,39 @@ final class BookmarkManagementTests: XCTestCase {
|
|||||||
XCTAssertTrue(bookmarkButton.waitForExistence(timeout: 3), "移除书签后按钮应仍然存在")
|
XCTAssertTrue(bookmarkButton.waitForExistence(timeout: 3), "移除书签后按钮应仍然存在")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testBookmarksButtonDisabledWhenNoBookmarks() throws {
|
||||||
|
app.launchAndOpenSampleBook(resetsReaderState: true)
|
||||||
|
app.waitForReader(timeout: 12)
|
||||||
|
|
||||||
|
app.showReaderChromeIfNeeded()
|
||||||
|
let bookmarksButton = app.buttons[IDs.readerBookmarks]
|
||||||
|
XCTAssertTrue(bookmarksButton.waitForExistence(timeout: 3), "书签列表按钮不存在")
|
||||||
|
XCTAssertFalse(bookmarksButton.isEnabled, "零书签时书签列表按钮应禁用")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBookmarkIndicatorStaysSelectedAtBookmarkedLocation() throws {
|
||||||
|
app.launchAndOpenSampleBook(resetsReaderState: true)
|
||||||
|
app.waitForReader(timeout: 12)
|
||||||
|
|
||||||
|
app.showReaderChromeIfNeeded()
|
||||||
|
let bookmarkButton = app.buttons[IDs.readerBookmark]
|
||||||
|
XCTAssertTrue(bookmarkButton.waitForExistence(timeout: 3), "书签按钮不存在")
|
||||||
|
bookmarkButton.tap()
|
||||||
|
app.waitForDemoReaderState(timeout: 5, description: "bookmarks=1") { $0.bookmarks == 1 }
|
||||||
|
XCTAssertEqual(bookmarkButton.value as? String, "selected", "当前页已加书签时顶部书签按钮应为选中态")
|
||||||
|
|
||||||
|
let paging = app.collectionViews[IDs.readerPaging]
|
||||||
|
if paging.waitForExistence(timeout: 5) {
|
||||||
|
paging.swipeLeft()
|
||||||
|
app.showReaderChromeIfNeeded()
|
||||||
|
XCTAssertEqual(bookmarkButton.value as? String, "unselected", "离开书签页后顶部书签按钮应取消选中")
|
||||||
|
|
||||||
|
paging.swipeRight()
|
||||||
|
app.showReaderChromeIfNeeded()
|
||||||
|
XCTAssertEqual(bookmarkButton.value as? String, "selected", "返回书签页后顶部书签按钮应恢复选中")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func waitForCellCount(
|
private func waitForCellCount(
|
||||||
in table: XCUIElement,
|
in table: XCUIElement,
|
||||||
minimum: Int,
|
minimum: Int,
|
||||||
|
|||||||
@ -15,12 +15,37 @@ final class DisplayTypeTests: XCTestCase {
|
|||||||
func testOpenWithHorizontalScroll() {
|
func testOpenWithHorizontalScroll() {
|
||||||
app.launchAndOpenSampleBook(displayType: "scroll")
|
app.launchAndOpenSampleBook(displayType: "scroll")
|
||||||
app.waitForReader()
|
app.waitForReader()
|
||||||
app.waitForReaderState(containing: "display=horizontalScroll")
|
app.waitForDemoReaderState(timeout: 8, description: "display=horizontalScroll") { $0.display == "horizontalScroll" }
|
||||||
}
|
}
|
||||||
|
|
||||||
func testOpenWithVerticalScroll() {
|
func testOpenWithVerticalScroll() {
|
||||||
app.launchAndOpenSampleBook(displayType: "vertical")
|
app.launchAndOpenSampleBook(displayType: "vertical")
|
||||||
app.waitForReader()
|
app.waitForReader()
|
||||||
app.waitForReaderState(containing: "display=verticalScroll")
|
app.waitForDemoReaderState(timeout: 8, description: "display=verticalScroll") { $0.display == "verticalScroll" }
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInspectableWebViewsDisabledByDefault() {
|
||||||
|
app.launchAndOpenSampleBook(bookTitleQuery: "Web交互流式外链验证")
|
||||||
|
app.waitForReader()
|
||||||
|
|
||||||
|
let state = app.waitForDemoReaderState(timeout: 8, description: "inspectable=0") { $0.inspectable != nil }
|
||||||
|
XCTAssertEqual(state.inspectable, 0, "默认配置下 WebView inspectable 应关闭")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDisplayTypeSwitchMidSession() {
|
||||||
|
app.launchAndOpenSampleBook(displayType: "scroll")
|
||||||
|
app.waitForReader()
|
||||||
|
app.waitForDemoReaderState(timeout: 8, description: "display=horizontalScroll") { $0.display == "horizontalScroll" }
|
||||||
|
|
||||||
|
app.showReaderChromeIfNeeded()
|
||||||
|
XCTAssertTrue(app.buttons[IDs.readerSettings].waitForExistence(timeout: 3))
|
||||||
|
app.buttons[IDs.readerSettings].tap()
|
||||||
|
|
||||||
|
let displayTypeControl = app.segmentedControls[IDs.settingsDisplayType]
|
||||||
|
XCTAssertTrue(displayTypeControl.waitForExistence(timeout: 5))
|
||||||
|
displayTypeControl.buttons.element(boundBy: 2).tap()
|
||||||
|
app.buttons[IDs.settingsDone].tap()
|
||||||
|
|
||||||
|
app.waitForDemoReaderState(timeout: 8, description: "display=verticalScroll after switch") { $0.display == "verticalScroll" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,7 +14,7 @@ final class ErrorAndEdgeCaseTests: XCTestCase {
|
|||||||
let paging = app.collectionViews[IDs.readerPaging]
|
let paging = app.collectionViews[IDs.readerPaging]
|
||||||
if paging.waitForExistence(timeout: 5) { paging.swipeUp() }
|
if paging.waitForExistence(timeout: 5) { paging.swipeUp() }
|
||||||
|
|
||||||
app.waitForReaderState(containing: "reader=opened", timeout: 5)
|
app.waitForDemoReaderState(timeout: 5, description: "reader=opened") { $0.isOpened }
|
||||||
}
|
}
|
||||||
|
|
||||||
func testOpenAndCloseMultipleTimes() throws {
|
func testOpenAndCloseMultipleTimes() throws {
|
||||||
@ -42,7 +42,7 @@ final class ErrorAndEdgeCaseTests: XCTestCase {
|
|||||||
|
|
||||||
for _ in 1...5 { paging.swipeLeft() }
|
for _ in 1...5 { paging.swipeLeft() }
|
||||||
|
|
||||||
app.waitForReaderState(containing: "reader=opened", timeout: 8)
|
app.waitForDemoReaderState(timeout: 8, description: "reader=opened") { $0.isOpened }
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSettingsPanelScrollReachesAllControls() throws {
|
func testSettingsPanelScrollReachesAllControls() throws {
|
||||||
@ -66,4 +66,85 @@ final class ErrorAndEdgeCaseTests: XCTestCase {
|
|||||||
|
|
||||||
app.buttons[IDs.settingsDone].tap()
|
app.buttons[IDs.settingsDone].tap()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
func testHTTPSExternalLinkShowsConfirmationAlert() throws {
|
||||||
|
app.launchAndOpenSampleBook(bookTitleQuery: "Web交互流式外链验证", displayType: "scroll")
|
||||||
|
app.waitForReader(timeout: 20)
|
||||||
|
|
||||||
|
let httpsLink = externalLinkElement(named: "HTTPS Link")
|
||||||
|
guard httpsLink.waitForExistence(timeout: 8) else {
|
||||||
|
throw XCTSkip("HTTPS 外链元素未暴露")
|
||||||
|
}
|
||||||
|
httpsLink.tap()
|
||||||
|
|
||||||
|
XCTAssertTrue(app.alerts["打开外部链接"].waitForExistence(timeout: 5), "HTTPS 外链应弹出确认框")
|
||||||
|
XCTAssertTrue(app.buttons["打开"].waitForExistence(timeout: 2), "确认框应提供打开按钮")
|
||||||
|
app.buttons["取消"].tap()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMailtoExternalLinkBlockedByDefaultPolicy() throws {
|
||||||
|
app.launchAndOpenSampleBook(bookTitleQuery: "Web交互流式外链验证", displayType: "scroll")
|
||||||
|
app.waitForReader(timeout: 20)
|
||||||
|
|
||||||
|
let mailtoLink = externalLinkElement(named: "Mailto Link")
|
||||||
|
guard mailtoLink.waitForExistence(timeout: 8) else {
|
||||||
|
throw XCTSkip("Mailto 外链元素未暴露")
|
||||||
|
}
|
||||||
|
mailtoLink.tap()
|
||||||
|
|
||||||
|
XCTAssertFalse(app.alerts["打开外部链接"].waitForExistence(timeout: 2), "默认策略下 mailto 不应弹出确认框")
|
||||||
|
let state = app.waitForDemoReaderState(timeout: 5, description: "mailto activation recorded") { state in
|
||||||
|
(state.externalLinks ?? 0) >= 1 && state.lastExternalURL != nil
|
||||||
|
}
|
||||||
|
XCTAssertTrue(state.lastExternalURL?.contains("mailto:test@example.com") == true, "应记录 mailto 激活但不继续打开")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLargeWebResourceUsesStreamingPath() throws {
|
||||||
|
app.launchAndOpenSampleBook(bookTitleQuery: "Web交互流式外链验证", displayType: "scroll")
|
||||||
|
app.waitForReader(timeout: 20)
|
||||||
|
|
||||||
|
let state = app.waitForDemoReaderState(timeout: 15, description: "streamedResources>0") { state in
|
||||||
|
(state.streamedResources ?? 0) > 0
|
||||||
|
}
|
||||||
|
XCTAssertGreaterThan(state.streamedResources ?? 0, 0, "大资源应走流式传输路径")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInvalidEPUBReportsFailure() {
|
||||||
|
app.launchAndOpenSampleBook(bookTitleQuery: "损坏结构样本", resetsReaderState: true)
|
||||||
|
|
||||||
|
let state = app.waitForDemoReaderState(timeout: 12, description: "invalid epub failure") { state in
|
||||||
|
state.lastError != nil && state.lastError != "none"
|
||||||
|
}
|
||||||
|
XCTAssertNotEqual(state.lastError, "none")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEmptyArchiveReportsFailure() {
|
||||||
|
app.launchAndOpenSampleBook(bookTitleQuery: "空归档样本", resetsReaderState: true)
|
||||||
|
|
||||||
|
let state = app.waitForDemoReaderState(timeout: 12, description: "empty archive failure") { state in
|
||||||
|
state.lastError != nil && state.lastError != "none"
|
||||||
|
}
|
||||||
|
XCTAssertNotEqual(state.lastError, "none")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMissingSpineDocumentReportsFailure() {
|
||||||
|
app.launchAndOpenSampleBook(bookTitleQuery: "缺失文件样本", resetsReaderState: true)
|
||||||
|
|
||||||
|
let state = app.waitForDemoReaderState(timeout: 12, description: "missing file failure") { state in
|
||||||
|
state.lastError != nil && state.lastError != "none"
|
||||||
|
}
|
||||||
|
XCTAssertNotEqual(state.lastError, "none")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func externalLinkElement(named label: String) -> XCUIElement {
|
||||||
|
let link = app.links[label]
|
||||||
|
if link.exists {
|
||||||
|
return link
|
||||||
|
}
|
||||||
|
let staticText = app.staticTexts[label]
|
||||||
|
if staticText.exists {
|
||||||
|
return staticText
|
||||||
|
}
|
||||||
|
return link
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -23,7 +23,7 @@ final class HighlightsManagementTests: XCTestCase {
|
|||||||
if highlightMenuItem.waitForExistence(timeout: 3) { highlightMenuItem.tap() }
|
if highlightMenuItem.waitForExistence(timeout: 3) { highlightMenuItem.tap() }
|
||||||
else if app.buttons["高亮"].waitForExistence(timeout: 2) { app.buttons["高亮"].tap() }
|
else if app.buttons["高亮"].waitForExistence(timeout: 2) { app.buttons["高亮"].tap() }
|
||||||
|
|
||||||
app.waitForReaderState(containing: "highlights=", timeout: 5)
|
app.waitForDemoReaderState(timeout: 5, description: "highlights exists") { $0.highlights != nil }
|
||||||
|
|
||||||
app.showReaderChromeIfNeeded()
|
app.showReaderChromeIfNeeded()
|
||||||
XCTAssertTrue(app.buttons[IDs.readerHighlights].waitForExistence(timeout: 3), "高亮列表按钮不存在")
|
XCTAssertTrue(app.buttons[IDs.readerHighlights].waitForExistence(timeout: 3), "高亮列表按钮不存在")
|
||||||
|
|||||||
@ -50,9 +50,10 @@ final class LargeBookOnDemandTests: XCTestCase {
|
|||||||
app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, resetsReaderState: true)
|
app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, resetsReaderState: true)
|
||||||
app.waitForReader(timeout: 20)
|
app.waitForReader(timeout: 20)
|
||||||
|
|
||||||
_ = app.waitForDemoReaderState(timeout: 60, description: "首次打开完成全书缓存") { state in
|
let firstFullState = app.waitForDemoReaderState(timeout: 60, description: "首次打开完成全书缓存") { state in
|
||||||
state.mode == "bookPageMap" && state.pagination == "full" && (state.buildableChapters ?? 0) > 0
|
state.mode == "bookPageMap" && state.pagination == "full" && (state.buildableChapters ?? 0) > 0
|
||||||
}
|
}
|
||||||
|
XCTAssertGreaterThan(firstFullState.cacheFiles ?? 0, 0, "首次全量解析后应生成章节摘要缓存")
|
||||||
|
|
||||||
app.showReaderChromeIfNeeded()
|
app.showReaderChromeIfNeeded()
|
||||||
XCTAssertTrue(app.buttons[IDs.readerBack].waitForExistence(timeout: 5), "返回按钮不存在")
|
XCTAssertTrue(app.buttons[IDs.readerBack].waitForExistence(timeout: 5), "返回按钮不存在")
|
||||||
@ -68,6 +69,47 @@ final class LargeBookOnDemandTests: XCTestCase {
|
|||||||
|
|
||||||
XCTAssertEqual(reopenedState.pagination, "full")
|
XCTAssertEqual(reopenedState.pagination, "full")
|
||||||
XCTAssertEqual(reopenedState.knownChapters, reopenedState.buildableChapters)
|
XCTAssertEqual(reopenedState.knownChapters, reopenedState.buildableChapters)
|
||||||
|
XCTAssertEqual(reopenedState.knownPages, firstFullState.knownPages, "二开命中缓存后总页数应稳定一致")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCorruptedCacheFallsBackGracefully() throws {
|
||||||
|
app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, resetsReaderState: true, clearsCache: true)
|
||||||
|
app.waitForReader(timeout: 20)
|
||||||
|
_ = app.waitForDemoReaderState(timeout: 60, description: "首次打开完成缓存建立") { state in
|
||||||
|
state.pagination == "full" && (state.cacheFiles ?? 0) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
app.showReaderChromeIfNeeded()
|
||||||
|
app.buttons[IDs.readerBack].tap()
|
||||||
|
XCTAssertTrue(app.tables[IDs.demoBooksTable].waitForExistence(timeout: 5))
|
||||||
|
|
||||||
|
app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, resetsReaderState: false, corruptsCache: true)
|
||||||
|
app.waitForReader(timeout: 20)
|
||||||
|
|
||||||
|
let recoveredState = app.waitForDemoReaderState(timeout: 45, description: "损坏缓存后仍可恢复阅读") { state in
|
||||||
|
state.isOpened && state.pagination != "none" && state.mode == "bookPageMap"
|
||||||
|
}
|
||||||
|
XCTAssertEqual(recoveredState.lastError, "none", "缓存损坏不应导致阅读器进入错误状态")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testClearingCacheForcesRebuild() throws {
|
||||||
|
app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, resetsReaderState: true, clearsCache: true)
|
||||||
|
app.waitForReader(timeout: 20)
|
||||||
|
_ = app.waitForDemoReaderState(timeout: 60, description: "首次缓存建立") { state in
|
||||||
|
state.pagination == "full" && (state.cacheFiles ?? 0) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
app.showReaderChromeIfNeeded()
|
||||||
|
app.buttons[IDs.readerBack].tap()
|
||||||
|
XCTAssertTrue(app.tables[IDs.demoBooksTable].waitForExistence(timeout: 5))
|
||||||
|
|
||||||
|
app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, resetsReaderState: false, clearsCache: true)
|
||||||
|
app.waitForReader(timeout: 20)
|
||||||
|
|
||||||
|
let rebuiltState = app.waitForDemoReaderState(timeout: 60, description: "清空缓存后重新构建") { state in
|
||||||
|
state.pagination == "full" && (state.cacheFiles ?? 0) > 0
|
||||||
|
}
|
||||||
|
XCTAssertGreaterThan(rebuiltState.cacheFiles ?? 0, 0, "清空缓存后应重新写入缓存文件")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testMiddleChapterOpenPositionStableAfterFullParse() throws {
|
func testMiddleChapterOpenPositionStableAfterFullParse() throws {
|
||||||
@ -146,7 +188,7 @@ final class LargeBookOnDemandTests: XCTestCase {
|
|||||||
func testLargeBookContinuousPagingExtendsKnownPageMap() throws {
|
func testLargeBookContinuousPagingExtendsKnownPageMap() throws {
|
||||||
app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, displayType: "scroll", resetsReaderState: true)
|
app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, displayType: "scroll", resetsReaderState: true)
|
||||||
app.waitForReader(timeout: 20)
|
app.waitForReader(timeout: 20)
|
||||||
app.waitForReaderState(containing: "display=horizontalScroll", timeout: 8)
|
app.waitForDemoReaderState(timeout: 8, description: "display=horizontalScroll") { $0.display == "horizontalScroll" }
|
||||||
|
|
||||||
let initialState = app.waitForDemoReaderState(timeout: 15, description: "初始局部分页") { state in
|
let initialState = app.waitForDemoReaderState(timeout: 15, description: "初始局部分页") { state in
|
||||||
state.mode == "bookPageMap" && state.knownPages != nil && state.page != nil
|
state.mode == "bookPageMap" && state.knownPages != nil && state.page != nil
|
||||||
|
|||||||
@ -57,7 +57,7 @@ final class LocationPersistenceTests: XCTestCase {
|
|||||||
|
|
||||||
let paging = app.collectionViews[IDs.readerPaging]
|
let paging = app.collectionViews[IDs.readerPaging]
|
||||||
if paging.waitForExistence(timeout: 5) { paging.swipeLeft() }
|
if paging.waitForExistence(timeout: 5) { paging.swipeLeft() }
|
||||||
app.waitForReaderState(containing: "page=", timeout: 5)
|
app.waitForDemoReaderState(timeout: 5, description: "page exists") { $0.page != nil }
|
||||||
|
|
||||||
app.showReaderChromeIfNeeded()
|
app.showReaderChromeIfNeeded()
|
||||||
if app.buttons[IDs.readerSettings].waitForExistence(timeout: 3) { app.buttons[IDs.readerSettings].tap() }
|
if app.buttons[IDs.readerSettings].waitForExistence(timeout: 3) { app.buttons[IDs.readerSettings].tap() }
|
||||||
@ -70,6 +70,37 @@ final class LocationPersistenceTests: XCTestCase {
|
|||||||
app.launchAndOpenSampleBook()
|
app.launchAndOpenSampleBook()
|
||||||
app.waitForReader(timeout: 12)
|
app.waitForReader(timeout: 12)
|
||||||
|
|
||||||
app.waitForReaderState(containing: "reader=opened", timeout: 8)
|
app.waitForDemoReaderState(timeout: 8, description: "reader=opened") { $0.isOpened }
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBookmarkAndHighlightPersistAcrossReopen() throws {
|
||||||
|
app.launchAndOpenSampleBook(resetsReaderState: true)
|
||||||
|
app.waitForReader(timeout: 12)
|
||||||
|
|
||||||
|
app.showReaderChromeIfNeeded()
|
||||||
|
XCTAssertTrue(app.buttons[IDs.readerBookmark].waitForExistence(timeout: 3))
|
||||||
|
app.buttons[IDs.readerBookmark].tap()
|
||||||
|
app.waitForDemoReaderState(timeout: 5, description: "bookmarks=1") { $0.bookmarks == 1 }
|
||||||
|
|
||||||
|
app.hideReaderChromeIfNeeded()
|
||||||
|
let content = app.otherElements[IDs.readerSelectionText].firstMatch
|
||||||
|
XCTAssertTrue(content.waitForExistence(timeout: 5))
|
||||||
|
let start = content.coordinate(withNormalizedOffset: CGVector(dx: 0.2, dy: 0.5))
|
||||||
|
let end = content.coordinate(withNormalizedOffset: CGVector(dx: 0.55, dy: 0.5))
|
||||||
|
start.press(forDuration: 0.8, thenDragTo: end)
|
||||||
|
XCTAssertTrue(app.menuItems["高亮"].waitForExistence(timeout: 3))
|
||||||
|
app.menuItems["高亮"].tap()
|
||||||
|
app.waitForDemoReaderState(timeout: 8, description: "highlights=1") { $0.highlights == 1 }
|
||||||
|
|
||||||
|
app.showReaderChromeIfNeeded()
|
||||||
|
app.buttons[IDs.readerBack].tap()
|
||||||
|
|
||||||
|
app.launchAndOpenSampleBook(resetsReaderState: false)
|
||||||
|
app.waitForReader(timeout: 12)
|
||||||
|
let state = app.waitForDemoReaderState(timeout: 8, description: "bookmark/highlight restored") { state in
|
||||||
|
state.bookmarks == 1 && state.highlights == 1
|
||||||
|
}
|
||||||
|
XCTAssertEqual(state.bookmarks, 1)
|
||||||
|
XCTAssertEqual(state.highlights, 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,83 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
/// 向后导航和显示模式切换测试
|
||||||
|
/// 覆盖:向右滑动回到上一页、阅读中途切换显示模式
|
||||||
|
final class NavigationBackwardTests: XCTestCase {
|
||||||
|
|
||||||
|
private let app = XCUIApplication()
|
||||||
|
|
||||||
|
override func setUpWithError() throws {
|
||||||
|
continueAfterFailure = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 向右滑动应返回上一页
|
||||||
|
func testSwipeRightGoesToPreviousPage() throws {
|
||||||
|
app.launchAndOpenSampleBook()
|
||||||
|
app.waitForReader()
|
||||||
|
|
||||||
|
// 先向左滑动两次前进
|
||||||
|
let content = app.otherElements[IDs.readerContent].firstMatch
|
||||||
|
guard content.waitForExistence(timeout: 5) else {
|
||||||
|
throw XCTSkip("内容区域不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
content.swipeLeft()
|
||||||
|
Thread.sleep(forTimeInterval: 0.5)
|
||||||
|
content.swipeLeft()
|
||||||
|
Thread.sleep(forTimeInterval: 0.5)
|
||||||
|
|
||||||
|
// 记录当前页码
|
||||||
|
let stateAfterForward = app.waitForDemoReaderState(timeout: 5, description: "page after forward") {
|
||||||
|
($0.page ?? 0) > 1
|
||||||
|
}
|
||||||
|
let pageAfterForward = stateAfterForward.page ?? 2
|
||||||
|
XCTAssertGreaterThan(pageAfterForward, 1, "应已前进到第 2 页或更后")
|
||||||
|
|
||||||
|
// 向右滑动返回
|
||||||
|
content.swipeRight()
|
||||||
|
Thread.sleep(forTimeInterval: 0.5)
|
||||||
|
|
||||||
|
// 验证页码减少
|
||||||
|
let stateAfterBack = app.waitForDemoReaderState(timeout: 5, description: "page after back") {
|
||||||
|
($0.page ?? 0) < pageAfterForward
|
||||||
|
}
|
||||||
|
XCTAssertLessThan(stateAfterBack.page ?? 0, pageAfterForward, "向右滑动后页码应减少")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 阅读中途切换显示模式
|
||||||
|
func testDisplayTypeSwitchMidSession() throws {
|
||||||
|
app.launchAndOpenSampleBook(displayType: "horizontalScroll")
|
||||||
|
app.waitForReader()
|
||||||
|
|
||||||
|
// 确认初始模式
|
||||||
|
app.waitForDemoReaderState(timeout: 5, description: "initial horizontalScroll") {
|
||||||
|
$0.display == "horizontalScroll"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开设置面板
|
||||||
|
app.showReaderChromeIfNeeded()
|
||||||
|
let settingsButton = app.buttons[IDs.readerSettings]
|
||||||
|
guard settingsButton.waitForExistence(timeout: 3) else {
|
||||||
|
throw XCTSkip("设置按钮不存在")
|
||||||
|
}
|
||||||
|
settingsButton.tap()
|
||||||
|
|
||||||
|
// 切换到垂直滚动
|
||||||
|
let displayTypeButton = app.buttons[IDs.settingsDisplayType]
|
||||||
|
guard displayTypeButton.waitForExistence(timeout: 3) else {
|
||||||
|
throw XCTSkip("显示模式按钮不存在")
|
||||||
|
}
|
||||||
|
displayTypeButton.tap()
|
||||||
|
|
||||||
|
// 关闭设置
|
||||||
|
let doneButton = app.buttons[IDs.settingsDone]
|
||||||
|
if doneButton.waitForExistence(timeout: 3) {
|
||||||
|
doneButton.tap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证显示模式已切换
|
||||||
|
app.waitForDemoReaderState(timeout: 5, description: "switched to verticalScroll") {
|
||||||
|
$0.display == "verticalScroll"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -18,7 +18,7 @@ final class PageNavigationTests: XCTestCase {
|
|||||||
func testPagingViewExistsInScrollMode() {
|
func testPagingViewExistsInScrollMode() {
|
||||||
app.launchAndOpenSampleBook(displayType: "scroll")
|
app.launchAndOpenSampleBook(displayType: "scroll")
|
||||||
app.waitForReader()
|
app.waitForReader()
|
||||||
app.waitForReaderState(containing: "display=horizontalScroll", timeout: 5)
|
app.waitForDemoReaderState(timeout: 5, description: "display=horizontalScroll") { $0.display == "horizontalScroll" }
|
||||||
|
|
||||||
let paging = app.collectionViews[IDs.readerPaging]
|
let paging = app.collectionViews[IDs.readerPaging]
|
||||||
XCTAssertTrue(paging.waitForExistence(timeout: 5), "分页滚动视图未出现")
|
XCTAssertTrue(paging.waitForExistence(timeout: 5), "分页滚动视图未出现")
|
||||||
@ -27,7 +27,7 @@ final class PageNavigationTests: XCTestCase {
|
|||||||
func testPagingViewExistsInVerticalScrollMode() {
|
func testPagingViewExistsInVerticalScrollMode() {
|
||||||
app.launchAndOpenSampleBook(displayType: "vertical")
|
app.launchAndOpenSampleBook(displayType: "vertical")
|
||||||
app.waitForReader()
|
app.waitForReader()
|
||||||
app.waitForReaderState(containing: "display=verticalScroll", timeout: 5)
|
app.waitForDemoReaderState(timeout: 5, description: "display=verticalScroll") { $0.display == "verticalScroll" }
|
||||||
|
|
||||||
let paging = app.collectionViews[IDs.readerPaging]
|
let paging = app.collectionViews[IDs.readerPaging]
|
||||||
XCTAssertTrue(paging.waitForExistence(timeout: 5), "分页滚动视图未出现")
|
XCTAssertTrue(paging.waitForExistence(timeout: 5), "分页滚动视图未出现")
|
||||||
@ -36,7 +36,7 @@ final class PageNavigationTests: XCTestCase {
|
|||||||
func testSwipeInHorizontalScrollMode() {
|
func testSwipeInHorizontalScrollMode() {
|
||||||
app.launchAndOpenSampleBook(displayType: "scroll")
|
app.launchAndOpenSampleBook(displayType: "scroll")
|
||||||
app.waitForReader()
|
app.waitForReader()
|
||||||
app.waitForReaderState(containing: "display=horizontalScroll", timeout: 5)
|
app.waitForDemoReaderState(timeout: 5, description: "display=horizontalScroll") { $0.display == "horizontalScroll" }
|
||||||
|
|
||||||
let paging = app.collectionViews[IDs.readerPaging]
|
let paging = app.collectionViews[IDs.readerPaging]
|
||||||
XCTAssertTrue(paging.waitForExistence(timeout: 5))
|
XCTAssertTrue(paging.waitForExistence(timeout: 5))
|
||||||
@ -51,4 +51,26 @@ final class PageNavigationTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
XCTAssertNotEqual(beforePage, afterState.page, "水平滑动后页码未变化")
|
XCTAssertNotEqual(beforePage, afterState.page, "水平滑动后页码未变化")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testSwipeRightReturnsToPreviousPage() {
|
||||||
|
app.launchAndOpenSampleBook(displayType: "scroll", pageNumber: 3)
|
||||||
|
app.waitForReader()
|
||||||
|
app.waitForDemoReaderState(timeout: 8, description: "page=3") { $0.page == 3 }
|
||||||
|
|
||||||
|
let paging = app.collectionViews[IDs.readerPaging]
|
||||||
|
XCTAssertTrue(paging.waitForExistence(timeout: 5))
|
||||||
|
paging.swipeRight()
|
||||||
|
|
||||||
|
let state = app.waitForDemoReaderState(timeout: 8, description: "returned to previous page") { state in
|
||||||
|
guard let page = state.page else { return false }
|
||||||
|
return page < 3
|
||||||
|
}
|
||||||
|
XCTAssertEqual(state.page, 2, "向右滑动后应回到上一页")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLaunchAtSpecificPageNumber() {
|
||||||
|
app.launchAndOpenSampleBook(pageNumber: 4)
|
||||||
|
app.waitForReader()
|
||||||
|
app.waitForDemoReaderState(timeout: 8, description: "page=4") { $0.page == 4 }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,12 +37,11 @@ final class ReaderAnnotationExtendedTests: XCTestCase {
|
|||||||
XCTAssertTrue(app.buttons[IDs.readerSettings].exists, "设置按钮未出现")
|
XCTAssertTrue(app.buttons[IDs.readerSettings].exists, "设置按钮未出现")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSelectionTextElementExists() {
|
func testSelectionTextElementExists() throws {
|
||||||
app.launchAndOpenSampleBook()
|
app.launchAndOpenSampleBook()
|
||||||
app.waitForReader()
|
app.waitForReader()
|
||||||
|
|
||||||
let selectionText = app.otherElements[IDs.readerSelectionText].firstMatch
|
_ = try app.requireSelectionText()
|
||||||
XCTAssertTrue(selectionText.waitForExistence(timeout: 5), "文本选区元素未出现")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func testReaderStateReportsPageInfo() {
|
func testReaderStateReportsPageInfo() {
|
||||||
|
|||||||
@ -7,22 +7,20 @@ final class ReaderAnnotationTests: XCTestCase {
|
|||||||
continueAfterFailure = false
|
continueAfterFailure = false
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSelectionMenuCreatesHighlight() {
|
func testSelectionMenuCreatesHighlight() throws {
|
||||||
app.launchAndOpenSampleBook(pageNumber: 2)
|
app.launchAndOpenSampleBook()
|
||||||
app.waitForReader()
|
app.waitForReader()
|
||||||
app.waitForReaderPage(2)
|
|
||||||
app.hideReaderChromeIfNeeded()
|
app.hideReaderChromeIfNeeded()
|
||||||
|
|
||||||
let content = app.otherElements[IDs.readerSelectionText].firstMatch
|
let content = try app.requireSelectionText()
|
||||||
XCTAssertTrue(content.waitForExistence(timeout: 5), "阅读内容区域未出现")
|
|
||||||
|
|
||||||
app.waitForReaderState(containing: "toolbar=hidden", timeout: 5)
|
app.waitForDemoReaderState(timeout: 5, description: "toolbar=hidden") { $0.toolbar == "hidden" }
|
||||||
|
|
||||||
let start = content.coordinate(withNormalizedOffset: CGVector(dx: 0.2, dy: 0.5))
|
let start = content.coordinate(withNormalizedOffset: CGVector(dx: 0.2, dy: 0.5))
|
||||||
let end = content.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.5))
|
let end = content.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.5))
|
||||||
start.press(forDuration: 0.8, thenDragTo: end)
|
start.press(forDuration: 0.8, thenDragTo: end)
|
||||||
|
|
||||||
app.waitForReaderState(containing: "selection=1", timeout: 5)
|
app.waitForDemoReaderState(timeout: 5, description: "selection=1") { $0.selection == 1 }
|
||||||
|
|
||||||
let highlightMenuItem = app.menuItems["高亮"]
|
let highlightMenuItem = app.menuItems["高亮"]
|
||||||
let highlightButton = app.buttons[IDs.readerSelectionHighlight]
|
let highlightButton = app.buttons[IDs.readerSelectionHighlight]
|
||||||
@ -34,25 +32,43 @@ final class ReaderAnnotationTests: XCTestCase {
|
|||||||
XCTFail("选中文本后未出现高亮菜单")
|
XCTFail("选中文本后未出现高亮菜单")
|
||||||
}
|
}
|
||||||
|
|
||||||
app.waitForReaderState(containing: "highlights=1", timeout: 8)
|
app.waitForDemoReaderState(timeout: 8, description: "highlights=1") { $0.highlights == 1 }
|
||||||
}
|
}
|
||||||
|
|
||||||
func testLongPressSelectionDoesNotShowToolbars() {
|
func testLongPressSelectionDoesNotShowToolbars() throws {
|
||||||
app.launchAndOpenSampleBook(pageNumber: 2)
|
app.launchAndOpenSampleBook()
|
||||||
app.waitForReader()
|
app.waitForReader()
|
||||||
app.waitForReaderPage(2)
|
|
||||||
app.hideReaderChromeIfNeeded()
|
app.hideReaderChromeIfNeeded()
|
||||||
|
|
||||||
let content = app.otherElements[IDs.readerSelectionText].firstMatch
|
let content = try app.requireSelectionText()
|
||||||
XCTAssertTrue(content.waitForExistence(timeout: 5), "阅读内容区域未出现")
|
|
||||||
|
|
||||||
app.waitForReaderState(containing: "toolbar=hidden", timeout: 5)
|
app.waitForDemoReaderState(timeout: 5, description: "toolbar=hidden") { $0.toolbar == "hidden" }
|
||||||
|
|
||||||
let start = content.coordinate(withNormalizedOffset: CGVector(dx: 0.2, dy: 0.5))
|
let start = content.coordinate(withNormalizedOffset: CGVector(dx: 0.2, dy: 0.5))
|
||||||
let end = content.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.5))
|
let end = content.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.5))
|
||||||
start.press(forDuration: 0.8, thenDragTo: end)
|
start.press(forDuration: 0.8, thenDragTo: end)
|
||||||
|
|
||||||
app.waitForReaderState(containing: "selection=1", timeout: 8)
|
app.waitForDemoReaderState(timeout: 8, description: "selection=1") { $0.selection == 1 }
|
||||||
app.waitForReaderState(containing: "toolbar=hidden", timeout: 3)
|
app.waitForDemoReaderState(timeout: 3, description: "toolbar=hidden") { $0.toolbar == "hidden" }
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSelectionMenuAnnotateCreatesHighlight() throws {
|
||||||
|
app.launchAndOpenSampleBook()
|
||||||
|
app.waitForReader()
|
||||||
|
app.hideReaderChromeIfNeeded()
|
||||||
|
|
||||||
|
let content = try app.requireSelectionText()
|
||||||
|
|
||||||
|
let start = content.coordinate(withNormalizedOffset: CGVector(dx: 0.25, dy: 0.5))
|
||||||
|
let end = content.coordinate(withNormalizedOffset: CGVector(dx: 0.7, dy: 0.5))
|
||||||
|
start.press(forDuration: 0.8, thenDragTo: end)
|
||||||
|
|
||||||
|
app.waitForDemoReaderState(timeout: 5, description: "selection=1") { $0.selection == 1 }
|
||||||
|
|
||||||
|
let annotateMenuItem = app.menuItems["批注"]
|
||||||
|
XCTAssertTrue(annotateMenuItem.waitForExistence(timeout: 3), "选中文本后未出现批注菜单")
|
||||||
|
annotateMenuItem.tap()
|
||||||
|
|
||||||
|
app.waitForDemoReaderState(timeout: 8, description: "highlights=1 after annotate") { $0.highlights == 1 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -247,6 +247,189 @@ final class SearchTests: XCTestCase {
|
|||||||
XCTAssertNotEqual(countLabel.label, "0/0", "搜索状态应在工具栏恢复后保留")
|
XCTAssertNotEqual(countLabel.label, "0/0", "搜索状态应在工具栏恢复后保留")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - 搜索高亮字符验证
|
||||||
|
|
||||||
|
/// 搜索匹配文本应与搜索关键字一致
|
||||||
|
/// 验证当前匹配项的高亮确实覆盖了搜索的关键字
|
||||||
|
func testSearchMatchTextEqualsKeyword() {
|
||||||
|
let keyword = "辽"
|
||||||
|
app.launchAndOpenSampleBook(bookTitleQuery: "宝山辽墓材料与释读", searchKeyword: keyword)
|
||||||
|
app.waitForReader()
|
||||||
|
app.showReaderChromeIfNeeded()
|
||||||
|
|
||||||
|
// 等待搜索结果
|
||||||
|
let countLabel = app.staticTexts[IDs.searchCount]
|
||||||
|
XCTAssertTrue(countLabel.waitForExistence(timeout: 10), "搜索计数应出现")
|
||||||
|
XCTAssertNotEqual(countLabel.label, "0/0", "关键词 '\(keyword)' 应有匹配")
|
||||||
|
|
||||||
|
// 验证当前匹配的文本就是搜索关键字
|
||||||
|
let state = app.waitForDemoReaderState(timeout: 5, description: "searchMatchText exists") {
|
||||||
|
$0.searchMatchText != nil && $0.searchMatchText != "none"
|
||||||
|
}
|
||||||
|
XCTAssertEqual(state.searchMatchText, keyword,
|
||||||
|
"当前搜索匹配文本应为 '\(keyword)',实际:\(state.searchMatchText ?? "nil")")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重排后搜索匹配文本仍应与关键字一致
|
||||||
|
func testSearchMatchTextAfterRepagination() {
|
||||||
|
let keyword = "辽"
|
||||||
|
app.launchAndOpenSampleBook(bookTitleQuery: "宝山辽墓材料与释读", searchKeyword: keyword)
|
||||||
|
app.waitForReader()
|
||||||
|
app.showReaderChromeIfNeeded()
|
||||||
|
|
||||||
|
// 等待搜索结果
|
||||||
|
let countLabel = app.staticTexts[IDs.searchCount]
|
||||||
|
XCTAssertTrue(countLabel.waitForExistence(timeout: 10), "搜索计数应出现")
|
||||||
|
|
||||||
|
// 改变字体触发重排
|
||||||
|
app.showReaderChromeIfNeeded()
|
||||||
|
let settingsButton = app.buttons[IDs.readerSettings]
|
||||||
|
XCTAssertTrue(settingsButton.waitForExistence(timeout: 3))
|
||||||
|
settingsButton.tap()
|
||||||
|
|
||||||
|
let fontIncrease = app.buttons[IDs.settingsFontIncrease]
|
||||||
|
XCTAssertTrue(fontIncrease.waitForExistence(timeout: 3))
|
||||||
|
fontIncrease.tap()
|
||||||
|
fontIncrease.tap()
|
||||||
|
Thread.sleep(forTimeInterval: 0.5)
|
||||||
|
|
||||||
|
let doneButton = app.buttons[IDs.settingsDone]
|
||||||
|
if doneButton.waitForExistence(timeout: 3) {
|
||||||
|
doneButton.tap()
|
||||||
|
}
|
||||||
|
Thread.sleep(forTimeInterval: 1)
|
||||||
|
|
||||||
|
// 重新打开搜索并导航到匹配
|
||||||
|
app.showReaderChromeIfNeeded()
|
||||||
|
let prevButton = app.buttons[IDs.searchPrevious]
|
||||||
|
let nextButton = app.buttons[IDs.searchNext]
|
||||||
|
if prevButton.waitForExistence(timeout: 3), prevButton.isEnabled {
|
||||||
|
prevButton.tap()
|
||||||
|
Thread.sleep(forTimeInterval: 0.5)
|
||||||
|
}
|
||||||
|
if nextButton.waitForExistence(timeout: 3), nextButton.isEnabled {
|
||||||
|
nextButton.tap()
|
||||||
|
Thread.sleep(forTimeInterval: 0.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证重排后匹配文本仍正确
|
||||||
|
let state = app.waitForDemoReaderState(timeout: 5, description: "searchMatchText after repagination") {
|
||||||
|
$0.searchMatchText != nil && $0.searchMatchText != "none"
|
||||||
|
}
|
||||||
|
XCTAssertEqual(state.searchMatchText, keyword,
|
||||||
|
"重排后搜索匹配文本应为 '\(keyword)',实际:\(state.searchMatchText ?? "nil")")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 重排后搜索跳转正确性
|
||||||
|
|
||||||
|
/// 重排后搜索跳转页码应正确(多匹配覆盖)
|
||||||
|
/// 回归:chapterOffset(for:) 曾优先使用 row/column 查表而非存储的 chapterOffset,
|
||||||
|
/// 导致重新排版后搜索跳转到错误的页面
|
||||||
|
func testSearchNavigationAfterRepagination() {
|
||||||
|
app.launchAndOpenSampleBook(searchKeyword: "的")
|
||||||
|
app.waitForReader()
|
||||||
|
app.showReaderChromeIfNeeded()
|
||||||
|
|
||||||
|
// 等待搜索结果
|
||||||
|
let countLabel = app.staticTexts[IDs.searchCount]
|
||||||
|
XCTAssertTrue(countLabel.waitForExistence(timeout: 10), "搜索计数应出现")
|
||||||
|
XCTAssertNotEqual(countLabel.label, "0/0", "关键词 '的' 应有匹配")
|
||||||
|
|
||||||
|
// 解析匹配总数
|
||||||
|
let countText = countLabel.label
|
||||||
|
let parts = countText.split(separator: "/")
|
||||||
|
guard parts.count == 2, let total = Int(parts[1]), total >= 3 else {
|
||||||
|
XCTSkip("需要至少 3 个匹配才能测试多页跳转")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 改变字体大小触发重新排版
|
||||||
|
app.showReaderChromeIfNeeded()
|
||||||
|
let settingsButton = app.buttons[IDs.readerSettings]
|
||||||
|
XCTAssertTrue(settingsButton.waitForExistence(timeout: 3), "设置按钮应存在")
|
||||||
|
settingsButton.tap()
|
||||||
|
|
||||||
|
let fontIncrease = app.buttons[IDs.settingsFontIncrease]
|
||||||
|
XCTAssertTrue(fontIncrease.waitForExistence(timeout: 3), "字号增大按钮应存在")
|
||||||
|
fontIncrease.tap()
|
||||||
|
fontIncrease.tap()
|
||||||
|
Thread.sleep(forTimeInterval: 0.5)
|
||||||
|
|
||||||
|
let doneButton = app.buttons[IDs.settingsDone]
|
||||||
|
if doneButton.waitForExistence(timeout: 3) {
|
||||||
|
doneButton.tap()
|
||||||
|
}
|
||||||
|
Thread.sleep(forTimeInterval: 1)
|
||||||
|
|
||||||
|
// 测试多个匹配位置的跳转正确性
|
||||||
|
let nextButton = app.buttons[IDs.searchNext]
|
||||||
|
let prevButton = app.buttons[IDs.searchPrevious]
|
||||||
|
XCTAssertTrue(nextButton.waitForExistence(timeout: 3), "下一个按钮应存在")
|
||||||
|
XCTAssertTrue(prevButton.waitForExistence(timeout: 3), "上一个按钮应存在")
|
||||||
|
|
||||||
|
// 导航到第 2 个匹配,记录页码
|
||||||
|
nextButton.tap()
|
||||||
|
Thread.sleep(forTimeInterval: 0.5)
|
||||||
|
let stateMatch2 = app.waitForDemoReaderState(timeout: 5, description: "match 2 page") {
|
||||||
|
($0.page ?? 0) > 0
|
||||||
|
}
|
||||||
|
let pageAtMatch2 = stateMatch2.page ?? 1
|
||||||
|
|
||||||
|
// 翻到其他页
|
||||||
|
let content = app.otherElements[IDs.readerContent].firstMatch
|
||||||
|
if content.waitForExistence(timeout: 3) {
|
||||||
|
for _ in 0..<4 {
|
||||||
|
content.swipeLeft()
|
||||||
|
Thread.sleep(forTimeInterval: 0.3)
|
||||||
|
}
|
||||||
|
Thread.sleep(forTimeInterval: 0.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跳转到第 3 个匹配
|
||||||
|
nextButton.tap()
|
||||||
|
Thread.sleep(forTimeInterval: 0.5)
|
||||||
|
let stateMatch3 = app.waitForDemoReaderState(timeout: 5, description: "match 3 page") {
|
||||||
|
($0.page ?? 0) > 0
|
||||||
|
}
|
||||||
|
let pageAtMatch3 = stateMatch3.page ?? 1
|
||||||
|
|
||||||
|
// 再翻到其他页
|
||||||
|
if content.waitForExistence(timeout: 3) {
|
||||||
|
for _ in 0..<4 {
|
||||||
|
content.swipeLeft()
|
||||||
|
Thread.sleep(forTimeInterval: 0.3)
|
||||||
|
}
|
||||||
|
Thread.sleep(forTimeInterval: 0.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跳回第 2 个匹配,验证页码不变
|
||||||
|
prevButton.tap()
|
||||||
|
Thread.sleep(forTimeInterval: 0.5)
|
||||||
|
let stateBack2 = app.waitForDemoReaderState(timeout: 5, description: "back to match 2") {
|
||||||
|
$0.page == pageAtMatch2
|
||||||
|
}
|
||||||
|
XCTAssertEqual(stateBack2.page, pageAtMatch2,
|
||||||
|
"重排后搜索跳回第 2 个匹配应回到第 \(pageAtMatch2) 页,实际:\(stateBack2.page ?? -1)")
|
||||||
|
|
||||||
|
// 再翻到其他页
|
||||||
|
if content.waitForExistence(timeout: 3) {
|
||||||
|
for _ in 0..<4 {
|
||||||
|
content.swipeLeft()
|
||||||
|
Thread.sleep(forTimeInterval: 0.3)
|
||||||
|
}
|
||||||
|
Thread.sleep(forTimeInterval: 0.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跳到第 3 个匹配,验证页码不变
|
||||||
|
nextButton.tap()
|
||||||
|
Thread.sleep(forTimeInterval: 0.5)
|
||||||
|
let stateBack3 = app.waitForDemoReaderState(timeout: 5, description: "back to match 3") {
|
||||||
|
$0.page == pageAtMatch3
|
||||||
|
}
|
||||||
|
XCTAssertEqual(stateBack3.page, pageAtMatch3,
|
||||||
|
"重排后搜索跳到第 3 个匹配应回到第 \(pageAtMatch3) 页,实际:\(stateBack3.page ?? -1)")
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - 辅助方法
|
// MARK: - 辅助方法
|
||||||
|
|
||||||
private func openSearchBar() {
|
private func openSearchBar() {
|
||||||
|
|||||||
@ -0,0 +1,162 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
/// 选区批注动作和高亮持久化测试
|
||||||
|
/// 覆盖:批注菜单动作创建高亮、高亮跨重启存活、重排后高亮跳转页码正确
|
||||||
|
final class SelectionAnnotateTests: XCTestCase {
|
||||||
|
|
||||||
|
private let app = XCUIApplication()
|
||||||
|
|
||||||
|
override func setUpWithError() throws {
|
||||||
|
continueAfterFailure = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 点击批注菜单项应创建高亮
|
||||||
|
func testAnnotateMenuActionCreatesHighlight() throws {
|
||||||
|
app.launchAndOpenSampleBook()
|
||||||
|
app.waitForReader()
|
||||||
|
|
||||||
|
// 长按并拖拽选中文本
|
||||||
|
let selectionText = app.otherElements[IDs.readerSelectionText].firstMatch
|
||||||
|
guard selectionText.waitForExistence(timeout: 5) else {
|
||||||
|
throw XCTSkip("文本选区元素不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
let start = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.3, dy: 0.5))
|
||||||
|
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||||
|
start.press(forDuration: 0.8, thenDragTo: end)
|
||||||
|
|
||||||
|
// 等待选区状态
|
||||||
|
Thread.sleep(forTimeInterval: 0.5)
|
||||||
|
|
||||||
|
// 点击批注菜单项
|
||||||
|
let annotateItem = app.menuItems["批注"]
|
||||||
|
guard annotateItem.waitForExistence(timeout: 3) else {
|
||||||
|
throw XCTSkip("批注菜单项不存在")
|
||||||
|
}
|
||||||
|
annotateItem.tap()
|
||||||
|
|
||||||
|
// 验证高亮数量变为 1
|
||||||
|
app.waitForDemoReaderState(timeout: 5, description: "highlights=1") { $0.highlights == 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 高亮跨重启存活
|
||||||
|
func testHighlightSurvivesRestart() throws {
|
||||||
|
app.launchAndOpenSampleBook()
|
||||||
|
app.waitForReader()
|
||||||
|
|
||||||
|
// 创建高亮
|
||||||
|
let selectionText = app.otherElements[IDs.readerSelectionText].firstMatch
|
||||||
|
guard selectionText.waitForExistence(timeout: 5) else {
|
||||||
|
throw XCTSkip("文本选区元素不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
let start = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.3, dy: 0.5))
|
||||||
|
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||||
|
start.press(forDuration: 0.8, thenDragTo: end)
|
||||||
|
|
||||||
|
Thread.sleep(forTimeInterval: 0.5)
|
||||||
|
|
||||||
|
let annotateItem = app.menuItems["批注"]
|
||||||
|
guard annotateItem.waitForExistence(timeout: 3) else {
|
||||||
|
throw XCTSkip("批注菜单项不存在")
|
||||||
|
}
|
||||||
|
annotateItem.tap()
|
||||||
|
|
||||||
|
// 等待高亮创建完成
|
||||||
|
app.waitForDemoReaderState(timeout: 5, description: "highlights=1") { $0.highlights == 1 }
|
||||||
|
|
||||||
|
// 关闭阅读器
|
||||||
|
app.buttons[IDs.readerBack].tap()
|
||||||
|
|
||||||
|
// 重新打开(不重置状态)
|
||||||
|
app.launchAndOpenSampleBook(resetsReaderState: false)
|
||||||
|
app.waitForReader()
|
||||||
|
|
||||||
|
// 验证高亮仍然存在
|
||||||
|
app.waitForDemoReaderState(timeout: 8, description: "highlights=1 after restart") { $0.highlights == 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重排后高亮跳转页码应正确(多页覆盖)
|
||||||
|
/// 回归:chapterOffset(for:) 曾优先使用 row/column 查表而非存储的 chapterOffset,
|
||||||
|
/// 导致重新排版后跳转到错误的页面
|
||||||
|
func testHighlightNavigationAfterRepagination() throws {
|
||||||
|
// 测试多页:在不同页面创建高亮,改变字体后跳转,验证页码正确
|
||||||
|
let testPages = [2, 3, 5]
|
||||||
|
|
||||||
|
for page in testPages {
|
||||||
|
// 每轮重新启动以确保干净状态
|
||||||
|
app.launchAndOpenSampleBook(pageNumber: page)
|
||||||
|
app.waitForReader()
|
||||||
|
|
||||||
|
let stateBefore = app.waitForDemoReaderState(timeout: 5, description: "at page \(page)") { $0.page == page }
|
||||||
|
XCTAssertEqual(stateBefore.page, page, "应成功打开第 \(page) 页")
|
||||||
|
|
||||||
|
// 创建高亮
|
||||||
|
let selectionText = app.otherElements[IDs.readerSelectionText].firstMatch
|
||||||
|
guard selectionText.waitForExistence(timeout: 5) else {
|
||||||
|
throw XCTSkip("文本选区元素不存在")
|
||||||
|
}
|
||||||
|
let start = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.2, dy: 0.5))
|
||||||
|
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
|
||||||
|
start.press(forDuration: 0.8, thenDragTo: end)
|
||||||
|
Thread.sleep(forTimeInterval: 0.5)
|
||||||
|
|
||||||
|
let highlightItem = app.menuItems["高亮"]
|
||||||
|
guard highlightItem.waitForExistence(timeout: 3) else {
|
||||||
|
throw XCTSkip("高亮菜单项不存在")
|
||||||
|
}
|
||||||
|
highlightItem.tap()
|
||||||
|
app.waitForDemoReaderState(timeout: 5, description: "highlights=1 on page \(page)") { $0.highlights == 1 }
|
||||||
|
|
||||||
|
// 改变字体大小触发重新排版
|
||||||
|
app.showReaderChromeIfNeeded()
|
||||||
|
let settingsButton = app.buttons[IDs.readerSettings]
|
||||||
|
XCTAssertTrue(settingsButton.waitForExistence(timeout: 3), "设置按钮应存在")
|
||||||
|
settingsButton.tap()
|
||||||
|
|
||||||
|
let fontIncrease = app.buttons[IDs.settingsFontIncrease]
|
||||||
|
XCTAssertTrue(fontIncrease.waitForExistence(timeout: 3), "字号增大按钮应存在")
|
||||||
|
fontIncrease.tap()
|
||||||
|
fontIncrease.tap()
|
||||||
|
Thread.sleep(forTimeInterval: 0.5)
|
||||||
|
|
||||||
|
let doneButton = app.buttons[IDs.settingsDone]
|
||||||
|
if doneButton.waitForExistence(timeout: 3) {
|
||||||
|
doneButton.tap()
|
||||||
|
}
|
||||||
|
Thread.sleep(forTimeInterval: 1)
|
||||||
|
|
||||||
|
// 翻到其他页
|
||||||
|
let content = app.otherElements[IDs.readerContent].firstMatch
|
||||||
|
if content.waitForExistence(timeout: 3) {
|
||||||
|
for _ in 0..<3 {
|
||||||
|
content.swipeLeft()
|
||||||
|
Thread.sleep(forTimeInterval: 0.3)
|
||||||
|
}
|
||||||
|
Thread.sleep(forTimeInterval: 0.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通过高亮面板跳转回高亮位置
|
||||||
|
app.showReaderChromeIfNeeded()
|
||||||
|
let highlightsButton = app.buttons[IDs.readerHighlights]
|
||||||
|
XCTAssertTrue(highlightsButton.waitForExistence(timeout: 3), "高亮列表按钮应存在")
|
||||||
|
highlightsButton.tap()
|
||||||
|
|
||||||
|
let highlightsTable = app.tables[IDs.readerHighlightsTable]
|
||||||
|
XCTAssertTrue(highlightsTable.waitForExistence(timeout: 5), "高亮列表应出现")
|
||||||
|
|
||||||
|
let firstCell = highlightsTable.cells.firstMatch
|
||||||
|
XCTAssertTrue(firstCell.waitForExistence(timeout: 3), "高亮列表应有至少一个条目")
|
||||||
|
firstCell.tap()
|
||||||
|
|
||||||
|
// 等待跳转完成
|
||||||
|
Thread.sleep(forTimeInterval: 1)
|
||||||
|
|
||||||
|
// 验证跳转后的页码应为创建高亮时的页码
|
||||||
|
let stateAfter = app.waitForDemoReaderState(timeout: 5, description: "back at page \(page) after repagination") {
|
||||||
|
$0.page == page
|
||||||
|
}
|
||||||
|
XCTAssertEqual(stateAfter.page, page, "重排后高亮跳转应回到第 \(page) 页,实际:\(stateAfter.page ?? -1)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -71,6 +71,6 @@ final class SelectionMenuTests: XCTestCase {
|
|||||||
let copyMenuItem = app.menuItems["拷贝"]
|
let copyMenuItem = app.menuItems["拷贝"]
|
||||||
if copyMenuItem.waitForExistence(timeout: 3) { copyMenuItem.tap() }
|
if copyMenuItem.waitForExistence(timeout: 3) { copyMenuItem.tap() }
|
||||||
|
|
||||||
app.waitForReaderState(containing: "reader=opened", timeout: 5)
|
app.waitForDemoReaderState(timeout: 5, description: "reader=opened") { $0.isOpened }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -10,7 +10,7 @@ final class SettingsEffectTests: XCTestCase {
|
|||||||
func testFontSizeChangeUpdatesContent() throws {
|
func testFontSizeChangeUpdatesContent() throws {
|
||||||
app.launchAndOpenSampleBook()
|
app.launchAndOpenSampleBook()
|
||||||
app.waitForReader(timeout: 12)
|
app.waitForReader(timeout: 12)
|
||||||
app.waitForReaderState(containing: "page=", timeout: 5)
|
app.waitForDemoReaderState(timeout: 5, description: "page exists") { $0.page != nil }
|
||||||
|
|
||||||
app.showReaderChromeIfNeeded()
|
app.showReaderChromeIfNeeded()
|
||||||
XCTAssertTrue(app.buttons[IDs.readerSettings].waitForExistence(timeout: 3), "设置按钮不存在")
|
XCTAssertTrue(app.buttons[IDs.readerSettings].waitForExistence(timeout: 3), "设置按钮不存在")
|
||||||
@ -23,7 +23,7 @@ final class SettingsEffectTests: XCTestCase {
|
|||||||
XCTAssertTrue(app.buttons[IDs.settingsDone].waitForExistence(timeout: 3), "完成按钮不存在")
|
XCTAssertTrue(app.buttons[IDs.settingsDone].waitForExistence(timeout: 3), "完成按钮不存在")
|
||||||
app.buttons[IDs.settingsDone].tap()
|
app.buttons[IDs.settingsDone].tap()
|
||||||
|
|
||||||
app.waitForReaderState(containing: "reader=opened", timeout: 8)
|
app.waitForDemoReaderState(timeout: 8, description: "reader=opened") { $0.isOpened }
|
||||||
}
|
}
|
||||||
|
|
||||||
func testThemeSwitchChangesBackground() throws {
|
func testThemeSwitchChangesBackground() throws {
|
||||||
@ -59,7 +59,7 @@ final class SettingsEffectTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.buttons[IDs.settingsDone].tap()
|
app.buttons[IDs.settingsDone].tap()
|
||||||
app.waitForReaderState(containing: "reader=opened", timeout: 8)
|
app.waitForDemoReaderState(timeout: 8, description: "reader=opened") { $0.isOpened }
|
||||||
}
|
}
|
||||||
|
|
||||||
func testLineHeightChangeUpdatesContent() throws {
|
func testLineHeightChangeUpdatesContent() throws {
|
||||||
@ -76,13 +76,13 @@ final class SettingsEffectTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.buttons[IDs.settingsDone].tap()
|
app.buttons[IDs.settingsDone].tap()
|
||||||
app.waitForReaderState(containing: "reader=opened", timeout: 8)
|
app.waitForDemoReaderState(timeout: 8, description: "reader=opened") { $0.isOpened }
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDisplayTypeChangeRePaginates() throws {
|
func testDisplayTypeChangeRePaginates() throws {
|
||||||
app.launchAndOpenSampleBook(displayType: "horizontalscroll")
|
app.launchAndOpenSampleBook(displayType: "horizontalscroll")
|
||||||
app.waitForReader(timeout: 12)
|
app.waitForReader(timeout: 12)
|
||||||
app.waitForReaderState(containing: "display=horizontalScroll", timeout: 5)
|
app.waitForDemoReaderState(timeout: 5, description: "display=horizontalScroll") { $0.display == "horizontalScroll" }
|
||||||
|
|
||||||
app.showReaderChromeIfNeeded()
|
app.showReaderChromeIfNeeded()
|
||||||
app.buttons[IDs.readerSettings].tap()
|
app.buttons[IDs.readerSettings].tap()
|
||||||
@ -94,7 +94,7 @@ final class SettingsEffectTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.buttons[IDs.settingsDone].tap()
|
app.buttons[IDs.settingsDone].tap()
|
||||||
app.waitForReaderState(containing: "display=", timeout: 8)
|
app.waitForDemoReaderState(timeout: 8, description: "display changed") { $0.display != nil }
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDefaultBodyPaginationDoesNotEnableWidowOrphanCompaction() throws {
|
func testDefaultBodyPaginationDoesNotEnableWidowOrphanCompaction() throws {
|
||||||
@ -141,4 +141,60 @@ final class SettingsEffectTests: XCTestCase {
|
|||||||
|
|
||||||
app.buttons[IDs.settingsDone].tap()
|
app.buttons[IDs.settingsDone].tap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testThemeSelectionPersistsAfterReopen() throws {
|
||||||
|
app.launchAndOpenSampleBook(resetsReaderState: true)
|
||||||
|
app.waitForReader(timeout: 12)
|
||||||
|
|
||||||
|
app.showReaderChromeIfNeeded()
|
||||||
|
app.buttons[IDs.readerSettings].tap()
|
||||||
|
|
||||||
|
let darkThemeButton = app.buttons[IDs.settingsTheme(5)]
|
||||||
|
XCTAssertTrue(darkThemeButton.waitForExistence(timeout: 5))
|
||||||
|
darkThemeButton.tap()
|
||||||
|
XCTAssertEqual(darkThemeButton.value as? String, "selected", "切换主题后当前主题按钮应为选中态")
|
||||||
|
app.buttons[IDs.settingsDone].tap()
|
||||||
|
|
||||||
|
app.showReaderChromeIfNeeded()
|
||||||
|
app.buttons[IDs.readerBack].tap()
|
||||||
|
|
||||||
|
app.launchAndOpenSampleBook(resetsReaderState: false)
|
||||||
|
app.waitForReader(timeout: 12)
|
||||||
|
app.showReaderChromeIfNeeded()
|
||||||
|
app.buttons[IDs.readerSettings].tap()
|
||||||
|
|
||||||
|
let reopenedDarkThemeButton = app.buttons[IDs.settingsTheme(5)]
|
||||||
|
XCTAssertTrue(reopenedDarkThemeButton.waitForExistence(timeout: 5))
|
||||||
|
XCTAssertEqual(reopenedDarkThemeButton.value as? String, "selected", "主题应在重启后保持选中")
|
||||||
|
app.buttons[IDs.settingsDone].tap()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFontChoiceChangePersistsAfterReopen() throws {
|
||||||
|
app.launchAndOpenSampleBook(resetsReaderState: true)
|
||||||
|
app.waitForReader(timeout: 12)
|
||||||
|
|
||||||
|
app.showReaderChromeIfNeeded()
|
||||||
|
app.buttons[IDs.readerSettings].tap()
|
||||||
|
|
||||||
|
let fontChoiceControl = app.segmentedControls[IDs.settingsFontChoice]
|
||||||
|
XCTAssertTrue(fontChoiceControl.waitForExistence(timeout: 5))
|
||||||
|
if fontChoiceControl.buttons.count > 1 {
|
||||||
|
fontChoiceControl.buttons.element(boundBy: 1).tap()
|
||||||
|
}
|
||||||
|
XCTAssertEqual(fontChoiceControl.value as? String, "宋体", "切换字体后当前字体值应更新")
|
||||||
|
app.buttons[IDs.settingsDone].tap()
|
||||||
|
|
||||||
|
app.showReaderChromeIfNeeded()
|
||||||
|
app.buttons[IDs.readerBack].tap()
|
||||||
|
|
||||||
|
app.launchAndOpenSampleBook(resetsReaderState: false)
|
||||||
|
app.waitForReader(timeout: 12)
|
||||||
|
app.showReaderChromeIfNeeded()
|
||||||
|
app.buttons[IDs.readerSettings].tap()
|
||||||
|
|
||||||
|
let reopenedFontChoiceControl = app.segmentedControls[IDs.settingsFontChoice]
|
||||||
|
XCTAssertTrue(reopenedFontChoiceControl.waitForExistence(timeout: 5))
|
||||||
|
XCTAssertEqual(reopenedFontChoiceControl.value as? String, "宋体", "字体选择应在重启后保持")
|
||||||
|
app.buttons[IDs.settingsDone].tap()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -55,7 +55,7 @@ final class SettingsExtendedTests: XCTestCase {
|
|||||||
|
|
||||||
displayType.buttons["横滑"].tap()
|
displayType.buttons["横滑"].tap()
|
||||||
app.buttons[IDs.settingsDone].tap()
|
app.buttons[IDs.settingsDone].tap()
|
||||||
app.waitForReaderState(containing: "display=horizontalScroll", timeout: 5)
|
app.waitForDemoReaderState(timeout: 5, description: "display=horizontalScroll") { $0.display == "horizontalScroll" }
|
||||||
}
|
}
|
||||||
|
|
||||||
func testAllThemeButtonsExist() {
|
func testAllThemeButtonsExist() {
|
||||||
|
|||||||
@ -47,7 +47,7 @@ final class TOCInteractionTests: XCTestCase {
|
|||||||
XCTAssertNotEqual(updatedPage, initialPage, "目录跳转后页码应变化")
|
XCTAssertNotEqual(updatedPage, initialPage, "目录跳转后页码应变化")
|
||||||
} else {
|
} else {
|
||||||
tocTable.cells.element(boundBy: 0).tap()
|
tocTable.cells.element(boundBy: 0).tap()
|
||||||
app.waitForReaderState(containing: "reader=opened", timeout: 8)
|
app.waitForDemoReaderState(timeout: 8, description: "reader=opened") { $0.isOpened }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,7 @@ final class TableOfContentsTests: XCTestCase {
|
|||||||
func testTocButtonExists() {
|
func testTocButtonExists() {
|
||||||
app.launchAndOpenSampleBook(bookTitleQuery: "宝山辽墓材料与释读")
|
app.launchAndOpenSampleBook(bookTitleQuery: "宝山辽墓材料与释读")
|
||||||
app.waitForReader()
|
app.waitForReader()
|
||||||
app.showReaderChromeIfNeeded()
|
app.showReaderChromeIfNeeded(requiredButtonIDs: [IDs.readerToc])
|
||||||
|
|
||||||
let tocButton = app.buttons[IDs.readerToc]
|
let tocButton = app.buttons[IDs.readerToc]
|
||||||
XCTAssertTrue(tocButton.waitForExistence(timeout: 5), "目录按钮未出现")
|
XCTAssertTrue(tocButton.waitForExistence(timeout: 5), "目录按钮未出现")
|
||||||
@ -19,7 +19,7 @@ final class TableOfContentsTests: XCTestCase {
|
|||||||
func testTocButtonTappable() {
|
func testTocButtonTappable() {
|
||||||
app.launchAndOpenSampleBook(bookTitleQuery: "宝山辽墓材料与释读")
|
app.launchAndOpenSampleBook(bookTitleQuery: "宝山辽墓材料与释读")
|
||||||
app.waitForReader()
|
app.waitForReader()
|
||||||
app.showReaderChromeIfNeeded()
|
app.showReaderChromeIfNeeded(requiredButtonIDs: [IDs.readerToc])
|
||||||
|
|
||||||
let tocButton = app.buttons[IDs.readerToc]
|
let tocButton = app.buttons[IDs.readerToc]
|
||||||
XCTAssertTrue(tocButton.waitForExistence(timeout: 5))
|
XCTAssertTrue(tocButton.waitForExistence(timeout: 5))
|
||||||
|
|||||||
@ -35,7 +35,7 @@ final class ToolbarStateTests: XCTestCase {
|
|||||||
app.showReaderChromeIfNeeded()
|
app.showReaderChromeIfNeeded()
|
||||||
XCTAssertTrue(app.buttons[IDs.readerBack].waitForExistence(timeout: 3))
|
XCTAssertTrue(app.buttons[IDs.readerBack].waitForExistence(timeout: 3))
|
||||||
|
|
||||||
app.waitForReaderState(containing: "reader=opened", timeout: 5)
|
app.waitForDemoReaderState(timeout: 5, description: "reader=opened") { $0.isOpened }
|
||||||
}
|
}
|
||||||
|
|
||||||
func testAddHighlightButtonDisabledWithoutSelection() throws {
|
func testAddHighlightButtonDisabledWithoutSelection() throws {
|
||||||
@ -66,6 +66,6 @@ final class ToolbarStateTests: XCTestCase {
|
|||||||
let paging = app.collectionViews[IDs.readerPaging]
|
let paging = app.collectionViews[IDs.readerPaging]
|
||||||
if paging.waitForExistence(timeout: 5) { paging.swipeLeft() }
|
if paging.waitForExistence(timeout: 5) { paging.swipeLeft() }
|
||||||
|
|
||||||
app.waitForReaderState(containing: "reader=opened", timeout: 5)
|
app.waitForDemoReaderState(timeout: 5, description: "reader=opened") { $0.isOpened }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -72,7 +72,11 @@ enum RDEPUBJavaScriptBridge {
|
|||||||
window.WeReadApi.clearHighlights();
|
window.WeReadApi.clearHighlights();
|
||||||
window.WeReadApi.setHighlights(\(jsonString(from: highlightsPayload(request.highlights), fallback: "[]")));
|
window.WeReadApi.setHighlights(\(jsonString(from: highlightsPayload(request.highlights), fallback: "[]")));
|
||||||
if (\(jsonString(from: targetLocationPayload(request.targetLocation), fallback: "null")) !== null) {
|
if (\(jsonString(from: targetLocationPayload(request.targetLocation), fallback: "null")) !== null) {
|
||||||
window.WeReadApi.scrollToLocation(\(jsonString(from: targetLocationPayload(request.targetLocation), fallback: "null")), \(request.pageIndex));
|
window.WeReadApi.scrollToLocation(
|
||||||
|
\(jsonString(from: targetLocationPayload(request.targetLocation), fallback: "null")),
|
||||||
|
\(request.pageIndex),
|
||||||
|
\(javaScriptStringLiteral(request.targetHighlightRangeInfo))
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
window.WeReadApi.scrollToPage(\(request.pageIndex));
|
window.WeReadApi.scrollToPage(\(request.pageIndex));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -212,6 +212,8 @@ public enum RDEPUBParserError: LocalizedError {
|
|||||||
case missingManifestItem(idref: String)
|
case missingManifestItem(idref: String)
|
||||||
/// 构建完成的 spine 为空(无可阅读的内容)
|
/// 构建完成的 spine 为空(无可阅读的内容)
|
||||||
case emptySpine
|
case emptySpine
|
||||||
|
/// 归档条目路径包含路径穿越或非法字符
|
||||||
|
case invalidArchiveEntryPath(String)
|
||||||
|
|
||||||
public var errorDescription: String? {
|
public var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
@ -229,6 +231,8 @@ public enum RDEPUBParserError: LocalizedError {
|
|||||||
return "spine itemref 找不到对应 manifest 项: \(idref)"
|
return "spine itemref 找不到对应 manifest 项: \(idref)"
|
||||||
case .emptySpine:
|
case .emptySpine:
|
||||||
return "OPF spine 为空"
|
return "OPF spine 为空"
|
||||||
|
case .invalidArchiveEntryPath(let path):
|
||||||
|
return "归档条目路径非法: \(path)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -53,9 +53,7 @@ public final class RDEPUBPaginator: NSObject {
|
|||||||
webView.scrollView.showsVerticalScrollIndicator = false
|
webView.scrollView.showsVerticalScrollIndicator = false
|
||||||
webView.isUserInteractionEnabled = false
|
webView.isUserInteractionEnabled = false
|
||||||
if #available(iOS 16.4, *) {
|
if #available(iOS 16.4, *) {
|
||||||
webView.isInspectable = true
|
webView.isInspectable = RDEPUBWebViewDebug.isInspectableEnabled
|
||||||
} else {
|
|
||||||
// Fallback on earlier versions
|
|
||||||
}
|
}
|
||||||
RDEPUBWebViewDebug.log("PaginatorWebView", message: "configured webView=\(RDEPUBWebViewDebug.webViewID(webView))")
|
RDEPUBWebViewDebug.log("PaginatorWebView", message: "configured webView=\(RDEPUBWebViewDebug.webViewID(webView))")
|
||||||
return webView
|
return webView
|
||||||
@ -297,6 +295,9 @@ public final class RDEPUBPaginator: NSObject {
|
|||||||
return mediaType.contains("html") || mediaType.contains("xhtml") || mediaType.contains("xml")
|
return mediaType.contains("html") || mediaType.contains("xhtml") || mediaType.contains("xml")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 各轮测量记录值(用于稳定性日志)
|
||||||
|
private var measurementPassValues: [Int] = []
|
||||||
|
|
||||||
/// 调度多轮测量:0ms/80ms/180ms 三轮延迟,取最大值以应对布局抖动
|
/// 调度多轮测量:0ms/80ms/180ms 三轮延迟,取最大值以应对布局抖动
|
||||||
private func scheduleMeasurementPass() {
|
private func scheduleMeasurementPass() {
|
||||||
let sessionID = activeSessionID
|
let sessionID = activeSessionID
|
||||||
@ -306,8 +307,18 @@ public final class RDEPUBPaginator: NSObject {
|
|||||||
let currentSpineIndex = currentSpineIndexForMeasurement() else {
|
let currentSpineIndex = currentSpineIndexForMeasurement() else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
pageCounts[currentSpineIndex] = max(1, pendingMeasurementValue)
|
let finalValue = max(1, pendingMeasurementValue)
|
||||||
|
// 测量稳定性日志:三轮差异过大时告警
|
||||||
|
if measurementPassValues.count >= 2 {
|
||||||
|
let minVal = measurementPassValues.min() ?? 1
|
||||||
|
let maxVal = measurementPassValues.max() ?? 1
|
||||||
|
if minVal > 0 && Double(maxVal - minVal) / Double(minVal) > 0.2 {
|
||||||
|
RDEPUBWebViewDebug.log(debugScope, message: "measurement instability: passes=\(measurementPassValues) spine=\(currentSpineIndex)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pageCounts[currentSpineIndex] = finalValue
|
||||||
currentMeasurementOffset += 1
|
currentMeasurementOffset += 1
|
||||||
|
measurementPassValues = []
|
||||||
measureNextSpineItem()
|
measureNextSpineItem()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -333,8 +344,10 @@ public final class RDEPUBPaginator: NSObject {
|
|||||||
}
|
}
|
||||||
if let number = value as? NSNumber {
|
if let number = value as? NSNumber {
|
||||||
self.pendingMeasurementValue = max(self.pendingMeasurementValue, number.intValue)
|
self.pendingMeasurementValue = max(self.pendingMeasurementValue, number.intValue)
|
||||||
|
self.measurementPassValues.append(number.intValue)
|
||||||
} else if let intValue = value as? Int {
|
} else if let intValue = value as? Int {
|
||||||
self.pendingMeasurementValue = max(self.pendingMeasurementValue, intValue)
|
self.pendingMeasurementValue = max(self.pendingMeasurementValue, intValue)
|
||||||
|
self.measurementPassValues.append(intValue)
|
||||||
}
|
}
|
||||||
RDEPUBWebViewDebug.log(self.debugScope, message: "measurement value=\(self.pendingMeasurementValue) session=\(sessionID)")
|
RDEPUBWebViewDebug.log(self.debugScope, message: "measurement value=\(self.pendingMeasurementValue) session=\(sessionID)")
|
||||||
self.scheduleMeasurementPass()
|
self.scheduleMeasurementPass()
|
||||||
@ -344,6 +357,20 @@ public final class RDEPUBPaginator: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension RDEPUBPaginator: WKNavigationDelegate {
|
extension RDEPUBPaginator: WKNavigationDelegate {
|
||||||
|
public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||||
|
guard let url = navigationAction.request.url else {
|
||||||
|
decisionHandler(.cancel)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let scheme = url.scheme?.lowercased() ?? ""
|
||||||
|
if scheme == "file" || scheme == RDEPUBResourceURLSchemeHandler.scheme {
|
||||||
|
decisionHandler(.allow)
|
||||||
|
} else {
|
||||||
|
RDEPUBWebViewDebug.log(debugScope, message: "blocked navigation to non-local scheme: \(scheme)")
|
||||||
|
decisionHandler(.cancel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
public func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
||||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didStart", url: webView.url)
|
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didStart", url: webView.url)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -49,7 +49,9 @@ extension RDEPUBParser {
|
|||||||
try fileManager.createDirectory(at: extractionURL, withIntermediateDirectories: true)
|
try fileManager.createDirectory(at: extractionURL, withIntermediateDirectories: true)
|
||||||
|
|
||||||
for entry in archive {
|
for entry in archive {
|
||||||
let destinationURL = extractionURL.appendingPathComponent(entry.path)
|
guard let destinationURL = validatedExtractionDestination(for: entry.path, extractionRoot: extractionURL) else {
|
||||||
|
throw RDEPUBParserError.invalidArchiveEntryPath(entry.path)
|
||||||
|
}
|
||||||
switch entry.type {
|
switch entry.type {
|
||||||
case .directory:
|
case .directory:
|
||||||
try fileManager.createDirectory(at: destinationURL, withIntermediateDirectories: true)
|
try fileManager.createDirectory(at: destinationURL, withIntermediateDirectories: true)
|
||||||
@ -64,6 +66,31 @@ extension RDEPUBParser {
|
|||||||
return extractionURL
|
return extractionURL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 校验归档条目路径,防止路径穿越攻击
|
||||||
|
/// - Parameters:
|
||||||
|
/// - entryPath: 归档中的原始条目路径
|
||||||
|
/// - extractionRoot: 解压根目录
|
||||||
|
/// - Returns: 校验通过的目标 URL,非法路径返回 nil
|
||||||
|
private func validatedExtractionDestination(for entryPath: String, extractionRoot: URL) -> URL? {
|
||||||
|
// 拒绝绝对路径
|
||||||
|
if entryPath.hasPrefix("/") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// 拒绝包含 .. 的路径段
|
||||||
|
let components = entryPath.split(separator: "/", omittingEmptySubsequences: true)
|
||||||
|
if components.contains(where: { $0 == ".." }) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let destinationURL = extractionRoot.appendingPathComponent(entryPath)
|
||||||
|
let standardizedDest = destinationURL.standardizedFileURL.path
|
||||||
|
let standardizedRoot = extractionRoot.standardizedFileURL.path
|
||||||
|
// 确保最终路径位于解压根目录下
|
||||||
|
guard standardizedDest.hasPrefix(standardizedRoot) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return destinationURL
|
||||||
|
}
|
||||||
|
|
||||||
/// 计算 EPUB 的临时解压目录路径
|
/// 计算 EPUB 的临时解压目录路径
|
||||||
/// 路径格式:~/Library/Caches/ssreaderview-epub/{slug}-{fileSize}-{modifiedTimestamp}/
|
/// 路径格式:~/Library/Caches/ssreaderview-epub/{slug}-{fileSize}-{modifiedTimestamp}/
|
||||||
func temporaryExtractionDirectory(for epubURL: URL) -> URL {
|
func temporaryExtractionDirectory(for epubURL: URL) -> URL {
|
||||||
|
|||||||
@ -76,6 +76,7 @@ public struct RDEPUBPreferences: Equatable {
|
|||||||
publication: RDEPUBPublication,
|
publication: RDEPUBPublication,
|
||||||
viewportSize: CGSize,
|
viewportSize: CGSize,
|
||||||
targetLocation: RDEPUBLocation? = nil,
|
targetLocation: RDEPUBLocation? = nil,
|
||||||
|
targetHighlightRangeInfo: String? = nil,
|
||||||
highlights: [RDEPUBHighlight] = [],
|
highlights: [RDEPUBHighlight] = [],
|
||||||
searchPresentation: RDEPUBSearchPresentation? = nil
|
searchPresentation: RDEPUBSearchPresentation? = nil
|
||||||
) -> RDEPUBRenderRequest? {
|
) -> RDEPUBRenderRequest? {
|
||||||
@ -104,6 +105,7 @@ public struct RDEPUBPreferences: Equatable {
|
|||||||
totalPagesInChapter: page.totalPagesInChapter,
|
totalPagesInChapter: page.totalPagesInChapter,
|
||||||
presentation: presentationStyle(viewportSize: viewportSize),
|
presentation: presentationStyle(viewportSize: viewportSize),
|
||||||
targetLocation: targetLocation,
|
targetLocation: targetLocation,
|
||||||
|
targetHighlightRangeInfo: targetHighlightRangeInfo,
|
||||||
highlights: highlights,
|
highlights: highlights,
|
||||||
searchPresentation: searchPresentation
|
searchPresentation: searchPresentation
|
||||||
)
|
)
|
||||||
|
|||||||
@ -31,6 +31,8 @@ public final class RDEPUBReadingSession {
|
|||||||
public private(set) var pendingNavigationLocation: RDEPUBLocation?
|
public private(set) var pendingNavigationLocation: RDEPUBLocation?
|
||||||
/// 挂起的导航目标页码
|
/// 挂起的导航目标页码
|
||||||
public private(set) var pendingNavigationPageNum: Int?
|
public private(set) var pendingNavigationPageNum: Int?
|
||||||
|
/// 挂起的高亮 Range 信息(仅 Web 渲染路径使用)
|
||||||
|
public private(set) var pendingNavigationHighlightRangeInfo: String?
|
||||||
/// 当前视口信息
|
/// 当前视口信息
|
||||||
public private(set) var currentViewport: RDEPUBViewport?
|
public private(set) var currentViewport: RDEPUBViewport?
|
||||||
/// 当前阅读上下文(位置 + 视口 + 页码 + 章节)
|
/// 当前阅读上下文(位置 + 视口 + 页码 + 章节)
|
||||||
@ -105,6 +107,7 @@ public final class RDEPUBReadingSession {
|
|||||||
public func clearPendingNavigation() {
|
public func clearPendingNavigation() {
|
||||||
pendingNavigationLocation = nil
|
pendingNavigationLocation = nil
|
||||||
pendingNavigationPageNum = nil
|
pendingNavigationPageNum = nil
|
||||||
|
pendingNavigationHighlightRangeInfo = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 检查指定页码和 spine 索引是否有挂起的导航位置
|
/// 检查指定页码和 spine 索引是否有挂起的导航位置
|
||||||
@ -123,6 +126,26 @@ public final class RDEPUBReadingSession {
|
|||||||
return pendingNavigationLocation
|
return pendingNavigationLocation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 查询指定页是否存在待消费的高亮 Range 信息。
|
||||||
|
public func pendingHighlightRangeInfo(forPageNumber pageNumber: Int, spineIndex: Int?) -> String? {
|
||||||
|
guard pendingNavigationPageNum == pageNumber,
|
||||||
|
let pendingNavigationHighlightRangeInfo,
|
||||||
|
!pendingNavigationHighlightRangeInfo.isEmpty else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
guard let pendingNavigationLocation else {
|
||||||
|
return pendingNavigationHighlightRangeInfo
|
||||||
|
}
|
||||||
|
guard let spineIndex else {
|
||||||
|
return pendingNavigationHighlightRangeInfo
|
||||||
|
}
|
||||||
|
let pageHref = resourceResolver.href(forSpineIndex: spineIndex)
|
||||||
|
guard resourceResolver.normalizedHref(pageHref ?? "") == resourceResolver.normalizedHref(pendingNavigationLocation.href) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return pendingNavigationHighlightRangeInfo
|
||||||
|
}
|
||||||
|
|
||||||
/// 判断指定页面是否包含给定的 spine 索引(固定版式需检查 spread 内所有资源)
|
/// 判断指定页面是否包含给定的 spine 索引(固定版式需检查 spread 内所有资源)
|
||||||
public func pageContains(spineIndex: Int, in page: EPUBPage) -> Bool {
|
public func pageContains(spineIndex: Int, in page: EPUBPage) -> Bool {
|
||||||
if let fixedSpread = page.fixedSpread {
|
if let fixedSpread = page.fixedSpread {
|
||||||
@ -225,7 +248,8 @@ public final class RDEPUBReadingSession {
|
|||||||
public func queueNavigation(
|
public func queueNavigation(
|
||||||
to location: RDEPUBLocation,
|
to location: RDEPUBLocation,
|
||||||
relativeToSpineIndex spineIndex: Int? = nil,
|
relativeToSpineIndex spineIndex: Int? = nil,
|
||||||
bookIdentifier: String?
|
bookIdentifier: String?,
|
||||||
|
targetHighlightRangeInfo: String? = nil
|
||||||
) -> Int? {
|
) -> Int? {
|
||||||
guard let normalizedLocation = resourceResolver.normalizedLocation(
|
guard let normalizedLocation = resourceResolver.normalizedLocation(
|
||||||
location,
|
location,
|
||||||
@ -235,8 +259,14 @@ public final class RDEPUBReadingSession {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
pendingNavigationLocation = normalizedLocation.fragment == nil ? nil : normalizedLocation
|
let hasTargetHighlightRangeInfo = targetHighlightRangeInfo?.isEmpty == false
|
||||||
pendingNavigationPageNum = normalizedLocation.fragment == nil ? nil : pageIndex + 1
|
let shouldKeepPendingNavigation =
|
||||||
|
normalizedLocation.fragment != nil ||
|
||||||
|
normalizedLocation.rangeAnchor != nil ||
|
||||||
|
hasTargetHighlightRangeInfo
|
||||||
|
pendingNavigationLocation = shouldKeepPendingNavigation ? normalizedLocation : nil
|
||||||
|
pendingNavigationPageNum = shouldKeepPendingNavigation ? pageIndex + 1 : nil
|
||||||
|
pendingNavigationHighlightRangeInfo = hasTargetHighlightRangeInfo ? targetHighlightRangeInfo : nil
|
||||||
transition(to: .jumping)
|
transition(to: .jumping)
|
||||||
return pageIndex + 1
|
return pageIndex + 1
|
||||||
}
|
}
|
||||||
@ -363,4 +393,4 @@ public final class RDEPUBReadingSession {
|
|||||||
|
|
||||||
return (pages, chapters)
|
return (pages, chapters)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -85,6 +85,8 @@ public struct RDEPUBReflowableRenderRequest: Equatable {
|
|||||||
public var presentation: RDEPUBPresentationStyle
|
public var presentation: RDEPUBPresentationStyle
|
||||||
/// 目标跳转位置(用于恢复阅读位置或锚点跳转)
|
/// 目标跳转位置(用于恢复阅读位置或锚点跳转)
|
||||||
public var targetLocation: RDEPUBLocation?
|
public var targetLocation: RDEPUBLocation?
|
||||||
|
/// 目标高亮的 DOM Range 信息,用于 Web 渲染路径下的精确跳转
|
||||||
|
public var targetHighlightRangeInfo: String?
|
||||||
/// 高亮列表
|
/// 高亮列表
|
||||||
public var highlights: [RDEPUBHighlight]
|
public var highlights: [RDEPUBHighlight]
|
||||||
/// 搜索结果展示信息
|
/// 搜索结果展示信息
|
||||||
@ -97,6 +99,7 @@ public struct RDEPUBReflowableRenderRequest: Equatable {
|
|||||||
totalPagesInChapter: Int,
|
totalPagesInChapter: Int,
|
||||||
presentation: RDEPUBPresentationStyle,
|
presentation: RDEPUBPresentationStyle,
|
||||||
targetLocation: RDEPUBLocation? = nil,
|
targetLocation: RDEPUBLocation? = nil,
|
||||||
|
targetHighlightRangeInfo: String? = nil,
|
||||||
highlights: [RDEPUBHighlight] = [],
|
highlights: [RDEPUBHighlight] = [],
|
||||||
searchPresentation: RDEPUBSearchPresentation? = nil
|
searchPresentation: RDEPUBSearchPresentation? = nil
|
||||||
) {
|
) {
|
||||||
@ -106,6 +109,7 @@ public struct RDEPUBReflowableRenderRequest: Equatable {
|
|||||||
self.totalPagesInChapter = totalPagesInChapter
|
self.totalPagesInChapter = totalPagesInChapter
|
||||||
self.presentation = presentation
|
self.presentation = presentation
|
||||||
self.targetLocation = targetLocation
|
self.targetLocation = targetLocation
|
||||||
|
self.targetHighlightRangeInfo = targetHighlightRangeInfo
|
||||||
self.highlights = highlights
|
self.highlights = highlights
|
||||||
self.searchPresentation = searchPresentation
|
self.searchPresentation = searchPresentation
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,12 @@ import WebKit
|
|||||||
|
|
||||||
/// 自定义 URL 协议处理器,将 ss-reader://book/ 请求映射到本地 EPUB 资源文件
|
/// 自定义 URL 协议处理器,将 ss-reader://book/ 请求映射到本地 EPUB 资源文件
|
||||||
public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler {
|
public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||||
|
struct DebugMetrics {
|
||||||
|
let streamedResponses: Int
|
||||||
|
let inMemoryResponses: Int
|
||||||
|
let failures: Int
|
||||||
|
}
|
||||||
|
|
||||||
/// 自定义协议名
|
/// 自定义协议名
|
||||||
public static let scheme = "ss-reader"
|
public static let scheme = "ss-reader"
|
||||||
/// 自定义主机名
|
/// 自定义主机名
|
||||||
@ -21,12 +27,34 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
|
|||||||
private let syncQueue = DispatchQueue(label: "com.ssreaderview.epub.scheme-handler")
|
private let syncQueue = DispatchQueue(label: "com.ssreaderview.epub.scheme-handler")
|
||||||
/// 活跃的 URL Scheme 任务集合(用于任务取消检测)
|
/// 活跃的 URL Scheme 任务集合(用于任务取消检测)
|
||||||
private var activeTasks: [ObjectIdentifier: Bool] = [:]
|
private var activeTasks: [ObjectIdentifier: Bool] = [:]
|
||||||
|
private static let debugMetricsQueue = DispatchQueue(label: "com.ssreaderview.epub.scheme-handler.metrics")
|
||||||
|
private static var streamedResponseCount = 0
|
||||||
|
private static var inMemoryResponseCount = 0
|
||||||
|
private static var failureCount = 0
|
||||||
|
|
||||||
public init(parser: RDEPUBParser) {
|
public init(parser: RDEPUBParser) {
|
||||||
self.parser = parser
|
self.parser = parser
|
||||||
super.init()
|
super.init()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func resetDebugMetrics() {
|
||||||
|
debugMetricsQueue.sync {
|
||||||
|
streamedResponseCount = 0
|
||||||
|
inMemoryResponseCount = 0
|
||||||
|
failureCount = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func debugMetricsSnapshot() -> DebugMetrics {
|
||||||
|
debugMetricsQueue.sync {
|
||||||
|
DebugMetrics(
|
||||||
|
streamedResponses: streamedResponseCount,
|
||||||
|
inMemoryResponses: inMemoryResponseCount,
|
||||||
|
failures: failureCount
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 处理 URL Scheme 请求:解析文件路径、读取数据、返回响应
|
/// 处理 URL Scheme 请求:解析文件路径、读取数据、返回响应
|
||||||
/// 对缺失的可选资源(字体/图片/CSS/JS)返回空响应,其他返回 404 错误
|
/// 对缺失的可选资源(字体/图片/CSS/JS)返回空响应,其他返回 404 错误
|
||||||
public func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) {
|
public func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) {
|
||||||
@ -55,6 +83,7 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
|
|||||||
urlSchemeTask.didFinish()
|
urlSchemeTask.didFinish()
|
||||||
} else {
|
} else {
|
||||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, event: "missing-file")
|
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, event: "missing-file")
|
||||||
|
Self.recordFailure()
|
||||||
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorFileDoesNotExist))
|
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorFileDoesNotExist))
|
||||||
}
|
}
|
||||||
clearTask(taskID)
|
clearTask(taskID)
|
||||||
@ -63,27 +92,12 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
|
|||||||
|
|
||||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "resolved")
|
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "resolved")
|
||||||
|
|
||||||
do {
|
let fileSize = resourceSize(for: fileURL)
|
||||||
let data = try Data(contentsOf: fileURL)
|
if let fileSize, fileSize > 524_288 {
|
||||||
guard isTaskActive(taskID) else { return }
|
respondWithStreaming(fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask, fileSize: fileSize)
|
||||||
|
} else {
|
||||||
let response = URLResponse(
|
respondWithInMemoryData(fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask)
|
||||||
url: requestURL,
|
|
||||||
mimeType: Self.mimeType(for: fileURL.pathExtension),
|
|
||||||
expectedContentLength: data.count,
|
|
||||||
textEncodingName: Self.textEncodingName(for: fileURL.pathExtension)
|
|
||||||
)
|
|
||||||
urlSchemeTask.didReceive(response)
|
|
||||||
urlSchemeTask.didReceive(data)
|
|
||||||
urlSchemeTask.didFinish()
|
|
||||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "finished")
|
|
||||||
} catch {
|
|
||||||
guard isTaskActive(taskID) else { return }
|
|
||||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "failed", error: error)
|
|
||||||
urlSchemeTask.didFailWithError(error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
clearTask(taskID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 取消 URL Scheme 任务
|
/// 取消 URL Scheme 任务
|
||||||
@ -106,6 +120,89 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func resourceSize(for url: URL) -> UInt64? {
|
||||||
|
guard let attrs = try? fileManager.attributesOfItem(atPath: url.path),
|
||||||
|
let size = attrs[.size] as? UInt64 else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return size
|
||||||
|
}
|
||||||
|
|
||||||
|
private func respondWithInMemoryData(fileURL: URL, requestURL: URL, taskID: ObjectIdentifier, urlSchemeTask: any WKURLSchemeTask) {
|
||||||
|
do {
|
||||||
|
let data = try Data(contentsOf: fileURL)
|
||||||
|
guard isTaskActive(taskID) else { return }
|
||||||
|
let response = URLResponse(
|
||||||
|
url: requestURL,
|
||||||
|
mimeType: Self.mimeType(for: fileURL.pathExtension),
|
||||||
|
expectedContentLength: data.count,
|
||||||
|
textEncodingName: Self.textEncodingName(for: fileURL.pathExtension)
|
||||||
|
)
|
||||||
|
urlSchemeTask.didReceive(response)
|
||||||
|
urlSchemeTask.didReceive(data)
|
||||||
|
urlSchemeTask.didFinish()
|
||||||
|
Self.recordInMemoryResponse()
|
||||||
|
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "finished")
|
||||||
|
} catch {
|
||||||
|
guard isTaskActive(taskID) else { return }
|
||||||
|
Self.recordFailure()
|
||||||
|
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "failed", error: error)
|
||||||
|
urlSchemeTask.didFailWithError(error)
|
||||||
|
}
|
||||||
|
clearTask(taskID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func respondWithStreaming(fileURL: URL, requestURL: URL, taskID: ObjectIdentifier, urlSchemeTask: any WKURLSchemeTask, fileSize: UInt64) {
|
||||||
|
let response = URLResponse(
|
||||||
|
url: requestURL,
|
||||||
|
mimeType: Self.mimeType(for: fileURL.pathExtension),
|
||||||
|
expectedContentLength: Int(fileSize),
|
||||||
|
textEncodingName: Self.textEncodingName(for: fileURL.pathExtension)
|
||||||
|
)
|
||||||
|
urlSchemeTask.didReceive(response)
|
||||||
|
|
||||||
|
guard let fileHandle = try? FileHandle(forReadingFrom: fileURL) else {
|
||||||
|
guard isTaskActive(taskID) else { return }
|
||||||
|
Self.recordFailure()
|
||||||
|
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorFileDoesNotExist))
|
||||||
|
clearTask(taskID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let chunkSize = 65_536
|
||||||
|
defer {
|
||||||
|
fileHandle.closeFile()
|
||||||
|
clearTask(taskID)
|
||||||
|
}
|
||||||
|
while true {
|
||||||
|
guard isTaskActive(taskID) else { return }
|
||||||
|
let data = fileHandle.readData(ofLength: chunkSize)
|
||||||
|
if data.isEmpty { break }
|
||||||
|
urlSchemeTask.didReceive(data)
|
||||||
|
}
|
||||||
|
urlSchemeTask.didFinish()
|
||||||
|
Self.recordStreamedResponse()
|
||||||
|
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "finished-streaming")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func recordStreamedResponse() {
|
||||||
|
debugMetricsQueue.sync {
|
||||||
|
streamedResponseCount += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func recordInMemoryResponse() {
|
||||||
|
debugMetricsQueue.sync {
|
||||||
|
inMemoryResponseCount += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func recordFailure() {
|
||||||
|
debugMetricsQueue.sync {
|
||||||
|
failureCount += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 根据文件扩展名推断 MIME 类型
|
/// 根据文件扩展名推断 MIME 类型
|
||||||
private static func mimeType(for pathExtension: String) -> String {
|
private static func mimeType(for pathExtension: String) -> String {
|
||||||
switch pathExtension.lowercased() {
|
switch pathExtension.lowercased() {
|
||||||
@ -165,4 +262,4 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -60,7 +60,7 @@ extension RDEPUBWebView {
|
|||||||
webView.backgroundColor = .clear
|
webView.backgroundColor = .clear
|
||||||
webView.clipsToBounds = true
|
webView.clipsToBounds = true
|
||||||
if #available(iOS 16.4, *) {
|
if #available(iOS 16.4, *) {
|
||||||
webView.isInspectable = true
|
webView.isInspectable = RDEPUBWebViewDebug.isInspectableEnabled
|
||||||
}
|
}
|
||||||
|
|
||||||
addSubview(webView)
|
addSubview(webView)
|
||||||
|
|||||||
@ -97,7 +97,8 @@ extension RDEPUBWebView {
|
|||||||
request.targetLocation?.href ?? "",
|
request.targetLocation?.href ?? "",
|
||||||
String(request.targetLocation?.progression ?? 0),
|
String(request.targetLocation?.progression ?? 0),
|
||||||
String(request.targetLocation?.lastProgression ?? 0),
|
String(request.targetLocation?.lastProgression ?? 0),
|
||||||
request.targetLocation?.fragment ?? ""
|
request.targetLocation?.fragment ?? "",
|
||||||
|
request.targetHighlightRangeInfo ?? ""
|
||||||
].joined(separator: "|")
|
].joined(separator: "|")
|
||||||
let highlightSignature = request.highlights
|
let highlightSignature = request.highlights
|
||||||
.map { [$0.id, $0.rangeInfo ?? "", $0.color, $0.style.rawValue].joined(separator: "|") }
|
.map { [$0.id, $0.rangeInfo ?? "", $0.color, $0.style.rawValue].joined(separator: "|") }
|
||||||
|
|||||||
@ -6,6 +6,14 @@ import UIKit
|
|||||||
import WebKit
|
import WebKit
|
||||||
|
|
||||||
extension RDEPUBWebView {
|
extension RDEPUBWebView {
|
||||||
|
private var normalSearchOverlayColor: UIColor {
|
||||||
|
UIColor(red: 0.21, green: 0.48, blue: 0.95, alpha: 0.16)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var activeSearchOverlayColor: UIColor {
|
||||||
|
UIColor(red: 0.14, green: 0.42, blue: 0.95, alpha: 0.34)
|
||||||
|
}
|
||||||
|
|
||||||
/// 如果当前渲染请求包含搜索信息,注入搜索高亮脚本到 WebView
|
/// 如果当前渲染请求包含搜索信息,注入搜索高亮脚本到 WebView
|
||||||
func applySearchDecorationsIfNeeded(completion: (() -> Void)? = nil) {
|
func applySearchDecorationsIfNeeded(completion: (() -> Void)? = nil) {
|
||||||
guard let webView else {
|
guard let webView else {
|
||||||
@ -70,7 +78,7 @@ extension RDEPUBWebView {
|
|||||||
)
|
)
|
||||||
let searchDecorations = decorationList(
|
let searchDecorations = decorationList(
|
||||||
from: payload["search"] as? [[String: Any]],
|
from: payload["search"] as? [[String: Any]],
|
||||||
defaultColor: UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.55)
|
defaultColor: normalSearchOverlayColor
|
||||||
)
|
)
|
||||||
return highlightDecorations + searchDecorations
|
return highlightDecorations + searchDecorations
|
||||||
}
|
}
|
||||||
@ -105,9 +113,9 @@ extension RDEPUBWebView {
|
|||||||
let hex = item["color"] as? String ?? "#F8E16C"
|
let hex = item["color"] as? String ?? "#F8E16C"
|
||||||
color = overlayColor(hex: hex, alpha: 1) ?? defaultColor
|
color = overlayColor(hex: hex, alpha: 1) ?? defaultColor
|
||||||
case .activeSearch:
|
case .activeSearch:
|
||||||
color = UIColor(red: 255 / 255, green: 159 / 255, blue: 67 / 255, alpha: 0.75)
|
color = activeSearchOverlayColor
|
||||||
case .search:
|
case .search:
|
||||||
color = UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.55)
|
color = normalSearchOverlayColor
|
||||||
case .highlight:
|
case .highlight:
|
||||||
let hex = item["color"] as? String ?? "#F8E16C"
|
let hex = item["color"] as? String ?? "#F8E16C"
|
||||||
color = overlayColor(hex: hex, alpha: 0.45) ?? defaultColor
|
color = overlayColor(hex: hex, alpha: 0.45) ?? defaultColor
|
||||||
|
|||||||
@ -21,6 +21,30 @@ enum RDEPUBWebViewDebug {
|
|||||||
#endif
|
#endif
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
/// 详细日志模式是否启用(默认关闭,可通过 UserDefaults "RDEPUBWebViewVerboseEnabled" 开启)
|
||||||
|
/// 开启后 logMessage 将输出完整消息体,关闭时仅输出消息名、字段名和文本长度
|
||||||
|
static var isVerboseEnabled: Bool = {
|
||||||
|
if let configured = UserDefaults.standard.object(forKey: "RDEPUBWebViewVerboseEnabled") as? Bool {
|
||||||
|
return configured
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}()
|
||||||
|
|
||||||
|
/// 是否允许开启 inspectable。
|
||||||
|
/// 默认关闭,仅在阅读器配置显式允许或 UserDefaults 覆盖时开启。
|
||||||
|
static var isInspectableEnabled: Bool = {
|
||||||
|
if let configured = UserDefaults.standard.object(forKey: "RDEPUBInspectableWebViewsEnabled") as? Bool {
|
||||||
|
return configured
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}()
|
||||||
|
|
||||||
|
/// 将阅读器配置同步到调试策略,保证默认安全策略由配置显式控制。
|
||||||
|
static func applyDebugPolicy(inspectableEnabled: Bool, verboseLoggingEnabled: Bool) {
|
||||||
|
isInspectableEnabled = inspectableEnabled
|
||||||
|
isVerboseEnabled = verboseLoggingEnabled
|
||||||
|
}
|
||||||
|
|
||||||
/// 获取 WebView 的十六进制标识符(用于日志区分多个 WebView 实例)
|
/// 获取 WebView 的十六进制标识符(用于日志区分多个 WebView 实例)
|
||||||
static func webViewID(_ webView: WKWebView?) -> String {
|
static func webViewID(_ webView: WKWebView?) -> String {
|
||||||
guard let webView else { return "nil-webview" }
|
guard let webView else { return "nil-webview" }
|
||||||
@ -51,10 +75,26 @@ enum RDEPUBWebViewDebug {
|
|||||||
log(scope, message: "webView=\(webViewID(webView)) js=\(action) \(details)")
|
log(scope, message: "webView=\(webViewID(webView)) js=\(action) \(details)")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 输出 JS 消息接收日志(含消息名称和消息体)
|
/// 输出 JS 消息接收日志(默认仅输出字段名和文本长度,verbose 模式输出完整消息体)
|
||||||
static func logMessage(_ scope: String, webView: WKWebView?, name: String, body: Any) {
|
static func logMessage(_ scope: String, webView: WKWebView?, name: String, body: Any) {
|
||||||
guard isEnabled else { return }
|
guard isEnabled else { return }
|
||||||
log(scope, message: "webView=\(webViewID(webView)) message=\(name) body=\(String(describing: body))")
|
if isVerboseEnabled {
|
||||||
|
log(scope, message: "webView=\(webViewID(webView)) message=\(name) body=\(String(describing: body))")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let dict = body as? [String: Any] {
|
||||||
|
let keys = dict.keys.sorted().joined(separator: ",")
|
||||||
|
var meta = "keys=[\(keys)]"
|
||||||
|
if let text = dict["text"] as? String {
|
||||||
|
meta += " textLen=\(text.count)"
|
||||||
|
}
|
||||||
|
if let urlString = dict["url"] as? String ?? dict["href"] as? String {
|
||||||
|
meta += " url=\(summarizedURL(URL(string: urlString)))"
|
||||||
|
}
|
||||||
|
log(scope, message: "webView=\(webViewID(webView)) message=\(name) \(meta)")
|
||||||
|
} else {
|
||||||
|
log(scope, message: "webView=\(webViewID(webView)) message=\(name) type=\(Swift.type(of: body))")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 输出 URL Scheme 任务日志(含请求 URL、文件 URL、事件类型、错误信息)
|
/// 输出 URL Scheme 任务日志(含请求 URL、文件 URL、事件类型、错误信息)
|
||||||
@ -81,4 +121,4 @@ enum RDEPUBWebViewDebug {
|
|||||||
}
|
}
|
||||||
return url.lastPathComponent.isEmpty ? path : url.lastPathComponent
|
return url.lastPathComponent.isEmpty ? path : url.lastPathComponent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
(function() {
|
(function() {
|
||||||
if (window.RDReaderBridge) { return; }
|
if (window.RDReaderBridge) { return; }
|
||||||
window.addEventListener('error', function(event) {
|
window.addEventListener('error', function(event) {
|
||||||
try {
|
try {
|
||||||
@ -470,7 +470,18 @@
|
|||||||
setVisiblePageIndex(pageIndex);
|
setVisiblePageIndex(pageIndex);
|
||||||
reportProgression(null);
|
reportProgression(null);
|
||||||
},
|
},
|
||||||
scrollToLocation: function(location, fallbackPageIndex) {
|
scrollToLocation: function(location, fallbackPageIndex, targetRangeInfo) {
|
||||||
|
if (targetRangeInfo) {
|
||||||
|
var highlightRange = rangeFromInfo(targetRangeInfo);
|
||||||
|
var highlightRects = rectPayloadForRange(highlightRange, 0, 0);
|
||||||
|
if (highlightRects.length) {
|
||||||
|
var highlightRect = highlightRects[0];
|
||||||
|
var highlightAbsoluteLeft = Math.max(0, highlightRect.x + currentPageOffset());
|
||||||
|
setVisiblePageIndex(Math.max(0, Math.floor(highlightAbsoluteLeft / effectivePageStride())));
|
||||||
|
reportProgression(location && location.fragment ? location.fragment : null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (location && location.fragment) {
|
if (location && location.fragment) {
|
||||||
var resolvedTarget = fragmentTarget(location.fragment);
|
var resolvedTarget = fragmentTarget(location.fragment);
|
||||||
if (resolvedTarget) {
|
if (resolvedTarget) {
|
||||||
|
|||||||
@ -20,7 +20,9 @@ struct RDEPUBChapterTailNormalizer {
|
|||||||
previous.diagnostics.append(note)
|
previous.diagnostics.append(note)
|
||||||
compacted.append(previous)
|
compacted.append(previous)
|
||||||
} else {
|
} else {
|
||||||
|
#if DEBUG
|
||||||
print("[EPUB][Pagination] href=\(href) dropped leading/intermediate whitespace frame \(NSStringFromRange(frame.contentRange))")
|
print("[EPUB][Pagination] href=\(href) dropped leading/intermediate whitespace frame \(NSStringFromRange(frame.contentRange))")
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@ -36,7 +38,9 @@ struct RDEPUBChapterTailNormalizer {
|
|||||||
previousFrame.diagnostics.append(note)
|
previousFrame.diagnostics.append(note)
|
||||||
normalized.append(previousFrame)
|
normalized.append(previousFrame)
|
||||||
} else {
|
} else {
|
||||||
|
#if DEBUG
|
||||||
print("[EPUB][Pagination] href=\(href) dropped trailing frame \(NSStringFromRange(lastFrame.contentRange))")
|
print("[EPUB][Pagination] href=\(href) dropped trailing frame \(NSStringFromRange(lastFrame.contentRange))")
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -137,7 +137,9 @@ public final class RDEPUBTextBookBuilder {
|
|||||||
// 保存分页缓存(只缓存页范围和分页原因,不缓存富文本)
|
// 保存分页缓存(只缓存页范围和分页原因,不缓存富文本)
|
||||||
cacheCoordinator.save(chapters: chapters, key: cacheKey)
|
cacheCoordinator.save(chapters: chapters, key: cacheKey)
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
print(sampler.summary())
|
print(sampler.summary())
|
||||||
|
#endif
|
||||||
lastBuildPerformanceSamples = sampler.samples
|
lastBuildPerformanceSamples = sampler.samples
|
||||||
|
|
||||||
return book
|
return book
|
||||||
@ -207,11 +209,15 @@ public final class RDEPUBTextBookBuilder {
|
|||||||
|
|
||||||
let plainText = rendered.attributedString.string.trimmingCharacters(in: .whitespacesAndNewlines)
|
let plainText = rendered.attributedString.string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if item.href.lowercased().contains("cover") {
|
if item.href.lowercased().contains("cover") {
|
||||||
|
#if DEBUG
|
||||||
print("[EPUB][Cover] rendered href=\(item.href) textLength=\(plainText.count) attrLength=\(rendered.attributedString.length) attachments=\(attachmentCount(in: rendered.attributedString))")
|
print("[EPUB][Cover] rendered href=\(item.href) textLength=\(plainText.count) attrLength=\(rendered.attributedString.length) attachments=\(attachmentCount(in: rendered.attributedString))")
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
if shouldSkipChapter(item: item, content: rendered.attributedString, text: plainText) {
|
if shouldSkipChapter(item: item, content: rendered.attributedString, text: plainText) {
|
||||||
if item.href.lowercased().contains("cover") {
|
if item.href.lowercased().contains("cover") {
|
||||||
|
#if DEBUG
|
||||||
print("[EPUB][Cover] skipped href=\(item.href)")
|
print("[EPUB][Cover] skipped href=\(item.href)")
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -301,7 +307,9 @@ public final class RDEPUBTextBookBuilder {
|
|||||||
: normalizedFrames
|
: normalizedFrames
|
||||||
|
|
||||||
if item.href.lowercased().contains("cover") {
|
if item.href.lowercased().contains("cover") {
|
||||||
|
#if DEBUG
|
||||||
print("[EPUB][Cover] paginated href=\(item.href) pages=\(effectiveFrames.count) firstRange=\(effectiveFrames.first.map { NSStringFromRange($0.contentRange) } ?? "none")")
|
print("[EPUB][Cover] paginated href=\(item.href) pages=\(effectiveFrames.count) firstRange=\(effectiveFrames.first.map { NSStringFromRange($0.contentRange) } ?? "none")")
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
let chapterAttributedContent = content.copy() as! NSAttributedString
|
let chapterAttributedContent = content.copy() as! NSAttributedString
|
||||||
|
|||||||
@ -170,7 +170,9 @@ public final class RDEPUBTextBookCache {
|
|||||||
queue.sync {
|
queue.sync {
|
||||||
let fileURL = cacheDirectory.appendingPathComponent(key)
|
let fileURL = cacheDirectory.appendingPathComponent(key)
|
||||||
guard FileManager.default.fileExists(atPath: fileURL.path) else {
|
guard FileManager.default.fileExists(atPath: fileURL.path) else {
|
||||||
|
#if DEBUG
|
||||||
print("[Cache] load MISS key=\(key)")
|
print("[Cache] load MISS key=\(key)")
|
||||||
|
#endif
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
@ -179,17 +181,23 @@ public final class RDEPUBTextBookCache {
|
|||||||
ofClass: PaginationCacheArchive.self,
|
ofClass: PaginationCacheArchive.self,
|
||||||
from: data
|
from: data
|
||||||
) else {
|
) else {
|
||||||
|
#if DEBUG
|
||||||
print("[Cache] load MISS key=\(key) (unarchive returned nil)")
|
print("[Cache] load MISS key=\(key) (unarchive returned nil)")
|
||||||
|
#endif
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var result: [String: RDEPUBTextChapterPaginationCache] = [:]
|
var result: [String: RDEPUBTextChapterPaginationCache] = [:]
|
||||||
for chapter in archive.chapters {
|
for chapter in archive.chapters {
|
||||||
result[chapter.href] = chapter.toCache()
|
result[chapter.href] = chapter.toCache()
|
||||||
}
|
}
|
||||||
|
#if DEBUG
|
||||||
print("[Cache] load HIT key=\(key) chapters=\(result.count)")
|
print("[Cache] load HIT key=\(key) chapters=\(result.count)")
|
||||||
|
#endif
|
||||||
return result
|
return result
|
||||||
} catch {
|
} catch {
|
||||||
|
#if DEBUG
|
||||||
print("[Cache] load MISS key=\(key) error=\(error)")
|
print("[Cache] load MISS key=\(key) error=\(error)")
|
||||||
|
#endif
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -204,9 +212,13 @@ public final class RDEPUBTextBookCache {
|
|||||||
let bookArchive = PaginationCacheArchive(chapters: archives)
|
let bookArchive = PaginationCacheArchive(chapters: archives)
|
||||||
let data = try NSKeyedArchiver.archivedData(withRootObject: bookArchive, requiringSecureCoding: true)
|
let data = try NSKeyedArchiver.archivedData(withRootObject: bookArchive, requiringSecureCoding: true)
|
||||||
try data.write(to: fileURL, options: .atomic)
|
try data.write(to: fileURL, options: .atomic)
|
||||||
|
#if DEBUG
|
||||||
print("[Cache] save key=\(key) chapters=\(chapters.count)")
|
print("[Cache] save key=\(key) chapters=\(chapters.count)")
|
||||||
|
#endif
|
||||||
} catch {
|
} catch {
|
||||||
|
#if DEBUG
|
||||||
print("[Cache] save FAILED key=\(key) error=\(error)")
|
print("[Cache] save FAILED key=\(key) error=\(error)")
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -223,7 +235,9 @@ public final class RDEPUBTextBookCache {
|
|||||||
for file in contents {
|
for file in contents {
|
||||||
try? fileManager.removeItem(at: file)
|
try? fileManager.removeItem(at: file)
|
||||||
}
|
}
|
||||||
|
#if DEBUG
|
||||||
print("[Cache] invalidateAll")
|
print("[Cache] invalidateAll")
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -51,7 +51,9 @@ public final class RDEPUBTextPerformanceSampler {
|
|||||||
/// 记录单个章节的性能采样,并输出日志
|
/// 记录单个章节的性能采样,并输出日志
|
||||||
public func record(_ sample: RDEPUBTextPerformanceSample) {
|
public func record(_ sample: RDEPUBTextPerformanceSample) {
|
||||||
samples.append(sample)
|
samples.append(sample)
|
||||||
|
#if DEBUG
|
||||||
print("[PERF] \(sample.chapterHref): render=\(formatMS(sample.renderDuration)) paginate=\(formatMS(sample.paginateDuration)) pages=\(sample.pageCount) cache=\(sample.cacheHit ? "HIT" : "MISS")")
|
print("[PERF] \(sample.chapterHref): render=\(formatMS(sample.renderDuration)) paginate=\(formatMS(sample.paginateDuration)) pages=\(sample.pageCount) cache=\(sample.cacheHit ? "HIT" : "MISS")")
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 生成性能汇总报告,包含总渲染/分页耗时和缓存命中率
|
/// 生成性能汇总报告,包含总渲染/分页耗时和缓存命中率
|
||||||
|
|||||||
@ -166,80 +166,6 @@ struct RDEPUBPageBreakPolicy {
|
|||||||
|
|
||||||
// MARK: - 语义边界查找
|
// MARK: - 语义边界查找
|
||||||
|
|
||||||
/// 在指定范围内查找最优的语义分页点(pageBreakBefore / pageBreakAfter)。
|
|
||||||
func preferredSemanticBoundary(
|
|
||||||
in range: NSRange,
|
|
||||||
minimumEnd: Int,
|
|
||||||
factory: RDEPUBCoreTextPageFrameFactory
|
|
||||||
) -> (location: Int, trigger: String)? {
|
|
||||||
guard let safeRange = factory.clampedRange(range), safeRange.length > 0 else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var boundary: (location: Int, trigger: String)?
|
|
||||||
attributedString.enumerateAttribute(.rdPageSemanticHints, in: safeRange) { value, attributeRange, stop in
|
|
||||||
guard let rawValue = value as? String else { return }
|
|
||||||
let hints = rawValue
|
|
||||||
.split(separator: ",")
|
|
||||||
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
|
|
||||||
guard !hints.isEmpty else { return }
|
|
||||||
|
|
||||||
if hints.contains(.pageBreakBefore),
|
|
||||||
attributeRange.location > safeRange.location,
|
|
||||||
attributeRange.location >= minimumEnd {
|
|
||||||
boundary = (attributeRange.location, RDEPUBTextSemanticHint.pageBreakBefore.rawValue)
|
|
||||||
stop.pointee = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let attributeEnd = attributeRange.location + attributeRange.length
|
|
||||||
if hints.contains(.pageBreakAfter),
|
|
||||||
attributeEnd > minimumEnd,
|
|
||||||
attributeEnd < safeRange.location + safeRange.length {
|
|
||||||
boundary = (attributeEnd, RDEPUBTextSemanticHint.pageBreakAfter.rawValue)
|
|
||||||
stop.pointee = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return boundary
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 查找附件边界:只有块级附件才触发分页。
|
|
||||||
func preferredAttachmentBoundary(
|
|
||||||
in range: NSRange,
|
|
||||||
minimumEnd: Int,
|
|
||||||
factory: RDEPUBCoreTextPageFrameFactory
|
|
||||||
) -> Int? {
|
|
||||||
guard let safeRange = factory.clampedRange(range), safeRange.length > 0 else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var boundary: Int?
|
|
||||||
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: safeRange) { value, attributeRange, stop in
|
|
||||||
guard value != nil else { return }
|
|
||||||
|
|
||||||
let location = attributeRange.location
|
|
||||||
let placement = factory.attachmentPlacement(at: location)
|
|
||||||
let blockKind = factory.blockKind(at: location)
|
|
||||||
|
|
||||||
let isBlockLevelAttachment: Bool
|
|
||||||
switch placement {
|
|
||||||
case .centered:
|
|
||||||
isBlockLevelAttachment = true
|
|
||||||
case .inline, .baseline:
|
|
||||||
isBlockLevelAttachment = false
|
|
||||||
case nil:
|
|
||||||
isBlockLevelAttachment = blockKind == .attachment
|
|
||||||
}
|
|
||||||
guard isBlockLevelAttachment else { return }
|
|
||||||
|
|
||||||
let boundaryRange = factory.blockRange(at: location) ?? factory.paragraphRange(containing: location)
|
|
||||||
if boundaryRange.location > safeRange.location, boundaryRange.location >= minimumEnd {
|
|
||||||
boundary = boundaryRange.location
|
|
||||||
stop.pointee = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return boundary
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 查找 pageRelate 跨页关联边界。
|
/// 查找 pageRelate 跨页关联边界。
|
||||||
func preferredPageRelateBoundary(
|
func preferredPageRelateBoundary(
|
||||||
after range: NSRange,
|
after range: NSRange,
|
||||||
|
|||||||
@ -174,12 +174,12 @@ public final class RDEPUBChapterData {
|
|||||||
|
|
||||||
/// 从搜索结果还原绝对字符范围
|
/// 从搜索结果还原绝对字符范围
|
||||||
public func absoluteRange(for searchMatch: RDEPUBSearchMatch) -> NSRange? {
|
public func absoluteRange(for searchMatch: RDEPUBSearchMatch) -> NSRange? {
|
||||||
|
if let location = searchMatch.rangeLocation {
|
||||||
|
return NSRange(location: location, length: max(searchMatch.rangeLength, 1))
|
||||||
|
}
|
||||||
if let rangeAnchor = searchMatch.rangeAnchor {
|
if let rangeAnchor = searchMatch.rangeAnchor {
|
||||||
return indexTable.chapterRange(for: rangeAnchor)
|
return indexTable.chapterRange(for: rangeAnchor)
|
||||||
}
|
}
|
||||||
if let location = searchMatch.rangeLocation {
|
|
||||||
return NSRange(location: location, length: searchMatch.rangeLength)
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -187,11 +187,7 @@ public struct RDEPUBTextIndexTable {
|
|||||||
|
|
||||||
/// 将锚点转换为章节内字符偏移量。
|
/// 将锚点转换为章节内字符偏移量。
|
||||||
public func chapterOffset(for anchor: RDEPUBTextAnchor) -> Int {
|
public func chapterOffset(for anchor: RDEPUBTextAnchor) -> Int {
|
||||||
chapterOffset(
|
anchor.chapterOffset
|
||||||
fileIndex: anchor.fileIndex,
|
|
||||||
row: anchor.row,
|
|
||||||
column: anchor.column
|
|
||||||
) ?? anchor.chapterOffset
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 将锚点转换为全书绝对字符索引。
|
/// 将锚点转换为全书绝对字符索引。
|
||||||
|
|||||||
@ -46,7 +46,9 @@ struct RDEPUBAttachmentNormalizer {
|
|||||||
|
|
||||||
if !didLogFootnoteAttachment {
|
if !didLogFootnoteAttachment {
|
||||||
didLogFootnoteAttachment = true
|
didLogFootnoteAttachment = true
|
||||||
|
#if DEBUG
|
||||||
print("[EPUB][Attachment] footnote original=\(RDEPUBHTMLNormalizer.string(from: originalSize)) display=\(RDEPUBHTMLNormalizer.string(from: attachment.displaySize)) font=\(pointSize)")
|
print("[EPUB][Attachment] footnote original=\(RDEPUBHTMLNormalizer.string(from: originalSize)) display=\(RDEPUBHTMLNormalizer.string(from: attachment.displaySize)) font=\(pointSize)")
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -66,7 +68,9 @@ struct RDEPUBAttachmentNormalizer {
|
|||||||
|
|
||||||
if !didLogCoverAttachment {
|
if !didLogCoverAttachment {
|
||||||
didLogCoverAttachment = true
|
didLogCoverAttachment = true
|
||||||
|
#if DEBUG
|
||||||
print("[EPUB][Attachment] cover original=\(RDEPUBHTMLNormalizer.string(from: originalSize)) display=\(RDEPUBHTMLNormalizer.string(from: attachment.displaySize))")
|
print("[EPUB][Attachment] cover original=\(RDEPUBHTMLNormalizer.string(from: originalSize)) display=\(RDEPUBHTMLNormalizer.string(from: attachment.displaySize))")
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@ -62,15 +62,17 @@ extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
|
|||||||
readerView.transitionToPage(pageNum: max(pageNumber - 1, 0), animated: true)
|
readerView.transitionToPage(pageNum: max(pageNumber - 1, 0), animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Web 内容视图外部链接点击回调,使用系统浏览器打开
|
/// Web 内容视图外部链接点击回调,根据配置策略决定是否打开
|
||||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateExternalLink url: URL) {
|
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateExternalLink url: URL) {
|
||||||
delegate?.epubReader(self, didActivateExternalLink: url)
|
delegate?.epubReader(self, didActivateExternalLink: url)
|
||||||
UIApplication.shared.open(url, options: [:], completionHandler: nil)
|
openExternalURLIfAllowed(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Web 内容视图 JavaScript 错误日志回调
|
/// Web 内容视图 JavaScript 错误日志回调
|
||||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didLogJavaScriptError message: String) {
|
func epubWebContentView(_ contentView: RDEPUBWebContentView, didLogJavaScriptError message: String) {
|
||||||
|
#if DEBUG
|
||||||
print("EPUB JS Error: \(message)")
|
print("EPUB JS Error: \(message)")
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -96,6 +98,14 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
|||||||
contentView.clearSelection()
|
contentView.clearSelection()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func textContentView(
|
||||||
|
_ contentView: RDEPUBTextContentView,
|
||||||
|
didActivateAttachmentText text: String,
|
||||||
|
sourceRect: CGRect
|
||||||
|
) {
|
||||||
|
presentAttachmentTooltip(text: text, sourceView: contentView, sourceRect: sourceRect)
|
||||||
|
}
|
||||||
|
|
||||||
func textContentView(
|
func textContentView(
|
||||||
_ contentView: RDEPUBTextContentView,
|
_ contentView: RDEPUBTextContentView,
|
||||||
didRequestHighlightActions highlight: RDEPUBHighlight,
|
didRequestHighlightActions highlight: RDEPUBHighlight,
|
||||||
@ -296,4 +306,242 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
|||||||
}
|
}
|
||||||
return bestID
|
return bestID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - 外部链接策略
|
||||||
|
|
||||||
|
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 presentAttachmentTooltip(text: String, sourceView: UIView, sourceRect: CGRect) {
|
||||||
|
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
|
||||||
|
tooltip.configure(text: text, maxWidth: min(view.bounds.width - 48, 320))
|
||||||
|
|
||||||
|
let anchorRect = sourceView.convert(sourceRect, to: view)
|
||||||
|
let horizontalPadding: CGFloat = 24
|
||||||
|
let verticalSpacing: CGFloat = 6
|
||||||
|
let tooltipSize = tooltip.frame.size
|
||||||
|
let idealX = anchorRect.midX - tooltipSize.width / 2
|
||||||
|
let minX = horizontalPadding
|
||||||
|
let maxX = max(minX, view.bounds.width - horizontalPadding - tooltipSize.width)
|
||||||
|
let originX = min(max(idealX, minX), maxX)
|
||||||
|
let originY = max(view.safeAreaInsets.top + 12, anchorRect.minY - tooltipSize.height - verticalSpacing)
|
||||||
|
let arrowTipX = min(
|
||||||
|
max(anchorRect.midX - originX, tooltip.minimumArrowX),
|
||||||
|
tooltipSize.width - tooltip.minimumArrowX
|
||||||
|
)
|
||||||
|
|
||||||
|
tooltip.setArrowTipX(arrowTipX)
|
||||||
|
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 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 {
|
||||||
|
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 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 labelFrame = bounds.inset(by: UIEdgeInsets(
|
||||||
|
top: contentInsets.top,
|
||||||
|
left: contentInsets.left,
|
||||||
|
bottom: contentInsets.bottom + arrowSize.height,
|
||||||
|
right: contentInsets.right
|
||||||
|
))
|
||||||
|
textLabel.frame = labelFrame
|
||||||
|
}
|
||||||
|
|
||||||
|
func setArrowTipX(_ value: CGFloat) {
|
||||||
|
arrowTipX = value
|
||||||
|
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(
|
||||||
|
x: rect.minX,
|
||||||
|
y: rect.minY,
|
||||||
|
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()
|
||||||
|
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
|
||||||
|
)
|
||||||
|
path.close()
|
||||||
|
return path
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -97,16 +97,28 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
|||||||
private func searchState(for page: RDEPUBTextPage) -> RDEPUBSearchState? {
|
private func searchState(for page: RDEPUBTextPage) -> RDEPUBSearchState? {
|
||||||
guard let globalSearchState = searchState else { return nil }
|
guard let globalSearchState = searchState else { return nil }
|
||||||
|
|
||||||
let matches = globalSearchState.matches.filter { searchMatch in
|
let matches: [RDEPUBSearchMatch]
|
||||||
searchMatchBelongsToPage(searchMatch, page: page)
|
let currentMatchIndex: Int?
|
||||||
|
|
||||||
|
if let chapterData = chapterData(for: page),
|
||||||
|
let resolvedState = resolvedSearchState(
|
||||||
|
for: page,
|
||||||
|
chapterData: chapterData,
|
||||||
|
globalSearchState: globalSearchState
|
||||||
|
) {
|
||||||
|
matches = resolvedState.matches
|
||||||
|
currentMatchIndex = resolvedState.currentMatchIndex
|
||||||
|
} else {
|
||||||
|
matches = globalSearchState.matches.filter { searchMatch in
|
||||||
|
searchMatchBelongsToPage(searchMatch, page: page)
|
||||||
|
}
|
||||||
|
currentMatchIndex = globalSearchState.currentMatch.flatMap { currentMatch in
|
||||||
|
matches.firstIndex(of: currentMatch)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
guard !matches.isEmpty || globalSearchState.currentMatch != nil else {
|
guard !matches.isEmpty || globalSearchState.currentMatch != nil else {
|
||||||
return globalSearchState.matches.isEmpty ? globalSearchState : nil
|
return globalSearchState.matches.isEmpty ? globalSearchState : nil
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentMatchIndex = globalSearchState.currentMatch.flatMap { currentMatch in
|
|
||||||
matches.firstIndex(of: currentMatch)
|
|
||||||
}
|
|
||||||
return RDEPUBSearchState(
|
return RDEPUBSearchState(
|
||||||
keyword: globalSearchState.keyword,
|
keyword: globalSearchState.keyword,
|
||||||
matches: matches,
|
matches: matches,
|
||||||
@ -114,6 +126,126 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func resolvedSearchState(
|
||||||
|
for page: RDEPUBTextPage,
|
||||||
|
chapterData: RDEPUBChapterData,
|
||||||
|
globalSearchState: RDEPUBSearchState
|
||||||
|
) -> RDEPUBSearchState? {
|
||||||
|
let normalizedKeyword = globalSearchState.keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !normalizedKeyword.isEmpty else {
|
||||||
|
return globalSearchState.matches.isEmpty ? globalSearchState : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalizedHref = normalizedPageHref(for: page)
|
||||||
|
let exactMatches = exactChapterSearchMatches(
|
||||||
|
in: chapterData,
|
||||||
|
keyword: normalizedKeyword,
|
||||||
|
normalizedHref: normalizedHref
|
||||||
|
)
|
||||||
|
let pageMatches = exactMatches.filter { match in
|
||||||
|
guard let rangeLocation = match.rangeLocation else { return false }
|
||||||
|
let range = NSRange(location: rangeLocation, length: max(match.rangeLength, 1))
|
||||||
|
return NSIntersectionRange(range, page.contentRange).length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentLocalMatchIndex = globalSearchState.currentMatch?.localMatchIndex
|
||||||
|
let currentMatchIndex = currentLocalMatchIndex.flatMap { localMatchIndex in
|
||||||
|
pageMatches.firstIndex(where: { $0.localMatchIndex == localMatchIndex })
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !pageMatches.isEmpty || currentMatchIndex != nil else {
|
||||||
|
return globalSearchState.matches.isEmpty ? globalSearchState : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return RDEPUBSearchState(
|
||||||
|
keyword: globalSearchState.keyword,
|
||||||
|
matches: pageMatches,
|
||||||
|
currentMatchIndex: currentMatchIndex
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func exactChapterSearchMatches(
|
||||||
|
in chapterData: RDEPUBChapterData,
|
||||||
|
keyword: String,
|
||||||
|
normalizedHref: String
|
||||||
|
) -> [RDEPUBSearchMatch] {
|
||||||
|
let source = chapterData.attributedContent.string as NSString
|
||||||
|
let fullLength = source.length
|
||||||
|
guard fullLength > 0 else { return [] }
|
||||||
|
|
||||||
|
var matches: [RDEPUBSearchMatch] = []
|
||||||
|
var localMatchIndex = 0
|
||||||
|
var searchRange = NSRange(location: 0, length: fullLength)
|
||||||
|
|
||||||
|
while searchRange.length > 0 {
|
||||||
|
let foundRange = source.range(of: keyword, options: [.caseInsensitive], range: searchRange)
|
||||||
|
guard foundRange.location != NSNotFound else { break }
|
||||||
|
|
||||||
|
let progressionDenominator = max(fullLength - 1, 1)
|
||||||
|
let progression = Double(foundRange.location) / Double(progressionDenominator)
|
||||||
|
matches.append(
|
||||||
|
RDEPUBSearchMatch(
|
||||||
|
href: normalizedHref,
|
||||||
|
progression: progression,
|
||||||
|
previewText: previewText(in: source, matchRange: foundRange),
|
||||||
|
localMatchIndex: localMatchIndex,
|
||||||
|
rangeLocation: foundRange.location,
|
||||||
|
rangeLength: max(foundRange.length, 1),
|
||||||
|
rangeAnchor: chapterData.rangeAnchor(for: foundRange)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
localMatchIndex += 1
|
||||||
|
let nextLocation = foundRange.location + max(foundRange.length, 1)
|
||||||
|
if nextLocation >= fullLength {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation)
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches
|
||||||
|
}
|
||||||
|
|
||||||
|
private func previewText(in text: NSString, matchRange: NSRange) -> String {
|
||||||
|
let previewRadius = 12
|
||||||
|
let start = max(matchRange.location - previewRadius, 0)
|
||||||
|
let end = min(matchRange.location + matchRange.length + previewRadius, text.length)
|
||||||
|
let range = NSRange(location: start, length: max(end - start, 0))
|
||||||
|
return text.substring(with: range).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func chapterData(for page: RDEPUBTextPage) -> RDEPUBChapterData? {
|
||||||
|
if let textBook,
|
||||||
|
let chapterData = textBook.chapterData(for: page.href) {
|
||||||
|
return chapterData
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let runtimeChapter = runtime.chapterRuntimeStore.chapterData(for: page.spineIndex) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return makeChapterData(from: runtimeChapter, chapterIndex: page.chapterIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeChapterData(
|
||||||
|
from runtimeChapter: RDEPUBRuntimeChapter,
|
||||||
|
chapterIndex: Int
|
||||||
|
) -> RDEPUBChapterData {
|
||||||
|
let textChapter = RDEPUBTextChapter(
|
||||||
|
chapterIndex: chapterIndex,
|
||||||
|
spineIndex: runtimeChapter.spineIndex,
|
||||||
|
href: runtimeChapter.href,
|
||||||
|
title: runtimeChapter.title,
|
||||||
|
attributedContent: runtimeChapter.typesetAttributedString,
|
||||||
|
fragmentOffsets: runtimeChapter.chapterOffsetMap.fragmentOffsets,
|
||||||
|
pageBreakReasons: runtimeChapter.pages.map(\.metadata.breakReason),
|
||||||
|
pages: runtimeChapter.pages
|
||||||
|
)
|
||||||
|
return RDEPUBChapterData(
|
||||||
|
chapter: textChapter,
|
||||||
|
indexTable: RDEPUBTextIndexTable(chapters: [textChapter])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private func searchMatchBelongsToPage(_ searchMatch: RDEPUBSearchMatch, page: RDEPUBTextPage) -> Bool {
|
private func searchMatchBelongsToPage(_ searchMatch: RDEPUBSearchMatch, page: RDEPUBTextPage) -> Bool {
|
||||||
if let textBook,
|
if let textBook,
|
||||||
let chapterData = textBook.chapterData(for: page.href),
|
let chapterData = textBook.chapterData(for: page.href),
|
||||||
|
|||||||
@ -60,11 +60,16 @@ extension RDEPUBReaderController {
|
|||||||
}
|
}
|
||||||
let page = activePages[pageIndex]
|
let page = activePages[pageIndex]
|
||||||
let pendingLocation = readingSession?.pendingLocation(forPageNumber: pageIndex + 1, spineIndex: page.spineIndex)
|
let pendingLocation = readingSession?.pendingLocation(forPageNumber: pageIndex + 1, spineIndex: page.spineIndex)
|
||||||
|
let pendingHighlightRangeInfo = readingSession?.pendingHighlightRangeInfo(
|
||||||
|
forPageNumber: pageIndex + 1,
|
||||||
|
spineIndex: page.spineIndex
|
||||||
|
)
|
||||||
return currentPreferences().renderRequest(
|
return currentPreferences().renderRequest(
|
||||||
for: page,
|
for: page,
|
||||||
publication: publication,
|
publication: publication,
|
||||||
viewportSize: currentLayoutContext().viewportSize,
|
viewportSize: currentLayoutContext().viewportSize,
|
||||||
targetLocation: pendingLocation,
|
targetLocation: pendingLocation,
|
||||||
|
targetHighlightRangeInfo: pendingHighlightRangeInfo,
|
||||||
highlights: highlights(for: page),
|
highlights: highlights(for: page),
|
||||||
searchPresentation: searchPresentation(for: page)
|
searchPresentation: searchPresentation(for: page)
|
||||||
)
|
)
|
||||||
@ -97,4 +102,3 @@ extension RDEPUBReaderController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -91,8 +91,16 @@ extension RDEPUBReaderController {
|
|||||||
|
|
||||||
/// 恢复到指定阅读位置,返回是否成功
|
/// 恢复到指定阅读位置,返回是否成功
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func restoreReadingLocation(_ location: RDEPUBLocation, animated: Bool = false) -> Bool {
|
func restoreReadingLocation(
|
||||||
runtime.restoreReadingLocation(location, animated: animated)
|
_ location: RDEPUBLocation,
|
||||||
|
animated: Bool = false,
|
||||||
|
targetHighlightRangeInfo: String? = nil
|
||||||
|
) -> Bool {
|
||||||
|
runtime.restoreReadingLocation(
|
||||||
|
location,
|
||||||
|
animated: animated,
|
||||||
|
targetHighlightRangeInfo: targetHighlightRangeInfo
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取当前可见页面的位置
|
/// 获取当前可见页面的位置
|
||||||
|
|||||||
@ -30,6 +30,7 @@ public final class RDEPUBReaderController: UIViewController {
|
|||||||
public var configuration: RDEPUBReaderConfiguration {
|
public var configuration: RDEPUBReaderConfiguration {
|
||||||
didSet {
|
didSet {
|
||||||
readerContext.configuration = configuration
|
readerContext.configuration = configuration
|
||||||
|
applyWebViewDebugPolicy()
|
||||||
persistReaderSettingsIfNeeded()
|
persistReaderSettingsIfNeeded()
|
||||||
guard isViewLoaded else { return }
|
guard isViewLoaded else { return }
|
||||||
let oldConfiguration = oldValue
|
let oldConfiguration = oldValue
|
||||||
@ -230,12 +231,20 @@ public final class RDEPUBReaderController: UIViewController {
|
|||||||
readerContext.epubURL = epubURL
|
readerContext.epubURL = epubURL
|
||||||
readerContext.persistence = persistence
|
readerContext.persistence = persistence
|
||||||
self.currentBrightness = brightness
|
self.currentBrightness = brightness
|
||||||
|
applyWebViewDebugPolicy()
|
||||||
}
|
}
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
required init?(coder: NSCoder) {
|
||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func applyWebViewDebugPolicy() {
|
||||||
|
RDEPUBWebViewDebug.applyDebugPolicy(
|
||||||
|
inspectableEnabled: configuration.allowsInspectableWebViews,
|
||||||
|
verboseLoggingEnabled: configuration.enablesVerboseWebViewLogging
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// 使用已构建的 TextBook 初始化,跳过 EPUB 解析流程
|
/// 使用已构建的 TextBook 初始化,跳过 EPUB 解析流程
|
||||||
/// 适用于 TXT 等纯文本文件,调用方需自行构建 TextBook
|
/// 适用于 TXT 等纯文本文件,调用方需自行构建 TextBook
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
@ -318,9 +327,21 @@ public final class RDEPUBReaderController: UIViewController {
|
|||||||
searchBarView.apply(theme: configuration.theme)
|
searchBarView.apply(theme: configuration.theme)
|
||||||
|
|
||||||
searchBarView.onSearchSubmit = { [weak self] keyword in
|
searchBarView.onSearchSubmit = { [weak self] keyword in
|
||||||
|
self?.searchBarView.showSearching()
|
||||||
self?.runtime.search(keyword: keyword)
|
self?.runtime.search(keyword: keyword)
|
||||||
self?.updateSearchCount()
|
self?.updateSearchCount()
|
||||||
}
|
}
|
||||||
|
searchBarView.onSearchTextChanged = { [weak self] keyword in
|
||||||
|
guard let self else { return }
|
||||||
|
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if normalizedKeyword.isEmpty {
|
||||||
|
self.runtime.clearSearch()
|
||||||
|
} else {
|
||||||
|
self.searchBarView.showSearching()
|
||||||
|
self.runtime.search(keyword: normalizedKeyword)
|
||||||
|
}
|
||||||
|
self.updateSearchCount()
|
||||||
|
}
|
||||||
searchBarView.onSearchPrevious = { [weak self] in
|
searchBarView.onSearchPrevious = { [weak self] in
|
||||||
_ = self?.runtime.searchPrevious()
|
_ = self?.runtime.searchPrevious()
|
||||||
self?.updateSearchCount()
|
self?.updateSearchCount()
|
||||||
@ -329,6 +350,14 @@ public final class RDEPUBReaderController: UIViewController {
|
|||||||
_ = self?.runtime.searchNext()
|
_ = self?.runtime.searchNext()
|
||||||
self?.updateSearchCount()
|
self?.updateSearchCount()
|
||||||
}
|
}
|
||||||
|
searchBarView.onSelectMatch = { [weak self] matchIndex in
|
||||||
|
guard let self else { return }
|
||||||
|
let didNavigate = self.runtime.selectSearchMatch(at: matchIndex)
|
||||||
|
self.updateSearchCount()
|
||||||
|
if didNavigate {
|
||||||
|
self.hideSearchBar(clearSearch: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
searchBarView.onClose = { [weak self] in
|
searchBarView.onClose = { [weak self] in
|
||||||
self?.hideSearchBar(clearSearch: true)
|
self?.hideSearchBar(clearSearch: true)
|
||||||
}
|
}
|
||||||
@ -345,22 +374,28 @@ public final class RDEPUBReaderController: UIViewController {
|
|||||||
|
|
||||||
readerView.addSubview(searchBarView)
|
readerView.addSubview(searchBarView)
|
||||||
readerView.searchBarView = searchBarView
|
readerView.searchBarView = searchBarView
|
||||||
let topToolbarHeight: CGFloat = readerView.safeAreaInsets.top + 52
|
let bottomAnchor = bottomToolView.superview == nil
|
||||||
|
? readerView.bottomAnchor
|
||||||
|
: bottomToolView.topAnchor
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
searchBarView.leadingAnchor.constraint(equalTo: readerView.leadingAnchor),
|
searchBarView.leadingAnchor.constraint(equalTo: readerView.leadingAnchor),
|
||||||
searchBarView.trailingAnchor.constraint(equalTo: readerView.trailingAnchor),
|
searchBarView.trailingAnchor.constraint(equalTo: readerView.trailingAnchor),
|
||||||
searchBarView.topAnchor.constraint(equalTo: readerView.topAnchor, constant: topToolbarHeight),
|
searchBarView.topAnchor.constraint(equalTo: readerView.topAnchor),
|
||||||
searchBarView.heightAnchor.constraint(equalToConstant: 52)
|
searchBarView.bottomAnchor.constraint(equalTo: bottomAnchor)
|
||||||
])
|
])
|
||||||
|
|
||||||
searchBarView.transform = CGAffineTransform(translationX: 0, y: -52)
|
searchBarView.alpha = 0
|
||||||
UIView.animate(withDuration: 0.3) {
|
searchBarView.presentedView.transform = CGAffineTransform(translationX: 0, y: 40)
|
||||||
self.searchBarView.transform = .identity
|
UIView.animate(withDuration: 0.28) {
|
||||||
|
self.searchBarView.alpha = 1
|
||||||
|
self.searchBarView.presentedView.transform = .identity
|
||||||
}
|
}
|
||||||
|
|
||||||
if let keyword = searchState?.keyword, !keyword.isEmpty {
|
if let keyword = searchState?.keyword, !keyword.isEmpty {
|
||||||
searchBarView.restoreKeyword(keyword)
|
searchBarView.restoreKeyword(keyword)
|
||||||
updateSearchCount()
|
updateSearchCount()
|
||||||
|
} else {
|
||||||
|
searchBarView.showNoResults()
|
||||||
}
|
}
|
||||||
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
@ -375,11 +410,13 @@ public final class RDEPUBReaderController: UIViewController {
|
|||||||
isSearchBarVisible = false
|
isSearchBarVisible = false
|
||||||
|
|
||||||
searchBarView.textField.resignFirstResponder()
|
searchBarView.textField.resignFirstResponder()
|
||||||
UIView.animate(withDuration: 0.3, animations: {
|
UIView.animate(withDuration: 0.25, animations: {
|
||||||
self.searchBarView.transform = CGAffineTransform(translationX: 0, y: -52)
|
self.searchBarView.alpha = 0
|
||||||
|
self.searchBarView.presentedView.transform = CGAffineTransform(translationX: 0, y: 40)
|
||||||
}) { _ in
|
}) { _ in
|
||||||
self.searchBarView.removeFromSuperview()
|
self.searchBarView.removeFromSuperview()
|
||||||
self.searchBarView.transform = .identity
|
self.searchBarView.alpha = 1
|
||||||
|
self.searchBarView.presentedView.transform = .identity
|
||||||
self.readerView.searchBarView = nil
|
self.readerView.searchBarView = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -394,11 +431,11 @@ public final class RDEPUBReaderController: UIViewController {
|
|||||||
searchBarView.showNoResults()
|
searchBarView.showNoResults()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if let index = searchState.currentMatchIndex {
|
searchBarView.updateResults(
|
||||||
searchBarView.updateMatchCount(current: index + 1, total: searchState.matches.count)
|
sections: searchResultSections(for: searchState),
|
||||||
} else if searchState.matches.isEmpty {
|
keyword: searchState.keyword,
|
||||||
searchBarView.showNoResults()
|
currentMatchIndex: searchState.currentMatchIndex
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 当工具栏可见性变化时同步搜索栏(由 RDReaderView 回调调用)
|
/// 当工具栏可见性变化时同步搜索栏(由 RDReaderView 回调调用)
|
||||||
@ -417,3 +454,53 @@ public final class RDEPUBReaderController: UIViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extension RDEPUBReaderController {
|
||||||
|
func searchResultSections(for searchState: RDEPUBSearchState) -> [RDEPUBReaderSearchSection] {
|
||||||
|
let groupedMatches = Dictionary(grouping: Array(searchState.matches.enumerated()), by: { entry in
|
||||||
|
searchSectionTitle(for: entry.element)
|
||||||
|
})
|
||||||
|
|
||||||
|
let orderedTitles = searchState.matches.reduce(into: [String]()) { titles, match in
|
||||||
|
let title = searchSectionTitle(for: match)
|
||||||
|
if titles.last != title, titles.contains(title) == false {
|
||||||
|
titles.append(title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return orderedTitles.compactMap { title in
|
||||||
|
guard let matches = groupedMatches[title] else { return nil }
|
||||||
|
let items = matches.map { offset, match in
|
||||||
|
RDEPUBReaderSearchSection.Item(
|
||||||
|
matchIndex: offset,
|
||||||
|
previewText: match.previewText,
|
||||||
|
isCurrent: offset == searchState.currentMatchIndex
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return RDEPUBReaderSearchSection(title: title, items: items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func searchSectionTitle(for match: RDEPUBSearchMatch) -> String {
|
||||||
|
guard let publication else {
|
||||||
|
return match.href
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalizedMatchHref = publication.resourceResolver.normalizedHref(match.href) ?? match.href
|
||||||
|
let tocItems = flattenedTableOfContentsItems(from: publication.tableOfContents, includePageNumbers: false)
|
||||||
|
if let tocItem = tocItems.last(where: {
|
||||||
|
let rawHref = $0.href.components(separatedBy: "#").first ?? $0.href
|
||||||
|
let normalizedItemHref = publication.resourceResolver.normalizedHref(rawHref) ?? rawHref
|
||||||
|
return normalizedItemHref == normalizedMatchHref
|
||||||
|
}) {
|
||||||
|
return tocItem.title
|
||||||
|
}
|
||||||
|
|
||||||
|
if let spineIndex = publication.resourceResolver.spineIndex(forNormalizedHref: normalizedMatchHref),
|
||||||
|
publication.spine.indices.contains(spineIndex) {
|
||||||
|
return publication.spine[spineIndex].title
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedMatchHref
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -64,6 +64,13 @@ public protocol RDEPUBReaderDelegate: AnyObject {
|
|||||||
/// - url: 外部链接 URL
|
/// - url: 外部链接 URL
|
||||||
func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL)
|
func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL)
|
||||||
|
|
||||||
|
/// 外部链接打开前的拦截钩子,返回 false 可阻止打开
|
||||||
|
/// - Parameters:
|
||||||
|
/// - reader: 阅读器控制器
|
||||||
|
/// - url: 外部链接 URL
|
||||||
|
/// - Returns: 是否允许打开该链接
|
||||||
|
func epubReader(_ reader: UIViewController, shouldOpenExternalURL url: URL) -> Bool
|
||||||
|
|
||||||
/// 阅读器发生错误时调用
|
/// 阅读器发生错误时调用
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - reader: 阅读器控制器
|
/// - reader: 阅读器控制器
|
||||||
@ -92,6 +99,7 @@ public extension RDEPUBReaderDelegate {
|
|||||||
func epubReader(_ reader: UIViewController, didChangeCurrentSearchMatch match: RDEPUBSearchMatch?) {}
|
func epubReader(_ reader: UIViewController, didChangeCurrentSearchMatch match: RDEPUBSearchMatch?) {}
|
||||||
func epubReader(_ reader: UIViewController, didUpdateCurrentTableOfContentsItem item: RDEPUBReaderTableOfContentsItem?) {}
|
func epubReader(_ reader: UIViewController, didUpdateCurrentTableOfContentsItem item: RDEPUBReaderTableOfContentsItem?) {}
|
||||||
func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL) {}
|
func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL) {}
|
||||||
|
func epubReader(_ reader: UIViewController, shouldOpenExternalURL url: URL) -> Bool { true }
|
||||||
func epubReader(_ reader: UIViewController, didFailWithError error: Error) {}
|
func epubReader(_ reader: UIViewController, didFailWithError error: Error) {}
|
||||||
func epubReader(_ reader: UIViewController, configureTopToolView topToolView: RDEPUBReaderTopToolView) {}
|
func epubReader(_ reader: UIViewController, configureTopToolView topToolView: RDEPUBReaderTopToolView) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,23 +39,32 @@ public protocol RDEPUBReaderPersistence: AnyObject {
|
|||||||
// MARK: - 默认实现
|
// MARK: - 默认实现
|
||||||
|
|
||||||
/// 协议的默认空实现,书签/高亮/设置为可选功能
|
/// 协议的默认空实现,书签/高亮/设置为可选功能
|
||||||
|
/// DEBUG 模式下会对 no-op 行为输出警告,便于发现未对接持久化层的误用
|
||||||
public extension RDEPUBReaderPersistence {
|
public extension RDEPUBReaderPersistence {
|
||||||
func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark] {
|
func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark] {
|
||||||
_ = bookIdentifier
|
#if DEBUG
|
||||||
|
print("[RDEPUBReaderPersistence] ⚠️ loadBookmarks called on default no-op implementation for: \(bookIdentifier)")
|
||||||
|
#endif
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String) {
|
func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String) {
|
||||||
_ = bookmarks
|
#if DEBUG
|
||||||
_ = bookIdentifier
|
print("[RDEPUBReaderPersistence] ⚠️ saveBookmarks(\(bookmarks.count) items) called on default no-op implementation for: \(bookIdentifier)")
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadReaderSettings() -> RDEPUBReaderSettings? {
|
func loadReaderSettings() -> RDEPUBReaderSettings? {
|
||||||
nil
|
#if DEBUG
|
||||||
|
print("[RDEPUBReaderPersistence] ⚠️ loadReaderSettings called on default no-op implementation")
|
||||||
|
#endif
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveReaderSettings(_ settings: RDEPUBReaderSettings) {
|
func saveReaderSettings(_ settings: RDEPUBReaderSettings) {
|
||||||
_ = settings
|
#if DEBUG
|
||||||
|
print("[RDEPUBReaderPersistence] ⚠️ saveReaderSettings called on default no-op implementation")
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -136,6 +145,11 @@ public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence {
|
|||||||
guard let data = try? JSONEncoder().encode(highlights) else {
|
guard let data = try? JSONEncoder().encode(highlights) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if data.count > 1_048_576 {
|
||||||
|
#if DEBUG
|
||||||
|
print("[RDEPUBUserDefaultsPersistence] ⚠️ saveHighlights data size (\(data.count) bytes) exceeds 1MB for: \(bookIdentifier)")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
defaults.set(data, forKey: highlightsPrefix + bookIdentifier)
|
defaults.set(data, forKey: highlightsPrefix + bookIdentifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,47 +1,32 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
// MARK: - 搜索栏
|
struct RDEPUBReaderSearchSection: Equatable {
|
||||||
|
struct Item: Equatable {
|
||||||
|
let matchIndex: Int
|
||||||
|
let previewText: String
|
||||||
|
let isCurrent: Bool
|
||||||
|
}
|
||||||
|
|
||||||
/// 阅读器搜索栏视图
|
let title: String
|
||||||
/// 提供搜索输入、上一个/下一个匹配导航、匹配计数和关闭功能
|
let items: [Item]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 搜索面板
|
||||||
|
|
||||||
|
/// 阅读器搜索面板
|
||||||
|
/// 提供底部抽屉式搜索输入、分组结果列表和匹配跳转能力。
|
||||||
final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
||||||
// MARK: 回调闭包
|
|
||||||
|
|
||||||
/// 提交搜索关键词回调
|
|
||||||
var onSearchSubmit: ((String) -> Void)?
|
var onSearchSubmit: ((String) -> Void)?
|
||||||
/// 点击上一个匹配回调
|
var onSearchTextChanged: ((String) -> Void)?
|
||||||
var onSearchPrevious: (() -> Void)?
|
var onSearchPrevious: (() -> Void)?
|
||||||
/// 点击下一个匹配回调
|
|
||||||
var onSearchNext: (() -> Void)?
|
var onSearchNext: (() -> Void)?
|
||||||
/// 关闭搜索回调
|
var onSelectMatch: ((Int) -> Void)?
|
||||||
var onClose: (() -> Void)?
|
var onClose: (() -> Void)?
|
||||||
|
|
||||||
// MARK: UI 组件
|
|
||||||
|
|
||||||
private let containerView: UIView = {
|
|
||||||
let view = UIView()
|
|
||||||
view.layer.cornerRadius = 8
|
|
||||||
view.layer.masksToBounds = true
|
|
||||||
view.isAccessibilityElement = false
|
|
||||||
view.accessibilityElementsHidden = false
|
|
||||||
return view
|
|
||||||
}()
|
|
||||||
|
|
||||||
private let searchIcon: UIImageView = {
|
|
||||||
let imageView = UIImageView()
|
|
||||||
imageView.contentMode = .scaleAspectFit
|
|
||||||
imageView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 14, weight: .medium)
|
|
||||||
if #available(iOS 13.0, *) {
|
|
||||||
imageView.image = UIImage(systemName: "magnifyingglass")
|
|
||||||
}
|
|
||||||
imageView.tintColor = .gray
|
|
||||||
return imageView
|
|
||||||
}()
|
|
||||||
|
|
||||||
let textField: UITextField = {
|
let textField: UITextField = {
|
||||||
let field = UITextField()
|
let field = UITextField()
|
||||||
field.placeholder = "搜索..."
|
field.placeholder = "搜索"
|
||||||
field.font = UIFont.systemFont(ofSize: 15)
|
field.font = UIFont.systemFont(ofSize: 18, weight: .medium)
|
||||||
field.returnKeyType = .search
|
field.returnKeyType = .search
|
||||||
field.autocorrectionType = .no
|
field.autocorrectionType = .no
|
||||||
field.autocapitalizationType = .none
|
field.autocapitalizationType = .none
|
||||||
@ -53,25 +38,25 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
|||||||
return field
|
return field
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
private let backgroundButton = UIButton(type: .custom)
|
||||||
|
private let panelView = UIView()
|
||||||
|
private let grabberView = UIView()
|
||||||
|
private let searchRowView = UIView()
|
||||||
|
private let searchFieldContainer = UIView()
|
||||||
|
private let searchIcon = UIImageView()
|
||||||
|
private let searchFieldDivider = UIView()
|
||||||
|
private let cancelButton = UIButton(type: .system)
|
||||||
|
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||||
|
private let emptyStateLabel = UILabel()
|
||||||
|
|
||||||
|
// Preserve legacy accessibility hooks used by existing tests and demo logic.
|
||||||
private let previousButton = RDEPUBReaderTintButton(type: .system)
|
private let previousButton = RDEPUBReaderTintButton(type: .system)
|
||||||
private let nextButton = RDEPUBReaderTintButton(type: .system)
|
private let nextButton = RDEPUBReaderTintButton(type: .system)
|
||||||
|
private let countLabel = UILabel()
|
||||||
|
|
||||||
private let countLabel: UILabel = {
|
private var searchSections: [RDEPUBReaderSearchSection] = []
|
||||||
let label = UILabel()
|
private var keyword = ""
|
||||||
label.font = UIFont.systemFont(ofSize: 13, weight: .medium)
|
private var currentMatchIndex: Int?
|
||||||
label.textAlignment = .center
|
|
||||||
label.setContentHuggingPriority(.required, for: .horizontal)
|
|
||||||
label.setContentCompressionResistancePriority(.required, for: .horizontal)
|
|
||||||
return label
|
|
||||||
}()
|
|
||||||
|
|
||||||
private let closeButton = RDEPUBReaderTintButton(type: .system)
|
|
||||||
|
|
||||||
// MARK: 布局常量
|
|
||||||
|
|
||||||
private let horizontalInset: CGFloat = 12
|
|
||||||
private let spacing: CGFloat = 6
|
|
||||||
private let containerHeight: CGFloat = 36
|
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
@ -81,162 +66,331 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
|||||||
setupSubviews()
|
setupSubviews()
|
||||||
setupConstraints()
|
setupConstraints()
|
||||||
setupActions()
|
setupActions()
|
||||||
updateNavigationEnabled(false)
|
updateLegacyNavigationEnabled(false)
|
||||||
|
showInitialState()
|
||||||
}
|
}
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
required init?(coder: NSCoder) {
|
||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: 布局
|
|
||||||
|
|
||||||
override func lineFrame(in bounds: CGRect) -> CGRect {
|
override func lineFrame(in bounds: CGRect) -> CGRect {
|
||||||
CGRect(x: 0, y: bounds.height - 0.5, width: bounds.width, height: 0.5)
|
.zero
|
||||||
}
|
}
|
||||||
|
|
||||||
override func apply(theme: RDEPUBReaderTheme) {
|
override func apply(theme: RDEPUBReaderTheme) {
|
||||||
super.apply(theme: theme)
|
super.apply(theme: theme)
|
||||||
backgroundColor = theme.toolBackgroundColor
|
let isDarkBackground = theme.contentBackgroundColor.rd_searchIsDarkBackground
|
||||||
containerView.backgroundColor = theme.toolControlBorderUnselectColor
|
|
||||||
searchIcon.tintColor = theme.toolControlTextColor
|
let overlayColor = isDarkBackground
|
||||||
textField.textColor = theme.toolControlTextColor
|
? UIColor(white: 0.12, alpha: 0.92)
|
||||||
|
: UIColor(white: 0.08, alpha: 0.82)
|
||||||
|
let panelColor = isDarkBackground
|
||||||
|
? UIColor(red: 0.18, green: 0.18, blue: 0.19, alpha: 1)
|
||||||
|
: UIColor(red: 0.15, green: 0.15, blue: 0.16, alpha: 1)
|
||||||
|
let rowColor = isDarkBackground
|
||||||
|
? UIColor(white: 0.18, alpha: 1)
|
||||||
|
: UIColor(white: 0.14, alpha: 0.96)
|
||||||
|
let cardColor = isDarkBackground
|
||||||
|
? UIColor(white: 0.12, alpha: 1)
|
||||||
|
: UIColor(white: 0.10, alpha: 0.98)
|
||||||
|
let activeCardColor = UIColor(red: 0.17, green: 0.28, blue: 0.38, alpha: 1)
|
||||||
|
let textColor = UIColor(white: 0.96, alpha: 1)
|
||||||
|
let secondaryTextColor = UIColor(white: 0.72, alpha: 1)
|
||||||
|
|
||||||
|
backgroundColor = .clear
|
||||||
|
backgroundButton.backgroundColor = overlayColor
|
||||||
|
panelView.backgroundColor = panelColor
|
||||||
|
grabberView.backgroundColor = UIColor(white: 0.75, alpha: 0.7)
|
||||||
|
searchRowView.backgroundColor = rowColor
|
||||||
|
searchFieldContainer.backgroundColor = .clear
|
||||||
|
searchFieldDivider.backgroundColor = UIColor(white: 1, alpha: 0.12)
|
||||||
|
searchIcon.tintColor = secondaryTextColor
|
||||||
|
cancelButton.tintColor = textColor
|
||||||
|
cancelButton.setTitleColor(textColor, for: .normal)
|
||||||
|
textField.textColor = textColor
|
||||||
|
textField.tintColor = UIColor.systemBlue
|
||||||
|
textField.keyboardAppearance = isDarkBackground ? .dark : .default
|
||||||
textField.attributedPlaceholder = NSAttributedString(
|
textField.attributedPlaceholder = NSAttributedString(
|
||||||
string: "搜索...",
|
string: "搜索",
|
||||||
attributes: [.foregroundColor: theme.toolControlTextColor.withAlphaComponent(0.5)]
|
attributes: [.foregroundColor: secondaryTextColor]
|
||||||
)
|
)
|
||||||
countLabel.textColor = theme.toolControlTextColor
|
|
||||||
previousButton.tintColor = theme.toolControlTextColor
|
emptyStateLabel.textColor = secondaryTextColor
|
||||||
nextButton.tintColor = theme.toolControlTextColor
|
tableView.backgroundColor = .clear
|
||||||
closeButton.tintColor = theme.toolControlTextColor
|
tableView.separatorStyle = .none
|
||||||
|
|
||||||
|
previousButton.tintColor = textColor
|
||||||
|
nextButton.tintColor = textColor
|
||||||
|
countLabel.textColor = textColor
|
||||||
|
countLabel.backgroundColor = .clear
|
||||||
|
|
||||||
|
RDEPUBReaderSearchResultCell.cardBackgroundColor = cardColor
|
||||||
|
RDEPUBReaderSearchResultCell.activeCardBackgroundColor = activeCardColor
|
||||||
|
RDEPUBReaderSearchResultCell.primaryTextColor = textColor
|
||||||
|
RDEPUBReaderSearchResultCell.highlightTextColor = UIColor.systemBlue
|
||||||
|
RDEPUBReaderSearchResultCell.activeHighlightTextColor = UIColor(red: 0.40, green: 0.77, blue: 1, alpha: 1)
|
||||||
|
|
||||||
|
tableView.reloadData()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: 公开方法
|
var presentedView: UIView {
|
||||||
|
panelView
|
||||||
|
}
|
||||||
|
|
||||||
/// 更新匹配计数显示
|
|
||||||
func updateMatchCount(current: Int, total: Int) {
|
func updateMatchCount(current: Int, total: Int) {
|
||||||
countLabel.text = "\(current)/\(total)"
|
countLabel.text = "\(current)/\(total)"
|
||||||
updateNavigationEnabled(total > 0)
|
textField.accessibilityValue = "\(current)/\(total)"
|
||||||
|
updateLegacyNavigationEnabled(total > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 显示无结果状态
|
|
||||||
func showNoResults() {
|
func showNoResults() {
|
||||||
countLabel.text = "0/0"
|
currentMatchIndex = nil
|
||||||
updateNavigationEnabled(false)
|
updateMatchCount(current: 0, total: 0)
|
||||||
|
tableView.isHidden = true
|
||||||
|
emptyStateLabel.isHidden = false
|
||||||
|
emptyStateLabel.text = keyword.isEmpty ? "输入关键词开始搜索" : "未找到相关内容"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 显示搜索中状态
|
|
||||||
func showSearching() {
|
func showSearching() {
|
||||||
countLabel.text = "搜索中..."
|
updateMatchCount(current: 0, total: 0)
|
||||||
updateNavigationEnabled(false)
|
tableView.isHidden = true
|
||||||
|
emptyStateLabel.isHidden = false
|
||||||
|
emptyStateLabel.text = "搜索中..."
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 恢复已有的搜索关键词(搜索栏重新显示时)
|
|
||||||
func restoreKeyword(_ keyword: String) {
|
func restoreKeyword(_ keyword: String) {
|
||||||
textField.text = keyword
|
textField.text = keyword
|
||||||
|
self.keyword = keyword
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: 私有方法
|
func updateResults(
|
||||||
|
sections: [RDEPUBReaderSearchSection],
|
||||||
|
keyword: String,
|
||||||
|
currentMatchIndex: Int?
|
||||||
|
) {
|
||||||
|
self.keyword = keyword
|
||||||
|
self.searchSections = sections
|
||||||
|
self.currentMatchIndex = currentMatchIndex
|
||||||
|
|
||||||
|
let total = sections.reduce(0) { $0 + $1.items.count }
|
||||||
|
if let currentMatchIndex, total > 0 {
|
||||||
|
updateMatchCount(current: currentMatchIndex + 1, total: total)
|
||||||
|
} else {
|
||||||
|
updateMatchCount(current: 0, total: total)
|
||||||
|
}
|
||||||
|
|
||||||
|
if total == 0 {
|
||||||
|
showNoResults()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
emptyStateLabel.isHidden = true
|
||||||
|
tableView.isHidden = false
|
||||||
|
tableView.reloadData()
|
||||||
|
scrollToCurrentMatchIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
private func setupSubviews() {
|
private func setupSubviews() {
|
||||||
addSubview(containerView)
|
backgroundButton.translatesAutoresizingMaskIntoConstraints = false
|
||||||
containerView.addSubview(searchIcon)
|
panelView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
containerView.addSubview(textField)
|
grabberView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
searchRowView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
searchFieldContainer.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
searchIcon.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
textField.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
searchFieldDivider.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
cancelButton.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
tableView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
emptyStateLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
previousButton.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
nextButton.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
countLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
|
addSubview(backgroundButton)
|
||||||
|
addSubview(panelView)
|
||||||
|
|
||||||
|
panelView.addSubview(grabberView)
|
||||||
|
panelView.addSubview(searchRowView)
|
||||||
|
panelView.addSubview(tableView)
|
||||||
|
panelView.addSubview(emptyStateLabel)
|
||||||
|
|
||||||
|
searchRowView.addSubview(searchFieldContainer)
|
||||||
|
searchRowView.addSubview(searchFieldDivider)
|
||||||
|
searchRowView.addSubview(cancelButton)
|
||||||
|
searchFieldContainer.addSubview(searchIcon)
|
||||||
|
searchFieldContainer.addSubview(textField)
|
||||||
|
|
||||||
|
// Legacy shims
|
||||||
addSubview(previousButton)
|
addSubview(previousButton)
|
||||||
addSubview(nextButton)
|
addSubview(nextButton)
|
||||||
addSubview(countLabel)
|
addSubview(countLabel)
|
||||||
addSubview(closeButton)
|
|
||||||
|
|
||||||
if #available(iOS 13.0, *) {
|
if #available(iOS 13.0, *) {
|
||||||
previousButton.setImage(UIImage(systemName: "chevron.up")?.withRenderingMode(.alwaysTemplate), for: .normal)
|
searchIcon.image = UIImage(systemName: "magnifyingglass")
|
||||||
nextButton.setImage(UIImage(systemName: "chevron.down")?.withRenderingMode(.alwaysTemplate), for: .normal)
|
previousButton.setImage(UIImage(systemName: "chevron.up"), for: .normal)
|
||||||
closeButton.setImage(UIImage(systemName: "xmark")?.withRenderingMode(.alwaysTemplate), for: .normal)
|
nextButton.setImage(UIImage(systemName: "chevron.down"), for: .normal)
|
||||||
} else {
|
} else {
|
||||||
previousButton.setTitle("▲", for: .normal)
|
previousButton.setTitle("▲", for: .normal)
|
||||||
nextButton.setTitle("▼", for: .normal)
|
nextButton.setTitle("▼", for: .normal)
|
||||||
closeButton.setTitle("✕", for: .normal)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
searchIcon.contentMode = .scaleAspectFit
|
||||||
|
searchIcon.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 22, weight: .regular)
|
||||||
|
|
||||||
|
panelView.layer.cornerRadius = 28
|
||||||
|
panelView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
|
||||||
|
panelView.clipsToBounds = true
|
||||||
|
|
||||||
|
grabberView.layer.cornerRadius = 3
|
||||||
|
searchRowView.layer.cornerRadius = 22
|
||||||
|
searchFieldContainer.layer.cornerRadius = 22
|
||||||
|
searchRowView.clipsToBounds = true
|
||||||
|
|
||||||
|
cancelButton.setTitle("取消", for: .normal)
|
||||||
|
cancelButton.titleLabel?.font = UIFont.systemFont(ofSize: 18, weight: .medium)
|
||||||
|
cancelButton.accessibilityIdentifier = "epub.reader.search.close"
|
||||||
|
|
||||||
|
emptyStateLabel.font = UIFont.systemFont(ofSize: 17, weight: .medium)
|
||||||
|
emptyStateLabel.textAlignment = .center
|
||||||
|
emptyStateLabel.numberOfLines = 0
|
||||||
|
|
||||||
previousButton.accessibilityIdentifier = "epub.reader.search.previous"
|
previousButton.accessibilityIdentifier = "epub.reader.search.previous"
|
||||||
nextButton.accessibilityIdentifier = "epub.reader.search.next"
|
nextButton.accessibilityIdentifier = "epub.reader.search.next"
|
||||||
closeButton.accessibilityIdentifier = "epub.reader.search.close"
|
|
||||||
countLabel.accessibilityIdentifier = "epub.reader.search.count"
|
countLabel.accessibilityIdentifier = "epub.reader.search.count"
|
||||||
textField.accessibilityIdentifier = "epub.reader.search.field"
|
textField.accessibilityIdentifier = "epub.reader.search.field"
|
||||||
|
previousButton.alpha = 0.01
|
||||||
|
nextButton.alpha = 0.01
|
||||||
|
countLabel.alpha = 0.01
|
||||||
|
|
||||||
[previousButton, nextButton, closeButton].forEach { button in
|
tableView.register(RDEPUBReaderSearchResultCell.self, forCellReuseIdentifier: RDEPUBReaderSearchResultCell.reuseIdentifier)
|
||||||
button.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .medium)
|
tableView.dataSource = self
|
||||||
button.tintColor = .black
|
tableView.delegate = self
|
||||||
button.setTitleColor(.black, for: .normal)
|
tableView.showsVerticalScrollIndicator = false
|
||||||
}
|
tableView.contentInset = UIEdgeInsets(top: 4, left: 0, bottom: 16, right: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupConstraints() {
|
private func setupConstraints() {
|
||||||
[containerView, searchIcon, textField, previousButton, nextButton, countLabel, closeButton].forEach {
|
|
||||||
$0.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
}
|
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
// 容器(搜索输入区域)
|
backgroundButton.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||||
containerView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalInset),
|
backgroundButton.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||||
containerView.centerYAnchor.constraint(equalTo: centerYAnchor),
|
backgroundButton.topAnchor.constraint(equalTo: topAnchor),
|
||||||
containerView.heightAnchor.constraint(equalToConstant: containerHeight),
|
backgroundButton.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||||
|
|
||||||
// 搜索图标
|
panelView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||||
searchIcon.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 10),
|
panelView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||||
searchIcon.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
|
panelView.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 8),
|
||||||
searchIcon.widthAnchor.constraint(equalToConstant: 16),
|
panelView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||||
|
|
||||||
// 输入框
|
grabberView.topAnchor.constraint(equalTo: panelView.topAnchor, constant: 10),
|
||||||
textField.leadingAnchor.constraint(equalTo: searchIcon.trailingAnchor, constant: 6),
|
grabberView.centerXAnchor.constraint(equalTo: panelView.centerXAnchor),
|
||||||
textField.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -8),
|
grabberView.widthAnchor.constraint(equalToConstant: 92),
|
||||||
textField.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
|
grabberView.heightAnchor.constraint(equalToConstant: 6),
|
||||||
textField.heightAnchor.constraint(equalToConstant: containerHeight - 4),
|
|
||||||
|
|
||||||
// 上一个按钮
|
searchRowView.leadingAnchor.constraint(equalTo: panelView.leadingAnchor, constant: 20),
|
||||||
previousButton.leadingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: spacing),
|
searchRowView.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: -20),
|
||||||
previousButton.centerYAnchor.constraint(equalTo: centerYAnchor),
|
searchRowView.topAnchor.constraint(equalTo: grabberView.bottomAnchor, constant: 18),
|
||||||
previousButton.widthAnchor.constraint(equalToConstant: 32),
|
searchRowView.heightAnchor.constraint(equalToConstant: 52),
|
||||||
previousButton.heightAnchor.constraint(equalToConstant: 32),
|
|
||||||
|
|
||||||
// 下一个按钮
|
searchFieldContainer.leadingAnchor.constraint(equalTo: searchRowView.leadingAnchor, constant: 12),
|
||||||
nextButton.leadingAnchor.constraint(equalTo: previousButton.trailingAnchor, constant: spacing),
|
searchFieldContainer.topAnchor.constraint(equalTo: searchRowView.topAnchor),
|
||||||
nextButton.centerYAnchor.constraint(equalTo: centerYAnchor),
|
searchFieldContainer.bottomAnchor.constraint(equalTo: searchRowView.bottomAnchor),
|
||||||
nextButton.widthAnchor.constraint(equalToConstant: 32),
|
|
||||||
nextButton.heightAnchor.constraint(equalToConstant: 32),
|
|
||||||
|
|
||||||
// 计数标签
|
searchIcon.leadingAnchor.constraint(equalTo: searchFieldContainer.leadingAnchor, constant: 10),
|
||||||
countLabel.leadingAnchor.constraint(equalTo: nextButton.trailingAnchor, constant: spacing),
|
searchIcon.centerYAnchor.constraint(equalTo: searchFieldContainer.centerYAnchor),
|
||||||
countLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
|
searchIcon.widthAnchor.constraint(equalToConstant: 24),
|
||||||
countLabel.widthAnchor.constraint(greaterThanOrEqualToConstant: 44),
|
searchIcon.heightAnchor.constraint(equalToConstant: 24),
|
||||||
|
|
||||||
// 关闭按钮
|
textField.leadingAnchor.constraint(equalTo: searchIcon.trailingAnchor, constant: 10),
|
||||||
closeButton.leadingAnchor.constraint(equalTo: countLabel.trailingAnchor, constant: spacing),
|
textField.trailingAnchor.constraint(equalTo: searchFieldContainer.trailingAnchor, constant: -10),
|
||||||
closeButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -horizontalInset),
|
textField.topAnchor.constraint(equalTo: searchFieldContainer.topAnchor),
|
||||||
closeButton.centerYAnchor.constraint(equalTo: centerYAnchor),
|
textField.bottomAnchor.constraint(equalTo: searchFieldContainer.bottomAnchor),
|
||||||
closeButton.widthAnchor.constraint(equalToConstant: 32),
|
|
||||||
closeButton.heightAnchor.constraint(equalToConstant: 32)
|
searchFieldDivider.leadingAnchor.constraint(equalTo: searchFieldContainer.trailingAnchor, constant: 12),
|
||||||
|
searchFieldDivider.centerYAnchor.constraint(equalTo: searchRowView.centerYAnchor),
|
||||||
|
searchFieldDivider.widthAnchor.constraint(equalToConstant: 1),
|
||||||
|
searchFieldDivider.heightAnchor.constraint(equalToConstant: 28),
|
||||||
|
|
||||||
|
cancelButton.leadingAnchor.constraint(equalTo: searchFieldDivider.trailingAnchor, constant: 18),
|
||||||
|
cancelButton.trailingAnchor.constraint(equalTo: searchRowView.trailingAnchor, constant: -18),
|
||||||
|
cancelButton.centerYAnchor.constraint(equalTo: searchRowView.centerYAnchor),
|
||||||
|
|
||||||
|
tableView.leadingAnchor.constraint(equalTo: panelView.leadingAnchor, constant: 0),
|
||||||
|
tableView.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: 0),
|
||||||
|
tableView.topAnchor.constraint(equalTo: searchRowView.bottomAnchor, constant: 18),
|
||||||
|
tableView.bottomAnchor.constraint(equalTo: panelView.safeAreaLayoutGuide.bottomAnchor),
|
||||||
|
|
||||||
|
emptyStateLabel.leadingAnchor.constraint(equalTo: panelView.leadingAnchor, constant: 32),
|
||||||
|
emptyStateLabel.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: -32),
|
||||||
|
emptyStateLabel.topAnchor.constraint(equalTo: searchRowView.bottomAnchor, constant: 56),
|
||||||
|
|
||||||
|
previousButton.topAnchor.constraint(equalTo: topAnchor),
|
||||||
|
previousButton.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||||
|
previousButton.widthAnchor.constraint(equalToConstant: 1),
|
||||||
|
previousButton.heightAnchor.constraint(equalToConstant: 1),
|
||||||
|
|
||||||
|
nextButton.topAnchor.constraint(equalTo: topAnchor),
|
||||||
|
nextButton.leadingAnchor.constraint(equalTo: previousButton.trailingAnchor),
|
||||||
|
nextButton.widthAnchor.constraint(equalToConstant: 1),
|
||||||
|
nextButton.heightAnchor.constraint(equalToConstant: 1),
|
||||||
|
|
||||||
|
countLabel.topAnchor.constraint(equalTo: topAnchor),
|
||||||
|
countLabel.leadingAnchor.constraint(equalTo: nextButton.trailingAnchor),
|
||||||
|
countLabel.widthAnchor.constraint(equalToConstant: 1),
|
||||||
|
countLabel.heightAnchor.constraint(equalToConstant: 1)
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupActions() {
|
private func setupActions() {
|
||||||
|
textField.delegate = self
|
||||||
textField.addTarget(self, action: #selector(textFieldDidReturn), for: .editingDidEndOnExit)
|
textField.addTarget(self, action: #selector(textFieldDidReturn), for: .editingDidEndOnExit)
|
||||||
|
textField.addTarget(self, action: #selector(textFieldDidChange), for: .editingChanged)
|
||||||
previousButton.addTarget(self, action: #selector(previousAction), for: .touchUpInside)
|
previousButton.addTarget(self, action: #selector(previousAction), for: .touchUpInside)
|
||||||
nextButton.addTarget(self, action: #selector(nextAction), for: .touchUpInside)
|
nextButton.addTarget(self, action: #selector(nextAction), for: .touchUpInside)
|
||||||
closeButton.addTarget(self, action: #selector(closeAction), for: .touchUpInside)
|
cancelButton.addTarget(self, action: #selector(closeAction), for: .touchUpInside)
|
||||||
|
backgroundButton.addTarget(self, action: #selector(closeAction), for: .touchUpInside)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateNavigationEnabled(_ enabled: Bool) {
|
private func updateLegacyNavigationEnabled(_ enabled: Bool) {
|
||||||
previousButton.isEnabled = enabled
|
previousButton.isEnabled = enabled
|
||||||
previousButton.alpha = enabled ? 1 : 0.45
|
|
||||||
nextButton.isEnabled = enabled
|
nextButton.isEnabled = enabled
|
||||||
nextButton.alpha = enabled ? 1 : 0.45
|
}
|
||||||
|
|
||||||
|
private func showInitialState() {
|
||||||
|
tableView.isHidden = true
|
||||||
|
emptyStateLabel.isHidden = false
|
||||||
|
emptyStateLabel.text = "输入关键词开始搜索"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scrollToCurrentMatchIfNeeded() {
|
||||||
|
guard let currentMatchIndex else { return }
|
||||||
|
for (sectionIndex, section) in searchSections.enumerated() {
|
||||||
|
if let rowIndex = section.items.firstIndex(where: { $0.matchIndex == currentMatchIndex }) {
|
||||||
|
let indexPath = IndexPath(row: rowIndex, section: sectionIndex)
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.tableView.scrollToRow(at: indexPath, at: .middle, animated: false)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func item(at indexPath: IndexPath) -> RDEPUBReaderSearchSection.Item {
|
||||||
|
searchSections[indexPath.section].items[indexPath.row]
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func textFieldDidReturn() {
|
@objc private func textFieldDidReturn() {
|
||||||
guard let keyword = textField.text, !keyword.isEmpty else { return }
|
let keyword = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
guard !keyword.isEmpty else { return }
|
||||||
onSearchSubmit?(keyword)
|
onSearchSubmit?(keyword)
|
||||||
textField.resignFirstResponder()
|
textField.resignFirstResponder()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc private func textFieldDidChange() {
|
||||||
|
guard textField.markedTextRange == nil else { return }
|
||||||
|
onSearchTextChanged?(textField.text ?? "")
|
||||||
|
}
|
||||||
|
|
||||||
@objc private func previousAction() {
|
@objc private func previousAction() {
|
||||||
onSearchPrevious?()
|
onSearchPrevious?()
|
||||||
}
|
}
|
||||||
@ -249,3 +403,197 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
|||||||
onClose?()
|
onClose?()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
searchSections.count
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
|
searchSections[section].items.count
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||||
|
let cell = tableView.dequeueReusableCell(
|
||||||
|
withIdentifier: RDEPUBReaderSearchResultCell.reuseIdentifier,
|
||||||
|
for: indexPath
|
||||||
|
)
|
||||||
|
|
||||||
|
guard let cell = cell as? RDEPUBReaderSearchResultCell else {
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
let item = item(at: indexPath)
|
||||||
|
cell.configure(
|
||||||
|
previewText: item.previewText,
|
||||||
|
keyword: keyword,
|
||||||
|
isCurrent: item.isCurrent
|
||||||
|
)
|
||||||
|
cell.accessibilityIdentifier = "epub.reader.search.result.\(item.matchIndex)"
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
|
||||||
|
let container = UIView()
|
||||||
|
let label = UILabel()
|
||||||
|
label.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
label.font = UIFont.systemFont(ofSize: 19, weight: .bold)
|
||||||
|
label.textColor = UIColor(white: 0.96, alpha: 1)
|
||||||
|
label.text = searchSections[section].title
|
||||||
|
label.numberOfLines = 2
|
||||||
|
container.addSubview(label)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
label.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 20),
|
||||||
|
label.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -20),
|
||||||
|
label.topAnchor.constraint(equalTo: container.topAnchor, constant: 4),
|
||||||
|
label.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -4)
|
||||||
|
])
|
||||||
|
return container
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
|
||||||
|
40
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||||
|
UITableView.automaticDimension
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||||
|
116
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
|
tableView.deselectRow(at: indexPath, animated: true)
|
||||||
|
onSelectMatch?(item(at: indexPath).matchIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension RDEPUBReaderSearchBarView: UITextFieldDelegate {
|
||||||
|
func textFieldShouldClear(_ textField: UITextField) -> Bool {
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.onSearchTextChanged?("")
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class RDEPUBReaderSearchResultCell: UITableViewCell {
|
||||||
|
static let reuseIdentifier = "RDEPUBReaderSearchResultCell"
|
||||||
|
|
||||||
|
static var cardBackgroundColor = UIColor(white: 0.12, alpha: 1)
|
||||||
|
static var activeCardBackgroundColor = UIColor(red: 0.17, green: 0.28, blue: 0.38, alpha: 1)
|
||||||
|
static var primaryTextColor = UIColor(white: 0.96, alpha: 1)
|
||||||
|
static var highlightTextColor = UIColor.systemBlue
|
||||||
|
static var activeHighlightTextColor = UIColor(red: 0.40, green: 0.77, blue: 1, alpha: 1)
|
||||||
|
|
||||||
|
private let cardView = UIView()
|
||||||
|
private let previewLabel = UILabel()
|
||||||
|
|
||||||
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||||
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||||
|
setupSubviews()
|
||||||
|
setupConstraints()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func prepareForReuse() {
|
||||||
|
super.prepareForReuse()
|
||||||
|
previewLabel.attributedText = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func configure(previewText: String, keyword: String, isCurrent: Bool) {
|
||||||
|
selectionStyle = .none
|
||||||
|
backgroundColor = .clear
|
||||||
|
contentView.backgroundColor = .clear
|
||||||
|
cardView.backgroundColor = isCurrent ? Self.activeCardBackgroundColor : Self.cardBackgroundColor
|
||||||
|
previewLabel.attributedText = attributedPreviewText(
|
||||||
|
previewText,
|
||||||
|
keyword: keyword,
|
||||||
|
isCurrent: isCurrent
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupSubviews() {
|
||||||
|
cardView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
previewLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
|
contentView.addSubview(cardView)
|
||||||
|
cardView.addSubview(previewLabel)
|
||||||
|
|
||||||
|
cardView.layer.cornerRadius = 16
|
||||||
|
cardView.clipsToBounds = true
|
||||||
|
|
||||||
|
previewLabel.numberOfLines = 0
|
||||||
|
previewLabel.font = UIFont.systemFont(ofSize: 18, weight: .regular)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupConstraints() {
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
cardView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20),
|
||||||
|
cardView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20),
|
||||||
|
cardView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10),
|
||||||
|
cardView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10),
|
||||||
|
|
||||||
|
previewLabel.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 16),
|
||||||
|
previewLabel.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -16),
|
||||||
|
previewLabel.topAnchor.constraint(equalTo: cardView.topAnchor, constant: 16),
|
||||||
|
previewLabel.bottomAnchor.constraint(equalTo: cardView.bottomAnchor, constant: -16)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func attributedPreviewText(_ previewText: String, keyword: String, isCurrent: Bool) -> NSAttributedString {
|
||||||
|
let normalizedText = previewText
|
||||||
|
.replacingOccurrences(of: "\n", with: " ")
|
||||||
|
.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
|
let paragraphStyle = NSMutableParagraphStyle()
|
||||||
|
paragraphStyle.lineSpacing = 8
|
||||||
|
|
||||||
|
let attributed = NSMutableAttributedString(
|
||||||
|
string: normalizedText,
|
||||||
|
attributes: [
|
||||||
|
.font: UIFont.systemFont(ofSize: 18, weight: .regular),
|
||||||
|
.foregroundColor: Self.primaryTextColor,
|
||||||
|
.paragraphStyle: paragraphStyle
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
let searchKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !searchKeyword.isEmpty else { return attributed }
|
||||||
|
|
||||||
|
let nsText = normalizedText as NSString
|
||||||
|
var searchRange = NSRange(location: 0, length: nsText.length)
|
||||||
|
let highlightColor = isCurrent ? Self.activeHighlightTextColor : Self.highlightTextColor
|
||||||
|
|
||||||
|
while searchRange.length > 0 {
|
||||||
|
let foundRange = nsText.range(of: searchKeyword, options: [.caseInsensitive], range: searchRange)
|
||||||
|
guard foundRange.location != NSNotFound else { break }
|
||||||
|
attributed.addAttribute(.foregroundColor, value: highlightColor, range: foundRange)
|
||||||
|
let nextLocation = foundRange.location + max(foundRange.length, 1)
|
||||||
|
guard nextLocation < nsText.length else { break }
|
||||||
|
searchRange = NSRange(location: nextLocation, length: nsText.length - nextLocation)
|
||||||
|
}
|
||||||
|
|
||||||
|
return attributed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -136,5 +136,6 @@ public final class RDEPUBReaderTopToolView: RDEPUBReaderToolView {
|
|||||||
} else {
|
} else {
|
||||||
bookmarkButton.setTitle(isBookmarked ? "已签" : "书签", for: .normal)
|
bookmarkButton.setTitle(isBookmarked ? "已签" : "书签", for: .normal)
|
||||||
}
|
}
|
||||||
|
bookmarkButton.accessibilityValue = isBookmarked ? "selected" : "unselected"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -54,6 +54,9 @@ public final class RDURLReaderController: UIViewController {
|
|||||||
private var demoStateTimer: Timer?
|
private var demoStateTimer: Timer?
|
||||||
private var lastEmittedDemoState = ""
|
private var lastEmittedDemoState = ""
|
||||||
private var pendingSearchKeyword: String?
|
private var pendingSearchKeyword: String?
|
||||||
|
private var externalLinkActivationCount = 0
|
||||||
|
private var lastActivatedExternalURL: URL?
|
||||||
|
private var lastReaderErrorDescription = "none"
|
||||||
|
|
||||||
/// 初始化方法
|
/// 初始化方法
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
@ -78,6 +81,7 @@ public final class RDURLReaderController: UIViewController {
|
|||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
view.backgroundColor = .systemBackground
|
view.backgroundColor = .systemBackground
|
||||||
title = bookURL.deletingPathExtension().lastPathComponent
|
title = bookURL.deletingPathExtension().lastPathComponent
|
||||||
|
RDEPUBResourceURLSchemeHandler.resetDebugMetrics()
|
||||||
embedReaderController()
|
embedReaderController()
|
||||||
installDemoStateLabel()
|
installDemoStateLabel()
|
||||||
}
|
}
|
||||||
@ -370,6 +374,10 @@ public final class RDURLReaderController: UIViewController {
|
|||||||
let progression = location.map { String(format: "%.4f", $0.navigationProgression) } ?? "nil"
|
let progression = location.map { String(format: "%.4f", $0.navigationProgression) } ?? "nil"
|
||||||
let mapSnapshot = demoPaginationSnapshot()
|
let mapSnapshot = demoPaginationSnapshot()
|
||||||
let layoutConfig = readerController?.readerContext.currentTextLayoutConfig(pageSize: currentTextPageSize())
|
let layoutConfig = readerController?.readerContext.currentTextLayoutConfig(pageSize: currentTextPageSize())
|
||||||
|
let resourceMetrics = RDEPUBResourceURLSchemeHandler.debugMetricsSnapshot()
|
||||||
|
let cacheStats = readerController?.readerContext.makeChapterSummaryDiskCache().cacheStatistics
|
||||||
|
?? (fileCount: 0, totalBytes: 0)
|
||||||
|
let inspectable = readerController?.configuration.allowsInspectableWebViews ?? epubConfiguration.allowsInspectableWebViews
|
||||||
let state = [
|
let state = [
|
||||||
"reader=opened",
|
"reader=opened",
|
||||||
"page=\(page)",
|
"page=\(page)",
|
||||||
@ -389,7 +397,17 @@ public final class RDURLReaderController: UIViewController {
|
|||||||
"avoidOrphans=\(layoutConfig?.avoidOrphans == true ? 1 : 0)",
|
"avoidOrphans=\(layoutConfig?.avoidOrphans == true ? 1 : 0)",
|
||||||
"windowSize=\(readerController?.configuration.onDemandChapterWindowSize ?? epubConfiguration.onDemandChapterWindowSize)",
|
"windowSize=\(readerController?.configuration.onDemandChapterWindowSize ?? epubConfiguration.onDemandChapterWindowSize)",
|
||||||
"parseMs=\(readerController?.readerContext.lastMetadataParseWallClockMs ?? 0)",
|
"parseMs=\(readerController?.readerContext.lastMetadataParseWallClockMs ?? 0)",
|
||||||
"parseConcurrency=\(readerController?.readerContext.lastMetadataParseConcurrency ?? 0)"
|
"parseConcurrency=\(readerController?.readerContext.lastMetadataParseConcurrency ?? 0)",
|
||||||
|
"inspectable=\(inspectable ? 1 : 0)",
|
||||||
|
"streamedResources=\(resourceMetrics.streamedResponses)",
|
||||||
|
"inMemoryResources=\(resourceMetrics.inMemoryResponses)",
|
||||||
|
"resourceFailures=\(resourceMetrics.failures)",
|
||||||
|
"cacheFiles=\(cacheStats.fileCount)",
|
||||||
|
"cacheBytes=\(cacheStats.totalBytes)",
|
||||||
|
"externalLinks=\(externalLinkActivationCount)",
|
||||||
|
"lastExternalURL=\(encodedDemoLocationHref(lastActivatedExternalURL?.absoluteString))",
|
||||||
|
"lastError=\(encodedDemoField(lastReaderErrorDescription))",
|
||||||
|
"searchMatchText=\(encodedDemoField(currentSearchMatchText()))"
|
||||||
].joined(separator: " ")
|
].joined(separator: " ")
|
||||||
demoStateLabel.text = state
|
demoStateLabel.text = state
|
||||||
if let logPrefix {
|
if let logPrefix {
|
||||||
@ -405,6 +423,25 @@ public final class RDURLReaderController: UIViewController {
|
|||||||
return href.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? href.replacingOccurrences(of: " ", with: "%20")
|
return href.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? href.replacingOccurrences(of: " ", with: "%20")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func encodedDemoField(_ value: String?) -> String {
|
||||||
|
guard let value, !value.isEmpty else { return "nil" }
|
||||||
|
return value.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? value.replacingOccurrences(of: " ", with: "_")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func currentSearchMatchText() -> String {
|
||||||
|
guard let match = readerController?.searchState?.currentMatch,
|
||||||
|
let rangeLocation = match.rangeLocation,
|
||||||
|
let chapterData = readerController?.textChapterData(forNormalizedHref: match.href) else {
|
||||||
|
return "none"
|
||||||
|
}
|
||||||
|
let nsRange = NSRange(location: rangeLocation, length: match.rangeLength)
|
||||||
|
guard nsRange.location >= 0,
|
||||||
|
nsRange.location + nsRange.length <= chapterData.attributedContent.length else {
|
||||||
|
return "none"
|
||||||
|
}
|
||||||
|
return chapterData.attributedContent.attributedSubstring(from: nsRange).string
|
||||||
|
}
|
||||||
|
|
||||||
private func demoPaginationSnapshot() -> (mode: String, phase: String, knownPages: Int, knownChapters: Int, buildableChapters: Int) {
|
private func demoPaginationSnapshot() -> (mode: String, phase: String, knownPages: Int, knownChapters: Int, buildableChapters: Int) {
|
||||||
guard let readerController else {
|
guard let readerController else {
|
||||||
return ("unavailable", "none", 0, 0, 0)
|
return ("unavailable", "none", 0, 0, 0)
|
||||||
@ -486,6 +523,17 @@ extension RDURLReaderController: RDEPUBReaderDelegate {
|
|||||||
public func epubReader(_ reader: UIViewController, didUpdateBookmarks bookmarks: [RDEPUBBookmark]) {
|
public func epubReader(_ reader: UIViewController, didUpdateBookmarks bookmarks: [RDEPUBBookmark]) {
|
||||||
emitDemoState(prefix: "bookmarks=\(bookmarks.count)")
|
emitDemoState(prefix: "bookmarks=\(bookmarks.count)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL) {
|
||||||
|
externalLinkActivationCount += 1
|
||||||
|
lastActivatedExternalURL = url
|
||||||
|
emitDemoState(prefix: "externalLinks=\(externalLinkActivationCount)")
|
||||||
|
}
|
||||||
|
|
||||||
|
public func epubReader(_ reader: UIViewController, didFailWithError error: Error) {
|
||||||
|
lastReaderErrorDescription = String(describing: error)
|
||||||
|
emitDemoState(prefix: "lastError=\(lastReaderErrorDescription)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 翻页模式扩展:提供 Demo 命令行参数值
|
/// 翻页模式扩展:提供 Demo 命令行参数值
|
||||||
|
|||||||
@ -34,8 +34,28 @@ final class RDEPUBChapterSummaryDiskCache {
|
|||||||
|
|
||||||
func read(for key: RDEPUBChapterCacheKey) -> RDEPUBChapterSummary? {
|
func read(for key: RDEPUBChapterCacheKey) -> RDEPUBChapterSummary? {
|
||||||
let fileURL = self.fileURL(for: key)
|
let fileURL = self.fileURL(for: key)
|
||||||
guard let data = try? Data(contentsOf: fileURL) else { return nil }
|
let data: Data
|
||||||
return try? JSONDecoder().decode(RDEPUBChapterSummary.self, from: data)
|
do {
|
||||||
|
data = try Data(contentsOf: fileURL)
|
||||||
|
} catch {
|
||||||
|
let nsError = error as NSError
|
||||||
|
if nsError.domain == NSCocoaErrorDomain && nsError.code == NSFileReadNoSuchFileError {
|
||||||
|
// 文件不存在属于正常缓存未命中,不报错
|
||||||
|
} else {
|
||||||
|
#if DEBUG
|
||||||
|
print("[RDEPUBChapterSummaryDiskCache] ⚠️ read IO error for \(fileURL.lastPathComponent): \(error.localizedDescription)")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
return try JSONDecoder().decode(RDEPUBChapterSummary.self, from: data)
|
||||||
|
} catch {
|
||||||
|
#if DEBUG
|
||||||
|
print("[RDEPUBChapterSummaryDiskCache] ⚠️ decode error for \(fileURL.lastPathComponent): \(error.localizedDescription)")
|
||||||
|
#endif
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 批量读取:二次打开时直接从磁盘构建 BookPageMap
|
// MARK: - 批量读取:二次打开时直接从磁盘构建 BookPageMap
|
||||||
@ -75,32 +95,78 @@ final class RDEPUBChapterSummaryDiskCache {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 语义化别名:用于判断当前 renderSignature 下是否具备完整章节摘要集合。
|
|
||||||
func containsCompleteSet(keys: [RDEPUBChapterCacheKey]) -> Bool {
|
|
||||||
isCacheComplete(keys: keys)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 清空所有缓存文件
|
/// 清空所有缓存文件
|
||||||
func removeAll() {
|
func removeAll() {
|
||||||
guard let files = try? fileManager.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: nil) else { return }
|
removeFiles(matching: { _ in true })
|
||||||
for fileURL in files where fileURL.pathExtension == "json" {
|
}
|
||||||
try? fileManager.removeItem(at: fileURL)
|
|
||||||
|
/// 清空指定书籍的所有缓存文件
|
||||||
|
func removeAll(forBookID bookID: String) {
|
||||||
|
let bookPrefix = Self.cacheNamespacePrefix(for: bookID)
|
||||||
|
removeFiles { $0.hasPrefix(bookPrefix + "__") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清空指定渲染签名下的所有缓存文件
|
||||||
|
func removeAll(forRenderSignature renderSignature: String) {
|
||||||
|
let renderPrefix = "__" + Self.cacheNamespacePrefix(for: renderSignature) + "__"
|
||||||
|
removeFiles { $0.contains(renderPrefix) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 缓存统计信息
|
||||||
|
var cacheStatistics: (fileCount: Int, totalBytes: Int64) {
|
||||||
|
guard let files = try? fileManager.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: [.fileSizeKey]) else {
|
||||||
|
return (0, 0)
|
||||||
}
|
}
|
||||||
|
var count = 0
|
||||||
|
var totalBytes: Int64 = 0
|
||||||
|
for fileURL in files where fileURL.pathExtension == "json" {
|
||||||
|
count += 1
|
||||||
|
if let size = try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize {
|
||||||
|
totalBytes += Int64(size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (count, totalBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - key -> 文件路径
|
// MARK: - key -> 文件路径
|
||||||
|
|
||||||
/// 使用确定性字符串拼接生成文件名,不依赖 Hashable.hashValue
|
/// 使用确定性字符串拼接生成文件名,不依赖 Hashable.hashValue
|
||||||
private func fileURL(for key: RDEPUBChapterCacheKey) -> URL {
|
private func fileURL(for key: RDEPUBChapterCacheKey) -> URL {
|
||||||
|
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 rawKey = "\(key.bookID)_\(key.spineIndex)_\(key.renderSignature)_\(key.chapterContentHash)"
|
||||||
let digest = rawKey.sha256Hex
|
let digest = rawKey.sha256Hex
|
||||||
return cacheDirectory.appendingPathComponent("\(digest).json")
|
return cacheDirectory.appendingPathComponent("\(bookPrefix)__\(renderPrefix)__\(digest).json")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func writeImmediately(summary: RDEPUBChapterSummary, for key: RDEPUBChapterCacheKey) {
|
private func writeImmediately(summary: RDEPUBChapterSummary, for key: RDEPUBChapterCacheKey) {
|
||||||
let fileURL = self.fileURL(for: key)
|
let fileURL = self.fileURL(for: key)
|
||||||
let data = try? JSONEncoder().encode(summary)
|
let tmpURL = fileURL.appendingPathExtension("tmp")
|
||||||
try? data?.write(to: fileURL)
|
do {
|
||||||
|
let data = try JSONEncoder().encode(summary)
|
||||||
|
try data.write(to: tmpURL)
|
||||||
|
if fileManager.fileExists(atPath: fileURL.path) {
|
||||||
|
_ = try fileManager.replaceItemAt(fileURL, withItemAt: tmpURL)
|
||||||
|
} else {
|
||||||
|
try fileManager.moveItem(at: tmpURL, to: fileURL)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
#if DEBUG
|
||||||
|
print("[RDEPUBChapterSummaryDiskCache] ⚠️ write error for \(fileURL.lastPathComponent): \(error.localizedDescription)")
|
||||||
|
#endif
|
||||||
|
try? fileManager.removeItem(at: tmpURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeFiles(matching predicate: (String) -> Bool) {
|
||||||
|
guard let files = try? fileManager.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: nil) else { return }
|
||||||
|
for fileURL in files where fileURL.pathExtension == "json" && predicate(fileURL.lastPathComponent) {
|
||||||
|
try? fileManager.removeItem(at: fileURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func cacheNamespacePrefix(for rawValue: String) -> String {
|
||||||
|
rawValue.sha256Hex.prefix(12).lowercased()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -52,7 +52,9 @@ final class RDEPUBChapterWindowCoordinator {
|
|||||||
self.isSwitchingChapter = false
|
self.isSwitchingChapter = false
|
||||||
self.buildSnapshotAroundCurrent(chapter: chapter)
|
self.buildSnapshotAroundCurrent(chapter: chapter)
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
|
#if DEBUG
|
||||||
print("[EPUB][WindowCoord] loadChapter failed at spine=\(initialSpineIndex): \(error), trying next")
|
print("[EPUB][WindowCoord] loadChapter failed at spine=\(initialSpineIndex): \(error), trying next")
|
||||||
|
#endif
|
||||||
// 自动跳过不可渲染的章节(封面/版权页等 linear=false 的 spine 项)
|
// 自动跳过不可渲染的章节(封面/版权页等 linear=false 的 spine 项)
|
||||||
let nextIndex = initialSpineIndex + 1
|
let nextIndex = initialSpineIndex + 1
|
||||||
if nextIndex < totalSpineCount {
|
if nextIndex < totalSpineCount {
|
||||||
@ -76,7 +78,9 @@ final class RDEPUBChapterWindowCoordinator {
|
|||||||
|
|
||||||
private func buildSnapshotAroundCurrent(chapter: RDEPUBRuntimeChapter) {
|
private func buildSnapshotAroundCurrent(chapter: RDEPUBRuntimeChapter) {
|
||||||
guard let current = store.currentSpineIndex else {
|
guard let current = store.currentSpineIndex else {
|
||||||
|
#if DEBUG
|
||||||
print("[EPUB][WindowCoord] buildSnapshot: currentSpineIndex is nil, ABORT")
|
print("[EPUB][WindowCoord] buildSnapshot: currentSpineIndex is nil, ABORT")
|
||||||
|
#endif
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let chapters = store.windowSpineIndices.compactMap { spineIndex -> RDEPUBRuntimeChapter? in
|
let chapters = store.windowSpineIndices.compactMap { spineIndex -> RDEPUBRuntimeChapter? in
|
||||||
@ -86,7 +90,9 @@ final class RDEPUBChapterWindowCoordinator {
|
|||||||
return store.chapterData(for: spineIndex)
|
return store.chapterData(for: spineIndex)
|
||||||
}
|
}
|
||||||
let snapshot = RDEPUBChapterWindowSnapshot.from(chapters: chapters, anchorSpineIndex: current)
|
let snapshot = RDEPUBChapterWindowSnapshot.from(chapters: chapters, anchorSpineIndex: current)
|
||||||
|
#if DEBUG
|
||||||
print("[EPUB][WindowCoord] snapshot: chapters=\(snapshot.chapters.count) pages=\(snapshot.pageCount) anchorPage=\(snapshot.anchorPageOffset)")
|
print("[EPUB][WindowCoord] snapshot: chapters=\(snapshot.chapters.count) pages=\(snapshot.pageCount) anchorPage=\(snapshot.anchorPageOffset)")
|
||||||
|
#endif
|
||||||
currentSnapshot = snapshot
|
currentSnapshot = snapshot
|
||||||
isApplyingSnapshot = true
|
isApplyingSnapshot = true
|
||||||
onSnapshotChanged?(snapshot)
|
onSnapshotChanged?(snapshot)
|
||||||
@ -240,14 +246,18 @@ final class RDEPUBChapterWindowCoordinator {
|
|||||||
|
|
||||||
private func handle(error: Error) {
|
private func handle(error: Error) {
|
||||||
// 日志记录,不中断当前阅读状态
|
// 日志记录,不中断当前阅读状态
|
||||||
|
#if DEBUG
|
||||||
print("[RDEPUBChapterWindowCoordinator] chapter load error: \(error)")
|
print("[RDEPUBChapterWindowCoordinator] chapter load error: \(error)")
|
||||||
|
#endif
|
||||||
// 确保 loading 指示器在加载失败时也被隐藏(避免永久白屏)
|
// 确保 loading 指示器在加载失败时也被隐藏(避免永久白屏)
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.context.hideLoading()
|
self.context.hideLoading()
|
||||||
// 如果有快照但没内容显示,显示错误提示
|
// 如果有快照但没内容显示,显示错误提示
|
||||||
if self.currentSnapshot == nil {
|
if self.currentSnapshot == nil {
|
||||||
|
#if DEBUG
|
||||||
print("[RDEPUBChapterWindowCoordinator] No snapshot after error, page will be blank")
|
print("[RDEPUBChapterWindowCoordinator] No snapshot after error, page will be blank")
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,111 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
struct RDEPUBLocationConverter {
|
|
||||||
|
|
||||||
// MARK: - 主路径:先构建目标章,拿到真实长度后再精确转换
|
|
||||||
|
|
||||||
/// 旧版 RDEPUBLocation -> 新版 RDEPUBChapterLocation
|
|
||||||
/// 主迁移路径:要求先构建目标章,用真实 chapterLength 做精确转换
|
|
||||||
/// 仅在无法获取章节长度时才降级到粗估 fallback
|
|
||||||
static func convert(
|
|
||||||
legacy location: RDEPUBLocation,
|
|
||||||
parser: RDEPUBParser,
|
|
||||||
publication: RDEPUBPublication,
|
|
||||||
chapterLengthProvider: ((Int) -> Int?)? = nil
|
|
||||||
) -> RDEPUBChapterLocation? {
|
|
||||||
// 1. 从 href 找到 spineIndex
|
|
||||||
guard let spineItem = publication.spine.first(where: {
|
|
||||||
$0.href == location.href || $0.href.contains(location.href)
|
|
||||||
}) else { return nil }
|
|
||||||
|
|
||||||
let spineIndex = publication.spine.firstIndex(of: spineItem) ?? 0
|
|
||||||
|
|
||||||
// 2. 优先用 fragmentID 定位(最精确,不受 progression 精度影响)
|
|
||||||
if let fragmentID = location.fragment {
|
|
||||||
return RDEPUBChapterLocation(
|
|
||||||
spineIndex: spineIndex,
|
|
||||||
chapterOffset: 0, // fragmentID 由 chapterOffsetMap 精确解析
|
|
||||||
fragmentID: fragmentID,
|
|
||||||
progressionInChapter: location.progression
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 有 chapterLength 时做精确转换
|
|
||||||
if let provider = chapterLengthProvider,
|
|
||||||
let chapterLength = provider(spineIndex), chapterLength > 0 {
|
|
||||||
return convert(
|
|
||||||
legacy: location,
|
|
||||||
spineIndex: spineIndex,
|
|
||||||
chapterLength: chapterLength
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Fallback:无法获取章节长度时的粗估(仅作临时降级)
|
|
||||||
let estimatedOffset = Int(location.progression * 10000)
|
|
||||||
return RDEPUBChapterLocation(
|
|
||||||
spineIndex: spineIndex,
|
|
||||||
chapterOffset: estimatedOffset,
|
|
||||||
fragmentID: nil,
|
|
||||||
progressionInChapter: location.progression,
|
|
||||||
schemaVersion: 1 // 标记为降级结果,后续可被精确值覆盖
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 精确转换:已知章节实际长度
|
|
||||||
static func convert(
|
|
||||||
legacy location: RDEPUBLocation,
|
|
||||||
spineIndex: Int,
|
|
||||||
chapterLength: Int
|
|
||||||
) -> RDEPUBChapterLocation? {
|
|
||||||
let offset = Int(location.progression * Double(chapterLength))
|
|
||||||
return RDEPUBChapterLocation(
|
|
||||||
spineIndex: spineIndex,
|
|
||||||
chapterOffset: offset,
|
|
||||||
fragmentID: location.fragment,
|
|
||||||
progressionInChapter: location.progression,
|
|
||||||
schemaVersion: 2
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从已构建的 RDEPUBRuntimeChapter 做精确转换(推荐迁移路径)
|
|
||||||
static func convert(
|
|
||||||
legacy location: RDEPUBLocation,
|
|
||||||
chapter: RDEPUBRuntimeChapter
|
|
||||||
) -> RDEPUBChapterLocation? {
|
|
||||||
// 优先用 fragmentID
|
|
||||||
if let fragmentID = location.fragment,
|
|
||||||
let fragmentOffset = chapter.chapterOffsetMap.chapterOffset(forFragmentID: fragmentID) {
|
|
||||||
return RDEPUBChapterLocation(
|
|
||||||
spineIndex: chapter.spineIndex,
|
|
||||||
chapterOffset: fragmentOffset,
|
|
||||||
fragmentID: fragmentID,
|
|
||||||
progressionInChapter: nil,
|
|
||||||
schemaVersion: 2
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 用 progression + 真实长度
|
|
||||||
let chapterLength = chapter.typesetAttributedString.length
|
|
||||||
return convert(
|
|
||||||
legacy: location,
|
|
||||||
spineIndex: chapter.spineIndex,
|
|
||||||
chapterLength: chapterLength
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 新版 -> 旧版(兼容外部接口)
|
|
||||||
static func toLegacy(
|
|
||||||
chapterLocation: RDEPUBChapterLocation,
|
|
||||||
href: String,
|
|
||||||
chapterLength: Int
|
|
||||||
) -> RDEPUBLocation {
|
|
||||||
let progression = chapterLength > 0
|
|
||||||
? Double(chapterLocation.chapterOffset) / Double(chapterLength)
|
|
||||||
: 0
|
|
||||||
return RDEPUBLocation(
|
|
||||||
href: href,
|
|
||||||
progression: min(max(progression, 0), 1),
|
|
||||||
fragment: chapterLocation.fragmentID
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -17,12 +17,6 @@ final class RDEPUBPageCountCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func entriesForSpineIndex(_ spineIndex: Int) -> [(RDEPUBChapterCacheKey, RDEPUBRuntimePageCount)] {
|
|
||||||
lock.lock()
|
|
||||||
defer { lock.unlock() }
|
|
||||||
return storage.filter { $0.value.spineIndex == spineIndex }.map { ($0.key, $0.value) }
|
|
||||||
}
|
|
||||||
|
|
||||||
func remove(forSpineIndex spineIndex: Int) {
|
func remove(forSpineIndex spineIndex: Int) {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
|
|||||||
@ -32,14 +32,34 @@ final class RDEPUBReaderAnnotationCoordinator {
|
|||||||
|
|
||||||
/// 更新当前文本选区状态,并同步刷新底部工具栏的高亮按钮可用性。
|
/// 更新当前文本选区状态,并同步刷新底部工具栏的高亮按钮可用性。
|
||||||
func updateCurrentSelection(_ selection: RDEPUBSelection?) {
|
func updateCurrentSelection(_ selection: RDEPUBSelection?) {
|
||||||
guard let controller else { return }
|
if let selection, !selection.isEmpty {
|
||||||
controller.currentSelection = selection?.isEmpty == false ? selection : nil
|
applySelectionState(.selected(selection))
|
||||||
if controller.currentSelection != nil,
|
} else {
|
||||||
controller.readerView.isShowToolView == false {
|
applySelectionState(.idle)
|
||||||
controller.readerView.tapCenter()
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 统一选区状态变更入口。
|
||||||
|
/// 在 `.selected` 时:更新 context 选区、显示工具栏、刷新 chrome、通知 delegate。
|
||||||
|
/// 在 `.idle` 时:清空选区、刷新 chrome、通知 delegate。
|
||||||
|
func applySelectionState(_ state: RDEPUBSelectionState) {
|
||||||
|
guard let controller else { return }
|
||||||
|
context.selectionState = state
|
||||||
|
switch state {
|
||||||
|
case .idle:
|
||||||
|
controller.updateReaderChrome()
|
||||||
|
controller.delegate?.epubReader(controller, didChangeSelection: nil)
|
||||||
|
case .selecting:
|
||||||
|
break
|
||||||
|
case .selected(let selection):
|
||||||
|
if controller.readerView.isShowToolView == false {
|
||||||
|
controller.readerView.tapCenter()
|
||||||
|
}
|
||||||
|
controller.updateReaderChrome()
|
||||||
|
controller.delegate?.epubReader(controller, didChangeSelection: selection)
|
||||||
|
case .committingAction:
|
||||||
|
break
|
||||||
}
|
}
|
||||||
controller.updateReaderChrome()
|
|
||||||
controller.delegate?.epubReader(controller, didChangeSelection: controller.currentSelection)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 基于当前选区添加高亮标记,自动去重并持久化。
|
/// 基于当前选区添加高亮标记,自动去重并持久化。
|
||||||
@ -139,11 +159,10 @@ final class RDEPUBReaderAnnotationCoordinator {
|
|||||||
/// 跳转到指定高亮所在位置。
|
/// 跳转到指定高亮所在位置。
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func go(toHighlightID id: String, animated: Bool = true) -> Bool {
|
func go(toHighlightID id: String, animated: Bool = true) -> Bool {
|
||||||
guard let controller else { return false }
|
|
||||||
guard let highlight = highlight(withID: id) else {
|
guard let highlight = highlight(withID: id) else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return controller.restoreReadingLocation(highlight.location, animated: animated)
|
return navigate(to: highlight, animated: animated)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 清除所有高亮标记。
|
/// 清除所有高亮标记。
|
||||||
@ -196,9 +215,8 @@ final class RDEPUBReaderAnnotationCoordinator {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
highlightsController.onSelectHighlight = { [weak self, weak highlightsController] highlight in
|
highlightsController.onSelectHighlight = { [weak self, weak highlightsController] highlight in
|
||||||
guard let controller = self?.controller else { return }
|
|
||||||
highlightsController?.dismiss(animated: true) {
|
highlightsController?.dismiss(animated: true) {
|
||||||
controller.go(to: highlight.location)
|
_ = self?.navigate(to: highlight, animated: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
highlightsController.onUpdateHighlight = { [weak self] highlight in
|
highlightsController.onUpdateHighlight = { [weak self] highlight in
|
||||||
@ -371,6 +389,17 @@ final class RDEPUBReaderAnnotationCoordinator {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
private func navigate(to highlight: RDEPUBHighlight, animated: Bool) -> Bool {
|
||||||
|
guard let controller else { return false }
|
||||||
|
let navigationTarget = scopedHighlight(highlight) ?? highlight
|
||||||
|
return controller.restoreReadingLocation(
|
||||||
|
navigationTarget.location,
|
||||||
|
animated: animated,
|
||||||
|
targetHighlightRangeInfo: navigationTarget.rangeInfo
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private func persistHighlightsAndRefreshContent() {
|
private func persistHighlightsAndRefreshContent() {
|
||||||
guard let controller else { return }
|
guard let controller else { return }
|
||||||
if let currentBookIdentifier = controller.currentBookIdentifier {
|
if let currentBookIdentifier = controller.currentBookIdentifier {
|
||||||
|
|||||||
@ -23,7 +23,9 @@ final class RDEPUBReaderAssemblyCoordinator {
|
|||||||
setupLoadingIndicator(controller.loadingIndicator, in: controller.view)
|
setupLoadingIndicator(controller.loadingIndicator, in: controller.view)
|
||||||
setupErrorLabel(controller.errorLabel, in: controller.view)
|
setupErrorLabel(controller.errorLabel, in: controller.view)
|
||||||
controller.delegate?.epubReader(controller, configureTopToolView: controller.topToolView)
|
controller.delegate?.epubReader(controller, configureTopToolView: controller.topToolView)
|
||||||
|
#if DEBUG
|
||||||
print("[ReadViewDemo] assembleInterface: dataSource=\(readerView.dataSource != nil ? "set" : "nil"), numberOfPages=\(readerView.numberOfPages())")
|
print("[ReadViewDemo] assembleInterface: dataSource=\(readerView.dataSource != nil ? "set" : "nil"), numberOfPages=\(readerView.numberOfPages())")
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 外部纯文本图书启动时,加载已保存的书签、高亮和阅读位置,完成分页收尾。
|
/// 外部纯文本图书启动时,加载已保存的书签、高亮和阅读位置,完成分页收尾。
|
||||||
@ -41,9 +43,13 @@ final class RDEPUBReaderAssemblyCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let textBook = controller.textBook {
|
if let textBook = controller.textBook {
|
||||||
|
#if DEBUG
|
||||||
print("[ReadViewDemo] finishExternalTextBook: applying textBook with \(textBook.pages.count) pages")
|
print("[ReadViewDemo] finishExternalTextBook: applying textBook with \(textBook.pages.count) pages")
|
||||||
|
#endif
|
||||||
runtime.applyTextBook(textBook, restoreLocation: restoreLocation)
|
runtime.applyTextBook(textBook, restoreLocation: restoreLocation)
|
||||||
|
#if DEBUG
|
||||||
print("[ReadViewDemo] finishExternalTextBook: after applyTextBook, numberOfPages=\(context.readerView?.numberOfPages() ?? -1)")
|
print("[ReadViewDemo] finishExternalTextBook: after applyTextBook, numberOfPages=\(context.readerView?.numberOfPages() ?? -1)")
|
||||||
|
#endif
|
||||||
} else {
|
} else {
|
||||||
runtime.finishPagination(restoreLocation: restoreLocation)
|
runtime.finishPagination(restoreLocation: restoreLocation)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -70,7 +70,7 @@ final class RDEPUBReaderChromeCoordinator {
|
|||||||
canToggleBookmark: controller.currentBookIdentifier != nil,
|
canToggleBookmark: controller.currentBookIdentifier != nil,
|
||||||
hasBookmarkAtCurrentLocation: hasBookmarkAtCurrentLocation(),
|
hasBookmarkAtCurrentLocation: hasBookmarkAtCurrentLocation(),
|
||||||
canShowBookmarks: !controller.activeBookmarks.isEmpty,
|
canShowBookmarks: !controller.activeBookmarks.isEmpty,
|
||||||
canAddHighlight: controller.configuration.allowsHighlights && controller.currentSelection != nil,
|
canAddHighlight: controller.configuration.allowsHighlights && context.selectionState.hasSelection,
|
||||||
canShowHighlights: controller.configuration.allowsHighlights && !controller.activeHighlights.isEmpty,
|
canShowHighlights: controller.configuration.allowsHighlights && !controller.activeHighlights.isEmpty,
|
||||||
showsTableOfContents: controller.configuration.showsTableOfContents,
|
showsTableOfContents: controller.configuration.showsTableOfContents,
|
||||||
allowsHighlights: controller.configuration.allowsHighlights,
|
allowsHighlights: controller.configuration.allowsHighlights,
|
||||||
|
|||||||
@ -59,8 +59,19 @@ final class RDEPUBReaderContext {
|
|||||||
var lastMetadataParseWallClockMs: Int = 0
|
var lastMetadataParseWallClockMs: Int = 0
|
||||||
/// 后台元数据解析使用的并发数。
|
/// 后台元数据解析使用的并发数。
|
||||||
var lastMetadataParseConcurrency: Int = 0
|
var lastMetadataParseConcurrency: Int = 0
|
||||||
/// 当前用户文本选区。
|
/// 当前用户文本选区(对外只读语义,底层由 selectionState 推导)。
|
||||||
var currentSelection: RDEPUBSelection?
|
var currentSelection: RDEPUBSelection? {
|
||||||
|
get { selectionState.selection }
|
||||||
|
set {
|
||||||
|
if let newValue, !newValue.isEmpty {
|
||||||
|
selectionState = .selected(newValue)
|
||||||
|
} else {
|
||||||
|
selectionState = .idle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// 统一选区状态模型,收口所有选区相关状态变更。
|
||||||
|
var selectionState: RDEPUBSelectionState = .idle
|
||||||
|
|
||||||
// MARK: - 控制器状态(从 controller 下沉)
|
// MARK: - 控制器状态(从 controller 下沉)
|
||||||
|
|
||||||
|
|||||||
@ -16,7 +16,11 @@ final class RDEPUBReaderLocationCoordinator {
|
|||||||
|
|
||||||
/// 恢复到指定阅读位置,返回是否成功跳转。
|
/// 恢复到指定阅读位置,返回是否成功跳转。
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func restoreReadingLocation(_ location: RDEPUBLocation, animated: Bool = false) -> Bool {
|
func restoreReadingLocation(
|
||||||
|
_ location: RDEPUBLocation,
|
||||||
|
animated: Bool = false,
|
||||||
|
targetHighlightRangeInfo: String? = nil
|
||||||
|
) -> Bool {
|
||||||
guard let controller = context.controller,
|
guard let controller = context.controller,
|
||||||
let readerView = context.readerView else { return false }
|
let readerView = context.readerView else { return false }
|
||||||
guard let targetPageNumber = controller.pageNumber(for: location) else {
|
guard let targetPageNumber = controller.pageNumber(for: location) else {
|
||||||
@ -32,13 +36,15 @@ final class RDEPUBReaderLocationCoordinator {
|
|||||||
_ = context.readingSession?.queueNavigation(
|
_ = context.readingSession?.queueNavigation(
|
||||||
to: location,
|
to: location,
|
||||||
relativeToSpineIndex: nil,
|
relativeToSpineIndex: nil,
|
||||||
bookIdentifier: context.currentBookIdentifier
|
bookIdentifier: context.currentBookIdentifier,
|
||||||
|
targetHighlightRangeInfo: targetHighlightRangeInfo
|
||||||
)
|
)
|
||||||
} else if context.textBook == nil {
|
} else if context.textBook == nil {
|
||||||
_ = context.readingSession?.queueNavigation(
|
_ = context.readingSession?.queueNavigation(
|
||||||
to: location,
|
to: location,
|
||||||
relativeToSpineIndex: nil,
|
relativeToSpineIndex: nil,
|
||||||
bookIdentifier: context.currentBookIdentifier
|
bookIdentifier: context.currentBookIdentifier,
|
||||||
|
targetHighlightRangeInfo: targetHighlightRangeInfo
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
context.readingSession?.transition(to: .jumping)
|
context.readingSession?.transition(to: .jumping)
|
||||||
|
|||||||
@ -33,10 +33,14 @@ final class RDEPUBReaderPaginationCoordinator {
|
|||||||
controller.showLoading()
|
controller.showLoading()
|
||||||
let token = UUID()
|
let token = UUID()
|
||||||
context.paginationToken = token
|
context.paginationToken = token
|
||||||
|
#if DEBUG
|
||||||
print("[EPUB][Pagination] profile=\(publication.readingProfile.rawValue) layout=\(publication.layout.rawValue) spine=\(publication.spine.count)")
|
print("[EPUB][Pagination] profile=\(publication.readingProfile.rawValue) layout=\(publication.layout.rawValue) spine=\(publication.spine.count)")
|
||||||
|
#endif
|
||||||
|
|
||||||
if publication.readingProfile == .textReflowable {
|
if publication.readingProfile == .textReflowable {
|
||||||
|
#if DEBUG
|
||||||
print("[EPUB][Pagination] path=text-reflowable-on-demand")
|
print("[EPUB][Pagination] path=text-reflowable-on-demand")
|
||||||
|
#endif
|
||||||
paginateTextPublication(
|
paginateTextPublication(
|
||||||
parser: parser,
|
parser: parser,
|
||||||
publication: publication,
|
publication: publication,
|
||||||
@ -48,7 +52,9 @@ final class RDEPUBReaderPaginationCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if publication.layout == .fixed {
|
if publication.layout == .fixed {
|
||||||
|
#if DEBUG
|
||||||
print("[EPUB][Pagination] path=fixed-layout")
|
print("[EPUB][Pagination] path=fixed-layout")
|
||||||
|
#endif
|
||||||
let snapshot = readingSession.makePaginationSnapshot(
|
let snapshot = readingSession.makePaginationSnapshot(
|
||||||
pageCounts: Array(repeating: 1, count: publication.spine.count),
|
pageCounts: Array(repeating: 1, count: publication.spine.count),
|
||||||
preferences: controller.currentPreferences(),
|
preferences: controller.currentPreferences(),
|
||||||
@ -59,7 +65,9 @@ final class RDEPUBReaderPaginationCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let paginator = context.makePaginator()
|
let paginator = context.makePaginator()
|
||||||
|
#if DEBUG
|
||||||
print("[EPUB][Pagination] path=web-paginator")
|
print("[EPUB][Pagination] path=web-paginator")
|
||||||
|
#endif
|
||||||
context.paginator = paginator
|
context.paginator = paginator
|
||||||
paginator.calculate(
|
paginator.calculate(
|
||||||
parser: parser,
|
parser: parser,
|
||||||
|
|||||||
@ -230,6 +230,12 @@ final class RDEPUBReaderRuntime {
|
|||||||
searchCoordinator.searchPrevious()
|
searchCoordinator.searchPrevious()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 跳转到指定搜索匹配项
|
||||||
|
@discardableResult
|
||||||
|
func selectSearchMatch(at index: Int) -> Bool {
|
||||||
|
searchCoordinator.selectSearchMatch(at: index)
|
||||||
|
}
|
||||||
|
|
||||||
/// 清除搜索状态
|
/// 清除搜索状态
|
||||||
func clearSearch() {
|
func clearSearch() {
|
||||||
searchCoordinator.clearSearch()
|
searchCoordinator.clearSearch()
|
||||||
@ -365,8 +371,16 @@ final class RDEPUBReaderRuntime {
|
|||||||
|
|
||||||
/// 恢复到指定阅读位置
|
/// 恢复到指定阅读位置
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func restoreReadingLocation(_ location: RDEPUBLocation, animated: Bool = false) -> Bool {
|
func restoreReadingLocation(
|
||||||
locationCoordinator.restoreReadingLocation(location, animated: animated)
|
_ location: RDEPUBLocation,
|
||||||
|
animated: Bool = false,
|
||||||
|
targetHighlightRangeInfo: String? = nil
|
||||||
|
) -> Bool {
|
||||||
|
locationCoordinator.restoreReadingLocation(
|
||||||
|
location,
|
||||||
|
animated: animated,
|
||||||
|
targetHighlightRangeInfo: targetHighlightRangeInfo
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取当前可见页面的阅读位置
|
/// 获取当前可见页面的阅读位置
|
||||||
|
|||||||
@ -51,6 +51,21 @@ final class RDEPUBReaderSearchCoordinator {
|
|||||||
advanceSearch(by: -1)
|
advanceSearch(by: -1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 跳转到指定索引的搜索匹配项
|
||||||
|
@discardableResult
|
||||||
|
func selectSearchMatch(at index: Int) -> Bool {
|
||||||
|
guard let controller else { return false }
|
||||||
|
guard var searchState = controller.searchState,
|
||||||
|
searchState.matches.indices.contains(index) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
searchState.currentMatchIndex = index
|
||||||
|
controller.searchState = searchState
|
||||||
|
notifySearchStateChanged()
|
||||||
|
return navigateToCurrentSearchMatch(animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
/// 清除搜索状态并刷新当前可见内容
|
/// 清除搜索状态并刷新当前可见内容
|
||||||
func clearSearch() {
|
func clearSearch() {
|
||||||
guard let controller else { return }
|
guard let controller else { return }
|
||||||
@ -109,12 +124,109 @@ final class RDEPUBReaderSearchCoordinator {
|
|||||||
}
|
}
|
||||||
return RDEPUBTextSearchEngine.searchWithoutPublication(textBook: textBook, keyword: keyword)
|
return RDEPUBTextSearchEngine.searchWithoutPublication(textBook: textBook, keyword: keyword)
|
||||||
}
|
}
|
||||||
|
if controller.readerContext.bookPageMap != nil, controller.publication != nil {
|
||||||
|
return resolvedOnDemandSearchMatches(for: keyword)
|
||||||
|
}
|
||||||
if let parser = controller.parser, let publication = controller.publication {
|
if let parser = controller.parser, let publication = controller.publication {
|
||||||
return RDEPUBHTMLSearchEngine(parser: parser, publication: publication).search(keyword: keyword)
|
return RDEPUBHTMLSearchEngine(parser: parser, publication: publication).search(keyword: keyword)
|
||||||
}
|
}
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func resolvedOnDemandSearchMatches(for keyword: String) -> [RDEPUBSearchMatch] {
|
||||||
|
guard let controller,
|
||||||
|
let publication = controller.publication else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !normalizedKeyword.isEmpty else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
let buildableSpineIndices = publication.spine.indices.filter { index in
|
||||||
|
let item = publication.spine[index]
|
||||||
|
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
|
||||||
|
}
|
||||||
|
|
||||||
|
var matches: [RDEPUBSearchMatch] = []
|
||||||
|
for spineIndex in buildableSpineIndices {
|
||||||
|
guard let chapter = try? controller.runtime.chapterLoader.loadChapterSynchronouslyForMigration(
|
||||||
|
spineIndex: spineIndex,
|
||||||
|
store: controller.runtime.chapterRuntimeStore
|
||||||
|
) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let chapterData = makeChapterData(from: chapter, chapterIndex: chapter.pages.first?.chapterIndex ?? 0)
|
||||||
|
let source = chapter.typesetAttributedString.string as NSString
|
||||||
|
let fullLength = source.length
|
||||||
|
guard fullLength > 0 else { continue }
|
||||||
|
|
||||||
|
let normalizedHref = publication.resourceResolver.normalizedHref(chapter.href) ?? chapter.href
|
||||||
|
var localMatchIndex = 0
|
||||||
|
var searchRange = NSRange(location: 0, length: fullLength)
|
||||||
|
|
||||||
|
while searchRange.length > 0 {
|
||||||
|
let foundRange = source.range(of: normalizedKeyword, options: [.caseInsensitive], range: searchRange)
|
||||||
|
guard foundRange.location != NSNotFound else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
let progressionDenominator = max(fullLength - 1, 1)
|
||||||
|
let progression = Double(foundRange.location) / Double(progressionDenominator)
|
||||||
|
matches.append(
|
||||||
|
RDEPUBSearchMatch(
|
||||||
|
href: normalizedHref,
|
||||||
|
progression: progression,
|
||||||
|
previewText: previewText(in: source, matchRange: foundRange),
|
||||||
|
localMatchIndex: localMatchIndex,
|
||||||
|
rangeLocation: foundRange.location,
|
||||||
|
rangeLength: foundRange.length,
|
||||||
|
rangeAnchor: chapterData.rangeAnchor(for: foundRange)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
localMatchIndex += 1
|
||||||
|
let nextLocation = foundRange.location + max(foundRange.length, 1)
|
||||||
|
if nextLocation >= fullLength {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeChapterData(
|
||||||
|
from runtimeChapter: RDEPUBRuntimeChapter,
|
||||||
|
chapterIndex: Int
|
||||||
|
) -> RDEPUBChapterData {
|
||||||
|
let textChapter = RDEPUBTextChapter(
|
||||||
|
chapterIndex: chapterIndex,
|
||||||
|
spineIndex: runtimeChapter.spineIndex,
|
||||||
|
href: runtimeChapter.href,
|
||||||
|
title: runtimeChapter.title,
|
||||||
|
attributedContent: runtimeChapter.typesetAttributedString,
|
||||||
|
fragmentOffsets: runtimeChapter.chapterOffsetMap.fragmentOffsets,
|
||||||
|
pageBreakReasons: runtimeChapter.pages.map(\.metadata.breakReason),
|
||||||
|
pages: runtimeChapter.pages
|
||||||
|
)
|
||||||
|
return RDEPUBChapterData(
|
||||||
|
chapter: textChapter,
|
||||||
|
indexTable: RDEPUBTextIndexTable(chapters: [textChapter])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func previewText(in text: NSString, matchRange: NSRange) -> String {
|
||||||
|
let previewRadius = 12
|
||||||
|
let start = max(matchRange.location - previewRadius, 0)
|
||||||
|
let end = min(matchRange.location + matchRange.length + previewRadius, text.length)
|
||||||
|
let range = NSRange(location: start, length: max(end - start, 0))
|
||||||
|
return text.substring(with: range).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
|
||||||
private func advanceSearch(by delta: Int) -> Bool {
|
private func advanceSearch(by delta: Int) -> Bool {
|
||||||
guard let controller else { return false }
|
guard let controller else { return false }
|
||||||
guard var searchState = controller.searchState, !searchState.matches.isEmpty else {
|
guard var searchState = controller.searchState, !searchState.matches.isEmpty else {
|
||||||
@ -162,6 +274,14 @@ final class RDEPUBReaderSearchCoordinator {
|
|||||||
private func pageNumber(for searchMatch: RDEPUBSearchMatch) -> Int? {
|
private func pageNumber(for searchMatch: RDEPUBSearchMatch) -> Int? {
|
||||||
guard let controller else { return nil }
|
guard let controller else { return nil }
|
||||||
if let chapterData = controller.textChapterData(forNormalizedHref: searchMatch.href) {
|
if let chapterData = controller.textChapterData(forNormalizedHref: searchMatch.href) {
|
||||||
|
if let exactPageNumber = exactPageNumber(
|
||||||
|
for: searchMatch,
|
||||||
|
in: chapterData,
|
||||||
|
keyword: controller.searchState?.keyword
|
||||||
|
) {
|
||||||
|
return exactPageNumber
|
||||||
|
}
|
||||||
|
|
||||||
if let pageNumber = chapterData.pageNumber(for: searchMatch) {
|
if let pageNumber = chapterData.pageNumber(for: searchMatch) {
|
||||||
return pageNumber
|
return pageNumber
|
||||||
}
|
}
|
||||||
@ -194,4 +314,39 @@ final class RDEPUBReaderSearchCoordinator {
|
|||||||
bookIdentifier: controller.currentBookIdentifier
|
bookIdentifier: controller.currentBookIdentifier
|
||||||
).map { $0 + 1 }
|
).map { $0 + 1 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func exactPageNumber(
|
||||||
|
for searchMatch: RDEPUBSearchMatch,
|
||||||
|
in chapterData: RDEPUBChapterData,
|
||||||
|
keyword: String?
|
||||||
|
) -> Int? {
|
||||||
|
let normalizedKeyword = keyword?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
guard !normalizedKeyword.isEmpty else { return nil }
|
||||||
|
|
||||||
|
let source = chapterData.attributedContent.string as NSString
|
||||||
|
let fullLength = source.length
|
||||||
|
guard fullLength > 0 else { return nil }
|
||||||
|
|
||||||
|
var localMatchIndex = 0
|
||||||
|
var searchRange = NSRange(location: 0, length: fullLength)
|
||||||
|
|
||||||
|
while searchRange.length > 0 {
|
||||||
|
let foundRange = source.range(of: normalizedKeyword, options: [.caseInsensitive], range: searchRange)
|
||||||
|
guard foundRange.location != NSNotFound else { break }
|
||||||
|
|
||||||
|
if localMatchIndex == searchMatch.localMatchIndex,
|
||||||
|
let page = chapterData.page(containing: foundRange.location) {
|
||||||
|
return page.absolutePageIndex + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
localMatchIndex += 1
|
||||||
|
let nextLocation = foundRange.location + max(foundRange.length, 1)
|
||||||
|
if nextLocation >= fullLength {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,39 @@
|
|||||||
|
// RDEPUBSelectionState.swift
|
||||||
|
// 统一选区状态模型
|
||||||
|
// 收口选区相关状态的冗余表达,降低 view 层与 controller 层各持有一份选区状态
|
||||||
|
// 所带来的维护成本和时序问题。
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 统一选区状态枚举
|
||||||
|
/// 替代原先散落在 view/controller/coordinator 的 `currentSelection != nil` 判断
|
||||||
|
enum RDEPUBSelectionState: Equatable {
|
||||||
|
/// 无选区
|
||||||
|
case idle
|
||||||
|
/// 用户正在拖拽选区(长按手势已开始,尚未松手)
|
||||||
|
case selecting(anchor: Int)
|
||||||
|
/// 选区已完成(用户松手,有有效文本)
|
||||||
|
case selected(RDEPUBSelection)
|
||||||
|
/// 正在执行选区菜单动作(拷贝/高亮/批注),动作完成后回到 idle
|
||||||
|
case committingAction(RDEPUBSelection, action: RDEPUBAnnotationMenuAction)
|
||||||
|
|
||||||
|
/// 当前是否有有效选区(selecting / selected / committingAction 均视为有选区)
|
||||||
|
var hasSelection: Bool {
|
||||||
|
switch self {
|
||||||
|
case .idle:
|
||||||
|
return false
|
||||||
|
case .selecting, .selected, .committingAction:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前选区数据(如有)
|
||||||
|
var selection: RDEPUBSelection? {
|
||||||
|
switch self {
|
||||||
|
case .idle, .selecting:
|
||||||
|
return nil
|
||||||
|
case .selected(let selection), .committingAction(let selection, _):
|
||||||
|
return selection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -114,6 +114,17 @@ public struct RDEPUBReaderConfiguration: Equatable {
|
|||||||
/// 若 writeTotalMs 占比显著(I/O 等待),可试探 cpuCount * 1.25~1.5 以填充 I/O 等待间隙。
|
/// 若 writeTotalMs 占比显著(I/O 等待),可试探 cpuCount * 1.25~1.5 以填充 I/O 等待间隙。
|
||||||
public var metadataParsingConcurrency: Int
|
public var metadataParsingConcurrency: Int
|
||||||
|
|
||||||
|
// MARK: 安全策略
|
||||||
|
|
||||||
|
/// 允许直接打开的外部 URL scheme 集合,默认仅允许 https
|
||||||
|
public var allowedExternalURLSchemes: Set<String>
|
||||||
|
/// 打开外部链接前是否需要用户确认,默认 true
|
||||||
|
public var requiresExternalLinkConfirmation: Bool
|
||||||
|
/// 是否允许正文和离屏分页 WebView 开启 inspectable,默认 false
|
||||||
|
public var allowsInspectableWebViews: Bool
|
||||||
|
/// 是否允许输出完整 WebView 消息体日志,默认 false
|
||||||
|
public var enablesVerboseWebViewLogging: Bool
|
||||||
|
|
||||||
// MARK: 初始化
|
// MARK: 初始化
|
||||||
|
|
||||||
/// 创建阅读器配置,所有参数均提供合理的默认值
|
/// 创建阅读器配置,所有参数均提供合理的默认值
|
||||||
@ -136,6 +147,10 @@ public struct RDEPUBReaderConfiguration: Equatable {
|
|||||||
/// - textRenderingEngine: 文本渲染引擎
|
/// - textRenderingEngine: 文本渲染引擎
|
||||||
/// - onDemandChapterWindowSize: 章节按需加载窗口大小(总章节数 3...15,偶数自动向上取奇)
|
/// - onDemandChapterWindowSize: 章节按需加载窗口大小(总章节数 3...15,偶数自动向上取奇)
|
||||||
/// - metadataParsingConcurrency: 后台元数据解析并发数,默认 CPU 核心数
|
/// - metadataParsingConcurrency: 后台元数据解析并发数,默认 CPU 核心数
|
||||||
|
/// - allowedExternalURLSchemes: 允许直接打开的外部 URL scheme 集合,默认仅 https
|
||||||
|
/// - requiresExternalLinkConfirmation: 打开外部链接前是否需要确认,默认 true
|
||||||
|
/// - allowsInspectableWebViews: 是否允许开启 inspectable,默认 false
|
||||||
|
/// - enablesVerboseWebViewLogging: 是否允许输出完整 WebView 消息体日志,默认 false
|
||||||
public init(
|
public init(
|
||||||
fontSize: CGFloat = 15,
|
fontSize: CGFloat = 15,
|
||||||
lineHeightMultiple: CGFloat = 1.6,
|
lineHeightMultiple: CGFloat = 1.6,
|
||||||
@ -156,7 +171,11 @@ public struct RDEPUBReaderConfiguration: Equatable {
|
|||||||
fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode = .automatic,
|
fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode = .automatic,
|
||||||
textRenderingEngine: RDEPUBTextRenderingEngine = .dtCoreText,
|
textRenderingEngine: RDEPUBTextRenderingEngine = .dtCoreText,
|
||||||
onDemandChapterWindowSize: Int = 3,
|
onDemandChapterWindowSize: Int = 3,
|
||||||
metadataParsingConcurrency: Int = ProcessInfo.processInfo.activeProcessorCount
|
metadataParsingConcurrency: Int = ProcessInfo.processInfo.activeProcessorCount,
|
||||||
|
allowedExternalURLSchemes: Set<String> = ["https"],
|
||||||
|
requiresExternalLinkConfirmation: Bool = true,
|
||||||
|
allowsInspectableWebViews: Bool = false,
|
||||||
|
enablesVerboseWebViewLogging: Bool = false
|
||||||
) {
|
) {
|
||||||
self.fontSize = fontSize
|
self.fontSize = fontSize
|
||||||
self.lineHeightMultiple = lineHeightMultiple
|
self.lineHeightMultiple = lineHeightMultiple
|
||||||
@ -178,6 +197,10 @@ public struct RDEPUBReaderConfiguration: Equatable {
|
|||||||
self.textRenderingEngine = textRenderingEngine
|
self.textRenderingEngine = textRenderingEngine
|
||||||
self.onDemandChapterWindowSize = Self.normalizedChapterWindowSize(onDemandChapterWindowSize)
|
self.onDemandChapterWindowSize = Self.normalizedChapterWindowSize(onDemandChapterWindowSize)
|
||||||
self.metadataParsingConcurrency = max(1, metadataParsingConcurrency)
|
self.metadataParsingConcurrency = max(1, metadataParsingConcurrency)
|
||||||
|
self.allowedExternalURLSchemes = allowedExternalURLSchemes
|
||||||
|
self.requiresExternalLinkConfirmation = requiresExternalLinkConfirmation
|
||||||
|
self.allowsInspectableWebViews = allowsInspectableWebViews
|
||||||
|
self.enablesVerboseWebViewLogging = enablesVerboseWebViewLogging
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 默认配置实例,使用所有参数的默认值
|
/// 默认配置实例,使用所有参数的默认值
|
||||||
|
|||||||
@ -239,6 +239,7 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
|
|||||||
|
|
||||||
let selectedPreset = ThemePreset.allCases.first(where: { $0.theme == currentConfiguration.theme }) ?? .light
|
let selectedPreset = ThemePreset.allCases.first(where: { $0.theme == currentConfiguration.theme }) ?? .light
|
||||||
updateThemeSelection(selectedPreset)
|
updateThemeSelection(selectedPreset)
|
||||||
|
updateControlAccessibilityValues()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applyTheme(_ theme: RDEPUBReaderTheme) {
|
private func applyTheme(_ theme: RDEPUBReaderTheme) {
|
||||||
@ -279,9 +280,17 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
|
|||||||
let isSelected = button.tag == preset.rawValue
|
let isSelected = button.tag == preset.rawValue
|
||||||
button.layer.borderWidth = isSelected ? 2 : 1
|
button.layer.borderWidth = isSelected ? 2 : 1
|
||||||
button.layer.borderColor = isSelected ? currentConfiguration.theme.toolControlTextColor.cgColor : currentConfiguration.theme.toolControlBorderUnselectColor.cgColor
|
button.layer.borderColor = isSelected ? currentConfiguration.theme.toolControlTextColor.cgColor : currentConfiguration.theme.toolControlBorderUnselectColor.cgColor
|
||||||
|
button.accessibilityValue = isSelected ? "selected" : "unselected"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func updateControlAccessibilityValues() {
|
||||||
|
fontChoiceControl.accessibilityValue = fontChoiceControl.titleForSegment(at: fontChoiceControl.selectedSegmentIndex)
|
||||||
|
lineHeightControl.accessibilityValue = lineHeightControl.titleForSegment(at: lineHeightControl.selectedSegmentIndex)
|
||||||
|
columnCountControl.accessibilityValue = columnCountControl.titleForSegment(at: columnCountControl.selectedSegmentIndex)
|
||||||
|
displayTypeControl.accessibilityValue = displayTypeControl.titleForSegment(at: displayTypeControl.selectedSegmentIndex)
|
||||||
|
}
|
||||||
|
|
||||||
@objc private func doneAction() {
|
@objc private func doneAction() {
|
||||||
dismiss(animated: true)
|
dismiss(animated: true)
|
||||||
}
|
}
|
||||||
@ -312,6 +321,7 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
|
|||||||
let choice = choices[index]
|
let choice = choices[index]
|
||||||
guard choice != currentConfiguration.fontChoice else { return }
|
guard choice != currentConfiguration.fontChoice else { return }
|
||||||
currentConfiguration.fontChoice = choice
|
currentConfiguration.fontChoice = choice
|
||||||
|
updateControlAccessibilityValues()
|
||||||
onFontChoiceChange?(choice)
|
onFontChoiceChange?(choice)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -319,12 +329,14 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
|
|||||||
let index = max(0, min(control.selectedSegmentIndex, lineHeightValues.count - 1))
|
let index = max(0, min(control.selectedSegmentIndex, lineHeightValues.count - 1))
|
||||||
let value = lineHeightValues[index]
|
let value = lineHeightValues[index]
|
||||||
currentConfiguration.lineHeightMultiple = value
|
currentConfiguration.lineHeightMultiple = value
|
||||||
|
updateControlAccessibilityValues()
|
||||||
onLineHeightChange?(value)
|
onLineHeightChange?(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func columnCountChanged(_ control: UISegmentedControl) {
|
@objc private func columnCountChanged(_ control: UISegmentedControl) {
|
||||||
let numberOfColumns = control.selectedSegmentIndex == 1 ? 2 : 1
|
let numberOfColumns = control.selectedSegmentIndex == 1 ? 2 : 1
|
||||||
currentConfiguration.numberOfColumns = numberOfColumns
|
currentConfiguration.numberOfColumns = numberOfColumns
|
||||||
|
updateControlAccessibilityValues()
|
||||||
onColumnCountChange?(numberOfColumns)
|
onColumnCountChange?(numberOfColumns)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -339,6 +351,7 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
|
|||||||
displayType = .pageCurl
|
displayType = .pageCurl
|
||||||
}
|
}
|
||||||
currentConfiguration.displayType = displayType
|
currentConfiguration.displayType = displayType
|
||||||
|
updateControlAccessibilityValues()
|
||||||
onDisplayTypeChange?(displayType)
|
onDisplayTypeChange?(displayType)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,9 @@ import UIKit
|
|||||||
|
|
||||||
/// 原生文本渲染路径的批注覆盖层,负责绘制用户高亮、搜索命中高亮和当前选区装饰。
|
/// 原生文本渲染路径的批注覆盖层,负责绘制用户高亮、搜索命中高亮和当前选区装饰。
|
||||||
final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
|
final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
|
||||||
|
private let normalSearchColor = UIColor(red: 0.21, green: 0.48, blue: 0.95, alpha: 0.16)
|
||||||
|
private let activeSearchColor = UIColor(red: 0.14, green: 0.42, blue: 0.95, alpha: 0.34)
|
||||||
|
|
||||||
/// 将用户高亮批注以富文本属性形式应用到页面内容上
|
/// 将用户高亮批注以富文本属性形式应用到页面内容上
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - highlights: 高亮批注数组
|
/// - highlights: 高亮批注数组
|
||||||
@ -58,8 +61,6 @@ final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
|
|||||||
) {
|
) {
|
||||||
guard let searchState else { return }
|
guard let searchState else { return }
|
||||||
|
|
||||||
let normalColor = UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.55)
|
|
||||||
let activeColor = UIColor(red: 255 / 255, green: 159 / 255, blue: 67 / 255, alpha: 0.75)
|
|
||||||
let pageRange = absoluteOffsetRange(for: page)
|
let pageRange = absoluteOffsetRange(for: page)
|
||||||
let pageStart = pageRange.lowerBound
|
let pageStart = pageRange.lowerBound
|
||||||
let pageEndExclusive = pageRange.upperBound
|
let pageEndExclusive = pageRange.upperBound
|
||||||
@ -72,7 +73,7 @@ final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
|
|||||||
guard overlapStart < overlapEnd else { continue }
|
guard overlapStart < overlapEnd else { continue }
|
||||||
|
|
||||||
let relativeRange = NSRange(location: Int(overlapStart - contentBaseOffset), length: Int(overlapEnd - overlapStart))
|
let relativeRange = NSRange(location: Int(overlapStart - contentBaseOffset), length: Int(overlapEnd - overlapStart))
|
||||||
let color = match == searchState.currentMatch ? activeColor : normalColor
|
let color = match == searchState.currentMatch ? activeSearchColor : normalSearchColor
|
||||||
content.addAttribute(.backgroundColor, value: color, range: relativeRange)
|
content.addAttribute(.backgroundColor, value: color, range: relativeRange)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -97,9 +98,6 @@ final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
|
|||||||
let pageEndExclusive = pageRange.upperBound
|
let pageEndExclusive = pageRange.upperBound
|
||||||
|
|
||||||
if let searchState {
|
if let searchState {
|
||||||
let normalColor = UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.55)
|
|
||||||
let activeColor = UIColor(red: 255 / 255, green: 159 / 255, blue: 67 / 255, alpha: 0.75)
|
|
||||||
|
|
||||||
for match in searchState.matches {
|
for match in searchState.matches {
|
||||||
guard let matchStart = match.rangeLocation else { continue }
|
guard let matchStart = match.rangeLocation else { continue }
|
||||||
let matchEnd = matchStart + match.rangeLength
|
let matchEnd = matchStart + match.rangeLength
|
||||||
@ -113,7 +111,7 @@ final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
|
|||||||
|
|
||||||
let isActive = match == searchState.currentMatch
|
let isActive = match == searchState.currentMatch
|
||||||
let kind: RDEPUBTextOverlayDecoration.Kind = isActive ? .activeSearch : .search
|
let kind: RDEPUBTextOverlayDecoration.Kind = isActive ? .activeSearch : .search
|
||||||
let color = isActive ? activeColor : normalColor
|
let color = isActive ? activeSearchColor : normalSearchColor
|
||||||
background.append(RDEPUBTextOverlayDecoration(kind: kind, absoluteRange: absoluteRange, rects: rects, color: color))
|
background.append(RDEPUBTextOverlayDecoration(kind: kind, absoluteRange: absoluteRange, rects: rects, color: color))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,11 @@ protocol RDEPUBTextContentViewDelegate: AnyObject {
|
|||||||
didRequestSelectionAction action: RDEPUBAnnotationMenuAction,
|
didRequestSelectionAction action: RDEPUBAnnotationMenuAction,
|
||||||
selection: RDEPUBSelection?
|
selection: RDEPUBSelection?
|
||||||
)
|
)
|
||||||
|
func textContentView(
|
||||||
|
_ contentView: RDEPUBTextContentView,
|
||||||
|
didActivateAttachmentText text: String,
|
||||||
|
sourceRect: CGRect
|
||||||
|
)
|
||||||
func textContentView(
|
func textContentView(
|
||||||
_ contentView: RDEPUBTextContentView,
|
_ contentView: RDEPUBTextContentView,
|
||||||
didRequestHighlightActions highlight: RDEPUBHighlight,
|
didRequestHighlightActions highlight: RDEPUBHighlight,
|
||||||
@ -33,6 +38,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
|||||||
private var currentSelection: RDEPUBSelection?
|
private var currentSelection: RDEPUBSelection?
|
||||||
private var menuSelection: RDEPUBSelection?
|
private var menuSelection: RDEPUBSelection?
|
||||||
private var currentHighlights: [RDEPUBHighlight] = []
|
private var currentHighlights: [RDEPUBHighlight] = []
|
||||||
|
private var currentSearchState: RDEPUBSearchState?
|
||||||
weak var delegate: RDEPUBTextContentViewDelegate?
|
weak var delegate: RDEPUBTextContentViewDelegate?
|
||||||
|
|
||||||
#if canImport(DTCoreText)
|
#if canImport(DTCoreText)
|
||||||
@ -188,6 +194,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
|||||||
currentSelection = nil
|
currentSelection = nil
|
||||||
menuSelection = nil
|
menuSelection = nil
|
||||||
currentHighlights = highlights
|
currentHighlights = highlights
|
||||||
|
currentSearchState = searchState
|
||||||
selectionController.clearSelection(renderView: coreTextRenderView)
|
selectionController.clearSelection(renderView: coreTextRenderView)
|
||||||
contentInsets = configuration.reflowableContentInsets
|
contentInsets = configuration.reflowableContentInsets
|
||||||
backgroundColor = configuration.theme.contentBackgroundColor
|
backgroundColor = configuration.theme.contentBackgroundColor
|
||||||
@ -244,14 +251,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
|||||||
overlayView.configure(page: page, selectionColor: overlayView.selectionColor, snapshot: interactionController.snapshot)
|
overlayView.configure(page: page, selectionColor: overlayView.selectionColor, snapshot: interactionController.snapshot)
|
||||||
#if canImport(DTCoreText)
|
#if canImport(DTCoreText)
|
||||||
backgroundOverlayView.configure(page: page, selectionColor: overlayView.selectionColor, snapshot: interactionController.snapshot)
|
backgroundOverlayView.configure(page: page, selectionColor: overlayView.selectionColor, snapshot: interactionController.snapshot)
|
||||||
let (bgDecorations, fgDecorations) = overlayView.buildDecorations(
|
|
||||||
page: page,
|
|
||||||
highlights: [],
|
|
||||||
searchState: searchState,
|
|
||||||
interactionController: interactionController
|
|
||||||
)
|
|
||||||
backgroundOverlayView.applyDecorations(bgDecorations)
|
|
||||||
overlayView.applyDecorations(fgDecorations)
|
|
||||||
#endif
|
#endif
|
||||||
updateAccessibilityDecorationSummary()
|
updateAccessibilityDecorationSummary()
|
||||||
|
|
||||||
@ -262,6 +261,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
|||||||
func clearSelection() {
|
func clearSelection() {
|
||||||
currentSelection = nil
|
currentSelection = nil
|
||||||
menuSelection = nil
|
menuSelection = nil
|
||||||
|
currentSearchState = nil
|
||||||
panGestureRecognizer.isEnabled = false
|
panGestureRecognizer.isEnabled = false
|
||||||
selectionController.clearSelection(renderView: coreTextRenderView)
|
selectionController.clearSelection(renderView: coreTextRenderView)
|
||||||
overlayView.clearSelection()
|
overlayView.clearSelection()
|
||||||
@ -487,6 +487,17 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
|||||||
interactionController.configure(layoutFrame: layoutFrame, page: page)
|
interactionController.configure(layoutFrame: layoutFrame, page: page)
|
||||||
overlayView.updateSnapshot(interactionController.snapshot)
|
overlayView.updateSnapshot(interactionController.snapshot)
|
||||||
backgroundOverlayView.updateSnapshot(interactionController.snapshot)
|
backgroundOverlayView.updateSnapshot(interactionController.snapshot)
|
||||||
|
|
||||||
|
if let page = currentPage {
|
||||||
|
let (bgDecorations, fgDecorations) = overlayView.buildDecorations(
|
||||||
|
page: page,
|
||||||
|
highlights: [],
|
||||||
|
searchState: currentSearchState,
|
||||||
|
interactionController: interactionController
|
||||||
|
)
|
||||||
|
backgroundOverlayView.applyDecorations(bgDecorations)
|
||||||
|
overlayView.applyDecorations(fgDecorations)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@ -539,6 +550,11 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
|||||||
clearSelection()
|
clearSelection()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if let attachmentText = attachmentText(at: point),
|
||||||
|
let sourceRect = attachmentSourceRect(at: point, fallbackPoint: point) {
|
||||||
|
delegate?.textContentView(self, didActivateAttachmentText: attachmentText, sourceRect: sourceRect)
|
||||||
|
return
|
||||||
|
}
|
||||||
guard let highlight = highlight(at: point),
|
guard let highlight = highlight(at: point),
|
||||||
let sourceRect = highlightSourceRect(for: highlight, fallbackPoint: point) else {
|
let sourceRect = highlightSourceRect(for: highlight, fallbackPoint: point) else {
|
||||||
return
|
return
|
||||||
@ -621,6 +637,41 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
|||||||
return overlayView.convert(fallbackRect, to: self)
|
return overlayView.convert(fallbackRect, to: self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func attachmentText(at point: CGPoint) -> String? {
|
||||||
|
guard let page = currentPage else { return nil }
|
||||||
|
guard let attachmentRange = interactionController.snapshot?.attachment(at: point)?.stringRange else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
guard page.chapterContent.length > attachmentRange.location else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let attachment = page.chapterContent.attribute(.attachment, at: attachmentRange.location, effectiveRange: nil)
|
||||||
|
|
||||||
|
#if canImport(DTCoreText)
|
||||||
|
if let textAttachment = attachment as? DTTextAttachment,
|
||||||
|
let altText = (textAttachment.attributes["alt"] as? String)?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!altText.isEmpty {
|
||||||
|
return altText
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
if let fileAttachment = attachment as? NSTextAttachment,
|
||||||
|
let altText = fileAttachment.accessibilityLabel?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!altText.isEmpty {
|
||||||
|
return altText
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func attachmentSourceRect(at point: CGPoint, fallbackPoint: CGPoint) -> CGRect? {
|
||||||
|
if let attachmentRect = interactionController.snapshot?.attachment(at: point)?.frame {
|
||||||
|
return overlayView.convert(attachmentRect, to: self)
|
||||||
|
}
|
||||||
|
let fallbackRect = CGRect(origin: fallbackPoint, size: CGSize(width: 1, height: 1))
|
||||||
|
return overlayView.convert(fallbackRect, to: self)
|
||||||
|
}
|
||||||
|
|
||||||
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||||
if gestureRecognizer === panGestureRecognizer {
|
if gestureRecognizer === panGestureRecognizer {
|
||||||
return selectionController.isSelecting
|
return selectionController.isSelecting
|
||||||
@ -633,7 +684,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
let point = tapGestureRecognizer.location(in: overlayView)
|
let point = tapGestureRecognizer.location(in: overlayView)
|
||||||
return highlight(at: point) != nil
|
return attachmentText(at: point) != nil || highlight(at: point) != nil
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@ -140,12 +140,25 @@ final class RDEPUBTextSelectionController: NSObject {
|
|||||||
let totalLength = max(page.chapterContent.length - 1, 1)
|
let totalLength = max(page.chapterContent.length - 1, 1)
|
||||||
let globalStart = absoluteRange.location
|
let globalStart = absoluteRange.location
|
||||||
let globalEnd = absoluteRange.location + absoluteRange.length
|
let globalEnd = absoluteRange.location + absoluteRange.length
|
||||||
|
let startAnchor = RDEPUBTextAnchor(
|
||||||
|
fileIndex: page.spineIndex,
|
||||||
|
row: 0,
|
||||||
|
column: 0,
|
||||||
|
chapterOffset: globalStart
|
||||||
|
)
|
||||||
|
let endAnchor = RDEPUBTextAnchor(
|
||||||
|
fileIndex: page.spineIndex,
|
||||||
|
row: 0,
|
||||||
|
column: 0,
|
||||||
|
chapterOffset: globalEnd
|
||||||
|
)
|
||||||
return RDEPUBSelection(
|
return RDEPUBSelection(
|
||||||
location: RDEPUBLocation(
|
location: RDEPUBLocation(
|
||||||
href: page.href,
|
href: page.href,
|
||||||
progression: Double(globalStart) / Double(totalLength),
|
progression: Double(globalStart) / Double(totalLength),
|
||||||
lastProgression: Double(max(globalEnd - 1, globalStart)) / Double(totalLength),
|
lastProgression: Double(max(globalEnd - 1, globalStart)) / Double(totalLength),
|
||||||
fragment: nil
|
fragment: nil,
|
||||||
|
rangeAnchor: RDEPUBTextRangeAnchor(start: startAnchor, end: endAnchor)
|
||||||
),
|
),
|
||||||
text: selectedText,
|
text: selectedText,
|
||||||
rangeInfo: RDEPUBTextOffsetRangeInfo(href: page.href, start: globalStart, end: globalEnd).jsonString()
|
rangeInfo: RDEPUBTextOffsetRangeInfo(href: page.href, start: globalStart, end: globalEnd).jsonString()
|
||||||
|
|||||||
@ -27,18 +27,24 @@ SUMMARY_SCRIPT="$ROOT_DIR/scripts/summarize_ui_results.py"
|
|||||||
TARGET_NAME="ReadViewDemoUITests"
|
TARGET_NAME="ReadViewDemoUITests"
|
||||||
|
|
||||||
QUICK_TESTS=(
|
QUICK_TESTS=(
|
||||||
|
ArchivePathValidationTests
|
||||||
|
BookmarkChromeStateTests
|
||||||
BookmarkManagementTests
|
BookmarkManagementTests
|
||||||
BookmarkTests
|
BookmarkTests
|
||||||
|
CacheHardeningTests
|
||||||
DisplayTypeTests
|
DisplayTypeTests
|
||||||
ErrorAndEdgeCaseTests
|
ErrorAndEdgeCaseTests
|
||||||
|
ExternalLinkPolicyTests
|
||||||
HighlightsManagementTests
|
HighlightsManagementTests
|
||||||
LocationPersistenceTests
|
LocationPersistenceTests
|
||||||
|
NavigationBackwardTests
|
||||||
PageNavigationTests
|
PageNavigationTests
|
||||||
ReaderAnnotationExtendedTests
|
ReaderAnnotationExtendedTests
|
||||||
ReaderAnnotationTests
|
ReaderAnnotationTests
|
||||||
ReaderOpenCloseTests
|
ReaderOpenCloseTests
|
||||||
ReaderToolbarTests
|
ReaderToolbarTests
|
||||||
SearchTests
|
SearchTests
|
||||||
|
SelectionAnnotateTests
|
||||||
SelectionMenuTests
|
SelectionMenuTests
|
||||||
SettingsEffectTests
|
SettingsEffectTests
|
||||||
SettingsExtendedTests
|
SettingsExtendedTests
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user