import Foundation /// EPUB 阅读器分页协调器:负责出版物的分页计算和页面数据更新。 /// /// 职责: /// - 根据出版物类型(文本重排/Fixed Layout/Web 内容)选择分页策略 /// - 文本大书优先恢复分页摘要并切换到按需加载 /// - 重新分页时保持当前阅读位置 /// - 刷新可见内容并保持位置 /// - 重建外部纯文本图书 final class RDEPUBReaderPaginationCoordinator { private let backgroundInteractionCooldown: CFAbsoluteTime = 0.8 /// 每 N 章刷新一次 pageMap,可通过修改此值实测调优。 static var pageMapRefreshInterval: Int = 32 private unowned let context: RDEPUBReaderContext init(context: RDEPUBReaderContext) { self.context = context } /// 对出版物执行分页:文本重排优先走摘要恢复/按需加载,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 #if DEBUG print("[EPUB][Pagination] profile=\(publication.readingProfile.rawValue) layout=\(publication.layout.rawValue) spine=\(publication.spine.count)") #endif if publication.readingProfile == .textReflowable { #if DEBUG print("[EPUB][Pagination] path=text-reflowable-on-demand") #endif paginateTextPublication( parser: parser, publication: publication, readingSession: readingSession, restoreLocation: restoreLocation, token: token ) return } if publication.layout == .fixed { #if DEBUG print("[EPUB][Pagination] path=fixed-layout") #endif 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() #if DEBUG print("[EPUB][Pagination] path=web-paginator") #endif 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 } context.textBook = textBook context.bookPageMap = nil context.pendingFullPageMap = nil 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.bookPageMap = nil context.pendingFullPageMap = 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) context.readingSession?.transition(to: .idle) } 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) } } private func paginateTextPublication( parser: RDEPUBParser, publication: RDEPUBPublication, readingSession: RDEPUBReadingSession, restoreLocation: RDEPUBLocation?, token: UUID ) { guard let controller = context.controller else { return } let context = self.context let pageSize = controller.currentTextPageSize() context.lastTextPaginationPageSize = pageSize DispatchQueue.global(qos: .utility).async { [weak controller] in guard controller != nil else { return } guard context.controller != nil else { return } if let restoredPageMap = self.restoreBookPageMapIfPossible(publication: publication) { DispatchQueue.main.async { guard context.paginationToken == token, context.controller != nil else { return } context.runtime?.applyBookPageMap(restoredPageMap, restoreLocation: restoreLocation) } return } let prioritizedCandidates = self.prioritizedBuildableSpineIndices( publication: publication, readingSession: readingSession, restoreLocation: restoreLocation ) guard prioritizedCandidates.first != nil else { DispatchQueue.main.async { guard context.paginationToken == token, context.controller != nil else { return } context.handle(error: RDEPUBParserError.emptySpine) } return } do { guard context.controller != nil, let runtime = context.runtime else { return } RDEPUBBackgroundTrace.log( "QuickOpen", "begin token=\(token.uuidString) candidates=\(prioritizedCandidates.count) restoreSpine=\(readingSession.initialSpineIndex(for: restoreLocation))" ) let runtimeChapter = try RDEPUBBackgroundTrace.measure( "QuickOpen", "loadFirstRenderableRuntimeChapter" ) { try self.loadFirstRenderableRuntimeChapter( prioritizedSpineIndices: prioritizedCandidates, runtime: runtime ) } let quickWindowChapters = try RDEPUBBackgroundTrace.measure( "QuickOpen", "loadInitialRuntimeChapters anchorSpine=\(runtimeChapter.spineIndex)" ) { try self.loadInitialRuntimeChapters( anchorSpineIndex: runtimeChapter.spineIndex, publication: publication, runtime: runtime ) } RDEPUBBackgroundTrace.log( "QuickOpen", "ready anchorSpine=\(runtimeChapter.spineIndex) quickWindow=\(quickWindowChapters.map { $0.spineIndex }) pages=\(quickWindowChapters.reduce(0) { $0 + $1.pages.count })" ) DispatchQueue.main.async { guard context.paginationToken == token, context.controller != nil else { return } runtime.chapterRuntimeStore.setCurrentChapter( spineIndex: runtimeChapter.spineIndex, totalSpineCount: publication.spine.count, windowRadius: context.configuration.chapterWindowRadius ) let partialMap = self.makePartialPageMap(from: quickWindowChapters) runtime.applyBookPageMap(partialMap, restoreLocation: restoreLocation) self.paginateMetadataOnly(token: token, restoreLocation: restoreLocation) } } catch { DispatchQueue.main.async { guard context.paginationToken == token, context.controller != nil else { return } context.handle(error: error) } } } } private func loadFirstRenderableRuntimeChapter( prioritizedSpineIndices: [Int], runtime: RDEPUBReaderRuntime ) throws -> RDEPUBRuntimeChapter { var lastError: Error? for spineIndex in prioritizedSpineIndices { do { return try runtime.chapterLoader.loadChapterSynchronouslyForMigration( spineIndex: spineIndex, store: runtime.chapterRuntimeStore ) } catch { lastError = error RDEPUBBackgroundTrace.log("QuickOpen", "skip spine=\(spineIndex) reason=\(error)") } } throw lastError ?? RDEPUBParserError.emptySpine } private func loadInitialRuntimeChapters( anchorSpineIndex: Int, publication: RDEPUBPublication, runtime: RDEPUBReaderRuntime ) throws -> [RDEPUBRuntimeChapter] { let windowSpineIndices = initialWindowSpineIndices( around: anchorSpineIndex, in: publication, maxChapterCount: context.configuration.onDemandChapterWindowSize ) var chapters: [RDEPUBRuntimeChapter] = [] for spineIndex in windowSpineIndices { do { let chapter = try runtime.chapterLoader.loadChapterSynchronouslyForMigration( spineIndex: spineIndex, store: runtime.chapterRuntimeStore ) chapters.append(chapter) } catch { if spineIndex == anchorSpineIndex { throw error } RDEPUBBackgroundTrace.log("QuickOpen", "skip adjacent spine=\(spineIndex) reason=\(error)") } } return chapters } private func initialWindowSpineIndices( around anchorSpineIndex: Int, in publication: RDEPUBPublication, maxChapterCount: Int = 3 ) -> [Int] { let normalizedMaxChapterCount = RDEPUBReaderConfiguration.normalizedChapterWindowSize(maxChapterCount) let buildableIndices = allBuildableSpineIndices(in: publication) guard let anchorPosition = buildableIndices.firstIndex(of: anchorSpineIndex) else { return [anchorSpineIndex] } var selected = [anchorSpineIndex] var nextPosition = anchorPosition + 1 var previousPosition = anchorPosition - 1 while selected.count < normalizedMaxChapterCount, nextPosition < buildableIndices.count || previousPosition >= 0 { if nextPosition < buildableIndices.count { selected.append(buildableIndices[nextPosition]) nextPosition += 1 if selected.count == normalizedMaxChapterCount { break } } if previousPosition >= 0 { selected.insert(buildableIndices[previousPosition], at: 0) previousPosition -= 1 } } return selected } 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 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 allBuildableSpineIndices(in publication: RDEPUBPublication) -> [Int] { publication.spine.indices.filter { isBuildableTextSpine(at: $0, in: publication) } } private func waitForReadingInteractionToSettle(using context: RDEPUBReaderContext) { while context.controller != nil, context.secondsSinceLastUserNavigation() < backgroundInteractionCooldown { Thread.sleep(forTimeInterval: 0.08) } } 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")) } // MARK: - 元数据专用解析(Phase 0) /// 后台遍历所有章节,只提取轻量元数据(pageCount、pageRanges、fragmentOffsets), /// 写入磁盘摘要缓存,不累积 RDEPUBTextBook。 func paginateMetadataOnly(token: UUID, restoreLocation: RDEPUBLocation?) { let context = self.context guard let parser = context.parser, let publication = context.publication else { return } let pageSize = context.currentTextPageSize() let layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize) let style = context.currentTextRenderStyle() let renderSignature = context.currentRenderSignature() let allBuildableIndices = allBuildableSpineIndices(in: publication) let summaryDiskCache = context.runtime?.summaryDiskCache let workerCount = max(1, context.configuration.metadataParsingConcurrency) let cpuCount = ProcessInfo.processInfo.activeProcessorCount RDEPUBBackgroundTrace.log("MetadataParse", "config concurrency=\(workerCount) cpuCores=\(cpuCount)") DispatchQueue.global(qos: .utility).async { [weak self] in guard let self else { return } guard context.controller != nil else { return } // 预计算所有章节的 contentHash,避免后续重复读盘 + SHA-256 let prewarmStart = CFAbsoluteTimeGetCurrent() var contentHashBySpineIndex: [Int: String] = [:] for spineIndex in allBuildableIndices { guard let href = publication.spine.indices.contains(spineIndex) ? publication.spine[spineIndex].href : nil, let html = parser.htmlString(forRelativePath: href) else { contentHashBySpineIndex[spineIndex] = "" continue } contentHashBySpineIndex[spineIndex] = html.sha256Hex } let prewarmMs = Int((CFAbsoluteTimeGetCurrent() - prewarmStart) * 1000) RDEPUBBackgroundTrace.log("MetadataParse", "prewarmHashMs=\(prewarmMs) chapters=\(allBuildableIndices.count)") let catalog = allBuildableIndices.map { spineIndex in let item = publication.spine[spineIndex] return ( key: context.chapterCacheKey( forSpineIndex: spineIndex, precomputedContentHash: contentHashBySpineIndex[spineIndex] ?? "", renderSignature: renderSignature ), spineIndex: spineIndex, href: item.href, title: item.title ) } let restored = summaryDiskCache?.readAll(keys: catalog) RDEPUBBackgroundTrace.log( "MetadataParse", "begin token=\(token.uuidString) buildableChapters=\(allBuildableIndices.count) concurrency=\(workerCount)" ) let cachedSummaries = restored?.summaries ?? [:] let cachedSpineIndices = Set(cachedSummaries.keys) let resultLock = NSLock() var summariesBySpineIndex = cachedSummaries var totalResolvedCount = cachedSpineIndices.count var lastAppliedCount = cachedSpineIndices.count if !cachedSpineIndices.isEmpty { RDEPUBBackgroundTrace.log( "MetadataParse", "resumeFromCache cachedChapters=\(cachedSpineIndices.count) total=\(allBuildableIndices.count)" ) let cachedMap = self.buildPageMap(from: catalog, summaries: summariesBySpineIndex) DispatchQueue.main.async { guard context.paginationToken == token, context.controller != nil else { return } context.runtime?.refreshBookPageMapInPlace(cachedMap) } } let uncachedSpineIndices = allBuildableIndices.filter { !cachedSpineIndices.contains($0) } self.waitForReadingInteractionToSettle(using: context) let wallClockStart = CFAbsoluteTimeGetCurrent() var totalRenderMs: Double = 0 var totalWriteMs: Double = 0 var totalMergeMs: Double = 0 var completedChapters = 0 var failedChapters = 0 let timingLock = NSLock() let queue = OperationQueue() queue.name = "com.rdreader.metadata.parse" queue.qualityOfService = .utility queue.maxConcurrentOperationCount = workerCount let refreshInterval = RDEPUBReaderPaginationCoordinator.pageMapRefreshInterval for (offset, spineIndex) in uncachedSpineIndices.enumerated() { queue.addOperation { guard context.controller != nil, context.paginationToken == token else { return } do { RDEPUBBackgroundTrace.log("MetadataParse", "正在解析 spine=\(spineIndex) \(offset + 1)/\(uncachedSpineIndices.count)") let renderResult: RDEPUBChapterSummary? = try autoreleasepool { () -> RDEPUBChapterSummary? in let chapterBuilder = context.makeTextBookBuilder(layoutConfig: layoutConfig) let renderStart = CFAbsoluteTimeGetCurrent() guard let result = try chapterBuilder.buildChapter( parser: parser, publication: publication, spineIndex: spineIndex, pageSize: pageSize, style: style ) else { return nil } let renderElapsed = (CFAbsoluteTimeGetCurrent() - renderStart) * 1000 let chapter = result.chapter let precomputedHash = contentHashBySpineIndex[spineIndex] ?? "" let cacheKey = context.chapterCacheKey( forSpineIndex: spineIndex, precomputedContentHash: precomputedHash, renderSignature: renderSignature ) let summary = RDEPUBChapterSummary( pageRanges: chapter.pages.map { .init(location: $0.contentRange.location, length: $0.contentRange.length) }, pageCount: chapter.pages.count, fragmentOffsets: chapter.fragmentOffsets, renderSignature: cacheKey.renderSignature, schemaVersion: RDEPUBChapterSummary.currentSchemaVersion, chapterContentHash: cacheKey.chapterContentHash, pageMetadataList: chapter.pages.map { .from($0.metadata) } ) let writeStart = CFAbsoluteTimeGetCurrent() summaryDiskCache?.write(summary: summary, for: cacheKey) let writeElapsed = (CFAbsoluteTimeGetCurrent() - writeStart) * 1000 timingLock.lock() totalRenderMs += renderElapsed totalWriteMs += writeElapsed completedChapters += 1 timingLock.unlock() RDEPUBBackgroundTrace.log( "MetadataParse", "spine=\(spineIndex) renderMs=\(Int(renderElapsed)) writeMs=\(Int(writeElapsed))" ) return summary } guard let renderResult else { return } // 锁内只做写入和计数,快照数据后锁外构建 pageMap var snapshot: [Int: RDEPUBChapterSummary]? resultLock.lock() summariesBySpineIndex[spineIndex] = renderResult totalResolvedCount += 1 if totalResolvedCount - lastAppliedCount >= refreshInterval || totalResolvedCount == allBuildableIndices.count { lastAppliedCount = totalResolvedCount snapshot = summariesBySpineIndex } resultLock.unlock() if let snapshot { let mergeStart = CFAbsoluteTimeGetCurrent() let partialMap = self.buildPageMap(from: catalog, summaries: snapshot) let mergeElapsed = (CFAbsoluteTimeGetCurrent() - mergeStart) * 1000 timingLock.lock() totalMergeMs += mergeElapsed timingLock.unlock() DispatchQueue.main.async { guard context.paginationToken == token, context.controller != nil else { return } context.runtime?.refreshBookPageMapInPlace(partialMap) } } } catch { timingLock.lock() failedChapters += 1 timingLock.unlock() RDEPUBBackgroundTrace.log("MetadataParse", "buildChapter FAILED: spine=\(spineIndex) error=\(error)") } } } queue.waitUntilAllOperationsAreFinished() summaryDiskCache?.flushPendingWrites() let wallClockMs = Int((CFAbsoluteTimeGetCurrent() - wallClockStart) * 1000) timingLock.lock() let renderTotal = Int(totalRenderMs) let writeTotal = Int(totalWriteMs) let mergeTotal = Int(totalMergeMs) let rendered = completedChapters let failed = failedChapters timingLock.unlock() let avgRenderMs = rendered > 0 ? renderTotal / rendered : 0 RDEPUBBackgroundTrace.log( "MetadataParse", "timing wallClockMs=\(wallClockMs) chapters=\(rendered) failed=\(failed) " + "prewarmHashMs=\(prewarmMs) renderTotalMs=\(renderTotal) writeTotalMs=\(writeTotal) " + "mergeTotalMs=\(mergeTotal) avgRenderMs=\(avgRenderMs) concurrency=\(workerCount)" ) context.lastMetadataParseWallClockMs = wallClockMs context.lastMetadataParseConcurrency = workerCount guard context.controller != nil, context.paginationToken == token else { RDEPUBBackgroundTrace.log("MetadataParse", "abort: reader dismissed or token changed") return } let finalMergeStart = CFAbsoluteTimeGetCurrent() let pageMap = self.buildPageMap(from: catalog, summaries: summariesBySpineIndex) let finalMergeMs = Int((CFAbsoluteTimeGetCurrent() - finalMergeStart) * 1000) RDEPUBBackgroundTrace.log( "MetadataParse", "complete chapters=\(pageMap.totalChapters) pages=\(pageMap.totalPages) finalMergeMs=\(finalMergeMs)" ) DispatchQueue.main.async { guard context.paginationToken == token, context.controller != nil else { return } context.runtime?.refreshBookPageMapInPlace(pageMap) } } } private func restoreBookPageMapIfPossible(publication: RDEPUBPublication) -> RDEPUBBookPageMap? { guard let summaryDiskCache = context.runtime?.summaryDiskCache, let parser = context.parser else { return nil } let renderSignature = context.currentRenderSignature() let catalog = allBuildableSpineIndices(in: publication).map { spineIndex in let item = publication.spine[spineIndex] let href = item.href let contentHash = parser.htmlString(forRelativePath: href)?.sha256Hex ?? "" return ( key: context.chapterCacheKey( forSpineIndex: spineIndex, precomputedContentHash: contentHash, renderSignature: renderSignature ), spineIndex: spineIndex, href: href, title: item.title ) } guard summaryDiskCache.isCacheComplete(keys: catalog.map(\.key)) else { return nil } let restored = summaryDiskCache.readAll(keys: catalog) guard restored.summaries.count == catalog.count else { return nil } return restored.mapBuilder.build() } private func buildPageMap( from catalog: [(key: RDEPUBChapterCacheKey, spineIndex: Int, href: String, title: String)], summaries: [Int: RDEPUBChapterSummary] ) -> RDEPUBBookPageMap { var builder = RDEPUBBookPageMap.Builder() for item in catalog { guard let summary = summaries[item.spineIndex] else { continue } builder.add( spineIndex: item.spineIndex, href: item.href, title: item.title, pageCount: summary.pageCount, fragmentOffsets: summary.fragmentOffsets ) } return builder.build() } }