ReadViewSDK/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLoader.swift
shenlei 7132e4952b 章节加载器补充三级缓存命中日志
loadChapterWithSnapshot 打印内存章节 HIT、并发加载合流、构建来源
(memoryPageCount/diskSummary/fullRender)与构建耗时,便于判断章节
跳转时预加载与缓存的命中情况。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 11:34:02 +09:00

841 lines
31 KiB
Swift

import Foundation
final class RDEPUBChapterLoader {
private typealias LoadCompletion = (Result<RDEPUBRuntimeChapter, Error>) -> Void
private struct PendingLoad {
var priority: LoadPriority
var completions: [LoadCompletion]
}
private weak var context: RDEPUBReaderContext?
private var summaryDiskCache: RDEPUBChapterSummaryDiskCache?
private let pendingLoadsLock = NSLock()
private var pendingLoads: [Int: PendingLoad] = [:]
var onDeferredCFIMapReady: ((Int) -> Void)?
init(context: RDEPUBReaderContext) {
self.context = context
}
func setSummaryDiskCache(_ cache: RDEPUBChapterSummaryDiskCache) {
summaryDiskCache = cache
}
enum LoadPriority {
case navigation
case preview
case prefetch
}
private enum LoadRegistrationResult {
case created
case joined(existingPriority: LoadPriority, effectivePriority: LoadPriority)
}
func loadChapter(
spineIndex: Int,
store: RDEPUBChapterRuntimeStore,
priority: LoadPriority = .navigation,
completion: @escaping (Result<RDEPUBRuntimeChapter, Error>) -> Void
) {
let layoutSnapshot = context?.makeLayoutSnapshot()
loadChapterWithSnapshot(
spineIndex: spineIndex,
store: store,
priority: priority,
layoutSnapshot: layoutSnapshot,
completion: completion
)
}
private func loadChapterWithSnapshot(
spineIndex: Int,
store: RDEPUBChapterRuntimeStore,
priority: LoadPriority,
layoutSnapshot: RDEPUBLayoutSnapshot?,
completion: @escaping (Result<RDEPUBRuntimeChapter, Error>) -> Void
) {
if let cached = store.chapterData(for: spineIndex) {
RDEPUBBackgroundTrace.log(
"ChapterLoad",
"memory HIT spine=\(spineIndex) pages=\(cached.pages.count) priority=\(priority)"
)
if let context {
scheduleDeferredCFIMapBuildIfNeeded(
for: cached,
cacheKey: makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: layoutSnapshot),
store: store
)
}
DispatchQueue.main.async {
completion(.success(cached))
}
return
}
let completionOnMain: LoadCompletion = { result in
DispatchQueue.main.async {
completion(result)
}
}
let registration = registerPendingLoad(
spineIndex: spineIndex,
priority: priority,
completion: completionOnMain
)
switch registration {
case .joined(let existingPriority, let effectivePriority):
RDEPUBBackgroundTrace.log(
"ChapterLoad",
"join pendingLoad spine=\(spineIndex) existing=\(existingPriority) effective=\(effectivePriority)"
)
return
case .created:
break
}
store.markBuilding(true)
_ = store.beginPendingChapterLoad(for: spineIndex)
store.chapterLoadQueue.async { [weak self] in
guard let self, let context = self.context else {
store.endPendingChapterLoad(for: spineIndex)
store.markBuilding(false)
self?.resolvePendingLoad(spineIndex: spineIndex, result: .failure(RDEPUBChapterLoadError.missingParser))
return
}
let queuePriority = self.pendingPriority(for: spineIndex) ?? priority
let cacheKey = self.makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: layoutSnapshot)
let precomputedPageRanges = store.pageCount(for: cacheKey)?.pageRanges
let diskSummary: RDEPUBChapterSummary?
if precomputedPageRanges == nil {
diskSummary = self.summaryDiskCache?.read(for: cacheKey)
} else {
diskSummary = nil
}
let diskPageRanges = diskSummary?.pageRanges.map { $0.nsRange }
let availablePageRanges = precomputedPageRanges ?? diskPageRanges
let pageRangeSource: String
if precomputedPageRanges != nil {
pageRangeSource = "HIT(memoryPageCount)"
} else if diskPageRanges != nil {
pageRangeSource = "HIT(diskSummary)"
} else {
pageRangeSource = "MISS(fullRender)"
}
RDEPUBBackgroundTrace.log(
"ChapterLoad",
"build start spine=\(spineIndex) priority=\(queuePriority) pageRanges=\(pageRangeSource)"
)
let buildStart = CFAbsoluteTimeGetCurrent()
do {
let chapter = try self.buildChapter(
spineIndex: spineIndex,
availablePageRanges: availablePageRanges,
diskSummary: diskSummary,
context: context,
layoutSnapshot: layoutSnapshot
)
RDEPUBBackgroundTrace.log(
"ChapterLoad",
"build done spine=\(spineIndex) pages=\(chapter.pages.count) elapsedMs=\(Int((CFAbsoluteTimeGetCurrent() - buildStart) * 1000)) pageRanges=\(pageRangeSource)"
)
store.insertChapter(chapter)
let pc = RDEPUBRuntimePageCount(
cacheKey: cacheKey,
spineIndex: spineIndex,
pageRanges: chapter.pageRanges,
pageCount: chapter.pages.count,
renderSignature: cacheKey.renderSignature
)
store.insertPageCount(pc, for: cacheKey)
self.scheduleDeferredCFIMapBuildIfNeeded(
for: chapter,
cacheKey: cacheKey,
store: store
)
let effectivePriority = self.pendingPriority(for: spineIndex) ?? queuePriority
store.endPendingChapterLoad(for: spineIndex)
switch effectivePriority {
case .navigation:
let nextTarget = store.consumeNavigationTarget()
if let target = nextTarget, target != spineIndex {
store.markBuilding(false)
self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter))
self.loadChapterWithSnapshot(spineIndex: target, store: store, priority: .navigation, layoutSnapshot: layoutSnapshot, completion: { _ in })
return
}
store.markBuilding(false)
self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter))
case .preview:
store.markBuilding(false)
self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter))
case .prefetch:
store.removePrefetchTarget(spineIndex)
store.markBuilding(false)
self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter))
}
} catch {
RDEPUBBackgroundTrace.log(
"ChapterLoad",
"build failed spine=\(spineIndex) pageRanges=\(pageRangeSource) error=\(String(describing: error))"
)
store.endPendingChapterLoad(for: spineIndex)
store.markBuilding(false)
self.resolvePendingLoad(spineIndex: spineIndex, result: .failure(error))
}
}
}
func loadChapterSynchronouslyForMigration(
spineIndex: Int,
store: RDEPUBChapterRuntimeStore?,
layoutSnapshot: RDEPUBLayoutSnapshot? = nil
) throws -> RDEPUBRuntimeChapter {
guard let context else {
throw RDEPUBChapterLoadError.missingParser
}
if let cached = store?.chapterData(for: spineIndex) {
if let store {
scheduleDeferredCFIMapBuildIfNeeded(
for: cached,
cacheKey: makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: layoutSnapshot),
store: store
)
}
return cached
}
guard let store else {
throw RDEPUBChapterLoadError.missingParser
}
if store.hasPendingChapterLoad(for: spineIndex) {
var result: Result<RDEPUBRuntimeChapter, Error>?
let semaphore = DispatchSemaphore(value: 0)
let registration = registerPendingLoad(
spineIndex: spineIndex,
priority: .navigation
) { pendingResult in
result = pendingResult
semaphore.signal()
}
if case .joined(let existingPriority, let effectivePriority) = registration {
semaphore.wait()
return try result!.get()
}
}
store.assertNotOnChapterLoadQueue()
let snapshot = layoutSnapshot ?? context.makeLayoutSnapshot()
var result: Result<RDEPUBRuntimeChapter, Error>?
let semaphore = DispatchSemaphore(value: 0)
store.chapterLoadQueue.async {
do {
let chapter: RDEPUBRuntimeChapter = try autoreleasepool {
let cacheKey = self.makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: snapshot)
let precomputedPageRanges = store.pageCount(for: cacheKey)?.pageRanges
let diskSummary: RDEPUBChapterSummary?
if precomputedPageRanges == nil {
diskSummary = self.summaryDiskCache?.read(for: cacheKey)
} else {
diskSummary = nil
}
let chapter = try self.buildChapter(
spineIndex: spineIndex,
availablePageRanges: precomputedPageRanges ?? diskSummary?.pageRanges.map(\.nsRange),
diskSummary: diskSummary,
context: context,
layoutSnapshot: snapshot
)
store.insertChapter(chapter)
let pageCount = RDEPUBRuntimePageCount(
cacheKey: cacheKey,
spineIndex: spineIndex,
pageRanges: chapter.pageRanges,
pageCount: chapter.pages.count,
renderSignature: cacheKey.renderSignature
)
store.insertPageCount(pageCount, for: cacheKey)
self.scheduleDeferredCFIMapBuildIfNeeded(
for: chapter,
cacheKey: cacheKey,
store: store
)
return chapter
}
result = .success(chapter)
} catch {
result = .failure(error)
}
semaphore.signal()
}
semaphore.wait()
return try result!.get()
}
private func registerPendingLoad(
spineIndex: Int,
priority: LoadPriority,
completion: @escaping LoadCompletion
) -> LoadRegistrationResult {
pendingLoadsLock.lock()
defer { pendingLoadsLock.unlock() }
if var pending = pendingLoads[spineIndex] {
let existingPriority = pending.priority
pending.priority = LoadPriority.higherPriority(existingPriority, priority)
pending.completions.append(completion)
pendingLoads[spineIndex] = pending
return .joined(existingPriority: existingPriority, effectivePriority: pending.priority)
}
pendingLoads[spineIndex] = PendingLoad(priority: priority, completions: [completion])
return .created
}
private func pendingPriority(for spineIndex: Int) -> LoadPriority? {
pendingLoadsLock.lock()
let priority = pendingLoads[spineIndex]?.priority
pendingLoadsLock.unlock()
return priority
}
private func resolvePendingLoad(spineIndex: Int, result: Result<RDEPUBRuntimeChapter, Error>) {
pendingLoadsLock.lock()
let completions = pendingLoads.removeValue(forKey: spineIndex)?.completions ?? []
pendingLoadsLock.unlock()
guard !completions.isEmpty else { return }
completions.forEach { $0(result) }
}
private func buildChapter(
spineIndex: Int,
availablePageRanges: [NSRange]?,
diskSummary: RDEPUBChapterSummary? = nil,
context: RDEPUBReaderContext,
layoutSnapshot: RDEPUBLayoutSnapshot? = nil
) throws -> RDEPUBRuntimeChapter {
guard let parser = context.parser,
let publication = context.publication else {
throw RDEPUBChapterLoadError.missingParser
}
let pageSize: CGSize
let style: RDEPUBTextRenderStyle
let layoutConfig: RDEPUBTextLayoutConfig
if let snapshot = layoutSnapshot {
pageSize = snapshot.pageSize
style = snapshot.style
layoutConfig = snapshot.layoutConfig
} else {
assert(Thread.isMainThread, "buildChapter() requires a layoutSnapshot when called off the main thread. Capture a snapshot via makeLayoutSnapshot() before dispatching to a background queue.")
pageSize = context.currentTextPageSize()
style = context.currentTextRenderStyle()
layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
}
if let pageRanges = availablePageRanges {
return try buildChapterFromCachedPageRanges(
spineIndex: spineIndex,
pageRanges: pageRanges,
parser: parser,
publication: publication,
pageSize: pageSize,
style: style,
layoutConfig: layoutConfig,
diskSummary: diskSummary,
context: context
)
}
let builder = context.makeTextBookBuilder(layoutConfig: layoutConfig)
guard let result = try builder.buildChapter(
parser: parser,
publication: publication,
spineIndex: spineIndex,
pageSize: pageSize,
style: style
) else {
throw RDEPUBChapterLoadError.emptyChapter(spineIndex: spineIndex)
}
return try assembleRuntimeChapter(
from: result.chapter,
spineIndex: spineIndex,
pageSize: pageSize,
layoutConfig: layoutConfig,
context: context,
layoutSnapshot: layoutSnapshot
)
}
private func buildChapterFromCachedPageRanges(
spineIndex: Int,
pageRanges: [NSRange],
parser: RDEPUBParser,
publication: RDEPUBPublication,
pageSize: CGSize,
style: RDEPUBTextRenderStyle,
layoutConfig: RDEPUBTextLayoutConfig,
diskSummary: RDEPUBChapterSummary? = nil,
context: RDEPUBReaderContext
) throws -> RDEPUBRuntimeChapter {
let spineItem = publication.spine[spineIndex]
let href = spineItem.href
let title = spineItem.title
let baseURL = parser.fileURL(forRelativePath: href)?.deletingLastPathComponent()
let rawHTML = try requireHTMLString(parser, href: href)
let request = RDEPUBTextRendererSupport.makeChapterRenderRequest(
href: href,
title: title,
rawHTML: rawHTML,
baseURL: baseURL,
style: style,
resourceResolver: publication.resourceResolver,
pageSize: pageSize,
layoutConfig: layoutConfig
)
let renderer = context.resolvedTextRenderer()
let rendered = try renderer.renderChapter(request: request)
let typesetString = NSMutableAttributedString(attributedString: rendered.attributedString)
RDEPUBTextRendererSupport.normalizeReadingAttributes(
in: typesetString, style: style, layoutConfig: layoutConfig
)
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 {
effectivePageRanges = typesetString.rd_paginatedFrames(size: pageSize, config: layoutConfig).map(\.contentRange)
metadataSource = nil
}
let pages = buildPagesFromRanges(
pageRanges: effectivePageRanges,
typesetString: typesetString,
spineIndex: spineIndex,
href: href,
title: title,
metadataSource: metadataSource
)
let layouter = RDEPUBTextLayouter(
attributedString: typesetString,
pageSize: pageSize,
config: layoutConfig
)
let offsetMap = RDEPUBChapterOffsetMap(
fragmentOffsets: rendered.fragmentOffsets,
pageStartOffsets: pages.map { $0.pageStartOffset },
pageEndOffsets: pages.map { $0.pageEndOffset },
cfiMap: diskSummary?.cfiMap,
chapterText: typesetString.string
)
return RDEPUBRuntimeChapter(
spineIndex: spineIndex,
href: href,
title: title,
sourceAttributedString: nil,
typesetAttributedString: typesetString,
layouter: layouter,
pageRanges: effectivePageRanges,
pages: pages,
chapterOffsetMap: offsetMap
)
}
private func buildPagesFromRanges(
pageRanges: [NSRange],
typesetString: NSAttributedString,
spineIndex: Int,
href: String,
title: String,
metadataSource: [RDEPUBChapterSummary.PageMetadataSummary]? = nil
) -> [RDEPUBTextPage] {
let totalPageCount = pageRanges.count
return pageRanges.enumerated().map { (pageIndex, range) in
let pageContent = typesetString.attributedSubstring(from: range)
let metadata: RDEPUBTextPageMetadata
if let metaList = metadataSource, pageIndex < metaList.count {
metadata = metaList[pageIndex].toPageMetadata()
} else {
metadata = inferPageMetadata(
from: typesetString,
range: range,
isLastPage: pageIndex == totalPageCount - 1
)
}
return RDEPUBTextPage(
absolutePageIndex: -1,
chapterIndex: 0,
spineIndex: spineIndex,
href: href,
chapterTitle: title,
pageIndexInChapter: pageIndex,
totalPagesInChapter: totalPageCount,
chapterContent: typesetString,
content: pageContent,
contentRange: range,
pageStartOffset: range.location,
pageEndOffset: range.location + range.length - 1,
metadata: 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,
range: NSRange,
isLastPage: Bool
) -> RDEPUBTextPageMetadata {
var attachmentRanges: [NSRange] = []
var attachmentKinds: [RDEPUBTextAttachmentKind] = []
var blockKinds: [RDEPUBTextBlockKind] = []
var semanticHints: [RDEPUBTextSemanticHint] = []
var attachmentPlacements: [RDEPUBTextAttachmentPlacement] = []
var trailingFragmentID: String? = nil
string.enumerateAttribute(.rdPageAttachmentKind, in: range, options: []) { value, attrRange, _ in
if let rawValue = value as? String,
let kind = RDEPUBTextAttachmentKind(rawValue: rawValue) {
attachmentRanges.append(attrRange)
attachmentKinds.append(kind)
}
}
string.enumerateAttribute(.rdPageBlockKind, in: range, options: []) { value, _, _ in
if let rawValue = value as? String,
let kind = RDEPUBTextBlockKind(rawValue: rawValue),
!blockKinds.contains(kind) {
blockKinds.append(kind)
}
}
string.enumerateAttribute(.rdPageSemanticHints, in: range, options: []) { value, _, _ in
if let rawValue = value as? String {
let hints = rawValue
.split(separator: ",")
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
for hint in hints where !semanticHints.contains(hint) {
semanticHints.append(hint)
}
}
}
string.enumerateAttribute(.rdPageAttachmentPlacement, in: range, options: []) { value, _, _ in
if let rawValue = value as? String,
let placement = RDEPUBTextAttachmentPlacement(rawValue: rawValue),
!attachmentPlacements.contains(placement) {
attachmentPlacements.append(placement)
}
}
string.enumerateAttribute(.rdPageFragmentID, in: range, options: [.reverse]) { value, _, stop in
if let fid = value as? String {
trailingFragmentID = fid
stop.pointee = true
}
}
return RDEPUBTextPageMetadata(
breakReason: isLastPage ? .chapterEnd : .frameLimit,
blockRange: nil,
attachmentRanges: attachmentRanges,
attachmentKinds: attachmentKinds,
blockKinds: blockKinds,
semanticHints: semanticHints,
attachmentPlacements: attachmentPlacements,
trailingFragmentID: trailingFragmentID,
diagnostics: []
)
}
private func assembleRuntimeChapter(
from chapter: RDEPUBTextChapter,
spineIndex: Int,
pageSize: CGSize,
layoutConfig: RDEPUBTextLayoutConfig,
context: RDEPUBReaderContext,
layoutSnapshot: RDEPUBLayoutSnapshot? = nil
) throws -> RDEPUBRuntimeChapter {
let layouter = RDEPUBTextLayouter(
attributedString: chapter.attributedContent,
pageSize: pageSize,
config: layoutConfig
)
let offsetMap = RDEPUBChapterOffsetMap(
fragmentOffsets: chapter.fragmentOffsets,
pageStartOffsets: chapter.pages.map { $0.pageStartOffset },
pageEndOffsets: chapter.pages.map { $0.pageEndOffset },
cfiMap: chapter.cfiMap,
chapterText: chapter.attributedContent.string
)
let pageRanges = chapter.pages.map { $0.contentRange }
let cacheKey = makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: layoutSnapshot)
summaryDiskCache?.write(summary: makeSummary(for: chapter.pages, fragmentOffsets: chapter.fragmentOffsets, offsetMap: offsetMap, cacheKey: cacheKey), for: cacheKey)
return RDEPUBRuntimeChapter(
spineIndex: spineIndex,
href: chapter.href,
title: chapter.title,
sourceAttributedString: nil,
typesetAttributedString: chapter.attributedContent,
layouter: layouter,
pageRanges: pageRanges,
pages: chapter.pages,
chapterOffsetMap: offsetMap
)
}
private func makeCacheKey(spineIndex: Int, context: RDEPUBReaderContext, layoutSnapshot: RDEPUBLayoutSnapshot? = nil) -> RDEPUBChapterCacheKey {
let style: RDEPUBTextRenderStyle
let layoutConfig: RDEPUBTextLayoutConfig
let lineHeightMultiple: CGFloat
if let snapshot = layoutSnapshot {
style = snapshot.style
layoutConfig = snapshot.layoutConfig
lineHeightMultiple = context.configuration.lineHeightMultiple
} else {
assert(Thread.isMainThread, "makeCacheKey() requires a layoutSnapshot when called off the main thread. Capture a snapshot via makeLayoutSnapshot() before dispatching to a background queue.")
let pageSize = context.currentTextPageSize()
style = context.currentTextRenderStyle()
layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
lineHeightMultiple = context.configuration.lineHeightMultiple
}
let renderSignature = [
style.font.fontName,
"\(style.font.pointSize)",
"\(lineHeightMultiple)",
"\(style.lineSpacing)",
layoutConfig.cacheSignature,
"\(RDEPUBChapterSummary.currentSchemaVersion)"
].joined(separator: "|")
let contentHash = contentHashForSpineIndex(spineIndex, context: context)
return RDEPUBChapterCacheKey(
bookID: context.currentBookIdentifier ?? "",
spineIndex: spineIndex,
renderSignature: renderSignature,
chapterContentHash: contentHash
)
}
private func scheduleDeferredCFIMapBuildIfNeeded(
for chapter: RDEPUBRuntimeChapter,
cacheKey: RDEPUBChapterCacheKey,
store: RDEPUBChapterRuntimeStore
) {
guard chapter.chapterOffsetMap.cfiMap == nil,
store.beginBuildingCFIMap(for: chapter.spineIndex) else {
return
}
let spineIndex = chapter.spineIndex
let href = chapter.href
let fragmentOffsets = chapter.chapterOffsetMap.fragmentOffsets
let chapterText = chapter.chapterOffsetMap.chapterText
store.chapterLoadQueue.async {
defer { store.endBuildingCFIMap(for: spineIndex) }
guard let rawHTML = self.context?.parser?.htmlString(forRelativePath: href),
let chapterText else {
return
}
let cfiMap = self.makeCFIMap(
href: href,
spineIndex: spineIndex,
fragmentOffsets: fragmentOffsets,
rawHTML: rawHTML,
chapterText: chapterText
)
chapter.updateCFIMap(cfiMap)
self.summaryDiskCache?.write(
summary: self.makeSummary(
for: chapter.pages,
fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets,
offsetMap: chapter.chapterOffsetMap,
cacheKey: cacheKey
),
for: cacheKey
)
DispatchQueue.main.async {
self.onDeferredCFIMapReady?(spineIndex)
}
}
}
private func makeSummary(
for pages: [RDEPUBTextPage],
fragmentOffsets: [String: Int],
offsetMap: RDEPUBChapterOffsetMap,
cacheKey: RDEPUBChapterCacheKey
) -> RDEPUBChapterSummary {
RDEPUBChapterSummary(
pageRanges: pages.map { .init(location: $0.contentRange.location, length: $0.contentRange.length) },
pageCount: pages.count,
fragmentOffsets: fragmentOffsets,
cfiMap: offsetMap.cfiMap,
renderSignature: cacheKey.renderSignature,
schemaVersion: RDEPUBChapterSummary.currentSchemaVersion,
chapterContentHash: cacheKey.chapterContentHash,
pageMetadataList: pages.map { .from($0.metadata) }
)
}
private func contentHashForSpineIndex(_ spineIndex: Int, context: RDEPUBReaderContext) -> String {
guard let parser = context.parser,
let publication = context.publication else { return "" }
let href = publication.spine[spineIndex].href
guard let html = parser.htmlString(forRelativePath: href) else { return "" }
return html.rd_sha256Hex
}
private func requireHTMLString(_ parser: RDEPUBParser, href: String) throws -> String {
guard let html = parser.htmlString(forRelativePath: href) else {
throw RDEPUBChapterLoadError.emptyChapterHref(href)
}
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).rd_sha256Hex,
fragmentPathMap: domPaths
)
)
}
}
private extension RDEPUBChapterLoader.LoadPriority {
static func higherPriority(_ lhs: Self, _ rhs: Self) -> Self {
if lhs.rank >= rhs.rank {
return lhs
}
return rhs
}
var rank: Int {
switch self {
case .prefetch:
return 0
case .preview:
return 1
case .navigation:
return 2
}
}
}
enum RDEPUBChapterLoadError: LocalizedError {
case missingParser
case emptyChapter(spineIndex: Int)
case emptyChapterHref(String)
var errorDescription: String? {
switch self {
case .missingParser:
return "章节加载失败:缺少解析上下文。"
case .emptyChapter(let spineIndex):
return "章节加载失败:第 \(spineIndex) 章无法生成分页内容。"
case .emptyChapterHref(let href):
return "章节加载失败:未找到章节资源 \(href)"
}
}
}