import UIKit /// EPUB 阅读器运行时总协调器。 /// 统一持有并分发给加载、分页、定位、搜索、工具栏、批注、视口监测等子协调器, /// 作为阅读器控制器的门面(Facade),简化外部调用。 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) 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() } /// 跳转到指定阅读位置 /// - Parameters: /// - location: 目标位置 /// - animated: 是否动画过渡 /// - Returns: 跳转是否成功 func go(to location: RDEPUBLocation, animated: Bool = false) -> Bool { locationCoordinator.restoreReadingLocation(location, animated: animated) } /// 跳转到指定页码 /// - Parameters: /// - pageNumber: 目标页码(从 1 开始) /// - animated: 是否动画过渡 /// - Returns: 跳转是否成功 @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() } /// 清除搜索状态 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() } /// 启动 Publication 加载流程 func loadPublication() { loadCoordinator.loadPublication() } /// 将已解析的 Publication 应用到控制器 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 ) } /// 对 Publication 执行分页计算 func paginatePublication(restoreLocation: RDEPUBLocation?) { paginationCoordinator.paginatePublication(restoreLocation: restoreLocation) } /// 应用外部 TextBook 并恢复阅读位置 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) { // 暂存完整 map,等用户下次导航时再应用,避免当前阅读位置跳转 // 此时保留旧 map,用户看到的内容和页码完全不变 context.pendingFullPageMap = bookPageMap } /// 用户导航时检查并应用待处理的完整 BookPageMap func applyPendingFullPageMapIfNeeded() { guard let pendingMap = context.pendingFullPageMap, let readerView = context.readerView, let controller = context.controller else { return } context.pendingFullPageMap = nil // 保存当前位置(在旧 map 下解析) let currentLocation = locationCoordinator.currentVisibleLocation() // 替换 map 和快照 context.textBook = nil context.bookPageMap = pendingMap context.replaceActiveSnapshot(makeSnapshot(from: pendingMap)) // 用位置在新 map 中重新解析正确的页码 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() } } /// 完成分页流程并恢复阅读位置 func finishPagination(restoreLocation: RDEPUBLocation?) { paginationCoordinator.finishPagination(restoreLocation: restoreLocation) } /// 重新分页并保持当前阅读位置不变 func repaginatePreservingCurrentLocation() { paginationCoordinator.repaginatePreservingCurrentLocation() } /// 刷新当前可见内容,保持阅读位置不变 func refreshVisibleContentPreservingLocation() { paginationCoordinator.refreshVisibleContentPreservingLocation() } /// 重建外部 TextBook 数据 func rebuildExternalTextBook() { paginationCoordinator.rebuildExternalTextBook() } /// 恢复到指定阅读位置 @discardableResult func restoreReadingLocation(_ location: RDEPUBLocation, animated: Bool = false) -> Bool { locationCoordinator.restoreReadingLocation(location, animated: animated) } /// 获取当前可见页面的阅读位置 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 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 } guard currentMap.totalPages - currentPageNumber <= minimumTrailingPages else { return } let lastKnownSpineIndex = currentMap.entries.last?.spineIndex ?? -1 let nextSpineIndices = buildableSpineIndices.filter { $0 > lastKnownSpineIndex }.prefix(batchChapterCount) guard !nextSpineIndices.isEmpty else { return } RDEPUBBackgroundTrace.log( "Runtime", "extendPartialBookPageMap currentPage=\(currentPageNumber) totalPages=\(currentMap.totalPages) appendSpines=\(Array(nextSpineIndices))" ) var appendedEntries: [RDEPUBBookPageMapEntry] = [] for spineIndex in nextSpineIndices { 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 clearOnDemandPageModeState() { chapterRuntimeStore.invalidateAllForSettingsChange() context.bookPageMap = nil context.pendingFullPageMap = nil } private func makeSnapshot(from bookPageMap: RDEPUBBookPageMap) -> RDEPUBReadingSession.PaginationSnapshot { let pages = bookPageMap.entries.flatMap { entry in (0..