refactor: rename RDReaderView -> RDEpubReaderView, update pod config and docs
- Rename source module from RDReaderView to RDEpubReaderView - Move all source files from Sources/RDReaderView/ to Sources/RDEpubReaderView/ - Update podspec: RDReaderView.podspec -> RDEpubReaderView.podspec - Update Podfile, demo project, and CocoaPods config for new pod name - Delete old RDReaderView pod support files from ReadViewDemo/Pods - Add new RDEpubReaderView pod support files - Update documentation (API ref, architecture, UML, conventions, etc.) - Add FixedLayoutRotationTests - Update .gitignore: exclude .DS_Store, manual unpack backups, _ssoft-output
This commit is contained in:
+392
@@ -0,0 +1,392 @@
|
||||
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 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user