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:
+79
-13
@@ -34,8 +34,28 @@ final class RDEPUBChapterSummaryDiskCache {
|
||||
|
||||
func read(for key: RDEPUBChapterCacheKey) -> RDEPUBChapterSummary? {
|
||||
let fileURL = self.fileURL(for: key)
|
||||
guard let data = try? Data(contentsOf: fileURL) else { return nil }
|
||||
return try? JSONDecoder().decode(RDEPUBChapterSummary.self, from: data)
|
||||
let data: Data
|
||||
do {
|
||||
data = try Data(contentsOf: fileURL)
|
||||
} catch {
|
||||
let nsError = error as NSError
|
||||
if nsError.domain == NSCocoaErrorDomain && nsError.code == NSFileReadNoSuchFileError {
|
||||
// 文件不存在属于正常缓存未命中,不报错
|
||||
} else {
|
||||
#if DEBUG
|
||||
print("[RDEPUBChapterSummaryDiskCache] ⚠️ read IO error for \(fileURL.lastPathComponent): \(error.localizedDescription)")
|
||||
#endif
|
||||
}
|
||||
return nil
|
||||
}
|
||||
do {
|
||||
return try JSONDecoder().decode(RDEPUBChapterSummary.self, from: data)
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("[RDEPUBChapterSummaryDiskCache] ⚠️ decode error for \(fileURL.lastPathComponent): \(error.localizedDescription)")
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 批量读取:二次打开时直接从磁盘构建 BookPageMap
|
||||
@@ -75,32 +95,78 @@ final class RDEPUBChapterSummaryDiskCache {
|
||||
return true
|
||||
}
|
||||
|
||||
/// 语义化别名:用于判断当前 renderSignature 下是否具备完整章节摘要集合。
|
||||
func containsCompleteSet(keys: [RDEPUBChapterCacheKey]) -> Bool {
|
||||
isCacheComplete(keys: keys)
|
||||
}
|
||||
|
||||
/// 清空所有缓存文件
|
||||
func removeAll() {
|
||||
guard let files = try? fileManager.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: nil) else { return }
|
||||
for fileURL in files where fileURL.pathExtension == "json" {
|
||||
try? fileManager.removeItem(at: fileURL)
|
||||
removeFiles(matching: { _ in true })
|
||||
}
|
||||
|
||||
/// 清空指定书籍的所有缓存文件
|
||||
func removeAll(forBookID bookID: String) {
|
||||
let bookPrefix = Self.cacheNamespacePrefix(for: bookID)
|
||||
removeFiles { $0.hasPrefix(bookPrefix + "__") }
|
||||
}
|
||||
|
||||
/// 清空指定渲染签名下的所有缓存文件
|
||||
func removeAll(forRenderSignature renderSignature: String) {
|
||||
let renderPrefix = "__" + Self.cacheNamespacePrefix(for: renderSignature) + "__"
|
||||
removeFiles { $0.contains(renderPrefix) }
|
||||
}
|
||||
|
||||
/// 缓存统计信息
|
||||
var cacheStatistics: (fileCount: Int, totalBytes: Int64) {
|
||||
guard let files = try? fileManager.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: [.fileSizeKey]) else {
|
||||
return (0, 0)
|
||||
}
|
||||
var count = 0
|
||||
var totalBytes: Int64 = 0
|
||||
for fileURL in files where fileURL.pathExtension == "json" {
|
||||
count += 1
|
||||
if let size = try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize {
|
||||
totalBytes += Int64(size)
|
||||
}
|
||||
}
|
||||
return (count, totalBytes)
|
||||
}
|
||||
|
||||
// MARK: - key -> 文件路径
|
||||
|
||||
/// 使用确定性字符串拼接生成文件名,不依赖 Hashable.hashValue
|
||||
private func fileURL(for key: RDEPUBChapterCacheKey) -> URL {
|
||||
let bookPrefix = Self.cacheNamespacePrefix(for: key.bookID)
|
||||
let renderPrefix = Self.cacheNamespacePrefix(for: key.renderSignature)
|
||||
let rawKey = "\(key.bookID)_\(key.spineIndex)_\(key.renderSignature)_\(key.chapterContentHash)"
|
||||
let digest = rawKey.sha256Hex
|
||||
return cacheDirectory.appendingPathComponent("\(digest).json")
|
||||
return cacheDirectory.appendingPathComponent("\(bookPrefix)__\(renderPrefix)__\(digest).json")
|
||||
}
|
||||
|
||||
private func writeImmediately(summary: RDEPUBChapterSummary, for key: RDEPUBChapterCacheKey) {
|
||||
let fileURL = self.fileURL(for: key)
|
||||
let data = try? JSONEncoder().encode(summary)
|
||||
try? data?.write(to: fileURL)
|
||||
let tmpURL = fileURL.appendingPathExtension("tmp")
|
||||
do {
|
||||
let data = try JSONEncoder().encode(summary)
|
||||
try data.write(to: tmpURL)
|
||||
if fileManager.fileExists(atPath: fileURL.path) {
|
||||
_ = try fileManager.replaceItemAt(fileURL, withItemAt: tmpURL)
|
||||
} else {
|
||||
try fileManager.moveItem(at: tmpURL, to: fileURL)
|
||||
}
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("[RDEPUBChapterSummaryDiskCache] ⚠️ write error for \(fileURL.lastPathComponent): \(error.localizedDescription)")
|
||||
#endif
|
||||
try? fileManager.removeItem(at: tmpURL)
|
||||
}
|
||||
}
|
||||
|
||||
private func removeFiles(matching predicate: (String) -> Bool) {
|
||||
guard let files = try? fileManager.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: nil) else { return }
|
||||
for fileURL in files where fileURL.pathExtension == "json" && predicate(fileURL.lastPathComponent) {
|
||||
try? fileManager.removeItem(at: fileURL)
|
||||
}
|
||||
}
|
||||
|
||||
private static func cacheNamespacePrefix(for rawValue: String) -> String {
|
||||
rawValue.sha256Hex.prefix(12).lowercased()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
@@ -52,7 +52,9 @@ final class RDEPUBChapterWindowCoordinator {
|
||||
self.isSwitchingChapter = false
|
||||
self.buildSnapshotAroundCurrent(chapter: chapter)
|
||||
case .failure(let error):
|
||||
#if DEBUG
|
||||
print("[EPUB][WindowCoord] loadChapter failed at spine=\(initialSpineIndex): \(error), trying next")
|
||||
#endif
|
||||
// 自动跳过不可渲染的章节(封面/版权页等 linear=false 的 spine 项)
|
||||
let nextIndex = initialSpineIndex + 1
|
||||
if nextIndex < totalSpineCount {
|
||||
@@ -76,7 +78,9 @@ final class RDEPUBChapterWindowCoordinator {
|
||||
|
||||
private func buildSnapshotAroundCurrent(chapter: RDEPUBRuntimeChapter) {
|
||||
guard let current = store.currentSpineIndex else {
|
||||
#if DEBUG
|
||||
print("[EPUB][WindowCoord] buildSnapshot: currentSpineIndex is nil, ABORT")
|
||||
#endif
|
||||
return
|
||||
}
|
||||
let chapters = store.windowSpineIndices.compactMap { spineIndex -> RDEPUBRuntimeChapter? in
|
||||
@@ -86,7 +90,9 @@ final class RDEPUBChapterWindowCoordinator {
|
||||
return store.chapterData(for: spineIndex)
|
||||
}
|
||||
let snapshot = RDEPUBChapterWindowSnapshot.from(chapters: chapters, anchorSpineIndex: current)
|
||||
#if DEBUG
|
||||
print("[EPUB][WindowCoord] snapshot: chapters=\(snapshot.chapters.count) pages=\(snapshot.pageCount) anchorPage=\(snapshot.anchorPageOffset)")
|
||||
#endif
|
||||
currentSnapshot = snapshot
|
||||
isApplyingSnapshot = true
|
||||
onSnapshotChanged?(snapshot)
|
||||
@@ -240,14 +246,18 @@ final class RDEPUBChapterWindowCoordinator {
|
||||
|
||||
private func handle(error: Error) {
|
||||
// 日志记录,不中断当前阅读状态
|
||||
#if DEBUG
|
||||
print("[RDEPUBChapterWindowCoordinator] chapter load error: \(error)")
|
||||
#endif
|
||||
// 确保 loading 指示器在加载失败时也被隐藏(避免永久白屏)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.context.hideLoading()
|
||||
// 如果有快照但没内容显示,显示错误提示
|
||||
if self.currentSnapshot == nil {
|
||||
#if DEBUG
|
||||
print("[RDEPUBChapterWindowCoordinator] No snapshot after error, page will be blank")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-111
@@ -1,111 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct RDEPUBLocationConverter {
|
||||
|
||||
// MARK: - 主路径:先构建目标章,拿到真实长度后再精确转换
|
||||
|
||||
/// 旧版 RDEPUBLocation -> 新版 RDEPUBChapterLocation
|
||||
/// 主迁移路径:要求先构建目标章,用真实 chapterLength 做精确转换
|
||||
/// 仅在无法获取章节长度时才降级到粗估 fallback
|
||||
static func convert(
|
||||
legacy location: RDEPUBLocation,
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
chapterLengthProvider: ((Int) -> Int?)? = nil
|
||||
) -> RDEPUBChapterLocation? {
|
||||
// 1. 从 href 找到 spineIndex
|
||||
guard let spineItem = publication.spine.first(where: {
|
||||
$0.href == location.href || $0.href.contains(location.href)
|
||||
}) else { return nil }
|
||||
|
||||
let spineIndex = publication.spine.firstIndex(of: spineItem) ?? 0
|
||||
|
||||
// 2. 优先用 fragmentID 定位(最精确,不受 progression 精度影响)
|
||||
if let fragmentID = location.fragment {
|
||||
return RDEPUBChapterLocation(
|
||||
spineIndex: spineIndex,
|
||||
chapterOffset: 0, // fragmentID 由 chapterOffsetMap 精确解析
|
||||
fragmentID: fragmentID,
|
||||
progressionInChapter: location.progression
|
||||
)
|
||||
}
|
||||
|
||||
// 3. 有 chapterLength 时做精确转换
|
||||
if let provider = chapterLengthProvider,
|
||||
let chapterLength = provider(spineIndex), chapterLength > 0 {
|
||||
return convert(
|
||||
legacy: location,
|
||||
spineIndex: spineIndex,
|
||||
chapterLength: chapterLength
|
||||
)
|
||||
}
|
||||
|
||||
// 4. Fallback:无法获取章节长度时的粗估(仅作临时降级)
|
||||
let estimatedOffset = Int(location.progression * 10000)
|
||||
return RDEPUBChapterLocation(
|
||||
spineIndex: spineIndex,
|
||||
chapterOffset: estimatedOffset,
|
||||
fragmentID: nil,
|
||||
progressionInChapter: location.progression,
|
||||
schemaVersion: 1 // 标记为降级结果,后续可被精确值覆盖
|
||||
)
|
||||
}
|
||||
|
||||
/// 精确转换:已知章节实际长度
|
||||
static func convert(
|
||||
legacy location: RDEPUBLocation,
|
||||
spineIndex: Int,
|
||||
chapterLength: Int
|
||||
) -> RDEPUBChapterLocation? {
|
||||
let offset = Int(location.progression * Double(chapterLength))
|
||||
return RDEPUBChapterLocation(
|
||||
spineIndex: spineIndex,
|
||||
chapterOffset: offset,
|
||||
fragmentID: location.fragment,
|
||||
progressionInChapter: location.progression,
|
||||
schemaVersion: 2
|
||||
)
|
||||
}
|
||||
|
||||
/// 从已构建的 RDEPUBRuntimeChapter 做精确转换(推荐迁移路径)
|
||||
static func convert(
|
||||
legacy location: RDEPUBLocation,
|
||||
chapter: RDEPUBRuntimeChapter
|
||||
) -> RDEPUBChapterLocation? {
|
||||
// 优先用 fragmentID
|
||||
if let fragmentID = location.fragment,
|
||||
let fragmentOffset = chapter.chapterOffsetMap.chapterOffset(forFragmentID: fragmentID) {
|
||||
return RDEPUBChapterLocation(
|
||||
spineIndex: chapter.spineIndex,
|
||||
chapterOffset: fragmentOffset,
|
||||
fragmentID: fragmentID,
|
||||
progressionInChapter: nil,
|
||||
schemaVersion: 2
|
||||
)
|
||||
}
|
||||
|
||||
// 用 progression + 真实长度
|
||||
let chapterLength = chapter.typesetAttributedString.length
|
||||
return convert(
|
||||
legacy: location,
|
||||
spineIndex: chapter.spineIndex,
|
||||
chapterLength: chapterLength
|
||||
)
|
||||
}
|
||||
|
||||
/// 新版 -> 旧版(兼容外部接口)
|
||||
static func toLegacy(
|
||||
chapterLocation: RDEPUBChapterLocation,
|
||||
href: String,
|
||||
chapterLength: Int
|
||||
) -> RDEPUBLocation {
|
||||
let progression = chapterLength > 0
|
||||
? Double(chapterLocation.chapterOffset) / Double(chapterLength)
|
||||
: 0
|
||||
return RDEPUBLocation(
|
||||
href: href,
|
||||
progression: min(max(progression, 0), 1),
|
||||
fragment: chapterLocation.fragmentID
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -17,12 +17,6 @@ final class RDEPUBPageCountCache {
|
||||
}
|
||||
}
|
||||
|
||||
func entriesForSpineIndex(_ spineIndex: Int) -> [(RDEPUBChapterCacheKey, RDEPUBRuntimePageCount)] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return storage.filter { $0.value.spineIndex == spineIndex }.map { ($0.key, $0.value) }
|
||||
}
|
||||
|
||||
func remove(forSpineIndex spineIndex: Int) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
Reference in New Issue
Block a user