Add RDEPUBBackgroundTrace.log at key decision points in the reconciliation pipeline to trace why pageMap updates get rejected: 1. refreshBookPageMapInPlace — logs when a full pageMap update is enqueued 2. evaluateTakeover — logs when keepCurrentWindow is returned due to: - jumpSession coverage ratio < 0.8 - adjacent coverage missing (hasPrev/hasNext) - renderSignature mismatch 3. evaluateFullPageMapTakeover — logs when keepCurrentWindow is returned due to: - currentSpineIndex not in candidate chapters - adjacent coverage missing 4. commitPendingPageMapUpdate — logs when keepCurrentWindow decision is applied and pending update is removed from queue Also fix unused variable warning for currentPageNumber in extendPartial case. Co-Authored-By: Claude <noreply@anthropic.com>
388 lines
14 KiB
Swift
388 lines
14 KiB
Swift
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)
|
|
RDEPUBBackgroundTrace.log(
|
|
"Reconciliation",
|
|
"refreshBookPageMapInPlace: enqueued reconcileFullMap chapters=\(bookPageMap.totalChapters) pages=\(bookPageMap.totalPages)"
|
|
)
|
|
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 {
|
|
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..<entry.pageCount).map { localPageIndex in
|
|
EPUBPage(
|
|
spineIndex: entry.spineIndex,
|
|
chapterIndex: bookPageMap.chapterIndex(forSpineIndex: entry.spineIndex) ?? 0,
|
|
pageIndexInChapter: localPageIndex,
|
|
totalPagesInChapter: entry.pageCount,
|
|
chapterTitle: entry.title,
|
|
fixedSpread: nil
|
|
)
|
|
}
|
|
}
|
|
let chapters = bookPageMap.entries.map { entry in
|
|
EPUBChapterInfo(
|
|
spineIndex: entry.spineIndex,
|
|
title: entry.title,
|
|
pageCount: entry.pageCount
|
|
)
|
|
}
|
|
return (pages, chapters)
|
|
}
|
|
|
|
private func applyFullPageMapReplacement(
|
|
_ newPageMap: RDEPUBBookPageMap,
|
|
readerView: RDReaderView,
|
|
controller: RDEPUBReaderController
|
|
) {
|
|
let currentLocation = locationCoordinator.currentVisibleLocation()
|
|
|
|
context.textBook = nil
|
|
applyPageMapToLiveModel(newPageMap, source: .fullReplacement)
|
|
|
|
if let currentLocation {
|
|
if rebindVisibleLocation(currentLocation, readerView: readerView, controller: controller) == false {
|
|
let newPageNumber = controller.pageNumber(for: currentLocation) ?? (readerView.currentPage + 1)
|
|
let newPage = max(0, newPageNumber - 1)
|
|
rebindVisiblePage(
|
|
to: newPage,
|
|
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
|
|
paginationState.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: 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",
|
|
"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 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
|
|
)
|
|
}
|
|
}
|