import Foundation final class RDEPUBReaderPaginationCoordinator { private final class MetadataParseState { var summariesBySpineIndex: [Int: RDEPUBChapterSummary] var totalResolvedCount: Int var lastAppliedCount: Int init( summariesBySpineIndex: [Int: RDEPUBChapterSummary], totalResolvedCount: Int, lastAppliedCount: Int ) { self.summariesBySpineIndex = summariesBySpineIndex self.totalResolvedCount = totalResolvedCount self.lastAppliedCount = lastAppliedCount } } private final class MetadataParseCancellationController { let token: UUID private let lock = NSLock() private weak var queue: OperationQueue? private var cancelled = false init(token: UUID) { self.token = token } func attach(queue: OperationQueue) { let shouldCancelImmediately: Bool lock.lock() self.queue = queue shouldCancelImmediately = cancelled lock.unlock() if shouldCancelImmediately { queue.cancelAllOperations() } } func cancel() { let queueToCancel: OperationQueue? lock.lock() cancelled = true queueToCancel = queue lock.unlock() queueToCancel?.cancelAllOperations() } var isCancelled: Bool { lock.lock() let value = cancelled lock.unlock() return value } } private let backgroundInteractionCooldown: CFAbsoluteTime = 0.8 static var pageMapRefreshInterval: Int = 32 private unowned let context: RDEPUBReaderContext private let metadataParseControlLock = NSLock() private var activeMetadataParseCancellationController: MetadataParseCancellationController? 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 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) } 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 } 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 ) } RDEPUBBackgroundTrace.log( "QuickOpen", "ready anchorSpine=\(runtimeChapter.spineIndex) pages=\(runtimeChapter.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: [runtimeChapter]) runtime.applyBookPageMap(partialMap, restoreLocation: restoreLocation) runtime.prefetchForwardChaptersAfterInitialOpen( anchorSpineIndex: runtimeChapter.spineIndex, totalSpineCount: publication.spine.count ) 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 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, cancellationController: MetadataParseCancellationController? = nil ) { while context.controller != nil, cancellationController?.isCancelled != true, 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")) } private static let maxRetryCount = 3 private static let retryDelays: [TimeInterval] = [0.5, 2.0, 8.0] func paginateMetadataOnly(token: UUID, restoreLocation: RDEPUBLocation?) { let context = self.context guard let parser = context.parser, let publication = context.publication else { return } let cancellationController = beginMetadataParseCancellationController(for: token) 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 } defer { self.finishMetadataParseCancellationController(cancellationController) } guard context.controller != nil, !cancellationController.isCancelled, context.paginationToken == token else { return } if let restoredPageMap = self.restoreBookPageMapIfPossible(publication: publication) { RDEPUBBackgroundTrace.log( "MetadataParse", "full cache restore hit chapters=\(restoredPageMap.totalChapters) pages=\(restoredPageMap.totalPages)" ) DispatchQueue.main.async { guard context.paginationToken == token, context.controller != nil, !cancellationController.isCancelled else { return } context.runtime?.refreshBookPageMapInPlace(restoredPageMap) } return } let prewarmStart = CFAbsoluteTimeGetCurrent() var contentHashBySpineIndex: [Int: String] = [:] for spineIndex in allBuildableIndices { guard !cancellationController.isCancelled, context.paginationToken == token, context.controller != nil else { RDEPUBBackgroundTrace.log("MetadataParse", "abort during content hash prewarm") return } 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() let parseState = MetadataParseState( summariesBySpineIndex: cachedSummaries, totalResolvedCount: cachedSpineIndices.count, lastAppliedCount: cachedSpineIndices.count ) if !cachedSpineIndices.isEmpty { RDEPUBBackgroundTrace.log( "MetadataParse", "resumeFromCache cachedChapters=\(cachedSpineIndices.count) total=\(allBuildableIndices.count)" ) let cachedMap = self.buildPageMap(from: catalog, summaries: parseState.summariesBySpineIndex) DispatchQueue.main.async { guard context.paginationToken == token, context.controller != nil else { return } context.runtime?.refreshBookPageMapInPlace(cachedMap) } } let prioritizedSpineIndices: [Int] if let priorityManager = context.runtime?.backgroundPriorityManager { let currentSpineIndex = context.runtime?.locationCoordinator.currentVisibleLocation() .flatMap { context.normalizedSpineIndex(for: $0) } prioritizedSpineIndices = priorityManager.makeMetadataPriorityOrder( allBuildableIndices: allBuildableIndices, currentSpineIndex: currentSpineIndex, cachedSpineIndices: cachedSpineIndices ) RDEPUBBackgroundTrace.log( "MetadataParse", "prioritized hot=\(prioritizedSpineIndices.prefix(10).count) total=\(prioritizedSpineIndices.count)" ) } else { prioritizedSpineIndices = allBuildableIndices.filter { !cachedSpineIndices.contains($0) } } let uncachedSpineIndices = prioritizedSpineIndices self.waitForReadingInteractionToSettle( using: context, cancellationController: cancellationController ) guard !cancellationController.isCancelled, context.controller != nil, context.paginationToken == token else { RDEPUBBackgroundTrace.log("MetadataParse", "abort before queue start") return } 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 cancellationController.attach(queue: queue) let refreshInterval = RDEPUBReaderPaginationCoordinator.pageMapRefreshInterval for (offset, spineIndex) in uncachedSpineIndices.enumerated() { let operation = BlockOperation() operation.addExecutionBlock { [weak operation] in guard context.controller != nil, context.paginationToken == token, !cancellationController.isCancelled, operation?.isCancelled != true else { return } do { RDEPUBBackgroundTrace.log("MetadataParse", "正在解析 spine=\(spineIndex) \(offset + 1)/\(uncachedSpineIndices.count)") let renderResult: RDEPUBChapterSummary? = try autoreleasepool { () -> RDEPUBChapterSummary? in guard context.controller != nil, context.paginationToken == token, !cancellationController.isCancelled, operation?.isCancelled != true else { return nil } 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 } guard context.controller != nil, context.paginationToken == token, !cancellationController.isCancelled, operation?.isCancelled != true else { RDEPUBBackgroundTrace.log("MetadataParse", "drop rendered chapter due to cancellation spine=\(spineIndex)") 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, cfiMap: chapter.cfiMap, renderSignature: cacheKey.renderSignature, schemaVersion: RDEPUBChapterSummary.currentSchemaVersion, chapterContentHash: cacheKey.chapterContentHash, pageMetadataList: chapter.pages.map { .from($0.metadata) } ) guard context.controller != nil, context.paginationToken == token, !cancellationController.isCancelled, operation?.isCancelled != true else { RDEPUBBackgroundTrace.log("MetadataParse", "skip disk write due to cancellation spine=\(spineIndex)") return nil } 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 } guard context.controller != nil, context.paginationToken == token, !cancellationController.isCancelled, operation?.isCancelled != true else { return } var snapshot: [Int: RDEPUBChapterSummary]? resultLock.lock() parseState.summariesBySpineIndex[spineIndex] = renderResult parseState.totalResolvedCount += 1 if parseState.totalResolvedCount - parseState.lastAppliedCount >= refreshInterval || parseState.totalResolvedCount == allBuildableIndices.count { parseState.lastAppliedCount = parseState.totalResolvedCount snapshot = parseState.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, !cancellationController.isCancelled else { return } context.runtime?.refreshBookPageMapInPlace(partialMap) } } } catch { guard !cancellationController.isCancelled, context.paginationToken == token, context.controller != nil, operation?.isCancelled != true else { return } timingLock.lock() failedChapters += 1 timingLock.unlock() RDEPUBBackgroundTrace.log("MetadataParse", "buildChapter FAILED: spine=\(spineIndex) error=\(error)") self.scheduleRetry( spineIndex: spineIndex, retryCount: 0, token: token, context: context, parser: parser, publication: publication, pageSize: pageSize, layoutConfig: layoutConfig, style: style, renderSignature: renderSignature, summaryDiskCache: summaryDiskCache, contentHashBySpineIndex: contentHashBySpineIndex, resultLock: resultLock, parseState: parseState, allBuildableIndices: allBuildableIndices, catalog: catalog, refreshInterval: refreshInterval, cancellationController: cancellationController ) } } queue.addOperation(operation) } queue.waitUntilAllOperationsAreFinished() if !cancellationController.isCancelled, context.paginationToken == token, context.controller != nil { 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, !cancellationController.isCancelled else { RDEPUBBackgroundTrace.log("MetadataParse", "abort: reader dismissed or token changed") return } let finalMergeStart = CFAbsoluteTimeGetCurrent() let pageMap = self.buildPageMap(from: catalog, summaries: parseState.summariesBySpineIndex) let finalMergeMs = Int((CFAbsoluteTimeGetCurrent() - finalMergeStart) * 1000) RDEPUBBackgroundTrace.log( "MetadataParse", "complete chapters=\(pageMap.totalChapters) pages=\(pageMap.totalPages) finalMergeMs=\(finalMergeMs)" ) if let coverageStore = context.runtime?.backgroundCoverageStore { let resolvedSpineIndices = Set(parseState.summariesBySpineIndex.keys) let lowerSpine = resolvedSpineIndices.min() ?? 0 let upperSpine = resolvedSpineIndices.max() ?? 0 let estimatedBytes = 256 + pageMap.entries.count * 96 + resolvedSpineIndices.count * 16 let segment = RDEPUBBackgroundCoverageSegment( lowerSpineIndex: lowerSpine, upperSpineIndex: upperSpine, pageMap: pageMap, resolvedSpineIndices: resolvedSpineIndices, generatedAt: CFAbsoluteTimeGetCurrent(), renderSignature: renderSignature, estimatedMemoryBytes: estimatedBytes ) coverageStore.addSegment(segment) } DispatchQueue.main.async { guard context.paginationToken == token, context.controller != nil else { return } context.runtime?.refreshBookPageMapInPlace(pageMap) } } } private func scheduleRetry( spineIndex: Int, retryCount: Int, token: UUID, context: RDEPUBReaderContext, parser: RDEPUBParser, publication: RDEPUBPublication, pageSize: CGSize, layoutConfig: RDEPUBTextLayoutConfig, style: RDEPUBTextRenderStyle, renderSignature: String, summaryDiskCache: RDEPUBChapterSummaryDiskCache?, contentHashBySpineIndex: [Int: String], resultLock: NSLock, parseState: MetadataParseState, allBuildableIndices: [Int], catalog: [(key: RDEPUBChapterCacheKey, spineIndex: Int, href: String, title: String)], refreshInterval: Int, cancellationController: MetadataParseCancellationController ) { guard retryCount < Self.maxRetryCount else { RDEPUBBackgroundTrace.log( "MetadataParse", "spine=\(spineIndex) max retries reached, marking as deferredFailure" ) return } let delay = Self.retryDelays[min(retryCount, Self.retryDelays.count - 1)] RDEPUBBackgroundTrace.log( "MetadataParse", "scheduling retry for spine=\(spineIndex) attempt=\(retryCount + 1) delay=\(delay)s" ) DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + delay) { [weak self] in guard let self else { return } guard context.controller != nil, context.paginationToken == token, !cancellationController.isCancelled else { return } do { let chapterBuilder = context.makeTextBookBuilder(layoutConfig: layoutConfig) guard let result = try chapterBuilder.buildChapter( parser: parser, publication: publication, spineIndex: spineIndex, pageSize: pageSize, style: style ) else { return } guard context.controller != nil, context.paginationToken == token, !cancellationController.isCancelled else { RDEPUBBackgroundTrace.log("MetadataParse", "drop retry result due to cancellation spine=\(spineIndex)") return } 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, cfiMap: chapter.cfiMap, renderSignature: cacheKey.renderSignature, schemaVersion: RDEPUBChapterSummary.currentSchemaVersion, chapterContentHash: cacheKey.chapterContentHash, pageMetadataList: chapter.pages.map { .from($0.metadata) } ) guard context.controller != nil, context.paginationToken == token, !cancellationController.isCancelled else { RDEPUBBackgroundTrace.log("MetadataParse", "skip retry disk write due to cancellation spine=\(spineIndex)") return } summaryDiskCache?.write(summary: summary, for: cacheKey) resultLock.lock() parseState.summariesBySpineIndex[spineIndex] = summary parseState.totalResolvedCount += 1 let shouldRefresh = parseState.totalResolvedCount - parseState.lastAppliedCount >= refreshInterval || parseState.totalResolvedCount == allBuildableIndices.count if shouldRefresh { parseState.lastAppliedCount = parseState.totalResolvedCount } resultLock.unlock() if shouldRefresh { let partialMap = self.buildPageMap(from: catalog, summaries: parseState.summariesBySpineIndex) DispatchQueue.main.async { guard context.paginationToken == token, context.controller != nil, !cancellationController.isCancelled else { return } context.runtime?.refreshBookPageMapInPlace(partialMap) } } RDEPUBBackgroundTrace.log( "MetadataParse", "retry succeeded for spine=\(spineIndex) attempt=\(retryCount + 1)" ) } catch { RDEPUBBackgroundTrace.log( "MetadataParse", "retry failed for spine=\(spineIndex) attempt=\(retryCount + 1) error=\(error)" ) self.scheduleRetry( spineIndex: spineIndex, retryCount: retryCount + 1, token: token, context: context, parser: parser, publication: publication, pageSize: pageSize, layoutConfig: layoutConfig, style: style, renderSignature: renderSignature, summaryDiskCache: summaryDiskCache, contentHashBySpineIndex: contentHashBySpineIndex, resultLock: resultLock, parseState: parseState, allBuildableIndices: allBuildableIndices, catalog: catalog, refreshInterval: refreshInterval, cancellationController: cancellationController ) } } } func cancelActiveMetadataParseWork() { metadataParseControlLock.lock() let controller = activeMetadataParseCancellationController activeMetadataParseCancellationController = nil metadataParseControlLock.unlock() controller?.cancel() } private func beginMetadataParseCancellationController(for token: UUID) -> MetadataParseCancellationController { let controller = MetadataParseCancellationController(token: token) metadataParseControlLock.lock() let previous = activeMetadataParseCancellationController activeMetadataParseCancellationController = controller metadataParseControlLock.unlock() previous?.cancel() return controller } private func finishMetadataParseCancellationController(_ controller: MetadataParseCancellationController) { metadataParseControlLock.lock() if activeMetadataParseCancellationController === controller { activeMetadataParseCancellationController = nil } metadataParseControlLock.unlock() } 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() } }