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: SPEC CHECKSUMS:
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0 DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
RDReaderView: 617ec758a5db3c10024acf83b110465ea8343b5b RDReaderView: 54205c82c62ea6c674b43c833642d2643c98c573
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
SSAlertSwift: aad8dc0c20b36fcffe700b81d7be89d60c7ba4f1 SSAlertSwift: aad8dc0c20b36fcffe700b81d7be89d60c7ba4f1
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351 ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351

View File

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

View File

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

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 */; }; 1A2B3C4D00000011AABBCC01 /* DemoReaderState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000012AABBCC01 /* DemoReaderState.swift */; };
1A2B3C4D00000014AABBCC01 /* FanrenParseTimeTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000013AABBCC01 /* FanrenParseTimeTest.swift */; }; 1A2B3C4D00000014AABBCC01 /* FanrenParseTimeTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000013AABBCC01 /* FanrenParseTimeTest.swift */; };
23BB1155EA379786DAA10A89 /* DisplayTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */; }; 23BB1155EA379786DAA10A89 /* DisplayTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */; };
369C9658D870DCAFC17EB7F7 /* SearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFE01DBDCB8D790832A4DE3F /* SearchTests.swift */; };
3BC5C96D7A0ACF35F2192CC7 /* XCUIApplication+Launch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74B4C44287820D68ED6570F8 /* XCUIApplication+Launch.swift */; }; 3BC5C96D7A0ACF35F2192CC7 /* XCUIApplication+Launch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74B4C44287820D68ED6570F8 /* XCUIApplication+Launch.swift */; };
4509ED928F228F43888E063D /* ReaderToolbarTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */; }; 4509ED928F228F43888E063D /* ReaderToolbarTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */; };
52A6A0C9941A4B5F9B88C6B0 /* ReaderAnnotationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20BD15E5D8F04E7E9A239E14 /* ReaderAnnotationTests.swift */; }; 52A6A0C9941A4B5F9B88C6B0 /* ReaderAnnotationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20BD15E5D8F04E7E9A239E14 /* ReaderAnnotationTests.swift */; };
@ -26,7 +27,6 @@
C08FF8D030048DC5147729E9 /* ReaderOpenCloseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */; }; C08FF8D030048DC5147729E9 /* ReaderOpenCloseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */; };
DE1437A969DA1C5F0CBB047D /* Pods_ReadViewDemo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 792DF85CE4A3DD80D67843C7 /* Pods_ReadViewDemo.framework */; }; DE1437A969DA1C5F0CBB047D /* Pods_ReadViewDemo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 792DF85CE4A3DD80D67843C7 /* Pods_ReadViewDemo.framework */; };
FEDB5937CEB858CB06E38E2D /* SettingsPanelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201C2B482287866487EFAE66 /* SettingsPanelTests.swift */; }; FEDB5937CEB858CB06E38E2D /* SettingsPanelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201C2B482287866487EFAE66 /* SettingsPanelTests.swift */; };
369C9658D870DCAFC17EB7F7 /* SearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFE01DBDCB8D790832A4DE3F /* SearchTests.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@ -61,9 +61,9 @@
8FFD606A5A1CBDCC3CA87F1C /* ReadViewDemoUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ReadViewDemoUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 8FFD606A5A1CBDCC3CA87F1C /* ReadViewDemoUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ReadViewDemoUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderOpenCloseTests.swift; sourceTree = "<group>"; }; 9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderOpenCloseTests.swift; sourceTree = "<group>"; };
BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderToolbarTests.swift; sourceTree = "<group>"; }; BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderToolbarTests.swift; sourceTree = "<group>"; };
CFE01DBDCB8D790832A4DE3F /* SearchTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SearchTests.swift; sourceTree = "<group>"; };
DE070C1D2FBF0CC900ED065F /* ReadViewDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ReadViewDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; DE070C1D2FBF0CC900ED065F /* ReadViewDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ReadViewDemo.app; sourceTree = BUILT_PRODUCTS_DIR; };
FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DisplayTypeTests.swift; sourceTree = "<group>"; }; FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DisplayTypeTests.swift; sourceTree = "<group>"; };
CFE01DBDCB8D790832A4DE3F /* SearchTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SearchTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */

View File

@ -18,6 +18,9 @@ final class ViewController: UIViewController {
let windowSize: Int? let windowSize: Int?
let concurrency: Int? let concurrency: Int?
let clearsCache: Bool let clearsCache: Bool
let corruptsCache: Bool
let allowsInspectableWebViews: Bool
let enablesVerboseWebLogs: Bool
let searchKeyword: String? let searchKeyword: String?
nonisolated private static func parseDisplayType(_ rawValue: String) -> RDReaderView.DisplayType? { nonisolated private static func parseDisplayType(_ rawValue: String) -> RDReaderView.DisplayType? {
@ -57,6 +60,9 @@ final class ViewController: UIViewController {
let windowSize = value(after: "--demo-window-size").flatMap(Int.init) let windowSize = value(after: "--demo-window-size").flatMap(Int.init)
let concurrency = value(after: "--demo-concurrency").flatMap(Int.init) let concurrency = value(after: "--demo-concurrency").flatMap(Int.init)
let clearsCache = arguments.contains("--demo-clear-cache") let clearsCache = arguments.contains("--demo-clear-cache")
let corruptsCache = arguments.contains("--demo-corrupt-cache")
let allowsInspectableWebViews = arguments.contains("--demo-allow-inspectable-webviews")
let enablesVerboseWebLogs = arguments.contains("--demo-enable-verbose-weblogs")
let searchKeyword = value(after: "--demo-search-keyword") let searchKeyword = value(after: "--demo-search-keyword")
return LaunchAutomationPlan( return LaunchAutomationPlan(
@ -69,6 +75,9 @@ final class ViewController: UIViewController {
windowSize: windowSize, windowSize: windowSize,
concurrency: concurrency, concurrency: concurrency,
clearsCache: clearsCache, clearsCache: clearsCache,
corruptsCache: corruptsCache,
allowsInspectableWebViews: allowsInspectableWebViews,
enablesVerboseWebLogs: enablesVerboseWebLogs,
searchKeyword: searchKeyword searchKeyword: searchKeyword
) )
} }
@ -262,6 +271,9 @@ final class ViewController: UIViewController {
if launchAutomationPlan.clearsCache { if launchAutomationPlan.clearsCache {
clearChapterSummaryCache() clearChapterSummaryCache()
} }
if launchAutomationPlan.corruptsCache {
corruptChapterSummaryCache()
}
var configuration = RDEPUBReaderConfiguration.default var configuration = RDEPUBReaderConfiguration.default
if let displayType = launchAutomationPlan.displayType { if let displayType = launchAutomationPlan.displayType {
@ -273,6 +285,8 @@ final class ViewController: UIViewController {
if let concurrency = launchAutomationPlan.concurrency { if let concurrency = launchAutomationPlan.concurrency {
configuration.metadataParsingConcurrency = concurrency configuration.metadataParsingConcurrency = concurrency
} }
configuration.allowsInspectableWebViews = launchAutomationPlan.allowsInspectableWebViews
configuration.enablesVerboseWebViewLogging = launchAutomationPlan.enablesVerboseWebLogs
_ = openBook(book, configuration: configuration, automationPlan: launchAutomationPlan) _ = openBook(book, configuration: configuration, automationPlan: launchAutomationPlan)
} }
@ -300,6 +314,19 @@ final class ViewController: UIViewController {
print("[ReadViewDemo] cleared chapter summary cache at \(cacheDir.path)") print("[ReadViewDemo] cleared chapter summary cache at \(cacheDir.path)")
} }
private func corruptChapterSummaryCache() {
let cachesDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
?? FileManager.default.temporaryDirectory
let cacheDir = cachesDirectory.appendingPathComponent("RDEPUBChapterSummaryCache", isDirectory: true)
guard let enumerator = FileManager.default.enumerator(at: cacheDir, includingPropertiesForKeys: nil) else {
return
}
for case let fileURL as URL in enumerator where fileURL.pathExtension == "json" {
try? Data("corrupted-cache".utf8).write(to: fileURL, options: .atomic)
}
print("[ReadViewDemo] corrupted chapter summary cache at \(cacheDir.path)")
}
} }
extension ViewController: UITableViewDataSource { extension ViewController: UITableViewDataSource {

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 windowSize: Int? { fields["windowSize"].flatMap(Int.init) }
var parseMs: Int? { fields["parseMs"].flatMap(Int.init) } var parseMs: Int? { fields["parseMs"].flatMap(Int.init) }
var parseConcurrency: Int? { fields["parseConcurrency"].flatMap(Int.init) } var parseConcurrency: Int? { fields["parseConcurrency"].flatMap(Int.init) }
var inspectable: Int? { fields["inspectable"].flatMap(Int.init) }
var streamedResources: Int? { fields["streamedResources"].flatMap(Int.init) }
var inMemoryResources: Int? { fields["inMemoryResources"].flatMap(Int.init) }
var resourceFailures: Int? { fields["resourceFailures"].flatMap(Int.init) }
var cacheFiles: Int? { fields["cacheFiles"].flatMap(Int.init) }
var cacheBytes: Int? { fields["cacheBytes"].flatMap(Int.init) }
var externalLinks: Int? { fields["externalLinks"].flatMap(Int.init) }
var lastExternalURL: String? { fields["lastExternalURL"] }
var lastError: String? { fields["lastError"] }
var searchMatchText: String? { fields["searchMatchText"] }
} }
extension XCUIApplication { extension XCUIApplication {

View File

@ -9,6 +9,9 @@ extension XCUIApplication {
windowSize: Int? = nil, windowSize: Int? = nil,
concurrency: Int? = nil, concurrency: Int? = nil,
clearsCache: Bool = false, clearsCache: Bool = false,
corruptsCache: Bool = false,
allowsInspectableWebViews: Bool = false,
enablesVerboseWebLogs: Bool = false,
searchKeyword: String? = nil searchKeyword: String? = nil
) { ) {
var args = ["--demo-book-title", bookTitleQuery] var args = ["--demo-book-title", bookTitleQuery]
@ -18,6 +21,15 @@ extension XCUIApplication {
if clearsCache { if clearsCache {
args.append("--demo-clear-cache") args.append("--demo-clear-cache")
} }
if corruptsCache {
args.append("--demo-corrupt-cache")
}
if allowsInspectableWebViews {
args.append("--demo-allow-inspectable-webviews")
}
if enablesVerboseWebLogs {
args.append("--demo-enable-verbose-weblogs")
}
if let displayType { if let displayType {
args += ["--demo-display-type", displayType] args += ["--demo-display-type", displayType]
} }
@ -52,16 +64,27 @@ extension XCUIApplication {
} }
} }
func showReaderChromeIfNeeded() { func showReaderChromeIfNeeded(
if buttons[IDs.readerBack].exists && buttons[IDs.readerSettings].exists { requiredButtonIDs: [String] = [IDs.readerBack, IDs.readerSettings],
timeout: TimeInterval = 5
) {
if readerChromeIsVisible(requiredButtonIDs: requiredButtonIDs) {
return return
} }
let content = otherElements[IDs.readerContent] let deadline = Date().addingTimeInterval(timeout)
if content.waitForExistence(timeout: 3) { while Date() < deadline {
content.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() let content = otherElements[IDs.readerContent]
} else { if content.waitForExistence(timeout: 1) {
windows.firstMatch.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() content.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
} else {
windows.firstMatch.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
}
RunLoop.current.run(until: Date().addingTimeInterval(0.35))
if readerChromeIsVisible(requiredButtonIDs: requiredButtonIDs) {
return
}
} }
} }
@ -94,4 +117,39 @@ extension XCUIApplication {
XCTFail("阅读器状态未包含 \(expectedText),当前:\(lastLabel)") XCTFail("阅读器状态未包含 \(expectedText),当前:\(lastLabel)")
} }
@discardableResult
func requireSelectionText(
expectedPage: Int? = nil,
timeout: TimeInterval = 12
) throws -> XCUIElement {
let selectionText = otherElements[IDs.readerSelectionText].firstMatch
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if let expectedPage {
let state = currentDemoReaderState()
if state?.page != expectedPage {
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
continue
}
}
if selectionText.exists {
return selectionText
}
let content = otherElements[IDs.readerContent]
if content.exists {
content.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
}
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
}
throw XCTSkip("文本选区元素未出现,当前渲染路径未暴露 \(IDs.readerSelectionText)")
}
private func readerChromeIsVisible(requiredButtonIDs: [String]) -> Bool {
requiredButtonIDs.allSatisfy { buttons[$0].exists }
}
} }

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] let bookmarkButton = app.buttons[IDs.readerBookmark]
XCTAssertTrue(bookmarkButton.waitForExistence(timeout: 3), "书签按钮不存在") XCTAssertTrue(bookmarkButton.waitForExistence(timeout: 3), "书签按钮不存在")
bookmarkButton.tap() bookmarkButton.tap()
app.waitForReaderState(containing: "bookmarks=1", timeout: 5) app.waitForDemoReaderState(timeout: 5, description: "bookmarks=1") { $0.bookmarks == 1 }
app.showReaderChromeIfNeeded() app.showReaderChromeIfNeeded()
let bookmarksButton = app.buttons[IDs.readerBookmarks] let bookmarksButton = app.buttons[IDs.readerBookmarks]
@ -47,7 +47,7 @@ final class BookmarkManagementTests: XCTestCase {
app.showReaderChromeIfNeeded() app.showReaderChromeIfNeeded()
app.buttons[IDs.readerBookmark].tap() app.buttons[IDs.readerBookmark].tap()
app.waitForReaderState(containing: "bookmarks=1", timeout: 5) app.waitForDemoReaderState(timeout: 5, description: "bookmarks=1") { $0.bookmarks == 1 }
app.showReaderChromeIfNeeded() app.showReaderChromeIfNeeded()
app.buttons[IDs.readerBookmarks].tap() app.buttons[IDs.readerBookmarks].tap()
@ -78,6 +78,39 @@ final class BookmarkManagementTests: XCTestCase {
XCTAssertTrue(bookmarkButton.waitForExistence(timeout: 3), "移除书签后按钮应仍然存在") XCTAssertTrue(bookmarkButton.waitForExistence(timeout: 3), "移除书签后按钮应仍然存在")
} }
func testBookmarksButtonDisabledWhenNoBookmarks() throws {
app.launchAndOpenSampleBook(resetsReaderState: true)
app.waitForReader(timeout: 12)
app.showReaderChromeIfNeeded()
let bookmarksButton = app.buttons[IDs.readerBookmarks]
XCTAssertTrue(bookmarksButton.waitForExistence(timeout: 3), "书签列表按钮不存在")
XCTAssertFalse(bookmarksButton.isEnabled, "零书签时书签列表按钮应禁用")
}
func testBookmarkIndicatorStaysSelectedAtBookmarkedLocation() throws {
app.launchAndOpenSampleBook(resetsReaderState: true)
app.waitForReader(timeout: 12)
app.showReaderChromeIfNeeded()
let bookmarkButton = app.buttons[IDs.readerBookmark]
XCTAssertTrue(bookmarkButton.waitForExistence(timeout: 3), "书签按钮不存在")
bookmarkButton.tap()
app.waitForDemoReaderState(timeout: 5, description: "bookmarks=1") { $0.bookmarks == 1 }
XCTAssertEqual(bookmarkButton.value as? String, "selected", "当前页已加书签时顶部书签按钮应为选中态")
let paging = app.collectionViews[IDs.readerPaging]
if paging.waitForExistence(timeout: 5) {
paging.swipeLeft()
app.showReaderChromeIfNeeded()
XCTAssertEqual(bookmarkButton.value as? String, "unselected", "离开书签页后顶部书签按钮应取消选中")
paging.swipeRight()
app.showReaderChromeIfNeeded()
XCTAssertEqual(bookmarkButton.value as? String, "selected", "返回书签页后顶部书签按钮应恢复选中")
}
}
private func waitForCellCount( private func waitForCellCount(
in table: XCUIElement, in table: XCUIElement,
minimum: Int, minimum: Int,

View File

@ -15,12 +15,37 @@ final class DisplayTypeTests: XCTestCase {
func testOpenWithHorizontalScroll() { func testOpenWithHorizontalScroll() {
app.launchAndOpenSampleBook(displayType: "scroll") app.launchAndOpenSampleBook(displayType: "scroll")
app.waitForReader() app.waitForReader()
app.waitForReaderState(containing: "display=horizontalScroll") app.waitForDemoReaderState(timeout: 8, description: "display=horizontalScroll") { $0.display == "horizontalScroll" }
} }
func testOpenWithVerticalScroll() { func testOpenWithVerticalScroll() {
app.launchAndOpenSampleBook(displayType: "vertical") app.launchAndOpenSampleBook(displayType: "vertical")
app.waitForReader() app.waitForReader()
app.waitForReaderState(containing: "display=verticalScroll") app.waitForDemoReaderState(timeout: 8, description: "display=verticalScroll") { $0.display == "verticalScroll" }
}
func testInspectableWebViewsDisabledByDefault() {
app.launchAndOpenSampleBook(bookTitleQuery: "Web交互流式外链验证")
app.waitForReader()
let state = app.waitForDemoReaderState(timeout: 8, description: "inspectable=0") { $0.inspectable != nil }
XCTAssertEqual(state.inspectable, 0, "默认配置下 WebView inspectable 应关闭")
}
func testDisplayTypeSwitchMidSession() {
app.launchAndOpenSampleBook(displayType: "scroll")
app.waitForReader()
app.waitForDemoReaderState(timeout: 8, description: "display=horizontalScroll") { $0.display == "horizontalScroll" }
app.showReaderChromeIfNeeded()
XCTAssertTrue(app.buttons[IDs.readerSettings].waitForExistence(timeout: 3))
app.buttons[IDs.readerSettings].tap()
let displayTypeControl = app.segmentedControls[IDs.settingsDisplayType]
XCTAssertTrue(displayTypeControl.waitForExistence(timeout: 5))
displayTypeControl.buttons.element(boundBy: 2).tap()
app.buttons[IDs.settingsDone].tap()
app.waitForDemoReaderState(timeout: 8, description: "display=verticalScroll after switch") { $0.display == "verticalScroll" }
} }
} }

View File

@ -14,7 +14,7 @@ final class ErrorAndEdgeCaseTests: XCTestCase {
let paging = app.collectionViews[IDs.readerPaging] let paging = app.collectionViews[IDs.readerPaging]
if paging.waitForExistence(timeout: 5) { paging.swipeUp() } if paging.waitForExistence(timeout: 5) { paging.swipeUp() }
app.waitForReaderState(containing: "reader=opened", timeout: 5) app.waitForDemoReaderState(timeout: 5, description: "reader=opened") { $0.isOpened }
} }
func testOpenAndCloseMultipleTimes() throws { func testOpenAndCloseMultipleTimes() throws {
@ -42,7 +42,7 @@ final class ErrorAndEdgeCaseTests: XCTestCase {
for _ in 1...5 { paging.swipeLeft() } for _ in 1...5 { paging.swipeLeft() }
app.waitForReaderState(containing: "reader=opened", timeout: 8) app.waitForDemoReaderState(timeout: 8, description: "reader=opened") { $0.isOpened }
} }
func testSettingsPanelScrollReachesAllControls() throws { func testSettingsPanelScrollReachesAllControls() throws {
@ -66,4 +66,85 @@ final class ErrorAndEdgeCaseTests: XCTestCase {
app.buttons[IDs.settingsDone].tap() app.buttons[IDs.settingsDone].tap()
} }
}
func testHTTPSExternalLinkShowsConfirmationAlert() throws {
app.launchAndOpenSampleBook(bookTitleQuery: "Web交互流式外链验证", displayType: "scroll")
app.waitForReader(timeout: 20)
let httpsLink = externalLinkElement(named: "HTTPS Link")
guard httpsLink.waitForExistence(timeout: 8) else {
throw XCTSkip("HTTPS 外链元素未暴露")
}
httpsLink.tap()
XCTAssertTrue(app.alerts["打开外部链接"].waitForExistence(timeout: 5), "HTTPS 外链应弹出确认框")
XCTAssertTrue(app.buttons["打开"].waitForExistence(timeout: 2), "确认框应提供打开按钮")
app.buttons["取消"].tap()
}
func testMailtoExternalLinkBlockedByDefaultPolicy() throws {
app.launchAndOpenSampleBook(bookTitleQuery: "Web交互流式外链验证", displayType: "scroll")
app.waitForReader(timeout: 20)
let mailtoLink = externalLinkElement(named: "Mailto Link")
guard mailtoLink.waitForExistence(timeout: 8) else {
throw XCTSkip("Mailto 外链元素未暴露")
}
mailtoLink.tap()
XCTAssertFalse(app.alerts["打开外部链接"].waitForExistence(timeout: 2), "默认策略下 mailto 不应弹出确认框")
let state = app.waitForDemoReaderState(timeout: 5, description: "mailto activation recorded") { state in
(state.externalLinks ?? 0) >= 1 && state.lastExternalURL != nil
}
XCTAssertTrue(state.lastExternalURL?.contains("mailto:test@example.com") == true, "应记录 mailto 激活但不继续打开")
}
func testLargeWebResourceUsesStreamingPath() throws {
app.launchAndOpenSampleBook(bookTitleQuery: "Web交互流式外链验证", displayType: "scroll")
app.waitForReader(timeout: 20)
let state = app.waitForDemoReaderState(timeout: 15, description: "streamedResources>0") { state in
(state.streamedResources ?? 0) > 0
}
XCTAssertGreaterThan(state.streamedResources ?? 0, 0, "大资源应走流式传输路径")
}
func testInvalidEPUBReportsFailure() {
app.launchAndOpenSampleBook(bookTitleQuery: "损坏结构样本", resetsReaderState: true)
let state = app.waitForDemoReaderState(timeout: 12, description: "invalid epub failure") { state in
state.lastError != nil && state.lastError != "none"
}
XCTAssertNotEqual(state.lastError, "none")
}
func testEmptyArchiveReportsFailure() {
app.launchAndOpenSampleBook(bookTitleQuery: "空归档样本", resetsReaderState: true)
let state = app.waitForDemoReaderState(timeout: 12, description: "empty archive failure") { state in
state.lastError != nil && state.lastError != "none"
}
XCTAssertNotEqual(state.lastError, "none")
}
func testMissingSpineDocumentReportsFailure() {
app.launchAndOpenSampleBook(bookTitleQuery: "缺失文件样本", resetsReaderState: true)
let state = app.waitForDemoReaderState(timeout: 12, description: "missing file failure") { state in
state.lastError != nil && state.lastError != "none"
}
XCTAssertNotEqual(state.lastError, "none")
}
private func externalLinkElement(named label: String) -> XCUIElement {
let link = app.links[label]
if link.exists {
return link
}
let staticText = app.staticTexts[label]
if staticText.exists {
return staticText
}
return link
}
}

View File

@ -23,7 +23,7 @@ final class HighlightsManagementTests: XCTestCase {
if highlightMenuItem.waitForExistence(timeout: 3) { highlightMenuItem.tap() } if highlightMenuItem.waitForExistence(timeout: 3) { highlightMenuItem.tap() }
else if app.buttons["高亮"].waitForExistence(timeout: 2) { app.buttons["高亮"].tap() } else if app.buttons["高亮"].waitForExistence(timeout: 2) { app.buttons["高亮"].tap() }
app.waitForReaderState(containing: "highlights=", timeout: 5) app.waitForDemoReaderState(timeout: 5, description: "highlights exists") { $0.highlights != nil }
app.showReaderChromeIfNeeded() app.showReaderChromeIfNeeded()
XCTAssertTrue(app.buttons[IDs.readerHighlights].waitForExistence(timeout: 3), "高亮列表按钮不存在") XCTAssertTrue(app.buttons[IDs.readerHighlights].waitForExistence(timeout: 3), "高亮列表按钮不存在")

View File

@ -50,9 +50,10 @@ final class LargeBookOnDemandTests: XCTestCase {
app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, resetsReaderState: true) app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, resetsReaderState: true)
app.waitForReader(timeout: 20) app.waitForReader(timeout: 20)
_ = app.waitForDemoReaderState(timeout: 60, description: "首次打开完成全书缓存") { state in let firstFullState = app.waitForDemoReaderState(timeout: 60, description: "首次打开完成全书缓存") { state in
state.mode == "bookPageMap" && state.pagination == "full" && (state.buildableChapters ?? 0) > 0 state.mode == "bookPageMap" && state.pagination == "full" && (state.buildableChapters ?? 0) > 0
} }
XCTAssertGreaterThan(firstFullState.cacheFiles ?? 0, 0, "首次全量解析后应生成章节摘要缓存")
app.showReaderChromeIfNeeded() app.showReaderChromeIfNeeded()
XCTAssertTrue(app.buttons[IDs.readerBack].waitForExistence(timeout: 5), "返回按钮不存在") XCTAssertTrue(app.buttons[IDs.readerBack].waitForExistence(timeout: 5), "返回按钮不存在")
@ -68,6 +69,47 @@ final class LargeBookOnDemandTests: XCTestCase {
XCTAssertEqual(reopenedState.pagination, "full") XCTAssertEqual(reopenedState.pagination, "full")
XCTAssertEqual(reopenedState.knownChapters, reopenedState.buildableChapters) XCTAssertEqual(reopenedState.knownChapters, reopenedState.buildableChapters)
XCTAssertEqual(reopenedState.knownPages, firstFullState.knownPages, "二开命中缓存后总页数应稳定一致")
}
func testCorruptedCacheFallsBackGracefully() throws {
app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, resetsReaderState: true, clearsCache: true)
app.waitForReader(timeout: 20)
_ = app.waitForDemoReaderState(timeout: 60, description: "首次打开完成缓存建立") { state in
state.pagination == "full" && (state.cacheFiles ?? 0) > 0
}
app.showReaderChromeIfNeeded()
app.buttons[IDs.readerBack].tap()
XCTAssertTrue(app.tables[IDs.demoBooksTable].waitForExistence(timeout: 5))
app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, resetsReaderState: false, corruptsCache: true)
app.waitForReader(timeout: 20)
let recoveredState = app.waitForDemoReaderState(timeout: 45, description: "损坏缓存后仍可恢复阅读") { state in
state.isOpened && state.pagination != "none" && state.mode == "bookPageMap"
}
XCTAssertEqual(recoveredState.lastError, "none", "缓存损坏不应导致阅读器进入错误状态")
}
func testClearingCacheForcesRebuild() throws {
app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, resetsReaderState: true, clearsCache: true)
app.waitForReader(timeout: 20)
_ = app.waitForDemoReaderState(timeout: 60, description: "首次缓存建立") { state in
state.pagination == "full" && (state.cacheFiles ?? 0) > 0
}
app.showReaderChromeIfNeeded()
app.buttons[IDs.readerBack].tap()
XCTAssertTrue(app.tables[IDs.demoBooksTable].waitForExistence(timeout: 5))
app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, resetsReaderState: false, clearsCache: true)
app.waitForReader(timeout: 20)
let rebuiltState = app.waitForDemoReaderState(timeout: 60, description: "清空缓存后重新构建") { state in
state.pagination == "full" && (state.cacheFiles ?? 0) > 0
}
XCTAssertGreaterThan(rebuiltState.cacheFiles ?? 0, 0, "清空缓存后应重新写入缓存文件")
} }
func testMiddleChapterOpenPositionStableAfterFullParse() throws { func testMiddleChapterOpenPositionStableAfterFullParse() throws {
@ -146,7 +188,7 @@ final class LargeBookOnDemandTests: XCTestCase {
func testLargeBookContinuousPagingExtendsKnownPageMap() throws { func testLargeBookContinuousPagingExtendsKnownPageMap() throws {
app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, displayType: "scroll", resetsReaderState: true) app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, displayType: "scroll", resetsReaderState: true)
app.waitForReader(timeout: 20) app.waitForReader(timeout: 20)
app.waitForReaderState(containing: "display=horizontalScroll", timeout: 8) app.waitForDemoReaderState(timeout: 8, description: "display=horizontalScroll") { $0.display == "horizontalScroll" }
let initialState = app.waitForDemoReaderState(timeout: 15, description: "初始局部分页") { state in let initialState = app.waitForDemoReaderState(timeout: 15, description: "初始局部分页") { state in
state.mode == "bookPageMap" && state.knownPages != nil && state.page != nil state.mode == "bookPageMap" && state.knownPages != nil && state.page != nil

View File

@ -57,7 +57,7 @@ final class LocationPersistenceTests: XCTestCase {
let paging = app.collectionViews[IDs.readerPaging] let paging = app.collectionViews[IDs.readerPaging]
if paging.waitForExistence(timeout: 5) { paging.swipeLeft() } if paging.waitForExistence(timeout: 5) { paging.swipeLeft() }
app.waitForReaderState(containing: "page=", timeout: 5) app.waitForDemoReaderState(timeout: 5, description: "page exists") { $0.page != nil }
app.showReaderChromeIfNeeded() app.showReaderChromeIfNeeded()
if app.buttons[IDs.readerSettings].waitForExistence(timeout: 3) { app.buttons[IDs.readerSettings].tap() } if app.buttons[IDs.readerSettings].waitForExistence(timeout: 3) { app.buttons[IDs.readerSettings].tap() }
@ -70,6 +70,37 @@ final class LocationPersistenceTests: XCTestCase {
app.launchAndOpenSampleBook() app.launchAndOpenSampleBook()
app.waitForReader(timeout: 12) app.waitForReader(timeout: 12)
app.waitForReaderState(containing: "reader=opened", timeout: 8) app.waitForDemoReaderState(timeout: 8, description: "reader=opened") { $0.isOpened }
}
func testBookmarkAndHighlightPersistAcrossReopen() throws {
app.launchAndOpenSampleBook(resetsReaderState: true)
app.waitForReader(timeout: 12)
app.showReaderChromeIfNeeded()
XCTAssertTrue(app.buttons[IDs.readerBookmark].waitForExistence(timeout: 3))
app.buttons[IDs.readerBookmark].tap()
app.waitForDemoReaderState(timeout: 5, description: "bookmarks=1") { $0.bookmarks == 1 }
app.hideReaderChromeIfNeeded()
let content = app.otherElements[IDs.readerSelectionText].firstMatch
XCTAssertTrue(content.waitForExistence(timeout: 5))
let start = content.coordinate(withNormalizedOffset: CGVector(dx: 0.2, dy: 0.5))
let end = content.coordinate(withNormalizedOffset: CGVector(dx: 0.55, dy: 0.5))
start.press(forDuration: 0.8, thenDragTo: end)
XCTAssertTrue(app.menuItems["高亮"].waitForExistence(timeout: 3))
app.menuItems["高亮"].tap()
app.waitForDemoReaderState(timeout: 8, description: "highlights=1") { $0.highlights == 1 }
app.showReaderChromeIfNeeded()
app.buttons[IDs.readerBack].tap()
app.launchAndOpenSampleBook(resetsReaderState: false)
app.waitForReader(timeout: 12)
let state = app.waitForDemoReaderState(timeout: 8, description: "bookmark/highlight restored") { state in
state.bookmarks == 1 && state.highlights == 1
}
XCTAssertEqual(state.bookmarks, 1)
XCTAssertEqual(state.highlights, 1)
} }
} }

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() { func testPagingViewExistsInScrollMode() {
app.launchAndOpenSampleBook(displayType: "scroll") app.launchAndOpenSampleBook(displayType: "scroll")
app.waitForReader() app.waitForReader()
app.waitForReaderState(containing: "display=horizontalScroll", timeout: 5) app.waitForDemoReaderState(timeout: 5, description: "display=horizontalScroll") { $0.display == "horizontalScroll" }
let paging = app.collectionViews[IDs.readerPaging] let paging = app.collectionViews[IDs.readerPaging]
XCTAssertTrue(paging.waitForExistence(timeout: 5), "分页滚动视图未出现") XCTAssertTrue(paging.waitForExistence(timeout: 5), "分页滚动视图未出现")
@ -27,7 +27,7 @@ final class PageNavigationTests: XCTestCase {
func testPagingViewExistsInVerticalScrollMode() { func testPagingViewExistsInVerticalScrollMode() {
app.launchAndOpenSampleBook(displayType: "vertical") app.launchAndOpenSampleBook(displayType: "vertical")
app.waitForReader() app.waitForReader()
app.waitForReaderState(containing: "display=verticalScroll", timeout: 5) app.waitForDemoReaderState(timeout: 5, description: "display=verticalScroll") { $0.display == "verticalScroll" }
let paging = app.collectionViews[IDs.readerPaging] let paging = app.collectionViews[IDs.readerPaging]
XCTAssertTrue(paging.waitForExistence(timeout: 5), "分页滚动视图未出现") XCTAssertTrue(paging.waitForExistence(timeout: 5), "分页滚动视图未出现")
@ -36,7 +36,7 @@ final class PageNavigationTests: XCTestCase {
func testSwipeInHorizontalScrollMode() { func testSwipeInHorizontalScrollMode() {
app.launchAndOpenSampleBook(displayType: "scroll") app.launchAndOpenSampleBook(displayType: "scroll")
app.waitForReader() app.waitForReader()
app.waitForReaderState(containing: "display=horizontalScroll", timeout: 5) app.waitForDemoReaderState(timeout: 5, description: "display=horizontalScroll") { $0.display == "horizontalScroll" }
let paging = app.collectionViews[IDs.readerPaging] let paging = app.collectionViews[IDs.readerPaging]
XCTAssertTrue(paging.waitForExistence(timeout: 5)) XCTAssertTrue(paging.waitForExistence(timeout: 5))
@ -51,4 +51,26 @@ final class PageNavigationTests: XCTestCase {
} }
XCTAssertNotEqual(beforePage, afterState.page, "水平滑动后页码未变化") XCTAssertNotEqual(beforePage, afterState.page, "水平滑动后页码未变化")
} }
func testSwipeRightReturnsToPreviousPage() {
app.launchAndOpenSampleBook(displayType: "scroll", pageNumber: 3)
app.waitForReader()
app.waitForDemoReaderState(timeout: 8, description: "page=3") { $0.page == 3 }
let paging = app.collectionViews[IDs.readerPaging]
XCTAssertTrue(paging.waitForExistence(timeout: 5))
paging.swipeRight()
let state = app.waitForDemoReaderState(timeout: 8, description: "returned to previous page") { state in
guard let page = state.page else { return false }
return page < 3
}
XCTAssertEqual(state.page, 2, "向右滑动后应回到上一页")
}
func testLaunchAtSpecificPageNumber() {
app.launchAndOpenSampleBook(pageNumber: 4)
app.waitForReader()
app.waitForDemoReaderState(timeout: 8, description: "page=4") { $0.page == 4 }
}
} }

View File

@ -37,12 +37,11 @@ final class ReaderAnnotationExtendedTests: XCTestCase {
XCTAssertTrue(app.buttons[IDs.readerSettings].exists, "设置按钮未出现") XCTAssertTrue(app.buttons[IDs.readerSettings].exists, "设置按钮未出现")
} }
func testSelectionTextElementExists() { func testSelectionTextElementExists() throws {
app.launchAndOpenSampleBook() app.launchAndOpenSampleBook()
app.waitForReader() app.waitForReader()
let selectionText = app.otherElements[IDs.readerSelectionText].firstMatch _ = try app.requireSelectionText()
XCTAssertTrue(selectionText.waitForExistence(timeout: 5), "文本选区元素未出现")
} }
func testReaderStateReportsPageInfo() { func testReaderStateReportsPageInfo() {

View File

@ -7,22 +7,20 @@ final class ReaderAnnotationTests: XCTestCase {
continueAfterFailure = false continueAfterFailure = false
} }
func testSelectionMenuCreatesHighlight() { func testSelectionMenuCreatesHighlight() throws {
app.launchAndOpenSampleBook(pageNumber: 2) app.launchAndOpenSampleBook()
app.waitForReader() app.waitForReader()
app.waitForReaderPage(2)
app.hideReaderChromeIfNeeded() app.hideReaderChromeIfNeeded()
let content = app.otherElements[IDs.readerSelectionText].firstMatch let content = try app.requireSelectionText()
XCTAssertTrue(content.waitForExistence(timeout: 5), "阅读内容区域未出现")
app.waitForReaderState(containing: "toolbar=hidden", timeout: 5) app.waitForDemoReaderState(timeout: 5, description: "toolbar=hidden") { $0.toolbar == "hidden" }
let start = content.coordinate(withNormalizedOffset: CGVector(dx: 0.2, dy: 0.5)) let start = content.coordinate(withNormalizedOffset: CGVector(dx: 0.2, dy: 0.5))
let end = content.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.5)) let end = content.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.5))
start.press(forDuration: 0.8, thenDragTo: end) start.press(forDuration: 0.8, thenDragTo: end)
app.waitForReaderState(containing: "selection=1", timeout: 5) app.waitForDemoReaderState(timeout: 5, description: "selection=1") { $0.selection == 1 }
let highlightMenuItem = app.menuItems["高亮"] let highlightMenuItem = app.menuItems["高亮"]
let highlightButton = app.buttons[IDs.readerSelectionHighlight] let highlightButton = app.buttons[IDs.readerSelectionHighlight]
@ -34,25 +32,43 @@ final class ReaderAnnotationTests: XCTestCase {
XCTFail("选中文本后未出现高亮菜单") XCTFail("选中文本后未出现高亮菜单")
} }
app.waitForReaderState(containing: "highlights=1", timeout: 8) app.waitForDemoReaderState(timeout: 8, description: "highlights=1") { $0.highlights == 1 }
} }
func testLongPressSelectionDoesNotShowToolbars() { func testLongPressSelectionDoesNotShowToolbars() throws {
app.launchAndOpenSampleBook(pageNumber: 2) app.launchAndOpenSampleBook()
app.waitForReader() app.waitForReader()
app.waitForReaderPage(2)
app.hideReaderChromeIfNeeded() app.hideReaderChromeIfNeeded()
let content = app.otherElements[IDs.readerSelectionText].firstMatch let content = try app.requireSelectionText()
XCTAssertTrue(content.waitForExistence(timeout: 5), "阅读内容区域未出现")
app.waitForReaderState(containing: "toolbar=hidden", timeout: 5) app.waitForDemoReaderState(timeout: 5, description: "toolbar=hidden") { $0.toolbar == "hidden" }
let start = content.coordinate(withNormalizedOffset: CGVector(dx: 0.2, dy: 0.5)) let start = content.coordinate(withNormalizedOffset: CGVector(dx: 0.2, dy: 0.5))
let end = content.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.5)) let end = content.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.5))
start.press(forDuration: 0.8, thenDragTo: end) start.press(forDuration: 0.8, thenDragTo: end)
app.waitForReaderState(containing: "selection=1", timeout: 8) app.waitForDemoReaderState(timeout: 8, description: "selection=1") { $0.selection == 1 }
app.waitForReaderState(containing: "toolbar=hidden", timeout: 3) app.waitForDemoReaderState(timeout: 3, description: "toolbar=hidden") { $0.toolbar == "hidden" }
}
func testSelectionMenuAnnotateCreatesHighlight() throws {
app.launchAndOpenSampleBook()
app.waitForReader()
app.hideReaderChromeIfNeeded()
let content = try app.requireSelectionText()
let start = content.coordinate(withNormalizedOffset: CGVector(dx: 0.25, dy: 0.5))
let end = content.coordinate(withNormalizedOffset: CGVector(dx: 0.7, dy: 0.5))
start.press(forDuration: 0.8, thenDragTo: end)
app.waitForDemoReaderState(timeout: 5, description: "selection=1") { $0.selection == 1 }
let annotateMenuItem = app.menuItems["批注"]
XCTAssertTrue(annotateMenuItem.waitForExistence(timeout: 3), "选中文本后未出现批注菜单")
annotateMenuItem.tap()
app.waitForDemoReaderState(timeout: 8, description: "highlights=1 after annotate") { $0.highlights == 1 }
} }
} }

View File

@ -247,6 +247,189 @@ final class SearchTests: XCTestCase {
XCTAssertNotEqual(countLabel.label, "0/0", "搜索状态应在工具栏恢复后保留") XCTAssertNotEqual(countLabel.label, "0/0", "搜索状态应在工具栏恢复后保留")
} }
// MARK: -
///
///
func testSearchMatchTextEqualsKeyword() {
let keyword = ""
app.launchAndOpenSampleBook(bookTitleQuery: "宝山辽墓材料与释读", searchKeyword: keyword)
app.waitForReader()
app.showReaderChromeIfNeeded()
//
let countLabel = app.staticTexts[IDs.searchCount]
XCTAssertTrue(countLabel.waitForExistence(timeout: 10), "搜索计数应出现")
XCTAssertNotEqual(countLabel.label, "0/0", "关键词 '\(keyword)' 应有匹配")
//
let state = app.waitForDemoReaderState(timeout: 5, description: "searchMatchText exists") {
$0.searchMatchText != nil && $0.searchMatchText != "none"
}
XCTAssertEqual(state.searchMatchText, keyword,
"当前搜索匹配文本应为 '\(keyword)',实际:\(state.searchMatchText ?? "nil")")
}
///
func testSearchMatchTextAfterRepagination() {
let keyword = ""
app.launchAndOpenSampleBook(bookTitleQuery: "宝山辽墓材料与释读", searchKeyword: keyword)
app.waitForReader()
app.showReaderChromeIfNeeded()
//
let countLabel = app.staticTexts[IDs.searchCount]
XCTAssertTrue(countLabel.waitForExistence(timeout: 10), "搜索计数应出现")
//
app.showReaderChromeIfNeeded()
let settingsButton = app.buttons[IDs.readerSettings]
XCTAssertTrue(settingsButton.waitForExistence(timeout: 3))
settingsButton.tap()
let fontIncrease = app.buttons[IDs.settingsFontIncrease]
XCTAssertTrue(fontIncrease.waitForExistence(timeout: 3))
fontIncrease.tap()
fontIncrease.tap()
Thread.sleep(forTimeInterval: 0.5)
let doneButton = app.buttons[IDs.settingsDone]
if doneButton.waitForExistence(timeout: 3) {
doneButton.tap()
}
Thread.sleep(forTimeInterval: 1)
//
app.showReaderChromeIfNeeded()
let prevButton = app.buttons[IDs.searchPrevious]
let nextButton = app.buttons[IDs.searchNext]
if prevButton.waitForExistence(timeout: 3), prevButton.isEnabled {
prevButton.tap()
Thread.sleep(forTimeInterval: 0.5)
}
if nextButton.waitForExistence(timeout: 3), nextButton.isEnabled {
nextButton.tap()
Thread.sleep(forTimeInterval: 0.5)
}
//
let state = app.waitForDemoReaderState(timeout: 5, description: "searchMatchText after repagination") {
$0.searchMatchText != nil && $0.searchMatchText != "none"
}
XCTAssertEqual(state.searchMatchText, keyword,
"重排后搜索匹配文本应为 '\(keyword)',实际:\(state.searchMatchText ?? "nil")")
}
// MARK: -
///
/// chapterOffset(for:) 使 row/column chapterOffset
///
func testSearchNavigationAfterRepagination() {
app.launchAndOpenSampleBook(searchKeyword: "")
app.waitForReader()
app.showReaderChromeIfNeeded()
//
let countLabel = app.staticTexts[IDs.searchCount]
XCTAssertTrue(countLabel.waitForExistence(timeout: 10), "搜索计数应出现")
XCTAssertNotEqual(countLabel.label, "0/0", "关键词 '的' 应有匹配")
//
let countText = countLabel.label
let parts = countText.split(separator: "/")
guard parts.count == 2, let total = Int(parts[1]), total >= 3 else {
XCTSkip("需要至少 3 个匹配才能测试多页跳转")
return
}
//
app.showReaderChromeIfNeeded()
let settingsButton = app.buttons[IDs.readerSettings]
XCTAssertTrue(settingsButton.waitForExistence(timeout: 3), "设置按钮应存在")
settingsButton.tap()
let fontIncrease = app.buttons[IDs.settingsFontIncrease]
XCTAssertTrue(fontIncrease.waitForExistence(timeout: 3), "字号增大按钮应存在")
fontIncrease.tap()
fontIncrease.tap()
Thread.sleep(forTimeInterval: 0.5)
let doneButton = app.buttons[IDs.settingsDone]
if doneButton.waitForExistence(timeout: 3) {
doneButton.tap()
}
Thread.sleep(forTimeInterval: 1)
//
let nextButton = app.buttons[IDs.searchNext]
let prevButton = app.buttons[IDs.searchPrevious]
XCTAssertTrue(nextButton.waitForExistence(timeout: 3), "下一个按钮应存在")
XCTAssertTrue(prevButton.waitForExistence(timeout: 3), "上一个按钮应存在")
// 2
nextButton.tap()
Thread.sleep(forTimeInterval: 0.5)
let stateMatch2 = app.waitForDemoReaderState(timeout: 5, description: "match 2 page") {
($0.page ?? 0) > 0
}
let pageAtMatch2 = stateMatch2.page ?? 1
//
let content = app.otherElements[IDs.readerContent].firstMatch
if content.waitForExistence(timeout: 3) {
for _ in 0..<4 {
content.swipeLeft()
Thread.sleep(forTimeInterval: 0.3)
}
Thread.sleep(forTimeInterval: 0.5)
}
// 3
nextButton.tap()
Thread.sleep(forTimeInterval: 0.5)
let stateMatch3 = app.waitForDemoReaderState(timeout: 5, description: "match 3 page") {
($0.page ?? 0) > 0
}
let pageAtMatch3 = stateMatch3.page ?? 1
//
if content.waitForExistence(timeout: 3) {
for _ in 0..<4 {
content.swipeLeft()
Thread.sleep(forTimeInterval: 0.3)
}
Thread.sleep(forTimeInterval: 0.5)
}
// 2
prevButton.tap()
Thread.sleep(forTimeInterval: 0.5)
let stateBack2 = app.waitForDemoReaderState(timeout: 5, description: "back to match 2") {
$0.page == pageAtMatch2
}
XCTAssertEqual(stateBack2.page, pageAtMatch2,
"重排后搜索跳回第 2 个匹配应回到第 \(pageAtMatch2) 页,实际:\(stateBack2.page ?? -1)")
//
if content.waitForExistence(timeout: 3) {
for _ in 0..<4 {
content.swipeLeft()
Thread.sleep(forTimeInterval: 0.3)
}
Thread.sleep(forTimeInterval: 0.5)
}
// 3
nextButton.tap()
Thread.sleep(forTimeInterval: 0.5)
let stateBack3 = app.waitForDemoReaderState(timeout: 5, description: "back to match 3") {
$0.page == pageAtMatch3
}
XCTAssertEqual(stateBack3.page, pageAtMatch3,
"重排后搜索跳到第 3 个匹配应回到第 \(pageAtMatch3) 页,实际:\(stateBack3.page ?? -1)")
}
// MARK: - // MARK: -
private func openSearchBar() { private func openSearchBar() {

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["拷贝"] let copyMenuItem = app.menuItems["拷贝"]
if copyMenuItem.waitForExistence(timeout: 3) { copyMenuItem.tap() } if copyMenuItem.waitForExistence(timeout: 3) { copyMenuItem.tap() }
app.waitForReaderState(containing: "reader=opened", timeout: 5) app.waitForDemoReaderState(timeout: 5, description: "reader=opened") { $0.isOpened }
} }
} }

View File

@ -10,7 +10,7 @@ final class SettingsEffectTests: XCTestCase {
func testFontSizeChangeUpdatesContent() throws { func testFontSizeChangeUpdatesContent() throws {
app.launchAndOpenSampleBook() app.launchAndOpenSampleBook()
app.waitForReader(timeout: 12) app.waitForReader(timeout: 12)
app.waitForReaderState(containing: "page=", timeout: 5) app.waitForDemoReaderState(timeout: 5, description: "page exists") { $0.page != nil }
app.showReaderChromeIfNeeded() app.showReaderChromeIfNeeded()
XCTAssertTrue(app.buttons[IDs.readerSettings].waitForExistence(timeout: 3), "设置按钮不存在") XCTAssertTrue(app.buttons[IDs.readerSettings].waitForExistence(timeout: 3), "设置按钮不存在")
@ -23,7 +23,7 @@ final class SettingsEffectTests: XCTestCase {
XCTAssertTrue(app.buttons[IDs.settingsDone].waitForExistence(timeout: 3), "完成按钮不存在") XCTAssertTrue(app.buttons[IDs.settingsDone].waitForExistence(timeout: 3), "完成按钮不存在")
app.buttons[IDs.settingsDone].tap() app.buttons[IDs.settingsDone].tap()
app.waitForReaderState(containing: "reader=opened", timeout: 8) app.waitForDemoReaderState(timeout: 8, description: "reader=opened") { $0.isOpened }
} }
func testThemeSwitchChangesBackground() throws { func testThemeSwitchChangesBackground() throws {
@ -59,7 +59,7 @@ final class SettingsEffectTests: XCTestCase {
} }
app.buttons[IDs.settingsDone].tap() app.buttons[IDs.settingsDone].tap()
app.waitForReaderState(containing: "reader=opened", timeout: 8) app.waitForDemoReaderState(timeout: 8, description: "reader=opened") { $0.isOpened }
} }
func testLineHeightChangeUpdatesContent() throws { func testLineHeightChangeUpdatesContent() throws {
@ -76,13 +76,13 @@ final class SettingsEffectTests: XCTestCase {
} }
app.buttons[IDs.settingsDone].tap() app.buttons[IDs.settingsDone].tap()
app.waitForReaderState(containing: "reader=opened", timeout: 8) app.waitForDemoReaderState(timeout: 8, description: "reader=opened") { $0.isOpened }
} }
func testDisplayTypeChangeRePaginates() throws { func testDisplayTypeChangeRePaginates() throws {
app.launchAndOpenSampleBook(displayType: "horizontalscroll") app.launchAndOpenSampleBook(displayType: "horizontalscroll")
app.waitForReader(timeout: 12) app.waitForReader(timeout: 12)
app.waitForReaderState(containing: "display=horizontalScroll", timeout: 5) app.waitForDemoReaderState(timeout: 5, description: "display=horizontalScroll") { $0.display == "horizontalScroll" }
app.showReaderChromeIfNeeded() app.showReaderChromeIfNeeded()
app.buttons[IDs.readerSettings].tap() app.buttons[IDs.readerSettings].tap()
@ -94,7 +94,7 @@ final class SettingsEffectTests: XCTestCase {
} }
app.buttons[IDs.settingsDone].tap() app.buttons[IDs.settingsDone].tap()
app.waitForReaderState(containing: "display=", timeout: 8) app.waitForDemoReaderState(timeout: 8, description: "display changed") { $0.display != nil }
} }
func testDefaultBodyPaginationDoesNotEnableWidowOrphanCompaction() throws { func testDefaultBodyPaginationDoesNotEnableWidowOrphanCompaction() throws {
@ -141,4 +141,60 @@ final class SettingsEffectTests: XCTestCase {
app.buttons[IDs.settingsDone].tap() app.buttons[IDs.settingsDone].tap()
} }
func testThemeSelectionPersistsAfterReopen() throws {
app.launchAndOpenSampleBook(resetsReaderState: true)
app.waitForReader(timeout: 12)
app.showReaderChromeIfNeeded()
app.buttons[IDs.readerSettings].tap()
let darkThemeButton = app.buttons[IDs.settingsTheme(5)]
XCTAssertTrue(darkThemeButton.waitForExistence(timeout: 5))
darkThemeButton.tap()
XCTAssertEqual(darkThemeButton.value as? String, "selected", "切换主题后当前主题按钮应为选中态")
app.buttons[IDs.settingsDone].tap()
app.showReaderChromeIfNeeded()
app.buttons[IDs.readerBack].tap()
app.launchAndOpenSampleBook(resetsReaderState: false)
app.waitForReader(timeout: 12)
app.showReaderChromeIfNeeded()
app.buttons[IDs.readerSettings].tap()
let reopenedDarkThemeButton = app.buttons[IDs.settingsTheme(5)]
XCTAssertTrue(reopenedDarkThemeButton.waitForExistence(timeout: 5))
XCTAssertEqual(reopenedDarkThemeButton.value as? String, "selected", "主题应在重启后保持选中")
app.buttons[IDs.settingsDone].tap()
}
func testFontChoiceChangePersistsAfterReopen() throws {
app.launchAndOpenSampleBook(resetsReaderState: true)
app.waitForReader(timeout: 12)
app.showReaderChromeIfNeeded()
app.buttons[IDs.readerSettings].tap()
let fontChoiceControl = app.segmentedControls[IDs.settingsFontChoice]
XCTAssertTrue(fontChoiceControl.waitForExistence(timeout: 5))
if fontChoiceControl.buttons.count > 1 {
fontChoiceControl.buttons.element(boundBy: 1).tap()
}
XCTAssertEqual(fontChoiceControl.value as? String, "宋体", "切换字体后当前字体值应更新")
app.buttons[IDs.settingsDone].tap()
app.showReaderChromeIfNeeded()
app.buttons[IDs.readerBack].tap()
app.launchAndOpenSampleBook(resetsReaderState: false)
app.waitForReader(timeout: 12)
app.showReaderChromeIfNeeded()
app.buttons[IDs.readerSettings].tap()
let reopenedFontChoiceControl = app.segmentedControls[IDs.settingsFontChoice]
XCTAssertTrue(reopenedFontChoiceControl.waitForExistence(timeout: 5))
XCTAssertEqual(reopenedFontChoiceControl.value as? String, "宋体", "字体选择应在重启后保持")
app.buttons[IDs.settingsDone].tap()
}
} }

View File

@ -55,7 +55,7 @@ final class SettingsExtendedTests: XCTestCase {
displayType.buttons["横滑"].tap() displayType.buttons["横滑"].tap()
app.buttons[IDs.settingsDone].tap() app.buttons[IDs.settingsDone].tap()
app.waitForReaderState(containing: "display=horizontalScroll", timeout: 5) app.waitForDemoReaderState(timeout: 5, description: "display=horizontalScroll") { $0.display == "horizontalScroll" }
} }
func testAllThemeButtonsExist() { func testAllThemeButtonsExist() {

View File

@ -47,7 +47,7 @@ final class TOCInteractionTests: XCTestCase {
XCTAssertNotEqual(updatedPage, initialPage, "目录跳转后页码应变化") XCTAssertNotEqual(updatedPage, initialPage, "目录跳转后页码应变化")
} else { } else {
tocTable.cells.element(boundBy: 0).tap() tocTable.cells.element(boundBy: 0).tap()
app.waitForReaderState(containing: "reader=opened", timeout: 8) app.waitForDemoReaderState(timeout: 8, description: "reader=opened") { $0.isOpened }
} }
} }

View File

@ -10,7 +10,7 @@ final class TableOfContentsTests: XCTestCase {
func testTocButtonExists() { func testTocButtonExists() {
app.launchAndOpenSampleBook(bookTitleQuery: "宝山辽墓材料与释读") app.launchAndOpenSampleBook(bookTitleQuery: "宝山辽墓材料与释读")
app.waitForReader() app.waitForReader()
app.showReaderChromeIfNeeded() app.showReaderChromeIfNeeded(requiredButtonIDs: [IDs.readerToc])
let tocButton = app.buttons[IDs.readerToc] let tocButton = app.buttons[IDs.readerToc]
XCTAssertTrue(tocButton.waitForExistence(timeout: 5), "目录按钮未出现") XCTAssertTrue(tocButton.waitForExistence(timeout: 5), "目录按钮未出现")
@ -19,7 +19,7 @@ final class TableOfContentsTests: XCTestCase {
func testTocButtonTappable() { func testTocButtonTappable() {
app.launchAndOpenSampleBook(bookTitleQuery: "宝山辽墓材料与释读") app.launchAndOpenSampleBook(bookTitleQuery: "宝山辽墓材料与释读")
app.waitForReader() app.waitForReader()
app.showReaderChromeIfNeeded() app.showReaderChromeIfNeeded(requiredButtonIDs: [IDs.readerToc])
let tocButton = app.buttons[IDs.readerToc] let tocButton = app.buttons[IDs.readerToc]
XCTAssertTrue(tocButton.waitForExistence(timeout: 5)) XCTAssertTrue(tocButton.waitForExistence(timeout: 5))

View File

@ -35,7 +35,7 @@ final class ToolbarStateTests: XCTestCase {
app.showReaderChromeIfNeeded() app.showReaderChromeIfNeeded()
XCTAssertTrue(app.buttons[IDs.readerBack].waitForExistence(timeout: 3)) XCTAssertTrue(app.buttons[IDs.readerBack].waitForExistence(timeout: 3))
app.waitForReaderState(containing: "reader=opened", timeout: 5) app.waitForDemoReaderState(timeout: 5, description: "reader=opened") { $0.isOpened }
} }
func testAddHighlightButtonDisabledWithoutSelection() throws { func testAddHighlightButtonDisabledWithoutSelection() throws {
@ -66,6 +66,6 @@ final class ToolbarStateTests: XCTestCase {
let paging = app.collectionViews[IDs.readerPaging] let paging = app.collectionViews[IDs.readerPaging]
if paging.waitForExistence(timeout: 5) { paging.swipeLeft() } if paging.waitForExistence(timeout: 5) { paging.swipeLeft() }
app.waitForReaderState(containing: "reader=opened", timeout: 5) app.waitForDemoReaderState(timeout: 5, description: "reader=opened") { $0.isOpened }
} }
} }

View File

@ -72,7 +72,11 @@ enum RDEPUBJavaScriptBridge {
window.WeReadApi.clearHighlights(); window.WeReadApi.clearHighlights();
window.WeReadApi.setHighlights(\(jsonString(from: highlightsPayload(request.highlights), fallback: "[]"))); window.WeReadApi.setHighlights(\(jsonString(from: highlightsPayload(request.highlights), fallback: "[]")));
if (\(jsonString(from: targetLocationPayload(request.targetLocation), fallback: "null")) !== null) { if (\(jsonString(from: targetLocationPayload(request.targetLocation), fallback: "null")) !== null) {
window.WeReadApi.scrollToLocation(\(jsonString(from: targetLocationPayload(request.targetLocation), fallback: "null")), \(request.pageIndex)); window.WeReadApi.scrollToLocation(
\(jsonString(from: targetLocationPayload(request.targetLocation), fallback: "null")),
\(request.pageIndex),
\(javaScriptStringLiteral(request.targetHighlightRangeInfo))
);
} else { } else {
window.WeReadApi.scrollToPage(\(request.pageIndex)); window.WeReadApi.scrollToPage(\(request.pageIndex));
} }

View File

@ -212,6 +212,8 @@ public enum RDEPUBParserError: LocalizedError {
case missingManifestItem(idref: String) case missingManifestItem(idref: String)
/// spine /// spine
case emptySpine case emptySpine
/// 穿
case invalidArchiveEntryPath(String)
public var errorDescription: String? { public var errorDescription: String? {
switch self { switch self {
@ -229,6 +231,8 @@ public enum RDEPUBParserError: LocalizedError {
return "spine itemref 找不到对应 manifest 项: \(idref)" return "spine itemref 找不到对应 manifest 项: \(idref)"
case .emptySpine: case .emptySpine:
return "OPF spine 为空" return "OPF spine 为空"
case .invalidArchiveEntryPath(let path):
return "归档条目路径非法: \(path)"
} }
} }
} }

View File

@ -53,9 +53,7 @@ public final class RDEPUBPaginator: NSObject {
webView.scrollView.showsVerticalScrollIndicator = false webView.scrollView.showsVerticalScrollIndicator = false
webView.isUserInteractionEnabled = false webView.isUserInteractionEnabled = false
if #available(iOS 16.4, *) { if #available(iOS 16.4, *) {
webView.isInspectable = true webView.isInspectable = RDEPUBWebViewDebug.isInspectableEnabled
} else {
// Fallback on earlier versions
} }
RDEPUBWebViewDebug.log("PaginatorWebView", message: "configured webView=\(RDEPUBWebViewDebug.webViewID(webView))") RDEPUBWebViewDebug.log("PaginatorWebView", message: "configured webView=\(RDEPUBWebViewDebug.webViewID(webView))")
return webView return webView
@ -297,6 +295,9 @@ public final class RDEPUBPaginator: NSObject {
return mediaType.contains("html") || mediaType.contains("xhtml") || mediaType.contains("xml") return mediaType.contains("html") || mediaType.contains("xhtml") || mediaType.contains("xml")
} }
///
private var measurementPassValues: [Int] = []
/// 0ms/80ms/180ms /// 0ms/80ms/180ms
private func scheduleMeasurementPass() { private func scheduleMeasurementPass() {
let sessionID = activeSessionID let sessionID = activeSessionID
@ -306,8 +307,18 @@ public final class RDEPUBPaginator: NSObject {
let currentSpineIndex = currentSpineIndexForMeasurement() else { let currentSpineIndex = currentSpineIndexForMeasurement() else {
return return
} }
pageCounts[currentSpineIndex] = max(1, pendingMeasurementValue) let finalValue = max(1, pendingMeasurementValue)
//
if measurementPassValues.count >= 2 {
let minVal = measurementPassValues.min() ?? 1
let maxVal = measurementPassValues.max() ?? 1
if minVal > 0 && Double(maxVal - minVal) / Double(minVal) > 0.2 {
RDEPUBWebViewDebug.log(debugScope, message: "measurement instability: passes=\(measurementPassValues) spine=\(currentSpineIndex)")
}
}
pageCounts[currentSpineIndex] = finalValue
currentMeasurementOffset += 1 currentMeasurementOffset += 1
measurementPassValues = []
measureNextSpineItem() measureNextSpineItem()
return return
} }
@ -333,8 +344,10 @@ public final class RDEPUBPaginator: NSObject {
} }
if let number = value as? NSNumber { if let number = value as? NSNumber {
self.pendingMeasurementValue = max(self.pendingMeasurementValue, number.intValue) self.pendingMeasurementValue = max(self.pendingMeasurementValue, number.intValue)
self.measurementPassValues.append(number.intValue)
} else if let intValue = value as? Int { } else if let intValue = value as? Int {
self.pendingMeasurementValue = max(self.pendingMeasurementValue, intValue) self.pendingMeasurementValue = max(self.pendingMeasurementValue, intValue)
self.measurementPassValues.append(intValue)
} }
RDEPUBWebViewDebug.log(self.debugScope, message: "measurement value=\(self.pendingMeasurementValue) session=\(sessionID)") RDEPUBWebViewDebug.log(self.debugScope, message: "measurement value=\(self.pendingMeasurementValue) session=\(sessionID)")
self.scheduleMeasurementPass() self.scheduleMeasurementPass()
@ -344,6 +357,20 @@ public final class RDEPUBPaginator: NSObject {
} }
extension RDEPUBPaginator: WKNavigationDelegate { extension RDEPUBPaginator: WKNavigationDelegate {
public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
guard let url = navigationAction.request.url else {
decisionHandler(.cancel)
return
}
let scheme = url.scheme?.lowercased() ?? ""
if scheme == "file" || scheme == RDEPUBResourceURLSchemeHandler.scheme {
decisionHandler(.allow)
} else {
RDEPUBWebViewDebug.log(debugScope, message: "blocked navigation to non-local scheme: \(scheme)")
decisionHandler(.cancel)
}
}
public func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { public func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didStart", url: webView.url) RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didStart", url: webView.url)
} }

View File

@ -49,7 +49,9 @@ extension RDEPUBParser {
try fileManager.createDirectory(at: extractionURL, withIntermediateDirectories: true) try fileManager.createDirectory(at: extractionURL, withIntermediateDirectories: true)
for entry in archive { for entry in archive {
let destinationURL = extractionURL.appendingPathComponent(entry.path) guard let destinationURL = validatedExtractionDestination(for: entry.path, extractionRoot: extractionURL) else {
throw RDEPUBParserError.invalidArchiveEntryPath(entry.path)
}
switch entry.type { switch entry.type {
case .directory: case .directory:
try fileManager.createDirectory(at: destinationURL, withIntermediateDirectories: true) try fileManager.createDirectory(at: destinationURL, withIntermediateDirectories: true)
@ -64,6 +66,31 @@ extension RDEPUBParser {
return extractionURL return extractionURL
} }
/// 穿
/// - Parameters:
/// - entryPath:
/// - extractionRoot:
/// - Returns: URL nil
private func validatedExtractionDestination(for entryPath: String, extractionRoot: URL) -> URL? {
//
if entryPath.hasPrefix("/") {
return nil
}
// ..
let components = entryPath.split(separator: "/", omittingEmptySubsequences: true)
if components.contains(where: { $0 == ".." }) {
return nil
}
let destinationURL = extractionRoot.appendingPathComponent(entryPath)
let standardizedDest = destinationURL.standardizedFileURL.path
let standardizedRoot = extractionRoot.standardizedFileURL.path
//
guard standardizedDest.hasPrefix(standardizedRoot) else {
return nil
}
return destinationURL
}
/// EPUB /// EPUB
/// ~/Library/Caches/ssreaderview-epub/{slug}-{fileSize}-{modifiedTimestamp}/ /// ~/Library/Caches/ssreaderview-epub/{slug}-{fileSize}-{modifiedTimestamp}/
func temporaryExtractionDirectory(for epubURL: URL) -> URL { func temporaryExtractionDirectory(for epubURL: URL) -> URL {

View File

@ -76,6 +76,7 @@ public struct RDEPUBPreferences: Equatable {
publication: RDEPUBPublication, publication: RDEPUBPublication,
viewportSize: CGSize, viewportSize: CGSize,
targetLocation: RDEPUBLocation? = nil, targetLocation: RDEPUBLocation? = nil,
targetHighlightRangeInfo: String? = nil,
highlights: [RDEPUBHighlight] = [], highlights: [RDEPUBHighlight] = [],
searchPresentation: RDEPUBSearchPresentation? = nil searchPresentation: RDEPUBSearchPresentation? = nil
) -> RDEPUBRenderRequest? { ) -> RDEPUBRenderRequest? {
@ -104,6 +105,7 @@ public struct RDEPUBPreferences: Equatable {
totalPagesInChapter: page.totalPagesInChapter, totalPagesInChapter: page.totalPagesInChapter,
presentation: presentationStyle(viewportSize: viewportSize), presentation: presentationStyle(viewportSize: viewportSize),
targetLocation: targetLocation, targetLocation: targetLocation,
targetHighlightRangeInfo: targetHighlightRangeInfo,
highlights: highlights, highlights: highlights,
searchPresentation: searchPresentation searchPresentation: searchPresentation
) )

View File

@ -31,6 +31,8 @@ public final class RDEPUBReadingSession {
public private(set) var pendingNavigationLocation: RDEPUBLocation? public private(set) var pendingNavigationLocation: RDEPUBLocation?
/// ///
public private(set) var pendingNavigationPageNum: Int? public private(set) var pendingNavigationPageNum: Int?
/// Range Web 使
public private(set) var pendingNavigationHighlightRangeInfo: String?
/// ///
public private(set) var currentViewport: RDEPUBViewport? public private(set) var currentViewport: RDEPUBViewport?
/// + + + /// + + +
@ -105,6 +107,7 @@ public final class RDEPUBReadingSession {
public func clearPendingNavigation() { public func clearPendingNavigation() {
pendingNavigationLocation = nil pendingNavigationLocation = nil
pendingNavigationPageNum = nil pendingNavigationPageNum = nil
pendingNavigationHighlightRangeInfo = nil
} }
/// spine /// spine
@ -123,6 +126,26 @@ public final class RDEPUBReadingSession {
return pendingNavigationLocation return pendingNavigationLocation
} }
/// Range
public func pendingHighlightRangeInfo(forPageNumber pageNumber: Int, spineIndex: Int?) -> String? {
guard pendingNavigationPageNum == pageNumber,
let pendingNavigationHighlightRangeInfo,
!pendingNavigationHighlightRangeInfo.isEmpty else {
return nil
}
guard let pendingNavigationLocation else {
return pendingNavigationHighlightRangeInfo
}
guard let spineIndex else {
return pendingNavigationHighlightRangeInfo
}
let pageHref = resourceResolver.href(forSpineIndex: spineIndex)
guard resourceResolver.normalizedHref(pageHref ?? "") == resourceResolver.normalizedHref(pendingNavigationLocation.href) else {
return nil
}
return pendingNavigationHighlightRangeInfo
}
/// spine spread /// spine spread
public func pageContains(spineIndex: Int, in page: EPUBPage) -> Bool { public func pageContains(spineIndex: Int, in page: EPUBPage) -> Bool {
if let fixedSpread = page.fixedSpread { if let fixedSpread = page.fixedSpread {
@ -225,7 +248,8 @@ public final class RDEPUBReadingSession {
public func queueNavigation( public func queueNavigation(
to location: RDEPUBLocation, to location: RDEPUBLocation,
relativeToSpineIndex spineIndex: Int? = nil, relativeToSpineIndex spineIndex: Int? = nil,
bookIdentifier: String? bookIdentifier: String?,
targetHighlightRangeInfo: String? = nil
) -> Int? { ) -> Int? {
guard let normalizedLocation = resourceResolver.normalizedLocation( guard let normalizedLocation = resourceResolver.normalizedLocation(
location, location,
@ -235,8 +259,14 @@ public final class RDEPUBReadingSession {
return nil return nil
} }
pendingNavigationLocation = normalizedLocation.fragment == nil ? nil : normalizedLocation let hasTargetHighlightRangeInfo = targetHighlightRangeInfo?.isEmpty == false
pendingNavigationPageNum = normalizedLocation.fragment == nil ? nil : pageIndex + 1 let shouldKeepPendingNavigation =
normalizedLocation.fragment != nil ||
normalizedLocation.rangeAnchor != nil ||
hasTargetHighlightRangeInfo
pendingNavigationLocation = shouldKeepPendingNavigation ? normalizedLocation : nil
pendingNavigationPageNum = shouldKeepPendingNavigation ? pageIndex + 1 : nil
pendingNavigationHighlightRangeInfo = hasTargetHighlightRangeInfo ? targetHighlightRangeInfo : nil
transition(to: .jumping) transition(to: .jumping)
return pageIndex + 1 return pageIndex + 1
} }
@ -363,4 +393,4 @@ public final class RDEPUBReadingSession {
return (pages, chapters) return (pages, chapters)
} }
} }

View File

@ -85,6 +85,8 @@ public struct RDEPUBReflowableRenderRequest: Equatable {
public var presentation: RDEPUBPresentationStyle public var presentation: RDEPUBPresentationStyle
/// ///
public var targetLocation: RDEPUBLocation? public var targetLocation: RDEPUBLocation?
/// DOM Range Web
public var targetHighlightRangeInfo: String?
/// ///
public var highlights: [RDEPUBHighlight] public var highlights: [RDEPUBHighlight]
/// ///
@ -97,6 +99,7 @@ public struct RDEPUBReflowableRenderRequest: Equatable {
totalPagesInChapter: Int, totalPagesInChapter: Int,
presentation: RDEPUBPresentationStyle, presentation: RDEPUBPresentationStyle,
targetLocation: RDEPUBLocation? = nil, targetLocation: RDEPUBLocation? = nil,
targetHighlightRangeInfo: String? = nil,
highlights: [RDEPUBHighlight] = [], highlights: [RDEPUBHighlight] = [],
searchPresentation: RDEPUBSearchPresentation? = nil searchPresentation: RDEPUBSearchPresentation? = nil
) { ) {
@ -106,6 +109,7 @@ public struct RDEPUBReflowableRenderRequest: Equatable {
self.totalPagesInChapter = totalPagesInChapter self.totalPagesInChapter = totalPagesInChapter
self.presentation = presentation self.presentation = presentation
self.targetLocation = targetLocation self.targetLocation = targetLocation
self.targetHighlightRangeInfo = targetHighlightRangeInfo
self.highlights = highlights self.highlights = highlights
self.searchPresentation = searchPresentation self.searchPresentation = searchPresentation
} }

View File

@ -8,6 +8,12 @@ import WebKit
/// URL ss-reader://book/ EPUB /// URL ss-reader://book/ EPUB
public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler { public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler {
struct DebugMetrics {
let streamedResponses: Int
let inMemoryResponses: Int
let failures: Int
}
/// ///
public static let scheme = "ss-reader" public static let scheme = "ss-reader"
/// ///
@ -21,12 +27,34 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
private let syncQueue = DispatchQueue(label: "com.ssreaderview.epub.scheme-handler") private let syncQueue = DispatchQueue(label: "com.ssreaderview.epub.scheme-handler")
/// URL Scheme /// URL Scheme
private var activeTasks: [ObjectIdentifier: Bool] = [:] private var activeTasks: [ObjectIdentifier: Bool] = [:]
private static let debugMetricsQueue = DispatchQueue(label: "com.ssreaderview.epub.scheme-handler.metrics")
private static var streamedResponseCount = 0
private static var inMemoryResponseCount = 0
private static var failureCount = 0
public init(parser: RDEPUBParser) { public init(parser: RDEPUBParser) {
self.parser = parser self.parser = parser
super.init() super.init()
} }
static func resetDebugMetrics() {
debugMetricsQueue.sync {
streamedResponseCount = 0
inMemoryResponseCount = 0
failureCount = 0
}
}
static func debugMetricsSnapshot() -> DebugMetrics {
debugMetricsQueue.sync {
DebugMetrics(
streamedResponses: streamedResponseCount,
inMemoryResponses: inMemoryResponseCount,
failures: failureCount
)
}
}
/// URL Scheme /// URL Scheme
/// //CSS/JS 404 /// //CSS/JS 404
public func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) { public func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) {
@ -55,6 +83,7 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
urlSchemeTask.didFinish() urlSchemeTask.didFinish()
} else { } else {
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, event: "missing-file") RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, event: "missing-file")
Self.recordFailure()
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorFileDoesNotExist)) urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorFileDoesNotExist))
} }
clearTask(taskID) clearTask(taskID)
@ -63,27 +92,12 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "resolved") RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "resolved")
do { let fileSize = resourceSize(for: fileURL)
let data = try Data(contentsOf: fileURL) if let fileSize, fileSize > 524_288 {
guard isTaskActive(taskID) else { return } respondWithStreaming(fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask, fileSize: fileSize)
} else {
let response = URLResponse( respondWithInMemoryData(fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask)
url: requestURL,
mimeType: Self.mimeType(for: fileURL.pathExtension),
expectedContentLength: data.count,
textEncodingName: Self.textEncodingName(for: fileURL.pathExtension)
)
urlSchemeTask.didReceive(response)
urlSchemeTask.didReceive(data)
urlSchemeTask.didFinish()
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "finished")
} catch {
guard isTaskActive(taskID) else { return }
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "failed", error: error)
urlSchemeTask.didFailWithError(error)
} }
clearTask(taskID)
} }
/// URL Scheme /// URL Scheme
@ -106,6 +120,89 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
} }
} }
private func resourceSize(for url: URL) -> UInt64? {
guard let attrs = try? fileManager.attributesOfItem(atPath: url.path),
let size = attrs[.size] as? UInt64 else {
return nil
}
return size
}
private func respondWithInMemoryData(fileURL: URL, requestURL: URL, taskID: ObjectIdentifier, urlSchemeTask: any WKURLSchemeTask) {
do {
let data = try Data(contentsOf: fileURL)
guard isTaskActive(taskID) else { return }
let response = URLResponse(
url: requestURL,
mimeType: Self.mimeType(for: fileURL.pathExtension),
expectedContentLength: data.count,
textEncodingName: Self.textEncodingName(for: fileURL.pathExtension)
)
urlSchemeTask.didReceive(response)
urlSchemeTask.didReceive(data)
urlSchemeTask.didFinish()
Self.recordInMemoryResponse()
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "finished")
} catch {
guard isTaskActive(taskID) else { return }
Self.recordFailure()
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "failed", error: error)
urlSchemeTask.didFailWithError(error)
}
clearTask(taskID)
}
private func respondWithStreaming(fileURL: URL, requestURL: URL, taskID: ObjectIdentifier, urlSchemeTask: any WKURLSchemeTask, fileSize: UInt64) {
let response = URLResponse(
url: requestURL,
mimeType: Self.mimeType(for: fileURL.pathExtension),
expectedContentLength: Int(fileSize),
textEncodingName: Self.textEncodingName(for: fileURL.pathExtension)
)
urlSchemeTask.didReceive(response)
guard let fileHandle = try? FileHandle(forReadingFrom: fileURL) else {
guard isTaskActive(taskID) else { return }
Self.recordFailure()
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorFileDoesNotExist))
clearTask(taskID)
return
}
let chunkSize = 65_536
defer {
fileHandle.closeFile()
clearTask(taskID)
}
while true {
guard isTaskActive(taskID) else { return }
let data = fileHandle.readData(ofLength: chunkSize)
if data.isEmpty { break }
urlSchemeTask.didReceive(data)
}
urlSchemeTask.didFinish()
Self.recordStreamedResponse()
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "finished-streaming")
}
private static func recordStreamedResponse() {
debugMetricsQueue.sync {
streamedResponseCount += 1
}
}
private static func recordInMemoryResponse() {
debugMetricsQueue.sync {
inMemoryResponseCount += 1
}
}
private static func recordFailure() {
debugMetricsQueue.sync {
failureCount += 1
}
}
/// MIME /// MIME
private static func mimeType(for pathExtension: String) -> String { private static func mimeType(for pathExtension: String) -> String {
switch pathExtension.lowercased() { switch pathExtension.lowercased() {
@ -165,4 +262,4 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
return false return false
} }
} }
} }

View File

@ -60,7 +60,7 @@ extension RDEPUBWebView {
webView.backgroundColor = .clear webView.backgroundColor = .clear
webView.clipsToBounds = true webView.clipsToBounds = true
if #available(iOS 16.4, *) { if #available(iOS 16.4, *) {
webView.isInspectable = true webView.isInspectable = RDEPUBWebViewDebug.isInspectableEnabled
} }
addSubview(webView) addSubview(webView)

View File

@ -97,7 +97,8 @@ extension RDEPUBWebView {
request.targetLocation?.href ?? "", request.targetLocation?.href ?? "",
String(request.targetLocation?.progression ?? 0), String(request.targetLocation?.progression ?? 0),
String(request.targetLocation?.lastProgression ?? 0), String(request.targetLocation?.lastProgression ?? 0),
request.targetLocation?.fragment ?? "" request.targetLocation?.fragment ?? "",
request.targetHighlightRangeInfo ?? ""
].joined(separator: "|") ].joined(separator: "|")
let highlightSignature = request.highlights let highlightSignature = request.highlights
.map { [$0.id, $0.rangeInfo ?? "", $0.color, $0.style.rawValue].joined(separator: "|") } .map { [$0.id, $0.rangeInfo ?? "", $0.color, $0.style.rawValue].joined(separator: "|") }

View File

@ -6,6 +6,14 @@ import UIKit
import WebKit import WebKit
extension RDEPUBWebView { extension RDEPUBWebView {
private var normalSearchOverlayColor: UIColor {
UIColor(red: 0.21, green: 0.48, blue: 0.95, alpha: 0.16)
}
private var activeSearchOverlayColor: UIColor {
UIColor(red: 0.14, green: 0.42, blue: 0.95, alpha: 0.34)
}
/// WebView /// WebView
func applySearchDecorationsIfNeeded(completion: (() -> Void)? = nil) { func applySearchDecorationsIfNeeded(completion: (() -> Void)? = nil) {
guard let webView else { guard let webView else {
@ -70,7 +78,7 @@ extension RDEPUBWebView {
) )
let searchDecorations = decorationList( let searchDecorations = decorationList(
from: payload["search"] as? [[String: Any]], from: payload["search"] as? [[String: Any]],
defaultColor: UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.55) defaultColor: normalSearchOverlayColor
) )
return highlightDecorations + searchDecorations return highlightDecorations + searchDecorations
} }
@ -105,9 +113,9 @@ extension RDEPUBWebView {
let hex = item["color"] as? String ?? "#F8E16C" let hex = item["color"] as? String ?? "#F8E16C"
color = overlayColor(hex: hex, alpha: 1) ?? defaultColor color = overlayColor(hex: hex, alpha: 1) ?? defaultColor
case .activeSearch: case .activeSearch:
color = UIColor(red: 255 / 255, green: 159 / 255, blue: 67 / 255, alpha: 0.75) color = activeSearchOverlayColor
case .search: case .search:
color = UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.55) color = normalSearchOverlayColor
case .highlight: case .highlight:
let hex = item["color"] as? String ?? "#F8E16C" let hex = item["color"] as? String ?? "#F8E16C"
color = overlayColor(hex: hex, alpha: 0.45) ?? defaultColor color = overlayColor(hex: hex, alpha: 0.45) ?? defaultColor

View File

@ -21,6 +21,30 @@ enum RDEPUBWebViewDebug {
#endif #endif
}() }()
/// UserDefaults "RDEPUBWebViewVerboseEnabled"
/// logMessage
static var isVerboseEnabled: Bool = {
if let configured = UserDefaults.standard.object(forKey: "RDEPUBWebViewVerboseEnabled") as? Bool {
return configured
}
return false
}()
/// inspectable
/// UserDefaults
static var isInspectableEnabled: Bool = {
if let configured = UserDefaults.standard.object(forKey: "RDEPUBInspectableWebViewsEnabled") as? Bool {
return configured
}
return false
}()
///
static func applyDebugPolicy(inspectableEnabled: Bool, verboseLoggingEnabled: Bool) {
isInspectableEnabled = inspectableEnabled
isVerboseEnabled = verboseLoggingEnabled
}
/// WebView WebView /// WebView WebView
static func webViewID(_ webView: WKWebView?) -> String { static func webViewID(_ webView: WKWebView?) -> String {
guard let webView else { return "nil-webview" } guard let webView else { return "nil-webview" }
@ -51,10 +75,26 @@ enum RDEPUBWebViewDebug {
log(scope, message: "webView=\(webViewID(webView)) js=\(action) \(details)") log(scope, message: "webView=\(webViewID(webView)) js=\(action) \(details)")
} }
/// JS /// JS verbose
static func logMessage(_ scope: String, webView: WKWebView?, name: String, body: Any) { static func logMessage(_ scope: String, webView: WKWebView?, name: String, body: Any) {
guard isEnabled else { return } guard isEnabled else { return }
log(scope, message: "webView=\(webViewID(webView)) message=\(name) body=\(String(describing: body))") if isVerboseEnabled {
log(scope, message: "webView=\(webViewID(webView)) message=\(name) body=\(String(describing: body))")
return
}
if let dict = body as? [String: Any] {
let keys = dict.keys.sorted().joined(separator: ",")
var meta = "keys=[\(keys)]"
if let text = dict["text"] as? String {
meta += " textLen=\(text.count)"
}
if let urlString = dict["url"] as? String ?? dict["href"] as? String {
meta += " url=\(summarizedURL(URL(string: urlString)))"
}
log(scope, message: "webView=\(webViewID(webView)) message=\(name) \(meta)")
} else {
log(scope, message: "webView=\(webViewID(webView)) message=\(name) type=\(Swift.type(of: body))")
}
} }
/// URL Scheme URL URL /// URL Scheme URL URL
@ -81,4 +121,4 @@ enum RDEPUBWebViewDebug {
} }
return url.lastPathComponent.isEmpty ? path : url.lastPathComponent return url.lastPathComponent.isEmpty ? path : url.lastPathComponent
} }
} }

View File

@ -1,4 +1,4 @@
(function() { (function() {
if (window.RDReaderBridge) { return; } if (window.RDReaderBridge) { return; }
window.addEventListener('error', function(event) { window.addEventListener('error', function(event) {
try { try {
@ -470,7 +470,18 @@
setVisiblePageIndex(pageIndex); setVisiblePageIndex(pageIndex);
reportProgression(null); reportProgression(null);
}, },
scrollToLocation: function(location, fallbackPageIndex) { scrollToLocation: function(location, fallbackPageIndex, targetRangeInfo) {
if (targetRangeInfo) {
var highlightRange = rangeFromInfo(targetRangeInfo);
var highlightRects = rectPayloadForRange(highlightRange, 0, 0);
if (highlightRects.length) {
var highlightRect = highlightRects[0];
var highlightAbsoluteLeft = Math.max(0, highlightRect.x + currentPageOffset());
setVisiblePageIndex(Math.max(0, Math.floor(highlightAbsoluteLeft / effectivePageStride())));
reportProgression(location && location.fragment ? location.fragment : null);
return;
}
}
if (location && location.fragment) { if (location && location.fragment) {
var resolvedTarget = fragmentTarget(location.fragment); var resolvedTarget = fragmentTarget(location.fragment);
if (resolvedTarget) { if (resolvedTarget) {

View File

@ -20,7 +20,9 @@ struct RDEPUBChapterTailNormalizer {
previous.diagnostics.append(note) previous.diagnostics.append(note)
compacted.append(previous) compacted.append(previous)
} else { } else {
#if DEBUG
print("[EPUB][Pagination] href=\(href) dropped leading/intermediate whitespace frame \(NSStringFromRange(frame.contentRange))") print("[EPUB][Pagination] href=\(href) dropped leading/intermediate whitespace frame \(NSStringFromRange(frame.contentRange))")
#endif
} }
continue continue
} }
@ -36,7 +38,9 @@ struct RDEPUBChapterTailNormalizer {
previousFrame.diagnostics.append(note) previousFrame.diagnostics.append(note)
normalized.append(previousFrame) normalized.append(previousFrame)
} else { } else {
#if DEBUG
print("[EPUB][Pagination] href=\(href) dropped trailing frame \(NSStringFromRange(lastFrame.contentRange))") print("[EPUB][Pagination] href=\(href) dropped trailing frame \(NSStringFromRange(lastFrame.contentRange))")
#endif
} }
} }

View File

@ -137,7 +137,9 @@ public final class RDEPUBTextBookBuilder {
// //
cacheCoordinator.save(chapters: chapters, key: cacheKey) cacheCoordinator.save(chapters: chapters, key: cacheKey)
#if DEBUG
print(sampler.summary()) print(sampler.summary())
#endif
lastBuildPerformanceSamples = sampler.samples lastBuildPerformanceSamples = sampler.samples
return book return book
@ -207,11 +209,15 @@ public final class RDEPUBTextBookBuilder {
let plainText = rendered.attributedString.string.trimmingCharacters(in: .whitespacesAndNewlines) let plainText = rendered.attributedString.string.trimmingCharacters(in: .whitespacesAndNewlines)
if item.href.lowercased().contains("cover") { if item.href.lowercased().contains("cover") {
#if DEBUG
print("[EPUB][Cover] rendered href=\(item.href) textLength=\(plainText.count) attrLength=\(rendered.attributedString.length) attachments=\(attachmentCount(in: rendered.attributedString))") print("[EPUB][Cover] rendered href=\(item.href) textLength=\(plainText.count) attrLength=\(rendered.attributedString.length) attachments=\(attachmentCount(in: rendered.attributedString))")
#endif
} }
if shouldSkipChapter(item: item, content: rendered.attributedString, text: plainText) { if shouldSkipChapter(item: item, content: rendered.attributedString, text: plainText) {
if item.href.lowercased().contains("cover") { if item.href.lowercased().contains("cover") {
#if DEBUG
print("[EPUB][Cover] skipped href=\(item.href)") print("[EPUB][Cover] skipped href=\(item.href)")
#endif
} }
return nil return nil
} }
@ -301,7 +307,9 @@ public final class RDEPUBTextBookBuilder {
: normalizedFrames : normalizedFrames
if item.href.lowercased().contains("cover") { if item.href.lowercased().contains("cover") {
#if DEBUG
print("[EPUB][Cover] paginated href=\(item.href) pages=\(effectiveFrames.count) firstRange=\(effectiveFrames.first.map { NSStringFromRange($0.contentRange) } ?? "none")") print("[EPUB][Cover] paginated href=\(item.href) pages=\(effectiveFrames.count) firstRange=\(effectiveFrames.first.map { NSStringFromRange($0.contentRange) } ?? "none")")
#endif
} }
let chapterAttributedContent = content.copy() as! NSAttributedString let chapterAttributedContent = content.copy() as! NSAttributedString

View File

@ -170,7 +170,9 @@ public final class RDEPUBTextBookCache {
queue.sync { queue.sync {
let fileURL = cacheDirectory.appendingPathComponent(key) let fileURL = cacheDirectory.appendingPathComponent(key)
guard FileManager.default.fileExists(atPath: fileURL.path) else { guard FileManager.default.fileExists(atPath: fileURL.path) else {
#if DEBUG
print("[Cache] load MISS key=\(key)") print("[Cache] load MISS key=\(key)")
#endif
return nil return nil
} }
do { do {
@ -179,17 +181,23 @@ public final class RDEPUBTextBookCache {
ofClass: PaginationCacheArchive.self, ofClass: PaginationCacheArchive.self,
from: data from: data
) else { ) else {
#if DEBUG
print("[Cache] load MISS key=\(key) (unarchive returned nil)") print("[Cache] load MISS key=\(key) (unarchive returned nil)")
#endif
return nil return nil
} }
var result: [String: RDEPUBTextChapterPaginationCache] = [:] var result: [String: RDEPUBTextChapterPaginationCache] = [:]
for chapter in archive.chapters { for chapter in archive.chapters {
result[chapter.href] = chapter.toCache() result[chapter.href] = chapter.toCache()
} }
#if DEBUG
print("[Cache] load HIT key=\(key) chapters=\(result.count)") print("[Cache] load HIT key=\(key) chapters=\(result.count)")
#endif
return result return result
} catch { } catch {
#if DEBUG
print("[Cache] load MISS key=\(key) error=\(error)") print("[Cache] load MISS key=\(key) error=\(error)")
#endif
return nil return nil
} }
} }
@ -204,9 +212,13 @@ public final class RDEPUBTextBookCache {
let bookArchive = PaginationCacheArchive(chapters: archives) let bookArchive = PaginationCacheArchive(chapters: archives)
let data = try NSKeyedArchiver.archivedData(withRootObject: bookArchive, requiringSecureCoding: true) let data = try NSKeyedArchiver.archivedData(withRootObject: bookArchive, requiringSecureCoding: true)
try data.write(to: fileURL, options: .atomic) try data.write(to: fileURL, options: .atomic)
#if DEBUG
print("[Cache] save key=\(key) chapters=\(chapters.count)") print("[Cache] save key=\(key) chapters=\(chapters.count)")
#endif
} catch { } catch {
#if DEBUG
print("[Cache] save FAILED key=\(key) error=\(error)") print("[Cache] save FAILED key=\(key) error=\(error)")
#endif
} }
} }
} }
@ -223,7 +235,9 @@ public final class RDEPUBTextBookCache {
for file in contents { for file in contents {
try? fileManager.removeItem(at: file) try? fileManager.removeItem(at: file)
} }
#if DEBUG
print("[Cache] invalidateAll") print("[Cache] invalidateAll")
#endif
} }
} }
} }

View File

@ -51,7 +51,9 @@ public final class RDEPUBTextPerformanceSampler {
/// ///
public func record(_ sample: RDEPUBTextPerformanceSample) { public func record(_ sample: RDEPUBTextPerformanceSample) {
samples.append(sample) samples.append(sample)
#if DEBUG
print("[PERF] \(sample.chapterHref): render=\(formatMS(sample.renderDuration)) paginate=\(formatMS(sample.paginateDuration)) pages=\(sample.pageCount) cache=\(sample.cacheHit ? "HIT" : "MISS")") print("[PERF] \(sample.chapterHref): render=\(formatMS(sample.renderDuration)) paginate=\(formatMS(sample.paginateDuration)) pages=\(sample.pageCount) cache=\(sample.cacheHit ? "HIT" : "MISS")")
#endif
} }
/// / /// /

View File

@ -166,80 +166,6 @@ struct RDEPUBPageBreakPolicy {
// MARK: - // MARK: -
/// pageBreakBefore / pageBreakAfter
func preferredSemanticBoundary(
in range: NSRange,
minimumEnd: Int,
factory: RDEPUBCoreTextPageFrameFactory
) -> (location: Int, trigger: String)? {
guard let safeRange = factory.clampedRange(range), safeRange.length > 0 else {
return nil
}
var boundary: (location: Int, trigger: String)?
attributedString.enumerateAttribute(.rdPageSemanticHints, in: safeRange) { value, attributeRange, stop in
guard let rawValue = value as? String else { return }
let hints = rawValue
.split(separator: ",")
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
guard !hints.isEmpty else { return }
if hints.contains(.pageBreakBefore),
attributeRange.location > safeRange.location,
attributeRange.location >= minimumEnd {
boundary = (attributeRange.location, RDEPUBTextSemanticHint.pageBreakBefore.rawValue)
stop.pointee = true
return
}
let attributeEnd = attributeRange.location + attributeRange.length
if hints.contains(.pageBreakAfter),
attributeEnd > minimumEnd,
attributeEnd < safeRange.location + safeRange.length {
boundary = (attributeEnd, RDEPUBTextSemanticHint.pageBreakAfter.rawValue)
stop.pointee = true
return
}
}
return boundary
}
///
func preferredAttachmentBoundary(
in range: NSRange,
minimumEnd: Int,
factory: RDEPUBCoreTextPageFrameFactory
) -> Int? {
guard let safeRange = factory.clampedRange(range), safeRange.length > 0 else {
return nil
}
var boundary: Int?
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: safeRange) { value, attributeRange, stop in
guard value != nil else { return }
let location = attributeRange.location
let placement = factory.attachmentPlacement(at: location)
let blockKind = factory.blockKind(at: location)
let isBlockLevelAttachment: Bool
switch placement {
case .centered:
isBlockLevelAttachment = true
case .inline, .baseline:
isBlockLevelAttachment = false
case nil:
isBlockLevelAttachment = blockKind == .attachment
}
guard isBlockLevelAttachment else { return }
let boundaryRange = factory.blockRange(at: location) ?? factory.paragraphRange(containing: location)
if boundaryRange.location > safeRange.location, boundaryRange.location >= minimumEnd {
boundary = boundaryRange.location
stop.pointee = true
}
}
return boundary
}
/// pageRelate /// pageRelate
func preferredPageRelateBoundary( func preferredPageRelateBoundary(
after range: NSRange, after range: NSRange,

View File

@ -174,12 +174,12 @@ public final class RDEPUBChapterData {
/// ///
public func absoluteRange(for searchMatch: RDEPUBSearchMatch) -> NSRange? { public func absoluteRange(for searchMatch: RDEPUBSearchMatch) -> NSRange? {
if let location = searchMatch.rangeLocation {
return NSRange(location: location, length: max(searchMatch.rangeLength, 1))
}
if let rangeAnchor = searchMatch.rangeAnchor { if let rangeAnchor = searchMatch.rangeAnchor {
return indexTable.chapterRange(for: rangeAnchor) return indexTable.chapterRange(for: rangeAnchor)
} }
if let location = searchMatch.rangeLocation {
return NSRange(location: location, length: searchMatch.rangeLength)
}
return nil return nil
} }

View File

@ -187,11 +187,7 @@ public struct RDEPUBTextIndexTable {
/// ///
public func chapterOffset(for anchor: RDEPUBTextAnchor) -> Int { public func chapterOffset(for anchor: RDEPUBTextAnchor) -> Int {
chapterOffset( anchor.chapterOffset
fileIndex: anchor.fileIndex,
row: anchor.row,
column: anchor.column
) ?? anchor.chapterOffset
} }
/// ///

View File

@ -46,7 +46,9 @@ struct RDEPUBAttachmentNormalizer {
if !didLogFootnoteAttachment { if !didLogFootnoteAttachment {
didLogFootnoteAttachment = true didLogFootnoteAttachment = true
#if DEBUG
print("[EPUB][Attachment] footnote original=\(RDEPUBHTMLNormalizer.string(from: originalSize)) display=\(RDEPUBHTMLNormalizer.string(from: attachment.displaySize)) font=\(pointSize)") print("[EPUB][Attachment] footnote original=\(RDEPUBHTMLNormalizer.string(from: originalSize)) display=\(RDEPUBHTMLNormalizer.string(from: attachment.displaySize)) font=\(pointSize)")
#endif
} }
return return
} }
@ -66,7 +68,9 @@ struct RDEPUBAttachmentNormalizer {
if !didLogCoverAttachment { if !didLogCoverAttachment {
didLogCoverAttachment = true didLogCoverAttachment = true
#if DEBUG
print("[EPUB][Attachment] cover original=\(RDEPUBHTMLNormalizer.string(from: originalSize)) display=\(RDEPUBHTMLNormalizer.string(from: attachment.displaySize))") print("[EPUB][Attachment] cover original=\(RDEPUBHTMLNormalizer.string(from: originalSize)) display=\(RDEPUBHTMLNormalizer.string(from: attachment.displaySize))")
#endif
} }
return return
} }

View File

@ -62,15 +62,17 @@ extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
readerView.transitionToPage(pageNum: max(pageNumber - 1, 0), animated: true) readerView.transitionToPage(pageNum: max(pageNumber - 1, 0), animated: true)
} }
/// Web 使 /// Web
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateExternalLink url: URL) { func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateExternalLink url: URL) {
delegate?.epubReader(self, didActivateExternalLink: url) delegate?.epubReader(self, didActivateExternalLink: url)
UIApplication.shared.open(url, options: [:], completionHandler: nil) openExternalURLIfAllowed(url)
} }
/// Web JavaScript /// Web JavaScript
func epubWebContentView(_ contentView: RDEPUBWebContentView, didLogJavaScriptError message: String) { func epubWebContentView(_ contentView: RDEPUBWebContentView, didLogJavaScriptError message: String) {
#if DEBUG
print("EPUB JS Error: \(message)") print("EPUB JS Error: \(message)")
#endif
} }
} }
@ -96,6 +98,14 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
contentView.clearSelection() contentView.clearSelection()
} }
func textContentView(
_ contentView: RDEPUBTextContentView,
didActivateAttachmentText text: String,
sourceRect: CGRect
) {
presentAttachmentTooltip(text: text, sourceView: contentView, sourceRect: sourceRect)
}
func textContentView( func textContentView(
_ contentView: RDEPUBTextContentView, _ contentView: RDEPUBTextContentView,
didRequestHighlightActions highlight: RDEPUBHighlight, didRequestHighlightActions highlight: RDEPUBHighlight,
@ -296,4 +306,242 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
} }
return bestID return bestID
} }
// MARK: -
private func shouldAllowExternalURL(_ url: URL) -> Bool {
guard let scheme = url.scheme?.lowercased() else { return false }
if delegate?.epubReader(self, shouldOpenExternalURL: url) == false {
return false
}
return configuration.allowedExternalURLSchemes.contains(scheme)
}
private func openExternalURLIfAllowed(_ url: URL) {
guard shouldAllowExternalURL(url) else { return }
if configuration.requiresExternalLinkConfirmation {
presentExternalLinkConfirmation(for: url)
} else {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
}
}
private func presentExternalLinkConfirmation(for url: URL) {
let alert = UIAlertController(
title: "打开外部链接",
message: url.absoluteString,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "打开", style: .default) { _ in
UIApplication.shared.open(url, options: [:], completionHandler: nil)
})
present(alert, animated: true)
}
private func presentAttachmentTooltip(text: String, sourceView: UIView, sourceRect: CGRect) {
hideAttachmentTooltipIfNeeded()
let overlay = RDEPUBAttachmentTooltipOverlayView(frame: view.bounds)
overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight]
overlay.onBackgroundTap = { [weak self, weak overlay] in
guard let self, let overlay else { return }
self.dismissAttachmentTooltipOverlay(overlay)
}
let tooltip = RDEPUBAttachmentTooltipView()
tooltip.alpha = 0
tooltip.configure(text: text, maxWidth: min(view.bounds.width - 48, 320))
let anchorRect = sourceView.convert(sourceRect, to: view)
let horizontalPadding: CGFloat = 24
let verticalSpacing: CGFloat = 6
let tooltipSize = tooltip.frame.size
let idealX = anchorRect.midX - tooltipSize.width / 2
let minX = horizontalPadding
let maxX = max(minX, view.bounds.width - horizontalPadding - tooltipSize.width)
let originX = min(max(idealX, minX), maxX)
let originY = max(view.safeAreaInsets.top + 12, anchorRect.minY - tooltipSize.height - verticalSpacing)
let arrowTipX = min(
max(anchorRect.midX - originX, tooltip.minimumArrowX),
tooltipSize.width - tooltip.minimumArrowX
)
tooltip.setArrowTipX(arrowTipX)
tooltip.frame.origin = CGPoint(x: originX, y: originY)
overlay.addSubview(tooltip)
view.addSubview(overlay)
UIView.animate(withDuration: 0.2) {
tooltip.alpha = 1
}
DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) { [weak self, weak overlay] in
guard let self, let overlay else { return }
self.dismissAttachmentTooltipOverlay(overlay)
}
}
private func hideAttachmentTooltipIfNeeded() {
view.subviews
.compactMap { $0 as? RDEPUBAttachmentTooltipOverlayView }
.forEach { $0.removeFromSuperview() }
}
private func dismissAttachmentTooltipOverlay(_ overlay: RDEPUBAttachmentTooltipOverlayView) {
UIView.animate(withDuration: 0.18, animations: {
overlay.tooltipView?.alpha = 0
}, completion: { _ in
overlay.removeFromSuperview()
})
}
}
private final class RDEPUBAttachmentTooltipOverlayView: UIView {
var onBackgroundTap: (() -> Void)?
var tooltipView: RDEPUBAttachmentTooltipView? {
subviews.compactMap { $0 as? RDEPUBAttachmentTooltipView }.first
}
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
tapGesture.cancelsTouchesInView = false
addGestureRecognizer(tapGesture)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc
private func handleTap(_ gesture: UITapGestureRecognizer) {
let point = gesture.location(in: self)
guard let tooltipView else {
onBackgroundTap?()
return
}
if !tooltipView.frame.contains(point) {
onBackgroundTap?()
}
}
}
private final class RDEPUBAttachmentTooltipView: UIView {
private let contentInsets = UIEdgeInsets(top: 18, left: 20, bottom: 24, right: 20)
private let arrowSize = CGSize(width: 20, height: 10)
private let cornerRadius: CGFloat = 18
private(set) var minimumArrowX: CGFloat = 28
private var arrowTipX: CGFloat?
private let textLabel: UILabel = {
let label = UILabel()
label.numberOfLines = 0
label.textColor = .white
label.font = .systemFont(ofSize: 16, weight: .regular)
label.lineBreakMode = .byWordWrapping
return label
}()
private let shapeLayer = CAShapeLayer()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
isOpaque = false
layer.addSublayer(shapeLayer)
addSubview(textLabel)
accessibilityIdentifier = "epub.reader.attachment.tooltip"
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
shapeLayer.frame = bounds
shapeLayer.path = bubblePath(in: bounds).cgPath
shapeLayer.fillColor = UIColor(white: 0.26, alpha: 0.96).cgColor
let labelFrame = bounds.inset(by: UIEdgeInsets(
top: contentInsets.top,
left: contentInsets.left,
bottom: contentInsets.bottom + arrowSize.height,
right: contentInsets.right
))
textLabel.frame = labelFrame
}
func setArrowTipX(_ value: CGFloat) {
arrowTipX = value
setNeedsLayout()
}
func configure(text: String, maxWidth: CGFloat) {
textLabel.text = text
let labelMaxWidth = max(maxWidth - contentInsets.left - contentInsets.right, 120)
let labelSize = textLabel.sizeThatFits(CGSize(width: labelMaxWidth, height: .greatestFiniteMagnitude))
frame.size = CGSize(
width: min(maxWidth, labelSize.width + contentInsets.left + contentInsets.right),
height: labelSize.height + contentInsets.top + contentInsets.bottom + arrowSize.height
)
setNeedsLayout()
layoutIfNeeded()
}
private func bubblePath(in rect: CGRect) -> UIBezierPath {
let bubbleRect = CGRect(
x: rect.minX,
y: rect.minY,
width: rect.width,
height: rect.height - arrowSize.height
)
let arrowMidX = min(
max(arrowTipX ?? bubbleRect.midX, minimumArrowX),
bubbleRect.width - minimumArrowX
)
let arrowHalfWidth = arrowSize.width / 2
let path = UIBezierPath()
path.move(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY))
path.addLine(to: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY))
path.addArc(
withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY + cornerRadius),
radius: cornerRadius,
startAngle: -.pi / 2,
endAngle: 0,
clockwise: true
)
path.addLine(to: CGPoint(x: bubbleRect.maxX, y: bubbleRect.maxY - cornerRadius))
path.addArc(
withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.maxY - cornerRadius),
radius: cornerRadius,
startAngle: 0,
endAngle: .pi / 2,
clockwise: true
)
path.addLine(to: CGPoint(x: arrowMidX + arrowHalfWidth, y: bubbleRect.maxY))
path.addLine(to: CGPoint(x: arrowMidX, y: bubbleRect.maxY + arrowSize.height))
path.addLine(to: CGPoint(x: arrowMidX - arrowHalfWidth, y: bubbleRect.maxY))
path.addLine(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY))
path.addArc(
withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY - cornerRadius),
radius: cornerRadius,
startAngle: .pi / 2,
endAngle: .pi,
clockwise: true
)
path.addLine(to: CGPoint(x: bubbleRect.minX, y: bubbleRect.minY + cornerRadius))
path.addArc(
withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY + cornerRadius),
radius: cornerRadius,
startAngle: .pi,
endAngle: -.pi / 2,
clockwise: true
)
path.close()
return path
}
} }

View File

@ -97,16 +97,28 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
private func searchState(for page: RDEPUBTextPage) -> RDEPUBSearchState? { private func searchState(for page: RDEPUBTextPage) -> RDEPUBSearchState? {
guard let globalSearchState = searchState else { return nil } guard let globalSearchState = searchState else { return nil }
let matches = globalSearchState.matches.filter { searchMatch in let matches: [RDEPUBSearchMatch]
searchMatchBelongsToPage(searchMatch, page: page) let currentMatchIndex: Int?
if let chapterData = chapterData(for: page),
let resolvedState = resolvedSearchState(
for: page,
chapterData: chapterData,
globalSearchState: globalSearchState
) {
matches = resolvedState.matches
currentMatchIndex = resolvedState.currentMatchIndex
} else {
matches = globalSearchState.matches.filter { searchMatch in
searchMatchBelongsToPage(searchMatch, page: page)
}
currentMatchIndex = globalSearchState.currentMatch.flatMap { currentMatch in
matches.firstIndex(of: currentMatch)
}
} }
guard !matches.isEmpty || globalSearchState.currentMatch != nil else { guard !matches.isEmpty || globalSearchState.currentMatch != nil else {
return globalSearchState.matches.isEmpty ? globalSearchState : nil return globalSearchState.matches.isEmpty ? globalSearchState : nil
} }
let currentMatchIndex = globalSearchState.currentMatch.flatMap { currentMatch in
matches.firstIndex(of: currentMatch)
}
return RDEPUBSearchState( return RDEPUBSearchState(
keyword: globalSearchState.keyword, keyword: globalSearchState.keyword,
matches: matches, matches: matches,
@ -114,6 +126,126 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
) )
} }
private func resolvedSearchState(
for page: RDEPUBTextPage,
chapterData: RDEPUBChapterData,
globalSearchState: RDEPUBSearchState
) -> RDEPUBSearchState? {
let normalizedKeyword = globalSearchState.keyword.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedKeyword.isEmpty else {
return globalSearchState.matches.isEmpty ? globalSearchState : nil
}
let normalizedHref = normalizedPageHref(for: page)
let exactMatches = exactChapterSearchMatches(
in: chapterData,
keyword: normalizedKeyword,
normalizedHref: normalizedHref
)
let pageMatches = exactMatches.filter { match in
guard let rangeLocation = match.rangeLocation else { return false }
let range = NSRange(location: rangeLocation, length: max(match.rangeLength, 1))
return NSIntersectionRange(range, page.contentRange).length > 0
}
let currentLocalMatchIndex = globalSearchState.currentMatch?.localMatchIndex
let currentMatchIndex = currentLocalMatchIndex.flatMap { localMatchIndex in
pageMatches.firstIndex(where: { $0.localMatchIndex == localMatchIndex })
}
guard !pageMatches.isEmpty || currentMatchIndex != nil else {
return globalSearchState.matches.isEmpty ? globalSearchState : nil
}
return RDEPUBSearchState(
keyword: globalSearchState.keyword,
matches: pageMatches,
currentMatchIndex: currentMatchIndex
)
}
private func exactChapterSearchMatches(
in chapterData: RDEPUBChapterData,
keyword: String,
normalizedHref: String
) -> [RDEPUBSearchMatch] {
let source = chapterData.attributedContent.string as NSString
let fullLength = source.length
guard fullLength > 0 else { return [] }
var matches: [RDEPUBSearchMatch] = []
var localMatchIndex = 0
var searchRange = NSRange(location: 0, length: fullLength)
while searchRange.length > 0 {
let foundRange = source.range(of: keyword, options: [.caseInsensitive], range: searchRange)
guard foundRange.location != NSNotFound else { break }
let progressionDenominator = max(fullLength - 1, 1)
let progression = Double(foundRange.location) / Double(progressionDenominator)
matches.append(
RDEPUBSearchMatch(
href: normalizedHref,
progression: progression,
previewText: previewText(in: source, matchRange: foundRange),
localMatchIndex: localMatchIndex,
rangeLocation: foundRange.location,
rangeLength: max(foundRange.length, 1),
rangeAnchor: chapterData.rangeAnchor(for: foundRange)
)
)
localMatchIndex += 1
let nextLocation = foundRange.location + max(foundRange.length, 1)
if nextLocation >= fullLength {
break
}
searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation)
}
return matches
}
private func previewText(in text: NSString, matchRange: NSRange) -> String {
let previewRadius = 12
let start = max(matchRange.location - previewRadius, 0)
let end = min(matchRange.location + matchRange.length + previewRadius, text.length)
let range = NSRange(location: start, length: max(end - start, 0))
return text.substring(with: range).trimmingCharacters(in: .whitespacesAndNewlines)
}
private func chapterData(for page: RDEPUBTextPage) -> RDEPUBChapterData? {
if let textBook,
let chapterData = textBook.chapterData(for: page.href) {
return chapterData
}
guard let runtimeChapter = runtime.chapterRuntimeStore.chapterData(for: page.spineIndex) else {
return nil
}
return makeChapterData(from: runtimeChapter, chapterIndex: page.chapterIndex)
}
private func makeChapterData(
from runtimeChapter: RDEPUBRuntimeChapter,
chapterIndex: Int
) -> RDEPUBChapterData {
let textChapter = RDEPUBTextChapter(
chapterIndex: chapterIndex,
spineIndex: runtimeChapter.spineIndex,
href: runtimeChapter.href,
title: runtimeChapter.title,
attributedContent: runtimeChapter.typesetAttributedString,
fragmentOffsets: runtimeChapter.chapterOffsetMap.fragmentOffsets,
pageBreakReasons: runtimeChapter.pages.map(\.metadata.breakReason),
pages: runtimeChapter.pages
)
return RDEPUBChapterData(
chapter: textChapter,
indexTable: RDEPUBTextIndexTable(chapters: [textChapter])
)
}
private func searchMatchBelongsToPage(_ searchMatch: RDEPUBSearchMatch, page: RDEPUBTextPage) -> Bool { private func searchMatchBelongsToPage(_ searchMatch: RDEPUBSearchMatch, page: RDEPUBTextPage) -> Bool {
if let textBook, if let textBook,
let chapterData = textBook.chapterData(for: page.href), let chapterData = textBook.chapterData(for: page.href),

View File

@ -60,11 +60,16 @@ extension RDEPUBReaderController {
} }
let page = activePages[pageIndex] let page = activePages[pageIndex]
let pendingLocation = readingSession?.pendingLocation(forPageNumber: pageIndex + 1, spineIndex: page.spineIndex) let pendingLocation = readingSession?.pendingLocation(forPageNumber: pageIndex + 1, spineIndex: page.spineIndex)
let pendingHighlightRangeInfo = readingSession?.pendingHighlightRangeInfo(
forPageNumber: pageIndex + 1,
spineIndex: page.spineIndex
)
return currentPreferences().renderRequest( return currentPreferences().renderRequest(
for: page, for: page,
publication: publication, publication: publication,
viewportSize: currentLayoutContext().viewportSize, viewportSize: currentLayoutContext().viewportSize,
targetLocation: pendingLocation, targetLocation: pendingLocation,
targetHighlightRangeInfo: pendingHighlightRangeInfo,
highlights: highlights(for: page), highlights: highlights(for: page),
searchPresentation: searchPresentation(for: page) searchPresentation: searchPresentation(for: page)
) )
@ -97,4 +102,3 @@ extension RDEPUBReaderController {
} }
} }
} }

View File

@ -91,8 +91,16 @@ extension RDEPUBReaderController {
/// ///
@discardableResult @discardableResult
func restoreReadingLocation(_ location: RDEPUBLocation, animated: Bool = false) -> Bool { func restoreReadingLocation(
runtime.restoreReadingLocation(location, animated: animated) _ location: RDEPUBLocation,
animated: Bool = false,
targetHighlightRangeInfo: String? = nil
) -> Bool {
runtime.restoreReadingLocation(
location,
animated: animated,
targetHighlightRangeInfo: targetHighlightRangeInfo
)
} }
/// ///

View File

@ -30,6 +30,7 @@ public final class RDEPUBReaderController: UIViewController {
public var configuration: RDEPUBReaderConfiguration { public var configuration: RDEPUBReaderConfiguration {
didSet { didSet {
readerContext.configuration = configuration readerContext.configuration = configuration
applyWebViewDebugPolicy()
persistReaderSettingsIfNeeded() persistReaderSettingsIfNeeded()
guard isViewLoaded else { return } guard isViewLoaded else { return }
let oldConfiguration = oldValue let oldConfiguration = oldValue
@ -230,12 +231,20 @@ public final class RDEPUBReaderController: UIViewController {
readerContext.epubURL = epubURL readerContext.epubURL = epubURL
readerContext.persistence = persistence readerContext.persistence = persistence
self.currentBrightness = brightness self.currentBrightness = brightness
applyWebViewDebugPolicy()
} }
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
private func applyWebViewDebugPolicy() {
RDEPUBWebViewDebug.applyDebugPolicy(
inspectableEnabled: configuration.allowsInspectableWebViews,
verboseLoggingEnabled: configuration.enablesVerboseWebViewLogging
)
}
/// 使 TextBook EPUB /// 使 TextBook EPUB
/// TXT TextBook /// TXT TextBook
/// - Parameters: /// - Parameters:
@ -318,9 +327,21 @@ public final class RDEPUBReaderController: UIViewController {
searchBarView.apply(theme: configuration.theme) searchBarView.apply(theme: configuration.theme)
searchBarView.onSearchSubmit = { [weak self] keyword in searchBarView.onSearchSubmit = { [weak self] keyword in
self?.searchBarView.showSearching()
self?.runtime.search(keyword: keyword) self?.runtime.search(keyword: keyword)
self?.updateSearchCount() self?.updateSearchCount()
} }
searchBarView.onSearchTextChanged = { [weak self] keyword in
guard let self else { return }
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
if normalizedKeyword.isEmpty {
self.runtime.clearSearch()
} else {
self.searchBarView.showSearching()
self.runtime.search(keyword: normalizedKeyword)
}
self.updateSearchCount()
}
searchBarView.onSearchPrevious = { [weak self] in searchBarView.onSearchPrevious = { [weak self] in
_ = self?.runtime.searchPrevious() _ = self?.runtime.searchPrevious()
self?.updateSearchCount() self?.updateSearchCount()
@ -329,6 +350,14 @@ public final class RDEPUBReaderController: UIViewController {
_ = self?.runtime.searchNext() _ = self?.runtime.searchNext()
self?.updateSearchCount() self?.updateSearchCount()
} }
searchBarView.onSelectMatch = { [weak self] matchIndex in
guard let self else { return }
let didNavigate = self.runtime.selectSearchMatch(at: matchIndex)
self.updateSearchCount()
if didNavigate {
self.hideSearchBar(clearSearch: false)
}
}
searchBarView.onClose = { [weak self] in searchBarView.onClose = { [weak self] in
self?.hideSearchBar(clearSearch: true) self?.hideSearchBar(clearSearch: true)
} }
@ -345,22 +374,28 @@ public final class RDEPUBReaderController: UIViewController {
readerView.addSubview(searchBarView) readerView.addSubview(searchBarView)
readerView.searchBarView = searchBarView readerView.searchBarView = searchBarView
let topToolbarHeight: CGFloat = readerView.safeAreaInsets.top + 52 let bottomAnchor = bottomToolView.superview == nil
? readerView.bottomAnchor
: bottomToolView.topAnchor
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
searchBarView.leadingAnchor.constraint(equalTo: readerView.leadingAnchor), searchBarView.leadingAnchor.constraint(equalTo: readerView.leadingAnchor),
searchBarView.trailingAnchor.constraint(equalTo: readerView.trailingAnchor), searchBarView.trailingAnchor.constraint(equalTo: readerView.trailingAnchor),
searchBarView.topAnchor.constraint(equalTo: readerView.topAnchor, constant: topToolbarHeight), searchBarView.topAnchor.constraint(equalTo: readerView.topAnchor),
searchBarView.heightAnchor.constraint(equalToConstant: 52) searchBarView.bottomAnchor.constraint(equalTo: bottomAnchor)
]) ])
searchBarView.transform = CGAffineTransform(translationX: 0, y: -52) searchBarView.alpha = 0
UIView.animate(withDuration: 0.3) { searchBarView.presentedView.transform = CGAffineTransform(translationX: 0, y: 40)
self.searchBarView.transform = .identity UIView.animate(withDuration: 0.28) {
self.searchBarView.alpha = 1
self.searchBarView.presentedView.transform = .identity
} }
if let keyword = searchState?.keyword, !keyword.isEmpty { if let keyword = searchState?.keyword, !keyword.isEmpty {
searchBarView.restoreKeyword(keyword) searchBarView.restoreKeyword(keyword)
updateSearchCount() updateSearchCount()
} else {
searchBarView.showNoResults()
} }
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
@ -375,11 +410,13 @@ public final class RDEPUBReaderController: UIViewController {
isSearchBarVisible = false isSearchBarVisible = false
searchBarView.textField.resignFirstResponder() searchBarView.textField.resignFirstResponder()
UIView.animate(withDuration: 0.3, animations: { UIView.animate(withDuration: 0.25, animations: {
self.searchBarView.transform = CGAffineTransform(translationX: 0, y: -52) self.searchBarView.alpha = 0
self.searchBarView.presentedView.transform = CGAffineTransform(translationX: 0, y: 40)
}) { _ in }) { _ in
self.searchBarView.removeFromSuperview() self.searchBarView.removeFromSuperview()
self.searchBarView.transform = .identity self.searchBarView.alpha = 1
self.searchBarView.presentedView.transform = .identity
self.readerView.searchBarView = nil self.readerView.searchBarView = nil
} }
@ -394,11 +431,11 @@ public final class RDEPUBReaderController: UIViewController {
searchBarView.showNoResults() searchBarView.showNoResults()
return return
} }
if let index = searchState.currentMatchIndex { searchBarView.updateResults(
searchBarView.updateMatchCount(current: index + 1, total: searchState.matches.count) sections: searchResultSections(for: searchState),
} else if searchState.matches.isEmpty { keyword: searchState.keyword,
searchBarView.showNoResults() currentMatchIndex: searchState.currentMatchIndex
} )
} }
/// RDReaderView /// RDReaderView
@ -417,3 +454,53 @@ public final class RDEPUBReaderController: UIViewController {
} }
} }
private extension RDEPUBReaderController {
func searchResultSections(for searchState: RDEPUBSearchState) -> [RDEPUBReaderSearchSection] {
let groupedMatches = Dictionary(grouping: Array(searchState.matches.enumerated()), by: { entry in
searchSectionTitle(for: entry.element)
})
let orderedTitles = searchState.matches.reduce(into: [String]()) { titles, match in
let title = searchSectionTitle(for: match)
if titles.last != title, titles.contains(title) == false {
titles.append(title)
}
}
return orderedTitles.compactMap { title in
guard let matches = groupedMatches[title] else { return nil }
let items = matches.map { offset, match in
RDEPUBReaderSearchSection.Item(
matchIndex: offset,
previewText: match.previewText,
isCurrent: offset == searchState.currentMatchIndex
)
}
return RDEPUBReaderSearchSection(title: title, items: items)
}
}
func searchSectionTitle(for match: RDEPUBSearchMatch) -> String {
guard let publication else {
return match.href
}
let normalizedMatchHref = publication.resourceResolver.normalizedHref(match.href) ?? match.href
let tocItems = flattenedTableOfContentsItems(from: publication.tableOfContents, includePageNumbers: false)
if let tocItem = tocItems.last(where: {
let rawHref = $0.href.components(separatedBy: "#").first ?? $0.href
let normalizedItemHref = publication.resourceResolver.normalizedHref(rawHref) ?? rawHref
return normalizedItemHref == normalizedMatchHref
}) {
return tocItem.title
}
if let spineIndex = publication.resourceResolver.spineIndex(forNormalizedHref: normalizedMatchHref),
publication.spine.indices.contains(spineIndex) {
return publication.spine[spineIndex].title
}
return normalizedMatchHref
}
}

View File

@ -64,6 +64,13 @@ public protocol RDEPUBReaderDelegate: AnyObject {
/// - url: URL /// - url: URL
func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL) func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL)
/// false
/// - Parameters:
/// - reader:
/// - url: URL
/// - Returns:
func epubReader(_ reader: UIViewController, shouldOpenExternalURL url: URL) -> Bool
/// ///
/// - Parameters: /// - Parameters:
/// - reader: /// - reader:
@ -92,6 +99,7 @@ public extension RDEPUBReaderDelegate {
func epubReader(_ reader: UIViewController, didChangeCurrentSearchMatch match: RDEPUBSearchMatch?) {} func epubReader(_ reader: UIViewController, didChangeCurrentSearchMatch match: RDEPUBSearchMatch?) {}
func epubReader(_ reader: UIViewController, didUpdateCurrentTableOfContentsItem item: RDEPUBReaderTableOfContentsItem?) {} func epubReader(_ reader: UIViewController, didUpdateCurrentTableOfContentsItem item: RDEPUBReaderTableOfContentsItem?) {}
func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL) {} func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL) {}
func epubReader(_ reader: UIViewController, shouldOpenExternalURL url: URL) -> Bool { true }
func epubReader(_ reader: UIViewController, didFailWithError error: Error) {} func epubReader(_ reader: UIViewController, didFailWithError error: Error) {}
func epubReader(_ reader: UIViewController, configureTopToolView topToolView: RDEPUBReaderTopToolView) {} func epubReader(_ reader: UIViewController, configureTopToolView topToolView: RDEPUBReaderTopToolView) {}
} }

View File

@ -39,23 +39,32 @@ public protocol RDEPUBReaderPersistence: AnyObject {
// MARK: - // MARK: -
/// // /// //
/// DEBUG no-op 便
public extension RDEPUBReaderPersistence { public extension RDEPUBReaderPersistence {
func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark] { func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark] {
_ = bookIdentifier #if DEBUG
print("[RDEPUBReaderPersistence] ⚠️ loadBookmarks called on default no-op implementation for: \(bookIdentifier)")
#endif
return [] return []
} }
func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String) { func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String) {
_ = bookmarks #if DEBUG
_ = bookIdentifier print("[RDEPUBReaderPersistence] ⚠️ saveBookmarks(\(bookmarks.count) items) called on default no-op implementation for: \(bookIdentifier)")
#endif
} }
func loadReaderSettings() -> RDEPUBReaderSettings? { func loadReaderSettings() -> RDEPUBReaderSettings? {
nil #if DEBUG
print("[RDEPUBReaderPersistence] ⚠️ loadReaderSettings called on default no-op implementation")
#endif
return nil
} }
func saveReaderSettings(_ settings: RDEPUBReaderSettings) { func saveReaderSettings(_ settings: RDEPUBReaderSettings) {
_ = settings #if DEBUG
print("[RDEPUBReaderPersistence] ⚠️ saveReaderSettings called on default no-op implementation")
#endif
} }
} }
@ -136,6 +145,11 @@ public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence {
guard let data = try? JSONEncoder().encode(highlights) else { guard let data = try? JSONEncoder().encode(highlights) else {
return return
} }
if data.count > 1_048_576 {
#if DEBUG
print("[RDEPUBUserDefaultsPersistence] ⚠️ saveHighlights data size (\(data.count) bytes) exceeds 1MB for: \(bookIdentifier)")
#endif
}
defaults.set(data, forKey: highlightsPrefix + bookIdentifier) defaults.set(data, forKey: highlightsPrefix + bookIdentifier)
} }

View File

@ -1,47 +1,32 @@
import UIKit import UIKit
// MARK: - struct RDEPUBReaderSearchSection: Equatable {
struct Item: Equatable {
let matchIndex: Int
let previewText: String
let isCurrent: Bool
}
/// let title: String
/// / let items: [Item]
}
// MARK: -
///
///
final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView { final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
// MARK:
///
var onSearchSubmit: ((String) -> Void)? var onSearchSubmit: ((String) -> Void)?
/// var onSearchTextChanged: ((String) -> Void)?
var onSearchPrevious: (() -> Void)? var onSearchPrevious: (() -> Void)?
///
var onSearchNext: (() -> Void)? var onSearchNext: (() -> Void)?
/// var onSelectMatch: ((Int) -> Void)?
var onClose: (() -> Void)? var onClose: (() -> Void)?
// MARK: UI
private let containerView: UIView = {
let view = UIView()
view.layer.cornerRadius = 8
view.layer.masksToBounds = true
view.isAccessibilityElement = false
view.accessibilityElementsHidden = false
return view
}()
private let searchIcon: UIImageView = {
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFit
imageView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 14, weight: .medium)
if #available(iOS 13.0, *) {
imageView.image = UIImage(systemName: "magnifyingglass")
}
imageView.tintColor = .gray
return imageView
}()
let textField: UITextField = { let textField: UITextField = {
let field = UITextField() let field = UITextField()
field.placeholder = "搜索..." field.placeholder = "搜索"
field.font = UIFont.systemFont(ofSize: 15) field.font = UIFont.systemFont(ofSize: 18, weight: .medium)
field.returnKeyType = .search field.returnKeyType = .search
field.autocorrectionType = .no field.autocorrectionType = .no
field.autocapitalizationType = .none field.autocapitalizationType = .none
@ -53,25 +38,25 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
return field return field
}() }()
private let backgroundButton = UIButton(type: .custom)
private let panelView = UIView()
private let grabberView = UIView()
private let searchRowView = UIView()
private let searchFieldContainer = UIView()
private let searchIcon = UIImageView()
private let searchFieldDivider = UIView()
private let cancelButton = UIButton(type: .system)
private let tableView = UITableView(frame: .zero, style: .plain)
private let emptyStateLabel = UILabel()
// Preserve legacy accessibility hooks used by existing tests and demo logic.
private let previousButton = RDEPUBReaderTintButton(type: .system) private let previousButton = RDEPUBReaderTintButton(type: .system)
private let nextButton = RDEPUBReaderTintButton(type: .system) private let nextButton = RDEPUBReaderTintButton(type: .system)
private let countLabel = UILabel()
private let countLabel: UILabel = { private var searchSections: [RDEPUBReaderSearchSection] = []
let label = UILabel() private var keyword = ""
label.font = UIFont.systemFont(ofSize: 13, weight: .medium) private var currentMatchIndex: Int?
label.textAlignment = .center
label.setContentHuggingPriority(.required, for: .horizontal)
label.setContentCompressionResistancePriority(.required, for: .horizontal)
return label
}()
private let closeButton = RDEPUBReaderTintButton(type: .system)
// MARK:
private let horizontalInset: CGFloat = 12
private let spacing: CGFloat = 6
private let containerHeight: CGFloat = 36
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
@ -81,162 +66,331 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
setupSubviews() setupSubviews()
setupConstraints() setupConstraints()
setupActions() setupActions()
updateNavigationEnabled(false) updateLegacyNavigationEnabled(false)
showInitialState()
} }
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
// MARK:
override func lineFrame(in bounds: CGRect) -> CGRect { override func lineFrame(in bounds: CGRect) -> CGRect {
CGRect(x: 0, y: bounds.height - 0.5, width: bounds.width, height: 0.5) .zero
} }
override func apply(theme: RDEPUBReaderTheme) { override func apply(theme: RDEPUBReaderTheme) {
super.apply(theme: theme) super.apply(theme: theme)
backgroundColor = theme.toolBackgroundColor let isDarkBackground = theme.contentBackgroundColor.rd_searchIsDarkBackground
containerView.backgroundColor = theme.toolControlBorderUnselectColor
searchIcon.tintColor = theme.toolControlTextColor let overlayColor = isDarkBackground
textField.textColor = theme.toolControlTextColor ? UIColor(white: 0.12, alpha: 0.92)
: UIColor(white: 0.08, alpha: 0.82)
let panelColor = isDarkBackground
? UIColor(red: 0.18, green: 0.18, blue: 0.19, alpha: 1)
: UIColor(red: 0.15, green: 0.15, blue: 0.16, alpha: 1)
let rowColor = isDarkBackground
? UIColor(white: 0.18, alpha: 1)
: UIColor(white: 0.14, alpha: 0.96)
let cardColor = isDarkBackground
? UIColor(white: 0.12, alpha: 1)
: UIColor(white: 0.10, alpha: 0.98)
let activeCardColor = UIColor(red: 0.17, green: 0.28, blue: 0.38, alpha: 1)
let textColor = UIColor(white: 0.96, alpha: 1)
let secondaryTextColor = UIColor(white: 0.72, alpha: 1)
backgroundColor = .clear
backgroundButton.backgroundColor = overlayColor
panelView.backgroundColor = panelColor
grabberView.backgroundColor = UIColor(white: 0.75, alpha: 0.7)
searchRowView.backgroundColor = rowColor
searchFieldContainer.backgroundColor = .clear
searchFieldDivider.backgroundColor = UIColor(white: 1, alpha: 0.12)
searchIcon.tintColor = secondaryTextColor
cancelButton.tintColor = textColor
cancelButton.setTitleColor(textColor, for: .normal)
textField.textColor = textColor
textField.tintColor = UIColor.systemBlue
textField.keyboardAppearance = isDarkBackground ? .dark : .default
textField.attributedPlaceholder = NSAttributedString( textField.attributedPlaceholder = NSAttributedString(
string: "搜索...", string: "搜索",
attributes: [.foregroundColor: theme.toolControlTextColor.withAlphaComponent(0.5)] attributes: [.foregroundColor: secondaryTextColor]
) )
countLabel.textColor = theme.toolControlTextColor
previousButton.tintColor = theme.toolControlTextColor emptyStateLabel.textColor = secondaryTextColor
nextButton.tintColor = theme.toolControlTextColor tableView.backgroundColor = .clear
closeButton.tintColor = theme.toolControlTextColor tableView.separatorStyle = .none
previousButton.tintColor = textColor
nextButton.tintColor = textColor
countLabel.textColor = textColor
countLabel.backgroundColor = .clear
RDEPUBReaderSearchResultCell.cardBackgroundColor = cardColor
RDEPUBReaderSearchResultCell.activeCardBackgroundColor = activeCardColor
RDEPUBReaderSearchResultCell.primaryTextColor = textColor
RDEPUBReaderSearchResultCell.highlightTextColor = UIColor.systemBlue
RDEPUBReaderSearchResultCell.activeHighlightTextColor = UIColor(red: 0.40, green: 0.77, blue: 1, alpha: 1)
tableView.reloadData()
} }
// MARK: var presentedView: UIView {
panelView
}
///
func updateMatchCount(current: Int, total: Int) { func updateMatchCount(current: Int, total: Int) {
countLabel.text = "\(current)/\(total)" countLabel.text = "\(current)/\(total)"
updateNavigationEnabled(total > 0) textField.accessibilityValue = "\(current)/\(total)"
updateLegacyNavigationEnabled(total > 0)
} }
///
func showNoResults() { func showNoResults() {
countLabel.text = "0/0" currentMatchIndex = nil
updateNavigationEnabled(false) updateMatchCount(current: 0, total: 0)
tableView.isHidden = true
emptyStateLabel.isHidden = false
emptyStateLabel.text = keyword.isEmpty ? "输入关键词开始搜索" : "未找到相关内容"
} }
///
func showSearching() { func showSearching() {
countLabel.text = "搜索中..." updateMatchCount(current: 0, total: 0)
updateNavigationEnabled(false) tableView.isHidden = true
emptyStateLabel.isHidden = false
emptyStateLabel.text = "搜索中..."
} }
///
func restoreKeyword(_ keyword: String) { func restoreKeyword(_ keyword: String) {
textField.text = keyword textField.text = keyword
self.keyword = keyword
} }
// MARK: func updateResults(
sections: [RDEPUBReaderSearchSection],
keyword: String,
currentMatchIndex: Int?
) {
self.keyword = keyword
self.searchSections = sections
self.currentMatchIndex = currentMatchIndex
let total = sections.reduce(0) { $0 + $1.items.count }
if let currentMatchIndex, total > 0 {
updateMatchCount(current: currentMatchIndex + 1, total: total)
} else {
updateMatchCount(current: 0, total: total)
}
if total == 0 {
showNoResults()
return
}
emptyStateLabel.isHidden = true
tableView.isHidden = false
tableView.reloadData()
scrollToCurrentMatchIfNeeded()
}
private func setupSubviews() { private func setupSubviews() {
addSubview(containerView) backgroundButton.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(searchIcon) panelView.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(textField) grabberView.translatesAutoresizingMaskIntoConstraints = false
searchRowView.translatesAutoresizingMaskIntoConstraints = false
searchFieldContainer.translatesAutoresizingMaskIntoConstraints = false
searchIcon.translatesAutoresizingMaskIntoConstraints = false
textField.translatesAutoresizingMaskIntoConstraints = false
searchFieldDivider.translatesAutoresizingMaskIntoConstraints = false
cancelButton.translatesAutoresizingMaskIntoConstraints = false
tableView.translatesAutoresizingMaskIntoConstraints = false
emptyStateLabel.translatesAutoresizingMaskIntoConstraints = false
previousButton.translatesAutoresizingMaskIntoConstraints = false
nextButton.translatesAutoresizingMaskIntoConstraints = false
countLabel.translatesAutoresizingMaskIntoConstraints = false
addSubview(backgroundButton)
addSubview(panelView)
panelView.addSubview(grabberView)
panelView.addSubview(searchRowView)
panelView.addSubview(tableView)
panelView.addSubview(emptyStateLabel)
searchRowView.addSubview(searchFieldContainer)
searchRowView.addSubview(searchFieldDivider)
searchRowView.addSubview(cancelButton)
searchFieldContainer.addSubview(searchIcon)
searchFieldContainer.addSubview(textField)
// Legacy shims
addSubview(previousButton) addSubview(previousButton)
addSubview(nextButton) addSubview(nextButton)
addSubview(countLabel) addSubview(countLabel)
addSubview(closeButton)
if #available(iOS 13.0, *) { if #available(iOS 13.0, *) {
previousButton.setImage(UIImage(systemName: "chevron.up")?.withRenderingMode(.alwaysTemplate), for: .normal) searchIcon.image = UIImage(systemName: "magnifyingglass")
nextButton.setImage(UIImage(systemName: "chevron.down")?.withRenderingMode(.alwaysTemplate), for: .normal) previousButton.setImage(UIImage(systemName: "chevron.up"), for: .normal)
closeButton.setImage(UIImage(systemName: "xmark")?.withRenderingMode(.alwaysTemplate), for: .normal) nextButton.setImage(UIImage(systemName: "chevron.down"), for: .normal)
} else { } else {
previousButton.setTitle("", for: .normal) previousButton.setTitle("", for: .normal)
nextButton.setTitle("", for: .normal) nextButton.setTitle("", for: .normal)
closeButton.setTitle("", for: .normal)
} }
searchIcon.contentMode = .scaleAspectFit
searchIcon.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 22, weight: .regular)
panelView.layer.cornerRadius = 28
panelView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
panelView.clipsToBounds = true
grabberView.layer.cornerRadius = 3
searchRowView.layer.cornerRadius = 22
searchFieldContainer.layer.cornerRadius = 22
searchRowView.clipsToBounds = true
cancelButton.setTitle("取消", for: .normal)
cancelButton.titleLabel?.font = UIFont.systemFont(ofSize: 18, weight: .medium)
cancelButton.accessibilityIdentifier = "epub.reader.search.close"
emptyStateLabel.font = UIFont.systemFont(ofSize: 17, weight: .medium)
emptyStateLabel.textAlignment = .center
emptyStateLabel.numberOfLines = 0
previousButton.accessibilityIdentifier = "epub.reader.search.previous" previousButton.accessibilityIdentifier = "epub.reader.search.previous"
nextButton.accessibilityIdentifier = "epub.reader.search.next" nextButton.accessibilityIdentifier = "epub.reader.search.next"
closeButton.accessibilityIdentifier = "epub.reader.search.close"
countLabel.accessibilityIdentifier = "epub.reader.search.count" countLabel.accessibilityIdentifier = "epub.reader.search.count"
textField.accessibilityIdentifier = "epub.reader.search.field" textField.accessibilityIdentifier = "epub.reader.search.field"
previousButton.alpha = 0.01
nextButton.alpha = 0.01
countLabel.alpha = 0.01
[previousButton, nextButton, closeButton].forEach { button in tableView.register(RDEPUBReaderSearchResultCell.self, forCellReuseIdentifier: RDEPUBReaderSearchResultCell.reuseIdentifier)
button.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .medium) tableView.dataSource = self
button.tintColor = .black tableView.delegate = self
button.setTitleColor(.black, for: .normal) tableView.showsVerticalScrollIndicator = false
} tableView.contentInset = UIEdgeInsets(top: 4, left: 0, bottom: 16, right: 0)
} }
private func setupConstraints() { private func setupConstraints() {
[containerView, searchIcon, textField, previousButton, nextButton, countLabel, closeButton].forEach {
$0.translatesAutoresizingMaskIntoConstraints = false
}
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
// backgroundButton.leadingAnchor.constraint(equalTo: leadingAnchor),
containerView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalInset), backgroundButton.trailingAnchor.constraint(equalTo: trailingAnchor),
containerView.centerYAnchor.constraint(equalTo: centerYAnchor), backgroundButton.topAnchor.constraint(equalTo: topAnchor),
containerView.heightAnchor.constraint(equalToConstant: containerHeight), backgroundButton.bottomAnchor.constraint(equalTo: bottomAnchor),
// panelView.leadingAnchor.constraint(equalTo: leadingAnchor),
searchIcon.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 10), panelView.trailingAnchor.constraint(equalTo: trailingAnchor),
searchIcon.centerYAnchor.constraint(equalTo: containerView.centerYAnchor), panelView.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 8),
searchIcon.widthAnchor.constraint(equalToConstant: 16), panelView.bottomAnchor.constraint(equalTo: bottomAnchor),
// grabberView.topAnchor.constraint(equalTo: panelView.topAnchor, constant: 10),
textField.leadingAnchor.constraint(equalTo: searchIcon.trailingAnchor, constant: 6), grabberView.centerXAnchor.constraint(equalTo: panelView.centerXAnchor),
textField.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -8), grabberView.widthAnchor.constraint(equalToConstant: 92),
textField.centerYAnchor.constraint(equalTo: containerView.centerYAnchor), grabberView.heightAnchor.constraint(equalToConstant: 6),
textField.heightAnchor.constraint(equalToConstant: containerHeight - 4),
// searchRowView.leadingAnchor.constraint(equalTo: panelView.leadingAnchor, constant: 20),
previousButton.leadingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: spacing), searchRowView.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: -20),
previousButton.centerYAnchor.constraint(equalTo: centerYAnchor), searchRowView.topAnchor.constraint(equalTo: grabberView.bottomAnchor, constant: 18),
previousButton.widthAnchor.constraint(equalToConstant: 32), searchRowView.heightAnchor.constraint(equalToConstant: 52),
previousButton.heightAnchor.constraint(equalToConstant: 32),
// searchFieldContainer.leadingAnchor.constraint(equalTo: searchRowView.leadingAnchor, constant: 12),
nextButton.leadingAnchor.constraint(equalTo: previousButton.trailingAnchor, constant: spacing), searchFieldContainer.topAnchor.constraint(equalTo: searchRowView.topAnchor),
nextButton.centerYAnchor.constraint(equalTo: centerYAnchor), searchFieldContainer.bottomAnchor.constraint(equalTo: searchRowView.bottomAnchor),
nextButton.widthAnchor.constraint(equalToConstant: 32),
nextButton.heightAnchor.constraint(equalToConstant: 32),
// searchIcon.leadingAnchor.constraint(equalTo: searchFieldContainer.leadingAnchor, constant: 10),
countLabel.leadingAnchor.constraint(equalTo: nextButton.trailingAnchor, constant: spacing), searchIcon.centerYAnchor.constraint(equalTo: searchFieldContainer.centerYAnchor),
countLabel.centerYAnchor.constraint(equalTo: centerYAnchor), searchIcon.widthAnchor.constraint(equalToConstant: 24),
countLabel.widthAnchor.constraint(greaterThanOrEqualToConstant: 44), searchIcon.heightAnchor.constraint(equalToConstant: 24),
// textField.leadingAnchor.constraint(equalTo: searchIcon.trailingAnchor, constant: 10),
closeButton.leadingAnchor.constraint(equalTo: countLabel.trailingAnchor, constant: spacing), textField.trailingAnchor.constraint(equalTo: searchFieldContainer.trailingAnchor, constant: -10),
closeButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -horizontalInset), textField.topAnchor.constraint(equalTo: searchFieldContainer.topAnchor),
closeButton.centerYAnchor.constraint(equalTo: centerYAnchor), textField.bottomAnchor.constraint(equalTo: searchFieldContainer.bottomAnchor),
closeButton.widthAnchor.constraint(equalToConstant: 32),
closeButton.heightAnchor.constraint(equalToConstant: 32) searchFieldDivider.leadingAnchor.constraint(equalTo: searchFieldContainer.trailingAnchor, constant: 12),
searchFieldDivider.centerYAnchor.constraint(equalTo: searchRowView.centerYAnchor),
searchFieldDivider.widthAnchor.constraint(equalToConstant: 1),
searchFieldDivider.heightAnchor.constraint(equalToConstant: 28),
cancelButton.leadingAnchor.constraint(equalTo: searchFieldDivider.trailingAnchor, constant: 18),
cancelButton.trailingAnchor.constraint(equalTo: searchRowView.trailingAnchor, constant: -18),
cancelButton.centerYAnchor.constraint(equalTo: searchRowView.centerYAnchor),
tableView.leadingAnchor.constraint(equalTo: panelView.leadingAnchor, constant: 0),
tableView.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: 0),
tableView.topAnchor.constraint(equalTo: searchRowView.bottomAnchor, constant: 18),
tableView.bottomAnchor.constraint(equalTo: panelView.safeAreaLayoutGuide.bottomAnchor),
emptyStateLabel.leadingAnchor.constraint(equalTo: panelView.leadingAnchor, constant: 32),
emptyStateLabel.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: -32),
emptyStateLabel.topAnchor.constraint(equalTo: searchRowView.bottomAnchor, constant: 56),
previousButton.topAnchor.constraint(equalTo: topAnchor),
previousButton.leadingAnchor.constraint(equalTo: leadingAnchor),
previousButton.widthAnchor.constraint(equalToConstant: 1),
previousButton.heightAnchor.constraint(equalToConstant: 1),
nextButton.topAnchor.constraint(equalTo: topAnchor),
nextButton.leadingAnchor.constraint(equalTo: previousButton.trailingAnchor),
nextButton.widthAnchor.constraint(equalToConstant: 1),
nextButton.heightAnchor.constraint(equalToConstant: 1),
countLabel.topAnchor.constraint(equalTo: topAnchor),
countLabel.leadingAnchor.constraint(equalTo: nextButton.trailingAnchor),
countLabel.widthAnchor.constraint(equalToConstant: 1),
countLabel.heightAnchor.constraint(equalToConstant: 1)
]) ])
} }
private func setupActions() { private func setupActions() {
textField.delegate = self
textField.addTarget(self, action: #selector(textFieldDidReturn), for: .editingDidEndOnExit) textField.addTarget(self, action: #selector(textFieldDidReturn), for: .editingDidEndOnExit)
textField.addTarget(self, action: #selector(textFieldDidChange), for: .editingChanged)
previousButton.addTarget(self, action: #selector(previousAction), for: .touchUpInside) previousButton.addTarget(self, action: #selector(previousAction), for: .touchUpInside)
nextButton.addTarget(self, action: #selector(nextAction), for: .touchUpInside) nextButton.addTarget(self, action: #selector(nextAction), for: .touchUpInside)
closeButton.addTarget(self, action: #selector(closeAction), for: .touchUpInside) cancelButton.addTarget(self, action: #selector(closeAction), for: .touchUpInside)
backgroundButton.addTarget(self, action: #selector(closeAction), for: .touchUpInside)
} }
private func updateNavigationEnabled(_ enabled: Bool) { private func updateLegacyNavigationEnabled(_ enabled: Bool) {
previousButton.isEnabled = enabled previousButton.isEnabled = enabled
previousButton.alpha = enabled ? 1 : 0.45
nextButton.isEnabled = enabled nextButton.isEnabled = enabled
nextButton.alpha = enabled ? 1 : 0.45 }
private func showInitialState() {
tableView.isHidden = true
emptyStateLabel.isHidden = false
emptyStateLabel.text = "输入关键词开始搜索"
}
private func scrollToCurrentMatchIfNeeded() {
guard let currentMatchIndex else { return }
for (sectionIndex, section) in searchSections.enumerated() {
if let rowIndex = section.items.firstIndex(where: { $0.matchIndex == currentMatchIndex }) {
let indexPath = IndexPath(row: rowIndex, section: sectionIndex)
DispatchQueue.main.async { [weak self] in
self?.tableView.scrollToRow(at: indexPath, at: .middle, animated: false)
}
return
}
}
}
private func item(at indexPath: IndexPath) -> RDEPUBReaderSearchSection.Item {
searchSections[indexPath.section].items[indexPath.row]
} }
@objc private func textFieldDidReturn() { @objc private func textFieldDidReturn() {
guard let keyword = textField.text, !keyword.isEmpty else { return } let keyword = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !keyword.isEmpty else { return }
onSearchSubmit?(keyword) onSearchSubmit?(keyword)
textField.resignFirstResponder() textField.resignFirstResponder()
} }
@objc private func textFieldDidChange() {
guard textField.markedTextRange == nil else { return }
onSearchTextChanged?(textField.text ?? "")
}
@objc private func previousAction() { @objc private func previousAction() {
onSearchPrevious?() onSearchPrevious?()
} }
@ -249,3 +403,197 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
onClose?() onClose?()
} }
} }
private extension UIColor {
var rd_searchIsDarkBackground: Bool {
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var alpha: CGFloat = 0
guard getRed(&red, green: &green, blue: &blue, alpha: &alpha) else {
return false
}
let luminance = (0.299 * red) + (0.587 * green) + (0.114 * blue)
return luminance < 0.5
}
}
extension RDEPUBReaderSearchBarView: UITableViewDataSource, UITableViewDelegate {
func numberOfSections(in tableView: UITableView) -> Int {
searchSections.count
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
searchSections[section].items.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(
withIdentifier: RDEPUBReaderSearchResultCell.reuseIdentifier,
for: indexPath
)
guard let cell = cell as? RDEPUBReaderSearchResultCell else {
return cell
}
let item = item(at: indexPath)
cell.configure(
previewText: item.previewText,
keyword: keyword,
isCurrent: item.isCurrent
)
cell.accessibilityIdentifier = "epub.reader.search.result.\(item.matchIndex)"
return cell
}
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
let container = UIView()
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.font = UIFont.systemFont(ofSize: 19, weight: .bold)
label.textColor = UIColor(white: 0.96, alpha: 1)
label.text = searchSections[section].title
label.numberOfLines = 2
container.addSubview(label)
NSLayoutConstraint.activate([
label.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 20),
label.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -20),
label.topAnchor.constraint(equalTo: container.topAnchor, constant: 4),
label.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -4)
])
return container
}
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
40
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
UITableView.automaticDimension
}
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
116
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
onSelectMatch?(item(at: indexPath).matchIndex)
}
}
extension RDEPUBReaderSearchBarView: UITextFieldDelegate {
func textFieldShouldClear(_ textField: UITextField) -> Bool {
DispatchQueue.main.async { [weak self] in
self?.onSearchTextChanged?("")
}
return true
}
}
private final class RDEPUBReaderSearchResultCell: UITableViewCell {
static let reuseIdentifier = "RDEPUBReaderSearchResultCell"
static var cardBackgroundColor = UIColor(white: 0.12, alpha: 1)
static var activeCardBackgroundColor = UIColor(red: 0.17, green: 0.28, blue: 0.38, alpha: 1)
static var primaryTextColor = UIColor(white: 0.96, alpha: 1)
static var highlightTextColor = UIColor.systemBlue
static var activeHighlightTextColor = UIColor(red: 0.40, green: 0.77, blue: 1, alpha: 1)
private let cardView = UIView()
private let previewLabel = UILabel()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setupSubviews()
setupConstraints()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func prepareForReuse() {
super.prepareForReuse()
previewLabel.attributedText = nil
}
func configure(previewText: String, keyword: String, isCurrent: Bool) {
selectionStyle = .none
backgroundColor = .clear
contentView.backgroundColor = .clear
cardView.backgroundColor = isCurrent ? Self.activeCardBackgroundColor : Self.cardBackgroundColor
previewLabel.attributedText = attributedPreviewText(
previewText,
keyword: keyword,
isCurrent: isCurrent
)
}
private func setupSubviews() {
cardView.translatesAutoresizingMaskIntoConstraints = false
previewLabel.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(cardView)
cardView.addSubview(previewLabel)
cardView.layer.cornerRadius = 16
cardView.clipsToBounds = true
previewLabel.numberOfLines = 0
previewLabel.font = UIFont.systemFont(ofSize: 18, weight: .regular)
}
private func setupConstraints() {
NSLayoutConstraint.activate([
cardView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20),
cardView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20),
cardView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10),
cardView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10),
previewLabel.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 16),
previewLabel.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -16),
previewLabel.topAnchor.constraint(equalTo: cardView.topAnchor, constant: 16),
previewLabel.bottomAnchor.constraint(equalTo: cardView.bottomAnchor, constant: -16)
])
}
private func attributedPreviewText(_ previewText: String, keyword: String, isCurrent: Bool) -> NSAttributedString {
let normalizedText = previewText
.replacingOccurrences(of: "\n", with: " ")
.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
.trimmingCharacters(in: .whitespacesAndNewlines)
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineSpacing = 8
let attributed = NSMutableAttributedString(
string: normalizedText,
attributes: [
.font: UIFont.systemFont(ofSize: 18, weight: .regular),
.foregroundColor: Self.primaryTextColor,
.paragraphStyle: paragraphStyle
]
)
let searchKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
guard !searchKeyword.isEmpty else { return attributed }
let nsText = normalizedText as NSString
var searchRange = NSRange(location: 0, length: nsText.length)
let highlightColor = isCurrent ? Self.activeHighlightTextColor : Self.highlightTextColor
while searchRange.length > 0 {
let foundRange = nsText.range(of: searchKeyword, options: [.caseInsensitive], range: searchRange)
guard foundRange.location != NSNotFound else { break }
attributed.addAttribute(.foregroundColor, value: highlightColor, range: foundRange)
let nextLocation = foundRange.location + max(foundRange.length, 1)
guard nextLocation < nsText.length else { break }
searchRange = NSRange(location: nextLocation, length: nsText.length - nextLocation)
}
return attributed
}
}

View File

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

View File

@ -54,6 +54,9 @@ public final class RDURLReaderController: UIViewController {
private var demoStateTimer: Timer? private var demoStateTimer: Timer?
private var lastEmittedDemoState = "" private var lastEmittedDemoState = ""
private var pendingSearchKeyword: String? private var pendingSearchKeyword: String?
private var externalLinkActivationCount = 0
private var lastActivatedExternalURL: URL?
private var lastReaderErrorDescription = "none"
/// ///
/// - Parameters: /// - Parameters:
@ -78,6 +81,7 @@ public final class RDURLReaderController: UIViewController {
super.viewDidLoad() super.viewDidLoad()
view.backgroundColor = .systemBackground view.backgroundColor = .systemBackground
title = bookURL.deletingPathExtension().lastPathComponent title = bookURL.deletingPathExtension().lastPathComponent
RDEPUBResourceURLSchemeHandler.resetDebugMetrics()
embedReaderController() embedReaderController()
installDemoStateLabel() installDemoStateLabel()
} }
@ -370,6 +374,10 @@ public final class RDURLReaderController: UIViewController {
let progression = location.map { String(format: "%.4f", $0.navigationProgression) } ?? "nil" let progression = location.map { String(format: "%.4f", $0.navigationProgression) } ?? "nil"
let mapSnapshot = demoPaginationSnapshot() let mapSnapshot = demoPaginationSnapshot()
let layoutConfig = readerController?.readerContext.currentTextLayoutConfig(pageSize: currentTextPageSize()) let layoutConfig = readerController?.readerContext.currentTextLayoutConfig(pageSize: currentTextPageSize())
let resourceMetrics = RDEPUBResourceURLSchemeHandler.debugMetricsSnapshot()
let cacheStats = readerController?.readerContext.makeChapterSummaryDiskCache().cacheStatistics
?? (fileCount: 0, totalBytes: 0)
let inspectable = readerController?.configuration.allowsInspectableWebViews ?? epubConfiguration.allowsInspectableWebViews
let state = [ let state = [
"reader=opened", "reader=opened",
"page=\(page)", "page=\(page)",
@ -389,7 +397,17 @@ public final class RDURLReaderController: UIViewController {
"avoidOrphans=\(layoutConfig?.avoidOrphans == true ? 1 : 0)", "avoidOrphans=\(layoutConfig?.avoidOrphans == true ? 1 : 0)",
"windowSize=\(readerController?.configuration.onDemandChapterWindowSize ?? epubConfiguration.onDemandChapterWindowSize)", "windowSize=\(readerController?.configuration.onDemandChapterWindowSize ?? epubConfiguration.onDemandChapterWindowSize)",
"parseMs=\(readerController?.readerContext.lastMetadataParseWallClockMs ?? 0)", "parseMs=\(readerController?.readerContext.lastMetadataParseWallClockMs ?? 0)",
"parseConcurrency=\(readerController?.readerContext.lastMetadataParseConcurrency ?? 0)" "parseConcurrency=\(readerController?.readerContext.lastMetadataParseConcurrency ?? 0)",
"inspectable=\(inspectable ? 1 : 0)",
"streamedResources=\(resourceMetrics.streamedResponses)",
"inMemoryResources=\(resourceMetrics.inMemoryResponses)",
"resourceFailures=\(resourceMetrics.failures)",
"cacheFiles=\(cacheStats.fileCount)",
"cacheBytes=\(cacheStats.totalBytes)",
"externalLinks=\(externalLinkActivationCount)",
"lastExternalURL=\(encodedDemoLocationHref(lastActivatedExternalURL?.absoluteString))",
"lastError=\(encodedDemoField(lastReaderErrorDescription))",
"searchMatchText=\(encodedDemoField(currentSearchMatchText()))"
].joined(separator: " ") ].joined(separator: " ")
demoStateLabel.text = state demoStateLabel.text = state
if let logPrefix { if let logPrefix {
@ -405,6 +423,25 @@ public final class RDURLReaderController: UIViewController {
return href.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? href.replacingOccurrences(of: " ", with: "%20") return href.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? href.replacingOccurrences(of: " ", with: "%20")
} }
private func encodedDemoField(_ value: String?) -> String {
guard let value, !value.isEmpty else { return "nil" }
return value.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? value.replacingOccurrences(of: " ", with: "_")
}
private func currentSearchMatchText() -> String {
guard let match = readerController?.searchState?.currentMatch,
let rangeLocation = match.rangeLocation,
let chapterData = readerController?.textChapterData(forNormalizedHref: match.href) else {
return "none"
}
let nsRange = NSRange(location: rangeLocation, length: match.rangeLength)
guard nsRange.location >= 0,
nsRange.location + nsRange.length <= chapterData.attributedContent.length else {
return "none"
}
return chapterData.attributedContent.attributedSubstring(from: nsRange).string
}
private func demoPaginationSnapshot() -> (mode: String, phase: String, knownPages: Int, knownChapters: Int, buildableChapters: Int) { private func demoPaginationSnapshot() -> (mode: String, phase: String, knownPages: Int, knownChapters: Int, buildableChapters: Int) {
guard let readerController else { guard let readerController else {
return ("unavailable", "none", 0, 0, 0) return ("unavailable", "none", 0, 0, 0)
@ -486,6 +523,17 @@ extension RDURLReaderController: RDEPUBReaderDelegate {
public func epubReader(_ reader: UIViewController, didUpdateBookmarks bookmarks: [RDEPUBBookmark]) { public func epubReader(_ reader: UIViewController, didUpdateBookmarks bookmarks: [RDEPUBBookmark]) {
emitDemoState(prefix: "bookmarks=\(bookmarks.count)") emitDemoState(prefix: "bookmarks=\(bookmarks.count)")
} }
public func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL) {
externalLinkActivationCount += 1
lastActivatedExternalURL = url
emitDemoState(prefix: "externalLinks=\(externalLinkActivationCount)")
}
public func epubReader(_ reader: UIViewController, didFailWithError error: Error) {
lastReaderErrorDescription = String(describing: error)
emitDemoState(prefix: "lastError=\(lastReaderErrorDescription)")
}
} }
/// Demo /// Demo

View File

@ -34,8 +34,28 @@ final class RDEPUBChapterSummaryDiskCache {
func read(for key: RDEPUBChapterCacheKey) -> RDEPUBChapterSummary? { func read(for key: RDEPUBChapterCacheKey) -> RDEPUBChapterSummary? {
let fileURL = self.fileURL(for: key) let fileURL = self.fileURL(for: key)
guard let data = try? Data(contentsOf: fileURL) else { return nil } let data: Data
return try? JSONDecoder().decode(RDEPUBChapterSummary.self, from: data) do {
data = try Data(contentsOf: fileURL)
} catch {
let nsError = error as NSError
if nsError.domain == NSCocoaErrorDomain && nsError.code == NSFileReadNoSuchFileError {
//
} else {
#if DEBUG
print("[RDEPUBChapterSummaryDiskCache] ⚠️ read IO error for \(fileURL.lastPathComponent): \(error.localizedDescription)")
#endif
}
return nil
}
do {
return try JSONDecoder().decode(RDEPUBChapterSummary.self, from: data)
} catch {
#if DEBUG
print("[RDEPUBChapterSummaryDiskCache] ⚠️ decode error for \(fileURL.lastPathComponent): \(error.localizedDescription)")
#endif
return nil
}
} }
// MARK: - BookPageMap // MARK: - BookPageMap
@ -75,32 +95,78 @@ final class RDEPUBChapterSummaryDiskCache {
return true return true
} }
/// renderSignature
func containsCompleteSet(keys: [RDEPUBChapterCacheKey]) -> Bool {
isCacheComplete(keys: keys)
}
/// ///
func removeAll() { func removeAll() {
guard let files = try? fileManager.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: nil) else { return } removeFiles(matching: { _ in true })
for fileURL in files where fileURL.pathExtension == "json" { }
try? fileManager.removeItem(at: fileURL)
///
func removeAll(forBookID bookID: String) {
let bookPrefix = Self.cacheNamespacePrefix(for: bookID)
removeFiles { $0.hasPrefix(bookPrefix + "__") }
}
///
func removeAll(forRenderSignature renderSignature: String) {
let renderPrefix = "__" + Self.cacheNamespacePrefix(for: renderSignature) + "__"
removeFiles { $0.contains(renderPrefix) }
}
///
var cacheStatistics: (fileCount: Int, totalBytes: Int64) {
guard let files = try? fileManager.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: [.fileSizeKey]) else {
return (0, 0)
} }
var count = 0
var totalBytes: Int64 = 0
for fileURL in files where fileURL.pathExtension == "json" {
count += 1
if let size = try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize {
totalBytes += Int64(size)
}
}
return (count, totalBytes)
} }
// MARK: - key -> // MARK: - key ->
/// 使 Hashable.hashValue /// 使 Hashable.hashValue
private func fileURL(for key: RDEPUBChapterCacheKey) -> URL { private func fileURL(for key: RDEPUBChapterCacheKey) -> URL {
let bookPrefix = Self.cacheNamespacePrefix(for: key.bookID)
let renderPrefix = Self.cacheNamespacePrefix(for: key.renderSignature)
let rawKey = "\(key.bookID)_\(key.spineIndex)_\(key.renderSignature)_\(key.chapterContentHash)" let rawKey = "\(key.bookID)_\(key.spineIndex)_\(key.renderSignature)_\(key.chapterContentHash)"
let digest = rawKey.sha256Hex let digest = rawKey.sha256Hex
return cacheDirectory.appendingPathComponent("\(digest).json") return cacheDirectory.appendingPathComponent("\(bookPrefix)__\(renderPrefix)__\(digest).json")
} }
private func writeImmediately(summary: RDEPUBChapterSummary, for key: RDEPUBChapterCacheKey) { private func writeImmediately(summary: RDEPUBChapterSummary, for key: RDEPUBChapterCacheKey) {
let fileURL = self.fileURL(for: key) let fileURL = self.fileURL(for: key)
let data = try? JSONEncoder().encode(summary) let tmpURL = fileURL.appendingPathExtension("tmp")
try? data?.write(to: fileURL) do {
let data = try JSONEncoder().encode(summary)
try data.write(to: tmpURL)
if fileManager.fileExists(atPath: fileURL.path) {
_ = try fileManager.replaceItemAt(fileURL, withItemAt: tmpURL)
} else {
try fileManager.moveItem(at: tmpURL, to: fileURL)
}
} catch {
#if DEBUG
print("[RDEPUBChapterSummaryDiskCache] ⚠️ write error for \(fileURL.lastPathComponent): \(error.localizedDescription)")
#endif
try? fileManager.removeItem(at: tmpURL)
}
}
private func removeFiles(matching predicate: (String) -> Bool) {
guard let files = try? fileManager.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: nil) else { return }
for fileURL in files where fileURL.pathExtension == "json" && predicate(fileURL.lastPathComponent) {
try? fileManager.removeItem(at: fileURL)
}
}
private static func cacheNamespacePrefix(for rawValue: String) -> String {
rawValue.sha256Hex.prefix(12).lowercased()
} }
} }

View File

@ -52,7 +52,9 @@ final class RDEPUBChapterWindowCoordinator {
self.isSwitchingChapter = false self.isSwitchingChapter = false
self.buildSnapshotAroundCurrent(chapter: chapter) self.buildSnapshotAroundCurrent(chapter: chapter)
case .failure(let error): case .failure(let error):
#if DEBUG
print("[EPUB][WindowCoord] loadChapter failed at spine=\(initialSpineIndex): \(error), trying next") print("[EPUB][WindowCoord] loadChapter failed at spine=\(initialSpineIndex): \(error), trying next")
#endif
// / linear=false spine // / linear=false spine
let nextIndex = initialSpineIndex + 1 let nextIndex = initialSpineIndex + 1
if nextIndex < totalSpineCount { if nextIndex < totalSpineCount {
@ -76,7 +78,9 @@ final class RDEPUBChapterWindowCoordinator {
private func buildSnapshotAroundCurrent(chapter: RDEPUBRuntimeChapter) { private func buildSnapshotAroundCurrent(chapter: RDEPUBRuntimeChapter) {
guard let current = store.currentSpineIndex else { guard let current = store.currentSpineIndex else {
#if DEBUG
print("[EPUB][WindowCoord] buildSnapshot: currentSpineIndex is nil, ABORT") print("[EPUB][WindowCoord] buildSnapshot: currentSpineIndex is nil, ABORT")
#endif
return return
} }
let chapters = store.windowSpineIndices.compactMap { spineIndex -> RDEPUBRuntimeChapter? in let chapters = store.windowSpineIndices.compactMap { spineIndex -> RDEPUBRuntimeChapter? in
@ -86,7 +90,9 @@ final class RDEPUBChapterWindowCoordinator {
return store.chapterData(for: spineIndex) return store.chapterData(for: spineIndex)
} }
let snapshot = RDEPUBChapterWindowSnapshot.from(chapters: chapters, anchorSpineIndex: current) let snapshot = RDEPUBChapterWindowSnapshot.from(chapters: chapters, anchorSpineIndex: current)
#if DEBUG
print("[EPUB][WindowCoord] snapshot: chapters=\(snapshot.chapters.count) pages=\(snapshot.pageCount) anchorPage=\(snapshot.anchorPageOffset)") print("[EPUB][WindowCoord] snapshot: chapters=\(snapshot.chapters.count) pages=\(snapshot.pageCount) anchorPage=\(snapshot.anchorPageOffset)")
#endif
currentSnapshot = snapshot currentSnapshot = snapshot
isApplyingSnapshot = true isApplyingSnapshot = true
onSnapshotChanged?(snapshot) onSnapshotChanged?(snapshot)
@ -240,14 +246,18 @@ final class RDEPUBChapterWindowCoordinator {
private func handle(error: Error) { private func handle(error: Error) {
// //
#if DEBUG
print("[RDEPUBChapterWindowCoordinator] chapter load error: \(error)") print("[RDEPUBChapterWindowCoordinator] chapter load error: \(error)")
#endif
// loading // loading
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
guard let self else { return } guard let self else { return }
self.context.hideLoading() self.context.hideLoading()
// //
if self.currentSnapshot == nil { if self.currentSnapshot == nil {
#if DEBUG
print("[RDEPUBChapterWindowCoordinator] No snapshot after error, page will be blank") print("[RDEPUBChapterWindowCoordinator] No snapshot after error, page will be blank")
#endif
} }
} }
} }

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) { func remove(forSpineIndex spineIndex: Int) {
lock.lock() lock.lock()
defer { lock.unlock() } defer { lock.unlock() }

View File

@ -32,14 +32,34 @@ final class RDEPUBReaderAnnotationCoordinator {
/// ///
func updateCurrentSelection(_ selection: RDEPUBSelection?) { func updateCurrentSelection(_ selection: RDEPUBSelection?) {
guard let controller else { return } if let selection, !selection.isEmpty {
controller.currentSelection = selection?.isEmpty == false ? selection : nil applySelectionState(.selected(selection))
if controller.currentSelection != nil, } else {
controller.readerView.isShowToolView == false { applySelectionState(.idle)
controller.readerView.tapCenter() }
}
///
/// `.selected` context chrome delegate
/// `.idle` chrome delegate
func applySelectionState(_ state: RDEPUBSelectionState) {
guard let controller else { return }
context.selectionState = state
switch state {
case .idle:
controller.updateReaderChrome()
controller.delegate?.epubReader(controller, didChangeSelection: nil)
case .selecting:
break
case .selected(let selection):
if controller.readerView.isShowToolView == false {
controller.readerView.tapCenter()
}
controller.updateReaderChrome()
controller.delegate?.epubReader(controller, didChangeSelection: selection)
case .committingAction:
break
} }
controller.updateReaderChrome()
controller.delegate?.epubReader(controller, didChangeSelection: controller.currentSelection)
} }
/// ///
@ -139,11 +159,10 @@ final class RDEPUBReaderAnnotationCoordinator {
/// ///
@discardableResult @discardableResult
func go(toHighlightID id: String, animated: Bool = true) -> Bool { func go(toHighlightID id: String, animated: Bool = true) -> Bool {
guard let controller else { return false }
guard let highlight = highlight(withID: id) else { guard let highlight = highlight(withID: id) else {
return false return false
} }
return controller.restoreReadingLocation(highlight.location, animated: animated) return navigate(to: highlight, animated: animated)
} }
/// ///
@ -196,9 +215,8 @@ final class RDEPUBReaderAnnotationCoordinator {
} }
) )
highlightsController.onSelectHighlight = { [weak self, weak highlightsController] highlight in highlightsController.onSelectHighlight = { [weak self, weak highlightsController] highlight in
guard let controller = self?.controller else { return }
highlightsController?.dismiss(animated: true) { highlightsController?.dismiss(animated: true) {
controller.go(to: highlight.location) _ = self?.navigate(to: highlight, animated: true)
} }
} }
highlightsController.onUpdateHighlight = { [weak self] highlight in highlightsController.onUpdateHighlight = { [weak self] highlight in
@ -371,6 +389,17 @@ final class RDEPUBReaderAnnotationCoordinator {
) )
} }
@discardableResult
private func navigate(to highlight: RDEPUBHighlight, animated: Bool) -> Bool {
guard let controller else { return false }
let navigationTarget = scopedHighlight(highlight) ?? highlight
return controller.restoreReadingLocation(
navigationTarget.location,
animated: animated,
targetHighlightRangeInfo: navigationTarget.rangeInfo
)
}
private func persistHighlightsAndRefreshContent() { private func persistHighlightsAndRefreshContent() {
guard let controller else { return } guard let controller else { return }
if let currentBookIdentifier = controller.currentBookIdentifier { if let currentBookIdentifier = controller.currentBookIdentifier {

View File

@ -23,7 +23,9 @@ final class RDEPUBReaderAssemblyCoordinator {
setupLoadingIndicator(controller.loadingIndicator, in: controller.view) setupLoadingIndicator(controller.loadingIndicator, in: controller.view)
setupErrorLabel(controller.errorLabel, in: controller.view) setupErrorLabel(controller.errorLabel, in: controller.view)
controller.delegate?.epubReader(controller, configureTopToolView: controller.topToolView) controller.delegate?.epubReader(controller, configureTopToolView: controller.topToolView)
#if DEBUG
print("[ReadViewDemo] assembleInterface: dataSource=\(readerView.dataSource != nil ? "set" : "nil"), numberOfPages=\(readerView.numberOfPages())") print("[ReadViewDemo] assembleInterface: dataSource=\(readerView.dataSource != nil ? "set" : "nil"), numberOfPages=\(readerView.numberOfPages())")
#endif
} }
/// ///
@ -41,9 +43,13 @@ final class RDEPUBReaderAssemblyCoordinator {
} }
if let textBook = controller.textBook { if let textBook = controller.textBook {
#if DEBUG
print("[ReadViewDemo] finishExternalTextBook: applying textBook with \(textBook.pages.count) pages") print("[ReadViewDemo] finishExternalTextBook: applying textBook with \(textBook.pages.count) pages")
#endif
runtime.applyTextBook(textBook, restoreLocation: restoreLocation) runtime.applyTextBook(textBook, restoreLocation: restoreLocation)
#if DEBUG
print("[ReadViewDemo] finishExternalTextBook: after applyTextBook, numberOfPages=\(context.readerView?.numberOfPages() ?? -1)") print("[ReadViewDemo] finishExternalTextBook: after applyTextBook, numberOfPages=\(context.readerView?.numberOfPages() ?? -1)")
#endif
} else { } else {
runtime.finishPagination(restoreLocation: restoreLocation) runtime.finishPagination(restoreLocation: restoreLocation)
} }

View File

@ -70,7 +70,7 @@ final class RDEPUBReaderChromeCoordinator {
canToggleBookmark: controller.currentBookIdentifier != nil, canToggleBookmark: controller.currentBookIdentifier != nil,
hasBookmarkAtCurrentLocation: hasBookmarkAtCurrentLocation(), hasBookmarkAtCurrentLocation: hasBookmarkAtCurrentLocation(),
canShowBookmarks: !controller.activeBookmarks.isEmpty, canShowBookmarks: !controller.activeBookmarks.isEmpty,
canAddHighlight: controller.configuration.allowsHighlights && controller.currentSelection != nil, canAddHighlight: controller.configuration.allowsHighlights && context.selectionState.hasSelection,
canShowHighlights: controller.configuration.allowsHighlights && !controller.activeHighlights.isEmpty, canShowHighlights: controller.configuration.allowsHighlights && !controller.activeHighlights.isEmpty,
showsTableOfContents: controller.configuration.showsTableOfContents, showsTableOfContents: controller.configuration.showsTableOfContents,
allowsHighlights: controller.configuration.allowsHighlights, allowsHighlights: controller.configuration.allowsHighlights,

View File

@ -59,8 +59,19 @@ final class RDEPUBReaderContext {
var lastMetadataParseWallClockMs: Int = 0 var lastMetadataParseWallClockMs: Int = 0
/// 使 /// 使
var lastMetadataParseConcurrency: Int = 0 var lastMetadataParseConcurrency: Int = 0
/// /// selectionState
var currentSelection: RDEPUBSelection? var currentSelection: RDEPUBSelection? {
get { selectionState.selection }
set {
if let newValue, !newValue.isEmpty {
selectionState = .selected(newValue)
} else {
selectionState = .idle
}
}
}
///
var selectionState: RDEPUBSelectionState = .idle
// MARK: - controller // MARK: - controller

View File

@ -16,7 +16,11 @@ final class RDEPUBReaderLocationCoordinator {
/// ///
@discardableResult @discardableResult
func restoreReadingLocation(_ location: RDEPUBLocation, animated: Bool = false) -> Bool { func restoreReadingLocation(
_ location: RDEPUBLocation,
animated: Bool = false,
targetHighlightRangeInfo: String? = nil
) -> Bool {
guard let controller = context.controller, guard let controller = context.controller,
let readerView = context.readerView else { return false } let readerView = context.readerView else { return false }
guard let targetPageNumber = controller.pageNumber(for: location) else { guard let targetPageNumber = controller.pageNumber(for: location) else {
@ -32,13 +36,15 @@ final class RDEPUBReaderLocationCoordinator {
_ = context.readingSession?.queueNavigation( _ = context.readingSession?.queueNavigation(
to: location, to: location,
relativeToSpineIndex: nil, relativeToSpineIndex: nil,
bookIdentifier: context.currentBookIdentifier bookIdentifier: context.currentBookIdentifier,
targetHighlightRangeInfo: targetHighlightRangeInfo
) )
} else if context.textBook == nil { } else if context.textBook == nil {
_ = context.readingSession?.queueNavigation( _ = context.readingSession?.queueNavigation(
to: location, to: location,
relativeToSpineIndex: nil, relativeToSpineIndex: nil,
bookIdentifier: context.currentBookIdentifier bookIdentifier: context.currentBookIdentifier,
targetHighlightRangeInfo: targetHighlightRangeInfo
) )
} else { } else {
context.readingSession?.transition(to: .jumping) context.readingSession?.transition(to: .jumping)

View File

@ -33,10 +33,14 @@ final class RDEPUBReaderPaginationCoordinator {
controller.showLoading() controller.showLoading()
let token = UUID() let token = UUID()
context.paginationToken = token context.paginationToken = token
#if DEBUG
print("[EPUB][Pagination] profile=\(publication.readingProfile.rawValue) layout=\(publication.layout.rawValue) spine=\(publication.spine.count)") print("[EPUB][Pagination] profile=\(publication.readingProfile.rawValue) layout=\(publication.layout.rawValue) spine=\(publication.spine.count)")
#endif
if publication.readingProfile == .textReflowable { if publication.readingProfile == .textReflowable {
#if DEBUG
print("[EPUB][Pagination] path=text-reflowable-on-demand") print("[EPUB][Pagination] path=text-reflowable-on-demand")
#endif
paginateTextPublication( paginateTextPublication(
parser: parser, parser: parser,
publication: publication, publication: publication,
@ -48,7 +52,9 @@ final class RDEPUBReaderPaginationCoordinator {
} }
if publication.layout == .fixed { if publication.layout == .fixed {
#if DEBUG
print("[EPUB][Pagination] path=fixed-layout") print("[EPUB][Pagination] path=fixed-layout")
#endif
let snapshot = readingSession.makePaginationSnapshot( let snapshot = readingSession.makePaginationSnapshot(
pageCounts: Array(repeating: 1, count: publication.spine.count), pageCounts: Array(repeating: 1, count: publication.spine.count),
preferences: controller.currentPreferences(), preferences: controller.currentPreferences(),
@ -59,7 +65,9 @@ final class RDEPUBReaderPaginationCoordinator {
} }
let paginator = context.makePaginator() let paginator = context.makePaginator()
#if DEBUG
print("[EPUB][Pagination] path=web-paginator") print("[EPUB][Pagination] path=web-paginator")
#endif
context.paginator = paginator context.paginator = paginator
paginator.calculate( paginator.calculate(
parser: parser, parser: parser,

View File

@ -230,6 +230,12 @@ final class RDEPUBReaderRuntime {
searchCoordinator.searchPrevious() searchCoordinator.searchPrevious()
} }
///
@discardableResult
func selectSearchMatch(at index: Int) -> Bool {
searchCoordinator.selectSearchMatch(at: index)
}
/// ///
func clearSearch() { func clearSearch() {
searchCoordinator.clearSearch() searchCoordinator.clearSearch()
@ -365,8 +371,16 @@ final class RDEPUBReaderRuntime {
/// ///
@discardableResult @discardableResult
func restoreReadingLocation(_ location: RDEPUBLocation, animated: Bool = false) -> Bool { func restoreReadingLocation(
locationCoordinator.restoreReadingLocation(location, animated: animated) _ location: RDEPUBLocation,
animated: Bool = false,
targetHighlightRangeInfo: String? = nil
) -> Bool {
locationCoordinator.restoreReadingLocation(
location,
animated: animated,
targetHighlightRangeInfo: targetHighlightRangeInfo
)
} }
/// ///

View File

@ -51,6 +51,21 @@ final class RDEPUBReaderSearchCoordinator {
advanceSearch(by: -1) advanceSearch(by: -1)
} }
///
@discardableResult
func selectSearchMatch(at index: Int) -> Bool {
guard let controller else { return false }
guard var searchState = controller.searchState,
searchState.matches.indices.contains(index) else {
return false
}
searchState.currentMatchIndex = index
controller.searchState = searchState
notifySearchStateChanged()
return navigateToCurrentSearchMatch(animated: true)
}
/// ///
func clearSearch() { func clearSearch() {
guard let controller else { return } guard let controller else { return }
@ -109,12 +124,109 @@ final class RDEPUBReaderSearchCoordinator {
} }
return RDEPUBTextSearchEngine.searchWithoutPublication(textBook: textBook, keyword: keyword) return RDEPUBTextSearchEngine.searchWithoutPublication(textBook: textBook, keyword: keyword)
} }
if controller.readerContext.bookPageMap != nil, controller.publication != nil {
return resolvedOnDemandSearchMatches(for: keyword)
}
if let parser = controller.parser, let publication = controller.publication { if let parser = controller.parser, let publication = controller.publication {
return RDEPUBHTMLSearchEngine(parser: parser, publication: publication).search(keyword: keyword) return RDEPUBHTMLSearchEngine(parser: parser, publication: publication).search(keyword: keyword)
} }
return [] return []
} }
private func resolvedOnDemandSearchMatches(for keyword: String) -> [RDEPUBSearchMatch] {
guard let controller,
let publication = controller.publication else {
return []
}
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedKeyword.isEmpty else {
return []
}
let buildableSpineIndices = publication.spine.indices.filter { index in
let item = publication.spine[index]
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
}
var matches: [RDEPUBSearchMatch] = []
for spineIndex in buildableSpineIndices {
guard let chapter = try? controller.runtime.chapterLoader.loadChapterSynchronouslyForMigration(
spineIndex: spineIndex,
store: controller.runtime.chapterRuntimeStore
) else {
continue
}
let chapterData = makeChapterData(from: chapter, chapterIndex: chapter.pages.first?.chapterIndex ?? 0)
let source = chapter.typesetAttributedString.string as NSString
let fullLength = source.length
guard fullLength > 0 else { continue }
let normalizedHref = publication.resourceResolver.normalizedHref(chapter.href) ?? chapter.href
var localMatchIndex = 0
var searchRange = NSRange(location: 0, length: fullLength)
while searchRange.length > 0 {
let foundRange = source.range(of: normalizedKeyword, options: [.caseInsensitive], range: searchRange)
guard foundRange.location != NSNotFound else {
break
}
let progressionDenominator = max(fullLength - 1, 1)
let progression = Double(foundRange.location) / Double(progressionDenominator)
matches.append(
RDEPUBSearchMatch(
href: normalizedHref,
progression: progression,
previewText: previewText(in: source, matchRange: foundRange),
localMatchIndex: localMatchIndex,
rangeLocation: foundRange.location,
rangeLength: foundRange.length,
rangeAnchor: chapterData.rangeAnchor(for: foundRange)
)
)
localMatchIndex += 1
let nextLocation = foundRange.location + max(foundRange.length, 1)
if nextLocation >= fullLength {
break
}
searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation)
}
}
return matches
}
private func makeChapterData(
from runtimeChapter: RDEPUBRuntimeChapter,
chapterIndex: Int
) -> RDEPUBChapterData {
let textChapter = RDEPUBTextChapter(
chapterIndex: chapterIndex,
spineIndex: runtimeChapter.spineIndex,
href: runtimeChapter.href,
title: runtimeChapter.title,
attributedContent: runtimeChapter.typesetAttributedString,
fragmentOffsets: runtimeChapter.chapterOffsetMap.fragmentOffsets,
pageBreakReasons: runtimeChapter.pages.map(\.metadata.breakReason),
pages: runtimeChapter.pages
)
return RDEPUBChapterData(
chapter: textChapter,
indexTable: RDEPUBTextIndexTable(chapters: [textChapter])
)
}
private func previewText(in text: NSString, matchRange: NSRange) -> String {
let previewRadius = 12
let start = max(matchRange.location - previewRadius, 0)
let end = min(matchRange.location + matchRange.length + previewRadius, text.length)
let range = NSRange(location: start, length: max(end - start, 0))
return text.substring(with: range).trimmingCharacters(in: .whitespacesAndNewlines)
}
private func advanceSearch(by delta: Int) -> Bool { private func advanceSearch(by delta: Int) -> Bool {
guard let controller else { return false } guard let controller else { return false }
guard var searchState = controller.searchState, !searchState.matches.isEmpty else { guard var searchState = controller.searchState, !searchState.matches.isEmpty else {
@ -162,6 +274,14 @@ final class RDEPUBReaderSearchCoordinator {
private func pageNumber(for searchMatch: RDEPUBSearchMatch) -> Int? { private func pageNumber(for searchMatch: RDEPUBSearchMatch) -> Int? {
guard let controller else { return nil } guard let controller else { return nil }
if let chapterData = controller.textChapterData(forNormalizedHref: searchMatch.href) { if let chapterData = controller.textChapterData(forNormalizedHref: searchMatch.href) {
if let exactPageNumber = exactPageNumber(
for: searchMatch,
in: chapterData,
keyword: controller.searchState?.keyword
) {
return exactPageNumber
}
if let pageNumber = chapterData.pageNumber(for: searchMatch) { if let pageNumber = chapterData.pageNumber(for: searchMatch) {
return pageNumber return pageNumber
} }
@ -194,4 +314,39 @@ final class RDEPUBReaderSearchCoordinator {
bookIdentifier: controller.currentBookIdentifier bookIdentifier: controller.currentBookIdentifier
).map { $0 + 1 } ).map { $0 + 1 }
} }
private func exactPageNumber(
for searchMatch: RDEPUBSearchMatch,
in chapterData: RDEPUBChapterData,
keyword: String?
) -> Int? {
let normalizedKeyword = keyword?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !normalizedKeyword.isEmpty else { return nil }
let source = chapterData.attributedContent.string as NSString
let fullLength = source.length
guard fullLength > 0 else { return nil }
var localMatchIndex = 0
var searchRange = NSRange(location: 0, length: fullLength)
while searchRange.length > 0 {
let foundRange = source.range(of: normalizedKeyword, options: [.caseInsensitive], range: searchRange)
guard foundRange.location != NSNotFound else { break }
if localMatchIndex == searchMatch.localMatchIndex,
let page = chapterData.page(containing: foundRange.location) {
return page.absolutePageIndex + 1
}
localMatchIndex += 1
let nextLocation = foundRange.location + max(foundRange.length, 1)
if nextLocation >= fullLength {
break
}
searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation)
}
return nil
}
} }

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 /// writeTotalMs I/O cpuCount * 1.25~1.5 I/O
public var metadataParsingConcurrency: Int public var metadataParsingConcurrency: Int
// MARK:
/// URL scheme https
public var allowedExternalURLSchemes: Set<String>
/// true
public var requiresExternalLinkConfirmation: Bool
/// WebView inspectable false
public var allowsInspectableWebViews: Bool
/// WebView false
public var enablesVerboseWebViewLogging: Bool
// MARK: // MARK:
/// ///
@ -136,6 +147,10 @@ public struct RDEPUBReaderConfiguration: Equatable {
/// - textRenderingEngine: /// - textRenderingEngine:
/// - onDemandChapterWindowSize: 3...15 /// - onDemandChapterWindowSize: 3...15
/// - metadataParsingConcurrency: CPU /// - metadataParsingConcurrency: CPU
/// - allowedExternalURLSchemes: URL scheme https
/// - requiresExternalLinkConfirmation: true
/// - allowsInspectableWebViews: inspectable false
/// - enablesVerboseWebViewLogging: WebView false
public init( public init(
fontSize: CGFloat = 15, fontSize: CGFloat = 15,
lineHeightMultiple: CGFloat = 1.6, lineHeightMultiple: CGFloat = 1.6,
@ -156,7 +171,11 @@ public struct RDEPUBReaderConfiguration: Equatable {
fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode = .automatic, fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode = .automatic,
textRenderingEngine: RDEPUBTextRenderingEngine = .dtCoreText, textRenderingEngine: RDEPUBTextRenderingEngine = .dtCoreText,
onDemandChapterWindowSize: Int = 3, onDemandChapterWindowSize: Int = 3,
metadataParsingConcurrency: Int = ProcessInfo.processInfo.activeProcessorCount metadataParsingConcurrency: Int = ProcessInfo.processInfo.activeProcessorCount,
allowedExternalURLSchemes: Set<String> = ["https"],
requiresExternalLinkConfirmation: Bool = true,
allowsInspectableWebViews: Bool = false,
enablesVerboseWebViewLogging: Bool = false
) { ) {
self.fontSize = fontSize self.fontSize = fontSize
self.lineHeightMultiple = lineHeightMultiple self.lineHeightMultiple = lineHeightMultiple
@ -178,6 +197,10 @@ public struct RDEPUBReaderConfiguration: Equatable {
self.textRenderingEngine = textRenderingEngine self.textRenderingEngine = textRenderingEngine
self.onDemandChapterWindowSize = Self.normalizedChapterWindowSize(onDemandChapterWindowSize) self.onDemandChapterWindowSize = Self.normalizedChapterWindowSize(onDemandChapterWindowSize)
self.metadataParsingConcurrency = max(1, metadataParsingConcurrency) self.metadataParsingConcurrency = max(1, metadataParsingConcurrency)
self.allowedExternalURLSchemes = allowedExternalURLSchemes
self.requiresExternalLinkConfirmation = requiresExternalLinkConfirmation
self.allowsInspectableWebViews = allowsInspectableWebViews
self.enablesVerboseWebViewLogging = enablesVerboseWebViewLogging
} }
/// 使 /// 使

View File

@ -239,6 +239,7 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
let selectedPreset = ThemePreset.allCases.first(where: { $0.theme == currentConfiguration.theme }) ?? .light let selectedPreset = ThemePreset.allCases.first(where: { $0.theme == currentConfiguration.theme }) ?? .light
updateThemeSelection(selectedPreset) updateThemeSelection(selectedPreset)
updateControlAccessibilityValues()
} }
private func applyTheme(_ theme: RDEPUBReaderTheme) { private func applyTheme(_ theme: RDEPUBReaderTheme) {
@ -279,9 +280,17 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
let isSelected = button.tag == preset.rawValue let isSelected = button.tag == preset.rawValue
button.layer.borderWidth = isSelected ? 2 : 1 button.layer.borderWidth = isSelected ? 2 : 1
button.layer.borderColor = isSelected ? currentConfiguration.theme.toolControlTextColor.cgColor : currentConfiguration.theme.toolControlBorderUnselectColor.cgColor button.layer.borderColor = isSelected ? currentConfiguration.theme.toolControlTextColor.cgColor : currentConfiguration.theme.toolControlBorderUnselectColor.cgColor
button.accessibilityValue = isSelected ? "selected" : "unselected"
} }
} }
private func updateControlAccessibilityValues() {
fontChoiceControl.accessibilityValue = fontChoiceControl.titleForSegment(at: fontChoiceControl.selectedSegmentIndex)
lineHeightControl.accessibilityValue = lineHeightControl.titleForSegment(at: lineHeightControl.selectedSegmentIndex)
columnCountControl.accessibilityValue = columnCountControl.titleForSegment(at: columnCountControl.selectedSegmentIndex)
displayTypeControl.accessibilityValue = displayTypeControl.titleForSegment(at: displayTypeControl.selectedSegmentIndex)
}
@objc private func doneAction() { @objc private func doneAction() {
dismiss(animated: true) dismiss(animated: true)
} }
@ -312,6 +321,7 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
let choice = choices[index] let choice = choices[index]
guard choice != currentConfiguration.fontChoice else { return } guard choice != currentConfiguration.fontChoice else { return }
currentConfiguration.fontChoice = choice currentConfiguration.fontChoice = choice
updateControlAccessibilityValues()
onFontChoiceChange?(choice) onFontChoiceChange?(choice)
} }
@ -319,12 +329,14 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
let index = max(0, min(control.selectedSegmentIndex, lineHeightValues.count - 1)) let index = max(0, min(control.selectedSegmentIndex, lineHeightValues.count - 1))
let value = lineHeightValues[index] let value = lineHeightValues[index]
currentConfiguration.lineHeightMultiple = value currentConfiguration.lineHeightMultiple = value
updateControlAccessibilityValues()
onLineHeightChange?(value) onLineHeightChange?(value)
} }
@objc private func columnCountChanged(_ control: UISegmentedControl) { @objc private func columnCountChanged(_ control: UISegmentedControl) {
let numberOfColumns = control.selectedSegmentIndex == 1 ? 2 : 1 let numberOfColumns = control.selectedSegmentIndex == 1 ? 2 : 1
currentConfiguration.numberOfColumns = numberOfColumns currentConfiguration.numberOfColumns = numberOfColumns
updateControlAccessibilityValues()
onColumnCountChange?(numberOfColumns) onColumnCountChange?(numberOfColumns)
} }
@ -339,6 +351,7 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
displayType = .pageCurl displayType = .pageCurl
} }
currentConfiguration.displayType = displayType currentConfiguration.displayType = displayType
updateControlAccessibilityValues()
onDisplayTypeChange?(displayType) onDisplayTypeChange?(displayType)
} }

View File

@ -2,6 +2,9 @@ import UIKit
/// ///
final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView { final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
private let normalSearchColor = UIColor(red: 0.21, green: 0.48, blue: 0.95, alpha: 0.16)
private let activeSearchColor = UIColor(red: 0.14, green: 0.42, blue: 0.95, alpha: 0.34)
/// ///
/// - Parameters: /// - Parameters:
/// - highlights: /// - highlights:
@ -58,8 +61,6 @@ final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
) { ) {
guard let searchState else { return } guard let searchState else { return }
let normalColor = UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.55)
let activeColor = UIColor(red: 255 / 255, green: 159 / 255, blue: 67 / 255, alpha: 0.75)
let pageRange = absoluteOffsetRange(for: page) let pageRange = absoluteOffsetRange(for: page)
let pageStart = pageRange.lowerBound let pageStart = pageRange.lowerBound
let pageEndExclusive = pageRange.upperBound let pageEndExclusive = pageRange.upperBound
@ -72,7 +73,7 @@ final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
guard overlapStart < overlapEnd else { continue } guard overlapStart < overlapEnd else { continue }
let relativeRange = NSRange(location: Int(overlapStart - contentBaseOffset), length: Int(overlapEnd - overlapStart)) let relativeRange = NSRange(location: Int(overlapStart - contentBaseOffset), length: Int(overlapEnd - overlapStart))
let color = match == searchState.currentMatch ? activeColor : normalColor let color = match == searchState.currentMatch ? activeSearchColor : normalSearchColor
content.addAttribute(.backgroundColor, value: color, range: relativeRange) content.addAttribute(.backgroundColor, value: color, range: relativeRange)
} }
} }
@ -97,9 +98,6 @@ final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
let pageEndExclusive = pageRange.upperBound let pageEndExclusive = pageRange.upperBound
if let searchState { if let searchState {
let normalColor = UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.55)
let activeColor = UIColor(red: 255 / 255, green: 159 / 255, blue: 67 / 255, alpha: 0.75)
for match in searchState.matches { for match in searchState.matches {
guard let matchStart = match.rangeLocation else { continue } guard let matchStart = match.rangeLocation else { continue }
let matchEnd = matchStart + match.rangeLength let matchEnd = matchStart + match.rangeLength
@ -113,7 +111,7 @@ final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
let isActive = match == searchState.currentMatch let isActive = match == searchState.currentMatch
let kind: RDEPUBTextOverlayDecoration.Kind = isActive ? .activeSearch : .search let kind: RDEPUBTextOverlayDecoration.Kind = isActive ? .activeSearch : .search
let color = isActive ? activeColor : normalColor let color = isActive ? activeSearchColor : normalSearchColor
background.append(RDEPUBTextOverlayDecoration(kind: kind, absoluteRange: absoluteRange, rects: rects, color: color)) background.append(RDEPUBTextOverlayDecoration(kind: kind, absoluteRange: absoluteRange, rects: rects, color: color))
} }
} }

View File

@ -14,6 +14,11 @@ protocol RDEPUBTextContentViewDelegate: AnyObject {
didRequestSelectionAction action: RDEPUBAnnotationMenuAction, didRequestSelectionAction action: RDEPUBAnnotationMenuAction,
selection: RDEPUBSelection? selection: RDEPUBSelection?
) )
func textContentView(
_ contentView: RDEPUBTextContentView,
didActivateAttachmentText text: String,
sourceRect: CGRect
)
func textContentView( func textContentView(
_ contentView: RDEPUBTextContentView, _ contentView: RDEPUBTextContentView,
didRequestHighlightActions highlight: RDEPUBHighlight, didRequestHighlightActions highlight: RDEPUBHighlight,
@ -33,6 +38,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
private var currentSelection: RDEPUBSelection? private var currentSelection: RDEPUBSelection?
private var menuSelection: RDEPUBSelection? private var menuSelection: RDEPUBSelection?
private var currentHighlights: [RDEPUBHighlight] = [] private var currentHighlights: [RDEPUBHighlight] = []
private var currentSearchState: RDEPUBSearchState?
weak var delegate: RDEPUBTextContentViewDelegate? weak var delegate: RDEPUBTextContentViewDelegate?
#if canImport(DTCoreText) #if canImport(DTCoreText)
@ -188,6 +194,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
currentSelection = nil currentSelection = nil
menuSelection = nil menuSelection = nil
currentHighlights = highlights currentHighlights = highlights
currentSearchState = searchState
selectionController.clearSelection(renderView: coreTextRenderView) selectionController.clearSelection(renderView: coreTextRenderView)
contentInsets = configuration.reflowableContentInsets contentInsets = configuration.reflowableContentInsets
backgroundColor = configuration.theme.contentBackgroundColor backgroundColor = configuration.theme.contentBackgroundColor
@ -244,14 +251,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
overlayView.configure(page: page, selectionColor: overlayView.selectionColor, snapshot: interactionController.snapshot) overlayView.configure(page: page, selectionColor: overlayView.selectionColor, snapshot: interactionController.snapshot)
#if canImport(DTCoreText) #if canImport(DTCoreText)
backgroundOverlayView.configure(page: page, selectionColor: overlayView.selectionColor, snapshot: interactionController.snapshot) backgroundOverlayView.configure(page: page, selectionColor: overlayView.selectionColor, snapshot: interactionController.snapshot)
let (bgDecorations, fgDecorations) = overlayView.buildDecorations(
page: page,
highlights: [],
searchState: searchState,
interactionController: interactionController
)
backgroundOverlayView.applyDecorations(bgDecorations)
overlayView.applyDecorations(fgDecorations)
#endif #endif
updateAccessibilityDecorationSummary() updateAccessibilityDecorationSummary()
@ -262,6 +261,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
func clearSelection() { func clearSelection() {
currentSelection = nil currentSelection = nil
menuSelection = nil menuSelection = nil
currentSearchState = nil
panGestureRecognizer.isEnabled = false panGestureRecognizer.isEnabled = false
selectionController.clearSelection(renderView: coreTextRenderView) selectionController.clearSelection(renderView: coreTextRenderView)
overlayView.clearSelection() overlayView.clearSelection()
@ -487,6 +487,17 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
interactionController.configure(layoutFrame: layoutFrame, page: page) interactionController.configure(layoutFrame: layoutFrame, page: page)
overlayView.updateSnapshot(interactionController.snapshot) overlayView.updateSnapshot(interactionController.snapshot)
backgroundOverlayView.updateSnapshot(interactionController.snapshot) backgroundOverlayView.updateSnapshot(interactionController.snapshot)
if let page = currentPage {
let (bgDecorations, fgDecorations) = overlayView.buildDecorations(
page: page,
highlights: [],
searchState: currentSearchState,
interactionController: interactionController
)
backgroundOverlayView.applyDecorations(bgDecorations)
overlayView.applyDecorations(fgDecorations)
}
} }
#endif #endif
@ -539,6 +550,11 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
clearSelection() clearSelection()
return return
} }
if let attachmentText = attachmentText(at: point),
let sourceRect = attachmentSourceRect(at: point, fallbackPoint: point) {
delegate?.textContentView(self, didActivateAttachmentText: attachmentText, sourceRect: sourceRect)
return
}
guard let highlight = highlight(at: point), guard let highlight = highlight(at: point),
let sourceRect = highlightSourceRect(for: highlight, fallbackPoint: point) else { let sourceRect = highlightSourceRect(for: highlight, fallbackPoint: point) else {
return return
@ -621,6 +637,41 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
return overlayView.convert(fallbackRect, to: self) return overlayView.convert(fallbackRect, to: self)
} }
private func attachmentText(at point: CGPoint) -> String? {
guard let page = currentPage else { return nil }
guard let attachmentRange = interactionController.snapshot?.attachment(at: point)?.stringRange else {
return nil
}
guard page.chapterContent.length > attachmentRange.location else {
return nil
}
let attachment = page.chapterContent.attribute(.attachment, at: attachmentRange.location, effectiveRange: nil)
#if canImport(DTCoreText)
if let textAttachment = attachment as? DTTextAttachment,
let altText = (textAttachment.attributes["alt"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!altText.isEmpty {
return altText
}
#endif
if let fileAttachment = attachment as? NSTextAttachment,
let altText = fileAttachment.accessibilityLabel?.trimmingCharacters(in: .whitespacesAndNewlines),
!altText.isEmpty {
return altText
}
return nil
}
private func attachmentSourceRect(at point: CGPoint, fallbackPoint: CGPoint) -> CGRect? {
if let attachmentRect = interactionController.snapshot?.attachment(at: point)?.frame {
return overlayView.convert(attachmentRect, to: self)
}
let fallbackRect = CGRect(origin: fallbackPoint, size: CGSize(width: 1, height: 1))
return overlayView.convert(fallbackRect, to: self)
}
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer === panGestureRecognizer { if gestureRecognizer === panGestureRecognizer {
return selectionController.isSelecting return selectionController.isSelecting
@ -633,7 +684,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
return true return true
} }
let point = tapGestureRecognizer.location(in: overlayView) let point = tapGestureRecognizer.location(in: overlayView)
return highlight(at: point) != nil return attachmentText(at: point) != nil || highlight(at: point) != nil
} }
return true return true
} }

View File

@ -140,12 +140,25 @@ final class RDEPUBTextSelectionController: NSObject {
let totalLength = max(page.chapterContent.length - 1, 1) let totalLength = max(page.chapterContent.length - 1, 1)
let globalStart = absoluteRange.location let globalStart = absoluteRange.location
let globalEnd = absoluteRange.location + absoluteRange.length let globalEnd = absoluteRange.location + absoluteRange.length
let startAnchor = RDEPUBTextAnchor(
fileIndex: page.spineIndex,
row: 0,
column: 0,
chapterOffset: globalStart
)
let endAnchor = RDEPUBTextAnchor(
fileIndex: page.spineIndex,
row: 0,
column: 0,
chapterOffset: globalEnd
)
return RDEPUBSelection( return RDEPUBSelection(
location: RDEPUBLocation( location: RDEPUBLocation(
href: page.href, href: page.href,
progression: Double(globalStart) / Double(totalLength), progression: Double(globalStart) / Double(totalLength),
lastProgression: Double(max(globalEnd - 1, globalStart)) / Double(totalLength), lastProgression: Double(max(globalEnd - 1, globalStart)) / Double(totalLength),
fragment: nil fragment: nil,
rangeAnchor: RDEPUBTextRangeAnchor(start: startAnchor, end: endAnchor)
), ),
text: selectedText, text: selectedText,
rangeInfo: RDEPUBTextOffsetRangeInfo(href: page.href, start: globalStart, end: globalEnd).jsonString() rangeInfo: RDEPUBTextOffsetRangeInfo(href: page.href, start: globalStart, end: globalEnd).jsonString()

View File

@ -27,18 +27,24 @@ SUMMARY_SCRIPT="$ROOT_DIR/scripts/summarize_ui_results.py"
TARGET_NAME="ReadViewDemoUITests" TARGET_NAME="ReadViewDemoUITests"
QUICK_TESTS=( QUICK_TESTS=(
ArchivePathValidationTests
BookmarkChromeStateTests
BookmarkManagementTests BookmarkManagementTests
BookmarkTests BookmarkTests
CacheHardeningTests
DisplayTypeTests DisplayTypeTests
ErrorAndEdgeCaseTests ErrorAndEdgeCaseTests
ExternalLinkPolicyTests
HighlightsManagementTests HighlightsManagementTests
LocationPersistenceTests LocationPersistenceTests
NavigationBackwardTests
PageNavigationTests PageNavigationTests
ReaderAnnotationExtendedTests ReaderAnnotationExtendedTests
ReaderAnnotationTests ReaderAnnotationTests
ReaderOpenCloseTests ReaderOpenCloseTests
ReaderToolbarTests ReaderToolbarTests
SearchTests SearchTests
SelectionAnnotateTests
SelectionMenuTests SelectionMenuTests
SettingsEffectTests SettingsEffectTests
SettingsExtendedTests SettingsExtendedTests