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,17 +1,13 @@
import Foundation
/// EPUB
///
///
/// - /Fixed Layout/Web
/// -
/// -
/// -
/// -
final class RDEPUBReaderPaginationCoordinator {
private final class MetadataParseState {
var summariesBySpineIndex: [Int: RDEPUBChapterSummary]
var totalResolvedCount: Int
var lastAppliedCount: Int
init(
@@ -26,10 +22,13 @@ final class RDEPUBReaderPaginationCoordinator {
}
private final class MetadataParseCancellationController {
let token: UUID
private let lock = NSLock()
private weak var queue: OperationQueue?
private var cancelled = false
init(token: UUID) {
@@ -66,18 +65,19 @@ final class RDEPUBReaderPaginationCoordinator {
}
private let backgroundInteractionCooldown: CFAbsoluteTime = 0.8
/// N pageMap
static var pageMapRefreshInterval: Int = 32
private unowned let context: RDEPUBReaderContext
private let metadataParseControlLock = NSLock()
private var activeMetadataParseCancellationController: MetadataParseCancellationController?
init(context: RDEPUBReaderContext) {
self.context = context
}
/// /Fixed Layout Web Paginator
func paginatePublication(restoreLocation: RDEPUBLocation?) {
guard let controller = context.controller,
let parser = context.parser,
@@ -143,7 +143,6 @@ final class RDEPUBReaderPaginationCoordinator {
}
}
///
func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
guard let controller = context.controller else { return }
context.textBook = textBook
@@ -160,7 +159,6 @@ final class RDEPUBReaderPaginationCoordinator {
finishPagination(restoreLocation: restoreLocation)
}
/// Fixed Layout Web
func applyPaginationSnapshot(
_ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]),
restoreLocation: RDEPUBLocation?
@@ -179,7 +177,6 @@ final class RDEPUBReaderPaginationCoordinator {
finishPagination(restoreLocation: restoreLocation)
}
///
func finishPagination(restoreLocation: RDEPUBLocation?) {
guard let controller = context.controller,
let readerView = context.readerView else { return }
@@ -197,7 +194,6 @@ final class RDEPUBReaderPaginationCoordinator {
context.runtime?.viewportMonitor.processPendingChangeAfterPagination()
}
/// 使
func repaginatePreservingCurrentLocation() {
guard context.publication != nil else { return }
let restoreLocation = context.runtime?.viewportMonitor.consumePendingPresentationRestoreLocation()
@@ -206,7 +202,6 @@ final class RDEPUBReaderPaginationCoordinator {
paginatePublication(restoreLocation: restoreLocation)
}
///
func refreshVisibleContentPreservingLocation() {
guard let readerView = context.readerView else { return }
let restoreLocation = context.currentVisibleLocation() ?? context.persistenceLocation()
@@ -216,7 +211,6 @@ final class RDEPUBReaderPaginationCoordinator {
}
}
///
func rebuildExternalTextBook() {
guard let controller = context.controller,
let textFileURL = controller.textFileURL else { return }
@@ -245,15 +239,6 @@ final class RDEPUBReaderPaginationCoordinator {
DispatchQueue.global(qos: .utility).async { [weak controller] in
guard controller != nil else { return }
guard context.controller != nil else { return }
if let restoredPageMap = self.restoreBookPageMapIfPossible(publication: publication) {
DispatchQueue.main.async {
guard context.paginationToken == token,
context.controller != nil else { return }
context.runtime?.applyBookPageMap(restoredPageMap, restoreLocation: restoreLocation)
}
return
}
let prioritizedCandidates = self.prioritizedBuildableSpineIndices(
publication: publication,
readingSession: readingSession,
@@ -284,19 +269,9 @@ final class RDEPUBReaderPaginationCoordinator {
runtime: runtime
)
}
let quickWindowChapters = try RDEPUBBackgroundTrace.measure(
"QuickOpen",
"loadInitialRuntimeChapters anchorSpine=\(runtimeChapter.spineIndex)"
) {
try self.loadInitialRuntimeChapters(
anchorSpineIndex: runtimeChapter.spineIndex,
publication: publication,
runtime: runtime
)
}
RDEPUBBackgroundTrace.log(
"QuickOpen",
"ready anchorSpine=\(runtimeChapter.spineIndex) quickWindow=\(quickWindowChapters.map { $0.spineIndex }) pages=\(quickWindowChapters.reduce(0) { $0 + $1.pages.count })"
"ready anchorSpine=\(runtimeChapter.spineIndex) pages=\(runtimeChapter.pages.count)"
)
DispatchQueue.main.async {
@@ -307,8 +282,12 @@ final class RDEPUBReaderPaginationCoordinator {
totalSpineCount: publication.spine.count,
windowRadius: context.configuration.chapterWindowRadius
)
let partialMap = self.makePartialPageMap(from: quickWindowChapters)
let partialMap = self.makePartialPageMap(from: [runtimeChapter])
runtime.applyBookPageMap(partialMap, restoreLocation: restoreLocation)
runtime.prefetchForwardChaptersAfterInitialOpen(
anchorSpineIndex: runtimeChapter.spineIndex,
totalSpineCount: publication.spine.count
)
self.paginateMetadataOnly(token: token, restoreLocation: restoreLocation)
}
} catch {
@@ -340,68 +319,6 @@ final class RDEPUBReaderPaginationCoordinator {
throw lastError ?? RDEPUBParserError.emptySpine
}
private func loadInitialRuntimeChapters(
anchorSpineIndex: Int,
publication: RDEPUBPublication,
runtime: RDEPUBReaderRuntime
) throws -> [RDEPUBRuntimeChapter] {
let windowSpineIndices = initialWindowSpineIndices(
around: anchorSpineIndex,
in: publication,
maxChapterCount: context.configuration.onDemandChapterWindowSize
)
var chapters: [RDEPUBRuntimeChapter] = []
for spineIndex in windowSpineIndices {
do {
let chapter = try runtime.chapterLoader.loadChapterSynchronouslyForMigration(
spineIndex: spineIndex,
store: runtime.chapterRuntimeStore
)
chapters.append(chapter)
} catch {
if spineIndex == anchorSpineIndex {
throw error
}
RDEPUBBackgroundTrace.log("QuickOpen", "skip adjacent spine=\(spineIndex) reason=\(error)")
}
}
return chapters
}
private func initialWindowSpineIndices(
around anchorSpineIndex: Int,
in publication: RDEPUBPublication,
maxChapterCount: Int = 3
) -> [Int] {
let normalizedMaxChapterCount = RDEPUBReaderConfiguration.normalizedChapterWindowSize(maxChapterCount)
let buildableIndices = allBuildableSpineIndices(in: publication)
guard let anchorPosition = buildableIndices.firstIndex(of: anchorSpineIndex) else {
return [anchorSpineIndex]
}
var selected = [anchorSpineIndex]
var nextPosition = anchorPosition + 1
var previousPosition = anchorPosition - 1
while selected.count < normalizedMaxChapterCount,
nextPosition < buildableIndices.count || previousPosition >= 0 {
if nextPosition < buildableIndices.count {
selected.append(buildableIndices[nextPosition])
nextPosition += 1
if selected.count == normalizedMaxChapterCount {
break
}
}
if previousPosition >= 0 {
selected.insert(buildableIndices[previousPosition], at: 0)
previousPosition -= 1
}
}
return selected
}
private func makePartialPageMap(from chapters: [RDEPUBRuntimeChapter]) -> RDEPUBBookPageMap {
var builder = RDEPUBBookPageMap.Builder()
for chapter in chapters {
@@ -450,14 +367,9 @@ final class RDEPUBReaderPaginationCoordinator {
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
}
// MARK: - Phase 0
///
private static let maxRetryCount = 3
private static let retryDelays: [TimeInterval] = [0.5, 2.0, 8.0]
/// pageCountpageRangesfragmentOffsets
/// RDEPUBTextBook
func paginateMetadataOnly(token: UUID, restoreLocation: RDEPUBLocation?) {
let context = self.context
guard let parser = context.parser,
@@ -481,7 +393,20 @@ final class RDEPUBReaderPaginationCoordinator {
!cancellationController.isCancelled,
context.paginationToken == token else { return }
// contentHash + SHA-256
if let restoredPageMap = self.restoreBookPageMapIfPossible(publication: publication) {
RDEPUBBackgroundTrace.log(
"MetadataParse",
"full cache restore hit chapters=\(restoredPageMap.totalChapters) pages=\(restoredPageMap.totalPages)"
)
DispatchQueue.main.async {
guard context.paginationToken == token,
context.controller != nil,
!cancellationController.isCancelled else { return }
context.runtime?.refreshBookPageMapInPlace(restoredPageMap)
}
return
}
let prewarmStart = CFAbsoluteTimeGetCurrent()
var contentHashBySpineIndex: [Int: String] = [:]
for spineIndex in allBuildableIndices {
@@ -542,7 +467,6 @@ final class RDEPUBReaderPaginationCoordinator {
}
}
// 使 spineIndex
let prioritizedSpineIndices: [Int]
if let priorityManager = context.runtime?.backgroundPriorityManager {
let currentSpineIndex = context.runtime?.locationCoordinator.currentVisibleLocation()
@@ -639,6 +563,7 @@ final class RDEPUBReaderPaginationCoordinator {
pageRanges: chapter.pages.map { .init(location: $0.contentRange.location, length: $0.contentRange.length) },
pageCount: chapter.pages.count,
fragmentOffsets: chapter.fragmentOffsets,
cfiMap: chapter.cfiMap,
renderSignature: cacheKey.renderSignature,
schemaVersion: RDEPUBChapterSummary.currentSchemaVersion,
chapterContentHash: cacheKey.chapterContentHash,
@@ -676,7 +601,6 @@ final class RDEPUBReaderPaginationCoordinator {
return
}
// pageMap
var snapshot: [Int: RDEPUBChapterSummary]?
resultLock.lock()
parseState.summariesBySpineIndex[spineIndex] = renderResult
@@ -714,7 +638,6 @@ final class RDEPUBReaderPaginationCoordinator {
timingLock.unlock()
RDEPUBBackgroundTrace.log("MetadataParse", "buildChapter FAILED: spine=\(spineIndex) error=\(error)")
// 退
self.scheduleRetry(
spineIndex: spineIndex,
retryCount: 0,
@@ -779,7 +702,6 @@ final class RDEPUBReaderPaginationCoordinator {
"complete chapters=\(pageMap.totalChapters) pages=\(pageMap.totalPages) finalMergeMs=\(finalMergeMs)"
)
// BackgroundCoverageStore
if let coverageStore = context.runtime?.backgroundCoverageStore {
let resolvedSpineIndices = Set(parseState.summariesBySpineIndex.keys)
let lowerSpine = resolvedSpineIndices.min() ?? 0
@@ -806,7 +728,6 @@ final class RDEPUBReaderPaginationCoordinator {
}
}
///
private func scheduleRetry(
spineIndex: Int,
retryCount: Int,
@@ -878,6 +799,7 @@ final class RDEPUBReaderPaginationCoordinator {
pageRanges: chapter.pages.map { .init(location: $0.contentRange.location, length: $0.contentRange.length) },
pageCount: chapter.pages.count,
fragmentOffsets: chapter.fragmentOffsets,
cfiMap: chapter.cfiMap,
renderSignature: cacheKey.renderSignature,
schemaVersion: RDEPUBChapterSummary.currentSchemaVersion,
chapterContentHash: cacheKey.chapterContentHash,
@@ -921,7 +843,7 @@ final class RDEPUBReaderPaginationCoordinator {
"MetadataParse",
"retry failed for spine=\(spineIndex) attempt=\(retryCount + 1) error=\(error)"
)
//
self.scheduleRetry(
spineIndex: spineIndex,
retryCount: retryCount + 1,