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) loader.onDeferredCFIMapReady = { [weak self] spineIndex in self?.handleDeferredCFIMapReady(for: spineIndex) } 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) lazy var presentationRuntime = RDEPUBPresentationRuntime( context: context, locationCoordinator: locationCoordinator, jumpSessionManager: jumpSessionManager, reconciliationCoordinator: reconciliationCoordinator ) lazy var chapterWarmupOrchestrator = RDEPUBChapterWarmupOrchestrator( context: context, store: chapterRuntimeStore, loader: chapterLoader, presentationRuntime: presentationRuntime, locationCoordinator: locationCoordinator, backgroundPriorityManager: backgroundPriorityManager, jumpSessionManager: jumpSessionManager, refreshVisibleContentPreservingLocation: { [weak self] in self?.refreshVisibleContentPreservingLocation() } ) 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.pendingPageMapUpdates.removeAll() 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 context.controller != nil, 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?) { presentationRuntime.applyBookPageMap( bookPageMap, restoreLocation: restoreLocation ) { [weak self] restoreLocation in self?.paginationCoordinator.finishPagination(restoreLocation: restoreLocation) } } func refreshBookPageMapInPlace(_ bookPageMap: RDEPUBBookPageMap) { presentationRuntime.refreshBookPageMapInPlace(bookPageMap) } func applyPendingFullPageMapIfNeeded() { presentationRuntime.commitPendingPageMapUpdateIfNeeded() } 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.presentationRuntime.applySettingsPreviewPageMap(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 { chapterWarmupOrchestrator.ensureNavigationTargetAvailable(for: location) } @discardableResult func prepareOnDemandChapter( forAbsolutePageNumber pageNumber: Int, allowSynchronousLoad: Bool = true, completion: ((Bool) -> Void)? = nil ) -> Bool { chapterWarmupOrchestrator.prepareOnDemandChapter( forAbsolutePageNumber: pageNumber, allowSynchronousLoad: allowSynchronousLoad, completion: completion ) } func extendPartialBookPageMapIfNeeded( currentPageNumber: Int, minimumTrailingPages: Int = 2, batchChapterCount: Int = 3 ) { chapterWarmupOrchestrator.extendPartialBookPageMapIfNeeded( currentPageNumber: currentPageNumber, minimumTrailingPages: minimumTrailingPages, batchChapterCount: batchChapterCount ) } func prefetchForwardChaptersAfterInitialOpen(anchorSpineIndex: Int, totalSpineCount: Int) { chapterWarmupOrchestrator.prefetchForwardChaptersAfterInitialOpen( anchorSpineIndex: anchorSpineIndex, totalSpineCount: totalSpineCount ) } func clearOnDemandPageModeState() { paginationCoordinator.cancelActiveMetadataParseWork() chapterRuntimeStore.invalidateAllForSettingsChange() context.bookPageMap = nil context.pendingPageMapUpdates.removeAll() jumpSessionManager.clearSession() backgroundPriorityManager.reset() backgroundCoverageStore.clearAll() chapterWarmupOrchestrator.clear() presentationRuntime.clear() } 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 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 handleDeferredCFIMapReady(for spineIndex: Int) { guard let currentLocation = locationCoordinator.currentVisibleLocation(), let visibleSpineIndex = context.normalizedSpineIndex(for: currentLocation), visibleSpineIndex == spineIndex else { return } refreshVisibleContentPreservingLocation() } }