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:
|
||||
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
|
||||
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
|
||||
RDReaderView: 617ec758a5db3c10024acf83b110465ea8343b5b
|
||||
RDReaderView: 54205c82c62ea6c674b43c833642d2643c98c573
|
||||
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
|
||||
SSAlertSwift: aad8dc0c20b36fcffe700b81d7be89d60c7ba4f1
|
||||
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
|
||||
|
||||
@ -27,15 +27,9 @@
|
||||
"ZIPFoundation": [
|
||||
"~> 0.9"
|
||||
],
|
||||
"DTCoreText": [
|
||||
|
||||
],
|
||||
"SnapKit": [
|
||||
|
||||
],
|
||||
"SSAlertSwift": [
|
||||
|
||||
]
|
||||
"DTCoreText": [],
|
||||
"SnapKit": [],
|
||||
"SSAlertSwift": []
|
||||
},
|
||||
"requires_arc": true,
|
||||
"swift_version": "5.10"
|
||||
|
||||
2
ReadViewDemo/Pods/Manifest.lock
generated
2
ReadViewDemo/Pods/Manifest.lock
generated
@ -43,7 +43,7 @@ EXTERNAL SOURCES:
|
||||
SPEC CHECKSUMS:
|
||||
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
|
||||
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
|
||||
RDReaderView: 617ec758a5db3c10024acf83b110465ea8343b5b
|
||||
RDReaderView: 54205c82c62ea6c674b43c833642d2643c98c573
|
||||
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
|
||||
SSAlertSwift: aad8dc0c20b36fcffe700b81d7be89d60c7ba4f1
|
||||
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 */; };
|
||||
1A2B3C4D00000014AABBCC01 /* FanrenParseTimeTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000013AABBCC01 /* FanrenParseTimeTest.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 */; };
|
||||
4509ED928F228F43888E063D /* ReaderToolbarTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.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 */; };
|
||||
DE1437A969DA1C5F0CBB047D /* Pods_ReadViewDemo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 792DF85CE4A3DD80D67843C7 /* Pods_ReadViewDemo.framework */; };
|
||||
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 */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@ -61,9 +61,9 @@
|
||||
8FFD606A5A1CBDCC3CA87F1C /* ReadViewDemoUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ReadViewDemoUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderOpenCloseTests.swift; sourceTree = "<group>"; };
|
||||
BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderToolbarTests.swift; sourceTree = "<group>"; };
|
||||
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; };
|
||||
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 */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
@ -18,6 +18,9 @@ final class ViewController: UIViewController {
|
||||
let windowSize: Int?
|
||||
let concurrency: Int?
|
||||
let clearsCache: Bool
|
||||
let corruptsCache: Bool
|
||||
let allowsInspectableWebViews: Bool
|
||||
let enablesVerboseWebLogs: Bool
|
||||
let searchKeyword: String?
|
||||
|
||||
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 concurrency = value(after: "--demo-concurrency").flatMap(Int.init)
|
||||
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")
|
||||
|
||||
return LaunchAutomationPlan(
|
||||
@ -69,6 +75,9 @@ final class ViewController: UIViewController {
|
||||
windowSize: windowSize,
|
||||
concurrency: concurrency,
|
||||
clearsCache: clearsCache,
|
||||
corruptsCache: corruptsCache,
|
||||
allowsInspectableWebViews: allowsInspectableWebViews,
|
||||
enablesVerboseWebLogs: enablesVerboseWebLogs,
|
||||
searchKeyword: searchKeyword
|
||||
)
|
||||
}
|
||||
@ -262,6 +271,9 @@ final class ViewController: UIViewController {
|
||||
if launchAutomationPlan.clearsCache {
|
||||
clearChapterSummaryCache()
|
||||
}
|
||||
if launchAutomationPlan.corruptsCache {
|
||||
corruptChapterSummaryCache()
|
||||
}
|
||||
|
||||
var configuration = RDEPUBReaderConfiguration.default
|
||||
if let displayType = launchAutomationPlan.displayType {
|
||||
@ -273,6 +285,8 @@ final class ViewController: UIViewController {
|
||||
if let concurrency = launchAutomationPlan.concurrency {
|
||||
configuration.metadataParsingConcurrency = concurrency
|
||||
}
|
||||
configuration.allowsInspectableWebViews = launchAutomationPlan.allowsInspectableWebViews
|
||||
configuration.enablesVerboseWebViewLogging = launchAutomationPlan.enablesVerboseWebLogs
|
||||
_ = openBook(book, configuration: configuration, automationPlan: launchAutomationPlan)
|
||||
}
|
||||
|
||||
@ -300,6 +314,19 @@ final class ViewController: UIViewController {
|
||||
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 {
|
||||
|
||||
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 parseMs: Int? { fields["parseMs"].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 {
|
||||
|
||||
@ -9,6 +9,9 @@ extension XCUIApplication {
|
||||
windowSize: Int? = nil,
|
||||
concurrency: Int? = nil,
|
||||
clearsCache: Bool = false,
|
||||
corruptsCache: Bool = false,
|
||||
allowsInspectableWebViews: Bool = false,
|
||||
enablesVerboseWebLogs: Bool = false,
|
||||
searchKeyword: String? = nil
|
||||
) {
|
||||
var args = ["--demo-book-title", bookTitleQuery]
|
||||
@ -18,6 +21,15 @@ extension XCUIApplication {
|
||||
if clearsCache {
|
||||
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 {
|
||||
args += ["--demo-display-type", displayType]
|
||||
}
|
||||
@ -52,17 +64,28 @@ extension XCUIApplication {
|
||||
}
|
||||
}
|
||||
|
||||
func showReaderChromeIfNeeded() {
|
||||
if buttons[IDs.readerBack].exists && buttons[IDs.readerSettings].exists {
|
||||
func showReaderChromeIfNeeded(
|
||||
requiredButtonIDs: [String] = [IDs.readerBack, IDs.readerSettings],
|
||||
timeout: TimeInterval = 5
|
||||
) {
|
||||
if readerChromeIsVisible(requiredButtonIDs: requiredButtonIDs) {
|
||||
return
|
||||
}
|
||||
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
let content = otherElements[IDs.readerContent]
|
||||
if content.waitForExistence(timeout: 3) {
|
||||
if content.waitForExistence(timeout: 1) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func hideReaderChromeIfNeeded() {
|
||||
@ -94,4 +117,39 @@ extension XCUIApplication {
|
||||
|
||||
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]
|
||||
XCTAssertTrue(bookmarkButton.waitForExistence(timeout: 3), "书签按钮不存在")
|
||||
bookmarkButton.tap()
|
||||
app.waitForReaderState(containing: "bookmarks=1", timeout: 5)
|
||||
app.waitForDemoReaderState(timeout: 5, description: "bookmarks=1") { $0.bookmarks == 1 }
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
let bookmarksButton = app.buttons[IDs.readerBookmarks]
|
||||
@ -47,7 +47,7 @@ final class BookmarkManagementTests: XCTestCase {
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
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.buttons[IDs.readerBookmarks].tap()
|
||||
|
||||
@ -78,6 +78,39 @@ final class BookmarkManagementTests: XCTestCase {
|
||||
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(
|
||||
in table: XCUIElement,
|
||||
minimum: Int,
|
||||
|
||||
@ -15,12 +15,37 @@ final class DisplayTypeTests: XCTestCase {
|
||||
func testOpenWithHorizontalScroll() {
|
||||
app.launchAndOpenSampleBook(displayType: "scroll")
|
||||
app.waitForReader()
|
||||
app.waitForReaderState(containing: "display=horizontalScroll")
|
||||
app.waitForDemoReaderState(timeout: 8, description: "display=horizontalScroll") { $0.display == "horizontalScroll" }
|
||||
}
|
||||
|
||||
func testOpenWithVerticalScroll() {
|
||||
app.launchAndOpenSampleBook(displayType: "vertical")
|
||||
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]
|
||||
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 {
|
||||
@ -42,7 +42,7 @@ final class ErrorAndEdgeCaseTests: XCTestCase {
|
||||
|
||||
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 {
|
||||
@ -66,4 +66,85 @@ final class ErrorAndEdgeCaseTests: XCTestCase {
|
||||
|
||||
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() }
|
||||
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()
|
||||
XCTAssertTrue(app.buttons[IDs.readerHighlights].waitForExistence(timeout: 3), "高亮列表按钮不存在")
|
||||
|
||||
@ -50,9 +50,10 @@ final class LargeBookOnDemandTests: XCTestCase {
|
||||
app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, resetsReaderState: true)
|
||||
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
|
||||
}
|
||||
XCTAssertGreaterThan(firstFullState.cacheFiles ?? 0, 0, "首次全量解析后应生成章节摘要缓存")
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
XCTAssertTrue(app.buttons[IDs.readerBack].waitForExistence(timeout: 5), "返回按钮不存在")
|
||||
@ -68,6 +69,47 @@ final class LargeBookOnDemandTests: XCTestCase {
|
||||
|
||||
XCTAssertEqual(reopenedState.pagination, "full")
|
||||
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 {
|
||||
@ -146,7 +188,7 @@ final class LargeBookOnDemandTests: XCTestCase {
|
||||
func testLargeBookContinuousPagingExtendsKnownPageMap() throws {
|
||||
app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, displayType: "scroll", resetsReaderState: true)
|
||||
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
|
||||
state.mode == "bookPageMap" && state.knownPages != nil && state.page != nil
|
||||
|
||||
@ -57,7 +57,7 @@ final class LocationPersistenceTests: XCTestCase {
|
||||
|
||||
let paging = app.collectionViews[IDs.readerPaging]
|
||||
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()
|
||||
if app.buttons[IDs.readerSettings].waitForExistence(timeout: 3) { app.buttons[IDs.readerSettings].tap() }
|
||||
@ -70,6 +70,37 @@ final class LocationPersistenceTests: XCTestCase {
|
||||
app.launchAndOpenSampleBook()
|
||||
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() {
|
||||
app.launchAndOpenSampleBook(displayType: "scroll")
|
||||
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]
|
||||
XCTAssertTrue(paging.waitForExistence(timeout: 5), "分页滚动视图未出现")
|
||||
@ -27,7 +27,7 @@ final class PageNavigationTests: XCTestCase {
|
||||
func testPagingViewExistsInVerticalScrollMode() {
|
||||
app.launchAndOpenSampleBook(displayType: "vertical")
|
||||
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]
|
||||
XCTAssertTrue(paging.waitForExistence(timeout: 5), "分页滚动视图未出现")
|
||||
@ -36,7 +36,7 @@ final class PageNavigationTests: XCTestCase {
|
||||
func testSwipeInHorizontalScrollMode() {
|
||||
app.launchAndOpenSampleBook(displayType: "scroll")
|
||||
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]
|
||||
XCTAssertTrue(paging.waitForExistence(timeout: 5))
|
||||
@ -51,4 +51,26 @@ final class PageNavigationTests: XCTestCase {
|
||||
}
|
||||
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, "设置按钮未出现")
|
||||
}
|
||||
|
||||
func testSelectionTextElementExists() {
|
||||
func testSelectionTextElementExists() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader()
|
||||
|
||||
let selectionText = app.otherElements[IDs.readerSelectionText].firstMatch
|
||||
XCTAssertTrue(selectionText.waitForExistence(timeout: 5), "文本选区元素未出现")
|
||||
_ = try app.requireSelectionText()
|
||||
}
|
||||
|
||||
func testReaderStateReportsPageInfo() {
|
||||
|
||||
@ -7,22 +7,20 @@ final class ReaderAnnotationTests: XCTestCase {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
func testSelectionMenuCreatesHighlight() {
|
||||
app.launchAndOpenSampleBook(pageNumber: 2)
|
||||
func testSelectionMenuCreatesHighlight() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader()
|
||||
app.waitForReaderPage(2)
|
||||
app.hideReaderChromeIfNeeded()
|
||||
|
||||
let content = app.otherElements[IDs.readerSelectionText].firstMatch
|
||||
XCTAssertTrue(content.waitForExistence(timeout: 5), "阅读内容区域未出现")
|
||||
let content = try app.requireSelectionText()
|
||||
|
||||
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 end = content.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.5))
|
||||
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 highlightButton = app.buttons[IDs.readerSelectionHighlight]
|
||||
@ -34,25 +32,43 @@ final class ReaderAnnotationTests: XCTestCase {
|
||||
XCTFail("选中文本后未出现高亮菜单")
|
||||
}
|
||||
|
||||
app.waitForReaderState(containing: "highlights=1", timeout: 8)
|
||||
app.waitForDemoReaderState(timeout: 8, description: "highlights=1") { $0.highlights == 1 }
|
||||
}
|
||||
|
||||
func testLongPressSelectionDoesNotShowToolbars() {
|
||||
app.launchAndOpenSampleBook(pageNumber: 2)
|
||||
func testLongPressSelectionDoesNotShowToolbars() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader()
|
||||
app.waitForReaderPage(2)
|
||||
app.hideReaderChromeIfNeeded()
|
||||
|
||||
let content = app.otherElements[IDs.readerSelectionText].firstMatch
|
||||
XCTAssertTrue(content.waitForExistence(timeout: 5), "阅读内容区域未出现")
|
||||
let content = try app.requireSelectionText()
|
||||
|
||||
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 end = content.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.5))
|
||||
start.press(forDuration: 0.8, thenDragTo: end)
|
||||
|
||||
app.waitForReaderState(containing: "selection=1", timeout: 8)
|
||||
app.waitForReaderState(containing: "toolbar=hidden", timeout: 3)
|
||||
app.waitForDemoReaderState(timeout: 8, description: "selection=1") { $0.selection == 1 }
|
||||
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", "搜索状态应在工具栏恢复后保留")
|
||||
}
|
||||
|
||||
// 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: - 辅助方法
|
||||
|
||||
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["拷贝"]
|
||||
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 {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
app.waitForReaderState(containing: "page=", timeout: 5)
|
||||
app.waitForDemoReaderState(timeout: 5, description: "page exists") { $0.page != nil }
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
XCTAssertTrue(app.buttons[IDs.readerSettings].waitForExistence(timeout: 3), "设置按钮不存在")
|
||||
@ -23,7 +23,7 @@ final class SettingsEffectTests: XCTestCase {
|
||||
XCTAssertTrue(app.buttons[IDs.settingsDone].waitForExistence(timeout: 3), "完成按钮不存在")
|
||||
app.buttons[IDs.settingsDone].tap()
|
||||
|
||||
app.waitForReaderState(containing: "reader=opened", timeout: 8)
|
||||
app.waitForDemoReaderState(timeout: 8, description: "reader=opened") { $0.isOpened }
|
||||
}
|
||||
|
||||
func testThemeSwitchChangesBackground() throws {
|
||||
@ -59,7 +59,7 @@ final class SettingsEffectTests: XCTestCase {
|
||||
}
|
||||
|
||||
app.buttons[IDs.settingsDone].tap()
|
||||
app.waitForReaderState(containing: "reader=opened", timeout: 8)
|
||||
app.waitForDemoReaderState(timeout: 8, description: "reader=opened") { $0.isOpened }
|
||||
}
|
||||
|
||||
func testLineHeightChangeUpdatesContent() throws {
|
||||
@ -76,13 +76,13 @@ final class SettingsEffectTests: XCTestCase {
|
||||
}
|
||||
|
||||
app.buttons[IDs.settingsDone].tap()
|
||||
app.waitForReaderState(containing: "reader=opened", timeout: 8)
|
||||
app.waitForDemoReaderState(timeout: 8, description: "reader=opened") { $0.isOpened }
|
||||
}
|
||||
|
||||
func testDisplayTypeChangeRePaginates() throws {
|
||||
app.launchAndOpenSampleBook(displayType: "horizontalscroll")
|
||||
app.waitForReader(timeout: 12)
|
||||
app.waitForReaderState(containing: "display=horizontalScroll", timeout: 5)
|
||||
app.waitForDemoReaderState(timeout: 5, description: "display=horizontalScroll") { $0.display == "horizontalScroll" }
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
app.buttons[IDs.readerSettings].tap()
|
||||
@ -94,7 +94,7 @@ final class SettingsEffectTests: XCTestCase {
|
||||
}
|
||||
|
||||
app.buttons[IDs.settingsDone].tap()
|
||||
app.waitForReaderState(containing: "display=", timeout: 8)
|
||||
app.waitForDemoReaderState(timeout: 8, description: "display changed") { $0.display != nil }
|
||||
}
|
||||
|
||||
func testDefaultBodyPaginationDoesNotEnableWidowOrphanCompaction() throws {
|
||||
@ -141,4 +141,60 @@ final class SettingsEffectTests: XCTestCase {
|
||||
|
||||
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()
|
||||
app.buttons[IDs.settingsDone].tap()
|
||||
app.waitForReaderState(containing: "display=horizontalScroll", timeout: 5)
|
||||
app.waitForDemoReaderState(timeout: 5, description: "display=horizontalScroll") { $0.display == "horizontalScroll" }
|
||||
}
|
||||
|
||||
func testAllThemeButtonsExist() {
|
||||
|
||||
@ -47,7 +47,7 @@ final class TOCInteractionTests: XCTestCase {
|
||||
XCTAssertNotEqual(updatedPage, initialPage, "目录跳转后页码应变化")
|
||||
} else {
|
||||
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() {
|
||||
app.launchAndOpenSampleBook(bookTitleQuery: "宝山辽墓材料与释读")
|
||||
app.waitForReader()
|
||||
app.showReaderChromeIfNeeded()
|
||||
app.showReaderChromeIfNeeded(requiredButtonIDs: [IDs.readerToc])
|
||||
|
||||
let tocButton = app.buttons[IDs.readerToc]
|
||||
XCTAssertTrue(tocButton.waitForExistence(timeout: 5), "目录按钮未出现")
|
||||
@ -19,7 +19,7 @@ final class TableOfContentsTests: XCTestCase {
|
||||
func testTocButtonTappable() {
|
||||
app.launchAndOpenSampleBook(bookTitleQuery: "宝山辽墓材料与释读")
|
||||
app.waitForReader()
|
||||
app.showReaderChromeIfNeeded()
|
||||
app.showReaderChromeIfNeeded(requiredButtonIDs: [IDs.readerToc])
|
||||
|
||||
let tocButton = app.buttons[IDs.readerToc]
|
||||
XCTAssertTrue(tocButton.waitForExistence(timeout: 5))
|
||||
|
||||
@ -35,7 +35,7 @@ final class ToolbarStateTests: XCTestCase {
|
||||
app.showReaderChromeIfNeeded()
|
||||
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 {
|
||||
@ -66,6 +66,6 @@ final class ToolbarStateTests: XCTestCase {
|
||||
let paging = app.collectionViews[IDs.readerPaging]
|
||||
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.setHighlights(\(jsonString(from: highlightsPayload(request.highlights), fallback: "[]")));
|
||||
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 {
|
||||
window.WeReadApi.scrollToPage(\(request.pageIndex));
|
||||
}
|
||||
|
||||
@ -212,6 +212,8 @@ public enum RDEPUBParserError: LocalizedError {
|
||||
case missingManifestItem(idref: String)
|
||||
/// 构建完成的 spine 为空(无可阅读的内容)
|
||||
case emptySpine
|
||||
/// 归档条目路径包含路径穿越或非法字符
|
||||
case invalidArchiveEntryPath(String)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
@ -229,6 +231,8 @@ public enum RDEPUBParserError: LocalizedError {
|
||||
return "spine itemref 找不到对应 manifest 项: \(idref)"
|
||||
case .emptySpine:
|
||||
return "OPF spine 为空"
|
||||
case .invalidArchiveEntryPath(let path):
|
||||
return "归档条目路径非法: \(path)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -53,9 +53,7 @@ public final class RDEPUBPaginator: NSObject {
|
||||
webView.scrollView.showsVerticalScrollIndicator = false
|
||||
webView.isUserInteractionEnabled = false
|
||||
if #available(iOS 16.4, *) {
|
||||
webView.isInspectable = true
|
||||
} else {
|
||||
// Fallback on earlier versions
|
||||
webView.isInspectable = RDEPUBWebViewDebug.isInspectableEnabled
|
||||
}
|
||||
RDEPUBWebViewDebug.log("PaginatorWebView", message: "configured webView=\(RDEPUBWebViewDebug.webViewID(webView))")
|
||||
return webView
|
||||
@ -297,6 +295,9 @@ public final class RDEPUBPaginator: NSObject {
|
||||
return mediaType.contains("html") || mediaType.contains("xhtml") || mediaType.contains("xml")
|
||||
}
|
||||
|
||||
/// 各轮测量记录值(用于稳定性日志)
|
||||
private var measurementPassValues: [Int] = []
|
||||
|
||||
/// 调度多轮测量:0ms/80ms/180ms 三轮延迟,取最大值以应对布局抖动
|
||||
private func scheduleMeasurementPass() {
|
||||
let sessionID = activeSessionID
|
||||
@ -306,8 +307,18 @@ public final class RDEPUBPaginator: NSObject {
|
||||
let currentSpineIndex = currentSpineIndexForMeasurement() else {
|
||||
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
|
||||
measurementPassValues = []
|
||||
measureNextSpineItem()
|
||||
return
|
||||
}
|
||||
@ -333,8 +344,10 @@ public final class RDEPUBPaginator: NSObject {
|
||||
}
|
||||
if let number = value as? NSNumber {
|
||||
self.pendingMeasurementValue = max(self.pendingMeasurementValue, number.intValue)
|
||||
self.measurementPassValues.append(number.intValue)
|
||||
} else if let intValue = value as? Int {
|
||||
self.pendingMeasurementValue = max(self.pendingMeasurementValue, intValue)
|
||||
self.measurementPassValues.append(intValue)
|
||||
}
|
||||
RDEPUBWebViewDebug.log(self.debugScope, message: "measurement value=\(self.pendingMeasurementValue) session=\(sessionID)")
|
||||
self.scheduleMeasurementPass()
|
||||
@ -344,6 +357,20 @@ public final class RDEPUBPaginator: NSObject {
|
||||
}
|
||||
|
||||
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!) {
|
||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didStart", url: webView.url)
|
||||
}
|
||||
|
||||
@ -49,7 +49,9 @@ extension RDEPUBParser {
|
||||
try fileManager.createDirectory(at: extractionURL, withIntermediateDirectories: true)
|
||||
|
||||
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 {
|
||||
case .directory:
|
||||
try fileManager.createDirectory(at: destinationURL, withIntermediateDirectories: true)
|
||||
@ -64,6 +66,31 @@ extension RDEPUBParser {
|
||||
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 的临时解压目录路径
|
||||
/// 路径格式:~/Library/Caches/ssreaderview-epub/{slug}-{fileSize}-{modifiedTimestamp}/
|
||||
func temporaryExtractionDirectory(for epubURL: URL) -> URL {
|
||||
|
||||
@ -76,6 +76,7 @@ public struct RDEPUBPreferences: Equatable {
|
||||
publication: RDEPUBPublication,
|
||||
viewportSize: CGSize,
|
||||
targetLocation: RDEPUBLocation? = nil,
|
||||
targetHighlightRangeInfo: String? = nil,
|
||||
highlights: [RDEPUBHighlight] = [],
|
||||
searchPresentation: RDEPUBSearchPresentation? = nil
|
||||
) -> RDEPUBRenderRequest? {
|
||||
@ -104,6 +105,7 @@ public struct RDEPUBPreferences: Equatable {
|
||||
totalPagesInChapter: page.totalPagesInChapter,
|
||||
presentation: presentationStyle(viewportSize: viewportSize),
|
||||
targetLocation: targetLocation,
|
||||
targetHighlightRangeInfo: targetHighlightRangeInfo,
|
||||
highlights: highlights,
|
||||
searchPresentation: searchPresentation
|
||||
)
|
||||
|
||||
@ -31,6 +31,8 @@ public final class RDEPUBReadingSession {
|
||||
public private(set) var pendingNavigationLocation: RDEPUBLocation?
|
||||
/// 挂起的导航目标页码
|
||||
public private(set) var pendingNavigationPageNum: Int?
|
||||
/// 挂起的高亮 Range 信息(仅 Web 渲染路径使用)
|
||||
public private(set) var pendingNavigationHighlightRangeInfo: String?
|
||||
/// 当前视口信息
|
||||
public private(set) var currentViewport: RDEPUBViewport?
|
||||
/// 当前阅读上下文(位置 + 视口 + 页码 + 章节)
|
||||
@ -105,6 +107,7 @@ public final class RDEPUBReadingSession {
|
||||
public func clearPendingNavigation() {
|
||||
pendingNavigationLocation = nil
|
||||
pendingNavigationPageNum = nil
|
||||
pendingNavigationHighlightRangeInfo = nil
|
||||
}
|
||||
|
||||
/// 检查指定页码和 spine 索引是否有挂起的导航位置
|
||||
@ -123,6 +126,26 @@ public final class RDEPUBReadingSession {
|
||||
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 内所有资源)
|
||||
public func pageContains(spineIndex: Int, in page: EPUBPage) -> Bool {
|
||||
if let fixedSpread = page.fixedSpread {
|
||||
@ -225,7 +248,8 @@ public final class RDEPUBReadingSession {
|
||||
public func queueNavigation(
|
||||
to location: RDEPUBLocation,
|
||||
relativeToSpineIndex spineIndex: Int? = nil,
|
||||
bookIdentifier: String?
|
||||
bookIdentifier: String?,
|
||||
targetHighlightRangeInfo: String? = nil
|
||||
) -> Int? {
|
||||
guard let normalizedLocation = resourceResolver.normalizedLocation(
|
||||
location,
|
||||
@ -235,8 +259,14 @@ public final class RDEPUBReadingSession {
|
||||
return nil
|
||||
}
|
||||
|
||||
pendingNavigationLocation = normalizedLocation.fragment == nil ? nil : normalizedLocation
|
||||
pendingNavigationPageNum = normalizedLocation.fragment == nil ? nil : pageIndex + 1
|
||||
let hasTargetHighlightRangeInfo = targetHighlightRangeInfo?.isEmpty == false
|
||||
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)
|
||||
return pageIndex + 1
|
||||
}
|
||||
|
||||
@ -85,6 +85,8 @@ public struct RDEPUBReflowableRenderRequest: Equatable {
|
||||
public var presentation: RDEPUBPresentationStyle
|
||||
/// 目标跳转位置(用于恢复阅读位置或锚点跳转)
|
||||
public var targetLocation: RDEPUBLocation?
|
||||
/// 目标高亮的 DOM Range 信息,用于 Web 渲染路径下的精确跳转
|
||||
public var targetHighlightRangeInfo: String?
|
||||
/// 高亮列表
|
||||
public var highlights: [RDEPUBHighlight]
|
||||
/// 搜索结果展示信息
|
||||
@ -97,6 +99,7 @@ public struct RDEPUBReflowableRenderRequest: Equatable {
|
||||
totalPagesInChapter: Int,
|
||||
presentation: RDEPUBPresentationStyle,
|
||||
targetLocation: RDEPUBLocation? = nil,
|
||||
targetHighlightRangeInfo: String? = nil,
|
||||
highlights: [RDEPUBHighlight] = [],
|
||||
searchPresentation: RDEPUBSearchPresentation? = nil
|
||||
) {
|
||||
@ -106,6 +109,7 @@ public struct RDEPUBReflowableRenderRequest: Equatable {
|
||||
self.totalPagesInChapter = totalPagesInChapter
|
||||
self.presentation = presentation
|
||||
self.targetLocation = targetLocation
|
||||
self.targetHighlightRangeInfo = targetHighlightRangeInfo
|
||||
self.highlights = highlights
|
||||
self.searchPresentation = searchPresentation
|
||||
}
|
||||
|
||||
@ -8,6 +8,12 @@ import WebKit
|
||||
|
||||
/// 自定义 URL 协议处理器,将 ss-reader://book/ 请求映射到本地 EPUB 资源文件
|
||||
public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||
struct DebugMetrics {
|
||||
let streamedResponses: Int
|
||||
let inMemoryResponses: Int
|
||||
let failures: Int
|
||||
}
|
||||
|
||||
/// 自定义协议名
|
||||
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")
|
||||
/// 活跃的 URL Scheme 任务集合(用于任务取消检测)
|
||||
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) {
|
||||
self.parser = parser
|
||||
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 请求:解析文件路径、读取数据、返回响应
|
||||
/// 对缺失的可选资源(字体/图片/CSS/JS)返回空响应,其他返回 404 错误
|
||||
public func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) {
|
||||
@ -55,6 +83,7 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
|
||||
urlSchemeTask.didFinish()
|
||||
} else {
|
||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, event: "missing-file")
|
||||
Self.recordFailure()
|
||||
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorFileDoesNotExist))
|
||||
}
|
||||
clearTask(taskID)
|
||||
@ -63,27 +92,12 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
|
||||
|
||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "resolved")
|
||||
|
||||
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()
|
||||
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)
|
||||
let fileSize = resourceSize(for: fileURL)
|
||||
if let fileSize, fileSize > 524_288 {
|
||||
respondWithStreaming(fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask, fileSize: fileSize)
|
||||
} else {
|
||||
respondWithInMemoryData(fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask)
|
||||
}
|
||||
|
||||
clearTask(taskID)
|
||||
}
|
||||
|
||||
/// 取消 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 类型
|
||||
private static func mimeType(for pathExtension: String) -> String {
|
||||
switch pathExtension.lowercased() {
|
||||
|
||||
@ -60,7 +60,7 @@ extension RDEPUBWebView {
|
||||
webView.backgroundColor = .clear
|
||||
webView.clipsToBounds = true
|
||||
if #available(iOS 16.4, *) {
|
||||
webView.isInspectable = true
|
||||
webView.isInspectable = RDEPUBWebViewDebug.isInspectableEnabled
|
||||
}
|
||||
|
||||
addSubview(webView)
|
||||
|
||||
@ -97,7 +97,8 @@ extension RDEPUBWebView {
|
||||
request.targetLocation?.href ?? "",
|
||||
String(request.targetLocation?.progression ?? 0),
|
||||
String(request.targetLocation?.lastProgression ?? 0),
|
||||
request.targetLocation?.fragment ?? ""
|
||||
request.targetLocation?.fragment ?? "",
|
||||
request.targetHighlightRangeInfo ?? ""
|
||||
].joined(separator: "|")
|
||||
let highlightSignature = request.highlights
|
||||
.map { [$0.id, $0.rangeInfo ?? "", $0.color, $0.style.rawValue].joined(separator: "|") }
|
||||
|
||||
@ -6,6 +6,14 @@ import UIKit
|
||||
import WebKit
|
||||
|
||||
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
|
||||
func applySearchDecorationsIfNeeded(completion: (() -> Void)? = nil) {
|
||||
guard let webView else {
|
||||
@ -70,7 +78,7 @@ extension RDEPUBWebView {
|
||||
)
|
||||
let searchDecorations = decorationList(
|
||||
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
|
||||
}
|
||||
@ -105,9 +113,9 @@ extension RDEPUBWebView {
|
||||
let hex = item["color"] as? String ?? "#F8E16C"
|
||||
color = overlayColor(hex: hex, alpha: 1) ?? defaultColor
|
||||
case .activeSearch:
|
||||
color = UIColor(red: 255 / 255, green: 159 / 255, blue: 67 / 255, alpha: 0.75)
|
||||
color = activeSearchOverlayColor
|
||||
case .search:
|
||||
color = UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.55)
|
||||
color = normalSearchOverlayColor
|
||||
case .highlight:
|
||||
let hex = item["color"] as? String ?? "#F8E16C"
|
||||
color = overlayColor(hex: hex, alpha: 0.45) ?? defaultColor
|
||||
|
||||
@ -21,6 +21,30 @@ enum RDEPUBWebViewDebug {
|
||||
#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 实例)
|
||||
static func webViewID(_ webView: WKWebView?) -> String {
|
||||
guard let webView else { return "nil-webview" }
|
||||
@ -51,10 +75,26 @@ enum RDEPUBWebViewDebug {
|
||||
log(scope, message: "webView=\(webViewID(webView)) js=\(action) \(details)")
|
||||
}
|
||||
|
||||
/// 输出 JS 消息接收日志(含消息名称和消息体)
|
||||
/// 输出 JS 消息接收日志(默认仅输出字段名和文本长度,verbose 模式输出完整消息体)
|
||||
static func logMessage(_ scope: String, webView: WKWebView?, name: String, body: Any) {
|
||||
guard isEnabled else { return }
|
||||
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、事件类型、错误信息)
|
||||
|
||||
@ -470,7 +470,18 @@
|
||||
setVisiblePageIndex(pageIndex);
|
||||
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) {
|
||||
var resolvedTarget = fragmentTarget(location.fragment);
|
||||
if (resolvedTarget) {
|
||||
|
||||
@ -20,7 +20,9 @@ struct RDEPUBChapterTailNormalizer {
|
||||
previous.diagnostics.append(note)
|
||||
compacted.append(previous)
|
||||
} else {
|
||||
#if DEBUG
|
||||
print("[EPUB][Pagination] href=\(href) dropped leading/intermediate whitespace frame \(NSStringFromRange(frame.contentRange))")
|
||||
#endif
|
||||
}
|
||||
continue
|
||||
}
|
||||
@ -36,7 +38,9 @@ struct RDEPUBChapterTailNormalizer {
|
||||
previousFrame.diagnostics.append(note)
|
||||
normalized.append(previousFrame)
|
||||
} else {
|
||||
#if DEBUG
|
||||
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)
|
||||
|
||||
#if DEBUG
|
||||
print(sampler.summary())
|
||||
#endif
|
||||
lastBuildPerformanceSamples = sampler.samples
|
||||
|
||||
return book
|
||||
@ -207,11 +209,15 @@ public final class RDEPUBTextBookBuilder {
|
||||
|
||||
let plainText = rendered.attributedString.string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
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))")
|
||||
#endif
|
||||
}
|
||||
if shouldSkipChapter(item: item, content: rendered.attributedString, text: plainText) {
|
||||
if item.href.lowercased().contains("cover") {
|
||||
#if DEBUG
|
||||
print("[EPUB][Cover] skipped href=\(item.href)")
|
||||
#endif
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -301,7 +307,9 @@ public final class RDEPUBTextBookBuilder {
|
||||
: normalizedFrames
|
||||
|
||||
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")")
|
||||
#endif
|
||||
}
|
||||
|
||||
let chapterAttributedContent = content.copy() as! NSAttributedString
|
||||
|
||||
@ -170,7 +170,9 @@ public final class RDEPUBTextBookCache {
|
||||
queue.sync {
|
||||
let fileURL = cacheDirectory.appendingPathComponent(key)
|
||||
guard FileManager.default.fileExists(atPath: fileURL.path) else {
|
||||
#if DEBUG
|
||||
print("[Cache] load MISS key=\(key)")
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
do {
|
||||
@ -179,17 +181,23 @@ public final class RDEPUBTextBookCache {
|
||||
ofClass: PaginationCacheArchive.self,
|
||||
from: data
|
||||
) else {
|
||||
#if DEBUG
|
||||
print("[Cache] load MISS key=\(key) (unarchive returned nil)")
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
var result: [String: RDEPUBTextChapterPaginationCache] = [:]
|
||||
for chapter in archive.chapters {
|
||||
result[chapter.href] = chapter.toCache()
|
||||
}
|
||||
#if DEBUG
|
||||
print("[Cache] load HIT key=\(key) chapters=\(result.count)")
|
||||
#endif
|
||||
return result
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("[Cache] load MISS key=\(key) error=\(error)")
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@ -204,9 +212,13 @@ public final class RDEPUBTextBookCache {
|
||||
let bookArchive = PaginationCacheArchive(chapters: archives)
|
||||
let data = try NSKeyedArchiver.archivedData(withRootObject: bookArchive, requiringSecureCoding: true)
|
||||
try data.write(to: fileURL, options: .atomic)
|
||||
#if DEBUG
|
||||
print("[Cache] save key=\(key) chapters=\(chapters.count)")
|
||||
#endif
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("[Cache] save FAILED key=\(key) error=\(error)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -223,7 +235,9 @@ public final class RDEPUBTextBookCache {
|
||||
for file in contents {
|
||||
try? fileManager.removeItem(at: file)
|
||||
}
|
||||
#if DEBUG
|
||||
print("[Cache] invalidateAll")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -51,7 +51,9 @@ public final class RDEPUBTextPerformanceSampler {
|
||||
/// 记录单个章节的性能采样,并输出日志
|
||||
public func record(_ sample: RDEPUBTextPerformanceSample) {
|
||||
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")")
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 生成性能汇总报告,包含总渲染/分页耗时和缓存命中率
|
||||
|
||||
@ -166,80 +166,6 @@ struct RDEPUBPageBreakPolicy {
|
||||
|
||||
// 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 跨页关联边界。
|
||||
func preferredPageRelateBoundary(
|
||||
after range: NSRange,
|
||||
|
||||
@ -174,12 +174,12 @@ public final class RDEPUBChapterData {
|
||||
|
||||
/// 从搜索结果还原绝对字符范围
|
||||
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 {
|
||||
return indexTable.chapterRange(for: rangeAnchor)
|
||||
}
|
||||
if let location = searchMatch.rangeLocation {
|
||||
return NSRange(location: location, length: searchMatch.rangeLength)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@ -187,11 +187,7 @@ public struct RDEPUBTextIndexTable {
|
||||
|
||||
/// 将锚点转换为章节内字符偏移量。
|
||||
public func chapterOffset(for anchor: RDEPUBTextAnchor) -> Int {
|
||||
chapterOffset(
|
||||
fileIndex: anchor.fileIndex,
|
||||
row: anchor.row,
|
||||
column: anchor.column
|
||||
) ?? anchor.chapterOffset
|
||||
anchor.chapterOffset
|
||||
}
|
||||
|
||||
/// 将锚点转换为全书绝对字符索引。
|
||||
|
||||
@ -46,7 +46,9 @@ struct RDEPUBAttachmentNormalizer {
|
||||
|
||||
if !didLogFootnoteAttachment {
|
||||
didLogFootnoteAttachment = true
|
||||
#if DEBUG
|
||||
print("[EPUB][Attachment] footnote original=\(RDEPUBHTMLNormalizer.string(from: originalSize)) display=\(RDEPUBHTMLNormalizer.string(from: attachment.displaySize)) font=\(pointSize)")
|
||||
#endif
|
||||
}
|
||||
return
|
||||
}
|
||||
@ -66,7 +68,9 @@ struct RDEPUBAttachmentNormalizer {
|
||||
|
||||
if !didLogCoverAttachment {
|
||||
didLogCoverAttachment = true
|
||||
#if DEBUG
|
||||
print("[EPUB][Attachment] cover original=\(RDEPUBHTMLNormalizer.string(from: originalSize)) display=\(RDEPUBHTMLNormalizer.string(from: attachment.displaySize))")
|
||||
#endif
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@ -62,15 +62,17 @@ extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
|
||||
readerView.transitionToPage(pageNum: max(pageNumber - 1, 0), animated: true)
|
||||
}
|
||||
|
||||
/// Web 内容视图外部链接点击回调,使用系统浏览器打开
|
||||
/// Web 内容视图外部链接点击回调,根据配置策略决定是否打开
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateExternalLink url: URL) {
|
||||
delegate?.epubReader(self, didActivateExternalLink: url)
|
||||
UIApplication.shared.open(url, options: [:], completionHandler: nil)
|
||||
openExternalURLIfAllowed(url)
|
||||
}
|
||||
|
||||
/// Web 内容视图 JavaScript 错误日志回调
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didLogJavaScriptError message: String) {
|
||||
#if DEBUG
|
||||
print("EPUB JS Error: \(message)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@ -96,6 +98,14 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
||||
contentView.clearSelection()
|
||||
}
|
||||
|
||||
func textContentView(
|
||||
_ contentView: RDEPUBTextContentView,
|
||||
didActivateAttachmentText text: String,
|
||||
sourceRect: CGRect
|
||||
) {
|
||||
presentAttachmentTooltip(text: text, sourceView: contentView, sourceRect: sourceRect)
|
||||
}
|
||||
|
||||
func textContentView(
|
||||
_ contentView: RDEPUBTextContentView,
|
||||
didRequestHighlightActions highlight: RDEPUBHighlight,
|
||||
@ -296,4 +306,242 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
||||
}
|
||||
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? {
|
||||
guard let globalSearchState = searchState else { return nil }
|
||||
|
||||
let matches = globalSearchState.matches.filter { searchMatch in
|
||||
let matches: [RDEPUBSearchMatch]
|
||||
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 {
|
||||
return globalSearchState.matches.isEmpty ? globalSearchState : nil
|
||||
}
|
||||
|
||||
let currentMatchIndex = globalSearchState.currentMatch.flatMap { currentMatch in
|
||||
matches.firstIndex(of: currentMatch)
|
||||
}
|
||||
return RDEPUBSearchState(
|
||||
keyword: globalSearchState.keyword,
|
||||
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 {
|
||||
if let textBook,
|
||||
let chapterData = textBook.chapterData(for: page.href),
|
||||
|
||||
@ -60,11 +60,16 @@ extension RDEPUBReaderController {
|
||||
}
|
||||
let page = activePages[pageIndex]
|
||||
let pendingLocation = readingSession?.pendingLocation(forPageNumber: pageIndex + 1, spineIndex: page.spineIndex)
|
||||
let pendingHighlightRangeInfo = readingSession?.pendingHighlightRangeInfo(
|
||||
forPageNumber: pageIndex + 1,
|
||||
spineIndex: page.spineIndex
|
||||
)
|
||||
return currentPreferences().renderRequest(
|
||||
for: page,
|
||||
publication: publication,
|
||||
viewportSize: currentLayoutContext().viewportSize,
|
||||
targetLocation: pendingLocation,
|
||||
targetHighlightRangeInfo: pendingHighlightRangeInfo,
|
||||
highlights: highlights(for: page),
|
||||
searchPresentation: searchPresentation(for: page)
|
||||
)
|
||||
@ -97,4 +102,3 @@ extension RDEPUBReaderController {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -91,8 +91,16 @@ extension RDEPUBReaderController {
|
||||
|
||||
/// 恢复到指定阅读位置,返回是否成功
|
||||
@discardableResult
|
||||
func restoreReadingLocation(_ location: RDEPUBLocation, animated: Bool = false) -> Bool {
|
||||
runtime.restoreReadingLocation(location, animated: animated)
|
||||
func restoreReadingLocation(
|
||||
_ 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 {
|
||||
didSet {
|
||||
readerContext.configuration = configuration
|
||||
applyWebViewDebugPolicy()
|
||||
persistReaderSettingsIfNeeded()
|
||||
guard isViewLoaded else { return }
|
||||
let oldConfiguration = oldValue
|
||||
@ -230,12 +231,20 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
readerContext.epubURL = epubURL
|
||||
readerContext.persistence = persistence
|
||||
self.currentBrightness = brightness
|
||||
applyWebViewDebugPolicy()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
private func applyWebViewDebugPolicy() {
|
||||
RDEPUBWebViewDebug.applyDebugPolicy(
|
||||
inspectableEnabled: configuration.allowsInspectableWebViews,
|
||||
verboseLoggingEnabled: configuration.enablesVerboseWebViewLogging
|
||||
)
|
||||
}
|
||||
|
||||
/// 使用已构建的 TextBook 初始化,跳过 EPUB 解析流程
|
||||
/// 适用于 TXT 等纯文本文件,调用方需自行构建 TextBook
|
||||
/// - Parameters:
|
||||
@ -318,9 +327,21 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
searchBarView.apply(theme: configuration.theme)
|
||||
|
||||
searchBarView.onSearchSubmit = { [weak self] keyword in
|
||||
self?.searchBarView.showSearching()
|
||||
self?.runtime.search(keyword: keyword)
|
||||
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
|
||||
_ = self?.runtime.searchPrevious()
|
||||
self?.updateSearchCount()
|
||||
@ -329,6 +350,14 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
_ = self?.runtime.searchNext()
|
||||
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
|
||||
self?.hideSearchBar(clearSearch: true)
|
||||
}
|
||||
@ -345,22 +374,28 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
|
||||
readerView.addSubview(searchBarView)
|
||||
readerView.searchBarView = searchBarView
|
||||
let topToolbarHeight: CGFloat = readerView.safeAreaInsets.top + 52
|
||||
let bottomAnchor = bottomToolView.superview == nil
|
||||
? readerView.bottomAnchor
|
||||
: bottomToolView.topAnchor
|
||||
NSLayoutConstraint.activate([
|
||||
searchBarView.leadingAnchor.constraint(equalTo: readerView.leadingAnchor),
|
||||
searchBarView.trailingAnchor.constraint(equalTo: readerView.trailingAnchor),
|
||||
searchBarView.topAnchor.constraint(equalTo: readerView.topAnchor, constant: topToolbarHeight),
|
||||
searchBarView.heightAnchor.constraint(equalToConstant: 52)
|
||||
searchBarView.topAnchor.constraint(equalTo: readerView.topAnchor),
|
||||
searchBarView.bottomAnchor.constraint(equalTo: bottomAnchor)
|
||||
])
|
||||
|
||||
searchBarView.transform = CGAffineTransform(translationX: 0, y: -52)
|
||||
UIView.animate(withDuration: 0.3) {
|
||||
self.searchBarView.transform = .identity
|
||||
searchBarView.alpha = 0
|
||||
searchBarView.presentedView.transform = CGAffineTransform(translationX: 0, y: 40)
|
||||
UIView.animate(withDuration: 0.28) {
|
||||
self.searchBarView.alpha = 1
|
||||
self.searchBarView.presentedView.transform = .identity
|
||||
}
|
||||
|
||||
if let keyword = searchState?.keyword, !keyword.isEmpty {
|
||||
searchBarView.restoreKeyword(keyword)
|
||||
updateSearchCount()
|
||||
} else {
|
||||
searchBarView.showNoResults()
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
@ -375,11 +410,13 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
isSearchBarVisible = false
|
||||
|
||||
searchBarView.textField.resignFirstResponder()
|
||||
UIView.animate(withDuration: 0.3, animations: {
|
||||
self.searchBarView.transform = CGAffineTransform(translationX: 0, y: -52)
|
||||
UIView.animate(withDuration: 0.25, animations: {
|
||||
self.searchBarView.alpha = 0
|
||||
self.searchBarView.presentedView.transform = CGAffineTransform(translationX: 0, y: 40)
|
||||
}) { _ in
|
||||
self.searchBarView.removeFromSuperview()
|
||||
self.searchBarView.transform = .identity
|
||||
self.searchBarView.alpha = 1
|
||||
self.searchBarView.presentedView.transform = .identity
|
||||
self.readerView.searchBarView = nil
|
||||
}
|
||||
|
||||
@ -394,11 +431,11 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
searchBarView.showNoResults()
|
||||
return
|
||||
}
|
||||
if let index = searchState.currentMatchIndex {
|
||||
searchBarView.updateMatchCount(current: index + 1, total: searchState.matches.count)
|
||||
} else if searchState.matches.isEmpty {
|
||||
searchBarView.showNoResults()
|
||||
}
|
||||
searchBarView.updateResults(
|
||||
sections: searchResultSections(for: searchState),
|
||||
keyword: searchState.keyword,
|
||||
currentMatchIndex: searchState.currentMatchIndex
|
||||
)
|
||||
}
|
||||
|
||||
/// 当工具栏可见性变化时同步搜索栏(由 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
|
||||
func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL)
|
||||
|
||||
/// 外部链接打开前的拦截钩子,返回 false 可阻止打开
|
||||
/// - Parameters:
|
||||
/// - reader: 阅读器控制器
|
||||
/// - url: 外部链接 URL
|
||||
/// - Returns: 是否允许打开该链接
|
||||
func epubReader(_ reader: UIViewController, shouldOpenExternalURL url: URL) -> Bool
|
||||
|
||||
/// 阅读器发生错误时调用
|
||||
/// - Parameters:
|
||||
/// - reader: 阅读器控制器
|
||||
@ -92,6 +99,7 @@ public extension RDEPUBReaderDelegate {
|
||||
func epubReader(_ reader: UIViewController, didChangeCurrentSearchMatch match: RDEPUBSearchMatch?) {}
|
||||
func epubReader(_ reader: UIViewController, didUpdateCurrentTableOfContentsItem item: RDEPUBReaderTableOfContentsItem?) {}
|
||||
func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL) {}
|
||||
func epubReader(_ reader: UIViewController, shouldOpenExternalURL url: URL) -> Bool { true }
|
||||
func epubReader(_ reader: UIViewController, didFailWithError error: Error) {}
|
||||
func epubReader(_ reader: UIViewController, configureTopToolView topToolView: RDEPUBReaderTopToolView) {}
|
||||
}
|
||||
|
||||
@ -39,23 +39,32 @@ public protocol RDEPUBReaderPersistence: AnyObject {
|
||||
// MARK: - 默认实现
|
||||
|
||||
/// 协议的默认空实现,书签/高亮/设置为可选功能
|
||||
/// DEBUG 模式下会对 no-op 行为输出警告,便于发现未对接持久化层的误用
|
||||
public extension RDEPUBReaderPersistence {
|
||||
func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark] {
|
||||
_ = bookIdentifier
|
||||
#if DEBUG
|
||||
print("[RDEPUBReaderPersistence] ⚠️ loadBookmarks called on default no-op implementation for: \(bookIdentifier)")
|
||||
#endif
|
||||
return []
|
||||
}
|
||||
|
||||
func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String) {
|
||||
_ = bookmarks
|
||||
_ = bookIdentifier
|
||||
#if DEBUG
|
||||
print("[RDEPUBReaderPersistence] ⚠️ saveBookmarks(\(bookmarks.count) items) called on default no-op implementation for: \(bookIdentifier)")
|
||||
#endif
|
||||
}
|
||||
|
||||
func loadReaderSettings() -> RDEPUBReaderSettings? {
|
||||
nil
|
||||
#if DEBUG
|
||||
print("[RDEPUBReaderPersistence] ⚠️ loadReaderSettings called on default no-op implementation")
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@ -1,47 +1,32 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 搜索栏
|
||||
|
||||
/// 阅读器搜索栏视图
|
||||
/// 提供搜索输入、上一个/下一个匹配导航、匹配计数和关闭功能
|
||||
final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
||||
// MARK: 回调闭包
|
||||
|
||||
/// 提交搜索关键词回调
|
||||
var onSearchSubmit: ((String) -> Void)?
|
||||
/// 点击上一个匹配回调
|
||||
var onSearchPrevious: (() -> Void)?
|
||||
/// 点击下一个匹配回调
|
||||
var onSearchNext: (() -> 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")
|
||||
struct RDEPUBReaderSearchSection: Equatable {
|
||||
struct Item: Equatable {
|
||||
let matchIndex: Int
|
||||
let previewText: String
|
||||
let isCurrent: Bool
|
||||
}
|
||||
imageView.tintColor = .gray
|
||||
return imageView
|
||||
}()
|
||||
|
||||
let title: String
|
||||
let items: [Item]
|
||||
}
|
||||
|
||||
// MARK: - 搜索面板
|
||||
|
||||
/// 阅读器搜索面板
|
||||
/// 提供底部抽屉式搜索输入、分组结果列表和匹配跳转能力。
|
||||
final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
||||
var onSearchSubmit: ((String) -> Void)?
|
||||
var onSearchTextChanged: ((String) -> Void)?
|
||||
var onSearchPrevious: (() -> Void)?
|
||||
var onSearchNext: (() -> Void)?
|
||||
var onSelectMatch: ((Int) -> Void)?
|
||||
var onClose: (() -> Void)?
|
||||
|
||||
let textField: UITextField = {
|
||||
let field = UITextField()
|
||||
field.placeholder = "搜索..."
|
||||
field.font = UIFont.systemFont(ofSize: 15)
|
||||
field.placeholder = "搜索"
|
||||
field.font = UIFont.systemFont(ofSize: 18, weight: .medium)
|
||||
field.returnKeyType = .search
|
||||
field.autocorrectionType = .no
|
||||
field.autocapitalizationType = .none
|
||||
@ -53,25 +38,25 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
||||
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 nextButton = RDEPUBReaderTintButton(type: .system)
|
||||
private let countLabel = UILabel()
|
||||
|
||||
private let countLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.font = UIFont.systemFont(ofSize: 13, weight: .medium)
|
||||
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
|
||||
private var searchSections: [RDEPUBReaderSearchSection] = []
|
||||
private var keyword = ""
|
||||
private var currentMatchIndex: Int?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
@ -81,162 +66,331 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
||||
setupSubviews()
|
||||
setupConstraints()
|
||||
setupActions()
|
||||
updateNavigationEnabled(false)
|
||||
updateLegacyNavigationEnabled(false)
|
||||
showInitialState()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
// MARK: 布局
|
||||
|
||||
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) {
|
||||
super.apply(theme: theme)
|
||||
backgroundColor = theme.toolBackgroundColor
|
||||
containerView.backgroundColor = theme.toolControlBorderUnselectColor
|
||||
searchIcon.tintColor = theme.toolControlTextColor
|
||||
textField.textColor = theme.toolControlTextColor
|
||||
let isDarkBackground = theme.contentBackgroundColor.rd_searchIsDarkBackground
|
||||
|
||||
let overlayColor = isDarkBackground
|
||||
? 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(
|
||||
string: "搜索...",
|
||||
attributes: [.foregroundColor: theme.toolControlTextColor.withAlphaComponent(0.5)]
|
||||
string: "搜索",
|
||||
attributes: [.foregroundColor: secondaryTextColor]
|
||||
)
|
||||
countLabel.textColor = theme.toolControlTextColor
|
||||
previousButton.tintColor = theme.toolControlTextColor
|
||||
nextButton.tintColor = theme.toolControlTextColor
|
||||
closeButton.tintColor = theme.toolControlTextColor
|
||||
|
||||
emptyStateLabel.textColor = secondaryTextColor
|
||||
tableView.backgroundColor = .clear
|
||||
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) {
|
||||
countLabel.text = "\(current)/\(total)"
|
||||
updateNavigationEnabled(total > 0)
|
||||
textField.accessibilityValue = "\(current)/\(total)"
|
||||
updateLegacyNavigationEnabled(total > 0)
|
||||
}
|
||||
|
||||
/// 显示无结果状态
|
||||
func showNoResults() {
|
||||
countLabel.text = "0/0"
|
||||
updateNavigationEnabled(false)
|
||||
currentMatchIndex = nil
|
||||
updateMatchCount(current: 0, total: 0)
|
||||
tableView.isHidden = true
|
||||
emptyStateLabel.isHidden = false
|
||||
emptyStateLabel.text = keyword.isEmpty ? "输入关键词开始搜索" : "未找到相关内容"
|
||||
}
|
||||
|
||||
/// 显示搜索中状态
|
||||
func showSearching() {
|
||||
countLabel.text = "搜索中..."
|
||||
updateNavigationEnabled(false)
|
||||
updateMatchCount(current: 0, total: 0)
|
||||
tableView.isHidden = true
|
||||
emptyStateLabel.isHidden = false
|
||||
emptyStateLabel.text = "搜索中..."
|
||||
}
|
||||
|
||||
/// 恢复已有的搜索关键词(搜索栏重新显示时)
|
||||
func restoreKeyword(_ keyword: String) {
|
||||
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() {
|
||||
addSubview(containerView)
|
||||
containerView.addSubview(searchIcon)
|
||||
containerView.addSubview(textField)
|
||||
backgroundButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
panelView.translatesAutoresizingMaskIntoConstraints = false
|
||||
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(nextButton)
|
||||
addSubview(countLabel)
|
||||
addSubview(closeButton)
|
||||
|
||||
if #available(iOS 13.0, *) {
|
||||
previousButton.setImage(UIImage(systemName: "chevron.up")?.withRenderingMode(.alwaysTemplate), for: .normal)
|
||||
nextButton.setImage(UIImage(systemName: "chevron.down")?.withRenderingMode(.alwaysTemplate), for: .normal)
|
||||
closeButton.setImage(UIImage(systemName: "xmark")?.withRenderingMode(.alwaysTemplate), for: .normal)
|
||||
searchIcon.image = UIImage(systemName: "magnifyingglass")
|
||||
previousButton.setImage(UIImage(systemName: "chevron.up"), for: .normal)
|
||||
nextButton.setImage(UIImage(systemName: "chevron.down"), for: .normal)
|
||||
} else {
|
||||
previousButton.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"
|
||||
nextButton.accessibilityIdentifier = "epub.reader.search.next"
|
||||
closeButton.accessibilityIdentifier = "epub.reader.search.close"
|
||||
countLabel.accessibilityIdentifier = "epub.reader.search.count"
|
||||
textField.accessibilityIdentifier = "epub.reader.search.field"
|
||||
previousButton.alpha = 0.01
|
||||
nextButton.alpha = 0.01
|
||||
countLabel.alpha = 0.01
|
||||
|
||||
[previousButton, nextButton, closeButton].forEach { button in
|
||||
button.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .medium)
|
||||
button.tintColor = .black
|
||||
button.setTitleColor(.black, for: .normal)
|
||||
}
|
||||
tableView.register(RDEPUBReaderSearchResultCell.self, forCellReuseIdentifier: RDEPUBReaderSearchResultCell.reuseIdentifier)
|
||||
tableView.dataSource = self
|
||||
tableView.delegate = self
|
||||
tableView.showsVerticalScrollIndicator = false
|
||||
tableView.contentInset = UIEdgeInsets(top: 4, left: 0, bottom: 16, right: 0)
|
||||
}
|
||||
|
||||
private func setupConstraints() {
|
||||
[containerView, searchIcon, textField, previousButton, nextButton, countLabel, closeButton].forEach {
|
||||
$0.translatesAutoresizingMaskIntoConstraints = false
|
||||
}
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
// 容器(搜索输入区域)
|
||||
containerView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalInset),
|
||||
containerView.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||
containerView.heightAnchor.constraint(equalToConstant: containerHeight),
|
||||
backgroundButton.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
backgroundButton.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
backgroundButton.topAnchor.constraint(equalTo: topAnchor),
|
||||
backgroundButton.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
|
||||
// 搜索图标
|
||||
searchIcon.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 10),
|
||||
searchIcon.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
|
||||
searchIcon.widthAnchor.constraint(equalToConstant: 16),
|
||||
panelView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
panelView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
panelView.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 8),
|
||||
panelView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
|
||||
// 输入框
|
||||
textField.leadingAnchor.constraint(equalTo: searchIcon.trailingAnchor, constant: 6),
|
||||
textField.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -8),
|
||||
textField.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
|
||||
textField.heightAnchor.constraint(equalToConstant: containerHeight - 4),
|
||||
grabberView.topAnchor.constraint(equalTo: panelView.topAnchor, constant: 10),
|
||||
grabberView.centerXAnchor.constraint(equalTo: panelView.centerXAnchor),
|
||||
grabberView.widthAnchor.constraint(equalToConstant: 92),
|
||||
grabberView.heightAnchor.constraint(equalToConstant: 6),
|
||||
|
||||
// 上一个按钮
|
||||
previousButton.leadingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: spacing),
|
||||
previousButton.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||
previousButton.widthAnchor.constraint(equalToConstant: 32),
|
||||
previousButton.heightAnchor.constraint(equalToConstant: 32),
|
||||
searchRowView.leadingAnchor.constraint(equalTo: panelView.leadingAnchor, constant: 20),
|
||||
searchRowView.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: -20),
|
||||
searchRowView.topAnchor.constraint(equalTo: grabberView.bottomAnchor, constant: 18),
|
||||
searchRowView.heightAnchor.constraint(equalToConstant: 52),
|
||||
|
||||
// 下一个按钮
|
||||
nextButton.leadingAnchor.constraint(equalTo: previousButton.trailingAnchor, constant: spacing),
|
||||
nextButton.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||
nextButton.widthAnchor.constraint(equalToConstant: 32),
|
||||
nextButton.heightAnchor.constraint(equalToConstant: 32),
|
||||
searchFieldContainer.leadingAnchor.constraint(equalTo: searchRowView.leadingAnchor, constant: 12),
|
||||
searchFieldContainer.topAnchor.constraint(equalTo: searchRowView.topAnchor),
|
||||
searchFieldContainer.bottomAnchor.constraint(equalTo: searchRowView.bottomAnchor),
|
||||
|
||||
// 计数标签
|
||||
countLabel.leadingAnchor.constraint(equalTo: nextButton.trailingAnchor, constant: spacing),
|
||||
countLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||
countLabel.widthAnchor.constraint(greaterThanOrEqualToConstant: 44),
|
||||
searchIcon.leadingAnchor.constraint(equalTo: searchFieldContainer.leadingAnchor, constant: 10),
|
||||
searchIcon.centerYAnchor.constraint(equalTo: searchFieldContainer.centerYAnchor),
|
||||
searchIcon.widthAnchor.constraint(equalToConstant: 24),
|
||||
searchIcon.heightAnchor.constraint(equalToConstant: 24),
|
||||
|
||||
// 关闭按钮
|
||||
closeButton.leadingAnchor.constraint(equalTo: countLabel.trailingAnchor, constant: spacing),
|
||||
closeButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -horizontalInset),
|
||||
closeButton.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||
closeButton.widthAnchor.constraint(equalToConstant: 32),
|
||||
closeButton.heightAnchor.constraint(equalToConstant: 32)
|
||||
textField.leadingAnchor.constraint(equalTo: searchIcon.trailingAnchor, constant: 10),
|
||||
textField.trailingAnchor.constraint(equalTo: searchFieldContainer.trailingAnchor, constant: -10),
|
||||
textField.topAnchor.constraint(equalTo: searchFieldContainer.topAnchor),
|
||||
textField.bottomAnchor.constraint(equalTo: searchFieldContainer.bottomAnchor),
|
||||
|
||||
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() {
|
||||
textField.delegate = self
|
||||
textField.addTarget(self, action: #selector(textFieldDidReturn), for: .editingDidEndOnExit)
|
||||
textField.addTarget(self, action: #selector(textFieldDidChange), for: .editingChanged)
|
||||
previousButton.addTarget(self, action: #selector(previousAction), 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.alpha = enabled ? 1 : 0.45
|
||||
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() {
|
||||
guard let keyword = textField.text, !keyword.isEmpty else { return }
|
||||
let keyword = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !keyword.isEmpty else { return }
|
||||
onSearchSubmit?(keyword)
|
||||
textField.resignFirstResponder()
|
||||
}
|
||||
|
||||
@objc private func textFieldDidChange() {
|
||||
guard textField.markedTextRange == nil else { return }
|
||||
onSearchTextChanged?(textField.text ?? "")
|
||||
}
|
||||
|
||||
@objc private func previousAction() {
|
||||
onSearchPrevious?()
|
||||
}
|
||||
@ -249,3 +403,197 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
||||
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 {
|
||||
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 lastEmittedDemoState = ""
|
||||
private var pendingSearchKeyword: String?
|
||||
private var externalLinkActivationCount = 0
|
||||
private var lastActivatedExternalURL: URL?
|
||||
private var lastReaderErrorDescription = "none"
|
||||
|
||||
/// 初始化方法
|
||||
/// - Parameters:
|
||||
@ -78,6 +81,7 @@ public final class RDURLReaderController: UIViewController {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .systemBackground
|
||||
title = bookURL.deletingPathExtension().lastPathComponent
|
||||
RDEPUBResourceURLSchemeHandler.resetDebugMetrics()
|
||||
embedReaderController()
|
||||
installDemoStateLabel()
|
||||
}
|
||||
@ -370,6 +374,10 @@ public final class RDURLReaderController: UIViewController {
|
||||
let progression = location.map { String(format: "%.4f", $0.navigationProgression) } ?? "nil"
|
||||
let mapSnapshot = demoPaginationSnapshot()
|
||||
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 = [
|
||||
"reader=opened",
|
||||
"page=\(page)",
|
||||
@ -389,7 +397,17 @@ public final class RDURLReaderController: UIViewController {
|
||||
"avoidOrphans=\(layoutConfig?.avoidOrphans == true ? 1 : 0)",
|
||||
"windowSize=\(readerController?.configuration.onDemandChapterWindowSize ?? epubConfiguration.onDemandChapterWindowSize)",
|
||||
"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: " ")
|
||||
demoStateLabel.text = state
|
||||
if let logPrefix {
|
||||
@ -405,6 +423,25 @@ public final class RDURLReaderController: UIViewController {
|
||||
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) {
|
||||
guard let readerController else {
|
||||
return ("unavailable", "none", 0, 0, 0)
|
||||
@ -486,6 +523,17 @@ extension RDURLReaderController: RDEPUBReaderDelegate {
|
||||
public func epubReader(_ reader: UIViewController, didUpdateBookmarks bookmarks: [RDEPUBBookmark]) {
|
||||
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 命令行参数值
|
||||
|
||||
@ -34,8 +34,28 @@ final class RDEPUBChapterSummaryDiskCache {
|
||||
|
||||
func read(for key: RDEPUBChapterCacheKey) -> RDEPUBChapterSummary? {
|
||||
let fileURL = self.fileURL(for: key)
|
||||
guard let data = try? Data(contentsOf: fileURL) else { return nil }
|
||||
return try? JSONDecoder().decode(RDEPUBChapterSummary.self, from: data)
|
||||
let data: 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
|
||||
@ -75,32 +95,78 @@ final class RDEPUBChapterSummaryDiskCache {
|
||||
return true
|
||||
}
|
||||
|
||||
/// 语义化别名:用于判断当前 renderSignature 下是否具备完整章节摘要集合。
|
||||
func containsCompleteSet(keys: [RDEPUBChapterCacheKey]) -> Bool {
|
||||
isCacheComplete(keys: keys)
|
||||
}
|
||||
|
||||
/// 清空所有缓存文件
|
||||
func removeAll() {
|
||||
guard let files = try? fileManager.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: nil) else { return }
|
||||
for fileURL in files where fileURL.pathExtension == "json" {
|
||||
try? fileManager.removeItem(at: fileURL)
|
||||
removeFiles(matching: { _ in true })
|
||||
}
|
||||
|
||||
/// 清空指定书籍的所有缓存文件
|
||||
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 -> 文件路径
|
||||
|
||||
/// 使用确定性字符串拼接生成文件名,不依赖 Hashable.hashValue
|
||||
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 digest = rawKey.sha256Hex
|
||||
return cacheDirectory.appendingPathComponent("\(digest).json")
|
||||
return cacheDirectory.appendingPathComponent("\(bookPrefix)__\(renderPrefix)__\(digest).json")
|
||||
}
|
||||
|
||||
private func writeImmediately(summary: RDEPUBChapterSummary, for key: RDEPUBChapterCacheKey) {
|
||||
let fileURL = self.fileURL(for: key)
|
||||
let data = try? JSONEncoder().encode(summary)
|
||||
try? data?.write(to: fileURL)
|
||||
let tmpURL = fileURL.appendingPathExtension("tmp")
|
||||
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.buildSnapshotAroundCurrent(chapter: chapter)
|
||||
case .failure(let error):
|
||||
#if DEBUG
|
||||
print("[EPUB][WindowCoord] loadChapter failed at spine=\(initialSpineIndex): \(error), trying next")
|
||||
#endif
|
||||
// 自动跳过不可渲染的章节(封面/版权页等 linear=false 的 spine 项)
|
||||
let nextIndex = initialSpineIndex + 1
|
||||
if nextIndex < totalSpineCount {
|
||||
@ -76,7 +78,9 @@ final class RDEPUBChapterWindowCoordinator {
|
||||
|
||||
private func buildSnapshotAroundCurrent(chapter: RDEPUBRuntimeChapter) {
|
||||
guard let current = store.currentSpineIndex else {
|
||||
#if DEBUG
|
||||
print("[EPUB][WindowCoord] buildSnapshot: currentSpineIndex is nil, ABORT")
|
||||
#endif
|
||||
return
|
||||
}
|
||||
let chapters = store.windowSpineIndices.compactMap { spineIndex -> RDEPUBRuntimeChapter? in
|
||||
@ -86,7 +90,9 @@ final class RDEPUBChapterWindowCoordinator {
|
||||
return store.chapterData(for: spineIndex)
|
||||
}
|
||||
let snapshot = RDEPUBChapterWindowSnapshot.from(chapters: chapters, anchorSpineIndex: current)
|
||||
#if DEBUG
|
||||
print("[EPUB][WindowCoord] snapshot: chapters=\(snapshot.chapters.count) pages=\(snapshot.pageCount) anchorPage=\(snapshot.anchorPageOffset)")
|
||||
#endif
|
||||
currentSnapshot = snapshot
|
||||
isApplyingSnapshot = true
|
||||
onSnapshotChanged?(snapshot)
|
||||
@ -240,14 +246,18 @@ final class RDEPUBChapterWindowCoordinator {
|
||||
|
||||
private func handle(error: Error) {
|
||||
// 日志记录,不中断当前阅读状态
|
||||
#if DEBUG
|
||||
print("[RDEPUBChapterWindowCoordinator] chapter load error: \(error)")
|
||||
#endif
|
||||
// 确保 loading 指示器在加载失败时也被隐藏(避免永久白屏)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.context.hideLoading()
|
||||
// 如果有快照但没内容显示,显示错误提示
|
||||
if self.currentSnapshot == nil {
|
||||
#if DEBUG
|
||||
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) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
@ -32,14 +32,34 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
|
||||
/// 更新当前文本选区状态,并同步刷新底部工具栏的高亮按钮可用性。
|
||||
func updateCurrentSelection(_ selection: RDEPUBSelection?) {
|
||||
if let selection, !selection.isEmpty {
|
||||
applySelectionState(.selected(selection))
|
||||
} else {
|
||||
applySelectionState(.idle)
|
||||
}
|
||||
}
|
||||
|
||||
/// 统一选区状态变更入口。
|
||||
/// 在 `.selected` 时:更新 context 选区、显示工具栏、刷新 chrome、通知 delegate。
|
||||
/// 在 `.idle` 时:清空选区、刷新 chrome、通知 delegate。
|
||||
func applySelectionState(_ state: RDEPUBSelectionState) {
|
||||
guard let controller else { return }
|
||||
controller.currentSelection = selection?.isEmpty == false ? selection : nil
|
||||
if controller.currentSelection != nil,
|
||||
controller.readerView.isShowToolView == false {
|
||||
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: controller.currentSelection)
|
||||
controller.delegate?.epubReader(controller, didChangeSelection: selection)
|
||||
case .committingAction:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/// 基于当前选区添加高亮标记,自动去重并持久化。
|
||||
@ -139,11 +159,10 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
/// 跳转到指定高亮所在位置。
|
||||
@discardableResult
|
||||
func go(toHighlightID id: String, animated: Bool = true) -> Bool {
|
||||
guard let controller else { return false }
|
||||
guard let highlight = highlight(withID: id) else {
|
||||
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
|
||||
guard let controller = self?.controller else { return }
|
||||
highlightsController?.dismiss(animated: true) {
|
||||
controller.go(to: highlight.location)
|
||||
_ = self?.navigate(to: highlight, animated: true)
|
||||
}
|
||||
}
|
||||
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() {
|
||||
guard let controller else { return }
|
||||
if let currentBookIdentifier = controller.currentBookIdentifier {
|
||||
|
||||
@ -23,7 +23,9 @@ final class RDEPUBReaderAssemblyCoordinator {
|
||||
setupLoadingIndicator(controller.loadingIndicator, in: controller.view)
|
||||
setupErrorLabel(controller.errorLabel, in: controller.view)
|
||||
controller.delegate?.epubReader(controller, configureTopToolView: controller.topToolView)
|
||||
#if DEBUG
|
||||
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 DEBUG
|
||||
print("[ReadViewDemo] finishExternalTextBook: applying textBook with \(textBook.pages.count) pages")
|
||||
#endif
|
||||
runtime.applyTextBook(textBook, restoreLocation: restoreLocation)
|
||||
#if DEBUG
|
||||
print("[ReadViewDemo] finishExternalTextBook: after applyTextBook, numberOfPages=\(context.readerView?.numberOfPages() ?? -1)")
|
||||
#endif
|
||||
} else {
|
||||
runtime.finishPagination(restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
@ -70,7 +70,7 @@ final class RDEPUBReaderChromeCoordinator {
|
||||
canToggleBookmark: controller.currentBookIdentifier != nil,
|
||||
hasBookmarkAtCurrentLocation: hasBookmarkAtCurrentLocation(),
|
||||
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,
|
||||
showsTableOfContents: controller.configuration.showsTableOfContents,
|
||||
allowsHighlights: controller.configuration.allowsHighlights,
|
||||
|
||||
@ -59,8 +59,19 @@ final class RDEPUBReaderContext {
|
||||
var lastMetadataParseWallClockMs: Int = 0
|
||||
/// 后台元数据解析使用的并发数。
|
||||
var lastMetadataParseConcurrency: Int = 0
|
||||
/// 当前用户文本选区。
|
||||
var currentSelection: RDEPUBSelection?
|
||||
/// 当前用户文本选区(对外只读语义,底层由 selectionState 推导)。
|
||||
var currentSelection: RDEPUBSelection? {
|
||||
get { selectionState.selection }
|
||||
set {
|
||||
if let newValue, !newValue.isEmpty {
|
||||
selectionState = .selected(newValue)
|
||||
} else {
|
||||
selectionState = .idle
|
||||
}
|
||||
}
|
||||
}
|
||||
/// 统一选区状态模型,收口所有选区相关状态变更。
|
||||
var selectionState: RDEPUBSelectionState = .idle
|
||||
|
||||
// MARK: - 控制器状态(从 controller 下沉)
|
||||
|
||||
|
||||
@ -16,7 +16,11 @@ final class RDEPUBReaderLocationCoordinator {
|
||||
|
||||
/// 恢复到指定阅读位置,返回是否成功跳转。
|
||||
@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,
|
||||
let readerView = context.readerView else { return false }
|
||||
guard let targetPageNumber = controller.pageNumber(for: location) else {
|
||||
@ -32,13 +36,15 @@ final class RDEPUBReaderLocationCoordinator {
|
||||
_ = context.readingSession?.queueNavigation(
|
||||
to: location,
|
||||
relativeToSpineIndex: nil,
|
||||
bookIdentifier: context.currentBookIdentifier
|
||||
bookIdentifier: context.currentBookIdentifier,
|
||||
targetHighlightRangeInfo: targetHighlightRangeInfo
|
||||
)
|
||||
} else if context.textBook == nil {
|
||||
_ = context.readingSession?.queueNavigation(
|
||||
to: location,
|
||||
relativeToSpineIndex: nil,
|
||||
bookIdentifier: context.currentBookIdentifier
|
||||
bookIdentifier: context.currentBookIdentifier,
|
||||
targetHighlightRangeInfo: targetHighlightRangeInfo
|
||||
)
|
||||
} else {
|
||||
context.readingSession?.transition(to: .jumping)
|
||||
|
||||
@ -33,10 +33,14 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
controller.showLoading()
|
||||
let token = UUID()
|
||||
context.paginationToken = token
|
||||
#if DEBUG
|
||||
print("[EPUB][Pagination] profile=\(publication.readingProfile.rawValue) layout=\(publication.layout.rawValue) spine=\(publication.spine.count)")
|
||||
#endif
|
||||
|
||||
if publication.readingProfile == .textReflowable {
|
||||
#if DEBUG
|
||||
print("[EPUB][Pagination] path=text-reflowable-on-demand")
|
||||
#endif
|
||||
paginateTextPublication(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
@ -48,7 +52,9 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
}
|
||||
|
||||
if publication.layout == .fixed {
|
||||
#if DEBUG
|
||||
print("[EPUB][Pagination] path=fixed-layout")
|
||||
#endif
|
||||
let snapshot = readingSession.makePaginationSnapshot(
|
||||
pageCounts: Array(repeating: 1, count: publication.spine.count),
|
||||
preferences: controller.currentPreferences(),
|
||||
@ -59,7 +65,9 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
}
|
||||
|
||||
let paginator = context.makePaginator()
|
||||
#if DEBUG
|
||||
print("[EPUB][Pagination] path=web-paginator")
|
||||
#endif
|
||||
context.paginator = paginator
|
||||
paginator.calculate(
|
||||
parser: parser,
|
||||
|
||||
@ -230,6 +230,12 @@ final class RDEPUBReaderRuntime {
|
||||
searchCoordinator.searchPrevious()
|
||||
}
|
||||
|
||||
/// 跳转到指定搜索匹配项
|
||||
@discardableResult
|
||||
func selectSearchMatch(at index: Int) -> Bool {
|
||||
searchCoordinator.selectSearchMatch(at: index)
|
||||
}
|
||||
|
||||
/// 清除搜索状态
|
||||
func clearSearch() {
|
||||
searchCoordinator.clearSearch()
|
||||
@ -365,8 +371,16 @@ final class RDEPUBReaderRuntime {
|
||||
|
||||
/// 恢复到指定阅读位置
|
||||
@discardableResult
|
||||
func restoreReadingLocation(_ location: RDEPUBLocation, animated: Bool = false) -> Bool {
|
||||
locationCoordinator.restoreReadingLocation(location, animated: animated)
|
||||
func restoreReadingLocation(
|
||||
_ 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)
|
||||
}
|
||||
|
||||
/// 跳转到指定索引的搜索匹配项
|
||||
@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() {
|
||||
guard let controller else { return }
|
||||
@ -109,12 +124,109 @@ final class RDEPUBReaderSearchCoordinator {
|
||||
}
|
||||
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 {
|
||||
return RDEPUBHTMLSearchEngine(parser: parser, publication: publication).search(keyword: keyword)
|
||||
}
|
||||
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 {
|
||||
guard let controller else { return false }
|
||||
guard var searchState = controller.searchState, !searchState.matches.isEmpty else {
|
||||
@ -162,6 +274,14 @@ final class RDEPUBReaderSearchCoordinator {
|
||||
private func pageNumber(for searchMatch: RDEPUBSearchMatch) -> Int? {
|
||||
guard let controller else { return nil }
|
||||
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) {
|
||||
return pageNumber
|
||||
}
|
||||
@ -194,4 +314,39 @@ final class RDEPUBReaderSearchCoordinator {
|
||||
bookIdentifier: controller.currentBookIdentifier
|
||||
).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 等待间隙。
|
||||
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: 初始化
|
||||
|
||||
/// 创建阅读器配置,所有参数均提供合理的默认值
|
||||
@ -136,6 +147,10 @@ public struct RDEPUBReaderConfiguration: Equatable {
|
||||
/// - textRenderingEngine: 文本渲染引擎
|
||||
/// - onDemandChapterWindowSize: 章节按需加载窗口大小(总章节数 3...15,偶数自动向上取奇)
|
||||
/// - metadataParsingConcurrency: 后台元数据解析并发数,默认 CPU 核心数
|
||||
/// - allowedExternalURLSchemes: 允许直接打开的外部 URL scheme 集合,默认仅 https
|
||||
/// - requiresExternalLinkConfirmation: 打开外部链接前是否需要确认,默认 true
|
||||
/// - allowsInspectableWebViews: 是否允许开启 inspectable,默认 false
|
||||
/// - enablesVerboseWebViewLogging: 是否允许输出完整 WebView 消息体日志,默认 false
|
||||
public init(
|
||||
fontSize: CGFloat = 15,
|
||||
lineHeightMultiple: CGFloat = 1.6,
|
||||
@ -156,7 +171,11 @@ public struct RDEPUBReaderConfiguration: Equatable {
|
||||
fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode = .automatic,
|
||||
textRenderingEngine: RDEPUBTextRenderingEngine = .dtCoreText,
|
||||
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.lineHeightMultiple = lineHeightMultiple
|
||||
@ -178,6 +197,10 @@ public struct RDEPUBReaderConfiguration: Equatable {
|
||||
self.textRenderingEngine = textRenderingEngine
|
||||
self.onDemandChapterWindowSize = Self.normalizedChapterWindowSize(onDemandChapterWindowSize)
|
||||
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
|
||||
updateThemeSelection(selectedPreset)
|
||||
updateControlAccessibilityValues()
|
||||
}
|
||||
|
||||
private func applyTheme(_ theme: RDEPUBReaderTheme) {
|
||||
@ -279,9 +280,17 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
|
||||
let isSelected = button.tag == preset.rawValue
|
||||
button.layer.borderWidth = isSelected ? 2 : 1
|
||||
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() {
|
||||
dismiss(animated: true)
|
||||
}
|
||||
@ -312,6 +321,7 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
|
||||
let choice = choices[index]
|
||||
guard choice != currentConfiguration.fontChoice else { return }
|
||||
currentConfiguration.fontChoice = choice
|
||||
updateControlAccessibilityValues()
|
||||
onFontChoiceChange?(choice)
|
||||
}
|
||||
|
||||
@ -319,12 +329,14 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
|
||||
let index = max(0, min(control.selectedSegmentIndex, lineHeightValues.count - 1))
|
||||
let value = lineHeightValues[index]
|
||||
currentConfiguration.lineHeightMultiple = value
|
||||
updateControlAccessibilityValues()
|
||||
onLineHeightChange?(value)
|
||||
}
|
||||
|
||||
@objc private func columnCountChanged(_ control: UISegmentedControl) {
|
||||
let numberOfColumns = control.selectedSegmentIndex == 1 ? 2 : 1
|
||||
currentConfiguration.numberOfColumns = numberOfColumns
|
||||
updateControlAccessibilityValues()
|
||||
onColumnCountChange?(numberOfColumns)
|
||||
}
|
||||
|
||||
@ -339,6 +351,7 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
|
||||
displayType = .pageCurl
|
||||
}
|
||||
currentConfiguration.displayType = displayType
|
||||
updateControlAccessibilityValues()
|
||||
onDisplayTypeChange?(displayType)
|
||||
}
|
||||
|
||||
|
||||
@ -2,6 +2,9 @@ import UIKit
|
||||
|
||||
/// 原生文本渲染路径的批注覆盖层,负责绘制用户高亮、搜索命中高亮和当前选区装饰。
|
||||
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:
|
||||
/// - highlights: 高亮批注数组
|
||||
@ -58,8 +61,6 @@ final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
|
||||
) {
|
||||
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 pageStart = pageRange.lowerBound
|
||||
let pageEndExclusive = pageRange.upperBound
|
||||
@ -72,7 +73,7 @@ final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
|
||||
guard overlapStart < overlapEnd else { continue }
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -97,9 +98,6 @@ final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
|
||||
let pageEndExclusive = pageRange.upperBound
|
||||
|
||||
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 {
|
||||
guard let matchStart = match.rangeLocation else { continue }
|
||||
let matchEnd = matchStart + match.rangeLength
|
||||
@ -113,7 +111,7 @@ final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
|
||||
|
||||
let isActive = match == searchState.currentMatch
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,6 +14,11 @@ protocol RDEPUBTextContentViewDelegate: AnyObject {
|
||||
didRequestSelectionAction action: RDEPUBAnnotationMenuAction,
|
||||
selection: RDEPUBSelection?
|
||||
)
|
||||
func textContentView(
|
||||
_ contentView: RDEPUBTextContentView,
|
||||
didActivateAttachmentText text: String,
|
||||
sourceRect: CGRect
|
||||
)
|
||||
func textContentView(
|
||||
_ contentView: RDEPUBTextContentView,
|
||||
didRequestHighlightActions highlight: RDEPUBHighlight,
|
||||
@ -33,6 +38,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
private var currentSelection: RDEPUBSelection?
|
||||
private var menuSelection: RDEPUBSelection?
|
||||
private var currentHighlights: [RDEPUBHighlight] = []
|
||||
private var currentSearchState: RDEPUBSearchState?
|
||||
weak var delegate: RDEPUBTextContentViewDelegate?
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
@ -188,6 +194,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
currentSelection = nil
|
||||
menuSelection = nil
|
||||
currentHighlights = highlights
|
||||
currentSearchState = searchState
|
||||
selectionController.clearSelection(renderView: coreTextRenderView)
|
||||
contentInsets = configuration.reflowableContentInsets
|
||||
backgroundColor = configuration.theme.contentBackgroundColor
|
||||
@ -244,14 +251,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
overlayView.configure(page: page, selectionColor: overlayView.selectionColor, snapshot: interactionController.snapshot)
|
||||
#if canImport(DTCoreText)
|
||||
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
|
||||
updateAccessibilityDecorationSummary()
|
||||
|
||||
@ -262,6 +261,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
func clearSelection() {
|
||||
currentSelection = nil
|
||||
menuSelection = nil
|
||||
currentSearchState = nil
|
||||
panGestureRecognizer.isEnabled = false
|
||||
selectionController.clearSelection(renderView: coreTextRenderView)
|
||||
overlayView.clearSelection()
|
||||
@ -487,6 +487,17 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
interactionController.configure(layoutFrame: layoutFrame, page: page)
|
||||
overlayView.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
|
||||
|
||||
@ -539,6 +550,11 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
clearSelection()
|
||||
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),
|
||||
let sourceRect = highlightSourceRect(for: highlight, fallbackPoint: point) else {
|
||||
return
|
||||
@ -621,6 +637,41 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
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 {
|
||||
if gestureRecognizer === panGestureRecognizer {
|
||||
return selectionController.isSelecting
|
||||
@ -633,7 +684,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
return true
|
||||
}
|
||||
let point = tapGestureRecognizer.location(in: overlayView)
|
||||
return highlight(at: point) != nil
|
||||
return attachmentText(at: point) != nil || highlight(at: point) != nil
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@ -140,12 +140,25 @@ final class RDEPUBTextSelectionController: NSObject {
|
||||
let totalLength = max(page.chapterContent.length - 1, 1)
|
||||
let globalStart = absoluteRange.location
|
||||
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(
|
||||
location: RDEPUBLocation(
|
||||
href: page.href,
|
||||
progression: Double(globalStart) / Double(totalLength),
|
||||
lastProgression: Double(max(globalEnd - 1, globalStart)) / Double(totalLength),
|
||||
fragment: nil
|
||||
fragment: nil,
|
||||
rangeAnchor: RDEPUBTextRangeAnchor(start: startAnchor, end: endAnchor)
|
||||
),
|
||||
text: selectedText,
|
||||
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"
|
||||
|
||||
QUICK_TESTS=(
|
||||
ArchivePathValidationTests
|
||||
BookmarkChromeStateTests
|
||||
BookmarkManagementTests
|
||||
BookmarkTests
|
||||
CacheHardeningTests
|
||||
DisplayTypeTests
|
||||
ErrorAndEdgeCaseTests
|
||||
ExternalLinkPolicyTests
|
||||
HighlightsManagementTests
|
||||
LocationPersistenceTests
|
||||
NavigationBackwardTests
|
||||
PageNavigationTests
|
||||
ReaderAnnotationExtendedTests
|
||||
ReaderAnnotationTests
|
||||
ReaderOpenCloseTests
|
||||
ReaderToolbarTests
|
||||
SearchTests
|
||||
SelectionAnnotateTests
|
||||
SelectionMenuTests
|
||||
SettingsEffectTests
|
||||
SettingsExtendedTests
|
||||
|
||||
Loading…
Reference in New Issue
Block a user