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) 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 /// 设置页内 preview 防抖任务,只保留最后一次请求。 private var pendingSettingsPreviewWorkItem: DispatchWorkItem? /// 设置页内 preview 防抖延迟,合并连续点击。 private let settingsPreviewDebounceDelay: TimeInterval = 0.2 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() } /// 跳转到指定搜索匹配项 @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() } /// 启动 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 } // 如果正在重新分页,跳过本次检查,避免状态冲突 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: // 这些情况在当前实现中不会发生,因为我们传入的是 candidatePageMap RDEPUBBackgroundTrace.log("Reconciliation", "decision: unexpected segment decision") break } } /// 应用全量页图替换 private func applyFullPageMapReplacement( _ newPageMap: RDEPUBBookPageMap, readerView: RDReaderView, controller: RDEPUBReaderController ) { let currentLocation = locationCoordinator.currentVisibleLocation() context.pendingFullPageMap = nil // 替换 map 和快照 context.textBook = nil context.bookPageMap = newPageMap context.replaceActiveSnapshot(makeSnapshot(from: newPageMap)) // 用位置在新 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() } // 检查是否应该结束 JumpSession(coverage-complete) 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() } } /// 防抖调度设置页内的当前章 preview,只保留最后一次请求。 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 self.chapterRuntimeStore.invalidateAllForSettingsChange() self.repaginateCurrentChapterOnly(previewGeneration: previewGeneration) } pendingSettingsPreviewWorkItem = workItem DispatchQueue.main.asyncAfter( deadline: .now() + settingsPreviewDebounceDelay, execute: workItem ) } /// 只重新计算当前章节(设置页面打开时使用) private func repaginateCurrentChapterOnly(previewGeneration: Int) { 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 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)" ) } } } /// 设置页面即将打开 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() } /// 重建外部 TextBook 数据 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 { // 如果是远距跳转且目标已在当前窗口中,创建 JumpSession 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() // 远距跳转成功后创建 JumpSession 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 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 makeSnapshot(from bookPageMap: RDEPUBBookPageMap) -> RDEPUBReadingSession.PaginationSnapshot { let pages = bookPageMap.entries.flatMap { entry in (0..