feat: EPUB阅读器搜索、注释、CFI模块及大书远距跳转优化

- 实现EPUB阅读器搜索功能及选中注释功能
- 优化CFI模块,修复代码审查发现的11个问题
- 实现大书远距目录跳转与后台补全优化方案
- 优化设置面板与章节运行时联动
- 重构及大量改进优化
This commit is contained in:
shenlei
2026-06-22 20:26:34 +08:00
parent f50495ad91
commit c65c190b71
178 changed files with 11380 additions and 6728 deletions
@@ -1,22 +1,30 @@
import Foundation
enum RDEPUBBackgroundTrace {
static func log(_ scope: String, _ message: String) {
let threadRole = Thread.isMainThread ? "main" : "bg"
let threadName = resolvedThreadName()
let queueLabel = resolvedQueueLabel()
print("[EPUB][\(scope)][\(threadRole)][queue=\(queueLabel)][thread=\(threadName)] \(message)")
}
static func measure<T>(_ scope: String, _ message: String, work: () throws -> T) rethrows -> T {
let startedAt = CFAbsoluteTimeGetCurrent()
log(scope, "START \(message)")
do {
let result = try work()
let elapsedMs = Int((CFAbsoluteTimeGetCurrent() - startedAt) * 1000)
log(scope, "END \(message) elapsedMs=\(elapsedMs)")
return result
} catch {
let elapsedMs = Int((CFAbsoluteTimeGetCurrent() - startedAt) * 1000)
log(scope, "FAIL \(message) elapsedMs=\(elapsedMs) error=\(error)")
throw error
@@ -24,12 +32,15 @@ enum RDEPUBBackgroundTrace {
}
private static func resolvedThreadName() -> String {
if let name = Thread.current.name, !name.isEmpty {
return name
}
if Thread.isMainThread {
return "main"
}
return String(describing: Unmanaged.passUnretained(Thread.current).toOpaque())
}
@@ -1,29 +1,26 @@
import Foundation
/// BookPageMap
/// NSAttributedString 100
struct RDEPUBBookPageMapEntry {
let spineIndex: Int
let href: String
let title: String
///
let pageCount: Int
/// 0
let absolutePageStart: Int
/// fragment ID
let fragmentOffsets: [String: Int]
}
///
/// 100 /1000 100KB
///
/// spineIndex
///
struct RDEPUBBookPageMap {
let entries: [RDEPUBBookPageMapEntry]
/// spineIndex
private let indexBySpine: [Int: Int] // spineIndex -> entries
///
private let indexBySpine: [Int: Int]
let totalPages: Int
init(entries: [RDEPUBBookPageMapEntry]) {
@@ -38,9 +35,6 @@ struct RDEPUBBookPageMap {
static let empty = RDEPUBBookPageMap(entries: [])
// MARK: -
/// spineIndex +
func absolutePageIndex(spineIndex: Int, localPageIndex: Int) -> Int? {
guard let idx = indexBySpine[spineIndex] else { return nil }
let entry = entries[idx]
@@ -48,10 +42,9 @@ struct RDEPUBBookPageMap {
return entry.absolutePageStart + localPageIndex
}
/// spineIndex
func spineIndex(forAbsolutePage absolutePage: Int) -> Int? {
guard absolutePage >= 0, absolutePage < totalPages else { return nil }
// entries absolutePageStart
var lo = 0, hi = entries.count
while lo < hi {
let mid = lo + (hi - lo) / 2
@@ -65,7 +58,6 @@ struct RDEPUBBookPageMap {
return entries[lo - 1].spineIndex
}
///
func localPageIndex(forAbsolutePage absolutePage: Int) -> Int? {
guard let si = spineIndex(forAbsolutePage: absolutePage),
let idx = indexBySpine[si] else { return nil }
@@ -75,29 +67,23 @@ struct RDEPUBBookPageMap {
return local
}
/// spineIndex
func entry(forSpineIndex spineIndex: Int) -> RDEPUBBookPageMapEntry? {
guard let idx = indexBySpine[spineIndex] else { return nil }
return entries[idx]
}
/// spineIndex entries
func chapterIndex(forSpineIndex spineIndex: Int) -> Int? {
indexBySpine[spineIndex]
}
/// spineIndex
func pageCount(forSpineIndex spineIndex: Int) -> Int? {
entry(forSpineIndex: spineIndex)?.pageCount
}
///
var totalChapters: Int { entries.count }
// MARK: -
/// Builder pageCount BookPageMap
struct Builder {
private var items: [(spineIndex: Int, href: String, title: String, pageCount: Int, fragmentOffsets: [String: Int])] = []
mutating func add(spineIndex: Int, href: String, title: String, pageCount: Int, fragmentOffsets: [String: Int]) {
@@ -105,7 +91,7 @@ struct RDEPUBBookPageMap {
}
func build() -> RDEPUBBookPageMap {
// spineIndex
let sorted = items.sorted { $0.spineIndex < $1.spineIndex }
var entries: [RDEPUBBookPageMapEntry] = []
var absolutePageStart = 0
@@ -1,8 +1,12 @@
import Foundation
struct RDEPUBChapterCacheKey: Hashable {
let bookID: String
let spineIndex: Int
let renderSignature: String
let chapterContentHash: String
}
@@ -1,7 +1,9 @@
import Foundation
final class RDEPUBChapterDataCache {
private var storage: [Int: RDEPUBRuntimeChapter] = [:]
private let lock = NSLock()
subscript(_ spineIndex: Int) -> RDEPUBRuntimeChapter? {
@@ -1,7 +1,9 @@
import Foundation
final class RDEPUBChapterLoader {
private unowned let context: RDEPUBReaderContext
private var summaryDiskCache: RDEPUBChapterSummaryDiskCache?
init(context: RDEPUBReaderContext) {
@@ -12,24 +14,22 @@ final class RDEPUBChapterLoader {
summaryDiskCache = cache
}
// MARK: -
enum LoadPriority {
case navigation //
case preview //
case prefetch // ±1 +
case navigation
case preview
case prefetch
}
// MARK: -
/// chapterLoadQueue 线
func loadChapter(
spineIndex: Int,
store: RDEPUBChapterRuntimeStore,
priority: LoadPriority = .navigation,
completion: @escaping (Result<RDEPUBRuntimeChapter, Error>) -> Void
) {
// 1. 线 completion 线
if let cached = store.chapterData(for: spineIndex) {
RDEPUBBackgroundTrace.log("ChapterLoader", "cache hit spine=\(spineIndex) priority=\(priority)")
DispatchQueue.main.async {
@@ -38,14 +38,12 @@ final class RDEPUBChapterLoader {
return
}
// 2-3. + pageCountCache
// contentHashForSpineIndex SHA256 + I/O 线
store.markBuilding(true)
store.chapterLoadQueue.async {
RDEPUBBackgroundTrace.log("ChapterLoader", "queue start spine=\(spineIndex) priority=\(priority)")
let cacheKey = self.makeCacheKey(spineIndex: spineIndex)
// pageCountCache
let precomputedPageRanges = store.pageCount(for: cacheKey)?.pageRanges
let diskSummary: RDEPUBChapterSummary?
if precomputedPageRanges == nil {
@@ -63,6 +61,7 @@ final class RDEPUBChapterLoader {
let availablePageRanges = precomputedPageRanges ?? diskPageRanges
do {
let chapter = try RDEPUBBackgroundTrace.measure(
"ChapterLoader",
"buildChapter spine=\(spineIndex) priority=\(priority) cachedRanges=\(availablePageRanges?.count ?? 0)"
@@ -75,7 +74,6 @@ final class RDEPUBChapterLoader {
}
RDEPUBBackgroundTrace.log("ChapterLoader", "buildChapter OK: spine=\(spineIndex) pages=\(chapter.pages.count)")
// 5.
store.insertChapter(chapter)
let pc = RDEPUBRuntimePageCount(
cacheKey: cacheKey,
@@ -86,13 +84,12 @@ final class RDEPUBChapterLoader {
)
store.insertPageCount(pc, for: cacheKey)
// 6.
switch priority {
case .navigation:
// §14.4
let nextTarget = store.consumeNavigationTarget()
if let target = nextTarget, target != spineIndex {
//
store.markBuilding(false)
self.loadChapter(spineIndex: target, store: store, priority: .navigation, completion: completion)
return
@@ -109,8 +106,7 @@ final class RDEPUBChapterLoader {
}
case .prefetch:
//
//
store.removePrefetchTarget(spineIndex)
store.markBuilding(false)
DispatchQueue.main.async {
@@ -118,6 +114,7 @@ final class RDEPUBChapterLoader {
}
}
} catch {
RDEPUBBackgroundTrace.log("ChapterLoader", "buildChapter FAILED: spine=\(spineIndex) error=\(error)")
store.markBuilding(false)
DispatchQueue.main.async {
@@ -127,14 +124,6 @@ final class RDEPUBChapterLoader {
}
}
// MARK: - legacy 使
///
/// - chapterLoadQueue WXRead
/// - RDEPUBTextBook
/// - 线 chapterLoadQueue
/// - store.assertNotOnChapterLoadQueue()
/// - 使
func loadChapterSynchronouslyForMigration(
spineIndex: Int,
store: RDEPUBChapterRuntimeStore?
@@ -193,8 +182,6 @@ final class RDEPUBChapterLoader {
return try result!.get()
}
// MARK: -
private func buildChapter(
spineIndex: Int,
availablePageRanges: [NSRange]?,
@@ -210,7 +197,7 @@ final class RDEPUBChapterLoader {
let layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
if let pageRanges = availablePageRanges {
// ---- pageCountCache chapterSummaryDiskCache ----
RDEPUBBackgroundTrace.log("ChapterLoader", "轻量路径 spine=\(spineIndex) 缓存页数=\(pageRanges.count)")
return try buildChapterFromCachedPageRanges(
spineIndex: spineIndex,
@@ -224,7 +211,6 @@ final class RDEPUBChapterLoader {
)
}
// ---- + ----
RDEPUBBackgroundTrace.log("ChapterLoader", "完整路径 spine=\(spineIndex)")
let builder = context.makeTextBookBuilder(layoutConfig: layoutConfig)
guard let result = try builder.buildChapter(
@@ -245,8 +231,6 @@ final class RDEPUBChapterLoader {
)
}
// MARK: - pageRanges
private func buildChapterFromCachedPageRanges(
spineIndex: Int,
pageRanges: [NSRange],
@@ -259,14 +243,14 @@ final class RDEPUBChapterLoader {
) throws -> RDEPUBRuntimeChapter {
let spineItem = publication.spine[spineIndex]
let href = spineItem.href
let title = spineItem.title ?? ""
let title = spineItem.title
let baseURL = parser.fileURL(forRelativePath: href)?.deletingLastPathComponent()
let rawHTML = try requireHTMLString(parser, href: href)
// 1. HTML NSAttributedString
let request = RDEPUBTextRendererSupport.makeChapterRenderRequest(
href: href,
title: title,
rawHTML: try requireHTMLString(parser, href: href),
rawHTML: rawHTML,
baseURL: baseURL,
style: style,
resourceResolver: publication.resourceResolver,
@@ -281,14 +265,24 @@ final class RDEPUBChapterLoader {
in: typesetString, style: style, layoutConfig: layoutConfig
)
// 2. metadata
// - diskSummary metadata
// - diskSummary pageCountCache attributedString
let metadataSource = diskSummary?.pageMetadataList
let sanitizedCachedRanges = sanitizedPageRanges(pageRanges, contentLength: typesetString.length)
let effectivePageRanges: [NSRange]
let metadataSource: [RDEPUBChapterSummary.PageMetadataSummary]?
if sanitizedCachedRanges.count == pageRanges.count {
effectivePageRanges = sanitizedCachedRanges
metadataSource = diskSummary?.pageMetadataList
} else {
RDEPUBBackgroundTrace.log(
"ChapterLoader",
"缓存页范围失效,回退重分页 spine=\(spineIndex) cached=\(pageRanges.count) valid=\(sanitizedCachedRanges.count) textLength=\(typesetString.length)"
)
effectivePageRanges = typesetString.rd_paginatedFrames(size: pageSize, config: layoutConfig).map(\.contentRange)
metadataSource = nil
}
// 3. pageRanges pages CoreText
let pages = buildPagesFromRanges(
pageRanges: pageRanges,
pageRanges: effectivePageRanges,
typesetString: typesetString,
spineIndex: spineIndex,
href: href,
@@ -296,35 +290,39 @@ final class RDEPUBChapterLoader {
metadataSource: metadataSource
)
// 4. layouter
let layouter = RDEPUBTextLayouter(
attributedString: typesetString,
pageSize: pageSize,
config: layoutConfig
)
// 5. chapterOffsetMap
let offsetMap = RDEPUBChapterOffsetMap(
fragmentOffsets: rendered.fragmentOffsets,
pageStartOffsets: pages.map { $0.pageStartOffset },
pageEndOffsets: pages.map { $0.pageEndOffset }
pageEndOffsets: pages.map { $0.pageEndOffset },
cfiMap: diskSummary?.cfiMap ?? makeCFIMap(
href: href,
spineIndex: spineIndex,
fragmentOffsets: rendered.fragmentOffsets,
rawHTML: rawHTML,
chapterText: typesetString.string
),
chapterText: typesetString.string
)
return RDEPUBRuntimeChapter(
spineIndex: spineIndex,
href: href,
title: title,
sourceAttributedString: nil, // source
sourceAttributedString: nil,
typesetAttributedString: typesetString,
layouter: layouter,
pageRanges: pageRanges,
pageRanges: effectivePageRanges,
pages: pages,
chapterOffsetMap: offsetMap
)
}
// MARK: - pageRanges RDEPUBTextPage
private func buildPagesFromRanges(
pageRanges: [NSRange],
typesetString: NSAttributedString,
@@ -338,10 +336,10 @@ final class RDEPUBChapterLoader {
let pageContent = typesetString.attributedSubstring(from: range)
let metadata: RDEPUBTextPageMetadata
if let metaList = metadataSource, pageIndex < metaList.count {
// metadata
metadata = metaList[pageIndex].toPageMetadata()
} else {
// metadata attributedString
metadata = inferPageMetadata(
from: typesetString,
range: range,
@@ -366,7 +364,21 @@ final class RDEPUBChapterLoader {
}
}
// MARK: - attributedString metadata
private func sanitizedPageRanges(_ pageRanges: [NSRange], contentLength: Int) -> [NSRange] {
guard contentLength > 0 else { return [] }
return pageRanges.compactMap { range in
guard range.location >= 0, range.location < contentLength else {
return nil
}
let maxLength = contentLength - range.location
let clampedLength = min(max(range.length, 0), maxLength)
guard clampedLength > 0 else {
return nil
}
return NSRange(location: range.location, length: clampedLength)
}
}
private func inferPageMetadata(
from string: NSAttributedString,
@@ -431,8 +443,6 @@ final class RDEPUBChapterLoader {
)
}
// MARK: - RDEPUBRuntimeChapter
private func assembleRuntimeChapter(
from chapter: RDEPUBTextChapter,
spineIndex: Int,
@@ -448,17 +458,25 @@ final class RDEPUBChapterLoader {
let offsetMap = RDEPUBChapterOffsetMap(
fragmentOffsets: chapter.fragmentOffsets,
pageStartOffsets: chapter.pages.map { $0.pageStartOffset },
pageEndOffsets: chapter.pages.map { $0.pageEndOffset }
pageEndOffsets: chapter.pages.map { $0.pageEndOffset },
cfiMap: chapter.cfiMap ?? makeCFIMap(
href: chapter.href,
spineIndex: spineIndex,
fragmentOffsets: chapter.fragmentOffsets,
rawHTML: context.parser?.htmlString(forRelativePath: chapter.href),
chapterText: chapter.attributedContent.string
),
chapterText: chapter.attributedContent.string
)
let pageRanges = chapter.pages.map { $0.contentRange }
// P2
let cacheKey = makeCacheKey(spineIndex: spineIndex)
let summary = RDEPUBChapterSummary(
pageRanges: pageRanges.map { .init(location: $0.location, length: $0.length) },
pageCount: chapter.pages.count,
fragmentOffsets: chapter.fragmentOffsets,
cfiMap: offsetMap.cfiMap,
renderSignature: cacheKey.renderSignature,
schemaVersion: RDEPUBChapterSummary.currentSchemaVersion,
chapterContentHash: cacheKey.chapterContentHash,
@@ -479,13 +497,10 @@ final class RDEPUBChapterLoader {
)
}
// MARK: -
private func makeCacheKey(spineIndex: Int) -> RDEPUBChapterCacheKey {
let style = context.currentTextRenderStyle()
let layoutConfig = context.currentTextLayoutConfig(pageSize: context.currentTextPageSize())
// renderSignature §8.2
let lineHeightMultiple = context.configuration.lineHeightMultiple
let renderSignature = [
@@ -521,11 +536,57 @@ final class RDEPUBChapterLoader {
}
return html
}
private func makeCFIMap(
href: String,
spineIndex: Int,
fragmentOffsets: [String: Int],
rawHTML: String?,
chapterText: String
) -> RDEPUBCFIMap {
if let rawHTML {
return RDEPUBCFITextNodeMapBuilder.makeMap(
href: href,
rawHTML: rawHTML,
chapterText: chapterText,
fragmentOffsets: fragmentOffsets
)
}
let domPaths: [String: RDEPUBCFIPath] = [:]
let markers = fragmentOffsets
.sorted { $0.value < $1.value }
.map { fragmentID, offset in
let cfi = RDEPUBCFIGenerator.makeOffsetCFI(
href: href,
fileIndex: spineIndex,
chapterOffset: offset,
fragmentID: fragmentID
)
return RDEPUBCFIMarker(
cfiPath: domPaths[fragmentID] ?? cfi.contentPath,
chapterOffset: offset,
fragmentID: fragmentID
)
}
return RDEPUBCFIMap(
href: href,
markers: markers,
recoveryMetadata: RDEPUBCFIRecoveryMetadata(
domFingerprint: "",
normalizedTextChecksum: RDEPUBCFITextNodeMapBuilder.normalizedText(from: chapterText).sha256Hex,
fragmentPathMap: domPaths
)
)
}
}
enum RDEPUBChapterLoadError: LocalizedError {
case missingParser
case emptyChapter(spineIndex: Int)
case emptyChapterHref(String)
var errorDescription: String? {
@@ -1,17 +1,15 @@
import Foundation
///
/// RDEPUBLocation progression
public struct RDEPUBChapterLocation: Codable, Equatable {
/// spine
public var spineIndex: Int
/// 0-based
public var chapterOffset: Int
/// HTML fragment ID #section1
public var fragmentID: String?
/// progressionfragmentID nil
public var progressionInChapter: Double?
/// schema 1=, 2=
public var schemaVersion: Int
public init(
@@ -28,6 +26,5 @@ public struct RDEPUBChapterLocation: Codable, Equatable {
self.schemaVersion = schemaVersion
}
/// schemaVersion == 1 chapterOffset progression
var isFallbackEstimate: Bool { schemaVersion == 1 }
}
@@ -1,16 +1,35 @@
import Foundation
struct RDEPUBChapterOffsetMap {
let fragmentOffsets: [String: Int]
let pageStartOffsets: [Int]
let pageEndOffsets: [Int]
/// fragmentID ->
let cfiMap: RDEPUBCFIMap?
let chapterText: String?
func chapterOffset(forFragmentID fragmentID: String) -> Int? {
return fragmentOffsets[fragmentID]
}
/// -> 0
func chapterOffset(forCFI rawCFI: String?) -> Int? {
guard let cfi = RDEPUBCFICompatibility.parseLossy(rawCFI) else { return nil }
let resolved = RDEPUBCFIResolver.resolve(cfi)
let lastOffset = max((pageEndOffsets.max() ?? 0), 0)
return RDEPUBCFIRecoveryEngine.recover(
cfi: cfi,
cfiMap: cfiMap,
chapterText: chapterText,
fragmentOffsets: fragmentOffsets,
fallbackOffset: resolved.chapterOffset,
lastOffset: lastOffset
)?.chapterOffset
}
func pageIndex(forChapterOffset offset: Int) -> Int? {
for i in 0..<pageStartOffsets.count {
if offset >= pageStartOffsets[i] && offset <= pageEndOffsets[i] {
@@ -19,4 +38,4 @@ struct RDEPUBChapterOffsetMap {
}
return nil
}
}
}
@@ -2,50 +2,36 @@ import UIKit
final class RDEPUBChapterRuntimeStore {
// MARK: -
/// WXRead chapterDataCache
private let chapterDataCache = RDEPUBChapterDataCache()
/// WXRead pageCountCache
private let pageCountCache = RDEPUBPageCountCache()
/// NSCache WXRead imageCache
let imageCache = NSCache<NSString, UIImage>()
/// WXRead com.weread.chapterload
/// QoS .userInitiated/
let chapterLoadQueue = DispatchQueue(label: "com.rdreader.chapterload", qos: .userInitiated)
private let chapterLoadQueueKey = DispatchSpecificKey<Void>()
// MARK: -
/// spineIndex
private(set) var currentSpineIndex: Int?
/// spineIndex
private(set) var windowSpineIndices: [Int] = []
// MARK: - vs
/// ///
///
private var pendingNavigationTarget: Int?
private let navigationLock = NSLock()
/// ±1
///
private var pendingPrefetchTargets: Set<Int> = []
private let prefetchLock = NSLock()
///
private(set) var isBuilding: Bool = false
private let buildingLock = NSLock()
// MARK: -
init() {
imageCache.countLimit = 50
chapterLoadQueue.setSpecific(key: chapterLoadQueueKey, value: ())
}
@@ -53,8 +39,6 @@ final class RDEPUBChapterRuntimeStore {
dispatchPrecondition(condition: .notOnQueue(chapterLoadQueue))
}
// MARK: - 线 cache wrapper lock
func chapterData(for spineIndex: Int) -> RDEPUBRuntimeChapter? {
return chapterDataCache[spineIndex]
}
@@ -63,8 +47,6 @@ final class RDEPUBChapterRuntimeStore {
return pageCountCache[key]
}
// MARK: -
func insertChapter(_ chapter: RDEPUBRuntimeChapter) {
chapterDataCache[chapter.spineIndex] = chapter
}
@@ -73,9 +55,6 @@ final class RDEPUBChapterRuntimeStore {
pageCountCache[key] = pc
}
// MARK: -
///
func setCurrentChapter(spineIndex: Int, totalSpineCount: Int, windowRadius: Int = 1) {
currentSpineIndex = spineIndex
let radius = max(0, windowRadius)
@@ -88,14 +67,11 @@ final class RDEPUBChapterRuntimeStore {
windowSpineIndices = Array(lowerBound...upperBound)
}
/// spineIndex
func evictableSpineIndices() -> [Int] {
let windowSet = Set(windowSpineIndices)
return chapterDataCache.storedSpineIndices.filter { !windowSet.contains($0) }
}
// MARK: -
func evict(spineIndex: Int) {
chapterDataCache.remove(spineIndex: spineIndex)
pageCountCache.remove(forSpineIndex: spineIndex)
@@ -112,28 +88,21 @@ final class RDEPUBChapterRuntimeStore {
if let ch = currentChapter {
chapterDataCache[current] = ch
}
// WXRead pageCountCache
pageCountCache.removeAll()
}
// MARK: -
func handleMemoryWarning() {
evictAllExceptCurrent()
imageCache.removeAllObjects()
}
// MARK: - §14.4
///
///
func setNavigationTarget(spineIndex: Int) {
navigationLock.lock()
pendingNavigationTarget = spineIndex
navigationLock.unlock()
}
///
func consumeNavigationTarget() -> Int? {
navigationLock.lock()
let target = pendingNavigationTarget
@@ -142,31 +111,24 @@ final class RDEPUBChapterRuntimeStore {
return target
}
// MARK: -
/// ±1
///
func addPrefetchTarget(_ spineIndex: Int) {
prefetchLock.lock()
pendingPrefetchTargets.insert(spineIndex)
prefetchLock.unlock()
}
///
func removePrefetchTarget(_ spineIndex: Int) {
prefetchLock.lock()
pendingPrefetchTargets.remove(spineIndex)
prefetchLock.unlock()
}
///
func clearPrefetchTargets() {
prefetchLock.lock()
pendingPrefetchTargets.removeAll()
prefetchLock.unlock()
}
///
func hasPrefetchTarget(_ spineIndex: Int) -> Bool {
prefetchLock.lock()
let has = pendingPrefetchTargets.contains(spineIndex)
@@ -180,8 +142,6 @@ final class RDEPUBChapterRuntimeStore {
buildingLock.unlock()
}
// MARK: - P1: §8.5
func invalidateAllForSettingsChange() {
chapterDataCache.removeAll()
pageCountCache.removeAll()
@@ -1,8 +1,11 @@
import Foundation
final class RDEPUBChapterSummaryDiskCache {
private let cacheDirectory: URL
private let fileManager = FileManager.default
private let queue = DispatchQueue(label: "com.rdreader.summarydiskcache", qos: .utility)
init(cacheDirectory: URL) {
@@ -10,28 +13,22 @@ final class RDEPUBChapterSummaryDiskCache {
try? fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
}
// MARK: -
func write(summary: RDEPUBChapterSummary, for key: RDEPUBChapterCacheKey) {
queue.async {
self.writeImmediately(summary: summary, for: key)
}
}
///
func writeSynchronously(summary: RDEPUBChapterSummary, for key: RDEPUBChapterCacheKey) {
queue.sync {
self.writeImmediately(summary: summary, for: key)
}
}
///
func flushPendingWrites() {
queue.sync { }
}
// MARK: - loadChapter
func read(for key: RDEPUBChapterCacheKey) -> RDEPUBChapterSummary? {
let fileURL = self.fileURL(for: key)
let data: Data
@@ -40,7 +37,7 @@ final class RDEPUBChapterSummaryDiskCache {
} 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)")
@@ -58,11 +55,6 @@ final class RDEPUBChapterSummaryDiskCache {
}
}
// MARK: - BookPageMap
/// spineIndex summary
/// 线
/// contentHash
func readAll(keys: [(key: RDEPUBChapterCacheKey, spineIndex: Int, href: String, title: String)]) -> (
summaries: [Int: RDEPUBChapterSummary],
mapBuilder: RDEPUBBookPageMap.Builder
@@ -85,7 +77,6 @@ final class RDEPUBChapterSummaryDiskCache {
return (summaries, mapBuilder)
}
///
func isCacheComplete(keys: [RDEPUBChapterCacheKey]) -> Bool {
for key in keys {
if read(for: key) == nil {
@@ -95,24 +86,20 @@ final class RDEPUBChapterSummaryDiskCache {
return true
}
///
func removeAll() {
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)
@@ -128,9 +115,6 @@ final class RDEPUBChapterSummaryDiskCache {
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)
@@ -171,29 +155,48 @@ final class RDEPUBChapterSummaryDiskCache {
}
struct RDEPUBChapterSummary: Codable {
let pageRanges: [RangeData]
let pageCount: Int
let fragmentOffsets: [String: Int]
let cfiMap: RDEPUBCFIMap?
let renderSignature: String
let schemaVersion: Int
let chapterContentHash: String
let pageMetadataList: [PageMetadataSummary]
static let currentSchemaVersion = 6
static let currentSchemaVersion = 9
struct RangeData: Codable {
let location: Int
let length: Int
var nsRange: NSRange { NSRange(location: location, length: length) }
}
struct PageMetadataSummary: Codable {
let breakReason: String
let attachmentRanges: [RangeData]
let attachmentKinds: [String]
let blockKinds: [String]
let semanticHints: [String]
let attachmentPlacements: [String]
let trailingFragmentID: String?
func toPageMetadata() -> RDEPUBTextPageMetadata {
@@ -1,14 +1,15 @@
import Foundation
final class RDEPUBChapterWindowCoordinator {
private unowned let context: RDEPUBReaderContext
private let store: RDEPUBChapterRuntimeStore
private let loader: RDEPUBChapterLoader
///
private(set) var currentSnapshot: RDEPUBChapterWindowSnapshot?
///
var onSnapshotChanged: ((RDEPUBChapterWindowSnapshot) -> Void)?
init(context: RDEPUBReaderContext, store: RDEPUBChapterRuntimeStore, loader: RDEPUBChapterLoader) {
@@ -17,11 +18,8 @@ final class RDEPUBChapterWindowCoordinator {
self.loader = loader
}
///
private var restoreChapterOffset: Int?
// MARK: -
func openBook(at targetSpineIndex: Int, restoreChapterOffset: Int? = nil) {
let totalSpineCount = context.publication?.spine.count ?? 0
store.setCurrentChapter(
@@ -31,19 +29,15 @@ final class RDEPUBChapterWindowCoordinator {
)
self.restoreChapterOffset = restoreChapterOffset
//
isSwitchingChapter = true
//
store.setNavigationTarget(spineIndex: targetSpineIndex)
//
store.clearPrefetchTargets()
//
loadChapterWithFallback(initialSpineIndex: targetSpineIndex, totalSpineCount: totalSpineCount)
}
///
private func loadChapterWithFallback(initialSpineIndex: Int, totalSpineCount: Int) {
loader.loadChapter(spineIndex: initialSpineIndex, store: store, priority: .navigation) { [weak self] result in
guard let self = self else { return }
@@ -55,7 +49,7 @@ final class RDEPUBChapterWindowCoordinator {
#if DEBUG
print("[EPUB][WindowCoord] loadChapter failed at spine=\(initialSpineIndex): \(error), trying next")
#endif
// / linear=false spine
let nextIndex = initialSpineIndex + 1
if nextIndex < totalSpineCount {
self.store.setCurrentChapter(
@@ -66,7 +60,7 @@ final class RDEPUBChapterWindowCoordinator {
self.store.setNavigationTarget(spineIndex: nextIndex)
self.loadChapterWithFallback(initialSpineIndex: nextIndex, totalSpineCount: totalSpineCount)
} else {
//
self.isSwitchingChapter = false
self.handle(error: error)
}
@@ -74,8 +68,6 @@ final class RDEPUBChapterWindowCoordinator {
}
}
// MARK: -
private func buildSnapshotAroundCurrent(chapter: RDEPUBRuntimeChapter) {
guard let current = store.currentSpineIndex else {
#if DEBUG
@@ -98,26 +90,20 @@ final class RDEPUBChapterWindowCoordinator {
onSnapshotChanged?(snapshot)
isApplyingSnapshot = false
// chapterOffset
if let offset = restoreChapterOffset,
let chapter = snapshot.chapterForPage(flattenedPageIndex: snapshot.anchorPageOffset),
let pageIndex = chapter.chapterOffsetMap.pageIndex(forChapterOffset: offset) {
let targetPage = snapshot.anchorPageOffset + pageIndex
context.readerView?.transitionToPage(pageNum: targetPage, animated: false)
} else if snapshot.pageCount > 0 {
//
// reloadData() switchReaderDisplayType currentPage -1 0
// 0 anchorPageOffset transition
context.readerView?.transitionToPage(pageNum: snapshot.anchorPageOffset, animated: false)
}
restoreChapterOffset = nil
//
prefetchAdjacent(current: current)
}
// MARK: -
private func prefetchAdjacent(current: Int) {
for spineIndex in store.windowSpineIndices where spineIndex != current {
guard store.chapterData(for: spineIndex) == nil else { continue }
@@ -129,9 +115,6 @@ final class RDEPUBChapterWindowCoordinator {
}
}
// MARK: -
///
func flipToNextChapter(completion: @escaping (Result<RDEPUBChapterWindowSnapshot, Error>) -> Void) {
guard let current = store.currentSpineIndex else { return }
let next = current + 1
@@ -141,27 +124,23 @@ final class RDEPUBChapterWindowCoordinator {
flipToChapter(spineIndex: next, completion: completion)
}
///
func flipToPreviousChapter(completion: @escaping (Result<RDEPUBChapterWindowSnapshot, Error>) -> Void) {
guard let current = store.currentSpineIndex, current > 0 else { return }
flipToChapter(spineIndex: current - 1, completion: completion)
}
/// //
func flipToChapter(
spineIndex: Int,
completion: @escaping (Result<RDEPUBChapterWindowSnapshot, Error>) -> Void
) {
let totalSpineCount = context.publication?.spine.count ?? 0
//
store.setNavigationTarget(spineIndex: spineIndex)
//
store.clearPrefetchTargets()
//
isSwitchingChapter = true
//
store.setCurrentChapter(
spineIndex: spineIndex,
totalSpineCount: totalSpineCount,
@@ -172,7 +151,6 @@ final class RDEPUBChapterWindowCoordinator {
store.evict(spineIndex: idx)
}
//
if let cached = store.chapterData(for: spineIndex) {
buildSnapshotAroundCurrent(chapter: cached)
isSwitchingChapter = false
@@ -182,7 +160,6 @@ final class RDEPUBChapterWindowCoordinator {
return
}
//
loader.loadChapter(spineIndex: spineIndex, store: store, priority: .navigation) { [weak self] result in
guard let self = self else { return }
self.isSwitchingChapter = false
@@ -198,13 +175,10 @@ final class RDEPUBChapterWindowCoordinator {
}
}
// MARK: -
func refreshSnapshot() {
guard let current = store.currentSpineIndex,
let currentChapter = store.chapterData(for: current) else { return }
//
guard isReaderIdle() else {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
self?.refreshSnapshot()
@@ -223,13 +197,9 @@ final class RDEPUBChapterWindowCoordinator {
}
}
// MARK: - P1:
///
func maintainWindow(afterMovingTo spineIndex: Int) {
let totalSpineCount = context.publication?.spine.count ?? 0
//
store.setCurrentChapter(
spineIndex: spineIndex,
totalSpineCount: totalSpineCount,
@@ -242,18 +212,16 @@ final class RDEPUBChapterWindowCoordinator {
prefetchAdjacent(current: spineIndex)
}
// MARK: -
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")
@@ -293,8 +261,7 @@ final class RDEPUBChapterWindowCoordinator {
return true
}
///
private var isSwitchingChapter: Bool = false
///
private var isApplyingSnapshot: Bool = false
}
@@ -1,26 +1,17 @@
import Foundation
struct RDEPUBChapterWindowSnapshot {
///
let chapters: [RDEPUBRuntimeChapter]
/// RDReaderView
/// RDEPUBTextPage
/// flattenedPages 0
let flattenedPages: [RDEPUBTextPage]
/// chapters
let anchorChapterIndex: Int
/// flattenedPages 0
let anchorPageOffset: Int
/// spineIndex
let windowStartSpineIndex: Int
// MARK: -
///
static func from(
chapters: [RDEPUBRuntimeChapter],
anchorSpineIndex: Int
@@ -29,7 +20,6 @@ struct RDEPUBChapterWindowSnapshot {
let anchorIndex = sortedChapters.firstIndex { $0.spineIndex == anchorSpineIndex } ?? 0
let pageOffset = sortedChapters.prefix(anchorIndex).reduce(0) { $0 + $1.pages.count }
//
var allPages: [RDEPUBTextPage] = []
for (chIdx, ch) in sortedChapters.enumerated() {
for var page in ch.pages {
@@ -49,9 +39,6 @@ struct RDEPUBChapterWindowSnapshot {
)
}
// MARK: -
/// flattenedPages ->
func chapterForPage(flattenedPageIndex: Int) -> RDEPUBRuntimeChapter? {
var offset = 0
for ch in chapters {
@@ -63,11 +50,9 @@ struct RDEPUBChapterWindowSnapshot {
return nil
}
/// flattenedPages -> spineIndex
func spineIndexForPage(flattenedPageIndex: Int) -> Int? {
return chapterForPage(flattenedPageIndex: flattenedPageIndex)?.spineIndex
}
///
var pageCount: Int { flattenedPages.count }
}
@@ -1,7 +1,9 @@
import Foundation
final class RDEPUBPageCountCache {
private var storage: [RDEPUBChapterCacheKey: RDEPUBRuntimePageCount] = [:]
private let lock = NSLock()
subscript(key: RDEPUBChapterCacheKey) -> RDEPUBRuntimePageCount? {
@@ -1,13 +1,18 @@
import Foundation
struct RDEPUBResolvedPage {
let page: RDEPUBTextPage
let chapter: RDEPUBRuntimeChapter
let chapterIndex: Int
}
final class RDEPUBPageResolver {
private unowned let context: RDEPUBReaderContext
private let store: RDEPUBChapterRuntimeStore
init(context: RDEPUBReaderContext, store: RDEPUBChapterRuntimeStore) {
@@ -1,26 +1,23 @@
import Foundation
final class RDEPUBRuntimeChapter {
let spineIndex: Int
let href: String
let title: String
///
var sourceAttributedString: NSAttributedString?
///
let typesetAttributedString: NSAttributedString
///
let layouter: RDEPUBTextLayouter
///
let pageRanges: [NSRange]
///
let pages: [RDEPUBTextPage]
///
let chapterOffsetMap: RDEPUBChapterOffsetMap
init(
@@ -45,7 +42,6 @@ final class RDEPUBRuntimeChapter {
self.chapterOffsetMap = chapterOffsetMap
}
/// sourceAttributedString
func releaseSourceText() {
sourceAttributedString = nil
}
@@ -1,9 +1,14 @@
import Foundation
struct RDEPUBRuntimePageCount {
let cacheKey: RDEPUBChapterCacheKey
let spineIndex: Int
let pageRanges: [NSRange]
let pageCount: Int
let renderSignature: String
}
@@ -1,8 +1,11 @@
import CryptoKit
extension String {
var sha256Hex: String {
let digest = SHA256.hash(data: Data(self.utf8))
return digest.map { String(format: "%02x", $0) }.joined()
}
}