ReadViewSDK/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift
2026-07-13 13:01:14 +09:00

388 lines
16 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}
}