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 4824 additions and 1879 deletions
@@ -72,7 +72,11 @@ enum RDEPUBJavaScriptBridge {
window.WeReadApi.clearHighlights();
window.WeReadApi.setHighlights(\(jsonString(from: highlightsPayload(request.highlights), fallback: "[]")));
if (\(jsonString(from: targetLocationPayload(request.targetLocation), fallback: "null")) !== null) {
window.WeReadApi.scrollToLocation(\(jsonString(from: targetLocationPayload(request.targetLocation), fallback: "null")), \(request.pageIndex));
window.WeReadApi.scrollToLocation(
\(jsonString(from: targetLocationPayload(request.targetLocation), fallback: "null")),
\(request.pageIndex),
\(javaScriptStringLiteral(request.targetHighlightRangeInfo))
);
} else {
window.WeReadApi.scrollToPage(\(request.pageIndex));
}
@@ -212,6 +212,8 @@ public enum RDEPUBParserError: LocalizedError {
case missingManifestItem(idref: String)
/// spine
case emptySpine
/// 穿
case invalidArchiveEntryPath(String)
public var errorDescription: String? {
switch self {
@@ -229,6 +231,8 @@ public enum RDEPUBParserError: LocalizedError {
return "spine itemref 找不到对应 manifest 项: \(idref)"
case .emptySpine:
return "OPF spine 为空"
case .invalidArchiveEntryPath(let path):
return "归档条目路径非法: \(path)"
}
}
}
@@ -53,9 +53,7 @@ public final class RDEPUBPaginator: NSObject {
webView.scrollView.showsVerticalScrollIndicator = false
webView.isUserInteractionEnabled = false
if #available(iOS 16.4, *) {
webView.isInspectable = true
} else {
// Fallback on earlier versions
webView.isInspectable = RDEPUBWebViewDebug.isInspectableEnabled
}
RDEPUBWebViewDebug.log("PaginatorWebView", message: "configured webView=\(RDEPUBWebViewDebug.webViewID(webView))")
return webView
@@ -297,6 +295,9 @@ public final class RDEPUBPaginator: NSObject {
return mediaType.contains("html") || mediaType.contains("xhtml") || mediaType.contains("xml")
}
///
private var measurementPassValues: [Int] = []
/// 0ms/80ms/180ms
private func scheduleMeasurementPass() {
let sessionID = activeSessionID
@@ -306,8 +307,18 @@ public final class RDEPUBPaginator: NSObject {
let currentSpineIndex = currentSpineIndexForMeasurement() else {
return
}
pageCounts[currentSpineIndex] = max(1, pendingMeasurementValue)
let finalValue = max(1, pendingMeasurementValue)
//
if measurementPassValues.count >= 2 {
let minVal = measurementPassValues.min() ?? 1
let maxVal = measurementPassValues.max() ?? 1
if minVal > 0 && Double(maxVal - minVal) / Double(minVal) > 0.2 {
RDEPUBWebViewDebug.log(debugScope, message: "measurement instability: passes=\(measurementPassValues) spine=\(currentSpineIndex)")
}
}
pageCounts[currentSpineIndex] = finalValue
currentMeasurementOffset += 1
measurementPassValues = []
measureNextSpineItem()
return
}
@@ -333,8 +344,10 @@ public final class RDEPUBPaginator: NSObject {
}
if let number = value as? NSNumber {
self.pendingMeasurementValue = max(self.pendingMeasurementValue, number.intValue)
self.measurementPassValues.append(number.intValue)
} else if let intValue = value as? Int {
self.pendingMeasurementValue = max(self.pendingMeasurementValue, intValue)
self.measurementPassValues.append(intValue)
}
RDEPUBWebViewDebug.log(self.debugScope, message: "measurement value=\(self.pendingMeasurementValue) session=\(sessionID)")
self.scheduleMeasurementPass()
@@ -344,6 +357,20 @@ public final class RDEPUBPaginator: NSObject {
}
extension RDEPUBPaginator: WKNavigationDelegate {
public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
guard let url = navigationAction.request.url else {
decisionHandler(.cancel)
return
}
let scheme = url.scheme?.lowercased() ?? ""
if scheme == "file" || scheme == RDEPUBResourceURLSchemeHandler.scheme {
decisionHandler(.allow)
} else {
RDEPUBWebViewDebug.log(debugScope, message: "blocked navigation to non-local scheme: \(scheme)")
decisionHandler(.cancel)
}
}
public func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didStart", url: webView.url)
}
@@ -49,7 +49,9 @@ extension RDEPUBParser {
try fileManager.createDirectory(at: extractionURL, withIntermediateDirectories: true)
for entry in archive {
let destinationURL = extractionURL.appendingPathComponent(entry.path)
guard let destinationURL = validatedExtractionDestination(for: entry.path, extractionRoot: extractionURL) else {
throw RDEPUBParserError.invalidArchiveEntryPath(entry.path)
}
switch entry.type {
case .directory:
try fileManager.createDirectory(at: destinationURL, withIntermediateDirectories: true)
@@ -64,6 +66,31 @@ extension RDEPUBParser {
return extractionURL
}
/// 穿
/// - Parameters:
/// - entryPath:
/// - extractionRoot:
/// - Returns: URL nil
private func validatedExtractionDestination(for entryPath: String, extractionRoot: URL) -> URL? {
//
if entryPath.hasPrefix("/") {
return nil
}
// ..
let components = entryPath.split(separator: "/", omittingEmptySubsequences: true)
if components.contains(where: { $0 == ".." }) {
return nil
}
let destinationURL = extractionRoot.appendingPathComponent(entryPath)
let standardizedDest = destinationURL.standardizedFileURL.path
let standardizedRoot = extractionRoot.standardizedFileURL.path
//
guard standardizedDest.hasPrefix(standardizedRoot) else {
return nil
}
return destinationURL
}
/// EPUB
/// ~/Library/Caches/ssreaderview-epub/{slug}-{fileSize}-{modifiedTimestamp}/
func temporaryExtractionDirectory(for epubURL: URL) -> URL {
@@ -76,6 +76,7 @@ public struct RDEPUBPreferences: Equatable {
publication: RDEPUBPublication,
viewportSize: CGSize,
targetLocation: RDEPUBLocation? = nil,
targetHighlightRangeInfo: String? = nil,
highlights: [RDEPUBHighlight] = [],
searchPresentation: RDEPUBSearchPresentation? = nil
) -> RDEPUBRenderRequest? {
@@ -104,6 +105,7 @@ public struct RDEPUBPreferences: Equatable {
totalPagesInChapter: page.totalPagesInChapter,
presentation: presentationStyle(viewportSize: viewportSize),
targetLocation: targetLocation,
targetHighlightRangeInfo: targetHighlightRangeInfo,
highlights: highlights,
searchPresentation: searchPresentation
)
@@ -31,6 +31,8 @@ public final class RDEPUBReadingSession {
public private(set) var pendingNavigationLocation: RDEPUBLocation?
///
public private(set) var pendingNavigationPageNum: Int?
/// Range Web 使
public private(set) var pendingNavigationHighlightRangeInfo: String?
///
public private(set) var currentViewport: RDEPUBViewport?
/// + + +
@@ -105,6 +107,7 @@ public final class RDEPUBReadingSession {
public func clearPendingNavigation() {
pendingNavigationLocation = nil
pendingNavigationPageNum = nil
pendingNavigationHighlightRangeInfo = nil
}
/// spine
@@ -123,6 +126,26 @@ public final class RDEPUBReadingSession {
return pendingNavigationLocation
}
/// Range
public func pendingHighlightRangeInfo(forPageNumber pageNumber: Int, spineIndex: Int?) -> String? {
guard pendingNavigationPageNum == pageNumber,
let pendingNavigationHighlightRangeInfo,
!pendingNavigationHighlightRangeInfo.isEmpty else {
return nil
}
guard let pendingNavigationLocation else {
return pendingNavigationHighlightRangeInfo
}
guard let spineIndex else {
return pendingNavigationHighlightRangeInfo
}
let pageHref = resourceResolver.href(forSpineIndex: spineIndex)
guard resourceResolver.normalizedHref(pageHref ?? "") == resourceResolver.normalizedHref(pendingNavigationLocation.href) else {
return nil
}
return pendingNavigationHighlightRangeInfo
}
/// spine spread
public func pageContains(spineIndex: Int, in page: EPUBPage) -> Bool {
if let fixedSpread = page.fixedSpread {
@@ -225,7 +248,8 @@ public final class RDEPUBReadingSession {
public func queueNavigation(
to location: RDEPUBLocation,
relativeToSpineIndex spineIndex: Int? = nil,
bookIdentifier: String?
bookIdentifier: String?,
targetHighlightRangeInfo: String? = nil
) -> Int? {
guard let normalizedLocation = resourceResolver.normalizedLocation(
location,
@@ -235,8 +259,14 @@ public final class RDEPUBReadingSession {
return nil
}
pendingNavigationLocation = normalizedLocation.fragment == nil ? nil : normalizedLocation
pendingNavigationPageNum = normalizedLocation.fragment == nil ? nil : pageIndex + 1
let hasTargetHighlightRangeInfo = targetHighlightRangeInfo?.isEmpty == false
let shouldKeepPendingNavigation =
normalizedLocation.fragment != nil ||
normalizedLocation.rangeAnchor != nil ||
hasTargetHighlightRangeInfo
pendingNavigationLocation = shouldKeepPendingNavigation ? normalizedLocation : nil
pendingNavigationPageNum = shouldKeepPendingNavigation ? pageIndex + 1 : nil
pendingNavigationHighlightRangeInfo = hasTargetHighlightRangeInfo ? targetHighlightRangeInfo : nil
transition(to: .jumping)
return pageIndex + 1
}
@@ -363,4 +393,4 @@ public final class RDEPUBReadingSession {
return (pages, chapters)
}
}
}
@@ -85,6 +85,8 @@ public struct RDEPUBReflowableRenderRequest: Equatable {
public var presentation: RDEPUBPresentationStyle
///
public var targetLocation: RDEPUBLocation?
/// DOM Range Web
public var targetHighlightRangeInfo: String?
///
public var highlights: [RDEPUBHighlight]
///
@@ -97,6 +99,7 @@ public struct RDEPUBReflowableRenderRequest: Equatable {
totalPagesInChapter: Int,
presentation: RDEPUBPresentationStyle,
targetLocation: RDEPUBLocation? = nil,
targetHighlightRangeInfo: String? = nil,
highlights: [RDEPUBHighlight] = [],
searchPresentation: RDEPUBSearchPresentation? = nil
) {
@@ -106,6 +109,7 @@ public struct RDEPUBReflowableRenderRequest: Equatable {
self.totalPagesInChapter = totalPagesInChapter
self.presentation = presentation
self.targetLocation = targetLocation
self.targetHighlightRangeInfo = targetHighlightRangeInfo
self.highlights = highlights
self.searchPresentation = searchPresentation
}
@@ -8,6 +8,12 @@ import WebKit
/// URL ss-reader://book/ EPUB
public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler {
struct DebugMetrics {
let streamedResponses: Int
let inMemoryResponses: Int
let failures: Int
}
///
public static let scheme = "ss-reader"
///
@@ -21,12 +27,34 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
private let syncQueue = DispatchQueue(label: "com.ssreaderview.epub.scheme-handler")
/// URL Scheme
private var activeTasks: [ObjectIdentifier: Bool] = [:]
private static let debugMetricsQueue = DispatchQueue(label: "com.ssreaderview.epub.scheme-handler.metrics")
private static var streamedResponseCount = 0
private static var inMemoryResponseCount = 0
private static var failureCount = 0
public init(parser: RDEPUBParser) {
self.parser = parser
super.init()
}
static func resetDebugMetrics() {
debugMetricsQueue.sync {
streamedResponseCount = 0
inMemoryResponseCount = 0
failureCount = 0
}
}
static func debugMetricsSnapshot() -> DebugMetrics {
debugMetricsQueue.sync {
DebugMetrics(
streamedResponses: streamedResponseCount,
inMemoryResponses: inMemoryResponseCount,
failures: failureCount
)
}
}
/// URL Scheme
/// //CSS/JS 404
public func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) {
@@ -55,6 +83,7 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
urlSchemeTask.didFinish()
} else {
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, event: "missing-file")
Self.recordFailure()
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorFileDoesNotExist))
}
clearTask(taskID)
@@ -63,27 +92,12 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "resolved")
do {
let data = try Data(contentsOf: fileURL)
guard isTaskActive(taskID) else { return }
let response = URLResponse(
url: requestURL,
mimeType: Self.mimeType(for: fileURL.pathExtension),
expectedContentLength: data.count,
textEncodingName: Self.textEncodingName(for: fileURL.pathExtension)
)
urlSchemeTask.didReceive(response)
urlSchemeTask.didReceive(data)
urlSchemeTask.didFinish()
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "finished")
} catch {
guard isTaskActive(taskID) else { return }
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "failed", error: error)
urlSchemeTask.didFailWithError(error)
let fileSize = resourceSize(for: fileURL)
if let fileSize, fileSize > 524_288 {
respondWithStreaming(fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask, fileSize: fileSize)
} else {
respondWithInMemoryData(fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask)
}
clearTask(taskID)
}
/// URL Scheme
@@ -106,6 +120,89 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
}
}
private func resourceSize(for url: URL) -> UInt64? {
guard let attrs = try? fileManager.attributesOfItem(atPath: url.path),
let size = attrs[.size] as? UInt64 else {
return nil
}
return size
}
private func respondWithInMemoryData(fileURL: URL, requestURL: URL, taskID: ObjectIdentifier, urlSchemeTask: any WKURLSchemeTask) {
do {
let data = try Data(contentsOf: fileURL)
guard isTaskActive(taskID) else { return }
let response = URLResponse(
url: requestURL,
mimeType: Self.mimeType(for: fileURL.pathExtension),
expectedContentLength: data.count,
textEncodingName: Self.textEncodingName(for: fileURL.pathExtension)
)
urlSchemeTask.didReceive(response)
urlSchemeTask.didReceive(data)
urlSchemeTask.didFinish()
Self.recordInMemoryResponse()
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "finished")
} catch {
guard isTaskActive(taskID) else { return }
Self.recordFailure()
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "failed", error: error)
urlSchemeTask.didFailWithError(error)
}
clearTask(taskID)
}
private func respondWithStreaming(fileURL: URL, requestURL: URL, taskID: ObjectIdentifier, urlSchemeTask: any WKURLSchemeTask, fileSize: UInt64) {
let response = URLResponse(
url: requestURL,
mimeType: Self.mimeType(for: fileURL.pathExtension),
expectedContentLength: Int(fileSize),
textEncodingName: Self.textEncodingName(for: fileURL.pathExtension)
)
urlSchemeTask.didReceive(response)
guard let fileHandle = try? FileHandle(forReadingFrom: fileURL) else {
guard isTaskActive(taskID) else { return }
Self.recordFailure()
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorFileDoesNotExist))
clearTask(taskID)
return
}
let chunkSize = 65_536
defer {
fileHandle.closeFile()
clearTask(taskID)
}
while true {
guard isTaskActive(taskID) else { return }
let data = fileHandle.readData(ofLength: chunkSize)
if data.isEmpty { break }
urlSchemeTask.didReceive(data)
}
urlSchemeTask.didFinish()
Self.recordStreamedResponse()
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "finished-streaming")
}
private static func recordStreamedResponse() {
debugMetricsQueue.sync {
streamedResponseCount += 1
}
}
private static func recordInMemoryResponse() {
debugMetricsQueue.sync {
inMemoryResponseCount += 1
}
}
private static func recordFailure() {
debugMetricsQueue.sync {
failureCount += 1
}
}
/// MIME
private static func mimeType(for pathExtension: String) -> String {
switch pathExtension.lowercased() {
@@ -165,4 +262,4 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
return false
}
}
}
}
@@ -60,7 +60,7 @@ extension RDEPUBWebView {
webView.backgroundColor = .clear
webView.clipsToBounds = true
if #available(iOS 16.4, *) {
webView.isInspectable = true
webView.isInspectable = RDEPUBWebViewDebug.isInspectableEnabled
}
addSubview(webView)
@@ -97,7 +97,8 @@ extension RDEPUBWebView {
request.targetLocation?.href ?? "",
String(request.targetLocation?.progression ?? 0),
String(request.targetLocation?.lastProgression ?? 0),
request.targetLocation?.fragment ?? ""
request.targetLocation?.fragment ?? "",
request.targetHighlightRangeInfo ?? ""
].joined(separator: "|")
let highlightSignature = request.highlights
.map { [$0.id, $0.rangeInfo ?? "", $0.color, $0.style.rawValue].joined(separator: "|") }
@@ -6,6 +6,14 @@ import UIKit
import WebKit
extension RDEPUBWebView {
private var normalSearchOverlayColor: UIColor {
UIColor(red: 0.21, green: 0.48, blue: 0.95, alpha: 0.16)
}
private var activeSearchOverlayColor: UIColor {
UIColor(red: 0.14, green: 0.42, blue: 0.95, alpha: 0.34)
}
/// WebView
func applySearchDecorationsIfNeeded(completion: (() -> Void)? = nil) {
guard let webView else {
@@ -70,7 +78,7 @@ extension RDEPUBWebView {
)
let searchDecorations = decorationList(
from: payload["search"] as? [[String: Any]],
defaultColor: UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.55)
defaultColor: normalSearchOverlayColor
)
return highlightDecorations + searchDecorations
}
@@ -105,9 +113,9 @@ extension RDEPUBWebView {
let hex = item["color"] as? String ?? "#F8E16C"
color = overlayColor(hex: hex, alpha: 1) ?? defaultColor
case .activeSearch:
color = UIColor(red: 255 / 255, green: 159 / 255, blue: 67 / 255, alpha: 0.75)
color = activeSearchOverlayColor
case .search:
color = UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.55)
color = normalSearchOverlayColor
case .highlight:
let hex = item["color"] as? String ?? "#F8E16C"
color = overlayColor(hex: hex, alpha: 0.45) ?? defaultColor
@@ -21,6 +21,30 @@ enum RDEPUBWebViewDebug {
#endif
}()
/// UserDefaults "RDEPUBWebViewVerboseEnabled"
/// logMessage
static var isVerboseEnabled: Bool = {
if let configured = UserDefaults.standard.object(forKey: "RDEPUBWebViewVerboseEnabled") as? Bool {
return configured
}
return false
}()
/// inspectable
/// UserDefaults
static var isInspectableEnabled: Bool = {
if let configured = UserDefaults.standard.object(forKey: "RDEPUBInspectableWebViewsEnabled") as? Bool {
return configured
}
return false
}()
///
static func applyDebugPolicy(inspectableEnabled: Bool, verboseLoggingEnabled: Bool) {
isInspectableEnabled = inspectableEnabled
isVerboseEnabled = verboseLoggingEnabled
}
/// WebView WebView
static func webViewID(_ webView: WKWebView?) -> String {
guard let webView else { return "nil-webview" }
@@ -51,10 +75,26 @@ enum RDEPUBWebViewDebug {
log(scope, message: "webView=\(webViewID(webView)) js=\(action) \(details)")
}
/// JS
/// JS verbose
static func logMessage(_ scope: String, webView: WKWebView?, name: String, body: Any) {
guard isEnabled else { return }
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
@@ -81,4 +121,4 @@ enum RDEPUBWebViewDebug {
}
return url.lastPathComponent.isEmpty ? path : url.lastPathComponent
}
}
}
@@ -1,4 +1,4 @@
(function() {
(function() {
if (window.RDReaderBridge) { return; }
window.addEventListener('error', function(event) {
try {
@@ -470,7 +470,18 @@
setVisiblePageIndex(pageIndex);
reportProgression(null);
},
scrollToLocation: function(location, fallbackPageIndex) {
scrollToLocation: function(location, fallbackPageIndex, targetRangeInfo) {
if (targetRangeInfo) {
var highlightRange = rangeFromInfo(targetRangeInfo);
var highlightRects = rectPayloadForRange(highlightRange, 0, 0);
if (highlightRects.length) {
var highlightRect = highlightRects[0];
var highlightAbsoluteLeft = Math.max(0, highlightRect.x + currentPageOffset());
setVisiblePageIndex(Math.max(0, Math.floor(highlightAbsoluteLeft / effectivePageStride())));
reportProgression(location && location.fragment ? location.fragment : null);
return;
}
}
if (location && location.fragment) {
var resolvedTarget = fragmentTarget(location.fragment);
if (resolvedTarget) {