- PaginationCoordinator: 新增 textReflowable 快速进入路径,支持增量章节构建 与 staged 合并策略,避免整书一次性构建的主线程阻塞 - TextBookBuilder: 重构为支持单章构建与增量合并模式 - ChapterPageCounter: 优化分页计数逻辑,支持章节级分页缓存 - PageFrameFactory: 新增 CoreText 页面帧工厂,提升排版性能 - ReaderContext/ReaderRuntime: 适配增量分页状态管理 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
553 lines
21 KiB
Swift
553 lines
21 KiB
Swift
import Foundation
|
||
|
||
/// EPUB 阅读器分页协调器:负责出版物的分页计算和页面数据更新。
|
||
///
|
||
/// 职责:
|
||
/// - 根据出版物类型(文本重排/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
|
||
|
||
init(context: RDEPUBReaderContext) {
|
||
self.context = context
|
||
}
|
||
|
||
/// 对出版物执行分页:文本重排走 TextBookBuilder,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-fast-entry")
|
||
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 }
|
||
stagedIncrementalApplyWorkItem?.cancel()
|
||
stagedIncrementalApplyWorkItem = nil
|
||
clearStagedBookRequest()
|
||
context.textBook = textBook
|
||
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.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)
|
||
} 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)
|
||
}
|
||
}
|
||
|
||
/// 文本 EPUB 大书快速进入:先构建目标章节进入阅读器,再后台补齐完整 TextBook。
|
||
private func paginateTextPublication(
|
||
parser: RDEPUBParser,
|
||
publication: RDEPUBPublication,
|
||
readingSession: RDEPUBReadingSession,
|
||
restoreLocation: RDEPUBLocation?,
|
||
token: UUID
|
||
) {
|
||
guard let controller = context.controller else { return }
|
||
|
||
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
|
||
|
||
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
|
||
)
|
||
|
||
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 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,
|
||
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()
|
||
}
|
||
}
|
||
}
|
||
|
||
self.stageBookRequest(
|
||
chapterStore: chapterStore,
|
||
orderedSpineIndices: allBuildableIndices,
|
||
restoreLocation: restoreLocation,
|
||
isComplete: true
|
||
)
|
||
DispatchQueue.main.async {
|
||
guard self.context.paginationToken == token else { return }
|
||
self.scheduleStagedIncrementalTextBookApplication()
|
||
}
|
||
} 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)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
anchorResult = result
|
||
break
|
||
}
|
||
|
||
guard let anchorResult else {
|
||
return nil
|
||
}
|
||
|
||
let quickWindow = quickWindowSpineIndices(
|
||
around: anchorResult.chapter.spineIndex,
|
||
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,
|
||
spineIndex: spineIndex,
|
||
pageSize: pageSize,
|
||
style: style
|
||
)
|
||
}
|
||
|
||
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
|
||
)
|
||
}
|
||
|
||
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(
|
||
around anchorSpineIndex: Int,
|
||
in publication: RDEPUBPublication,
|
||
maxChapterCount: Int = 3
|
||
) -> [Int] {
|
||
let buildableIndices = allBuildableSpineIndices(in: publication)
|
||
guard let anchorPosition = buildableIndices.firstIndex(of: anchorSpineIndex) else {
|
||
return [anchorSpineIndex]
|
||
}
|
||
|
||
var selected: [Int] = [anchorSpineIndex]
|
||
var nextPosition = anchorPosition + 1
|
||
var previousPosition = anchorPosition - 1
|
||
|
||
while selected.count < maxChapterCount,
|
||
nextPosition < buildableIndices.count || previousPosition >= 0 {
|
||
if nextPosition < buildableIndices.count {
|
||
selected.append(buildableIndices[nextPosition])
|
||
nextPosition += 1
|
||
if selected.count == maxChapterCount {
|
||
break
|
||
}
|
||
}
|
||
|
||
if previousPosition >= 0 {
|
||
selected.insert(buildableIndices[previousPosition], at: 0)
|
||
previousPosition -= 1
|
||
}
|
||
}
|
||
|
||
return selected
|
||
}
|
||
|
||
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 {
|
||
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"))
|
||
}
|
||
}
|