1. Configurable chapter window size (onDemandChapterWindowSize: 3-15) - Parameterized window radius in RDEPUBChapterRuntimeStore - Updated RDEPUBChapterWindowCoordinator to use configurable radius - RDEPUBChapterWindowSnapshot.from() accepts chapter array instead of fixed prev/next - Even numbers round up to odd (4→5), min 3, max 15 2. Configurable metadata parsing concurrency (metadataParsingConcurrency) - Default equals CPU core count - Parallel execution via OperationQueue in paginateMetadataOnly - Each worker creates independent builder instance - NSLock protects result aggregation 3. Per-chapter and total wall-clock timing instrumentation - Separated render vs I/O timing per chapter - Summary log with wallClockMs, renderTotalMs, writeTotalMs, avgRenderMs - Timing stored in RDEPUBReaderContext for test access 4. UI automation test infrastructure - Added --demo-window-size, --demo-concurrency, --demo-clear-cache launch args - DemoReaderState exposes windowSize, parseMs, parseConcurrency - ConfigurableWindowTests: 5 test cases for window size 3/5/15 - ConcurrentParsingTests: 4 test cases for concurrency 2/4 - MetadataParseBenchmarkTests: serial vs parallel benchmark 5. Bug fixes - Fixed page snap-back during background parsing (isUserInteracting check) - Reduced BookPageMap refresh frequency from 16 to 32 chapters - Moved waitForReadingInteractionToSettle outside operation loop 6. Design doc: dual-layer PageMap (estimated + precise mixed)
608 lines
27 KiB
Swift
608 lines
27 KiB
Swift
import Foundation
|
||
|
||
/// EPUB 阅读器分页协调器:负责出版物的分页计算和页面数据更新。
|
||
///
|
||
/// 职责:
|
||
/// - 根据出版物类型(文本重排/Fixed Layout/Web 内容)选择分页策略
|
||
/// - 文本大书优先恢复分页摘要并切换到按需加载
|
||
/// - 重新分页时保持当前阅读位置
|
||
/// - 刷新可见内容并保持位置
|
||
/// - 重建外部纯文本图书
|
||
final class RDEPUBReaderPaginationCoordinator {
|
||
private let backgroundInteractionCooldown: CFAbsoluteTime = 0.8
|
||
|
||
private unowned let context: RDEPUBReaderContext
|
||
|
||
init(context: RDEPUBReaderContext) {
|
||
self.context = context
|
||
}
|
||
|
||
/// 对出版物执行分页:文本重排优先走摘要恢复/按需加载,Fixed Layout 直接生成快照,Web 内容走 Paginator。
|
||
func paginatePublication(restoreLocation: RDEPUBLocation?) {
|
||
guard let controller = context.controller,
|
||
let parser = context.parser,
|
||
let publication = context.publication,
|
||
let readingSession = context.readingSession else {
|
||
return
|
||
}
|
||
|
||
controller.isRepaginating = true
|
||
controller.errorLabel.isHidden = true
|
||
controller.showLoading()
|
||
let token = UUID()
|
||
context.paginationToken = token
|
||
print("[EPUB][Pagination] profile=\(publication.readingProfile.rawValue) layout=\(publication.layout.rawValue) spine=\(publication.spine.count)")
|
||
|
||
if publication.readingProfile == .textReflowable {
|
||
print("[EPUB][Pagination] path=text-reflowable-on-demand")
|
||
paginateTextPublication(
|
||
parser: parser,
|
||
publication: publication,
|
||
readingSession: readingSession,
|
||
restoreLocation: restoreLocation,
|
||
token: token
|
||
)
|
||
return
|
||
}
|
||
|
||
if publication.layout == .fixed {
|
||
print("[EPUB][Pagination] path=fixed-layout")
|
||
let snapshot = readingSession.makePaginationSnapshot(
|
||
pageCounts: Array(repeating: 1, count: publication.spine.count),
|
||
preferences: controller.currentPreferences(),
|
||
layoutContext: controller.currentLayoutContext()
|
||
)
|
||
context.runtime?.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation)
|
||
return
|
||
}
|
||
|
||
let paginator = context.makePaginator()
|
||
print("[EPUB][Pagination] path=web-paginator")
|
||
context.paginator = paginator
|
||
paginator.calculate(
|
||
parser: parser,
|
||
hostingView: controller.ensurePaginationHostView(),
|
||
presentation: controller.currentPreferences().presentationStyle(viewportSize: controller.currentLayoutContext().viewportSize)
|
||
) { [weak controller] pageCounts in
|
||
guard let controller, self.context.paginationToken == token else { return }
|
||
let snapshot = readingSession.makePaginationSnapshot(
|
||
pageCounts: pageCounts,
|
||
preferences: controller.currentPreferences(),
|
||
layoutContext: controller.currentLayoutContext()
|
||
)
|
||
self.context.paginator = nil
|
||
self.context.runtime?.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation)
|
||
}
|
||
}
|
||
|
||
/// 应用文本图书模型:生成分页快照并完成分页流程。
|
||
func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
|
||
guard let controller = context.controller else { return }
|
||
context.textBook = textBook
|
||
context.bookPageMap = nil
|
||
let snapshot = controller.nativeTextSnapshot(from: textBook)
|
||
context.replaceActiveSnapshot(snapshot)
|
||
|
||
guard !textBook.pages.isEmpty else {
|
||
context.handle(error: RDEPUBParserError.emptySpine)
|
||
return
|
||
}
|
||
|
||
finishPagination(restoreLocation: restoreLocation)
|
||
}
|
||
|
||
/// 应用分页快照(Fixed Layout 或 Web 内容),并完成分页流程。
|
||
func applyPaginationSnapshot(
|
||
_ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]),
|
||
restoreLocation: RDEPUBLocation?
|
||
) {
|
||
guard context.controller != nil else { return }
|
||
context.textBook = nil
|
||
context.bookPageMap = nil
|
||
context.replaceActiveSnapshot(snapshot)
|
||
|
||
guard !snapshot.pages.isEmpty else {
|
||
context.handle(error: RDEPUBParserError.emptySpine)
|
||
return
|
||
}
|
||
|
||
finishPagination(restoreLocation: restoreLocation)
|
||
}
|
||
|
||
/// 分页完成后的收尾:刷新视图、恢复阅读位置、处理待定视口变更。
|
||
func finishPagination(restoreLocation: RDEPUBLocation?) {
|
||
guard let controller = context.controller,
|
||
let readerView = context.readerView else { return }
|
||
controller.isRepaginating = false
|
||
controller.hideLoading()
|
||
readerView.reloadData()
|
||
if let targetLocation = restoreLocation {
|
||
controller.restoreReadingLocation(targetLocation)
|
||
context.readingSession?.transition(to: .idle)
|
||
} else {
|
||
readerView.transitionToPage(pageNum: 0)
|
||
context.readingSession?.transition(to: .idle)
|
||
}
|
||
|
||
context.runtime?.viewportMonitor.processPendingChangeAfterPagination()
|
||
}
|
||
|
||
/// 重新分页并保持当前阅读位置(优先使用待恢复位置,其次当前位置,最后持久化位置)。
|
||
func repaginatePreservingCurrentLocation() {
|
||
guard context.publication != nil else { return }
|
||
let restoreLocation = context.runtime?.viewportMonitor.consumePendingPresentationRestoreLocation()
|
||
?? context.currentVisibleLocation()
|
||
?? context.persistenceLocation()
|
||
paginatePublication(restoreLocation: restoreLocation)
|
||
}
|
||
|
||
/// 刷新可见内容并保持当前阅读位置不变。
|
||
func refreshVisibleContentPreservingLocation() {
|
||
guard let readerView = context.readerView else { return }
|
||
let restoreLocation = context.currentVisibleLocation() ?? context.persistenceLocation()
|
||
readerView.reloadData()
|
||
if let restoreLocation {
|
||
_ = context.restoreReadingLocation(restoreLocation)
|
||
}
|
||
}
|
||
|
||
/// 重建外部纯文本图书(布局变更后重新排版)。
|
||
func rebuildExternalTextBook() {
|
||
guard let controller = context.controller,
|
||
let textFileURL = controller.textFileURL else { return }
|
||
let restoreLocation = context.currentVisibleLocation() ?? context.persistenceLocation()
|
||
let pageSize = controller.currentTextPageSize()
|
||
let style = controller.currentTextRenderStyle()
|
||
let builder = context.makePlainTextBookBuilder(layoutConfig: controller.currentTextLayoutConfig(pageSize: pageSize))
|
||
if let newBook = try? builder.build(textFileURL: textFileURL, pageSize: pageSize, style: style) {
|
||
context.runtime?.applyTextBook(newBook, restoreLocation: restoreLocation)
|
||
}
|
||
}
|
||
|
||
private func paginateTextPublication(
|
||
parser: RDEPUBParser,
|
||
publication: RDEPUBPublication,
|
||
readingSession: RDEPUBReadingSession,
|
||
restoreLocation: RDEPUBLocation?,
|
||
token: UUID
|
||
) {
|
||
guard let controller = context.controller else { return }
|
||
let context = self.context
|
||
|
||
let pageSize = controller.currentTextPageSize()
|
||
context.lastTextPaginationPageSize = pageSize
|
||
|
||
DispatchQueue.global(qos: .utility).async { [weak controller] in
|
||
guard controller != nil else { return }
|
||
guard context.controller != nil else { return }
|
||
if let restoredPageMap = self.restoreBookPageMapIfPossible(publication: publication) {
|
||
DispatchQueue.main.async {
|
||
guard context.paginationToken == token,
|
||
context.controller != nil else { return }
|
||
context.runtime?.applyBookPageMap(restoredPageMap, restoreLocation: restoreLocation)
|
||
}
|
||
return
|
||
}
|
||
|
||
let prioritizedCandidates = self.prioritizedBuildableSpineIndices(
|
||
publication: publication,
|
||
readingSession: readingSession,
|
||
restoreLocation: restoreLocation
|
||
)
|
||
guard prioritizedCandidates.first != nil else {
|
||
DispatchQueue.main.async {
|
||
guard context.paginationToken == token,
|
||
context.controller != nil else { return }
|
||
context.handle(error: RDEPUBParserError.emptySpine)
|
||
}
|
||
return
|
||
}
|
||
|
||
do {
|
||
guard context.controller != nil,
|
||
let runtime = context.runtime else { return }
|
||
RDEPUBBackgroundTrace.log(
|
||
"QuickOpen",
|
||
"begin token=\(token.uuidString) candidates=\(prioritizedCandidates.count) restoreSpine=\(readingSession.initialSpineIndex(for: restoreLocation))"
|
||
)
|
||
let runtimeChapter = try RDEPUBBackgroundTrace.measure(
|
||
"QuickOpen",
|
||
"loadFirstRenderableRuntimeChapter"
|
||
) {
|
||
try self.loadFirstRenderableRuntimeChapter(
|
||
prioritizedSpineIndices: prioritizedCandidates,
|
||
runtime: runtime
|
||
)
|
||
}
|
||
let quickWindowChapters = try RDEPUBBackgroundTrace.measure(
|
||
"QuickOpen",
|
||
"loadInitialRuntimeChapters anchorSpine=\(runtimeChapter.spineIndex)"
|
||
) {
|
||
try self.loadInitialRuntimeChapters(
|
||
anchorSpineIndex: runtimeChapter.spineIndex,
|
||
publication: publication,
|
||
runtime: runtime
|
||
)
|
||
}
|
||
RDEPUBBackgroundTrace.log(
|
||
"QuickOpen",
|
||
"ready anchorSpine=\(runtimeChapter.spineIndex) quickWindow=\(quickWindowChapters.map { $0.spineIndex }) pages=\(quickWindowChapters.reduce(0) { $0 + $1.pages.count })"
|
||
)
|
||
|
||
DispatchQueue.main.async {
|
||
guard context.paginationToken == token,
|
||
context.controller != nil else { return }
|
||
runtime.chapterRuntimeStore.setCurrentChapter(
|
||
spineIndex: runtimeChapter.spineIndex,
|
||
totalSpineCount: publication.spine.count,
|
||
windowRadius: context.configuration.chapterWindowRadius
|
||
)
|
||
let partialMap = self.makePartialPageMap(from: quickWindowChapters)
|
||
runtime.applyBookPageMap(partialMap, restoreLocation: restoreLocation)
|
||
self.paginateMetadataOnly(token: token, restoreLocation: restoreLocation)
|
||
}
|
||
} catch {
|
||
DispatchQueue.main.async {
|
||
guard context.paginationToken == token,
|
||
context.controller != nil else { return }
|
||
context.handle(error: error)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func loadFirstRenderableRuntimeChapter(
|
||
prioritizedSpineIndices: [Int],
|
||
runtime: RDEPUBReaderRuntime
|
||
) throws -> RDEPUBRuntimeChapter {
|
||
var lastError: Error?
|
||
for spineIndex in prioritizedSpineIndices {
|
||
do {
|
||
return try runtime.chapterLoader.loadChapterSynchronouslyForMigration(
|
||
spineIndex: spineIndex,
|
||
store: runtime.chapterRuntimeStore
|
||
)
|
||
} catch {
|
||
lastError = error
|
||
RDEPUBBackgroundTrace.log("QuickOpen", "skip spine=\(spineIndex) reason=\(error)")
|
||
}
|
||
}
|
||
throw lastError ?? RDEPUBParserError.emptySpine
|
||
}
|
||
|
||
private func loadInitialRuntimeChapters(
|
||
anchorSpineIndex: Int,
|
||
publication: RDEPUBPublication,
|
||
runtime: RDEPUBReaderRuntime
|
||
) throws -> [RDEPUBRuntimeChapter] {
|
||
let windowSpineIndices = initialWindowSpineIndices(
|
||
around: anchorSpineIndex,
|
||
in: publication,
|
||
maxChapterCount: context.configuration.onDemandChapterWindowSize
|
||
)
|
||
var chapters: [RDEPUBRuntimeChapter] = []
|
||
for spineIndex in windowSpineIndices {
|
||
do {
|
||
let chapter = try runtime.chapterLoader.loadChapterSynchronouslyForMigration(
|
||
spineIndex: spineIndex,
|
||
store: runtime.chapterRuntimeStore
|
||
)
|
||
chapters.append(chapter)
|
||
} catch {
|
||
if spineIndex == anchorSpineIndex {
|
||
throw error
|
||
}
|
||
RDEPUBBackgroundTrace.log("QuickOpen", "skip adjacent spine=\(spineIndex) reason=\(error)")
|
||
}
|
||
}
|
||
return chapters
|
||
}
|
||
|
||
private func initialWindowSpineIndices(
|
||
around anchorSpineIndex: Int,
|
||
in publication: RDEPUBPublication,
|
||
maxChapterCount: Int = 3
|
||
) -> [Int] {
|
||
let normalizedMaxChapterCount = RDEPUBReaderConfiguration.normalizedChapterWindowSize(maxChapterCount)
|
||
let buildableIndices = allBuildableSpineIndices(in: publication)
|
||
guard let anchorPosition = buildableIndices.firstIndex(of: anchorSpineIndex) else {
|
||
return [anchorSpineIndex]
|
||
}
|
||
|
||
var selected = [anchorSpineIndex]
|
||
var nextPosition = anchorPosition + 1
|
||
var previousPosition = anchorPosition - 1
|
||
|
||
while selected.count < normalizedMaxChapterCount,
|
||
nextPosition < buildableIndices.count || previousPosition >= 0 {
|
||
if nextPosition < buildableIndices.count {
|
||
selected.append(buildableIndices[nextPosition])
|
||
nextPosition += 1
|
||
if selected.count == normalizedMaxChapterCount {
|
||
break
|
||
}
|
||
}
|
||
|
||
if previousPosition >= 0 {
|
||
selected.insert(buildableIndices[previousPosition], at: 0)
|
||
previousPosition -= 1
|
||
}
|
||
}
|
||
|
||
return selected
|
||
}
|
||
|
||
private func makePartialPageMap(from chapters: [RDEPUBRuntimeChapter]) -> RDEPUBBookPageMap {
|
||
var builder = RDEPUBBookPageMap.Builder()
|
||
for chapter in chapters {
|
||
builder.add(
|
||
spineIndex: chapter.spineIndex,
|
||
href: chapter.href,
|
||
title: chapter.title,
|
||
pageCount: chapter.pages.count,
|
||
fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets
|
||
)
|
||
}
|
||
return builder.build()
|
||
}
|
||
|
||
private func prioritizedBuildableSpineIndices(
|
||
publication: RDEPUBPublication,
|
||
readingSession: RDEPUBReadingSession,
|
||
restoreLocation: RDEPUBLocation?
|
||
) -> [Int] {
|
||
let preferred = readingSession.initialSpineIndex(for: restoreLocation)
|
||
return publication.spine.indices
|
||
.filter { isBuildableTextSpine(at: $0, in: publication) }
|
||
.sorted { lhs, rhs in
|
||
abs(lhs - preferred) < abs(rhs - preferred)
|
||
}
|
||
}
|
||
|
||
private func allBuildableSpineIndices(in publication: RDEPUBPublication) -> [Int] {
|
||
publication.spine.indices.filter { isBuildableTextSpine(at: $0, in: publication) }
|
||
}
|
||
|
||
private func waitForReadingInteractionToSettle(using context: RDEPUBReaderContext) {
|
||
while context.controller != nil,
|
||
context.secondsSinceLastUserNavigation() < backgroundInteractionCooldown {
|
||
Thread.sleep(forTimeInterval: 0.08)
|
||
}
|
||
}
|
||
|
||
private func isBuildableTextSpine(at index: Int, in publication: RDEPUBPublication) -> Bool {
|
||
guard publication.spine.indices.contains(index) else { return false }
|
||
let item = publication.spine[index]
|
||
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
|
||
}
|
||
|
||
// MARK: - 元数据专用解析(Phase 0)
|
||
|
||
/// 后台遍历所有章节,只提取轻量元数据(pageCount、pageRanges、fragmentOffsets),
|
||
/// 写入磁盘摘要缓存,不累积 RDEPUBTextBook。
|
||
func paginateMetadataOnly(token: UUID, restoreLocation: RDEPUBLocation?) {
|
||
let context = self.context
|
||
guard let parser = context.parser,
|
||
let publication = context.publication else { return }
|
||
|
||
let pageSize = context.currentTextPageSize()
|
||
let layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
|
||
let style = context.currentTextRenderStyle()
|
||
let allBuildableIndices = allBuildableSpineIndices(in: publication)
|
||
let summaryDiskCache = context.runtime?.summaryDiskCache
|
||
let workerCount = max(1, context.configuration.metadataParsingConcurrency)
|
||
let cpuCount = ProcessInfo.processInfo.activeProcessorCount
|
||
RDEPUBBackgroundTrace.log("MetadataParse", "config concurrency=\(workerCount) cpuCores=\(cpuCount)")
|
||
|
||
DispatchQueue.global(qos: .utility).async { [weak self] in
|
||
guard let self else { return }
|
||
guard context.controller != nil else { return }
|
||
let catalog = allBuildableIndices.map { spineIndex in
|
||
let item = publication.spine[spineIndex]
|
||
return (
|
||
key: context.chapterCacheKey(forSpineIndex: spineIndex),
|
||
spineIndex: spineIndex,
|
||
href: item.href,
|
||
title: item.title
|
||
)
|
||
}
|
||
let restored = summaryDiskCache?.readAll(keys: catalog)
|
||
RDEPUBBackgroundTrace.log(
|
||
"MetadataParse",
|
||
"begin token=\(token.uuidString) buildableChapters=\(allBuildableIndices.count) concurrency=\(workerCount)"
|
||
)
|
||
let cachedSummaries = restored?.summaries ?? [:]
|
||
let cachedSpineIndices = Set(cachedSummaries.keys)
|
||
let resultLock = NSLock()
|
||
var summariesBySpineIndex = cachedSummaries
|
||
var totalResolvedCount = cachedSpineIndices.count
|
||
var lastAppliedCount = cachedSpineIndices.count
|
||
|
||
if !cachedSpineIndices.isEmpty {
|
||
RDEPUBBackgroundTrace.log(
|
||
"MetadataParse",
|
||
"resumeFromCache cachedChapters=\(cachedSpineIndices.count) total=\(allBuildableIndices.count)"
|
||
)
|
||
let cachedMap = self.buildPageMap(from: catalog, summaries: summariesBySpineIndex)
|
||
DispatchQueue.main.async {
|
||
guard context.paginationToken == token,
|
||
context.controller != nil else { return }
|
||
context.runtime?.refreshBookPageMapInPlace(cachedMap)
|
||
}
|
||
}
|
||
|
||
let uncachedSpineIndices = allBuildableIndices.filter { !cachedSpineIndices.contains($0) }
|
||
|
||
self.waitForReadingInteractionToSettle(using: context)
|
||
|
||
let wallClockStart = CFAbsoluteTimeGetCurrent()
|
||
var totalRenderMs: Double = 0
|
||
var totalWriteMs: Double = 0
|
||
var completedChapters = 0
|
||
var failedChapters = 0
|
||
let timingLock = NSLock()
|
||
|
||
let queue = OperationQueue()
|
||
queue.name = "com.rdreader.metadata.parse"
|
||
queue.qualityOfService = .utility
|
||
queue.maxConcurrentOperationCount = workerCount
|
||
|
||
for (offset, spineIndex) in uncachedSpineIndices.enumerated() {
|
||
queue.addOperation {
|
||
guard context.controller != nil,
|
||
context.paginationToken == token else {
|
||
return
|
||
}
|
||
do {
|
||
RDEPUBBackgroundTrace.log("MetadataParse", "正在解析 spine=\(spineIndex) \(offset + 1)/\(uncachedSpineIndices.count)")
|
||
|
||
let renderResult: RDEPUBChapterSummary? = try autoreleasepool { () -> RDEPUBChapterSummary? in
|
||
let chapterBuilder = context.makeTextBookBuilder(layoutConfig: layoutConfig)
|
||
let renderStart = CFAbsoluteTimeGetCurrent()
|
||
guard let result = try chapterBuilder.buildChapter(
|
||
parser: parser,
|
||
publication: publication,
|
||
spineIndex: spineIndex,
|
||
pageSize: pageSize,
|
||
style: style
|
||
) else {
|
||
return nil
|
||
}
|
||
let renderElapsed = (CFAbsoluteTimeGetCurrent() - renderStart) * 1000
|
||
|
||
let chapter = result.chapter
|
||
let cacheKey = context.chapterCacheKey(forSpineIndex: spineIndex)
|
||
let summary = RDEPUBChapterSummary(
|
||
pageRanges: chapter.pages.map { .init(location: $0.contentRange.location, length: $0.contentRange.length) },
|
||
pageCount: chapter.pages.count,
|
||
fragmentOffsets: chapter.fragmentOffsets,
|
||
renderSignature: cacheKey.renderSignature,
|
||
schemaVersion: RDEPUBChapterSummary.currentSchemaVersion,
|
||
chapterContentHash: cacheKey.chapterContentHash,
|
||
pageMetadataList: chapter.pages.map { .from($0.metadata) }
|
||
)
|
||
let writeStart = CFAbsoluteTimeGetCurrent()
|
||
summaryDiskCache?.write(summary: summary, for: cacheKey)
|
||
let writeElapsed = (CFAbsoluteTimeGetCurrent() - writeStart) * 1000
|
||
|
||
timingLock.lock()
|
||
totalRenderMs += renderElapsed
|
||
totalWriteMs += writeElapsed
|
||
completedChapters += 1
|
||
timingLock.unlock()
|
||
|
||
RDEPUBBackgroundTrace.log(
|
||
"MetadataParse",
|
||
"spine=\(spineIndex) renderMs=\(Int(renderElapsed)) writeMs=\(Int(writeElapsed))"
|
||
)
|
||
return summary
|
||
}
|
||
|
||
guard let renderResult else { return }
|
||
|
||
var partialMap: RDEPUBBookPageMap?
|
||
resultLock.lock()
|
||
summariesBySpineIndex[spineIndex] = renderResult
|
||
totalResolvedCount += 1
|
||
if totalResolvedCount - lastAppliedCount >= 32 || totalResolvedCount == allBuildableIndices.count {
|
||
lastAppliedCount = totalResolvedCount
|
||
partialMap = self.buildPageMap(from: catalog, summaries: summariesBySpineIndex)
|
||
}
|
||
resultLock.unlock()
|
||
|
||
if let partialMap {
|
||
DispatchQueue.main.async {
|
||
guard context.paginationToken == token,
|
||
context.controller != nil else { return }
|
||
context.runtime?.refreshBookPageMapInPlace(partialMap)
|
||
}
|
||
}
|
||
} catch {
|
||
timingLock.lock()
|
||
failedChapters += 1
|
||
timingLock.unlock()
|
||
RDEPUBBackgroundTrace.log("MetadataParse", "buildChapter FAILED: spine=\(spineIndex) error=\(error)")
|
||
}
|
||
}
|
||
}
|
||
queue.waitUntilAllOperationsAreFinished()
|
||
summaryDiskCache?.flushPendingWrites()
|
||
|
||
let wallClockMs = Int((CFAbsoluteTimeGetCurrent() - wallClockStart) * 1000)
|
||
timingLock.lock()
|
||
let renderTotal = Int(totalRenderMs)
|
||
let writeTotal = Int(totalWriteMs)
|
||
let rendered = completedChapters
|
||
let failed = failedChapters
|
||
timingLock.unlock()
|
||
let avgRenderMs = rendered > 0 ? renderTotal / rendered : 0
|
||
RDEPUBBackgroundTrace.log(
|
||
"MetadataParse",
|
||
"timing wallClockMs=\(wallClockMs) chapters=\(rendered) failed=\(failed) " +
|
||
"renderTotalMs=\(renderTotal) writeTotalMs=\(writeTotal) avgRenderMs=\(avgRenderMs) concurrency=\(workerCount)"
|
||
)
|
||
context.lastMetadataParseWallClockMs = wallClockMs
|
||
context.lastMetadataParseConcurrency = workerCount
|
||
|
||
guard context.controller != nil,
|
||
context.paginationToken == token else {
|
||
RDEPUBBackgroundTrace.log("MetadataParse", "abort: reader dismissed or token changed")
|
||
return
|
||
}
|
||
|
||
let pageMap = self.buildPageMap(from: catalog, summaries: summariesBySpineIndex)
|
||
RDEPUBBackgroundTrace.log(
|
||
"MetadataParse",
|
||
"complete chapters=\(pageMap.totalChapters) pages=\(pageMap.totalPages)"
|
||
)
|
||
|
||
DispatchQueue.main.async {
|
||
guard context.paginationToken == token,
|
||
context.controller != nil else { return }
|
||
context.runtime?.refreshBookPageMapInPlace(pageMap)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func restoreBookPageMapIfPossible(publication: RDEPUBPublication) -> RDEPUBBookPageMap? {
|
||
guard let summaryDiskCache = context.runtime?.summaryDiskCache else {
|
||
return nil
|
||
}
|
||
let catalog = allBuildableSpineIndices(in: publication).map { spineIndex in
|
||
let item = publication.spine[spineIndex]
|
||
return (
|
||
key: context.chapterCacheKey(forSpineIndex: spineIndex),
|
||
spineIndex: spineIndex,
|
||
href: item.href,
|
||
title: item.title
|
||
)
|
||
}
|
||
guard summaryDiskCache.isCacheComplete(keys: catalog.map(\.key)) else {
|
||
return nil
|
||
}
|
||
let restored = summaryDiskCache.readAll(keys: catalog)
|
||
guard restored.summaries.count == catalog.count else {
|
||
return nil
|
||
}
|
||
return restored.mapBuilder.build()
|
||
}
|
||
|
||
private func buildPageMap(
|
||
from catalog: [(key: RDEPUBChapterCacheKey, spineIndex: Int, href: String, title: String)],
|
||
summaries: [Int: RDEPUBChapterSummary]
|
||
) -> RDEPUBBookPageMap {
|
||
var builder = RDEPUBBookPageMap.Builder()
|
||
for item in catalog {
|
||
guard let summary = summaries[item.spineIndex] else { continue }
|
||
builder.add(
|
||
spineIndex: item.spineIndex,
|
||
href: item.href,
|
||
title: item.title,
|
||
pageCount: summary.pageCount,
|
||
fragmentOffsets: summary.fragmentOffsets
|
||
)
|
||
}
|
||
return builder.build()
|
||
}
|
||
}
|