Phase 1: Context 拆分 - 新增 RDEPUBReaderState/RDEPUBReaderEnvironment/RDEPUBReaderServices - RDEPUBReaderContext 改为过渡门面,代理到 State/Environment/Services Phase 2: Runtime 拆分 - 新增 RDEPUBPresentationRuntime 处理分页状态管理 - 新增 RDEPUBChapterWarmupOrchestrator 处理章节预热与加载编排 - RDEPUBReaderRuntime 从 1277 行收缩,公共 API 转发到新 facade Phase 0.5: 性能优化 - prepareOnDemandChapter 支持异步模式(allowSynchronousLoad: false) - extendPartialBookPageMapIfNeeded 改为 DispatchGroup 并发加载 - RDEPUBChapterOffsetMap.cfiMap 加 NSLock 保护数据竞争 - CFI Map 构建延迟到后台队列(scheduleDeferredCFIMapBuildIfNeeded) - RDEPUBTextPageRenderView 引入静态位图缓存 - RDEPUBTextContentView 新增 loadingSpinner 占位页 Phase 3: 状态机 - 新增 RDEPUBNavigationStateMachine(含 DEBUG 合法转换校验) - 新增 RDEPUBPaginationState 记录分页来源 Review 修复 - makeSummary 重复方法合并 - ensureNavigationTargetAvailable 同步路径加注释标记 UI 测试 - 新增 AsyncChapterLoadingTests(20 个测试,覆盖全部架构整改场景) - 跨章节翻页、延迟 CFI、状态机、内存警告、预加载、位置恢复等
667 lines
25 KiB
Swift
667 lines
25 KiB
Swift
import Foundation
|
|
|
|
final class RDEPUBChapterLoader {
|
|
|
|
private unowned let context: RDEPUBReaderContext
|
|
|
|
private var summaryDiskCache: RDEPUBChapterSummaryDiskCache?
|
|
|
|
var onDeferredCFIMapReady: ((Int) -> Void)?
|
|
|
|
init(context: RDEPUBReaderContext) {
|
|
self.context = context
|
|
}
|
|
|
|
func setSummaryDiskCache(_ cache: RDEPUBChapterSummaryDiskCache) {
|
|
summaryDiskCache = cache
|
|
}
|
|
|
|
enum LoadPriority {
|
|
|
|
case navigation
|
|
|
|
case preview
|
|
|
|
case prefetch
|
|
}
|
|
|
|
func loadChapter(
|
|
spineIndex: Int,
|
|
store: RDEPUBChapterRuntimeStore,
|
|
priority: LoadPriority = .navigation,
|
|
completion: @escaping (Result<RDEPUBRuntimeChapter, Error>) -> Void
|
|
) {
|
|
|
|
if let cached = store.chapterData(for: spineIndex) {
|
|
RDEPUBBackgroundTrace.log("ChapterLoader", "cache hit spine=\(spineIndex) priority=\(priority)")
|
|
scheduleDeferredCFIMapBuildIfNeeded(
|
|
for: cached,
|
|
cacheKey: makeCacheKey(spineIndex: spineIndex),
|
|
store: store
|
|
)
|
|
DispatchQueue.main.async {
|
|
completion(.success(cached))
|
|
}
|
|
return
|
|
}
|
|
|
|
store.markBuilding(true)
|
|
|
|
store.chapterLoadQueue.async {
|
|
RDEPUBBackgroundTrace.log("ChapterLoader", "queue start spine=\(spineIndex) priority=\(priority)")
|
|
let cacheKey = self.makeCacheKey(spineIndex: spineIndex)
|
|
|
|
let precomputedPageRanges = store.pageCount(for: cacheKey)?.pageRanges
|
|
let diskSummary: RDEPUBChapterSummary?
|
|
if precomputedPageRanges == nil {
|
|
diskSummary = self.summaryDiskCache?.read(for: cacheKey)
|
|
if diskSummary != nil {
|
|
RDEPUBBackgroundTrace.log("ChapterLoader", "磁盘摘要缓存命中 spine=\(spineIndex)")
|
|
} else {
|
|
RDEPUBBackgroundTrace.log("ChapterLoader", "缓存未命中 spine=\(spineIndex)")
|
|
}
|
|
} else {
|
|
diskSummary = nil
|
|
RDEPUBBackgroundTrace.log("ChapterLoader", "页数缓存命中 spine=\(spineIndex)")
|
|
}
|
|
let diskPageRanges = diskSummary?.pageRanges.map { $0.nsRange }
|
|
let availablePageRanges = precomputedPageRanges ?? diskPageRanges
|
|
|
|
do {
|
|
|
|
let chapter = try RDEPUBBackgroundTrace.measure(
|
|
"ChapterLoader",
|
|
"buildChapter spine=\(spineIndex) priority=\(priority) cachedRanges=\(availablePageRanges?.count ?? 0)"
|
|
) {
|
|
try self.buildChapter(
|
|
spineIndex: spineIndex,
|
|
availablePageRanges: availablePageRanges,
|
|
diskSummary: diskSummary
|
|
)
|
|
}
|
|
RDEPUBBackgroundTrace.log("ChapterLoader", "buildChapter OK: spine=\(spineIndex) pages=\(chapter.pages.count)")
|
|
|
|
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
|
|
)
|
|
|
|
switch priority {
|
|
case .navigation:
|
|
|
|
let nextTarget = store.consumeNavigationTarget()
|
|
if let target = nextTarget, target != spineIndex {
|
|
|
|
store.markBuilding(false)
|
|
self.loadChapter(spineIndex: target, store: store, priority: .navigation, completion: completion)
|
|
return
|
|
}
|
|
store.markBuilding(false)
|
|
DispatchQueue.main.async {
|
|
completion(.success(chapter))
|
|
}
|
|
|
|
case .preview:
|
|
store.markBuilding(false)
|
|
DispatchQueue.main.async {
|
|
completion(.success(chapter))
|
|
}
|
|
|
|
case .prefetch:
|
|
|
|
store.removePrefetchTarget(spineIndex)
|
|
store.markBuilding(false)
|
|
DispatchQueue.main.async {
|
|
completion(.success(chapter))
|
|
}
|
|
}
|
|
} catch {
|
|
|
|
RDEPUBBackgroundTrace.log("ChapterLoader", "buildChapter FAILED: spine=\(spineIndex) error=\(error)")
|
|
store.markBuilding(false)
|
|
DispatchQueue.main.async {
|
|
completion(.failure(error))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func loadChapterSynchronouslyForMigration(
|
|
spineIndex: Int,
|
|
store: RDEPUBChapterRuntimeStore?
|
|
) throws -> RDEPUBRuntimeChapter {
|
|
if let cached = store?.chapterData(for: spineIndex) {
|
|
if let store {
|
|
scheduleDeferredCFIMapBuildIfNeeded(
|
|
for: cached,
|
|
cacheKey: makeCacheKey(spineIndex: spineIndex),
|
|
store: store
|
|
)
|
|
}
|
|
return cached
|
|
}
|
|
|
|
guard let store else {
|
|
throw RDEPUBChapterLoadError.missingParser
|
|
}
|
|
|
|
store.assertNotOnChapterLoadQueue()
|
|
|
|
var result: Result<RDEPUBRuntimeChapter, Error>?
|
|
let semaphore = DispatchSemaphore(value: 0)
|
|
RDEPUBBackgroundTrace.log("ChapterLoader", "sync request spine=\(spineIndex)")
|
|
store.chapterLoadQueue.async {
|
|
do {
|
|
result = try RDEPUBBackgroundTrace.measure(
|
|
"ChapterLoader",
|
|
"sync buildChapter spine=\(spineIndex)"
|
|
) {
|
|
try autoreleasepool { () -> Result<RDEPUBRuntimeChapter, Error> in
|
|
let cacheKey = self.makeCacheKey(spineIndex: spineIndex)
|
|
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
|
|
)
|
|
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 .success(chapter)
|
|
}
|
|
}
|
|
} catch {
|
|
result = .failure(error)
|
|
}
|
|
semaphore.signal()
|
|
}
|
|
semaphore.wait()
|
|
return try result!.get()
|
|
}
|
|
|
|
private func buildChapter(
|
|
spineIndex: Int,
|
|
availablePageRanges: [NSRange]?,
|
|
diskSummary: RDEPUBChapterSummary? = nil
|
|
) throws -> RDEPUBRuntimeChapter {
|
|
guard let parser = context.parser,
|
|
let publication = context.publication else {
|
|
throw RDEPUBChapterLoadError.missingParser
|
|
}
|
|
|
|
let pageSize = context.currentTextPageSize()
|
|
let style = context.currentTextRenderStyle()
|
|
let layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
|
|
|
|
if let pageRanges = availablePageRanges {
|
|
|
|
RDEPUBBackgroundTrace.log("ChapterLoader", "轻量路径 spine=\(spineIndex) 缓存页数=\(pageRanges.count)")
|
|
return try buildChapterFromCachedPageRanges(
|
|
spineIndex: spineIndex,
|
|
pageRanges: pageRanges,
|
|
parser: parser,
|
|
publication: publication,
|
|
pageSize: pageSize,
|
|
style: style,
|
|
layoutConfig: layoutConfig,
|
|
diskSummary: diskSummary
|
|
)
|
|
}
|
|
|
|
RDEPUBBackgroundTrace.log("ChapterLoader", "完整路径 spine=\(spineIndex)")
|
|
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
|
|
)
|
|
}
|
|
|
|
private func buildChapterFromCachedPageRanges(
|
|
spineIndex: Int,
|
|
pageRanges: [NSRange],
|
|
parser: RDEPUBParser,
|
|
publication: RDEPUBPublication,
|
|
pageSize: CGSize,
|
|
style: RDEPUBTextRenderStyle,
|
|
layoutConfig: RDEPUBTextLayoutConfig,
|
|
diskSummary: RDEPUBChapterSummary? = nil
|
|
) 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 {
|
|
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
|
|
}
|
|
|
|
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
|
|
) 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)
|
|
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) -> RDEPUBChapterCacheKey {
|
|
let style = context.currentTextRenderStyle()
|
|
let layoutConfig = context.currentTextLayoutConfig(pageSize: context.currentTextPageSize())
|
|
|
|
let 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)
|
|
|
|
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) -> 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.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).sha256Hex,
|
|
fragmentPathMap: domPaths
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
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)。"
|
|
}
|
|
}
|
|
}
|