388 lines
16 KiB
Swift
388 lines
16 KiB
Swift
import Foundation
|
||
|
||
final class RDEPUBReaderPaginationCoordinator {
|
||
|
||
static var pageMapRefreshInterval: Int = 32
|
||
|
||
private unowned let context: RDEPUBReaderContext
|
||
|
||
private let metadataParseControlLock = NSLock()
|
||
|
||
private var activeMetadataParseCancellationController: RDEPUBMetadataParseCancellationController?
|
||
|
||
init(context: RDEPUBReaderContext) {
|
||
self.context = context
|
||
}
|
||
|
||
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
|
||
|
||
if publication.readingProfile == .textReflowable {
|
||
paginateTextPublication(
|
||
parser: parser,
|
||
publication: publication,
|
||
readingSession: readingSession,
|
||
restoreLocation: restoreLocation,
|
||
token: token
|
||
)
|
||
return
|
||
}
|
||
|
||
if publication.layout == .fixed {
|
||
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()
|
||
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.runtime?.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation)
|
||
}
|
||
}
|
||
|
||
func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
|
||
guard let controller = context.controller else { return }
|
||
context.textBook = textBook
|
||
context.bookPageMap = nil
|
||
context.pendingPageMapUpdates.removeAll()
|
||
let snapshot = controller.nativeTextSnapshot(from: textBook)
|
||
context.replaceActiveSnapshot(snapshot)
|
||
|
||
guard !textBook.pages.isEmpty else {
|
||
context.handle(error: RDEPUBParserError.emptySpine)
|
||
return
|
||
}
|
||
|
||
finishPagination(restoreLocation: restoreLocation)
|
||
}
|
||
|
||
func applyPaginationSnapshot(
|
||
_ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]),
|
||
restoreLocation: RDEPUBLocation?
|
||
) {
|
||
guard context.controller != nil else { return }
|
||
context.textBook = nil
|
||
context.bookPageMap = nil
|
||
context.pendingPageMapUpdates.removeAll()
|
||
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.runtime?.viewportMonitor.processPendingChangeAfterPagination()
|
||
}
|
||
|
||
func repaginatePreservingCurrentLocation(
|
||
preferredRestoreLocation: RDEPUBLocation? = nil
|
||
) {
|
||
guard context.publication != nil else { return }
|
||
let restoreLocation = preferredRestoreLocation
|
||
?? context.runtime?.viewportMonitor.consumePendingPresentationRestoreLocation()
|
||
?? context.currentVisibleLocation()
|
||
?? context.persistenceLocation()
|
||
// Chapter pages, page counts, partial maps, and background coverage all
|
||
// depend on the viewport. Keeping them across a rotation can pair the
|
||
// new page map with chapters paginated for the old size, leaving an
|
||
// on-demand page in its loading state forever.
|
||
context.runtime?.prepareForFullRepagination()
|
||
paginatePublication(restoreLocation: restoreLocation)
|
||
}
|
||
|
||
func refreshVisibleContentPreservingLocation() {
|
||
guard let readerView = context.readerView else { return }
|
||
if readerView.isPageCurlTransitioning {
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
||
self?.refreshVisibleContentPreservingLocation()
|
||
}
|
||
return
|
||
}
|
||
let restoreLocation = context.currentVisibleLocation() ?? context.persistenceLocation()
|
||
// pageCurl 不能走 transitionToPage(当前页, animated: false):同页无 fault 会被
|
||
// 直接跳过,主题/暗色图片等外观变化不会重新 configure 可见页。reloadData
|
||
// 会失效预加载缓存并重建翻页控制器,保证可见页与相邻页都按新配置重建。
|
||
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
|
||
let layoutSnapshot = context.makeLayoutSnapshot()
|
||
let runtime = context.runtime
|
||
|
||
DispatchQueue.global(qos: .utility).async { [weak controller] in
|
||
guard controller != nil else { return }
|
||
guard let runtime else { 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 {
|
||
let runtimeChapter = try self.loadFirstRenderableRuntimeChapter(
|
||
prioritizedSpineIndices: prioritizedCandidates,
|
||
runtime: runtime,
|
||
layoutSnapshot: layoutSnapshot
|
||
)
|
||
RDEPUBBackgroundTrace.log(
|
||
"Pagination",
|
||
"anchorChapterReady spine=\(runtimeChapter.spineIndex) pages=\(runtimeChapter.pages.count)"
|
||
)
|
||
let initialChapters = self.loadInitialInteractiveRuntimeChapters(
|
||
anchorChapter: runtimeChapter,
|
||
publication: publication,
|
||
runtime: runtime,
|
||
layoutSnapshot: layoutSnapshot
|
||
)
|
||
let initialPageCount = initialChapters.reduce(0) { $0 + $1.pages.count }
|
||
RDEPUBBackgroundTrace.log(
|
||
"Pagination",
|
||
"initialInteractiveChapters ready count=\(initialChapters.count) pages=\(initialPageCount) spines=\(initialChapters.map(\.spineIndex))"
|
||
)
|
||
|
||
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: initialChapters)
|
||
runtime.applyBookPageMap(partialMap, restoreLocation: restoreLocation)
|
||
runtime.prefetchForwardChaptersAfterInitialOpen(
|
||
anchorSpineIndex: runtimeChapter.spineIndex,
|
||
totalSpineCount: publication.spine.count
|
||
)
|
||
let cancellationController = self.beginMetadataParseCancellationController(for: token)
|
||
let worker = RDEPUBMetadataParseWorker(
|
||
context: context,
|
||
cancellationController: cancellationController,
|
||
token: token,
|
||
parser: parser,
|
||
publication: publication
|
||
)
|
||
RDEPUBBackgroundTrace.log(
|
||
"Pagination",
|
||
"startingMetadataWorker token=\(token.uuidString) anchorSpine=\(runtimeChapter.spineIndex) partialPages=\(partialMap.totalPages) partialChapters=\(partialMap.totalChapters)"
|
||
)
|
||
worker.start(token: token, restoreLocation: restoreLocation)
|
||
}
|
||
} catch {
|
||
RDEPUBBackgroundTrace.log(
|
||
"Pagination",
|
||
"initialPaginationFailed error=\(String(describing: error)) prioritizedCandidates=\(prioritizedCandidates)"
|
||
)
|
||
DispatchQueue.main.async {
|
||
guard context.paginationToken == token,
|
||
context.controller != nil else { return }
|
||
context.handle(error: error)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func loadFirstRenderableRuntimeChapter(
|
||
prioritizedSpineIndices: [Int],
|
||
runtime: RDEPUBReaderRuntime,
|
||
layoutSnapshot: RDEPUBLayoutSnapshot
|
||
) throws -> RDEPUBRuntimeChapter {
|
||
var lastError: Error?
|
||
for spineIndex in prioritizedSpineIndices {
|
||
do {
|
||
return try runtime.chapterLoader.loadChapterSynchronouslyForMigration(
|
||
spineIndex: spineIndex,
|
||
store: runtime.chapterRuntimeStore,
|
||
layoutSnapshot: layoutSnapshot
|
||
)
|
||
} catch {
|
||
lastError = error
|
||
}
|
||
}
|
||
throw lastError ?? RDEPUBParserError.emptySpine
|
||
}
|
||
|
||
private func loadInitialInteractiveRuntimeChapters(
|
||
anchorChapter: RDEPUBRuntimeChapter,
|
||
publication: RDEPUBPublication,
|
||
runtime: RDEPUBReaderRuntime,
|
||
layoutSnapshot: RDEPUBLayoutSnapshot
|
||
) -> [RDEPUBRuntimeChapter] {
|
||
let minimumInteractivePageCount = 2
|
||
let maximumAdditionalChapters = 1
|
||
|
||
guard anchorChapter.pages.count < minimumInteractivePageCount else {
|
||
return [anchorChapter]
|
||
}
|
||
|
||
let buildableSpineIndices = publication.spine.indices.filter { isBuildableTextSpine(at: $0, in: publication) }
|
||
guard let anchorPosition = buildableSpineIndices.firstIndex(of: anchorChapter.spineIndex) else {
|
||
return [anchorChapter]
|
||
}
|
||
|
||
var selectedChapters: [RDEPUBRuntimeChapter] = [anchorChapter]
|
||
|
||
for offset in 1...maximumAdditionalChapters {
|
||
let candidatePositions = [anchorPosition + offset, anchorPosition - offset]
|
||
for candidatePosition in candidatePositions {
|
||
guard buildableSpineIndices.indices.contains(candidatePosition) else { continue }
|
||
let spineIndex = buildableSpineIndices[candidatePosition]
|
||
guard selectedChapters.contains(where: { $0.spineIndex == spineIndex }) == false else { continue }
|
||
|
||
do {
|
||
let chapter = try runtime.chapterLoader.loadChapterSynchronouslyForMigration(
|
||
spineIndex: spineIndex,
|
||
store: runtime.chapterRuntimeStore,
|
||
layoutSnapshot: layoutSnapshot
|
||
)
|
||
selectedChapters.append(chapter)
|
||
} catch {
|
||
#if DEBUG
|
||
print("[RDEPUBReaderPaginationCoordinator] ⚠️ Failed to load chapter at spineIndex \(spineIndex): \(error)")
|
||
#endif
|
||
}
|
||
}
|
||
|
||
let loadedPageCount = selectedChapters.reduce(0) { $0 + $1.pages.count }
|
||
if loadedPageCount >= minimumInteractivePageCount {
|
||
break
|
||
}
|
||
}
|
||
|
||
return selectedChapters
|
||
}
|
||
|
||
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 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"))
|
||
}
|
||
|
||
func cancelActiveMetadataParseWork() {
|
||
metadataParseControlLock.lock()
|
||
let controller = activeMetadataParseCancellationController
|
||
activeMetadataParseCancellationController = nil
|
||
metadataParseControlLock.unlock()
|
||
controller?.cancel()
|
||
}
|
||
|
||
func finishMetadataParseCancellationController(_ controller: RDEPUBMetadataParseCancellationController) {
|
||
metadataParseControlLock.lock()
|
||
if activeMetadataParseCancellationController === controller {
|
||
activeMetadataParseCancellationController = nil
|
||
}
|
||
metadataParseControlLock.unlock()
|
||
}
|
||
|
||
private func beginMetadataParseCancellationController(for token: UUID) -> RDEPUBMetadataParseCancellationController {
|
||
let controller = RDEPUBMetadataParseCancellationController(token: token)
|
||
metadataParseControlLock.lock()
|
||
let previous = activeMetadataParseCancellationController
|
||
activeMetadataParseCancellationController = controller
|
||
metadataParseControlLock.unlock()
|
||
previous?.cancel()
|
||
return controller
|
||
}
|
||
}
|