import UIKit enum RDEPUBPendingPageMapUpdateKind { case reconcileFullMap case extendPartial(currentPageNumber: Int, currentLocation: RDEPUBLocation?) case appendForward } struct RDEPUBPendingPageMapUpdate { let pageMap: RDEPUBBookPageMap 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 private var pendingCommitRetryWorkItem: DispatchWorkItem? 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 ) { context.textBook = nil context.bookPageMap = bookPageMap context.pendingPageMapUpdates.removeAll() context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap)) finishPagination(restoreLocation) } func refreshBookPageMapInPlace(_ bookPageMap: RDEPUBBookPageMap) { RDEPUBBackgroundTrace.log( "Reconciliation", "refreshBookPageMapInPlace: enqueued reconcileFullMap chapters=\(bookPageMap.totalChapters) pages=\(bookPageMap.totalPages)" ) enqueuePendingPageMapUpdate( RDEPUBPendingPageMapUpdate( pageMap: bookPageMap, kind: .reconcileFullMap ) ) } func commitPendingPageMapUpdateIfNeeded() { guard let readerView = context.readerView, let controller = context.controller else { return } guard !context.pendingPageMapUpdates.isEmpty else { cancelPendingCommitRetry() return } guard !controller.isRepaginating else { schedulePendingCommitRetry() return } guard !readerView.isPageCurlTransitioning else { schedulePendingCommitRetry() return } cancelPendingCommitRetry() let rankedUpdates = rankedPendingPageMapUpdates() for (index, update) in rankedUpdates { if commitPendingPageMapUpdate( update, at: index, readerView: readerView, controller: controller ) { if !context.pendingPageMapUpdates.isEmpty { schedulePendingCommitRetry() } return } } if !context.pendingPageMapUpdates.isEmpty { schedulePendingCommitRetry() } } func queueExtendedPartialPageMap( _ bookPageMap: RDEPUBBookPageMap, currentPageNumber: Int, currentLocation: RDEPUBLocation? ) { enqueuePendingPageMapUpdate( RDEPUBPendingPageMapUpdate( pageMap: bookPageMap, kind: .extendPartial( currentPageNumber: currentPageNumber, currentLocation: currentLocation ) ) ) } func applySettingsPreviewPageMap(_ bookPageMap: RDEPUBBookPageMap) { context.bookPageMap = bookPageMap context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap)) } func queueForwardAppendedPageMap(_ bookPageMap: RDEPUBBookPageMap) { enqueuePendingPageMapUpdate( RDEPUBPendingPageMapUpdate( pageMap: bookPageMap, kind: .appendForward ) ) } func makeSnapshot(from bookPageMap: RDEPUBBookPageMap) -> RDEPUBReadingSession.PaginationSnapshot { let pages = bookPageMap.entries.flatMap { entry in (0.. full-book takeover). let oldResolvedPage = currentLocation.flatMap { controller.pageNumber(for: $0) } context.textBook = nil applyPageMapToLiveModel(newPageMap) RDEPUBBackgroundTrace.log( "Reconciliation", "applyFullPageMapReplacement livePageBeforeApply=\(livePageBeforeApply) totalPages=\(newPageMap.totalPages) totalChapters=\(newPageMap.totalChapters)" ) if let currentLocation { let resolvedTargetPage = controller.pageNumber(for: currentLocation) let shouldTrustResolvedLocation = shouldTrustFullReplaceResolvedPage( resolvedTargetPage, livePageBeforeApply: livePageBeforeApply, locationMatchesLivePage: oldResolvedPage == livePageBeforeApply ) RDEPUBBackgroundTrace.log( "Reconciliation", "applyFullPageMapReplacement decision livePage=\(livePageBeforeApply) oldResolvedPage=\(oldResolvedPage ?? -1) resolvedTargetPage=\(resolvedTargetPage ?? -1) trustResolved=\(shouldTrustResolvedLocation) href=\(currentLocation.href)" ) if shouldTrustResolvedLocation, rebindVisibleLocation(currentLocation, readerView: readerView, controller: controller) { return } let fallbackPage = max(livePageBeforeApply, 1) RDEPUBBackgroundTrace.log( "Reconciliation", "applyFullPageMapReplacement preserveLivePage fallbackPage=\(fallbackPage) currentReaderPage=\(readerView.currentPage + 1)" ) rebindVisiblePage( to: fallbackPage - 1, readerView: readerView ) } else { readerView.reloadPageCountOnly() } if let currentLocation, context.normalizedSpineIndex(for: currentLocation) != nil, let activeSession = jumpSessionManager.activeSession { let candidateIndices = Set(newPageMap.entries.map { $0.spineIndex }) let protectedIndices = activeSession.protectedSpineIndices if protectedIndices.isSubset(of: candidateIndices) { jumpSessionManager.endSession(.coverageComplete) } } } private func enqueuePendingPageMapUpdate(_ update: RDEPUBPendingPageMapUpdate) { var updates = context.pendingPageMapUpdates if let existingIndex = updates.firstIndex(where: { pendingPageMapUpdateKindMatches($0.kind, update.kind) }) { let existing = updates[existingIndex] if shouldReplacePendingPageMapUpdate(existing, with: update) { updates[existingIndex] = update } } else { updates.append(update) } context.pendingPageMapUpdates = updates commitPendingPageMapUpdateIfNeeded() } private func rankedPendingPageMapUpdates() -> [(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: RDEpubReaderView, controller: RDEPUBReaderController ) -> Bool { switch update.kind { case .reconcileFullMap: let decision = reconciliationCoordinator.evaluateTakeover( candidatePageMap: update.pageMap, candidateSegment: nil, currentWindow: context.bookPageMap, jumpSession: jumpSessionManager.activeSession ) switch decision { case .keepCurrentWindow: RDEPUBBackgroundTrace.log( "Reconciliation", "commitPendingPageMapUpdate: keepCurrentWindow — removed pending update, totalPages=\(update.pageMap.totalPages)" ) removePendingPageMapUpdate(at: index) return false case .fullReplace(let newPageMap): removePendingPageMapUpdate(at: index) applyFullPageMapReplacement(newPageMap, readerView: readerView, controller: controller) return true case .expandWindow, .segmentReplace: removePendingPageMapUpdate(at: index) return false } case .extendPartial(let capturedPageNumber, let currentLocation): removePendingPageMapUpdate(at: index) applyPageMapToLiveModel(update.pageMap) let livePageNumber = max(readerView.currentPage, 0) + 1 let shouldTrustCapturedLocation = livePageNumber == max(capturedPageNumber, 1) RDEPUBBackgroundTrace.log( "Reconciliation", "extendPartial commit capturedPage=\(capturedPageNumber) livePage=\(livePageNumber) trustCaptured=\(shouldTrustCapturedLocation) totalPages=\(update.pageMap.totalPages) totalChapters=\(update.pageMap.totalChapters)" ) if shouldTrustCapturedLocation, let currentLocation, rebindVisibleLocation(currentLocation, readerView: readerView, controller: controller) { return true } // Prefer the live readerView page when the user has moved since the // extension request was created; otherwise a stale captured location can // snap pageCurl back to the previous page after the turn completes. let livePageIndex = max(readerView.currentPage, 0) rebindVisiblePage( to: livePageIndex, readerView: readerView ) return true case .appendForward: removePendingPageMapUpdate(at: index) applyPageMapToLiveModel(update.pageMap) readerView.reloadPageCountOnly() return true } } private func rebindVisiblePage(to pageIndex: Int, readerView: RDEpubReaderView) { if pageIndex == readerView.currentPage { RDEPUBBackgroundTrace.log( "Reconciliation", "rebindVisiblePage pageUnchanged currentPage=\(readerView.currentPage + 1) displayType=\(readerView.currentDisplayType)" ) readerView.reloadPageCountOnly() return } RDEPUBBackgroundTrace.log( "Reconciliation", "rebindVisiblePage targetPage=\(pageIndex + 1) currentPage=\(readerView.currentPage + 1) displayType=\(readerView.currentDisplayType) isPageCurlTransitioning=\(readerView.isPageCurlTransitioning)" ) 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) RDEPUBBackgroundTrace.log( "Reconciliation", "rebindVisiblePage deferredTransition livePage=\(livePageIndex + 1)" ) 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: RDEpubReaderView, controller: RDEPUBReaderController ) -> Bool { guard let targetPageNumber = controller.pageNumber(for: location) else { RDEPUBBackgroundTrace.log( "Reconciliation", "rebindVisibleLocation failedToResolve locationHref=\(location.href) currentPage=\(readerView.currentPage + 1)" ) return false } if context.bookPageMap != nil, context.runtime?.prepareOnDemandChapter( forAbsolutePageNumber: targetPageNumber, allowSynchronousLoad: false ) == false { RDEPUBBackgroundTrace.log( "Reconciliation", "rebindVisibleLocation prepareOnDemandBlocked targetPage=\(targetPageNumber) currentPage=\(readerView.currentPage + 1)" ) return false } RDEPUBBackgroundTrace.log( "Reconciliation", "rebindVisibleLocation targetPage=\(targetPageNumber) currentPage=\(readerView.currentPage + 1) href=\(location.href)" ) rebindVisiblePage( to: max(targetPageNumber - 1, 0), readerView: readerView ) return true } private func applyPageMapToLiveModel(_ pageMap: RDEPUBBookPageMap) { context.bookPageMap = pageMap context.replaceActiveSnapshot(makeSnapshot(from: pageMap)) discardSupersededPendingPageMapUpdates(afterApplying: pageMap) } private func removePendingPageMapUpdate(at index: Int) { var updates = context.pendingPageMapUpdates guard updates.indices.contains(index) else { return } updates.remove(at: index) context.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 } 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 ) } private func shouldTrustFullReplaceResolvedPage( _ resolvedTargetPage: Int?, livePageBeforeApply: Int, locationMatchesLivePage: Bool ) -> Bool { guard let resolvedTargetPage else { return false } if locationMatchesLivePage { return true } // The location did not round-trip to the live page in the outgoing map // (stale persisted location or mid-transition), so only follow it when it // stays next to the page the user is actually looking at. return abs(resolvedTargetPage - livePageBeforeApply) <= 1 } private func schedulePendingCommitRetry() { guard pendingCommitRetryWorkItem == nil else { return } let workItem = DispatchWorkItem { [weak self] in guard let self else { return } self.pendingCommitRetryWorkItem = nil self.commitPendingPageMapUpdateIfNeeded() } pendingCommitRetryWorkItem = workItem DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: workItem) } private func cancelPendingCommitRetry() { pendingCommitRetryWorkItem?.cancel() pendingCommitRetryWorkItem = nil } }