import UIKit enum RDEPUBPendingPageMapUpdateKind { case reconcileFullMap case extendPartial(currentPageNumber: Int, currentLocation: RDEPUBLocation?) case appendForward } struct RDEPUBPendingPageMapUpdate { let pageMap: RDEPUBBookPageMap let source: RDEPUBPaginationStateSource let kind: RDEPUBPendingPageMapUpdateKind } final class RDEPUBPresentationRuntime { private unowned let context: RDEPUBReaderContext private unowned let locationCoordinator: RDEPUBReaderLocationCoordinator private unowned let jumpSessionManager: RDEPUBJumpSessionManager private unowned let reconciliationCoordinator: RDEPUBPageMapReconciliationCoordinator let navigationStateMachine = RDEPUBNavigationStateMachine() private(set) var paginationState = RDEPUBPaginationState() init( context: RDEPUBReaderContext, locationCoordinator: RDEPUBReaderLocationCoordinator, jumpSessionManager: RDEPUBJumpSessionManager, reconciliationCoordinator: RDEPUBPageMapReconciliationCoordinator ) { self.context = context self.locationCoordinator = locationCoordinator self.jumpSessionManager = jumpSessionManager self.reconciliationCoordinator = reconciliationCoordinator } func applyBookPageMap( _ bookPageMap: RDEPUBBookPageMap, restoreLocation: RDEPUBLocation?, finishPagination: (RDEPUBLocation?) -> Void ) { navigationStateMachine.transition(to: .presentingWindow) context.textBook = nil context.bookPageMap = bookPageMap context.pendingPageMapUpdates.removeAll() context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap)) paginationState.activePageMap = bookPageMap paginationState.pendingPageMapUpdates.removeAll() paginationState.source = .initialPartial finishPagination(restoreLocation) } func refreshBookPageMapInPlace(_ bookPageMap: RDEPUBBookPageMap) { navigationStateMachine.transition(to: .reconcilingFullMap) enqueuePendingPageMapUpdate( RDEPUBPendingPageMapUpdate( pageMap: bookPageMap, source: .pendingFullMap, kind: .reconcileFullMap ) ) } func commitPendingPageMapUpdateIfNeeded() { guard let readerView = context.readerView, let controller = context.controller else { return } guard !controller.isRepaginating else { return } guard !readerView.isPageCurlTransitioning else { RDEPUBBackgroundTrace.log("PageMapCommit", "defer commit reason=pageCurlTransition") return } let rankedUpdates = rankedPendingPageMapUpdates() for (index, update) in rankedUpdates { if commitPendingPageMapUpdate( update, at: index, readerView: readerView, controller: controller ) { return } } } func queueExtendedPartialPageMap( _ bookPageMap: RDEPUBBookPageMap, currentPageNumber: Int, currentLocation: RDEPUBLocation? ) { enqueuePendingPageMapUpdate( RDEPUBPendingPageMapUpdate( pageMap: bookPageMap, source: .asyncExtension, kind: .extendPartial( currentPageNumber: currentPageNumber, currentLocation: currentLocation ) ) ) } func applySettingsPreviewPageMap(_ bookPageMap: RDEPUBBookPageMap) { navigationStateMachine.transition(to: .presentingWindow) context.bookPageMap = bookPageMap context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap)) paginationState.activePageMap = bookPageMap paginationState.source = .settingsPreview } func queueForwardAppendedPageMap(_ bookPageMap: RDEPUBBookPageMap) { enqueuePendingPageMapUpdate( RDEPUBPendingPageMapUpdate( pageMap: bookPageMap, source: .asyncExtension, kind: .appendForward ) ) } func clear() { paginationState = RDEPUBPaginationState() navigationStateMachine.transition(to: .idle) } func makeSnapshot(from bookPageMap: RDEPUBBookPageMap) -> RDEPUBReadingSession.PaginationSnapshot { let pages = bookPageMap.entries.flatMap { entry in (0.. [(Int, RDEPUBPendingPageMapUpdate)] { context.pendingPageMapUpdates.enumerated().sorted { lhs, rhs in pendingPriority(for: lhs.element.kind) > pendingPriority(for: rhs.element.kind) } } private func commitPendingPageMapUpdate( _ update: RDEPUBPendingPageMapUpdate, at index: Int, readerView: RDReaderView, controller: RDEPUBReaderController ) -> Bool { switch update.kind { case .reconcileFullMap: navigationStateMachine.transition(to: .reconcilingFullMap) let decision = reconciliationCoordinator.evaluateTakeover( candidatePageMap: update.pageMap, candidateSegment: nil, currentWindow: context.bookPageMap, jumpSession: jumpSessionManager.activeSession ) switch decision { case .keepCurrentWindow: RDEPUBBackgroundTrace.log("Reconciliation", "decision: keepCurrentWindow — pending update removed to prevent indefinite retry") removePendingPageMapUpdate(at: index) return false case .fullReplace(let newPageMap): RDEPUBBackgroundTrace.log("Reconciliation", "decision: fullReplace") removePendingPageMapUpdate(at: index) applyFullPageMapReplacement(newPageMap, readerView: readerView, controller: controller) return true case .expandWindow, .segmentReplace: RDEPUBBackgroundTrace.log("Reconciliation", "decision: unexpected segment decision") removePendingPageMapUpdate(at: index) return false } case .extendPartial(let currentPageNumber, let currentLocation): removePendingPageMapUpdate(at: index) applyPageMapToLiveModel(update.pageMap, source: update.source) if let currentLocation, rebindVisibleLocation(currentLocation, readerView: readerView, controller: controller) { return true } // Fallback: prefer the live readerView.currentPage over the stale captured // currentPageNumber, which may be outdated by the time this commit runs // (especially in pageCurl mode where the user may have turned several pages // since the extension was initiated). let livePageIndex = max(readerView.currentPage, 0) rebindVisiblePage( to: livePageIndex, readerView: readerView ) return true case .appendForward: removePendingPageMapUpdate(at: index) applyPageMapToLiveModel(update.pageMap, source: update.source) readerView.reloadPageCountOnly() return true } } private func rebindVisiblePage(to pageIndex: Int, readerView: RDReaderView) { if readerView.currentDisplayType == .pageCurl { if readerView.isPageCurlTransitioning { // Defer the transition until the current page-curl animation completes, // and re-read the live page at that time to avoid jumping to a stale position. DispatchQueue.main.async { [weak readerView] in guard let readerView, !readerView.isPageCurlTransitioning else { return } let livePageIndex = max(readerView.currentPage, 0) readerView.transitionToPage(pageNum: livePageIndex, animated: false) } } else { readerView.transitionToPage(pageNum: pageIndex, animated: false) } } else { readerView.reloadPageCountOnly() if pageIndex != readerView.currentPage { readerView.transitionToPage(pageNum: pageIndex, animated: false) } } } private func rebindVisibleLocation( _ location: RDEPUBLocation, readerView: RDReaderView, controller: RDEPUBReaderController ) -> Bool { guard let targetPageNumber = controller.pageNumber(for: location) else { return false } if context.bookPageMap != nil, context.runtime?.prepareOnDemandChapter( forAbsolutePageNumber: targetPageNumber, allowSynchronousLoad: true ) == false { return false } rebindVisiblePage( to: max(targetPageNumber - 1, 0), readerView: readerView ) return true } private func applyPageMapToLiveModel( _ pageMap: RDEPUBBookPageMap, source: RDEPUBPaginationStateSource ) { navigationStateMachine.transition(to: .presentingWindow) context.bookPageMap = pageMap context.replaceActiveSnapshot(makeSnapshot(from: pageMap)) discardSupersededPendingPageMapUpdates(afterApplying: pageMap) paginationState.activePageMap = pageMap paginationState.source = source } private func removePendingPageMapUpdate(at index: Int) { var updates = context.pendingPageMapUpdates guard updates.indices.contains(index) else { return } updates.remove(at: index) context.pendingPageMapUpdates = updates paginationState.pendingPageMapUpdates = updates } private func discardSupersededPendingPageMapUpdates(afterApplying liveMap: RDEPUBBookPageMap) { let updates = context.pendingPageMapUpdates.filter { update in update.pageMap.totalChapters > liveMap.totalChapters || ( update.pageMap.totalChapters == liveMap.totalChapters && update.pageMap.totalPages > liveMap.totalPages ) } context.pendingPageMapUpdates = updates paginationState.pendingPageMapUpdates = updates } private func pendingPriority(for kind: RDEPUBPendingPageMapUpdateKind) -> Int { switch kind { case .extendPartial: return 3 case .appendForward: return 2 case .reconcileFullMap: return 1 } } private func pendingPageMapUpdateKindMatches( _ lhs: RDEPUBPendingPageMapUpdateKind, _ rhs: RDEPUBPendingPageMapUpdateKind ) -> Bool { switch (lhs, rhs) { case (.reconcileFullMap, .reconcileFullMap), (.appendForward, .appendForward), (.extendPartial, .extendPartial): return true default: return false } } private func shouldReplacePendingPageMapUpdate( _ existing: RDEPUBPendingPageMapUpdate, with candidate: RDEPUBPendingPageMapUpdate ) -> Bool { candidate.pageMap.totalChapters > existing.pageMap.totalChapters || ( candidate.pageMap.totalChapters == existing.pageMap.totalChapters && candidate.pageMap.totalPages >= existing.pageMap.totalPages ) } }