535 lines
22 KiB
Swift
535 lines
22 KiB
Swift
import Foundation
|
|
|
|
final class RDEPUBChapterLoader {
|
|
private unowned let context: RDEPUBReaderContext
|
|
private var summaryDiskCache: RDEPUBChapterSummaryDiskCache?
|
|
|
|
init(context: RDEPUBReaderContext) {
|
|
self.context = context
|
|
}
|
|
|
|
func setSummaryDiskCache(_ cache: RDEPUBChapterSummaryDiskCache) {
|
|
summaryDiskCache = cache
|
|
}
|
|
|
|
// MARK: - 请求优先级
|
|
|
|
enum LoadPriority {
|
|
case navigation // 前台导航:用户主动跳章,完成后检查导航目标队列
|
|
case prefetch // 后台预取:±1 相邻章,完成后仅回填缓存 + 刷新快照
|
|
}
|
|
|
|
// 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 {
|
|
completion(.success(cached))
|
|
}
|
|
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 {
|
|
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)")
|
|
|
|
// 5. 回填缓存
|
|
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)
|
|
|
|
// 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
|
|
}
|
|
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))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - 同步加载入口(仅供 legacy 位置迁移使用)
|
|
|
|
/// 约束:
|
|
/// - 必须复用同一个 chapterLoadQueue,保持 WXRead 的单章串行语义
|
|
/// - 不允许恢复整书 RDEPUBTextBook
|
|
/// - 只允许从主线程或明确的非 chapterLoadQueue 上下文调用
|
|
/// - 调用前必须执行 store.assertNotOnChapterLoadQueue()
|
|
/// - 只在首次迁移且目标章未命中缓存时使用
|
|
func loadChapterSynchronouslyForMigration(
|
|
spineIndex: Int,
|
|
store: RDEPUBChapterRuntimeStore?
|
|
) throws -> RDEPUBRuntimeChapter {
|
|
if let cached = store?.chapterData(for: spineIndex) {
|
|
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)
|
|
return .success(chapter)
|
|
}
|
|
}
|
|
} catch {
|
|
result = .failure(error)
|
|
}
|
|
semaphore.signal()
|
|
}
|
|
semaphore.wait()
|
|
return try result!.get()
|
|
}
|
|
|
|
// MARK: - 单章构建(支持轻量缓存命中后跳过分页)
|
|
|
|
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 {
|
|
// ---- 轻量路径:pageCountCache 或 chapterSummaryDiskCache 命中 ----
|
|
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
|
|
)
|
|
}
|
|
|
|
// MARK: - 轻量路径:复用已有 pageRanges,跳过完整分页
|
|
|
|
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()
|
|
|
|
// 1. 只做 HTML → NSAttributedString 渲染,不做分页
|
|
let request = RDEPUBTextRendererSupport.makeChapterRenderRequest(
|
|
href: href,
|
|
title: title,
|
|
rawHTML: try requireHTMLString(parser, href: href),
|
|
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
|
|
)
|
|
|
|
// 2. metadata 来源策略:
|
|
// - diskSummary 非空(磁盘路径命中):从摘要恢复完整 metadata
|
|
// - diskSummary 为空(pageCountCache 命中但没走磁盘):从 attributedString 属性推断
|
|
let metadataSource = diskSummary?.pageMetadataList
|
|
|
|
// 3. 直接用缓存的 pageRanges 构建 pages(跳过 CoreText 分页)
|
|
let pages = buildPagesFromRanges(
|
|
pageRanges: pageRanges,
|
|
typesetString: typesetString,
|
|
spineIndex: spineIndex,
|
|
href: href,
|
|
title: title,
|
|
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 }
|
|
)
|
|
|
|
return RDEPUBRuntimeChapter(
|
|
spineIndex: spineIndex,
|
|
href: href,
|
|
title: title,
|
|
sourceAttributedString: nil, // 轻量路径不保留原始 source,降低内存
|
|
typesetAttributedString: typesetString,
|
|
layouter: layouter,
|
|
pageRanges: pageRanges,
|
|
pages: pages,
|
|
chapterOffsetMap: offsetMap
|
|
)
|
|
}
|
|
|
|
// MARK: - 从缓存的 pageRanges 直接构建 RDEPUBTextPage 数组
|
|
|
|
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
|
|
metadata = metaList[pageIndex].toPageMetadata()
|
|
} else {
|
|
// 无缓存 metadata,从 attributedString 属性推断
|
|
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
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - 从 attributedString 的自定义属性推断页 metadata
|
|
|
|
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: []
|
|
)
|
|
}
|
|
|
|
// MARK: - 从完整构建结果组装 RDEPUBRuntimeChapter
|
|
|
|
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 }
|
|
)
|
|
|
|
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,
|
|
renderSignature: cacheKey.renderSignature,
|
|
schemaVersion: RDEPUBChapterSummary.currentSchemaVersion,
|
|
chapterContentHash: cacheKey.chapterContentHash,
|
|
pageMetadataList: chapter.pages.map { .from($0.metadata) }
|
|
)
|
|
summaryDiskCache?.write(summary: summary, 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
|
|
)
|
|
}
|
|
|
|
// 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 = [
|
|
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 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
|
|
}
|
|
}
|
|
|
|
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)。"
|
|
}
|
|
}
|
|
}
|