import Foundation /// EPUB 阅读器分页协调器:负责出版物的分页计算和页面数据更新。 /// /// 职责: /// - 根据出版物类型(文本重排/Fixed Layout/Web 内容)选择分页策略 /// - 后台构建文本图书模型并应用分页快照 /// - 重新分页时保持当前阅读位置 /// - 刷新可见内容并保持位置 /// - 重建外部纯文本图书 final class RDEPUBReaderPaginationCoordinator { private let backgroundInteractionCooldown: CFAbsoluteTime = 0.8 private let backgroundChapterPause: TimeInterval = 0.04 private let incrementalMergeChapterThreshold = 20 private let stagedIncrementalApplyDelay: TimeInterval = 1.0 private var stagedIncrementalApplyWorkItem: DispatchWorkItem? private let stagedBookRequestLock = NSLock() private var stagedBookRequest: StagedBookRequest? private struct QuickBuildState { var quickWindow: [Int] var chapterStore: IncrementalChapterStore var book: RDEPUBTextBook } private struct StagedBookRequest { var chapterStore: IncrementalChapterStore var orderedSpineIndices: [Int] var restoreLocation: RDEPUBLocation? var isComplete: Bool } private final class IncrementalChapterStore { private let lock = NSLock() private var builtChapters: [Int: RDEPUBTextChapter] = [:] func insert(_ chapter: RDEPUBTextChapter, for spineIndex: Int) { lock.lock() builtChapters[spineIndex] = chapter lock.unlock() } func contains(_ spineIndex: Int) -> Bool { lock.lock() let contains = builtChapters[spineIndex] != nil lock.unlock() return contains } func snapshot(orderedBy spineIndices: [Int]) -> [Int: RDEPUBTextChapter] { lock.lock() let chapters = builtChapters lock.unlock() return chapters } } private unowned let context: RDEPUBReaderContext init(context: RDEPUBReaderContext) { self.context = context } /// 对出版物执行分页:文本重排走 TextBookBuilder,Fixed Layout 直接生成快照,Web 内容走 Paginator。 func paginatePublication(restoreLocation: RDEPUBLocation?) { guard let controller = context.controller, let parser = context.parser, let publication = context.publication, let readingSession = context.readingSession else { return } controller.isRepaginating = true controller.errorLabel.isHidden = true controller.showLoading() let token = UUID() context.paginationToken = token print("[EPUB][Pagination] profile=\(publication.readingProfile.rawValue) layout=\(publication.layout.rawValue) spine=\(publication.spine.count)") if publication.readingProfile == .textReflowable { print("[EPUB][Pagination] path=text-reflowable-fast-entry") paginateTextPublication( parser: parser, publication: publication, readingSession: readingSession, restoreLocation: restoreLocation, token: token ) return } if publication.layout == .fixed { print("[EPUB][Pagination] path=fixed-layout") let snapshot = readingSession.makePaginationSnapshot( pageCounts: Array(repeating: 1, count: publication.spine.count), preferences: controller.currentPreferences(), layoutContext: controller.currentLayoutContext() ) context.runtime?.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation) return } let paginator = context.makePaginator() print("[EPUB][Pagination] path=web-paginator") context.paginator = paginator paginator.calculate( parser: parser, hostingView: controller.ensurePaginationHostView(), presentation: controller.currentPreferences().presentationStyle(viewportSize: controller.currentLayoutContext().viewportSize) ) { [weak controller] pageCounts in guard let controller, self.context.paginationToken == token else { return } let snapshot = readingSession.makePaginationSnapshot( pageCounts: pageCounts, preferences: controller.currentPreferences(), layoutContext: controller.currentLayoutContext() ) self.context.paginator = nil self.context.runtime?.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation) } } /// 应用文本图书模型:生成分页快照并完成分页流程。 func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) { guard let controller = context.controller else { return } stagedIncrementalApplyWorkItem?.cancel() stagedIncrementalApplyWorkItem = nil clearStagedBookRequest() context.textBook = textBook let snapshot = controller.nativeTextSnapshot(from: textBook) context.replaceActiveSnapshot(snapshot) guard !textBook.pages.isEmpty else { context.handle(error: RDEPUBParserError.emptySpine) return } finishPagination(restoreLocation: restoreLocation) } /// 应用分页快照(Fixed Layout 或 Web 内容),并完成分页流程。 func applyPaginationSnapshot( _ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]), restoreLocation: RDEPUBLocation? ) { guard context.controller != nil else { return } context.textBook = nil context.replaceActiveSnapshot(snapshot) guard !snapshot.pages.isEmpty else { context.handle(error: RDEPUBParserError.emptySpine) return } finishPagination(restoreLocation: restoreLocation) } /// 分页完成后的收尾:刷新视图、恢复阅读位置、处理待定视口变更。 func finishPagination(restoreLocation: RDEPUBLocation?) { guard let controller = context.controller, let readerView = context.readerView else { return } controller.isRepaginating = false controller.hideLoading() readerView.reloadData() if let targetLocation = restoreLocation { controller.restoreReadingLocation(targetLocation) } else { readerView.transitionToPage(pageNum: 0) context.readingSession?.transition(to: .idle) } context.runtime?.viewportMonitor.processPendingChangeAfterPagination() } /// 重新分页并保持当前阅读位置(优先使用待恢复位置,其次当前位置,最后持久化位置)。 func repaginatePreservingCurrentLocation() { guard context.publication != nil else { return } let restoreLocation = context.runtime?.viewportMonitor.consumePendingPresentationRestoreLocation() ?? context.currentVisibleLocation() ?? context.persistenceLocation() paginatePublication(restoreLocation: restoreLocation) } /// 刷新可见内容并保持当前阅读位置不变。 func refreshVisibleContentPreservingLocation() { guard let readerView = context.readerView else { return } let restoreLocation = context.currentVisibleLocation() ?? context.persistenceLocation() readerView.reloadData() if let restoreLocation { _ = context.restoreReadingLocation(restoreLocation) } } /// 重建外部纯文本图书(布局变更后重新排版)。 func rebuildExternalTextBook() { guard let controller = context.controller, let textFileURL = controller.textFileURL else { return } let restoreLocation = context.currentVisibleLocation() ?? context.persistenceLocation() let pageSize = controller.currentTextPageSize() let style = controller.currentTextRenderStyle() let builder = context.makePlainTextBookBuilder(layoutConfig: controller.currentTextLayoutConfig(pageSize: pageSize)) if let newBook = try? builder.build(textFileURL: textFileURL, pageSize: pageSize, style: style) { context.runtime?.applyTextBook(newBook, restoreLocation: restoreLocation) } } /// 文本 EPUB 大书快速进入:先构建目标章节进入阅读器,再后台补齐完整 TextBook。 private func paginateTextPublication( parser: RDEPUBParser, publication: RDEPUBPublication, readingSession: RDEPUBReadingSession, restoreLocation: RDEPUBLocation?, token: UUID ) { guard let controller = context.controller else { return } let pageSize = controller.currentTextPageSize() context.lastTextPaginationPageSize = pageSize let layoutConfig = controller.currentTextLayoutConfig(pageSize: pageSize) let builder = context.makeTextBookBuilder(layoutConfig: layoutConfig) let renderStyle = controller.currentTextRenderStyle() let quickSpineCandidates = prioritizedBuildableSpineIndices( publication: publication, readingSession: readingSession, restoreLocation: restoreLocation ) DispatchQueue.global(qos: .utility).async { [weak controller] in guard controller != nil else { return } var didApplyQuickChapter = false do { self.waitForReadingInteractionToSettle() let allBuildableIndices = self.allBuildableSpineIndices(in: publication) let quickBuildState = try self.buildQuickTextBook( builder: builder, parser: parser, publication: publication, pageSize: pageSize, style: renderStyle, prioritizedCandidates: quickSpineCandidates ) if let quickBook = quickBuildState?.book { DispatchQueue.main.sync { guard self.context.paginationToken == token else { return } didApplyQuickChapter = true self.context.runtime?.applyTextBook(quickBook, restoreLocation: restoreLocation) } } let chapterStore = quickBuildState?.chapterStore ?? IncrementalChapterStore() var pendingIncrementalCount = 0 let incrementalBuildOrder = self.incrementalBuildOrder( allBuildableIndices: allBuildableIndices, quickWindow: quickBuildState?.quickWindow ?? [] ) for spineIndex in incrementalBuildOrder where !chapterStore.contains(spineIndex) { self.waitForReadingInteractionToSettle() guard let result = try builder.buildChapter( parser: parser, publication: publication, spineIndex: spineIndex, pageSize: pageSize, style: renderStyle ) else { continue } chapterStore.insert(result.chapter, for: spineIndex) pendingIncrementalCount += 1 Thread.sleep(forTimeInterval: self.backgroundChapterPause) if pendingIncrementalCount >= self.incrementalMergeChapterThreshold { pendingIncrementalCount = 0 self.stageBookRequest( chapterStore: chapterStore, orderedSpineIndices: allBuildableIndices, restoreLocation: restoreLocation, isComplete: false ) DispatchQueue.main.async { guard self.context.paginationToken == token else { return } self.scheduleStagedIncrementalTextBookApplication() } } } self.stageBookRequest( chapterStore: chapterStore, orderedSpineIndices: allBuildableIndices, restoreLocation: restoreLocation, isComplete: true ) DispatchQueue.main.async { guard self.context.paginationToken == token else { return } self.scheduleStagedIncrementalTextBookApplication() } } catch { DispatchQueue.main.async { guard self.context.paginationToken == token else { return } if didApplyQuickChapter { self.context.isRepaginating = false self.context.hideLoading() } else { self.context.handle(error: error) } } } } } private func buildQuickTextBook( builder: RDEPUBTextBookBuilder, parser: RDEPUBParser, publication: RDEPUBPublication, pageSize: CGSize, style: RDEPUBTextRenderStyle, prioritizedCandidates: [Int] ) throws -> QuickBuildState? { var anchorResult: RDEPUBTextChapterBuildResult? for spineIndex in prioritizedCandidates { guard let result = try builder.buildChapter( parser: parser, publication: publication, spineIndex: spineIndex, pageSize: pageSize, style: style ), !result.chapter.pages.isEmpty else { continue } anchorResult = result break } guard let anchorResult else { return nil } let quickWindow = quickWindowSpineIndices( around: anchorResult.chapter.spineIndex, in: publication ) let chapterStore = IncrementalChapterStore() for spineIndex in quickWindow { let result: RDEPUBTextChapterBuildResult? if spineIndex == anchorResult.chapter.spineIndex { result = anchorResult } else { result = try builder.buildChapter( parser: parser, publication: publication, spineIndex: spineIndex, pageSize: pageSize, style: style ) } guard let chapter = result?.chapter, !chapter.pages.isEmpty else { continue } chapterStore.insert(chapter, for: spineIndex) } guard let book = textBook(from: chapterStore.snapshot(orderedBy: quickWindow), orderedBy: quickWindow) else { return nil } print("[EPUB][Pagination] quick-book chapters=\(book.chapters.count) pages=\(book.pages.count) anchor=\(anchorResult.chapter.href)") return QuickBuildState( quickWindow: quickWindow, chapterStore: chapterStore, book: book ) } private func prioritizedBuildableSpineIndices( publication: RDEPUBPublication, readingSession: RDEPUBReadingSession, restoreLocation: RDEPUBLocation? ) -> [Int] { let preferred = readingSession.initialSpineIndex(for: restoreLocation) return publication.spine.indices .filter { isBuildableTextSpine(at: $0, in: publication) } .sorted { lhs, rhs in abs(lhs - preferred) < abs(rhs - preferred) } } private func quickWindowSpineIndices( around anchorSpineIndex: Int, in publication: RDEPUBPublication, maxChapterCount: Int = 3 ) -> [Int] { let buildableIndices = allBuildableSpineIndices(in: publication) guard let anchorPosition = buildableIndices.firstIndex(of: anchorSpineIndex) else { return [anchorSpineIndex] } var selected: [Int] = [anchorSpineIndex] var nextPosition = anchorPosition + 1 var previousPosition = anchorPosition - 1 while selected.count < maxChapterCount, nextPosition < buildableIndices.count || previousPosition >= 0 { if nextPosition < buildableIndices.count { selected.append(buildableIndices[nextPosition]) nextPosition += 1 if selected.count == maxChapterCount { break } } if previousPosition >= 0 { selected.insert(buildableIndices[previousPosition], at: 0) previousPosition -= 1 } } return selected } private func allBuildableSpineIndices(in publication: RDEPUBPublication) -> [Int] { publication.spine.indices.filter { isBuildableTextSpine(at: $0, in: publication) } } func scheduleStagedIncrementalTextBookApplication() { stagedIncrementalApplyWorkItem?.cancel() let workItem = DispatchWorkItem { [weak self] in self?.applyStagedIncrementalTextBookIfPossible() } stagedIncrementalApplyWorkItem = workItem DispatchQueue.main.asyncAfter(deadline: .now() + stagedIncrementalApplyDelay, execute: workItem) } private func applyStagedIncrementalTextBookIfPossible() { guard context.secondsSinceLastUserNavigation() >= backgroundInteractionCooldown, !context.isRepaginating, context.readingSession?.navigatorState == .idle else { scheduleStagedIncrementalTextBookApplication() return } guard let staged = consumeStagedBookRequest() else { stagedIncrementalApplyWorkItem = nil return } stagedIncrementalApplyWorkItem = nil guard let stagedBook = textBook( from: staged.chapterStore.snapshot(orderedBy: staged.orderedSpineIndices), orderedBy: staged.orderedSpineIndices ) else { return } let restoreLocation = context.currentVisibleLocation() ?? staged.restoreLocation let logPrefix = staged.isComplete ? "full-book" : "applied-staged-book" print("[EPUB][Pagination] \(logPrefix) chapters=\(stagedBook.chapters.count) pages=\(stagedBook.pages.count)") context.runtime?.applyTextBook(stagedBook, restoreLocation: restoreLocation) } private func waitForReadingInteractionToSettle() { while context.secondsSinceLastUserNavigation() < backgroundInteractionCooldown { Thread.sleep(forTimeInterval: 0.08) } } private func incrementalBuildOrder( allBuildableIndices: [Int], quickWindow: [Int] ) -> [Int] { guard let firstWindowIndex = quickWindow.first, let lastWindowIndex = quickWindow.last else { return allBuildableIndices } let forward = allBuildableIndices.filter { $0 > lastWindowIndex } let backward = allBuildableIndices.filter { $0 < firstWindowIndex } return quickWindow + forward + backward } private func textBook( from builtChapters: [Int: RDEPUBTextChapter], orderedBy spineIndices: [Int] ) -> RDEPUBTextBook? { var chapters: [RDEPUBTextChapter] = [] var pages: [RDEPUBTextPage] = [] for spineIndex in spineIndices { guard var chapter = builtChapters[spineIndex], !chapter.pages.isEmpty else { continue } chapter.chapterIndex = chapters.count let normalizedPages = chapter.pages.enumerated().map { localPageIndex, page -> RDEPUBTextPage in var page = page page.absolutePageIndex = pages.count + localPageIndex page.chapterIndex = chapters.count page.pageIndexInChapter = localPageIndex page.totalPagesInChapter = chapter.pages.count return page } chapter.pages = normalizedPages chapters.append(chapter) pages.append(contentsOf: normalizedPages) } guard !pages.isEmpty else { return nil } return RDEPUBTextBook(chapters: chapters, pages: pages) } private func stageBookRequest( chapterStore: IncrementalChapterStore, orderedSpineIndices: [Int], restoreLocation: RDEPUBLocation?, isComplete: Bool ) { stagedBookRequestLock.lock() stagedBookRequest = StagedBookRequest( chapterStore: chapterStore, orderedSpineIndices: orderedSpineIndices, restoreLocation: restoreLocation, isComplete: isComplete ) stagedBookRequestLock.unlock() } private func consumeStagedBookRequest() -> StagedBookRequest? { stagedBookRequestLock.lock() defer { stagedBookRequestLock.unlock() } let request = stagedBookRequest stagedBookRequest = nil return request } private func clearStagedBookRequest() { stagedBookRequestLock.lock() stagedBookRequest = nil stagedBookRequestLock.unlock() } private func isBuildableTextSpine(at index: Int, in publication: RDEPUBPublication) -> Bool { guard publication.spine.indices.contains(index) else { return false } let item = publication.spine[index] return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml")) } }