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,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? {