import Foundation final class RDEPUBChapterLoader { private typealias LoadCompletion = (Result) -> Void private struct PendingLoad { var priority: LoadPriority var completions: [LoadCompletion] } private weak var context: RDEPUBReaderContext? private var summaryDiskCache: RDEPUBChapterSummaryDiskCache? private let pendingLoadsLock = NSLock() private var pendingLoads: [Int: PendingLoad] = [:] var onDeferredCFIMapReady: ((Int) -> Void)? init(context: RDEPUBReaderContext) { self.context = context } func setSummaryDiskCache(_ cache: RDEPUBChapterSummaryDiskCache) { summaryDiskCache = cache } enum LoadPriority { case navigation case preview case prefetch } private enum LoadRegistrationResult { case created case joined(existingPriority: LoadPriority, effectivePriority: LoadPriority) } func loadChapter( spineIndex: Int, store: RDEPUBChapterRuntimeStore, priority: LoadPriority = .navigation, completion: @escaping (Result) -> Void ) { let layoutSnapshot = context?.makeLayoutSnapshot() loadChapterWithSnapshot( spineIndex: spineIndex, store: store, priority: priority, layoutSnapshot: layoutSnapshot, completion: completion ) } private func loadChapterWithSnapshot( spineIndex: Int, store: RDEPUBChapterRuntimeStore, priority: LoadPriority, layoutSnapshot: RDEPUBLayoutSnapshot?, completion: @escaping (Result) -> Void ) { if let cached = store.chapterData(for: spineIndex) { RDEPUBBackgroundTrace.log( "ChapterLoad", "memory HIT spine=\(spineIndex) pages=\(cached.pages.count) priority=\(priority)" ) if let context { scheduleDeferredCFIMapBuildIfNeeded( for: cached, cacheKey: makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: layoutSnapshot), store: store ) } DispatchQueue.main.async { completion(.success(cached)) } return } let completionOnMain: LoadCompletion = { result in DispatchQueue.main.async { completion(result) } } let registration = registerPendingLoad( spineIndex: spineIndex, priority: priority, completion: completionOnMain ) switch registration { case .joined(let existingPriority, let effectivePriority): RDEPUBBackgroundTrace.log( "ChapterLoad", "join pendingLoad spine=\(spineIndex) existing=\(existingPriority) effective=\(effectivePriority)" ) return case .created: break } store.markBuilding(true) _ = store.beginPendingChapterLoad(for: spineIndex) store.chapterLoadQueue.async { [weak self] in guard let self, let context = self.context else { store.endPendingChapterLoad(for: spineIndex) store.markBuilding(false) self?.resolvePendingLoad(spineIndex: spineIndex, result: .failure(RDEPUBChapterLoadError.missingParser)) return } let queuePriority = self.pendingPriority(for: spineIndex) ?? priority let cacheKey = self.makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: layoutSnapshot) let precomputedPageRanges = store.pageCount(for: cacheKey)?.pageRanges let diskSummary: RDEPUBChapterSummary? if precomputedPageRanges == nil { diskSummary = self.summaryDiskCache?.read(for: cacheKey) } else { diskSummary = nil } let diskPageRanges = diskSummary?.pageRanges.map { $0.nsRange } let availablePageRanges = precomputedPageRanges ?? diskPageRanges let pageRangeSource: String if precomputedPageRanges != nil { pageRangeSource = "HIT(memoryPageCount)" } else if diskPageRanges != nil { pageRangeSource = "HIT(diskSummary)" } else { pageRangeSource = "MISS(fullRender)" } RDEPUBBackgroundTrace.log( "ChapterLoad", "build start spine=\(spineIndex) priority=\(queuePriority) pageRanges=\(pageRangeSource)" ) let buildStart = CFAbsoluteTimeGetCurrent() do { let chapter = try self.buildChapter( spineIndex: spineIndex, availablePageRanges: availablePageRanges, diskSummary: diskSummary, context: context, layoutSnapshot: layoutSnapshot ) RDEPUBBackgroundTrace.log( "ChapterLoad", "build done spine=\(spineIndex) pages=\(chapter.pages.count) elapsedMs=\(Int((CFAbsoluteTimeGetCurrent() - buildStart) * 1000)) pageRanges=\(pageRangeSource)" ) store.insertChapter(chapter) let pc = RDEPUBRuntimePageCount( cacheKey: cacheKey, spineIndex: spineIndex, pageRanges: chapter.pageRanges, pageCount: chapter.pages.count, renderSignature: cacheKey.renderSignature ) store.insertPageCount(pc, for: cacheKey) self.scheduleDeferredCFIMapBuildIfNeeded( for: chapter, cacheKey: cacheKey, store: store ) let effectivePriority = self.pendingPriority(for: spineIndex) ?? queuePriority store.endPendingChapterLoad(for: spineIndex) switch effectivePriority { case .navigation: let nextTarget = store.consumeNavigationTarget() if let target = nextTarget, target != spineIndex { store.markBuilding(false) self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter)) self.loadChapterWithSnapshot(spineIndex: target, store: store, priority: .navigation, layoutSnapshot: layoutSnapshot, completion: { _ in }) return } store.markBuilding(false) self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter)) case .preview: store.markBuilding(false) self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter)) case .prefetch: store.removePrefetchTarget(spineIndex) store.markBuilding(false) self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter)) } } catch { RDEPUBBackgroundTrace.log( "ChapterLoad", "build failed spine=\(spineIndex) pageRanges=\(pageRangeSource) error=\(String(describing: error))" ) store.endPendingChapterLoad(for: spineIndex) store.markBuilding(false) self.resolvePendingLoad(spineIndex: spineIndex, result: .failure(error)) } } } func loadChapterSynchronouslyForMigration( spineIndex: Int, store: RDEPUBChapterRuntimeStore?, layoutSnapshot: RDEPUBLayoutSnapshot? = nil ) throws -> RDEPUBRuntimeChapter { guard let context else { throw RDEPUBChapterLoadError.missingParser } if let cached = store?.chapterData(for: spineIndex) { if let store { scheduleDeferredCFIMapBuildIfNeeded( for: cached, cacheKey: makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: layoutSnapshot), store: store ) } return cached } guard let store else { throw RDEPUBChapterLoadError.missingParser } if store.hasPendingChapterLoad(for: spineIndex) { var result: Result? let semaphore = DispatchSemaphore(value: 0) let registration = registerPendingLoad( spineIndex: spineIndex, priority: .navigation ) { pendingResult in result = pendingResult semaphore.signal() } if case .joined(let existingPriority, let effectivePriority) = registration { semaphore.wait() return try result!.get() } } store.assertNotOnChapterLoadQueue() let snapshot = layoutSnapshot ?? context.makeLayoutSnapshot() var result: Result? let semaphore = DispatchSemaphore(value: 0) store.chapterLoadQueue.async { do { let chapter: RDEPUBRuntimeChapter = try autoreleasepool { let cacheKey = self.makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: snapshot) let precomputedPageRanges = store.pageCount(for: cacheKey)?.pageRanges let diskSummary: RDEPUBChapterSummary? if precomputedPageRanges == nil { diskSummary = self.summaryDiskCache?.read(for: cacheKey) } else { diskSummary = nil } let chapter = try self.buildChapter( spineIndex: spineIndex, availablePageRanges: precomputedPageRanges ?? diskSummary?.pageRanges.map(\.nsRange), diskSummary: diskSummary, context: context, layoutSnapshot: snapshot ) store.insertChapter(chapter) let pageCount = RDEPUBRuntimePageCount( cacheKey: cacheKey, spineIndex: spineIndex, pageRanges: chapter.pageRanges, pageCount: chapter.pages.count, renderSignature: cacheKey.renderSignature ) store.insertPageCount(pageCount, for: cacheKey) self.scheduleDeferredCFIMapBuildIfNeeded( for: chapter, cacheKey: cacheKey, store: store ) return chapter } result = .success(chapter) } catch { result = .failure(error) } semaphore.signal() } semaphore.wait() return try result!.get() } private func registerPendingLoad( spineIndex: Int, priority: LoadPriority, completion: @escaping LoadCompletion ) -> LoadRegistrationResult { pendingLoadsLock.lock() defer { pendingLoadsLock.unlock() } if var pending = pendingLoads[spineIndex] { let existingPriority = pending.priority pending.priority = LoadPriority.higherPriority(existingPriority, priority) pending.completions.append(completion) pendingLoads[spineIndex] = pending return .joined(existingPriority: existingPriority, effectivePriority: pending.priority) } pendingLoads[spineIndex] = PendingLoad(priority: priority, completions: [completion]) return .created } private func pendingPriority(for spineIndex: Int) -> LoadPriority? { pendingLoadsLock.lock() let priority = pendingLoads[spineIndex]?.priority pendingLoadsLock.unlock() return priority } private func resolvePendingLoad(spineIndex: Int, result: Result) { pendingLoadsLock.lock() let completions = pendingLoads.removeValue(forKey: spineIndex)?.completions ?? [] pendingLoadsLock.unlock() guard !completions.isEmpty else { return } completions.forEach { $0(result) } } private func buildChapter( spineIndex: Int, availablePageRanges: [NSRange]?, diskSummary: RDEPUBChapterSummary? = nil, context: RDEPUBReaderContext, layoutSnapshot: RDEPUBLayoutSnapshot? = nil ) throws -> RDEPUBRuntimeChapter { guard let parser = context.parser, let publication = context.publication else { throw RDEPUBChapterLoadError.missingParser } let pageSize: CGSize let style: RDEPUBTextRenderStyle let layoutConfig: RDEPUBTextLayoutConfig if let snapshot = layoutSnapshot { pageSize = snapshot.pageSize style = snapshot.style layoutConfig = snapshot.layoutConfig } else { assert(Thread.isMainThread, "buildChapter() requires a layoutSnapshot when called off the main thread. Capture a snapshot via makeLayoutSnapshot() before dispatching to a background queue.") pageSize = context.currentTextPageSize() style = context.currentTextRenderStyle() layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize) } if let pageRanges = availablePageRanges { return try buildChapterFromCachedPageRanges( spineIndex: spineIndex, pageRanges: pageRanges, parser: parser, publication: publication, pageSize: pageSize, style: style, layoutConfig: layoutConfig, diskSummary: diskSummary, context: context ) } let builder = context.makeTextBookBuilder(layoutConfig: layoutConfig) guard let result = try builder.buildChapter( parser: parser, publication: publication, spineIndex: spineIndex, pageSize: pageSize, style: style ) else { throw RDEPUBChapterLoadError.emptyChapter(spineIndex: spineIndex) } return try assembleRuntimeChapter( from: result.chapter, spineIndex: spineIndex, pageSize: pageSize, layoutConfig: layoutConfig, context: context, layoutSnapshot: layoutSnapshot ) } private func buildChapterFromCachedPageRanges( spineIndex: Int, pageRanges: [NSRange], parser: RDEPUBParser, publication: RDEPUBPublication, pageSize: CGSize, style: RDEPUBTextRenderStyle, layoutConfig: RDEPUBTextLayoutConfig, diskSummary: RDEPUBChapterSummary? = nil, context: RDEPUBReaderContext ) throws -> RDEPUBRuntimeChapter { let spineItem = publication.spine[spineIndex] let href = spineItem.href let title = spineItem.title let baseURL = parser.fileURL(forRelativePath: href)?.deletingLastPathComponent() let rawHTML = try requireHTMLString(parser, href: href) let request = RDEPUBTextRendererSupport.makeChapterRenderRequest( href: href, title: title, rawHTML: rawHTML, baseURL: baseURL, style: style, resourceResolver: publication.resourceResolver, pageSize: pageSize, layoutConfig: layoutConfig ) let renderer = context.resolvedTextRenderer() let rendered = try renderer.renderChapter(request: request) let typesetString = NSMutableAttributedString(attributedString: rendered.attributedString) RDEPUBTextRendererSupport.normalizeReadingAttributes( in: typesetString, style: style, layoutConfig: layoutConfig ) let sanitizedCachedRanges = sanitizedPageRanges(pageRanges, contentLength: typesetString.length) let effectivePageRanges: [NSRange] let metadataSource: [RDEPUBChapterSummary.PageMetadataSummary]? if sanitizedCachedRanges.count == pageRanges.count { effectivePageRanges = sanitizedCachedRanges metadataSource = diskSummary?.pageMetadataList } else { effectivePageRanges = typesetString.rd_paginatedFrames(size: pageSize, config: layoutConfig).map(\.contentRange) metadataSource = nil } let pages = buildPagesFromRanges( pageRanges: effectivePageRanges, typesetString: typesetString, spineIndex: spineIndex, href: href, title: title, metadataSource: metadataSource ) let layouter = RDEPUBTextLayouter( attributedString: typesetString, pageSize: pageSize, config: layoutConfig ) let offsetMap = RDEPUBChapterOffsetMap( fragmentOffsets: rendered.fragmentOffsets, pageStartOffsets: pages.map { $0.pageStartOffset }, pageEndOffsets: pages.map { $0.pageEndOffset }, cfiMap: diskSummary?.cfiMap, chapterText: typesetString.string ) return RDEPUBRuntimeChapter( spineIndex: spineIndex, href: href, title: title, sourceAttributedString: nil, typesetAttributedString: typesetString, layouter: layouter, pageRanges: effectivePageRanges, pages: pages, chapterOffsetMap: offsetMap ) } private func buildPagesFromRanges( pageRanges: [NSRange], typesetString: NSAttributedString, spineIndex: Int, href: String, title: String, metadataSource: [RDEPUBChapterSummary.PageMetadataSummary]? = nil ) -> [RDEPUBTextPage] { let totalPageCount = pageRanges.count return pageRanges.enumerated().map { (pageIndex, range) in let metadata: RDEPUBTextPageMetadata if let metaList = metadataSource, pageIndex < metaList.count { metadata = metaList[pageIndex].toPageMetadata() } else { metadata = inferPageMetadata( from: typesetString, range: range, isLastPage: pageIndex == totalPageCount - 1 ) } return RDEPUBTextPage( absolutePageIndex: -1, chapterIndex: 0, spineIndex: spineIndex, href: href, chapterTitle: title, pageIndexInChapter: pageIndex, totalPagesInChapter: totalPageCount, chapterContent: typesetString, contentRange: range, pageStartOffset: range.location, pageEndOffset: range.location + range.length - 1, metadata: metadata ) } } private func sanitizedPageRanges(_ pageRanges: [NSRange], contentLength: Int) -> [NSRange] { guard contentLength > 0 else { return [] } return pageRanges.compactMap { range in guard range.location >= 0, range.location < contentLength else { return nil } let maxLength = contentLength - range.location let clampedLength = min(max(range.length, 0), maxLength) guard clampedLength > 0 else { return nil } return NSRange(location: range.location, length: clampedLength) } } private func inferPageMetadata( from string: NSAttributedString, range: NSRange, isLastPage: Bool ) -> RDEPUBTextPageMetadata { var attachmentRanges: [NSRange] = [] var attachmentKinds: [RDEPUBTextAttachmentKind] = [] var blockKinds: [RDEPUBTextBlockKind] = [] var semanticHints: [RDEPUBTextSemanticHint] = [] var attachmentPlacements: [RDEPUBTextAttachmentPlacement] = [] var trailingFragmentID: String? = nil string.enumerateAttribute(.rdPageAttachmentKind, in: range, options: []) { value, attrRange, _ in if let rawValue = value as? String, let kind = RDEPUBTextAttachmentKind(rawValue: rawValue) { attachmentRanges.append(attrRange) attachmentKinds.append(kind) } } string.enumerateAttribute(.rdPageBlockKind, in: range, options: []) { value, _, _ in if let rawValue = value as? String, let kind = RDEPUBTextBlockKind(rawValue: rawValue), !blockKinds.contains(kind) { blockKinds.append(kind) } } string.enumerateAttribute(.rdPageSemanticHints, in: range, options: []) { value, _, _ in if let rawValue = value as? String { let hints = rawValue .split(separator: ",") .compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) } for hint in hints where !semanticHints.contains(hint) { semanticHints.append(hint) } } } string.enumerateAttribute(.rdPageAttachmentPlacement, in: range, options: []) { value, _, _ in if let rawValue = value as? String, let placement = RDEPUBTextAttachmentPlacement(rawValue: rawValue), !attachmentPlacements.contains(placement) { attachmentPlacements.append(placement) } } string.enumerateAttribute(.rdPageFragmentID, in: range, options: [.reverse]) { value, _, stop in if let fid = value as? String { trailingFragmentID = fid stop.pointee = true } } return RDEPUBTextPageMetadata( breakReason: isLastPage ? .chapterEnd : .frameLimit, blockRange: nil, attachmentRanges: attachmentRanges, attachmentKinds: attachmentKinds, blockKinds: blockKinds, semanticHints: semanticHints, attachmentPlacements: attachmentPlacements, trailingFragmentID: trailingFragmentID, diagnostics: [] ) } private func assembleRuntimeChapter( from chapter: RDEPUBTextChapter, spineIndex: Int, pageSize: CGSize, layoutConfig: RDEPUBTextLayoutConfig, context: RDEPUBReaderContext, layoutSnapshot: RDEPUBLayoutSnapshot? = nil ) throws -> RDEPUBRuntimeChapter { let layouter = RDEPUBTextLayouter( attributedString: chapter.attributedContent, pageSize: pageSize, config: layoutConfig ) let offsetMap = RDEPUBChapterOffsetMap( fragmentOffsets: chapter.fragmentOffsets, pageStartOffsets: chapter.pages.map { $0.pageStartOffset }, pageEndOffsets: chapter.pages.map { $0.pageEndOffset }, cfiMap: chapter.cfiMap, chapterText: chapter.attributedContent.string ) let pageRanges = chapter.pages.map { $0.contentRange } let cacheKey = makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: layoutSnapshot) summaryDiskCache?.write(summary: makeSummary(for: chapter.pages, fragmentOffsets: chapter.fragmentOffsets, offsetMap: offsetMap, cacheKey: cacheKey), for: cacheKey) return RDEPUBRuntimeChapter( spineIndex: spineIndex, href: chapter.href, title: chapter.title, sourceAttributedString: nil, typesetAttributedString: chapter.attributedContent, layouter: layouter, pageRanges: pageRanges, pages: chapter.pages, chapterOffsetMap: offsetMap ) } private func makeCacheKey(spineIndex: Int, context: RDEPUBReaderContext, layoutSnapshot: RDEPUBLayoutSnapshot? = nil) -> RDEPUBChapterCacheKey { let style: RDEPUBTextRenderStyle let layoutConfig: RDEPUBTextLayoutConfig let lineHeightMultiple: CGFloat if let snapshot = layoutSnapshot { style = snapshot.style layoutConfig = snapshot.layoutConfig lineHeightMultiple = context.configuration.lineHeightMultiple } else { assert(Thread.isMainThread, "makeCacheKey() requires a layoutSnapshot when called off the main thread. Capture a snapshot via makeLayoutSnapshot() before dispatching to a background queue.") let pageSize = context.currentTextPageSize() style = context.currentTextRenderStyle() layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize) lineHeightMultiple = context.configuration.lineHeightMultiple } let renderSignature = [ style.font.fontName, "\(style.font.pointSize)", "\(lineHeightMultiple)", "\(style.lineSpacing)", layoutConfig.cacheSignature, "\(RDEPUBChapterSummary.currentSchemaVersion)" ].joined(separator: "|") let contentHash = contentHashForSpineIndex(spineIndex, context: context) return RDEPUBChapterCacheKey( bookID: context.currentBookIdentifier ?? "", spineIndex: spineIndex, renderSignature: renderSignature, chapterContentHash: contentHash ) } private func scheduleDeferredCFIMapBuildIfNeeded( for chapter: RDEPUBRuntimeChapter, cacheKey: RDEPUBChapterCacheKey, store: RDEPUBChapterRuntimeStore ) { guard chapter.chapterOffsetMap.cfiMap == nil, store.beginBuildingCFIMap(for: chapter.spineIndex) else { return } let spineIndex = chapter.spineIndex let href = chapter.href let fragmentOffsets = chapter.chapterOffsetMap.fragmentOffsets let chapterText = chapter.chapterOffsetMap.chapterText store.chapterLoadQueue.async { defer { store.endBuildingCFIMap(for: spineIndex) } guard let rawHTML = self.context?.parser?.htmlString(forRelativePath: href), let chapterText else { return } let cfiMap = self.makeCFIMap( href: href, spineIndex: spineIndex, fragmentOffsets: fragmentOffsets, rawHTML: rawHTML, chapterText: chapterText ) chapter.updateCFIMap(cfiMap) self.summaryDiskCache?.write( summary: self.makeSummary( for: chapter.pages, fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets, offsetMap: chapter.chapterOffsetMap, cacheKey: cacheKey ), for: cacheKey ) DispatchQueue.main.async { self.onDeferredCFIMapReady?(spineIndex) } } } private func makeSummary( for pages: [RDEPUBTextPage], fragmentOffsets: [String: Int], offsetMap: RDEPUBChapterOffsetMap, cacheKey: RDEPUBChapterCacheKey ) -> RDEPUBChapterSummary { RDEPUBChapterSummary( pageRanges: pages.map { .init(location: $0.contentRange.location, length: $0.contentRange.length) }, pageCount: pages.count, fragmentOffsets: fragmentOffsets, cfiMap: offsetMap.cfiMap, renderSignature: cacheKey.renderSignature, schemaVersion: RDEPUBChapterSummary.currentSchemaVersion, chapterContentHash: cacheKey.chapterContentHash, pageMetadataList: pages.map { .from($0.metadata) } ) } private func contentHashForSpineIndex(_ spineIndex: Int, context: RDEPUBReaderContext) -> String { guard let parser = context.parser, let publication = context.publication else { return "" } let href = publication.spine[spineIndex].href guard let html = parser.htmlString(forRelativePath: href) else { return "" } return html.rd_sha256Hex } private func requireHTMLString(_ parser: RDEPUBParser, href: String) throws -> String { guard let html = parser.htmlString(forRelativePath: href) else { throw RDEPUBChapterLoadError.emptyChapterHref(href) } return html } private func makeCFIMap( href: String, spineIndex: Int, fragmentOffsets: [String: Int], rawHTML: String?, chapterText: String ) -> RDEPUBCFIMap { if let rawHTML { return RDEPUBCFITextNodeMapBuilder.makeMap( href: href, rawHTML: rawHTML, chapterText: chapterText, fragmentOffsets: fragmentOffsets ) } let domPaths: [String: RDEPUBCFIPath] = [:] let markers = fragmentOffsets .sorted { $0.value < $1.value } .map { fragmentID, offset in let cfi = RDEPUBCFIGenerator.makeOffsetCFI( href: href, fileIndex: spineIndex, chapterOffset: offset, fragmentID: fragmentID ) return RDEPUBCFIMarker( cfiPath: domPaths[fragmentID] ?? cfi.contentPath, chapterOffset: offset, fragmentID: fragmentID ) } return RDEPUBCFIMap( href: href, markers: markers, recoveryMetadata: RDEPUBCFIRecoveryMetadata( domFingerprint: "", normalizedTextChecksum: RDEPUBCFITextNodeMapBuilder.normalizedText(from: chapterText).rd_sha256Hex, fragmentPathMap: domPaths ) ) } } private extension RDEPUBChapterLoader.LoadPriority { static func higherPriority(_ lhs: Self, _ rhs: Self) -> Self { if lhs.rank >= rhs.rank { return lhs } return rhs } var rank: Int { switch self { case .prefetch: return 0 case .preview: return 1 case .navigation: return 2 } } } enum RDEPUBChapterLoadError: LocalizedError { case missingParser case emptyChapter(spineIndex: Int) case emptyChapterHref(String) var errorDescription: String? { switch self { case .missingParser: return "章节加载失败:缺少解析上下文。" case .emptyChapter(let spineIndex): return "章节加载失败:第 \(spineIndex) 章无法生成分页内容。" case .emptyChapterHref(let href): return "章节加载失败:未找到章节资源 \(href)。" } } }