import Foundation final class RDEPUBReaderPaginationCoordinator { static var pageMapRefreshInterval: Int = 32 private unowned let context: RDEPUBReaderContext private let metadataParseControlLock = NSLock() private var activeMetadataParseCancellationController: RDEPUBMetadataParseCancellationController? init(context: RDEPUBReaderContext) { self.context = context } 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 publication.readingProfile == .textReflowable { paginateTextPublication( parser: parser, publication: publication, readingSession: readingSession, restoreLocation: restoreLocation, token: token ) return } if publication.layout == .fixed { 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() 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.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.pendingPageMapUpdates.removeAll() let snapshot = controller.nativeTextSnapshot(from: textBook) context.replaceActiveSnapshot(snapshot) guard !textBook.pages.isEmpty else { context.handle(error: RDEPUBParserError.emptySpine) return } finishPagination(restoreLocation: restoreLocation) } func applyPaginationSnapshot( _ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]), restoreLocation: RDEPUBLocation? ) { guard context.controller != nil else { return } context.textBook = nil context.bookPageMap = nil context.pendingPageMapUpdates.removeAll() 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.runtime?.viewportMonitor.processPendingChangeAfterPagination() } func repaginatePreservingCurrentLocation( preferredRestoreLocation: RDEPUBLocation? = nil ) { guard context.publication != nil else { return } let restoreLocation = preferredRestoreLocation ?? context.runtime?.viewportMonitor.consumePendingPresentationRestoreLocation() ?? context.currentVisibleLocation() ?? context.persistenceLocation() // Chapter pages, page counts, partial maps, and background coverage all // depend on the viewport. Keeping them across a rotation can pair the // new page map with chapters paginated for the old size, leaving an // on-demand page in its loading state forever. context.runtime?.prepareForFullRepagination() paginatePublication(restoreLocation: restoreLocation) } func refreshVisibleContentPreservingLocation() { guard let readerView = context.readerView else { return } if readerView.isPageCurlTransitioning { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in self?.refreshVisibleContentPreservingLocation() } return } let restoreLocation = context.currentVisibleLocation() ?? context.persistenceLocation() if readerView.currentDisplayType == .pageCurl, readerView.currentPage >= 0 { readerView.transitionToPage(pageNum: readerView.currentPage, animated: false) } else { 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 let layoutSnapshot = context.makeLayoutSnapshot() let runtime = context.runtime DispatchQueue.global(qos: .utility).async { [weak controller] in guard controller != nil else { return } guard let runtime else { 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 { let runtimeChapter = try self.loadFirstRenderableRuntimeChapter( prioritizedSpineIndices: prioritizedCandidates, runtime: runtime, layoutSnapshot: layoutSnapshot ) RDEPUBBackgroundTrace.log( "Pagination", "anchorChapterReady spine=\(runtimeChapter.spineIndex) pages=\(runtimeChapter.pages.count)" ) let initialChapters = self.loadInitialInteractiveRuntimeChapters( anchorChapter: runtimeChapter, publication: publication, runtime: runtime, layoutSnapshot: layoutSnapshot ) let initialPageCount = initialChapters.reduce(0) { $0 + $1.pages.count } RDEPUBBackgroundTrace.log( "Pagination", "initialInteractiveChapters ready count=\(initialChapters.count) pages=\(initialPageCount) spines=\(initialChapters.map(\.spineIndex))" ) 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: initialChapters) runtime.applyBookPageMap(partialMap, restoreLocation: restoreLocation) runtime.prefetchForwardChaptersAfterInitialOpen( anchorSpineIndex: runtimeChapter.spineIndex, totalSpineCount: publication.spine.count ) let cancellationController = self.beginMetadataParseCancellationController(for: token) let worker = RDEPUBMetadataParseWorker( context: context, cancellationController: cancellationController, token: token, parser: parser, publication: publication ) RDEPUBBackgroundTrace.log( "Pagination", "startingMetadataWorker token=\(token.uuidString) anchorSpine=\(runtimeChapter.spineIndex) partialPages=\(partialMap.totalPages) partialChapters=\(partialMap.totalChapters)" ) worker.start(token: token, restoreLocation: restoreLocation) } } catch { RDEPUBBackgroundTrace.log( "Pagination", "initialPaginationFailed error=\(String(describing: error)) prioritizedCandidates=\(prioritizedCandidates)" ) DispatchQueue.main.async { guard context.paginationToken == token, context.controller != nil else { return } context.handle(error: error) } } } } private func loadFirstRenderableRuntimeChapter( prioritizedSpineIndices: [Int], runtime: RDEPUBReaderRuntime, layoutSnapshot: RDEPUBLayoutSnapshot ) throws -> RDEPUBRuntimeChapter { var lastError: Error? for spineIndex in prioritizedSpineIndices { do { return try runtime.chapterLoader.loadChapterSynchronouslyForMigration( spineIndex: spineIndex, store: runtime.chapterRuntimeStore, layoutSnapshot: layoutSnapshot ) } catch { lastError = error } } throw lastError ?? RDEPUBParserError.emptySpine } private func loadInitialInteractiveRuntimeChapters( anchorChapter: RDEPUBRuntimeChapter, publication: RDEPUBPublication, runtime: RDEPUBReaderRuntime, layoutSnapshot: RDEPUBLayoutSnapshot ) -> [RDEPUBRuntimeChapter] { let minimumInteractivePageCount = 2 let maximumAdditionalChapters = 1 guard anchorChapter.pages.count < minimumInteractivePageCount else { return [anchorChapter] } let buildableSpineIndices = publication.spine.indices.filter { isBuildableTextSpine(at: $0, in: publication) } guard let anchorPosition = buildableSpineIndices.firstIndex(of: anchorChapter.spineIndex) else { return [anchorChapter] } var selectedChapters: [RDEPUBRuntimeChapter] = [anchorChapter] for offset in 1...maximumAdditionalChapters { let candidatePositions = [anchorPosition + offset, anchorPosition - offset] for candidatePosition in candidatePositions { guard buildableSpineIndices.indices.contains(candidatePosition) else { continue } let spineIndex = buildableSpineIndices[candidatePosition] guard selectedChapters.contains(where: { $0.spineIndex == spineIndex }) == false else { continue } do { let chapter = try runtime.chapterLoader.loadChapterSynchronouslyForMigration( spineIndex: spineIndex, store: runtime.chapterRuntimeStore, layoutSnapshot: layoutSnapshot ) selectedChapters.append(chapter) } catch { #if DEBUG print("[RDEPUBReaderPaginationCoordinator] ⚠️ Failed to load chapter at spineIndex \(spineIndex): \(error)") #endif } } let loadedPageCount = selectedChapters.reduce(0) { $0 + $1.pages.count } if loadedPageCount >= minimumInteractivePageCount { break } } return selectedChapters } 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 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")) } func cancelActiveMetadataParseWork() { metadataParseControlLock.lock() let controller = activeMetadataParseCancellationController activeMetadataParseCancellationController = nil metadataParseControlLock.unlock() controller?.cancel() } func finishMetadataParseCancellationController(_ controller: RDEPUBMetadataParseCancellationController) { metadataParseControlLock.lock() if activeMetadataParseCancellationController === controller { activeMetadataParseCancellationController = nil } metadataParseControlLock.unlock() } private func beginMetadataParseCancellationController(for token: UUID) -> RDEPUBMetadataParseCancellationController { let controller = RDEPUBMetadataParseCancellationController(token: token) metadataParseControlLock.lock() let previous = activeMetadataParseCancellationController activeMetadataParseCancellationController = controller metadataParseControlLock.unlock() previous?.cancel() return controller } }