385 lines
16 KiB
Swift
385 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() {
|
|
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 }
|
|
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 allBuildableSpineIndices(in publication: RDEPUBPublication) -> [Int] {
|
|
publication.spine.indices.filter { isBuildableTextSpine(at: $0, in: publication) }
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|