ReadViewSDK/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift

389 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()
if readerView.currentDisplayType == .pageCurl, readerView.currentPage >= 0 {
readerView.transitionToPage(pageNum: readerView.currentPage, animated: false)
} else {
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
}
}