feat: complete large-book pagination runtime and UI coverage
This commit is contained in:
+296
-311
@@ -4,56 +4,12 @@ import Foundation
|
||||
///
|
||||
/// 职责:
|
||||
/// - 根据出版物类型(文本重排/Fixed Layout/Web 内容)选择分页策略
|
||||
/// - 后台构建文本图书模型并应用分页快照
|
||||
/// - 文本大书优先恢复分页摘要并切换到按需加载
|
||||
/// - 重新分页时保持当前阅读位置
|
||||
/// - 刷新可见内容并保持位置
|
||||
/// - 重建外部纯文本图书
|
||||
final class RDEPUBReaderPaginationCoordinator {
|
||||
private let backgroundInteractionCooldown: CFAbsoluteTime = 0.8
|
||||
private let backgroundChapterPause: TimeInterval = 0.04
|
||||
private let incrementalMergeChapterThreshold = 20
|
||||
private let stagedIncrementalApplyDelay: TimeInterval = 1.0
|
||||
private var stagedIncrementalApplyWorkItem: DispatchWorkItem?
|
||||
private let stagedBookRequestLock = NSLock()
|
||||
private var stagedBookRequest: StagedBookRequest?
|
||||
|
||||
private struct QuickBuildState {
|
||||
var quickWindow: [Int]
|
||||
var chapterStore: IncrementalChapterStore
|
||||
var book: RDEPUBTextBook
|
||||
}
|
||||
|
||||
private struct StagedBookRequest {
|
||||
var chapterStore: IncrementalChapterStore
|
||||
var orderedSpineIndices: [Int]
|
||||
var restoreLocation: RDEPUBLocation?
|
||||
var isComplete: Bool
|
||||
}
|
||||
|
||||
private final class IncrementalChapterStore {
|
||||
private let lock = NSLock()
|
||||
private var builtChapters: [Int: RDEPUBTextChapter] = [:]
|
||||
|
||||
func insert(_ chapter: RDEPUBTextChapter, for spineIndex: Int) {
|
||||
lock.lock()
|
||||
builtChapters[spineIndex] = chapter
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func contains(_ spineIndex: Int) -> Bool {
|
||||
lock.lock()
|
||||
let contains = builtChapters[spineIndex] != nil
|
||||
lock.unlock()
|
||||
return contains
|
||||
}
|
||||
|
||||
func snapshot(orderedBy spineIndices: [Int]) -> [Int: RDEPUBTextChapter] {
|
||||
lock.lock()
|
||||
let chapters = builtChapters
|
||||
lock.unlock()
|
||||
return chapters
|
||||
}
|
||||
}
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
@@ -61,7 +17,7 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
/// 对出版物执行分页:文本重排走 TextBookBuilder,Fixed Layout 直接生成快照,Web 内容走 Paginator。
|
||||
/// 对出版物执行分页:文本重排优先走摘要恢复/按需加载,Fixed Layout 直接生成快照,Web 内容走 Paginator。
|
||||
func paginatePublication(restoreLocation: RDEPUBLocation?) {
|
||||
guard let controller = context.controller,
|
||||
let parser = context.parser,
|
||||
@@ -78,7 +34,7 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
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-fast-entry")
|
||||
print("[EPUB][Pagination] path=text-reflowable-on-demand")
|
||||
paginateTextPublication(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
@@ -122,10 +78,8 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
/// 应用文本图书模型:生成分页快照并完成分页流程。
|
||||
func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
|
||||
guard let controller = context.controller else { return }
|
||||
stagedIncrementalApplyWorkItem?.cancel()
|
||||
stagedIncrementalApplyWorkItem = nil
|
||||
clearStagedBookRequest()
|
||||
context.textBook = textBook
|
||||
context.bookPageMap = nil
|
||||
let snapshot = controller.nativeTextSnapshot(from: textBook)
|
||||
context.replaceActiveSnapshot(snapshot)
|
||||
|
||||
@@ -144,6 +98,7 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
) {
|
||||
guard context.controller != nil else { return }
|
||||
context.textBook = nil
|
||||
context.bookPageMap = nil
|
||||
context.replaceActiveSnapshot(snapshot)
|
||||
|
||||
guard !snapshot.pages.isEmpty else {
|
||||
@@ -163,6 +118,7 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
readerView.reloadData()
|
||||
if let targetLocation = restoreLocation {
|
||||
controller.restoreReadingLocation(targetLocation)
|
||||
context.readingSession?.transition(to: .idle)
|
||||
} else {
|
||||
readerView.transitionToPage(pageNum: 0)
|
||||
context.readingSession?.transition(to: .idle)
|
||||
@@ -203,7 +159,6 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
/// 文本 EPUB 大书快速进入:先构建目标章节进入阅读器,再后台补齐完整 TextBook。
|
||||
private func paginateTextPublication(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
@@ -212,184 +167,136 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
token: UUID
|
||||
) {
|
||||
guard let controller = context.controller else { return }
|
||||
let context = self.context
|
||||
|
||||
let pageSize = controller.currentTextPageSize()
|
||||
context.lastTextPaginationPageSize = pageSize
|
||||
let layoutConfig = controller.currentTextLayoutConfig(pageSize: pageSize)
|
||||
let builder = context.makeTextBookBuilder(layoutConfig: layoutConfig)
|
||||
let renderStyle = controller.currentTextRenderStyle()
|
||||
let quickSpineCandidates = prioritizedBuildableSpineIndices(
|
||||
publication: publication,
|
||||
readingSession: readingSession,
|
||||
restoreLocation: restoreLocation
|
||||
)
|
||||
|
||||
DispatchQueue.global(qos: .utility).async { [weak controller] in
|
||||
guard controller != nil else { return }
|
||||
var didApplyQuickChapter = false
|
||||
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 {
|
||||
self.waitForReadingInteractionToSettle()
|
||||
let allBuildableIndices = self.allBuildableSpineIndices(in: publication)
|
||||
let quickBuildState = try self.buildQuickTextBook(
|
||||
builder: builder,
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
pageSize: pageSize,
|
||||
style: renderStyle,
|
||||
prioritizedCandidates: quickSpineCandidates
|
||||
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))"
|
||||
)
|
||||
|
||||
if let quickBook = quickBuildState?.book {
|
||||
DispatchQueue.main.sync {
|
||||
guard self.context.paginationToken == token else { return }
|
||||
didApplyQuickChapter = true
|
||||
self.context.runtime?.applyTextBook(quickBook, restoreLocation: restoreLocation)
|
||||
}
|
||||
let runtimeChapter = try RDEPUBBackgroundTrace.measure(
|
||||
"QuickOpen",
|
||||
"loadFirstRenderableRuntimeChapter"
|
||||
) {
|
||||
try self.loadFirstRenderableRuntimeChapter(
|
||||
prioritizedSpineIndices: prioritizedCandidates,
|
||||
runtime: runtime
|
||||
)
|
||||
}
|
||||
|
||||
let chapterStore = quickBuildState?.chapterStore ?? IncrementalChapterStore()
|
||||
var pendingIncrementalCount = 0
|
||||
let incrementalBuildOrder = self.incrementalBuildOrder(
|
||||
allBuildableIndices: allBuildableIndices,
|
||||
quickWindow: quickBuildState?.quickWindow ?? []
|
||||
)
|
||||
|
||||
for spineIndex in incrementalBuildOrder where !chapterStore.contains(spineIndex) {
|
||||
self.waitForReadingInteractionToSettle()
|
||||
guard let result = try builder.buildChapter(
|
||||
parser: parser,
|
||||
let quickWindowChapters = try RDEPUBBackgroundTrace.measure(
|
||||
"QuickOpen",
|
||||
"loadInitialRuntimeChapters anchorSpine=\(runtimeChapter.spineIndex)"
|
||||
) {
|
||||
try self.loadInitialRuntimeChapters(
|
||||
anchorSpineIndex: runtimeChapter.spineIndex,
|
||||
publication: publication,
|
||||
spineIndex: spineIndex,
|
||||
pageSize: pageSize,
|
||||
style: renderStyle
|
||||
) else {
|
||||
continue
|
||||
}
|
||||
|
||||
chapterStore.insert(result.chapter, for: spineIndex)
|
||||
pendingIncrementalCount += 1
|
||||
Thread.sleep(forTimeInterval: self.backgroundChapterPause)
|
||||
|
||||
if pendingIncrementalCount >= self.incrementalMergeChapterThreshold {
|
||||
pendingIncrementalCount = 0
|
||||
self.stageBookRequest(
|
||||
chapterStore: chapterStore,
|
||||
orderedSpineIndices: allBuildableIndices,
|
||||
restoreLocation: restoreLocation,
|
||||
isComplete: false
|
||||
)
|
||||
DispatchQueue.main.async {
|
||||
guard self.context.paginationToken == token else { return }
|
||||
self.scheduleStagedIncrementalTextBookApplication()
|
||||
}
|
||||
}
|
||||
runtime: runtime
|
||||
)
|
||||
}
|
||||
|
||||
self.stageBookRequest(
|
||||
chapterStore: chapterStore,
|
||||
orderedSpineIndices: allBuildableIndices,
|
||||
restoreLocation: restoreLocation,
|
||||
isComplete: true
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"QuickOpen",
|
||||
"ready anchorSpine=\(runtimeChapter.spineIndex) quickWindow=\(quickWindowChapters.map { $0.spineIndex }) pages=\(quickWindowChapters.reduce(0) { $0 + $1.pages.count })"
|
||||
)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
guard self.context.paginationToken == token else { return }
|
||||
self.scheduleStagedIncrementalTextBookApplication()
|
||||
guard context.paginationToken == token,
|
||||
context.controller != nil else { return }
|
||||
runtime.chapterRuntimeStore.setCurrentChapter(
|
||||
spineIndex: runtimeChapter.spineIndex,
|
||||
totalSpineCount: publication.spine.count
|
||||
)
|
||||
let partialMap = self.makePartialPageMap(from: quickWindowChapters)
|
||||
runtime.applyBookPageMap(partialMap, restoreLocation: restoreLocation)
|
||||
self.paginateMetadataOnly(token: token, restoreLocation: restoreLocation)
|
||||
}
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
guard self.context.paginationToken == token else { return }
|
||||
if didApplyQuickChapter {
|
||||
self.context.isRepaginating = false
|
||||
self.context.hideLoading()
|
||||
} else {
|
||||
self.context.handle(error: error)
|
||||
}
|
||||
guard context.paginationToken == token,
|
||||
context.controller != nil else { return }
|
||||
context.handle(error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func buildQuickTextBook(
|
||||
builder: RDEPUBTextBookBuilder,
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
pageSize: CGSize,
|
||||
style: RDEPUBTextRenderStyle,
|
||||
prioritizedCandidates: [Int]
|
||||
) throws -> QuickBuildState? {
|
||||
var anchorResult: RDEPUBTextChapterBuildResult?
|
||||
for spineIndex in prioritizedCandidates {
|
||||
guard let result = try builder.buildChapter(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
spineIndex: spineIndex,
|
||||
pageSize: pageSize,
|
||||
style: style
|
||||
), !result.chapter.pages.isEmpty else {
|
||||
continue
|
||||
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)")
|
||||
}
|
||||
anchorResult = result
|
||||
break
|
||||
}
|
||||
throw lastError ?? RDEPUBParserError.emptySpine
|
||||
}
|
||||
|
||||
guard let anchorResult else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let quickWindow = quickWindowSpineIndices(
|
||||
around: anchorResult.chapter.spineIndex,
|
||||
private func loadInitialRuntimeChapters(
|
||||
anchorSpineIndex: Int,
|
||||
publication: RDEPUBPublication,
|
||||
runtime: RDEPUBReaderRuntime
|
||||
) throws -> [RDEPUBRuntimeChapter] {
|
||||
let windowSpineIndices = initialWindowSpineIndices(
|
||||
around: anchorSpineIndex,
|
||||
in: publication
|
||||
)
|
||||
|
||||
let chapterStore = IncrementalChapterStore()
|
||||
for spineIndex in quickWindow {
|
||||
let result: RDEPUBTextChapterBuildResult?
|
||||
if spineIndex == anchorResult.chapter.spineIndex {
|
||||
result = anchorResult
|
||||
} else {
|
||||
result = try builder.buildChapter(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
var chapters: [RDEPUBRuntimeChapter] = []
|
||||
for spineIndex in windowSpineIndices {
|
||||
do {
|
||||
let chapter = try runtime.chapterLoader.loadChapterSynchronouslyForMigration(
|
||||
spineIndex: spineIndex,
|
||||
pageSize: pageSize,
|
||||
style: style
|
||||
store: runtime.chapterRuntimeStore
|
||||
)
|
||||
chapters.append(chapter)
|
||||
} catch {
|
||||
if spineIndex == anchorSpineIndex {
|
||||
throw error
|
||||
}
|
||||
RDEPUBBackgroundTrace.log("QuickOpen", "skip adjacent spine=\(spineIndex) reason=\(error)")
|
||||
}
|
||||
|
||||
guard let chapter = result?.chapter,
|
||||
!chapter.pages.isEmpty else {
|
||||
continue
|
||||
}
|
||||
chapterStore.insert(chapter, for: spineIndex)
|
||||
}
|
||||
|
||||
guard let book = textBook(from: chapterStore.snapshot(orderedBy: quickWindow), orderedBy: quickWindow) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
print("[EPUB][Pagination] quick-book chapters=\(book.chapters.count) pages=\(book.pages.count) anchor=\(anchorResult.chapter.href)")
|
||||
return QuickBuildState(
|
||||
quickWindow: quickWindow,
|
||||
chapterStore: chapterStore,
|
||||
book: book
|
||||
)
|
||||
return chapters
|
||||
}
|
||||
|
||||
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 quickWindowSpineIndices(
|
||||
private func initialWindowSpineIndices(
|
||||
around anchorSpineIndex: Int,
|
||||
in publication: RDEPUBPublication,
|
||||
maxChapterCount: Int = 3
|
||||
@@ -399,7 +306,7 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
return [anchorSpineIndex]
|
||||
}
|
||||
|
||||
var selected: [Int] = [anchorSpineIndex]
|
||||
var selected = [anchorSpineIndex]
|
||||
var nextPosition = anchorPosition + 1
|
||||
var previousPosition = anchorPosition - 1
|
||||
|
||||
@@ -422,131 +329,209 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
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) }
|
||||
}
|
||||
|
||||
func scheduleStagedIncrementalTextBookApplication() {
|
||||
stagedIncrementalApplyWorkItem?.cancel()
|
||||
let workItem = DispatchWorkItem { [weak self] in
|
||||
self?.applyStagedIncrementalTextBookIfPossible()
|
||||
}
|
||||
stagedIncrementalApplyWorkItem = workItem
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + stagedIncrementalApplyDelay, execute: workItem)
|
||||
}
|
||||
|
||||
private func applyStagedIncrementalTextBookIfPossible() {
|
||||
guard context.secondsSinceLastUserNavigation() >= backgroundInteractionCooldown,
|
||||
!context.isRepaginating,
|
||||
context.readingSession?.navigatorState == .idle else {
|
||||
scheduleStagedIncrementalTextBookApplication()
|
||||
return
|
||||
}
|
||||
|
||||
guard let staged = consumeStagedBookRequest() else {
|
||||
stagedIncrementalApplyWorkItem = nil
|
||||
return
|
||||
}
|
||||
|
||||
stagedIncrementalApplyWorkItem = nil
|
||||
guard let stagedBook = textBook(
|
||||
from: staged.chapterStore.snapshot(orderedBy: staged.orderedSpineIndices),
|
||||
orderedBy: staged.orderedSpineIndices
|
||||
) else {
|
||||
return
|
||||
}
|
||||
let restoreLocation = context.currentVisibleLocation() ?? staged.restoreLocation
|
||||
let logPrefix = staged.isComplete ? "full-book" : "applied-staged-book"
|
||||
print("[EPUB][Pagination] \(logPrefix) chapters=\(stagedBook.chapters.count) pages=\(stagedBook.pages.count)")
|
||||
context.runtime?.applyTextBook(stagedBook, restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
private func waitForReadingInteractionToSettle() {
|
||||
while context.secondsSinceLastUserNavigation() < backgroundInteractionCooldown {
|
||||
private func waitForReadingInteractionToSettle(using context: RDEPUBReaderContext) {
|
||||
while context.controller != nil,
|
||||
context.secondsSinceLastUserNavigation() < backgroundInteractionCooldown {
|
||||
Thread.sleep(forTimeInterval: 0.08)
|
||||
}
|
||||
}
|
||||
|
||||
private func incrementalBuildOrder(
|
||||
allBuildableIndices: [Int],
|
||||
quickWindow: [Int]
|
||||
) -> [Int] {
|
||||
guard let firstWindowIndex = quickWindow.first,
|
||||
let lastWindowIndex = quickWindow.last else {
|
||||
return allBuildableIndices
|
||||
}
|
||||
|
||||
let forward = allBuildableIndices.filter { $0 > lastWindowIndex }
|
||||
let backward = allBuildableIndices.filter { $0 < firstWindowIndex }
|
||||
return quickWindow + forward + backward
|
||||
}
|
||||
|
||||
private func textBook(
|
||||
from builtChapters: [Int: RDEPUBTextChapter],
|
||||
orderedBy spineIndices: [Int]
|
||||
) -> RDEPUBTextBook? {
|
||||
var chapters: [RDEPUBTextChapter] = []
|
||||
var pages: [RDEPUBTextPage] = []
|
||||
|
||||
for spineIndex in spineIndices {
|
||||
guard var chapter = builtChapters[spineIndex],
|
||||
!chapter.pages.isEmpty else {
|
||||
continue
|
||||
}
|
||||
|
||||
chapter.chapterIndex = chapters.count
|
||||
let normalizedPages = chapter.pages.enumerated().map { localPageIndex, page -> RDEPUBTextPage in
|
||||
var page = page
|
||||
page.absolutePageIndex = pages.count + localPageIndex
|
||||
page.chapterIndex = chapters.count
|
||||
page.pageIndexInChapter = localPageIndex
|
||||
page.totalPagesInChapter = chapter.pages.count
|
||||
return page
|
||||
}
|
||||
chapter.pages = normalizedPages
|
||||
chapters.append(chapter)
|
||||
pages.append(contentsOf: normalizedPages)
|
||||
}
|
||||
|
||||
guard !pages.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
return RDEPUBTextBook(chapters: chapters, pages: pages)
|
||||
}
|
||||
|
||||
private func stageBookRequest(
|
||||
chapterStore: IncrementalChapterStore,
|
||||
orderedSpineIndices: [Int],
|
||||
restoreLocation: RDEPUBLocation?,
|
||||
isComplete: Bool
|
||||
) {
|
||||
stagedBookRequestLock.lock()
|
||||
stagedBookRequest = StagedBookRequest(
|
||||
chapterStore: chapterStore,
|
||||
orderedSpineIndices: orderedSpineIndices,
|
||||
restoreLocation: restoreLocation,
|
||||
isComplete: isComplete
|
||||
)
|
||||
stagedBookRequestLock.unlock()
|
||||
}
|
||||
|
||||
private func consumeStagedBookRequest() -> StagedBookRequest? {
|
||||
stagedBookRequestLock.lock()
|
||||
defer { stagedBookRequestLock.unlock() }
|
||||
let request = stagedBookRequest
|
||||
stagedBookRequest = nil
|
||||
return request
|
||||
}
|
||||
|
||||
private func clearStagedBookRequest() {
|
||||
stagedBookRequestLock.lock()
|
||||
stagedBookRequest = nil
|
||||
stagedBookRequestLock.unlock()
|
||||
}
|
||||
|
||||
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 builder = context.makeTextBookBuilder(layoutConfig: layoutConfig)
|
||||
let style = context.currentTextRenderStyle()
|
||||
let allBuildableIndices = allBuildableSpineIndices(in: publication)
|
||||
let summaryDiskCache = context.runtime?.summaryDiskCache
|
||||
|
||||
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)"
|
||||
)
|
||||
var mapBuilder = restored?.mapBuilder ?? RDEPUBBookPageMap.Builder()
|
||||
var lastAppliedCount = 0
|
||||
let cachedSpineIndices = Set((restored?.summaries ?? [:]).keys)
|
||||
|
||||
if !cachedSpineIndices.isEmpty {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"MetadataParse",
|
||||
"resumeFromCache cachedChapters=\(cachedSpineIndices.count) total=\(allBuildableIndices.count)"
|
||||
)
|
||||
let cachedMap = mapBuilder.build()
|
||||
DispatchQueue.main.async {
|
||||
guard context.paginationToken == token,
|
||||
context.controller != nil else { return }
|
||||
context.runtime?.refreshBookPageMapInPlace(cachedMap)
|
||||
}
|
||||
}
|
||||
|
||||
for (offset, spineIndex) in allBuildableIndices.enumerated() {
|
||||
guard context.controller != nil,
|
||||
context.paginationToken == token else {
|
||||
RDEPUBBackgroundTrace.log("MetadataParse", "abort: reader dismissed or token changed")
|
||||
return
|
||||
}
|
||||
if cachedSpineIndices.contains(spineIndex) {
|
||||
continue
|
||||
}
|
||||
self.waitForReadingInteractionToSettle(using: context)
|
||||
do {
|
||||
RDEPUBBackgroundTrace.log("MetadataParse", "正在解析 spine=\(spineIndex) \(offset + 1)/\(allBuildableIndices.count)")
|
||||
let lightweightEntry = try RDEPUBBackgroundTrace.measure(
|
||||
"MetadataParse",
|
||||
"spine=\(spineIndex)"
|
||||
) {
|
||||
try autoreleasepool { () -> RDEPUBBookPageMapEntry? in
|
||||
guard let result = try builder.buildChapter(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
spineIndex: spineIndex,
|
||||
pageSize: pageSize,
|
||||
style: style
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
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) }
|
||||
)
|
||||
summaryDiskCache?.writeSynchronously(summary: summary, for: cacheKey)
|
||||
|
||||
return RDEPUBBookPageMapEntry(
|
||||
spineIndex: spineIndex,
|
||||
href: chapter.href,
|
||||
title: chapter.title,
|
||||
pageCount: chapter.pages.count,
|
||||
absolutePageStart: 0,
|
||||
fragmentOffsets: chapter.fragmentOffsets
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if let lightweightEntry {
|
||||
mapBuilder.add(
|
||||
spineIndex: lightweightEntry.spineIndex,
|
||||
href: lightweightEntry.href,
|
||||
title: lightweightEntry.title,
|
||||
pageCount: lightweightEntry.pageCount,
|
||||
fragmentOffsets: lightweightEntry.fragmentOffsets
|
||||
)
|
||||
let builtCount = offset + 1
|
||||
if builtCount - lastAppliedCount >= 16 || builtCount == allBuildableIndices.count {
|
||||
lastAppliedCount = builtCount
|
||||
let partialMap = mapBuilder.build()
|
||||
DispatchQueue.main.async {
|
||||
guard context.paginationToken == token,
|
||||
context.controller != nil else { return }
|
||||
context.runtime?.refreshBookPageMapInPlace(partialMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
RDEPUBBackgroundTrace.log("MetadataParse", "buildChapter FAILED: spine=\(spineIndex) error=\(error)")
|
||||
}
|
||||
}
|
||||
|
||||
let pageMap = mapBuilder.build()
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user