import UIKit final class RDEPUBReaderRuntime { private unowned let context: RDEPUBReaderContext lazy var chapterRuntimeStore = RDEPUBChapterRuntimeStore() lazy var summaryDiskCache = context.makeChapterSummaryDiskCache() lazy var chapterLoader: RDEPUBChapterLoader = { let loader = RDEPUBChapterLoader(context: context) loader.setSummaryDiskCache(summaryDiskCache) return loader }() lazy var pageResolver = RDEPUBPageResolver(context: context, store: chapterRuntimeStore) lazy var loadCoordinator = RDEPUBReaderLoadCoordinator(context: context) lazy var paginationCoordinator = RDEPUBReaderPaginationCoordinator(context: context) lazy var locationCoordinator = RDEPUBReaderLocationCoordinator(context: context) lazy var searchCoordinator = RDEPUBReaderSearchCoordinator(context: context) lazy var chromeCoordinator = RDEPUBReaderChromeCoordinator(context: context) lazy var annotationCoordinator = RDEPUBReaderAnnotationCoordinator(context: context) lazy var viewportMonitor = RDEPUBReaderViewportMonitor(context: context) lazy var jumpSessionManager = RDEPUBJumpSessionManager(context: context) lazy var backgroundPriorityManager = RDEPUBBackgroundPriorityManager(context: context) lazy var backgroundCoverageStore = RDEPUBBackgroundCoverageStore(context: context) lazy var reconciliationCoordinator = RDEPUBPageMapReconciliationCoordinator(context: context) var isSettingsPanelOpen: Bool = false var needsFullRepaginationAfterSettingsClose: Bool = false private var settingsPreviewGeneration: Int = 0 private var pendingSettingsPreviewWorkItem: DispatchWorkItem? private let settingsPreviewDebounceDelay: TimeInterval = 0.2 private struct SettingsPreviewAnchor { let spineIndex: Int let href: String let offset: Int } init(context: RDEPUBReaderContext) { self.context = context } func makeTopToolView() -> RDEPUBReaderTopToolView { chromeCoordinator.makeTopToolView() } func makeBottomToolView() -> RDEPUBReaderBottomToolView { chromeCoordinator.makeBottomToolView() } func startInitialLoadIfNeeded() { loadCoordinator.startInitialLoadIfNeeded() } func reloadBook() { guard let readerView = context.readerView else { return } context.didStartInitialLoad = false context.parser = nil context.publication = nil context.clearActiveSnapshot() context.readingSession = nil context.textBook = nil context.bookPageMap = nil context.pendingFullPageMap = nil context.activeBookmarks = [] context.activeHighlights = [] context.searchState = nil clearOnDemandPageModeState() viewportMonitor.resetForReload() annotationCoordinator.updateCurrentSelection(nil) readerView.reloadData() startInitialLoadIfNeeded() } func go(to location: RDEPUBLocation, animated: Bool = false) -> Bool { locationCoordinator.restoreReadingLocation(location, animated: animated) } @discardableResult func go(toPageNumber pageNumber: Int, animated: Bool = false) -> Bool { guard let controller = context.controller, let readerView = context.readerView, pageNumber > 0 else { return false } if let textBook = context.textBook { guard textBook.page(at: pageNumber) != nil else { return false } readerView.transitionToPage(pageNum: pageNumber - 1, animated: animated) if let location = locationCoordinator.currentVisibleLocation() { context.persist(location: location) } return true } if context.bookPageMap != nil { guard prepareOnDemandChapter(forAbsolutePageNumber: pageNumber) else { return false } readerView.transitionToPage(pageNum: pageNumber - 1, animated: animated) if let location = locationCoordinator.currentVisibleLocation() { context.persist(location: location) } return true } guard context.activePages.indices.contains(pageNumber - 1) else { return false } readerView.transitionToPage(pageNum: pageNumber - 1, animated: animated) if let location = locationCoordinator.currentVisibleLocation() { context.persist(location: location) } return true } func clearSelection() { annotationCoordinator.updateCurrentSelection(nil) } func bookmark(withID id: String) -> RDEPUBBookmark? { annotationCoordinator.bookmark(withID: id) } func highlight(withID id: String) -> RDEPUBHighlight? { annotationCoordinator.highlight(withID: id) } @discardableResult func addHighlight( from selection: RDEPUBSelection? = nil, color: String = "#F8E16C", note: String? = nil ) -> RDEPUBHighlight? { annotationCoordinator.addHighlight(from: selection, color: color, note: note) } @discardableResult func addAnnotation( from selection: RDEPUBSelection? = nil, style: RDEPUBHighlightStyle, color: String = "#F8E16C", note: String? = nil ) -> RDEPUBHighlight? { annotationCoordinator.addAnnotation(from: selection, style: style, color: color, note: note) } @discardableResult func upsertHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight? { annotationCoordinator.upsertHighlight(highlight) } @discardableResult func removeHighlight(id: String) -> RDEPUBHighlight? { annotationCoordinator.removeHighlight(id: id) } @discardableResult func updateHighlightNote(id: String, note: String?) -> RDEPUBHighlight? { annotationCoordinator.updateHighlightNote(id: id, note: note) } @discardableResult func go(toHighlightID id: String, animated: Bool = true) -> Bool { annotationCoordinator.go(toHighlightID: id, animated: animated) } func removeAllHighlights() { annotationCoordinator.removeAllHighlights() } @discardableResult func addBookmark(note: String? = nil) -> RDEPUBBookmark? { annotationCoordinator.addBookmark(note: note) } @discardableResult func toggleBookmark(note: String? = nil) -> RDEPUBBookmark? { annotationCoordinator.toggleBookmark(note: note) } @discardableResult func removeBookmark(id: String) -> RDEPUBBookmark? { annotationCoordinator.removeBookmark(id: id) } @discardableResult func go(toBookmarkID id: String, animated: Bool = true) -> Bool { annotationCoordinator.go(toBookmarkID: id, animated: animated) } func presentBookmarksManager() { annotationCoordinator.presentBookmarksManager() } func presentHighlightsManager() { annotationCoordinator.presentHighlightsManager() } func presentAnnotationCreation() { annotationCoordinator.presentAnnotationCreation() } func presentHighlightActions(for highlight: RDEPUBHighlight, sourceView: UIView, sourceRect: CGRect) { annotationCoordinator.presentHighlightActions(for: highlight, sourceView: sourceView, sourceRect: sourceRect) } func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) { annotationCoordinator.handleSelectionMenuAction(action, selection: selection) } func search(keyword: String) { searchCoordinator.search(keyword: keyword) } @discardableResult func searchNext() -> Bool { searchCoordinator.searchNext() } @discardableResult func searchPrevious() -> Bool { searchCoordinator.searchPrevious() } @discardableResult func selectSearchMatch(at index: Int) -> Bool { searchCoordinator.selectSearchMatch(at: index) } func clearSearch() { searchCoordinator.clearSearch() } func searchPresentation(for page: EPUBPage) -> RDEPUBSearchPresentation? { searchCoordinator.searchPresentation(for: page) } func updateReaderChrome() { chromeCoordinator.updateReaderChrome() } func presentSettings() { chromeCoordinator.presentSettings() } func presentTableOfContents() { chromeCoordinator.presentTableOfContents() } func handleBackAction() { chromeCoordinator.handleBackAction() } func loadPublication() { loadCoordinator.loadPublication() } func applyParsedPublication( parser: RDEPUBParser, publication: RDEPUBPublication, bookIdentifier: String, restoreLocation: RDEPUBLocation?, bookmarks: [RDEPUBBookmark], highlights: [RDEPUBHighlight] ) { loadCoordinator.applyParsedPublication( parser: parser, publication: publication, bookIdentifier: bookIdentifier, restoreLocation: restoreLocation, bookmarks: bookmarks, highlights: highlights ) } func paginatePublication(restoreLocation: RDEPUBLocation?) { paginationCoordinator.paginatePublication(restoreLocation: restoreLocation) } func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) { paginationCoordinator.applyTextBook(textBook, restoreLocation: restoreLocation) } func applyPaginationSnapshot( _ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]), restoreLocation: RDEPUBLocation? ) { paginationCoordinator.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation) } func applyBookPageMap(_ bookPageMap: RDEPUBBookPageMap, restoreLocation: RDEPUBLocation?) { context.textBook = nil context.bookPageMap = bookPageMap context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap)) paginationCoordinator.finishPagination(restoreLocation: restoreLocation) } func refreshBookPageMapInPlace(_ bookPageMap: RDEPUBBookPageMap) { if let pendingMap = context.pendingFullPageMap { let shouldKeepExisting = pendingMap.totalChapters > bookPageMap.totalChapters || (pendingMap.totalChapters == bookPageMap.totalChapters && pendingMap.totalPages >= bookPageMap.totalPages) if shouldKeepExisting { return } } context.pendingFullPageMap = bookPageMap } func applyPendingFullPageMapIfNeeded() { guard let pendingMap = context.pendingFullPageMap, let readerView = context.readerView, let controller = context.controller else { return } guard !controller.isRepaginating else { return } let decision = reconciliationCoordinator.evaluateTakeover( candidatePageMap: pendingMap, candidateSegment: nil, currentWindow: context.bookPageMap, jumpSession: jumpSessionManager.activeSession ) switch decision { case .keepCurrentWindow: RDEPUBBackgroundTrace.log("Reconciliation", "decision: keepCurrentWindow") return case .fullReplace(let newPageMap): RDEPUBBackgroundTrace.log("Reconciliation", "decision: fullReplace") applyFullPageMapReplacement(newPageMap, readerView: readerView, controller: controller) case .expandWindow, .segmentReplace: RDEPUBBackgroundTrace.log("Reconciliation", "decision: unexpected segment decision") break } } private func applyFullPageMapReplacement( _ newPageMap: RDEPUBBookPageMap, readerView: RDReaderView, controller: RDEPUBReaderController ) { let currentLocation = locationCoordinator.currentVisibleLocation() context.pendingFullPageMap = nil context.textBook = nil context.bookPageMap = newPageMap context.replaceActiveSnapshot(makeSnapshot(from: newPageMap)) if let currentLocation { let newPageNumber = controller.pageNumber(for: currentLocation) ?? (readerView.currentPage + 1) let newPage = max(0, newPageNumber - 1) readerView.reloadPageCountOnly() if newPage != readerView.currentPage { readerView.transitionToPage(pageNum: newPage, animated: false) } } else { readerView.reloadPageCountOnly() } if let currentLocation, let currentSpineIndex = context.normalizedSpineIndex(for: currentLocation), let activeSession = jumpSessionManager.activeSession { let candidateIndices = Set(newPageMap.entries.map { $0.spineIndex }) let protectedIndices = activeSession.protectedSpineIndices let isFullyCovered = protectedIndices.isSubset(of: candidateIndices) if isFullyCovered { jumpSessionManager.endSession(.coverageComplete) } } } func finishPagination(restoreLocation: RDEPUBLocation?) { paginationCoordinator.finishPagination(restoreLocation: restoreLocation) } func repaginatePreservingCurrentLocation() { if isSettingsPanelOpen { needsFullRepaginationAfterSettingsClose = true paginationCoordinator.cancelActiveMetadataParseWork() scheduleSettingsPreviewRepagination() } else { paginationCoordinator.repaginatePreservingCurrentLocation() } } private func scheduleSettingsPreviewRepagination() { pendingSettingsPreviewWorkItem?.cancel() settingsPreviewGeneration += 1 let previewGeneration = settingsPreviewGeneration let workItem = DispatchWorkItem { [weak self] in guard let self, self.isSettingsPanelOpen, previewGeneration == self.settingsPreviewGeneration else { return } self.pendingSettingsPreviewWorkItem = nil let previewAnchor = self.captureSettingsPreviewAnchor() self.chapterRuntimeStore.invalidateAllForSettingsChange() self.repaginateCurrentChapterOnly( previewGeneration: previewGeneration, previewAnchor: previewAnchor ) } pendingSettingsPreviewWorkItem = workItem DispatchQueue.main.asyncAfter( deadline: .now() + settingsPreviewDebounceDelay, execute: workItem ) } private func captureSettingsPreviewAnchor() -> SettingsPreviewAnchor? { guard let bookPageMap = context.bookPageMap, let readerView = context.readerView else { return nil } let absolutePageIndex = readerView.currentPage guard absolutePageIndex >= 0, let spineIndex = bookPageMap.spineIndex(forAbsolutePage: absolutePageIndex), let localPageIndex = bookPageMap.localPageIndex(forAbsolutePage: absolutePageIndex), let chapter = chapterRuntimeStore.chapterData(for: spineIndex), chapter.pages.indices.contains(localPageIndex) else { return nil } let page = chapter.pages[localPageIndex] let offset = page.contentRange.length > 0 ? page.contentRange.location : page.pageStartOffset return SettingsPreviewAnchor( spineIndex: spineIndex, href: chapter.href, offset: offset ) } private func repaginateCurrentChapterOnly( previewGeneration: Int, previewAnchor: SettingsPreviewAnchor? ) { guard let bookPageMap = context.bookPageMap, let readerView = context.readerView else { return } let currentPageNumber = readerView.currentPage + 1 guard let currentSpineIndex = bookPageMap.spineIndex(forAbsolutePage: currentPageNumber - 1) else { return } let previewLocation = locationCoordinator.currentVisibleLocation() ?? context.persistenceLocation() RDEPUBBackgroundTrace.log( "Runtime", "repaginateCurrentChapterOnly spine=\(currentSpineIndex) generation=\(previewGeneration)" ) chapterLoader.loadChapter( spineIndex: currentSpineIndex, store: chapterRuntimeStore, priority: .preview ) { [weak self] result in guard let self, self.isSettingsPanelOpen, previewGeneration == self.settingsPreviewGeneration, let readerView = self.context.readerView else { return } switch result { case .success(let chapter): let partialMap = self.makePartialPageMap(from: [chapter]) self.context.bookPageMap = partialMap self.context.replaceActiveSnapshot(self.makeSnapshot(from: partialMap)) readerView.reloadData() if let targetPage = self.settingsPreviewTargetPage( in: chapter, for: previewAnchor ) { readerView.transitionToPage(pageNum: targetPage, animated: false) return } if let previewLocation, self.locationCoordinator.restoreReadingLocation(previewLocation, animated: false) { return } readerView.transitionToPage(pageNum: 0, animated: false) case .failure(let error): RDEPUBBackgroundTrace.log( "Runtime", "repaginateCurrentChapterOnly FAILED spine=\(currentSpineIndex) generation=\(previewGeneration) error=\(error)" ) } } } private func settingsPreviewTargetPage( in chapter: RDEPUBRuntimeChapter, for anchor: SettingsPreviewAnchor? ) -> Int? { guard let anchor, anchor.spineIndex == chapter.spineIndex, anchor.href == chapter.href, !chapter.pages.isEmpty else { return nil } if let exactPage = chapter.pages.first(where: { page in let lowerBound = page.contentRange.location let upperBound = page.contentRange.location + page.contentRange.length if page.contentRange.length == 0 { return anchor.offset == lowerBound } return anchor.offset >= lowerBound && anchor.offset < upperBound }) { return exactPage.pageIndexInChapter } if let nextPage = chapter.pages.first(where: { page in page.contentRange.location > anchor.offset }) { return nextPage.pageIndexInChapter } return max(chapter.pages.count - 1, 0) } func settingsPanelWillAppear() { pendingSettingsPreviewWorkItem?.cancel() pendingSettingsPreviewWorkItem = nil isSettingsPanelOpen = true needsFullRepaginationAfterSettingsClose = false settingsPreviewGeneration += 1 } func settingsPanelDidDisappear() { pendingSettingsPreviewWorkItem?.cancel() pendingSettingsPreviewWorkItem = nil isSettingsPanelOpen = false settingsPreviewGeneration += 1 if needsFullRepaginationAfterSettingsClose { needsFullRepaginationAfterSettingsClose = false RDEPUBBackgroundTrace.log("Runtime", "settingsPanelDidDisappear: triggering full repagination") paginationCoordinator.repaginatePreservingCurrentLocation() } } func refreshVisibleContentPreservingLocation() { paginationCoordinator.refreshVisibleContentPreservingLocation() } func rebuildExternalTextBook() { paginationCoordinator.rebuildExternalTextBook() } @discardableResult func restoreReadingLocation( _ location: RDEPUBLocation, animated: Bool = false, targetHighlightRangeInfo: String? = nil ) -> Bool { locationCoordinator.restoreReadingLocation( location, animated: animated, targetHighlightRangeInfo: targetHighlightRangeInfo ) } func currentVisibleLocation() -> RDEPUBLocation? { locationCoordinator.currentVisibleLocation() } func currentViewportSignature() -> RDEPUBViewportSignature? { viewportMonitor.currentViewportSignature() } func handleViewportChangeIfNeeded( reason: RDEPUBViewportChangeReason, viewportSignature: RDEPUBViewportSignature? = nil ) { viewportMonitor.handleViewportChangeIfNeeded(reason: reason, viewportSignature: viewportSignature) } @discardableResult func ensureOnDemandNavigationTargetAvailable(for location: RDEPUBLocation) -> Bool { guard context.bookPageMap != nil, let publication = context.publication, let targetSpineIndex = context.normalizedSpineIndex(for: location) else { return false } let currentSpineIndex = locationCoordinator.currentVisibleLocation() .flatMap { context.normalizedSpineIndex(for: $0) } let isDistantJump = if let current = currentSpineIndex { abs(current - targetSpineIndex) > context.configuration.jumpSessionPolicy.protectedNeighborRadius * 2 } else { false } if context.bookPageMap?.entry(forSpineIndex: targetSpineIndex) != nil { if isDistantJump { jumpSessionManager.createSession( anchorSpineIndex: targetSpineIndex, reason: .tableOfContentsJump, totalSpineCount: publication.spine.count ) } return true } if let pendingMap = context.pendingFullPageMap, pendingMap.entry(forSpineIndex: targetSpineIndex) != nil { applyPendingFullPageMapIfNeeded() if context.bookPageMap?.entry(forSpineIndex: targetSpineIndex) != nil { if isDistantJump { jumpSessionManager.createSession( anchorSpineIndex: targetSpineIndex, reason: .tableOfContentsJump, totalSpineCount: publication.spine.count ) } return true } } let buildableSpineIndices = publication.spine.indices.filter { index in let item = publication.spine[index] return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml")) } guard let anchorPosition = buildableSpineIndices.firstIndex(of: targetSpineIndex) else { return false } let normalizedWindowSize = RDEPUBReaderConfiguration.normalizedChapterWindowSize( context.configuration.onDemandChapterWindowSize ) let chapters = loadPartialWindowChapters( around: anchorPosition, in: buildableSpineIndices, targetSpineIndex: targetSpineIndex, windowSize: normalizedWindowSize ) guard !chapters.isEmpty else { return false } chapterRuntimeStore.setCurrentChapter( spineIndex: targetSpineIndex, totalSpineCount: publication.spine.count, windowRadius: context.configuration.chapterWindowRadius ) let partialMap = makePartialPageMap(from: chapters) context.bookPageMap = partialMap context.replaceActiveSnapshot(makeSnapshot(from: partialMap)) context.readerView?.reloadData() if isDistantJump { jumpSessionManager.createSession( anchorSpineIndex: targetSpineIndex, reason: .tableOfContentsJump, totalSpineCount: publication.spine.count ) backgroundPriorityManager.addWarmAnchor(spineIndex: targetSpineIndex) } return partialMap.entry(forSpineIndex: targetSpineIndex) != nil } @discardableResult func prepareOnDemandChapter(forAbsolutePageNumber pageNumber: Int) -> Bool { guard let bookPageMap = context.bookPageMap, let publication = context.publication else { return false } let absolutePageIndex = pageNumber - 1 guard let spineIndex = bookPageMap.spineIndex(forAbsolutePage: absolutePageIndex) else { return false } chapterRuntimeStore.setCurrentChapter( spineIndex: spineIndex, totalSpineCount: publication.spine.count, windowRadius: context.configuration.chapterWindowRadius ) RDEPUBBackgroundTrace.log( "Runtime", "prepareOnDemandChapter page=\(pageNumber) absoluteIndex=\(absolutePageIndex) spine=\(spineIndex)" ) if chapterRuntimeStore.chapterData(for: spineIndex) == nil { do { _ = try chapterLoader.loadChapterSynchronouslyForMigration( spineIndex: spineIndex, store: chapterRuntimeStore ) } catch { RDEPUBBackgroundTrace.log("Runtime", "prepareOnDemandChapter FAILED: spine=\(spineIndex) error=\(error)") return false } } for evictable in chapterRuntimeStore.evictableSpineIndices() { chapterRuntimeStore.evict(spineIndex: evictable) } for adjacentSpineIndex in chapterRuntimeStore.windowSpineIndices where adjacentSpineIndex != spineIndex { guard chapterRuntimeStore.chapterData(for: adjacentSpineIndex) == nil else { continue } chapterRuntimeStore.addPrefetchTarget(adjacentSpineIndex) RDEPUBBackgroundTrace.log( "Runtime", "schedule prefetch currentSpine=\(spineIndex) adjacentSpine=\(adjacentSpineIndex)" ) chapterLoader.loadChapter( spineIndex: adjacentSpineIndex, store: chapterRuntimeStore, priority: .prefetch ) { _ in } } return true } func extendPartialBookPageMapIfNeeded(currentPageNumber: Int, minimumTrailingPages: Int = 2, batchChapterCount: Int = 3) { guard let publication = context.publication, let currentMap = context.bookPageMap, let readerView = context.readerView else { return } let buildableSpineIndices = publication.spine.indices.filter { let item = publication.spine[$0] return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml")) } guard currentMap.totalChapters < buildableSpineIndices.count else { return } let currentSpineIndex = locationCoordinator.currentVisibleLocation() .flatMap { context.normalizedSpineIndex(for: $0) } let isNearEnd = currentMap.totalPages - currentPageNumber <= minimumTrailingPages let isNearStart = currentPageNumber <= minimumTrailingPages var spineIndicesToAppend: [Int] = [] if isNearEnd { let lastKnownSpineIndex = currentMap.entries.last?.spineIndex ?? -1 spineIndicesToAppend = Array(buildableSpineIndices.filter { $0 > lastKnownSpineIndex }.prefix(batchChapterCount)) } else if isNearStart { let firstKnownSpineIndex = currentMap.entries.first?.spineIndex ?? Int.max let prependCandidates = buildableSpineIndices.filter { $0 < firstKnownSpineIndex } spineIndicesToAppend = Array(prependCandidates.suffix(batchChapterCount)) } else { return } guard !spineIndicesToAppend.isEmpty else { return } RDEPUBBackgroundTrace.log( "Runtime", "extendPartialBookPageMap currentPage=\(currentPageNumber) totalPages=\(currentMap.totalPages) appendSpines=\(spineIndicesToAppend) direction=\(isNearEnd ? "forward" : "backward")" ) var appendedEntries: [RDEPUBBookPageMapEntry] = [] for spineIndex in spineIndicesToAppend { do { let chapter = try chapterLoader.loadChapterSynchronouslyForMigration( spineIndex: spineIndex, store: chapterRuntimeStore ) appendedEntries.append( RDEPUBBookPageMapEntry( spineIndex: chapter.spineIndex, href: chapter.href, title: chapter.title, pageCount: chapter.pages.count, absolutePageStart: 0, fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets ) ) } catch { RDEPUBBackgroundTrace.log("Runtime", "extendPartialBookPageMap skip spine=\(spineIndex) error=\(error)") } } guard !appendedEntries.isEmpty else { return } let combinedEntries = (currentMap.entries.map { RDEPUBBookPageMapEntry( spineIndex: $0.spineIndex, href: $0.href, title: $0.title, pageCount: $0.pageCount, absolutePageStart: 0, fragmentOffsets: $0.fragmentOffsets ) } + appendedEntries).sorted { $0.spineIndex < $1.spineIndex } var absolutePageStart = 0 let normalizedEntries = combinedEntries.map { entry -> RDEPUBBookPageMapEntry in let normalized = RDEPUBBookPageMapEntry( spineIndex: entry.spineIndex, href: entry.href, title: entry.title, pageCount: entry.pageCount, absolutePageStart: absolutePageStart, fragmentOffsets: entry.fragmentOffsets ) absolutePageStart += entry.pageCount return normalized } let newMap = RDEPUBBookPageMap(entries: normalizedEntries) RDEPUBBackgroundTrace.log( "Runtime", "extendPartialBookPageMap applied chapters=\(newMap.totalChapters) pages=\(newMap.totalPages)" ) context.bookPageMap = newMap context.replaceActiveSnapshot(makeSnapshot(from: newMap)) readerView.reloadData() readerView.transitionToPage(pageNum: max(currentPageNumber - 1, 0), animated: false) } func prefetchForwardChaptersAfterInitialOpen(anchorSpineIndex: Int, totalSpineCount: Int) { guard context.publication != nil else { return } chapterRuntimeStore.setCurrentChapter( spineIndex: anchorSpineIndex, totalSpineCount: totalSpineCount, windowRadius: context.configuration.chapterWindowRadius ) let forwardTargets = chapterRuntimeStore.windowSpineIndices.filter { $0 > anchorSpineIndex } guard !forwardTargets.isEmpty else { return } for spineIndex in forwardTargets { if chapterRuntimeStore.chapterData(for: spineIndex) != nil { appendLoadedForwardChaptersToCurrentPageMapIfPossible() continue } chapterRuntimeStore.addPrefetchTarget(spineIndex) RDEPUBBackgroundTrace.log( "Runtime", "initial open prefetch forward spine=\(spineIndex)" ) chapterLoader.loadChapter( spineIndex: spineIndex, store: chapterRuntimeStore, priority: .prefetch ) { [weak self] result in guard let self, case .success = result else { return } self.appendLoadedForwardChaptersToCurrentPageMapIfPossible() } } } func clearOnDemandPageModeState() { paginationCoordinator.cancelActiveMetadataParseWork() chapterRuntimeStore.invalidateAllForSettingsChange() context.bookPageMap = nil context.pendingFullPageMap = nil jumpSessionManager.clearSession() backgroundPriorityManager.reset() backgroundCoverageStore.clearAll() } func handleMemoryWarning() { let currentSpineIndex = locationCoordinator.currentVisibleLocation() .flatMap { context.normalizedSpineIndex(for: $0) } let activeWindowIndices: Set = if let currentSpineIndex { [currentSpineIndex, currentSpineIndex - 1, currentSpineIndex + 1] } else { [] } let protectedIndices = jumpSessionManager.activeSession?.protectedSpineIndices ?? [] backgroundCoverageStore.handleMemoryWarning( activeWindowSpineIndices: activeWindowIndices, protectedSpineIndices: protectedIndices ) } private func loadPartialWindowChapters( around anchorPosition: Int, in buildableSpineIndices: [Int], targetSpineIndex: Int, windowSize: Int ) -> [RDEPUBRuntimeChapter] { let lowerBound = max(anchorPosition - max(windowSize / 2, 0), 0) let upperBound = min(lowerBound + max(windowSize, 1), buildableSpineIndices.count) let startIndex = max(0, upperBound - max(windowSize, 1)) let window = Array(buildableSpineIndices[startIndex.. 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 appendLoadedForwardChaptersToCurrentPageMapIfPossible() { guard let publication = context.publication, let currentMap = context.bookPageMap, let readerView = context.readerView, let lastKnownSpineIndex = currentMap.entries.last?.spineIndex else { return } let buildableSpineIndices = publication.spine.indices.filter { let item = publication.spine[$0] return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml")) } var appendedEntries: [RDEPUBBookPageMapEntry] = [] for spineIndex in buildableSpineIndices where spineIndex > lastKnownSpineIndex { guard let chapter = chapterRuntimeStore.chapterData(for: spineIndex) else { break } appendedEntries.append( RDEPUBBookPageMapEntry( spineIndex: chapter.spineIndex, href: chapter.href, title: chapter.title, pageCount: chapter.pages.count, absolutePageStart: 0, fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets ) ) } guard !appendedEntries.isEmpty else { return } let existingEntries = currentMap.entries.map { RDEPUBBookPageMapEntry( spineIndex: $0.spineIndex, href: $0.href, title: $0.title, pageCount: $0.pageCount, absolutePageStart: 0, fragmentOffsets: $0.fragmentOffsets ) } var absolutePageStart = 0 let newEntries = (existingEntries + appendedEntries).map { entry -> RDEPUBBookPageMapEntry in let normalizedEntry = RDEPUBBookPageMapEntry( spineIndex: entry.spineIndex, href: entry.href, title: entry.title, pageCount: entry.pageCount, absolutePageStart: absolutePageStart, fragmentOffsets: entry.fragmentOffsets ) absolutePageStart += entry.pageCount return normalizedEntry } let newMap = RDEPUBBookPageMap(entries: newEntries) guard newMap.totalPages > currentMap.totalPages else { return } RDEPUBBackgroundTrace.log( "Runtime", "appendLoadedForwardChapters chapters=\(newMap.totalChapters) pages=\(newMap.totalPages)" ) context.bookPageMap = newMap context.replaceActiveSnapshot(makeSnapshot(from: newMap)) readerView.reloadPageCountOnly() } private func makeSnapshot(from bookPageMap: RDEPUBBookPageMap) -> RDEPUBReadingSession.PaginationSnapshot { let pages = bookPageMap.entries.flatMap { entry in (0..