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:
shen 2026-06-13 22:48:56 +08:00
parent 27e9b85ddb
commit 6f75b083f7
83 changed files with 4825 additions and 1880 deletions

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

View File

@ -1,6 +1,6 @@
# 当前阅读器问题修复开发清单
> 最后更新2026-06-12
> 最后更新2026-06-13
> 依据说明:本清单仅基于当前仓库代码实现整理,不以现有说明文档是否准确为前提。
---

View File

@ -43,7 +43,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
RDReaderView: 617ec758a5db3c10024acf83b110465ea8343b5b
RDReaderView: 54205c82c62ea6c674b43c833642d2643c98c573
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
SSAlertSwift: aad8dc0c20b36fcffe700b81d7be89d60c7ba4f1
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351

View File

@ -27,15 +27,9 @@
"ZIPFoundation": [
"~> 0.9"
],
"DTCoreText": [
],
"SnapKit": [
],
"SSAlertSwift": [
]
"DTCoreText": [],
"SnapKit": [],
"SSAlertSwift": []
},
"requires_arc": true,
"swift_version": "5.10"

View File

@ -43,7 +43,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
RDReaderView: 617ec758a5db3c10024acf83b110465ea8343b5b
RDReaderView: 54205c82c62ea6c674b43c833642d2643c98c573
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
SSAlertSwift: aad8dc0c20b36fcffe700b81d7be89d60c7ba4f1
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351

File diff suppressed because it is too large Load Diff

View File

@ -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 */

View File

@ -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 {

View File

@ -0,0 +1 @@
not a valid epub archive

Binary file not shown.

View File

@ -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 {

View File

@ -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 }
}
}

View File

@ -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 }
}
}

View File

@ -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,

View File

@ -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" }
}
}

View File

@ -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
}
}

View File

@ -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), "高亮列表按钮不存在")

View File

@ -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

View File

@ -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)
}
}

View File

@ -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"
}
}
}

View File

@ -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 }
}
}

View File

@ -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() {

View File

@ -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 }
}
}

View File

@ -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() {

View File

@ -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)")
}
}
}

View File

@ -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 }
}
}

View File

@ -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()
}
}

View File

@ -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() {

View File

@ -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 }
}
}

View File

@ -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))

View File

@ -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 }
}
}

View File

@ -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));
}

View File

@ -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)"
}
}
}

View File

@ -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)
}

View File

@ -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 {

View File

@ -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
)

View File

@ -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
}

View File

@ -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
}

View File

@ -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() {

View File

@ -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)

View File

@ -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: "|") }

View File

@ -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

View File

@ -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

View File

@ -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) {

View File

@ -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
}
}

View File

@ -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

View File

@ -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
}
}
}

View File

@ -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
}
/// /

View File

@ -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,

View File

@ -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
}

View File

@ -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
}
///

View File

@ -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
}

View File

@ -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
}
}

View File

@ -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),

View File

@ -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 {
}
}
}

View File

@ -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
)
}
///

View File

@ -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
}
}

View File

@ -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) {}
}

View File

@ -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)
}

View File

@ -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
}
}

View File

@ -136,5 +136,6 @@ public final class RDEPUBReaderTopToolView: RDEPUBReaderToolView {
} else {
bookmarkButton.setTitle(isBookmarked ? "已签" : "书签", for: .normal)
}
bookmarkButton.accessibilityValue = isBookmarked ? "selected" : "unselected"
}
}

View File

@ -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

View File

@ -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()
}
}

View File

@ -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
}
}
}

View File

@ -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
)
}
}

View File

@ -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() }

View File

@ -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 {

View File

@ -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)
}

View File

@ -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,

View File

@ -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

View File

@ -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)

View File

@ -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,

View File

@ -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
)
}
///

View File

@ -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
}
}

View File

@ -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
}
}
}

View File

@ -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
}
/// 使

View File

@ -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)
}

View File

@ -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))
}
}

View File

@ -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
}

View File

@ -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()

View File

@ -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