- 拆分 ContentDelegates/TextContentView 为独立协调器(InteractionCoordinator、LocationResolution、ExternalLinks、AttachmentTooltip) - 新增 RDEPUBAttachmentTooltipView/OverlayView 附件气泡提示 - 新增 RDEPUBDarkImageAdjuster 暗色模式图片亮度适配 - 新增 RDEPUBSelectionLoupeView 选区放大镜 - 新增 MetadataParseWorker/CancellationController 元数据解析取消机制 - 重构 PresentationRuntime/PaginationCoordinator 精简职责 - 优化 ChapterLoader/WarmupOrchestrator 异步章节加载 - CFI 模块微调与 NoteModels 更新 - 清理冗余文档,更新架构/UML/业务逻辑文档 Co-Authored-By: Claude <noreply@anthropic.com>
802 lines
30 KiB
Swift
802 lines
30 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
|
|
) {
|
|
RDEPUBBackgroundTrace.log("ChapterLoader", "request spine=\(spineIndex) priority=\(priority)")
|
|
|
|
if let cached = store.chapterData(for: spineIndex) {
|
|
RDEPUBBackgroundTrace.log("ChapterLoader", "cache hit spine=\(spineIndex) priority=\(priority)")
|
|
if let context {
|
|
scheduleDeferredCFIMapBuildIfNeeded(
|
|
for: cached,
|
|
cacheKey: makeCacheKey(spineIndex: spineIndex, context: context),
|
|
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(
|
|
"ChapterLoader",
|
|
"dedupe spine=\(spineIndex) existingPriority=\(existingPriority) requestedPriority=\(priority) effectivePriority=\(effectivePriority)"
|
|
)
|
|
return
|
|
case .created:
|
|
break
|
|
}
|
|
|
|
store.markBuilding(true)
|
|
_ = store.beginPendingChapterLoad(for: spineIndex)
|
|
|
|
store.chapterLoadQueue.async { [self] in
|
|
guard 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
|
|
RDEPUBBackgroundTrace.log("ChapterLoader", "queue start spine=\(spineIndex) priority=\(queuePriority)")
|
|
let cacheKey = self.makeCacheKey(spineIndex: spineIndex, context: context)
|
|
|
|
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=\(queuePriority) cachedRanges=\(availablePageRanges?.count ?? 0)"
|
|
) {
|
|
try self.buildChapter(
|
|
spineIndex: spineIndex,
|
|
availablePageRanges: availablePageRanges,
|
|
diskSummary: diskSummary,
|
|
context: context
|
|
)
|
|
}
|
|
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
|
|
)
|
|
|
|
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.loadChapter(spineIndex: target, store: store, priority: .navigation, 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("ChapterLoader", "buildChapter FAILED: spine=\(spineIndex) error=\(error)")
|
|
store.endPendingChapterLoad(for: spineIndex)
|
|
store.markBuilding(false)
|
|
self.resolvePendingLoad(spineIndex: spineIndex, result: .failure(error))
|
|
}
|
|
}
|
|
}
|
|
|
|
func loadChapterSynchronouslyForMigration(
|
|
spineIndex: Int,
|
|
store: RDEPUBChapterRuntimeStore?
|
|
) 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),
|
|
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 {
|
|
RDEPUBBackgroundTrace.log(
|
|
"ChapterLoader",
|
|
"sync join spine=\(spineIndex) existingPriority=\(existingPriority) effectivePriority=\(effectivePriority)"
|
|
)
|
|
semaphore.wait()
|
|
return try result!.get()
|
|
}
|
|
}
|
|
|
|
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, context: context)
|
|
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
|
|
)
|
|
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 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
|
|
) 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,
|
|
context: context
|
|
)
|
|
}
|
|
|
|
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,
|
|
context: context
|
|
)
|
|
}
|
|
|
|
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 {
|
|
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,
|
|
context: RDEPUBReaderContext
|
|
) 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)
|
|
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) -> 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, 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)。"
|
|
}
|
|
}
|
|
}
|